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 +18 -6
- package/lib/index.js +27 -2
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +136 -27
- package/runtime/dsh-setup.mjs +51 -6
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
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
* ·
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
728
|
-
if (
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
790
|
+
queued: dataQ.length,
|
|
782
791
|
controlQueued: controlQ.length,
|
|
783
792
|
closed
|
|
784
793
|
}),
|
|
785
|
-
hasBacklog: () =>
|
|
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); }
|
package/runtime/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(`⚠️
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
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
|
}
|