@mrrisega/dsh-remote 0.6.4-beta.1 → 0.6.4-beta.10

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.4-beta.1",
3
+ "version": "0.6.4-beta.10",
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,303 @@
1
+ // 【自动化】「自动重启 dsh web」的安全回归(浏览器半,0.6.4)。
2
+ //
3
+ // 目标:**确实需要重启时**,用户不必自己点按钮 —— 面板发现待重启后自己倒计时重启,重启后自动恢复页面
4
+ // (复用既有 restartHarness + waitHarnessBack:轮询到 harness 回来即自动刷新)。
5
+ //
6
+ // ⚠️ 0.6.4-beta.9 起:kind="refresh"(插件被运行时改写 → 只需刷新页面)**不再自动重启**,
7
+ // 因为刷新是零风险动作、重启会打断用户正在进行的会话。因此本用例用 kind="update"
8
+ // (非 refresh)来构造"确实需要重启"的场景,以覆盖倒计时链路。
9
+ // 但「自动重启进程」是危险动作,本用例锁死四条安全阀:
10
+ // ① 只在面板可见时计时(用户没在看就不动);
11
+ // ② 15 秒倒计时内可一键取消,取消标记按本次事件(restart.at)持久化,同一事件不再自动重启;
12
+ // ③ 面板上有其他操作在跑(busy≠"")时暂停计时,不打断用户正在做的事;
13
+ // ④ 只重启一次(重启后 pending 结清即停);无待重启时一个请求都不发。
14
+ import assert from "node:assert/strict";
15
+ import { readFileSync } from "node:fs";
16
+ import test from "node:test";
17
+ import vm from "node:vm";
18
+
19
+ const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
20
+
21
+ function walk(node, visit) {
22
+ if (node == null) return;
23
+ if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
24
+ if (typeof node !== "object") return;
25
+ visit(node);
26
+ for (const child of node.children || []) walk(child, visit);
27
+ }
28
+ function find(tree, predicate) {
29
+ let match;
30
+ walk(tree, (node) => { if (!match && predicate(node)) match = node; });
31
+ return match;
32
+ }
33
+ function textHas(tree, substr) {
34
+ return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
35
+ }
36
+ function nodeWithText(tree, substr) {
37
+ return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
38
+ }
39
+
40
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
41
+
42
+ const NOT_LOGGED = { ok: true, config: { phone: "", hasPhone: false, mode: "saas", deviceId: "" }, service: { running: false }, remoteUrl: "https://app.test/" };
43
+ const LOGGED = { ok: true, config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" }, service: { running: true }, remoteUrl: "https://app.test/" };
44
+ const KEY_URL = "https://app.test/a/K1";
45
+ const SESSIONS = [{ id: "ms_1", label: "iPhone 15", os: "iOS", browser: "Safari", created_at: 1700000000000, last_seen_at: 1700000600000, revoked_at: null }];
46
+ function statusWithRestart(pending, at) {
47
+ // kind 用 "update"(非 refresh):只有这一路才走自动重启倒计时;
48
+ // kind="refresh" 的面板只提示刷新页面(见 harness-restart.test.mjs)。
49
+ return Object.assign({}, LOGGED, { restart: { pending: !!pending, kind: "update", reason: "需要重启 dsh web 才能载入新插件", at: at || 0 } });
50
+ }
51
+
52
+ function loadPlugin(opts = {}) {
53
+ let moduleFactory;
54
+ const registered = new Map();
55
+ const injects = new Map();
56
+ const requests = [];
57
+ const clipboard = [];
58
+ const states = [];
59
+ let hook = 0;
60
+ let effectCursor = 0;
61
+ let effectSlots = [];
62
+ let pending = [];
63
+ const listeners = new Map();
64
+
65
+ let clock = 0;
66
+ let timerSeq = 1;
67
+ const timers = new Map();
68
+ const job = (fn) => { try { fn(); } catch (e) { /* 内部错误由断言暴露 */ } };
69
+ const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
70
+ const fakeSetInterval = (fn, ms) => { const id = timerSeq++; const every = Math.max(1, Number(ms) || 1); timers.set(id, { at: clock + every, fn, every }); return id; };
71
+ const fakeClear = (id) => { timers.delete(id); };
72
+ async function advance(ms) {
73
+ const target = clock + ms;
74
+ for (let guard = 0; guard < 2000; guard++) {
75
+ let due = null;
76
+ for (const [id, t] of timers) if (t.at <= target && (!due || t.at < due[1].at)) due = [id, t];
77
+ if (!due) break;
78
+ const [id, t] = due;
79
+ clock = t.at;
80
+ if (t.every) t.at = clock + t.every; else timers.delete(id);
81
+ job(t.fn);
82
+ await flush();
83
+ }
84
+ clock = target;
85
+ await flush(); await flush();
86
+ }
87
+
88
+ const react = {
89
+ createElement(type, props, ...children) { return { type, props: props || {}, children }; },
90
+ useState(initial) {
91
+ const index = hook++;
92
+ if (!(index in states)) states[index] = typeof initial === "function" ? initial() : initial;
93
+ return [states[index], (value) => { states[index] = typeof value === "function" ? value(states[index]) : value; }];
94
+ },
95
+ useEffect(fn, deps) {
96
+ const index = effectCursor++;
97
+ const prev = effectSlots[index];
98
+ const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
99
+ if (changed) pending.push({ index, fn, deps: deps ? [...deps] : deps });
100
+ },
101
+ useCallback(fn) { return fn; },
102
+ useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
103
+ };
104
+
105
+ const response = (status, body) => Promise.resolve({
106
+ ok: status >= 200 && status < 300,
107
+ status,
108
+ text: async () => JSON.stringify(body),
109
+ });
110
+
111
+ let statusBody = opts.status || NOT_LOGGED;
112
+ let restartCalls = 0;
113
+ const connBody = null;
114
+ let connCalls = 0;
115
+ let keyCalls = 0;
116
+ let sessCalls = 0;
117
+
118
+ const makeEl = () => ({
119
+ tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
120
+ setAttribute(k, v) { this.attributes[k] = v; },
121
+ getAttribute(k) { return this.attributes[k] ?? null; },
122
+ appendChild(c) { this.children.push(c); },
123
+ addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(fn); },
124
+ removeEventListener(type, fn) {
125
+ const list = listeners.get(type) || [];
126
+ const i = list.indexOf(fn);
127
+ if (i >= 0) list.splice(i, 1);
128
+ },
129
+ });
130
+ const doc = {
131
+ hidden: false,
132
+ createElement: makeEl,
133
+ head: makeEl(),
134
+ body: makeEl(),
135
+ querySelector() { return null; },
136
+ querySelectorAll() { return []; },
137
+ getElementById() { return null; },
138
+ addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(fn); },
139
+ removeEventListener(type, fn) {
140
+ const list = listeners.get(type) || [];
141
+ const i = list.indexOf(fn);
142
+ if (i >= 0) list.splice(i, 1);
143
+ },
144
+ };
145
+ class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
146
+ const localStorage = {
147
+ // 支持预置存储(验证「取消标记跨刷新生效」:getItem 走真实读取路径)
148
+ _s: new Map(Object.entries(opts.localStorage || {})),
149
+ getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
150
+ setItem(k, v) { this._s.set(k, String(v)); },
151
+ removeItem(k) { this._s.delete(k); },
152
+ };
153
+
154
+ const sandbox = {
155
+ window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
156
+ document: doc,
157
+ localStorage,
158
+ MutationObserver: MutationObserverMock,
159
+ navigator: { clipboard: { writeText: async (t) => { clipboard.push(String(t)); } } },
160
+ setTimeout: fakeSetTimeout,
161
+ clearTimeout: fakeClear,
162
+ setInterval: fakeSetInterval,
163
+ clearInterval: fakeClear,
164
+ fetch(path, options = {}) {
165
+ requests.push({ path, method: options.method || "GET", body: options.body ? JSON.parse(options.body) : null });
166
+ if (path === "/dsh-remote/status") return response(200, statusBody);
167
+ if (path === "/dsh-remote/bridge-status") { connCalls += 1; return response(200, { ok: true }); }
168
+ if (path === "/dsh-remote/harness/restart") {
169
+ restartCalls += 1;
170
+ // 重启后 harness 会带着新的 bootId 回来:模拟「待重启已结清」
171
+ statusBody = Object.assign({}, statusBody, { restart: { pending: false, kind: "", reason: "", at: statusBody.restart?.at || 0 } });
172
+ return response(200, Object.assign({}, statusBody, { ok: true, mode: "launchd" }));
173
+ }
174
+ if (path === "/dsh-remote/access-key") {
175
+ keyCalls += 1;
176
+ return response(200, { ok: true, url: KEY_URL, key: "K1", expires_at: Date.now() + 1800000, ttl_ms: 1800000, qr_data_url: "data:image/png;base64,AAAA" });
177
+ }
178
+ if (path === "/dsh-remote/mobile-sessions") {
179
+ sessCalls += 1;
180
+ return response(200, { ok: true, sessions: opts.sessions || SESSIONS });
181
+ }
182
+ if (path === "/dsh-remote/captcha") return response(200, { captcha_id: "cap-1", svg: "<svg></svg>" });
183
+ if (path === "/dsh-remote/register") return response(201, { ok: true, token: "jwt-reg" });
184
+ if (path === "/dsh-remote/config") return response(200, statusBody);
185
+ if (path === "/dsh-remote/account") return response(200, { ok: true, account: { phone: "138****0000", plan: "free", plan_source: "plan" } });
186
+ if (path === "/dsh-remote/quota") return response(200, { ok: true, quota: null });
187
+ if (path === "/dsh-remote/remote-url") return response(200, { ok: true, remoteUrl: "https://app.test/", publicConfig: {} });
188
+ return response(200, { ok: true });
189
+ },
190
+ Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
191
+ };
192
+
193
+ vm.runInNewContext(SOURCE, sandbox);
194
+ const plugin = moduleFactory((name) => {
195
+ assert.equal(name, "react");
196
+ return react;
197
+ });
198
+ plugin.apply({
199
+ slots: {
200
+ inject(name, cb) { injects.set(name, cb); cb(); },
201
+ register(meta, component) { registered.set(meta.id, component); return () => {}; },
202
+ },
203
+ });
204
+
205
+ return {
206
+ requests, states, clipboard,
207
+ counts() { return { connCalls, keyCalls, sessCalls, restartCalls }; },
208
+ /** 改写状态响应(模拟 pending 被结清/用户取消后再次出现等)。 */
209
+ setStatus(body) { statusBody = body; },
210
+ /** 渲染一轮并执行本轮需要运行的 effects(含 cleanup)。 */
211
+ render() {
212
+ hook = 0; effectCursor = 0; pending = [];
213
+ const tree = registered.get("dsh-remote")({ close() {} });
214
+ const todos = pending; pending = [];
215
+ for (const t of todos) {
216
+ const prev = effectSlots[t.index];
217
+ if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
218
+ const cleanup = t.fn();
219
+ effectSlots[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
220
+ }
221
+ return tree;
222
+ },
223
+ async settle(times = 3) {
224
+ for (let i = 0; i < times; i++) { this.render(); await flush(); await flush(); }
225
+ return this.render();
226
+ },
227
+ advance,
228
+ /** 登录成功(面板读到登录态)→ 登录态 effect 重跑,自动开始推进连接。 */
229
+ async login() {
230
+ statusBody = LOGGED;
231
+ await advance(30_000); // 等 30s 状态轮询把登录态带进面板
232
+ return this.settle();
233
+ },
234
+ /** 连接阶段推进(node 半短轮询的应答变化)。 */
235
+ setConnect(phase, extra) { connBody = connectPayload(phase, extra); },
236
+ setConnectRaw(body) { connBody = body; },
237
+ hide() { doc.hidden = true; },
238
+ show() { doc.hidden = false; for (const fn of listeners.get("visibilitychange") || []) job(fn); },
239
+ };
240
+ }
241
+
242
+
243
+
244
+ test("待重启 + 面板可见 → 15 秒后自动重启(用户零操作),重启后 pending 结清即停止", async () => {
245
+ const plugin = loadPlugin({ status: statusWithRestart(true, 1111) });
246
+ let tree = await plugin.settle();
247
+ assert.ok(textHas(tree, "需要重启 dsh web 才能载入新插件"), "应显示待重启横幅");
248
+ assert.ok(textHas(tree, "秒后自动重启"), "应显示自动重启倒计时文案");
249
+ assert.equal(plugin.counts().restartCalls, 0, "倒计时期间不得提前重启");
250
+
251
+ await plugin.advance(14000);
252
+ assert.equal(plugin.counts().restartCalls, 0, "14 秒时还没到点");
253
+ await plugin.advance(2000);
254
+ assert.equal(plugin.counts().restartCalls, 1, "15 秒到点应自动重启一次");
255
+
256
+ tree = await plugin.settle();
257
+ assert.ok(!textHas(tree, "秒后自动重启"), "重启后不应残留倒计时");
258
+ await plugin.advance(60000);
259
+ assert.equal(plugin.counts().restartCalls, 1, "pending 结清后不得反复重启");
260
+ });
261
+
262
+ test("点「取消自动重启」→ 不再自动重启,且标记按事件持久化(刷新后仍不自动重启)", async () => {
263
+ const plugin = loadPlugin({ status: statusWithRestart(true, 2222) });
264
+ const tree = await plugin.settle();
265
+ const cancelBtn = nodeWithText(tree, "取消自动重启");
266
+ assert.ok(cancelBtn, "倒计时期间必须提供取消入口");
267
+ cancelBtn.props.onClick();
268
+ assert.ok(textHas(plugin.render(), "已取消自动重启"), "取消后应有明确反馈");
269
+
270
+ await plugin.advance(120000);
271
+ assert.equal(plugin.counts().restartCalls, 0, "取消后不得自动重启");
272
+
273
+ // 模拟用户刷新页面:同一 pending 事件(at=2222)仍不自动重启
274
+ const plugin2 = loadPlugin({ status: statusWithRestart(true, 2222), localStorage: { "dsh-remote-auto-restart-cancelled": "2222" } });
275
+ await plugin2.settle();
276
+ await plugin2.advance(120000);
277
+ assert.equal(plugin2.counts().restartCalls, 0, "刷新后仍尊重取消标记");
278
+
279
+ // 但下一次安装/更新(新的 at)应重新自动触发
280
+ const plugin3 = loadPlugin({ status: statusWithRestart(true, 3333), localStorage: { "dsh-remote-auto-restart-cancelled": "2222" } });
281
+ await plugin3.settle();
282
+ await plugin3.advance(16000);
283
+ assert.equal(plugin3.counts().restartCalls, 1, "新的待重启事件应重新自动重启");
284
+ });
285
+
286
+ test("面板不可见 → 倒计时暂停;回到前台继续", async () => {
287
+ const plugin = loadPlugin({ status: statusWithRestart(true, 4444) });
288
+ await plugin.settle();
289
+ plugin.hide();
290
+ await plugin.advance(60000);
291
+ assert.equal(plugin.counts().restartCalls, 0, "页面隐藏时绝不能在用户没看到的情况下重启进程");
292
+ plugin.show();
293
+ await plugin.advance(16000);
294
+ assert.equal(plugin.counts().restartCalls, 1, "回到前台应继续倒计时并最终重启");
295
+ });
296
+
297
+ test("无待重启 → 一个请求都不发;横幅不出现", async () => {
298
+ const plugin = loadPlugin({ status: LOGGED });
299
+ const tree = await plugin.settle();
300
+ await plugin.advance(60000);
301
+ assert.equal(plugin.counts().restartCalls, 0);
302
+ assert.ok(!textHas(tree, "自动重启"), "无待重启时不应出现自动重启文案");
303
+ });
@@ -1,9 +1,16 @@
1
- // 「首次安装需要重启 DeepSeek harness」回归(0.6.2 新增)。
1
+ // 「插件装载 / 刷新 / 重启」回归(0.6.2 新增,0.6.4-beta.9 语义修订)。
2
2
  //
