@mrrisega/dsh-remote 0.7.5 → 0.7.6-beta.10
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/clients/dsh-remote/dsh-bridge.mjs +166 -29
- package/clients/dsh-remote/e2ee-client.mjs +11 -6
- package/clients/dsh-remote/test/bridge-control-priority.test.mjs +12 -4
- package/clients/dsh-remote/test/e2ee-session-store.test.mjs +8 -11
- package/clients/dsh-remote/test/pending-plugin-update.test.mjs +96 -0
- package/dsh-setup.mjs +109 -10
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +138 -13
- package/packages/dsh-remote-web/lib/index.js +27 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +166 -29
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +11 -6
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +109 -10
- package/packages/dsh-remote-web/test/desktop-compat.test.mjs +7 -5
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +4 -2
- package/packages/dsh-remote-web/test/setup-output.test.mjs +17 -8
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +2 -2
|
@@ -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";
|
|
@@ -557,6 +558,19 @@ let upstreamRefreshInflight = null;
|
|
|
557
558
|
* 重新解析上游地址(带身份校验;显式 DSH_BRIDGE_UPSTREAM 时是恒等操作)。
|
|
558
559
|
* 上游端口变了(Desktop 换端口 / dsh web 重启到别的端口)时,靠它自愈而不必重启 bridge。
|
|
559
560
|
*/
|
|
561
|
+
/** 轻量探测上游是否活着(refreshUpstream 用;4xx/5xx 也算活着)。 */
|
|
562
|
+
async function upstreamReachableForRefresh(url, timeoutMs = 800) {
|
|
563
|
+
try {
|
|
564
|
+
const ac = new AbortController();
|
|
565
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
566
|
+
await fetch(String(url).replace(/\/+$/, "") + "/", { signal: ac.signal });
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
return true;
|
|
569
|
+
} catch {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
560
574
|
async function refreshUpstream(reason = "") {
|
|
561
575
|
if (upstreamRefreshInflight) return upstreamRefreshInflight;
|
|
562
576
|
const now = Date.now();
|
|
@@ -564,7 +578,22 @@ async function refreshUpstream(reason = "") {
|
|
|
564
578
|
upstreamRefreshedAt = now;
|
|
565
579
|
upstreamRefreshInflight = (async () => {
|
|
566
580
|
try {
|
|
567
|
-
|
|
581
|
+
let found = null;
|
|
582
|
+
// 🔴 2026-10-25(设备掉线实测):升级前装的 launchd plist 可能带着安装时的旧端口,
|
|
583
|
+
// 旧版 discoverUpstream 对「显式 env」**不探测直接采信** → 桥永久连旧端口、每次请求
|
|
584
|
+
// upstream_unreachable。这里先验证 env:死了就临时忽略它,按插件写的 .dsh-upstream/探测
|
|
585
|
+
// 重新发现;发现成功后清掉失效 env,后续都不再被它挡。
|
|
586
|
+
const envUrl = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "");
|
|
587
|
+
if (envUrl && !(await upstreamReachableForRefresh(envUrl))) {
|
|
588
|
+
const env2 = { ...process.env };
|
|
589
|
+
delete env2.DSH_BRIDGE_UPSTREAM;
|
|
590
|
+
found = await discoverUpstream({ relayDir: RELAY_DIR, env: env2 });
|
|
591
|
+
if (found && found.url && found.source !== "fallback") {
|
|
592
|
+
delete process.env.DSH_BRIDGE_UPSTREAM;
|
|
593
|
+
console.log(`[bridge] 旧上游 ${envUrl} 已不可达 → 改用 ${found.url}(来源 ${found.source})`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (!found) found = await discoverUpstream({ relayDir: RELAY_DIR });
|
|
568
597
|
if (found.url && found.url !== UPSTREAM) {
|
|
569
598
|
console.log(`[bridge] 上游地址切换: ${UPSTREAM} → ${found.url}(来源 ${found.source}${reason ? `,${reason}` : ""})`);
|
|
570
599
|
UPSTREAM = found.url;
|
|
@@ -630,7 +659,10 @@ export function dcSend(dc, obj) {
|
|
|
630
659
|
* · 每轮发送前先排空控制车道,并检查 `ws.bufferedAmount`:超过高水位就停手等它排空。
|
|
631
660
|
* 于是控制帧前面最多只有「高水位 + 一片」的待发字节 —— 默认 64KB + 64KB,手机 1Mbps
|
|
632
661
|
* 下 ≈1s,8Mbps 下 ≈0.13s,远小于 router 的 20s,也满足「大响应传输中 1s 内应答」目标。
|
|
633
|
-
* ·
|
|
662
|
+
* · **数据帧严格 FIFO**(2026-10-24 修):早期实现让小帧(交互/SSE 块)插到大帧下一片之前,
|
|
663
|
+
* 这在同一 WS 流里是**乱序** —— 生产表现为手机远程页「历史加载失败:session event stream
|
|
664
|
+
* emitted an entry before its opening cursor」。现在只有一个数据队列,大帧按片就地推进,
|
|
665
|
+
* 绝不因大小/类型被后来的帧超越;只有控制帧(不承载有序数据)保留插队权。
|
|
634
666
|
* · 阈值 / 分片大小可用环境变量调(见 tunnelBulkOptionsFromEnv),弱网部署可再调低。
|
|
635
667
|
*/
|
|
636
668
|
export const TUNNEL_BULK_HWM_DEFAULT = 64 * 1024; // 发送缓冲高水位(字节)
|
|
@@ -679,13 +711,19 @@ export function makePrioritySender({
|
|
|
679
711
|
let pumping = false;
|
|
680
712
|
let scheduled = false;
|
|
681
713
|
let timerPending = false;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
714
|
+
// 🔴 2026-10-24 事故修复:数据帧**必须严格 FIFO**。
|
|
715
|
+
// 旧实现分「小帧车道 / 大帧车道」,小帧会插到大帧下一片之前 —— 这违反了同一 WS 流里
|
|
716
|
+
// 消息的顺序语义,生产表现为手机远程页「历史加载失败:session event stream emitted an
|
|
717
|
+
// entry before its opening cursor(gateway/internal)」:事件流请求/响应被乱序送达,
|
|
718
|
+
// 客户端的游标推进到了前面,重连时服务端按该游标回放就触发 cursor 错乱。
|
|
719
|
+
// 现在:控制帧仍可插队(它们是协议控制帧,不承载有序数据);**数据帧只有一个队列**,
|
|
720
|
+
// 大帧按片就地推进,绝不因大小/类型被后来的帧超越。
|
|
721
|
+
const dataQ = []; // { frames: string[], i: number };frames.length===1 = 未分片小帧
|
|
722
|
+
const stats = { controlSent: 0, dataSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
|
|
723
|
+
|
|
724
|
+
function enqueueData(text) {
|
|
687
725
|
if (text.length <= chunkSize) {
|
|
688
|
-
|
|
726
|
+
dataQ.push({ frames: [text], i: 0 });
|
|
689
727
|
return;
|
|
690
728
|
}
|
|
691
729
|
const cid = `c${++chunkSeq}`;
|
|
@@ -695,7 +733,7 @@ export function makePrioritySender({
|
|
|
695
733
|
frames[i] = JSON.stringify({ __chunk: { id: cid, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } });
|
|
696
734
|
}
|
|
697
735
|
stats.bigFrames += 1;
|
|
698
|
-
|
|
736
|
+
dataQ.push({ frames, i: 0 });
|
|
699
737
|
}
|
|
700
738
|
|
|
701
739
|
function scheduleNext(waitMs) {
|
|
@@ -715,7 +753,7 @@ export function makePrioritySender({
|
|
|
715
753
|
if (pumping || closed) return;
|
|
716
754
|
pumping = true;
|
|
717
755
|
let yieldMode = ""; // ""=没有剩余,"schedule"=让出事件循环,"poll"=等缓冲排空
|
|
718
|
-
let budget =
|
|
756
|
+
let budget = 64; // 一轮最多发多少条未分片小帧(大帧每轮只发一片)
|
|
719
757
|
try {
|
|
720
758
|
for (;;) {
|
|
721
759
|
// ① 控制车道永远先走:哪怕大帧正传着 16MB,控制帧也在**下一轮**第一时间出去。
|
|
@@ -724,31 +762,31 @@ export function makePrioritySender({
|
|
|
724
762
|
try { rawSend(frame); stats.controlSent += 1; } catch { closed = true; }
|
|
725
763
|
}
|
|
726
764
|
if (closed) { yieldMode = ""; break; }
|
|
727
|
-
|
|
728
|
-
if (
|
|
729
|
-
|
|
765
|
+
if (dataQ.length === 0) { yieldMode = ""; break; }
|
|
766
|
+
if (budget <= 0) { yieldMode = "schedule"; break; }
|
|
767
|
+
const head = dataQ[0];
|
|
768
|
+
if (head.frames.length === 1) {
|
|
769
|
+
// 未分片小帧:可连着多发几条,但绝不越过队头(保证 FIFO)
|
|
770
|
+
try { rawSend(head.frames[0]); stats.dataSent += 1; } catch { closed = true; break; }
|
|
771
|
+
dataQ.shift();
|
|
730
772
|
budget -= 1;
|
|
731
773
|
continue; // 回环顶先查控制车道
|
|
732
774
|
}
|
|
733
|
-
|
|
734
|
-
if (bigQ.length === 0) { yieldMode = ""; break; }
|
|
735
|
-
// ③ 背压:已入队字节超过高水位 → 停手等排空,别把控制帧埋进几十秒的队列里。
|
|
775
|
+
// 队头是大帧:每轮只推一片,片间让出事件循环(控制帧/入站帧不被饿死),顺序不变
|
|
736
776
|
let pending = 0;
|
|
737
777
|
try { pending = Number(bufferedAmount()) || 0; } catch { pending = 0; }
|
|
738
778
|
if (pending > bulkHighWater) { stats.waits += 1; yieldMode = "poll"; break; }
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
yieldMode = "schedule"; // 大帧:一片一让,给入站帧 / 控制帧 / 小帧机会
|
|
779
|
+
try { rawSend(head.frames[head.i]); stats.dataSent += 1; stats.bigChunks += 1; } catch { closed = true; break; }
|
|
780
|
+
head.i += 1;
|
|
781
|
+
if (head.i >= head.frames.length) dataQ.shift();
|
|
782
|
+
yieldMode = "schedule";
|
|
744
783
|
break;
|
|
745
784
|
}
|
|
746
785
|
} finally {
|
|
747
786
|
pumping = false;
|
|
748
787
|
if (closed) {
|
|
749
788
|
controlQ.length = 0;
|
|
750
|
-
|
|
751
|
-
bigQ.length = 0;
|
|
789
|
+
dataQ.length = 0;
|
|
752
790
|
} else if (yieldMode) {
|
|
753
791
|
scheduleNext(yieldMode === "poll" ? pollMs : 0);
|
|
754
792
|
}
|
|
@@ -760,7 +798,7 @@ export function makePrioritySender({
|
|
|
760
798
|
try { text = JSON.stringify(obj); } catch { return false; }
|
|
761
799
|
if (closed) { stats.dropped += 1; return false; }
|
|
762
800
|
if (isControlFrame(obj)) controlQ.push(text);
|
|
763
|
-
else
|
|
801
|
+
else enqueueData(text);
|
|
764
802
|
pump();
|
|
765
803
|
return true;
|
|
766
804
|
}
|
|
@@ -769,8 +807,7 @@ export function makePrioritySender({
|
|
|
769
807
|
function close() {
|
|
770
808
|
closed = true;
|
|
771
809
|
controlQ.length = 0;
|
|
772
|
-
|
|
773
|
-
bigQ.length = 0;
|
|
810
|
+
dataQ.length = 0;
|
|
774
811
|
}
|
|
775
812
|
|
|
776
813
|
return {
|
|
@@ -778,11 +815,11 @@ export function makePrioritySender({
|
|
|
778
815
|
close,
|
|
779
816
|
stats: () => ({
|
|
780
817
|
...stats,
|
|
781
|
-
queued:
|
|
818
|
+
queued: dataQ.length,
|
|
782
819
|
controlQueued: controlQ.length,
|
|
783
820
|
closed
|
|
784
821
|
}),
|
|
785
|
-
hasBacklog: () =>
|
|
822
|
+
hasBacklog: () => dataQ.length > 0 || controlQ.length > 0
|
|
786
823
|
};
|
|
787
824
|
}
|
|
788
825
|
|
|
@@ -2302,7 +2339,7 @@ async function initE2ee(token) {
|
|
|
2302
2339
|
const storeEnabled = process.env.DSH_BRIDGE_E2EE_SESSIONS !== "0";
|
|
2303
2340
|
const store = e2ee.attachStore(storeDir, { enabled: storeEnabled });
|
|
2304
2341
|
if (store.restored > 0) {
|
|
2305
|
-
console.log(`[bridge] 🔓 已恢复 ${store.restored} 个 E2EE 会话(
|
|
2342
|
+
console.log(`[bridge] 🔓 已恢复 ${store.restored} 个 E2EE 会话(加密落盘;安全策略=v2:全部标记「需重新握手」,不复用旧计数)`);
|
|
2306
2343
|
}
|
|
2307
2344
|
if (store.discarded) {
|
|
2308
2345
|
// 丢弃 = 手机手里的旧票必然对不上:后续 ws-open/HTTP 会带 retryable 的 rehandshake 信号,
|
|
@@ -2723,9 +2760,109 @@ async function main() {
|
|
|
2723
2760
|
console.log("[bridge] 单实例锁检查降级(取不到进程命令行或锁不可写)——照常启动;若出现 code=4000 互踢,本实例会自动让位。");
|
|
2724
2761
|
}
|
|
2725
2762
|
process.on("exit", releaseInstanceLock);
|
|
2763
|
+
// 桌面端插件「暂存更新」自动安装(应用退出后生效;见 applyPendingPluginUpdate 注释)
|
|
2764
|
+
startPendingPluginUpdateWatcher();
|
|
2726
2765
|
return runTunnel();
|
|
2727
2766
|
}
|
|
2728
2767
|
|
|
2768
|
+
// ── 桌面端插件「暫存安装」落地(2026-10-24)────────────────────────────────────────────
|
|
2769
|
+
// dsh-setup 在桌面应用运行时无法直接改写它的 profile(会触发应用退出/重启),改为暂存到
|
|
2770
|
+
// <relayDir>/pending-plugin/ 并写 marker;bridge 常驻、检测到应用退出后自动原子替换插件目录,
|
|
2771
|
+
// 用户下次打开应用即加载新版 —— 这样「一键更新」对桌面端也能真正生效(不再需要终端)。
|
|
2772
|
+
const PENDING_PLUGIN_MARKER = path.join(RELAY_DIR, ".plugin-update-pending.json");
|
|
2773
|
+
const PENDING_PLUGIN_REQUIRED = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
|
|
2774
|
+
const PENDING_PLUGIN_DISABLED = process.env.DSH_BRIDGE_PENDING_UPDATE === "0";
|
|
2775
|
+
|
|
2776
|
+
/** upstream 端口是否在监听(= DeepSeek Harness 桌面端在运行)。TCP 判断,跨平台。 */
|
|
2777
|
+
export function probeUpstreamListening({ host = "127.0.0.1", port, timeoutMs = 400 } = {}) {
|
|
2778
|
+
return new Promise((resolve) => {
|
|
2779
|
+
if (!port) return resolve(false);
|
|
2780
|
+
const sock = net.connect({ host, port });
|
|
2781
|
+
let done = false;
|
|
2782
|
+
const finish = (v) => { if (done) return; done = true; try { sock.destroy(); } catch { /* 忽略 */ } resolve(v); };
|
|
2783
|
+
sock.setTimeout(timeoutMs, () => finish(false));
|
|
2784
|
+
sock.once("connect", () => finish(true));
|
|
2785
|
+
sock.once("error", () => finish(false));
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
function upstreamPortOf(url) {
|
|
2789
|
+
try { const u = new URL(String(url)); return Number(u.port) || (u.protocol === "https:" ? 443 : 80); } catch { return 0; }
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
/**
|
|
2793
|
+
* 应用暂存的插件更新(仅在桌面应用不在运行时)。原子替换:staging → rename,失败保留旧版。
|
|
2794
|
+
* @returns {{applied:boolean, reason?:string, version?:string, error?:string}}
|
|
2795
|
+
*/
|
|
2796
|
+
export async function applyPendingPluginUpdate({ relayDir = RELAY_DIR, isAppRunning, now = Date.now(), log = console } = {}) {
|
|
2797
|
+
const markerPath = path.join(relayDir, ".plugin-update-pending.json");
|
|
2798
|
+
let marker;
|
|
2799
|
+
try { marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); } catch { return { applied: false, reason: "no_pending" }; }
|
|
2800
|
+
const stageDir = String(marker.stageDir || "");
|
|
2801
|
+
const profileDir = String(marker.profileDir || "");
|
|
2802
|
+
const name = String(marker.pluginDirName || "dsh-remote-web-plugin");
|
|
2803
|
+
if (!stageDir || !profileDir || !fs.existsSync(stageDir)) {
|
|
2804
|
+
try { fs.rmSync(markerPath, { force: true }); } catch { /* 忽略 */ }
|
|
2805
|
+
return { applied: false, reason: "staging_missing" };
|
|
2806
|
+
}
|
|
2807
|
+
const running = typeof isAppRunning === "function"
|
|
2808
|
+
? await isAppRunning()
|
|
2809
|
+
: await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
|
|
2810
|
+
if (running) return { applied: false, reason: "app_running" };
|
|
2811
|
+
const dest = path.join(profileDir, name);
|
|
2812
|
+
const stage = `${dest}.staging-${process.pid}`;
|
|
2813
|
+
const backup = `${dest}.backup-${process.pid}`;
|
|
2814
|
+
try {
|
|
2815
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
2816
|
+
fs.cpSync(stageDir, stage, { recursive: true });
|
|
2817
|
+
for (const rel of PENDING_PLUGIN_REQUIRED) {
|
|
2818
|
+
if (!fs.existsSync(path.join(stage, rel))) throw new Error(`暂存包缺少 ${rel}`);
|
|
2819
|
+
}
|
|
2820
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
2821
|
+
const had = fs.existsSync(dest);
|
|
2822
|
+
if (had) fs.renameSync(dest, backup);
|
|
2823
|
+
try {
|
|
2824
|
+
fs.renameSync(stage, dest);
|
|
2825
|
+
} catch (e) {
|
|
2826
|
+
if (had) { try { fs.renameSync(backup, dest); } catch { /* 忽略 */ } }
|
|
2827
|
+
throw e;
|
|
2828
|
+
}
|
|
2829
|
+
if (had) { try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* 忽略 */ } }
|
|
2830
|
+
try {
|
|
2831
|
+
fs.writeFileSync(
|
|
2832
|
+
path.join(relayDir, ".plugin-update-applied.json"),
|
|
2833
|
+
JSON.stringify({ version: marker.version, appliedAt: now, profileDir }, null, 2) + "\n"
|
|
2834
|
+
);
|
|
2835
|
+
} catch { /* 忽略 */ }
|
|
2836
|
+
try { fs.rmSync(markerPath, { force: true }); fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
|
|
2837
|
+
log.info?.(`[bridge] ✅ 插件新版本 ${marker.version} 已自动安装到 ${profileDir}(桌面应用退出后);重新打开应用即生效`);
|
|
2838
|
+
return { applied: true, version: marker.version };
|
|
2839
|
+
} catch (e) {
|
|
2840
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* 忽略 */ }
|
|
2841
|
+
log.warn?.(`[bridge] 插件暂存安装失败(保留旧版可回退):${e && e.message ? e.message : e}`);
|
|
2842
|
+
return { applied: false, reason: "apply_failed", error: String((e && e.message) || e) };
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
function startPendingPluginUpdateWatcher() {
|
|
2847
|
+
if (PENDING_PLUGIN_DISABLED) return null;
|
|
2848
|
+
let closedStreak = 0;
|
|
2849
|
+
const tick = async () => {
|
|
2850
|
+
try {
|
|
2851
|
+
if (!fs.existsSync(PENDING_PLUGIN_MARKER)) { closedStreak = 0; return; }
|
|
2852
|
+
const running = await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
|
|
2853
|
+
if (running) { closedStreak = 0; return; }
|
|
2854
|
+
closedStreak += 1;
|
|
2855
|
+
if (closedStreak < 2) return; // 连续两次(≈30s)判定不在运行才动手,避开退出/重启瞬间
|
|
2856
|
+
await applyPendingPluginUpdate({});
|
|
2857
|
+
closedStreak = 0;
|
|
2858
|
+
} catch { /* 绝不影响隧道业务 */ }
|
|
2859
|
+
};
|
|
2860
|
+
const t = setInterval(tick, 15_000);
|
|
2861
|
+
t.unref?.();
|
|
2862
|
+
void tick();
|
|
2863
|
+
return t;
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2729
2866
|
// test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
|
|
2730
2867
|
export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
|
|
2731
2868
|
export function __armMirrorLoadWatch(path) { armMirrorLoadWatch(path); }
|
|
@@ -792,14 +792,17 @@ export class E2eeService {
|
|
|
792
792
|
profile: String(row.profile || this.profile),
|
|
793
793
|
epoch: Number(row.epoch) || 0
|
|
794
794
|
});
|
|
795
|
-
|
|
795
|
+
// ★ 2026-10-24 安全升级:从磁盘恢复的会话**一律标记未验证**,必须重新握手后才接受数据流。
|
|
796
|
+
// 原因:落盘的收发计数可能落后于手机侧(桥在 flush 与退出之间重启过),直接复用旧计数
|
|
797
|
+
// 会导致 nonce 重放/解不开 → 生产「无法解密」。宁可让手机自动重握手一次,也不冒错帧风险。
|
|
798
|
+
session.verified = false;
|
|
799
|
+
session.restoredNeedsHandshake = true;
|
|
796
800
|
session.createdAt = Number(row.createdAt) || usedAt;
|
|
797
801
|
session.usedAt = usedAt;
|
|
798
802
|
this.sessions.set(sessId, session);
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
this.ctrlCounters.set(sessId, guards);
|
|
803
|
+
// ★ 计数同样**不恢复**:恢复的会话未验证,重新握手会从 0 重新计数;
|
|
804
|
+
// 继承旧计数只会在下一次探测时误判「nonce 重放」。
|
|
805
|
+
this.ctrlCounters.set(sessId, { p2b: new SequenceGuard(), b2p: new SequenceGuard() });
|
|
803
806
|
} catch { /* 单条坏 → 跳过,不影响其它会话 */ }
|
|
804
807
|
}
|
|
805
808
|
this.prune();
|
|
@@ -932,7 +935,9 @@ export class E2eeService {
|
|
|
932
935
|
// 会话持久化原语(2026-10-23;方案/信任边界见 E2eeService.attachStore 上方长注释)
|
|
933
936
|
// ============================================================
|
|
934
937
|
|
|
935
|
-
|
|
938
|
+
// 2026-10-24 → 2:0.7.5 的 v1 库恢复后直接复用旧计数,生产出现「nonce 重放/无法解密」。
|
|
939
|
+
// 升级到 v2 时**旧库一律作废**(restore 里版本不符会 clearSessionStore),手机重新握手一次。
|
|
940
|
+
export const SESSION_STORE_VERSION = 2;
|
|
936
941
|
export const SESSION_STORE_FILE = ".e2ee-sessions.enc";
|
|
937
942
|
/** 会话库 AAD(域分隔;换格式/换用途必须改它,旧文件自动解不开 → 丢弃重握手)。 */
|
|
938
943
|
export const SESSION_STORE_AAD = Buffer.from(`${E2EE_DOMAIN}\x00sessions-store-v1`, "utf8");
|
|
@@ -1127,7 +1127,15 @@ const UPSTREAM_FILE = ".dsh-upstream";
|
|
|
1127
1127
|
* 本次安装要**钉住**的上游(null = 不钉,运行时按 env → .dsh-upstream → 探测 取值)。
|
|
1128
1128
|
* 见 desktopAppPort() 的注释:插件装进多个 profile 后,各宿主会互相覆盖 `.dsh-upstream`。
|
|
1129
1129
|
*/
|
|
1130
|
-
|
|
1130
|
+
// 🔴 2026-10-25 事故:以前把安装进程里的 DSH_BRIDGE_UPSTREAM(一个**当时的动态端口**)写进
|
|
1131
|
+
// launchd plist,之后 dsh 每次重启换端口,plist 里的旧端口就成了永久生效的"显式指定",
|
|
1132
|
+
// 守护/桥永远连不上 → 设备掉线。现在默认**不钉**(运行时按 插件写的 .dsh-upstream → 探测 取值);
|
|
1133
|
+
// 仅当部署方显式要求(DSH_BRIDGE_PIN_UPSTREAM=1)时才沿用旧行为。
|
|
1134
|
+
let PINNED_UPSTREAM = (() => {
|
|
1135
|
+
const pinFlag = String(process.env.DSH_BRIDGE_PIN_UPSTREAM || "").trim().toLowerCase();
|
|
1136
|
+
if (pinFlag !== "1" && pinFlag !== "true" && pinFlag !== "on") return null;
|
|
1137
|
+
return String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "") || null;
|
|
1138
|
+
})();
|
|
1131
1139
|
|
|
1132
1140
|
/**
|
|
1133
1141
|
* 探测桌面端(Electron 应用)当前监听的**回环端口**。
|
|
@@ -1223,6 +1231,18 @@ async function upstreamReachable(url = upstreamUrl(), timeoutMs = 1200) {
|
|
|
1223
1231
|
*/
|
|
1224
1232
|
async function isDshWebUp(timeoutMs = 1200) {
|
|
1225
1233
|
if (await upstreamReachable(upstreamUrl(), timeoutMs)) return true;
|
|
1234
|
+
// 🔴 2026-10-25(设备掉线实测):Electron 每次启动可能换端口。端口文件由 dsh web 内的插件
|
|
1235
|
+
// 在每次启动时重写,比进程内的 discoveredUpstream 缓存新 —— 先直接试文件,别被旧缓存挡住。
|
|
1236
|
+
try {
|
|
1237
|
+
const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
|
|
1238
|
+
if (/^https?:\/\/[^\s]+$/i.test(fromFile)) {
|
|
1239
|
+
const normalized = fromFile.replace(/\/+$/, "");
|
|
1240
|
+
if (normalized !== upstreamUrl() && (await upstreamReachable(normalized, timeoutMs))) {
|
|
1241
|
+
discoveredUpstream = normalized;
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
} catch { /* 没有端口文件:继续走动态发现 */ }
|
|
1226
1246
|
const before = upstreamUrl();
|
|
1227
1247
|
const after = await refreshUpstreamDiscovery(true);
|
|
1228
1248
|
if (after === before) return false;
|
|
@@ -1659,7 +1679,22 @@ function releaseSpawnLock() {
|
|
|
1659
1679
|
}
|
|
1660
1680
|
|
|
1661
1681
|
// ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
|
|
1682
|
+
/** 以插件写入的 .dsh-upstream 为准校正进程内的旧上游环境变量(dsh 换端口后不再连旧端口)。 */
|
|
1683
|
+
function reconcileUpstreamEnvFromFile() {
|
|
1684
|
+
try {
|
|
1685
|
+
const fileUp = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim().replace(/\/+$/, "");
|
|
1686
|
+
const envUp = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "");
|
|
1687
|
+
if (envUp && /^https?:\/\/[^\s]+$/i.test(fileUp) && fileUp !== envUp) {
|
|
1688
|
+
console.log(`[dsh-remote] 忽略旧的上游地址 ${envUp} → 使用插件写入的最新地址 ${fileUp}(防端口漂移掉线)`);
|
|
1689
|
+
process.env.DSH_BRIDGE_UPSTREAM = fileUp;
|
|
1690
|
+
discoveredUpstream = fileUp;
|
|
1691
|
+
}
|
|
1692
|
+
} catch { /* 无文件:保持原行为 */ }
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1662
1695
|
async function runBridge() {
|
|
1696
|
+
// 启动/每次拉起前都按插件写入的端口校正继承来的旧 env(旧 plist 可能钉着安装时的端口)
|
|
1697
|
+
reconcileUpstreamEnvFromFile();
|
|
1663
1698
|
// 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
|
|
1664
1699
|
// 两个 bridge 会抢同一个设备登记 → 以 pid 文件为准。
|
|
1665
1700
|
//
|
|
@@ -1856,9 +1891,13 @@ async function runBridge() {
|
|
|
1856
1891
|
}
|
|
1857
1892
|
warnedDuplicateBridge = false;
|
|
1858
1893
|
|
|
1894
|
+
reconcileUpstreamEnvFromFile(); // dsh 可能刚重启并换了端口:拉起前再以最新端口文件为准
|
|
1859
1895
|
const alive = await checkUpstream();
|
|
1860
1896
|
if (alive && childStopped(bridgeProc)) {
|
|
1861
1897
|
starting = true;
|
|
1898
|
+
// dsh 重启可能换端口(Electron 每次启动端口可能变):拉起前**强制重新发现**,
|
|
1899
|
+
// 否则 discoveredUpstream 缓存旧端口 → bridge 连不上上游 → 设备掉线(2026-10-25 实测)。
|
|
1900
|
+
await refreshUpstreamDiscovery(true);
|
|
1862
1901
|
console.log("[dsh-remote] dsh web 在线,启动 bridge...");
|
|
1863
1902
|
// 清除代理环境变量(bridge 需直连 relay,不受本机代理影响)
|
|
1864
1903
|
const childEnv = {
|
|
@@ -1900,6 +1939,17 @@ async function runBridge() {
|
|
|
1900
1939
|
// 另一个 bridge 把本连接顶掉)后会以"重复实例"退出码优雅退出(见 dsh-bridge.mjs)。
|
|
1901
1940
|
// 这种退出**绝不能**照常 10s 重拉:本机已经有另一个实例在跑,重拉只会继续乒乓互踢。
|
|
1902
1941
|
if (code === DUPLICATE_INSTANCE_EXIT_CODE) {
|
|
1942
|
+
// 🔴 2026-10-25:升级/重启后常见「上一代 bridge 变成孤儿继续占着单实例锁」,
|
|
1943
|
+
// 新 bridge 因此退出 code=4。旧逻辑一律暂停 5 分钟 → 设备在线要等 5 分钟(用户实测掉线)。
|
|
1944
|
+
// 现在先**清扫同运行时的孤儿 bridge**;扫到了说明冲突已解除,3 秒后立即重试;
|
|
1945
|
+
// 扫不到(多半真有另一个 watcher 的活实例)才按 5 分钟退避,避免乒乓互踢。
|
|
1946
|
+
let swept = [];
|
|
1947
|
+
try { swept = sweepStrayBridges(); } catch { /* 清扫失败不致命 */ }
|
|
1948
|
+
if (swept.length > 0) {
|
|
1949
|
+
duplicateBridgeBackoffUntil = Date.now() + 3000;
|
|
1950
|
+
console.log(`[dsh-remote] bridge 因同实例冲突退出(code=${code}),已清扫 ${swept.length} 个孤儿 bridge(pid ${swept.join(", ")})→ 3 秒后重试。`);
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1903
1953
|
duplicateBridgeBackoffUntil = Date.now() + DUPLICATE_BRIDGE_BACKOFF_MS;
|
|
1904
1954
|
console.log(`[dsh-remote] bridge 检测到同 deviceId 的另一个实例并退出(code=${code})——本守护暂停 ${Math.round(DUPLICATE_BRIDGE_BACKOFF_MS / 60000)} 分钟后再试,避免两个实例反复互踢。`);
|
|
1905
1955
|
return;
|
|
@@ -1913,6 +1963,8 @@ async function runBridge() {
|
|
|
1913
1963
|
setTimeout(() => { starting = false; }, 5000);
|
|
1914
1964
|
} else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
|
|
1915
1965
|
console.log("[dsh-remote] dsh web 离线,停止 bridge...");
|
|
1966
|
+
// dsh 重启后端口可能变化:清掉进程内缓存,下一轮优先用插件新写的端口文件探测
|
|
1967
|
+
discoveredUpstream = "";
|
|
1916
1968
|
await stopChildGracefully(bridgeProc); // 同上:Windows 上 kill() 跑不到退出钩子
|
|
1917
1969
|
}
|
|
1918
1970
|
};
|
|
@@ -2024,9 +2076,11 @@ async function setup(argv) {
|
|
|
2024
2076
|
if (!PINNED_UPSTREAM && pluginTargets.some((t) => t.name === "desktop")) {
|
|
2025
2077
|
const p = desktopAppPort();
|
|
2026
2078
|
if (p) {
|
|
2027
|
-
PINNED_UPSTREAM
|
|
2028
|
-
|
|
2029
|
-
|
|
2079
|
+
// 🔴 2026-10-25:这里只能写运行时端口文件;**不能**回填 PINNED_UPSTREAM ——
|
|
2080
|
+
// 它会被写进 launchd plist 变成永久显式指定,dsh 换端口后守护/桥永远连旧端口
|
|
2081
|
+
// (用户实测 upstream_unreachable)。
|
|
2082
|
+
try { fs.writeFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), `http://127.0.0.1:${p}` + "\n"); } catch { /* 忽略 */ }
|
|
2083
|
+
console.log(`ℹ 桌面端在 127.0.0.1:${p} 监听 → 已写入运行时上游端口(不再钉进自启动配置)`);
|
|
2030
2084
|
}
|
|
2031
2085
|
}
|
|
2032
2086
|
} catch { /* 探测失败不阻断安装 */ }
|
|
@@ -2583,6 +2637,45 @@ function missingPluginFiles(dir) {
|
|
|
2583
2637
|
*
|
|
2584
2638
|
* 另外清掉上次被强杀留下的 `.staging-*` / `.backup-*`,不留垃圾。
|
|
2585
2639
|
*/
|
|
2640
|
+
// ── 桌面端「暫存安装」:应用运行中不能改写它的 profile(会把它弄退出/重启),
|
|
2641
|
+
// 但用户点「一键更新」必须能装到桌面端。做法:先整份拷到 ~/.dsh-remote/pending-plugin/ 并写 marker,
|
|
2642
|
+
// 由常驻的 bridge 在检测到应用退出后自动原子替换 profile 内的插件目录(见 dsh-bridge.mjs)。
|
|
2643
|
+
const PENDING_PLUGIN_MARKER = path.join(CONFIG_DIR, ".plugin-update-pending.json");
|
|
2644
|
+
const PENDING_PLUGIN_DIR = path.join(CONFIG_DIR, "pending-plugin");
|
|
2645
|
+
|
|
2646
|
+
/** 暂存一份插件更新(供 bridge 在应用退出后安装)。返回 marker 对象。 */
|
|
2647
|
+
function stagePluginUpdateForProfile(profileDir, pluginDir) {
|
|
2648
|
+
fs.rmSync(PENDING_PLUGIN_DIR, { recursive: true, force: true });
|
|
2649
|
+
fs.mkdirSync(PENDING_PLUGIN_DIR, { recursive: true });
|
|
2650
|
+
const cpOpts = {
|
|
2651
|
+
recursive: true,
|
|
2652
|
+
filter: (src) => {
|
|
2653
|
+
if (src === pluginDir) return true;
|
|
2654
|
+
const rel = String(path.relative(pluginDir, src));
|
|
2655
|
+
const segs = rel.split(path.sep);
|
|
2656
|
+
if (segs.some((seg) => seg === ".git" || seg === ".DS_Store")) return false;
|
|
2657
|
+
const inBundledRuntime = segs[0] === "runtime";
|
|
2658
|
+
if (!inBundledRuntime && segs.some((seg) => seg === "node_modules")) return false;
|
|
2659
|
+
return true;
|
|
2660
|
+
}
|
|
2661
|
+
};
|
|
2662
|
+
fs.cpSync(pluginDir, PENDING_PLUGIN_DIR, cpOpts);
|
|
2663
|
+
for (const rel of PLUGIN_REQUIRED_FILES) {
|
|
2664
|
+
const f = path.join(PENDING_PLUGIN_DIR, rel);
|
|
2665
|
+
if (!fs.existsSync(f)) throw new Error(`暂存校验失败:缺少 ${rel}`);
|
|
2666
|
+
}
|
|
2667
|
+
const version = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf8")).version;
|
|
2668
|
+
const marker = {
|
|
2669
|
+
version,
|
|
2670
|
+
profileDir,
|
|
2671
|
+
pluginDirName: PLUGIN_LOCAL_DIR,
|
|
2672
|
+
stageDir: PENDING_PLUGIN_DIR,
|
|
2673
|
+
stagedAt: Date.now()
|
|
2674
|
+
};
|
|
2675
|
+
fs.writeFileSync(PENDING_PLUGIN_MARKER, JSON.stringify(marker, null, 2) + "\n");
|
|
2676
|
+
return marker;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2586
2679
|
function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
2587
2680
|
// 清理历史名残留目录(≤0.4.9 的 dsh-remote-ui-plugin),避免与新版并存
|
|
2588
2681
|
for (const id of PLUGIN_LEGACY_IDS) {
|
|
@@ -3031,16 +3124,21 @@ async function pluginCmd(argv) {
|
|
|
3031
3124
|
let last = null;
|
|
3032
3125
|
let ok = 0;
|
|
3033
3126
|
let skippedRunning = 0;
|
|
3127
|
+
let stagedRunning = 0;
|
|
3034
3128
|
const force = hasFlag(argv, "--force");
|
|
3035
3129
|
for (const t of targets) {
|
|
3036
3130
|
// ⚠️ 桌面应用在运行时**绝不改写它的 profile**:应用会因此退出/重启(2026-10-11 实测故障)
|
|
3037
3131
|
if (t.name === "desktop" && !force && dshDesktopAppRunning()) {
|
|
3038
|
-
console.log(`⚠️
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3132
|
+
console.log(`⚠️ DeepSeek Harness 应用正在运行 → 不直接改写它的 profile(会把它弄退出/重启)。`);
|
|
3133
|
+
try {
|
|
3134
|
+
const staged = stagePluginUpdateForProfile(t.dir, path.join(THIS_DIR, "packages", PLUGIN_ID));
|
|
3135
|
+
stagedRunning += 1;
|
|
3136
|
+
console.log(` ✅ 新版本 ${staged.version} 已暂存:**退出 DeepSeek Harness 后会自动安装**,重新打开应用即生效(无需终端)。`);
|
|
3137
|
+
} catch (e) {
|
|
3138
|
+
console.log(` ⚠️ 暂存失败:${e && e.message ? e.message : e}`);
|
|
3139
|
+
console.log(` 请完全退出应用后重跑本命令,或在应用内「插件」页安装/更新 dsh-remote-web。`);
|
|
3140
|
+
skippedRunning += 1;
|
|
3141
|
+
}
|
|
3044
3142
|
continue;
|
|
3045
3143
|
}
|
|
3046
3144
|
if (multi) console.log(`\n── profile: ${t.name}(${t.dir})`);
|
|
@@ -3050,6 +3148,7 @@ async function pluginCmd(argv) {
|
|
|
3050
3148
|
if (r !== undefined && r !== null) last = r;
|
|
3051
3149
|
}
|
|
3052
3150
|
if (ok === 0 && skippedRunning === 0) process.exit(1);
|
|
3151
|
+
if (stagedRunning > 0) console.log(`ℹ 桌面端插件已暂存(${stagedRunning} 个):退出 DeepSeek Harness 后由后台服务自动安装,重开应用生效。`);
|
|
3053
3152
|
if (skippedRunning > 0) console.log(`ℹ 本次跳过了 ${skippedRunning} 个 profile(应用在运行);桥与运行环境不受影响。`);
|
|
3054
3153
|
return last;
|
|
3055
3154
|
}
|
|
@@ -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-
|
|
171
|
-
//
|
|
172
|
-
assert.
|
|
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 业主约定)", () => {
|
|
@@ -193,11 +193,13 @@ test("入口:settings.section 栏目(id/order/label)+ 侧栏底部官方
|
|
|
193
193
|
assert.ok(String(label).includes("📱"), "栏目名应带 📱 远程访问图标(手机访问语义)");
|
|
194
194
|
assert.ok(!String(label).includes("远程控制"), "栏目名不应再残留旧称「远程控制」");
|
|
195
195
|
|
|
196
|
-
// shell.overlay
|
|
196
|
+
// shell.overlay:满意度弹窗 + 公告弹窗 + 「远程访问」独立弹层(2026-10-25 新增)
|
|
197
197
|
assert.ok(plugin.injects.has("shell.overlay"));
|
|
198
198
|
plugin.injects.get("shell.overlay")();
|
|
199
199
|
assert.ok(plugin.registered.has("dsh-feedback-popup"), "满意度弹窗应保留在 shell.overlay");
|
|
200
|
-
assert.
|
|
200
|
+
assert.ok(plugin.registered.has("dsh-remote-standalone"),
|
|
201
|
+
"入口点击要弹**独立**的远程访问设置页(不再依赖官方设置面板 DOM)");
|
|
202
|
+
assert.equal(plugin.registered.has("dsh-remote-panel"), false, "旧的浮动配置面板不应复活");
|
|
201
203
|
});
|
|
202
204
|
|
|
203
205
|
test("入口迁移:settings.section 里**只有一个**栏目(id=dsh-remote)——微信机器人不再是第二个栏目", () => {
|
|
@@ -214,17 +214,23 @@ test("★ launchd bootout 后必须等 label 摘干净再 bootstrap(否则随
|
|
|
214
214
|
assert.match(fn, /bootok|!boot\.ok[\s\S]{0,400}launchctl bootstrap/, "bootstrap 失败应重试一次再兜底");
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
-
test("★
|
|
217
|
+
test("★ 自启动配置**默认不钉**上游端口;仅在 DSH_BRIDGE_PIN_UPSTREAM=1 时显式 opt-in", () => {
|
|
218
|
+
// 2026-10-25 事故:把安装时的动态端口写进 launchd plist 后,dsh 每次重启换端口,
|
|
219
|
+
// 守护/桥就永远连旧端口(upstream_unreachable / 设备掉线)。所以默认改为不钉。
|
|
218
220
|
const src = readFileSync(SETUP, "utf8");
|
|
219
221
|
assert.match(src, /function desktopAppPort\(/, "要有桌面端端口探测(lsof -c DeepSeek)");
|
|
220
|
-
assert.match(src, /
|
|
221
|
-
// plist
|
|
222
|
+
assert.match(src, /DSH_BRIDGE_PIN_UPSTREAM/, "要有显式 opt-in 开关(部署方需要固定上游时用)");
|
|
223
|
+
// plist 模板仍支持写 DSH_BRIDGE_UPSTREAM(opt-in 时),但默认 PINNED_UPSTREAM 必须为空
|
|
222
224
|
assert.match(src, /DSH_BRIDGE_UPSTREAM<\/key><string>\$\{PINNED_UPSTREAM\}/,
|
|
223
|
-
"plist
|
|
224
|
-
//
|
|
225
|
-
|
|
225
|
+
"opt-in 时 plist 仍要能写入 DSH_BRIDGE_UPSTREAM");
|
|
226
|
+
// 关键回归锁:安装流程里不能再出现「把探测到的端口赋给 PINNED_UPSTREAM」
|
|
227
|
+
assert.doesNotMatch(src, /PINNED_UPSTREAM = `http:\/\/127\.0\.0\.1:\$\{p\}`/,
|
|
228
|
+
"不得再把动态端口回填进 PINNED_UPSTREAM(否则又会写进 plist,dsh 换端口即掉线)");
|
|
229
|
+
// 运行时端口仍必须写进 .dsh-upstream(守护/桥按它取最新端口)
|
|
230
|
+
assert.match(src, /UPSTREAM_FILE\)[^\n]*\n/);
|
|
231
|
+
const writeFileAt = src.indexOf("fs.writeFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE)");
|
|
226
232
|
const svcAt = src.indexOf("svc = installAutostart()");
|
|
227
|
-
assert.ok(
|
|
233
|
+
assert.ok(writeFileAt > 0 && svcAt > 0 && writeFileAt < svcAt, "运行时端口文件要在 installAutostart() 之前写好");
|
|
228
234
|
});
|
|
229
235
|
|
|
230
236
|
test("★ 桌面应用在运行时,绝不改写它的 profile(否则应用会自己退出/重启)", () => {
|
|
@@ -240,7 +246,10 @@ test("★ 桌面应用在运行时,绝不改写它的 profile(否则应用
|
|
|
240
246
|
const applyAt = src.indexOf("await pluginApplyTo(", loopAt);
|
|
241
247
|
assert.ok(loopAt > 0 && guardAt > loopAt && applyAt > guardAt,
|
|
242
248
|
"顺序必须是:循环 → 应用在运行?→ 跳过;否则才 pluginApplyTo()");
|
|
243
|
-
|
|
249
|
+
// 2026-10-24:应用在运行时不再只是"跳过",而是把插件更新**暂存**,由 bridge 在应用退出后自动安装。
|
|
250
|
+
assert.match(src, /stagePluginUpdateForProfile\(/, "应用在运行时必须暂存插件更新(否则一键更新永远装不上桌面端)");
|
|
251
|
+
assert.match(src, /\.plugin-update-pending\.json/, "暂存要写 marker 供 bridge 落地");
|
|
252
|
+
assert.match(src, /退出 DeepSeek Harness 后会自动安装/, "要如实告诉用户:退出应用后自动安装、重开生效");
|
|
244
253
|
assert.match(src, /应用内「插件」页安装/, "要给出官方替代路径(应用内插件页)");
|
|
245
254
|
assert.match(src, /"--force"/, "要留 --force 逃生口");
|
|
246
255
|
// sync-local 脚本同样要保护(本机同步最常用)
|
|
@@ -467,8 +467,8 @@ test("主题不变量:根节点带 .dru-settings-section + data-dru-theme;
|
|
|
467
467
|
assert.doesNotMatch(block, /--dru-[a-z0-9-]+\s*:/, "新样式不得**声明**令牌(只许引用)");
|
|
468
468
|
assert.doesNotMatch(block, /:root|\bbody\b/, "新样式不得挂在 :root/body 上(会污染整个 GUI)");
|
|
469
469
|
assert.match(block, /var\(--dru-/, "新样式应复用既有令牌");
|
|
470
|
-
//
|
|
471
|
-
assert.match(SOURCE, /"\.dru-settings-section,\.dru-popup,\.dru-nav-remote\{"/, "
|
|
470
|
+
// 令牌声明只允许出现在那四个选择器上(2026-10-25 新增 .dru-standalone-card:独立远程访问弹层)
|
|
471
|
+
assert.match(SOURCE, /"\.dru-settings-section,\.dru-popup,\.dru-nav-remote,\.dru-standalone-card\{"/, "亮色令牌块的选择器不得再扩大");
|
|
472
472
|
assert.doesNotMatch(SOURCE, /":root\{/);
|
|
473
473
|
});
|
|
474
474
|
|