dsh-remote-web 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1267 -0
- package/lib/index.js +1325 -0
- package/package.json +43 -0
- package/screenshots.json +5 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
// dsh-remote-web — node half (host plugin)(2026-09 由 dsh-remote-web 更名;卸载/清理兼容旧名)
|
|
2
|
+
//
|
|
3
|
+
// 提供 /dsh-remote/* 同源 HTTP 路由,供浏览器半的配置面板调用:
|
|
4
|
+
// - 读写 dsh-remote-open/.dsh-config.json(0600)
|
|
5
|
+
// - 查询/启停 bridge(launchctl,plist 缺失时自动生成,逻辑与 dsh-setup.mjs 一致)
|
|
6
|
+
// - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理
|
|
7
|
+
// - 自管理 self*(版本可见 / 新版检测 / 一键在线更新 / 彻底卸载):插件市场没有更新卸载按钮,
|
|
8
|
+
// 面板内即官方管理入口;更新=后台 npx @mrrisega/dsh-remote@latest(幂等补齐运行环境并重启 bridge);
|
|
9
|
+
// 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
|
|
10
|
+
// 删 plist|unit / 杀残留进程 / 清空配置目录 ~/.dsh-remote),0.4.7 起回归真正「未安装」状态
|
|
11
|
+
// - 运行时自愈:缺运行环境自动后台安装、登录后自动拉起 bridge(0.4.2 起)
|
|
12
|
+
// - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
|
|
13
|
+
//
|
|
14
|
+
// 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
|
|
15
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, constants as fsConstants } from "node:fs";
|
|
16
|
+
import { join, dirname, sep } from "node:path";
|
|
17
|
+
import { execSync, spawn } from "node:child_process";
|
|
18
|
+
import { homedir, hostname, platform } from "node:os";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
/** 本插件在 host 侧的服务依赖。 */
|
|
22
|
+
export const inject = ["webServer"];
|
|
23
|
+
|
|
24
|
+
/** 默认配置目录(可被 entry config 的 relayDir / DSH_RELAY_DIR 环境变量覆盖)。 */
|
|
25
|
+
const DEFAULT_RELAY_DIR = process.env.DSH_RELAY_DIR || join(homedir(), ".dsh-remote");
|
|
26
|
+
// 默认云端服务地址(SaaS 入口;自建用户在设置页/面板切换)
|
|
27
|
+
const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
|
|
28
|
+
const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
|
|
29
|
+
|
|
30
|
+
// ---------- 小工具 ----------
|
|
31
|
+
|
|
32
|
+
/** 执行 shell 命令,不抛异常,返回 { ok, stdout, stderr, code }。 */
|
|
33
|
+
function sh(cmd) {
|
|
34
|
+
try {
|
|
35
|
+
const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000 });
|
|
36
|
+
return { ok: true, stdout: String(stdout ?? ""), stderr: "", code: 0 };
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return { ok: false, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), code: e.status ?? -1 };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 解析真实可执行路径(launchd 需要真实文件 + 可执行位)。 */
|
|
43
|
+
function resolveExecutable(p) {
|
|
44
|
+
try {
|
|
45
|
+
const real = realpathSync(p);
|
|
46
|
+
accessSync(real, fsConstants.X_OK);
|
|
47
|
+
return real;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 优先 node@20(node-datachannel 兼容性),回退当前 node。 */
|
|
54
|
+
function preferredNode() {
|
|
55
|
+
const candidates = [
|
|
56
|
+
"/opt/homebrew/opt/node@20/bin/node",
|
|
57
|
+
"/usr/local/opt/node@20/bin/node",
|
|
58
|
+
process.env.DSH_SETUP_NODE20 || "",
|
|
59
|
+
].filter(Boolean);
|
|
60
|
+
for (const p of candidates) {
|
|
61
|
+
const real = resolveExecutable(p);
|
|
62
|
+
if (real) return real;
|
|
63
|
+
}
|
|
64
|
+
return resolveExecutable(process.execPath) || process.execPath;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const NODE_BIN = preferredNode();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 解析 npx 绝对路径。DeepSeek App 拉起 dsh web 时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin
|
|
71
|
+
* (没有 /opt/homebrew/bin 等),裸 `npx` 会 spawn ENOENT 而静默失败——必须按绝对路径找,
|
|
72
|
+
* 且子进程 env 的 PATH 要把当前 node 所在目录补在最前(npx 的 #!/usr/bin/env node 依赖它)。
|
|
73
|
+
*/
|
|
74
|
+
function npxCommand() {
|
|
75
|
+
const name = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
76
|
+
const dirs = [
|
|
77
|
+
dirname(process.execPath), // 与当前 node 同目录(homebrew/usr/local 均可覆盖)
|
|
78
|
+
process.env.DSH_SETUP_NPX_DIR || "",
|
|
79
|
+
"/opt/homebrew/bin",
|
|
80
|
+
"/usr/local/bin",
|
|
81
|
+
"/opt/homebrew/opt/node@20/bin",
|
|
82
|
+
"/usr/local/opt/node@20/bin",
|
|
83
|
+
"/usr/bin",
|
|
84
|
+
].filter(Boolean);
|
|
85
|
+
for (const d of dirs) {
|
|
86
|
+
const real = resolveExecutable(join(d, name));
|
|
87
|
+
if (real) return real;
|
|
88
|
+
}
|
|
89
|
+
return name; // 全找不到 → 退回裸名(普通 shell 场景仍可用)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 子进程环境:把 node 目录补进 PATH(npx 及其 shebang 需要),可附加额外变量。 */
|
|
93
|
+
function spawnEnv(extra) {
|
|
94
|
+
const nodeDir = dirname(process.execPath);
|
|
95
|
+
const base = process.env.PATH || "";
|
|
96
|
+
const PATH = [nodeDir, base, "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"].filter(Boolean).join(":");
|
|
97
|
+
return { ...process.env, PATH, ...(extra || {}) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------- 后台子进程标记(防重入 + 宿主重启自愈) ----------
|
|
101
|
+
// marker 内容 = JSON {pid, at}:pid 供“宿主重启后立即清理死进程残留”判断;
|
|
102
|
+
// 兼容旧格式(纯时间戳数字 → 只按超时清理)。
|
|
103
|
+
|
|
104
|
+
function readMarkerInfo(filePath) {
|
|
105
|
+
try {
|
|
106
|
+
const raw = readFileSync(filePath, "utf8").trim();
|
|
107
|
+
const j = JSON.parse(raw);
|
|
108
|
+
if (Number.isInteger(j?.pid) || Number.isInteger(j?.at)) return j;
|
|
109
|
+
} catch { /* 非 JSON → 数字时间戳或空 */ }
|
|
110
|
+
const t = Number(raw || "0");
|
|
111
|
+
return Number.isFinite(t) && t > 0 ? { pid: null, at: t } : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function writeMarker(filePath, pid) {
|
|
115
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
116
|
+
writeFileSync(filePath, JSON.stringify({ pid: pid ?? null, at: Date.now() }), { mode: 0o600 });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** pid 是否存活(ESRCH=已死)。 */
|
|
120
|
+
function pidAlive(pid) {
|
|
121
|
+
if (!Number.isInteger(pid) || pid <= 0) return null; // 未知 → 由超时规则兜底
|
|
122
|
+
try {
|
|
123
|
+
process.kill(pid, 0);
|
|
124
|
+
return true;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return e.code === "EPERM" ? true : false; // EPERM=存在但无权限
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 清理残留标记:宿主 dsh web 在后台安装/更新期间被重启/强杀时,子进程清理回调随之丢失,
|
|
132
|
+
* 若只按“30 分钟超时”清理,用户会在这半小时内反复遇到“已有更新进行中/正在安装”。
|
|
133
|
+
* 现在:记录 pid → 重启后立刻清掉已死进程的标记;pid 不可读的旧标记仍按超时兜底。
|
|
134
|
+
*/
|
|
135
|
+
function sweepStaleMarkers(relayDir) {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
for (const name of [PROVISION_MARKER, UPDATE_MARKER]) {
|
|
138
|
+
const p = join(relayDir, name);
|
|
139
|
+
let info;
|
|
140
|
+
try { info = readMarkerInfo(p); } catch { continue; }
|
|
141
|
+
if (!info) continue;
|
|
142
|
+
const dead = pidAlive(info.pid);
|
|
143
|
+
const expired = now - info.at > STALE_MARKER_MS;
|
|
144
|
+
if (dead === false || (dead === null && expired)) {
|
|
145
|
+
try { rmSync(p, { force: true }); } catch { /* ignore */ }
|
|
146
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
147
|
+
`[dsh-remote-web] 清理残留标记 ${name}(pid=${info.pid ?? "?"}, at=${new Date(info.at).toISOString()}${dead === false ? ", 进程已死" : ", 已超时"})`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 读取 JSON body。 */
|
|
153
|
+
async function readJsonBody(req) {
|
|
154
|
+
let raw = "";
|
|
155
|
+
for await (const chunk of req) raw += chunk;
|
|
156
|
+
if (!raw) return {};
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(raw);
|
|
159
|
+
} catch {
|
|
160
|
+
return { __parseError: true };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 统一 JSON 响应。 */
|
|
165
|
+
function sendJson(res, code, body) {
|
|
166
|
+
const payload = JSON.stringify(body);
|
|
167
|
+
res.writeHead(code, {
|
|
168
|
+
"content-type": "application/json; charset=utf-8",
|
|
169
|
+
"cache-control": "no-store",
|
|
170
|
+
});
|
|
171
|
+
res.end(payload);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------- 配置读写(与 dsh-setup.mjs 同一份 .dsh-config.json) ----------
|
|
175
|
+
|
|
176
|
+
function configPathOf(relayDir) {
|
|
177
|
+
return join(relayDir, ".dsh-config.json");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function loadConfig(relayDir) {
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(readFileSync(configPathOf(relayDir), "utf8"));
|
|
183
|
+
} catch {
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function saveConfig(relayDir, cfg) {
|
|
189
|
+
mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
|
|
190
|
+
// mode 0o600:与 dsh-setup.mjs 一致(文件已存在时 writeFileSync 不改权限,显式 chmod 兜底)
|
|
191
|
+
writeFileSync(configPathOf(relayDir), JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
192
|
+
try {
|
|
193
|
+
chmodSync(configPathOf(relayDir), 0o600);
|
|
194
|
+
} catch {
|
|
195
|
+
/* 非关键 */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* SaaS 模式权威归一化(与 dsh-setup.mjs 同规则):
|
|
201
|
+
* 清除自建残留(local_key/假 tunnel_url),api_url 与 tunnel_url 一律按云端权威地址重算。
|
|
202
|
+
* 解决“切过自建(填了假地址)后,再登/重装云端账号仍连错服务器、手机看不到设备”的残留配置问题。
|
|
203
|
+
*/
|
|
204
|
+
function applySaaSMode(cfg) {
|
|
205
|
+
delete cfg.local_key;
|
|
206
|
+
delete cfg.server;
|
|
207
|
+
cfg.api_url = String(cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
208
|
+
cfg.tunnel_url = cfg.api_url.replace(/\/relay-api\/?$/, "").replace(/^https/, "wss");
|
|
209
|
+
return cfg;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------- Harness 浏览器会话代持(0.1.2-rc.1+ 的 ?token 鉴权) ----------
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 新版 dsh web(0.1.2-rc.1+)启动会打印带 ?token= 的 URL,并只给"换到了会话 Cookie"的浏览器放行,
|
|
216
|
+
* 其余请求一律 401(手机经隧道因此白页)。本插件与 Harness 同进程:
|
|
217
|
+
* - 通过 ctx.connection 服务拿到本进程 launch token(authenticatedUrl 自带 token);
|
|
218
|
+
* - 在本地向 /?token=… 发起 token 交换,捕获下发的 dsh-auth-* 会话 Cookie;
|
|
219
|
+
* - 写入 <relayDir>/.harness-cookie.json,bridge 上游转发时自动携带,让手机表现为已授权浏览器。
|
|
220
|
+
* 老版本(无该鉴权)下 connection 服务没有 authenticatedUrl → 静默跳过,行为不变。
|
|
221
|
+
*/
|
|
222
|
+
const HARNESS_COOKIE_FILE = ".harness-cookie.json";
|
|
223
|
+
const HARNESS_AUTH_RETRY_MS = 3000;
|
|
224
|
+
const HARNESS_AUTH_RETRY_MAX = 20; // 最多约 60s 等 connection 服务就绪
|
|
225
|
+
const HARNESS_AUTH_REFRESH_MS = 6 * 3600 * 1000;
|
|
226
|
+
|
|
227
|
+
/** 读取响应里的 set-cookie(兼容 getSetCookie / get 两种实现)。 */
|
|
228
|
+
function setCookieOf(res) {
|
|
229
|
+
try {
|
|
230
|
+
if (typeof res.headers.getSetCookie === "function") return res.headers.getSetCookie().join("; ");
|
|
231
|
+
} catch { /* ignore */ }
|
|
232
|
+
return res.headers.get("set-cookie") || "";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function mintHarnessCookie(ctx, relayDir) {
|
|
236
|
+
if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再代持会话、不再重建配置目录
|
|
237
|
+
try {
|
|
238
|
+
const port = ctx.webServer?.port;
|
|
239
|
+
if (!port) return false;
|
|
240
|
+
// 不能把 "connection" 写进 inject(0.1.1 无该服务会拖死激活),只能运行时 try 获取
|
|
241
|
+
let holder;
|
|
242
|
+
try { holder = ctx.get("connection"); } catch { holder = void 0; }
|
|
243
|
+
if (!holder && ctx.connection !== void 0) { try { holder = ctx.connection; } catch { /* ignore */ } }
|
|
244
|
+
const svc = holder && typeof holder.authenticatedUrl === "function"
|
|
245
|
+
? holder
|
|
246
|
+
: holder && holder.connection && typeof holder.connection.authenticatedUrl === "function"
|
|
247
|
+
? holder.connection
|
|
248
|
+
: null;
|
|
249
|
+
if (!svc) return false; // 老版本无浏览器鉴权 → 无需 cookie
|
|
250
|
+
const tokenUrl = svc.authenticatedUrl(`http://127.0.0.1:${port}`);
|
|
251
|
+
const res = await fetch(tokenUrl, { redirect: "manual", signal: AbortSignal.timeout(6000) });
|
|
252
|
+
const cookie = setCookieOf(res).split(";")[0].trim();
|
|
253
|
+
if (!cookie || !cookie.startsWith("dsh-auth-")) return false;
|
|
254
|
+
// 竞态兜底:fetch 期间用户点击了「彻底卸载」→ 不得重建已被清空的配置目录
|
|
255
|
+
if (UNINSTALLED_DIRS.has(relayDir)) return false;
|
|
256
|
+
const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
|
|
257
|
+
mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
|
|
258
|
+
writeFileSync(join(relayDir, HARNESS_COOKIE_FILE), JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
259
|
+
return true;
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 后台调度:启动重试直到换取成功,成功后每 6h 刷新(与插件生命周期同进退)。 */
|
|
266
|
+
function scheduleHarnessMint(ctx, relayDir) {
|
|
267
|
+
let succeeded = false;
|
|
268
|
+
let retries = 0;
|
|
269
|
+
const attempt = async () => {
|
|
270
|
+
if (succeeded) return;
|
|
271
|
+
if (await mintHarnessCookie(ctx, relayDir)) succeeded = true;
|
|
272
|
+
};
|
|
273
|
+
const bootIv = setInterval(() => {
|
|
274
|
+
if (succeeded || ++retries > HARNESS_AUTH_RETRY_MAX) {
|
|
275
|
+
clearInterval(bootIv);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
void attempt();
|
|
279
|
+
}, HARNESS_AUTH_RETRY_MS);
|
|
280
|
+
bootIv.unref?.();
|
|
281
|
+
const refreshIv = setInterval(() => {
|
|
282
|
+
void mintHarnessCookie(ctx, relayDir).catch(() => {});
|
|
283
|
+
}, HARNESS_AUTH_REFRESH_MS);
|
|
284
|
+
refreshIv.unref?.();
|
|
285
|
+
return () => {
|
|
286
|
+
clearInterval(bootIv);
|
|
287
|
+
clearInterval(refreshIv);
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------- bridge 服务状态 / 启停(launchctl,macOS) ----------
|
|
292
|
+
|
|
293
|
+
function launchAgentPath() {
|
|
294
|
+
if (platform() === "darwin") return join(homedir(), "Library/LaunchAgents/com.dshremote.bridge.plist");
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function launchTarget() {
|
|
299
|
+
return `gui/${process.getuid()}/com.dshremote.bridge`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 检查 launchd 服务状态(state=running + pid;兜底 launchctl list)。 */
|
|
303
|
+
function launchdStatus() {
|
|
304
|
+
const target = launchTarget();
|
|
305
|
+
const pr = sh(`launchctl print ${target}`);
|
|
306
|
+
if (pr.ok && /state\s*=\s*running/.test(pr.stdout)) {
|
|
307
|
+
const m = pr.stdout.match(/pid\s*=\s*(\d+)/);
|
|
308
|
+
return { running: true, pid: m ? Number(m[1]) : null };
|
|
309
|
+
}
|
|
310
|
+
const ls = sh(`launchctl list | grep com.dshremote.bridge`);
|
|
311
|
+
if (ls.ok) {
|
|
312
|
+
const pidStr = ls.stdout.trim().split(/\s+/)[0];
|
|
313
|
+
if (pidStr && pidStr !== "-" && /^\d+$/.test(pidStr)) return { running: true, pid: Number(pidStr) };
|
|
314
|
+
}
|
|
315
|
+
return { running: false, pid: null };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 检查手动运行的 watcher(dsh-setup.mjs run)与 bridge 子进程(排除 launchd 托管链)。 */
|
|
319
|
+
function manualStatus() {
|
|
320
|
+
const launchdPid = launchdStatus().pid;
|
|
321
|
+
const out = (() => {
|
|
322
|
+
const r = sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs'");
|
|
323
|
+
return r.ok ? r.stdout : "";
|
|
324
|
+
})();
|
|
325
|
+
const watcher = [];
|
|
326
|
+
const bridge = [];
|
|
327
|
+
// 取候选进程的父 pid,判断是否属于 launchd 托管链
|
|
328
|
+
const parentOf = (pid) => {
|
|
329
|
+
const r = sh(`ps -o ppid= -p ${pid}`);
|
|
330
|
+
const m = r.ok && r.stdout.trim().match(/^(\d+)/);
|
|
331
|
+
return m ? Number(m[1]) : null;
|
|
332
|
+
};
|
|
333
|
+
for (const line of out.split("\n")) {
|
|
334
|
+
if (/pgrep/.test(line)) continue; // 排除 execSync 的 sh -c 包装进程
|
|
335
|
+
const m = line.match(/^(\d+)\s+(.+)$/);
|
|
336
|
+
if (!m) continue;
|
|
337
|
+
const pid = Number(m[1]);
|
|
338
|
+
if (pid === process.pid || pid === launchdPid) continue;
|
|
339
|
+
if (launchdPid !== null && parentOf(pid) === launchdPid) continue; // launchd 托管的 bridge 子进程
|
|
340
|
+
if (/dsh-setup\.mjs/.test(m[2])) watcher.push(pid);
|
|
341
|
+
else if (/dsh-bridge\.mjs/.test(m[2])) bridge.push(pid);
|
|
342
|
+
}
|
|
343
|
+
return { watcher, bridge };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------- 插件市场一键全功能:缺桌面运行环境时自动后台安装 dsh-remote ----------
|
|
347
|
+
|
|
348
|
+
const PROVISION_MARKER = ".dsh-setup-installing";
|
|
349
|
+
const AUTO_INSTALL_LOG = ".dsh-setup-install.log";
|
|
350
|
+
const STALE_MARKER_MS = 30 * 60 * 1000; // 超过该时长视为上次进程残留,插件启动时清理
|
|
351
|
+
|
|
352
|
+
/** 向日志追加一行(多个子进程写同一日志用 append 模式,互不覆盖)。 */
|
|
353
|
+
function appendLogLine(relayDir, name, line) {
|
|
354
|
+
try {
|
|
355
|
+
const fd = openSync(join(relayDir, name), "a");
|
|
356
|
+
try {
|
|
357
|
+
writeFileSync(fd, `\n${line}\n`);
|
|
358
|
+
} finally {
|
|
359
|
+
closeSync(fd);
|
|
360
|
+
}
|
|
361
|
+
} catch { /* 非关键 */ }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 清理“进程残留”标记的实现见上方小工具区 sweepStaleMarkers(pid 存活 + 超时双保险)。
|
|
366
|
+
*/
|
|
367
|
+
|
|
368
|
+
/** 插件市场只装了 UI 插件;若桌面缺 dsh-remote 运行环境(dsh-setup.mjs=bridge/自启动),
|
|
369
|
+
* 由插件在后台自动执行一次 `npx @mrrisega/dsh-remote` 补齐,用户无需手动跑命令。
|
|
370
|
+
* 已有环境(包括手动 npx 装过)直接跳过。返回 true=已就绪。
|
|
371
|
+
* 注意:默认优先官方源——镜像(npmmirror)可能滞后于刚发布的版本,装到旧版会把
|
|
372
|
+
* 已被 0.4.5 移除的“用户 include”重新写回 profile(历史上造成 dsh web 重复 ID 崩溃)。 */
|
|
373
|
+
function ensureRuntime(relayDir) {
|
|
374
|
+
if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再自动安装运行环境
|
|
375
|
+
if (existsSync(join(relayDir, "dsh-setup.mjs"))) return true;
|
|
376
|
+
const marker = join(relayDir, PROVISION_MARKER);
|
|
377
|
+
if (existsSync(marker)) return false; // 正在安装中
|
|
378
|
+
try {
|
|
379
|
+
mkdirSync(relayDir, { recursive: true });
|
|
380
|
+
const log = join(relayDir, AUTO_INSTALL_LOG);
|
|
381
|
+
const child = spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote"], {
|
|
382
|
+
detached: true,
|
|
383
|
+
env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org" }),
|
|
384
|
+
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
|
|
385
|
+
});
|
|
386
|
+
writeMarker(marker, child.pid); // 记 pid:宿主重启后可立即清理死进程残留
|
|
387
|
+
const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
|
|
388
|
+
child.on("exit", (code) => {
|
|
389
|
+
clear();
|
|
390
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] npx 退出 code=${code ?? "?"}`);
|
|
391
|
+
});
|
|
392
|
+
child.on("error", (e) => {
|
|
393
|
+
clear();
|
|
394
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动失败: ${e.message}`);
|
|
395
|
+
console.warn(`[dsh-remote-web] 自动安装子进程启动失败: ${e.message}`);
|
|
396
|
+
});
|
|
397
|
+
child.unref();
|
|
398
|
+
console.log(`[dsh-remote-web] 检测到缺少桌面运行环境,已在后台自动安装(日志: ${log}),完成后将自动启动 bridge`);
|
|
399
|
+
return false;
|
|
400
|
+
} catch (e) {
|
|
401
|
+
console.warn(`[dsh-remote-web] 自动安装启动失败: ${e.message}`);
|
|
402
|
+
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 生成 plist(与 dsh-setup.mjs writeAutostartFile 同构),返回路径。 */
|
|
408
|
+
function writeAutostartFile(relayDir) {
|
|
409
|
+
const plistPath = launchAgentPath();
|
|
410
|
+
if (!plistPath) return null;
|
|
411
|
+
const setupUrl = join(relayDir, "dsh-setup.mjs");
|
|
412
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
413
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
414
|
+
<plist version="1.0"><dict>
|
|
415
|
+
<key>Label</key><string>com.dshremote.bridge</string>
|
|
416
|
+
<key>ProgramArguments</key>
|
|
417
|
+
<array><string>${NODE_BIN}</string><string>${setupUrl}</string><string>run</string></array>
|
|
418
|
+
<key>RunAtLoad</key><true/>
|
|
419
|
+
<key>KeepAlive</key><true/>
|
|
420
|
+
<key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
421
|
+
<key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
422
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string></dict>
|
|
423
|
+
</dict></plist>`;
|
|
424
|
+
mkdirSync(dirname(plistPath), { recursive: true });
|
|
425
|
+
writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
426
|
+
return plistPath;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 启动 bridge:确保 plist 存在 → launchctl bootstrap(回退 load -w)。 */
|
|
430
|
+
function startBridge(relayDir) {
|
|
431
|
+
if (UNINSTALLED_DIRS.has(relayDir)) {
|
|
432
|
+
// 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
|
|
433
|
+
return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
|
|
434
|
+
}
|
|
435
|
+
const plistPath = launchAgentPath();
|
|
436
|
+
if (!plistPath) return { ok: false, status: "unsupported", detail: "仅支持 macOS" };
|
|
437
|
+
if (!existsSync(plistPath)) writeAutostartFile(relayDir);
|
|
438
|
+
if (!existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 生成失败" };
|
|
439
|
+
const target = launchTarget();
|
|
440
|
+
const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
441
|
+
sh(`launchctl bootout ${target}`);
|
|
442
|
+
let boot = sh(`launchctl bootstrap gui/${process.getuid()} ${q(plistPath)}`);
|
|
443
|
+
if (!boot.ok) {
|
|
444
|
+
sh(`launchctl unload ${q(plistPath)}`);
|
|
445
|
+
boot = sh(`launchctl load -w ${q(plistPath)}`);
|
|
446
|
+
}
|
|
447
|
+
if (!boot.ok) return { ok: false, status: "failed", detail: (boot.stderr || boot.stdout).trim() || "launchctl 启动失败" };
|
|
448
|
+
const st = launchdStatus();
|
|
449
|
+
return { ok: st.running, status: st.running ? "running" : "failed", pid: st.pid, detail: st.running ? void 0 : "服务未进入运行态" };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 运行时自愈 watcher:账号就绪后若环境缺失则自动安装;装好/重启后自动拉起 bridge。 */
|
|
453
|
+
function scheduleRuntime(relayDir) {
|
|
454
|
+
let done = false;
|
|
455
|
+
const iv = setInterval(() => {
|
|
456
|
+
if (done) { clearInterval(iv); return; }
|
|
457
|
+
if (UNINSTALLED_DIRS.has(relayDir)) { done = true; clearInterval(iv); return; } // 已彻底卸载:自愈 watcher 停摆
|
|
458
|
+
try {
|
|
459
|
+
const cfg = loadConfig(relayDir);
|
|
460
|
+
const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
|
|
461
|
+
if (!hasAcct) return;
|
|
462
|
+
// 先看服务是否已在运行(runtime 可能位于 npx 缓存/固化目录,不必重复安装)
|
|
463
|
+
const st = launchdStatus();
|
|
464
|
+
if (st.running) { done = true; clearInterval(iv); return; }
|
|
465
|
+
const setupUrl = join(relayDir, "dsh-setup.mjs");
|
|
466
|
+
if (!existsSync(setupUrl)) {
|
|
467
|
+
ensureRuntime(relayDir); // 什么环境都没有 → 后台 npx 安装一次
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
startBridge(relayDir); // 环境在但服务没起 → 拉起
|
|
471
|
+
} catch { /* 下一轮再试 */ }
|
|
472
|
+
}, 12_000);
|
|
473
|
+
iv.unref?.();
|
|
474
|
+
return () => clearInterval(iv);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** 停止 bridge:launchctl bootout。 */
|
|
478
|
+
function stopBridge() {
|
|
479
|
+
const target = launchTarget();
|
|
480
|
+
const r = sh(`launchctl bootout ${target}`);
|
|
481
|
+
const st = launchdStatus();
|
|
482
|
+
return { ok: !st.running, status: st.running ? "failed" : "stopped", pid: null, detail: st.running ? (r.stderr || "停止失败").trim() : void 0 };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---------- 彻底卸载:bridge 自启动 / 残留进程 / 配置目录 ----------
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 已执行「彻底卸载」的 relayDir 集合。卸载动作本身不改代码,但 dsh web 重启前本插件仍在内存中:
|
|
489
|
+
* 自愈调度(scheduleRuntime/ensureRuntime/startBridge)与浏览器会话代持(mintHarnessCookie)
|
|
490
|
+
* 若继续执行,会把刚清空的配置目录 / 自启动服务重新拉起来——故卸载后本进程内一律停摆,
|
|
491
|
+
* 直到 dsh web 重启(profile 引用已移除,插件整体不再加载)或重新激活(apply 时清除)。
|
|
492
|
+
*/
|
|
493
|
+
const UNINSTALLED_DIRS = new Set();
|
|
494
|
+
|
|
495
|
+
/** 标记某 relayDir 已完成彻底卸载(其后续自愈/代持调度全部停摆)。 */
|
|
496
|
+
function markUninstalled(relayDir) {
|
|
497
|
+
if (relayDir) UNINSTALLED_DIRS.add(relayDir);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 彻底卸载 —— 「运行时/bridge」部分:把本机 dsh-remote 运行时回归到未安装状态。
|
|
502
|
+
* 执行顺序(每步独立 try/catch,单项失败不致命,不影响后续步骤;结果以标志位返回):
|
|
503
|
+
* 1) 停掉自启动服务并移除自启动文件:macOS launchctl bootout com.dshremote.bridge +
|
|
504
|
+
* 删 ~/Library/LaunchAgents/com.dshremote.bridge.plist;Linux systemctl --user
|
|
505
|
+
* stop/disable dsh-bridge(+ 删 ~/.config/systemd/user/dsh-bridge.service)。
|
|
506
|
+
* ⚠ 必须先停服务再删配置目录:否则 launchd KeepAlive / systemd Restart 会立刻
|
|
507
|
+
* 重启一个「指向已被删除文件」的进程;
|
|
508
|
+
* 2) 杀掉仍存活的手动 watcher/bridge 进程(launchd/systemd 托管的进程已随 bootout 结束,
|
|
509
|
+
* manualStatus() 本身也排除了本进程与 launchd 托管链);
|
|
510
|
+
* 3) rm -rf 配置目录 relayDir(账号/设备密钥/.dsh-config.json/.harness-cookie.json/
|
|
511
|
+
* 固化运行时 dsh-setup.mjs + clients 等全部残留)。
|
|
512
|
+
* 安全护栏:
|
|
513
|
+
* - DSH_RELAY_SKIP_SERVICE=1(测试隔离开关,生产勿设):跳过 1/2 的一切系统级操作,
|
|
514
|
+
* 只清理配置目录——避免测试真的去 launchctl / systemctl / kill 真实服务;
|
|
515
|
+
* - 自启动文件只处理「属于当前 HOME 的 plist/unit」,误配/测试环境不碰同名真实服务;
|
|
516
|
+
* - 配置目录删除前校验:不是 "/"、不是家目录、不是 dsh web profile 目录或其父级(防误删用户数据)。
|
|
517
|
+
*/
|
|
518
|
+
function uninstallRuntime(relayDir, protectedPath) {
|
|
519
|
+
const out = {
|
|
520
|
+
stoppedService: false, // 自启动服务原本在运行且已停止
|
|
521
|
+
removedPlist: false, // 自启动文件(plist / systemd unit)已删除
|
|
522
|
+
killedPids: [], // 额外结束的残留进程 pid 列表
|
|
523
|
+
removedDir: false, // 配置目录 relayDir 已整目录清空
|
|
524
|
+
servicePlatform: platform() === "darwin" ? "launchd" : platform() === "linux" ? "systemd" : "none",
|
|
525
|
+
};
|
|
526
|
+
const skipService = process.env.DSH_RELAY_SKIP_SERVICE === "1";
|
|
527
|
+
if (!skipService) {
|
|
528
|
+
// a) 停服务 + 移除自启动文件
|
|
529
|
+
try {
|
|
530
|
+
if (platform() === "darwin") {
|
|
531
|
+
// 只处理「plist 位于当前 HOME」的服务:本插件/dsh-setup.mjs 安装的服务一定在此
|
|
532
|
+
const plistPath = launchAgentPath();
|
|
533
|
+
if (plistPath && existsSync(plistPath)) {
|
|
534
|
+
if (launchdStatus().running) {
|
|
535
|
+
const r = stopBridge(); // launchctl bootout → KeepAlive 一并失效
|
|
536
|
+
out.stoppedService = r.ok;
|
|
537
|
+
}
|
|
538
|
+
rmSync(plistPath, { force: true });
|
|
539
|
+
out.removedPlist = !existsSync(plistPath);
|
|
540
|
+
}
|
|
541
|
+
} else if (platform() === "linux") {
|
|
542
|
+
// systemd --user 用户态服务,与 dsh-setup.mjs 安装的 dsh-bridge 同名
|
|
543
|
+
const isActive = sh("systemctl --user is-active dsh-bridge");
|
|
544
|
+
if (isActive.ok && String(isActive.stdout).trim() === "active") {
|
|
545
|
+
sh("systemctl --user stop dsh-bridge");
|
|
546
|
+
const after = sh("systemctl --user is-active dsh-bridge");
|
|
547
|
+
out.stoppedService = !(after.ok && String(after.stdout).trim() === "active");
|
|
548
|
+
}
|
|
549
|
+
sh("systemctl --user disable dsh-bridge"); // 幂等;失败不致命
|
|
550
|
+
const unitPath = join(homedir(), ".config", "systemd", "user", "dsh-bridge.service");
|
|
551
|
+
if (existsSync(unitPath)) {
|
|
552
|
+
try { rmSync(unitPath, { force: true }); } catch { /* 非关键 */ }
|
|
553
|
+
sh("systemctl --user daemon-reload");
|
|
554
|
+
out.removedPlist = !existsSync(unitPath);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch { /* 服务清理失败不致命:目录照常清理,剩余残留可由用户手动处理 */ }
|
|
558
|
+
// b) 杀残留手动进程(launchd 托管的已随 bootout 结束;manualStatus 排除本进程)
|
|
559
|
+
try {
|
|
560
|
+
const manual = manualStatus();
|
|
561
|
+
const targets = [...manual.watcher, ...manual.bridge];
|
|
562
|
+
for (const pid of targets) {
|
|
563
|
+
try { process.kill(pid, "SIGTERM"); out.killedPids.push(pid); } catch { /* EPERM/ESRCH 忽略 */ }
|
|
564
|
+
}
|
|
565
|
+
if (targets.length) {
|
|
566
|
+
try { execSync("sleep 1", { timeout: 3000 }); } catch { /* 等待进程退出 */ }
|
|
567
|
+
for (const pid of targets) {
|
|
568
|
+
if (pidAlive(pid)) {
|
|
569
|
+
try { process.kill(pid, "SIGKILL"); } catch { /* 已退出 */ }
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
} catch { /* ignore */ }
|
|
574
|
+
}
|
|
575
|
+
// c) 清空配置目录(账号/密钥/会话 cookie/固化运行时等全部残留)
|
|
576
|
+
try {
|
|
577
|
+
const isRoot = dirname(relayDir) === relayDir; // "/" 或盘符根
|
|
578
|
+
const isHome = relayDir === homedir();
|
|
579
|
+
const hitsProfile = Boolean(protectedPath) && (
|
|
580
|
+
relayDir === protectedPath || relayDir.startsWith(protectedPath + sep)
|
|
581
|
+
|| protectedPath.startsWith(relayDir + sep)
|
|
582
|
+
); // 配置目录误指向 dsh web profile → 绝不整目录删除
|
|
583
|
+
if (relayDir && !isRoot && !isHome && !hitsProfile && existsSync(relayDir)) {
|
|
584
|
+
rmSync(relayDir, { recursive: true, force: true });
|
|
585
|
+
out.removedDir = !existsSync(relayDir);
|
|
586
|
+
}
|
|
587
|
+
} catch { /* 目录正被占用等:删除失败不致命(残留可由用户手动删除) */ }
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---------- relay API 代理(直连,不走系统代理;undici 默认忽略代理环境变量) ----------
|
|
592
|
+
|
|
593
|
+
async function relayFetch(relayDir, pathname, init) {
|
|
594
|
+
const cfg = loadConfig(relayDir);
|
|
595
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
596
|
+
const url = `${api}${pathname}`;
|
|
597
|
+
try {
|
|
598
|
+
// 6s 超时:relay 不可达时快速降级,不拖慢面板
|
|
599
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(6000) });
|
|
600
|
+
const text = await res.text();
|
|
601
|
+
let body = null;
|
|
602
|
+
try {
|
|
603
|
+
body = JSON.parse(text);
|
|
604
|
+
} catch {
|
|
605
|
+
body = text;
|
|
606
|
+
}
|
|
607
|
+
return { status: res.status, ok: res.ok, body };
|
|
608
|
+
} catch (e) {
|
|
609
|
+
return { status: 0, ok: false, body: { error: { message: `relay 不可达: ${e.message}` } } };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ---------- v2 账号/配额/邀请代理(我的信息 与 免费额度提示) ----------
|
|
614
|
+
|
|
615
|
+
/** 获取短期 relay token:SaaS → device-login;本地模式 → /_login(从隧道地址推导同源)。 */
|
|
616
|
+
async function relayToken(relayDir) {
|
|
617
|
+
const cfg = loadConfig(relayDir);
|
|
618
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
619
|
+
if (cfg.local_key) {
|
|
620
|
+
// 本地认证:POST {tunnel 同源}/_login
|
|
621
|
+
const u = new URL(cfg.tunnel_url || api.replace(/^https?/, "wss"));
|
|
622
|
+
u.protocol = u.protocol === "wss:" ? "https:" : "http:";
|
|
623
|
+
u.pathname = "/_login";
|
|
624
|
+
const r = await fetch(u.toString(), {
|
|
625
|
+
method: "POST",
|
|
626
|
+
headers: { "content-type": "application/json" },
|
|
627
|
+
body: JSON.stringify({ key: cfg.local_key }),
|
|
628
|
+
signal: AbortSignal.timeout(6000)
|
|
629
|
+
});
|
|
630
|
+
if (!r.ok) return "";
|
|
631
|
+
const d = await r.json();
|
|
632
|
+
return d.token || "";
|
|
633
|
+
}
|
|
634
|
+
if (!cfg.phone || !cfg.password) return "";
|
|
635
|
+
const r = await fetch(`${api}/api/device-login`, {
|
|
636
|
+
method: "POST",
|
|
637
|
+
headers: { "content-type": "application/json", ...(cfg.bridge_secret ? { "x-dsh-bridge-secret": cfg.bridge_secret } : {}) },
|
|
638
|
+
body: JSON.stringify({ phone: cfg.phone, email: cfg.phone, password: cfg.password }),
|
|
639
|
+
signal: AbortSignal.timeout(6000)
|
|
640
|
+
});
|
|
641
|
+
if (!r.ok) return "";
|
|
642
|
+
const d = await r.json();
|
|
643
|
+
return d.token || "";
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** 我的信息:SaaS 账号的生效套餐/到期日/邀请码(经 /api/me)。 */
|
|
647
|
+
async function relayAccount(relayDir) {
|
|
648
|
+
const token = await relayToken(relayDir);
|
|
649
|
+
if (!token) return null;
|
|
650
|
+
const r = await relayFetch(relayDir, "/api/me", { headers: { authorization: `Bearer ${token}` } });
|
|
651
|
+
if (!r.ok || !r.body || !r.body.user) return null;
|
|
652
|
+
const u = r.body.user;
|
|
653
|
+
return {
|
|
654
|
+
phone: u.phone || "",
|
|
655
|
+
plan: u.plan || "free",
|
|
656
|
+
plan_source: u.plan_source || "plan",
|
|
657
|
+
plan_ends_at: u.plan_ends_at ?? null,
|
|
658
|
+
trial_expires_at: u.trial_expires_at ?? null,
|
|
659
|
+
invite_code: u.invite_code || "",
|
|
660
|
+
invited_by: u.invited_by ?? null
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** 流量用量:router /_quota(免费用户百分比提示)。 */
|
|
665
|
+
async function relayQuota(relayDir) {
|
|
666
|
+
const token = await relayToken(relayDir);
|
|
667
|
+
if (!token) return null;
|
|
668
|
+
const cfg = loadConfig(relayDir);
|
|
669
|
+
// router 同源:apiUrl(https://host/relay-api) → https://host;本地模式从 tunnel_url 推导
|
|
670
|
+
let origin = "";
|
|
671
|
+
if (cfg.tunnel_url) {
|
|
672
|
+
const u = new URL(cfg.tunnel_url);
|
|
673
|
+
u.protocol = u.protocol === "wss:" ? "https:" : "http:";
|
|
674
|
+
origin = u.origin;
|
|
675
|
+
} else {
|
|
676
|
+
const u = new URL((cfg.api_url || DEFAULT_API));
|
|
677
|
+
origin = u.origin;
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
const r = await fetch(`${origin}/_quota`, {
|
|
681
|
+
headers: { cookie: `dsh_token=${encodeURIComponent(token)}` },
|
|
682
|
+
signal: AbortSignal.timeout(6000)
|
|
683
|
+
});
|
|
684
|
+
if (!r.ok) return null;
|
|
685
|
+
const d = await r.json();
|
|
686
|
+
return d.quota || null;
|
|
687
|
+
} catch {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** 我的邀请记录(登录态):有效邀请 + 奖励。 */
|
|
693
|
+
async function relayInviteRecords(relayDir) {
|
|
694
|
+
const token = await relayToken(relayDir);
|
|
695
|
+
if (!token) return null;
|
|
696
|
+
const r = await relayFetch(relayDir, "/api/invite-records", { headers: { authorization: `Bearer ${token}` } });
|
|
697
|
+
if (!r.ok || !r.body) return null;
|
|
698
|
+
return { records: r.body.records || [], rewards: r.body.rewards || [] };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ---------- 综合状态 ----------
|
|
702
|
+
|
|
703
|
+
async function composeStatus(relayDir) {
|
|
704
|
+
const cfg = loadConfig(relayDir);
|
|
705
|
+
const launchd = launchdStatus();
|
|
706
|
+
const manual = manualStatus();
|
|
707
|
+
// 注册/绑定失败提示(bridge 写 .bind-error.json;面板据此展示“已达上限/需解绑”引导)
|
|
708
|
+
let bindError = null;
|
|
709
|
+
try {
|
|
710
|
+
const f = join(relayDir, ".bind-error.json");
|
|
711
|
+
if (existsSync(f)) bindError = JSON.parse(readFileSync(f, "utf8"));
|
|
712
|
+
} catch { /* 无/损坏忽略 */ }
|
|
713
|
+
// 远程地址(public-config 的 app_url,取不到用默认)
|
|
714
|
+
const pub = await relayFetch(relayDir, "/api/public-config");
|
|
715
|
+
const pubBody = pub.ok && pub.body && typeof pub.body === "object" ? pub.body : {};
|
|
716
|
+
const remoteUrl = pubBody.app_url || DEFAULT_APP_URL;
|
|
717
|
+
const apiUrl = pubBody.api_url || cfg.api_url || DEFAULT_API;
|
|
718
|
+
return {
|
|
719
|
+
ok: true,
|
|
720
|
+
config: {
|
|
721
|
+
phone: cfg.phone || "",
|
|
722
|
+
hasPassword: Boolean(cfg.password),
|
|
723
|
+
deviceId: cfg.device_id || "",
|
|
724
|
+
apiUrl,
|
|
725
|
+
mode: cfg.local_key ? "local" : "saas", // 连接模式:saas(公网) | local(自建)
|
|
726
|
+
selfHostUrl: cfg.tunnel_url ? cfg.tunnel_url.replace(/^wss?:\/\//, "").replace(/\/+$/, "") : "",
|
|
727
|
+
hasLocalKey: Boolean(cfg.local_key),
|
|
728
|
+
},
|
|
729
|
+
remoteUrl,
|
|
730
|
+
relayReachable: pub.ok,
|
|
731
|
+
service: {
|
|
732
|
+
plistExists: Boolean(launchAgentPath() && existsSync(launchAgentPath())),
|
|
733
|
+
launchd,
|
|
734
|
+
manual,
|
|
735
|
+
running: launchd.running || manual.bridge.length > 0,
|
|
736
|
+
bindError,
|
|
737
|
+
},
|
|
738
|
+
host: hostname(),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ---------- 用户反馈代理(反馈 API 由 relay-enterprise 提供,同源 /relay-api/) ----------
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* 反馈 API 基址:feedback_url(自建/兼容实现)> 账号 API 基址(默认生产 relay-api)。
|
|
746
|
+
* 反馈端点路径与账号 API 同构:{base}/api/feedback*。
|
|
747
|
+
*/
|
|
748
|
+
function feedbackApiOf(cfg) {
|
|
749
|
+
return (cfg.feedback_url || cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** 读取请求体(上限 64KB,与反馈服务一致)。 */
|
|
753
|
+
async function readBodyBuffer(req, limit = 64 * 1024) {
|
|
754
|
+
const chunks = [];
|
|
755
|
+
let total = 0;
|
|
756
|
+
for await (const c of req) {
|
|
757
|
+
total += c.length;
|
|
758
|
+
if (total > limit) {
|
|
759
|
+
const e = new Error("body too large");
|
|
760
|
+
e.status = 413;
|
|
761
|
+
throw e;
|
|
762
|
+
}
|
|
763
|
+
chunks.push(c);
|
|
764
|
+
}
|
|
765
|
+
return Buffer.concat(chunks);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* 把 /dsh-remote/feedback/* 代理到反馈 API(relay-enterprise 同源 /relay-api/):
|
|
770
|
+
* - 自动附加本机稳定身份 X-Dsh-Device(device_id)与 X-Dsh-Phone(已登录手机号)
|
|
771
|
+
* - 透传浏览器带的 Authorization(thread_token,存于浏览器 localStorage)
|
|
772
|
+
* - 不转发 cookie/浏览器标记;反馈服务不可达时降级 502 JSON
|
|
773
|
+
*/
|
|
774
|
+
// 账号 JWT 缓存(反馈请求高频,避免每次 device-login 刷审计日志);过期前复用
|
|
775
|
+
let fbTokenCache = { token: "", exp: 0 };
|
|
776
|
+
async function feedbackAuthToken(relayDir) {
|
|
777
|
+
if (fbTokenCache.token && Date.now() < fbTokenCache.exp) return fbTokenCache.token;
|
|
778
|
+
const t = await relayToken(relayDir).catch(() => "");
|
|
779
|
+
if (t) fbTokenCache = { token: t, exp: Date.now() + 100 * 60 * 1000 };
|
|
780
|
+
else fbTokenCache = { token: "", exp: 0 };
|
|
781
|
+
return t;
|
|
782
|
+
}
|
|
783
|
+
async function proxyFeedback(relayDir, req, res, pathname) {
|
|
784
|
+
const cfg = loadConfig(relayDir);
|
|
785
|
+
const api = feedbackApiOf(cfg);
|
|
786
|
+
const suffix = pathname.replace(/^\/dsh-remote\/feedback/, "") || "/";
|
|
787
|
+
// 相对路径解析:保留基址的路径前缀(如 /relay-api),避免 new URL 绝对路径吞掉 base path
|
|
788
|
+
const base = api.endsWith("/") ? api : `${api}/`;
|
|
789
|
+
const url = new URL(suffix.replace(/^\//, ""), base);
|
|
790
|
+
const headers = {
|
|
791
|
+
"x-dsh-device": cfg.device_id || "",
|
|
792
|
+
"x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
|
|
793
|
+
};
|
|
794
|
+
if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
|
|
795
|
+
const auth = req.headers.authorization;
|
|
796
|
+
if (auth && /^Bearer\s+/i.test(auth)) {
|
|
797
|
+
headers.authorization = auth;
|
|
798
|
+
} else if (cfg.local_key || (cfg.phone && cfg.password)) {
|
|
799
|
+
// 登录态统一免验证码:节点半自动附加账号 JWT(服务端对有效 JWT 免验证码)
|
|
800
|
+
const t = await feedbackAuthToken(relayDir);
|
|
801
|
+
if (t) headers.authorization = `Bearer ${t}`;
|
|
802
|
+
}
|
|
803
|
+
const method = req.method || "GET";
|
|
804
|
+
const init = { method, headers };
|
|
805
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
806
|
+
let buf;
|
|
807
|
+
try {
|
|
808
|
+
buf = await readBodyBuffer(req);
|
|
809
|
+
} catch (e) {
|
|
810
|
+
return sendJson(res, e.status || 413, { ok: false, error: "请求体过大" });
|
|
811
|
+
}
|
|
812
|
+
if (buf.length) {
|
|
813
|
+
const ct = req.headers["content-type"] || "application/json";
|
|
814
|
+
init.body = buf;
|
|
815
|
+
headers["content-type"] = ct;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
const r = await fetch(url, { ...init, signal: AbortSignal.timeout(8000) });
|
|
820
|
+
// 401(token 失效)→ 清缓存,下次请求自动刷新
|
|
821
|
+
if (r.status === 401) fbTokenCache = { token: "", exp: 0 };
|
|
822
|
+
const text = await r.text();
|
|
823
|
+
let body = null;
|
|
824
|
+
try {
|
|
825
|
+
body = JSON.parse(text);
|
|
826
|
+
} catch {
|
|
827
|
+
body = text;
|
|
828
|
+
}
|
|
829
|
+
res.writeHead(r.status, { "content-type": r.headers.get("content-type") || "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
830
|
+
res.end(text);
|
|
831
|
+
return body;
|
|
832
|
+
} catch (e) {
|
|
833
|
+
return sendJson(res, 502, { ok: false, error: `反馈服务不可达: ${e.message}`, hint: "请确认反馈服务已启动,或检查 .dsh-config.json 的 feedback_url" });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
|
|
838
|
+
|
|
839
|
+
/** 插件 id / 包名(2026-09 由 dsh-remote-ui 更名)。 */
|
|
840
|
+
const PLUGIN_ID = "dsh-remote-web";
|
|
841
|
+
/** 更名前 id(≤0.4.9):彻底卸载/清理时一并移除,防旧拷贝残留。 */
|
|
842
|
+
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
843
|
+
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
844
|
+
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
845
|
+
const PLUGIN_VERSION = "0.5.0";
|
|
846
|
+
const UPDATE_LOG = ".dsh-update.log";
|
|
847
|
+
const UPDATE_MARKER = ".dsh-update-running";
|
|
848
|
+
|
|
849
|
+
/** 查询 npm 最新版(官方源优先,失败回退 npmmirror;纯服务端无 CORS 限制)。 */
|
|
850
|
+
async function npmLatestVersion() {
|
|
851
|
+
for (const reg of ["https://registry.npmjs.org/@mrrisega/dsh-remote", "https://registry.npmmirror.com/@mrrisega/dsh-remote"]) {
|
|
852
|
+
try {
|
|
853
|
+
const res = await fetch(reg, { signal: AbortSignal.timeout(8000) });
|
|
854
|
+
if (!res.ok) continue;
|
|
855
|
+
const j = await res.json();
|
|
856
|
+
if (j && j["dist-tags"] && typeof j["dist-tags"].latest === "string") return j["dist-tags"].latest;
|
|
857
|
+
} catch { /* 试下一个源 */ }
|
|
858
|
+
}
|
|
859
|
+
return "";
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** 以 detached 子进程执行 `npx --yes @mrrisega/dsh-remote@latest`(env 可覆盖 npm 源)。 */
|
|
863
|
+
function spawnUpdater(relayDir, extraEnv) {
|
|
864
|
+
const log = join(relayDir, UPDATE_LOG);
|
|
865
|
+
return spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote@latest"], {
|
|
866
|
+
detached: true,
|
|
867
|
+
cwd: homedir(),
|
|
868
|
+
env: spawnEnv(extraEnv), // PATH 补 node 目录:App 最小 PATH 下也能跑 npx
|
|
869
|
+
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* 后台执行在线一键更新:npx @mrrisega/dsh-remote@latest(幂等自愈:补运行环境/更新 bridge/收敛 include)。
|
|
875
|
+
* 稳健性:
|
|
876
|
+
* - npx 用绝对路径 + PATH 补全解析(App 拉起的 dsh web PATH 最小化时不再 ENOENT 静默失败);
|
|
877
|
+
* - 【官方源优先】镜像(npmmirror)滞后时会把旧版(如 0.4.4)当成最新安装,旧 pluginCmd 会把
|
|
878
|
+
* 已被移除的“用户 include”重新写回 profile → dsh web 重启重复 ID 崩溃。故先试官方源,
|
|
879
|
+
* 失败(国内网络)才回退用户默认镜像源;
|
|
880
|
+
* - marker 记录 pid,子进程退出/出错即清理;宿主重启后由 sweepStaleMarkers 立即清掉死进程残留。
|
|
881
|
+
*/
|
|
882
|
+
function runOnlineUpdate(relayDir) {
|
|
883
|
+
try {
|
|
884
|
+
mkdirSync(relayDir, { recursive: true });
|
|
885
|
+
const marker = join(relayDir, UPDATE_MARKER);
|
|
886
|
+
if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
|
|
887
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 @mrrisega/dsh-remote@latest (${new Date().toISOString()})`);
|
|
888
|
+
|
|
889
|
+
let retried = false;
|
|
890
|
+
const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
|
|
891
|
+
const run = () => {
|
|
892
|
+
// 第一次:官方 npm 源;失败(exit≠0/网络)才回退用户默认源(通常为国内镜像)
|
|
893
|
+
const child = spawnUpdater(relayDir, retried ? {} : { npm_config_registry: "https://registry.npmjs.org" });
|
|
894
|
+
writeMarker(marker, child.pid);
|
|
895
|
+
child.on("exit", (code) => {
|
|
896
|
+
if (!retried && code !== 0) {
|
|
897
|
+
retried = true;
|
|
898
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] 官方源安装失败(exit=${code}),回退默认源(npmmirror 等)重试…`);
|
|
899
|
+
run();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}(${retried ? "默认源" : "官方源"})`);
|
|
903
|
+
clear();
|
|
904
|
+
});
|
|
905
|
+
child.on("error", (e) => {
|
|
906
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
|
|
907
|
+
clear();
|
|
908
|
+
});
|
|
909
|
+
child.unref();
|
|
910
|
+
return child;
|
|
911
|
+
};
|
|
912
|
+
const child = run();
|
|
913
|
+
return { ok: true, pid: child.pid, log: join(relayDir, UPDATE_LOG) };
|
|
914
|
+
} catch (e) {
|
|
915
|
+
try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
|
|
916
|
+
return { ok: false, detail: String(e.message || e) };
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** 读日志尾部(更新进度展示)。 */
|
|
921
|
+
function tailOf(filePath, lines = 24) {
|
|
922
|
+
try {
|
|
923
|
+
const all = readFileSync(filePath, "utf8").split("\n");
|
|
924
|
+
return all.slice(-lines).join("\n");
|
|
925
|
+
} catch { return ""; }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** 彻底卸载第 2 步 —— profile 插件清理(兼容市场“拒绝改写用户补丁”):移除 include、依赖、bundle、本地目录与链接。 */
|
|
929
|
+
function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
|
|
930
|
+
const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
|
|
931
|
+
try {
|
|
932
|
+
const patch = readFileSync(patchFile, "utf8");
|
|
933
|
+
// 兼容当前与历史(dsh-remote-ui)两种管理标记
|
|
934
|
+
const cleaned = patch
|
|
935
|
+
.replace(/\n?# >>> dsh-remote-(?:web|ui) .*?# <<< dsh-remote-(?:web|ui)\s*/s, "\n")
|
|
936
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
937
|
+
.trimEnd() + "\n";
|
|
938
|
+
if (cleaned !== patch) { writeFileSync(patchFile, cleaned); out.removedPatch = true; }
|
|
939
|
+
} catch { /* 无 patch 忽略 */ }
|
|
940
|
+
try {
|
|
941
|
+
const pkg = JSON.parse(readFileSync(pkgFile, "utf8"));
|
|
942
|
+
if (pkg.dependencies) {
|
|
943
|
+
let removed = false;
|
|
944
|
+
for (const id of PLUGIN_ALL_IDS) {
|
|
945
|
+
if (pkg.dependencies[id] !== undefined) { delete pkg.dependencies[id]; removed = true; }
|
|
946
|
+
}
|
|
947
|
+
if (removed) out.removedDep = true;
|
|
948
|
+
}
|
|
949
|
+
const bundles = pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles) ? pkg.dsh.profile.bundles : null;
|
|
950
|
+
if (bundles) {
|
|
951
|
+
const filtered = bundles.filter((b) => !PLUGIN_ALL_IDS.includes(b));
|
|
952
|
+
if (filtered.length !== bundles.length) {
|
|
953
|
+
pkg.dsh.profile.bundles = filtered;
|
|
954
|
+
out.removedBundle = true;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (out.removedDep || out.removedBundle) writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
958
|
+
} catch { /* 无 package.json 忽略 */ }
|
|
959
|
+
try {
|
|
960
|
+
for (const id of PLUGIN_ALL_IDS) {
|
|
961
|
+
rmSync(join(profileDir, `${id}-plugin`), { recursive: true, force: true });
|
|
962
|
+
rmSync(join(profileDir, "node_modules", id), { recursive: true, force: true });
|
|
963
|
+
}
|
|
964
|
+
out.removedDir = true;
|
|
965
|
+
} catch { /* ignore */ }
|
|
966
|
+
return out;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ---------- 路由 ----------
|
|
970
|
+
|
|
971
|
+
/** 路由表:{method, path, handler}。 */
|
|
972
|
+
function registerRoutes(ctx, relayDir) {
|
|
973
|
+
// 本插件所在 profile(由插件自身文件位置推导,覆盖市场 git 安装与本地 include 两种形态)
|
|
974
|
+
let profileDir = join(homedir(), ".dsh", "profiles", "web");
|
|
975
|
+
try {
|
|
976
|
+
const here = fileURLToPath(import.meta.url);
|
|
977
|
+
// 依次尝试两种安装布局(当前名优先,历史名兜底);split()[0] 未命中时返回原串,需显式判断后再试下一种
|
|
978
|
+
let m = here.split("/dsh-remote-web-plugin/")[0];
|
|
979
|
+
if (m === here) m = here.split("/node_modules/dsh-remote-web/")[0];
|
|
980
|
+
if (m === here) m = here.split("/dsh-remote-ui-plugin/")[0];
|
|
981
|
+
if (m === here) m = here.split("/node_modules/dsh-remote-ui/")[0];
|
|
982
|
+
if (m !== here) profileDir = m;
|
|
983
|
+
} catch { /* 保持默认 */ }
|
|
984
|
+
const routes = [
|
|
985
|
+
// 自管理:版本信息 / 检查更新 / 一键更新 / 更新日志 / 彻底卸载
|
|
986
|
+
{
|
|
987
|
+
method: "GET",
|
|
988
|
+
path: "/dsh-remote/self",
|
|
989
|
+
handler: async (_req, res) => {
|
|
990
|
+
const runtimeReady = existsSync(join(relayDir, "dsh-setup.mjs"));
|
|
991
|
+
sendJson(res, 200, { ok: true, version: PLUGIN_VERSION, runtimeReady, relayDir });
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
method: "GET",
|
|
996
|
+
path: "/dsh-remote/self/update-check",
|
|
997
|
+
handler: async (_req, res) => {
|
|
998
|
+
const latest = await npmLatestVersion();
|
|
999
|
+
const current = PLUGIN_VERSION;
|
|
1000
|
+
sendJson(res, 200, { ok: true, current, latest, outdated: !!latest && latest !== current });
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
method: "POST",
|
|
1005
|
+
path: "/dsh-remote/self/update",
|
|
1006
|
+
handler: async (_req, res) => {
|
|
1007
|
+
sendJson(res, 200, { ok: true, ...runOnlineUpdate(relayDir) });
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
method: "GET",
|
|
1012
|
+
path: "/dsh-remote/self/update-log",
|
|
1013
|
+
handler: async (_req, res) => {
|
|
1014
|
+
sendJson(res, 200, { ok: true, running: existsSync(join(relayDir, UPDATE_MARKER)), log: tailOf(join(relayDir, UPDATE_LOG)) });
|
|
1015
|
+
},
|
|
1016
|
+
},
|
|
1017
|
+
{
|
|
1018
|
+
method: "POST",
|
|
1019
|
+
path: "/dsh-remote/self/uninstall",
|
|
1020
|
+
handler: async (_req, res) => {
|
|
1021
|
+
// 彻底卸载 = ① 运行时/bridge 清理(停自启动 → 杀残留 → 清空配置目录,顺序防 KeepAlive 复活)
|
|
1022
|
+
// + ② 插件 profile 清理(include 块/依赖/bundle/本地目录与链接,解锁市场卸载)
|
|
1023
|
+
const rt = uninstallRuntime(relayDir, profileDir);
|
|
1024
|
+
const prof = uninstallSelf(relayDir, profileDir, join(profileDir, "cordis.patch.yml"), join(profileDir, "package.json"));
|
|
1025
|
+
// 卸载后本进程内(直到重启)自愈/代持调度一律停摆,不再重建配置目录或拉起 bridge
|
|
1026
|
+
markUninstalled(relayDir);
|
|
1027
|
+
const bits = [];
|
|
1028
|
+
if (prof.removedPatch || prof.removedDep || prof.removedBundle || prof.removedDir) bits.push("插件引用与本地文件已移除");
|
|
1029
|
+
if (rt.stoppedService) bits.push("bridge 自启动服务已停止");
|
|
1030
|
+
if (rt.removedPlist) bits.push("自启动项已删除");
|
|
1031
|
+
if (rt.killedPids.length) bits.push(`已结束 ${rt.killedPids.length} 个残留进程`);
|
|
1032
|
+
if (rt.removedDir) bits.push("配置目录已清空(账号/密钥/固化运行时等)");
|
|
1033
|
+
bits.push("请重启 dsh web 后完全卸载生效(本插件与远程控制将消失);如需再次使用,在插件市场重新安装即可。");
|
|
1034
|
+
sendJson(res, 200, {
|
|
1035
|
+
ok: true,
|
|
1036
|
+
...prof, // removedPatch / removedDep / removedBundle / removedDir(profile 插件目录)
|
|
1037
|
+
servicePlatform: rt.servicePlatform,
|
|
1038
|
+
stoppedService: rt.stoppedService,
|
|
1039
|
+
removedPlist: rt.removedPlist,
|
|
1040
|
+
killedPids: rt.killedPids,
|
|
1041
|
+
relayDirRemoved: rt.removedDir, // 配置目录 relayDir 已整目录清空
|
|
1042
|
+
relayDir,
|
|
1043
|
+
detail: bits.join(";"),
|
|
1044
|
+
});
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
method: "GET",
|
|
1049
|
+
path: "/dsh-remote/status",
|
|
1050
|
+
handler: async (_req, res) => {
|
|
1051
|
+
sendJson(res, 200, await composeStatus(relayDir));
|
|
1052
|
+
},
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
method: "GET",
|
|
1056
|
+
path: "/dsh-remote/account",
|
|
1057
|
+
handler: async (_req, res) => {
|
|
1058
|
+
sendJson(res, 200, { ok: true, account: await relayAccount(relayDir) });
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
method: "GET",
|
|
1063
|
+
path: "/dsh-remote/quota",
|
|
1064
|
+
handler: async (_req, res) => {
|
|
1065
|
+
sendJson(res, 200, { ok: true, quota: await relayQuota(relayDir) });
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
method: "GET",
|
|
1070
|
+
path: "/dsh-remote/invite-records",
|
|
1071
|
+
handler: async (_req, res) => {
|
|
1072
|
+
sendJson(res, 200, { ok: true, ...(await relayInviteRecords(relayDir)) });
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
method: "GET",
|
|
1077
|
+
path: "/dsh-remote/remote-url",
|
|
1078
|
+
handler: async (_req, res) => {
|
|
1079
|
+
const pub = await relayFetch(relayDir, "/api/public-config");
|
|
1080
|
+
const body = pub.ok && pub.body && typeof pub.body === "object" ? pub.body : {};
|
|
1081
|
+
sendJson(res, 200, {
|
|
1082
|
+
ok: true,
|
|
1083
|
+
remoteUrl: body.app_url || DEFAULT_APP_URL,
|
|
1084
|
+
relayReachable: pub.ok,
|
|
1085
|
+
publicConfig: body
|
|
1086
|
+
});
|
|
1087
|
+
},
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
method: "POST",
|
|
1091
|
+
path: "/dsh-remote/config",
|
|
1092
|
+
handler: async (req, res) => {
|
|
1093
|
+
const body = await readJsonBody(req);
|
|
1094
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
1095
|
+
const cfg = loadConfig(relayDir);
|
|
1096
|
+
const mode = body.mode === "local" ? "local" : "saas";
|
|
1097
|
+
if (mode === "local") {
|
|
1098
|
+
// 自建模式:服务器地址 + 访问密钥(免账号体系;随时可切回 SaaS)
|
|
1099
|
+
const selfHostUrl = String(body.selfHostUrl ?? "").trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
1100
|
+
const localKey = String(body.localKey ?? "").trim();
|
|
1101
|
+
if (!selfHostUrl || !localKey) return sendJson(res, 400, { ok: false, error: "自建模式需要服务器地址与访问密钥" });
|
|
1102
|
+
cfg.tunnel_url = `wss://${selfHostUrl}`;
|
|
1103
|
+
cfg.local_key = localKey;
|
|
1104
|
+
} else {
|
|
1105
|
+
const phone = String(body.phone ?? "").trim();
|
|
1106
|
+
const password = String(body.password ?? "");
|
|
1107
|
+
if (!phone || !password) return sendJson(res, 400, { ok: false, error: "手机号与密码必填" });
|
|
1108
|
+
const accountChanged = (cfg.phone || cfg.email || "") !== phone || Boolean(cfg.email && cfg.email !== phone);
|
|
1109
|
+
cfg.phone = phone;
|
|
1110
|
+
cfg.password = password;
|
|
1111
|
+
delete cfg.email;
|
|
1112
|
+
if (accountChanged) {
|
|
1113
|
+
delete cfg.device_id;
|
|
1114
|
+
delete cfg.device_private_key;
|
|
1115
|
+
delete cfg.device_public_key;
|
|
1116
|
+
}
|
|
1117
|
+
// SaaS 权威归一化:清除自建残留(local_key/假 tunnel_url),api/tunnel 一律按云端重算
|
|
1118
|
+
applySaaSMode(cfg);
|
|
1119
|
+
}
|
|
1120
|
+
saveConfig(relayDir, cfg);
|
|
1121
|
+
const bridgeRestart = startBridge(relayDir);
|
|
1122
|
+
sendJson(res, 200, { ok: true, bridgeRestart, ...(await composeStatus(relayDir)) });
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
method: "POST",
|
|
1127
|
+
path: "/dsh-remote/logout",
|
|
1128
|
+
handler: async (_req, res) => {
|
|
1129
|
+
// 退出登录:清除本机保存的账号(邮箱/密码),bridge 下次重启将不再自动登录
|
|
1130
|
+
const cfg = loadConfig(relayDir);
|
|
1131
|
+
delete cfg.phone;
|
|
1132
|
+
delete cfg.password;
|
|
1133
|
+
saveConfig(relayDir, cfg);
|
|
1134
|
+
sendJson(res, 200, { ok: true, ...(await composeStatus(relayDir)) });
|
|
1135
|
+
},
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
method: "POST",
|
|
1139
|
+
path: "/dsh-remote/start",
|
|
1140
|
+
handler: async (_req, res) => {
|
|
1141
|
+
const r = startBridge(relayDir);
|
|
1142
|
+
sendJson(res, r.ok ? 200 : 500, { ok: r.ok, status: r.status, pid: r.pid, detail: r.detail, ...(await composeStatus(relayDir)) });
|
|
1143
|
+
},
|
|
1144
|
+
},
|
|
1145
|
+
{
|
|
1146
|
+
method: "POST",
|
|
1147
|
+
path: "/dsh-remote/stop",
|
|
1148
|
+
handler: async (_req, res) => {
|
|
1149
|
+
const r = stopBridge();
|
|
1150
|
+
sendJson(res, r.ok ? 200 : 500, { ok: r.ok, status: r.status, detail: r.detail, ...(await composeStatus(relayDir)) });
|
|
1151
|
+
},
|
|
1152
|
+
},
|
|
1153
|
+
{
|
|
1154
|
+
method: "POST",
|
|
1155
|
+
path: "/dsh-remote/sms-code",
|
|
1156
|
+
handler: async (req, res) => {
|
|
1157
|
+
const body = await readJsonBody(req);
|
|
1158
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
1159
|
+
const phone = String(body.phone ?? "").trim();
|
|
1160
|
+
if (!phone) return sendJson(res, 400, { ok: false, error: "手机号必填" });
|
|
1161
|
+
const r = await relayFetch(relayDir, "/api/sms-code", {
|
|
1162
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
1163
|
+
body: JSON.stringify({ phone, ...(body.captcha_id !== undefined ? { captcha_id: String(body.captcha_id), captcha_answer: String(body.captcha_answer ?? "") } : {}) }),
|
|
1164
|
+
});
|
|
1165
|
+
sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
|
|
1166
|
+
},
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
method: "GET",
|
|
1170
|
+
path: "/dsh-remote/captcha",
|
|
1171
|
+
handler: async (_req, res) => {
|
|
1172
|
+
// 代理 relay /api/captcha。live 契约:200 JSON {captcha_id, svg};
|
|
1173
|
+
// 兼容旧服务端可能返回的图片(content-type 以 image/ 开头时原样透传)。
|
|
1174
|
+
const cfg = loadConfig(relayDir);
|
|
1175
|
+
const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
1176
|
+
try {
|
|
1177
|
+
const r = await fetch(`${api}/api/captcha`, { signal: AbortSignal.timeout(6000) });
|
|
1178
|
+
const type = r.headers.get("content-type") || "";
|
|
1179
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
1180
|
+
if (!r.ok) {
|
|
1181
|
+
sendJson(res, r.status, { ok: false, error: "验证码获取失败", relayStatus: r.status });
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (type.startsWith("image/")) {
|
|
1185
|
+
res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
|
|
1186
|
+
res.end(buf);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
// JSON({captcha_id, svg})原样透传
|
|
1190
|
+
res.writeHead(200, { "content-type": type || "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
1191
|
+
res.end(buf);
|
|
1192
|
+
} catch (e) {
|
|
1193
|
+
sendJson(res, 502, { ok: false, error: `验证码服务不可达: ${e.message}` });
|
|
1194
|
+
}
|
|
1195
|
+
},
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
method: "POST",
|
|
1199
|
+
path: "/dsh-remote/register",
|
|
1200
|
+
handler: async (req, res) => {
|
|
1201
|
+
const body = await readJsonBody(req);
|
|
1202
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
1203
|
+
const phone = String(body.phone ?? "").trim();
|
|
1204
|
+
const smsCode = String(body.sms_code ?? "").trim();
|
|
1205
|
+
const password = String(body.password ?? "");
|
|
1206
|
+
if (!phone || !smsCode || !password) return sendJson(res, 400, { ok: false, error: "手机号、短信验证码与密码必填" });
|
|
1207
|
+
const payload = { phone, sms_code: smsCode, password };
|
|
1208
|
+
const captchaId = body.captcha_id ?? body.captchaId;
|
|
1209
|
+
const captchaAnswer = body.captcha_answer ?? body.captcha;
|
|
1210
|
+
if (captchaId !== void 0) payload.captcha_id = String(captchaId);
|
|
1211
|
+
if (captchaAnswer !== void 0) payload.captcha_answer = String(captchaAnswer);
|
|
1212
|
+
const r = await relayFetch(relayDir, "/api/register", {
|
|
1213
|
+
method: "POST",
|
|
1214
|
+
headers: { "content-type": "application/json" },
|
|
1215
|
+
body: JSON.stringify(payload),
|
|
1216
|
+
});
|
|
1217
|
+
// 透传 relay 响应体(成功 {token,user} / 失败 {error:{message}})
|
|
1218
|
+
sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
|
|
1219
|
+
},
|
|
1220
|
+
},
|
|
1221
|
+
{
|
|
1222
|
+
method: "POST",
|
|
1223
|
+
path: "/dsh-remote/login",
|
|
1224
|
+
handler: async (req, res) => {
|
|
1225
|
+
const body = await readJsonBody(req);
|
|
1226
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
1227
|
+
const phone = String(body.phone ?? "").trim();
|
|
1228
|
+
const password = String(body.password ?? "");
|
|
1229
|
+
if (!phone || !password) return sendJson(res, 400, { ok: false, error: "手机号与密码必填" });
|
|
1230
|
+
// 登录接口已加图形验证码,透传 captcha 字段
|
|
1231
|
+
const payload = { phone, password };
|
|
1232
|
+
if (body.captcha_id !== undefined) payload.captcha_id = String(body.captcha_id);
|
|
1233
|
+
if (body.captcha_answer !== undefined) payload.captcha_answer = String(body.captcha_answer);
|
|
1234
|
+
const r = await relayFetch(relayDir, "/api/login", {
|
|
1235
|
+
method: "POST",
|
|
1236
|
+
headers: { "content-type": "application/json" },
|
|
1237
|
+
body: JSON.stringify(payload),
|
|
1238
|
+
});
|
|
1239
|
+
sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
|
|
1240
|
+
},
|
|
1241
|
+
},
|
|
1242
|
+
{
|
|
1243
|
+
method: "GET",
|
|
1244
|
+
path: "/dsh-remote/feedback-config",
|
|
1245
|
+
handler: async (_req, res) => {
|
|
1246
|
+
const cfg = loadConfig(relayDir);
|
|
1247
|
+
const api = feedbackApiOf(cfg);
|
|
1248
|
+
let reachable = false;
|
|
1249
|
+
try {
|
|
1250
|
+
const r = await fetch(`${api}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
1251
|
+
reachable = r.ok;
|
|
1252
|
+
} catch {
|
|
1253
|
+
reachable = false;
|
|
1254
|
+
}
|
|
1255
|
+
sendJson(res, 200, {
|
|
1256
|
+
ok: true,
|
|
1257
|
+
feedbackUrl: api,
|
|
1258
|
+
reachable,
|
|
1259
|
+
deviceId: cfg.device_id || "",
|
|
1260
|
+
phone: cfg.phone || "",
|
|
1261
|
+
// 登录态(已配置账号或自建密钥)→ 节点半自动附加 JWT,免图形验证码
|
|
1262
|
+
auth: cfg.local_key || (cfg.phone && cfg.password) ? "account" : "anonymous"
|
|
1263
|
+
});
|
|
1264
|
+
},
|
|
1265
|
+
},
|
|
1266
|
+
{
|
|
1267
|
+
method: "ALL",
|
|
1268
|
+
path: "/dsh-remote/feedback",
|
|
1269
|
+
prefix: true,
|
|
1270
|
+
handler: async (req, res) => {
|
|
1271
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
1272
|
+
await proxyFeedback(relayDir, req, res, url.pathname + url.search);
|
|
1273
|
+
},
|
|
1274
|
+
},
|
|
1275
|
+
];
|
|
1276
|
+
|
|
1277
|
+
const disposers = [];
|
|
1278
|
+
for (const route of routes) {
|
|
1279
|
+
const dispose = ctx.webServer.register({
|
|
1280
|
+
kind: route.prefix ? "prefix" : "exact",
|
|
1281
|
+
path: route.path,
|
|
1282
|
+
handler: (req, res) => {
|
|
1283
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
1284
|
+
const match = route.prefix
|
|
1285
|
+
? url.pathname === route.path || url.pathname.startsWith(route.path + "/")
|
|
1286
|
+
: url.pathname === route.path;
|
|
1287
|
+
const methodOk = route.method === "ALL" || req.method === route.method;
|
|
1288
|
+
if (!match || !methodOk) {
|
|
1289
|
+
res.writeHead(404);
|
|
1290
|
+
res.end();
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
Promise.resolve(route.handler(req, res)).catch((e) => {
|
|
1294
|
+
ctx.logger?.warn?.(`dsh-remote-web: ${route.method} ${route.path} failed: ${e?.stack || e}`);
|
|
1295
|
+
if (!res.headersSent) sendJson(res, 500, { ok: false, error: String(e?.message || e) });
|
|
1296
|
+
else res.end();
|
|
1297
|
+
});
|
|
1298
|
+
return; // webserver 不需要返回值;返回 void 保持 node:http 语义
|
|
1299
|
+
},
|
|
1300
|
+
});
|
|
1301
|
+
disposers.push(dispose);
|
|
1302
|
+
}
|
|
1303
|
+
return () => {
|
|
1304
|
+
for (const dispose of disposers) dispose();
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* 插件主体:注册 /dsh-remote/* 路由。
|
|
1310
|
+
* @param ctx - host cordis context(注入 webServer)。
|
|
1311
|
+
* @param config - entry config(可选 relayDir)。
|
|
1312
|
+
*/
|
|
1313
|
+
export function apply(ctx, config = {}) {
|
|
1314
|
+
const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
|
|
1315
|
+
// 全新激活(dsh web 重启后插件重新加载,或卸载后再次安装)→ 解除上次的「已卸载」停摆标记
|
|
1316
|
+
UNINSTALLED_DIRS.delete(relayDir);
|
|
1317
|
+
// 清理上次进程残留的安装/更新 marker(宿主被重启/强杀时子进程清理回调会丢失)
|
|
1318
|
+
sweepStaleMarkers(relayDir);
|
|
1319
|
+
ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-web: /dsh-remote routes");
|
|
1320
|
+
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
1321
|
+
ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
|
|
1322
|
+
// 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
|
|
1323
|
+
ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-web: runtime self-provision");
|
|
1324
|
+
ctx.logger?.info?.(`dsh-remote-web: /dsh-remote routes ready (relayDir=${relayDir})`);
|
|
1325
|
+
}
|