@mrrisega/dsh-remote 0.6.11 → 0.6.13
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/clients/dsh-remote/dsh-bridge.mjs +123 -2
- package/clients/dsh-remote/mobile-adapter.mjs +108 -4
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
- package/dsh-setup.mjs +55 -15
- package/package.json +4 -4
- package/packages/dsh-remote-web/lib/client.js +137 -0
- package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
- package/packages/dsh-remote-web/lib/index.js +748 -18
- package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
- package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
- package/packages/dsh-remote-web/package.json +5 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1572 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1679 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
- package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
- package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
- package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
|
@@ -20,12 +20,13 @@
|
|
|
20
20
|
// - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
|
|
21
21
|
//
|
|
22
22
|
// 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
|
|
23
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, constants as fsConstants } from "node:fs";
|
|
23
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, copyFileSync, cpSync, constants as fsConstants } from "node:fs";
|
|
24
24
|
import { join, dirname, sep, delimiter } from "node:path";
|
|
25
25
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
26
26
|
import { randomUUID } from "node:crypto";
|
|
27
27
|
import { homedir, tmpdir } from "node:os";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { buildFixPrompt, REPORT_TITLE, REPORT_FORMAT, REPORT_KEYS } from "./fix-prompt.js";
|
|
29
30
|
|
|
30
31
|
/** 本插件在 host 侧的服务依赖。 */
|
|
31
32
|
export const inject = ["webServer"];
|
|
@@ -90,6 +91,88 @@ const DEFAULT_API = String(process.env.DSH_RELAY_DEFAULT_API || "").trim()
|
|
|
90
91
|
|| "https://n.risegao.cn:13443/relay-api";
|
|
91
92
|
const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
|
|
92
93
|
|
|
94
|
+
// ---------- 上游 dsh web 地址(bridge/watcher 都要连它,绝不写死 3080) ----------
|
|
95
|
+
//
|
|
96
|
+
// 【2026-09-23 用户实测事故】这里(以及运行时的 checkUpstream)原本把上游**写死**
|
|
97
|
+
// `http://127.0.0.1:3080`:只要用户的 dsh web 不在默认端口(`dsh web --port 8090`、
|
|
98
|
+
// profile 里配了 port、或 `--port 0` 让系统分配空闲端口),watcher 的「dsh web 在线吗」
|
|
99
|
+
// 探测就永远失败 → **bridge 永远不启动**,而面板只会说「正在启动 Bridge…」(错误为空)
|
|
100
|
+
// —— 用户反复修复也没有任何变化。
|
|
101
|
+
// 这个插件就跑在 dsh web 进程里:`ctx.webServer` 是它自己的 HTTP 服务(`port` 是实际监听端口,
|
|
102
|
+
// `config.port=0` 时也已被系统分配并回填),因此上游地址必须是**问出来**的,不是猜出来的。
|
|
103
|
+
let webServerCtx = null;
|
|
104
|
+
const FALLBACK_UPSTREAM = "http://127.0.0.1:3080";
|
|
105
|
+
/** 从 dsh web 服务实例取实际端口(apply 时可能还没 listen 完,故调用时再读)。 */
|
|
106
|
+
function boundWebPort() {
|
|
107
|
+
let ws = null;
|
|
108
|
+
try { ws = webServerCtx?.webServer || null; } catch { ws = null; } // 宿主 ctx 已销毁时静默回落
|
|
109
|
+
for (const v of [ws?.port, ws?.config?.port]) {
|
|
110
|
+
const n = Number(v);
|
|
111
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
112
|
+
}
|
|
113
|
+
const raw = String(process.env.DSH_WEB_URL || "").trim();
|
|
114
|
+
if (raw) {
|
|
115
|
+
try {
|
|
116
|
+
const n = Number(new URL(raw).port);
|
|
117
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
118
|
+
} catch { /* 非 URL:忽略 */ }
|
|
119
|
+
}
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 本机 dsh web 的**回环**上游地址(bridge 的 loopback 围栏只认 127.0.0.1,
|
|
124
|
+
* 所以即使 dsh web 绑的是 0.0.0.0,上游也照样写回环地址)。
|
|
125
|
+
* 优先级:显式 DSH_BRIDGE_UPSTREAM(用户/部署覆盖)> 实际监听端口 > DSH_WEB_URL 的端口 > 3080。
|
|
126
|
+
*/
|
|
127
|
+
function upstreamUrl() {
|
|
128
|
+
const explicit = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim();
|
|
129
|
+
if (explicit) return explicit.replace(/\/+$/, "");
|
|
130
|
+
const port = boundWebPort();
|
|
131
|
+
return port ? `http://127.0.0.1:${port}` : FALLBACK_UPSTREAM;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 把上游地址落盘(`<relayDir>/.dsh-upstream`),供**没有环境变量**的那些守护读取:
|
|
136
|
+
* 开机自启的 watcher(Windows 登录任务 / launchd)由系统拉起,拿不到本进程的 env,
|
|
137
|
+
* 只有插件半知道 dsh web 真实监听在哪个端口。运行时(0.6.12+)读这个文件当第二优先来源。
|
|
138
|
+
* 内容没变就不写(面板每轮都会走到这里,别把磁盘写热)。
|
|
139
|
+
*/
|
|
140
|
+
const UPSTREAM_FILE = ".dsh-upstream";
|
|
141
|
+
function publishUpstream(relayDir) {
|
|
142
|
+
if (skipsSystemOps()) return; // 测试/诊断隔离:不得对磁盘产生任何副作用(含创建配置目录)
|
|
143
|
+
try {
|
|
144
|
+
const url = upstreamUrl();
|
|
145
|
+
const file = join(relayDir, UPSTREAM_FILE);
|
|
146
|
+
let cur = "";
|
|
147
|
+
try { cur = readFileSync(file, "utf8").trim(); } catch { /* 首次 */ }
|
|
148
|
+
if (cur === url) return;
|
|
149
|
+
mkdirSync(relayDir, { recursive: true });
|
|
150
|
+
writeFileSync(file, url + "\n", { mode: 0o600 });
|
|
151
|
+
} catch { /* 非关键:读不到就沿用运行时的回退链 */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 上游是否可达(面板诊断用,与 watcher 的判据同构:**只认网络失败**,
|
|
156
|
+
* 4xx/5xx 也算「端口在听」——那样 bridge 连得上,问题在别处)。
|
|
157
|
+
* 带 TTL 缓存:面板 2~3s 轮询一次,不能每次都发一个请求。
|
|
158
|
+
*/
|
|
159
|
+
const UPSTREAM_PROBE_TTL_MS = 5000;
|
|
160
|
+
const upstreamProbes = new Map();
|
|
161
|
+
async function probeUpstream(relayDir) {
|
|
162
|
+
const url = upstreamUrl();
|
|
163
|
+
const cached = upstreamProbes.get(relayDir);
|
|
164
|
+
if (cached && cached.url === url && Date.now() - cached.at < UPSTREAM_PROBE_TTL_MS) return cached.value;
|
|
165
|
+
let value;
|
|
166
|
+
try {
|
|
167
|
+
await fetch(url + "/", { signal: AbortSignal.timeout(1500) });
|
|
168
|
+
value = { ok: true, error: "" };
|
|
169
|
+
} catch (e) {
|
|
170
|
+
value = { ok: false, error: String(e?.message || e).slice(0, 200) };
|
|
171
|
+
}
|
|
172
|
+
upstreamProbes.set(relayDir, { at: Date.now(), url, value });
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
93
176
|
/**
|
|
94
177
|
* 自启动服务(launchd plist / systemd unit)与子进程使用的 PATH。
|
|
95
178
|
*
|
|
@@ -395,9 +478,21 @@ function healStalledJobs(relayDir) {
|
|
|
395
478
|
}
|
|
396
479
|
|
|
397
480
|
/** 读取 JSON body。 */
|
|
398
|
-
async function readJsonBody(req) {
|
|
481
|
+
async function readJsonBody(req, maxBytes = 256 * 1024) {
|
|
399
482
|
let raw = "";
|
|
400
|
-
|
|
483
|
+
let n = 0;
|
|
484
|
+
let tooLarge = false;
|
|
485
|
+
for await (const chunk of req) {
|
|
486
|
+
n += chunk.length;
|
|
487
|
+
if (n > maxBytes) {
|
|
488
|
+
// 超限:**继续把请求读完**(丢弃内容)再返回,否则客户端可能在等一个没被读完的请求
|
|
489
|
+
tooLarge = true;
|
|
490
|
+
raw = "";
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (!tooLarge) raw += chunk;
|
|
494
|
+
}
|
|
495
|
+
if (tooLarge) return { __tooLarge: true }; // 上限保护:面板只发诊断/报告,256KB 足够
|
|
401
496
|
if (!raw) return {};
|
|
402
497
|
try {
|
|
403
498
|
return JSON.parse(raw);
|
|
@@ -588,12 +683,78 @@ function scheduleHarnessMint(ctx, relayDir) {
|
|
|
588
683
|
// ---------- 桌面运行环境就绪判定 / 系统级操作开关 ----------
|
|
589
684
|
|
|
590
685
|
/**
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
686
|
+
* 入口脚本(`<relayDir>/dsh-setup.mjs`)**声明要用到的**运行时文件清单。
|
|
687
|
+
*
|
|
688
|
+
* 为什么要解析入口脚本、而不是写死一张文件表:
|
|
689
|
+
* · 写死会与运行时版本漂移(0.6.x 期间 clients/ 下文件增删过多次),漏一个就把好机器误判成坏机器;
|
|
690
|
+
* · 入口脚本自己「点名」的文件就是它启动/拉起 bridge 时**必然**要读到的文件,是唯一稳定的真相源。
|
|
691
|
+
* 解析规则:只认**带引号的相对路径字面量**(注释里用反引号提到的名字不算),避免被说明文字误伤。
|
|
692
|
+
* 再对每个点名的脚本做一次有界闭包展开(它的 `./x.mjs` 相对导入同样必须存在)。
|
|
693
|
+
*
|
|
694
|
+
* 结果按「入口文件的 mtime+size」缓存:面板 2~3s 轮询一次,不能每次都读 200KB 源码;
|
|
695
|
+
* 而入口一变(补装/更新)键就变,立刻重算——依赖文件后来补齐时靠 existsSync 当场看到。
|
|
696
|
+
*/
|
|
697
|
+
const RUNTIME_MANIFEST_CACHE = new Map();
|
|
698
|
+
const RUNTIME_REF_RE = /["'](?:\.\/)?(clients[\\/]dsh-remote[\\/][^"'\n]+?\.mjs)["']/g;
|
|
699
|
+
const RUNTIME_IMPORT_RE = /from\s+["']\.\/([^"'\n]+\.mjs)["']/g;
|
|
700
|
+
/** 闭包展开深度上限:入口 → dsh-bridge.mjs → 其直接依赖(实测正好 2 层)。 */
|
|
701
|
+
const RUNTIME_CLOSURE_DEPTH = 4;
|
|
702
|
+
|
|
703
|
+
function runtimeManifest(relayDir) {
|
|
704
|
+
const entry = join(relayDir, "dsh-setup.mjs");
|
|
705
|
+
let key = "";
|
|
706
|
+
try {
|
|
707
|
+
const st = statSync(entry);
|
|
708
|
+
key = `${st.mtimeMs}:${st.size}`;
|
|
709
|
+
} catch {
|
|
710
|
+
return []; // 入口不存在 → 由 missingRuntimeFiles 单独报告
|
|
711
|
+
}
|
|
712
|
+
const cached = RUNTIME_MANIFEST_CACHE.get(relayDir);
|
|
713
|
+
if (cached && cached.key === key) return cached.files;
|
|
714
|
+
let src = "";
|
|
715
|
+
try { src = readFileSync(entry, "utf8"); } catch { return []; }
|
|
716
|
+
const files = new Set();
|
|
717
|
+
const queue = [...src.matchAll(RUNTIME_REF_RE)].map((m) => m[1].replace(/\\/g, "/"));
|
|
718
|
+
for (let i = 0; i < queue.length && i < 64; i += 1) {
|
|
719
|
+
const rel = queue[i];
|
|
720
|
+
if (files.has(rel)) continue;
|
|
721
|
+
files.add(rel);
|
|
722
|
+
if (queue.length > RUNTIME_CLOSURE_DEPTH * 16) break; // 上限兜底:异常源码不至于把这里拖死
|
|
723
|
+
let body = "";
|
|
724
|
+
try { body = readFileSync(join(relayDir, rel), "utf8"); } catch { continue; } // 缺文件由调用方报告
|
|
725
|
+
const dir = dirname(rel);
|
|
726
|
+
for (const m of body.matchAll(RUNTIME_IMPORT_RE)) {
|
|
727
|
+
const next = join(dir, m[1]).replace(/\\/g, "/");
|
|
728
|
+
if (!files.has(next) && !queue.includes(next)) queue.push(next);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const list = [...files];
|
|
732
|
+
RUNTIME_MANIFEST_CACHE.set(relayDir, { key, files: list });
|
|
733
|
+
return list;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* 桌面运行环境缺失/不完整的文件清单(空 = 就绪)。
|
|
738
|
+
*
|
|
739
|
+
* 【2026-09-23 用户实测事故】判据原本是「`dsh-setup.mjs` 存在即就绪」——**存在 ≠ 能加载**。
|
|
740
|
+
* npx 补装是「先拷入口脚本、再递归拷 clients/」,中途被打断(用户关窗口 / 杀软锁文件 /
|
|
741
|
+
* 机器休眠 / 看门狗判超时结束进程)就会留下一个**只有入口脚本的半装运行时**。此后:
|
|
742
|
+
* · runtimeReady() 恒为 true → 插件再也不补装(自愈彻底停摆);
|
|
743
|
+
* · 面板如实显示「运行环境: 已就绪」,而插起来的 watcher **秒退**(Cannot find module …);
|
|
744
|
+
* · 用户看到的是「连接阶段: starting」+「最近错误: 无」永远不动 —— 正是那份用户反馈。
|
|
745
|
+
* 现在改成「入口脚本点名的文件真的都在」,半装运行时会被判为缺失 → 走既有的自动补装链路自愈。
|
|
746
|
+
*/
|
|
747
|
+
function missingRuntimeFiles(relayDir) {
|
|
748
|
+
if (!existsSync(join(relayDir, "dsh-setup.mjs"))) return ["dsh-setup.mjs"];
|
|
749
|
+
return runtimeManifest(relayDir).filter((rel) => !existsSync(join(relayDir, rel)));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* 桌面运行环境是否就绪:入口脚本存在 **且** 它声明要用到的文件都在
|
|
754
|
+
* (只装「插件半」时入口本身不存在,必须先补装;半装运行时同样判为缺失以便自愈)。
|
|
594
755
|
*/
|
|
595
756
|
function runtimeReady(relayDir) {
|
|
596
|
-
return
|
|
757
|
+
return missingRuntimeFiles(relayDir).length === 0;
|
|
597
758
|
}
|
|
598
759
|
|
|
599
760
|
/**
|
|
@@ -918,10 +1079,85 @@ function resetProvisionRetry(relayDir) {
|
|
|
918
1079
|
provisionRetry.delete(String(relayDir));
|
|
919
1080
|
}
|
|
920
1081
|
|
|
1082
|
+
// ---------- 运行环境随插件分发(0.6.12):本地拷贝优先,npx 只作兜底 ----------
|
|
1083
|
+
//
|
|
1084
|
+
// 【2026-09-23 生产取证】Windows 上「装插件 → 后台 npx 补装运行环境」这条链是最大的流失源:
|
|
1085
|
+
// 近 14 天 427 台 win32 里 138 台 install_failed(32%);v0.6.6 的 95 台里 73 台装机失败,
|
|
1086
|
+
// 其中 48 台点过「一键更新」却一台都没救回来。npx 在 Windows 上可失败的环节太多:
|
|
1087
|
+
// spawn npx.cmd 的 EINVAL、控制台代码页(GBK)导致连归因都读不出来、npm 缓存目录不可写、
|
|
1088
|
+
// 拷贝到一半被打断 → 半装运行时(入口在、依赖不在,插件却报「已就绪」)。
|
|
1089
|
+
// 现在插件包自带 runtime/(scripts/bundle-runtime.mjs 打进 npm 包),补装退化为**本地文件拷贝**:
|
|
1090
|
+
// 零网络、零 npx、零代码页,而且运行环境版本永远等于插件版本(不再出现插件 0.6.10 配运行时 0.6.9)。
|
|
1091
|
+
// 拷贝完必须自检(missingRuntimeFiles)——「半装」绝不当作成功,那正是上一版用户卡死的直接原因。
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* 本插件自带的运行环境目录(lib/ → ../runtime);仓库开发形态/未打包时返回 null。
|
|
1095
|
+
* DSH_RELAY_BUNDLED_RUNTIME 可显式指定(测试/诊断用;指向不存在的目录 = 强制走 npx 兜底分支)。
|
|
1096
|
+
*/
|
|
1097
|
+
function readBundledRuntime() {
|
|
1098
|
+
const override = String(process.env.DSH_RELAY_BUNDLED_RUNTIME || "").trim();
|
|
1099
|
+
if (override) return existsSync(join(override, "dsh-setup.mjs")) ? override : null;
|
|
1100
|
+
try {
|
|
1101
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1102
|
+
const cand = join(here, "..", "runtime");
|
|
1103
|
+
return existsSync(join(cand, "dsh-setup.mjs")) ? cand : null;
|
|
1104
|
+
} catch { return null; }
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** 自带运行环境拷贝失败的退避(避免每次面板轮询都重试同一件不可能的事)。 */
|
|
1108
|
+
const bundledCopyFails = new Map(); // relayDir → nextAt
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* 用插件自带的运行环境就地补装(纯本地拷贝)。
|
|
1112
|
+
* @returns {{ok:boolean, reason?:string, detail?:string, missing?:string[]}}
|
|
1113
|
+
*/
|
|
1114
|
+
function provisionFromBundledRuntime(relayDir) {
|
|
1115
|
+
const src = readBundledRuntime();
|
|
1116
|
+
if (!src) return { ok: false, reason: "no-bundled-runtime" };
|
|
1117
|
+
if (skipsSystemOps()) return { ok: false, reason: "skipped" };
|
|
1118
|
+
if (Date.now() < (bundledCopyFails.get(relayDir) || 0)) return { ok: false, reason: "backoff" };
|
|
1119
|
+
try {
|
|
1120
|
+
mkdirSync(relayDir, { recursive: true });
|
|
1121
|
+
copyFileSync(join(src, "dsh-setup.mjs"), join(relayDir, "dsh-setup.mjs"));
|
|
1122
|
+
for (const dir of ["clients", "node_modules"]) {
|
|
1123
|
+
const from = join(src, dir);
|
|
1124
|
+
if (!existsSync(from)) continue;
|
|
1125
|
+
cpSync(from, join(relayDir, dir), { recursive: true, force: true });
|
|
1126
|
+
}
|
|
1127
|
+
// 版本标记:与插件同版(诊断据此显示「运行时 x.y.z」)
|
|
1128
|
+
try { writeFileSync(join(relayDir, ".dsh-setup-version"), `${PLUGIN_VERSION}\n`); } catch { /* 非关键 */ }
|
|
1129
|
+
const missing = missingRuntimeFiles(relayDir);
|
|
1130
|
+
if (missing.length) {
|
|
1131
|
+
bundledCopyFails.set(relayDir, Date.now() + 60_000);
|
|
1132
|
+
return { ok: false, reason: "incomplete", missing };
|
|
1133
|
+
}
|
|
1134
|
+
bundledCopyFails.delete(relayDir);
|
|
1135
|
+
return { ok: true };
|
|
1136
|
+
} catch (e) {
|
|
1137
|
+
bundledCopyFails.set(relayDir, Date.now() + 60_000);
|
|
1138
|
+
return { ok: false, reason: "copy-failed", detail: e.message };
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
921
1142
|
function ensureRuntime(relayDir) {
|
|
922
1143
|
if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再自动安装运行环境
|
|
923
1144
|
if (runtimeReady(relayDir)) return true;
|
|
924
1145
|
if (skipsSystemOps()) return false; // 测试隔离:绝不在用例里 spawn 真实 npx 安装
|
|
1146
|
+
// ① 插件自带的运行环境优先:本地拷贝即可,**不经过 npx、不联网**。
|
|
1147
|
+
// 这一条同时治好「半装运行环境」(runtimeReady 为假 → 整份重拷 → 自检通过才算成功)。
|
|
1148
|
+
const local = provisionFromBundledRuntime(relayDir);
|
|
1149
|
+
if (local.ok) {
|
|
1150
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
1151
|
+
`[dsh-remote-web] 已用插件自带的运行环境就地补齐(v${PLUGIN_VERSION},本地拷贝,未使用 npx)`);
|
|
1152
|
+
telemetryRuntimeReady(relayDir);
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
if (local.reason === "incomplete") {
|
|
1156
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
1157
|
+
`[dsh-remote-web] 自带运行环境拷贝后仍不完整(缺 ${local.missing.slice(0, 3).join(", ")})→ 转 npx 补装`);
|
|
1158
|
+
telemetryRecord(relayDir, "install_failed", { fail_code: "install_script_missing" });
|
|
1159
|
+
}
|
|
1160
|
+
// ② 兜底:npx 补装(老路径保持不变;仓库开发形态、或没打包 runtime/ 的安装形态走这里)
|
|
925
1161
|
const gate = provisionRetryGate(relayDir);
|
|
926
1162
|
if (!gate.ok) {
|
|
927
1163
|
// 退避中:不重复 spawn(避免失败风暴)。面板会据 provisionRetryInfo() 显示"上次失败、X 后可重试"。
|
|
@@ -1151,13 +1387,20 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
|
|
|
1151
1387
|
}
|
|
1152
1388
|
const setup = join(relayDir, "dsh-setup.mjs");
|
|
1153
1389
|
if (!existsSync(setup)) return { ok: false, status: "not-installed", detail: `运行环境入口脚本不存在(${setup})` };
|
|
1390
|
+
publishUpstream(relayDir); // 落盘上游地址:开机自启拉起的守护(没有 env)据此找到 dsh web
|
|
1154
1391
|
const log = join(relayDir, ".dsh-bridge.log");
|
|
1155
1392
|
let out = "ignore";
|
|
1156
1393
|
try { out = openSync(log, "a"); } catch { /* 打不开日志:退化到丢弃输出,不阻断启动 */ }
|
|
1157
1394
|
const { child, error } = safeSpawn(process.execPath, [setup, "run"], {
|
|
1158
1395
|
detached: true,
|
|
1159
1396
|
cwd: homedir(),
|
|
1160
|
-
env: spawnEnv({
|
|
1397
|
+
env: spawnEnv({
|
|
1398
|
+
DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
|
|
1399
|
+
DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
|
|
1400
|
+
// 上游端口**必须**透传:watcher 用它判断「dsh web 在线吗」,bridge 用它做转发目标。
|
|
1401
|
+
// 不传的话两边都回落到写死的 3080 —— dsh web 不在默认端口时 bridge 永远不启动(0.6.12 修)。
|
|
1402
|
+
DSH_BRIDGE_UPSTREAM: upstreamUrl(),
|
|
1403
|
+
}),
|
|
1161
1404
|
stdio: ["ignore", out, out],
|
|
1162
1405
|
windowsHide: true,
|
|
1163
1406
|
onError: (e) => appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-web] Windows 启动 bridge 失败: ${e.message}`),
|
|
@@ -2817,6 +3060,73 @@ async function runOnlineUpdate(relayDir) {
|
|
|
2817
3060
|
noteUpdateFailure(relayDir, e.message, fc); // ← 面板可查询,不再只躺在日志里
|
|
2818
3061
|
telemetryRecord(relayDir, "update_failed", { fail_code: fc });
|
|
2819
3062
|
};
|
|
3063
|
+
/**
|
|
3064
|
+
* 第三跳:离线通道(0.6.12)。
|
|
3065
|
+
* 为什么必须有:Windows 上 Node ≥20.12 起 `spawn("npx.cmd")` 不带 shell 会抛 EINVAL,
|
|
3066
|
+
* 于是「更新」这条路对老版本机器**彻底失效**(生产遥测:79 台点过更新、79 台全失败)。
|
|
3067
|
+
* 离线通道用 HTTPS 取 tarball + 纯 Node 解包 + 跑包内安装器,不经 npx/npm/shell。
|
|
3068
|
+
*/
|
|
3069
|
+
const runOffline = () => {
|
|
3070
|
+
if (updateCancelledDirs.has(relayDir)) { updateCancelledDirs.delete(relayDir); return null; }
|
|
3071
|
+
const helper = join(dirname(fileURLToPath(import.meta.url)), "offline-update.mjs");
|
|
3072
|
+
if (!existsSync(helper)) {
|
|
3073
|
+
appendLogLine(relayDir, UPDATE_LOG, "[update] 离线更新助手缺失(插件包不完整),放弃离线通道");
|
|
3074
|
+
return null;
|
|
3075
|
+
}
|
|
3076
|
+
appendLogLine(relayDir, UPDATE_LOG,
|
|
3077
|
+
`[update] npx 两次都未成功 → 改用离线通道(tarball + 纯 Node 解包,不经 npx/npm)…`);
|
|
3078
|
+
let out = "ignore";
|
|
3079
|
+
try { out = openSync(join(relayDir, UPDATE_LOG), "a"); } catch { /* 打不开日志:退化丢弃输出 */ }
|
|
3080
|
+
const holder = { child: null };
|
|
3081
|
+
const { child, error } = safeSpawn(process.execPath, [helper, relayDir, target.tag], {
|
|
3082
|
+
detached: true,
|
|
3083
|
+
env: spawnEnv({
|
|
3084
|
+
DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
|
|
3085
|
+
DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
|
|
3086
|
+
npm_config_registry: "https://registry.npmjs.org",
|
|
3087
|
+
}),
|
|
3088
|
+
stdio: ["ignore", out, out],
|
|
3089
|
+
windowsHide: true,
|
|
3090
|
+
onError: (e) => failUpdaterSpawn(e),
|
|
3091
|
+
});
|
|
3092
|
+
if (typeof out === "number") { try { closeSync(out); } catch { /* 子进程已继承 */ } }
|
|
3093
|
+
if (error || !child) return null;
|
|
3094
|
+
holder.child = child;
|
|
3095
|
+
writeMarker(marker, child.pid);
|
|
3096
|
+
startProgressWatch(UPDATE_WATCH_KEY, {
|
|
3097
|
+
relayDir, logName: UPDATE_LOG, kind: "update",
|
|
3098
|
+
getChild: () => holder.child,
|
|
3099
|
+
onStall: (idle) => {
|
|
3100
|
+
stalled = true;
|
|
3101
|
+
appendLogLine(relayDir, UPDATE_LOG,
|
|
3102
|
+
`[update] 离线通道也已 ${Math.round(idle / 1000)}s 无输出 → 判定卡住,结束该进程`);
|
|
3103
|
+
},
|
|
3104
|
+
});
|
|
3105
|
+
child.on("exit", (code) => {
|
|
3106
|
+
stopProgressWatch(UPDATE_WATCH_KEY);
|
|
3107
|
+
if (updateCancelledDirs.has(relayDir)) {
|
|
3108
|
+
updateCancelledDirs.delete(relayDir);
|
|
3109
|
+
appendLogLine(relayDir, UPDATE_LOG, "[update] 已取消:不再重试");
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] 离线通道退出 code=${code ?? "?"}`);
|
|
3113
|
+
clear();
|
|
3114
|
+
if (code !== 0 || stalled) {
|
|
3115
|
+
const tail = readTail(join(relayDir, UPDATE_LOG));
|
|
3116
|
+
const fc = stalled ? "update_stalled" : telemetryFailCodeFromText(tail);
|
|
3117
|
+
const lastLine = String(tail || "").split("\n").map((l) => l.trim()).filter(Boolean).slice(-1)[0] || "";
|
|
3118
|
+
noteUpdateFailure(relayDir, stalled
|
|
3119
|
+
? "离线通道也长时间没有输出(已判定卡住并结束):请检查本机能否访问 registry.npmjs.org"
|
|
3120
|
+
: (lastLine || `离线通道退出码 ${code}`), fc);
|
|
3121
|
+
telemetryRecord(relayDir, "update_failed", { fail_code: fc });
|
|
3122
|
+
} else {
|
|
3123
|
+
updateFailures.delete(String(relayDir));
|
|
3124
|
+
}
|
|
3125
|
+
});
|
|
3126
|
+
child.unref();
|
|
3127
|
+
return child;
|
|
3128
|
+
};
|
|
3129
|
+
let offlineTried = false;
|
|
2820
3130
|
const run = () => {
|
|
2821
3131
|
if (updateCancelledDirs.has(relayDir)) { updateCancelledDirs.delete(relayDir); return null; } // 已取消:不再重试
|
|
2822
3132
|
// 第一次:官方 npm 源;失败(exit≠0 **或长时间无输出**)才回退用户默认源(通常为国内镜像)
|
|
@@ -2858,6 +3168,13 @@ async function runOnlineUpdate(relayDir) {
|
|
|
2858
3168
|
run();
|
|
2859
3169
|
return;
|
|
2860
3170
|
}
|
|
3171
|
+
// 两次 npx 都没成功 → 第三跳:离线通道(不经 npx/npm)。这是给"npx 坏掉的机器"留的出路,
|
|
3172
|
+
// 也是 2026-09 那批「点了一键更新却永远失败」的 Windows 用户唯一可能自救的路径。
|
|
3173
|
+
if (!offlineTried && (code !== 0 || stalled)) {
|
|
3174
|
+
offlineTried = true;
|
|
3175
|
+
if (runOffline()) return;
|
|
3176
|
+
appendLogLine(relayDir, UPDATE_LOG, "[update] 离线通道也未能启动,按失败收尾");
|
|
3177
|
+
}
|
|
2861
3178
|
appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}(${retried ? "默认源" : "官方源"})`);
|
|
2862
3179
|
clear();
|
|
2863
3180
|
// 在线更新改写了插件/运行环境文件 → 版本对不上时由 pluginInstalledAfterBoot() 自动识别,
|
|
@@ -3225,7 +3542,7 @@ const connectBooks = new Map();
|
|
|
3225
3542
|
function connectBook(relayDir) {
|
|
3226
3543
|
let b = connectBooks.get(relayDir);
|
|
3227
3544
|
if (!b) {
|
|
3228
|
-
b = { attempts: 0, lastAttemptAt: 0, reportAt: 0 };
|
|
3545
|
+
b = { attempts: 0, lastAttemptAt: 0, reportAt: 0, watcherSeenAt: 0 };
|
|
3229
3546
|
connectBooks.set(relayDir, b);
|
|
3230
3547
|
}
|
|
3231
3548
|
return b;
|
|
@@ -3324,6 +3641,113 @@ function clearStaleInstallMarker(relayDir) {
|
|
|
3324
3641
|
} catch { return false; }
|
|
3325
3642
|
}
|
|
3326
3643
|
|
|
3644
|
+
// ---------- 「守护活着、bridge 却迟迟起不来」:判据 + 真重启(0.6.12) ----------
|
|
3645
|
+
//
|
|
3646
|
+
// 【2026-09-23 用户实测】反馈里那句关键的「**多次修复都没有解决**」,根因就在这里:
|
|
3647
|
+
// startBridgeDetached 只要读到 `.dsh-watcher.pid` 指向一个**活着的**进程就返回「已在运行」——
|
|
3648
|
+
// 于是一个**活着但拉不起 bridge 的守护**(上游端口不对 / 运行环境半装 / 它自己卡住)会让
|
|
3649
|
+
// 所有自救路径全部空转:面板「启动 / 立即重试 / 一键修复」、插件每轮的自愈,一律 no-op。
|
|
3650
|
+
// 用户看到的永远是「正在启动 Bridge…」,重试次数一直涨,而状态一点不变。
|
|
3651
|
+
// 现在:守护存活超过阈值却仍没有 bridge 进程 → **真的把它结束掉再重新拉起**(有次数上限,不成风暴)。
|
|
3652
|
+
const WEDGER_MS = 90_000; // 正常一台机器:守护起来后 1~10s 内 bridge 就该出现
|
|
3653
|
+
const WEDGE_FORCE_MS = 20_000; // 用户显式点了「重试/修复」:给他更短的等待,别让他干等 90 秒
|
|
3654
|
+
const MAX_WATCHER_RESTARTS = 3; // 连续重启上限:再不行就不是「重启能解决」的问题,改为如实报错
|
|
3655
|
+
const watcherRestarts = new Map(); // relayDir → { count, at }
|
|
3656
|
+
|
|
3657
|
+
/**
|
|
3658
|
+
* 判定窗口(毫秒)。DSH_RELAY_WEDGE_MS 仅供本仓库测试/诊断把节奏压到亚秒级
|
|
3659
|
+
* (与 DSH_RELAY_SELFHEAL_MS / DSH_REMOTE_TELEMETRY_MS 同一约定):生产不设置,值就是上面那串。
|
|
3660
|
+
* 设置时「显式重试」也走同一个窗口(否则用例得干等 20 秒)。
|
|
3661
|
+
*/
|
|
3662
|
+
function wedgeLimitMs(force) {
|
|
3663
|
+
const v = Number(process.env.DSH_RELAY_WEDGE_MS);
|
|
3664
|
+
if (Number.isFinite(v) && v > 0) return Math.max(80, v);
|
|
3665
|
+
return force ? WEDGE_FORCE_MS : WEDGER_MS;
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
/**
|
|
3669
|
+
* 守护(watcher)已存活多久:优先用 pid 文件的 mtime 当「本次拉起时刻」(插件刚 spawn 完就写它);
|
|
3670
|
+
* 没有 pid 文件时(0.6.7 之前的运行时、或文件被删)回退到 `observedSince` —— 本进程第一次看到
|
|
3671
|
+
* 「守护活着但没有 bridge」的时刻。没有这层兜底,那类守护会永远算不出年龄、也就永远不会被重启。
|
|
3672
|
+
*/
|
|
3673
|
+
function watcherStartedAt(relayDir, observedSince = 0) {
|
|
3674
|
+
try {
|
|
3675
|
+
const m = statSync(join(relayDir, WATCHER_PID_FILE)).mtimeMs || 0;
|
|
3676
|
+
if (m) return m;
|
|
3677
|
+
} catch { /* 无 pid 文件 → 用观测起点 */ }
|
|
3678
|
+
return Number(observedSince) || 0;
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
/**
|
|
3682
|
+
* 「服务端登录限流」的最早可重试时刻(bridge 收到 429 时落盘,watcher 据此退避)。
|
|
3683
|
+
* 读它是为了**如实解释**「正在启动 Bridge…」为什么不动:限流窗口内 watcher 会跳过拉起 bridge,
|
|
3684
|
+
* 这是正常等待(15 分钟内自愈),不该被当成故障去重启守护。
|
|
3685
|
+
*/
|
|
3686
|
+
function loginRateLimitedUntil(relayDir) {
|
|
3687
|
+
try {
|
|
3688
|
+
const until = Number(String(readFileSync(join(relayDir, ".dsh-login-ratelimited"), "utf8")).trim());
|
|
3689
|
+
return Number.isFinite(until) && until > Date.now() ? until : 0;
|
|
3690
|
+
} catch { return 0; }
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
/** 本轮是否该把守护当成「卡死」并重启。纯函数式判据,便于用例直接锁死边界。 */
|
|
3694
|
+
function watcherWedgeOf(relayDir, { watcherRunning, bridgeRunning, force = false, now = Date.now(), observedSince = 0 } = {}) {
|
|
3695
|
+
const rec = watcherRestarts.get(relayDir) || { count: 0, at: 0 };
|
|
3696
|
+
const base = { wedged: false, ageMs: 0, known: false, restarts: rec.count, rateLimitedUntil: 0, exhausted: false };
|
|
3697
|
+
if (!watcherRunning || bridgeRunning) return base;
|
|
3698
|
+
const rateLimitedUntil = loginRateLimitedUntil(relayDir);
|
|
3699
|
+
if (rateLimitedUntil) return { ...base, rateLimitedUntil };
|
|
3700
|
+
const startedAt = watcherStartedAt(relayDir, observedSince);
|
|
3701
|
+
if (!startedAt) return base; // 完全不知道它活了多久 → 不做激进结论
|
|
3702
|
+
const ageMs = Math.max(0, now - startedAt);
|
|
3703
|
+
const limit = wedgeLimitMs(force);
|
|
3704
|
+
return { ...base, ageMs, known: true, wedged: ageMs >= limit, exhausted: rec.count >= MAX_WATCHER_RESTARTS };
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
/** 记一次「重启卡死守护」;bridge 真的起来后由 resetWatcherRestart 清零。 */
|
|
3708
|
+
function noteWatcherRestart(relayDir) {
|
|
3709
|
+
const rec = watcherRestarts.get(relayDir) || { count: 0, at: 0 };
|
|
3710
|
+
rec.count += 1;
|
|
3711
|
+
rec.at = Date.now();
|
|
3712
|
+
watcherRestarts.set(relayDir, rec);
|
|
3713
|
+
return rec;
|
|
3714
|
+
}
|
|
3715
|
+
function resetWatcherRestart(relayDir) {
|
|
3716
|
+
watcherRestarts.delete(relayDir);
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3719
|
+
/** 运行环境版本(运行时自己写的 .dsh-setup-version;缺 = 旧版本,诊断里如实显示「未知」)。 */
|
|
3720
|
+
function runtimeVersionOf(relayDir) {
|
|
3721
|
+
try { return String(readFileSync(join(relayDir, ".dsh-setup-version"), "utf8")).trim(); } catch { return ""; }
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
/** 上游地址的来源说明(诊断用:一旦端口不对,这一行就能定位)。 */
|
|
3725
|
+
function upstreamSourceLabel() {
|
|
3726
|
+
if (String(process.env.DSH_BRIDGE_UPSTREAM || "").trim()) return "来自 DSH_BRIDGE_UPSTREAM";
|
|
3727
|
+
if (boundWebPort()) return "dsh web 实际监听端口";
|
|
3728
|
+
if (String(process.env.DSH_WEB_URL || "").trim()) return "来自 DSH_WEB_URL";
|
|
3729
|
+
return "默认值 3080(未取到 dsh web 实际监听端口)";
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
/**
|
|
3733
|
+
* 诊断信息里的日志尾部:**必须先脱敏**。
|
|
3734
|
+
* 这份文本是给用户复制出去发客服/贴到群里的,而 bridge 日志里有手机号("用账号 138… 登录")
|
|
3735
|
+
* 与可能的 token/密码片段。只取末尾若干行、逐行截断,并抹掉形如凭据的内容。
|
|
3736
|
+
*/
|
|
3737
|
+
function redactForDiagnostics(text) {
|
|
3738
|
+
return String(text || "")
|
|
3739
|
+
.replace(/(1[3-9]\d)\d{4}(\d{4})/g, "$1****$2") // 手机号
|
|
3740
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]{8,}=*/gi, "$1<已隐去>") // Bearer 令牌
|
|
3741
|
+
.replace(/((?:password|passwd|token|secret|local_key|bridge_secret)"?\s*[:=]\s*"?)[^\s",}]{4,}/gi, "$1<已隐去>");
|
|
3742
|
+
}
|
|
3743
|
+
/** 取日志尾部若干行(含脱敏 + 行截断),供「复制诊断信息」使用。 */
|
|
3744
|
+
function logTailForDiagnostics(relayDir, name, maxLines = 15) {
|
|
3745
|
+
const tail = readTail(join(relayDir, name), 8 * 1024);
|
|
3746
|
+
if (!tail) return "";
|
|
3747
|
+
const lines = tail.split("\n").map((l) => l.trim()).filter(Boolean).slice(-maxLines);
|
|
3748
|
+
return lines.map((l) => " " + redactForDiagnostics(l).slice(0, 240)).join("\n");
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3327
3751
|
/** 账号设备表里是否已登记本机 device_id(手机端 /api/devices 能看到的那一份)+ 节流缓存。 */
|
|
3328
3752
|
async function accountDeviceBound(relayDir, cfg) {
|
|
3329
3753
|
const deviceId = String(cfg.device_id || "");
|
|
@@ -3366,9 +3790,14 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
3366
3790
|
const now = Date.now();
|
|
3367
3791
|
const hasAcct = hasAccountCreds(cfg);
|
|
3368
3792
|
const runtime = runtimeReady(relayDir);
|
|
3793
|
+
const runtimeMissing = runtime ? [] : missingRuntimeFiles(relayDir);
|
|
3369
3794
|
const inst = installStateOf(relayDir);
|
|
3370
3795
|
const bridgePids = Array.isArray(manual.allBridge) ? manual.allBridge : [];
|
|
3371
3796
|
const bridgeRunning = Boolean(launchd.running || bridgePids.length > 0 || (manual.bridge || []).length > 0);
|
|
3797
|
+
const watcherPids = Array.isArray(manual.watcher) ? manual.watcher : [];
|
|
3798
|
+
const watcherRunning = Boolean(!bridgeRunning && watcherPids.length > 0);
|
|
3799
|
+
const wedge = watcherWedgeOf(relayDir, { watcherRunning, bridgeRunning, now });
|
|
3800
|
+
const rateLimitedUntil = loginRateLimitedUntil(relayDir);
|
|
3372
3801
|
const bindError = readBindError(relayDir);
|
|
3373
3802
|
const bindFresh = Boolean(bindError && (!bindError.at || now - Number(bindError.at) < BIND_ERROR_FRESH_MS));
|
|
3374
3803
|
const log = bridgeLogState(relayDir);
|
|
@@ -3397,6 +3826,18 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
3397
3826
|
}
|
|
3398
3827
|
}
|
|
3399
3828
|
|
|
3829
|
+
// ── 上游可达性(只在「还没连上」时才探,带 5s 缓存) ──
|
|
3830
|
+
// 这是「bridge 为什么起不来」最常见、也最容易无人知晓的一种:dsh web 不在 bridge 以为的端口上。
|
|
3831
|
+
// 只把结论写进 detail(不动 phase):探测失败也可能是本机网络栈/代理的偶发抖动,
|
|
3832
|
+
// 直接判死会让「其实马上就好」的用户看到一条红字错误;而 starting 分支同样带「立即重试/复制诊断」。
|
|
3833
|
+
const needUpstream = hasAcct && runtime && !bridgeRunning;
|
|
3834
|
+
const upstream = needUpstream ? await probeUpstream(relayDir) : null;
|
|
3835
|
+
const upstreamBroken = Boolean(upstream && !upstream.ok);
|
|
3836
|
+
const upstreamNote = upstreamBroken
|
|
3837
|
+
? `\n⚠️ bridge 要连的本机 dsh web 地址 ${upstreamUrl()}(${upstreamSourceLabel()})探测不通:${upstream.error || "连接被拒绝"}。`
|
|
3838
|
+
+ "若 dsh web 不在默认端口,请点「一键更新」升级运行环境(新版本会自动跟随实际端口),或把 dsh web 重启在 3080。"
|
|
3839
|
+
: "";
|
|
3840
|
+
|
|
3400
3841
|
let phase;
|
|
3401
3842
|
let error = null;
|
|
3402
3843
|
let detail = "";
|
|
@@ -3421,6 +3862,12 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
3421
3862
|
detail = inst.installing
|
|
3422
3863
|
? "正在后台安装运行环境(bridge 本体),首次约 1~2 分钟,装完会自动启动。"
|
|
3423
3864
|
: "检测到缺少运行环境,正在后台自动安装(首次约 1~2 分钟),无需任何操作。";
|
|
3865
|
+
if (runtimeMissing.length) {
|
|
3866
|
+
// 半装运行时(入口在、依赖不在):如实说清是「不完整」而不是「没装」,
|
|
3867
|
+
// 否则用户看到「正在准备运行环境」会以为要等它装完,而它其实**已经卡在这状态很久了**。
|
|
3868
|
+
detail = `运行环境不完整(缺 ${runtimeMissing.slice(0, 3).join("、")}${runtimeMissing.length > 3 ? " 等" : ""}),`
|
|
3869
|
+
+ "正在后台自动补装(约 1~2 分钟),装完会自动启动 bridge。";
|
|
3870
|
+
}
|
|
3424
3871
|
}
|
|
3425
3872
|
} else if (!bridgeRunning) {
|
|
3426
3873
|
if (launchd.crashing) {
|
|
@@ -3434,11 +3881,32 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
3434
3881
|
phase = "error";
|
|
3435
3882
|
error = { code: bindError.code || "bind_failed", message: bindError.message || "设备登记失败" };
|
|
3436
3883
|
detail = "bridge 因设备登记失败退出,已进入自动重试;请按提示处理后重试。";
|
|
3884
|
+
} else if (wedge.wedged && wedge.exhausted) {
|
|
3885
|
+
// 守护活着、却反复拉不起 bridge,且已自动重启到上限 → 这是**真的**卡死了,给明确结论 + 出路。
|
|
3886
|
+
// 旧实现在这种状态下永远显示「正在启动 Bridge…」,用户只能反复点修复(实测反馈)。
|
|
3887
|
+
phase = "error";
|
|
3888
|
+
error = {
|
|
3889
|
+
code: "bridge_wedged",
|
|
3890
|
+
message: `后台守护已运行 ${Math.round(wedge.ageMs / 1000)} 秒仍拉不起 bridge(已自动重启 ${wedge.restarts} 次)`,
|
|
3891
|
+
};
|
|
3892
|
+
detail = `运行环境版本 ${runtimeVersionOf(relayDir) || "未知"}。`
|
|
3893
|
+
+ "请点「一键更新」重装运行环境后重试(更新会一并升级 bridge 守护脚本);仍不行请点「复制诊断信息」发给客服。"
|
|
3894
|
+
+ upstreamNote;
|
|
3895
|
+
} else if (wedge.wedged) {
|
|
3896
|
+
phase = "starting";
|
|
3897
|
+
detail = `后台守护已运行 ${Math.round(wedge.ageMs / 1000)} 秒仍未拉起 bridge,正在自动重启它(无需操作)…`;
|
|
3898
|
+
} else if (rateLimitedUntil) {
|
|
3899
|
+
// 服务端登录限流:watcher 会主动等到窗口结束再拉起(这是等待,不是故障)
|
|
3900
|
+
phase = "starting";
|
|
3901
|
+
detail = `账号登录被服务端临时限流,约 ${Math.ceil((rateLimitedUntil - now) / 1000)} 秒后自动重试`
|
|
3902
|
+
+ "(凭据没错、无需改密码,也不需要任何操作)。";
|
|
3437
3903
|
} else {
|
|
3438
3904
|
phase = "starting";
|
|
3439
|
-
detail =
|
|
3440
|
-
? "
|
|
3441
|
-
:
|
|
3905
|
+
detail = (watcherRunning
|
|
3906
|
+
? `后台守护已启动${wedge.known ? `(${Math.round(wedge.ageMs / 1000)} 秒)` : ""},正在等待 bridge 进程就绪…`
|
|
3907
|
+
: (launchd.running
|
|
3908
|
+
? "后台服务已启动,正在等待 bridge 进程就绪…"
|
|
3909
|
+
: "正在启动后台服务(bridge),通常几秒内完成,无需任何操作。")) + upstreamNote;
|
|
3442
3910
|
}
|
|
3443
3911
|
} else if (registered) {
|
|
3444
3912
|
phase = "online";
|
|
@@ -3472,11 +3940,25 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
3472
3940
|
retryable: retryable !== false,
|
|
3473
3941
|
deviceId,
|
|
3474
3942
|
runtimeReady: runtime,
|
|
3943
|
+
runtimeMissing,
|
|
3944
|
+
runtimeVersion: runtimeVersionOf(relayDir),
|
|
3475
3945
|
installing: inst.installing,
|
|
3476
3946
|
installStale: inst.stale,
|
|
3477
3947
|
bridgeRunning,
|
|
3478
3948
|
accountCurrent, // 运行中的 bridge 是否属于当前账号(false = 上一个账号的进程还在跑)
|
|
3479
3949
|
bridgePids,
|
|
3950
|
+
// 守护(watcher)与上游:这两项以前完全不下发,于是「守护活着但拉不起 bridge」
|
|
3951
|
+
// 与「上游端口不对」在面板上都长得跟「正在启动」一模一样(用户反馈就是这么来的)。
|
|
3952
|
+
watcherPids,
|
|
3953
|
+
watcherRunning,
|
|
3954
|
+
watcherAgeMs: wedge.known ? wedge.ageMs : 0,
|
|
3955
|
+
watcherWedged: Boolean(wedge.wedged),
|
|
3956
|
+
watcherRestarts: wedge.restarts,
|
|
3957
|
+
upstreamUrl: upstreamUrl(),
|
|
3958
|
+
upstreamSource: upstreamSourceLabel(),
|
|
3959
|
+
upstreamReachable: upstream ? upstream.ok : null,
|
|
3960
|
+
upstreamError: upstream && !upstream.ok ? upstream.error : "",
|
|
3961
|
+
rateLimitedUntil,
|
|
3480
3962
|
launchdState: launchd.state || "",
|
|
3481
3963
|
launchdCrashLoop: Boolean(launchd.crashing),
|
|
3482
3964
|
registered,
|
|
@@ -3492,24 +3974,153 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
3492
3974
|
return conn;
|
|
3493
3975
|
}
|
|
3494
3976
|
|
|
3495
|
-
/**
|
|
3977
|
+
/**
|
|
3978
|
+
* 可复制的诊断信息(面板「复制诊断信息」按钮):版本 / relayDir / 阶段 / 最近错误 / 进程状态 /
|
|
3979
|
+
* 日志路径 + **两个日志的尾部**。
|
|
3980
|
+
*
|
|
3981
|
+
* 【2026-09-23 用户实测】旧版只给日志**路径**:用户反馈里「最近错误: 无」而实际已经卡死——
|
|
3982
|
+
* 因为 lastError 只认 bridge 日志里几个特定文案,node 自己的崩溃栈(MODULE_NOT_FOUND 等)
|
|
3983
|
+
* 一律不算「错误」,客服/作者手上因此没有任何可用信息,只能来回问。现在把尾部**原文**带上
|
|
3984
|
+
* (已脱敏手机号/令牌,见 redactForDiagnostics),一次复制就能定位。
|
|
3985
|
+
*/
|
|
3496
3986
|
function buildConnectDiagnostics(relayDir, conn, log) {
|
|
3497
|
-
|
|
3987
|
+
const rows = [
|
|
3498
3988
|
"dsh-remote 连接诊断",
|
|
3499
3989
|
`插件版本: ${PLUGIN_VERSION}`,
|
|
3500
3990
|
`配置目录: ${relayDir}`,
|
|
3501
3991
|
`连接阶段: ${conn.phase}(${conn.text})`,
|
|
3502
3992
|
`设备 ID: ${conn.deviceId || "(未生成)"}`,
|
|
3503
|
-
`运行环境: ${conn.runtimeReady ? "已就绪" : "缺失"}${conn.installing ? "(后台安装中)" : ""}${conn.installStale ? "(安装标记已超时)" : ""}`,
|
|
3993
|
+
`运行环境: ${conn.runtimeReady ? "已就绪" : "缺失"}${conn.runtimeVersion ? `(运行时 ${conn.runtimeVersion})` : ""}${conn.installing ? "(后台安装中)" : ""}${conn.installStale ? "(安装标记已超时)" : ""}`,
|
|
3994
|
+
conn.runtimeReady || !conn.runtimeMissing || !conn.runtimeMissing.length
|
|
3995
|
+
? ""
|
|
3996
|
+
: `运行环境缺文件: ${conn.runtimeMissing.slice(0, 8).join(", ")}${conn.runtimeMissing.length > 8 ? ` 等 ${conn.runtimeMissing.length} 个` : ""}`,
|
|
3504
3997
|
`bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
|
|
3998
|
+
`后台守护: ${conn.watcherRunning ? `在运行(pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `,已 ${Math.round(conn.watcherAgeMs / 1000)} 秒` : ""}${conn.watcherWedged ? ",⚠️ 迟迟未拉起 bridge" : ""}${conn.watcherRestarts ? `,已自动重启 ${conn.watcherRestarts} 次` : ""})` : "未运行"}`,
|
|
3999
|
+
`上游 dsh web: ${conn.upstreamUrl}(${conn.upstreamSource}${conn.upstreamReachable === null ? "" : conn.upstreamReachable ? ",可达" : `,⚠️ 不可达:${conn.upstreamError}`})`,
|
|
3505
4000
|
`中继注册: ${conn.registered ? "已注册(" + conn.registerSource + ")" : "未注册"}`,
|
|
3506
4001
|
`bridge 账号: ${conn.accountCurrent === false ? "⚠️ 仍是上一个账号的身份(需重启 bridge 重新登记)" : "当前账号"}`,
|
|
4002
|
+
conn.rateLimitedUntil ? `登录限流: 约 ${Math.ceil((conn.rateLimitedUntil - Date.now()) / 1000)} 秒后自动重试(凭据无误,无需改密码)` : "",
|
|
3507
4003
|
`最近错误: ${conn.error ? conn.error.code + ": " + conn.error.message : (log && log.lastError ? log.lastError : "无")}`,
|
|
3508
4004
|
`自动重试: 已尝试 ${conn.attempts} 次${conn.nextRetryInMs ? `,约 ${Math.ceil(conn.nextRetryInMs / 1000)} 秒后重试` : ""}`,
|
|
3509
4005
|
`bridge 日志: ${conn.logPath}`,
|
|
3510
4006
|
`安装日志: ${conn.installLogPath}`,
|
|
3511
4007
|
`时间: ${new Date().toISOString()}`,
|
|
3512
|
-
].
|
|
4008
|
+
].filter(Boolean);
|
|
4009
|
+
const bridgeTail = logTailForDiagnostics(relayDir, BRIDGE_LOG_FILE);
|
|
4010
|
+
const installTail = logTailForDiagnostics(relayDir, AUTO_INSTALL_LOG);
|
|
4011
|
+
if (bridgeTail) rows.push("", "--- bridge 日志(末尾)---", bridgeTail);
|
|
4012
|
+
if (installTail) rows.push("", "--- 安装日志(末尾)---", installTail);
|
|
4013
|
+
return rows.join("\n");
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
// ---------- 故障上报(用户主动、可预览、可编辑)+「让 DeepSeek 帮我修」提示词 ----------
|
|
4017
|
+
//
|
|
4018
|
+
// 2026-09-23 用户反馈的教训:故障现场我们**看不见** —— 面板只给日志"路径",诊断里写「最近错误: 无」,
|
|
4019
|
+
// 而我们又绝不做后台自动上传日志(隐私红线,见 docs/telemetry.md)。结果客服与开发者只能来回问,
|
|
4020
|
+
// 用户在「装不上 → 不知道怎么报 → 放弃」里流失(近 14 天 Windows 427 台里成功率只有 35.6%)。
|
|
4021
|
+
// 现在把「知情同意」做成产品能力,而不是绕过它:
|
|
4022
|
+
// · GET /dsh-remote/diag/bundle → 完整预览:将要发送的每一个字(用户可看、可改、可删);
|
|
4023
|
+
// · POST /dsh-remote/diag/report → **用户点确认后**才提交;
|
|
4024
|
+
// · GET /dsh-remote/diag/fix-prompt → 让用户本机的 DSH 自己修,并产出定格式结论报告,
|
|
4025
|
+
// 用户可把报告贴回同一个上报道(不需要我们发版就能自救)。
|
|
4026
|
+
// 走**既有反馈通道**(企业端 /api/feedback),因此不需要任何服务端部署:
|
|
4027
|
+
// 正文受服务端 2000 字硬限(fb CONTENT_MAX),超出的日志作为**同一条反馈的回复**分片,
|
|
4028
|
+
// 于是后台「用户反馈」里就是一条可回复、可追踪的线程。
|
|
4029
|
+
const DIAG_NOTE_MAX = 600;
|
|
4030
|
+
const DIAG_CONTENT_MAX = 1900; // 服务端 content 上限 2000,留余量
|
|
4031
|
+
const DIAG_PART_MAX = 1900;
|
|
4032
|
+
const DIAG_MAX_PARTS = 4; // 日志/报告分片上限(超出只留尾部,宁缺毋滥)
|
|
4033
|
+
|
|
4034
|
+
/** 上报标题(服务端 title 上限 120 字)。 */
|
|
4035
|
+
function diagTitleOf(conn, pluginVer) {
|
|
4036
|
+
const bits = ["[诊断]", conn.phase || "unknown", `v${pluginVer || PLUGIN_VERSION}`, osPlatform(), process.arch];
|
|
4037
|
+
return bits.join(" ").slice(0, 110);
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
/**
|
|
4041
|
+
* 组装「用户主动上报」的内容:summary(正文)+ parts(分片,作为回复追加)。
|
|
4042
|
+
* @param {{note?:string, attachLogs?:boolean, report?:string}} opts
|
|
4043
|
+
* note = 用户自己写的描述(必填引导);attachLogs = 是否附带两个日志的尾部(默认附);
|
|
4044
|
+
* report = 用户从 DSH 拿回来的结论报告(可选)。
|
|
4045
|
+
*/
|
|
4046
|
+
async function buildDiagReport(relayDir, opts = {}) {
|
|
4047
|
+
const note = String(opts.note || "").slice(0, DIAG_NOTE_MAX).trim();
|
|
4048
|
+
const attachLogs = opts.attachLogs !== false;
|
|
4049
|
+
const report = String(opts.report || "");
|
|
4050
|
+
const manual = manualStatus(relayDir);
|
|
4051
|
+
const conn = await composeConnect(relayDir, { manual });
|
|
4052
|
+
const cfg = loadConfig(relayDir);
|
|
4053
|
+
const head = redactForDiagnostics([
|
|
4054
|
+
`【插件】v${PLUGIN_VERSION} 运行时 ${runtimeVersionOf(relayDir) || "未知"} ${osPlatform()}/${process.arch} node ${String(process.versions.node).split(".")[0]}`,
|
|
4055
|
+
`【阶段】${conn.phase}(${conn.text})`,
|
|
4056
|
+
`【设备】ID=${conn.deviceId || "(未生成)"} 运行环境=${conn.runtimeReady ? "就绪" : `缺失${conn.runtimeMissing && conn.runtimeMissing.length ? `(缺 ${conn.runtimeMissing.slice(0, 3).join("、")})` : ""}`}`,
|
|
4057
|
+
`【bridge】${conn.bridgeRunning ? `在运行 pid=${conn.bridgePids.join(",")}` : "未运行"} 守护=${conn.watcherRunning ? `在运行 pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `(${Math.round(conn.watcherAgeMs / 1000)}s)` : ""}` : "未运行"}${conn.watcherWedged ? " ⚠️活着但拉不起 bridge" : ""}${conn.watcherRestarts ? ` 已自动重启 ${conn.watcherRestarts} 次` : ""}`,
|
|
4058
|
+
`【上游】${conn.upstreamUrl}(${conn.upstreamSource}${conn.upstreamReachable === null ? "" : conn.upstreamReachable ? ",可达" : `,⚠️不可达:${conn.upstreamError}`})`,
|
|
4059
|
+
`【中继】${conn.registered ? `已注册(${conn.registerSource})` : "未注册"} 账号=${conn.accountCurrent === false ? "⚠️上一个账号(需重启 bridge)" : "当前账号"} 自动重试=${conn.attempts} 次`,
|
|
4060
|
+
`【错误】${conn.error ? `${conn.error.code}: ${conn.error.message}` : (conn.detail ? "无(见上方阶段说明)" : "无")}`,
|
|
4061
|
+
`【配置目录】${relayDir}`,
|
|
4062
|
+
`【账号】${cfg.phone || cfg.email ? "已登录" : "未登录"}(手机号不随本上报外发;服务端据登录态已知是谁)`,
|
|
4063
|
+
].join("\n"));
|
|
4064
|
+
const parts = [];
|
|
4065
|
+
const pushPart = (title, text) => {
|
|
4066
|
+
let rest = String(text || "").trim();
|
|
4067
|
+
while (rest && parts.length < DIAG_MAX_PARTS) {
|
|
4068
|
+
parts.push(`--- ${title} ---\n${rest.slice(0, DIAG_PART_MAX)}`);
|
|
4069
|
+
rest = rest.slice(DIAG_PART_MAX);
|
|
4070
|
+
}
|
|
4071
|
+
};
|
|
4072
|
+
if (report.trim()) pushPart("用户回传的结论报告", report);
|
|
4073
|
+
if (attachLogs) {
|
|
4074
|
+
pushPart("bridge 日志(末尾)", logTailForDiagnostics(relayDir, BRIDGE_LOG_FILE, 40));
|
|
4075
|
+
pushPart("安装/更新日志(末尾)", logTailForDiagnostics(relayDir, AUTO_INSTALL_LOG, 40));
|
|
4076
|
+
}
|
|
4077
|
+
const userPart = note ? `【用户描述】\n${note}\n\n` : "";
|
|
4078
|
+
const summary = (userPart + head).slice(0, DIAG_CONTENT_MAX);
|
|
4079
|
+
return { conn, title: diagTitleOf(conn, PLUGIN_VERSION), summary, parts };
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
/** 提交一条反馈(与面板同一条通道:登录态自动附账号 JWT,失败抛错带服务端人话)。 */
|
|
4083
|
+
async function submitFeedback(relayDir, payload) {
|
|
4084
|
+
const cfg = loadConfig(relayDir);
|
|
4085
|
+
const api = feedbackApiOf(cfg);
|
|
4086
|
+
const headers = {
|
|
4087
|
+
"content-type": "application/json",
|
|
4088
|
+
"x-dsh-device": cfg.device_id || "",
|
|
4089
|
+
"x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
|
|
4090
|
+
};
|
|
4091
|
+
if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
|
|
4092
|
+
const t = await feedbackAuthToken(relayDir).catch(() => "");
|
|
4093
|
+
if (t) headers.authorization = `Bearer ${t}`;
|
|
4094
|
+
const r = await fetch(`${api}/api/feedback`, {
|
|
4095
|
+
method: "POST", headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(12000),
|
|
4096
|
+
});
|
|
4097
|
+
const body = await r.json().catch(() => null);
|
|
4098
|
+
if (!r.ok) {
|
|
4099
|
+
const e = new Error(body?.error?.message || `反馈服务返回 ${r.status}`);
|
|
4100
|
+
e.status = r.status;
|
|
4101
|
+
e.code = body?.error?.code || "";
|
|
4102
|
+
throw e;
|
|
4103
|
+
}
|
|
4104
|
+
return body || {};
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
/** 在同一条反馈下追加一条回复(日志分片用;账号态用账号 JWT 即可,无需 thread_token)。 */
|
|
4108
|
+
async function replyFeedback(relayDir, id, content) {
|
|
4109
|
+
const cfg = loadConfig(relayDir);
|
|
4110
|
+
const api = feedbackApiOf(cfg);
|
|
4111
|
+
const headers = {
|
|
4112
|
+
"content-type": "application/json",
|
|
4113
|
+
"x-dsh-device": cfg.device_id || "",
|
|
4114
|
+
"x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
|
|
4115
|
+
};
|
|
4116
|
+
if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
|
|
4117
|
+
const t = await feedbackAuthToken(relayDir).catch(() => "");
|
|
4118
|
+
if (t) headers.authorization = `Bearer ${t}`;
|
|
4119
|
+
const r = await fetch(`${api}/api/feedback/${encodeURIComponent(id)}/replies`, {
|
|
4120
|
+
method: "POST", headers, body: JSON.stringify({ content: content.slice(0, DIAG_PART_MAX) }), signal: AbortSignal.timeout(12000),
|
|
4121
|
+
});
|
|
4122
|
+
if (!r.ok) return { ok: false, status: r.status };
|
|
4123
|
+
return { ok: true };
|
|
3513
4124
|
}
|
|
3514
4125
|
|
|
3515
4126
|
/**
|
|
@@ -3539,10 +4150,16 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
3539
4150
|
}
|
|
3540
4151
|
const launchd = launchdStatus();
|
|
3541
4152
|
const manual = manualStatus(relayDir);
|
|
3542
|
-
|
|
4153
|
+
const bridgeProcs = [...(manual.allBridge || []), ...(manual.bridge || [])];
|
|
4154
|
+
const watcherProcs = Array.isArray(manual.watcher) ? manual.watcher : [];
|
|
4155
|
+
// ⚠️ 「有进程」必须分成两类看:bridge 本体(能让设备登记)与**守护 watcher**(只是负责拉起它)。
|
|
4156
|
+
// 旧实现把两者混成一个 if,于是「守护活着、bridge 一直没起来」直接走「已在跑」分支 →
|
|
4157
|
+
// 每一次修复/重试都变成 no-op(用户反馈:「多次修复都没有解决」)。0.6.12 拆开处理。
|
|
4158
|
+
const watcherRunning = Boolean(launchd.running || watcherProcs.length);
|
|
4159
|
+
if (bridgeProcs.length || watcherRunning) {
|
|
3543
4160
|
// 进程在跑 ≠ 跑对了账号:切换账号后旧 bridge 会一直用旧凭据,
|
|
3544
4161
|
// 只看"有没有进程"会让自愈永久停摆(实测事故)。这里多判一次账号归属。
|
|
3545
|
-
if (!bridgeMatchesCurrentAccount(relayDir, cfg)) {
|
|
4162
|
+
if (bridgeProcs.length && !bridgeMatchesCurrentAccount(relayDir, cfg)) {
|
|
3546
4163
|
book.lastAttemptAt = now;
|
|
3547
4164
|
book.attempts += 1;
|
|
3548
4165
|
resetBridgeForAccountChange(relayDir, "bridge 身份与当前账号不符");
|
|
@@ -3551,6 +4168,37 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
3551
4168
|
`[dsh-remote-web] 检测到旧账号的 bridge 仍在运行,已重启以用当前账号重新登记(${r.status || "?"})`);
|
|
3552
4169
|
return { action: "restart", result: r };
|
|
3553
4170
|
}
|
|
4171
|
+
if (!bridgeProcs.length) {
|
|
4172
|
+
// 第一次看到「守护活着但没有 bridge」的时刻:pid 文件缺失时用它当存活起点(见 watcherStartedAt)
|
|
4173
|
+
if (!book.watcherSeenAt) book.watcherSeenAt = now;
|
|
4174
|
+
// 守护活着但没有 bridge:只有在「真的卡住了」时才动手(正常启动窗口内不打扰)。
|
|
4175
|
+
const wedge = watcherWedgeOf(relayDir, {
|
|
4176
|
+
watcherRunning: true, bridgeRunning: false, force: Boolean(opts.force), now,
|
|
4177
|
+
observedSince: book.watcherSeenAt,
|
|
4178
|
+
});
|
|
4179
|
+
if (wedge.wedged && !wedge.exhausted && (isWindows() || isDarwin())) {
|
|
4180
|
+
const rec = noteWatcherRestart(relayDir);
|
|
4181
|
+
// Windows 没有服务管理器:必须显式结束卡死的守护(否则 startBridge 幂等直接返回);
|
|
4182
|
+
// macOS 走 launchctl,startBridge 内部已先 bootout 再 bootstrap,等价于重启。
|
|
4183
|
+
if (isWindows()) stopBridgeDetached(relayDir);
|
|
4184
|
+
const r = startBridge(relayDir);
|
|
4185
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
4186
|
+
`[dsh-remote-web] 后台守护已存活 ${Math.round(wedge.ageMs / 1000)} 秒仍无 bridge 进程`
|
|
4187
|
+
+ `(上游 ${upstreamUrl()})→ 已第 ${rec.count} 次重启它(${r && r.status ? r.status : "?"})`);
|
|
4188
|
+
book.attempts = 0; // 刚重新拉起:给下一轮一个立即复核的机会
|
|
4189
|
+
book.lastAttemptAt = 0;
|
|
4190
|
+
return { action: "restart-wedged-watcher", result: r, wedge };
|
|
4191
|
+
}
|
|
4192
|
+
if (wedge.wedged && wedge.exhausted) {
|
|
4193
|
+
// 连续重启都不行 → 不再风暴式重启(面板据 wedged/exhausted 给出「一键更新」结论)
|
|
4194
|
+
book.lastAttemptAt = now;
|
|
4195
|
+
book.attempts += 1;
|
|
4196
|
+
return { action: "wedge-exhausted", wedge };
|
|
4197
|
+
}
|
|
4198
|
+
} else {
|
|
4199
|
+
resetWatcherRestart(relayDir); // bridge 真的在跑:连续重启计数清零
|
|
4200
|
+
book.watcherSeenAt = 0;
|
|
4201
|
+
}
|
|
3554
4202
|
book.attempts = 0; // 进程已在跑:退避计数归零,后续只等注册
|
|
3555
4203
|
book.lastAttemptAt = now;
|
|
3556
4204
|
return { action: "none" };
|
|
@@ -4737,6 +5385,83 @@ function registerRoutes(ctx, relayDir) {
|
|
|
4737
5385
|
sendJson(res, 200, { ok: true, retried: true, action: action && action.action ? action.action : "none", connect });
|
|
4738
5386
|
},
|
|
4739
5387
|
},
|
|
5388
|
+
// ── 🧾 故障上报(用户主动)与「让 DeepSeek 帮我修」提示词(0.6.12) ──────────
|
|
5389
|
+
// 隐私红线:**绝不**后台自动上传日志。这里只提供「预览 → 用户确认 → 提交」,
|
|
5390
|
+
// 以及把提示词交给用户,让他自己那台机器的 DSH 去修 —— 两条都由用户主动触发。
|
|
5391
|
+
{
|
|
5392
|
+
method: "GET",
|
|
5393
|
+
path: "/dsh-remote/diag/bundle",
|
|
5394
|
+
handler: async (_req, res) => {
|
|
5395
|
+
// 预览 = 用户点确认后**真正会发出去**的全文(正文 + 分片),一字不多一分不少
|
|
5396
|
+
const built = await buildDiagReport(relayDir, { attachLogs: true, note: "" });
|
|
5397
|
+
sendJson(res, 200, {
|
|
5398
|
+
ok: true,
|
|
5399
|
+
title: built.title,
|
|
5400
|
+
summary: built.summary,
|
|
5401
|
+
parts: built.parts,
|
|
5402
|
+
noteMax: DIAG_NOTE_MAX,
|
|
5403
|
+
partMax: DIAG_PART_MAX,
|
|
5404
|
+
});
|
|
5405
|
+
},
|
|
5406
|
+
},
|
|
5407
|
+
{
|
|
5408
|
+
method: "POST",
|
|
5409
|
+
path: "/dsh-remote/diag/report",
|
|
5410
|
+
handler: async (req, res) => {
|
|
5411
|
+
const body = await readJsonBody(req);
|
|
5412
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, code: "bad_request", error: "上报数据不是合法 JSON,请刷新页面重试。" });
|
|
5413
|
+
if (body.__tooLarge) return sendJson(res, 413, { ok: false, code: "too_large", error: "报告内容过大(超过 256KB),请只保留结论部分。" });
|
|
5414
|
+
let built;
|
|
5415
|
+
try {
|
|
5416
|
+
built = await buildDiagReport(relayDir, {
|
|
5417
|
+
note: body.note, attachLogs: body.attachLogs !== false, report: body.report,
|
|
5418
|
+
});
|
|
5419
|
+
} catch (e) {
|
|
5420
|
+
return sendJson(res, 500, { ok: false, code: "bundle_failed", error: `组装诊断信息失败:${e.message}` });
|
|
5421
|
+
}
|
|
5422
|
+
let created;
|
|
5423
|
+
try {
|
|
5424
|
+
created = await submitFeedback(relayDir, {
|
|
5425
|
+
kind: "feedback", category: "bug", title: built.title, content: built.summary, contact: "",
|
|
5426
|
+
});
|
|
5427
|
+
} catch (e) {
|
|
5428
|
+
return sendJson(res, e.status && e.status < 500 ? e.status : 502, {
|
|
5429
|
+
ok: false, code: e.code || "submit_failed", error: `上报失败:${e.message}`,
|
|
5430
|
+
});
|
|
5431
|
+
}
|
|
5432
|
+
const id = created?.feedback?.id || created?.id || "";
|
|
5433
|
+
const threadToken = created?.thread_token || "";
|
|
5434
|
+
// 日志/报告分片:作为**同一条反馈的回复**追加(服务端正文 2000 字硬限;分片失败不影响正文)
|
|
5435
|
+
const warnings = [];
|
|
5436
|
+
let sent = 0;
|
|
5437
|
+
for (const part of built.parts) {
|
|
5438
|
+
if (!id) break;
|
|
5439
|
+
const r = await replyFeedback(relayDir, id, part).catch(() => ({ ok: false }));
|
|
5440
|
+
if (r.ok) sent += 1;
|
|
5441
|
+
else if (!warnings.includes("部分日志分片未能上传(正文已到达,不影响定位)")) warnings.push("部分日志分片未能上传(正文已到达,不影响定位)");
|
|
5442
|
+
}
|
|
5443
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
5444
|
+
`[dsh-remote-web] 用户主动上报故障诊断(${id || "?"},正文 1 条 + 分片 ${sent}/${built.parts.length})`);
|
|
5445
|
+
sendJson(res, 200, { ok: true, id, threadToken, parts: sent, totalParts: built.parts.length, warnings });
|
|
5446
|
+
},
|
|
5447
|
+
},
|
|
5448
|
+
{
|
|
5449
|
+
method: "GET",
|
|
5450
|
+
path: "/dsh-remote/diag/fix-prompt",
|
|
5451
|
+
handler: async (_req, res) => {
|
|
5452
|
+
const conn = await composeConnect(relayDir);
|
|
5453
|
+
const prompt = buildFixPrompt({
|
|
5454
|
+
relayDir,
|
|
5455
|
+
bridgeLogPath: join(relayDir, BRIDGE_LOG_FILE),
|
|
5456
|
+
installLogPath: join(relayDir, AUTO_INSTALL_LOG),
|
|
5457
|
+
pluginVersion: PLUGIN_VERSION,
|
|
5458
|
+
runtimeVersion: runtimeVersionOf(relayDir),
|
|
5459
|
+
upstreamUrl: conn.upstreamUrl,
|
|
5460
|
+
phaseText: `${conn.text}${conn.detail ? " " + conn.detail : ""}`,
|
|
5461
|
+
});
|
|
5462
|
+
sendJson(res, 200, { ok: true, prompt, reportTitle: REPORT_TITLE, reportFormat: REPORT_FORMAT, reportKeys: REPORT_KEYS });
|
|
5463
|
+
},
|
|
5464
|
+
},
|
|
4740
5465
|
// ── 🤖 微信机器人通道(设置页「🤖 微信机器人」栏目) ──────────────────────
|
|
4741
5466
|
// 这六条只做代理:真正的协议与扫码状态机在 bridge 里(docs/wechat-bot-channel.md §3/§10)。
|
|
4742
5467
|
// 失败一律是 5xx + {ok:false, code, error:<人话>}:面板按 code/文案分流,绝不塌成「失败」两个字。
|
|
@@ -5171,6 +5896,11 @@ function registerRoutes(ctx, relayDir) {
|
|
|
5171
5896
|
*/
|
|
5172
5897
|
export function apply(ctx, config = {}) {
|
|
5173
5898
|
const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
|
|
5899
|
+
// 记住宿主 ctx:上游地址要问 dsh web 自己的监听端口(见 upstreamUrl)。
|
|
5900
|
+
// 存 ctx 而不是当场取端口:apply 可能早于 webServer.listen 完成,端口那时还是 0/undefined。
|
|
5901
|
+
webServerCtx = ctx;
|
|
5902
|
+
// 上游地址落盘(0.6.12):系统自启拉起的守护没有本进程的 env,只认这个文件。
|
|
5903
|
+
publishUpstream(relayDir);
|
|
5174
5904
|
// 全新激活(dsh web 重启后插件重新加载,或卸载后再次安装)→ 解除上次的「已卸载」停摆标记
|
|
5175
5905
|
UNINSTALLED_DIRS.delete(relayDir);
|
|
5176
5906
|
// 匿名遥测:装载即初始化(读回上次进程遗留的磁盘队列,例如重启前那条 harness_restart,
|