@mrrisega/dsh-remote 0.4.3 → 0.4.5

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>
@@ -601,6 +658,91 @@ function declarePluginDep(pkgFile) {
601
658
  fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
602
659
  }
603
660
 
661
+ /** 把 dsh-remote-ui 加入 dsh.profile.bundles(幂等)。返回是否发生变更。 */
662
+ function ensureBundleEntry(pkgFile) {
663
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
664
+ const bundles = pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles)
665
+ ? pkg.dsh.profile.bundles
666
+ : null;
667
+ if (bundles && bundles.includes("dsh-remote-ui")) return false;
668
+ pkg.dsh = pkg.dsh || {};
669
+ pkg.dsh.profile = pkg.dsh.profile || {};
670
+ pkg.dsh.profile.bundles = bundles || [];
671
+ pkg.dsh.profile.bundles.push("dsh-remote-ui");
672
+ fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
673
+ return true;
674
+ }
675
+
676
+ /** 从 dsh.profile.bundles 移除 dsh-remote-ui(幂等)。返回是否发生变更。 */
677
+ function removeBundleEntry(pkgFile) {
678
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
679
+ const bundles = pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles)
680
+ ? pkg.dsh.profile.bundles
681
+ : null;
682
+ if (!bundles || !bundles.includes("dsh-remote-ui")) return false;
683
+ pkg.dsh.profile.bundles = bundles.filter((b) => b !== "dsh-remote-ui");
684
+ fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
685
+ return true;
686
+ }
687
+
688
+ /**
689
+ * 插件安装(pluginCmd 非卸载分支)收敛策略 —— 2026-09-06「重复 ID 崩溃」根治:
690
+ * dsh-remote-ui 是带 dsh.bundle.patch 的 bundle:package.json 的 dsh.profile.bundles
691
+ * 声明它后,加载器会自动应用插件自带的 cordis.patch.yml(节点半+浏览器半的唯一激活点)。
692
+ * 若用户级 cordis.patch.yml 再手工 insert 同一个 id,dsh web 启动即报“重复 ID”崩溃。
693
+ * 因此本命令【绝不写用户 include】,只负责:让插件以 bundle 形态可解析,
694
+ * 并清理历史遗留的 include 块。市场形态(github:/npm 依赖)则完全交由市场管理,只清理 include。
695
+ */
696
+ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch) {
697
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
698
+ const dep = pkg.dependencies && pkg.dependencies["dsh-remote-ui"];
699
+ const inBundles = !!(pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles)
700
+ && pkg.dsh.profile.bundles.includes("dsh-remote-ui"));
701
+ const marketManaged = dep && !String(dep).startsWith("file:"); // github:/npm: 等由市场/包管理器管源码
702
+ const managedByUs = !dep || String(dep).startsWith("file:"); // 无依赖或 file: 拷贝 → 我们管
703
+
704
+ const stripInclude = (reason) => {
705
+ const newPatch = stripPluginEntries(patch);
706
+ if (newPatch !== patch) {
707
+ fs.writeFileSync(patchFile, newPatch);
708
+ console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活统一走插件自带 bundle patch,避免重复 ID 崩溃)`);
709
+ }
710
+ };
711
+
712
+ if (marketManaged && inBundles) {
713
+ // 插件市场安装形态:依赖与源码归市场管,我们只清历史 include(若旧版曾写过)
714
+ stripInclude("插件市场安装形态无需用户 include");
715
+ console.log("ℹ 插件市场安装形态(bundles+dependency):已保持市场管理的源码不变。");
716
+ return;
717
+ }
718
+
719
+ if (managedByUs) {
720
+ const pluginLocalDir = copyPluginIntoProfile(profileDir, pluginDir);
721
+ const entryFile = path.join(pluginLocalDir, "lib", "index.js");
722
+ if (!fs.existsSync(entryFile)) {
723
+ console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
724
+ process.exit(1);
725
+ }
726
+ console.log(`✅ 插件已拷贝到 ${pluginLocalDir}`);
727
+ declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
728
+ const addedBundle = ensureBundleEntry(pkgFile); // bundles 声明 → 插件自带 patch 自动激活
729
+ const linked = ensurePluginLinked(profileDir, pluginLocalDir); // 自建 node_modules 链接
730
+ stripInclude("bundle patch 已是唯一激活点"); // 清历史 include
731
+ console.log(addedBundle
732
+ ? "✅ 已加入 dsh.profile.bundles(dsh-remote-ui 自带 patch 自动生效)"
733
+ : "ℹ dsh.profile.bundles 已含 dsh-remote-ui");
734
+ console.log(linked
735
+ ? `✅ 已建立 node_modules/dsh-remote-ui → ${pluginLocalDir}(免包管理器即可解析)`
736
+ : "✅ node_modules/dsh-remote-ui 已就绪");
737
+ console.log(`✅ 插件安装完成(bundle 形态,无用户 include)。配置目录: ${CONFIG_DIR}`);
738
+ return;
739
+ }
740
+
741
+ // 异常形态:有非 file: 依赖但不在 bundles(无法靠 bundle patch 激活)
742
+ console.log(`ℹ 检测到依赖 dsh-remote-ui(${dep}) 但未声明在 dsh.profile.bundles——插件不会激活。`);
743
+ console.log(" 请在 dsh 插件市场重新添加该插件,或先执行 `dsh-remote plugin --uninstall` 再一键安装。");
744
+ }
745
+
604
746
  async function pluginCmd(argv) {
605
747
  const uninstall = hasFlag(argv, "--uninstall");
606
748
  const profileIdx = argv.indexOf("--profile");
@@ -639,35 +781,17 @@ async function pluginCmd(argv) {
639
781
  if (pkg.dependencies) delete pkg.dependencies["dsh-remote-ui"];
640
782
  fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
641
783
  } catch { /* ignore */ }
784
+ // 同步移除 bundles 声明,避免“bundles 引用已删除包 → dsh web 启动报错”
785
+ try {
786
+ if (removeBundleEntry(pkgFile)) console.log("✅ 已从 dsh.profile.bundles 移除 dsh-remote-ui");
787
+ } catch { /* ignore */ }
642
788
  console.log("✅ 卸载完成。重启 dsh web 生效。");
643
789
  return;
644
790
  }
645
791
 
646
- // 安装 = 拷贝完整插件目录 + 写裸名 include + 自建 node_modules 链接 + 声明 file: 依赖。
647
- // 全程不跑 pnpm/npm;但链接与依赖双保险,任何包管理器之后 install 也不会破坏它。
648
- const pluginLocalDir = copyPluginIntoProfile(profileDir, pluginDir);
649
- const entryFile = path.join(pluginLocalDir, "lib", "index.js");
650
- if (!fs.existsSync(entryFile)) {
651
- console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
652
- process.exit(1);
653
- }
654
- console.log(`✅ 插件已拷贝到 ${pluginLocalDir}`);
655
-
656
- // 先清掉旧条目(含旧版无标记条目),再写入带标记的新块,保证不重复。
657
- // 关键:先清除默认的 [] 空文档占位行,否则拼接出的 YAML 非法,dsh web 启动即崩。
658
- const stripped = stripPluginEntries(patch);
659
- const base = normalizePatchBase(stripped);
660
- const block = pluginBlock(CONFIG_DIR);
661
- fs.writeFileSync(patchFile, (base ? base + "\n" : "") + block + "\n");
662
- console.log(`✅ 已写入 ${patchFile}(裸名 include:dsh-remote-ui → 节点半 + 浏览器半均生效)`);
663
-
664
- declarePluginDep(pkgFile); // package.json 声明 file: 依赖(后端各类 install 不误删)
665
- const linked = ensurePluginLinked(profileDir, pluginLocalDir); // 自建 node_modules 链接
666
- console.log(linked
667
- ? `✅ 已建立 node_modules/dsh-remote-ui → ${pluginLocalDir}(免包管理器即可解析)`
668
- : "✅ node_modules/dsh-remote-ui 已就绪");
669
-
670
- console.log(`✅ 插件安装完成。配置目录: ${CONFIG_DIR}`);
792
+ // 安装:收敛到“恰好一处激活”(bundle patch 唯一激活点),绝不与市场/历史 include 并存
793
+ convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch);
794
+
671
795
  console.log(" 打开 dsh web → 设置 → 「远程控制」,注册/登录手机号即可(无需任何命令)。");
672
796
  console.log(" 若从 DeepSeek App/插件市场 安装:请完全退出并重开 App 让插件生效。");
673
797
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",
@@ -64,6 +64,89 @@ function preferredNode() {
64
64
 
65
65
  const NODE_BIN = preferredNode();
66
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
+
67
150
  /** 读取 JSON body。 */
68
151
  async function readJsonBody(req) {
69
152
  let raw = "";
@@ -274,23 +357,8 @@ function appendLogLine(relayDir, name, line) {
274
357
  }
275
358
 
276
359
  /**
277
- * 清理“进程残留”标记:dsh web 在后台安装/更新期间被重启/强杀时,
278
- * 子进程的清理回调会随宿主进程一起丢失,marker 会永久卡住后续安装/更新。
279
- * 插件每次启动时把超时的 marker 清掉(内容=创建时间戳)。
360
+ * 清理“进程残留”标记的实现见上方小工具区 sweepStaleMarkers(pid 存活 + 超时双保险)。
280
361
  */
281
- function sweepStaleMarkers(relayDir) {
282
- const now = Date.now();
283
- for (const name of [PROVISION_MARKER, UPDATE_MARKER]) {
284
- const p = join(relayDir, name);
285
- try {
286
- const t = Number((readFileSync(p, "utf8") || "0").trim());
287
- if (Number.isFinite(t) && t > 0 && now - t > STALE_MARKER_MS) {
288
- rmSync(p, { force: true });
289
- appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-ui] 清理残留标记 ${name}(${new Date(t).toISOString()} 创建,已超时)`);
290
- }
291
- } catch { /* 无文件等 → 忽略 */ }
292
- }
293
- }
294
362
 
