@mrrisega/dsh-remote 0.6.14 → 0.6.15

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.
Files changed (34) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +64 -13
  2. package/clients/dsh-remote/dsh-events.mjs +22 -2
  3. package/clients/dsh-remote/e2ee-client.mjs +97 -13
  4. package/clients/dsh-remote/e2ee-shim-script.js +31 -3
  5. package/clients/dsh-remote/mobile-adapter.mjs +172 -0
  6. package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
  7. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
  8. package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
  9. package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
  10. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
  11. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
  12. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
  13. package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
  14. package/clients/dsh-remote/upstream-discovery.mjs +186 -30
  15. package/clients/dsh-remote/wechat-channel.mjs +86 -3
  16. package/clients/dsh-remote/wechat-runtime.mjs +253 -10
  17. package/dsh-setup.mjs +161 -7
  18. package/package.json +1 -1
  19. package/packages/dsh-remote-web/lib/client.js +31 -1
  20. package/packages/dsh-remote-web/lib/index.js +392 -49
  21. package/packages/dsh-remote-web/package.json +1 -1
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +64 -13
  23. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
  24. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +172 -0
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +186 -30
  28. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
  29. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
  30. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +161 -7
  31. package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
  32. package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
  33. package/packages/dsh-remote-web/test/picker-pin.test.mjs +80 -27
  34. package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
@@ -89,6 +89,8 @@ import {
89
89
  encodeHttpResponsePlain,
90
90
  hasEnvelopeMarker,
91
91
  parseWsE2eeParams,
92
+ stripPlainWantHeader,
93
+ wantsBinaryResponsePlain,
92
94
  writeE2eeStateFile
93
95
  } from "./e2ee-client.mjs";
94
96
  // 微信机器人通道(编排层):绑定控制面 + 出站通知路由 + 入站长轮询。
@@ -800,6 +802,34 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
800
802
  };
801
803
  }
802
804
 
