@mrrisega/dsh-remote 0.4.5 → 0.4.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/package.json +1 -1
- package/packages/dsh-remote-ui/README.md +10 -1
- package/packages/dsh-remote-ui/lib/client.js +5 -2
- package/packages/dsh-remote-ui/lib/index.js +159 -13
- package/packages/dsh-remote-ui/package.json +1 -1
- package/packages/dsh-remote-ui/test/self-manage.test.mjs +13 -0
- package/packages/dsh-remote-ui/test/uninstall-runtime.test.mjs +317 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
# dsh-remote-ui — dsh web
|
|
1
|
+
# dsh-remote-ui — 公网远程控制 dsh web 的插件
|
|
2
|
+
|
|
3
|
+
> **给 DeepSeek Harness(dsh web)一个「随时随地」的公网入口。**
|
|
4
|
+
> 安装后在 dsh web 的设置页登录账号,即得到一个专属的**加密远程地址**:人在办公室外、
|
|
5
|
+
> 用手机流量或任意网络打开它,都能像坐在电脑前一样操作 dsh——继续对话、看工具执行、
|
|
6
|
+
> 审批授权、改设置。**不要求手机和电脑在同一 WiFi/局域网**,也不需要公网 IP、
|
|
7
|
+
> 路由器映射或自己搭服务器,开箱即用、全程加密。
|
|
8
|
+
> 懂技术的用户也可以把服务部署到自己的服务器上,数据与流量完全自控(普通用户无需关心)。
|
|
9
|
+
|
|
10
|
+
以下为面向开发者/维护者的实现说明:
|
|
2
11
|
|
|
3
12
|
Embeds the dsh-remote configuration UI into dsh web itself (the "everything is a
|
|
4
13
|
plugin" model):
|
|
@@ -713,7 +713,10 @@ window.__ModuleLoader__.load({
|
|
|
713
713
|
post("/dsh-remote/self/uninstall", {}).then(function (b) {
|
|
714
714
|
if (b && b.ok) {
|
|
715
715
|
setArmed(false);
|
|
716
|
-
|
|
716
|
+
// 优先展示服务端 detail(含 bridge 自启动/配置目录的逐项清理结果与重启提示);
|
|
717
|
+
// 兜底文案同样说明 bridge 自启动服务与本地配置目录会一并移除/清空
|
|
718
|
+
var detail = b && b.detail ? String(b.detail) : "";
|
|
719
|
+
setSelfMsg({ kind: "ok", text: detail || "已彻底卸载:插件引用、bridge 自启动服务与本地配置目录(账号/密钥/运行时等)已一并移除并清空。请重启 dsh web 后完全生效(本栏目将消失);如需再次使用,在插件市场重新安装即可。" });
|
|
717
720
|
} else {
|
|
718
721
|
setArmed(false);
|
|
719
722
|
setSelfMsg({ kind: "err", text: "卸载失败:" + ((b && (b.error || b.detail)) || "未知错误") });
|
|
@@ -762,7 +765,7 @@ window.__ModuleLoader__.load({
|
|
|
762
765
|
log ? h("div", { className: "dru-up-log", title: "更新日志(尾部)" }, log) : null,
|
|
763
766
|
selfMsg ? h("div", { className: "dru-msg dru-msg-" + selfMsg.kind }, selfMsg.text) : null,
|
|
764
767
|
h("div", { className: "dru-hint", style: { marginTop: 8 } },
|
|
765
|
-
armed ? "
|
|
768
|
+
armed ? "⚠ 再次点击后即开始彻底卸载:① 移除 dsh web 配置中的插件引用与本地文件;② 停止并移除 bridge 自启动服务(macOS com.dshremote.bridge / Linux dsh-bridge)并结束残留进程;③ 清空本地配置目录(~/.dsh-remote:账号、设备密钥、固化运行时等)。此操作不可撤销,如需再次使用请在插件市场重新安装。" :
|
|
766
769
|
"插件市场没有更新/卸载按钮(dsh 官方市场暂不提供),本卡片即官方管理入口:检测新版、一键在线更新、彻底卸载都在这里完成。")
|
|
767
770
|
);
|
|
768
771
|
}
|
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
// - 查询/启停 bridge(launchctl,plist 缺失时自动生成,逻辑与 dsh-setup.mjs 一致)
|
|
6
6
|
// - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理
|
|
7
7
|
// - 自管理 self*(版本可见 / 新版检测 / 一键在线更新 / 彻底卸载):插件市场没有更新卸载按钮,
|
|
8
|
-
// 面板内即官方管理入口;更新=后台 npx @mrrisega/dsh-remote@latest(幂等补齐运行环境并重启 bridge
|
|
8
|
+
// 面板内即官方管理入口;更新=后台 npx @mrrisega/dsh-remote@latest(幂等补齐运行环境并重启 bridge);
|
|
9
|
+
// 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
|
|
10
|
+
// 删 plist|unit / 杀残留进程 / 清空配置目录 ~/.dsh-remote),0.4.7 起回归真正「未安装」状态
|
|
9
11
|
// - 运行时自愈:缺运行环境自动后台安装、登录后自动拉起 bridge(0.4.2 起)
|
|
10
12
|
// - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
|
|
11
13
|
//
|
|
12
14
|
// 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
|
|
13
15
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, constants as fsConstants } from "node:fs";
|
|
14
|
-
import { join, dirname } from "node:path";
|
|
16
|
+
import { join, dirname, sep } from "node:path";
|
|
15
17
|
import { execSync, spawn } from "node:child_process";
|
|
16
18
|
import { homedir, hostname, platform } from "node:os";
|
|
17
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -231,6 +233,7 @@ function setCookieOf(res) {
|
|
|
231
233
|
}
|
|
232
234
|
|
|
233
235
|
async function mintHarnessCookie(ctx, relayDir) {
|
|
236
|
+
if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再代持会话、不再重建配置目录
|
|
234
237
|
try {
|
|
235
238
|
const port = ctx.webServer?.port;
|
|
236
239
|
if (!port) return false;
|
|
@@ -248,6 +251,8 @@ async function mintHarnessCookie(ctx, relayDir) {
|
|
|
248
251
|
const res = await fetch(tokenUrl, { redirect: "manual", signal: AbortSignal.timeout(6000) });
|
|
249
252
|
const cookie = setCookieOf(res).split(";")[0].trim();
|
|
250
253
|
if (!cookie || !cookie.startsWith("dsh-auth-")) return false;
|
|
254
|
+
// 竞态兜底:fetch 期间用户点击了「彻底卸载」→ 不得重建已被清空的配置目录
|
|
255
|
+
if (UNINSTALLED_DIRS.has(relayDir)) return false;
|
|
251
256
|
const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
|
|
252
257
|
mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
|
|
253
258
|
writeFileSync(join(relayDir, HARNESS_COOKIE_FILE), JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
@@ -362,8 +367,11 @@ function appendLogLine(relayDir, name, line) {
|
|
|
362
367
|
|
|
363
368
|
/** 插件市场只装了 UI 插件;若桌面缺 dsh-remote 运行环境(dsh-setup.mjs=bridge/自启动),
|
|
364
369
|
* 由插件在后台自动执行一次 `npx @mrrisega/dsh-remote` 补齐,用户无需手动跑命令。
|
|
365
|
-
* 已有环境(包括手动 npx 装过)直接跳过。返回 true=已就绪。
|
|
370
|
+
* 已有环境(包括手动 npx 装过)直接跳过。返回 true=已就绪。
|
|
371
|
+
* 注意:默认优先官方源——镜像(npmmirror)可能滞后于刚发布的版本,装到旧版会把
|
|
372
|
+
* 已被 0.4.5 移除的“用户 include”重新写回 profile(历史上造成 dsh web 重复 ID 崩溃)。 */
|
|
366
373
|
function ensureRuntime(relayDir) {
|
|
374
|
+
if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再自动安装运行环境
|
|
367
375
|
if (existsSync(join(relayDir, "dsh-setup.mjs"))) return true;
|
|
368
376
|
const marker = join(relayDir, PROVISION_MARKER);
|
|
369
377
|
if (existsSync(marker)) return false; // 正在安装中
|
|
@@ -372,7 +380,7 @@ function ensureRuntime(relayDir) {
|
|
|
372
380
|
const log = join(relayDir, AUTO_INSTALL_LOG);
|
|
373
381
|
const child = spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote"], {
|
|
374
382
|
detached: true,
|
|
375
|
-
env: spawnEnv(),
|
|
383
|
+
env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org" }),
|
|
376
384
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
|
|
377
385
|
});
|
|
378
386
|
writeMarker(marker, child.pid); // 记 pid:宿主重启后可立即清理死进程残留
|
|
@@ -420,6 +428,10 @@ function writeAutostartFile(relayDir) {
|
|
|
420
428
|
|
|
421
429
|
/** 启动 bridge:确保 plist 存在 → launchctl bootstrap(回退 load -w)。 */
|
|
422
430
|
function startBridge(relayDir) {
|
|
431
|
+
if (UNINSTALLED_DIRS.has(relayDir)) {
|
|
432
|
+
// 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
|
|
433
|
+
return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
|
|
434
|
+
}
|
|
423
435
|
const plistPath = launchAgentPath();
|
|
424
436
|
if (!plistPath) return { ok: false, status: "unsupported", detail: "仅支持 macOS" };
|
|
425
437
|
if (!existsSync(plistPath)) writeAutostartFile(relayDir);
|
|
@@ -442,6 +454,7 @@ function scheduleRuntime(relayDir) {
|
|
|
442
454
|
let done = false;
|
|
443
455
|
const iv = setInterval(() => {
|
|
444
456
|
if (done) { clearInterval(iv); return; }
|
|
457
|
+
if (UNINSTALLED_DIRS.has(relayDir)) { done = true; clearInterval(iv); return; } // 已彻底卸载:自愈 watcher 停摆
|
|
445
458
|
try {
|
|
446
459
|
const cfg = loadConfig(relayDir);
|
|
447
460
|
const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
|
|
@@ -469,6 +482,112 @@ function stopBridge() {
|
|
|
469
482
|
return { ok: !st.running, status: st.running ? "failed" : "stopped", pid: null, detail: st.running ? (r.stderr || "停止失败").trim() : void 0 };
|
|
470
483
|
}
|
|
471
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
|
+
|
|
472
591
|
// ---------- relay API 代理(直连,不走系统代理;undici 默认忽略代理环境变量) ----------
|
|
473
592
|
|
|
474
593
|
async function relayFetch(relayDir, pathname, init) {
|
|
@@ -711,7 +830,7 @@ async function proxyFeedback(relayDir, req, res, pathname) {
|
|
|
711
830
|
// ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
|
|
712
831
|
|
|
713
832
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
714
|
-
const PLUGIN_VERSION = "0.4.
|
|
833
|
+
const PLUGIN_VERSION = "0.4.7";
|
|
715
834
|
const UPDATE_LOG = ".dsh-update.log";
|
|
716
835
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
717
836
|
|
|
@@ -740,10 +859,12 @@ function spawnUpdater(relayDir, extraEnv) {
|
|
|
740
859
|
}
|
|
741
860
|
|
|
742
861
|
/**
|
|
743
|
-
* 后台执行在线一键更新:npx @mrrisega/dsh-remote@latest(幂等自愈:补运行环境/更新 bridge
|
|
862
|
+
* 后台执行在线一键更新:npx @mrrisega/dsh-remote@latest(幂等自愈:补运行环境/更新 bridge/收敛 include)。
|
|
744
863
|
* 稳健性:
|
|
745
864
|
* - npx 用绝对路径 + PATH 补全解析(App 拉起的 dsh web PATH 最小化时不再 ENOENT 静默失败);
|
|
746
|
-
* -
|
|
865
|
+
* - 【官方源优先】镜像(npmmirror)滞后时会把旧版(如 0.4.4)当成最新安装,旧 pluginCmd 会把
|
|
866
|
+
* 已被移除的“用户 include”重新写回 profile → dsh web 重启重复 ID 崩溃。故先试官方源,
|
|
867
|
+
* 失败(国内网络)才回退用户默认镜像源;
|
|
747
868
|
* - marker 记录 pid,子进程退出/出错即清理;宿主重启后由 sweepStaleMarkers 立即清掉死进程残留。
|
|
748
869
|
*/
|
|
749
870
|
function runOnlineUpdate(relayDir) {
|
|
@@ -756,16 +877,17 @@ function runOnlineUpdate(relayDir) {
|
|
|
756
877
|
let retried = false;
|
|
757
878
|
const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
|
|
758
879
|
const run = () => {
|
|
759
|
-
|
|
880
|
+
// 第一次:官方 npm 源;失败(exit≠0/网络)才回退用户默认源(通常为国内镜像)
|
|
881
|
+
const child = spawnUpdater(relayDir, retried ? {} : { npm_config_registry: "https://registry.npmjs.org" });
|
|
760
882
|
writeMarker(marker, child.pid);
|
|
761
883
|
child.on("exit", (code) => {
|
|
762
884
|
if (!retried && code !== 0) {
|
|
763
885
|
retried = true;
|
|
764
|
-
appendLogLine(relayDir, UPDATE_LOG, `[update]
|
|
886
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] 官方源安装失败(exit=${code}),回退默认源(npmmirror 等)重试…`);
|
|
765
887
|
run();
|
|
766
888
|
return;
|
|
767
889
|
}
|
|
768
|
-
appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}
|
|
890
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}(${retried ? "默认源" : "官方源"})`);
|
|
769
891
|
clear();
|
|
770
892
|
});
|
|
771
893
|
child.on("error", (e) => {
|
|
@@ -791,7 +913,7 @@ function tailOf(filePath, lines = 24) {
|
|
|
791
913
|
} catch { return ""; }
|
|
792
914
|
}
|
|
793
915
|
|
|
794
|
-
/**
|
|
916
|
+
/** 彻底卸载第 2 步 —— profile 插件清理(兼容市场“拒绝改写用户补丁”):移除 include、依赖、bundle、本地目录与链接。 */
|
|
795
917
|
function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
|
|
796
918
|
const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
|
|
797
919
|
try {
|
|
@@ -870,8 +992,30 @@ function registerRoutes(ctx, relayDir) {
|
|
|
870
992
|
method: "POST",
|
|
871
993
|
path: "/dsh-remote/self/uninstall",
|
|
872
994
|
handler: async (_req, res) => {
|
|
873
|
-
|
|
874
|
-
|
|
995
|
+
// 彻底卸载 = ① 运行时/bridge 清理(停自启动 → 杀残留 → 清空配置目录,顺序防 KeepAlive 复活)
|
|
996
|
+
// + ② 插件 profile 清理(include 块/依赖/bundle/本地目录与链接,解锁市场卸载)
|
|
997
|
+
const rt = uninstallRuntime(relayDir, profileDir);
|
|
998
|
+
const prof = uninstallSelf(relayDir, profileDir, join(profileDir, "cordis.patch.yml"), join(profileDir, "package.json"));
|
|
999
|
+
// 卸载后本进程内(直到重启)自愈/代持调度一律停摆,不再重建配置目录或拉起 bridge
|
|
1000
|
+
markUninstalled(relayDir);
|
|
1001
|
+
const bits = [];
|
|
1002
|
+
if (prof.removedPatch || prof.removedDep || prof.removedBundle || prof.removedDir) bits.push("插件引用与本地文件已移除");
|
|
1003
|
+
if (rt.stoppedService) bits.push("bridge 自启动服务已停止");
|
|
1004
|
+
if (rt.removedPlist) bits.push("自启动项已删除");
|
|
1005
|
+
if (rt.killedPids.length) bits.push(`已结束 ${rt.killedPids.length} 个残留进程`);
|
|
1006
|
+
if (rt.removedDir) bits.push("配置目录已清空(账号/密钥/固化运行时等)");
|
|
1007
|
+
bits.push("请重启 dsh web 后完全卸载生效(本插件与远程控制将消失);如需再次使用,在插件市场重新安装即可。");
|
|
1008
|
+
sendJson(res, 200, {
|
|
1009
|
+
ok: true,
|
|
1010
|
+
...prof, // removedPatch / removedDep / removedBundle / removedDir(profile 插件目录)
|
|
1011
|
+
servicePlatform: rt.servicePlatform,
|
|
1012
|
+
stoppedService: rt.stoppedService,
|
|
1013
|
+
removedPlist: rt.removedPlist,
|
|
1014
|
+
killedPids: rt.killedPids,
|
|
1015
|
+
relayDirRemoved: rt.removedDir, // 配置目录 relayDir 已整目录清空
|
|
1016
|
+
relayDir,
|
|
1017
|
+
detail: bits.join(";"),
|
|
1018
|
+
});
|
|
875
1019
|
},
|
|
876
1020
|
},
|
|
877
1021
|
{
|
|
@@ -1142,6 +1286,8 @@ function registerRoutes(ctx, relayDir) {
|
|
|
1142
1286
|
*/
|
|
1143
1287
|
export function apply(ctx, config = {}) {
|
|
1144
1288
|
const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
|
|
1289
|
+
// 全新激活(dsh web 重启后插件重新加载,或卸载后再次安装)→ 解除上次的「已卸载」停摆标记
|
|
1290
|
+
UNINSTALLED_DIRS.delete(relayDir);
|
|
1145
1291
|
// 清理上次进程残留的安装/更新 marker(宿主被重启/强杀时子进程清理回调会丢失)
|
|
1146
1292
|
sweepStaleMarkers(relayDir);
|
|
1147
1293
|
ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-ui: /dsh-remote routes");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-ui",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
-
"description": "
|
|
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).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -175,6 +175,9 @@ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本
|
|
|
175
175
|
await writeFile(path.join(relayDir, ".dsh-config.json"), "{}");
|
|
176
176
|
|
|
177
177
|
try {
|
|
178
|
+
// 测试隔离开关:卸载路由跳过 launchctl / systemctl / 杀进程等系统级操作(本机真实 bridge 正由 launchd 运行,
|
|
179
|
+
// 不能被测试误停),只验证「插件 profile 清理 + 配置目录清空」逻辑。
|
|
180
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
178
181
|
// 关键:以 tempHome 为 HOME 启动,profileDir 默认推导才会落在 temp profile 而非真实 ~/.dsh
|
|
179
182
|
const routes = boot(tempHome, relayDir);
|
|
180
183
|
const { host, base } = await serve(routes);
|
|
@@ -186,6 +189,15 @@ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本
|
|
|
186
189
|
assert.equal(r.removedBundle, true, "应移除 dsh.profile.bundles 条目");
|
|
187
190
|
assert.equal(r.removedDir, true, "应删除本地插件目录与 node_modules 链接");
|
|
188
191
|
|
|
192
|
+
// 运行时/bridge 清理(隔离模式下只清空配置目录,绝不触碰真实 launchd 服务)
|
|
193
|
+
assert.equal(r.relayDirRemoved, true, "配置目录 relayDir 应被整目录清空");
|
|
194
|
+
assert.equal(r.stoppedService, false, "DSH_RELAY_SKIP_SERVICE=1 → 不做真实服务操作");
|
|
195
|
+
assert.equal(r.removedPlist, false, "DSH_RELAY_SKIP_SERVICE=1 → 不删自启动 plist");
|
|
196
|
+
assert.deepEqual(r.killedPids, [], "DSH_RELAY_SKIP_SERVICE=1 → 不杀进程");
|
|
197
|
+
assert.equal(existsSync(relayDir), false, "relayDir 物理上应已不存在");
|
|
198
|
+
assert.match(String(r.detail), /配置目录已清空/, "detail 应说明配置目录已清空");
|
|
199
|
+
assert.match(String(r.detail), /请重启 dsh web/, "detail 应提示重启生效");
|
|
200
|
+
|
|
189
201
|
const patch = await readFile(patchFile, "utf8");
|
|
190
202
|
assert.ok(!patch.includes("dsh-remote-ui"), "patch 不应再引用 dsh-remote-ui");
|
|
191
203
|
assert.ok(patch.includes("other-plugin"), "其他插件 include 不应被误伤");
|
|
@@ -198,6 +210,7 @@ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本
|
|
|
198
210
|
host.close();
|
|
199
211
|
}
|
|
200
212
|
} finally {
|
|
213
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
201
214
|
await rm(tempHome, { recursive: true, force: true });
|
|
202
215
|
}
|
|
203
216
|
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// 插件 node 半「彻底卸载(运行时/bridge 清理)」回归:/dsh-remote/self/uninstall 的新增行为。
|
|
2
|
+
// 背景:用户反馈卸载不干净——bridge 自启动服务(launchd com.dshremote.bridge / Linux dsh-bridge)
|
|
3
|
+
// 仍在运行、自启动 plist/unit 仍在、配置目录 ~/.dsh-remote(账号/设备密钥/.dsh-config.json/
|
|
4
|
+
// .harness-cookie.json/固化运行时 dsh-setup.mjs + clients 等)也还在。
|
|
5
|
+
// 卸载路由现在依次执行:profile 插件清理(uninstallSelf,原逻辑)+ 运行时清理(uninstallRuntime:
|
|
6
|
+
// 停自启动服务 → 删自启动文件 → 杀残留进程 → rm -rf 配置目录),全部幂等、单项失败不致命。
|
|
7
|
+
// 测试隔离(绝不触碰本机真实 launchd/systemd/进程):
|
|
8
|
+
// - 大多数用例设 DSH_RELAY_SKIP_SERVICE=1 → 跳过一切系统级操作,只验证配置目录清理与 profile 清理;
|
|
9
|
+
// - 「自然跳过」用例把 PATH 指向假 launchctl/pgrep/ps,完全接管系统命令后走无开关路径。
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import { apply } from "../lib/index.js";
|
|
18
|
+
|
|
19
|
+
const PLUGIN_BLOCK = `# >>> dsh-remote-ui (managed by dsh-remote plugin; do not edit)
|
|
20
|
+
- type: plugin
|
|
21
|
+
name: dsh-remote-ui
|
|
22
|
+
apply: dsh-remote-ui
|
|
23
|
+
# <<< dsh-remote-ui
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
/** 加载插件(要求调用方已把 process.env.HOME 指向 tempHome;profile 推导才落在 temp 而非真实 ~/.dsh)。 */
|
|
27
|
+
function boot(relayDir) {
|
|
28
|
+
const routes = new Map();
|
|
29
|
+
apply({
|
|
30
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
31
|
+
effect(register) { return register(); },
|
|
32
|
+
logger: { info() {}, warn() {} }
|
|
33
|
+
}, { relayDir });
|
|
34
|
+
return routes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 起一个指向 routes 的 http server,返回 base url。 */
|
|
38
|
+
async function serve(routes) {
|
|
39
|
+
const host = http.createServer((req, res) => {
|
|
40
|
+
const url = new URL(req.url, "http://x");
|
|
41
|
+
const handler = routes.get(url.pathname);
|
|
42
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
43
|
+
});
|
|
44
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
45
|
+
return { host, base: `http://127.0.0.1:${host.address().port}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 在 tempHome 下造一份「真实安装痕迹」:profile(patch+package.json+插件目录)+ relayDir(配置/密钥/运行时)。 */
|
|
49
|
+
async function plantInstallation(tempHome, opts = {}) {
|
|
50
|
+
const relayDir = opts.relayDir || path.join(tempHome, "relay");
|
|
51
|
+
const profile = path.join(tempHome, ".dsh", "profiles", "web");
|
|
52
|
+
await mkdir(profile, { recursive: true });
|
|
53
|
+
await writeFile(path.join(profile, "cordis.patch.yml"), `base: .\ninclude:\n${PLUGIN_BLOCK} - other-plugin\n`);
|
|
54
|
+
await writeFile(path.join(profile, "package.json"), JSON.stringify({
|
|
55
|
+
dependencies: { "dsh-remote-ui": "github:mrRisega/dsh-remote#path:/packages/dsh-remote-ui", "other": "^1.0.0" },
|
|
56
|
+
dsh: { profile: { bundles: ["dsh-remote-ui", "other-bundle"] } },
|
|
57
|
+
}));
|
|
58
|
+
if (!opts.skipPluginDirs) {
|
|
59
|
+
await mkdir(path.join(profile, "dsh-remote-ui-plugin"), { recursive: true });
|
|
60
|
+
await mkdir(path.join(profile, "node_modules", "dsh-remote-ui"), { recursive: true });
|
|
61
|
+
await writeFile(path.join(profile, "dsh-remote-ui-plugin", "index.js"), "// stub");
|
|
62
|
+
await writeFile(path.join(profile, "node_modules", "dsh-remote-ui", "index.js"), "// stub");
|
|
63
|
+
}
|
|
64
|
+
if (!opts.skipRelay) {
|
|
65
|
+
// 模拟固化运行时 + 账号/密钥/会话 cookie/安装标记等全部残留
|
|
66
|
+
await mkdir(path.join(relayDir, "clients", "dsh-remote"), { recursive: true });
|
|
67
|
+
await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
68
|
+
phone: "13800000000", password: "pw", device_id: "dev", device_private_key: "pk", local_key: "lk",
|
|
69
|
+
}));
|
|
70
|
+
await writeFile(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
71
|
+
await writeFile(path.join(relayDir, "clients", "dsh-remote", "dsh-bridge.mjs"), "// bridge stub");
|
|
72
|
+
await writeFile(path.join(relayDir, ".harness-cookie.json"), JSON.stringify({ authority: "127.0.0.1:3080", cookie: "dsh-auth-x=1" }));
|
|
73
|
+
await writeFile(path.join(relayDir, ".dsh-setup-installing"), JSON.stringify({ pid: 999999, at: Date.now() }));
|
|
74
|
+
await writeFile(path.join(relayDir, ".dsh-update-running"), JSON.stringify({ pid: 999999, at: Date.now() }));
|
|
75
|
+
await writeFile(path.join(relayDir, "unrelated-marker.txt"), "keep-me");
|
|
76
|
+
}
|
|
77
|
+
return { relayDir, profile };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行时),不误伤 profile 其它条目与家目录其它数据", async () => {
|
|
81
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-"));
|
|
82
|
+
const prevHome = process.env.HOME;
|
|
83
|
+
process.env.HOME = tempHome;
|
|
84
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 测试隔离:跳过 launchctl/systemctl/杀进程
|
|
85
|
+
try {
|
|
86
|
+
const { relayDir, profile } = await plantInstallation(tempHome);
|
|
87
|
+
// 家目录里放一份「用户自己的数据」:卸载绝不能误删
|
|
88
|
+
await writeFile(path.join(tempHome, "my-notes.txt"), "user data");
|
|
89
|
+
const routes = boot(relayDir);
|
|
90
|
+
const { host, base } = await serve(routes);
|
|
91
|
+
try {
|
|
92
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
93
|
+
assert.equal(r.ok, true);
|
|
94
|
+
// profile 清理(原 uninstallSelf 行为不变)
|
|
95
|
+
assert.equal(r.removedPatch, true);
|
|
96
|
+
assert.equal(r.removedDep, true);
|
|
97
|
+
assert.equal(r.removedBundle, true);
|
|
98
|
+
assert.equal(r.removedDir, true, "profile 插件目录与 node_modules 链接应删除");
|
|
99
|
+
// 运行时清理标志位
|
|
100
|
+
assert.equal(r.relayDirRemoved, true, "relayDir 应被整目录清空");
|
|
101
|
+
assert.equal(r.servicePlatform, "launchd", "macOS 下 servicePlatform=launchd");
|
|
102
|
+
assert.equal(r.stoppedService, false, "DSH_RELAY_SKIP_SERVICE=1 → 不触碰真实 launchd 服务");
|
|
103
|
+
assert.equal(r.removedPlist, false);
|
|
104
|
+
assert.deepEqual(r.killedPids, []);
|
|
105
|
+
// 物理断言:relayDir 与其中全部残留都消失
|
|
106
|
+
assert.equal(existsSync(relayDir), false, "relayDir 目录应整体不存在");
|
|
107
|
+
assert.equal(existsSync(path.join(relayDir, ".dsh-config.json")), false);
|
|
108
|
+
assert.equal(existsSync(path.join(relayDir, "dsh-setup.mjs")), false);
|
|
109
|
+
assert.equal(existsSync(path.join(relayDir, "clients")), false);
|
|
110
|
+
// 不误伤:profile 的其它 include/依赖仍在;家目录其它文件仍在
|
|
111
|
+
const patch = await readFile(path.join(profile, "cordis.patch.yml"), "utf8");
|
|
112
|
+
assert.ok(!patch.includes("dsh-remote-ui") && patch.includes("other-plugin"), "patch 只移除插件条目");
|
|
113
|
+
const pkg = JSON.parse(await readFile(path.join(profile, "package.json"), "utf8"));
|
|
114
|
+
assert.equal(pkg.dependencies["other"], "^1.0.0");
|
|
115
|
+
assert.deepEqual(pkg.dsh.profile.bundles, ["other-bundle"]);
|
|
116
|
+
assert.equal(existsSync(path.join(tempHome, "my-notes.txt")), true, "家目录其它用户数据不得被删");
|
|
117
|
+
assert.equal(existsSync(profile), true, "dsh web profile 目录整体不得被删");
|
|
118
|
+
// 人类可读 detail
|
|
119
|
+
assert.match(String(r.detail), /插件引用与本地文件已移除/);
|
|
120
|
+
assert.match(String(r.detail), /配置目录已清空/);
|
|
121
|
+
assert.match(String(r.detail), /请重启 dsh web/);
|
|
122
|
+
} finally {
|
|
123
|
+
host.close();
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
127
|
+
process.env.HOME = prevHome;
|
|
128
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("彻底卸载边界:只装了插件、从未跑过 bridge → relayDir 不存在仍返回成功且不虚报「已清空」", async () => {
|
|
133
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-nodir-"));
|
|
134
|
+
const prevHome = process.env.HOME;
|
|
135
|
+
process.env.HOME = tempHome;
|
|
136
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
137
|
+
try {
|
|
138
|
+
// profile 有插件引用,但 relayDir(~/.dsh-remote)从未被创建
|
|
139
|
+
const relayDir = path.join(tempHome, "relay");
|
|
140
|
+
await plantInstallation(tempHome, { skipRelay: true });
|
|
141
|
+
assert.equal(existsSync(relayDir), false);
|
|
142
|
+
const routes = boot(relayDir);
|
|
143
|
+
const { host, base } = await serve(routes);
|
|
144
|
+
try {
|
|
145
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
146
|
+
assert.equal(r.ok, true);
|
|
147
|
+
assert.equal(r.removedPatch, true, "profile 里的插件引用应照常清理");
|
|
148
|
+
assert.equal(r.relayDirRemoved, false, "目录本就不存在 → 不声称已清空");
|
|
149
|
+
assert.equal(r.stoppedService, false);
|
|
150
|
+
assert.equal(r.removedPlist, false);
|
|
151
|
+
assert.deepEqual(r.killedPids, []);
|
|
152
|
+
const detail = String(r.detail);
|
|
153
|
+
assert.ok(!detail.includes("配置目录已清空"), "目录不存在时不应谎报已清空,实际: " + detail);
|
|
154
|
+
assert.match(detail, /请重启 dsh web/);
|
|
155
|
+
} finally {
|
|
156
|
+
host.close();
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
160
|
+
process.env.HOME = prevHome;
|
|
161
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("彻底卸载边界:什么都没有安装 → 全链路幂等返回 ok,不抛错不崩溃", async () => {
|
|
166
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-empty-"));
|
|
167
|
+
const prevHome = process.env.HOME;
|
|
168
|
+
process.env.HOME = tempHome;
|
|
169
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
170
|
+
try {
|
|
171
|
+
const relayDir = path.join(tempHome, "relay"); // 不存在
|
|
172
|
+
const routes = boot(relayDir);
|
|
173
|
+
const { host, base } = await serve(routes);
|
|
174
|
+
try {
|
|
175
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
176
|
+
assert.equal(r.ok, true);
|
|
177
|
+
assert.equal(r.relayDirRemoved, false);
|
|
178
|
+
assert.equal(r.removedPatch, false);
|
|
179
|
+
assert.equal(r.removedDep, false);
|
|
180
|
+
assert.equal(r.removedBundle, false);
|
|
181
|
+
assert.deepEqual(r.killedPids, []);
|
|
182
|
+
assert.match(String(r.detail), /请重启 dsh web/);
|
|
183
|
+
} finally {
|
|
184
|
+
host.close();
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
188
|
+
process.env.HOME = prevHome;
|
|
189
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也不执行系统级清理,仅清空配置目录", async () => {
|
|
194
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-gate-"));
|
|
195
|
+
const prevHome = process.env.HOME;
|
|
196
|
+
process.env.HOME = tempHome;
|
|
197
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
198
|
+
try {
|
|
199
|
+
const { relayDir } = await plantInstallation(tempHome);
|
|
200
|
+
// 在「当前 HOME 的 LaunchAgents」放一个真实存在的 plist:开关必须挡下删除动作
|
|
201
|
+
const fakePlist = path.join(tempHome, "Library", "LaunchAgents", "com.dshremote.bridge.plist");
|
|
202
|
+
await mkdir(path.dirname(fakePlist), { recursive: true });
|
|
203
|
+
await writeFile(fakePlist, "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
|
|
204
|
+
const routes = boot(relayDir);
|
|
205
|
+
const { host, base } = await serve(routes);
|
|
206
|
+
try {
|
|
207
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
208
|
+
assert.equal(r.ok, true);
|
|
209
|
+
assert.equal(r.removedPlist, false, "开关置位 → plist 不得被删");
|
|
210
|
+
assert.equal(existsSync(fakePlist), true, "plist 应原样保留(开关只放行配置目录清理)");
|
|
211
|
+
assert.equal(r.relayDirRemoved, true, "配置目录清理不受开关影响");
|
|
212
|
+
assert.equal(existsSync(relayDir), false);
|
|
213
|
+
} finally {
|
|
214
|
+
host.close();
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
218
|
+
process.env.HOME = prevHome;
|
|
219
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("自然跳过(无开关):HOME 有 plist 但服务未运行 → 删除 plist 但不发 launchctl bootout(PATH 假命令接管)", async () => {
|
|
224
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-natural-"));
|
|
225
|
+
const prevHome = process.env.HOME;
|
|
226
|
+
const prevPath = process.env.PATH;
|
|
227
|
+
const prevLog = process.env.DSH_TEST_LAUNCH_LOG;
|
|
228
|
+
process.env.HOME = tempHome;
|
|
229
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE; // 明确走无开关的真实分支
|
|
230
|
+
const launchLog = path.join(tempHome, "launchctl.log");
|
|
231
|
+
try {
|
|
232
|
+
// 假系统命令:launchctl 只记录参数、绝不报 running;pgrep/ps 一律空
|
|
233
|
+
const fakeBin = path.join(tempHome, "bin");
|
|
234
|
+
await mkdir(fakeBin, { recursive: true });
|
|
235
|
+
const fakeLaunchctl = `#!/bin/sh
|
|
236
|
+
printf '%s\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
|
|
237
|
+
exit 0
|
|
238
|
+
`;
|
|
239
|
+
const fakeNoMatch = "#!/bin/sh\nexit 1\n";
|
|
240
|
+
writeFileSync(path.join(fakeBin, "launchctl"), fakeLaunchctl); chmodSync(path.join(fakeBin, "launchctl"), 0o755);
|
|
241
|
+
writeFileSync(path.join(fakeBin, "pgrep"), fakeNoMatch); chmodSync(path.join(fakeBin, "pgrep"), 0o755);
|
|
242
|
+
writeFileSync(path.join(fakeBin, "ps"), fakeNoMatch); chmodSync(path.join(fakeBin, "ps"), 0o755);
|
|
243
|
+
process.env.PATH = `${fakeBin}:${prevPath}`;
|
|
244
|
+
process.env.DSH_TEST_LAUNCH_LOG = launchLog;
|
|
245
|
+
|
|
246
|
+
const { relayDir } = await plantInstallation(tempHome);
|
|
247
|
+
// 当前 HOME 的 LaunchAgents 里有 plist(服务此前装过,但现在未运行)
|
|
248
|
+
const plist = path.join(tempHome, "Library", "LaunchAgents", "com.dshremote.bridge.plist");
|
|
249
|
+
await mkdir(path.dirname(plist), { recursive: true });
|
|
250
|
+
await writeFile(plist, "<?xml version=\"1.0\"?><plist version=\"1.0\"><dict/></plist>");
|
|
251
|
+
|
|
252
|
+
const routes = boot(relayDir);
|
|
253
|
+
const { host, base } = await serve(routes);
|
|
254
|
+
try {
|
|
255
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
256
|
+
assert.equal(r.ok, true);
|
|
257
|
+
assert.equal(r.removedPlist, true, "plist 存在于当前 HOME → 应被删除");
|
|
258
|
+
assert.equal(existsSync(plist), false);
|
|
259
|
+
assert.equal(r.stoppedService, false, "服务未运行 → 无需 bootout,不虚报已停止");
|
|
260
|
+
assert.deepEqual(r.killedPids, [], "无残留进程可杀");
|
|
261
|
+
assert.equal(r.relayDirRemoved, true);
|
|
262
|
+
assert.equal(existsSync(relayDir), false);
|
|
263
|
+
// 关键:全程不得发出 bootout(避免任何真实/假服务被停)
|
|
264
|
+
const log = readFileSync(launchLog, "utf8");
|
|
265
|
+
assert.ok(!log.includes("bootout"), "未运行的服务不应触发 bootout,实际调用: " + log);
|
|
266
|
+
} finally {
|
|
267
|
+
host.close();
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
if (prevLog === undefined) delete process.env.DSH_TEST_LAUNCH_LOG; else process.env.DSH_TEST_LAUNCH_LOG = prevLog;
|
|
271
|
+
process.env.PATH = prevPath;
|
|
272
|
+
process.env.HOME = prevHome;
|
|
273
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("保护参数:relayDir 误配置成 dsh web profile 目录 → 绝不整目录删除(防误删 dsh 本体)", async () => {
|
|
278
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-protect-"));
|
|
279
|
+
const prevHome = process.env.HOME;
|
|
280
|
+
process.env.HOME = tempHome;
|
|
281
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
282
|
+
try {
|
|
283
|
+
// relayDir 与 profileDir 相同(异常配置):卸载只清插件引用/文件,绝不能 rm 整个 profile
|
|
284
|
+
const profile = path.join(tempHome, ".dsh", "profiles", "web");
|
|
285
|
+
const { relayDir } = await plantInstallation(tempHome, { relayDir: profile, skipRelay: true });
|
|
286
|
+
assert.equal(relayDir, profile);
|
|
287
|
+
const routes = boot(relayDir);
|
|
288
|
+
const { host, base } = await serve(routes);
|
|
289
|
+
try {
|
|
290
|
+
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
291
|
+
assert.equal(r.ok, true);
|
|
292
|
+
assert.equal(r.relayDirRemoved, false, "profile 目录受保护 → 不得整目录删除");
|
|
293
|
+
assert.equal(existsSync(profile), true, "dsh web profile 必须存活");
|
|
294
|
+
assert.equal(existsSync(path.join(profile, "package.json")), true);
|
|
295
|
+
assert.equal(existsSync(path.join(profile, "dsh-remote-ui-plugin")), false, "插件子目录应被 uninstallSelf 单独移除");
|
|
296
|
+
assert.equal(existsSync(path.join(profile, "node_modules", "dsh-remote-ui")), false);
|
|
297
|
+
} finally {
|
|
298
|
+
host.close();
|
|
299
|
+
}
|
|
300
|
+
} finally {
|
|
301
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
302
|
+
process.env.HOME = prevHome;
|
|
303
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("源码约束:浏览器半彻底卸载文案与行为说明 bridge 服务与本地配置一并移除/清空", () => {
|
|
308
|
+
const source = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
309
|
+
// 二次确认文案:说明会停 bridge 自启动、清配置目录(不再有旧的「bridge 与数据目录保留」误导)
|
|
310
|
+
assert.match(source, /停止并移除 bridge 自启动服务/);
|
|
311
|
+
assert.match(source, /清空本地配置目录/);
|
|
312
|
+
assert.ok(!source.includes("bridge 与数据目录保留"), "旧文案已移除:卸载不再保留 bridge 与数据目录");
|
|
313
|
+
// 成功提示:优先展示服务端 detail,兜底文案同样声明 bridge/本地配置已移除
|
|
314
|
+
assert.match(source, /b\.detail/);
|
|
315
|
+
assert.match(source, /bridge 自启动服务与本地配置目录/);
|
|
316
|
+
assert.match(source, /请重启 dsh web/);
|
|
317
|
+
});
|