dsh-remote-web 0.7.6 → 0.7.9

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.
@@ -30,6 +30,7 @@ import { spawn, spawnSync, execSync } from "node:child_process";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { childStopped, stopChildGracefully } from "./clients/dsh-remote/src/lifecycle.mjs";
32
32
  import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./clients/dsh-remote/upstream-discovery.mjs";
33
+ import { normalizeRefCode, applyRefCodeToConfig } from "./clients/dsh-remote/src/channel-ref.mjs";
33
34
 
34
35
  const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
35
36
  const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
@@ -49,7 +50,16 @@ const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
49
50
  const REPO_URL = "https://github.com/mrRisega/dsh-remote";
50
51
 
51
52
  // ---------- 平台(0.6.7 起支持 Windows) ----------
52
- const IS_WIN = process.platform === "win32";
53
+ /**
54
+ * 有效平台:`DSH_SETUP_PLATFORM` 可覆盖(**只用于测试**,让 win32 分支能在 macOS/Linux 上被
55
+ * 行为级跑一遍;生产不设置 → 与 `process.platform` 完全一致,行为不变)。
56
+ *
57
+ * 为什么值得开这个口子:Windows 的进程发现 / 自检扫描在开发机上根本跑不到,而它们恰恰是
58
+ * 最贵的一类 bug —— 2026-10 那次「活着的 bridge 被判成没运行 → 反复 taskkill → bridge_wedged」
59
+ * 里,win32 的两条扫描分支(`Name='node.exe'` 过滤 + doctor 的 tasklist 无命令行)**从来没被真正执行过**。
60
+ */
61
+ const SETUP_PLATFORM = String(process.env.DSH_SETUP_PLATFORM || process.platform).toLowerCase();
62
+ const IS_WIN = SETUP_PLATFORM === "win32";
53
63
  /**
54
64
  * Windows 自启动 = 任务计划程序(Task Scheduler)里的登录任务。
55
65
  * 为什么不是「启动文件夹快捷方式」:生成 .lnk 需要额外依赖或 COM 调用,而 schtasks 系统自带,
@@ -74,6 +84,39 @@ const BRIDGE_PID_FILE = ".dsh-bridge.pid";
74
84
  */
75
85
  const DUPLICATE_INSTANCE_EXIT_CODE = 4;
76
86
  const DUPLICATE_BRIDGE_BACKOFF_MS = 5 * 60 * 1000;
87
+ /**
88
+ * 设备绑定「永久性失败」的退避(契约 docs/device-overquota-lock-contract.md §6.3/§6.4)。
89
+ *
90
+ * bridge 遇到服务端 `device_bind_cap`(防滥用硬上限)会写 `.bind-error.json` 并以**专用退出码 5**
91
+ * 退出:这是永久性失败 —— 用户不去手机端解绑就不会好。旧逻辑与普通失败共用 exit(1),
92
+ * watcher 于是每 10s 拉起一次、每 10s 撞一次 409(日志刷屏、白耗一次 device-login 额度)。
93
+ * 现在按 10s→1min→5min→15min 指数退避(封顶 30min);`.bind-error.json` 的**内容签名**变化
94
+ * (换账号 / 解绑后重试得到了不同结论 / 提示被清掉)时复位,用户处理完不必等满退避窗口。
95
+ */
96
+ const DEVICE_BIND_CAP_EXIT_CODE = 5;
97
+ const BIND_CAP_BACKOFF_MS = [10_000, 60_000, 300_000, 900_000];
98
+ const BIND_CAP_BACKOFF_MAX_MS = 30 * 60 * 1000;
99
+ /** bridge 写的「设备登记失败提示」(面板读它展示原因;watcher 用它的签名判断是否需要复位退避)。 */
100
+ const BIND_ERROR_FILE = path.join(CONFIG_DIR, ".bind-error.json");
101
+
102
+ /**
103
+ * `.bind-error.json` 的稳定签名:只取 code/device_id/message,**不含 at** ——
104
+ * bridge 每次重试都会刷新 at,拿它当变化依据等于"永远变化",退避会形同虚设。
105
+ */
106
+ function bindErrorSignature() {
107
+ try {
108
+ const j = JSON.parse(fs.readFileSync(BIND_ERROR_FILE, "utf8")) || {};
109
+ return [j.code || "", j.device_id || "", j.message || ""].join("|");
110
+ } catch { return ""; }
111
+ }
112
+
113
+ /** 第 step 次(从 1 开始)退出码 5 后的退避时长:10s→1min→5min→15min,之后翻倍到封顶 30min。 */
114
+ function bindCapBackoffMs(step) {
115
+ const list = BIND_CAP_BACKOFF_MS;
116
+ const n = Math.max(1, Number(step) || 1);
117
+ if (n <= list.length) return Math.min(BIND_CAP_BACKOFF_MAX_MS, list[n - 1]);
118
+ return BIND_CAP_BACKOFF_MAX_MS;
119
+ }
77
120
 
