@mrrisega/dsh-remote 0.7.2 → 0.7.3-beta.2

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 (25) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +214 -9
  2. package/clients/dsh-remote/e2ee-shim-script.js +98 -18
  3. package/clients/dsh-remote/mirror-diag-script.js +19 -0
  4. package/clients/dsh-remote/mobile-adapter.mjs +803 -16
  5. package/clients/dsh-remote/test/bridge-guard.test.mjs +169 -0
  6. package/clients/dsh-remote/test/e2ee-shim-selfheal.test.mjs +264 -0
  7. package/clients/dsh-remote/test/mirror-diag.test.mjs +75 -0
  8. package/clients/dsh-remote/test/mobile-adapter-compat.test.mjs +175 -0
  9. package/clients/dsh-remote/test/mobile-adapter-copy.test.mjs +56 -0
  10. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +87 -8
  11. package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +9 -0
  12. package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +233 -28
  13. package/clients/dsh-remote/upstream-discovery.mjs +6 -1
  14. package/dsh-setup.mjs +225 -6
  15. package/package.json +1 -1
  16. package/packages/dsh-remote-web/lib/index.js +140 -3
  17. package/packages/dsh-remote-web/package.json +1 -1
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +214 -9
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +98 -18
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +19 -0
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +803 -16
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +6 -1
  23. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -6
  24. package/packages/dsh-remote-web/test/bridge-hardening.test.mjs +299 -0
  25. package/packages/dsh-remote-web/test/windows-compat.test.mjs +82 -12
package/dsh-setup.mjs CHANGED
@@ -63,6 +63,17 @@ const WIN_TASK_NAME = "dsh-remote-bridge";
63
63
  */
64
64
  const WATCHER_PID_FILE = ".dsh-watcher.pid";
65
65
  const BRIDGE_PID_FILE = ".dsh-bridge.pid";
66
+ /**
67
+ * C2 互踢根治:bridge 判定"本机已有同 deviceId 的另一个 bridge"时的退出码(必须与
68
+ * dsh-bridge.mjs 里的 DUPLICATE_INSTANCE_EXIT_CODE 一致),以及 watcher 收到它之后的暂停时长。
69
+ *
70
+ * 为什么 watcher 要暂停而不是照常 10s 重拉:互踢的本质是"两个实例抢同一个 deviceId",
71
+ * 重拉的唯一效果是**把对方也顶掉**(对方同样重连),形成 2~3.5s 周期的乒乓(生产日志实测
72
+ * dev-a50dced9cc19 327 次/10min)。本机已有实例在跑时,让位才是对的;5 分钟后若对方已经
73
+ * 退出(用户重装/升级收尾),本守护自然恢复。
74
+ */
75
+ const DUPLICATE_INSTANCE_EXIT_CODE = 4;
76
+ const DUPLICATE_BRIDGE_BACKOFF_MS = 5 * 60 * 1000;
66
77
 
67
78
  // ---------- 运行时自物化(npm/npx 安装 → 固化到配置目录,脱离 npx 缓存) ----------
68
79
  // npx 每次安装的缓存目录(~/.npm/_npx/<hash>)不固定:缓存一旦清理,指向它的自启动服务
@@ -232,7 +243,8 @@ function runtimeSetupPath() {
232
243
 
233
244
  function sh(cmd, timeoutMs = 15000, cwd = undefined) {
234
245
  try {
235
- const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, ...(cwd ? { cwd } : {}) });
246
+ // windowsHide:POSIX 上被忽略;Windows 上避免 execSync 经 cmd.exe 执行控制台程序时闪窗/弹窗。
247
+ const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, windowsHide: true, ...(cwd ? { cwd } : {}) });
236
248
  return { ok: true, stdout: String(stdout ?? ""), stderr: "", code: 0 };
237
249
  } catch (e) {
238
250
  return { ok: false, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), code: e.status ?? -1 };
@@ -411,13 +423,50 @@ function hardenFile(file) {
411
423
  }
412
424
 
413
425
  // ---------- 配置读写 ----------
426
+ /** 配置读失败只报一次(每 10s 一轮的 ensureBridge 会把同一行刷成噪音)。 */
427
+ let configReadWarned = false;
414
428
  function loadConfig() {
415
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); }
416
- catch { return {}; }
429
+ try {
430
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
431
+ } catch (e) {
432
+ // ⚠️ 静默返回 {} 是历史行为(首次安装时文件确实不存在)。但**读失败不能与"没登录"混淆**:
433
+ // 配置文件存在却读不出来(杀软占用 / OneDrive 同步中 / EPERM / 半写)时,指纹会瞬间变成
434
+ // `saas|||`,旧逻辑据此按"账号变了"停掉 bridge → 下一轮再拉起 → 反复重启(诊断报告 04 §4.3)。
435
+ // 这里只补"可观测"(写一次原因),重启抑制由 accountFingerprint 的连续确认负责。
436
+ if (e && e.code !== "ENOENT" && !configReadWarned) {
437
+ configReadWarned = true;
438
+ console.warn(`⚠️ 读取配置失败(${e.code || e.message}):${CONFIG_PATH} —— 本轮按空配置处理,但不据此重启 bridge。`);
439
+ }
440
+ return {};
441
+ }
417
442
  }
