@mrrisega/dsh-remote 0.6.14 → 0.6.16

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 (35) 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 +190 -108
  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 +45 -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 +190 -108
  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
  35. package/clients/dsh-remote/test/boot-tip.test.mjs +0 -158
@@ -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> 且原文含官方标记
@@ -95,33 +100,6 @@ const RUNTIME_HOST_RE =
95
100
  /(data-sidebar-collapsed|data-details-collapsed|data-rightbar-collapsed|data-shell-overlay)|(pI_x6G_frame|hHd-Xa_root|hHd-Xa_toggle)/;
96
101
 
97
102
  const STYLE = `
98
- /* ===== 首屏加载提示(0.6.14)=====
99
- 为什么要有它:镜像页首屏要拉 dsh web 的全部客户端插件(实测 ~14 MB,一个不可拆分的
100
- /plugins/?? 聚合请求),免费档限速下几十秒起步。此前页面上**只有官方那个转圈**,
101
- 用户无法区分"在加载"和"卡死了",只能反复刷新 —— 反而更慢。
102
- 这里给一句人话提示 + 分阶段的补充说明,加载完成(或超时兜底)即自动移除。
103
- ⚠️ 刻意**不**放进 @media (max-width:820px):Windows/桌面浏览器打开镜像页同样要等。
104
- ⚠️ pointer-events:none + 半透明:万一移除逻辑失效,也绝不挡住任何点击。 */
105
- .dsh-ma-boot {
106
- position: fixed; inset: 0; z-index: 2147483000;
107
- display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
108
- background: color-mix(in srgb, var(--dsw-alias-bg-base, #0f1115) 88%, transparent);
109
- backdrop-filter: blur(2px);
110
- pointer-events: none;
111
- font-family: inherit; text-align: center; padding: 24px;
112
- transition: opacity .3s ease;
113
- }
114
- .dsh-ma-boot[data-fading="1"] { opacity: 0; }
115
- .dsh-ma-boot-spin {
116
- width: 34px; height: 34px; border-radius: 50%;
117
- border: 3px solid color-mix(in srgb, currentColor 22%, transparent);
118
- border-top-color: currentColor;
119
- animation: dsh-ma-boot-spin 1s linear infinite;
120
- }
121
- @keyframes dsh-ma-boot-spin { to { transform: rotate(360deg); } }
122
- .dsh-ma-boot-title { font-size: 15px; font-weight: 600; }
123
- .dsh-ma-boot-sub { font-size: 13px; line-height: 1.6; opacity: .75; max-width: 22em; }
124
- .dsh-ma-boot-elapsed { font-size: 12px; opacity: .55; font-variant-numeric: tabular-nums; }
125
103
 
126
104
  @media (max-width: 820px) {
127
105
  html, body { max-width: 100%; overflow-x: hidden; }
@@ -453,88 +431,25 @@ html[data-dsh-ma-merged-e2ee] #dsh-e2ee-badge { display: none !important; }
453
431
  const SCRIPT = `(() => {
454
432
  "use strict";
455
433
 
456
- /* ===== 首屏加载提示(0.6.14)=====
457
- 用户反馈:「普通用户加载进入镜像页面时等待时间较长,目前等待过程中一直在转圈」
458
- —— 官方那个转圈不告诉用户任何事。这里在**页面解析的第一时间**就挂上一条人话提示,
459
- 并在官方 UI 就绪(或兜底超时)后自动移除。
434
+ /* ===== 【0.6.16 已删除】首屏加载提示层(0.6.14 引入,0.6.15 仍在)=====
435
+ 为什么删掉(业主实测,2026-09-25):「界面都已经加载完了,十几秒就把插件的接口加载好了,
436
+ 但这个遮罩一直不消失」—— 截图里官方 UI 就在下面透出来,秒表走到 187 秒。
437
+
438
+ 根因是**设计错误**,不是某个选择器写错:
439
+ · 那是个 position:fixed / inset:0 / z-index:2147483000 的**全屏层**(88% 不透明背景 +
440
+ backdrop-blur),视觉上盖住整个应用;
441
+ · 它唯一的退出条件是 maBootLooksReady() 这组**DOM 启发式**(官方 frame 的
442
+ data-*-collapsed / 类名前缀,或 textarea/[contenteditable]/[role=textbox])。
443
+ 官方改版、会话尚未挂载输入框、或前缀变化时两组信号全不命中 → 层就永远留着。
444
+ · 当时我给自己写的安慰是「pointer-events:none,万一移除失效也绝不挡住点击」——
445
+ 可对用户来说,**盖住视线和进不去没有区别**:刷新也一样,每次都要等 4 分钟兜底。
446
+ 结论(业主要求):**直接删掉这个功能**。首屏反馈交给官方自己的转圈;
447
+ 我们不再在用户页面上放任何会覆盖整屏的东西 —— 这类层一旦判断失误就是"永远进不去",
448
+ 代价远大于它带来的提示价值(何况限速放开后首屏本来就快了)。
449
+
450
+ 护栏见 test/mobile-adapter-guards.test.mjs:适配层不得再引入全屏覆盖层。 */
451
+ void 0;
460
452
 
461
- ⚠️ 必须放在所有主机判定(HOSTISH / NARROW)之前:首屏慢的正是这些判定等不到的时候。
462
- ⚠️ 只做提示,不做任何拦截:pointer-events:none,也没有遮罩语义。 */
463
- var maBootTip = null, maBootTimers = [], maBootStart = Date.now(), maBootElapsedTimer = 0, maBootWatchTimer = 0;
464
- function maBootSet(cls, text) {
465
- try { var n = maBootTip && maBootTip.querySelector(cls); if (n) n.textContent = text; } catch (e) { /* 忽略 */ }
466
- }
467
- function maBootShow() {
468
- try {
469
- if (maBootTip) return;
470
- if (!document.body) return; // 解析太早(body 还没出来)→ 交给 DOMContentLoaded 再挂
471
- maBootTip = document.createElement("div");
472
- maBootTip.className = "dsh-ma-boot";
473
- maBootTip.setAttribute("role", "status");
474
- maBootTip.setAttribute("aria-live", "polite");
475
- maBootTip.innerHTML =
476
- '<div class="dsh-ma-boot-spin" aria-hidden="true"></div>' +
477
- '<div class="dsh-ma-boot-title">正在加载远程桌面…</div>' +
478
- '<div class="dsh-ma-boot-sub">首次打开需要下载较完整的前端资源,请稍候</div>' +
479
- '<div class="dsh-ma-boot-elapsed">已等待 0 秒</div>';
480
- document.body.appendChild(maBootTip);
481
- // 秒表:让"到底等了多久"可见(用户据此判断是慢还是死了)
482
- maBootElapsedTimer = setInterval(function () {
483
- try {
484
- var s = Math.floor((Date.now() - maBootStart) / 1000);
485
- var n = maBootTip && maBootTip.querySelector(".dsh-ma-boot-elapsed");
486
- if (n) n.textContent = "已等待 " + s + " 秒";
487
- } catch (e) { /* 忽略 */ }
488
- }, 1000);
489
- maBootTimers.push(setTimeout(function () {
490
- maBootSet(".dsh-ma-boot-sub", "网络较慢时可能需要一两分钟;加载完成后会自动进入,不用重复刷新");
491
- }, 8000));
492
- maBootTimers.push(setTimeout(function () {
493
- maBootSet(".dsh-ma-boot-sub", "还在加载…如果长时间停在这里,可以下拉刷新重试(重复刷新不会更快)");
494
- }, 30 * 1000));
495
- } catch (e) { /* 提示失败绝不影响主功能 */ }
496
- }
497
- function maBootHide() {
498
- try {
499
- for (var i = 0; i < maBootTimers.length; i++) clearTimeout(maBootTimers[i]);
500
- maBootTimers = [];
501
- if (maBootElapsedTimer) { clearInterval(maBootElapsedTimer); maBootElapsedTimer = 0; }
502
- if (maBootWatchTimer) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
503
- var tip = maBootTip;
504
- maBootTip = null;
505
- if (tip && tip.parentNode) {
506
- try { tip.setAttribute("data-fading", "1"); } catch (e) { /* 忽略 */ }
507
- setTimeout(function () { try { if (tip.parentNode) tip.parentNode.removeChild(tip); } catch (e) { /* 忽略 */ } }, 300);
508
- }
509
- } catch (e) { /* 忽略 */ }
510
- }
511
- /** 官方 UI 是否已经可用了(= 提示可以撤了)。 */
512
- function maBootLooksReady() {
513
- try {
514
- // ① 适配层认出来的官方 frame(与下方 boot() 同一组选择器)
515
- if (document.querySelector(".pI_x6G_frame, [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]")) return true;
516
- // ② 出现输入框/会话区也算"进来了"(官方改版首选信号失灵时的兜底)
517
- return Boolean(document.querySelector('textarea, [contenteditable="true"], [role="textbox"]'));
518
- } catch (e) { return false; }
519
- }
520
- if (document.readyState === "loading") {
521
- document.addEventListener("DOMContentLoaded", maBootShow);
522
- } else {
523
- maBootShow();
524
- }
525
- // 轮询撤下:1s 一次,成本可忽略(只是两次 querySelector);一旦撤下就自我停止。
526
- // 上限与"4 分钟兜底"对齐,避免页面异常时留下常驻定时器。
527
- maBootWatchTimer = setInterval(function () {
528
- if (maBootLooksReady()) { maBootHide(); return; }
529
- if (!maBootTip) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
530
- }, 1000);
531
- maBootTimers.push(setTimeout(function () { maBootHide(); }, 240 * 1000));
532
- // 页面卸载/进 bfcache 时收干净(测试与真机都靠这条不留悬挂定时器)
533
- try {
534
- var maBootBye = function () { maBootHide(); };
535
- window.addEventListener("pagehide", maBootBye);
536
- window.addEventListener("unload", maBootBye);
537
- } catch (e) { /* 忽略 */ }
538
453
 
539
454
  /* —— 主机门解除(2026-09):镜像页(如 n.risegao.cn)经设备流认证回连同一台 127.0.0.1:3080,
540
455
  桥已把 Host 回环化并剥 Origin(不扩大信任面)。官方客户端以 transport.ownsHost 判定
@@ -1699,6 +1614,172 @@ const SCRIPT = `(() => {
1699
1614
  try { if (typeof requestAnimationFrame === "function") { requestAnimationFrame(run); return; } } catch (eRaf) { /* 退化 */ }
1700
1615
  setTimeout(run, 0);
1701
1616
  };
