@mrrisega/dsh-remote 0.7.6-beta.2 → 0.7.6-beta.3

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.
@@ -82,6 +82,7 @@ import WebSocket from "ws";
82
82
  import fs from "node:fs";
83
83
  import os from "node:os";
84
84
  import path from "node:path";
85
+ import net from "node:net";
85
86
  import { execSync, spawnSync } from "node:child_process";
86
87
  import { randomBytes, generateKeyPairSync, createHash } from "node:crypto";
87
88
  import { pathToFileURL, fileURLToPath } from "node:url";
@@ -2723,9 +2724,109 @@ async function main() {
2723
2724
  console.log("[bridge] 单实例锁检查降级(取不到进程命令行或锁不可写)——照常启动;若出现 code=4000 互踢,本实例会自动让位。");
2724
2725
  }
2725
2726
  process.on("exit", releaseInstanceLock);
2727
+ // 桌面端插件「暂存更新」自动安装(应用退出后生效;见 applyPendingPluginUpdate 注释)
2728
+ startPendingPluginUpdateWatcher();
2726
2729
  return runTunnel();
2727
2730
  }
2728
2731
 
2732
+ // ── 桌面端插件「暫存安装」落地(2026-10-24)────────────────────────────────────────────
2733
+ // dsh-setup 在桌面应用运行时无法直接改写它的 profile(会触发应用退出/重启),改为暂存到
2734
+ // <relayDir>/pending-plugin/ 并写 marker;bridge 常驻、检测到应用退出后自动原子替换插件目录,
2735
+ // 用户下次打开应用即加载新版 —— 这样「一键更新」对桌面端也能真正生效(不再需要终端)。
2736
+ const PENDING_PLUGIN_MARKER = path.join(RELAY_DIR, ".plugin-update-pending.json");
2737
+ const PENDING_PLUGIN_REQUIRED = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
2738
+ const PENDING_PLUGIN_DISABLED = process.env.DSH_BRIDGE_PENDING_UPDATE === "0";
2739
+
2740
+ /** upstream 端口是否在监听(= DeepSeek Harness 桌面端在运行)。TCP 判断,跨平台。 */
2741
+ export function probeUpstreamListening({ host = "127.0.0.1", port, timeoutMs = 400 } = {}) {
2742
+ return new Promise((resolve) => {
2743
+ if (!port) return resolve(false);
2744
+ const sock = net.connect({ host, port });
2745
+ let done = false;
2746
+ const finish = (v) => { if (done) return; done = true; try { sock.destroy(); } catch { /* 忽略 */ } resolve(v); };
2747
+ sock.setTimeout(timeoutMs, () => finish(false));
2748
+ sock.once("connect", () => finish(true));
2749
+ sock.once("error", () => finish(false));
2750
+ });
2751
+ }
2752
+ function upstreamPortOf(url) {
2753
+ try { const u = new URL(String(url)); return Number(u.port) || (u.protocol === "https:" ? 443 : 80); } catch { return 0; }
2754
+ }
2755
+
2756
+ /**
2757
+ * 应用暂存的插件更新(仅在桌面应用不在运行时)。原子替换:staging → rename,失败保留旧版。
2758
+ * @returns {{applied:boolean, reason?:string, version?:string, error?:string}}
2759
+ */
2760
+ export async function applyPendingPluginUpdate({ relayDir = RELAY_DIR, isAppRunning, now = Date.now(), log = console } = {}) {
2761
+ const markerPath = path.join(relayDir, ".plugin-update-pending.json");
2762
+ let marker;
2763
+ try { marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); } catch { return { applied: false, reason: "no_pending" }; }
2764
+ const stageDir = String(marker.stageDir || "");
2765
+ const profileDir = String(marker.profileDir || "");
2766
+ const name = String(marker.pluginDirName || "dsh-remote-web-plugin");
2767
+ if (!stageDir || !profileDir || !fs.existsSync(stageDir)) {
2768
+ try { fs.rmSync(markerPath, { force: true }); } catch { /* 忽略 */ }
2769
+ return { applied: false, reason: "staging_missing" };
2770
+ }
2771
+ const running = typeof isAppRunning === "function"
2772
+ ? await isAppRunning()
2773
+ : await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
2774
+ if (running) return { applied: false, reason: "app_running" };
2775
+ const dest = path.join(profileDir, name);
2776
+ const stage = `${dest}.staging-${process.pid}`;
2777
+ const backup = `${dest}.backup-${process.pid}`;
2778
+ try {
2779
+ fs.rmSync(stage, { recursive: true, force: true });
2780
+ fs.cpSync(stageDir, stage, { recursive: true });
2781
+ for (const rel of PENDING_PLUGIN_REQUIRED) {
2782
+ if (!fs.existsSync(path.join(stage, rel))) throw new Error(`暂存包缺少 ${rel}`);
2783
+ }
2784
+ fs.rmSync(backup, { recursive: true, force: true });
2785
+ const had = fs.existsSync(dest);
2786
+ if (had) fs.renameSync(dest, backup);
2787
+ try {
2788
+ fs.renameSync(stage, dest);
2789
+ } catch (e) {
2790
+ if (had) { try { fs.renameSync(backup, dest); } catch { /* 忽略 */ } }
2791
+ throw e;
2792
+ }
2793
+ if (had) { try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* 忽略 */ } }
2794
+ try {
2795
+ fs.writeFileSync(
2796
+ path.join(relayDir, ".plugin-update-applied.json"),
2797
+ JSON.stringify({ version: marker.version, appliedAt: now, profileDir }, null, 2) + "\n"
2798
+ );
2799
+ } catch { /* 忽略 */ }
2800
+ try { fs.rmSync(markerPath, { force: true }); fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
2801
+ log.info?.(`[bridge] ✅ 插件新版本 ${marker.version} 已自动安装到 ${profileDir}(桌面应用退出后);重新打开应用即生效`);
2802
+ return { applied: true, version: marker.version };
2803
+ } catch (e) {
2804
+ try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* 忽略 */ }
2805
+ log.warn?.(`[bridge] 插件暂存安装失败(保留旧版可回退):${e && e.message ? e.message : e}`);
2806
+ return { applied: false, reason: "apply_failed", error: String((e && e.message) || e) };
2807
+ }
2808
+ }
2809
+
2810
+ function startPendingPluginUpdateWatcher() {
2811
+ if (PENDING_PLUGIN_DISABLED) return null;
2812
+ let closedStreak = 0;
2813
+ const tick = async () => {
2814
+ try {
2815
+ if (!fs.existsSync(PENDING_PLUGIN_MARKER)) { closedStreak = 0; return; }
2816
+ const running = await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
2817
+ if (running) { closedStreak = 0; return; }
2818
+ closedStreak += 1;
2819
+ if (closedStreak < 2) return; // 连续两次(≈30s)判定不在运行才动手,避开退出/重启瞬间
2820
+ await applyPendingPluginUpdate({});
2821
+ closedStreak = 0;
2822
+ } catch { /* 绝不影响隧道业务 */ }
2823
+ };
2824
+ const t = setInterval(tick, 15_000);
2825
+ t.unref?.();
2826
+ void tick();
2827
+ return t;
2828
+ }
2829
+
2729
2830
  // test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
