@mrrisega/dsh-remote 0.7.3-beta.1 → 0.7.3-beta.3
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 +214 -9
- package/clients/dsh-remote/e2ee-shim-script.js +98 -18
- package/clients/dsh-remote/mirror-diag-script.js +19 -0
- package/clients/dsh-remote/mobile-adapter.mjs +552 -16
- package/clients/dsh-remote/test/bridge-guard.test.mjs +169 -0
- package/clients/dsh-remote/test/e2ee-shim-selfheal.test.mjs +264 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +75 -0
- package/clients/dsh-remote/test/mobile-adapter-copy.test.mjs +56 -0
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +87 -8
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +9 -0
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +233 -28
- package/clients/dsh-remote/upstream-discovery.mjs +6 -1
- package/dsh-setup.mjs +225 -6
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/index.js +140 -3
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +214 -9
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +98 -18
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +19 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +552 -16
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +6 -1
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -6
- package/packages/dsh-remote-web/test/bridge-hardening.test.mjs +299 -0
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +82 -12
|
@@ -890,7 +890,11 @@ const SCRIPT = `(() => {
|
|
|
890
890
|
var maLoadWatch = { startedAt: Date.now(), slow: false, wired: false, bound: false, warnEl: null, warnTxt: null,
|
|
891
891
|
reported: false, reportMs: 0,
|
|
892
892
|
// 2026-10-17:底部可操作提示条(barEl = 懒创建的节点;barTimer = 秒数刷新)
|
|
893
|
-
barEl: null, barTxt: null, barTimer: 0, dismissed: false
|
|
893
|
+
barEl: null, barTxt: null, barTimer: 0, dismissed: false,
|
|
894
|
+
// 2026-10-20(C4):镜像页里**唯一**可靠的"电脑端不可达"信号 ——
|
|
895
|
+
// 受保护的 /api/* 请求被回 5xx 或直接网络错误(presence 不在镜像页这条链路上;
|
|
896
|
+
// E2EE 死会话的 502 另带 x-dsh-e2ee-error,不算离线,见 maInstallNetSampler)。
|
|
897
|
+
apiFails: 0, apiFailAt: 0, lastApiStatus: 0 };
|
|
894
898
|
/* ===== 旧浏览器兼容提示条(2026-10-18)=====
|
|
895
899
|
只有两种触发:① 注入体最前面的 maCompat 探测到"补不了的"(语法级不支持);
|
|
896
900
|
② 页面脚本报出 SyntaxError / is not a function 这类典型兼容错误且界面还没就绪。
|
|
@@ -993,8 +997,435 @@ const SCRIPT = `(() => {
|
|
|
993
997
|
} catch (e) { /* 忽略 */ }
|
|
994
998
|
}
|
|
995
999
|
|
|
996
|
-
/**
|
|
997
|
-
|
|
1000
|
+
/** 看门狗阈值(毫秒):**20 秒**仍认不出官方界面才开始提示。
|
|
1001
|
+
* 为什么 12→20(2026-10-20 诊断 C4,03 报告 §2.3):mirror_load 当天 858 样本 p50 2682ms、
|
|
1002
|
+
* **p90 15285ms**、≥12s 占 **17.6%** —— 12 秒阈值比真实 p90 还短,1/10 的正常加载会被贴上
|
|
1003
|
+
* "电脑端可能未连接",与代码里"宁可晚一点也别误报"的意图正好相反。20 秒在 p90 之上留了余量,
|
|
1004
|
+
* 又远低于用户无法忍耐的 40 秒级。测试按常量断言,不要散落魔法数。 */
|
|
1005
|
+
var MA_LOAD_WATCH_MS = 20 * 1000;
|
|
1006
|
+
|
|
1007
|
+
/* ===== C4:首屏关键资源失败 → 自动重试(2026-10-20 诊断,03 报告 §2.3) =====
|
|
1008
|
+
现场:文档 / assets/*.js / plugins 聚合包只要失败一次就**永远卡住** —— 旧实现只弹
|
|
1009
|
+
"已等 N 秒 + 重试",页面不会自愈;而用户按「重试」会 abort 掉仍在传输的资源 → nginx 499,
|
|
1010
|
+
反复点就是 499 风暴(当天文档导航 22.3% 失败/中断,499 集中在 3 台设备 = 人肉重试的指纹)。
|
|
1011
|
+
现在分两层重试,都不打扰用户:
|
|
1012
|
+
① **就地重建**:首屏关键资源(/assets/、/plugins/、manifest)加载失败一次 → 2s/4s 指数退避
|
|
1013
|
+
重建那一个 <script>/<link>(同一 URL 最多 2 次)。页面里已经跑起来的东西完全不动。
|
|
1014
|
+
② **受控整页重试**:出现官方启动失败文案(Failed to load plugins / did not activate,实测
|
|
1015
|
+
22:01:44 的现场)且界面还没就绪 → 整页刷新一次(同一标签页最多 2 次,sessionStorage 计数,
|
|
1016
|
+
90 秒后自动过期)。这才是"重新拉一次首屏"。
|
|
1017
|
+
③ 「重试」按钮:先取消在途请求与排队中的自动重试,再刷新(不再制造新的 499)。
|
|
1018
|
+
所有阈值都是常量(测试按常量断言);任何异常都吞掉,绝不影响页面。 */
|
|
1019
|
+
var MA_RES_RETRY_MAX = 2; // 同一个 URL 最多就地重试 2 次
|
|
1020
|
+
var MA_RES_RETRY_BASE_MS = 2000; // 退避基数:2s → 4s(指数)
|
|
1021
|
+
var MA_PAGE_RETRY_MAX = 2; // 整页自动重试上限(同一标签页)
|
|
1022
|
+
var MA_PAGE_RETRY_KEY = "dsh-ma-firstscreen-retry";
|
|
1023
|
+
var MA_PAGE_RETRY_TTL_MS = 90 * 1000; // 计数过期:90 秒后算"一次新的打开页面"
|
|
1024
|
+
var maRetry = {
|
|
1025
|
+
page: 0, // 本标签页已整页重试次数(从 sessionStorage 恢复)
|
|
1026
|
+
resFails: [], // 已观测到的关键资源失败(诊断 + 文案分档用)
|
|
1027
|
+
perUrl: {}, // url -> 已就地重试次数
|
|
1028
|
+
resRetries: 0, // 已发出的就地重试次数
|
|
1029
|
+
bootFail: false, // 是否看到官方启动失败文案
|
|
1030
|
+
pageTimer: 0, // 排队中的整页重试
|
|
1031
|
+
lastRetryClickAt: 0,
|
|
1032
|
+
manual: false, // 用户点过「重试」→ 不再自动排队(避免刚重载又自己重载)
|
|
1033
|
+
exhausted: false
|
|
1034
|
+
};
|
|
1035
|
+
var maRetryTimers = []; // 排队中的就地重试(「重试」按钮先清掉它们)
|
|
1036
|
+
var maInflight = []; // 我们跟踪到的在途 fetch(AbortController)——「重试」前取消,避免 499
|
|
1037
|
+
var maRetryStats = { page: 0, res: 0, lastKind: "", at: 0 }; // 对外只读诊断(mirror-diag 会带上)
|
|
1038
|
+
/* C6 诊断计数:用户报"又点不动了"时,要能区分「遮罩在拦」与「遮罩早被摘了」。 */
|
|
1039
|
+
var maScrimStats = { activations: 0, invariantHits: 0, lastActivationAt: 0 };
|
|
1040
|
+
var maCompatOfficial = { on: false, reasons: [] };
|
|
1041
|
+
|
|
1042
|
+
/** 诊断出口:这些计数不参与任何判定,只为了让用户上报现场时能说清楚。 */
|
|
1043
|
+
function maPublishDiag() {
|
|
1044
|
+
try {
|
|
1045
|
+
if (typeof window === "undefined" || !window) return;
|
|
1046
|
+
window.__dshMaRetryStats = maRetryStats;
|
|
1047
|
+
window.__dshMaDiag = {
|
|
1048
|
+
compatOfficial: maCompatOfficial,
|
|
1049
|
+
scrim: maScrimStats,
|
|
1050
|
+
retry: maRetryStats,
|
|
1051
|
+
load: { slow: maLoadWatch.slow, apiFails: maLoadWatch.apiFails, lastApiStatus: maLoadWatch.lastApiStatus }
|
|
1052
|
+
};
|
|
1053
|
+
} catch (e) { /* 诊断失败绝不影响页面 */ }
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** 首屏关键资源:文档主干 JS(/assets/)、插件聚合包(/plugins/)、manifest。
|
|
1057
|
+
* 设备作用域形态 /remote/<dev>/assets/... 先归一(与 e2ee-shim 的 seUpstreamPathOf 同向)。
|
|
1058
|
+
* 文档本身(HTML)失败时注入脚本根本不会执行 —— 那种情况只能靠服务端修复,
|
|
1059
|
+
* 不在本层职责内;这里覆盖的是"页面起来了、资源没起来"。 */
|
|
1060
|
+
function maIsBootCriticalPath(urlStr) {
|
|
1061
|
+
try {
|
|
1062
|
+
var p = String(urlStr || "");
|
|
1063
|
+
/* 用字符串切分而不是正则字面量:本文件是**模板字符串**,注入体里的「\/」会被吃掉一层,
|
|
1064
|
+
正则一旦少一层转义就变成语法错误(整段脚本不执行,页面直接裸奔)。 */
|
|
1065
|
+
var schemeAt = p.indexOf("://");
|
|
1066
|
+
if (schemeAt >= 0) {
|
|
1067
|
+
var pathAt = p.indexOf("/", schemeAt + 3);
|
|
1068
|
+
p = pathAt >= 0 ? p.slice(pathAt) : "/";
|
|
1069
|
+
}
|
|
1070
|
+
p = p.split("?")[0].split("#")[0];
|
|
1071
|
+
/* 设备作用域归一:/remote/<dev>/assets/… → /assets/…(与 e2ee-shim 的 seUpstreamPathOf 同向) */
|
|
1072
|
+
if (p.indexOf("/remote/") === 0) {
|
|
1073
|
+
var rest = p.slice("/remote/".length);
|
|
1074
|
+
var slashAt = rest.indexOf("/");
|
|
1075
|
+
p = slashAt >= 0 ? rest.slice(slashAt) : "/";
|
|
1076
|
+
}
|
|
1077
|
+
if (p.indexOf("/assets/") === 0 || p.indexOf("/plugins/") === 0) return true;
|
|
1078
|
+
if (p === "/manifest.json") return true;
|
|
1079
|
+
return false;
|
|
1080
|
+
} catch (e) { return false; }
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** 排队一个重试(统一登记,便于「重试」按钮一键取消)。unref:不拖住测试进程。 */
|
|
1084
|
+
function maSchedule(fn, ms) {
|
|
1085
|
+
try {
|
|
1086
|
+
var id = setTimeout(function () { try { fn(); } catch (e) { /* 重试失败绝不影响页面 */ } }, ms);
|
|
1087
|
+
try { if (id && id.unref) id.unref(); } catch (eU) { /* 忽略 */ }
|
|
1088
|
+
maRetryTimers.push(id);
|
|
1089
|
+
return id;
|
|
1090
|
+
} catch (e) { return 0; }
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** 取消所有排队中的自动重试(用户点了「重试」= 他自己要重来)。 */
|
|
1094
|
+
function maCancelPendingRetries() {
|
|
1095
|
+
try {
|
|
1096
|
+
for (var i = 0; i < maRetryTimers.length; i++) { try { clearTimeout(maRetryTimers[i]); } catch (e) { /* 忽略 */ } }
|
|
1097
|
+
maRetryTimers.length = 0;
|
|
1098
|
+
if (maRetry.pageTimer) { try { clearTimeout(maRetry.pageTimer); } catch (e2) { /* 忽略 */ } }
|
|
1099
|
+
maRetry.pageTimer = 0;
|
|
1100
|
+
} catch (e3) { /* 忽略 */ }
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** 取消我们跟踪到的在途 fetch:先取消再 reload,别让上一轮请求被浏览器 abort 成 499。 */
|
|
1104
|
+
function maAbortInflight() {
|
|
1105
|
+
var n = 0;
|
|
1106
|
+
try {
|
|
1107
|
+
while (maInflight.length) {
|
|
1108
|
+
var it = maInflight.pop();
|
|
1109
|
+
try { if (it && it.ac && it.ac.abort) { it.ac.abort(); n += 1; } } catch (e) { /* 忽略 */ }
|
|
1110
|
+
}
|
|
1111
|
+
} catch (e2) { /* 忽略 */ }
|
|
1112
|
+
return n;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** 本标签页的存储(sessionStorage 优先,退化 localStorage;都不可用返回 null)。 */
|
|
1116
|
+
function maTabStore() {
|
|
1117
|
+
try {
|
|
1118
|
+
var w = typeof window !== "undefined" ? window : null;
|
|
1119
|
+
if (w && w.sessionStorage) return w.sessionStorage;
|
|
1120
|
+
if (w && w.localStorage) return w.localStorage;
|
|
1121
|
+
} catch (e) { /* 无痕模式等 */ }
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
/** 读"本标签页已整页重试次数"(90 秒后过期 → 算一次新的打开页面)。 */
|
|
1125
|
+
function maPageRetryRead() {
|
|
1126
|
+
try {
|
|
1127
|
+
var st = maTabStore();
|
|
1128
|
+
if (!st) return 0;
|
|
1129
|
+
var raw = st.getItem(MA_PAGE_RETRY_KEY);
|
|
1130
|
+
if (!raw) return 0;
|
|
1131
|
+
var o = JSON.parse(raw);
|
|
1132
|
+
if (!o || typeof o.n !== "number") return 0;
|
|
1133
|
+
if (Date.now() - Number(o.at || 0) > MA_PAGE_RETRY_TTL_MS) return 0;
|
|
1134
|
+
return Math.max(0, Math.min(MA_PAGE_RETRY_MAX, o.n));
|
|
1135
|
+
} catch (e) { return 0; }
|
|
1136
|
+
}
|
|
1137
|
+
function maPageRetrySave(n) {
|
|
1138
|
+
try {
|
|
1139
|
+
var st = maTabStore();
|
|
1140
|
+
if (st) st.setItem(MA_PAGE_RETRY_KEY, JSON.stringify({ n: n, at: Date.now() }));
|
|
1141
|
+
} catch (e) { /* 存不了就算了(最坏是少一次防重保护) */ }
|
|
1142
|
+
}
|
|
1143
|
+
/* 启动时恢复"本标签页已自动重试过几次"(90 秒内有效)。必须在这里读:同一标签页刷新后
|
|
1144
|
+
计数要接着走,否则"每页最多 2 次"每次刷新都重新开始(又能刷 2 次 = reload 风暴)。 */
|
|
1145
|
+
try { maRetry.page = maPageRetryRead(); } catch (eR) { maRetry.page = 0; }
|
|
1146
|
+
|
|
1147
|
+
/** 就地重建一个失败的首屏资源元素(只认 <script>/<link>,其余跳过)。 */
|
|
1148
|
+
function maRetryBootResource(el, url, tag) {
|
|
1149
|
+
try {
|
|
1150
|
+
var t = String(tag || (el && el.tagName) || "").toLowerCase();
|
|
1151
|
+
if (t !== "script" && t !== "link") return false;
|
|
1152
|
+
var clone = document.createElement(t);
|
|
1153
|
+
var attr = t === "script" ? "src" : "href";
|
|
1154
|
+
clone.setAttribute(attr, url);
|
|
1155
|
+
/* ⚠️ 模块类型必须保留(官方 SPA 是 type=module 的队列加载),否则重建出来的脚本不执行;
|
|
1156
|
+
integrity 刻意**不**沿用:同一个坏响应带 integrity 重建只会再失败一次,白费这 2 秒。 */
|
|
1157
|
+
if (t === "script") {
|
|
1158
|
+
var ty = el && el.getAttribute ? el.getAttribute("type") : null;
|
|
1159
|
+
if (ty) clone.setAttribute("type", ty);
|
|
1160
|
+
} else {
|
|
1161
|
+
clone.setAttribute("rel", "stylesheet");
|
|
1162
|
+
}
|
|
1163
|
+
clone.setAttribute("data-dsh-ma-retry", "1");
|
|
1164
|
+
var host = (el && el.parentNode) || document.head || document.body || document.documentElement;
|
|
1165
|
+
if (!host) return false;
|
|
1166
|
+
host.appendChild(clone);
|
|
1167
|
+
maRetry.resRetries += 1;
|
|
1168
|
+
maBumpRetryStats("res:" + t);
|
|
1169
|
+
return true;
|
|
1170
|
+
} catch (e) { return false; }
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function maBumpRetryStats(kind) {
|
|
1174
|
+
try {
|
|
1175
|
+
maRetryStats.page = maRetry.page;
|
|
1176
|
+
maRetryStats.res = maRetry.resRetries;
|
|
1177
|
+
maRetryStats.lastKind = String(kind || "");
|
|
1178
|
+
maRetryStats.at = Date.now();
|
|
1179
|
+
maPublishDiag();
|
|
1180
|
+
} catch (e) { /* 忽略 */ }
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* 观测到首屏关键资源失败:登记 + 排队一次就地重建(同一 URL 最多 2 次,2s→4s)。
|
|
1185
|
+
* 为什么"就地重建"而不是直接刷新:官方 SPA 的模块加载器是队列模式(__ModuleLoader__ mode=queue),
|
|
1186
|
+
* 单个聚合包失败时重建那一个 <script> 就够;整页刷新成本高得多,还可能把已渲染的界面清掉。
|
|
1187
|
+
*/
|
|
1188
|
+
function maNoteResourceFail(url, tag, el) {
|
|
1189
|
+
try {
|
|
1190
|
+
var key = String(url || "");
|
|
1191
|
+
if (!key) return false;
|
|
1192
|
+
var n = maRetry.perUrl[key] || 0;
|
|
1193
|
+
maRetry.resFails.push({ url: key, tag: String(tag || ""), at: Date.now() });
|
|
1194
|
+
if (maRetry.resFails.length > 12) maRetry.resFails.shift();
|
|
1195
|
+
/* 界面已经就绪 = 这次失败没有卡住首屏。此时重建元素可能让模块/插件**再执行一遍**
|
|
1196
|
+
(官方模块加载器是队列式),风险大于收益 —— 只记录、不重试。 */
|
|
1197
|
+
if (maLoadReadyNow()) return false;
|
|
1198
|
+
if (n >= MA_RES_RETRY_MAX) { maRetry.exhausted = true; return false; }
|
|
1199
|
+
maRetry.perUrl[key] = n + 1;
|
|
1200
|
+
var delay = MA_RES_RETRY_BASE_MS * Math.pow(2, n); // 2s → 4s(指数退避)
|
|
1201
|
+
maSchedule(function () { maRetryBootResource(el, key, tag); }, delay);
|
|
1202
|
+
return true;
|
|
1203
|
+
} catch (e) { return false; }
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** 整页受控重试:仅当"官方界面还没就绪 + 没被用户手动接管 + 每标签页额度没用完"。 */
|
|
1207
|
+
function maSchedulePageRetry(why) {
|
|
1208
|
+
try {
|
|
1209
|
+
if (maRetry.manual) return false; // 用户已点过「重试」→ 我们不替他再刷
|
|
1210
|
+
if (maRetry.pageTimer) return false; // 已经排了一个
|
|
1211
|
+
if (maCompat && maCompat.ok === false) return false; // 语法级不兼容:刷新也起不来
|
|
1212
|
+
if (maLoadReadyNow()) return false; // 界面其实好了 → 绝不打扰
|
|
1213
|
+
if (maRetry.page >= MA_PAGE_RETRY_MAX) { maRetry.exhausted = true; return false; }
|
|
1214
|
+
var delay = MA_RES_RETRY_BASE_MS * Math.pow(2, maRetry.page); // 2s → 4s
|
|
1215
|
+
maRetry.page += 1;
|
|
1216
|
+
maPageRetrySave(maRetry.page);
|
|
1217
|
+
maRetry.pageTimer = setTimeout(function () {
|
|
1218
|
+
maRetry.pageTimer = 0;
|
|
1219
|
+
maRetryHardReload(why);
|
|
1220
|
+
}, delay);
|
|
1221
|
+
try { if (maRetry.pageTimer && maRetry.pageTimer.unref) maRetry.pageTimer.unref(); } catch (eU) { /* 忽略 */ }
|
|
1222
|
+
maBumpRetryStats("page:" + String(why || ""));
|
|
1223
|
+
return true;
|
|
1224
|
+
} catch (e) { return false; }
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** 取消在途请求 + 清排队重试,然后真正刷新页面。 */
|
|
1228
|
+
function maRetryHardReload(why) {
|
|
1229
|
+
try {
|
|
1230
|
+
maCancelPendingRetries();
|
|
1231
|
+
maAbortInflight();
|
|
1232
|
+
maBumpRetryStats("reload:" + String(why || ""));
|
|
1233
|
+
try { window.location.reload(); return; } catch (e) { /* 落到下面 */ }
|
|
1234
|
+
try { location.reload(); } catch (e2) { /* 忽略 */ }
|
|
1235
|
+
} catch (e3) { /* 忽略 */ }
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* 「重试」按钮:**先取消在途请求与排队中的自动重试,再刷新**(2026-10-20 C4 第 3 条)。
|
|
1240
|
+
* 为什么(03 报告 §2.3):旧实现直接 location.reload() —— 上一轮还在传输的文档/JS/插件包
|
|
1241
|
+
* 被浏览器 abort → nginx 499;用户连点几次就是 499 风暴,把瞬时故障放大成"永久打不开"。
|
|
1242
|
+
* 现在:① 2 秒冷却(连点不再叠加,人肉风暴的机械成因被掐掉);
|
|
1243
|
+
* ② abort 我们跟踪到的在途 fetch + 清掉排队的自动重试;
|
|
1244
|
+
* ③ 再刷新(留 120ms 让浏览器真的取消,而不是同一拍里直接跳走)。
|
|
1245
|
+
* @returns {boolean} 是否受理了这次点击
|
|
1246
|
+
*/
|
|
1247
|
+
function maRetryNow() {
|
|
1248
|
+
try {
|
|
1249
|
+
var now = Date.now();
|
|
1250
|
+
if (maRetry.lastRetryClickAt && now - maRetry.lastRetryClickAt < 2000) return false;
|
|
1251
|
+
maRetry.lastRetryClickAt = now;
|
|
1252
|
+
maRetry.manual = true;
|
|
1253
|
+
maCancelPendingRetries();
|
|
1254
|
+
maAbortInflight();
|
|
1255
|
+
var doReload = function () { try { window.location.reload(); } catch (e) { try { location.reload(); } catch (e2) { /* 忽略 */ } } };
|
|
1256
|
+
try { setTimeout(doReload, 120); } catch (e3) { doReload(); }
|
|
1257
|
+
return true;
|
|
1258
|
+
} catch (e) { return false; }
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* 首次打开页面时的"官方启动失败文案"探测(Failed to load plugins / did not activate)。
|
|
1263
|
+
* 为什么只在 3 个时间点扫、不挂 MutationObserver:body.innerText 是重操作,
|
|
1264
|
+
* 官方流式输出时每帧扫一次会把主线程拖死(mirror-diag 是服务端采集器,代价模型不同)。
|
|
1265
|
+
*/
|
|
1266
|
+
function maScanBootFailure() {
|
|
1267
|
+
try {
|
|
1268
|
+
if (maRetry.manual) return false;
|
|
1269
|
+
var b = document.body;
|
|
1270
|
+
var txt = String((b && b.innerText) || "");
|
|
1271
|
+
if (!txt) return false;
|
|
1272
|
+
if (txt.indexOf("Failed to load plugins") < 0 && txt.indexOf("did not activate") < 0) return false;
|
|
1273
|
+
maRetry.bootFail = true;
|
|
1274
|
+
return maSchedulePageRetry("boot_failed");
|
|
1275
|
+
} catch (e) { return false; }
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/** 底部提示条"原因分档":offline(电脑端不可达)/ resource(首屏资源失败)/ slow(网络慢)。 */
|
|
1279
|
+
function maLoadReason() {
|
|
1280
|
+
try {
|
|
1281
|
+
var now = Date.now();
|
|
1282
|
+
/* 10 秒内的不可达证据才算:过期的失败不能把文案一直钉在"未连接"上(那正是 03 报告
|
|
1283
|
+
说的"文案归因错误"——截图 09 面板同时显示 95ms 顺畅)。 */
|
|
1284
|
+
if ((maLoadWatch.apiFails || 0) > 0 && now - (maLoadWatch.apiFailAt || 0) < 10000) return "offline";
|
|
1285
|
+
if (maRetry.resFails.length > 0) return "resource";
|
|
1286
|
+
} catch (e) { /* 落到 slow */ }
|
|
1287
|
+
return "slow";
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** 响应带 x-dsh-e2ee-error = 桥端在说"这个加密会话我不认",不是"电脑端离线"。 */
|
|
1291
|
+
function maResHasE2eeError(res) {
|
|
1292
|
+
try {
|
|
1293
|
+
var h = res && res.headers;
|
|
1294
|
+
if (!h || typeof h.get !== "function") return false;
|
|
1295
|
+
return !!h.get("x-dsh-e2ee-error");
|
|
1296
|
+
} catch (e) { return false; }
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* 受保护 API 请求失败(5xx/网络错误)—— 镜像页里**唯一**可靠的"电脑端不可达"信号。
|
|
1301
|
+
* 为什么不用 presence:presence 不在镜像页这条链路上(镜像页只有一个隧道 + 官方客户端),
|
|
1302
|
+
* 而 03 报告里所有"电脑端未连接"的现场同时都伴随着 /api 的 502/504。
|
|
1303
|
+
* 只做计数与文案分档,绝不据此做任何重试/跳转。
|
|
1304
|
+
*/
|
|
1305
|
+
function maNotePresenceFail(status) {
|
|
1306
|
+
try {
|
|
1307
|
+
maLoadWatch.apiFails = (maLoadWatch.apiFails || 0) + 1;
|
|
1308
|
+
maLoadWatch.apiFailAt = Date.now();
|
|
1309
|
+
maLoadWatch.lastApiStatus = Number(status) || 0;
|
|
1310
|
+
maPublishDiag();
|
|
1311
|
+
if (maLoadWatch.slow) maLoadRenderBar(); // 提示条已经出来了 → 立刻按新原因换文案
|
|
1312
|
+
} catch (e) { /* 纯诊断,失败忽略 */ }
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/* ===== C6:官方 UI 锚点探测 → compat-official 降级(2026-10-20 诊断,00 §6.2 C6 / 04 §V-4) =====
|
|
1316
|
+
故障形态:装了「dsh web all」(或官方重新打包)后,我们的自绘抽屉/全屏 scrim 依赖的一组官方锚点
|
|
1317
|
+
可能整组失效:「.pI_x6G_frame」外壳、「.pI_x6G_sidebarCol」/「.hHd-Xa_root」侧栏列、
|
|
1318
|
+
侧栏开合按钮(aria-label / .hHd-Xa_toggle)、折叠态「data-sidebar-collapsed / data-sidebar-open」
|
|
1319
|
+
(以及 0.1.5 起改名的「data-details-collapsed / data-rightbar-collapsed」)。
|
|
1320
|
+
认不出这些锚点时的**最坏结局不是"没有手势",而是"自绘抽屉盖住官方界面 + scrim 常亮 → 点哪都没反应"**。
|
|
1321
|
+
所以启动时一次性判定:
|
|
1322
|
+
锚点齐全 → 原行为(不变量看门狗照旧,见 enforceScrimInvariant);
|
|
1323
|
+
锚点缺失/异常 → 「compat-official」模式:**不建自绘抽屉、不建 scrim、不代替用户点官方 toggle**,
|
|
1324
|
+
只保留字号 + E2EE 徽标这些纯悬浮件(FAB/菜单在上一步已经建好),侧栏完全交还官方。
|
|
1325
|
+
⚠️ 一次性判定(不是每拍重算):官方重新打包时整页锚点是一齐变的,反复探测只会反复误判。
|
|
1326
|
+
@param {Element} frameEl boot 里用官方特征选择器找到的 frame
|
|
1327
|
+
@returns {{on: boolean, reasons: string[]}} reasons 只作诊断(进 __dshMaDiag / 客服话术) */
|
|
1328
|
+
/** 读元素类名:className 优先,空则退回 class 属性(真实 DOM 两者等价,但假 DOM/合成节点不一定同步)。 */
|
|
1329
|
+
function maClassOf(el) {
|
|
1330
|
+
try {
|
|
1331
|
+
var c = (el && typeof el.className === "string") ? el.className : "";
|
|
1332
|
+
if (c) return c;
|
|
1333
|
+
if (el && typeof el.getAttribute === "function") return String(el.getAttribute("class") || "");
|
|
1334
|
+
} catch (e) { /* 忽略 */ }
|
|
1335
|
+
return "";
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function maProbeOfficialAnchors(frameEl) {
|
|
1339
|
+
var reasons = [];
|
|
1340
|
+
try {
|
|
1341
|
+
if (!frameEl) return { on: true, reasons: ["no_frame"] };
|
|
1342
|
+
var cls = maClassOf(frameEl);
|
|
1343
|
+
var sidebarAttrs = ["data-sidebar-collapsed", "data-sidebar-open"];
|
|
1344
|
+
var shellAttrs = sidebarAttrs.concat(["data-details-collapsed", "data-rightbar-collapsed", "data-shell-overlay"]);
|
|
1345
|
+
var hasSidebarAttr = false, hasShellAttr = false;
|
|
1346
|
+
for (var i = 0; i < shellAttrs.length; i++) {
|
|
1347
|
+
var has = false;
|
|
1348
|
+
try { has = !!(frameEl.hasAttribute && frameEl.hasAttribute(shellAttrs[i])); } catch (eH) { has = false; }
|
|
1349
|
+
if (has) { hasShellAttr = true; if (i < sidebarAttrs.length) hasSidebarAttr = true; }
|
|
1350
|
+
}
|
|
1351
|
+
var isOfficialFrame = /pI_x6G_frame/.test(cls) || hasShellAttr;
|
|
1352
|
+
if (!isOfficialFrame) reasons.push("frame_anchor_missing");
|
|
1353
|
+
/* 侧栏列:类名含 sidebar(排除第三列 rightbar/details、拖拽把手、overlay 层),
|
|
1354
|
+
或结构上真的有官方侧栏组件根 .hHd-Xa_root。 */
|
|
1355
|
+
var cols = [];
|
|
1356
|
+
try { cols = [].slice.call((frameEl && frameEl.children) || []); } catch (eC) { cols = []; }
|
|
1357
|
+
/* frame 已挂载但**一个子列都没有** = 官方 React 子树还没渲染完(或正在重渲染)。
|
|
1358
|
+
这不是"锚点变了",不能据此永久降级 —— 交给 pump 下一拍再看(return pending)。 */
|
|
1359
|
+
if (cols.length === 0) return { on: false, pending: true, reasons: [] };
|
|
1360
|
+
var sidebarCol = null;
|
|
1361
|
+
for (var k = 0; k < cols.length; k++) {
|
|
1362
|
+
var c = cols[k];
|
|
1363
|
+
var ccls = maClassOf(c);
|
|
1364
|
+
if (/rightbar|right-bar|details|handle|resizer|overlaylayer/i.test(ccls)) continue;
|
|
1365
|
+
if (/sidebar/i.test(ccls)) { sidebarCol = c; break; }
|
|
1366
|
+
try { if (c && c.querySelector && c.querySelector(".hHd-Xa_root")) { sidebarCol = c; break; } } catch (eQ) { /* 忽略 */ }
|
|
1367
|
+
}
|
|
1368
|
+
if (!sidebarCol) reasons.push("sidebar_anchor_missing");
|
|
1369
|
+
/* 能不能**驱动**官方侧栏:官方开合按钮,或官方折叠态属性。
|
|
1370
|
+
两者都没有 = 我们的汉堡按钮点了也不会动官方状态 → 自绘抽屉只会变成一个死按钮 + 可能的遮罩死局。 */
|
|
1371
|
+
var drive = null;
|
|
1372
|
+
try {
|
|
1373
|
+
drive = document.querySelector('[aria-label="Open sidebar"], [aria-label="Close sidebar"], [aria-label="Collapse sidebar"], [aria-label="Expand sidebar"], [aria-label="打开侧边栏"], [aria-label="关闭侧边栏"], [aria-label="收起侧边栏"], [aria-label="展开侧边栏"], .hHd-Xa_toggle');
|
|
1374
|
+
} catch (eD) { drive = null; }
|
|
1375
|
+
if (!drive && !hasSidebarAttr) reasons.push("sidebar_toggle_missing");
|
|
1376
|
+
return { on: reasons.length > 0, reasons: reasons };
|
|
1377
|
+
} catch (e) { return { on: true, reasons: ["probe_threw"] }; }
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** 进入 compat-official 模式:只记诊断 + 一条**可见**的 console 说明;不碰官方 DOM 结构。
|
|
1381
|
+
* 为什么不加页面提示条:护栏禁止适配层引入覆盖层,而"少一组手势"用户其实感觉得到
|
|
1382
|
+
* (官方自己的侧栏按钮就在),不需要再弹一条打扰。 */
|
|
1383
|
+
function maEnterCompatOfficial(reasons) {
|
|
1384
|
+
try {
|
|
1385
|
+
maCompatOfficial.on = true;
|
|
1386
|
+
maCompatOfficial.reasons = [].slice.call(reasons || []);
|
|
1387
|
+
var html = document.documentElement;
|
|
1388
|
+
if (html && html.classList) html.classList.add("dsh-ma-compat-official");
|
|
1389
|
+
try {
|
|
1390
|
+
if (window && window.console && window.console.warn) {
|
|
1391
|
+
window.console.warn("[dsh-mobile-adapter] 官方界面锚点缺失/异常(" + maCompatOfficial.reasons.join(",")
|
|
1392
|
+
+ ") → 进入 compat-official 模式:不自绘抽屉/scrim,不代替用户点官方入口,侧栏完全交还官方。");
|
|
1393
|
+
}
|
|
1394
|
+
} catch (eW) { /* 诊断出不来也不能影响主功能 */ }
|
|
1395
|
+
maPublishDiag();
|
|
1396
|
+
} catch (e) { /* 降级本身失败 = 维持现状(至少不比旧行为更糟) */ }
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/* C6:能不能**代替用户**点官方侧栏 toggle。点失败过一次就永久停手 —— 最坏退化成
|
|
1400
|
+
"没有手势"(用户还能用官方自己的入口),而不是把官方状态点乱后两态互相打架(点哪都没反应)。
|
|
1401
|
+
「window.__dshMaOfficialClicks = false」是给真机排查用的开关(不占 UI)。 */
|
|
1402
|
+
var maOfficialClickOK = true;
|
|
1403
|
+
function maClickOfficialToggle(el) {
|
|
1404
|
+
if (!el || !maOfficialClickOK) return false;
|
|
1405
|
+
try { if (typeof window !== "undefined" && window && window.__dshMaOfficialClicks === false) return false; } catch (e0) { /* 忽略 */ }
|
|
1406
|
+
try { el.click(); return true; } catch (e) { maOfficialClickOK = false; return false; }
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/* ---- C4 接线:资源失败捕获(捕获阶段才能拿到 <script>/<link> 的 error target)---- */
|
|
1410
|
+
try {
|
|
1411
|
+
window.addEventListener("error", function (ev) {
|
|
1412
|
+
try {
|
|
1413
|
+
var t = ev && ev.target;
|
|
1414
|
+
if (!t || t === window) return; // 脚本运行期错误不是资源失败
|
|
1415
|
+
var url = String(t.src || t.href || "");
|
|
1416
|
+
if (!maIsBootCriticalPath(url)) return;
|
|
1417
|
+
maNoteResourceFail(url, String(t.tagName || ""), t);
|
|
1418
|
+
} catch (e2) { /* 采集失败绝不影响页面 */ }
|
|
1419
|
+
}, true);
|
|
1420
|
+
} catch (e0) { /* 老浏览器:忽略 */ }
|
|
1421
|
+
/* 启动失败文案:6s/14s/18s 三个时间点(刻意避开 12s/20s 这两个既有定时器,
|
|
1422
|
+
免得测试与真机上"哪一路先到"变得不可预测)。 */
|
|
1423
|
+
try {
|
|
1424
|
+
maSchedule(maScanBootFailure, 6000);
|
|
1425
|
+
maSchedule(maScanBootFailure, 14000);
|
|
1426
|
+
maSchedule(maScanBootFailure, 18000);
|
|
1427
|
+
} catch (e1) { /* 忽略 */ }
|
|
1428
|
+
maPublishDiag();
|
|
998
1429
|
/** 官方界面是否已经可用(只看最强信号;只用于决定"要不要多显示一行字")。 */
|
|
999
1430
|
function maLoadReadyNow() {
|
|
1000
1431
|
try {
|
|
@@ -1047,6 +1478,11 @@ const SCRIPT = `(() => {
|
|
|
1047
1478
|
function maReportMirrorLoad(outcome) {
|
|
1048
1479
|
if (maLoadWatch.reported) return;
|
|
1049
1480
|
maLoadWatch.reported = true;
|
|
1481
|
+
/* 首屏成功就绪 = 这次打开是好的 → 把"整页自动重试"额度清零,别让一次偶发失败
|
|
1482
|
+
把接下来 90 秒的额度一起吃掉(额度只该限制**连续失败**,不该跨页面累计)。 */
|
|
1483
|
+
if (outcome !== "slow") {
|
|
1484
|
+
try { maRetry.page = 0; maPageRetrySave(0); } catch (eReset) { /* 忽略 */ }
|
|
1485
|
+
}
|
|
1050
1486
|
try {
|
|
1051
1487
|
var ms = Math.round(maLoadElapsedMs());
|
|
1052
1488
|
if (!isFinite(ms) || ms < 0) ms = 0;
|
|
@@ -1070,7 +1506,10 @@ const SCRIPT = `(() => {
|
|
|
1070
1506
|
function maLoadCopyLine() {
|
|
1071
1507
|
let ua = "";
|
|
1072
1508
|
try { ua = String((navigator && navigator.userAgent) || "").slice(0, 120); } catch (e) { /* 忽略 */ }
|
|
1073
|
-
return "dsh-remote 手机端加载疑似卡住:等待 " + maLoadWaitedSec() + " 秒后仍未出现对话界面。
|
|
1509
|
+
return "dsh-remote 手机端加载疑似卡住:等待 " + maLoadWaitedSec() + " 秒后仍未出现对话界面。"
|
|
1510
|
+
+ "原因=" + maLoadReason() + ",首屏资源失败=" + maRetry.resFails.length + ",自动重试="
|
|
1511
|
+
+ (maRetry.page + maRetry.resRetries) + " 次;scrim 拦截=" + maScrimStats.activations
|
|
1512
|
+
+ " 次/不变量摘除=" + maScrimStats.invariantHits + " 次。UA=" + ua;
|
|
1074
1513
|
}
|
|
1075
1514
|
|
|
1076
1515
|
/* ===== 底部可操作提示条(2026-10-17,见上方看门狗注释)=====
|
|
@@ -1079,9 +1518,22 @@ const SCRIPT = `(() => {
|
|
|
1079
1518
|
· **绝不阻塞**:只占底部一条(CSS 限高),浮层外照常可点;按钮动作全部就地完成;
|
|
1080
1519
|
· **任何异常都吞掉**:提示层坏掉也绝不影响页面(每个入口都有 try/catch);
|
|
1081
1520
|
· **只提示一次**:用户点 ✕ 后 dismissed=true,本页不再弹(不骚扰)。 */
|
|
1082
|
-
/**
|
|
1521
|
+
/** 提示条主文案:**按原因分档** + 已等待秒数(秒数由 barTimer 每秒刷新)。
|
|
1522
|
+
* 为什么分档(2026-10-20 诊断 C4,03 报告 §2.3「文案归因错误」):旧实现把
|
|
1523
|
+
* HTML 502 / JS 500 / 插件包失败 / E2EE 会话未建立 / nginx 连接打满**统统**说成
|
|
1524
|
+
* "电脑端可能未连接";截图 09 的反证最强 —— 面板同时显示「网络延迟 95ms·顺畅」。
|
|
1525
|
+
* 现在:· 电脑端不可达(受保护 API 请求 5xx/网络错误)→「电脑端可能未连接」;
|
|
1526
|
+
* · 首屏关键资源失败 →「页面资源加载失败,正在重试…」(重试用尽时如实说明);
|
|
1527
|
+
* · 其余 →「网络较慢」+ 已等待秒数(不再替用户猜原因)。 */
|
|
1083
1528
|
function maLoadBarText() {
|
|
1084
|
-
|
|
1529
|
+
var sec = maLoadWaitedSec();
|
|
1530
|
+
var reason = maLoadReason();
|
|
1531
|
+
if (reason === "offline") return "电脑端可能未连接(已等待 " + sec + " 秒)";
|
|
1532
|
+
if (reason === "resource") {
|
|
1533
|
+
if (maRetry.exhausted) return "页面资源加载失败,自动重试已用尽(已等待 " + sec + " 秒)";
|
|
1534
|
+
return "页面资源加载失败,正在重试…(已等待 " + sec + " 秒)";
|
|
1535
|
+
}
|
|
1536
|
+
return "网络较慢(已等待 " + sec + " 秒)";
|
|
1085
1537
|
}
|
|
1086
1538
|
/** 收起提示条并停掉秒数刷新(不改变 dismissed:用于"认出界面后自动收起"这类临时收起)。 */
|
|
1087
1539
|
function maLoadHideBar() {
|
|
@@ -1161,7 +1613,7 @@ const SCRIPT = `(() => {
|
|
|
1161
1613
|
t = t.parentNode || null;
|
|
1162
1614
|
}
|
|
1163
1615
|
if (role === "loadbar-retry") {
|
|
1164
|
-
|
|
1616
|
+
maRetryNow();
|
|
1165
1617
|
} else if (role === "loadbar-back") {
|
|
1166
1618
|
maLoadGoto("/app/"); // 绝对路径:镜像页当前路径可能是 /remote/<deviceId>/…
|
|
1167
1619
|
} else if (role === "loadbar-copy") {
|
|
@@ -1777,7 +2229,36 @@ const SCRIPT = `(() => {
|
|
|
1777
2229
|
let url = "";
|
|
1778
2230
|
try { url = typeof input === "string" ? input : (input && input.url) || ""; } catch (eU) { url = ""; }
|
|
1779
2231
|
const t0 = maNowMs();
|
|
1780
|
-
|
|
2232
|
+
/* C4 第 3 条(2026-10-20):给每个在途请求挂一个**我们自己的** AbortController,
|
|
2233
|
+
用户点「重试」时先 abort 再刷新 —— 旧实现直接 reload,浏览器在那一拍把在途请求
|
|
2234
|
+
全 abort 成 nginx 499(当天文档导航 22.3% 失败/中断的主要成因)。 */
|
|
2235
|
+
let ac = null;
|
|
2236
|
+
let args = [input, init];
|
|
2237
|
+
try {
|
|
2238
|
+
if (typeof AbortController === "function") {
|
|
2239
|
+
ac = new AbortController();
|
|
2240
|
+
let merged = ac.signal;
|
|
2241
|
+
try {
|
|
2242
|
+
const origSignal = init && init.signal;
|
|
2243
|
+
if (origSignal && typeof AbortSignal !== "undefined" && AbortSignal && typeof AbortSignal.any === "function") {
|
|
2244
|
+
merged = AbortSignal.any([origSignal, ac.signal]); // 与调用方自己的取消语义合并
|
|
2245
|
+
} else if (origSignal) {
|
|
2246
|
+
merged = origSignal; // 老浏览器没有 AbortSignal.any:不自作主张,保留调用方语义
|
|
2247
|
+
}
|
|
2248
|
+
} catch (eM) { merged = ac.signal; }
|
|
2249
|
+
args = [input, Object.assign({}, init || {}, { signal: merged })];
|
|
2250
|
+
maInflight.push({ ac: ac, url: url, at: t0 });
|
|
2251
|
+
if (maInflight.length > 200) maInflight.shift(); // 有界:绝不因为跟踪而泄漏
|
|
2252
|
+
}
|
|
2253
|
+
} catch (eAC) { ac = null; }
|
|
2254
|
+
const p = orig.apply(this, args);
|
|
2255
|
+
const drop = () => {
|
|
2256
|
+
try {
|
|
2257
|
+
for (let i = maInflight.length - 1; i >= 0; i -= 1) {
|
|
2258
|
+
if (maInflight[i] && maInflight[i].ac === ac) { maInflight.splice(i, 1); break; }
|
|
2259
|
+
}
|
|
2260
|
+
} catch (eD) { /* 忽略 */ }
|
|
2261
|
+
};
|
|
1781
2262
|
try {
|
|
1782
2263
|
const abs = url.slice(0, 8).toLowerCase();
|
|
1783
2264
|
const absolute = abs.indexOf("http://") === 0 || abs.indexOf("https://") === 0;
|
|
@@ -1785,9 +2266,31 @@ const SCRIPT = `(() => {
|
|
|
1785
2266
|
const apiish = url.indexOf("/api/") >= 0 || url.indexOf("/remote/") >= 0
|
|
1786
2267
|
|| url.indexOf("_devices") >= 0 || url.indexOf("_quota") >= 0;
|
|
1787
2268
|
if (sameOrigin && apiish && p && typeof p.then === "function") {
|
|
1788
|
-
p.then(() => {
|
|
2269
|
+
p.then((res) => {
|
|
2270
|
+
drop();
|
|
2271
|
+
try {
|
|
2272
|
+
const st = res && typeof res.status === "number" ? res.status : 0;
|
|
2273
|
+
/* 5xx 且**没有** x-dsh-e2ee-error = 电脑端/隧道不可达;
|
|
2274
|
+
E2EE 死会话的 502 另带那个头,它属于"会话失效"(C3 修的就是它),
|
|
2275
|
+
和"电脑端离线"是两回事 —— 混在一起就又变成文案归因错误。 */
|
|
2276
|
+
if (st >= 500 && !maResHasE2eeError(res)) maNotePresenceFail(st);
|
|
2277
|
+
else maNoteRtt(maNowMs() - t0, url);
|
|
2278
|
+
} catch (eS) { maNoteRtt(maNowMs() - t0, url); }
|
|
2279
|
+
}, (err) => {
|
|
2280
|
+
drop();
|
|
2281
|
+
try {
|
|
2282
|
+
/* 自己/调用方主动取消的请求不是"不可达" —— 官方客户端在切会话时本来就会 abort,
|
|
2283
|
+
把它算成离线会让文案长期钉在"电脑端可能未连接"(正是要修的那个误导)。 */
|
|
2284
|
+
const selfAborted = !!(ac && ac.signal && ac.signal.aborted);
|
|
2285
|
+
const callerAborted = !!(init && init.signal && init.signal.aborted);
|
|
2286
|
+
const abortErr = !!(err && (err.name === "AbortError" || err.code === 20));
|
|
2287
|
+
if (!selfAborted && !callerAborted && !abortErr) maNotePresenceFail(0);
|
|
2288
|
+
} catch (eF) { /* 忽略 */ }
|
|
2289
|
+
});
|
|
2290
|
+
} else if (p && typeof p.then === "function") {
|
|
2291
|
+
p.then(drop, drop);
|
|
1789
2292
|
}
|
|
1790
|
-
} catch (eW) {
|
|
2293
|
+
} catch (eW) { drop(); }
|
|
1791
2294
|
return p;
|
|
1792
2295
|
};
|
|
1793
2296
|
wrapped.__dshMaNetSampler = true; // 只作记号便于人造排查(**不作**幂等判据)
|
|
@@ -1878,7 +2381,14 @@ const SCRIPT = `(() => {
|
|
|
1878
2381
|
'<span class="ico">⚠</span>' +
|
|
1879
2382
|
'<span class="txt" data-role="load-warn-txt"></span>' +
|
|
1880
2383
|
'</div>' +
|
|
1881
|
-
|
|
2384
|
+
/* C9(2026-10-20 诊断,03 报告 §2.4 D3「文案与实现不一致」):旧文案说「密钥只在内存、刷新就没了」,
|
|
2385
|
+
已经**不成立** —— 实现会把派生 MK 写 localStorage、交接单同时写 sessionStorage +
|
|
2386
|
+
localStorage(native.html:5143-5160 / :5204,镜像页 _boot 从 localStorage 恢复)。
|
|
2387
|
+
照着旧文案,客服和用户会做出错误动作(以为刷新必丢密钥)。现在如实说清三件事:
|
|
2388
|
+
① 已记住本机,刷新自动恢复加密;② 提示「加密会话已失效」才需要回列表重新解锁;
|
|
2389
|
+
③ 明文模式功能可用、但不加密(避免用户以为"没加密=没得用")。 */
|
|
2390
|
+
'<div class="dsh-ma-menu-note">返回后可重新选择设备。端到端加密已记住本机:刷新页面会自动恢复加密;'
|
|
2391
|
+
+ '若提示「加密会话已失效」,需返回设备列表重新解锁;明文模式下功能可用,但数据不经端到端加密。</div>';
|
|
1882
2392
|
document.body.appendChild(menu);
|
|
1883
2393
|
maWireLoadWarn(menu); // 加载疑似失败时,在这一行里说明白(点一下可复制)
|
|
1884
2394
|
|
|
@@ -2283,6 +2793,19 @@ const SCRIPT = `(() => {
|
|
|
2283
2793
|
if (done || !NARROW()) return;
|
|
2284
2794
|
const frame = document.querySelector(".pI_x6G_frame, [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]");
|
|
2285
2795
|
if (!frame || !frame.querySelector) return;
|
|
2796
|
+
/* ==== C6(2026-10-20 诊断):先探测官方锚点,认不出就整段降级 ====
|
|
2797
|
+
为什么必须在**打标记/建节点之前**判:compat-official 模式下我们一个 class 都不给官方加
|
|
2798
|
+
(否则 CSS 会把三列轨道改成 0/1fr/0,等于用错的锚点把官方布局也一起改了)。
|
|
2799
|
+
认不出来时的正确退路是"没有手势",不是"自绘抽屉 + scrim 盖住官方界面"。 */
|
|
2800
|
+
const anchors = maProbeOfficialAnchors(frame);
|
|
2801
|
+
/* 官方 frame 已挂载、但子列还没渲染出来 → 只是"还没长好",不是锚点缺失:
|
|
2802
|
+
不降级、不置 done,交给 pump 下一拍再看(一次性降级只应该发生在"结构稳定但认不出"时)。 */
|
|
2803
|
+
if (anchors.pending) return;
|
|
2804
|
+
if (anchors.on) {
|
|
2805
|
+
maEnterCompatOfficial(anchors.reasons);
|
|
2806
|
+
done = true; // 不再重试:官方锚点是一齐变的,反复探测只会反复误判(还会让 pump 每拍全量扫 DOM)
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2286
2809
|
done = true;
|
|
2287
2810
|
frame.classList.add("dsh-ma-frame");
|
|
2288
2811
|
|
|
@@ -2411,7 +2934,9 @@ const SCRIPT = `(() => {
|
|
|
2411
2934
|
e.stopPropagation();
|
|
2412
2935
|
userWantsOpen = false; // 用户明确要关(遮罩的作用就是关抽屉)
|
|
2413
2936
|
const t = toggleOf();
|
|
2414
|
-
|
|
2937
|
+
/* C6:代替用户点官方 toggle 一律走 maClickOfficialToggle(失败即停手/可被开关关闭),
|
|
2938
|
+
绝不让"点官方入口"变成把官方状态点乱的来源。 */
|
|
2939
|
+
maClickOfficialToggle(t);
|
|
2415
2940
|
/* 兜底 + 保险:无论官方 toggle 在不在,都确保"点空白处"能关掉/不再拦截 */
|
|
2416
2941
|
document.documentElement.classList.remove("dsh-ma-sidebar-open", "dsh-ma-scrim-on");
|
|
2417
2942
|
});
|
|
@@ -2436,7 +2961,7 @@ const SCRIPT = `(() => {
|
|
|
2436
2961
|
这一点会把官方**推回展开**,而我们的 class 被摘掉 → 抽屉隐形;再点一次又反过来,
|
|
2437
2962
|
两态互相打架、白柱反复重现。现在只在**官方当前态 ≠ 期望态**时才点官方 toggle:
|
|
2438
2963
|
状态一致时点它只会把官方推到相反方向。 */
|
|
2439
|
-
if (t && expanded() !== desired) { t
|
|
2964
|
+
if (t && expanded() !== desired) { maClickOfficialToggle(t); }
|
|
2440
2965
|
sync(); // 立刻按意图生效(不依赖官方属性回调)
|
|
2441
2966
|
});
|
|
2442
2967
|
|
|
@@ -2555,9 +3080,17 @@ const SCRIPT = `(() => {
|
|
|
2555
3080
|
if (html.classList.contains("dsh-ma-sidebar-open") && sidebarInView()) return true;
|
|
2556
3081
|
return false;
|
|
2557
3082
|
};
|
|
3083
|
+
/* C6 诊断:scrim 的"实际下闸"与"不变量摘除"都计数(「window.__dshMaDiag」/ 复制诊断时带出)。
|
|
3084
|
+
为什么需要:用户报「又点不动了」时,只靠肉眼分不清"遮罩在拦"和"遮罩早被摘了",
|
|
3085
|
+
这两类问题的修法完全不同 —— 计数就是现场的第一手证据。 */
|
|
3086
|
+
let maScrimWasOn = false;
|
|
2558
3087
|
const applyScrim = () => {
|
|
2559
|
-
try {
|
|
2560
|
-
|
|
3088
|
+
try {
|
|
3089
|
+
const want = scrimShouldBeOn();
|
|
3090
|
+
document.documentElement.classList.toggle("dsh-ma-scrim-on", want);
|
|
3091
|
+
if (want && !maScrimWasOn) { maScrimStats.activations += 1; maScrimStats.lastActivationAt = Date.now(); maPublishDiag(); }
|
|
3092
|
+
maScrimWasOn = want;
|
|
3093
|
+
} catch (e2) { /* 忽略 */ }
|
|
2561
3094
|
};
|
|
2562
3095
|
/* 🔒 不变量(硬要求):遮罩只要在拦截点击,视口内就必须**存在**至少一层可交互抽屉;
|
|
2563
3096
|
否则立刻摘掉遮罩。它不看任何判定链(属性/几何/class)的自洽性,只认"实际在拦 + 实际没有抽屉"
|
|
@@ -2569,6 +3102,9 @@ const SCRIPT = `(() => {
|
|
|
2569
3102
|
if (!scrimBlocks()) return false;
|
|
2570
3103
|
if (anyDrawerInView()) return false;
|
|
2571
3104
|
document.documentElement.classList.remove("dsh-ma-scrim-on");
|
|
3105
|
+
maScrimWasOn = false;
|
|
3106
|
+
maScrimStats.invariantHits += 1; // C6 诊断:不变量真的救过一次场
|
|
3107
|
+
maPublishDiag();
|
|
2572
3108
|
return true;
|
|
2573
3109
|
} catch (e2) { return false; }
|
|
2574
3110
|
};
|
|
@@ -2707,7 +3243,7 @@ const SCRIPT = `(() => {
|
|
|
2707
3243
|
if (!t.closest(AUTO_CLOSE_SEL)) return; // 只认"导航/切换"类,就地操作不收起
|
|
2708
3244
|
userWantsOpen = false;
|
|
2709
3245
|
const togg = toggleOf();
|
|
2710
|
-
if (togg) setTimeout(() => {
|
|
3246
|
+
if (togg) setTimeout(() => { maClickOfficialToggle(togg); }, 0);
|
|
2711
3247
|
setTimeout(sync, 0);
|
|
2712
3248
|
} catch (e6) { /* 忽略 */ }
|
|
2713
3249
|
});
|