@mrrisega/dsh-remote 0.6.6 → 0.6.7-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.
@@ -21,15 +21,65 @@
21
21
  //
22
22
  // 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
23
23
  import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, statSync, constants as fsConstants } from "node:fs";
24
- import { join, dirname, sep } from "node:path";
25
- import { execSync, spawn } from "node:child_process";
24
+ import { join, dirname, sep, delimiter } from "node:path";
25
+ import { execSync, spawn, spawnSync } from "node:child_process";
26
26
  import { randomUUID } from "node:crypto";
27
- import { homedir, platform } from "node:os";
27
+ import { homedir } from "node:os";
28
28
  import { fileURLToPath } from "node:url";
29
29
 
30
30
  /** 本插件在 host 侧的服务依赖。 */
31
31
  export const inject = ["webServer"];
32
32
 
33
+ // ---------- 平台抽象层(0.6.7:Windows 支持) ----------
34
+ //
35
+ // 为什么单独抽一层:本插件整套「服务状态 / 启停」原本按 macOS/Linux 写死
36
+ // (launchctl / systemctl / pgrep / ps / /bin/sh),在 Windows 上运行期直接炸:
37
+ // · process.getuid() 在 Windows 不存在(不是返回 undefined,是**没有这个函数**)
38
+ // → /dsh-remote/status 与 /bridge-status 双双 500,面板永久停在「查询中…」;
39
+ // · spawn("npx.cmd") 不带 shell(Node ≥20.12 起)同步抛 EINVAL → 运行环境永远补不上;
40
+ // · spawn("/bin/sh") 在 Windows 报 ENOENT,且当时没有 'error' 监听 → 未捕获异常打挂 dsh web。
41
+ // 下面所有平台判断一律走 osPlatform(),绝不直接读 process.platform——这样测试可以用
42
+ // DSH_RELAY_PLATFORM=win32 在 macOS/Linux 开发机上把 Windows 分支真正跑一遍(见 test/windows-compat.test.mjs)。
43
+
44
+ /**
45
+ * 当前运行平台(win32/darwin/linux/…)。
46
+ * DSH_RELAY_PLATFORM 仅供测试/诊断模拟其它平台,生产环境不设置;调用时读取(非模块加载时固定)。
47
+ */
48
+ function osPlatform() {
49
+ return String(process.env.DSH_RELAY_PLATFORM || "").trim() || process.platform;
50
+ }
51
+ /** 是否 Windows:无 launchd/systemd、无 pgrep/ps、无 /bin/sh、npx 是 .cmd。 */
52
+ function isWindows() {
53
+ return osPlatform() === "win32";
54
+ }
55
+ function isDarwin() {
56
+ return osPlatform() === "darwin";
57
+ }
58
+
59
+ /**
60
+ * 当前进程 uid。**Windows 没有 process.getuid**(typeof !== "function"),
61
+ * 旧实现无条件调用它,导致所有读状态的接口 500。取不到一律返回 null,
62
+ * 调用方按「本平台无 launchd domain」处理。
63
+ */
64
+ function currentUid() {
65
+ return typeof process.getuid === "function" ? process.getuid() : null;
66
+ }
67
+
68
+ /** 同步睡眠(不引第三方依赖;Windows 没有 `sleep` 命令)。 */
69
+ function sleepSync(ms) {
70
+ try {
71
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
72
+ } catch {
73
+ const end = Date.now() + ms;
74
+ while (Date.now() < end) { /* 极端环境退化为忙等 */ }
75
+ }
76
+ }
77
+
78
+ /** quote 成 POSIX shell 单引号字面量(仅 darwin/linux 的 sh 调用用)。 */
79
+ function shQuote(s) {
80
+ return "'" + String(s).replace(/'/g, `'\\''`) + "'";
81
+ }
82
+
33
83
  /** 默认配置目录(可被 entry config 的 relayDir / DSH_RELAY_DIR 环境变量覆盖)。 */
34
84
  const DEFAULT_RELAY_DIR = process.env.DSH_RELAY_DIR || join(homedir(), ".dsh-remote");
35
85
  // 默认云端服务地址(SaaS 入口;自建用户在设置页/面板切换)
@@ -76,15 +126,45 @@ function preferredNode() {
76
126
  const NODE_BIN = preferredNode();
77
127
 
78
128
  /**
79
- * 解析 npx 绝对路径。DeepSeek App 拉起 dsh web 时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin
129
+ * 解析 npx 的调用方式。DeepSeek App 拉起 dsh web 时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin
80
130
  * (没有 /opt/homebrew/bin 等),裸 `npx` 会 spawn ENOENT 而静默失败——必须按绝对路径找,
81
131
  * 且子进程 env 的 PATH 要把当前 node 所在目录补在最前(npx 的 #!/usr/bin/env node 依赖它)。
82
- */
83
- function npxCommand() {
84
- const name = process.platform === "win32" ? "npx.cmd" : "npx";
132
+ *
133
+ * 【0.6.7 Windows 修复】Windows 上 npx 是批处理 npx.cmd:
134
+ * · Node ≥20.12(CVE-2024-27980 的修复)起,`spawn("npx.cmd")` 不带 shell 会**同步抛 EINVAL**,
135
+ * 于是运行环境永远补不上(生产遥测里 install_failed/fail_code=npx_cmd_unavailable 全在 win32);
136
+ * · 首选「用当前 node 直接跑 npm 自带的 npx-cli.js」——完全不经过 cmd.exe,没有空格/引号/
137
+ * 中文路径陷阱,也不需要 shell:true;
138
+ * · 找不到 npx-cli.js 才退回 npx.cmd + shell:true(参数是常量,无注入面)。
139
+ * @returns {{command:string, args:string[], shell:boolean, kind:string}}
140
+ * args 是前置参数(如 npx-cli.js 的路径),调用方接上自己的参数即可。
141
+ */
142
+ function npxInvocation() {
143
+ // 显式覆盖优先(测试注入假 npx;生产通常不设)
144
+ const overrideDir = process.env.DSH_SETUP_NPX_DIR || "";
145
+ const nodeDir = dirname(process.execPath);
146
+ if (isWindows()) {
147
+ // npm 自带 CLI 的位置:Node 官方 Windows 安装在 <node>\node_modules\npm\bin\npx-cli.js,
148
+ // 也有把 npm 放在同级 lib\node_modules 下的布局,一并尝试。
149
+ const cliCandidates = [
150
+ ...(overrideDir ? [join(overrideDir, "node_modules", "npm", "bin", "npx-cli.js")] : []),
151
+ join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js"),
152
+ join(nodeDir, "lib", "node_modules", "npm", "bin", "npx-cli.js"),
153
+ ];
154
+ for (const p of cliCandidates) {
155
+ if (existsSync(p)) return { command: process.execPath, args: [p], shell: false, kind: "node-npx-cli" };
156
+ }
157
+ // 退回 .cmd:Windows 上**必须** shell:true,否则 EINVAL(这是 0.6.6 及更早的死因)
158
+ const cmdDirs = [overrideDir, nodeDir, process.env.APPDATA ? join(process.env.APPDATA, "npm") : ""].filter(Boolean);
159
+ for (const d of cmdDirs) {
160
+ const real = resolveExecutable(join(d, "npx.cmd"));
161
+ if (real) return { command: real, args: [], shell: true, kind: "npx-cmd" };
162
+ }
163
+ return { command: "npx.cmd", args: [], shell: true, kind: "npx-cmd-bare" };
164
+ }
85
165
  const dirs = [
86
- process.env.DSH_SETUP_NPX_DIR || "", // 显式覆盖优先(测试注入假 npx;生产通常不设)
87
- dirname(process.execPath), // 与当前 node 同目录(homebrew/usr/local 均可覆盖)
166
+ overrideDir,
167
+ nodeDir, // 与当前 node 同目录(homebrew/usr/local 均可覆盖)
88
168
  "/opt/homebrew/bin",
89
169
  "/usr/local/bin",
90
170
  "/opt/homebrew/opt/node@20/bin",
@@ -92,20 +172,76 @@ function npxCommand() {
92
172
  "/usr/bin",
93
173
  ].filter(Boolean);
94
174
  for (const d of dirs) {
95
- const real = resolveExecutable(join(d, name));
96
- if (real) return real;
175
+ const real = resolveExecutable(join(d, "npx"));
176
+ if (real) return { command: real, args: [], shell: false, kind: "npx" };
97
177
  }
98
- return name; // 全找不到 → 退回裸名(普通 shell 场景仍可用)
178
+ return { command: "npx", args: [], shell: false, kind: "npx-bare" }; // 全找不到 → 退回裸名(普通 shell 场景仍可用)
99
179
  }
100
180
 
101
181
  /** 子进程环境:把 node 目录补进 PATH(npx 及其 shebang 需要),可附加额外变量。 */
102
182
  function spawnEnv(extra) {
103
183
  const nodeDir = dirname(process.execPath);
104
184
  const base = process.env.PATH || "";
105
- const PATH = [nodeDir, base, "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"].filter(Boolean).join(":");
185
+ // PATH 分隔符必须按平台取(Windows 是 ";",POSIX 是 ":"):写死 ":" 会把整条 PATH
186
+ // 在 Windows 上拼成一个不存在的路径,node/npx 全部找不到。
187
+ // 追加的 POSIX 目录只在类 Unix 上有意义,Windows 上不追加。
188
+ const posixDirs = isWindows() ? [] : ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"];
189
+ const PATH = [nodeDir, base, ...posixDirs].filter(Boolean).join(delimiter);
106
190
  return { ...process.env, PATH, ...(extra || {}) };
107
191
  }
108
192
 
193
+ /**
194
+ * 派生后台子进程并**始终挂 'error' 监听**。
195
+ *
196
+ * 背景(Windows 实测致命):没有 'error' 监听器的 ChildProcess,一旦启动失败
197
+ * (ENOENT/EINVAL/EPERM)就会把 error 事件抛成**未捕获异常**,直接打挂宿主 dsh web。
198
+ * 面板的「重启 DeepSeek harness」按钮就是这么把 dsh web 打死的(spawn("/bin/sh") 在 Windows ENOENT)。
199
+ *
200
+ * @returns {{child: import("node:child_process").ChildProcess|null, error: Error|null}}
201
+ * error 非空 = 同步启动失败(参数非法/被策略拒绝);异步失败由 onError 回调兜底。
202
+ */
203
+ function safeSpawn(command, args, opts = {}) {
204
+ const { onError, ...spawnOpts } = opts;
205
+ let child;
206
+ try {
207
+ child = spawn(command, args, spawnOpts);
208
+ } catch (e) {
209
+ return { child: null, error: e };
210
+ }
211
+ // 兜底监听:既用于上报,也用于「吞掉」本可打挂宿主的未捕获 error 事件。
212
+ child.on("error", (e) => {
213
+ try { onError?.(e); } catch { /* 回调自身出错不得影响宿主 */ }
214
+ });
215
+ return { child, error: null };
216
+ }
217
+
218
+ /**
219
+ * 派生后台子进程并等它「真的起来了」再返回。
220
+ * 异步启动失败(ENOENT)不抛异常、只在下一个 tick 发 'error' 事件——不等就会误报成功
221
+ * (旧实现的「已调度重启」就是在 spawn 失败的情况下照样返回成功)。
222
+ * @returns {Promise<{child, error}>}
223
+ */
224
+ function spawnAndConfirm(command, args, opts = {}) {
225
+ return new Promise((resolve) => {
226
+ let child;
227
+ try {
228
+ child = spawn(command, args, opts);
229
+ } catch (e) {
230
+ resolve({ child: null, error: e });
231
+ return;
232
+ }
233
+ let settled = false;
234
+ const settle = (error) => {
235
+ if (settled) return;
236
+ settled = true;
237
+ resolve({ child, error });
238
+ };
239
+ child.once("error", (e) => settle(e));
240
+ child.once("spawn", () => settle(null));
241
+ child.on("error", () => { /* 已 settle 之后的 error 只吞掉,绝不外抛 */ });
242
+ });
243
+ }
244
+
109
245
  // ---------- 后台子进程标记(防重入 + 宿主重启自愈) ----------
110
246
  // marker 内容 = JSON {pid, at}:pid 供“宿主重启后立即清理死进程残留”判断;
111
247
  // 兼容旧格式(纯时间戳数字 → 只按超时清理)。
@@ -369,12 +505,21 @@ function skipsSystemOps() {
369
505
  // ---------- bridge 服务状态 / 启停(launchctl,macOS) ----------
370
506
 
371
507
  function launchAgentPath() {
372
- if (platform() === "darwin") return join(homedir(), "Library/LaunchAgents/com.dshremote.bridge.plist");
508
+ if (isDarwin()) return join(homedir(), "Library/LaunchAgents/com.dshremote.bridge.plist");
373
509
  return null;
374
510
  }
375
511
 
512
+ /**
513
+ * launchd 作业 target(`gui/<uid>/<label>`)。
514
+ * **Windows/Linux 上没有 launchctl、也没有 process.getuid**——旧实现无条件调
515
+ * `process.getuid()`,在 Windows 上直接 TypeError(这正是面板两个读状态接口 500 的根因)。
516
+ * 现在:非 macOS 或取不到 uid 一律返回 null,调用方必须先判空。
517
+ */
376
518
  function launchTarget() {
377
- return `gui/${process.getuid()}/com.dshremote.bridge`;
519
+ if (!isDarwin()) return null;
520
+ const uid = currentUid();
521
+ if (uid === null) return null;
522
+ return `gui/${uid}/com.dshremote.bridge`;
378
523
  }
379
524
 
380
525
  /**
@@ -383,7 +528,7 @@ function launchTarget() {
383
528
  * 【0.6.2 关键修复】`launchctl print` 只要成功,它的 `state` 就是权威,**不得**再回退
384
529
  * `launchctl list`。原因:plist 指向不存在的入口脚本时,KeepAlive 会让作业陷入
385
530
  * 「秒退→立刻重拉」的崩溃循环,而在重拉的瞬间 `launchctl list` 的 PID 列会闪现一个
386
- * **已经死掉**的 pid(本机实测 `launchctl list` = `40213 1 com.dshremote.bridge`,
531
+ * **已经死掉**的 pid(实测 `launchctl list` = `40213 1 com.dshremote.bridge`,
387
532
  * 同时 `ps -p 40213` 为空、`launchctl print` = `state = spawn scheduled`)。
388
533
  * 旧实现据此谎报 running=true,后果有二:
389
534
  * 1) 面板显示「运行中」,用户以为 bridge 在跑(实际从未注册成功);
@@ -392,7 +537,11 @@ function launchTarget() {
392
537
  * 仅在 print 本身不可用时才回退 list,且回退路径要求 pid 真实存活。
393
538
  */
394
539
  function launchdStatus() {
540
+ // 非 macOS 没有 launchctl(Windows 上连 process.getuid 都没有):直接返回「无此服务」,
541
+ // 不再去 shell 里调一个不存在的命令——这既避免 500,也避免每次轮询白起一个 cmd.exe。
542
+ if (!isDarwin()) return { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
395
543
  const target = launchTarget();
544
+ if (!target) return { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
396
545
  const pr = sh(`launchctl print ${target}`);
397
546
  if (pr.ok) {
398
547
  const stateMatch = pr.stdout.match(/state\s*=\s*([^\n]+)/);
@@ -419,8 +568,109 @@ function launchdStatus() {
419
568
  return { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
420
569
  }
421
570
 
571
+ // ---------- Windows:bridge 进程发现(0.6.7) ----------
572
+ //
573
+ // Windows 没有 pgrep/ps/launchctl,而 bridge 在 Windows 上也不由服务管理器托管
574
+ // (dsh-setup.mjs 明确不生成 Windows 自启动)。所以进程发现按「pid 文件优先、PowerShell 兜底」两级:
575
+ // ① pid 文件:插件自己 spawn 的 watcher、以及 `dsh-remote run` 跑起来的 watcher/bridge
576
+ // 都会把 pid 落到配置目录(见 dsh-setup.mjs 的 writePidFile)。读文件 + process.kill(pid,0)
577
+ // 判活,零 shell 开销——面板 2~3s 轮询一次也扛得住。
578
+ // ② PowerShell 扫 node.exe 的命令行:兼容「用户跑的是没写 pid 文件的旧版运行时」。
579
+ // 这条路径要起一个 powershell.exe(数百毫秒),故结果带 TTL 缓存,绝不被轮询打爆。
580
+
581
+ const WATCHER_PID_FILE = ".dsh-watcher.pid";
582
+ const BRIDGE_PID_FILE = ".dsh-bridge.pid";
583
+ /** Windows 自启动任务名(与 dsh-setup.mjs 的 WIN_TASK_NAME 必须一致)。 */
584
+ const WIN_TASK_NAME = "dsh-remote-bridge";
585
+ const WIN_SCAN_TTL_MS = 5000;
586
+
587
+ /** 读 pid 文件(内容为纯数字 pid;缺失/损坏返回 null)。 */
588
+ function readPidFile(relayDir, name) {
589
+ try {
590
+ const n = Number(String(readFileSync(join(relayDir, name), "utf8")).trim());
591
+ return Number.isInteger(n) && n > 0 ? n : null;
592
+ } catch {
593
+ return null;
594
+ }
595
+ }
596
+
597
+ /**
598
+ * 用 PowerShell 列出所有 node.exe 的 pid/父 pid/命令行。
599
+ * 走 -EncodedCommand(UTF-16LE base64):命令行里带引号/中文/空格时不会被 PowerShell 自己的
600
+ * 引号解析吃掉,也不需要经过 shell。Get-CimInstance 不可用时回退 Get-WmiObject(Win7/精简系统)。
601
+ */
602
+ let winProcScanCache = { at: 0, procs: [] };
603
+ function windowsNodeProcesses() {
604
+ if (Date.now() - winProcScanCache.at < WIN_SCAN_TTL_MS) return winProcScanCache.procs;
605
+ const script = [
606
+ "try { $p = Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" }",
607
+ "catch { $p = Get-WmiObject Win32_Process -Filter \"Name='node.exe'\" }",
608
+ "$p | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress",
609
+ ].join("; ");
610
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
611
+ let procs = [];
612
+ try {
613
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
614
+ encoding: "utf8", timeout: 8000, windowsHide: true, maxBuffer: 8 * 1024 * 1024,
615
+ });
616
+ const raw = String(r.stdout || "").trim();
617
+ if (raw) {
618
+ const parsed = JSON.parse(raw);
619
+ for (const p of (Array.isArray(parsed) ? parsed : [parsed])) {
620
+ const pid = Number(p && p.ProcessId);
621
+ if (!Number.isInteger(pid) || pid <= 0) continue;
622
+ procs.push({
623
+ pid,
624
+ ppid: Number(p.ParentProcessId) || null,
625
+ commandLine: String((p && p.CommandLine) || ""),
626
+ });
627
+ }
628
+ }
629
+ } catch { procs = []; } // 无 PowerShell / 解析失败 → 静默降级为空(不阻断状态查询)
630
+ winProcScanCache = { at: Date.now(), procs };
631
+ return procs;
632
+ }
633
+
634
+ /**
635
+ * Windows 登录任务(任务计划程序)是否已注册——由 dsh-setup.mjs 安装/一键更新时注册,
636
+ * 插件只读不写。带 60s 缓存:面板 2~3s 轮询一次状态,不能每轮都起一个 schtasks。
637
+ */
638
+ let winTaskCache = { at: 0, ok: false };
639
+ function windowsTaskRegistered() {
640
+ if (Date.now() - winTaskCache.at < 60_000) return winTaskCache.ok;
641
+ let ok = false;
642
+ try {
643
+ const r = spawnSync("schtasks", ["/Query", "/TN", WIN_TASK_NAME], { windowsHide: true, encoding: "utf8", timeout: 8000 });
644
+ ok = r.status === 0;
645
+ } catch { ok = false; }
646
+ winTaskCache = { at: Date.now(), ok };
647
+ return ok;
648
+ }
649
+
650
+ /** Windows 版 manualStatus:pid 文件优先,PowerShell 兜底。语义与 POSIX 版一致。 */
651
+ function windowsManualStatus(relayDir) {
652
+ const watcher = [];
653
+ const bridge = [];
654
+ const allBridge = [];
655
+ const push = (arr, pid) => { if (pid && !arr.includes(pid)) arr.push(pid); };
656
+ const wPid = readPidFile(relayDir, WATCHER_PID_FILE);
657
+ const bPid = readPidFile(relayDir, BRIDGE_PID_FILE);
658
+ if (wPid && pidAlive(wPid) !== false && wPid !== process.pid) push(watcher, wPid);
659
+ if (bPid && pidAlive(bPid) !== false && bPid !== process.pid) { push(bridge, bPid); push(allBridge, bPid); }
660
+ // 兜底扫描只在 pid 文件一无所获时进行:避免每次轮询都起一个 PowerShell。
661
+ if (!watcher.length && !bridge.length) {
662
+ for (const p of windowsNodeProcesses()) {
663
+ if (p.pid === process.pid || p.pid === wPid || p.pid === bPid) continue;
664
+ if (/dsh-setup\.mjs/.test(p.commandLine)) push(watcher, p.pid);
665
+ else if (/dsh-bridge\.mjs/.test(p.commandLine)) { push(bridge, p.pid); push(allBridge, p.pid); }
666
+ }
667
+ }
668
+ return { watcher, bridge, allBridge };
669
+ }
670
+
422
671
  /** 检查手动运行的 watcher(dsh-setup.mjs run)与 bridge 子进程(排除 launchd 托管链)。 */
423
- function manualStatus() {
672
+ function manualStatus(relayDir) {
673
+ if (isWindows()) return windowsManualStatus(relayDir || DEFAULT_RELAY_DIR);
424
674
  const launchdPid = launchdStatus().pid;
425
675
  const out = (() => {
426
676
  const r = sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs'");
@@ -554,13 +804,32 @@ function ensureRuntime(relayDir) {
554
804
  try {
555
805
  mkdirSync(relayDir, { recursive: true });
556
806
  const log = join(relayDir, AUTO_INSTALL_LOG);
557
- const child = spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
807
+ const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
808
+ // 补装失败(spawn 都起不来)时统一收尾:清安装标记 + 记日志 + 进退避 + 匿名遥测。
809
+ // 没有这条兜底,spawn 的异步 'error' 会变成未捕获异常打挂宿主(旧实现在 Windows 上正是如此)。
810
+ const failSpawn = (e, kind) => {
811
+ clear();
812
+ appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动 npx 失败(${kind}): ${e.message}`);
813
+ noteProvisionFailure(relayDir);
814
+ telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
815
+ };
816
+ const npx = npxInvocation();
817
+ const { child, error } = safeSpawn(npx.command, [...npx.args, "--yes", UPDATE_SPEC], {
558
818
  detached: true,
819
+ // Windows 上跑 npx.cmd 必须经 shell(Node ≥20.12 起否则 EINVAL);走 node+npx-cli.js 时不需要。
820
+ shell: npx.shell,
821
+ // Windows:别让 Node 给子进程开一个控制台窗口(POSIX 上该选项被忽略)
822
+ windowsHide: true,
559
823
  // DSH_BRIDGE_INSTALL_SOURCE:本机运行环境是插件自愈补的 → 安装器原样透传到 plist/bridge env,
560
824
  // 于是设备行上的 install_source 如实记成 plugin_market(而不是安装器默认的 npx)。
561
825
  env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org", DSH_BRIDGE_INSTALL_SOURCE: "plugin_market" }),
562
- stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
826
+ stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
827
+ onError: (e) => failSpawn(e, npx.kind),
563
828
  });
