@mrrisega/dsh-remote 0.4.2 → 0.4.4

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 CHANGED
@@ -79,6 +79,12 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
79
79
  其他命令:`settings`(设置页)、`status`(查看状态)、`run`(前台调试)、
80
80
  `plugin`(重装/卸载 dsh web 插件)。运行 `npx @mrrisega/dsh-remote --help` 查看完整说明。
81
81
 
82
+ > **版本与更新 / 卸载**:dsh 官方插件市场目前不提供更新按钮,也不会改写用户补丁(因此市场
83
+ > 卸载会提示「仍通过 insert 引用 dsh-remote-ui」而拒绝)。插件设置面板里已内置管理入口
84
+ > (dsh web → 设置 → 「远程控制」→「🔄 版本与更新」卡片):显示当前版本、自动检测 npm 新版、
85
+ > **一键在线更新**(后台补运行环境并重启 bridge,完成后重启 dsh web 生效)、以及**彻底卸载**
86
+ > (移除补丁 include / 依赖 / bundle 与本地文件,之后市场卸载或直接重启均可完成卸载)。
87
+
82
88
  源码安装(开发 / 自建服务器):`git clone https://github.com/mrRisega/dsh-remote.git`
83
89
  并 `npm install`,见下文各组件说明。
84
90
 
package/dsh-setup.mjs CHANGED
@@ -41,6 +41,63 @@ const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
41
41
  const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
42
42
  const REPO_URL = "https://github.com/mrRisega/dsh-remote";
43
43
 