1617
+ /* ===== 手机图片上传兼容(0.6.15)=====
1618
+ 业主反馈:「手机端无法识别图片」——现象是选了照片发出去,模型看不到图 / 当成普通文件。
1619
+
1620
+ 根因(官方 dsh web 客户端逻辑,dsh-client-ui-conversation 的 createDrafts):
1621
+ · 只有 file.type ∈ {image/png, image/jpeg, image/webp, image/gif} 才走**图片**通道
1622
+ (本地编码后随 prompt 发出,模型真的"能看");
1623
+ · 其余一律当**普通文件**:走 Web Worker 后台上传,模型只拿到一个文件引用。
1624
+ 而手机恰恰最容易产出"非白名单"的图片:
1625
+ · iPhone 默认「高效」格式拍出来是 image/heic(不在白名单);
1626
+ · 部分 Android 图库 / 文件提供器给出的是**空 type**("");
1627
+ · 从"文件"入口选的还可能是 heif / bmp / tiff / avif。
1628
+
1629
+ 做法(全在浏览器里完成,不动官方代码、不动 host):
1630
+ 1) 在**捕获阶段**监听 change(比 React 根容器的监听更早):官方隐藏 input[type=file]
1631
+ 被选中后,若里面有"是图片但不在白名单"的文件,就先拦下这次事件,
1632
+ 用 canvas 把它转成 JPEG,再**替换 input.files 并重新派发一次 change** ——
1633
+ 这样官方按图片通道收下,模型就能"看见"了;
1634
+ 2) 任何一步失败(浏览器解不了 HEIC / canvas 不可用 / DataTransfer 不可用…)
1635
+ 一律**把原始文件原样重新派发**,绝不比现状更差(宁可当普通文件,也不能丢文件);
1636
+ 3) 官方认得的类型一个字节都不碰(零行为)。 */
1637
+ var MA_IMG_OK = { "image/png": 1, "image/jpeg": 1, "image/webp": 1, "image/gif": 1 };
1638
+ var MA_IMG_EXT_RE = /\\.(png|jpe?g|webp|gif|heic|heif|bmp|tiff?|avif)$/i;
1639
+ var MA_MAX_DIM = 4096; // 与官方 imageLimits.maxImageDimension 对齐,避免超限被拒
1640
+ var MA_CONVERT_TIMEOUT = 8000; // 解码卡住就放弃(原样放行)
1641
+ var MA_REENTRY = "__dshMaImageDone"; // 标记"这次 change 是我们自己派发的"
1642
+
1643
+ function maIsImageFile(f) {
1644
+ var t = String((f && f.type) || "").toLowerCase();
1645
+ if (t) return t.indexOf("image/") === 0;
1646
+ return MA_IMG_EXT_RE.test(String((f && f.name) || ""));
1647
+ }
1648
+ /** 需要转码吗:是图片、但不在官方白名单里。 */
1649
+ function maNeedsConvert(f) {
1650
+ var t = String((f && f.type) || "").toLowerCase();
1651
+ if (MA_IMG_OK[t]) return false;
1652
+ return maIsImageFile(f);
1653
+ }
1654
+ /** 扩展名 → 白名单里的 MIME(拿不到返回空)。 */
1655
+ function maMimeFromName(name) {
1656
+ var m = /\.([A-Za-z0-9]+)$/.exec(String(name || ""));
1657
+ if (!m) return "";
1658
+ var e = m[1].toLowerCase();
1659
+ if (e === "png") return "image/png";
1660
+ if (e === "jpg" || e === "jpeg") return "image/jpeg";
1661
+ if (e === "webp") return "image/webp";
1662
+ if (e === "gif") return "image/gif";
1663
+ return "";
1664
+ }
1665
+ /**
1666
+ * 只改 type、不重编码(**无损**)。
1667
+ * 为什么需要:Android 图库/文件提供器很常见地给出 type:"",而文件本身其实是标准 PNG/JPEG ——
1668
+ * 这时把字节丢进 canvas 重编码纯属浪费(还可能丢透明通道),改个 MIME 就够了。
1669
+ */
1670
+ function maRelabel(file, mime) {
1671
+ try {
1672
+ return new File([file], String(file.name || "image"), { type: mime, lastModified: file.lastModified || Date.now() });
1673
+ } catch (e) {
1674
+ try { var b = file.slice(0, file.size, mime); b.name = file.name; return b; } catch (e2) { return null; }
1675
+ }
1676
+ }
1677
+ /** 单个文件的归一化:优先无损改 MIME,实在不行才 canvas 转 JPEG。 */
1678
+ async function maNormalizeImage(file) {
1679
+ var mime = maMimeFromName(file && file.name);
1680
+ if (mime && String(file.type || "") !== mime) {
1681
+ var relabeled = maRelabel(file, mime);
1682
+ if (relabeled) return relabeled;
1683
+ }
1684
+ return await maToJpeg(file);
1685
+ }
1686
+ function maToJpeg(file) {
1687
+ return new Promise(function (resolve) {
1688
+ var url = "";
1689
+ try { url = URL.createObjectURL(file); } catch (e0) { resolve(null); return; }
1690
+ var settled = false;
1691
+ var finish = function (out) {
1692
+ if (settled) return;
1693
+ settled = true;
1694
+ try { URL.revokeObjectURL(url); } catch (e1) { /* 忽略 */ }
1695
+ resolve(out);
1696
+ };
1697
+ var timer = setTimeout(function () { finish(null); }, MA_CONVERT_TIMEOUT);
1698
+ var img;
1699
+ try { img = new Image(); } catch (e2) { clearTimeout(timer); finish(null); return; }
1700
+ img.onload = function () {
1701
+ clearTimeout(timer);
1702
+ try {
1703
+ var w = img.naturalWidth || img.width || 0;
1704
+ var h = img.naturalHeight || img.height || 0;
1705
+ if (!w || !h) return finish(null);
1706
+ var scale = Math.min(1, MA_MAX_DIM / Math.max(w, h));
1707
+ var cw = Math.max(1, Math.round(w * scale));
1708
+ var ch = Math.max(1, Math.round(h * scale));
1709
+ var cv = document.createElement("canvas");
1710
+ cv.width = cw; cv.height = ch;
1711
+ var ctx = cv.getContext("2d");
1712
+ if (!ctx) return finish(null);
1713
+ ctx.drawImage(img, 0, 0, cw, ch);
1714
+ cv.toBlob(function (blob) {
1715
+ if (!blob) return finish(null);
1716
+ var base = String(file.name || "photo").replace(/\\.[^./\\\\]+$/, "") || "photo";
1717
+ try {
1718
+ finish(new File([blob], base + ".jpg", { type: "image/jpeg", lastModified: Date.now() }));
1719
+ } catch (e3) {
1720
+ // 极老的浏览器没有 File 构造器 → 用 type 改写过的 Blob 兜底(官方只看 type/name)
1721
+ try { blob.name = base + ".jpg"; finish(blob); } catch (e4) { finish(null); }
1722
+ }
1723
+ }, "image/jpeg", 0.92);
1724
+ } catch (e5) { finish(null); }
1725
+ };
1726
+ img.onerror = function () { clearTimeout(timer); finish(null); };
1727
+ try { img.src = url; } catch (e6) { clearTimeout(timer); finish(null); }
1728
+ });
1729
+ }
1730
+ /** 把 files 塞回 input(DataTransfer 缺失/只读 → 返回 false,调用方走"原样重发")。 */
1731
+ function maSetInputFiles(input, files) {
1732
+ try {
1733
+ var dt = new DataTransfer();
1734
+ for (var i = 0; i < files.length; i++) dt.items.add(files[i]);
1735
+ if (!dt.files || dt.files.length !== files.length) return false;
1736
+ input.files = dt.files;
1737
+ return true;
1738
+ } catch (e) { return false; }
1739
+ }
1740
+ /** 重新派发一次 change(带重入标记,避免我们自己的监听再拦一次)。 */
1741
+ function maRedispatch(input) {
1742
+ try {
1743
+ input[MA_REENTRY] = true;
1744
+ input.dispatchEvent(new Event("change", { bubbles: true }));
1745
+ } catch (e) { /* 忽略 */ } finally {
1746
+ setTimeout(function () { try { input[MA_REENTRY] = false; } catch (e2) { /* 忽略 */ } }, 0);
1747
+ }
1748
+ }
1749
+ async function maOnFilePicked(input) {
1750
+ var list = input && input.files ? Array.prototype.slice.call(input.files) : [];
1751
+ if (!list.length) return;
1752
+ var need = list.filter(maNeedsConvert);
1753
+ if (!need.length) return; // 官方认得的类型 → 零行为
1754
+ var out = [];
1755
+ for (var i = 0; i < list.length; i++) {
1756
+ if (!maNeedsConvert(list[i])) { out.push(list[i]); continue; }
1757
+ var conv = await maNormalizeImage(list[i]);
1758
+ if (conv) out.push(conv);
1759
+ else out.push(list[i]); // 转不了就按原文件走(与现状一致,绝不丢)
1760
+ }
1761
+ if (maSetInputFiles(input, out)) maRedispatch(input);
1762
+ else maRedispatch(input); // 塞不回去 → 原样重发(文件不丢)
1763
+ }
1764
+ function maInstallImageCompat() {
1765
+ try {
1766
+ document.addEventListener("change", function (e) {
1767
+ var t = e.target;
1768
+ if (!t || String(t.tagName || "").toUpperCase() !== "INPUT") return;
1769
+ if (String(t.type || "").toLowerCase() !== "file") return;
1770
+ if (t[MA_REENTRY]) return; // 我们自己派发的那次 → 放行
1771
+ var files = t.files ? Array.prototype.slice.call(t.files) : [];
1772
+ if (!files.length) return;
1773
+ var any = false;
1774
+ for (var i = 0; i < files.length; i++) { if (maNeedsConvert(files[i])) { any = true; break; } }
1775
+ if (!any) return; // 无需转码 → 完全不拦(官方照旧处理)
1776
+ // 拦下这一次:官方若先看到原文件,会立刻按"普通文件"起后台上传(那就白改了)
1777
+ try { e.stopPropagation(); } catch (eS) { /* 忽略 */ }
1778
+ maOnFilePicked(t).catch(function () { maRedispatch(t); });
1779
+ }, true); // ★ 捕获阶段:必须早于 React 根容器的 change 监听
1780
+ } catch (e) { /* 适配层绝不把页面拖挂 */ }
1781
+ }
1782
+
1702
1783
  /* 挂上"叫醒"的两条路。做成可重入(先清旧的再建新的),这样 bfcache 恢复后能重新武装。 */
