dsh-remote-web 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -484,6 +484,26 @@ const CHUNK_SIZE = 200 * 1024;
484
484
  // 单个 HTTP 请求的兜底超时(秒)。dsh 的 prompt 等操作可能跑很久,给足余量。
485
485
  const HTTP_TIMEOUT_MS = 120_000;
486
486
 
487
+ /**
488
+ * 隧道 WS **接收**上限,与 relay-router 的 `WS_MAX_PAYLOAD`(256MB) 对齐。
489
+ *
490
+ * 【为什么要显式抬高】`ws` 的**客户端**默认 `maxPayload = 100MiB`;而 router → bridge
491
+ * 方向的帧**不分块**(router 的 `sendToBridge` 直接 `ws.send(JSON.stringify(frame))`),
492
+ * 最大的一帧来自上传:`/api/session/uploadFileBinary` 的请求体在 router 侧被
493
+ * `bodyBuf.toString("base64")` 后塞进帧里 —— router `MAX_BODY_BYTES = 64MiB`,
494
+ * 64MiB 的请求体 = 89.5MB 的 base64 字符串,离 100MiB 只剩 ~15% 余量。
495
+ * 一旦越界,`ws` 以 1009(message too big) 直接关闭:bridge 掉线 → router 对所有
496
+ * 后续请求回 502 `device_offline`(生产上"连续 502、怎么重试都失败"就是这个形态),
497
+ * 而**用户只看到"上传失败"**。对齐 256MB 后这一侧不再是瓶颈(内存风险仍由 router 的
498
+ * MAX_BODY_BYTES 兜住,这里只是不再替 router 提前掐线)。
499
+ */
500
+ export const TUNNEL_WS_MAX_PAYLOAD = 256 * 1024 * 1024;
501
+ /** 隧道 `new WebSocket` 的选项(导出给用例钉住 maxPayload,避免以后被"顺手删掉")。 */
502
+ export const TUNNEL_WS_OPTIONS = Object.freeze({ followRedirects: false, maxPayload: TUNNEL_WS_MAX_PAYLOAD });
503
+
504
+ /** 需要「长请求」可观测性的路径:大文件上传最典型(分钟级、几十 MB、失败无解释)。 */
505
+ const LONG_REQUEST_PATH_RE = /^\/api\/session\/uploadFileBinary(?:\?|$)/;
506
+
487
507
  // ---------- E2EE 运行时(默认禁用;main() 启动时按配置/服务端开关初始化) ----------
488
508
 
