@mrrisega/dsh-remote 0.7.0-beta.8 → 0.7.1-beta.1

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 (71) 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 +294 -43
  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/mobile-adapter-settings.test.mjs +352 -0
  19. package/clients/dsh-remote/test/mobile-adapter.test.mjs +35 -0
  20. package/clients/dsh-remote/test/upstream-discovery.test.mjs +1 -1
  21. package/clients/dsh-remote/test/wechat-channel.test.mjs +5 -5
  22. package/clients/dsh-remote/test/wechat-runtime.test.mjs +26 -26
  23. package/clients/dsh-remote/test/wechat-tier-source.test.mjs +2 -2
  24. package/clients/dsh-remote/wechat-channel.mjs +9 -9
  25. package/clients/dsh-remote/wechat-runtime.mjs +38 -38
  26. package/docs/telemetry.md +3 -3
  27. package/dsh-setup.mjs +27 -27
  28. package/package.json +3 -3
  29. package/packages/dsh-remote-web/lib/client.js +56 -56
  30. package/packages/dsh-remote-web/lib/fix-prompt.js +1 -1
  31. package/packages/dsh-remote-web/lib/index.js +380 -59
  32. package/packages/dsh-remote-web/lib/tarball-update.js +1 -1
  33. package/packages/dsh-remote-web/package.json +1 -1
  34. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +17 -17
  35. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +6 -6
  36. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +7 -7
  37. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +9 -9
  38. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +294 -43
  39. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +9 -9
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +38 -38
  41. package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +5 -5
  42. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +27 -27
  43. package/packages/dsh-remote-web/test/account-switch-device.test.mjs +2 -2
  44. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +1 -1
  45. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +1 -1
  46. package/packages/dsh-remote-web/test/connect-loop.test.mjs +1 -1
  47. package/packages/dsh-remote-web/test/connect-ui.test.mjs +2 -2
  48. package/packages/dsh-remote-web/test/desktop-compat.test.mjs +4 -4
  49. package/packages/dsh-remote-web/test/diag-report.test.mjs +1 -1
  50. package/packages/dsh-remote-web/test/fix-prompt.test.mjs +2 -2
  51. package/packages/dsh-remote-web/test/harness-restart.test.mjs +1 -1
  52. package/packages/dsh-remote-web/test/install-profile-targets.test.mjs +3 -3
  53. package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
  54. package/packages/dsh-remote-web/test/launchd-domain.test.mjs +2 -2
  55. package/packages/dsh-remote-web/test/patch-activation.test.mjs +2 -2
  56. package/packages/dsh-remote-web/test/picker-pin.test.mjs +1 -1
  57. package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
  58. package/packages/dsh-remote-web/test/quota-absent.test.mjs +4 -4
  59. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +2 -2
  60. package/packages/dsh-remote-web/test/selfheal-restart-guard.test.mjs +420 -0
  61. package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +6 -6
  62. package/packages/dsh-remote-web/test/settings-entry.test.mjs +2 -2
  63. package/packages/dsh-remote-web/test/settings-open-path.test.mjs +4 -4
  64. package/packages/dsh-remote-web/test/setup-output.test.mjs +11 -11
  65. package/packages/dsh-remote-web/test/tarball-update.test.mjs +1 -1
  66. package/packages/dsh-remote-web/test/telemetry.test.mjs +4 -4
  67. package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +2 -2
  68. package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +1 -1
  69. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +7 -7
  70. package/packages/dsh-remote-web/test/windows-compat.test.mjs +5 -5
  71. package/scripts/test-net-guard.cjs +1 -1
@@ -15,7 +15,7 @@
15
15
  // (no_account/no_runtime/installing/starting/connecting/online/error),并在返回前自动补装运行环境、
16
16
  // 拉起 bridge、清理卡死标记(带退避);区分「bridge 进程在跑」与「设备已在中继注册成功(online)」。
17
17
  // 面板用 2~3s 短轮询自动推进到 online(无需点按钮/刷新页面),error 才给重试与诊断信息;
18
- // 另 POST /dsh-remote/connect/retry 手动重试、POST <api>/api/install-report 上报安装口径
18
+ // 另 POST /dsh-remote/connect/retry 手动重试、POST <api>/api/install-report 上报安装约定
19
19
  // (install_source/version + host_os/arch,详见 installSourceOf)。
20
20
  // - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
21
21
  //