78
121
  // ---------- 运行时自物化(npm/npx 安装 → 固化到配置目录,脱离 npx 缓存) ----------
79
122
  // npx 每次安装的缓存目录(~/.npm/_npx/<hash>)不固定:缓存一旦清理,指向它的自启动服务
@@ -104,9 +147,9 @@ function findDepWs(startDir) {
104
147
  * 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。
105
148
  * ⚠️ `src/lifecycle.mjs` 也在表里:本安装器**自己**import 它(进程优雅停机的共用实现),
106
149
  * 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
107
- * 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。
150
+ * 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
108
151
  */
109
- const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "src/lifecycle.mjs"];
152
+ const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
110
153
 
111
154
  /**
112
155
  * 把"客户端脚本"补齐到配置目录。
@@ -196,6 +239,9 @@ function printHelp() {
196
239
  自建模式(可选):
197
240
  dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
198
241
 
242
+ 可选参数:
243
+ --ref <标识> 指定接入标识(可选,用于路由/灰度分组)
244
+
199
245
  登录/连接配置: 打开 dsh web → 设置 → 「远程访问」→ 注册或登录手机号即可
200
246
  (自建用户切「自建服务」标签或直接用上方 setup 命令,无需另开页面)。
201
247
  文档: ${REPO_URL}
@@ -204,7 +250,7 @@ function printHelp() {
204
250
 
205
251
  // 认识的参数(只给 flag 也要能装上去,所以不认识时才拦)。
206
252
  const KNOWN_FLAGS = new Set([
207
- "--api", "--server", "--key", "--profile",
253
+ "--api", "--server", "--key", "--profile", "--ref",
208
254
  "--no-autostart", "--no-plugin", "--no-restart", "--uninstall", "--force"
209
255
  ]);
210
256
 
@@ -550,17 +596,55 @@ function schtasks(args, timeoutMs = 15000) {
550
596
  }
551
597
  }
552
598
 
553
- /** 写 pid 文件(Windows 进程发现用;失败不致命,插件半还有 PowerShell 兜底扫描)。 */
599
+ /**
600
+ * 运行期留痕:写/删 pid 文件这类"非关键"动作失败时**必须留一行**(追加到安装日志)。
601
+ *
602
+ * 为什么不再静默 `catch {}`:pid 文件是插件半发现进程的唯一入口(Windows 无 pgrep/ps)。
603
+ * 它一旦"莫名消失",现场能看到的只有「bridge 未运行」——而**为什么**消失(EBUSY / 杀软 /
604
+ * 权限 / 并发截断)一点线索都没有。2026-10 那次排查里,这一层缺失直接把方向带偏。
605
+ * 日志本身写失败也不得影响业务(磁盘满/目录不可写 → 静默放弃)。
606
+ */
607
+ function logSetupIssue(line) {
608
+ try {
609
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
610
+ fs.appendFileSync(path.join(CONFIG_DIR, ".dsh-setup-install.log"),
611
+ `\n[${new Date().toISOString()}] [dsh-setup] ${line}\n`);
612
+ } catch { /* 日志失败不得影响业务 */ }
613
+ }
614
+
615
+ /** 写 pid 文件(Windows 进程发现用;失败不致命,插件半还有 PowerShell 兜底扫描,但必须留痕)。 */
554
616
  function writePidFile(name, pid) {
555
617
  if (!Number.isInteger(pid) || pid <= 0) return;
556
618
  try {
557
619
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
558
620
  fs.writeFileSync(path.join(CONFIG_DIR, name), String(pid));
559
- } catch { /* 非关键 */ }
621
+ } catch (e) {
622
+ logSetupIssue(`写 pid 文件失败(${name},pid=${pid}):${e && (e.code || e.message)}`);
623
+ }
560
624
  }
561
625
 