295
363
  /** 插件市场只装了 UI 插件;若桌面缺 dsh-remote 运行环境(dsh-setup.mjs=bridge/自启动),
296
364
  * 由插件在后台自动执行一次 `npx @mrrisega/dsh-remote` 补齐,用户无需手动跑命令。
@@ -301,18 +369,23 @@ function ensureRuntime(relayDir) {
301
369
  if (existsSync(marker)) return false; // 正在安装中
302
370
  try {
303
371
  mkdirSync(relayDir, { recursive: true });
304
- writeFileSync(marker, String(Date.now()), { mode: 0o600 });
305
372
  const log = join(relayDir, AUTO_INSTALL_LOG);
306
- const npx = process.platform === "win32" ? "npx.cmd" : "npx";
307
- const child = spawn(npx, ["--yes", "@mrrisega/dsh-remote"], {
373
+ const child = spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote"], {
308
374
  detached: true,
375
+ env: spawnEnv(),
309
376
  stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
310
377
  });
378
+ writeMarker(marker, child.pid); // 记 pid:宿主重启后可立即清理死进程残留
379
+ const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
311
380
  child.on("exit", (code) => {
312
- // 安装结束即移除 marker(无论成败,失败由下一轮 scheduleRuntime 重试)
313
- try { rmSync(marker, { force: true }); } catch { /* ignore */ }
381
+ clear();
314
382
  appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] npx 退出 code=${code ?? "?"}`);
315
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
+ });
316
389
  child.unref();
317
390
  console.log(`[dsh-remote-ui] 检测到缺少桌面运行环境,已在后台自动安装(日志: ${log}),完成后将自动启动 bridge`);
318
391
  return false;
@@ -373,14 +446,15 @@ function scheduleRuntime(relayDir) {
373
446
  const cfg = loadConfig(relayDir);
374
447
  const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
375
448
  if (!hasAcct) return;
449
+ // 先看服务是否已在运行(runtime 可能位于 npx 缓存/固化目录,不必重复安装)
450
+ const st = launchdStatus();
451
+ if (st.running) { done = true; clearInterval(iv); return; }
376
452
  const setupUrl = join(relayDir, "dsh-setup.mjs");
377
453
  if (!existsSync(setupUrl)) {
378
- ensureRuntime(relayDir);
454
+ ensureRuntime(relayDir); // 什么环境都没有 → 后台 npx 安装一次
379
455
  return;
380
456
  }
381
- const st = launchdStatus();
382
- if (st.running) { done = true; clearInterval(iv); return; }
383
- startBridge(relayDir);
457
+ startBridge(relayDir); // 环境在但服务没起 → 拉起
384
458
  } catch { /* 下一轮再试 */ }
385
459
  }, 12_000);
386
460
  iv.unref?.();
@@ -637,7 +711,7 @@ async function proxyFeedback(relayDir, req, res, pathname) {
637
711
  // ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
638
712
 
639
713
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
640
- const PLUGIN_VERSION = "0.4.3";
714
+ const PLUGIN_VERSION = "0.4.5";
641
715
  const UPDATE_LOG = ".dsh-update.log";
642
716
  const UPDATE_MARKER = ".dsh-update-running";
643
717
 
@@ -657,11 +731,10 @@ async function npmLatestVersion() {
657
731
  /** 以 detached 子进程执行 `npx --yes @mrrisega/dsh-remote@latest`(env 可覆盖 npm 源)。 */
658
732
  function spawnUpdater(relayDir, extraEnv) {
659
733
  const log = join(relayDir, UPDATE_LOG);
660
- const npx = process.platform === "win32" ? "npx.cmd" : "npx";
661
- return spawn(npx, ["--yes", "@mrrisega/dsh-remote@latest"], {
734
+ return spawn(npxCommand(), ["--yes", "@mrrisega/dsh-remote@latest"], {
662
735
  detached: true,
663
736
  cwd: homedir(),
664
- env: { ...process.env, ...(extraEnv || {}) },
737
+ env: spawnEnv(extraEnv), // PATH 补 node 目录:App 最小 PATH 下也能跑 npx
665
738
  stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
666
739
  });
667
740
  }
@@ -669,20 +742,22 @@ function spawnUpdater(relayDir, extraEnv) {
669
742
  /**
670
743
  * 后台执行在线一键更新:npx @mrrisega/dsh-remote@latest(幂等自愈:补运行环境/更新 bridge/重写 include)。
671
744
  * 稳健性:
745
+ * - npx 用绝对路径 + PATH 补全解析(App 拉起的 dsh web PATH 最小化时不再 ENOENT 静默失败);
672
746
  * - 默认 npx 源(国内常为 npmmirror)未同步到最新版导致失败时,自动用官方 npm 源重试一次;
673
- * - marker 由子进程退出回调清理;若宿主 dsh web 在更新期间被重启,残留 marker 由启动时的 sweepStaleMarkers 兜底。
747
+ * - marker 记录 pid,子进程退出/出错即清理;宿主重启后由 sweepStaleMarkers 立即清掉死进程残留。
674
748
  */
675
749
  function runOnlineUpdate(relayDir) {
676
750
  try {
677
751
  mkdirSync(relayDir, { recursive: true });
678
752
  const marker = join(relayDir, UPDATE_MARKER);
679
753
  if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
680
- writeFileSync(marker, String(Date.now()), { mode: 0o600 });
681
754
  appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 @mrrisega/dsh-remote@latest (${new Date().toISOString()})`);