3
3
  // 产品诉求(用户原话):
4
- // 1. 安装完成后桌面端 UI 直接可用,并提示「首次安装需要重启 DeepSeek harness」,旁边给「重启」按钮;
5
- // 2. 常驻入口最底下要有「重启 DeepSeek harness」按钮;
6
- // 3. 首次打开(尚未重启)时,该按钮要出现在上方显眼位置。
4
+ // 1. 安装完成后桌面端 UI 直接可用;
5
+ // 2. 常驻入口最底下要有「重启 dsh web」按钮(排查用,始终可达);
6
+ // 3. 需要用户动一下时,该提示要出现在上方显眼位置。
7
+ //
8
+ // ⚠️ 0.6.4-beta.9 语义修订(用户反馈「能热加载了,这个设置窗口就不需要了」):
9
+ // 插件已改为 profile patch **热加载**装载(HMR 监听 cordis.patch.yml,存盘约 1 秒生效),
10
+ // 运行环境(bridge)又是独立 launchd 进程 —— 所以「装插件 / 在线更新完成」都**不再需要重启
11
+ // DeepSeek harness**,那条横幅确实是误导。现在只在一种情形提示用户:
12
+ // **磁盘上的插件文件比当前进程新**(= 运行中被市场安装/在线更新改写),文案是
13
+ // 「插件已更新,刷新页面即可生效」,并且**不自动重启**(刷新零风险,重启会打断用户会话)。
7
14
  //
