@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
|
@@ -0,0 +1,2390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-remote — 一键安装 + 配置 + 自启动(电脑端)
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* dsh-remote [setup] [选项] 一键安装(默认命令,无需任何参数)
|
|
7
|
+
* dsh-remote settings 提示如何登录(独立设置页已移除,见 dsh web 面板)
|
|
8
|
+
* dsh-remote run 前台运行 bridge(调试/守护)
|
|
9
|
+
* dsh-remote status 查看配置与服务状态
|
|
10
|
+
* dsh-remote plugin [--uninstall] 手动安装/卸载 dsh web 远程访问插件
|
|
11
|
+
*
|
|
12
|
+
* setup 选项(全部可选):
|
|
13
|
+
* --server <wss://host:port> --key <访问密钥> 自建模式(不填则连默认云端服务)
|
|
14
|
+
* --api <URL> 覆盖云端服务地址(高级)
|
|
15
|
+
* --no-autostart 不安装开机自启服务
|
|
16
|
+
* --no-plugin 不安装 dsh web 插件
|
|
17
|
+
*
|
|
18
|
+
* 行为:
|
|
19
|
+
* 1. 写入配置 <CONFIG_DIR>/.dsh-config.json(0600;npm 安装时为 ~/.dsh-remote/)
|
|
20
|
+
* 2. 生成自启动服务(macOS launchd / Linux systemd),随 dsh web(3080) 存活自动保活
|
|
21
|
+
* 3. 自动把远程访问插件装进 dsh web 设置页(若检测到 profile)
|
|
22
|
+
* 4. 登录/连接配置在 dsh web → 设置 → 「远程访问」面板完成(SaaS 注册登录 / 自建切换)
|
|
23
|
+
* 自建 CLI 用户也可用 `dsh-remote setup --server … --key …`(无需再打开设置页)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { spawn, spawnSync, execSync } from "node:child_process";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { childStopped, stopChildGracefully } from "./clients/dsh-remote/src/lifecycle.mjs";
|
|
32
|
+
|
|
33
|
+
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
|
|
34
|
+
const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
|
|
35
|
+
// 配置目录:**两种形态统一放 ~/.dsh-remote**(npx/npm 安装时 node_modules 内不可写,故放用户目录;
|
|
36
|
+
// 仓库开发时也放同一处)。为什么不按形态分叉:插件半的默认配置目录本来就是 ~/.dsh-remote
|
|
37
|
+
// (见 packages/dsh-remote-web/lib/index.js 的 DEFAULT_RELAY_DIR),而激活块会把这里写进 relayDir。
|
|
38
|
+
// 两边不一致会出现「安装器把 .dsh-config.json 写进仓库根,插件却去 ~/.dsh-remote 找配置」→
|
|
39
|
+
// 面板显示成未登录/空配置,且安装器的状态文件散落进仓库工作区(实测踩到)。
|
|
40
|
+
// 需要隔离(测试 / 多账号 / 多实例)时用 DSH_RELAY_DIR 显式覆盖。
|
|
41
|
+
const CONFIG_DIR = process.env.DSH_RELAY_DIR || path.join(os.homedir(), ".dsh-remote");
|
|
42
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, ".dsh-config.json");
|
|
43
|
+
// 默认云端服务地址(服务商 SaaS 入口;自建用户用 --server/--key 指向自己的 router)
|
|
44
|
+
// DSH_RELAY_DEFAULT_API 可覆盖(测试脚本指向本地死端口,避免兜底路径打到生产)。
|
|
45
|
+
const DEFAULT_API = String(process.env.DSH_RELAY_DEFAULT_API || "").trim()
|
|
46
|
+
|| "https://n.risegao.cn:13443/relay-api";
|
|
47
|
+
const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
|
|
48
|
+
const REPO_URL = "https://github.com/mrRisega/dsh-remote";
|
|
49
|
+
|
|
50
|
+
// ---------- 平台(0.6.7 起支持 Windows) ----------
|
|
51
|
+
const IS_WIN = process.platform === "win32";
|
|
52
|
+
/**
|
|
53
|
+
* Windows 自启动 = 任务计划程序(Task Scheduler)里的登录任务。
|
|
54
|
+
* 为什么不是「启动文件夹快捷方式」:生成 .lnk 需要额外依赖或 COM 调用,而 schtasks 系统自带,
|
|
55
|
+
* 且**不需要管理员**就能为当前用户建 ONLOGON 任务(默认「仅在用户登录时运行」,不存密码)。
|
|
56
|
+
*/
|
|
57
|
+
const WIN_TASK_NAME = "dsh-remote-bridge";
|
|
58
|
+
/**
|
|
59
|
+
* watcher / bridge 的 pid 文件(Windows 必需)。
|
|
60
|
+
* Windows 没有 pgrep/ps,插件半(dsh-remote-web)与安装器都靠这两个文件发现进程:
|
|
61
|
+
* watcher = 本安装器 `run` 子命令的进程,bridge = 它拉起的 dsh-bridge.mjs 子进程。
|
|
62
|
+
*/
|
|
63
|
+
const WATCHER_PID_FILE = ".dsh-watcher.pid";
|
|
64
|
+
const BRIDGE_PID_FILE = ".dsh-bridge.pid";
|
|
65
|
+
|
|
66
|
+
// ---------- 运行时自物化(npm/npx 安装 → 固化到配置目录,脱离 npx 缓存) ----------
|
|
67
|
+
// npx 每次安装的缓存目录(~/.npm/_npx/<hash>)不固定:缓存一旦清理,指向它的自启动服务
|
|
68
|
+
// 就会像“找不到模块”一样崩溃。因此 npm 形态安装时把 dsh-setup.mjs + clients + 依赖(ws)
|
|
69
|
+
// 固化到 CONFIG_DIR(~/.dsh-remote),自启动服务只指向这个稳定路径。
|
|
70
|
+
// 插件(dsh-remote-web;2026-09 前为 dsh-remote-ui)的“运行环境已就绪”判断同样以 CONFIG_DIR/dsh-setup.mjs 为准。
|
|
71
|
+
|
|
72
|
+
function pkgVersion() {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(fs.readFileSync(path.join(THIS_DIR, "package.json"), "utf8")).version || "";
|
|
75
|
+
} catch { return ""; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 向上查找依赖树里的 ws(npx 布局通常提升到缓存根 node_modules,npm 布局则内嵌)。 */
|
|
79
|
+
function findDepWs(startDir) {
|
|
80
|
+
let d = startDir;
|
|
81
|
+
while (true) {
|
|
82
|
+
const cand = path.join(d, "node_modules", "ws");
|
|
83
|
+
if (fs.existsSync(cand)) return cand;
|
|
84
|
+
const parent = path.dirname(d);
|
|
85
|
+
if (parent === d) break;
|
|
86
|
+
d = parent;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。
|
|
93
|
+
* ⚠️ `src/lifecycle.mjs` 也在表里:本安装器**自己**import 它(进程优雅停机的共用实现),
|
|
94
|
+
* 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
|
|
95
|
+
* 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。
|
|
96
|
+
*/
|
|
97
|
+
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "src/lifecycle.mjs"];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 把"客户端脚本"补齐到配置目录。
|
|
101
|
+
*
|
|
102
|
+
* ⚠️ 2026-09-15 实测的坑:只按**版本号**判断"要不要同步"是不够的 ——
|
|
103
|
+
* 手机端遮罩 bug 的修复落在 `clients/dsh-remote/mobile-adapter.mjs` 里(bridge 会把它注入官方页面后
|
|
104
|
+
* 直接发给手机)。用户升级到新版本时,若配置目录里那份旧脚本没被覆盖,
|
|
105
|
+
* **修复就永远到不了手机**(现场表现:改了、发了版,用户还是整屏阴影)。
|
|
106
|
+
* 所以这里按**内容**比对:只要与装置器手里的不一致就覆盖(幂等、无副作用)。
|
|
107
|
+
* @returns {number} 实际覆盖的文件数
|
|
108
|
+
*/
|
|
109
|
+
function syncRuntimeClientFiles() {
|
|
110
|
+
const destRoot = path.join(CONFIG_DIR, "clients", "dsh-remote");
|
|
111
|
+
let n = 0;
|
|
112
|
+
for (const name of RUNTIME_CLIENT_FILES) {
|
|
113
|
+
const src = path.join(THIS_DIR, "clients", "dsh-remote", name);
|
|
114
|
+
if (!fs.existsSync(src)) continue;
|
|
115
|
+
const dst = path.join(destRoot, name);
|
|
116
|
+
try {
|
|
117
|
+
if (fs.existsSync(dst) && fs.readFileSync(src).equals(fs.readFileSync(dst))) continue; // 一致 → 不动
|
|
118
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
119
|
+
fs.copyFileSync(src, dst);
|
|
120
|
+
n += 1;
|
|
121
|
+
} catch { /* 单个文件失败不影响整体 */ }
|
|
122
|
+
}
|
|
123
|
+
return n;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ensureRuntimeCopy() {
|
|
127
|
+
// 仓库开发形态:装置器要使用**仓库里这份**(实时生效),但配置目录里的运行时副本
|
|
128
|
+
// 仍必须与之一致 —— 否则 bridge 会把陈旧脚本发给手机(上面的 syncRuntimeClientFiles 说明)。
|
|
129
|
+
if (!IS_NPM_INSTALL) {
|
|
130
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
131
|
+
const srcSetup = path.join(THIS_DIR, "dsh-setup.mjs");
|
|
132
|
+
const dstSetup = path.join(CONFIG_DIR, "dsh-setup.mjs");
|
|
133
|
+
try { if (!fs.existsSync(dstSetup) || !fs.readFileSync(srcSetup).equals(fs.readFileSync(dstSetup))) fs.copyFileSync(srcSetup, dstSetup); } catch { /* ignore */ }
|
|
134
|
+
const n = syncRuntimeClientFiles();
|
|
135
|
+
if (n > 0) console.log(`✅ 运行时: 已同步 ${n} 个客户端脚本到 ${CONFIG_DIR}`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
139
|
+
const ver = pkgVersion();
|
|
140
|
+
const setupTarget = path.join(CONFIG_DIR, "dsh-setup.mjs");
|
|
141
|
+
if (setupTarget === path.join(THIS_DIR, "dsh-setup.mjs")) return; // 已在配置目录内执行
|
|
142
|
+
const verFile = path.join(CONFIG_DIR, ".dsh-setup-version");
|
|
143
|
+
let cur = "";
|
|
144
|
+
try { cur = fs.readFileSync(verFile, "utf8").trim(); } catch { /* 首次 */ }
|
|
145
|
+
if (fs.existsSync(setupTarget) && cur === ver) {
|
|
146
|
+
// 版本没变也要确保客户端脚本一致(版本号相同但脚本被修过的情况真实存在)
|
|
147
|
+
const n = syncRuntimeClientFiles();
|
|
148
|
+
if (n > 0) console.log(`✅ 运行时: 已同步 ${n} 个客户端脚本到 ${CONFIG_DIR}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
fs.cpSync(path.join(THIS_DIR, "dsh-setup.mjs"), setupTarget);
|
|
152
|
+
fs.cpSync(path.join(THIS_DIR, "clients"), path.join(CONFIG_DIR, "clients"), { recursive: true, force: true });
|
|
153
|
+
const wsSrc = findDepWs(THIS_DIR);
|
|
154
|
+
if (wsSrc) {
|
|
155
|
+
fs.mkdirSync(path.join(CONFIG_DIR, "node_modules"), { recursive: true });
|
|
156
|
+
fs.cpSync(wsSrc, path.join(CONFIG_DIR, "node_modules", "ws"), { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
fs.writeFileSync(verFile, ver);
|
|
159
|
+
console.log(`✅ 运行时: ${CONFIG_DIR}`);
|
|
160
|
+
}
|
|
161
|
+
/** 打印用法(唯一实现:提前退出与末尾分派共用,避免两处漂移)。 */
|
|
162
|
+
function printHelp() {
|
|
163
|
+
console.log(`dsh-remote — 手机远程控制 dsh web(隧道模式)
|
|
164
|
+
|
|
165
|
+
用法:
|
|
166
|
+
dsh-remote 一键安装(默认命令,无需任何参数;含插件与自启动)
|
|
167
|
+
dsh-remote settings 显示登录/连接配置指引(独立设置页已移除)
|
|
168
|
+
dsh-remote run 前台运行 bridge(调试)
|
|
169
|
+
dsh-remote status 查看配置与服务状态
|
|
170
|
+
dsh-remote plugin 手动安装 dsh web 远程访问插件(--uninstall 卸载)
|
|
171
|
+
dsh-remote repair 修复插件挂载(dsh web 起不来时用;只碰 profile,不联网)
|
|
172
|
+
dsh-remote --help 显示本用法(等同 dsh-remote help)
|
|
173
|
+
|
|
174
|
+
自建模式(可选):
|
|
175
|
+
dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
|
|
176
|
+
|
|
177
|
+
登录/连接配置: 打开 dsh web → 设置 → 「远程访问」→ 注册或登录手机号即可
|
|
178
|
+
(自建用户切「自建服务」标签或直接用上方 setup 命令,无需另开页面)。
|
|
179
|
+
文档: ${REPO_URL}
|
|
180
|
+
`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 认识的参数(只给 flag 也要能装上去,所以不认识时才拦)。
|
|
184
|
+
const KNOWN_FLAGS = new Set([
|
|
185
|
+
"--api", "--server", "--key", "--profile",
|
|
186
|
+
"--no-autostart", "--no-plugin", "--no-restart", "--uninstall"
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
// ⚠️ 这一整块必须挡在 ensureRuntimeCopy() **之前**:它会往 ~/.dsh-remote 同步运行时脚本。
|
|
190
|
+
// 看用法、或参数拼错,都不该改动用户磁盘 —— 此前 `dsh-remote --help` 会一路走到 setup,
|
|
191
|
+
// 什么都不问就开始安装。
|
|
192
|
+
{
|
|
193
|
+
const first = process.argv[2];
|
|
194
|
+
if (first === "-h" || first === "--help" || first === "help") {
|
|
195
|
+
printHelp();
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
198
|
+
if (first && first.startsWith("-") && !KNOWN_FLAGS.has(first)) {
|
|
199
|
+
console.error(`❌ 未知参数: ${first}`);
|
|
200
|
+
console.error(" 直接安装就用 dsh-remote(不带参数);查看用法用 dsh-remote --help。");
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
|
|
205
|
+
|
|
206
|
+
/** 自启动服务应指向的 dsh-setup.mjs:优先配置目录内的固化副本,否则当前执行文件。 */
|
|
207
|
+
function runtimeSetupPath() {
|
|
208
|
+
const local = path.join(CONFIG_DIR, "dsh-setup.mjs");
|
|
209
|
+
try {
|
|
210
|
+
fs.accessSync(local, fs.constants.R_OK);
|
|
211
|
+
return local;
|
|
212
|
+
} catch { /* 未固化(如仓库开发)→ 用当前文件 */ }
|
|
213
|
+
return fileURLToPath(import.meta.url);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------- 工具 ----------
|
|
217
|
+
|
|
218
|
+
function sh(cmd, timeoutMs = 15000, cwd = undefined) {
|
|
219
|
+
try {
|
|
220
|
+
const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, ...(cwd ? { cwd } : {}) });
|
|
221
|
+
return { ok: true, stdout: String(stdout ?? ""), stderr: "", code: 0 };
|
|
222
|
+
} catch (e) {
|
|
223
|
+
return { ok: false, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), code: e.status ?? -1 };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 解析真实可执行路径(launchd/systemd 需要真实文件 + 可执行位)。 */
|
|
228
|
+
function resolveExecutable(p) {
|
|
229
|
+
try {
|
|
230
|
+
const real = fs.realpathSync(p);
|
|
231
|
+
fs.accessSync(real, fs.constants.X_OK);
|
|
232
|
+
return real;
|
|
233
|
+
} catch { return null; }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function preferredNode() {
|
|
237
|
+
const candidates = [
|
|
238
|
+
process.env.DSH_SETUP_NODE20 || "",
|
|
239
|
+
process.env.DSH_SETUP_NODE || "",
|
|
240
|
+
process.execPath
|
|
241
|
+
].filter(Boolean);
|
|
242
|
+
for (const p of candidates) {
|
|
243
|
+
const real = resolveExecutable(p);
|
|
244
|
+
if (real) return real;
|
|
245
|
+
}
|
|
246
|
+
return process.execPath;
|
|
247
|
+
}
|
|
248
|
+
const NODE_BIN = preferredNode();
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 自启动服务里注入的 PATH。
|
|
252
|
+
* 必须含 /usr/sbin 与 /sbin:bridge 会用 ioreg 之类系统命令探测本机信息,
|
|
253
|
+
* 缺少这两个目录时 launchd 服务日志里会刷 `ioreg: command not found`(用户反馈实测)。
|
|
254
|
+
*/
|
|
255
|
+
const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
256
|
+
|
|
257
|
+
// ---------- 安装口径(随 bridge 环境变量注入;服务端存到设备行,用于区分安装来源) ----------
|
|
258
|
+
/**
|
|
259
|
+
* install_source:本文件即「一键安装器」→ 默认 npx(用户自己跑 `npx @mrrisega/dsh-remote` 的那条路径)。
|
|
260
|
+
* 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
|
|
261
|
+
* DSH_BRIDGE_INSTALL_SOURCE=plugin_market,这里原样透传(外部注入 > 硬编码默认)。
|
|
262
|
+
* install_version:优先取外部注入(插件/在线更新器),否则取本包 package.json 版本。
|
|
263
|
+
*/
|
|
264
|
+
const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
|
|
265
|
+
const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 把文件权限收紧到「只有本人可读写」。
|
|
269
|
+
*
|
|
270
|
+
* ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL,不是 POSIX mode 位):
|
|
271
|
+
* 实测 .dsh-config.json 拿到的是用户目录的默认**继承 ACL**(AreAccessRulesProtected=False),
|
|
272
|
+
* 也就是说代码里那句 0600 对 Windows 用户完全没效果 —— 而文件里是**明文账号密码**。
|
|
273
|
+
* 风险不在"同机他人可读"(默认 ACL 下其实读不到),而在**任何按文件复制的场景**
|
|
274
|
+
* (备份/同步盘/杀软上报/崩溃转储/用户发给作者的支持包)都会连钥匙一起被带走,
|
|
275
|
+
* 而文档里的"0600 请妥善保护"会让用户误以为 Windows 上已有这层保护。
|
|
276
|
+
* 这里显式断开继承、只授予当前用户;失败只警告一次,绝不影响主流程。
|
|
277
|
+
*/
|
|
278
|
+
let hardenWarned = false;
|
|
279
|
+
function hardenFile(file) {
|
|
280
|
+
try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
|
|
281
|
+
if (process.platform !== "win32") return;
|
|
282
|
+
const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
|
|
283
|
+
// 两个环境变量都取不到时**绝不能静默返回**:那会让人以为文件已加固,实际仍是继承 ACL。
|
|
284
|
+
// 正常 Windows 会话不会走到这里(USERNAME 必然存在),但受限令牌/服务账户下有可能。
|
|
285
|
+
if (!who) {
|
|
286
|
+
if (!hardenWarned) {
|
|
287
|
+
hardenWarned = true;
|
|
288
|
+
console.warn(`⚠️ 无法收紧文件权限(USERDOMAIN/USERNAME 均未设置):${file}`);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
let r;
|
|
293
|
+
try {
|
|
294
|
+
r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
|
|
295
|
+
{ windowsHide: true, encoding: "utf8", timeout: 8000 });
|
|
296
|
+
} catch (e) { r = { status: -1, stderr: e.message }; }
|
|
297
|
+
if (r.status !== 0 && !hardenWarned) {
|
|
298
|
+
hardenWarned = true;
|
|
299
|
+
console.warn(`⚠️ 收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`);
|
|
300
|
+
console.warn(" 配置文件可能仍可被其它账户/备份工具读取,请自行确认其存放位置。");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------- 配置读写 ----------
|
|
305
|
+
function loadConfig() {
|
|
306
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); }
|
|
307
|
+
catch { return {}; }
|
|
308
|
+
}
|
|
309
|
+
function saveConfig(cfg) {
|
|
310
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
311
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
312
|
+
hardenFile(CONFIG_PATH); // Windows 上 mode 是空操作 → 必须显式收紧 ACL(内含明文账号密码)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------- 公共配置(从服务端取域名,服务商可随时更换) ----------
|
|
316
|
+
async function fetchPublicConfig(apiBase) {
|
|
317
|
+
try {
|
|
318
|
+
const r = await fetch(apiBase + "/api/public-config", { signal: AbortSignal.timeout(6000) });
|
|
319
|
+
if (r.ok) return await r.json();
|
|
320
|
+
} catch {}
|
|
321
|
+
return {};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 从账号 API 地址推导隧道 WebSocket 地址:https://host/relay-api → wss://host */
|
|
325
|
+
function deriveTunnelUrl(apiUrl) {
|
|
326
|
+
return apiUrl.replace(/\/relay-api\/?$/, "").replace(/^https/, "wss");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 模式归一化(SaaS 权威):清掉自建残留(local_key/假 tunnel_url),api_url 与
|
|
331
|
+
* tunnel_url 一律按云端权威地址重算。防止"切过自建后又登云端"时残留配置把
|
|
332
|
+
* bridge 带到错误服务器(此前手机永远看不到设备的根因之一)。
|
|
333
|
+
*/
|
|
334
|
+
function applySaaSMode(cfg) {
|
|
335
|
+
delete cfg.local_key;
|
|
336
|
+
delete cfg.server; // 旧字段兜底
|
|
337
|
+
cfg.api_url = String(cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
338
|
+
cfg.tunnel_url = deriveTunnelUrl(cfg.api_url);
|
|
339
|
+
return cfg;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** 归一化自建服务器地址:缺省补 wss://,去掉末尾 / */
|
|
343
|
+
function normalizeTunnelUrl(raw) {
|
|
344
|
+
let u = String(raw || "").trim().replace(/\/+$/, "");
|
|
345
|
+
if (!/^wss?:/i.test(u)) u = "wss://" + u;
|
|
346
|
+
return u;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function argValue(argv, name) {
|
|
350
|
+
const i = argv.indexOf(name);
|
|
351
|
+
return i > -1 ? argv[i + 1] : null;
|
|
352
|
+
}
|
|
353
|
+
function hasFlag(argv, name) {
|
|
354
|
+
return argv.includes(name);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------- 自启动服务生成与热启动 ----------
|
|
358
|
+
/** 自启动服务 label(macOS LaunchAgent / Linux systemd 共用同一个名字)。 */
|
|
359
|
+
const BRIDGE_LABEL = "com.dshremote.bridge";
|
|
360
|
+
|
|
361
|
+
function autostartFilePath() {
|
|
362
|
+
if (process.platform === "darwin")
|
|
363
|
+
return path.join(os.homedir(), `Library/LaunchAgents/${BRIDGE_LABEL}.plist`);
|
|
364
|
+
if (process.platform === "linux")
|
|
365
|
+
return path.join(os.homedir(), ".config/systemd/user/dsh-bridge.service");
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 同步睡眠(生成 launchd 状态轮询用;不引第三方依赖)。 */
|
|
370
|
+
function sleepSync(ms) {
|
|
371
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
372
|
+
catch { /* 极端环境不支持 → 退化为忙等一小会 */ const end = Date.now() + ms; while (Date.now() < end) { /* spin */ } }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 运行 schtasks(Windows 任务计划程序)。
|
|
377
|
+
* **不走 sh()**:/TR 里必然带引号(node 路径与脚本路径都可能含空格),
|
|
378
|
+
* 经 cmd.exe 转一层会把嵌套引号绞碎;spawnSync 直接把 argv 交给 CreateProcess,
|
|
379
|
+
* 由 Node 负责转义,参数原样到达 schtasks。
|
|
380
|
+
*/
|
|
381
|
+
function schtasks(args, timeoutMs = 15000) {
|
|
382
|
+
try {
|
|
383
|
+
const r = spawnSync("schtasks", args, { encoding: "utf8", timeout: timeoutMs, windowsHide: true });
|
|
384
|
+
return {
|
|
385
|
+
ok: r.status === 0,
|
|
386
|
+
code: r.status,
|
|
387
|
+
stdout: String(r.stdout || ""),
|
|
388
|
+
stderr: String(r.stderr || (r.error && r.error.message) || ""),
|
|
389
|
+
};
|
|
390
|
+
} catch (e) {
|
|
391
|
+
return { ok: false, code: -1, stdout: "", stderr: e.message };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** 写 pid 文件(Windows 进程发现用;失败不致命,插件半还有 PowerShell 兜底扫描)。 */
|
|
396
|
+
function writePidFile(name, pid) {
|
|
397
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
398
|
+
try {
|
|
399
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
400
|
+
fs.writeFileSync(path.join(CONFIG_DIR, name), String(pid));
|
|
401
|
+
} catch { /* 非关键 */ }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function removePidFile(name) {
|
|
405
|
+
try { fs.rmSync(path.join(CONFIG_DIR, name), { force: true }); } catch { /* 非关键 */ }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function readPidFile(name) {
|
|
409
|
+
try {
|
|
410
|
+
const n = Number(String(fs.readFileSync(path.join(CONFIG_DIR, name), "utf8")).trim());
|
|
411
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
412
|
+
} catch { return null; }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 这个 pid 看起来**真的**是 watcher 吗?
|
|
417
|
+
*
|
|
418
|
+
* 为什么必须查:pid 文件可能过期,而 pid 会被系统**回收给无关进程**。
|
|
419
|
+
* 去重逻辑信任 `pidAlive()` 只会导致"我多退一次"(安全方向);但监管者接管时要
|
|
420
|
+
* **主动结束**那个 pid —— 盲杀一个被回收的无关进程是事故。所以动它之前先认一下命令行。
|
|
421
|
+
*/
|
|
422
|
+
function looksLikeWatcher(pid) {
|
|
423
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
424
|
+
const r = sh(`ps -p ${pid} -o command=`);
|
|
425
|
+
return Boolean(r.ok && /dsh-setup\.mjs/.test(r.stdout));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* 这个 pid 是不是我们的 **bridge 子进程**(不是 watcher、也不是别的 node)。
|
|
430
|
+
*
|
|
431
|
+
* 为什么要单独判:接管时我们要回收"上一任遗留的 bridge",而 pid 可能已被系统回收给无关进程 ——
|
|
432
|
+
* 对无关进程发 SIGKILL 是不可接受的。所以对 `ps` 出来的命令行做**双重**校验:
|
|
433
|
+
* 必须同时出现 bridge 脚本名与 clients/dsh-remote 路径片段。
|
|
434
|
+
*/
|
|
435
|
+
function looksLikeBridge(pid) {
|
|
436
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
437
|
+
const r = sh(`ps -p ${pid} -o command=`);
|
|
438
|
+
if (!r.ok) return false;
|
|
439
|
+
const cmd = String(r.stdout || "");
|
|
440
|
+
return /dsh-bridge\.mjs/.test(cmd) && /clients[\\/]dsh-remote/.test(cmd);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 该不该回收这个"上一任留下的 bridge 子进程"(纯函数 —— 便于直接测)。
|
|
445
|
+
*
|
|
446
|
+
* 只有**确认它就是我们的 bridge 脚本**才敢动它;是自己、已死、非整数一律不动。
|
|
447
|
+
* 这条判断代价很高(误杀无关进程 / 漏杀导致两个实例抢同一微信账号的消息),所以抽出来单测。
|
|
448
|
+
*/
|
|
449
|
+
function shouldReapLeftoverBridge({ pid, selfPid = 0, alive = false, isBridgeScript = false } = {}) {
|
|
450
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
451
|
+
if (pid === selfPid) return false;
|
|
452
|
+
if (!alive) return false;
|
|
453
|
+
return Boolean(isBridgeScript);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* 去重决策(纯函数 —— 这条判断的代价很高,必须能被直接测)。
|
|
458
|
+
*
|
|
459
|
+
* · "run" → 没有别的守护(或就是自己),正常跑
|
|
460
|
+
* · "takeover" → 有别的守护,但**本进程是监管者(launchd)拉起的**:请它让位,我们接管
|
|
461
|
+
* · "yield" → 有别的守护,本进程是手动/插件脱离进程拉起的:直接退出,避免两个 bridge
|
|
462
|
+
* 抢同一个设备登记
|
|
463
|
+
*
|
|
464
|
+
* 为什么"被监管者拉起"必须走 takeover 而不是 yield:
|
|
465
|
+
* KeepAlive 每次重拉都秒退 → 监管者判为崩溃并节流 → 插件的自愈认定"服务坏了"→
|
|
466
|
+
* bootout 摘掉作业(入口判定成立时连 plist 一起删)→ **自启动彻底消失**(真机实测)。
|
|
467
|
+
*/
|
|
468
|
+
function dedupDecision({ existingPid = null, selfPid = 0, supervisor = "" } = {}) {
|
|
469
|
+
if (!existingPid || existingPid === selfPid) return "run";
|
|
470
|
+
return supervisor ? "takeover" : "yield";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function pidAlive(pid) {
|
|
474
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
475
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 注册 Windows 登录任务(幂等:先删再建)。
|
|
480
|
+
* /DELAY 0000:15:登录后等 15 秒再起,避免与桌面环境抢启动时机
|
|
481
|
+
* (watcher 本身就会等 dsh web 的 3080 端口,晚起没有任何副作用)。
|
|
482
|
+
*
|
|
483
|
+
* 【修复「黑色空白命令行窗口」】早期版本把 `node dsh-setup.mjs run` 直接交给任务计划程序,
|
|
484
|
+
* 并把它当成"只能忍受"的限制 —— 这不是限制,是 bug:
|
|
485
|
+
* · 任务计划程序在**交互会话**里启动进程,不会给 CREATE_NO_WINDOW,
|
|
486
|
+
* 控制台程序(node.exe)于是必然弹出一个黑色空白窗口;用户关掉它 = 杀掉 watcher,
|
|
487
|
+
* 而 dsh web 的自愈轮次又会把它拉回来 → 表现为"窗口关不掉、一直重开",体验极差。
|
|
488
|
+
* · 零依赖的彻底隐藏办法:任务只负责起 `wscript.exe`,脚本里用
|
|
489
|
+
* `WScript.Shell.Run cmd, 0, False`(窗口样式 0 = 不显示;不等待 = node 成为独立进程)。
|
|
490
|
+
* wscript.exe 与 VBScript 是 Windows 自带组件,**不需要凭据、不需要 S4U/存密码、
|
|
491
|
+
* 不需要第三方隐藏器**(旧注释误判为必须付出这些代价,实测不成立)。
|
|
492
|
+
* · watcher 仍由它自己写 `.dsh-watcher.pid`,所以 pid 判活/幂等完全不受这层包装影响。
|
|
493
|
+
* · 极少数机器被组策略禁用 Windows Script Host → 注册失败时自动退回直接启动,
|
|
494
|
+
* 保证「能自启」这条底线不丢(代价是那种机器上仍会有窗口,但不至于完全没自启)。
|
|
495
|
+
* 旧版本注册的可见任务会被这里的 /Delete + /Create 一并迁移成隐藏形式(重装/一键更新即生效)。
|
|
496
|
+
*/
|
|
497
|
+
let winTaskCache = null;
|
|
498
|
+
|
|
499
|
+
/** 隐藏启动器(.vbs)路径:放配置目录,随重装覆盖。 */
|
|
500
|
+
function winHiddenLauncherPath() {
|
|
501
|
+
return path.join(CONFIG_DIR, "launch-hidden.vbs");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* 生成隐藏启动器 —— `wscript.exe //B <此文件>` 会在完全隐藏的窗口里执行
|
|
506
|
+
* `"<node>" "<setup>" run`。
|
|
507
|
+
* 转义要点:VBScript 的字符串字面量内嵌 `"` 要写成 `""`,故先把整个命令行的引号翻倍。
|
|
508
|
+
*/
|
|
509
|
+
function writeWinHiddenLauncher(node, setup) {
|
|
510
|
+
const vbsPath = winHiddenLauncherPath();
|
|
511
|
+
const commandLine = `"${node}" "${setup}" run`; // 真正要执行的命令行
|
|
512
|
+
const literal = commandLine.replace(/"/g, '""'); // 嵌进 VBS 字符串字面量后
|
|
513
|
+
const src = [
|
|
514
|
+
"' dsh-remote 自动生成(勿手改,重装会覆盖)",
|
|
515
|
+
"' 隐藏启动 bridge 守护:窗口样式 0 = 不显示窗口;False = 不等待,node 成为独立进程。",
|
|
516
|
+
`CreateObject("WScript.Shell").Run "${literal}", 0, False`,
|
|
517
|
+
"",
|
|
518
|
+
].join("\r\n");
|
|
519
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
520
|
+
fs.writeFileSync(vbsPath, src, "utf8");
|
|
521
|
+
return vbsPath;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function writeWindowsTask() {
|
|
525
|
+
const node = NODE_BIN;
|
|
526
|
+
const setup = runtimeSetupPath();
|
|
527
|
+
// 首选 wscript 包装(无黑窗);写不了 .vbs 就退回直接启动(有窗,但保底能自启)
|
|
528
|
+
let direct = `"${node}" "${setup}" run`;
|
|
529
|
+
let tr = direct;
|
|
530
|
+
let hidden = false;
|
|
531
|
+
try {
|
|
532
|
+
tr = `wscript.exe //B "${writeWinHiddenLauncher(node, setup)}"`;
|
|
533
|
+
hidden = true;
|
|
534
|
+
} catch (e) {
|
|
535
|
+
console.warn(`⚠️ 无法生成隐藏启动器(${e.message}),自启动时会显示命令行窗口。`);
|
|
536
|
+
}
|
|
537
|
+
if (tr.length > 255) {
|
|
538
|
+
console.log(`⚠️ 自启动命令过长(${tr.length} 字符,schtasks 上限 261):${tr}`);
|
|
539
|
+
console.log(" 建议把 dsh-remote 装到更短的路径,或用 `dsh-remote run` 手动运行 bridge。");
|
|
540
|
+
}
|
|
541
|
+
schtasks(["/Delete", "/TN", WIN_TASK_NAME, "/F"]); // 幂等清理(不存在也只是非 0 退出,不影响后续
|
|
542
|
+
let created = schtasks(["/Create", "/TN", WIN_TASK_NAME, "/TR", tr, "/SC", "ONLOGON", "/DELAY", "0000:15", "/F"]);
|
|
543
|
+
if (!created.ok && hidden) {
|
|
544
|
+
// 组策略禁用 Windows Script Host 等 → 退回直接启动,别让"自启"整个失效
|
|
545
|
+
console.warn(`⚠️ 隐藏方式注册失败(${(created.stderr || created.stdout).trim() || `退出码 ${created.code}`}),改用直接启动。`);
|
|
546
|
+
tr = direct;
|
|
547
|
+
hidden = false;
|
|
548
|
+
created = schtasks(["/Create", "/TN", WIN_TASK_NAME, "/TR", tr, "/SC", "ONLOGON", "/DELAY", "0000:15", "/F"]);
|
|
549
|
+
}
|
|
550
|
+
if (!created.ok) {
|
|
551
|
+
winTaskCache = false;
|
|
552
|
+
console.warn(`⚠️ 自启动任务注册失败: ${(created.stderr || created.stdout).trim() || `schtasks 退出码 ${created.code}`}`);
|
|
553
|
+
console.warn(" 可手动执行 `dsh-remote run` 运行 bridge(功能完全一样,只是不随登录自启)。");
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
winTaskCache = true;
|
|
557
|
+
return `任务计划程序 / ${WIN_TASK_NAME}`; // 汇总里展示的"路径"
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** 查询 Windows 登录任务是否已注册(退出码 0 = 存在;不解析本地化输出)。 */
|
|
561
|
+
function windowsTaskInstalled() {
|
|
562
|
+
return schtasks(["/Query", "/TN", WIN_TASK_NAME]).ok;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 本次进程内查询一次登录任务是否已注册(汇总里多处要用,避免反复起 schtasks)。
|
|
567
|
+
* 注册/删除任务时同步刷新缓存,保证汇总看到的是最终状态。
|
|
568
|
+
*/
|
|
569
|
+
function winTaskOk() {
|
|
570
|
+
if (winTaskCache === null) winTaskCache = windowsTaskInstalled();
|
|
571
|
+
return winTaskCache;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function writeAutostartFile() {
|
|
575
|
+
const runCmd = `"${NODE_BIN}" "${runtimeSetupPath()}" run`;
|
|
576
|
+
if (process.platform === "darwin") {
|
|
577
|
+
const plistPath = autostartFilePath();
|
|
578
|
+
// LimitLoadToSessionType 是**必需项**,不是可选优化:
|
|
579
|
+
// · macOS 26 上 gui/<uid> 会进入 on-demand-only 模式,RunAtLoad/KeepAlive 全部失效
|
|
580
|
+
// (只登记不派生,runs=0,日志报 "pending spawn, domain in on-demand-only mode");
|
|
581
|
+
// 迁到 user/<uid> domain 才能恢复派生与崩溃自愈。
|
|
582
|
+
// · 而不带该键时 `launchctl bootstrap user/<uid>` 直接失败(实测 rc=5 Input/output error)。
|
|
583
|
+
// · 带上 [Aqua, Background] 后 user/<uid> 与 gui/<uid> 都能正常装载(macOS 14/26 均实测)。
|
|
584
|
+
// 仍保留 RunAtLoad/KeepAlive:user domain 下它们是真生效的(实测 kill -9 后自动重建)。
|
|
585
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
586
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
587
|
+
<plist version="1.0"><dict>
|
|
588
|
+
<key>Label</key><string>${BRIDGE_LABEL}</string>
|
|
589
|
+
<key>ProgramArguments</key>
|
|
590
|
+
<array><string>${NODE_BIN}</string><string>${runtimeSetupPath()}</string><string>run</string></array>
|
|
591
|
+
<key>RunAtLoad</key><true/>
|
|
592
|
+
<key>KeepAlive</key><true/>
|
|
593
|
+
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
594
|
+
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
595
|
+
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
596
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string><key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
|
|
597
|
+
</dict></plist>`;
|
|
598
|
+
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
599
|
+
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
600
|
+
try {
|
|
601
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
602
|
+
fs.writeFileSync(plistPath, plist);
|
|
603
|
+
} catch (e) {
|
|
604
|
+
console.warn(`⚠️ 自启动服务写入失败: ${e.message}(可稍后用 \`dsh-remote install\` 重试)`);
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
return plistPath; // 路径在最终汇总里统一展示(避免同一信息打印两遍)
|
|
608
|
+
}
|
|
609
|
+
if (process.platform === "linux") {
|
|
610
|
+
const dir = path.join(os.homedir(), ".config/systemd/user");
|
|
611
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
612
|
+
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\nEnvironment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
613
|
+
const unitPath = autostartFilePath();
|
|
614
|
+
fs.writeFileSync(unitPath, unit);
|
|
615
|
+
return unitPath; // 路径在最终汇总里统一展示
|
|
616
|
+
}
|
|
617
|
+
if (IS_WIN) return writeWindowsTask();
|
|
618
|
+
console.log(`⚠️ 当前平台(${process.platform})暂不支持自启动,请手动运行 \`dsh-remote run\``);
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* 读取 launchd job 状态。
|
|
624
|
+
* deferred = launchd 登记了但**从未派生**(macOS 26 的 on-demand-only 特征);
|
|
625
|
+
* 光看 "state = not running" 无法区分"被系统挂起"与"正常退出",必须单独识别才能给出正确建议。
|
|
626
|
+
*/
|
|
627
|
+
function launchdState(target) {
|
|
628
|
+
const pr = sh(`launchctl print ${target}`);
|
|
629
|
+
if (!pr.ok) return { exists: false, running: false, pid: null, deferred: false, raw: "" };
|
|
630
|
+
const raw = pr.stdout;
|
|
631
|
+
const running = /state\s*=\s*running/.test(raw);
|
|
632
|
+
const m = raw.match(/pid\s*=\s*(\d+)/);
|
|
633
|
+
const runs = raw.match(/runs\s*=\s*(\d+)/);
|
|
634
|
+
return {
|
|
635
|
+
exists: true,
|
|
636
|
+
running,
|
|
637
|
+
pid: running && m ? Number(m[1]) : null,
|
|
638
|
+
runs: runs ? Number(runs[1]) : 0,
|
|
639
|
+
deferred: /pended nondemand spawn/.test(raw) || /on-demand-only/.test(raw),
|
|
640
|
+
raw
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* 轮询等待 job 起来。
|
|
646
|
+
* launchd 派生是异步的:bootstrap 成功后立刻 print 必然看到 not running(旧实现因此误报"启动失败")。
|
|
647
|
+
* 明确"被挂起且没有派生"时提前放弃(等满也没用),否则给足 ~8 秒。
|
|
648
|
+
*/
|
|
649
|
+
function waitLaunchdRunning(target, tries = 20, gapMs = 400) {
|
|
650
|
+
let st = launchdState(target);
|
|
651
|
+
for (let i = 0; i < tries; i += 1) {
|
|
652
|
+
st = launchdState(target);
|
|
653
|
+
if (st.running) return st;
|
|
654
|
+
// 已登记、没在跑、又没被挂起(runs>0 说明派生过)→ 是启动后立刻退出,不必等满
|
|
655
|
+
if (st.exists && st.runs > 0 && !st.deferred) break;
|
|
656
|
+
if (i >= 2 && st.exists && !st.deferred && st.runs === 0) break; // 登记了却一次都没派生 → 被挂起
|
|
657
|
+
sleepSync(gapMs);
|
|
658
|
+
}
|
|
659
|
+
return st;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** 终极兜底:脱离 launchd 直接后台拉起 bridge(至少"现在能用",但没有开机自启/崩溃自愈)。 */
|
|
663
|
+
function spawnDetachedBridge() {
|
|
664
|
+
try {
|
|
665
|
+
const logPath = path.join(CONFIG_DIR, ".dsh-bridge.log");
|
|
666
|
+
const out = fs.openSync(logPath, "a");
|
|
667
|
+
const child = spawn(NODE_BIN, [runtimeSetupPath(), "run"], {
|
|
668
|
+
detached: true,
|
|
669
|
+
stdio: ["ignore", out, out],
|
|
670
|
+
env: process.env,
|
|
671
|
+
// Windows 上 detached 即 DETACHED_PROCESS:子进程**不继承父控制台**,
|
|
672
|
+
// 控制台程序(node.exe)会因此拿到一个**全新的可见窗口**。
|
|
673
|
+
// 必须显式 windowsHide 才会带上 CREATE_NO_WINDOW(POSIX 上该选项被忽略)。
|
|
674
|
+
windowsHide: true
|
|
675
|
+
});
|
|
676
|
+
child.unref();
|
|
677
|
+
fs.closeSync(out);
|
|
678
|
+
return { ok: Boolean(child.pid), pid: child.pid || null };
|
|
679
|
+
} catch (e) {
|
|
680
|
+
return { ok: false, pid: null, error: e.message };
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* macOS:按「可自愈优先」的阶梯启动 bridge。
|
|
686
|
+
* ① user/<uid> ← 首选。macOS 26 上唯一支持 RunAtLoad/KeepAlive 的 domain(可崩溃自愈)
|
|
687
|
+
* ② gui/<uid> + kickstart ← 兼容兜底。bootstrap 只登记不派生时用 kickstart 强拉一次;
|
|
688
|
+
* 但实测 kickstart 只解决"这一次",进程被杀后不会重建(KeepAlive 已被系统绕过)→ 如实告知
|
|
689
|
+
* ③ 后台进程 ← 连 launchd 都托管不了时保证可用
|
|
690
|
+
*/
|
|
691
|
+
function startBridgeDarwin(plistPath) {
|
|
692
|
+
const uid = process.getuid();
|
|
693
|
+
const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
694
|
+
let lastDetail = "";
|
|
695
|
+
for (const domain of [`user/${uid}`, `gui/${uid}`]) {
|
|
696
|
+
const target = `${domain}/${BRIDGE_LABEL}`;
|
|
697
|
+
// 清掉另一个 domain 的历史注册,避免两处并存导致重复实例
|
|
698
|
+
for (const d of [`user/${uid}`, `gui/${uid}`]) {
|
|
699
|
+
if (d !== domain) sh(`launchctl bootout ${d}/${BRIDGE_LABEL}`);
|
|
700
|
+
}
|
|
701
|
+
sh(`launchctl bootout ${target}`);
|
|
702
|
+
let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
703
|
+
if (!boot.ok) {
|
|
704
|
+
// 老写法兜底(部分系统上 load -w 仍可用)
|
|
705
|
+
sh(`launchctl unload ${q(plistPath)}`);
|
|
706
|
+
boot = sh(`launchctl load -w ${q(plistPath)}`);
|
|
707
|
+
}
|
|
708
|
+
if (!boot.ok) {
|
|
709
|
+
lastDetail = (boot.stderr || boot.stdout).trim() || "launchctl bootstrap 失败";
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
// bootstrap 只是登记;立刻 kickstart 一次,确保"这一次"一定起来
|
|
713
|
+
// (on-demand-only 的 gui domain 下这是唯一能拉起的手段)
|
|
714
|
+
sh(`launchctl kickstart -k ${target}`);
|
|
715
|
+
const st = waitLaunchdRunning(target);
|
|
716
|
+
if (st.running) {
|
|
717
|
+
// 被挂起(deferred)过一次 = 本机 launchd 不保证崩溃自愈,必须如实报告,不能报成完全成功
|
|
718
|
+
const selfHealing = !st.deferred;
|
|
719
|
+
return { ok: true, status: "running", pid: st.pid, domain, selfHealing, detail: selfHealing ? "" : "本机 launchd 处于 on-demand-only,进程退出后不会自动重建" };
|
|
720
|
+
}
|
|
721
|
+
lastDetail = st.deferred
|
|
722
|
+
? `launchd 拒绝派生(domain ${domain} 处于 on-demand-only 模式)`
|
|
723
|
+
: (st.exists ? `已登记但未运行(runs=${st.runs})` : "已登记但查不到状态");
|
|
724
|
+
}
|
|
725
|
+
const d = spawnDetachedBridge();
|
|
726
|
+
if (d.ok) {
|
|
727
|
+
return {
|
|
728
|
+
ok: false,
|
|
729
|
+
status: "degraded-detached",
|
|
730
|
+
pid: d.pid,
|
|
731
|
+
domain: null,
|
|
732
|
+
selfHealing: false,
|
|
733
|
+
detail: "launchd 无法托管本机 bridge,已改为后台进程运行(现在可用,但不会开机自启、崩溃后不自动恢复)"
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return { ok: false, status: "failed", detail: `launchd 与后台进程均启动失败:${lastDetail}` };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function restartBridgeService() {
|
|
740
|
+
if (IS_WIN) {
|
|
741
|
+
if (!windowsTaskInstalled()) writeWindowsTask(); // 静默补生成(调用方会汇总展示)
|
|
742
|
+
// /Run 只是"现在跑一次";等 pid 文件出现才算真的起来了(schtasks 的退出码只说明任务被触发)
|
|
743
|
+
const r = schtasks(["/Run", "/TN", WIN_TASK_NAME]);
|
|
744
|
+
if (!r.ok) return { ok: false, status: "failed", detail: (r.stderr || r.stdout).trim() || `schtasks /Run 退出码 ${r.code}` };
|
|
745
|
+
for (let i = 0; i < 20; i += 1) {
|
|
746
|
+
const pid = readPidFile(WATCHER_PID_FILE);
|
|
747
|
+
if (pid && pidAlive(pid)) return { ok: true, status: "running", pid };
|
|
748
|
+
sleepSync(400);
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
ok: false,
|
|
752
|
+
status: "degraded-detached",
|
|
753
|
+
detail: "已触发自启动任务,但没等到 bridge 守护进程(dsh web 可能还没运行;打开 dsh web 后会自动接上)",
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
const svcFile = autostartFilePath();
|
|
757
|
+
if (svcFile && !fs.existsSync(svcFile)) {
|
|
758
|
+
writeAutostartFile(); // 静默补生成(调用方会汇总展示路径)
|
|
759
|
+
}
|
|
760
|
+
if (process.platform === "darwin") {
|
|
761
|
+
const plistPath = autostartFilePath();
|
|
762
|
+
if (!fs.existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 不存在" };
|
|
763
|
+
return startBridgeDarwin(plistPath);
|
|
764
|
+
}
|
|
765
|
+
if (process.platform === "linux") {
|
|
766
|
+
const r = sh(`systemctl --user restart dsh-bridge`);
|
|
767
|
+
if (!r.ok) return { ok: false, status: "failed", detail: (r.stderr || r.stdout).trim() || "systemctl restart 失败" };
|
|
768
|
+
const a = sh(`systemctl --user is-active dsh-bridge`);
|
|
769
|
+
return a.ok && a.stdout.trim() === "active"
|
|
770
|
+
? { ok: true, status: "running", pid: null }
|
|
771
|
+
: { ok: false, status: "failed", detail: (a.stdout || a.stderr).trim() };
|
|
772
|
+
}
|
|
773
|
+
return { ok: false, status: "unsupported", detail: `平台 ${process.platform} 不支持自启动` };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* 上游 dsh web 地址(bridge 的转发目标 + watcher 的「dsh web 在线吗」判据**同一个来源**)。
|
|
778
|
+
*
|
|
779
|
+
* 【2026-09-23 用户实测事故】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
|
|
780
|
+
* 不在默认端口(`dsh web --port 8090`、profile 里配了 port、或 `--port 0` 让系统分配空闲端口),
|
|
781
|
+
* watcher 的在线探测就**永远**失败 → bridge 永远不启动,而面板只会说「正在启动 Bridge…」。
|
|
782
|
+
* 优先级:显式环境变量 > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
|
|
783
|
+
* 为什么要有「端口文件」这一层:开机自启(登录任务 / launchd)拉起 watcher 时**没有**任何环境变量,
|
|
784
|
+
* 只有插件半知道 dsh web 真实监听在哪个端口,所以它会把地址落盘(见插件 half 的 publishUpstream)。
|
|
785
|
+
*/
|
|
786
|
+
const UPSTREAM_FILE = ".dsh-upstream";
|
|
787
|
+
function upstreamUrl() {
|
|
788
|
+
const explicit = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim();
|
|
789
|
+
if (explicit) return explicit.replace(/\/+$/, "");
|
|
790
|
+
try {
|
|
791
|
+
const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
|
|
792
|
+
if (/^https?:\/\/[^\s]+$/i.test(fromFile)) return fromFile.replace(/\/+$/, "");
|
|
793
|
+
} catch { /* 没写过(旧版/首次)→ 往下回退 */ }
|
|
794
|
+
const web = String(process.env.DSH_WEB_URL || "").trim();
|
|
795
|
+
if (web) {
|
|
796
|
+
try {
|
|
797
|
+
const u = new URL(web);
|
|
798
|
+
if (u.port) return `http://127.0.0.1:${u.port}`; // bridge 的 loopback 围栏只认 127.0.0.1
|
|
799
|
+
} catch { /* 非 URL:忽略 */ }
|
|
800
|
+
}
|
|
801
|
+
return "http://127.0.0.1:3080";
|
|
802
|
+
}
|
|
803
|
+
/** 上游端口(诊断/日志用;取不到就回退 3080)。 */
|
|
804
|
+
function upstreamPort() {
|
|
805
|
+
try { return Number(new URL(upstreamUrl()).port) || 3080; } catch { return 3080; }
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* dsh web 是否正在运行(默认 127.0.0.1:3080,端口以 upstreamUrl() 为准)。
|
|
810
|
+
* bridge 本身依赖 dsh web 才工作:dsh web 没开时 bridge 起来也会立刻退出,
|
|
811
|
+
* 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
|
|
812
|
+
*/
|
|
813
|
+
async function isDshWebUp(timeoutMs = 1200) {
|
|
814
|
+
try {
|
|
815
|
+
const ac = new AbortController();
|
|
816
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
817
|
+
await fetch(upstreamUrl() + "/", { signal: ac.signal });
|
|
818
|
+
clearTimeout(timer);
|
|
819
|
+
return true;
|
|
820
|
+
} catch { return false; }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* 解析 dsh web profile 目录(安装与插件管理共用同一口径)。
|
|
825
|
+
*/
|
|
826
|
+
function resolveProfileDir(argv = []) {
|
|
827
|
+
const i = argv.indexOf("--profile");
|
|
828
|
+
return i > -1 && argv[i + 1]
|
|
829
|
+
? argv[i + 1]
|
|
830
|
+
: process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* 监听本机 dsh web 端口的进程 pid(= dsh web)。端口取 upstreamPort()(不再写死 3080)。
|
|
835
|
+
* macOS/Linux:lsof → pgrep;Windows:netstat -ano 取 LISTENING 行的最后一列
|
|
836
|
+
* (netstat 是系统自带,比每次都起 PowerShell 快得多)。
|
|
837
|
+
*/
|
|
838
|
+
function dshWebPid() {
|
|
839
|
+
const port = upstreamPort();
|
|
840
|
+
if (IS_WIN) {
|
|
841
|
+
const r = sh(`netstat -ano | findstr ":${port}"`);
|
|
842
|
+
for (const line of String(r.stdout || "").split("\n")) {
|
|
843
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
844
|
+
const m = line.trim().match(/(\d+)\s*$/);
|
|
845
|
+
if (m) return Number(m[1]);
|
|
846
|
+
}
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
const l = sh(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null || true`);
|
|
850
|
+
const pid = String(l.stdout || "").trim().split(/\s+/).filter(Boolean)[0];
|
|
851
|
+
if (pid && /^\d+$/.test(pid)) return Number(pid);
|
|
852
|
+
const p = sh("pgrep -f 'dsh web' 2>/dev/null | head -1");
|
|
853
|
+
const p2 = String(p.stdout || "").trim();
|
|
854
|
+
return /^\d+$/.test(p2) ? Number(p2) : null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* 进程启动时刻(毫秒)。
|
|
859
|
+
* 用 `ps -o lstart=`(如 "Sun Sep 13 13:54:43 2026")——它**不含年份以外的 TZ 差异**,
|
|
860
|
+
* 比 `ps -o etimes=` 稳(后者在部分平台不可用),比 /proc 通用。
|
|
861
|
+
*/
|
|
862
|
+
function processStartMs(pid) {
|
|
863
|
+
try {
|
|
864
|
+
const r = sh(`ps -o lstart= -p ${Number(pid)}`);
|
|
865
|
+
const t = Date.parse(String(r.stdout || "").trim());
|
|
866
|
+
return Number.isFinite(t) ? t : 0;
|
|
867
|
+
} catch { return 0; }
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* 重启 dsh web。
|
|
872
|
+
* dsh web 由**用户手工或 launchd** 跑着,插件是进程启动时装载的,所以更新插件后必须重启它才有面板。
|
|
873
|
+
* 阶梯:launchd job(macOS)→ systemd user unit(Linux)→ 原命令行自拉起 → 交给用户手动。
|
|
874
|
+
*/
|
|
875
|
+
function dshWebLaunchdJob() {
|
|
876
|
+
const l = sh("launchctl list 2>/dev/null | grep -i dsh | grep -v dshremote || true");
|
|
877
|
+
for (const line of String(l.stdout || "").split("\n")) {
|
|
878
|
+
const label = line.trim().split(/\s+/).pop();
|
|
879
|
+
if (label && /dsh/i.test(label)) return label;
|
|
880
|
+
}
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function restartDshWeb() {
|
|
885
|
+
// ⚠️ process.getuid 只在 macOS 分支里取:Windows 上**没有这个函数**,
|
|
886
|
+
// 旧写法把它放在函数首行无条件调用 → 安装流程在 Windows 直接 TypeError 中断。
|
|
887
|
+
if (process.platform === "darwin") {
|
|
888
|
+
const uid = process.getuid();
|
|
889
|
+
const job = dshWebLaunchdJob();
|
|
890
|
+
if (job) {
|
|
891
|
+
const r = sh(`launchctl kickstart -k gui/${uid}/${job}`);
|
|
892
|
+
if (r.ok) return { ok: true, how: `launchctl kickstart ${job}` };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (process.platform === "linux") {
|
|
896
|
+
const r = sh("systemctl --user restart dsh-web 2>/dev/null || systemctl --user restart dsh 2>/dev/null");
|
|
897
|
+
if (r.ok) return { ok: true, how: "systemctl --user restart dsh-web" };
|
|
898
|
+
}
|
|
899
|
+
if (IS_WIN) return restartDshWebWindows();
|
|
900
|
+
// 兜底:沿用原命令行重启(只在能拿到真实 node 入口时做,npx 缓存路径不可靠)
|
|
901
|
+
const pid = dshWebPid();
|
|
902
|
+
if (!pid) return { ok: false, how: "", detail: "未找到 dsh web 进程" };
|
|
903
|
+
const cmd = String(sh(`ps -o command= -p ${pid}`).stdout || "").trim();
|
|
904
|
+
if (!cmd || /node_modules\/\.bin|_npx/.test(cmd)) {
|
|
905
|
+
return { ok: false, how: "", detail: "无法获取可复用的启动命令(可能是经 npx 启动),请手动重启" };
|
|
906
|
+
}
|
|
907
|
+
try {
|
|
908
|
+
const child = spawn("/bin/sh", ["-c", cmd], { detached: true, stdio: "ignore", cwd: os.homedir() });
|
|
909
|
+
child.unref();
|
|
910
|
+
return { ok: true, how: "原命令行重新拉起" };
|
|
911
|
+
} catch (e) {
|
|
912
|
+
return { ok: false, how: "", detail: e.message };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Windows:重启 dsh web(沿用原命令行自拉起)。
|
|
918
|
+
*
|
|
919
|
+
* 与插件半的重启助手同源思路:**先校验新进程拉得起来,再动旧进程**;用 node 跑一个 .mjs 助手,
|
|
920
|
+
* 不经过任何 shell(Windows 没有 /bin/sh,cmd.exe 的引号规则又极易把带空格的 node/入口路径绞碎)。
|
|
921
|
+
* 拿不到命令行(例如经 npx 启动)时如实拒绝,让用户手动重启,绝不"杀掉却起不来"。
|
|
922
|
+
*/
|
|
923
|
+
function restartDshWebWindows() {
|
|
924
|
+
const pid = dshWebPid();
|
|
925
|
+
if (!pid) return { ok: false, how: "", detail: `未找到监听 ${upstreamPort()} 的进程(dsh web 没在运行?)` };
|
|
926
|
+
const cmdline = windowsProcessCommandLine(pid);
|
|
927
|
+
if (!cmdline) return { ok: false, how: "", detail: "无法获取 dsh web 的启动命令行,请手动重启" };
|
|
928
|
+
if (/_npx|node_modules\\\.bin|node_modules\/\.bin/.test(cmdline)) {
|
|
929
|
+
return { ok: false, how: "", detail: "dsh web 是经 npx 启动的,无法安全复用命令行,请手动重启" };
|
|
930
|
+
}
|
|
931
|
+
const argv = parseWindowsCommandLine(cmdline);
|
|
932
|
+
if (!argv.length || !fs.existsSync(argv[0])) {
|
|
933
|
+
return { ok: false, how: "", detail: "dsh web 的启动命令无法解析为可执行程序,请手动重启" };
|
|
934
|
+
}
|
|
935
|
+
const helper = path.join(CONFIG_DIR, ".dsh-restart-dshweb.mjs");
|
|
936
|
+
const plan = JSON.stringify({ pid, argv, log: path.join(CONFIG_DIR, ".dsh-restart.log") });
|
|
937
|
+
const src = `import { spawn, spawnSync } from "node:child_process";
|
|
938
|
+
import { appendFileSync, existsSync, openSync, closeSync } from "node:fs";
|
|
939
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
940
|
+
const PLAN = ${plan};
|
|
941
|
+
function logLine(m) { try { appendFileSync(PLAN.log, "\\n[" + new Date().toISOString() + "] " + m + "\\n"); } catch (e) {} }
|
|
942
|
+
if (!existsSync(PLAN.argv[0])) { logLine("重启中止:可执行文件不存在 " + PLAN.argv[0]); process.exit(1); }
|
|
943
|
+
function alive(p) { try { process.kill(p, 0); return true; } catch (e) { return e.code === "EPERM"; } }
|
|
944
|
+
logLine("dsh web 重启助手启动:旧 pid=" + PLAN.pid);
|
|
945
|
+
await delay(1000);
|
|
946
|
+
try { process.kill(PLAN.pid); } catch (e) {}
|
|
947
|
+
const deadline = Date.now() + 30000;
|
|
948
|
+
while (Date.now() < deadline && alive(PLAN.pid)) await delay(200);
|
|
949
|
+
if (alive(PLAN.pid)) spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true, timeout: 8000 });
|
|
950
|
+
let out = "ignore";
|
|
951
|
+
try { out = openSync(PLAN.log, "a"); } catch (e) {}
|
|
952
|
+
const child = spawn(PLAN.argv[0], PLAN.argv.slice(1), { detached: true, stdio: ["ignore", out, out], windowsHide: true });
|
|
953
|
+
child.on("error", (e) => logLine("重新拉起失败:" + e.message));
|
|
954
|
+
child.unref();
|
|
955
|
+
if (typeof out === "number") { try { closeSync(out); } catch (e) {} }
|
|
956
|
+
logLine("已重新拉起 dsh web:pid=" + String(child.pid));
|
|
957
|
+
`;
|
|
958
|
+
try {
|
|
959
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
960
|
+
fs.writeFileSync(helper, src);
|
|
961
|
+
const child = spawn(NODE_BIN, [helper], { detached: true, stdio: "ignore", cwd: os.homedir(), windowsHide: true });
|
|
962
|
+
child.on("error", () => { /* 有 error 监听:绝不能把宿主/安装器打挂 */ });
|
|
963
|
+
child.unref();
|
|
964
|
+
return { ok: true, how: "Windows 重启助手(原命令行自拉起)" };
|
|
965
|
+
} catch (e) {
|
|
966
|
+
return { ok: false, how: "", detail: e.message };
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** 读某进程的完整命令行(PowerShell CIM;取不到返回空串)。 */
|
|
971
|
+
function windowsProcessCommandLine(pid) {
|
|
972
|
+
const script = `(Get-CimInstance Win32_Process -Filter "ProcessId=${Number(pid)}").CommandLine`;
|
|
973
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
974
|
+
try {
|
|
975
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
|
976
|
+
{ encoding: "utf8", timeout: 8000, windowsHide: true });
|
|
977
|
+
return String(r.stdout || "").trim();
|
|
978
|
+
} catch { return ""; }
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* 把 Windows 命令行拆成 argv(按 CreateProcess/CRT 的引号规则)。
|
|
983
|
+
* 不引第三方依赖;处理 `"a b" c`、`a\b c`、`"a\"b"` 等常见形态即可。
|
|
984
|
+
*/
|
|
985
|
+
function parseWindowsCommandLine(cmd) {
|
|
986
|
+
const out = [];
|
|
987
|
+
let cur = "";
|
|
988
|
+
let inQuote = false;
|
|
989
|
+
let started = false;
|
|
990
|
+
for (let i = 0; i < cmd.length; i += 1) {
|
|
991
|
+
const ch = cmd[i];
|
|
992
|
+
if (ch === "\\" && cmd[i + 1] === '"') { cur += '"'; i += 1; started = true; continue; }
|
|
993
|
+
if (ch === '"') { inQuote = !inQuote; started = true; continue; }
|
|
994
|
+
if (!inQuote && /\s/.test(ch)) {
|
|
995
|
+
if (started) { out.push(cur); cur = ""; started = false; }
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
cur += ch;
|
|
999
|
+
started = true;
|
|
1000
|
+
}
|
|
1001
|
+
if (started) out.push(cur);
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function installAutostart() {
|
|
1006
|
+
const svcPath = writeAutostartFile();
|
|
1007
|
+
const r = restartBridgeService();
|
|
1008
|
+
// 不再在这里直接打印(信息在 install 汇总里给一次),身份由调用方持有
|
|
1009
|
+
return { path: svcPath, status: r };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// ---------- settings:旧独立设置页已移除(引导到 dsh web 插件面板) ----------
|
|
1013
|
+
// 云服务用户在 dsh web → 设置 → 「远程访问」面板即可注册/登录(自建切换也在此面板);
|
|
1014
|
+
// 独立设置页(127.0.0.1:3499)与面板功能完全重复、且暴露自建表单给普通用户造成困惑,已移除。
|
|
1015
|
+
// 保留 `dsh-remote settings` 命令为“引导提示”,避免旧脚本/旧文档直接调它时报错。
|
|
1016
|
+
function settingsHint() {
|
|
1017
|
+
console.log(`
|
|
1018
|
+
dsh-remote:独立的本地设置页已移除。
|
|
1019
|
+
|
|
1020
|
+
请直接在 dsh web 里完成登录/连接配置(无需任何命令):
|
|
1021
|
+
打开 dsh web → 设置 → 「远程访问」→ 注册或登录手机号即可(自建模式切换也在该面板)。
|
|
1022
|
+
|
|
1023
|
+
自建用户若偏好命令行,可用:
|
|
1024
|
+
dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
|
|
1025
|
+
`);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* 账号指纹:账号 / 模式 / 设备身份任一变化都意味着"必须用新凭据重新登记"。
|
|
1030
|
+
* 为什么需要:bridge 的 DSH_BRIDGE_PHONE/PASSWORD 是**进程启动时**从环境变量固化的,
|
|
1031
|
+
* 配置改了并不会影响已在跑的进程 —— 这是"切换账号后设备仍留在旧账号"的根因之一。
|
|
1032
|
+
*/
|
|
1033
|
+
function accountFingerprint(cfg) {
|
|
1034
|
+
const c = cfg || {};
|
|
1035
|
+
return [c.local_key ? "local" : "saas", c.phone || c.email || "", String(c.device_id || ""), String(c.local_key || "")].join("|");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* 服务端登录限流的"最早可重试时刻"(bridge 在收到 429 时落盘)。
|
|
1040
|
+
* 为什么需要:限流窗口是 15 分钟,而 watcher 每 10 秒就会重新拉起 bridge ——
|
|
1041
|
+
* 不读这个文件就是 90 次空撞(日志刷屏、还可能拖长窗口)。它只是**退避提示**,绝不阻止正常启动。
|
|
1042
|
+
*/
|
|
1043
|
+
const LOGIN_RATELIMIT_FILE = ".dsh-login-ratelimited";
|
|
1044
|
+
function loginRateLimitUntil() {
|
|
1045
|
+
const f = path.join(CONFIG_DIR, LOGIN_RATELIMIT_FILE);
|
|
1046
|
+
try {
|
|
1047
|
+
const until = Number(String(fs.readFileSync(f, "utf8")).trim());
|
|
1048
|
+
if (Number.isFinite(until) && until > Date.now()) return until;
|
|
1049
|
+
fs.rmSync(f, { force: true }); // 已过期:清掉,恢复正常启动
|
|
1050
|
+
} catch { /* 无文件 = 未限流 */ }
|
|
1051
|
+
return 0;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
|
|
1055
|
+
async function runBridge() {
|
|
1056
|
+
// 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
|
|
1057
|
+
// 两个 bridge 会抢同一个设备登记 → 以 pid 文件为准。
|
|
1058
|
+
//
|
|
1059
|
+
// ⚠️ 但**被监管者(launchd)拉起的那个实例绝不能再"直接退出"**。
|
|
1060
|
+
// 真机实测的后果链:KeepAlive 每次重拉 → 本进程秒退 → 监管者判为崩溃并节流
|
|
1061
|
+
// (`launchctl print` 显示 `state = spawn scheduled`)→ 插件的自愈据此认定"服务坏了"
|
|
1062
|
+
// → `bootout` 摘掉作业(入口判定成立时连 plist 一起删)→
|
|
1063
|
+
// **自启动彻底消失,连 `launchctl kickstart` 都报"域里找不到该服务"**,
|
|
1064
|
+
// 只剩插件面板的「启动」能救回来。
|
|
1065
|
+
// 被监管者拉起 = 我们才是那个该活下来的实例 → 请游离实例让位,然后接管。
|
|
1066
|
+
{
|
|
1067
|
+
const other = readPidFile(WATCHER_PID_FILE);
|
|
1068
|
+
if (other && other !== process.pid && pidAlive(other)) {
|
|
1069
|
+
const supervisor = String(process.env.XPC_SERVICE_NAME || "");
|
|
1070
|
+
const decision = dedupDecision({ existingPid: other, selfPid: process.pid, supervisor });
|
|
1071
|
+
if (decision === "takeover") {
|
|
1072
|
+
console.log(`[dsh-remote] 检测到游离守护(pid=${other})——本进程由 ${supervisor} 监管,请它让位后接管。`);
|
|
1073
|
+
// ⚠️ pid 可能已被系统回收给无关进程:必须确认它**真的**是 watcher 才敢动它,
|
|
1074
|
+
// 否则宁可当作过期 pid 直接接管(下面 writePidFile 会覆盖掉)。
|
|
1075
|
+
if (looksLikeWatcher(other)) {
|
|
1076
|
+
try { process.kill(other, "SIGTERM"); } catch { /* 已退出 */ }
|
|
1077
|
+
// 等它自己退(它的退出钩子会清理 pid 文件并停掉它的 bridge)。最多 5 秒 ——
|
|
1078
|
+
// 不能等太久,否则监管者又看到一次"迟迟没起来"。
|
|
1079
|
+
const deadline = Date.now() + 5000;
|
|
1080
|
+
while (Date.now() < deadline && pidAlive(other)) sleepSync(100);
|
|
1081
|
+
if (pidAlive(other)) { try { process.kill(other, "SIGKILL"); } catch { /* 已退出 */ } }
|
|
1082
|
+
}
|
|
1083
|
+
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
1084
|
+
} else {
|
|
1085
|
+
console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// ── 回收「上一任留下的 bridge 子进程」────────────────────────────────────
|
|
1090
|
+
//
|
|
1091
|
+
// 🔴 真机 bug(2026-09-23 用户实测):一条微信指令**回两条**。
|
|
1092
|
+
// 成因链:接管时把老 watcher SIGTERM(它只"请"子进程退出、最多等 3 秒就自己 exit),
|
|
1093
|
+
// 而 bridge 此刻常正卡在 35 秒的 getUpdates 长轮询里 —— 来不及退 → 变成孤儿继续跑;
|
|
1094
|
+
// 若老 watcher 是被 SIGKILL 干掉的(等超 5 秒那条路),连"请"都不会发生。
|
|
1095
|
+
// 两个 bridge 各自持有自己的 updatesBuf、各自轮询同一个微信账号 ⇒ 同一条消息被消费两次、
|
|
1096
|
+
// 回两条。所以**接管之后必须显式清掉上一任的子进程**,不能只靠"它应该会自己退"。
|
|
1097
|
+
//
|
|
1098
|
+
// 安全边界:pid 可能已被系统回收给无关进程 —— 必须确认它**真的**是我们的 bridge 脚本才动手
|
|
1099
|
+
//(与 looksLikeWatcher 同一套纪律)。插件半的「脱离进程兜底」也会拉起 bridge,
|
|
1100
|
+
// 但那种情况**没有** watcher 在跑(有 watcher 就不会走兜底),所以这里回收不会误伤正常形态。
|
|
1101
|
+
{
|
|
1102
|
+
const leftover = readPidFile(BRIDGE_PID_FILE);
|
|
1103
|
+
const reap = shouldReapLeftoverBridge({
|
|
1104
|
+
pid: leftover,
|
|
1105
|
+
selfPid: process.pid,
|
|
1106
|
+
alive: leftover ? pidAlive(leftover) : false,
|
|
1107
|
+
isBridgeScript: leftover ? looksLikeBridge(leftover) : false
|
|
1108
|
+
});
|
|
1109
|
+
if (reap) {
|
|
1110
|
+
console.log(`[dsh-remote] 回收上一任遗留的 bridge 子进程(pid=${leftover})——避免两个实例抢同一账号的消息。`);
|
|
1111
|
+
try { process.kill(leftover, "SIGTERM"); } catch { /* 已退出 */ }
|
|
1112
|
+
const dl = Date.now() + 4000;
|
|
1113
|
+
while (Date.now() < dl && pidAlive(leftover)) sleepSync(100);
|
|
1114
|
+
if (pidAlive(leftover) && looksLikeBridge(leftover)) {
|
|
1115
|
+
try { process.kill(leftover, "SIGKILL"); } catch { /* 已退出 */ }
|
|
1116
|
+
}
|
|
1117
|
+
removePidFile(BRIDGE_PID_FILE);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
let cfg = loadConfig();
|
|
1122
|
+
let warnedNoLogin = false;
|
|
1123
|
+
/** 当前 bridge 子进程使用的账号指纹(账号/模式/设备身份任一变化 → 重启子进程)。 */
|
|
1124
|
+
let bridgeAccountFp = "";
|
|
1125
|
+
/** 限流提示只打一次,避免刷屏。 */
|
|
1126
|
+
let warnedRateLimit = false;
|
|
1127
|
+
|
|
1128
|
+
// watcher:检测 dsh web 是否存活(端口以 upstreamUrl() 为准,绝不写死 3080),存活才启动 bridge
|
|
1129
|
+
const checkUpstream = () => new Promise((resolve) => {
|
|
1130
|
+
const t = setTimeout(() => resolve(false), 2000);
|
|
1131
|
+
fetch(upstreamUrl() + "/")
|
|
1132
|
+
.then(() => { clearTimeout(t); resolve(true); })
|
|
1133
|
+
.catch(() => { clearTimeout(t); resolve(false); });
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
console.log(`[dsh-remote] 等待 dsh web(${upstreamUrl()})启动...`);
|
|
1137
|
+
let bridgeProc = null;
|
|
1138
|
+
let starting = false;
|
|
1139
|
+
|
|
1140
|
+
const ensureBridge = async () => {
|
|
1141
|
+
if (starting) return;
|
|
1142
|
+
// 每次循环重读配置:设置页登录/切换模式后无需重启守护即可生效
|
|
1143
|
+
cfg = loadConfig();
|
|
1144
|
+
const saas = Boolean((cfg.phone || cfg.email) && cfg.password);
|
|
1145
|
+
let local = Boolean(cfg.local_key);
|
|
1146
|
+
if (!saas && !local) {
|
|
1147
|
+
if (!warnedNoLogin) {
|
|
1148
|
+
warnedNoLogin = true;
|
|
1149
|
+
console.log("⚠️ 尚未登录:请打开 dsh web → 设置 → 「远程访问」,注册/登录手机号(或切到自建模式)后自动启动。");
|
|
1150
|
+
}
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
warnedNoLogin = false;
|
|
1154
|
+
if (local && !cfg.tunnel_url) {
|
|
1155
|
+
console.log("⚠️ 自建模式缺少服务器地址:请用 `dsh-remote setup --server wss://host:port --key <密钥>` 重新配置。");
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (saas) {
|
|
1159
|
+
// SaaS 权威归一化:清掉任何自建残留,tunnel/api 始终指向云端地址
|
|
1160
|
+
applySaaSMode(cfg);
|
|
1161
|
+
local = false;
|
|
1162
|
+
saveConfig(cfg);
|
|
1163
|
+
} else if (local && !cfg.tunnel_url) {
|
|
1164
|
+
cfg.tunnel_url = deriveTunnelUrl(cfg.api_url || DEFAULT_API);
|
|
1165
|
+
}
|
|
1166
|
+
const apiUrl = saas ? (cfg.api_url || DEFAULT_API) : "";
|
|
1167
|
+
|
|
1168
|
+
// 账号/模式/设备身份变了 → 必须重启 bridge(它的凭据是启动时从环境变量固化的)。
|
|
1169
|
+
// 没有这一条时:面板切了账号、旧 bridge 却继续用旧账号跑,新账号的设备列表里看不到这台机器
|
|
1170
|
+
//(2026-09-19 实测事故)。
|
|
1171
|
+
const fp = accountFingerprint(cfg);
|
|
1172
|
+
if (!childStopped(bridgeProc) && bridgeAccountFp && bridgeAccountFp !== fp) {
|
|
1173
|
+
console.log("[dsh-remote] 账号配置已变化 → 重启 bridge 让新账号生效...");
|
|
1174
|
+
await stopChildGracefully(bridgeProc); // 先请它优雅退出,跑完微信通道下线通知
|
|
1175
|
+
bridgeAccountFp = "";
|
|
1176
|
+
return; // 下一轮(10s 内)用新配置重新拉起
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// 服务端登录限流窗口内不反复拉起 bridge(凭据没错,改了也没用)
|
|
1180
|
+
const rlUntil = loginRateLimitUntil();
|
|
1181
|
+
if (rlUntil > Date.now()) {
|
|
1182
|
+
if (!warnedRateLimit) {
|
|
1183
|
+
warnedRateLimit = true;
|
|
1184
|
+
console.log(`[dsh-remote] 登录被服务端限流,约 ${Math.ceil((rlUntil - Date.now()) / 1000)} 秒后自动重试(凭据无需修改)。`);
|
|
1185
|
+
}
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
warnedRateLimit = false;
|
|
1189
|
+
|
|
1190
|
+
const alive = await checkUpstream();
|
|
1191
|
+
if (alive && childStopped(bridgeProc)) {
|
|
1192
|
+
starting = true;
|
|
1193
|
+
console.log("[dsh-remote] dsh web 在线,启动 bridge...");
|
|
1194
|
+
// 清除代理环境变量(bridge 需直连 relay,不受本机代理影响)
|
|
1195
|
+
const childEnv = {
|
|
1196
|
+
...process.env,
|
|
1197
|
+
DSH_BRIDGE_CONFIG: CONFIG_PATH,
|
|
1198
|
+
DSH_BRIDGE_TUNNEL_URL: cfg.tunnel_url,
|
|
1199
|
+
// 上游端口显式传给 bridge:它自己在旧版本里也回退到写死的 3080(见 dsh-bridge.mjs 的 UPSTREAM)。
|
|
1200
|
+
DSH_BRIDGE_UPSTREAM: upstreamUrl(),
|
|
1201
|
+
// 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
|
|
1202
|
+
DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
|
|
1203
|
+
DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
|
|
1204
|
+
...(saas ? { DSH_BRIDGE_PHONE: (cfg.phone || cfg.email || ""), DSH_BRIDGE_PASSWORD: cfg.password, DSH_BRIDGE_API: apiUrl, DSH_BRIDGE_SECRET: cfg.bridge_secret || "" } : {}),
|
|
1205
|
+
...(local ? { DSH_BRIDGE_LOCAL_KEY: cfg.local_key } : {})
|
|
1206
|
+
};
|
|
1207
|
+
for (const k of ["http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "no_proxy", "NO_PROXY"]) {
|
|
1208
|
+
delete childEnv[k];
|
|
1209
|
+
}
|
|
1210
|
+
// stdio 的第 4 个 fd 是 IPC 通道:Windows 上 kill() 是**无条件终止**,信号处理函数不会执行,
|
|
1211
|
+
// 只有它能让我们在停 bridge 之前请它跑完退出钩子(notifystop)—— 见 stopChildGracefully。
|
|
1212
|
+
bridgeProc = spawn(NODE_BIN,
|
|
1213
|
+
[path.join(THIS_DIR, "clients/dsh-remote/dsh-bridge.mjs")],
|
|
1214
|
+
{ env: childEnv, stdio: ["inherit", "inherit", "inherit", "ipc"] });
|
|
1215
|
+
bridgeProc.on("exit", () => {
|
|
1216
|
+
removePidFile(BRIDGE_PID_FILE);
|
|
1217
|
+
console.log("[dsh-remote] bridge 退出,等待重启...");
|
|
1218
|
+
});
|
|
1219
|
+
// Windows:落 pid 文件,供插件半发现进程(Windows 没有 pgrep/ps)。
|
|
1220
|
+
// 插件半启动 bridge 走的就是本函数,所以这里写一次两边都覆盖到。
|
|
1221
|
+
writePidFile(BRIDGE_PID_FILE, bridgeProc.pid); // 供插件半发现进程(Windows 无 pgrep;POSIX 用于脱离进程兜底的去重)
|
|
1222
|
+
bridgeAccountFp = fp; // 记下这个子进程用的是哪套凭据,配置一变就重启它
|
|
1223
|
+
setTimeout(() => { starting = false; }, 5000);
|
|
1224
|
+
} else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
|
|
1225
|
+
console.log("[dsh-remote] dsh web 离线,停止 bridge...");
|
|
1226
|
+
await stopChildGracefully(bridgeProc); // 同上:Windows 上 kill() 跑不到退出钩子
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
// watcher 自身的 pid 也要落盘(插件半据此判断"守护在跑"并避免重复拉起)
|
|
1231
|
+
{
|
|
1232
|
+
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
1233
|
+
const cleanup = () => { removePidFile(WATCHER_PID_FILE); removePidFile(BRIDGE_PID_FILE); };
|
|
1234
|
+
process.on("exit", cleanup);
|
|
1235
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]) {
|
|
1236
|
+
try {
|
|
1237
|
+
process.on(sig, () => {
|
|
1238
|
+
// 先请 bridge 优雅退出,再给自己留出它跑完退出钩子(notifystop)的时间。
|
|
1239
|
+
// 尤其重要:Windows 上 kill() 是无条件终止,bridge 根本没有机会做收尾。
|
|
1240
|
+
if (bridgeProc && bridgeProc.connected) {
|
|
1241
|
+
try { bridgeProc.send({ type: "shutdown" }); } catch { /* 通道已断,只能随父进程一起被回收 */ }
|
|
1242
|
+
}
|
|
1243
|
+
cleanup();
|
|
1244
|
+
// 🔴 但**只"请"是不够的**(2026-09-23 真机 bug:一条微信指令回两条):
|
|
1245
|
+
// bridge 此刻常正卡在 35s 的 getUpdates 长轮询里,3 秒根本退不完;
|
|
1246
|
+
// 老 watcher 一 exit,子进程就被 reparent 成孤儿、继续轮询同一个微信账号 →
|
|
1247
|
+
// 与新 bridge 抢消息(一条被消费两次、回两条)。所以退出前必须**确保它真的死了**。
|
|
1248
|
+
setTimeout(() => {
|
|
1249
|
+
if (bridgeProc && bridgeProc.exitCode === null) {
|
|
1250
|
+
try { bridgeProc.kill("SIGTERM"); } catch { /* 已退出 */ }
|
|
1251
|
+
}
|
|
1252
|
+
}, 1500);
|
|
1253
|
+
setTimeout(() => {
|
|
1254
|
+
if (bridgeProc && bridgeProc.exitCode === null) {
|
|
1255
|
+
try { bridgeProc.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
1256
|
+
}
|
|
1257
|
+
process.exit(0);
|
|
1258
|
+
}, 3000);
|
|
1259
|
+
});
|
|
1260
|
+
} catch { /* 该信号在本平台不可注册 */ }
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
await ensureBridge();
|
|
1265
|
+
setInterval(ensureBridge, 10000); // 每 10s 检查
|
|
1266
|
+
console.log("[dsh-remote] 守护运行中(Ctrl-C 退出)");
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// ---------- setup:一键安装(默认云端服务;--server/--key 走自建) ----------
|
|
1270
|
+
async function setup(argv) {
|
|
1271
|
+
const api = argValue(argv, "--api");
|
|
1272
|
+
const server = argValue(argv, "--server");
|
|
1273
|
+
const key = argValue(argv, "--key");
|
|
1274
|
+
const noAutostart = hasFlag(argv, "--no-autostart");
|
|
1275
|
+
const noPlugin = hasFlag(argv, "--no-plugin");
|
|
1276
|
+
const noRestart = hasFlag(argv, "--no-restart");
|
|
1277
|
+
const selfHosted = Boolean(server || key);
|
|
1278
|
+
|
|
1279
|
+
let cfg = loadConfig();
|
|
1280
|
+
|
|
1281
|
+
if (selfHosted) {
|
|
1282
|
+
if (!server || !key) {
|
|
1283
|
+
console.error("❌ 自建模式需要 --server(wss://host:port)与 --key(访问密钥)两个参数。");
|
|
1284
|
+
process.exit(1);
|
|
1285
|
+
}
|
|
1286
|
+
cfg.tunnel_url = normalizeTunnelUrl(server);
|
|
1287
|
+
cfg.local_key = String(key).trim();
|
|
1288
|
+
delete cfg.phone; delete cfg.password;
|
|
1289
|
+
saveConfig(cfg);
|
|
1290
|
+
|
|
1291
|
+
// 校验:用访问密钥向 router 换本地 JWT(连不通立即报错)
|
|
1292
|
+
const u = new URL(cfg.tunnel_url);
|
|
1293
|
+
u.protocol = u.protocol === "wss:" ? "https:" : "http:";
|
|
1294
|
+
u.pathname = "/_login";
|
|
1295
|
+
try {
|
|
1296
|
+
const r = await fetch(u.toString(), {
|
|
1297
|
+
method: "POST",
|
|
1298
|
+
headers: { "content-type": "application/json" },
|
|
1299
|
+
body: JSON.stringify({ key: cfg.local_key }),
|
|
1300
|
+
signal: AbortSignal.timeout(8000)
|
|
1301
|
+
});
|
|
1302
|
+
const d = await r.json().catch(() => ({}));
|
|
1303
|
+
if (r.status !== 200 || !d.token) {
|
|
1304
|
+
console.error(`❌ 访问密钥校验失败(${r.status}): ${d.error?.message || "未知错误"}`);
|
|
1305
|
+
process.exit(1);
|
|
1306
|
+
}
|
|
1307
|
+
console.log("✅ 已连接你的 relay-router,访问密钥有效。");
|
|
1308
|
+
} catch (e) {
|
|
1309
|
+
console.error(`❌ 无法连接 ${u.toString()}: ${e.message}`);
|
|
1310
|
+
console.error(" 请确认服务器地址、端口与 TLS 配置(自建需 https/wss 入口)。");
|
|
1311
|
+
process.exit(1);
|
|
1312
|
+
}
|
|
1313
|
+
} else {
|
|
1314
|
+
// 默认云端服务:无需任何参数;登录在设置页完成
|
|
1315
|
+
cfg.api_url = (api || cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
1316
|
+
if (!cfg.tunnel_url) cfg.tunnel_url = deriveTunnelUrl(cfg.api_url);
|
|
1317
|
+
// 自动获取服务端下发的 bridge_secret(device-login 共享密钥,一键安装开箱即用)
|
|
1318
|
+
if (!cfg.bridge_secret) {
|
|
1319
|
+
const pub = await fetchPublicConfig(cfg.api_url);
|
|
1320
|
+
if (pub.bridge_secret) cfg.bridge_secret = String(pub.bridge_secret);
|
|
1321
|
+
}
|
|
1322
|
+
saveConfig(cfg);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
let svc = { path: null, status: { ok: true, status: "skipped", detail: "--no-autostart" } };
|
|
1326
|
+
if (!noAutostart) {
|
|
1327
|
+
svc = installAutostart();
|
|
1328
|
+
} else {
|
|
1329
|
+
console.log("ℹ --no-autostart:跳过自启动服务安装(可用 `dsh-remote run` 手动运行 bridge)。");
|
|
1330
|
+
}
|
|
1331
|
+
const st = svc.status;
|
|
1332
|
+
|
|
1333
|
+
// 自动安装 dsh web 插件(非致命:失败只提示,不阻断安装)
|
|
1334
|
+
let pluginResult = null;
|
|
1335
|
+
if (!noPlugin) {
|
|
1336
|
+
try {
|
|
1337
|
+
pluginResult = await pluginCmd([]);
|
|
1338
|
+
} catch (e) {
|
|
1339
|
+
console.warn(`⚠️ 插件安装未完成:${e.message}`);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// 插件是**进程启动时**装载的:刚装进 profile 的插件,运行中的 dsh web 里并没有
|
|
1344
|
+
// (既没有 /dsh-remote/* 路由,也没有「设置 → 远程访问」面板项)。
|
|
1345
|
+
// 这里做一个确定性判断:进程启动时间早于插件落盘时间 → 必须重启 dsh web 才生效。
|
|
1346
|
+
const pluginDir = path.join(resolveProfileDir(argv), PLUGIN_LOCAL_DIR);
|
|
1347
|
+
const pluginMtime = fs.existsSync(path.join(pluginDir, "lib", "index.js"))
|
|
1348
|
+
? fs.statSync(path.join(pluginDir, "lib", "index.js")).mtimeMs
|
|
1349
|
+
: 0;
|
|
1350
|
+
let webPid = null;
|
|
1351
|
+
let needRestart = false;
|
|
1352
|
+
let hotMounted = false;
|
|
1353
|
+
let runningSeen = ""; // 探测窗口里看到过的运行中插件版本(超时时用于给出准确提示)
|
|
1354
|
+
const webUp = await isDshWebUp();
|
|
1355
|
+
if (webUp) {
|
|
1356
|
+
webPid = dshWebPid();
|
|
1357
|
+
const startedAt = webPid ? processStartMs(webPid) : 0;
|
|
1358
|
+
needRestart = Boolean(pluginMtime && startedAt && startedAt < pluginMtime);
|
|
1359
|
+
// patch 激活形态:harness 的 HMR 监听 profile patch 文件,存盘后约 1 秒重新 compose。
|
|
1360
|
+
// 所以这里**先等热挂载**,等到了就完全不需要重启(用户只需刷新页面拿浏览器半)。
|
|
1361
|
+
if (needRestart && pluginResult && pluginResult.hotPatch) {
|
|
1362
|
+
// HMR 通常 1~3 秒完成,但插件节点半还要起服务、注册路由,机器繁忙时会到十几秒;
|
|
1363
|
+
// ⚠️ 判据必须是**运行中的插件版本 == 我们刚装上的版本**,不能只看"接口是否 200" ——
|
|
1364
|
+
// 旧版本插件本来就有这个接口,接口通不代表新代码已装载(实测踩到:profile 已是 0.6.4,
|
|
1365
|
+
// 运行中仍报 beta.10,而探测却认为"热挂载成功")。
|
|
1366
|
+
const wantVersion = pkgVersion();
|
|
1367
|
+
// 先**主动触发**一次 HMR:patch 文件是唯一被监听的对象。
|
|
1368
|
+
// 注意边界(实测):这招对"插件首次出现在 patch 行里"有效(行从无到有 = 真变化,
|
|
1369
|
+
// 约 1~3 秒装载);但对"同一插件换了新版本"**无效** —— patch 行内容没变,
|
|
1370
|
+
// 而且加载器按 URL 缓存模块,改写插件文件本身不会重新装载。后者必须重启 dsh web。
|
|
1371
|
+
// 因此下面按**版本**轮询,等不到就如实让用户重启。
|
|
1372
|
+
try {
|
|
1373
|
+
const patchPath = path.join(resolveProfileDir(argv), "cordis.patch.yml");
|
|
1374
|
+
const cur = fs.readFileSync(patchPath, "utf8");
|
|
1375
|
+
fs.writeFileSync(patchPath, cur);
|
|
1376
|
+
} catch { /* 忽略:轮询会给出结论 */ }
|
|
1377
|
+
for (let i = 0; i < 45; i += 1) {
|
|
1378
|
+
const r = await fetch(upstreamUrl() + "/dsh-remote/self", { signal: AbortSignal.timeout(900) })
|
|
1379
|
+
.catch(() => null);
|
|
1380
|
+
if (r && r.ok) {
|
|
1381
|
+
const j = await r.json().catch(() => null);
|
|
1382
|
+
const running = j && typeof j.version === "string" ? j.version : "";
|
|
1383
|
+
if (running && running === wantVersion) { hotMounted = true; break; }
|
|
1384
|
+
// 接口通但版本还是旧的 → HMR 还没换过来(或没触发),继续等
|
|
1385
|
+
runningSeen = running || runningSeen;
|
|
1386
|
+
}
|
|
1387
|
+
sleepSync(700);
|
|
1388
|
+
}
|
|
1389
|
+
if (hotMounted) needRestart = false;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
// 需要就用**最省事的方式**替用户重启:插件装完还要用户自己琢磨怎么重启,是这一步唯一的手工负担
|
|
1393
|
+
let restartResult = null;
|
|
1394
|
+
if (needRestart && !noRestart && webUp) {
|
|
1395
|
+
console.log("");
|
|
1396
|
+
console.log("ℹ 检测到运行中的 dsh web 早于本次插件安装 —— 插件只在进程启动时装载,");
|
|
1397
|
+
console.log(" 所以「设置 → 远程访问」面板暂时还没出现。正在为你重启 dsh web(2 秒后执行,Ctrl-C 可跳过)…");
|
|
1398
|
+
sleepSync(2000);
|
|
1399
|
+
restartResult = restartDshWeb();
|
|
1400
|
+
if (restartResult.ok) {
|
|
1401
|
+
// 等它回来再确认端口确实在听(避免"重启完其实没起来"还要用户自己发现)
|
|
1402
|
+
for (let i = 0; i < 20; i += 1) {
|
|
1403
|
+
if (await isDshWebUp(800)) break;
|
|
1404
|
+
sleepSync(700);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
console.log(restartResult.ok
|
|
1408
|
+
? `✅ 已重启 dsh web(${restartResult.how})`
|
|
1409
|
+
: `⚠️ 自动重启失败: ${restartResult.detail}`);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
|
|
1413
|
+
// 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改口径。
|
|
1414
|
+
// 三分支:running / 未运行(有原因) / 未安装(跳过或平台不支持)——最后一种既不能显示"运行中",
|
|
1415
|
+
// 也不能显示"未运行(原因)",它压根没装(此前会打出自相矛盾的"(当前平台不支持) — ✅ 运行中")。
|
|
1416
|
+
const svcSkipped = st.status === "skipped" || st.status === "unsupported" || !svc.path;
|
|
1417
|
+
// 第三态:起来了、但本机 launchd 不保证崩溃自愈(on-demand-only 的 gui domain 只能靠 kickstart 拉起),
|
|
1418
|
+
// 必须与"完全正常"分开说 —— 否则用户以为有自启,进程一崩就永久掉线且找不到原因。
|
|
1419
|
+
// Windows 没有 launchd,这条判断对它不成立(否则会把 win32 误报成"本机不支持崩溃自愈")。
|
|
1420
|
+
const svcFragile = !IS_WIN && !svcSkipped && st.ok && st.selfHealing === false;
|
|
1421
|
+
const svcDegraded = st.status === "degraded-detached";
|
|
1422
|
+
// Windows:自启动 = 任务计划程序里的登录任务。口径与 macOS/Linux 不同
|
|
1423
|
+
// ("任务已注册" ≠ "进程此刻在跑"),所以单独给文案,避免复用"未被系统托管"这种会误导的措辞。
|
|
1424
|
+
const winTask = IS_WIN && winTaskOk();
|
|
1425
|
+
const svcState = IS_WIN
|
|
1426
|
+
? (st.status === "skipped"
|
|
1427
|
+
? "未安装(本次显式跳过)"
|
|
1428
|
+
: (!winTask
|
|
1429
|
+
? "未注册(登录任务创建失败,可用 `dsh-remote run` 手动运行 bridge)"
|
|
1430
|
+
: (st.ok ? `✅ 已注册,守护进程在运行${st.pid ? ` (pid=${st.pid})` : ""}` : "✅ 已注册(登录后自动运行;dsh web 打开时会立刻接上)")))
|
|
1431
|
+
: (svcSkipped
|
|
1432
|
+
? (st.status === "skipped" ? "未安装(本次显式跳过)" : "未安装(当前平台不支持自启动)")
|
|
1433
|
+
: (st.ok
|
|
1434
|
+
? `✅ 运行中${st.pid ? ` (pid=${st.pid})` : ""}${svcFragile ? ",但本机不支持崩溃自愈" : ""}`
|
|
1435
|
+
: (svcDegraded ? "⚠️ 未被系统托管(已用后台进程兜底)" : `未运行 (${st.detail || st.status})`)));
|
|
1436
|
+
const L = [];
|
|
1437
|
+
L.push("✅ 安装完成");
|
|
1438
|
+
if (selfHosted) {
|
|
1439
|
+
L.push(` 服务器: ${cfg.tunnel_url}`);
|
|
1440
|
+
L.push(` 手机端: 打开 ${cfg.tunnel_url.replace(/^ws/, "https")}/app/ ,用访问密钥登录`);
|
|
1441
|
+
} else {
|
|
1442
|
+
L.push(` 远程访问地址: ${pub.app_url || DEFAULT_APP_URL}`);
|
|
1443
|
+
}
|
|
1444
|
+
L.push(svcSkipped
|
|
1445
|
+
? ` 自启动服务: ${svcState},可用 \`dsh-remote run\` 手动运行 bridge`
|
|
1446
|
+
: ` 自启动服务: ${svc.path} — ${svcState}`);
|
|
1447
|
+
// 唯一一处"下一步"引导(上面各阶段不再重复打印同样的话)
|
|
1448
|
+
if (svcSkipped) {
|
|
1449
|
+
// 没装自启动服务,就没有"服务状态"可谈,更不该给出"打开 dsh web 就会自动启动"的承诺
|
|
1450
|
+
L.push("");
|
|
1451
|
+
L.push("ℹ 未安装自启动服务:需要时执行 `dsh-remote run`(前台运行 bridge),或重新安装以启用自启动。");
|
|
1452
|
+
} else if (!st.ok && !webUp) {
|
|
1453
|
+
// dsh web 没开时 bridge 起来即退,这是正常状态;要说清"什么时候会自己好",而不是甩一句"启动失败"
|
|
1454
|
+
L.push("");
|
|
1455
|
+
L.push("ℹ 检测到 dsh web 当前没有运行,所以 bridge 还没接上(正常,不是安装出错)。");
|
|
1456
|
+
L.push(" 打开 dsh web 后 bridge 会自动启动,无需任何命令。");
|
|
1457
|
+
L.push(" 如果 dsh web 已经开着但看不到本机,先重启 dsh web 让插件生效。");
|
|
1458
|
+
} else if (!st.ok) {
|
|
1459
|
+
L.push("");
|
|
1460
|
+
if (IS_WIN) {
|
|
1461
|
+
// Windows:登录任务已注册,只是此刻 bridge 守护还没就绪(dsh web 刚起、或还没登录账号)
|
|
1462
|
+
L.push(`ℹ bridge 守护还没就绪: ${st.detail || st.status}`);
|
|
1463
|
+
L.push(` 已注册登录任务「${WIN_TASK_NAME}」,下次登录会自动运行;`);
|
|
1464
|
+
L.push(" 也可以现在执行 `dsh-remote run` 前台运行 bridge(Ctrl-C 退出)。");
|
|
1465
|
+
L.push(` 日志: ${CONFIG_DIR}\\.dsh-bridge.log`);
|
|
1466
|
+
} else if (svcDegraded) {
|
|
1467
|
+
// launchd 托管失败但进程活着:能立刻用,只是没有自启/自愈。如实说明,别让用户以为有自启。
|
|
1468
|
+
L.push(`⚠️ ${st.detail}`);
|
|
1469
|
+
L.push(` bridge 已在后台运行${st.pid ? `(pid=${st.pid})` : ""},现在就能用;`);
|
|
1470
|
+
L.push(" 但重启电脑或进程退出后不会自动恢复,需要时执行 `dsh-remote run`。");
|
|
1471
|
+
} else {
|
|
1472
|
+
L.push(`⚠️ bridge 未能启动: ${st.detail || st.status}`);
|
|
1473
|
+
L.push(` 可查看日志: ${CONFIG_DIR}/.dsh-bridge.log`);
|
|
1474
|
+
}
|
|
1475
|
+
} else if (svcFragile) {
|
|
1476
|
+
// 起来了但靠 kickstart 拉起的 → 明确告知"进程退出后不会自动重建"
|
|
1477
|
+
L.push("");
|
|
1478
|
+
L.push("ℹ 本机 launchd 处于 on-demand-only 模式(macOS 26 起会出现),系统不会自动派生自启动服务,");
|
|
1479
|
+
L.push(" 安装脚本已手动把它拉起来一次,现在可用;但**进程退出后不会自动重建**。");
|
|
1480
|
+
L.push(" 建议升级到最新版 dsh-remote(已针对该模式改用 user domain 启动,可恢复自动重建)。");
|
|
1481
|
+
}
|
|
1482
|
+
if (selfHosted) {
|
|
1483
|
+
L.push("");
|
|
1484
|
+
L.push(" 下一步: 手机端用访问密钥登录即可(bridge 登录后自动启动)。");
|
|
1485
|
+
} else if (hotMounted) {
|
|
1486
|
+
L.push("");
|
|
1487
|
+
L.push(" 下一步: 插件已热加载(无需重启 dsh web)——刷新一下浏览器页面,");
|
|
1488
|
+
L.push(" 再打开 设置 → 「远程访问」→ 注册/登录手机号即可。");
|
|
1489
|
+
} else if (needRestart && restartResult && restartResult.ok) {
|
|
1490
|
+
L.push("");
|
|
1491
|
+
L.push(" 下一步: dsh web 已重启,直接打开 " + upstreamUrl() + " → 设置 → 「远程访问」→ 注册/登录手机号。");
|
|
1492
|
+
} else if (needRestart && pluginResult && pluginResult.hotPatch && pluginResult.changed) {
|
|
1493
|
+
// patch 已写入但探测窗口内没等到面板接口:热加载很可能还在进行(插件节点半启动+注册路由)。
|
|
1494
|
+
// 这种情况**不能**直接让用户重启 —— 先让他等几秒刷新,再给兜底方案。
|
|
1495
|
+
L.push("");
|
|
1496
|
+
if (runningSeen) {
|
|
1497
|
+
L.push(`ℹ 插件已按热加载方式登记,但运行中的 dsh web 仍在用旧版本(${runningSeen} → 期望 ${pkgVersion()}):`);
|
|
1498
|
+
L.push(" 刷新页面即可(浏览器半会跟着更新);若刷新后功能仍不对,再重启一次 dsh web。");
|
|
1499
|
+
} else {
|
|
1500
|
+
L.push("ℹ 插件已按热加载方式登记(patch 已写入),若「设置 → 远程访问」还没出现:");
|
|
1501
|
+
L.push(" 等几秒后刷新页面即可;仍未出现再重启一次 dsh web(命令见 README)。");
|
|
1502
|
+
}
|
|
1503
|
+
} else if (needRestart) {
|
|
1504
|
+
// 需要重启但没做成功:必须给出**可照抄**的命令,而不是只说"请重启"
|
|
1505
|
+
L.push("");
|
|
1506
|
+
L.push("⚠️ 还需要重启一次 dsh web,插件面板才会出现(插件只在进程启动时装载)。");
|
|
1507
|
+
L.push(" 请手动执行(任选其一):");
|
|
1508
|
+
L.push(" launchctl kickstart -k gui/$(id -u)/com.dshweb.dev # 用 launchd 托管时");
|
|
1509
|
+
L.push(` kill $(lsof -ti tcp:${upstreamPort()} -sTCP:LISTEN) && dsh web --no-open # 手动启动时`);
|
|
1510
|
+
L.push(` 重启后:打开 ${upstreamUrl()} → 设置 → 「远程访问」→ 注册/登录手机号。`);
|
|
1511
|
+
} else {
|
|
1512
|
+
L.push("");
|
|
1513
|
+
L.push(" 下一步: 打开 dsh web → 设置 → 「远程访问」→ 注册/登录手机号即可(无需任何命令)。");
|
|
1514
|
+
}
|
|
1515
|
+
console.log("\n" + L.join("\n") + "\n");
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// ---------- plugin:安装/卸载 dsh web 远程访问插件 ----------
|
|
1519
|
+
/** 当前插件名(2026-09 由 dsh-remote-ui 更名;dsh-remote-web = dsh-remote 的 dsh web 插件半)。 */
|
|
1520
|
+
const PLUGIN_ID = "dsh-remote-web";
|
|
1521
|
+
/** 更名前的历史插件名(≤0.4.9):升级安装/卸载时一并清理,避免旧 id 残留导致重复激活。 */
|
|
1522
|
+
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1523
|
+
/** 当前名 + 全部历史名。 */
|
|
1524
|
+
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1525
|
+
const PLUGIN_MARKER_START = `# >>> ${PLUGIN_ID} (managed by dsh-remote plugin; do not edit)`;
|
|
1526
|
+
const PLUGIN_MARKER_END = `# <<< ${PLUGIN_ID}`;
|
|
1527
|
+
/** 插件在 profile 内的固定本地目录(安装时整目录拷贝)。 */
|
|
1528
|
+
const PLUGIN_LOCAL_DIR = `${PLUGIN_ID}-plugin`;
|
|
1529
|
+
/** 片段是否涉及本插件(当前名或任一历史名)。 */
|
|
1530
|
+
const pluginRef = (s) => PLUGIN_ALL_IDS.some((id) => s.includes(id));
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* include 块。name 必须是【裸包名】'${PLUGIN_ID}'(≤0.4.9 为 'dsh-remote-ui'):
|
|
1534
|
+
* - 加载器据此 import 节点半(${PLUGIN_ID} 由 node_modules 链接解析);
|
|
1535
|
+
* - dsh 客户端模块系统(@deepseek-ai/dsh-client-modules)用 require.resolve(
|
|
1536
|
+
* '<name>/package.json') 从 profile 解析该包并读取 package.json 的
|
|
1537
|
+
* dsh.client 声明 → 注入浏览器半(设置面板 UI)。相对文件路径入口只会加载
|
|
1538
|
+
* 节点半、不会注入 UI —— 0.3.7/0.3.8 曾因此丢设置页。
|
|
1539
|
+
* 链接由安装器自建(node_modules/${PLUGIN_ID} → 拷贝出的目录),不依赖 pnpm/npm。
|
|
1540
|
+
*/
|
|
1541
|
+
function pluginBlock(relayDir) {
|
|
1542
|
+
return `${PLUGIN_MARKER_START}
|
|
1543
|
+
- insert:
|
|
1544
|
+
- id: ${PLUGIN_ID}
|
|
1545
|
+
name: '${PLUGIN_ID}'
|
|
1546
|
+
config:
|
|
1547
|
+
relayDir: '${relayDir}'
|
|
1548
|
+
${PLUGIN_MARKER_END}`;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** 移除 patch 中所有引用本插件(当前或历史名)的条目块(含其前置注释),返回剩余内容。 */
|
|
1552
|
+
function stripPluginEntries(patch) {
|
|
1553
|
+
const lines = patch.split("\n");
|
|
1554
|
+
const out = [];
|
|
1555
|
+
let i = 0;
|
|
1556
|
+
while (i < lines.length) {
|
|
1557
|
+
const line = lines[i];
|
|
1558
|
+
if (/^- insert:\s*$/.test(line)) {
|
|
1559
|
+
// insert 块 = 该行 + 后续「缩进」行;空行/注释行属于下一个条目,不并入
|
|
1560
|
+
const block = [line];
|
|
1561
|
+
let j = i + 1;
|
|
1562
|
+
while (j < lines.length && (lines[j].startsWith(" ") || lines[j].startsWith("\t"))) {
|
|
1563
|
+
block.push(lines[j]);
|
|
1564
|
+
j++;
|
|
1565
|
+
}
|
|
1566
|
+
if (pluginRef(block.join("\n"))) {
|
|
1567
|
+
// 连带删除块前的连续注释(旧版条目说明 / 管理标记),以及块后的收尾标记行
|
|
1568
|
+
while (out.length && /^\s*#/.test(out[out.length - 1])) out.pop();
|
|
1569
|
+
if (j < lines.length && /^\s*#/.test(lines[j]) && pluginRef(lines[j])) j++;
|
|
1570
|
+
i = j;
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
out.push(...block);
|
|
1574
|
+
i = j;
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
out.push(line);
|
|
1578
|
+
i++;
|
|
1579
|
+
}
|
|
1580
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* 语义化版本比较(只处理本仓库用到的形态:X.Y.Z 与 X.Y.Z-<pre>.<n>)。
|
|
1585
|
+
* 为什么需要:装置器必须判断"本地这份包 vs profile 里已装的那份"谁更新 ——
|
|
1586
|
+
* 用字符串比较会把 0.6.6-beta.2 与 0.6.5 比错(预设版低于同号正式版),
|
|
1587
|
+
* 于是要么跳过本该做的升级、要么把新版降级。任一侧解析不了返回 null(调用方保守处理)。
|
|
1588
|
+
* @returns {number|null} a>b → 1;a<b → -1;相等 → 0;无法解析 → null
|
|
1589
|
+
*/
|
|
1590
|
+
function compareVersionStrings(a, b) {
|
|
1591
|
+
const parse = (v) => {
|
|
1592
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || "").trim());
|
|
1593
|
+
if (!m) return null;
|
|
1594
|
+
return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split(".") : null };
|
|
1595
|
+
};
|
|
1596
|
+
const x = parse(a);
|
|
1597
|
+
const y = parse(b);
|
|
1598
|
+
if (!x || !y) return null;
|
|
1599
|
+
for (let i = 0; i < 3; i += 1) if (x.nums[i] !== y.nums[i]) return x.nums[i] > y.nums[i] ? 1 : -1;
|
|
1600
|
+
if (!x.pre && !y.pre) return 0;
|
|
1601
|
+
if (!x.pre) return 1; // 预设版 < 同号正式版(semver §11)
|
|
1602
|
+
if (!y.pre) return -1;
|
|
1603
|
+
const len = Math.max(x.pre.length, y.pre.length);
|
|
1604
|
+
for (let i = 0; i < len; i += 1) {
|
|
1605
|
+
const p1 = x.pre[i];
|
|
1606
|
+
const p2 = y.pre[i];
|
|
1607
|
+
if (p1 === undefined) return -1;
|
|
1608
|
+
if (p2 === undefined) return 1;
|
|
1609
|
+
const n1 = /^\d+$/.test(p1) ? Number(p1) : null;
|
|
1610
|
+
const n2 = /^\d+$/.test(p2) ? Number(p2) : null;
|
|
1611
|
+
if (n1 !== null && n2 !== null) { if (n1 !== n2) return n1 > n2 ? 1 : -1; continue; }
|
|
1612
|
+
if (n1 !== null) return -1;
|
|
1613
|
+
if (n2 !== null) return 1;
|
|
1614
|
+
if (p1 !== p2) return p1 > p2 ? 1 : -1;
|
|
1615
|
+
}
|
|
1616
|
+
return 0;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* 解析 patch 文本里的 insert 行 id(与 dshmarket 同口径的极简解析,不引 YAML 依赖)。
|
|
1621
|
+
* 用途有二:① 判断本插件是否已激活(幂等);② **写完校验**——patch 文件形态非法时
|
|
1622
|
+
* dsh web 会直接启动失败,所以每次写入后必须重新解析确认结果仍是「一行一条的 insert 列表」。
|
|
1623
|
+
* @returns {{ids: string[], badLines: string[]}} badLines 非空 = 文件形态不合法
|
|
1624
|
+
*/
|
|
1625
|
+
function parsePatchInsertIds(text) {
|
|
1626
|
+
const ids = [];
|
|
1627
|
+
const badLines = [];
|
|
1628
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
1629
|
+
// 先剥注释与尾部空白;注意 id 行在块内是缩进的(` - id: xxx`),不能要求顶格
|
|
1630
|
+
const line = raw.replace(/#.*$/, "").replace(/\s+$/, "");
|
|
1631
|
+
const trimmed = line.trim();
|
|
1632
|
+
if (trimmed === "") continue;
|
|
1633
|
+
if (trimmed === "[]") continue; // 新 profile 的空文档占位
|
|
1634
|
+
if (/^-\s*insert:\s*$/.test(trimmed)) continue; // insert 块头(可缩进)
|
|
1635
|
+
const m = /^-\s*id:\s*(\S+)\s*$/.exec(trimmed); // 条目行(可缩进)
|
|
1636
|
+
if (m) { ids.push(m[1]); continue; }
|
|
1637
|
+
// 其余缩进行属于某个条目的字段(name/config/...),合法;顶层非列表行才是异常形态
|
|
1638
|
+
if (/^\s+\S/.test(line)) continue;
|
|
1639
|
+
badLines.push(trimmed);
|
|
1640
|
+
}
|
|
1641
|
+
return { ids, badLines };
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* 写入 patch 前校验基座形态。
|
|
1646
|
+
* 只接受「空/仅占位/现有条目」三种基座;发现异常内容就**拒写**(绝不把一个本来还能用的
|
|
1647
|
+
* profile 弄坏 —— 改动前先确认目标文件是可安全追加的形态,而不是先写再看结果)。
|
|
1648
|
+
* @returns {{ok: boolean, reason?: string, cleaned: string, insertedIds: string[]}}
|
|
1649
|
+
*/
|
|
1650
|
+
function validatePatchBase(text) {
|
|
1651
|
+
const { ids, badLines } = parsePatchInsertIds(text);
|
|
1652
|
+
if (badLines.length) {
|
|
1653
|
+
return { ok: false, reason: `patch 文件含无法识别的行(未做改动): ${badLines[0].slice(0, 60)}`, cleaned: "", insertedIds: ids };
|
|
1654
|
+
}
|
|
1655
|
+
// 去掉空文档占位 `[]`(它在条目之前会让整份文档变成「数组套列表」而解析失败)
|
|
1656
|
+
const cleaned = String(text ?? "").split(/\r?\n/).filter((l) => l.trim() !== "[]").join("\n").trim();
|
|
1657
|
+
return { ok: true, cleaned, insertedIds: ids };
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* patch 文本是否是「合法的顶层 YAML 数组文档」。
|
|
1662
|
+
*
|
|
1663
|
+
* 为什么必须判:dsh 侧解析 cordis.patch.yml 时**要求顶层是数组**,而
|
|
1664
|
+
* **纯注释文档的 YAML 解析结果是 `null`(不是空数组)**,于是 dsh 直接启动失败:
|
|
1665
|
+
* `Error: dsh: overlay …/cordis.patch.yml must be a top-level YAML array of loader patch entries`
|
|
1666
|
+
* 官方空 profile 模板(dsh-app-boot)正是「注释 + `[]`」——`[]` 这个占位符不能省。
|
|
1667
|
+
* 现场(2026-09-18 Windows 用户实测):卸载把插件条目摘掉后只剩顶部注释,dsh web 再也起不来。
|
|
1668
|
+
*/
|
|
1669
|
+
/** 顶层(无缩进)非注释行 = YAML 的结构行;缩进行属于上一条目。 */
|
|
1670
|
+
function patchStructuralLines(text) {
|
|
1671
|
+
return String(text ?? "")
|
|
1672
|
+
.split(/\r?\n/)
|
|
1673
|
+
.map((l) => l.replace(/\s+$/, ""))
|
|
1674
|
+
.filter((l) => l.trim() !== "" && !/^\s*#/.test(l) && !/^[ \t]/.test(l));
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/** 是否是合法的顶层数组文档(`[]` 占位,或若干 `-` 开头的顶层条目)。 */
|
|
1678
|
+
function isPatchArrayDocument(text) {
|
|
1679
|
+
const roots = patchStructuralLines(text);
|
|
1680
|
+
return roots.length > 0 && roots.every((l) => l === "[]" || l.startsWith("-"));
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/** 只有注释/空行 → YAML 解析成 null(不是空数组),必须补 `[]` 占位,否则 dsh 起不来。 */
|
|
1684
|
+
function needsArrayPlaceholder(text) {
|
|
1685
|
+
return patchStructuralLines(text).length === 0;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* 写回 patch 文档,并**保证结果仍是合法顶层数组**:条目被删光时补回 `[]` 占位。
|
|
1690
|
+
* 卸载、摘 include、回滚都必须走这里 —— 任何一条写路径漏了这步,用户就会拿到
|
|
1691
|
+
* 一个「dsh web 起不来且面板也进不去」的 profile(只能靠 CLI 自救)。
|
|
1692
|
+
*/
|
|
1693
|
+
function writePatchDocument(patchFile, text) {
|
|
1694
|
+
const body = String(text ?? "").replace(/\s+$/, "") + "\n";
|
|
1695
|
+
// ⚠️ 只在「一个结构行都不剩」时补 —— 不能因为"看起来不像数组"就乱补,
|
|
1696
|
+
// 那会把本来好的文件写坏(把 `[]` 追加到已有 insert 块后面 → YAML 直接报错)。
|
|
1697
|
+
const fixed = needsArrayPlaceholder(body) ? `${body}[]\n` : body;
|
|
1698
|
+
writePatchAtomic(patchFile, fixed);
|
|
1699
|
+
return fixed;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/** 安全写回 patch(原子:先写同目录临时文件再 rename,避免中途被打断留下半截文件)。 */
|
|
1703
|
+
function writePatchAtomic(patchFile, text) {
|
|
1704
|
+
const tmp = `${patchFile}.dsh-remote.tmp`;
|
|
1705
|
+
fs.writeFileSync(tmp, text);
|
|
1706
|
+
fs.renameSync(tmp, patchFile);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* 原子地写入插件 patch 激活行;写后**重新解析校验**,不合法立即回滚。
|
|
1711
|
+
* 返回 {ok, changed, reason?, basePatch}
|
|
1712
|
+
*
|
|
1713
|
+
* ⚠️ 硬约束:**本函数绝不能在插件已在 dsh.profile.bundles 时写入 patch 行**。
|
|
1714
|
+
* 两个激活点同时存在时,dsh web 启动即 `duplicate loader entry id: dsh-remote-web`
|
|
1715
|
+
* 并导致「plugin tree failed to load」——整个插件树加载失败(实测日志)。
|
|
1716
|
+
* 因此所有 return 之前都要保证:要么只有 patch 行、要么只有 bundles 条目。
|
|
1717
|
+
*/
|
|
1718
|
+
function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
|
|
1719
|
+
const original = fs.readFileSync(patchFile, "utf8");
|
|
1720
|
+
const pkgBundles = () => {
|
|
1721
|
+
try {
|
|
1722
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
1723
|
+
return (pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles))
|
|
1724
|
+
? pkg.dsh.profile.bundles.filter((b) => PLUGIN_ALL_IDS.includes(b)) : [];
|
|
1725
|
+
} catch { return []; }
|
|
1726
|
+
};
|
|
1727
|
+
// ★★ 稳定形态 = **只由 `dsh.profile.bundles` 激活**,我们**不再往 patch 里写激活行**。
|
|
1728
|
+
//
|
|
1729
|
+
// 2026-09-22 实测事故:`duplicate loader entry id: dsh-remote-web` →
|
|
1730
|
+
// `dsh: plugin tree failed to load` → **dsh web 启动即挂**(用真实启动复现)。
|
|
1731
|
+
// 根因是**两处激活同时存在**:
|
|
1732
|
+
// (a) 插件自带的 bundle 在 `package.json` 的 `dsh.profile.bundles` 里;
|
|
1733
|
+
// (b) 我们写进 profile `cordis.patch.yml` 的激活行又声明了同一个 id。
|
|
1734
|
+
//
|
|
1735
|
+
// 旧实现走的是**反方向**——"删 bundles 条目、留 patch 行"(见下方已删的 removeBundleEntry 调用),
|
|
1736
|
+
// 还为此写了复杂的回滚。但**自动维护流程会把插件自带 bundle 周期性写回**,
|
|
1737
|
+
// 于是"删了又回来"→ 又变两处 → 再次崩。只要方向是反的,这个 bug 就会反复出现。
|
|
1738
|
+
//
|
|
1739
|
+
// 所以现在改成:**bundle 归包管理器/维护流程管,我们绝不动它**;
|
|
1740
|
+
// 我们只保证自己**不再制造第二个激活点**,并顺手把历史遗留的重复 patch 行清掉。
|
|
1741
|
+
const inBundles = pkgBundles();
|
|
1742
|
+
if (inBundles.length) {
|
|
1743
|
+
const stripped = stripPluginEntries(original);
|
|
1744
|
+
if (stripped !== original) {
|
|
1745
|
+
writePatchDocument(patchFile, stripped);
|
|
1746
|
+
console.log("✅ 已移除 profile patch 中与本插件重复的激活行(激活统一走 dsh.profile.bundles,避免 duplicate id 崩树)");
|
|
1747
|
+
return { ok: true, changed: true, bundleOnly: true, basePatch: original };
|
|
1748
|
+
}
|
|
1749
|
+
return { ok: false, reason: "插件已由 dsh.profile.bundles 声明(保持单一激活点,不写 patch 行)", changed: false, bundleOnly: true, basePatch: original };
|
|
1750
|
+
}
|
|
1751
|
+
// 到这里说明 bundles 里没有本插件 —— 此时 patch 行是**唯一**激活点,必须写(升级/热加载场景)。
|
|
1752
|
+
// `forcePatch` 仍然保留语义:它只影响"bundles 为空时"的行为,不再能覆盖上面那条硬约束。
|
|
1753
|
+
const base = validatePatchBase(original);
|
|
1754
|
+
if (!base.ok) return { ok: false, reason: base.reason, changed: false, basePatch: original };
|
|
1755
|
+
const hasOurRow = base.insertedIds.some((id) => PLUGIN_ALL_IDS.includes(id));
|
|
1756
|
+
if (hasOurRow) {
|
|
1757
|
+
// ⚠️ 走到这里说明 **bundles 里没有本插件**(上面 inBundles 为空的早退已排除两处激活的形态),
|
|
1758
|
+
// 所以 patch 行就是唯一激活点,可以安心保留它 —— 这里**不再**去动 bundles。
|
|
1759
|
+
// (旧实现在这里调 removeBundleEntry,方向是反的:见本函数顶部注释。)
|
|
1760
|
+
// 已有条目也要**自我修正**:配置目录变了(换安装方式 / 之前写错)时 relayDir 必须跟着更新,
|
|
1761
|
+
// 否则面板会一直去旧目录找 .dsh-config.json(表现就是"未登录 / 空配置")。
|
|
1762
|
+
const want = pluginBlock(CONFIG_DIR);
|
|
1763
|
+
const wantDir = (/relayDir: '([^']*)'/.exec(want) || [])[1] || "";
|
|
1764
|
+
if (wantDir && !original.includes(`relayDir: '${wantDir}'`)) {
|
|
1765
|
+
const base2 = validatePatchBase(stripPluginEntries(original));
|
|
1766
|
+
if (base2.ok) {
|
|
1767
|
+
const merged2 = `${base2.cleaned ? base2.cleaned + "\n" : ""}\n${want}\n`;
|
|
1768
|
+
const chk = parsePatchInsertIds(merged2);
|
|
1769
|
+
if (!chk.badLines.length && chk.ids.filter((id) => id === PLUGIN_ID).length === 1) {
|
|
1770
|
+
writePatchDocument(patchFile, merged2);
|
|
1771
|
+
console.log(`✅ 已同步激活行的 relayDir → ${wantDir}`);
|
|
1772
|
+
return { ok: true, changed: true, basePatch: original };
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
console.warn("⚠️ 激活行的 relayDir 与当前配置目录不一致,但未能安全改写(保持原样)");
|
|
1776
|
+
}
|
|
1777
|
+
return { ok: true, changed: false, basePatch: original };
|
|
1778
|
+
}
|
|
1779
|
+
// 写前再次校验我们的块本身可解析(id 行必须能被 parsePatchInsertIds 认出来)
|
|
1780
|
+
// ⚠️ relayDir 必须传**配置目录**(CONFIG_DIR, 即 ~/.dsh-remote),不是插件安装目录:
|
|
1781
|
+
// 面板要读的是该目录下的 .dsh-config.json(账号/中继配置);传成 pluginLocalDir 会让面板
|
|
1782
|
+
// 对着插件源码目录找配置 → 界面显示成"未登录 / 空配置"(实测踩到)。
|
|
1783
|
+
const block = pluginBlock(CONFIG_DIR);
|
|
1784
|
+
if (!parsePatchInsertIds(block).ids.includes(PLUGIN_ID)) {
|
|
1785
|
+
return { ok: false, reason: "生成的激活块自身不合法", changed: false, basePatch: original };
|
|
1786
|
+
}
|
|
1787
|
+
const merged = `${base.cleaned ? base.cleaned + "\n" : ""}\n${block}\n`;
|
|
1788
|
+
// 写后校验:整份文件必须仍是干净的 insert 列表,且我们的 id 恰好出现一次
|
|
1789
|
+
const check = parsePatchInsertIds(merged);
|
|
1790
|
+
if (check.badLines.length || check.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
|
|
1791
|
+
return { ok: false, reason: "合并后的 patch 校验未通过(未做改动)", changed: false, basePatch: original };
|
|
1792
|
+
}
|
|
1793
|
+
writePatchDocument(patchFile, merged);
|
|
1794
|
+
const after = fs.readFileSync(patchFile, "utf8");
|
|
1795
|
+
const verify = parsePatchInsertIds(after);
|
|
1796
|
+
if (verify.badLines.length || verify.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
|
|
1797
|
+
writePatchDocument(patchFile, original); // 回滚
|
|
1798
|
+
return { ok: false, reason: "写入后校验失败,已回滚", changed: false, basePatch: original };
|
|
1799
|
+
}
|
|
1800
|
+
// ⚠️ 这里**不再** removeBundleEntry:能走到这行说明 bundles 里本来就没有本插件
|
|
1801
|
+
// (inBundles 为空的早退已保证),删是空操作;而且方向是反的 —— 见函数顶部注释。
|
|
1802
|
+
// 写前/写后双校验已经确保"我们的 id 在 patch 里恰好一次",这就是唯一激活点。
|
|
1803
|
+
return { ok: true, changed: true, basePatch: original };
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/** 清理 patch 中引用本插件的条目块(含前置注释/收尾标记)。返回是否发生变更。 */
|
|
1807
|
+
function stripIncludeEntries(patchFile, patch, reason) {
|
|
1808
|
+
const next = stripPluginEntries(patch);
|
|
1809
|
+
if (next === patch) return false;
|
|
1810
|
+
writePatchDocument(patchFile, next);
|
|
1811
|
+
console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活点必须唯一,避免重复 ID 崩溃)`);
|
|
1812
|
+
return true;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* 归一化 patch 基座:去掉 dsh 新 profile 默认的空文档占位行 `[]`。 * 默认 cordis.patch.yml 是「顶部注释 + []」,若保留 [] 再往下拼 `- insert:`,
|
|
1817
|
+
* YAML 会报 “end of the stream or a document separator is expected”(dsh web 启动即崩)。
|
|
1818
|
+
* 返回内容不含结尾换行;[] 行只在独立成行时视为占位,不影响真正的条目。
|
|
1819
|
+
*/
|
|
1820
|
+
function normalizePatchBase(patch) {
|
|
1821
|
+
return String(patch ?? "")
|
|
1822
|
+
.split("\n")
|
|
1823
|
+
.filter((l) => !/^\[\s*\]\s*$/.test(l))
|
|
1824
|
+
.join("\n")
|
|
1825
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1826
|
+
.trimEnd();
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* 把插件整目录拷贝进 profile 的固定子目录并返回该目录。
|
|
1831
|
+
* 源(本包 packages/${PLUGIN_ID})可能在 npx 临时缓存里,因此必须拷到 profile 内持久化,
|
|
1832
|
+
* include 用相对路径直接指向拷贝出的入口,不写依赖、不跑任何包管理器。
|
|
1833
|
+
*/
|
|
1834
|
+
function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
1835
|
+
// 清理历史名残留目录(≤0.4.9 的 dsh-remote-ui-plugin),避免与新版并存
|
|
1836
|
+
for (const id of PLUGIN_LEGACY_IDS) {
|
|
1837
|
+
try { fs.rmSync(path.join(profileDir, `${id}-plugin`), { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1838
|
+
}
|
|
1839
|
+
const dest = path.join(profileDir, PLUGIN_LOCAL_DIR);
|
|
1840
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
1841
|
+
fs.cpSync(pluginDir, dest, {
|
|
1842
|
+
recursive: true,
|
|
1843
|
+
// 注意:插件源(npx 缓存 / npm 全局)本身就位于某个 node_modules 之下,
|
|
1844
|
+
// 过滤必须基于“相对插件目录”的路径片段,绝不能整条 src 路径包含判断,
|
|
1845
|
+
// 否则会把插件文件全过滤掉、拷出空目录(此前反复“插件入口缺失”的总根因)。
|
|
1846
|
+
filter: (src) => {
|
|
1847
|
+
if (src === pluginDir) return true;
|
|
1848
|
+
const rel = path.relative(pluginDir, src);
|
|
1849
|
+
return !String(rel).split(path.sep).some((seg) => seg === "node_modules" || seg === ".git" || seg === ".DS_Store");
|
|
1850
|
+
}
|
|
1851
|
+
});
|
|
1852
|
+
return dest;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/** 把插件挂到 <profile>/node_modules/<PLUGIN_ID>(裸包名解析需要),指向拷贝目录;缺失/指错时重建。 */
|
|
1856
|
+
/**
|
|
1857
|
+
* 判定 `node_modules/<PLUGIN_ID>` 是否**真的可用**。
|
|
1858
|
+
*
|
|
1859
|
+
* 判据绝不是"函数有没有抛错" —— 这是 0.6.7-beta.1 在 Windows 上翻车的根因:
|
|
1860
|
+
* 非特权进程 + 开发者模式关闭时,`fs.symlinkSync(target, path, "dir")` 会**既不抛错也产不出可用链接**
|
|
1861
|
+
* (实测:用户目录下留下一个空目录;%TEMP% 下什么都不留;同一位置两次运行一次报成功一次抛 UNKNOWN)。
|
|
1862
|
+
* 于是"用 try/catch 探测能力"这个前提在 Windows 上根本不成立,写在其 catch 里的 junction 兜底永不执行。
|
|
1863
|
+
*
|
|
1864
|
+
* 这里只认 dsh 自己那条解析路径能不能走通:
|
|
1865
|
+
* ① 读得到 package.json 且 name 正确(cordis 加载器 + @deepseek-ai/dsh-client-modules 靠它读 dsh.client);
|
|
1866
|
+
* ② 读得到 lib/index.js(节点半入口);
|
|
1867
|
+
* ③ 要么 realpath 指回本地拷贝目录(链接形态),要么入口内容与本地拷贝逐字节一致(整目录拷贝形态)。
|
|
1868
|
+
*/
|
|
1869
|
+
function pluginLinkUsable(nmPlugin, pluginLocalDir) {
|
|
1870
|
+
try {
|
|
1871
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(nmPlugin, "package.json"), "utf8"));
|
|
1872
|
+
if (pkg.name !== PLUGIN_ID) return false;
|
|
1873
|
+
if (!fs.existsSync(path.join(nmPlugin, "lib", "index.js"))) return false;
|
|
1874
|
+
} catch { return false; }
|
|
1875
|
+
|
|
1876
|
+
let real = "";
|
|
1877
|
+
try { real = fs.realpathSync(nmPlugin); } catch { /* 悬空链接 */ }
|
|
1878
|
+
if (real) {
|
|
1879
|
+
try { if (real === fs.realpathSync(pluginLocalDir)) return true; } catch { /* 本地目录不在 → 看内容 */ }
|
|
1880
|
+
}
|
|
1881
|
+
try {
|
|
1882
|
+
return fs.readFileSync(path.join(nmPlugin, "lib", "index.js"), "utf8")
|
|
1883
|
+
=== fs.readFileSync(path.join(pluginLocalDir, "lib", "index.js"), "utf8");
|
|
1884
|
+
} catch { return false; }
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/**
|
|
1888
|
+
* 把插件挂到 `<profile>/node_modules/<PLUGIN_ID>`。
|
|
1889
|
+
*
|
|
1890
|
+
* 规格:已就绪返回 false(幂等)、重建成功返回 true、**任何情况下都没能挂好就抛错**。
|
|
1891
|
+
* 抛错是刻意的 —— 调用方必须据此中止安装:写进 patch 的激活行会让 dsh web 按裸包名解析这个目录,
|
|
1892
|
+
* 解析不到就 `Cannot find package '...\node_modules\dsh-remote-web\index.js'`,
|
|
1893
|
+
* 而 cordis 的插件树加载失败 = **整个 dsh web 起不来**(用户连面板都打不开,没有自助修复入口)。
|
|
1894
|
+
* 宁可不装,也不能写出一个起不来的 profile。
|
|
1895
|
+
*/
|
|
1896
|
+
function ensurePluginLinked(profileDir, pluginLocalDir) {
|
|
1897
|
+
const nmDir = path.join(profileDir, "node_modules");
|
|
1898
|
+
const nmPlugin = path.join(nmDir, PLUGIN_ID);
|
|
1899
|
+
if (!fs.existsSync(path.join(pluginLocalDir, "package.json"))) {
|
|
1900
|
+
throw new Error(`本地插件拷贝缺失或不完整:${pluginLocalDir}(应含 package.json)`);
|
|
1901
|
+
}
|
|
1902
|
+
fs.mkdirSync(nmDir, { recursive: true });
|
|
1903
|
+
// 清理历史名链接(node_modules/dsh-remote-ui),避免解析到旧拷贝
|
|
1904
|
+
for (const id of PLUGIN_LEGACY_IDS) {
|
|
1905
|
+
try { fs.rmSync(path.join(nmDir, id), { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1906
|
+
}
|
|
1907
|
+
if (pluginLinkUsable(nmPlugin, pluginLocalDir)) return false; // 已就绪(拷贝形态也算就绪)
|
|
1908
|
+
|
|
1909
|
+
// ⚠️ Windows 上**不要用 symlinkSync(...,'dir')**:非特权进程下它产不出可用链接,
|
|
1910
|
+
// 而且抛不抛错不确定、失败时还会留下空目录残留 —— 兜底链会因此整条失效(实测)。
|
|
1911
|
+
// junction(目录联接)免特权、realpath 正确、require.resolve 与 ESM 裸包名解析都正常。
|
|
1912
|
+
const attempts = process.platform === "win32"
|
|
1913
|
+
? [
|
|
1914
|
+
["junction", () => fs.symlinkSync(path.resolve(pluginLocalDir), nmPlugin, "junction")], // junction 只接受绝对路径
|
|
1915
|
+
["copy", () => fs.cpSync(pluginLocalDir, nmPlugin, { recursive: true })],
|
|
1916
|
+
]
|
|
1917
|
+
: [
|
|
1918
|
+
["dir", () => fs.symlinkSync(pluginLocalDir, nmPlugin, "dir")],
|
|
1919
|
+
["copy", () => fs.cpSync(pluginLocalDir, nmPlugin, { recursive: true })],
|
|
1920
|
+
];
|
|
1921
|
+
|
|
1922
|
+
const failures = [];
|
|
1923
|
+
for (const [how, run] of attempts) {
|
|
1924
|
+
// 每支开始前先清残留:上一支"没抛错但留了个空目录"时,不清就会让下一支撞 EEXIST 而失败
|
|
1925
|
+
//(这正是原实现三级兜底退化成一级的原因)。
|
|
1926
|
+
try { fs.rmSync(nmPlugin, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1927
|
+
let err = null;
|
|
1928
|
+
try { run(); } catch (e) { err = e; }
|
|
1929
|
+
if (pluginLinkUsable(nmPlugin, pluginLocalDir)) return true;
|
|
1930
|
+
failures.push(`${how}: ${err ? err.message : "未抛错但落盘结果不可用(读不到 package.json / lib/index.js)"}`);
|
|
1931
|
+
}
|
|
1932
|
+
throw new Error(`插件挂载失败:${nmPlugin}\n ${failures.join("\n ")}`);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* 独立验收:复刻 dsh 客户端模块系统的真实动作 —— 从 profile 目录按**裸包名**解析到本插件。
|
|
1938
|
+
*
|
|
1939
|
+
* 与 pluginLinkUsable 是两道独立的闸门:前者看文件在不在、是不是真指向目标;
|
|
1940
|
+
* 这条走 Node 的解析器(exports 字段、symlink 跟随、pnpm 布局),是"面板能不能被注入"的前提
|
|
1941
|
+
* (@deepseek-ai/dsh-client-modules 就是 require.resolve('<name>/package.json') 读 dsh.client)。
|
|
1942
|
+
*/
|
|
1943
|
+
function probePluginResolve(profileDir) {
|
|
1944
|
+
const base = path.join(profileDir, "__dsh_resolve_probe.cjs");
|
|
1945
|
+
// 必须**另起一个 node 进程**做这次解析,不能在本进程里 require.resolve:
|
|
1946
|
+
// 实测(Node 25 / macOS):本进程里只要先失败过一次裸包名解析(例如"修复前诊断"那次),
|
|
1947
|
+
// 链接随后修好了,同一个进程里的裸包名解析**仍会持续失败**(子路径却正常)——
|
|
1948
|
+
// 进程内的解析缓存会把假阴性一直带下去,让"修复后验收"误判成没修好、拒绝恢复激活。
|
|
1949
|
+
// 另起进程同时也更忠实:dsh 启动时就是在一个全新进程里解析这些裸包名的。
|
|
1950
|
+
const script = [
|
|
1951
|
+
'const { createRequire } = require("node:module");',
|
|
1952
|
+
`const req = createRequire(${JSON.stringify(base)});`,
|
|
1953
|
+
"process.stdout.write(JSON.stringify({",
|
|
1954
|
+
` pkgPath: req.resolve(${JSON.stringify(`${PLUGIN_ID}/package.json`)}),`,
|
|
1955
|
+
` entry: req.resolve(${JSON.stringify(PLUGIN_ID)})`,
|
|
1956
|
+
"}));",
|
|
1957
|
+
].join("\n");
|
|
1958
|
+
try {
|
|
1959
|
+
const r = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 30_000, windowsHide: true });
|
|
1960
|
+
if (r.error) return { ok: false, reason: `无法启动解析探测进程:${r.error.message}` };
|
|
1961
|
+
if (r.status !== 0) {
|
|
1962
|
+
const line = String(r.stderr || "").split("\n").find((l) => /MODULE_NOT_FOUND|ERR_|Error/.test(l)) || "解析失败";
|
|
1963
|
+
return { ok: false, reason: line.trim().slice(0, 200) };
|
|
1964
|
+
}
|
|
1965
|
+
const out = JSON.parse(String(r.stdout || "{}"));
|
|
1966
|
+
const pkg = JSON.parse(fs.readFileSync(out.pkgPath, "utf8"));
|
|
1967
|
+
return {
|
|
1968
|
+
ok: true,
|
|
1969
|
+
pkgPath: out.pkgPath,
|
|
1970
|
+
entry: out.entry,
|
|
1971
|
+
name: pkg.name,
|
|
1972
|
+
clientPlatform: (pkg.dsh && pkg.dsh.client && pkg.dsh.client.platform) || "",
|
|
1973
|
+
};
|
|
1974
|
+
} catch (e) {
|
|
1975
|
+
return { ok: false, reason: `${e.code || e.name}: ${e.message}` };
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
/**
|
|
1980
|
+
* 描述插件在 profile 里的挂载形态(供 status / repair 输出)。
|
|
1981
|
+
* 用户报障时这三行就能区分"链接坏了"与"别的毛病"——不必再靠猜。
|
|
1982
|
+
* @returns {{localExists:boolean, exists:boolean, usable:boolean, kind:"missing"|"broken"|"linked"|"copy"|"no-local-copy"}}
|
|
1983
|
+
*/
|
|
1984
|
+
function describePluginLink(profileDir) {
|
|
1985
|
+
const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
|
|
1986
|
+
const localDir = path.join(profileDir, PLUGIN_LOCAL_DIR);
|
|
1987
|
+
const localExists = fs.existsSync(path.join(localDir, "package.json"));
|
|
1988
|
+
if (!fs.existsSync(nmPlugin)) return { localExists, exists: false, usable: false, kind: localExists ? "missing" : "no-local-copy" };
|
|
1989
|
+
const usable = pluginLinkUsable(nmPlugin, localDir);
|
|
1990
|
+
let isLink = false;
|
|
1991
|
+
try { isLink = fs.lstatSync(nmPlugin).isSymbolicLink(); } catch { /* ignore */ }
|
|
1992
|
+
return { localExists, exists: true, usable, kind: usable ? (isLink ? "linked" : "copy") : "broken" };
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
/** package.json 写入 file: 依赖(让后续任何 pnpm/npm install 也认可该插件,不会删链接)。 */
|
|
1996
|
+
function declarePluginDep(pkgFile) {
|
|
1997
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
1998
|
+
pkg.dependencies = pkg.dependencies || {};
|
|
1999
|
+
for (const id of PLUGIN_LEGACY_IDS) { delete pkg.dependencies[id]; }
|
|
2000
|
+
pkg.dependencies[PLUGIN_ID] = `file:./${PLUGIN_LOCAL_DIR}`;
|
|
2001
|
+
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
/** 把 PLUGIN_ID 加入 dsh.profile.bundles(幂等;顺带移除历史名条目)。返回是否发生变更。 */
|
|
2005
|
+
function ensureBundleEntry(pkgFile) {
|
|
2006
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
2007
|
+
const bundles = pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles)
|
|
2008
|
+
? pkg.dsh.profile.bundles
|
|
2009
|
+
: null;
|
|
2010
|
+
if (bundles && bundles.includes(PLUGIN_ID)) {
|
|
2011
|
+
const hadLegacy = bundles.some((b) => PLUGIN_LEGACY_IDS.includes(b));
|
|
2012
|
+
if (hadLegacy) {
|
|
2013
|
+
pkg.dsh.profile.bundles = bundles.filter((b) => !PLUGIN_LEGACY_IDS.includes(b));
|
|
2014
|
+
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
2015
|
+
return true;
|
|
2016
|
+
}
|
|
2017
|
+
return false;
|
|
2018
|
+
}
|
|
2019
|
+
pkg.dsh = pkg.dsh || {};
|
|
2020
|
+
pkg.dsh.profile = pkg.dsh.profile || {};
|
|
2021
|
+
pkg.dsh.profile.bundles = (bundles || []).filter((b) => !PLUGIN_LEGACY_IDS.includes(b) && b !== PLUGIN_ID);
|
|
2022
|
+
pkg.dsh.profile.bundles.push(PLUGIN_ID);
|
|
2023
|
+
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
2024
|
+
return true;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/** 从 dsh.profile.bundles 移除本插件(当前或历史名,幂等)。返回是否发生变更。 */
|
|
2028
|
+
function removeBundleEntry(pkgFile) {
|
|
2029
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
2030
|
+
const bundles = pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles)
|
|
2031
|
+
? pkg.dsh.profile.bundles
|
|
2032
|
+
: null;
|
|
2033
|
+
if (!bundles) return false;
|
|
2034
|
+
const filtered = bundles.filter((b) => !PLUGIN_ALL_IDS.includes(b));
|
|
2035
|
+
if (filtered.length === bundles.length) return false;
|
|
2036
|
+
pkg.dsh.profile.bundles = filtered;
|
|
2037
|
+
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
2038
|
+
return true;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* 把我们手上这份插件包落到 profile 并激活。
|
|
2043
|
+
*
|
|
2044
|
+
* ★ 2026-09-22 方向反转:激活点**优先由 `dsh.profile.bundles` 承担**,我们不再写 patch 行。
|
|
2045
|
+
* 旧实现为了"装完即热加载"而摘掉 bundles、改走 patch 行;但**自动维护流程会把插件自带
|
|
2046
|
+
* bundle 周期性写回** bundles,于是"删了又回来"→ 两处激活 → dsh web 启动即
|
|
2047
|
+
* `duplicate loader entry id: dsh-remote-web` 崩树。
|
|
2048
|
+
* 现在稳定方向 = **bundle 归包管理器管、我们不动它**;代价是失去热加载,
|
|
2049
|
+
* 由**安装器随后触发的重启**来让新版生效(`hotPatch:false` → 调用方会重启)。
|
|
2050
|
+
* @returns {{hotPatch:boolean, changed:boolean, basePatch?:string}|undefined}
|
|
2051
|
+
*/
|
|
2052
|
+
function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg, opts = {}) {
|
|
2053
|
+
const pluginLocalDir = copyPluginIntoProfile(profileDir, pluginDir);
|
|
2054
|
+
// forcePatch 由 ensurePatchActivation 内部处理(摘 bundles → 写 patch 行),
|
|
2055
|
+
// 这样不依赖调用顺序:源码既已换成本地这份,激活点就必须跟着改,否则装完仍看不到新版。
|
|
2056
|
+
|
|
2057
|
+
const entryFile = path.join(pluginLocalDir, "lib", "index.js");
|
|
2058
|
+
if (!fs.existsSync(entryFile)) {
|
|
2059
|
+
console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
|
|
2060
|
+
process.exit(1);
|
|
2061
|
+
}
|
|
2062
|
+
// ⚠️ 顺序与"失败即中止"都是刻意的(0.6.7-beta.1 Windows 实测事故):
|
|
2063
|
+
// 老代码把 ensurePluginLinked 的返回值丢掉、patch 照写、还打印「✅ 插件已就绪」,
|
|
2064
|
+
// 于是用户拿到的是**起不来的 dsh web**(cordis 按裸包名解析到空目录 → plugin tree failed to load,
|
|
2065
|
+
// 整个 dsh web 启动失败,而面板/插件市场都在那个进程里 → 用户没有任何自助修复入口)。
|
|
2066
|
+
// 现在:先挂载 → 独立验收 → 才写依赖与激活行;任何一步不过就中止,绝不留下坏 profile。
|
|
2067
|
+
try {
|
|
2068
|
+
ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
|
|
2069
|
+
} catch (e) {
|
|
2070
|
+
console.error(`❌ ${e.message}`);
|
|
2071
|
+
console.error(" 已中止安装:继续写 patch 会让 dsh web 因插件树加载失败而完全无法启动。");
|
|
2072
|
+
console.error(" 请把上面的错误整段反馈给作者(含平台 / 是否管理员 / 开发者模式是否开启)。");
|
|
2073
|
+
process.exit(1);
|
|
2074
|
+
}
|
|
2075
|
+
const probe = probePluginResolve(profileDir);
|
|
2076
|
+
if (!probe.ok) {
|
|
2077
|
+
console.error(`❌ 验收失败:dsh 无法从 profile 按裸包名解析 ${PLUGIN_ID}(${probe.reason})`);
|
|
2078
|
+
console.error(" 已中止安装(未写 patch):否则 dsh web 会因插件树加载失败而完全无法启动。");
|
|
2079
|
+
process.exit(1);
|
|
2080
|
+
}
|
|
2081
|
+
declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
|
|
2082
|
+
const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, { forcePatch: !!opts.forcePatch });
|
|
2083
|
+
if (r.ok) {
|
|
2084
|
+
// 走到这里 = 插件**不在** bundles,所以 patch 行是唯一激活点,可以热加载。
|
|
2085
|
+
console.log(`✅ 插件已就绪: ${pluginLocalDir}(激活点为本插件的 patch 行,装完即热加载)`);
|
|
2086
|
+
return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
|
|
2087
|
+
}
|
|
2088
|
+
if (r.bundleOnly) {
|
|
2089
|
+
// 稳定形态:bundle 归包管理器管,我们不写第二个激活点。代价是无热加载 → 调用方会重启。
|
|
2090
|
+
console.log("ℹ 插件已由 dsh.profile.bundles 激活(保持单一激活点,不重复写 patch 行;重启后生效)");
|
|
2091
|
+
} else {
|
|
2092
|
+
console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
|
|
2093
|
+
ensureBundleEntry(pkgFile);
|
|
2094
|
+
}
|
|
2095
|
+
return { hotPatch: false, changed: true, basePatch: patch };
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
/**
|
|
2099
|
+
* 插件安装(pluginCmd 非卸载分支)收敛策略 —— 2026-09-06「重复 ID 崩溃」根治;
|
|
2100
|
+
* 2026-09 插件由 dsh-remote-ui 更名 dsh-remote-web,本函数同时兼容清理旧名残留。
|
|
2101
|
+
*
|
|
2102
|
+
* ⚠️ 2026-09-13 调整:「恰好一处激活」仍然成立,但**激活点二选一**:
|
|
2103
|
+
* · dsh-setup 安装(file: 依赖 + 自建 node_modules 链接)→ 激活点 = 用户级 cordis.patch.yml
|
|
2104
|
+
* 的一行 insert。理由:harness 的 web profile 是 `patchReload: "live"`,会加载
|
|
2105
|
+
* `@deepseek-ai/cordis-plugin-hmr` 监听该文件,**存盘后约 1 秒重新 compose 并动态装载**,
|
|
2106
|
+
* 用户不需要重启 dsh web(只需刷新页面拿到浏览器半)。插件市场自己也是这套机制。
|
|
2107
|
+
* · 插件市场安装(github:/npm: 依赖,源码归市场管)→ 激活点 = dsh.profile.bundles
|
|
2108
|
+
* (插件自带 bundle patch)。bundles 只在启动时读、没有 watcher,所以这一路仍需重启。
|
|
2109
|
+
* 两条路都**只保留一个激活点**:写 patch 就同时从 bundles 移除,反之清掉 include,
|
|
2110
|
+
* 绝不两处并存(那正是历史上 dsh web 启动即报「重复 ID」崩溃的原因)。
|
|
2111
|
+
*/
|
|
2112
|
+
|
|
2113
|
+
/** 读取 profile 内已安装插件的版本(市场/包管理器装法)。 */
|
|
2114
|
+
function readInstalledPluginVersion(profileDir, name) {
|
|
2115
|
+
try {
|
|
2116
|
+
return JSON.parse(fs.readFileSync(path.join(profileDir, "node_modules", name, "package.json"), "utf8")).version;
|
|
2117
|
+
} catch { return null; }
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/**
|
|
2121
|
+
* 识别 profile 用哪个包管理器(市场装法「一键更新」要用它改源码)。
|
|
2122
|
+
*
|
|
2123
|
+
* ⚠️ 不能用 `sh("command -v pnpm && echo pnpm || echo npm")`:`sh()` 走 execSync,
|
|
2124
|
+
* Windows 下是 cmd.exe,而 `command` 是 POSIX 内建 → 永远命中 `||` 分支,
|
|
2125
|
+
* **Windows 上永远选 npm**。而 dsh profile 是 pnpm 管理的(pnpm-workspace.yaml + 虚拟 store),
|
|
2126
|
+
* 用 npm 去改同一个 node_modules 会写出与 pnpm 不同的布局(污染/冲突风险)。
|
|
2127
|
+
* 现在:先用**声明文件**判断(最可靠、零子进程),再用 where/which 探测可执行文件;
|
|
2128
|
+
* 兜底选 pnpm 而不是 npm —— dsh profile 默认就是 pnpm,选错方向的代价不对称。
|
|
2129
|
+
*/
|
|
2130
|
+
function detectPackageManager(profileDir) {
|
|
2131
|
+
try {
|
|
2132
|
+
if (fs.existsSync(path.join(profileDir, "pnpm-lock.yaml"))
|
|
2133
|
+
|| fs.existsSync(path.join(profileDir, "pnpm-workspace.yaml"))) return "pnpm";
|
|
2134
|
+
if (fs.existsSync(path.join(profileDir, "package-lock.json"))) return "npm";
|
|
2135
|
+
} catch { /* 读不到就往下探测 */ }
|
|
2136
|
+
const has = (bin) => {
|
|
2137
|
+
try {
|
|
2138
|
+
return process.platform === "win32"
|
|
2139
|
+
? spawnSync("where", [bin], { stdio: "ignore", windowsHide: true }).status === 0
|
|
2140
|
+
: spawnSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" }).status === 0;
|
|
2141
|
+
} catch { return false; }
|
|
2142
|
+
};
|
|
2143
|
+
if (has("pnpm")) return "pnpm";
|
|
2144
|
+
if (has("npm")) return "npm";
|
|
2145
|
+
return "pnpm";
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/**
|
|
2149
|
+
* 市场装法下把插件包升到最新。
|
|
2150
|
+
*
|
|
2151
|
+
* 2026-09-13 实测的坑:用户在插件面板点「一键更新」时,本函数原先的调用方(marketManaged 分支)
|
|
2152
|
+
* 只清 include、不动源码,日志写着"已保持市场管理的源码不变",命令 exit 0、界面显示"安装完成",
|
|
2153
|
+
* 但插件包始终停在旧版本 —— 用户看到的就是"一键更新到不了最新版"。
|
|
2154
|
+
*
|
|
2155
|
+
* 规格判断:registry 规格(^0.6.3 / 0.6.3 / >=…)用 `add <name>@latest`;
|
|
2156
|
+
* github:/git+/file:/https: 之类用 `update <name>` 让包管理器重新解析引用(例如 git HEAD)。
|
|
2157
|
+
*/
|
|
2158
|
+
function upgradeMarketManagedPlugin(profileDir, name, dep) {
|
|
2159
|
+
const spec = String(dep ?? "").trim();
|
|
2160
|
+
const isRegistryRange = /^[\^~><=v\d]/.test(spec);
|
|
2161
|
+
const pm = detectPackageManager(profileDir);
|
|
2162
|
+
const args = isRegistryRange ? `add ${name}@latest` : `update ${name}`;
|
|
2163
|
+
const cmd = `${pm} ${args}`;
|
|
2164
|
+
const r = sh(cmd, 180000, profileDir);
|
|
2165
|
+
if (!r.ok) console.warn(`⚠️ ${cmd} 失败:${(r.stderr || r.stdout || "").trim().split("\n").slice(-3).join(" / ")}`);
|
|
2166
|
+
return { ok: r.ok, cmd };
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch) {
|
|
2170
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
2171
|
+
const legacyDep = PLUGIN_LEGACY_IDS.map((id) => pkg.dependencies && pkg.dependencies[id]).find((v) => v !== undefined);
|
|
2172
|
+
const dep = (pkg.dependencies && pkg.dependencies[PLUGIN_ID]) ?? legacyDep;
|
|
2173
|
+
const inBundles = !!(pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles)
|
|
2174
|
+
&& pkg.dsh.profile.bundles.some((b) => PLUGIN_ALL_IDS.includes(b)));
|
|
2175
|
+
const marketManaged = dep && !String(dep).startsWith("file:"); // github:/npm: 等由市场/包管理器管源码
|
|
2176
|
+
const managedByUs = !dep || String(dep).startsWith("file:"); // 无依赖或 file: 拷贝 → 我们管
|
|
2177
|
+
|
|
2178
|
+
if (marketManaged && inBundles) {
|
|
2179
|
+
// 插件市场安装形态:源码归市场/包管理器管 → 只清历史 include,但**必须主动升级到最新**,
|
|
2180
|
+
// 否则「一键更新」会报成功却什么都没做(2026-09-13 实测)。
|
|
2181
|
+
stripIncludeEntries(patchFile, patch, "插件市场安装形态无需用户 include");
|
|
2182
|
+
const depName = PLUGIN_ALL_IDS.find((id) => pkg.dependencies && pkg.dependencies[id] !== undefined) || PLUGIN_ID;
|
|
2183
|
+
const before = readInstalledPluginVersion(profileDir, depName);
|
|
2184
|
+
// ⚠️ 关键:装置器**手上这份包的版本**才是权威(用户跑的就是它)。
|
|
2185
|
+
// 以前这里无条件"去 npm 装 market 渠道的最新版",于是本地跑 0.6.6-beta.2 时,
|
|
2186
|
+
// profile 里市场装的 0.6.5 被判成"已是最新"→ 什么都不做(测试自己就踩到:
|
|
2187
|
+
// 「✅ 插件包已是市场最新版(0.6.5)」,明明带的是 beta)。
|
|
2188
|
+
// 现在:本地严格更新 → 直接落本地包(顺带把激活点换成 patch 行,拿到热加载);
|
|
2189
|
+
// 相等 → 不做;本地更旧 → 不动(绝不降级)。
|
|
2190
|
+
const localVer = pkgVersion();
|
|
2191
|
+
const cmpLocal = compareVersionStrings(localVer, before);
|
|
2192
|
+
if (cmpLocal !== null && cmpLocal > 0) {
|
|
2193
|
+
console.log(`ℹ 本地包更新(${before ?? "未装"} → ${localVer}),落本地版本并改用热加载激活…`);
|
|
2194
|
+
return activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg, { forcePatch: true });
|
|
2195
|
+
}
|
|
2196
|
+
if (cmpLocal !== null && cmpLocal < 0) {
|
|
2197
|
+
console.log(`ℹ profile 里已是更新版本(${before} > ${localVer}),保持不动(不降级)。`);
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
console.log(`ℹ 插件市场安装形态(bundles+dependency):源码归包管理器管,正在升级到最新…(安装前 ${before ?? "未知"})`);
|
|
2201
|
+
const { ok, cmd } = upgradeMarketManagedPlugin(profileDir, depName, dep);
|
|
2202
|
+
const after = readInstalledPluginVersion(profileDir, depName);
|
|
2203
|
+
if (ok && after && after !== before) {
|
|
2204
|
+
console.log(`✅ 插件包已升级:${before ?? "?"} → ${after}`);
|
|
2205
|
+
console.log(" 该形态经 dsh.profile.bundles 激活,只在启动时读取 —— 请重启 dsh web 生效。");
|
|
2206
|
+
} else if (ok && after) {
|
|
2207
|
+
console.log(`✅ 插件包已是市场最新版(${after})。`);
|
|
2208
|
+
console.log(" 若面板仍显示旧版本,请重启 dsh web(bundles 只在启动时读取)。");
|
|
2209
|
+
} else {
|
|
2210
|
+
console.log(`⚠️ 插件包升级未成功(当前 ${after ?? "未知"})。可手动执行:`);
|
|
2211
|
+
console.log(` cd ${profileDir} && ${cmd}`);
|
|
2212
|
+
}
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
// 源码归我们(无依赖或 file: 依赖)→ 必然用本地这份,激活点也必须是 patch 行(热加载)。
|
|
2217
|
+
if (managedByUs) return activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg, { forcePatch: true });
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
// 异常形态:有非 file: 依赖但不在 bundles(无法靠 bundle patch 激活)
|
|
2221
|
+
console.log(`ℹ 检测到依赖 ${PLUGIN_ID}(${dep}) 但未声明在 dsh.profile.bundles——插件不会激活。`);
|
|
2222
|
+
console.log(" 请在 dsh 插件市场重新添加该插件,或先执行 `dsh-remote plugin --uninstall` 再一键安装。");
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
async function pluginCmd(argv) {
|
|
2226
|
+
const uninstall = hasFlag(argv, "--uninstall");
|
|
2227
|
+
const profileDir = resolveProfileDir(argv);
|
|
2228
|
+
const pkgFile = path.join(profileDir, "package.json");
|
|
2229
|
+
const patchFile = path.join(profileDir, "cordis.patch.yml");
|
|
2230
|
+
const pluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
|
|
2231
|
+
|
|
2232
|
+
if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
|
|
2233
|
+
console.error(`❌ 未找到 dsh web profile(${profileDir})。`);
|
|
2234
|
+
console.error(" 请先安装 DeepSeek Harness(npx @deepseek-ai/dsh web)并初始化默认 profile。");
|
|
2235
|
+
process.exit(1);
|
|
2236
|
+
}
|
|
2237
|
+
if (!fs.existsSync(pluginDir)) {
|
|
2238
|
+
console.error(`❌ 本包缺少 packages/${PLUGIN_ID}(${pluginDir})。`);
|
|
2239
|
+
process.exit(1);
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
const patch = fs.readFileSync(patchFile, "utf8");
|
|
2243
|
+
|
|
2244
|
+
if (uninstall) {
|
|
2245
|
+
// 移除 patch 中的插件条目(兼容旧版无标记条目;旧名 dsh-remote-ui 一并清理)
|
|
2246
|
+
// 用原子写回:卸载同样不能在 profile 配置上留下半截文件
|
|
2247
|
+
if (!stripIncludeEntries(patchFile, patch, "卸载清理")) {
|
|
2248
|
+
console.log(`ℹ patch 中未发现 ${PLUGIN_ID}(或历史名)条目。`);
|
|
2249
|
+
}
|
|
2250
|
+
// 清理本地目录与链接:当前名 + 历史名(dsh-remote-ui-plugin / node_modules/dsh-remote-ui)
|
|
2251
|
+
for (const id of PLUGIN_ALL_IDS) {
|
|
2252
|
+
fs.rmSync(path.join(profileDir, `${id}-plugin`), { recursive: true, force: true });
|
|
2253
|
+
fs.rmSync(path.join(profileDir, "node_modules", id), { recursive: true, force: true });
|
|
2254
|
+
}
|
|
2255
|
+
try {
|
|
2256
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
2257
|
+
let removed = false;
|
|
2258
|
+
for (const id of PLUGIN_ALL_IDS) {
|
|
2259
|
+
if (pkg.dependencies && pkg.dependencies[id] !== undefined) { delete pkg.dependencies[id]; removed = true; }
|
|
2260
|
+
}
|
|
2261
|
+
if (removed) fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
2262
|
+
} catch { /* ignore */ }
|
|
2263
|
+
// 同步移除 bundles 声明,避免“bundles 引用已删除包 → dsh web 启动报错”
|
|
2264
|
+
try {
|
|
2265
|
+
if (removeBundleEntry(pkgFile)) console.log(`✅ 已从 dsh.profile.bundles 移除 ${PLUGIN_ID}`);
|
|
2266
|
+
} catch { /* ignore */ }
|
|
2267
|
+
console.log("✅ 卸载完成。重启 dsh web 生效。");
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// 安装:收敛到"恰好一处激活"(热挂载 patch 行 或 bundles,二选一),绝不与市场/历史 include 并存
|
|
2272
|
+
//
|
|
2273
|
+
// ⚠️ 必须 **return 结果**:setup() 依赖它判断激活形态(false/undefined = 走 bundles,需重启),
|
|
2274
|
+
// 少了这个 return,安装后就不会去验证热挂载,白白多报一次"需要重启"(实测踩到)。
|
|
2275
|
+
return convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch);
|
|
2276
|
+
|
|
2277
|
+
// 引导语只在 setup 汇总里打印一次(这里不再重复;单独跑 `dsh-remote plugin` 也无需引导)
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
/**
|
|
2281
|
+
* 打印插件挂载诊断三行(供 status / repair 共用)。
|
|
2282
|
+
* 用户报障时这三行就能区分「链接坏了」与「别的毛病」,不必靠猜:
|
|
2283
|
+
* link = 有没有真挂上(junction / 符号链接 / 整目录拷贝 / BROKEN)
|
|
2284
|
+
* resolve = dsh 的裸包名解析能不能走通(浏览器半注入面板就靠它)
|
|
2285
|
+
* client = dsh.client.platform 声明在不在
|
|
2286
|
+
*/
|
|
2287
|
+
function printPluginDiagnostics(profileDir) {
|
|
2288
|
+
const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
|
|
2289
|
+
const link = describePluginLink(profileDir);
|
|
2290
|
+
const probe = probePluginResolve(profileDir);
|
|
2291
|
+
const kindText = { linked: "已挂载(链接)", copy: "已挂载(整目录拷贝)", broken: "BROKEN(存在但读不到内容)", missing: "缺失", "no-local-copy": "缺失(本地插件目录也没有)" }[link.kind] || link.kind;
|
|
2292
|
+
console.log(` [link] ${nmPlugin}\n → ${kindText}`);
|
|
2293
|
+
console.log(` [resolve] ${PLUGIN_ID}/package.json → ${probe.ok ? probe.pkgPath : `失败(${probe.reason})`}`);
|
|
2294
|
+
console.log(` [client] dsh.client.platform = ${probe.ok ? (probe.clientPlatform || "缺失") : "(解析失败,无法确认)"}`);
|
|
2295
|
+
return { link, probe };
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
/**
|
|
2299
|
+
* `dsh-remote repair` —— 只修 profile 里的插件挂载,不碰运行环境 / 不联网 / 不碰自启动。
|
|
2300
|
+
*
|
|
2301
|
+
* 为什么需要这个独立入口:插件挂载坏掉时 dsh web **整个起不来**(cordis 插件树加载失败),
|
|
2302
|
+
* 而设置面板与插件市场都在那个进程里 —— 用户没有任何自助修复入口,只剩命令行。
|
|
2303
|
+
* 执行顺序刻意是"先保命、再修复、最后恢复激活":
|
|
2304
|
+
* ① patch 里引用了本插件但挂载不可用 → **先把 include 摘掉**(profile 立刻恢复可启动);
|
|
2305
|
+
* ② 再重建挂载(失败会抛错,但①的结果保留 → 用户至少能打开 dsh web);
|
|
2306
|
+
* ③ 只有①②都成功,才把激活行写回去。
|
|
2307
|
+
*/
|
|
2308
|
+
function repairProfile(argv = []) {
|
|
2309
|
+
const profileDir = resolveProfileDir(argv);
|
|
2310
|
+
const pkgFile = path.join(profileDir, "package.json");
|
|
2311
|
+
const patchFile = path.join(profileDir, "cordis.patch.yml");
|
|
2312
|
+
const localDir = path.join(profileDir, PLUGIN_LOCAL_DIR);
|
|
2313
|
+
if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
|
|
2314
|
+
console.error(`❌ 未找到 dsh web profile(${profileDir})。`);
|
|
2315
|
+
console.error(" 先确认 dsh web 装好并初始化过 profile,或用 --profile <目录> 指定。");
|
|
2316
|
+
process.exit(1);
|
|
2317
|
+
}
|
|
2318
|
+
console.log(`dsh-remote repair — ${profileDir}`);
|
|
2319
|
+
const before = printPluginDiagnostics(profileDir);
|
|
2320
|
+
if (!before.link.localExists) {
|
|
2321
|
+
console.error(`❌ 本地插件目录缺失(${localDir}):无法就地修复。`);
|
|
2322
|
+
console.error(" 请重新安装:npx @mrrisega/dsh-remote@latest");
|
|
2323
|
+
process.exit(1);
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
const patch = fs.readFileSync(patchFile, "utf8");
|
|
2327
|
+
const referenced = parsePatchInsertIds(patch).ids.some((id) => PLUGIN_ALL_IDS.includes(id));
|
|
2328
|
+
if (referenced && !before.link.usable) {
|
|
2329
|
+
// 保命动作:先摘掉激活行,让 dsh web 至少能启动(此时插件不可用,但用户进得去、能重装)
|
|
2330
|
+
stripIncludeEntries(patchFile, patch, "挂载不可用,先摘掉 include 保命");
|
|
2331
|
+
try { removeBundleEntry(pkgFile); } catch { /* ignore */ }
|
|
2332
|
+
console.log("🛟 挂载不可用 → 已先摘掉插件激活行(dsh web 现在应该能启动了)。");
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
try {
|
|
2336
|
+
ensurePluginLinked(profileDir, localDir);
|
|
2337
|
+
} catch (e) {
|
|
2338
|
+
console.error(`❌ 重建挂载失败:${e.message}`);
|
|
2339
|
+
console.error(" profile 已处于「能启动、插件未激活」状态;请把上面错误反馈给作者。");
|
|
2340
|
+
process.exit(1);
|
|
2341
|
+
}
|
|
2342
|
+
const after = printPluginDiagnostics(profileDir);
|
|
2343
|
+
if (!after.probe.ok) {
|
|
2344
|
+
console.error("❌ 挂载已重建,但 dsh 仍解析不到本插件 —— 请把上面的输出反馈给作者。");
|
|
2345
|
+
process.exit(1);
|
|
2346
|
+
}
|
|
2347
|
+
const srcPluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
|
|
2348
|
+
if (!fs.existsSync(srcPluginDir)) {
|
|
2349
|
+
console.error(`❌ 本包缺少 packages/${PLUGIN_ID},无法就地恢复激活行。`);
|
|
2350
|
+
console.error(" 请重新安装:npx @mrrisega/dsh-remote@latest");
|
|
2351
|
+
process.exit(1);
|
|
2352
|
+
}
|
|
2353
|
+
convergePluginActivation(profileDir, pkgFile, patchFile, srcPluginDir, fs.readFileSync(patchFile, "utf8"));
|
|
2354
|
+
console.log("✅ 挂载与激活已修复 —— 重启一次 dsh web 生效。");
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
// ---------- main ----------
|
|
2358
|
+
// 无命令名(或首个参数以 - 开头)时默认执行 setup —— 这样 `dsh-remote --no-autostart`
|
|
2359
|
+
// 这类「只给 flag」的用法才能装上去。
|
|
2360
|
+
//
|
|
2361
|
+
// 但有两个例外必须挡在前面:
|
|
2362
|
+
// ① -h/--help:用户想先看用法,绝不该顺手把东西装上(此前会走 setup,直接安装);
|
|
2363
|
+
// ② 不认识的 flag:拼错参数时静默安装比报错更糟,宁可停下来说清楚。
|
|
2364
|
+
const raw = process.argv[2];
|
|
2365
|
+
const cmd = raw && !raw.startsWith("-") ? raw : "setup";
|
|
2366
|
+
const args = raw && !raw.startsWith("-") ? process.argv.slice(3) : process.argv.slice(2);
|
|
2367
|
+
if (cmd === "setup" || cmd === "install") await setup(args);
|
|
2368
|
+
else if (cmd === "settings") settingsHint();
|
|
2369
|
+
else if (cmd === "run") await runBridge();
|
|
2370
|
+
else if (cmd === "plugin") await pluginCmd(process.argv.slice(3));
|
|
2371
|
+
else if (cmd === "repair") repairProfile(process.argv.slice(3));
|
|
2372
|
+
else if (cmd === "status") {
|
|
2373
|
+
const cfg = loadConfig();
|
|
2374
|
+
const local = Boolean(cfg.local_key);
|
|
2375
|
+
console.log("配置文件:", CONFIG_PATH);
|
|
2376
|
+
console.log("连接模式:", local ? `自建服务(${cfg.tunnel_url || "未设置服务器地址"})` : `SaaS 云端服务(${cfg.phone || "未配置账号"})`);
|
|
2377
|
+
console.log("API:", cfg.api_url || (local ? "(自建模式无需账号 API)" : DEFAULT_API));
|
|
2378
|
+
console.log("远程地址/登录: 打开 dsh web → 设置 → 「远程访问」查看与操作");
|
|
2379
|
+
// 插件挂载诊断:报障时一眼看出是"链接坏了"还是"别的毛病"
|
|
2380
|
+
const profileDir = resolveProfileDir([]);
|
|
2381
|
+
if (fs.existsSync(path.join(profileDir, "package.json"))) {
|
|
2382
|
+
console.log("插件挂载:");
|
|
2383
|
+
printPluginDiagnostics(profileDir);
|
|
2384
|
+
} else {
|
|
2385
|
+
console.log(`插件挂载: 未找到 profile(${profileDir})`);
|
|
2386
|
+
}
|
|
2387
|
+
} else {
|
|
2388
|
+
printHelp();
|
|
2389
|
+
process.exit(1);
|
|
2390
|
+
}
|