682
755
 
683
756
  let retried = false;
757
+ const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
684
758
  const run = () => {
685
759
  const child = spawnUpdater(relayDir, retried ? { npm_config_registry: "https://registry.npmjs.org" } : {});
760
+ writeMarker(marker, child.pid);
686
761
  child.on("exit", (code) => {
687
762
  if (!retried && code !== 0) {
688
763
  retried = true;
@@ -691,7 +766,11 @@ function runOnlineUpdate(relayDir) {
691
766
  return;
692
767
  }
693
768
  appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}(默认源${retried ? "/官方源" : ""})`);
694
- try { rmSync(marker, { force: true }); } catch { /* ignore */ }
769
+ clear();
770
+ });
771
+ child.on("error", (e) => {
772
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
773
+ clear();
695
774
  });
696
775
  child.unref();
697
776
  return child;
@@ -5,8 +5,8 @@
5
5
  // 3) 版本可见 + 新版本检测 + 稳健更新(含残留 marker 清理兜底逻辑在 apply 时执行)。
6
6
  import assert from "node:assert/strict";
7
7
  import http from "node:http";
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
9
  import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
9
- import { readFileSync } from "node:fs";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
12
  import test from "node:test";
@@ -66,24 +66,24 @@ test("self 路由:版本可见 + 运行环境状态(无 npx 环境时不谎
66
66
  }
67
67
  });
68
68
 
69
- test("update-check 路由:从 npm 检测新版本(dist-tags.latest 0.4.4 > 当前)", async () => {
69
+ test("update-check 路由:从 npm 检测新版本(dist-tags.latest 9.9.9 > 当前)", async () => {
70
70
  const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-upchk-"));
71
71
  const origFetch = globalThis.fetch;
72
72
  try {
73
73
  const routes = boot(null, tempDir);
74
74
  const { host, base } = await serve(routes);
75
- // 假 npm 源:registry 请求回 0.4.4,其余请求(本测试自身的 HTTP 调用)走真实 fetch
75
+ // 假 npm 源:registry 请求回 9.9.9,其余请求(本测试自身的 HTTP 调用)走真实 fetch
76
76
  globalThis.fetch = async (url, init) => {
77
77
  if (String(url).startsWith("https://registry.")) {
78
- return { ok: true, status: 200, json: async () => ({ "dist-tags": { latest: "0.4.4" } }) };
78
+ return { ok: true, status: 200, json: async () => ({ "dist-tags": { latest: "9.9.9" } }) };
79
79
  }
80
80
  return origFetch(url, init);
81
81
  };
82
82
  try {
83
83
  const r = await (await fetch(`${base}/dsh-remote/self/update-check`)).json();
84
84
  assert.equal(r.ok, true);
85
- assert.equal(r.latest, "0.4.4");
86
- assert.equal(r.outdated, true, "0.4.4 > 当前版本 → outdated 应为 true");
85
+ assert.equal(r.latest, "9.9.9");
86
+ assert.equal(r.outdated, true, "9.9.9 > 当前版本 → outdated 应为 true");
87
87
  assert.notEqual(r.current, r.latest);
88
88
  } finally {
89
89
  host.close();
@@ -139,6 +139,22 @@ test("update 路由:已有更新进行中时拒绝重复触发(防重入)"
139
139
  }
140
140
  });
141
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
+
142
158
  test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本地目录(解锁市场卸载)", async () => {
143
159
  const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-home-"));
144
160
  const relayDir = path.join(tempHome, "relay");