@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.
- package/README.md +30 -8
- package/clients/dsh-remote/dsh-bridge.mjs +1 -1
- package/clients/dsh-remote/e2ee-shim.mjs +4 -4
- package/clients/dsh-remote/mobile-adapter.mjs +1 -1
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +4 -4
- package/docs/self-hosting.md +27 -9
- package/dsh-setup.mjs +593 -41
- package/package.json +3 -3
- package/packages/dsh-remote-web/lib/client.js +57 -6
- package/packages/dsh-remote-web/lib/index.js +578 -70
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/community-qr-ui.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-loop.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +3 -3
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +82 -0
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +400 -0
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +3 -1
- package/packages/dsh-remote-web/test/quota-absent.test.mjs +94 -0
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +1 -1
- package/packages/dsh-remote-web/test/telemetry.test.mjs +2 -2
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +466 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
sh(`launchctl
|
|
704
|
-
|
|
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:
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
1489
|
+
if (isDarwin()) {
|
|
1021
1490
|
const label = launchdLabelForPid(process.pid);
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
// ----------
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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);
|