@mrrisega/dsh-remote 0.6.11 → 0.6.13

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.
Files changed (48) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +123 -2
  2. package/clients/dsh-remote/mobile-adapter.mjs +108 -4
  3. package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
  4. package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
  5. package/dsh-setup.mjs +55 -15
  6. package/package.json +4 -4
  7. package/packages/dsh-remote-web/lib/client.js +137 -0
  8. package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
  9. package/packages/dsh-remote-web/lib/index.js +748 -18
  10. package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
  11. package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
  12. package/packages/dsh-remote-web/package.json +5 -1
  13. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1572 -0
  14. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
  15. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
  16. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
  17. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1679 -0
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
  23. package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
  24. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
  25. package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
  26. package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
  27. package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
  28. package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
  29. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
  30. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
  31. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
  32. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
  33. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
  34. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
  35. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
  36. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
  37. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
  38. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
  39. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
  40. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
  41. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
  42. package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
  43. package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
  44. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
  45. package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
  46. package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
  47. package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
  48. package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mobile-adapter 回归测试:**网络再慢,左侧抽屉按钮(汉堡)也必须最终出现**。
4
+ *
5
+ * 用户原话(2026-09-2x 手机实测):
6
+ * 「在网络比较慢的情况下,左边的抽屉按钮有时候出不来。你要考虑修复这个问题,
7
+ * 甭管网络有多慢,都要让那个抽屉能加载出来。」
8
+ *
9
+ * 根因(本文件针对的 bug):
10
+ * 注入脚本 boot() 的**执行时机**是写死的一组固定重试
11
+ * (DOMContentLoaded + 700 / 1600 / 3200 / 7000 / 12000 ms),
12
+ * 而汉堡按钮的创建排在 `if (!frame) return;` **之后**:
13
+ * · 官方 SPA bundle 有 13.4MB(实测未压缩),免费档被中继限速到 128KB/s 时冷启动要 1-2 分钟;
14
+ * · 官方 frame 在 12 秒内根本没出现 → 5 次重试全部在那一行 return
15
+ * → `done` 永远为 false → 汉堡按钮永远不创建 → 用户看到"左边抽屉按钮出不来"。
16
+ *
17
+ * 修法(本文件断言的契约):
18
+ * ① 不再依赖任何固定重试窗口 —— 改成「事件驱动(MutationObserver 观察 document/subtree)
19
+ * + 低频兜底(setInterval 1000ms)」的**无限期**等待:只要 done 还是 false 就继续 boot();
20
+ * ② done=true 之后仍有「存在性看门狗」:官方重渲染把 dsh-ma-hamburger / dsh-ma-scrim
21
+ * 从 DOM 里冲掉时,按引用补回(节点不变 = 事件处理与类名都还在);
22
+ * ③ 两条路都必须幂等、有节流(观察者回调走 rAF 合并,兜底表 1s 一跳),
23
+ * 且页面卸载(pagehide/unload)时断开观察者与表。
24
+ *
25
+ * 本文件用假 DOM + vm 跑**真实注入脚本**,并把时间轴完全握在手里(不依赖真实计时器):
26
+ * · flushTimeouts() —— 把旧的固定重试窗口一次性走完(正是"窗口耗尽"的现场);
27
+ * · tickIntervals() —— 模拟兜底 setInterval 每秒一跳,可以一直跳到第 15 秒、第 60 秒;
28
+ * · mountFrame() —— 模拟官方 DOM 迟到挂载,并按真实 MutationObserver 语义通知观察者。
29
+ */
30
+ import assert from "node:assert/strict";
31
+ import test from "node:test";
32
+ import vm from "node:vm";
33
+ import { injectMobileAdapter } from "../mobile-adapter.mjs";
34
+
35
+ /** 取注入结果里的真实 <script> 内容(不加壳,原样执行)。 */
36
+ function adapterScript() {
37
+ const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
38
+ const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
39
+ assert.ok(m, "应能取出注入脚本");
40
+ return m[1];
41
+ }
42
+
43
+ // ── 极简假 DOM(与 mobile-adapter-runtime.test.mjs 同风格,另加父子链/isConnected) ──────
44
+
45
+ class El {
46
+ constructor(tag = "div", attrs = {}) {
47
+ this.tagName = String(tag).toUpperCase();
48
+ this.attrs = { ...attrs };
49
+ this.children = [];
50
+ this.parent = null;
51
+ this._cls = new Set(String(attrs.class || "").split(/\s+/).filter(Boolean));
52
+ this._l = new Map();
53
+ this.style = {
54
+ setProperty: (k, v) => { this._cssVars[k] = v; },
55
+ removeProperty: (k) => { delete this._cssVars[k]; },
56
+ display: "",
57
+ };
58
+ this._cssVars = {};
59
+ this._rect = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };
60
+ this.hidden = false;
61
+ this.classList = {
62
+ add: (...cs) => cs.forEach((c) => this._cls.add(c)),
63
+ remove: (...cs) => cs.forEach((c) => this._cls.delete(c)),
64
+ contains: (c) => this._cls.has(c),
65
+ toggle: (c, on) => {
66
+ const want = on === undefined ? !this._cls.has(c) : !!on;
67
+ if (want) this._cls.add(c); else this._cls.delete(c);
68
+ return want;
69
+ },
70
+ };
71
+ }
72
+ set innerHTML(html) {
73
+ this._html = String(html);
74
+ this.children = [];
75
+ for (const m of String(html).matchAll(/<(\w+)([^>]*)>/g)) {
76
+ const el = new El(m[1]);
77
+ const cls = /class="([^"]*)"/.exec(m[2] || "");
78
+ if (cls) el.className = cls[1];
79
+ const type = /type="([^"]*)"/.exec(m[2] || "");
80
+ if (type) el.attrs.type = type[1];
81
+ this.appendChild(el);
82
+ }
83
+ }
84
+ get innerHTML() { return this._html || ""; }
85
+ get className() { return [...this._cls].join(" "); }
86
+ set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
87
+ get textContent() { return this._text || ""; }
88
+ set textContent(v) { this._text = String(v); }
89
+ getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; }
90
+ setAttribute(n, v) { this.attrs[n] = String(v); }
91
+ removeAttribute(n) { delete this.attrs[n]; }
92
+ hasAttribute(n) { return n in this.attrs; }
93
+ appendChild(c) { c.parent = this; this.children.push(c); return c; }
94
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); if (c.parent === this) c.parent = null; return c; }
95
+ remove() { if (this.parent) this.parent.removeChild(this); }
96
+ contains(el) { let n = el; while (n) { if (n === this) return true; n = n.parent; } return false; }
97
+ /* 真实 DOM 语义:节点是否还挂在文档里。本假 DOM 以"能沿 parent 链走到 doc"为准。 */
98
+ get isConnected() { let n = this; while (n) { if (n._inDoc) return true; n = n.parent; } return false; }
99
+ prepend() {} focus() {} blur() {} scrollIntoView() {} setPointerCapture() {}
100
+ matches() { return false; }
101
+ addEventListener(t, fn) { if (!this._l.has(t)) this._l.set(t, []); this._l.get(t).push(fn); }
102
+ removeEventListener() {}
103
+ dispatchEvent() { return true; }
104
+ dispatch(t, ev = {}) { for (const fn of this._l.get(t) || []) fn({ stopPropagation() {}, preventDefault() {}, ...ev }); }
105
+ getBoundingClientRect() { return this._rect; }
106
+ querySelector(sel) {
107
+ const s = String(sel);
108
+ const hit = (el) => {
109
+ if (s.includes('input[type="range"]')) return el.tagName === "INPUT" && el.attrs.type === "range";
110
+ if (s.startsWith(".")) return el._cls.has(s.slice(1));
111
+ if (s.startsWith("input")) return el.tagName === "INPUT";
112
+ return false;
113
+ };
114
+ return this.children.find(hit) || null;
115
+ }
116
+ querySelectorAll(sel) { const one = this.querySelector(sel); return one ? [one] : []; }
117
+ }
118
+
119
+ /** 记录型 MutationObserver:可被手工触发,也可被 mountFrame 按真实语义叫醒。 */
120
+ class MutationObserverStub {
121
+ constructor(cb) { this.cb = cb; this.targets = []; this.disconnected = false; MutationObserverStub.instances.push(this); }
122
+ observe(t, opts) { this.targets.push({ t, opts }); }
123
+ disconnect() { this.disconnected = true; }
124
+ fire(records) { if (!this.disconnected) this.cb(records, this); }
125
+ static reset() { MutationObserverStub.instances = []; }
126
+ }
127
+ MutationObserverStub.instances = [];
128
+
129
+ /**
130
+ * 搭一个"官方 index.html 已经回来、但 13.4MB 的 SPA bundle 还没渲染出 frame"的现场:
131
+ * · html.outerHTML 只有官方外壳特征(__ModuleLoader__ / DeepSeek Harness)——
132
+ * 与真实 index.html 一致,**不含** frame 的任何 data-* 标记;
133
+ * · document.querySelector(官方 frame 选择器) 在 mountFrame() 之前一律返回 null
134
+ * (= 官方 UI 还没挂载,正是用户网络慢时看到的状态)。
135
+ */
136
+ function harness({ width = 390, height = 844 } = {}) {
137
+ MutationObserverStub.reset();
138
+ const intervals = [];
139
+ const timeouts = [];
140
+ let rafs = [];
141
+ const state = { mounted: false, pagehide: null };
142
+
143
+ const doc = new El("#document");
144
+ doc.tagName = "#DOCUMENT";
145
+ doc._inDoc = true; // 文档根:沿 parent 链能走到它就说明"在文档里"
146
+ const html = new El("html");
147
+ const body = new El("body");
148
+ doc.appendChild(html);
149
+ html.appendChild(body);
150
+ // 官方 index.html 外壳(真实 HTML 里就是这些字样;frame 是 bundle 跑起来之后才有的)
151
+ // outerHTML 做成计数器:boot() 第一行的 HOSTISH() 会整段序列化它,读数就是"整页判定"的次数
152
+ let outerHtmlReads = 0;
153
+ Object.defineProperty(html, "outerHTML", {
154
+ configurable: true,
155
+ get() { outerHtmlReads += 1; return '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
156
+ + "<title>DeepSeek Harness</title></head><body></body></html>"; },
157
+ set() { /* 允许写入(本用例不依赖) */ },
158
+ });
159
+
160
+ const frame = new El("div", { class: "pI_x6G_frame" });
161
+ frame.setAttribute("data-sidebar-collapsed", "true");
162
+ frame.querySelector = () => null; // 本用例不涉及第三列/overlay 识别
163
+
164
+ doc.readyState = "complete";
165
+ doc.documentElement = html;
166
+ doc.body = body;
167
+ doc.createElement = (t) => new El(t);
168
+ doc.createTextNode = (t) => ({ nodeValue: t });
169
+ doc.getElementById = () => null;
170
+ doc.addEventListener = () => {};
171
+ doc.removeEventListener = () => {};
172
+ doc.querySelector = (sel) => {
173
+ const s = String(sel);
174
+ if (s.includes(".pI_x6G_frame") || /\[data-(sidebar|details|rightbar)-collapsed\]/.test(s) || s.includes("[data-shell-overlay]")) {
175
+ return state.mounted ? frame : null; // ⬅️ 官方 UI 迟到:挂载前一律查不到
176
+ }
177
+ return null;
178
+ };
179
+ doc.querySelectorAll = () => [];
180
+
181
+ const loc = { href: "https://n.risegao.cn:13443/remote/dev-x/", host: "n.risegao.cn", pathname: "/remote/dev-x/", origin: "https://n.risegao.cn:13443" };
182
+ const win = {
183
+ innerWidth: width,
184
+ innerHeight: height,
185
+ devicePixelRatio: 2,
186
+ localStorage: { _d: {}, getItem(k) { return k in this._d ? this._d[k] : null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } },
187
+ location: loc,
188
+ MutationObserver: MutationObserverStub,
189
+ ResizeObserver: class { observe() {} disconnect() {} },
190
+ getComputedStyle: () => ({ getPropertyValue: () => "", pointerEvents: "none" }),
191
+ addEventListener(t, fn) {
192
+ if (t === "pagehide" || t === "unload") state.pagehide = fn;
193
+ if (t === "pageshow") state.pageshow = fn;
194
+ },
195
+ removeEventListener() {},
196
+ /* 计时器全部"抓住不放",时间轴由用例自己推 —— 不依赖真实时钟,也不会有测试结束后的悬挂回调。 */
197
+ setTimeout: (fn) => timeouts.push(fn),
198
+ clearTimeout: () => {},
199
+ setInterval: (fn) => { intervals.push(fn); return intervals.length; },
200
+ clearInterval: (id) => { if (id) intervals[id - 1] = null; },
201
+ requestAnimationFrame: (fn) => { rafs.push(fn); return rafs.length; },
202
+ };
203
+
204
+ const ctx = vm.createContext({
205
+ window: win, document: doc, console,
206
+ navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
207
+ location: loc, localStorage: win.localStorage,
208
+ setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
209
+ setInterval: win.setInterval, clearInterval: win.clearInterval,
210
+ MutationObserver: MutationObserverStub, ResizeObserver: win.ResizeObserver,
211
+ requestAnimationFrame: win.requestAnimationFrame,
212
+ URL, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
213
+ HTMLElement: El,
214
+ });
215
+ ctx.globalThis = ctx;
216
+ ctx.self = win;
217
+
218
+ /** 官方 UI 迟到挂载:把 frame 挂进 body,并按真实 MutationObserver 语义通知观察祖先树的观察者。 */
219
+ const mountFrame = ({ notify = true } = {}) => {
220
+ state.mounted = true;
221
+ body.appendChild(frame);
222
+ if (!notify) return;
223
+ for (const ob of MutationObserverStub.instances.slice()) {
224
+ if (ob.disconnected) continue;
225
+ const targets = ob.targets.map((x) => x.t);
226
+ // 新节点加进 body → 观察 body / documentElement / document(subtree)的观察者都会收到 childList 记录
227
+ if (targets.includes(body) || targets.includes(html) || targets.includes(doc)) {
228
+ ob.fire([{ type: "childList", target: body, addedNodes: [frame] }]);
229
+ }
230
+ }
231
+ };
232
+ /** 把旧的"固定重试窗口"(700/1600/3200/7000/12000ms 那批 setTimeout)一次性走完。 */
233
+ const flushTimeouts = () => { const q = timeouts.splice(0, timeouts.length); for (const fn of q) fn(); };
234
+ /** 模拟兜底 setInterval 每秒一跳(间隔由实现决定,用例只关心"它一直在跳")。 */
235
+ const tickIntervals = (seconds = 1) => {
236
+ for (let i = 0; i < seconds; i++) for (const fn of intervals.slice()) if (fn) fn();
237
+ };
238
+ const flushRaf = () => { const q = rafs; rafs = []; for (const fn of q) fn(); };
239
+ /** 模拟官方 SPA 的 DOM 变更风暴(流式输出时每帧都在改 DOM),并按真实语义通知观察者。 */
240
+ const churn = (n = 20) => {
241
+ for (let i = 0; i < n; i++) {
242
+ const d = new El("div");
243
+ body.appendChild(d);
244
+ for (const ob of MutationObserverStub.instances.slice()) {
245
+ if (ob.disconnected) continue;
246
+ const targets = ob.targets.map((x) => x.t);
247
+ if (targets.includes(body) || targets.includes(html) || targets.includes(doc)) {
248
+ ob.fire([{ type: "childList", target: body, addedNodes: [d] }]);
249
+ }
250
+ }
251
+ flushRaf();
252
+ }
253
+ };
254
+
255
+ return { ctx, doc, html, body, frame, win, state, mountFrame, flushTimeouts, tickIntervals, flushRaf, churn,
256
+ intervals, outerHtmlReads: () => outerHtmlReads };
257
+ }
258
+
259
+ function run(env, js) {
260
+ vm.runInContext(js, env.ctx, { timeout: 5000 });
261
+ return env;
262
+ }
263
+
264
+ const findHamburger = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
265
+ const findScrim = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
266
+
267
+ // ── 用例 ─────────────────────────────────────────────────────────────────────
268
+
269
+ test("慢网回归:官方 frame 第 15 秒才出现,汉堡按钮最终仍必须创建(旧的 12s 固定重试窗口会永久放弃)", () => {
270
+ const env = run(harness({ width: 390 }), adapterScript());
271
+
272
+ assert.ok(!findHamburger(env), "前提:官方 frame 还没出现时,不该凭空造出汉堡按钮");
273
+ assert.ok(!findScrim(env), "前提:官方 frame 还没出现时,不该凭空造出遮罩");
274
+
275
+ // ① 先把旧的固定重试窗口整批走完(700…12000ms 全部到点)—— 这正是当年"窗口耗尽"的现场
276
+ env.flushTimeouts();
277
+ assert.ok(!findHamburger(env), "官方 frame 未出现时仍不得创建汉堡按钮(不能提前造出无锚点的入口)");
278
+
279
+ // ② 再把时间推过 15 秒(兜底表每秒一跳),官方 frame 依然没来
280
+ env.tickIntervals(15);
281
+ assert.ok(!findHamburger(env), "前提:frame 还没来");
282
+
283
+ // ③ 第 15 秒:13.4MB 的 bundle 终于在 128KB/s 的限制下加载完,官方 frame 挂上 DOM。
284
+ // 这里刻意不通知观察者(notify:false)—— 只留"低频兜底 setInterval"这一条路,
285
+ // 证明它不是"靠事件侥幸",而是真的无限期重试。
286
+ env.mountFrame({ notify: false });
287
+ assert.ok(!findHamburger(env), "前提:这一瞬间还没轮到兜底表跳");
288
+
289
+ env.tickIntervals(1);
290
+ assert.ok(findHamburger(env),
291
+ "网络再慢,官方 frame 一出现抽屉按钮也必须最终创建出来(本次 bug 的回归点:固定重试窗口耗尽就永久放弃)");
292
+ assert.ok(findScrim(env), "遮罩同样必须补上(它和汉堡按钮在同一段之后创建)");
293
+ });
294
+
295
+ test("慢网回归:跳到第 60 秒(远超旧窗口 5 倍)官方 frame 才出现,汉堡按钮仍必须出现", () => {
296
+ const env = run(harness({ width: 390 }), adapterScript());
297
+ env.flushTimeouts();
298
+ env.tickIntervals(60); // 60 秒过去,frame 还没来
299
+ assert.ok(!findHamburger(env), "前提:frame 仍然没来");
300
+ env.mountFrame({ notify: false }); // 第 60 秒才挂上
301
+ env.tickIntervals(1);
302
+ assert.ok(findHamburger(env), "「甭管网络有多慢」——60 秒后才出现官方 frame 也必须能拿到抽屉按钮");
303
+ });
304
+
305
+ test("事件驱动:官方 frame 挂载的 DOM 变更直接叫醒适配层(观察者回调经 rAF 合并,不等兜底表)", () => {
306
+ const env = run(harness({ width: 390 }), adapterScript());
307
+ assert.ok(!findHamburger(env), "前提:frame 未挂载");
308
+ env.mountFrame(); // 通知观察者
309
+ env.flushRaf(); // 合并后的那一帧
310
+ assert.ok(findHamburger(env), "官方 frame 一挂上就必须立刻跟上(事件驱动路径),不必等 1s 兜底表");
311
+ });
312
+
313
+ test("存在性看门狗:done 之后官方重渲染把汉堡/遮罩冲掉,必须按引用补回来", () => {
314
+ const env = run(harness({ width: 390 }), adapterScript());
315
+ env.mountFrame({ notify: false });
316
+ env.tickIntervals(1);
317
+ const ham = findHamburger(env);
318
+ const scrim = findScrim(env);
319
+ assert.ok(ham && scrim, "前提:抽屉入口已建好");
320
+
321
+ // 模拟官方 SPA 重渲染:整片换掉 body 子树(实测某些路由/会话切换会重建 DOM)
322
+ env.body.removeChild(ham);
323
+ env.body.removeChild(scrim);
324
+ assert.ok(!findHamburger(env), "前提:官方重渲染把我们的节点冲掉了");
325
+ assert.equal(ham.isConnected, false, "假 DOM 语义:被移除的节点应报 isConnected=false");
326
+
327
+ env.tickIntervals(2);
328
+ assert.equal(findHamburger(env), ham, "补回的必须是**同一个节点**(事件处理与类名原样保留),而不是重建一个");
329
+ assert.equal(findScrim(env), scrim, "遮罩同样必须补回");
330
+ assert.equal(ham.isConnected, true, "补回后应重新在文档里");
331
+ });
332
+
333
+ test("清理:pagehide 必须断开观察者与兜底表(避免 bfcache/长驻页面泄漏)", () => {
334
+ const env = run(harness({ width: 390 }), adapterScript());
335
+ assert.equal(typeof env.state.pagehide, "function", "应注册 pagehide 清理钩子");
336
+ const observers = MutationObserverStub.instances.slice();
337
+ env.state.pagehide();
338
+ assert.ok(observers.every((o) => o.disconnected), "pagehide 必须 disconnect 所有已挂观察者");
339
+ // 断开后时间轴上再跳也不再工作(表已清)
340
+ env.mountFrame({ notify: false });
341
+ env.tickIntervals(2);
342
+ assert.ok(!findHamburger(env), "卸载后不得再往页面里塞东西");
343
+ });
344
+
345
+ test("bfcache:切走再切回来(pageshow persisted)必须重新武装看门狗,否则回来后就永久失灵", () => {
346
+ const env = run(harness({ width: 390 }), adapterScript());
347
+ env.mountFrame({ notify: false });
348
+ env.tickIntervals(1);
349
+ const ham = findHamburger(env);
350
+ assert.ok(ham, "前提:抽屉入口已就位");
351
+
352
+ env.state.pagehide(); // 切走(进入 bfcache)
353
+ env.state.pageshow({ persisted: true }); // 切回来(从 bfcache 恢复)
354
+ env.body.removeChild(ham); // 官方此时重渲染冲掉我们的节点
355
+ env.tickIntervals(1);
356
+ assert.equal(findHamburger(env), ham, "bfcache 恢复后看门狗必须仍然有效(重新武装)");
357
+ env.state.pageshow({ persisted: false }); // 普通 pageshow 不得重复武装出第二套计时器
358
+ const live = env.intervals.filter(Boolean).length;
359
+ env.state.pageshow({ persisted: true });
360
+ assert.equal(env.intervals.filter(Boolean).length, live, "重新武装必须先清旧表(不能越切越多计时器)");
361
+ });
362
+
363
+ test("幂等:兜底表长时间空转不得重复插入节点(每类节点在 body 里最多一个)", () => {
364
+ const env = run(harness({ width: 390 }), adapterScript());
365
+ env.mountFrame({ notify: false });
366
+ env.tickIntervals(30); // 空转 30 秒
367
+ const count = (cls) => env.body.children.filter((c) => c._cls.has(cls)).length;
368
+ assert.equal(count("dsh-ma-hamburger"), 1, "汉堡按钮必须只有一个(幂等,不能每次 tick 都插一个)");
369
+ assert.equal(count("dsh-ma-scrim"), 1, "遮罩必须只有一个");
370
+ });
371
+
372
+ test("开销:抽屉入口就位后,官方的 DOM 变更风暴不得再触发整页判定(HOSTISH 会序列化整个 outerHTML)", () => {
373
+ const env = run(harness({ width: 390 }), adapterScript());
374
+ env.mountFrame({ notify: false });
375
+ env.tickIntervals(1);
376
+ assert.ok(findHamburger(env), "前提:抽屉入口已就位");
377
+
378
+ // 官方流式输出时每帧都在改 DOM —— 这时适配层必须已经"静默",不能再做昂贵判定
379
+ const before = env.outerHtmlReads();
380
+ env.churn(30); // 30 帧 DOM 变更(每帧都通知观察者)
381
+ env.tickIntervals(3); // 兜底表再空转 3 秒
382
+ assert.equal(env.outerHtmlReads(), before,
383
+ "入口已经建好且都在文档里时,不得再为每次 DOM 变更/每秒 tick 重新序列化整页(那是每秒几十次全量 outerHTML)");
384
+ // 但"看门狗"必须还在:真被冲掉时依然能补回来(静默 ≠ 失灵)
385
+ env.body.removeChild(findHamburger(env));
386
+ env.churn(1);
387
+ assert.ok(findHamburger(env), "静默之后看门狗仍必须工作");
388
+ });
package/dsh-setup.mjs CHANGED
@@ -88,8 +88,13 @@ function findDepWs(startDir) {
88
88
  return null;
89
89
  }
