@mrrisega/dsh-remote 0.6.17 → 0.6.18-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.
- package/clients/dsh-remote/e2ee-shim-script.js +203 -11
- package/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +43 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +145 -4
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +95 -6
- package/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/docs/telemetry.md +7 -1
- package/dsh-setup.mjs +152 -3
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +754 -39
- package/packages/dsh-remote-web/lib/index.js +279 -6
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +203 -11
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +152 -3
- package/packages/dsh-remote-web/test/account-switch-device.test.mjs +20 -2
- package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +310 -0
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +524 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +4 -1
- package/packages/dsh-remote-web/test/diag-report.test.mjs +4 -1
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +5 -1
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +26 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +32 -2
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +3 -1
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +4 -1
- package/packages/dsh-remote-web/test/setup-output.test.mjs +22 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +29 -10
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +3 -3
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +8 -3
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +7 -4
|
@@ -136,6 +136,19 @@ const STYLE = `
|
|
|
136
136
|
html.dsh-ma-sidebar-open div.pI_x6G_sidebarCol:not(.dsh-ma-details),
|
|
137
137
|
html.dsh-ma-sidebar-open div.dsh-ma-sidebar:not(.dsh-ma-details) { z-index: 310; }
|
|
138
138
|
|
|
139
|
+
/* 🔒 0.6.18 白柱兜底(用户报的「点官方抽屉里的收起按钮 → 左半边一片白色占位,只有刷新能出去」):
|
|
140
|
+
**官方说收起 + 我们此刻没有活的展开意图** → 这一列一律留在屏外,不管别的 class 怎么说。
|
|
141
|
+
为什么必须放在 CSS 层:那根白柱是 sidebarCol 被钉成 fixed + translateX(0) + z-index:310 的
|
|
142
|
+
**空壳**(官方已把列宽压成 0、内容收起,只剩一个没有内容的白色固定柱盖住左半屏),
|
|
143
|
+
而且它几何上"在视口里",连遮罩不变量都会误判成"抽屉还在" → 整屏被困、只有刷新能出去。
|
|
144
|
+
JS 侧已经会在官方收起时放弃过期意图(见 sync() 的 collapsedEdge 判定),这一条是不依赖任何
|
|
145
|
+
JS 判定链的最后一道硬保证。
|
|
146
|
+
⚠️ 必须带 :not(.dsh-ma-user-wants-open):用户明确要开时必须能开 —— 哪怕官方 toggle 坏了、
|
|
147
|
+
属性永远停在"收起"(那是 0.6.9-beta.2 就定下的自救路径,护栏用例守着)。
|
|
148
|
+
"官方收起 + 用户仍要开"是**合法**状态(离屏抽屉滑进来),不是白柱。 */
|
|
149
|
+
html.dsh-ma-official-collapsed:not(.dsh-ma-user-wants-open) div.pI_x6G_sidebarCol:not(.dsh-ma-details),
|
|
150
|
+
html.dsh-ma-official-collapsed:not(.dsh-ma-user-wants-open) div.dsh-ma-sidebar:not(.dsh-ma-details) { transform: translateX(-103%) !important; }
|
|
151
|
+
|
|
139
152
|
div.pI_x6G_detailsCol, div.pI_x6G_rightbarCol, div.dsh-ma-details, div.dsh-ma-rightbar {
|
|
140
153
|
position: fixed; right: 0; top: 0; bottom: 0; margin: 0;
|
|
141
154
|
width: min(92vw, 400px); max-width: 96vw;
|
|
@@ -321,6 +334,24 @@ button.dsh-ma-fab .dsh-ma-fab-lock {
|
|
|
321
334
|
background: var(--dsw-alias-bg-elevated, #fff);
|
|
322
335
|
box-shadow: 0 0 0 .5px var(--dsw-alias-border-l3, rgba(127,127,127,.3));
|
|
323
336
|
}
|
|
337
|
+
/* 手机图标(内联 SVG,0.6.18 起替代原来的「字」):flex 居中,颜色继承按钮的 color
|
|
338
|
+
(暗色主题/官方变量变化都跟着走),尺寸由 SVG 的 width/height 决定。 */
|
|
339
|
+
button.dsh-ma-fab .dsh-ma-fab-ico { display: flex; align-items: center; justify-content: center; color: inherit; }
|
|
340
|
+
/* 延迟角标:**必须**在 FAB 内部(绝对定位子元素)——右上角已经是 .dsh-ma-fab-lock 的位置
|
|
341
|
+
(top:-3px;right:-3px),所以放正下方;也**不得**做成 body 级浮层(护栏:
|
|
342
|
+
test/mobile-adapter-guards.test.mjs「提示行必须在菜单内、不得有固定层」)。
|
|
343
|
+
pointer-events:none:角标压在按钮下沿外,别把按钮的拖动/轻点热区吃掉。 */
|
|
344
|
+
button.dsh-ma-fab .dsh-ma-fab-rtt {
|
|
345
|
+
position: absolute; left: 50%; bottom: -13px; transform: translateX(-50%);
|
|
346
|
+
font-size: 9.5px; line-height: 1; padding: 1px 4px; border-radius: 999px; white-space: nowrap;
|
|
347
|
+
pointer-events: none;
|
|
348
|
+
background: var(--dsw-alias-bg-elevated, #fff); color: var(--dsw-alias-label-tertiary, #6b7280);
|
|
349
|
+
box-shadow: 0 0 0 .5px var(--dsw-alias-border-l3, rgba(127,127,127,.3));
|
|
350
|
+
}
|
|
351
|
+
button.dsh-ma-fab .dsh-ma-fab-rtt[hidden] { display: none !important; }
|
|
352
|
+
button.dsh-ma-fab .dsh-ma-fab-rtt[data-rtt="ok"] { color: var(--dsw-alias-label-success, #1a7f37); }
|
|
353
|
+
button.dsh-ma-fab .dsh-ma-fab-rtt[data-rtt="mid"] { color: var(--dsw-alias-label-warning, #9a6700); }
|
|
354
|
+
button.dsh-ma-fab .dsh-ma-fab-rtt[data-rtt="bad"] { color: var(--dsw-alias-label-danger, #cf222e); }
|
|
324
355
|
/* 菜单卡片:fixed 贴右下,最大高度受限可滚动 */
|
|
325
356
|
div.dsh-ma-menu {
|
|
326
357
|
position: fixed;
|
|
@@ -365,18 +396,23 @@ div.dsh-ma-menu .dsh-ma-menu-ticks {
|
|
|
365
396
|
display: flex; justify-content: space-between;
|
|
366
397
|
color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 11px;
|
|
367
398
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
div.dsh-ma-menu .dsh-ma-menu-
|
|
399
|
+
/* 网络延迟行(0.6.18):与「加密状态」行同款方块,纯只读。
|
|
400
|
+
⚠️ 它**不是**测速:样本全部来自适配层已经包装过的 window.fetch(会话过期探测那一处),
|
|
401
|
+
零新增请求;没有样本时显示「测速中…」而不是 0ms(0ms 是谎言,见 SCRIPT 里的注释)。 */
|
|
402
|
+
div.dsh-ma-menu .dsh-ma-menu-e2ee,
|
|
403
|
+
div.dsh-ma-menu .dsh-ma-menu-net {
|
|
373
404
|
display: flex; align-items: flex-start; gap: 8px;
|
|
374
405
|
font-size: 12.5px; line-height: 1.6;
|
|
375
406
|
background: var(--dsw-alias-bg-base, rgba(127,127,127,.08));
|
|
376
407
|
border-radius: 10px; padding: 9px 11px;
|
|
377
408
|
}
|
|
378
|
-
div.dsh-ma-menu .dsh-ma-menu-e2ee .ico
|
|
379
|
-
div.dsh-ma-menu .dsh-ma-menu-
|
|
409
|
+
div.dsh-ma-menu .dsh-ma-menu-e2ee .ico,
|
|
410
|
+
div.dsh-ma-menu .dsh-ma-menu-net .ico { flex: none; }
|
|
411
|
+
div.dsh-ma-menu .dsh-ma-menu-e2ee .txt,
|
|
412
|
+
div.dsh-ma-menu .dsh-ma-menu-net .txt { min-width: 0; overflow-wrap: anywhere; }
|
|
413
|
+
div.dsh-ma-menu .dsh-ma-menu-net[data-rtt="ok"] .txt { color: var(--dsw-alias-label-success, #1a7f37); }
|
|
414
|
+
div.dsh-ma-menu .dsh-ma-menu-net[data-rtt="mid"] .txt { color: var(--dsw-alias-label-warning, #9a6700); }
|
|
415
|
+
div.dsh-ma-menu .dsh-ma-menu-net[data-rtt="bad"] .txt { color: var(--dsw-alias-label-danger, #cf222e); }
|
|
380
416
|
/* ⚠️ 必须显式写 [hidden]:UA 的 [hidden]{display:none} 会被上面的 display:flex 盖过(实测过)。 */
|
|
381
417
|
div.dsh-ma-menu .dsh-ma-e2ee-sec[hidden],
|
|
382
418
|
div.dsh-ma-menu .dsh-ma-menu-e2ee[hidden] { display: none !important; }
|
|
@@ -384,7 +420,8 @@ button.dsh-ma-fab .dsh-ma-fab-lock[hidden] { display: none !important; }
|
|
|
384
420
|
div.dsh-ma-menu .dsh-ma-menu-e2ee.is-warn .txt { color: var(--dsw-alias-label-warning, #9a6700); }
|
|
385
421
|
div.dsh-ma-menu .dsh-ma-menu-e2ee.is-err .txt { color: var(--dsw-alias-label-danger, #cf222e); }
|
|
386
422
|
div.dsh-ma-menu .dsh-ma-menu-e2ee a { color: var(--dsw-alias-brand-primary, #4d6bfe); }
|
|
387
|
-
button.dsh-ma-menu-back
|
|
423
|
+
button.dsh-ma-menu-back,
|
|
424
|
+
button.dsh-ma-menu-right {
|
|
388
425
|
display: block; width: 100%; box-sizing: border-box;
|
|
389
426
|
margin-top: 12px; padding: 11px 12px;
|
|
390
427
|
border-radius: 10px; border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.3));
|
|
@@ -394,7 +431,15 @@ button.dsh-ma-menu-back {
|
|
|
394
431
|
cursor: pointer; touch-action: manipulation;
|
|
395
432
|
-webkit-tap-highlight-color: transparent;
|
|
396
433
|
}
|
|
397
|
-
button.dsh-ma-menu-back:active
|
|
434
|
+
button.dsh-ma-menu-back:active,
|
|
435
|
+
button.dsh-ma-menu-right:active { transform: scale(.98); }
|
|
436
|
+
/* 「打开右侧栏」是本层**唯一**能主动把第三列(官方右栏)滑进手机的入口,
|
|
437
|
+
用品牌色描边与「返回设备列表」区分开(后者是退路,前者是功能)。 */
|
|
438
|
+
button.dsh-ma-menu-right {
|
|
439
|
+
margin-top: 8px;
|
|
440
|
+
color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
441
|
+
border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 45%, transparent);
|
|
442
|
+
}
|
|
398
443
|
div.dsh-ma-menu .dsh-ma-menu-note {
|
|
399
444
|
margin-top: 8px; font-size: 11.5px; line-height: 1.7;
|
|
400
445
|
color: var(--dsw-alias-label-tertiary, #6b7280);
|
|
@@ -440,6 +485,23 @@ html[data-dsh-ma-merged-e2ee] #dsh-e2ee-badge { display: none !important; }
|
|
|
440
485
|
官方界面本身不受任何影响。 */
|
|
441
486
|
`;
|
|
442
487
|
|
|
488
|
+
/**
|
|
489
|
+
* 悬浮按钮上的手机图标(0.6.18 起替代原来的「字」)。
|
|
490
|
+
*
|
|
491
|
+
* 为什么内联 SVG:仓库里**没有**可复用的手机图标 —— 全工作区唯一的 SVG 是
|
|
492
|
+
* `image/icon.svg`(应用图标,含「远程控制」文字 + 显示器,viewBox 512,做 20px 图标不可用);
|
|
493
|
+
* 产品其它地方用的「手机图标」是 📱 emoji(client.js:804/4287),但 emoji 的字形与基线随系统变,
|
|
494
|
+
* 放在 46×46 的圆形按钮里会偏。写法照本文件汉堡按钮(viewBox 24 + stroke="currentColor"),
|
|
495
|
+
* 这样颜色跟着按钮的 color 走,暗色主题/官方变量变化都不用另写规则。
|
|
496
|
+
*/
|
|
497
|
+
const FAB_PHONE_SVG =
|
|
498
|
+
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
|
499
|
+
'stroke-width="1.8" stroke-linecap="round" aria-hidden="true">' +
|
|
500
|
+
'<rect x="6" y="2.5" width="12" height="19" rx="3"/>' +
|
|
501
|
+
'<path d="M10 5.5h4"/>' +
|
|
502
|
+
'<circle cx="12" cy="18.6" r="1.1" fill="currentColor" stroke="none"/>' +
|
|
503
|
+
"</svg>";
|
|
504
|
+
|
|
443
505
|
const SCRIPT = `(() => {
|
|
444
506
|
"use strict";
|
|
445
507
|
|
|
@@ -510,7 +572,8 @@ const SCRIPT = `(() => {
|
|
|
510
572
|
|
|
511
573
|
⚠️ 刻意**不**做成页面顶部/底部的固定细条:那种细条同样会盖住官方 UI 的一部分,
|
|
512
574
|
而且同样有"永不消失"的失败方向。菜单是被动展开的,天然没有这个问题。 */
|
|
513
|
-
var maLoadWatch = { startedAt: Date.now(), slow: false, wired: false, bound: false, warnEl: null, warnTxt: null
|
|
575
|
+
var maLoadWatch = { startedAt: Date.now(), slow: false, wired: false, bound: false, warnEl: null, warnTxt: null,
|
|
576
|
+
reported: false, reportMs: 0 };
|
|
514
577
|
/** 官方界面是否已经可用(只看最强信号;只用于决定"要不要多显示一行字")。 */
|
|
515
578
|
function maLoadReadyNow() {
|
|
516
579
|
try {
|
|
@@ -521,6 +584,67 @@ const SCRIPT = `(() => {
|
|
|
521
584
|
function maLoadWaitedSec() {
|
|
522
585
|
return Math.max(0, Math.round((Date.now() - maLoadWatch.startedAt) / 1000));
|
|
523
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* 单调时钟(performance.now;不可用时退回 Date.now)。
|
|
589
|
+
* 延迟采样与 mirror_load 量测共用:**同一次量测的两端必须用同一个时钟**,
|
|
590
|
+
* 否则 performance.now 与 Date.now 混用会算出负数/巨值。
|
|
591
|
+
*/
|
|
592
|
+
function maNowMs() {
|
|
593
|
+
try {
|
|
594
|
+
if (typeof performance !== "undefined" && performance && typeof performance.now === "function") return performance.now();
|
|
595
|
+
} catch (e) { /* 继续退化 */ }
|
|
596
|
+
return Date.now();
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* 镜像页就绪耗时(毫秒)——**口径见 docs/TELEMETRY-CONTRACT.md / 迭代冻结文档 §2.3**:
|
|
600
|
+
* · 起点 = **导航开始**(performance.now() 的零点 = performance.timeOrigin ≈ 导航开始),
|
|
601
|
+
* 所以一条值同时覆盖 DNS+TLS+HTML TTFB + 解析 + 界面就绪;
|
|
602
|
+
* · **不能**用 Date.now() - maLoadWatch.startedAt:那个起点是"注入脚本解析时刻",会**漏掉
|
|
603
|
+
* HTML TTFB**(HAR 实测那 79 秒里 TTFB 就有 8.7 秒),报出来的数会系统性小于用户体感;
|
|
604
|
+
* 而且 bfcache 恢复(pageshow persisted)不会重置 startedAt,那口径还能报出几十分钟。
|
|
605
|
+
* · 没有 performance 时退化回 startedAt 口径(老浏览器兜底,宁可粗糙也不能漏报)。
|
|
606
|
+
*/
|
|
607
|
+
function maLoadElapsedMs() {
|
|
608
|
+
try {
|
|
609
|
+
if (typeof performance !== "undefined" && performance && typeof performance.now === "function") {
|
|
610
|
+
var v = performance.now();
|
|
611
|
+
if (typeof v === "number" && isFinite(v) && v >= 0) return v;
|
|
612
|
+
}
|
|
613
|
+
} catch (e) { /* 继续退化 */ }
|
|
614
|
+
return Math.max(0, Date.now() - maLoadWatch.startedAt);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* 上报镜像页加载耗时(事件名 mirror_load;宿主半路由由 D6 线新增)。
|
|
618
|
+
*
|
|
619
|
+
* 纪律(三条都来自"绝不打扰用户"):
|
|
620
|
+
* ① **同一页只报一次**(reported 标志)—— bfcache 恢复/多路兜底重复触发都不再报;
|
|
621
|
+
* ② **全程静默**:任何异常都吞掉,失败绝不影响页面,也绝不做重试;
|
|
622
|
+
* ③ 只在**已有的**同一批请求之外发这一次(一次 POST,几十字节),不是测速、不轮询。
|
|
623
|
+
* body = { ms, outcome, at }:ms=耗时毫秒(截到 [0,600000],与契约一致);outcome ∈ {ok,slow}
|
|
624
|
+
* (slow = 25 秒看门狗兜底,是"删失下限",后台只把 ok 计入 P50/P90);at=事件发生的 epoch ms。
|
|
625
|
+
*/
|
|
626
|
+
function maReportMirrorLoad(outcome) {
|
|
627
|
+
if (maLoadWatch.reported) return;
|
|
628
|
+
maLoadWatch.reported = true;
|
|
629
|
+
try {
|
|
630
|
+
var ms = Math.round(maLoadElapsedMs());
|
|
631
|
+
if (!isFinite(ms) || ms < 0) ms = 0;
|
|
632
|
+
if (ms > 600000) ms = 600000;
|
|
633
|
+
maLoadWatch.reportMs = ms;
|
|
634
|
+
var body = JSON.stringify({ ms: ms, outcome: outcome === "slow" ? "slow" : "ok", at: Date.now() });
|
|
635
|
+
var p = null;
|
|
636
|
+
try {
|
|
637
|
+
p = fetch("/dsh-remote/telemetry/mirror-load", {
|
|
638
|
+
method: "POST",
|
|
639
|
+
headers: { "content-type": "application/json" },
|
|
640
|
+
body: body,
|
|
641
|
+
keepalive: true
|
|
642
|
+
});
|
|
643
|
+
} catch (e) { p = null; }
|
|
644
|
+
// 失败静默:不重试、不提示、不 console.error(这是纯遥测,任何噪音都是打扰)
|
|
645
|
+
if (p && typeof p.catch === "function") p.catch(function () { /* 忽略 */ });
|
|
646
|
+
} catch (e) { /* 遥测绝不把页面拖挂 */ }
|
|
647
|
+
}
|
|
524
648
|
/** 给客服看的一句话(含等待时长与 UA,便于直接定位)。 */
|
|
525
649
|
function maLoadCopyLine() {
|
|
526
650
|
let ua = "";
|
|
@@ -566,11 +690,14 @@ const SCRIPT = `(() => {
|
|
|
566
690
|
}
|
|
567
691
|
// 25 秒后仍认不出官方界面 → 置慢标记(**只改状态,不碰 DOM**)。
|
|
568
692
|
// 阈值取得很宽松:宁可漏报(什么都不显示,与今天一致),也不要误报吓人。
|
|
693
|
+
// 同一处顺手把 mirror_load 的**兜底样本**发出去(outcome="slow"):25 秒没就绪的页面上报的
|
|
694
|
+
// 必然是"≥25 秒",这是**删失下限**而不是真实耗时,后台只把它单列,不混进 P50/P90。
|
|
569
695
|
setTimeout(function () {
|
|
570
696
|
try {
|
|
571
697
|
if (maLoadReadyNow()) return;
|
|
572
698
|
maLoadWatch.slow = true;
|
|
573
699
|
if (maLoadWatch.wired) maLoadRenderWarn();
|
|
700
|
+
maReportMirrorLoad("slow");
|
|
574
701
|
} catch (e) { /* 忽略 */ }
|
|
575
702
|
}, 25 * 1000);
|
|
576
703
|
|
|
@@ -877,6 +1004,34 @@ const SCRIPT = `(() => {
|
|
|
877
1004
|
|
|
878
1005
|
/* 悬浮按钮/菜单:任何宽度都要有(电脑端访问镜像页同样需要「返回设备列表」的出口),
|
|
879
1006
|
与手机端布局改造分开守门;失败也不影响布局适配。 */
|
|
1007
|
+
/* —— 「打开右侧栏」的接线(落点见 buildRemoteMenu 里的按钮注释)——
|
|
1008
|
+
buildRemoteMenu 在**任何宽度**都会建(电脑端也要有返回设备列表的出口),而驱动第三列滑入的
|
|
1009
|
+
状态机(detailsSticky / centerTouchedAt / sync)只在 boot() 的窄屏段里存在 ——
|
|
1010
|
+
两者作用域不同、执行顺序也不保证(fab 先建)。所以用**一个模块级回调**做唯一接口:
|
|
1011
|
+
菜单按钮只调 maRightPaneRequest(),状态机自己决定怎么开,两边永不互相引用私有变量。 */
|
|
1012
|
+
let maOpenRightPane = null; // 由 boot() 的窄屏段注入;null = 还没建好或当前是宽屏
|
|
1013
|
+
/** 官方第三列(右栏)的「展开」按钮探测 —— 只用官方 aria-label / 结构,不认第三方标记。
|
|
1014
|
+
官方 0.1.5-rc.2:收起态下会话头有一颗 ExpandButton,展开后它就消失 ——
|
|
1015
|
+
所以"找不到"是正常情况(宽屏本来就不需要),不是错误。 */
|
|
1016
|
+
const maRightPaneExpandBtn = () => {
|
|
1017
|
+
try {
|
|
1018
|
+
const sels = [
|
|
1019
|
+
'[aria-label="Expand right sidebar"]', '[aria-label="Open right sidebar"]',
|
|
1020
|
+
'[aria-label="Expand right pane"]',
|
|
1021
|
+
'[aria-label="展开右侧栏"]', '[aria-label="打开右侧栏"]', '[aria-label="展开右栏"]'
|
|
1022
|
+
];
|
|
1023
|
+
for (let i = 0; i < sels.length; i++) {
|
|
1024
|
+
const el = document.querySelector(sels[i]);
|
|
1025
|
+
if (el) return el;
|
|
1026
|
+
}
|
|
1027
|
+
} catch (e) { /* 选择器异常/老浏览器:当作找不到 */ }
|
|
1028
|
+
return null;
|
|
1029
|
+
};
|
|
1030
|
+
const maRightPaneRequest = () => {
|
|
1031
|
+
if (typeof maOpenRightPane === "function") { try { maOpenRightPane(); return; } catch (e) { /* 落到下面兜底 */ } }
|
|
1032
|
+
// 宽屏 / 状态机还没建好:至少替用户点一下官方那颗展开按钮(没有就什么都不做,绝不报错)
|
|
1033
|
+
try { const b = maRightPaneExpandBtn(); if (b && b.click) b.click(); } catch (e2) { /* 忽略 */ }
|
|
1034
|
+
};
|
|
880
1035
|
let fabDone = false;
|
|
881
1036
|
const buildRemoteMenu = () => {
|
|
882
1037
|
/* ===== 统一「远程控制」悬浮按钮:字号 + 加密状态 + 返回设备列表 =====
|
|
@@ -938,11 +1093,19 @@ const SCRIPT = `(() => {
|
|
|
938
1093
|
'<div class="dsh-ma-menu-sub">当前 <b class="dsh-ma-menu-fsval">最小</b> · 只影响这台设备的远程页面</div>' +
|
|
939
1094
|
'<input type="range" min="0" max="3" step="1" value="0" aria-label="显示字号">' +
|
|
940
1095
|
'<div class="dsh-ma-menu-ticks"><span>最小</span><span>中</span><span>大</span><span>超大</span></div>' +
|
|
941
|
-
|
|
1096
|
+
// 网络延迟(0.6.18):样本来自**已有**的 fetch 包装,零新增请求;无样本时显示「测速中…」。
|
|
1097
|
+
// ⚠️ 单独一小节(不塞进「加密状态」):两者是独立的可观测项,混在一起会让"加密好好的、
|
|
1098
|
+
// 但网很慢"这种情况读起来像加密出了问题。
|
|
1099
|
+
'<div class="dsh-ma-menu-sec">网络延迟</div>' +
|
|
1100
|
+
'<div class="dsh-ma-menu-net" data-role="net"><span class="ico">📶</span><span class="txt">测速中…</span></div>' +
|
|
942
1101
|
'<div class="dsh-ma-e2ee-sec" data-role="e2ee-sec">' +
|
|
943
1102
|
'<div class="dsh-ma-menu-sec">加密状态</div>' +
|
|
944
1103
|
'<div class="dsh-ma-menu-e2ee" data-role="e2ee"><span class="ico">🔒</span><span class="txt">正在读取…</span></div>' +
|
|
945
1104
|
'</div>' +
|
|
1105
|
+
// 「打开右侧栏」(0.6.18):手机端第三列是离屏浮层,滑入闸门要求"用户 8 秒内在正文点过",
|
|
1106
|
+
// 而把按钮注册进官方右栏的第三方插件(如 dsh-better-sidebar)只翻它自己的 store,
|
|
1107
|
+
// 官方 cols.rightbar 永远是 0 → 面板永远不滑进来。这里给用户一条明路(详见下方 click 处理)。
|
|
1108
|
+
'<button type="button" class="dsh-ma-menu-right" data-role="right-pane">📋 打开右侧栏</button>' +
|
|
946
1109
|
'<button type="button" class="dsh-ma-menu-back">← 返回设备列表</button>' +
|
|
947
1110
|
'<div class="dsh-ma-load-warn" data-role="load-warn" hidden>' +
|
|
948
1111
|
'<span class="ico">⚠</span>' +
|
|
@@ -1006,20 +1169,25 @@ const SCRIPT = `(() => {
|
|
|
1006
1169
|
fab.type = "button";
|
|
1007
1170
|
fab.className = "dsh-ma-fab";
|
|
1008
1171
|
fab.setAttribute("aria-label", "远程控制菜单(可拖动)");
|
|
1009
|
-
fab.title = "远程控制:字号 / 加密状态 / 返回设备列表(可拖动移动位置)";
|
|
1010
|
-
|
|
1172
|
+
fab.title = "远程控制:字号 / 网络延迟 / 加密状态 / 返回设备列表(可拖动移动位置)";
|
|
1173
|
+
/* 图标(0.6.18 起):原来的「字」换成手机图标 —— 用户口述「要我们自己用的那个手机的图标」。
|
|
1174
|
+
锁 🔒 依旧在右上角(.dsh-ma-fab-lock),延迟角标在**正下方**(.dsh-ma-fab-rtt,见 CSS)。 */
|
|
1175
|
+
fab.innerHTML = '<span class="dsh-ma-fab-ico" aria-hidden="true">' + ${JSON.stringify(FAB_PHONE_SVG)} + '</span>'
|
|
1176
|
+
+ '<span class="dsh-ma-fab-lock" data-role="lock">🔒</span>'
|
|
1177
|
+
+ '<span class="dsh-ma-fab-rtt" data-role="rtt" hidden></span>';
|
|
1011
1178
|
fab.style.left = Math.round(clampFabXY(fabXY(fabPos)).left) + "px";
|
|
1012
1179
|
fab.style.top = Math.round(clampFabXY(fabXY(fabPos)).top) + "px";
|
|
1013
1180
|
document.body.appendChild(fab);
|
|
1014
1181
|
|
|
1015
1182
|
const fsRange = menu.querySelector('input[type="range"]');
|
|
1016
1183
|
const fsVal = menu.querySelector(".dsh-ma-menu-fsval");
|
|
1017
|
-
|
|
1184
|
+
/* 注:原来的「预览:手机远程控制,随手可用。」那一行(.dsh-ma-menu-preview)已按用户要求删除
|
|
1185
|
+
—— DOM / CSS / 这里的联动三处一起删。字号是否生效不需要一行示例文案:改滑块时
|
|
1186
|
+
官方正文(--dsh-content-font-size)与整页文本缩放是立刻可见的。 */
|
|
1018
1187
|
const setLevel = (level, persist) => {
|
|
1019
1188
|
const L = applyFsLevel(level);
|
|
1020
1189
|
if (fsVal) fsVal.textContent = L.name;
|
|
1021
1190
|
if (fsRange && Number(fsRange.value) !== level) fsRange.value = String(level);
|
|
1022
|
-
if (fsPreview) fsPreview.style.fontSize = L.content + "px";
|
|
1023
1191
|
if (persist) { try { window.localStorage && window.localStorage.setItem(FS_KEY, String(level)); } catch (e3) { /* 忽略 */ } }
|
|
1024
1192
|
};
|
|
1025
1193
|
setLevel(readFsLevel(), false);
|
|
@@ -1059,6 +1227,88 @@ const SCRIPT = `(() => {
|
|
|
1059
1227
|
renderE2ee();
|
|
1060
1228
|
[600, 2000, 5000].forEach((ms) => setTimeout(renderE2ee, ms)); // shim 可能晚于本层挂牌
|
|
1061
1229
|
|
|
1230
|
+
/* ---- 网络延迟(0.6.18)-------------------------------------------------------
|
|
1231
|
+
需求原话是「要能看见网络延迟」。这里**绝不主动测速**:任何 ping/定时打点都会给手机
|
|
1232
|
+
加流量、给中继加请求,而镜像页本来就有 HTTP 流量。样本全部取自下面那个**已经存在**的
|
|
1233
|
+
window.fetch 包装(0.6.17 起为「会话过期探测」而装,只读 res.status)。
|
|
1234
|
+
注入顺序保证了语义正确:bridge 先注入 adapter、后注入 e2ee shim
|
|
1235
|
+
(dsh-bridge.mjs:763 → :770),两者都 splice 到 </head> 前 → adapter 的 fetch 包装在
|
|
1236
|
+
**内层** → 它量到的是"去掉加解密后的真实网络往返"(这正是用户想要的"网速")。
|
|
1237
|
+
|
|
1238
|
+
统计口径:最近 RTT_KEEP(8) 次**成功**样本取中位数。
|
|
1239
|
+
· 用中位数而不是均值/EWMA:偶发的一次 GC/系统调度卡顿会把均值永久带偏,
|
|
1240
|
+
中位数对单次离群点免疫,同时比"最小值"更贴近体感(最小值永远偏乐观);
|
|
1241
|
+
· 失败(reject)/超时(>20s)/亚毫秒(同一帧内的缓存命中)都不进样本:
|
|
1242
|
+
前者不是网络耗时,后者会算出 0ms 这种"谎言";
|
|
1243
|
+
· **无样本时显示「测速中…」而不是 0ms** —— 0ms 会让人以为网络飞快。
|
|
1244
|
+
分档:≤120ms 顺畅 / ≤300ms 一般 / >300ms 较慢(移动网络下这三档比较贴合体感)。 */
|
|
1245
|
+
const RTT_KEEP = 8, RTT_MAX_MS = 20000;
|
|
1246
|
+
const rttSamples = [];
|
|
1247
|
+
const rttEl = fab.querySelector('[data-role="rtt"]');
|
|
1248
|
+
const netEl = menu.querySelector('[data-role="net"]');
|
|
1249
|
+
const rttValue = () => {
|
|
1250
|
+
if (!rttSamples.length) return null;
|
|
1251
|
+
const s = rttSamples.slice().sort((a, b) => a - b);
|
|
1252
|
+
const mid = s.length >> 1;
|
|
1253
|
+
return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
|
|
1254
|
+
};
|
|
1255
|
+
const rttGrade = (ms) => (ms <= 120 ? "ok" : ms <= 300 ? "mid" : "bad");
|
|
1256
|
+
const rttLabel = (g) => (g === "ok" ? "顺畅" : g === "mid" ? "一般" : "较慢");
|
|
1257
|
+
const FAB_TITLE_BASE = "远程控制:字号 / 网络延迟 / 加密状态 / 返回设备列表(可拖动移动位置)";
|
|
1258
|
+
const renderRtt = () => {
|
|
1259
|
+
try {
|
|
1260
|
+
const ms = rttValue();
|
|
1261
|
+
const g = ms === null ? "" : rttGrade(ms);
|
|
1262
|
+
if (rttEl) {
|
|
1263
|
+
if (ms === null) { rttEl.hidden = true; rttEl.textContent = ""; rttEl.removeAttribute("data-rtt"); }
|
|
1264
|
+
else {
|
|
1265
|
+
rttEl.hidden = false;
|
|
1266
|
+
rttEl.textContent = ms + "ms";
|
|
1267
|
+
rttEl.setAttribute("data-rtt", g);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (netEl) {
|
|
1271
|
+
netEl.setAttribute("data-rtt", g || "");
|
|
1272
|
+
const ico = netEl.querySelector(".ico");
|
|
1273
|
+
const txt = netEl.querySelector(".txt");
|
|
1274
|
+
if (ico) ico.textContent = ms === null ? "📶" : (g === "bad" ? "📵" : "📶");
|
|
1275
|
+
if (txt) txt.textContent = ms === null ? "测速中…(等一次网络请求后显示)" : (ms + " ms · " + rttLabel(g));
|
|
1276
|
+
}
|
|
1277
|
+
try { fab.title = ms === null ? FAB_TITLE_BASE : ("远程控制:网络延迟 " + ms + "ms(" + rttLabel(g) + ") / 字号 / 加密状态 / 返回设备列表(可拖动)"); } catch (e) { /* 忽略 */ }
|
|
1278
|
+
} catch (e) { /* 延迟显示失败绝不影响主功能 */ }
|
|
1279
|
+
};
|
|
1280
|
+
/** 记一个成功样本(由 fetch 包装在**响应到达**时调用;失败/超时不调用)。 */
|
|
1281
|
+
const noteRtt = (ms) => {
|
|
1282
|
+
if (typeof ms !== "number" || !isFinite(ms)) return;
|
|
1283
|
+
const r = Math.round(ms);
|
|
1284
|
+
if (r < 1 || r > RTT_MAX_MS) return; // 亚毫秒=同帧缓存命中;>20s=卡住/超时 → 都不是"网络延迟"
|
|
1285
|
+
rttSamples.push(r);
|
|
1286
|
+
while (rttSamples.length > RTT_KEEP) rttSamples.shift();
|
|
1287
|
+
renderRtt();
|
|
1288
|
+
};
|
|
1289
|
+
renderRtt();
|
|
1290
|
+
|
|
1291
|
+
/* 「打开右侧栏」:手机端第三列(官方右栏)是**离屏浮层**,唯一滑入闸门在 sync() 里:
|
|
1292
|
+
detailsAttrLive() && !detailsCollapsed() && (用户 8s 内在正文点过 || detailsSticky)
|
|
1293
|
+
而 better-sidebar 这类"把按钮注册进官方右栏"的插件只翻它**自己**的 store,官方 cols.rightbar
|
|
1294
|
+
永远是 0 → 面板永远不会滑进来(用户报的「右侧按钮区看不见」)。
|
|
1295
|
+
这里给用户一条**明路**:把"我要看右侧栏"登记成和"在正文里点了一下"等价的意图(不受 8s 窗口
|
|
1296
|
+
限制),并在官方确实有展开按钮时替用户点一下。
|
|
1297
|
+
⚠️ 探测只用**官方类名/aria-label/结构**,绝不依赖 better-sidebar 的私有标记
|
|
1298
|
+
(data-rightbar-col / data-dsh-center-col)—— 那正是 0.6.15/0.6.16 反复踩的坑:
|
|
1299
|
+
插件换版本或被卸载,标记就没了,适配层会跟着失灵。
|
|
1300
|
+
⚠️ 状态机在 boot() 的窄屏段里(buildRemoteMenu 宽窄都建、且可能更早执行),
|
|
1301
|
+
所以通过模块级回调 maOpenRightPane 单向调用;拿不到回调(宽屏/还没建好)时
|
|
1302
|
+
退化成"只点官方那颗展开按钮"。 */
|
|
1303
|
+
const rightBtn = menu.querySelector('[data-role="right-pane"]');
|
|
1304
|
+
if (rightBtn) {
|
|
1305
|
+
rightBtn.addEventListener("click", (e) => {
|
|
1306
|
+
e.stopPropagation(); e.preventDefault();
|
|
1307
|
+
closeMenu();
|
|
1308
|
+
maRightPaneRequest();
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1062
1312
|
/* ---- 交互 ---- */
|
|
1063
1313
|
/* 菜单跟随按钮:按钮在上半屏 → 菜单挂在它下方;按钮靠左 → 菜单左对齐(都不超出视口)。 */
|
|
1064
1314
|
const placeMenu = () => {
|
|
@@ -1084,7 +1334,9 @@ const SCRIPT = `(() => {
|
|
|
1084
1334
|
} catch (e) { /* 定位失败不影响功能(退回 CSS 默认位置) */ }
|
|
1085
1335
|
};
|
|
1086
1336
|
const closeMenu = () => { menu.hidden = true; };
|
|
1087
|
-
|
|
1337
|
+
// 打开菜单时刷新一次延迟显示(与 renderE2ee 同理:被动展开的 UI 不需要定时器,
|
|
1338
|
+
// 打开的那一刻就是最新的)
|
|
1339
|
+
const openMenu = () => { renderE2ee(); renderRtt(); menu.hidden = false; placeMenu(); };
|
|
1088
1340
|
let suppressClickUntil = 0; // 拖完的那一下不要当成"点击打开菜单"
|
|
1089
1341
|
let tapHandledAt = 0; // 轻点已在 pointerup 里处理过(避免 click 再翻一次)
|
|
1090
1342
|
const toggleMenu = () => { if (menu.hidden) openMenu(); else closeMenu(); };
|
|
@@ -1198,14 +1450,24 @@ const SCRIPT = `(() => {
|
|
|
1198
1450
|
window.fetch = function (input, init) {
|
|
1199
1451
|
let url = "";
|
|
1200
1452
|
try { url = typeof input === "string" ? input : (input && input.url) || ""; } catch (e5) { url = ""; }
|
|
1453
|
+
/* 网络延迟采样(0.6.18):**复用这处已有的包装**,零新增请求。
|
|
1454
|
+
t0 取在真正发请求之前、响应到达之后读差值 —— 量到的就是这一趟的真实往返。
|
|
1455
|
+
⚠️ 只在同源且"像 API"的请求上量(与下面会话过期探测同一组判定):
|
|
1456
|
+
静态壳/图片/字体不该代表"网络延迟";telemetry 那条 /dsh-remote/telemetry/... 也
|
|
1457
|
+
不在 apiish 里(它既不含 "/api/" 也不含 "/remote/",只有 "-remote/"),
|
|
1458
|
+
所以一次性的遥测上报不会污染样本。
|
|
1459
|
+
⚠️ 失败(reject)/超时(>20s)不进样本 —— 那不是"延迟",是"没通"(noteRtt 里已过滤)。 */
|
|
1460
|
+
const t0 = maNowMs();
|
|
1201
1461
|
const p = origFetch.call(this, input, init);
|
|
1202
1462
|
try {
|
|
1203
1463
|
const abs = url.slice(0, 8).toLowerCase();
|
|
1204
1464
|
const sameOrigin = !(abs.indexOf("http://") === 0 || abs.indexOf("https://") === 0) || url.indexOf(window.location.origin) === 0;
|
|
1205
1465
|
const apiish = url.indexOf("/api/") >= 0 || url.indexOf("/remote/") >= 0 || url.indexOf("_devices") >= 0 || url.indexOf("_quota") >= 0;
|
|
1206
1466
|
if (sameOrigin && apiish && p && typeof p.then === "function") {
|
|
1207
|
-
p.then((res) => {
|
|
1208
|
-
|
|
1467
|
+
p.then((res) => {
|
|
1468
|
+
try { noteStatus(res && res.status); } catch (e6) { /* 忽略 */ }
|
|
1469
|
+
try { noteRtt(maNowMs() - t0); } catch (e6b) { /* 忽略 */ }
|
|
1470
|
+
}, () => { /* 网络错误不算会话过期,也不算延迟样本 */ });
|
|
1209
1471
|
}
|
|
1210
1472
|
} catch (e7) { /* 忽略 */ }
|
|
1211
1473
|
return p;
|
|
@@ -1382,6 +1644,7 @@ const SCRIPT = `(() => {
|
|
|
1382
1644
|
e.stopPropagation();
|
|
1383
1645
|
userWantsOpen = false; // 用户明确要关(遮罩的作用就是关抽屉)
|
|
1384
1646
|
centerTouchedAt = 0; detailsSticky = false; // 顺带收回"要看详情"的意图(下次在正文里点一下即可恢复)
|
|
1647
|
+
rightPaneWanted = false; // 也收回「打开右侧栏」的显式请求
|
|
1385
1648
|
const t = toggleOf();
|
|
1386
1649
|
if (t) { t.click(); }
|
|
1387
1650
|
/* 兜底 + 保险:无论官方 toggle 在不在,都确保"点空白处"能关掉/不再拦截 */
|
|
@@ -1400,10 +1663,15 @@ const SCRIPT = `(() => {
|
|
|
1400
1663
|
e.stopPropagation(); e.preventDefault();
|
|
1401
1664
|
const nowOpen = document.documentElement.classList.contains("dsh-ma-sidebar-open");
|
|
1402
1665
|
// 先登记用户意图:即便官方 toggle 不存在、或几何判定一时不满足,这次展开也必须成立
|
|
1403
|
-
|
|
1666
|
+
const desired = !nowOpen;
|
|
1667
|
+
userWantsOpen = desired;
|
|
1404
1668
|
const t = toggleOf();
|
|
1405
|
-
|
|
1406
|
-
|
|
1669
|
+
/* ⚠️ 0.6.18 修(用户报的「点官方抽屉里的收起按钮 → 白柱 + 整屏被困,只有刷新能出去」):
|
|
1670
|
+
旧实现无论官方当前是什么状态都无条件 t.click() —— 于是"适配层以为开着、官方其实已收起"时,
|
|
1671
|
+
这一点会把官方**推回展开**,而我们的 class 被摘掉 → 抽屉隐形;再点一次又反过来,
|
|
1672
|
+
两态互相打架、白柱反复重现。现在只在**官方当前态 ≠ 期望态**时才点官方 toggle:
|
|
1673
|
+
状态一致时点它只会把官方推到相反方向。 */
|
|
1674
|
+
if (t && expanded() !== desired) { t.click(); }
|
|
1407
1675
|
sync(); // 立刻按意图生效(不依赖官方属性回调)
|
|
1408
1676
|
});
|
|
1409
1677
|
|
|
@@ -1437,6 +1705,31 @@ const SCRIPT = `(() => {
|
|
|
1437
1705
|
};
|
|
1438
1706
|
const sidebarInView = () => isDrawer(sidebar || document.querySelector(".dsh-ma-sidebar"));
|
|
1439
1707
|
const detailsInView = () => isDrawer(details || document.querySelector(".dsh-ma-details, .dsh-ma-rightbar"));
|
|
1708
|
+
/**
|
|
1709
|
+
* 抽屉列是不是**空壳**:官方已经把内容收起来了,只剩一个宽度还在的固定柱。
|
|
1710
|
+
*
|
|
1711
|
+
* 为什么需要(0.6.18 用户报的白柱):用户点官方抽屉里的收起按钮后,userWantsOpen 仍为 true
|
|
1712
|
+
* → html.dsh-ma-sidebar-open 常亮 → CSS 把 sidebarCol 钉成 fixed + translateX(0) + z-index:310,
|
|
1713
|
+
* 而官方已把列宽压成 0、内容收起 → 剩下一个**没有内容的白色固定柱**盖住左半屏;
|
|
1714
|
+
* 它几何上"在视口里"(width≈340 < 0.98vw),连遮罩不变量都会误判成"抽屉还在"→ 整屏被困。
|
|
1715
|
+
* 这里让"空壳"不算可交互抽屉,遮罩就有机会被摘掉(不变量见 enforceScrimInvariant)。
|
|
1716
|
+
*
|
|
1717
|
+
* ⚠️ 判不出来时一律当作"不是空壳"(保守:宁可多留遮罩一帧,也绝不在正常抽屉上误摘)。
|
|
1718
|
+
* ⚠️ 只对**抽屉列**判:详情列在"还没挂内容"时是合法状态(官方按需挂载),
|
|
1719
|
+
* 对它用同一条判据会把"详情列展开"的正常路径一起判死。
|
|
1720
|
+
*/
|
|
1721
|
+
const isShellColumn = (el) => {
|
|
1722
|
+
if (!el) return true;
|
|
1723
|
+
try {
|
|
1724
|
+
// ① 有实质子节点 → 不是空壳(折叠≠卸载:官方收起时面板仍挂载)
|
|
1725
|
+
const kids = el.children;
|
|
1726
|
+
if (!kids || typeof kids.length !== "number") return false;
|
|
1727
|
+
if (kids.length > 0) return false;
|
|
1728
|
+
// ② 真浏览器里还能再看一眼可视高度:高度为 0 = 内容确实没了
|
|
1729
|
+
if (typeof el.clientHeight === "number" && el.clientHeight === 0) return true;
|
|
1730
|
+
return true; // 子节点为空 → 空壳(官方折叠后内容被卸载的情况)
|
|
1731
|
+
} catch (e) { return false; }
|
|
1732
|
+
};
|
|
1440
1733
|
/* 第三列折叠态:官方 0.1.2-rc.1 是 data-details-collapsed,0.1.5-rc.2 起是 data-rightbar-collapsed;
|
|
1441
1734
|
两个都认;都不存在(未来再改名)时**不当作展开**(见下面 detailsAttrLive)。 */
|
|
1442
1735
|
const detailsCollapsed = () =>
|
|
@@ -1457,6 +1750,12 @@ const SCRIPT = `(() => {
|
|
|
1457
1750
|
点菜单按钮加 class → 动画未开始、rect 仍在屏外 → sync 立刻把 class 摘掉 → 抽屉永远打不开。
|
|
1458
1751
|
现在只让几何判定决定**遮罩能否拦截点击**(最后一道保险),不再否决用户的展开意图。 */
|
|
1459
1752
|
let userWantsOpen = null; // null=未表达意图(跟随官方); true/false=用户已明确开/关
|
|
1753
|
+
/* 0.6.18 白柱修复的辅助状态(见 sync() 的长注释):
|
|
1754
|
+
· lastOfficialCollapsed —— 上一次 sync 时官方说的折叠态;用它识别"官方**刚刚**收起"
|
|
1755
|
+
这个**边沿**(只有边沿才算"用户点了抽屉内的收起按钮",才不会误杀用户刚表达的展开意图);
|
|
1756
|
+
· officialExpandedSeen —— 是否**见过官方说"展开"**(与上一条共同构成边沿的合法性)。 */
|
|
1757
|
+
let officialExpandedSeen = false;
|
|
1758
|
+
let lastOfficialCollapsed = null;
|
|
1460
1759
|
let scrimTimer = 0, scrimWatchdog = 0;
|
|
1461
1760
|
/* ---- 详情列(第三列)在手机端的显示条件 ----------------------------------------
|
|
1462
1761
|
⚠️ 为什么不能"官方属性说展开就滑进来"(2026-09-19 白框事故的根因之一):
|
|
@@ -1483,6 +1782,32 @@ const SCRIPT = `(() => {
|
|
|
1483
1782
|
const CENTER_TOUCH_MS = 8000;
|
|
1484
1783
|
let centerTouchedAt = 0;
|
|
1485
1784
|
let detailsSticky = false;
|
|
1785
|
+
/* 「打开右侧栏」的**显式**请求(0.6.18,来自悬浮菜单那颗按钮)。
|
|
1786
|
+
与 centerTouchedAt/detailsSticky 的区别:那两个是"用户大概想看点细节"的弱信号,
|
|
1787
|
+
这个是"我就是要看官方右栏"的强信号 —— 强信号才允许跳过"官方说未收起"这一条
|
|
1788
|
+
(better-sidebar 这类只翻自己 store 的插件永远不会让官方 cols.rightbar 变正)。 */
|
|
1789
|
+
let rightPaneWanted = false;
|
|
1790
|
+
/**
|
|
1791
|
+
* 官方右栏里到底有没有"宿主注册的内容"?—— 有内容才值得滑进来。
|
|
1792
|
+
* 判据只用**官方结构 + 几何**,绝不认 better-sidebar 的私有标记
|
|
1793
|
+
* (data-rightbar-col / data-dsh-center-col —— R2 明确记录那是 0.6.15/0.6.16 反复踩的坑:
|
|
1794
|
+
* 插件换版本/被卸载,标记就没了,适配层跟着失灵)。
|
|
1795
|
+
* 为什么重要:官方右栏收起时**面板仍然挂载**(只是平移出 frame 右缘),所以"有子节点"
|
|
1796
|
+
* 就足以说明"这颗按钮后面真有东西可看",而不是一片空白。
|
|
1797
|
+
*/
|
|
1798
|
+
const rightPaneHasContent = () => {
|
|
1799
|
+
try {
|
|
1800
|
+
const el = details || document.querySelector(".dsh-ma-details, .dsh-ma-rightbar");
|
|
1801
|
+
if (!el) return false;
|
|
1802
|
+
const kids = el.children;
|
|
1803
|
+
if (!kids || typeof kids.length !== "number") return false;
|
|
1804
|
+
if (kids.length === 0) return false;
|
|
1805
|
+
/* 官方折叠时宽度会被压到 0;但我们自己在窄屏把它改成 fixed + translateX(103%),
|
|
1806
|
+
所以宽度不再是"官方有没有内容"的判据,只用来排除"被压成 0 宽的列"。 */
|
|
1807
|
+
if (typeof el.clientWidth === "number" && el.clientWidth === 0) return false;
|
|
1808
|
+
return true;
|
|
1809
|
+
} catch (e2) { return false; }
|
|
1810
|
+
};
|
|
1486
1811
|
/* scrim 的"到底有没有在拦"以浏览器算出来的 pointer-events 为准(class 只是我们的意图) */
|
|
1487
1812
|
const scrimBlocks = () => {
|
|
1488
1813
|
const html = document.documentElement;
|
|
@@ -1494,7 +1819,8 @@ const SCRIPT = `(() => {
|
|
|
1494
1819
|
} catch (e2) { /* 忽略 */ }
|
|
1495
1820
|
return true; // 认不出实际样式 → 以 class 为准(保守:当成在拦)
|
|
1496
1821
|
};
|
|
1497
|
-
const anyDrawerInView = () =>
|
|
1822
|
+
const anyDrawerInView = () =>
|
|
1823
|
+
(sidebarInView() && !isShellColumn(sidebar || document.querySelector(".dsh-ma-sidebar"))) || detailsInView();
|
|
1498
1824
|
/* 遮罩闸门:**只有此刻真的看得见抽屉**才允许拦截点击。
|
|
1499
1825
|
抽屉是滑入动画(.22s),所以同步判定必然"还看不见" —— 那一瞬间不下闸即可,
|
|
1500
1826
|
真正的下闸交给下一帧与动画结束后的复核(applyScrim),既不误拦也不影响手感。
|
|
@@ -1539,6 +1865,27 @@ const SCRIPT = `(() => {
|
|
|
1539
1865
|
} catch (e2) { scrimWatchdog = 0; }
|
|
1540
1866
|
};
|
|
1541
1867
|
const sync = () => {
|
|
1868
|
+
/* ★ 0.6.18 修(用户报的「点官方抽屉里的收起按钮 → 左半边一片白色占位,只有刷新能出去」):
|
|
1869
|
+
识别的关键是**边沿**而不是状态 —— 官方"刚刚从展开翻转成收起"(collapsedEdge)才是
|
|
1870
|
+
"用户点了抽屉内的收起按钮"这个动作。为什么不能只看"官方说收起":用户点汉堡展开时
|
|
1871
|
+
官方属性往往还没跟上(toggle 点了 store 还没提交,或官方 toggle 压根不存在),
|
|
1872
|
+
只按状态复位会把用户的展开意图当场掐掉 → 抽屉永远打不开(0.6.6-beta.3 的回归点,
|
|
1873
|
+
护栏用例守着);也不能只靠几何(白柱 width≈340,"在视口里",几何判不出它是空壳)。 */
|
|
1874
|
+
const officialCollapsed = collapsed();
|
|
1875
|
+
const collapsedEdge = officialCollapsed && lastOfficialCollapsed === false;
|
|
1876
|
+
if (!officialCollapsed) officialExpandedSeen = true;
|
|
1877
|
+
lastOfficialCollapsed = officialCollapsed;
|
|
1878
|
+
if (userWantsOpen === true && collapsedEdge) userWantsOpen = null; // 官方收起 → 放弃过期意图
|
|
1879
|
+
/* 把**官方**的折叠态与我们**当前的展开意图**一起镜像到 <html>:
|
|
1880
|
+
CSS 依据这两者做最后一道不依赖任何 JS 判定的硬保证(见 STYLE 里的
|
|
1881
|
+
html.dsh-ma-official-collapsed:not(.dsh-ma-user-wants-open) 那条)——
|
|
1882
|
+
"官方说收起、且我们已经没有活的展开意图"时,这一列一律留在屏外。
|
|
1883
|
+
为什么这样收窄:用户明确要开(意图为 true)时必须能开,哪怕官方 toggle 坏了、
|
|
1884
|
+
属性永远是"收起"(那是 0.6.9-beta.2 就定下的自救路径,护栏用例守着)。 */
|
|
1885
|
+
try {
|
|
1886
|
+
document.documentElement.classList.toggle("dsh-ma-official-collapsed", officialCollapsed);
|
|
1887
|
+
document.documentElement.classList.toggle("dsh-ma-user-wants-open", userWantsOpen === true);
|
|
1888
|
+
} catch (e0) { /* 忽略 */ }
|
|
1542
1889
|
const open = userWantsOpen !== null ? userWantsOpen : expanded();
|
|
1543
1890
|
document.documentElement.classList.toggle("dsh-ma-sidebar-open", open);
|
|
1544
1891
|
/* 汉堡按钮**始终可见**:以前判定为"展开"时会把它 display:none 隐藏,
|
|
@@ -1546,14 +1893,21 @@ const SCRIPT = `(() => {
|
|
|
1546
1893
|
保持可见 + 点它可开可关(见其 click 处理),任何异常状态下都留一条出路。
|
|
1547
1894
|
它的 z-index(320) 高于抽屉(310)/详情列(300)/遮罩(290) —— 判定怎么错都点得到。 */
|
|
1548
1895
|
if (hamburger) hamburger.style.display = "";
|
|
1549
|
-
/* 第三列(详情/rightbar)
|
|
1550
|
-
①
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1896
|
+
/* 第三列(详情/rightbar)在手机端要不要滑进来(0.6.18 四层判据):
|
|
1897
|
+
① 官方说得清(detailsAttrLive)+ 官方未收起 + 用户此刻想看 → 滑入(原判据);
|
|
1898
|
+
② **用户从 FAB 明确要求**(rightPaneWanted)+ 右栏确实有内容 → 也滑入。为什么必须绕过
|
|
1899
|
+
"官方未收起":better-sidebar 这类把按钮注册进官方右栏的插件只翻它**自己**的 store,
|
|
1900
|
+
官方 cols.rightbar 永远是 0 → 等它松口 = 面板永远打不开(用户报的「右侧按钮区看不见」)。
|
|
1901
|
+
而"有没有内容"用官方结构/几何判,**绝不**认任何第三方私有标记;
|
|
1902
|
+
③ 官方属性改名了(detailsAttrLive 为假)但右栏里确实有内容 → 在用户已表达"要看"的前提下
|
|
1903
|
+
也允许滑入(未来官方再改名的兜底;"载入即滑出白框"由 wantsRightPane 挡掉 —— 它要求
|
|
1904
|
+
先有用户动作,那正是 2026-09-19 白框事故的根因);
|
|
1905
|
+
④ 官方收起 → 立刻退出,并复位所有粘性意图(下次点正文 / 点 FAB 即可恢复)。 */
|
|
1906
|
+
if (detailsCollapsed()) { detailsSeenCollapsed = true; detailsSticky = false; rightPaneWanted = false; }
|
|
1555
1907
|
const touchedRecently = centerTouchedAt !== 0 && (Date.now() - centerTouchedAt) <= CENTER_TOUCH_MS;
|
|
1556
|
-
const
|
|
1908
|
+
const wantsRightPane = touchedRecently || detailsSticky || rightPaneWanted;
|
|
1909
|
+
const officialOpen = detailsAttrLive() && !detailsCollapsed();
|
|
1910
|
+
const dOpen = wantsRightPane && (officialOpen || rightPaneHasContent());
|
|
1557
1911
|
if (dOpen) detailsSticky = true;
|
|
1558
1912
|
document.documentElement.classList.toggle("dsh-ma-details-open", dOpen);
|
|
1559
1913
|
// 遮罩:同步先按当前几何下闸(抽屉已在视口内时无延迟),再在动画开始/结束后复核两次
|
|
@@ -1565,6 +1919,23 @@ const SCRIPT = `(() => {
|
|
|
1565
1919
|
} catch (e2) { /* 忽略 */ }
|
|
1566
1920
|
armScrimWatchdog();
|
|
1567
1921
|
};
|
|
1922
|
+
/* 「打开右侧栏」的真正实现(0.6.18;悬浮菜单那颗按钮通过模块级 maOpenRightPane 回调进来)——
|
|
1923
|
+
状态机在这里,菜单那边看不到 detailsSticky/sync(两者作用域与创建时机都不同:
|
|
1924
|
+
菜单宽窄都建、可能更早;状态机只在 boot() 的窄屏段)。
|
|
1925
|
+
① 登记"我就是要看官方右栏"的强意图(不受 8s 窗口限制);
|
|
1926
|
+
② 官方若真有展开按钮就替用户点一下(探测失败是正常的:宽屏/已展开时它本来就不在);
|
|
1927
|
+
③ sync() 立刻按意图生效;官方展开是异步的,动画结束(≈260ms)后再对齐一次。 */
|
|
1928
|
+
maOpenRightPane = () => {
|
|
1929
|
+
rightPaneWanted = true;
|
|
1930
|
+
detailsSticky = true; // 双保险:即便官方 toggle 点不动,判据②也放行
|
|
1931
|
+
centerTouchedAt = Date.now();
|
|
1932
|
+
try {
|
|
1933
|
+
const b = maRightPaneExpandBtn();
|
|
1934
|
+
if (b && b.click && detailsCollapsed()) b.click();
|
|
1935
|
+
} catch (eR) { /* 探测/点击失败不影响下面的 sync */ }
|
|
1936
|
+
sync();
|
|
1937
|
+
try { setTimeout(() => { try { sync(); } catch (eS) { /* 忽略 */ } }, 260); } catch (eS2) { /* 忽略 */ }
|
|
1938
|
+
};
|
|
1568
1939
|
/* 用户在主内容列里点一下 → 视为"他要看详情"(手机端显示详情列的唯一开关)。
|
|
1569
1940
|
为什么必须由用户动作来解锁:官方表达"详情展开"的方式有两种 —— 属性被移除,或写成 =false,
|
|
1570
1941
|
而"载入时官方就是展开的"多半是宽屏/上次会话留下的状态;手机端一进来就滑出一个空白白面板
|
|
@@ -1695,6 +2066,10 @@ const SCRIPT = `(() => {
|
|
|
1695
2066
|
if (maTorn) return;
|
|
1696
2067
|
if (bootUseful()) { try { boot(); } catch (eBoot) { /* 幂等重试 */ } }
|
|
1697
2068
|
try { ensureDrawerChrome(); } catch (eChrome2) { /* 忽略 */ }
|
|
2069
|
+
/* 镜像页就绪探测(mirror_load 的量测终点):pump 本来就被 MutationObserver(子树) + 1s 兜底表
|
|
2070
|
+
驱动,官方 frame/输入框一挂上就会在**这一拍或下一拍**(≤1s)命中 —— 这正是契约要求的
|
|
2071
|
+
"终点的时刻",而且不需要再加任何定时器或观察者。只在还没上报过时判一次(reported 挡重复)。 */
|
|
2072
|
+
if (!maLoadWatch.reported) { try { if (maLoadReadyNow()) maReportMirrorLoad("ok"); } catch (eLoadOk) { /* 忽略 */ } }
|
|
1698
2073
|
if (!whaleGuardsOn || !settingsWatchOn) {
|
|
1699
2074
|
try { whaleHookInit(); watchSettings(); } catch (eHook) { /* 两者内部都自带一次性守卫 */ }
|
|
1700
2075
|
}
|