@mrrisega/dsh-remote 0.6.6-beta.1 → 0.6.6-beta.3

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.
package/docs/telemetry.md CHANGED
@@ -61,10 +61,17 @@
61
61
  ### 失败码白名单(`fail_code`)
62
62
 
63
63
  `node_missing` · `node_too_old` · `npm_unreachable` · `npm_eacces` · `platform_unsupported` ·
64
- `runtime_install_timeout` · `launchd_failed` · `bridge_exit` · `bind_conflict` · `bind_device_limit` · `unknown`
64
+ `runtime_install_timeout` · `launchd_failed` · `bridge_exit` · `bind_conflict` · `bind_device_limit` ·
65
+ `npx_cmd_unavailable` · `registry_timeout` · `install_script_missing` · `npx_exit_nonzero` ·
66
+ `npx_output_encoding` · `unknown`
65
67
 
66
68
  > 原始错误文本**绝不外发**(它可能含文件路径、用户名、主机名):只做白名单归类,
67
69
  > 归不进去的一律记 `unknown`。
70
+ >
71
+ > `npx_cmd_unavailable` / `registry_timeout` / `install_script_missing` / `npx_exit_nonzero` /
72
+ > `npx_output_encoding` 是 2026-09 为定位「Windows 上装不上运行环境」而拆细的形态
73
+ > (命令调不起来 / 注册表超时 / 包或安装脚本缺失 / 退出码非零 / 输出乱码)——
74
+ > 它们仍然只是**枚举值**,不包含任何原始文本。
68
75
 
69
76
  ## 3. 不采集什么(硬边界)
70
77
 
package/dsh-setup.mjs CHANGED
@@ -70,8 +70,48 @@ function findDepWs(startDir) {
70
70
  return null;
71
71
  }
72
72
 
