@mrrisega/dsh-remote 0.7.0-beta.7 → 0.7.0

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 (68) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +17 -17
  2. package/clients/dsh-remote/dsh-events.mjs +6 -6
  3. package/clients/dsh-remote/e2ee-client.mjs +7 -7
  4. package/clients/dsh-remote/e2ee-shim-script.js +9 -9
  5. package/clients/dsh-remote/mobile-adapter.mjs +21 -21
  6. package/clients/dsh-remote/test/bridge-gzip.test.mjs +1 -1
  7. package/clients/dsh-remote/test/device-registration-report.test.mjs +1 -1
  8. package/clients/dsh-remote/test/dsh-events.test.mjs +10 -10
  9. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +4 -4
  10. package/clients/dsh-remote/test/e2ee-client.test.mjs +7 -7
  11. package/clients/dsh-remote/test/e2ee-shim.test.mjs +1 -1
  12. package/clients/dsh-remote/test/e2ee-wecrypto-parity.test.mjs +1 -1
  13. package/clients/dsh-remote/test/harness-cookie-authority.test.mjs +4 -4
  14. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +2 -2
  15. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +6 -6
  16. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +1 -1
  17. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +5 -5
  18. package/clients/dsh-remote/test/upstream-discovery.test.mjs +1 -1
  19. package/clients/dsh-remote/test/wechat-channel.test.mjs +5 -5
  20. package/clients/dsh-remote/test/wechat-runtime.test.mjs +26 -26
  21. package/clients/dsh-remote/test/wechat-tier-source.test.mjs +2 -2
  22. package/clients/dsh-remote/wechat-channel.mjs +9 -9
  23. package/clients/dsh-remote/wechat-runtime.mjs +38 -38
  24. package/docs/telemetry.md +3 -3
  25. package/dsh-setup.mjs +63 -26
  26. package/package.json +2 -2
  27. package/packages/dsh-remote-web/lib/client.js +56 -56
  28. package/packages/dsh-remote-web/lib/fix-prompt.js +1 -1
  29. package/packages/dsh-remote-web/lib/index.js +43 -43
  30. package/packages/dsh-remote-web/lib/tarball-update.js +1 -1
  31. package/packages/dsh-remote-web/package.json +1 -1
  32. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +17 -17
  33. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +6 -6
  34. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +7 -7
  35. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +9 -9
  36. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +21 -21
  37. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +9 -9
  38. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +38 -38
  39. package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +5 -5
  40. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +63 -26
  41. package/packages/dsh-remote-web/test/account-switch-device.test.mjs +2 -2
  42. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +1 -1
  43. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +1 -1
  44. package/packages/dsh-remote-web/test/connect-loop.test.mjs +1 -1
  45. package/packages/dsh-remote-web/test/connect-ui.test.mjs +2 -2
  46. package/packages/dsh-remote-web/test/desktop-compat.test.mjs +4 -4
  47. package/packages/dsh-remote-web/test/diag-report.test.mjs +1 -1
  48. package/packages/dsh-remote-web/test/fix-prompt.test.mjs +2 -2
  49. package/packages/dsh-remote-web/test/harness-restart.test.mjs +1 -1
  50. package/packages/dsh-remote-web/test/install-profile-targets.test.mjs +6 -4
  51. package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
  52. package/packages/dsh-remote-web/test/launchd-domain.test.mjs +2 -2
  53. package/packages/dsh-remote-web/test/patch-activation.test.mjs +2 -2
  54. package/packages/dsh-remote-web/test/picker-pin.test.mjs +1 -1
  55. package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
  56. package/packages/dsh-remote-web/test/quota-absent.test.mjs +4 -4
  57. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +2 -2
  58. package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +6 -6
  59. package/packages/dsh-remote-web/test/settings-entry.test.mjs +2 -2
  60. package/packages/dsh-remote-web/test/settings-open-path.test.mjs +4 -4
  61. package/packages/dsh-remote-web/test/setup-output.test.mjs +32 -9
  62. package/packages/dsh-remote-web/test/tarball-update.test.mjs +1 -1
  63. package/packages/dsh-remote-web/test/telemetry.test.mjs +4 -4
  64. package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +2 -2
  65. package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +1 -1
  66. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +7 -7
  67. package/packages/dsh-remote-web/test/windows-compat.test.mjs +5 -5
  68. package/scripts/test-net-guard.cjs +1 -1
