@mrrisega/dsh-remote 0.6.7-beta.2 → 0.6.7-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.7-beta.2",
3
+ "version": "0.6.7-beta.4",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,307 @@
1
+ /**
2
+ * 「切换账号后设备没登记到新账号」回归(0.6.7-beta.4 新增)。
3
+ *
4
+ * 现场(2026-09-19 用户实测,本机复现):
5
+ * 23:55 装好插件 → 00:03 登录账号 A → bridge 用 A 的凭据登记(dev-xxxx 进 A 的设备列表)
6
+ * → 00:29 退出 A、登录账号 B(配置已改、device_id 已清)
7
+ * → **B 的设备列表里永远看不到这台 Mac**,而 A 那边它还在线。
8
+ *
9
+ * 三个缺陷叠加(本文件逐个锁死):
10
+ * ① bridge 的账号/密码是**进程启动时**从环境变量固化的;`dsh-setup run` 每 10s 重读配置,
11
+ * 但只在子进程**已退出**时才重新拉起它 → 换了账号,进程不换凭据。
12
+ * ② 切换账号时那次"重启 bridge"其实失败了:插件只认 `gui/<uid>` 域,而安装器按 macOS 26 的
13
+ * 修复把作业装在 `user/<uid>` 域 → `Could not find service ... in user gui: 501`,
14
+ * 而面板**不看 bridgeRestart 的返回值** → 静默失败。
15
+ * ③ 旧账号留下的 `.dsh-bridge-state.json`(phase=online + 旧 device_id)没被清理,
16
+ * 面板据此谎报「已连接」,自愈(只判"有没有进程在跑")也就永远不会去纠正。
17
+ *
18
+ * ⚠️ 隔离:unset 隔离开关的用例必须同时伪造 HOME 与 PATH(否则会碰真实 HOME 下的 launchd)。
19
+ */
20
+ import assert from "node:assert/strict";
21
+ import http from "node:http";
22
+ import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import test from "node:test";
27
+ import { fileURLToPath } from "node:url";
28
+ import { apply } from "../lib/index.js";
29
+
30
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
31
+ const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8");
32
+ const CLIENT_SRC = readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8");
33
+ const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
34
+
35
+ /** 假 launchctl:user 域报告 running、gui 域报告不存在(复刻 macOS 26 + 安装器实际形态)。 */
36
+ const FAKE_LAUNCHCTL = `#!/bin/sh
37
+ printf '%s\\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
38
+ if [ "$1" = print ]; then
39
+ case "$2" in
40
+ user/*) printf 'state = running\\npid = 4242\\nruns = 3\\n'; exit 0 ;;
41
+ gui/*) echo "Could not find service in domain for user gui" >&2; exit 1 ;;
42
+ esac
43
+ fi
44
+ exit 0
45
+ `;
46
+
47
+ async function makeEnv({ fakeLaunchd = true } = {}) {
48
+ const root = await mkdtemp(path.join(os.tmpdir(), "dsh-acct-dev-"));
49
+ const fakeHome = path.join(root, "home");
50
+ const fakeBin = path.join(root, "bin");
51
+ const relayDir = path.join(root, "relay");
52
+ await mkdir(fakeHome, { recursive: true });
53
+ await mkdir(fakeBin, { recursive: true });
54
+ await mkdir(relayDir, { recursive: true });
55
+ if (fakeLaunchd) {
56
+ await writeFile(path.join(fakeBin, "launchctl"), FAKE_LAUNCHCTL);
57
+ await chmod(path.join(fakeBin, "launchctl"), 0o755);
58
+ }
59
+ const saved = {};
60
+ for (const k of ["HOME", "PATH", "DSH_TEST_LAUNCH_LOG", "DSH_RELAY_SKIP_SERVICE", "DSH_REMOTE_TELEMETRY"]) {
61
+ saved[k] = process.env[k];
62
+ }
63
+ process.env.HOME = fakeHome;
64
+ if (fakeLaunchd) process.env.PATH = `${fakeBin}:${saved.PATH}`;
65
+ process.env.DSH_TEST_LAUNCH_LOG = path.join(root, "launchctl.log");
66
+ process.env.DSH_REMOTE_TELEMETRY = "0";
67
+ process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 默认隔离:用例按需 unset
68
+ return {
69
+ root, relayDir, fakeHome, launchLog: path.join(root, "launchctl.log"),
70
+ async restore() {
71
+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
72
+ await rm(root, { recursive: true, force: true });
73
+ },
74
+ };
75
+ }
76
+
77
+ function boot(relayDir) {
78
+ const routes = new Map();
79
+ apply({
80
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
81
+ effect(register) { return register(); },
82
+ logger: { info() {}, warn() {} },
83
+ }, { relayDir });
84
+ return routes;
85
+ }
86
+
87
+ async function serve(routes) {
88
+ const host = http.createServer((req, res) => {
89
+ const handler = routes.get(new URL(req.url, "http://x").pathname);
90
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
91
+ });
92
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
93
+ return { host, base: `http://127.0.0.1:${host.address().port}` };
94
+ }
95
+
96
+ const json = async (p, init) => {
97
+ const res = await fetch(p, init);
98
+ return { status: res.status, body: await res.json().catch(() => null) };
99
+ };
100
+
101
+ // ─────────── ① 旧账号的 bridge 不得被当成「已连接」 ───────────
102
+
103
+ test("切换账号后:旧账号留下的 online 状态不得再当作注册证据", async () => {
104
+ const env = await makeEnv();
105
+ try {
106
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
107
+ // 切账号后的配置:新账号、device_id 已被清空
108
+ await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
109
+ phone: "account-B", password: "pw", api_url: "http://127.0.0.1:1",
110
+ }));
111
+ // 旧账号 bridge 留下的状态:device_id 是旧的、还写着 online
112
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({
113
+ device_id: "dev-old", phase: "online", tunnel_registered_at: 1789747521560, account_bound_at: 1789747429422,
114
+ }));
115
+ const routes = boot(env.relayDir);
116
+ const { host, base } = await serve(routes);
117
+ const stateFile = path.join(env.relayDir, ".dsh-bridge-state.json");
118
+ try {
119
+ // ① /status 是纯读接口:必须如实报告"跑着的是上一个账号的 bridge"
120
+ const { body: st } = await json(`${base}/dsh-remote/status`);
121
+ assert.equal(st.connect.accountCurrent, false, "必须识别出「跑着的是上一个账号的 bridge」");
122
+ assert.equal(st.connect.registered, false, "★旧账号的注册证据必须作废(否则面板谎报已连接)");
123
+ assert.notEqual(st.connect.phase, "online", "★不得报 online");
124
+ assert.match(String(st.connect.detail), /上一个账号|重启/, "应说明正在用新账号重新登记");
125
+ assert.match(String(st.connect.diagnostics), /bridge 账号: ⚠️/, "诊断信息里要能一眼看出");
126
+ assert.equal(existsSync(stateFile), true, "纯读接口不得改动状态文件");
127
+
128
+ // ② /bridge-status 会自愈:清掉过期状态(下一轮用新账号重新登记)
129
+ const { body: bs } = await json(`${base}/dsh-remote/bridge-status`);
130
+ assert.equal(existsSync(stateFile), false, "★过期状态必须被自愈清掉(否则面板一直谎报,永不纠正)");
131
+ assert.equal(bs.connect.registered, false, "清掉之后也不得凭空报已连接");
132
+ } finally {
133
+ host.close();
134
+ }
135
+ } finally {
136
+ await env.restore();
137
+ }
138
+ });
139
+
140
+ test("账号一致时行为不变:同一 device_id 的 online 状态仍算已连接(回归)", async () => {
141
+ const env = await makeEnv();
142
+ try {
143
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
144
+ await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
145
+ phone: "account-A", password: "pw", device_id: "dev-1", api_url: "http://127.0.0.1:1",
146
+ }));
147
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({
148
+ device_id: "dev-1", phase: "online", tunnel_registered_at: 1789747521560,
149
+ }));
150
+ const routes = boot(env.relayDir);
151
+ const { host, base } = await serve(routes);
152
+ try {
153
+ const { body } = await json(`${base}/dsh-remote/bridge-status`);
154
+ assert.equal(body.connect.accountCurrent, true);
155
+ assert.equal(body.connect.registered, true, "同一账号的注册证据仍然有效");
156
+ assert.match(String(body.connect.diagnostics), /bridge 账号: 当前账号/);
157
+ } finally {
158
+ host.close();
159
+ }
160
+ } finally {
161
+ await env.restore();
162
+ }
163
+ });
164
+
165
+ // ─────────── ② 切换账号要真的把旧 bridge 停掉 + 清掉过期状态 ───────────
166
+
167
+ test("切换账号:停掉旧 bridge 并清除过期状态文件(否则新账号永远等不到设备)", async () => {
168
+ const env = await makeEnv();
169
+ try {
170
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
171
+ await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
172
+ phone: "account-A", password: "pw", device_id: "dev-old", api_url: "http://127.0.0.1:1",
173
+ }));
174
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({
175
+ device_id: "dev-old", phase: "online", tunnel_registered_at: 1789747521560,
176
+ }));
177
+ const routes = boot(env.relayDir);
178
+ const { host, base } = await serve(routes);
179
+ try {
180
+ const { body } = await json(`${base}/dsh-remote/config`, {
181
+ method: "POST", headers: { "content-type": "application/json" },
182
+ body: JSON.stringify({ phone: "account-B", password: "pw2" }),
183
+ });
184
+ assert.equal(body.ok, true);
185
+ assert.ok(body.bridgeRestart, "响应必须带上重启结果(前端据此提示,不再静默)");
186
+ const cfg = JSON.parse(await readFile(path.join(env.relayDir, ".dsh-config.json"), "utf8"));
187
+ assert.equal(cfg.phone, "account-B");
188
+ assert.equal(cfg.device_id, undefined, "设备身份必须轮换");
189
+ assert.equal(existsSync(path.join(env.relayDir, ".dsh-bridge-state.json")), false,
190
+ "★旧账号的注册证据必须删除(否则面板一直谎报已连接,自愈永不纠正)");
191
+ } finally {
192
+ host.close();
193
+ }
194
+ } finally {
195
+ await env.restore();
196
+ }
197
+ });
198
+
199
+ test("退出登录:必须停 bridge 并作废设备身份(否则旧账号的隧道还在对外服务)", async () => {
200
+ const env = await makeEnv();
201
+ try {
202
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
203
+ await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
204
+ phone: "account-A", password: "pw", device_id: "dev-1",
205
+ device_private_key: "pk", device_public_key: "pub", api_url: "http://127.0.0.1:1",
206
+ }));
207
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({
208
+ device_id: "dev-1", phase: "online", tunnel_registered_at: 1,
209
+ }));
210
+ const routes = boot(env.relayDir);
211
+ const { host, base } = await serve(routes);
212
+ try {
213
+ const { body } = await json(`${base}/dsh-remote/logout`, { method: "POST" });
214
+ assert.equal(body.ok, true);
215
+ const cfg = JSON.parse(await readFile(path.join(env.relayDir, ".dsh-config.json"), "utf8"));
216
+ assert.equal(cfg.phone, undefined, "账号应被清除");
217
+ assert.equal(cfg.password, undefined);
218
+ assert.equal(cfg.device_id, undefined, "★设备身份必须一起作废");
219
+ assert.equal(existsSync(path.join(env.relayDir, ".dsh-bridge-state.json")), false, "★状态记录应清除");
220
+ } finally {
221
+ host.close();
222
+ }
223
+ } finally {
224
+ await env.restore();
225
+ }
226
+ });
227
+
228
+ // ─────────── ③ launchd 域名阶梯(事故的直接技术原因) ───────────
229
+
230
+ test("launchd:作业在 user/<uid> 域时也必须被认成「运行中」(旧实现只看 gui/<uid>)", { skip: process.platform !== "darwin" }, async () => {
231
+ const env = await makeEnv();
232
+ delete process.env.DSH_RELAY_SKIP_SERVICE; // 本用例要真的去调(假)launchctl
233
+ try {
234
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
235
+ await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
236
+ phone: "account-A", password: "pw", api_url: "http://127.0.0.1:1",
237
+ }));
238
+ const routes = boot(env.relayDir);
239
+ const { host, base } = await serve(routes);
240
+ try {
241
+ const { body } = await json(`${base}/dsh-remote/status`);
242
+ assert.equal(body.service.launchd.running, true,
243
+ "★安装器把作业装在 user/<uid>:插件必须查到这个域,否则会误判「未运行」并反复重复启动");
244
+ assert.equal(body.service.launchd.pid, 4242);
245
+ const log = existsSync(env.launchLog) ? readFileSync(env.launchLog, "utf8") : "";
246
+ assert.match(log, new RegExp(`print user/${process.getuid()}/com\\.dshremote\\.bridge`), "应先查 user 域");
247
+ } finally {
248
+ host.close();
249
+ }
250
+ } finally {
251
+ await env.restore();
252
+ }
253
+ });
254
+
255
+ test("源码契约:启动/停止都要覆盖两个 launchd 域(只认 gui 就是本次事故)", () => {
256
+ assert.ok(/function launchDomains\(\)/.test(INDEX_SRC), "应有统一的域阶梯");
257
+ const startFn = INDEX_SRC.slice(INDEX_SRC.indexOf("function startBridge(relayDir)"), INDEX_SRC.indexOf("function scheduleRuntime("));
258
+ assert.ok(/for \(const domain of launchDomains\(\)\)/.test(startFn), "启动必须遍历域阶梯(user 优先)");
259
+ assert.ok(/com\.dshremote\.bridge`\)\}/.test(startFn) || /launchDomains\(\)/.test(startFn));
260
+ const stopFn = INDEX_SRC.slice(INDEX_SRC.indexOf("/** 停止 bridge"), INDEX_SRC.indexOf("function uninstallRuntime("));
261
+ assert.ok(/launchTargets\(\)/.test(stopFn), "停止必须覆盖两个域(作业可能在 user 域)");
262
+ assert.ok(/resetBridgeForAccountChange/.test(INDEX_SRC), "应有「账号变更 → 停旧 bridge + 清过期状态」的统一入口");
263
+ // 自愈必须判账号归属,而不是只看"有没有进程在跑"
264
+ const ensureFn = INDEX_SRC.slice(INDEX_SRC.indexOf("function ensureConnection("), INDEX_SRC.indexOf("// ---------- 匿名装机"));
265
+ assert.ok(/bridgeMatchesCurrentAccount\(relayDir, cfg\)/.test(ensureFn), "自愈必须判账号归属");
266
+ assert.ok(/action: "restart"/.test(ensureFn), "账号不符时应重启而不是返回 none");
267
+ });
268
+
269
+ test("源码契约:watcher(dsh-setup run)也要能发现账号变化并重启子进程", () => {
270
+ assert.ok(/function accountFingerprint\(/.test(SETUP_SRC), "应有账号指纹");
271
+ assert.ok(/let bridgeAccountFp = ""/.test(SETUP_SRC), "应记录当前子进程用的凭据");
272
+ assert.ok(/bridgeAccountFp !== fp/.test(SETUP_SRC), "配置里的账号一变就要重启 bridge 子进程");
273
+ assert.ok(/账号配置已变化 → 重启 bridge/.test(SETUP_SRC), "应打日志说明为什么重启");
274
+ // pid 文件与去重护栏不再只服务 Windows(脱离进程兜底在 POSIX 上也用)
275
+ assert.ok(!/if \(IS_WIN\) writePidFile\(BRIDGE_PID_FILE/.test(SETUP_SRC), "pid 文件应全平台写");
276
+ assert.ok(!/ if \(IS_WIN\) \{\n const other = readPidFile\(WATCHER_PID_FILE\)/.test(SETUP_SRC), "去重护栏应全平台生效");
277
+ });
278
+
279
+ test("限流:429 不得被当成「密码错」,且 watcher 要退避而不是空撞", () => {
280
+ const bridge = readFileSync(path.join(HERE, "..", "..", "..", "clients", "dsh-remote", "dsh-bridge.mjs"), "utf8");
281
+ assert.ok(/r\.status === 429/.test(bridge), "bridge 必须单独处理 429");
282
+ assert.ok(/retry_after_ms/.test(bridge), "应读取服务端给的 retry_after_ms");
283
+ assert.ok(/凭据没错|无需改密码/.test(bridge), "必须明确告诉用户不是密码问题(旧文案会误导人去改密码)");
284
+ const rateStart = bridge.indexOf("r.status === 429");
285
+ const rateBlock = bridge.slice(rateStart, bridge.indexOf("process.exit(3)", rateStart)); // 只取 429 分支自身
286
+ assert.ok(/\.dsh-login-ratelimited/.test(rateBlock), "应落盘「最早可重试时刻」供 watcher 退避");
287
+ // 只看**真实打印**的行(注释里引用旧文案是可以的)
288
+ const emitted = rateBlock.split("\n")
289
+ .filter((l) => !/^\s*(\/\/|\*)/.test(l))
290
+ .filter((l) => /请检查 DSH_BRIDGE_EMAIL/.test(l));
291
+ assert.equal(emitted.length, 0, `429 分支不得再提示去检查密码:${emitted.join(" / ")}`);
292
+ // 登录成功要清掉退避提示(服务端成功即清零)
293
+ assert.ok(/rmSync\(path\.join\(path\.dirname\(CONFIG_PATH\), "\.dsh-login-ratelimited"/.test(bridge));
294
+ // watcher 侧:限流窗口内不反复拉起 bridge
295
+ assert.ok(/function loginRateLimitUntil\(/.test(SETUP_SRC), "watcher 应能读限流退避时间");
296
+ assert.ok(/if \(rlUntil > Date\.now\(\)\)/.test(SETUP_SRC), "窗口内应直接返回,不空撞");
297
+ assert.ok(/凭据无需修改/.test(SETUP_SRC), "提示要说清楚不是凭据问题");
298
+ });
299
+
300
+ test("前端:登录/注册落盘后必须检查 bridgeRestart,不再静默失败", () => {
301
+ assert.ok(/function applyAccountSaved\(/.test(CLIENT_SRC), "应有统一的账号落盘提示");
302
+ assert.ok(/br\.ok === false/.test(CLIENT_SRC), "必须检查 bridgeRestart.ok === false");
303
+ assert.ok(/未能自动重启/.test(CLIENT_SRC), "失败时给出可见提示");
304
+ // 只数**调用点**(第二个实参是字符串字面量),别把函数定义也算进去
305
+ const calls = CLIENT_SRC.match(/applyAccountSaved\(cfg, "/g) || [];
306
+ assert.equal(calls.length, 2, `登录与注册两条路径都要用它(实际 ${calls.length} 处)`);
307
+ });
@@ -155,6 +155,16 @@ test("残留 marker 自愈:记录进程已死的 marker 在插件启动时立
155
155
  }
156
156
  });
157
157
 
158
+ /** 等待条件成立:运行时清理现在是独立子进程,物理结果要等它跑完。 */
159
+ async function waitFor(fn, { timeout = 10000, step = 50 } = {}) {
160
+ const deadline = Date.now() + timeout;
161
+ for (;;) {
162
+ if (await fn()) return true;
163
+ if (Date.now() > deadline) return false;
164
+ await new Promise((r) => setTimeout(r, step));
165
+ }
166
+ }
167
+
158
168
  test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本地目录(解锁市场卸载)", async () => {
159
169
  const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-home-"));
160
170
  const relayDir = path.join(tempHome, "relay");
@@ -189,13 +199,12 @@ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本
189
199
  assert.equal(r.removedBundle, true, "应移除 dsh.profile.bundles 条目");
190
200
  assert.equal(r.removedDir, true, "应删除本地插件目录与 node_modules 链接");
191
201
 
192
- // 运行时/bridge 清理(隔离模式下只清空配置目录,绝不触碰真实 launchd 服务)
193
- assert.equal(r.relayDirRemoved, true, "配置目录 relayDir 应被整目录清空");
194
- assert.equal(r.stoppedService, false, "DSH_RELAY_SKIP_SERVICE=1 → 不做真实服务操作");
195
- assert.equal(r.removedPlist, false, "DSH_RELAY_SKIP_SERVICE=1 → 不删自启动 plist");
196
- assert.deepEqual(r.killedPids, [], "DSH_RELAY_SKIP_SERVICE=1 → 不杀进程");
197
- assert.equal(existsSync(relayDir), false, "relayDir 物理上应已不存在");
198
- assert.match(String(r.detail), /配置目录已清空/, "detail 应说明配置目录已清空");
202
+ // 运行时/bridge 清理:0.6.7-beta.3 起交给**独立子进程**(先回响应,慢活不阻塞事件循环),
203
+ // 所以这里断言响应契约 + 轮询等它的物理结果(隔离模式下只清空配置目录,绝不触碰真实 launchd 服务)
204
+ assert.equal(r.runtimeCleanup, "deferred", "运行时清理应由子进程接管");
205
+ const gone = await waitFor(() => !existsSync(relayDir));
206
+ assert.equal(gone, true, "relayDir 物理上应已不存在(子进程执行)");
207
+ assert.match(String(r.detail), /正在后台清理/, "detail 应说明后台清理");
199
208
  assert.match(String(r.detail), /请重启 dsh web/, "detail 应提示重启生效");
200
209
 
201
210
  const patch = await readFile(patchFile, "utf8");
@@ -2,8 +2,12 @@
2
2
  // 背景:用户反馈卸载不干净——bridge 自启动服务(launchd com.dshremote.bridge / Linux dsh-bridge)
3
3
  // 仍在运行、自启动 plist/unit 仍在、配置目录 ~/.dsh-remote(账号/设备密钥/.dsh-config.json/
4
4
  // .harness-cookie.json/固化运行时 dsh-setup.mjs + clients 等)也还在。
5
- // 卸载路由现在依次执行:profile 插件清理(uninstallSelf,原逻辑)+ 运行时清理(uninstallRuntime:
6
- // 停自启动服务 → 删自启动文件 → 杀残留进程 → rm -rf 配置目录),全部幂等、单项失败不致命。
5
+ // 0.6.7-beta.3 起卸载**分两段**(顺序是刻意的,见 uninstall 路由注释):
6
+ // ① 同步段:uninstallSelf(纯 fs、快)→ markUninstalled → 立刻回响应;
7
+ // ② 异步段:运行时清理(停自启动/杀残留/清配置目录)交给**独立子进程**执行。
8
+ // 为什么必须这样:同步段里任何一次 spawnSync 卡住(Windows 沙箱拦 schtasks,而 spawnSync 的
9
+ // timeout 在 Windows 上并不可靠),旧实现会让 uninstallSelf 永远不执行、整个 dsh web 僵死
10
+ // (端口在听、无响应)。因此这些用例改为:断言响应契约(runtimeCleanup)+ 轮询等待子进程的物理结果。
7
11
  // 测试隔离(绝不触碰本机真实 launchd/systemd/进程):
8
12
  // - 大多数用例设 DSH_RELAY_SKIP_SERVICE=1 → 跳过一切系统级操作,只验证配置目录清理与 profile 清理;
9
13
  // - 「自然跳过」用例把 PATH 指向假 launchctl/pgrep/ps,完全接管系统命令后走无开关路径。
@@ -23,6 +27,18 @@ const PLUGIN_BLOCK = `# >>> dsh-remote-ui (managed by dsh-remote plugin; do not
23
27
  # <<< dsh-remote-ui
24
28
  `;
25
29
 
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+
32
+ /** 等待条件成立:运行时清理现在是独立子进程,物理结果要等它跑完(而不是同步返回)。 */
33
+ async function waitFor(fn, { timeout = 10000, step = 50 } = {}) {
34
+ const deadline = Date.now() + timeout;
35
+ for (;;) {
36
+ if (await fn()) return true;
37
+ if (Date.now() > deadline) return false;
38
+ await sleep(step);
39
+ }
40
+ }
41
+
26
42
  /** 加载插件(要求调用方已把 process.env.HOME 指向 tempHome;profile 推导才落在 temp 而非真实 ~/.dsh)。 */
27
43
  function boot(relayDir) {
28
44
  const routes = new Map();
@@ -96,14 +112,11 @@ test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行
96
112
  assert.equal(r.removedDep, true);
97
113
  assert.equal(r.removedBundle, true);
98
114
  assert.equal(r.removedDir, true, "profile 插件目录与 node_modules 链接应删除");
99
- // 运行时清理标志位
100
- assert.equal(r.relayDirRemoved, true, "relayDir 应被整目录清空");
101
- assert.equal(r.servicePlatform, "launchd", "macOS 下 servicePlatform=launchd");
102
- assert.equal(r.stoppedService, false, "DSH_RELAY_SKIP_SERVICE=1 → 不触碰真实 launchd 服务");
103
- assert.equal(r.removedPlist, false);
104
- assert.deepEqual(r.killedPids, []);
105
- // 物理断言:relayDir 与其中全部残留都消失
106
- assert.equal(existsSync(relayDir), false, "relayDir 目录应整体不存在");
115
+ // 运行时清理交给独立子进程(响应先回,慢活不再钉住事件循环)
116
+ assert.equal(r.runtimeCleanup, "deferred", "运行时清理必须由子进程接管(不得同步阻塞)");
117
+ assert.ok(r.cleanupLog, "应给出清理日志路径");
118
+ // 物理断言:等子进程跑完,relayDir 与其中全部残留都应消失
119
+ assert.ok(await waitFor(() => !existsSync(relayDir)), "relayDir 应被整目录清空(子进程执行)");
107
120
  assert.equal(existsSync(path.join(relayDir, ".dsh-config.json")), false);
108
121
  assert.equal(existsSync(path.join(relayDir, "dsh-setup.mjs")), false);
109
122
  assert.equal(existsSync(path.join(relayDir, "clients")), false);
@@ -117,7 +130,7 @@ test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行
117
130
  assert.equal(existsSync(profile), true, "dsh web profile 目录整体不得被删");
118
131
  // 人类可读 detail
119
132
  assert.match(String(r.detail), /插件引用与本地文件已移除/);
120
- assert.match(String(r.detail), /配置目录已清空/);
133
+ assert.match(String(r.detail), /正在后台清理/);
121
134
  assert.match(String(r.detail), /请重启 dsh web/);
122
135
  } finally {
123
136
  host.close();
@@ -145,13 +158,12 @@ test("彻底卸载边界:只装了插件、从未跑过 bridge → relayDir
145
158
  const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
146
159
  assert.equal(r.ok, true);
147
160
  assert.equal(r.removedPatch, true, "profile 里的插件引用应照常清理");
148
- assert.equal(r.relayDirRemoved, false, "目录本就不存在 → 不声称已清空");
149
- assert.equal(r.stoppedService, false);
150
- assert.equal(r.removedPlist, false);
151
- assert.deepEqual(r.killedPids, []);
161
+ assert.equal(r.runtimeCleanup, "deferred");
152
162
  const detail = String(r.detail);
153
163
  assert.ok(!detail.includes("配置目录已清空"), "目录不存在时不应谎报已清空,实际: " + detail);
154
164
  assert.match(detail, /请重启 dsh web/);
165
+ await sleep(800); // 等清理子进程跑完
166
+ assert.equal(existsSync(relayDir), false, "relayDir 本来就不存在 → 不得被凭空创建");
155
167
  } finally {
156
168
  host.close();
157
169
  }
@@ -174,12 +186,13 @@ test("彻底卸载边界:什么都没有安装 → 全链路幂等返回 ok,
174
186
  try {
175
187
  const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
176
188
  assert.equal(r.ok, true);
177
- assert.equal(r.relayDirRemoved, false);
189
+ assert.equal(r.runtimeCleanup, "deferred");
178
190
  assert.equal(r.removedPatch, false);
179
191
  assert.equal(r.removedDep, false);
180
192
  assert.equal(r.removedBundle, false);
181
- assert.deepEqual(r.killedPids, []);
182
193
  assert.match(String(r.detail), /请重启 dsh web/);
194
+ await sleep(800);
195
+ assert.equal(existsSync(relayDir), false, "什么都没有也不该凭空创建目录");
183
196
  } finally {
184
197
  host.close();
185
198
  }
@@ -206,10 +219,9 @@ test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也
206
219
  try {
207
220
  const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
208
221
  assert.equal(r.ok, true);
209
- assert.equal(r.removedPlist, false, "开关置位 → plist 不得被删");
210
- assert.equal(existsSync(fakePlist), true, "plist 应原样保留(开关只放行配置目录清理)");
211
- assert.equal(r.relayDirRemoved, true, "配置目录清理不受开关影响");
212
- assert.equal(existsSync(relayDir), false);
222
+ assert.equal(r.runtimeCleanup, "deferred");
223
+ assert.ok(await waitFor(() => !existsSync(relayDir)), "配置目录清理不受开关影响");
224
+ assert.equal(existsSync(fakePlist), true, "开关置位 → plist 不得被删(子进程同样受开关约束)");
213
225
  } finally {
214
226
  host.close();
215
227
  }
@@ -220,7 +232,7 @@ test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也
220
232
  }
221
233
  });
222
234
 
223
- test("自然跳过(无开关):HOME 有 plist 但服务未运行 → 删除 plist 但不发 launchctl bootout(PATH 假命令接管)", { skip: process.platform !== "darwin" }, async () => {
235
+ test("无开关(真实分支):自启动清理由独立子进程执行,且走 PATH 上的假 launchctl(不碰真实服务)", { skip: process.platform !== "darwin" }, async () => {
224
236
  const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-natural-"));
225
237
  const prevHome = process.env.HOME;
226
238
  const prevPath = process.env.PATH;
@@ -254,15 +266,13 @@ exit 0
254
266
  try {
255
267
  const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
256
268
  assert.equal(r.ok, true);
257
- assert.equal(r.removedPlist, true, "plist 存在于当前 HOME → 应被删除");
258
- assert.equal(existsSync(plist), false);
259
- assert.equal(r.stoppedService, false, "服务未运行 → 无需 bootout,不虚报已停止");
260
- assert.deepEqual(r.killedPids, [], "无残留进程可杀");
261
- assert.equal(r.relayDirRemoved, true);
262
- assert.equal(existsSync(relayDir), false);
263
- // 关键:全程不得发出 bootout(避免任何真实/假服务被停)
264
- const log = readFileSync(launchLog, "utf8");
265
- assert.ok(!log.includes("bootout"), "未运行的服务不应触发 bootout,实际调用: " + log);
269
+ assert.equal(r.runtimeCleanup, "deferred", "无开关时同样走子进程");
270
+ // 等清理子进程跑完(它继承 HOME/PATH → plist 路径与 launchctl 都落在测试环境里)
271
+ assert.ok(await waitFor(() => !existsSync(plist)), "plist 应被清理子进程删除");
272
+ assert.ok(await waitFor(() => !existsSync(relayDir)), "配置目录应被清空");
273
+ // 关键安全性质:清理用的是 PATH 上的**假** launchctl —— 证明没有触碰任何真实服务
274
+ const log = existsSync(launchLog) ? readFileSync(launchLog, "utf8") : "";
275
+ assert.ok(log.includes("com.dshremote.bridge"), "应通过(假)launchctl 清理自启动,实际: " + log);
266
276
  } finally {
267
277
  host.close();
268
278
  }
@@ -289,8 +299,9 @@ test("保护参数:relayDir 误配置成 dsh web profile 目录 → 绝不整
289
299
  try {
290
300
  const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
291
301
  assert.equal(r.ok, true);
292
- assert.equal(r.relayDirRemoved, false, "profile 目录受保护 → 不得整目录删除");
293
- assert.equal(existsSync(profile), true, "dsh web profile 必须存活");
302
+ assert.equal(r.runtimeCleanup, "deferred");
303
+ await sleep(1000); // 等清理子进程跑完:它必须自己判断出"这是 profile,不许删"
304
+ assert.equal(existsSync(profile), true, "dsh web profile 必须存活(子进程也受同一条护栏约束)");
294
305
  assert.equal(existsSync(path.join(profile, "package.json")), true);
295
306
  assert.equal(existsSync(path.join(profile, "dsh-remote-ui-plugin")), false, "插件子目录应被 uninstallSelf 单独移除");
296
307
  assert.equal(existsSync(path.join(profile, "node_modules", "dsh-remote-ui")), false);