@mrrisega/dsh-remote 0.6.7-beta.2 → 0.6.7-beta.4

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.
@@ -20,11 +20,11 @@
20
20
  // - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
21
21
  //
22
22
  // 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
23
- import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, statSync, constants as fsConstants } from "node:fs";
23
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, constants as fsConstants } from "node:fs";
24
24
  import { join, dirname, sep, delimiter } from "node:path";
25
25
  import { execSync, spawn, spawnSync } from "node:child_process";
26
26
  import { randomUUID } from "node:crypto";
27
- import { homedir } from "node:os";
27
+ import { homedir, tmpdir } from "node:os";
28
28
  import { fileURLToPath } from "node:url";
29
29
 
30
30
  /** 本插件在 host 侧的服务依赖。 */
@@ -86,6 +86,35 @@ const DEFAULT_RELAY_DIR = process.env.DSH_RELAY_DIR || join(homedir(), ".dsh-rem
86
86
  const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
87
87
  const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
88
88
 
89
+ /**
90
+ * 把文件权限收紧到「只有本人可读写」。
91
+ *
92
+ * ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL,不是 POSIX mode 位):
93
+ * 实测 .dsh-config.json 拿到的是用户目录的默认**继承 ACL**(AreAccessRulesProtected=False),
94
+ * 也就是说代码里那句 0600 对 Windows 用户完全没效果 —— 而文件里是**明文账号密码**。
95
+ * 风险不在"同机他人可读"(默认 ACL 下其实读不到),而在**任何按文件复制的场景**
96
+ * (备份/同步盘/杀软上报/崩溃转储/用户发给作者的支持包)都会连钥匙一起被带走,
97
+ * 而文档里的"0600 请妥善保护"会让用户误以为 Windows 上已有这层保护。
98
+ * 这里显式断开继承、只授予当前用户;失败只警告一次,绝不影响主流程。
99
+ */
100
+ let hardenWarned = false;
101
+ function hardenFile(file) {
102
+ try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
103
+ if (process.platform !== "win32") return;
104
+ const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
105
+ if (!who) return;
106
+ let r;
107
+ try {
108
+ r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
109
+ { windowsHide: true, encoding: "utf8", timeout: 8000 });
110
+ } catch (e) { r = { status: -1, stderr: e.message }; }
111
+ if (r.status !== 0 && !hardenWarned) {
112
+ hardenWarned = true;
113
+ console.warn(`⚠️ 收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`);
114
+ console.warn(" 配置文件可能仍可被其它账户/备份工具读取,请自行确认其存放位置。");
115
+ }
116
+ }
117
+
89
118
  // ---------- 小工具 ----------
90
119
 
91
120
  /** 执行 shell 命令,不抛异常,返回 { ok, stdout, stderr, code }。 */