443
+ /**
444
+ * 保存配置。
445
+ *
446
+ * 【C8 原子写】旧实现是"先截断再写"(writeFileSync 直接覆盖):saas 模式**每 10 秒**重写一次,
447
+ * 而插件半(设置页/切账号/备份)也在写同一个文件 —— Windows 上被杀软扫描/OneDrive 同步放大成
448
+ * "读到半截 JSON" → loadConfig 静默 {} → 指纹抖动 → bridge 反复重启。
449
+ *
450
+ * 现在写成 `<config>.<pid>.tmp` 再 rename 覆盖(rename 在同一目录内是原子的):
451
+ * 读的一方要么看到旧内容、要么看到新内容,永远读不到半截。
452
+ * 失败安全:Windows 上目标文件被杀软/备份工具持有句柄时 rename 可能 EBUSY/EPERM ——
453
+ * 先短暂重试,仍失败就退回直接写(宁可丢原子性,也绝不写不进去把配置搞丢)。
454
+ */
418
455
  function saveConfig(cfg) {
419
456
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
420
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
457
+ const body = JSON.stringify(cfg, null, 2);
458
+ const tmp = `${CONFIG_PATH}.${process.pid}.tmp`;
459
+ let renamed = false;
460
+ try {
461
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
462
+ for (let i = 0; i < 3 && !renamed; i += 1) {
463
+ try { fs.renameSync(tmp, CONFIG_PATH); renamed = true; }
464
+ catch (e) { if (i === 2) throw e; sleepSync(30); }
465
+ }
466
+ } catch {
467
+ try { fs.rmSync(tmp, { force: true }); } catch { /* 临时文件删不掉不致命 */ }
468
+ fs.writeFileSync(CONFIG_PATH, body, { mode: 0o600 }); // 兜底:绝不让保存失败
469
+ }
421
470
  hardenFile(CONFIG_PATH); // Windows 上 mode 是空操作 → 必须显式收紧 ACL(内含明文账号密码)
422
471
  }
423
472
 
@@ -584,6 +633,83 @@ function pidAlive(pid) {
584
633
  try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
585
634
  }
586
635
 
