@mrrisega/dsh-remote 0.6.16 → 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 +94 -0
- package/clients/dsh-remote/test/bridge-html-inject.test.mjs +33 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +23 -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 +94 -0
- package/packages/dsh-remote-web/test/doctor-cli.test.mjs +17 -1
|
@@ -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) {
|
|
@@ -399,6 +399,18 @@ div.dsh-ma-menu .dsh-ma-menu-note {
|
|
|
399
399
|
margin-top: 8px; font-size: 11.5px; line-height: 1.7;
|
|
400
400
|
color: var(--dsw-alias-label-tertiary, #6b7280);
|
|
401
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; }
|
|
402
414
|
|
|
403
415
|
/* 会话过期引导条:镜像页在会话过期后只会"连不上",这里给一句能照做的话 + 一个出口。
|
|
404
416
|
非阻塞(顶部细条),点关闭即消失;不拦截任何请求。 */
|
|
@@ -485,6 +497,83 @@ const SCRIPT = `(() => {
|
|
|
485
497
|
}
|
|
486
498
|
})();
|
|
487
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
|
+
|
|
488
577
|
try {
|
|
489
578
|
const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
|
|
490
579
|
const NARROW = () => window.innerWidth <= 820;
|
|
@@ -855,8 +944,13 @@ const SCRIPT = `(() => {
|
|
|
855
944
|
'<div class="dsh-ma-menu-e2ee" data-role="e2ee"><span class="ico">🔒</span><span class="txt">正在读取…</span></div>' +
|
|
856
945
|
'</div>' +
|
|
857
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>' +
|
|
858
951
|
'<div class="dsh-ma-menu-note">返回后可重新选择设备;端到端加密需在设备列表页重新解锁(会话密钥只存在本机内存里,刷新即失效)。</div>';
|
|
859
952
|
document.body.appendChild(menu);
|
|
953
|
+
maWireLoadWarn(menu); // 加载疑似失败时,在这一行里说明白(点一下可复制)
|
|
860
954
|
|
|
861
955
|
/* ---- 悬浮按钮的位置:默认**右上角**(原来在右下角,正好压住输入框的发送按钮);
|
|
862
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
|
+
});
|
|
@@ -874,3 +874,26 @@ test("★ 护栏:解析期绝不许往页面塞东西(0.6.14 首屏提示层
|
|
|
874
874
|
"不得出现 1000000 以上的 z-index(压过一切的全屏层 = 判断错一次就锁死用户)");
|
|
875
875
|
assert.ok(!/dsh-ma-boot/.test(css), "首屏提示层的样式必须已彻底删除");
|
|
876
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) {
|
|
@@ -399,6 +399,18 @@ div.dsh-ma-menu .dsh-ma-menu-note {
|
|
|
399
399
|
margin-top: 8px; font-size: 11.5px; line-height: 1.7;
|
|
400
400
|
color: var(--dsw-alias-label-tertiary, #6b7280);
|
|
401
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; }
|
|
402
414
|
|
|
403
415
|
/* 会话过期引导条:镜像页在会话过期后只会"连不上",这里给一句能照做的话 + 一个出口。
|
|
404
416
|
非阻塞(顶部细条),点关闭即消失;不拦截任何请求。 */
|
|
@@ -485,6 +497,83 @@ const SCRIPT = `(() => {
|
|
|
485
497
|
}
|
|
486
498
|
})();
|
|
487
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
|
+
|
|
488
577
|
try {
|
|
489
578
|
const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
|
|
490
579
|
const NARROW = () => window.innerWidth <= 820;
|
|
@@ -855,8 +944,13 @@ const SCRIPT = `(() => {
|
|
|
855
944
|
'<div class="dsh-ma-menu-e2ee" data-role="e2ee"><span class="ico">🔒</span><span class="txt">正在读取…</span></div>' +
|
|
856
945
|
'</div>' +
|
|
857
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>' +
|
|
858
951
|
'<div class="dsh-ma-menu-note">返回后可重新选择设备;端到端加密需在设备列表页重新解锁(会话密钥只存在本机内存里,刷新即失效)。</div>';
|
|
859
952
|
document.body.appendChild(menu);
|
|
953
|
+
maWireLoadWarn(menu); // 加载疑似失败时,在这一行里说明白(点一下可复制)
|
|
860
954
|
|
|
861
955
|
/* ---- 悬浮按钮的位置:默认**右上角**(原来在右下角,正好压住输入框的发送按钮);
|
|
862
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
|
+
});
|