44
+ // ---------- 运行时自物化(npm/npx 安装 → 固化到配置目录,脱离 npx 缓存) ----------
45
+ // npx 每次安装的缓存目录(~/.npm/_npx/<hash>)不固定:缓存一旦清理,指向它的自启动服务
46
+ // 就会像“找不到模块”一样崩溃。因此 npm 形态安装时把 dsh-setup.mjs + clients + 依赖(ws)
47
+ // 固化到 CONFIG_DIR(~/.dsh-remote),自启动服务只指向这个稳定路径。
48
+ // 插件(dsh-remote-ui)的“运行环境已就绪”判断同样以 CONFIG_DIR/dsh-setup.mjs 为准。
49
+
50
+ function pkgVersion() {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(path.join(THIS_DIR, "package.json"), "utf8")).version || "";
53
+ } catch { return ""; }
54
+ }
55
+
56
+ /** 向上查找依赖树里的 ws(npx 布局通常提升到缓存根 node_modules,npm 布局则内嵌)。 */
57
+ function findDepWs(startDir) {
58
+ let d = startDir;
59
+ while (true) {
60
+ const cand = path.join(d, "node_modules", "ws");
61
+ if (fs.existsSync(cand)) return cand;
62
+ const parent = path.dirname(d);
63
+ if (parent === d) break;
64
+ d = parent;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function ensureRuntimeCopy() {
70
+ if (!IS_NPM_INSTALL) return; // 仓库开发形态:原地使用
71
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
72
+ const ver = pkgVersion();
73
+ const setupTarget = path.join(CONFIG_DIR, "dsh-setup.mjs");
74
+ if (setupTarget === path.join(THIS_DIR, "dsh-setup.mjs")) return; // 已在配置目录内执行
75
+ const verFile = path.join(CONFIG_DIR, ".dsh-setup-version");
76
+ let cur = "";
77
+ try { cur = fs.readFileSync(verFile, "utf8").trim(); } catch { /* 首次 */ }
78
+ if (fs.existsSync(setupTarget) && cur === ver) return; // 同版本幂等跳过
79
+ fs.cpSync(path.join(THIS_DIR, "dsh-setup.mjs"), setupTarget);
80
+ fs.cpSync(path.join(THIS_DIR, "clients"), path.join(CONFIG_DIR, "clients"), { recursive: true, force: true });
81
+ const wsSrc = findDepWs(THIS_DIR);
82
+ if (wsSrc) {
83
+ fs.mkdirSync(path.join(CONFIG_DIR, "node_modules"), { recursive: true });
84
+ fs.cpSync(wsSrc, path.join(CONFIG_DIR, "node_modules", "ws"), { recursive: true, force: true });
85
+ }
86
+ fs.writeFileSync(verFile, ver);
87
+ console.log(`✅ 运行时已固化到 ${CONFIG_DIR}(自启动指向稳定路径,不再依赖 npx 缓存)`);
88
+ }
89
+ try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
90
+
91
+ /** 自启动服务应指向的 dsh-setup.mjs:优先配置目录内的固化副本,否则当前执行文件。 */
92
+ function runtimeSetupPath() {
93
+ const local = path.join(CONFIG_DIR, "dsh-setup.mjs");
94
+ try {
95
+ fs.accessSync(local, fs.constants.R_OK);
96
+ return local;
97
+ } catch { /* 未固化(如仓库开发)→ 用当前文件 */ }
98
+ return fileURLToPath(import.meta.url);
99
+ }
100
+
44
101
  // ---------- 工具 ----------
45
102
 
46
103
  function sh(cmd, timeoutMs = 15000, cwd = undefined) {
@@ -137,7 +194,7 @@ function autostartFilePath() {
137
194
  }
138
195
 
139
196
  function writeAutostartFile() {
140
- const runCmd = `"${NODE_BIN}" "${fileURLToPath(import.meta.url)}" run`;
197
+ const runCmd = `"${NODE_BIN}" "${runtimeSetupPath()}" run`;
141
198
  if (process.platform === "darwin") {
142
199
  const plistPath = autostartFilePath();
143
200
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
@@ -145,7 +202,7 @@ function writeAutostartFile() {
145
202
  <plist version="1.0"><dict>
146
203
  <key>Label</key><string>com.dshremote.bridge</string>
147
204
  <key>ProgramArguments</key>
148
- <array><string>${NODE_BIN}</string><string>${fileURLToPath(import.meta.url)}</string><string>run</string></array>
205
+ <array><string>${NODE_BIN}</string><string>${runtimeSetupPath()}</string><string>run</string></array>
149
206
  <key>RunAtLoad</key><true/>
150
207
  <key>KeepAlive</key><true/>
151
208
  <key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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",
@@ -124,6 +124,11 @@ window.__ModuleLoader__.load({
124
124
  ".dru-popup-foot button{border:none;background:none;color:#57606a;cursor:pointer;font-size:12px;font-family:inherit;padding:4px 6px}",
125
125
  ".dru-popup-foot button:hover{color:#0969da}",
126
126
  ".dru-popup .dru-msg{text-align:left}",
127
+ // ── 自管理:版本与更新(插件面板内提供在线更新/彻底卸载,市场无更新按钮) ──
128
+ ".dru-ver-badge{display:inline-block;font-size:11px;border-radius:999px;padding:1px 8px;margin-left:6px;vertical-align:1px}",
129
+ ".dru-ver-badge-new{color:#9a6700;background:#fff8c5;border:1px solid #eed888}",
130
+ ".dru-ver-badge-ok{color:#1a7f37;background:#dafbe1;border:1px solid #aceebb}",
131
+ ".dru-up-log{margin-top:8px;background:#0d1117;color:#e6edf3;border-radius:8px;padding:8px 10px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:150px;overflow:auto}",
127
132
  ].join("\n");
128
133
  document.head.appendChild(styleEl);
129
134
 
@@ -607,6 +612,161 @@ window.__ModuleLoader__.load({
607
612
  // 通用「远程控制」图标(🖥 风格 emoji,与面板内既有 emoji 图标体系一致):
608
613
  // 栏目导航 label 与栏目头部均使用它。原侧边栏入口(电源图标按钮)已随入口迁移移除。
609
614
 
615
+ // ── 版本与更新卡片(自管理:市场没有更新按钮,这里提供在线一键更新/彻底卸载) ──
616
+ // 数据来自 node 半新增的 /dsh-remote/self* 路由;逻辑均在插件 node 半实现,
617
+ // 因此无论插件从「插件市场」还是 npx 安装,界面与行为完全一致。
618
+ function SelfManageCard() {
619
+ var verArr = useState(null); var ver = verArr[0]; var setVer = verArr[1]; // {version, runtimeReady}
620
+ var chkArr = useState(null); var chk = chkArr[0]; var setChk = chkArr[1]; // {current, latest, outdated}
621
+ var chkBusyArr = useState(false); var chkBusy = chkBusyArr[0]; var setChkBusy = chkBusyArr[1];
622
+ var upBusyArr = useState(false); var upBusy = upBusyArr[0]; var setUpBusy = upBusyArr[1];
623
+ var unBusyArr = useState(false); var unBusy = unBusyArr[0]; var setUnBusy = unBusyArr[1];
624
+ var logArr = useState(""); var log = logArr[0]; var setLog = logArr[1]; // 更新日志尾部
625
+ var updArr = useState(false); var updating = updArr[0]; var setUpdating = updArr[1]; // 更新任务是否仍在跑
626
+ var doneArr = useState(false); var updated = doneArr[0]; var setUpdated = doneArr[1]; // 本轮已更新完成(提示重启)
627
+ var armArr = useState(false); var armed = armArr[0]; var setArmed = armArr[1]; // 彻底卸载二次确认
628
+ var msgArr = useState(null); var selfMsg = msgArr[0]; var setSelfMsg = msgArr[1]; // {kind, text}
629
+
630
+ var loadVer = useCallback(function () {
631
+ api("/dsh-remote/self").then(function (b) {
632
+ if (b && b.ok) setVer(b);
633
+ }).catch(function () {});
634
+ }, []);
635
+
636
+ var doCheck = useCallback(function () {
637
+ setChkBusy(true);
638
+ api("/dsh-remote/self/update-check").then(function (b) {
639
+ if (b && b.ok) setChk(b);
640
+ else setSelfMsg({ kind: "err", text: "检查更新失败:" + ((b && (b.error || b.detail)) || "未知错误") });
641
+ }).catch(function (e) {
642
+ setSelfMsg({ kind: "err", text: "无法连接更新服务:" + e.message });
643
+ }).finally(function () { setChkBusy(false); });
644
+ }, []);
645
+
646
+ // 轮询更新日志:点击一键更新后,每 2s 拉一次日志;直到 running=false 视为完成
647
+ var logTimer = useCallback(function (force) {
648
+ if (!force && updating) return;
649
+ api("/dsh-remote/self/update-log").then(function (b) {
650
+ if (b && b.ok) {
651
+ setLog(b.log || "");
652
+ if (!b.running) {
653
+ setUpdating(false);
654
+ setUpdated(true);
655
+ loadVer();
656
+ return;
657
+ }
658
+ }
659
+ setUpdating(true);
660
+ }).catch(function () { setUpdating(false); });
661
+ }, [updating, loadVer]);
662
+
663
+ useEffect(function () { loadVer(); doCheck(); }, [loadVer, doCheck]);
664
+ useEffect(function () {
665
+ if (!updating) return;
666
+ var iv = setInterval(function () {
667
+ api("/dsh-remote/self/update-log").then(function (b) {
668
+ if (!b || !b.ok) return;
669
+ setLog(b.log || "");
670
+ if (!b.running) {
671
+ clearInterval(iv);
672
+ setUpdating(false);
673
+ setUpdated(true);
674
+ loadVer();
675
+ doCheck();
676
+ }
677
+ }).catch(function () {});
678
+ }, 2000);
679
+ return function () { clearInterval(iv); };
680
+ }, [updating, loadVer, doCheck]);
681
+
682
+ var doUpdate = function () {
683
+ setUpBusy(true);
684
+ setUpdated(false);
685
+ setSelfMsg(null);
686
+ post("/dsh-remote/self/update", {}).then(function (b) {
687
+ if (b && b.ok) {
688
+ setSelfMsg({ kind: "ok", text: "更新已在后台开始,正在下载安装…(本页会实时显示进度日志)" });
689
+ setUpdating(true);
690
+ logTimer(true);
691
+ } else {
692
+ var detail = String((b && (b.detail || b.error)) || "更新启动失败");
693
+ // 另一种常见情况:node 半返回 ok:false + “已有更新在进行中” → 转为跟踪进度而非报错
694
+ api("/dsh-remote/self/update-log").then(function (lb) {
695
+ if (lb && lb.ok && lb.running) {
696
+ setSelfMsg({ kind: "ok", text: "检测到已有一次更新正在进行,正在跟踪进度…" });
697
+ setUpdating(true);
698
+ logTimer(true);
699
+ } else {
700
+ setSelfMsg({ kind: "err", text: detail });
701
+ }
702
+ }).catch(function () { setSelfMsg({ kind: "err", text: detail }); });
703
+ }
704
+ }).catch(function (e) {
705
+ setSelfMsg({ kind: "err", text: "更新失败:" + e.message });
706
+ }).finally(function () { setUpBusy(false); });
707
+ };
708
+
709
+ var doUninstall = function () {
710
+ if (!armed) { setArmed(true); return; }
711
+ setUnBusy(true);
712
+ setSelfMsg(null);
713
+ post("/dsh-remote/self/uninstall", {}).then(function (b) {
714
+ if (b && b.ok) {
715
+ setArmed(false);
716
+ setSelfMsg({ kind: "ok", text: "已移除插件引用与本地文件。请重启 dsh web:插件将完全卸载(本栏目也会消失)。之后如需重新安装,直接在插件市场再次安装即可。" });
717
+ } else {
718
+ setArmed(false);
719
+ setSelfMsg({ kind: "err", text: "卸载失败:" + ((b && (b.error || b.detail)) || "未知错误") });
720
+ }
721
+ }).catch(function (e) {
722
+ setArmed(false);
723
+ setSelfMsg({ kind: "err", text: "卸载失败:" + e.message });
724
+ }).finally(function () { setUnBusy(false); });
725
+ };
726
+
727
+ var outdated = !!(chk && chk.outdated && chk.latest && chk.latest !== chk.current);
728
+ var currentV = (ver && ver.version) || (chk && chk.current) || "…";
729
+ var runtimeReady = ver ? !!ver.runtimeReady : null;
730
+
731
+ return h("div", { className: "dru-card", style: { marginTop: 2 } },
732
+ h("h3", null, "🔄 版本与更新"),
733
+ h("div", { className: "dru-status-line" },
734
+ h("span", null, "插件版本 v" + currentV),
735
+ chk === null && chkBusy ? h("span", { className: "dru-meta", style: { margin: 0 } }, "(检查新版本中…)") : null,
736
+ chk && outdated
737
+ ? h("span", { className: "dru-ver-badge dru-ver-badge-new" }, "发现新版本 v" + chk.latest)
738
+ : chk && !outdated ? h("span", { className: "dru-ver-badge dru-ver-badge-ok" }, "已是最新版本") : null
739
+ ),
740
+ h("div", { className: "dru-meta" },
741
+ runtimeReady === false ? "⚠ 桌面运行环境缺失(点击下方「一键更新」会自动补全并启动)" : runtimeReady === true ? "桌面运行环境正常" : "读取运行环境中…"
742
+ ),
743
+ h("div", { className: "dru-actions", style: { marginTop: 10 } },
744
+ outdated
745
+ ? h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: upBusy || unBusy || chkBusy || updating, onClick: doUpdate },
746
+ upBusy ? "更新启动中…" : updating ? "正在更新…" : "一键更新到 v" + chk.latest)
747
+ : h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: upBusy || unBusy || chkBusy || updating, onClick: doUpdate },
748
+ updating ? "正在更新…" : (ver && !ver.runtimeReady) ? "安装并启动(一键修复)" : "重新检查 / 修复"),
749
+ h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: chkBusy || updating || upBusy, onClick: doCheck }, chkBusy ? "检查中…" : "检查更新"),
750
+ h("button", {
751
+ type: "button",
752
+ className: "dru-btn dru-btn-danger",
753
+ style: { marginLeft: "auto" },
754
+ disabled: unBusy || updating || upBusy,
755
+ onClick: doUninstall
756
+ }, unBusy ? "卸载中…" : armed ? "⚠ 再点一次确认彻底卸载" : "彻底卸载")
757
+ ),
758
+ updated
759
+ ? h("div", { className: "dru-msg dru-msg-ok" },
760
+ "✅ 更新已完成,最新代码已就位。请", h("strong", null, "重启 dsh web"), "后生效;桌面 bridge 会随系统自启自动运行新版本。")
761
+ : null,
762
+ log ? h("div", { className: "dru-up-log", title: "更新日志(尾部)" }, log) : null,
763
+ selfMsg ? h("div", { className: "dru-msg dru-msg-" + selfMsg.kind }, selfMsg.text) : null,
764
+ h("div", { className: "dru-hint", style: { marginTop: 8 } },
765
+ armed ? "卸载会移除插件引用与本地文件;远程控制用的桌面 bridge 与数据目录保留,可随时重新安装。" :
766
+ "插件市场没有更新/卸载按钮(dsh 官方市场暂不提供),本卡片即官方管理入口:检测新版、一键在线更新、彻底卸载都在这里完成。")
767
+ );
768
+ }
769
+
610
770
  // ── 面板主体(渲染于设置页 settings.section 栏目内) ─────────────────────
611
771
  function RemoteControlSection(props) {
612
772
 
@@ -1023,6 +1183,8 @@ window.__ModuleLoader__.load({
1023
1183
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "③ 也可以自建:项目完全开源,有服务器可自行部署,流量走自己的服务器,闭环自控。"),
1024
1184
  h("div", { className: "dru-hint" }, "④ 一句话总结:简单省心用 SaaS,技术玩家可自建。")
1025
1185
  ]),
1186
+ // 版本与更新(自管理:检测新版 / 一键在线更新 / 彻底卸载)
1187
+ h(SelfManageCard, null),
1026
1188
  message && h("div", { className: "dru-msg dru-msg-" + message.kind }, message.text)
1027
1189
  );
1028
1190
  }
@@ -4,12 +4,17 @@
4
4
  // - 读写 dsh-remote-open/.dsh-config.json(0600)
5
5
  // - 查询/启停 bridge(launchctl,plist 缺失时自动生成,逻辑与 dsh-setup.mjs 一致)
6
6
  // - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理
7
+ // - 自管理 self*(版本可见 / 新版检测 / 一键在线更新 / 彻底卸载):插件市场没有更新卸载按钮,
8
+ // 面板内即官方管理入口;更新=后台 npx @mrrisega/dsh-remote@latest(幂等补齐运行环境并重启 bridge)
9
+ // - 运行时自愈:缺运行环境自动后台安装、登录后自动拉起 bridge(0.4.2 起)
10
+ // - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
7
11
  //
8
12
  // 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
9
- import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, rmSync, constants as fsConstants } from "node:fs";
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, constants as fsConstants } from "node:fs";
10
14
  import { join, dirname } from "node:path";