1703
1784
  const maArm = () => {
1704
1785
  maTorn = false;
@@ -1718,6 +1799,7 @@ const SCRIPT = `(() => {
1718
1799
  try { maInterval = setInterval(pump, 1000); } catch (eInt) { maInterval = 0; }
1719
1800
  };
1720
1801
  maArm();
1802
+ maInstallImageCompat(); // 手机图片上传兼容(见上方长注释):装了就一直有效,与 pump 无关
1721
1803
  /* 清理:页面卸载时断开观察者与兜底表(bfcache/长驻页面都不留悬挂回调)。
1722
1804
  pagehide 覆盖 bfcache 场景,unload 兜底更老的浏览器。 */
1723
1805
  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
+ });
@@ -105,9 +105,12 @@ const OFFICIAL_BODY = `<!doctype html>
105
105
  <body><div id="root"></div></body></html>`;
106
106
 
107
107
  const upstream = http.createServer((req, res) => {
108
- let body = "";
109
- req.on("data", (c) => { body += c; });
108
+ // ⚠️ 必须按 Buffer 攒再一次性 toString:`body += chunk` 会对**每个分片**单独 toString,
109
+ // 一个多字节 UTF-8 字符正好跨在 64KB 分片边界上就会被拆坏(0.6.15 加了大正文用例才暴露)。
110
+ const chunks = [];
111
+ req.on("data", (c) => chunks.push(c));
110
112
  req.on("end", () => {
113
+ const body = Buffer.concat(chunks).toString("utf8");
111
114
  // 官方 dsh web 特征 html(Phase-4:验证 e2ee 启用的桥端注入镜像 shim)
112
115
  if (req.url === "/official" || req.url.startsWith("/api/official")) {
113
116
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
@@ -115,7 +118,7 @@ const upstream = http.createServer((req, res) => {
115
118
  return;
116
119
  }
117
120
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
118
- res.end(JSON.stringify({ method: req.method, url: req.url, ct: req.headers["content-type"] || "", auth: req.headers["x-dsh-test-auth"] || "", body }));
121
+ res.end(JSON.stringify({ method: req.method, url: req.url, ct: req.headers["content-type"] || "", auth: req.headers["x-dsh-test-auth"] || "", want: req.headers["x-dsh-e2ee-want"] || "", body }));
119
122
  });
120
123
  });
121
124
  let upstreamWss = null;
@@ -375,6 +378,73 @@ test("E2EE HTTP:信封请求解封→上游→封回往返一致;篡改密文
375
378
  assert.match(badText, /auth_failed|无法解密/);
376
379
  });
377
380
 
381
+ test("★ 0.6.15 三层 base64 降为两层:声明过的客户端收到二进制明文,协商头不转发上游", async () => {
382
+ // 业主实测(dev-1014f947f8ab,Ubuntu 上的 dsh + Windows 访问,首屏要等几分钟):
383
+ // 同一条响应被 base64 编了三层(1.333³≈2.37×)。第一层是"gzip 正文进 JSON",
384
+ // 现在由客户端声明 x-dsh-e2ee-want: bin 换成二进制框架;老客户端不声明 → 形态不变。
385
+ const u = globalThis.__unlocked;
386
+ assert.ok(u, "需要先解锁");
387
+ const { client } = u;
388
+ const payload = "首屏聚合包内容-".repeat(4000); // ~ 100KB:足够看出编码层差异
389
+
390
+ // ① 新客户端:声明二进制明文
391
+ const plainBin = Buffer.from(JSON.stringify({
392
+ m: "POST",
393
+ p: "/api/echo?bin=1",
394
+ h: { "content-type": "application/json; charset=utf-8", "x-dsh-e2ee-want": "bin" },
395
+ b: Buffer.from(payload, "utf8").toString("base64")
396
+ }), "utf8");
397
+ const envBin = client.seal({ kind: "http", dir: "p2b", counter: 21, data: plainBin });
398
+ const resBin = await fetch(`${routerBase}/remote/${DEV_ON}/api/echo?bin=1`, {
399
+ method: "POST",
400
+ headers: { ...envelopeRequestHeaders(client.sessId, "http"), cookie: COOKIE },
401
+ body: JSON.stringify(envBin)
402
+ });
403
+ assert.equal(resBin.status, 200);
404
+ const envRespBin = JSON.parse(await resBin.text());
405
+ const openedBin = client.open({ kind: "http-resp", dir: "b2p", env: envRespBin, counter: envRespBin.c, reqNonceB64: envBin.n });
406
+ assert.equal(openedBin.data[0], 0x02, "★ 声明过的客户端必须收到二进制明文框架(首字节 0x02)");
407
+ const respBin = decodeHttpResponsePlain(openedBin.data);
408
+ const upBin = JSON.parse(respBin.bodyBuffer.toString("utf8"));
409
+ assert.equal(upBin.want, "", "★ 内部协商头绝不能转发给上游 dsh web(它不是业务头)");
410
+ assert.equal(upBin.body, payload, "正文必须逐字节还原");
411
+
412
+ // ② 老客户端(不声明)→ 仍是旧 JSON 形态,解码照样成功(兼容性)
413
+ const plainOld = Buffer.from(JSON.stringify({
414
+ m: "POST",
415
+ p: "/api/echo?bin=0",
416
+ h: { "content-type": "application/json; charset=utf-8" },
417
+ b: Buffer.from(payload, "utf8").toString("base64")
418
+ }), "utf8");
419
+ const envOld = client.seal({ kind: "http", dir: "p2b", counter: 22, data: plainOld });
420
+ const resOld = await fetch(`${routerBase}/remote/${DEV_ON}/api/echo?bin=0`, {
421
+ method: "POST",
422
+ headers: { ...envelopeRequestHeaders(client.sessId, "http"), cookie: COOKIE },
423
+ body: JSON.stringify(envOld)
424
+ });
425
+ const envRespOld = JSON.parse(await resOld.text());
426
+ const openedOld = client.open({ kind: "http-resp", dir: "b2p", env: envRespOld, counter: envRespOld.c, reqNonceB64: envOld.n });
427
+ assert.equal(openedOld.data[0], 0x7b, "老客户端必须仍收到旧 JSON 明文(首字节 '{')");
428
+ const respOld = decodeHttpResponsePlain(openedOld.data);
429
+ assert.equal(JSON.parse(respOld.bodyBuffer.toString("utf8")).body, payload);
430
+ // 同一份正文:新形态必须明显更小(省掉的就是那一层 33%)
431
+ assert.ok(openedOld.data.length > openedBin.data.length * 1.25,
432
+ `旧明文形态必须明显更大(实测 ${openedOld.data.length} vs ${openedBin.data.length})`);
433
+ });
434
+
435
+ test("★ 0.6.15 隧道帧不再给信封 JSON 套第三层 base64(bridge→中继 是计量那条腿)", async () => {
436
+ const { e2eeHttpResponseFrame } = await import("../dsh-bridge.mjs");
437
+ const env = { v: 2, k: "http-resp", s: "s".repeat(32), c: 1, n: "n".repeat(16), t: 0, d: "A".repeat(3 * 1024 * 1024) };
438
+ const frame = e2eeHttpResponseFrame(7, env.s, env);
439
+ assert.equal(frame.bodyBase64, false, "★ 不得再标记 bodyBase64(那一层纯属白花 33% 流量)");
440
+ assert.equal(frame.status, 200);
441
+ assert.match(frame.headers["x-dsh-e2ee"], /k=http-resp$/);
442
+ assert.deepEqual(JSON.parse(frame.body), env, "中继解出来必须与原信封逐字段一致");
443
+ const oldBody = Buffer.from(JSON.stringify(env)).toString("base64");
444
+ assert.ok(oldBody.length > frame.body.length * 1.3,
445
+ `旧写法会把这条帧撑大 33%(实测 ${oldBody.length} vs ${frame.body.length})`);
446
+ });
447
+
378
448
  test("E2EE WS 数据流:&e2ee=&w= 标记 → 桥剥除标记连上游,逐消息加解密 echo 一致", async () => {
379
449
  const u = globalThis.__unlocked;
380
450
  assert.ok(u, "需要先解锁");
@@ -32,7 +32,9 @@ import {
32
32
  normalizeKdf,
33
33
  parseEnvelopeMarker,
34
34
  parseWsE2eeParams,
35
- randomB64
35
+ randomB64,
36
+ stripPlainWantHeader,
37
+ wantsBinaryResponsePlain
36
38
  } from "../e2ee-client.mjs";
37
39
 
38
40
  // 固定口令/盐的 MK 期望值:由 enterprise e2ee.js deriveE2eeMasterKey 计算得到
@@ -213,6 +215,45 @@ function flipB64(b64) {
213
215
  return buf.toString("base64url");
214
216
  }
215
217
 
218
+ test("★ 0.6.15 二进制明文框架:正文不再 base64(省掉三层里的第一层),且旧 JSON 形态仍能解", () => {
219
+ // 业主实测"打开慢几分钟"的直接原因之一:同一条响应被 base64 编了三层(1.333³≈2.37×)。
220
+ // 第一层就是"gzip 正文塞进 JSON 明文"。二进制框架把它去掉,且必须**两种形态都能解**
221
+ // (老客户端只发旧形态,新客户端声明后收新形态)。
222
+ const body = Buffer.from("首屏聚合包".repeat(500), "utf8");
223
+ const headers = { "content-type": "application/javascript", "content-encoding": "gzip" };
224
+
225
+ const bin = encodeHttpResponsePlain({ status: 200, headers, bodyBuffer: body, binary: true });
226
+ const legacy = encodeHttpResponsePlain({ status: 200, headers, bodyBuffer: body });
227
+ assert.equal(bin[0], 0x02, "二进制框架必须以 0x02 开头(JSON 明文以 `{`=0x7B 开头,不可能冲突)");
228
+ // 旧形态:正文 base64 后是 1.333 倍(这就是被省掉的那一层)
229
+ assert.ok(legacy.length > bin.length * 1.3,
230
+ `旧 JSON 形态必须明显更大(实测 ${legacy.length} vs ${bin.length})`);
231
+ assert.ok(bin.length < body.length + 256, `二进制框架只应多出一点头(实测 ${bin.length} vs 正文 ${body.length})`);
232
+
233
+ const back = decodeHttpResponsePlain(bin);
234
+ assert.equal(back.status, 200);
235
+ assert.equal(back.enc, "gzip");
236
+ assert.equal(back.headers["content-type"], "application/javascript");
237
+ assert.deepEqual(Buffer.from(back.bodyBuffer), body, "正文必须逐字节还原");
238
+ const backLegacy = decodeHttpResponsePlain(legacy);
239
+ assert.deepEqual(Buffer.from(backLegacy.bodyBuffer), body, "旧形态必须照样能解(老客户端不受影响)");
240
+
241
+ // 空正文 / 只有头的响应
242
+ const empty = decodeHttpResponsePlain(encodeHttpResponsePlain({ status: 204, headers: {}, bodyBuffer: Buffer.alloc(0), binary: true }));
243
+ assert.equal(empty.status, 204);
244
+ assert.equal(empty.bodyBuffer.length, 0);
245
+ });
246
+
247
+ test("★ 0.6.15 协商头:只有客户端声明才用二进制框架,且声明头绝不转发给上游", () => {
248
+ assert.equal(wantsBinaryResponsePlain({ "x-dsh-e2ee-want": "bin" }), true);
249
+ assert.equal(wantsBinaryResponsePlain({ "X-DSH-E2EE-WANT": "BIN" }), true, "头名/值大小写不敏感");
250
+ assert.equal(wantsBinaryResponsePlain({ "x-dsh-e2ee-want": "json" }), false);
251
+ assert.equal(wantsBinaryResponsePlain({ "content-type": "application/json" }), false, "老客户端不发这个头 → 保持旧形态");
252
+
253
+ const stripped = stripPlainWantHeader({ "Content-Type": "application/json", "X-Dsh-E2ee-Want": "bin", "x-t": "v" });
254
+ assert.deepEqual(stripped, { "Content-Type": "application/json", "x-t": "v" }, "内部协商头绝不能出现在转发给上游的头里");
255
+ });
256
+
216
257
  test("http 响应信封:封包→开包,enc=gzip 被提取、实体头剥离,正文还原", () => {
217
258
  const s = makeSession();
218
259
  const body = Buffer.from(JSON.stringify({ ok: true }), "utf8");
@@ -230,7 +230,16 @@ test("信封逐字段一致:http/http-resp/w(文本·二进制)/ctrl(shim ↔ no
230
230
  test("请求信封明文编解码与信封标记头同构(node 交叉)", () => {
231
231
  const plainShim = shim.seEncodeHttpReqPlain({ method: "POST", path: "/api/echo?b=2", headers: { "content-type": "application/json", "x-t": "v" }, bodyBytes: new Uint8Array(Buffer.from("载荷", "utf8")) });
232
232
  const plainNode = encodeHttpRequestPlain({ method: "POST", path: "/api/echo?b=2", headers: { "content-type": "application/json", "x-t": "v" }, bodyB64: Buffer.from("载荷", "utf8").toString("base64") });
233
- assert.deepEqual(Buffer.from(plainShim), Buffer.from(plainNode));
233
+ const decShim = decodeHttpRequestPlain(plainShim);
234
+ const decNode = decodeHttpRequestPlain(plainNode);
235
+ // ★ 0.6.15:shim **额外**声明「响应明文请用二进制框架」(省掉正文那一层 base64)。
236
+ // 这是本版唯一一处**有意为之**的差异:老客户端(native.html / 企业版内置那份)不发这个头,
237
+ // 桥端也就永远不给他们发二进制明文 —— 兼容性靠"客户端声明"而不是靠版本号猜。
238
+ assert.equal(decShim.headers["x-dsh-e2ee-want"], "bin", "镜像页 shim 必须声明二进制明文框架");
239
+ assert.equal(decNode.headers["x-dsh-e2ee-want"], undefined, "桥端自己的编码器不得替客户端声明(否则老客户端会收到看不懂的字节)");
240
+ delete decShim.headers["x-dsh-e2ee-want"];
241
+ assert.deepEqual(decShim, decNode, "除该声明头外,请求明文逐字段同构");
242
+ assert.deepEqual(Buffer.from(decShim.bodyB64, "base64"), Buffer.from("载荷", "utf8"));
234
243
  const sessId = newSessId();
235
244
  assert.deepEqual(shim.seEnvelopeHeaders(sessId), envelopeRequestHeaders(sessId, "http"));
236
245
  });