@mrrisega/dsh-remote 0.6.12 → 0.6.14

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.
@@ -3997,6 +3997,7 @@ function buildConnectDiagnostics(relayDir, conn, log) {
3997
3997
  `bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
3998
3998
  `后台守护: ${conn.watcherRunning ? `在运行(pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `,已 ${Math.round(conn.watcherAgeMs / 1000)} 秒` : ""}${conn.watcherWedged ? ",⚠️ 迟迟未拉起 bridge" : ""}${conn.watcherRestarts ? `,已自动重启 ${conn.watcherRestarts} 次` : ""})` : "未运行"}`,
3999
3999
  `上游 dsh web: ${conn.upstreamUrl}(${conn.upstreamSource}${conn.upstreamReachable === null ? "" : conn.upstreamReachable ? ",可达" : `,⚠️ 不可达:${conn.upstreamError}`})`,
4000
+ `目录选择器: ${pickerPinState.kind === "native" ? "⚠️ 系统原生(远程时对话框弹在电脑那台机器上 → 手机上点「添加工作区」会像没反应)" : (pickerPinState.kind || "未知")}${pickerPinState.detail ? `(${pickerPinState.detail})` : ""}`,
4000
4001
  `中继注册: ${conn.registered ? "已注册(" + conn.registerSource + ")" : "未注册"}`,
4001
4002
  `bridge 账号: ${conn.accountCurrent === false ? "⚠️ 仍是上一个账号的身份(需重启 bridge 重新登记)" : "当前账号"}`,
4002
4003
  conn.rateLimitedUntil ? `登录限流: 约 ${Math.ceil((conn.rateLimitedUntil - Date.now()) / 1000)} 秒后自动重试(凭据无误,无需改密码)` : "",
@@ -5894,6 +5895,100 @@ function registerRoutes(ctx, relayDir) {
5894
5895
  * @param ctx - host cordis context(注入 webServer)。
5895
5896
  * @param config - entry config(可选 relayDir)。
5896
5897
  */
5898
+
5899
+ // ---------- 目录选择器:远程控制下必须用「浏览器内实现」(0.6.14) ----------
5900
+ //
5901
+ // 用户实测(Windows):镜像页里点「添加工作区」**没反应**;同一功能在 Mac 上正常。
5902
+ //
5903
+ // 根因:官方 `directory-picker-auto` 在**绑定仅回环**且平台是 darwin/win32 时会挂
5904
+ // **原生**选择器(macOS 走 osascript,Windows 走一个 koffi + worker.cjs 子进程去弹 Win32
5905
+ // 文件夹对话框,还要合成 Alt 抢前台)。对远程控制来说这是双输:
5906
+ // · 对话框弹在**电脑**那块屏上 —— 拿手机的人什么也看不到,表现就是"按钮无效";
5907
+ // · Windows 那条原生链路依赖 koffi 原生模块 + 子进程,失败点比 macOS 多得多。
5908
+ // 而 auto 只在 `webServer.host !== "127.0.0.1"` 时才改选 browse —— DSH Desktop(Electron 壳)
5909
+ // 自己持有 webserver 配置(实测监听 43120 / 回环),我们的 LAN 绑定补丁并不生效,于是它一直
5910
+ // 用的是原生那条路。**不能把"手机能不能选工作区"押在宿主的绑定地址上。**
5911
+ //
5912
+ // 做法(官方文档给的"直接合成那一对"的钉法):在 loader 里先建起 browse 宿主+界面,
5913
+ // **成功之后**再摘掉 auto 条目(auto 的 effect 析构会连带卸掉 native 那一对)。
5914
+ //
5915
+ // 三条护栏,顺序不能改:
5916
+ // ① 只在当前 kind === "native" 时动手;已经是 browse 就完全不碰(多数自建部署就是这样);
5917
+ // ② 先建 browse、后摘 auto —— browse 建不起来就什么都不做,宁可用原生也不能让选择器消失;
5918
+ // ③ 任何失败只记日志、绝不抛给宿主(插件不能把 dsh web 拖挂)。
5919
+ // `DSH_REMOTE_NATIVE_PICKER=1` 可整体退出(想保留系统原生对话框的人)。
5920
+ const PICKER_AUTO_ENTRY_ID = "directory-picker";
5921
+ const PICKER_BROWSE_PACKAGES = [
5922
+ "@deepseek-ai/dsh-host-directory-picker-browse",
5923
+ "@deepseek-ai/dsh-client-ui-directory-picker-browse"
5924
+ ];
5925
+ /** 最近一次固定的结果(诊断面板会显示:这类"点了没反应"的问题一眼定位)。 */
5926
+ let pickerPinState = { kind: "", action: "pending", detail: "" };
5927
+
5928
+ /** 当前挂的是哪一路选择器(服务未就绪时返回空串)。 */
5929
+ function directoryPickerKind(ctx) {
5930
+ try {
5931
+ const svc = typeof ctx.get === "function" ? ctx.get("directoryPicker") : null;
5932
+ const cap = svc && typeof svc.capability === "function" ? svc.capability() : null;
5933
+ return cap && typeof cap.kind === "string" ? cap.kind : "";
5934
+ } catch {
5935
+ return "";
5936
+ }
5937
+ }
5938
+
5939
+ /**
5940
+ * 把目录选择器固定成 browse(幂等)。服务要等 auto 挂完才存在,所以内部带重试窗口。
5941
+ * @returns {Promise<{kind:string, action:string, detail:string}>}
5942
+ */
5943
+ async function pinBrowseDirectoryPicker(ctx, { attempts = 6, gapMs = 700 } = {}) {
5944
+ if (String(process.env.DSH_REMOTE_NATIVE_PICKER || "") === "1") {
5945
+ pickerPinState = { kind: directoryPickerKind(ctx), action: "skipped", detail: "DSH_REMOTE_NATIVE_PICKER=1,保留系统原生对话框" };
5946
+ return pickerPinState;
5947
+ }
5948
+ let loader = null;
5949
+ try { loader = typeof ctx.get === "function" ? ctx.get("loader") : null; } catch { loader = null; }
5950
+ if (!loader || typeof loader.create !== "function") {
5951
+ pickerPinState = { kind: directoryPickerKind(ctx), action: "skipped", detail: "没有 loader 服务,无法干预选择器" };
5952
+ return pickerPinState;
5953
+ }
5954
+ for (let i = 0; i < attempts; i += 1) {
5955
+ const kind = directoryPickerKind(ctx);
5956
+ if (kind === "browse") {
5957
+ pickerPinState = { kind, action: "none", detail: "已经是浏览器内选择器" };
5958
+ return pickerPinState;
5959
+ }
5960
+ if (kind === "native") {
5961
+ const created = [];
5962
+ try {
5963
+ for (const name of PICKER_BROWSE_PACKAGES) created.push(await loader.create({ name }));
5964
+ } catch (e) {
5965
+ for (const id of [...created].reverse()) { try { await loader.remove(id); } catch { /* 尽力回滚 */ } }
5966
+ pickerPinState = { kind, action: "kept_native", detail: `浏览器内选择器装不起来,保留原生:${e && e.message ? e.message : e}` };
5967
+ ctx.logger?.warn?.(`dsh-remote-web: 目录选择器固定为 browse 失败,保留原生:${e && e.message ? e.message : e}`);
5968
+ return pickerPinState;
5969
+ }
5970
+ try {
5971
+ const entry = loader.store ? loader.store[PICKER_AUTO_ENTRY_ID] : null;
5972
+ if (entry) await loader.remove(PICKER_AUTO_ENTRY_ID);
5973
+ } catch (e) {
5974
+ // browse 已经起来了,选择器可用;只是 auto 没摘掉(两者并存时 browse 生效即可)
5975
+ pickerPinState = { kind: "browse", action: "pinned", detail: `已切到浏览器内选择器(auto 条目未摘除:${e && e.message ? e.message : e})` };
5976
+ return pickerPinState;
5977
+ }
5978
+ pickerPinState = {
5979
+ kind: "browse",
5980
+ action: "pinned",
5981
+ detail: "原生对话框在远程时弹在电脑那台屏上(手机上看不到),已改为浏览器内选择器"
5982
+ };
5983
+ ctx.logger?.info?.("dsh-remote-web: 目录选择器已固定为浏览器内实现(原生对话框远程不可见)");
5984
+ return pickerPinState;
5985
+ }
5986
+ await sleep(gapMs); // 服务还没挂上(auto 正在建)→ 下一轮再看
5987
+ }
5988
+ pickerPinState = { kind: directoryPickerKind(ctx), action: "skipped", detail: "启动窗口内未确认到选择器服务" };
5989
+ return pickerPinState;
5990
+ }
5991
+
5897
5992
  export function apply(ctx, config = {}) {
5898
5993
  const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
5899
5994
  // 记住宿主 ctx:上游地址要问 dsh web 自己的监听端口(见 upstreamUrl)。
@@ -5929,6 +6024,12 @@ export function apply(ctx, config = {}) {
5929
6024
  ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-web: /dsh-remote routes");
5930
6025
  // 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
5931
6026
  ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
6027
+ // 目录选择器:远程控制下必须用浏览器内实现(否则手机点「添加工作区」像没反应) —— 见上方长注释
6028
+ ctx.effect(() => {
6029
+ let alive = true;
6030
+ void pinBrowseDirectoryPicker(ctx).catch(() => { /* 绝不影响宿主 */ });
6031
+ return () => { alive = false; void alive; };
6032
+ }, "dsh-remote-web: pin browser directory picker");
5932
6033
  // 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
5933
6034
  ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-web: runtime self-provision");
5934
6035
  // 匿名遥测:插件停摆(卸载/重载)时停掉发送心跳;磁盘队列留给下次装载补发。
@@ -5946,3 +6047,8 @@ export function apply(ctx, config = {}) {
5946
6047
  maybeReportInstall(relayDir);
5947
6048
  ctx.logger?.info?.(`dsh-remote-web: /dsh-remote routes ready (relayDir=${relayDir})`);
5948
6049
  }
6050
+
6051
+ // test hooks:cordis 只读 name/inject/apply,这些导出只给用例(见 test/picker-pin.test.mjs)。
6052
+ // 为什么不放进 apply 内部直接测:apply 是"装载即副作用"的同步函数,选择器固定是异步且带重试窗口的,
6053
+ // 单独导出才能把「先建 browse、后摘 auto」这个顺序契约钉死。
6054
+ export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, pickerPinState as __pickerPinState, directoryPickerKind as __directoryPickerKind, PICKER_AUTO_ENTRY_ID as __PICKER_AUTO_ENTRY_ID, PICKER_BROWSE_PACKAGES as __PICKER_BROWSE_PACKAGES };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
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",
@@ -76,6 +76,7 @@ import { promisify } from "node:util";
76
76
  import { gzip as gzipCb } from "node:zlib";
77
77
  // 移动端适配层(经隧道访问的官方 dsh web 窄屏注入;DSH_MOBILE_ADAPTER=0 可关闭,默认开启)
78
78
  import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
79
+ import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./upstream-discovery.mjs";
79
80
  // 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
80
81
  import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
81
82
  // E2EE(端到端加密)客户端基建(Phase-2):MK 派生/会话密钥/信封/握手/开关
@@ -114,7 +115,23 @@ const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
114
115
  // 隧道模式(唯一):bridge 主动 WS 连 relay-router 的 /_bridge
115
116
  const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "");
116
117
  const TUNNEL_HEARTBEAT_MS = Math.max(100, Number(process.env.DSH_BRIDGE_HEARTBEAT_MS) || 15_000);
117
- const UPSTREAM = process.env.DSH_BRIDGE_UPSTREAM || "http://127.0.0.1:3080";
118
+ /**
119
+ * 上游 dsh web 的地址。
120
+ *
121
+ * ⚠️ 这里**不能**只信环境变量 + 写死 3080(2026-09-23 两起用户实测):
122
+ * ① `dsh web --port 8090` / `--port 0`(系统分配)→ 3080 没人听;
123
+ * ② **DSH Desktop**(Electron 壳 `dsh-plugin-desktop`)默认 43120,被占用还会 +1。
124
+ * bridge 与 watcher 共用同一份发现实现(`upstream-discovery.mjs`):
125
+ * 显式环境变量 > `<relayDir>/.dsh-upstream`(插件半用 ctx.webServer.port 落盘)> DSH_WEB_URL
126
+ * > **动态发现**(本机 dsh 进程实际监听端口 + Desktop 端口区间,且每个候选都做身份校验)。
127
+ *
128
+ * 用 `let` 是因为:① 发现发生在启动之后(异步);② 上游端口变了要能在**不重启 bridge** 的前提下跟上。
129
+ */
130
+ const RELAY_DIR = path.dirname(CONFIG_PATH);
131
+ let UPSTREAM = (() => {
132
+ const hint = resolveUpstreamHint({ relayDir: RELAY_DIR });
133
+ return hint.url || FALLBACK_UPSTREAM;
134
+ })();
118
135
  // 微信机器人通道总开关:DSH_WECHAT=0 关闭(默认开启)。控制面只 bind 回环,且必须带 bridge_secret。
119
136
  const WECHAT_DISABLED = String(process.env.DSH_WECHAT || "") === "0";
120
137
  // 默认云端服务地址(dsh-remote setup 会显式传入;自建模式无需账号 API)
@@ -385,6 +402,37 @@ const STRIP_RES_HEADERS = new Set([
385
402
  "connection", "keep-alive", "upgrade"
386
403
  ]);
387
404
 
405
+
406
+ /** 上游重新发现的冷却:失败请求可能连成片,不能每个都去 lsof/pgrep + 探端口。 */
407
+ const UPSTREAM_REFRESH_COOLDOWN_MS = 15_000;
408
+ let upstreamRefreshedAt = 0;
409
+ let upstreamRefreshInflight = null;
410
+ /**
411
+ * 重新解析上游地址(带身份校验;显式 DSH_BRIDGE_UPSTREAM 时是恒等操作)。
412
+ * 上游端口变了(Desktop 换端口 / dsh web 重启到别的端口)时,靠它自愈而不必重启 bridge。
413
+ */
414
+ async function refreshUpstream(reason = "") {
415
+ if (upstreamRefreshInflight) return upstreamRefreshInflight;
416
+ const now = Date.now();
417
+ if (now - upstreamRefreshedAt < UPSTREAM_REFRESH_COOLDOWN_MS) return UPSTREAM;
418
+ upstreamRefreshedAt = now;
419
+ upstreamRefreshInflight = (async () => {
420
+ try {
421
+ const found = await discoverUpstream({ relayDir: RELAY_DIR });
422
+ if (found.url && found.url !== UPSTREAM) {
423
+ console.log(`[bridge] 上游地址切换: ${UPSTREAM} → ${found.url}(来源 ${found.source}${reason ? `,${reason}` : ""})`);
424
+ UPSTREAM = found.url;
425
+ }
426
+ return UPSTREAM;
427
+ } catch {
428
+ return UPSTREAM;
429
+ } finally {
430
+ upstreamRefreshInflight = null;
431
+ }
432
+ })();
433
+ return upstreamRefreshInflight;
434
+ }
435
+
388
436
  console.log(`[bridge] 设备 ${DEVICE_ID} → 隧道 ${TUNNEL_URL}/_bridge`);
389
437
  console.log(`[bridge] 上游 ${UPSTREAM}`);
390
438
 
@@ -505,6 +553,15 @@ export async function handleFrame(dchOrSend, frame) {
505
553
  if (id === undefined || id === null) return;
506
554
  const send = toSender(dchOrSend);
507
555
  if (type === "http") return handleHttpFrame(send, frame);
556
+ if (type === "http-abort") {
557
+ // 手机侧断开了这条流(切页/锁屏):掐掉上游,别在电脑上留一条永不结束的 SSE 连接。
558
+ const c = streamAborts.get(id);
559
+ if (c) {
560
+ streamAborts.delete(id);
561
+ try { c.abort(); } catch { /* ignore */ }
562
+ }
563
+ return;
564
+ }
508
565
  if (type === "ws-open") return handleWsOpen(send, frame);
509
566
  if (type === "ws-msg") return handleWsMessage(send, frame);
510
567
  if (type === "ws-close") return handleWsClose(send, frame);
@@ -541,7 +598,10 @@ function headerValue(headers, name) {
541
598
  * 保守策略:任一条件不满足都返回 null(原样回传):
542
599
  * - 方法非 HEAD、状态非 204/304;
543
600
  * - buf ≥ 1KB(太小不值得压);
544
- * - 上游未编码(contentEncoding 为空;undici 已自动解压 body,这里只看原 header);
601
+ * - `contentEncoding` 为空 —— ⚠️ 它指的是**手上这份 buf 的编码**,不是上游响应头的值:
602
+ * 上游头的 "gzip" 在 undici 解压后依然留着(见下),照抄过来只会让我们白丢压缩收益;
603
+ * 调用方必须传"buf 是否已编码",dsh-bridge 的 doHttp 因此**固定传空串**。
604
+ * 若哪天 buf 真是一段未解压的字节流,调用方才应把对应编码传进来(函数会保守放弃压缩)。
545
605
  * - 请求 Accept-Encoding 含 gzip(手机浏览器必带;没有就不压,避免手机不会解压);
546
606
  * - content-type 可压缩(排除 text/event-stream);
547
607
  * - gzip 后确实更小(极小文件/不可压数据 gzip 反而更大,保守判断)。
@@ -565,6 +625,97 @@ export async function maybeCompressResponse({ buf, contentType, contentEncoding,
565
625
  return { buf: out, headers: { "content-encoding": "gzip" } };
566
626
  }
567
627
 
628
+ /**
629
+ * 进行中的 SSE 流:frameId → AbortController。
630
+ * 手机侧断开时,中继会发 `http-abort`,这里据此掐掉上游(否则电脑上会留一条永不结束的连接)。
631
+ */
632
+ const streamAborts = new Map();
633
+
634
+ /**
635
+ * 中继在 `tunnel-register-ok` 里声明支持的**追加帧能力**。
636
+ *
637
+ * 为什么必须协商:browser→中继→bridge 是两段**独立升级**的链路。
638
+ * 新 bridge 的流式帧(`http-chunk`/`http-end`)对**旧中继**是不认识的 —— 旧中继会把
639
+ * "仅头部"帧当成一个完整的空响应直接 `end()`,SSE 于是变成"秒断 + 浏览器狂重连",
640
+ * 比原来的 120 秒挂起**更糟**。所以能力由中继声明:拿不到 `http-stream` 就老实退回
641
+ * 旧的整包缓冲行为(慢,但正确)。默认空集合 = 保守(连不上/旧中继都不冒险用新帧)。
642
+ */
643
+ const routerCaps = new Set();
644
+
645
+ /**
646
+ * SSE(`text/event-stream`)流式转发。
647
+ *
648
+ * 为什么必须流式:`doHttp` 是「整包 `await res.arrayBuffer()`」的语义,而 SSE **永不结束** ——
649
+ * 实测 DSH 的 `/plugins/events`(client-hmr 的事件通道)每次页面加载都把这条请求挂满
650
+ * bridge 的 120s 上游超时,然后报错;浏览器 EventSource 立刻重连,于是手机端 Network 里
651
+ * **永远有一条转圈的请求**,页面也始终拿不到事件。
652
+ *
653
+ * 帧协议(对中继**追加**、与既有 `http` 帧并存;不认识 streaming 的旧中继只会忽略后续
654
+ * chunk,退化成"空 body 后结束",不会把页面打挂):
655
+ * ← { id, type:"http", status, headers, streaming:true } 仅头部,无 body
656
+ * ← { id, type:"http-chunk", seq, body, bodyBase64:true } 0..n 次
657
+ * ← { id, type:"http-end", seq } 流结束(含出错收尾)
658
+ * → { id, type:"http-abort" } 手机断开 → 掐上游
659
+ *
660
+ * ⚠️ E2EE 路径**不**走这里:信封是"一问一答"的结构,SSE 要逐块封/解需要另立协议;
661
+ * 而现实里 SSE 由 `EventSource` 发出、shim 不接管它(不带头部信封标记),
662
+ * 所以密文流根本不存在 —— 真要做时再单独设计,不要在这里偷偷降级成明文。
663
+ *
664
+ * @returns {Promise<{status:number, chunks:number, bytes:number, aborted?:boolean}>}
665
+ */
666
+ async function doHttpStream(send, id, method, path, reqHeaders) {
667
+ const safe = safePath(path);
668
+ if (safe === null) throw new Error("非法路径");
669
+ const reqHdrs = sanitizeRequestHeaders(reqHeaders);
670
+ const ck = harnessCookieOf(); // 新版 dsh web 的浏览器会话 Cookie(否则 401 白页)
671
+ if (ck) reqHdrs.Cookie = ck;
672
+ const controller = new AbortController();
673
+ streamAborts.set(id, controller);
674
+
675
+ let res;
676
+ try {
677
+ res = await fetch(`${UPSTREAM}${safe}`, { method, headers: reqHdrs, signal: controller.signal });
678
+ } catch (e) {
679
+ streamAborts.delete(id);
680
+ throw e; // 头部都还没发出去 → 交给调用方回一个普通错误帧
681
+ }
682
+
683
+ const status = res.status;
684
+ // 头部帧:告诉中继"这是一条流",随后才逐块推 body
685
+ send({
686
+ id,
687
+ type: "http",
688
+ status,
689
+ headers: sanitizeResponseHeaders(Object.fromEntries(res.headers.entries())),
690
+ streaming: true
691
+ });
692
+
693
+ let seq = 0;
694
+ let bytes = 0;
695
+ try {
696
+ const reader = res.body && typeof res.body.getReader === "function" ? res.body.getReader() : null;
697
+ if (reader) {
698
+ for (;;) {
699
+ const { done, value } = await reader.read();
700
+ if (done) break;
701
+ if (!value || value.length === 0) continue;
702
+ bytes += value.length;
703
+ send({ id, type: "http-chunk", seq: ++seq, body: Buffer.from(value).toString("base64"), bodyBase64: true });
704
+ }
705
+ }
706
+ } catch (e) {
707
+ // 头部已经发出去了:这里**绝不能**抛(调用方会用普通帧回 502,而中继那边响应头已定,
708
+ // 再写一次 head 会炸)。统一以 http-end 收尾,让 EventSource 按自己的节奏重连。
709
+ const aborted = controller.signal.aborted === true;
710
+ streamAborts.delete(id);
711
+ send({ id, type: "http-end", seq, ...(aborted ? { aborted: true } : { error: String(e?.message || e) }) });
712
+ return { status, chunks: seq, bytes, aborted };
713
+ }
714
+ streamAborts.delete(id);
715
+ send({ id, type: "http-end", seq });
716
+ return { status, chunks: seq, bytes };
717
+ }
718
+
568
719
  async function doHttp(method, path, reqHeaders, body, isB64) {
569
720
  const safe = safePath(path);
570
721
  if (safe === null) throw new Error("非法路径");
@@ -624,7 +775,14 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
624
775
  const compressed = await maybeCompressResponse({
625
776
  buf,
626
777
  contentType,
627
- contentEncoding: res.headers.get("content-encoding") || "",
778
+ // 🔴 必须传空:这里的 `buf` 是 **undici 已经解压过**的明文,不是上游线速字节。
779
+ // 以前传的是 `res.headers.get("content-encoding")` —— 而 undici 解压之后**仍会把该头留着**
780
+ // (本文件 sanitizeResponseHeaders 的注释正是这么写的:"undici 已自动解压 body,
781
+ // content-encoding/length 会误导浏览器")。于是 maybeCompressResponse 看到 "gzip"
782
+ // 就保守地 `return null`,**永不重压** —— 实测代价:线上 dsh web 的 `/plugins/??`
783
+ // 聚合包本来 gzip 后 5.12MB,被原样发出 13.39MB(2.6×),免费档 1Mbps 下从 40 秒变成 100+ 秒。
784
+ // 传空之后语义才是自洽的:"我手上这份 buf 是未编码的明文,可以压"。
785
+ contentEncoding: "",
628
786
  acceptEncoding: headerValue(reqHeaders, "accept-encoding"),
629
787
  status: res.status,
630
788
  method
@@ -693,12 +851,22 @@ export async function handleHttpFrame(dchOrSend, frame) {
693
851
  }
694
852
  }
695
853
  try {
854
+ // SSE 必须流式转发(否则会挂满 120s 超时,见 doHttpStream 注释)。
855
+ // 客户端是 EventSource 时必带 Accept: text/event-stream,以此为准(不看路径白名单,免得漏)。
856
+ if (routerCaps.has("http-stream") && String(headerValue(headers, "accept") || "").includes("text/event-stream")) {
857
+ const s = await doHttpStream(send, id, method, path, headers);
858
+ console.log(`[bridge] ${method} ${path} → SSE ${s.status} (${Date.now() - t0}ms, ${s.chunks} 块 / ${(s.bytes / 1024).toFixed(1)}KB${s.aborted ? ", 手机已断开" : ""})`);
859
+ return;
860
+ }
696
861
  const reply = await doHttp(method, path, headers, body, !!isB64);
697
862
  reply.id = id;
698
863
  reply.type = "http";
699
864
  send(reply);
700
865
  console.log(`[bridge] ${method} ${path} → ${reply.status} (${Date.now() - t0}ms, ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
701
866
  } catch (e) {
867
+ // 上游不可达是「端口可能变了」的最强信号(Desktop 换端口 / dsh web 重启到别的端口):
868
+ // 触发一次带冷却的重新发现,后续请求自动跟上,不必重启 bridge。
869
+ void refreshUpstream("上游请求失败");
702
870
  console.log(`[bridge] ${method} ${path} 上游错误: ${e.message}`);
703
871
  send({ id, type: "http", status: 502, headers: { "content-type": "application/json" }, body: Buffer.from(JSON.stringify({ error: String(e.message || e) })).toString("base64"), bodyBase64: true });
704
872
  }
@@ -760,6 +928,7 @@ export async function handleLegacyFrame(dchOrSend, frame) {
760
928
  send({ id, status: reply.status, headers: reply.headers, body: text });
761
929
  console.log(`[bridge] legacy ${method} ${path} → ${reply.status} (${Date.now() - t0}ms)`);
762
930
  } catch (e) {
931
+ void refreshUpstream("上游请求失败(legacy)");
763
932
  console.log(`[bridge] legacy ${method} ${path} 上游错误: ${e.message}`);
764
933
  send({ id, status: 502, headers: {}, body: JSON.stringify({ error: String(e.message || e) }) });
765
934
  }
@@ -1157,6 +1326,9 @@ function connectTunnel(token) {
1157
1326
  try {
1158
1327
  receive(raw, (frame) => {
1159
1328
  if (frame?.type === "tunnel-register-ok") {
1329
+ // 记下中继声明的追加帧能力(旧中继不带 caps → 空集合 → 退回整包缓冲,见 routerCaps 注释)
1330
+ routerCaps.clear();
1331
+ for (const c of Array.isArray(frame.caps) ? frame.caps : []) routerCaps.add(String(c));
1160
1332
  // 中继注册成功 = 手机端此刻真的能进这台电脑:面板据此把阶段推进到 online(已连接 ✅)
1161
1333
  persistBridgeState({ device_id: DEVICE_ID, tunnel_registered_at: Date.now(), phase: "online", last_error: null });
1162
1334
  console.log(`[bridge] ✅ router 注册成功: ${DEVICE_ID},等待手机访问 /remote/${DEVICE_ID}/`);
@@ -1172,6 +1344,7 @@ function connectTunnel(token) {
1172
1344
  } catch (e) { console.log(`[bridge] 隧道帧错误: ${e.message}`); }
1173
1345
  });
1174
1346
  ws.on("close", (code, reason) => {
1347
+ routerCaps.clear(); // 换中继/断线:能力重新协商,期间一律退回保守行为
1175
1348
  clearInterval(heartbeat);
1176
1349
  console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
1177
1350
  closeAllWsSessions();
@@ -1348,6 +1521,9 @@ function startWeChat() {
1348
1521
  const cfg = loadLocalConfig();
1349
1522
  wechatRuntime = createWeChatRuntime({
1350
1523
  relayDir,
1524
+ // 上游地址在 runTunnel() 开头已完成动态发现(见 refreshUpstream),所以这里拿到的是真实端口。
1525
+ // ⚠️ 这是**取值**而非引用:运行中若上游端口又变了(自愈重发现),微信通道要等 bridge 重启才跟上;
1526
+ // 隧道转发那一侧不受影响(它每次都读最新的 UPSTREAM)。
1351
1527
  upstream: UPSTREAM,
1352
1528
  cookieOf: harnessCookieOf,
1353
1529
  secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
@@ -1393,6 +1569,10 @@ function redactText(e) {
1393
1569
  }
1394
1570
 
1395
1571
  async function runTunnel() {
1572
+ // 先动态确定上游再连隧道:上游端口不是 3080 时(DSH Desktop 默认 43120)这一步是关键 ——
1573
+ // 否则 bridge 会连上中继、手机也能打开页面,但每个请求都打到一个没人听的端口。
1574
+ await refreshUpstream("启动");
1575
+ console.log(`[bridge] 上游地址已确定: ${UPSTREAM}`);
1396
1576
  const token = await resolveToken();
1397
1577
  if (!token) {
1398
1578
  console.error("[bridge] 隧道模式需要账号认证:请设 DSH_BRIDGE_TOKEN,或 DSH_BRIDGE_PHONE+DSH_BRIDGE_PASSWORD");