562
- function removePidFile(name) {
563
- try { fs.rmSync(path.join(CONFIG_DIR, name), { force: true }); } catch { /* 非关键 */ }
626
+ /**
627
+ * 删除 pid 文件。
628
+ *
629
+ * `expectPid`:**只在文件内容等于它时才删**(不传 = 旧语义「无条件删」,仅留给确实要清理
630
+ * 整份残留的场景;本文件 `run()` 里的所有调用点都必须传)。
631
+ *
632
+ * 为什么必须校验归属:pid 文件是"谁持有谁负责"的:父进程无条件删会把**别的实例(或孤儿)**
633
+ * 的 pid 文件删掉,状态立刻变成「进程还活着、pid 文件没了」。2026-10 事故的根因链第 2 步
634
+ * 正是这个:新 bridge 撞本地实例锁秒退 → watcher 的 exit 钩子无条件删 `.dsh-bridge.pid` →
635
+ * 真正持有隧道的孤儿 bridge 从面板上"消失" → 被判成"没运行" → 反复重启 → `bridge_wedged`。
636
+ */
637
+ function removePidFile(name, expectPid = null) {
638
+ const file = path.join(CONFIG_DIR, name);
639
+ try {
640
+ if (expectPid != null) {
641
+ const cur = readPidFile(name);
642
+ if (cur !== expectPid) return; // 不是我们的(或已被别人改写/删除)→ 一个字节都不动
643
+ }
644
+ fs.rmSync(file, { force: true });
645
+ } catch (e) {
646
+ logSetupIssue(`删除 pid 文件失败(${name}):${e && (e.code || e.message)}`);
647
+ }
564
648
  }
565
649
 
566
650
  function readPidFile(name) {
@@ -570,6 +654,22 @@ function readPidFile(name) {
570
654
  } catch { return null; }
571
655
  }
572
656
 
657
+ /**
658
+ * 取某 pid 的**完整命令行**(跨平台)。取不到 → 空串(调用方一律按"不认识这个进程"处理)。
659
+ *
660
+ * Windows 分支是本次修复的关键之一:旧实现只有 `ps -p <pid> -o command=`,而 Windows
661
+ * 没有 `ps`(`sh()` 走 cmd.exe)⇒ `looksLikeWatcher/looksLikeBridge` 在 Windows 上**恒 false**
662
+ * ⇒ 接管/回收上一任进程这两条安全回收路径在 Windows 上**从不生效**。这里改用
663
+ * `windowsProcessCommandLine()`(PowerShell CIM 按 ProcessId 点查,与 bridge 自己
664
+ * `instanceLockVerdict` 用的是同族手法)。
665
+ */
666
+ function processCommandLine(pid) {
667
+ if (!Number.isInteger(pid) || pid <= 0) return "";
668
+ if (IS_WIN) return windowsProcessCommandLine(pid);
669
+ const r = sh(`ps -p ${pid} -o command=`);
670
+ return r.ok ? String(r.stdout || "") : "";
671
+ }
672
+
573
673
  /**
574
674
  * 这个 pid 看起来**真的**是 watcher 吗?
575
675
  *
@@ -579,22 +679,21 @@ function readPidFile(name) {
579
679
  */
580
680
  function looksLikeWatcher(pid) {
581
681
  if (!Number.isInteger(pid) || pid <= 0) return false;
582
- const r = sh(`ps -p ${pid} -o command=`);
583
- return Boolean(r.ok && /dsh-setup\.mjs/.test(r.stdout));
682
+ return /dsh-setup\.mjs/.test(processCommandLine(pid));
584
683
  }
585
684
 
586
685
  /**
587
686
  * 这个 pid 是不是我们的 **bridge 子进程**(不是 watcher、也不是别的 node)。
588
687
  *
589
688
  * 为什么要单独判:接管时我们要回收"上一任遗留的 bridge",而 pid 可能已被系统回收给无关进程 ——
590
- * 对无关进程发 SIGKILL 是不可接受的。所以对 `ps` 出来的命令行做**双重**校验:
689
+ * 对无关进程发 SIGKILL 是不可接受的。所以对命令行做**双重**校验:
591
690
  * 必须同时出现 bridge 脚本名与 clients/dsh-remote 路径片段。
691
+ * (Windows 分支见 `processCommandLine`:Electron 宿主下拉起的 bridge 可能不叫 node.exe,
692
+ * 所以**不能**靠镜像名判断,只能靠命令行。)
592
693
  */
593
694
  function looksLikeBridge(pid) {
594
695
  if (!Number.isInteger(pid) || pid <= 0) return false;
595
- const r = sh(`ps -p ${pid} -o command=`);
596
- if (!r.ok) return false;
597
- const cmd = String(r.stdout || "");
696
+ const cmd = processCommandLine(pid);
598
697
  return /dsh-bridge\.mjs/.test(cmd) && /clients[\\/]dsh-remote/.test(cmd);
599
698
  }
600
699
 
@@ -655,29 +754,60 @@ function isThisRuntimeBridgeCmd(cmd) {
655
754
  return norm(c).includes(norm(needle));
656
755
  }
657
756
 
658
- /** 列出本机属于**本运行时**的 bridge 进程 pid(命令行校验;取不到命令行 → 空数组)。 */
757
+ /**
758
+ * Windows:一次性 PowerShell CIM **全量枚举**进程的 {pid, name, cmd}。
759
+ *
760
+ * 返回值的三态是**契约**(doctor 的"假证据"就是被两态混用坑出来的):
761
+ * · `null` → **扫描不可用**(无 PowerShell / 超时 / 退出码非 0 / JSON 解析失败)
762
+ * · `[]` → 扫描成功,但确实一个进程都没有(或输出为空)
763
+ * · 数组 → 扫描成功,逐项 `{ pid, name, cmd }`
764
+ *
765
+ * 🔴 为什么不按镜像名过滤(旧实现是 `-Filter "Name='node.exe'"`):
766
+ * 桌面端(Electron)宿主起的 watcher 镜像名**一定不是** node.exe
767
+ *(`process.execPath` = 桌面端 exe),bridge 的镜像名则取决于本机有没有真 node
768
+ *(`preferredNode()` 回落 Electron 时为桌面端 exe)。按 node.exe 过滤 ⇒ 这类进程
769
+ * **永远扫不到** ⇒ 活着的孤儿 bridge 清不掉、doctor 恒报 0/0。
770
+ * 全量枚举 + 命令行匹配的代价是 PowerShell 多返回一些行(一次性 8s 上限),换来
771
+ * "与镜像名彻底解耦"——与 bridge 自己的单实例锁判定(按 ProcessId 查 CommandLine)同族手法。
772
+ */
773
+ function windowsCimProcesses() {
774
+ if (!IS_WIN) return null;
775
+ const script = [
776
+ "try { $p = Get-CimInstance Win32_Process }",
777
+ "catch { $p = Get-WmiObject Win32_Process }",
778
+ "$p | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress",
779
+ ].join("; ");
780
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
781
+ try {
782
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
783
+ { encoding: "utf8", timeout: 8000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
784
+ if (r && r.error) return null; // ENOENT(没 PowerShell)等
785
+ if (r && typeof r.status === "number" && r.status !== 0) return null; // 命令失败 ≠ 没有进程
786
+ const raw = String((r && r.stdout) || "").trim();
787
+ if (!raw) return [];
788
+ const parsed = JSON.parse(raw);
789
+ const out = [];
790
+ for (const p of Array.isArray(parsed) ? parsed : [parsed]) {
791
+ const pid = Number(p && p.ProcessId);
792
+ if (!Number.isInteger(pid) || pid <= 0) continue;
793
+ out.push({ pid, name: String((p && p.Name) || ""), cmd: String((p && p.CommandLine) || "") });
794
+ }
795
+ return out;
796
+ } catch { return null; } // 无 PowerShell / 解析失败 → 调用方按"扫描不可用"处理,绝不输出 0
797
+ }
798
+
799
+ /** 列出本机属于**本运行时**的 bridge 进程 pid(命令行校验;扫描不可用/取不到命令行 → 空数组)。 */
659
800
  function listRuntimeBridgePids() {
660
801
  const pids = [];
661
802
  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 / 解析失败 → 不杀任何进程 */ }
803
+ // 全量 CIM 枚举(**不按镜像名过滤**:Electron 宿主拉起的进程可能不叫 node.exe)
804
+ const procs = windowsCimProcesses();
805
+ if (!procs) return pids; // 扫描不可用 → 一个都不杀(失败方向必须是漏杀,不能误杀)
806
+ for (const p of procs) {
807
+ // isThisRuntimeBridgeCmd 的严格性保持不变:命令行必须同时含 dsh-bridge.mjs 与
808
+ // **本运行时**的 clients/dsh-remote 路径(另一个运行时目录的进程不动,避免跨配置目录误杀)
809
+ if (isThisRuntimeBridgeCmd(p.cmd)) pids.push(p.pid);
810
+ }
681
811
  return pids;
682
812
  }
683
813
  const r = sh("pgrep -fl 'dsh-bridge\\.mjs'");
@@ -1774,7 +1904,8 @@ async function runBridge() {
1774
1904
  if (pidAlive(leftover) && looksLikeBridge(leftover)) {
1775
1905
  try { process.kill(leftover, "SIGKILL"); } catch { /* 已退出 */ }
1776
1906
  }
1777
- removePidFile(BRIDGE_PID_FILE);
1907
+ // 只删**刚才确认属于我们的**那个 pid(内容已核对过是 leftover);不无条件删整份文件
1908
+ removePidFile(BRIDGE_PID_FILE, leftover);
1778
1909
  }
1779
1910
  }