90
90
 
91
- /** 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。 */
92
- const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs"];
91
+ /**
92
+ * 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。
93
+ * ⚠️ `src/lifecycle.mjs` 也在表里:本安装器**自己**import 它(进程优雅停机的共用实现),
94
+ * 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
95
+ * 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。
96
+ */
97
+ const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "src/lifecycle.mjs"];
93
98
 
94
99
  /**
95
100
  * 把"客户端脚本"补齐到配置目录。
@@ -769,7 +774,39 @@ function restartBridgeService() {
769
774
  }
770
775
 
771
776
  /**
772
- * dsh web 是否正在运行(127.0.0.1:3080)。
777
+ * 上游 dsh web 地址(bridge 的转发目标 + watcher 的「dsh web 在线吗」判据**同一个来源**)。
778
+ *
779
+ * 【2026-09-23 用户实测事故】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
780
+ * 不在默认端口(`dsh web --port 8090`、profile 里配了 port、或 `--port 0` 让系统分配空闲端口),
781
+ * watcher 的在线探测就**永远**失败 → bridge 永远不启动,而面板只会说「正在启动 Bridge…」。
782
+ * 优先级:显式环境变量 > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
783
+ * 为什么要有「端口文件」这一层:开机自启(登录任务 / launchd)拉起 watcher 时**没有**任何环境变量,
784
+ * 只有插件半知道 dsh web 真实监听在哪个端口,所以它会把地址落盘(见插件 half 的 publishUpstream)。
785
+ */
786
+ const UPSTREAM_FILE = ".dsh-upstream";
787
+ function upstreamUrl() {
788
+ const explicit = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim();
789
+ if (explicit) return explicit.replace(/\/+$/, "");
790
+ try {
791
+ const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
792
+ if (/^https?:\/\/[^\s]+$/i.test(fromFile)) return fromFile.replace(/\/+$/, "");
793
+ } catch { /* 没写过(旧版/首次)→ 往下回退 */ }
794
+ const web = String(process.env.DSH_WEB_URL || "").trim();
795
+ if (web) {
796
+ try {
797
+ const u = new URL(web);
798
+ if (u.port) return `http://127.0.0.1:${u.port}`; // bridge 的 loopback 围栏只认 127.0.0.1
799
+ } catch { /* 非 URL:忽略 */ }
800
+ }
801
+ return "http://127.0.0.1:3080";
802
+ }
803
+ /** 上游端口(诊断/日志用;取不到就回退 3080)。 */
804
+ function upstreamPort() {
805
+ try { return Number(new URL(upstreamUrl()).port) || 3080; } catch { return 3080; }
806
+ }
807
+
808
+ /**
809
+ * dsh web 是否正在运行(默认 127.0.0.1:3080,端口以 upstreamUrl() 为准)。
773
810
  * bridge 本身依赖 dsh web 才工作:dsh web 没开时 bridge 起来也会立刻退出,
774
811
  * 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
775
812
  */