@@ -334,6 +363,7 @@ function saveConfig(relayDir, cfg) {
334
363
  mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
335
364
  // mode 0o600:与 dsh-setup.mjs 一致(文件已存在时 writeFileSync 不改权限,显式 chmod 兜底)
336
365
  writeFileSync(configPathOf(relayDir), JSON.stringify(cfg, null, 2), { mode: 0o600 });
366
+ hardenFile(configPathOf(relayDir)); // Windows 上 mode 是空操作 → 显式收紧 ACL(内含明文账号密码)
337
367
  try {
338
368
  chmodSync(configPathOf(relayDir), 0o600);
339
369
  } catch {
@@ -412,6 +442,7 @@ async function mintHarnessCookie(ctx, relayDir) {
412
442
  const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
413
443
  mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
414
444
  writeFileSync(join(relayDir, HARNESS_COOKIE_FILE), JSON.stringify(out, null, 2), { mode: 0o600 });
445
+ hardenFile(join(relayDir, HARNESS_COOKIE_FILE)); // 会话 Cookie ≈ dsh web 的完整访问权,同样要收紧
415
446
  return true;
416
447
  } catch {
417
448
  return false;
@@ -515,11 +546,28 @@ function launchAgentPath() {
515
546
  * `process.getuid()`,在 Windows 上直接 TypeError(这正是面板两个读状态接口 500 的根因)。
516
547
  * 现在:非 macOS 或取不到 uid 一律返回 null,调用方必须先判空。
517
548
  */
518
- function launchTarget() {
519
- if (!isDarwin()) return null;
549
+ /**
550
+ * launchd 域名阶梯(**必须与 dsh-setup.mjs 一致**)。
551
+ *
552
+ * 事故(2026-09-19 实测):安装器按 macOS 26 的修复把作业装在 **`user/<uid>`** 域
553
+ * (gui 域会被系统置为 on-demand-only,RunAtLoad/KeepAlive 失效),
554
+ * 但插件这边只认 `gui/<uid>` → 切换账号时那次"重启 bridge"实际执行的是
555
+ * launchctl bootout gui/501/com.dshremote.bridge → 找不到
556
+ * launchctl bootstrap gui/501 <plist> → 失败
557
+ * 结果:旧 bridge 继续用**旧账号**跑,新账号的设备列表里永远看不到这台机器。
558
+ * 现在两边用同一条阶梯:user/<uid> 优先,gui/<uid> 兜底。
559
+ */
560
+ function launchDomains() {
561
+ if (!isDarwin()) return [];
520
562
  const uid = currentUid();
521
- if (uid === null) return null;
522
- return `gui/${uid}/com.dshremote.bridge`;
563
+ return uid === null ? [] : [`user/${uid}`, `gui/${uid}`];
564
+ }
565
+ /** 所有可能承载本服务的 launchd target(按优先级)。 */
566
+ function launchTargets() {
567
+ return launchDomains().map((d) => `${d}/com.dshremote.bridge`);
568
+ }
569
+ function launchTarget() {
570
+ return launchTargets()[0] || null; // 首选域(user/<uid>)
523
571
  }
524
572
 
525
573
  /**
@@ -536,12 +584,21 @@ function launchTarget() {
536
584
  * 现在:print 的 state 为准 + 附带 runs/lastExitCode/crashing 供面板与自愈解释原因;
537
585
  * 仅在 print 本身不可用时才回退 list,且回退路径要求 pid 真实存活。
538
586
  */
587
+ const NO_LAUNCHD = { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
588
+
539
589
  function launchdStatus() {
540
590
  // 非 macOS 没有 launchctl(Windows 上连 process.getuid 都没有):直接返回「无此服务」,
541
591
  // 不再去 shell 里调一个不存在的命令——这既避免 500,也避免每次轮询白起一个 cmd.exe。
542
- if (!isDarwin()) return { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
543
- const target = launchTarget();
544
- if (!target) return { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
592
+ if (!isDarwin()) return { ...NO_LAUNCHD };
593
+ // 逐域查询:安装器可能把作业装在 user/<uid>,插件以前只看 gui/<uid> → 会把"正在运行"看成"未运行"。
594
+ for (const target of launchTargets()) {
595
+ const st = launchdStatusIn(target);
596
+ if (st.running || st.crashing || st.state) return st;
597
+ }
598
+ return { ...NO_LAUNCHD };
599
+ }
600
+
601
+ function launchdStatusIn(target) {
545
602
  const pr = sh(`launchctl print ${target}`);
546
603
  if (pr.ok) {
547
604
  const stateMatch = pr.stdout.match(/state\s*=\s*([^\n]+)/);
@@ -930,7 +987,7 @@ function reapBrokenAutostart(relayDir) {
930
987
  if (!plistEntryMissing()) return false;
931
988
  const st = launchdStatus();
932
989
  if (!st.running && !st.crashing && !st.state) return false; // 作业未被 launchd 加载、无可摘的东西
933
- sh(`launchctl bootout ${launchTarget()}`);
990
+ for (const target of launchTargets()) sh(`launchctl bootout ${target}`);
934
991
  try { rmSync(launchAgentPath(), { force: true }); } catch { /* 非关键 */ }
935
992
  appendLogLine(relayDir, AUTO_INSTALL_LOG,
936
993
  `[dsh-remote-web] 已摘除失效自启动:plist 入口 ${join(relayDir, "dsh-setup.mjs")} 不存在`
@@ -966,16 +1023,15 @@ function killPid(pid, signal = "SIGTERM") {
966
1023
  }
967
1024
 
968
1025
  /**
969
- * Windows:启动 bridge(0.6.7 新增)。
1026
+ * 「脱离进程」方式启动 bridge(Windows 必用;macOS/Linux 在 launchd/systemd 托管不了时兜底)。
970
1027
  *
971
- * Windows 上没有 launchd/systemd,dsh-setup.mjs 也不为 win32 生成自启动
972
- * (它明写「平台 win32 不支持自启动」)。但 bridge 本身在 Windows 上能跑——
973
- * 所以插件直接把 watcher(`node <relayDir>/dsh-setup.mjs run`)作为脱离进程拉起:
1028
+ * Windows 上没有服务管理器自启动;macOS 上 launchd 被系统置为 on-demand-only(gui 域)时也托管不住。
1029
+ * 此时插件直接把 watcher(`node <relayDir>/dsh-setup.mjs run`)作为脱离进程拉起:
974
1030
  * 这与用户手动跑 `dsh-remote run` 是**同一条代码路径**,watcher 自己盯着 127.0.0.1:3080,
975
1031
  * dsh web 活着就拉起 dsh-bridge.mjs、dsh web 退出就把 bridge 停掉。
976
1032
  * 代价(如实告知用户):不随开机自启;但 dsh web 每次启动后插件都会在自愈轮次里重新拉起它。
977
1033
  */
978
- function startBridgeWindows(relayDir) {
1034
+ function startBridgeDetached(relayDir, fallbackReason = "") {
979
1035
  if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
980
1036
  if (!runtimeReady(relayDir)) {
981
1037
  ensureRuntime(relayDir);
@@ -1015,16 +1071,18 @@ function startBridgeWindows(relayDir) {
1015
1071
  ok: true,
1016
1072
  status: "running",
1017
1073
  pid: child.pid,
1018
- // 如实区分两条路径:装过(或更新过)就有登录任务;没有的话只有"重开 dsh web 自动拉起"这一层保障
1019
- detail: windowsTaskRegistered()
1074
+ // 如实区分两条路径:装过(且平台支持)就有自启动;否则只有"重开 dsh web 自动拉起"这一层保障
1075
+ detail: isWindows() && windowsTaskRegistered()
1020
1076
  ? "已在后台启动 bridge 守护(已注册登录任务,下次登录会自动运行)"
1021
- : "已在后台启动 bridge 守护(未注册登录任务:不随开机自启,重开 dsh web 时插件会自动拉起)",
1077
+ : `已在后台启动 bridge 守护${fallbackReason ? `(${fallbackReason};不随开机自启,重开 dsh web 时插件会自动拉起)` : "(不随开机自启,重开 dsh web 时插件会自动拉起)"}`,
1022
1078
  };
1023
1079
  }
1024
1080
 
1025
- /** Windows:停止 bridge(结束 watcher 与其 bridge 子进程,并清掉 pid 文件)。 */
1026
- function stopBridgeWindows(relayDir) {
1027
- if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
1081
+ /** 停止「脱离进程」方式运行的 bridge(结束 watcher 与其 bridge 子进程,并清掉 pid 文件)。 */
1082
+ function stopBridgeDetached(relayDir, opts = {}) {
1083
+ // 账号切换时用 force:即使处于测试隔离模式也要把进程停掉(否则"换个账号"在测试里走不通),
1084
+ // 但隔离模式下仍只用信号结束**本插件记录的** pid,不碰任何系统服务。
1085
+ if (skipsSystemOps() && !opts.force) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
1028
1086
  const targets = [];
1029
1087
  for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
1030
1088
  const pid = readPidFile(relayDir, name);
@@ -1051,7 +1109,7 @@ function startBridge(relayDir) {
1051
1109
  // 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
1052
1110
  return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
1053
1111
  }
1054
- if (isWindows()) return startBridgeWindows(relayDir);
1112
+ if (isWindows()) return startBridgeDetached(relayDir);
1055
1113
  const plistPath = launchAgentPath();
1056
1114
  if (!plistPath) {
1057
1115
  return {
@@ -1079,21 +1137,32 @@ function startBridge(relayDir) {
1079
1137
  }
1080
1138
  if (!existsSync(plistPath)) writeAutostartFile(relayDir);
1081
1139
  if (!existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 生成失败" };
1082
- const target = launchTarget();
1083
- const boot = (() => {
1140
+ // 与 dsh-setup.mjs 同一条域阶梯:user/<uid> 优先(macOS 26 上唯一支持 RunAtLoad/KeepAlive 的域),
1141
+ // gui/<uid> 兜底(只能靠 kickstart 拉这一次,不保证崩溃自愈)。
1142
+ let lastDetail = "";
1143
+ for (const domain of launchDomains()) {
1144
+ const target = `${domain}/com.dshremote.bridge`;
1145
+ // 清掉**另一个**域的历史注册,避免两处并存导致重复实例(与安装器同构)
1146
+ for (const other of launchDomains()) {
1147
+ if (other !== domain) sh(`launchctl bootout ${other}/com.dshremote.bridge`);
1148
+ }
1084
1149
  sh(`launchctl bootout ${target}`);
1085
- // domain 一律由 launchTarget 推导(不再直接 process.getuid():Windows 上没有该 API)
1086
- const domain = `gui/${currentUid()}`;
1087
- let r = sh(`launchctl bootstrap ${domain} ${shQuote(plistPath)}`);
1088
- if (!r.ok) {
1150
+ let boot = sh(`launchctl bootstrap ${domain} ${shQuote(plistPath)}`);
1151
+ if (!boot.ok) {
1089
1152
  sh(`launchctl unload ${shQuote(plistPath)}`);
1090
- r = sh(`launchctl load -w ${shQuote(plistPath)}`);
1153
+ boot = sh(`launchctl load -w ${shQuote(plistPath)}`);
1091
1154
  }
1092
- return r;
1093
- })();
1094
- if (!boot.ok) return { ok: false, status: "failed", detail: (boot.stderr || boot.stdout).trim() || "launchctl 启动失败" };
1095
- const st = launchdStatus();
1096
- return { ok: st.running, status: st.running ? "running" : "failed", pid: st.pid, detail: st.running ? void 0 : "服务未进入运行态" };
1155
+ if (!boot.ok) { lastDetail = (boot.stderr || boot.stdout).trim() || `launchctl bootstrap ${domain} 失败`; continue; }
1156
+ // bootstrap 只是登记:立刻 kickstart 一次,确保"这一次"一定起来
1157
+ sh(`launchctl kickstart -k ${target}`);
1158
+ const st = launchdStatusIn(target);
1159
+ if (st.running) return { ok: true, status: "running", pid: st.pid, domain };
1160
+ lastDetail = st.state ? `已登记但未运行(state=${st.state}, runs=${st.runs ?? "?"})` : "已登记但查不到状态";
1161
+ }
1162
+ // launchd 都托管不了(macOS 26 的 on-demand-only / 权限异常等)→ 退化为脱离进程,
1163
+ // 与 Windows 走同一条路径:绝不出现"重启失败但用户无感、旧账号继续跑"这种静默失败。
1164
+ const d = startBridgeDetached(relayDir, `launchd 无法托管(${lastDetail || "未知原因"})`);
1165
+ return d.ok ? d : { ok: false, status: "failed", detail: `${lastDetail};脱离进程兜底也失败:${d.detail}` };
1097
1166
  }
1098
1167
 
1099
1168
  /**
@@ -1146,12 +1215,20 @@ function scheduleRuntime(relayDir) {
1146
1215
 
1147
1216
  /** 停止 bridge:macOS/Linux 走 launchctl bootout;Windows 结束 watcher/bridge 进程。 */
1148
1217
  function stopBridge(relayDir) {
1149
- if (isWindows()) return stopBridgeWindows(relayDir);
1150
- const target = launchTarget();
1151
- if (!target) return { ok: false, status: "unsupported", detail: `当前平台(${osPlatform()})没有 launchd 服务可停,请手动结束 \`dsh-remote run\` 进程` };
1152
- const r = sh(`launchctl bootout ${target}`);
1218
+ if (isWindows()) return stopBridgeDetached(relayDir);
1219
+ // 测试隔离:置位时绝不对真实 launchd/systemd 下手(切换账号也会走到这里,
1220
+ // 少了这一条,用例就会去 bootout 开发者本机上真实运行的 bridge —— 实测过的污染风险)。
1221
+ if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
1222
+ const targets = launchTargets();
1223
+ if (!targets.length) return { ok: false, status: "unsupported", detail: `当前平台(${osPlatform()})没有 launchd 服务可停,请手动结束 \`dsh-remote run\` 进程` };
1224
+ // 两个域都要停:作业可能由安装器装在 user/<uid>,而旧版插件只会去 gui/<uid> 找不到(实测事故)
1225
+ let lastErr = "";
1226
+ for (const target of targets) {
1227
+ const r = sh(`launchctl bootout ${target}`);
1228
+ if (!r.ok) lastErr = (r.stderr || r.stdout).trim();
1229
+ }
1153
1230
  const st = launchdStatus();
1154
- return { ok: !st.running, status: st.running ? "failed" : "stopped", pid: null, detail: st.running ? (r.stderr || "停止失败").trim() : void 0 };
1231
+ return { ok: !st.running, status: st.running ? "failed" : "stopped", pid: null, detail: st.running ? (lastErr || "停止失败").trim() : void 0 };
1155
1232
  }
1156
1233
 
1157
1234
  // ---------- 彻底卸载:bridge 自启动 / 残留进程 / 配置目录 ----------
@@ -1459,7 +1536,7 @@ const deadline = Date.now() + 30000;
1459
1536
  while (Date.now() < deadline && alive(PLAN.pid)) await delay(200);
1460
1537
  if (alive(PLAN.pid)) {
1461
1538
  logLine("旧进程 30s 未退出,改用 taskkill /T /F");
1462
- spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true });
1539
+ spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true, timeout: 8000 });
1463
1540
  await delay(500);
1464
1541
  }
1465
1542
 
@@ -2302,7 +2379,7 @@ const PLUGIN_ID = "dsh-remote-web";
2302
2379
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
2303
2380
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
2304
2381
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
2305
- const PLUGIN_VERSION = "0.6.7-beta.2";
2382
+ const PLUGIN_VERSION = "0.6.7-beta.4";
2306
2383
  const UPDATE_LOG = ".dsh-update.log";
2307
2384
  const UPDATE_MARKER = ".dsh-update-running";
2308
2385
 
@@ -2507,17 +2584,249 @@ function tailOf(filePath, lines = 24) {
2507
2584
  } catch { return ""; }
2508
2585
  }
2509
2586
 
2587
+ /**
2588
+ * 判断 patch 文本是否是「合法的顶层 YAML 数组文档」。
2589
+ *
2590
+ * 为什么必须判(2026-09-18 Windows 用户实测的致命事故):dsh 解析 cordis.patch.yml 时
2591
+ * **要求顶层是数组**,而**纯注释文档的 YAML 解析结果是 `null` 而不是空数组**,于是 dsh 直接:
2592
+ * `Error: dsh: overlay …/cordis.patch.yml must be a top-level YAML array of loader patch entries`
2593
+ * 官方空 profile 模板是「注释 + `[]`」——`[]` 这个占位符不能省。
2594
+ * 卸载把插件条目摘掉后若只剩注释,用户就再也起不来 dsh web(面板也进不去,只能靠 CLI 自救)。
2595
+ */
2596
+ /** 顶层(无缩进)非注释行 = YAML 的结构行;缩进行属于上一条目。 */
2597
+ function patchStructuralLines(text) {
2598
+ return String(text ?? "")
2599
+ .split(/\r?\n/)
2600
+ .map((l) => l.replace(/\s+$/, ""))
2601
+ .filter((l) => l.trim() !== "" && !/^\s*#/.test(l) && !/^[ \t]/.test(l));
2602
+ }
2603
+
2604
+ /** 是否是合法的顶层数组文档(`[]` 占位,或若干 `-` 开头的顶层条目)。 */
2605
+ function isPatchArrayDocument(text) {
2606
+ const roots = patchStructuralLines(text);
2607
+ return roots.length > 0 && roots.every((l) => l === "[]" || l.startsWith("-"));
2608
+ }
2609
+
2610
+ /** 只有注释/空行 → YAML 解析成 null(不是空数组),必须补 `[]` 占位,否则 dsh 起不来。 */
2611
+ function needsArrayPlaceholder(text) {
2612
+ return patchStructuralLines(text).length === 0;
2613
+ }
2614
+
2615
+ /** 摘掉本插件的 patch 条目:管理标记块 + 无标记的 `- insert:` 块(当前名与历史名都清)。 */
2616
+ function stripOwnEntriesFromPatch(patch) {
2617
+ // ① 管理标记块(dsh-setup 写入的形式)
2618
+ // 结尾标记后面可能还有残留文字(历史变体),用 [^\n]* 吃掉整行,避免留下半截内容
2619
+ const out = String(patch ?? "").replace(/\n?# >>> dsh-remote-(?:web|ui)[^\n]*\n[\s\S]*?# <<< dsh-remote-(?:web|ui)[^\n]*\n?/g, "\n");
2620
+ // ② 无标记的 insert 块(旧版 / 其它工具写入的形式,历史事故里出现过)。
2621
+ // 只摘标记块是不够的:目录已删而 patch 仍引用 → dsh 插件树加载失败 → 整个 dsh web 起不来。
2622
+ const lines = out.split("\n");
2623
+ const kept = [];
2624
+ let i = 0;
2625
+ while (i < lines.length) {
2626
+ if (/^- insert:\s*$/.test(lines[i])) {
2627
+ const block = [lines[i]];
2628
+ let j = i + 1;
2629
+ while (j < lines.length && (lines[j].startsWith(" ") || lines[j].startsWith("\t"))) { block.push(lines[j]); j += 1; }
2630
+ if (PLUGIN_ALL_IDS.some((id) => new RegExp(`\\b${id}\\b`).test(block.join("\n")))) {
2631
+ while (kept.length && /^\s*#/.test(kept[kept.length - 1])) kept.pop(); // 连带条目上方的说明注释
2632
+ i = j;
2633
+ continue;
2634
+ }
2635
+ kept.push(...block);
2636
+ i = j;
2637
+ continue;
2638
+ }
2639
+ kept.push(lines[i]);
2640
+ i += 1;
2641
+ }
2642
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
2643
+ }
2644
+
2645
+ /**
2646
+ * 原子写回 patch,并保证结果仍是合法顶层数组:条目被删光时补回 `[]` 占位。
2647
+ * ⚠️ 只在「一个结构行都不剩」时补 —— 不能因为"看起来不像数组"就乱补,
2648
+ * 那会把本来好的文件写坏(例如把 `[]` 追加到已有 insert 块后面 → YAML 直接报错)。
2649
+ */
2650
+ function writePatchDocument(patchFile, text) {
2651
+ const body = String(text ?? "").replace(/\s+$/, "") + "\n";
2652
+ const fixed = needsArrayPlaceholder(body) ? `${body}[]\n` : body;
2653
+ const tmp = `${patchFile}.dsh-remote.tmp`;
2654
+ writeFileSync(tmp, fixed);
2655
+ renameSync(tmp, patchFile);
2656
+ return fixed;
2657
+ }
2658
+
2659
+ /**
2660
+ * 「运行时清理助手」源码(卸载用;由插件生成、detached 执行)。
2661
+ *
2662
+ * 为什么必须是独立进程(2026-09-18 用户实测的僵死事故):
2663
+ * uninstallRuntime() 里有 spawnSync("schtasks"/"powershell"/"taskkill"),同步子进程调用会把
2664
+ * **dsh web 的事件循环整个钉死** —— 不只是这一个请求不返回,手机端隧道、其它页面、所有 API
2665
+ * 一起停摆,表现为「端口在听、连接建立、但永不响应」。而 spawnSync 的 timeout 在 Windows 上
2666
+ * **并不可靠**(实测被策略拦截的进程 18s+ 仍未返回),所以不能把 timeout 当设计依据:
2667
+ * 真正的解法是不要把同步子进程调用留在宿主进程里。
2668
+ *
2669
+ * 助手做的是「宁可少做也不卡住」的有界清理:杀残留进程 → 删自启动 → 清配置目录,每步独立 try/catch。
2670
+ */
2671
+ function buildRuntimeCleanupHelper(relayDir, protectedPath) {
2672
+ const plan = JSON.stringify({
2673
+ relayDir,
2674
+ protectedPath: protectedPath || "",
2675
+ platform: osPlatform(),
2676
+ taskName: WIN_TASK_NAME,
2677
+ pidFiles: [WATCHER_PID_FILE, BRIDGE_PID_FILE],
2678
+ plistPath: join(homedir(), "Library", "LaunchAgents", "com.dshremote.bridge.plist"),
2679
+ unitPath: join(homedir(), ".config", "systemd", "user", "dsh-bridge.service"),
2680
+ log: join(tmpdir(), "dsh-remote-uninstall.log"),
2681
+ });
2682
+ return `// dsh-remote 自动生成:彻底卸载的「运行时清理」部分(独立进程执行,避免阻塞 dsh web)
2683
+ import { spawnSync } from "node:child_process";
2684
+ import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
2685
+ import { homedir } from "node:os";
2686
+ import { dirname, join, sep } from "node:path";
2687
+
2688
+ const PLAN = ${plan};
2689
+ // 与主进程一致的测试隔离开关:置位时**绝不做任何真实系统操作**(不碰 launchctl/systemctl/schtasks/taskkill),
2690
+ // 但仍清空配置目录 —— 否则用例会去动开发者本机真实的自启动服务(历史事故,见 setup-output 用例的护栏)。
2691
+ const SKIP_SYSTEM_OPS = process.env.DSH_RELAY_SKIP_SERVICE === "1";
2692
+ function log(m) { try { appendFileSync(PLAN.log, "\\n[" + new Date().toISOString() + "] " + m + "\\n"); } catch (e) { /* 日志失败不阻断清理 */ } }
2693
+ function run(cmd, args, timeout) {
2694
+ try { const r = spawnSync(cmd, args, { windowsHide: true, encoding: "utf8", timeout }); return r.status === 0; }
2695
+ catch (e) { log("命令失败 " + cmd + ": " + e.message); return false; }
2696
+ }
2697
+ function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
2698
+ function readPid(name) {
2699
+ try { const n = Number(String(readFileSync(join(PLAN.relayDir, name), "utf8")).trim()); return Number.isInteger(n) && n > 0 ? n : null; }
2700
+ catch (e) { return null; }
2701
+ }
2702
+ log("运行时清理开始(platform=" + PLAN.platform + ",relayDir=" + PLAN.relayDir + ")");
2703
+ if (SKIP_SYSTEM_OPS) log("测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过一切真实系统操作");
2704
+
2705
+ // ① 结束 watcher / bridge 残留进程
2706
+ for (const name of (SKIP_SYSTEM_OPS ? [] : PLAN.pidFiles)) {
2707
+ const pid = readPid(name);
2708
+ if (!pid || pid === process.pid) continue;
2709
+ if (!alive(pid)) { log(name + " 里的 pid " + pid + " 已不在"); continue; }
2710
+ if (PLAN.platform === "win32") run("taskkill", ["/PID", String(pid), "/T", "/F"], 8000);
2711
+ else { try { process.kill(pid, "SIGTERM"); } catch (e) { /* 已退出 */ } }
2712
+ log("已结束 " + name + " 的进程 pid=" + pid);
2713
+ }
2714
+
2715
+ // ② 移除自启动(按平台)
2716
+ if (SKIP_SYSTEM_OPS) {
2717
+ log("跳过自启动清理(测试隔离)");
2718
+ } else if (PLAN.platform === "win32") {
2719
+ run("schtasks", ["/Delete", "/TN", PLAN.taskName, "/F"], 15000);
2720
+ log("已请求删除登录任务 " + PLAN.taskName);
2721
+ } else if (PLAN.platform === "darwin") {
2722
+ if (existsSync(PLAN.plistPath)) {
2723
+ run("launchctl", ["bootout", "gui/" + (typeof process.getuid === "function" ? process.getuid() : 0) + "/com.dshremote.bridge"], 8000);
2724
+ try { rmSync(PLAN.plistPath, { force: true }); } catch (e) { /* 非关键 */ }
2725
+ log("已移除自启动 plist");
2726
+ }
2727
+ } else if (PLAN.platform === "linux") {
2728
+ run("systemctl", ["--user", "stop", "dsh-bridge"], 8000);
2729
+ run("systemctl", ["--user", "disable", "dsh-bridge"], 8000);
2730
+ try { if (existsSync(PLAN.unitPath)) rmSync(PLAN.unitPath, { force: true }); } catch (e) { /* 非关键 */ }
2731
+ log("已移除 systemd 用户服务");
2732
+ }
2733
+
2734
+ // ③ 清空配置目录(账号/密钥/会话 cookie/固化运行时)——安全护栏与主进程一致
2735
+ try {
2736
+ const isRoot = dirname(PLAN.relayDir) === PLAN.relayDir;
2737
+ const isHome = PLAN.relayDir === homedir();
2738
+ const hitsProfile = Boolean(PLAN.protectedPath) && (
2739
+ PLAN.relayDir === PLAN.protectedPath
2740
+ || PLAN.relayDir.startsWith(PLAN.protectedPath + sep)
2741
+ || PLAN.protectedPath.startsWith(PLAN.relayDir + sep));
2742
+ if (PLAN.relayDir && !isRoot && !isHome && !hitsProfile && existsSync(PLAN.relayDir)) {
2743
+ rmSync(PLAN.relayDir, { recursive: true, force: true });
2744
+ log("已清空配置目录");
2745
+ } else {
2746
+ log("跳过配置目录清理(护栏命中或目录不存在)");
2747
+ }
2748
+ } catch (e) { log("配置目录清理失败: " + e.message); }
2749
+ log("运行时清理结束");
2750
+ `;
2751
+ }
2752
+
2753
+ /**
2754
+ * 把「运行时清理」交给一个 detached 子进程(不阻塞宿主事件循环)。
2755
+ * @returns {{ok:boolean, pid:number|null, log:string, detail:string}}
2756
+ */
2757
+ function spawnRuntimeCleanupDetached(relayDir, protectedPath) {
2758
+ const logPath = join(tmpdir(), "dsh-remote-uninstall.log");
2759
+ try {
2760
+ // 助手放**系统临时目录**而不是 relayDir:它自己就要删 relayDir,
2761
+ // 把正在运行的脚本放在待删目录里既别扭又可能在 Windows 上因文件占用而失败。
2762
+ const helperPath = join(tmpdir(), `dsh-remote-uninstall-${process.pid}-${Date.now()}.mjs`);
2763
+ writeFileSync(helperPath, buildRuntimeCleanupHelper(relayDir, protectedPath));
2764
+ const { child, error } = safeSpawn(process.execPath, [helperPath], {
2765
+ detached: true, cwd: homedir(), stdio: "ignore", windowsHide: true,
2766
+ onError: (e) => console.warn(`[dsh-remote-web] 运行时清理子进程启动失败: ${e.message}`),
2767
+ });
2768
+ if (error || !child) {
2769
+ return { ok: false, pid: null, log: logPath, detail: (error || new Error("spawn 未返回子进程")).message };
2770
+ }
2771
+ child.unref();
2772
+ return { ok: true, pid: child.pid, log: logPath, detail: "已在后台清理" };
2773
+ } catch (e) {
2774
+ return { ok: false, pid: null, log: logPath, detail: e.message };
2775
+ }
2776
+ }
2777
+
2778
+ /** 账号指纹:账号 / 模式 / 设备身份任一变化都意味着"必须用新凭据重新登记"。 */
2779
+ function accountFingerprintOf(cfg) {
2780
+ const c = cfg || {};
2781
+ return [c.local_key ? "local" : "saas", c.phone || c.email || "", String(c.device_id || "")].join("|");
2782
+ }
2783
+
2784
+ /**
2785
+ * 账号(或模式/设备身份)变化后,**必须让 bridge 用新凭据重新登记**。
2786
+ *
2787
+ * 事故(2026-09-19 实测):从 A 账号切到 B 账号后,配置已经改了、device_id 也清了,
2788
+ * 但**正在运行的 bridge 是启动时把账号/密码固化在环境变量里的**,它不会自己换账号;
2789
+ * 而那次"重启 bridge"又因 launchd 域名不一致(插件只认 gui/<uid>,安装器装在 user/<uid>)静默失败
2790
+ * → 设备继续留在 A 账号,B 账号的设备列表里永远看不到这台机器。
2791
+ *
2792
+ * 这里做三件事:① 停掉旧 bridge(含两个 launchd 域与脱离进程两种形态);
2793
+ * ② 删掉旧账号留下的状态文件(否则面板据 phase=online + 旧 device_id 谎报"已连接",
2794
+ * 自愈也就永远不会去纠正);③ 交给调用方随后重新启动。
2795
+ */
2796
+ function resetBridgeForAccountChange(relayDir, why) {
2797
+ const stopped = stopBridge(relayDir);
2798
+ // 脱离进程形态(launchd 托管不住时的兜底)也要收干净,否则它会用旧凭据继续跑
2799
+ try { stopBridgeDetached(relayDir, { force: true }); } catch { /* 非关键 */ }
2800
+ try {
2801
+ if (bridgeStateFile(relayDir)) {
2802
+ rmSync(join(relayDir, BRIDGE_STATE_FILE), { force: true });
2803
+ appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-web] ${why}:已清除过期的 bridge 状态记录(旧账号的注册证据作废)`);
2804
+ }
2805
+ } catch { /* 非关键 */ }
2806
+ return stopped;
2807
+ }
2808
+
2809
+ /**
2810
+ * 运行中的 bridge 是否属于「当前账号」。
2811
+ * 判据:bridge 状态文件记录的 device_id 必须与配置里的一致;配置里没有 device_id
2812
+ * (= 刚切过账号、旧身份已作废)而状态里还有 → 说明跑的是**上一个账号**的 bridge。
2813
+ * @returns {boolean} true=属于当前账号;false=过期(必须重启);未记录 device_id 时按旧行为返回 true
2814
+ */
2815
+ function bridgeMatchesCurrentAccount(relayDir, cfg) {
2816
+ const state = bridgeStateFile(relayDir);
2817
+ if (!state || !state.device_id) return true; // 无记录可判:不改变既有行为
2818
+ const want = String((cfg || loadConfig(relayDir)).device_id || "");
2819
+ if (!want) return false; // 配置里身份已清空 = 旧 bridge 的注册已作废
2820
+ return want === String(state.device_id);
2821
+ }
2822
+
2510
2823
  /** 彻底卸载第 2 步 —— profile 插件清理(兼容市场“拒绝改写用户补丁”):移除 include、依赖、bundle、本地目录与链接。 */
2511
2824
  function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
2512
2825
  const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
2513
2826
  try {
2514
2827
  const patch = readFileSync(patchFile, "utf8");
2515
- // 兼容当前与历史(dsh-remote-ui)两种管理标记
2516
- const cleaned = patch
2517
- .replace(/\n?# >>> dsh-remote-(?:web|ui) .*?# <<< dsh-remote-(?:web|ui)\s*/s, "\n")
2518
- .replace(/\n{3,}/g, "\n\n")
2519
- .trimEnd() + "\n";
2520
- if (cleaned !== patch) { writeFileSync(patchFile, cleaned); out.removedPatch = true; }
2828
+ const cleaned = stripOwnEntriesFromPatch(patch); // 兼容当前与历史(dsh-remote-ui)两种名
2829
+ if (cleaned !== patch) { writePatchDocument(patchFile, cleaned); out.removedPatch = true; }
2521
2830
  } catch { /* 无 patch 忽略 */ }
2522
2831
  try {
2523
2832
  const pkg = JSON.parse(readFileSync(pkgFile, "utf8"));
@@ -2745,9 +3054,13 @@ async function composeConnect(relayDir, opts = {}) {
2745
3054
  const deviceId = String(cfg.device_id || "");
2746
3055
 
2747
3056
  // ── 中继注册证据(只有 bridge 进程在跑时才算「现在能用」) ──
3057
+ // ⚠️ 先判「这个 bridge 是不是当前账号的」:账号切换后进程不会自己换凭据,
3058
+ // 旧账号留下的状态文件(phase=online + 旧 device_id)会让面板谎报「已连接」,
3059
+ // 自愈也就永远不会去重启它(2026-09-19 实测事故)。
3060
+ const accountCurrent = bridgeMatchesCurrentAccount(relayDir, cfg);
2748
3061
  let registered = false;
2749
3062
  let registerSource = "";
2750
- if (runtime && bridgeRunning) {
3063
+ if (runtime && bridgeRunning && accountCurrent) {
2751
3064
  if (state && state.phase === "online" && Number(state.tunnel_registered_at) > 0) {
2752
3065
  registered = true; registerSource = "state";
2753
3066
  } else if (log.tunnelRegistered) {
@@ -2816,6 +3129,10 @@ async function composeConnect(relayDir, opts = {}) {
2816
3129
  detail = bindError.code === "device_limit_exceeded"
2817
3130
  ? "已达本套餐设备数上限:同机重装会自动顶替旧设备;仍失败请到手机端「设备管理」解绑旧设备后点「重试」。"
2818
3131
  : "请确认网络与账号状态后点「重试」;仍不成功可点「复制诊断信息」发给客服。";
3132
+ } else if (!accountCurrent) {
3133
+ // 进程在跑,但它是**上一个账号**的 bridge:必须重启才能用新账号登记
3134
+ phase = "starting";
3135
+ detail = "检测到后台 bridge 仍是上一个账号的身份(账号切换后需要重启它才能用新账号重新登记),正在自动重启…";
2819
3136
  } else {
2820
3137
  phase = "connecting";
2821
3138
  detail = "bridge 进程已在运行,正在等待设备注册到中继…通常几秒内完成。";
@@ -2836,6 +3153,7 @@ async function composeConnect(relayDir, opts = {}) {
2836
3153
  installing: inst.installing,
2837
3154
  installStale: inst.stale,
2838
3155
  bridgeRunning,
3156
+ accountCurrent, // 运行中的 bridge 是否属于当前账号(false = 上一个账号的进程还在跑)
2839
3157
  bridgePids,
2840
3158
  launchdState: launchd.state || "",
2841
3159
  launchdCrashLoop: Boolean(launchd.crashing),
@@ -2863,6 +3181,7 @@ function buildConnectDiagnostics(relayDir, conn, log) {
2863
3181
  `运行环境: ${conn.runtimeReady ? "已就绪" : "缺失"}${conn.installing ? "(后台安装中)" : ""}${conn.installStale ? "(安装标记已超时)" : ""}`,
2864
3182
  `bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
2865
3183
  `中继注册: ${conn.registered ? "已注册(" + conn.registerSource + ")" : "未注册"}`,
3184
+ `bridge 账号: ${conn.accountCurrent === false ? "⚠️ 仍是上一个账号的身份(需重启 bridge 重新登记)" : "当前账号"}`,
2866
3185
  `最近错误: ${conn.error ? conn.error.code + ": " + conn.error.message : (log && log.lastError ? log.lastError : "无")}`,
2867
3186
  `自动重试: 已尝试 ${conn.attempts} 次${conn.nextRetryInMs ? `,约 ${Math.ceil(conn.nextRetryInMs / 1000)} 秒后重试` : ""}`,
2868
3187
  `bridge 日志: ${conn.logPath}`,
@@ -2897,6 +3216,17 @@ function ensureConnection(relayDir, opts = {}) {
2897
3216
  const launchd = launchdStatus();
2898
3217
  const manual = manualStatus(relayDir);
2899
3218
  if (launchd.running || (manual.allBridge || []).length || (manual.bridge || []).length) {
3219
+ // 进程在跑 ≠ 跑对了账号:切换账号后旧 bridge 会一直用旧凭据,
3220
+ // 只看"有没有进程"会让自愈永久停摆(实测事故)。这里多判一次账号归属。
3221
+ if (!bridgeMatchesCurrentAccount(relayDir, cfg)) {
3222
+ book.lastAttemptAt = now;
3223
+ book.attempts += 1;
3224
+ resetBridgeForAccountChange(relayDir, "bridge 身份与当前账号不符");
3225
+ const r = startBridge(relayDir);
3226
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
3227
+ `[dsh-remote-web] 检测到旧账号的 bridge 仍在运行,已重启以用当前账号重新登记(${r.status || "?"})`);
3228
+ return { action: "restart", result: r };
3229
+ }
2900
3230
  book.attempts = 0; // 进程已在跑:退避计数归零,后续只等注册
2901
3231
  book.lastAttemptAt = now;
2902
3232
  return { action: "none" };
@@ -3571,30 +3901,47 @@ function registerRoutes(ctx, relayDir) {
3571
3901
  method: "POST",
3572
3902
  path: "/dsh-remote/self/uninstall",
3573
3903
  handler: async (_req, res) => {
3574
- // 彻底卸载 = ① 运行时/bridge 清理(停自启动 → 杀残留 → 清空配置目录,顺序防 KeepAlive 复活)
3575
- // + ② 插件 profile 清理(include 块/依赖/bundle/本地目录与链接,解锁市场卸载)
3576
- const rt = uninstallRuntime(relayDir, profileDir);
3577
- const prof = uninstallSelf(relayDir, profileDir, join(profileDir, "cordis.patch.yml"), join(profileDir, "package.json"));
3578
- // 卸载后本进程内(直到重启)自愈/代持调度一律停摆,不再重建配置目录或拉起 bridge
3904
+ // 彻底卸载 = ① 插件 profile 清理(快、纯 fs、可预期)② 运行时/bridge 清理(慢、要调外部命令)
3905
+ //
3906
+ // ⚠️ 顺序是刻意的,别改回去(2026-09-18 用户实测的僵死事故):
3907
+ // 旧实现把**慢的** uninstallRuntime 排在最前、uninstallSelf 排在其后,于是
3908
+ // uninstallRuntime 里任何一次 spawnSync 卡住(Windows 沙箱拦 schtasks,而 spawnSync 的
3909
+ // timeout 在 Windows 上并不可靠,实测 18s+ 未返回),uninstallSelf 就**永远不执行**
3910
+ // → 用户看到「点了卸载、插件纹丝不动」,而且整个 dsh web 僵死(端口在听、无响应)。
3911
+ // 现在:先做真正"卸载掉插件"的部分 → 立刻关掉自愈 → 立刻回响应 → 慢步骤交独立子进程。
3912
+ let prof = { removedPatch: false, removedDep: false, removedBundle: false, removedDir: false };
3913
+ try {
3914
+ prof = uninstallSelf(relayDir, profileDir, join(profileDir, "cordis.patch.yml"), join(profileDir, "package.json"));
3915
+ } catch (e) {
3916
+ console.warn(`[dsh-remote-web] 卸载插件引用失败: ${e.message}`);
3917
+ }
3918
+ // 必须在慢步骤之前置位:否则慢步骤期间自愈有机会把 bridge / 配置目录再拉回来
3579
3919
  markUninstalled(relayDir);
3920
+ // 运行时清理交给独立进程:宿主事件循环绝不做同步子进程调用
3921
+ const cleanup = spawnRuntimeCleanupDetached(relayDir, profileDir);
3922
+ const cleanupMode = cleanup.ok ? "deferred" : "in-process";
3580
3923
  const bits = [];
3581
3924
  if (prof.removedPatch || prof.removedDep || prof.removedBundle || prof.removedDir) bits.push("插件引用与本地文件已移除");
3582
- if (rt.stoppedService) bits.push("bridge 自启动服务已停止");
3583
- if (rt.removedPlist) bits.push("自启动项已删除");
3584
- if (rt.killedPids.length) bits.push(`已结束 ${rt.killedPids.length} 个残留进程`);
3585
- if (rt.removedDir) bits.push("配置目录已清空(账号/密钥/固化运行时等)");
3925
+ bits.push(cleanup.ok
3926
+ ? "bridge 自启动项与本地配置目录(账号/密钥/固化运行时等)正在后台清理"
3927
+ : `bridge 自启动项与配置目录的后台清理未能启动(${cleanup.detail}),已改在本进程内清理`);
3586
3928
  bits.push("请重启 dsh web 后完全卸载生效(本插件与「远程访问」面板将消失);如需再次使用,在插件市场重新安装即可。");
3587
3929
  sendJson(res, 200, {
3588
3930
  ok: true,
3589
3931
  ...prof, // removedPatch / removedDep / removedBundle / removedDir(profile 插件目录)
3590
- servicePlatform: rt.servicePlatform,
3591
- stoppedService: rt.stoppedService,
3592
- removedPlist: rt.removedPlist,
3593
- killedPids: rt.killedPids,
3594
- relayDirRemoved: rt.removedDir, // 配置目录 relayDir 已整目录清空
3932
+ runtimeCleanup: cleanupMode,
3933
+ cleanupLog: cleanup.log,
3595
3934
  relayDir,
3596
3935
  detail: bits.join(";"),
3597
3936
  });
3937
+ // 兜底:助手起不来(relayDir 不可写 / node 起不来)时才在进程内清理。
3938
+ // **必须放在响应之后**:这条路径会阻塞事件循环,绝不能让用户先等它。
3939
+ if (!cleanup.ok) {
3940
+ setTimeout(() => {
3941
+ try { uninstallRuntime(relayDir, profileDir); }
3942
+ catch { /* 非致命:残留可由用户手动清理 */ }
3943
+ }, 50).unref?.();
3944
+ }
3598
3945
  },
3599
3946
  },
3600
3947
  {
@@ -3768,8 +4115,11 @@ function registerRoutes(ctx, relayDir) {
3768
4115
  if (secret) cfg.bridge_secret = secret;
3769
4116
  }
3770
4117
  }
4118
+ // 账号/模式变化 → 先停掉旧 bridge 并作废它的注册证据,否则新账号永远等不到这台设备
4119
+ const accountFpBefore = accountFingerprintOf(loadConfig(relayDir));
3771
4120
  saveConfig(relayDir, cfg);
3772
4121
  invalidateRelayToken(relayDir); // 账号可能变了:丢弃旧 token 缓存,立即用新账号认证
4122
+ if (accountFingerprintOf(cfg) !== accountFpBefore) resetBridgeForAccountChange(relayDir, "账号/模式已变更");
3773
4123
  const bridgeRestart = startBridge(relayDir);
3774
4124
  // 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」口径);失败静默。
3775
4125
  maybeReportInstall(relayDir, { force: true });
@@ -3780,11 +4130,17 @@ function registerRoutes(ctx, relayDir) {
3780
4130
  method: "POST",
3781
4131
  path: "/dsh-remote/logout",
3782
4132
  handler: async (_req, res) => {
3783
- // 退出登录:清除本机保存的账号(邮箱/密码),bridge 下次重启将不再自动登录
4133
+ // 退出登录:清除本机保存的账号(邮箱/密码),并**立刻停掉 bridge**。
4134
+ // 为什么必须停:bridge 的凭据是启动时固化的,不停就会继续用旧账号的隧道对外服务 ——
4135
+ // 用户以为"已退出登录",实际上手机端仍能访问这台电脑(安全语义不成立)。
3784
4136
  const cfg = loadConfig(relayDir);
3785
4137
  delete cfg.phone;
3786
4138
  delete cfg.password;
4139
+ delete cfg.device_id; // 设备身份一并作废:下次登录会生成新身份并重新登记
4140
+ delete cfg.device_private_key;
4141
+ delete cfg.device_public_key;
3787
4142
  saveConfig(relayDir, cfg);
4143
+ resetBridgeForAccountChange(relayDir, "退出登录");
3788
4144
  invalidateRelayToken(relayDir); // 退出登录:token 缓存立即失效
3789
4145
  sendJson(res, 200, { ok: true, ...(await composeStatus(relayDir)) });
3790
4146
  },