@mrrisega/dsh-remote 0.6.13 → 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 (38) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +124 -14
  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 +283 -1
  6. package/clients/dsh-remote/test/boot-tip.test.mjs +158 -0
  7. package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
  8. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
  9. package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
  10. package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
  11. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
  12. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
  13. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
  14. package/clients/dsh-remote/test/upstream-discovery.test.mjs +196 -0
  15. package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
  16. package/clients/dsh-remote/upstream-discovery.mjs +448 -0
  17. package/clients/dsh-remote/wechat-channel.mjs +86 -3
  18. package/clients/dsh-remote/wechat-runtime.mjs +253 -10
  19. package/dsh-setup.mjs +225 -23
  20. package/package.json +1 -1
  21. package/packages/dsh-remote-web/lib/client.js +31 -1
  22. package/packages/dsh-remote-web/lib/index.js +464 -15
  23. package/packages/dsh-remote-web/package.json +1 -1
  24. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +124 -14
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
  28. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +283 -1
  29. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +448 -0
  30. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
  31. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
  32. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -23
  33. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +16 -1
  34. package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
  35. package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
  36. package/packages/dsh-remote-web/test/picker-pin.test.mjs +201 -0
  37. package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
  38. package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +170 -0
@@ -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,7 +100,34 @@ 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
- /* ===== dsh-remote mobile adapter (bridge 注入;仅 ≤820px 生效,桌面宽屏无任何规则) ===== */
103
+ /* ===== 首屏加载提示(0.6.14)=====
104
+ 为什么要有它:镜像页首屏要拉 dsh web 的全部客户端插件(实测 ~14 MB,一个不可拆分的
105
+ /plugins/?? 聚合请求),免费档限速下几十秒起步。此前页面上**只有官方那个转圈**,
106
+ 用户无法区分"在加载"和"卡死了",只能反复刷新 —— 反而更慢。
107
+ 这里给一句人话提示 + 分阶段的补充说明,加载完成(或超时兜底)即自动移除。
108
+ ⚠️ 刻意**不**放进 @media (max-width:820px):Windows/桌面浏览器打开镜像页同样要等。
109
+ ⚠️ pointer-events:none + 半透明:万一移除逻辑失效,也绝不挡住任何点击。 */
110
+ .dsh-ma-boot {
111
+ position: fixed; inset: 0; z-index: 2147483000;
112
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
113
+ background: color-mix(in srgb, var(--dsw-alias-bg-base, #0f1115) 88%, transparent);
114
+ backdrop-filter: blur(2px);
115
+ pointer-events: none;
116
+ font-family: inherit; text-align: center; padding: 24px;
117
+ transition: opacity .3s ease;
118
+ }
119
+ .dsh-ma-boot[data-fading="1"] { opacity: 0; }
120
+ .dsh-ma-boot-spin {
121
+ width: 34px; height: 34px; border-radius: 50%;
122
+ border: 3px solid color-mix(in srgb, currentColor 22%, transparent);
123
+ border-top-color: currentColor;
124
+ animation: dsh-ma-boot-spin 1s linear infinite;
125
+ }
126
+ @keyframes dsh-ma-boot-spin { to { transform: rotate(360deg); } }
127
+ .dsh-ma-boot-title { font-size: 15px; font-weight: 600; }
128
+ .dsh-ma-boot-sub { font-size: 13px; line-height: 1.6; opacity: .75; max-width: 22em; }
129
+ .dsh-ma-boot-elapsed { font-size: 12px; opacity: .55; font-variant-numeric: tabular-nums; }
130
+
99
131
  @media (max-width: 820px) {
100
132
  html, body { max-width: 100%; overflow-x: hidden; }
101
133
  html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
@@ -426,6 +458,89 @@ html[data-dsh-ma-merged-e2ee] #dsh-e2ee-badge { display: none !important; }
426
458
  const SCRIPT = `(() => {
427
459
  "use strict";
428
460
 
461
+ /* ===== 首屏加载提示(0.6.14)=====
462
+ 用户反馈:「普通用户加载进入镜像页面时等待时间较长,目前等待过程中一直在转圈」
463
+ —— 官方那个转圈不告诉用户任何事。这里在**页面解析的第一时间**就挂上一条人话提示,
464
+ 并在官方 UI 就绪(或兜底超时)后自动移除。
465
+
466
+ ⚠️ 必须放在所有主机判定(HOSTISH / NARROW)之前:首屏慢的正是这些判定等不到的时候。
467
+ ⚠️ 只做提示,不做任何拦截:pointer-events:none,也没有遮罩语义。 */
468
+ var maBootTip = null, maBootTimers = [], maBootStart = Date.now(), maBootElapsedTimer = 0, maBootWatchTimer = 0;
469
+ function maBootSet(cls, text) {
470
+ try { var n = maBootTip && maBootTip.querySelector(cls); if (n) n.textContent = text; } catch (e) { /* 忽略 */ }
471
+ }
472
+ function maBootShow() {
473
+ try {
474
+ if (maBootTip) return;
475
+ if (!document.body) return; // 解析太早(body 还没出来)→ 交给 DOMContentLoaded 再挂
476
+ maBootTip = document.createElement("div");
477
+ maBootTip.className = "dsh-ma-boot";
478
+ maBootTip.setAttribute("role", "status");
479
+ maBootTip.setAttribute("aria-live", "polite");
480
+ maBootTip.innerHTML =
481
+ '<div class="dsh-ma-boot-spin" aria-hidden="true"></div>' +
482
+ '<div class="dsh-ma-boot-title">正在加载远程桌面…</div>' +
483
+ '<div class="dsh-ma-boot-sub">首次打开需要下载较完整的前端资源,请稍候</div>' +
484
+ '<div class="dsh-ma-boot-elapsed">已等待 0 秒</div>';
485
+ document.body.appendChild(maBootTip);
486
+ // 秒表:让"到底等了多久"可见(用户据此判断是慢还是死了)
487
+ maBootElapsedTimer = setInterval(function () {
488
+ try {
489
+ var s = Math.floor((Date.now() - maBootStart) / 1000);
490
+ var n = maBootTip && maBootTip.querySelector(".dsh-ma-boot-elapsed");
491
+ if (n) n.textContent = "已等待 " + s + " 秒";
492
+ } catch (e) { /* 忽略 */ }
493
+ }, 1000);
494
+ maBootTimers.push(setTimeout(function () {
495
+ maBootSet(".dsh-ma-boot-sub", "网络较慢时可能需要一两分钟;加载完成后会自动进入,不用重复刷新");
496
+ }, 8000));
497
+ maBootTimers.push(setTimeout(function () {
498
+ maBootSet(".dsh-ma-boot-sub", "还在加载…如果长时间停在这里,可以下拉刷新重试(重复刷新不会更快)");
499
+ }, 30 * 1000));
500
+ } catch (e) { /* 提示失败绝不影响主功能 */ }
501
+ }
502
+ function maBootHide() {
503
+ try {
504
+ for (var i = 0; i < maBootTimers.length; i++) clearTimeout(maBootTimers[i]);
505
+ maBootTimers = [];
506
+ if (maBootElapsedTimer) { clearInterval(maBootElapsedTimer); maBootElapsedTimer = 0; }
507
+ if (maBootWatchTimer) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
508
+ var tip = maBootTip;
509
+ maBootTip = null;
510
+ if (tip && tip.parentNode) {
511
+ try { tip.setAttribute("data-fading", "1"); } catch (e) { /* 忽略 */ }
512
+ setTimeout(function () { try { if (tip.parentNode) tip.parentNode.removeChild(tip); } catch (e) { /* 忽略 */ } }, 300);
513
+ }
514
+ } catch (e) { /* 忽略 */ }
515
+ }
516
+ /** 官方 UI 是否已经可用了(= 提示可以撤了)。 */
517
+ function maBootLooksReady() {
518
+ try {
519
+ // ① 适配层认出来的官方 frame(与下方 boot() 同一组选择器)
520
+ if (document.querySelector(".pI_x6G_frame, [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]")) return true;
521
+ // ② 出现输入框/会话区也算"进来了"(官方改版首选信号失灵时的兜底)
522
+ return Boolean(document.querySelector('textarea, [contenteditable="true"], [role="textbox"]'));
523
+ } catch (e) { return false; }
524
+ }
525
+ if (document.readyState === "loading") {
526
+ document.addEventListener("DOMContentLoaded", maBootShow);
527
+ } else {
528
+ maBootShow();
529
+ }
530
+ // 轮询撤下:1s 一次,成本可忽略(只是两次 querySelector);一旦撤下就自我停止。
531
+ // 上限与"4 分钟兜底"对齐,避免页面异常时留下常驻定时器。
532
+ maBootWatchTimer = setInterval(function () {
533
+ if (maBootLooksReady()) { maBootHide(); return; }
534
+ if (!maBootTip) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
535
+ }, 1000);
536
+ maBootTimers.push(setTimeout(function () { maBootHide(); }, 240 * 1000));
537
+ // 页面卸载/进 bfcache 时收干净(测试与真机都靠这条不留悬挂定时器)
538
+ try {
539
+ var maBootBye = function () { maBootHide(); };
540
+ window.addEventListener("pagehide", maBootBye);
541
+ window.addEventListener("unload", maBootBye);
542
+ } catch (e) { /* 忽略 */ }
543
+
429
544
  /* —— 主机门解除(2026-09):镜像页(如 n.risegao.cn)经设备流认证回连同一台 127.0.0.1:3080,
430
545
  桥已把 Host 回环化并剥 Origin(不扩大信任面)。官方客户端以 transport.ownsHost 判定
431
546
  「自有主机」,非回环域名下不置 true 会把设置页置为 memory(不可用)镜像 →
@@ -1589,6 +1704,172 @@ const SCRIPT = `(() => {
1589
1704
  try { if (typeof requestAnimationFrame === "function") { requestAnimationFrame(run); return; } } catch (eRaf) { /* 退化 */ }
1590
1705
  setTimeout(run, 0);
1591
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
+
1592
1873
  /* 挂上"叫醒"的两条路。做成可重入(先清旧的再建新的),这样 bfcache 恢复后能重新武装。 */
1593
1874
  const maArm = () => {
1594
1875
  maTorn = false;
@@ -1608,6 +1889,7 @@ const SCRIPT = `(() => {
1608
1889
  try { maInterval = setInterval(pump, 1000); } catch (eInt) { maInterval = 0; }
1609
1890
  };
1610
1891
  maArm();
1892
+ maInstallImageCompat(); // 手机图片上传兼容(见上方长注释):装了就一直有效,与 pump 无关
1611
1893
  /* 清理:页面卸载时断开观察者与兜底表(bfcache/长驻页面都不留悬挂回调)。
1612
1894
  pagehide 覆盖 bfcache 场景,unload 兜底更老的浏览器。 */
1613
1895
  const maTeardown = () => {
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 镜像页首屏加载提示的契约与行为测试(0.6.14)。
4
+ *
5
+ * 用户反馈:「普通用户加载进入镜像页面时等待时间较长,目前等待过程中一直在转圈。
6
+ * 建议给普通用户增加一个友好的提示(例如提示"正在排队中")。」
7
+ *
8
+ * 首屏为什么慢:镜像页要拉 dsh web 的全部客户端插件(实测 ~14 MB 的一个**不可拆分**聚合请求),
9
+ * 免费档限速下几十秒起步。官方那个转圈不告诉用户任何事,用户只能反复刷新 —— 反而更慢。
10
+ *
11
+ * 这里守四件事:
12
+ * ① 提示必须在**页面解析的第一时间**出现(早于所有主机判定 —— 首屏慢的正是判定等不到的时候);
13
+ * ② 分阶段补充说明(8s / 30s)与秒表,让"到底等了多久"可见;
14
+ * ③ 官方 UI 就绪后自动撤下;且有 4 分钟兜底 + pagehide/unload 清理,绝不留悬挂定时器;
15
+ * ④ 只做提示、不做拦截(pointer-events:none),提示失效也绝不挡住用户操作。
16
+ *
17
+ * 用法: node --test test/boot-tip.test.mjs
18
+ */
19
+ import assert from "node:assert/strict";
20
+ import { readFileSync } from "node:fs";
21
+ import path from "node:path";
22
+ import test from "node:test";
23
+ import vm from "node:vm";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
27
+ const SRC = readFileSync(path.join(HERE, "..", "mobile-adapter.mjs"), "utf8");
28
+ const STYLE = SRC.slice(SRC.indexOf("const STYLE = `") + "const STYLE = `".length, SRC.indexOf("`;\n\n/**", SRC.indexOf("const STYLE = `")));
29
+ const SCRIPT_START = SRC.indexOf("const SCRIPT = `(() => {") + "const SCRIPT = `".length;
30
+ const SCRIPT = SRC.slice(SCRIPT_START, SRC.indexOf("`;\n\n/**", SCRIPT_START));
31
+ /**
32
+ * 只取「首屏提示」这一段来跑行为。
33
+ * 为什么不像别的用例那样跑整个适配层:整段要一整套 DOM 桩(抽屉/遮罩/设置面板…),
34
+ * 而这里要验的是**提示的生命周期**,隔离出来既稳又准(改动范围也正好是这一段)。
35
+ */
36
+ const BOOT = SCRIPT.slice(SCRIPT.indexOf("var maBootTip"), SCRIPT.indexOf("/* —— 主机门解除"));
37
+
38
+ /** 极简 DOM 桩:只支撑"提示出现/撤下"这条路径。 */
39
+ function makeDom({ body = true, ready = false, frame = false } = {}) {
40
+ const nodes = [];
41
+ const el = (tag) => {
42
+ const n = {
43
+ tagName: String(tag).toUpperCase(), className: "", _text: "", children: [], attrs: {}, parentNode: null,
44
+ set innerHTML(v) { this._html = v; }, get innerHTML() { return this._html || ""; },
45
+ set textContent(v) { this._text = String(v); }, get textContent() { return this._text; },
46
+ setAttribute(k, v) { this.attrs[k] = String(v); },
47
+ appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
48
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parentNode = null; return c; },
49
+ querySelector(sel) {
50
+ const cls = String(sel).replace(/^\./, "");
51
+ const hit = (x) => (x.className || "").split(/\s+/).includes(cls);
52
+ const walk = (x) => { for (const c of x.children) { if (hit(c)) return c; const d = walk(c); if (d) return d; } return null; };
53
+ return walk(this);
54
+ }
55
+ };
56
+ return n;
57
+ };
58
+ const doc = {
59
+ readyState: ready ? "complete" : "loading",
60
+ listeners: {},
61
+ createElement: el,
62
+ querySelector(sel) {
63
+ if (sel === ".dsh-ma-boot") return nodes.find((n) => (n.className || "").includes("dsh-ma-boot")) || null;
64
+ if (frame && /frame|textarea|contenteditable|textbox|sidebar-collapsed/.test(sel)) return el("div");
65
+ return null;
66
+ },
67
+ addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
68
+ dispatch(type) { (this.listeners[type] || []).forEach((fn) => fn({})); }
69
+ };
70
+ if (body) { doc.body = el("body"); doc.body.className = ""; }
71
+ else { doc.body = null; doc.bodyHolder = el("body"); }
72
+ return { doc, nodes, el, attachBody() { doc.body = doc.bodyHolder; } };
73
+ }
74
+
75
+ function run(env) {
76
+ const timers = [];
77
+ const intervals = [];
78
+ const win = {
79
+ listeners: {},
80
+ addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
81
+ dispatch(type) { (this.listeners[type] || []).forEach((fn) => fn({})); },
82
+ innerWidth: 390, innerHeight: 844,
83
+ location: { href: "https://example.test/remote/dev-x/", origin: "https://example.test" },
84
+ MutationObserver: class { observe() {} disconnect() {} },
85
+ requestAnimationFrame: (fn) => { timers.push({ fn, ms: 0 }); return timers.length; },
86
+ cancelAnimationFrame() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
87
+ };
88
+ const ctx = {
89
+ window: win,
90
+ document: env.doc,
91
+ location: win.location,
92
+ navigator: { userAgent: "node-test", language: "zh-CN" },
93
+ console: { warn() {}, log() {}, error() {} },
94
+ setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
95
+ clearTimeout: () => {},
96
+ setInterval: (fn, ms) => { intervals.push({ fn, ms }); return intervals.length; },
97
+ clearInterval: () => {},
98
+ Date, Math, JSON, Object, Array, String, Number, Boolean, Error, RegExp, Promise, Map, Set
99
+ };
100
+ vm.createContext(ctx);
101
+ vm.runInContext(BOOT, ctx, { timeout: 5000 });
102
+ return { win, timers, intervals };
103
+ }
104
+
105
+ test("样式:提示层不挡点击(pointer-events:none)、覆盖所有宽度、有转圈与秒表样式", () => {
106
+ assert.match(STYLE, /\.dsh-ma-boot\s*\{[\s\S]*?pointer-events:\s*none/, "提示层绝不能拦截点击");
107
+ assert.ok(!/@media \(max-width: 820px\) \{\s*\.dsh-ma-boot/.test(STYLE), "提示不受窄屏限定(Windows/桌面浏览器同样要等)");
108
+ assert.match(STYLE, /\.dsh-ma-boot-spin/, "要有加载动画");
109
+ assert.match(STYLE, /\.dsh-ma-boot-elapsed/, "要有秒表");
110
+ });
111
+
112
+ test("★ 行为:页面还在解析(readyState=loading)也要立刻挂出提示", () => {
113
+ const env = makeDom({ ready: false, frame: false });
114
+ const { win, timers } = run(env);
115
+ // 提示挂载挂在 DOMContentLoaded 上(此时 body 可能还没出来)
116
+ assert.ok((env.doc.listeners.DOMContentLoaded || []).length > 0, "必须监听 DOMContentLoaded 再挂提示");
117
+ env.doc.dispatch("DOMContentLoaded");
118
+ const tip = env.doc.body.children.find((c) => (c.className || "").includes("dsh-ma-boot"));
119
+ assert.ok(tip, "解析阶段就要出现提示(这正是首屏最慢的时候)");
120
+ assert.match(tip.innerHTML, /正在加载远程桌面/);
121
+ assert.match(tip.innerHTML, /首次打开需要下载/, "要说明为什么慢");
122
+ assert.ok(timers.some((t) => t.ms === 8000) && timers.some((t) => t.ms === 30000), "要有 8s/30s 两段补充说明");
123
+ assert.ok(timers.some((t) => t.ms === 240000), "要有 4 分钟兜底撤下");
124
+ assert.ok((win.listeners.pagehide || []).length > 0, "pagehide 要清理(不留悬挂定时器)");
125
+ });
126
+
127
+ test("★ 行为:官方 UI 就绪后自动撤下提示", () => {
128
+ const env = makeDom({ ready: true, frame: true }); // ready=complete → 立即挂;frame=true → 已就绪
129
+ const { intervals } = run(env);
130
+ const tip = env.doc.body.children.find((c) => (c.className || "").includes("dsh-ma-boot"));
131
+ assert.ok(tip, "先挂上");
132
+ assert.ok(intervals.length >= 2, "要有秒表 + 就绪轮询两个定时器");
133
+ // 两个 interval 都是 1000ms(秒表 / 就绪轮询),顺序不保证 —— 全部推一次,模拟"1 秒过去"
134
+ intervals.forEach((t) => t.fn());
135
+ assert.equal(tip.attrs["data-fading"], "1", "官方 UI 就绪后应进入淡出");
136
+ });
137
+
138
+ test("行为:脚本在 <head> 里先跑(readyState=loading,body 还没出来)也绝不能抛,body 就位后补挂", () => {
139
+ // 真实时序就是这条:适配层被内联在 <head>,执行时 body 可能还不存在
140
+ const env = makeDom({ body: false, ready: false });
141
+ assert.doesNotThrow(() => run(env), "body 缺失时绝不能抛(适配层其他逻辑还要跑)");
142
+ env.attachBody();
143
+ env.doc.dispatch("DOMContentLoaded");
144
+ const tip = env.doc.body.children.find((c) => (c.className || "").includes("dsh-ma-boot"));
145
+ assert.ok(tip, "body 就位后补挂提示");
146
+ assert.match(tip.innerHTML, /正在加载远程桌面/);
147
+ });
148
+
149
+ test("源码契约:提示必须早于主机判定(HOSTISH/NARROW),否则首屏最慢那段没有提示", () => {
150
+ // ⚠️ 基准要用 **HOSTISH 的定义**(const HOSTISH =),不能用 indexOf("HOSTISH"):
151
+ // 提示那段自己的注释里就提到了 HOSTISH,拿注释当基准会把顺序判反(第一版就这么红的)。
152
+ const iTip = SCRIPT.indexOf("var maBootTip");
153
+ const iHost = SCRIPT.indexOf("const HOSTISH");
154
+ assert.ok(iTip > 0 && iHost > 0, "找不到提示段或主机判定");
155
+ assert.ok(iTip < iHost, "提示段必须在主机判定之前");
156
+ assert.ok(BOOT.includes("pointer-events: none") || STYLE.includes("pointer-events: none"), "必须写明不拦截点击");
157
+ assert.ok(!/dsh-ma-boot[\s\S]{0,400}?pointer-events:\s*auto/.test(STYLE), "提示层不得改成可点击拦截");
158
+ });
@@ -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
+ });