829
+ if (error || !child) {
830
+ failSpawn(error || new Error("spawn 未返回子进程"), npx.kind);
831
+ return false;
832
+ }
564
833
  writeMarker(marker, child.pid); // 记 pid:宿主重启后可立即清理死进程残留
565
834
  // 记下「本机运行环境是插件自愈补的」:登录后的 /api/install-report 与 bridge 设备登记
566
835
  // 都据此上报 install_source=plugin_market(区分用户自己跑 npx 安装器的那条路径)。
@@ -568,7 +837,6 @@ function ensureRuntime(relayDir) {
568
837
  writeFileSync(join(relayDir, PROVISIONED_MARKER),
569
838
  JSON.stringify({ at: Date.now(), version: PLUGIN_VERSION, source: "plugin_market" }));
570
839
  } catch { /* 非关键:上报时按缺省判据降级 */ }
571
- const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
572
840
  // 匿名遥测:补装生命周期(install_started → runtime_ready / install_failed(fail_code))。
573
841
  // 失败原因只归类到白名单 fail_code,绝不外发原始错误文本(可能含路径/主机名/用户名)。
574
842
  const tb = telemetryBook(relayDir);
@@ -657,6 +925,8 @@ function plistEntryMissing() {
657
925
  */
658
926
  function reapBrokenAutostart(relayDir) {
659
927
  if (skipsSystemOps()) return false;
928
+ // 只有 macOS 有 launchd 自启动项可摘;Windows/Linux 走的是 systemd/无自启动,判据不适用。
929
+ if (!isDarwin()) return false;
660
930
  if (!plistEntryMissing()) return false;
661
931
  const st = launchdStatus();
662
932
  if (!st.running && !st.crashing && !st.state) return false; // 作业未被 launchd 加载、无可摘的东西
@@ -668,14 +938,128 @@ function reapBrokenAutostart(relayDir) {
668
938
  return true;
669
939
  }
670
940
 
941
+ /** 写 pid 文件(Windows 进程发现用;写不了不致命,还有 PowerShell 兜底扫描)。 */
942
+ function writePidFile(relayDir, name, pid) {
943
+ if (!Number.isInteger(pid) || pid <= 0) return false;
944
+ try {
945
+ mkdirSync(relayDir, { recursive: true });
946
+ writeFileSync(join(relayDir, name), String(pid), { mode: 0o600 });
947
+ return true;
948
+ } catch {
949
+ return false;
950
+ }
951
+ }
952
+
953
+ /** 结束一个进程(Windows 用 taskkill 连子进程一起收;POSIX 用给定信号)。 */
954
+ function killPid(pid, signal = "SIGTERM") {
955
+ if (!Number.isInteger(pid) || pid <= 0) return false;
956
+ if (isWindows()) {
957
+ const r = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, encoding: "utf8", timeout: 8000 });
958
+ return r.status === 0 || pidAlive(pid) === false;
959
+ }
960
+ try {
961
+ process.kill(pid, signal);
962
+ return true;
963
+ } catch {
964
+ return pidAlive(pid) !== true; // ESRCH = 本来就不在了,也算成功
965
+ }
966
+ }
967
+
968
+ /**
969
+ * Windows:启动 bridge(0.6.7 新增)。
970
+ *
971
+ * Windows 上没有 launchd/systemd,dsh-setup.mjs 也不为 win32 生成自启动
972
+ * (它明写「平台 win32 不支持自启动」)。但 bridge 本身在 Windows 上能跑——
973
+ * 所以插件直接把 watcher(`node <relayDir>/dsh-setup.mjs run`)作为脱离进程拉起:
974
+ * 这与用户手动跑 `dsh-remote run` 是**同一条代码路径**,watcher 自己盯着 127.0.0.1:3080,
975
+ * dsh web 活着就拉起 dsh-bridge.mjs、dsh web 退出就把 bridge 停掉。
976
+ * 代价(如实告知用户):不随开机自启;但 dsh web 每次启动后插件都会在自愈轮次里重新拉起它。
977
+ */
978
+ function startBridgeWindows(relayDir) {
979
+ if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
980
+ if (!runtimeReady(relayDir)) {
981
+ ensureRuntime(relayDir);
982
+ return {
983
+ ok: false,
984
+ status: "provisioning",
985
+ runtimeMissing: true,
986
+ detail: "桌面运行环境(bridge)尚未安装,已在后台自动安装,完成后会自动启动 bridge——请稍候刷新面板(也可点下方「一键更新」查看进度)",
987
+ };
988
+ }
989
+ // 幂等:watcher 已在跑就别再拉第二个(否则会有两个 bridge 抢同一个设备登记)
990
+ const existing = readPidFile(relayDir, WATCHER_PID_FILE);
991
+ if (existing && pidAlive(existing) !== false) {
992
+ return { ok: true, status: "running", pid: existing };
993
+ }
994
+ const setup = join(relayDir, "dsh-setup.mjs");
995
+ if (!existsSync(setup)) return { ok: false, status: "not-installed", detail: `运行环境入口脚本不存在(${setup})` };
996
+ const log = join(relayDir, ".dsh-bridge.log");
997
+ let out = "ignore";
998
+ try { out = openSync(log, "a"); } catch { /* 打不开日志:退化到丢弃输出,不阻断启动 */ }
999
+ const { child, error } = safeSpawn(process.execPath, [setup, "run"], {
1000
+ detached: true,
1001
+ cwd: homedir(),
1002
+ env: spawnEnv({ DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir), DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION }),
1003
+ stdio: ["ignore", out, out],
1004
+ windowsHide: true,
1005
+ onError: (e) => appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-web] Windows 启动 bridge 失败: ${e.message}`),
1006
+ });
1007
+ if (typeof out === "number") { try { closeSync(out); } catch { /* 已随子进程继承,父进程这份可以关 */ } }
1008
+ if (error || !child) {
1009
+ const msg = (error || new Error("spawn 未返回子进程")).message;
1010
+ return { ok: false, status: "failed", detail: `启动 bridge 失败: ${msg}` };
1011
+ }
1012
+ writePidFile(relayDir, WATCHER_PID_FILE, child.pid);
1013
+ child.unref();
1014
+ return {
1015
+ ok: true,
1016
+ status: "running",
1017
+ pid: child.pid,
1018
+ // 如实区分两条路径:装过(或更新过)就有登录任务;没有的话只有"重开 dsh web 自动拉起"这一层保障
1019
+ detail: windowsTaskRegistered()
1020
+ ? "已在后台启动 bridge 守护(已注册登录任务,下次登录会自动运行)"
1021
+ : "已在后台启动 bridge 守护(未注册登录任务:不随开机自启,重开 dsh web 时插件会自动拉起)",
1022
+ };
1023
+ }
1024
+
1025
+ /** Windows:停止 bridge(结束 watcher 与其 bridge 子进程,并清掉 pid 文件)。 */
1026
+ function stopBridgeWindows(relayDir) {
1027
+ if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
1028
+ const targets = [];
1029
+ for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
1030
+ const pid = readPidFile(relayDir, name);
1031
+ if (pid && pidAlive(pid) !== false && !targets.includes(pid)) targets.push(pid);
1032
+ }
1033
+ for (const pid of targets) killPid(pid, "SIGKILL");
1034
+ for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
1035
+ try { rmSync(join(relayDir, name), { force: true }); } catch { /* 非关键 */ }
1036
+ }
1037
+ // 复核:pid 文件之外可能还有用户手动跑起来、或用旧版运行时起的进程
1038
+ const manual = manualStatus(relayDir);
1039
+ const still = [...new Set([...manual.watcher, ...manual.bridge])];
1040
+ return {
1041
+ ok: still.length === 0,
1042
+ status: still.length ? "failed" : "stopped",
1043
+ pid: null,
1044
+ detail: still.length ? `仍有 ${still.length} 个 bridge 相关进程未结束(pid ${still.join(", ")})` : void 0,
1045
+ };
1046
+ }
1047
+
671
1048
  /** 启动 bridge:确保运行环境就绪 + plist 存在 → launchctl bootstrap(回退 load -w)。 */
672
1049
  function startBridge(relayDir) {
673
1050
  if (UNINSTALLED_DIRS.has(relayDir)) {
674
1051
  // 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
675
1052
  return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
676
1053
  }
1054
+ if (isWindows()) return startBridgeWindows(relayDir);
677
1055
  const plistPath = launchAgentPath();
678
- if (!plistPath) return { ok: false, status: "unsupported", detail: "仅支持 macOS" };
1056
+ if (!plistPath) {
1057
+ return {
1058
+ ok: false,
1059
+ status: "unsupported",
1060
+ detail: `当前平台(${osPlatform()})暂不支持自启动服务,请手动运行 \`dsh-remote run\``,
1061
+ };
1062
+ }
679
1063
  if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