11
15
  import { execSync, spawn } from "node:child_process";
12
16
  import { homedir, hostname, platform } from "node:os";
17
+ import { fileURLToPath } from "node:url";
13
18
 
14
19
  /** 本插件在 host 侧的服务依赖。 */
15
20
  export const inject = ["webServer"];
@@ -59,6 +64,89 @@ function preferredNode() {
59
64
 
60
65
  const NODE_BIN = preferredNode();
61
66
 
67
+ /**
68
+ * 解析 npx 绝对路径。DeepSeek App 拉起 dsh web 时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin
69
+ * (没有 /opt/homebrew/bin 等),裸 `npx` 会 spawn ENOENT 而静默失败——必须按绝对路径找,
70
+ * 且子进程 env 的 PATH 要把当前 node 所在目录补在最前(npx 的 #!/usr/bin/env node 依赖它)。
71
+ */
72
+ function npxCommand() {
73
+ const name = process.platform === "win32" ? "npx.cmd" : "npx";
74
+ const dirs = [
75
+ dirname(process.execPath), // 与当前 node 同目录(homebrew/usr/local 均可覆盖)
76
+ process.env.DSH_SETUP_NPX_DIR || "",
77
+ "/opt/homebrew/bin",
78
+ "/usr/local/bin",
79
+ "/opt/homebrew/opt/node@20/bin",
80
+ "/usr/local/opt/node@20/bin",
81
+ "/usr/bin",
82
+ ].filter(Boolean);
83
+ for (const d of dirs) {
84
+ const real = resolveExecutable(join(d, name));
85
+ if (real) return real;
86
+ }
87
+ return name; // 全找不到 → 退回裸名(普通 shell 场景仍可用)
88
+ }
89
+
90
+ /** 子进程环境:把 node 目录补进 PATH(npx 及其 shebang 需要),可附加额外变量。 */
91
+ function spawnEnv(extra) {
92
+ const nodeDir = dirname(process.execPath);
93
+ const base = process.env.PATH || "";
94
+ const PATH = [nodeDir, base, "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"].filter(Boolean).join(":");
95
+ return { ...process.env, PATH, ...(extra || {}) };
96
+ }
97
+
98
+ // ---------- 后台子进程标记(防重入 + 宿主重启自愈) ----------
99
+ // marker 内容 = JSON {pid, at}:pid 供“宿主重启后立即清理死进程残留”判断;
100
+ // 兼容旧格式(纯时间戳数字 → 只按超时清理)。
101
+
102
+ function readMarkerInfo(filePath) {
103
+ try {
104
+ const raw = readFileSync(filePath, "utf8").trim();
105
+ const j = JSON.parse(raw);
106
+ if (Number.isInteger(j?.pid) || Number.isInteger(j?.at)) return j;
107
+ } catch { /* 非 JSON → 数字时间戳或空 */ }
108
+ const t = Number(raw || "0");
109
+ return Number.isFinite(t) && t > 0 ? { pid: null, at: t } : null;
110
+ }
111
+
112
+ function writeMarker(filePath, pid) {
113
+ mkdirSync(dirname(filePath), { recursive: true });
114
+ writeFileSync(filePath, JSON.stringify({ pid: pid ?? null, at: Date.now() }), { mode: 0o600 });
115
+ }
116
+
117
+ /** pid 是否存活(ESRCH=已死)。 */
118
+ function pidAlive(pid) {
119
+ if (!Number.isInteger(pid) || pid <= 0) return null; // 未知 → 由超时规则兜底
120
+ try {
121
+ process.kill(pid, 0);
122
+ return true;
123
+ } catch (e) {
124
+ return e.code === "EPERM" ? true : false; // EPERM=存在但无权限
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 清理残留标记:宿主 dsh web 在后台安装/更新期间被重启/强杀时,子进程清理回调随之丢失,
130
+ * 若只按“30 分钟超时”清理,用户会在这半小时内反复遇到“已有更新进行中/正在安装”。
131
+ * 现在:记录 pid → 重启后立刻清掉已死进程的标记;pid 不可读的旧标记仍按超时兜底。
132
+ */
133
+ function sweepStaleMarkers(relayDir) {
134
+ const now = Date.now();
135
+ for (const name of [PROVISION_MARKER, UPDATE_MARKER]) {
136
+ const p = join(relayDir, name);
137
+ let info;
138
+ try { info = readMarkerInfo(p); } catch { continue; }
139
+ if (!info) continue;
140
+ const dead = pidAlive(info.pid);
141
+ const expired = now - info.at > STALE_MARKER_MS;
142
+ if (dead === false || (dead === null && expired)) {
143
+ try { rmSync(p, { force: true }); } catch { /* ignore */ }
144
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
145
+ `[dsh-remote-ui] 清理残留标记 ${name}(pid=${info.pid ?? "?"}, at=${new Date(info.at).toISOString()}${dead === false ? ", 进程已死" : ", 已超时"})`);
146
+ }
147
+ }
148
+ }
149
+
62
150
  /** 读取 JSON body。 */
63
151
  async function readJsonBody(req) {
64
152
  let raw = "";
@@ -254,6 +342,23 @@ function manualStatus() {
254
342
 
255
343
  const PROVISION_MARKER = ".dsh-setup-installing";
256
344
  const AUTO_INSTALL_LOG = ".dsh-setup-install.log";
345
+ const STALE_MARKER_MS = 30 * 60 * 1000; // 超过该时长视为上次进程残留,插件启动时清理
346
+
347
+ /** 向日志追加一行(多个子进程写同一日志用 append 模式,互不覆盖)。 */
348
+ function appendLogLine(relayDir, name, line) {
349
+ try {
350
+ const fd = openSync(join(relayDir, name), "a");
351
+ try {
352
+ writeFileSync(fd, `\n${line}\n`);
353
+ } finally {
354
+ closeSync(fd);
355
+ }
356
+ } catch { /* 非关键 */ }
357
+ }
358
+
359
+ /**
360
+ * 清理“进程残留”标记的实现见上方小工具区 sweepStaleMarkers(pid 存活 + 超时双保险)。
361
+ */
257
362
 
258
363
  /** 插件市场只装了 UI 插件;若桌面缺 dsh-remote 运行环境(dsh-setup.mjs=bridge/自启动),
259
364
  * 由插件在后台自动执行一次 `npx @mrrisega/dsh-remote` 补齐,用户无需手动跑命令。
@@ -264,13 +369,23 @@ function ensureRuntime(relayDir) {
264
369
  if (existsSync(marker)) return false; // 正在安装中
265
370
  try {
266
371
  mkdirSync(relayDir, { recursive: true });
267
- writeFileSync(marker, String(Date.now()), { mode: 0o600 });
268
372
  const log = join(relayDir, AUTO_INSTALL_LOG);
269
- const npx = process.platform === "win32" ? "npx.cmd" : "npx";
270
- const child = spawn(npx, ["--yes", "@mrrisega/dsh-remote"], {
373
+ const child = spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote"], {
271
374
  detached: true,
375
+ env: spawnEnv(),
272
376
  stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
273
377
  });
378
+ writeMarker(marker, child.pid); // 记 pid:宿主重启后可立即清理死进程残留
379
+ const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
380
+ child.on("exit", (code) => {
381
+ clear();
382
+ appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] npx 退出 code=${code ?? "?"}`);
383
+ });
384
+ child.on("error", (e) => {
385
+ clear();
386
+ appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动失败: ${e.message}`);
387
+ console.warn(`[dsh-remote-ui] 自动安装子进程启动失败: ${e.message}`);
388
+ });
274
389
  child.unref();
275
390
  console.log(`[dsh-remote-ui] 检测到缺少桌面运行环境,已在后台自动安装(日志: ${log}),完成后将自动启动 bridge`);
276
391
  return false;
@@ -331,14 +446,15 @@ function scheduleRuntime(relayDir) {
331
446
  const cfg = loadConfig(relayDir);
332
447
  const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
333
448
  if (!hasAcct) return;
449
+ // 先看服务是否已在运行(runtime 可能位于 npx 缓存/固化目录,不必重复安装)
450
+ const st = launchdStatus();
451
+ if (st.running) { done = true; clearInterval(iv); return; }
334
452
  const setupUrl = join(relayDir, "dsh-setup.mjs");
335
453
  if (!existsSync(setupUrl)) {
336
- ensureRuntime(relayDir);
454
+ ensureRuntime(relayDir); // 什么环境都没有 → 后台 npx 安装一次
337
455
  return;
338
456
  }
339
- const st = launchdStatus();
340
- if (st.running) { done = true; clearInterval(iv); return; }
341
- startBridge(relayDir);
457
+ startBridge(relayDir); // 环境在但服务没起 → 拉起
342
458
  } catch { /* 下一轮再试 */ }
343
459
  }, 12_000);
344
460
  iv.unref?.();
@@ -592,11 +708,172 @@ async function proxyFeedback(relayDir, req, res, pathname) {
592
708
  }
593
709
  }
