@mrrisega/dsh-remote 0.6.15 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -100,33 +100,6 @@ const RUNTIME_HOST_RE =
100
100
  /(data-sidebar-collapsed|data-details-collapsed|data-rightbar-collapsed|data-shell-overlay)|(pI_x6G_frame|hHd-Xa_root|hHd-Xa_toggle)/;
101
101
 
102
102
  const STYLE = `
103
- /* ===== 首屏加载提示(0.6.14)=====
104
- 为什么要有它:镜像页首屏要拉 dsh web 的全部客户端插件(实测 ~14 MB,一个不可拆分的
105
- /plugins/?? 聚合请求),免费档限速下几十秒起步。此前页面上**只有官方那个转圈**,
106
- 用户无法区分"在加载"和"卡死了",只能反复刷新 —— 反而更慢。
107
- 这里给一句人话提示 + 分阶段的补充说明,加载完成(或超时兜底)即自动移除。
108
- ⚠️ 刻意**不**放进 @media (max-width:820px):Windows/桌面浏览器打开镜像页同样要等。
109
- ⚠️ pointer-events:none + 半透明:万一移除逻辑失效,也绝不挡住任何点击。 */
110
- .dsh-ma-boot {
111
- position: fixed; inset: 0; z-index: 2147483000;
112
- display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
113
- background: color-mix(in srgb, var(--dsw-alias-bg-base, #0f1115) 88%, transparent);
114
- backdrop-filter: blur(2px);
115
- pointer-events: none;
116
- font-family: inherit; text-align: center; padding: 24px;
117
- transition: opacity .3s ease;
118
- }
119
- .dsh-ma-boot[data-fading="1"] { opacity: 0; }
120
- .dsh-ma-boot-spin {
121
- width: 34px; height: 34px; border-radius: 50%;
122
- border: 3px solid color-mix(in srgb, currentColor 22%, transparent);
123
- border-top-color: currentColor;
124
- animation: dsh-ma-boot-spin 1s linear infinite;
125
- }
126
- @keyframes dsh-ma-boot-spin { to { transform: rotate(360deg); } }
127
- .dsh-ma-boot-title { font-size: 15px; font-weight: 600; }
128
- .dsh-ma-boot-sub { font-size: 13px; line-height: 1.6; opacity: .75; max-width: 22em; }
129
- .dsh-ma-boot-elapsed { font-size: 12px; opacity: .55; font-variant-numeric: tabular-nums; }
130
103
 
131
104
  @media (max-width: 820px) {
132
105
  html, body { max-width: 100%; overflow-x: hidden; }
@@ -458,88 +431,25 @@ html[data-dsh-ma-merged-e2ee] #dsh-e2ee-badge { display: none !important; }
458
431
  const SCRIPT = `(() => {
459
432
  "use strict";
460
433
 
461
- /* ===== 首屏加载提示(0.6.14)=====
462
- 用户反馈:「普通用户加载进入镜像页面时等待时间较长,目前等待过程中一直在转圈」
463
- —— 官方那个转圈不告诉用户任何事。这里在**页面解析的第一时间**就挂上一条人话提示,
464
- 并在官方 UI 就绪(或兜底超时)后自动移除。
434
+ /* ===== 【0.6.16 已删除】首屏加载提示层(0.6.14 引入,0.6.15 仍在)=====
435
+ 为什么删掉(业主实测,2026-09-25):「界面都已经加载完了,十几秒就把插件的接口加载好了,
436
+ 但这个遮罩一直不消失」—— 截图里官方 UI 就在下面透出来,秒表走到 187 秒。
437
+
438
+ 根因是**设计错误**,不是某个选择器写错:
439
+ · 那是个 position:fixed / inset:0 / z-index:2147483000 的**全屏层**(88% 不透明背景 +
440
+ backdrop-blur),视觉上盖住整个应用;
441
+ · 它唯一的退出条件是 maBootLooksReady() 这组**DOM 启发式**(官方 frame 的
442
+ data-*-collapsed / 类名前缀,或 textarea/[contenteditable]/[role=textbox])。
443
+ 官方改版、会话尚未挂载输入框、或前缀变化时两组信号全不命中 → 层就永远留着。
444
+ · 当时我给自己写的安慰是「pointer-events:none,万一移除失效也绝不挡住点击」——
445
+ 可对用户来说,**盖住视线和进不去没有区别**:刷新也一样,每次都要等 4 分钟兜底。
446
+ 结论(业主要求):**直接删掉这个功能**。首屏反馈交给官方自己的转圈;
447
+ 我们不再在用户页面上放任何会覆盖整屏的东西 —— 这类层一旦判断失误就是"永远进不去",
448
+ 代价远大于它带来的提示价值(何况限速放开后首屏本来就快了)。
449
+
450
+ 护栏见 test/mobile-adapter-guards.test.mjs:适配层不得再引入全屏覆盖层。 */
451
+ void 0;
465
452
 
466
- ⚠️ 必须放在所有主机判定(HOSTISH / NARROW)之前:首屏慢的正是这些判定等不到的时候。
467
- ⚠️ 只做提示,不做任何拦截:pointer-events:none,也没有遮罩语义。 */
468
- var maBootTip = null, maBootTimers = [], maBootStart = Date.now(), maBootElapsedTimer = 0, maBootWatchTimer = 0;
469
- function maBootSet(cls, text) {
470
- try { var n = maBootTip && maBootTip.querySelector(cls); if (n) n.textContent = text; } catch (e) { /* 忽略 */ }
471
- }
472
- function maBootShow() {
473
- try {
474
- if (maBootTip) return;
475
- if (!document.body) return; // 解析太早(body 还没出来)→ 交给 DOMContentLoaded 再挂
476
- maBootTip = document.createElement("div");
477
- maBootTip.className = "dsh-ma-boot";
478
- maBootTip.setAttribute("role", "status");
479
- maBootTip.setAttribute("aria-live", "polite");
480
- maBootTip.innerHTML =
481
- '<div class="dsh-ma-boot-spin" aria-hidden="true"></div>' +
482
- '<div class="dsh-ma-boot-title">正在加载远程桌面…</div>' +
483
- '<div class="dsh-ma-boot-sub">首次打开需要下载较完整的前端资源,请稍候</div>' +
484
- '<div class="dsh-ma-boot-elapsed">已等待 0 秒</div>';
485
- document.body.appendChild(maBootTip);
486
- // 秒表:让"到底等了多久"可见(用户据此判断是慢还是死了)
487
- maBootElapsedTimer = setInterval(function () {
488
- try {
489
- var s = Math.floor((Date.now() - maBootStart) / 1000);
490
- var n = maBootTip && maBootTip.querySelector(".dsh-ma-boot-elapsed");
491
- if (n) n.textContent = "已等待 " + s + " 秒";
492
- } catch (e) { /* 忽略 */ }
493
- }, 1000);
494
- maBootTimers.push(setTimeout(function () {
495
- maBootSet(".dsh-ma-boot-sub", "网络较慢时可能需要一两分钟;加载完成后会自动进入,不用重复刷新");
496
- }, 8000));
497
- maBootTimers.push(setTimeout(function () {
498
- maBootSet(".dsh-ma-boot-sub", "还在加载…如果长时间停在这里,可以下拉刷新重试(重复刷新不会更快)");
499
- }, 30 * 1000));
500
- } catch (e) { /* 提示失败绝不影响主功能 */ }
501
- }
502
- function maBootHide() {
503
- try {
504
- for (var i = 0; i < maBootTimers.length; i++) clearTimeout(maBootTimers[i]);
505
- maBootTimers = [];
506
- if (maBootElapsedTimer) { clearInterval(maBootElapsedTimer); maBootElapsedTimer = 0; }
507
- if (maBootWatchTimer) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
508
- var tip = maBootTip;
509
- maBootTip = null;
510
- if (tip && tip.parentNode) {
511
- try { tip.setAttribute("data-fading", "1"); } catch (e) { /* 忽略 */ }
512
- setTimeout(function () { try { if (tip.parentNode) tip.parentNode.removeChild(tip); } catch (e) { /* 忽略 */ } }, 300);
513
- }
514
- } catch (e) { /* 忽略 */ }
515
- }
516
- /** 官方 UI 是否已经可用了(= 提示可以撤了)。 */
517
- function maBootLooksReady() {
518
- try {
519
- // ① 适配层认出来的官方 frame(与下方 boot() 同一组选择器)
520
- if (document.querySelector(".pI_x6G_frame, [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]")) return true;
521
- // ② 出现输入框/会话区也算"进来了"(官方改版首选信号失灵时的兜底)
522
- return Boolean(document.querySelector('textarea, [contenteditable="true"], [role="textbox"]'));
523
- } catch (e) { return false; }
524
- }
525
- if (document.readyState === "loading") {
526
- document.addEventListener("DOMContentLoaded", maBootShow);
527
- } else {
528
- maBootShow();
529
- }
530
- // 轮询撤下:1s 一次,成本可忽略(只是两次 querySelector);一旦撤下就自我停止。
531
- // 上限与"4 分钟兜底"对齐,避免页面异常时留下常驻定时器。
532
- maBootWatchTimer = setInterval(function () {
533
- if (maBootLooksReady()) { maBootHide(); return; }
534
- if (!maBootTip) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
535
- }, 1000);
536
- maBootTimers.push(setTimeout(function () { maBootHide(); }, 240 * 1000));
537
- // 页面卸载/进 bfcache 时收干净(测试与真机都靠这条不留悬挂定时器)
538
- try {
539
- var maBootBye = function () { maBootHide(); };
540
- window.addEventListener("pagehide", maBootBye);
541
- window.addEventListener("unload", maBootBye);
542
- } catch (e) { /* 忽略 */ }
543
453
 
544
454
  /* —— 主机门解除(2026-09):镜像页(如 n.risegao.cn)经设备流认证回连同一台 127.0.0.1:3080,
545
455
  桥已把 Host 回环化并剥 Origin(不扩大信任面)。官方客户端以 transport.ownsHost 判定
@@ -847,3 +847,30 @@ test("★ 迭代速度护栏:适配层测试的假定时器**必须 unref**(
847
847
  assert.match(src, /id\.unref\?\.\(\)/, `${f} 的假 setTimeout 必须 unref(否则该文件会空等数分钟)`);
848
848
  }
849
849
  });
850
+
851
+ test("★ 护栏:解析期绝不许往页面塞东西(0.6.14 首屏提示层就是这么做、然后永久盖住 UI 的)", () => {
852
+ // 事故 3(2026-09-25,业主实测):「界面都已经加载完了…但这个遮罩一直不消失」,
853
+ // 截图里官方 UI 就在 88% 不透明的全屏层下面透出来,秒表走到 187 秒 —— 刷新也一样。
854
+ //
855
+ // 根因是**设计错误**:那个提示层是 `position:fixed; inset:0; z-index:2147483000` 的全屏层,
856
+ // 退出条件却是 DOM 启发式(官方 data-* 属性 / 类名前缀 / textarea…)。官方一改版,
857
+ // 两组信号全不命中 → 层永远留着。当时我写的"pointer-events:none 所以不会挡点击"是自我安慰:
858
+ // **盖住视线和进不去对用户没区别**。
859
+ //
860
+ // 业主决定:直接删掉。这条护栏守两件事:
861
+ // ① `dsh-ma-boot` 不许回来;
862
+ // ② **解析期**(主机门之前)不许有任何 `appendChild` —— 页面还没确认是官方 dsh web,
863
+ // 更不该有任何覆盖层;所有注入节点都必须在 boot() 里、过了主机门之后才做。
864
+ const { js: script, css } = injected();
865
+ const gate = script.indexOf("/* —— 主机门解除");
866
+ assert.ok(gate > 0, "找不到主机门锚点(脚本结构变了,请同步更新本用例)");
867
+ const preGate = script.slice(script.indexOf('"use strict";'), gate);
868
+ assert.ok(!/appendChild/.test(preGate),
869
+ "★ 解析期不得往页面 appendChild 任何节点(首屏提示层就是在这里挂上去、然后永远不消失的)");
870
+ assert.ok(!/dsh-ma-boot/.test(script), "首屏提示层必须已彻底删除");
871
+
872
+ // 全屏 + 超高 z-index 的组合本身就是"一旦判断失误就永远进不去",不许出现
873
+ assert.ok(!/z-index:\s*[0-9]{7,}/.test(css),
874
+ "不得出现 1000000 以上的 z-index(压过一切的全屏层 = 判断错一次就锁死用户)");
875
+ assert.ok(!/dsh-ma-boot/.test(css), "首屏提示层的样式必须已彻底删除");
876
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.15",
3
+ "version": "0.6.16",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.15",
3
+ "version": "0.6.16",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -100,33 +100,6 @@ const RUNTIME_HOST_RE =
100
100
  /(data-sidebar-collapsed|data-details-collapsed|data-rightbar-collapsed|data-shell-overlay)|(pI_x6G_frame|hHd-Xa_root|hHd-Xa_toggle)/;
101
101
 
102
102
  const STYLE = `
103
- /* ===== 首屏加载提示(0.6.14)=====
104
- 为什么要有它:镜像页首屏要拉 dsh web 的全部客户端插件(实测 ~14 MB,一个不可拆分的
105
- /plugins/?? 聚合请求),免费档限速下几十秒起步。此前页面上**只有官方那个转圈**,
106
- 用户无法区分"在加载"和"卡死了",只能反复刷新 —— 反而更慢。
107
- 这里给一句人话提示 + 分阶段的补充说明,加载完成(或超时兜底)即自动移除。
108
- ⚠️ 刻意**不**放进 @media (max-width:820px):Windows/桌面浏览器打开镜像页同样要等。
109
- ⚠️ pointer-events:none + 半透明:万一移除逻辑失效,也绝不挡住任何点击。 */
110
- .dsh-ma-boot {
111
- position: fixed; inset: 0; z-index: 2147483000;
112
- display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
113
- background: color-mix(in srgb, var(--dsw-alias-bg-base, #0f1115) 88%, transparent);
114
- backdrop-filter: blur(2px);
115
- pointer-events: none;
116
- font-family: inherit; text-align: center; padding: 24px;
117
- transition: opacity .3s ease;
118
- }
119
- .dsh-ma-boot[data-fading="1"] { opacity: 0; }
120
- .dsh-ma-boot-spin {
121
- width: 34px; height: 34px; border-radius: 50%;
122
- border: 3px solid color-mix(in srgb, currentColor 22%, transparent);
123
- border-top-color: currentColor;
124
- animation: dsh-ma-boot-spin 1s linear infinite;
125
- }
126
- @keyframes dsh-ma-boot-spin { to { transform: rotate(360deg); } }
127
- .dsh-ma-boot-title { font-size: 15px; font-weight: 600; }
128
- .dsh-ma-boot-sub { font-size: 13px; line-height: 1.6; opacity: .75; max-width: 22em; }
129
- .dsh-ma-boot-elapsed { font-size: 12px; opacity: .55; font-variant-numeric: tabular-nums; }
130
103
 
131
104
  @media (max-width: 820px) {
132
105
  html, body { max-width: 100%; overflow-x: hidden; }
@@ -458,88 +431,25 @@ html[data-dsh-ma-merged-e2ee] #dsh-e2ee-badge { display: none !important; }
458
431
  const SCRIPT = `(() => {
459
432
  "use strict";
460
433
 
461
- /* ===== 首屏加载提示(0.6.14)=====
462
- 用户反馈:「普通用户加载进入镜像页面时等待时间较长,目前等待过程中一直在转圈」
463
- —— 官方那个转圈不告诉用户任何事。这里在**页面解析的第一时间**就挂上一条人话提示,
464
- 并在官方 UI 就绪(或兜底超时)后自动移除。
434
+ /* ===== 【0.6.16 已删除】首屏加载提示层(0.6.14 引入,0.6.15 仍在)=====
435
+ 为什么删掉(业主实测,2026-09-25):「界面都已经加载完了,十几秒就把插件的接口加载好了,
436
+ 但这个遮罩一直不消失」—— 截图里官方 UI 就在下面透出来,秒表走到 187 秒。
437
+
438
+ 根因是**设计错误**,不是某个选择器写错:
439
+ · 那是个 position:fixed / inset:0 / z-index:2147483000 的**全屏层**(88% 不透明背景 +
440
+ backdrop-blur),视觉上盖住整个应用;
441
+ · 它唯一的退出条件是 maBootLooksReady() 这组**DOM 启发式**(官方 frame 的
442
+ data-*-collapsed / 类名前缀,或 textarea/[contenteditable]/[role=textbox])。
443
+ 官方改版、会话尚未挂载输入框、或前缀变化时两组信号全不命中 → 层就永远留着。
444
+ · 当时我给自己写的安慰是「pointer-events:none,万一移除失效也绝不挡住点击」——
445
+ 可对用户来说,**盖住视线和进不去没有区别**:刷新也一样,每次都要等 4 分钟兜底。
446
+ 结论(业主要求):**直接删掉这个功能**。首屏反馈交给官方自己的转圈;
447
+ 我们不再在用户页面上放任何会覆盖整屏的东西 —— 这类层一旦判断失误就是"永远进不去",
448
+ 代价远大于它带来的提示价值(何况限速放开后首屏本来就快了)。
449
+
450
+ 护栏见 test/mobile-adapter-guards.test.mjs:适配层不得再引入全屏覆盖层。 */
451
+ void 0;
465
452
 
466
- ⚠️ 必须放在所有主机判定(HOSTISH / NARROW)之前:首屏慢的正是这些判定等不到的时候。
467
- ⚠️ 只做提示,不做任何拦截:pointer-events:none,也没有遮罩语义。 */
468
- var maBootTip = null, maBootTimers = [], maBootStart = Date.now(), maBootElapsedTimer = 0, maBootWatchTimer = 0;
469
- function maBootSet(cls, text) {
470
- try { var n = maBootTip && maBootTip.querySelector(cls); if (n) n.textContent = text; } catch (e) { /* 忽略 */ }
471
- }
472
- function maBootShow() {
473
- try {
474
- if (maBootTip) return;
475
- if (!document.body) return; // 解析太早(body 还没出来)→ 交给 DOMContentLoaded 再挂
476
- maBootTip = document.createElement("div");
477
- maBootTip.className = "dsh-ma-boot";
478
- maBootTip.setAttribute("role", "status");
479
- maBootTip.setAttribute("aria-live", "polite");
480
- maBootTip.innerHTML =
481
- '<div class="dsh-ma-boot-spin" aria-hidden="true"></div>' +
482
- '<div class="dsh-ma-boot-title">正在加载远程桌面…</div>' +
483
- '<div class="dsh-ma-boot-sub">首次打开需要下载较完整的前端资源,请稍候</div>' +
484
- '<div class="dsh-ma-boot-elapsed">已等待 0 秒</div>';
485
- document.body.appendChild(maBootTip);
486
- // 秒表:让"到底等了多久"可见(用户据此判断是慢还是死了)
487
- maBootElapsedTimer = setInterval(function () {
488
- try {
489
- var s = Math.floor((Date.now() - maBootStart) / 1000);
490
- var n = maBootTip && maBootTip.querySelector(".dsh-ma-boot-elapsed");
491
- if (n) n.textContent = "已等待 " + s + " 秒";
492
- } catch (e) { /* 忽略 */ }
493
- }, 1000);
494
- maBootTimers.push(setTimeout(function () {
495
- maBootSet(".dsh-ma-boot-sub", "网络较慢时可能需要一两分钟;加载完成后会自动进入,不用重复刷新");
496
- }, 8000));
497
- maBootTimers.push(setTimeout(function () {
498
- maBootSet(".dsh-ma-boot-sub", "还在加载…如果长时间停在这里,可以下拉刷新重试(重复刷新不会更快)");
499
- }, 30 * 1000));
500
- } catch (e) { /* 提示失败绝不影响主功能 */ }
501
- }
502
- function maBootHide() {
503
- try {
504
- for (var i = 0; i < maBootTimers.length; i++) clearTimeout(maBootTimers[i]);
505
- maBootTimers = [];
506
- if (maBootElapsedTimer) { clearInterval(maBootElapsedTimer); maBootElapsedTimer = 0; }
507
- if (maBootWatchTimer) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
508
- var tip = maBootTip;
509
- maBootTip = null;
510
- if (tip && tip.parentNode) {
511
- try { tip.setAttribute("data-fading", "1"); } catch (e) { /* 忽略 */ }
512
- setTimeout(function () { try { if (tip.parentNode) tip.parentNode.removeChild(tip); } catch (e) { /* 忽略 */ } }, 300);
513
- }
514
- } catch (e) { /* 忽略 */ }
515
- }
516
- /** 官方 UI 是否已经可用了(= 提示可以撤了)。 */
517
- function maBootLooksReady() {
518
- try {
519
- // ① 适配层认出来的官方 frame(与下方 boot() 同一组选择器)
520
- if (document.querySelector(".pI_x6G_frame, [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]")) return true;
521
- // ② 出现输入框/会话区也算"进来了"(官方改版首选信号失灵时的兜底)
522
- return Boolean(document.querySelector('textarea, [contenteditable="true"], [role="textbox"]'));
523
- } catch (e) { return false; }
524
- }
525
- if (document.readyState === "loading") {
526
- document.addEventListener("DOMContentLoaded", maBootShow);
527
- } else {
528
- maBootShow();
529
- }
530
- // 轮询撤下:1s 一次,成本可忽略(只是两次 querySelector);一旦撤下就自我停止。
531
- // 上限与"4 分钟兜底"对齐,避免页面异常时留下常驻定时器。
532
- maBootWatchTimer = setInterval(function () {
533
- if (maBootLooksReady()) { maBootHide(); return; }
534
- if (!maBootTip) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
535
- }, 1000);
536
- maBootTimers.push(setTimeout(function () { maBootHide(); }, 240 * 1000));
537
- // 页面卸载/进 bfcache 时收干净(测试与真机都靠这条不留悬挂定时器)
538
- try {
539
- var maBootBye = function () { maBootHide(); };
540
- window.addEventListener("pagehide", maBootBye);
541
- window.addEventListener("unload", maBootBye);
542
- } catch (e) { /* 忽略 */ }
543
453
 
544
454
  /* —— 主机门解除(2026-09):镜像页(如 n.risegao.cn)经设备流认证回连同一台 127.0.0.1:3080,
545
455
  桥已把 Host 回环化并剥 Origin(不扩大信任面)。官方客户端以 transport.ownsHost 判定
@@ -1,158 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * 镜像页首屏加载提示的契约与行为测试(0.6.14)。
4
- *
5
- * 用户反馈:「普通用户加载进入镜像页面时等待时间较长,目前等待过程中一直在转圈。
6
- * 建议给普通用户增加一个友好的提示(例如提示"正在排队中")。」
7
- *
8
- * 首屏为什么慢:镜像页要拉 dsh web 的全部客户端插件(实测 ~14 MB 的一个**不可拆分**聚合请求),
9
- * 免费档限速下几十秒起步。官方那个转圈不告诉用户任何事,用户只能反复刷新 —— 反而更慢。
10
- *
11
- * 这里守四件事:
12
- * ① 提示必须在**页面解析的第一时间**出现(早于所有主机判定 —— 首屏慢的正是判定等不到的时候);
13
- * ② 分阶段补充说明(8s / 30s)与秒表,让"到底等了多久"可见;
14
- * ③ 官方 UI 就绪后自动撤下;且有 4 分钟兜底 + pagehide/unload 清理,绝不留悬挂定时器;
15
- * ④ 只做提示、不做拦截(pointer-events:none),提示失效也绝不挡住用户操作。
16
- *
17
- * 用法: node --test test/boot-tip.test.mjs
18
- */
19
- import assert from "node:assert/strict";
20
- import { readFileSync } from "node:fs";
21
- import path from "node:path";
22
- import test from "node:test";
23
- import vm from "node:vm";
24
- import { fileURLToPath } from "node:url";
25
-
26
- const HERE = path.dirname(fileURLToPath(import.meta.url));
27
- const SRC = readFileSync(path.join(HERE, "..", "mobile-adapter.mjs"), "utf8");
28
- const STYLE = SRC.slice(SRC.indexOf("const STYLE = `") + "const STYLE = `".length, SRC.indexOf("`;\n\n/**", SRC.indexOf("const STYLE = `")));
29
- const SCRIPT_START = SRC.indexOf("const SCRIPT = `(() => {") + "const SCRIPT = `".length;
30
- const SCRIPT = SRC.slice(SCRIPT_START, SRC.indexOf("`;\n\n/**", SCRIPT_START));
31
- /**
32
- * 只取「首屏提示」这一段来跑行为。
33
- * 为什么不像别的用例那样跑整个适配层:整段要一整套 DOM 桩(抽屉/遮罩/设置面板…),
34
- * 而这里要验的是**提示的生命周期**,隔离出来既稳又准(改动范围也正好是这一段)。
35
- */
36
- const BOOT = SCRIPT.slice(SCRIPT.indexOf("var maBootTip"), SCRIPT.indexOf("/* —— 主机门解除"));
37
-
38
- /** 极简 DOM 桩:只支撑"提示出现/撤下"这条路径。 */
39
- function makeDom({ body = true, ready = false, frame = false } = {}) {
40
- const nodes = [];
41
- const el = (tag) => {
42
- const n = {
43
- tagName: String(tag).toUpperCase(), className: "", _text: "", children: [], attrs: {}, parentNode: null,
44
- set innerHTML(v) { this._html = v; }, get innerHTML() { return this._html || ""; },
45
- set textContent(v) { this._text = String(v); }, get textContent() { return this._text; },
46
- setAttribute(k, v) { this.attrs[k] = String(v); },
47
- appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
48
- removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parentNode = null; return c; },
49
- querySelector(sel) {
50
- const cls = String(sel).replace(/^\./, "");
51
- const hit = (x) => (x.className || "").split(/\s+/).includes(cls);
52
- const walk = (x) => { for (const c of x.children) { if (hit(c)) return c; const d = walk(c); if (d) return d; } return null; };
53
- return walk(this);
54
- }
55
- };
56
- return n;
57
- };
58
- const doc = {
59
- readyState: ready ? "complete" : "loading",
60
- listeners: {},
61
- createElement: el,
62
- querySelector(sel) {
63
- if (sel === ".dsh-ma-boot") return nodes.find((n) => (n.className || "").includes("dsh-ma-boot")) || null;
64
- if (frame && /frame|textarea|contenteditable|textbox|sidebar-collapsed/.test(sel)) return el("div");
65
- return null;
66
- },
67
- addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
68
- dispatch(type) { (this.listeners[type] || []).forEach((fn) => fn({})); }
69
- };
70
- if (body) { doc.body = el("body"); doc.body.className = ""; }
71
- else { doc.body = null; doc.bodyHolder = el("body"); }
72
- return { doc, nodes, el, attachBody() { doc.body = doc.bodyHolder; } };
73
- }
74
-
75
- function run(env) {
76
- const timers = [];
77
- const intervals = [];
78
- const win = {
79
- listeners: {},
80
- addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
81
- dispatch(type) { (this.listeners[type] || []).forEach((fn) => fn({})); },
82
- innerWidth: 390, innerHeight: 844,
83
- location: { href: "https://example.test/remote/dev-x/", origin: "https://example.test" },
84
- MutationObserver: class { observe() {} disconnect() {} },
85
- requestAnimationFrame: (fn) => { timers.push({ fn, ms: 0 }); return timers.length; },
86
- cancelAnimationFrame() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
87
- };
88
- const ctx = {
89
- window: win,
90
- document: env.doc,
91
- location: win.location,
92
- navigator: { userAgent: "node-test", language: "zh-CN" },
93
- console: { warn() {}, log() {}, error() {} },
94
- setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
95
- clearTimeout: () => {},
96
- setInterval: (fn, ms) => { intervals.push({ fn, ms }); return intervals.length; },
97
- clearInterval: () => {},
98
- Date, Math, JSON, Object, Array, String, Number, Boolean, Error, RegExp, Promise, Map, Set
99
- };
100
- vm.createContext(ctx);
101
- vm.runInContext(BOOT, ctx, { timeout: 5000 });
102
- return { win, timers, intervals };
103
- }
104
-
105
- test("样式:提示层不挡点击(pointer-events:none)、覆盖所有宽度、有转圈与秒表样式", () => {
106
- assert.match(STYLE, /\.dsh-ma-boot\s*\{[\s\S]*?pointer-events:\s*none/, "提示层绝不能拦截点击");
107
- assert.ok(!/@media \(max-width: 820px\) \{\s*\.dsh-ma-boot/.test(STYLE), "提示不受窄屏限定(Windows/桌面浏览器同样要等)");
108
- assert.match(STYLE, /\.dsh-ma-boot-spin/, "要有加载动画");
109
- assert.match(STYLE, /\.dsh-ma-boot-elapsed/, "要有秒表");
110
- });
111
-
112
- test("★ 行为:页面还在解析(readyState=loading)也要立刻挂出提示", () => {
113
- const env = makeDom({ ready: false, frame: false });
114
- const { win, timers } = run(env);
115
- // 提示挂载挂在 DOMContentLoaded 上(此时 body 可能还没出来)
116
- assert.ok((env.doc.listeners.DOMContentLoaded || []).length > 0, "必须监听 DOMContentLoaded 再挂提示");
117
- env.doc.dispatch("DOMContentLoaded");
118
- const tip = env.doc.body.children.find((c) => (c.className || "").includes("dsh-ma-boot"));
119
- assert.ok(tip, "解析阶段就要出现提示(这正是首屏最慢的时候)");
120
- assert.match(tip.innerHTML, /正在加载远程桌面/);
121
- assert.match(tip.innerHTML, /首次打开需要下载/, "要说明为什么慢");
122
- assert.ok(timers.some((t) => t.ms === 8000) && timers.some((t) => t.ms === 30000), "要有 8s/30s 两段补充说明");
123
- assert.ok(timers.some((t) => t.ms === 240000), "要有 4 分钟兜底撤下");
124
- assert.ok((win.listeners.pagehide || []).length > 0, "pagehide 要清理(不留悬挂定时器)");
125
- });
126
-
127
- test("★ 行为:官方 UI 就绪后自动撤下提示", () => {
128
- const env = makeDom({ ready: true, frame: true }); // ready=complete → 立即挂;frame=true → 已就绪
129
- const { intervals } = run(env);
130
- const tip = env.doc.body.children.find((c) => (c.className || "").includes("dsh-ma-boot"));
131
- assert.ok(tip, "先挂上");
132
- assert.ok(intervals.length >= 2, "要有秒表 + 就绪轮询两个定时器");
133
- // 两个 interval 都是 1000ms(秒表 / 就绪轮询),顺序不保证 —— 全部推一次,模拟"1 秒过去"
134
- intervals.forEach((t) => t.fn());
135
- assert.equal(tip.attrs["data-fading"], "1", "官方 UI 就绪后应进入淡出");
136
- });
137
-
138
- test("行为:脚本在 <head> 里先跑(readyState=loading,body 还没出来)也绝不能抛,body 就位后补挂", () => {
139
- // 真实时序就是这条:适配层被内联在 <head>,执行时 body 可能还不存在
140
- const env = makeDom({ body: false, ready: false });
141
- assert.doesNotThrow(() => run(env), "body 缺失时绝不能抛(适配层其他逻辑还要跑)");
142
- env.attachBody();
143
- env.doc.dispatch("DOMContentLoaded");
144
- const tip = env.doc.body.children.find((c) => (c.className || "").includes("dsh-ma-boot"));
145
- assert.ok(tip, "body 就位后补挂提示");
146
- assert.match(tip.innerHTML, /正在加载远程桌面/);
147
- });
148
-
149
- test("源码契约:提示必须早于主机判定(HOSTISH/NARROW),否则首屏最慢那段没有提示", () => {
150
- // ⚠️ 基准要用 **HOSTISH 的定义**(const HOSTISH =),不能用 indexOf("HOSTISH"):
151
- // 提示那段自己的注释里就提到了 HOSTISH,拿注释当基准会把顺序判反(第一版就这么红的)。
152
- const iTip = SCRIPT.indexOf("var maBootTip");
153
- const iHost = SCRIPT.indexOf("const HOSTISH");
154
- assert.ok(iTip > 0 && iHost > 0, "找不到提示段或主机判定");
155
- assert.ok(iTip < iHost, "提示段必须在主机判定之前");
156
- assert.ok(BOOT.includes("pointer-events: none") || STYLE.includes("pointer-events: none"), "必须写明不拦截点击");
157
- assert.ok(!/dsh-ma-boot[\s\S]{0,400}?pointer-events:\s*auto/.test(STYLE), "提示层不得改成可点击拦截");
158
- });