636
+ /* ── C2:孤儿 bridge 清扫(命令行校验,绝不误杀)─────────────────────────────────
637
+ *
638
+ * 为什么 pid 文件回收(上面的 shouldReapLeftoverBridge)还不够:pid 文件只覆盖"上一任 watcher
639
+ * 写过的那个 pid"。真实场景里还有两类抓不到的孤儿(诊断报告 04 §4.4):
640
+ * ① watcher 被 TerminateProcess / 关闭控制台收掉时,bridge 子进程被 reparent 成孤儿继续持有隧道;
641
+ * ② 0.7.2 的升级覆盖写运行时**不停旧进程**,旧 bridge 继续用旧脚本跑。
642
+ * 两种孤儿都与新 bridge 同 deviceId → 中继 close(4000) 互踢(生产上 3 台 Win 设备长期乒乓,全 0.7.2)。
643
+ *
644
+ * 安全边界(误杀无关进程是不可接受的):
645
+ * · 只认**命令行同时包含** `dsh-bridge.mjs` 与**本运行时** `clients/dsh-remote` 路径的进程
646
+ * (与 looksLikeBridge 同一套纪律;还额外要求是本 THIS_DIR 的运行时,避免碰到另一个配置目录/用户);
647
+ * · 取不到命令行(ps/PowerShell 不可用)→ 一个都不杀。失败方向必须是"漏杀"而不是"误杀"。
648
+ */
649
+ function isThisRuntimeBridgeCmd(cmd) {
650
+ const c = String(cmd || "");
651
+ if (!/dsh-bridge\.mjs/.test(c)) return false;
652
+ const needle = path.join(THIS_DIR, "clients", "dsh-remote");
653
+ // 命令行里的分隔符可能与本机 path.sep 不同(Windows 上两者都可能出现)→ 归一化后比较
654
+ const norm = (v) => v.replace(/[\\/]+/g, "/").toLowerCase();
655
+ return norm(c).includes(norm(needle));
656
+ }
657
+
658
+ /** 列出本机属于**本运行时**的 bridge 进程 pid(命令行校验;取不到命令行 → 空数组)。 */
659
+ function listRuntimeBridgePids() {
660
+ const pids = [];
661
+ if (IS_WIN) {
662
+ // 一次性 PowerShell CIM 枚举(逐个 windowsProcessCommandLine 会起 N 个进程,太贵)
663
+ const script = [
664
+ "try { $p = Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" }",
665
+ "catch { $p = Get-WmiObject Win32_Process -Filter \"Name='node.exe'\" }",
666
+ "$p | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress",
667
+ ].join("; ");
668
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
669
+ try {
670
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
671
+ { encoding: "utf8", timeout: 8000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
672
+ const raw = String(r.stdout || "").trim();
673
+ if (raw) {
674
+ const parsed = JSON.parse(raw);
675
+ for (const p of Array.isArray(parsed) ? parsed : [parsed]) {
676
+ const pid = Number(p && p.ProcessId);
677
+ if (Number.isInteger(pid) && pid > 0 && isThisRuntimeBridgeCmd(p.CommandLine)) pids.push(pid);
678
+ }
679
+ }
680
+ } catch { /* 无 PowerShell / 解析失败 → 不杀任何进程 */ }
681
+ return pids;
682
+ }
683
+ const r = sh("pgrep -fl 'dsh-bridge\\.mjs'");
684
+ if (!r.ok) return pids;
685
+ for (const line of String(r.stdout || "").split("\n")) {
686
+ const m = /^(\d+)\s+(.+)$/.exec(line.trim());
687
+ if (!m) continue;
688
+ if (isThisRuntimeBridgeCmd(m[2])) pids.push(Number(m[1]));
689
+ }
690
+ return pids;
691
+ }
692
+
693
+ /**
694
+ * 清扫孤儿 bridge(接管/退出时调用)。返回被结束的 pid 列表。
695
+ * @param {{ fast?: boolean }} opts fast=true 时不等待(退出钩子里只剩 3s 预算)
696
+ */
697
+ function sweepStrayBridges({ fast = false } = {}) {
698
+ const strays = listRuntimeBridgePids().filter((pid) => pid !== process.pid);
699
+ const killed = [];
700
+ for (const pid of strays) {
701
+ try { process.kill(pid, "SIGTERM"); killed.push(pid); } catch { /* 已退出 */ }
702
+ }
703
+ if (!fast) {
704
+ for (const pid of strays) {
705
+ const dl = Date.now() + 3000;
706
+ while (Date.now() < dl && pidAlive(pid)) sleepSync(100);
707
+ if (pidAlive(pid)) { try { process.kill(pid, "SIGKILL"); } catch { /* 已退出 */ } }
708
+ }
709
+ }
710
+ return killed;
711
+ }
712
+
587
713
  /**
588
714
  * 注册 Windows 登录任务(幂等:先删再建)。
589
715
  * /DELAY 0000:15:登录后等 15 秒再起,避免与桌面环境抢启动时机
@@ -1435,6 +1561,32 @@ function accountFingerprint(cfg) {
1435
1561
  return [c.local_key ? "local" : "saas", c.phone || c.email || "", String(c.device_id || ""), String(c.local_key || "")].join("|");
1436
1562
  }
1437
1563
 
1564
+ /* ── 账号指纹"变化确认"(C8)────────────────────────────────────────────────────
1565
+ *
1566
+ * 🔴 为什么不能"指纹一变就重启 bridge"(诊断报告 04 §4.3):
1567
+ * `loadConfig()` 读失败会静默返回 `{}`,此时指纹 = `saas|||`,与真实值不等 ——
1568
+ * 旧逻辑据此按"账号变了"停 bridge,下一轮再拉起,于是每 10~20s 重启一次;
1569
+ * Windows 上被杀软扫描/OneDrive 同步/文件占用放大的"读抖动"就是这样变成重启循环的。
1570
+ *
1571
+ * 修法:指纹变化必须**连续 2 轮**(ensureBridge 每 10s 一轮)看到同一个新值,
1572
+ * 或该新值已持续 ≥5s,才认为"真的变了"。单轮的 `{}` 抖动会被下一轮复位清掉。
1573
+ * 抽成纯函数是因为这条判断的代价很实在(每误判一次就断一次隧道),必须能被直接测。
1574
+ */
1575
+ const FINGERPRINT_CONFIRM_ROUNDS = 2;
1576
+ const FINGERPRINT_CONFIRM_MS = 5000;
1577
+
1578
+ /** 记一次"疑似新指纹":同一个 fp 连击计数,换值则重新从 1 开始。 */
1579
+ function noteFingerprintCandidate(prev, fp, now = Date.now()) {
1580
+ if (!prev || prev.value !== fp) return { value: fp, rounds: 1, since: now };
1581
+ return { value: fp, rounds: prev.rounds + 1, since: prev.since };
1582
+ }
1583
+
1584
+ /** 这个"疑似变化"是否已经确认(连续 2 轮,或持续 ≥5s)。 */
1585
+ function fingerprintChangeConfirmed(cand, now = Date.now()) {
1586
+ if (!cand || !cand.value) return false;
1587
+ return cand.rounds >= FINGERPRINT_CONFIRM_ROUNDS || now - cand.since >= FINGERPRINT_CONFIRM_MS;
1588
+ }
1589
+
1438
1590
  /**
1439
1591
  * 服务端登录限流的"最早可重试时刻"(bridge 在收到 429 时落盘)。
1440
1592
  * 为什么需要:限流窗口是 15 分钟,而 watcher 每 10 秒就会重新拉起 bridge ——
@@ -1590,11 +1742,30 @@ async function runBridge() {
1590
1742
  removePidFile(BRIDGE_PID_FILE);
1591
1743
  }
1592
1744
  }
1745
+ // ── C2:清扫「pid 文件之外的孤儿 bridge」(命令行校验,绝不误杀)──────────────
1746
+ //
1747
+ // pid 文件只能覆盖"上一任 watcher 写过的那个 pid"。真实世界里还有两类抓不到的孤儿:
1748
+ // ① watcher 被 TerminateProcess / 关闭控制台收掉 → bridge 被 reparent 成孤儿继续持有隧道;
1749
+ // ② 0.7.2 升级覆盖运行时**不停旧进程** → 旧 bridge 继续用旧脚本跑。
1750
+ // 它们与新 bridge 共用同一 deviceId → 中继 close(4000) 互踢(生产 3 台 Win 设备长期乒乓,全 0.7.2)。
1751
+ // 走到这里说明**本机此刻只有本守护**(去重已通过),所以任何"本运行时的 bridge"都是上一代的遗留。
1752
+ {
1753
+ const strays = sweepStrayBridges();
1754
+ if (strays.length) {
1755
+ console.log(`[dsh-remote] 已清扫 ${strays.length} 个孤儿 bridge 进程(pid ${strays.join(", ")})——避免与新实例互踢。`);
1756
+ }
1757
+ }
1593
1758
  }
1594
1759
  let cfg = loadConfig();
1595
1760
  let warnedNoLogin = false;
1596
1761
  /** 当前 bridge 子进程使用的账号指纹(账号/模式/设备身份任一变化 → 重启子进程)。 */
