@mrrisega/dsh-remote 0.4.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.4.6",
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
- setSelfMsg({ kind: "ok", text: "已移除插件引用与本地文件。请重启 dsh web:插件将完全卸载(本栏目也会消失)。之后如需重新安装,直接在插件市场再次安装即可。" });
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 ? "卸载会移除插件引用与本地文件;远程控制用的桌面 bridge 与数据目录保留,可随时重新安装。" :
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 });
@@ -366,6 +371,7 @@ function appendLogLine(relayDir, name, line) {
366
371
  * 注意:默认优先官方源——镜像(npmmirror)可能滞后于刚发布的版本,装到旧版会把
367
372
  * 已被 0.4.5 移除的“用户 include”重新写回 profile(历史上造成 dsh web 重复 ID 崩溃)。 */
368
373
  function ensureRuntime(relayDir) {
374
+ if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再自动安装运行环境
369
375
  if (existsSync(join(relayDir, "dsh-setup.mjs"))) return true;
370
376
  const marker = join(relayDir, PROVISION_MARKER);
371
377
  if (existsSync(marker)) return false; // 正在安装中
@@ -422,6 +428,10 @@ function writeAutostartFile(relayDir) {
422
428
 
423
429
  /** 启动 bridge:确保 plist 存在 → launchctl bootstrap(回退 load -w)。 */
424
430
  function startBridge(relayDir) {
431
+ if (UNINSTALLED_DIRS.has(relayDir)) {
432
+ // 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
433
+ return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
434
+ }
425
435
  const plistPath = launchAgentPath();
426
436
  if (!plistPath) return { ok: false, status: "unsupported", detail: "仅支持 macOS" };
427
437
  if (!existsSync(plistPath)) writeAutostartFile(relayDir);
@@ -444,6 +454,7 @@ function scheduleRuntime(relayDir) {
444
454
  let done = false;
445
455
  const iv = setInterval(() => {
446
456
  if (done) { clearInterval(iv); return; }
457
+ if (UNINSTALLED_DIRS.has(relayDir)) { done = true; clearInterval(iv); return; } // 已彻底卸载:自愈 watcher 停摆
447
458
  try {
448
459
  const cfg = loadConfig(relayDir);
449
460
  const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
@@ -471,6 +482,112 @@ function stopBridge() {
471
482
  return { ok: !st.running, status: st.running ? "failed" : "stopped", pid: null, detail: st.running ? (r.stderr || "停止失败").trim() : void 0 };
472
483
  }
473
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
+
474
591
  // ---------- relay API 代理(直连,不走系统代理;undici 默认忽略代理环境变量) ----------
475
592
 
476
593
  async function relayFetch(relayDir, pathname, init) {
@@ -713,7 +830,7 @@ async function proxyFeedback(relayDir, req, res, pathname) {
713
830
  // ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
714
831
 
715
832
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
716
- const PLUGIN_VERSION = "0.4.6";
833
+ const PLUGIN_VERSION = "0.4.7";
717
834
  const UPDATE_LOG = ".dsh-update.log";
718
835
  const UPDATE_MARKER = ".dsh-update-running";
719
836
 
@@ -796,7 +913,7 @@ function tailOf(filePath, lines = 24) {
796
913
  } catch { return ""; }
797
914
  }
798
915
 
799
- /** 彻底卸载(兼容市场“拒绝改写用户补丁”的场景):移除 include、依赖、bundle、本地目录与链接。 */
916
+ /** 彻底卸载第 2 步 —— profile 插件清理(兼容市场“拒绝改写用户补丁”):移除 include、依赖、bundle、本地目录与链接。 */
800
917
  function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
801
918
  const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
802
919
  try {
@@ -875,8 +992,30 @@ function registerRoutes(ctx, relayDir) {
875
992
  method: "POST",
876
993
  path: "/dsh-remote/self/uninstall",
877
994
  handler: async (_req, res) => {
878
- const r = uninstallSelf(relayDir, profileDir, join(profileDir, "cordis.patch.yml"), join(profileDir, "package.json"));
879
- sendJson(res, 200, { ok: true, ...r, detail: "已移除插件引用与本地文件,重启 dsh web 后完全卸载生效" });
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
+ });
880
1019
  },
881
1020
  },
882
1021
  {
@@ -1147,6 +1286,8 @@ function registerRoutes(ctx, relayDir) {
1147
1286
  */
1148
1287
  export function apply(ctx, config = {}) {
1149
1288
  const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
1289
+ // 全新激活(dsh web 重启后插件重新加载,或卸载后再次安装)→ 解除上次的「已卸载」停摆标记
1290
+ UNINSTALLED_DIRS.delete(relayDir);
1150
1291
  // 清理上次进程残留的安装/更新 marker(宿主被重启/强杀时子进程清理回调会丢失)
1151
1292
  sweepStaleMarkers(relayDir);
1152
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": "Remote control for dsh web from a phone browser: an in-harness Settings panel (cloud/self-hosted mode tabs, account login, bridge start/stop, feedback) plus same-origin /dsh-remote relay routes; the desktop bridge and self-hosted relay-router shipped with this repo tunnel the local dsh web to a phone PWA with access-key auth, live device list and optional traffic quotas. · dsh web 远程控制插件:设置页「远程控制」栏目(settings.section 官方扩展点)+ 配置面板 + bridge 启停 + 反馈/邀请/满意度(双半插件:node 半提供 /dsh-remote 宿主 API,浏览器半提供 UI)。",
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
+ });