@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 = () => {
@@ -158,13 +158,140 @@ export function dshProcessPids(platform = process.platform) {
158
158
  .filter((n) => Number.isInteger(n) && n > 1 && n !== process.pid);
159
159
  }
160
160
 
161
+ // ---------- 「本机所有回环监听端口」:NAS 上的救命稻草(0.6.15) ----------
162
+ //
163
+ // 【为什么必须加这一层】飞牛 fnOS 用户诊断(2026-09-25,插件 0.6.14):
164
+ // 上游 dsh web: http://127.0.0.1:3080(默认值 3080(**未取到 dsh web 实际监听端口**),⚠️ 不可达)
165
+ // 他的 dsh web 实际在 **2298**(fnOS 应用里配的内部监听端口),而上面五条发现路径**全都没命中**:
166
+ // · 插件半的 ctx.webServer.port 没取到(该环境下拿不到);
167
+ // · `lsof` 在 NAS 上**根本没装**;`pgrep -f dsh` 也可能没有;
168
+ // · 静态候选只有 3080 与 Desktop 的 43120 区间 —— 2298 永远不在里面。
169
+ // 于是用户去改端口号(改成 20xx、又改回 3080)当然都没用:**没有任何一条路径会去试 2298**。
170
+ //
171
+ // 做法:直接把"这台机器上正在 LISTEN 的回环端口"全列出来当候选,再逐个做**身份校验**。
172
+ // 为什么敢全列:身份校验(`looksLikeDshWeb` / `looksLikeDshRemoteSelf`)会拒掉所有陌生服务,
173
+ // 多探几个端口只是几十毫秒;而漏掉一个端口就是"用户永远连不上"。
174
+
175
+ /** 解析 /proc/net/tcp 一行里的本地地址与状态(Linux 无依赖:不需要 ss/netstat/lsof)。 */
176
+ function parseProcNetLine(line) {
177
+ const cols = String(line || "").trim().split(/\s+/);
178
+ if (cols.length < 4) return null;
179
+ const local = cols[1];
180
+ const state = cols[3];
181
+ const m = /^([0-9A-Fa-f]{1,32}):([0-9A-Fa-f]{1,4})$/.exec(local || "");
182
+ if (!m) return null;
183
+ return { hex: m[1], port: parseInt(m[2], 16), state: String(state).toUpperCase() };
184
+ }
185
+
186
+ /** 32 位小端 hex(/proc/net/tcp)→ IPv4 点分串。 */
187
+ function ipv4OfHex(hex) {
188
+ const h = String(hex).padStart(8, "0").slice(-8);
189
+ const bytes = [h.slice(6, 8), h.slice(4, 6), h.slice(2, 4), h.slice(0, 2)].map((x) => parseInt(x, 16));
190
+ return bytes.join(".");
191
+ }
192
+
193
+ /** 128 位 hex(/proc/net/tcp6)→ 是否回环(::1)。 */
194
+ function isIpv6LoopbackHex(hex) {
195
+ const h = String(hex).toLowerCase().padStart(32, "0");
196
+ // ::1 在 /proc 里按 4 组小端 32 位存放 → 末组为 01000000,其余全 0
197
+ return /^0{24}01000000$/.test(h);
198
+ }
199
+
200
+ /** Linux:直接读 /proc/net/tcp{,6}(内核真值,NAS 上不需要任何外部命令)。 */
201
+ export function procNetLoopbackPorts() {
202
+ const ports = new Set();
203
+ for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
204
+ let text = "";
205
+ try { text = fs.readFileSync(file, "utf8"); } catch { continue; }
206
+ for (const line of text.split("\n").slice(1)) {
207
+ const row = parseProcNetLine(line);
208
+ if (!row || row.state !== "0A") continue; // 0A = LISTEN
209
+ const loop = file.endsWith("tcp6")
210
+ ? isIpv6LoopbackHex(row.hex)
211
+ : ipv4OfHex(row.hex).startsWith("127.");
212
+ // 通配绑定(0.0.0.0 / ::)也收:dsh web 绑 0.0.0.0 时回环照样能连
213
+ const wildcard = file.endsWith("tcp6") ? /^0+$/.test(String(row.hex)) : ipv4OfHex(row.hex) === "0.0.0.0";
214
+ if (loop || wildcard) ports.add(row.port);
215
+ }
216
+ }
217
+ return [...ports];
218
+ }
219
+
220
+ /** `ss -ltnH` 输出里的本地端口(Linux;部分精简系统没有 /proc,或反之)。 */
221
+ function ssLoopbackPorts() {
222
+ const ports = new Set();
223
+ const out = tryExec("ss", ["-ltnH"], 5000) || tryExec("ss", ["-ltn"], 5000);
224
+ for (const line of out.split("\n")) {
225
+ const cols = line.trim().split(/\s+/);
226
+ if (cols.length < 4) continue;
227
+ // ss 的本地地址是第 4 列(State Recv-Q Send-Q Local Address:Port Peer Address:Port)
228
+ const m = /[:.](\d+)$/.exec(cols[3] || "");
229
+ if (m) ports.add(Number(m[1]));
230
+ }
231
+ return [...ports];
232
+ }
233
+
234
+ /** `netstat -ltn` 输出里的本地端口(BSD/GNU 两种格式都认)。 */
235
+ function netstatLoopbackPorts() {
236
+ const ports = new Set();
237
+ const out = (tryExec("netstat", ["-ltn"], 5000) || tryExec("netstat", ["-an"], 6000));
238
+ for (const line of out.split("\n")) {
239
+ if (!/listen/i.test(line) && !/^tcp/i.test(line)) continue;
240
+ const m = /(?:^|\s)(?:\d{1,3}\.){3}\d{1,3}[:.](\d+)|\[?::1?\]?[:.](\d+)/.exec(line);
241
+ const p = m ? Number(m[1] || m[2]) : 0;
242
+ if (p > 0 && p < 65536) ports.add(p);
243
+ }
244
+ return [...ports];
245
+ }
246
+
247
+ /** `lsof -nP -iTCP -sTCP:LISTEN` 输出里的本地端口(macOS/装了 lsof 的 Linux)。 */
248
+ function lsofLoopbackPorts() {
249
+ const ports = new Set();
250
+ const out = tryExec("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], 6000);
251
+ for (const line of out.split("\n")) {
252
+ const m = /(?:TCP\s+)?(?:\d{1,3}\.){3}\d{1,3}:(\d+)\s*\(LISTEN\)/.exec(line) || /TCP\s+\*:(\d+)/.exec(line);
253
+ if (m) ports.add(Number(m[1]));
254
+ }
255
+ return [...ports];
256
+ }
257
+
258
+ /**
259
+ * 本机所有回环(含通配绑定)LISTEN 端口,去重。
260
+ * 多来源合并而不是"取第一个成功的":NAS 上 lsof/ss/netstat/pgrep 各有缺失,
261
+ * 只有合起来才够全;重复端口由 Set 消化,代价只是多读几个文件。
262
+ */
263
+ export function allLoopbackListeningPorts(platform = process.platform) {
264
+ const ports = new Set();
265
+ const push = (list) => { for (const p of list) { const n = Number(p); if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n); } };
266
+ if (platform === "win32") {
267
+ for (const line of tryExec("netstat", ["-ano"], 6000).split(/\r?\n/)) {
268
+ if (!/LISTENING/i.test(line)) continue;
269
+ const cols = line.trim().split(/\s+/);
270
+ const m = /:(\d+)$/.exec(cols[1] || "");
271
+ if (m) push([Number(m[1])]);
272
+ }
273
+ return [...ports];
274
+ }
275
+ if (platform === "linux") {
276
+ push(procNetLoopbackPorts()); // 无依赖,优先
277
+ push(ssLoopbackPorts());
278
+ }
279
+ push(lsofLoopbackPorts());
280
+ push(netstatLoopbackPorts());
281
+ return [...ports];
282
+ }
283
+
161
284
  /**
162
285
  * 候选列表(已去重,按"可能性"排序),每项带**来源标注**(用于日志与测试断言)。
163
286
  * 顺序 = 提示里的端口 → 显式额外端口 → DSH_WEB_URL 的端口 → 祖先/dsh 进程实测监听端口
164
- * → 3080 → Desktop 默认端口区间。
287
+ * → **本机所有回环监听端口** → 3080 → Desktop 默认端口区间。
288
+ *
289
+ * 「所有回环监听端口」排在静态候选之前:NAS 上 dsh web 的端口(实测 2298)**只可能**从这一层找到,
290
+ * 排在 3080 之后就等于白加(3080 探测失败之前不会去试它,而静态候选有 12 个 Desktop 端口)。
291
+ * 最后仍留静态候选兜底(宿主把端口藏得很深、/proc 也没有的极端情况)。
165
292
  * @returns {Array<{port:number, source:string}>}
166
293
  */
