@mrrisega/dsh-remote 0.7.3-beta.3 → 0.7.4-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dsh-bridge.mjs +206 -5
- package/clients/dsh-remote/e2ee-shim-script.js +109 -43
- package/clients/dsh-remote/mobile-adapter.mjs +454 -1
- package/clients/dsh-remote/test/bridge-file-transfer.test.mjs +281 -0
- package/clients/dsh-remote/test/e2ee-shim-retry.test.mjs +249 -0
- package/clients/dsh-remote/test/lib-e2ee-shim.mjs +1 -0
- package/clients/dsh-remote/test/mobile-adapter-resume.test.mjs +460 -0
- package/clients/dsh-remote/test/mobile-adapter-upload-hint.test.mjs +157 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +206 -5
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +109 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +454 -1
- package/packages/dsh-remote-web/test/mobile-adapter-resume.test.mjs +125 -0
|
@@ -1048,7 +1048,18 @@ const SCRIPT = `(() => {
|
|
|
1048
1048
|
compatOfficial: maCompatOfficial,
|
|
1049
1049
|
scrim: maScrimStats,
|
|
1050
1050
|
retry: maRetryStats,
|
|
1051
|
-
load: { slow: maLoadWatch.slow, apiFails: maLoadWatch.apiFails, lastApiStatus: maLoadWatch.lastApiStatus }
|
|
1051
|
+
load: { slow: maLoadWatch.slow, apiFails: maLoadWatch.apiFails, lastApiStatus: maLoadWatch.lastApiStatus },
|
|
1052
|
+
// E2EE 死会话自动恢复 / 回前台重连(2026-10-23):只作诊断,不参与任何判定。
|
|
1053
|
+
// var 提升在此处可能尚未赋值(maPublishDiag 在恢复层定义之前就会被调用一次)→ 用 typeof 兜住。
|
|
1054
|
+
resume: (typeof maResumeState !== "undefined" && maResumeState)
|
|
1055
|
+
? {
|
|
1056
|
+
armed: !!maResumeState.armed, done: !!maResumeState.done,
|
|
1057
|
+
reason: maResumeState.reason, lastAt: maResumeState.lastAt,
|
|
1058
|
+
fg: (typeof maFgState !== "undefined" && maFgState)
|
|
1059
|
+
? { count: maFgState.count, lastAt: maFgState.lastAt, src: maFgState.lastSrc, probing: !!maFgState.probing }
|
|
1060
|
+
: null
|
|
1061
|
+
}
|
|
1062
|
+
: null
|
|
1052
1063
|
};
|
|
1053
1064
|
} catch (e) { /* 诊断失败绝不影响页面 */ }
|
|
1054
1065
|
}
|
|
@@ -1704,6 +1715,445 @@ const SCRIPT = `(() => {
|
|
|
1704
1715
|
} catch (e) { /* 忽略 */ }
|
|
1705
1716
|
}, MA_LOAD_WATCH_MS);
|
|
1706
1717
|
|
|
1718
|
+
/* ============================================================================
|
|
1719
|
+
* E2EE 死会话自动恢复 + 回到前台重连(2026-10-23,iOS 断线重连体验)
|
|
1720
|
+
*
|
|
1721
|
+
* 现场(iOS 用户反馈):退出 Safari / 切到别的 App 一两分钟再回来,镜像页几乎必然重载;
|
|
1722
|
+
* 而桥端 E2EE 会话表是**进程内存**(24h TTL + CAP 32 LRU,电脑端重启即丢)—— 重载后
|
|
1723
|
+
* 常撞上「加密会话已失效,需要重新解锁」,旧行为只能回设备列表手动重新解锁。
|
|
1724
|
+
* 本段只做两件事,都**只读**状态(window.__dshE2eeBadge + dsh-e2ee-badge 事件),
|
|
1725
|
+
* 不碰任何加解密逻辑、不重建会话、不自己重连 WS:
|
|
1726
|
+
*
|
|
1727
|
+
* ① 死会话 → 自动回 /app/?resume=<deviceId>&from=mirror,由 native.html 用
|
|
1728
|
+
* 「记住本机」的派生 MK 静默解锁再回镜像页(全程无手势、无密码输入);
|
|
1729
|
+
* ② 从后台恢复(pageshow persisted / visibilitychange)→ 一次轻量探针确认隧道还在;
|
|
1730
|
+
* 失败则唤醒官方客户端(online 事件)并再探一次,仍失败只给**可见**提示
|
|
1731
|
+
* (复用底部提示条的「重试 / 返回设备列表 / 复制诊断」出口),绝不做静默无限重连。
|
|
1732
|
+
*
|
|
1733
|
+
* 触发条件极窄(业主验收口径:只有"确认 E2EE 不可用"才触发):
|
|
1734
|
+
* · 只认镜像页路径 /remote/<deviceId>/(不是镜像页直接什么都不做);
|
|
1735
|
+
* · 只认桥端明确答复的「加密会话已失效 / 需要重新解锁」;
|
|
1736
|
+
* · 纯明文(「未加密」「未解锁」「明文」)默认**绝不**打扰 —— 用户可能主动选了「暂不加密」。
|
|
1737
|
+
* 唯一例外:本页是为了甩掉死会话才被 shim 重载过(sessionStorage 里有它的重载闸门),
|
|
1738
|
+
* 且本机还留着「记住本机」的派生 MK —— 那才是"本该加密、却被自动降级成明文"。
|
|
1739
|
+
* 闸门:本页最多一次 + 60 秒冷却 + 60 秒窗口内最多 2 次(sessionStorage,跨重载仍有效)。
|
|
1740
|
+
*
|
|
1741
|
+
* 注入体纪律:本段不得出现反引号;字符串里不得出现反斜杠转义(模板字符串会先吃一层)。
|
|
1742
|
+
* ========================================================================= */
|
|
1743
|
+
|
|
1744
|
+
/* ===DSH-MA-RESUME-ENGINE-START=== */
|
|
1745
|
+
/* ===DSH-MA-RESUME-PURE-START=== */
|
|
1746
|
+
/** 镜像页设备 id:/remote/<deviceId>/… → deviceId(纯函数;不是镜像页返回空串)。
|
|
1747
|
+
* 设备 id 之后要拼进跳转地址,所以只接受 URL 安全字符,其它一律当没有。 */
|
|
1748
|
+
function maDeviceIdOfMirrorPath(pathname) {
|
|
1749
|
+
try {
|
|
1750
|
+
var parts = String(pathname || "").split("/").filter(Boolean);
|
|
1751
|
+
if (parts[0] !== "remote" || !parts[1]) return "";
|
|
1752
|
+
var id = "";
|
|
1753
|
+
try { id = decodeURIComponent(parts[1]); } catch (e) { id = ""; }
|
|
1754
|
+
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id) ? id : "";
|
|
1755
|
+
} catch (e) { return ""; }
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* 是否该自动恢复(纯函数,便于单测)。输入 E2EE 徽标状态 + 本机上下文。
|
|
1760
|
+
* @param {{mode?:string,text?:string}|null} st __dshE2eeBadge.state()
|
|
1761
|
+
* @param {{mk?:boolean,deadReloadAt?:number,now?:number}} ctx mk=有派生 MK;deadReloadAt=死会话重载闸门时间戳
|
|
1762
|
+
* @returns {string} "" 不恢复;"dead_session" 桥端明确说不认这张票;"plaintext_after_dead" 抢修降级后的明文
|
|
1763
|
+
*/
|
|
1764
|
+
function maE2eeResumeDecision(st, ctx) {
|
|
1765
|
+
try {
|
|
1766
|
+
if (!st || typeof st !== "object") return "";
|
|
1767
|
+
var mode = String(st.mode || "");
|
|
1768
|
+
var text = String(st.text || "");
|
|
1769
|
+
if (mode === "ok" || !text) return "";
|
|
1770
|
+
var c = ctx && typeof ctx === "object" ? ctx : {};
|
|
1771
|
+
/* ① 桥端明确答复:会话没了 / 需要重新解锁。这是**唯一**的确定信号。 */
|
|
1772
|
+
if (/加密会话已失效|会话已失效|需要重新解锁/.test(text)) return "dead_session";
|
|
1773
|
+
/* ② 明文/未解锁:默认绝不打扰。仅当"本页刚为甩掉死会话被重载过 + 本机有记住的 MK"才抢修 ——
|
|
1774
|
+
这两个条件同时成立时,用户本来应该是加密的,现在只是被自动降级成了明文。 */
|
|
1775
|
+
if (/未加密|未解锁|明文/.test(text)) {
|
|
1776
|
+
var reloadAt = Number(c.deadReloadAt) || 0;
|
|
1777
|
+
var now = Number(c.now) || 0;
|
|
1778
|
+
if (c.mk === true && reloadAt > 0 && now > reloadAt && now - reloadAt < 90 * 1000) {
|
|
1779
|
+
return "plaintext_after_dead";
|
|
1780
|
+
}
|
|
1781
|
+
return "";
|
|
1782
|
+
}
|
|
1783
|
+
return "";
|
|
1784
|
+
} catch (e) { return ""; }
|
|
1785
|
+
}
|
|
1786
|
+
/* ===DSH-MA-RESUME-PURE-END=== */
|
|
1787
|
+
|
|
1788
|
+
var MA_RESUME_DELAY_MS = 400; // 认出死会话后 400ms 才跳(让 shim 自己的收尾先跑完,避免两路导航抢)
|
|
1789
|
+
var MA_RESUME_COOLDOWN_MS = 60 * 1000; // 两次恢复之间的冷却
|
|
1790
|
+
var MA_RESUME_WINDOW_MS = 60 * 1000; // 时间窗
|
|
1791
|
+
var MA_RESUME_MAX_IN_WINDOW = 2; // 窗口内最多 2 次(与 native.html 闸门一致,双层兜底)
|
|
1792
|
+
var MA_RESUME_KEY = "dsh-ma-e2ee-resume";
|
|
1793
|
+
var MA_DEAD_RELOAD_KEY = "dsh-e2ee-dead-reload"; // e2ee-shim 的"每页只重载一次"闸门(只读)
|
|
1794
|
+
var MA_MK_KEY = "dsh-e2ee-mk-v1"; // native.html「记住本机」的派生 MK(只读)
|
|
1795
|
+
var MA_FG_MIN_BG_MS = 8000; // 后台停留 <8s 不算"断线场景",不打扰
|
|
1796
|
+
var MA_FG_COOLDOWN_MS = 60 * 1000; // 回前台恢复的冷却
|
|
1797
|
+
var MA_FG_MAX_PER_PAGE = 3; // 本页最多 3 次(防抖动/循环)
|
|
1798
|
+
var MA_PROBE_TIMEOUT_MS = 4000; // 探针超时
|
|
1799
|
+
var MA_PROBE_RETRY_MS = 2500; // 唤醒官方客户端后再探的间隔
|
|
1800
|
+
|
|
1801
|
+
var maResumeState = { done: false, redirecting: false, lastAt: 0, reason: "", armed: false };
|
|
1802
|
+
var maFgState = { hiddenAt: 0, lastAt: 0, count: 0, probing: false, lastSrc: "" };
|
|
1803
|
+
|
|
1804
|
+
/** 读 E2EE 徽标状态(shim 未注入 → null,全程不动作)。 */
|
|
1805
|
+
function maE2eeStateNow() {
|
|
1806
|
+
try {
|
|
1807
|
+
return window.__dshE2eeBadge && typeof window.__dshE2eeBadge.state === "function"
|
|
1808
|
+
? window.__dshE2eeBadge.state() : null;
|
|
1809
|
+
} catch (e) { return null; }
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/** 本机上下文:有没有「记住本机」的 MK、shim 是否刚为死会话重载过(都只读)。 */
|
|
1813
|
+
function maResumeContext() {
|
|
1814
|
+
var mk = false, reloadAt = 0;
|
|
1815
|
+
try { mk = !!(window.localStorage && window.localStorage.getItem(MA_MK_KEY)); } catch (e) { mk = false; }
|
|
1816
|
+
try { reloadAt = Number((window.sessionStorage && window.sessionStorage.getItem(MA_DEAD_RELOAD_KEY)) || 0) || 0; } catch (e2) { reloadAt = 0; }
|
|
1817
|
+
return { mk: mk, deadReloadAt: reloadAt, now: Date.now() };
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** 闸门:本页一次 + 冷却 + 时间窗内限次(sessionStorage 跨重载仍有效)。 */
|
|
1821
|
+
function maResumeGatePass(now) {
|
|
1822
|
+
try {
|
|
1823
|
+
if (maResumeState.done || maResumeState.redirecting) return false;
|
|
1824
|
+
if (maResumeState.lastAt && now - maResumeState.lastAt < MA_RESUME_COOLDOWN_MS) return false;
|
|
1825
|
+
var raw = null;
|
|
1826
|
+
try { raw = window.sessionStorage && window.sessionStorage.getItem(MA_RESUME_KEY); } catch (e0) { raw = null; }
|
|
1827
|
+
var hits = null;
|
|
1828
|
+
try { hits = JSON.parse(raw || "[]"); } catch (e1) { hits = []; }
|
|
1829
|
+
if (!Array.isArray(hits)) hits = [];
|
|
1830
|
+
var kept = [];
|
|
1831
|
+
for (var i = 0; i < hits.length; i++) {
|
|
1832
|
+
var t = Number(hits[i]);
|
|
1833
|
+
if (isFinite(t) && now - t >= 0 && now - t < MA_RESUME_WINDOW_MS) kept.push(t);
|
|
1834
|
+
}
|
|
1835
|
+
if (kept.length >= MA_RESUME_MAX_IN_WINDOW) return false;
|
|
1836
|
+
kept.push(now);
|
|
1837
|
+
try { window.sessionStorage && window.sessionStorage.setItem(MA_RESUME_KEY, JSON.stringify(kept.slice(-8))); } catch (e2) { /* 存不下不构成"永不恢复" */ }
|
|
1838
|
+
return true;
|
|
1839
|
+
} catch (e) { return true; }
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/** 自动回设备列表入口(由 /app/ 用「记住本机」的 MK 静默解锁后回镜像页)。 */
|
|
1843
|
+
function maE2eeTryResume(reason) {
|
|
1844
|
+
try {
|
|
1845
|
+
var dev = maDeviceIdOfMirrorPath(window.location.pathname);
|
|
1846
|
+
if (!dev) return false;
|
|
1847
|
+
var now = Date.now();
|
|
1848
|
+
if (!maResumeGatePass(now)) { maPublishDiag(); return false; }
|
|
1849
|
+
maResumeState.done = true;
|
|
1850
|
+
maResumeState.redirecting = true;
|
|
1851
|
+
maResumeState.lastAt = now;
|
|
1852
|
+
maResumeState.reason = String(reason || "");
|
|
1853
|
+
maPublishDiag();
|
|
1854
|
+
var to = "/app/?resume=" + encodeURIComponent(dev) + "&from=mirror";
|
|
1855
|
+
setTimeout(function () {
|
|
1856
|
+
try { window.location.replace(to); return; } catch (e1) { /* 继续 */ }
|
|
1857
|
+
try { window.location.assign(to); } catch (e2) { /* 继续 */ }
|
|
1858
|
+
try { window.location.href = to; } catch (e3) { /* 放弃(例如沙箱里没有 location) */ }
|
|
1859
|
+
}, MA_RESUME_DELAY_MS);
|
|
1860
|
+
return true;
|
|
1861
|
+
} catch (e) { return false; }
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function maDelay(ms) {
|
|
1865
|
+
return new Promise(function (resolve) {
|
|
1866
|
+
try { setTimeout(resolve, Number(ms) || 0); } catch (e) { resolve(); }
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/** 底部提示条的**临时**用途:回前台重连失败时给一条可见说明(复用既有重试/返回出口)。
|
|
1871
|
+
* 优先级让给首屏慢加载(那条有更高价值,别抢它的文案);用户关过一次就不再打扰。 */
|
|
1872
|
+
function maShowFgHint(text) {
|
|
1873
|
+
try {
|
|
1874
|
+
if (maLoadWatch.dismissed || maLoadWatch.slow) return;
|
|
1875
|
+
var bar = maLoadWatch.barEl || maLoadMountBar();
|
|
1876
|
+
if (!bar) return;
|
|
1877
|
+
maLoadWatch.fgHint = true;
|
|
1878
|
+
if (maLoadWatch.barTxt) maLoadWatch.barTxt.textContent = String(text || "");
|
|
1879
|
+
bar.hidden = false;
|
|
1880
|
+
} catch (e) { /* 提示失败不影响恢复本身 */ }
|
|
1881
|
+
}
|
|
1882
|
+
function maHideFgHint() {
|
|
1883
|
+
try {
|
|
1884
|
+
if (!maLoadWatch.fgHint) return;
|
|
1885
|
+
maLoadWatch.fgHint = false;
|
|
1886
|
+
maLoadHideBar();
|
|
1887
|
+
} catch (e) { /* 忽略 */ }
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/** 唤醒官方客户端:派发一次 online 事件(它自己按"网络恢复"重连)—— 只派发,不替它重连。 */
|
|
1891
|
+
function maNudgeOnline() {
|
|
1892
|
+
try {
|
|
1893
|
+
var ev = null;
|
|
1894
|
+
try { ev = new Event("online"); } catch (e0) { ev = null; }
|
|
1895
|
+
if (ev) window.dispatchEvent(ev);
|
|
1896
|
+
} catch (e) { /* 忽略 */ }
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/**
|
|
1900
|
+
* 轻量探针:**必须过桥**才算数(业主 2026-10-23 指正)。
|
|
1901
|
+
*
|
|
1902
|
+
* 为什么不能用 manifest:relay-router 的设备无关静态资源兜底(serveDeviceFreeAsset)会**本地直出**
|
|
1903
|
+
* manifest.webmanifest / favicon.svg —— 打这些路径只能证明"手机能连上中继",证明不了
|
|
1904
|
+
* "这台电脑的隧道还活着",而 iOS 回前台的真实故障恰恰是后者。
|
|
1905
|
+
*
|
|
1906
|
+
* 现在打 「HEAD /remote/<deviceId>/」:走 resolveRoute → authorizeRemote → bridge 的 http 帧 →
|
|
1907
|
+
* 电脑端 dsh 返回 200(HEAD 无 body,成本最低)。判定按业主口径:
|
|
1908
|
+
* OK = 拿到响应且 status 不是 502/504(502/504 = 隧道/上游不可达,这才是失败)
|
|
1909
|
+
* 401/403/404 都算"有回应"(cookie 过期或路径不存在不等于隧道断了,加密状态另判)
|
|
1910
|
+
* FAIL = 网络错误 / 超时 / 502 / 504
|
|
1911
|
+
* 兜底:HEAD 被中间代理/旧环境拒绝时(第一次探针 reject),第二次退化为
|
|
1912
|
+
* 「GET /remote/<deviceId>/favicon.ico」(同样过桥;不在 DEVICE_FREE_ASSETS 白名单里)。
|
|
1913
|
+
*
|
|
1914
|
+
* @param {string} deviceId
|
|
1915
|
+
* @param {boolean} [useGet] true = 走 favicon.ico 的 GET 退化路径
|
|
1916
|
+
*/
|
|
1917
|
+
function maProbeMirrorOnce(deviceId, useGet) {
|
|
1918
|
+
var tail = useGet ? "/favicon.ico" : "/";
|
|
1919
|
+
var url = "/remote/" + encodeURIComponent(deviceId) + tail + "?dshProbe=" + Date.now();
|
|
1920
|
+
var ctrl = null;
|
|
1921
|
+
try { ctrl = typeof AbortController === "function" ? new AbortController() : null; } catch (e0) { ctrl = null; }
|
|
1922
|
+
/* accept 用 json 而不是 html:router 的 isDocNavigation 在缺少 sec-fetch-* 的老浏览器上
|
|
1923
|
+
会回落到 accept 判定 —— 带上 html 会被当成"页面导航",设备离线时改走 302 引导页,
|
|
1924
|
+
探针就永远看不到 502 了(这是这条探针最容易踩的坑)。 */
|
|
1925
|
+
var opts = { method: useGet ? "GET" : "HEAD", cache: "no-store", headers: { accept: "application/json" } };
|
|
1926
|
+
if (ctrl) opts.signal = ctrl.signal;
|
|
1927
|
+
return new Promise(function (resolve) {
|
|
1928
|
+
var settled = false;
|
|
1929
|
+
var timer = 0;
|
|
1930
|
+
var done = function (r) {
|
|
1931
|
+
if (settled) return;
|
|
1932
|
+
settled = true;
|
|
1933
|
+
try { if (timer) clearTimeout(timer); } catch (e1) { /* 忽略 */ }
|
|
1934
|
+
resolve(r);
|
|
1935
|
+
};
|
|
1936
|
+
try {
|
|
1937
|
+
timer = setTimeout(function () {
|
|
1938
|
+
try { if (ctrl) ctrl.abort(); } catch (e2) { /* 忽略 */ }
|
|
1939
|
+
done({ ok: false, why: "timeout" });
|
|
1940
|
+
}, MA_PROBE_TIMEOUT_MS);
|
|
1941
|
+
} catch (e3) { /* 忽略 */ }
|
|
1942
|
+
var p = null;
|
|
1943
|
+
try { p = typeof window.fetch === "function" ? window.fetch(url, opts) : null; } catch (e4) { p = null; }
|
|
1944
|
+
if (!p || typeof p.then !== "function") { done({ ok: false, why: "nocall" }); return; }
|
|
1945
|
+
p.then(function (res) {
|
|
1946
|
+
var status = 0;
|
|
1947
|
+
try { status = Number(res && res.status) || 0; } catch (e5) { status = 0; }
|
|
1948
|
+
// 502/504 = router 或上游明确"这台的隧道不可达";其余状态码都说明"有回应"
|
|
1949
|
+
done({ ok: status > 0 && status !== 502 && status !== 504, status: status });
|
|
1950
|
+
}, function () { done({ ok: false, why: "reject" }); });
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/** 回到前台:确认加密状态 + 一次轻量探针 → 原地重连(online)→ 再探一次 → 可见提示。 */
|
|
1955
|
+
function maForegroundResume(src) {
|
|
1956
|
+
try {
|
|
1957
|
+
if (!maResumeState.armed) return;
|
|
1958
|
+
var dev = maDeviceIdOfMirrorPath(window.location.pathname);
|
|
1959
|
+
if (!dev) return;
|
|
1960
|
+
var now = Date.now();
|
|
1961
|
+
var gap = maFgState.hiddenAt ? now - maFgState.hiddenAt : 0;
|
|
1962
|
+
maFgState.hiddenAt = 0;
|
|
1963
|
+
if (gap < MA_FG_MIN_BG_MS) return; // 短切走不算断线
|
|
1964
|
+
if (maFgState.probing) return; // 不叠并发
|
|
1965
|
+
if (maFgState.count >= MA_FG_MAX_PER_PAGE) return; // 本页额度用完就不再自动碰
|
|
1966
|
+
if (maFgState.lastAt && now - maFgState.lastAt < MA_FG_COOLDOWN_MS) return;
|
|
1967
|
+
var st = maE2eeStateNow();
|
|
1968
|
+
if (!st) return; // shim 未注入(旧 bridge/关闭 E2EE)→ 加密与本次恢复无关,官方客户端自己处理
|
|
1969
|
+
var decision = maE2eeResumeDecision(st, maResumeContext());
|
|
1970
|
+
if (decision) { maE2eeTryResume(decision); return; } // 确认不可用 → 直接自动恢复(无手势)
|
|
1971
|
+
maFgState.count += 1;
|
|
1972
|
+
maFgState.lastAt = now;
|
|
1973
|
+
maFgState.probing = true;
|
|
1974
|
+
maFgState.lastSrc = String(src || "");
|
|
1975
|
+
var settle = function () { try { maFgState.probing = false; } catch (e0) { /* 忽略 */ } };
|
|
1976
|
+
maProbeMirrorOnce(dev).then(function (r1) {
|
|
1977
|
+
if (r1 && r1.ok) { maHideFgHint(); settle(); return; }
|
|
1978
|
+
maShowFgHint("网络似乎断开了,正在重新连接…");
|
|
1979
|
+
maNudgeOnline();
|
|
1980
|
+
// 第二次探针换 GET favicon.ico:既验证"隧道是否真的断了",也顺手覆盖 HEAD 不被支持的旧环境
|
|
1981
|
+
return maDelay(MA_PROBE_RETRY_MS).then(function () { return maProbeMirrorOnce(dev, true); }).then(function (r2) {
|
|
1982
|
+
if (r2 && r2.ok) { maHideFgHint(); settle(); return; }
|
|
1983
|
+
maShowFgHint("连接没有恢复。点「重试」重新加载本页,或点「返回设备列表」重新进入。");
|
|
1984
|
+
settle();
|
|
1985
|
+
});
|
|
1986
|
+
}).then(function () { settle(); }, function () { settle(); });
|
|
1987
|
+
} catch (e) { try { maFgState.probing = false; } catch (e2) { /* 忽略 */ } }
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/** 徽标状态一变就看一眼(纯读,不做别的)。 */
|
|
1991
|
+
function maE2eeOnBadge() {
|
|
1992
|
+
try {
|
|
1993
|
+
var st = maE2eeStateNow();
|
|
1994
|
+
if (!st) return;
|
|
1995
|
+
var decision = maE2eeResumeDecision(st, maResumeContext());
|
|
1996
|
+
if (decision) maE2eeTryResume(decision);
|
|
1997
|
+
} catch (e) { /* 忽略 */ }
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/** 安装监听(幂等)。**只**在镜像页 /remote/<deviceId>/ 上生效。 */
|
|
2001
|
+
function maArmE2eeResume() {
|
|
2002
|
+
try {
|
|
2003
|
+
if (maResumeState.armed) return;
|
|
2004
|
+
var dev = maDeviceIdOfMirrorPath(window.location.pathname);
|
|
2005
|
+
if (!dev) return;
|
|
2006
|
+
maResumeState.armed = true;
|
|
2007
|
+
var onBadge = function () { maE2eeOnBadge(); };
|
|
2008
|
+
try { document.addEventListener("dsh-e2ee-badge", onBadge); } catch (e1) { /* 忽略 */ }
|
|
2009
|
+
try { window.addEventListener("dsh-e2ee-badge", onBadge); } catch (e2) { /* 忽略 */ }
|
|
2010
|
+
// shim 可能在本层挂牌前就发布了状态(注入顺序:adapter 先、shim 后,通常不会;这里兜底两次)
|
|
2011
|
+
setTimeout(function () { try { maE2eeOnBadge(); } catch (e3) { /* 忽略 */ } }, 800);
|
|
2012
|
+
setTimeout(function () { try { maE2eeOnBadge(); } catch (e4) { /* 忽略 */ } }, 3000);
|
|
2013
|
+
// 回前台:bfcache 恢复走 pageshow(persisted);普通切后台走 visibilitychange
|
|
2014
|
+
try {
|
|
2015
|
+
window.addEventListener("pageshow", function (ev) {
|
|
2016
|
+
try { if (ev && ev.persisted) maForegroundResume("pageshow"); } catch (e5) { /* 忽略 */ }
|
|
2017
|
+
});
|
|
2018
|
+
} catch (e6) { /* 忽略 */ }
|
|
2019
|
+
try {
|
|
2020
|
+
document.addEventListener("visibilitychange", function () {
|
|
2021
|
+
try {
|
|
2022
|
+
if (document.visibilityState === "hidden") { maFgState.hiddenAt = Date.now(); return; }
|
|
2023
|
+
maForegroundResume("visibilitychange");
|
|
2024
|
+
} catch (e7) { /* 忽略 */ }
|
|
2025
|
+
});
|
|
2026
|
+
} catch (e8) { /* 忽略 */ }
|
|
2027
|
+
maPublishDiag();
|
|
2028
|
+
} catch (e) { /* 恢复层装不上也不能影响页面 */ }
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
try { maArmE2eeResume(); } catch (eResumeArm) { /* 忽略 */ }
|
|
2032
|
+
|
|
2033
|
+
/* ===DSH-MA-RESUME-ENGINE-END=== */
|
|
2034
|
+
|
|
2035
|
+
/* ============================================================================
|
|
2036
|
+
* 上传失败「可读提示」(2026-10-23,文件上传反馈)
|
|
2037
|
+
*
|
|
2038
|
+
* 现场(生产反馈 fb_930b950aed94 + nginx 日志 22 次 502 / 12 次 413):手机上传失败只看到
|
|
2039
|
+
* 「上传失败,点击重试」。根因在 dsh 上游客户端:dsh-client-file-upload/lib/client.js
|
|
2040
|
+
* 在 status !== 200 时直接抛错并**丢弃响应体**;而中继/桥端本轮已能给出可读原因
|
|
2041
|
+
* (>64MB 的 413 JSON、上游不可达的分类 502 JSON),这些文字却到不了用户。
|
|
2042
|
+
*
|
|
2043
|
+
* 本层只做一件事:**旁听** dsh 上传 Worker 的完成消息({kind:"complete",status,body}),
|
|
2044
|
+
* 非 200 时补一条人话提示。不改协议、不拦请求、不重试、不碰 E2EE(Worker 独立作用域,
|
|
2045
|
+
* 上传内容本来就不经 shim —— 那是另案)。
|
|
2046
|
+
* 触发面收窄:只接管 new Worker(..., { name: "dsh-file-upload" }),其它 Worker 原样返回;
|
|
2047
|
+
* 任何异常都退回原生 Worker(上传功能绝不受本层影响)。
|
|
2048
|
+
* ========================================================================= */
|
|
2049
|
+
/* ===DSH-MA-UPLOAD-HINT-PURE-START=== */
|
|
2050
|
+
/** 上传失败状态码 + 响应体 → 人话(纯函数)。空串 = 不提示(成功/交给 dsh 自己)。 */
|
|
2051
|
+
function maUploadFailureHint(status, body) {
|
|
2052
|
+
var s = Number(status) || 0;
|
|
2053
|
+
if (!s || s === 200 || s === 201) return "";
|
|
2054
|
+
var b = String(body == null ? "" : body);
|
|
2055
|
+
if (s === 413 || /payload_too_large|body_too_large|too large|exceeds? byte limit/i.test(b)) {
|
|
2056
|
+
return "上传失败:文件超过 64MB 上限,请压缩后重试,或改用电脑端上传。";
|
|
2057
|
+
}
|
|
2058
|
+
if (s === 502 || s === 504) return "上传失败:与电脑端的连接中断了。请重试;反复失败请确认电脑端 DSH 仍在运行。";
|
|
2059
|
+
if (s === 401 || s === 403) return "上传失败:登录状态已过期,请返回设备列表重新进入后再试。";
|
|
2060
|
+
var m = null;
|
|
2061
|
+
try { m = JSON.parse(b); } catch (e) { m = null; }
|
|
2062
|
+
var msg = "";
|
|
2063
|
+
try { if (m && m.error) msg = String(m.error.message || m.error.code || ""); } catch (e2) { msg = ""; }
|
|
2064
|
+
return "上传失败(HTTP " + s + ")" + (msg ? ":" + msg : ",请重试。");
|
|
2065
|
+
}
|
|
2066
|
+
/* ===DSH-MA-UPLOAD-HINT-PURE-END=== */
|
|
2067
|
+
|
|
2068
|
+
/* ===DSH-MA-UPLOAD-HINT-ENGINE-START=== */
|
|
2069
|
+
var MA_UPLOAD_FAIL_KEY = "dsh-ma-upload-fail";
|
|
2070
|
+
/** 底部一次性提示(自绘、6 秒自动消失、不挡点击;同一批失败只提示一次)。 */
|
|
2071
|
+
function maUploadToast(text) {
|
|
2072
|
+
try {
|
|
2073
|
+
var now = Date.now();
|
|
2074
|
+
var last = 0;
|
|
2075
|
+
try { last = Number(sessionStorage.getItem(MA_UPLOAD_FAIL_KEY)) || 0; } catch (eLast) { last = 0; }
|
|
2076
|
+
if (last && now - last < 4000) return;
|
|
2077
|
+
try { sessionStorage.setItem(MA_UPLOAD_FAIL_KEY, String(now)); } catch (eSet) { /* 忽略 */ }
|
|
2078
|
+
var doc = document;
|
|
2079
|
+
var el = doc.getElementById("dsh-ma-upload-toast");
|
|
2080
|
+
if (el && el.parentNode) el.parentNode.removeChild(el);
|
|
2081
|
+
el = doc.createElement("div");
|
|
2082
|
+
el.id = "dsh-ma-upload-toast";
|
|
2083
|
+
el.setAttribute("role", "status");
|
|
2084
|
+
el.textContent = String(text || "");
|
|
2085
|
+
el.style.cssText = "position:fixed;left:12px;right:12px;bottom:calc(18px + env(safe-area-inset-bottom));z-index:2147483001;"
|
|
2086
|
+
+ "padding:10px 14px;border-radius:12px;background:rgba(32,32,32,.94);color:#fff;"
|
|
2087
|
+
+ "font:13px/1.5 -apple-system,BlinkMacSystemFont,'PingFang SC','Segoe UI',Roboto,sans-serif;"
|
|
2088
|
+
+ "box-shadow:0 6px 24px rgba(0,0,0,.35);pointer-events:none;opacity:0;transition:opacity .2s ease;";
|
|
2089
|
+
(doc.body || doc.documentElement).appendChild(el);
|
|
2090
|
+
setTimeout(function () { try { el.style.opacity = "1"; } catch (eF) { /* 忽略 */ } }, 16);
|
|
2091
|
+
setTimeout(function () {
|
|
2092
|
+
try { el.style.opacity = "0"; } catch (eO) { /* 忽略 */ }
|
|
2093
|
+
setTimeout(function () { try { if (el.parentNode) el.parentNode.removeChild(el); } catch (eR) { /* 忽略 */ } }, 260);
|
|
2094
|
+
}, 6000);
|
|
2095
|
+
} catch (e) { /* 提示失败不影响上传本身 */ }
|
|
2096
|
+
}
|
|
2097
|
+
/** 旁听上传 Worker:只包 onmessage/onerror,其余成员透传(方法绑定到原生对象)。 */
|
|
2098
|
+
function maTapUploadWorker(w) {
|
|
2099
|
+
var proxy = null;
|
|
2100
|
+
var userMsg = null;
|
|
2101
|
+
var userErr = null;
|
|
2102
|
+
var onMsg = function (ev) {
|
|
2103
|
+
try {
|
|
2104
|
+
var d = ev && ev.data;
|
|
2105
|
+
if (d && d.kind === "complete") {
|
|
2106
|
+
var hint = maUploadFailureHint(d.status, d.body);
|
|
2107
|
+
if (hint) maUploadToast(hint);
|
|
2108
|
+
}
|
|
2109
|
+
} catch (eObs) { /* 忽略 */ }
|
|
2110
|
+
if (typeof userMsg === "function") return userMsg.call(proxy, ev);
|
|
2111
|
+
return undefined;
|
|
2112
|
+
};
|
|
2113
|
+
var onErr = function (ev) {
|
|
2114
|
+
if (typeof userErr === "function") return userErr.call(proxy, ev);
|
|
2115
|
+
return undefined;
|
|
2116
|
+
};
|
|
2117
|
+
proxy = new Proxy(w, {
|
|
2118
|
+
get: function (t, k) {
|
|
2119
|
+
if (k === "onmessage") return userMsg || undefined;
|
|
2120
|
+
if (k === "onerror") return userErr || undefined;
|
|
2121
|
+
var v = t[k];
|
|
2122
|
+
return typeof v === "function" ? v.bind(t) : v;
|
|
2123
|
+
},
|
|
2124
|
+
set: function (t, k, v) {
|
|
2125
|
+
if (k === "onmessage") { userMsg = v; t.onmessage = onMsg; return true; }
|
|
2126
|
+
if (k === "onerror") { userErr = v; t.onerror = onErr; return true; }
|
|
2127
|
+
t[k] = v;
|
|
2128
|
+
return true;
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
2131
|
+
return proxy;
|
|
2132
|
+
}
|
|
2133
|
+
/** 安装(幂等、可在看门狗里重复调用):只接管 name=dsh-file-upload 的 Worker。 */
|
|
2134
|
+
function maInstallUploadTap() {
|
|
2135
|
+
try {
|
|
2136
|
+
if (typeof window === "undefined" || typeof window.Worker !== "function") return false;
|
|
2137
|
+
var Native = window.Worker;
|
|
2138
|
+
if (Native && Native.__dshMaUploadTap) return true;
|
|
2139
|
+
var Wrapped = new Proxy(Native, {
|
|
2140
|
+
construct: function (Target, args) {
|
|
2141
|
+
var w = Reflect.construct(Target, args);
|
|
2142
|
+
try {
|
|
2143
|
+
var opts = args && args[1];
|
|
2144
|
+
var name = opts && typeof opts === "object" ? String(opts.name || "") : "";
|
|
2145
|
+
if (name !== "dsh-file-upload") return w;
|
|
2146
|
+
return maTapUploadWorker(w);
|
|
2147
|
+
} catch (eTap) { return w; }
|
|
2148
|
+
}
|
|
2149
|
+
});
|
|
2150
|
+
try { Wrapped.__dshMaUploadTap = true; } catch (eMark) { /* 忽略 */ }
|
|
2151
|
+
window.Worker = Wrapped;
|
|
2152
|
+
return true;
|
|
2153
|
+
} catch (e) { return false; }
|
|
2154
|
+
}
|
|
2155
|
+
/* ===DSH-MA-UPLOAD-HINT-ENGINE-END=== */
|
|
2156
|
+
|
|
1707
2157
|
try {
|
|
1708
2158
|
const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
|
|
1709
2159
|
const NARROW = () => window.innerWidth <= 820;
|
|
@@ -2787,6 +3237,8 @@ const SCRIPT = `(() => {
|
|
|
2787
3237
|
旧实现把包装写在 buildRemoteMenu 内部的一次性分支里,那正是"请求明明在打、
|
|
2788
3238
|
界面却永远测速中"的结构性原因。 */
|
|
2789
3239
|
try { maInstallNetSampler(); } catch (eSampler0) { /* 忽略 */ }
|
|
3240
|
+
/* 上传失败可读提示(2026-10-23):与采样器同样"越早越好",且幂等 —— 看门狗每拍调用无副作用。 */
|
|
3241
|
+
try { maInstallUploadTap(); } catch (eUploadTap0) { /* 忽略 */ }
|
|
2790
3242
|
/* 悬浮按钮/菜单:任何宽度都要有(电脑端访问镜像页时同样需要返回设备列表的出口)。
|
|
2791
3243
|
失败也不影响手机端布局适配 —— 两条链路各自独立。 */
|
|
2792
3244
|
if (!fabDone) { fabDone = true; try { buildRemoteMenu(); } catch (eFabDone) { /* 忽略 */ } }
|
|
@@ -3285,6 +3737,7 @@ const SCRIPT = `(() => {
|
|
|
3285
3737
|
官方若在运行期替换 fetch 同理),下一拍就会把采样器重新挂回去 —— 幂等,已挂上时零开销。
|
|
3286
3738
|
同时顺手重绘一次:官方 SPA 换掉菜单子树后,数字不必等下一次请求才回来。 */
|
|
3287
3739
|
try { maInstallNetSampler(); } catch (eSampler) { /* 忽略 */ }
|
|
3740
|
+
try { maInstallUploadTap(); } catch (eUploadTap) { /* 忽略 */ }
|
|
3288
3741
|
try { if (maNetRender) maNetRender(); } catch (eNetRender) { /* 忽略 */ }
|
|
3289
3742
|
try { ensureDrawerChrome(); } catch (eChrome2) { /* 忽略 */ }
|
|
3290
3743
|
/* 镜像页就绪探测(mirror_load 的量测终点):pump 本来就被 MutationObserver(子树) + 1s 兜底表
|