@@ -194,7 +194,7 @@ function printHelp() {
194
194
  // 认识的参数(只给 flag 也要能装上去,所以不认识时才拦)。
195
195
  const KNOWN_FLAGS = new Set([
196
196
  "--api", "--server", "--key", "--profile",
197
- "--no-autostart", "--no-plugin", "--no-restart", "--uninstall"
197
+ "--no-autostart", "--no-plugin", "--no-restart", "--uninstall", "--force"
198
198
  ]);
199
199
 
200
200
  // ⚠️ 这一整块必须挡在 ensureRuntimeCopy() **之前**:它会往 ~/.dsh-remote 同步运行时脚本。
@@ -254,7 +254,7 @@ function resolveExecutable(p) {
254
254
  * 桌面端 `process.execPath` 是 **Electron 应用二进制**而不是 node:不带
255
255
  * `ELECTRON_RUN_AS_NODE=1` 直接拿它跑脚本,得到的不是「跑脚本的 node」,而是
256
256
  * **又启动一个桌面端应用实例**。自启动服务是系统在应用退出后拉起的,踩到这里
257
- * 的表现是「服务登记成功、进程秒退、bridge 永远起不来」,与 0.6.15 那次端口事故
257
+ * 的表现是「服务登记成功、进程秒退、bridge 永远起不来」,与 0.6.15 那次端口故障
258
258
  * 一样难排查 —— 所以必须在**写服务定义时**就把变量带上。
259
259
  */
260
260
  const IS_ELECTRON_HOST = Boolean(process.versions && process.versions.electron);
@@ -359,7 +359,7 @@ function nodeEnv(extra) {
359
359
  */
360
360
  const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
361
361
 
362
- // ---------- 安装口径(随 bridge 环境变量注入;服务端存到设备行,用于区分安装来源) ----------
362
+ // ---------- 安装约定(随 bridge 环境变量注入;服务端存到设备行,用于区分安装来源) ----------
363
363
  /**
364
364
  * install_source:本文件即「一键安装器」→ 默认 npx(用户自己跑 `npx @mrrisega/dsh-remote` 的那条路径)。
365
365
  * 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
@@ -526,7 +526,7 @@ function readPidFile(name) {
526
526
  *
527
527
  * 为什么必须查:pid 文件可能过期,而 pid 会被系统**回收给无关进程**。
528
528
  * 去重逻辑信任 `pidAlive()` 只会导致"我多退一次"(安全方向);但监管者接管时要
529
- * **主动结束**那个 pid —— 盲杀一个被回收的无关进程是事故。所以动它之前先认一下命令行。
529
+ * **主动结束**那个 pid —— 盲杀一个被回收的无关进程是故障。所以动它之前先认一下命令行。
530
530
  */
531
531
  function looksLikeWatcher(pid) {
532
532
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -838,7 +838,7 @@ function spawnDetachedBridge() {
838
838
  * ③ 后台进程 ← 连 launchd 都托管不了时保证可用
839
839
  */
840
840
  /**
841
- * 🔴 标签占用保护(2026-10-10 事故):`com.dshremote.bridge` 这个 launchd label 是**全局唯一**的,
841
+ * 🔴 标签占用保护(2026-10-10 故障):`com.dshremote.bridge` 这个 launchd label 是**全局唯一**的,
842
842
  * 谁 bootstrap 谁就接管它。当天跑测试时,某个用例用**假 HOME + 真 launchctl** 把测试临时目录的
843
843
  * plist 登记到了同一个 label 上 → **真桥被顶掉**、远程访问中断,而日志里看不出任何异常。
844
844
  *
@@ -984,7 +984,7 @@ function restartBridgeService() {
984
984
  /**
985
985
  * 上游 dsh web 地址(bridge 的转发目标 + watcher 的「dsh web 在线吗」判据**同一个来源**)。
986
986
  *
987
- * 【2026-09-23 用户实测事故】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
987
+ * 【2026-09-23 用户实测故障】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
988
988
  * 不在默认端口(`dsh web --port 8090`、profile 里配了 port、或 `--port 0` 让系统分配空闲端口),
989
989
  * watcher 的在线探测就**永远**失败 → bridge 永远不启动,而面板只会说「正在启动 Bridge…」。
990
990
  * 优先级:显式环境变量 > **运行时动态发现** > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
@@ -1006,9 +1006,9 @@ let PINNED_UPSTREAM = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().repla
1006
1006
  /**
1007
1007
  * 探测桌面端(Electron 应用)当前监听的**回环端口**。
1008
1008
  *
1009
- * 为什么需要(2026-10-11 实测事故):插件同时装进 web 与 desktop 后,**每个宿主**都会在装载时
1009
+ * 为什么需要(2026-10-11 实测故障):插件同时装进 web 与 desktop 后,**每个宿主**都会在装载时
1010
1010
  * 把自己 `ctx.webServer.port` 写进 `<relayDir>/.dsh-upstream` —— 谁最后写谁生效。于是桥的上游
1011
- * 会在两个应用之间来回跳:那次桥跑去连了 Web 版的 3080,而业主面对的是桌面端 19387,
1011
+ * 会在两个应用之间来回跳:那次桥跑去连了 Web 版的 3080,而用户面对的是桌面端 19387,
1012
1012
  * 表现就是"命令跑完了但没装上 / 手机连上看到的不是我在用的界面"。
1013
1013
  * 装桌面端时把端口**钉进自启动配置**(环境变量优先级最高),两个宿主就抢不动了。
1014
1014
  */
@@ -1105,7 +1105,7 @@ async function isDshWebUp(timeoutMs = 1200) {
1105
1105
 
1106
1106
  /** 已知的 DSH profile 名(`--profile web` / `--profile desktop` 这种写法要能用)。 */
1107
1107
  const KNOWN_PROFILE_NAMES = ["web", "desktop"];
1108
- /** profile 名 → 目录(与 dsh 自己的 `$DSH_HOME/profiles/<name>` 口径一致)。 */
1108
+ /** profile 名 → 目录(与 dsh 自己的 `$DSH_HOME/profiles/<name>` 约定一致)。 */
1109
1109
  function profileDirOfName(name) {
1110
1110
  return path.join(os.homedir(), ".dsh", "profiles", name);
1111
1111
  }
@@ -1115,13 +1115,13 @@ function isDesktopProfileDir(dir) {
1115
1115
  }
1116
1116
 
1117
1117
  /**
1118
- * 解析目标 profile 目录(安装与插件管理共用同一口径)。
1118
+ * 解析目标 profile 目录(安装与插件管理共用同一约定)。
1119
1119
  *
1120
1120
  * 三种写法都支持:
1121
1121
  * · `--profile web` / `--profile desktop` → `~/.dsh/profiles/<name>`
1122
1122
  * (新增:DeepSeek Harness 桌面端的 profile 就叫 `desktop`)
1123
1123
  * · `--profile <任意路径>` → 原样当目录用(老行为,保持不变)
1124
- * · `DSH_PROFILE_DIR` 环境变量 → 与 `--profile` 同口径
1124
+ * · `DSH_PROFILE_DIR` 环境变量 → 与 `--profile` 同约定
1125
1125
  * 缺省仍是 Web 版 profile,行为与改动前完全一致。
1126
1126
  */
1127
1127
  function resolveProfileDir(argv = []) {
@@ -1139,9 +1139,33 @@ function resolveProfileDir(argv = []) {
1139
1139
  * `pluginCmd()` 时原本传的是空数组 —— 于是"指定 desktop profile"会被静默忽略、
1140
1140
  * 插件仍然装进 Web 版 profile(桌面端用户装了却看不到面板,且极难自查)。
1141
1141
  */
1142
+ /**
1143
+ * DeepSeek Harness **桌面应用**是否正在运行。
1144
+ *
1145
+ * 🔴 2026-10-11 故障:我在应用运行中改写了 `~/.dsh/profiles/desktop/` 里的插件文件,
1146
+ * 应用随即**自己退出/重启**(用户:"怎么又触发了 dsh 的关闭")。安装器自己的提示早就写着
1147
+ * 「请先**完全退出** DeepSeek Harness 应用再安装(应用独占该 profile)」—— 这条必须由程序强制,
1148
+ * 不能靠"我记得"。
1149
+ * 检测方式:macOS 上桌面应用的进程名就是 `DeepSeek`(lsof/pgrep 实测),另加 App 包路径兜底。
1150
+ */
1151
+ function dshDesktopAppRunning() {
1152
+ // 显式覆盖(测试隔离 / 探测误报时的逃生口):DSH_DESKTOP_APP_RUNNING=0|1
1153
+ const override = String(process.env.DSH_DESKTOP_APP_RUNNING || "").trim();
1154
+ if (override === "0" || override === "1") return override === "1";
1155
+ if (process.platform !== "darwin") return false;
1156
+ try {
1157
+ // 实测(2026-10-11):`pgrep -x DeepSeek` 与 `pgrep -f "...app/Contents/MacOS"` 都匹配不到,
1158
+ // 必须用下面的写法(`Contents/MacOS/DeepSeek` 或短名 `DeepSeek Harness.app` 均可命中)。
1159
+ const a = sh("pgrep -f 'Contents/MacOS/DeepSeek'");
1160
+ if (a.ok && String(a.stdout || "").trim()) return true;
1161
+ const b = sh("lsof -nP -iTCP:19387 -sTCP:LISTEN");
1162
+ return Boolean(b.ok && /LISTEN/.test(String(b.stdout || "")));
1163
+ } catch { return false; }
1164
+ }
1165
+
1142
1166
  /**
1143
1167
  * 探测本机**已存在**的 profile(可装载 = 同时有 package.json 与 cordis.patch.yml)。
1144
- * desktop 排在前面:业主主用的是桌面应用,多个 profile 一起装时它是"首选/权威"那个。
1168
+ * desktop 排在前面:用户主用的是桌面应用,多个 profile 一起装时它是"首选/权威"那个。
1145
1169
  */
1146
1170
  function detectProfileDirs() {
1147
1171
  const root = path.join(os.homedir(), ".dsh", "profiles");
@@ -1159,7 +1183,7 @@ function detectProfileDirs() {
1159
1183
  /**
1160
1184
  * 本次要操作的 profile 列表。
1161
1185
  *
1162
- * 业主口径(2026-10-11):「默认安装的时候也能同步安装给 desktop」。
1186
+ * 用户约定(2026-10-11):「默认安装的时候也能同步安装给 desktop」。
1163
1187
  * 以前不带 `--profile` 时**写死装 web** —— 桌面端用户执行官方那条 `npx @mrrisega/dsh-remote@beta`
1164
1188
  * 之后,插件全进了 Web 版 profile,而用户面对的是桌面应用 → 面板永远不出现,且极难自查。
1165
1189
  *
@@ -1175,7 +1199,7 @@ function targetProfileDirs(argv = []) {
1175
1199
  if (!all.length) return [{ name: "web", dir: profileDirOfName("web") }];
1176
1200
  /* 默认装哪些?**不是"全部"** —— 机器上常有过实验/自动化用的 profile(headless、web02…),
1177
1201
  往那些里面塞插件是越界。规则:
1178
- · 已知的面向用户的 profile(web / desktop):**有就装**(这是业主的核心诉求:默认也要给 desktop)
1202
+ · 已知的面向用户的 profile(web / desktop):**有就装**(这是用户的核心诉求:默认也要给 desktop)
1179
1203
  · 其他 profile:**只有本来就已经装了插件**才继续管(升级不能悄悄漏掉它)
1180
1204
  · 上面并集为空(例如只有 headless 一种)→ 退回"全部已存在",避免什么都不装 */
1181
1205
  const chosen = all.filter((p) => KNOWN_PROFILE_NAMES.includes(p.name)
@@ -1429,7 +1453,7 @@ function loginRateLimitUntil() {
1429
1453
 
1430
1454
  /* ── 守护启动锁:把"一堆守护同时被拉起"的惊群收敛成串行 ─────────────────────────────
1431
1455
  *
1432
- * 🔴 2026-09-26 真机事故(dsh web 启动故障的连带伤害):一次重启窗口里同时出现了
1456
+ * 🔴 2026-09-26 真机故障(dsh web 启动故障的连带伤害):一次重启窗口里同时出现了
1433
1457
  * **6 个 `dsh-setup.mjs run` 守护 + 6 个 bridge**,全部用同一个 deviceId 去 router 注册,
1434
1458
  * 互相把对方顶掉 —— bridge 日志刷满
1435
1459
  * `隧道断开(code=4000, replaced by new connection)` → `2s 后重连` → 再被顶掉,
@@ -1615,7 +1639,7 @@ async function runBridge() {
1615
1639
 
1616
1640
  // 账号/模式/设备身份变了 → 必须重启 bridge(它的凭据是启动时从环境变量固化的)。
1617
1641
  // 没有这一条时:面板切了账号、旧 bridge 却继续用旧账号跑,新账号的设备列表里看不到这台机器
1618
- //(2026-09-19 实测事故)。
1642
+ //(2026-09-19 实测故障)。
1619
1643
  const fp = accountFingerprint(cfg);
1620
1644
  if (!childStopped(bridgeProc) && bridgeAccountFp && bridgeAccountFp !== fp) {
1621
1645
  console.log("[dsh-remote] 账号配置已变化 → 重启 bridge 让新账号生效...");
@@ -1646,7 +1670,7 @@ async function runBridge() {
1646
1670
  DSH_BRIDGE_TUNNEL_URL: cfg.tunnel_url,
1647
1671
  // 上游端口显式传给 bridge:它自己在旧版本里也回退到写死的 3080(见 dsh-bridge.mjs 的 UPSTREAM)。
1648
1672
  DSH_BRIDGE_UPSTREAM: upstreamUrl(),
1649
- // 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
1673
+ // 安装约定透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
1650
1674
  DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
1651
1675
  DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
1652
1676
  // 宿主版本:外部注入(插件在宿主进程内问出来的),安装器只是搬运工;没有就是空串
@@ -1879,7 +1903,7 @@ async function setup(argv) {
1879
1903
  }
1880
1904
 
1881
1905
  const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
1882
- // 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改口径。
1906
+ // 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改约定。
1883
1907
  // 三分支:running / 未运行(有原因) / 未安装(跳过或平台不支持)——最后一种既不能显示"运行中",
1884
1908
  // 也不能显示"未运行(原因)",它压根没装(此前会打出自相矛盾的"(当前平台不支持) — ✅ 运行中")。
1885
1909
  const svcSkipped = st.status === "skipped" || st.status === "unsupported" || !svc.path;
@@ -1888,7 +1912,7 @@ async function setup(argv) {
1888
1912
  // Windows 没有 launchd,这条判断对它不成立(否则会把 win32 误报成"本机不支持崩溃自愈")。
1889
1913
  const svcFragile = !IS_WIN && !svcSkipped && st.ok && st.selfHealing === false;
1890
1914
  const svcDegraded = st.status === "degraded-detached";
1891
- // Windows:自启动 = 任务计划程序里的登录任务。口径与 macOS/Linux 不同
1915
+ // Windows:自启动 = 任务计划程序里的登录任务。约定与 macOS/Linux 不同
1892
1916
  // ("任务已注册" ≠ "进程此刻在跑"),所以单独给文案,避免复用"未被系统托管"这种会误导的措辞。
1893
1917
  const winTask = IS_WIN && winTaskOk();
1894
1918
  const svcState = IS_WIN
@@ -2086,7 +2110,7 @@ function compareVersionStrings(a, b) {
2086
2110
  }
2087
2111
 
2088
2112
  /**
2089
- * 解析 patch 文本里的 insert 行 id(与 dshmarket 同口径的极简解析,不引 YAML 依赖)。
2113
+ * 解析 patch 文本里的 insert 行 id(与 dshmarket 同约定的极简解析,不引 YAML 依赖)。
2090
2114
  * 用途有二:① 判断本插件是否已激活(幂等);② **写完校验**——patch 文件形态非法时
2091
2115
  * dsh web 会直接启动失败,所以每次写入后必须重新解析确认结果仍是「一行一条的 insert 列表」。
2092
2116
  * @returns {{ids: string[], badLines: string[]}} badLines 非空 = 文件形态不合法
@@ -2195,7 +2219,7 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
2195
2219
  };
2196
2220
  // ★★ 稳定形态 = **只由 `dsh.profile.bundles` 激活**,我们**不再往 patch 里写激活行**。
2197
2221
  //
2198
- // 2026-09-22 实测事故:`duplicate loader entry id: dsh-remote-web` →
2222
+ // 2026-09-22 实测故障:`duplicate loader entry id: dsh-remote-web` →
2199
2223
  // `dsh: plugin tree failed to load` → **dsh web 启动即挂**(用真实启动复现)。
2200
2224
  // 根因是**两处激活同时存在**:
2201
2225
  // (a) 插件自带的 bundle 在 `package.json` 的 `dsh.profile.bundles` 里;
@@ -2298,7 +2322,7 @@ function normalizePatchBase(patch) {
2298
2322
  /**
2299
2323
  * 插件目录里**必须存在且非空**的文件。缺任何一个都说明这次拷贝是"半个插件"。
2300
2324
  *
2301
- * 🔴 为什么必须有这一步(2026-09-26 23:46 真实事故):当时是"先 rmSync 整个 dest、再逐文件 cpSync",
2325
+ * 🔴 为什么必须有这一步(2026-09-26 23:46 真实故障):当时是"先 rmSync 整个 dest、再逐文件 cpSync",
2302
2326
  * 而 dsh 恰好在这个空窗里启动 → `package.json` 已经声明 bundle,但 `cordis.patch.yml` 还没拷到
2303
2327
  * → dsh 读 overlay 直接 `ENOENT: no such file or directory` 退出,**整个 GUI 起不来**:
2304
2328
  *
@@ -2329,7 +2353,7 @@ function missingPluginFiles(dir) {
2329
2353
  * 源(本包 packages/${PLUGIN_ID})可能在 npx 临时缓存里,因此必须拷到 profile 内持久化,
2330
2354
  * include 用相对路径直接指向拷贝出的入口,不写依赖、不跑任何包管理器。
2331
2355
  *
2332
- * 🔴 **原子覆盖(2026-09-26 事故后的硬要求)**:
2356
+ * 🔴 **原子覆盖(2026-09-26 故障后的硬要求)**:
2333
2357
  * 1. 先整份拷到同级的 `.staging-<pid>`,**全程不碰 dest** —— dsh 任何时刻读到的
2334
2358
  * 要么是完整的旧版、要么是完整的新版;
2335
2359
  * 2. 拷完**校验**必需文件(见 `PLUGIN_REQUIRED_FILES`),不通过就丢弃 staging、抛错、
@@ -2382,7 +2406,7 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
2382
2406
  throw new Error(`插件拷贝失败(已保留原目录不动):${e && e.message ? e.message : e}`);
2383
2407
  }
2384
2408
 
2385
- // 2) 自检:必需文件一个都不能少(这一步就是 23:46 那次事故的闸门)
2409
+ // 2) 自检:必需文件一个都不能少(这一步就是 23:46 那次故障的闸门)
2386
2410
  const missing = missingPluginFiles(stage);
2387
2411
  if (missing.length > 0) {
2388
2412
  try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
@@ -2615,7 +2639,7 @@ function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg
2615
2639
  console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
2616
2640
  process.exit(1);
2617
2641
  }
2618
- // ⚠️ 顺序与"失败即中止"都是刻意的(0.6.7-beta.1 Windows 实测事故):
2642
+ // ⚠️ 顺序与"失败即中止"都是刻意的(0.6.7-beta.1 Windows 实测故障):
2619
2643
  // 老代码把 ensurePluginLinked 的返回值丢掉、patch 照写、还打印「✅ 插件已就绪」,
2620
2644
  // 于是用户拿到的是**起不来的 dsh web**(cordis 按裸包名解析到空目录 → plugin tree failed to load,
2621
2645
  // 整个 dsh web 启动失败,而面板/插件市场都在那个进程里 → 用户没有任何自助修复入口)。
@@ -2787,14 +2811,27 @@ async function pluginCmd(argv) {
2787
2811
  }
2788
2812
  let last = null;
2789
2813
  let ok = 0;
2814
+ let skippedRunning = 0;
2815
+ const force = hasFlag(argv, "--force");
2790
2816
  for (const t of targets) {
2817
+ // ⚠️ 桌面应用在运行时**绝不改写它的 profile**:应用会因此退出/重启(2026-10-11 实测故障)
2818
+ if (t.name === "desktop" && !force && dshDesktopAppRunning()) {
2819
+ console.log(`⚠️ 跳过 desktop:DeepSeek Harness 应用正在运行。`);
2820
+ console.log(` 应用独占该 profile,运行中改写会让应用自己退出/重启。请任选其一:`);
2821
+ console.log(` · 完全退出应用后重跑本命令;或`);
2822
+ console.log(` · 在应用内「插件」页安装/更新 dsh-remote-web(官方路径,装完即热加载)。`);
2823
+ console.log(` (确实要强行写入:加 --force)`);
2824
+ skippedRunning += 1;
2825
+ continue;
2826
+ }
2791
2827
  if (multi) console.log(`\n── profile: ${t.name}(${t.dir})`);
2792
2828
  const r = await pluginApplyTo(t.dir, uninstall, multi);
2793
2829
  if (r === "skip") continue;
2794
2830
  ok += 1;
2795
2831
  if (r !== undefined && r !== null) last = r;
2796
2832
  }
2797
- if (ok === 0) process.exit(1);
2833
+ if (ok === 0 && skippedRunning === 0) process.exit(1);
2834
+ if (skippedRunning > 0) console.log(`ℹ 本次跳过了 ${skippedRunning} 个 profile(应用在运行);桥与运行环境不受影响。`);
2798
2835
  return last;
2799
2836
  }
2800
2837
 
@@ -243,7 +243,7 @@ test("退出登录:必须停 bridge 并作废设备身份(否则旧账号的
243
243
  }
244
244
  });
245
245
 
246
- // ─────────── ③ launchd 域名阶梯(事故的直接技术原因) ───────────
246
+ // ─────────── ③ launchd 域名阶梯(故障的直接技术原因) ───────────
247
247
 
248
248
  test("launchd:作业在 user/<uid> 域时也必须被认成「运行中」(旧实现只看 gui/<uid>)", { skip: process.platform !== "darwin" }, async () => {
249
249
  const env = await makeEnv();
@@ -270,7 +270,7 @@ test("launchd:作业在 user/<uid> 域时也必须被认成「运行中」(
270
270
  }
271
271
  });
272
272
 
273
- test("源码契约:启动/停止都要覆盖两个 launchd 域(只认 gui 就是本次事故)", () => {
273
+ test("源码契约:启动/停止都要覆盖两个 launchd 域(只认 gui 就是本次故障)", () => {
274
274
  assert.ok(/function launchDomains\(\)/.test(INDEX_SRC), "应有统一的域阶梯");
275
275
  const startFn = INDEX_SRC.slice(INDEX_SRC.indexOf("function startBridge(relayDir)"), INDEX_SRC.indexOf("function scheduleRuntime("));
276
276
  assert.ok(/for \(const domain of launchDomains\(\)\)/.test(startFn), "启动必须遍历域阶梯(user 优先)");
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 激活点唯一性 —— 配置不变量 + 安装器方向守护。
3
3
  *
4
- * 背景(2026-09-22 实测事故):插件被两处同时激活时,dsh web **启动即挂**:
4
+ * 背景(2026-09-22 实测故障):插件被两处同时激活时,dsh web **启动即挂**:
5
5
  * `duplicate loader entry id: dsh-remote-web` → `plugin tree failed to load`。
6
6
  * 不是"插件不生效",是**dsh 起不来**。
7
7
  *
@@ -1,6 +1,6 @@
1
1
  // 📣 公告(插件端)回归:徽标 / 面板入口 / 电脑端弹窗 / 已读上报 / 一条铁律(**不轮询**)。
2
2
  //
3
- // 契约:dsh-relay-enterprise/docs/announcements-contract.md(冻结 v1)+ 926 迭代 §1(client=desktop)。
3
+ // 契约:服务端公告契约文档(冻结 v1)(冻结 v1)+ 926 迭代 §1(client=desktop)。
4
4
  // GET /dsh-remote/announcements?client=desktop → { bubble_enabled, items[], unread_count, popup|null }
5
5
  // POST /dsh-remote/announcements/click { id } → 记 clicked_at(**现契约只有 /click,没有 /read**)
6
6
  //
@@ -506,7 +506,7 @@ test("注册来源透传:/dsh-remote/register 全量转发 body(reg_source=p
506
506
  assert.equal(r.ok, true);
507
507
  const reg = relay.seen.filter((s) => s.path === "/api/register").pop();
508
508
  assert.ok(reg, "应转发到企业端 /api/register");
509
- assert.equal(reg.body.reg_source, "panel_register", "注册来源必须透传(增长口径)");
509
+ assert.equal(reg.body.reg_source, "panel_register", "注册来源必须透传(增长约定)");
510
510
  assert.equal(reg.body.invite_code, "A8K2M4XQ", "未知/扩展字段不得被丢弃");
511
511
  assert.equal(reg.body.captcha_id, "cap-1");
512
512
  assert.equal(reg.body.captcha_answer, "1234");
@@ -8,7 +8,7 @@
8
8
  // 已授权设备列表(全程不刷新页面);
9
9
  // ③ error 阶段必须有可操作项:重试 / 复制诊断信息 / 日志路径(不留死胡同);
10
10
  // ④ document.hidden 时短轮询暂停(后台标签页不空转),回前台立即补一次;online 后退避到 15s;
11
- // ⑤ 面板注册带注册来源 reg_source=panel_register(增长口径)。
11
+ // ⑤ 面板注册带注册来源 reg_source=panel_register(增长约定)。
12
12
  import assert from "node:assert/strict";
13
13
  import { readFileSync } from "node:fs";
14
14
  import test from "node:test";
@@ -377,7 +377,7 @@ test("轮询策略:document.hidden 时暂停短轮询,回前台立即补一
377
377
  assert.ok(plugin.counts().connCalls > onlineCalls + extra, "online 后仍应按 ~15s 节奏保活(新设备扫码后列表能自动出现)");
378
378
  });
379
379
 
380
- test("面板注册带注册来源 reg_source=panel_register(增长口径:电脑端 vs 手机端网页注册)", async () => {
380
+ test("面板注册带注册来源 reg_source=panel_register(增长约定:电脑端 vs 手机端网页注册)", async () => {
381
381
  const plugin = loadPlugin({ status: NOT_LOGGED });
382
382
  let tree = await plugin.settle();
383
383
  const regTab = nodeWithText(tree, "注册");
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 背景:DeepSeek Harness 于 2026-09-29 发布桌面端 v0.2 预览(Electron 薄壳 + 完整 dsh Web Host,
5
5
  * Host 以 `--no-open --port 0` 启动,端口随机、每进程令牌鉴权)。dsh-remote 的两大难点
6
- * (随机端口、会话 Cookie)此前已因真实事故适配过,因此这次新增的适配**集中在三处**:
6
+ * (随机端口、会话 Cookie)此前已因真实故障适配过,因此这次新增的适配**集中在三处**:
7
7
  *
8
8
  * ① **Electron 宿主下 process.execPath 不是 node**:它是 Electron 应用二进制。
9
9
  * 直接 `spawn(process.execPath, [script])` 得到的不是"跑脚本的 node",而是**又开一个桌面端应用**
@@ -92,7 +92,7 @@ test("spawnEnv:NODE_INTERPRETER 变量不得挤掉调用方的业务变量,P
92
92
  // ─────────────── ② 服务定义:plist / systemd 必须带上解释器变量(跨文件同构)───────────────
93
93
 
94
94
  test("跨文件不变量:插件与安装器的 plist / systemd 模板都必须带解释器变量占位", () => {
95
- // 为什么是"两份都要查":历史上前述两份模板漂移过、并因此各造成一个真机事故
95
+ // 为什么是"两份都要查":历史上前述两份模板漂移过、并因此各造成一个真机故障
96
96
  // (插件每次自愈都会重写 plist,谁写谁说了算 → 漂移等于把另一份正确的覆盖掉)。
97
97
  for (const [name, src] of [["lib/index.js", INDEX_SRC], ["dsh-setup.mjs", SETUP_SRC]]) {
98
98
  assert.match(src, /const SERVICE_NODE_ENV\b/, `${name}: 必须有 SERVICE_NODE_ENV`);
@@ -214,7 +214,7 @@ test("安装器:setup() 必须把 --profile 透传给插件挂载步骤", () =
214
214
  const { profileArgvOf } = makeResolveProfileDir("/Users/tester");
215
215
  assert.deepEqual(profileArgvOf(["--profile", "desktop"]), ["--profile", "desktop"]);
216
216
  assert.deepEqual(profileArgvOf([]), [], "未指定时不得凭空塞参数");
217
- // 事故面:setup() 原本调 pluginCmd([]) → --profile desktop 被静默忽略、插件装进 web profile
217
+ // 故障面:setup() 原本调 pluginCmd([]) → --profile desktop 被静默忽略、插件装进 web profile
218
218
  assert.match(SETUP_SRC, /pluginCmd\(profileArgvOf\(argv\)\)/, "setup 必须透传 profile 选择");
219
219
  assert.ok(!/pluginCmd\(\[\]\)/.test(SETUP_SRC), "不得再有硬编码空参数(会把 --profile 吞掉)");
220
220
  });
@@ -227,7 +227,7 @@ test("安装器:desktop profile 未初始化时给桌面端专属引导(不
227
227
  assert.match(fn, /isDesktopProfileDir\(profileDir\)/, "必须区分桌面端 profile");
228
228
  assert.match(fn, /启动一次 DeepSeek Harness 应用/, "必须给桌面端专属引导:profile 由应用自己创建");
229
229
  assert.match(fn, /完全退出应用/, "必须提醒完全退出(应用独占 profile,运行中安装不会被装载)");
230
- // 事故面:桌面端 profile 由 Electron 应用初始化,npm 版 dsh 明确拒绝管理它
230
+ // 故障面:桌面端 profile 由 Electron 应用初始化,npm 版 dsh 明确拒绝管理它
231
231
  //(apps/cli 的 rejectElectronProfile)—— 照抄 Web 版那句会让用户白折腾。
232
232
  assert.ok(!/未找到 dsh web profile/.test(fn), "不得再把桌面端统一报成 'dsh web profile'");
233
233
  });
@@ -219,7 +219,7 @@ test("修复提示词:带当前故障上下文(目录/上游/版本),便
219
219
  const b = await getJson(`${base}/dsh-remote/diag/fix-prompt`);
220
220
  assert.equal(b.ok, true);
221
221
  assert.match(b.prompt, new RegExp(env.relayDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "提示词要带上本机配置目录");
222
- assert.match(b.prompt, /127\.0\.0\.1:3080/, "提示词要带上上游地址(端口错配是本次事故的最大嫌疑)");
222
+ assert.match(b.prompt, /127\.0\.0\.1:3080/, "提示词要带上上游地址(端口错配是本次故障的最大嫌疑)");
223
223
  assert.match(b.prompt, /\.dsh-bridge\.log/);
224
224
  assert.match(b.prompt, /\.dsh-setup-install\.log/);
225
225
  assert.match(b.reportTitle, /dsh-remote 诊断结论/, "报告格式要有固定首行,便于后台机器解析");
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 「连接卡住 → 一键修复提示词 + 结论报告」契约测试(node:test,零框架)。
3
3
  *
4
- * 背景(真实事故,2026-09-23,Windows 用户):
4
+ * 背景(真实故障,2026-09-23,Windows 用户):
5
5
  * 插件面板卡在「连接阶段: starting(正在启动 Bridge…)」,手机端看不到任何设备,反复修都没用。
6
6
  * 事后定位到三种机制(半装运行环境 / 守护活着没起作用 / bridge 起来就崩),它们在面板上长得一模一样,
7
7
  * 面板侧诊断分不开;只能由用户本机的 agent 在自己电脑上取证。
@@ -120,7 +120,7 @@ test("报告格式:REPORT_KEYS 六个字段齐全,REPORT_FORMAT 首行即固
120
120
  for (const key of ["症状", "机制判定", "关键证据", "已做的修复", "修复结果", "仍需人工处理"]) {
121
121
  assert.ok(REPORT_KEYS.includes(key), `REPORT_KEYS 应覆盖「${key}」`);
122
122
  }
123
- assert.equal(REPORT_KEYS.length, 6, "REPORT_KEYS 就是这六项(多了会让解析口径漂移)");
123
+ assert.equal(REPORT_KEYS.length, 6, "REPORT_KEYS 就是这六项(多了会让解析约定漂移)");
124
124
  assert.equal(REPORT_FORMAT.split("\n")[0], REPORT_TITLE, "报告模板首行必须是固定标题");
125
125
  assert.equal(REPORT_TITLE, "=== dsh-remote 诊断结论 ===");
126
126
  // 六项在模板里都要有对应行,且顺序与 REPORT_KEYS 一致
@@ -141,7 +141,7 @@ test("首次安装(运行环境在本进程内补齐)→ **不再**提示重
141
141
  });
142
142
 
143
143
  test("补装把**同版本**插件重写一遍 → 不得提示(用户实测的误报)", async () => {
144
- // 事故复盘:安装器/自愈补装会把 profile 副本整目录重写,即使是**同一个版本**。
144
+ // 故障回顾:安装器/自愈补装会把 profile 副本整目录重写,即使是**同一个版本**。
145
145
  // 旧实现只看"插件文件 mtime 晚于进程启动"→ 误判成"有新插件要装载"→ 弹出需要重启/刷新的横幅,
146
146
  // 而用户刷新后横幅还在(因为第二次补装又写了一遍)、最后靠重启才消失。
147
147
  // 现在判的是「磁盘版本 != 运行版本」,版本相同就什么都不提示。
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 「默认安装装给谁」契约测试(2026-10-11 业主口径)。
2
+ * 「默认安装装给谁」契约测试(2026-10-11 用户约定)。
3
3
  *
4
- * 事故/反馈:业主执行官方那条 `npx --yes @mrrisega/dsh-remote@beta`(不带参数)后,
4
+ * 故障/反馈:用户执行官方那条 `npx --yes @mrrisega/dsh-remote@beta`(不带参数)后,
5
5
  * 插件**全进了 Web 版 profile**,而他面对的是**桌面应用** —— 面板永远不出现,
6
6
  * 且面板/文档里都看不出来"装错了地方"。根因:`resolveProfileDir()` 在没有 `--profile`
7
7
  * 与 `DSH_PROFILE_DIR` 时**写死 `profileDirOfName("web")`**。
@@ -42,7 +42,9 @@ function makeProfile(home, name, { withPlugin = false } = {}) {
42
42
 
43
43
  /** 跑真实的 `plugin` 子进程。**显式清掉 DSH_PROFILE_DIR**:宿主环境天然带着它(会盖掉默认多选)。 */
44
44
  function runPlugin(home, args = []) {
45
- const env = { ...process.env, HOME: home, DSH_PROFILE_DIR: "", DSH_RELAY_DIR: join(home, ".dsh-remote"), DSH_RELAY_SKIP_SERVICE: "1" };
45
+ const env = { ...process.env, HOME: home, DSH_PROFILE_DIR: "", DSH_RELAY_DIR: join(home, ".dsh-remote"), DSH_RELAY_SKIP_SERVICE: "1",
46
+ // 假 HOME 与真机无关:显式声明"桌面应用未运行",否则会命中"应用在跑就别写 desktop"的保护
47
+ DSH_DESKTOP_APP_RUNNING: "0" };
46
48
  const r = spawnSync(process.execPath, [SETUP, "plugin", ...args], { encoding: "utf8", env, timeout: 120000 });
47
49
  return { out: String(r.stdout || ""), err: String(r.stderr || ""), code: r.status };
48
50
  }
@@ -58,7 +60,7 @@ test("默认(不带 --profile):桌面端与 Web 版**都装**,实验 pro
58
60
  const headless = makeProfile(home, "headless");
59
61
  const r = runPlugin(home);
60
62
  assert.equal(r.code, 0, `安装应成功:${r.err.slice(0, 300)}`);
61
- // ★ 业主的核心诉求:默认也要装给 desktop
63
+ // ★ 用户的核心诉求:默认也要装给 desktop
62
64
  assert.ok(hasPlugin(desktop), "★ 默认安装必须装进 desktop profile(否则桌面端用户看不到面板)");
63
65
  assert.ok(patched(desktop), "desktop 的 patch 必须写入激活行");
64
66
  assert.ok(hasPlugin(web), "Web 版 profile 同样要装");
@@ -11,7 +11,7 @@
11
11
  * ② 参数拼错(不认识的 flag)→ 明确报错、退出码 1、**也不碰磁盘**。
12
12
  *
13
13
  * ⚠️ 隔离:所有用例都伪造 HOME + DSH_PROFILE_DIR,并设 DSH_RELAY_SKIP_SERVICE=1,
14
- * 否则会污染真实的自启动服务与 ~/.dsh-remote(见 setup-output.test.mjs 的事故复盘)。
14
+ * 否则会污染真实的自启动服务与 ~/.dsh-remote(见 setup-output.test.mjs 的故障回顾)。
15
15
  */
16
16
  import assert from "node:assert/strict";
17
17
  import { execFile } from "node:child_process";
@@ -114,7 +114,7 @@ test("★ 两份 plist 模板的 PATH 必须一致(插件那份曾漏掉 /usr/
114
114
  });
115
115
 
116
116
  test("★ 被监管者(launchd)拉起的实例不得因『已有游离守护』而秒退", () => {
117
- // 真机事故链:KeepAlive 重拉 → 本进程发现 pid 文件里已有游离守护 → 秒退
117
+ // 真机故障链:KeepAlive 重拉 → 本进程发现 pid 文件里已有游离守护 → 秒退
118
118
  // → 监管者判为崩溃并节流(`launchctl print` = `state = spawn scheduled`)
119
119
  // → 插件自愈认定"服务坏了" → bootout 摘作业(入口判定成立时连 plist 一起删)
120
120
  // → **自启动彻底消失,`launchctl kickstart` 报"域里找不到该服务"**。
@@ -184,7 +184,7 @@ test("★ watcher 退出前必须确保子进程真的死了(只『请』不
184
184
  });
185
185
 
186
186
  test("★ 守护惊群:启动锁必须排在去重读取**之前**,且先写 pid 再释放锁", () => {
187
- // 2026-09-26 真机事故:一次重启窗口里同时活下来 6 个 `dsh-setup.mjs run` + 6 个 bridge,
187
+ // 2026-09-26 真机故障:一次重启窗口里同时活下来 6 个 `dsh-setup.mjs run` + 6 个 bridge,
188
188
  // 全部用同一 deviceId 抢 router 注册(日志刷满 `replaced by new connection`),隧道无限抖动。
189
189
  // 成因:去重是"先读 pid 文件、后写 pid 文件",N 个实例同时启动时每个都读到"没有别人"。
190
190
  const rb = src.slice(src.indexOf("async function runBridge()"), src.indexOf("// ---------- setup"));
@@ -172,7 +172,7 @@ test("安全护栏:原子写回 —— 不留 .tmp 残留,且末尾换行规
172
172
  });
173
173
 
174
174
  test("回归护栏:plugin 子命令必须把激活结果 return 给 setup(否则热挂载验证被跳过)", () => {
175
- // 事故复盘:convergePluginActivation 已经返回 {hotPatch},但 pluginCmd 没把它 return 出去,
175
+ // 故障回顾:convergePluginActivation 已经返回 {hotPatch},但 pluginCmd 没把它 return 出去,
176
176
  // 于是 setup() 拿不到激活形态 → 跳过热挂载验证 → 明明热挂载成功却仍报「需要重启」。
177
177
  const src = readFileSync(SETUP, "utf8");
178
178
  const call = src.match(/return convergePluginActivation\(profileDir[^\n]*/);
@@ -230,7 +230,7 @@ test("硬约束:写入 patch 行后 bundles 必须被清空(单一激活点
230
230
  });
231
231
 
232
232
  test("回归护栏:热挂载探测必须比对**版本**,不能只看接口 200", () => {
233
- // 事故复盘:profile 已更新到 0.6.4,运行中的插件仍是 beta.10,而旧探测只看
233
+ // 故障回顾:profile 已更新到 0.6.4,运行中的插件仍是 beta.10,而旧探测只看
234
234
  // `/dsh-remote/status` 是否 200(旧版本本来就有这个接口)→ 误报"热加载成功",
235
235
  // 用户以为已生效、实际还在跑旧代码。
236
236
  const src = readFileSync(SETUP, "utf8");
@@ -190,7 +190,7 @@ test("诊断面板必须显示目录选择器状态(这类'按钮没反应'才
190
190
  });
191
191
 
192
192
  test("接线:apply 挂的是**延后**接线(schedulePickerPin),绝不在启动期直接切 Loader", () => {
193
- // 🔴 2026-09-26 事故锁:旧实现是 apply() 里直接 `pinBrowseDirectoryPicker(ctx)`,
193
+ // 🔴 2026-09-26 回归锁:旧实现是 apply() 里直接 `pinBrowseDirectoryPicker(ctx)`,
194
194
  // 于是 dsh 还在装配插件图时就去 loader.remove/create → dsh-client-modules 反复 compose
195
195
  // 整张前端图(buildCombo/newlineCount/identitySectionMap 热点)→ dsh web 3 分钟不监听、
196
196
  // 表现为假死。DSH_REMOTE_NATIVE_PICKER=1 能立刻恢复,正是这条链的证据。
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 插件清单与「一键更新静默失败」契约测试(node:test,零框架)。
3
3
  *
4
- * 事故背景(2026-09-15 生产反馈):
4
+ * 故障背景(2026-09-15 生产反馈):
5
5
  * A. 用户扫码后报「fail to load plugin / DeepSeek client collection」。
6
6
  * 排查发现 package.json 的 dsh.client.inject 声明了 `@deepseek-ai/dsh-client-runtime`,
7
7
  * 而该包在 harness 安装树里**根本不存在**(harness 只有 dsh-client-ui-* 等真包)。
@@ -84,7 +84,7 @@ test("一键更新:前端在进程结束但没成功时必须报错,而不
84
84
  });
85
85
 
86
86
  test("运行时同步:客户端脚本必须按**内容**补齐(否则手机端修复永远到不了用户)", () => {
87
- // 事故背景(2026-09-15 实测):手机端遮罩 bug 的修复落在 clients/dsh-remote/mobile-adapter.mjs,
87
+ // 故障背景(2026-09-15 实测):手机端遮罩 bug 的修复落在 clients/dsh-remote/mobile-adapter.mjs,
88
88
  // 而 bridge 是把**配置目录里那份**脚本注入官方页面后发给手机的。装置器原先只在"版本号变化"时
89
89
  // 才覆盖运行时副本,于是出现过:改了、发了版、用户升级了,但手机端拿到的仍是旧脚本 —— 修复不生效。
90
90
  // 现在按内容比对,只要与装置器手里的不一致就覆盖。
@@ -84,11 +84,11 @@ test("面板文案:额度数据缺失时不得断言任何限制", () => {
84
84
  // 【0.6.11 改断言】旧断言是
85
85
  // assert.match(CLIENT_SRC, /quota\.max_mbps \? "带宽 ≈" \+ quota\.max_mbps/, "带宽值服务端有才写")
86
86
  // 它编码的是**旧行为**(账号卡「更多」档位明细里的「当前:带宽 ≈N Mbps」那一行),不是不变量。
87
- // 业主口径:「插件面板里面不要展示『更多』里的 PRO 版本流量带宽,我看你把数字都展示出来了。
87
+ // 用户约定:「插件面板里面不要展示『更多』里的 PRO 版本流量带宽,我看你把数字都展示出来了。
88
88
  // 在本地的设置面板里面,把这个功能删掉。」→ 整块(renderPlanDetail + planSpecText/freeMbpsOf)
89
89
  // 已删,那个字符串在代码里不再存在。保留下来的**不变量**(比原来更强):
90
- // 客户端不得渲染任何带宽/流量/价格**数值** —— 服务端没下发时不许编(旧约束),下发了也不摆(新口径)。
91
- assert.doesNotMatch(CLIENT_SRC, /" Mbps"/, "面板不再渲染带宽数值(业主口径:本地面板不展示规格数字)");
90
+ // 客户端不得渲染任何带宽/流量/价格**数值** —— 服务端没下发时不许编(旧约束),下发了也不摆(新约定)。
91
+ assert.doesNotMatch(CLIENT_SRC, /" Mbps"/, "面板不再渲染带宽数值(用户约定:本地面板不展示规格数字)");
92
92
  assert.doesNotMatch(CLIENT_SRC, /" GB\/月,¥"/, "面板不再渲染流量/价格数值(档位明细已删)");
93
93
  // 但「已限速 / 不限速」这条**定性**提示仍在,且仍由服务端字段驱动(只是不再带数值)——
94
94
  // 这是上面第 ① 条不变量「服务端没说的限制一个字都不许编」的正面表达,也是必须保留的行为。
@@ -102,7 +102,7 @@ test("面板文案:额度数据缺失时不得断言任何限制", () => {
102
102
  assert.doesNotMatch(CLIENT_SRC, /:\s*" · 本月流量限额 1GB"/, "硬编码限额回退已移除");
103
103
  // 中继报告「不限量」时也不该说限额。
104
104
  // 【0.6.10 改断言】旧断言是 /免费用户 · 当前不限速、不限流量/ —— 那是**旧产品文案**(首屏挂档位名、
105
- // 用「流量」这个词),不是不变量;业主口径改为「首页面不展示会员相关信息」「流量一律叫额度」,
105
+ // 用「流量」这个词),不是不变量;用户约定改为「首页面不展示会员相关信息」「流量一律叫额度」,
106
106
  // 所以文案变成「当前不限速、不限额度」。这里保留的**不变量**是:中继明确报告不限量时必须如实说明
107
107
  // (下面这条分支仍在、仍由服务端字段驱动),不是那句具体措辞。
108
108
  assert.match(CLIENT_SRC, /当前不限速、不限额度/, "不限量应如实说明(首屏不再带档位名)");
@@ -428,7 +428,7 @@ test("「更多」只收纳营销入口:档位规格数字(Mbps / GB / 价
428
428
 
429
429
  // 【0.6.11】档位明细整块删除:标题「套餐与额度」、PRO / Pro Max 规格行、价格与结论句都不再渲染
430
430
  assert.ok(!textHas(body, "套餐与额度"), "「套餐与额度」标题应删除");
431
- assert.ok(!textHas(body, "Mbps"), "不得再渲染带宽数值(业主口径:本地面板不展示 PRO 版本流量带宽)");
431
+ assert.ok(!textHas(body, "Mbps"), "不得再渲染带宽数值(用户约定:本地面板不展示 PRO 版本流量带宽)");
432
432
  assert.ok(!textHas(body, "GB"), "不得再渲染流量数值");
433
433
  assert.ok(!textHas(body, "¥"), "不得再渲染价格");
434
434
  assert.ok(!textHas(body, "以套餐页为准"), "随档位明细一起删掉的结论句不得残留");
@@ -436,7 +436,7 @@ test("「更多」只收纳营销入口:档位规格数字(Mbps / GB / 价
436
436
  assert.ok(!textHas(tree, "Mbps"), "账号卡里不得出现 Mbps");
437
437
  assert.ok(!textHas(tree, "¥"), "账号卡里不得出现价格");
438
438
 
439
- // 但两条**折叠的营销入口**仍在(业主只要数字走,入口留着)
439
+ // 但两条**折叠的营销入口**仍在(用户只要数字走,入口留着)
440
440
  const upsell = find(body, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("升级 PRO")));
441
441
  const invite = find(body, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("带新用户换会员")));
442
442
  assert.ok(upsell, "「更多」里应保留「🚀 升级 PRO」入口");
@@ -1,9 +1,9 @@
1
- // dsh-remote-web 侧栏快捷入口「落点」回归(2026-10-09 桌面端事故)。
1
+ // dsh-remote-web 侧栏快捷入口「落点」回归(2026-10-09 桌面端故障)。
2
2
  //
3
- // 【事故】桌面端(DSH 0.2)设置面板本身就是 `role="dialog" aria-modal="true"` 的弹层,
3
+ // 【故障】桌面端(DSH 0.2)设置面板本身就是 `role="dialog" aria-modal="true"` 的弹层,
4
4
  // 它左侧栏目列表的第一项叫 **「通用设置」**。旧代码用「文本包含『设置』」(`indexOf !== -1`)
5
5
  // 去找侧栏那颗齿轮按钮 → **命中的是弹层里的「通用设置」**,于是我们的「📱 远程访问」
6
- // 快捷按钮被插进了设置面板栏目列表的最上面。业主截图反馈:「植入错了地方」。
6
+ // 快捷按钮被插进了设置面板栏目列表的最上面。用户截图反馈:「植入错了地方」。
7
7
  //
8
8
  // 本用例把 client.js 里的导航注入辅助函数**原样切出来**,喂一个最小 DOM,逐条锁住:
9
9
  // ① 官方「设置」触发器只按**语义锚点**(aria-haspopup="dialog")识别,且必须在弹层之外;
@@ -129,13 +129,13 @@ function desktopFixture() {
129
129
 
130
130
  // ───────────────────────── 用例 ─────────────────────────
131
131
 
132
- test("★ 事故锁:官方「设置」触发器只认语义锚点,且必须落在**设置弹层之外**", () => {
132
+ test("★ 回归锁:官方「设置」触发器只认语义锚点,且必须落在**设置弹层之外**", () => {
133
133
  const { doc, trigger } = desktopFixture();
134
134
  const { pickSettingsTrigger } = loadNavHelpers(doc);
135
135
  assert.equal(pickSettingsTrigger(), trigger, "必须命中侧栏那颗 aria-haspopup=dialog 的齿轮按钮");
136
136
  });
137
137
 
138
- test("★ 2026-10-11 业主实测:点入口弹出的是 token 用量弹窗 —— 官方设置槽位必须优先于底部槽邻居", () => {
138
+ test("★ 2026-10-11 用户实测:点入口弹出的是 token 用量弹窗 —— 官方设置槽位必须优先于底部槽邻居", () => {
139
139
  // 现场:入口挂在 sidebar.footer.action,而同一底部区域的「token 本轮用量」按钮
140
140
  // 也带 aria-haspopup="dialog"(甚至带快捷键)→ 旧实现"谁先扫到谁"就点中了它。
141
141
  // 现在:① 官方设置槽位里的按钮排第一;② 底部槽(我们所在那块)里的候选直接排除。
@@ -159,7 +159,7 @@ test("★ 2026-10-11 业主实测:点入口弹出的是 token 用量弹窗 —
159
159
  assert.equal(pickSettingsTrigger(), trigger, "★ 必须选中官方设置槽位里的齿轮,而不是 token 用量按钮");
160
160
  });
161
161
 
162
- test("★ 事故锁:弹层里的「通用设置」永远不能成为落点", () => {
162
+ test("★ 回归锁:弹层里的「通用设置」永远不能成为落点", () => {
163
163
  const { doc, general } = desktopFixture();
164
164
  const { pickSettingsTrigger, pickNavByToken } = loadNavHelpers(doc);
165
165
  assert.notEqual(pickSettingsTrigger(), general, "「通用设置」在弹层内,绝不能被当成侧栏设置按钮");