@mrrisega/dsh-remote 0.6.13 → 0.6.14

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.
@@ -76,6 +76,7 @@ import { promisify } from "node:util";
76
76
  import { gzip as gzipCb } from "node:zlib";
77
77
  // 移动端适配层(经隧道访问的官方 dsh web 窄屏注入;DSH_MOBILE_ADAPTER=0 可关闭,默认开启)
78
78
  import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
79
+ import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./upstream-discovery.mjs";
79
80
  // 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
80
81
  import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
81
82
  // E2EE(端到端加密)客户端基建(Phase-2):MK 派生/会话密钥/信封/握手/开关
@@ -114,7 +115,23 @@ const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
114
115
  // 隧道模式(唯一):bridge 主动 WS 连 relay-router 的 /_bridge
115
116
  const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "");
116
117
  const TUNNEL_HEARTBEAT_MS = Math.max(100, Number(process.env.DSH_BRIDGE_HEARTBEAT_MS) || 15_000);
117
- const UPSTREAM = process.env.DSH_BRIDGE_UPSTREAM || "http://127.0.0.1:3080";
118
+ /**
119
+ * 上游 dsh web 的地址。
120
+ *
121
+ * ⚠️ 这里**不能**只信环境变量 + 写死 3080(2026-09-23 两起用户实测):
122
+ * ① `dsh web --port 8090` / `--port 0`(系统分配)→ 3080 没人听;
123
+ * ② **DSH Desktop**(Electron 壳 `dsh-plugin-desktop`)默认 43120,被占用还会 +1。
124
+ * bridge 与 watcher 共用同一份发现实现(`upstream-discovery.mjs`):
125
+ * 显式环境变量 > `<relayDir>/.dsh-upstream`(插件半用 ctx.webServer.port 落盘)> DSH_WEB_URL
126
+ * > **动态发现**(本机 dsh 进程实际监听端口 + Desktop 端口区间,且每个候选都做身份校验)。
127
+ *
128
+ * 用 `let` 是因为:① 发现发生在启动之后(异步);② 上游端口变了要能在**不重启 bridge** 的前提下跟上。
129
+ */
130
+ const RELAY_DIR = path.dirname(CONFIG_PATH);
131
+ let UPSTREAM = (() => {
132
+ const hint = resolveUpstreamHint({ relayDir: RELAY_DIR });
133
+ return hint.url || FALLBACK_UPSTREAM;
134
+ })();
118
135
  // 微信机器人通道总开关:DSH_WECHAT=0 关闭(默认开启)。控制面只 bind 回环,且必须带 bridge_secret。
119
136
  const WECHAT_DISABLED = String(process.env.DSH_WECHAT || "") === "0";
120
137
  // 默认云端服务地址(dsh-remote setup 会显式传入;自建模式无需账号 API)
@@ -385,6 +402,37 @@ const STRIP_RES_HEADERS = new Set([
385
402
  "connection", "keep-alive", "upgrade"
386
403
  ]);
387
404
 
405
+
406
+ /** 上游重新发现的冷却:失败请求可能连成片,不能每个都去 lsof/pgrep + 探端口。 */
407
+ const UPSTREAM_REFRESH_COOLDOWN_MS = 15_000;
408
+ let upstreamRefreshedAt = 0;
409
+ let upstreamRefreshInflight = null;
410
+ /**
411
+ * 重新解析上游地址(带身份校验;显式 DSH_BRIDGE_UPSTREAM 时是恒等操作)。
412
+ * 上游端口变了(Desktop 换端口 / dsh web 重启到别的端口)时,靠它自愈而不必重启 bridge。
413
+ */
414
+ async function refreshUpstream(reason = "") {
415
+ if (upstreamRefreshInflight) return upstreamRefreshInflight;
416
+ const now = Date.now();
417
+ if (now - upstreamRefreshedAt < UPSTREAM_REFRESH_COOLDOWN_MS) return UPSTREAM;
418
+ upstreamRefreshedAt = now;
419
+ upstreamRefreshInflight = (async () => {
420
+ try {
421
+ const found = await discoverUpstream({ relayDir: RELAY_DIR });
422
+ if (found.url && found.url !== UPSTREAM) {
423
+ console.log(`[bridge] 上游地址切换: ${UPSTREAM} → ${found.url}(来源 ${found.source}${reason ? `,${reason}` : ""})`);
424
+ UPSTREAM = found.url;
425
+ }
426
+ return UPSTREAM;
427
+ } catch {
428
+ return UPSTREAM;
429
+ } finally {
430
+ upstreamRefreshInflight = null;
431
+ }
432
+ })();
433
+ return upstreamRefreshInflight;
434
+ }
435
+
388
436
  console.log(`[bridge] 设备 ${DEVICE_ID} → 隧道 ${TUNNEL_URL}/_bridge`);
