@mrrisega/dsh-remote 0.6.4-beta.5 → 0.6.4-beta.7
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 +1 -0
- package/dsh-setup.mjs +465 -71
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +2 -15
- package/packages/dsh-remote-web/lib/index.js +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +9 -1
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +78 -0
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +172 -0
- package/packages/dsh-remote-web/test/setup-output.test.mjs +27 -2
- package/packages/dsh-remote-web/test/telemetry.test.mjs +13 -6
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
|
|
|
34
34
|
- **手机端自动等待电脑上线**:空设备列表不再让人干等或手动刷新,电脑一上线设备自己出现。
|
|
35
35
|
- **安装引导改版**:有插件市场入口就搜索 `dsh-remote` 安装(不用敲命令);没有就复制一条命令;两条路都收敛到「登录后稍等,电脑会自己出现」。
|
|
36
36
|
- **安装输出更干净**:不再重复打印同一段引导,结尾只给一份汇总(地址 / 自启动服务 / 下一步);装完当下 dsh web 没开着也会如实说明「打开 dsh web 后 bridge 会自动启动」,不再像报错。
|
|
37
|
+
- **macOS 26 自启动死角已修**:macOS 26 会把 `gui/<uid>` 会话域置为 on-demand-only,`RunAtLoad` / `KeepAlive` 失效(服务只登记不启动,`runs = 0`)。现在优先用 `user/<uid>` 域启动(该域仍支持开机自启与崩溃自愈),失败才回退 `gui/<uid>` + `kickstart`(只拉起这一次,会**明确告知**不支持崩溃自愈),再不行退化为后台进程(现在能用、无自启)。自启动服务的 PATH 也补了 `/usr/sbin`、`/sbin`(bridge 要调 `ioreg`)。
|
|
37
38
|
|
|
38
39
|
**0.6.2 起已有**
|
|
39
40
|
|
package/dsh-setup.mjs
CHANGED
|
@@ -32,8 +32,13 @@ import { childStopped } from "./clients/dsh-remote/src/lifecycle.mjs";
|
|
|
32
32
|
|
|
33
33
|
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
|
|
34
34
|
const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
|
|
35
|
-
//
|
|
36
|
-
|
|
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");
|
|
37
42
|
const CONFIG_PATH = path.join(CONFIG_DIR, ".dsh-config.json");
|
|
38
43
|
// 默认云端服务地址(服务商 SaaS 入口;自建用户用 --server/--key 指向自己的 router)
|
|
39
44
|
const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
|
|
@@ -131,6 +136,13 @@ function preferredNode() {
|
|
|
131
136
|
}
|
|
132
137
|
const NODE_BIN = preferredNode();
|
|
133
138
|
|
|
139
|
+
/**
|
|
140
|
+
* 自启动服务里注入的 PATH。
|
|
141
|
+
* 必须含 /usr/sbin 与 /sbin:bridge 会用 ioreg 之类系统命令探测本机信息,
|
|
142
|
+
* 缺少这两个目录时 launchd 服务日志里会刷 `ioreg: command not found`(用户反馈实测)。
|
|
143
|
+
*/
|
|
144
|
+
const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
145
|
+
|
|
134
146
|
// ---------- 安装口径(随 bridge 环境变量注入;服务端存到设备行,供运营统计) ----------
|
|
135
147
|
/**
|
|
136
148
|
* install_source:本文件即「一键安装器」→ 默认 npx(用户自己跑 `npx @mrrisega/dsh-remote` 的那条路径)。
|
|
@@ -194,37 +206,62 @@ function hasFlag(argv, name) {
|
|
|
194
206
|
}
|
|
195
207
|
|
|
196
208
|
// ---------- 自启动服务生成与热启动 ----------
|
|
209
|
+
/** 自启动服务 label(macOS LaunchAgent / Linux systemd 共用同一个名字)。 */
|
|
210
|
+
const BRIDGE_LABEL = "com.dshremote.bridge";
|
|
211
|
+
|
|
197
212
|
function autostartFilePath() {
|
|
198
213
|
if (process.platform === "darwin")
|
|
199
|
-
return path.join(os.homedir(),
|
|
214
|
+
return path.join(os.homedir(), `Library/LaunchAgents/${BRIDGE_LABEL}.plist`);
|
|
200
215
|
if (process.platform === "linux")
|
|
201
216
|
return path.join(os.homedir(), ".config/systemd/user/dsh-bridge.service");
|
|
202
217
|
return null;
|
|
203
218
|
}
|
|
204
219
|
|
|
220
|
+
/** 同步睡眠(生成 launchd 状态轮询用;不引第三方依赖)。 */
|
|
221
|
+
function sleepSync(ms) {
|
|
222
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
223
|
+
catch { /* 极端环境不支持 → 退化为忙等一小会 */ const end = Date.now() + ms; while (Date.now() < end) { /* spin */ } }
|
|
224
|
+
}
|
|
225
|
+
|
|
205
226
|
function writeAutostartFile() {
|
|
206
227
|
const runCmd = `"${NODE_BIN}" "${runtimeSetupPath()}" run`;
|
|
207
228
|
if (process.platform === "darwin") {
|
|
208
229
|
const plistPath = autostartFilePath();
|
|
230
|
+
// LimitLoadToSessionType 是**必需项**,不是可选优化:
|
|
231
|
+
// · macOS 26 上 gui/<uid> 会进入 on-demand-only 模式,RunAtLoad/KeepAlive 全部失效
|
|
232
|
+
// (只登记不派生,runs=0,日志报 "pending spawn, domain in on-demand-only mode");
|
|
233
|
+
// 迁到 user/<uid> domain 才能恢复派生与崩溃自愈。
|
|
234
|
+
// · 而不带该键时 `launchctl bootstrap user/<uid>` 直接失败(实测 rc=5 Input/output error)。
|
|
235
|
+
// · 带上 [Aqua, Background] 后 user/<uid> 与 gui/<uid> 都能正常装载(macOS 14/26 均实测)。
|
|
236
|
+
// 仍保留 RunAtLoad/KeepAlive:user domain 下它们是真生效的(实测 kill -9 后自动重建)。
|
|
209
237
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
210
238
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
211
239
|
<plist version="1.0"><dict>
|
|
212
|
-
<key>Label</key><string
|
|
240
|
+
<key>Label</key><string>${BRIDGE_LABEL}</string>
|
|
213
241
|
<key>ProgramArguments</key>
|
|
214
242
|
<array><string>${NODE_BIN}</string><string>${runtimeSetupPath()}</string><string>run</string></array>
|
|
215
243
|
<key>RunAtLoad</key><true/>
|
|
216
244
|
<key>KeepAlive</key><true/>
|
|
245
|
+
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
217
246
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
218
247
|
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
219
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string
|
|
248
|
+
<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>
|
|
220
249
|
</dict></plist>`;
|
|
221
|
-
|
|
250
|
+
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
251
|
+
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
252
|
+
try {
|
|
253
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
254
|
+
fs.writeFileSync(plistPath, plist);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
console.warn(`⚠️ 自启动服务写入失败: ${e.message}(可稍后用 \`dsh-remote install\` 重试)`);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
222
259
|
return plistPath; // 路径在最终汇总里统一展示(避免同一信息打印两遍)
|
|
223
260
|
}
|
|
224
261
|
if (process.platform === "linux") {
|
|
225
262
|
const dir = path.join(os.homedir(), ".config/systemd/user");
|
|
226
263
|
fs.mkdirSync(dir, { recursive: true });
|
|
227
|
-
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH
|
|
264
|
+
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`;
|
|
228
265
|
const unitPath = autostartFilePath();
|
|
229
266
|
fs.writeFileSync(unitPath, unit);
|
|
230
267
|
return unitPath; // 路径在最终汇总里统一展示
|
|
@@ -233,37 +270,128 @@ function writeAutostartFile() {
|
|
|
233
270
|
return null;
|
|
234
271
|
}
|
|
235
272
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
273
|
+
/**
|
|
274
|
+
* 读取 launchd job 状态。
|
|
275
|
+
* deferred = launchd 登记了但**从未派生**(macOS 26 的 on-demand-only 特征);
|
|
276
|
+
* 光看 "state = not running" 无法区分"被系统挂起"与"正常退出",必须单独识别才能给出正确建议。
|
|
277
|
+
*/
|
|
278
|
+
function launchdState(target) {
|
|
279
|
+
const pr = sh(`launchctl print ${target}`);
|
|
280
|
+
if (!pr.ok) return { exists: false, running: false, pid: null, deferred: false, raw: "" };
|
|
281
|
+
const raw = pr.stdout;
|
|
282
|
+
const running = /state\s*=\s*running/.test(raw);
|
|
283
|
+
const m = raw.match(/pid\s*=\s*(\d+)/);
|
|
284
|
+
const runs = raw.match(/runs\s*=\s*(\d+)/);
|
|
285
|
+
return {
|
|
286
|
+
exists: true,
|
|
287
|
+
running,
|
|
288
|
+
pid: running && m ? Number(m[1]) : null,
|
|
289
|
+
runs: runs ? Number(runs[1]) : 0,
|
|
290
|
+
deferred: /pended nondemand spawn/.test(raw) || /on-demand-only/.test(raw),
|
|
291
|
+
raw
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 轮询等待 job 起来。
|
|
297
|
+
* launchd 派生是异步的:bootstrap 成功后立刻 print 必然看到 not running(旧实现因此误报"启动失败")。
|
|
298
|
+
* 明确"被挂起且没有派生"时提前放弃(等满也没用),否则给足 ~8 秒。
|
|
299
|
+
*/
|
|
300
|
+
function waitLaunchdRunning(target, tries = 20, gapMs = 400) {
|
|
301
|
+
let st = launchdState(target);
|
|
302
|
+
for (let i = 0; i < tries; i += 1) {
|
|
303
|
+
st = launchdState(target);
|
|
304
|
+
if (st.running) return st;
|
|
305
|
+
// 已登记、没在跑、又没被挂起(runs>0 说明派生过)→ 是启动后立刻退出,不必等满
|
|
306
|
+
if (st.exists && st.runs > 0 && !st.deferred) break;
|
|
307
|
+
if (i >= 2 && st.exists && !st.deferred && st.runs === 0) break; // 登记了却一次都没派生 → 被挂起
|
|
308
|
+
sleepSync(gapMs);
|
|
240
309
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
310
|
+
return st;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 终极兜底:脱离 launchd 直接后台拉起 bridge(至少"现在能用",但没有开机自启/崩溃自愈)。 */
|
|
314
|
+
function spawnDetachedBridge() {
|
|
315
|
+
try {
|
|
316
|
+
const logPath = path.join(CONFIG_DIR, ".dsh-bridge.log");
|
|
317
|
+
const out = fs.openSync(logPath, "a");
|
|
318
|
+
const child = spawn(NODE_BIN, [runtimeSetupPath(), "run"], {
|
|
319
|
+
detached: true,
|
|
320
|
+
stdio: ["ignore", out, out],
|
|
321
|
+
env: process.env
|
|
322
|
+
});
|
|
323
|
+
child.unref();
|
|
324
|
+
fs.closeSync(out);
|
|
325
|
+
return { ok: Boolean(child.pid), pid: child.pid || null };
|
|
326
|
+
} catch (e) {
|
|
327
|
+
return { ok: false, pid: null, error: e.message };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* macOS:按「可自愈优先」的阶梯启动 bridge。
|
|
333
|
+
* ① user/<uid> ← 首选。macOS 26 上唯一支持 RunAtLoad/KeepAlive 的 domain(可崩溃自愈)
|
|
334
|
+
* ② gui/<uid> + kickstart ← 兼容兜底。bootstrap 只登记不派生时用 kickstart 强拉一次;
|
|
335
|
+
* 但实测 kickstart 只解决"这一次",进程被杀后不会重建(KeepAlive 已被系统绕过)→ 如实告知
|
|
336
|
+
* ③ 后台进程 ← 连 launchd 都托管不了时保证可用
|
|
337
|
+
*/
|
|
338
|
+
function startBridgeDarwin(plistPath) {
|
|
339
|
+
const uid = process.getuid();
|
|
340
|
+
const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
341
|
+
let lastDetail = "";
|
|
342
|
+
for (const domain of [`user/${uid}`, `gui/${uid}`]) {
|
|
343
|
+
const target = `${domain}/${BRIDGE_LABEL}`;
|
|
344
|
+
// 清掉另一个 domain 的历史注册,避免两处并存导致重复实例
|
|
345
|
+
for (const d of [`user/${uid}`, `gui/${uid}`]) {
|
|
346
|
+
if (d !== domain) sh(`launchctl bootout ${d}/${BRIDGE_LABEL}`);
|
|
347
|
+
}
|
|
248
348
|
sh(`launchctl bootout ${target}`);
|
|
249
349
|
let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
250
350
|
if (!boot.ok) {
|
|
351
|
+
// 老写法兜底(部分系统上 load -w 仍可用)
|
|
251
352
|
sh(`launchctl unload ${q(plistPath)}`);
|
|
252
353
|
boot = sh(`launchctl load -w ${q(plistPath)}`);
|
|
253
354
|
}
|
|
254
|
-
if (!boot.ok)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const m = pr.stdout.match(/pid\s*=\s*(\d+)/);
|
|
258
|
-
return { ok: true, status: "running", pid: m ? Number(m[1]) : null };
|
|
355
|
+
if (!boot.ok) {
|
|
356
|
+
lastDetail = (boot.stderr || boot.stdout).trim() || "launchctl bootstrap 失败";
|
|
357
|
+
continue;
|
|
259
358
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
359
|
+
// bootstrap 只是登记;立刻 kickstart 一次,确保"这一次"一定起来
|
|
360
|
+
// (on-demand-only 的 gui domain 下这是唯一能拉起的手段)
|
|
361
|
+
sh(`launchctl kickstart -k ${target}`);
|
|
362
|
+
const st = waitLaunchdRunning(target);
|
|
363
|
+
if (st.running) {
|
|
364
|
+
// 被挂起(deferred)过一次 = 本机 launchd 不保证崩溃自愈,必须如实报告,不能报成完全成功
|
|
365
|
+
const selfHealing = !st.deferred;
|
|
366
|
+
return { ok: true, status: "running", pid: st.pid, domain, selfHealing, detail: selfHealing ? "" : "本机 launchd 处于 on-demand-only,进程退出后不会自动重建" };
|
|
265
367
|
}
|
|
266
|
-
|
|
368
|
+
lastDetail = st.deferred
|
|
369
|
+
? `launchd 拒绝派生(domain ${domain} 处于 on-demand-only 模式)`
|
|
370
|
+
: (st.exists ? `已登记但未运行(runs=${st.runs})` : "已登记但查不到状态");
|
|
371
|
+
}
|
|
372
|
+
const d = spawnDetachedBridge();
|
|
373
|
+
if (d.ok) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
status: "degraded-detached",
|
|
377
|
+
pid: d.pid,
|
|
378
|
+
domain: null,
|
|
379
|
+
selfHealing: false,
|
|
380
|
+
detail: "launchd 无法托管本机 bridge,已改为后台进程运行(现在可用,但不会开机自启、崩溃后不自动恢复)"
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return { ok: false, status: "failed", detail: `launchd 与后台进程均启动失败:${lastDetail}` };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function restartBridgeService() {
|
|
387
|
+
const svcFile = autostartFilePath();
|
|
388
|
+
if (svcFile && !fs.existsSync(svcFile)) {
|
|
389
|
+
writeAutostartFile(); // 静默补生成(调用方会汇总展示路径)
|
|
390
|
+
}
|
|
391
|
+
if (process.platform === "darwin") {
|
|
392
|
+
const plistPath = autostartFilePath();
|
|
393
|
+
if (!fs.existsSync(plistPath)) return { ok: false, status: "not-installed", detail: "plist 不存在" };
|
|
394
|
+
return startBridgeDarwin(plistPath);
|
|
267
395
|
}
|
|
268
396
|
if (process.platform === "linux") {
|
|
269
397
|
const r = sh(`systemctl --user restart dsh-bridge`);
|
|
@@ -291,6 +419,82 @@ async function isDshWebUp(timeoutMs = 1200) {
|
|
|
291
419
|
} catch { return false; }
|
|
292
420
|
}
|
|
293
421
|
|
|
422
|
+
/**
|
|
423
|
+
* 解析 dsh web profile 目录(安装与插件管理共用同一口径)。
|
|
424
|
+
*/
|
|
425
|
+
function resolveProfileDir(argv = []) {
|
|
426
|
+
const i = argv.indexOf("--profile");
|
|
427
|
+
return i > -1 && argv[i + 1]
|
|
428
|
+
? argv[i + 1]
|
|
429
|
+
: process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** 监听 127.0.0.1:3080 的进程 pid(= dsh web)。lsof 不可用时回退 pgrep。 */
|
|
433
|
+
function dshWebPid() {
|
|
434
|
+
const l = sh("lsof -nP -iTCP:3080 -sTCP:LISTEN -t 2>/dev/null || true");
|
|
435
|
+
const pid = String(l.stdout || "").trim().split(/\s+/).filter(Boolean)[0];
|
|
436
|
+
if (pid && /^\d+$/.test(pid)) return Number(pid);
|
|
437
|
+
const p = sh("pgrep -f 'dsh web' 2>/dev/null | head -1");
|
|
438
|
+
const p2 = String(p.stdout || "").trim();
|
|
439
|
+
return /^\d+$/.test(p2) ? Number(p2) : null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* 进程启动时刻(毫秒)。
|
|
444
|
+
* 用 `ps -o lstart=`(如 "Sun Sep 13 13:54:43 2026")——它**不含年份以外的 TZ 差异**,
|
|
445
|
+
* 比 `ps -o etimes=` 稳(后者在部分平台不可用),比 /proc 通用。
|
|
446
|
+
*/
|
|
447
|
+
function processStartMs(pid) {
|
|
448
|
+
try {
|
|
449
|
+
const r = sh(`ps -o lstart= -p ${Number(pid)}`);
|
|
450
|
+
const t = Date.parse(String(r.stdout || "").trim());
|
|
451
|
+
return Number.isFinite(t) ? t : 0;
|
|
452
|
+
} catch { return 0; }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 重启 dsh web。
|
|
457
|
+
* dsh web 由**用户手工或 launchd** 跑着,插件是进程启动时装载的,所以更新插件后必须重启它才有面板。
|
|
458
|
+
* 阶梯:launchd job(macOS)→ systemd user unit(Linux)→ 原命令行自拉起 → 交给用户手动。
|
|
459
|
+
*/
|
|
460
|
+
function dshWebLaunchdJob() {
|
|
461
|
+
const l = sh("launchctl list 2>/dev/null | grep -i dsh | grep -v dshremote || true");
|
|
462
|
+
for (const line of String(l.stdout || "").split("\n")) {
|
|
463
|
+
const label = line.trim().split(/\s+/).pop();
|
|
464
|
+
if (label && /dsh/i.test(label)) return label;
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function restartDshWeb() {
|
|
470
|
+
const uid = process.getuid();
|
|
471
|
+
if (process.platform === "darwin") {
|
|
472
|
+
const job = dshWebLaunchdJob();
|
|
473
|
+
if (job) {
|
|
474
|
+
const r = sh(`launchctl kickstart -k gui/${uid}/${job}`);
|
|
475
|
+
if (r.ok) return { ok: true, how: `launchctl kickstart ${job}` };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (process.platform === "linux") {
|
|
479
|
+
const r = sh("systemctl --user restart dsh-web 2>/dev/null || systemctl --user restart dsh 2>/dev/null");
|
|
480
|
+
if (r.ok) return { ok: true, how: "systemctl --user restart dsh-web" };
|
|
481
|
+
}
|
|
482
|
+
// 兜底:沿用原命令行重启(只在能拿到真实 node 入口时做,npx 缓存路径不可靠)
|
|
483
|
+
const pid = dshWebPid();
|
|
484
|
+
if (!pid) return { ok: false, how: "", detail: "未找到 dsh web 进程" };
|
|
485
|
+
const cmd = String(sh(`ps -o command= -p ${pid}`).stdout || "").trim();
|
|
486
|
+
if (!cmd || /node_modules\/\.bin|_npx/.test(cmd)) {
|
|
487
|
+
return { ok: false, how: "", detail: "无法获取可复用的启动命令(可能是经 npx 启动),请手动重启" };
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
const child = spawn("/bin/sh", ["-c", cmd], { detached: true, stdio: "ignore", cwd: os.homedir() });
|
|
491
|
+
child.unref();
|
|
492
|
+
return { ok: true, how: "原命令行重新拉起" };
|
|
493
|
+
} catch (e) {
|
|
494
|
+
return { ok: false, how: "", detail: e.message };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
294
498
|
function installAutostart() {
|
|
295
499
|
const svcPath = writeAutostartFile();
|
|
296
500
|
const r = restartBridgeService();
|
|
@@ -400,6 +604,7 @@ async function setup(argv) {
|
|
|
400
604
|
const key = argValue(argv, "--key");
|
|
401
605
|
const noAutostart = hasFlag(argv, "--no-autostart");
|
|
402
606
|
const noPlugin = hasFlag(argv, "--no-plugin");
|
|
607
|
+
const noRestart = hasFlag(argv, "--no-restart");
|
|
403
608
|
const selfHosted = Boolean(server || key);
|
|
404
609
|
|
|
405
610
|
let cfg = loadConfig();
|
|
@@ -457,23 +662,76 @@ async function setup(argv) {
|
|
|
457
662
|
const st = svc.status;
|
|
458
663
|
|
|
459
664
|
// 自动安装 dsh web 插件(非致命:失败只提示,不阻断安装)
|
|
665
|
+
let pluginResult = null;
|
|
460
666
|
if (!noPlugin) {
|
|
461
667
|
try {
|
|
462
|
-
await pluginCmd([]);
|
|
668
|
+
pluginResult = await pluginCmd([]);
|
|
463
669
|
} catch (e) {
|
|
464
670
|
console.warn(`⚠️ 插件安装未完成:${e.message}`);
|
|
465
671
|
}
|
|
466
672
|
}
|
|
467
673
|
|
|
468
|
-
|
|
674
|
+
// 插件是**进程启动时**装载的:刚装进 profile 的插件,运行中的 dsh web 里并没有
|
|
675
|
+
// (既没有 /dsh-remote/* 路由,也没有「设置 → 远程控制」面板项)。
|
|
676
|
+
// 这里做一个确定性判断:进程启动时间早于插件落盘时间 → 必须重启 dsh web 才生效。
|
|
677
|
+
const pluginDir = path.join(resolveProfileDir(argv), PLUGIN_LOCAL_DIR);
|
|
678
|
+
const pluginMtime = fs.existsSync(path.join(pluginDir, "lib", "index.js"))
|
|
679
|
+
? fs.statSync(path.join(pluginDir, "lib", "index.js")).mtimeMs
|
|
680
|
+
: 0;
|
|
681
|
+
let webPid = null;
|
|
682
|
+
let needRestart = false;
|
|
683
|
+
let hotMounted = false;
|
|
469
684
|
const webUp = await isDshWebUp();
|
|
685
|
+
if (webUp) {
|
|
686
|
+
webPid = dshWebPid();
|
|
687
|
+
const startedAt = webPid ? processStartMs(webPid) : 0;
|
|
688
|
+
needRestart = Boolean(pluginMtime && startedAt && startedAt < pluginMtime);
|
|
689
|
+
// patch 激活形态:harness 的 HMR 监听 profile patch 文件,存盘后约 1 秒重新 compose。
|
|
690
|
+
// 所以这里**先等热挂载**,等到了就完全不需要重启(用户只需刷新页面拿浏览器半)。
|
|
691
|
+
if (needRestart && pluginResult && pluginResult.hotPatch) {
|
|
692
|
+
for (let i = 0; i < 20; i += 1) {
|
|
693
|
+
const r = await fetch("http://127.0.0.1:3080/dsh-remote/status", { signal: AbortSignal.timeout(900) })
|
|
694
|
+
.catch(() => null);
|
|
695
|
+
if (r && r.ok) { hotMounted = true; break; }
|
|
696
|
+
sleepSync(600);
|
|
697
|
+
}
|
|
698
|
+
if (hotMounted) needRestart = false;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// 需要就用**最省事的方式**替用户重启:插件装完还要用户自己琢磨怎么重启,是这一步唯一的手工负担
|
|
702
|
+
let restartResult = null;
|
|
703
|
+
if (needRestart && !noRestart && webUp) {
|
|
704
|
+
console.log("");
|
|
705
|
+
console.log("ℹ 检测到运行中的 dsh web 早于本次插件安装 —— 插件只在进程启动时装载,");
|
|
706
|
+
console.log(" 所以「设置 → 远程控制」面板暂时还没出现。正在为你重启 dsh web(2 秒后执行,Ctrl-C 可跳过)…");
|
|
707
|
+
sleepSync(2000);
|
|
708
|
+
restartResult = restartDshWeb();
|
|
709
|
+
if (restartResult.ok) {
|
|
710
|
+
// 等它回来再确认端口确实在听(避免"重启完其实没起来"还要用户自己发现)
|
|
711
|
+
for (let i = 0; i < 20; i += 1) {
|
|
712
|
+
if (await isDshWebUp(800)) break;
|
|
713
|
+
sleepSync(700);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
console.log(restartResult.ok
|
|
717
|
+
? `✅ 已重启 dsh web(${restartResult.how})`
|
|
718
|
+
: `⚠️ 自动重启失败: ${restartResult.detail}`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
|
|
470
722
|
// 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改口径。
|
|
471
723
|
// 三分支:running / 未运行(有原因) / 未安装(跳过或平台不支持)——最后一种既不能显示"运行中",
|
|
472
724
|
// 也不能显示"未运行(原因)",它压根没装(此前会打出自相矛盾的"(当前平台不支持) — ✅ 运行中")。
|
|
473
725
|
const svcSkipped = st.status === "skipped" || st.status === "unsupported" || !svc.path;
|
|
726
|
+
// 第三态:起来了、但本机 launchd 不保证崩溃自愈(on-demand-only 的 gui domain 只能靠 kickstart 拉起),
|
|
727
|
+
// 必须与"完全正常"分开说 —— 否则用户以为有自启,进程一崩就永久掉线且找不到原因。
|
|
728
|
+
const svcFragile = !svcSkipped && st.ok && st.selfHealing === false;
|
|
729
|
+
const svcDegraded = st.status === "degraded-detached";
|
|
474
730
|
const svcState = svcSkipped
|
|
475
731
|
? (st.status === "skipped" ? "未安装(本次显式跳过)" : "未安装(当前平台不支持自启动)")
|
|
476
|
-
: (st.ok
|
|
732
|
+
: (st.ok
|
|
733
|
+
? `✅ 运行中${st.pid ? ` (pid=${st.pid})` : ""}${svcFragile ? ",但本机不支持崩溃自愈" : ""}`
|
|
734
|
+
: (svcDegraded ? "⚠️ 未被系统托管(已用后台进程兜底)" : `未运行 (${st.detail || st.status})`));
|
|
477
735
|
const L = [];
|
|
478
736
|
L.push("✅ 安装完成");
|
|
479
737
|
if (selfHosted) {
|
|
@@ -498,12 +756,40 @@ async function setup(argv) {
|
|
|
498
756
|
L.push(" 如果 dsh web 已经开着但看不到本机,先重启 dsh web 让插件生效。");
|
|
499
757
|
} else if (!st.ok) {
|
|
500
758
|
L.push("");
|
|
501
|
-
|
|
502
|
-
|
|
759
|
+
if (svcDegraded) {
|
|
760
|
+
// launchd 托管失败但进程活着:能立刻用,只是没有自启/自愈。如实说明,别让用户以为有自启。
|
|
761
|
+
L.push(`⚠️ ${st.detail}`);
|
|
762
|
+
L.push(` bridge 已在后台运行${st.pid ? `(pid=${st.pid})` : ""},现在就能用;`);
|
|
763
|
+
L.push(" 但重启电脑或进程退出后不会自动恢复,需要时执行 `dsh-remote run`。");
|
|
764
|
+
} else {
|
|
765
|
+
L.push(`⚠️ bridge 未能启动: ${st.detail || st.status}`);
|
|
766
|
+
L.push(` 可查看日志: ${CONFIG_DIR}/.dsh-bridge.log`);
|
|
767
|
+
}
|
|
768
|
+
} else if (svcFragile) {
|
|
769
|
+
// 起来了但靠 kickstart 拉起的 → 明确告知"进程退出后不会自动重建"
|
|
770
|
+
L.push("");
|
|
771
|
+
L.push("ℹ 本机 launchd 处于 on-demand-only 模式(macOS 26 起会出现),系统不会自动派生自启动服务,");
|
|
772
|
+
L.push(" 安装脚本已手动把它拉起来一次,现在可用;但**进程退出后不会自动重建**。");
|
|
773
|
+
L.push(" 建议升级到最新版 dsh-remote(已针对该模式改用 user domain 启动,可恢复自动重建)。");
|
|
503
774
|
}
|
|
504
775
|
if (selfHosted) {
|
|
505
776
|
L.push("");
|
|
506
777
|
L.push(" 下一步: 手机端用访问密钥登录即可(bridge 登录后自动启动)。");
|
|
778
|
+
} else if (hotMounted) {
|
|
779
|
+
L.push("");
|
|
780
|
+
L.push(" 下一步: 插件已热加载(无需重启 dsh web)——刷新一下浏览器页面,");
|
|
781
|
+
L.push(" 再打开 设置 → 「远程控制」→ 注册/登录手机号即可。");
|
|
782
|
+
} else if (needRestart && restartResult && restartResult.ok) {
|
|
783
|
+
L.push("");
|
|
784
|
+
L.push(" 下一步: dsh web 已重启,直接打开 http://127.0.0.1:3080 → 设置 → 「远程控制」→ 注册/登录手机号。");
|
|
785
|
+
} else if (needRestart) {
|
|
786
|
+
// 需要重启但没做成功:必须给出**可照抄**的命令,而不是只说"请重启"
|
|
787
|
+
L.push("");
|
|
788
|
+
L.push("⚠️ 还需要重启一次 dsh web,插件面板才会出现(插件只在进程启动时装载)。");
|
|
789
|
+
L.push(" 请手动执行(任选其一):");
|
|
790
|
+
L.push(" launchctl kickstart -k gui/$(id -u)/com.dshweb.dev # 用 launchd 托管时");
|
|
791
|
+
L.push(" kill $(lsof -ti tcp:3080 -sTCP:LISTEN) && dsh web --no-open # 手动启动时");
|
|
792
|
+
L.push(" 重启后:打开 http://127.0.0.1:3080 → 设置 → 「远程控制」→ 注册/登录手机号。");
|
|
507
793
|
} else {
|
|
508
794
|
L.push("");
|
|
509
795
|
L.push(" 下一步: 打开 dsh web → 设置 → 「远程控制」→ 注册/登录手机号即可(无需任何命令)。");
|
|
@@ -577,8 +863,121 @@ function stripPluginEntries(patch) {
|
|
|
577
863
|
}
|
|
578
864
|
|
|
579
865
|
/**
|
|
580
|
-
*
|
|
581
|
-
*
|
|
866
|
+
* 解析 patch 文本里的 insert 行 id(与 dshmarket 同口径的极简解析,不引 YAML 依赖)。
|
|
867
|
+
* 用途有二:① 判断本插件是否已激活(幂等);② **写完校验**——patch 文件形态非法时
|
|
868
|
+
* dsh web 会直接启动失败,所以每次写入后必须重新解析确认结果仍是「一行一条的 insert 列表」。
|
|
869
|
+
* @returns {{ids: string[], badLines: string[]}} badLines 非空 = 文件形态不合法
|
|
870
|
+
*/
|
|
871
|
+
function parsePatchInsertIds(text) {
|
|
872
|
+
const ids = [];
|
|
873
|
+
const badLines = [];
|
|
874
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
875
|
+
// 先剥注释与尾部空白;注意 id 行在块内是缩进的(` - id: xxx`),不能要求顶格
|
|
876
|
+
const line = raw.replace(/#.*$/, "").replace(/\s+$/, "");
|
|
877
|
+
const trimmed = line.trim();
|
|
878
|
+
if (trimmed === "") continue;
|
|
879
|
+
if (trimmed === "[]") continue; // 新 profile 的空文档占位
|
|
880
|
+
if (/^-\s*insert:\s*$/.test(trimmed)) continue; // insert 块头(可缩进)
|
|
881
|
+
const m = /^-\s*id:\s*(\S+)\s*$/.exec(trimmed); // 条目行(可缩进)
|
|
882
|
+
if (m) { ids.push(m[1]); continue; }
|
|
883
|
+
// 其余缩进行属于某个条目的字段(name/config/...),合法;顶层非列表行才是异常形态
|
|
884
|
+
if (/^\s+\S/.test(line)) continue;
|
|
885
|
+
badLines.push(trimmed);
|
|
886
|
+
}
|
|
887
|
+
return { ids, badLines };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* 写入 patch 前校验基座形态。
|
|
892
|
+
* 只接受「空/仅占位/现有条目」三种基座;发现异常内容就**拒写**(绝不把一个本来还能用的
|
|
893
|
+
* profile 弄坏 —— 改动前先确认目标文件是可安全追加的形态,而不是先写再看结果)。
|
|
894
|
+
* @returns {{ok: boolean, reason?: string, cleaned: string, insertedIds: string[]}}
|
|
895
|
+
*/
|
|
896
|
+
function validatePatchBase(text) {
|
|
897
|
+
const { ids, badLines } = parsePatchInsertIds(text);
|
|
898
|
+
if (badLines.length) {
|
|
899
|
+
return { ok: false, reason: `patch 文件含无法识别的行(未做改动): ${badLines[0].slice(0, 60)}`, cleaned: "", insertedIds: ids };
|
|
900
|
+
}
|
|
901
|
+
// 去掉空文档占位 `[]`(它在条目之前会让整份文档变成「数组套列表」而解析失败)
|
|
902
|
+
const cleaned = String(text ?? "").split(/\r?\n/).filter((l) => l.trim() !== "[]").join("\n").trim();
|
|
903
|
+
return { ok: true, cleaned, insertedIds: ids };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** 安全写回 patch(原子:先写同目录临时文件再 rename,避免中途被打断留下半截文件)。 */
|
|
907
|
+
function writePatchAtomic(patchFile, text) {
|
|
908
|
+
const tmp = `${patchFile}.dsh-remote.tmp`;
|
|
909
|
+
fs.writeFileSync(tmp, text);
|
|
910
|
+
fs.renameSync(tmp, patchFile);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* 原子地写入插件 patch 激活行;写后**重新解析校验**,不合法立即回滚。
|
|
915
|
+
* 返回 {ok, changed, reason?, basePatch}
|
|
916
|
+
*/
|
|
917
|
+
function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
|
|
918
|
+
const original = fs.readFileSync(patchFile, "utf8");
|
|
919
|
+
// 幂等:已经是唯一激活点就什么都不做(但要确保 bundles 里没有重复激活)
|
|
920
|
+
const base = validatePatchBase(original);
|
|
921
|
+
if (!base.ok) return { ok: false, reason: base.reason, changed: false, basePatch: original };
|
|
922
|
+
const hasOurRow = base.insertedIds.some((id) => PLUGIN_ALL_IDS.includes(id));
|
|
923
|
+
if (hasOurRow) {
|
|
924
|
+
const bundleRemoved = removeBundleEntry(pkgFile);
|
|
925
|
+
if (bundleRemoved) console.log("✅ 已从 dsh.profile.bundles 移除重复激活点(激活统一走 patch 行)");
|
|
926
|
+
// 已有条目也要**自我修正**:配置目录变了(换安装方式 / 之前写错)时 relayDir 必须跟着更新,
|
|
927
|
+
// 否则面板会一直去旧目录找 .dsh-config.json(表现就是"未登录 / 空配置")。
|
|
928
|
+
const want = pluginBlock(CONFIG_DIR);
|
|
929
|
+
const wantDir = (/relayDir: '([^']*)'/.exec(want) || [])[1] || "";
|
|
930
|
+
if (wantDir && !original.includes(`relayDir: '${wantDir}'`)) {
|
|
931
|
+
const base2 = validatePatchBase(stripPluginEntries(original));
|
|
932
|
+
if (base2.ok) {
|
|
933
|
+
const merged2 = `${base2.cleaned ? base2.cleaned + "\n" : ""}\n${want}\n`;
|
|
934
|
+
const chk = parsePatchInsertIds(merged2);
|
|
935
|
+
if (!chk.badLines.length && chk.ids.filter((id) => id === PLUGIN_ID).length === 1) {
|
|
936
|
+
writePatchAtomic(patchFile, merged2);
|
|
937
|
+
console.log(`✅ 已同步激活行的 relayDir → ${wantDir}`);
|
|
938
|
+
return { ok: true, changed: true, basePatch: original };
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
console.warn("⚠️ 激活行的 relayDir 与当前配置目录不一致,但未能安全改写(保持原样)");
|
|
942
|
+
}
|
|
943
|
+
return { ok: true, changed: bundleRemoved, basePatch: original };
|
|
944
|
+
}
|
|
945
|
+
// 写前再次校验我们的块本身可解析(id 行必须能被 parsePatchInsertIds 认出来)
|
|
946
|
+
// ⚠️ relayDir 必须传**配置目录**(CONFIG_DIR, 即 ~/.dsh-remote),不是插件安装目录:
|
|
947
|
+
// 面板要读的是该目录下的 .dsh-config.json(账号/中继配置);传成 pluginLocalDir 会让面板
|
|
948
|
+
// 对着插件源码目录找配置 → 界面显示成"未登录 / 空配置"(实测踩到)。
|
|
949
|
+
const block = pluginBlock(CONFIG_DIR);
|
|
950
|
+
if (!parsePatchInsertIds(block).ids.includes(PLUGIN_ID)) {
|
|
951
|
+
return { ok: false, reason: "生成的激活块自身不合法", changed: false, basePatch: original };
|
|
952
|
+
}
|
|
953
|
+
const merged = `${base.cleaned ? base.cleaned + "\n" : ""}\n${block}\n`;
|
|
954
|
+
// 写后校验:整份文件必须仍是干净的 insert 列表,且我们的 id 恰好出现一次
|
|
955
|
+
const check = parsePatchInsertIds(merged);
|
|
956
|
+
if (check.badLines.length || check.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
|
|
957
|
+
return { ok: false, reason: "合并后的 patch 校验未通过(未做改动)", changed: false, basePatch: original };
|
|
958
|
+
}
|
|
959
|
+
writePatchAtomic(patchFile, merged);
|
|
960
|
+
const after = fs.readFileSync(patchFile, "utf8");
|
|
961
|
+
const verify = parsePatchInsertIds(after);
|
|
962
|
+
if (verify.badLines.length || verify.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
|
|
963
|
+
writePatchAtomic(patchFile, original); // 回滚
|
|
964
|
+
return { ok: false, reason: "写入后校验失败,已回滚", changed: false, basePatch: original };
|
|
965
|
+
}
|
|
966
|
+
removeBundleEntry(pkgFile); // 单一激活点:bundles 里不能再有本插件
|
|
967
|
+
return { ok: true, changed: true, basePatch: original };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** 清理 patch 中引用本插件的条目块(含前置注释/收尾标记)。返回是否发生变更。 */
|
|
971
|
+
function stripIncludeEntries(patchFile, patch, reason) {
|
|
972
|
+
const next = stripPluginEntries(patch);
|
|
973
|
+
if (next === patch) return false;
|
|
974
|
+
writePatchAtomic(patchFile, next);
|
|
975
|
+
console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活点必须唯一,避免重复 ID 崩溃)`);
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* 归一化 patch 基座:去掉 dsh 新 profile 默认的空文档占位行 `[]`。 * 默认 cordis.patch.yml 是「顶部注释 + []」,若保留 [] 再往下拼 `- insert:`,
|
|
582
981
|
* YAML 会报 “end of the stream or a document separator is expected”(dsh web 启动即崩)。
|
|
583
982
|
* 返回内容不含结尾换行;[] 行只在独立成行时视为占位,不影响真正的条目。
|
|
584
983
|
*/
|
|
@@ -686,13 +1085,17 @@ function removeBundleEntry(pkgFile) {
|
|
|
686
1085
|
|
|
687
1086
|
/**
|
|
688
1087
|
* 插件安装(pluginCmd 非卸载分支)收敛策略 —— 2026-09-06「重复 ID 崩溃」根治;
|
|
689
|
-
* 2026-09 插件由 dsh-remote-ui 更名 dsh-remote-web
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
1088
|
+
* 2026-09 插件由 dsh-remote-ui 更名 dsh-remote-web,本函数同时兼容清理旧名残留。
|
|
1089
|
+
*
|
|
1090
|
+
* ⚠️ 2026-09-13 调整:「恰好一处激活」仍然成立,但**激活点二选一**:
|
|
1091
|
+
* · dsh-setup 安装(file: 依赖 + 自建 node_modules 链接)→ 激活点 = 用户级 cordis.patch.yml
|
|
1092
|
+
* 的一行 insert。理由:harness 的 web profile 是 `patchReload: "live"`,会加载
|
|
1093
|
+
* `@deepseek-ai/cordis-plugin-hmr` 监听该文件,**存盘后约 1 秒重新 compose 并动态装载**,
|
|
1094
|
+
* 用户不需要重启 dsh web(只需刷新页面拿到浏览器半)。插件市场自己也是这套机制。
|
|
1095
|
+
* · 插件市场安装(github:/npm: 依赖,源码归市场管)→ 激活点 = dsh.profile.bundles
|
|
1096
|
+
* (插件自带 bundle patch)。bundles 只在启动时读、没有 watcher,所以这一路仍需重启。
|
|
1097
|
+
* 两条路都**只保留一个激活点**:写 patch 就同时从 bundles 移除,反之清掉 include,
|
|
1098
|
+
* 绝不两处并存(那正是历史上 dsh web 启动即报「重复 ID」崩溃的原因)。
|
|
696
1099
|
*/
|
|
697
1100
|
function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch) {
|
|
698
1101
|
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
@@ -703,18 +1106,11 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
|
|
|
703
1106
|
const marketManaged = dep && !String(dep).startsWith("file:"); // github:/npm: 等由市场/包管理器管源码
|
|
704
1107
|
const managedByUs = !dep || String(dep).startsWith("file:"); // 无依赖或 file: 拷贝 → 我们管
|
|
705
1108
|
|
|
706
|
-
const stripInclude = (reason) => {
|
|
707
|
-
const newPatch = stripPluginEntries(patch);
|
|
708
|
-
if (newPatch !== patch) {
|
|
709
|
-
fs.writeFileSync(patchFile, newPatch);
|
|
710
|
-
console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活统一走插件自带 bundle patch,避免重复 ID 崩溃)`);
|
|
711
|
-
}
|
|
712
|
-
};
|
|
713
|
-
|
|
714
1109
|
if (marketManaged && inBundles) {
|
|
715
1110
|
// 插件市场安装形态:依赖与源码归市场管,我们只清历史 include(若旧版曾写过)
|
|
716
|
-
|
|
1111
|
+
stripIncludeEntries(patchFile, patch, "插件市场安装形态无需用户 include");
|
|
717
1112
|
console.log("ℹ 插件市场安装形态(bundles+dependency):已保持市场管理的源码不变。");
|
|
1113
|
+
console.log(" (该形态经 dsh.profile.bundles 激活,只在启动时读取 —— 装完需重启一次 dsh web)");
|
|
718
1114
|
return;
|
|
719
1115
|
}
|
|
720
1116
|
|
|
@@ -725,14 +1121,19 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
|
|
|
725
1121
|
console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
|
|
726
1122
|
process.exit(1);
|
|
727
1123
|
}
|
|
728
|
-
declarePluginDep(pkgFile);
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1124
|
+
declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
|
|
1125
|
+
ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
|
|
1126
|
+
// 唯一激活点:patch 行(+ 顺带把 bundles 里的本插件条目移除,避免两处并存)
|
|
1127
|
+
const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile);
|
|
1128
|
+
if (r.ok) {
|
|
1129
|
+
console.log(`✅ 插件已就绪: ${pluginLocalDir}`);
|
|
1130
|
+
return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
|
|
1131
|
+
}
|
|
1132
|
+
// patch 行写不进去(文件形态异常)→ 回退 bundles(需重启),保证功能可用
|
|
1133
|
+
console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
|
|
1134
|
+
ensureBundleEntry(pkgFile);
|
|
1135
|
+
console.log(`✅ 插件已就绪: ${pluginLocalDir}(bundles 形态)`);
|
|
1136
|
+
return { hotPatch: false, changed: true, basePatch: patch };
|
|
736
1137
|
}
|
|
737
1138
|
|
|
738
1139
|
// 异常形态:有非 file: 依赖但不在 bundles(无法靠 bundle patch 激活)
|
|
@@ -742,10 +1143,7 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
|
|
|
742
1143
|
|
|
743
1144
|
async function pluginCmd(argv) {
|
|
744
1145
|
const uninstall = hasFlag(argv, "--uninstall");
|
|
745
|
-
const
|
|
746
|
-
const profileDir = profileIdx > -1 && argv[profileIdx + 1]
|
|
747
|
-
? argv[profileIdx + 1]
|
|
748
|
-
: process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
|
|
1146
|
+
const profileDir = resolveProfileDir(argv);
|
|
749
1147
|
const pkgFile = path.join(profileDir, "package.json");
|
|
750
1148
|
const patchFile = path.join(profileDir, "cordis.patch.yml");
|
|
751
1149
|
const pluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
|
|
@@ -764,11 +1162,8 @@ async function pluginCmd(argv) {
|
|
|
764
1162
|
|
|
765
1163
|
if (uninstall) {
|
|
766
1164
|
// 移除 patch 中的插件条目(兼容旧版无标记条目;旧名 dsh-remote-ui 一并清理)
|
|
767
|
-
|
|
768
|
-
if (
|
|
769
|
-
fs.writeFileSync(patchFile, newPatch);
|
|
770
|
-
console.log(`✅ 已从 ${patchFile} 移除插件条目`);
|
|
771
|
-
} else {
|
|
1165
|
+
// 用原子写回:卸载同样不能在 profile 配置上留下半截文件
|
|
1166
|
+
if (!stripIncludeEntries(patchFile, patch, "卸载清理")) {
|
|
772
1167
|
console.log(`ℹ patch 中未发现 ${PLUGIN_ID}(或历史名)条目。`);
|
|
773
1168
|
}
|
|
774
1169
|
// 清理本地目录与链接:当前名 + 历史名(dsh-remote-ui-plugin / node_modules/dsh-remote-ui)
|
|
@@ -795,8 +1190,7 @@ async function pluginCmd(argv) {
|
|
|
795
1190
|
// 安装:收敛到“恰好一处激活”(bundle patch 唯一激活点),绝不与市场/历史 include 并存
|
|
796
1191
|
convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch);
|
|
797
1192
|
|
|
798
|
-
|
|
799
|
-
console.log(" 若从 DeepSeek App/插件市场 安装:请完全退出并重开 App 让插件生效。");
|
|
1193
|
+
// 引导语只在 setup 汇总里打印一次(这里不再重复;单独跑 `dsh-remote plugin` 也无需引导)
|
|
800
1194
|
}
|
|
801
1195
|
|
|
802
1196
|
// ---------- main ----------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.4-beta.
|
|
3
|
+
"version": "0.6.4-beta.7",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -2423,25 +2423,12 @@ window.__ModuleLoader__.load({
|
|
|
2423
2423
|
h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表;服务端可配置流量配额。"),
|
|
2424
2424
|
h("div", { className: "dru-hint" }, "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。"),
|
|
2425
2425
|
// 匿名装机统计(隐私披露):开源项目必须把「采什么 / 不采什么 / 怎么关」写在用户看得到的地方。
|
|
2426
|
+
// 只保留结论式的说明句:不再单列「隐私说明: README… · docs/telemetry.md」那一行文档链接
|
|
2427
|
+
// (面板里堆文档链接既占地方又不像产品文案;完整字段清单在仓库 docs/telemetry.md)。
|
|
2426
2428
|
h("div", { className: "dru-hint", style: { marginTop: 6 } },
|
|
2427
2429
|
"📊 匿名装机统计:只上报「装机/连接是否成功」这类事件(安装开始与失败原因、bridge 是否注册成功、是否首次远程打通)——"
|
|
2428
2430
|
+ "不含任何账号、手机号、会话或文件内容、主机名、路径、设备指纹与 IP;标识是本机随机 ID(重装即变)。"
|
|
2429
2431
|
+ "用环境变量 DSH_REMOTE_TELEMETRY=0 可完全关闭(不生成 ID、不发任何请求)。"),
|
|
2430
|
-
h("div", { className: "dru-hint", style: { marginTop: 4 } },
|
|
2431
|
-
"隐私说明:",
|
|
2432
|
-
h("a", {
|
|
2433
|
-
className: "dru-linkbtn",
|
|
2434
|
-
href: "https://github.com/mrRisega/dsh-remote/blob/main/README.md#匿名装机统计与隐私",
|
|
2435
|
-
target: "_blank",
|
|
2436
|
-
rel: "noreferrer"
|
|
2437
|
-
}, "README「匿名装机统计与隐私」"),
|
|
2438
|
-
" · ",
|
|
2439
|
-
h("a", {
|
|
2440
|
-
className: "dru-linkbtn",
|
|
2441
|
-
href: "https://github.com/mrRisega/dsh-remote/blob/main/docs/telemetry.md",
|
|
2442
|
-
target: "_blank",
|
|
2443
|
-
rel: "noreferrer"
|
|
2444
|
-
}, "完整字段清单 docs/telemetry.md")),
|
|
2445
2432
|
// 加入交流群(后台上传二维码后出现;点击弹出二维码大图便于扫码)
|
|
2446
2433
|
community && community.qrcode
|
|
2447
2434
|
? h("div", { className: "dru-actions", style: { marginTop: 10 } },
|
|
@@ -1739,7 +1739,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
1739
1739
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1740
1740
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1741
1741
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
1742
|
-
const PLUGIN_VERSION = "0.6.4-beta.
|
|
1742
|
+
const PLUGIN_VERSION = "0.6.4-beta.7";
|
|
1743
1743
|
const UPDATE_LOG = ".dsh-update.log";
|
|
1744
1744
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
1745
1745
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.4-beta.
|
|
3
|
+
"version": "0.6.4-beta.7",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -52,8 +52,16 @@ async function setup({ list = "" } = {}) {
|
|
|
52
52
|
await writeFile(path.join(fakeBin, "npx"), "#!/bin/sh\nexit 0\n"); await chmodSync(path.join(fakeBin, "npx"), 0o755);
|
|
53
53
|
|
|
54
54
|
const saved = {};
|
|
55
|
-
for (const k of ["PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_LIST", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_SELFHEAL_MS",
|
|
55
|
+
for (const k of ["HOME", "PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_LIST", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_SELFHEAL_MS",
|
|
56
56
|
"DSH_RELAY_RESTART_DRYRUN", "DSH_SETUP_NPX_DIR"]) saved[k] = process.env[k];
|
|
57
|
+
// HOME 必须一起伪造:本用例会 unset DSH_RELAY_SKIP_SERVICE 去走真实分支,
|
|
58
|
+
// 而插件的自启动 plist 路径写作 join(homedir(), "Library/LaunchAgents/…")。
|
|
59
|
+
// 不伪造 HOME 就会把**指向测试临时目录**的 plist 覆盖到开发者真实的
|
|
60
|
+
// ~/Library/LaunchAgents/com.dshremote.bridge.plist(实测污染,且临时目录随即被删 →
|
|
61
|
+
// 真实 bridge 自启动彻底失效)。这是测试对开发者机器的破坏,必须隔离。
|
|
62
|
+
const fakeHome = path.join(root, "home");
|
|
63
|
+
await mkdir(path.join(fakeHome, "Library", "LaunchAgents"), { recursive: true });
|
|
64
|
+
process.env.HOME = fakeHome;
|
|
57
65
|
process.env.PATH = `${fakeBin}:${saved.PATH}`;
|
|
58
66
|
process.env.DSH_TEST_LAUNCH_LOG = path.join(root, "launchctl.log");
|
|
59
67
|
process.env.DSH_TEST_LIST = list;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS 自启动(launchd)契约测试(node:test,零框架)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:用户(macOS 26.1)反馈自启动服务登记后**从未被派生**(`runs = 0`),
|
|
5
|
+
* 安装脚本报「自启动服务启动失败:服务未在运行」。根因:
|
|
6
|
+
* ① macOS 26 会把 `gui/<uid>` domain 置于 on-demand-only 模式,`RunAtLoad` / `KeepAlive`
|
|
7
|
+
* 全部失效(只登记不派生);系统日志:`pending spawn, domain in on-demand-only mode`。
|
|
8
|
+
* 迁到 `user/<uid>` domain(并带 `LimitLoadToSessionType`)才能恢复派生与崩溃自愈。
|
|
9
|
+
* ② 旧实现 `bootstrap` 后**立刻** `print` 一次就判定失败 —— launchd 派生是异步的。
|
|
10
|
+
* ③ 从不调用 `launchctl kickstart`,所以被挂起时没有任何东西会把它拉起来。
|
|
11
|
+
* ④ plist 的 PATH 缺 `/usr/sbin`、`/sbin`,bridge 日志里刷 `ioreg: command not found`。
|
|
12
|
+
*
|
|
13
|
+
* 本机实测(macOS 14.4.1,同机验证):
|
|
14
|
+
* · `bootstrap user/501 <plist 无 LimitLoadToSessionType>` → rc=5 `Input/output error`
|
|
15
|
+
* · 带 `[Aqua, Background]` 后:user/501 与 gui/501 都装载成功且 `runs = 1`
|
|
16
|
+
* · user/501 下 `kill -9` 后 launchd 自动重建 → `runs = 2`(KeepAlive 真生效)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { test } from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
|
|
25
|
+
const SETUP = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "dsh-setup.mjs");
|
|
26
|
+
const src = readFileSync(SETUP, "utf8");
|
|
27
|
+
|
|
28
|
+
test("launchd 修复:plist 必须带 LimitLoadToSessionType(user domain 的必要条件)", () => {
|
|
29
|
+
assert.match(
|
|
30
|
+
src,
|
|
31
|
+
/<key>LimitLoadToSessionType<\/key><array><string>Aqua<\/string><string>Background<\/string><\/array>/,
|
|
32
|
+
"缺该键时 user/<uid> bootstrap 直接 EIO(rc=5);macOS 26 上它也是恢复派生的前提"
|
|
33
|
+
);
|
|
34
|
+
// RunAtLoad / KeepAlive 仍需保留:user domain 下它们才真正生效
|
|
35
|
+
assert.match(src, /<key>RunAtLoad<\/key><true\/>/, "RunAtLoad 不能删(user domain 下靠它开机自启)");
|
|
36
|
+
assert.match(src, /<key>KeepAlive<\/key><true\/>/, "KeepAlive 不能删(user domain 下靠它崩溃自愈)");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("launchd 修复:启动走 user → gui 阶梯,且每次都 kickstart", () => {
|
|
40
|
+
assert.ok(/function startBridgeDarwin/.test(src), "应有专门的 darwin 启动阶梯函数");
|
|
41
|
+
const fn = src.slice(src.indexOf("function startBridgeDarwin"), src.indexOf("function restartBridgeService"));
|
|
42
|
+
// 首选 user domain(macOS 26 上唯一支持自启动语义的 domain)
|
|
43
|
+
assert.ok(/for \(const domain of \[`user\/\$\{uid\}`, `gui\/\$\{uid\}`\]\)/.test(fn), "应为 user → gui 的阶梯顺序");
|
|
44
|
+
assert.ok(/launchctl kickstart -k/.test(fn), "bootstrap 只是登记,必须 kickstart 才能确保这次起来");
|
|
45
|
+
// 清掉另一个 domain 的残留,避免两处并存跑出两个 bridge
|
|
46
|
+
assert.ok(/bootout \$\{d\}\/\$\{BRIDGE_LABEL\}/.test(fn), "切换到某 domain 时应清掉另一 domain 的历史注册");
|
|
47
|
+
// 终极兜底:launchd 托管不了也要保证「现在能用」
|
|
48
|
+
assert.ok(/spawnDetachedBridge/.test(fn), "launchd 不可用时应退化为后台进程兜底");
|
|
49
|
+
assert.ok(/degraded-detached/.test(fn), "兜底状态要与完全失败区分开");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("launchd 修复:不再只 print 一次就判定失败(异步派生必须轮询)", () => {
|
|
53
|
+
assert.ok(/function waitLaunchdRunning/.test(src), "应有轮询等待函数");
|
|
54
|
+
const fn = src.slice(src.indexOf("function launchdState"), src.indexOf("function spawnDetachedBridge"));
|
|
55
|
+
assert.ok(/for \(let i = 0; i < tries; i \+= 1\)/.test(fn), "应循环轮询而非单次检查");
|
|
56
|
+
assert.ok(/sleepSync\(gapMs\)/.test(fn), "轮询之间要真正等待");
|
|
57
|
+
assert.ok(/tries = 20/.test(fn), "总等待时长应足够 launchd 完成异步派生");
|
|
58
|
+
// 必须能识别「登记了但从未派生」这一 macOS 26 特征,才能给出正确建议而不是干等
|
|
59
|
+
assert.ok(/pended nondemand spawn/.test(fn) && /on-demand-only/.test(fn), "应识别 on-demand-only 挂起特征");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("launchd 修复:被挂起后靠 kickstart 起来时,不得报成完全成功", () => {
|
|
63
|
+
assert.ok(/selfHealing/.test(src), "应回报是否具备崩溃自愈能力");
|
|
64
|
+
assert.ok(/本机 launchd 处于 on-demand-only/.test(src), "安装输出要如实说明不支持崩溃自愈");
|
|
65
|
+
const summary = src.slice(src.indexOf("const svcFragile"), src.indexOf("const L = [];"));
|
|
66
|
+
assert.ok(/svcFragile/.test(summary), "汇总里应区分「可自愈」与「仅拉起一次」");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("launchd 修复:服务 PATH 必须含 /usr/sbin 与 /sbin(否则 ioreg 找不到)", () => {
|
|
70
|
+
const m = src.match(/const SERVICE_PATH = "([^"]+)"/);
|
|
71
|
+
assert.ok(m, "应集中定义 SERVICE_PATH");
|
|
72
|
+
for (const dir of ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]) {
|
|
73
|
+
assert.ok(m[1].split(":").includes(dir), `PATH 缺少 ${dir}`);
|
|
74
|
+
}
|
|
75
|
+
// plist 与 systemd unit 都必须用这个常量,不能各写各的
|
|
76
|
+
assert.ok(/\$\{SERVICE_PATH\}/.test(src), "plist/systemd 应使用 SERVICE_PATH 常量");
|
|
77
|
+
assert.ok(!/Environment=PATH=\/usr\/local\/bin:\/usr\/bin:\/bin/.test(src), "systemd unit 不应再写死旧 PATH");
|
|
78
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「patch 热加载激活」契约测试(node:test,零框架)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:harness 的 web profile 是 `patchReload: "live"`,启动时加载 `@deepseek-ai/cordis-plugin-hmr`
|
|
5
|
+
* 并监听 profile 的 `cordis.patch.yml` —— 存盘后约 1 秒重新 compose 并**动态装载**插件,
|
|
6
|
+
* 不需要重启 dsh web(插件市场自己就用这套机制)。而 `dsh.profile.bundles` 只在启动时读、
|
|
7
|
+
* 没有 watcher,所以「bundles 激活」必须重启 —— 这正是用户装完看不到「远程控制」面板的原因。
|
|
8
|
+
*
|
|
9
|
+
* 因此安装器改为:**激活点 = patch 行**(热加载),bundles 那条同时移除(单一激活点,
|
|
10
|
+
* 否则 dsh web 启动即报「重复 ID」崩溃)。
|
|
11
|
+
*
|
|
12
|
+
* 这些用例在**临时 profile**(mkdtemp)里跑真实的 `dsh-setup.mjs plugin` 子进程,
|
|
13
|
+
* 断言生成的文件产物;绝不触碰开发者真实的 ~/.dsh/profiles/**。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { test } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
19
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, cpSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
25
|
+
const SETUP = join(ROOT, "dsh-setup.mjs");
|
|
26
|
+
|
|
27
|
+
const PLUGIN_BUNDLES = ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"];
|
|
28
|
+
|
|
29
|
+
/** 造一个临时 profile(默认 patch 是新 profile 的空文档占位 `[]`)。 */
|
|
30
|
+
function makeProfile({ patch = "# dsh profile patch\n[]\n", bundles = PLUGIN_BUNDLES } = {}) {
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), "dshac-"));
|
|
32
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({
|
|
33
|
+
name: "dsh-profile-web", private: true, dependencies: {},
|
|
34
|
+
dsh: { profile: { bundles: [...bundles], patchReload: "live" } }
|
|
35
|
+
}, null, 2) + "\n");
|
|
36
|
+
writeFileSync(join(dir, "cordis.patch.yml"), patch);
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 跑真实的 plugin 安装(子进程,指向临时 profile 与临时配置目录)。stdout+stderr 一并返回。 */
|
|
41
|
+
function installPluginWithStderr(profileDir, relayDir) {
|
|
42
|
+
const r = spawnSync(process.execPath, [SETUP, "plugin", "--profile", profileDir], {
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
env: { ...process.env, DSH_RELAY_DIR: relayDir, DSH_RELAY_SKIP_SERVICE: "1" }
|
|
45
|
+
});
|
|
46
|
+
return { out: String(r.stdout || ""), err: String(r.stderr || ""), code: r.status };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 只要 stdout(正常路径够用)。 */
|
|
50
|
+
function installPlugin(profileDir, relayDir) {
|
|
51
|
+
const r = installPluginWithStderr(profileDir, relayDir);
|
|
52
|
+
assert.equal(r.code, 0, `安装子进程应成功退出,stderr=${r.err.slice(0, 200)}`);
|
|
53
|
+
return r.out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cleanup(...dirs) {
|
|
57
|
+
for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const readBundles = (dir) => JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).dsh.profile.bundles;
|
|
61
|
+
const readPatch = (dir) => readFileSync(join(dir, "cordis.patch.yml"), "utf8");
|
|
62
|
+
|
|
63
|
+
test("patch 激活:写入 insert 行(热加载形态),并从 bundles 移除该插件(单一激活点)", () => {
|
|
64
|
+
const profile = makeProfile();
|
|
65
|
+
const relay = join(profile, "relay");
|
|
66
|
+
try {
|
|
67
|
+
installPlugin(profile, relay);
|
|
68
|
+
const patch = readPatch(profile);
|
|
69
|
+
assert.match(patch, /- insert:/, "应写入 insert 块");
|
|
70
|
+
assert.match(patch, /- id: dsh-remote-web/, "应写入本插件的 id 行");
|
|
71
|
+
assert.match(patch, /name: 'dsh-remote-web'/, "name 必须是裸包名(加载器据此 import)");
|
|
72
|
+
assert.ok(!patch.includes("[]"), "空文档占位 `[]` 必须被清掉(否则整份 patch 解析失败)");
|
|
73
|
+
assert.deepEqual(readBundles(profile), PLUGIN_BUNDLES, "bundles 里不能再有本插件(两处激活=重复 ID 崩溃)");
|
|
74
|
+
} finally { cleanup(profile); }
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("patch 激活:relayDir 写的是**配置目录**而不是插件安装目录", () => {
|
|
78
|
+
const profile = makeProfile();
|
|
79
|
+
const relay = join(profile, "relay-config");
|
|
80
|
+
mkdirSync(relay, { recursive: true });
|
|
81
|
+
try {
|
|
82
|
+
installPlugin(profile, relay);
|
|
83
|
+
const patch = readPatch(profile);
|
|
84
|
+
const m = /relayDir: '([^']+)'/.exec(patch);
|
|
85
|
+
assert.ok(m, "应带 relayDir 配置");
|
|
86
|
+
// 面板要读的是该目录下的 .dsh-config.json;写成插件目录会让面板显示成"未登录/空配置"
|
|
87
|
+
assert.equal(m[1], relay, "relayDir 必须是配置目录");
|
|
88
|
+
assert.ok(!m[1].includes("dsh-remote-web-plugin"), "relayDir 不得指向插件源码目录");
|
|
89
|
+
} finally { cleanup(profile); }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("patch 激活:幂等 —— 重复安装不会写出第二个 id(否则重复 ID 崩溃)", () => {
|
|
93
|
+
const profile = makeProfile();
|
|
94
|
+
const relay = join(profile, "relay");
|
|
95
|
+
try {
|
|
96
|
+
installPlugin(profile, relay);
|
|
97
|
+
const first = readPatch(profile);
|
|
98
|
+
installPlugin(profile, relay);
|
|
99
|
+
const second = readPatch(profile);
|
|
100
|
+
assert.equal(second, first, "第二次安装不应再改 patch");
|
|
101
|
+
const ids = second.match(/- id: dsh-remote-web/g) || [];
|
|
102
|
+
assert.equal(ids.length, 1, `id 行必须恰好一条,实际 ${ids.length}`);
|
|
103
|
+
} finally { cleanup(profile); }
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("patch 激活:既有其它插件条目必须原样保留(不能吃掉别人的配置)", () => {
|
|
107
|
+
const other = [
|
|
108
|
+
"# dsh profile patch",
|
|
109
|
+
"[]",
|
|
110
|
+
"",
|
|
111
|
+
"# --- mba-feishu managed block ---",
|
|
112
|
+
"- insert:",
|
|
113
|
+
" - id: mba-feishu",
|
|
114
|
+
" name: 'mba-feishu'",
|
|
115
|
+
" config:",
|
|
116
|
+
" transport: stdio",
|
|
117
|
+
" args:",
|
|
118
|
+
" - 'mcp'",
|
|
119
|
+
"# --- end mba-feishu managed block ---",
|
|
120
|
+
""
|
|
121
|
+
].join("\n");
|
|
122
|
+
const profile = makeProfile({ patch: other });
|
|
123
|
+
const relay = join(profile, "relay");
|
|
124
|
+
try {
|
|
125
|
+
installPlugin(profile, relay);
|
|
126
|
+
const patch = readPatch(profile);
|
|
127
|
+
assert.match(patch, /id: mba-feishu/, "其它插件的条目必须保留");
|
|
128
|
+
assert.match(patch, /transport: stdio/, "其它插件的字段必须保留");
|
|
129
|
+
assert.match(patch, /- 'mcp'/, "其它插件的嵌套列表必须保留");
|
|
130
|
+
assert.match(patch, /# --- end mba-feishu managed block ---/, "注释块必须保留");
|
|
131
|
+
assert.match(patch, /- id: dsh-remote-web/, "同时写入我们的条目");
|
|
132
|
+
assert.equal((patch.match(/^- insert:/gm) || []).length, 2, "应有两个独立的 insert 块");
|
|
133
|
+
} finally { cleanup(profile); }
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("安全护栏:patch 形态异常时**拒写**并回退 bundles(绝不把能用的 profile 弄坏)", () => {
|
|
137
|
+
const broken = "# 坏 patch:顶层塞了非列表内容\nthis is not a list ===\n- insert:\n - id: other\n name: 'other'\n";
|
|
138
|
+
const profile = makeProfile({ patch: broken });
|
|
139
|
+
const relay = join(profile, "relay");
|
|
140
|
+
try {
|
|
141
|
+
const { out, err } = installPluginWithStderr(profile, relay);
|
|
142
|
+
const all = out + err; // 警告走 stderr,必须一并检查
|
|
143
|
+
assert.match(all, /无法写入 patch 激活行/, "应明确报告拒写");
|
|
144
|
+
assert.match(all, /bundles 形态/, "应回退 bundles 保证功能可用");
|
|
145
|
+
assert.equal(readPatch(profile), broken, "坏文件必须一字未改(拒写,不是先写再看)");
|
|
146
|
+
assert.ok(readBundles(profile).includes("dsh-remote-web"), "回退时应把插件加进 bundles");
|
|
147
|
+
} finally { cleanup(profile); }
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("安全护栏:基座含空文档占位 `[]` 时也要先清掉(否则整份 patch 解析失败)", () => {
|
|
151
|
+
const profile = makeProfile({ patch: "[]\n" });
|
|
152
|
+
const relay = join(profile, "relay");
|
|
153
|
+
try {
|
|
154
|
+
installPlugin(profile, relay);
|
|
155
|
+
const patch = readPatch(profile);
|
|
156
|
+
assert.ok(!/^\[\]$/m.test(patch), "占位 [] 必须被移除");
|
|
157
|
+
assert.match(patch, /- id: dsh-remote-web/, "并写入我们的条目");
|
|
158
|
+
} finally { cleanup(profile); }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("安全护栏:原子写回 —— 不留 .tmp 残留,且末尾换行规范", () => {
|
|
162
|
+
const profile = makeProfile();
|
|
163
|
+
const relay = join(profile, "relay");
|
|
164
|
+
try {
|
|
165
|
+
installPlugin(profile, relay);
|
|
166
|
+
assert.ok(!readPatch(profile).includes(".dsh-remote.tmp"), "不应把临时文件名写进内容");
|
|
167
|
+
const leftovers = execFileSync("ls", ["-a", profile], { encoding: "utf8" })
|
|
168
|
+
.split("\n").filter((f) => f.includes(".tmp"));
|
|
169
|
+
assert.deepEqual(leftovers, [], `不应留下临时文件: ${leftovers.join(",")}`);
|
|
170
|
+
assert.ok(readPatch(profile).endsWith("\n"), "文件应以换行结尾");
|
|
171
|
+
} finally { cleanup(profile); }
|
|
172
|
+
});
|
|
@@ -18,22 +18,47 @@ import assert from "node:assert/strict";
|
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { dirname, join } from "node:path";
|
|
21
|
+
import { readdirSync } from "node:fs";
|
|
21
22
|
|
|
22
23
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
24
|
const SETUP = join(HERE, "..", "..", "..", "dsh-setup.mjs");
|
|
24
25
|
|
|
25
26
|
const src = readFileSync(SETUP, "utf8");
|
|
26
27
|
|
|
28
|
+
test("测试隔离:unset 系统操作开关的用例必须同时伪造 HOME(否则污染真实自启动)", () => {
|
|
29
|
+
// 事故复盘:harness-restart 用例 unset DSH_RELAY_SKIP_SERVICE 去走真实分支,
|
|
30
|
+
// 但没伪造 HOME → 插件写 plist 的路径是 join(homedir(), "Library/LaunchAgents/…"),
|
|
31
|
+
// 于是把**指向测试临时目录**的 plist 覆盖到开发者真实的 ~/Library/LaunchAgents,
|
|
32
|
+
// 临时目录随即被删 → 真实 bridge 自启动彻底失效(本机实测踩到,macOS 26 用户反馈排查时发现)。
|
|
33
|
+
// 静态护栏:凡 unset 该开关的用例,文件里必须出现 process.env.HOME =。
|
|
34
|
+
const rows = readdirSync(HERE).filter((f) => f.endsWith(".test.mjs"));
|
|
35
|
+
const offenders = [];
|
|
36
|
+
for (const f of rows) {
|
|
37
|
+
const text = readFileSync(join(HERE, f), "utf8");
|
|
38
|
+
if (/delete process\.env\.DSH_RELAY_SKIP_SERVICE/.test(text) && !/process\.env\.HOME\s*=/.test(text)) offenders.push(f);
|
|
39
|
+
}
|
|
40
|
+
assert.deepEqual(offenders, [], `这些用例会碰真实 HOME(必须伪造 HOME): ${offenders.join(", ")}`);
|
|
41
|
+
// 反向确认:护栏本身确有保护对象,否则是空断言
|
|
42
|
+
const guarded = rows.filter((f) => /delete process\.env\.DSH_RELAY_SKIP_SERVICE/.test(readFileSync(join(HERE, f), "utf8")));
|
|
43
|
+
assert.ok(guarded.length >= 3, `应至少有 3 个用例走真实分支(实际 ${guarded.length})`);
|
|
44
|
+
});
|
|
45
|
+
|
|
27
46
|
test("安装输出:拼接汇总时「下一步」引导只有一处文案源(不再多处重复打印)", () => {
|
|
28
|
-
// 引导语只由汇总的 L.push
|
|
47
|
+
// 引导语只由汇总的 L.push 产出:自建 / 热挂载 / 需重启 / 普通 四个**互斥**分支,
|
|
48
|
+
// 运行时只可能走其中一条 → 用户始终只看到一条引导。
|
|
29
49
|
const pushes = src.match(/L\.push\(" 下一步[::]/g) || [];
|
|
30
|
-
assert.equal(pushes.length,
|
|
50
|
+
assert.equal(pushes.length, 4, `「下一步」应只由汇总的 4 个互斥分支产出(实际 ${pushes.length} 处)`);
|
|
31
51
|
// 源码里不得再有其它直接打印引导的地方
|
|
32
52
|
const direct = src.match(/console\.log\((?!.*L\.join)[^)]*下一步/g) || [];
|
|
33
53
|
assert.equal(direct.length, 0, "除汇总外不得再直接打印引导");
|
|
34
54
|
// 插件阶段不得再打印用户引导
|
|
35
55
|
const converge = src.slice(src.indexOf("function convergePluginActivation"), src.indexOf("async function pluginCmd"));
|
|
36
56
|
assert.ok(!/下一步|打开 dsh web → 设置/.test(converge), "插件阶段不应再重复打印引导");
|
|
57
|
+
// 互斥性:这些分支必须挂在同一条 if/else-if 链上,否则可能同时打出两条引导
|
|
58
|
+
const chain = src.slice(src.indexOf("} else if (hotMounted) {"), src.indexOf('L.push("\\n" + L.join'));
|
|
59
|
+
assert.ok(chain.length > 0, "应能定位到引导分支链");
|
|
60
|
+
assert.ok(/else if \(needRestart && restartResult/.test(chain), "热挂载与需重启必须在同一条 else-if 链上");
|
|
61
|
+
assert.ok(/else if \(needRestart\)/.test(chain), "兜底的手动重启分支也必须在同一条链上");
|
|
37
62
|
});
|
|
38
63
|
|
|
39
64
|
test("安装输出:「已创建自启动服务」路径只在汇总里打印一次", () => {
|
|
@@ -450,10 +450,12 @@ test("失败退避:不阻塞主流程;退避窗口内不重试;累计 6
|
|
|
450
450
|
const cost = Date.now() - t0;
|
|
451
451
|
assert.equal(r.status, 200, "发送失败不得影响面板路由");
|
|
452
452
|
assert.equal(body.ok, true);
|
|
453
|
-
|
|
453
|
+
// 阈值只用于抓「同步重试风暴」这类病态问题(真同步会卡到几十秒),
|
|
454
|
+
// 不能设得太紧:CI/本机高负载时单次请求几秒钟是正常的,紧阈值只会制造假失败。
|
|
455
|
+
assert.ok(cost < 5000, "面板路由不得被遥测发送拖慢,实际 " + cost + "ms");
|
|
454
456
|
|
|
455
457
|
// 累计 6 次仍失败 → 丢弃该批(不永久堆积)
|
|
456
|
-
const ok = await waitFor(() => TELEMETRY.queueOf(env.relayDir).length === 0, { timeout:
|
|
458
|
+
const ok = await waitFor(() => TELEMETRY.queueOf(env.relayDir).length === 0, { timeout: 15000 });
|
|
457
459
|
assert.ok(ok, "6 次失败后必须丢弃该批(队列清零)");
|
|
458
460
|
const total = relay.count("/api/telemetry/events");
|
|
459
461
|
assert.ok(total >= 6, "必须真的重试到 6 次,实际 " + total);
|
|
@@ -579,16 +581,21 @@ test("不为遥测创建配置目录 / 测试隔离:隔离开关下零副作
|
|
|
579
581
|
} finally { await env.restore(); relay.srv.close(); }
|
|
580
582
|
});
|
|
581
583
|
|
|
582
|
-
test("面板可见性:关于卡片写明「匿名统计 +
|
|
584
|
+
test("面板可见性:关于卡片写明「匿名统计 + 关闭方式」,但不在面板里堆文档链接", () => {
|
|
583
585
|
const client = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
584
586
|
assert.match(client, /匿名装机统计/, "面板必须有匿名统计说明");
|
|
585
587
|
assert.match(client, /DSH_REMOTE_TELEMETRY=0/, "面板必须写明关闭方法");
|
|
586
|
-
assert.ok(client.includes("README.md#匿名装机统计与隐私"), "面板必须链到 README 对应段落");
|
|
587
|
-
assert.ok(client.includes("docs/telemetry.md"), "面板必须链到完整字段清单");
|
|
588
588
|
assert.match(client, /不含任何账号、手机号、会话或文件内容/, "面板必须写明不采集什么");
|
|
589
|
+
// 面板只讲结论,不放「隐私说明: README… · docs/telemetry.md」这类文档链接行(用户明确要求)
|
|
590
|
+
const about = client.slice(client.indexOf("关于 dsh-remote"), client.indexOf("加入交流群"));
|
|
591
|
+
assert.ok(!/隐私说明/.test(about), "关于卡片不应再出现「隐私说明:」链接行");
|
|
592
|
+
assert.ok(!/完整字段清单/.test(about), "关于卡片不应再出现「完整字段清单」链接");
|
|
593
|
+
assert.ok(!about.includes("README.md#匿名装机统计与隐私"), "关于卡片不应链 README 锚点");
|
|
594
|
+
assert.ok(!about.includes("docs/telemetry.md"), "关于卡片不应链 docs/telemetry.md");
|
|
589
595
|
assert.ok(client.includes("/dsh-remote/telemetry/panel-opened"), "面板打开事件必须走本地路由(浏览器半不直连遥测端点)");
|
|
596
|
+
// 披露文档本身仍必须存在且可查(面板不再链,但 README 链,公开发布物包含)
|
|
590
597
|
const readme = readFileSync(new URL("../../../README.md", import.meta.url), "utf8");
|
|
591
|
-
assert.match(readme, /## 匿名装机统计与隐私/, "README
|
|
598
|
+
assert.match(readme, /## 匿名装机统计与隐私/, "README 必须有对应小节");
|
|
592
599
|
assert.ok(readme.includes("docs/telemetry.md"), "README 必须链到 docs/telemetry.md");
|
|
593
600
|
assert.match(readme, /DSH_REMOTE_TELEMETRY=0/, "README 必须写明关闭方法");
|
|
594
601
|
const doc = readFileSync(new URL("../../../docs/telemetry.md", import.meta.url), "utf8");
|