@@ -90,14 +90,14 @@ const DEFAULT_RELAY_DIR = process.env.DSH_RELAY_DIR || join(homedir(), ".dsh-rem
90
90
  // 默认云端服务地址(SaaS 入口;自建用户在设置页/面板切换)。
91
91
  // DSH_RELAY_DEFAULT_API 只改「配置里根本没写 api_url」时的兜底:测试脚本把它指向本地死端口,
92
92
  // 这样任何**在临时目录被拆掉之后**才跑到的后台请求(如装机上报的重试)都不会打生产。
93
- // 真实事故形态见 test/wechat-bind-telemetry.test.mjs 的注释:用例拆目录 → 读到空配置 → 回落生产。
93
+ // 真实故障形态见 test/wechat-bind-telemetry.test.mjs 的注释:用例拆目录 → 读到空配置 → 回落生产。
94
94
  const DEFAULT_API = String(process.env.DSH_RELAY_DEFAULT_API || "").trim()
95
95
  || "https://n.risegao.cn:13443/relay-api";
96
96
  const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
97
97
 
98
98
  // ---------- 上游 dsh web 地址(bridge/watcher 都要连它,绝不写死 3080) ----------
99
99
  //
100
- // 【2026-09-23 用户实测事故】这里(以及运行时的 checkUpstream)原本把上游**写死**
100
+ // 【2026-09-23 用户实测故障】这里(以及运行时的 checkUpstream)原本把上游**写死**
101
101
  // `http://127.0.0.1:3080`:只要用户的 dsh web 不在默认端口(`dsh web --port 8090`、
102
102
  // profile 里配了 port、或 `--port 0` 让系统分配空闲端口),watcher 的「dsh web 在线吗」
103
103
  // 探测就永远失败 → **bridge 永远不启动**,而面板只会说「正在启动 Bridge…」(错误为空)
@@ -215,19 +215,28 @@ async function discoverUpstreamSelf(relayDir, { force = false } = {}) {
215
215
  * 开机自启的 watcher(Windows 登录任务 / launchd)由系统拉起,拿不到本进程的 env,
216
216
  * 只有插件半知道 dsh web 真实监听在哪个端口。运行时(0.6.12+)读这个文件当第二优先来源。
217
217
  * 内容没变就不写(面板每轮都会走到这里,别把磁盘写热)。
218
+ *
219
+ * @returns {{changed:boolean, url:string}} `changed=true` = **这一次真的改写了文件**。
220
+ * ★ 2026-10-13 为什么必须回报这个:修「12 秒重启风暴」时,唯一还站得住的"需要重启一个
221
+ * **正在健康运行**的 bridge"的理由就是"它的上游地址变了(进程环境里是旧地址,不重启不生效)"。
222
+ * 而这个判据必须是**磁盘上的真实变化**,不能是"我觉得它可能变了" —— 后者又会变成
223
+ * 每轮自愈都重启一次。调用方一律忽略返回值也没关系(旧行为不变)。
218
224
  */
219
225
  const UPSTREAM_FILE = ".dsh-upstream";
220
226
  function publishUpstream(relayDir) {
221
- if (skipsSystemOps()) return; // 测试/诊断隔离:不得对磁盘产生任何副作用(含创建配置目录)
227
+ if (skipsSystemOps()) return { changed: false, url: "", skipped: true }; // 测试/诊断隔离:不得对磁盘产生任何副作用(含创建配置目录)
222
228
  try {
223
229
  const url = upstreamUrl();
224
230
  const file = join(relayDir, UPSTREAM_FILE);
225
231
  let cur = "";
226
232
  try { cur = readFileSync(file, "utf8").trim(); } catch { /* 首次 */ }
227
- if (cur === url) return;
233
+ if (cur === url) return { changed: false, url };
228
234
  mkdirSync(relayDir, { recursive: true });
229
235
  writeFileSync(file, url + "\n", { mode: 0o600 });
230
- } catch { /* 非关键:读不到就沿用运行时的回退链 */ }
236
+ return { changed: true, url };
237
+ } catch { /* 非关键:读不到就沿用运行时的回退链 */
238
+ return { changed: false, url: "" };
239
+ }
231
240
  }
232
241
 
233
242
  /**
@@ -491,7 +500,7 @@ function npxInvocation() {
491
500
  /** 子进程环境:把 node 目录补进 PATH(npx 及其 shebang 需要),可附加额外变量。 */
492
501
  function spawnEnv(extra) {
493
502
  // ⚠️ 这里刻意用 **process.execPath**,不是 NODE_BIN:PATH 首位的用途是让 npx 的
494
- // `#!/usr/bin/env node` 解析到**正在跑本插件的那个 node**(与 npxInvocation 同口径)。
503
+ // `#!/usr/bin/env node` 解析到**正在跑本插件的那个 node**(与 npxInvocation 同约定)。
495
504
  // NODE_BIN 回答的是另一个问题——"自启动服务里该登记哪个解释器"(真实 node 优先)。
496
505
  // 两者语义不同,混用会悄悄改掉 web 版上 npx 用哪个 node。
497
506
  const nodeDir = dirname(process.execPath);
@@ -759,9 +768,9 @@ function fetchFailReason(e) {
759
768
  }
760
769
 
761
770
  /**
762
- * Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
771
+ * Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 故障根治)。
763
772
  *
764
- * 【事故】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端(每次启动
773
+ * 【故障】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端(每次启动
765
774
  * 随机端口)是**两个宿主进程**,各自的插件都往里写"自己 authority 的 Cookie" → 互相覆盖。
766
775
  * 桥撞 401 后重读磁盘,拿到的是另一个宿主刚写的 Cookie → 重试 → 还是 401 → 再读 → 又被
767
776
  * 覆盖回去……**乒乓,永不自愈**,手机端持续白页(现象与"自愈坏了"一模一样,极易误判)。
@@ -782,7 +791,7 @@ function harnessCookieFileFor(authority) {
782
791
  function harnessCookieRevokedFileFor(authority) {
783
792
  return `${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`;
784
793
  }
785
- /** 由本进程 webServer 端口推出 authority(与 bridge 的 Host 口径一致)。 */
794
+ /** 由本进程 webServer 端口推出 authority(与 bridge 的 Host 约定一致)。 */
786
795
  function harnessAuthorityOfPort(port) {
787
796
  return port ? `127.0.0.1:${port}` : "";
788
797
  }
@@ -832,7 +841,7 @@ async function mintHarnessCookie(ctx, relayDir) {
832
841
  if (UNINSTALLED_DIRS.has(relayDir)) return false;
833
842
  const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
834
843
  mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
835
- // ① 本宿主专属文件(权威,两个宿主互不覆盖 —— 事故根治点)
844
+ // ① 本宿主专属文件(权威,两个宿主互不覆盖 —— 故障根治点)
836
845
  const ownPath = join(relayDir, harnessCookieFileFor(out.authority));
837
846
  writeFileSync(ownPath, JSON.stringify(out, null, 2), { mode: 0o600 });
838
847
  hardenFile(ownPath); // 会话 Cookie ≈ dsh web 的完整访问权,同样要收紧
@@ -1003,7 +1012,7 @@ function runtimeManifest(relayDir) {
1003
1012
  /**
1004
1013
  * 桌面运行环境缺失/不完整的文件清单(空 = 就绪)。
1005
1014
  *
1006
- * 【2026-09-23 用户实测事故】判据原本是「`dsh-setup.mjs` 存在即就绪」——**存在 ≠ 能加载**。
1015
+ * 【2026-09-23 用户实测故障】判据原本是「`dsh-setup.mjs` 存在即就绪」——**存在 ≠ 能加载**。
1007
1016
  * npx 补装是「先拷入口脚本、再递归拷 clients/」,中途被打断(用户关窗口 / 杀软锁文件 /
1008
1017
  * 机器休眠 / 看门狗判超时结束进程)就会留下一个**只有入口脚本的半装运行时**。此后:
1009
1018
  * · runtimeReady() 恒为 true → 插件再也不补装(自愈彻底停摆);
@@ -1049,7 +1058,7 @@ function launchAgentPath() {
1049
1058
  /**
1050
1059
  * ★ 这份自启动 plist 是否**确实是为本 relayDir 装的**(内容里写着 `<relayDir>/dsh-setup.mjs`)。
1051
1060
  *
1052
- * 🔴 2026-10-10 事故(两次,同一根因):`reapBrokenAutostart()` 用**当前 relayDir** 判断
1061
+ * 🔴 2026-10-10 故障(两次,同一根因):`reapBrokenAutostart()` 用**当前 relayDir** 判断
1053
1062
  * "入口脚本缺失"(测试的临时 relayDir 当然没有 dsh-setup.mjs → 判 true),
1054
1063
  * 但删除的却是 `launchAgentPath()` = **真实 HOME 下那份 plist** → 把开发者本机的自启动
1055
1064
  * 连 bootout 带删文件一起干掉了(远程访问中断,且日志里看不出异常)。
@@ -1102,6 +1111,41 @@ function systemdUserAvailableCached(ttlMs = 60_000) {
1102
1111
  return ok;
1103
1112
  }
1104
1113
 
1114
+ /**
1115
+ * ★★ 2026-10-13 修复(线上反馈 fb_3c4c5a13bc47 / 用户 842):Linux 上「bridge 到底在不在跑」的
1116
+ * **权威**判据 —— 问服务管理器本人,而不是猜进程名。
1117
+ *
1118
+ * 为什么 `pgrep` 不能当就绪判据:自愈 watcher 每 `selfhealIntervalMs()`(默认 12s)拍一次,
1119
+ * 旧就绪判据是 `manualStatus(relayDir).watcher.length > 0`,它靠
1120
+ * `pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs'` 的名称匹配。这个探测**极易拿到空结果**
1121
+ * (PATH 里没有 /usr/bin、pgrep 不存在、进程名与模式不匹配、进程属于别的会话……),
1122
+ * 而拿到空结果后的动作是 `systemctl --user restart dsh-bridge` —— **restart 会杀掉正在
1123
+ * 健康运行的 bridge**。实测后果:该设备 `audit_log` 的 `device.claim`(= bridge 进程启动的
1124
+ * 必然产物)一天 **3567** 次、相邻间隔 p50 **12001ms**、连续 10 小时整点 300 次/小时;
1125
+ * 全站上百台设备每天 1.1~1.5 万次重登记。每次重启都以新连接顶掉旧隧道,中继随即把该设备
1126
+ * **所有手机镜像 WS 关掉(close 1012 "bridge offline")**,镜像页每 ~12.3 秒断线重连并全量重放
1127
+ * 界面状态 —— 用户看到的就是"选项窗口一会儿显示、一会儿消失"。
1128
+ * 而中继侧对这类 churn 只做了日志抑制,所以运维侧长期看不见。
1129
+ *
1130
+ * 🔴 结论:**探测失败 ≠ 进程死了**,绝不允许把前者翻译成"每 12 秒杀一次隧道"。
1131
+ *
1132
+ * 为什么必须是三态而不是布尔:`known:false`(问不到 systemd —— NAS/容器里没有 user manager、
1133
+ * 甚至没有 systemctl 命令)与「问了,明确不是 active」在语义上完全不同:
1134
+ * · known=false → 回退到进程探测(与旧行为一致,不能因为问不到就认为它死了);
1135
+ * · known=true & active=false → 才可以动手拉起。
1136
+ * 把这两种混成一个 `false`,等于又制造了一次"判据失败就重启"的事故。
1137
+ */
1138
+ function linuxSystemdUnitState() {
1139
+ if (!isLinux()) return { known: false, active: false, raw: "" };
1140
+ const r = sh("systemctl --user is-active dsh-bridge");
1141
+ const raw = String(r.stdout || "").trim();
1142
+ if (raw === "active") return { known: true, active: true, raw };
1143
+ // is-active 对 inactive/failed/activating… 会以非 0 退出,但**会打印状态字** —— 那是有效回答。
1144
+ // 一个字都没打印 = systemctl 根本没回答(没有 user manager / 没有这个命令)→ 未知,不是"死"。
1145
+ if (!raw) return { known: false, active: false, raw };
1146
+ return { known: true, active: false, raw };
1147
+ }
1148
+
1105
1149
  /** 写 Linux systemd user unit(与 dsh-setup.mjs 同内容)。写不了返回 null(不致命,还有脱离进程兜底)。 */
1106
1150
  function writeLinuxUnit(relayDir) {
1107
1151
  const unitPath = linuxUnitPath();
@@ -1136,7 +1180,7 @@ WantedBy=default.target
1136
1180
  /**
1137
1181
  * Linux:拉起 bridge 的阶梯(**与 macOS 同构:可自愈优先,可用兜底**)。
1138
1182
  *
1139
- * 【0.6.15 事故根因】0.6.14 及更早,`startBridge` 只认 macOS 的 launchd,Linux 直接返回
1183
+ * 【0.6.15 故障根因】0.6.14 及更早,`startBridge` 只认 macOS 的 launchd,Linux 直接返回
1140
1184
  * `unsupported: 当前平台(linux)暂不支持自启动服务`。于是:
1141
1185
  * · 自愈轮次每轮都调用它、每轮都拿到 unsupported、**bridge 永远起不来**;
1142
1186
  * · 用户面板上的表现是永远停在「正在启动 Bridge…」+「bridge 进程: 未运行」;
@@ -1160,18 +1204,64 @@ function startBridgeLinux(relayDir) {
1160
1204
  detail: "桌面运行环境(bridge)尚未安装,已在后台自动安装,完成后会自动启动 bridge——请稍候刷新面板(也可点下方「一键更新」查看进度)",
1161
1205
  };
1162
1206
  }
1163
- publishUpstream(relayDir); // 脱离进程拉起的 watcher 没有本进程 env,只认这个文件
1207
+ const up = publishUpstream(relayDir); // 脱离进程拉起的 watcher 没有本进程 env,只认这个文件
1164
1208
  const unit = writeLinuxUnit(relayDir);
1165
1209
  if (unit && systemdUserAvailable()) {
1166
- sh("systemctl --user daemon-reload");
1167
- const en = sh("systemctl --user enable dsh-bridge");
1168
- const rs = sh("systemctl --user restart dsh-bridge");
1169
- const act = rs.ok ? sh("systemctl --user is-active dsh-bridge") : { ok: false, stdout: "", stderr: "" };
1170
- if (rs.ok && act.ok && act.stdout.trim() === "active") {
1171
- return { ok: true, status: "running", pid: null, detail: "已由 systemd --user 托管 bridge(开机自启 + 崩溃自愈)" };
1210
+ // ★★ 2026-10-13 幂等闸门(修「12 秒重启风暴」的核心):
1211
+ // 先问**服务管理器本人**这个 unit 是不是 active,而不是拿 pgrep 猜进程名。
1212
+ // active = systemd 把它拉起来了、正在跑 —— 此时绝不能 restart(restart 会杀掉旧进程、
1213
+ // 新建隧道,中继随即 close(1012) 掉该设备所有手机镜像 WS,镜像页全量重放)。
1214
+ // 旧代码在这里**无条件 restart**:只要上游判据(pgrep)拿不到,它就每 12 秒把一台
1215
+ // 健康运行的 bridge 杀掉重建一次,一天 3567 次(见 linuxSystemdUnitState 的长注释)。
1216
+ const state = linuxSystemdUnitState();
1217
+ if (state.active && !up.changed) {
1218
+ // 唯一还站得住的"需要重启"的理由不存在 → 什么都不做,如实回报"在跑、没重启"。
1219
+ return {
1220
+ ok: true, status: "running", pid: null, reused: true,
1221
+ detail: "已由 systemd --user 托管 bridge(运行中,未重启)",
1222
+ };
1223
+ }
1224
+ const why = state.active
1225
+ // 上游地址**确实变了**(运行中的进程环境里是旧地址,不重启就会对着旧端口转发)——
1226
+ // 这是唯一允许动一个健康服务的理由,所以必须过护栏:万一上游地址在抖,
1227
+ // 也不能退化成"每 12 秒重启一次"(那正是本次故障的形态)。
1228
+ ? `上游地址变更为 ${up.url}`
1229
+ // 没在跑(含 is-active=activating/failed 这类瞬时态):本该起它,但**反复 restart 一个
1230
+ // 起不来的 unit** 每次都会派生一个新 bridge 进程(= 一次 device.claim = 顶掉一次隧道),
1231
+ // 所以这里的 restart 同样要过护栏 —— 护栏就该放在"破坏性动作"的调用点上,
1232
+ // 而不是指望每个调用者自己记得限流。
1233
+ : `systemd --user 说它没在跑(is-active=${state.raw || "无回答"})`;
1234
+ const g = guardedSystemdRestart(relayDir, why);
1235
+ if (!g.allowed) {
1236
+ // ⚠️ 被护栏拦下时**绝不能**再落到下面的"脱离进程"兜底:那条路会直接派生一个新 watcher,
1237
+ // 每拍一个 —— 护栏就形同虚设(这正是我们要止住的东西)。
1238
+ // 而回报要分两种:unit 本来就 active 时它**仍在运行**,不能因为"我们没重启"就报失败
1239
+ // (否则面板会把一台好机器显示成故障)。
1240
+ if (state.active) {
1241
+ return {
1242
+ ok: true, status: "running", pid: null, reused: true, staleUpstream: true,
1243
+ detail: `bridge 正在运行;上游地址已变更为 ${up.url},但为避免重启风暴暂不重启它(重开 dsh web 后自然生效)`,
1244
+ };
1245
+ }
1246
+ return {
1247
+ ok: false, status: "selfheal-throttled",
1248
+ detail: `自愈护栏拦下了对 systemd unit 的重启(${g.gate.reason === "exhausted" ? "已达本进程重启上限" : "距上次重启过近"}),本轮不重启 bridge`,
1249
+ };
1250
+ }
1251
+ if (g.ok) {
1252
+ // accounted: true = 这次破坏性重启已经在 guardedSystemdRestart 里记过账(护栏额度 + 可见日志)。
1253
+ // 调用方(自愈 watcher)据此**不得再记一次**,否则一次尝试吃掉两格额度,5 分钟退避缩水成 2.5 分钟。
1254
+ return { ok: true, status: "running", pid: null, accounted: true, detail: "已由 systemd --user 托管 bridge(开机自启 + 崩溃自愈)" };
1255
+ }
1256
+ if (state.active) {
1257
+ // 因上游变更而重启、但重启后仍不是 active:如实回报(已在 guardedSystemdRestart 里记账)
1258
+ return {
1259
+ ok: true, status: "running", pid: null, reused: true, staleUpstream: true,
1260
+ detail: `bridge 仍在运行,但上游地址已变更为 ${up.url} 且本次重启未确认生效(详见安装日志)`,
1261
+ };
1172
1262
  }
1173
1263
  appendLogLine(relayDir, AUTO_INSTALL_LOG,
1174
- `[dsh-remote-web] systemd --user 拉起 bridge 失败(enable=${en.ok ? "ok" : "fail"}, restart=${rs.ok ? "ok" : "fail"}),改用后台进程`);
1264
+ `[dsh-remote-web] systemd --user 拉起 bridge 失败(restart=${g.rs.ok ? "ok" : "fail"}),改用后台进程`);
1175
1265
  }
1176
1266
  const d = startBridgeDetached(relayDir, unit
1177
1267
  ? "本机没有可用的 user systemd 会话(NAS / 容器 / 精简系统常见),已改为后台进程"
@@ -1179,6 +1269,30 @@ function startBridgeLinux(relayDir) {
1179
1269
  return d;
1180
1270
  }
1181
1271
 
1272
+ /**
1273
+ * 执行一次「systemd 破坏性重启 bridge」:**先过护栏,过了才真的 restart**,并记账 + 写可见日志。
1274
+ *
1275
+ * 为什么要把 `systemctl --user restart` 收敛到这一个函数里:这条命令是本次故障里唯一真正
1276
+ * 造成伤害的动作(杀旧 bridge → 新进程 → 新隧道 → 中继 close(1012) 掉手机镜像)。
1277
+ * 它散落在多处调用点时,任何一处忘了限流就又是一次"十二秒杀一次隧道";
1278
+ * 收到一处之后,"什么情况下允许执行它"就有了唯一答案,也能被单测直接钉住。
1279
+ * @returns {{allowed:boolean, gate:object, ok:boolean, en:object, rs:object, act:object}}
1280
+ */
1281
+ function guardedSystemdRestart(relayDir, why) {
1282
+ const gate = selfhealRestartGate(relayDir);
1283
+ if (!gate.allowed) {
1284
+ noteSelfhealBlocked(relayDir, gate);
1285
+ return { allowed: false, gate, ok: false, en: null, rs: null, act: null };
1286
+ }
1287
+ sh("systemctl --user daemon-reload");
1288
+ const en = sh("systemctl --user enable dsh-bridge");
1289
+ const rs = sh("systemctl --user restart dsh-bridge");
1290
+ const act = rs.ok ? sh("systemctl --user is-active dsh-bridge") : { ok: false, stdout: "", stderr: "" };
1291
+ const active = Boolean(rs.ok && act.ok && String(act.stdout).trim() === "active");
1292
+ noteSelfhealRestart(relayDir, why, active ? "running" : `restart=${rs.ok ? "ok" : "fail"}/is-active=${String(act.stdout).trim() || "?"}`);
1293
+ return { allowed: true, gate, ok: active, en, rs, act };
1294
+ }
1295
+
1182
1296
  /**
1183
1297
  * launchd 作业 target(`gui/<uid>/<label>`)。
1184
1298
  * **Windows/Linux 上没有 launchctl、也没有 process.getuid**——旧实现无条件调
@@ -1188,7 +1302,7 @@ function startBridgeLinux(relayDir) {
1188
1302
  /**
1189
1303
  * launchd 域名阶梯(**必须与 dsh-setup.mjs 一致**)。
1190
1304
  *
1191
- * 事故(2026-09-19 实测):安装器按 macOS 26 的修复把作业装在 **`user/<uid>`** 域
1305
+ * 故障(2026-09-19 实测):安装器按 macOS 26 的修复把作业装在 **`user/<uid>`** 域
1192
1306
  * (gui 域会被系统置为 on-demand-only,RunAtLoad/KeepAlive 失效),
1193
1307
  * 但插件这边只认 `gui/<uid>` → 切换账号时那次"重启 bridge"实际执行的是
1194
1308
  * launchctl bootout gui/501/com.dshremote.bridge → 找不到
@@ -1427,6 +1541,97 @@ function selfhealIntervalMs() {
1427
1541
  return Math.max(200, Number(process.env.DSH_RELAY_SELFHEAL_MS) || 12_000);
1428
1542
  }
1429
1543
 
1544
+ /**
1545
+ * ★★ 2026-10-13 自愈重启护栏(熔断 + 退避)—— 修「12 秒重启风暴」的第二道闸。
1546
+ *
1547
+ * 为什么在"权威存活判据"之外还必须再有一道:
1548
+ * 权威判据解决的是"**活着却被判成死**"(见 `linuxSystemdUnitState()`)。
1549
+ * 但如果 bridge **真的**起不来(上游端口不对、运行环境半装、unit 写不进去、unit 一直 failed),
1550
+ * 旧的 12s 循环同样会每拍重启一次 —— 那是同一个病的另一种形态:
1551
+ * **一次真实故障被翻译成"每天 7200 次重启"**(历史上 Windows 补装失败就出过 12 秒一次、
1552
+ * 累计 606 次的失败风暴,见 `provisionRetryGate` 的注释)。所以:
1553
+ * · 同一 relayDir、**本进程生命周期内**的破坏性重启最多 `SELFHEAL_MAX_RESTARTS` 次;
1554
+ * · 两次之间至少隔 `selfhealMinRestartMs()`(默认 5 分钟);
1555
+ * · 超限就**停手**,并把原因明确写进安装日志(面板「复制诊断信息」与用户主动上报的
1556
+ * 诊断包里都会带上它)——而不是静默地每 12 秒再来一次。
1557
+ *
1558
+ * 为什么上限按"本进程生命周期"而不是"每天":合法的上游变更(dsh web 换端口)几乎总伴随着
1559
+ * dsh web 重启,而重启就会重置这份记录 —— 所以 3 次对正常用户绰绰有余,对故障循环刚好够呛。
1560
+ * `DSH_RELAY_SELFHEAL_MIN_RESTART_MS` 仅供本仓库测试把节奏压到亚秒级
1561
+ * (与 `DSH_RELAY_SELFHEAL_MS` / `DSH_RELAY_WEDGE_MS` 同一约定):生产不设置。
1562
+ */
1563
+ const SELFHEAL_MAX_RESTARTS = 3;
1564
+ const SELFHEAL_MIN_RESTART_MS = 5 * 60 * 1000;
1565
+ function selfhealMinRestartMs() {
1566
+ const v = Number(process.env.DSH_RELAY_SELFHEAL_MIN_RESTART_MS);
1567
+ return Number.isFinite(v) && v >= 0 ? v : SELFHEAL_MIN_RESTART_MS;
1568
+ }
1569
+ /** relayDir → { count, lastAt, blockedLine };blockedLine = 已经告知过的"被拦下"结论(防刷屏)。 */
1570
+ const selfhealRestarts = new Map();
1571
+ function selfhealGuardOf(relayDir) {
1572
+ return selfhealRestarts.get(relayDir) || { count: 0, lastAt: 0, blockedLine: "" };
1573
+ }
1574
+
1575
+ /**
1576
+ * 现在允许对这个 relayDir 做**破坏性重启**吗?(纯查询,不改状态 —— 调用方在真的重启之后
1577
+ * 用 `noteSelfhealRestart` 记账,这样"只查不记"的路径不会凭空消耗额度。)
1578
+ * @returns {{allowed:boolean, reason:"ok"|"too-soon"|"exhausted", count:number, waitMs:number}}
1579
+ */
1580
+ function selfhealRestartGate(relayDir, now = Date.now()) {
1581
+ const rec = selfhealGuardOf(relayDir);
1582
+ if (rec.count >= SELFHEAL_MAX_RESTARTS) return { allowed: false, reason: "exhausted", count: rec.count, waitMs: 0 };
1583
+ const min = selfhealMinRestartMs();
1584
+ const since = now - (rec.lastAt || 0);
1585
+ if (rec.lastAt && since < min) return { allowed: false, reason: "too-soon", count: rec.count, waitMs: min - since };
1586
+ return { allowed: true, reason: "ok", count: rec.count, waitMs: 0 };
1587
+ }
1588
+
1589
+ /**
1590
+ * 记一次**真的做了**破坏性重启,并留下一行可见日志。
1591
+ *
1592
+ * ★ 可观测性是这次修复的一半:这个故障拖了 3 个月才被定位,直接原因就是"重连风暴在中继侧
1593
+ * 只被日志抑制、在客户端侧又什么都没记"——运维侧全程看不见。所以重启事件必须留痕:
1594
+ * 写既有的安装日志(`.dsh-setup-install.log`,面板诊断与用户上报都会带它的尾部),不新造通道。
1595
+ * @param {string} why 人类可读的原因("权威判据说没在跑" / "上游地址变更")
1596
+ * @param {string} outcome 重启后的结果("running" / "failed: …")
1597
+ */
1598
+ function noteSelfhealRestart(relayDir, why, outcome = "") {
1599
+ const rec = selfhealGuardOf(relayDir);
1600
+ rec.count += 1;
1601
+ rec.lastAt = Date.now();
1602
+ rec.blockedLine = ""; // 结论变了:下一次"被拦下"要重新说一遍
1603
+ selfhealRestarts.set(relayDir, rec);
1604
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
1605
+ `[dsh-remote-web] 自愈重启 bridge(第 ${rec.count}/${SELFHEAL_MAX_RESTARTS} 次·本进程生命周期内,`
1606
+ + `之后 ${Math.round(selfhealMinRestartMs() / 1000)} 秒内不再重启):${why}${outcome ? ` → ${outcome}` : ""}`);
1607
+ return rec;
1608
+ }
1609
+
1610
+ /**
1611
+ * 「这一拍本来想重启,但被护栏拦下了」——**同一条结论只说一次**(理由同 `noteAutostartFailure`:
1612
+ * 每 12 秒写一行的日志等于没有日志,用户复制过来的诊断里会全是重复行,第一现场反而被淹没)。
1613
+ * 注意:文案里**不得带倒计时秒数**,否则每一拍的文本都不同,去重就失效了。
1614
+ */
1615
+ function noteSelfhealBlocked(relayDir, gate) {
1616
+ const rec = selfhealGuardOf(relayDir);
1617
+ const line = gate.reason === "exhausted"
1618
+ ? `已停止自愈重启 bridge:本进程生命周期内已重启 ${SELFHEAL_MAX_RESTARTS} 次仍未就绪,`
1619
+ + `为避免"每 ${Math.round(selfhealIntervalMs() / 1000)} 秒杀掉一次正在跑的隧道"已熔断;`
1620
+ + `请点面板「一键更新」,或查看 bridge 日志确认真正的失败原因`
1621
+ : `已暂缓自愈重启 bridge:距上次重启不足 ${Math.round(selfhealMinRestartMs() / 1000)} 秒`
1622
+ + `(不会每 ${Math.round(selfhealIntervalMs() / 1000)} 秒重启一次,那样会反复切断手机镜像连接)`;
1623
+ if (rec.blockedLine === line) return false;
1624
+ rec.blockedLine = line;
1625
+ selfhealRestarts.set(relayDir, rec);
1626
+ appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-web] ${line}`);
1627
+ return true;
1628
+ }
1629
+
1630
+ /** 桥已确认就绪:清掉护栏状态,让"后来真的再挂一次"能重新自愈(否则额度会被历史故障吃光)。 */
1631
+ function resetSelfhealGuard(relayDir) {
1632
+ selfhealRestarts.delete(relayDir);
1633
+ }
1634
+
1430
1635
  /** 向日志追加一行(多个子进程写同一日志用 append 模式,互不覆盖)。 */
1431
1636
  function appendLogLine(relayDir, name, line) {
1432
1637
  try {
@@ -1449,7 +1654,7 @@ function appendLogLine(relayDir, name, line) {
1449
1654
  * 注意:默认优先官方源——镜像(npmmirror)可能滞后于刚发布的版本,装到旧版会把
1450
1655
  * 已被 0.4.5 移除的“用户 include”重新写回 profile(历史上造成 dsh web 重复 ID 崩溃)。 */
1451
1656
  /**
1452
- * 补装失败退避(2026-09-15 生产事故)。
1657
+ * 补装失败退避(2026-09-15 生产故障)。
1453
1658
  *
1454
1659
  * 背景:Windows 用户补装 `npx @mrrisega/dsh-remote` 持续失败时,自愈调度每轮都重试,
1455
1660
  * 生产遥测里看到单机 12 秒一次、累计 606 次的失败风暴(1659 次 install_failed 全在 Windows)。
@@ -1673,7 +1878,7 @@ function ensureRuntime(relayDir) {
1673
1878
  /**
1674
1879
  * 生成 plist(与 dsh-setup.mjs 的 writeAutostartFile **必须同构**),返回路径。
1675
1880
  *
1676
- * ⚠️ 两份模板历史上漂移过、并因此各造成一个真机事故(插件每次自愈/启动都会重写 plist,
1881
+ * ⚠️ 两份模板历史上漂移过、并因此各造成一个真机故障(插件每次自愈/启动都会重写 plist,
1677
1882
  * 所以**插件这份谁写谁说了算**,漂移等于把安装器那份正确的覆盖掉):
1678
1883
  * · 漏 `/usr/sbin`、`/sbin`(SERVICE_PATH)→ bridge 找不到 `ioreg` → 机器指纹静默退化成
1679
1884
  * hostname 哈希(改一次主机名就被当成新设备);
@@ -1936,6 +2141,26 @@ function startBridge(relayDir) {
1936
2141
  * → watcher `done = true` 永久停摆,自动补装(ensureRuntime)再也不会执行,
1937
2142
  * 这正是「市场装完插件、bridge 却起不来」且永不恢复的直接原因;
1938
2143
  * - 补装运行环境不该等用户先登录:市场安装只给到插件半,登录那一刻就会 startBridge。
2144
+ *
2145
+ * ★★ 2026-10-13 重写就绪判据与"动手"策略(修线上反馈 fb_3c4c5a13bc47 的 12 秒重启风暴):
2146
+ * 【症状】Linux 用户(npx 安装,0.6.18)的 bridge 被本函数**每 12.001 秒重启一次**,
2147
+ * 一天 3567 次;每次重启换一条新隧道 → 中继 close(1012) 掉该设备所有手机镜像 WS →
2148
+ * 镜像页每 ~12.3 秒断线重连并全量重放 → 用户看到的交互选择窗口"一会显示、一会儿消失"。
2149
+ * 全站上百台设备每天 1.1~1.5 万次重登记,多台设备间隔 p50 精确 ≈12.0s。
2150
+ * 【成因】就绪判据只有 `launchdStatus().running`(macOS)与
2151
+ * `manualStatus().watcher.length > 0`(靠 pgrep 名称匹配,Linux/Windows 共用)。
2152
+ * 这两个判据都**可能在服务其实健康时返回"没在跑"**,而拿到"没在跑"之后直接
2153
+ * `startBridge()` → `systemctl --user restart`(**破坏性**:杀旧进程、建新隧道)。
2154
+ * 于是"判据失败"被逐字翻译成"每 12 秒杀一次隧道",而且 `done` 永远不置位。
2155
+ * 【现在】三层,缺一不可:
2156
+ * ① **权威判据优先**:Linux 问 `systemctl --user is-active dsh-bridge`(见
2157
+ * `linuxSystemdUnitState()`),pgrep 只作为"问不到 systemd 时"的兜底;
2158
+ * ② **成功即止**:任一判据确认就绪 → 立刻 `done = true; clearInterval`,
2159
+ * 不再依赖下一拍去重新猜(旧代码即使这次 startBridge 成功,也要等下一拍 pgrep 认得出来
2160
+ * 才算成功,而它恰恰认不出来 → 永远重启);
2161
+ * ③ **护栏**:确需动手时先过 `selfhealRestartGate()`(≥5 分钟最小间隔 + 本进程内最多 3 次),
2162
+ * 被拦下或触顶都写入**可见**的安装日志(这个故障之所以拖了三个月,正是因为客户端侧
2163
+ * 什么都没记、中继侧又把 churn 日志抑制了,运维全程看不见)。
1939
2164
  */
1940
2165
  function scheduleRuntime(relayDir) {
1941
2166
  let done = false;
@@ -1963,21 +2188,85 @@ function scheduleRuntime(relayDir) {
1963
2188
  const cfg = loadConfig(relayDir);
1964
2189
  const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
1965
2190
  if (!hasAcct) return;
2191
+ // ── ① 权威判据(按平台各问各的服务管理器)──
1966
2192
  // 运行环境已在,看服务是否已在运行(runtime 可能位于 npx 缓存/固化目录,不必重复安装)
1967
2193
  const st = launchdStatus();
1968
- if (st.running) { done = true; clearInterval(iv); return; }
2194
+ if (st.running) {
2195
+ done = true; clearInterval(iv); resetSelfhealGuard(relayDir);
2196
+ logSelfhealReady(relayDir, cfg, `launchd state=${st.state || "running"}`);
2197
+ return;
2198
+ }
2199
+ // Linux:**systemd 才是权威**。它说 active 就是 bridge 真的在跑(隧道是它的职责),
2200
+ // 此时哪怕 pgrep 一个进程都没匹配上,也必须认为"就绪"并停摆 —— 这正是本次故障的正解。
2201
+ if (isLinux()) {
2202
+ const sys = linuxSystemdUnitState();
2203
+ if (sys.active) {
2204
+ done = true; clearInterval(iv); resetSelfhealGuard(relayDir);
2205
+ logSelfhealReady(relayDir, cfg, "systemd --user is-active=active");
2206
+ return;
2207
+ }
2208
+ }
2209
+ // ── ② 兜底判据(问不到服务管理器时,还是得靠进程探测;Windows 只有这一条)──
1969
2210
  // 非 macOS(Windows **与 Linux**)没有 launchd:以「watcher 进程在跑」为就绪判据。
1970
2211
  // 否则 launchdStatus 永远是 false,done 永远不置位,watcher 会一直空转。
1971
2212
  // ★ Linux 也必须走这一支:`startBridge` 现在能在 Linux 上真正拉起 bridge(0.6.15),
1972
2213
  // 但就绪判据漏了 Linux 的话,每轮自愈都会重复走一遍"写 unit + 起进程"。
1973
- if (!isDarwin() && manualStatus(relayDir).watcher.length > 0) { done = true; clearInterval(iv); return; }
1974
- startBridge(relayDir); // 环境在但服务没起 → 拉起
2214
+ if (!isDarwin() && manualStatus(relayDir).watcher.length > 0) {
2215
+ done = true; clearInterval(iv); resetSelfhealGuard(relayDir);
2216
+ logSelfhealReady(relayDir, cfg, "进程探测(pgrep/pid 文件)");
2217
+ return;
2218
+ }
2219
+ // ── ③ 确实没在跑 → 过护栏后才动手(绝不允许"判据失败"直接变成"每 12 秒杀一次隧道")──
2220
+ const gate = selfhealRestartGate(relayDir);
2221
+ if (!gate.allowed) { noteSelfhealBlocked(relayDir, gate); return; }
2222
+ const r = startBridge(relayDir); // 环境在但服务没起 → 拉起
2223
+ if (r && r.ok) {
2224
+ // 起来了:立刻置位 + 记账 + 清掉失败去重状态。**不再等下一拍重新判据** ——
2225
+ // 旧代码把"是否成功"交给下一拍的 pgrep 去回答,而它答不出来,于是永远重启。
2226
+ // `accounted` = 这次重启已经在 startBridgeLinux/guardedSystemdRestart 里记过账,别再记一次。
2227
+ if (!r.reused && !r.accounted) noteSelfhealRestart(relayDir, "权威判据说没在跑", r.status || "running");
2228
+ clearAutostartFailure(relayDir);
2229
+ done = true; clearInterval(iv); resetSelfhealGuard(relayDir);
2230
+ logSelfhealReady(relayDir, cfg, `startBridge=${r.status || "running"}${r.reused ? "(幂等:本来就在跑)" : ""}`);
2231
+ return;
2232
+ }
2233
+ // 测试隔离(DSH_RELAY_SKIP_SERVICE=1):startBridge 只会返回 `skipped` —— 它**什么都没做**,
2234
+ // 既不该吃掉护栏额度,也不该往安装日志里写"自愈重启"(生产不会出现这个状态)。
2235
+ if (r && r.status === "skipped") return;
2236
+ // Linux 的 systemd 重启已经在 `guardedSystemdRestart` 里记过账、也写过日志(含"被拦下"),
2237
+ // 这里不能再记一次 —— 否则一次尝试吃掉两格额度,5 分钟的退避会缩水成 2.5 分钟。
2238
+ if (r && r.status === "selfheal-throttled") return;
2239
+ // 其它路径(macOS launchd 失败 / Windows / 脱离进程)没有自带护栏:在此记账 + 写可见日志,
2240
+ // 否则"拉起失败 → 下一拍再试 → 每 12 秒一次"就是同一个病换个平台复发。
2241
+ noteSelfhealRestart(relayDir, "权威判据说没在跑", (r && r.detail) || (r && r.status) || "未知原因");
2242
+ // 触顶:护栏会在下一拍开始拦住我们并写一行"已停止自愈重启",这里不再做任何动作。
1975
2243
  } catch { /* 下一轮再试 */ }
1976
2244
  }, selfhealIntervalMs());
1977
2245
  iv.unref?.();
1978
2246
  return () => clearInterval(iv);
1979
2247
  }
1980
2248
 
2249
+ /**
2250
+ * 自愈判定「已就绪、循环停摆」时留一行可见日志(同一条结论只写一次)。
2251
+ *
2252
+ * 为什么值得单独记一行:用户反馈里"连接是不是自己在修、修好了没有"无从判断 ——
2253
+ * 旧代码在就绪时**什么都不写**,于是"自愈watcher 判据失败→每 12 秒重启"这种故障
2254
+ * 在日志里只留下 bridge 反复重启的痕迹,看不出是插件在动手。这一行把"是我判定的、依据是什么"钉住。
2255
+ * ⚠️ 带上**隧道注册**证据(bridge 状态文件里的 device_id / 日志尾部),但不拿它当就绪判据:
2256
+ * 它比进程存活更容易闪断,若用它当 done 的条件,等于又造一个会抖的判据(正是本次故障的病因)。
2257
+ */
2258
+ function logSelfhealReady(relayDir, cfg, basis) {
2259
+ if (lastSelfhealReadyLine.get(relayDir) === basis) return;
2260
+ lastSelfhealReadyLine.set(relayDir, basis);
2261
+ const state = bridgeStateFile(relayDir);
2262
+ const registered = Boolean(state && state.device_id) && (!cfg || !cfg.device_id || String(state.device_id) === String(cfg.device_id));
2263
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2264
+ `[dsh-remote-web] 自愈判定 bridge 已就绪,已停止自愈循环(依据:${basis};`
2265
+ + `隧道注册:${registered ? `已登记(device_id=${state.device_id})` : "尚无登记证据(不影响就绪判定,注册由 bridge/watcher 自己负责)"})`);
2266
+ }
2267
+ /** `logSelfhealReady` 的去重状态:同一个 relayDir 只有判据变了才再写一行。 */
2268
+ const lastSelfhealReadyLine = new Map();
2269
+
1981
2270
  /** 停止 bridge:macOS 走 launchctl bootout;Linux 走 systemd/脱离进程;Windows 结束 watcher/bridge 进程。 */
1982
2271
  function stopBridge(relayDir) {
1983
2272
  if (isWindows()) return stopBridgeDetached(relayDir);
@@ -2007,7 +2296,7 @@ function stopBridge(relayDir) {
2007
2296
  }
2008
2297
  const targets = launchTargets();
2009
2298
  if (!targets.length) return { ok: false, status: "unsupported", detail: `当前平台(${osPlatform()})没有 launchd 服务可停,请手动结束 \`dsh-remote run\` 进程` };
2010
- // 两个域都要停:作业可能由安装器装在 user/<uid>,而旧版插件只会去 gui/<uid> 找不到(实测事故)
2299
+ // 两个域都要停:作业可能由安装器装在 user/<uid>,而旧版插件只会去 gui/<uid> 找不到(实测故障)
2011
2300
  let lastErr = "";
2012
2301
  for (const target of targets) {
2013
2302
  const r = sh(`launchctl bootout ${target}`);
@@ -2065,7 +2354,7 @@ function uninstallRuntime(relayDir, protectedPath) {
2065
2354
  // a) 停服务 + 移除自启动文件
2066
2355
  try {
2067
2356
  if (isDarwin()) {
2068
- /* 🔴 2026-10-10 事故护栏:**relayDir 不在当前 HOME 之下时,绝不碰真实自启动**。
2357
+ /* 🔴 2026-10-10 故障护栏:**relayDir 不在当前 HOME 之下时,绝不碰真实自启动**。
2069
2358
  当天某个用例用「真 HOME + 临时 relayDir」调到了这里:plist 路径解析到真实的
2070
2359
  ~/Library/LaunchAgents/com.dshremote.bridge.plist → 先 bootout 真桥、再把 plist 删了
2071
2360
  → **开发者本机的远程访问被测试整个卸掉**(job 消失、桥停摆)。
@@ -2085,7 +2374,7 @@ function uninstallRuntime(relayDir, protectedPath) {
2085
2374
  // 只处理「plist 位于当前 HOME」的服务:本插件/dsh-setup.mjs 安装的服务一定在此
2086
2375
  const plistPath = launchAgentPath();
2087
2376
  if (plistPath && existsSync(plistPath)) {
2088
- /* ★★ 归属校验(2026-10-10 事故的最终护栏):
2377
+ /* ★★ 归属校验(2026-10-10 故障的最终护栏):
2089
2378
  这份 plist 必须**确实是为本 relayDir 装的**(内容里写着 <relayDir>/dsh-setup.mjs)。
2090
2379
  不校验的话,只要有用例拿「真 HOME + 临时 relayDir」走到这里,就会把开发者本机
2091
2380
  真实的服务**先 bootout 再删文件**(当天实测发生:桥停摆、远程访问中断)。
@@ -3240,7 +3529,7 @@ async function proxyFeedback(relayDir, req, res, pathname) {
3240
3529
 
3241
3530
  /**
3242
3531
  * 把 `/dsh-remote/announcements*` 代理到云端公告 API(契约冻结见
3243
- * dsh-relay-enterprise/docs/announcements-contract.md §3):
3532
+ * 服务端公告契约文档(冻结 v1) §3):
3244
3533
  * · GET /dsh-remote/announcements → 上游 GET {api}/api/announcements?client=desktop[&install_id=…]
3245
3534
  * · POST /dsh-remote/announcements/click → 上游 POST {api}/api/announcements/click {id[, install_id]}
3246
3535
  *
@@ -3251,7 +3540,7 @@ async function proxyFeedback(relayDir, req, res, pathname) {
3251
3540
  * 两者**同时**带上会让唯一索引 (announcement_id, user_id, install_id) 把"同一人两台机器"
3252
3541
  * 算成两行 —— UV 直接翻倍。所以下面是有 Bearer 就不带 install_id,反之亦然。
3253
3542
  * ② **install_id 复用本机既有的 device_id,绝不新造 ID**:服务端那个维度就是"这台电脑",
3254
- * 再生成一个 ID 只会让同一台机器在统计里变成两台(且新 ID 与反馈/设备列表的口径对不上)。
3543
+ * 再生成一个 ID 只会让同一台机器在统计里变成两台(且新 ID 与反馈/设备列表的约定对不上)。
3255
3544
  * 注意 `cfg.device_id` 是**本机设备标识**,与遥测的 `.telemetry-install-id` 不是一回事:
3256
3545
  * 后者在 DSH_REMOTE_TELEMETRY=0 时根本不生成,公告不该被遥测开关牵连。
3257
3546
  * ③ **失败一律软着陆**:上游 404(老服务端)/ 不可达 / 超时都只回一个 JSON,**绝不抛**。
@@ -3271,7 +3560,7 @@ async function proxyAnnouncements(relayDir, req, res, pathname) {
3271
3560
  }
3272
3561
 
3273
3562
  const headers = { "x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}` };
3274
- // 浏览器已带的 Bearer 优先透传(与 proxyFeedback 同口径:那是请求方自己的身份);
3563
+ // 浏览器已带的 Bearer 优先透传(与 proxyFeedback 同约定:那是请求方自己的身份);
3275
3564
  // 否则由 node 半附当前账号 JWT —— 只有"确实配了账号"才值得为此跑一次 device-login。
3276
3565
  const auth = req.headers.authorization;
3277
3566
  let bearer = "";
@@ -3497,7 +3786,7 @@ async function fetchDistTags() {
3497
3786
  return {};
3498
3787
  }
3499
3788
 
3500
- /** 在「当前通道」与 latest 之间取更高者(**更新与"检查更新"必须用同一口径**,否则面板会说"已是最新"而更新却装新版)。 */
3789
+ /** 在「当前通道」与 latest 之间取更高者(**更新与"检查更新"必须用同一约定**,否则面板会说"已是最新"而更新却装新版)。 */
3501
3790
  function pickBestTag(tags) {
3502
3791
  const t = tags || {};
3503
3792
  const channelVer = typeof t[UPDATE_TAG] === "string" ? t[UPDATE_TAG] : "";
@@ -3511,7 +3800,7 @@ function pickBestTag(tags) {
3511
3800
  return { tag: UPDATE_TAG, version: channelVer, why: `当前通道 ${UPDATE_TAG}(${channelVer}) 不低于 latest(${latestVer})` };
3512
3801
  }
3513
3802
 
3514
- /** 查询"对我而言"的最新版本号(口径同 pickBestTag)。 */
3803
+ /** 查询"对我而言"的最新版本号(约定同 pickBestTag)。 */
3515
3804
  async function npmLatestVersion() {
3516
3805
  return pickBestTag(await fetchDistTags()).version;
3517
3806
  }
@@ -3607,7 +3896,7 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
3607
3896
  // Windows 上跑 npx.cmd 必须经 shell(Node ≥20.12 起否则 EINVAL);走 node+npx-cli.js 时不需要。
3608
3897
  shell: npx.shell,
3609
3898
  windowsHide: true, // Windows:不弹控制台窗口(POSIX 上该选项被忽略)
3610
- // PATH 补 node 目录:App 最小 PATH 下也能跑 npx;同时保持本机既有的安装来源口径
3899
+ // PATH 补 node 目录:App 最小 PATH 下也能跑 npx;同时保持本机既有的安装来源约定
3611
3900
  // (在线更新会重跑安装器并重写 plist,若不带来源就会把 plugin_market 误记成 npx)。
3612
3901
  env: spawnEnv({
3613
3902
  DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
@@ -3877,7 +4166,7 @@ function rotateUpdateLog(relayDir) {
3877
4166
  /**
3878
4167
  * 判断 patch 文本是否是「合法的顶层 YAML 数组文档」。
3879
4168
  *
3880
- * 为什么必须判(2026-09-18 Windows 用户实测的致命事故):dsh 解析 cordis.patch.yml 时
4169
+ * 为什么必须判(2026-09-18 Windows 用户实测的致命故障):dsh 解析 cordis.patch.yml 时
3881
4170
  * **要求顶层是数组**,而**纯注释文档的 YAML 解析结果是 `null` 而不是空数组**,于是 dsh 直接:
3882
4171
  * `Error: dsh: overlay …/cordis.patch.yml must be a top-level YAML array of loader patch entries`
3883
4172
  * 官方空 profile 模板是「注释 + `[]`」——`[]` 这个占位符不能省。
@@ -3907,7 +4196,7 @@ function stripOwnEntriesFromPatch(patch) {
3907
4196
  // ① 管理标记块(dsh-setup 写入的形式)
3908
4197
  // 结尾标记后面可能还有残留文字(历史变体),用 [^\n]* 吃掉整行,避免留下半截内容
3909
4198
  const out = String(patch ?? "").replace(/\n?# >>> dsh-remote-(?:web|ui)[^\n]*\n[\s\S]*?# <<< dsh-remote-(?:web|ui)[^\n]*\n?/g, "\n");
3910
- // ② 无标记的 insert 块(旧版 / 其它工具写入的形式,历史事故里出现过)。
4199
+ // ② 无标记的 insert 块(旧版 / 其它工具写入的形式,历史故障里出现过)。
3911
4200
  // 只摘标记块是不够的:目录已删而 patch 仍引用 → dsh 插件树加载失败 → 整个 dsh web 起不来。
3912
4201
  const lines = out.split("\n");
3913
4202
  const kept = [];
@@ -3949,7 +4238,7 @@ function writePatchDocument(patchFile, text) {
3949
4238
  /**
3950
4239
  * 「运行时清理助手」源码(卸载用;由插件生成、detached 执行)。
3951
4240
  *
3952
- * 为什么必须是独立进程(2026-09-18 用户实测的僵死事故):
4241
+ * 为什么必须是独立进程(2026-09-18 用户实测的僵死故障):
3953
4242
  * uninstallRuntime() 里有 spawnSync("schtasks"/"powershell"/"taskkill"),同步子进程调用会把
3954
4243
  * **dsh web 的事件循环整个钉死** —— 不只是这一个请求不返回,手机端隧道、其它页面、所有 API
3955
4244
  * 一起停摆,表现为「端口在听、连接建立、但永不响应」。而 spawnSync 的 timeout 在 Windows 上
@@ -3959,7 +4248,7 @@ function writePatchDocument(patchFile, text) {
3959
4248
  * 助手做的是「宁可少做也不卡住」的有界清理:杀残留进程 → 删自启动 → 清配置目录,每步独立 try/catch。
3960
4249
  */
3961
4250
  function buildRuntimeCleanupHelper(relayDir, protectedPath) {
3962
- /* ★ 归属校验(2026-10-10 事故第三条路径):计划里的 plistPath 用的是**当前 homedir**,
4251
+ /* ★ 归属校验(2026-10-10 故障第三条路径):计划里的 plistPath 用的是**当前 homedir**,
3963
4252
  而 relayDir 来自调用方 —— 两者不必是同一个目录。曾有测试用「真 HOME + 临时 relayDir」
3964
4253
  走到这里,于是这个 detached 助手把**开发者本机真实的自启动 plist 删了**(异步删,
3965
4254
  所以在进程内插桩根本抓不到)。现在:plist 里没写着本 relayDir 的入口脚本 → **不给路径**,
@@ -3986,7 +4275,7 @@ import { dirname, join, sep } from "node:path";
3986
4275
 
3987
4276
  const PLAN = ${plan};
3988
4277
  // 与主进程一致的测试隔离开关:置位时**绝不做任何真实系统操作**(不碰 launchctl/systemctl/schtasks/taskkill),
3989
- // 但仍清空配置目录 —— 否则用例会去动开发者本机真实的自启动服务(历史事故,见 setup-output 用例的护栏)。
4278
+ // 但仍清空配置目录 —— 否则用例会去动开发者本机真实的自启动服务(历史故障,见 setup-output 用例的护栏)。
3990
4279
  const SKIP_SYSTEM_OPS = process.env.DSH_RELAY_SKIP_SERVICE === "1";
3991
4280
  function log(m) { try { appendFileSync(PLAN.log, "\\n[" + new Date().toISOString() + "] " + m + "\\n"); } catch (e) { /* 日志失败不阻断清理 */ } }
3992
4281
  function run(cmd, args, timeout) {
@@ -4086,7 +4375,7 @@ function accountFingerprintOf(cfg) {
4086
4375
  /**
4087
4376
  * 账号(或模式/设备身份)变化后,**必须让 bridge 用新凭据重新登记**。
4088
4377
  *
4089
- * 事故(2026-09-19 实测):从 A 账号切到 B 账号后,配置已经改了、device_id 也清了,
4378
+ * 故障(2026-09-19 实测):从 A 账号切到 B 账号后,配置已经改了、device_id 也清了,
4090
4379
  * 但**正在运行的 bridge 是启动时把账号/密码固化在环境变量里的**,它不会自己换账号;
4091
4380
  * 而那次"重启 bridge"又因 launchd 域名不一致(插件只认 gui/<uid>,安装器装在 user/<uid>)静默失败
4092
4381
  * → 设备继续留在 A 账号,B 账号的设备列表里永远看不到这台机器。
@@ -4096,6 +4385,10 @@ function accountFingerprintOf(cfg) {
4096
4385
  * 自愈也就永远不会去纠正);③ 交给调用方随后重新启动。
4097
4386
  */
4098
4387
  function resetBridgeForAccountChange(relayDir, why) {
4388
+ // ★ 2026-10-13:账号/模式变更是**人**做的决定(登录、切换账号、改自建模式),
4389
+ // 它必须无条件生效 —— 所以顺手清掉自愈护栏额度,绝不允许"自愈循环之前用完了 3 次额度"
4390
+ // 导致切换账号后新 bridge 起不来(那会让设备继续留在旧账号里,是 2026-09-19 那个老故障的翻版)。
4391
+ resetSelfhealGuard(relayDir);
4099
4392
  const stopped = stopBridge(relayDir);
4100
4393
  // 脱离进程形态(launchd 托管不住时的兜底)也要收干净,否则它会用旧凭据继续跑
4101
4394
  try { stopBridgeDetached(relayDir, { force: true }); } catch { /* 非关键 */ }
@@ -4477,7 +4770,7 @@ async function composeConnect(relayDir, opts = {}) {
4477
4770
  // ── 中继注册证据(只有 bridge 进程在跑时才算「现在能用」) ──
4478
4771
  // ⚠️ 先判「这个 bridge 是不是当前账号的」:账号切换后进程不会自己换凭据,
4479
4772
  // 旧账号留下的状态文件(phase=online + 旧 device_id)会让面板谎报「已连接」,
4480
- // 自愈也就永远不会去重启它(2026-09-19 实测事故)。
4773
+ // 自愈也就永远不会去重启它(2026-09-19 实测故障)。
4481
4774
  const accountCurrent = bridgeMatchesCurrentAccount(relayDir, cfg);
4482
4775
  let registered = false;
4483
4776
  let registerSource = "";
@@ -4626,6 +4919,10 @@ async function composeConnect(relayDir, opts = {}) {
4626
4919
  watcherAgeMs: wedge.known ? wedge.ageMs : 0,
4627
4920
  watcherWedged: Boolean(wedge.wedged),
4628
4921
  watcherRestarts: wedge.restarts,
4922
+ // 自愈循环自己的重启账本(见 scheduleRuntime 的长注释):面板诊断据此如实说明
4923
+ // "插件为这台机器重启过几次 bridge、是不是已经熔断停手"。默认 0 = 正常,不产生任何文案。
4924
+ selfhealRestarts: selfhealGuardOf(relayDir).count,
4925
+ selfhealExhausted: selfhealGuardOf(relayDir).count >= SELFHEAL_MAX_RESTARTS,
4629
4926
  upstreamUrl: upstreamUrl(),
4630
4927
  upstreamSource: upstreamSourceLabel(),
4631
4928
  // 宿主形态:桌面端(Electron)与 Web 版(node)在若干动作上语义不同 ——
@@ -4723,6 +5020,12 @@ function buildConnectDiagnostics(relayDir, conn, log) {
4723
5020
  : `运行环境缺文件: ${conn.runtimeMissing.slice(0, 8).join(", ")}${conn.runtimeMissing.length > 8 ? ` 等 ${conn.runtimeMissing.length} 个` : ""}`,
4724
5021
  `bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
4725
5022
  `后台守护: ${conn.watcherRunning ? `在运行(pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `,已 ${Math.round(conn.watcherAgeMs / 1000)} 秒` : ""}${conn.watcherWedged ? ",⚠️ 迟迟未拉起 bridge" : ""}${conn.watcherRestarts ? `,已自动重启 ${conn.watcherRestarts} 次` : ""})` : "未运行"}`,
5023
+ // 只在真的重启过 bridge 时才出现这一行(正常用户零新增文案,不制造噪音):
5024
+ // 它回答的是"插件自己有没有在反复重启 bridge"—— 2026-10-13 那个故障里,这个问题
5025
+ // 在客户端侧完全没有答案,运维只能从服务端 audit_log 反推。
5026
+ conn.selfhealRestarts
5027
+ ? `自愈重启: 本进程内已重启 bridge ${conn.selfhealRestarts} 次${conn.selfhealExhausted ? "(⚠️ 已达上限,已停止自动重启,请点「一键更新」)" : ""}`
5028
+ : "",
4726
5029
  `上游 dsh web: ${conn.upstreamUrl}(${conn.upstreamSource}${conn.upstreamReachable === null ? "" : conn.upstreamReachable ? ",可达" : `,⚠️ 不可达:${conn.upstreamError}`})`,
4727
5030
  `目录选择器: ${pickerPinState.kind === "native" ? "⚠️ 系统原生(远程时对话框弹在电脑那台机器上 → 手机上点「添加工作区」会像没反应)" : (pickerPinState.kind || "未知")}${pickerPinState.detail ? `(${pickerPinState.detail})` : ""}`,
4728
5031
  `中继注册: ${conn.registered ? "已注册(" + conn.registerSource + ")" : "未注册"}`,
@@ -4782,7 +5085,7 @@ async function buildDiagReport(relayDir, opts = {}) {
4782
5085
  `【插件】v${PLUGIN_VERSION} 运行时 ${runtimeVersionOf(relayDir) || "未知"} 宿主 ${harnessVersion() || "未知"} ${osPlatform()}/${process.arch} node ${String(process.versions.node).split(".")[0]}`,
4783
5086
  `【阶段】${conn.phase}(${conn.text})`,
4784
5087
  `【设备】ID=${conn.deviceId || "(未生成)"} 运行环境=${conn.runtimeReady ? "就绪" : `缺失${conn.runtimeMissing && conn.runtimeMissing.length ? `(缺 ${conn.runtimeMissing.slice(0, 3).join("、")})` : ""}`}`,
4785
- `【bridge】${conn.bridgeRunning ? `在运行 pid=${conn.bridgePids.join(",")}` : "未运行"} 守护=${conn.watcherRunning ? `在运行 pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `(${Math.round(conn.watcherAgeMs / 1000)}s)` : ""}` : "未运行"}${conn.watcherWedged ? " ⚠️活着但拉不起 bridge" : ""}${conn.watcherRestarts ? ` 已自动重启 ${conn.watcherRestarts} 次` : ""}`,
5088
+ `【bridge】${conn.bridgeRunning ? `在运行 pid=${conn.bridgePids.join(",")}` : "未运行"} 守护=${conn.watcherRunning ? `在运行 pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `(${Math.round(conn.watcherAgeMs / 1000)}s)` : ""}` : "未运行"}${conn.watcherWedged ? " ⚠️活着但拉不起 bridge" : ""}${conn.watcherRestarts ? ` 已自动重启 ${conn.watcherRestarts} 次` : ""}${conn.selfhealRestarts ? ` 【自愈重启】本进程内 ${conn.selfhealRestarts} 次${conn.selfhealExhausted ? "(已达上限,已停止自动重启)" : ""}` : ""}`,
4786
5089
  `【上游】${conn.upstreamUrl}(${conn.upstreamSource}${conn.upstreamReachable === null ? "" : conn.upstreamReachable ? ",可达" : `,⚠️不可达:${conn.upstreamError}`})`,
4787
5090
  `【中继】${conn.registered ? `已注册(${conn.registerSource})` : "未注册"} 账号=${conn.accountCurrent === false ? "⚠️上一个账号(需重启 bridge)" : "当前账号"} 自动重试=${conn.attempts} 次`,
4788
5091
  `【错误】${conn.error ? `${conn.error.code}: ${conn.error.message}` : (conn.detail ? "无(见上方阶段说明)" : "无")}`,
@@ -4815,7 +5118,7 @@ async function submitFeedback(relayDir, payload) {
4815
5118
  "content-type": "application/json",
4816
5119
  "x-dsh-device": cfg.device_id || "",
4817
5120
  "x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
4818
- // 与 proxyFeedback 同一口径:结构化平台字段 + 真实 UA(此处没有浏览器请求可转发,
5121
+ // 与 proxyFeedback 同一约定:结构化平台字段 + 真实 UA(此处没有浏览器请求可转发,
4819
5122
  // 就如实写本机平台,绝不冒充浏览器 UA)。
4820
5123
  "user-agent": `dsh-remote-web/${PLUGIN_VERSION} (${process.platform}; ${process.arch})`,
4821
5124
  "x-dsh-platform": process.platform,
@@ -4868,6 +5171,10 @@ function ensureConnection(relayDir, opts = {}) {
4868
5171
  // 顺手治一次「卡住的更新」(复用面板已有的轮询,不需要新定时器 —— 诊断报告 F5)
4869
5172
  healStalledJobs(relayDir);
4870
5173
  const book = connectBook(relayDir);
5174
+ // ★ 2026-10-13:**人工操作永远立即放行**(与 provisionRetryGate 的 resetProvisionRetry 同一原则)。
5175
+ // 面板的「重试 / 一键修复」走的就是 force=true;若不放行,护栏触顶后用户点重试会静默无效 ——
5176
+ // 那就把"安全阀"变成了"用户被自己的插件锁在门外"。护栏只约束**自动**自愈,不约束人。
5177
+ if (opts.force) resetSelfhealGuard(relayDir);
4871
5178
  if (opts.force) { book.attempts = 0; book.lastAttemptAt = 0; }
4872
5179
  const now = Date.now();
4873
5180
  if (!opts.force && book.lastAttemptAt && now - book.lastAttemptAt < connectBackoffMs(book.attempts)) return null;
@@ -4890,7 +5197,7 @@ function ensureConnection(relayDir, opts = {}) {
4890
5197
  const watcherRunning = Boolean(launchd.running || watcherProcs.length);
4891
5198
  if (bridgeProcs.length || watcherRunning) {
4892
5199
  // 进程在跑 ≠ 跑对了账号:切换账号后旧 bridge 会一直用旧凭据,
4893
- // 只看"有没有进程"会让自愈永久停摆(实测事故)。这里多判一次账号归属。
5200
+ // 只看"有没有进程"会让自愈永久停摆(实测故障)。这里多判一次账号归属。
4894
5201
  if (bridgeProcs.length && !bridgeMatchesCurrentAccount(relayDir, cfg)) {
4895
5202
  book.lastAttemptAt = now;
4896
5203
  book.attempts += 1;
@@ -5473,7 +5780,7 @@ function telemetryRuntimeReady(relayDir) {
5473
5780
  }
5474
5781
 
5475
5782
  /**
5476
- * 运行环境探针(在补装 watcher 与面板轮询里顺手调用,不额外起定时器):
5783
+ * 运行环境探测(在补装 watcher 与面板轮询里顺手调用,不额外起定时器):
5477
5784
  * - 现在缺运行环境 → 记下「本进程见过缺失」,并在超过 INSTALL_STALE_MS 仍缺时归因为
5478
5785
  * install_failed(runtime_install_timeout)(每进程一次);
5479
5786
  * - 之前见过缺失、现在已就绪 → runtime_ready(每进程一次)。
@@ -5729,7 +6036,7 @@ export const __telemetryInternals = {
5729
6036
 
5730
6037
  /**
5731
6038
  * 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
5732
- * 之类的口径统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
6039
+ * 之类的约定统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
5733
6040
  * 契约:POST <api_url>/api/install-report,头 `authorization: Bearer <device-login JWT>`,
5734
6041
  * body { device_id?, install_source, install_version, harness_version, host_os, host_arch },成功 200 {ok:true};
5735
6042
  * 404/400 一律忽略(老服务端没有该接口时不能报错)。
@@ -6218,7 +6525,7 @@ function registerRoutes(ctx, relayDir) {
6218
6525
  handler: async (_req, res) => {
6219
6526
  // 彻底卸载 = ① 插件 profile 清理(快、纯 fs、可预期)② 运行时/bridge 清理(慢、要调外部命令)
6220
6527
  //
6221
- // ⚠️ 顺序是刻意的,别改回去(2026-09-18 用户实测的僵死事故):
6528
+ // ⚠️ 顺序是刻意的,别改回去(2026-09-18 用户实测的僵死故障):
6222
6529
  // 旧实现把**慢的** uninstallRuntime 排在最前、uninstallSelf 排在其后,于是
6223
6530
  // uninstallRuntime 里任何一次 spawnSync 卡住(Windows 沙箱拦 schtasks,而 spawnSync 的
6224
6531
  // timeout 在 Windows 上并不可靠,实测 18s+ 未返回),uninstallSelf 就**永远不执行**
@@ -6596,7 +6903,7 @@ function registerRoutes(ctx, relayDir) {
6596
6903
  invalidateRelayToken(relayDir); // 账号可能变了:丢弃旧 token 缓存,立即用新账号认证
6597
6904
  if (accountFingerprintOf(cfg) !== accountFpBefore) resetBridgeForAccountChange(relayDir, "账号/模式已变更");
6598
6905
  const bridgeRestart = startBridge(relayDir);
6599
- // 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」口径);失败静默。
6906
+ // 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」约定);失败静默。
6600
6907
  maybeReportInstall(relayDir, { force: true });
6601
6908
  sendJson(res, 200, { ok: true, bridgeRestart, ...(await composeStatus(relayDir)) });
6602
6909
  },
@@ -6627,7 +6934,7 @@ function registerRoutes(ctx, relayDir) {
6627
6934
  // 首次安装/在线更新后重启 DeepSeek harness:插件本体与浏览器半在进程启动时装载,
6628
6935
  // 不重启则面板入口与 /dsh-remote/* 路由都不会出现。只重启承载本插件的进程。
6629
6936
  //
6630
- // 🔴 2026-10-11 业主口径:**桌面端绝不代为重启**。
6937
+ // 🔴 2026-10-11 用户约定:**桌面端绝不代为重启**。
6631
6938
  // "自动重启会很突兀地把窗口给关掉,关没了……我们应该提示用户自己重启,不要由我们来重启。"
6632
6939
  // 桌面端 Host 由 Electron 托管,重启 = 强杀承载窗口的进程(还会弹原生致命错误)。
6633
6940
  // 这里做成**硬拒绝**(不只是前端藏按钮):任何调用方都拿不到"被重启"的结果,
@@ -6651,6 +6958,9 @@ function registerRoutes(ctx, relayDir) {
6651
6958
  method: "POST",
6652
6959
  path: "/dsh-remote/start",
6653
6960
  handler: async (_req, res) => {
6961
+ // 用户点了「启动」= 人工指令:清掉自愈护栏额度再动手(同 resetProvisionRetry 的原则),
6962
+ // 否则"自动自愈刚刚熔断"会把用户这一次明确的操作也一起挡掉。
6963
+ resetSelfhealGuard(relayDir);
6654
6964
  const r = startBridge(relayDir);
6655
6965
  // 【0.6.2】composeStatus 自带 ok:true(那是「状态查询成功」),必须先展开再覆盖,
6656
6966
  // 否则不管启动成没成功都回 ok:true → 面板一律弹「✅ bridge 已启动」。
@@ -6754,7 +7064,7 @@ function registerRoutes(ctx, relayDir) {
6754
7064
  const password = String(body.password ?? "");
6755
7065
  if (!phone || !smsCode || !password) return sendJson(res, 400, { ok: false, error: "手机号、短信验证码与密码必填" });
6756
7066
  // 全量透传 body(不挑字段):注册来源 reg_source(面板=panel_register/手机端网页=remoteweb_register)
6757
- // 等扩展字段必须原样进企业端,否则「用户从哪注册的」这类增长口径会全部丢失。
7067
+ // 等扩展字段必须原样进企业端,否则「用户从哪注册的」这类增长约定会全部丢失。
6758
7068
  const payload = { ...body, phone, sms_code: smsCode, password };
6759
7069
  delete payload.__parseError;
6760
7070
  const captchaId = body.captcha_id ?? body.captchaId;
@@ -6904,7 +7214,7 @@ function registerRoutes(ctx, relayDir) {
6904
7214
  // 自己持有 webserver 配置(实测监听 43120 / 回环),我们的 LAN 绑定补丁并不生效,于是它一直
6905
7215
  // 用的是原生那条路。**不能把"手机能不能选工作区"押在宿主的绑定地址上。**
6906
7216
  //
6907
- // 【0.6.15 事故修正 —— 0.6.14 的"先建 browse、后摘 auto"在 DSH Desktop 上必然失败】
7217
+ // 【0.6.15 故障修正 —— 0.6.14 的"先建 browse、后摘 auto"在 DSH Desktop 上必然失败】
6908
7218
  //
6909
7219
  // 现场(用户诊断日志):
6910
7220
  // error: service "directoryPicker" has been registered at .dLateDirectoryPicker3
@@ -7064,9 +7374,9 @@ const PICKER_PIN_PROBE_WAIT_MS = 30_000; // 等服务挂出来的上限(只
7064
7374
  const PICKER_PIN_PROBE_GAP_MS = 3_000; // 上面那段轮询间隔
7065
7375
 
7066
7376
  /**
7067
- * 目录选择器的**延后**接线 —— 这是 2026-09-26 启动事故的直接修复。
7377
+ * 目录选择器的**延后**接线 —— 这是 2026-09-26 启动故障的直接修复。
7068
7378
  *
7069
- * 🔴 事故:旧实现在 `apply()` 里**立刻**调 `pinBrowseDirectoryPicker(ctx)`(最多 6 次 × 700ms 轮询)。
7379
+ * 🔴 故障:旧实现在 `apply()` 里**立刻**调 `pinBrowseDirectoryPicker(ctx)`(最多 6 次 × 700ms 轮询)。
7070
7380
  * `loader.remove()` / `loader.create()` 会触发 dsh 的 `internal/plugin` 事件,而
7071
7381
  * `@deepseek-ai/dsh-client-modules` 收到事件就**重新 compose 整张前端插件图**
7072
7382
  * (`ClientModuleRegistry.flush() → buildCombo() → newlineCount()/identitySectionMap()`)。
@@ -7184,7 +7494,7 @@ export function apply(ctx, config = {}) {
7184
7494
  // 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
7185
7495
  ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
7186
7496
  // 目录选择器:远程控制下必须用浏览器内实现(否则手机点「添加工作区」像没反应) —— 见上方长注释
7187
- // ⚠️ **必须延后**:绝不能在 apply() 里直接切 Loader —— 会把宿主启动拖成假死(2026-09-26 事故,
7497
+ // ⚠️ **必须延后**:绝不能在 apply() 里直接切 Loader —— 会把宿主启动拖成假死(2026-09-26 故障,
7188
7498
  // 见 schedulePickerPin 上方长注释)。schedulePickerPin 自己等"宿主已 listen + 静置"再动手。
7189
7499
  ctx.effect(() => schedulePickerPin(ctx), "dsh-remote-web: pin browser directory picker");
7190
7500
  // 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
@@ -7200,7 +7510,7 @@ export function apply(ctx, config = {}) {
7200
7510
  telemetryRuntimeProbe(relayDir); // 匿名遥测:记下「本进程见过运行环境缺失」(装好后发 runtime_ready)
7201
7511
  }
7202
7512
  // 插件启动即上报本机安装信息(install_source/version + 主机 OS/架构;仅有账号凭据时上报,失败静默)。
7203
- // 面板登录成功(POST /dsh-remote/config)时会再上报一次,覆盖「已登录用户升级插件」的口径。
7513
+ // 面板登录成功(POST /dsh-remote/config)时会再上报一次,覆盖「已登录用户升级插件」的约定。
7204
7514
  maybeReportInstall(relayDir);
7205
7515
  ctx.logger?.info?.(`dsh-remote-web: /dsh-remote routes ready (relayDir=${relayDir})`);
7206
7516
  }
@@ -7210,4 +7520,15 @@ export function apply(ctx, config = {}) {
7210
7520
  // 单独导出才能把「**先摘 auto、后建 browse**」这个顺序契约钉死。
7211
7521
  export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerPin as __schedulePickerPin, pickerPinState as __pickerPinState, directoryPickerKind as __directoryPickerKind, PICKER_AUTO_ENTRY_ID as __PICKER_AUTO_ENTRY_ID, PICKER_AUTO_PACKAGE as __PICKER_AUTO_PACKAGE, PICKER_BROWSE_PACKAGES as __PICKER_BROWSE_PACKAGES,
7212
7522
  // 日志去重(同一条失败不刷屏)也要能被单测钉住 —— 它属于「诊断可读性」,见 test/linux-bridge.test.mjs
7213
- noteAutostartFailure as __noteAutostartFailure, clearAutostartFailure as __clearAutostartFailure };
7523
+ noteAutostartFailure as __noteAutostartFailure, clearAutostartFailure as __clearAutostartFailure,
7524
+ // ★ 2026-10-13 自愈重启策略(修 12 秒重启风暴)必须能被**行为级**单测钉住,见 test/selfheal-restart-guard.test.mjs。
7525
+ // 为什么必须导出而不是只靠 apply():这一组用例要断言的是"定时器跑 N 拍之后,
7526
+ // `systemctl restart` 到底被调用了几次"——只有把定时器本体拿到用例里才能确定性地驱动它,
7527
+ // 靠 apply() 间接跑就得靠真实 sleep 猜拍数(脆弱且慢)。
7528
+ // 系统命令仍然走**假 PATH 上的假 systemctl**(与 test/linux-bridge.test.mjs 同一套手法),
7529
+ // 所以不需要为了可测性往生产代码里插入任何依赖注入层。
7530
+ scheduleRuntime as __scheduleRuntime, startBridge as __startBridge, manualStatus as __manualStatus,
7531
+ linuxSystemdUnitState as __linuxSystemdUnitState,
7532
+ selfhealRestartGate as __selfhealRestartGate, noteSelfhealRestart as __noteSelfhealRestart,
7533
+ noteSelfhealBlocked as __noteSelfhealBlocked, resetSelfhealGuard as __resetSelfhealGuard,
7534
+ selfhealGuardOf as __selfhealGuardOf, SELFHEAL_MAX_RESTARTS as __SELFHEAL_MAX_RESTARTS };