@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.2

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 (29) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +148 -9
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  5. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  6. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  7. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
  8. package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
  9. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  10. package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
  11. package/clients/dsh-remote/wechat-channel.mjs +2705 -0
  12. package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
  13. package/docs/telemetry.md +31 -2
  14. package/dsh-setup.mjs +134 -40
  15. package/package.json +6 -5
  16. package/packages/dsh-remote-web/lib/client.js +799 -65
  17. package/packages/dsh-remote-web/lib/index.js +460 -10
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  20. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  21. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  22. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  23. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  24. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  25. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  26. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  27. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
  28. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  29. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
@@ -1,7 +1,122 @@
1
1
  import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ import { mkdtemp, rm } from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
2
7
  import test from "node:test";
3
- import { childStopped } from "../src/lifecycle.mjs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { childStopped, stopChildGracefully } from "../src/lifecycle.mjs";
10
+
11
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
12
+ const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8");
13
+ const BRIDGE_SRC = readFileSync(path.join(HERE, "..", "dsh-bridge.mjs"), "utf8");
4
14
 
5
15
  test("被信号终止的 bridge 子进程可以重新拉起", () => {
6
16
  assert.equal(childStopped({ exitCode: null, signalCode: "SIGTERM" }), true);
7
17
  });
18
+
19
+ // ───────── 优雅停机:Windows 上 kill() 跑不到退出钩子,必须靠 IPC 请退 ─────────
20
+
21
+ /** 假子进程:可控地「收到消息后自己退出」/「赖着不退」/「没有 IPC 通道」。 */
22
+ function fakeChild({ connected = true, exitAfterMs = null, sendThrows = false, stopped = false } = {}) {
23
+ const calls = { sent: [], killed: 0 };
24
+ const child = {
25
+ exitCode: stopped ? 0 : null,
26
+ signalCode: null,
27
+ connected,
28
+ send(m) {
29
+ if (sendThrows) throw new Error("channel closed");
30
+ calls.sent.push(m);
31
+ if (exitAfterMs !== null) setTimeout(() => { child.exitCode = 0; }, exitAfterMs);
32
+ },
33
+ kill() { calls.killed += 1; child.exitCode = 0; },
34
+ };
35
+ return { child, calls };
36
+ }
37
+
38
+ test("优雅停机:有 IPC 通道时先请退,子进程自己退了就不强杀", async () => {
39
+ const { child, calls } = fakeChild({ exitAfterMs: 10 });
40
+ const how = await stopChildGracefully(child, { timeoutMs: 1000, intervalMs: 5 });
41
+ assert.equal(how, "graceful", "子进程自己退出应报 graceful");
42
+ assert.deepEqual(calls.sent, [{ type: "shutdown" }], "必须先发约定好的 shutdown 消息");
43
+ assert.equal(calls.killed, 0, "已经优雅退出就绝不能再去 kill(Windows 上 kill 会跳过它的收尾)");
44
+ });
45
+
46
+ test("优雅停机:子进程赖着不退 → 超时后强杀,并如实报 killed", async () => {
47
+ const { child, calls } = fakeChild({ exitAfterMs: null });
48
+ const how = await stopChildGracefully(child, { timeoutMs: 120, intervalMs: 20 });
49
+ assert.equal(how, "killed", "超时必须如实报 killed(不能假装优雅)");
50
+ assert.deepEqual(calls.sent, [{ type: "shutdown" }], "仍应先礼后兵");
51
+ assert.equal(calls.killed, 1, "超时后应强杀一次");
52
+ });
53
+
54
+ test("优雅停机:没有 IPC 通道(旧进程/别的拉起方式)→ 直接强杀,不假装优雅", async () => {
55
+ const { child, calls } = fakeChild({ connected: false });
56
+ const how = await stopChildGracefully(child, { timeoutMs: 50 });
57
+ assert.equal(how, "killed");
58
+ assert.equal(calls.sent.length, 0, "没有通道就不该尝试 send");
59
+ assert.equal(calls.killed, 1);
60
+ });
61
+
62
+ test("优雅停机:send 抛错(通道刚断)→ 退回强杀,绝不把异常抛给调用方", async () => {
63
+ const { child, calls } = fakeChild({ sendThrows: true });
64
+ const how = await stopChildGracefully(child, { timeoutMs: 50 });
65
+ assert.equal(how, "killed");
66
+ assert.equal(calls.killed, 1, "send 失败必须退回 kill,否则子进程会变成孤儿");
67
+ });
68
+
69
+ test("优雅停机:已退出/null 不产生任何副作用", async () => {
70
+ const { child, calls } = fakeChild({ stopped: true });
71
+ assert.equal(await stopChildGracefully(child), "already-stopped");
72
+ assert.equal(calls.killed, 0);
73
+ assert.equal(calls.sent.length, 0);
74
+ assert.equal(await stopChildGracefully(null), "none");
75
+ assert.equal(await stopChildGracefully(undefined), "none");
76
+ });
77
+
78
+ test("契约:watcher 必须给 bridge 开 IPC 通道,且不能再裸 kill()", () => {
79
+ // 这两条是本修复的**接线**:少了任何一条,Windows 上的 notifystop 又会静默失效。
80
+ assert.match(SETUP_SRC, /stdio: \["inherit", "inherit", "inherit", "ipc"\]/,
81
+ "watcher 生成 bridge 时必须带 IPC 通道(第 4 个 fd),否则请退消息发不出去");
82
+ assert.ok(!/bridgeProc\.kill\(\)/.test(SETUP_SRC),
83
+ "watcher 不得再直接 bridgeProc.kill()(Windows 上会跳过 bridge 的退出钩子)");
84
+ assert.match(SETUP_SRC, /await stopChildGracefully\(bridgeProc\)/,
85
+ "停止 bridge 必须走优雅停机");
86
+ assert.match(BRIDGE_SRC, /process\.on\("message"/,
87
+ "bridge 必须监听 IPC 消息,否则 watcher 请退没人应答");
88
+ assert.match(BRIDGE_SRC, /m\.type === "shutdown"[\s\S]{0,40}shutdown\(/,
89
+ "bridge 收到 shutdown 消息必须走与信号相同的优雅退出逻辑");
90
+ });
91
+
92
+ test("真实子进程:IPC 请退能跑完异步收尾再退出(不是假对象里的空转)", async () => {
93
+ // 上面几条用的是假 child,只能证明"逻辑分支对"。这条起一个**真 node 子进程**,
94
+ // 证明「IPC 通道 + 请退消息 + 子进程跑完异步收尾后退出」这条链路真的成立 ——
95
+ // 而这正是 Windows 上 notifystop 唯一的执行机会。
96
+ const dir = await mkdtemp(path.join(os.tmpdir(), "dsh-lifecycle-"));
97
+ const mark = path.join(dir, "flushed.txt");
98
+ const script = `
99
+ const fs = require("node:fs");
100
+ process.on("message", async (m) => {
101
+ if (!m || m.type !== "shutdown") return;
102
+ await new Promise((r) => setTimeout(r, 50)); // 模拟 notifystop 这类异步收尾
103
+ fs.writeFileSync(process.env.DSH_MARK, "flushed");
104
+ process.exit(0);
105
+ });
106
+ setInterval(() => {}, 1000); // 保持存活等消息
107
+ `;
108
+ const child = spawn(process.execPath, ["-e", script], {
109
+ env: { ...process.env, DSH_MARK: mark },
110
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
111
+ });
112
+ try {
113
+ if (!child.pid) await new Promise((r) => child.on("spawn", r));
114
+ const how = await stopChildGracefully(child, { timeoutMs: 3000, intervalMs: 20 });
115
+ assert.equal(how, "graceful", "真实子进程应在收到 shutdown 后自己退出");
116
+ assert.equal(readFileSync(mark, "utf8"), "flushed",
117
+ "异步收尾必须真的跑完 —— 少了 IPC 通道这一步在 Windows 上就永远做不到");
118
+ } finally {
119
+ try { child.kill(); } catch { /* 已退出 */ }
120
+ await rm(dir, { recursive: true, force: true });
121
+ }
122
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Windows 机器指纹回归。
3
+ *
4
+ * 背景:machineUniqueId() 原本只有 darwin(ioreg) 与 linux(machine-id) 两条分支,
5
+ * Windows 上恒返回 "",于是 machineFingerprint() 退化成 `${hostname}|${platform}` 的哈希 ——
6
+ * 用户**改一次主机名就被服务端当成一台新设备**:旧设备记录不会被顶替,
7
+ * 设备列表里堆出幽灵条目,面板上"这台机器"也会对不上。
8
+ *
9
+ * 这些分支在 macOS 开发机上跑不到,所以这里把函数从源码里抠出来,
10
+ * 注入假 process / 假 execSync / 假 spawnSync / 假 fs,把 win32 分支**真正执行一遍**,
11
+ * 而不是只看源码长得像不像。
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { readFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ import test from "node:test";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
20
+ const BRIDGE_SRC = readFileSync(path.join(HERE, "..", "dsh-bridge.mjs"), "utf8");
21
+
22
+ /** 真实 `reg query ... /v MachineGuid` 的输出形态:CRLF 行尾、键名与值之间是空格。 */
23
+ const REG_OUT = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography\r\n"
24
+ + " MachineGuid REG_SZ 9f8a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b\r\n\r\n";
25
+ const GUID = "9f8a1c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b";
26
+
27
+ /**
28
+ * 把 machineUniqueId 抠出来执行。切片锚点一旦漂移会立刻在下面第一条断言里暴露。
29
+ */
30
+ function loadMachineUniqueId({
31
+ platform,
32
+ env = {},
33
+ reg = { status: 0, stdout: REG_OUT },
34
+ regThrows = false,
35
+ ioreg = "",
36
+ machineIdFiles = {},
37
+ } = {}) {
38
+ const from = BRIDGE_SRC.indexOf("function machineUniqueId()");
39
+ const to = BRIDGE_SRC.indexOf("/** 稳定指纹:");
40
+ assert.ok(from > -1 && to > from, "未能从 dsh-bridge.mjs 切出 machineUniqueId(锚点已漂移)");
41
+ const src = BRIDGE_SRC.slice(from, to);
42
+ assert.ok(!/稳定指纹/.test(src), "切片不应越过 machineUniqueId 的边界");
43
+
44
+ const calls = [];
45
+ const execCalls = [];
46
+ const fakeSpawnSync = (cmd, args, opts) => {
47
+ calls.push({ cmd, args, opts });
48
+ if (regThrows) throw new Error("spawn EPERM");
49
+ return reg;
50
+ };
51
+ const fakeExecSync = (cmd) => { execCalls.push(String(cmd)); return ioreg; };
52
+ const fakeFs = {
53
+ readFileSync: (f) => {
54
+ if (f in machineIdFiles) return machineIdFiles[f];
55
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
56
+ },
57
+ };
58
+ const make = new Function("process", "execSync", "spawnSync", "fs", "os",
59
+ `${src}\nreturn machineUniqueId;`);
60
+ const fn = make({ platform, env }, fakeExecSync, fakeSpawnSync, fakeFs, {});
61
+ return { fn, calls, execCalls };
62
+ }
63
+
64
+ test("win32:机器指纹取注册表 MachineGuid(改主机名不再被当成新设备)", () => {
65
+ const { fn } = loadMachineUniqueId({ platform: "win32" });
66
+ assert.equal(fn(), GUID, "应从 reg query 输出里解析出 MachineGuid");
67
+ });
68
+
69
+ test("win32:读注册表走 spawnSync 数组传参 + windowsHide(不经 cmd.exe、不弹窗)", () => {
70
+ const { fn, calls } = loadMachineUniqueId({ platform: "win32" });
71
+ fn();
72
+ assert.equal(calls.length, 1, "只应调用一次 reg");
73
+ const [c] = calls;
74
+ assert.equal(c.cmd, "reg", "应使用 reg.exe(恒定存在、约 20ms),而不是 PowerShell/WMI");
75
+ assert.deepEqual(c.args, ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
76
+ "参数必须走数组(不经 cmd.exe,故没有引号/空格/中文路径的转义坑)");
77
+ assert.equal(c.opts.windowsHide, true, "必须 windowsHide:否则 Windows 上会闪一个黑色命令行窗口");
78
+ assert.ok(!c.opts.shell, "不得启用 shell");
79
+ assert.ok(Number.isFinite(c.opts.timeout), "必须有超时,不能把启动卡死");
80
+ });
81
+
82
+ test("win32:注册表读不到时返回空串,让上层退回宿主名指纹(不抛异常)", () => {
83
+ // 这三种都是真实可能的:非 Windows 上不该走到这条分支、受限账户、以及被安全软件拦截。
84
+ const failed = loadMachineUniqueId({ platform: "win32", reg: { status: 1, stdout: "", stderr: "拒绝访问" } });
85
+ assert.equal(failed.fn(), "", "reg 退出码非 0 → 空串");
86
+ const garbled = loadMachineUniqueId({ platform: "win32", reg: { status: 0, stdout: "乱七八糟" } });
87
+ assert.equal(garbled.fn(), "", "输出不可解析 → 空串");
88
+ const thrown = loadMachineUniqueId({ platform: "win32", regThrows: true });
89
+ assert.equal(thrown.fn(), "", "spawn 抛错必须被吞掉(机器指纹不是关键路径,不能拖垮启动)");
90
+ });
91
+
92
+ test("机器指纹:显式环境变量覆盖在所有平台优先", () => {
93
+ for (const platform of ["win32", "darwin", "linux"]) {
94
+ const { fn } = loadMachineUniqueId({ platform, env: { DSH_BRIDGE_MACHINE_FP: "override-me" } });
95
+ assert.equal(fn(), "override-me", `${platform} 上 DSH_BRIDGE_MACHINE_FP 应优先`);
96
+ }
97
+ });
98
+
99
+ test("机器指纹:darwin/linux 两条老分支没有被改坏", () => {
100
+ const darwin = loadMachineUniqueId({
101
+ platform: "darwin",
102
+ ioreg: ' "IOPlatformUUID" = "AAAA-BBBB-CCCC"\n',
103
+ });
104
+ assert.equal(darwin.fn(), "AAAA-BBBB-CCCC", "darwin 仍读 ioreg 的 IOPlatformUUID");
105
+ assert.match(darwin.execCalls[0], /^\/usr\/sbin\/ioreg /,
106
+ "★必须用绝对路径调 ioreg:launchd 的 plist PATH 不含 /usr/sbin,用裸名会 command not found,",
107
+ );
108
+
109
+ const linux = loadMachineUniqueId({
110
+ platform: "linux",
111
+ machineIdFiles: { "/etc/machine-id": "0123456789abcdef\n" },
112
+ });
113
+ assert.equal(linux.fn(), "0123456789abcdef", "linux 仍读 /etc/machine-id(并去掉换行)");
114
+ });
115
+
116
+ test("★回归:子进程一律用绝对路径优先,不赌 PATH(launchd 的 PATH 不含 /usr/sbin 的真实事故)", () => {
117
+ // 事故(2026-09-22 真机日志):bridge 由 launchd 拉起,plist 给的 PATH 是
118
+ // /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin —— 没有 /usr/sbin,而 ioreg 在那里。
119
+ // 日志里出现 `/bin/sh: ioreg: command not found`,指纹**静默**退化成 hostname 哈希:
120
+ // 用户改一次主机名就被服务端当成新设备。这类"PATH 假设"必须用绝对路径堵死。
121
+ const mac = loadMachineUniqueId({ platform: "darwin", ioreg: 'x "IOPlatformUUID" = "U1"\n' });
122
+ mac.fn();
123
+ assert.match(mac.execCalls[0], /^\/usr\/sbin\/ioreg /, "darwin:首个候选必须是 /usr/sbin/ioreg");
124
+
125
+ // 绝对路径取不到时仍要退回裸名(别把别的机型的 macOS 弄死)
126
+ const macFallback = loadMachineUniqueId({ platform: "darwin", ioreg: "" });
127
+ assert.equal(macFallback.fn(), "", "两次都拿不到 → 空串(不是抛)");
128
+ assert.equal(macFallback.execCalls.length, 2, "应依次尝试绝对路径与裸名");
129
+ assert.match(macFallback.execCalls[1], /^ioreg /, "第二个候选是裸名");
130
+
131
+ // Windows 同理:SystemRoot 可用时优先 System32 的绝对路径
132
+ const win = loadMachineUniqueId({ platform: "win32", env: { SystemRoot: "C:\\Windows" } });
133
+ win.fn();
134
+ assert.equal(win.calls[0].cmd, "C:\\Windows\\System32\\reg.exe", "win32:应优先 System32 下的绝对路径");
135
+ assert.deepEqual(win.calls[0].args, ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]);
136
+ assert.equal(win.calls[0].opts.windowsHide, true, "绝对路径候选同样必须 windowsHide");
137
+ });
138
+
139
+ test("机器指纹:拿不到机器唯一值时才退回宿主名哈希(这正是要修的退化路径)", () => {
140
+ // 断言 machineFingerprint 的兜底表达式仍然存在 —— 它是"改主机名 = 新设备"的直接来源,
141
+ // 修好 win32 分支后这条兜底只应在容器等极端环境生效。
142
+ const i = BRIDGE_SRC.indexOf("function machineFingerprint()");
143
+ assert.ok(i > -1, "应能找到 machineFingerprint");
144
+ const body = BRIDGE_SRC.slice(i, i + 400);
145
+ assert.match(body, /machineUniqueId\(\) \|\| `\$\{os\.hostname\(\)\}\|\$\{os\.platform\(\)\}`/,
146
+ "兜底应保持 hostname|platform 的形态(改了这里等于改了设备识别语义)");
147
+ assert.match(body, /sha256/, "指纹必须哈希后再用(机器唯一值原文不得外发)");
148
+ });