@mrrisega/dsh-remote 0.6.7-beta.3 → 0.6.7

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.
@@ -546,11 +546,28 @@ function launchAgentPath() {
546
546
  * `process.getuid()`,在 Windows 上直接 TypeError(这正是面板两个读状态接口 500 的根因)。
547
547
  * 现在:非 macOS 或取不到 uid 一律返回 null,调用方必须先判空。
548
548
  */
549
- function launchTarget() {
550
- 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 [];
551
562
  const uid = currentUid();
552
- if (uid === null) return null;
553
- 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>)
554
571
  }
555
572
 
556
573
  /**
@@ -567,12 +584,21 @@ function launchTarget() {
567
584
  * 现在:print 的 state 为准 + 附带 runs/lastExitCode/crashing 供面板与自愈解释原因;
568
585
  * 仅在 print 本身不可用时才回退 list,且回退路径要求 pid 真实存活。
569
586
  */
587
+ const NO_LAUNCHD = { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
588
+
570
589
  function launchdStatus() {
571
590
  // 非 macOS 没有 launchctl(Windows 上连 process.getuid 都没有):直接返回「无此服务」,
572
591
  // 不再去 shell 里调一个不存在的命令——这既避免 500,也避免每次轮询白起一个 cmd.exe。
573
- if (!isDarwin()) return { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
574
- const target = launchTarget();
575
- 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) {
576
602
  const pr = sh(`launchctl print ${target}`);
577
603
  if (pr.ok) {
578
604
  const stateMatch = pr.stdout.match(/state\s*=\s*([^\n]+)/);
@@ -961,7 +987,7 @@ function reapBrokenAutostart(relayDir) {
961
987
  if (!plistEntryMissing()) return false;
962
988
  const st = launchdStatus();
963
989
  if (!st.running && !st.crashing && !st.state) return false; // 作业未被 launchd 加载、无可摘的东西
964
- sh(`launchctl bootout ${launchTarget()}`);
990
+ for (const target of launchTargets()) sh(`launchctl bootout ${target}`);
965
991
  try { rmSync(launchAgentPath(), { force: true }); } catch { /* 非关键 */ }
966
992
  appendLogLine(relayDir, AUTO_INSTALL_LOG,
967
993
  `[dsh-remote-web] 已摘除失效自启动:plist 入口 ${join(relayDir, "dsh-setup.mjs")} 不存在`
@@ -997,16 +1023,15 @@ function killPid(pid, signal = "SIGTERM") {
997
1023
  }
998
1024
 
999
1025
  /**
1000
- * Windows:启动 bridge(0.6.7 新增)。
1026
+ * 「脱离进程」方式启动 bridge(Windows 必用;macOS/Linux 在 launchd/systemd 托管不了时兜底)。
1001
1027
  *
1002
- * Windows 上没有 launchd/systemd,dsh-setup.mjs 也不为 win32 生成自启动
1003
- * (它明写「平台 win32 不支持自启动」)。但 bridge 本身在 Windows 上能跑——
1004
- * 所以插件直接把 watcher(`node <relayDir>/dsh-setup.mjs run`)作为脱离进程拉起:
1028
+ * Windows 上没有服务管理器自启动;macOS 上 launchd 被系统置为 on-demand-only(gui 域)时也托管不住。
1029
+ * 此时插件直接把 watcher(`node <relayDir>/dsh-setup.mjs run`)作为脱离进程拉起:
1005
1030
  * 这与用户手动跑 `dsh-remote run` 是**同一条代码路径**,watcher 自己盯着 127.0.0.1:3080,
1006
1031
  * dsh web 活着就拉起 dsh-bridge.mjs、dsh web 退出就把 bridge 停掉。
1007
1032
  * 代价(如实告知用户):不随开机自启;但 dsh web 每次启动后插件都会在自愈轮次里重新拉起它。
1008
1033
  */
1009
- function startBridgeWindows(relayDir) {
1034
+ function startBridgeDetached(relayDir, fallbackReason = "") {
1010
1035
  if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
1011
1036
  if (!runtimeReady(relayDir)) {
1012
1037
  ensureRuntime(relayDir);
@@ -1046,16 +1071,18 @@ function startBridgeWindows(relayDir) {
1046
1071
  ok: true,
1047
1072
  status: "running",
1048
1073
  pid: child.pid,
1049
- // 如实区分两条路径:装过(或更新过)就有登录任务;没有的话只有"重开 dsh web 自动拉起"这一层保障
1050
- detail: windowsTaskRegistered()
1074
+ // 如实区分两条路径:装过(且平台支持)就有自启动;否则只有"重开 dsh web 自动拉起"这一层保障
1075
+ detail: isWindows() && windowsTaskRegistered()
1051
1076
  ? "已在后台启动 bridge 守护(已注册登录任务,下次登录会自动运行)"
1052
- : "已在后台启动 bridge 守护(未注册登录任务:不随开机自启,重开 dsh web 时插件会自动拉起)",
1077
+ : `已在后台启动 bridge 守护${fallbackReason ? `(${fallbackReason};不随开机自启,重开 dsh web 时插件会自动拉起)` : "(不随开机自启,重开 dsh web 时插件会自动拉起)"}`,
1053
1078
  };
1054
1079
  }
1055
1080
 
1056
- /** Windows:停止 bridge(结束 watcher 与其 bridge 子进程,并清掉 pid 文件)。 */
1057
- function stopBridgeWindows(relayDir) {
1058
- 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):跳过真实服务操作" };
1059
1086
  const targets = [];
1060
1087
  for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
1061
1088
  const pid = readPidFile(relayDir, name);
@@ -1082,7 +1109,7 @@ function startBridge(relayDir) {
1082
1109
  // 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
1083
1110
  return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
1084
1111
  }
1085
- if (isWindows()) return startBridgeWindows(relayDir);
1112
+ if (isWindows()) return startBridgeDetached(relayDir);
1086
1113
  const plistPath = launchAgentPath();
1087
1114
  if (!plistPath) {
1088
1115
  return {
@@ -1110,21 +1137,32 @@ function startBridge(relayDir) {
1110
1137
  }
1111
1138
  if (!existsSync(plistPath)) writeAutostartFile(relayDir);
1112
1139
  if (!existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 生成失败" };
1113
- const target = launchTarget();
1114
- 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
+ }
1115
1149
  sh(`launchctl bootout ${target}`);
1116
- // domain 一律由 launchTarget 推导(不再直接 process.getuid():Windows 上没有该 API)
1117
- const domain = `gui/${currentUid()}`;
1118
- let r = sh(`launchctl bootstrap ${domain} ${shQuote(plistPath)}`);
1119
- if (!r.ok) {
1150
+ let boot = sh(`launchctl bootstrap ${domain} ${shQuote(plistPath)}`);
1151
+ if (!boot.ok) {
1120
1152
  sh(`launchctl unload ${shQuote(plistPath)}`);
1121
- r = sh(`launchctl load -w ${shQuote(plistPath)}`);
1153
+ boot = sh(`launchctl load -w ${shQuote(plistPath)}`);
1122
1154
  }
1123
- return r;
1124
- })();
1125
- if (!boot.ok) return { ok: false, status: "failed", detail: (boot.stderr || boot.stdout).trim() || "launchctl 启动失败" };
1126
- const st = launchdStatus();
1127
- 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}` };
1128
1166
  }
1129
1167
 
1130
1168
  /**
@@ -1177,12 +1215,20 @@ function scheduleRuntime(relayDir) {
1177
1215
 
1178
1216
  /** 停止 bridge:macOS/Linux 走 launchctl bootout;Windows 结束 watcher/bridge 进程。 */
1179
1217
  function stopBridge(relayDir) {
1180
- if (isWindows()) return stopBridgeWindows(relayDir);
1181
- const target = launchTarget();
1182
- if (!target) return { ok: false, status: "unsupported", detail: `当前平台(${osPlatform()})没有 launchd 服务可停,请手动结束 \`dsh-remote run\` 进程` };
1183
- 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
+ }
1184
1230
  const st = launchdStatus();
1185
- 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 };
1186
1232
  }
1187
1233
 
1188
1234
  // ---------- 彻底卸载:bridge 自启动 / 残留进程 / 配置目录 ----------
@@ -2333,7 +2379,7 @@ const PLUGIN_ID = "dsh-remote-web";
2333
2379
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
2334
2380
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
2335
2381
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
2336
- const PLUGIN_VERSION = "0.6.7-beta.3";
2382
+ const PLUGIN_VERSION = "0.6.7";
2337
2383
  const UPDATE_LOG = ".dsh-update.log";
2338
2384
  const UPDATE_MARKER = ".dsh-update-running";
2339
2385
 
@@ -2729,6 +2775,51 @@ function spawnRuntimeCleanupDetached(relayDir, protectedPath) {
2729
2775
  }
2730
2776
  }
2731
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
+
2732
2823
  /** 彻底卸载第 2 步 —— profile 插件清理(兼容市场“拒绝改写用户补丁”):移除 include、依赖、bundle、本地目录与链接。 */
2733
2824
  function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
2734
2825
  const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
@@ -2963,9 +3054,13 @@ async function composeConnect(relayDir, opts = {}) {
2963
3054
  const deviceId = String(cfg.device_id || "");
2964
3055
 
2965
3056
  // ── 中继注册证据(只有 bridge 进程在跑时才算「现在能用」) ──
3057
+ // ⚠️ 先判「这个 bridge 是不是当前账号的」:账号切换后进程不会自己换凭据,
3058
+ // 旧账号留下的状态文件(phase=online + 旧 device_id)会让面板谎报「已连接」,
3059
+ // 自愈也就永远不会去重启它(2026-09-19 实测事故)。
3060
+ const accountCurrent = bridgeMatchesCurrentAccount(relayDir, cfg);
2966
3061
  let registered = false;
2967
3062
  let registerSource = "";
2968
- if (runtime && bridgeRunning) {
3063
+ if (runtime && bridgeRunning && accountCurrent) {
2969
3064
  if (state && state.phase === "online" && Number(state.tunnel_registered_at) > 0) {
2970
3065
  registered = true; registerSource = "state";
2971
3066
  } else if (log.tunnelRegistered) {
@@ -3034,6 +3129,10 @@ async function composeConnect(relayDir, opts = {}) {
3034
3129
  detail = bindError.code === "device_limit_exceeded"
3035
3130
  ? "已达本套餐设备数上限:同机重装会自动顶替旧设备;仍失败请到手机端「设备管理」解绑旧设备后点「重试」。"
3036
3131
  : "请确认网络与账号状态后点「重试」;仍不成功可点「复制诊断信息」发给客服。";
3132
+ } else if (!accountCurrent) {
3133
+ // 进程在跑,但它是**上一个账号**的 bridge:必须重启才能用新账号登记
3134
+ phase = "starting";
3135
+ detail = "检测到后台 bridge 仍是上一个账号的身份(账号切换后需要重启它才能用新账号重新登记),正在自动重启…";
3037
3136
  } else {
3038
3137
  phase = "connecting";
3039
3138
  detail = "bridge 进程已在运行,正在等待设备注册到中继…通常几秒内完成。";
@@ -3054,6 +3153,7 @@ async function composeConnect(relayDir, opts = {}) {
3054
3153
  installing: inst.installing,
3055
3154
  installStale: inst.stale,
3056
3155
  bridgeRunning,
3156
+ accountCurrent, // 运行中的 bridge 是否属于当前账号(false = 上一个账号的进程还在跑)
3057
3157
  bridgePids,
3058
3158
  launchdState: launchd.state || "",
3059
3159
  launchdCrashLoop: Boolean(launchd.crashing),
@@ -3081,6 +3181,7 @@ function buildConnectDiagnostics(relayDir, conn, log) {
3081
3181
  `运行环境: ${conn.runtimeReady ? "已就绪" : "缺失"}${conn.installing ? "(后台安装中)" : ""}${conn.installStale ? "(安装标记已超时)" : ""}`,
3082
3182
  `bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
3083
3183
  `中继注册: ${conn.registered ? "已注册(" + conn.registerSource + ")" : "未注册"}`,
3184
+ `bridge 账号: ${conn.accountCurrent === false ? "⚠️ 仍是上一个账号的身份(需重启 bridge 重新登记)" : "当前账号"}`,
3084
3185
  `最近错误: ${conn.error ? conn.error.code + ": " + conn.error.message : (log && log.lastError ? log.lastError : "无")}`,
3085
3186
  `自动重试: 已尝试 ${conn.attempts} 次${conn.nextRetryInMs ? `,约 ${Math.ceil(conn.nextRetryInMs / 1000)} 秒后重试` : ""}`,
3086
3187
  `bridge 日志: ${conn.logPath}`,
@@ -3115,6 +3216,17 @@ function ensureConnection(relayDir, opts = {}) {
3115
3216
  const launchd = launchdStatus();
3116
3217
  const manual = manualStatus(relayDir);
3117
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
+ }
3118
3230
  book.attempts = 0; // 进程已在跑:退避计数归零,后续只等注册
3119
3231
  book.lastAttemptAt = now;
3120
3232
  return { action: "none" };
@@ -4003,8 +4115,11 @@ function registerRoutes(ctx, relayDir) {
4003
4115
  if (secret) cfg.bridge_secret = secret;
4004
4116
  }
4005
4117
  }
4118
+ // 账号/模式变化 → 先停掉旧 bridge 并作废它的注册证据,否则新账号永远等不到这台设备
4119
+ const accountFpBefore = accountFingerprintOf(loadConfig(relayDir));
4006
4120
  saveConfig(relayDir, cfg);
4007
4121
  invalidateRelayToken(relayDir); // 账号可能变了:丢弃旧 token 缓存,立即用新账号认证
4122
+ if (accountFingerprintOf(cfg) !== accountFpBefore) resetBridgeForAccountChange(relayDir, "账号/模式已变更");
4008
4123
  const bridgeRestart = startBridge(relayDir);
4009
4124
  // 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」口径);失败静默。
4010
4125
  maybeReportInstall(relayDir, { force: true });
@@ -4015,11 +4130,17 @@ function registerRoutes(ctx, relayDir) {
4015
4130
  method: "POST",
4016
4131
  path: "/dsh-remote/logout",
4017
4132
  handler: async (_req, res) => {
4018
- // 退出登录:清除本机保存的账号(邮箱/密码),bridge 下次重启将不再自动登录
4133
+ // 退出登录:清除本机保存的账号(邮箱/密码),并**立刻停掉 bridge**。
4134
+ // 为什么必须停:bridge 的凭据是启动时固化的,不停就会继续用旧账号的隧道对外服务 ——
4135
+ // 用户以为"已退出登录",实际上手机端仍能访问这台电脑(安全语义不成立)。
4019
4136
  const cfg = loadConfig(relayDir);
4020
4137
  delete cfg.phone;
4021
4138
  delete cfg.password;
4139
+ delete cfg.device_id; // 设备身份一并作废:下次登录会生成新身份并重新登记
4140
+ delete cfg.device_private_key;
4141
+ delete cfg.device_public_key;
4022
4142
  saveConfig(relayDir, cfg);
4143
+ resetBridgeForAccountChange(relayDir, "退出登录");
4023
4144
  invalidateRelayToken(relayDir); // 退出登录:token 缓存立即失效
4024
4145
  sendJson(res, 200, { ok: true, ...(await composeStatus(relayDir)) });
4025
4146
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.7-beta.3",
3
+ "version": "0.6.7",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",