680
1064
  // 【0.6.2 关键修复】运行环境缺失时绝不 bootstrap:写一个指向不存在脚本的 plist
681
1065
  // 只会让 launchd 进入 KeepAlive 崩溃循环——面板还可能因瞬时 pid 谎报「已启动」。
@@ -696,13 +1080,17 @@ function startBridge(relayDir) {
696
1080
  if (!existsSync(plistPath)) writeAutostartFile(relayDir);
697
1081
  if (!existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 生成失败" };
698
1082
  const target = launchTarget();
699
- const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
700
- sh(`launchctl bootout ${target}`);
701
- let boot = sh(`launchctl bootstrap gui/${process.getuid()} ${q(plistPath)}`);
702
- if (!boot.ok) {
703
- sh(`launchctl unload ${q(plistPath)}`);
704
- boot = sh(`launchctl load -w ${q(plistPath)}`);
705
- }
1083
+ const boot = (() => {
1084
+ 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) {
1089
+ sh(`launchctl unload ${shQuote(plistPath)}`);
1090
+ r = sh(`launchctl load -w ${shQuote(plistPath)}`);
1091
+ }
1092
+ return r;
1093
+ })();
706
1094
  if (!boot.ok) return { ok: false, status: "failed", detail: (boot.stderr || boot.stdout).trim() || "launchctl 启动失败" };
707
1095
  const st = launchdStatus();
708
1096
  return { ok: st.running, status: st.running ? "running" : "failed", pid: st.pid, detail: st.running ? void 0 : "服务未进入运行态" };
@@ -746,6 +1134,9 @@ function scheduleRuntime(relayDir) {
746
1134
  // 运行环境已在,看服务是否已在运行(runtime 可能位于 npx 缓存/固化目录,不必重复安装)
747
1135
  const st = launchdStatus();
748
1136
  if (st.running) { done = true; clearInterval(iv); return; }
1137
+ // Windows 没有 launchd:以「watcher 进程在跑」为就绪判据。否则 launchdStatus 永远是 false,
1138
+ // done 永远不置位,watcher 会一直空转(虽然 startBridge 幂等,但每轮都白跑一次进程发现)。
1139
+ if (isWindows() && manualStatus(relayDir).watcher.length > 0) { done = true; clearInterval(iv); return; }
749
1140
  startBridge(relayDir); // 环境在但服务没起 → 拉起
750
1141
  } catch { /* 下一轮再试 */ }
751
1142
  }, selfhealIntervalMs());