1780
1911
  // ── C2:清扫「pid 文件之外的孤儿 bridge」(命令行校验,绝不误杀)──────────────
@@ -1803,6 +1934,12 @@ async function runBridge() {
1803
1934
  let warnedDuplicateBridge = false;
1804
1935
  /** 限流提示只打一次,避免刷屏。 */
1805
1936
  let warnedRateLimit = false;
1937
+ /** 设备绑定硬上限(bridge 退出码 5)的指数退避状态(契约 §6.4)。 */
1938
+ let bindCapBackoffStep = 0;
1939
+ let bindCapBackoffUntil = 0;
1940
+ let bindCapErrorSig = "";
1941
+ /** 退避提示只打一次,避免刷屏。 */
1942
+ let warnedBindCap = false;
1806
1943
 
1807
1944
  // watcher:检测 dsh web 是否存活(端口动态解析,绝不写死 3080),存活才启动 bridge。
1808
1945
  // 走 isDshWebUp():探测失败会自动做一次动态发现(DSH Desktop 的 43120 / 自定义端口都靠它)。
@@ -1863,6 +2000,7 @@ async function runBridge() {
1863
2000
  await stopChildGracefully(bridgeProc); // 先请它优雅退出,跑完微信通道下线通知
1864
2001
  bridgeAccountFp = "";
1865
2002
  duplicateBridgeBackoffUntil = 0; // 用户明确换了账号:清掉"让位"暂停,立即用新凭据拉起
2003
+ bindCapBackoffStep = 0; bindCapBackoffUntil = 0; bindCapErrorSig = ""; // 换账号 = 绑定前提已变:设备绑定退避一并复位
1866
2004
  return; // 下一轮(10s 内)用新配置重新拉起
1867
2005
  }
1868
2006
  // 指纹又回到一致(或本来就没变)→ 之前的"疑似变化"作废,绝不让它跨轮累积。
@@ -1891,6 +2029,28 @@ async function runBridge() {
1891
2029
  }
1892
2030
  warnedDuplicateBridge = false;
1893
2031
 
2032
+ // 设备绑定硬上限(bridge 退出码 5):永久性失败 → 指数退避,别每 10s 空撞一次。
2033
+ // 复位:`.bind-error.json` 的内容签名变了(换账号 / 用户解绑后重试得到不同结论 / 提示被清掉)。
2034
+ // 提示只打一次;退避窗口内直接返回、不拉起 bridge(也不占用一次 device-login 额度)。
2035
+ const bindSig = bindErrorSignature();
2036
+ if (bindSig !== bindCapErrorSig) {
2037
+ if (bindCapBackoffUntil > Date.now()) {
2038
+ console.log("[dsh-remote] 设备登记失败提示已变化(解绑/换账号/服务端结论变了)→ 重置设备绑定退避,立即重试。");
2039
+ }
2040
+ bindCapErrorSig = bindSig;
2041
+ bindCapBackoffStep = 0;
2042
+ bindCapBackoffUntil = 0;
2043
+ }
2044
+ if (Date.now() < bindCapBackoffUntil) {
2045
+ if (!warnedBindCap) {
2046
+ warnedBindCap = true;
2047
+ console.log(`[dsh-remote] 设备绑定已达上限(bridge code=${DEVICE_BIND_CAP_EXIT_CODE})→ 本守护暂停拉起约 ${Math.ceil((bindCapBackoffUntil - Date.now()) / 1000)} 秒(指数退避,最长 30 分钟)。`);
2048
+ console.log(" 请在手机端「管理电脑」解绑不再使用的设备;解绑后会自动恢复,无需重启。");
2049
+ }
2050
+ return;
2051
+ }
2052
+ warnedBindCap = false;
2053
+
1894
2054
  reconcileUpstreamEnvFromFile(); // dsh 可能刚重启并换了端口:拉起前再以最新端口文件为准
1895
2055
  const alive = await checkUpstream();
1896
2056
  if (alive && childStopped(bridgeProc)) {
@@ -1933,8 +2093,16 @@ async function runBridge() {
1933
2093
  // POSIX 上该选项被忽略。
1934
2094
  windowsHide: true
1935
2095
  });
2096
+ // 记下这个子进程的 pid:退出钩子删 pid 文件时必须核对归属(见 removePidFile 注释)。
2097
+ // 为什么不能直接用 bridgeProc.pid:bridgeProc 之后会被下一次 spawn 覆盖,而旧子进程的
2098
+ // exit 回调仍会跑 —— 那时若去删"当前"pid 文件,就会把新实例的 pid 文件删掉。
2099
+ const spawnedBridgePid = bridgeProc.pid;
1936
2100
  bridgeProc.on("exit", (code) => {
1937
- removePidFile(BRIDGE_PID_FILE);
2101
+ // ⚠️ 只删**属于这个子进程**的 pid 文件(内容 == 它的 pid)。
2102
+ // 旧实现无条件删:新 bridge 撞本地实例锁秒退(code=4)是**常态事件**,每次都把
2103
+ // 真正活着的那条 bridge(孤儿)的 pid 文件删掉 → 面板「bridge 未运行」→ 反复重启
2104
+ // → bridge_wedged。这是 2026-10 事故根因链的第 2 步。
2105
+ removePidFile(BRIDGE_PID_FILE, spawnedBridgePid);
1938
2106
  // C2 互踢根治的 watcher 半边:bridge 连续 2 次收到 code=4000(= 本机已有同 deviceId 的
1939
2107
  // 另一个 bridge 把本连接顶掉)后会以"重复实例"退出码优雅退出(见 dsh-bridge.mjs)。
1940
2108
  // 这种退出**绝不能**照常 10s 重拉:本机已经有另一个实例在跑,重拉只会继续乒乓互踢。
@@ -1954,6 +2122,24 @@ async function runBridge() {
1954
2122
  console.log(`[dsh-remote] bridge 检测到同 deviceId 的另一个实例并退出(code=${code})——本守护暂停 ${Math.round(DUPLICATE_BRIDGE_BACKOFF_MS / 60000)} 分钟后再试,避免两个实例反复互踢。`);
1955
2123
  return;
1956
2124
  }
2125
+ // 设备绑定硬上限(退出码 5,契约 §6.3/§6.4):永久性失败 → 指数退避重试,
2126
+ // 绝不沿用"10s 后照常重拉"(那会每 10s 撞一次 409,日志刷屏且白耗登录额度)。
2127
+ if (code === DEVICE_BIND_CAP_EXIT_CODE) {
2128
+ const sig = bindErrorSignature();
2129
+ if (sig && sig !== bindCapErrorSig) {
2130
+ bindCapErrorSig = sig;
2131
+ bindCapBackoffStep = 1;
2132
+ console.log("[dsh-remote] 设备绑定失败提示已变化 → 重置设备绑定退避。");
2133
+ } else {
2134
+ bindCapBackoffStep = Math.min(bindCapBackoffStep + 1, 99);
2135
+ }
2136
+ const waitMs = bindCapBackoffMs(bindCapBackoffStep);
2137
+ bindCapBackoffUntil = Date.now() + waitMs;
2138
+ warnedBindCap = false;
2139
+ console.log(`[dsh-remote] bridge 因设备绑定达到硬上限退出(code=${code},第 ${bindCapBackoffStep} 次)——本守护 ${Math.round(waitMs / 1000)} 秒后再试(指数退避,最长 30 分钟)。`);
2140
+ console.log(" 请在手机端「管理电脑」解绑不再使用的设备;解绑后自动恢复,无需重启。");
2141
+ return;
2142
+ }
1957
2143
  console.log("[dsh-remote] bridge 退出,等待重启...");
1958
2144
  });
1959
2145
  // Windows:落 pid 文件,供插件半发现进程(Windows 没有 pgrep/ps)。
@@ -1972,7 +2158,18 @@ async function runBridge() {
1972
2158
  // watcher 自身的 pid 也要落盘(插件半据此判断"守护在跑"并避免重复拉起)
1973
2159
  {
1974
2160
  writePidFile(WATCHER_PID_FILE, process.pid);
1975
- const cleanup = () => { removePidFile(WATCHER_PID_FILE); removePidFile(BRIDGE_PID_FILE); };
2161
+ /**
2162
+ * 退出清理:**只删属于自己的 pid 文件**。
2163
+ * watcher 文件属于本进程;bridge 文件属于**本进程拉起的那个子进程**。
2164
+ * 无条件删(旧行为)会把孤儿/别的实例的 pid 文件删掉 → 状态变成「进程还活着、pid 文件没了」,
2165
+ * 正是本次事故根因链的第 2 步;失败方向必须是"残留一份过期文件"(插件按 pidAlive 会判掉),
2166
+ * 而不是"删掉别人活着的证据"。
2167
+ */
2168
+ const cleanup = () => {
2169
+ removePidFile(WATCHER_PID_FILE, process.pid);
2170
+ const childPid = bridgeProc && bridgeProc.pid;
2171
+ if (Number.isInteger(childPid) && childPid > 0) removePidFile(BRIDGE_PID_FILE, childPid);
2172
+ };
1976
2173
  process.on("exit", cleanup);
1977
2174
  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]) {
1978
2175
  try {
@@ -2022,6 +2219,11 @@ async function setup(argv) {
2022
2219
  const selfHosted = Boolean(server || key);
2023
2220
 
2024
2221
  let cfg = loadConfig();
2222
+ // 接入标识(可选):本次带合法 `--ref <数字>`(或环境变量 DSH_REF)才写入;没带/非法 →
2223
+ // **保留**配置里已有的值。⚠️ saveConfig 是整对象覆盖写,所以必须在刚 loadConfig() 的这份
2224
+ // 对象上合并再写回 —— 绝不能新建对象,否则升级装一次就把上一次的归因清空了。
2225
+ // 非法值静默忽略:归因是附加信息,绝不能因此让安装失败。
2226
+ cfg = applyRefCodeToConfig(cfg, argv);
2025
2227
 
2026
2228
  if (selfHosted) {
2027
2229
  if (!server || !key) {
@@ -3252,7 +3454,11 @@ async function doctorCmd(argv = []) {
3252
3454
  };
3253
3455
 
3254
3456
  console.log("dsh-remote 自检(doctor,只读,不改任何东西)");
3255
- console.log("时间:", new Date().toISOString(), "| 平台:", `${process.platform}/${process.arch}`, "| node:", process.version);
3457
+ // 平台:若被 DSH_SETUP_PLATFORM 覆盖(仅测试用),如实标注 —— 否则"平台 darwin 却走了 win32 扫描"会非常难解释
3458
+ const platformLabel = SETUP_PLATFORM === process.platform
3459
+ ? `${process.platform}/${process.arch}`
3460
+ : `${SETUP_PLATFORM}/${process.arch}(DSH_SETUP_PLATFORM 覆盖;实际 ${process.platform})`;
3461
+ console.log("时间:", new Date().toISOString(), "| 平台:", platformLabel, "| node:", process.version);
3256
3462
  console.log("配置目录:", CONFIG_DIR);
3257
3463
 
3258
3464
  // ── ① 插件与运行环境版本 ────────────────────────────────────────────────
@@ -3284,20 +3490,55 @@ async function doctorCmd(argv = []) {
3284
3490
  const bridgePid = readPidFile(BRIDGE_PID_FILE);
3285
3491
  console.log(` watcher pid : ${watcherPid || "-"}${watcherPid ? (alive(watcherPid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
3286
3492
  console.log(` bridge pid : ${bridgePid || "-"}${bridgePid ? (alive(bridgePid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
3287
- // 进程扫描(平台各自的方式):只报数量,不猜
3493
+ // 进程扫描(平台各自的方式):只报**扫描拿到的真实数据**;扫不到就说"扫描不可用"。
3494
+ //
3495
+ // 🔴 旧实现是本次「假证据」的直接来源:Windows 侧用
3496
+ // `tasklist /FI "IMAGENAME eq node.exe" /FO CSV` 拿镜像名列表,再用
3497
+ // /dsh-setup\.mjs/、/dsh-bridge\.mjs/ 去过滤 **CSV 行** —— 而 tasklist 的输出里
3498
+ // **根本没有命令行**,于是两个计数在 Windows 上**恒为 0/0**,与真实情况毫无关系。
3499
+ // 2026-10 那次排查正是被这条"证据"带向「进程真的没起来」的错误方向。
3500
+ // 现在:Windows 复用 CIM 全量枚举(windowsCimProcesses,**带 CommandLine、不按镜像名过滤**),
3501
+ // 按命令行分别统计 watcher / bridge,并打出 pid + 解释器名 + 命令行摘要;
3502
+ // 扫描不可用(没 PowerShell / 超时 / 命令失败)时**显式打印「扫描不可用」**,
3503
+ // 绝不输出一个会被误读成"没有进程"的 0。
3288
3504
  try {
3289
- const r = process.platform === "win32"
3290
- ? sh("tasklist /FI \"IMAGENAME eq node.exe\" /FO CSV 2>NUL")
3291
- : sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs' 2>/dev/null");
3292
- const lines = String(r.stdout || "").split("\n").map((l) => l.trim()).filter((l) => l && !/pgrep/.test(l));
3293
- const watchers = lines.filter((l) => /dsh-setup\.mjs/.test(l));
3294
- const bridges = lines.filter((l) => /dsh-bridge\.mjs/.test(l)).filter((l) => !/dsh-setup/.test(l));
3295
- console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个`);
3296
- if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
3297
- if (!watchers.length && !bridges.length) {
3298
- console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
3299
- }
3300
- } catch (e) { console.log(` 实测进程 : 扫描失败(${e.message})`); }
3505
+ if (IS_WIN) {
3506
+ const procs = windowsCimProcesses();
3507
+ if (!procs) {
3508
+ console.log(" 实测进程 : 扫描不可用(PowerShell/CIM 取不到进程列表:可能没装 PowerShell、被策略禁用或超时)");
3509
+ console.log(" ⚠️ 这条**不能**当作「进程没在跑」的证据 —— 只看上面的 pid 文件结论与日志尾部。");
3510
+ } else {
3511
+ const watchers = procs.filter((p) => /dsh-setup\.mjs/.test(p.cmd));
3512
+ const bridges = procs.filter((p) => /dsh-bridge\.mjs/.test(p.cmd)).filter((p) => !/dsh-setup/.test(p.cmd));
3513
+ console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个(CIM 全量扫描,按命令行识别)`);
3514
+ for (const [kind, list] of [["watcher", watchers], ["bridge", bridges]]) {
3515
+ for (const p of list) {
3516
+ const brief = p.cmd.length > 160 ? `${p.cmd.slice(0, 157)}...` : p.cmd;
3517
+ console.log(` · ${kind} pid=${p.pid} 解释器=${p.name || "?"} 命令行=${brief || "(取不到)"}`);
3518
+ }
3519
+ }
3520
+ if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
3521
+ if (!watchers.length && !bridges.length) {
3522
+ console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
3523
+ }
3524
+ }
3525
+ } else {
3526
+ const r = sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs' 2>/dev/null");
3527
+ if (!r.ok && r.code !== 1) {
3528
+ // pgrep 退出码 1 = "没有匹配的进程"(合法的 0),其它码 = 工具不可用(不能当 0 读)
3529
+ console.log(` 实测进程 : 扫描不可用(pgrep 退出码 ${r.code}:本机没有 pgrep 或被禁用)`);
3530
+ } else {
3531
+ const lines = String(r.stdout || "").split("\n").map((l) => l.trim()).filter((l) => l && !/pgrep/.test(l));
3532
+ const watchers = lines.filter((l) => /dsh-setup\.mjs/.test(l));
3533
+ const bridges = lines.filter((l) => /dsh-bridge\.mjs/.test(l)).filter((l) => !/dsh-setup/.test(l));
3534
+ console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个`);
3535
+ if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
3536
+ if (!watchers.length && !bridges.length) {
3537
+ console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
3538
+ }
3539
+ }
3540
+ }
3541
+ } catch (e) { console.log(` 实测进程 : 扫描不可用(${e.message})`); }
3301
3542
  if (process.platform === "linux") {
3302
3543
  const hasUser = Boolean(process.env.XDG_RUNTIME_DIR);
3303
3544
  console.log(` 自启动 : systemd --user ${hasUser ? "(有 runtime dir)" : "(无 XDG_RUNTIME_DIR → 本机没有 user systemd,插件会用后台进程兜底)"}`);
@@ -3446,6 +3687,11 @@ else if (cmd === "status") {
3446
3687
  console.log("配置文件:", CONFIG_PATH);
3447
3688
  console.log("连接模式:", local ? `自建服务(${cfg.tunnel_url || "未设置服务器地址"})` : `SaaS 云端服务(${cfg.phone || "未配置账号"})`);
3448
3689
  console.log("API:", cfg.api_url || (local ? "(自建模式无需账号 API)" : DEFAULT_API));
3690
+ // 接入标识(安装时 --ref 写入):运维自查用;没设置就不打这一行,保持默认输出稳定。
3691
+ {
3692
+ const refCode = normalizeRefCode(cfg.ref_code);
3693
+ if (refCode) console.log("接入标识:", refCode);
3694
+ }
3449
3695
  console.log("远程地址/登录: 打开 dsh web → 设置 → 「远程访问」查看与操作");
3450
3696
  // 插件挂载诊断:报障时一眼看出是"链接坏了"还是"别的毛病"
3451
3697
  const profileDir = resolveProfileDir([]);