dsh-remote-web 0.6.0 → 0.6.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/lib/client.js +311 -18
- package/lib/index.js +655 -52
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
|
|
15
15
|
//
|
|
16
16
|
// 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
|
|
17
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, constants as fsConstants } from "node:fs";
|
|
17
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, statSync, constants as fsConstants } from "node:fs";
|
|
18
18
|
import { join, dirname, sep } from "node:path";
|
|
19
19
|
import { execSync, spawn } from "node:child_process";
|
|
20
20
|
import { homedir, platform } from "node:os";
|
|
@@ -76,8 +76,8 @@ const NODE_BIN = preferredNode();
|
|
|
76
76
|
function npxCommand() {
|
|
77
77
|
const name = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
78
78
|
const dirs = [
|
|
79
|
+
process.env.DSH_SETUP_NPX_DIR || "", // 显式覆盖优先(测试注入假 npx;生产通常不设)
|
|
79
80
|
dirname(process.execPath), // 与当前 node 同目录(homebrew/usr/local 均可覆盖)
|
|
80
|
-
process.env.DSH_SETUP_NPX_DIR || "",
|
|
81
81
|
"/opt/homebrew/bin",
|
|
82
82
|
"/usr/local/bin",
|
|
83
83
|
"/opt/homebrew/opt/node@20/bin",
|
|
@@ -290,6 +290,25 @@ function scheduleHarnessMint(ctx, relayDir) {
|
|
|
290
290
|
};
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
// ---------- 桌面运行环境就绪判定 / 系统级操作开关 ----------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 桌面运行环境是否就绪:固化运行时 `<relayDir>/dsh-setup.mjs` 存在即视为就绪。
|
|
297
|
+
* 这是「bridge 能不能跑」的唯一权威判据,也是 launchd 自启动指向的入口脚本
|
|
298
|
+
* (见 writeAutostartFile)——插件市场只装「插件半」时该文件不存在,必须先补装。
|
|
299
|
+
*/
|
|
300
|
+
function runtimeReady(relayDir) {
|
|
301
|
+
return existsSync(join(relayDir, "dsh-setup.mjs"));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 系统级操作总开关(测试隔离):置位后不做 launchctl / npx 安装 / 杀进程等一切真实系统操作。
|
|
306
|
+
* 生产环境绝不设置;测试与 CI 必须设置,否则用例会去碰本机真实的 bridge 自启动服务。
|
|
307
|
+
*/
|
|
308
|
+
function skipsSystemOps() {
|
|
309
|
+
return process.env.DSH_RELAY_SKIP_SERVICE === "1";
|
|
310
|
+
}
|
|
311
|
+
|
|
293
312
|
// ---------- bridge 服务状态 / 启停(launchctl,macOS) ----------
|
|
294
313
|
|
|
295
314
|
function launchAgentPath() {
|
|
@@ -301,20 +320,46 @@ function launchTarget() {
|
|
|
301
320
|
return `gui/${process.getuid()}/com.dshremote.bridge`;
|
|
302
321
|
}
|
|
303
322
|
|
|
304
|
-
/**
|
|
323
|
+
/**
|
|
324
|
+
* 检查 launchd 服务状态。
|
|
325
|
+
*
|
|
326
|
+
* 【0.6.2 关键修复】`launchctl print` 只要成功,它的 `state` 就是权威,**不得**再回退
|
|
327
|
+
* `launchctl list`。原因:plist 指向不存在的入口脚本时,KeepAlive 会让作业陷入
|
|
328
|
+
* 「秒退→立刻重拉」的崩溃循环,而在重拉的瞬间 `launchctl list` 的 PID 列会闪现一个
|
|
329
|
+
* **已经死掉**的 pid(本机实测 `launchctl list` = `40213 1 com.dshremote.bridge`,
|
|
330
|
+
* 同时 `ps -p 40213` 为空、`launchctl print` = `state = spawn scheduled`)。
|
|
331
|
+
* 旧实现据此谎报 running=true,后果有二:
|
|
332
|
+
* 1) 面板显示「运行中」,用户以为 bridge 在跑(实际从未注册成功);
|
|
333
|
+
* 2) scheduleRuntime 判定「已在运行」→ 自愈 watcher 永久停摆,再也不补运行环境。
|
|
334
|
+
* 现在:print 的 state 为准 + 附带 runs/lastExitCode/crashing 供面板与自愈解释原因;
|
|
335
|
+
* 仅在 print 本身不可用时才回退 list,且回退路径要求 pid 真实存活。
|
|
336
|
+
*/
|
|
305
337
|
function launchdStatus() {
|
|
306
338
|
const target = launchTarget();
|
|
307
339
|
const pr = sh(`launchctl print ${target}`);
|
|
308
|
-
if (pr.ok
|
|
309
|
-
const
|
|
310
|
-
|
|
340
|
+
if (pr.ok) {
|
|
341
|
+
const stateMatch = pr.stdout.match(/state\s*=\s*([^\n]+)/);
|
|
342
|
+
const state = stateMatch ? stateMatch[1].trim() : "";
|
|
343
|
+
const pidMatch = pr.stdout.match(/(?:^|\n)\s*pid\s*=\s*(\d+)/);
|
|
344
|
+
const runsMatch = pr.stdout.match(/runs\s*=\s*(\d+)/);
|
|
345
|
+
const exitMatch = pr.stdout.match(/last exit code\s*=\s*(-?\d+)/);
|
|
346
|
+
const runs = runsMatch ? Number(runsMatch[1]) : null;
|
|
347
|
+
const lastExitCode = exitMatch ? Number(exitMatch[1]) : null;
|
|
348
|
+
const running = state === "running";
|
|
349
|
+
// 崩溃循环:作业在 launchd 里可见但没在跑,且上次退出码非 0(KeepAlive 会无限重拉)
|
|
350
|
+
const crashing = !running && runs !== null && runs > 0 && lastExitCode !== null && lastExitCode !== 0;
|
|
351
|
+
return { running, pid: running && pidMatch ? Number(pidMatch[1]) : null, state, runs, lastExitCode, crashing };
|
|
311
352
|
}
|
|
312
|
-
const ls = sh(
|
|
353
|
+
const ls = sh("launchctl list | grep com.dshremote.bridge");
|
|
313
354
|
if (ls.ok) {
|
|
314
355
|
const pidStr = ls.stdout.trim().split(/\s+/)[0];
|
|
315
|
-
if (pidStr && pidStr !== "-" && /^\d+$/.test(pidStr))
|
|
356
|
+
if (pidStr && pidStr !== "-" && /^\d+$/.test(pidStr)) {
|
|
357
|
+
const pid = Number(pidStr);
|
|
358
|
+
// list 的 PID 列在崩溃循环中会出现已死进程 → 必须校验存活才敢报运行
|
|
359
|
+
if (pidAlive(pid) !== false) return { running: true, pid, state: "running", runs: null, lastExitCode: null, crashing: false };
|
|
360
|
+
}
|
|
316
361
|
}
|
|
317
|
-
return { running: false, pid: null };
|
|
362
|
+
return { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
|
|
318
363
|
}
|
|
319
364
|
|
|
320
365
|
/** 检查手动运行的 watcher(dsh-setup.mjs run)与 bridge 子进程(排除 launchd 托管链)。 */
|
|
@@ -350,6 +395,14 @@ function manualStatus() {
|
|
|
350
395
|
const PROVISION_MARKER = ".dsh-setup-installing";
|
|
351
396
|
const AUTO_INSTALL_LOG = ".dsh-setup-install.log";
|
|
352
397
|
const STALE_MARKER_MS = 30 * 60 * 1000; // 超过该时长视为上次进程残留,插件启动时清理
|
|
398
|
+
/**
|
|
399
|
+
* 自愈 watcher 轮询间隔(默认 12s)。DSH_RELAY_SELFHEAL_MS 可覆盖——仅供测试把
|
|
400
|
+
* 崩溃循环自愈链路压到亚秒级验证,生产不必设置。调用时读取(而非模块加载时固定),
|
|
401
|
+
* 测试才能在 import 之后再改。
|
|
402
|
+
*/
|
|
403
|
+
function selfhealIntervalMs() {
|
|
404
|
+
return Math.max(200, Number(process.env.DSH_RELAY_SELFHEAL_MS) || 12_000);
|
|
405
|
+
}
|
|
353
406
|
|
|
354
407
|
/** 向日志追加一行(多个子进程写同一日志用 append 模式,互不覆盖)。 */
|
|
355
408
|
function appendLogLine(relayDir, name, line) {
|
|
@@ -374,7 +427,8 @@ function appendLogLine(relayDir, name, line) {
|
|
|
374
427
|
* 已被 0.4.5 移除的“用户 include”重新写回 profile(历史上造成 dsh web 重复 ID 崩溃)。 */
|
|
375
428
|
function ensureRuntime(relayDir) {
|
|
376
429
|
if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再自动安装运行环境
|
|
377
|
-
if (
|
|
430
|
+
if (runtimeReady(relayDir)) return true;
|
|
431
|
+
if (skipsSystemOps()) return false; // 测试隔离:绝不在用例里 spawn 真实 npx 安装
|
|
378
432
|
const marker = join(relayDir, PROVISION_MARKER);
|
|
379
433
|
if (existsSync(marker)) return false; // 正在安装中
|
|
380
434
|
try {
|
|
@@ -419,6 +473,7 @@ function writeAutostartFile(relayDir) {
|
|
|
419
473
|
<array><string>${NODE_BIN}</string><string>${setupUrl}</string><string>run</string></array>
|
|
420
474
|
<key>RunAtLoad</key><true/>
|
|
421
475
|
<key>KeepAlive</key><true/>
|
|
476
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
422
477
|
<key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
423
478
|
<key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
424
479
|
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string></dict>
|
|
@@ -428,7 +483,44 @@ function writeAutostartFile(relayDir) {
|
|
|
428
483
|
return plistPath;
|
|
429
484
|
}
|
|
430
485
|
|
|
431
|
-
/**
|
|
486
|
+
/**
|
|
487
|
+
* 自启动 plist 里的入口脚本是否已不存在(不存在 = launchd 必然陷入崩溃循环)。
|
|
488
|
+
* 只解析 ProgramArguments 里的第一个 .mjs 参数,不引入 plist 解析依赖。
|
|
489
|
+
*/
|
|
490
|
+
function plistEntryMissing() {
|
|
491
|
+
const plistPath = launchAgentPath();
|
|
492
|
+
if (!plistPath || !existsSync(plistPath)) return false;
|
|
493
|
+
let raw = "";
|
|
494
|
+
try { raw = readFileSync(plistPath, "utf8"); } catch { return false; }
|
|
495
|
+
const m = raw.match(/<string>([^<]*\.mjs)<\/string>/);
|
|
496
|
+
if (!m) return false;
|
|
497
|
+
return !existsSync(m[1]);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 摘掉「指向不存在入口脚本」的自启动项,止住 launchd 崩溃循环。
|
|
502
|
+
*
|
|
503
|
+
* 现场(0.6.1 及更早):只装插件半的用户在面板点「启动 bridge」或首次登录时,
|
|
504
|
+
* startBridge 会无条件生成指向 `<relayDir>/dsh-setup.mjs` 的 plist 并 bootstrap,
|
|
505
|
+
* 而该文件并不存在 → launchd KeepAlive 无限重拉(日志 20+ 次 MODULE_NOT_FOUND、
|
|
506
|
+
* `runs = 23 / last exit code = 1`)。此时仅删 plist 文件没用:作业已加载在 launchd 里,
|
|
507
|
+
* 必须 bootout 才会停。判据严格限定为「入口脚本确实缺失」,绝不碰正常停止的服务。
|
|
508
|
+
* @returns {boolean} 是否执行了清理
|
|
509
|
+
*/
|
|
510
|
+
function reapBrokenAutostart(relayDir) {
|
|
511
|
+
if (skipsSystemOps()) return false;
|
|
512
|
+
if (!plistEntryMissing()) return false;
|
|
513
|
+
const st = launchdStatus();
|
|
514
|
+
if (!st.running && !st.crashing && !st.state) return false; // 作业未被 launchd 加载、无可摘的东西
|
|
515
|
+
sh(`launchctl bootout ${launchTarget()}`);
|
|
516
|
+
try { rmSync(launchAgentPath(), { force: true }); } catch { /* 非关键 */ }
|
|
517
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
518
|
+
`[dsh-remote-web] 已摘除失效自启动:plist 入口 ${join(relayDir, "dsh-setup.mjs")} 不存在`
|
|
519
|
+
+ `(launchd state=${st.state || "?"}, runs=${st.runs ?? "?"}, lastExit=${st.lastExitCode ?? "?"})→ 转入后台补装运行环境`);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** 启动 bridge:确保运行环境就绪 + plist 存在 → launchctl bootstrap(回退 load -w)。 */
|
|
432
524
|
function startBridge(relayDir) {
|
|
433
525
|
if (UNINSTALLED_DIRS.has(relayDir)) {
|
|
434
526
|
// 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
|
|
@@ -436,6 +528,23 @@ function startBridge(relayDir) {
|
|
|
436
528
|
}
|
|
437
529
|
const plistPath = launchAgentPath();
|
|
438
530
|
if (!plistPath) return { ok: false, status: "unsupported", detail: "仅支持 macOS" };
|
|
531
|
+
if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
|
|
532
|
+
// 【0.6.2 关键修复】运行环境缺失时绝不 bootstrap:写一个指向不存在脚本的 plist
|
|
533
|
+
// 只会让 launchd 进入 KeepAlive 崩溃循环——面板还可能因瞬时 pid 谎报「已启动」。
|
|
534
|
+
// 正确做法:先把运行环境补起来(后台 npx),就绪后由 scheduleRuntime / 面板再次启动。
|
|
535
|
+
if (!runtimeReady(relayDir)) {
|
|
536
|
+
ensureRuntime(relayDir);
|
|
537
|
+
return {
|
|
538
|
+
ok: false,
|
|
539
|
+
status: "provisioning",
|
|
540
|
+
runtimeMissing: true,
|
|
541
|
+
detail: "桌面运行环境(bridge)尚未安装,已在后台自动安装,完成后会自动启动 bridge——请稍候刷新面板(也可点下方「一键更新」查看进度)",
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
// plist 存在但入口不是当前运行环境(relayDir 变更/旧安装)→ 重写,避免又指到失效路径
|
|
545
|
+
if (existsSync(plistPath) && !readFileSync(plistPath, "utf8").includes(join(relayDir, "dsh-setup.mjs"))) {
|
|
546
|
+
writeAutostartFile(relayDir);
|
|
547
|
+
}
|
|
439
548
|
if (!existsSync(plistPath)) writeAutostartFile(relayDir);
|
|
440
549
|
if (!existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 生成失败" };
|
|
441
550
|
const target = launchTarget();
|
|
@@ -451,27 +560,42 @@ function startBridge(relayDir) {
|
|
|
451
560
|
return { ok: st.running, status: st.running ? "running" : "failed", pid: st.pid, detail: st.running ? void 0 : "服务未进入运行态" };
|
|
452
561
|
}
|
|
453
562
|
|
|
454
|
-
/**
|
|
563
|
+
/**
|
|
564
|
+
* 运行时自愈 watcher:补齐桌面运行环境 + 环境就绪后自动拉起 bridge。
|
|
565
|
+
*
|
|
566
|
+
* 【0.6.2 顺序修复】运行环境检查提到最前,且必须先于「账号就绪」与「服务是否在跑」:
|
|
567
|
+
* - 旧版先判 `st.running` 才判运行环境,而崩溃循环中的作业会被误判为 running
|
|
568
|
+
* → watcher `done = true` 永久停摆,自动补装(ensureRuntime)再也不会执行,
|
|
569
|
+
* 这正是「市场装完插件、bridge 却起不来」且永不恢复的直接原因;
|
|
570
|
+
* - 补装运行环境不该等用户先登录:市场安装只给到插件半,登录那一刻就会 startBridge。
|
|
571
|
+
*/
|
|
455
572
|
function scheduleRuntime(relayDir) {
|
|
456
573
|
let done = false;
|
|
574
|
+
// 本进程启动时是否缺运行环境:缺 → 补齐完成后需要重启 DeepSeek harness 才算「安装完成」。
|
|
575
|
+
let awaitingRestartHint = !runtimeReady(relayDir);
|
|
457
576
|
const iv = setInterval(() => {
|
|
458
577
|
if (done) { clearInterval(iv); return; }
|
|
459
578
|
if (UNINSTALLED_DIRS.has(relayDir)) { done = true; clearInterval(iv); return; } // 已彻底卸载:自愈 watcher 停摆
|
|
460
579
|
try {
|
|
580
|
+
if (!runtimeReady(relayDir)) {
|
|
581
|
+
reapBrokenAutostart(relayDir); // 先摘掉指向不存在脚本的自启动,止住崩溃循环
|
|
582
|
+
ensureRuntime(relayDir); // 后台 npx 补齐(marker 防重入);就绪后下一轮自动拉起
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (awaitingRestartHint) {
|
|
586
|
+
awaitingRestartHint = false;
|
|
587
|
+
// 首次安装:运行环境(bridge + 自启动)刚在本进程内补齐 → 提示重启,重启后即完全可用
|
|
588
|
+
markRestartPending(relayDir, "first-install", "首次安装需要重启 DeepSeek harness");
|
|
589
|
+
}
|
|
461
590
|
const cfg = loadConfig(relayDir);
|
|
462
591
|
const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
|
|
463
592
|
if (!hasAcct) return;
|
|
464
|
-
//
|
|
593
|
+
// 运行环境已在,看服务是否已在运行(runtime 可能位于 npx 缓存/固化目录,不必重复安装)
|
|
465
594
|
const st = launchdStatus();
|
|
466
595
|
if (st.running) { done = true; clearInterval(iv); return; }
|
|
467
|
-
const setupUrl = join(relayDir, "dsh-setup.mjs");
|
|
468
|
-
if (!existsSync(setupUrl)) {
|
|
469
|
-
ensureRuntime(relayDir); // 什么环境都没有 → 后台 npx 安装一次
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
596
|
startBridge(relayDir); // 环境在但服务没起 → 拉起
|
|
473
597
|
} catch { /* 下一轮再试 */ }
|
|
474
|
-
},
|
|
598
|
+
}, selfhealIntervalMs());
|
|
475
599
|
iv.unref?.();
|
|
476
600
|
return () => clearInterval(iv);
|
|
477
601
|
}
|
|
@@ -533,7 +657,10 @@ function uninstallRuntime(relayDir, protectedPath) {
|
|
|
533
657
|
// 只处理「plist 位于当前 HOME」的服务:本插件/dsh-setup.mjs 安装的服务一定在此
|
|
534
658
|
const plistPath = launchAgentPath();
|
|
535
659
|
if (plistPath && existsSync(plistPath)) {
|
|
536
|
-
|
|
660
|
+
// crashing 也要 bootout:崩溃循环中的作业在 launchd 里仍是「已加载」,
|
|
661
|
+
// 只删 plist 文件它照样被 KeepAlive 反复重拉(见 launchdStatus 注释)。
|
|
662
|
+
const st = launchdStatus();
|
|
663
|
+
if (st.running || st.crashing) {
|
|
537
664
|
const r = stopBridge(); // launchctl bootout → KeepAlive 一并失效
|
|
538
665
|
out.stoppedService = r.ok;
|
|
539
666
|
}
|
|
@@ -590,6 +717,181 @@ function uninstallRuntime(relayDir, protectedPath) {
|
|
|
590
717
|
return out;
|
|
591
718
|
}
|
|
592
719
|
|
|
720
|
+
// ---------- DeepSeek harness 重启(0.6.2:首次安装/更新后必须重启才能加载插件本体) ----------
|
|
721
|
+
//
|
|
722
|
+
// 为什么需要:dsh web 的插件(宿主半 + 浏览器半)都是在**进程启动时**装载的。
|
|
723
|
+
// 市场点击安装只把文件写进 profile,当前进程里既没有 /dsh-remote/* 路由、也没有面板入口,
|
|
724
|
+
// 必须重启 DeepSeek harness 才生效(面板与 bridge 才能真正可用)。
|
|
725
|
+
// 本段提供:① 持久化「待重启」状态(跨进程判定重启是否真的发生过);
|
|
726
|
+
// ② 一条可靠的重启实现(优先交回监管者,其次自拉起);③ 面板按钮用的路由。
|
|
727
|
+
|
|
728
|
+
const RESTART_STATE_FILE = ".dsh-restart-state.json";
|
|
729
|
+
const RESTART_SCRIPT_FILE = ".dsh-restart-harness.sh";
|
|
730
|
+
const RESTART_LOG_FILE = ".dsh-restart.log";
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* 当前 dsh web 进程身份(pid + 启动时刻)。模块加载时算一次并固定:
|
|
734
|
+
* 若每次调用重算,`Date.now() - uptime*1000` 的毫秒漂移会让同一个进程算出不同的 id,
|
|
735
|
+
* 结清逻辑就会误判「已经重启过」而错误撤下提示。
|
|
736
|
+
*/
|
|
737
|
+
const BOOT_ID = `${process.pid}-${Math.round(Date.now() - process.uptime() * 1000)}`;
|
|
738
|
+
|
|
739
|
+
function bootId() {
|
|
740
|
+
return BOOT_ID;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** 读取「待重启」状态;文件缺失/损坏一律视为无需重启。 */
|
|
744
|
+
function readRestartState(relayDir) {
|
|
745
|
+
try {
|
|
746
|
+
const raw = JSON.parse(readFileSync(join(relayDir, RESTART_STATE_FILE), "utf8"));
|
|
747
|
+
if (raw && typeof raw === "object") return raw;
|
|
748
|
+
} catch { /* 无文件 / 损坏 */ }
|
|
749
|
+
return { pending: false };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function writeRestartState(relayDir, state) {
|
|
753
|
+
try {
|
|
754
|
+
mkdirSync(relayDir, { recursive: true });
|
|
755
|
+
writeFileSync(join(relayDir, RESTART_STATE_FILE), JSON.stringify({ ...state, updatedAt: Date.now() }, null, 2), { mode: 0o600 });
|
|
756
|
+
} catch { /* 写盘失败不致命:面板退化为不提示 */ }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** 标记「需要重启 DeepSeek harness」。kind: first-install(首次安装) | update(在线更新)。 */
|
|
760
|
+
function markRestartPending(relayDir, kind, reason) {
|
|
761
|
+
const cur = readRestartState(relayDir);
|
|
762
|
+
if (cur.pending && cur.kind === kind) return cur; // 幂等:同一原因不反复重写
|
|
763
|
+
const next = { pending: true, kind, reason, at: Date.now(), bootId: bootId(), installedBy: cur.installedBy || "" };
|
|
764
|
+
writeRestartState(relayDir, next);
|
|
765
|
+
return next;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* 插件装载时调用:bootId 与记录中的不同 = 中间确实重启过一次 → 结清「待重启」状态。
|
|
770
|
+
* @returns {{cleared:boolean, state:object}}
|
|
771
|
+
*/
|
|
772
|
+
function settleRestartState(relayDir) {
|
|
773
|
+
const cur = readRestartState(relayDir);
|
|
774
|
+
if (!cur.pending) return { cleared: false, state: cur };
|
|
775
|
+
if (cur.bootId && cur.bootId === bootId()) return { cleared: false, state: cur }; // 同一进程:仍待重启
|
|
776
|
+
const state = { pending: false, kind: "", reason: "", at: cur.at || 0, bootId: bootId(), lastRestartedAt: Date.now() };
|
|
777
|
+
writeRestartState(relayDir, state);
|
|
778
|
+
return { cleared: true, state };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* 插件文件是否**晚于本进程启动**才落盘(= 运行中被市场安装/在线更新改写过)。
|
|
783
|
+
* 依据:本插件自身 package.json 的 mtime 对比 dsh web 进程启动时刻(2s 容差)。
|
|
784
|
+
*/
|
|
785
|
+
function pluginInstalledAfterBoot() {
|
|
786
|
+
try {
|
|
787
|
+
const here = fileURLToPath(import.meta.url); // <profile>/node_modules/<pkg>/lib/index.js
|
|
788
|
+
const pkgFile = join(dirname(dirname(here)), "package.json");
|
|
789
|
+
const mtimeMs = statSync(pkgFile).mtimeMs;
|
|
790
|
+
const bootMs = Date.now() - process.uptime() * 1000;
|
|
791
|
+
return mtimeMs > bootMs + 2000;
|
|
792
|
+
} catch { return false; }
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** 本进程的启动命令(重启时原样复用:node 路径 + dsh 参数 + 工作目录)。 */
|
|
796
|
+
function selfCommand() {
|
|
797
|
+
return { node: process.execPath, args: process.argv.slice(1), cwd: process.cwd() };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** macOS:从 `launchctl list` 里找出托管本进程(pid 精确匹配)的作业标签。 */
|
|
801
|
+
function launchdLabelForPid(pid) {
|
|
802
|
+
const r = sh("launchctl list");
|
|
803
|
+
if (!r.ok) return "";
|
|
804
|
+
for (const line of r.stdout.split("\n")) {
|
|
805
|
+
const m = line.trim().match(/^(\d+)\s+(-?\d+)\s+(\S+)$/);
|
|
806
|
+
if (m && Number(m[1]) === pid) return m[3];
|
|
807
|
+
}
|
|
808
|
+
return "";
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Linux:从 /proc/self/cgroup 里找出托管本进程的 systemd 单元。 */
|
|
812
|
+
function systemdUnitForSelf() {
|
|
813
|
+
try {
|
|
814
|
+
const cg = readFileSync("/proc/self/cgroup", "utf8");
|
|
815
|
+
const m = cg.match(/\/([A-Za-z0-9_.@-]+\.service)/);
|
|
816
|
+
return m ? m[1] : "";
|
|
817
|
+
} catch { return ""; }
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 生成重启脚本(延迟执行,先把 HTTP 响应让浏览器收完)。
|
|
822
|
+
* mode=launchd/systemd:交给监管者重启最干净(KeepAlive/Restart 会拉起新进程);
|
|
823
|
+
* mode=relaunch:没有监管者时自行拉起同一条命令行(detached 新会话,脱离将被 kill 的旧进程)。
|
|
824
|
+
*/
|
|
825
|
+
function buildRestartScript(mode, target, cmd, relayDir) {
|
|
826
|
+
const log = join(relayDir, RESTART_LOG_FILE);
|
|
827
|
+
const head = `#!/bin/sh\n# dsh-remote 自动生成:重启 DeepSeek harness(mode=${mode})\necho "[$(date '+%F %T')] restart mode=${mode} target=${target || "-"} pid=${process.pid}" >> '${log}'\nsleep 1\n`;
|
|
828
|
+
if (mode === "launchd") {
|
|
829
|
+
return `${head}exec launchctl kickstart -k '${target}' >> '${log}' 2>&1\n`;
|
|
830
|
+
}
|
|
831
|
+
if (mode === "systemd") {
|
|
832
|
+
return `${head}exec systemctl --user restart '${target}' >> '${log}' 2>&1\n`;
|
|
833
|
+
}
|
|
834
|
+
const quote = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
835
|
+
const argv = [cmd.node, ...cmd.args].map(quote).join(" ");
|
|
836
|
+
// 先优雅退出旧进程,超时再强杀;随后在新会话里拉起同一条命令
|
|
837
|
+
return `${head}kill -TERM ${process.pid} 2>/dev/null\n`
|
|
838
|
+
+ `i=0\nwhile [ $i -lt 60 ]; do kill -0 ${process.pid} 2>/dev/null || break; i=$((i+1)); sleep 0.5; done\n`
|
|
839
|
+
+ `kill -KILL ${process.pid} 2>/dev/null\n`
|
|
840
|
+
+ `cd ${quote(cmd.cwd)} || exit 1\n`
|
|
841
|
+
+ `exec ${argv} >> '${log}' 2>&1\n`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* 重启 DeepSeek harness(dsh web)。
|
|
846
|
+
* 只重启「承载本插件的那个进程」(pid 匹配到的监管者或自身),绝不触碰 bridge。
|
|
847
|
+
* @returns {{ok:boolean, status:string, mode?:string, detail?:string, script?:string, log?:string}}
|
|
848
|
+
*/
|
|
849
|
+
function restartHarness(relayDir, opts = {}) {
|
|
850
|
+
if (skipsSystemOps()) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实重启" };
|
|
851
|
+
const cmd = selfCommand();
|
|
852
|
+
let mode = "relaunch";
|
|
853
|
+
let target = "";
|
|
854
|
+
if (platform() === "darwin") {
|
|
855
|
+
const label = launchdLabelForPid(process.pid);
|
|
856
|
+
if (label) { mode = "launchd"; target = `gui/${process.getuid()}/${label}`; }
|
|
857
|
+
} else if (platform() === "linux") {
|
|
858
|
+
const unit = systemdUnitForSelf();
|
|
859
|
+
if (unit) { mode = "systemd"; target = unit; }
|
|
860
|
+
}
|
|
861
|
+
const script = buildRestartScript(mode, target, cmd, relayDir);
|
|
862
|
+
if (opts.dryRun || process.env.DSH_RELAY_RESTART_DRYRUN === "1") {
|
|
863
|
+
// 测试/诊断用:只返回将要执行的脚本,不做任何真实动作
|
|
864
|
+
return { ok: true, status: "dry-run", mode, target, script, log: join(relayDir, RESTART_LOG_FILE) };
|
|
865
|
+
}
|
|
866
|
+
const scriptPath = join(relayDir, RESTART_SCRIPT_FILE);
|
|
867
|
+
try {
|
|
868
|
+
mkdirSync(relayDir, { recursive: true });
|
|
869
|
+
writeFileSync(scriptPath, script, { mode: 0o700 });
|
|
870
|
+
const log = join(relayDir, RESTART_LOG_FILE);
|
|
871
|
+
const child = spawn("/bin/sh", [scriptPath], {
|
|
872
|
+
detached: true,
|
|
873
|
+
cwd: homedir(),
|
|
874
|
+
env: spawnEnv(),
|
|
875
|
+
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
|
|
876
|
+
});
|
|
877
|
+
child.unref();
|
|
878
|
+
appendLogLine(relayDir, RESTART_LOG_FILE, `[restart] 已调度重启:mode=${mode}${target ? " target=" + target : ""} helper=${child.pid}`);
|
|
879
|
+
return {
|
|
880
|
+
ok: true,
|
|
881
|
+
status: "restarting",
|
|
882
|
+
mode,
|
|
883
|
+
target,
|
|
884
|
+
pid: child.pid,
|
|
885
|
+
log,
|
|
886
|
+
detail: mode === "launchd" ? "已交由 launchd 重启(约 2~5 秒)"
|
|
887
|
+
: mode === "systemd" ? "已交由 systemd 重启(约 2~5 秒)"
|
|
888
|
+
: "无监管者,已用同一条命令行自拉起(约 2~5 秒)",
|
|
889
|
+
};
|
|
890
|
+
} catch (e) {
|
|
891
|
+
return { ok: false, status: "failed", detail: `调度重启失败: ${e.message}` };
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
593
895
|
// ---------- relay API 代理(直连,不走系统代理;undici 默认忽略代理环境变量) ----------
|
|
594
896
|
|
|
595
897
|
async function relayFetch(relayDir, pathname, init) {
|
|
@@ -612,15 +914,135 @@ async function relayFetch(relayDir, pathname, init) {
|
|
|
612
914
|
}
|
|
613
915
|
}
|
|
614
916
|
|
|
615
|
-
// ----------
|
|
917
|
+
// ---------- bridge_secret 自愈(device-login 共享密钥) ----------
|
|
616
918
|
|
|
617
|
-
/**
|
|
618
|
-
|
|
619
|
-
|
|
919
|
+
/**
|
|
920
|
+
* 企业端 POST /api/device-login 强制校验 `x-dsh-bridge-secret`(缺失/失效 → 401 "需要有效设备密钥"),
|
|
921
|
+
* 密钥由服务端公开配置 /api/public-config 下发、与 dsh-setup.mjs 的 `bridge_secret` 同源。
|
|
922
|
+
*
|
|
923
|
+
* 一键安装器(npx @mrrisega/dsh-remote → dsh-setup.mjs)会在安装时取一次并写入 .dsh-config.json;
|
|
924
|
+
* 但**只装插件**的路径(dsh plugin add / 插件市场安装)没有这一步,于是:
|
|
925
|
+
* 首次安装 → 打开设置面板立即登录 → 面板请求 /dsh-remote/access-key & mobile-sessions
|
|
926
|
+
* → relayToken 拿不到 token(401 需要设备密钥)→ 面板显示「尚未登录」(其实是密钥缺失)
|
|
927
|
+
* 直到后台自愈(scheduleRuntime → ensureRuntime 跑一次 npx 安装器)把 bridge_secret 写回配置,
|
|
928
|
+
* 或用户手动刷新页面才恢复——这正是「首次安装后立即登录,二维码/设备列表报红字」的根因。
|
|
929
|
+
*
|
|
930
|
+
* 这里在插件侧补上同一份自愈:缺密钥就取一次、落盘并缓存;失败短退避后可再试,不阻塞面板。
|
|
931
|
+
*/
|
|
932
|
+
let bridgeSecretCache = { dir: "", secret: "", failedAt: 0 };
|
|
933
|
+
const BRIDGE_SECRET_RETRY_MS = 15_000;
|
|
934
|
+
|
|
935
|
+
/** 读取配置里已保存的 bridge_secret(无则空串)。 */
|
|
936
|
+
function readBridgeSecret(relayDir) {
|
|
937
|
+
try {
|
|
938
|
+
return String(loadConfig(relayDir).bridge_secret || "").trim();
|
|
939
|
+
} catch {
|
|
940
|
+
return "";
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* 取 device-login 共享密钥:配置已有 → 直接用;否则向企业端公开配置取一次并写回配置(内存缓存兜底)。
|
|
946
|
+
* @param {string} relayDir 配置目录
|
|
947
|
+
* @param {object} [cfgOverride] 尚未落盘的配置(登录请求刚写入 phone/password 时用),仅用于判断模式
|
|
948
|
+
* @param {{force?: boolean, timeoutMs?: number}} [opts] force=true 跳过本地文件/缓存强制重取;
|
|
949
|
+
* timeoutMs 限制单次公开配置请求耗时(登录路径用更短的上限,避免拖慢登录响应)
|
|
950
|
+
* @returns {Promise<string>} 密钥(取不到为空串,调用方按“中继未就绪”降级,不抛错)
|
|
951
|
+
*/
|
|
952
|
+
async function bridgeSecretOf(relayDir, cfgOverride, opts) {
|
|
953
|
+
const force = !!(opts && opts.force);
|
|
954
|
+
if (!force) {
|
|
955
|
+
const local = readBridgeSecret(relayDir);
|
|
956
|
+
if (local) {
|
|
957
|
+
bridgeSecretCache = { dir: relayDir, secret: local, failedAt: 0 };
|
|
958
|
+
return local;
|
|
959
|
+
}
|
|
960
|
+
if (bridgeSecretCache.dir === relayDir && bridgeSecretCache.secret) return bridgeSecretCache.secret;
|
|
961
|
+
if (bridgeSecretCache.dir === relayDir && Date.now() - bridgeSecretCache.failedAt < BRIDGE_SECRET_RETRY_MS) return ""; // 负缓存:别把公开配置打爆
|
|
962
|
+
}
|
|
963
|
+
const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
|
|
964
|
+
if (cfg.local_key) return ""; // 自建模式走 /_login,不用设备密钥
|
|
620
965
|
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
966
|
+
const timeoutMs = Number((opts && opts.timeoutMs) || 0) > 0 ? Number(opts.timeoutMs) : 6000;
|
|
967
|
+
let secret = "";
|
|
968
|
+
try {
|
|
969
|
+
const r = await fetch(`${api}/api/public-config`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
970
|
+
if (r.ok) {
|
|
971
|
+
const d = await r.json();
|
|
972
|
+
secret = String((d && d.bridge_secret) || "").trim();
|
|
973
|
+
}
|
|
974
|
+
} catch { /* 网络抖动:负缓存短退避后再试 */ }
|
|
975
|
+
if (!secret) {
|
|
976
|
+
bridgeSecretCache = { dir: relayDir, secret: "", failedAt: Date.now() };
|
|
977
|
+
return "";
|
|
978
|
+
}
|
|
979
|
+
bridgeSecretCache = { dir: relayDir, secret, failedAt: 0 };
|
|
980
|
+
// 落盘(与 dsh-setup.mjs 同一份配置):即使本进程退出,下次启动也不再缺密钥/不再是旧密钥
|
|
981
|
+
let healed = false;
|
|
982
|
+
try {
|
|
983
|
+
const cur = loadConfig(relayDir);
|
|
984
|
+
if (!cur.bridge_secret || (force && cur.bridge_secret !== secret)) {
|
|
985
|
+
cur.bridge_secret = secret;
|
|
986
|
+
saveConfig(relayDir, cur);
|
|
987
|
+
healed = true;
|
|
988
|
+
}
|
|
989
|
+
} catch { /* 写盘失败不致命:内存缓存本次进程内仍生效 */ }
|
|
990
|
+
// 密钥是「从无到有」补上的:已经在跑的 bridge 是用空密钥起的,隧道认证同样会失败——
|
|
991
|
+
// 后台重启一次让它带上密钥(一次性事件,失败只记日志不打断面板请求)。
|
|
992
|
+
if (healed && !UNINSTALLED_DIRS.has(relayDir)) {
|
|
993
|
+
setTimeout(() => {
|
|
994
|
+
try {
|
|
995
|
+
const st = launchdStatus();
|
|
996
|
+
if (!st.running) return;
|
|
997
|
+
const r = startBridge(relayDir);
|
|
998
|
+
console.log(`[dsh-remote-web] 已补记 bridge_secret,后台重启 bridge 使其生效: ${r.status}`);
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
console.warn(`[dsh-remote-web] 补记 bridge_secret 后重启 bridge 失败: ${e.message}`);
|
|
1001
|
+
}
|
|
1002
|
+
}, 50).unref?.();
|
|
1003
|
+
}
|
|
1004
|
+
return secret;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ---------- v2 账号/配额/邀请代理(我的信息 与 免费额度提示) ----------
|
|
1008
|
+
|
|
1009
|
+
/** token 获取失败的原因(供 UI 区分“未登录”与“中继未就绪”,不再一律谎报“尚未登录”)。 */
|
|
1010
|
+
const AUTH_NO_CREDENTIALS = "no_credentials"; // 本机没存账号/自建密钥 → 该去登录
|
|
1011
|
+
const AUTH_BAD_CREDENTIALS = "bad_credentials"; // 账号密码被企业端拒绝 → 该重新登录
|
|
1012
|
+
const AUTH_RELAY_UNREACHABLE = "relay_unreachable"; // 网络/5xx/超时 → 稍后重试
|
|
1013
|
+
const AUTH_RELAY_NOT_READY = "relay_not_ready"; // 凭证齐全但企业端未接受(缺失/轮换中的设备密钥等)→ 稍后重试
|
|
1014
|
+
|
|
1015
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1016
|
+
|
|
1017
|
+
/** 单次 device-login:返回 { ok, status, token, code, detail }(不抛错,便于按原因降级/重试)。 */
|
|
1018
|
+
async function deviceLoginOnce(api, cfg, secret) {
|
|
1019
|
+
try {
|
|
1020
|
+
const r = await fetch(`${api}/api/device-login`, {
|
|
1021
|
+
method: "POST",
|
|
1022
|
+
headers: { "content-type": "application/json", ...(secret ? { "x-dsh-bridge-secret": secret } : {}) },
|
|
1023
|
+
body: JSON.stringify({ phone: cfg.phone, email: cfg.phone, password: cfg.password }),
|
|
1024
|
+
signal: AbortSignal.timeout(6000)
|
|
1025
|
+
});
|
|
1026
|
+
const text = await r.text();
|
|
1027
|
+
let d = null;
|
|
1028
|
+
try { d = JSON.parse(text); } catch { d = null; }
|
|
1029
|
+
return {
|
|
1030
|
+
ok: r.ok,
|
|
1031
|
+
status: r.status,
|
|
1032
|
+
token: (d && d.token) || "",
|
|
1033
|
+
code: (d && d.error && d.error.code) || "",
|
|
1034
|
+
detail: (d && d.error && (d.error.message || d.error.code)) || "",
|
|
1035
|
+
};
|
|
1036
|
+
} catch (e) {
|
|
1037
|
+
return { ok: false, status: 0, token: "", code: "", detail: e.message };
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** 单次本地认证(自建模式):POST {隧道同源}/_login。 */
|
|
1042
|
+
async function localLoginOnce(cfg, api) {
|
|
1043
|
+
const fallbackApi = (api || cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
1044
|
+
try {
|
|
1045
|
+
const u = new URL(cfg.tunnel_url || fallbackApi.replace(/^https?/, "wss"));
|
|
624
1046
|
u.protocol = u.protocol === "wss:" ? "https:" : "http:";
|
|
625
1047
|
u.pathname = "/_login";
|
|
626
1048
|
const r = await fetch(u.toString(), {
|
|
@@ -629,20 +1051,59 @@ async function relayToken(relayDir) {
|
|
|
629
1051
|
body: JSON.stringify({ key: cfg.local_key }),
|
|
630
1052
|
signal: AbortSignal.timeout(6000)
|
|
631
1053
|
});
|
|
632
|
-
if (!r.ok) return "";
|
|
1054
|
+
if (!r.ok) return { ok: false, status: r.status, token: "", code: "", detail: "" };
|
|
633
1055
|
const d = await r.json();
|
|
634
|
-
return d.token || "";
|
|
1056
|
+
return { ok: true, status: 200, token: (d && d.token) || "", code: "", detail: "" };
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
return { ok: false, status: 0, token: "", code: "", detail: e.message };
|
|
635
1059
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* 获取短期 relay token:SaaS → device-login;本地模式 → /_login(从隧道地址推导同源)。
|
|
1064
|
+
* 与旧版差异:① 缺 device-login 共享密钥时自愈补齐;② 服务端轮换密钥(401/403)自动重取重试;
|
|
1065
|
+
* ③ 网络抖动/5xx 退避重试一次;④ 失败时给出**可判定原因**而非空串(消除“尚未登录”误报)。
|
|
1066
|
+
* @returns {Promise<{token:string, reason:string, status:number, detail:string}>}
|
|
1067
|
+
*/
|
|
1068
|
+
async function relayTokenWithReason(relayDir, cfgOverride) {
|
|
1069
|
+
const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
|
|
1070
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
1071
|
+
if (cfg.local_key) {
|
|
1072
|
+
let r = await localLoginOnce(cfg, api);
|
|
1073
|
+
if (!r.token && (r.status === 0 || r.status >= 500)) { await sleep(350); r = await localLoginOnce(cfg, api); }
|
|
1074
|
+
if (r.token) return { token: r.token, reason: "", status: r.status, detail: "" };
|
|
1075
|
+
return {
|
|
1076
|
+
token: "",
|
|
1077
|
+
reason: r.status === 401 || r.status === 403 ? AUTH_BAD_CREDENTIALS : AUTH_RELAY_UNREACHABLE,
|
|
1078
|
+
status: r.status,
|
|
1079
|
+
detail: r.detail,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
if (!cfg.phone || !cfg.password) return { token: "", reason: AUTH_NO_CREDENTIALS, status: 0, detail: "" };
|
|
1083
|
+
|
|
1084
|
+
let secret = await bridgeSecretOf(relayDir, cfgOverride);
|
|
1085
|
+
let r = await deviceLoginOnce(api, cfg, secret);
|
|
1086
|
+
// 带了密钥仍被拒 → 可能是服务端轮换了共享密钥:丢弃缓存强制重取一次再试
|
|
1087
|
+
if (!r.token && (r.status === 401 || r.status === 403) && secret) {
|
|
1088
|
+
const fresh = await bridgeSecretOf(relayDir, cfgOverride, { force: true });
|
|
1089
|
+
if (fresh && fresh !== secret) r = await deviceLoginOnce(api, cfg, fresh);
|
|
1090
|
+
}
|
|
1091
|
+
// 网络抖动/超时/5xx:短退避重试一次(首次安装后中继握手偶发失败很常见)
|
|
1092
|
+
if (!r.token && (r.status === 0 || r.status >= 500)) {
|
|
1093
|
+
await sleep(350);
|
|
1094
|
+
r = await deviceLoginOnce(api, cfg, await bridgeSecretOf(relayDir, cfgOverride));
|
|
1095
|
+
}
|
|
1096
|
+
if (r.token) return { token: r.token, reason: "", status: r.status, detail: "" };
|
|
1097
|
+
if (r.code === "bad_credentials") return { token: "", reason: AUTH_BAD_CREDENTIALS, status: r.status, detail: r.detail };
|
|
1098
|
+
// 凭证齐全但企业端没给 token(设备密钥缺失/轮换中、限流、其它 4xx)→ 可重试,不谎报“未登录”
|
|
1099
|
+
if (r.status === 0 || r.status >= 500) return { token: "", reason: AUTH_RELAY_UNREACHABLE, status: r.status, detail: r.detail };
|
|
1100
|
+
return { token: "", reason: AUTH_RELAY_NOT_READY, status: r.status, detail: r.detail };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** 兼容旧调用方(反馈/账号/配额等只需 token 字符串)。 */
|
|
1104
|
+
async function relayToken(relayDir) {
|
|
1105
|
+
const r = await relayTokenWithReason(relayDir).catch(() => ({ token: "" }));
|
|
1106
|
+
return r.token || "";
|
|
646
1107
|
}
|
|
647
1108
|
|
|
648
1109
|
/** 我的信息:SaaS 账号的生效套餐/到期日/邀请码(经 /api/me)。 */
|
|
@@ -700,6 +1161,35 @@ async function relayInviteRecords(relayDir) {
|
|
|
700
1161
|
return { records: r.body.records || [], rewards: r.body.rewards || [] };
|
|
701
1162
|
}
|
|
702
1163
|
|
|
1164
|
+
// ---------- 交流群二维码(运营配置,公开读取) ----------
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* 交流群二维码/客服微信:企业端配置经公开配置 /api/public-config 的 community 字段下发
|
|
1168
|
+
* (community.qrcode 形如 "/qr/group-qr.png")。面板与反馈页展示「加入交流群」用。
|
|
1169
|
+
*
|
|
1170
|
+
* 面板跑在电脑本机 dsh web(http://127.0.0.1:3080),相对路径无法直接当 <img src>,
|
|
1171
|
+
* 故这里按企业端公开约定拼成绝对地址:<api_url><qrcode>(与手机端 PWA 的 API_BASE + qr 一致)。
|
|
1172
|
+
* 30s 内存缓存:面板与反馈卡都会取,避免重复打公开配置。
|
|
1173
|
+
*/
|
|
1174
|
+
const COMMUNITY_TTL_MS = 30_000;
|
|
1175
|
+
let communityCache = { dir: "", at: 0, data: null };
|
|
1176
|
+
|
|
1177
|
+
async function communityInfo(relayDir) {
|
|
1178
|
+
if (communityCache.data && communityCache.dir === relayDir && Date.now() - communityCache.at < COMMUNITY_TTL_MS) {
|
|
1179
|
+
return communityCache.data;
|
|
1180
|
+
}
|
|
1181
|
+
const cfg = loadConfig(relayDir);
|
|
1182
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
1183
|
+
const r = await relayFetch(relayDir, "/api/public-config");
|
|
1184
|
+
const b = r.ok && r.body && typeof r.body === "object" ? r.body : {};
|
|
1185
|
+
const c = b.community && typeof b.community === "object" ? b.community : {};
|
|
1186
|
+
const raw = String(c.qrcode || "").trim();
|
|
1187
|
+
const qrcode = raw && !/^https?:/i.test(raw) ? api + (raw.startsWith("/") ? raw : `/${raw}`) : raw;
|
|
1188
|
+
const data = { qrcode, wechat: String(c.wechat || "") };
|
|
1189
|
+
communityCache = { dir: relayDir, at: Date.now(), data };
|
|
1190
|
+
return data;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
703
1193
|
// ---------- 一次性访问密钥 / 已授权设备代理(E1 企业端新增 auth-key / mobile-sessions) ----------
|
|
704
1194
|
|
|
705
1195
|
/** 从 relay 响应里尽量提取人类可读错误信息(兼容 {error:{message}} / {error:".."} / {message} / 纯文本)。 */
|
|
@@ -722,6 +1212,57 @@ function notLoggedInJson() {
|
|
|
722
1212
|
return { ok: false, error: "尚未登录:请先在「账号」卡片登录手机号账号(或切换到自建服务)后重试", hint: "login_required" };
|
|
723
1213
|
}
|
|
724
1214
|
|
|
1215
|
+
/**
|
|
1216
|
+
* 凭证齐全但拿不到 relay token 时的统一返回:区分「密码失效需重新登录」与「中继暂未就绪可重试」。
|
|
1217
|
+
* 关键:不再把中继未就绪/设备密钥缺失谎报成“尚未登录”(旧版 UI 因此显示误导性红字)。
|
|
1218
|
+
*/
|
|
1219
|
+
function relayAuthFailureJson(reason, detail) {
|
|
1220
|
+
if (reason === AUTH_NO_CREDENTIALS) return { status: 401, body: notLoggedInJson() };
|
|
1221
|
+
if (reason === AUTH_BAD_CREDENTIALS) {
|
|
1222
|
+
return {
|
|
1223
|
+
status: 401,
|
|
1224
|
+
body: {
|
|
1225
|
+
ok: false,
|
|
1226
|
+
error: "本机保存的账号密码已被中继拒绝(可能已在别处修改过密码):请用新密码重新登录「账号」卡片后重试",
|
|
1227
|
+
hint: "relogin_required",
|
|
1228
|
+
retryable: false,
|
|
1229
|
+
detail: String(detail || ""),
|
|
1230
|
+
},
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
const unreachable = reason === AUTH_RELAY_UNREACHABLE;
|
|
1234
|
+
return {
|
|
1235
|
+
status: 503,
|
|
1236
|
+
body: {
|
|
1237
|
+
ok: false,
|
|
1238
|
+
error: unreachable
|
|
1239
|
+
? "中继服务暂时不可达(网络波动或中继正在重启),请稍后重试"
|
|
1240
|
+
: "账号已登录,但中继连接尚未就绪(正在建立安全通道),请稍后重试",
|
|
1241
|
+
hint: unreachable ? "relay_unreachable" : "relay_not_ready",
|
|
1242
|
+
retryable: true,
|
|
1243
|
+
detail: String(detail || ""),
|
|
1244
|
+
},
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* 取 relay token 或直接回错误响应(面板代理统一入口)。
|
|
1250
|
+
* @returns {Promise<string>} token;为空串表示已写出错误响应(调用方直接 return)
|
|
1251
|
+
*/
|
|
1252
|
+
async function relayTokenOrReject(relayDir, res, cfgOverride) {
|
|
1253
|
+
const a = await relayTokenWithReason(relayDir, cfgOverride).catch((e) => ({
|
|
1254
|
+
token: "",
|
|
1255
|
+
reason: AUTH_RELAY_UNREACHABLE,
|
|
1256
|
+
status: 0,
|
|
1257
|
+
detail: e && e.message,
|
|
1258
|
+
}));
|
|
1259
|
+
if (a.token) return a.token;
|
|
1260
|
+
const { status, body } = relayAuthFailureJson(a.reason, a.detail);
|
|
1261
|
+
console.warn(`[dsh-remote-web] 取 relay token 失败(${a.reason}/${a.status}): ${a.detail || "无详情"}`);
|
|
1262
|
+
sendJson(res, status, body);
|
|
1263
|
+
return "";
|
|
1264
|
+
}
|
|
1265
|
+
|
|
725
1266
|
/**
|
|
726
1267
|
* 透传企业端响应体:契约字段可能在顶层或 data 子对象里(容错)。
|
|
727
1268
|
* 返回扁平对象;数组字段只取首层数组。
|
|
@@ -737,8 +1278,8 @@ function flattenRelayBody(r) {
|
|
|
737
1278
|
* 契约容错:url 必须可用;qr_data_url 取不到时返回 null(UI 只展示链接并说明“二维码暂不可用”,不报错)。
|
|
738
1279
|
*/
|
|
739
1280
|
async function proxyCreateAccessKey(relayDir, res) {
|
|
740
|
-
const token = await
|
|
741
|
-
if (!token) return
|
|
1281
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1282
|
+
if (!token) return;
|
|
742
1283
|
const r = await relayFetch(relayDir, "/api/auth-key", {
|
|
743
1284
|
method: "POST",
|
|
744
1285
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
@@ -766,8 +1307,8 @@ async function proxyCreateAccessKey(relayDir, res) {
|
|
|
766
1307
|
* GET /dsh-remote/mobile-sessions → 已授权设备列表(企业端 GET /api/mobile-sessions,Bearer)。
|
|
767
1308
|
*/
|
|
768
1309
|
async function proxyMobileSessions(relayDir, res) {
|
|
769
|
-
const token = await
|
|
770
|
-
if (!token) return
|
|
1310
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1311
|
+
if (!token) return;
|
|
771
1312
|
const r = await relayFetch(relayDir, "/api/mobile-sessions", {
|
|
772
1313
|
method: "GET",
|
|
773
1314
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -788,8 +1329,8 @@ async function proxyRevokeMobileSession(relayDir, req, res) {
|
|
|
788
1329
|
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
789
1330
|
const id = String(body.id ?? "").trim();
|
|
790
1331
|
if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
|
|
791
|
-
const token = await
|
|
792
|
-
if (!token) return
|
|
1332
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1333
|
+
if (!token) return;
|
|
793
1334
|
const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}/revoke`, {
|
|
794
1335
|
method: "POST",
|
|
795
1336
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -812,8 +1353,8 @@ async function proxyDeleteMobileSession(relayDir, req, res) {
|
|
|
812
1353
|
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
813
1354
|
const id = String(body.id ?? "").trim();
|
|
814
1355
|
if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
|
|
815
|
-
const token = await
|
|
816
|
-
if (!token) return
|
|
1356
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1357
|
+
if (!token) return;
|
|
817
1358
|
const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}`, {
|
|
818
1359
|
method: "DELETE",
|
|
819
1360
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -831,8 +1372,8 @@ async function proxyDeleteMobileSession(relayDir, req, res) {
|
|
|
831
1372
|
* (企业端 POST /api/mobile-sessions/purge,Bearer)。
|
|
832
1373
|
*/
|
|
833
1374
|
async function proxyPurgeMobileSessions(relayDir, res) {
|
|
834
|
-
const token = await
|
|
835
|
-
if (!token) return
|
|
1375
|
+
const token = await relayTokenOrReject(relayDir, res);
|
|
1376
|
+
if (!token) return;
|
|
836
1377
|
const r = await relayFetch(relayDir, "/api/mobile-sessions/purge", {
|
|
837
1378
|
method: "POST",
|
|
838
1379
|
headers: { authorization: `Bearer ${token}` },
|
|
@@ -918,11 +1459,17 @@ async function composeStatus(relayDir) {
|
|
|
918
1459
|
launchd,
|
|
919
1460
|
manual,
|
|
920
1461
|
running: launchd.running || manual.bridge.length > 0,
|
|
1462
|
+
// 桌面运行环境(固化运行时 dsh-setup.mjs)是否就绪:面板据此区分
|
|
1463
|
+
// 「运行环境没装(正在自动补装)」与「装了但没跑」,不再把崩溃循环显示成「运行中」。
|
|
1464
|
+
runtimeReady: runtimeReady(relayDir),
|
|
921
1465
|
bindError,
|
|
922
1466
|
// E2EE 开关状态(bridge 写 .e2ee-state.json;文件缺失 = 未启用明文):
|
|
923
1467
|
// {enabled, reason, profile, epoch, caps},供面板「📱 远程访问」卡展示加密状态。
|
|
924
1468
|
e2ee: readE2eeStateFile(relayDir),
|
|
925
1469
|
},
|
|
1470
|
+
// 是否需要重启 DeepSeek harness(首次安装 / 在线更新后必须重启才加载插件本体):
|
|
1471
|
+
// {pending, kind: first-install|update, reason, at},供面板顶部醒目提示 + 重启按钮。
|
|
1472
|
+
restart: readRestartState(relayDir),
|
|
926
1473
|
// 隐私审计(2026-09):不再下发真实 hostname(移除 host 字段)——设备标识统一走 deviceId/服务端登记名
|
|
927
1474
|
};
|
|
928
1475
|
}
|
|
@@ -1030,7 +1577,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
1030
1577
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1031
1578
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1032
1579
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
1033
|
-
const PLUGIN_VERSION = "0.6.
|
|
1580
|
+
const PLUGIN_VERSION = "0.6.2";
|
|
1034
1581
|
const UPDATE_LOG = ".dsh-update.log";
|
|
1035
1582
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
1036
1583
|
|
|
@@ -1097,6 +1644,8 @@ function runOnlineUpdate(relayDir) {
|
|
|
1097
1644
|
}
|
|
1098
1645
|
appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}(${retried ? "默认源" : "官方源"})`);
|
|
1099
1646
|
clear();
|
|
1647
|
+
// 在线更新改写了插件/运行环境文件 → 必须重启 DeepSeek harness 才装载新版本
|
|
1648
|
+
if (code === 0) markRestartPending(relayDir, "update", "已在线更新,需要重启 DeepSeek harness 生效");
|
|
1100
1649
|
});
|
|
1101
1650
|
child.on("error", (e) => {
|
|
1102
1651
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
|
|
@@ -1282,6 +1831,14 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1282
1831
|
});
|
|
1283
1832
|
},
|
|
1284
1833
|
},
|
|
1834
|
+
// 交流群二维码(设置面板「加入交流群」按钮 + 用户反馈页展示;未配置返回空串,UI 不展示入口)
|
|
1835
|
+
{
|
|
1836
|
+
method: "GET",
|
|
1837
|
+
path: "/dsh-remote/community",
|
|
1838
|
+
handler: async (_req, res) => {
|
|
1839
|
+
sendJson(res, 200, { ok: true, ...(await communityInfo(relayDir)) });
|
|
1840
|
+
},
|
|
1841
|
+
},
|
|
1285
1842
|
// 一次性访问密钥(📱 远程访问卡):GET 即创建新 key,企业端 POST /api/auth-key(Bearer)
|
|
1286
1843
|
{
|
|
1287
1844
|
method: "GET",
|
|
@@ -1352,6 +1909,14 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1352
1909
|
}
|
|
1353
1910
|
// SaaS 权威归一化:清除自建残留(local_key/假 tunnel_url),api/tunnel 一律按云端重算
|
|
1354
1911
|
applySaaSMode(cfg);
|
|
1912
|
+
// device-login 共享密钥自愈:只装插件的路径没有安装器那一步,缺密钥会导致
|
|
1913
|
+
// 「首次安装后立即登录」时面板拿不到二维码/设备列表(旧版要等后台自愈或手动刷新)。
|
|
1914
|
+
// 这里在启动 bridge 之前补齐,bridge 首次启动即可带上密钥。
|
|
1915
|
+
if (!cfg.bridge_secret) {
|
|
1916
|
+
// 3s 上限:这次取密钥在登录响应路径上,宁可先放行(后续请求还会自愈)也不拖慢登录
|
|
1917
|
+
const secret = await bridgeSecretOf(relayDir, cfg, { timeoutMs: 3000 });
|
|
1918
|
+
if (secret) cfg.bridge_secret = secret;
|
|
1919
|
+
}
|
|
1355
1920
|
}
|
|
1356
1921
|
saveConfig(relayDir, cfg);
|
|
1357
1922
|
const bridgeRestart = startBridge(relayDir);
|
|
@@ -1370,12 +1935,29 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1370
1935
|
sendJson(res, 200, { ok: true, ...(await composeStatus(relayDir)) });
|
|
1371
1936
|
},
|
|
1372
1937
|
},
|
|
1938
|
+
{
|
|
1939
|
+
method: "POST",
|
|
1940
|
+
path: "/dsh-remote/harness/restart",
|
|
1941
|
+
handler: async (_req, res) => {
|
|
1942
|
+
// 首次安装/在线更新后重启 DeepSeek harness:插件本体与浏览器半在进程启动时装载,
|
|
1943
|
+
// 不重启则面板入口与 /dsh-remote/* 路由都不会出现。只重启承载本插件的进程。
|
|
1944
|
+
const r = restartHarness(relayDir);
|
|
1945
|
+
const payload = { ...(await composeStatus(relayDir)), ...r };
|
|
1946
|
+
if (!r.ok) payload.error = r.detail || r.status || "重启失败";
|
|
1947
|
+
sendJson(res, r.ok ? 200 : 500, payload);
|
|
1948
|
+
},
|
|
1949
|
+
},
|
|
1373
1950
|
{
|
|
1374
1951
|
method: "POST",
|
|
1375
1952
|
path: "/dsh-remote/start",
|
|
1376
1953
|
handler: async (_req, res) => {
|
|
1377
1954
|
const r = startBridge(relayDir);
|
|
1378
|
-
|
|
1955
|
+
// 【0.6.2】composeStatus 自带 ok:true(那是「状态查询成功」),必须先展开再覆盖,
|
|
1956
|
+
// 否则不管启动成没成功都回 ok:true → 面板一律弹「✅ bridge 已启动」。
|
|
1957
|
+
// 失败时额外带上 error:浏览器半的 api() 优先用它做提示文案,避免只看到「HTTP 500」。
|
|
1958
|
+
const payload = { ...(await composeStatus(relayDir)), ...r };
|
|
1959
|
+
if (!r.ok) payload.error = r.detail || r.status || "启动失败";
|
|
1960
|
+
sendJson(res, r.ok ? 200 : 500, payload);
|
|
1379
1961
|
},
|
|
1380
1962
|
},
|
|
1381
1963
|
{
|
|
@@ -1383,7 +1965,9 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1383
1965
|
path: "/dsh-remote/stop",
|
|
1384
1966
|
handler: async (_req, res) => {
|
|
1385
1967
|
const r = stopBridge();
|
|
1386
|
-
|
|
1968
|
+
const payload = { ...(await composeStatus(relayDir)), ...r };
|
|
1969
|
+
if (!r.ok) payload.error = r.detail || r.status || "停止失败";
|
|
1970
|
+
sendJson(res, r.ok ? 200 : 500, payload);
|
|
1387
1971
|
},
|
|
1388
1972
|
},
|
|
1389
1973
|
{
|
|
@@ -1581,10 +2165,29 @@ export function apply(ctx, config = {}) {
|
|
|
1581
2165
|
UNINSTALLED_DIRS.delete(relayDir);
|
|
1582
2166
|
// 清理上次进程残留的安装/更新 marker(宿主被重启/强杀时子进程清理回调会丢失)
|
|
1583
2167
|
sweepStaleMarkers(relayDir);
|
|
2168
|
+
// 「待重启」状态结清 + 运行时安装检测:
|
|
2169
|
+
// - bootId 变了 = 这次装载发生在新进程里 → 重启已完成,撤下提示;
|
|
2170
|
+
// - 插件文件晚于本进程启动才落盘(市场安装/在线更新在运行中改写)→ 当前进程里
|
|
2171
|
+
// 根本没有本插件的路由与面板,必须重启 DeepSeek harness 才能生效。
|
|
2172
|
+
const settled = settleRestartState(relayDir);
|
|
2173
|
+
if (settled.cleared) {
|
|
2174
|
+
ctx.logger?.info?.("dsh-remote-web: 检测到 DeepSeek harness 已重启,撤下「需要重启」提示");
|
|
2175
|
+
}
|
|
2176
|
+
if (!readRestartState(relayDir).pending && pluginInstalledAfterBoot()) {
|
|
2177
|
+
markRestartPending(relayDir, "first-install", "首次安装需要重启 DeepSeek harness");
|
|
2178
|
+
ctx.logger?.info?.("dsh-remote-web: 插件文件晚于本次进程启动 → 需要重启 DeepSeek harness");
|
|
2179
|
+
}
|
|
1584
2180
|
ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-web: /dsh-remote routes");
|
|
1585
2181
|
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
1586
2182
|
ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
|
|
1587
2183
|
// 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
|
|
1588
2184
|
ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-web: runtime self-provision");
|
|
2185
|
+
// 【0.6.2】市场安装路径的「装到用户电脑上」第一步:插件加载即后台补装桌面运行环境
|
|
2186
|
+
// (bridge + 自启动),不再等用户先登录、也不再依赖 launchd 状态判断是否「已在运行」。
|
|
2187
|
+
// 已就绪时是零副作用 no-op;正在安装中/已卸载时 ensureRuntime 自身幂等挡下。
|
|
2188
|
+
const provisioned = ensureRuntime(relayDir);
|
|
2189
|
+
if (!provisioned && !runtimeReady(relayDir)) {
|
|
2190
|
+
ctx.logger?.info?.(`dsh-remote-web: 桌面运行环境缺失,已在后台自动安装(relayDir=${relayDir})`);
|
|
2191
|
+
}
|
|
1589
2192
|
ctx.logger?.info?.(`dsh-remote-web: /dsh-remote routes ready (relayDir=${relayDir})`);
|
|
1590
2193
|
}
|