dsh-remote-web 0.7.11-beta.3 → 0.7.12-beta.2

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.
@@ -979,7 +979,12 @@ const SCRIPT = `(() => {
979
979
  // 2026-10-20(C4):镜像页里**唯一**可靠的"电脑端不可达"信号 ——
980
980
  // 受保护的 /api/* 请求被回 5xx 或直接网络错误(presence 不在镜像页这条链路上;
981
981
  // E2EE 死会话的 502 另带 x-dsh-e2ee-error,不算离线,见 maInstallNetSampler)。
982
- apiFails: 0, apiFailAt: 0, lastApiStatus: 0 };
982
+ apiFails: 0, apiFailAt: 0, lastApiStatus: 0,
983
+ // 2026-11-08(W3):active-time 口径。挂起(锁屏/切 App/bfcache/进程冻结)的时长
984
+ // 记进 hiddenMs,不再冒充"已等待";startedAt 仍不重置(老浏览器兜底仍按它算)。
985
+ hiddenMs: 0, hiddenAt: 0, lastBeatAt: 0, resumeCount: 0,
986
+ progress: false, finished: false, checks: 0,
987
+ beatTimer: 0, checkTimer: 0 };
983
988
 
984
989
  /* ================= 首屏性能自测(2026-10-2x:只写诊断,**不上报**)=================
985
990
  背景:手机镜像页首屏 p50 3.9s / p90 21.6s、20.2% 打开 ≥12s(生产遥测 n=8903,事件名 mirror_load)。
@@ -1216,6 +1221,18 @@ const SCRIPT = `(() => {
1216
1221
  * "电脑端可能未连接",与代码里"宁可晚一点也别误报"的意图正好相反。20 秒在 p90 之上留了余量,
1217
1222
  * 又远低于用户无法忍耐的 40 秒级。测试按常量断言,不要散落魔法数。 */
1218
1223
  var MA_LOAD_WATCH_MS = 20 * 1000;
1224
+ /* 2026-11-08(W3)active-time 口径的三个补充常量(20s 仍是"可操作故障条"的升级点):
1225
+ · 12s(活跃时间)→ 中性进度提示"正在准备界面…",让用户前 20s 不再面对空白;
1226
+ · 20s(活跃时间)之后每 20s 复检一次(更新秒数 + 复核就绪 + 触发补偿),不是只判一次;
1227
+ · 60s(活跃时间)兜底样本:万一任何一路都没报过,补一条 slow,避免样本丢失。
1228
+ 心跳 1s;漂移 >3× 期望即判定"被冻结/挂起"(iOS 进程冻结时 visibilitychange 可能不触发)。 */
1229
+ var MA_LOAD_PROGRESS_MS = 12 * 1000;
1230
+ var MA_LOAD_RECHECK_MS = 20 * 1000;
1231
+ var MA_LOAD_FALLBACK_MS = 60 * 1000;
1232
+ var MA_LOAD_BEAT_MS = 1000;
1233
+ var MA_LOAD_BEAT_GAP_MS = 3000;
1234
+ var MA_LOAD_MAX_CHECKS = 30; // 复检上限(≈10 分钟);之后只靠恢复钩子/条内 1s 刷新
1235
+ var MA_RESUME_COMPENSATE_MAX = 2; // 每次打开页面最多补偿重建 2 个 <link>(不重建 <script>)
1219
1236
 
1220
1237
  /* ===== C4:首屏关键资源失败 → 自动重试(2026-10-20 诊断,03 报告 §2.3) =====
1221
1238
  现场:文档 / assets/*.js / plugins 聚合包只要失败一次就**永远卡住** —— 旧实现只弹
@@ -1243,7 +1260,10 @@ const SCRIPT = `(() => {
1243
1260
  pageTimer: 0, // 排队中的整页重试
1244
1261
  lastRetryClickAt: 0,
1245
1262
  manual: false, // 用户点过「重试」→ 不再自动排队(避免刚重载又自己重载)
1246
- exhausted: false
1263
+ exhausted: false,
1264
+ // 2026-11-08(W3):恢复后补偿重建的次数/最后一次失败(诊断串用)。
1265
+ resumeBonus: 0,
1266
+ lastFail: null
1247
1267
  };
1248
1268
  var maRetryTimers = []; // 排队中的就地重试(「重试」按钮先清掉它们)
1249
1269
  var maInflight = []; // 我们跟踪到的在途 fetch(AbortController)——「重试」前取消,避免 499
@@ -1261,7 +1281,11 @@ const SCRIPT = `(() => {
1261
1281
  compatOfficial: maCompatOfficial,
1262
1282
  scrim: maScrimStats,
1263
1283
  retry: maRetryStats,
1264
- load: { slow: maLoadWatch.slow, apiFails: maLoadWatch.apiFails, lastApiStatus: maLoadWatch.lastApiStatus },
1284
+ load: { slow: maLoadWatch.slow, progress: maLoadWatch.progress, apiFails: maLoadWatch.apiFails,
1285
+ lastApiStatus: maLoadWatch.lastApiStatus,
1286
+ activeMs: (typeof maLoadActiveMs === "function") ? maLoadActiveMs() : -1,
1287
+ hiddenMs: (typeof maLoadHiddenMs === "function") ? maLoadHiddenMs() : -1,
1288
+ resumes: maLoadWatch.resumeCount || 0 },
1265
1289
  // 首屏性能自测(2026-10-2x):只读、只在本对象里(**不上报**,见上方 maPerf 的说明)。
1266
1290
  perf: (typeof maPerfSnapshot === "function") ? maPerfSnapshot() : null,
1267
1291
  // E2EE 死会话自动恢复 / 回前台重连(2026-10-23):只作诊断,不参与任何判定。
@@ -1418,6 +1442,8 @@ const SCRIPT = `(() => {
1418
1442
  var n = maRetry.perUrl[key] || 0;
1419
1443
  maRetry.resFails.push({ url: key, tag: String(tag || ""), at: Date.now() });
1420
1444
  if (maRetry.resFails.length > 12) maRetry.resFails.shift();
1445
+ /* 2026-11-08(W3):最后一次失败单独留一份(跟 resFails 的滚动窗口无关),诊断串用。 */
1446
+ try { maRetry.lastFail = { url: key, tag: String(tag || ""), at: Date.now() }; } catch (eLF) { /* 忽略 */ }
1421
1447
  /* 界面已经就绪 = 这次失败没有卡住首屏。此时重建元素可能让模块/插件**再执行一遍**
1422
1448
  (官方模块加载器是队列式),风险大于收益 —— 只记录、不重试。 */
1423
1449
  if (maLoadReadyNow()) return false;
@@ -1504,10 +1530,11 @@ const SCRIPT = `(() => {
1504
1530
  /** 底部提示条"原因分档":offline(电脑端不可达)/ resource(首屏资源失败)/ slow(网络慢)。 */
1505
1531
  function maLoadReason() {
1506
1532
  try {
1507
- var now = Date.now();
1508
1533
  /* 10 秒内的不可达证据才算:过期的失败不能把文案一直钉在"未连接"上(那正是 03 报告
1509
- 说的"文案归因错误"——截图 09 面板同时显示 95ms 顺畅)。 */
1510
- if ((maLoadWatch.apiFails || 0) > 0 && now - (maLoadWatch.apiFailAt || 0) < 10000) return "offline";
1534
+ 说的"文案归因错误"——截图 09 面板同时显示 95ms 顺畅)。
1535
+ W3:窗口改用**活跃时间**——挂起 8 分钟回来不该把"10 秒内有过 5xx"直接判成过期。 */
1536
+ var act = (typeof maLoadActiveMs === "function") ? maLoadActiveMs() : Date.now();
1537
+ if ((maLoadWatch.apiFails || 0) > 0 && act - (maLoadWatch.apiFailAt || 0) < 10000) return "offline";
1511
1538
  if (maRetry.resFails.length > 0) return "resource";
1512
1539
  } catch (e) { /* 落到 slow */ }
1513
1540
  return "slow";
@@ -1531,7 +1558,8 @@ const SCRIPT = `(() => {
1531
1558
  function maNotePresenceFail(status) {
1532
1559
  try {
1533
1560
  maLoadWatch.apiFails = (maLoadWatch.apiFails || 0) + 1;
1534
- maLoadWatch.apiFailAt = Date.now();
1561
+ /* W3:记录"活跃时间"而不是墙钟——见 maLoadReason 的窗口说明。 */
1562
+ maLoadWatch.apiFailAt = (typeof maLoadActiveMs === "function") ? maLoadActiveMs() : Date.now();
1535
1563
  maLoadWatch.lastApiStatus = Number(status) || 0;
1536
1564
  maPublishDiag();
1537
1565
  if (maLoadWatch.slow) maLoadRenderBar(); // 提示条已经出来了 → 立刻按新原因换文案
@@ -1659,8 +1687,171 @@ const SCRIPT = `(() => {
1659
1687
  return Boolean(document.querySelector('[data-composer-input], [contenteditable="true"], [role="textbox"], textarea'));
1660
1688
  } catch (e) { return false; }
1661
1689
  }
1690
+ /* ===== W3(2026-11-08):active-time 口径 + 恢复即补偿 =====
1691
+ 事故:页面 13:43:51 注入 → 13:44:06 传完最后一个插件包后被 iOS 冻结 → 13:52:44 恢复瞬间
1692
+ 20s 看门狗才执行,报 slow(541.664s)、提示条显示"已等待 533 秒"(其中 518s 是挂起)。
1693
+ 现在:挂起时长记 hiddenMs,看门狗/文案/上报一律用 activeMs;恢复时补判就绪 + 补扫启动失败 +
1694
+ 限量补偿重建(只 link);startedAt 仍不重置(老浏览器兜底与"墙钟"诊断仍按它算)。
1695
+ 所有入口都 try/catch:计时/恢复逻辑坏掉也绝不影响页面。 */
1696
+ /** 已挂起时长(毫秒):可见性切换/心跳冻结检测的累计值;未结算的 open gap 也计入。 */
1697
+ function maLoadHiddenMs() {
1698
+ try {
1699
+ var h = maLoadWatch.hiddenMs || 0;
1700
+ var a = maLoadWatch.hiddenAt || 0;
1701
+ if (a > 0) h += Math.max(0, Date.now() - a);
1702
+ return Math.max(0, Math.round(h));
1703
+ } catch (e) { return 0; }
1704
+ }
1705
+ /** 活跃加载毫秒 = 墙钟耗时(performance.now,起点≈导航开始)− 挂起时长。 */
1706
+ function maLoadActiveMs() {
1707
+ try { return Math.max(0, Math.round(maLoadElapsedMs() - maLoadHiddenMs())); } catch (e) { return 0; }
1708
+ }
1709
+ /** 已等待秒数(活跃时间;挂起不计入)。 */
1662
1710
  function maLoadWaitedSec() {
1663
- return Math.max(0, Math.round((Date.now() - maLoadWatch.startedAt) / 1000));
1711
+ return Math.max(0, Math.round(maLoadActiveMs() / 1000));
1712
+ }
1713
+ /** 标记"开始挂起"(可见性变隐藏 / pagehide);幂等,避免 visibilitychange 与 pagehide 重复记账。 */
1714
+ function maLoadMarkHidden(now) {
1715
+ try {
1716
+ var t = Number(now) || Date.now();
1717
+ if (!(maLoadWatch.hiddenAt > 0)) maLoadWatch.hiddenAt = t;
1718
+ maLoadWatch.lastBeatAt = 0;
1719
+ } catch (e) { /* 忽略 */ }
1720
+ }
1721
+ /** 结算一段挂起(恢复时):hiddenMs 累加、hiddenAt 清零、心跳基准对齐到恢复时刻。 */
1722
+ function maLoadSettleHidden(now) {
1723
+ try {
1724
+ var t = Number(now) || Date.now();
1725
+ if (maLoadWatch.hiddenAt > 0) {
1726
+ maLoadWatch.hiddenMs = (maLoadWatch.hiddenMs || 0) + Math.max(0, t - maLoadWatch.hiddenAt);
1727
+ maLoadWatch.hiddenAt = 0;
1728
+ try { if (typeof maPublishDiag === "function") maPublishDiag(); } catch (ePub2) { /* 忽略 */ }
1729
+ }
1730
+ maLoadWatch.lastBeatAt = t;
1731
+ } catch (e) { /* 忽略 */ }
1732
+ }
1733
+ /**
1734
+ * 1s 心跳(第二道保险):进程被冻结后恢复时 heartbeat 间隔会远大于期望,而 visibilitychange
1735
+ * 可能根本没触发(iOS 锁屏/被系统回收时)。把 gap 记进"挂起",保证 activeMs 不会把冻结时长
1736
+ * 算成"已等待"。已结算过的挂起不会重复扣(hiddenAt 清零 + 心跳基准对齐)。
1737
+ */
1738
+ function maLoadBeat() {
1739
+ try {
1740
+ var now = Date.now();
1741
+ var last = maLoadWatch.lastBeatAt || 0;
1742
+ if (last > 0 && now > last) {
1743
+ var gap = now - last;
1744
+ if (gap > MA_LOAD_BEAT_GAP_MS) {
1745
+ if (maLoadWatch.hiddenAt > 0) {
1746
+ maLoadWatch.hiddenMs = (maLoadWatch.hiddenMs || 0) + Math.max(0, now - maLoadWatch.hiddenAt);
1747
+ maLoadWatch.hiddenAt = 0;
1748
+ } else {
1749
+ maLoadWatch.hiddenMs = (maLoadWatch.hiddenMs || 0) + Math.max(0, gap - MA_LOAD_BEAT_MS);
1750
+ }
1751
+ /* 冻结被心跳识别出来时立刻更新诊断(这是低频事件,不会影响每秒开销)。 */
1752
+ try { if (typeof maPublishDiag === "function") maPublishDiag(); } catch (ePub) { /* 忽略 */ }
1753
+ }
1754
+ }
1755
+ maLoadWatch.lastBeatAt = now;
1756
+ } catch (e) { /* 计时异常绝不影响页面 */ }
1757
+ }
1758
+ /** 加载收尾(就绪):收提示条、停心跳/复检、按契约上报一次 ok(reported 语义不变)。 */
1759
+ function maLoadFinish() {
1760
+ try {
1761
+ if (maLoadWatch.finished) return;
1762
+ maLoadWatch.finished = true;
1763
+ maLoadWatch.progress = false;
1764
+ maLoadWatch.slow = false;
1765
+ try { maLoadHideBar(); } catch (e1) { /* 忽略 */ }
1766
+ try { maLoadRenderWarn(); } catch (e2) { /* 忽略 */ }
1767
+ try { if (maLoadWatch.checkTimer) { clearTimeout(maLoadWatch.checkTimer); maLoadWatch.checkTimer = 0; } } catch (e3) { /* 忽略 */ }
1768
+ try { if (maLoadWatch.beatTimer) { clearInterval(maLoadWatch.beatTimer); maLoadWatch.beatTimer = 0; } } catch (e4) { /* 忽略 */ }
1769
+ maReportMirrorLoad("ok");
1770
+ } catch (e) { /* 忽略 */ }
1771
+ }
1772
+ /** 排下一次复检(去重 + 上限;active-time 的里程碑由 maLoadCheck 自己判断)。 */
1773
+ function maLoadArmRecheck(ms) {
1774
+ try {
1775
+ if (maLoadWatch.finished || maLoadWatch.checkTimer) return;
1776
+ if ((maLoadWatch.checks || 0) >= MA_LOAD_MAX_CHECKS) return;
1777
+ var delay = Number(ms);
1778
+ if (!isFinite(delay) || delay < 1000) delay = MA_LOAD_RECHECK_MS;
1779
+ maLoadWatch.checkTimer = maSchedule(function () {
1780
+ maLoadWatch.checkTimer = 0;
1781
+ try { maLoadCheck("recheck"); } catch (eC) { /* 忽略 */ }
1782
+ }, delay);
1783
+ } catch (e) { /* 忽略 */ }
1784
+ }
1785
+ /**
1786
+ * 看门狗主判定(active-time,2026-11-08 W3):
1787
+ * · 就绪 → 收尾(只报一次,ok);
1788
+ * · 活跃 ≥12s → 中性进度条「正在准备界面…」(不再让用户前 20s 面对空白);
1789
+ * · 活跃 ≥20s → 升级为现有可操作故障条 + 上报 mirror_load(slow);
1790
+ * · 之后每 20s 活跃时间复检(更新秒数 / 复核就绪 / 给恢复补偿留机会)。
1791
+ * 回调可能在"冻结恢复瞬间"才真正执行 —— 一律用 maLoadActiveMs() 复核,不拿墙钟吓人。
1792
+ */
1793
+ function maLoadCheck(why) {
1794
+ try {
1795
+ if (maLoadWatch.finished) return true;
1796
+ maLoadWatch.checks = (maLoadWatch.checks || 0) + 1;
1797
+ maLoadBeat();
1798
+ if (maLoadReadyNow()) { maLoadFinish(); return true; }
1799
+ var active = maLoadActiveMs();
1800
+ if (!maLoadWatch.progress && active >= MA_LOAD_PROGRESS_MS) {
1801
+ maLoadWatch.progress = true;
1802
+ try { maLoadRenderBar(); } catch (eP) { /* 忽略 */ }
1803
+ }
1804
+ if (!maLoadWatch.slow && active >= MA_LOAD_WATCH_MS) {
1805
+ maLoadWatch.slow = true;
1806
+ try { maLoadRenderBar(); } catch (eS) { /* 忽略 */ }
1807
+ maReportMirrorLoad("slow");
1808
+ }
1809
+ maLoadArmRecheck(MA_LOAD_RECHECK_MS);
1810
+ return false;
1811
+ } catch (e) { return false; }
1812
+ }
1813
+ /**
1814
+ * 恢复即补偿:只重建 <link>(样式 / manifest)。
1815
+ * <script> **不重建** —— 官方模块加载器是 queue 模式,克隆后再执行一次插件有重复注册风险;
1816
+ * 脚本的恢复交给「补扫启动失败文案 → 整页受控重试」那条既有通道(额度不变)。
1817
+ */
1818
+ function maLoadCompensate() {
1819
+ try {
1820
+ if (maRetry.manual) return false; // 用户点过「重试」→ 我们不替他再自愈
1821
+ if (maLoadWatch.finished) return false;
1822
+ var used = Number(maRetry.resumeBonus || 0);
1823
+ var remain = MA_RESUME_COMPENSATE_MAX - used;
1824
+ if (!(remain > 0)) return false;
1825
+ var list = maRetry.resFails || [];
1826
+ var seen = {};
1827
+ var done = 0;
1828
+ for (var i = list.length - 1; i >= 0 && done < remain; i--) {
1829
+ var f = list[i];
1830
+ if (!f || !f.url) continue;
1831
+ if (String(f.tag || "").toLowerCase() !== "link") continue;
1832
+ if (seen[f.url]) continue;
1833
+ seen[f.url] = 1;
1834
+ try { if (maRetryBootResource(null, f.url, "link")) done += 1; } catch (eB) { /* 忽略 */ }
1835
+ }
1836
+ if (done > 0) {
1837
+ maRetry.resumeBonus = used + done;
1838
+ maBumpRetryStats("resume:link");
1839
+ return true;
1840
+ }
1841
+ return false;
1842
+ } catch (e) { return false; }
1843
+ }
1844
+ /** 从后台 / bfcache / 冻结恢复:补判就绪 → 补扫启动失败文案 → 限量补偿重建 → 排复检。 */
1845
+ function maLoadOnResume(src) {
1846
+ try {
1847
+ maLoadWatch.resumeCount = (maLoadWatch.resumeCount || 0) + 1;
1848
+ maLoadBeat();
1849
+ if (maLoadCheck("resume")) return;
1850
+ try { maScanBootFailure(); } catch (eScan) { /* 忽略 */ }
1851
+ maLoadCompensate();
1852
+ if (!maLoadWatch.finished) { try { maLoadRenderBar(); } catch (eBar) { /* 忽略 */ } }
1853
+ maLoadArmRecheck(MA_LOAD_RECHECK_MS);
1854
+ } catch (e) { /* 恢复逻辑异常绝不影响页面 */ }
1664
1855
  }
1665
1856
  /**
1666
1857
  * 单调时钟(performance.now;不可用时退回 Date.now)。
@@ -1714,11 +1905,17 @@ const SCRIPT = `(() => {
1714
1905
  if (!isFinite(ms) || ms < 0) ms = 0;
1715
1906
  if (ms > 600000) ms = 600000;
1716
1907
  maLoadWatch.reportMs = ms;
1717
- /* 首屏真正可用的时刻也进本地诊断(与上报给服务端的 mirror_load.ms 是同一个数)。
1718
- 上报体**一个字都不改**:服务端契约只看 { ms, outcome, at }。 */
1908
+ /* W3:把 active/hidden 一并带出去(服务端 task-5 落列后即可区分"真慢"与"被挂起")。
1909
+ outcome 仍只发 ok|slow —— 插件半的 telemetryEventOf 会把非 slow 一律归一成 ok
1910
+ (packages/dsh-remote-web/lib/index.js:6784),所以"suspended"由服务端用 hidden_ms 派生。 */
1911
+ var activeMs = 0, hiddenMs = 0;
1912
+ try { activeMs = Math.max(0, Math.round(maLoadActiveMs())); } catch (eAct) { activeMs = 0; }
1913
+ try { hiddenMs = Math.max(0, Math.round(maLoadHiddenMs())); } catch (eHid) { hiddenMs = 0; }
1914
+ /* 首屏真正可用的时刻也进本地诊断(与上报给服务端的 mirror_load.ms 是同一个数)。 */
1719
1915
  try { maPerf.uiReadyMs = ms; } catch (ePerfUi) { /* 忽略 */ }
1720
1916
  try { maPublishDiag(); } catch (ePerfPub) { /* 忽略 */ }
1721
- var body = JSON.stringify({ ms: ms, outcome: outcome === "slow" ? "slow" : "ok", at: Date.now() });
1917
+ var body = JSON.stringify({ ms: ms, outcome: outcome === "slow" ? "slow" : "ok", at: Date.now(),
1918
+ active_ms: activeMs, hidden_ms: hiddenMs });
1722
1919
  var p = null;
1723
1920
  try {
1724
1921
  p = fetch("/dsh-remote/telemetry/mirror-load", {
@@ -1732,14 +1929,70 @@ const SCRIPT = `(() => {
1732
1929
  if (p && typeof p.catch === "function") p.catch(function () { /* 忽略 */ });
1733
1930
  } catch (e) { /* 遥测绝不把页面拖挂 */ }
1734
1931
  }
1735
- /** 给客服看的一句话(含等待时长与 UA,便于直接定位)。 */
1932
+ /** W3 诊断辅助:把失败 URL 缩成"路径尾段"(剥 query/hash,≤60 字符,中文省略号)。 */
1933
+ function maLoadShortUrl(url) {
1934
+ try {
1935
+ var s = String(url || "");
1936
+ var q = s.indexOf("?");
1937
+ if (q >= 0) s = s.slice(0, q);
1938
+ var h = s.indexOf("#");
1939
+ if (h >= 0) s = s.slice(0, h);
1940
+ if (s.length > 60) s = "…" + s.slice(s.length - 59);
1941
+ return s;
1942
+ } catch (e) { return ""; }
1943
+ }
1944
+ /** W3 诊断辅助:首屏失败按 tag 拆分(link / script),供"页/元素"分档与归因。 */
1945
+ function maLoadFailCounts() {
1946
+ var out = { link: 0, script: 0 };
1947
+ try {
1948
+ var list = maRetry.resFails || [];
1949
+ for (var i = 0; i < list.length; i++) {
1950
+ var tag = String((list[i] || {}).tag || "").toLowerCase();
1951
+ if (tag === "link") out.link += 1;
1952
+ else if (tag === "script") out.script += 1;
1953
+ }
1954
+ } catch (e) { /* 忽略 */ }
1955
+ return out;
1956
+ }
1957
+ /** 给客服看的一句话(含等待时长与 UA,便于直接定位)。W3:补 active/hidden/wall 三时长、
1958
+ * 页/元素重试拆分、exhausted、恢复补偿次数、最后失败 URL+tag、apiFails 状态;单行 ≤400 字符。 */
1736
1959
  function maLoadCopyLine() {
1737
1960
  let ua = "";
1738
1961
  try { ua = String((navigator && navigator.userAgent) || "").slice(0, 120); } catch (e) { /* 忽略 */ }
1739
- return "dsh-remote 手机端加载疑似卡住:等待 " + maLoadWaitedSec() + " 秒后仍未出现对话界面。"
1740
- + "原因=" + maLoadReason() + ",首屏资源失败=" + maRetry.resFails.length + ",自动重试="
1741
- + (maRetry.page + maRetry.resRetries) + " 次;scrim 拦截=" + maScrimStats.activations
1742
- + " 次/不变量摘除=" + maScrimStats.invariantHits + " 次。UA=" + ua;
1962
+ var activeSec = maLoadWaitedSec();
1963
+ var hiddenSec = Math.round(maLoadHiddenMs() / 1000);
1964
+ var wallSec = Math.round(maLoadElapsedMs() / 1000);
1965
+ var cnt = maLoadFailCounts();
1966
+ var lastTxt = "-";
1967
+ try {
1968
+ if (maRetry.lastFail && maRetry.lastFail.url) {
1969
+ lastTxt = maLoadShortUrl(maRetry.lastFail.url) + " " + String(maRetry.lastFail.tag || "");
1970
+ }
1971
+ } catch (eLast) { lastTxt = "-"; }
1972
+ var line = "dsh-remote 手机端加载疑似卡住:等待 " + activeSec + " 秒"
1973
+ + (hiddenSec > 0 ? "(期间页面被系统挂起 " + hiddenSec + " 秒,墙钟 " + wallSec + " 秒)" : "")
1974
+ + "后仍未出现对话界面。原因=" + maLoadReason()
1975
+ + ",首屏资源失败=" + maRetry.resFails.length
1976
+ + "(link " + cnt.link + "/script " + cnt.script + ";最后 " + lastTxt + ")"
1977
+ + ",自动重试=页" + maRetry.page + "/元素" + maRetry.resRetries
1978
+ + "(已用尽=" + (maRetry.exhausted ? "是" : "否") + ",恢复补偿=" + (maRetry.resumeBonus || 0) + ")"
1979
+ + ";scrim 拦截=" + maScrimStats.activations + " 次/不变量摘除=" + maScrimStats.invariantHits + " 次"
1980
+ + ";api失败=" + (maLoadWatch.apiFails || 0) + "(" + (maLoadWatch.lastApiStatus || 0) + ")。UA=" + ua;
1981
+ /* 400 字符上限:先降级冗余细节,再硬截断(保留等待/挂起/原因/UA 这些客服必读项)。 */
1982
+ try {
1983
+ if (line.length > 400) {
1984
+ line = "dsh-remote 手机端加载疑似卡住:等待 " + activeSec + " 秒"
1985
+ + (hiddenSec > 0 ? "(挂起 " + hiddenSec + " 秒/墙钟 " + wallSec + " 秒)" : "")
1986
+ + "后仍未出现对话界面。原因=" + maLoadReason()
1987
+ + ",首屏资源失败=" + maRetry.resFails.length + "(link " + cnt.link + "/script " + cnt.script + ")"
1988
+ + ",自动重试=页" + maRetry.page + "/元素" + maRetry.resRetries
1989
+ + "(已用尽=" + (maRetry.exhausted ? "是" : "否") + ",恢复补偿=" + (maRetry.resumeBonus || 0) + ")"
1990
+ + ";scrim=" + maScrimStats.activations + "/" + maScrimStats.invariantHits
1991
+ + ";api失败=" + (maLoadWatch.apiFails || 0) + "。UA=" + ua;
1992
+ }
1993
+ if (line.length > 400) line = line.slice(0, 400);
1994
+ } catch (eClamp) { /* 忽略 */ }
1995
+ return line;
1743
1996
  }
1744
1997
 
1745
1998
  /* ===== 底部可操作提示条(2026-10-17,见上方看门狗注释)=====
@@ -1755,15 +2008,26 @@ const SCRIPT = `(() => {
1755
2008
  * 现在:· 电脑端不可达(受保护 API 请求 5xx/网络错误)→「电脑端可能未连接」;
1756
2009
  * · 首屏关键资源失败 →「页面资源加载失败,正在重试…」(重试用尽时如实说明);
1757
2010
  * · 其余 →「网络较慢」+ 已等待秒数(不再替用户猜原因)。 */
2011
+ /** 挂起提示后缀(W3):只在真的挂起过时追加,避免日常文案变长。 */
2012
+ function maLoadHiddenSuffix() {
2013
+ try {
2014
+ var hid = Math.round(maLoadHiddenMs() / 1000);
2015
+ if (hid < 1) return "";
2016
+ return ",期间页面被系统挂起 " + hid + " 秒";
2017
+ } catch (e) { return ""; }
2018
+ }
1758
2019
  function maLoadBarText() {
1759
2020
  var sec = maLoadWaitedSec();
1760
2021
  var reason = maLoadReason();
1761
- if (reason === "offline") return "电脑端可能未连接(已等待 " + sec + " 秒)";
2022
+ var tail = maLoadHiddenSuffix();
2023
+ /* W3:12s(活跃时间)起先给中性进度——"正在准备界面…"不是故障判定,不吓人。 */
2024
+ if (!maLoadWatch.slow) return "正在准备界面…(已等待 " + sec + " 秒" + tail + ")";
2025
+ if (reason === "offline") return "电脑端可能未连接(已等待 " + sec + " 秒" + tail + ")";
1762
2026
  if (reason === "resource") {
1763
- if (maRetry.exhausted) return "页面资源加载失败,自动重试已用尽(已等待 " + sec + " 秒)";
1764
- return "页面资源加载失败,正在重试…(已等待 " + sec + " 秒)";
2027
+ if (maRetry.exhausted) return "页面资源加载失败,自动重试已用尽(已等待 " + sec + " 秒" + tail + ")";
2028
+ return "页面资源加载失败,正在重试…(已等待 " + sec + " 秒" + tail + ")";
1765
2029
  }
1766
- return "网络较慢(已等待 " + sec + " 秒)";
2030
+ return "网络较慢(已等待 " + sec + " 秒" + tail + ")";
1767
2031
  }
1768
2032
  /** 收起提示条并停掉秒数刷新(不改变 dismissed:用于"认出界面后自动收起"这类临时收起)。 */
1769
2033
  function maLoadHideBar() {
@@ -1857,25 +2121,21 @@ const SCRIPT = `(() => {
1857
2121
  return bar;
1858
2122
  } catch (e) { return null; }
1859
2123
  }
1860
- /** 按当前状态渲染提示条(slow + 未关闭 → 显示;否则收起)。 */
2124
+ /** 按当前状态渲染提示条(progress 中性 / slow 可操作;未关闭 → 显示;否则收起)。 */
1861
2125
  function maLoadRenderBar() {
1862
2126
  try {
1863
- if (!maLoadWatch.slow || maLoadWatch.dismissed) { maLoadHideBar(); return; }
2127
+ if ((!maLoadWatch.slow && !maLoadWatch.progress) || maLoadWatch.dismissed) { maLoadHideBar(); return; }
1864
2128
  var bar = maLoadWatch.barEl || maLoadMountBar();
1865
2129
  if (!bar) return;
1866
2130
  if (maLoadWatch.barTxt) maLoadWatch.barTxt.textContent = maLoadBarText();
1867
2131
  bar.hidden = false;
1868
2132
  if (!maLoadWatch.barTimer) {
1869
- // 每秒刷新「已等待 N 秒」;同时复核"是不是其实早就好了"——好了就自动收起(不长期挂着)。
2133
+ // 每秒刷新「已等待 N 秒」(active 秒数);同时复核"是不是其实早就好了"——好了就收尾。
1870
2134
  maLoadWatch.barTimer = setInterval(function () {
1871
2135
  try {
1872
- if (!maLoadWatch.slow || maLoadWatch.dismissed) { maLoadHideBar(); return; }
1873
- if (maLoadReadyNow()) {
1874
- maLoadWatch.slow = false;
1875
- maLoadHideBar();
1876
- maLoadRenderWarn(); // 菜单那一行也一起收起
1877
- return;
1878
- }
2136
+ maLoadBeat(); // 条在显示期间也顺手记心跳:冻结恢复的第一时间就能扣掉挂起
2137
+ if ((!maLoadWatch.slow && !maLoadWatch.progress) || maLoadWatch.dismissed) { maLoadHideBar(); return; }
2138
+ if (maLoadReadyNow()) { maLoadFinish(); return; }
1879
2139
  if (maLoadWatch.barTxt) maLoadWatch.barTxt.textContent = maLoadBarText();
1880
2140
  } catch (e) { /* 忽略 */ }
1881
2141
  }, 1000);
@@ -1921,18 +2181,71 @@ const SCRIPT = `(() => {
1921
2181
  }
1922
2182
  } catch (e) { /* 忽略 */ }
1923
2183
  }
1924
- // 12 秒后仍认不出官方界面 → 置慢标记 + 懒创建底部提示条(**只在这时才碰 DOM**)。
1925
- // 同一处顺手把 mirror_load 的**兜底样本**发出去(outcome="slow"):12 秒没就绪的页面上报的
1926
- // 必然是"≥12 秒",这是**删失下限**而不是真实耗时,后台只把它单列,不混进 P50/P90。
2184
+ /* ===== 看门狗与恢复钩子接线(2026-11-08 W3:active-time 口径)=====
2185
+ 旧实现:20s 单次 setTimeout,触发即 slow;页面被 iOS 冻结时它直到恢复瞬间才执行,
2186
+ 于是拿墙钟(541s)冒充"加载耗时"、提示条显示 533 秒。
2187
+ 现在:12s 活跃时间 → 中性进度条;20s 活跃时间 → 现有可操作故障条 + 上报 slow;
2188
+ 之后每 20s 活跃时间复检;挂起恢复(pageshow / visibilitychange)→ 补判就绪 + 补扫启动
2189
+ 失败文案 + 限量补偿 <link>(补偿逻辑见 maLoadOnResume / maLoadCompensate)。
2190
+ 计时全靠 activeMs(扣除 hiddenMs),startedAt 不重置;心跳 1s 也用来兜住"visibilitychange
2191
+ 没触发"的进程冻结(漂移 >3× 即记账)。 */
2192
+ try {
2193
+ maLoadWatch.beatTimer = setInterval(function () {
2194
+ try { maLoadBeat(); } catch (eBe) { /* 心跳失败绝不影响页面 */ }
2195
+ }, MA_LOAD_BEAT_MS);
2196
+ try { if (maLoadWatch.beatTimer && maLoadWatch.beatTimer.unref) maLoadWatch.beatTimer.unref(); } catch (eBU) { /* 忽略 */ }
2197
+ } catch (eBeat) { /* 没有 setInterval 也不影响主功能(activeMs 退化为墙钟) */ }
2198
+ maSchedule(function () {
2199
+ try { maLoadCheck("progress"); } catch (eP) { /* 忽略 */ }
2200
+ }, MA_LOAD_PROGRESS_MS);
1927
2201
  setTimeout(function () {
1928
2202
  try {
1929
- if (maLoadReadyNow()) return;
1930
- maLoadWatch.slow = true;
1931
- if (maLoadWatch.wired) maLoadRenderWarn();
1932
- maLoadRenderBar(); // 底部可操作提示条(重试 / 返回设备列表 / 复制诊断 / 关闭)
1933
- maReportMirrorLoad("slow");
2203
+ maLoadCheck("watch");
1934
2204
  } catch (e) { /* 忽略 */ }
1935
2205
  }, MA_LOAD_WATCH_MS);
2206
+ /* 60s 兜底样本:任何一路都没报过(例如页面在 12s 前就被隐藏、定时器又被冻结)→ 补一条 slow,
2207
+ 避免样本丢失;有 reported 时它是 no-op(reported 一次性语义不变)。 */
2208
+ setTimeout(function () {
2209
+ try {
2210
+ if (!maLoadWatch.finished && !maLoadWatch.reported) maReportMirrorLoad("slow");
2211
+ } catch (eFb) { /* 忽略 */ }
2212
+ }, MA_LOAD_FALLBACK_MS);
2213
+ try {
2214
+ if (typeof document !== "undefined" && document && document.addEventListener) {
2215
+ document.addEventListener("visibilitychange", function () {
2216
+ try {
2217
+ if (document.visibilityState === "hidden") { maLoadMarkHidden(Date.now()); return; }
2218
+ maLoadSettleHidden(Date.now());
2219
+ if (!maLoadWatch.finished) maLoadOnResume("visibilitychange");
2220
+ } catch (eV) { /* 忽略 */ }
2221
+ });
2222
+ }
2223
+ } catch (eVis) { /* 老浏览器没有 visibilitychange:只靠心跳兜底 */ }
2224
+ try {
2225
+ if (typeof window !== "undefined" && window && window.addEventListener) {
2226
+ window.addEventListener("pagehide", function (ev) {
2227
+ try {
2228
+ maLoadMarkHidden(Date.now());
2229
+ if (ev && ev.persisted) return; // bfcache:等恢复,不在离开时上报
2230
+ if (maLoadWatch.finished || maLoadWatch.reported) return;
2231
+ /* 真正离开:把"未完成"也算一个样本(只有活跃等待 ≥12s 才发 slow,避免把 5s 就走的
2232
+ 样本当成慢加载污染统计)。 */
2233
+ if (maLoadReadyNow()) maReportMirrorLoad("ok");
2234
+ else if (maLoadActiveMs() >= MA_LOAD_PROGRESS_MS) maReportMirrorLoad("slow");
2235
+ } catch (ePh) { /* 忽略 */ }
2236
+ });
2237
+ }
2238
+ } catch (ePage) { /* 忽略 */ }
2239
+ try {
2240
+ if (typeof window !== "undefined" && window && window.addEventListener) {
2241
+ window.addEventListener("pageshow", function () {
2242
+ try {
2243
+ maLoadSettleHidden(Date.now());
2244
+ if (!maLoadWatch.finished) maLoadOnResume("pageshow");
2245
+ } catch (ePs) { /* 忽略 */ }
2246
+ });
2247
+ }
2248
+ } catch (ePs2) { /* 忽略 */ }
1936
2249
 
1937
2250
  /* ============================================================================
1938
2251
  * E2EE 死会话自动恢复 + 回到前台重连(2026-10-23,iOS 断线重连体验)
@@ -4493,7 +4806,9 @@ const SCRIPT = `(() => {
4493
4806
  /* 镜像页就绪探测(mirror_load 的量测终点):pump 本来就被 MutationObserver(子树) + 1s 兜底表
4494
4807
  驱动,官方 frame/输入框一挂上就会在**这一拍或下一拍**(≤1s)命中 —— 这正是契约要求的
4495
4808
  "终点的时刻",而且不需要再加任何定时器或观察者。只在还没上报过时判一次(reported 挡重复)。 */
4496
- if (!maLoadWatch.reported) { try { if (maLoadReadyNow()) maReportMirrorLoad("ok"); } catch (eLoadOk) { /* 忽略 */ } }
4809
+ /* W3:就绪走统一收尾(顺带停掉 1s 心跳/复检,别让已就绪的页面一直空转);
4810
+ reported 语义不变:slow 已报过就不再补 ok。 */
4811
+ if (!maLoadWatch.finished) { try { if (maLoadReadyNow()) maLoadFinish(); } catch (eLoadOk) { /* 忽略 */ } }
4497
4812
  if (!whaleGuardsOn || !settingsWatchOn) {
4498
4813
  try { whaleHookInit(); watchSettings(); } catch (eHook) { /* 两者内部都自带一次性守卫 */ }
4499
4814
  }
@@ -4732,7 +5047,7 @@ const SCRIPT = `(() => {
4732
5047
  try {
4733
5048
  if (ev && ev.persisted) {
4734
5049
  maArm();
4735
- if (maLoadWatch.slow && !maLoadWatch.dismissed) maLoadRenderBar();
5050
+ if ((maLoadWatch.slow || maLoadWatch.progress) && !maLoadWatch.dismissed) maLoadRenderBar();
4736
5051
  }
4737
5052
  } catch (ePs) { /* 忽略 */ }
4738
5053
  });