389
437
  console.log(`[bridge] 上游 ${UPSTREAM}`);
390
438
 
@@ -816,6 +864,9 @@ export async function handleHttpFrame(dchOrSend, frame) {
816
864
  send(reply);
817
865
  console.log(`[bridge] ${method} ${path} → ${reply.status} (${Date.now() - t0}ms, ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
818
866
  } catch (e) {
867
+ // 上游不可达是「端口可能变了」的最强信号(Desktop 换端口 / dsh web 重启到别的端口):
868
+ // 触发一次带冷却的重新发现,后续请求自动跟上,不必重启 bridge。
869
+ void refreshUpstream("上游请求失败");
819
870
  console.log(`[bridge] ${method} ${path} 上游错误: ${e.message}`);
820
871
  send({ id, type: "http", status: 502, headers: { "content-type": "application/json" }, body: Buffer.from(JSON.stringify({ error: String(e.message || e) })).toString("base64"), bodyBase64: true });
821
872
  }
@@ -877,6 +928,7 @@ export async function handleLegacyFrame(dchOrSend, frame) {
877
928
  send({ id, status: reply.status, headers: reply.headers, body: text });
878
929
  console.log(`[bridge] legacy ${method} ${path} → ${reply.status} (${Date.now() - t0}ms)`);
879
930
  } catch (e) {
931
+ void refreshUpstream("上游请求失败(legacy)");
880
932
  console.log(`[bridge] legacy ${method} ${path} 上游错误: ${e.message}`);
881
933
  send({ id, status: 502, headers: {}, body: JSON.stringify({ error: String(e.message || e) }) });
882
934
  }
@@ -1469,6 +1521,9 @@ function startWeChat() {
1469
1521
  const cfg = loadLocalConfig();
1470
1522
  wechatRuntime = createWeChatRuntime({
1471
1523
  relayDir,
1524
+ // 上游地址在 runTunnel() 开头已完成动态发现(见 refreshUpstream),所以这里拿到的是真实端口。
1525
+ // ⚠️ 这是**取值**而非引用:运行中若上游端口又变了(自愈重发现),微信通道要等 bridge 重启才跟上;
1526
+ // 隧道转发那一侧不受影响(它每次都读最新的 UPSTREAM)。
1472
1527
  upstream: UPSTREAM,
1473
1528
  cookieOf: harnessCookieOf,
1474
1529
  secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
@@ -1514,6 +1569,10 @@ function redactText(e) {
1514
1569
  }
1515
1570
 
1516
1571
  async function runTunnel() {
1572
+ // 先动态确定上游再连隧道:上游端口不是 3080 时(DSH Desktop 默认 43120)这一步是关键 ——
1573
+ // 否则 bridge 会连上中继、手机也能打开页面,但每个请求都打到一个没人听的端口。
1574
+ await refreshUpstream("启动");
1575
+ console.log(`[bridge] 上游地址已确定: ${UPSTREAM}`);
1517
1576
  const token = await resolveToken();
1518
1577
  if (!token) {
1519
1578
  console.error("[bridge] 隧道模式需要账号认证:请设 DSH_BRIDGE_TOKEN,或 DSH_BRIDGE_PHONE+DSH_BRIDGE_PASSWORD");
@@ -95,7 +95,34 @@ const RUNTIME_HOST_RE =
95
95
  /(data-sidebar-collapsed|data-details-collapsed|data-rightbar-collapsed|data-shell-overlay)|(pI_x6G_frame|hHd-Xa_root|hHd-Xa_toggle)/;
96
96
 
97
97
  const STYLE = `
98
- /* ===== dsh-remote mobile adapter (bridge 注入;仅 ≤820px 生效,桌面宽屏无任何规则) ===== */
98
+ /* ===== 首屏加载提示(0.6.14)=====
99
+ 为什么要有它:镜像页首屏要拉 dsh web 的全部客户端插件(实测 ~14 MB,一个不可拆分的
100
+ /plugins/?? 聚合请求),免费档限速下几十秒起步。此前页面上**只有官方那个转圈**,
101
+ 用户无法区分"在加载"和"卡死了",只能反复刷新 —— 反而更慢。
102
+ 这里给一句人话提示 + 分阶段的补充说明,加载完成(或超时兜底)即自动移除。
103
+ ⚠️ 刻意**不**放进 @media (max-width:820px):Windows/桌面浏览器打开镜像页同样要等。
104
+ ⚠️ pointer-events:none + 半透明:万一移除逻辑失效,也绝不挡住任何点击。 */
105
+ .dsh-ma-boot {
106
+ position: fixed; inset: 0; z-index: 2147483000;
107
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
108
+ background: color-mix(in srgb, var(--dsw-alias-bg-base, #0f1115) 88%, transparent);
109
+ backdrop-filter: blur(2px);
110
+ pointer-events: none;
111
+ font-family: inherit; text-align: center; padding: 24px;
112
+ transition: opacity .3s ease;
113
+ }
114
+ .dsh-ma-boot[data-fading="1"] { opacity: 0; }
115
+ .dsh-ma-boot-spin {
116
+ width: 34px; height: 34px; border-radius: 50%;
117
+ border: 3px solid color-mix(in srgb, currentColor 22%, transparent);
118
+ border-top-color: currentColor;
119
+ animation: dsh-ma-boot-spin 1s linear infinite;
120
+ }
121
+ @keyframes dsh-ma-boot-spin { to { transform: rotate(360deg); } }
122
+ .dsh-ma-boot-title { font-size: 15px; font-weight: 600; }
123
+ .dsh-ma-boot-sub { font-size: 13px; line-height: 1.6; opacity: .75; max-width: 22em; }
124
+ .dsh-ma-boot-elapsed { font-size: 12px; opacity: .55; font-variant-numeric: tabular-nums; }
125
+
99
126
  @media (max-width: 820px) {
100
127
  html, body { max-width: 100%; overflow-x: hidden; }
101
128
  html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
@@ -426,6 +453,89 @@ html[data-dsh-ma-merged-e2ee] #dsh-e2ee-badge { display: none !important; }
426
453
  const SCRIPT = `(() => {
427
454
  "use strict";
428
455
 
456
+ /* ===== 首屏加载提示(0.6.14)=====
457
+ 用户反馈:「普通用户加载进入镜像页面时等待时间较长,目前等待过程中一直在转圈」
458
+ —— 官方那个转圈不告诉用户任何事。这里在**页面解析的第一时间**就挂上一条人话提示,
459
+ 并在官方 UI 就绪(或兜底超时)后自动移除。
460
+
461
+ ⚠️ 必须放在所有主机判定(HOSTISH / NARROW)之前:首屏慢的正是这些判定等不到的时候。
462
+ ⚠️ 只做提示,不做任何拦截:pointer-events:none,也没有遮罩语义。 */
463
+ var maBootTip = null, maBootTimers = [], maBootStart = Date.now(), maBootElapsedTimer = 0, maBootWatchTimer = 0;
464
+ function maBootSet(cls, text) {
465
+ try { var n = maBootTip && maBootTip.querySelector(cls); if (n) n.textContent = text; } catch (e) { /* 忽略 */ }
466
+ }
467
+ function maBootShow() {
468
+ try {
469
+ if (maBootTip) return;
470
+ if (!document.body) return; // 解析太早(body 还没出来)→ 交给 DOMContentLoaded 再挂
471
+ maBootTip = document.createElement("div");
472
+ maBootTip.className = "dsh-ma-boot";
473
+ maBootTip.setAttribute("role", "status");
474
+ maBootTip.setAttribute("aria-live", "polite");
475
+ maBootTip.innerHTML =
476
+ '<div class="dsh-ma-boot-spin" aria-hidden="true"></div>' +
477
+ '<div class="dsh-ma-boot-title">正在加载远程桌面…</div>' +
478
+ '<div class="dsh-ma-boot-sub">首次打开需要下载较完整的前端资源,请稍候</div>' +
479
+ '<div class="dsh-ma-boot-elapsed">已等待 0 秒</div>';
480
+ document.body.appendChild(maBootTip);
481
+ // 秒表:让"到底等了多久"可见(用户据此判断是慢还是死了)
482
+ maBootElapsedTimer = setInterval(function () {
483
+ try {
484
+ var s = Math.floor((Date.now() - maBootStart) / 1000);
485
+ var n = maBootTip && maBootTip.querySelector(".dsh-ma-boot-elapsed");
486
+ if (n) n.textContent = "已等待 " + s + " 秒";
487
+ } catch (e) { /* 忽略 */ }
488
+ }, 1000);
489
+ maBootTimers.push(setTimeout(function () {
490
+ maBootSet(".dsh-ma-boot-sub", "网络较慢时可能需要一两分钟;加载完成后会自动进入,不用重复刷新");
491
+ }, 8000));
492
+ maBootTimers.push(setTimeout(function () {
493
+ maBootSet(".dsh-ma-boot-sub", "还在加载…如果长时间停在这里,可以下拉刷新重试(重复刷新不会更快)");
494
+ }, 30 * 1000));
495
+ } catch (e) { /* 提示失败绝不影响主功能 */ }
496
+ }
497
+ function maBootHide() {
498
+ try {
499
+ for (var i = 0; i < maBootTimers.length; i++) clearTimeout(maBootTimers[i]);
500
+ maBootTimers = [];
501
+ if (maBootElapsedTimer) { clearInterval(maBootElapsedTimer); maBootElapsedTimer = 0; }
502
+ if (maBootWatchTimer) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
503
+ var tip = maBootTip;
504
+ maBootTip = null;
505
+ if (tip && tip.parentNode) {
506
+ try { tip.setAttribute("data-fading", "1"); } catch (e) { /* 忽略 */ }
507
+ setTimeout(function () { try { if (tip.parentNode) tip.parentNode.removeChild(tip); } catch (e) { /* 忽略 */ } }, 300);
508
+ }
509
+ } catch (e) { /* 忽略 */ }
510
+ }
511
+ /** 官方 UI 是否已经可用了(= 提示可以撤了)。 */
512
+ function maBootLooksReady() {
513
+ try {
514
+ // ① 适配层认出来的官方 frame(与下方 boot() 同一组选择器)
515
+ if (document.querySelector(".pI_x6G_frame, [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]")) return true;
516
+ // ② 出现输入框/会话区也算"进来了"(官方改版首选信号失灵时的兜底)
517
+ return Boolean(document.querySelector('textarea, [contenteditable="true"], [role="textbox"]'));
518
+ } catch (e) { return false; }
519
+ }
520
+ if (document.readyState === "loading") {
521
+ document.addEventListener("DOMContentLoaded", maBootShow);
522
+ } else {
523
+ maBootShow();
524
+ }
525
+ // 轮询撤下:1s 一次,成本可忽略(只是两次 querySelector);一旦撤下就自我停止。
526
+ // 上限与"4 分钟兜底"对齐,避免页面异常时留下常驻定时器。
527
+ maBootWatchTimer = setInterval(function () {
528
+ if (maBootLooksReady()) { maBootHide(); return; }
529
+ if (!maBootTip) { try { clearInterval(maBootWatchTimer); } catch (e) { /* 忽略 */ } maBootWatchTimer = 0; }
530
+ }, 1000);
531
+ maBootTimers.push(setTimeout(function () { maBootHide(); }, 240 * 1000));
532
+ // 页面卸载/进 bfcache 时收干净(测试与真机都靠这条不留悬挂定时器)
533
+ try {
534
+ var maBootBye = function () { maBootHide(); };
535
+ window.addEventListener("pagehide", maBootBye);
536
+ window.addEventListener("unload", maBootBye);
537
+ } catch (e) { /* 忽略 */ }
538
+
429
539
  /* —— 主机门解除(2026-09):镜像页(如 n.risegao.cn)经设备流认证回连同一台 127.0.0.1:3080,
430
540
  桥已把 Host 回环化并剥 Origin(不扩大信任面)。官方客户端以 transport.ownsHost 判定
431
541
  「自有主机」,非回环域名下不置 true 会把设置页置为 memory(不可用)镜像 →
@@ -0,0 +1,158 @@
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
+ });
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 上游(dsh web)地址发现契约测试。
4
+ *
5
+ * 背景(两起真实事故,见 upstream-discovery.mjs 顶部注释):
6
+ * ① `dsh web --port 8090` / `--port 0`(系统分配端口)→ 写死 3080 的探测永远失败;
7
+ * ② **DSH Desktop**(Electron 壳 `dsh-plugin-desktop`)默认 43120、占用则 +1,插件 0.6.10
8
+ * 实测「面板永久停在 starting、设备从未登记」——watcher 探 3080 探不到就永不派生 bridge。
9
+ *
10
+ * 这里守四件事:
11
+ * ① 提示优先级(env > 端口文件 > DSH_WEB_URL)与规范化;
12
+ * ② **身份校验**:候选端口可能被别的程序占用,绝不能把 bridge 接到陌生人身上;
13
+ * ③ 提示不可用时会去探候选(含 Desktop 端口区间),并报出**准确的来源**;
14
+ * ④ 什么都找不到时保持历史行为(回退 3080),不能变成"抛错/空地址"。
15
+ *
16
+ * 用法: node --test test/upstream-discovery.test.mjs
17
+ */
18
+ import assert from "node:assert/strict";
19
+ import http from "node:http";
20
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import test from "node:test";
24
+
25
+ import {
26
+ candidatePorts,
27
+ discoverUpstream,
28
+ listeningPortsOfPid,
29
+ looksLikeDshWeb,
30
+ normalizeUpstream,
31
+ probeDshWeb,
32
+ resolveUpstreamHint,
33
+ DESKTOP_DEFAULT_PORT,
34
+ FALLBACK_UPSTREAM,
35
+ UPSTREAM_FILE
36
+ } from "../upstream-discovery.mjs";
37
+
38
+ const DIR = mkdtempSync(path.join(os.tmpdir(), "dsh-upstream-"));
39
+ process.on("exit", () => { try { rmSync(DIR, { recursive: true, force: true }); } catch { /* ignore */ } });
40
+
41
+ /** 假的 dsh web:回一段带官方特征标记的 HTML(身份校验靠它)。 */
42
+ const DSH_HTML = "<!doctype html><html><head><title>DeepSeek Harness</title></head><body><script>__ModuleLoader__</script></body></html>";
43
+ /** 冒牌服务:端口在听,但不是 dsh web。 */
44
+ const STRANGER = "hello, I am some other local server";
45
+
46
+ async function listen(handler) {
47
+ const srv = http.createServer(handler);
48
+ await new Promise((r) => srv.listen(0, "127.0.0.1", r));
49
+ return { srv, port: srv.address().port, url: `http://127.0.0.1:${srv.address().port}` };
50
+ }
51
+ const close = (x) => new Promise((r) => x.srv.close(r));
52
+
53
+ const writeHint = (url) => writeFileSync(path.join(DIR, UPSTREAM_FILE), url + "\n");
54
+ /**
55
+ * 把 3080 封掉的 fetch 包装。
56
+ * 为什么需要:开发机上真实存在一个 dsh web 监听 3080,候选扫描会命中它 ——
57
+ * 那是**宿主**而不是测试夹具,断言就变成了"看运气"。封掉它,测试才只依赖自己起的假服务。
58
+ */
59
+ const realFetch = globalThis.fetch;
60
+ const blockedFetch = (input, init) => {
61
+ const url = String(typeof input === "string" ? input : (input && input.url) || "");
62
+ if (/:3080(\/|$)/.test(url)) return Promise.reject(new Error("blocked: host 3080"));
63
+ return realFetch(input, init);
64
+ };
65
+
66
+
67
+ test("规范化:去尾斜杠;非 http(s) / 空值一律丢弃", () => {
68
+ assert.equal(normalizeUpstream("http://127.0.0.1:43120/"), "http://127.0.0.1:43120");
69
+ assert.equal(normalizeUpstream(" http://127.0.0.1:3080 "), "http://127.0.0.1:3080");
70
+ assert.equal(normalizeUpstream("wss://n.risegao.cn:13443"), "");
71
+ assert.equal(normalizeUpstream(""), "");
72
+ assert.equal(normalizeUpstream(undefined), "");
73
+ });
74
+
75
+ test("提示优先级:env > 端口文件 > DSH_WEB_URL", () => {
76
+ writeHint("http://127.0.0.1:43120");
77
+ assert.deepEqual(
78
+ resolveUpstreamHint({ relayDir: DIR, env: { DSH_BRIDGE_UPSTREAM: "http://127.0.0.1:9001" } }),
79
+ { url: "http://127.0.0.1:9001", source: "env" }
80
+ );
81
+ assert.deepEqual(resolveUpstreamHint({ relayDir: DIR, env: {} }), { url: "http://127.0.0.1:43120", source: "file" });
82
+ // 文件坏掉 → 落到 DSH_WEB_URL
83
+ writeHint("not-a-url");
84
+ assert.deepEqual(
85
+ resolveUpstreamHint({ relayDir: DIR, env: { DSH_WEB_URL: "https://example.com:8090/?token=x" } }),
86
+ { url: "http://127.0.0.1:8090", source: "dsh_web_url" }
87
+ );
88
+ writeHint("http://127.0.0.1:43120");
89
+ });
90
+
91
+ test("身份校验:只认 dsh web 特征,拒绝陌生服务与空响应", () => {
92
+ assert.equal(looksLikeDshWeb(200, DSH_HTML), true);
93
+ assert.equal(looksLikeDshWeb(401, "dsh web authentication required; reopen the URL printed by dsh web."), true);
94
+ assert.equal(looksLikeDshWeb(200, STRANGER), false);
95
+ assert.equal(looksLikeDshWeb(200, ""), false);
96
+ assert.equal(looksLikeDshWeb(500, DSH_HTML), false, "5xx 不算");
97
+ });
98
+
99
+ test("行为:probeDshWeb 认得出真 dsh web,也拒绝陌生服务与死端口", async () => {
100
+ const dsh = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/html" }); res.end(DSH_HTML); });
101
+ const stranger = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/plain" }); res.end(STRANGER); });
102
+ try {
103
+ assert.equal(await probeDshWeb(dsh.url), true);
104
+ assert.equal(await probeDshWeb(stranger.url), false, "端口在听但不是 dsh web → 必须拒绝");
105
+ assert.equal(await probeDshWeb("http://127.0.0.1:1"), false, "死端口 → false");
106
+ assert.equal(await probeDshWeb("wss://x"), false, "非 http(s) → false");
107
+ } finally {
108
+ await close(dsh); await close(stranger);
109
+ }
110
+ });
111
+
112
+ test("★ DSH Desktop 场景:端口文件过期/缺失时,靠候选探测找到 43120 上的 dsh web", async () => {
113
+ // 模拟 Desktop:dsh web 在 43120,而端口文件写的是上一轮的旧端口(已无人监听)
114
+ const desktop = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/html" }); res.end(DSH_HTML); });
115
+ // 探测真的走 43120 —— 测试里换成"把 Desktop 端口作为 extraPorts 注入",避免占用真实端口
116
+ writeHint("http://127.0.0.1:1"); // 过期提示
117
+ try {
118
+ const found = await discoverUpstream({
119
+ relayDir: DIR,
120
+ env: {},
121
+ extraPorts: [desktop.port],
122
+ fetchImpl: blockedFetch,
123
+ platform: "win32" // 隔离宿主:不走 ps/pgrep/netstat,候选只来自提示/额外端口/静态表
124
+ });
125
+ assert.equal(found.url, desktop.url, "必须落到候选命中的那个 dsh web");
126
+ assert.equal(found.source, "extra");
127
+ assert.ok(found.rejected.includes("http://127.0.0.1:1"), "过期提示应被记入 rejected");
128
+ } finally {
129
+ await close(desktop);
130
+ }
131
+ });
132
+
133
+ test("★ 候选探测不做身份校验的话会接错人:陌生服务在候选端口上也必须被拒绝", async () => {
134
+ const stranger = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/plain" }); res.end(STRANGER); });
135
+ writeHint("http://127.0.0.1:1");
136
+ try {
137
+ const found = await discoverUpstream({ relayDir: DIR, env: {}, extraPorts: [stranger.port], fetchImpl: blockedFetch, platform: "win32" });
138
+ assert.notEqual(found.url, stranger.url, "陌生服务绝不能成为上游");
139
+ assert.equal(found.url, "http://127.0.0.1:1", "找不到时回落到提示地址(让上层按'上游没起来'重试)");
140
+ } finally {
141
+ await close(stranger);
142
+ }
143
+ });
144
+
145
+ test("提示可用时不做候选扫描(省掉无谓探测)", async () => {
146
+ const dsh = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/html" }); res.end(DSH_HTML); });
147
+ writeHint(dsh.url);
148
+ try {
149
+ const found = await discoverUpstream({ relayDir: DIR, env: {}, fetchImpl: blockedFetch, platform: "win32" });
150
+ assert.equal(found.url, dsh.url);
151
+ assert.equal(found.source, "file");
152
+ assert.deepEqual(found.probed, [dsh.port], "只探了提示本身");
153
+ } finally {
154
+ await close(dsh);
155
+ }
156
+ });
157
+
158
+ test("显式 DSH_BRIDGE_UPSTREAM 直接采信(探测都不做) —— 保持既有语义", async () => {
159
+ const found = await discoverUpstream({
160
+ relayDir: DIR,
161
+ env: { DSH_BRIDGE_UPSTREAM: "http://127.0.0.1:63999" },
162
+ platform: "win32",
163
+ fetchImpl: () => { throw new Error("不该发请求"); }
164
+ });
165
+ assert.equal(found.url, "http://127.0.0.1:63999");
166
+ assert.equal(found.source, "env");
167
+ assert.deepEqual(found.probed, []);
168
+ });
169
+
170
+ test("什么都找不到 → 回退历史默认 3080(不抛错、不放空)", async () => {
171
+ writeHint("not-a-url");
172
+ const found = await discoverUpstream({ relayDir: DIR, env: {}, fetchImpl: blockedFetch, platform: "win32", timeoutMs: 120 });
173
+ assert.equal(found.url, FALLBACK_UPSTREAM);
174
+ assert.equal(found.source, "fallback");
175
+ });
176
+
177
+ test("候选构成:含 3080 与 DSH Desktop 默认端口区间,且去重、带来源", () => {
178
+ writeHint("http://127.0.0.1:43120");
179
+ const list = candidatePorts({ relayDir: DIR, env: {}, platform: "win32" });
180
+ const ports = list.map((e) => e.port);
181
+ assert.ok(ports.includes(3080), "必须包含历史默认端口");
182
+ for (let i = 0; i < 12; i++) assert.ok(ports.includes(DESKTOP_DEFAULT_PORT + i), `必须覆盖 Desktop 端口区间 +${i}`);
183
+ assert.equal(new Set(ports).size, ports.length, "不得重复");
184
+ assert.equal(list.find((e) => e.port === 43120).source, "file", "首个候选应来自端口文件");
185
+ });
186
+
187
+ test("实测端口:能从本进程真实监听端口里读出来(macOS/Linux 用 lsof)", async (t) => {
188
+ if (process.platform === "win32") return t.skip("Windows 走 netstat,另有实现");
189
+ const srv = await listen((_req, res) => res.end("x"));
190
+ try {
191
+ const ports = listeningPortsOfPid(process.pid);
192
+ assert.ok(ports.includes(srv.port), `应包含 ${srv.port},实际 ${JSON.stringify(ports)}`);
193
+ } finally {
194
+ await close(srv);
195
+ }
196
+ });