167
- export function candidatePorts({ relayDir = "", env = process.env, extraPorts = [], platform = process.platform } = {}) {
294
+ export function candidatePorts({ relayDir = "", env = process.env, extraPorts = [], platform = process.platform, listenPorts = null } = {}) {
168
295
  const out = [];
169
296
  const seen = new Set();
170
297
  const push = (p, source) => {
@@ -179,6 +306,10 @@ export function candidatePorts({ relayDir = "", env = process.env, extraPorts =
179
306
  push(portOf(upstreamFromDshWebUrl(env)), "dsh_web_url");
180
307
  const pids = [...new Set([process.pid, ...ancestorPids(process.pid, 6, platform), ...dshProcessPids(platform)])];
181
308
  for (const pid of pids) for (const p of listeningPortsOfPid(pid, platform)) push(p, "process");
309
+ // ★ 本机所有回环 LISTEN 端口(0.6.15):NAS 上 dsh web 在 2298 这类非默认端口时唯一的发现路径。
310
+ // 调用方可以注入 listenPorts(测试用),避免用例依赖开发机真实监听表。
311
+ const listen = Array.isArray(listenPorts) ? listenPorts : allLoopbackListeningPorts(platform);
312
+ for (const p of listen) push(p, "listen");
182
313
  push(3080, "static");
183
314
  for (let i = 0; i < DESKTOP_PORT_SCAN; i++) push(DESKTOP_DEFAULT_PORT + i, "static");
184
315
  return out;
@@ -208,8 +339,28 @@ export function looksLikeDshWeb(status, text) {
208
339
  return DSH_WEB_MARKERS.some((m) => body.includes(m));
209
340
  }
210
341
 
342
+ /** 本插件自己的路由(`GET /dsh-remote/self`)—— 只有"宿主这台 dsh web"才会应答。 */
343
+ export const DSH_REMOTE_SELF_PATH = "/dsh-remote/self";
344
+
345
+ /**
346
+ * 身份校验的**第二判据**:本插件自己的 `/dsh-remote/self`。
347
+ *
348
+ * 为什么需要它:`looksLikeDshWeb` 依赖 dsh web 首页的 HTML 特征(`__ModuleLoader__` 等),
349
+ * 而首页在有些部署下是**跳转/登录页/自定义门户**,特征一个字都不出现 —— 于是明明端口就是对的,
350
+ * 却被判成"陌生服务"。`/dsh-remote/self` 由**本插件**注册在同一个 web server 上,
351
+ * 返回 `{"ok":true,"version":…,"channel":…,"relayDir":…}`,是本机最硬的同一性证据
352
+ * (它跑在同一条隧道上,能应答就说明这个端口就是宿主的 dsh web)。
353
+ */
354
+ export function looksLikeDshRemoteSelf(status, text) {
355
+ if (Number(status) !== 200) return false;
356
+ const body = String(text || "");
357
+ if (!body.includes("relayDir")) return false;
358
+ return body.includes('"channel"') || body.includes('"runtimeReady"') || body.includes("dsh-remote");
359
+ }
360
+
211
361
  /**
212
- * 探测一个候选:GET `/`,带身份校验。
362
+ * 探测一个候选:先 GET `/`(dsh web 首页特征),不中再 GET `/dsh-remote/self`(本插件特征)。
363
+ * 两条判据任一命中即通过。
213
364
  * @returns {Promise<boolean>}
214
365
  */
215
366
  export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl } = {}) {
@@ -217,34 +368,38 @@ export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl
217
368
  if (!target) return false;
218
369
  const doFetch = fetchImpl || globalThis.fetch;
219
370
  if (typeof doFetch !== "function") return false;
220
- try {
221
- const ac = new AbortController();
222
- const timer = setTimeout(() => ac.abort(), timeoutMs);
223
- let res;
224
- try {
225
- // redirect: manual —— 不跟随跳转(跳走的多半不是我们要的宿主)
226
- res = await doFetch(`${target}/`, { signal: ac.signal, redirect: "manual" });
227
- } finally {
228
- clearTimeout(timer);
229
- }
230
- const status = Number(res && res.status) || 0;
231
- if (status >= 500) return false;
232
- // 只读前 8KB 足够命中特征;读流失败按"不是"处理(宁可少一个候选,不可误接)
233
- let text = "";
371
+ const once = async (path, check) => {
234
372
  try {
235
- if (res.body && typeof res.body.getReader === "function") {
236
- const reader = res.body.getReader();
237
- const { value } = await reader.read();
238
- text = value ? Buffer.from(value).toString("utf8") : "";
239
- try { await reader.cancel(); } catch { /* ignore */ }
240
- } else if (typeof res.text === "function") {
241
- text = await res.text();
373
+ const ac = new AbortController();
374
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
375
+ let res;
376
+ try {
377
+ // redirect: manual —— 不跟随跳转(跳走的多半不是我们要的宿主)
378
+ res = await doFetch(`${target}${path}`, { signal: ac.signal, redirect: "manual" });
379
+ } finally {
380
+ clearTimeout(timer);
242
381
  }
243
- } catch { /* 读失败 → text 为空 → 判否 */ }
244
- return looksLikeDshWeb(status, text.slice(0, 8192));
245
- } catch {
246
- return false;
247
- }
382
+ const status = Number(res && res.status) || 0;
383
+ if (status >= 500) return false;
384
+ // 只读前 8KB 足够命中特征;读流失败按"不是"处理(宁可少一个候选,不可误接)
385
+ let text = "";
386
+ try {
387
+ if (res.body && typeof res.body.getReader === "function") {
388
+ const reader = res.body.getReader();
389
+ const { value } = await reader.read();
390
+ text = value ? Buffer.from(value).toString("utf8") : "";
391
+ try { await reader.cancel(); } catch { /* ignore */ }
392
+ } else if (typeof res.text === "function") {
393
+ text = await res.text();
394
+ }
395
+ } catch { /* 读失败 → text 为空 → 判否 */ }
396
+ return check(status, text.slice(0, 8192));
397
+ } catch {
398
+ return false;
399
+ }
400
+ };
401
+ if (await once("/", looksLikeDshWeb)) return true;
402
+ return once(DSH_REMOTE_SELF_PATH, looksLikeDshRemoteSelf);
248
403
  }
249
404
 
250
405
  /**
@@ -261,6 +416,7 @@ export async function discoverUpstream({
261
416
  fetchImpl,
262
417
  platform = process.platform,
263
418
  extraPorts = [],
419
+ listenPorts = null,
264
420
  log
265
421
  } = {}) {
266
422
  const hint = resolveUpstreamHint({ relayDir, env });
@@ -273,7 +429,7 @@ export async function discoverUpstream({
273
429
 
274
430
  // ③ 候选并行探测(本地端口,拒绝是瞬时的;并行是为了不让 watcher 卡住)
275
431
  const hintPort = portOf(hint.url);
276
- const entries = candidatePorts({ relayDir, env, platform, extraPorts }).filter((e) => e.port !== hintPort);
432
+ const entries = candidatePorts({ relayDir, env, platform, extraPorts, listenPorts }).filter((e) => e.port !== hintPort);
277
433
  const results = await Promise.all(entries.map(async (e) => ({ ...e, ok: await probeDshWeb(`http://127.0.0.1:${e.port}`, { timeoutMs, fetchImpl }) })));
278
434
  const hit = results.find((r) => r.ok);
279
435
  if (hit) {