2730
2831
  export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
2731
2832
  export function __armMirrorLoadWatch(path) { armMirrorLoadWatch(path); }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 桌面端插件「暂存更新」自动安装(2026-10-24)契约测试。
3
+ *
4
+ * 背景:桌面端应用独占自己的 profile,运行中不能改写(会触发应用退出/重启)。
5
+ * 一键更新在应用运行中只能「暂存」,由常驻 bridge 在检测到应用退出后自动安装,
6
+ * 用户下次打开应用即生效 —— 否则就会出现「更新成功但版本没变、还得开终端」。
7
+ *
8
+ * 这里钉住四条最容易破的语义:
9
+ * ① 应用还在运行 → 绝不改写 profile(返回 app_running,marker 保留等待下一轮);
10
+ * ② 应用退出 → 原子替换插件目录、写 applied marker、清掉暂存与 marker;
11
+ * ③ 暂存包不完整(缺必需文件)→ 不动旧版,返回 apply_failed;
12
+ * ④ 没有 marker → no_pending(定时器空转,绝不能误删东西)。
13
+ */
14
+ import { test } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { applyPendingPluginUpdate } from "../dsh-bridge.mjs";
20
+
21
+ function mkTmpDir() {
22
+ return fs.mkdtempSync(path.join(os.tmpdir(), "dsh-pending-plugin-"));
23
+ }
24
+
25
+ function writeFile(p, content) {
26
+ fs.mkdirSync(path.dirname(p), { recursive: true });
27
+ fs.writeFileSync(p, content);
28
+ }
29
+
30
+ /** 造一个「旧版插件已安装 + 新版已暂存 + marker」的环境。 */
31
+ function setupFixture({ stageComplete = true } = {}) {
32
+ const relayDir = mkTmpDir();
33
+ const profileDir = mkTmpDir();
34
+ const pluginName = "dsh-remote-web-plugin";
35
+ const dest = path.join(profileDir, pluginName);
36
+ // 旧版
37
+ writeFile(path.join(dest, "package.json"), JSON.stringify({ name: "dsh-remote-web", version: "0.7.4" }));
38
+ writeFile(path.join(dest, "lib", "index.js"), "// old\n");
39
+ writeFile(path.join(dest, "lib", "client.js"), "// old\n");
40
+ writeFile(path.join(dest, "cordis.patch.yml"), "old: true\n");
41
+ // 新版暂存
42
+ const stageDir = path.join(relayDir, "pending-plugin");
43
+ writeFile(path.join(stageDir, "lib", "index.js"), "// new\n");
44
+ if (stageComplete) {
45
+ writeFile(path.join(stageDir, "package.json"), JSON.stringify({ name: "dsh-remote-web", version: "0.7.6-beta.2" }));
46
+ writeFile(path.join(stageDir, "lib", "client.js"), "// new\n");
47
+ writeFile(path.join(stageDir, "cordis.patch.yml"), "new: true\n");
48
+ }
49
+ writeFile(
50
+ path.join(relayDir, ".plugin-update-pending.json"),
51
+ JSON.stringify({ version: "0.7.6-beta.2", profileDir, pluginDirName: pluginName, stageDir, stagedAt: Date.now() })
52
+ );
53
+ return { relayDir, profileDir, dest, stageDir, pluginName };
54
+ }
55
+
56
+ test("① 应用仍在运行 → 不改写 profile,marker 保留", async () => {
57
+ const { relayDir, dest } = setupFixture();
58
+ const r = await applyPendingPluginUpdate({ relayDir, isAppRunning: async () => true });
59
+ assert.equal(r.applied, false);
60
+ assert.equal(r.reason, "app_running");
61
+ assert.match(fs.readFileSync(path.join(dest, "package.json"), "utf8"), /0\.7\.4/, "旧版必须原样保留");
62
+ assert.ok(fs.existsSync(path.join(relayDir, ".plugin-update-pending.json")), "marker 必须保留给下一轮");
63
+ });
64
+
65
+ test("② 应用已退出 → 原子替换为新版,写 applied marker 并清理暂存", async () => {
66
+ const { relayDir, dest, stageDir } = setupFixture();
67
+ const r = await applyPendingPluginUpdate({ relayDir, isAppRunning: async () => false });
68
+ assert.equal(r.applied, true);
69
+ assert.equal(r.version, "0.7.6-beta.2");
70
+ assert.match(fs.readFileSync(path.join(dest, "package.json"), "utf8"), /0\.7\.6-beta\.2/, "profile 内必须是新版");
71
+ assert.match(fs.readFileSync(path.join(dest, "lib", "index.js"), "utf8"), /new/);
72
+ const applied = JSON.parse(fs.readFileSync(path.join(relayDir, ".plugin-update-applied.json"), "utf8"));
73
+ assert.equal(applied.version, "0.7.6-beta.2");
74
+ assert.equal(fs.existsSync(path.join(relayDir, ".plugin-update-pending.json")), false, "marker 必须清掉");
75
+ assert.equal(fs.existsSync(stageDir), false, "暂存目录必须清掉");
76
+ // 不留 staging/backup 垃圾
77
+ const leftovers = fs.readdirSync(path.dirname(dest)).filter((n) => /\.(staging|backup)-/.test(n));
78
+ assert.deepEqual(leftovers, []);
79
+ });
80
+
81
+ test("③ 暂存包不完整 → 保留旧版、返回 apply_failed", async () => {
82
+ const { relayDir, dest } = setupFixture({ stageComplete: false });
83
+ const r = await applyPendingPluginUpdate({ relayDir, isAppRunning: async () => false });
84
+ assert.equal(r.applied, false);
85
+ assert.equal(r.reason, "apply_failed");
86
+ assert.ok(r.error && /缺少/.test(r.error), "错误里要写清缺哪个文件");
87
+ assert.match(fs.readFileSync(path.join(dest, "package.json"), "utf8"), /0\.7\.4/, "旧版必须可回退");
88
+ assert.ok(fs.existsSync(path.join(relayDir, ".plugin-update-pending.json")), "marker 保留,等下次修复后重试");
89
+ });
90
+
91
+ test("④ 没有 marker → no_pending(定时器不得误动作)", async () => {
92
+ const relayDir = mkTmpDir();
93
+ const r = await applyPendingPluginUpdate({ relayDir, isAppRunning: async () => false });
94
+ assert.equal(r.applied, false);
95
+ assert.equal(r.reason, "no_pending");
96
+ });
package/dsh-setup.mjs CHANGED
@@ -2583,6 +2583,45 @@ function missingPluginFiles(dir) {
2583
2583
  *
2584
2584
  * 另外清掉上次被强杀留下的 `.staging-*` / `.backup-*`,不留垃圾。
2585
2585
  */
2586
+ // ── 桌面端「暫存安装」:应用运行中不能改写它的 profile(会把它弄退出/重启),
2587
+ // 但用户点「一键更新」必须能装到桌面端。做法:先整份拷到 ~/.dsh-remote/pending-plugin/ 并写 marker,
2588
+ // 由常驻的 bridge 在检测到应用退出后自动原子替换 profile 内的插件目录(见 dsh-bridge.mjs)。
2589
+ const PENDING_PLUGIN_MARKER = path.join(CONFIG_DIR, ".plugin-update-pending.json");
2590
+ const PENDING_PLUGIN_DIR = path.join(CONFIG_DIR, "pending-plugin");
2591
+
2592
+ /** 暂存一份插件更新(供 bridge 在应用退出后安装)。返回 marker 对象。 */
2593
+ function stagePluginUpdateForProfile(profileDir, pluginDir) {
2594
+ fs.rmSync(PENDING_PLUGIN_DIR, { recursive: true, force: true });
2595
+ fs.mkdirSync(PENDING_PLUGIN_DIR, { recursive: true });
2596
+ const cpOpts = {
2597
+ recursive: true,
2598
+ filter: (src) => {
2599
+ if (src === pluginDir) return true;
2600
+ const rel = String(path.relative(pluginDir, src));
2601
+ const segs = rel.split(path.sep);
2602
+ if (segs.some((seg) => seg === ".git" || seg === ".DS_Store")) return false;
2603
+ const inBundledRuntime = segs[0] === "runtime";
2604
+ if (!inBundledRuntime && segs.some((seg) => seg === "node_modules")) return false;
2605
+ return true;
2606
+ }
2607
+ };
2608
+ fs.cpSync(pluginDir, PENDING_PLUGIN_DIR, cpOpts);
2609
+ for (const rel of PLUGIN_REQUIRED_FILES) {
2610
+ const f = path.join(PENDING_PLUGIN_DIR, rel);
2611
+ if (!fs.existsSync(f)) throw new Error(`暂存校验失败:缺少 ${rel}`);
2612
+ }
2613
+ const version = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf8")).version;
2614
+ const marker = {
2615
+ version,
2616
+ profileDir,
2617
+ pluginDirName: PLUGIN_LOCAL_DIR,
2618
+ stageDir: PENDING_PLUGIN_DIR,
2619
+ stagedAt: Date.now()
2620
+ };
2621
+ fs.writeFileSync(PENDING_PLUGIN_MARKER, JSON.stringify(marker, null, 2) + "\n");
2622
+ return marker;
2623
+ }
2624
+
2586
2625
  function copyPluginIntoProfile(profileDir, pluginDir) {
2587
2626
  // 清理历史名残留目录(≤0.4.9 的 dsh-remote-ui-plugin),避免与新版并存
2588
2627
  for (const id of PLUGIN_LEGACY_IDS) {
@@ -3031,16 +3070,21 @@ async function pluginCmd(argv) {
3031
3070
  let last = null;
3032
3071
  let ok = 0;
3033
3072
  let skippedRunning = 0;
3073
+ let stagedRunning = 0;
3034
3074
  const force = hasFlag(argv, "--force");
3035
3075
  for (const t of targets) {
3036
3076
  // ⚠️ 桌面应用在运行时**绝不改写它的 profile**:应用会因此退出/重启(2026-10-11 实测故障)
3037
3077
  if (t.name === "desktop" && !force && dshDesktopAppRunning()) {
3038
- console.log(`⚠️ 跳过 desktop:DeepSeek Harness 应用正在运行。`);
3039
- console.log(` 应用独占该 profile,运行中改写会让应用自己退出/重启。请任选其一:`);
3040
- console.log(` · 完全退出应用后重跑本命令;或`);
3041
- console.log(` · 在应用内「插件」页安装/更新 dsh-remote-web(官方路径,装完即热加载)。`);
3042
- console.log(` (确实要强行写入:加 --force)`);
3043
- skippedRunning += 1;
3078
+ console.log(`⚠️ DeepSeek Harness 应用正在运行 → 不直接改写它的 profile(会把它弄退出/重启)。`);
3079
+ try {
3080
+ const staged = stagePluginUpdateForProfile(t.dir, path.join(THIS_DIR, "packages", PLUGIN_ID));
3081
+ stagedRunning += 1;
3082
+ console.log(` ✅ 新版本 ${staged.version} 已暂存:**退出 DeepSeek Harness 后会自动安装**,重新打开应用即生效(无需终端)。`);
3083
+ } catch (e) {
3084
+ console.log(` ⚠️ 暂存失败:${e && e.message ? e.message : e}`);
3085
+ console.log(` 请完全退出应用后重跑本命令,或在应用内「插件」页安装/更新 dsh-remote-web。`);
3086
+ skippedRunning += 1;
3087
+ }
3044
3088
  continue;
3045
3089
  }
3046
3090
  if (multi) console.log(`\n── profile: ${t.name}(${t.dir})`);
@@ -3050,6 +3094,7 @@ async function pluginCmd(argv) {
3050
3094
  if (r !== undefined && r !== null) last = r;
3051
3095
  }
3052
3096
  if (ok === 0 && skippedRunning === 0) process.exit(1);
3097
+ if (stagedRunning > 0) console.log(`ℹ 桌面端插件已暂存(${stagedRunning} 个):退出 DeepSeek Harness 后由后台服务自动安装,重开应用生效。`);
3053
3098
  if (skippedRunning > 0) console.log(`ℹ 本次跳过了 ${skippedRunning} 个 profile(应用在运行);桥与运行环境不受影响。`);
3054
3099
  return last;
3055
3100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.6-beta.2",
3
+ "version": "0.7.6-beta.3",
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",
@@ -2950,6 +2950,8 @@ window.__ModuleLoader__.load({
2950
2950
  : "已停止";
2951
2951
  // 是否需要重启 DeepSeek harness(首次安装/在线更新后置顶提醒 + 底部常驻按钮)
2952
2952
  var restartInfo = (st && st.restart) || {};
2953
+ // 桌面端「暫存插件更新」:新版本已下载,退出应用后由 bridge 自动安装(见 dsh-bridge 的 applier)
2954
+ var desktopUpdatePending = (st && st.desktopUpdatePending) || null;
2953
2955
  var restartPending = !!restartInfo.pending;
2954
2956
  // 宿主形态(node 半下发):桌面端(Electron)Host 由桌面应用托管,重启这个动作
2955
2957
  // 在那边**做不到也不该做**(强行退出会弹原生致命错误)。面板据此:
@@ -5309,12 +5311,18 @@ window.__ModuleLoader__.load({
5309
5311
  // 不该混在桌面端适配里顺手做掉。桌面端才走新文案。
5310
5312
  // 2026-10-17(业主):桌面端运行环境由应用托管、更新自动热加载 ——
5311
5313
  // **不再引导用户重启 DeepSeek Harness**,统一只说「刷新页面即可生效」。
5312
- var restartRowText = (desktopHost || !isRestartHard)
5313
- ? "🔄 插件已更新:刷新页面即可生效"
5314
- : "🔄 插件已更新:重启 dsh web 后生效";
5314
+ // 桌面端:插件更新需要退出并重新打开应用(应用独占 profile,不能热改);
5315
+ // 若已暂存新版本,bridge 会在应用退出后自动安装,重开即生效 —— 绝不说「刷新页面即可」。
5316
+ var restartRowText = desktopHost
5317
+ ? (desktopUpdatePending
5318
+ ? "🆕 新版本已下载:退出并重新打开 DeepSeek Harness 后自动生效(无需终端)"
5319
+ : "🔄 插件已更新:退出并重新打开 DeepSeek Harness 后生效")
5320
+ : (!isRestartHard
5321
+ ? "🔄 插件已更新:刷新页面即可生效"
5322
+ : "🔄 插件已更新:重启 dsh web 后生效");
5315
5323
  rows.push(h("div", { className: "dru-access-tip-row", key: "restart" },
5316
5324
  h("span", null, restartRowText),
5317
- (isRefreshOnly || !desktopHost) ? h("button", {
5325
+ (!desktopHost || isRefreshOnly) && !desktopHost ? h("button", {
5318
5326
  type: "button",
5319
5327
  className: "dru-linkbtn",
5320
5328
  disabled: busy !== "",
@@ -5366,13 +5374,17 @@ window.__ModuleLoader__.load({
5366
5374
  // **任何情况都不再引导"重启/退出重开应用"**,只说「刷新页面即可生效」。
5367
5375
  // 非桌面端(自建 dsh web)保留原有 isRestartHard 判据,文案不变。
5368
5376
  desktopHost
5369
- ? "插件已更新:刷新页面即可生效"
5377
+ ? (desktopUpdatePending
5378
+ ? "新版本已下载:退出并重新打开 DeepSeek Harness 后生效"
5379
+ : "插件已更新:退出并重新打开 DeepSeek Harness 后生效")
5370
5380
  : (isRestartHard
5371
5381
  ? "插件已更新,重启 dsh web 后生效"
5372
5382
  : (isRefreshOnly ? "插件已更新,刷新页面即可生效" : "需要重启 dsh web 才能载入新插件"))),
5373
5383
  h("div", { className: "dru-restart-alert-sub" },
5374
5384
  desktopHost
5375
- ? "新插件已经在磁盘上,刷新一下这个页面即可载入(手机端连接与已登录状态都不受影响)。如果刷新后仍看不到面板,在应用内「插件」页重新安装一次即可。"
5385
+ ? (desktopUpdatePending
5386
+ ? "新版本已经下载到本机;DeepSeek Harness 退出后,后台服务会自动完成安装,重新打开应用即生效(无需终端,手机端连接与已登录状态都不受影响)。"
5387
+ : "新插件已在磁盘上,但桌面应用仍在运行旧插件;退出 DeepSeek Harness 后重新打开即可载入新版(应用退出后后台服务会自动完成安装,无需终端)。")
5376
5388
  : (isRestartHard
5377
5389
  ? "新版本已经装到这台电脑上,但当前运行的 dsh web 还是旧代码(新的后台路由与面板要等它重新载入)。手机端连接与已登录状态都不受影响;重启期间页面会短暂断开,然后自动恢复。"
5378
5390
  : (isRefreshOnly
@@ -2817,6 +2817,29 @@ function writeRestartState(relayDir, state) {
2817
2817
  * 标记「需要用户动一下」的状态。kind = refresh(插件被运行时改写,刷新页面即可)。
2818
2818
  * 历史 kind(first-install / update)已废弃:热加载落地后,装插件与在线更新都不再需要重启 harness。
2819
2819
  */
2820
+ /** 桌面端「暫存插件更新」marker(dsh-setup 在应用运行时暂存;bridge 在应用退出后自动安装)。 */
2821
+ function readDesktopUpdatePending(relayDir) {
2822
+ try {
2823
+ const m = JSON.parse(readFileSync(join(relayDir, ".plugin-update-pending.json"), "utf8"));
2824
+ return { version: String(m.version || ""), stagedAt: Number(m.stagedAt) || 0 };
2825
+ } catch {
2826
+ return null;
2827
+ }
2828
+ }
2829
+
2830
+ /** 更新进程成功收尾后写「待生效」提示:桌面端暂存更新 → 退出应用后自动安装;其它 → 重启 dsh web。 */
2831
+ function markUpdateRestartPending(relayDir) {
2832
+ const staged = readDesktopUpdatePending(relayDir);
2833
+ if (staged) {
2834
+ return markRestartPending(
2835
+ relayDir,
2836
+ "restart-app",
2837
+ `新版本${staged.version ? ` ${staged.version}` : ""} 已下载:退出并重新打开 DeepSeek Harness 后自动生效(无需终端)`
2838
+ );
2839
+ }
2840
+ return markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
2841
+ }
2842
+
2820
2843
  function markRestartPending(relayDir, kind, reason) {
2821
2844
  const cur = readRestartState(relayDir);
2822
2845
  if (cur.pending && cur.kind === kind) return cur; // 幂等:同一原因不反复重写
@@ -3748,6 +3771,8 @@ async function composeStatus(relayDir) {
3748
3771
  // 是否需要重启 DeepSeek harness(首次安装 / 在线更新后必须重启才加载插件本体):
3749
3772
  // {pending, kind: first-install|update, reason, at},供面板顶部醒目提示 + 重启按钮。
3750
3773
  restart: readRestartState(relayDir),
3774
+ // 桌面端暂存的插件更新(存在 = 新版本已下载,退出应用后由 bridge 自动安装并生效)
3775
+ desktopUpdatePending: readDesktopUpdatePending(relayDir),
3751
3776
  // 安装/配对自检(2026-10-17 事故):运行时(clients/)与配对(.dsh-config.json)是否齐全、
3752
3777
  // 缺什么、下一步该做什么;配对主配置缺失但备份存在时给出可一键恢复的 backup.action。
3753
3778
  // 字段契约见 setupStateOf;connect.setup 是同一份对象(bridge-status 短轮询也能拿到)。
@@ -4586,7 +4611,7 @@ async function runOnlineUpdate(relayDir) {
4586
4611
  updateFailures.delete(String(relayDir));
4587
4612
  sweepIfSucceeded(); // C8:装完清残留(按更新前的 pid 点名 + 命令行复核)
4588
4613
  // 更新成功 → 落一条「必须重启 dsh web」的强提醒(见 run() 成功分支的同款注释)
4589
- markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
4614
+ markUpdateRestartPending(relayDir);
4590
4615
  }
4591
4616
  });
4592
4617
  child.unref();
@@ -4676,7 +4701,7 @@ async function runOnlineUpdate(relayDir) {
4676
4701
  // 客户端按 kind 一一对应地换文案与主按钮(见 client.js 的 restartAlert),
4677
4702
  // 混用一个 kind 就会再次出现"文案说刷新、状态其实是重启"。
4678
4703
  // settleRestartState() 靠 bootId 自动结清 —— 用户真的重启后这条提示自己消失,不需要额外清理。
4679
- markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
4704
+ markUpdateRestartPending(relayDir);
4680
4705
  }
4681
4706
  });
4682
4707
  child.unref();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.6-beta.2",
3
+ "version": "0.7.6-beta.3",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -82,6 +82,7 @@ import WebSocket from "ws";
82
82
  import fs from "node:fs";
83
83
  import os from "node:os";
84
84
  import path from "node:path";
85
+ import net from "node:net";
85
86
  import { execSync, spawnSync } from "node:child_process";
86
87
  import { randomBytes, generateKeyPairSync, createHash } from "node:crypto";
87
88
  import { pathToFileURL, fileURLToPath } from "node:url";
@@ -2723,9 +2724,109 @@ async function main() {
2723
2724
  console.log("[bridge] 单实例锁检查降级(取不到进程命令行或锁不可写)——照常启动;若出现 code=4000 互踢,本实例会自动让位。");
2724
2725
  }
2725
2726
  process.on("exit", releaseInstanceLock);
2727
+ // 桌面端插件「暂存更新」自动安装(应用退出后生效;见 applyPendingPluginUpdate 注释)
2728
+ startPendingPluginUpdateWatcher();
2726
2729
  return runTunnel();
2727
2730
  }
2728
2731
 
2732
+ // ── 桌面端插件「暫存安装」落地(2026-10-24)────────────────────────────────────────────
2733
+ // dsh-setup 在桌面应用运行时无法直接改写它的 profile(会触发应用退出/重启),改为暂存到
2734
+ // <relayDir>/pending-plugin/ 并写 marker;bridge 常驻、检测到应用退出后自动原子替换插件目录,
2735
+ // 用户下次打开应用即加载新版 —— 这样「一键更新」对桌面端也能真正生效(不再需要终端)。
2736
+ const PENDING_PLUGIN_MARKER = path.join(RELAY_DIR, ".plugin-update-pending.json");
2737
+ const PENDING_PLUGIN_REQUIRED = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
2738
+ const PENDING_PLUGIN_DISABLED = process.env.DSH_BRIDGE_PENDING_UPDATE === "0";
2739
+
2740
+ /** upstream 端口是否在监听(= DeepSeek Harness 桌面端在运行)。TCP 判断,跨平台。 */
2741
+ export function probeUpstreamListening({ host = "127.0.0.1", port, timeoutMs = 400 } = {}) {
2742
+ return new Promise((resolve) => {
2743
+ if (!port) return resolve(false);
2744
+ const sock = net.connect({ host, port });
2745
+ let done = false;
2746
+ const finish = (v) => { if (done) return; done = true; try { sock.destroy(); } catch { /* 忽略 */ } resolve(v); };
2747
+ sock.setTimeout(timeoutMs, () => finish(false));
2748
+ sock.once("connect", () => finish(true));
2749
+ sock.once("error", () => finish(false));
2750
+ });
2751
+ }
2752
+ function upstreamPortOf(url) {
2753
+ try { const u = new URL(String(url)); return Number(u.port) || (u.protocol === "https:" ? 443 : 80); } catch { return 0; }
2754
+ }
2755
+
2756
+ /**
2757
+ * 应用暂存的插件更新(仅在桌面应用不在运行时)。原子替换:staging → rename,失败保留旧版。
2758
+ * @returns {{applied:boolean, reason?:string, version?:string, error?:string}}
2759
+ */
2760
+ export async function applyPendingPluginUpdate({ relayDir = RELAY_DIR, isAppRunning, now = Date.now(), log = console } = {}) {
2761
+ const markerPath = path.join(relayDir, ".plugin-update-pending.json");
2762
+ let marker;
2763
+ try { marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); } catch { return { applied: false, reason: "no_pending" }; }
2764
+ const stageDir = String(marker.stageDir || "");
2765
+ const profileDir = String(marker.profileDir || "");
2766
+ const name = String(marker.pluginDirName || "dsh-remote-web-plugin");
2767
+ if (!stageDir || !profileDir || !fs.existsSync(stageDir)) {
2768
+ try { fs.rmSync(markerPath, { force: true }); } catch { /* 忽略 */ }
2769
+ return { applied: false, reason: "staging_missing" };
2770
+ }
2771
+ const running = typeof isAppRunning === "function"
2772
+ ? await isAppRunning()
2773
+ : await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
2774
+ if (running) return { applied: false, reason: "app_running" };
2775
+ const dest = path.join(profileDir, name);
2776
+ const stage = `${dest}.staging-${process.pid}`;
2777
+ const backup = `${dest}.backup-${process.pid}`;
2778
+ try {
2779
+ fs.rmSync(stage, { recursive: true, force: true });
2780
+ fs.cpSync(stageDir, stage, { recursive: true });
2781
+ for (const rel of PENDING_PLUGIN_REQUIRED) {
2782
+ if (!fs.existsSync(path.join(stage, rel))) throw new Error(`暂存包缺少 ${rel}`);
2783
+ }
2784
+ fs.rmSync(backup, { recursive: true, force: true });
2785
+ const had = fs.existsSync(dest);
2786
+ if (had) fs.renameSync(dest, backup);
2787
+ try {
2788
+ fs.renameSync(stage, dest);
2789
+ } catch (e) {
2790
+ if (had) { try { fs.renameSync(backup, dest); } catch { /* 忽略 */ } }
2791
+ throw e;
2792
+ }
2793
+ if (had) { try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* 忽略 */ } }
2794
+ try {
2795
+ fs.writeFileSync(
2796
+ path.join(relayDir, ".plugin-update-applied.json"),
2797
+ JSON.stringify({ version: marker.version, appliedAt: now, profileDir }, null, 2) + "\n"
2798
+ );
2799
+ } catch { /* 忽略 */ }
2800
+ try { fs.rmSync(markerPath, { force: true }); fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
2801
+ log.info?.(`[bridge] ✅ 插件新版本 ${marker.version} 已自动安装到 ${profileDir}(桌面应用退出后);重新打开应用即生效`);
2802
+ return { applied: true, version: marker.version };
2803
+ } catch (e) {
2804
+ try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* 忽略 */ }
2805
+ log.warn?.(`[bridge] 插件暂存安装失败(保留旧版可回退):${e && e.message ? e.message : e}`);
2806
+ return { applied: false, reason: "apply_failed", error: String((e && e.message) || e) };
2807
+ }
2808
+ }
2809
+
2810
+ function startPendingPluginUpdateWatcher() {
2811
+ if (PENDING_PLUGIN_DISABLED) return null;
2812
+ let closedStreak = 0;
2813
+ const tick = async () => {
2814
+ try {
2815
+ if (!fs.existsSync(PENDING_PLUGIN_MARKER)) { closedStreak = 0; return; }
2816
+ const running = await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
2817
+ if (running) { closedStreak = 0; return; }
2818
+ closedStreak += 1;
2819
+ if (closedStreak < 2) return; // 连续两次(≈30s)判定不在运行才动手,避开退出/重启瞬间
2820
+ await applyPendingPluginUpdate({});
2821
+ closedStreak = 0;
2822
+ } catch { /* 绝不影响隧道业务 */ }
2823
+ };
2824
+ const t = setInterval(tick, 15_000);
2825
+ t.unref?.();
2826
+ void tick();
2827
+ return t;
2828
+ }
2829
+
2729
2830
  // test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
2730
2831
  export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
2731
2832
  export function __armMirrorLoadWatch(path) { armMirrorLoadWatch(path); }
@@ -2583,6 +2583,45 @@ function missingPluginFiles(dir) {
2583
2583
  *
2584
2584
  * 另外清掉上次被强杀留下的 `.staging-*` / `.backup-*`,不留垃圾。
2585
2585
  */
2586
+ // ── 桌面端「暫存安装」:应用运行中不能改写它的 profile(会把它弄退出/重启),
2587
+ // 但用户点「一键更新」必须能装到桌面端。做法:先整份拷到 ~/.dsh-remote/pending-plugin/ 并写 marker,
2588
+ // 由常驻的 bridge 在检测到应用退出后自动原子替换 profile 内的插件目录(见 dsh-bridge.mjs)。
2589
+ const PENDING_PLUGIN_MARKER = path.join(CONFIG_DIR, ".plugin-update-pending.json");
2590
+ const PENDING_PLUGIN_DIR = path.join(CONFIG_DIR, "pending-plugin");
2591
+
2592
+ /** 暂存一份插件更新(供 bridge 在应用退出后安装)。返回 marker 对象。 */
2593
+ function stagePluginUpdateForProfile(profileDir, pluginDir) {
2594
+ fs.rmSync(PENDING_PLUGIN_DIR, { recursive: true, force: true });
2595
+ fs.mkdirSync(PENDING_PLUGIN_DIR, { recursive: true });
2596
+ const cpOpts = {
2597
+ recursive: true,
2598
+ filter: (src) => {
2599
+ if (src === pluginDir) return true;
2600
+ const rel = String(path.relative(pluginDir, src));
2601
+ const segs = rel.split(path.sep);
2602
+ if (segs.some((seg) => seg === ".git" || seg === ".DS_Store")) return false;
2603
+ const inBundledRuntime = segs[0] === "runtime";
2604
+ if (!inBundledRuntime && segs.some((seg) => seg === "node_modules")) return false;
2605
+ return true;
2606
+ }
2607
+ };
2608
+ fs.cpSync(pluginDir, PENDING_PLUGIN_DIR, cpOpts);
2609
+ for (const rel of PLUGIN_REQUIRED_FILES) {
2610
+ const f = path.join(PENDING_PLUGIN_DIR, rel);
2611
+ if (!fs.existsSync(f)) throw new Error(`暂存校验失败:缺少 ${rel}`);
2612
+ }
2613
+ const version = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf8")).version;
2614
+ const marker = {
2615
+ version,
2616
+ profileDir,
2617
+ pluginDirName: PLUGIN_LOCAL_DIR,
2618
+ stageDir: PENDING_PLUGIN_DIR,
2619
+ stagedAt: Date.now()
2620
+ };
2621
+ fs.writeFileSync(PENDING_PLUGIN_MARKER, JSON.stringify(marker, null, 2) + "\n");
2622
+ return marker;
2623
+ }
2624
+
2586
2625
  function copyPluginIntoProfile(profileDir, pluginDir) {
2587
2626
  // 清理历史名残留目录(≤0.4.9 的 dsh-remote-ui-plugin),避免与新版并存
2588
2627
  for (const id of PLUGIN_LEGACY_IDS) {
@@ -3031,16 +3070,21 @@ async function pluginCmd(argv) {
3031
3070
  let last = null;
3032
3071
  let ok = 0;
3033
3072
  let skippedRunning = 0;
3073
+ let stagedRunning = 0;
3034
3074
  const force = hasFlag(argv, "--force");
3035
3075
  for (const t of targets) {
3036
3076
  // ⚠️ 桌面应用在运行时**绝不改写它的 profile**:应用会因此退出/重启(2026-10-11 实测故障)
3037
3077
  if (t.name === "desktop" && !force && dshDesktopAppRunning()) {
3038
- console.log(`⚠️ 跳过 desktop:DeepSeek Harness 应用正在运行。`);
3039
- console.log(` 应用独占该 profile,运行中改写会让应用自己退出/重启。请任选其一:`);
3040
- console.log(` · 完全退出应用后重跑本命令;或`);
3041
- console.log(` · 在应用内「插件」页安装/更新 dsh-remote-web(官方路径,装完即热加载)。`);
3042
- console.log(` (确实要强行写入:加 --force)`);
3043
- skippedRunning += 1;
3078
+ console.log(`⚠️ DeepSeek Harness 应用正在运行 → 不直接改写它的 profile(会把它弄退出/重启)。`);
3079
+ try {
3080
+ const staged = stagePluginUpdateForProfile(t.dir, path.join(THIS_DIR, "packages", PLUGIN_ID));
3081
+ stagedRunning += 1;
3082
+ console.log(` ✅ 新版本 ${staged.version} 已暂存:**退出 DeepSeek Harness 后会自动安装**,重新打开应用即生效(无需终端)。`);
3083
+ } catch (e) {
3084
+ console.log(` ⚠️ 暂存失败:${e && e.message ? e.message : e}`);
3085
+ console.log(` 请完全退出应用后重跑本命令,或在应用内「插件」页安装/更新 dsh-remote-web。`);
3086
+ skippedRunning += 1;
3087
+ }
3044
3088
  continue;
3045
3089
  }
3046
3090
  if (multi) console.log(`\n── profile: ${t.name}(${t.dir})`);
@@ -3050,6 +3094,7 @@ async function pluginCmd(argv) {
3050
3094
  if (r !== undefined && r !== null) last = r;
3051
3095
  }
3052
3096
  if (ok === 0 && skippedRunning === 0) process.exit(1);
3097
+ if (stagedRunning > 0) console.log(`ℹ 桌面端插件已暂存(${stagedRunning} 个):退出 DeepSeek Harness 后由后台服务自动安装,重开应用生效。`);
3053
3098
  if (skippedRunning > 0) console.log(`ℹ 本次跳过了 ${skippedRunning} 个 profile(应用在运行);桥与运行环境不受影响。`);
3054
3099
  return last;
3055
3100
  }
@@ -167,11 +167,13 @@ test("桌面端:面板不得自动倒计时重启,且不摆点了必然失
167
167
  "自动倒计时重启必须在桌面端短路(Host 由 Electron 托管,不能替用户重启)");
168
168
  assert.match(CLIENT_SRC, /\(desktopHost && !isRefreshOnly\) \? null : h\("button"/,
169
169
  "桌面端且非'刷新即可'时不得渲染重启按钮");
170
- // 2026-10-17(业主):桌面端**不再**出现"重启 DeepSeek Harness"这类引导,
171
- // 统一只说「刷新页面即可生效」;非桌面端保留原有 isRestartHard 判据(发布版文案不变)。
172
- assert.doesNotMatch(CLIENT_SRC, /退出并重新打开 DeepSeek Harness/,
173
- "桌面端不得再引导用户重启应用(运行环境由应用托管、更新自动热加载)");
174
- assert.match(CLIENT_SRC, /插件已更新:刷新页面即可生效/);
170
+ // 2026-10-24(业主):桌面端 profile 由应用独占,运行中无法改写 → 插件更新必须「退出并重开应用」;
171
+ // 已暂存的新版本由 bridge 在应用退出后自动安装。此前「刷新页面即可生效」的文案被证实是错的。
172
+ assert.match(CLIENT_SRC, /退出并重新打开 DeepSeek Harness 后自动生效/,
173
+ "桌面端必须如实引导:退出并重新打开应用后生效(新版本由 bridge 自动安装)");
174
+ assert.match(CLIENT_SRC, /desktopUpdatePending/, "面板要读桌面端暂存更新状态");
175
+ assert.doesNotMatch(CLIENT_SRC, /desktopHost[\s\S]{0,200}插件已更新:刷新页面即可生效/,
176
+ "桌面端不得再说「刷新页面即可生效」(应用在运行时根本不会加载新插件)");
175
177
  });
176
178
 
177
179
  test("桌面端:删除「重启 DeepSeek Harness」整段说明(2026-10-17 业主约定)", () => {
@@ -240,7 +240,10 @@ test("★ 桌面应用在运行时,绝不改写它的 profile(否则应用
240
240
  const applyAt = src.indexOf("await pluginApplyTo(", loopAt);
241
241
  assert.ok(loopAt > 0 && guardAt > loopAt && applyAt > guardAt,
242
242
  "顺序必须是:循环 → 应用在运行?→ 跳过;否则才 pluginApplyTo()");
243
- assert.match(src, /跳过 desktop:DeepSeek Harness 应用正在运行/, "要如实说明跳过了 desktop");
243
+ // 2026-10-24:应用在运行时不再只是"跳过",而是把插件更新**暂存**,由 bridge 在应用退出后自动安装。
244
+ assert.match(src, /stagePluginUpdateForProfile\(/, "应用在运行时必须暂存插件更新(否则一键更新永远装不上桌面端)");
245
+ assert.match(src, /\.plugin-update-pending\.json/, "暂存要写 marker 供 bridge 落地");
246
+ assert.match(src, /退出 DeepSeek Harness 后会自动安装/, "要如实告诉用户:退出应用后自动安装、重开生效");
244
247
  assert.match(src, /应用内「插件」页安装/, "要给出官方替代路径(应用内插件页)");
245
248
  assert.match(src, /"--force"/, "要留 --force 逃生口");
246
249
  // sync-local 脚本同样要保护(本机同步最常用)