@mrrisega/dsh-remote 0.6.12 → 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.
@@ -29,6 +29,7 @@ import path from "node:path";
29
29
  import { spawn, spawnSync, execSync } from "node:child_process";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { childStopped, stopChildGracefully } from "./clients/dsh-remote/src/lifecycle.mjs";
32
+ import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./clients/dsh-remote/upstream-discovery.mjs";
32
33
 
33
34
  const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
34
35
  const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
@@ -779,14 +780,21 @@ function restartBridgeService() {
779
780
  * 【2026-09-23 用户实测事故】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
780
781
  * 不在默认端口(`dsh web --port 8090`、profile 里配了 port、或 `--port 0` 让系统分配空闲端口),
781
782
  * watcher 的在线探测就**永远**失败 → bridge 永远不启动,而面板只会说「正在启动 Bridge…」。
782
- * 优先级:显式环境变量 > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
783
- * 为什么要有「端口文件」这一层:开机自启(登录任务 / launchd)拉起 watcher 时**没有**任何环境变量,
784
- * 只有插件半知道 dsh web 真实监听在哪个端口,所以它会把地址落盘(见插件 half 的 publishUpstream)。
783
+ * 优先级:显式环境变量 > **运行时动态发现** > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
784
+ *
785
+ * 为什么还要「动态发现」这一层(2026-09-23 第二起实测,DSH Desktop):
786
+ * DSH Desktop(Electron 壳 `dsh-plugin-desktop`)默认把 Web 服务放在 **43120**,被占用还会 +1;
787
+ * 而端口文件要等**插件半**加载后才有。于是「端口文件还没写」或「写的是上一轮的旧端口」时,
788
+ * 光靠文件仍会永久探错端口。发现逻辑(含 DSH 进程实际监听端口 + Desktop 端口区间 + 身份校验)
789
+ * 统一在 clients/dsh-remote/upstream-discovery.mjs,watcher 与 bridge 共用同一份实现。
785
790
  */
786
791
  const UPSTREAM_FILE = ".dsh-upstream";
792
+ /** 运行时动态发现到的上游(优于可能过期的端口文件;进程内缓存)。 */
793
+ let discoveredUpstream = "";
787
794
  function upstreamUrl() {
788
795
  const explicit = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim();
789
796
  if (explicit) return explicit.replace(/\/+$/, "");
797
+ if (discoveredUpstream) return discoveredUpstream;
790
798
  try {
791
799
  const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
792
800
  if (/^https?:\/\/[^\s]+$/i.test(fromFile)) return fromFile.replace(/\/+$/, "");
@@ -798,28 +806,69 @@ function upstreamUrl() {
798
806
  if (u.port) return `http://127.0.0.1:${u.port}`; // bridge 的 loopback 围栏只认 127.0.0.1
799
807
  } catch { /* 非 URL:忽略 */ }
800
808
  }
801
- return "http://127.0.0.1:3080";
809
+ return FALLBACK_UPSTREAM;
802
810
  }
803
811
  /** 上游端口(诊断/日志用;取不到就回退 3080)。 */
804
812
  function upstreamPort() {
805
813
  try { return Number(new URL(upstreamUrl()).port) || 3080; } catch { return 3080; }
806
814
  }
807
815
 
816
+ /** 发现结果缓存与冷却:避免每轮轮询都去 lsof/pgrep + 逐个端口探测。 */
817
+ let upstreamDiscoveryAt = 0;
818
+ let upstreamDiscoveryInflight = null;
819
+ const UPSTREAM_DISCOVERY_COOLDOWN_MS = 15_000;
820
+
808
821
  /**
809
- * dsh web 是否正在运行(默认 127.0.0.1:3080,端口以 upstreamUrl() 为准)。
810
- * bridge 本身依赖 dsh web 才工作:dsh web 没开时 bridge 起来也会立刻退出,
811
- * 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
822
+ * 动态发现上游(带身份校验),成功后写入 `discoveredUpstream` 供全局复用。
823
+ * @param {boolean} force 忽略冷却(探测失败后的重试用)
812
824
  */
813
- async function isDshWebUp(timeoutMs = 1200) {
825
+ async function refreshUpstreamDiscovery(force = false) {
826
+ if (upstreamDiscoveryInflight) return upstreamDiscoveryInflight;
827
+ const now = Date.now();
828
+ if (!force && now - upstreamDiscoveryAt < UPSTREAM_DISCOVERY_COOLDOWN_MS) return upstreamUrl();
829
+ upstreamDiscoveryAt = now;
830
+ upstreamDiscoveryInflight = (async () => {
831
+ try {
832
+ const found = await discoverUpstream({ relayDir: CONFIG_DIR, log: (m) => console.log(`[dsh-remote] ${m}`) });
833
+ // 「提示地址验证通过」与「候选命中」都值得记下来;fallback(什么都没找到)不污染现有判断
834
+ if (found.url && found.source !== "fallback") discoveredUpstream = found.url;
835
+ return upstreamUrl();
836
+ } catch {
837
+ return upstreamUrl();
838
+ } finally {
839
+ upstreamDiscoveryInflight = null;
840
+ }
841
+ })();
842
+ return upstreamDiscoveryInflight;
843
+ }
844
+
845
+ /** 上游是否**有响应**(判据刻意宽松:4xx/5xx 也算"端口在听",那种情况问题在别处)。 */
846
+ async function upstreamReachable(url = upstreamUrl(), timeoutMs = 1200) {
814
847
  try {
815
848
  const ac = new AbortController();
816
849
  const timer = setTimeout(() => ac.abort(), timeoutMs);
817
- await fetch(upstreamUrl() + "/", { signal: ac.signal });
850
+ await fetch(url + "/", { signal: ac.signal });
818
851
  clearTimeout(timer);
819
852
  return true;
820
853
  } catch { return false; }
821
854
  }
822
855
 
856
+ /**
857
+ * dsh web 是否正在运行(端口以 upstreamUrl() 为准,**绝不写死 3080**)。
858
+ * bridge 本身依赖 dsh web 才工作:dsh web 没开时 bridge 起来也会立刻退出,
859
+ * 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
860
+ *
861
+ * 探测失败时会**主动做一次动态发现**再试一次 —— 这一条正是 DSH Desktop 场景的关键:
862
+ * 上游在 43120(Desktop 默认,占用还会 +1),而端口文件可能还没写或写着上一轮的旧端口。
863
+ */
864
+ async function isDshWebUp(timeoutMs = 1200) {
865
+ if (await upstreamReachable(upstreamUrl(), timeoutMs)) return true;
866
+ const before = upstreamUrl();
867
+ const after = await refreshUpstreamDiscovery(true);
868
+ if (after === before) return false;
869
+ return upstreamReachable(after, timeoutMs);
870
+ }
871
+
823
872
  /**
824
873
  * 解析 dsh web profile 目录(安装与插件管理共用同一口径)。
825
874
  */
@@ -1125,14 +1174,13 @@ async function runBridge() {
1125
1174
  /** 限流提示只打一次,避免刷屏。 */
1126
1175
  let warnedRateLimit = false;
1127
1176
 
1128
- // watcher:检测 dsh web 是否存活(端口以 upstreamUrl() 为准,绝不写死 3080),存活才启动 bridge
1129
- const checkUpstream = () => new Promise((resolve) => {
1130
- const t = setTimeout(() => resolve(false), 2000);
1131
- fetch(upstreamUrl() + "/")
1132
- .then(() => { clearTimeout(t); resolve(true); })
1133
- .catch(() => { clearTimeout(t); resolve(false); });
1134
- });
1177
+ // watcher:检测 dsh web 是否存活(端口动态解析,绝不写死 3080),存活才启动 bridge。
1178
+ // 走 isDshWebUp():探测失败会自动做一次动态发现(DSH Desktop 的 43120 / 自定义端口都靠它)。
1179
+ const checkUpstream = () => isDshWebUp(2000);
1135
1180
 
1181
+ // 先做一次动态发现再打印/派生:否则日志会长期写着错误的端口(用户实测就卡在
1182
+ // 「等待 dsh web(127.0.0.1:3080)启动...」而真实上游在 43120)。
1183
+ await refreshUpstreamDiscovery(true);
1136
1184
  console.log(`[dsh-remote] 等待 dsh web(${upstreamUrl()})启动...`);
1137
1185
  let bridgeProc = null;
1138
1186
  let starting = false;
@@ -370,8 +370,23 @@ test("运行时 dsh-setup.mjs:上游端口只有一处真相源,watcher/子
370
370
  const code = SETUP_SRC.split("\n").filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join("\n");
371
371
  assert.ok(!/fetch\("http:\/\/127\.0\.0\.1:3080/.test(code), "探测不得写死 3080(dsh web 可能在别的端口)");
372
372
  assert.ok(!/findstr ":3080"|iTCP:3080/.test(code), "按端口找 dsh web 进程也不得写死 3080");
373
- assert.ok(/fetch\(upstreamUrl\(\) \+ "\/"\)/.test(code), "watcher 的在线判据必须用 upstreamUrl()");
373
+ assert.ok(/async function upstreamReachable\(url = upstreamUrl\(\)/.test(code), "在线判据必须用解析出来的上游地址");
374
+ assert.ok(/await upstreamReachable\(upstreamUrl\(\)/.test(code), "isDshWebUp 必须基于 upstreamUrl() 探测");
374
375
  assert.ok(/DSH_BRIDGE_UPSTREAM: upstreamUrl\(\)/.test(code), "必须把上游显式透传给 bridge 子进程");
376
+ // ── 上游端口动态发现(2026-09-23 第二起实测:DSH Desktop 默认 43120,占用还会 +1)──
377
+ // 只靠"端口文件"不够:文件可能还没写、或写着上一轮的旧端口;那时必须自己去找。
378
+ assert.ok(
379
+ /from "\.\/clients\/dsh-remote\/upstream-discovery\.mjs"/.test(SETUP_SRC),
380
+ "必须复用共享的上游发现实现(watcher/bridge 同一份,避免两边漂移)"
381
+ );
382
+ assert.ok(
383
+ /const after = await refreshUpstreamDiscovery\(true\)/.test(code),
384
+ "探测失败必须主动做一次动态发现再试(否则端口一变就永远卡在 starting)"
385
+ );
386
+ assert.ok(
387
+ /await refreshUpstreamDiscovery\(true\);/.test(code),
388
+ "启动时先发现再打印:日志不得再谎称固定端口"
389
+ );
375
390
  // 半装运行时的根因之一:本安装器自己 import 的 src/lifecycle.mjs 必须在同步表里
376
391
  assert.ok(/"src\/lifecycle\.mjs"/.test(SETUP_SRC), "RUNTIME_CLIENT_FILES 必须包含 src/lifecycle.mjs(否则会漏同步出半装运行时)");
377
392
  });
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 目录选择器固定为「浏览器内实现」的契约测试。
4
+ *
5
+ * 用户实测(Windows):镜像页点「添加工作区」**没反应**;同功能在 Mac 上正常。
6
+ * 根因:官方 `directory-picker-auto` 只在**绑定非回环**时才改选 browse,否则 darwin/win32 一律挂
7
+ * **原生**选择器 —— 那个对话框弹在**电脑那台机器的屏幕上**,拿手机的人看不到;
8
+ * Windows 那条原生链路还要 koffi 原生模块 + worker 子进程(合成 Alt 抢前台),失败点更多。
9
+ * 远程控制不能把"手机能不能选工作区"押在宿主的绑定地址上,所以插件主动把它钉成 browse。
10
+ *
11
+ * 这里守的是三条护栏(顺序错一条就会把用户的选择器搞没):
12
+ * ① 已是 browse → 一根手指都不许动;
13
+ * ② **先建 browse、后摘 auto**:browse 建不起来时必须原样保留原生;
14
+ * ③ 全程不抛异常(插件绝不能把 dsh web 拖挂)。
15
+ *
16
+ * 用法: node --test test/picker-pin.test.mjs
17
+ */
18
+ import assert from "node:assert/strict";
19
+ import { readFileSync } from "node:fs";
20
+ import path from "node:path";
21
+ import test from "node:test";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ import {
25
+ __pinBrowseDirectoryPicker,
26
+ __pickerPinState,
27
+ __PICKER_AUTO_ENTRY_ID,
28
+ __PICKER_BROWSE_PACKAGES
29
+ } from "../lib/index.js";
30
+
31
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
32
+ const SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
33
+
34
+ /** 假宿主:loader 记录 create/remove 的**调用顺序**,directoryPicker 报告当前 kind。 */
35
+ function fakeCtx({ kind = "native", failCreateOn = null } = {}) {
36
+ const calls = [];
37
+ const store = { [__PICKER_AUTO_ENTRY_ID]: { id: __PICKER_AUTO_ENTRY_ID } };
38
+ const loader = {
39
+ store,
40
+ async create({ name }) {
41
+ calls.push(`create:${name}`);
42
+ if (failCreateOn && name === failCreateOn) throw new Error("browse 装不起来");
43
+ const id = `dyn-${calls.length}`;
44
+ store[id] = { id };
45
+ return id;
46
+ },
47
+ async remove(id) {
48
+ calls.push(`remove:${id}`);
49
+ delete store[id];
50
+ }
51
+ };
52
+ return {
53
+ ctx: {
54
+ get(name) {
55
+ if (name === "loader") return loader;
56
+ if (name === "directoryPicker") return { capability: () => ({ kind }) };
57
+ return null;
58
+ },
59
+ logger: { info() {}, warn() {} }
60
+ },
61
+ calls,
62
+ store
63
+ };
64
+ }
65
+
66
+ const withEnv = async (value, fn) => {
67
+ const prev = process.env.DSH_REMOTE_NATIVE_PICKER;
68
+ if (value === undefined) delete process.env.DSH_REMOTE_NATIVE_PICKER;
69
+ else process.env.DSH_REMOTE_NATIVE_PICKER = value;
70
+ try { return await fn(); } finally {
71
+ if (prev === undefined) delete process.env.DSH_REMOTE_NATIVE_PICKER;
72
+ else process.env.DSH_REMOTE_NATIVE_PICKER = prev;
73
+ }
74
+ };
75
+
76
+ test("★ 原生(native)→ 换成 browse:先建 browse 那一对,再摘 auto 条目", async () => {
77
+ const { ctx, calls, store } = fakeCtx({ kind: "native" });
78
+ const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
79
+
80
+ assert.deepEqual(calls, [
81
+ `create:${__PICKER_BROWSE_PACKAGES[0]}`,
82
+ `create:${__PICKER_BROWSE_PACKAGES[1]}`,
83
+ `remove:${__PICKER_AUTO_ENTRY_ID}`
84
+ ], "顺序必须是「先建 browse、后摘 auto」——反过来一旦建失败,用户就没有选择器了");
85
+ assert.equal(store[__PICKER_AUTO_ENTRY_ID], undefined, "auto 条目应被摘除(它的析构会卸掉 native 那对)");
86
+ assert.equal(state.action, "pinned");
87
+ assert.equal(state.kind, "browse");
88
+ assert.match(state.detail, /远程/, "详情要写明为什么改(远程看不到原生对话框)");
89
+ });
90
+
91
+ test("已经是 browse → 完全不干预(自建部署 / 绑了 0.0.0.0 的场景)", async () => {
92
+ const { ctx, calls } = fakeCtx({ kind: "browse" });
93
+ const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
94
+ assert.deepEqual(calls, [], "不得创建、也不得摘除任何条目");
95
+ assert.equal(state.action, "none");
96
+ });
97
+
98
+ test("★ browse 装不起来 → 原样保留原生(宁可原生可用,也不能让选择器消失)", async () => {
99
+ const { ctx, calls, store } = fakeCtx({ kind: "native", failCreateOn: __PICKER_BROWSE_PACKAGES[0] });
100
+ const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
101
+ assert.ok(!calls.some((c) => c.startsWith(`remove:${__PICKER_AUTO_ENTRY_ID}`)), "摘 auto 必须发生在 browse 建成之后");
102
+ assert.ok(store[__PICKER_AUTO_ENTRY_ID], "auto 条目必须还在");
103
+ assert.equal(state.action, "kept_native");
104
+ assert.match(state.detail, /保留原生/);
105
+ });
106
+
107
+ test("第二个 browse 包失败 → 回滚已建的那个,并保留原生", async () => {
108
+ const { ctx, calls, store } = fakeCtx({ kind: "native", failCreateOn: __PICKER_BROWSE_PACKAGES[1] });
109
+ const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
110
+ assert.equal(calls[0], `create:${__PICKER_BROWSE_PACKAGES[0]}`);
111
+ assert.equal(calls[1], `create:${__PICKER_BROWSE_PACKAGES[1]}`);
112
+ assert.ok(calls[2] && calls[2].startsWith("remove:"), "失败后必须回滚刚建的宿主");
113
+ assert.ok(store[__PICKER_AUTO_ENTRY_ID], "auto 条目必须还在");
114
+ assert.equal(state.action, "kept_native");
115
+ });
116
+
117
+ test("DSH_REMOTE_NATIVE_PICKER=1 → 整体退出,保留系统原生对话框", async () => {
118
+ const { ctx, calls } = fakeCtx({ kind: "native" });
119
+ const state = await withEnv("1", () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
120
+ assert.deepEqual(calls, []);
121
+ assert.equal(state.action, "skipped");
122
+ assert.match(state.detail, /DSH_REMOTE_NATIVE_PICKER/);
123
+ });
124
+
125
+ test("服务还没挂上(kind 未知)→ 不抛错、按 skipped 收尾", async () => {
126
+ const { ctx, calls } = fakeCtx({ kind: "" });
127
+ const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
128
+ assert.deepEqual(calls, []);
129
+ assert.equal(state.action, "skipped");
130
+ });
131
+
132
+ test("没有 loader 服务 / ctx.get 抛错 → 静默跳过(绝不把宿主拖挂)", async () => {
133
+ const noLoader = { get: () => null, logger: { info() {}, warn() {} } };
134
+ const throwing = { get() { throw new Error("ctx destroyed"); }, logger: {} };
135
+ const a = await __pinBrowseDirectoryPicker(noLoader, { attempts: 1, gapMs: 1 });
136
+ const b = await __pinBrowseDirectoryPicker(throwing, { attempts: 1, gapMs: 1 });
137
+ assert.equal(a.action, "skipped");
138
+ assert.equal(b.action, "skipped");
139
+ });
140
+
141
+ test("诊断面板必须显示目录选择器状态(这类'按钮没反应'才能一眼定位)", () => {
142
+ assert.match(SRC, /`目录选择器: \$\{/, "诊断行缺失");
143
+ assert.match(SRC, /系统原生(远程时对话框弹在电脑那台机器上/, "原生时要给出可操作的解释");
144
+ });
145
+
146
+ test("接线:apply 里挂了选择器固定(eff/effect),并且是 fire-and-forget 不阻塞装载", () => {
147
+ assert.match(SRC, /ctx\.effect\(\(\) => \{[\s\S]{0,200}?pinBrowseDirectoryPicker\(ctx\)[\s\S]{0,120}?\}, "dsh-remote-web: pin browser directory picker"\);/);
148
+ });
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * watcher 的上游端口发现回归 —— 复现 2026-09-23 两起真实事故的**用户可见形态**。
4
+ *
5
+ * 现场(用户报告,DSH Desktop 2.0.4 + 插件 0.6.10):
6
+ * ```
7
+ * 连接阶段: starting(正在启动 Bridge…)
8
+ * bridge 进程: 未运行 中继注册: 未注册 自动重试: 已尝试 6 次
9
+ * ```
10
+ * 面板永久停在 starting,设备从未登记。根因:**watcher 把上游写死 3080**,
11
+ * 而 DSH Desktop(Electron 壳 `dsh-plugin-desktop`)把 Web 服务放在 **43120**(占用则 +1)、
12
+ * 独立用户也可能 `dsh web --port 8090` / `--port 0`。探不到 → `checkUpstream()` 恒 false
13
+ * → **bridge 永远不被派生**,而面板只会说「正在启动 Bridge…」。
14
+ *
15
+ * 本用例锁的是**决策点**(不是实现细节):只要上游在**非默认端口**且应答像 dsh web,
16
+ * watcher 就必须走进「dsh web 在线,启动 bridge...」并带着**正确的**
17
+ * `DSH_BRIDGE_UPSTREAM` 去派生 bridge。
18
+ *
19
+ * 隔离手法与 runtime-provision / connect-loop 一致:临时 HOME + 假 launchctl/pgrep/ps/npx,
20
+ * 绝不用到本机真实服务;上游是**测试进程内**起的假 dsh web(真 dsh web 只在 3080,
21
+ * 那正是我们要避开的默认值)。
22
+ */
23
+ import assert from "node:assert/strict";
24
+ import { spawn } from "node:child_process";
25
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
26
+ import http from "node:http";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import test from "node:test";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
33
+ const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
34
+
35
+ /** 假 dsh web 的应答:带官方特征(身份校验靠它),并记录被访问过。 */
36
+ const DSH_HTML = "<!doctype html><html><head><title>DeepSeek Harness</title></head>"
37
+ + "<body><script>__ModuleLoader__</script></body></html>";
38
+
39
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
40
+
41
+ /** 起一个假的上游(= 非默认端口上的 dsh web)。 */
42
+ async function startFakeDshWeb() {
43
+ const hits = [];
44
+ const srv = http.createServer((req, res) => {
45
+ hits.push(req.url);
46
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
47
+ res.end(DSH_HTML);
48
+ });
49
+ await new Promise((r) => srv.listen(0, "127.0.0.1", r));
50
+ return { srv, port: srv.address().port, hits, url: `http://127.0.0.1:${srv.address().port}` };
51
+ }
52
+
53
+ /** 临时 relayDir + 一份"已登录"的配置(指向本地死端口,绝不外联)。 */
54
+ function makeEnv() {
55
+ const root = mkdtempSync(path.join(os.tmpdir(), "dsh-upstream-port-"));
56
+ const home = path.join(root, "home");
57
+ const relayDir = path.join(root, "relay");
58
+ const bin = path.join(root, "bin");
59
+ for (const d of [home, relayDir, bin]) mkdirSync(d, { recursive: true });
60
+
61
+ // 假命令:让 watcher/插件的系统操作全部落空,绝不碰本机真实服务
62
+ const stub = (name, body) => {
63
+ const p = path.join(bin, name);
64
+ writeFileSync(p, `#!/bin/sh\n${body}\n`);
65
+ chmodSync(p, 0o755);
66
+ };
67
+ stub("launchctl", 'case "$1" in list) exit 0 ;; print) echo "could not find service" >&2; exit 113 ;; esac\nexit 0');
68
+ stub("pgrep", "exit 1");
69
+ stub("ps", 'case "$*" in *ppid*) echo 1 ;; *lstart*) echo "Mon Jan 1 00:00:00 2026" ;; esac\nexit 0');
70
+ stub("npx", "exit 127");
71
+
72
+ writeFileSync(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
73
+ phone: "13800000000",
74
+ password: "pw",
75
+ device_id: "dev-aaaaaaaaaaaa",
76
+ tunnel_url: "ws://127.0.0.1:1",
77
+ api_url: "http://127.0.0.1:1"
78
+ }, null, 2), { mode: 0o600 });
79
+
80
+ return { root, home, relayDir, bin };
81
+ }
82
+
83
+ /** 起 watcher,返回 {proc, out(), stop()}。 */
84
+ function startWatcher(env, extraEnv = {}) {
85
+ let out = "";
86
+ const proc = spawn(process.execPath, [SETUP, "run"], {
87
+ cwd: env.root,
88
+ env: {
89
+ ...process.env,
90
+ HOME: env.home,
91
+ DSH_RELAY_DIR: env.relayDir,
92
+ DSH_BRIDGE_CONFIG: path.join(env.relayDir, ".dsh-config.json"),
93
+ PATH: `${env.bin}:${process.env.PATH}`,
94
+ DSH_REMOTE_TELEMETRY: "0",
95
+ ...extraEnv
96
+ },
97
+ stdio: ["ignore", "pipe", "pipe"]
98
+ });
99
+ proc.stdout.on("data", (d) => { out += String(d); });
100
+ proc.stderr.on("data", (d) => { out += String(d); });
101
+ return {
102
+ proc,
103
+ out: () => out,
104
+ async stop() {
105
+ try { proc.kill("SIGKILL"); } catch { /* ignore */ }
106
+ await sleep(120);
107
+ }
108
+ };
109
+ }
110
+
111
+ const waitFor = async (fn, timeoutMs = 12_000) => {
112
+ const t0 = Date.now();
113
+ while (Date.now() - t0 < timeoutMs) {
114
+ if (fn()) return true;
115
+ await sleep(150);
116
+ }
117
+ return false;
118
+ };
119
+
120
+ test("★ 上游在非默认端口(DSH Desktop 场景):watcher 必须发现它并派生 bridge", async (t) => {
121
+ const env = makeEnv();
122
+ const upstream = await startFakeDshWeb();
123
+ let watcher = null;
124
+ try {
125
+ // 真实的 Desktop 情形:端口文件要么还没写、要么写着上一轮的旧端口
126
+ writeFileSync(path.join(env.relayDir, ".dsh-upstream"), "http://127.0.0.1:1\n", { mode: 0o600 });
127
+ // DSH_WEB_URL 是宿主真实端口的合法来源之一(Desktop 包就是用它把端口暴露给子进程的)
128
+ watcher = startWatcher(env, { DSH_WEB_URL: upstream.url });
129
+
130
+ const started = await waitFor(() => /dsh web 在线,启动 bridge/.test(watcher.out()));
131
+ assert.ok(
132
+ started,
133
+ `上游在 ${upstream.port}(非 3080)时 watcher 依然要启动 bridge。实际输出:\n${watcher.out().slice(0, 1200)}`
134
+ );
135
+ assert.ok(upstream.hits.length > 0, "watcher 必须真的探测了那个非默认端口");
136
+ assert.ok(
137
+ !/等待 dsh web(http:\/\/127\.0\.0\.1:3080)/.test(watcher.out()),
138
+ "日志不得再宣称自己在等 3080(用户实测就是被这句误导)"
139
+ );
140
+ // ★ 关键:派生出去的 bridge 必须拿到**被发现的那个端口**(否则它会自己去探 3080 并永久失败)
141
+ const gotBridgeUpstream = await waitFor(() => new RegExp(`上游地址已确定: http://127\\.0\\.0\\.1:${upstream.port}`).test(watcher.out()), 6000);
142
+ assert.ok(
143
+ gotBridgeUpstream,
144
+ `bridge 应被注入发现到的上游 ${upstream.url}。实际输出:\n${watcher.out().slice(0, 1200)}`
145
+ );
146
+ } finally {
147
+ if (watcher) await watcher.stop();
148
+ await new Promise((r) => upstream.srv.close(r));
149
+ rmSync(env.root, { recursive: true, force: true });
150
+ }
151
+ });
152
+
153
+ test("上游确实不在时:watcher 保持等待(不谎报在线,也不派生 bridge)", async (t) => {
154
+ const env = makeEnv();
155
+ // ⚠️ 必须用**显式** DSH_BRIDGE_UPSTREAM 钉死死端口:不加它时动态发现会去探 3080,
156
+ // 而开发机/CI 上真的可能有一个 dsh web 在 3080 —— 那样这条断言就变成看环境了。
157
+ // 显式值优先且不做探测(既有语义),于是"上游不可达"这件事在测试里是确定的。
158
+ let watcher = null;
159
+ try {
160
+ writeFileSync(path.join(env.relayDir, ".dsh-upstream"), "http://127.0.0.1:1\n", { mode: 0o600 });
161
+ watcher = startWatcher(env, { DSH_BRIDGE_UPSTREAM: "http://127.0.0.1:1" });
162
+ await sleep(3500);
163
+ const out = watcher.out();
164
+ assert.ok(!/dsh web 在线,启动 bridge/.test(out), `不得谎报在线:\n${out.slice(0, 600)}`);
165
+ assert.match(out, /等待 dsh web/, "应如实说明在等 dsh web");
166
+ } finally {
167
+ if (watcher) await watcher.stop();
168
+ rmSync(env.root, { recursive: true, force: true });
169
+ }
170
+ });