@mrrisega/dsh-remote 0.7.11-beta.3 → 0.7.12-beta.1

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.
@@ -210,6 +210,38 @@ async function discoverUpstreamSelf(relayDir, { force = false } = {}) {
210
210
  return upstreamDiscoveryInFlight;
211
211
  }
212
212
 
213
+ /**
214
+ * 上游地址是否已经"够用"(不需要在**响应路径里现扫**)。
215
+ * 【B1 · 2026-11-07】登录/首屏慢的主因之一就是在响应路径里 await 端口发现。
216
+ * 只要下面任一成立就够用:显式 env / 已知监听端口 / TTL 内的发现结果 / 测试隔离 / 平台模拟。
217
+ */
218
+ function upstreamDiscoverySettled() {
219
+ // 测试钩子**最优先**(true=强制"未 settled",false=强制"已 settled";null=不干预):
220
+ // DSH 宿主里跑测试时环境常带 DSH_WEB_URL(会被 boundWebPort 认成"已知端口"),
221
+ // 不放在最前面就没法模拟"端口未知、需要发现"的现场。
222
+ if (upstreamForceUnsettled === true) return false;
223
+ if (upstreamForceUnsettled === false) return true;
224
+ if (String(process.env.DSH_BRIDGE_UPSTREAM || "").trim()) return true;
225
+ if (boundWebPort()) return true;
226
+ if (discoveredUpstream.url && Date.now() - discoveredUpstream.at < UPSTREAM_DISCOVERY_TTL_MS) return true;
227
+ if (skipsSystemOps()) return true;
228
+ if (osPlatform() !== process.platform) return true;
229
+ return false;
230
+ }
231
+
232
+ /** 测试钩子:替换发现实现 / 强制"未 settled"(只给用例用,生产永远走真实实现)。 */
233
+ let discoverImpl = discoverUpstreamSelf;
234
+ let upstreamForceUnsettled = null;
235
+ function setUpstreamTestState(opts = {}) {
236
+ if (typeof opts.discover === "function") discoverImpl = opts.discover;
237
+ if (typeof opts.unsettled === "boolean") upstreamForceUnsettled = opts.unsettled;
238
+ return { discover: discoverImpl, unsettled: upstreamForceUnsettled };
239
+ }
240
+ function resetUpstreamTestState() {
241
+ discoverImpl = discoverUpstreamSelf;
242
+ upstreamForceUnsettled = null;
243
+ }
244
+
213
245
  /**
214
246
  * 把上游地址落盘(`<relayDir>/.dsh-upstream`),供**没有环境变量**的那些守护读取:
215
247
  * 开机自启的 watcher(Windows 登录任务 / launchd)由系统拉起,拿不到本进程的 env,
@@ -1461,7 +1493,7 @@ function writeLinuxUnit(relayDir) {
1461
1493
  if (!unitPath) return null;
1462
1494
  const setup = join(relayDir, "dsh-setup.mjs");
1463
1495
  const unit = `[Unit]
1464
- Description=dsh-remote bridge (auto-starts with dsh web)
1496
+ Description=dsh-remote-web bridge (auto-starts with dsh web)
1465
1497
 
1466
1498
  [Service]
1467
1499
  ExecStart=${NODE_BIN} ${setup} run
@@ -1648,6 +1680,31 @@ function launchTarget() {
1648
1680
  */