73
+ /** 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。 */
74
+ const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs"];
75
+
76
+ /**
77
+ * 把"客户端脚本"补齐到配置目录。
78
+ *
79
+ * ⚠️ 2026-09-15 实测的坑:只按**版本号**判断"要不要同步"是不够的 ——
80
+ * 手机端遮罩 bug 的修复落在 `clients/dsh-remote/mobile-adapter.mjs` 里(bridge 会把它注入官方页面后
81
+ * 直接发给手机)。用户升级到新版本时,若配置目录里那份旧脚本没被覆盖,
82
+ * **修复就永远到不了手机**(现场表现:改了、发了版,用户还是整屏阴影)。
83
+ * 所以这里按**内容**比对:只要与装置器手里的不一致就覆盖(幂等、无副作用)。
84
+ * @returns {number} 实际覆盖的文件数
85
+ */
86
+ function syncRuntimeClientFiles() {
87
+ const destRoot = path.join(CONFIG_DIR, "clients", "dsh-remote");
88
+ let n = 0;
89
+ for (const name of RUNTIME_CLIENT_FILES) {
90
+ const src = path.join(THIS_DIR, "clients", "dsh-remote", name);
91
+ if (!fs.existsSync(src)) continue;
92
+ const dst = path.join(destRoot, name);
93
+ try {
94
+ if (fs.existsSync(dst) && fs.readFileSync(src).equals(fs.readFileSync(dst))) continue; // 一致 → 不动
95
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
96
+ fs.copyFileSync(src, dst);
97
+ n += 1;
98
+ } catch { /* 单个文件失败不影响整体 */ }
99
+ }
100
+ return n;
101
+ }
102
+
73
103
  function ensureRuntimeCopy() {
74
- if (!IS_NPM_INSTALL) return; // 仓库开发形态:原地使用
104
+ // 仓库开发形态:装置器要使用**仓库里这份**(实时生效),但配置目录里的运行时副本
105
+ // 仍必须与之一致 —— 否则 bridge 会把陈旧脚本发给手机(上面的 syncRuntimeClientFiles 说明)。
106
+ if (!IS_NPM_INSTALL) {
107
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
108
+ const srcSetup = path.join(THIS_DIR, "dsh-setup.mjs");
109
+ const dstSetup = path.join(CONFIG_DIR, "dsh-setup.mjs");
110
+ try { if (!fs.existsSync(dstSetup) || !fs.readFileSync(srcSetup).equals(fs.readFileSync(dstSetup))) fs.copyFileSync(srcSetup, dstSetup); } catch { /* ignore */ }
111
+ const n = syncRuntimeClientFiles();
112
+ if (n > 0) console.log(`✅ 运行时: 已同步 ${n} 个客户端脚本到 ${CONFIG_DIR}`);
113
+ return;
114
+ }
75
115
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
76
116
  const ver = pkgVersion();
77
117
  const setupTarget = path.join(CONFIG_DIR, "dsh-setup.mjs");
@@ -79,7 +119,12 @@ function ensureRuntimeCopy() {
79
119
  const verFile = path.join(CONFIG_DIR, ".dsh-setup-version");
80
120
  let cur = "";
81
121
  try { cur = fs.readFileSync(verFile, "utf8").trim(); } catch { /* 首次 */ }
82
- if (fs.existsSync(setupTarget) && cur === ver) return; // 同版本幂等跳过
122
+ if (fs.existsSync(setupTarget) && cur === ver) {
123
+ // 版本没变也要确保客户端脚本一致(版本号相同但脚本被修过的情况真实存在)
124
+ const n = syncRuntimeClientFiles();
125
+ if (n > 0) console.log(`✅ 运行时: 已同步 ${n} 个客户端脚本到 ${CONFIG_DIR}`);
126
+ return;
127
+ }
83
128
  fs.cpSync(path.join(THIS_DIR, "dsh-setup.mjs"), setupTarget);
84
129
  fs.cpSync(path.join(THIS_DIR, "clients"), path.join(CONFIG_DIR, "clients"), { recursive: true, force: true });
85
130
  const wsSrc = findDepWs(THIS_DIR);
@@ -895,6 +940,42 @@ function stripPluginEntries(patch) {
895
940
  return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
896
941
  }
897
942
 
943
+ /**
944
+ * 语义化版本比较(只处理本仓库用到的形态:X.Y.Z 与 X.Y.Z-<pre>.<n>)。
945
+ * 为什么需要:装置器必须判断"本地这份包 vs profile 里已装的那份"谁更新 ——
946
+ * 用字符串比较会把 0.6.6-beta.2 与 0.6.5 比错(预设版低于同号正式版),
947
+ * 于是要么跳过本该做的升级、要么把新版降级。任一侧解析不了返回 null(调用方保守处理)。
948
+ * @returns {number|null} a>b → 1;a<b → -1;相等 → 0;无法解析 → null
949
+ */
950
+ function compareVersionStrings(a, b) {
951
+ const parse = (v) => {
952
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || "").trim());
953
+ if (!m) return null;
954
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split(".") : null };
955
+ };
956
+ const x = parse(a);
957
+ const y = parse(b);
958
+ if (!x || !y) return null;
959
+ for (let i = 0; i < 3; i += 1) if (x.nums[i] !== y.nums[i]) return x.nums[i] > y.nums[i] ? 1 : -1;
960
+ if (!x.pre && !y.pre) return 0;
961
+ if (!x.pre) return 1; // 预设版 < 同号正式版(semver §11)
962
+ if (!y.pre) return -1;
963
+ const len = Math.max(x.pre.length, y.pre.length);
964
+ for (let i = 0; i < len; i += 1) {
965
+ const p1 = x.pre[i];
966
+ const p2 = y.pre[i];
967
+ if (p1 === undefined) return -1;
968
+ if (p2 === undefined) return 1;
969
+ const n1 = /^\d+$/.test(p1) ? Number(p1) : null;
970
+ const n2 = /^\d+$/.test(p2) ? Number(p2) : null;
971
+ if (n1 !== null && n2 !== null) { if (n1 !== n2) return n1 > n2 ? 1 : -1; continue; }
972
+ if (n1 !== null) return -1;
973
+ if (n2 !== null) return 1;
974
+ if (p1 !== p2) return p1 > p2 ? 1 : -1;
975
+ }
976
+ return 0;
977
+ }
978
+
898
979
  /**
899
980
  * 解析 patch 文本里的 insert 行 id(与 dshmarket 同口径的极简解析,不引 YAML 依赖)。
900
981
  * 用途有二:① 判断本插件是否已激活(幂等);② **写完校验**——patch 文件形态非法时
@@ -952,7 +1033,7 @@ function writePatchAtomic(patchFile, text) {
952
1033
  * 并导致「plugin tree failed to load」——整个插件树加载失败(实测日志)。
953
1034
  * 因此所有 return 之前都要保证:要么只有 patch 行、要么只有 bundles 条目。
954
1035
  */
