@mrrisega/dsh-remote 0.6.17 → 0.6.18-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 (33) hide show
  1. package/clients/dsh-remote/e2ee-shim-script.js +203 -11
  2. package/clients/dsh-remote/mobile-adapter.mjs +404 -29
  3. package/clients/dsh-remote/test/e2ee-shim.test.mjs +43 -1
  4. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +145 -4
  5. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +95 -6
  6. package/clients/dsh-remote/wechat-runtime.mjs +25 -2
  7. package/docs/telemetry.md +7 -1
  8. package/dsh-setup.mjs +152 -3
  9. package/package.json +1 -1
  10. package/packages/dsh-remote-web/lib/client.js +754 -39
  11. package/packages/dsh-remote-web/lib/index.js +279 -6
  12. package/packages/dsh-remote-web/package.json +1 -1
  13. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +203 -11
  14. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +404 -29
  15. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
  16. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +152 -3
  17. package/packages/dsh-remote-web/test/account-switch-device.test.mjs +20 -2
  18. package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +310 -0
  19. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +524 -0
  20. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +4 -1
  21. package/packages/dsh-remote-web/test/diag-report.test.mjs +4 -1
  22. package/packages/dsh-remote-web/test/harness-restart.test.mjs +5 -1
  23. package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
  24. package/packages/dsh-remote-web/test/launchd-domain.test.mjs +26 -0
  25. package/packages/dsh-remote-web/test/picker-pin.test.mjs +32 -2
  26. package/packages/dsh-remote-web/test/plugin-link.test.mjs +3 -1
  27. package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
  28. package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +4 -1
  29. package/packages/dsh-remote-web/test/setup-output.test.mjs +22 -0
  30. package/packages/dsh-remote-web/test/telemetry.test.mjs +29 -10
  31. package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +3 -3
  32. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +8 -3
  33. package/packages/dsh-remote-web/test/windows-compat.test.mjs +7 -4
@@ -28,7 +28,9 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
28
28
  const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
29
29
  const PLUGIN_SRC = path.join(HERE, "..");
30
30
  const PLUGIN_ID = "dsh-remote-web";
31
- const SETUP_SRC = fs.readFileSync(SETUP, "utf8");
31
+ // ⚠️ 必须归一到 LF:源码切片用的锚点串里带 "\n",而 Windows 检出(autocrlf)是 CRLF,
32
+ // 于是 indexOf 全部 -1 → 8 个用例一起红(CI 的 Windows 专项从 0.6.9 起一直红,根因就在这)。
33
+ const SETUP_SRC = fs.readFileSync(SETUP, "utf8").replace(/\r\n/g, "\n");
32
34
 
33
35
  // ─────────────── 源码内取函数(安装器是 CLI 脚本,import 会直接执行 main) ───────────────
34
36
 
@@ -24,8 +24,8 @@ const REPO = join(HERE, "..", "..", "..");
24
24
  const INDEX = join(HERE, "..", "lib", "index.js");
25
25
  const CLIENT = join(HERE, "..", "lib", "client.js");
26
26
 
27
- const indexSrc = readFileSync(INDEX, "utf8");
28
- const clientSrc = readFileSync(CLIENT, "utf8");
27
+ const indexSrc = readFileSync(INDEX, "utf8").replace(/\r\n/g, "\n"); // CRLF 检出时下面的源码锚点会全部失配
28
+ const clientSrc = readFileSync(CLIENT, "utf8").replace(/\r\n/g, "\n");
29
29
 