1649
1681
  const NO_LAUNCHD = { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
1650
1682
 
1683
+ // ── B1:shell 探测短 TTL 缓存(2026-11-07 登录慢优化)─────────────────────────────
1684
+ // launchctl / ps / systemctl 每次调用都是一次进程 spawn(实测 30~120ms,慢机器更久),
1685
+ // 而状态轮询 2.5s 一次会反复重跑同一份"没变"的事实。这里给 10s TTL:
1686
+ // · 结论按 key 缓存;**变更动作(启动/停止/卸载/账号切换)会主动清缓存**,不会让面板显示过期状态。
1687
+ const STATUS_PROBE_TTL_MS = 10 * 1000;
1688
+ const statusProbeCache = new Map();
1689
+ function memoStatusProbe(key, fn) {
1690
+ // 测试/诊断隔离下**不缓存**:这类环境里 shell 探测是假命令/假结果(skipsSystemOps 或 DSH_TEST_*
1691
+ // 注入的 fake PATH),缓存只会让用例互相污染(一个用例伪造的 pid/服务状态被下一个用例读到 → 假红)。
1692
+ // 生产路径(真实 launchctl/ps)才走 10s TTL。
1693
+ if (skipsSystemOps()) return fn();
1694
+ try {
1695
+ if (Object.keys(process.env).some((k) => k.startsWith("DSH_TEST_"))) return fn();
1696
+ } catch { /* 忽略 */ }
1697
+ const now = Date.now();
1698
+ const hit = statusProbeCache.get(key);
1699
+ if (hit && now - hit.at < STATUS_PROBE_TTL_MS) return hit.value;
1700
+ const value = fn();
1701
+ statusProbeCache.set(key, { at: now, value });
1702
+ return value;
1703
+ }
1704
+ function clearStatusProbeCache() {
1705
+ try { statusProbeCache.clear(); } catch { /* 忽略 */ }
1706
+ }
1707
+
1651
1708
  function launchdStatus() {
1652
1709
  // 非 macOS 没有 launchctl(Windows 上连 process.getuid 都没有):直接返回「无此服务」,
1653
1710
  // 不再去 shell 里调一个不存在的命令——这既避免 500,也避免每次轮询白起一个 cmd.exe。
@@ -2655,6 +2712,7 @@ function stopBridgeDetached(relayDir, opts = {}) {
2655
2712
 
2656
2713
  /** 启动 bridge:确保运行环境就绪 + plist 存在 → launchctl bootstrap(回退 load -w)。 */
2657
2714
  function startBridge(relayDir) {
2715
+ clearStatusProbeCache(); // B1:状态变更 → 立刻作废 shell 探测缓存,避免显示过期状态
2658
2716
  if (UNINSTALLED_DIRS.has(relayDir)) {
2659
2717
  // 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
2660
2718
  return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
@@ -2872,6 +2930,7 @@ const lastSelfhealReadyLine = new Map();
2872
2930
 
2873
2931
  /** 停止 bridge:macOS 走 launchctl bootout;Linux 走 systemd/脱离进程;Windows 结束 watcher/bridge 进程。 */
2874
2932
  function stopBridge(relayDir) {
2933
+ clearStatusProbeCache(); // B1:状态变更 → 立刻作废 shell 探测缓存,避免显示过期状态
2875
2934
  if (isWindows()) return stopBridgeDetached(relayDir);
2876
2935
  // 测试隔离:置位时绝不对真实 launchd/systemd 下手(切换账号也会走到这里,
2877
2936
  // 少了这一条,用例就会去 bootout 开发者本机上真实运行的 bridge —— 实测过的污染风险)。
@@ -4008,8 +4067,9 @@ function maskPhone(p) {
4008
4067
 
4009
4068
  async function composeStatus(relayDir) {
4010
4069
  const cfg = loadConfig(relayDir);
4011
- const launchd = launchdStatus();
4012
- const manual = manualStatus(relayDir);
4070
+ // B1:同样的 10s 缓存(状态轮询时不重复 spawn launchctl/ps)。
4071
+ const launchd = memoStatusProbe("launchd", launchdStatus);
4072
+ const manual = memoStatusProbe("manual:" + relayDir, () => manualStatus(relayDir));
4013
4073
  // 注册/绑定失败提示(bridge 写 .bind-error.json;面板据此展示“已达上限/需解绑”引导)
4014
4074
  let bindError = null;
4015
4075
  try {
@@ -5704,12 +5764,18 @@ async function accountDeviceBound(relayDir, cfg) {
5704
5764
  */
5705
5765
  async function composeConnect(relayDir, opts = {}) {
5706
5766
  const cfg = opts.cfg || loadConfig(relayDir);
5707
- // 【0.6.15】上游端口问不出来时,先主动发现一次再算阶段:否则诊断里永远是"3080 不可达",
5708
- // 而用户的 dsh web 其实好好地跑在 2298(飞牛/NAS 实测)—— 面板与用户都会往"改端口"这个
5709
- // 错误方向使劲。发现很快(本机回环,全失败也就几百毫秒),失败不影响任何既有行为。
5710
- await discoverUpstreamSelf(relayDir).catch(() => FALLBACK_UPSTREAM);
5711
- const launchd = opts.launchd || launchdStatus();
5712
- const manual = opts.manual || manualStatus(relayDir);
5767
+ // 【B1 · 2026-11-07 登录慢优化】端口发现**绝不再阻塞状态/登录响应**:
5768
+ // · 已有可用结论(显式 env / 已知监听端口 / TTL 内的发现结果 / 测试隔离)→ 直接用;
5769
+ // · 否则**放后台**跑(void),本次先用 hint/回退地址把状态返回 —— 首屏不再等信息;
5770
+ // 发现完成后的结果会在下一次状态轮询自然带上(面板每 2.5~30s 轮询,无需推送)。
5771
+ // 旧实现的 await 会把 lsof/netstat + 数百端口探测整个塞进首屏路径(业主实测 7~8 秒)。
5772
+ const discoverySettled = upstreamDiscoverySettled();
5773
+ if (!discoverySettled) {
5774
+ void discoverImpl(relayDir).catch(() => FALLBACK_UPSTREAM);
5775
+ }
5776
+ // B1:这两次 shell 探测走 10s TTL 缓存(见 memoStatusProbe);变更动作会清缓存。
5777
+ const launchd = opts.launchd || memoStatusProbe("launchd", launchdStatus);
5778
+ const manual = opts.manual || memoStatusProbe("manual:" + relayDir, () => manualStatus(relayDir));
5713
5779
  const book = connectBook(relayDir);
5714
5780
  const now = Date.now();
5715
5781
  const hasAcct = hasAccountCreds(cfg);
@@ -5958,6 +6024,8 @@ async function composeConnect(relayDir, opts = {}) {
5958
6024
  selfhealExhausted: selfhealGuardOf(relayDir).count >= SELFHEAL_MAX_RESTARTS,
5959
6025
  upstreamUrl: upstreamUrl(),
5960
6026
  upstreamSource: upstreamSourceLabel(),
6027
+ // B1:这次响应是否"端口发现还没完成、已放后台跑"(只作诊断/文案;下一次轮询会自然收敛)
6028
+ discoveryPending: Boolean(!discoverySettled),
5961
6029
  // 宿主形态:桌面端(Electron)与 Web 版(node)在若干动作上语义不同 ——
5962
6030
  // 最典型的是「重启 DeepSeek harness」在桌面端做不到也不该做(Host 由 Electron 托管,
5963
6031
  // 强行退出会弹原生致命错误)。面板据此改文案/去掉无意义的按钮,而不是等用户点了才失败。
@@ -7627,6 +7695,24 @@ function wecomStatusOf(status) {
7627
7695
  boundSessionId: str(w.boundSessionId),
7628
7696
  // 「上次预览卡片」已处理到的时间戳(桥写;面板只用来显示「上次预览:HH:MM」)
7629
7697
  lastPreviewCardAt: num(w.lastPreviewCardAt),
7698
+ // ── 会员档位 / 额度(2026-11-07 桥侧新增,字段名锁死;只白名单,不整包吐)──
7699
+ // tier 只认三个枚举值;拿不到就是空串(面板据此**不渲染**档位条,绝不猜)。
7700
+ tier: (() => {
7701
+ const t = str(w.tier);
7702
+ return (t === "free" || t === "pro" || t === "pro_max") ? t : "";
7703
+ })(),
7704
+ // caps 是字符串数组:逐个转字符串 + 截断 + 上限 50 条(桥以后往里塞对象也别透出去)
7705
+ caps: (Array.isArray(w.caps) ? w.caps : []).slice(0, 50).map((c) => str(c).slice(0, 80)).filter(Boolean),
7706
+ capsCount: num(w.capsCount),
7707
+ bindingGated: !!w.bindingGated,
7708
+ messagesPerMonth: num(w.messagesPerMonth),
7709
+ // null = 不限或未知(**必须和 0 区分**:0 条剩余 ≠ 不限量)
7710
+ remainingMessages: typeof w.remainingMessages === "number" && Number.isFinite(w.remainingMessages) ? w.remainingMessages : null,
7711
+ usage: (() => {
7712
+ const u = w.usage && typeof w.usage === "object" ? w.usage : null;
7713
+ if (!u) return null;
7714
+ return { month: str(u.month), messages: num(u.messages) };
7715
+ })(),
7630
7716
  sessions: (Array.isArray(w.sessions) ? w.sessions : []).slice(0, 50).map(function (it) {
7631
7717
  const o = it && typeof it === "object" ? it : {};
7632
7718
  return {
@@ -8290,7 +8376,26 @@ function registerRoutes(ctx, relayDir, opts = {}) {
8290
8376
  const bridgeRestart = startBridge(relayDir);
8291
8377
  // 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」约定);失败静默。
8292
8378
  maybeReportInstall(relayDir, { force: true });
8293
- sendJson(res, 200, { ok: true, bridgeRestart, ...(await composeStatus(relayDir)) });
8379
+ // 【B2 · 2026-11-07】登录响应**立即返回**:旧实现 `...(await composeStatus())` 会把
8380
+ // 端口发现 / launchctl+ps / 中继探测全塞进登录路径(业主实测 7~8 秒)。
8381
+ // 这里只回"保存成功 + bridge 重启结果 + 最小配置回显",衍生信息由面板登录后异步拉
8382
+ // (GET /dsh-remote/status & /bridge-status),UI 已经先切到已登录态。
8383
+ const cfgEcho = loadConfig(relayDir);
8384
+ sendJson(res, 200, {
8385
+ ok: true,
8386
+ bridgeRestart,
8387
+ statusPending: true,
8388
+ config: {
8389
+ phone: maskPhone(cfgEcho.phone || ""),
8390
+ hasPhone: Boolean(cfgEcho.phone),
8391
+ hasPassword: Boolean(cfgEcho.password),
8392
+ deviceId: cfgEcho.device_id || "",
8393
+ apiUrl: cfgEcho.api_url || DEFAULT_API,
8394
+ mode: cfgEcho.local_key ? "local" : "saas",
8395
+ selfHostUrl: cfgEcho.tunnel_url ? String(cfgEcho.tunnel_url).replace(/^wss?:\/\//, "").replace(/\/+$/, "") : "",
8396
+ hasLocalKey: Boolean(cfgEcho.local_key)
8397
+ }
8398
+ });
8294
8399
  },
8295
8400
  },
8296
8401
  {
@@ -9025,6 +9130,11 @@ export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerP
9025
9130
  setupStateOf as __setupStateOf, ensureConnection as __ensureConnection,
9026
9131
  // 设备超额锁定(契约 §8):面板文案 / 插件半设备绑定退避都要能被单测钉住
9027
9132
  composeConnect as __composeConnect, readBindError as __readBindError, bindFailGate as __bindFailGate,
9133
+ // B1(2026-11-07 登录慢):端口发现是否阻塞、shell 探测 TTL 缓存、状态组装都要能被单测钉住
9134
+ composeStatus as __composeStatus, launchdStatus as __launchdStatus,
9135
+ clearStatusProbeCache as __clearStatusProbeCache, upstreamDiscoverySettled as __upstreamDiscoverySettled,
9136
+ setUpstreamTestState as __setUpstreamTestState, resetUpstreamTestState as __resetUpstreamTestState,
9137
+ STATUS_PROBE_TTL_MS as __STATUS_PROBE_TTL_MS,
9028
9138
  bindFailSignatureOf as __bindFailSignatureOf, isPermanentBindFailure as __isPermanentBindFailure,
9029
9139
  bindCapBackoffMs as __bindCapBackoffMs, connectBook as __connectBook, noteBindFailAttempt as __noteBindFailAttempt,
9030
9140
  DEVICE_BIND_CAP_EXIT_CODE as __DEVICE_BIND_CAP_EXIT_CODE, BIND_CAP_BACKOFF_MAX_MS as __BIND_CAP_BACKOFF_MAX_MS,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.11-beta.3",
3
+ "version": "0.7.12-beta.1",
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",
@@ -2963,7 +2963,8 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
2963
2963
  * 这里只认 dsh 自己那条解析路径能不能走通:
2964
2964
  * ① 读得到 package.json 且 name 正确(cordis 加载器 + @deepseek-ai/dsh-client-modules 靠它读 dsh.client);
2965
2965
  * ② 读得到 lib/index.js(节点半入口);
2966
- * ③ 要么 realpath 指回本地拷贝目录(链接形态),要么入口内容与本地拷贝逐字节一致(整目录拷贝形态)。
2966
+ * ③ 要么 realpath 指回本地拷贝目录(链接形态),要么**整目录拷贝**且
2967
+ * `lib/index.js` 与 `package.json` 都与本地拷贝逐字节一致(拷贝形态)。
2967
2968
  */
2968
2969
  function pluginLinkUsable(nmPlugin, pluginLocalDir) {
2969
2970
  try {
@@ -2977,9 +2978,23 @@ function pluginLinkUsable(nmPlugin, pluginLocalDir) {
2977
2978
  if (real) {
2978
2979
  try { if (real === fs.realpathSync(pluginLocalDir)) return true; } catch { /* 本地目录不在 → 看内容 */ }
2979
2980
  }
2981
+ /**
2982
+ * ★ 2026-11-07(桌面端实测):拷贝形态**必须连 package.json 一起比对**,只看入口会漏掉旧版残留。
2983
+ *
2984
+ * 桌面 profile 用 pnpm `nodeLinker: hoisted`,`file:` 依赖会被**复制**进 profile 的
2985
+ * `node_modules/<id>`(不是链接)。npx 安装器随后只替换了 `dsh-remote-web-plugin/`
2986
+ * (file: 的源目录)—— 那份复制品还是上一版。而版本号只写在 package.json 里,
2987
+ * 相邻 beta 之间的 `lib/index.js` 往往逐字节相同 ⇒ 旧判据判「已就绪」,
2988
+ * 桌面端继续 import 旧的 package.json:面板版本号是旧的、`/self/update-check` 一直
2989
+ * `outdated:true`,用户反复 npx 都"装不上新版"(实测 beta.3 → beta.10 复现)。
2990
+ * 拷贝形态本就该与源目录完全一致,逐字节比对 package.json 即可识别,代价是一次 re-link。
2991
+ */
2980
2992
  try {
2981
- return fs.readFileSync(path.join(nmPlugin, "lib", "index.js"), "utf8")
2993
+ const sameEntry = fs.readFileSync(path.join(nmPlugin, "lib", "index.js"), "utf8")
2982
2994
  === fs.readFileSync(path.join(pluginLocalDir, "lib", "index.js"), "utf8");
2995
+ if (!sameEntry) return false;
2996
+ return fs.readFileSync(path.join(nmPlugin, "package.json"))
2997
+ .equals(fs.readFileSync(path.join(pluginLocalDir, "package.json")));
2983
2998
  } catch { return false; }
2984
2999
  }
2985
3000
 
@@ -357,3 +357,44 @@ test("PUT previewCardAt:非法值(非数字 / 0 / 负数)→ 400 invalid_p
357
357
  assert.equal(cfg.wecom.previewCardAt, 0, "非法触发值绝不能写进文件(桥会永久失效且难排查)");
358
358
  });
359
359
  });
360
+
361
+ // ─────────────────── 会员档位 / 额度(2026-11-07 桥侧判定) ───────────────────
362
+
363
+ test("GET:白名单透出 tier/caps/capsCount/bindingGated/messagesPerMonth/remainingMessages/usage", async () => {
364
+ const relayDir = await tmpRelay();
365
+ await withHost(relayDir, {}, async (base) => {
366
+ await writeStatus(relayDir, {
367
+ enabled: true, connected: true, tier: "free",
368
+ caps: ["wecom", "session_binding"], capsCount: 2, bindingGated: true,
369
+ messagesPerMonth: 20, remainingMessages: 7,
370
+ usage: { month: "2026-11", messages: 13 },
371
+ // 攻击面:状态文件里的内部字段/额外字段绝不能被整包吐出去
372
+ secret: "LEAK", capsRaw: { internal: "LEAK2" }, extra: "LEAK3",
373
+ });
374
+ const r = await json(`${base}/dsh-remote/im-bots`);
375
+ const st = r.body.status;
376
+ assert.equal(st.tier, "free");
377
+ assert.deepEqual(st.caps, ["wecom", "session_binding"], "caps 只取字符串数组(截断/过滤),不得透出对象");
378
+ assert.equal(st.capsCount, 2);
379
+ assert.equal(st.bindingGated, true);
380
+ assert.equal(st.messagesPerMonth, 20);
381
+ assert.equal(st.remainingMessages, 7);
382
+ assert.deepEqual(st.usage, { month: "2026-11", messages: 13 });
383
+ assert.ok(!JSON.stringify(r.body).includes("LEAK"), "★ 白名单之外的状态字段绝不能被透出");
384
+ });
385
+ });
386
+
387
+ test("GET:tier 只认三个枚举值;remainingMessages=null 表示不限/未知(不能被当成 0)", async () => {
388
+ const relayDir = await tmpRelay();
389
+ await withHost(relayDir, {}, async (base) => {
390
+ await writeStatus(relayDir, { tier: "gold", remainingMessages: null, messagesPerMonth: 0 });
391
+ let st = (await json(`${base}/dsh-remote/im-bots`)).body.status;
392
+ assert.equal(st.tier, "", "未知档位必须归一成空串(面板据此不渲染,绝不猜)");
393
+ assert.equal(st.remainingMessages, null, "null 必须保持 null(0 条剩余 ≠ 不限量)");
394
+ assert.equal(st.messagesPerMonth, 0);
395
+
396
+ await writeStatus(relayDir, { tier: "pro_max", remainingMessages: null });
397
+ st = (await json(`${base}/dsh-remote/im-bots`)).body.status;
398
+ assert.equal(st.tier, "pro_max");
399
+ });
400
+ });
@@ -0,0 +1,104 @@
1
+ // 登录/首屏不再阻塞在「本机端口发现」上(2026-11-07 业主反馈:登录 7~8 秒太慢)。
2
+ //
3
+ // 根因定位(Lead):登录后前端拉 /bridge-status 与 /account,服务端 composeConnect 里
4
+ // `await discoverUpstreamSelf()`(lsof/netstat + 数百端口探测)+ launchctl/ps 全在响应路径上;
5
+ // /config 还在响应里 `...(await composeStatus())`。
6
+ //
7
+ // 本文件锁三件事:
8
+ // ① composeConnect **不 await** 慢发现(<300ms 返回,并标记 discoveryPending);
9
+ // ② POST /dsh-remote/config 立即返回(不带衍生状态,statusPending=true);
10
+ // ③ shell 探测(launchctl/ps)走 10s TTL 缓存(避免每次轮询重跑)。
11
+ import assert from "node:assert/strict";
12
+ import http from "node:http";
13
+ import { mkdtemp } from "node:fs/promises";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import test from "node:test";
17
+ import {
18
+ apply,
19
+ __composeConnect as composeConnect,
20
+ __setUpstreamTestState as setUpstreamTestState,
21
+ __resetUpstreamTestState as resetUpstreamTestState,
22
+ __clearStatusProbeCache as clearStatusProbeCache,
23
+ __STATUS_PROBE_TTL_MS as STATUS_PROBE_TTL_MS,
24
+ } from "../lib/index.js";
25
+
26
+ // unref:模拟"慢发现"的定时器不能把测试进程空等到 5 秒(本套件对时长敏感)。
27
+ const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); try { t.unref?.(); } catch { /* 忽略 */ } });
28
+ const tmpRelay = () => mkdtemp(path.join(os.tmpdir(), "dsh-login-fast-"));
29
+
30
+ test("composeConnect:端口发现再慢(5s)也不阻塞状态组装(<300ms),并标记 discoveryPending", async () => {
31
+ const relayDir = await tmpRelay();
32
+ setUpstreamTestState({ unsettled: true, discover: async () => { await sleep(5000); return "http://127.0.0.1:43210"; } });
33
+ clearStatusProbeCache();
34
+ try {
35
+ const t0 = Date.now();
36
+ const conn = await composeConnect(relayDir);
37
+ const cost = Date.now() - t0;
38
+ assert.ok(cost < 300, "★ composeConnect 必须 <300ms 返回(实测 " + cost + "ms)");
39
+ assert.equal(conn.discoveryPending, true, "端口发现还没完成时,必须如实标记 discoveryPending(面板可显示骨架)");
40
+ assert.ok(conn.phase, "状态阶段照常返回(不因发现未完成而缺字段)");
41
+ } finally {
42
+ resetUpstreamTestState();
43
+ clearStatusProbeCache();
44
+ }
45
+ });
46
+
47
+ test("composeConnect:发现已完成(缓存/hint)→ 不再触发发现、discoveryPending=false", async () => {
48
+ const relayDir = await tmpRelay();
49
+ setUpstreamTestState({ unsettled: false, discover: async () => { throw new Error("不该被调用"); } });
50
+ clearStatusProbeCache();
51
+ try {
52
+ const conn = await composeConnect(relayDir);
53
+ assert.equal(conn.discoveryPending, false, "发现已 settled 时不应标记 pending");
54
+ } finally {
55
+ resetUpstreamTestState();
56
+ clearStatusProbeCache();
57
+ }
58
+ });
59
+
60
+ test("POST /dsh-remote/config:立即返回(不 await 衍生状态),body.statusPending=true 且只回最小 config", async () => {
61
+ const relayDir = await tmpRelay();
62
+ setUpstreamTestState({ unsettled: true, discover: async () => { await sleep(5000); return "http://127.0.0.1:43210"; } });
63
+ const routes = [];
64
+ apply({
65
+ webServer: { register(rt) { routes.push(rt); return () => {}; } },
66
+ effect(reg) { return reg(); },
67
+ logger: { info() {}, warn() {} },
68
+ }, { relayDir });
69
+ const host = http.createServer((req, res) => {
70
+ const url = new URL(req.url, "http://x");
71
+ const rt = routes.find((r) => (r.kind === "prefix"
72
+ ? url.pathname === r.path || url.pathname.startsWith(r.path + "/")
73
+ : url.pathname === r.path));
74
+ (rt ? rt.handler : ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
75
+ });
76
+ await new Promise((r) => host.listen(0, "127.0.0.1", r));
77
+ const base = `http://127.0.0.1:${host.address().port}`;
78
+ try {
79
+ const t0 = Date.now();
80
+ const r = await fetch(`${base}/dsh-remote/config`, {
81
+ method: "POST",
82
+ headers: { "content-type": "application/json" },
83
+ body: JSON.stringify({ phone: "13800000000", password: "password123" }),
84
+ });
85
+ const cost = Date.now() - t0;
86
+ const body = await r.json();
87
+ assert.equal(r.status, 200);
88
+ assert.ok(cost < 300, "★ /config 必须 <300ms(实测 " + cost + "ms)—— 登录按钮的等待只该是登录请求本身");
89
+ assert.equal(body.ok, true);
90
+ assert.equal(body.statusPending, true, "★ 立即返回时必须声明 statusPending(前端据此异步补状态)");
91
+ assert.equal(typeof body.config, "object", "回最小配置回显(供 UI 立刻切换已登录态)");
92
+ assert.equal(body.config.hasPhone, true);
93
+ assert.equal("connect" in body, false, "★ 响应里不得再带衍生状态(connect/隧道/端口探测结果)");
94
+ assert.equal("service" in body, false, "★ 响应里不得再带 service 衍生信息");
95
+ } finally {
96
+ resetUpstreamTestState();
97
+ clearStatusProbeCache();
98
+ await new Promise((r) => host.close(r));
99
+ }
100
+ });
101
+
102
+ test("shell 探测 TTL:STATUS_PROBE_TTL_MS 是 10s 级短缓存(不是每次轮询都重跑 launchctl/ps)", () => {
103
+ assert.equal(STATUS_PROBE_TTL_MS, 10 * 1000, "缓存 TTL 必须是 10s(B1 约定)");
104
+ });
@@ -227,7 +227,10 @@ test("未登录:不请求企业端、不出误导性红字;登录后自动
227
227
 
228
228
  assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/access-key"), "未登录不得请求二维码接口");
229
229
  assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/mobile-sessions"), "未登录不得请求设备列表");
230
- assert.ok(textHas(tree, "登录下方「🔑 账号」卡片中的手机号账号后"), "未登录应给登录引导而非报错");
230
+ // IA 重做(2026-11-07):未登录点「打开手机端/生成访问码」→ 直接弹登录弹层并说明原因;
231
+ // 这张卡自己的按钮文案也写明「登录并生成访问码」(不再静默失败)。
232
+ assert.ok(textHas(tree, "登录并生成访问码") || textHas(tree, "登录后才能用手机访问"),
233
+ "未登录应给明确的登录引导(不是静默失败,也不是红字报错)");
231
234
  assert.ok(!textHas(tree, "尚未登录"), "未登录也不该出现接口报错红字");
232
235
 
233
236
  tree = await plugin.login();
@@ -75,6 +75,12 @@ function domIndex(tree, predicate) {
75
75
  }
76
76
  const flush = () => new Promise((resolve) => setImmediate(resolve));
77
77
 
78
+ /** 收集所有匹配节点(用例里要数"有几个单选"这类)。 */
79
+ function findAll(tree, predicate) {
80
+ const out = [];
81
+ walk(tree, (n) => { if (predicate(n)) out.push(n); });
82
+ return out;
83
+ }
78
84
  /** 子树全部文本(拼接;用于"卡片文案/按钮文案"断言)。 */
79
85
  function textOf(node) {
80
86
  let out = "";
@@ -92,6 +98,7 @@ function loadPlugin(opts = {}) {
92
98
  const metas = [];
93
99
  const injects = new Map();
94
100
  const requests = [];
101
+ const clipboard = [];
95
102
  let unmounted = false;
96
103
 
97
104
  // ── 迷你 React:真实 useState/useEffect + 递归展开**函数子组件** ──
@@ -223,7 +230,7 @@ function loadPlugin(opts = {}) {
223
230
  document: doc,
224
231
  localStorage,
225
232
  MutationObserver: MutationObserverMock,
226
- navigator: { clipboard: { writeText: async () => {} } },
233
+ navigator: { clipboard: { writeText: async (t) => { clipboard.push(String(t)); } } },
227
234
  setTimeout: fakeSetTimeout,
228
235
  clearTimeout: fakeClear,
229
236
  setInterval: fakeSetInterval,
@@ -299,7 +306,7 @@ function loadPlugin(opts = {}) {
299
306
  }
300
307
 
301
308
  return {
302
- requests, metas, injects,
309
+ requests, metas, injects, clipboard,
303
310
  counts() { return { ...counts }; },
304
311
  /** 设置页栏目列表(模拟 shell 的 ctx.slots.entries("settings.section"),按 order 排序)。 */
305
312
  sectionList() { return metas.filter((m) => m.name === "settings.section").slice().sort((a, b) => a.order - b.order); },
@@ -796,3 +803,194 @@ test("发送预览卡片:已绑定(boundChatId 非空)→ 不显示未绑
796
803
  assert.ok(!textOf(tree).includes("先在企微里给机器人发一句话完成绑定"), "已绑定就不该再提示未绑定");
797
804
  assert.ok(textOf(tree).includes("上次预览:09:07"), "应显示上次预览时间(HH:MM)");
798
805
  });
806
+
807
+ // ───────────── ⑨ 未登录守卫 + 后台服务横排 + 卸载挽留(2026-11-07 业主逐条)─────────────
808
+
809
+ test("未登录「手机访问」卡:文案写「登录并生成访问码」,点击弹登录弹层;登录成功后自动回到手机访问", async () => {
810
+ const plugin = loadPlugin();
811
+ plugin.setSignedIn(false);
812
+ let tree = await plugin.settle();
813
+ const card = find(tree, (n) => String(n.props?.className || "").includes("dru-ia-card")
814
+ && textOf(n).includes("手机访问"));
815
+ assert.ok(card, "入口网格里应有「手机访问」卡");
816
+ assert.ok(textOf(card).includes("登录并生成访问码"), "未登录时按钮文案必须是「登录并生成访问码」(不是静默失败)");
817
+ const btn = find(card, (n) => n.type === "button");
818
+ btn.props.onClick();
819
+ tree = await plugin.settle();
820
+ assert.ok(textOf(tree).includes("登录后才能用手机访问"), "点击后弹登录弹层并说明原因");
821
+
822
+ // 登录成功 → 自动回到「手机访问」流程(iaAfterLogin 回跳),不再让用户自己再找一遍
823
+ plugin.setSignedIn(true);
824
+ tree = await plugin.settle(4);
825
+ assert.ok(textOf(tree).includes("手机访问"), "登录后应回到手机访问流程");
826
+ const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
827
+ assert.ok(dlg, "登录后应自动打开手机访问弹层");
828
+ });
829
+
830
+ test("后台服务区块(A6):显眼提示 + 横向紧凑网格(状态/启停/自启动/设备ID 不再竖排占屏)", async () => {
831
+ const plugin = loadPlugin();
832
+ const tree = await plugin.settle();
833
+ assert.ok(textOf(tree).includes("保持在线,才能用手机端远程控制这台电脑"), "必须有「保持在线」提示(业主点名)");
834
+ const grid = find(tree, (n) => String(n.props?.className || "") === "dru-bridge-grid");
835
+ assert.ok(grid, "后台服务应为横向网格布局(.dru-bridge-grid)");
836
+ const cells = byClass(grid, "dru-bridge-cell");
837
+ assert.ok(cells.length >= 4, "网格应含 4 格:状态 / 启停 / 自启动 / 设备 ID,实际 " + cells.length);
838
+ assert.ok(cells.some((c) => textOf(c).includes("设备 ID")), "设备 ID 在网格里(横向)");
839
+ });
840
+
841
+ test("彻底卸载(A7):先弹挽留反馈(原因单选 + 建议)→ 提交走现有反馈通道 → 才继续卸载", async () => {
842
+ const plugin = loadPlugin();
843
+ let tree = await plugin.settle();
844
+ const unBtn = btnWith(tree, "彻底卸载");
845
+ assert.ok(unBtn, "应有「彻底卸载」按钮");
846
+ unBtn.props.onClick();
847
+ tree = await plugin.settle();
848
+ const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
849
+ assert.ok(dlg, "点彻底卸载应先弹挽留反馈弹层");
850
+ assert.ok(textOf(dlg).includes("为什么要卸载"), "弹层要问「为什么要卸载?」;实际=" + textOf(dlg).slice(0, 200));
851
+ const radios = findAll(dlg, (n) => n.type === "input" && n.props?.type === "radio");
852
+ assert.ok(radios.length >= 4, "应有多个原因单选,实际 " + radios.length);
853
+ assert.ok(find(dlg, (n) => n.type === "textarea"), "应有可填建议的输入框");
854
+ assert.ok(btnWith(dlg, "我再想想"), "必须有「我再想想」退出选项");
855
+ // 未选原因就点「仍要卸载」→ 就近报错,且不提交/不卸载
856
+ const before = plugin.requests.length;
857
+ btnWith(dlg, "仍要卸载").props.onClick();
858
+ tree = await plugin.settle();
859
+ assert.ok(textOf(tree).includes("请选择一个卸载原因"), "未选原因要给就近错误");
860
+ const after = plugin.requests.slice(before).map((r) => r.path);
861
+ assert.ok(!after.includes("/dsh-remote/feedback"), "未选原因不得提交反馈(" + after.join(",") + ")");
862
+ assert.ok(!after.includes("/dsh-remote/self/uninstall"), "未选原因更不得开始卸载");
863
+
864
+ // 选一个原因 + 写建议 → 仍要卸载:先 POST 反馈(kind=uninstall),再 POST 卸载
865
+ const r0 = findAll(tree, (n) => n.type === "input" && n.props?.type === "radio")[0];
866
+ r0.props.onChange({ target: { value: r0.props.value } });
867
+ tree = await plugin.settle();
868
+ const note = find(tree, (n) => n.type === "textarea");
869
+ note.props.onChange({ target: { value: "加载太慢,希望优化" } });
870
+ tree = await plugin.settle();
871
+ btnWith(tree, "仍要卸载").props.onClick();
872
+ await flush(); await flush(); await flush();
873
+ const fb = plugin.requests.find((r) => r.path === "/dsh-remote/feedback" && r.method === "POST");
874
+ assert.ok(fb, "★ 必须先提交挽留反馈(复用现有反馈通道,不新增后端)");
875
+ assert.equal(fb.body.kind, "uninstall", "反馈必须带 kind=uninstall 标记");
876
+ assert.ok(String(fb.body.content).includes("原因"), "反馈内容要含所选原因");
877
+ assert.ok(String(fb.body.content).includes("加载太慢"), "用户填的建议要带上");
878
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/self/uninstall" && r.method === "POST"),
879
+ "★ 提交反馈后应继续执行卸载(反馈不能反过来卡住用户)");
880
+ });
881
+
882
+ // ───────────────────── ⑩ 会员档位条 + 会话固定门禁(2026-11-07 桥侧判定) ─────────────────────
883
+
884
+ test("企微配置页档位条:免费档 → 「本月剩余 N 条指令(每月 20 条)」+ 「升级 Pro 不限量」链接", async () => {
885
+ const plugin = loadPlugin();
886
+ plugin.setImBotsGet({
887
+ ok: true,
888
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
889
+ status: {
890
+ connected: true, authenticated: true, tier: "free",
891
+ messagesPerMonth: 20, remainingMessages: 7, usage: { month: "2026-11", messages: 13 },
892
+ bindingGated: false, caps: ["wecom"], capsCount: 1,
893
+ sessions: [], activeSessionId: "", boundSessionId: "",
894
+ },
895
+ });
896
+ const tree = await openWecom(plugin);
897
+ const tierBar = find(tree, (n) => String(n.props?.className || "").includes("dru-ia-tier"));
898
+ assert.ok(tierBar, "免费档应渲染档位条");
899
+ assert.ok(textOf(tierBar).includes("免费档"), "档位条要写明「免费档」");
900
+ assert.ok(textOf(tierBar).includes("本月剩余 7 条指令"), "要显示剩余指令数");
901
+ assert.ok(textOf(tierBar).includes("每月 20 条"), "要显示每月上限");
902
+ const link = find(tierBar, (n) => n.type === "a");
903
+ assert.ok(link, "免费档必须有升级入口");
904
+ assert.equal(link.props.href, "/app/promo", "升级链接指向 /app/promo");
905
+ assert.ok((link.children || []).join("").includes("升级 Pro 不限量"));
906
+ });
907
+
908
+ test("企微配置页档位条:pro / pro_max → 「会员档 · 指令不限」,不出现升级链接", async () => {
909
+ for (const tier of ["pro", "pro_max"]) {
910
+ const plugin = loadPlugin();
911
+ plugin.setImBotsGet({
912
+ ok: true,
913
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
914
+ status: { connected: true, authenticated: true, tier, bindingGated: false, sessions: [], activeSessionId: "", boundSessionId: "" },
915
+ });
916
+ const tree = await openWecom(plugin);
917
+ const tierBar = find(tree, (n) => String(n.props?.className || "").includes("dru-ia-tier"));
918
+ assert.ok(tierBar, tier + " 应渲染档位条");
919
+ assert.ok(textOf(tierBar).includes("会员档 · 指令不限"), tier + " 文案应为「会员档 · 指令不限」");
920
+ assert.equal(find(tierBar, (n) => n.type === "a"), undefined, tier + " 会员档不该有升级链接");
921
+ }
922
+ });
923
+
924
+ test("企微配置页档位条:取不到 tier / 未知值 → 整行不渲染(绝不瞎猜)", async () => {
925
+ for (const status of [
926
+ { connected: true, sessions: [], activeSessionId: "", boundSessionId: "" }, // 旧桥:没有 tier
927
+ { connected: true, tier: "gold", sessions: [], activeSessionId: "", boundSessionId: "" }, // 未知值
928
+ ]) {
929
+ const plugin = loadPlugin();
930
+ plugin.setImBotsGet({
931
+ ok: true,
932
+ wecom: { enabled: false, botId: "", hasSecret: false, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
933
+ status,
934
+ });
935
+ const tree = await openWecom(plugin);
936
+ assert.equal(find(tree, (n) => String(n.props?.className || "").includes("dru-ia-tier")), undefined,
937
+ "拿不到有效档位时不得渲染档位条(" + JSON.stringify(status.tier) + ")");
938
+ }
939
+ });
940
+
941
+ test("会话固定门禁:bindingGated=true → 下拉置灰 + 「会员功能」徽章 + 悬停说明;已选值被忽略时如实说明", async () => {
942
+ const plugin = loadPlugin();
943
+ plugin.setImBotsGet({
944
+ ok: true,
945
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "sess-1-abcdef123456", sessionTitle: "修复登录问题" },
946
+ status: {
947
+ connected: true, authenticated: true, tier: "free", bindingGated: true,
948
+ sessions: [{ id: "sess-1-abcdef123456", title: "修复登录问题", updatedAt: 1 }],
949
+ activeSessionId: "sess-2-xyz", activeSessionTitle: "部署到 NAS", boundSessionId: "",
950
+ },
951
+ });
952
+ const tree = await openWecom(plugin);
953
+ const sel = find(tree, (n) => n.type === "select" && n.props?.id === "dru-wecom-session");
954
+ assert.ok(sel, "下拉仍应存在(只是置灰)");
955
+ assert.equal(sel.props.disabled, true, "★ 免费档必须置灰(不能假装能选)");
956
+ assert.equal(sel.props.title, "升级后可固定转接会话", "悬停必须说明为什么不能选");
957
+ assert.equal(sel.props.value, "", "被忽略的选择不能显示成生效值");
958
+ const badge = find(tree, (n) => String(n.props?.className || "").includes("dru-ia-badge"));
959
+ assert.ok(badge && textOf(badge).includes("会员功能"), "置灰处要有「会员功能」徽章");
960
+ assert.ok(textOf(tree).includes("当前跟随活跃会话"), "已选值被忽略时要显示「当前跟随活跃会话」");
961
+ assert.ok(textOf(tree).includes("免费档不能固定转接"), "小字要说明原因");
962
+ });
963
+
964
+ // ───────────────────── ⑪ 品牌全名 + 安装命令(2026-11-07 业主) ─────────────────────
965
+
966
+ test("品牌:面板标题 / 关于卡片统一用全名 dsh-remote-web,旧的「关于 dsh-remote」不再出现", async () => {
967
+ const plugin = loadPlugin();
968
+ const tree = await plugin.settle();
969
+ // 顶部面板标题(业主:「我的插件名字叫 dsh-remote-web,这个是全名」)
970
+ const title = find(tree, (n) => String(n.props?.className || "").includes("dru-settings-title"));
971
+ assert.ok(title, "应有面板标题");
972
+ assert.equal((title.children || []).join(""), "dsh-remote-web", "顶部标题必须是品牌全名 dsh-remote-web");
973
+ // 关于卡片
974
+ assert.ok(textOf(tree).includes("关于 dsh-remote-web"), "关于段落标题应为「关于 dsh-remote-web」");
975
+ // 精确断言:不得再出现裸品牌「关于 dsh-remote」(但不能误伤包名 @mrrisega/dsh-remote)
976
+ assert.doesNotMatch(SOURCE, /关于 dsh-remote(?!-web)/, "旧品牌写法「关于 dsh-remote」不得再出现");
977
+ assert.match(SOURCE, /@mrrisega\/dsh-remote/, "包名 @mrrisega/dsh-remote 必须保留(不是品牌文案)");
978
+ assert.match(SOURCE, /~\/\.dsh-remote/, "配置目录 ~/.dsh-remote 必须保留(代码/数据路径,不是品牌文案)");
979
+ });
980
+
981
+ test("版本与更新:有一行可复制的安装/更新命令 npx @mrrisega/dsh-remote@latest + 「重跑这条命令即可」说明", async () => {
982
+ const plugin = loadPlugin();
983
+ let tree = await plugin.settle();
984
+ assert.ok(textOf(tree).includes("npx @mrrisega/dsh-remote@latest"), "版本卡必须给出安装/更新命令");
985
+ assert.ok(textOf(tree).includes("已安装的更新:重跑这条命令即可"), "要说明更新方式是重跑该命令");
986
+ const copyBtn = find(tree, (n) => n.type === "button" && n.props?.["aria-label"] === "复制安装命令");
987
+ assert.ok(copyBtn, "命令旁必须有复制按钮(复用现有复制反馈样式)");
988
+ const code = find(tree, (n) => String(n.props?.className || "").includes("dru-code-val"));
989
+ assert.ok(code && (code.children || []).join("").includes("@mrrisega/dsh-remote@latest"), "命令要以可复制的代码样式展示");
990
+
991
+ copyBtn.props.onClick();
992
+ await flush(); await flush();
993
+ assert.deepEqual(plugin.clipboard, ["npx @mrrisega/dsh-remote@latest"], "★ 点复制必须把安装命令写进剪贴板");
994
+ tree = await plugin.settle();
995
+ assert.ok(textOf(tree).includes("✅ 已复制"), "复制成功要有与其它复制按钮一致的反馈");
996
+ });