955
- function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
1036
+ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
956
1037
  const original = fs.readFileSync(patchFile, "utf8");
957
1038
  const pkgBundles = () => {
958
1039
  try {
@@ -961,11 +1042,21 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
961
1042
  ? pkg.dsh.profile.bundles.filter((b) => PLUGIN_ALL_IDS.includes(b)) : [];
962
1043
  } catch { return []; }
963
1044
  };
964
- // 基座就是「插件已在 bundles」→ 保持 bundles 形态(不写 patch 行),避免两个激活点并存。
965
- // 这一路等价于插件市场安装形态:需要重启 dsh web 才装载。
966
- if (pkgBundles().length) {
1045
+ // 插件已在 bundles 时默认**不**写 patch 行(两条激活路径互斥,否则 duplicate id 崩树)。
1046
+ // 但有两个例外必须主动改写激活点:
1047
+ // · forcePatch:源码已被我们换成本地这份(升级场景)→ 必须转 patch 行才能热加载;
1048
+ // · 插件市场形态(bundles 里的条目由包管理器管)→ 保持 bundles,不碰。
1049
+ const inBundles = pkgBundles();
1050
+ if (inBundles.length && !opts.forcePatch) {
967
1051
  return { ok: false, reason: "插件已由 dsh.profile.bundles 声明(保持单一激活点,不写 patch 行)", changed: false, bundleOnly: true, basePatch: original };
968
1052
  }
1053
+ if (inBundles.length && opts.forcePatch) {
1054
+ // 自己动手摘掉 bundles 条目(不依赖调用方的执行顺序),再走 patch 行激活
1055
+ removeBundleEntry(pkgFile);
1056
+ if (pkgBundles().length) {
1057
+ return { ok: false, reason: "无法移除 dsh.profile.bundles 中的本插件条目", changed: false, basePatch: original };
1058
+ }
1059
+ }
969
1060
  const base = validatePatchBase(original);
970
1061
  if (!base.ok) return { ok: false, reason: base.reason, changed: false, basePatch: original };
971
1062
  const hasOurRow = base.insertedIds.some((id) => PLUGIN_ALL_IDS.includes(id));
@@ -1138,6 +1229,37 @@ function removeBundleEntry(pkgFile) {
1138
1229
  return true;
1139
1230
  }
1140
1231
 
1232
+ /**
1233
+ * 把我们手上这份插件包落到 profile 并激活(唯一激活点 = patch 行 → 热加载)。
1234
+ * 两条路径共用:① 无依赖或 file: 依赖(我们自己管源码);② 市场装法但本地版本更新。
1235
+ * @returns {{hotPatch:boolean, changed:boolean, basePatch?:string}|undefined}
1236
+ */
1237
+ function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg, opts = {}) {
1238
+ const pluginLocalDir = copyPluginIntoProfile(profileDir, pluginDir);
1239
+ // forcePatch 由 ensurePatchActivation 内部处理(摘 bundles → 写 patch 行),
1240
+ // 这样不依赖调用顺序:源码既已换成本地这份,激活点就必须跟着改,否则装完仍看不到新版。
1241
+
1242
+ const entryFile = path.join(pluginLocalDir, "lib", "index.js");
1243
+ if (!fs.existsSync(entryFile)) {
1244
+ console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
1245
+ process.exit(1);
1246
+ }
1247
+ declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
1248
+ ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
1249
+ const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, { forcePatch: !!opts.forcePatch });
1250
+ if (r.ok) {
1251
+ console.log(`✅ 插件已就绪: ${pluginLocalDir}${opts.forcePatch ? "(激活点已转为 patch 行,装完即热加载)" : ""}`);
1252
+ return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
1253
+ }
1254
+ if (r.bundleOnly) {
1255
+ console.log("ℹ 该插件已由 dsh.profile.bundles 声明(保持单一激活点,不重复写入 patch 行)");
1256
+ } else {
1257
+ console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
1258
+ ensureBundleEntry(pkgFile);
1259
+ }
1260
+ return { hotPatch: false, changed: true, basePatch: patch };
1261
+ }
1262
+
1141
1263
  /**
1142
1264
  * 插件安装(pluginCmd 非卸载分支)收敛策略 —— 2026-09-06「重复 ID 崩溃」根治;
1143
1265
  * 2026-09 插件由 dsh-remote-ui 更名 dsh-remote-web,本函数同时兼容清理旧名残留。
@@ -1196,6 +1318,22 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
1196
1318
  stripIncludeEntries(patchFile, patch, "插件市场安装形态无需用户 include");
1197
1319
  const depName = PLUGIN_ALL_IDS.find((id) => pkg.dependencies && pkg.dependencies[id] !== undefined) || PLUGIN_ID;
1198
1320
  const before = readInstalledPluginVersion(profileDir, depName);
1321
+ // ⚠️ 关键:装置器**手上这份包的版本**才是权威(用户跑的就是它)。
1322
+ // 以前这里无条件"去 npm 装 market 渠道的最新版",于是本地跑 0.6.6-beta.2 时,
1323
+ // profile 里市场装的 0.6.5 被判成"已是最新"→ 什么都不做(测试自己就踩到:
1324
+ // 「✅ 插件包已是市场最新版(0.6.5)」,明明带的是 beta)。
1325
+ // 现在:本地严格更新 → 直接落本地包(顺带把激活点换成 patch 行,拿到热加载);
1326
+ // 相等 → 不做;本地更旧 → 不动(绝不降级)。
1327
+ const localVer = pkgVersion();
1328
+ const cmpLocal = compareVersionStrings(localVer, before);
1329
+ if (cmpLocal !== null && cmpLocal > 0) {
1330
+ console.log(`ℹ 本地包更新(${before ?? "未装"} → ${localVer}),落本地版本并改用热加载激活…`);
1331
+ return activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg, { forcePatch: true });
1332
+ }
1333
+ if (cmpLocal !== null && cmpLocal < 0) {
1334
+ console.log(`ℹ profile 里已是更新版本(${before} > ${localVer}),保持不动(不降级)。`);
1335
+ return;
1336
+ }
1199
1337
  console.log(`ℹ 插件市场安装形态(bundles+dependency):源码归包管理器管,正在升级到最新…(安装前 ${before ?? "未知"})`);
1200
1338
  const { ok, cmd } = upgradeMarketManagedPlugin(profileDir, depName, dep);
1201
1339
  const after = readInstalledPluginVersion(profileDir, depName);
@@ -1212,31 +1350,9 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
1212
1350
  return;
1213
1351
  }
1214
1352
 
1215
- if (managedByUs) {
1216
- const pluginLocalDir = copyPluginIntoProfile(profileDir, pluginDir);
1217
- const entryFile = path.join(pluginLocalDir, "lib", "index.js");
1218
- if (!fs.existsSync(entryFile)) {
1219
- console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
1220
- process.exit(1);
1221
- }
1222
- declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
1223
- ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
1224
- // 唯一激活点:patch 行(+ 顺带把 bundles 里的本插件条目移除,避免两处并存)
1225
- const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile);
1226
- if (r.ok) {
1227
- console.log(`✅ 插件已就绪: ${pluginLocalDir}`);
1228
- return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
1229
- }
1230
- // patch 行不能写(文件形态异常,或插件已由 bundles 声明)→ 回退 bundles(需重启),保证功能可用。
1231
- // 这条路的输出要如实说明"装完要重启一次",不能让人以为已经热加载了。
1232
- if (r.bundleOnly) {
1233
- console.log("ℹ 该插件已由 dsh.profile.bundles 声明(保持单一激活点,不重复写入 patch 行)");
1234
- } else {
1235
- console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
1236
- ensureBundleEntry(pkgFile); // 回退激活点:插件仍可用,代价是必须重启一次
1237
- }
1238
- return { hotPatch: false, changed: true, basePatch: patch };
1239
- }
1353
+ // 源码归我们(无依赖或 file: 依赖)→ 必然用本地这份,激活点也必须是 patch 行(热加载)。
1354
+ if (managedByUs) return activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg, { forcePatch: true });
1355
+
1240
1356
 
1241
1357
  // 异常形态:有非 file: 依赖但不在 bundles(无法靠 bundle patch 激活)
1242
1358
  console.log(`ℹ 检测到依赖 ${PLUGIN_ID}(${dep}) 但未声明在 dsh.profile.bundles——插件不会激活。`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.6-beta.1",
3
+ "version": "0.6.6-beta.3",
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",
@@ -54,9 +54,7 @@
54
54
  "_comment:test:plugin": "DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)",
55
55
  "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
56
56
  "test:bridge": "node --test clients/dsh-remote/test/*.test.mjs",
57
- "check": "node --check clients/dsh-remote/dsh-bridge.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 && bash -n deploy/install-open.sh && npm run check:alias",
58
- "sync:alias": "node scripts/sync-legacy-alias.mjs",
59
- "check:alias": "node scripts/sync-legacy-alias.mjs --check",
57
+ "check": "node --check clients/dsh-remote/dsh-bridge.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 && bash -n deploy/install-open.sh",
60
58
  "release:tarballs": "bash scripts/release-tarballs.sh"
61
59
  },
62
60
  "dependencies": {
@@ -1812,7 +1812,7 @@ const PLUGIN_ID = "dsh-remote-web";
1812
1812
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1813
1813
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1814
1814
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1815
- const PLUGIN_VERSION = "0.6.6-beta.1";
1815
+ const PLUGIN_VERSION = "0.6.6-beta.3";
1816
1816
  const UPDATE_LOG = ".dsh-update.log";
1817
1817
  const UPDATE_MARKER = ".dsh-update-running";
1818
1818
 
@@ -2436,10 +2436,17 @@ const TELEMETRY_EVENT_NAMES = new Set([
2436
2436
  "tunnel_disconnected", "first_remote_ok", "plugin_loaded", "panel_opened", "harness_restart",
2437
2437
  "update_started", "update_failed",
2438
2438
  ]);
2439
- /** fail_code 白名单(白名单外的分类一律落 unknown)。 */
2439
+ /**
2440
+ * fail_code 白名单 —— **必须与服务端 TELEMETRY_FAIL_CODES 完全一致**。
2441
+ * 两个用途:① 构造事件时校验(白名单外会被清成空串,等于归因白做);
2442
+ * ② 受信边界:只有枚举值能出网络,原始错误文本永不上报。
2443
+ * 2026-09-15 教训:新增归因码时忘了同步这里,1659 次 Windows 失败仍然只会显示 unknown。
2444
+ */
2440
2445
  const TELEMETRY_FAIL_CODES = new Set([
2441
2446
  "node_missing", "node_too_old", "npm_unreachable", "npm_eacces", "platform_unsupported",
2442
- "runtime_install_timeout", "launchd_failed", "bridge_exit", "bind_conflict", "bind_device_limit", "unknown",
2447
+ "runtime_install_timeout", "launchd_failed", "bridge_exit", "bind_conflict", "bind_device_limit",
2448
+ "npx_cmd_unavailable", "registry_timeout", "install_script_missing", "npx_exit_nonzero", "npx_output_encoding",
2449
+ "unknown",
2443
2450
  ]);
