dsh-remote-web 0.7.6-beta.2 → 0.7.6-beta.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/lib/client.js CHANGED
@@ -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
package/lib/index.js CHANGED
@@ -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();
package/package.json CHANGED
@@ -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.4",
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";
@@ -630,7 +631,10 @@ export function dcSend(dc, obj) {
630
631
  * · 每轮发送前先排空控制车道,并检查 `ws.bufferedAmount`:超过高水位就停手等它排空。
631
632
  * 于是控制帧前面最多只有「高水位 + 一片」的待发字节 —— 默认 64KB + 64KB,手机 1Mbps
632
633
  * 下 ≈1s,8Mbps 下 ≈0.13s,远小于 router 的 20s,也满足「大响应传输中 1s 内应答」目标。
633
- * · 小帧(交互消息 / SSE 块)单独一条车道,优先于大帧的下一片(交互不被整帧传输拖住)。
634
+ * · **数据帧严格 FIFO**(2026-10-24 修):早期实现让小帧(交互/SSE 块)插到大帧下一片之前,
635
+ * 这在同一 WS 流里是**乱序** —— 生产表现为手机远程页「历史加载失败:session event stream
636
+ * emitted an entry before its opening cursor」。现在只有一个数据队列,大帧按片就地推进,
637
+ * 绝不因大小/类型被后来的帧超越;只有控制帧(不承载有序数据)保留插队权。
634
638
  * · 阈值 / 分片大小可用环境变量调(见 tunnelBulkOptionsFromEnv),弱网部署可再调低。
635
639
  */
636
640
  export const TUNNEL_BULK_HWM_DEFAULT = 64 * 1024; // 发送缓冲高水位(字节)
@@ -679,13 +683,19 @@ export function makePrioritySender({
679
683
  let pumping = false;
680
684
  let scheduled = false;
681
685
  let timerPending = false;
682
- const smallQ = []; // 小帧(可整帧直发):交互消息 / SSE 块 —— 优先于大帧的下一片
683
- const bigQ = []; // 大帧:{ frames: string[], i }
684
- const stats = { controlSent: 0, smallSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
685
-
686
- function enqueueBulk(text) {
686
+ // 🔴 2026-10-24 事故修复:数据帧**必须严格 FIFO**。
687
+ // 旧实现分「小帧车道 / 大帧车道」,小帧会插到大帧下一片之前 —— 这违反了同一 WS 流里
688
+ // 消息的顺序语义,生产表现为手机远程页「历史加载失败:session event stream emitted an
689
+ // entry before its opening cursor(gateway/internal)」:事件流请求/响应被乱序送达,
690
+ // 客户端的游标推进到了前面,重连时服务端按该游标回放就触发 cursor 错乱。
691
+ // 现在:控制帧仍可插队(它们是协议控制帧,不承载有序数据);**数据帧只有一个队列**,
692
+ // 大帧按片就地推进,绝不因大小/类型被后来的帧超越。
693
+ const dataQ = []; // { frames: string[], i: number };frames.length===1 = 未分片小帧
694
+ const stats = { controlSent: 0, dataSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
695
+
696
+ function enqueueData(text) {
687
697
  if (text.length <= chunkSize) {
688
- smallQ.push(text);
698
+ dataQ.push({ frames: [text], i: 0 });
689
699
  return;
690
700
  }
691
701
  const cid = `c${++chunkSeq}`;
@@ -695,7 +705,7 @@ export function makePrioritySender({
695
705
  frames[i] = JSON.stringify({ __chunk: { id: cid, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } });
696
706
  }
697
707
  stats.bigFrames += 1;
698
- bigQ.push({ frames, i: 0 });
708
+ dataQ.push({ frames, i: 0 });
699
709
  }
700
710
 
701
711
  function scheduleNext(waitMs) {
@@ -715,7 +725,7 @@ export function makePrioritySender({
715
725
  if (pumping || closed) return;
716
726
  pumping = true;
717
727
  let yieldMode = ""; // ""=没有剩余,"schedule"=让出事件循环,"poll"=等缓冲排空
718
- let budget = 32; // 一轮最多发多少条小帧
728
+ let budget = 64; // 一轮最多发多少条未分片小帧(大帧每轮只发一片)
719
729
  try {
720
730
  for (;;) {
721
731
  // ① 控制车道永远先走:哪怕大帧正传着 16MB,控制帧也在**下一轮**第一时间出去。
@@ -724,31 +734,31 @@ export function makePrioritySender({
724
734
  try { rawSend(frame); stats.controlSent += 1; } catch { closed = true; }
725
735
  }
726
736
  if (closed) { yieldMode = ""; break; }
727
- // ② 小帧优先于大帧的下一片:大包传输期间,交互消息 / SSE 块不该排在它后面。
728
- if (smallQ.length > 0 && budget > 0) {
729
- try { rawSend(smallQ.shift()); stats.smallSent += 1; } catch { closed = true; break; }
737
+ if (dataQ.length === 0) { yieldMode = ""; break; }
738
+ if (budget <= 0) { yieldMode = "schedule"; break; }
739
+ const head = dataQ[0];
740
+ if (head.frames.length === 1) {
741
+ // 未分片小帧:可连着多发几条,但绝不越过队头(保证 FIFO)
742
+ try { rawSend(head.frames[0]); stats.dataSent += 1; } catch { closed = true; break; }
743
+ dataQ.shift();
730
744
  budget -= 1;
731
745
  continue; // 回环顶先查控制车道
732
746
  }
733
- if (smallQ.length > 0) { yieldMode = "schedule"; break; } // 一轮预算用完
734
- if (bigQ.length === 0) { yieldMode = ""; break; }
735
- // ③ 背压:已入队字节超过高水位 → 停手等排空,别把控制帧埋进几十秒的队列里。
747
+ // 队头是大帧:每轮只推一片,片间让出事件循环(控制帧/入站帧不被饿死),顺序不变
736
748
  let pending = 0;
737
749
  try { pending = Number(bufferedAmount()) || 0; } catch { pending = 0; }
738
750
  if (pending > bulkHighWater) { stats.waits += 1; yieldMode = "poll"; break; }
739
- const item = bigQ[0];
740
- try { rawSend(item.frames[item.i]); stats.bigChunks += 1; } catch { closed = true; break; }
741
- item.i += 1;
742
- if (item.i >= item.frames.length) bigQ.shift();
743
- yieldMode = "schedule"; // 大帧:一片一让,给入站帧 / 控制帧 / 小帧机会
751
+ try { rawSend(head.frames[head.i]); stats.dataSent += 1; stats.bigChunks += 1; } catch { closed = true; break; }
752
+ head.i += 1;
753
+ if (head.i >= head.frames.length) dataQ.shift();
754
+ yieldMode = "schedule";
744
755
  break;
745
756
  }
746
757
  } finally {
747
758
  pumping = false;
748
759
  if (closed) {
749
760
  controlQ.length = 0;
750
- smallQ.length = 0;
751
- bigQ.length = 0;
761
+ dataQ.length = 0;
752
762
  } else if (yieldMode) {
753
763
  scheduleNext(yieldMode === "poll" ? pollMs : 0);
754
764
  }
@@ -760,7 +770,7 @@ export function makePrioritySender({
760
770
  try { text = JSON.stringify(obj); } catch { return false; }
761
771
  if (closed) { stats.dropped += 1; return false; }
762
772
  if (isControlFrame(obj)) controlQ.push(text);
763
- else enqueueBulk(text);
773
+ else enqueueData(text);
764
774
  pump();
765
775
  return true;
766
776
  }
@@ -769,8 +779,7 @@ export function makePrioritySender({
769
779
  function close() {
770
780
  closed = true;
771
781
  controlQ.length = 0;
772
- smallQ.length = 0;
773
- bigQ.length = 0;
782
+ dataQ.length = 0;
774
783
  }
775
784
 
776
785
  return {
@@ -778,11 +787,11 @@ export function makePrioritySender({
778
787
  close,
779
788
  stats: () => ({
780
789
  ...stats,
781
- queued: smallQ.length + bigQ.length,
790
+ queued: dataQ.length,
782
791
  controlQueued: controlQ.length,
783
792
  closed
784
793
  }),
785
- hasBacklog: () => smallQ.length > 0 || bigQ.length > 0 || controlQ.length > 0
794
+ hasBacklog: () => dataQ.length > 0 || controlQ.length > 0
786
795
  };
787
796
  }
788
797
 
@@ -2723,9 +2732,109 @@ async function main() {
2723
2732
  console.log("[bridge] 单实例锁检查降级(取不到进程命令行或锁不可写)——照常启动;若出现 code=4000 互踢,本实例会自动让位。");
2724
2733
  }
2725
2734
  process.on("exit", releaseInstanceLock);
2735
+ // 桌面端插件「暂存更新」自动安装(应用退出后生效;见 applyPendingPluginUpdate 注释)
2736
+ startPendingPluginUpdateWatcher();
2726
2737
  return runTunnel();
2727
2738
  }
2728
2739
 
2740
+ // ── 桌面端插件「暫存安装」落地(2026-10-24)────────────────────────────────────────────
2741
+ // dsh-setup 在桌面应用运行时无法直接改写它的 profile(会触发应用退出/重启),改为暂存到
2742
+ // <relayDir>/pending-plugin/ 并写 marker;bridge 常驻、检测到应用退出后自动原子替换插件目录,
2743
+ // 用户下次打开应用即加载新版 —— 这样「一键更新」对桌面端也能真正生效(不再需要终端)。
2744
+ const PENDING_PLUGIN_MARKER = path.join(RELAY_DIR, ".plugin-update-pending.json");
2745
+ const PENDING_PLUGIN_REQUIRED = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
2746
+ const PENDING_PLUGIN_DISABLED = process.env.DSH_BRIDGE_PENDING_UPDATE === "0";
2747
+
2748
+ /** upstream 端口是否在监听(= DeepSeek Harness 桌面端在运行)。TCP 判断,跨平台。 */
2749
+ export function probeUpstreamListening({ host = "127.0.0.1", port, timeoutMs = 400 } = {}) {
2750
+ return new Promise((resolve) => {
2751
+ if (!port) return resolve(false);
2752
+ const sock = net.connect({ host, port });
2753
+ let done = false;
2754
+ const finish = (v) => { if (done) return; done = true; try { sock.destroy(); } catch { /* 忽略 */ } resolve(v); };
2755
+ sock.setTimeout(timeoutMs, () => finish(false));
2756
+ sock.once("connect", () => finish(true));
2757
+ sock.once("error", () => finish(false));
2758
+ });
2759
+ }
2760
+ function upstreamPortOf(url) {
2761
+ try { const u = new URL(String(url)); return Number(u.port) || (u.protocol === "https:" ? 443 : 80); } catch { return 0; }
2762
+ }
2763
+
2764
+ /**
2765
+ * 应用暂存的插件更新(仅在桌面应用不在运行时)。原子替换:staging → rename,失败保留旧版。
2766
+ * @returns {{applied:boolean, reason?:string, version?:string, error?:string}}
2767
+ */
2768
+ export async function applyPendingPluginUpdate({ relayDir = RELAY_DIR, isAppRunning, now = Date.now(), log = console } = {}) {
2769
+ const markerPath = path.join(relayDir, ".plugin-update-pending.json");
2770
+ let marker;
2771
+ try { marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); } catch { return { applied: false, reason: "no_pending" }; }
2772
+ const stageDir = String(marker.stageDir || "");
2773
+ const profileDir = String(marker.profileDir || "");
2774
+ const name = String(marker.pluginDirName || "dsh-remote-web-plugin");
2775
+ if (!stageDir || !profileDir || !fs.existsSync(stageDir)) {
2776
+ try { fs.rmSync(markerPath, { force: true }); } catch { /* 忽略 */ }
2777
+ return { applied: false, reason: "staging_missing" };
2778
+ }
2779
+ const running = typeof isAppRunning === "function"
2780
+ ? await isAppRunning()
2781
+ : await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
2782
+ if (running) return { applied: false, reason: "app_running" };
2783
+ const dest = path.join(profileDir, name);
2784
+ const stage = `${dest}.staging-${process.pid}`;
2785
+ const backup = `${dest}.backup-${process.pid}`;
2786
+ try {
2787
+ fs.rmSync(stage, { recursive: true, force: true });
2788
+ fs.cpSync(stageDir, stage, { recursive: true });
2789
+ for (const rel of PENDING_PLUGIN_REQUIRED) {
2790
+ if (!fs.existsSync(path.join(stage, rel))) throw new Error(`暂存包缺少 ${rel}`);
2791
+ }
2792
+ fs.rmSync(backup, { recursive: true, force: true });
2793
+ const had = fs.existsSync(dest);
2794
+ if (had) fs.renameSync(dest, backup);
2795
+ try {
2796
+ fs.renameSync(stage, dest);
2797
+ } catch (e) {
2798
+ if (had) { try { fs.renameSync(backup, dest); } catch { /* 忽略 */ } }
2799
+ throw e;
2800
+ }
2801
+ if (had) { try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* 忽略 */ } }
2802
+ try {
2803
+ fs.writeFileSync(
2804
+ path.join(relayDir, ".plugin-update-applied.json"),
2805
+ JSON.stringify({ version: marker.version, appliedAt: now, profileDir }, null, 2) + "\n"
2806
+ );
2807
+ } catch { /* 忽略 */ }
2808
+ try { fs.rmSync(markerPath, { force: true }); fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
2809
+ log.info?.(`[bridge] ✅ 插件新版本 ${marker.version} 已自动安装到 ${profileDir}(桌面应用退出后);重新打开应用即生效`);
2810
+ return { applied: true, version: marker.version };
2811
+ } catch (e) {
2812
+ try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* 忽略 */ }
2813
+ log.warn?.(`[bridge] 插件暂存安装失败(保留旧版可回退):${e && e.message ? e.message : e}`);
2814
+ return { applied: false, reason: "apply_failed", error: String((e && e.message) || e) };
2815
+ }
2816
+ }
2817
+
2818
+ function startPendingPluginUpdateWatcher() {
2819
+ if (PENDING_PLUGIN_DISABLED) return null;
2820
+ let closedStreak = 0;
2821
+ const tick = async () => {
2822
+ try {
2823
+ if (!fs.existsSync(PENDING_PLUGIN_MARKER)) { closedStreak = 0; return; }
2824
+ const running = await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
2825
+ if (running) { closedStreak = 0; return; }
2826
+ closedStreak += 1;
2827
+ if (closedStreak < 2) return; // 连续两次(≈30s)判定不在运行才动手,避开退出/重启瞬间
2828
+ await applyPendingPluginUpdate({});
2829
+ closedStreak = 0;
2830
+ } catch { /* 绝不影响隧道业务 */ }
2831
+ };
2832
+ const t = setInterval(tick, 15_000);
2833
+ t.unref?.();
2834
+ void tick();
2835
+ return t;
2836
+ }
2837
+
2729
2838
  // test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
2730
2839
  export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
2731
2840
  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
  }