@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,74 @@
1
+ {
2
+ "name": "ws",
3
+ "version": "8.21.3",
4
+ "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
5
+ "keywords": [
6
+ "HyBi",
7
+ "Push",
8
+ "RFC-6455",
9
+ "WebSocket",
10
+ "WebSockets",
11
+ "real-time"
12
+ ],
13
+ "homepage": "https://github.com/websockets/ws",
14
+ "bugs": "https://github.com/websockets/ws/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/websockets/ws.git"
18
+ },
19
+ "author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)",
20
+ "license": "MIT",
21
+ "main": "index.js",
22
+ "exports": {
23
+ ".": {
24
+ "browser": "./browser.js",
25
+ "import": "./wrapper.mjs",
26
+ "require": "./index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "browser": "browser.js",
31
+ "engines": {
32
+ "node": ">=10.0.0"
33
+ },
34
+ "files": [
35
+ "browser.js",
36
+ "index.js",
37
+ "lib/*.js",
38
+ "wrapper.mjs"
39
+ ],
40
+ "scripts": {
41
+ "test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
42
+ "integration": "mocha --throw-deprecation test/*.integration.js",
43
+ "lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
44
+ },
45
+ "peerDependencies": {
46
+ "bufferutil": "^4.0.1",
47
+ "utf-8-validate": ">=5.0.2"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "bufferutil": {
51
+ "optional": true
52
+ },
53
+ "utf-8-validate": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "devDependencies": {
58
+ "@eslint/js": "^10.0.1",
59
+ "benchmark": "^2.1.4",
60
+ "bufferutil": "^4.0.1",
61
+ "eslint": "^10.0.1",
62
+ "eslint-config-prettier": "^10.0.1",
63
+ "eslint-plugin-prettier": "^5.0.0",
64
+ "globals": "^17.0.0",
65
+ "mocha": "^8.4.0",
66
+ "nyc": "^15.0.0",
67
+ "prettier": "^3.0.0",
68
+ "utf-8-validate": "^6.0.0"
69
+ },
70
+ "allowScripts": {
71
+ "bufferutil": true,
72
+ "utf-8-validate": true
73
+ }
74
+ }
@@ -0,0 +1,21 @@
1
+ import createWebSocketStream from './lib/stream.js';
2
+ import extension from './lib/extension.js';
3
+ import PerMessageDeflate from './lib/permessage-deflate.js';
4
+ import Receiver from './lib/receiver.js';
5
+ import Sender from './lib/sender.js';
6
+ import subprotocol from './lib/subprotocol.js';
7
+ import WebSocket from './lib/websocket.js';
8
+ import WebSocketServer from './lib/websocket-server.js';
9
+
10
+ export {
11
+ createWebSocketStream,
12
+ extension,
13
+ PerMessageDeflate,
14
+ Receiver,
15
+ Sender,
16
+ subprotocol,
17
+ WebSocket,
18
+ WebSocketServer
19
+ };
20
+
21
+ export default WebSocket;
@@ -0,0 +1,396 @@
1
+ // 「卡在 starting、而且**看不出为什么**」的定位用例(2026-09-23 用户反馈)。
2
+ //
3
+ // 反馈原文(Windows,插件 0.6.10):
4
+ // 连接阶段: starting(正在启动 Bridge…) / 设备 ID:(未生成) / 运行环境: 已就绪 /
5
+ // bridge 进程: 未运行(pid=-,launchd state=-,崩溃循环=否) / 中继注册: 未注册 /
6
+ // 最近错误: **无** / 自动重试: 已尝试 6 次 → 「手机显示还没有绑定任何电脑,多次修复都没有解决」
7
+ //
8
+ // 生产遥测(近 14 天 Windows 427 台)显示这一层不是个例:
9
+ // 注册成功 152 / install_failed 138 / **bridge_started 却从未 registered 52** / 从未登录 60 / 只装了插件 25。
10
+ //
11
+ // 这份诊断本身**不足以**区分「三种都会长得一模一样」的机制,代码里三种都真实存在:
12
+ // ① 半装运行时:入口 dsh-setup.mjs 在、它 import 的 clients/… 不在 → runtimeReady 恒为 true,
13
+ // 插件再也不补装,插起来的 watcher 秒退(Cannot find module)→ 面板永远「运行环境已就绪 + starting」;
14
+ // ② 守护活着却拉不起 bridge:startBridgeDetached 只要读到活着的 `.dsh-watcher.pid` 就返回「已在运行」
15
+ // → 面板的「启动 / 立即重试 / 一键修复」与插件每轮自愈**全是 no-op**(这正是「多次修复都没解决」);
16
+ // 而 watcher 的判据是「dsh web 在线吗」,端口**写死 127.0.0.1:3080** → dsh web 不在默认端口时必然如此;
17
+ // ③ bridge 子进程起来就崩(例如缺依赖/端口冲突):面板只看到「进程未运行」,而 `bridge_exit` 这个
18
+ // 白名单 fail_code 从来没有调用方,`bridgeLogState` 也只认几种中文文案 → node 的崩溃栈不算「错误」。
19
+ //
20
+ // 本文件锁死四条不变量(前三条是修复,第四条是「下次这类反馈必须一次定位」):
21
+ // ① 运行环境就绪 = 入口**点名的文件真的都在**;半装 → 判未就绪 → 自动补装(自愈重新可用);
22
+ // ② 守护活着但迟迟没有 bridge → **真的重启它**(有阈值、有上限,正常窗口内不打扰);
23
+ // ③ 上游地址取自 dsh web 实际监听端口(并透传给 watcher),不再写死 3080;
24
+ // ④ 诊断信息给出守护态 / 上游态 / 缺文件 / 两个日志尾部,且手机号与令牌**先脱敏**再出机器。
25
+ import assert from "node:assert/strict";
26
+ import http from "node:http";
27
+ import { spawn } from "node:child_process";
28
+ import { chmodSync, existsSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs";
29
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import test from "node:test";
33
+ import { apply } from "../lib/index.js";
34
+
35
+ const HERE = path.dirname(new URL(import.meta.url).pathname);
36
+ const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8");
37
+
38
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
39
+ async function waitFor(fn, { timeout = 4000, step = 25 } = {}) {
40
+ const deadline = Date.now() + timeout;
41
+ for (;;) {
42
+ if (await fn()) return true;
43
+ if (Date.now() > deadline) return false;
44
+ await sleep(step);
45
+ }
46
+ }
47
+ const readOrEmpty = async (p) => { try { return await readFile(p, "utf8"); } catch { return ""; } };
48
+
49
+ /** 假 npx:只记参数,绝不做真实安装(否则用例会去真的装包)。 */
50
+ const FAKE_NPX = `#!/bin/sh
51
+ printf '%s\\n' "$*" >> "$DSH_TEST_NPX_LOG"
52
+ exit 0
53
+ `;
54
+ /** 假 pgrep:回放 $DSH_TEST_PGREP(模拟「守护/bridge 进程在跑」)。 */
55
+ const FAKE_PGREP = `#!/bin/sh
56
+ if [ -n "$DSH_TEST_PGREP" ]; then printf '%s\\n' "$DSH_TEST_PGREP"; exit 0; fi
57
+ exit 1
58
+ `;
59
+ const FAKE_PS = `#!/bin/sh
60
+ printf '1\\n'
61
+ exit 0
62
+ `;
63
+ /** 假 launchctl:**必须存在**,否则用例会去碰开发机上真实运行的 bridge 服务。exit 1 = 托管不了。 */
64
+ const FAKE_LAUNCHCTL = `#!/bin/sh
65
+ printf '%s\\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
66
+ exit 1
67
+ `;
68
+
69
+ /** 一个「内容像真的」的运行环境入口:它点名要求 clients/… 下的文件必须在(用于半装运行时用例)。 */
70
+ const REAL_ENTRY = `import { childStopped } from "./clients/dsh-remote/src/lifecycle.mjs";
71
+ const bridge = new URL("./clients/dsh-remote/dsh-bridge.mjs", import.meta.url);
72
+ export { childStopped, bridge };
73
+ `;
74
+
75
+ /**
76
+ * 假运行环境入口(用于「守护/上游」用例):它只把继承到的上游地址写出来,
77
+ * 于是用例可以断言插件**真的**把 DSH_BRIDGE_UPSTREAM 透传给了 watcher。
78
+ */
79
+ const ENV_ENTRY = `import { writeFileSync } from "node:fs";
80
+ writeFileSync(process.env.DSH_STUB_OUT, JSON.stringify({
81
+ upstream: process.env.DSH_BRIDGE_UPSTREAM || "",
82
+ args: process.argv.slice(2),
83
+ }));
84
+ `;
85
+
86
+ async function setup({ runtime = "stub", platform = "darwin" } = {}) {
87
+ const root = await mkdtemp(path.join(os.tmpdir(), "dsh-stuck-"));
88
+ const fakeHome = path.join(root, "home");
89
+ const fakeBin = path.join(root, "bin");
90
+ const relayDir = path.join(root, "relay");
91
+ await mkdir(fakeBin, { recursive: true });
92
+ await mkdir(fakeHome, { recursive: true });
93
+ await mkdir(relayDir, { recursive: true });
94
+ const npxLog = path.join(root, "npx.log");
95
+ await writeFile(npxLog, "");
96
+ // win32 分支找的是 npx.cmd(且优先 DSH_SETUP_NPX_DIR),POSIX 分支找 npx —— 两个都放上
97
+ for (const name of ["npx", "npx.cmd"]) {
98
+ await writeFile(path.join(fakeBin, name), FAKE_NPX);
99
+ await chmodSync(path.join(fakeBin, name), 0o755);
100
+ }
101
+ await writeFile(path.join(fakeBin, "pgrep"), FAKE_PGREP);
102
+ await writeFile(path.join(fakeBin, "ps"), FAKE_PS);
103
+ await writeFile(path.join(fakeBin, "launchctl"), FAKE_LAUNCHCTL);
104
+ for (const name of ["pgrep", "ps", "launchctl"]) await chmodSync(path.join(fakeBin, name), 0o755);
105
+ if (runtime === "stub") await writeFile(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub\n");
106
+ if (runtime === "entry-only") await writeFile(path.join(relayDir, "dsh-setup.mjs"), REAL_ENTRY);
107
+ if (runtime === "env") await writeFile(path.join(relayDir, "dsh-setup.mjs"), ENV_ENTRY);
108
+
109
+ const saved = {};
110
+ for (const k of ["HOME", "PATH", "DSH_TEST_NPX_LOG", "DSH_TEST_PGREP", "DSH_TEST_LAUNCH_LOG", "DSH_STUB_OUT", "DSH_RELAY_SKIP_SERVICE", "DSH_SETUP_NPX_DIR", "DSH_RELAY_WEDGE_MS",
111
+ "DSH_RELAY_PLATFORM", "DSH_REMOTE_TELEMETRY", "DSH_RELAY_DEFAULT_API", "DSH_BRIDGE_UPSTREAM",
112
+ "DSH_WEB_URL", "DSH_RELAY_DIR"]) saved[k] = process.env[k];
113
+ process.env.HOME = fakeHome;
114
+ process.env.PATH = `${fakeBin}:${saved.PATH || ""}`;
115
+ process.env.DSH_TEST_NPX_LOG = npxLog;
116
+ process.env.DSH_SETUP_NPX_DIR = fakeBin; // 假 npx(与 windows-compat 用例同一手法:绝不真装包)
117
+ process.env.DSH_TEST_LAUNCH_LOG = path.join(root, "launchctl.log");
118
+ delete process.env.DSH_TEST_PGREP;
119
+ delete process.env.DSH_RELAY_WEDGE_MS;
120
+ process.env.DSH_STUB_OUT = path.join(root, "stub-env.json");
121
+ process.env.DSH_REMOTE_TELEMETRY = "0"; // 用例绝不发匿名遥测
122
+ process.env.DSH_RELAY_DEFAULT_API = "http://127.0.0.1:1"; // 后台请求一律打到死端口
123
+ process.env.DSH_RELAY_PLATFORM = platform;
124
+ delete process.env.DSH_RELAY_SKIP_SERVICE; // 走真实分支(系统命令全是假的/不存在)
125
+ delete process.env.DSH_BRIDGE_UPSTREAM;
126
+ delete process.env.DSH_WEB_URL;
127
+ delete process.env.DSH_RELAY_DIR;
128
+
129
+ return {
130
+ root, fakeHome, relayDir, npxLog,
131
+ stubEnvFile: process.env.DSH_STUB_OUT,
132
+ async restore() {
133
+ for (const [k, v] of Object.entries(saved)) {
134
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
135
+ }
136
+ await rm(root, { recursive: true, force: true });
137
+ },
138
+ };
139
+ }
140
+
141
+ /** 装载插件路由(webServer 可带真实端口,模拟 dsh web 监听在非默认端口)。 */
142
+ function boot(relayDir, { port } = {}) {
143
+ const routes = new Map();
144
+ const disposers = [];
145
+ apply({
146
+ webServer: { ...(port ? { port } : {}), register(route) { routes.set(route.path, route.handler); return () => {}; } },
147
+ effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
148
+ logger: { info() {}, warn() {} },
149
+ }, { relayDir });
150
+ routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* 忽略 */ } } };
151
+ return routes;
152
+ }
153
+
154
+ async function serve(routes) {
155
+ const host = http.createServer((req, res) => {
156
+ const handler = routes.get(new URL(req.url, "http://x").pathname);
157
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
158
+ });
159
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
160
+ return { host, base: `http://127.0.0.1:${host.address().port}` };
161
+ }
162
+
163
+ async function writeConfig(relayDir, extra = {}) {
164
+ await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
165
+ phone: "13800000000", password: "pw", device_id: "",
166
+ api_url: "http://127.0.0.1:1", ...extra,
167
+ }));
168
+ }
169
+
170
+ const bridgeStatus = async (base) => (await (await fetch(`${base}/dsh-remote/bridge-status`)).json());
171
+ const connectRetry = async (base) => (await (await fetch(`${base}/dsh-remote/connect/retry`, { method: "POST" })).json());
172
+
173
+ /** 起一个「活着但不是我们的 bridge」的进程,冒充卡死的守护。 */
174
+ function startFakeWatcher() {
175
+ const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { stdio: "ignore", windowsHide: true });
176
+ return {
177
+ pid: child.pid,
178
+ stop() { try { child.kill("SIGKILL"); } catch { /* 已退出 */ } },
179
+ };
180
+ }
181
+
182
+ // ───────────────────── ① 半装运行时:入口在、依赖不在 ─────────────────────
183
+
184
+ test("半装运行环境(入口在、依赖不在)必须判为未就绪并自动补装,绝不显示成「已就绪」", async () => {
185
+ const env = await setup({ runtime: "entry-only", platform: "win32" });
186
+ const routes = boot(env.relayDir);
187
+ const { host, base } = await serve(routes);
188
+ try {
189
+ await writeConfig(env.relayDir);
190
+
191
+ const first = await bridgeStatus(base);
192
+ assert.equal(first.connect.runtimeReady, false, "入口在但依赖缺失 → 绝不能报「运行环境已就绪」");
193
+ // 清单必须来自**入口脚本自己点名**的文件(import 的 lifecycle.mjs 与它引用的 dsh-bridge.mjs),
194
+ // 不是写死的一张表 —— 后者会随版本漂移,把好机器误判成坏机器。
195
+ assert.deepEqual([...first.connect.runtimeMissing].sort(),
196
+ ["clients/dsh-remote/dsh-bridge.mjs", "clients/dsh-remote/src/lifecycle.mjs"],
197
+ "缺的必须是入口脚本点名的那些文件(不能写死一张表)");
198
+ assert.ok(["no_runtime", "installing"].includes(first.connect.phase),
199
+ "阶段应落在「准备运行环境」,实际 " + first.connect.phase);
200
+ assert.match(first.connect.detail, /运行环境不完整/, "要如实说是「不完整」,而不是让它看起来像在正常安装");
201
+ // 关键:半装运行时**必须**触发补装(旧实现里 runtimeReady 恒为 true → 自愈从此停摆)
202
+ assert.ok(await waitFor(async () => (await readOrEmpty(env.npxLog)).includes("@mrrisega/dsh-remote")),
203
+ "半装运行环境必须走自动补装,实际 npx 日志:" + (await readOrEmpty(env.npxLog)));
204
+
205
+ const st = await (await fetch(`${base}/dsh-remote/status`)).json();
206
+ assert.equal(st.service.runtimeReady, false, "/status 也必须如实报未就绪(面板两个入口不能互相矛盾)");
207
+ assert.match(String(first.connect.diagnostics), /运行环境缺文件: clients\/dsh-remote\/src\/lifecycle\.mjs/,
208
+ "诊断信息要直接点出缺哪个文件");
209
+
210
+ // 依赖补齐(= 补装完成)→ 立刻恢复就绪,不再重复补装
211
+ await mkdir(path.join(env.relayDir, "clients", "dsh-remote", "src"), { recursive: true });
212
+ await writeFile(path.join(env.relayDir, "clients", "dsh-remote", "src", "lifecycle.mjs"), "export const childStopped = () => true;\n");
213
+ await writeFile(path.join(env.relayDir, "clients", "dsh-remote", "dsh-bridge.mjs"), "// bridge stub\n");
214
+ const npxBefore = await readOrEmpty(env.npxLog);
215
+ const after = await bridgeStatus(base);
216
+ assert.equal(after.connect.runtimeReady, true, "依赖补齐后必须立刻恢复就绪");
217
+ assert.equal(after.connect.phase, "starting", "就绪后应回到「正在启动 Bridge」(本用例没有真 bridge)");
218
+ assert.equal(await readOrEmpty(env.npxLog), npxBefore, "已就绪不得再触发补装");
219
+ } finally { host.close(); routes.dispose(); await env.restore(); }
220
+ });
221
+
222
+ // ───────────────────── ② 守护活着却拉不起 bridge ─────────────────────
223
+
224
+ test("win32:守护活着却迟迟拉不起 bridge → 真的重启它(旧实现每次都 no-op,这正是「多次修复都没解决」)", async () => {
225
+ const env = await setup({ runtime: "stub", platform: "win32" });
226
+ const routes = boot(env.relayDir);
227
+ const { host, base } = await serve(routes);
228
+ const fake = startFakeWatcher();
229
+ try {
230
+ await writeConfig(env.relayDir);
231
+ const pidFile = path.join(env.relayDir, ".dsh-watcher.pid");
232
+ await writeFile(pidFile, String(fake.pid));
233
+ const old = Date.now() / 1000 - 180; // 守护已经活了 3 分钟
234
+ utimesSync(pidFile, old, old);
235
+
236
+ const r = await connectRetry(base); // 用户点了「立即重试 / 一键修复」
237
+ assert.equal(r.action, "restart-wedged-watcher",
238
+ "守护活着但 3 分钟没拉起 bridge:重试必须真的重启它,实际 " + r.action);
239
+ assert.match(await readOrEmpty(path.join(env.relayDir, ".dsh-setup-install.log")),
240
+ /后台守护已存活 180 秒仍无 bridge 进程/, "要留下可回查的记录(含上游地址)");
241
+ assert.ok(await waitFor(async () => Number(await readOrEmpty(pidFile)) !== fake.pid),
242
+ "必须真的重新拉起(pid 文件被新守护覆盖),实际仍是 " + (await readOrEmpty(pidFile)));
243
+ } finally { fake.stop(); host.close(); routes.dispose(); await env.restore(); }
244
+ });
245
+
246
+ test("win32:守护刚起来(正常启动窗口内)不得被误重启", async () => {
247
+ const env = await setup({ runtime: "stub", platform: "win32" });
248
+ const routes = boot(env.relayDir);
249
+ const { host, base } = await serve(routes);
250
+ const fake = startFakeWatcher();
251
+ try {
252
+ await writeConfig(env.relayDir);
253
+ const pidFile = path.join(env.relayDir, ".dsh-watcher.pid");
254
+ await writeFile(pidFile, String(fake.pid)); // mtime = 现在(刚拉起)
255
+
256
+ const r = await connectRetry(base);
257
+ assert.notEqual(r.action, "restart-wedged-watcher", "刚起来就重启会把正常启动流程打断,实际 " + r.action);
258
+ assert.equal(Number(await readOrEmpty(pidFile)), fake.pid, "pid 文件不得被改写");
259
+ assert.ok(!/后台守护已存活/.test(await readOrEmpty(path.join(env.relayDir, ".dsh-setup-install.log"))));
260
+ } finally { fake.stop(); host.close(); routes.dispose(); await env.restore(); }
261
+ });
262
+
263
+ test("登录限流窗口内:如实说明在等限流,不判错、也不重启守护", async () => {
264
+ const env = await setup({ runtime: "stub", platform: "win32" });
265
+ const routes = boot(env.relayDir);
266
+ const { host, base } = await serve(routes);
267
+ const fake = startFakeWatcher();
268
+ try {
269
+ await writeConfig(env.relayDir);
270
+ const pidFile = path.join(env.relayDir, ".dsh-watcher.pid");
271
+ await writeFile(pidFile, String(fake.pid));
272
+ const old = Date.now() / 1000 - 600;
273
+ utimesSync(pidFile, old, old);
274
+ await writeFile(path.join(env.relayDir, ".dsh-login-ratelimited"), String(Date.now() + 600_000));
275
+
276
+ const r = await connectRetry(base);
277
+ assert.equal(r.connect.phase, "starting", "限流是等待,不是错误");
278
+ assert.match(r.connect.detail, /限流/, "必须说清在等服务端限流窗口(否则用户只能反复改密码)");
279
+ assert.notEqual(r.action, "restart-wedged-watcher", "限流期间重启守护没有任何意义");
280
+ } finally { fake.stop(); host.close(); routes.dispose(); await env.restore(); }
281
+ });
282
+
283
+ // ───────────────────── ③ 上游端口:取自 dsh web 实际监听端口 ─────────────────────
284
+
285
+ test("上游地址取自 dsh web 实际监听端口,并透传给 watcher(不再写死 3080)", async () => {
286
+ const env = await setup({ runtime: "env", platform: "win32" });
287
+ const upstream = http.createServer((_req, res) => res.end("dsh web"));
288
+ await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve));
289
+ const webPort = upstream.address().port;
290
+ const routes = boot(env.relayDir, { port: webPort });
291
+ const { host, base } = await serve(routes);
292
+ try {
293
+ await writeConfig(env.relayDir);
294
+ const r = await bridgeStatus(base);
295
+ assert.equal(r.connect.upstreamUrl, `http://127.0.0.1:${webPort}`, "上游必须是 dsh web 真正监听的端口");
296
+ assert.equal(r.connect.upstreamSource, "dsh web 实际监听端口");
297
+ assert.equal(r.connect.upstreamReachable, true, "上游在听 → 诊断应报可达");
298
+
299
+ assert.ok(await waitFor(async () => existsSync(env.stubEnvFile)), "应真的把 watcher 拉起来");
300
+ const seen = JSON.parse(await readOrEmpty(env.stubEnvFile) || "{}");
301
+ assert.equal(seen.upstream, `http://127.0.0.1:${webPort}`,
302
+ "上游端口必须透传给 watcher(它用它判断 dsh web 在线、bridge 用它转发),实际 " + seen.upstream);
303
+ assert.equal((await readOrEmpty(path.join(env.relayDir, ".dsh-upstream"))).trim(),
304
+ `http://127.0.0.1:${webPort}`, "开机自启拉起的守护没有 env,只能靠这个文件拿到端口");
305
+ } finally { upstream.close(); host.close(); routes.dispose(); await env.restore(); }
306
+ });
307
+
308
+ test("取不到实际端口时:回落到 3080 且如实标注来源,上游不通时把原因写进 detail", async () => {
309
+ const env = await setup({ runtime: "stub", platform: "win32" });
310
+ const routes = boot(env.relayDir); // 不传 port,也没有 DSH_WEB_URL
311
+ const { host, base } = await serve(routes);
312
+ try {
313
+ await writeConfig(env.relayDir);
314
+ const r = await bridgeStatus(base);
315
+ assert.equal(r.connect.upstreamUrl, "http://127.0.0.1:3080");
316
+ assert.match(r.connect.upstreamSource, /默认值 3080/, "回落到默认值时必须说明是回落,不能装作是实测端口");
317
+ // 用例环境里 3080 上没有 dsh web(或即使有也断言「探测结论一定被写出来」)
318
+ assert.equal(typeof r.connect.upstreamReachable, "boolean");
319
+ if (r.connect.upstreamReachable === false) {
320
+ assert.match(r.connect.detail, /探测不通/, "bridge 起不来时,上游不通必须出现在面板文案里(旧版这里是空白)");
321
+ }
322
+ } finally { host.close(); routes.dispose(); await env.restore(); }
323
+ });
324
+
325
+ // ───────────────────── ④ 诊断信息:说清卡在哪 + 脱敏 ─────────────────────
326
+
327
+ test("诊断信息:守护态 / 上游态 / 缺文件 / 两个日志尾部都带上,且手机号与令牌先脱敏", async () => {
328
+ const env = await setup({ runtime: "stub", platform: "win32" });
329
+ const routes = boot(env.relayDir);
330
+ const { host, base } = await serve(routes);
331
+ const fake = startFakeWatcher();
332
+ try {
333
+ await writeConfig(env.relayDir);
334
+ await writeFile(path.join(env.relayDir, ".dsh-watcher.pid"), String(fake.pid));
335
+ await writeFile(path.join(env.relayDir, ".dsh-bridge.log"), [
336
+ "[bridge] 用账号 13800000000 登录换取 JWT...",
337
+ "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abcdefghijklmnop",
338
+ '{"password":"MySecretPassw0rd"}',
339
+ "Error: Cannot find module '/Users/Scarlet/.dsh-remote/clients/dsh-remote/dsh-bridge.mjs'",
340
+ ].join("\n") + "\n");
341
+ await writeFile(path.join(env.relayDir, ".dsh-setup-install.log"),
342
+ "[dsh-remote-web] 自动启动 bridge 未成功(not-installed): 运行环境入口脚本不存在\n");
343
+
344
+ const r = await bridgeStatus(base);
345
+ const diag = String(r.connect.diagnostics);
346
+ assert.match(diag, /后台守护: 在运行(pid=/, "守护态必须可见(旧版只报 bridge 进程,守护是隐身的)");
347
+ assert.match(diag, /上游 dsh web: http:\/\/127\.0\.0\.1:3080(默认值 3080/, "上游与来源必须可见");
348
+ assert.match(diag, /连接阶段: starting/, "本用例复现的正是用户那份「卡在 starting」的现场");
349
+ assert.match(diag, /运行环境: 已就绪/, "(缺文件那一行由半装运行时用例单独锁死)");
350
+ assert.match(diag, /--- bridge 日志(末尾)---/, "必须带上 bridge 日志尾部");
351
+ assert.match(diag, /--- 安装日志(末尾)---/, "必须带上安装日志尾部");
352
+ assert.match(diag, /Cannot find module/, "真实崩溃原因必须原样带上(旧版只说「最近错误: 无」)");
353
+ // 隐私:这份文本用户会贴到群里/发给客服 → 先脱敏再出机器
354
+ assert.ok(!diag.includes("13800000000"), "手机号必须脱敏");
355
+ assert.match(diag, /138\*\*\*\*0000/);
356
+ assert.ok(!diag.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), "令牌必须隐去");
357
+ assert.ok(!diag.includes("MySecretPassw0rd"), "密码字段必须隐去");
358
+ } finally { fake.stop(); host.close(); routes.dispose(); await env.restore(); }
359
+ });
360
+
361
+ // ───────────────────── ⑤ 运行时侧的源级契约(桥的另一半) ─────────────────────
362
+
363
+ test("运行时 dsh-setup.mjs:上游端口只有一处真相源,watcher/子进程/探测共用它", () => {
364
+ assert.ok(/function upstreamUrl\(\)/.test(SETUP_SRC), "必须有统一的上游解析函数");
365
+ // 环境变量 > 插件留下的端口文件 > DSH_WEB_URL > 3080
366
+ const fn = SETUP_SRC.slice(SETUP_SRC.indexOf("function upstreamUrl()"), SETUP_SRC.indexOf("function upstreamPort()"));
367
+ assert.ok(/DSH_BRIDGE_UPSTREAM/.test(fn) && /UPSTREAM_FILE/.test(fn) && /DSH_WEB_URL/.test(fn),
368
+ "上游优先级必须是 env > 端口文件 > DSH_WEB_URL > 默认");
369
+ // 不许再有「写死 3080 的 fetch / netstat / lsof / 命令行」(注释除外)
370
+ const code = SETUP_SRC.split("\n").filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join("\n");
371
+ assert.ok(!/fetch\("http:\/\/127\.0\.0\.1:3080/.test(code), "探测不得写死 3080(dsh web 可能在别的端口)");
372
+ assert.ok(!/findstr ":3080"|iTCP:3080/.test(code), "按端口找 dsh web 进程也不得写死 3080");
373
+ assert.ok(/fetch\(upstreamUrl\(\) \+ "\/"\)/.test(code), "watcher 的在线判据必须用 upstreamUrl()");
374
+ assert.ok(/DSH_BRIDGE_UPSTREAM: upstreamUrl\(\)/.test(code), "必须把上游显式透传给 bridge 子进程");
375
+ // 半装运行时的根因之一:本安装器自己 import 的 src/lifecycle.mjs 必须在同步表里
376
+ assert.ok(/"src\/lifecycle\.mjs"/.test(SETUP_SRC), "RUNTIME_CLIENT_FILES 必须包含 src/lifecycle.mjs(否则会漏同步出半装运行时)");
377
+ });
378
+
379
+ test("无 pid 文件的守护(0.6.7 之前的运行时/文件被删)也能算出年龄并按同样规则重启", async () => {
380
+ const env = await setup({ runtime: "stub", platform: "darwin" });
381
+ const routes = boot(env.relayDir);
382
+ const { host, base } = await serve(routes);
383
+ process.env.DSH_RELAY_WEDGE_MS = "200"; // 仅供测试把节奏压到亚秒级(生产不设置)
384
+ try {
385
+ await writeConfig(env.relayDir);
386
+ // 只有 pgrep 能看到守护(没有 .dsh-watcher.pid)→ 年龄只能来自「本进程第一次看到它」的时刻
387
+ process.env.DSH_TEST_PGREP = "4242 dsh-setup.mjs run";
388
+ await connectRetry(base);
389
+ await sleep(260);
390
+ const r = await connectRetry(base);
391
+ assert.equal(r.action, "restart-wedged-watcher",
392
+ "没有 pid 文件时也必须能判定卡死(否则这类机器永远无人救),实际 " + r.action);
393
+ } finally {
394
+ host.close(); routes.dispose(); await env.restore();
395
+ }
396
+ });