2444
2451
  /** node 半只发 source=plugin。 */
2445
2452
  const TELEMETRY_SOURCE = "plugin";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.6-beta.1",
3
+ "version": "0.6.6-beta.3",
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",
@@ -182,19 +182,21 @@ test("回归护栏:plugin 子命令必须把激活结果 return 给 setup(
182
182
  assert.match(src, /pluginResult\.hotPatch/, "应据 hotPatch 判断是否走热挂载形态");
183
183
  });
184
184
 
185
- test("硬约束:插件已在 bundles 时**绝不**写 patch 行(两个激活点 = duplicate id 崩溃)", () => {
185
+ test("硬约束:源码归我们时**必须**把激活点从 bundles 转成 patch 行(否则热加载失效、且两处并存会崩)", () => {
186
186
  // 事故复盘:patch 行与插件自带 bundle patch 同时生效时,dsh web 启动即
187
187
  // TypeError: duplicate loader entry id: dsh-remote-web
188
188
  // Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include)
189
- // 整个插件树加载失败(实测生产日志)。所以这两条激活路径必须互斥。
189
+ // 所以两条激活路径必须互斥。而当装置器**要把本地这份源码装进去**时(升级/自装),
190
+ // 正确做法不是"保持 bundles",而是**摘掉 bundles 条目 + 写 patch 行**:
191
+ // · 只留 bundles → 只在启动时读取,装完看不到新版、也拿不到热加载(实测踩到);
192
+ // · 两处并存 → 启动即崩。
190
193
  const profile = makeProfile({ bundles: [...PLUGIN_BUNDLES, "dsh-remote-web"] });
191
194
  const relay = join(profile, "relay");
192
195
  try {
193
196
  const out = installPlugin(profile, relay);
194
- assert.match(out, /已由 dsh\.profile\.bundles 声明/, "应识别并保持 bundles 形态");
195
- assert.equal((readPatch(profile).match(/- id: dsh-remote-web/g) || []).length, 0,
196
- "bundles 已声明时不得再写 patch 行(否则重复 ID)");
197
- assert.ok(readBundles(profile).includes("dsh-remote-web"), "bundles 形态应保持");
197
+ assert.match(out, /激活点已转为 patch 行/, "应把激活点转成 patch 行(热加载)");
198
+ assert.equal((readPatch(profile).match(/- id: dsh-remote-web/g) || []).length, 1, "应恰好写一条 patch 行");
199
+ assert.ok(!readBundles(profile).includes("dsh-remote-web"), "bundles 条目必须被摘掉(单一激活点)");
198
200
  } finally { cleanup(profile); }
199
201
  });
200
202
 
@@ -28,20 +28,34 @@ const indexSrc = readFileSync(INDEX, "utf8");
28
28
  const clientSrc = readFileSync(CLIENT, "utf8");
29
29
 
30
30
  test("清单:dsh.client 不得声明不存在的宿主包(幻影 inject 会让插件永不装载)", () => {
31
- for (const pkg of ["packages/dsh-remote-web/package.json", "packages/dsh-remote-ui/package.json"]) {
32
- const manifest = JSON.parse(readFileSync(join(REPO, pkg), "utf8"));
33
- const client = manifest.dsh && manifest.dsh.client;
34
- assert.ok(client, `${pkg} 应有 dsh.client`);
35
- assert.equal(client.platform, "web");
36
- const inject = client.inject || [];
37
- assert.deepEqual(inject, [], `${pkg} 的 inject 必须为空:我们的浏览器半只 require("react"),不需要任何宿主包`);
38
- // 若将来真要用宿主扩展点,必须验证该包真实存在
39
- for (const name of inject) {
40
- assert.ok(!/dsh-client-runtime|dsh-client-collection/.test(name), `inject 里不得出现不存在的包名:${name}`);
41
- }
31
+ // 注:旧名别名包 dsh-remote-ui 已于 2026-09-15 退役并从仓库删除(npm 上已 deprecate),
32
+ // 因此这里只校验当前插件包 dsh-remote-web。
33
+ const pkg = "packages/dsh-remote-web/package.json";
34
+ const manifest = JSON.parse(readFileSync(join(REPO, pkg), "utf8"));
35
+ const client = manifest.dsh && manifest.dsh.client;
36
+ assert.ok(client, `${pkg} 应有 dsh.client`);
37
+ assert.equal(client.platform, "web");
38
+ const inject = client.inject || [];
39
+ assert.deepEqual(inject, [], `${pkg} 的 inject 必须为空:我们的浏览器半只 require("react"),不需要任何宿主包`);
40
+ for (const name of inject) {
41
+ assert.ok(!/dsh-client-runtime|dsh-client-collection/.test(name), `inject 里不得出现不存在的包名:${name}`);
42
42
  }
43
43
  });
44
44
 
45
+ test("旧名别名包确已退役:目录/同步脚本/发布步骤都不应再存在", () => {
46
+ assert.ok(!existsSync(join(REPO, "packages", "dsh-remote-ui")), "别名目录应已删除");
47
+ assert.ok(!existsSync(join(REPO, "scripts", "sync-legacy-alias.mjs")), "别名同步脚本应已删除");
48
+ const root = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
49
+ for (const k of ["sync:alias", "check:alias"]) {
50
+ assert.ok(!(k in root.scripts), `package.json 不应再有 ${k}`);
51
+ }
52
+ assert.ok(!/check:alias/.test(root.scripts.check || ""), "check 不应再引用别名校验");
53
+ // 但**旧名清理/迁移逻辑必须保留**:老用户升级依赖它
54
+ const setup = readFileSync(join(REPO, "dsh-setup.mjs"), "utf8");
55
+ assert.match(setup, /PLUGIN_LEGACY_IDS/, "安装器必须保留旧名清理(老用户升级路径)");
56
+ assert.match(setup, /dsh-remote-ui/, "旧名清理里应仍认得 dsh-remote-ui");
57
+ });
58
+
45
59
  test("清单:浏览器半只依赖 shell 提供的 react(不 require 宿主客户端包)", () => {
46
60
  const requires = [...clientSrc.matchAll(/require\("([^"]+)"\)/g)].map((m) => m[1]);
47
61
  assert.deepEqual([...new Set(requires)], ["react"], `实际 require: ${[...new Set(requires)].join(",")}`);
@@ -69,6 +83,22 @@ test("一键更新:前端在进程结束但没成功时必须报错,而不
69
83
  assert.match(indexSrc, /failure: failure \? \{ detail: failure\.detail/, "update-log 应下发 failure");
70
84
  });
71
85
 
86
+ test("运行时同步:客户端脚本必须按**内容**补齐(否则手机端修复永远到不了用户)", () => {
87
+ // 事故背景(2026-09-15 实测):手机端遮罩 bug 的修复落在 clients/dsh-remote/mobile-adapter.mjs,
88
+ // 而 bridge 是把**配置目录里那份**脚本注入官方页面后发给手机的。装置器原先只在"版本号变化"时
89
+ // 才覆盖运行时副本,于是出现过:改了、发了版、用户升级了,但手机端拿到的仍是旧脚本 —— 修复不生效。
90
+ // 现在按内容比对,只要与装置器手里的不一致就覆盖。
91
+ const setup = readFileSync(join(REPO, "dsh-setup.mjs"), "utf8");
92
+ assert.match(setup, /function syncRuntimeClientFiles/, "应有按内容同步的客户端脚本同步函数");
93
+ assert.match(setup, /mobile-adapter\.mjs/, "必须覆盖 mobile-adapter.mjs(手机端注入层)");
94
+ assert.match(setup, /dsh-bridge\.mjs/, "必须覆盖 dsh-bridge.mjs");
95
+ assert.match(setup, /equals\(/, "应按内容比对(而非只看版本号)");
96
+ assert.match(setup, /syncRuntimeClientFiles\(\)/, "应实际调用同步");
97
+ // 仓库开发形态也必须同步(否则本地测试拿到的是陈旧副本,测了个寂寞)
98
+ const devBranch = setup.slice(setup.indexOf("if (!IS_NPM_INSTALL) {"), setup.indexOf("fs.mkdirSync(CONFIG_DIR, { recursive: true });\n const ver = pkgVersion();"));
99
+ assert.match(devBranch, /syncRuntimeClientFiles\(\)/, "仓库形态也要同步客户端脚本");
100
+ });
101
+
72
102
  test("补装失败退避:Windows 的失败风暴不再每轮重试", () => {
73
103
  assert.match(indexSrc, /PROVISION_RETRY_BACKOFF_MS\s*=\s*\[30_000, 120_000, 600_000\]/, "应有 30s→2m→10m 退避");
74
104
  assert.match(indexSrc, /function provisionRetryGate/, "应有退避闸门");
@@ -76,18 +106,29 @@ test("补装失败退避:Windows 的失败风暴不再每轮重试", () => {
76
106
  assert.match(indexSrc, /noteProvisionSuccess\(relayDir\)/, "成功要清退避");
77
107
  });
78
108
 
79
- test("失败归因:Windows 真实错误不再全部落到 unknown(1659 次无法定位的教训)", () => {
109
+ test("失败归因:客户端白名单必须与服务端逐字一致,且事件真的带上新码", () => {
80
110
  const codes = ["npx_cmd_unavailable", "registry_timeout", "install_script_missing", "npx_exit_nonzero", "npx_output_encoding"];
81
- for (const c of codes) {
82
- assert.ok(indexSrc.includes(`"${c}"`), `客户端归因应包含 ${c}`);
83
- }
84
- // 与服务端白名单保持一致(服务端不认的码会被静默丢弃 → 又变成 unknown)
85
- const server = join(REPO, "..", "dsh-relay-enterprise", "relay-enterprise", "src", "api.js");
86
- if (existsSync(server)) {
87
- const apiSrc = readFileSync(server, "utf8");
88
- for (const c of codes) {
89
- assert.ok(apiSrc.includes(`"${c}"`), `服务端 TELEMETRY_FAIL_CODES 必须接受 ${c}(否则上报被丢弃)`);
90
- }
111
+
112
+ // ① 行为断言:构造一个带新码的事件,payload 里必须真的有这个码。
113
+ // (教训:只断言"文件里有这个字面量"是不够的 —— 客户端白名单没同步时,
114
+ // telemetryEventOf 会把白名单外的码清成空串,1659 次 Windows 失败仍然只显示 unknown。)
115
+ const out = JSON.parse(execFileSync(process.execPath, ["-e", `
116
+ import("${INDEX}").then((m) => {
117
+ const ev = m.__telemetryInternals.eventOf("install_failed", { fail_code: "npx_cmd_unavailable" });
118
+ console.log(JSON.stringify({ fail_code: ev && ev.fail_code, queued: m.__telemetryInternals.failCodes }));
119
+ });
120
+ `], { encoding: "utf8" }).trim());
121
+ assert.equal(out.fail_code, "npx_cmd_unavailable", "客户端白名单必须放行新码,否则归因被清空");
122
+ for (const c of codes) assert.ok(out.queued.includes(c), `客户端 failCodes 应含 ${c}`);
123
+
124
+ // ② 与服务端白名单**集合级**一致(少一个码 = 上报被丢弃、多一个 = 服务端拒收)
125
+ const serverPath = join(REPO, "..", "dsh-relay-enterprise", "relay-enterprise", "src", "api.js");
126
+ if (existsSync(serverPath)) {
127
+ const apiSrc = readFileSync(serverPath, "utf8");
128
+ const block = /const TELEMETRY_FAIL_CODES = new Set\(\[([\s\S]*?)\]\);/.exec(apiSrc);
129
+ assert.ok(block, "应能读到服务端白名单");
130
+ const serverCodes = [...block[1].matchAll(/"([a-z_]+)"/g)].map((m) => m[1]).sort();
131
+ assert.deepEqual([...out.queued].sort(), serverCodes, "客户端与服务端的 fail_code 白名单必须完全一致");
91
132
  }
92
133
  });
93
134