dsh-remote-web 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.
package/lib/index.js
CHANGED
|
@@ -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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.
|
|
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
|
-
|
|
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
|
-
* -
|
|
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
|
-
|
|
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");
|
|
@@ -95,7 +95,34 @@ const RUNTIME_HOST_RE =
|
|
|
95
95
|
/(data-sidebar-collapsed|data-details-collapsed|data-rightbar-collapsed|data-shell-overlay)|(pI_x6G_frame|hHd-Xa_root|hHd-Xa_toggle)/;
|
|
96
96
|
|
|
97
97
|
const STYLE = `
|
|
98
|
-
/* =====
|
|
98
|
+
/* ===== 首屏加载提示(0.6.14)=====
|
|
99
|
+
为什么要有它:镜像页首屏要拉 dsh web 的全部客户端插件(实测 ~14 MB,一个不可拆分的
|
|
100
|
+
/plugins/?? 聚合请求),免费档限速下几十秒起步。此前页面上**只有官方那个转圈**,
|
|
101
|
+
用户无法区分"在加载"和"卡死了",只能反复刷新 —— 反而更慢。
|
|
102
|
+
这里给一句人话提示 + 分阶段的补充说明,加载完成(或超时兜底)即自动移除。
|
|
103
|
+
⚠️ 刻意**不**放进 @media (max-width:820px):Windows/桌面浏览器打开镜像页同样要等。
|
|
104
|
+
⚠️ pointer-events:none + 半透明:万一移除逻辑失效,也绝不挡住任何点击。 */
|
|
105
|
+
.dsh-ma-boot {
|
|
106
|
+
position: fixed; inset: 0; z-index: 2147483000;
|
|
107
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
|
|
108
|
+
background: color-mix(in srgb, var(--dsw-alias-bg-base, #0f1115) 88%, transparent);
|
|
109
|
+
backdrop-filter: blur(2px);
|
|
110
|
+
pointer-events: none;
|
|
111
|
+
font-family: inherit; text-align: center; padding: 24px;
|
|
112
|
+
transition: opacity .3s ease;
|
|
113
|
+
}
|
|
114
|
+
.dsh-ma-boot[data-fading="1"] { opacity: 0; }
|
|
115
|
+
.dsh-ma-boot-spin {
|
|
116
|
+
width: 34px; height: 34px; border-radius: 50%;
|
|
117
|
+
border: 3px solid color-mix(in srgb, currentColor 22%, transparent);
|
|
118
|
+
border-top-color: currentColor;
|
|
119
|
+
animation: dsh-ma-boot-spin 1s linear infinite;
|
|
120
|
+
}
|
|
121
|
+
@keyframes dsh-ma-boot-spin { to { transform: rotate(360deg); } }
|
|
122
|
+
.dsh-ma-boot-title { font-size: 15px; font-weight: 600; }
|
|
123
|
+
.dsh-ma-boot-sub { font-size: 13px; line-height: 1.6; opacity: .75; max-width: 22em; }
|
|
124
|
+
.dsh-ma-boot-elapsed { font-size: 12px; opacity: .55; font-variant-numeric: tabular-nums; }
|
|
125
|
+
|
|
99
126
|
@media (max-width: 820px) {
|
|
100
127
|
html, body { max-width: 100%; overflow-x: hidden; }
|
|
101
128
|
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
|
@@ -426,6 +453,89 @@ html[data-dsh-ma-merged-e2ee] #dsh-e2ee-badge { display: none !important; }
|
|
|
426
453
|
const SCRIPT = `(() => {
|
|
427
454
|
"use strict";
|
|
428
455
|
|
|
456
|
+
/* ===== 首屏加载提示(0.6.14)=====
|
|
457
|
+
用户反馈:「普通用户加载进入镜像页面时等待时间较长,目前等待过程中一直在转圈」
|
|
458
|
+
—— 官方那个转圈不告诉用户任何事。这里在**页面解析的第一时间**就挂上一条人话提示,
|
|
459
|
+
并在官方 UI 就绪(或兜底超时)后自动移除。
|
|
460
|
+
|
|
461
|
+
⚠️ 必须放在所有主机判定(HOSTISH / NARROW)之前:首屏慢的正是这些判定等不到的时候。
|
|
462
|
+
⚠️ 只做提示,不做任何拦截:pointer-events:none,也没有遮罩语义。 */
|
|
463
|
+
var maBootTip = null, maBootTimers = [], maBootStart = Date.now(), maBootElapsedTimer = 0, maBootWatchTimer = 0;
|
|
464
|
+
function maBootSet(cls, text) {
|
|
465
|
+
try { var n = maBootTip && maBootTip.querySelector(cls); if (n) n.textContent = text; } catch (e) { /* 忽略 */ }
|
|
466
|
+
}
|
|
467
|
+
function maBootShow() {
|
|
468
|
+
try {
|
|
469
|
+
if (maBootTip) return;
|
|
470
|
+
if (!document.body) return; // 解析太早(body 还没出来)→ 交给 DOMContentLoaded 再挂
|
|
471
|
+
maBootTip = document.createElement("div");
|
|
472
|
+
maBootTip.className = "dsh-ma-boot";
|
|
473
|
+
maBootTip.setAttribute("role", "status");
|
|
474
|
+
maBootTip.setAttribute("aria-live", "polite");
|
|
475
|
+
maBootTip.innerHTML =
|
|
476
|
+
'<div class="dsh-ma-boot-spin" aria-hidden="true"></div>' +
|
|
477
|
+
'<div class="dsh-ma-boot-title">正在加载远程桌面…</div>' +
|
|
478
|
+
'<div class="dsh-ma-boot-sub">首次打开需要下载较完整的前端资源,请稍候</div>' +
|
|
479
|
+
'<div class="dsh-ma-boot-elapsed">已等待 0 秒</div>';
|
|
480
|
+
document.body.appendChild(maBootTip);
|
|
481
|
+
// 秒表:让"到底等了多久"可见(用户据此判断是慢还是死了)
|
|
482
|
+
maBootElapsedTimer = setInterval(function () {
|
|
483
|
+
try {
|
|
484
|
+
var s = Math.floor((Date.now() - maBootStart) / 1000);
|
|
485
|
+
var n = maBootTip && maBootTip.querySelector(".dsh-ma-boot-elapsed");
|
|
486
|
+
if (n) n.textContent = "已等待 " + s + " 秒";
|
|
487
|
+
} catch (e) { /* 忽略 */ }
|
|
488
|
+
}, 1000);
|
|
489
|
+
maBootTimers.push(setTimeout(function () {
|
|
490
|
+
maBootSet(".dsh-ma-boot-sub", "网络较慢时可能需要一两分钟;加载完成后会自动进入,不用重复刷新");
|
|
491
|
+
}, 8000));
|
|
492
|
+
maBootTimers.push(setTimeout(function () {
|
|
493
|
+
maBootSet(".dsh-ma-boot-sub", "还在加载…如果长时间停在这里,可以下拉刷新重试(重复刷新不会更快)");
|
|
494
|
+
}, 30 * 1000));
|
|
495
|
+
} catch (e) { /* 提示失败绝不影响主功能 */ }
|
|
496
|
+
}
|
|
497
|
+
function maBootHide() {
|
|
498
|
+
try {
|
|
499
|
+
for (var i = 0; i < maBootTimers.length; i++) clearTimeout(maBootTimers[i]);
|
|
500
|
+
maBootTimers = [];
|
|
501
|
+
if (maBootElapsedTimer) { clearInterval(maBootElapsedTimer); maBootElapsedTimer = 0; }
|
|
502
|
+
if (maBootWatchTimer) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
|
|
503
|
+
var tip = maBootTip;
|
|
504
|
+
maBootTip = null;
|
|
505
|
+
if (tip && tip.parentNode) {
|
|
506
|
+
try { tip.setAttribute("data-fading", "1"); } catch (e) { /* 忽略 */ }
|
|
507
|
+
setTimeout(function () { try { if (tip.parentNode) tip.parentNode.removeChild(tip); } catch (e) { /* 忽略 */ } }, 300);
|
|
508
|
+
}
|
|
509
|
+
} catch (e) { /* 忽略 */ }
|
|
510
|
+
}
|
|
511
|
+
/** 官方 UI 是否已经可用了(= 提示可以撤了)。 */
|
|
512
|
+
function maBootLooksReady() {
|
|
513
|
+
try {
|
|
514
|
+
// ① 适配层认出来的官方 frame(与下方 boot() 同一组选择器)
|
|
515
|
+
if (document.querySelector(".pI_x6G_frame, [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]")) return true;
|
|
516
|
+
// ② 出现输入框/会话区也算"进来了"(官方改版首选信号失灵时的兜底)
|
|
517
|
+
return Boolean(document.querySelector('textarea, [contenteditable="true"], [role="textbox"]'));
|
|
518
|
+
} catch (e) { return false; }
|
|
519
|
+
}
|
|
520
|
+
if (document.readyState === "loading") {
|
|
521
|
+
document.addEventListener("DOMContentLoaded", maBootShow);
|
|
522
|
+
} else {
|
|
523
|
+
maBootShow();
|
|
524
|
+
}
|
|
525
|
+
// 轮询撤下:1s 一次,成本可忽略(只是两次 querySelector);一旦撤下就自我停止。
|
|
526
|
+
// 上限与"4 分钟兜底"对齐,避免页面异常时留下常驻定时器。
|
|
527
|
+
maBootWatchTimer = setInterval(function () {
|
|
528
|
+
if (maBootLooksReady()) { maBootHide(); return; }
|
|
529
|
+
if (!maBootTip) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
|
|
530
|
+
}, 1000);
|
|
531
|
+
maBootTimers.push(setTimeout(function () { maBootHide(); }, 240 * 1000));
|
|
532
|
+
// 页面卸载/进 bfcache 时收干净(测试与真机都靠这条不留悬挂定时器)
|
|
533
|
+
try {
|
|
534
|
+
var maBootBye = function () { maBootHide(); };
|
|
535
|
+
window.addEventListener("pagehide", maBootBye);
|
|
536
|
+
window.addEventListener("unload", maBootBye);
|
|
537
|
+
} catch (e) { /* 忽略 */ }
|
|
538
|
+
|
|
429
539
|
/* —— 主机门解除(2026-09):镜像页(如 n.risegao.cn)经设备流认证回连同一台 127.0.0.1:3080,
|
|
430
540
|
桥已把 Host 回环化并剥 Origin(不扩大信任面)。官方客户端以 transport.ownsHost 判定
|
|
431
541
|
「自有主机」,非回环域名下不置 true 会把设置页置为 memory(不可用)镜像 →
|
|
@@ -1096,6 +1206,36 @@ const SCRIPT = `(() => {
|
|
|
1096
1206
|
} catch (e8) { /* 引导条失败绝不能影响主功能 */ }
|
|
1097
1207
|
};
|
|
1098
1208
|
|
|
1209
|
+
/* 适配层自己创建的「抽屉入口」引用(汉堡按钮 / 遮罩),提到 boot() 外层保存。
|
|
1210
|
+
为什么必须提到外层:官方 SPA 重渲染时会整片替换 body 子树,把这两个节点一起冲掉;
|
|
1211
|
+
留住引用才能在下面的「存在性看门狗」里把**同一个节点**补回去(节点不变 → 挂在它上面的
|
|
1212
|
+
click 处理、CSS 类、aria 属性全都原样保留,不会出现"补回来点不动"的假按钮)。 */
|
|
1213
|
+
let maHamburger = null, maScrim = null;
|
|
1214
|
+
|
|
1215
|
+
/* 节点是否已经脱离文档。判不出来时一律当作"还在"—— 宁可不补,也绝不重复插入。 */
|
|
1216
|
+
const maDetached = (el) => {
|
|
1217
|
+
if (!el) return false;
|
|
1218
|
+
try { if (typeof el.isConnected === "boolean") return !el.isConnected; } catch (eConn) { /* 继续退化 */ }
|
|
1219
|
+
try {
|
|
1220
|
+
const b = document.body;
|
|
1221
|
+
if (b && typeof b.contains === "function") return !b.contains(el);
|
|
1222
|
+
} catch (eHas) { /* 继续退化 */ }
|
|
1223
|
+
return false;
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
/* —— 存在性看门狗:「建好了」不等于「一直都在」----------------------------------
|
|
1227
|
+
done=true 只保证**曾经**建出来过。官方在路由/会话切换时会重建 DOM 子树,我们的汉堡按钮
|
|
1228
|
+
与遮罩会跟着消失 —— 用户看到的就是"抽屉按钮用着用着又没了"。
|
|
1229
|
+
这里按**引用**补回(不是重建),只在窄屏做(宽屏本来就不需要抽屉入口)。
|
|
1230
|
+
幂等:节点还在文档里 → 什么都不做;boot 还没成功(引用为 null) → 同样什么都不做。 */
|
|
1231
|
+
const ensureDrawerChrome = () => {
|
|
1232
|
+
try {
|
|
1233
|
+
if (!NARROW() || !document.body) return;
|
|
1234
|
+
if (maDetached(maScrim)) document.body.appendChild(maScrim);
|
|
1235
|
+
if (maDetached(maHamburger)) document.body.appendChild(maHamburger);
|
|
1236
|
+
} catch (eChrome) { /* 补不回去也不能影响抽屉开合本身 */ }
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1099
1239
|
let done = false;
|
|
1100
1240
|
const boot = () => {
|
|
1101
1241
|
if (!HOSTISH()) return;
|
|
@@ -1228,6 +1368,7 @@ const SCRIPT = `(() => {
|
|
|
1228
1368
|
const scrim = document.createElement("div");
|
|
1229
1369
|
scrim.className = "dsh-ma-scrim";
|
|
1230
1370
|
document.body.appendChild(scrim);
|
|
1371
|
+
maScrim = scrim; // 供「存在性看门狗」在被官方冲掉后补回
|
|
1231
1372
|
scrim.addEventListener("click", (e) => {
|
|
1232
1373
|
e.stopPropagation();
|
|
1233
1374
|
userWantsOpen = false; // 用户明确要关(遮罩的作用就是关抽屉)
|
|
@@ -1245,6 +1386,7 @@ const SCRIPT = `(() => {
|
|
|
1245
1386
|
hamburger.setAttribute("aria-label", "打开菜单");
|
|
1246
1387
|
hamburger.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>';
|
|
1247
1388
|
document.body.appendChild(hamburger);
|
|
1389
|
+
maHamburger = hamburger; // 供「存在性看门狗」在被官方冲掉后补回
|
|
1248
1390
|
hamburger.addEventListener("click", (e) => {
|
|
1249
1391
|
e.stopPropagation(); e.preventDefault();
|
|
1250
1392
|
const nowOpen = document.documentElement.classList.contains("dsh-ma-sidebar-open");
|
|
@@ -1516,10 +1658,82 @@ const SCRIPT = `(() => {
|
|
|
1516
1658
|
};
|
|
1517
1659
|
|
|
1518
1660
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1661
|
+
|
|
1662
|
+
/* ================= 执行时机:无限期等待官方 DOM 就绪 =================
|
|
1663
|
+
用户实测原话(2026-09-2x):「在网络比较慢的情况下,左边的抽屉按钮有时候出不来。
|
|
1664
|
+
甭管网络有多慢,都要让那个抽屉能加载出来。」
|
|
1665
|
+
⚠️ 旧实现是**一组写死的固定重试**([700, 1600, 3200, 7000, 12000]ms),而汉堡按钮的创建
|
|
1666
|
+
排在 boot() 里 "if (!frame) return;" 那一行之后 —— 官方 SPA bundle 有 13.4MB(实测未压缩),
|
|
1667
|
+
免费档被中继限速到 128KB/s 时冷启动要 1-2 分钟,官方 frame 在 12 秒内根本没出现:
|
|
1668
|
+
5 次重试全部在那一行返回 → done 永远为 false → 汉堡按钮**永远**不创建
|
|
1669
|
+
→ 用户看到"左边抽屉按钮出不来"。任何固定窗口都只是把这个阈值往后挪,治不了根。
|
|
1670
|
+
现在改成「事件驱动 + 低频兜底」的**无限期**等待,只要抽屉入口还没建好就一直试:
|
|
1671
|
+
· MutationObserver(document/subtree):官方 frame 一挂上就立刻跟上(网络快时零延迟);
|
|
1672
|
+
· setInterval(1000ms):兜底 + 存在性看门狗 —— observer 不可用/被绕过时照样收敛,
|
|
1673
|
+
也负责在 done 之后发现官方重渲染冲掉了我们的节点并补回。
|
|
1674
|
+
两条路都只是"再调一次 pump()",pump 内部**全部幂等**(done / fabDone / whaleGuardsOn /
|
|
1675
|
+
settingsWatchOn / drawerAutoCloseOn 等守卫都在),重复调用不会产生任何副作用,
|
|
1676
|
+
也不改变"执行什么"——开合、遮罩不变量、sync()、isDrawer() 判定一行未动。 */
|
|
1677
|
+
let maTorn = false, maInterval = 0, maObserver = null, maRafPending = false;
|
|
1678
|
+
/* 廉价的"boot() 还有事可做吗"闸门(只读两个现成的布尔标志,不做任何 DOM 操作):
|
|
1679
|
+
· fab 还没建出来(fabDone=false) → 要跑;
|
|
1680
|
+
· 抽屉那段还没建好(done=false)且当前是窄屏 → 要跑(旋转/缩放回窄屏也能补建)。
|
|
1681
|
+
两者都不成立 → boot() 里已无事可做,**绝不能**再跑:boot() 第一行 HOSTISH() 会把
|
|
1682
|
+
documentElement.outerHTML 整个序列化一遍(最长 200KB),而官方流式输出时每帧都有 DOM
|
|
1683
|
+
变更 —— 没有这道闸门就会变成每秒几十次全量序列化(旧实现只跑 5 次,不会有这个问题)。 */
|
|
1684
|
+
const bootUseful = () => !fabDone || (!done && NARROW());
|
|
1685
|
+
const pump = () => {
|
|
1686
|
+
if (maTorn) return;
|
|
1687
|
+
if (bootUseful()) { try { boot(); } catch (eBoot) { /* 幂等重试 */ } }
|
|
1688
|
+
try { ensureDrawerChrome(); } catch (eChrome2) { /* 忽略 */ }
|
|
1689
|
+
if (!whaleGuardsOn || !settingsWatchOn) {
|
|
1690
|
+
try { whaleHookInit(); watchSettings(); } catch (eHook) { /* 两者内部都自带一次性守卫 */ }
|
|
1691
|
+
}
|
|
1692
|
+
};
|
|
1693
|
+
/* 节流:一次 DOM 变更往往产生几十条记录,逐条 boot() 就是忙轮询。
|
|
1694
|
+
统一合并成"本帧最多跑一次"(有 rAF 用 rAF,没有就退化成 0ms 宏任务) —— 天然去重。 */
|
|
1695
|
+
const schedulePump = () => {
|
|
1696
|
+
if (maTorn || maRafPending) return;
|
|
1697
|
+
maRafPending = true;
|
|
1698
|
+
const run = () => { maRafPending = false; pump(); };
|
|
1699
|
+
try { if (typeof requestAnimationFrame === "function") { requestAnimationFrame(run); return; } } catch (eRaf) { /* 退化 */ }
|
|
1700
|
+
setTimeout(run, 0);
|
|
1701
|
+
};
|
|
1702
|
+
/* 挂上"叫醒"的两条路。做成可重入(先清旧的再建新的),这样 bfcache 恢复后能重新武装。 */
|
|
1703
|
+
const maArm = () => {
|
|
1704
|
+
maTorn = false;
|
|
1705
|
+
maRafPending = false; // 冻结时若有一帧没跑到,不清掉就会永远挡住后续调度
|
|
1706
|
+
try { if (maObserver) maObserver.disconnect(); } catch (eA1) { /* 忽略 */ }
|
|
1707
|
+
maObserver = null;
|
|
1708
|
+
try { if (maInterval) clearInterval(maInterval); } catch (eA2) { /* 忽略 */ }
|
|
1709
|
+
maInterval = 0;
|
|
1710
|
+
try {
|
|
1711
|
+
if (typeof MutationObserver === "function" && document.documentElement) {
|
|
1712
|
+
maObserver = new MutationObserver(schedulePump);
|
|
1713
|
+
/* 只观察 childList+subtree:节点被官方换掉/新增才叫醒我们。
|
|
1714
|
+
⚠️ 刻意**不**观察 attributes —— 我们自己的 sync() 就会不停改 class,那会变成自激循环。 */
|
|
1715
|
+
maObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
1716
|
+
}
|
|
1717
|
+
} catch (eObs) { maObserver = null; /* 退化:只靠下面的兜底表 */ }
|
|
1718
|
+
try { maInterval = setInterval(pump, 1000); } catch (eInt) { maInterval = 0; }
|
|
1719
|
+
};
|
|
1720
|
+
maArm();
|
|
1721
|
+
/* 清理:页面卸载时断开观察者与兜底表(bfcache/长驻页面都不留悬挂回调)。
|
|
1722
|
+
pagehide 覆盖 bfcache 场景,unload 兜底更老的浏览器。 */
|
|
1723
|
+
const maTeardown = () => {
|
|
1724
|
+
maTorn = true;
|
|
1725
|
+
try { if (maObserver) maObserver.disconnect(); } catch (eD1) { /* 忽略 */ }
|
|
1726
|
+
maObserver = null;
|
|
1727
|
+
try { if (maInterval) clearInterval(maInterval); } catch (eD2) { /* 忽略 */ }
|
|
1728
|
+
maInterval = 0;
|
|
1729
|
+
};
|
|
1730
|
+
try { window.addEventListener("pagehide", maTeardown); } catch (ePh) { /* 忽略 */ }
|
|
1731
|
+
try { window.addEventListener("unload", maTeardown); } catch (eUl) { /* 忽略 */ }
|
|
1732
|
+
/* 手机上"切到别的 App 再切回来"走的常是 bfcache:pagehide(persisted=true) → pageshow(persisted=true)。
|
|
1733
|
+
DOM 原样保留但计时器/观察者已断 —— 这里重新武装,否则回到页面后看门狗就永久失灵了。 */
|
|
1734
|
+
try {
|
|
1735
|
+
window.addEventListener("pageshow", (ev) => { try { if (ev && ev.persisted) maArm(); } catch (ePs) { /* 忽略 */ } });
|
|
1736
|
+
} catch (ePs2) { /* 忽略 */ }
|
|
1523
1737
|
} catch (e) { if (window.console) console.warn("[dsh-mobile-adapter]", e && e.message); }
|
|
1524
1738
|
})();`;
|
|
1525
1739
|
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上游(dsh web)地址发现 —— **单一实现**,watcher(dsh-setup.mjs)与 bridge(dsh-bridge.mjs)共用。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* 桥接链路的两端(watcher 的存活探测、bridge 的转发目标)原本把上游写死
|
|
7
|
+
* `http://127.0.0.1:3080`(独立 `dsh web` 的默认端口)。但真实世界里端口是会变的:
|
|
8
|
+
*
|
|
9
|
+
* · `dsh web --port 8090` / profile 里配了 port / `--port 0` 让系统分配空闲端口;
|
|
10
|
+
* · **DSH Desktop**(Electron 壳 `dsh-plugin-desktop`)默认监听 **43120**,且被占用时按 +1 递增;
|
|
11
|
+
* · `dsh web` 重启后端口可能不同。
|
|
12
|
+
*
|
|
13
|
+
* 2026-09-23 用户实测(DSH Desktop 2.0.4 + 插件 0.6.10):上游在 43120,而 watcher 探 3080 →
|
|
14
|
+
* **永远探不到 → bridge 永远不被派生 → 面板永久停在「正在启动 Bridge…」**,设备从未登记。
|
|
15
|
+
* 这不是"某个用户环境特殊",而是**所有改了端口的用户**都会踩的同一类问题,所以要有通用发现。
|
|
16
|
+
*
|
|
17
|
+
* ## 发现顺序(先便宜后昂贵,每一步都做**身份校验**)
|
|
18
|
+
*
|
|
19
|
+
* 1. `DSH_BRIDGE_UPSTREAM`(显式覆盖,部署/排障用)—— 唯一不做探测就采信的来源;
|
|
20
|
+
* 2. `<relayDir>/.dsh-upstream`(**插件半**在宿主进程里用 `ctx.webServer.port` 落盘,最权威);
|
|
21
|
+
* 3. `DSH_WEB_URL` 的端口;
|
|
22
|
+
* 4. **实测端口**:本进程的祖先进程 / 本机 dsh 进程正在 LISTEN 的端口(lsof / netstat);
|
|
23
|
+
* 5. 静态候选:3080 + DSH Desktop 的 43120 及其递增区间。
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ 第 4、5 步必须**校验身份**:43120 之类的端口可能被别的本地程序占用,
|
|
26
|
+
* 把 bridge 接到一个陌生人身上既不可用、也可能泄露数据。判据见 `looksLikeDshWeb`。
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ 探测全部**并行**且有总预算:本地端口连接被拒是瞬时的,只有"端口在听但不回包"才会等到超时,
|
|
29
|
+
* 串行探 12 个候选最坏要十几秒,会让 watcher 的轮询卡住。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
|
|
36
|
+
/** 插件半落盘的上游地址文件名(与 packages/dsh-remote-web/lib/index.js 的常量同名同义)。 */
|
|
37
|
+
export const UPSTREAM_FILE = ".dsh-upstream";
|
|
38
|
+
/** 独立 `dsh web` 的默认端口(历史行为,最后的兜底)。 */
|
|
39
|
+
export const FALLBACK_UPSTREAM = "http://127.0.0.1:3080";
|
|
40
|
+
/** DSH Desktop(Electron 壳)的默认 Web 端口;被占用时它按 +1 递增,所以要扫一段区间。 */
|
|
41
|
+
export const DESKTOP_DEFAULT_PORT = 43120;
|
|
42
|
+
/** Desktop 端口区间扫多少个(43120..43120+11)。 */
|
|
43
|
+
export const DESKTOP_PORT_SCAN = 12;
|
|
44
|
+
/** 单个候选的探测超时(本地回环,超过它基本就是"在听但不回包")。 */
|
|
45
|
+
const PROBE_TIMEOUT_MS = 900;
|
|
46
|
+
|
|
47
|
+
/** 规范化:去尾斜杠;非 http(s) 一律丢弃(返回空串)。 */
|
|
48
|
+
export function normalizeUpstream(value) {
|
|
49
|
+
const raw = String(value ?? "").trim();
|
|
50
|
+
if (!raw) return "";
|
|
51
|
+
if (!/^https?:\/\/[^\s]+$/i.test(raw)) return "";
|
|
52
|
+
return raw.replace(/\/+$/, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 从字符串里取端口(拿不到返回 0)。 */
|
|
56
|
+
function portOf(value) {
|
|
57
|
+
try {
|
|
58
|
+
const n = Number(new URL(String(value)).port);
|
|
59
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
60
|
+
} catch {
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 1) 显式环境变量。 */
|
|
66
|
+
export function upstreamFromEnv(env = process.env) {
|
|
67
|
+
return normalizeUpstream(env.DSH_BRIDGE_UPSTREAM);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 2) 插件半落盘的端口文件(插件不在场/首次安装时可能没有)。 */
|
|
71
|
+
export function upstreamFromFile(relayDir) {
|
|
72
|
+
if (!relayDir) return "";
|
|
73
|
+
try {
|
|
74
|
+
return normalizeUpstream(fs.readFileSync(path.join(relayDir, UPSTREAM_FILE), "utf8"));
|
|
75
|
+
} catch {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 3) DSH_WEB_URL 的端口(bridge 的 loopback 围栏只认 127.0.0.1,这里也统一回环地址)。 */
|
|
81
|
+
export function upstreamFromDshWebUrl(env = process.env) {
|
|
82
|
+
const raw = String(env.DSH_WEB_URL || "").trim();
|
|
83
|
+
if (!raw) return "";
|
|
84
|
+
const port = portOf(raw);
|
|
85
|
+
return port ? `http://127.0.0.1:${port}` : "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 按优先级取出"提示地址"(不做任何网络探测)。
|
|
90
|
+
* @returns {{url: string, source: "env"|"file"|"dsh_web_url"|""}}
|
|
91
|
+
*/
|
|
92
|
+
export function resolveUpstreamHint({ relayDir = "", env = process.env } = {}) {
|
|
93
|
+
const explicit = upstreamFromEnv(env);
|
|
94
|
+
if (explicit) return { url: explicit, source: "env" };
|
|
95
|
+
const fromFile = upstreamFromFile(relayDir);
|
|
96
|
+
if (fromFile) return { url: fromFile, source: "file" };
|
|
97
|
+
const fromWebUrl = upstreamFromDshWebUrl(env);
|
|
98
|
+
if (fromWebUrl) return { url: fromWebUrl, source: "dsh_web_url" };
|
|
99
|
+
return { url: "", source: "" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------- 实测端口(best effort,任何失败都只是少一个候选,绝不影响主流程) ----------
|
|
103
|
+
|
|
104
|
+
function tryExec(cmd, args, timeoutMs = 4000) {
|
|
105
|
+
try {
|
|
106
|
+
return String(execFileSync(cmd, args, { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"] }) || "");
|
|
107
|
+
} catch {
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 某进程正在 LISTEN 的 TCP 端口。macOS/Linux 用 lsof,Windows 用 netstat -ano。 */
|
|
113
|
+
export function listeningPortsOfPid(pid, platform = process.platform) {
|
|
114
|
+
const n = Number(pid);
|
|
115
|
+
if (!Number.isInteger(n) || n <= 0) return [];
|
|
116
|
+
const ports = new Set();
|
|
117
|
+
if (platform === "win32") {
|
|
118
|
+
for (const line of tryExec("netstat", ["-ano"], 6000).split(/\r?\n/)) {
|
|
119
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
120
|
+
const cols = line.trim().split(/\s+/);
|
|
121
|
+
if (cols.length < 5) continue;
|
|
122
|
+
if (Number(cols[cols.length - 1]) !== n) continue;
|
|
123
|
+
const m = /:(\d+)$/.exec(cols[1] || "");
|
|
124
|
+
if (m) ports.add(Number(m[1]));
|
|
125
|
+
}
|
|
126
|
+
return [...ports];
|
|
127
|
+
}
|
|
128
|
+
const out = tryExec("lsof", ["-nP", "-a", "-p", String(n), "-iTCP", "-sTCP:LISTEN"], 5000);
|
|
129
|
+
for (const line of out.split("\n")) {
|
|
130
|
+
const m = /:(\d+)\s*\(LISTEN\)/.exec(line);
|
|
131
|
+
if (m) ports.add(Number(m[1]));
|
|
132
|
+
}
|
|
133
|
+
return [...ports];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 祖先进程 pid(最多 6 层):被插件/宿主拉起的 watcher、bridge 走这条路能找到宿主。 */
|
|
137
|
+
export function ancestorPids(startPid = process.pid, maxDepth = 6, platform = process.platform) {
|
|
138
|
+
if (platform === "win32") return []; // Windows 无 ps;靠 netstat+pgrep 不可行,交给端口文件与静态候选
|
|
139
|
+
const out = [];
|
|
140
|
+
let pid = Number(startPid);
|
|
141
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
142
|
+
const txt = tryExec("ps", ["-o", "ppid=", "-p", String(pid)], 2000).trim();
|
|
143
|
+
const ppid = Number(txt);
|
|
144
|
+
if (!Number.isInteger(ppid) || ppid <= 1) break;
|
|
145
|
+
out.push(ppid);
|
|
146
|
+
pid = ppid;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 本机 dsh 相关进程(pgrep)—— Desktop 壳/独立 dsh web 都可能命中。 */
|
|
152
|
+
export function dshProcessPids(platform = process.platform) {
|
|
153
|
+
if (platform === "win32") return [];
|
|
154
|
+
const out = tryExec("pgrep", ["-f", "dsh"], 4000);
|
|
155
|
+
return out
|
|
156
|
+
.split(/\s+/)
|
|
157
|
+
.map((s) => Number(s))
|
|
158
|
+
.filter((n) => Number.isInteger(n) && n > 1 && n !== process.pid);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 候选列表(已去重,按"可能性"排序),每项带**来源标注**(用于日志与测试断言)。
|
|
163
|
+
* 顺序 = 提示里的端口 → 显式额外端口 → DSH_WEB_URL 的端口 → 祖先/dsh 进程实测监听端口
|
|
164
|
+
* → 3080 → Desktop 默认端口区间。
|
|
165
|
+
* @returns {Array<{port:number, source:string}>}
|
|
166
|
+
*/
|
|
167
|
+
export function candidatePorts({ relayDir = "", env = process.env, extraPorts = [], platform = process.platform } = {}) {
|
|
168
|
+
const out = [];
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
const push = (p, source) => {
|
|
171
|
+
const n = Number(p);
|
|
172
|
+
if (!Number.isInteger(n) || n <= 0 || n >= 65536 || seen.has(n)) return;
|
|
173
|
+
seen.add(n);
|
|
174
|
+
out.push({ port: n, source });
|
|
175
|
+
};
|
|
176
|
+
const hint = resolveUpstreamHint({ relayDir, env });
|
|
177
|
+
push(portOf(hint.url), hint.source || "hint");
|
|
178
|
+
for (const p of extraPorts) push(p, "extra");
|
|
179
|
+
push(portOf(upstreamFromDshWebUrl(env)), "dsh_web_url");
|
|
180
|
+
const pids = [...new Set([process.pid, ...ancestorPids(process.pid, 6, platform), ...dshProcessPids(platform)])];
|
|
181
|
+
for (const pid of pids) for (const p of listeningPortsOfPid(pid, platform)) push(p, "process");
|
|
182
|
+
push(3080, "static");
|
|
183
|
+
for (let i = 0; i < DESKTOP_PORT_SCAN; i++) push(DESKTOP_DEFAULT_PORT + i, "static");
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------- 身份校验 ----------
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 这个地址**真的**是 dsh web 吗?
|
|
191
|
+
*
|
|
192
|
+
* 为什么必须有:候选探测会去敲 43120 这类可能被别的程序占用的端口。只看"端口有响应"就把
|
|
193
|
+
* bridge 接上去,用户会得到一个"能连上但不工作"的怪状态,而且等于把这台机器的流量交给陌生服务。
|
|
194
|
+
* 判据取 dsh web 自身的稳定特征(HTML/标题/鉴权文案),任一命中即通过。
|
|
195
|
+
*/
|
|
196
|
+
export const DSH_WEB_MARKERS = [
|
|
197
|
+
"__ModuleLoader__",
|
|
198
|
+
"dsh-client-modules",
|
|
199
|
+
"deepseek harness",
|
|
200
|
+
"dsh web authentication required",
|
|
201
|
+
"dsh-remote"
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
export function looksLikeDshWeb(status, text) {
|
|
205
|
+
const body = String(text || "").toLowerCase();
|
|
206
|
+
if (!body) return false;
|
|
207
|
+
if (status >= 500) return false;
|
|
208
|
+
return DSH_WEB_MARKERS.some((m) => body.includes(m));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 探测一个候选:GET `/`,带身份校验。
|
|
213
|
+
* @returns {Promise<boolean>}
|
|
214
|
+
*/
|
|
215
|
+
export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl } = {}) {
|
|
216
|
+
const target = normalizeUpstream(url);
|
|
217
|
+
if (!target) return false;
|
|
218
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
219
|
+
if (typeof doFetch !== "function") return false;
|
|
220
|
+
try {
|
|
221
|
+
const ac = new AbortController();
|
|
222
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
223
|
+
let res;
|
|
224
|
+
try {
|
|
225
|
+
// redirect: manual —— 不跟随跳转(跳走的多半不是我们要的宿主)
|
|
226
|
+
res = await doFetch(`${target}/`, { signal: ac.signal, redirect: "manual" });
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
}
|
|
230
|
+
const status = Number(res && res.status) || 0;
|
|
231
|
+
if (status >= 500) return false;
|
|
232
|
+
// 只读前 8KB 足够命中特征;读流失败按"不是"处理(宁可少一个候选,不可误接)
|
|
233
|
+
let text = "";
|
|
234
|
+
try {
|
|
235
|
+
if (res.body && typeof res.body.getReader === "function") {
|
|
236
|
+
const reader = res.body.getReader();
|
|
237
|
+
const { value } = await reader.read();
|
|
238
|
+
text = value ? Buffer.from(value).toString("utf8") : "";
|
|
239
|
+
try { await reader.cancel(); } catch { /* ignore */ }
|
|
240
|
+
} else if (typeof res.text === "function") {
|
|
241
|
+
text = await res.text();
|
|
242
|
+
}
|
|
243
|
+
} catch { /* 读失败 → text 为空 → 判否 */ }
|
|
244
|
+
return looksLikeDshWeb(status, text.slice(0, 8192));
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 发现上游地址(带身份校验)。
|
|
252
|
+
*
|
|
253
|
+
* @param {number[]} [extraPorts] 调用方已知的额外候选(排在静态候选之前;测试与特化部署用)。
|
|
254
|
+
* @returns {Promise<{url: string, source: string, probed: number[], rejected: string[]}>}
|
|
255
|
+
* source: env | file | dsh_web_url | process | static | extra | fallback
|
|
256
|
+
*/
|
|
257
|
+
export async function discoverUpstream({
|
|
258
|
+
relayDir = "",
|
|
259
|
+
env = process.env,
|
|
260
|
+
timeoutMs = PROBE_TIMEOUT_MS,
|
|
261
|
+
fetchImpl,
|
|
262
|
+
platform = process.platform,
|
|
263
|
+
extraPorts = [],
|
|
264
|
+
log
|
|
265
|
+
} = {}) {
|
|
266
|
+
const hint = resolveUpstreamHint({ relayDir, env });
|
|
267
|
+
// ① 显式环境变量:部署方说是什么就是什么(不探测,保持既有语义)
|
|
268
|
+
if (hint.source === "env") return { url: hint.url, source: "env", probed: [], rejected: [] };
|
|
269
|
+
// ② 提示地址先验证:文件/DSh_WEB_URL 都可能过期(宿主换端口/换了程序占用)
|
|
270
|
+
if (hint.url && (await probeDshWeb(hint.url, { timeoutMs, fetchImpl }))) {
|
|
271
|
+
return { url: hint.url, source: hint.source, probed: [portOf(hint.url)], rejected: [] };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ③ 候选并行探测(本地端口,拒绝是瞬时的;并行是为了不让 watcher 卡住)
|
|
275
|
+
const hintPort = portOf(hint.url);
|
|
276
|
+
const entries = candidatePorts({ relayDir, env, platform, extraPorts }).filter((e) => e.port !== hintPort);
|
|
277
|
+
const results = await Promise.all(entries.map(async (e) => ({ ...e, ok: await probeDshWeb(`http://127.0.0.1:${e.port}`, { timeoutMs, fetchImpl }) })));
|
|
278
|
+
const hit = results.find((r) => r.ok);
|
|
279
|
+
if (hit) {
|
|
280
|
+
const url = `http://127.0.0.1:${hit.port}`;
|
|
281
|
+
if (typeof log === "function") log(`[upstream] 动态发现上游 ${url}(来源 ${hit.source};提示 ${hint.url || "无"} 不可用)`);
|
|
282
|
+
return { url, source: hit.source, probed: entries.map((e) => e.port), rejected: hint.url ? [hint.url] : [] };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ④ 都没找到:保持历史行为(3080),让上层按"上游没起来"正常重试
|
|
286
|
+
return {
|
|
287
|
+
url: hint.url || FALLBACK_UPSTREAM,
|
|
288
|
+
source: hint.url ? hint.source : "fallback",
|
|
289
|
+
probed: entries.map((e) => e.port),
|
|
290
|
+
rejected: hint.url ? [hint.url] : []
|
|
291
|
+
};
|
|
292
|
+
}
|
package/runtime/dsh-setup.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import path from "node:path";
|
|
|
29
29
|
import { spawn, spawnSync, execSync } from "node:child_process";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { childStopped, stopChildGracefully } from "./clients/dsh-remote/src/lifecycle.mjs";
|
|
32
|
+
import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./clients/dsh-remote/upstream-discovery.mjs";
|
|
32
33
|
|
|
33
34
|
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
|
|
34
35
|
const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
|
|
@@ -779,14 +780,21 @@ function restartBridgeService() {
|
|
|
779
780
|
* 【2026-09-23 用户实测事故】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
|
|
780
781
|
* 不在默认端口(`dsh web --port 8090`、profile 里配了 port、或 `--port 0` 让系统分配空闲端口),
|
|
781
782
|
* watcher 的在线探测就**永远**失败 → bridge 永远不启动,而面板只会说「正在启动 Bridge…」。
|
|
782
|
-
* 优先级:显式环境变量 > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
|
|
783
|
-
*
|
|
784
|
-
*
|
|
783
|
+
* 优先级:显式环境变量 > **运行时动态发现** > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
|
|
784
|
+
*
|
|
785
|
+
* 为什么还要「动态发现」这一层(2026-09-23 第二起实测,DSH Desktop):
|
|
786
|
+
* DSH Desktop(Electron 壳 `dsh-plugin-desktop`)默认把 Web 服务放在 **43120**,被占用还会 +1;
|
|
787
|
+
* 而端口文件要等**插件半**加载后才有。于是「端口文件还没写」或「写的是上一轮的旧端口」时,
|
|
788
|
+
* 光靠文件仍会永久探错端口。发现逻辑(含 DSH 进程实际监听端口 + Desktop 端口区间 + 身份校验)
|
|
789
|
+
* 统一在 clients/dsh-remote/upstream-discovery.mjs,watcher 与 bridge 共用同一份实现。
|
|
785
790
|
*/
|
|
786
791
|
const UPSTREAM_FILE = ".dsh-upstream";
|
|
792
|
+
/** 运行时动态发现到的上游(优于可能过期的端口文件;进程内缓存)。 */
|
|
793
|
+
let discoveredUpstream = "";
|
|
787
794
|
function upstreamUrl() {
|
|
788
795
|
const explicit = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim();
|
|
789
796
|
if (explicit) return explicit.replace(/\/+$/, "");
|
|
797
|
+
if (discoveredUpstream) return discoveredUpstream;
|
|
790
798
|
try {
|
|
791
799
|
const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
|
|
792
800
|
if (/^https?:\/\/[^\s]+$/i.test(fromFile)) return fromFile.replace(/\/+$/, "");
|
|
@@ -798,28 +806,69 @@ function upstreamUrl() {
|
|
|
798
806
|
if (u.port) return `http://127.0.0.1:${u.port}`; // bridge 的 loopback 围栏只认 127.0.0.1
|
|
799
807
|
} catch { /* 非 URL:忽略 */ }
|
|
800
808
|
}
|
|
801
|
-
return
|
|
809
|
+
return FALLBACK_UPSTREAM;
|
|
802
810
|
}
|
|
803
811
|
/** 上游端口(诊断/日志用;取不到就回退 3080)。 */
|
|
804
812
|
function upstreamPort() {
|
|
805
813
|
try { return Number(new URL(upstreamUrl()).port) || 3080; } catch { return 3080; }
|
|
806
814
|
}
|
|
807
815
|
|
|
816
|
+
/** 发现结果缓存与冷却:避免每轮轮询都去 lsof/pgrep + 逐个端口探测。 */
|
|
817
|
+
let upstreamDiscoveryAt = 0;
|
|
818
|
+
let upstreamDiscoveryInflight = null;
|
|
819
|
+
const UPSTREAM_DISCOVERY_COOLDOWN_MS = 15_000;
|
|
820
|
+
|
|
808
821
|
/**
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
* 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
|
|
822
|
+
* 动态发现上游(带身份校验),成功后写入 `discoveredUpstream` 供全局复用。
|
|
823
|
+
* @param {boolean} force 忽略冷却(探测失败后的重试用)
|
|
812
824
|
*/
|
|
813
|
-
async function
|
|
825
|
+
async function refreshUpstreamDiscovery(force = false) {
|
|
826
|
+
if (upstreamDiscoveryInflight) return upstreamDiscoveryInflight;
|
|
827
|
+
const now = Date.now();
|
|
828
|
+
if (!force && now - upstreamDiscoveryAt < UPSTREAM_DISCOVERY_COOLDOWN_MS) return upstreamUrl();
|
|
829
|
+
upstreamDiscoveryAt = now;
|
|
830
|
+
upstreamDiscoveryInflight = (async () => {
|
|
831
|
+
try {
|
|
832
|
+
const found = await discoverUpstream({ relayDir: CONFIG_DIR, log: (m) => console.log(`[dsh-remote] ${m}`) });
|
|
833
|
+
// 「提示地址验证通过」与「候选命中」都值得记下来;fallback(什么都没找到)不污染现有判断
|
|
834
|
+
if (found.url && found.source !== "fallback") discoveredUpstream = found.url;
|
|
835
|
+
return upstreamUrl();
|
|
836
|
+
} catch {
|
|
837
|
+
return upstreamUrl();
|
|
838
|
+
} finally {
|
|
839
|
+
upstreamDiscoveryInflight = null;
|
|
840
|
+
}
|
|
841
|
+
})();
|
|
842
|
+
return upstreamDiscoveryInflight;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** 上游是否**有响应**(判据刻意宽松:4xx/5xx 也算"端口在听",那种情况问题在别处)。 */
|
|
846
|
+
async function upstreamReachable(url = upstreamUrl(), timeoutMs = 1200) {
|
|
814
847
|
try {
|
|
815
848
|
const ac = new AbortController();
|
|
816
849
|
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
817
|
-
await fetch(
|
|
850
|
+
await fetch(url + "/", { signal: ac.signal });
|
|
818
851
|
clearTimeout(timer);
|
|
819
852
|
return true;
|
|
820
853
|
} catch { return false; }
|
|
821
854
|
}
|
|
822
855
|
|
|
856
|
+
/**
|
|
857
|
+
* dsh web 是否正在运行(端口以 upstreamUrl() 为准,**绝不写死 3080**)。
|
|
858
|
+
* bridge 本身依赖 dsh web 才工作:dsh web 没开时 bridge 起来也会立刻退出,
|
|
859
|
+
* 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
|
|
860
|
+
*
|
|
861
|
+
* 探测失败时会**主动做一次动态发现**再试一次 —— 这一条正是 DSH Desktop 场景的关键:
|
|
862
|
+
* 上游在 43120(Desktop 默认,占用还会 +1),而端口文件可能还没写或写着上一轮的旧端口。
|
|
863
|
+
*/
|
|
864
|
+
async function isDshWebUp(timeoutMs = 1200) {
|
|
865
|
+
if (await upstreamReachable(upstreamUrl(), timeoutMs)) return true;
|
|
866
|
+
const before = upstreamUrl();
|
|
867
|
+
const after = await refreshUpstreamDiscovery(true);
|
|
868
|
+
if (after === before) return false;
|
|
869
|
+
return upstreamReachable(after, timeoutMs);
|
|
870
|
+
}
|
|
871
|
+
|
|
823
872
|
/**
|
|
824
873
|
* 解析 dsh web profile 目录(安装与插件管理共用同一口径)。
|
|
825
874
|
*/
|
|
@@ -1125,14 +1174,13 @@ async function runBridge() {
|
|
|
1125
1174
|
/** 限流提示只打一次,避免刷屏。 */
|
|
1126
1175
|
let warnedRateLimit = false;
|
|
1127
1176
|
|
|
1128
|
-
// watcher:检测 dsh web
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
fetch(upstreamUrl() + "/")
|
|
1132
|
-
.then(() => { clearTimeout(t); resolve(true); })
|
|
1133
|
-
.catch(() => { clearTimeout(t); resolve(false); });
|
|
1134
|
-
});
|
|
1177
|
+
// watcher:检测 dsh web 是否存活(端口动态解析,绝不写死 3080),存活才启动 bridge。
|
|
1178
|
+
// 走 isDshWebUp():探测失败会自动做一次动态发现(DSH Desktop 的 43120 / 自定义端口都靠它)。
|
|
1179
|
+
const checkUpstream = () => isDshWebUp(2000);
|
|
1135
1180
|
|
|
1181
|
+
// 先做一次动态发现再打印/派生:否则日志会长期写着错误的端口(用户实测就卡在
|
|
1182
|
+
// 「等待 dsh web(127.0.0.1:3080)启动...」而真实上游在 43120)。
|
|
1183
|
+
await refreshUpstreamDiscovery(true);
|
|
1136
1184
|
console.log(`[dsh-remote] 等待 dsh web(${upstreamUrl()})启动...`);
|
|
1137
1185
|
let bridgeProc = null;
|
|
1138
1186
|
let starting = false;
|