30
30
  test("清单:dsh.client 不得声明不存在的宿主包(幻影 inject 会让插件永不装载)", () => {
31
31
  // 注:旧名别名包 dsh-remote-ui 已于 2026-09-15 退役并从仓库删除(npm 上已 deprecate),
@@ -17,10 +17,13 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
17
17
  import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
18
18
  import os from "node:os";
19
19
  import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
20
21
  import test from "node:test";
21
22
  import { apply } from "../lib/index.js";
22
23
 
23
- const HERE = path.dirname(new URL(import.meta.url).pathname);
24
+ // ⚠️ 必须用 fileURLToPath:Windows 上 `new URL(...).pathname` 得到的是 "/D:/a/..."(带前导斜杠),
25
+ // path.join 之后会拼成 "D:\\D:\\a\\..." → ENOENT。CI 的 Windows 专项就死在这条上。
26
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
24
27
  const ROOT = path.join(HERE, "..", "..", "..");
25
28
  const BUNDLER = path.join(ROOT, "scripts", "bundle-runtime.mjs");
26
29
  const PKG_JSON = path.join(ROOT, "packages", "dsh-remote-web", "package.json");
@@ -134,3 +134,25 @@ test("版本比较:预设版 < 同号正式版;beta.10 < beta.11(更新提
134
134
  assert.match(src, /const cmp = compareVersions\(latest, current\)/, "update-check 应用 semver 比较");
135
135
  assert.match(src, /cmp === null \? Boolean\(latest && latest !== current\) : cmp > 0/);
136
136
  });
137
+
138
+ test("★ 插件覆盖必须原子:staging → 必需文件自检 → rename 切换(2026-09-26「dsh 起不来」事故)", () => {
139
+ // 事故:旧实现 `rmSync(dest)` → `cpSync(pluginDir, dest)`,于是拷贝期间 dest 是"半个插件":
140
+ // dsh 恰在此刻启动 → package.json 已声明 bundle,但 cordis.patch.yml 还没拷到 →
141
+ // `failed to read overlay … cordis.patch.yml: ENOENT` → **整个 GUI 起不来**。
142
+ // 时间证据:dsh 报错 23:46:03,cordis.patch.yml 写完 23:46:05(差 2 秒)。
143
+ const reqFn = src.slice(src.indexOf("const PLUGIN_REQUIRED_FILES"), src.indexOf("function copyPluginIntoProfile"));
144
+ for (const f of ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"]) {
145
+ assert.match(reqFn, new RegExp(f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `必需文件清单缺 ${f}(缺了就又会出现"半个插件")`);
146
+ }
147
+ const fn = src.slice(src.indexOf("function copyPluginIntoProfile"), src.indexOf("function ensurePluginLinked"));
148
+ assert.match(fn, /\.staging-/, "必须先拷到 staging(全程不碰 dest)");
149
+ assert.match(fn, /missingPluginFiles\(stage\)/, "🔴 切换前必须自检必需文件");
150
+ assert.ok(fn.indexOf("missingPluginFiles(stage)") < fn.indexOf("renameSync(stage, dest)"),
151
+ "🔴 自检必须发生在 rename 切换**之前**");
152
+ assert.match(fn, /renameSync\(dest, backup\)/, "旧版本要先挪成 backup");
153
+ assert.match(fn, /renameSync\(backup, dest\)/, "切换失败必须把 backup 改回来(旧版仍可用)");
154
+ // 反向锁:绝不允许回到"先 rm dest、再 cpSync 逐文件"的旧写法
155
+ assert.ok(!/fs\.rmSync\(dest,\s*\{\s*recursive:\s*true,\s*force:\s*true\s*\}\);\s*\n\s*fs\.cpSync\(/.test(fn),
156
+ "🔴 不得再出现「先删 dest 再原地逐文件拷贝」——那正是这次事故的成因");
157
+ assert.match(fn, /\.staging-\$\{process\.pid\}|\.staging-/, "被强杀留下的 staging 要能清理");
158
+ });
@@ -366,7 +366,7 @@ test("隐私审计:对抗性 extra 字段(手机号/hostname/machine_fp/路
366
366
  assert.ok(!raw.includes(bad), "payload 泄漏了敏感取值:" + bad);
367
367
  }
368
368
  // 源码级约束:唯一的 payload 构造点里不得出现禁止字段名
369
- const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
369
+ const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8").replace(/\r\n/g, "\n"); // CRLF 检出时源码锚点失配
370
370
  const start = src.indexOf("function telemetryEventOf(");
371
371
  assert.ok(start > 0, "必须存在唯一的 payload 构造点 telemetryEventOf");
372
372
  const body = src.slice(start, src.indexOf("\n}\n", start));
@@ -502,8 +502,11 @@ test("冻结契约:既有事件名与既有字段一字不改(本次只新
502
502
  ];
503
503
  assert.deepEqual(TELEMETRY.eventNames.slice(0, FROZEN_EVENT_NAMES.length), FROZEN_EVENT_NAMES,
504
504
  "既有 12 个事件名一字不能改、不能删、不能重排(只允许在末尾追加新事件)");
505
- assert.deepEqual(TELEMETRY.eventNames, [...FROZEN_EVENT_NAMES, "wechat_bound", "wechat_unbound"],
506
- "事件名白名单只允许在末尾追加:本次追加微信通道的绑定/解绑,其余必须与服务端字面量冻结对齐");
505
+ // 【926 迭代】再在末尾追加 mirror_load(手机镜像页就绪耗时;由 D6 新增的
506
+ // /dsh-remote/telemetry/mirror-load 路由入队,字段 ms / at / outcome 见 telemetryEventOf)。
507
+ // 仍是"末尾追加":上面那条前缀比对已把"既有项一位都没动"钉死。
508
+ assert.deepEqual(TELEMETRY.eventNames, [...FROZEN_EVENT_NAMES, "wechat_bound", "wechat_unbound", "mirror_load"],
509
+ "事件名白名单只允许在末尾追加:本次追加微信通道绑定/解绑与镜像页加载耗时,其余必须与服务端字面量冻结对齐");
507
510
  const ev = TELEMETRY.eventOf("bridge_registered");
508
511
  assert.deepEqual(Object.keys(ev), ["name", "at", "version", "harness_version", "channel", "os", "arch", "node"],
509
512
  "字段白名单:既有的 7 个一个不少,只多 harness_version / channel");
@@ -746,7 +749,7 @@ test("不为遥测创建配置目录 / 测试隔离:隔离开关下零副作
746
749
  assert.equal(relay.count("/api/telemetry/events"), 0, "隔离开关下绝不得把事件发到真实端点");
747
750
  assert.equal(TELEMETRY.record(env.relayDir, "install_started"), false, "隔离开关下不得入队");
748
751
  // 源码级:遥测段落不得出现「为统计创建配置目录」的动作(否则会改变卸载/安装判定的既有语义)
749
- const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
752
+ const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8").replace(/\r\n/g, "\n"); // CRLF 检出时源码锚点失配
750
753
  const section = src.slice(src.indexOf("// ---------- 匿名装机/连接遥测"), src.indexOf("/**\n * 安装信息上报(插件侧通道)"));
751
754
  assert.ok(section.length > 2000, "必须定位到遥测段落");
752
755
  assert.ok(!/mkdirSync\(\s*relayDir/.test(section), "遥测不得为统计创建配置目录");
@@ -755,16 +758,32 @@ test("不为遥测创建配置目录 / 测试隔离:隔离开关下零副作
755
758
  });
756
759
 
757
760
  test("面板可见性:关于卡片写明「匿名统计 + 关闭方式」,但不在面板里堆文档链接", () => {
758
- const client = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
761
+ const client = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8").replace(/\r\n/g, "\n");
759
762
  assert.match(client, /匿名装机统计/, "面板必须有匿名统计说明");
760
763
  assert.match(client, /DSH_REMOTE_TELEMETRY=0/, "面板必须写明关闭方法");
761
764
  assert.match(client, /不含任何账号、手机号、会话或文件内容/, "面板必须写明不采集什么");
762
765
  // 面板只讲结论,不放「隐私说明: README… · docs/telemetry.md」这类文档链接行(用户明确要求)
763
- const about = client.slice(client.indexOf("关于 dsh-remote"), client.indexOf("加入交流群"));
764
- assert.ok(!/隐私说明/.test(about), "关于卡片不应再出现「隐私说明:」链接行");
765
- assert.ok(!/完整字段清单/.test(about), "关于卡片不应再出现「完整字段清单」链接");
766
- assert.ok(!about.includes("README.md#匿名装机统计与隐私"), "关于卡片不应链 README 锚点");
767
- assert.ok(!about.includes("docs/telemetry.md"), "关于卡片不应链 docs/telemetry.md");
766
+ //
767
+ // 🔴 取片必须按**卡片边界**取,不能再用 `slice(indexOf("关于 dsh-remote"), indexOf("加入交流群"))`:
768
+ // 那两个锚点本来就**不在同一个地方** —— 第一个命中文件头的模块注释、第二个命中
769
+ // 「交流群配置」的注释,于是切片覆盖的是整个文件的一大段,四条 `!` 断言等于什么都没校验;
770
+ // 0.6.18 面板重排(「加入交流群」搬进账号卡)之后更是会变成空串 → 断言全部平凡通过。
771
+ // 这是"静默失守":比变红更危险(它看起来一直是绿的)。
772
+ // 现在取片边界是「关于卡片开头 → renderHome 结尾」,并**显式断言切片非空且像样**。
773
+ const aboutStart = client.indexOf('card("📖 关于 dsh-remote"');
774
+ assert.ok(aboutStart > 0, "必须能定位到「关于」卡片的开头");
775
+ const aboutEnd = client.indexOf("\n // 【自动化】待重启时自动倒计时", aboutStart);
776
+ assert.ok(aboutEnd > aboutStart, "必须能定位到「关于」卡片的结束边界(renderHome 末尾)");
777
+ const about = client.slice(aboutStart, aboutEnd);
778
+ assert.ok(about.length > 400, "「关于」卡片切片不能是空串/碎片(空串会让下面四条断言全部平凡通过)");
779
+ assert.ok(about.includes("关于 dsh-remote"), "切片必须真的包含「关于」卡片本体");
780
+ // ⚠️ 断言的是**渲染出来的文案**:上面那条"不再单列隐私说明行"的解释性注释本身就写着这些词,
781
+ // 所以先剥掉整行注释再断言(注释不会出现在用户屏幕上,不该被当成"面板里放了链接")。
782
+ const aboutText = about.split("\n").filter((l) => !/^\s*\/\//.test(l)).join("\n");
783
+ assert.ok(!/隐私说明/.test(aboutText), "关于卡片不应再出现「隐私说明:」链接行");
784
+ assert.ok(!/完整字段清单/.test(aboutText), "关于卡片不应再出现「完整字段清单」链接");
785
+ assert.ok(!aboutText.includes("README.md#匿名装机统计与隐私"), "关于卡片不应链 README 锚点");
786
+ assert.ok(!aboutText.includes("docs/telemetry.md"), "关于卡片不应链 docs/telemetry.md");
768
787
  assert.ok(client.includes("/dsh-remote/telemetry/panel-opened"), "面板打开事件必须走本地路由(浏览器半不直连遥测端点)");
769
788
  // 披露文档本身仍必须存在且可查(面板不再链,但 README 链,公开发布物包含)
770
789
  const readme = readFileSync(new URL("../../../README.md", import.meta.url), "utf8");
@@ -27,10 +27,10 @@ import { fileURLToPath } from "node:url";
27
27
  import { apply } from "../lib/index.js";
28
28
 
29
29
  const HERE = path.dirname(fileURLToPath(import.meta.url));
30
- const INDEX_SRC = fs.readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
31
- const CLIENT_SRC = fs.readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8");
30
+ const INDEX_SRC = fs.readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8").replace(/\r\n/g, "\n"); // CRLF 检出时源码锚点失配
31
+ const CLIENT_SRC = fs.readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8").replace(/\r\n/g, "\n");
32
32
  const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
33
- const SETUP_SRC = fs.readFileSync(SETUP, "utf8");
33
+ const SETUP_SRC = fs.readFileSync(SETUP, "utf8").replace(/\r\n/g, "\n");
34
34
 
35
35
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36
36
  const PLUGIN_ID = "dsh-remote-web";
@@ -511,10 +511,15 @@ test("首屏导览:未绑定时就在最上面(DOM 顺序在「连接微信
511
511
  const btnAt = domIndex(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("连接微信机器人")));
512
512
  assert.ok(introAt >= 0 && btnAt >= 0, "导览与连接按钮都必须存在");
513
513
  assert.ok(introAt < btnAt, `导览必须排在连接按钮之前(导览 #${introAt},按钮 #${btnAt})`);
514
- // 而且导览是内容体的**第一个**孩子:不会把连接按钮推到需要滚动才看得见的地方
514
+ // 而且紧凑块排在内容体最前面:不会把连接按钮推到需要滚动才看得见的地方
515
515
  // (harness 渲染时会对普通节点做浅拷贝,所以拿树里的同一个节点比,不比组件里的原对象)
516
- assert.equal(embed.children[0], find(embed, (n) => n.props?.className === "dru-wx-intro"),
517
- "导览必须是内嵌内容体的第一个孩子(连接按钮仍在首屏)");
516
+ // 【0.6.18】内容体第一个孩子变成了「两步走」操作引导块(.dru-wx-guide):用户口径是
517
+ // 先在微信通道首屏讲清"这是干啥用的 + 两步怎么接",再给"你现在能用哪些能力"的清单 ——
518
+ // 所以导览降为**第二个**孩子,连接按钮仍在首屏(两块都是紧凑小字卡片)。
519
+ assert.equal(embed.children[0], find(embed, (n) => n.props?.className === "dru-wx-guide"),
520
+ "两步走引导必须是内嵌内容体的第一个孩子(先讲用途与做法)");
521
+ assert.equal(embed.children[1], find(embed, (n) => n.props?.className === "dru-wx-intro"),
522
+ "能力清单紧随引导块之后(连接按钮仍在首屏)");
518
523
  // 紧凑小字(不是宣传横幅):样式里明确给了小字号
519
524
  assert.match(SOURCE, /\.dru-wx-intro-row\{[^}]*font-size:12px/, "导览行必须是小字(不挤占首屏)");
520
525
 
@@ -28,13 +28,16 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
28
28
  import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
29
29
  import os from "node:os";
30
30
  import path from "node:path";
31
+ import { fileURLToPath } from "node:url";
31
32
  import test from "node:test";
32
33
  import { apply } from "../lib/index.js";
33
34
 
34
- const HERE = path.dirname(new URL(import.meta.url).pathname);
35
- const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
36
- const CLIENT_SRC = readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8");
37
- const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8");
35
+ // ⚠️ 必须用 fileURLToPath:Windows 上 `new URL(...).pathname` 得到的是 "/D:/a/..."(带前导斜杠),
36
+ // path.join 之后会拼成 "D:\\D:\\a\\..." → ENOENT。CI 的 Windows 专项就死在这条上。
37
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
38
+ const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8").replace(/\r\n/g, "\n"); // 本文件就是 Windows 专项:CRLF 检出时源码锚点必须仍然匹配
39
+ const CLIENT_SRC = readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8").replace(/\r\n/g, "\n");
40
+ const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8").replace(/\r\n/g, "\n");
38
41
 
39
42
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
40
43