805
+ /**
806
+ * 构造 e2ee http 响应帧(**导出给用例**:第三层 base64 的去留必须有测试钉住)。
807
+ *
808
+ * 【0.6.15】这里曾经是 `body: Buffer.from(JSON.stringify(respEnv)).toString("base64")`
809
+ * + `bodyBase64: true` —— 也就是把整个信封 JSON 再 base64 一次。中继拿到它做的第一件事
810
+ * 就是解回 UTF-8 再写给手机,所以这一层**纯属白花 33% 流量**,而且正好落在按流量计量的
811
+ * bridge→中继 那条 WS 上(业主实测那条聚合包:12.69 MiB vs 直接发 UTF-8 的 9.5 MiB)。
812
+ * 中继对两种形态都支持(`frame.bodyBase64` 真假之分),所以直接发 UTF-8 明文即可。
813
+ *
814
+ * @param {string|number} id 帧 id
815
+ * @param {string} sessId E2EE 会话 id(只进标记头)
816
+ * @param {object} respEnv 已封好的响应信封
817
+ * @returns {{id:any,type:string,status:number,headers:object,body:string,bodyBase64:boolean}}
818
+ */
819
+ export function e2eeHttpResponseFrame(id, sessId, respEnv) {
820
+ return {
821
+ id,
822
+ type: "http",
823
+ status: 200, // 外层一律 200,真实状态在信封明文 st 里(§4.3)
824
+ headers: {
825
+ "content-type": ENVELOPE_CONTENT_TYPE,
826
+ "x-dsh-e2ee": `v=2;s=${sessId};k=http-resp`
827
+ },
828
+ body: JSON.stringify(respEnv),
829
+ bodyBase64: false // ★ 第三层 base64 在这里被去掉(见上)
830
+ };
831
+ }
832
+
803
833
  export async function handleHttpFrame(dchOrSend, frame) {
804
834
  const send = toSender(dchOrSend);
805
835
  const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
@@ -827,22 +857,19 @@ export async function handleHttpFrame(dchOrSend, frame) {
827
857
  const session = e2ee.guardHttpRequest(String(env?.s || ""), env);
828
858
  const opened = session.open({ kind: "http", dir: "p2b", env, counter: env?.c ?? 0 });
829
859
  const req = decodeHttpRequestPlain(opened.data);
830
- const reply = await doHttp(req.method, req.path, req.headers, req.bodyB64, true);
860
+ // 客户端是否声明"响应用二进制明文框架"(省掉正文那一层 base64,见 e2ee-client.mjs 长注释)。
861
+ // 老客户端(native.html 的 WC-CORE / 企业版内置那份)不声明 → 照旧走 JSON,零影响。
862
+ // ★ 这个头**无条件**摘掉再转发:它是端到端内部协商头,不是业务头,不该出现在上游请求里
863
+ // (哪怕客户端给了个我们看不懂的值,也不能原样漏给 dsh web)。
864
+ const wantBinPlain = wantsBinaryResponsePlain(req.headers);
865
+ const upstreamHeaders = stripPlainWantHeader(req.headers);
866
+ const reply = await doHttp(req.method, req.path, upstreamHeaders, req.bodyB64, true);
831
867
  const bodyBuffer = Buffer.from(reply.body, "base64");
832
- const plain = encodeHttpResponsePlain({ status: reply.status, headers: reply.headers, bodyBuffer });
868
+ const plain = encodeHttpResponsePlain({ status: reply.status, headers: reply.headers, bodyBuffer, binary: wantBinPlain });
833
869
  // 响应压缩已发生在 doHttp(明文侧,gzip 在加密前 §4.7);信封用 http-resp kind
834
870
  const respEnv = session.seal({ kind: "http-resp", dir: "b2p", counter: env?.c ?? 0, data: plain, reqNonceB64: String(env?.n || "") });
835
- send({
836
- id,
837
- type: "http",
838
- status: 200, // 外层一律 200,真实状态在信封明文 st 里(§4.3)
839
- headers: {
840
- "content-type": ENVELOPE_CONTENT_TYPE,
841
- "x-dsh-e2ee": `v=2;s=${env.s};k=http-resp`
842
- },
843
- body: Buffer.from(JSON.stringify(respEnv)).toString("base64"),
844
- bodyBase64: true
845
- });
871
+ // ★ 0.6.15:**不再**把整个信封 JSON 再 base64 一次(第三层)。理由见 e2eeHttpResponseFrame。
872
+ send(e2eeHttpResponseFrame(id, env.s, respEnv));
846
873
  console.log(`[bridge] e2ee http ${req.method} ${req.path} → ${reply.status} (${Date.now() - t0}ms, 信封 ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
847
874
  return;
848
875
  } catch (e) {
@@ -1594,7 +1621,31 @@ async function runTunnel() {
1594
1621
  }, 1000);
1595
1622
  }
1596
1623
 
1624
+ /**
1625
+ * 给 stdout/stderr 的每一行加 ISO 时间戳。
1626
+ *
1627
+ * 【2026-09-25 事故】bridge 日志**没有任何时间戳**,于是排查
1628
+ * 「任务跑完没收到微信推送」时完全无法把日志和事件对上时间:
1629
+ * 不知道最后一条 `sendmessage 失败` 发生在用户的回话之前还是之后,
1630
+ * 最后只能靠"数行数 ÷ 每分钟行数"去估时间(估出来的结论还差点搞反方向)。
1631
+ * 加了时间戳之后,这类问题一眼可读。只影响本进程的输出(日志由 watcher 重定向到 .dsh-bridge.log)。
1632
+ */
1633
+ function stampConsoleLines() {
1634
+ for (const level of ["log", "info", "warn", "error"]) {
1635
+ const original = console[level]?.bind(console);
1636
+ if (typeof original !== "function") continue;
1637
+ console[level] = (...args) => {
1638
+ try {
1639
+ original(`[${new Date().toISOString()}]`, ...args);
1640
+ } catch {
1641
+ original(...args);
1642
+ }
1643
+ };
1644
+ }
1645
+ }
1646
+
1597
1647
  async function main() {
1648
+ stampConsoleLines();
1598
1649
  // 隧道模式是唯一模式(WebRTC/信令已废弃删除)
1599
1650
  if (!TUNNEL_URL) {
1600
1651
  console.error("[bridge] 缺少 DSH_BRIDGE_TUNNEL_URL:隧道模式是唯一模式(请设 relay-router 地址)");
@@ -951,6 +951,13 @@ class EventSubscriber extends EventEmitter {
951
951
  clearTimeout(entry.settleTimer);
952
952
  entry.settleTimer = null;
953
953
  }
954
+ // ★ 2026-09-25:一次 follow 失败不该把这条会话**永久**钉死。
955
+ // 旧行为是失败即 `#followBlocked.add()`,此后**本代**(直到 mux 重连)再也不订阅它 ——
956
+ // 而「这一轮又跑起来了」(status:true)恰恰是重新订阅的正当理由。
957
+ // 不清掉的话:一次 `session/agent-busy`/`session/not-found` 抖动 = 该会话这一整轮的
958
+ // turn/end 全部丢失 = 完成推送静默消失(与上面定时任务那个自我关停是同一类事故)。
959
+ // 重试天然有界:一次 running 边沿最多触发一次重新订阅,不会打转。
960
+ this.#followBlocked.delete(sessionId);
954
961
  this.#ensureFollow(sessionId);
955
962
  } else {
956
963
  this.#running.delete(sessionId);
@@ -1092,8 +1099,21 @@ class EventSubscriber extends EventEmitter {
1092
1099
  const timer = setTimeout(() => {
1093
1100
  this.#discoverTimer = null;
1094
1101
  if (this.#closed || this.#state !== "ready") return;
1095
- // 没有 follow 流就没有可对账的东西;但**每代**至少发现过一次(连接时就做过了)。
1096
- if (this.#follows.size > 0) void this.discoverRunningSessions().catch(() => {});
1102
+ // ★ 2026-09-25 事故修复:这里原本写成 `if (this.#follows.size > 0)`,注释是
1103
+ // 「没有 follow 流就没有可对账的东西(每代至少发现过一次)」。
1104
+ // 它漏掉了这条定时任务的**另一半职责:发现**。丢一次边沿事件(mux 重连、status:true
1105
+ // 早于我们订阅、帧被丢)就会让 `#follows` 长期为 0,而这条任务又因为「0 个流」把自己关掉
1106
+ // → **从此再也不发现任何会话** → 该会话的 turn/end 永远收不到 → 完成推送**静默消失**。
1107
+ //
1108
+ // 真机现场(业主本人,2026-09-25):任务跑完一条微信都没收到,bridge 日志里也
1109
+ // **没有任何报错**(因为确实什么都没发生)。活体探针(以微信通道同款方式订阅本机)显示:
1110
+ // · 新起的订阅器 **163ms** 就发现并 follow 了正在跑的会话 —— 机制本身是好的;
1111
+ // · 而当时在跑的 bridge 进程到 3080 只有 **2 条** WS(mux + control)、**没有 follow 流**,
1112
+ // 可它明明有一个正在跑长任务的会话 —— 正是这里的自我关停把发现能力锁死了。
1113
+ //
1114
+ // 代价:一次 `session/list`(本机实测 291 个会话约 200ms)。每 120s 一次完全承受得起,
1115
+ // 换来的是「丢了边沿也能在两分钟内自愈」。
1116
+ void this.discoverRunningSessions().catch(() => {});
1097
1117
  this.#scheduleDiscovery();
1098
1118
  }, interval);
1099
1119
  timer.unref?.();
@@ -469,9 +469,12 @@ export function headerValueOf(headers, name) {
469
469
  * @returns {{method:string, path:string, headers:object, bodyB64:string, hasBody:boolean}}
470
470
  */
471
471
  export function decodeHttpRequestPlain(buf) {
472
+ // ⚠️ 不能写 `String(buf)`:手机端 shim 交出来的是 Uint8Array,String() 会得到 "123,34,...",
473
+ // 结果永远是"不是 JSON"(0.6.15 对拍用例抓到的真问题)。
474
+ const raw = Buffer.isBuffer(buf) ? buf : (typeof buf === "string" ? Buffer.from(buf, "utf8") : Buffer.from(buf ?? []));
472
475
  let pt;
473
476
  try {
474
- pt = JSON.parse(Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf));
477
+ pt = JSON.parse(raw.toString("utf8"));
475
478
  } catch {
476
479
  throw new E2eeError("bad_plain", "e2ee: http 请求明文不是 JSON");
477
480
  }
@@ -479,8 +482,8 @@ export function decodeHttpRequestPlain(buf) {
479
482
  const method = typeof pt.m === "string" && pt.m ? pt.m.toUpperCase() : "GET";
480
483
  const path = typeof pt.p === "string" && pt.p ? pt.p : "/";
481
484
  const headers = pt.h && typeof pt.h === "object" ? pt.h : {};
482
- const b = typeof pt.b === "string" ? pt.b : "";
483
- return { method, path, headers, bodyB64: b, hasBody: b !== "" };
485
+ const bodyB64 = typeof pt.b === "string" ? pt.b : "";
486
+ return { method, path, headers, bodyB64, hasBody: bodyB64 !== "" };
484
487
  }
485
488
 
486
489
  /** 编码请求信封明文(测试端/手机侧对称实现用;b 一律为 base64 正文)。 */
@@ -490,13 +493,60 @@ export function encodeHttpRequestPlain({ method = "GET", path = "/", headers = {
490
493
  }
491
494
 
492
495
  /**
493
- * 响应信封明文(§4.3):{ "st":status, "h":{头, 去 content-length/encoding}, "enc":"gzip|", "b":<b64> }
494
- * @returns {{status:number, headers:object, enc:string, bodyBuffer:Buffer}}
496
+ * 响应信封明文(§4.3)。
497
+ *
498
+ * 两种形态(**自动识别**,由首字节判别,解码方无需知道对方是哪个版本):
499
+ *
500
+ * ① 旧/兼容 JSON:`{ "st", "h", "enc", "b":<base64 正文> }` —— 首字节 `{`(0x7B);
501
+ * ② 新二进制框架(0.6.15):`[0x02][4B 大端头长度][头 JSON][正文原始字节]` —— 首字节 0x02。
502
+ *
503
+ * ## 为什么要有 ②(这是"打开慢几分钟"的直接原因之一)
504
+ *
505
+ * 同一条响应在链路上被 base64 编码了**三层**(每层 +33%,叠起来 2.37 倍),其中第一层就是这里:
506
+ * gzip 后的正文塞进 JSON 只能 base64。以实测那条首屏聚合包(gzip 后 5.35 MiB)为例:
507
+ *
508
+ * gzip 正文 5.35 MiB
509
+ * ① base64 进 JSON 7.14 MiB ← 本函数(旧形态)
510
+ * ② AES-GCM 密文 base64url 9.52 MiB
511
+ * ③ 信封 JSON base64 12.69 MiB ← 隧道帧(bridge→中继,见 dsh-bridge.mjs)
512
+ *
513
+ * ②用"头 JSON + 正文原样跟在其后"的框架,**第一层 33% 直接消失**:明文 = 5.35 MiB + 几百字节头。
514
+ * 手机端下载量随之从 1.78× 降到 1.33×(9.52 → 7.14 MiB),中继那侧的计量也同步下降。
515
+ *
516
+ * ## 为什么是"客户端声明才用"
517
+ *
518
+ * 明文框架是**端到端**格式:老的手机客户端(`clients/dsh-web/native.html` 抽出的 WC-CORE、
519
+ * 企业版内置的那份)只认旧 JSON。所以改成**由客户端在请求里声明**(`x-dsh-e2ee-want: bin`),
520
+ * 桥端只对声明过的请求用新框架 —— 老客户端一个字都不用改,也不会收到看不懂的字节。
521
+ *
522
+ * @returns {{status:number, headers:object, enc:string, bodyBuffer:Buffer, bodyB64:string}}
523
+ * `bodyBuffer` 是原始字节(新旧两种形态都填);`bodyB64` 仅旧 JSON 形态填
524
+ * (兼容既有调用方/对拍用例;二进制形态**不**额外做一次 base64,那正是要省掉的开销)。
495
525
  */
496
526
  export function decodeHttpResponsePlain(buf) {
527
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf ?? []);
528
+ if (b.length >= 5 && b[0] === PLAIN_BIN_MAGIC) {
529
+ const len = b.readUInt32BE(1);
530
+ if (len > 0 && 5 + len <= b.length) {
531
+ let head;
532
+ try {
533
+ head = JSON.parse(b.subarray(5, 5 + len).toString("utf8"));
534
+ } catch {
535
+ throw new E2eeError("bad_plain", "e2ee: http 响应明文头不是 JSON");
536
+ }
537
+ if (!head || typeof head !== "object") throw new E2eeError("bad_plain", "e2ee: http 响应明文缺失");
538
+ return {
539
+ status: Number(head.st) || 502,
540
+ headers: head.h && typeof head.h === "object" ? head.h : {},
541
+ enc: typeof head.enc === "string" ? head.enc : "",
542
+ bodyBuffer: b.subarray(5 + len), // 视图,零拷贝
543
+ bodyB64: ""
544
+ };
545
+ }
546
+ }
497
547
  let pt;
498
548
  try {
499
- pt = JSON.parse(Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf));
549
+ pt = JSON.parse(b.toString("utf8"));
500
550
  } catch {
501
551
  throw new E2eeError("bad_plain", "e2ee: http 响应明文不是 JSON");
502
552
  }
@@ -512,11 +562,36 @@ export function decodeHttpResponsePlain(buf) {
512
562
  throw new E2eeError("bad_plain", "e2ee: http 响应 body base64 非法");
513
563
  }
514
564
  }
515
- return { status, headers, enc, bodyBuffer };
565
+ return { status, headers, enc, bodyBuffer, bodyB64: typeof pt.b === "string" ? pt.b : "" };
566
+ }
567
+
568
+ /** 二进制明文框架的魔数(JSON 明文以 `{`=0x7B 开头,0x02 永不冲突)。 */
569
+ export const PLAIN_BIN_MAGIC = 0x02;
570
+ /** 客户端在**加密请求明文**里声明"我的响应明文请用二进制框架"的头(见上方长注释)。 */
571
+ export const PLAIN_WANT_BIN_HEADER = "x-dsh-e2ee-want";
572
+ export const PLAIN_WANT_BIN_VALUE = "bin";
573
+
574
+ /** 请求头里是否声明了"响应用二进制明文框架"(大小写不敏感)。 */
575
+ export function wantsBinaryResponsePlain(headers) {
576
+ const v = headerValueOf(headers, PLAIN_WANT_BIN_HEADER);
577
+ return String(v).toLowerCase().split(/[\s,]+/).includes(PLAIN_WANT_BIN_VALUE);
516
578
  }
517
579
 
518
- /** 编码响应信封明文(status/headers/原始正文;content-encoding 提取到 enc)。 */
519
- export function encodeHttpResponsePlain({ status = 200, headers = {}, bodyBuffer = Buffer.alloc(0) }) {
580
+ /** 从请求头里摘掉那个"内部协商"头(它绝不能转发给上游 dsh web)。返回新的头对象(不改原对象)。 */
581
+ export function stripPlainWantHeader(headers) {
582
+ const out = {};
583
+ for (const [k, v] of Object.entries(headers || {})) {
584
+ if (String(k).toLowerCase() === PLAIN_WANT_BIN_HEADER) continue;
585
+ out[k] = v;
586
+ }
587
+ return out;
588
+ }
589
+
590
+ /**
591
+ * 编码响应信封明文(status/headers/原始正文;content-encoding 提取到 enc)。
592
+ * @param {boolean} [o.binary] true = 用二进制框架(省掉正文那一层 base64;仅客户端声明时使用)
593
+ */
594
+ export function encodeHttpResponsePlain({ status = 200, headers = {}, bodyBuffer = Buffer.alloc(0), binary = false }) {
520
595
  const h = {};
521
596
  let enc = "";
522
597
  for (const [k, v] of Object.entries(headers || {})) {
@@ -527,11 +602,20 @@ export function encodeHttpResponsePlain({ status = 200, headers = {}, bodyBuffer
527
602
  }
528
603
  h[k] = Array.isArray(v) ? v.join(", ") : String(v);
529
604
  }
605
+ const head = { st: Number(status) || 200, h, enc };
606
+ const body = Buffer.isBuffer(bodyBuffer) ? bodyBuffer : Buffer.alloc(0);
607
+ if (binary) {
608
+ const headBuf = Buffer.from(JSON.stringify(head), "utf8");
609
+ const out = Buffer.allocUnsafe(5 + headBuf.length + body.length);
610
+ out[0] = PLAIN_BIN_MAGIC;
611
+ out.writeUInt32BE(headBuf.length, 1);
612
+ headBuf.copy(out, 5);
613
+ body.copy(out, 5 + headBuf.length);
614
+ return out;
615
+ }
530
616
  return Buffer.from(JSON.stringify({
531
- st: Number(status) || 200,
532
- h,
533
- enc,
534
- b: Buffer.isBuffer(bodyBuffer) && bodyBuffer.length ? bodyBuffer.toString("base64") : ""
617
+ ...head,
618
+ b: body.length ? body.toString("base64") : ""
535
619
  }), "utf8");
536
620
  }
537
621
 
@@ -211,12 +211,39 @@
211
211
  };
212
212
 
213
213
  /* ---- http 明文载荷编解码(与 node encode/decodeHttp*Plain 同构) ---- */
214
+ /* 0.6.15:响应明文支持二进制框架(首字节 0x02),省掉"gzip 正文 → base64"那一层(+33%)。
215
+ 由**本客户端主动声明**(请求明文里的 x-dsh-e2ee-want: bin),桥端只对声明过的请求用它 ——
216
+ 老客户端/企业版内置的那份不认识这个头,也就永远收不到看不懂的字节。 */
217
+ var SE_PLAIN_BIN_MAGIC = 2;
218
+ var SE_WANT_BIN_HEADER = "x-dsh-e2ee-want";
214
219
  function seEncodeHttpReqPlain(opt) {
215
220
  var b = opt.bodyBytes && opt.bodyBytes.length ? seBytesToB64(opt.bodyBytes) : "";
216
- return seUtf8(JSON.stringify({ m: String(opt.method || "GET").toUpperCase(), p: opt.path, h: opt.headers || {}, b: b }));
221
+ var src = opt.headers || {};
222
+ var h = {};
223
+ for (var k in src) { if (Object.prototype.hasOwnProperty.call(src, k)) h[k] = src[k]; }
224
+ h[SE_WANT_BIN_HEADER] = "bin"; // 只加在信封明文里(外层 HTTP 头不带它)
225
+ return seUtf8(JSON.stringify({ m: String(opt.method || "GET").toUpperCase(), p: opt.path, h: h, b: b }));
217
226
  }
218
227
  function seDecodeHttpRespPlain(buf) {
219
- var text = buf instanceof Uint8Array ? seUtf8Decode(buf) : String(buf);
228
+ var u8 = buf instanceof Uint8Array ? buf : new Uint8Array(0);
229
+ // ① 二进制框架:[0x02][4B 大端头长度][头 JSON][正文原始字节]
230
+ if (u8.length >= 5 && u8[0] === SE_PLAIN_BIN_MAGIC) {
231
+ var headLen = ((u8[1] << 24) | (u8[2] << 16) | (u8[3] << 8) | u8[4]) >>> 0;
232
+ if (headLen > 0 && 5 + headLen <= u8.length) {
233
+ var head = null;
234
+ try { head = JSON.parse(seUtf8Decode(u8.subarray(5, 5 + headLen))); } catch (e) { throw seErr("bad_plain", "e2ee: http 响应明文头不是 JSON"); }
235
+ if (!head || typeof head !== "object") throw seErr("bad_plain", "e2ee: http 响应明文缺失");
236
+ return {
237
+ status: Number(head.st) || 502,
238
+ headers: head.h && typeof head.h === "object" ? head.h : {},
239
+ enc: typeof head.enc === "string" ? head.enc : "",
240
+ bodyBytes: u8.subarray(5 + headLen),
241
+ bodyB64: ""
242
+ };
243
+ }
244
+ }
245
+ // ② 兼容 JSON:{st,h,enc,b:<base64>}
246
+ var text = buf instanceof Uint8Array ? seUtf8Decode(buf) : String(buf || "");
220
247
  var pt = null;
221
248
  try { pt = JSON.parse(text); } catch (e) { throw seErr("bad_plain", "e2ee: http 响应明文不是 JSON"); }
222
249
  if (!pt || typeof pt !== "object") throw seErr("bad_plain", "e2ee: http 响应明文缺失");
@@ -224,6 +251,7 @@
224
251
  status: Number(pt.st) || 502,
225
252
  headers: pt.h && typeof pt.h === "object" ? pt.h : {},
226
253
  enc: typeof pt.enc === "string" ? pt.enc : "",
254
+ bodyBytes: typeof pt.b === "string" && pt.b ? seB64ToBytes(pt.b) : new Uint8Array(0),
227
255
  bodyB64: typeof pt.b === "string" ? pt.b : ""
228
256
  };
229
257
  }
@@ -455,7 +483,7 @@
455
483
  seNotifyFail("响应明文非法");
456
484
  return seErrorResponse("bad_plain", "⚠ 无法解密:响应明文非法");
457
485
  }
458
- var bodyU8 = rp.bodyB64 ? seB64ToBytes(rp.bodyB64) : new Uint8Array(0);
486
+ var bodyU8 = rp.bodyBytes instanceof Uint8Array ? rp.bodyBytes : (rp.bodyB64 ? seB64ToBytes(rp.bodyB64) : new Uint8Array(0));
459
487
  if (rp.enc === "gzip" && bodyU8.length) {
460
488
  var un = await seGunzip(bodyU8);
461
489
  if (!un) return seErrorResponse("gunzip", "⚠ 无法解压:响应 gzip 解压失败");
@@ -36,6 +36,11 @@
36
36
  * left+top 陈旧锚点,默认回到右下;再兜底一次“卡左上角”检测(合成鼠标事件走它自己
37
37
  * 的拖拽收尾,state/localStorage 由它自洽)。
38
38
  * 仅对 ≤820 且锚点命中 left+top 干预;透明区点击/滚动仍穿透,与桌面语义一致。
39
+ * 6)【0.6.15】**手机图片上传兼容**:官方只把 image/png|jpeg|webp|gif 当图片(本地编码随 prompt
40
+ * 发给模型),其余一律当普通文件后台上传 —— 模型于是"看不到图"。而手机最容易产出非白名单
41
+ * 类型(iPhone「高效」= image/heic;部分 Android 图库给空 type)。适配层在**捕获阶段**拦下
42
+ * 官方隐藏 input[type=file] 的 change,把这类文件**无损改 MIME**(扩展名可判时)或
43
+ * canvas 转 JPEG 后重新派发;任何失败都原样重发,绝不丢文件。详见脚本内长注释。
39
44
  *
40
45
  * 宿主特征门(防误注入其它 html):
41
46
  * - 注入前:content-type 必须 text/html 且含 </head> 且原文含官方标记
@@ -1699,6 +1704,172 @@ const SCRIPT = `(() => {
1699
1704
  try { if (typeof requestAnimationFrame === "function") { requestAnimationFrame(run); return; } } catch (eRaf) { /* 退化 */ }
1700
1705
  setTimeout(run, 0);
1701
1706
  };
1707
+ /* ===== 手机图片上传兼容(0.6.15)=====
1708
+ 业主反馈:「手机端无法识别图片」——现象是选了照片发出去,模型看不到图 / 当成普通文件。
1709
+
1710
+ 根因(官方 dsh web 客户端逻辑,dsh-client-ui-conversation 的 createDrafts):
1711
+ · 只有 file.type ∈ {image/png, image/jpeg, image/webp, image/gif} 才走**图片**通道
1712
+ (本地编码后随 prompt 发出,模型真的"能看");
1713
+ · 其余一律当**普通文件**:走 Web Worker 后台上传,模型只拿到一个文件引用。
1714
+ 而手机恰恰最容易产出"非白名单"的图片:
1715
+ · iPhone 默认「高效」格式拍出来是 image/heic(不在白名单);
1716
+ · 部分 Android 图库 / 文件提供器给出的是**空 type**("");
1717
+ · 从"文件"入口选的还可能是 heif / bmp / tiff / avif。
1718
+
1719
+ 做法(全在浏览器里完成,不动官方代码、不动 host):
1720
+ 1) 在**捕获阶段**监听 change(比 React 根容器的监听更早):官方隐藏 input[type=file]
1721
+ 被选中后,若里面有"是图片但不在白名单"的文件,就先拦下这次事件,
1722
+ 用 canvas 把它转成 JPEG,再**替换 input.files 并重新派发一次 change** ——
1723
+ 这样官方按图片通道收下,模型就能"看见"了;
1724
+ 2) 任何一步失败(浏览器解不了 HEIC / canvas 不可用 / DataTransfer 不可用…)
1725
+ 一律**把原始文件原样重新派发**,绝不比现状更差(宁可当普通文件,也不能丢文件);
1726
+ 3) 官方认得的类型一个字节都不碰(零行为)。 */
1727
+ var MA_IMG_OK = { "image/png": 1, "image/jpeg": 1, "image/webp": 1, "image/gif": 1 };
1728
+ var MA_IMG_EXT_RE = /\\.(png|jpe?g|webp|gif|heic|heif|bmp|tiff?|avif)$/i;
1729
+ var MA_MAX_DIM = 4096; // 与官方 imageLimits.maxImageDimension 对齐,避免超限被拒
1730
+ var MA_CONVERT_TIMEOUT = 8000; // 解码卡住就放弃(原样放行)
1731
+ var MA_REENTRY = "__dshMaImageDone"; // 标记"这次 change 是我们自己派发的"
1732
+
1733
+ function maIsImageFile(f) {
1734
+ var t = String((f && f.type) || "").toLowerCase();
1735
+ if (t) return t.indexOf("image/") === 0;
1736
+ return MA_IMG_EXT_RE.test(String((f && f.name) || ""));
1737
+ }
1738
+ /** 需要转码吗:是图片、但不在官方白名单里。 */
1739
+ function maNeedsConvert(f) {
1740
+ var t = String((f && f.type) || "").toLowerCase();
1741
+ if (MA_IMG_OK[t]) return false;
1742
+ return maIsImageFile(f);
1743
+ }
1744
+ /** 扩展名 → 白名单里的 MIME(拿不到返回空)。 */
1745
+ function maMimeFromName(name) {
1746
+ var m = /\.([A-Za-z0-9]+)$/.exec(String(name || ""));
1747
+ if (!m) return "";
1748
+ var e = m[1].toLowerCase();
1749
+ if (e === "png") return "image/png";
1750
+ if (e === "jpg" || e === "jpeg") return "image/jpeg";
1751
+ if (e === "webp") return "image/webp";
1752
+ if (e === "gif") return "image/gif";
1753
+ return "";
1754
+ }
1755
+ /**
1756
+ * 只改 type、不重编码(**无损**)。
1757
+ * 为什么需要:Android 图库/文件提供器很常见地给出 type:"",而文件本身其实是标准 PNG/JPEG ——
1758
+ * 这时把字节丢进 canvas 重编码纯属浪费(还可能丢透明通道),改个 MIME 就够了。
1759
+ */
1760
+ function maRelabel(file, mime) {
1761
+ try {
1762
+ return new File([file], String(file.name || "image"), { type: mime, lastModified: file.lastModified || Date.now() });
1763
+ } catch (e) {
1764
+ try { var b = file.slice(0, file.size, mime); b.name = file.name; return b; } catch (e2) { return null; }
1765
+ }
1766
+ }
1767
+ /** 单个文件的归一化:优先无损改 MIME,实在不行才 canvas 转 JPEG。 */
1768
+ async function maNormalizeImage(file) {
1769
+ var mime = maMimeFromName(file && file.name);
1770
+ if (mime && String(file.type || "") !== mime) {
1771
+ var relabeled = maRelabel(file, mime);
1772
+ if (relabeled) return relabeled;
1773
+ }
1774
+ return await maToJpeg(file);
1775
+ }
1776
+ function maToJpeg(file) {
1777
+ return new Promise(function (resolve) {
1778
+ var url = "";
1779
+ try { url = URL.createObjectURL(file); } catch (e0) { resolve(null); return; }
1780
+ var settled = false;
1781
+ var finish = function (out) {
1782
+ if (settled) return;
1783
+ settled = true;
1784
+ try { URL.revokeObjectURL(url); } catch (e1) { /* 忽略 */ }
1785
+ resolve(out);
1786
+ };
1787
+ var timer = setTimeout(function () { finish(null); }, MA_CONVERT_TIMEOUT);
1788
+ var img;
1789
+ try { img = new Image(); } catch (e2) { clearTimeout(timer); finish(null); return; }
1790
+ img.onload = function () {
1791
+ clearTimeout(timer);
1792
+ try {
1793
+ var w = img.naturalWidth || img.width || 0;
1794
+ var h = img.naturalHeight || img.height || 0;
1795
+ if (!w || !h) return finish(null);
1796
+ var scale = Math.min(1, MA_MAX_DIM / Math.max(w, h));
1797
+ var cw = Math.max(1, Math.round(w * scale));
1798
+ var ch = Math.max(1, Math.round(h * scale));
1799
+ var cv = document.createElement("canvas");
1800
+ cv.width = cw; cv.height = ch;
1801
+ var ctx = cv.getContext("2d");
1802
+ if (!ctx) return finish(null);
1803
+ ctx.drawImage(img, 0, 0, cw, ch);
1804
+ cv.toBlob(function (blob) {
1805
+ if (!blob) return finish(null);
1806
+ var base = String(file.name || "photo").replace(/\\.[^./\\\\]+$/, "") || "photo";
1807
+ try {
1808
+ finish(new File([blob], base + ".jpg", { type: "image/jpeg", lastModified: Date.now() }));
1809
+ } catch (e3) {
1810
+ // 极老的浏览器没有 File 构造器 → 用 type 改写过的 Blob 兜底(官方只看 type/name)
1811
+ try { blob.name = base + ".jpg"; finish(blob); } catch (e4) { finish(null); }
1812
+ }
1813
+ }, "image/jpeg", 0.92);
1814
+ } catch (e5) { finish(null); }
1815
+ };
1816
+ img.onerror = function () { clearTimeout(timer); finish(null); };
1817
+ try { img.src = url; } catch (e6) { clearTimeout(timer); finish(null); }
1818
+ });
1819
+ }
1820
+ /** 把 files 塞回 input(DataTransfer 缺失/只读 → 返回 false,调用方走"原样重发")。 */
1821
+ function maSetInputFiles(input, files) {
1822
+ try {
1823
+ var dt = new DataTransfer();
1824
+ for (var i = 0; i < files.length; i++) dt.items.add(files[i]);
1825
+ if (!dt.files || dt.files.length !== files.length) return false;
1826
+ input.files = dt.files;
1827
+ return true;
1828
+ } catch (e) { return false; }
1829
+ }
1830
+ /** 重新派发一次 change(带重入标记,避免我们自己的监听再拦一次)。 */
1831
+ function maRedispatch(input) {
1832
+ try {
1833
+ input[MA_REENTRY] = true;
1834
+ input.dispatchEvent(new Event("change", { bubbles: true }));
1835
+ } catch (e) { /* 忽略 */ } finally {
1836
+ setTimeout(function () { try { input[MA_REENTRY] = false; } catch (e2) { /* 忽略 */ } }, 0);
1837
+ }
1838
+ }
1839
+ async function maOnFilePicked(input) {
1840
+ var list = input && input.files ? Array.prototype.slice.call(input.files) : [];
1841
+ if (!list.length) return;
1842
+ var need = list.filter(maNeedsConvert);
1843
+ if (!need.length) return; // 官方认得的类型 → 零行为
1844
+ var out = [];
1845
+ for (var i = 0; i < list.length; i++) {
1846
+ if (!maNeedsConvert(list[i])) { out.push(list[i]); continue; }
1847
+ var conv = await maNormalizeImage(list[i]);
1848
+ if (conv) out.push(conv);
1849
+ else out.push(list[i]); // 转不了就按原文件走(与现状一致,绝不丢)
1850
+ }
1851
+ if (maSetInputFiles(input, out)) maRedispatch(input);
1852
+ else maRedispatch(input); // 塞不回去 → 原样重发(文件不丢)
1853
+ }
1854
+ function maInstallImageCompat() {
1855
+ try {
1856
+ document.addEventListener("change", function (e) {
1857
+ var t = e.target;
1858
+ if (!t || String(t.tagName || "").toUpperCase() !== "INPUT") return;
1859
+ if (String(t.type || "").toLowerCase() !== "file") return;
1860
+ if (t[MA_REENTRY]) return; // 我们自己派发的那次 → 放行
1861
+ var files = t.files ? Array.prototype.slice.call(t.files) : [];
1862
+ if (!files.length) return;
1863
+ var any = false;
1864
+ for (var i = 0; i < files.length; i++) { if (maNeedsConvert(files[i])) { any = true; break; } }
1865
+ if (!any) return; // 无需转码 → 完全不拦(官方照旧处理)
1866
+ // 拦下这一次:官方若先看到原文件,会立刻按"普通文件"起后台上传(那就白改了)
1867
+ try { e.stopPropagation(); } catch (eS) { /* 忽略 */ }
1868
+ maOnFilePicked(t).catch(function () { maRedispatch(t); });
1869
+ }, true); // ★ 捕获阶段:必须早于 React 根容器的 change 监听
1870
+ } catch (e) { /* 适配层绝不把页面拖挂 */ }
1871
+ }
1872
+
1702
1873
  /* 挂上"叫醒"的两条路。做成可重入(先清旧的再建新的),这样 bfcache 恢复后能重新武装。 */
1703
1874
  const maArm = () => {
1704
1875
  maTorn = false;
@@ -1718,6 +1889,7 @@ const SCRIPT = `(() => {
1718
1889
  try { maInterval = setInterval(pump, 1000); } catch (eInt) { maInterval = 0; }
1719
1890
  };
1720
1891
  maArm();
1892
+ maInstallImageCompat(); // 手机图片上传兼容(见上方长注释):装了就一直有效,与 pump 无关
1721
1893
  /* 清理:页面卸载时断开观察者与兜底表(bfcache/长驻页面都不留悬挂回调)。
1722
1894
  pagehide 覆盖 bfcache 场景,unload 兜底更老的浏览器。 */
1723
1895
  const maTeardown = () => {
@@ -1996,3 +1996,92 @@ test("一元:本地校验(空 text / 缺 sessionId / 坏 mode…)直接类
1996
1996
  await dispose();
1997
1997
  }
1998
1998
  });
1999
+
2000
+ // ═══════════════════ 2026-09-25 事故:完成推送静默消失(业主实测)═══════════════════
2001
+ //
2002
+ // 现场:任务跑完了,微信**一条推送都没有**,bridge 日志里也**没有任何报错**。
2003
+ // 活体探针(以微信通道同款方式订阅本机 dsh web)显示:
2004
+ // · 新起的订阅器 163ms 就发现并 follow 了正在跑的会话 —— 机制本身是好的;
2005
+ // · 而当时在跑的 bridge 进程到 3080 只有 2 条 WS(mux + control)、**没有 follow 流**,
2006
+ // 可它明明有一个正在跑长任务的会话。
2007
+ // 根因就在下面这条:周期性发现被"当前一条 follow 都没有"这个条件**关掉了自己**——
2008
+ // 丢一次 status:true 边沿就永久失去发现能力,而 turn/end 只在 follow 流上才有。
2009
+
2010
+ test("★ 事故锁:一条 follow 都没有时,周期性发现**仍然要跑**(丢一次边沿不能永久失聪)", async () => {
2011
+ const api = await startFakeDsh({ listItems: [] }); // 连上时没有任何会话在跑
2012
+ const sub = createEventSubscriber({
2013
+ upstream: api.base,
2014
+ cookie: () => api.cookie,
2015
+ discoverIntervalMs: 40,
2016
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
2017
+ });
2018
+ try {
2019
+ await start(sub);
2020
+ await api.waitForListCalls(1);
2021
+ assert.deepEqual(sub.sessions(), [], "前置条件:此刻不 follow 任何会话");
2022
+
2023
+ // 之后某个会话开始跑,但**故意不发任何 status 边沿事件**(模拟边沿丢失/早于我们订阅)
2024
+ const opened = [];
2025
+ sub.on("follow-opened", (info) => opened.push(info));
2026
+ api.setListItems([{ sessionId: "session-late", running: true }]);
2027
+
2028
+ // ⚠️ 用**有界**等待(`once` 会永久挂住)——回归时必须是"红",不能是"卡死"。
2029
+ try {
2030
+ await waitFor(() => opened.length > 0, { label: "周期性发现把在跑的会话 follow 起来" });
2031
+ } catch (e) {
2032
+ assert.fail(`★ 一条 follow 都没有时,周期性发现也必须继续跑(否则丢一次边沿就永久失聪):${e.message}`);
2033
+ }
2034
+ assert.equal(opened[0].sessionId, "session-late");
2035
+ assert.deepEqual(sub.sessions(), ["session-late"]);
2036
+ } finally {
2037
+ sub.close();
2038
+ await api.close();
2039
+ }
2040
+ });
2041
+
2042
+ test("★ 一次 follow 失败不该把这条会话**永久**钉死:下一轮 running 边沿必须重试", async () => {
2043
+ const faults = [];
2044
+ let failNext = true;
2045
+ const api = await startFakeDsh({
2046
+ onOpen(server, conn, stream) {
2047
+ if (stream.endpoint !== "session/follow") return;
2048
+ // 第一轮一律 agent-busy(真机上子代理/繁忙会话就是这个码);之后放行
2049
+ if (failNext) {
2050
+ server.fail(conn, stream.streamId, {
2051
+ code: "session/agent-busy",
2052
+ message: "session is busy",
2053
+ details: {},
2054
+ });
2055
+ }
2056
+ },
2057
+ });
2058
+ const sub = createEventSubscriber({
2059
+ upstream: api.base,
2060
+ cookie: () => api.cookie,
2061
+ followSettleMs: 20,
2062
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
2063
+ });
2064
+ try {
2065
+ sub.on("fault", (f) => faults.push(f));
2066
+ await start(sub);
2067
+
2068
+ api.pushEmit("api-session/status", ["session-busy-then-ok", true]);
2069
+ await waitFor(() => faults.some((f) => f.code === "session/agent-busy"), { label: "第一次 follow 被拒" });
2070
+ assert.deepEqual(sub.sessions(), [], "第一次失败:没有 follow 流");
2071
+
2072
+ // 新一轮 running(用户又跑了一轮)→ 必须重新订阅,而不是被 #followBlocked 永久挡住
2073
+ const opened = [];
2074
+ sub.on("follow-opened", (info) => opened.push(info));
2075
+ failNext = false;
2076
+ api.pushEmit("api-session/status", ["session-busy-then-ok", true]);
2077
+ try {
2078
+ await waitFor(() => opened.length > 0, { label: "新一轮 running 触发重新订阅" });
2079
+ } catch (e) {
2080
+ assert.fail(`★ 一次 follow 失败不得把会话永久钉死(这一整轮的 turn/end 都会丢):${e.message}`);
2081
+ }
2082
+ assert.equal(opened[0].sessionId, "session-busy-then-ok");
2083
+ } finally {
2084
+ sub.close();
2085
+ await api.close();
2086
+ }
2087
+ });