1597
1762
  let bridgeAccountFp = "";
1763
+ /** C8:疑似变化但还没确认的新指纹(连续 2 轮/≥5s 才确认,防 loadConfig 静默 {} 抖动)。 */
1764
+ let pendingFp = null;
1765
+ /** C2:bridge 判为「本机已有另一个实例」后的暂停拉起截止时刻。 */
1766
+ let duplicateBridgeBackoffUntil = 0;
1767
+ /** 暂停提示只打一次,避免每 10s 刷屏。 */
1768
+ let warnedDuplicateBridge = false;
1598
1769
  /** 限流提示只打一次,避免刷屏。 */
1599
1770
  let warnedRateLimit = false;
1600
1771
 
@@ -1642,11 +1813,25 @@ async function runBridge() {
1642
1813
  //(2026-09-19 实测故障)。
1643
1814
  const fp = accountFingerprint(cfg);
1644
1815
  if (!childStopped(bridgeProc) && bridgeAccountFp && bridgeAccountFp !== fp) {
1816
+ // C8:**连续确认**才重启(理由见 noteFingerprintCandidate 上方的长注释)。
1817
+ // 单独一次读到 `{}`(文件被占用/半写/杀软扫描)不构成"账号变了",下一轮自然复位。
1818
+ pendingFp = noteFingerprintCandidate(pendingFp, fp);
1819
+ if (!fingerprintChangeConfirmed(pendingFp)) {
1820
+ if (pendingFp.rounds === 1) {
1821
+ // 不打印指纹内容:它含手机号/设备 id(日志会被用户复制到群里)
1822
+ console.log("[dsh-remote] 账号配置疑似变化,先观察一轮再决定是否重启 bridge(防配置读抖动)。");
1823
+ }
1824
+ return;
1825
+ }
1826
+ pendingFp = null;
1645
1827
  console.log("[dsh-remote] 账号配置已变化 → 重启 bridge 让新账号生效...");
1646
1828
  await stopChildGracefully(bridgeProc); // 先请它优雅退出,跑完微信通道下线通知
1647
1829
  bridgeAccountFp = "";
1830
+ duplicateBridgeBackoffUntil = 0; // 用户明确换了账号:清掉"让位"暂停,立即用新凭据拉起
1648
1831
  return; // 下一轮(10s 内)用新配置重新拉起
1649
1832
  }
1833
+ // 指纹又回到一致(或本来就没变)→ 之前的"疑似变化"作废,绝不让它跨轮累积。
1834
+ pendingFp = null;
1650
1835
 
1651
1836
  // 服务端登录限流窗口内不反复拉起 bridge(凭据没错,改了也没用)
1652
1837
  const rlUntil = loginRateLimitUntil();
@@ -1659,6 +1844,18 @@ async function runBridge() {
1659
1844
  }
1660
1845
  warnedRateLimit = false;
1661
1846
 
1847
+ // C2:bridge 连续 2 次收到 code=4000 已判定"本机还有另一个同 deviceId 的实例"并退出(code=4)。
1848
+ // 那段时间内本守护**不再拉起**:拉起只会把对方顶掉,继续乒乓互踢。
1849
+ if (Date.now() < duplicateBridgeBackoffUntil) {
1850
+ if (!warnedDuplicateBridge) {
1851
+ warnedDuplicateBridge = true;
1852
+ console.log(`[dsh-remote] 本机已有另一个同 deviceId 的 bridge(互踢保护)→ 本守护暂停拉起约 ${Math.ceil((duplicateBridgeBackoffUntil - Date.now()) / 60000)} 分钟。`);
1853
+ console.log(" 若这是升级/重装留下的旧实例,可执行 `dsh-remote doctor` 或重启一次 dsh web 让它退出。");
1854
+ }
1855
+ return;
1856
+ }
1857
+ warnedDuplicateBridge = false;
1858
+
1662
1859
  const alive = await checkUpstream();
1663
1860
  if (alive && childStopped(bridgeProc)) {
1664
1861
  starting = true;
@@ -1685,9 +1882,28 @@ async function runBridge() {
1685
1882
  // 只有它能让我们在停 bridge 之前请它跑完退出钩子(notifystop)—— 见 stopChildGracefully。
1686
1883
  bridgeProc = spawn(NODE_BIN,
1687
1884
  [path.join(THIS_DIR, "clients/dsh-remote/dsh-bridge.mjs")],
1688
- { env: nodeEnv(childEnv), stdio: ["inherit", "inherit", "inherit", "ipc"] });
1689
- bridgeProc.on("exit", () => {
1885
+ {
1886
+ env: nodeEnv(childEnv),
1887
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
1888
+ // 🔴 C1 黑窗根治(2026-10-20 诊断 W1,生产截图 04):这里**必须**带 windowsHide。
1889
+ // 原理同 spawnDetachedBridge 的注释:watcher 自己常以"无控制台"方式被拉起
1890
+ //(launchd / 任务计划里的 wscript / 插件的 detached spawn)。无控制台的父进程
1891
+ // spawn 一个控制台程序(node.exe)却不给 CREATE_NO_WINDOW 时,Windows 会**为子进程
1892
+ // 新分配一个可见控制台窗口**;而 bridge 的 stdout/stderr 又被 watcher 指向日志文件,
1893
+ // 于是用户看到的就是一个**空白黑窗**。bridge 崩溃 → watcher 每 10s 重拉 → 每 10s 弹一个。
1894
+ // POSIX 上该选项被忽略。
1895
+ windowsHide: true
1896
+ });
1897
+ bridgeProc.on("exit", (code) => {
1690
1898
  removePidFile(BRIDGE_PID_FILE);
1899
+ // C2 互踢根治的 watcher 半边:bridge 连续 2 次收到 code=4000(= 本机已有同 deviceId 的
1900
+ // 另一个 bridge 把本连接顶掉)后会以"重复实例"退出码优雅退出(见 dsh-bridge.mjs)。
1901
+ // 这种退出**绝不能**照常 10s 重拉:本机已经有另一个实例在跑,重拉只会继续乒乓互踢。
1902
+ if (code === DUPLICATE_INSTANCE_EXIT_CODE) {
1903
+ duplicateBridgeBackoffUntil = Date.now() + DUPLICATE_BRIDGE_BACKOFF_MS;
1904
+ console.log(`[dsh-remote] bridge 检测到同 deviceId 的另一个实例并退出(code=${code})——本守护暂停 ${Math.round(DUPLICATE_BRIDGE_BACKOFF_MS / 60000)} 分钟后再试,避免两个实例反复互踢。`);
1905
+ return;
1906
+ }
1691
1907
  console.log("[dsh-remote] bridge 退出,等待重启...");
1692
1908
  });
1693
1909
  // Windows:落 pid 文件,供插件半发现进程(Windows 没有 pgrep/ps)。
@@ -1728,6 +1944,9 @@ async function runBridge() {
1728
1944
  if (bridgeProc && bridgeProc.exitCode === null) {
1729
1945
  try { bridgeProc.kill("SIGKILL"); } catch { /* 已退出 */ }
1730
1946
  }
1947
+ // C2:退出时顺手清掉本运行时的全部 bridge 残留(fast:不等待)。
1948
+ // 否则 Windows 上 watcher 被收掉后 bridge 可能成孤儿、继续与新实例互踢。
1949
+ try { sweepStrayBridges({ fast: true }); } catch { /* 清扫失败不影响退出 */ }
1731
1950
  process.exit(0);
1732
1951
  }, 3000);
1733
1952
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.2",
3
+ "version": "0.7.3-beta.2",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -317,7 +317,10 @@ function hardenFile(file) {
317
317
  /** 执行 shell 命令,不抛异常,返回 { ok, stdout, stderr, code }。 */
318
318
  function sh(cmd) {
319
319
  try {
320
- const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000 });
320
+ // windowsHide(C1 黑窗不变量):Windows 上 execSync 经 cmd.exe 起控制台程序时,
321
+ // 若宿主(dsh web / 桌面端)没有控制台,系统会给子进程分配一个可见窗口。
322
+ // POSIX 上该选项被忽略。
323
+ const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000, windowsHide: true });
321
324
  return { ok: true, stdout: String(stdout ?? ""), stderr: "", code: 0 };
322
325
  } catch (e) {
323
326
  return { ok: false, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), code: e.status ?? -1 };
@@ -532,7 +535,9 @@ function safeSpawn(command, args, opts = {}) {
532
535
  const { onError, ...spawnOpts } = opts;
533
536
  let child;
534
537
  try {
535
- child = spawn(command, args, spawnOpts);
538
+ // C1:默认 windowsHide(调用方显式传 false 才会覆盖)。插件派生的每一个子进程都是
539
+ // "后台辅助进程",Windows 上绝不该出现可见控制台窗口(诊断报告 04 的 W1 黑窗)。
540
+ child = spawn(command, args, { windowsHide: true, ...spawnOpts });
536
541
  } catch (e) {
537
542
  return { child: null, error: e };
538
543
  }
@@ -553,7 +558,8 @@ function spawnAndConfirm(command, args, opts = {}) {
553
558
  return new Promise((resolve) => {
554
559
  let child;
555
560
  try {
556
- child = spawn(command, args, opts);
561
+ // C1:同 safeSpawn,默认 windowsHide(Windows 上不弹控制台)。
562
+ child = spawn(command, args, { windowsHide: true, ...opts });
557
563
  } catch (e) {
558
564
  resolve({ child: null, error: e });
559
565
  return;
@@ -2325,6 +2331,12 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
2325
2331
  // 上游端口**必须**透传:watcher 用它判断「dsh web 在线吗」,bridge 用它做转发目标。
2326
2332
  // 不传的话两边都回落到写死的 3080 —— dsh web 不在默认端口时 bridge 永远不启动(0.6.12 修)。
2327
2333
  DSH_BRIDGE_UPSTREAM: upstreamUrl(),
2334
+ // C7:**必须显式**把 relayDir 传给 watcher。
2335
+ // 旧实现只靠 spawnEnv 继承宿主的 DSH_RELAY_DIR(第 7800+ 行:relayDir 也可能来自
2336
+ // config.relayDir),于是"relayDir 来自配置、宿主环境没有该变量"时,watcher/bridge 会
2337
+ // 回落到 ~/.dsh-remote —— 面板看到的现象是「守护在跑、bridge 永远不出现」(1415 那类故障),
2338
+ // 而且 pid 文件/日志也都写去了另一个目录。
2339
+ DSH_RELAY_DIR: relayDir,
2328
2340
  }),
2329
2341
  stdio: ["ignore", out, out],
2330
2342
  windowsHide: true,
@@ -4220,6 +4232,93 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
4220
4232
  });
4221
4233
  }
4222
4234
 
4235
+ /* ── C8:升级/更新「覆盖运行时之前停旧进程 + 装完按命令行清残留」───────────────────
4236
+ *
4237
+ * 为什么必须停(诊断报告 04 §五 R1):安装器的 ensureRuntimeCopy() 会把 clients/ 与
4238
+ * node_modules/ws **覆盖式**拷进运行时目录,而旧 watcher/bridge 正在用这些文件跑。
4239
+ * Windows 上杀软持有句柄 → 覆盖写失败 → 半装 → bridge `ERR_MODULE_NOT_FOUND: Cannot find
4240
+ * package 'ws'`;同时旧 bridge 成孤儿、与新起的一份同 deviceId 互踢。
4241
+ *
4242
+ * 安全纪律(这套代码在**开发者的真机上**被测试执行,误杀/误停是不可接受的):
4243
+ * · 只停「命令行里带**本 relayDir** 路径」的进程 —— 别的配置目录、别的用户的 bridge 一律不碰;
4244
+ * · 取不到命令行(ps/PowerShell 不可用)→ 不停(漏停由安装器自己接管处理,方向选安全侧);
4245
+ * · 走 stopBridgeDetached/manualStatus 那条路(pid 文件 + 命令行扫描),**不碰 launchd/systemd**
4246
+ * —— 服务本身由安装器收尾时重写并重启,插件这一侧只负责"别让旧代码继续跑"。
4247
+ */
4248
+
4249
+ /** 该 pid 的完整命令行(Windows 复用带 TTL 缓存的 PowerShell 扫描;取不到返回空串)。 */
4250
+ function commandLineOfPid(pid) {
4251
+ if (!Number.isInteger(pid) || pid <= 0) return "";
4252
+ if (isWindows()) {
4253
+ const hit = windowsNodeProcesses().find((p) => p.pid === pid);
4254
+ return hit ? String(hit.commandLine || "") : "";
4255
+ }
4256
+ const r = sh(`ps -p ${pid} -o command=`);
4257
+ return r.ok ? String(r.stdout || "").trim() : "";
4258
+ }
4259
+
4260
+ /** 这个 pid 是否真的在跑**本 relayDir** 里的脚本(命令行校验;取不到 → false)。 */
4261
+ function pidRunsFrom(pid, relayDir) {
4262
+ const cmd = commandLineOfPid(pid);
4263
+ if (!cmd) return false; // 看不清就不动:误杀无关进程的代价远高于漏停
4264
+ const norm = (v) => String(v).replace(/\\/g, "/").toLowerCase();
4265
+ return norm(cmd).includes(norm(relayDir));
4266
+ }
4267
+
4268
+ /** 收集本 relayDir 这套运行时的 watcher/bridge pid(pid 文件 + 命令行扫描)。 */
4269
+ function collectRuntimePids(relayDir) {
4270
+ const pids = new Set();
4271
+ for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
4272
+ const pid = readPidFile(relayDir, name);
4273
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) pids.add(pid);
4274
+ }
4275
+ const ms = manualStatus(relayDir) || {};
4276
+ for (const pid of [...(ms.watcher || []), ...(ms.bridge || []), ...(ms.allBridge || [])]) {
4277
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) pids.add(pid);
4278
+ }
4279
+ return [...pids];
4280
+ }
4281
+
4282
+ /**
4283
+ * 覆盖运行时之前停掉本 relayDir 的旧 watcher/bridge(幂等)。
4284
+ * @returns {{skipped: boolean, pids: number[]}}
4285
+ */
4286
+ function stopBridgeForRuntimeReplace(relayDir) {
4287
+ if (skipsSystemOps()) return { skipped: true, pids: [] };
4288
+ const pids = collectRuntimePids(relayDir).filter((pid) => pidAlive(pid) !== false && pidRunsFrom(pid, relayDir));
4289
+ for (const pid of pids) killPid(pid, "SIGKILL"); // Windows 上是 taskkill /T /F:连带子进程
4290
+ for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
4291
+ try { rmSync(join(relayDir, name), { force: true }); } catch { /* 非关键 */ }
4292
+ }
4293
+ return { skipped: false, pids };
4294
+ }
4295
+
4296
+ /**
4297
+ * 装完清残留:只处理**本轮更新前就在跑**的那批 pid,且仍满足命令行校验。
4298
+ * 为什么不用"扫所有本 relayDir 的 bridge":npx 安装器的收尾会重启服务并拉起**新的** bridge,
4299
+ * 那一个是新代码、必须留下;按"更新前的 pid 集合"点名清理才不会误杀新实例。
4300
+ * @returns {number[]} 被结束的 pid
4301
+ */
4302
+ function sweepBridgeAfterRuntimeReplace(relayDir, beforePids = []) {
4303
+ if (skipsSystemOps() || !Array.isArray(beforePids) || !beforePids.length) return [];
4304
+ const killed = [];
4305
+ for (const pid of beforePids) {
4306
+ if (pidAlive(pid) === false) continue;
4307
+ if (!pidRunsFrom(pid, relayDir)) continue; // pid 可能已被系统回收 → 必须重新校验命令行
4308
+ if (killPid(pid, "SIGKILL")) killed.push(pid);
4309
+ }
4310
+ return killed;
4311
+ }
4312
+
4313
+ /** 更新失败/取消时把 bridge 恢复起来(否则用户会莫名失去远程访问)。只在本轮真停过进程时执行。 */
4314
+ function restoreBridgeAfterUpdate(relayDir, stoppedPids = []) {
4315
+ if (skipsSystemOps() || !stoppedPids.length) return null;
4316
+ try {
4317
+ const r = startBridgeDetached(relayDir);
4318
+ return r;
4319
+ } catch { return null; }
4320
+ }
4321
+
4223
4322
  /**
4224
4323
  * 后台执行在线一键更新:npx 按 dist-tag(默认 latest)(幂等自愈:补运行环境/更新 bridge/收敛 include)。
4225
4324
  * 稳健性:
@@ -4230,6 +4329,8 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
4230
4329
  * - marker 记录 pid,子进程退出/出错即清理;宿主重启后由 sweepStaleMarkers 立即清掉死进程残留。
4231
4330
  */