594
710
 
711
+ // ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
712
+
713
+ /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
714
+ const PLUGIN_VERSION = "0.4.4";
715
+ const UPDATE_LOG = ".dsh-update.log";
716
+ const UPDATE_MARKER = ".dsh-update-running";
717
+
718
+ /** 查询 npm 最新版(官方源优先,失败回退 npmmirror;纯服务端无 CORS 限制)。 */
719
+ async function npmLatestVersion() {
720
+ for (const reg of ["https://registry.npmjs.org/@mrrisega/dsh-remote", "https://registry.npmmirror.com/@mrrisega/dsh-remote"]) {
721
+ try {
722
+ const res = await fetch(reg, { signal: AbortSignal.timeout(8000) });
723
+ if (!res.ok) continue;
724
+ const j = await res.json();
725
+ if (j && j["dist-tags"] && typeof j["dist-tags"].latest === "string") return j["dist-tags"].latest;
726
+ } catch { /* 试下一个源 */ }
727
+ }
728
+ return "";
729
+ }
730
+
731
+ /** 以 detached 子进程执行 `npx --yes @mrrisega/dsh-remote@latest`(env 可覆盖 npm 源)。 */
732
+ function spawnUpdater(relayDir, extraEnv) {
733
+ const log = join(relayDir, UPDATE_LOG);
734
+ return spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote@latest"], {
735
+ detached: true,
736
+ cwd: homedir(),
737
+ env: spawnEnv(extraEnv), // PATH 补 node 目录:App 最小 PATH 下也能跑 npx
738
+ stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
739
+ });
740
+ }
741
+
742
+ /**
743
+ * 后台执行在线一键更新:npx @mrrisega/dsh-remote@latest(幂等自愈:补运行环境/更新 bridge/重写 include)。
744
+ * 稳健性:
745
+ * - npx 用绝对路径 + PATH 补全解析(App 拉起的 dsh web PATH 最小化时不再 ENOENT 静默失败);
746
+ * - 默认 npx 源(国内常为 npmmirror)未同步到最新版导致失败时,自动用官方 npm 源重试一次;
747
+ * - marker 记录 pid,子进程退出/出错即清理;宿主重启后由 sweepStaleMarkers 立即清掉死进程残留。
748
+ */
749
+ function runOnlineUpdate(relayDir) {
750
+ try {
751
+ mkdirSync(relayDir, { recursive: true });
752
+ const marker = join(relayDir, UPDATE_MARKER);
753
+ if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
754
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 @mrrisega/dsh-remote@latest (${new Date().toISOString()})`);
755
+
756
+ let retried = false;
757
+ const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
758
+ const run = () => {
759
+ const child = spawnUpdater(relayDir, retried ? { npm_config_registry: "https://registry.npmjs.org" } : {});
760
+ writeMarker(marker, child.pid);
761
+ child.on("exit", (code) => {
762
+ if (!retried && code !== 0) {
763
+ retried = true;
764
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 默认源安装失败(exit=${code}),改用官方 npm 源重试…`);
765
+ run();
766
+ return;
767
+ }
768
+ appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}(默认源${retried ? "/官方源" : ""})`);
769
+ clear();
770
+ });
771
+ child.on("error", (e) => {
772
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
773
+ clear();
774
+ });
775
+ child.unref();
776
+ return child;
777
+ };
778
+ const child = run();
779
+ return { ok: true, pid: child.pid, log: join(relayDir, UPDATE_LOG) };
780
+ } catch (e) {
781
+ try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
782
+ return { ok: false, detail: String(e.message || e) };
783
+ }
784
+ }
785
+
786
+ /** 读日志尾部(更新进度展示)。 */
787
+ function tailOf(filePath, lines = 24) {
788
+ try {
789
+ const all = readFileSync(filePath, "utf8").split("\n");
790
+ return all.slice(-lines).join("\n");
791
+ } catch { return ""; }
792
+ }
793
+
794
+ /** 彻底卸载(兼容市场“拒绝改写用户补丁”的场景):移除 include、依赖、bundle、本地目录与链接。 */
795
+ function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
796
+ const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
797
+ try {
798
+ const patch = readFileSync(patchFile, "utf8");
799
+ const cleaned = patch
800
+ .replace(/\n?# >>> dsh-remote-ui .*?# <<< dsh-remote-ui\s*/s, "\n")
801
+ .replace(/\n{3,}/g, "\n\n")
802
+ .trimEnd() + "\n";
803
+ if (cleaned !== patch) { writeFileSync(patchFile, cleaned); out.removedPatch = true; }
804
+ } catch { /* 无 patch 忽略 */ }
805
+ try {
806
+ const pkg = JSON.parse(readFileSync(pkgFile, "utf8"));
807
+ if (pkg.dependencies && pkg.dependencies["dsh-remote-ui"]) { delete pkg.dependencies["dsh-remote-ui"]; out.removedDep = true; }
808
+ const bundles = pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles) ? pkg.dsh.profile.bundles : null;
809
+ if (bundles) {
810
+ const i = bundles.indexOf("dsh-remote-ui");
811
+ if (i >= 0) { bundles.splice(i, 1); out.removedBundle = true; }
812
+ }
813
+ if (out.removedDep || out.removedBundle) writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
814
+ } catch { /* 无 package.json 忽略 */ }
815
+ try {
816
+ rmSync(join(profileDir, "dsh-remote-ui-plugin"), { recursive: true, force: true });
817
+ rmSync(join(profileDir, "node_modules", "dsh-remote-ui"), { recursive: true, force: true });
818
+ out.removedDir = true;
819
+ } catch { /* ignore */ }
820
+ return out;
821
+ }
822
+
595
823
  // ---------- 路由 ----------
596
824
 
597
825
  /** 路由表:{method, path, handler}。 */
598
826
  function registerRoutes(ctx, relayDir) {
827
+ // 本插件所在 profile(由插件自身文件位置推导,覆盖市场 git 安装与本地 include 两种形态)
828
+ let profileDir = join(homedir(), ".dsh", "profiles", "web");
829
+ try {
830
+ const here = fileURLToPath(import.meta.url);
831
+ // 依次尝试两种安装布局;split()[0] 未命中时返回原串,需显式判断后再试下一种
832
+ let m = here.split("/dsh-remote-ui-plugin/")[0];
833
+ if (m === here) m = here.split("/node_modules/dsh-remote-ui/")[0];
834
+ if (m !== here) profileDir = m;
835
+ } catch { /* 保持默认 */ }
599
836
  const routes = [
837
+ // 自管理:版本信息 / 检查更新 / 一键更新 / 更新日志 / 彻底卸载
838
+ {
839
+ method: "GET",
840
+ path: "/dsh-remote/self",
841
+ handler: async (_req, res) => {
842
+ const runtimeReady = existsSync(join(relayDir, "dsh-setup.mjs"));
843
+ sendJson(res, 200, { ok: true, version: PLUGIN_VERSION, runtimeReady, relayDir });
844
+ },
845
+ },
846
+ {
847
+ method: "GET",
848
+ path: "/dsh-remote/self/update-check",
849
+ handler: async (_req, res) => {
850
+ const latest = await npmLatestVersion();
851
+ const current = PLUGIN_VERSION;
852
+ sendJson(res, 200, { ok: true, current, latest, outdated: !!latest && latest !== current });
853
+ },
854
+ },
855
+ {
856
+ method: "POST",
857
+ path: "/dsh-remote/self/update",
858
+ handler: async (_req, res) => {
859
+ sendJson(res, 200, { ok: true, ...runOnlineUpdate(relayDir) });
860
+ },
861
+ },
862
+ {
863
+ method: "GET",
864
+ path: "/dsh-remote/self/update-log",
865
+ handler: async (_req, res) => {
866
+ sendJson(res, 200, { ok: true, running: existsSync(join(relayDir, UPDATE_MARKER)), log: tailOf(join(relayDir, UPDATE_LOG)) });
867
+ },
868
+ },
869
+ {
870
+ method: "POST",
871
+ path: "/dsh-remote/self/uninstall",
872
+ handler: async (_req, res) => {
873
+ const r = uninstallSelf(relayDir, profileDir, join(profileDir, "cordis.patch.yml"), join(profileDir, "package.json"));
874
+ sendJson(res, 200, { ok: true, ...r, detail: "已移除插件引用与本地文件,重启 dsh web 后完全卸载生效" });
875
+ },
876
+ },
600
877
  {
601
878
  method: "GET",
602
879
  path: "/dsh-remote/status",
@@ -865,6 +1142,8 @@ function registerRoutes(ctx, relayDir) {
865
1142
  */
866
1143
  export function apply(ctx, config = {}) {
867
1144
  const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
1145
+ // 清理上次进程残留的安装/更新 marker(宿主被重启/强杀时子进程清理回调会丢失)
1146
+ sweepStaleMarkers(relayDir);
868
1147
  ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-ui: /dsh-remote routes");
869
1148
  // 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
870
1149
  ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-ui: harness browser-session mint");
@@ -0,0 +1,213 @@
1
+ // 插件 node 半「自管理」回归:/dsh-remote/self | update-check | update | update-log | uninstall。
2
+ // 覆盖用户三项诉求对应的机制:
3
+ // 1) 插件市场没有「更新按钮」→ 面板内 self* 路由提供版本检测与一键在线更新;
4
+ // 2) 市场无法卸载(我们的 include 引用挡住)→ uninstall 路由移除 include/依赖/bundle/本地文件;
5
+ // 3) 版本可见 + 新版本检测 + 稳健更新(含残留 marker 清理兜底逻辑在 apply 时执行)。
6
+ import assert from "node:assert/strict";
7
+ import http from "node:http";
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import test from "node:test";
13
+ import { apply } from "../lib/index.js";
14
+
15
+ const PLUGIN_BLOCK = `# >>> dsh-remote-ui (managed by dsh-remote plugin; do not edit)
16
+ - type: plugin
17
+ name: dsh-remote-ui
18
+ apply: dsh-remote-ui
19
+ # <<< dsh-remote-ui
20
+ `;
21
+
22
+ /** 加载插件(tempHome 可覆盖 os.homedir(),供 uninstall 的默认 profile 推导用)。 */
23
+ function boot(tempHome, relayDir) {
24
+ const routes = new Map();
25
+ const previousHome = process.env.HOME;
26
+ if (tempHome) process.env.HOME = tempHome;
27
+ try {
28
+ apply({
29
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
30
+ effect(register) { return register(); },
31
+ logger: { info() {}, warn() {} }
32
+ }, { relayDir });
33
+ } finally {
34
+ if (tempHome) process.env.HOME = previousHome;
35
+ }
36
+ return routes;
37
+ }
38
+
39
+ /** 起一个指向 routes 的 http server,返回 base url。 */
40
+ async function serve(routes) {
41
+ const host = http.createServer((req, res) => {
42
+ const url = new URL(req.url, "http://x");
43
+ const handler = routes.get(url.pathname);
44
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
45
+ });
46
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
47
+ return { host, base: `http://127.0.0.1:${host.address().port}` };
48
+ }
49
+
50
+ test("self 路由:版本可见 + 运行环境状态(无 npx 环境时不谎报已就绪)", async () => {
51
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-self-"));
52
+ try {
53
+ const routes = boot(null, tempDir);
54
+ const { host, base } = await serve(routes);
55
+ try {
56
+ const self = await (await fetch(`${base}/dsh-remote/self`)).json();
57
+ assert.equal(self.ok, true);
58
+ assert.match(self.version, /^\d+\.\d+\.\d+$/, `version 应为 semver,实际 ${self.version}`);
59
+ assert.equal(self.runtimeReady, false, "temp 目录没有 dsh-setup.mjs → runtimeReady=false");
60
+ assert.equal(self.relayDir, tempDir);
61
+ } finally {
62
+ host.close();
63
+ }
64
+ } finally {
65
+ await rm(tempDir, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test("update-check 路由:从 npm 检测新版本(dist-tags.latest 9.9.9 > 当前)", async () => {
70
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-upchk-"));
71
+ const origFetch = globalThis.fetch;
72
+ try {
73
+ const routes = boot(null, tempDir);
74
+ const { host, base } = await serve(routes);
75
+ // 假 npm 源:registry 请求回 9.9.9,其余请求(本测试自身的 HTTP 调用)走真实 fetch
76
+ globalThis.fetch = async (url, init) => {
77
+ if (String(url).startsWith("https://registry.")) {
78
+ return { ok: true, status: 200, json: async () => ({ "dist-tags": { latest: "9.9.9" } }) };
79
+ }
80
+ return origFetch(url, init);
81
+ };
82
+ try {
83
+ const r = await (await fetch(`${base}/dsh-remote/self/update-check`)).json();
84
+ assert.equal(r.ok, true);
85
+ assert.equal(r.latest, "9.9.9");
86
+ assert.equal(r.outdated, true, "9.9.9 > 当前版本 → outdated 应为 true");
87
+ assert.notEqual(r.current, r.latest);
88
+ } finally {
89
+ host.close();
90
+ }
91
+ } finally {
92
+ globalThis.fetch = origFetch;
93
+ await rm(tempDir, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test("update-log 路由:running 由 marker 决定,日志返回尾部", async () => {
98
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-uplog-"));
99
+ try {
100
+ const routes = boot(null, tempDir);
101
+ const { host, base } = await serve(routes);
102
+ try {
103
+ // 无 marker:不 running
104
+ let r = await (await fetch(`${base}/dsh-remote/self/update-log`)).json();
105
+ assert.equal(r.ok, true);
106
+ assert.equal(r.running, false);
107
+
108
+ // 写入 marker + 日志 → running=true 且返回日志尾部
109
+ const lines = Array.from({ length: 30 }, (_, i) => `line ${i}`);
110
+ await writeFile(path.join(tempDir, ".dsh-update-running"), String(Date.now()));
111
+ await writeFile(path.join(tempDir, ".dsh-update.log"), lines.join("\n"));
112
+ r = await (await fetch(`${base}/dsh-remote/self/update-log`)).json();
113
+ assert.equal(r.running, true);
114
+ assert.ok(r.log.includes("line 29"), "日志应含尾部内容");
115
+ assert.ok(!r.log.includes("line 0"), "日志过长时应只截尾部(tailOf 生效)");
116
+ } finally {
117
+ host.close();
118
+ }
119
+ } finally {
120
+ await rm(tempDir, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test("update 路由:已有更新进行中时拒绝重复触发(防重入)", async () => {
125
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-up-"));
126
+ try {
127
+ const routes = boot(null, tempDir);
128
+ const { host, base } = await serve(routes);
129
+ try {
130
+ await writeFile(path.join(tempDir, ".dsh-update-running"), String(Date.now()));
131
+ const r = await (await fetch(`${base}/dsh-remote/self/update`, { method: "POST" })).json();
132
+ assert.equal(r.ok, false);
133
+ assert.match(String(r.detail), /进行中/, `detail 应提示进行中,实际: ${r.detail}`);
134
+ } finally {
135
+ host.close();
136
+ }
137
+ } finally {
138
+ await rm(tempDir, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ test("残留 marker 自愈:记录进程已死的 marker 在插件启动时立即清理(不等 30 分钟超时)", () => {
143
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "dsh-ui-sweep-"));
144
+ try {
145
+ // 模拟“宿主在更新途中被重启”:更新子进程已死,但清理回调随旧宿主丢失
146
+ const deadPid = 2147483647; // 不可能存在的 pid
147
+ writeFileSync(path.join(tempDir, ".dsh-setup-installing"), JSON.stringify({ pid: deadPid, at: Date.now() }));
148
+ writeFileSync(path.join(tempDir, ".dsh-update-running"), JSON.stringify({ pid: deadPid, at: Date.now() }));
149
+ writeFileSync(path.join(tempDir, ".dsh-config.json"), "{}");
150
+ boot(null, tempDir); // apply() → sweepStaleMarkers
151
+ assert.equal(existsSync(path.join(tempDir, ".dsh-setup-installing")), false, "死进程的安装 marker 应被立即清理");
152
+ assert.equal(existsSync(path.join(tempDir, ".dsh-update-running")), false, "死进程的更新 marker 应被立即清理");
153
+ } finally {
154
+ rmSync(tempDir, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本地目录(解锁市场卸载)", async () => {
159
+ const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-home-"));
160
+ const relayDir = path.join(tempHome, "relay");
161
+ await mkdir(relayDir, { recursive: true });
162
+ // 模拟真实 profile 布局:cordis.patch.yml + package.json + 本地插件目录/链接
163
+ const profile = path.join(tempHome, ".dsh", "profiles", "web");
164
+ await mkdir(path.join(profile, "dsh-remote-ui-plugin"), { recursive: true });
165
+ await mkdir(path.join(profile, "node_modules", "dsh-remote-ui"), { recursive: true });
166
+ const patchFile = path.join(profile, "cordis.patch.yml");
167
+ const pkgFile = path.join(profile, "package.json");
168
+ await writeFile(patchFile, `base: .\ninclude:\n${PLUGIN_BLOCK} - other-plugin\n`);
169
+ await writeFile(pkgFile, JSON.stringify({
170
+ dependencies: { "dsh-remote-ui": "github:mrRisega/dsh-remote#path:/packages/dsh-remote-ui", "other": "^1.0.0" },
171
+ dsh: { profile: { bundles: ["dsh-remote-ui", "other-bundle"] } },
172
+ }));
173
+ await writeFile(path.join(profile, "dsh-remote-ui-plugin", "index.js"), "// stub");
174
+ await writeFile(path.join(profile, "node_modules", "dsh-remote-ui", "index.js"), "// stub");
175
+ await writeFile(path.join(relayDir, ".dsh-config.json"), "{}");
176
+
177
+ try {
178
+ // 关键:以 tempHome 为 HOME 启动,profileDir 默认推导才会落在 temp profile 而非真实 ~/.dsh
179
+ const routes = boot(tempHome, relayDir);
180
+ const { host, base } = await serve(routes);
181
+ try {
182
+ const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
183
+ assert.equal(r.ok, true);
184
+ assert.equal(r.removedPatch, true, "应移除 patch 中的 include 块");
185
+ assert.equal(r.removedDep, true, "应移除 package.json 依赖");
186
+ assert.equal(r.removedBundle, true, "应移除 dsh.profile.bundles 条目");
187
+ assert.equal(r.removedDir, true, "应删除本地插件目录与 node_modules 链接");
188
+
189
+ const patch = await readFile(patchFile, "utf8");
190
+ assert.ok(!patch.includes("dsh-remote-ui"), "patch 不应再引用 dsh-remote-ui");
191
+ assert.ok(patch.includes("other-plugin"), "其他插件 include 不应被误伤");
192
+
193
+ const pkg = JSON.parse(await readFile(pkgFile, "utf8"));
194
+ assert.equal(pkg.dependencies["dsh-remote-ui"], undefined, "依赖应被删除");
195
+ assert.equal(pkg.dependencies["other"], "^1.0.0", "其他依赖应保留");
196
+ assert.deepEqual(pkg.dsh.profile.bundles, ["other-bundle"], "其他 bundle 应保留");
197
+ } finally {
198
+ host.close();
199
+ }
200
+ } finally {
201
+ await rm(tempHome, { recursive: true, force: true });
202
+ }
203
+ });
204
+
205
+ test("源码约束:浏览器半提供版本与更新卡片(含彻底卸载确认)", () => {
206
+ const source = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
207
+ assert.match(source, /🔄 版本与更新/);
208
+ assert.match(source, /dsh-remote\/self\/update-check/);
209
+ assert.match(source, /一键更新/);
210
+ assert.match(source, /彻底卸载/);
211
+ assert.match(source, /dsh-remote\/self\/uninstall/);
212
+ assert.match(source, /再点一次确认彻底卸载/);
213
+ });