@@ -753,9 +1144,11 @@ function scheduleRuntime(relayDir) {
753
1144
  return () => clearInterval(iv);
754
1145
  }
755
1146
 
756
- /** 停止 bridge:launchctl bootout。 */
757
- function stopBridge() {
1147
+ /** 停止 bridge:macOS/Linux 走 launchctl bootout;Windows 结束 watcher/bridge 进程。 */
1148
+ function stopBridge(relayDir) {
1149
+ if (isWindows()) return stopBridgeWindows(relayDir);
758
1150
  const target = launchTarget();
1151
+ if (!target) return { ok: false, status: "unsupported", detail: `当前平台(${osPlatform()})没有 launchd 服务可停,请手动结束 \`dsh-remote run\` 进程` };
759
1152
  const r = sh(`launchctl bootout ${target}`);
760
1153
  const st = launchdStatus();
761
1154
  return { ok: !st.running, status: st.running ? "failed" : "stopped", pid: null, detail: st.running ? (r.stderr || "停止失败").trim() : void 0 };
@@ -802,13 +1195,13 @@ function uninstallRuntime(relayDir, protectedPath) {
802
1195
  removedPlist: false, // 自启动文件(plist / systemd unit)已删除
803
1196
  killedPids: [], // 额外结束的残留进程 pid 列表
804
1197
  removedDir: false, // 配置目录 relayDir 已整目录清空
805
- servicePlatform: platform() === "darwin" ? "launchd" : platform() === "linux" ? "systemd" : "none",
1198
+ servicePlatform: isDarwin() ? "launchd" : isWindows() ? "windows-detached" : osPlatform() === "linux" ? "systemd" : "none",
806
1199
  };
807
1200
  const skipService = process.env.DSH_RELAY_SKIP_SERVICE === "1";
808
1201
  if (!skipService) {
809
1202
  // a) 停服务 + 移除自启动文件
810
1203
  try {
811
- if (platform() === "darwin") {
1204
+ if (isDarwin()) {
812
1205
  // 只处理「plist 位于当前 HOME」的服务:本插件/dsh-setup.mjs 安装的服务一定在此
813
1206
  const plistPath = launchAgentPath();
814
1207
  if (plistPath && existsSync(plistPath)) {
@@ -816,13 +1209,25 @@ function uninstallRuntime(relayDir, protectedPath) {
816
1209
  // 只删 plist 文件它照样被 KeepAlive 反复重拉(见 launchdStatus 注释)。
817
1210
  const st = launchdStatus();
818
1211
  if (st.running || st.crashing) {
819
- const r = stopBridge(); // launchctl bootout → KeepAlive 一并失效
1212
+ const r = stopBridge(relayDir); // launchctl bootout → KeepAlive 一并失效
820
1213
  out.stoppedService = r.ok;
821
1214
  }
822
1215
  rmSync(plistPath, { force: true });
823
1216
  out.removedPlist = !existsSync(plistPath);
824
1217
  }
825
- } else if (platform() === "linux") {
1218
+ } else if (isWindows()) {
1219
+ // Windows:自启动 = 任务计划程序里的登录任务(dsh-setup.mjs 注册)。
1220
+ // 卸载必须把任务一起删掉,否则下次登录又会被拉起来 —— 用户会以为"卸载没生效"。
1221
+ const before = manualStatus(relayDir);
1222
+ if (before.watcher.length || before.bridge.length) {
1223
+ const r = stopBridgeWindows(relayDir);
1224
+ out.stoppedService = r.ok;
1225
+ }
1226
+ try {
1227
+ const r = spawnSync("schtasks", ["/Delete", "/TN", WIN_TASK_NAME, "/F"], { windowsHide: true, encoding: "utf8", timeout: 15000 });
1228
+ out.removedPlist = r.status === 0 || /cannot find|找不到|不存在/i.test(String(r.stdout || "") + String(r.stderr || ""));
1229
+ } catch { /* 任务不存在/无权限:不致命,残留可由用户手动清理 */ }
1230
+ } else if (osPlatform() === "linux") {
826
1231
  // systemd --user 用户态服务,与 dsh-setup.mjs 安装的 dsh-bridge 同名
827
1232
  const isActive = sh("systemctl --user is-active dsh-bridge");
828
1233
  if (isActive.ok && String(isActive.stdout).trim() === "active") {
@@ -841,13 +1246,13 @@ function uninstallRuntime(relayDir, protectedPath) {
841
1246
  } catch { /* 服务清理失败不致命:目录照常清理,剩余残留可由用户手动处理 */ }
842
1247
  // b) 杀残留手动进程(launchd 托管的已随 bootout 结束;manualStatus 排除本进程)
843
1248
  try {
844
- const manual = manualStatus();
1249
+ const manual = manualStatus(relayDir);
845
1250
  const targets = [...manual.watcher, ...manual.bridge];
846
1251
  for (const pid of targets) {
847
1252
  try { process.kill(pid, "SIGTERM"); out.killedPids.push(pid); } catch { /* EPERM/ESRCH 忽略 */ }
848
1253
  }
849
1254
  if (targets.length) {
850
- try { execSync("sleep 1", { timeout: 3000 }); } catch { /* 等待进程退出 */ }
1255
+ sleepSync(1000); // 同步等待进程退出(Windows 没有 sleep 命令,改用 Atomics.wait)
851
1256
  for (const pid of targets) {
852
1257
  if (pidAlive(pid)) {
853
1258
  try { process.kill(pid, "SIGKILL"); } catch { /* 已退出 */ }
@@ -881,7 +1286,8 @@ function uninstallRuntime(relayDir, protectedPath) {
881
1286
  // ② 一条可靠的重启实现(优先交回监管者,其次自拉起);③ 面板按钮用的路由。
882
1287
 
883
1288
  const RESTART_STATE_FILE = ".dsh-restart-state.json";
884
- const RESTART_SCRIPT_FILE = ".dsh-restart-harness.sh";
1289
+ const RESTART_SCRIPT_FILE = ".dsh-restart-harness.sh"; // macOS/Linux:/bin/sh 脚本
1290
+ const RESTART_HELPER_FILE = ".dsh-restart-harness.mjs"; // Windows:node 助手(无 /bin/sh)
885
1291
  const RESTART_LOG_FILE = ".dsh-restart.log";
886
1292
 
887
1293
  /**
@@ -1007,24 +1413,89 @@ function buildRestartScript(mode, target, cmd, relayDir) {
1007
1413
  + `exec ${argv} >> '${log}' 2>&1\n`;
1008
1414
  }
1009
1415
 
1416
+ /**
1417
+ * Windows 重启助手(0.6.7 新增)——**必须**是 .mjs 而不是 /bin/sh 脚本。
1418
+ *
1419
+ * 现场(0.6.6 及更早,用户实测):restartHarness 生成 `#!/bin/sh` 脚本再 `spawn("/bin/sh")`,
1420
+ * Windows 没有 /bin/sh → ENOENT;而那个 ChildProcess 又没挂 'error' 监听
1421
+ * → 未捕获异常直接终止 dsh web。更糟的是代码先把「已调度重启」写进日志并返回成功,
1422
+ * 用户看到"成功"、进程却已经没了(残留物 `.dsh-restart.log` 里正是 `mode=relaunch helper=undefined`)。
1423
+ *
1424
+ * 现在:用当前 node 跑本文件(不走任何 shell,参数原样传递,路径含空格/中文都不出问题),
1425
+ * 且**先校验新进程能拉起来再动旧进程**——宁可重启失败,也不能把自己杀掉却起不来。
1426
+ */
1427
+ function buildRestartHelperSource(cmd, relayDir) {
1428
+ const plan = JSON.stringify({
1429
+ pid: process.pid, node: cmd.node, args: cmd.args, cwd: cmd.cwd,
1430
+ log: join(relayDir, RESTART_LOG_FILE),
1431
+ });
1432
+ return `// dsh-remote 自动生成:重启 DeepSeek harness(Windows 助手;由插件写入并 detached 执行)
1433
+ import { spawn, spawnSync } from "node:child_process";
1434
+ import { appendFileSync, existsSync, openSync, closeSync } from "node:fs";
1435
+ import { setTimeout as delay } from "node:timers/promises";
1436
+
1437
+ const PLAN = ${plan};
1438
+ function logLine(msg) {
1439
+ try { appendFileSync(PLAN.log, "\\n[" + new Date().toISOString() + "] " + msg + "\\n"); } catch (e) { /* 日志写不了不阻断重启 */ }
1440
+ }
1441
+
1442
+ // ① 先确认新进程真的拉得起来,再动旧进程(旧实现最大的风险是"杀掉却起不来")
1443
+ if (PLAN.cwd && !existsSync(PLAN.cwd)) { logLine("重启中止:工作目录不存在 " + PLAN.cwd); process.exit(1); }
1444
+ if (PLAN.cwd) process.chdir(PLAN.cwd); // 先切回原工作目录:入口若是相对路径也能被正确解析
1445
+ if (!existsSync(PLAN.node)) { logLine("重启中止:node 不存在 " + PLAN.node); process.exit(1); }
1446
+ const entry = PLAN.args[0];
1447
+ if (!entry || !existsSync(entry)) { logLine("重启中止:dsh 入口不存在 " + entry); process.exit(1); }
1448
+
1449
+ function alive(pid) {
1450
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
1451
+ }
1452
+
1453
+ logLine("restart helper 启动:mode=relaunch(win32) 旧 pid=" + PLAN.pid);
1454
+ await delay(1000); // 先把 HTTP 响应让浏览器收完
1455
+
1456
+ // ② 结束旧进程(Windows 上 process.kill 即 TerminateProcess)
1457
+ try { process.kill(PLAN.pid); } catch (e) { /* 可能已自行退出 */ }
1458
+ const deadline = Date.now() + 30000;
1459
+ while (Date.now() < deadline && alive(PLAN.pid)) await delay(200);
1460
+ if (alive(PLAN.pid)) {
1461
+ logLine("旧进程 30s 未退出,改用 taskkill /T /F");
1462
+ spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true });
1463
+ await delay(500);
1464
+ }
1465
+
1466
+ // ③ 用同一条命令行自拉起(脱离本助手,助手退出后它继续跑)
1467
+ let out = "ignore";
1468
+ try { out = openSync(PLAN.log, "a"); } catch (e) { /* 退化到丢弃输出 */ }
1469
+ const child = spawn(PLAN.node, PLAN.args, {
1470
+ cwd: PLAN.cwd, detached: true, stdio: ["ignore", out, out], windowsHide: true,
1471
+ });
1472
+ child.on("error", (e) => logLine("重新拉起失败:" + e.message));
1473
+ child.unref();
1474
+ if (typeof out === "number") { try { closeSync(out); } catch (e) {} }
1475
+ logLine("已重新拉起 dsh web:pid=" + String(child.pid));
1476
+ `;
1477
+ }
1478
+
1010
1479
  /**
1011
1480
  * 重启 DeepSeek harness(dsh web)。
1012
1481
  * 只重启「承载本插件的那个进程」(pid 匹配到的监管者或自身),绝不触碰 bridge。
1013
- * @returns {{ok:boolean, status:string, mode?:string, detail?:string, script?:string, log?:string}}
1482
+ * @returns {Promise<{ok:boolean, status:string, mode?:string, detail?:string, script?:string, log?:string}>}
1014
1483
  */
1015
- function restartHarness(relayDir, opts = {}) {
1484
+ async function restartHarness(relayDir, opts = {}) {
1016
1485
  if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实重启" };
1017
1486
  const cmd = selfCommand();
1018
1487
  let mode = "relaunch";
1019
1488
  let target = "";
1020
- if (platform() === "darwin") {
1489
+ if (isDarwin()) {
1021
1490
  const label = launchdLabelForPid(process.pid);
1022
- if (label) { mode = "launchd"; target = `gui/${process.getuid()}/${label}`; }
1023
- } else if (platform() === "linux") {
1491
+ const uid = currentUid();
1492
+ if (label && uid !== null) { mode = "launchd"; target = `gui/${uid}/${label}`; }
1493
+ } else if (osPlatform() === "linux") {
1024
1494
  const unit = systemdUnitForSelf();
1025
1495
  if (unit) { mode = "systemd"; target = unit; }
1026
1496
  }
1027
- const script = buildRestartScript(mode, target, cmd, relayDir);
1497
+ // Windows 一律走 .mjs 助手(无监管者,也不存在 /bin/sh)
1498
+ const script = isWindows() ? buildRestartHelperSource(cmd, relayDir) : buildRestartScript(mode, target, cmd, relayDir);
1028
1499
  // 匿名遥测:重启 DeepSeek harness(自动倒计时或手动按钮都经这里)——触发一次记一条。
1029
1500
  // 放在 dry-run 之前:测试/诊断用的 dry-run 也走同一条入口,便于用例锁死这条埋点。
1030
1501
  telemetryRecord(relayDir, "harness_restart");
@@ -1033,17 +1504,30 @@ function restartHarness(relayDir, opts = {}) {
1033
1504
  // 测试/诊断用:只返回将要执行的脚本,不做任何真实动作
1034
1505
  return { ok: true, status: "dry-run", mode, target, script, log: join(relayDir, RESTART_LOG_FILE) };
1035
1506
  }
1036
- const scriptPath = join(relayDir, RESTART_SCRIPT_FILE);
1507
+ const scriptPath = join(relayDir, isWindows() ? RESTART_HELPER_FILE : RESTART_SCRIPT_FILE);
1037
1508
  try {
1038
1509
  mkdirSync(relayDir, { recursive: true });
1039
1510
  writeFileSync(scriptPath, script, { mode: 0o700 });
1040
1511
  const log = join(relayDir, RESTART_LOG_FILE);
1041
- const child = spawn("/bin/sh", [scriptPath], {
1512
+ // 非 macOS 平台没有 /bin/sh;Windows 用 node 直接跑 .mjs 助手。
1513
+ const runner = isWindows() ? process.execPath : "/bin/sh";
1514
+ const out = openSync(log, "a");
1515
+ // 【0.6.7 关键修复】必须等「真的派生成功」再报成功:spawn 失败是**异步** 'error' 事件,
1516
+ // 旧实现不等就返回 ok:true(Windows 上 /bin/sh ENOENT 时正是如此)→ 用户看到"成功"、
1517
+ // 进程其实已经没了。spawnAndConfirm 同时保证永远有 'error' 监听,绝不打挂宿主。
1518
+ const { child, error } = await spawnAndConfirm(runner, [scriptPath], {
1042
1519
  detached: true,
1043
1520
  cwd: homedir(),
1044
1521
  env: spawnEnv(),
1045
- stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
1522
+ stdio: ["ignore", out, out],
1523
+ windowsHide: true,
1046
1524
  });
1525
+ try { closeSync(out); } catch { /* 已随子进程继承,父进程这份可以关 */ }
1526
+ if (error || !child) {
1527
+ const msg = (error || new Error("spawn 未返回子进程")).message;
1528
+ appendLogLine(relayDir, RESTART_LOG_FILE, `[restart] 调度失败:${msg}(未执行任何重启动作,dsh web 仍在运行)`);
1529
+ return { ok: false, status: "failed", mode, target, log, detail: `调度重启失败: ${msg}` };
1530
+ }
1047
1531
  child.unref();
1048
1532
  appendLogLine(relayDir, RESTART_LOG_FILE, `[restart] 已调度重启:mode=${mode}${target ? " target=" + target : ""} helper=${child.pid}`);
1049
1533
  return {
@@ -1176,7 +1660,7 @@ async function bridgeSecretOf(relayDir, cfgOverride, opts) {
1176
1660
  return secret;
1177
1661
  }
1178
1662
 
1179
- // ---------- v2 账号/配额/邀请代理(我的信息 与 免费额度提示) ----------
1663
+ // ---------- 账号 / 用量 / 邀请代理(我的信息、额度提示、邀请记录) ----------
1180
1664
 
1181
1665
  /** token 获取失败的原因(供 UI 区分“未登录”与“中继未就绪”,不再一律谎报“尚未登录”)。 */
1182
1666
  const AUTH_NO_CREDENTIALS = "no_credentials"; // 本机没存账号/自建密钥 → 该去登录
@@ -1392,7 +1876,7 @@ async function relayInviteRecords(relayDir) {
1392
1876
  return { records: r.body.records || [], rewards: r.body.rewards || [] };
1393
1877
  }
1394
1878
 
1395
- // ---------- 交流群二维码(运营配置,公开读取) ----------
1879
+ // ---------- 交流群二维码(后台配置,公开读取) ----------
1396
1880
 
1397
1881
  /**
1398
1882
  * 交流群二维码/客服微信:企业端配置经公开配置 /api/public-config 的 community 字段下发
@@ -1659,7 +2143,7 @@ function maskPhone(p) {
1659
2143
  async function composeStatus(relayDir) {
1660
2144
  const cfg = loadConfig(relayDir);
1661
2145
  const launchd = launchdStatus();
1662
- const manual = manualStatus();
2146
+ const manual = manualStatus(relayDir);
1663
2147
  // 注册/绑定失败提示(bridge 写 .bind-error.json;面板据此展示“已达上限/需解绑”引导)
1664
2148
  let bindError = null;
1665
2149
  try {
@@ -1687,6 +2171,12 @@ async function composeStatus(relayDir) {
1687
2171
  relayReachable: pub.ok,
1688
2172
  service: {
1689
2173
  plistExists: Boolean(launchAgentPath() && existsSync(launchAgentPath())),
2174
+ // 平台与服务管理器:面板据此给出平台正确的文案(Windows 没有"自启动服务"这回事,
2175
+ // 也不该把 bridge 说成 launchd/systemd 服务)
2176
+ platform: osPlatform(),
2177
+ serviceManager: isDarwin() ? "launchd" : isWindows() ? "detached" : osPlatform() === "linux" ? "systemd" : "none",
2178
+ // Windows:登录任务(任务计划程序)是否已注册——面板据此给"能不能开机自启"的准确说法
2179
+ autostartTask: isWindows() ? windowsTaskRegistered() : null,
1690
2180
  launchd,
1691
2181
  manual,
1692
2182
  running: launchd.running || manual.bridge.length > 0,
@@ -1709,7 +2199,7 @@ async function composeStatus(relayDir) {
1709
2199
  };
1710
2200
  }
1711
2201
 
1712
- // ---------- 用户反馈代理(反馈 API 由 relay-enterprise 提供,同源 /relay-api/) ----------
2202
+ // ---------- 用户反馈代理(反馈 API 与账号服务同源,走 /relay-api/) ----------
1713
2203
 
1714
2204
  /**
1715
2205
  * 反馈 API 基址:feedback_url(自建/兼容实现)> 账号 API 基址(默认生产 relay-api)。
@@ -1736,7 +2226,7 @@ async function readBodyBuffer(req, limit = 64 * 1024) {
1736
2226
  }
1737
2227
 
1738
2228
  /**
1739
- * 把 /dsh-remote/feedback/* 代理到反馈 API(relay-enterprise 同源 /relay-api/):
2229
+ * 把 /dsh-remote/feedback/* 代理到反馈 API(同源 /relay-api/):
1740
2230
  * - 自动附加本机稳定身份 X-Dsh-Device(device_id)与 X-Dsh-Phone(已登录手机号)
1741
2231
  * - 透传浏览器带的 Authorization(thread_token,存于浏览器 localStorage)
1742
2232
  * - 不转发 cookie/浏览器标记;反馈服务不可达时降级 502 JSON
@@ -1812,7 +2302,7 @@ const PLUGIN_ID = "dsh-remote-web";
1812
2302
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1813
2303
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1814
2304
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1815
- const PLUGIN_VERSION = "0.6.6";
2305
+ const PLUGIN_VERSION = "0.6.7-beta.2";
1816
2306
  const UPDATE_LOG = ".dsh-update.log";
1817
2307
  const UPDATE_MARKER = ".dsh-update-running";
1818
2308
 
@@ -1898,16 +2388,23 @@ function recentUpdateFailure(relayDir) {
1898
2388
  return rec;
1899
2389
  }
1900
2390
 
1901
- /** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。 */
1902
- function spawnUpdater(relayDir, extraEnv) {
2391
+ /** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。
2392
+ * @returns {{child, error}} 由 safeSpawn 返回——永远挂了 'error' 监听,启动失败不会打挂宿主。
2393
+ */
2394
+ function spawnUpdater(relayDir, extraEnv, onError) {
1903
2395
  const log = join(relayDir, UPDATE_LOG);
1904
- return spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
2396
+ const npx = npxInvocation();
2397
+ return safeSpawn(npx.command, [...npx.args, "--yes", UPDATE_SPEC], {
1905
2398
  detached: true,
1906
2399
  cwd: homedir(),
2400
+ // Windows 上跑 npx.cmd 必须经 shell(Node ≥20.12 起否则 EINVAL);走 node+npx-cli.js 时不需要。
2401
+ shell: npx.shell,
2402
+ windowsHide: true, // Windows:不弹控制台窗口(POSIX 上该选项被忽略)
1907
2403
  // PATH 补 node 目录:App 最小 PATH 下也能跑 npx;同时保持本机既有的安装来源口径
1908
2404
  // (在线更新会重跑安装器并重写 plist,若不带来源就会把 plugin_market 误记成 npx)。
1909
2405
  env: spawnEnv({ DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir), ...(extraEnv || {}) }),
1910
- stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
2406
+ stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
2407
+ onError,
1911
2408
  });
1912
2409
  }
1913
2410
 
@@ -1941,9 +2438,23 @@ function runOnlineUpdate(relayDir) {
1941
2438
 
1942
2439
  let retried = false;
1943
2440
  const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
2441
+ // 启动失败(ENOENT/EINVAL…)的收尾:清 marker + 记日志 + 记失败原因(面板可查询)+ 匿名遥测。
2442
+ // 抽出来给「同步失败」与「异步 'error' 事件」共用,保证任何一种失败都不会变成未捕获异常。
2443
+ const failUpdaterSpawn = (e) => {
2444
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
2445
+ clear();
2446
+ const fc = telemetryFailCodeFromError(e);
2447
+ noteUpdateFailure(relayDir, e.message, fc); // ← 面板可查询,不再只躺在日志里
2448
+ telemetryRecord(relayDir, "update_failed", { fail_code: fc });
2449
+ };
1944
2450
  const run = () => {
1945
2451
  // 第一次:官方 npm 源;失败(exit≠0/网络)才回退用户默认源(通常为国内镜像)
1946
- const child = spawnUpdater(relayDir, retried ? {} : { npm_config_registry: "https://registry.npmjs.org" });
2452
+ const { child, error } = spawnUpdater(
2453
+ relayDir, retried ? {} : { npm_config_registry: "https://registry.npmjs.org" }, failUpdaterSpawn);
2454
+ if (error || !child) {
2455
+ if (error) failUpdaterSpawn(error);
2456
+ return null;
2457
+ }
1947
2458
  writeMarker(marker, child.pid);
1948
2459
  child.on("exit", (code) => {
1949
2460
  if (!retried && code !== 0) {
@@ -1970,13 +2481,6 @@ function runOnlineUpdate(relayDir) {
1970
2481
  updateFailures.delete(String(relayDir)); // 成功即清掉旧失败
1971
2482
  }
1972
2483
  });
1973
- child.on("error", (e) => {
1974
- appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
1975
- clear();
1976
- const fc = telemetryFailCodeFromError(e);
1977
- noteUpdateFailure(relayDir, e.message, fc); // ← 面板可查询,不再只躺在日志里
1978
- telemetryRecord(relayDir, "update_failed", { fail_code: fc });
1979
- });
1980
2484
  child.unref();
1981
2485
  return child;
1982
2486
  };
@@ -2050,7 +2554,7 @@ function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
2050
2554
  // ---------- 登录后自动闭环:连接阶段(运行环境 → bridge 进程 → 设备已在中继注册=online) ----------
2051
2555
 
2052
2556
  /**
2053
- * 生产现场(2026-09,id 42~52 共 11 个真实注册用户):7 人注册后手机端 /api/devices 一直是空列表,
2557
+ * 现场观察:新用户注册后手机端 /api/devices 常是空列表,
2054
2558
  * 多人反复点「生成访问链接」(auth.key_create 10~23 次)而 device.bind 始终为 0——他们的电脑端
2055
2559
  * bridge 从未连上过中继。典型路径:注册 → 手机端拿不到设备 → 反复轮询 → 放弃。
2056
2560
  *
@@ -2226,7 +2730,7 @@ async function accountDeviceBound(relayDir, cfg) {
2226
2730
  async function composeConnect(relayDir, opts = {}) {
2227
2731
  const cfg = opts.cfg || loadConfig(relayDir);
2228
2732
  const launchd = opts.launchd || launchdStatus();
2229
- const manual = opts.manual || manualStatus();
2733
+ const manual = opts.manual || manualStatus(relayDir);
2230
2734
  const book = connectBook(relayDir);
2231
2735
  const now = Date.now();
2232
2736
  const hasAcct = hasAccountCreds(cfg);
@@ -2391,7 +2895,7 @@ function ensureConnection(relayDir, opts = {}) {
2391
2895
  return { action: "provision" };
2392
2896
  }
2393
2897
  const launchd = launchdStatus();
2394
- const manual = manualStatus();
2898
+ const manual = manualStatus(relayDir);
2395
2899
  if (launchd.running || (manual.allBridge || []).length || (manual.bridge || []).length) {
2396
2900
  book.attempts = 0; // 进程已在跑:退避计数归零,后续只等注册
2397
2901
  book.lastAttemptAt = now;
@@ -2495,7 +2999,11 @@ function telemetryBackoffMs(attempt) {
2495
2999
  const base = TELEMETRY_BACKOFF_MS[Math.min(Math.max(attempt, 0), TELEMETRY_BACKOFF_MS.length - 1)];
2496
3000
  const v = Number(process.env.DSH_REMOTE_TELEMETRY_MS);
2497
3001
  const scale = Number.isFinite(v) && v > 0 ? Math.max(10, v) / TELEMETRY_FLUSH_MS : 1;
2498
- return Math.max(10, Math.round(base * scale));
3002
+ // 缩放下限 200ms:把整条重试链压到亚秒级时(仅测试/诊断设置该变量),
3003
+ // 若下限低到 10ms,退避窗口会比一次 await 还短,让“窗口内不重试”这类断言变成
3004
+ // 靠调度运气。200ms 既能让 6 次退避在数秒内跑完,又足以稳定观测。
3005
+ // 生产不设该变量(scale=1),base 最小 30s,不受此下限影响。
3006
+ return Math.max(200, Math.round(base * scale));
2499
3007
  }
2500
3008
 
2501
3009
  /** 每个 relayDir 的遥测簿:内存队列 + 心跳句柄 + 退避/去重状态(进程重启即重置)。 */
@@ -3287,7 +3795,7 @@ function registerRoutes(ctx, relayDir) {
3287
3795
  handler: async (_req, res) => {
3288
3796
  // 首次安装/在线更新后重启 DeepSeek harness:插件本体与浏览器半在进程启动时装载,
3289
3797
  // 不重启则面板入口与 /dsh-remote/* 路由都不会出现。只重启承载本插件的进程。
3290
- const r = restartHarness(relayDir);
3798
+ const r = await restartHarness(relayDir);
3291
3799
  const payload = { ...(await composeStatus(relayDir)), ...r };
3292
3800
  if (!r.ok) payload.error = r.detail || r.status || "重启失败";
3293
3801
  sendJson(res, r.ok ? 200 : 500, payload);
@@ -3310,7 +3818,7 @@ function registerRoutes(ctx, relayDir) {
3310
3818
  method: "POST",
3311
3819
  path: "/dsh-remote/stop",
3312
3820
  handler: async (_req, res) => {
3313
- const r = stopBridge();
3821
+ const r = stopBridge(relayDir);
3314
3822
  const payload = { ...(await composeStatus(relayDir)), ...r };
3315
3823
  if (!r.ok) payload.error = r.detail || r.status || "停止失败";
3316
3824
  sendJson(res, r.ok ? 200 : 500, payload);