489
509
  /**
@@ -736,6 +756,125 @@ export async function maybeCompressResponse({ buf, contentType, contentEncoding,
736
756
  return { buf: out, headers: { "content-encoding": "gzip" } };
737
757
  }
738
758
 
759
+ /**
760
+ * 帧里请求体的**字节数**(http 帧一律 base64;旧协议是原始文本按 UTF-8 计)。
761
+ * 只用于日志/错误回包里的 `bytes`,算错不影响转发。
762
+ */
763
+ export function requestBodyBytes(body, isB64) {
764
+ if (body === undefined || body === null || body === "") return 0;
765
+ const s = String(body);
766
+ if (!isB64) return Buffer.byteLength(s, "utf8");
767
+ // base64 → 字节数 = 去掉 padding 后的长度 * 3/4(避免为了记日志再解一遍几十 MB)
768
+ const pad = s.endsWith("==") ? 2 : s.endsWith("=") ? 1 : 0;
769
+ return Math.max(0, Math.floor((s.length * 3) / 4) - pad);
770
+ }
771
+
772
+ /**
773
+ * 把「上游 fetch 失败」翻译成**用户能懂、客服能查**的 code + message。
774
+ *
775
+ * 背景(生产 2026-10-20~22):上传接口的 22 次 502 里有 24 字节的 `{"error":"fetch failed"}`
776
+ * —— 手机端只看到"上传失败",客服拿到的日志也只有一句 undici 的 `fetch failed`,
777
+ * 既分不清"上游没起来"和"请求太大超时",也没法告诉用户该做什么。分类后:
778
+ * - `upstream_timeout` 本机 dsh web 120s 没响应(大文件/上游卡住);
779
+ * - `upstream_unreachable` 连不上本机 dsh web(退出/换端口/连接被重置);
780
+ * - `upstream_error` 其余(带原始 message 与 cause code)。
781
+ * 纯函数、无 IO —— 502 回包与日志共用同一份结论,导出给用例钉住 code。
782
+ *
783
+ * @param {any} e fetch 抛出的错误(可能带 undici 的 `cause.code`)
784
+ * @returns {{code:string,message:string,detail:string}}
785
+ */
786
+ export function classifyUpstreamError(e) {
787
+ const name = String((e && e.name) || "");
788
+ const msg = String((e && e.message) || e || "");
789
+ const cause = e && typeof e.cause === "object" && e.cause ? e.cause : null;
790
+ const causeCode = String((cause && cause.code) || (e && e.code) || "").toUpperCase();
791
+ if (name === "TimeoutError" || name === "AbortError" || /timed out|timeout|aborted due to timeout/i.test(msg)) {
792
+ return {
793
+ code: "upstream_timeout",
794
+ message: `本机 dsh web 在 ${Math.round(HTTP_TIMEOUT_MS / 1000)} 秒内没有响应(文件太大或电脑端卡住),请稍后重试;反复失败请确认电脑端 DSH 仍在运行。`,
795
+ detail: causeCode || name || "timeout"
796
+ };
797
+ }
798
+ if (/ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|EPIPE|UND_ERR|socket hang up|fetch failed|other side closed|terminated/i.test(causeCode + " " + msg)) {
799
+ return {
800
+ code: "upstream_unreachable",
801
+ message: "连不上电脑端的 dsh web(可能已退出或换了端口);bridge 会自动重新发现,请稍后重试。",
802
+ detail: causeCode || msg.slice(0, 80)
803
+ };
804
+ }
805
+ return {
806
+ code: "upstream_error",
807
+ message: `转发到本机 dsh web 失败: ${msg || "未知错误"}`,
808
+ detail: causeCode
809
+ };
810
+ }
811
+
812
+ /** 在 `/api/file?path=...` 的 path 参数里取文件名(取最后一段;非法输入回落 download)。 */
813
+ export function downloadFilenameOf(filePath) {
814
+ const raw = String(filePath || "").replace(/[\x00-\x1f\x7f]/g, "");
815
+ const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "");
816
+ const base = cleaned.split("/").filter(Boolean).pop() || "";
817
+ return base && base !== "." && base !== ".." ? base : "download";
818
+ }
819
+
820
+ /**
821
+ * 下载语义(C):给 `/api/file` 的**导航**请求补 `Content-Disposition: attachment`。
822
+ *
823
+ * 【为什么需要】上游 dsh web 的 `/api/file` 只给 `Content-Type`,**没有** Content-Disposition
824
+ * (dsh-api-session-controller 的 serveFile)。iOS Safari 于是把 PDF/大图**内联**打开;
825
+ * 而上游同时带 `Content-Security-Policy: sandbox; default-src 'none'`,内联页经常直接空白 ——
826
+ * 用户看到的正是"下载只能在电脑端查看"。加 attachment 让手机浏览器落到下载管理器。
827
+ *
828
+ * 【为什么只对导航加】同一个 `/api/file` 也被官方前端当 `<img src>` 用(聊天里的绝对路径图片),
829
+ * 无差别加 attachment 有把图片预览打成下载的风险。判定顺序:
830
+ * 1. 方法必须是 GET,路径必须是 `/api/file`;
831
+ * 2. 响应必须是 2xx 且**有实体**(200/206):4xx/5xx 是错误页,加了 attachment 会
832
+ * 让浏览器把「登录失效/文件太大」的报错正文**下载成文件**,比原来更糟;
833
+ * 3. 上游**已经**给了 content-disposition(attachment 或 inline)→ 一律不覆盖(上游语义优先);
834
+ * 4. `?download=1/0` 显式指定:1 强制加、0 强制不加;
835
+ * 5. 没有显式参数时看导航特征:有 `sec-fetch-dest` 就只认 `document`;
836
+ * 没有(老浏览器)则要求 Accept 含 `text/html`(导航)。
837
+ * `image`/`empty`(fetch/XHR)/`iframe` 一律不动 —— 子资源加载本来就不受 attachment 影响,
838
+ * 但没必要冒任何风险。
839
+ *
840
+ * @param {{method?:string,path?:string,status?:number,reqHeaders?:object,resHeaders?:object}} args
841
+ * @returns {string|null} 需要附加的 Content-Disposition 值;null = 不附加
842
+ */
843
+ export function downloadDispositionOf({ method = "GET", path = "", status = 200, reqHeaders = {}, resHeaders = {} } = {}) {
844
+ if (String(method || "").toUpperCase() !== "GET") return null;
845
+ if (status !== 200 && status !== 206) return null; // 错误页不伪装成下载
846
+ const raw = String(path || "");
847
+ const q = raw.indexOf("?");
848
+ const pathname = q === -1 ? raw : raw.slice(0, q);
849
+ const search = q === -1 ? "" : raw.slice(q + 1);
850
+ if (pathname !== "/api/file") return null;
851
+ if (headerValue(resHeaders, "content-disposition")) return null; // 上游已有 → 不覆盖
852
+ let params = null;
853
+ try {
854
+ params = new URLSearchParams(search);
855
+ } catch {
856
+ params = null;
857
+ }
858
+ const explicit = params ? params.get("download") : null;
859
+ if (explicit === "0" || explicit === "false") return null;
860
+ if (explicit !== "1" && explicit !== "true") {
861
+ const dest = headerValue(reqHeaders, "sec-fetch-dest").toLowerCase();
862
+ if (dest) {
863
+ if (dest !== "document") return null;
864
+ } else if (!headerValue(reqHeaders, "accept").toLowerCase().includes("text/html")) {
865
+ return null; // 没有导航特征(老浏览器) → 保守不动
866
+ }
867
+ }
868
+ const name = downloadFilenameOf(params ? params.get("path") : "");
869
+ // RFC 5987 的 ext-value:Safari/Chrome/Firefox 都认 filename*;同时给一个 ASCII 回退,
870
+ // 免得只认 filename= 的老客户端把文件存成 URL 里的 "file"(连带丢扩展名)。
871
+ // 非 ASCII 名(中文等)的 ASCII 回退统一成 download.<ext> —— 比一串下划线可读。
872
+ const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
873
+ const ext = (/\.([A-Za-z0-9]{1,8})$/.exec(name) || [""])[0];
874
+ const ascii = /[^\x20-\x7e]/.test(name) ? `download${ext}` : name.replace(/["\\]/g, "_") || "download";
875
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
876
+ }
877
+
739
878
  /**
740
879
  * 进行中的 SSE 流:frameId → AbortController。
741
880
  * 手机侧断开时,中继会发 `http-abort`,这里据此掐掉上游(否则电脑上会留一条永不结束的连接)。
@@ -868,6 +1007,13 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
868
1007
  // 若我们自行 gzip,必须在 sanitize 之后把 content-encoding: gzip 补回,手机才能正确解压。
869
1008
  const headers = sanitizeResponseHeaders(Object.fromEntries(res.headers.entries()));
870
1009
  const contentType = res.headers.get("content-type") || "";
1010
+ // 下载语义(C):`/api/file` 的**导航**响应补 Content-Disposition: attachment。
1011
+ // 上游只给 Content-Type(iOS 会内联打开 PDF/大图,叠加 sandbox CSP 后常常一片空白),
1012
+ // 手机浏览器就不会落到下载管理器 —— 判据见 downloadDispositionOf(子资源/图片不动)。
1013
+ {
1014
+ const disposition = downloadDispositionOf({ method, path: safe, status: res.status, reqHeaders, resHeaders: headers });
1015
+ if (disposition) headers["content-disposition"] = disposition;
1016
+ }
871
1017
  if (!contentType.includes("text/event-stream")) { // SSE 长连不缓冲不注入(见模块注释)
872
1018
  const m = maybeInjectMobileAdapter({ buf, contentType });
873
1019
  if (m.injected) {
@@ -980,6 +1126,9 @@ export async function handleHttpFrame(dchOrSend, frame) {
980
1126
  const send = toSender(dchOrSend);
981
1127
  const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
982
1128
  const t0 = Date.now();
1129
+ // 长请求(大文件上传)的可观测性:上行字节与耗时都要进日志/错误回包,见 B 段注释。
1130
+ const bytesIn = requestBodyBytes(body, !!isB64);
1131
+ const isLongRequest = LONG_REQUEST_PATH_RE.test(String(path));
983
1132
  // 页面还活着:任何后续请求都撤掉「镜像页加载」看门狗(见 armMirrorLoadWatch)
984
1133
  if (mirrorLoadWatch) { mirrorLoadWatch = null; }
985
1134
  // 桌面授权引导(方案A):POST /_e2ee/intro(device/channel 形态归一)—— 本地应答,不连上游
@@ -1036,17 +1185,67 @@ export async function handleHttpFrame(dchOrSend, frame) {
1036
1185
  const reply = await doHttp(method, path, headers, body, !!isB64);
1037
1186
  reply.id = id;
1038
1187
  reply.type = "http";
1039
- send(reply);
1040
- console.log(`[bridge] ${method} ${path} → ${reply.status} (${Date.now() - t0}ms, ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
1188
+ const sent = send(reply);
1189
+ const ms = Date.now() - t0;
1190
+ const outBytes = Math.floor((reply.body.length * 3) / 4);
1191
+ if (isLongRequest) {
1192
+ // 长请求(大文件上传)单独记:上行字节、总耗时、状态码 —— 客服排查"上传失败"最需要这三样。
1193
+ console.log(`[bridge] ${method} ${path} → ${reply.status} (${ms}ms, 上行 ${(bytesIn / 1024 / 1024).toFixed(2)}MB, 下行 ${(outBytes / 1024).toFixed(0)}KB)`);
1194
+ } else {
1195
+ console.log(`[bridge] ${method} ${path} → ${reply.status} (${ms}ms, ${(outBytes / 1024).toFixed(0)}KB)`);
1196
+ }
1197
+ reportUndelivered(sent, { method, path, phase: "reply", ms, bytesIn, outBytes });
1041
1198
  } catch (e) {
1042
1199
  // 上游不可达是「端口可能变了」的最强信号(Desktop 换端口 / dsh web 重启到别的端口):
1043
1200
  // 触发一次带冷却的重新发现,后续请求自动跟上,不必重启 bridge。
1044
1201
  void refreshUpstream("上游请求失败");
1045
- console.log(`[bridge] ${method} ${path} 上游错误: ${e.message}`);
1046
- 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 });
1202
+ const ms = Date.now() - t0;
1203
+ const info = classifyUpstreamError(e);
1204
+ const detail = info.detail ? `, detail=${info.detail}` : "";
1205
+ const tag = isLongRequest ? "长请求" : "普通请求";
1206
+ const line = `[bridge] ${method} ${path} 上游错误(${info.code}): ${info.message}(${tag}, ${ms}ms, 上行 ${bytesIn}B${detail})`;
1207
+ if (isLongRequest || info.code !== "upstream_error") console.error(line);
1208
+ else console.log(line);
1209
+ // 502 body:从 `{"error":"fetch failed"}`(24 字节、用户与客服都看不懂)改成
1210
+ // `{error:{code,message,...}}` —— 与 bridge/router 其它错误回包同一形态。
1211
+ // 兼容:保留顶层 code/message,老读取方从 error 字符串改读这两个即可。
1212
+ const payload = {
1213
+ error: {
1214
+ code: info.code,
1215
+ message: info.message,
1216
+ method,
1217
+ path,
1218
+ bytes: bytesIn,
1219
+ durationMs: ms,
1220
+ ...(info.detail ? { detail: info.detail } : {})
1221
+ },
1222
+ code: info.code,
1223
+ message: info.message
1224
+ };
1225
+ const sent = send({
1226
+ id,
1227
+ type: "http",
1228
+ status: 502,
1229
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
1230
+ body: Buffer.from(JSON.stringify(payload)).toString("base64"),
1231
+ bodyBase64: true
1232
+ });
1233
+ reportUndelivered(sent, { method, path, phase: "502", ms, bytesIn, outBytes: 0 });
1047
1234
  }
1048
1235
  }
1049
1236
 
1237
+ /**
1238
+ * 回包没送进隧道(ws 已断/缓冲写失败)时的**唯一在线证据**。
1239
+ *
1240
+ * 中继侧对"bridge 断开"只会回一句 `502 device_offline`,bridge 这边如果静默吞掉,
1241
+ * 事后就分不清是"桥没收到请求"还是"桥回了但没送出去"。这里只在 send 明确返回 false
1242
+ * (makeChunkedSender 捕获到 ws.send 抛错)时记一行 —— 不改变任何行为。
1243
+ */
1244
+ function reportUndelivered(sent, { method, path, phase, ms, bytesIn, outBytes }) {
1245
+ if (sent !== false) return;
1246
+ console.error(`[bridge] ${method} ${path} ${phase} 回包未能送入中继(tunnel_closed): ${ms}ms, 上行 ${bytesIn}B, 下行 ${outBytes}B`);
1247
+ }
1248
+
1050
1249
  /** 桌面授权引导(方案A):POST /_e2ee/intro —— 桥端本地应答,不连上游。
1051
1250
  * 路由层已按 dsh_token cookie 完成同账号授权(device/channel 形态都只到本设备);
1052
1251
  * 这里仅当桥端 E2EE 启用时一次性下发派生 MK(内存转发,不落盘不写日志)。 */
@@ -1536,7 +1735,9 @@ async function registerDeviceInAccount(token) {
1536
1735
 
1537
1736
  function connectTunnel(token) {
1538
1737
  const endpoint = tunnelEndpoint(TUNNEL_URL);
1539
- const ws = new WebSocket(endpoint, { followRedirects: false });
1738
+ // maxPayload 必须显式给:ws 客户端默认 100MiB,而 router→bridge 的单帧不分块,
1739
+ // 大文件上传的 base64 请求体可以顶到 89.5MB(见 TUNNEL_WS_MAX_PAYLOAD 注释)。
1740
+ const ws = new WebSocket(endpoint, TUNNEL_WS_OPTIONS);
1540
1741
  const send = makeChunkedSender((s) => { if (ws.readyState === WebSocket.OPEN) ws.send(s); });
1541
1742
  let heartbeat;
1542
1743
  let pongReceived = true;
@@ -571,9 +571,56 @@
571
571
  var noBody = status === 204 || status === 205 || status === 304 || bodyU8.length === 0;
572
572
  return new Response(noBody ? null : bodyU8, { status: status, headers: hdrs });
573
573
  }
574
+ /* ---- 网络层失败的一次极短重试(D,2026-10-23) ----------------------------------
575
+ * 为什么:大文件读(`/api/workspaceFiles/readBytes`)、会话历史(`/api/session/page`)、
576
+ * Office/PDF 渲染(`/api/officeToPdf/`)在弱网/中继限速下经常"读到一半断"——浏览器只
577
+ * 看到一次 fetch reject 或空响应,上层(文档预览/会话列表)直接落到失败态,用户得手点
578
+ * 「重新读取文件」。这些都是**幂等读**,重发一次几乎无副作用,却能吃掉大部分瞬时断流。
579
+ * 边界:
580
+ * - 只白名单幂等读路径;上传/写入路径永不重试;
581
+ * - 最多 1 次,间隔 300ms;
582
+ * - 只有**网络层失败**(fetch reject / 信封响应体为空或非 JSON)才重试;
583
+ * 已有响应体的 4xx/5xx(明文错误页)一律不重试;解封/解压失败(密钥问题)不重试;
584
+ * - 每次重试都重新封**新 nonce 的信封**:桥端 http 防重放是 5 分钟 nonce 滑动窗口,
585
+ * 复用同一个信封会被判 replay;
586
+ * - 调用方已 abort(切页/锁屏)→ 不再重试。
587
+ */
588
+ var SE_RETRY_PATH_PREFIXES = ["/api/workspaceFiles/", "/api/session/page", "/api/officeToPdf/"];
589
+ var SE_RETRY_DELAY_MS = 300;
590
+ /**
591
+ * 路径是否命中幂等读白名单。
592
+ *
593
+ * ⚠️ 必须用 `seUpstreamPathOf()` 归一后再匹配 —— 与加密策略(seShouldEncryptHttp)
594
+ * **同一口径**:device 形态下客户端会把路径改写成 `/remote/<deviceId>/api/...`,
595
+ * 直接拿 `urlObj.pathname` 匹配会漏掉这类请求(同样是被加密的读,失败同样该重试)。
596
+ * 匹配规则:目录式前缀(以 "/" 结尾)按前缀;其余(如 `/api/session/page`)要求**精确相等** ——
597
+ * 这样 `/api/session/pages`、`/api/session/page/extra` 都不会被误命中。
598
+ */
599
+ function seRetryableReadPath(fullPath) {
600
+ var up = "";
601
+ try { up = seUpstreamPathOf(fullPath); } catch (e) { return false; }
602
+ var p = String(up || "");
603
+ var q = p.indexOf("?");
604
+ if (q !== -1) p = p.slice(0, q);
605
+ for (var i = 0; i < SE_RETRY_PATH_PREFIXES.length; i++) {
606
+ var pre = SE_RETRY_PATH_PREFIXES[i];
607
+ if (pre.charAt(pre.length - 1) === "/") {
608
+ if (p.indexOf(pre) === 0) return true; // 目录式前缀(/api/workspaceFiles/、/api/officeToPdf/)
609
+ } else if (p === pre) {
610
+ return true; // 精确路径(/api/session/page)
611
+ }
612
+ }
613
+ return false;
614
+ }
615
+ function seDelay(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }
616
+ /** 与 fetch 的 abort 语义一致(调用方已 abort 时不该再重试,直接抛 AbortError)。 */
617
+ function seAbortSignalError() {
618
+ try { return new DOMException("The operation was aborted.", "AbortError"); } catch (e) { var err = new Error("The operation was aborted."); err.name = "AbortError"; return err; }
619
+ }
574
620
  /**
575
621
  * fetch 包装核心:命中策略 → 密封→发→响应信封解密→重建 Response;
576
622
  * 未命中/跨源/无法读取 body/明文响应 → 原样透传(零行为)。
623
+ * 幂等读路径的网络层失败最多重试一次(见上面 SE_RETRY_* 注释)。
577
624
  * @param {function} origFetch
578
625
  * @param {{sess: SeSession, origin: string}} ctx
579
626
  */
@@ -599,50 +646,69 @@
599
646
  }
600
647
  var upstream = seUpstreamPathOf(fullPath);
601
648
  var plain = seEncodeHttpReqPlain({ method: req.method, path: upstream, headers: seHeadersForEnvelope(req.headers), bodyBytes: bodyBytes });
602
- var n = seRandomBytes(SE_NONCE_BYTES);
603
- var nonceB64 = seBytesToB64(n);
604
- var env = await ctx.sess.seal({ kind: "http", dir: SE_DIR_P2B, counter: 0, data: plain, nonceBytes: n });
605
- var outerHeaders = seEnvelopeHeaders(ctx.sess.sessId);
606
- var cookieH = seHeaderValueOf(req.headers, "cookie");
607
- if (cookieH && req.credentials !== "omit") outerHeaders.cookie = cookieH; // node 测试显式 cookie;浏览器同源自动带
608
- var res;
609
- try {
610
- res = await origFetch(urlObj.href, {
611
- method: "POST",
612
- headers: outerHeaders,
613
- body: JSON.stringify(env),
614
- credentials: req.credentials,
615
- signal: req.signal
616
- });
617
- } catch (e) { throw e; }
618
- /* router/桥端明文错误页 → 原样透传(零行为),但**先看一眼**是不是桥端在说"会话我不认识"
619
- (x-dsh-e2ee-error)——是的话清掉本机失效交接单并给出明确原因(见 seNoteE2eeError)。 */
620
- if (!seIsEnvelopeResponse(res)) { seNoteE2eeError(res, ctx); return res; }
621
- var envResp = null;
622
- try { envResp = await res.json(); } catch (e) {
623
- seNotifyFail("响应信封非法");
624
- return seErrorResponse("bad_envelope", "⚠ 无法解密:响应信封非法");
625
- }
626
- var opened = null;
627
- try {
628
- opened = await ctx.sess.open({ kind: "http-resp", dir: SE_DIR_B2P, env: envResp, counter: envResp.c == null ? 0 : envResp.c, reqNonceB64: nonceB64 });
629
- } catch (e) {
630
- var code = e && e.code ? e.code : "auth_failed";
631
- seNotifyFail("响应解封失败: " + (e && e.message || code));
632
- return seErrorResponse(code, "⚠ 无法解密(" + code + "):密钥不一致、传输被篡改或会话已过期。请返回设备列表重新解锁。");
633
- }
634
- var rp = null;
635
- try { rp = seDecodeHttpRespPlain(opened.data); } catch (e) {
636
- seNotifyFail("响应明文非法");
637
- return seErrorResponse("bad_plain", "⚠ 无法解密:响应明文非法");
638
- }
639
- var bodyU8 = rp.bodyBytes instanceof Uint8Array ? rp.bodyBytes : (rp.bodyB64 ? seB64ToBytes(rp.bodyB64) : new Uint8Array(0));
640
- if (rp.enc === "gzip" && bodyU8.length) {
641
- var un = await seGunzip(bodyU8);
642
- if (!un) return seErrorResponse("gunzip", "⚠ 无法解压:响应 gzip 解压失败");
643
- bodyU8 = un;
649
+ var attempts = seRetryableReadPath(fullPath) ? 2 : 1;
650
+ var lastErr = null;
651
+ for (var attempt = 0; attempt < attempts; attempt++) {
652
+ if (attempt > 0) {
653
+ if (req.signal && req.signal.aborted) throw seAbortSignalError();
654
+ await seDelay(SE_RETRY_DELAY_MS);
655
+ if (req.signal && req.signal.aborted) throw seAbortSignalError();
656
+ }
657
+ var n = seRandomBytes(SE_NONCE_BYTES);
658
+ var nonceB64 = seBytesToB64(n);
659
+ var env = await ctx.sess.seal({ kind: "http", dir: SE_DIR_P2B, counter: 0, data: plain, nonceBytes: n });
660
+ var outerHeaders = seEnvelopeHeaders(ctx.sess.sessId);
661
+ var cookieH = seHeaderValueOf(req.headers, "cookie");
662
+ if (cookieH && req.credentials !== "omit") outerHeaders.cookie = cookieH; // node 测试显式 cookie;浏览器同源自动带
663
+ var res;
664
+ try {
665
+ res = await origFetch(urlObj.href, {
666
+ method: "POST",
667
+ headers: outerHeaders,
668
+ body: JSON.stringify(env),
669
+ credentials: req.credentials,
670
+ signal: req.signal
671
+ });
672
+ } catch (e) {
673
+ lastErr = e;
674
+ if (attempt + 1 < attempts) continue; // 网络层失败 → 白名单读路径再来一次
675
+ throw e; // 与改动前一致:不吞 fetch 异常(调用方按网络错误处理)
676
+ }
677
+ /* router/桥端明文错误页 → 原样透传(零行为),但**先看一眼**是不是桥端在说"会话我不认识"
678
+ (x-dsh-e2ee-error)——是的话清掉本机失效交接单并给出明确原因(见 seNoteE2eeError)。
679
+ 注意:这里已经有完整响应体,4xx/5xx 也**不重试**。 */
680
+ if (!seIsEnvelopeResponse(res)) { seNoteE2eeError(res, ctx); return res; }
681
+ var envResp = null;
682
+ try { envResp = await res.json(); } catch (e) {
683
+ // 信封响应体为空/被截断(网络层) → 白名单读路径再来一次
684
+ if (attempt + 1 < attempts) continue;
685
+ seNotifyFail("响应信封非法");
686
+ return seErrorResponse("bad_envelope", "⚠ 无法解密:响应信封非法");
687
+ }
688
+ var opened = null;
689
+ try {
690
+ opened = await ctx.sess.open({ kind: "http-resp", dir: SE_DIR_B2P, env: envResp, counter: envResp.c == null ? 0 : envResp.c, reqNonceB64: nonceB64 });
691
+ } catch (e) {
692
+ // 密钥不一致/被篡改/会话过期:重试解决不了,保持原有显式失败
693
+ var code = e && e.code ? e.code : "auth_failed";
694
+ seNotifyFail("响应解封失败: " + (e && e.message || code));
695
+ return seErrorResponse(code, "⚠ 无法解密(" + code + "):密钥不一致、传输被篡改或会话已过期。请返回设备列表重新解锁。");
696
+ }
697
+ var rp = null;
698
+ try { rp = seDecodeHttpRespPlain(opened.data); } catch (e) {
699
+ seNotifyFail("响应明文非法");
700
+ return seErrorResponse("bad_plain", "⚠ 无法解密:响应明文非法");
701
+ }
702
+ var bodyU8 = rp.bodyBytes instanceof Uint8Array ? rp.bodyBytes : (rp.bodyB64 ? seB64ToBytes(rp.bodyB64) : new Uint8Array(0));
703
+ if (rp.enc === "gzip" && bodyU8.length) {
704
+ var un = await seGunzip(bodyU8);
705
+ if (!un) return seErrorResponse("gunzip", "⚠ 无法解压:响应 gzip 解压失败");
706
+ bodyU8 = un;
707
+ }
708
+ return seBuildResponse(rp.status, rp.headers, bodyU8);
644
709
  }
645
- return seBuildResponse(rp.status, rp.headers, bodyU8);
710
+ /* 每轮都以 return/throw 收尾,这里只是兜底(避免静态检查报"可能不返回") */
711
+ throw lastErr || seErr("e2ee_failed", "e2ee: fetch 包装未产生结果");
646
712
  }
647
713
 
648
714
  /* ---- 数据 WS 代理(逐消息信封;上层看到的是解密后的原文) ---- */
@@ -1048,7 +1048,18 @@ const SCRIPT = `(() => {
1048
1048
  compatOfficial: maCompatOfficial,
1049
1049
  scrim: maScrimStats,
1050
1050
  retry: maRetryStats,
1051
- load: { slow: maLoadWatch.slow, apiFails: maLoadWatch.apiFails, lastApiStatus: maLoadWatch.lastApiStatus }
1051
+ load: { slow: maLoadWatch.slow, apiFails: maLoadWatch.apiFails, lastApiStatus: maLoadWatch.lastApiStatus },
1052
+ // E2EE 死会话自动恢复 / 回前台重连(2026-10-23):只作诊断,不参与任何判定。
1053
+ // var 提升在此处可能尚未赋值(maPublishDiag 在恢复层定义之前就会被调用一次)→ 用 typeof 兜住。
1054
+ resume: (typeof maResumeState !== "undefined" && maResumeState)
1055
+ ? {
1056
+ armed: !!maResumeState.armed, done: !!maResumeState.done,
1057
+ reason: maResumeState.reason, lastAt: maResumeState.lastAt,
1058
+ fg: (typeof maFgState !== "undefined" && maFgState)
1059
+ ? { count: maFgState.count, lastAt: maFgState.lastAt, src: maFgState.lastSrc, probing: !!maFgState.probing }
1060
+ : null
1061
+ }
1062
+ : null
1052
1063
  };
1053
1064
  } catch (e) { /* 诊断失败绝不影响页面 */ }
1054
1065
  }
@@ -1704,6 +1715,445 @@ const SCRIPT = `(() => {
1704
1715
  } catch (e) { /* 忽略 */ }
1705
1716
  }, MA_LOAD_WATCH_MS);
1706
1717
 
1718
+ /* ============================================================================
1719
+ * E2EE 死会话自动恢复 + 回到前台重连(2026-10-23,iOS 断线重连体验)
1720
+ *
1721
+ * 现场(iOS 用户反馈):退出 Safari / 切到别的 App 一两分钟再回来,镜像页几乎必然重载;
1722
+ * 而桥端 E2EE 会话表是**进程内存**(24h TTL + CAP 32 LRU,电脑端重启即丢)—— 重载后
1723
+ * 常撞上「加密会话已失效,需要重新解锁」,旧行为只能回设备列表手动重新解锁。
1724
+ * 本段只做两件事,都**只读**状态(window.__dshE2eeBadge + dsh-e2ee-badge 事件),
1725
+ * 不碰任何加解密逻辑、不重建会话、不自己重连 WS:
1726
+ *
1727
+ * ① 死会话 → 自动回 /app/?resume=<deviceId>&from=mirror,由 native.html 用
1728
+ * 「记住本机」的派生 MK 静默解锁再回镜像页(全程无手势、无密码输入);
1729
+ * ② 从后台恢复(pageshow persisted / visibilitychange)→ 一次轻量探针确认隧道还在;
1730
+ * 失败则唤醒官方客户端(online 事件)并再探一次,仍失败只给**可见**提示
1731
+ * (复用底部提示条的「重试 / 返回设备列表 / 复制诊断」出口),绝不做静默无限重连。
1732
+ *
1733
+ * 触发条件极窄(业主验收口径:只有"确认 E2EE 不可用"才触发):
1734
+ * · 只认镜像页路径 /remote/<deviceId>/(不是镜像页直接什么都不做);
1735
+ * · 只认桥端明确答复的「加密会话已失效 / 需要重新解锁」;
1736
+ * · 纯明文(「未加密」「未解锁」「明文」)默认**绝不**打扰 —— 用户可能主动选了「暂不加密」。
1737
+ * 唯一例外:本页是为了甩掉死会话才被 shim 重载过(sessionStorage 里有它的重载闸门),
1738
+ * 且本机还留着「记住本机」的派生 MK —— 那才是"本该加密、却被自动降级成明文"。
1739
+ * 闸门:本页最多一次 + 60 秒冷却 + 60 秒窗口内最多 2 次(sessionStorage,跨重载仍有效)。
1740
+ *
1741
+ * 注入体纪律:本段不得出现反引号;字符串里不得出现反斜杠转义(模板字符串会先吃一层)。
1742
+ * ========================================================================= */
1743
+
1744
+ /* ===DSH-MA-RESUME-ENGINE-START=== */
1745
+ /* ===DSH-MA-RESUME-PURE-START=== */
1746
+ /** 镜像页设备 id:/remote/<deviceId>/… → deviceId(纯函数;不是镜像页返回空串)。
1747
+ * 设备 id 之后要拼进跳转地址,所以只接受 URL 安全字符,其它一律当没有。 */
1748
+ function maDeviceIdOfMirrorPath(pathname) {
1749
+ try {
1750
+ var parts = String(pathname || "").split("/").filter(Boolean);
1751
+ if (parts[0] !== "remote" || !parts[1]) return "";
1752
+ var id = "";
1753
+ try { id = decodeURIComponent(parts[1]); } catch (e) { id = ""; }
1754
+ return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id) ? id : "";
1755
+ } catch (e) { return ""; }
1756
+ }
1757
+
1758
+ /**
1759
+ * 是否该自动恢复(纯函数,便于单测)。输入 E2EE 徽标状态 + 本机上下文。
1760
+ * @param {{mode?:string,text?:string}|null} st __dshE2eeBadge.state()
1761
+ * @param {{mk?:boolean,deadReloadAt?:number,now?:number}} ctx mk=有派生 MK;deadReloadAt=死会话重载闸门时间戳
1762
+ * @returns {string} "" 不恢复;"dead_session" 桥端明确说不认这张票;"plaintext_after_dead" 抢修降级后的明文
1763
+ */
1764
+ function maE2eeResumeDecision(st, ctx) {
1765
+ try {
1766
+ if (!st || typeof st !== "object") return "";
1767
+ var mode = String(st.mode || "");
1768
+ var text = String(st.text || "");
1769
+ if (mode === "ok" || !text) return "";
1770
+ var c = ctx && typeof ctx === "object" ? ctx : {};
1771
+ /* ① 桥端明确答复:会话没了 / 需要重新解锁。这是**唯一**的确定信号。 */
1772
+ if (/加密会话已失效|会话已失效|需要重新解锁/.test(text)) return "dead_session";
1773
+ /* ② 明文/未解锁:默认绝不打扰。仅当"本页刚为甩掉死会话被重载过 + 本机有记住的 MK"才抢修 ——
1774
+ 这两个条件同时成立时,用户本来应该是加密的,现在只是被自动降级成了明文。 */
1775
+ if (/未加密|未解锁|明文/.test(text)) {
1776
+ var reloadAt = Number(c.deadReloadAt) || 0;
1777
+ var now = Number(c.now) || 0;
1778
+ if (c.mk === true && reloadAt > 0 && now > reloadAt && now - reloadAt < 90 * 1000) {
1779
+ return "plaintext_after_dead";
1780
+ }
1781
+ return "";
1782
+ }
1783
+ return "";
1784
+ } catch (e) { return ""; }
1785
+ }
1786
+ /* ===DSH-MA-RESUME-PURE-END=== */
1787
+
1788
+ var MA_RESUME_DELAY_MS = 400; // 认出死会话后 400ms 才跳(让 shim 自己的收尾先跑完,避免两路导航抢)
1789
+ var MA_RESUME_COOLDOWN_MS = 60 * 1000; // 两次恢复之间的冷却
1790
+ var MA_RESUME_WINDOW_MS = 60 * 1000; // 时间窗
1791
+ var MA_RESUME_MAX_IN_WINDOW = 2; // 窗口内最多 2 次(与 native.html 闸门一致,双层兜底)
1792
+ var MA_RESUME_KEY = "dsh-ma-e2ee-resume";
1793
+ var MA_DEAD_RELOAD_KEY = "dsh-e2ee-dead-reload"; // e2ee-shim 的"每页只重载一次"闸门(只读)
1794
+ var MA_MK_KEY = "dsh-e2ee-mk-v1"; // native.html「记住本机」的派生 MK(只读)
1795
+ var MA_FG_MIN_BG_MS = 8000; // 后台停留 <8s 不算"断线场景",不打扰
1796
+ var MA_FG_COOLDOWN_MS = 60 * 1000; // 回前台恢复的冷却
1797
+ var MA_FG_MAX_PER_PAGE = 3; // 本页最多 3 次(防抖动/循环)
1798
+ var MA_PROBE_TIMEOUT_MS = 4000; // 探针超时
1799
+ var MA_PROBE_RETRY_MS = 2500; // 唤醒官方客户端后再探的间隔
1800
+
1801
+ var maResumeState = { done: false, redirecting: false, lastAt: 0, reason: "", armed: false };
1802
+ var maFgState = { hiddenAt: 0, lastAt: 0, count: 0, probing: false, lastSrc: "" };
1803
+
1804
+ /** 读 E2EE 徽标状态(shim 未注入 → null,全程不动作)。 */
1805
+ function maE2eeStateNow() {
1806
+ try {
1807
+ return window.__dshE2eeBadge && typeof window.__dshE2eeBadge.state === "function"
1808
+ ? window.__dshE2eeBadge.state() : null;
1809
+ } catch (e) { return null; }
1810
+ }
1811
+
1812
+ /** 本机上下文:有没有「记住本机」的 MK、shim 是否刚为死会话重载过(都只读)。 */
1813
+ function maResumeContext() {
1814
+ var mk = false, reloadAt = 0;
1815
+ try { mk = !!(window.localStorage && window.localStorage.getItem(MA_MK_KEY)); } catch (e) { mk = false; }
1816
+ try { reloadAt = Number((window.sessionStorage && window.sessionStorage.getItem(MA_DEAD_RELOAD_KEY)) || 0) || 0; } catch (e2) { reloadAt = 0; }
1817
+ return { mk: mk, deadReloadAt: reloadAt, now: Date.now() };
1818
+ }
1819
+
1820
+ /** 闸门:本页一次 + 冷却 + 时间窗内限次(sessionStorage 跨重载仍有效)。 */
1821
+ function maResumeGatePass(now) {
1822
+ try {
1823
+ if (maResumeState.done || maResumeState.redirecting) return false;
1824
+ if (maResumeState.lastAt && now - maResumeState.lastAt < MA_RESUME_COOLDOWN_MS) return false;
1825
+ var raw = null;
1826
+ try { raw = window.sessionStorage && window.sessionStorage.getItem(MA_RESUME_KEY); } catch (e0) { raw = null; }
1827
+ var hits = null;
1828
+ try { hits = JSON.parse(raw || "[]"); } catch (e1) { hits = []; }
1829
+ if (!Array.isArray(hits)) hits = [];
1830
+ var kept = [];
1831
+ for (var i = 0; i < hits.length; i++) {
1832
+ var t = Number(hits[i]);
1833
+ if (isFinite(t) && now - t >= 0 && now - t < MA_RESUME_WINDOW_MS) kept.push(t);
1834
+ }
1835
+ if (kept.length >= MA_RESUME_MAX_IN_WINDOW) return false;
1836
+ kept.push(now);
1837
+ try { window.sessionStorage && window.sessionStorage.setItem(MA_RESUME_KEY, JSON.stringify(kept.slice(-8))); } catch (e2) { /* 存不下不构成"永不恢复" */ }
1838
+ return true;
1839
+ } catch (e) { return true; }
1840
+ }
1841
+
1842
+ /** 自动回设备列表入口(由 /app/ 用「记住本机」的 MK 静默解锁后回镜像页)。 */
1843
+ function maE2eeTryResume(reason) {
1844
+ try {
1845
+ var dev = maDeviceIdOfMirrorPath(window.location.pathname);
1846
+ if (!dev) return false;
1847
+ var now = Date.now();
1848
+ if (!maResumeGatePass(now)) { maPublishDiag(); return false; }
1849
+ maResumeState.done = true;
1850
+ maResumeState.redirecting = true;
1851
+ maResumeState.lastAt = now;
1852
+ maResumeState.reason = String(reason || "");
1853
+ maPublishDiag();
1854
+ var to = "/app/?resume=" + encodeURIComponent(dev) + "&from=mirror";
1855
+ setTimeout(function () {
1856
+ try { window.location.replace(to); return; } catch (e1) { /* 继续 */ }
1857
+ try { window.location.assign(to); } catch (e2) { /* 继续 */ }
1858
+ try { window.location.href = to; } catch (e3) { /* 放弃(例如沙箱里没有 location) */ }
1859
+ }, MA_RESUME_DELAY_MS);
1860
+ return true;
1861
+ } catch (e) { return false; }
1862
+ }
1863
+
1864
+ function maDelay(ms) {
1865
+ return new Promise(function (resolve) {
1866
+ try { setTimeout(resolve, Number(ms) || 0); } catch (e) { resolve(); }
1867
+ });
1868
+ }
1869
+
1870
+ /** 底部提示条的**临时**用途:回前台重连失败时给一条可见说明(复用既有重试/返回出口)。
1871
+ * 优先级让给首屏慢加载(那条有更高价值,别抢它的文案);用户关过一次就不再打扰。 */
1872
+ function maShowFgHint(text) {
1873
+ try {
1874
+ if (maLoadWatch.dismissed || maLoadWatch.slow) return;
1875
+ var bar = maLoadWatch.barEl || maLoadMountBar();
1876
+ if (!bar) return;
1877
+ maLoadWatch.fgHint = true;
1878
+ if (maLoadWatch.barTxt) maLoadWatch.barTxt.textContent = String(text || "");
1879
+ bar.hidden = false;
1880
+ } catch (e) { /* 提示失败不影响恢复本身 */ }
1881
+ }
1882
+ function maHideFgHint() {
1883
+ try {
1884
+ if (!maLoadWatch.fgHint) return;
1885
+ maLoadWatch.fgHint = false;
1886
+ maLoadHideBar();
1887
+ } catch (e) { /* 忽略 */ }
1888
+ }
1889
+
1890
+ /** 唤醒官方客户端:派发一次 online 事件(它自己按"网络恢复"重连)—— 只派发,不替它重连。 */
1891
+ function maNudgeOnline() {
1892
+ try {
1893
+ var ev = null;
1894
+ try { ev = new Event("online"); } catch (e0) { ev = null; }
1895
+ if (ev) window.dispatchEvent(ev);
1896
+ } catch (e) { /* 忽略 */ }
1897
+ }
1898
+
1899
+ /**
1900
+ * 轻量探针:**必须过桥**才算数(业主 2026-10-23 指正)。
1901
+ *
1902
+ * 为什么不能用 manifest:relay-router 的设备无关静态资源兜底(serveDeviceFreeAsset)会**本地直出**
1903
+ * manifest.webmanifest / favicon.svg —— 打这些路径只能证明"手机能连上中继",证明不了
1904
+ * "这台电脑的隧道还活着",而 iOS 回前台的真实故障恰恰是后者。
1905
+ *
1906
+ * 现在打 「HEAD /remote/<deviceId>/」:走 resolveRoute → authorizeRemote → bridge 的 http 帧 →
1907
+ * 电脑端 dsh 返回 200(HEAD 无 body,成本最低)。判定按业主口径:
1908
+ * OK = 拿到响应且 status 不是 502/504(502/504 = 隧道/上游不可达,这才是失败)
1909
+ * 401/403/404 都算"有回应"(cookie 过期或路径不存在不等于隧道断了,加密状态另判)
1910
+ * FAIL = 网络错误 / 超时 / 502 / 504
1911
+ * 兜底:HEAD 被中间代理/旧环境拒绝时(第一次探针 reject),第二次退化为
1912
+ * 「GET /remote/<deviceId>/favicon.ico」(同样过桥;不在 DEVICE_FREE_ASSETS 白名单里)。
1913
+ *
1914
+ * @param {string} deviceId
1915
+ * @param {boolean} [useGet] true = 走 favicon.ico 的 GET 退化路径
1916
+ */
1917
+ function maProbeMirrorOnce(deviceId, useGet) {
1918
+ var tail = useGet ? "/favicon.ico" : "/";
1919
+ var url = "/remote/" + encodeURIComponent(deviceId) + tail + "?dshProbe=" + Date.now();
1920
+ var ctrl = null;
1921
+ try { ctrl = typeof AbortController === "function" ? new AbortController() : null; } catch (e0) { ctrl = null; }
1922
+ /* accept 用 json 而不是 html:router 的 isDocNavigation 在缺少 sec-fetch-* 的老浏览器上
1923
+ 会回落到 accept 判定 —— 带上 html 会被当成"页面导航",设备离线时改走 302 引导页,
1924
+ 探针就永远看不到 502 了(这是这条探针最容易踩的坑)。 */
1925
+ var opts = { method: useGet ? "GET" : "HEAD", cache: "no-store", headers: { accept: "application/json" } };
1926
+ if (ctrl) opts.signal = ctrl.signal;
1927
+ return new Promise(function (resolve) {
1928
+ var settled = false;
1929
+ var timer = 0;
1930
+ var done = function (r) {
1931
+ if (settled) return;
1932
+ settled = true;
1933
+ try { if (timer) clearTimeout(timer); } catch (e1) { /* 忽略 */ }
1934
+ resolve(r);
1935
+ };
1936
+ try {
1937
+ timer = setTimeout(function () {
1938
+ try { if (ctrl) ctrl.abort(); } catch (e2) { /* 忽略 */ }
1939
+ done({ ok: false, why: "timeout" });
1940
+ }, MA_PROBE_TIMEOUT_MS);
1941
+ } catch (e3) { /* 忽略 */ }
1942
+ var p = null;
1943
+ try { p = typeof window.fetch === "function" ? window.fetch(url, opts) : null; } catch (e4) { p = null; }
1944
+ if (!p || typeof p.then !== "function") { done({ ok: false, why: "nocall" }); return; }
1945
+ p.then(function (res) {
1946
+ var status = 0;
1947
+ try { status = Number(res && res.status) || 0; } catch (e5) { status = 0; }
1948
+ // 502/504 = router 或上游明确"这台的隧道不可达";其余状态码都说明"有回应"
1949
+ done({ ok: status > 0 && status !== 502 && status !== 504, status: status });
1950
+ }, function () { done({ ok: false, why: "reject" }); });
1951
+ });
1952
+ }
1953
+
1954
+ /** 回到前台:确认加密状态 + 一次轻量探针 → 原地重连(online)→ 再探一次 → 可见提示。 */
1955
+ function maForegroundResume(src) {
1956
+ try {
1957
+ if (!maResumeState.armed) return;
1958
+ var dev = maDeviceIdOfMirrorPath(window.location.pathname);
1959
+ if (!dev) return;
1960
+ var now = Date.now();
1961
+ var gap = maFgState.hiddenAt ? now - maFgState.hiddenAt : 0;
1962
+ maFgState.hiddenAt = 0;
1963
+ if (gap < MA_FG_MIN_BG_MS) return; // 短切走不算断线
1964
+ if (maFgState.probing) return; // 不叠并发
1965
+ if (maFgState.count >= MA_FG_MAX_PER_PAGE) return; // 本页额度用完就不再自动碰
1966
+ if (maFgState.lastAt && now - maFgState.lastAt < MA_FG_COOLDOWN_MS) return;
1967
+ var st = maE2eeStateNow();
1968
+ if (!st) return; // shim 未注入(旧 bridge/关闭 E2EE)→ 加密与本次恢复无关,官方客户端自己处理
1969
+ var decision = maE2eeResumeDecision(st, maResumeContext());
1970
+ if (decision) { maE2eeTryResume(decision); return; } // 确认不可用 → 直接自动恢复(无手势)
1971
+ maFgState.count += 1;
1972
+ maFgState.lastAt = now;
1973
+ maFgState.probing = true;
1974
+ maFgState.lastSrc = String(src || "");
1975
+ var settle = function () { try { maFgState.probing = false; } catch (e0) { /* 忽略 */ } };
1976
+ maProbeMirrorOnce(dev).then(function (r1) {
1977
+ if (r1 && r1.ok) { maHideFgHint(); settle(); return; }
1978
+ maShowFgHint("网络似乎断开了,正在重新连接…");
1979
+ maNudgeOnline();
1980
+ // 第二次探针换 GET favicon.ico:既验证"隧道是否真的断了",也顺手覆盖 HEAD 不被支持的旧环境
1981
+ return maDelay(MA_PROBE_RETRY_MS).then(function () { return maProbeMirrorOnce(dev, true); }).then(function (r2) {
1982
+ if (r2 && r2.ok) { maHideFgHint(); settle(); return; }
1983
+ maShowFgHint("连接没有恢复。点「重试」重新加载本页,或点「返回设备列表」重新进入。");
1984
+ settle();
1985
+ });
1986
+ }).then(function () { settle(); }, function () { settle(); });
1987
+ } catch (e) { try { maFgState.probing = false; } catch (e2) { /* 忽略 */ } }
1988
+ }
1989
+
1990
+ /** 徽标状态一变就看一眼(纯读,不做别的)。 */
1991
+ function maE2eeOnBadge() {
1992
+ try {
1993
+ var st = maE2eeStateNow();
1994
+ if (!st) return;
1995
+ var decision = maE2eeResumeDecision(st, maResumeContext());
1996
+ if (decision) maE2eeTryResume(decision);
1997
+ } catch (e) { /* 忽略 */ }
1998
+ }
1999
+
2000
+ /** 安装监听(幂等)。**只**在镜像页 /remote/<deviceId>/ 上生效。 */
2001
+ function maArmE2eeResume() {
2002
+ try {
2003
+ if (maResumeState.armed) return;
2004
+ var dev = maDeviceIdOfMirrorPath(window.location.pathname);
2005
+ if (!dev) return;
2006
+ maResumeState.armed = true;
2007
+ var onBadge = function () { maE2eeOnBadge(); };
2008
+ try { document.addEventListener("dsh-e2ee-badge", onBadge); } catch (e1) { /* 忽略 */ }
2009
+ try { window.addEventListener("dsh-e2ee-badge", onBadge); } catch (e2) { /* 忽略 */ }
2010
+ // shim 可能在本层挂牌前就发布了状态(注入顺序:adapter 先、shim 后,通常不会;这里兜底两次)
2011
+ setTimeout(function () { try { maE2eeOnBadge(); } catch (e3) { /* 忽略 */ } }, 800);
2012
+ setTimeout(function () { try { maE2eeOnBadge(); } catch (e4) { /* 忽略 */ } }, 3000);
2013
+ // 回前台:bfcache 恢复走 pageshow(persisted);普通切后台走 visibilitychange
2014
+ try {
2015
+ window.addEventListener("pageshow", function (ev) {
2016
+ try { if (ev && ev.persisted) maForegroundResume("pageshow"); } catch (e5) { /* 忽略 */ }
2017
+ });
2018
+ } catch (e6) { /* 忽略 */ }
2019
+ try {
2020
+ document.addEventListener("visibilitychange", function () {
2021
+ try {
2022
+ if (document.visibilityState === "hidden") { maFgState.hiddenAt = Date.now(); return; }
2023
+ maForegroundResume("visibilitychange");
2024
+ } catch (e7) { /* 忽略 */ }
2025
+ });
2026
+ } catch (e8) { /* 忽略 */ }
2027
+ maPublishDiag();
2028
+ } catch (e) { /* 恢复层装不上也不能影响页面 */ }
2029
+ }
2030
+
2031
+ try { maArmE2eeResume(); } catch (eResumeArm) { /* 忽略 */ }
2032
+
2033
+ /* ===DSH-MA-RESUME-ENGINE-END=== */
2034
+
2035
+ /* ============================================================================
2036
+ * 上传失败「可读提示」(2026-10-23,文件上传反馈)
2037
+ *
2038
+ * 现场(生产反馈 fb_930b950aed94 + nginx 日志 22 次 502 / 12 次 413):手机上传失败只看到
2039
+ * 「上传失败,点击重试」。根因在 dsh 上游客户端:dsh-client-file-upload/lib/client.js
2040
+ * 在 status !== 200 时直接抛错并**丢弃响应体**;而中继/桥端本轮已能给出可读原因
2041
+ * (>64MB 的 413 JSON、上游不可达的分类 502 JSON),这些文字却到不了用户。
2042
+ *
2043
+ * 本层只做一件事:**旁听** dsh 上传 Worker 的完成消息({kind:"complete",status,body}),
2044
+ * 非 200 时补一条人话提示。不改协议、不拦请求、不重试、不碰 E2EE(Worker 独立作用域,
2045
+ * 上传内容本来就不经 shim —— 那是另案)。
2046
+ * 触发面收窄:只接管 new Worker(..., { name: "dsh-file-upload" }),其它 Worker 原样返回;
2047
+ * 任何异常都退回原生 Worker(上传功能绝不受本层影响)。
2048
+ * ========================================================================= */
2049
+ /* ===DSH-MA-UPLOAD-HINT-PURE-START=== */
2050
+ /** 上传失败状态码 + 响应体 → 人话(纯函数)。空串 = 不提示(成功/交给 dsh 自己)。 */
2051
+ function maUploadFailureHint(status, body) {
2052
+ var s = Number(status) || 0;
2053
+ if (!s || s === 200 || s === 201) return "";
2054
+ var b = String(body == null ? "" : body);
2055
+ if (s === 413 || /payload_too_large|body_too_large|too large|exceeds? byte limit/i.test(b)) {
2056
+ return "上传失败:文件超过 64MB 上限,请压缩后重试,或改用电脑端上传。";
2057
+ }
2058
+ if (s === 502 || s === 504) return "上传失败:与电脑端的连接中断了。请重试;反复失败请确认电脑端 DSH 仍在运行。";
2059
+ if (s === 401 || s === 403) return "上传失败:登录状态已过期,请返回设备列表重新进入后再试。";
2060
+ var m = null;
2061
+ try { m = JSON.parse(b); } catch (e) { m = null; }
2062
+ var msg = "";
2063
+ try { if (m && m.error) msg = String(m.error.message || m.error.code || ""); } catch (e2) { msg = ""; }
2064
+ return "上传失败(HTTP " + s + ")" + (msg ? ":" + msg : ",请重试。");
2065
+ }
2066
+ /* ===DSH-MA-UPLOAD-HINT-PURE-END=== */
2067
+
2068
+ /* ===DSH-MA-UPLOAD-HINT-ENGINE-START=== */
2069
+ var MA_UPLOAD_FAIL_KEY = "dsh-ma-upload-fail";
2070
+ /** 底部一次性提示(自绘、6 秒自动消失、不挡点击;同一批失败只提示一次)。 */
2071
+ function maUploadToast(text) {
2072
+ try {
2073
+ var now = Date.now();
2074
+ var last = 0;
2075
+ try { last = Number(sessionStorage.getItem(MA_UPLOAD_FAIL_KEY)) || 0; } catch (eLast) { last = 0; }
2076
+ if (last && now - last < 4000) return;
2077
+ try { sessionStorage.setItem(MA_UPLOAD_FAIL_KEY, String(now)); } catch (eSet) { /* 忽略 */ }
2078
+ var doc = document;
2079
+ var el = doc.getElementById("dsh-ma-upload-toast");
2080
+ if (el && el.parentNode) el.parentNode.removeChild(el);
2081
+ el = doc.createElement("div");
2082
+ el.id = "dsh-ma-upload-toast";
2083
+ el.setAttribute("role", "status");
2084
+ el.textContent = String(text || "");
2085
+ el.style.cssText = "position:fixed;left:12px;right:12px;bottom:calc(18px + env(safe-area-inset-bottom));z-index:2147483001;"
2086
+ + "padding:10px 14px;border-radius:12px;background:rgba(32,32,32,.94);color:#fff;"
2087
+ + "font:13px/1.5 -apple-system,BlinkMacSystemFont,'PingFang SC','Segoe UI',Roboto,sans-serif;"
2088
+ + "box-shadow:0 6px 24px rgba(0,0,0,.35);pointer-events:none;opacity:0;transition:opacity .2s ease;";
2089
+ (doc.body || doc.documentElement).appendChild(el);
2090
+ setTimeout(function () { try { el.style.opacity = "1"; } catch (eF) { /* 忽略 */ } }, 16);
2091
+ setTimeout(function () {
2092
+ try { el.style.opacity = "0"; } catch (eO) { /* 忽略 */ }
2093
+ setTimeout(function () { try { if (el.parentNode) el.parentNode.removeChild(el); } catch (eR) { /* 忽略 */ } }, 260);
2094
+ }, 6000);
2095
+ } catch (e) { /* 提示失败不影响上传本身 */ }
2096
+ }
2097
+ /** 旁听上传 Worker:只包 onmessage/onerror,其余成员透传(方法绑定到原生对象)。 */
2098
+ function maTapUploadWorker(w) {
2099
+ var proxy = null;
2100
+ var userMsg = null;
2101
+ var userErr = null;
2102
+ var onMsg = function (ev) {
2103
+ try {
2104
+ var d = ev && ev.data;
2105
+ if (d && d.kind === "complete") {
2106
+ var hint = maUploadFailureHint(d.status, d.body);
2107
+ if (hint) maUploadToast(hint);
2108
+ }
2109
+ } catch (eObs) { /* 忽略 */ }
2110
+ if (typeof userMsg === "function") return userMsg.call(proxy, ev);
2111
+ return undefined;
2112
+ };
2113
+ var onErr = function (ev) {
2114
+ if (typeof userErr === "function") return userErr.call(proxy, ev);
2115
+ return undefined;
2116
+ };
2117
+ proxy = new Proxy(w, {
2118
+ get: function (t, k) {
2119
+ if (k === "onmessage") return userMsg || undefined;
2120
+ if (k === "onerror") return userErr || undefined;
2121
+ var v = t[k];
2122
+ return typeof v === "function" ? v.bind(t) : v;
2123
+ },
2124
+ set: function (t, k, v) {
2125
+ if (k === "onmessage") { userMsg = v; t.onmessage = onMsg; return true; }
2126
+ if (k === "onerror") { userErr = v; t.onerror = onErr; return true; }
2127
+ t[k] = v;
2128
+ return true;
2129
+ }
2130
+ });
2131
+ return proxy;
2132
+ }
2133
+ /** 安装(幂等、可在看门狗里重复调用):只接管 name=dsh-file-upload 的 Worker。 */
2134
+ function maInstallUploadTap() {
2135
+ try {
2136
+ if (typeof window === "undefined" || typeof window.Worker !== "function") return false;
2137
+ var Native = window.Worker;
2138
+ if (Native && Native.__dshMaUploadTap) return true;
2139
+ var Wrapped = new Proxy(Native, {
2140
+ construct: function (Target, args) {
2141
+ var w = Reflect.construct(Target, args);
2142
+ try {
2143
+ var opts = args && args[1];
2144
+ var name = opts && typeof opts === "object" ? String(opts.name || "") : "";
2145
+ if (name !== "dsh-file-upload") return w;
2146
+ return maTapUploadWorker(w);
2147
+ } catch (eTap) { return w; }
2148
+ }
2149
+ });
2150
+ try { Wrapped.__dshMaUploadTap = true; } catch (eMark) { /* 忽略 */ }
2151
+ window.Worker = Wrapped;
2152
+ return true;
2153
+ } catch (e) { return false; }
2154
+ }
2155
+ /* ===DSH-MA-UPLOAD-HINT-ENGINE-END=== */
2156
+
1707
2157
  try {
1708
2158
  const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
1709
2159
  const NARROW = () => window.innerWidth <= 820;
@@ -2787,6 +3237,8 @@ const SCRIPT = `(() => {
2787
3237
  旧实现把包装写在 buildRemoteMenu 内部的一次性分支里,那正是"请求明明在打、
2788
3238
  界面却永远测速中"的结构性原因。 */
2789
3239
  try { maInstallNetSampler(); } catch (eSampler0) { /* 忽略 */ }
3240
+ /* 上传失败可读提示(2026-10-23):与采样器同样"越早越好",且幂等 —— 看门狗每拍调用无副作用。 */
3241
+ try { maInstallUploadTap(); } catch (eUploadTap0) { /* 忽略 */ }
2790
3242
  /* 悬浮按钮/菜单:任何宽度都要有(电脑端访问镜像页时同样需要返回设备列表的出口)。
2791
3243
  失败也不影响手机端布局适配 —— 两条链路各自独立。 */
2792
3244
  if (!fabDone) { fabDone = true; try { buildRemoteMenu(); } catch (eFabDone) { /* 忽略 */ } }
@@ -3285,6 +3737,7 @@ const SCRIPT = `(() => {
3285
3737
  官方若在运行期替换 fetch 同理),下一拍就会把采样器重新挂回去 —— 幂等,已挂上时零开销。
3286
3738
  同时顺手重绘一次:官方 SPA 换掉菜单子树后,数字不必等下一次请求才回来。 */
3287
3739
  try { maInstallNetSampler(); } catch (eSampler) { /* 忽略 */ }
3740
+ try { maInstallUploadTap(); } catch (eUploadTap) { /* 忽略 */ }
3288
3741
  try { if (maNetRender) maNetRender(); } catch (eNetRender) { /* 忽略 */ }
3289
3742
  try { ensureDrawerChrome(); } catch (eChrome2) { /* 忽略 */ }
3290
3743
  /* 镜像页就绪探测(mirror_load 的量测终点):pump 本来就被 MutationObserver(子树) + 1s 兜底表