8
15
  // 机制:dsh web 的插件(宿主半 + 浏览器半)都在**进程启动时**装载,市场安装/在线更新只是把文件
9
16
  // 写进 profile,当前进程里既没有 /dsh-remote/* 路由也没有面板入口 → 必须重启才生效。
@@ -12,7 +19,7 @@
12
19
  import assert from "node:assert/strict";
13
20
  import http from "node:http";
14
21
  import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
15
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
22
+ import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises";
16
23
  import os from "node:os";
17
24
  import path from "node:path";
18
25
  import test from "node:test";
@@ -52,8 +59,16 @@ async function setup({ list = "" } = {}) {
52
59
  await writeFile(path.join(fakeBin, "npx"), "#!/bin/sh\nexit 0\n"); await chmodSync(path.join(fakeBin, "npx"), 0o755);
53
60
 
54
61
  const saved = {};
55
- for (const k of ["PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_LIST", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_SELFHEAL_MS",
62
+ for (const k of ["HOME", "PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_LIST", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_SELFHEAL_MS",
56
63
  "DSH_RELAY_RESTART_DRYRUN", "DSH_SETUP_NPX_DIR"]) saved[k] = process.env[k];
64
+ // HOME 必须一起伪造:本用例会 unset DSH_RELAY_SKIP_SERVICE 去走真实分支,
65
+ // 而插件的自启动 plist 路径写作 join(homedir(), "Library/LaunchAgents/…")。
66
+ // 不伪造 HOME 就会把**指向测试临时目录**的 plist 覆盖到开发者真实的
67
+ // ~/Library/LaunchAgents/com.dshremote.bridge.plist(实测污染,且临时目录随即被删 →
68
+ // 真实 bridge 自启动彻底失效)。这是测试对开发者机器的破坏,必须隔离。
69
+ const fakeHome = path.join(root, "home");
70
+ await mkdir(path.join(fakeHome, "Library", "LaunchAgents"), { recursive: true });
71
+ process.env.HOME = fakeHome;
57
72
  process.env.PATH = `${fakeBin}:${saved.PATH}`;
58
73
  process.env.DSH_TEST_LAUNCH_LOG = path.join(root, "launchctl.log");
59
74
  process.env.DSH_TEST_LIST = list;
@@ -99,7 +114,7 @@ async function serve(routes) {
99
114
 
100
115
  // ─────────────────────────── 宿主半:状态机 ───────────────────────────
101
116
 
102
- test("首次安装(运行环境在本进程内补齐)→ 自动标记「需要重启」并在 /status 下发", async () => {
117
+ test("首次安装(运行环境在本进程内补齐)→ **不再**提示重启 harness(热加载后已无必要)", async () => {
103
118
  const env = await setup();
104
119
  try {
105
120
  await writeConfig(env.relayDir);
@@ -110,16 +125,52 @@ test("首次安装(运行环境在本进程内补齐)→ 自动标记「需
110
125
  const { host, base } = await serve(routes);
111
126
  try {
112
127
  const before = await (await fetch(`${base}/dsh-remote/status`)).json();
113
- assert.equal(before.restart.pending, false, "运行环境补齐前不该提示重启");
128
+ assert.equal(before.restart.pending, false, "运行环境补齐前不该有提示");
114
129
 
115
130
  // 模拟后台 npx 安装完成:固化运行时落盘
116
131
  await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
117
- await waitFor(async () => readFile(env.stateFile, "utf8").then(() => true).catch(() => false));
132
+ await sleep(400); // 给自愈调度一轮时间(以前这一轮会 markRestartPending)
118
133
 
119
134
  const after = await (await fetch(`${base}/dsh-remote/status`)).json();
120
- assert.equal(after.restart.pending, true, "运行环境刚补齐 → 应提示需要重启 DeepSeek harness");
121
- assert.equal(after.restart.kind, "first-install");
122
- assert.match(String(after.restart.reason), /首次安装需要重启 DeepSeek harness/);
135
+ // bridge 是独立 launchd 进程、插件走 patch 热加载 → 补齐运行环境不需要重启 harness。
136
+ // 旧行为(提示"首次安装需要重启 DeepSeek harness")已被用户实测判定为误导,故断言必须为 false。
137
+ assert.equal(after.restart.pending, false,
138
+ "运行环境补齐**不该**再提示重启 harness(热加载 + 独立 bridge 进程)");
139
+ } finally { host.close(); routes.dispose(); }
140
+ } finally { await env.restore(); }
141
+ });
142
+
143
+ test("补装把**同版本**插件重写一遍 → 不得提示(用户实测的误报)", async () => {
144
+ // 事故复盘:安装器/自愈补装会把 profile 副本整目录重写,即使是**同一个版本**。
145
+ // 旧实现只看"插件文件 mtime 晚于进程启动"→ 误判成"有新插件要装载"→ 弹出需要重启/刷新的横幅,
146
+ // 而用户刷新后横幅还在(因为第二次补装又写了一遍)、最后靠重启才消失。
147
+ // 现在判的是「磁盘版本 != 运行版本」,版本相同就什么都不提示。
148
+ const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
149
+ const fn = src.slice(src.indexOf("function pluginInstalledAfterBoot("), src.indexOf("/** 本进程的启动命令"));
150
+ assert.match(fn, /diskVersion !== runningVersion/, "必须比较版本,而不是只看时间戳");
151
+ assert.match(fn, /PLUGIN_VERSION/, "必须用本进程装载的版本号做比较");
152
+ });
153
+
154
+ test("插件文件晚于本进程启动 → 提示「刷新页面」,且**不**自动重启", async () => {
155
+ const env = await setup();
156
+ try {
157
+ await writeConfig(env.relayDir);
158
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
159
+ const routes = boot(env.relayDir);
160
+ const { host, base } = await serve(routes);
161
+ try {
162
+ const cur = await (await fetch(`${base}/dsh-remote/status`)).json();
163
+ // 用**真实的 bootId** 写回状态:否则 settleRestartState 会判定"这是上个进程写的 = 已重启过"
164
+ // 而把提示结清(那样就测不到"同进程内保留提示"这条语义了)。
165
+ await writeFile(env.stateFile, JSON.stringify({
166
+ pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效",
167
+ at: Date.now() - 3000, bootId: cur.restart.bootId
168
+ }));
169
+
170
+ const st = await (await fetch(`${base}/dsh-remote/status`)).json();
171
+ assert.equal(st.restart.pending, true, "插件被运行时改写 → 应提示");
172
+ assert.equal(st.restart.kind, "refresh", "kind 必须是 refresh(刷新语义,不是重启)");
173
+ assert.match(String(st.restart.reason), /刷新页面/);
123
174
  } finally { host.close(); routes.dispose(); }
124
175
  } finally { await env.restore(); }
125
176
  });
@@ -140,18 +191,31 @@ test("重启已完成(bootId 变化)→ 自动撤下提示;同一进程内
140
191
  } finally { host.close(); routes.dispose(); }
141
192
  } finally { await a.restore(); }
142
193
 
143
- // 场景 B:状态由**当前进程**写下(用户还没点重启,只是刷新了页面 / 插件重新装载)→ 必须保留。
144
- // 走真实链路:插件自己写 pending(运行环境在本进程内补齐),再在同一进程内重新装载一次。
194
+ // 场景 B:状态由**当前进程**写下(用户还没处理,只是刷新了页面 / 插件重新装载)→ 必须保留提示。
195
+ // 注:0.6.4-beta.9 起"运行环境在本进程内补齐"不再写待重启(热加载后无必要),
196
+ // 所以这里直接按**真实 bootId** 写一份 kind=refresh 状态来构造该场景。
145
197
  const b = await setup();
146
198
  try {
147
199
  await writeConfig(b.relayDir);
200
+ await writeFile(path.join(b.relayDir, "dsh-setup.mjs"), "// runtime stub");
148
201
  process.env.DSH_RELAY_SELFHEAL_MS = "200";
202
+ // 本进程的 bootId:必须与 lib/index.js 的 BOOT_ID 完全一致,否则 settleRestartState
203
+ // 会判定"状态来自上一个进程 = 重启已完成"而把提示结清。
204
+ // 构造方式与 lib 相同;lib 对外不导出它,状态文件只在有 pending 时才写,故这里自行推出。
205
+ const sameProcessBootId = `${process.pid}-${Math.round(Date.now() - process.uptime() * 1000)}`;
149
206
  const routes1 = boot(b.relayDir);
150
207
  const { host: host1, base: base1 } = await serve(routes1);
151
- await writeFile(path.join(b.relayDir, "dsh-setup.mjs"), "// runtime stub");
152
- await waitFor(async () => readFile(b.stateFile, "utf8").then(() => true).catch(() => false));
153
- const st1 = await (await fetch(`${base1}/dsh-remote/status`)).json();
154
- assert.equal(st1.restart.pending, true, "运行环境补齐后应标记待重启");
208
+ await writeFile(b.stateFile, JSON.stringify({
209
+ pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效",
210
+ at: Date.now() - 3000, bootId: sameProcessBootId
211
+ }));
212
+ // 把状态文件的 mtime 置为"刚写过":pluginInstalledAfterBoot() 判的是"插件文件晚于本进程启动"。
213
+ // 不这么做,第二次 apply() 的条件 `!pending && pluginInstalledAfterBoot()` 会为真,
214
+ // 于是 markRestartPending 又写一次(新 at/bootId),测的就不是"settle 不清"这条语义了。
215
+ const nowMs = Date.now() / 1000;
216
+ await utimes(b.stateFile, nowMs, nowMs);
217
+ const st1b = await (await fetch(`${base1}/dsh-remote/status`)).json();
218
+ assert.equal(st1b.restart.pending, true, "同进程写下的待处理状态应保留");
155
219
  host1.close();
156
220
  routes1.dispose();
157
221
 
@@ -161,7 +225,7 @@ test("重启已完成(bootId 变化)→ 自动撤下提示;同一进程内
161
225
  try {
162
226
  const st2 = await (await fetch(`${base2}/dsh-remote/status`)).json();
163
227
  assert.equal(st2.restart.pending, true, "同一进程内(还没真重启)不得误撤提示");
164
- assert.equal(st2.restart.bootId, st1.restart.bootId, "bootId 应在同一进程内稳定不变");
228
+ assert.equal(st2.restart.bootId, sameProcessBootId, "bootId 应在同一进程内稳定不变");
165
229
  } finally { host2.close(); routes2.dispose(); }
166
230
  } finally { await b.restore(); }
167
231
  });
@@ -291,15 +355,11 @@ function loadPlugin(statusBody) {
291
355
  return { requests, states, render() { hook = 0; return registered.get("dsh-remote")({ close() {} }); } };
292
356
  }
293
357
 
294
- const STATUS_FIRST_INSTALL = {
358
+ // 现在唯一会提示用户的状态是 kind=refresh(插件文件被运行时改写 → 刷新页面即可)
359
+ const STATUS_REFRESH = {
295
360
  ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
296
361
  service: { running: true, runtimeReady: true, launchd: {} },
297
- restart: { pending: true, kind: "first-install", reason: "首次安装需要重启 DeepSeek harness" },
298
- };
299
- const STATUS_UPDATE = {
300
- ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
301
- service: { running: true, runtimeReady: true, launchd: {} },
302
- restart: { pending: true, kind: "update", reason: "已在线更新,需要重启 DeepSeek harness 生效" },
362
+ restart: { pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效", at: Date.now() },
303
363
  };
304
364
  const STATUS_IDLE = {
305
365
  ok: true, config: { phone: "13800000000", deviceId: "dev-x" },
@@ -307,21 +367,26 @@ const STATUS_IDLE = {
307
367
  restart: { pending: false },
308
368
  };
309
369
 
310
- test("UI:待重启 → 顶部醒目提示「首次安装需要重启 DeepSeek harness」+ 重启按钮", () => {
370
+ test("UI:插件更新后 → 顶部提示「刷新页面即可生效」+ 刷新按钮(不再喊重启 harness)", () => {
311
371
  const plugin = loadPlugin({
312
372
  ok: true,
313
373
  config: { phone: "13800000000", deviceId: "dev-x" },
314
374
  service: { running: true, runtimeReady: true, launchd: {} },
315
- restart: { pending: true, kind: "first-install", reason: "首次安装需要重启 DeepSeek harness" },
375
+ restart: { pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效" },
316
376
  });
317
- plugin.states[0] = STATUS_FIRST_INSTALL; // mock 的 useEffect 不执行 → 直接注入面板状态
377
+ plugin.states[0] = STATUS_REFRESH; // mock 的 useEffect 不执行 → 直接注入面板状态
318
378
  const tree = plugin.render();
319
- assert.ok(textHas(tree, "首次安装需要重启 DeepSeek harness"), "顶部应出现首次安装重启提示");
379
+ assert.ok(textHas(tree, "插件已更新,刷新页面即可生效"), "顶部应出现刷新提示");
380
+ // 不能再说"需要重启 DeepSeek harness" —— 热加载后那是误导(用户实测反馈)
381
+ assert.ok(!textHas(tree, "需要重启 DeepSeek harness"), "不应再出现「需要重启 DeepSeek harness」");
320
382
  const alert = find(tree, (n) => n.props && n.props.className === "dru-restart-alert");
321
383
  assert.ok(alert, "应有醒目提示块(dru-restart-alert)");
322
- const btns = findAll(tree, (n) => n.props && String(n.props.className || "").includes("dru-btn") &&
323
- (n.children || []).some((c) => typeof c === "string" && c.includes("重启 DeepSeek harness")));
324
- assert.ok(btns.length >= 2, "提示块内 + 底部常驻各一个重启按钮,实际 " + btns.length);
384
+ // 主按钮应是「刷新页面」
385
+ const refreshBtn = find(tree, (n) => n.props && String(n.props.className || "").includes("dru-btn-primary") &&
386
+ (n.children || []).some((c) => typeof c === "string" && c.includes("刷新页面")));
387
+ assert.ok(refreshBtn, "提示块内主按钮应是「刷新页面」");
388
+ // 重启按钮仍可达(兜底),但不再是主按钮
389
+ assert.ok(textHas(tree, "重启 dsh web"), "应保留「重启 dsh web」作为兜底入口");
325
390
  });
326
391
 
327
392
  test("UI:常驻入口最底部始终有「重启 DeepSeek harness」按钮;不需要重启时也不消失", () => {
@@ -333,28 +398,29 @@ test("UI:常驻入口最底部始终有「重启 DeepSeek harness」按钮;
333
398
  });
334
399
  plugin.states[0] = STATUS_IDLE;
335
400
  const tree = plugin.render();
336
- assert.ok(!textHas(tree, "首次安装需要重启 DeepSeek harness"), "不需要重启时不该出现顶部提示");
401
+ assert.ok(!textHas(tree, "刷新页面即可生效"), "不需要动作时不该出现顶部提示");
337
402
  const foot = find(tree, (n) => n.props && n.props.className === "dru-restart-foot");
338
403
  assert.ok(foot, "底部应常驻重启按钮区(dru-restart-foot)");
339
- assert.ok(textHas(foot, "重启 DeepSeek harness"), "常驻区应含重启按钮文案");
404
+ assert.ok(textHas(foot, "重启 dsh web"), "常驻区应含重启按钮文案(排查兜底,始终可达)");
340
405
  });
341
406
 
342
- test("UI:点「重启」→ 调用 /dsh-remote/harness/restart", async () => {
407
+ test("UI:点「重启 dsh web」→ 调用 /dsh-remote/harness/restart", async () => {
343
408
  const plugin = loadPlugin({
344
409
  ok: true,
345
410
  config: { phone: "13800000000", deviceId: "dev-x" },
346
411
  service: { running: true, runtimeReady: true, launchd: {} },
347
- restart: { pending: true, kind: "update", reason: "已在线更新,需要重启 DeepSeek harness 生效" },
412
+ restart: { pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效" },
348
413
  });
349
- plugin.states[0] = STATUS_UPDATE;
414
+ plugin.states[0] = STATUS_REFRESH;
350
415
  const tree = plugin.render();
351
- // 顶部提示块内的那个按钮(常驻脚注里也有一个)
416
+ // 顶部提示块内的兜底重启按钮(常驻脚注里也有一个)
352
417
  const alert = find(tree, (n) => n.props && n.props.className === "dru-restart-alert");
353
418
  const btn = find(alert, (n) => n.props && String(n.props.className || "").includes("dru-btn") &&
354
- (n.children || []).some((c) => typeof c === "string" && c.includes("重启 DeepSeek harness")));
419
+ (n.children || []).some((c) => typeof c === "string" && c.includes("重启 dsh web")));
355
420
  assert.ok(btn, "顶部提示块内应能定位到重启按钮");
356
421
  btn.props.onClick();
357
422
  await new Promise((r) => setImmediate(r));
358
423
  assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/harness/restart" && r.method === "POST"),
359
424
  "点击后应 POST /dsh-remote/harness/restart,实际 " + JSON.stringify(plugin.requests));
360
425
  });
426
+
@@ -0,0 +1,78 @@
1
+ /**
2
+ * macOS 自启动(launchd)契约测试(node:test,零框架)。
3
+ *
4
+ * 背景:用户(macOS 26.1)反馈自启动服务登记后**从未被派生**(`runs = 0`),
5
+ * 安装脚本报「自启动服务启动失败:服务未在运行」。根因:
6
+ * ① macOS 26 会把 `gui/<uid>` domain 置于 on-demand-only 模式,`RunAtLoad` / `KeepAlive`
7
+ * 全部失效(只登记不派生);系统日志:`pending spawn, domain in on-demand-only mode`。
8
+ * 迁到 `user/<uid>` domain(并带 `LimitLoadToSessionType`)才能恢复派生与崩溃自愈。
9
+ * ② 旧实现 `bootstrap` 后**立刻** `print` 一次就判定失败 —— launchd 派生是异步的。
10
+ * ③ 从不调用 `launchctl kickstart`,所以被挂起时没有任何东西会把它拉起来。
11
+ * ④ plist 的 PATH 缺 `/usr/sbin`、`/sbin`,bridge 日志里刷 `ioreg: command not found`。
12
+ *
13
+ * 本机实测(macOS 14.4.1,同机验证):
14
+ * · `bootstrap user/501 <plist 无 LimitLoadToSessionType>` → rc=5 `Input/output error`
15
+ * · 带 `[Aqua, Background]` 后:user/501 与 gui/501 都装载成功且 `runs = 1`
16
+ * · user/501 下 `kill -9` 后 launchd 自动重建 → `runs = 2`(KeepAlive 真生效)
17
+ */
18
+
19
+ import { test } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { readFileSync } from "node:fs";
22
+ import { fileURLToPath } from "node:url";
23
+ import { dirname, join } from "node:path";
24
+
25
+ const SETUP = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "dsh-setup.mjs");
26
+ const src = readFileSync(SETUP, "utf8");
27
+
28
+ test("launchd 修复:plist 必须带 LimitLoadToSessionType(user domain 的必要条件)", () => {
29
+ assert.match(
30
+ src,
31
+ /<key>LimitLoadToSessionType<\/key><array><string>Aqua<\/string><string>Background<\/string><\/array>/,
32
+ "缺该键时 user/<uid> bootstrap 直接 EIO(rc=5);macOS 26 上它也是恢复派生的前提"
33
+ );
34
+ // RunAtLoad / KeepAlive 仍需保留:user domain 下它们才真正生效
35
+ assert.match(src, /<key>RunAtLoad<\/key><true\/>/, "RunAtLoad 不能删(user domain 下靠它开机自启)");
36
+ assert.match(src, /<key>KeepAlive<\/key><true\/>/, "KeepAlive 不能删(user domain 下靠它崩溃自愈)");
37
+ });
38
+
39
+ test("launchd 修复:启动走 user → gui 阶梯,且每次都 kickstart", () => {
40
+ assert.ok(/function startBridgeDarwin/.test(src), "应有专门的 darwin 启动阶梯函数");
41
+ const fn = src.slice(src.indexOf("function startBridgeDarwin"), src.indexOf("function restartBridgeService"));
42
+ // 首选 user domain(macOS 26 上唯一支持自启动语义的 domain)
43
+ assert.ok(/for \(const domain of \[`user\/\$\{uid\}`, `gui\/\$\{uid\}`\]\)/.test(fn), "应为 user → gui 的阶梯顺序");
44
+ assert.ok(/launchctl kickstart -k/.test(fn), "bootstrap 只是登记,必须 kickstart 才能确保这次起来");
45
+ // 清掉另一个 domain 的残留,避免两处并存跑出两个 bridge
46
+ assert.ok(/bootout \$\{d\}\/\$\{BRIDGE_LABEL\}/.test(fn), "切换到某 domain 时应清掉另一 domain 的历史注册");
47
+ // 终极兜底:launchd 托管不了也要保证「现在能用」
48
+ assert.ok(/spawnDetachedBridge/.test(fn), "launchd 不可用时应退化为后台进程兜底");
49
+ assert.ok(/degraded-detached/.test(fn), "兜底状态要与完全失败区分开");
50
+ });
51
+
52
+ test("launchd 修复:不再只 print 一次就判定失败(异步派生必须轮询)", () => {
53
+ assert.ok(/function waitLaunchdRunning/.test(src), "应有轮询等待函数");
54
+ const fn = src.slice(src.indexOf("function launchdState"), src.indexOf("function spawnDetachedBridge"));
55
+ assert.ok(/for \(let i = 0; i < tries; i \+= 1\)/.test(fn), "应循环轮询而非单次检查");
56
+ assert.ok(/sleepSync\(gapMs\)/.test(fn), "轮询之间要真正等待");
57
+ assert.ok(/tries = 20/.test(fn), "总等待时长应足够 launchd 完成异步派生");
58
+ // 必须能识别「登记了但从未派生」这一 macOS 26 特征,才能给出正确建议而不是干等
59
+ assert.ok(/pended nondemand spawn/.test(fn) && /on-demand-only/.test(fn), "应识别 on-demand-only 挂起特征");
60
+ });
61
+
62
+ test("launchd 修复:被挂起后靠 kickstart 起来时,不得报成完全成功", () => {
63
+ assert.ok(/selfHealing/.test(src), "应回报是否具备崩溃自愈能力");
64
+ assert.ok(/本机 launchd 处于 on-demand-only/.test(src), "安装输出要如实说明不支持崩溃自愈");
65
+ const summary = src.slice(src.indexOf("const svcFragile"), src.indexOf("const L = [];"));
66
+ assert.ok(/svcFragile/.test(summary), "汇总里应区分「可自愈」与「仅拉起一次」");
67
+ });
68
+
69
+ test("launchd 修复:服务 PATH 必须含 /usr/sbin 与 /sbin(否则 ioreg 找不到)", () => {
70
+ const m = src.match(/const SERVICE_PATH = "([^"]+)"/);
71
+ assert.ok(m, "应集中定义 SERVICE_PATH");
72
+ for (const dir of ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]) {
73
+ assert.ok(m[1].split(":").includes(dir), `PATH 缺少 ${dir}`);
74
+ }
75
+ // plist 与 systemd unit 都必须用这个常量,不能各写各的
76
+ assert.ok(/\$\{SERVICE_PATH\}/.test(src), "plist/systemd 应使用 SERVICE_PATH 常量");
77
+ assert.ok(!/Environment=PATH=\/usr\/local\/bin:\/usr\/bin:\/bin/.test(src), "systemd unit 不应再写死旧 PATH");
78
+ });