@@ -777,7 +814,7 @@ async function isDshWebUp(timeoutMs = 1200) {
777
814
  try {
778
815
  const ac = new AbortController();
779
816
  const timer = setTimeout(() => ac.abort(), timeoutMs);
780
- await fetch("http://127.0.0.1:3080/", { signal: ac.signal });
817
+ await fetch(upstreamUrl() + "/", { signal: ac.signal });
781
818
  clearTimeout(timer);
782
819
  return true;
783
820
  } catch { return false; }
@@ -794,13 +831,14 @@ function resolveProfileDir(argv = []) {
794
831
  }
795
832
 
796
833
  /**
797
- * 监听 127.0.0.1:3080 的进程 pid(= dsh web)。
834
+ * 监听本机 dsh web 端口的进程 pid(= dsh web)。端口取 upstreamPort()(不再写死 3080)。
798
835
  * macOS/Linux:lsof → pgrep;Windows:netstat -ano 取 LISTENING 行的最后一列
799
836
  * (netstat 是系统自带,比每次都起 PowerShell 快得多)。
800
837
  */
801
838
  function dshWebPid() {
839
+ const port = upstreamPort();
802
840
  if (IS_WIN) {
803
- const r = sh('netstat -ano | findstr ":3080"');
841
+ const r = sh(`netstat -ano | findstr ":${port}"`);
804
842
  for (const line of String(r.stdout || "").split("\n")) {
805
843
  if (!/LISTENING/i.test(line)) continue;
806
844
  const m = line.trim().match(/(\d+)\s*$/);
@@ -808,7 +846,7 @@ function dshWebPid() {
808
846
  }
809
847
  return null;
810
848
  }
811
- const l = sh("lsof -nP -iTCP:3080 -sTCP:LISTEN -t 2>/dev/null || true");
849
+ const l = sh(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null || true`);
812
850
  const pid = String(l.stdout || "").trim().split(/\s+/).filter(Boolean)[0];
813
851
  if (pid && /^\d+$/.test(pid)) return Number(pid);
814
852
  const p = sh("pgrep -f 'dsh web' 2>/dev/null | head -1");
@@ -884,7 +922,7 @@ function restartDshWeb() {
884
922
  */
885
923
  function restartDshWebWindows() {
886
924
  const pid = dshWebPid();
887
- if (!pid) return { ok: false, how: "", detail: "未找到监听 3080 的进程(dsh web 没在运行?)" };
925
+ if (!pid) return { ok: false, how: "", detail: `未找到监听 ${upstreamPort()} 的进程(dsh web 没在运行?)` };
888
926
  const cmdline = windowsProcessCommandLine(pid);
889
927
  if (!cmdline) return { ok: false, how: "", detail: "无法获取 dsh web 的启动命令行,请手动重启" };
890
928
  if (/_npx|node_modules\\\.bin|node_modules\/\.bin/.test(cmdline)) {
@@ -1087,15 +1125,15 @@ async function runBridge() {
1087
1125
  /** 限流提示只打一次,避免刷屏。 */
1088
1126
  let warnedRateLimit = false;
1089
1127
 
1090
- // watcher:检测 dsh web(127.0.0.1:3080)存活,存活才启动 bridge
1128
+ // watcher:检测 dsh web 是否存活(端口以 upstreamUrl() 为准,绝不写死 3080),存活才启动 bridge
1091
1129
  const checkUpstream = () => new Promise((resolve) => {
1092
1130
  const t = setTimeout(() => resolve(false), 2000);
1093
- fetch("http://127.0.0.1:3080/")
1131
+ fetch(upstreamUrl() + "/")
1094
1132
  .then(() => { clearTimeout(t); resolve(true); })
1095
1133
  .catch(() => { clearTimeout(t); resolve(false); });
1096
1134
  });
1097
1135
 
1098
- console.log("[dsh-remote] 等待 dsh web(127.0.0.1:3080)启动...");
1136
+ console.log(`[dsh-remote] 等待 dsh web(${upstreamUrl()})启动...`);
1099
1137
  let bridgeProc = null;
1100
1138
  let starting = false;
1101
1139
 
@@ -1158,6 +1196,8 @@ async function runBridge() {
1158
1196
  ...process.env,
1159
1197
  DSH_BRIDGE_CONFIG: CONFIG_PATH,
1160
1198
  DSH_BRIDGE_TUNNEL_URL: cfg.tunnel_url,
1199
+ // 上游端口显式传给 bridge:它自己在旧版本里也回退到写死的 3080(见 dsh-bridge.mjs 的 UPSTREAM)。
1200
+ DSH_BRIDGE_UPSTREAM: upstreamUrl(),
1161
1201
  // 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
1162
1202
  DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
1163
1203
  DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
@@ -1335,7 +1375,7 @@ async function setup(argv) {
1335
1375
  fs.writeFileSync(patchPath, cur);
1336
1376
  } catch { /* 忽略:轮询会给出结论 */ }
1337
1377
  for (let i = 0; i < 45; i += 1) {
1338
- const r = await fetch("http://127.0.0.1:3080/dsh-remote/self", { signal: AbortSignal.timeout(900) })
1378
+ const r = await fetch(upstreamUrl() + "/dsh-remote/self", { signal: AbortSignal.timeout(900) })
1339
1379
  .catch(() => null);
1340
1380
  if (r && r.ok) {
1341
1381
  const j = await r.json().catch(() => null);
@@ -1448,7 +1488,7 @@ async function setup(argv) {
1448
1488
  L.push(" 再打开 设置 → 「远程访问」→ 注册/登录手机号即可。");
1449
1489
  } else if (needRestart && restartResult && restartResult.ok) {
1450
1490
  L.push("");
1451
- L.push(" 下一步: dsh web 已重启,直接打开 http://127.0.0.1:3080 → 设置 → 「远程访问」→ 注册/登录手机号。");
1491
+ L.push(" 下一步: dsh web 已重启,直接打开 " + upstreamUrl() + " → 设置 → 「远程访问」→ 注册/登录手机号。");
1452
1492
  } else if (needRestart && pluginResult && pluginResult.hotPatch && pluginResult.changed) {
1453
1493
  // patch 已写入但探测窗口内没等到面板接口:热加载很可能还在进行(插件节点半启动+注册路由)。
1454
1494
  // 这种情况**不能**直接让用户重启 —— 先让他等几秒刷新,再给兜底方案。
@@ -1466,8 +1506,8 @@ async function setup(argv) {
1466
1506
  L.push("⚠️ 还需要重启一次 dsh web,插件面板才会出现(插件只在进程启动时装载)。");
1467
1507
  L.push(" 请手动执行(任选其一):");
1468
1508
  L.push(" launchctl kickstart -k gui/$(id -u)/com.dshweb.dev # 用 launchd 托管时");
1469
- L.push(" kill $(lsof -ti tcp:3080 -sTCP:LISTEN) && dsh web --no-open # 手动启动时");
1470
- L.push(" 重启后:打开 http://127.0.0.1:3080 → 设置 → 「远程访问」→ 注册/登录手机号。");
1509
+ L.push(` kill $(lsof -ti tcp:${upstreamPort()} -sTCP:LISTEN) && dsh web --no-open # 手动启动时`);
1510
+ L.push(` 重启后:打开 ${upstreamUrl()} → 设置 → 「远程访问」→ 注册/登录手机号。`);
1471
1511
  } else {
1472
1512
  L.push("");
1473
1513
  L.push(" 下一步: 打开 dsh web → 设置 → 「远程访问」→ 注册/登录手机号即可(无需任何命令)。");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -51,9 +51,9 @@
51
51
  },
52
52
  "scripts": {
53
53
  "test": "npm run test:router && npm run test:plugin && npm run test:bridge",
54
- "test:router": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs",
55
- "_comment:test:plugin": "DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)。DSH_RELAY_DEFAULT_API= 死端口:用例拆掉临时目录后,仍在飞的后台请求(装机上报重试等)会读到空配置并回落「默认云端地址」—— 指到本地死端口才不会打生产。",
56
- "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
54
+ "test:router": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs packages/relay-router/test/device-free-assets.test.mjs packages/relay-router/test/sse-streaming-contract.test.mjs",
55
+ "_comment:test:plugin": "DSH_RELAY_BUNDLED_RUNTIME=/nonexistent = 强制走 npx 兜底分支(否则本机跑过 npm pack 后 packages/dsh-remote-web/runtime 存在,用例会改成走本地拷贝)。DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)。DSH_RELAY_DEFAULT_API= 死端口:用例拆掉临时目录后,仍在飞的后台请求(装机上报重试等)会读到空配置并回落「默认云端地址」—— 指到本地死端口才不会打生产。",
56
+ "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
57
57
  "_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
58
58
  "test:bridge": "DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 clients/dsh-remote/test/*.test.mjs",
59
59
  "check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",