4232
4331
  async function runOnlineUpdate(relayDir) {
4332
+ /** C8:本轮更新前停掉的旧进程 pid(失败时要恢复;成功时要清残留)。 */
4333
+ let stopInfo = { skipped: true, pids: [] };
4233
4334
  try {
4234
4335
  mkdirSync(relayDir, { recursive: true });
4235
4336
  const marker = join(relayDir, UPDATE_MARKER);
@@ -4256,6 +4357,30 @@ async function runOnlineUpdate(relayDir) {
4256
4357
  `[update] 开始在线更新 ${spec}(通道 ${UPDATE_TAG};${target.why})(${new Date().toISOString()})`);
4257
4358
  telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
4258
4359
 
4360
+ // C8:**覆盖运行时之前**先停掉本 relayDir 的旧 watcher/bridge(幂等)。
4361
+ // 安装器随后会覆盖写 clients/ 与 node_modules/ws —— 旧进程一边跑一边被覆盖,
4362
+ // Windows 上极易半装(`Cannot find package 'ws'`)并留下孤儿进程新老互踢。
4363
+ stopInfo = stopBridgeForRuntimeReplace(relayDir);
4364
+ appendLogLine(relayDir, UPDATE_LOG, stopInfo.skipped
4365
+ ? "[update] 测试隔离:跳过「停旧 bridge」(DSH_RELAY_SKIP_SERVICE=1)"
4366
+ : (stopInfo.pids.length
4367
+ ? `[update] 覆盖运行时前已停旧 bridge/watcher(pid ${stopInfo.pids.join(", ")})`
4368
+ : "[update] 覆盖运行时前检查:本 relayDir 没有旧 bridge/watcher 在跑"));
4369
+ /** 失败/取消:把刚停掉的 bridge 拉回来(只在本轮真停过进程时执行,避免测试里碰真实服务)。 */
4370
+ const restoreIfStopped = () => {
4371
+ if (!stopInfo.pids.length) return;
4372
+ const r = restoreBridgeAfterUpdate(relayDir, stopInfo.pids);
4373
+ appendLogLine(relayDir, UPDATE_LOG,
4374
+ `[update] 已重新拉起 bridge(${(r && r.status) || "unknown"}),远程访问不受影响`);
4375
+ };
4376
+ /** 成功:按"更新前那批 pid"点名清残留(保持命令行校验,绝不误杀安装器刚拉起的新实例)。 */
4377
+ const sweepIfSucceeded = () => {
4378
+ const killed = sweepBridgeAfterRuntimeReplace(relayDir, stopInfo.pids);
4379
+ if (killed.length) {
4380
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 装完清残留:已结束旧 bridge 进程(pid ${killed.join(", ")})`);
4381
+ }
4382
+ };
4383
+
4259
4384
  let retried = false;
4260
4385
  let stalled = false; // 本轮是否因"长时间无输出"被判定卡住(决定失败归因与是否回退)
4261
4386
  const clear = () => {
@@ -4270,6 +4395,7 @@ async function runOnlineUpdate(relayDir) {
4270
4395
  const fc = telemetryFailCodeFromError(e);
4271
4396
  noteUpdateFailure(relayDir, e.message, fc); // ← 面板可查询,不再只躺在日志里
4272
4397
  telemetryRecord(relayDir, "update_failed", { fail_code: fc });
4398
+ restoreIfStopped(); // C8:更新没起来 → 把刚停掉的 bridge 拉回来,别让用户失去远程访问
4273
4399
  };
4274
4400
  /**
4275
4401
  * 第三跳:离线通道(0.6.12)。
@@ -4319,6 +4445,7 @@ async function runOnlineUpdate(relayDir) {
4319
4445
  if (updateCancelledDirs.has(relayDir)) {
4320
4446
  updateCancelledDirs.delete(relayDir);
4321
4447
  appendLogLine(relayDir, UPDATE_LOG, "[update] 已取消:不再重试");
4448
+ restoreIfStopped(); // C8:取消也要把 bridge 拉回来
4322
4449
  return;
4323
4450
  }
4324
4451
  appendLogLine(relayDir, UPDATE_LOG, `[update] 离线通道退出 code=${code ?? "?"}`);
@@ -4331,8 +4458,10 @@ async function runOnlineUpdate(relayDir) {
4331
4458
  ? "离线通道也长时间没有输出(已判定卡住并结束):请检查本机能否访问 registry.npmjs.org"
4332
4459
  : (lastLine || `离线通道退出码 ${code}`), fc);
4333
4460
  telemetryRecord(relayDir, "update_failed", { fail_code: fc });
4461
+ restoreIfStopped(); // C8
4334
4462
  } else {
4335
4463
  updateFailures.delete(String(relayDir));
4464
+ sweepIfSucceeded(); // C8:装完清残留(按更新前的 pid 点名 + 命令行复核)
4336
4465
  // 更新成功 → 落一条「必须重启 dsh web」的强提醒(见 run() 成功分支的同款注释)
4337
4466
  markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
4338
4467
  }
@@ -4373,6 +4502,7 @@ async function runOnlineUpdate(relayDir) {
4373
4502
  if (updateCancelledDirs.has(relayDir)) {
4374
4503
  updateCancelledDirs.delete(relayDir);
4375
4504
  appendLogLine(relayDir, UPDATE_LOG, "[update] 已取消:不再换源重试");
4505
+ restoreIfStopped(); // C8:取消也要把 bridge 拉回来
4376
4506
  return;
4377
4507
  }
4378
4508
  if (!retried && (code !== 0 || stalled)) {
@@ -4407,8 +4537,10 @@ async function runOnlineUpdate(relayDir) {
4407
4537
  ? "更新进程长时间没有任何输出(已判定卡住并结束),通常是本机到 npm 官方源的链路不通"
4408
4538
  : (lastLine || `安装进程退出码 ${code}`), fc);
4409
4539
  telemetryRecord(relayDir, "update_failed", { fail_code: fc });
4540
+ restoreIfStopped(); // C8:三次尝试都已失败 → 先把 bridge 拉回来(用户可以先继续用,再手动重试)
4410
4541
  } else {
4411
4542
  updateFailures.delete(String(relayDir)); // 成功即清掉旧失败
4543
+ sweepIfSucceeded(); // C8:装完清残留(安装器可能又拉起一个新 bridge,只清更新前那批)
4412
4544
  // 【2026-09-26】更新成功 → 写一条 kind="restart" 的待重启标记。
4413
4545
  //
4414
4546
  // 修的是"文案与状态机不一致":版本卡一直写「请重启 dsh web 后生效」(client.js 的
@@ -4438,6 +4570,11 @@ async function runOnlineUpdate(relayDir) {
4438
4570
  } catch (e) {
4439
4571
  try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
4440
4572
  telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromError(e) });
4573
+ // C8:异常也要把停掉的 bridge 拉回来(否则"更新失败"= 远程访问直接没了)
4574
+ if (stopInfo.pids.length && !skipsSystemOps()) {
4575
+ const r = restoreBridgeAfterUpdate(relayDir, stopInfo.pids);
4576
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 响应异常,已重新拉起 bridge(${(r && r.status) || "unknown"})`);
4577
+ }
4441
4578
  return { ok: false, detail: String(e.message || e) };
4442
4579
  }
4443
4580
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.2",
3
+ "version": "0.7.3-beta.2",
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",