@mrrisega/dsh-remote 0.6.15 → 0.6.17
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 +34 -0
- package/clients/dsh-remote/mobile-adapter.mjs +112 -108
- package/clients/dsh-remote/test/bridge-html-inject.test.mjs +33 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +50 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +27 -11
- package/packages/dsh-remote-web/lib/index.js +63 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +34 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +112 -108
- package/packages/dsh-remote-web/test/doctor-cli.test.mjs +17 -1
- package/clients/dsh-remote/test/boot-tip.test.mjs +0 -158
|
@@ -764,6 +764,7 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
764
764
|
if (m.injected) {
|
|
765
765
|
console.log(`[bridge] mobile-adapter 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(m.buf.length / 1024).toFixed(0)}KB`);
|
|
766
766
|
buf = m.buf;
|
|
767
|
+
armMirrorLoadWatch(path);
|
|
767
768
|
}
|
|
768
769
|
// E2EE 镜像页加密 shim(Phase-4):仅桥端 e2ee 启用时注入(e2ee.enabled=false → 零注入零行为)
|
|
769
770
|
if (e2ee.enabled) {
|
|
@@ -830,10 +831,39 @@ export function e2eeHttpResponseFrame(id, sessId, respEnv) {
|
|
|
830
831
|
};
|
|
831
832
|
}
|
|
832
833
|
|
|
834
|
+
/**
|
|
835
|
+
* 「镜像页交付了、但页面再也没回来」的看门狗(2026-09-25)。
|
|
836
|
+
*
|
|
837
|
+
* 【为什么需要】首屏提示层被整块删除后,页面上不再有任何东西能告诉用户"没加载完"
|
|
838
|
+
* (那正是业主的要求:不许再有会盖住界面的层)。但**服务端能看见一个更硬的信号**:
|
|
839
|
+
* 我们把镜像页 HTML 交付给手机后,官方前端一定会在几秒内发一串请求(模块、会话、mux…)。
|
|
840
|
+
* 若 60 秒内一个都没有,那这个页面几乎必然没起来 —— 这时在**日志**里留一条带时间戳的结论,
|
|
841
|
+
* 用户「复制诊断信息」或 `dsh-remote doctor` 时就能看到,不用再靠猜。
|
|
842
|
+
*
|
|
843
|
+
* 代价与边界:
|
|
844
|
+
* · 只是**一次**待办标记 + 一个 unref 定时器,没有额外请求、没有额外文件;
|
|
845
|
+
* · 60 秒很宽松(正常加载 1~3 秒就回来一串请求);用户"打开就关掉"最多多一行日志;
|
|
846
|
+
* · 只记日志,不做任何用户可见动作(不注入、不弹层、不改状态)。
|
|
847
|
+
*/
|
|
848
|
+
let mirrorLoadWatch = null;
|
|
849
|
+
function armMirrorLoadWatch(path) {
|
|
850
|
+
mirrorLoadWatch = { at: Date.now(), path };
|
|
851
|
+
const timer = setTimeout(() => {
|
|
852
|
+
if (!mirrorLoadWatch) return;
|
|
853
|
+
const waited = Math.round((Date.now() - mirrorLoadWatch.at) / 1000);
|
|
854
|
+
console.error(`[bridge] ⚠️ 镜像页疑似没加载起来:交付 ${mirrorLoadWatch.path} 后 ${waited} 秒内没有任何后续请求`
|
|
855
|
+
+ `(正常 1~3 秒就会有一串)。手机侧请刷新一次;仍不行把这条日志发给客服。`);
|
|
856
|
+
mirrorLoadWatch = null;
|
|
857
|
+
}, 60 * 1000);
|
|
858
|
+
timer.unref?.();
|
|
859
|
+
}
|
|
860
|
+
|
|
833
861
|
export async function handleHttpFrame(dchOrSend, frame) {
|
|
834
862
|
const send = toSender(dchOrSend);
|
|
835
863
|
const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
|
|
836
864
|
const t0 = Date.now();
|
|
865
|
+
// 页面还活着:任何后续请求都撤掉「镜像页加载」看门狗(见 armMirrorLoadWatch)
|
|
866
|
+
if (mirrorLoadWatch) { mirrorLoadWatch = null; }
|
|
837
867
|
// 桌面授权引导(方案A):POST /_e2ee/intro(device/channel 形态归一)—— 本地应答,不连上游
|
|
838
868
|
if (method === "POST" && path === "/_e2ee/intro") {
|
|
839
869
|
return answerIntro(send, id);
|
|
@@ -1675,6 +1705,10 @@ async function main() {
|
|
|
1675
1705
|
return runTunnel();
|
|
1676
1706
|
}
|
|
1677
1707
|
|
|
1708
|
+
// test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
|
|
1709
|
+
export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
|
|
1710
|
+
export function __armMirrorLoadWatch(path) { armMirrorLoadWatch(path); }
|
|
1711
|
+
|
|
1678
1712
|
// 直接运行(node dsh-bridge.mjs)时启动服务;被测试 import 时只导出协议函数。
|
|
1679
1713
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1680
1714
|
if (isMain) {
|
|
@@ -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; }
|
|
@@ -426,6 +399,18 @@ div.dsh-ma-menu .dsh-ma-menu-note {
|
|
|
426
399
|
margin-top: 8px; font-size: 11.5px; line-height: 1.7;
|
|
427
400
|
color: var(--dsw-alias-label-tertiary, #6b7280);
|
|
428
401
|
}
|
|
402
|
+
/* 加载疑似没完成时的一行提示 —— **只放进已有的「远程控制」菜单**,不新增任何层。
|
|
403
|
+
2026-09-25 教训:首屏提示层(全屏覆盖 + 启发式退出)会永久盖住已加载完的界面,已整块删除。
|
|
404
|
+
这里换成**安全的失败方向**:判断错了最坏只是菜单里多一行字,绝不遮挡、绝不拦截。 */
|
|
405
|
+
div.dsh-ma-menu .dsh-ma-load-warn {
|
|
406
|
+
display: flex; gap: 8px; align-items: flex-start; margin-top: 8px; padding: 8px 10px;
|
|
407
|
+
border-radius: 8px; cursor: pointer; font-size: 12px; line-height: 1.6;
|
|
408
|
+
background: color-mix(in srgb, var(--dsw-alias-label-warning, #9a6700) 12%, transparent);
|
|
409
|
+
color: var(--dsw-alias-label-warning, #9a6700);
|
|
410
|
+
}
|
|
411
|
+
div.dsh-ma-menu .dsh-ma-load-warn[hidden] { display: none !important; }
|
|
412
|
+
div.dsh-ma-menu .dsh-ma-load-warn .ico { flex: none; }
|
|
413
|
+
div.dsh-ma-menu .dsh-ma-load-warn .txt { min-width: 0; overflow-wrap: anywhere; }
|
|
429
414
|
|
|
430
415
|
/* 会话过期引导条:镜像页在会话过期后只会"连不上",这里给一句能照做的话 + 一个出口。
|
|
431
416
|
非阻塞(顶部细条),点关闭即消失;不拦截任何请求。 */
|
|
@@ -458,88 +443,25 @@ html[data-dsh-ma-merged-e2ee] #dsh-e2ee-badge { display: none !important; }
|
|
|
458
443
|
const SCRIPT = `(() => {
|
|
459
444
|
"use strict";
|
|
460
445
|
|
|
461
|
-
/* =====
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
446
|
+
/* ===== 【0.6.16 已删除】首屏加载提示层(0.6.14 引入,0.6.15 仍在)=====
|
|
447
|
+
为什么删掉(业主实测,2026-09-25):「界面都已经加载完了,十几秒就把插件的接口加载好了,
|
|
448
|
+
但这个遮罩一直不消失」—— 截图里官方 UI 就在下面透出来,秒表走到 187 秒。
|
|
449
|
+
|
|
450
|
+
根因是**设计错误**,不是某个选择器写错:
|
|
451
|
+
· 那是个 position:fixed / inset:0 / z-index:2147483000 的**全屏层**(88% 不透明背景 +
|
|
452
|
+
backdrop-blur),视觉上盖住整个应用;
|
|
453
|
+
· 它唯一的退出条件是 maBootLooksReady() 这组**DOM 启发式**(官方 frame 的
|
|
454
|
+
data-*-collapsed / 类名前缀,或 textarea/[contenteditable]/[role=textbox])。
|
|
455
|
+
官方改版、会话尚未挂载输入框、或前缀变化时两组信号全不命中 → 层就永远留着。
|
|
456
|
+
· 当时我给自己写的安慰是「pointer-events:none,万一移除失效也绝不挡住点击」——
|
|
457
|
+
可对用户来说,**盖住视线和进不去没有区别**:刷新也一样,每次都要等 4 分钟兜底。
|
|
458
|
+
结论(业主要求):**直接删掉这个功能**。首屏反馈交给官方自己的转圈;
|
|
459
|
+
我们不再在用户页面上放任何会覆盖整屏的东西 —— 这类层一旦判断失误就是"永远进不去",
|
|
460
|
+
代价远大于它带来的提示价值(何况限速放开后首屏本来就快了)。
|
|
461
|
+
|
|
462
|
+
护栏见 test/mobile-adapter-guards.test.mjs:适配层不得再引入全屏覆盖层。 */
|
|
463
|
+
void 0;
|
|
465
464
|
|
|
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
465
|
|
|
544
466
|
/* —— 主机门解除(2026-09):镜像页(如 n.risegao.cn)经设备流认证回连同一台 127.0.0.1:3080,
|
|
545
467
|
桥已把 Host 回环化并剥 Origin(不扩大信任面)。官方客户端以 transport.ownsHost 判定
|
|
@@ -575,6 +497,83 @@ const SCRIPT = `(() => {
|
|
|
575
497
|
}
|
|
576
498
|
})();
|
|
577
499
|
|
|
500
|
+
/* ===== 「加载疑似没完成」看门狗(2026-09-25,替代被整块删除的首屏提示层)=====
|
|
501
|
+
那个提示层是全屏覆盖 + 启发式退出 —— 判断错一次就把**已经加载完**的界面永久盖住
|
|
502
|
+
(业主实测:秒表走到 187 秒,刷新也一样)。所以这里换一条**安全的失败方向**:
|
|
503
|
+
|
|
504
|
+
· 解析期**不创建任何 DOM**(只有几个变量 + 一个定时器)—— 页面还没过主机门,
|
|
505
|
+
更不该有任何覆盖层;
|
|
506
|
+
· 25 秒后仍认不出官方界面 → 只置一个 slow 标记,**不往页面塞任何东西**;
|
|
507
|
+
· 标记生效时,往**已有的**「远程控制」悬浮菜单里多一行字(点一下复制一句话给客服),
|
|
508
|
+
并建议点菜单里本来就有的「返回设备列表」重来;
|
|
509
|
+
· 判断错了的代价 = 菜单里多一行字(用户不点开菜单完全看不到);不会再挡住任何人。
|
|
510
|
+
|
|
511
|
+
⚠️ 刻意**不**做成页面顶部/底部的固定细条:那种细条同样会盖住官方 UI 的一部分,
|
|
512
|
+
而且同样有"永不消失"的失败方向。菜单是被动展开的,天然没有这个问题。 */
|
|
513
|
+
var maLoadWatch = { startedAt: Date.now(), slow: false, wired: false, bound: false, warnEl: null, warnTxt: null };
|
|
514
|
+
/** 官方界面是否已经可用(只看最强信号;只用于决定"要不要多显示一行字")。 */
|
|
515
|
+
function maLoadReadyNow() {
|
|
516
|
+
try {
|
|
517
|
+
if (document.querySelector(".pI_x6G_frame, div.dsh-ma-frame, [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]")) return true;
|
|
518
|
+
return Boolean(document.querySelector('[data-composer-input], [contenteditable="true"], [role="textbox"], textarea'));
|
|
519
|
+
} catch (e) { return false; }
|
|
520
|
+
}
|
|
521
|
+
function maLoadWaitedSec() {
|
|
522
|
+
return Math.max(0, Math.round((Date.now() - maLoadWatch.startedAt) / 1000));
|
|
523
|
+
}
|
|
524
|
+
/** 给客服看的一句话(含等待时长与 UA,便于直接定位)。 */
|
|
525
|
+
function maLoadCopyLine() {
|
|
526
|
+
let ua = "";
|
|
527
|
+
try { ua = String((navigator && navigator.userAgent) || "").slice(0, 120); } catch (e) { /* 忽略 */ }
|
|
528
|
+
return "dsh-remote 手机端加载疑似卡住:等待 " + maLoadWaitedSec() + " 秒后仍未出现对话界面。UA=" + ua;
|
|
529
|
+
}
|
|
530
|
+
function maLoadRenderWarn() {
|
|
531
|
+
try {
|
|
532
|
+
if (!maLoadWatch.warnEl) return;
|
|
533
|
+
if (!maLoadWatch.slow) { maLoadWatch.warnEl.hidden = true; return; }
|
|
534
|
+
if (maLoadWatch.warnTxt) {
|
|
535
|
+
maLoadWatch.warnTxt.textContent = "本次页面可能没加载完整(已等 " + maLoadWaitedSec()
|
|
536
|
+
+ " 秒)。点这里复制一句话发给客服,或点下方「返回设备列表」重来。";
|
|
537
|
+
}
|
|
538
|
+
maLoadWatch.warnEl.hidden = false;
|
|
539
|
+
} catch (e) { /* 提示失败绝不影响主功能 */ }
|
|
540
|
+
}
|
|
541
|
+
/** 把这一行接进已有的悬浮菜单(由菜单构建处调用;菜单不存在时什么都不做)。 */
|
|
542
|
+
function maWireLoadWarn(menu) {
|
|
543
|
+
try {
|
|
544
|
+
maLoadWatch.warnEl = menu.querySelector('[data-role="load-warn"]');
|
|
545
|
+
maLoadWatch.warnTxt = menu.querySelector('[data-role="load-warn-txt"]');
|
|
546
|
+
maLoadWatch.wired = true;
|
|
547
|
+
maLoadRenderWarn();
|
|
548
|
+
if (maLoadWatch.warnEl && !maLoadWatch.bound) {
|
|
549
|
+
maLoadWatch.bound = true;
|
|
550
|
+
maLoadWatch.warnEl.addEventListener("click", function () {
|
|
551
|
+
const line = maLoadCopyLine();
|
|
552
|
+
const show = (t) => { try { if (maLoadWatch.warnTxt) maLoadWatch.warnTxt.textContent = t; } catch (e) { /* 忽略 */ } };
|
|
553
|
+
try {
|
|
554
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
555
|
+
navigator.clipboard.writeText(line).then(
|
|
556
|
+
() => show("已复制。直接粘给客服即可(里面已带等待时长与机型信息)。"),
|
|
557
|
+
() => show(line) // 剪贴板不可用 → 至少把这句话显示出来让用户截图
|
|
558
|
+
);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
} catch (e) { /* 落到下面 */ }
|
|
562
|
+
show(line);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
} catch (e) { /* 忽略 */ }
|
|
566
|
+
}
|
|
567
|
+
// 25 秒后仍认不出官方界面 → 置慢标记(**只改状态,不碰 DOM**)。
|
|
568
|
+
// 阈值取得很宽松:宁可漏报(什么都不显示,与今天一致),也不要误报吓人。
|
|
569
|
+
setTimeout(function () {
|
|
570
|
+
try {
|
|
571
|
+
if (maLoadReadyNow()) return;
|
|
572
|
+
maLoadWatch.slow = true;
|
|
573
|
+
if (maLoadWatch.wired) maLoadRenderWarn();
|
|
574
|
+
} catch (e) { /* 忽略 */ }
|
|
575
|
+
}, 25 * 1000);
|
|
576
|
+
|
|
578
577
|
try {
|
|
579
578
|
const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
|
|
580
579
|
const NARROW = () => window.innerWidth <= 820;
|
|
@@ -945,8 +944,13 @@ const SCRIPT = `(() => {
|
|
|
945
944
|
'<div class="dsh-ma-menu-e2ee" data-role="e2ee"><span class="ico">🔒</span><span class="txt">正在读取…</span></div>' +
|
|
946
945
|
'</div>' +
|
|
947
946
|
'<button type="button" class="dsh-ma-menu-back">← 返回设备列表</button>' +
|
|
947
|
+
'<div class="dsh-ma-load-warn" data-role="load-warn" hidden>' +
|
|
948
|
+
'<span class="ico">⚠</span>' +
|
|
949
|
+
'<span class="txt" data-role="load-warn-txt"></span>' +
|
|
950
|
+
'</div>' +
|
|
948
951
|
'<div class="dsh-ma-menu-note">返回后可重新选择设备;端到端加密需在设备列表页重新解锁(会话密钥只存在本机内存里,刷新即失效)。</div>';
|
|
949
952
|
document.body.appendChild(menu);
|
|
953
|
+
maWireLoadWarn(menu); // 加载疑似失败时,在这一行里说明白(点一下可复制)
|
|
950
954
|
|
|
951
955
|
/* ---- 悬浮按钮的位置:默认**右上角**(原来在右下角,正好压住输入框的发送按钮);
|
|
952
956
|
可拖动,位置按"最近的角 + 距边偏移"记忆 —— 旋转屏幕/换窗口宽度都不会跑到屏幕外。 ---- */
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - 非 html(SSE/json/二进制)不动。
|
|
8
8
|
*/
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
11
11
|
import http from "node:http";
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import path from "node:path";
|
|
@@ -108,3 +108,35 @@ test("集成:非 html(SSE / js / json)不动", async () => {
|
|
|
108
108
|
assert.equal(decode(js), "console.log(1)");
|
|
109
109
|
assert.ok(!decode(js).includes("dsh-mobile-adapter"));
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
// ── 「镜像页交付了、页面再也没回来」的看门狗(2026-09-25)────────────────────────
|
|
113
|
+
// 背景:首屏提示层被整块删除(它会永久盖住已加载完的界面)。删掉之后页面上不再有任何东西
|
|
114
|
+
// 能告诉用户"没加载完" —— 但服务端有一个更硬的信号:交付 HTML 后官方前端一定会在几秒内
|
|
115
|
+
// 发一串请求;一个都没有,那这个页面几乎必然没起来。这里把它记进日志,供 doctor / 诊断复制查看。
|
|
116
|
+
|
|
117
|
+
test("★ 看门狗:交付镜像页后挂起;**任何后续请求都会撤掉它**(别把正常加载误报成失败)", async () => {
|
|
118
|
+
const mod = await import("../dsh-bridge.mjs");
|
|
119
|
+
assert.equal(typeof mod.__mirrorLoadWatchState, "function", "要有可断言的状态钩子");
|
|
120
|
+
assert.equal(mod.__mirrorLoadWatchState(), null, "初始应为空");
|
|
121
|
+
|
|
122
|
+
// ① 交付镜像页(/ 是官方特征 HTML)→ 挂起看门狗
|
|
123
|
+
const page = await request({ id: "w1", method: "GET", path: "/page", headers: {} });
|
|
124
|
+
assert.ok(page && page.status === 200);
|
|
125
|
+
const st = mod.__mirrorLoadWatchState();
|
|
126
|
+
assert.ok(st && st.path === "/page", "交付镜像页后必须挂起看门狗");
|
|
127
|
+
|
|
128
|
+
// ② 页面发来任何后续请求 → 立即撤掉(页面活着,不该误报)
|
|
129
|
+
await request({ id: "w2", method: "POST", path: "/api/session/list", headers: { "content-type": "application/json" }, body: "{}" });
|
|
130
|
+
assert.equal(mod.__mirrorLoadWatchState(), null, "★ 有后续请求 = 页面活着,看门狗必须撤销");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("看门狗只记日志、不做任何用户可见动作(不注入、不弹层、不改状态)", () => {
|
|
134
|
+
const src = readFileSync(new URL("../dsh-bridge.mjs", import.meta.url), "utf8");
|
|
135
|
+
const i = src.indexOf("function armMirrorLoadWatch");
|
|
136
|
+
assert.ok(i > 0, "找不到 armMirrorLoadWatch");
|
|
137
|
+
const body = src.slice(i, src.indexOf("\n}\n", i));
|
|
138
|
+
assert.match(body, /60 \* 1000/, "阈值 60 秒(宽松:正常加载 1~3 秒就回来一串请求)");
|
|
139
|
+
assert.match(body, /timer\.unref\?\.\(\)/, "定时器必须 unref(否则测试/退出会被它吊住)");
|
|
140
|
+
assert.match(body, /console\.error\(/, "只写日志");
|
|
141
|
+
assert.ok(!/appendChild|inject|writeFileSync|send\(/.test(body), "不得有注入/弹层/落盘/发帧等任何副作用");
|
|
142
|
+
});
|
|
@@ -847,3 +847,53 @@ 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
|
+
});
|
|
877
|
+
|
|
878
|
+
test("★ 加载疑似失败时的提示:只在**已有的悬浮菜单**里多一行(不新增层、解析期不碰 DOM)", () => {
|
|
879
|
+
// 替代被删除的首屏提示层:同样的需求(让用户知道"没加载完、该怎么办"),
|
|
880
|
+
// 但换成安全的失败方向 —— 判断错了最多是菜单里多一行字,不会挡住任何人。
|
|
881
|
+
const { js: script, css } = injected();
|
|
882
|
+
const gate = script.indexOf("/* —— 主机门解除");
|
|
883
|
+
const preGate = script.slice(script.indexOf('"use strict";'), gate);
|
|
884
|
+
assert.ok(!/appendChild/.test(preGate), "解析期仍不得 appendChild(看门狗只置状态)");
|
|
885
|
+
|
|
886
|
+
// 提示必须挂在菜单里(菜单是被动展开的 → 天然不会覆盖界面)
|
|
887
|
+
assert.match(script, /dsh-ma-menu[\s\S]{0,600}?data-role="load-warn"/, "提示行必须放在悬浮菜单的 HTML 里");
|
|
888
|
+
assert.match(css, /div\.dsh-ma-menu \.dsh-ma-load-warn/, "样式必须限定在菜单内(不得是 body 级固定层)");
|
|
889
|
+
assert.ok(!/\.dsh-ma-load-warn\s*\{[^}]*position:\s*(fixed|absolute)/.test(css),
|
|
890
|
+
"提示行不得用 fixed/absolute 定位(那就又变成覆盖层了)");
|
|
891
|
+
assert.ok(!/\.dsh-ma-load-warn\s*\{[^}]*z-index/.test(css), "提示行不得有 z-index(压层)");
|
|
892
|
+
|
|
893
|
+
// 阈值要宽松:宁可漏报,不可吓人
|
|
894
|
+
assert.match(script, /if \(maLoadReadyNow\(\)\) return;/, "认出官方界面就不该报警");
|
|
895
|
+
assert.match(script, /\}, 25 \* 1000\);/, "看门狗阈值应为 25 秒(宽松)");
|
|
896
|
+
// 一句话要能直接发给客服
|
|
897
|
+
assert.match(script, /dsh-remote 手机端加载疑似卡住/, "必须给一句可直接发给客服的话");
|
|
898
|
+
assert.match(script, /maLoadCopyLine/, "要能复制(否则用户只能截图)");
|
|
899
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.17",
|
|
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",
|
|
@@ -2594,18 +2594,34 @@ window.__ModuleLoader__.load({
|
|
|
2594
2594
|
}).finally(function () { setBusy(""); });
|
|
2595
2595
|
};
|
|
2596
2596
|
|
|
2597
|
-
/** 「复制诊断信息」:版本 / 配置目录 / 阶段 / 最近错误 / 进程状态 / 日志路径(node 半已拼好)。
|
|
2597
|
+
/** 「复制诊断信息」:版本 / 配置目录 / 阶段 / 最近错误 / 进程状态 / 日志路径(node 半已拼好)。
|
|
2598
|
+
* ★ 2026-09-25:再追加运行环境 `dsh-remote doctor` 的输出 —— 它把「上游端口是怎么来的 /
|
|
2599
|
+
* 身份校验过没过 / 守护与 bridge 进程 / 事件订阅与待补发通知 / 带时间戳的日志尾部」一次讲清。
|
|
2600
|
+
* 为什么不塞进 bridge-status 的负载:那是 2~3 秒一次的长轮询,而 doctor 要起一次子进程。
|
|
2601
|
+
* 取不到就退回原有文本(按钮永不因此失败)。 */
|
|
2598
2602
|
var copyDiagnostics = function () {
|
|
2599
|
-
var
|
|
2600
|
-
if (!
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2603
|
+
var base = (connInfo && connInfo.diagnostics) || "";
|
|
2604
|
+
if (!base) return;
|
|
2605
|
+
var copyNow = function (text) {
|
|
2606
|
+
copyText(text).then(function (done) {
|
|
2607
|
+
if (!done) {
|
|
2608
|
+
// 剪贴板不可用:文案里已给出日志路径,用户可自行查看
|
|
2609
|
+
setMsg("warn", "复制失败:浏览器可能限制了剪贴板权限;可展开下方日志路径自行查看。");
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
setCopiedDiag(true);
|
|
2613
|
+
later(function () { setCopiedDiag(false); }, 2000);
|
|
2614
|
+
});
|
|
2615
|
+
};
|
|
2616
|
+
// doctor 慢/挂住也不能让按钮失灵:12 秒后就用原有文本复制
|
|
2617
|
+
var timer = setTimeout(function () { copyNow(base); }, 12000);
|
|
2618
|
+
api("/dsh-remote/doctor", {}, 12000).then(function (r) {
|
|
2619
|
+
clearTimeout(timer);
|
|
2620
|
+
var t = r && r.ok && r.text ? String(r.text) : "";
|
|
2621
|
+
copyNow(t ? base + "\n\n" + t + "\n" : base);
|
|
2622
|
+
}).catch(function () {
|
|
2623
|
+
clearTimeout(timer);
|
|
2624
|
+
copyNow(base);
|
|
2609
2625
|
});
|
|
2610
2626
|
};
|
|
2611
2627
|
|
|
@@ -4205,6 +4205,54 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
4205
4205
|
return conn;
|
|
4206
4206
|
}
|
|
4207
4207
|
|
|
4208
|
+
/**
|
|
4209
|
+
* 跑一次运行环境的 `dsh-remote doctor`,把它的输出交给面板「复制诊断信息」。
|
|
4210
|
+
*
|
|
4211
|
+
* 为什么用子进程而不是在插件里重写一份:doctor 的每一条结论(上游来源、身份校验、进程、
|
|
4212
|
+
* 待补发队列、订阅异常、日志尾部)都必须与用户自己敲 `dsh-remote doctor` 看到的**完全一致** ——
|
|
4213
|
+
* 两份实现必然漂移,而这份文本正是我们用来定位用户问题的唯一凭据。
|
|
4214
|
+
*
|
|
4215
|
+
* 安全与边界:
|
|
4216
|
+
* · **只读**:doctor 自己承诺不改任何东西(连运行时脚本同步都跳过);
|
|
4217
|
+
* · 10 秒上限 + 输出上限 64KB:子进程卡死不会把面板按钮挂住,也不会把巨型文本塞进剪贴板;
|
|
4218
|
+
* · 测试隔离(DSH_RELAY_SKIP_SERVICE=1)直接跳过,用例不碰真实系统;
|
|
4219
|
+
* · 任何失败都返回 ok:false + 原因,面板退回原有诊断文本(绝不因此变红/报错)。
|
|
4220
|
+
*
|
|
4221
|
+
* @returns {Promise<{ok:boolean, text:string, error?:string}>}
|
|
4222
|
+
*/
|
|
4223
|
+
function runDoctorText(relayDir) {
|
|
4224
|
+
if (skipsSystemOps()) return Promise.resolve({ ok: false, text: "", error: "测试隔离(DSH_RELAY_SKIP_SERVICE=1)" });
|
|
4225
|
+
const setup = join(relayDir, "dsh-setup.mjs");
|
|
4226
|
+
if (!existsSync(setup)) return Promise.resolve({ ok: false, text: "", error: `运行环境入口不存在(${setup})` });
|
|
4227
|
+
return new Promise((resolve) => {
|
|
4228
|
+
let out = "";
|
|
4229
|
+
let done = false;
|
|
4230
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
4231
|
+
const { child, error } = safeSpawn(process.execPath, [setup, "doctor"], {
|
|
4232
|
+
cwd: homedir(),
|
|
4233
|
+
env: spawnEnv({}),
|
|
4234
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
4235
|
+
windowsHide: true,
|
|
4236
|
+
onError: (e) => finish({ ok: false, text: "", error: `启动 doctor 失败:${e.message}` }),
|
|
4237
|
+
});
|
|
4238
|
+
if (error || !child) return finish({ ok: false, text: "", error: `启动 doctor 失败:${error ? error.message : "无子进程"}` });
|
|
4239
|
+
const timer = setTimeout(() => {
|
|
4240
|
+
try { child.kill("SIGKILL"); } catch { /* 已经退出 */ }
|
|
4241
|
+
finish({ ok: false, text: out.trim(), error: "doctor 超过 10 秒未返回" });
|
|
4242
|
+
}, 10_000);
|
|
4243
|
+
timer.unref?.();
|
|
4244
|
+
child.stdout?.on("data", (chunk) => {
|
|
4245
|
+
if (out.length < 64 * 1024) out += String(chunk);
|
|
4246
|
+
});
|
|
4247
|
+
child.on("exit", (code) => {
|
|
4248
|
+
clearTimeout(timer);
|
|
4249
|
+
const text = out.trim();
|
|
4250
|
+
if (!text) return finish({ ok: false, text: "", error: `doctor 没有输出(退出码 ${code})` });
|
|
4251
|
+
finish({ ok: true, text });
|
|
4252
|
+
});
|
|
4253
|
+
});
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4208
4256
|
/**
|
|
4209
4257
|
* 可复制的诊断信息(面板「复制诊断信息」按钮):版本 / relayDir / 阶段 / 最近错误 / 进程状态 /
|
|
4210
4258
|
* 日志路径 + **两个日志的尾部**。
|
|
@@ -5492,6 +5540,21 @@ function registerRoutes(ctx, relayDir) {
|
|
|
5492
5540
|
sendJson(res, 200, { ok: true, version: PLUGIN_VERSION, channel: UPDATE_TAG, runtimeReady, relayDir });
|
|
5493
5541
|
},
|
|
5494
5542
|
},
|
|
5543
|
+
{
|
|
5544
|
+
/**
|
|
5545
|
+
* `GET /dsh-remote/doctor` —— 面板「复制诊断信息」用的**一条命令自检**输出(只读)。
|
|
5546
|
+
*
|
|
5547
|
+
* 为什么单开一条路由、而不是塞进 bridge-status 的负载:`doctor` 要**起一次子进程**跑
|
|
5548
|
+
* 运行环境里的 `dsh-setup.mjs doctor`(复用唯一实现 —— 插件半不抄第二份)。而 bridge-status
|
|
5549
|
+
* 是 2~3 秒一次的长轮询,往那里塞子进程等于每 3 秒 fork 一个 node。
|
|
5550
|
+
* 这里只在用户**真的点**「复制诊断信息」时跑一次(10 秒上限;任何失败都退回原有文本)。
|
|
5551
|
+
*/
|
|
5552
|
+
method: "GET",
|
|
5553
|
+
path: "/dsh-remote/doctor",
|
|
5554
|
+
handler: async (_req, res) => {
|
|
5555
|
+
sendJson(res, 200, await runDoctorText(relayDir));
|
|
5556
|
+
},
|
|
5557
|
+
},
|
|
5495
5558
|
{
|
|
5496
5559
|
// 取消「正在进行的更新」:结束挂死的更新子进程 + 清标记。
|
|
5497
5560
|
// 面板在「疑似卡住」时提供这个按钮 —— 此前用户面对的是没有时间、没有日志、没有按钮的 spinner。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.17",
|
|
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",
|
|
@@ -764,6 +764,7 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
764
764
|
if (m.injected) {
|
|
765
765
|
console.log(`[bridge] mobile-adapter 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(m.buf.length / 1024).toFixed(0)}KB`);
|
|
766
766
|
buf = m.buf;
|
|
767
|
+
armMirrorLoadWatch(path);
|
|
767
768
|
}
|
|
768
769
|
// E2EE 镜像页加密 shim(Phase-4):仅桥端 e2ee 启用时注入(e2ee.enabled=false → 零注入零行为)
|
|
769
770
|
if (e2ee.enabled) {
|
|
@@ -830,10 +831,39 @@ export function e2eeHttpResponseFrame(id, sessId, respEnv) {
|
|
|
830
831
|
};
|
|
831
832
|
}
|
|
832
833
|
|
|
834
|
+
/**
|
|
835
|
+
* 「镜像页交付了、但页面再也没回来」的看门狗(2026-09-25)。
|
|
836
|
+
*
|
|
837
|
+
* 【为什么需要】首屏提示层被整块删除后,页面上不再有任何东西能告诉用户"没加载完"
|
|
838
|
+
* (那正是业主的要求:不许再有会盖住界面的层)。但**服务端能看见一个更硬的信号**:
|
|
839
|
+
* 我们把镜像页 HTML 交付给手机后,官方前端一定会在几秒内发一串请求(模块、会话、mux…)。
|
|
840
|
+
* 若 60 秒内一个都没有,那这个页面几乎必然没起来 —— 这时在**日志**里留一条带时间戳的结论,
|
|
841
|
+
* 用户「复制诊断信息」或 `dsh-remote doctor` 时就能看到,不用再靠猜。
|
|
842
|
+
*
|
|
843
|
+
* 代价与边界:
|
|
844
|
+
* · 只是**一次**待办标记 + 一个 unref 定时器,没有额外请求、没有额外文件;
|
|
845
|
+
* · 60 秒很宽松(正常加载 1~3 秒就回来一串请求);用户"打开就关掉"最多多一行日志;
|
|
846
|
+
* · 只记日志,不做任何用户可见动作(不注入、不弹层、不改状态)。
|
|
847
|
+
*/
|
|
848
|
+
let mirrorLoadWatch = null;
|
|
849
|
+
function armMirrorLoadWatch(path) {
|
|
850
|
+
mirrorLoadWatch = { at: Date.now(), path };
|
|
851
|
+
const timer = setTimeout(() => {
|
|
852
|
+
if (!mirrorLoadWatch) return;
|
|
853
|
+
const waited = Math.round((Date.now() - mirrorLoadWatch.at) / 1000);
|
|
854
|
+
console.error(`[bridge] ⚠️ 镜像页疑似没加载起来:交付 ${mirrorLoadWatch.path} 后 ${waited} 秒内没有任何后续请求`
|
|
855
|
+
+ `(正常 1~3 秒就会有一串)。手机侧请刷新一次;仍不行把这条日志发给客服。`);
|
|
856
|
+
mirrorLoadWatch = null;
|
|
857
|
+
}, 60 * 1000);
|
|
858
|
+
timer.unref?.();
|
|
859
|
+
}
|
|
860
|
+
|
|
833
861
|
export async function handleHttpFrame(dchOrSend, frame) {
|
|
834
862
|
const send = toSender(dchOrSend);
|
|
835
863
|
const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
|
|
836
864
|
const t0 = Date.now();
|
|
865
|
+
// 页面还活着:任何后续请求都撤掉「镜像页加载」看门狗(见 armMirrorLoadWatch)
|
|
866
|
+
if (mirrorLoadWatch) { mirrorLoadWatch = null; }
|
|
837
867
|
// 桌面授权引导(方案A):POST /_e2ee/intro(device/channel 形态归一)—— 本地应答,不连上游
|
|
838
868
|
if (method === "POST" && path === "/_e2ee/intro") {
|
|
839
869
|
return answerIntro(send, id);
|
|
@@ -1675,6 +1705,10 @@ async function main() {
|
|
|
1675
1705
|
return runTunnel();
|
|
1676
1706
|
}
|
|
1677
1707
|
|
|
1708
|
+
// test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
|
|
1709
|
+
export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
|
|
1710
|
+
export function __armMirrorLoadWatch(path) { armMirrorLoadWatch(path); }
|
|
1711
|
+
|
|
1678
1712
|
// 直接运行(node dsh-bridge.mjs)时启动服务;被测试 import 时只导出协议函数。
|
|
1679
1713
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1680
1714
|
if (isMain) {
|
|
@@ -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; }
|
|
@@ -426,6 +399,18 @@ div.dsh-ma-menu .dsh-ma-menu-note {
|
|
|
426
399
|
margin-top: 8px; font-size: 11.5px; line-height: 1.7;
|
|
427
400
|
color: var(--dsw-alias-label-tertiary, #6b7280);
|
|
428
401
|
}
|
|
402
|
+
/* 加载疑似没完成时的一行提示 —— **只放进已有的「远程控制」菜单**,不新增任何层。
|
|
403
|
+
2026-09-25 教训:首屏提示层(全屏覆盖 + 启发式退出)会永久盖住已加载完的界面,已整块删除。
|
|
404
|
+
这里换成**安全的失败方向**:判断错了最坏只是菜单里多一行字,绝不遮挡、绝不拦截。 */
|
|
405
|
+
div.dsh-ma-menu .dsh-ma-load-warn {
|
|
406
|
+
display: flex; gap: 8px; align-items: flex-start; margin-top: 8px; padding: 8px 10px;
|
|
407
|
+
border-radius: 8px; cursor: pointer; font-size: 12px; line-height: 1.6;
|
|
408
|
+
background: color-mix(in srgb, var(--dsw-alias-label-warning, #9a6700) 12%, transparent);
|
|
409
|
+
color: var(--dsw-alias-label-warning, #9a6700);
|
|
410
|
+
}
|
|
411
|
+
div.dsh-ma-menu .dsh-ma-load-warn[hidden] { display: none !important; }
|
|
412
|
+
div.dsh-ma-menu .dsh-ma-load-warn .ico { flex: none; }
|
|
413
|
+
div.dsh-ma-menu .dsh-ma-load-warn .txt { min-width: 0; overflow-wrap: anywhere; }
|
|
429
414
|
|
|
430
415
|
/* 会话过期引导条:镜像页在会话过期后只会"连不上",这里给一句能照做的话 + 一个出口。
|
|
431
416
|
非阻塞(顶部细条),点关闭即消失;不拦截任何请求。 */
|
|
@@ -458,88 +443,25 @@ html[data-dsh-ma-merged-e2ee] #dsh-e2ee-badge { display: none !important; }
|
|
|
458
443
|
const SCRIPT = `(() => {
|
|
459
444
|
"use strict";
|
|
460
445
|
|
|
461
|
-
/* =====
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
446
|
+
/* ===== 【0.6.16 已删除】首屏加载提示层(0.6.14 引入,0.6.15 仍在)=====
|
|
447
|
+
为什么删掉(业主实测,2026-09-25):「界面都已经加载完了,十几秒就把插件的接口加载好了,
|
|
448
|
+
但这个遮罩一直不消失」—— 截图里官方 UI 就在下面透出来,秒表走到 187 秒。
|
|
449
|
+
|
|
450
|
+
根因是**设计错误**,不是某个选择器写错:
|
|
451
|
+
· 那是个 position:fixed / inset:0 / z-index:2147483000 的**全屏层**(88% 不透明背景 +
|
|
452
|
+
backdrop-blur),视觉上盖住整个应用;
|
|
453
|
+
· 它唯一的退出条件是 maBootLooksReady() 这组**DOM 启发式**(官方 frame 的
|
|
454
|
+
data-*-collapsed / 类名前缀,或 textarea/[contenteditable]/[role=textbox])。
|
|
455
|
+
官方改版、会话尚未挂载输入框、或前缀变化时两组信号全不命中 → 层就永远留着。
|
|
456
|
+
· 当时我给自己写的安慰是「pointer-events:none,万一移除失效也绝不挡住点击」——
|
|
457
|
+
可对用户来说,**盖住视线和进不去没有区别**:刷新也一样,每次都要等 4 分钟兜底。
|
|
458
|
+
结论(业主要求):**直接删掉这个功能**。首屏反馈交给官方自己的转圈;
|
|
459
|
+
我们不再在用户页面上放任何会覆盖整屏的东西 —— 这类层一旦判断失误就是"永远进不去",
|
|
460
|
+
代价远大于它带来的提示价值(何况限速放开后首屏本来就快了)。
|
|
461
|
+
|
|
462
|
+
护栏见 test/mobile-adapter-guards.test.mjs:适配层不得再引入全屏覆盖层。 */
|
|
463
|
+
void 0;
|
|
465
464
|
|
|
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
465
|
|
|
544
466
|
/* —— 主机门解除(2026-09):镜像页(如 n.risegao.cn)经设备流认证回连同一台 127.0.0.1:3080,
|
|
545
467
|
桥已把 Host 回环化并剥 Origin(不扩大信任面)。官方客户端以 transport.ownsHost 判定
|
|
@@ -575,6 +497,83 @@ const SCRIPT = `(() => {
|
|
|
575
497
|
}
|
|
576
498
|
})();
|
|
577
499
|
|
|
500
|
+
/* ===== 「加载疑似没完成」看门狗(2026-09-25,替代被整块删除的首屏提示层)=====
|
|
501
|
+
那个提示层是全屏覆盖 + 启发式退出 —— 判断错一次就把**已经加载完**的界面永久盖住
|
|
502
|
+
(业主实测:秒表走到 187 秒,刷新也一样)。所以这里换一条**安全的失败方向**:
|
|
503
|
+
|
|
504
|
+
· 解析期**不创建任何 DOM**(只有几个变量 + 一个定时器)—— 页面还没过主机门,
|
|
505
|
+
更不该有任何覆盖层;
|
|
506
|
+
· 25 秒后仍认不出官方界面 → 只置一个 slow 标记,**不往页面塞任何东西**;
|
|
507
|
+
· 标记生效时,往**已有的**「远程控制」悬浮菜单里多一行字(点一下复制一句话给客服),
|
|
508
|
+
并建议点菜单里本来就有的「返回设备列表」重来;
|
|
509
|
+
· 判断错了的代价 = 菜单里多一行字(用户不点开菜单完全看不到);不会再挡住任何人。
|
|
510
|
+
|
|
511
|
+
⚠️ 刻意**不**做成页面顶部/底部的固定细条:那种细条同样会盖住官方 UI 的一部分,
|
|
512
|
+
而且同样有"永不消失"的失败方向。菜单是被动展开的,天然没有这个问题。 */
|
|
513
|
+
var maLoadWatch = { startedAt: Date.now(), slow: false, wired: false, bound: false, warnEl: null, warnTxt: null };
|
|
514
|
+
/** 官方界面是否已经可用(只看最强信号;只用于决定"要不要多显示一行字")。 */
|
|
515
|
+
function maLoadReadyNow() {
|
|
516
|
+
try {
|
|
517
|
+
if (document.querySelector(".pI_x6G_frame, div.dsh-ma-frame, [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]")) return true;
|
|
518
|
+
return Boolean(document.querySelector('[data-composer-input], [contenteditable="true"], [role="textbox"], textarea'));
|
|
519
|
+
} catch (e) { return false; }
|
|
520
|
+
}
|
|
521
|
+
function maLoadWaitedSec() {
|
|
522
|
+
return Math.max(0, Math.round((Date.now() - maLoadWatch.startedAt) / 1000));
|
|
523
|
+
}
|
|
524
|
+
/** 给客服看的一句话(含等待时长与 UA,便于直接定位)。 */
|
|
525
|
+
function maLoadCopyLine() {
|
|
526
|
+
let ua = "";
|
|
527
|
+
try { ua = String((navigator && navigator.userAgent) || "").slice(0, 120); } catch (e) { /* 忽略 */ }
|
|
528
|
+
return "dsh-remote 手机端加载疑似卡住:等待 " + maLoadWaitedSec() + " 秒后仍未出现对话界面。UA=" + ua;
|
|
529
|
+
}
|
|
530
|
+
function maLoadRenderWarn() {
|
|
531
|
+
try {
|
|
532
|
+
if (!maLoadWatch.warnEl) return;
|
|
533
|
+
if (!maLoadWatch.slow) { maLoadWatch.warnEl.hidden = true; return; }
|
|
534
|
+
if (maLoadWatch.warnTxt) {
|
|
535
|
+
maLoadWatch.warnTxt.textContent = "本次页面可能没加载完整(已等 " + maLoadWaitedSec()
|
|
536
|
+
+ " 秒)。点这里复制一句话发给客服,或点下方「返回设备列表」重来。";
|
|
537
|
+
}
|
|
538
|
+
maLoadWatch.warnEl.hidden = false;
|
|
539
|
+
} catch (e) { /* 提示失败绝不影响主功能 */ }
|
|
540
|
+
}
|
|
541
|
+
/** 把这一行接进已有的悬浮菜单(由菜单构建处调用;菜单不存在时什么都不做)。 */
|
|
542
|
+
function maWireLoadWarn(menu) {
|
|
543
|
+
try {
|
|
544
|
+
maLoadWatch.warnEl = menu.querySelector('[data-role="load-warn"]');
|
|
545
|
+
maLoadWatch.warnTxt = menu.querySelector('[data-role="load-warn-txt"]');
|
|
546
|
+
maLoadWatch.wired = true;
|
|
547
|
+
maLoadRenderWarn();
|
|
548
|
+
if (maLoadWatch.warnEl && !maLoadWatch.bound) {
|
|
549
|
+
maLoadWatch.bound = true;
|
|
550
|
+
maLoadWatch.warnEl.addEventListener("click", function () {
|
|
551
|
+
const line = maLoadCopyLine();
|
|
552
|
+
const show = (t) => { try { if (maLoadWatch.warnTxt) maLoadWatch.warnTxt.textContent = t; } catch (e) { /* 忽略 */ } };
|
|
553
|
+
try {
|
|
554
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
555
|
+
navigator.clipboard.writeText(line).then(
|
|
556
|
+
() => show("已复制。直接粘给客服即可(里面已带等待时长与机型信息)。"),
|
|
557
|
+
() => show(line) // 剪贴板不可用 → 至少把这句话显示出来让用户截图
|
|
558
|
+
);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
} catch (e) { /* 落到下面 */ }
|
|
562
|
+
show(line);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
} catch (e) { /* 忽略 */ }
|
|
566
|
+
}
|
|
567
|
+
// 25 秒后仍认不出官方界面 → 置慢标记(**只改状态,不碰 DOM**)。
|
|
568
|
+
// 阈值取得很宽松:宁可漏报(什么都不显示,与今天一致),也不要误报吓人。
|
|
569
|
+
setTimeout(function () {
|
|
570
|
+
try {
|
|
571
|
+
if (maLoadReadyNow()) return;
|
|
572
|
+
maLoadWatch.slow = true;
|
|
573
|
+
if (maLoadWatch.wired) maLoadRenderWarn();
|
|
574
|
+
} catch (e) { /* 忽略 */ }
|
|
575
|
+
}, 25 * 1000);
|
|
576
|
+
|
|
578
577
|
try {
|
|
579
578
|
const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
|
|
580
579
|
const NARROW = () => window.innerWidth <= 820;
|
|
@@ -945,8 +944,13 @@ const SCRIPT = `(() => {
|
|
|
945
944
|
'<div class="dsh-ma-menu-e2ee" data-role="e2ee"><span class="ico">🔒</span><span class="txt">正在读取…</span></div>' +
|
|
946
945
|
'</div>' +
|
|
947
946
|
'<button type="button" class="dsh-ma-menu-back">← 返回设备列表</button>' +
|
|
947
|
+
'<div class="dsh-ma-load-warn" data-role="load-warn" hidden>' +
|
|
948
|
+
'<span class="ico">⚠</span>' +
|
|
949
|
+
'<span class="txt" data-role="load-warn-txt"></span>' +
|
|
950
|
+
'</div>' +
|
|
948
951
|
'<div class="dsh-ma-menu-note">返回后可重新选择设备;端到端加密需在设备列表页重新解锁(会话密钥只存在本机内存里,刷新即失效)。</div>';
|
|
949
952
|
document.body.appendChild(menu);
|
|
953
|
+
maWireLoadWarn(menu); // 加载疑似失败时,在这一行里说明白(点一下可复制)
|
|
950
954
|
|
|
951
955
|
/* ---- 悬浮按钮的位置:默认**右上角**(原来在右下角,正好压住输入框的发送按钮);
|
|
952
956
|
可拖动,位置按"最近的角 + 距边偏移"记忆 —— 旋转屏幕/换窗口宽度都不会跑到屏幕外。 ---- */
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import assert from "node:assert/strict";
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
|
-
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import test from "node:test";
|
|
@@ -115,3 +115,19 @@ test("用法里必须有 doctor(否则用户不知道有这个入口)", () =
|
|
|
115
115
|
assert.match(out, /dsh-remote doctor/, "用法里要列出 doctor");
|
|
116
116
|
assert.match(out, /为什么连不上/, "要一句话说清它是干什么的");
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
test("★ 面板「复制诊断信息」必须带上 doctor 输出(唯一实现,插件半不抄第二份)", () => {
|
|
120
|
+
const clientSrc = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
121
|
+
// 面板侧:复制前先取 doctor,取不到就退回原有文本(按钮永不因此失败)
|
|
122
|
+
assert.match(clientSrc, /api\("\/dsh-remote\/doctor"/, "面板必须去取 doctor 输出");
|
|
123
|
+
assert.match(clientSrc, /base \+ "\\n\\n" \+ t \+ "\\n"/, "doctor 文本要接在原有诊断之后");
|
|
124
|
+
assert.match(clientSrc, /setTimeout\(function \(\) \{ copyNow\(base\); \}, 12000\)/,
|
|
125
|
+
"doctor 慢/挂住时必须有兜底:12 秒后用原有文本复制(不能把按钮挂住)");
|
|
126
|
+
|
|
127
|
+
const indexSrc = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
|
|
128
|
+
// 节点侧:必须**起子进程跑运行环境里的 doctor**(复用唯一实现),而不是在插件里重写一份
|
|
129
|
+
assert.match(indexSrc, /path: "\/dsh-remote\/doctor"/, "节点半必须有这条路由");
|
|
130
|
+
assert.match(indexSrc, /safeSpawn\(process\.execPath, \[setup, "doctor"\]/, "必须跑运行环境的 doctor(同一份实现)");
|
|
131
|
+
assert.match(indexSrc, /skipsSystemOps\(\)\) return Promise\.resolve\(\{ ok: false, text: "", error: "测试隔离/,
|
|
132
|
+
"必须遵守测试隔离(用例不得真的起子进程)");
|
|
133
|
+
});
|
|
@@ -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
|
-
});
|