@mrrisega/dsh-remote 0.6.12 → 0.6.13
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 +123 -2
- package/clients/dsh-remote/mobile-adapter.mjs +108 -4
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
- package/package.json +2 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +123 -2
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +108 -4
|
@@ -505,6 +505,15 @@ export async function handleFrame(dchOrSend, frame) {
|
|
|
505
505
|
if (id === undefined || id === null) return;
|
|
506
506
|
const send = toSender(dchOrSend);
|
|
507
507
|
if (type === "http") return handleHttpFrame(send, frame);
|
|
508
|
+
if (type === "http-abort") {
|
|
509
|
+
// 手机侧断开了这条流(切页/锁屏):掐掉上游,别在电脑上留一条永不结束的 SSE 连接。
|
|
510
|
+
const c = streamAborts.get(id);
|
|
511
|
+
if (c) {
|
|
512
|
+
streamAborts.delete(id);
|
|
513
|
+
try { c.abort(); } catch { /* ignore */ }
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
508
517
|
if (type === "ws-open") return handleWsOpen(send, frame);
|
|
509
518
|
if (type === "ws-msg") return handleWsMessage(send, frame);
|
|
510
519
|
if (type === "ws-close") return handleWsClose(send, frame);
|
|
@@ -541,7 +550,10 @@ function headerValue(headers, name) {
|
|
|
541
550
|
* 保守策略:任一条件不满足都返回 null(原样回传):
|
|
542
551
|
* - 方法非 HEAD、状态非 204/304;
|
|
543
552
|
* - buf ≥ 1KB(太小不值得压);
|
|
544
|
-
* -
|
|
553
|
+
* - `contentEncoding` 为空 —— ⚠️ 它指的是**手上这份 buf 的编码**,不是上游响应头的值:
|
|
554
|
+
* 上游头的 "gzip" 在 undici 解压后依然留着(见下),照抄过来只会让我们白丢压缩收益;
|
|
555
|
+
* 调用方必须传"buf 是否已编码",dsh-bridge 的 doHttp 因此**固定传空串**。
|
|
556
|
+
* 若哪天 buf 真是一段未解压的字节流,调用方才应把对应编码传进来(函数会保守放弃压缩)。
|
|
545
557
|
* - 请求 Accept-Encoding 含 gzip(手机浏览器必带;没有就不压,避免手机不会解压);
|
|
546
558
|
* - content-type 可压缩(排除 text/event-stream);
|
|
547
559
|
* - gzip 后确实更小(极小文件/不可压数据 gzip 反而更大,保守判断)。
|
|
@@ -565,6 +577,97 @@ export async function maybeCompressResponse({ buf, contentType, contentEncoding,
|
|
|
565
577
|
return { buf: out, headers: { "content-encoding": "gzip" } };
|
|
566
578
|
}
|
|
567
579
|
|
|
580
|
+
/**
|
|
581
|
+
* 进行中的 SSE 流:frameId → AbortController。
|
|
582
|
+
* 手机侧断开时,中继会发 `http-abort`,这里据此掐掉上游(否则电脑上会留一条永不结束的连接)。
|
|
583
|
+
*/
|
|
584
|
+
const streamAborts = new Map();
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* 中继在 `tunnel-register-ok` 里声明支持的**追加帧能力**。
|
|
588
|
+
*
|
|
589
|
+
* 为什么必须协商:browser→中继→bridge 是两段**独立升级**的链路。
|
|
590
|
+
* 新 bridge 的流式帧(`http-chunk`/`http-end`)对**旧中继**是不认识的 —— 旧中继会把
|
|
591
|
+
* "仅头部"帧当成一个完整的空响应直接 `end()`,SSE 于是变成"秒断 + 浏览器狂重连",
|
|
592
|
+
* 比原来的 120 秒挂起**更糟**。所以能力由中继声明:拿不到 `http-stream` 就老实退回
|
|
593
|
+
* 旧的整包缓冲行为(慢,但正确)。默认空集合 = 保守(连不上/旧中继都不冒险用新帧)。
|
|
594
|
+
*/
|
|
595
|
+
const routerCaps = new Set();
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* SSE(`text/event-stream`)流式转发。
|
|
599
|
+
*
|
|
600
|
+
* 为什么必须流式:`doHttp` 是「整包 `await res.arrayBuffer()`」的语义,而 SSE **永不结束** ——
|
|
601
|
+
* 实测 DSH 的 `/plugins/events`(client-hmr 的事件通道)每次页面加载都把这条请求挂满
|
|
602
|
+
* bridge 的 120s 上游超时,然后报错;浏览器 EventSource 立刻重连,于是手机端 Network 里
|
|
603
|
+
* **永远有一条转圈的请求**,页面也始终拿不到事件。
|
|
604
|
+
*
|
|
605
|
+
* 帧协议(对中继**追加**、与既有 `http` 帧并存;不认识 streaming 的旧中继只会忽略后续
|
|
606
|
+
* chunk,退化成"空 body 后结束",不会把页面打挂):
|
|
607
|
+
* ← { id, type:"http", status, headers, streaming:true } 仅头部,无 body
|
|
608
|
+
* ← { id, type:"http-chunk", seq, body, bodyBase64:true } 0..n 次
|
|
609
|
+
* ← { id, type:"http-end", seq } 流结束(含出错收尾)
|
|
610
|
+
* → { id, type:"http-abort" } 手机断开 → 掐上游
|
|
611
|
+
*
|
|
612
|
+
* ⚠️ E2EE 路径**不**走这里:信封是"一问一答"的结构,SSE 要逐块封/解需要另立协议;
|
|
613
|
+
* 而现实里 SSE 由 `EventSource` 发出、shim 不接管它(不带头部信封标记),
|
|
614
|
+
* 所以密文流根本不存在 —— 真要做时再单独设计,不要在这里偷偷降级成明文。
|
|
615
|
+
*
|
|
616
|
+
* @returns {Promise<{status:number, chunks:number, bytes:number, aborted?:boolean}>}
|
|
617
|
+
*/
|
|
618
|
+
async function doHttpStream(send, id, method, path, reqHeaders) {
|
|
619
|
+
const safe = safePath(path);
|
|
620
|
+
if (safe === null) throw new Error("非法路径");
|
|
621
|
+
const reqHdrs = sanitizeRequestHeaders(reqHeaders);
|
|
622
|
+
const ck = harnessCookieOf(); // 新版 dsh web 的浏览器会话 Cookie(否则 401 白页)
|
|
623
|
+
if (ck) reqHdrs.Cookie = ck;
|
|
624
|
+
const controller = new AbortController();
|
|
625
|
+
streamAborts.set(id, controller);
|
|
626
|
+
|
|
627
|
+
let res;
|
|
628
|
+
try {
|
|
629
|
+
res = await fetch(`${UPSTREAM}${safe}`, { method, headers: reqHdrs, signal: controller.signal });
|
|
630
|
+
} catch (e) {
|
|
631
|
+
streamAborts.delete(id);
|
|
632
|
+
throw e; // 头部都还没发出去 → 交给调用方回一个普通错误帧
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const status = res.status;
|
|
636
|
+
// 头部帧:告诉中继"这是一条流",随后才逐块推 body
|
|
637
|
+
send({
|
|
638
|
+
id,
|
|
639
|
+
type: "http",
|
|
640
|
+
status,
|
|
641
|
+
headers: sanitizeResponseHeaders(Object.fromEntries(res.headers.entries())),
|
|
642
|
+
streaming: true
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
let seq = 0;
|
|
646
|
+
let bytes = 0;
|
|
647
|
+
try {
|
|
648
|
+
const reader = res.body && typeof res.body.getReader === "function" ? res.body.getReader() : null;
|
|
649
|
+
if (reader) {
|
|
650
|
+
for (;;) {
|
|
651
|
+
const { done, value } = await reader.read();
|
|
652
|
+
if (done) break;
|
|
653
|
+
if (!value || value.length === 0) continue;
|
|
654
|
+
bytes += value.length;
|
|
655
|
+
send({ id, type: "http-chunk", seq: ++seq, body: Buffer.from(value).toString("base64"), bodyBase64: true });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
} catch (e) {
|
|
659
|
+
// 头部已经发出去了:这里**绝不能**抛(调用方会用普通帧回 502,而中继那边响应头已定,
|
|
660
|
+
// 再写一次 head 会炸)。统一以 http-end 收尾,让 EventSource 按自己的节奏重连。
|
|
661
|
+
const aborted = controller.signal.aborted === true;
|
|
662
|
+
streamAborts.delete(id);
|
|
663
|
+
send({ id, type: "http-end", seq, ...(aborted ? { aborted: true } : { error: String(e?.message || e) }) });
|
|
664
|
+
return { status, chunks: seq, bytes, aborted };
|
|
665
|
+
}
|
|
666
|
+
streamAborts.delete(id);
|
|
667
|
+
send({ id, type: "http-end", seq });
|
|
668
|
+
return { status, chunks: seq, bytes };
|
|
669
|
+
}
|
|
670
|
+
|
|
568
671
|
async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
569
672
|
const safe = safePath(path);
|
|
570
673
|
if (safe === null) throw new Error("非法路径");
|
|
@@ -624,7 +727,14 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
624
727
|
const compressed = await maybeCompressResponse({
|
|
625
728
|
buf,
|
|
626
729
|
contentType,
|
|
627
|
-
|
|
730
|
+
// 🔴 必须传空:这里的 `buf` 是 **undici 已经解压过**的明文,不是上游线速字节。
|
|
731
|
+
// 以前传的是 `res.headers.get("content-encoding")` —— 而 undici 解压之后**仍会把该头留着**
|
|
732
|
+
// (本文件 sanitizeResponseHeaders 的注释正是这么写的:"undici 已自动解压 body,
|
|
733
|
+
// content-encoding/length 会误导浏览器")。于是 maybeCompressResponse 看到 "gzip"
|
|
734
|
+
// 就保守地 `return null`,**永不重压** —— 实测代价:线上 dsh web 的 `/plugins/??`
|
|
735
|
+
// 聚合包本来 gzip 后 5.12MB,被原样发出 13.39MB(2.6×),免费档 1Mbps 下从 40 秒变成 100+ 秒。
|
|
736
|
+
// 传空之后语义才是自洽的:"我手上这份 buf 是未编码的明文,可以压"。
|
|
737
|
+
contentEncoding: "",
|
|
628
738
|
acceptEncoding: headerValue(reqHeaders, "accept-encoding"),
|
|
629
739
|
status: res.status,
|
|
630
740
|
method
|
|
@@ -693,6 +803,13 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
693
803
|
}
|
|
694
804
|
}
|
|
695
805
|
try {
|
|
806
|
+
// SSE 必须流式转发(否则会挂满 120s 超时,见 doHttpStream 注释)。
|
|
807
|
+
// 客户端是 EventSource 时必带 Accept: text/event-stream,以此为准(不看路径白名单,免得漏)。
|
|
808
|
+
if (routerCaps.has("http-stream") && String(headerValue(headers, "accept") || "").includes("text/event-stream")) {
|
|
809
|
+
const s = await doHttpStream(send, id, method, path, headers);
|
|
810
|
+
console.log(`[bridge] ${method} ${path} → SSE ${s.status} (${Date.now() - t0}ms, ${s.chunks} 块 / ${(s.bytes / 1024).toFixed(1)}KB${s.aborted ? ", 手机已断开" : ""})`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
696
813
|
const reply = await doHttp(method, path, headers, body, !!isB64);
|
|
697
814
|
reply.id = id;
|
|
698
815
|
reply.type = "http";
|
|
@@ -1157,6 +1274,9 @@ function connectTunnel(token) {
|
|
|
1157
1274
|
try {
|
|
1158
1275
|
receive(raw, (frame) => {
|
|
1159
1276
|
if (frame?.type === "tunnel-register-ok") {
|
|
1277
|
+
// 记下中继声明的追加帧能力(旧中继不带 caps → 空集合 → 退回整包缓冲,见 routerCaps 注释)
|
|
1278
|
+
routerCaps.clear();
|
|
1279
|
+
for (const c of Array.isArray(frame.caps) ? frame.caps : []) routerCaps.add(String(c));
|
|
1160
1280
|
// 中继注册成功 = 手机端此刻真的能进这台电脑:面板据此把阶段推进到 online(已连接 ✅)
|
|
1161
1281
|
persistBridgeState({ device_id: DEVICE_ID, tunnel_registered_at: Date.now(), phase: "online", last_error: null });
|
|
1162
1282
|
console.log(`[bridge] ✅ router 注册成功: ${DEVICE_ID},等待手机访问 /remote/${DEVICE_ID}/`);
|
|
@@ -1172,6 +1292,7 @@ function connectTunnel(token) {
|
|
|
1172
1292
|
} catch (e) { console.log(`[bridge] 隧道帧错误: ${e.message}`); }
|
|
1173
1293
|
});
|
|
1174
1294
|
ws.on("close", (code, reason) => {
|
|
1295
|
+
routerCaps.clear(); // 换中继/断线:能力重新协商,期间一律退回保守行为
|
|
1175
1296
|
clearInterval(heartbeat);
|
|
1176
1297
|
console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
|
|
1177
1298
|
closeAllWsSessions();
|
|
@@ -1096,6 +1096,36 @@ const SCRIPT = `(() => {
|
|
|
1096
1096
|
} catch (e8) { /* 引导条失败绝不能影响主功能 */ }
|
|
1097
1097
|
};
|
|
1098
1098
|
|
|
1099
|
+
/* 适配层自己创建的「抽屉入口」引用(汉堡按钮 / 遮罩),提到 boot() 外层保存。
|
|
1100
|
+
为什么必须提到外层:官方 SPA 重渲染时会整片替换 body 子树,把这两个节点一起冲掉;
|
|
1101
|
+
留住引用才能在下面的「存在性看门狗」里把**同一个节点**补回去(节点不变 → 挂在它上面的
|
|
1102
|
+
click 处理、CSS 类、aria 属性全都原样保留,不会出现"补回来点不动"的假按钮)。 */
|
|
1103
|
+
let maHamburger = null, maScrim = null;
|
|
1104
|
+
|
|
1105
|
+
/* 节点是否已经脱离文档。判不出来时一律当作"还在"—— 宁可不补,也绝不重复插入。 */
|
|
1106
|
+
const maDetached = (el) => {
|
|
1107
|
+
if (!el) return false;
|
|
1108
|
+
try { if (typeof el.isConnected === "boolean") return !el.isConnected; } catch (eConn) { /* 继续退化 */ }
|
|
1109
|
+
try {
|
|
1110
|
+
const b = document.body;
|
|
1111
|
+
if (b && typeof b.contains === "function") return !b.contains(el);
|
|
1112
|
+
} catch (eHas) { /* 继续退化 */ }
|
|
1113
|
+
return false;
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
/* —— 存在性看门狗:「建好了」不等于「一直都在」----------------------------------
|
|
1117
|
+
done=true 只保证**曾经**建出来过。官方在路由/会话切换时会重建 DOM 子树,我们的汉堡按钮
|
|
1118
|
+
与遮罩会跟着消失 —— 用户看到的就是"抽屉按钮用着用着又没了"。
|
|
1119
|
+
这里按**引用**补回(不是重建),只在窄屏做(宽屏本来就不需要抽屉入口)。
|
|
1120
|
+
幂等:节点还在文档里 → 什么都不做;boot 还没成功(引用为 null) → 同样什么都不做。 */
|
|
1121
|
+
const ensureDrawerChrome = () => {
|
|
1122
|
+
try {
|
|
1123
|
+
if (!NARROW() || !document.body) return;
|
|
1124
|
+
if (maDetached(maScrim)) document.body.appendChild(maScrim);
|
|
1125
|
+
if (maDetached(maHamburger)) document.body.appendChild(maHamburger);
|
|
1126
|
+
} catch (eChrome) { /* 补不回去也不能影响抽屉开合本身 */ }
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1099
1129
|
let done = false;
|
|
1100
1130
|
const boot = () => {
|
|
1101
1131
|
if (!HOSTISH()) return;
|
|
@@ -1228,6 +1258,7 @@ const SCRIPT = `(() => {
|
|
|
1228
1258
|
const scrim = document.createElement("div");
|
|
1229
1259
|
scrim.className = "dsh-ma-scrim";
|
|
1230
1260
|
document.body.appendChild(scrim);
|
|
1261
|
+
maScrim = scrim; // 供「存在性看门狗」在被官方冲掉后补回
|
|
1231
1262
|
scrim.addEventListener("click", (e) => {
|
|
1232
1263
|
e.stopPropagation();
|
|
1233
1264
|
userWantsOpen = false; // 用户明确要关(遮罩的作用就是关抽屉)
|
|
@@ -1245,6 +1276,7 @@ const SCRIPT = `(() => {
|
|
|
1245
1276
|
hamburger.setAttribute("aria-label", "打开菜单");
|
|
1246
1277
|
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
1278
|
document.body.appendChild(hamburger);
|
|
1279
|
+
maHamburger = hamburger; // 供「存在性看门狗」在被官方冲掉后补回
|
|
1248
1280
|
hamburger.addEventListener("click", (e) => {
|
|
1249
1281
|
e.stopPropagation(); e.preventDefault();
|
|
1250
1282
|
const nowOpen = document.documentElement.classList.contains("dsh-ma-sidebar-open");
|
|
@@ -1516,10 +1548,82 @@ const SCRIPT = `(() => {
|
|
|
1516
1548
|
};
|
|
1517
1549
|
|
|
1518
1550
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1551
|
+
|
|
1552
|
+
/* ================= 执行时机:无限期等待官方 DOM 就绪 =================
|
|
1553
|
+
用户实测原话(2026-09-2x):「在网络比较慢的情况下,左边的抽屉按钮有时候出不来。
|
|
1554
|
+
甭管网络有多慢,都要让那个抽屉能加载出来。」
|
|
1555
|
+
⚠️ 旧实现是**一组写死的固定重试**([700, 1600, 3200, 7000, 12000]ms),而汉堡按钮的创建
|
|
1556
|
+
排在 boot() 里 "if (!frame) return;" 那一行之后 —— 官方 SPA bundle 有 13.4MB(实测未压缩),
|
|
1557
|
+
免费档被中继限速到 128KB/s 时冷启动要 1-2 分钟,官方 frame 在 12 秒内根本没出现:
|
|
1558
|
+
5 次重试全部在那一行返回 → done 永远为 false → 汉堡按钮**永远**不创建
|
|
1559
|
+
→ 用户看到"左边抽屉按钮出不来"。任何固定窗口都只是把这个阈值往后挪,治不了根。
|
|
1560
|
+
现在改成「事件驱动 + 低频兜底」的**无限期**等待,只要抽屉入口还没建好就一直试:
|
|
1561
|
+
· MutationObserver(document/subtree):官方 frame 一挂上就立刻跟上(网络快时零延迟);
|
|
1562
|
+
· setInterval(1000ms):兜底 + 存在性看门狗 —— observer 不可用/被绕过时照样收敛,
|
|
1563
|
+
也负责在 done 之后发现官方重渲染冲掉了我们的节点并补回。
|
|
1564
|
+
两条路都只是"再调一次 pump()",pump 内部**全部幂等**(done / fabDone / whaleGuardsOn /
|
|
1565
|
+
settingsWatchOn / drawerAutoCloseOn 等守卫都在),重复调用不会产生任何副作用,
|
|
1566
|
+
也不改变"执行什么"——开合、遮罩不变量、sync()、isDrawer() 判定一行未动。 */
|
|
1567
|
+
let maTorn = false, maInterval = 0, maObserver = null, maRafPending = false;
|
|
1568
|
+
/* 廉价的"boot() 还有事可做吗"闸门(只读两个现成的布尔标志,不做任何 DOM 操作):
|
|
1569
|
+
· fab 还没建出来(fabDone=false) → 要跑;
|
|
1570
|
+
· 抽屉那段还没建好(done=false)且当前是窄屏 → 要跑(旋转/缩放回窄屏也能补建)。
|
|
1571
|
+
两者都不成立 → boot() 里已无事可做,**绝不能**再跑:boot() 第一行 HOSTISH() 会把
|
|
1572
|
+
documentElement.outerHTML 整个序列化一遍(最长 200KB),而官方流式输出时每帧都有 DOM
|
|
1573
|
+
变更 —— 没有这道闸门就会变成每秒几十次全量序列化(旧实现只跑 5 次,不会有这个问题)。 */
|
|
1574
|
+
const bootUseful = () => !fabDone || (!done && NARROW());
|
|
1575
|
+
const pump = () => {
|
|
1576
|
+
if (maTorn) return;
|
|
1577
|
+
if (bootUseful()) { try { boot(); } catch (eBoot) { /* 幂等重试 */ } }
|
|
1578
|
+
try { ensureDrawerChrome(); } catch (eChrome2) { /* 忽略 */ }
|
|
1579
|
+
if (!whaleGuardsOn || !settingsWatchOn) {
|
|
1580
|
+
try { whaleHookInit(); watchSettings(); } catch (eHook) { /* 两者内部都自带一次性守卫 */ }
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
/* 节流:一次 DOM 变更往往产生几十条记录,逐条 boot() 就是忙轮询。
|
|
1584
|
+
统一合并成"本帧最多跑一次"(有 rAF 用 rAF,没有就退化成 0ms 宏任务) —— 天然去重。 */
|
|
1585
|
+
const schedulePump = () => {
|
|
1586
|
+
if (maTorn || maRafPending) return;
|
|
1587
|
+
maRafPending = true;
|
|
1588
|
+
const run = () => { maRafPending = false; pump(); };
|
|
1589
|
+
try { if (typeof requestAnimationFrame === "function") { requestAnimationFrame(run); return; } } catch (eRaf) { /* 退化 */ }
|
|
1590
|
+
setTimeout(run, 0);
|
|
1591
|
+
};
|
|
1592
|
+
/* 挂上"叫醒"的两条路。做成可重入(先清旧的再建新的),这样 bfcache 恢复后能重新武装。 */
|
|
1593
|
+
const maArm = () => {
|
|
1594
|
+
maTorn = false;
|
|
1595
|
+
maRafPending = false; // 冻结时若有一帧没跑到,不清掉就会永远挡住后续调度
|
|
1596
|
+
try { if (maObserver) maObserver.disconnect(); } catch (eA1) { /* 忽略 */ }
|
|
1597
|
+
maObserver = null;
|
|
1598
|
+
try { if (maInterval) clearInterval(maInterval); } catch (eA2) { /* 忽略 */ }
|
|
1599
|
+
maInterval = 0;
|
|
1600
|
+
try {
|
|
1601
|
+
if (typeof MutationObserver === "function" && document.documentElement) {
|
|
1602
|
+
maObserver = new MutationObserver(schedulePump);
|
|
1603
|
+
/* 只观察 childList+subtree:节点被官方换掉/新增才叫醒我们。
|
|
1604
|
+
⚠️ 刻意**不**观察 attributes —— 我们自己的 sync() 就会不停改 class,那会变成自激循环。 */
|
|
1605
|
+
maObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
1606
|
+
}
|
|
1607
|
+
} catch (eObs) { maObserver = null; /* 退化:只靠下面的兜底表 */ }
|
|
1608
|
+
try { maInterval = setInterval(pump, 1000); } catch (eInt) { maInterval = 0; }
|
|
1609
|
+
};
|
|
1610
|
+
maArm();
|
|
1611
|
+
/* 清理:页面卸载时断开观察者与兜底表(bfcache/长驻页面都不留悬挂回调)。
|
|
1612
|
+
pagehide 覆盖 bfcache 场景,unload 兜底更老的浏览器。 */
|
|
1613
|
+
const maTeardown = () => {
|
|
1614
|
+
maTorn = true;
|
|
1615
|
+
try { if (maObserver) maObserver.disconnect(); } catch (eD1) { /* 忽略 */ }
|
|
1616
|
+
maObserver = null;
|
|
1617
|
+
try { if (maInterval) clearInterval(maInterval); } catch (eD2) { /* 忽略 */ }
|
|
1618
|
+
maInterval = 0;
|
|
1619
|
+
};
|
|
1620
|
+
try { window.addEventListener("pagehide", maTeardown); } catch (ePh) { /* 忽略 */ }
|
|
1621
|
+
try { window.addEventListener("unload", maTeardown); } catch (eUl) { /* 忽略 */ }
|
|
1622
|
+
/* 手机上"切到别的 App 再切回来"走的常是 bfcache:pagehide(persisted=true) → pageshow(persisted=true)。
|
|
1623
|
+
DOM 原样保留但计时器/观察者已断 —— 这里重新武装,否则回到页面后看门狗就永久失灵了。 */
|
|
1624
|
+
try {
|
|
1625
|
+
window.addEventListener("pageshow", (ev) => { try { if (ev && ev.persisted) maArm(); } catch (ePs) { /* 忽略 */ } });
|
|
1626
|
+
} catch (ePs2) { /* 忽略 */ }
|
|
1523
1627
|
} catch (e) { if (window.console) console.warn("[dsh-mobile-adapter]", e && e.message); }
|
|
1524
1628
|
})();`;
|
|
1525
1629
|
|
|
@@ -15,7 +15,7 @@ import http from "node:http";
|
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import test, { after } from "node:test";
|
|
18
|
-
import { gunzipSync } from "node:zlib";
|
|
18
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
19
19
|
|
|
20
20
|
// ---------- 本地假上游(doHttp 全链路用;端口先占再 import,UPSTREAM 在模块加载时固定) ----------
|
|
21
21
|
|
|
@@ -31,6 +31,25 @@ const upstream = http.createServer((req, res) => {
|
|
|
31
31
|
res.end(BIG_JSON);
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
|
+
/* 模拟**真实 dsh web**:上游自己就把响应 gzip 掉并回 content-encoding: gzip
|
|
35
|
+
(线上 `/plugins/??` 那个 13.4MB 的聚合包正是这样发的)。
|
|
36
|
+
这条路由是 bridge「丢弃上游压缩」那个 bug 的回归哨兵 —— 见文件末尾的集成用例。 */
|
|
37
|
+
if (req.url === "/upstream-gzip") {
|
|
38
|
+
const body = Buffer.from(BIG_JSON, "utf8");
|
|
39
|
+
if (String(req.headers["accept-encoding"] || "").includes("gzip")) {
|
|
40
|
+
const z = gzipSync(body);
|
|
41
|
+
res.writeHead(200, {
|
|
42
|
+
"content-type": "application/json; charset=utf-8",
|
|
43
|
+
"content-encoding": "gzip",
|
|
44
|
+
"content-length": String(z.length)
|
|
45
|
+
});
|
|
46
|
+
res.end(z);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
50
|
+
res.end(body);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
34
53
|
if (req.url === "/tiny") {
|
|
35
54
|
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
36
55
|
res.end(TINY_TEXT);
|
|
@@ -213,3 +232,51 @@ test("集成:204 响应不压缩", async () => {
|
|
|
213
232
|
assert.equal(reply.status, 204);
|
|
214
233
|
assert.equal(reply.headers["content-encoding"], undefined);
|
|
215
234
|
});
|
|
235
|
+
|
|
236
|
+
test("★ 回归:上游自己 gzip 过(content-encoding: gzip),bridge 仍必须重压回传", async () => {
|
|
237
|
+
/* 事故原貌(2026-09-23):
|
|
238
|
+
* undici 的 fetch 会**自动解压** body,但**保留** `content-encoding: gzip` 响应头。
|
|
239
|
+
* 旧代码把该头当作"buf 已编码"传给 maybeCompressResponse → 函数保守 return null →
|
|
240
|
+
* **永不重压**。于是线上 dsh web 的 /plugins/?? 聚合包(上游 gzip 后 5.12MB)
|
|
241
|
+
* 被按 13.39MB 原样塞进隧道(实测 3 次,每次 bridge 日志都是 13707KB)。
|
|
242
|
+
* 免费档 1Mbps 下这一条就把首屏从 ~40 秒拖到 100+ 秒。
|
|
243
|
+
*
|
|
244
|
+
* 这条用例守的是:**上游压过 ≠ 我们手上是压缩数据**。bridge 手上永远是 undici 解压后的明文,
|
|
245
|
+
* 所以必须照常走压缩分支 —— 一旦有人把这个头又接回去,这里立刻红。
|
|
246
|
+
*/
|
|
247
|
+
const reply = await request({
|
|
248
|
+
id: "g5",
|
|
249
|
+
method: "GET",
|
|
250
|
+
path: "/upstream-gzip",
|
|
251
|
+
headers: { "accept-encoding": GZIP_ACCEPT, "user-agent": "phone-browser" }
|
|
252
|
+
});
|
|
253
|
+
assert.equal(reply.status, 200);
|
|
254
|
+
assert.equal(
|
|
255
|
+
reply.headers["content-encoding"],
|
|
256
|
+
"gzip",
|
|
257
|
+
"上游压过也照样要重压 —— 少了这个头,隧道里就是 2.6 倍的白流量"
|
|
258
|
+
);
|
|
259
|
+
const raw = Buffer.from(reply.body, "base64");
|
|
260
|
+
const plainLen = Buffer.byteLength(BIG_JSON);
|
|
261
|
+
assert.ok(raw.length < plainLen, `重压后必须小于明文 (${raw.length} < ${plainLen})`);
|
|
262
|
+
assert.ok(
|
|
263
|
+
raw.length < plainLen * 0.6,
|
|
264
|
+
`应当真正压下去(实测明文 ${plainLen} → ${raw.length});若接近明文说明压缩没生效`
|
|
265
|
+
);
|
|
266
|
+
// 只解压一次就应得到原文 —— 这一条同时排除"双重 gzip"这种更坏的结果
|
|
267
|
+
assert.equal(gunzipSync(raw).toString("utf8"), BIG_JSON, "只能有一层 gzip;解压一次必须就是原文");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("★ 回归:上游 gzip + 客户端没带 Accept-Encoding → 不压缩,但内容仍是明文原文", async () => {
|
|
271
|
+
// 上游按 accept-encoding 决定压不压;bridge 解压后原样回传(不带 content-encoding),
|
|
272
|
+
// 手机端按 identity 处理 → 内容正确,只是没省流量。绝不能出现"标了 gzip 其实是明文"。
|
|
273
|
+
const reply = await request({
|
|
274
|
+
id: "g6",
|
|
275
|
+
method: "GET",
|
|
276
|
+
path: "/upstream-gzip",
|
|
277
|
+
headers: { "user-agent": "phone-browser" }
|
|
278
|
+
});
|
|
279
|
+
assert.equal(reply.status, 200);
|
|
280
|
+
assert.equal(reply.headers["content-encoding"], undefined);
|
|
281
|
+
assert.equal(Buffer.from(reply.body, "base64").toString("utf8"), BIG_JSON);
|
|
282
|
+
});
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mobile-adapter 回归测试:**网络再慢,左侧抽屉按钮(汉堡)也必须最终出现**。
|
|
4
|
+
*
|
|
5
|
+
* 用户原话(2026-09-2x 手机实测):
|
|
6
|
+
* 「在网络比较慢的情况下,左边的抽屉按钮有时候出不来。你要考虑修复这个问题,
|
|
7
|
+
* 甭管网络有多慢,都要让那个抽屉能加载出来。」
|
|
8
|
+
*
|
|
9
|
+
* 根因(本文件针对的 bug):
|
|
10
|
+
* 注入脚本 boot() 的**执行时机**是写死的一组固定重试
|
|
11
|
+
* (DOMContentLoaded + 700 / 1600 / 3200 / 7000 / 12000 ms),
|
|
12
|
+
* 而汉堡按钮的创建排在 `if (!frame) return;` **之后**:
|
|
13
|
+
* · 官方 SPA bundle 有 13.4MB(实测未压缩),免费档被中继限速到 128KB/s 时冷启动要 1-2 分钟;
|
|
14
|
+
* · 官方 frame 在 12 秒内根本没出现 → 5 次重试全部在那一行 return
|
|
15
|
+
* → `done` 永远为 false → 汉堡按钮永远不创建 → 用户看到"左边抽屉按钮出不来"。
|
|
16
|
+
*
|
|
17
|
+
* 修法(本文件断言的契约):
|
|
18
|
+
* ① 不再依赖任何固定重试窗口 —— 改成「事件驱动(MutationObserver 观察 document/subtree)
|
|
19
|
+
* + 低频兜底(setInterval 1000ms)」的**无限期**等待:只要 done 还是 false 就继续 boot();
|
|
20
|
+
* ② done=true 之后仍有「存在性看门狗」:官方重渲染把 dsh-ma-hamburger / dsh-ma-scrim
|
|
21
|
+
* 从 DOM 里冲掉时,按引用补回(节点不变 = 事件处理与类名都还在);
|
|
22
|
+
* ③ 两条路都必须幂等、有节流(观察者回调走 rAF 合并,兜底表 1s 一跳),
|
|
23
|
+
* 且页面卸载(pagehide/unload)时断开观察者与表。
|
|
24
|
+
*
|
|
25
|
+
* 本文件用假 DOM + vm 跑**真实注入脚本**,并把时间轴完全握在手里(不依赖真实计时器):
|
|
26
|
+
* · flushTimeouts() —— 把旧的固定重试窗口一次性走完(正是"窗口耗尽"的现场);
|
|
27
|
+
* · tickIntervals() —— 模拟兜底 setInterval 每秒一跳,可以一直跳到第 15 秒、第 60 秒;
|
|
28
|
+
* · mountFrame() —— 模拟官方 DOM 迟到挂载,并按真实 MutationObserver 语义通知观察者。
|
|
29
|
+
*/
|
|
30
|
+
import assert from "node:assert/strict";
|
|
31
|
+
import test from "node:test";
|
|
32
|
+
import vm from "node:vm";
|
|
33
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
34
|
+
|
|
35
|
+
/** 取注入结果里的真实 <script> 内容(不加壳,原样执行)。 */
|
|
36
|
+
function adapterScript() {
|
|
37
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
38
|
+
const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
39
|
+
assert.ok(m, "应能取出注入脚本");
|
|
40
|
+
return m[1];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── 极简假 DOM(与 mobile-adapter-runtime.test.mjs 同风格,另加父子链/isConnected) ──────
|
|
44
|
+
|
|
45
|
+
class El {
|
|
46
|
+
constructor(tag = "div", attrs = {}) {
|
|
47
|
+
this.tagName = String(tag).toUpperCase();
|
|
48
|
+
this.attrs = { ...attrs };
|
|
49
|
+
this.children = [];
|
|
50
|
+
this.parent = null;
|
|
51
|
+
this._cls = new Set(String(attrs.class || "").split(/\s+/).filter(Boolean));
|
|
52
|
+
this._l = new Map();
|
|
53
|
+
this.style = {
|
|
54
|
+
setProperty: (k, v) => { this._cssVars[k] = v; },
|
|
55
|
+
removeProperty: (k) => { delete this._cssVars[k]; },
|
|
56
|
+
display: "",
|
|
57
|
+
};
|
|
58
|
+
this._cssVars = {};
|
|
59
|
+
this._rect = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };
|
|
60
|
+
this.hidden = false;
|
|
61
|
+
this.classList = {
|
|
62
|
+
add: (...cs) => cs.forEach((c) => this._cls.add(c)),
|
|
63
|
+
remove: (...cs) => cs.forEach((c) => this._cls.delete(c)),
|
|
64
|
+
contains: (c) => this._cls.has(c),
|
|
65
|
+
toggle: (c, on) => {
|
|
66
|
+
const want = on === undefined ? !this._cls.has(c) : !!on;
|
|
67
|
+
if (want) this._cls.add(c); else this._cls.delete(c);
|
|
68
|
+
return want;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
set innerHTML(html) {
|
|
73
|
+
this._html = String(html);
|
|
74
|
+
this.children = [];
|
|
75
|
+
for (const m of String(html).matchAll(/<(\w+)([^>]*)>/g)) {
|
|
76
|
+
const el = new El(m[1]);
|
|
77
|
+
const cls = /class="([^"]*)"/.exec(m[2] || "");
|
|
78
|
+
if (cls) el.className = cls[1];
|
|
79
|
+
const type = /type="([^"]*)"/.exec(m[2] || "");
|
|
80
|
+
if (type) el.attrs.type = type[1];
|
|
81
|
+
this.appendChild(el);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
get innerHTML() { return this._html || ""; }
|
|
85
|
+
get className() { return [...this._cls].join(" "); }
|
|
86
|
+
set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
|
|
87
|
+
get textContent() { return this._text || ""; }
|
|
88
|
+
set textContent(v) { this._text = String(v); }
|
|
89
|
+
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; }
|
|
90
|
+
setAttribute(n, v) { this.attrs[n] = String(v); }
|
|
91
|
+
removeAttribute(n) { delete this.attrs[n]; }
|
|
92
|
+
hasAttribute(n) { return n in this.attrs; }
|
|
93
|
+
appendChild(c) { c.parent = this; this.children.push(c); return c; }
|
|
94
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); if (c.parent === this) c.parent = null; return c; }
|
|
95
|
+
remove() { if (this.parent) this.parent.removeChild(this); }
|
|
96
|
+
contains(el) { let n = el; while (n) { if (n === this) return true; n = n.parent; } return false; }
|
|
97
|
+
/* 真实 DOM 语义:节点是否还挂在文档里。本假 DOM 以"能沿 parent 链走到 doc"为准。 */
|
|
98
|
+
get isConnected() { let n = this; while (n) { if (n._inDoc) return true; n = n.parent; } return false; }
|
|
99
|
+
prepend() {} focus() {} blur() {} scrollIntoView() {} setPointerCapture() {}
|
|
100
|
+
matches() { return false; }
|
|
101
|
+
addEventListener(t, fn) { if (!this._l.has(t)) this._l.set(t, []); this._l.get(t).push(fn); }
|
|
102
|
+
removeEventListener() {}
|
|
103
|
+
dispatchEvent() { return true; }
|
|
104
|
+
dispatch(t, ev = {}) { for (const fn of this._l.get(t) || []) fn({ stopPropagation() {}, preventDefault() {}, ...ev }); }
|
|
105
|
+
getBoundingClientRect() { return this._rect; }
|
|
106
|
+
querySelector(sel) {
|
|
107
|
+
const s = String(sel);
|
|
108
|
+
const hit = (el) => {
|
|
109
|
+
if (s.includes('input[type="range"]')) return el.tagName === "INPUT" && el.attrs.type === "range";
|
|
110
|
+
if (s.startsWith(".")) return el._cls.has(s.slice(1));
|
|
111
|
+
if (s.startsWith("input")) return el.tagName === "INPUT";
|
|
112
|
+
return false;
|
|
113
|
+
};
|
|
114
|
+
return this.children.find(hit) || null;
|
|
115
|
+
}
|
|
116
|
+
querySelectorAll(sel) { const one = this.querySelector(sel); return one ? [one] : []; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 记录型 MutationObserver:可被手工触发,也可被 mountFrame 按真实语义叫醒。 */
|
|
120
|
+
class MutationObserverStub {
|
|
121
|
+
constructor(cb) { this.cb = cb; this.targets = []; this.disconnected = false; MutationObserverStub.instances.push(this); }
|
|
122
|
+
observe(t, opts) { this.targets.push({ t, opts }); }
|
|
123
|
+
disconnect() { this.disconnected = true; }
|
|
124
|
+
fire(records) { if (!this.disconnected) this.cb(records, this); }
|
|
125
|
+
static reset() { MutationObserverStub.instances = []; }
|
|
126
|
+
}
|
|
127
|
+
MutationObserverStub.instances = [];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 搭一个"官方 index.html 已经回来、但 13.4MB 的 SPA bundle 还没渲染出 frame"的现场:
|
|
131
|
+
* · html.outerHTML 只有官方外壳特征(__ModuleLoader__ / DeepSeek Harness)——
|
|
132
|
+
* 与真实 index.html 一致,**不含** frame 的任何 data-* 标记;
|
|
133
|
+
* · document.querySelector(官方 frame 选择器) 在 mountFrame() 之前一律返回 null
|
|
134
|
+
* (= 官方 UI 还没挂载,正是用户网络慢时看到的状态)。
|
|
135
|
+
*/
|
|
136
|
+
function harness({ width = 390, height = 844 } = {}) {
|
|
137
|
+
MutationObserverStub.reset();
|
|
138
|
+
const intervals = [];
|
|
139
|
+
const timeouts = [];
|
|
140
|
+
let rafs = [];
|
|
141
|
+
const state = { mounted: false, pagehide: null };
|
|
142
|
+
|
|
143
|
+
const doc = new El("#document");
|
|
144
|
+
doc.tagName = "#DOCUMENT";
|
|
145
|
+
doc._inDoc = true; // 文档根:沿 parent 链能走到它就说明"在文档里"
|
|
146
|
+
const html = new El("html");
|
|
147
|
+
const body = new El("body");
|
|
148
|
+
doc.appendChild(html);
|
|
149
|
+
html.appendChild(body);
|
|
150
|
+
// 官方 index.html 外壳(真实 HTML 里就是这些字样;frame 是 bundle 跑起来之后才有的)
|
|
151
|
+
// outerHTML 做成计数器:boot() 第一行的 HOSTISH() 会整段序列化它,读数就是"整页判定"的次数
|
|
152
|
+
let outerHtmlReads = 0;
|
|
153
|
+
Object.defineProperty(html, "outerHTML", {
|
|
154
|
+
configurable: true,
|
|
155
|
+
get() { outerHtmlReads += 1; return '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
|
|
156
|
+
+ "<title>DeepSeek Harness</title></head><body></body></html>"; },
|
|
157
|
+
set() { /* 允许写入(本用例不依赖) */ },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const frame = new El("div", { class: "pI_x6G_frame" });
|
|
161
|
+
frame.setAttribute("data-sidebar-collapsed", "true");
|
|
162
|
+
frame.querySelector = () => null; // 本用例不涉及第三列/overlay 识别
|
|
163
|
+
|
|
164
|
+
doc.readyState = "complete";
|
|
165
|
+
doc.documentElement = html;
|
|
166
|
+
doc.body = body;
|
|
167
|
+
doc.createElement = (t) => new El(t);
|
|
168
|
+
doc.createTextNode = (t) => ({ nodeValue: t });
|
|
169
|
+
doc.getElementById = () => null;
|
|
170
|
+
doc.addEventListener = () => {};
|
|
171
|
+
doc.removeEventListener = () => {};
|
|
172
|
+
doc.querySelector = (sel) => {
|
|
173
|
+
const s = String(sel);
|
|
174
|
+
if (s.includes(".pI_x6G_frame") || /\[data-(sidebar|details|rightbar)-collapsed\]/.test(s) || s.includes("[data-shell-overlay]")) {
|
|
175
|
+
return state.mounted ? frame : null; // ⬅️ 官方 UI 迟到:挂载前一律查不到
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
};
|
|
179
|
+
doc.querySelectorAll = () => [];
|
|
180
|
+
|
|
181
|
+
const loc = { href: "https://n.risegao.cn:13443/remote/dev-x/", host: "n.risegao.cn", pathname: "/remote/dev-x/", origin: "https://n.risegao.cn:13443" };
|
|
182
|
+
const win = {
|
|
183
|
+
innerWidth: width,
|
|
184
|
+
innerHeight: height,
|
|
185
|
+
devicePixelRatio: 2,
|
|
186
|
+
localStorage: { _d: {}, getItem(k) { return k in this._d ? this._d[k] : null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } },
|
|
187
|
+
location: loc,
|
|
188
|
+
MutationObserver: MutationObserverStub,
|
|
189
|
+
ResizeObserver: class { observe() {} disconnect() {} },
|
|
190
|
+
getComputedStyle: () => ({ getPropertyValue: () => "", pointerEvents: "none" }),
|
|
191
|
+
addEventListener(t, fn) {
|
|
192
|
+
if (t === "pagehide" || t === "unload") state.pagehide = fn;
|
|
193
|
+
if (t === "pageshow") state.pageshow = fn;
|
|
194
|
+
},
|
|
195
|
+
removeEventListener() {},
|
|
196
|
+
/* 计时器全部"抓住不放",时间轴由用例自己推 —— 不依赖真实时钟,也不会有测试结束后的悬挂回调。 */
|
|
197
|
+
setTimeout: (fn) => timeouts.push(fn),
|
|
198
|
+
clearTimeout: () => {},
|
|
199
|
+
setInterval: (fn) => { intervals.push(fn); return intervals.length; },
|
|
200
|
+
clearInterval: (id) => { if (id) intervals[id - 1] = null; },
|
|
201
|
+
requestAnimationFrame: (fn) => { rafs.push(fn); return rafs.length; },
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const ctx = vm.createContext({
|
|
205
|
+
window: win, document: doc, console,
|
|
206
|
+
navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
|
|
207
|
+
location: loc, localStorage: win.localStorage,
|
|
208
|
+
setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
|
|
209
|
+
setInterval: win.setInterval, clearInterval: win.clearInterval,
|
|
210
|
+
MutationObserver: MutationObserverStub, ResizeObserver: win.ResizeObserver,
|
|
211
|
+
requestAnimationFrame: win.requestAnimationFrame,
|
|
212
|
+
URL, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
|
|
213
|
+
HTMLElement: El,
|
|
214
|
+
});
|
|
215
|
+
ctx.globalThis = ctx;
|
|
216
|
+
ctx.self = win;
|
|
217
|
+
|
|
218
|
+
/** 官方 UI 迟到挂载:把 frame 挂进 body,并按真实 MutationObserver 语义通知观察祖先树的观察者。 */
|
|
219
|
+
const mountFrame = ({ notify = true } = {}) => {
|
|
220
|
+
state.mounted = true;
|
|
221
|
+
body.appendChild(frame);
|
|
222
|
+
if (!notify) return;
|
|
223
|
+
for (const ob of MutationObserverStub.instances.slice()) {
|
|
224
|
+
if (ob.disconnected) continue;
|
|
225
|
+
const targets = ob.targets.map((x) => x.t);
|
|
226
|
+
// 新节点加进 body → 观察 body / documentElement / document(subtree)的观察者都会收到 childList 记录
|
|
227
|
+
if (targets.includes(body) || targets.includes(html) || targets.includes(doc)) {
|
|
228
|
+
ob.fire([{ type: "childList", target: body, addedNodes: [frame] }]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/** 把旧的"固定重试窗口"(700/1600/3200/7000/12000ms 那批 setTimeout)一次性走完。 */
|
|
233
|
+
const flushTimeouts = () => { const q = timeouts.splice(0, timeouts.length); for (const fn of q) fn(); };
|
|
234
|
+
/** 模拟兜底 setInterval 每秒一跳(间隔由实现决定,用例只关心"它一直在跳")。 */
|
|
235
|
+
const tickIntervals = (seconds = 1) => {
|
|
236
|
+
for (let i = 0; i < seconds; i++) for (const fn of intervals.slice()) if (fn) fn();
|
|
237
|
+
};
|
|
238
|
+
const flushRaf = () => { const q = rafs; rafs = []; for (const fn of q) fn(); };
|
|
239
|
+
/** 模拟官方 SPA 的 DOM 变更风暴(流式输出时每帧都在改 DOM),并按真实语义通知观察者。 */
|
|
240
|
+
const churn = (n = 20) => {
|
|
241
|
+
for (let i = 0; i < n; i++) {
|
|
242
|
+
const d = new El("div");
|
|
243
|
+
body.appendChild(d);
|
|
244
|
+
for (const ob of MutationObserverStub.instances.slice()) {
|
|
245
|
+
if (ob.disconnected) continue;
|
|
246
|
+
const targets = ob.targets.map((x) => x.t);
|
|
247
|
+
if (targets.includes(body) || targets.includes(html) || targets.includes(doc)) {
|
|
248
|
+
ob.fire([{ type: "childList", target: body, addedNodes: [d] }]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
flushRaf();
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return { ctx, doc, html, body, frame, win, state, mountFrame, flushTimeouts, tickIntervals, flushRaf, churn,
|
|
256
|
+
intervals, outerHtmlReads: () => outerHtmlReads };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function run(env, js) {
|
|
260
|
+
vm.runInContext(js, env.ctx, { timeout: 5000 });
|
|
261
|
+
return env;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const findHamburger = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
|
|
265
|
+
const findScrim = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
|
|
266
|
+
|
|
267
|
+
// ── 用例 ─────────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
test("慢网回归:官方 frame 第 15 秒才出现,汉堡按钮最终仍必须创建(旧的 12s 固定重试窗口会永久放弃)", () => {
|
|
270
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
271
|
+
|
|
272
|
+
assert.ok(!findHamburger(env), "前提:官方 frame 还没出现时,不该凭空造出汉堡按钮");
|
|
273
|
+
assert.ok(!findScrim(env), "前提:官方 frame 还没出现时,不该凭空造出遮罩");
|
|
274
|
+
|
|
275
|
+
// ① 先把旧的固定重试窗口整批走完(700…12000ms 全部到点)—— 这正是当年"窗口耗尽"的现场
|
|
276
|
+
env.flushTimeouts();
|
|
277
|
+
assert.ok(!findHamburger(env), "官方 frame 未出现时仍不得创建汉堡按钮(不能提前造出无锚点的入口)");
|
|
278
|
+
|
|
279
|
+
// ② 再把时间推过 15 秒(兜底表每秒一跳),官方 frame 依然没来
|
|
280
|
+
env.tickIntervals(15);
|
|
281
|
+
assert.ok(!findHamburger(env), "前提:frame 还没来");
|
|
282
|
+
|
|
283
|
+
// ③ 第 15 秒:13.4MB 的 bundle 终于在 128KB/s 的限制下加载完,官方 frame 挂上 DOM。
|
|
284
|
+
// 这里刻意不通知观察者(notify:false)—— 只留"低频兜底 setInterval"这一条路,
|
|
285
|
+
// 证明它不是"靠事件侥幸",而是真的无限期重试。
|
|
286
|
+
env.mountFrame({ notify: false });
|
|
287
|
+
assert.ok(!findHamburger(env), "前提:这一瞬间还没轮到兜底表跳");
|
|
288
|
+
|
|
289
|
+
env.tickIntervals(1);
|
|
290
|
+
assert.ok(findHamburger(env),
|
|
291
|
+
"网络再慢,官方 frame 一出现抽屉按钮也必须最终创建出来(本次 bug 的回归点:固定重试窗口耗尽就永久放弃)");
|
|
292
|
+
assert.ok(findScrim(env), "遮罩同样必须补上(它和汉堡按钮在同一段之后创建)");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("慢网回归:跳到第 60 秒(远超旧窗口 5 倍)官方 frame 才出现,汉堡按钮仍必须出现", () => {
|
|
296
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
297
|
+
env.flushTimeouts();
|
|
298
|
+
env.tickIntervals(60); // 60 秒过去,frame 还没来
|
|
299
|
+
assert.ok(!findHamburger(env), "前提:frame 仍然没来");
|
|
300
|
+
env.mountFrame({ notify: false }); // 第 60 秒才挂上
|
|
301
|
+
env.tickIntervals(1);
|
|
302
|
+
assert.ok(findHamburger(env), "「甭管网络有多慢」——60 秒后才出现官方 frame 也必须能拿到抽屉按钮");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("事件驱动:官方 frame 挂载的 DOM 变更直接叫醒适配层(观察者回调经 rAF 合并,不等兜底表)", () => {
|
|
306
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
307
|
+
assert.ok(!findHamburger(env), "前提:frame 未挂载");
|
|
308
|
+
env.mountFrame(); // 通知观察者
|
|
309
|
+
env.flushRaf(); // 合并后的那一帧
|
|
310
|
+
assert.ok(findHamburger(env), "官方 frame 一挂上就必须立刻跟上(事件驱动路径),不必等 1s 兜底表");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("存在性看门狗:done 之后官方重渲染把汉堡/遮罩冲掉,必须按引用补回来", () => {
|
|
314
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
315
|
+
env.mountFrame({ notify: false });
|
|
316
|
+
env.tickIntervals(1);
|
|
317
|
+
const ham = findHamburger(env);
|
|
318
|
+
const scrim = findScrim(env);
|
|
319
|
+
assert.ok(ham && scrim, "前提:抽屉入口已建好");
|
|
320
|
+
|
|
321
|
+
// 模拟官方 SPA 重渲染:整片换掉 body 子树(实测某些路由/会话切换会重建 DOM)
|
|
322
|
+
env.body.removeChild(ham);
|
|
323
|
+
env.body.removeChild(scrim);
|
|
324
|
+
assert.ok(!findHamburger(env), "前提:官方重渲染把我们的节点冲掉了");
|
|
325
|
+
assert.equal(ham.isConnected, false, "假 DOM 语义:被移除的节点应报 isConnected=false");
|
|
326
|
+
|
|
327
|
+
env.tickIntervals(2);
|
|
328
|
+
assert.equal(findHamburger(env), ham, "补回的必须是**同一个节点**(事件处理与类名原样保留),而不是重建一个");
|
|
329
|
+
assert.equal(findScrim(env), scrim, "遮罩同样必须补回");
|
|
330
|
+
assert.equal(ham.isConnected, true, "补回后应重新在文档里");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("清理:pagehide 必须断开观察者与兜底表(避免 bfcache/长驻页面泄漏)", () => {
|
|
334
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
335
|
+
assert.equal(typeof env.state.pagehide, "function", "应注册 pagehide 清理钩子");
|
|
336
|
+
const observers = MutationObserverStub.instances.slice();
|
|
337
|
+
env.state.pagehide();
|
|
338
|
+
assert.ok(observers.every((o) => o.disconnected), "pagehide 必须 disconnect 所有已挂观察者");
|
|
339
|
+
// 断开后时间轴上再跳也不再工作(表已清)
|
|
340
|
+
env.mountFrame({ notify: false });
|
|
341
|
+
env.tickIntervals(2);
|
|
342
|
+
assert.ok(!findHamburger(env), "卸载后不得再往页面里塞东西");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("bfcache:切走再切回来(pageshow persisted)必须重新武装看门狗,否则回来后就永久失灵", () => {
|
|
346
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
347
|
+
env.mountFrame({ notify: false });
|
|
348
|
+
env.tickIntervals(1);
|
|
349
|
+
const ham = findHamburger(env);
|
|
350
|
+
assert.ok(ham, "前提:抽屉入口已就位");
|
|
351
|
+
|
|
352
|
+
env.state.pagehide(); // 切走(进入 bfcache)
|
|
353
|
+
env.state.pageshow({ persisted: true }); // 切回来(从 bfcache 恢复)
|
|
354
|
+
env.body.removeChild(ham); // 官方此时重渲染冲掉我们的节点
|
|
355
|
+
env.tickIntervals(1);
|
|
356
|
+
assert.equal(findHamburger(env), ham, "bfcache 恢复后看门狗必须仍然有效(重新武装)");
|
|
357
|
+
env.state.pageshow({ persisted: false }); // 普通 pageshow 不得重复武装出第二套计时器
|
|
358
|
+
const live = env.intervals.filter(Boolean).length;
|
|
359
|
+
env.state.pageshow({ persisted: true });
|
|
360
|
+
assert.equal(env.intervals.filter(Boolean).length, live, "重新武装必须先清旧表(不能越切越多计时器)");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("幂等:兜底表长时间空转不得重复插入节点(每类节点在 body 里最多一个)", () => {
|
|
364
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
365
|
+
env.mountFrame({ notify: false });
|
|
366
|
+
env.tickIntervals(30); // 空转 30 秒
|
|
367
|
+
const count = (cls) => env.body.children.filter((c) => c._cls.has(cls)).length;
|
|
368
|
+
assert.equal(count("dsh-ma-hamburger"), 1, "汉堡按钮必须只有一个(幂等,不能每次 tick 都插一个)");
|
|
369
|
+
assert.equal(count("dsh-ma-scrim"), 1, "遮罩必须只有一个");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("开销:抽屉入口就位后,官方的 DOM 变更风暴不得再触发整页判定(HOSTISH 会序列化整个 outerHTML)", () => {
|
|
373
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
374
|
+
env.mountFrame({ notify: false });
|
|
375
|
+
env.tickIntervals(1);
|
|
376
|
+
assert.ok(findHamburger(env), "前提:抽屉入口已就位");
|
|
377
|
+
|
|
378
|
+
// 官方流式输出时每帧都在改 DOM —— 这时适配层必须已经"静默",不能再做昂贵判定
|
|
379
|
+
const before = env.outerHtmlReads();
|
|
380
|
+
env.churn(30); // 30 帧 DOM 变更(每帧都通知观察者)
|
|
381
|
+
env.tickIntervals(3); // 兜底表再空转 3 秒
|
|
382
|
+
assert.equal(env.outerHtmlReads(), before,
|
|
383
|
+
"入口已经建好且都在文档里时,不得再为每次 DOM 变更/每秒 tick 重新序列化整页(那是每秒几十次全量 outerHTML)");
|
|
384
|
+
// 但"看门狗"必须还在:真被冲掉时依然能补回来(静默 ≠ 失灵)
|
|
385
|
+
env.body.removeChild(findHamburger(env));
|
|
386
|
+
env.churn(1);
|
|
387
|
+
assert.ok(findHamburger(env), "静默之后看门狗仍必须工作");
|
|
388
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.13",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"test": "npm run test:router && npm run test:plugin && npm run test:bridge",
|
|
54
|
-
"test:router": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs",
|
|
54
|
+
"test:router": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs packages/relay-router/test/device-free-assets.test.mjs packages/relay-router/test/sse-streaming-contract.test.mjs",
|
|
55
55
|
"_comment:test:plugin": "DSH_RELAY_BUNDLED_RUNTIME=/nonexistent = 强制走 npx 兜底分支(否则本机跑过 npm pack 后 packages/dsh-remote-web/runtime 存在,用例会改成走本地拷贝)。DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)。DSH_RELAY_DEFAULT_API= 死端口:用例拆掉临时目录后,仍在飞的后台请求(装机上报重试等)会读到空配置并回落「默认云端地址」—— 指到本地死端口才不会打生产。",
|
|
56
56
|
"test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
|
|
57
57
|
"_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.13",
|
|
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",
|
|
@@ -505,6 +505,15 @@ export async function handleFrame(dchOrSend, frame) {
|
|
|
505
505
|
if (id === undefined || id === null) return;
|
|
506
506
|
const send = toSender(dchOrSend);
|
|
507
507
|
if (type === "http") return handleHttpFrame(send, frame);
|
|
508
|
+
if (type === "http-abort") {
|
|
509
|
+
// 手机侧断开了这条流(切页/锁屏):掐掉上游,别在电脑上留一条永不结束的 SSE 连接。
|
|
510
|
+
const c = streamAborts.get(id);
|
|
511
|
+
if (c) {
|
|
512
|
+
streamAborts.delete(id);
|
|
513
|
+
try { c.abort(); } catch { /* ignore */ }
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
508
517
|
if (type === "ws-open") return handleWsOpen(send, frame);
|
|
509
518
|
if (type === "ws-msg") return handleWsMessage(send, frame);
|
|
510
519
|
if (type === "ws-close") return handleWsClose(send, frame);
|
|
@@ -541,7 +550,10 @@ function headerValue(headers, name) {
|
|
|
541
550
|
* 保守策略:任一条件不满足都返回 null(原样回传):
|
|
542
551
|
* - 方法非 HEAD、状态非 204/304;
|
|
543
552
|
* - buf ≥ 1KB(太小不值得压);
|
|
544
|
-
* -
|
|
553
|
+
* - `contentEncoding` 为空 —— ⚠️ 它指的是**手上这份 buf 的编码**,不是上游响应头的值:
|
|
554
|
+
* 上游头的 "gzip" 在 undici 解压后依然留着(见下),照抄过来只会让我们白丢压缩收益;
|
|
555
|
+
* 调用方必须传"buf 是否已编码",dsh-bridge 的 doHttp 因此**固定传空串**。
|
|
556
|
+
* 若哪天 buf 真是一段未解压的字节流,调用方才应把对应编码传进来(函数会保守放弃压缩)。
|
|
545
557
|
* - 请求 Accept-Encoding 含 gzip(手机浏览器必带;没有就不压,避免手机不会解压);
|
|
546
558
|
* - content-type 可压缩(排除 text/event-stream);
|
|
547
559
|
* - gzip 后确实更小(极小文件/不可压数据 gzip 反而更大,保守判断)。
|
|
@@ -565,6 +577,97 @@ export async function maybeCompressResponse({ buf, contentType, contentEncoding,
|
|
|
565
577
|
return { buf: out, headers: { "content-encoding": "gzip" } };
|
|
566
578
|
}
|
|
567
579
|
|
|
580
|
+
/**
|
|
581
|
+
* 进行中的 SSE 流:frameId → AbortController。
|
|
582
|
+
* 手机侧断开时,中继会发 `http-abort`,这里据此掐掉上游(否则电脑上会留一条永不结束的连接)。
|
|
583
|
+
*/
|
|
584
|
+
const streamAborts = new Map();
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* 中继在 `tunnel-register-ok` 里声明支持的**追加帧能力**。
|
|
588
|
+
*
|
|
589
|
+
* 为什么必须协商:browser→中继→bridge 是两段**独立升级**的链路。
|
|
590
|
+
* 新 bridge 的流式帧(`http-chunk`/`http-end`)对**旧中继**是不认识的 —— 旧中继会把
|
|
591
|
+
* "仅头部"帧当成一个完整的空响应直接 `end()`,SSE 于是变成"秒断 + 浏览器狂重连",
|
|
592
|
+
* 比原来的 120 秒挂起**更糟**。所以能力由中继声明:拿不到 `http-stream` 就老实退回
|
|
593
|
+
* 旧的整包缓冲行为(慢,但正确)。默认空集合 = 保守(连不上/旧中继都不冒险用新帧)。
|
|
594
|
+
*/
|
|
595
|
+
const routerCaps = new Set();
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* SSE(`text/event-stream`)流式转发。
|
|
599
|
+
*
|
|
600
|
+
* 为什么必须流式:`doHttp` 是「整包 `await res.arrayBuffer()`」的语义,而 SSE **永不结束** ——
|
|
601
|
+
* 实测 DSH 的 `/plugins/events`(client-hmr 的事件通道)每次页面加载都把这条请求挂满
|
|
602
|
+
* bridge 的 120s 上游超时,然后报错;浏览器 EventSource 立刻重连,于是手机端 Network 里
|
|
603
|
+
* **永远有一条转圈的请求**,页面也始终拿不到事件。
|
|
604
|
+
*
|
|
605
|
+
* 帧协议(对中继**追加**、与既有 `http` 帧并存;不认识 streaming 的旧中继只会忽略后续
|
|
606
|
+
* chunk,退化成"空 body 后结束",不会把页面打挂):
|
|
607
|
+
* ← { id, type:"http", status, headers, streaming:true } 仅头部,无 body
|
|
608
|
+
* ← { id, type:"http-chunk", seq, body, bodyBase64:true } 0..n 次
|
|
609
|
+
* ← { id, type:"http-end", seq } 流结束(含出错收尾)
|
|
610
|
+
* → { id, type:"http-abort" } 手机断开 → 掐上游
|
|
611
|
+
*
|
|
612
|
+
* ⚠️ E2EE 路径**不**走这里:信封是"一问一答"的结构,SSE 要逐块封/解需要另立协议;
|
|
613
|
+
* 而现实里 SSE 由 `EventSource` 发出、shim 不接管它(不带头部信封标记),
|
|
614
|
+
* 所以密文流根本不存在 —— 真要做时再单独设计,不要在这里偷偷降级成明文。
|
|
615
|
+
*
|
|
616
|
+
* @returns {Promise<{status:number, chunks:number, bytes:number, aborted?:boolean}>}
|
|
617
|
+
*/
|
|
618
|
+
async function doHttpStream(send, id, method, path, reqHeaders) {
|
|
619
|
+
const safe = safePath(path);
|
|
620
|
+
if (safe === null) throw new Error("非法路径");
|
|
621
|
+
const reqHdrs = sanitizeRequestHeaders(reqHeaders);
|
|
622
|
+
const ck = harnessCookieOf(); // 新版 dsh web 的浏览器会话 Cookie(否则 401 白页)
|
|
623
|
+
if (ck) reqHdrs.Cookie = ck;
|
|
624
|
+
const controller = new AbortController();
|
|
625
|
+
streamAborts.set(id, controller);
|
|
626
|
+
|
|
627
|
+
let res;
|
|
628
|
+
try {
|
|
629
|
+
res = await fetch(`${UPSTREAM}${safe}`, { method, headers: reqHdrs, signal: controller.signal });
|
|
630
|
+
} catch (e) {
|
|
631
|
+
streamAborts.delete(id);
|
|
632
|
+
throw e; // 头部都还没发出去 → 交给调用方回一个普通错误帧
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const status = res.status;
|
|
636
|
+
// 头部帧:告诉中继"这是一条流",随后才逐块推 body
|
|
637
|
+
send({
|
|
638
|
+
id,
|
|
639
|
+
type: "http",
|
|
640
|
+
status,
|
|
641
|
+
headers: sanitizeResponseHeaders(Object.fromEntries(res.headers.entries())),
|
|
642
|
+
streaming: true
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
let seq = 0;
|
|
646
|
+
let bytes = 0;
|
|
647
|
+
try {
|
|
648
|
+
const reader = res.body && typeof res.body.getReader === "function" ? res.body.getReader() : null;
|
|
649
|
+
if (reader) {
|
|
650
|
+
for (;;) {
|
|
651
|
+
const { done, value } = await reader.read();
|
|
652
|
+
if (done) break;
|
|
653
|
+
if (!value || value.length === 0) continue;
|
|
654
|
+
bytes += value.length;
|
|
655
|
+
send({ id, type: "http-chunk", seq: ++seq, body: Buffer.from(value).toString("base64"), bodyBase64: true });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
} catch (e) {
|
|
659
|
+
// 头部已经发出去了:这里**绝不能**抛(调用方会用普通帧回 502,而中继那边响应头已定,
|
|
660
|
+
// 再写一次 head 会炸)。统一以 http-end 收尾,让 EventSource 按自己的节奏重连。
|
|
661
|
+
const aborted = controller.signal.aborted === true;
|
|
662
|
+
streamAborts.delete(id);
|
|
663
|
+
send({ id, type: "http-end", seq, ...(aborted ? { aborted: true } : { error: String(e?.message || e) }) });
|
|
664
|
+
return { status, chunks: seq, bytes, aborted };
|
|
665
|
+
}
|
|
666
|
+
streamAborts.delete(id);
|
|
667
|
+
send({ id, type: "http-end", seq });
|
|
668
|
+
return { status, chunks: seq, bytes };
|
|
669
|
+
}
|
|
670
|
+
|
|
568
671
|
async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
569
672
|
const safe = safePath(path);
|
|
570
673
|
if (safe === null) throw new Error("非法路径");
|
|
@@ -624,7 +727,14 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
624
727
|
const compressed = await maybeCompressResponse({
|
|
625
728
|
buf,
|
|
626
729
|
contentType,
|
|
627
|
-
|
|
730
|
+
// 🔴 必须传空:这里的 `buf` 是 **undici 已经解压过**的明文,不是上游线速字节。
|
|
731
|
+
// 以前传的是 `res.headers.get("content-encoding")` —— 而 undici 解压之后**仍会把该头留着**
|
|
732
|
+
// (本文件 sanitizeResponseHeaders 的注释正是这么写的:"undici 已自动解压 body,
|
|
733
|
+
// content-encoding/length 会误导浏览器")。于是 maybeCompressResponse 看到 "gzip"
|
|
734
|
+
// 就保守地 `return null`,**永不重压** —— 实测代价:线上 dsh web 的 `/plugins/??`
|
|
735
|
+
// 聚合包本来 gzip 后 5.12MB,被原样发出 13.39MB(2.6×),免费档 1Mbps 下从 40 秒变成 100+ 秒。
|
|
736
|
+
// 传空之后语义才是自洽的:"我手上这份 buf 是未编码的明文,可以压"。
|
|
737
|
+
contentEncoding: "",
|
|
628
738
|
acceptEncoding: headerValue(reqHeaders, "accept-encoding"),
|
|
629
739
|
status: res.status,
|
|
630
740
|
method
|
|
@@ -693,6 +803,13 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
693
803
|
}
|
|
694
804
|
}
|
|
695
805
|
try {
|
|
806
|
+
// SSE 必须流式转发(否则会挂满 120s 超时,见 doHttpStream 注释)。
|
|
807
|
+
// 客户端是 EventSource 时必带 Accept: text/event-stream,以此为准(不看路径白名单,免得漏)。
|
|
808
|
+
if (routerCaps.has("http-stream") && String(headerValue(headers, "accept") || "").includes("text/event-stream")) {
|
|
809
|
+
const s = await doHttpStream(send, id, method, path, headers);
|
|
810
|
+
console.log(`[bridge] ${method} ${path} → SSE ${s.status} (${Date.now() - t0}ms, ${s.chunks} 块 / ${(s.bytes / 1024).toFixed(1)}KB${s.aborted ? ", 手机已断开" : ""})`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
696
813
|
const reply = await doHttp(method, path, headers, body, !!isB64);
|
|
697
814
|
reply.id = id;
|
|
698
815
|
reply.type = "http";
|
|
@@ -1157,6 +1274,9 @@ function connectTunnel(token) {
|
|
|
1157
1274
|
try {
|
|
1158
1275
|
receive(raw, (frame) => {
|
|
1159
1276
|
if (frame?.type === "tunnel-register-ok") {
|
|
1277
|
+
// 记下中继声明的追加帧能力(旧中继不带 caps → 空集合 → 退回整包缓冲,见 routerCaps 注释)
|
|
1278
|
+
routerCaps.clear();
|
|
1279
|
+
for (const c of Array.isArray(frame.caps) ? frame.caps : []) routerCaps.add(String(c));
|
|
1160
1280
|
// 中继注册成功 = 手机端此刻真的能进这台电脑:面板据此把阶段推进到 online(已连接 ✅)
|
|
1161
1281
|
persistBridgeState({ device_id: DEVICE_ID, tunnel_registered_at: Date.now(), phase: "online", last_error: null });
|
|
1162
1282
|
console.log(`[bridge] ✅ router 注册成功: ${DEVICE_ID},等待手机访问 /remote/${DEVICE_ID}/`);
|
|
@@ -1172,6 +1292,7 @@ function connectTunnel(token) {
|
|
|
1172
1292
|
} catch (e) { console.log(`[bridge] 隧道帧错误: ${e.message}`); }
|
|
1173
1293
|
});
|
|
1174
1294
|
ws.on("close", (code, reason) => {
|
|
1295
|
+
routerCaps.clear(); // 换中继/断线:能力重新协商,期间一律退回保守行为
|
|
1175
1296
|
clearInterval(heartbeat);
|
|
1176
1297
|
console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
|
|
1177
1298
|
closeAllWsSessions();
|
|
@@ -1096,6 +1096,36 @@ const SCRIPT = `(() => {
|
|
|
1096
1096
|
} catch (e8) { /* 引导条失败绝不能影响主功能 */ }
|
|
1097
1097
|
};
|
|
1098
1098
|
|
|
1099
|
+
/* 适配层自己创建的「抽屉入口」引用(汉堡按钮 / 遮罩),提到 boot() 外层保存。
|
|
1100
|
+
为什么必须提到外层:官方 SPA 重渲染时会整片替换 body 子树,把这两个节点一起冲掉;
|
|
1101
|
+
留住引用才能在下面的「存在性看门狗」里把**同一个节点**补回去(节点不变 → 挂在它上面的
|
|
1102
|
+
click 处理、CSS 类、aria 属性全都原样保留,不会出现"补回来点不动"的假按钮)。 */
|
|
1103
|
+
let maHamburger = null, maScrim = null;
|
|
1104
|
+
|
|
1105
|
+
/* 节点是否已经脱离文档。判不出来时一律当作"还在"—— 宁可不补,也绝不重复插入。 */
|
|
1106
|
+
const maDetached = (el) => {
|
|
1107
|
+
if (!el) return false;
|
|
1108
|
+
try { if (typeof el.isConnected === "boolean") return !el.isConnected; } catch (eConn) { /* 继续退化 */ }
|
|
1109
|
+
try {
|
|
1110
|
+
const b = document.body;
|
|
1111
|
+
if (b && typeof b.contains === "function") return !b.contains(el);
|
|
1112
|
+
} catch (eHas) { /* 继续退化 */ }
|
|
1113
|
+
return false;
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
/* —— 存在性看门狗:「建好了」不等于「一直都在」----------------------------------
|
|
1117
|
+
done=true 只保证**曾经**建出来过。官方在路由/会话切换时会重建 DOM 子树,我们的汉堡按钮
|
|
1118
|
+
与遮罩会跟着消失 —— 用户看到的就是"抽屉按钮用着用着又没了"。
|
|
1119
|
+
这里按**引用**补回(不是重建),只在窄屏做(宽屏本来就不需要抽屉入口)。
|
|
1120
|
+
幂等:节点还在文档里 → 什么都不做;boot 还没成功(引用为 null) → 同样什么都不做。 */
|
|
1121
|
+
const ensureDrawerChrome = () => {
|
|
1122
|
+
try {
|
|
1123
|
+
if (!NARROW() || !document.body) return;
|
|
1124
|
+
if (maDetached(maScrim)) document.body.appendChild(maScrim);
|
|
1125
|
+
if (maDetached(maHamburger)) document.body.appendChild(maHamburger);
|
|
1126
|
+
} catch (eChrome) { /* 补不回去也不能影响抽屉开合本身 */ }
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1099
1129
|
let done = false;
|
|
1100
1130
|
const boot = () => {
|
|
1101
1131
|
if (!HOSTISH()) return;
|
|
@@ -1228,6 +1258,7 @@ const SCRIPT = `(() => {
|
|
|
1228
1258
|
const scrim = document.createElement("div");
|
|
1229
1259
|
scrim.className = "dsh-ma-scrim";
|
|
1230
1260
|
document.body.appendChild(scrim);
|
|
1261
|
+
maScrim = scrim; // 供「存在性看门狗」在被官方冲掉后补回
|
|
1231
1262
|
scrim.addEventListener("click", (e) => {
|
|
1232
1263
|
e.stopPropagation();
|
|
1233
1264
|
userWantsOpen = false; // 用户明确要关(遮罩的作用就是关抽屉)
|
|
@@ -1245,6 +1276,7 @@ const SCRIPT = `(() => {
|
|
|
1245
1276
|
hamburger.setAttribute("aria-label", "打开菜单");
|
|
1246
1277
|
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
1278
|
document.body.appendChild(hamburger);
|
|
1279
|
+
maHamburger = hamburger; // 供「存在性看门狗」在被官方冲掉后补回
|
|
1248
1280
|
hamburger.addEventListener("click", (e) => {
|
|
1249
1281
|
e.stopPropagation(); e.preventDefault();
|
|
1250
1282
|
const nowOpen = document.documentElement.classList.contains("dsh-ma-sidebar-open");
|
|
@@ -1516,10 +1548,82 @@ const SCRIPT = `(() => {
|
|
|
1516
1548
|
};
|
|
1517
1549
|
|
|
1518
1550
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1551
|
+
|
|
1552
|
+
/* ================= 执行时机:无限期等待官方 DOM 就绪 =================
|
|
1553
|
+
用户实测原话(2026-09-2x):「在网络比较慢的情况下,左边的抽屉按钮有时候出不来。
|
|
1554
|
+
甭管网络有多慢,都要让那个抽屉能加载出来。」
|
|
1555
|
+
⚠️ 旧实现是**一组写死的固定重试**([700, 1600, 3200, 7000, 12000]ms),而汉堡按钮的创建
|
|
1556
|
+
排在 boot() 里 "if (!frame) return;" 那一行之后 —— 官方 SPA bundle 有 13.4MB(实测未压缩),
|
|
1557
|
+
免费档被中继限速到 128KB/s 时冷启动要 1-2 分钟,官方 frame 在 12 秒内根本没出现:
|
|
1558
|
+
5 次重试全部在那一行返回 → done 永远为 false → 汉堡按钮**永远**不创建
|
|
1559
|
+
→ 用户看到"左边抽屉按钮出不来"。任何固定窗口都只是把这个阈值往后挪,治不了根。
|
|
1560
|
+
现在改成「事件驱动 + 低频兜底」的**无限期**等待,只要抽屉入口还没建好就一直试:
|
|
1561
|
+
· MutationObserver(document/subtree):官方 frame 一挂上就立刻跟上(网络快时零延迟);
|
|
1562
|
+
· setInterval(1000ms):兜底 + 存在性看门狗 —— observer 不可用/被绕过时照样收敛,
|
|
1563
|
+
也负责在 done 之后发现官方重渲染冲掉了我们的节点并补回。
|
|
1564
|
+
两条路都只是"再调一次 pump()",pump 内部**全部幂等**(done / fabDone / whaleGuardsOn /
|
|
1565
|
+
settingsWatchOn / drawerAutoCloseOn 等守卫都在),重复调用不会产生任何副作用,
|
|
1566
|
+
也不改变"执行什么"——开合、遮罩不变量、sync()、isDrawer() 判定一行未动。 */
|
|
1567
|
+
let maTorn = false, maInterval = 0, maObserver = null, maRafPending = false;
|
|
1568
|
+
/* 廉价的"boot() 还有事可做吗"闸门(只读两个现成的布尔标志,不做任何 DOM 操作):
|
|
1569
|
+
· fab 还没建出来(fabDone=false) → 要跑;
|
|
1570
|
+
· 抽屉那段还没建好(done=false)且当前是窄屏 → 要跑(旋转/缩放回窄屏也能补建)。
|
|
1571
|
+
两者都不成立 → boot() 里已无事可做,**绝不能**再跑:boot() 第一行 HOSTISH() 会把
|
|
1572
|
+
documentElement.outerHTML 整个序列化一遍(最长 200KB),而官方流式输出时每帧都有 DOM
|
|
1573
|
+
变更 —— 没有这道闸门就会变成每秒几十次全量序列化(旧实现只跑 5 次,不会有这个问题)。 */
|
|
1574
|
+
const bootUseful = () => !fabDone || (!done && NARROW());
|
|
1575
|
+
const pump = () => {
|
|
1576
|
+
if (maTorn) return;
|
|
1577
|
+
if (bootUseful()) { try { boot(); } catch (eBoot) { /* 幂等重试 */ } }
|
|
1578
|
+
try { ensureDrawerChrome(); } catch (eChrome2) { /* 忽略 */ }
|
|
1579
|
+
if (!whaleGuardsOn || !settingsWatchOn) {
|
|
1580
|
+
try { whaleHookInit(); watchSettings(); } catch (eHook) { /* 两者内部都自带一次性守卫 */ }
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
/* 节流:一次 DOM 变更往往产生几十条记录,逐条 boot() 就是忙轮询。
|
|
1584
|
+
统一合并成"本帧最多跑一次"(有 rAF 用 rAF,没有就退化成 0ms 宏任务) —— 天然去重。 */
|
|
1585
|
+
const schedulePump = () => {
|
|
1586
|
+
if (maTorn || maRafPending) return;
|
|
1587
|
+
maRafPending = true;
|
|
1588
|
+
const run = () => { maRafPending = false; pump(); };
|
|
1589
|
+
try { if (typeof requestAnimationFrame === "function") { requestAnimationFrame(run); return; } } catch (eRaf) { /* 退化 */ }
|
|
1590
|
+
setTimeout(run, 0);
|
|
1591
|
+
};
|
|
1592
|
+
/* 挂上"叫醒"的两条路。做成可重入(先清旧的再建新的),这样 bfcache 恢复后能重新武装。 */
|
|
1593
|
+
const maArm = () => {
|
|
1594
|
+
maTorn = false;
|
|
1595
|
+
maRafPending = false; // 冻结时若有一帧没跑到,不清掉就会永远挡住后续调度
|
|
1596
|
+
try { if (maObserver) maObserver.disconnect(); } catch (eA1) { /* 忽略 */ }
|
|
1597
|
+
maObserver = null;
|
|
1598
|
+
try { if (maInterval) clearInterval(maInterval); } catch (eA2) { /* 忽略 */ }
|
|
1599
|
+
maInterval = 0;
|
|
1600
|
+
try {
|
|
1601
|
+
if (typeof MutationObserver === "function" && document.documentElement) {
|
|
1602
|
+
maObserver = new MutationObserver(schedulePump);
|
|
1603
|
+
/* 只观察 childList+subtree:节点被官方换掉/新增才叫醒我们。
|
|
1604
|
+
⚠️ 刻意**不**观察 attributes —— 我们自己的 sync() 就会不停改 class,那会变成自激循环。 */
|
|
1605
|
+
maObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
1606
|
+
}
|
|
1607
|
+
} catch (eObs) { maObserver = null; /* 退化:只靠下面的兜底表 */ }
|
|
1608
|
+
try { maInterval = setInterval(pump, 1000); } catch (eInt) { maInterval = 0; }
|
|
1609
|
+
};
|
|
1610
|
+
maArm();
|
|
1611
|
+
/* 清理:页面卸载时断开观察者与兜底表(bfcache/长驻页面都不留悬挂回调)。
|
|
1612
|
+
pagehide 覆盖 bfcache 场景,unload 兜底更老的浏览器。 */
|
|
1613
|
+
const maTeardown = () => {
|
|
1614
|
+
maTorn = true;
|
|
1615
|
+
try { if (maObserver) maObserver.disconnect(); } catch (eD1) { /* 忽略 */ }
|
|
1616
|
+
maObserver = null;
|
|
1617
|
+
try { if (maInterval) clearInterval(maInterval); } catch (eD2) { /* 忽略 */ }
|
|
1618
|
+
maInterval = 0;
|
|
1619
|
+
};
|
|
1620
|
+
try { window.addEventListener("pagehide", maTeardown); } catch (ePh) { /* 忽略 */ }
|
|
1621
|
+
try { window.addEventListener("unload", maTeardown); } catch (eUl) { /* 忽略 */ }
|
|
1622
|
+
/* 手机上"切到别的 App 再切回来"走的常是 bfcache:pagehide(persisted=true) → pageshow(persisted=true)。
|
|
1623
|
+
DOM 原样保留但计时器/观察者已断 —— 这里重新武装,否则回到页面后看门狗就永久失灵了。 */
|
|
1624
|
+
try {
|
|
1625
|
+
window.addEventListener("pageshow", (ev) => { try { if (ev && ev.persisted) maArm(); } catch (ePs) { /* 忽略 */ } });
|
|
1626
|
+
} catch (ePs2) { /* 忽略 */ }
|
|
1523
1627
|
} catch (e) { if (window.console) console.warn("[dsh-mobile-adapter]", e && e.message); }
|
|
1524
1628
|
})();`;
|
|
1525
1629
|
|