@mrrisega/dsh-remote 0.6.3 → 0.6.4-beta.10

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.
@@ -11,12 +11,19 @@
11
11
  // 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
12
12
  // 删 plist|unit / 杀残留进程 / 清空配置目录 ~/.dsh-remote),0.4.7 起回归真正「未安装」状态
13
13
  // - 运行时自愈:缺运行环境自动后台安装、登录后自动拉起 bridge(0.4.2 起)
14
+ // - 登录后自动闭环(0.6.4):GET /dsh-remote/bridge-status 下发连接阶段
15
+ // (no_account/no_runtime/installing/starting/connecting/online/error),并在返回前自动补装运行环境、
16
+ // 拉起 bridge、清理卡死标记(带退避);区分「bridge 进程在跑」与「设备已在中继注册成功(online)」。
17
+ // 面板用 2~3s 短轮询自动推进到 online(无需点按钮/刷新页面),error 才给重试与诊断信息;
18
+ // 另 POST /dsh-remote/connect/retry 手动重试、POST <api>/api/install-report 上报安装口径
19
+ // (install_source/version + host_os/arch,详见 installSourceOf)。
14
20
  // - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
15
21
  //
16
22
  // 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
17
- import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, statSync, constants as fsConstants } from "node:fs";
23
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, statSync, constants as fsConstants } from "node:fs";
18
24
  import { join, dirname, sep } from "node:path";
19
25
  import { execSync, spawn } from "node:child_process";
26
+ import { randomUUID } from "node:crypto";
20
27
  import { homedir, platform } from "node:os";
21
28
  import { fileURLToPath } from "node:url";
22
29
 
@@ -222,9 +229,20 @@ function applySaaSMode(cfg) {
222
229
  * 老版本(无该鉴权)下 connection 服务没有 authenticatedUrl → 静默跳过,行为不变。
223
230
  */
224
231
  const HARNESS_COOKIE_FILE = ".harness-cookie.json";
232
+ /**
233
+ * 手机端 401「dsh web authentication required; reopen the URL printed by dsh web」的根因就在这里:
234
+ * 该 Cookie 由本插件在进程内经 ?token= 换取后交给 bridge 代持,**dsh web 每次重启都会换签名密钥**,
235
+ * 旧 Cookie 立即失效。旧实现只在启动后重试 20 次(约 60s)就放弃、之后 6 小时才刷新一次 ——
236
+ * 一旦启动那一刻 connection 服务还没就绪(或用户按引导重启 harness 后插件先于服务就绪),
237
+ * Cookie 就会长时间不可用,手机端看到那段英文提示且**无法自行恢复**(他打不开电脑上打印的 URL)。
238
+ * 现在改为:① 启动后持续重试 10 分钟;② 每 30 分钟主动刷新;③ 面板每次查状态时按需补齐;
239
+ * ④ bridge 撞到 401 会写「作废」标记,插件下次查状态立即重取。四处叠加后用户无需任何操作。
240
+ */
225
241
  const HARNESS_AUTH_RETRY_MS = 3000;
226
- const HARNESS_AUTH_RETRY_MAX = 20; // 最多约 60s 等 connection 服务就绪
227
- const HARNESS_AUTH_REFRESH_MS = 6 * 3600 * 1000;
242
+ const HARNESS_AUTH_RETRY_WINDOW_MS = 10 * 60 * 1000; // 持续尝试 10 分钟,不再 60s 后放弃
243
+ const HARNESS_AUTH_REFRESH_MS = 30 * 60 * 1000; // 主动刷新周期(本地一次 fetch,成本可忽略)
244
+ const HARNESS_COOKIE_STALE_MS = 30 * 60 * 1000; // 超过视为陈旧 → 按需重取
245
+ const HARNESS_COOKIE_REVOKED_FILE = ".harness-cookie-revoked"; // bridge 撞 401 时写的标记
228
246
 
229
247
  /** 读取响应里的 set-cookie(兼容 getSetCookie / get 两种实现)。 */
230
248
  function setCookieOf(res) {
@@ -264,24 +282,63 @@ async function mintHarnessCookie(ctx, relayDir) {
264
282
  }
265
283
  }
266
284
 
267
- /** 后台调度:启动重试直到换取成功,成功后每 6h 刷新(与插件生命周期同进退)。 */
285
+ /** 读取当前 Cookie 文件状态:{cookie, authority, mintedAt} 或 null。 */
286
+ function readHarnessCookie(relayDir) {
287
+ try {
288
+ const j = JSON.parse(readFileSync(join(relayDir, HARNESS_COOKIE_FILE), "utf8"));
289
+ return j && typeof j.cookie === "string" && j.cookie ? j : null;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ let harnessMintInflight = false;
296
+ let harnessMintLastAt = 0;
297
+
298
+ /**
299
+ * 按需确保 Cookie 可用(去重 + 限频):
300
+ * 缺失 / 超过 HARNESS_COOKIE_STALE_MS / bridge 写了作废标记 → 重取一次。
301
+ * 被 /dsh-remote/status 与 /dsh-remote/bridge-status 调用(面板轮询 2.5~30s),
302
+ * 因此只要面板开着,手机端 401 会在秒级内自愈;不需要用户做任何事。
303
+ * @param {object} ctx cordis 上下文
304
+ * @param {string} relayDir 配置目录
305
+ * @param {{force?: boolean}} [opts]
306
+ * @returns {Promise<boolean>} 本次是否确认 Cookie 可用
307
+ */
308
+ async function ensureHarnessCookie(ctx, relayDir, opts) {
309
+ const force = !!(opts && opts.force);
310
+ const revoked = existsSync(join(relayDir, HARNESS_COOKIE_REVOKED_FILE));
311
+ if (!force && !revoked) {
312
+ const cur = readHarnessCookie(relayDir);
313
+ if (cur && Date.now() - Number(cur.mintedAt || 0) < HARNESS_COOKIE_STALE_MS) return true;
314
+ }
315
+ if (harnessMintInflight) return false; // 去重:同一时刻只换一次
316
+ if (Date.now() - harnessMintLastAt < 5000) return false; // 限频:5s 内不重复换
317
+ harnessMintInflight = true;
318
+ harnessMintLastAt = Date.now();
319
+ try {
320
+ const ok = await mintHarnessCookie(ctx, relayDir);
321
+ if (ok || revoked) {
322
+ try { rmSync(join(relayDir, HARNESS_COOKIE_REVOKED_FILE), { force: true }); } catch { /* ignore */ }
323
+ }
324
+ if (!ok && revoked) console.warn("[dsh-remote-web] 浏览器会话 Cookie 已被 dsh web 作废,重取失败,下次查状态再试");
325
+ return ok;
326
+ } finally {
327
+ harnessMintInflight = false;
328
+ }
329
+ }
330
+
331
+ /** 后台调度:启动后持续重试(10 分钟窗口) + 每 30 分钟主动刷新(与插件生命周期同进退)。 */
268
332
  function scheduleHarnessMint(ctx, relayDir) {
269
- let succeeded = false;
270
- let retries = 0;
271
- const attempt = async () => {
272
- if (succeeded) return;
273
- if (await mintHarnessCookie(ctx, relayDir)) succeeded = true;
274
- };
333
+ const startedAt = Date.now();
275
334
  const bootIv = setInterval(() => {
276
- if (succeeded || ++retries > HARNESS_AUTH_RETRY_MAX) {
277
- clearInterval(bootIv);
278
- return;
279
- }
280
- void attempt();
335
+ if (Date.now() - startedAt > HARNESS_AUTH_RETRY_WINDOW_MS) { clearInterval(bootIv); return; }
336
+ void ensureHarnessCookie(ctx, relayDir, { force: true }).catch(() => {});
281
337
  }, HARNESS_AUTH_RETRY_MS);
282
338
  bootIv.unref?.();
339
+ void ensureHarnessCookie(ctx, relayDir, { force: true }).catch(() => {}); // 立刻来一次
283
340
  const refreshIv = setInterval(() => {
284
- void mintHarnessCookie(ctx, relayDir).catch(() => {});
341
+ void ensureHarnessCookie(ctx, relayDir).catch(() => {});
285
342
  }, HARNESS_AUTH_REFRESH_MS);
286
343
  refreshIv.unref?.();
287
344
  return () => {
@@ -371,6 +428,10 @@ function manualStatus() {
371
428
  })();
372
429
  const watcher = [];
373
430
  const bridge = [];
431
+ // 全部 bridge 子进程(含 launchd 托管链里的):连接阶段判定「bridge 进程真的在跑」用它。
432
+ // launchd 作业本身跑的是 watcher(dsh-setup.mjs run),它再拉起 dsh-bridge.mjs 子进程——
433
+ // 只看 launchd.running 会把「watcher 在、bridge 子进程没起来」误判成「进程在跑」。
434
+ const allBridge = [];
374
435
  // 取候选进程的父 pid,判断是否属于 launchd 托管链
375
436
  const parentOf = (pid) => {
376
437
  const r = sh(`ps -o ppid= -p ${pid}`);
@@ -383,11 +444,17 @@ function manualStatus() {
383
444
  if (!m) continue;
384
445
  const pid = Number(m[1]);
385
446
  if (pid === process.pid || pid === launchdPid) continue;
386
- if (launchdPid !== null && parentOf(pid) === launchdPid) continue; // launchd 托管的 bridge 子进程
387
- if (/dsh-setup\.mjs/.test(m[2])) watcher.push(pid);
388
- else if (/dsh-bridge\.mjs/.test(m[2])) bridge.push(pid);
447
+ const launchdManaged = launchdPid !== null && parentOf(pid) === launchdPid;
448
+ if (/dsh-setup\.mjs/.test(m[2])) {
449
+ if (!launchdManaged) watcher.push(pid);
450
+ continue;
451
+ }
452
+ if (/dsh-bridge\.mjs/.test(m[2])) {
453
+ allBridge.push(pid);
454
+ if (!launchdManaged) bridge.push(pid); // launchd 托管的 bridge 子进程(原语义:不计入 manual.bridge)
455
+ }
389
456
  }
390
- return { watcher, bridge };
457
+ return { watcher, bridge, allBridge };
391
458
  }
392
459
 
393
460
  // ---------- 插件市场一键全功能:缺桌面运行环境时自动后台安装 dsh-remote ----------
@@ -395,6 +462,13 @@ function manualStatus() {
395
462
  const PROVISION_MARKER = ".dsh-setup-installing";
396
463
  const AUTO_INSTALL_LOG = ".dsh-setup-install.log";
397
464
  const STALE_MARKER_MS = 30 * 60 * 1000; // 超过该时长视为上次进程残留,插件启动时清理
465
+ /**
466
+ * 「本次运行环境是插件自愈补装的」记录文件(install_source 判据):
467
+ * 插件市场只装面板插件时,桌面运行环境由插件后台 `npx @mrrisega/dsh-remote` 补上,
468
+ * 这条路径归为 install_source=plugin_market;由用户自己跑安装器的(plist 里带
469
+ * DSH_BRIDGE_INSTALL_SOURCE=npx)归为 npx。见 installSourceOf()。
470
+ */
471
+ const PROVISIONED_MARKER = ".dsh-provisioned-by-plugin";
398
472
  /**
399
473
  * 自愈 watcher 轮询间隔(默认 12s)。DSH_RELAY_SELFHEAL_MS 可覆盖——仅供测试把
400
474
  * 崩溃循环自愈链路压到亚秒级验证,生产不必设置。调用时读取(而非模块加载时固定),
@@ -436,19 +510,35 @@ function ensureRuntime(relayDir) {
436
510
  const log = join(relayDir, AUTO_INSTALL_LOG);
437
511
  const child = spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
438
512
  detached: true,
439
- env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org" }),
513
+ // DSH_BRIDGE_INSTALL_SOURCE:本机运行环境是插件自愈补的 → 安装器原样透传到 plist/bridge env,
514
+ // 于是设备行上的 install_source 如实记成 plugin_market(而不是安装器默认的 npx)。
515
+ env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org", DSH_BRIDGE_INSTALL_SOURCE: "plugin_market" }),
440
516
  stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
441
517
  });
442
518
  writeMarker(marker, child.pid); // 记 pid:宿主重启后可立即清理死进程残留
519
+ // 记下「本机运行环境是插件自愈补的」:登录后的 /api/install-report 与 bridge 设备登记
520
+ // 都据此上报 install_source=plugin_market(区分用户自己跑 npx 安装器的那条路径)。
521
+ try {
522
+ writeFileSync(join(relayDir, PROVISIONED_MARKER),
523
+ JSON.stringify({ at: Date.now(), version: PLUGIN_VERSION, source: "plugin_market" }));
524
+ } catch { /* 非关键:上报时按缺省判据降级 */ }
443
525
  const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
526
+ // 匿名遥测:补装生命周期(install_started → runtime_ready / install_failed(fail_code))。
527
+ // 失败原因只归类到白名单 fail_code,绝不外发原始错误文本(可能含路径/主机名/用户名)。
528
+ const tb = telemetryBook(relayDir);
529
+ tb.installStartedAt = Date.now();
530
+ telemetryRecord(relayDir, "install_started");
444
531
  child.on("exit", (code) => {
445
532
  clear();
446
533
  appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] npx 退出 code=${code ?? "?"}`);
534
+ if (code === 0 && runtimeReady(relayDir)) telemetryRuntimeReady(relayDir); // 退出码 0 ≠ 装好了
535
+ else if (code !== 0) telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromText(readTail(log)) });
447
536
  });
448
537
  child.on("error", (e) => {
449
538
  clear();
450
539
  appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动失败: ${e.message}`);
451
540
  console.warn(`[dsh-remote-web] 自动安装子进程启动失败: ${e.message}`);
541
+ telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
452
542
  });
453
543
  child.unref();
454
544
  console.log(`[dsh-remote-web] 检测到缺少桌面运行环境,已在后台自动安装(日志: ${log}),完成后将自动启动 bridge`);
@@ -456,6 +546,7 @@ function ensureRuntime(relayDir) {
456
546
  } catch (e) {
457
547
  console.warn(`[dsh-remote-web] 自动安装启动失败: ${e.message}`);
458
548
  try { rmSync(marker, { force: true }); } catch { /* ignore */ }
549
+ telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
459
550
  return false;
460
551
  }
461
552
  }
@@ -476,7 +567,7 @@ function writeAutostartFile(relayDir) {
476
567
  <key>ThrottleInterval</key><integer>10</integer>
477
568
  <key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
478
569
  <key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
479
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string></dict>
570
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string><key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string></dict>
480
571
  </dict></plist>`;
481
572
  mkdirSync(dirname(plistPath), { recursive: true });
482
573
  writeFileSync(plistPath, plist, { mode: 0o644 });
@@ -580,12 +671,17 @@ function scheduleRuntime(relayDir) {
580
671
  if (!runtimeReady(relayDir)) {
581
672
  reapBrokenAutostart(relayDir); // 先摘掉指向不存在脚本的自启动,止住崩溃循环
582
673
  ensureRuntime(relayDir); // 后台 npx 补齐(marker 防重入);就绪后下一轮自动拉起
674
+ telemetryRuntimeProbe(relayDir); // 匿名遥测:补装超时归类(runtime_install_timeout)
583
675
  return;
584
676
  }
677
+ telemetryRuntimeProbe(relayDir); // 匿名遥测:环境刚补齐 → runtime_ready(每进程一次)
585
678
  if (awaitingRestartHint) {
586
679
  awaitingRestartHint = false;
587
- // 首次安装:运行环境(bridge + 自启动)刚在本进程内补齐 → 提示重启,重启后即完全可用
588
- markRestartPending(relayDir, "first-install", "首次安装需要重启 DeepSeek harness");
680
+ // 运行环境刚在本进程内补齐 —— **不需要重启 harness**:
681
+ // · bridge 是独立 launchd 进程,补齐后自愈调度会直接把它拉起;
682
+ // · 插件本体走 patch 热加载,装完即生效。
683
+ // 以前这里会 markRestartPending("first-install") 弹出"需要重启 DeepSeek harness"横幅,
684
+ // 在热加载落地后已经变成纯粹的误导(用户实测反馈),故移除。
589
685
  }
590
686
  const cfg = loadConfig(relayDir);
591
687
  const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
@@ -756,7 +852,10 @@ function writeRestartState(relayDir, state) {
756
852
  } catch { /* 写盘失败不致命:面板退化为不提示 */ }
757
853
  }
758
854
 
759
- /** 标记「需要重启 DeepSeek harness」。kind: first-install(首次安装) | update(在线更新)。 */
855
+ /**
856
+ * 标记「需要用户动一下」的状态。kind = refresh(插件被运行时改写,刷新页面即可)。
857
+ * 历史 kind(first-install / update)已废弃:热加载落地后,装插件与在线更新都不再需要重启 harness。
858
+ */
760
859
  function markRestartPending(relayDir, kind, reason) {
761
860
  const cur = readRestartState(relayDir);
762
861
  if (cur.pending && cur.kind === kind) return cur; // 幂等:同一原因不反复重写
@@ -788,7 +887,15 @@ function pluginInstalledAfterBoot() {
788
887
  const pkgFile = join(dirname(dirname(here)), "package.json");
789
888
  const mtimeMs = statSync(pkgFile).mtimeMs;
790
889
  const bootMs = Date.now() - process.uptime() * 1000;
791
- return mtimeMs > bootMs + 2000;
890
+ if (!(mtimeMs > bootMs + 2000)) return false;
891
+ // ⚠️ 只看时间戳会**误报**:安装器补装/在线更新会把同样的文件重写一遍(哪怕是同一个版本),
892
+ // 时间戳变新 → 被当成"有新插件要装载" → 弹出"需要重启/刷新"横幅(用户实测踩到:
893
+ // 自愈补装把 profile 副本重写了一次,运行中的旧代码就写下了"需要重启")。
894
+ // 真正该提示用户的只有一件事:**磁盘上的插件版本与本进程装载的版本不同**。
895
+ // 版本相同 → 运行中的代码就是目标版本,什么都不用做。
896
+ const diskVersion = String(JSON.parse(readFileSync(pkgFile, "utf8")).version || "");
897
+ const runningVersion = String(PLUGIN_VERSION || "");
898
+ return Boolean(diskVersion && runningVersion && diskVersion !== runningVersion);
792
899
  } catch { return false; }
793
900
  }
794
901
 
@@ -859,6 +966,10 @@ function restartHarness(relayDir, opts = {}) {
859
966
  if (unit) { mode = "systemd"; target = unit; }
860
967
  }
861
968
  const script = buildRestartScript(mode, target, cmd, relayDir);
969
+ // 匿名遥测:重启 DeepSeek harness(自动倒计时或手动按钮都经这里)——触发一次记一条。
970
+ // 放在 dry-run 之前:测试/诊断用的 dry-run 也走同一条入口,便于用例锁死这条埋点。
971
+ telemetryRecord(relayDir, "harness_restart");
972
+ telemetryFlushSoon(relayDir); // 本进程约 1~2 秒后就会被替换:尽量当下把这条发出去(发不出去留盘上,下次装载补发)
862
973
  if (opts.dryRun || process.env.DSH_RELAY_RESTART_DRYRUN === "1") {
863
974
  // 测试/诊断用:只返回将要执行的脚本,不做任何真实动作
864
975
  return { ok: true, status: "dry-run", mode, target, script, log: join(relayDir, RESTART_LOG_FILE) };
@@ -901,6 +1012,8 @@ async function relayFetch(relayDir, pathname, init) {
901
1012
  try {
902
1013
  // 6s 超时:relay 不可达时快速降级,不拖慢面板
903
1014
  const res = await fetch(url, { ...init, signal: AbortSignal.timeout(6000) });
1015
+ // 企业端拒绝当前 JWT(过期/轮换)→ 立刻让 token 缓存失效,下一次请求重新认证
1016
+ if (res.status === 401 || res.status === 403) invalidateRelayToken(relayDir);
904
1017
  const text = await res.text();
905
1018
  let body = null;
906
1019
  try {
@@ -1014,6 +1127,38 @@ const AUTH_RELAY_NOT_READY = "relay_not_ready"; // 凭证齐全但企业端
1014
1127
 
1015
1128
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1016
1129
 
1130
+ /**
1131
+ * relay token 短期缓存(60s):连接阶段的 2~3s 短轮询会反复取 token,若每次都走 device-login
1132
+ * (即账号密码认证),就会**每几秒打一次企业端认证接口**——审计日志刷屏、可能触发限流,且纯属浪费。
1133
+ * - TTL 60s:远短于 JWT 自身有效期;账号密码/共享密钥变更最多 60s 生效;
1134
+ * - 键含 api_url + 账号 + 模式:换账号、切自建/云端、换服务端会自动 miss;
1135
+ * - 并发合并(relayTokenInflight):同一时刻多个请求共用一次认证;
1136
+ * - 只缓存成功结果;企业端回 401/403(JWT 过期/轮换)时 relayFetch 立即 invalidateRelayToken。
1137
+ */
1138
+ const RELAY_TOKEN_TTL_MS = 60_000;
1139
+ const relayTokenCache = new Map(); // relayDir → { key, token, exp, status }
1140
+ const relayTokenInflight = new Map(); // relayDir → { key, promise }
1141
+ /** 缓存键:同一台机器换了账号/服务端/模式时自动失效,避免把别人的 token 用错地方。 */
1142
+ function relayTokenKeyOf(cfg) {
1143
+ return [
1144
+ (cfg.api_url || DEFAULT_API).replace(/\/+$/, ""),
1145
+ cfg.local_key ? "local" : "saas",
1146
+ cfg.local_key ? String(cfg.local_key).slice(0, 8) : (cfg.phone || cfg.email || ""),
1147
+ ].join("|");
1148
+ }
1149
+ /** 丢弃某 relayDir 的 token 缓存(账号变更、退出登录、企业端拒绝 JWT 时调用)。 */
1150
+ function invalidateRelayToken(relayDir) {
1151
+ if (!relayDir) return;
1152
+ relayTokenCache.delete(relayDir);
1153
+ deviceProbeCache.delete(relayDir); // 认证失效时「设备探活」结论同样过期(否则 15s 内不会重试)
1154
+ }
1155
+ /**
1156
+ * 账号设备表探活缓存(见 accountDeviceBound):企业端每次 GET /api/devices 都会写一行活跃明细
1157
+ * (user_activity),因此绝不跟着 2.5s 的 UI 轮询打——15s 一次,且只在与中继注册证据缺失时才探。
1158
+ */
1159
+ const DEVICE_PROBE_TTL_MS = 15_000;
1160
+ const deviceProbeCache = new Map(); // relayDir → { at, value }
1161
+
1017
1162
  /** 单次 device-login:返回 { ok, status, token, code, detail }(不抛错,便于按原因降级/重试)。 */
1018
1163
  async function deviceLoginOnce(api, cfg, secret) {
1019
1164
  try {
@@ -1065,8 +1210,35 @@ async function localLoginOnce(cfg, api) {
1065
1210
  * ③ 网络抖动/5xx 退避重试一次;④ 失败时给出**可判定原因**而非空串(消除“尚未登录”误报)。
1066
1211
  * @returns {Promise<{token:string, reason:string, status:number, detail:string}>}
1067
1212
  */
1068
- async function relayTokenWithReason(relayDir, cfgOverride) {
1213
+ async function relayTokenWithReason(relayDir, cfgOverride, opts = {}) {
1069
1214
  const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
1215
+ const key = relayTokenKeyOf(cfg);
1216
+ const now = Date.now();
1217
+ const hit = relayTokenCache.get(relayDir);
1218
+ if (!opts.force && hit && hit.key === key && hit.token && now < hit.exp) {
1219
+ return { token: hit.token, reason: "", status: hit.status || 200, detail: "", cached: true };
1220
+ }
1221
+ // 并发合并:同一时刻的多个请求(状态轮询 / 二维码 / 设备列表 / 上报)共用一次认证
1222
+ const flying = relayTokenInflight.get(relayDir);
1223
+ if (!opts.force && flying && flying.key === key) return flying.promise;
1224
+ const promise = relayTokenUncached(relayDir, cfg, cfgOverride).then((res) => {
1225
+ if (res && res.token) {
1226
+ relayTokenCache.set(relayDir, { key, token: res.token, exp: Date.now() + RELAY_TOKEN_TTL_MS, status: res.status || 200 });
1227
+ } else {
1228
+ relayTokenCache.delete(relayDir); // 失败不缓存:下一轮立刻重试
1229
+ }
1230
+ relayTokenInflight.delete(relayDir);
1231
+ return res;
1232
+ }, (e) => {
1233
+ relayTokenInflight.delete(relayDir);
1234
+ throw e;
1235
+ });
1236
+ relayTokenInflight.set(relayDir, { key, promise });
1237
+ return promise;
1238
+ }
1239
+
1240
+ /** 未缓存的真实取 token 逻辑(relayTokenWithReason 的缓存/合并外壳在这里之上一层)。 */
1241
+ async function relayTokenUncached(relayDir, cfg, cfgOverride) {
1070
1242
  const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
1071
1243
  if (cfg.local_key) {
1072
1244
  let r = await localLoginOnce(cfg, api);
@@ -1470,6 +1642,10 @@ async function composeStatus(relayDir) {
1470
1642
  // 是否需要重启 DeepSeek harness(首次安装 / 在线更新后必须重启才加载插件本体):
1471
1643
  // {pending, kind: first-install|update, reason, at},供面板顶部醒目提示 + 重启按钮。
1472
1644
  restart: readRestartState(relayDir),
1645
+ // 连接阶段(登录后自动闭环:环境 → bridge 进程 → 设备已在中继注册):
1646
+ // 面板用它显示「正在准备运行环境…/正在启动 Bridge…/正在连接中继…/已连接 ✅」,
1647
+ // 并用 /dsh-remote/bridge-status 以 2~3s 短轮询自动推进(无需用户点按钮或刷新页面)。
1648
+ connect: await composeConnect(relayDir, { cfg, launchd, manual }),
1473
1649
  // 隐私审计(2026-09):不再下发真实 hostname(移除 host 字段)——设备标识统一走 deviceId/服务端登记名
1474
1650
  };
1475
1651
  }
@@ -1577,7 +1753,7 @@ const PLUGIN_ID = "dsh-remote-web";
1577
1753
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1578
1754
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1579
1755
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1580
- const PLUGIN_VERSION = "0.6.3";
1756
+ const PLUGIN_VERSION = "0.6.4-beta.10";
1581
1757
  const UPDATE_LOG = ".dsh-update.log";
1582
1758
  const UPDATE_MARKER = ".dsh-update-running";
1583
1759
 
@@ -1608,7 +1784,9 @@ function spawnUpdater(relayDir, extraEnv) {
1608
1784
  return spawn(npxCommand(), ["--yes", UPDATE_SPEC], {
1609
1785
  detached: true,
1610
1786
  cwd: homedir(),
1611
- env: spawnEnv(extraEnv), // PATH 补 node 目录:App 最小 PATH 下也能跑 npx
1787
+ // PATH 补 node 目录:App 最小 PATH 下也能跑 npx;同时保持本机既有的安装来源口径
1788
+ // (在线更新会重跑安装器并重写 plist,若不带来源就会把 plugin_market 误记成 npx)。
1789
+ env: spawnEnv({ DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir), ...(extraEnv || {}) }),
1612
1790
  stdio: ["ignore", openSync(log, "a"), openSync(log, "a")]
1613
1791
  });
1614
1792
  }
@@ -1628,6 +1806,7 @@ function runOnlineUpdate(relayDir) {
1628
1806
  const marker = join(relayDir, UPDATE_MARKER);
1629
1807
  if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
1630
1808
  appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 ${UPDATE_SPEC} (${new Date().toISOString()})`);
1809
+ telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
1631
1810
 
1632
1811
  let retried = false;
1633
1812
  const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
@@ -1644,12 +1823,17 @@ function runOnlineUpdate(relayDir) {
1644
1823
  }
1645
1824
  appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}(${retried ? "默认源" : "官方源"})`);
1646
1825
  clear();
1647
- // 在线更新改写了插件/运行环境文件 → 必须重启 DeepSeek harness 才装载新版本
1648
- if (code === 0) markRestartPending(relayDir, "update", "已在线更新,需要重启 DeepSeek harness 生效");
1826
+ // 在线更新改写了插件/运行环境文件 → 版本对不上时由 pluginInstalledAfterBoot() 自动识别,
1827
+ // 面板会提示"刷新页面"。这里**不再**写"需要重启 DeepSeek harness"的待重启标记:
1828
+ // 插件走 patch 热加载、bridge 是独立进程,重启 harness 已无必要(用户实测反馈)。
1829
+ // 成功不发事件(update_started 已发过,失败才发 update_failed;白名单里没有 update_done,
1830
+ // 硬发只会被服务端静默丢弃)。
1831
+ if (code !== 0) telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromText(readTail(join(relayDir, UPDATE_LOG))) });
1649
1832
  });
1650
1833
  child.on("error", (e) => {
1651
1834
  appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
1652
1835
  clear();
1836
+ telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromError(e) });
1653
1837
  });
1654
1838
  child.unref();
1655
1839
  return child;
@@ -1658,6 +1842,7 @@ function runOnlineUpdate(relayDir) {
1658
1842
  return { ok: true, pid: child.pid, log: join(relayDir, UPDATE_LOG) };
1659
1843
  } catch (e) {
1660
1844
  try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
1845
+ telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromError(e) });
1661
1846
  return { ok: false, detail: String(e.message || e) };
1662
1847
  }
1663
1848
  }
@@ -1714,6 +1899,935 @@ function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
1714
1899
  // ---------- 路由 ----------
1715
1900
 
1716
1901
  /** 路由表:{method, path, handler}。 */
1902
+ // ---------- 登录后自动闭环:连接阶段(运行环境 → bridge 进程 → 设备已在中继注册=online) ----------
1903
+
1904
+ /**
1905
+ * 生产现场(2026-09,id 42~52 共 11 个真实注册用户):7 人注册后手机端 /api/devices 一直是空列表,
1906
+ * 多人反复点「生成访问链接」(auth.key_create 10~23 次)而 device.bind 始终为 0——他们的电脑端
1907
+ * bridge 从未连上过中继。典型路径:注册 → 手机端拿不到设备 → 反复轮询 → 放弃。
1908
+ *
1909
+ * 缺口不是某个按钮坏了,而是「登录 → 设备已在中继注册成功」这条链路既不可见也不自动:
1910
+ * 用户看不出卡在哪一步(运行环境没装?bridge 没起?还是没注册上中继?),也不知道要不要刷新页面。
1911
+ *
1912
+ * 本段把链路拆成可查询的阶段(GET /dsh-remote/bridge-status),并刻意区分两个极易混淆的状态:
1913
+ * - starting :「bridge 进程已在跑」——只说明进程起来了;
1914
+ * - online :「设备已在中继注册成功」——手机端能看到并进入这台电脑,这才是用户要的「能用了」。
1915
+ * 阶段推进所需的动作(补装运行环境 / 拉起 bridge / 可重试错误退避重试)全部由 ensureConnection
1916
+ * 在轮询里自动完成,用户零操作、零刷新;只有「彻底失败」才给按钮,且永远留一条可走的路。
1917
+ */
1918
+ const BRIDGE_LOG_FILE = ".dsh-bridge.log";
1919
+ const BRIDGE_STATE_FILE = ".dsh-bridge-state.json";
1920
+ const BIND_ERROR_FILE = ".bind-error.json";
1921
+ /** 安装标记超时(进程已死或超 10 分钟)→ 视为卡死,清掉标记重新补装(否则 marker 会把补装永久挡住)。 */
1922
+ const INSTALL_STALE_MS = 10 * 60 * 1000;
1923
+ /** 设备登记失败提示只在一小时内当作「当前故障」(.bind-error.json 是持久文件,成功时才被清掉)。 */
1924
+ const BIND_ERROR_FRESH_MS = 60 * 60 * 1000;
1925
+ /** 自动重试退避(毫秒):可重试错误按此节奏自动重试,用尽后停在 60s(仍会自动重试,不出现死胡同)。 */
1926
+ const CONNECT_BACKOFF_MS = [2000, 4000, 8000, 16_000, 30_000, 60_000];
1927
+
1928
+ /** 连接阶段文案(面向非技术用户;node 半算好,面板直接显示,避免两端各写一套)。 */
1929
+ const CONNECT_TEXT = {
1930
+ no_account: "请先登录手机号账号(下方「🔑 账号」卡片),登录后会自动完成剩余步骤",
1931
+ no_runtime: "正在准备运行环境(首次约 1~2 分钟)…",
1932
+ installing: "正在准备运行环境(首次约 1~2 分钟)…",
1933
+ starting: "正在启动 Bridge…",
1934
+ connecting: "正在连接中继…",
1935
+ online: "已连接 ✅ 现在可以用手机扫码访问",
1936
+ error: "连接失败,请按下方提示处理",
1937
+ };
1938
+ const CONNECT_PHASES = ["no_account", "no_runtime", "installing", "starting", "connecting", "online", "error"];
1939
+
1940
+ /** 每个 relayDir 的连接状态簿(尝试次数 / 退避 / 上报节流):放内存,进程重启即重置。 */
1941
+ const connectBooks = new Map();
1942
+ function connectBook(relayDir) {
1943
+ let b = connectBooks.get(relayDir);
1944
+ if (!b) {
1945
+ b = { attempts: 0, lastAttemptAt: 0, reportAt: 0 };
1946
+ connectBooks.set(relayDir, b);
1947
+ }
1948
+ return b;
1949
+ }
1950
+ /** 已尝试 attempts 次后的退避时长。 */
1951
+ function connectBackoffMs(attempts) {
1952
+ const i = Math.min(Math.max(attempts, 0), CONNECT_BACKOFF_MS.length - 1);
1953
+ return CONNECT_BACKOFF_MS[i];
1954
+ }
1955
+ /** 本机是否已有可用账号凭据(SaaS 账号或自建访问密钥)——没有就谈不上自动连接,先引导登录。 */
1956
+ function hasAccountCreds(cfg) {
1957
+ return Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
1958
+ }
1959
+
1960
+ /** 读文件尾部(日志可能很长,只取末尾用于判定最近一次连接结果)。 */
1961
+ function readTail(filePath, maxBytes = 64 * 1024) {
1962
+ try {
1963
+ const fd = openSync(filePath, "r");
1964
+ try {
1965
+ const size = fstatSync(fd).size;
1966
+ const start = Math.max(0, size - maxBytes);
1967
+ const buf = Buffer.alloc(size - start);
1968
+ readSync(fd, buf, 0, buf.length, start);
1969
+ return buf.toString("utf8");
1970
+ } finally {
1971
+ closeSync(fd);
1972
+ }
1973
+ } catch { return ""; }
1974
+ }
1975
+
1976
+ /** 读取 bridge 写的状态文件(新版 bridge 会写;旧版没有 → 走日志/账号兜底判据)。 */
1977
+ function bridgeStateFile(relayDir) {
1978
+ try {
1979
+ const j = JSON.parse(readFileSync(join(relayDir, BRIDGE_STATE_FILE), "utf8"));
1980
+ return j && typeof j === "object" ? j : null;
1981
+ } catch { return null; }
1982
+ }
1983
+
1984
+ /**
1985
+ * 从 bridge 日志尾部判定「最近一次连接是否注册成功」。
1986
+ * 依据 dsh-bridge.mjs 的既有输出:`✅ 设备已登记到账号` / `✅ router 注册成功`;
1987
+ * 出现新一轮连接(`隧道已连`)或登记失败/拒绝时,之前那次成功不再代表当前状态。
1988
+ */
1989
+ function bridgeLogState(relayDir) {
1990
+ const tail = readTail(join(relayDir, BRIDGE_LOG_FILE));
1991
+ if (!tail) return { tunnelRegistered: false, accountBound: false, lastError: "" };
1992
+ let tunnelRegistered = false;
1993
+ let accountBound = false;
1994
+ let lastError = "";
1995
+ for (const raw of tail.split("\n")) {
1996
+ const line = raw.trim();
1997
+ if (!line) continue;
1998
+ if (/✅\s*router 注册成功/.test(line)) { tunnelRegistered = true; lastError = ""; continue; }
1999
+ if (/✅\s*设备已登记到账号/.test(line)) { accountBound = true; lastError = ""; continue; }
2000
+ if (/隧道已连|\[dsh-remote\] dsh web 在线,启动 bridge|bridge 退出/.test(line)) {
2001
+ tunnelRegistered = false;
2002
+ accountBound = false;
2003
+ continue;
2004
+ }
2005
+ if (/拒绝注册|注册发送失败|设备登记失败|无法连接账号 API|设备数已达上限/.test(line)) {
2006
+ tunnelRegistered = false;
2007
+ accountBound = false;
2008
+ lastError = line.slice(0, 300);
2009
+ }
2010
+ }
2011
+ return { tunnelRegistered, accountBound, lastError };
2012
+ }
2013
+
2014
+ /** 读取设备登记失败提示(bridge 在 409/网络失败时写 .bind-error.json)。 */
2015
+ function readBindError(relayDir) {
2016
+ try {
2017
+ const f = join(relayDir, BIND_ERROR_FILE);
2018
+ if (!existsSync(f)) return null;
2019
+ const j = JSON.parse(readFileSync(f, "utf8"));
2020
+ return j && typeof j === "object" ? j : null;
2021
+ } catch { return null; }
2022
+ }
2023
+
2024
+ /** 运行环境安装状态:installing=安装子进程在跑;stale=标记超时/进程已死(可清掉重来)。 */
2025
+ function installStateOf(relayDir) {
2026
+ const marker = join(relayDir, PROVISION_MARKER);
2027
+ if (!existsSync(marker)) return { installing: false, stale: false, at: 0 };
2028
+ const info = readMarkerInfo(marker) || {};
2029
+ const at = Number(info.at) || 0;
2030
+ const pidDead = info.pid ? pidAlive(Number(info.pid)) === false : false;
2031
+ const stale = pidDead || (at > 0 && Date.now() - at > INSTALL_STALE_MS);
2032
+ return { installing: !stale, stale, at };
2033
+ }
2034
+
2035
+ /** 清掉卡死的安装标记(下轮 ensureRuntime 才能重新拉起补装)。 */
2036
+ function clearStaleInstallMarker(relayDir) {
2037
+ try {
2038
+ rmSync(join(relayDir, PROVISION_MARKER), { force: true });
2039
+ appendLogLine(relayDir, AUTO_INSTALL_LOG, "[dsh-remote-web] 清理卡死的安装标记(安装进程已退出或超时),将重新补装运行环境");
2040
+ return true;
2041
+ } catch { return false; }
2042
+ }
2043
+
2044
+ /** 账号设备表里是否已登记本机 device_id(手机端 /api/devices 能看到的那一份)+ 节流缓存。 */
2045
+ async function accountDeviceBound(relayDir, cfg) {
2046
+ const deviceId = String(cfg.device_id || "");
2047
+ if (!deviceId) return { bound: false, reason: "no_device_id" };
2048
+ const cached = deviceProbeCache.get(relayDir);
2049
+ if (cached && Date.now() - cached.at < DEVICE_PROBE_TTL_MS) return cached.value;
2050
+ const done = (value) => { deviceProbeCache.set(relayDir, { at: Date.now(), value }); return value; };
2051
+ const token = await relayToken(relayDir).catch(() => "");
2052
+ if (!token) return done({ bound: false, reason: "no_token" });
2053
+ const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
2054
+ try {
2055
+ const r = await fetch(`${api}/api/devices`, {
2056
+ headers: { authorization: `Bearer ${token}`, "x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}` },
2057
+ signal: AbortSignal.timeout(5000),
2058
+ });
2059
+ if (!r.ok) {
2060
+ if (r.status === 401 || r.status === 403) invalidateRelayToken(relayDir); // JWT 过期/轮换 → 下次重新认证
2061
+ return done({ bound: false, reason: `http_${r.status}` });
2062
+ }
2063
+ const body = await r.json().catch(() => null);
2064
+ const list = Array.isArray(body?.devices) ? body.devices : Array.isArray(body) ? body : [];
2065
+ const bound = list.some((d) => String(d?.id || d?.device_id || "") === deviceId);
2066
+ return done({ bound, reason: bound ? "" : "not_in_list", count: list.length });
2067
+ } catch (e) {
2068
+ return done({ bound: false, reason: `unreachable: ${e.message}` });
2069
+ }
2070
+ }
2071
+
2072
+ /**
2073
+ * 计算当前连接阶段(纯读,不做任何系统操作)。
2074
+ * 阶段:no_account | no_runtime | installing | starting | connecting | online | error
2075
+ * 判据严格区分「进程在跑」与「已在中继注册」:
2076
+ * online 需要 bridge 进程在跑 **且** 有中继注册证据(状态文件 > 日志 > 账号设备表)。
2077
+ */
2078
+ async function composeConnect(relayDir, opts = {}) {
2079
+ const cfg = opts.cfg || loadConfig(relayDir);
2080
+ const launchd = opts.launchd || launchdStatus();
2081
+ const manual = opts.manual || manualStatus();
2082
+ const book = connectBook(relayDir);
2083
+ const now = Date.now();
2084
+ const hasAcct = hasAccountCreds(cfg);
2085
+ const runtime = runtimeReady(relayDir);
2086
+ const inst = installStateOf(relayDir);
2087
+ const bridgePids = Array.isArray(manual.allBridge) ? manual.allBridge : [];
2088
+ const bridgeRunning = Boolean(launchd.running || bridgePids.length > 0 || (manual.bridge || []).length > 0);
2089
+ const bindError = readBindError(relayDir);
2090
+ const bindFresh = Boolean(bindError && (!bindError.at || now - Number(bindError.at) < BIND_ERROR_FRESH_MS));
2091
+ const log = bridgeLogState(relayDir);
2092
+ const state = bridgeStateFile(relayDir);
2093
+ const deviceId = String(cfg.device_id || "");
2094
+
2095
+ // ── 中继注册证据(只有 bridge 进程在跑时才算「现在能用」) ──
2096
+ let registered = false;
2097
+ let registerSource = "";
2098
+ if (runtime && bridgeRunning) {
2099
+ if (state && state.phase === "online" && Number(state.tunnel_registered_at) > 0) {
2100
+ registered = true; registerSource = "state";
2101
+ } else if (log.tunnelRegistered) {
2102
+ registered = true; registerSource = "tunnel";
2103
+ } else if (state && Number(state.account_bound_at) > 0) {
2104
+ registered = true; registerSource = "state_account";
2105
+ } else if (log.accountBound) {
2106
+ registered = true; registerSource = "account";
2107
+ } else if (hasAcct) {
2108
+ const probe = await accountDeviceBound(relayDir, cfg);
2109
+ if (probe.bound) { registered = true; registerSource = "account_api"; }
2110
+ }
2111
+ }
2112
+
2113
+ let phase;
2114
+ let error = null;
2115
+ let detail = "";
2116
+ let retryable = true;
2117
+ if (UNINSTALLED_DIRS.has(relayDir)) {
2118
+ phase = "error"; retryable = false;
2119
+ error = { code: "uninstalled", message: "本插件已彻底卸载,重启 dsh web 后不再自动启动 bridge" };
2120
+ detail = error.message;
2121
+ } else if (!hasAcct) {
2122
+ phase = "no_account"; retryable = false;
2123
+ detail = "尚未登录账号:登录后会自动补装运行环境 → 启动 bridge → 连接中继,全程无需其他操作。";
2124
+ } else if (!runtime) {
2125
+ if (inst.stale) {
2126
+ phase = "error";
2127
+ error = {
2128
+ code: "install_stuck",
2129
+ message: "运行环境安装没有完成(上次安装进程已退出或超时),已清理残留标记并准备重新安装",
2130
+ };
2131
+ detail = error.message;
2132
+ } else {
2133
+ phase = inst.installing ? "installing" : "no_runtime";
2134
+ detail = inst.installing
2135
+ ? "正在后台安装运行环境(bridge 本体),首次约 1~2 分钟,装完会自动启动。"
2136
+ : "检测到缺少运行环境,正在后台自动安装(首次约 1~2 分钟),无需任何操作。";
2137
+ }
2138
+ } else if (!bridgeRunning) {
2139
+ if (launchd.crashing) {
2140
+ phase = "error";
2141
+ error = {
2142
+ code: "launchd_crash",
2143
+ message: `后台服务反复启动失败(launchd state=${launchd.state || "?"},lastExit=${launchd.lastExitCode ?? "?"})`,
2144
+ };
2145
+ detail = "已自动清理失效的自启动项并重新拉起,稍候会自动恢复。";
2146
+ } else if (bindFresh && bindError) {
2147
+ phase = "error";
2148
+ error = { code: bindError.code || "bind_failed", message: bindError.message || "设备登记失败" };
2149
+ detail = "bridge 因设备登记失败退出,已进入自动重试;请按提示处理后重试。";
2150
+ } else {
2151
+ phase = "starting";
2152
+ detail = launchd.running
2153
+ ? "后台服务已启动,正在等待 bridge 进程就绪…"
2154
+ : "正在启动后台服务(bridge),通常几秒内完成,无需任何操作。";
2155
+ }
2156
+ } else if (registered) {
2157
+ phase = "online";
2158
+ detail = registerSource === "account_api" || registerSource === "account" || registerSource === "state_account"
2159
+ ? "设备已登记到你的账号:手机端「设备列表」能看到这台电脑,扫码/打开链接即可进入。"
2160
+ : "bridge 已注册到中继:手机端扫码/打开链接即可进入这台电脑。";
2161
+ } else if (bindFresh && bindError) {
2162
+ phase = "error";
2163
+ error = { code: bindError.code || "bind_failed", message: bindError.message || "设备登记失败" };
2164
+ detail = bindError.code === "device_limit_exceeded"
2165
+ ? "已达本套餐设备数上限:同机重装会自动顶替旧设备;仍失败请到手机端「设备管理」解绑旧设备后点「重试」。"
2166
+ : "请确认网络与账号状态后点「重试」;仍不成功可点「复制诊断信息」发给客服。";
2167
+ } else {
2168
+ phase = "connecting";
2169
+ detail = "bridge 进程已在运行,正在等待设备注册到中继…通常几秒内完成。";
2170
+ }
2171
+
2172
+ const wait = connectBackoffMs(book.attempts);
2173
+ const elapsed = book.lastAttemptAt ? now - book.lastAttemptAt : wait;
2174
+ const autoRetrying = phase !== "online" && phase !== "no_account" && retryable !== false;
2175
+ const nextRetryInMs = autoRetrying ? Math.max(0, wait - elapsed) : 0;
2176
+ const conn = {
2177
+ phase,
2178
+ online: phase === "online",
2179
+ text: CONNECT_TEXT[phase] || "正在连接…",
2180
+ detail,
2181
+ retryable: retryable !== false,
2182
+ deviceId,
2183
+ runtimeReady: runtime,
2184
+ installing: inst.installing,
2185
+ installStale: inst.stale,
2186
+ bridgeRunning,
2187
+ bridgePids,
2188
+ launchdState: launchd.state || "",
2189
+ launchdCrashLoop: Boolean(launchd.crashing),
2190
+ registered,
2191
+ registerSource,
2192
+ attempts: book.attempts,
2193
+ nextRetryInMs,
2194
+ error,
2195
+ bindError: bindFresh && bindError ? { code: bindError.code || "", message: bindError.message || "" } : null,
2196
+ logPath: join(relayDir, BRIDGE_LOG_FILE),
2197
+ installLogPath: join(relayDir, AUTO_INSTALL_LOG),
2198
+ };
2199
+ conn.diagnostics = buildConnectDiagnostics(relayDir, conn, log);
2200
+ return conn;
2201
+ }
2202
+
2203
+ /** 可复制的诊断信息(面板「复制诊断信息」按钮):版本 / relayDir / 阶段 / 最近错误 / 进程状态 / 日志路径。 */
2204
+ function buildConnectDiagnostics(relayDir, conn, log) {
2205
+ return [
2206
+ "dsh-remote 连接诊断",
2207
+ `插件版本: ${PLUGIN_VERSION}`,
2208
+ `配置目录: ${relayDir}`,
2209
+ `连接阶段: ${conn.phase}(${conn.text})`,
2210
+ `设备 ID: ${conn.deviceId || "(未生成)"}`,
2211
+ `运行环境: ${conn.runtimeReady ? "已就绪" : "缺失"}${conn.installing ? "(后台安装中)" : ""}${conn.installStale ? "(安装标记已超时)" : ""}`,
2212
+ `bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
2213
+ `中继注册: ${conn.registered ? "已注册(" + conn.registerSource + ")" : "未注册"}`,
2214
+ `最近错误: ${conn.error ? conn.error.code + ": " + conn.error.message : (log && log.lastError ? log.lastError : "无")}`,
2215
+ `自动重试: 已尝试 ${conn.attempts} 次${conn.nextRetryInMs ? `,约 ${Math.ceil(conn.nextRetryInMs / 1000)} 秒后重试` : ""}`,
2216
+ `bridge 日志: ${conn.logPath}`,
2217
+ `安装日志: ${conn.installLogPath}`,
2218
+ `时间: ${new Date().toISOString()}`,
2219
+ ].join("\n");
2220
+ }
2221
+
2222
+ /**
2223
+ * 自动闭环的动作执行(幂等、内部退避):
2224
+ * ① 缺运行环境 → ensureRuntime(后台 npx 补装;卡死的安装标记先清掉);
2225
+ * ② 不再缺环境但没账号 → 什么都不做(先把登录引导交给 UI);
2226
+ * ③ 有账号但 bridge 没在跑 → startBridge(运行环境缺失时它自己会拒绝并转补装)。
2227
+ * 由面板的短轮询驱动(GET /dsh-remote/bridge-status),因此用户在登录后不需要点任何按钮、
2228
+ * 也不需要刷新页面;退避避免失败时把 launchctl/npx 打成风暴。
2229
+ */
2230
+ function ensureConnection(relayDir, opts = {}) {
2231
+ if (UNINSTALLED_DIRS.has(relayDir)) return null;
2232
+ const book = connectBook(relayDir);
2233
+ if (opts.force) { book.attempts = 0; book.lastAttemptAt = 0; }
2234
+ const now = Date.now();
2235
+ if (!opts.force && book.lastAttemptAt && now - book.lastAttemptAt < connectBackoffMs(book.attempts)) return null;
2236
+ const cfg = loadConfig(relayDir);
2237
+ if (!hasAccountCreds(cfg)) return null; // 未登录:不补装也不拉服务,先让用户登录
2238
+ if (!runtimeReady(relayDir)) {
2239
+ if (installStateOf(relayDir).stale) clearStaleInstallMarker(relayDir);
2240
+ book.lastAttemptAt = now;
2241
+ book.attempts += 1;
2242
+ ensureRuntime(relayDir);
2243
+ return { action: "provision" };
2244
+ }
2245
+ const launchd = launchdStatus();
2246
+ const manual = manualStatus();
2247
+ if (launchd.running || (manual.allBridge || []).length || (manual.bridge || []).length) {
2248
+ book.attempts = 0; // 进程已在跑:退避计数归零,后续只等注册
2249
+ book.lastAttemptAt = now;
2250
+ return { action: "none" };
2251
+ }
2252
+ if (launchd.crashing) reapBrokenAutostart(relayDir);
2253
+ book.lastAttemptAt = now;
2254
+ book.attempts += 1;
2255
+ const r = startBridge(relayDir);
2256
+ if (r && r.ok === false) {
2257
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2258
+ `[dsh-remote-web] 自动启动 bridge 未成功(${r.status || "?"}): ${r.detail || ""}`);
2259
+ }
2260
+ return { action: "start", result: r };
2261
+ }
2262
+
2263
+ // ---------- 匿名装机/连接遥测(客户端半;契约与隐私边界见 docs/telemetry.md) ----------
2264
+ //
2265
+ // 背景(2026-09 生产诊断):11 个新注册用户里只有 3 个把设备连上——6 人电脑端从未安装、2 人装了但
2266
+ // bridge 没连上。而「装不上」的人没有任何账号、也就没有任何数据,「本地 OK、新机器失败」因此无法归因。
2267
+ // 本通道只补这一段装机/连接事实,且必须守住开源项目的隐私边界:
2268
+ //
2269
+ // · 完全匿名:不带 Authorization、不带任何账号标识;install_id 是本机随机 UUID(非硬件派生、
2270
+ // 重装即变,不可跨机器关联);
2271
+ // · 只发白名单事件(TELEMETRY_EVENT_NAMES)与白名单 fail_code(TELEMETRY_FAIL_CODES),
2272
+ // 原始错误文本一律不透传(它可能含路径/主机名);
2273
+ // · 允许的字段**只有**:install_id / 事件名 / fail_code / 版本号 / os(process.platform) /
2274
+ // arch(process.arch) / node(仅主版本号)——唯一构造点是 telemetryEventOf();
2275
+ // · 禁止采集(代码与 docs/telemetry.md 双写死):手机号、邮箱、账号 ID、任何会话内容或文件内容、
2276
+ // 真实 hostname / 用户名 / 文件路径、密码与密钥、设备指纹(machine_fp)、原始 IP、精确地理位置;
2277
+ // · 可关闭:DSH_REMOTE_TELEMETRY=0 → 完全关闭(不生成 install_id、不落任何文件、不发任何请求);
2278
+ // · 全程静默:任何异常都被吞掉,绝不影响面板 / bridge / 连接流程与用户可见行为。
2279
+ //
2280
+ // 契约(服务端冻结):POST <api_url>/api/telemetry/events,headers
2281
+ // { content-type: application/json, x-dsh-client: dsh-remote/<PLUGIN_VERSION> }(无 Authorization),
2282
+ // body { install_id, source: "plugin", events: [{ name, at, version, os, arch, node, fail_code? }] },
2283
+ // 单批 ≤ 20 条、body ≤ 32KB。
2284
+
2285
+ /** 事件名白名单:只用这些,其它一律不发。 */
2286
+ const TELEMETRY_EVENT_NAMES = new Set([
2287
+ "install_started", "install_failed", "runtime_ready", "bridge_started", "bridge_registered",
2288
+ "tunnel_disconnected", "first_remote_ok", "plugin_loaded", "panel_opened", "harness_restart",
2289
+ "update_started", "update_failed",
2290
+ ]);
2291
+ /** fail_code 白名单(白名单外的分类一律落 unknown)。 */
2292
+ const TELEMETRY_FAIL_CODES = new Set([
2293
+ "node_missing", "node_too_old", "npm_unreachable", "npm_eacces", "platform_unsupported",
2294
+ "runtime_install_timeout", "launchd_failed", "bridge_exit", "bind_conflict", "bind_device_limit", "unknown",
2295
+ ]);
2296
+ /** node 半只发 source=plugin。 */
2297
+ const TELEMETRY_SOURCE = "plugin";
2298
+ const TELEMETRY_INSTALL_ID_FILE = ".telemetry-install-id";
2299
+ const TELEMETRY_QUEUE_FILE = ".telemetry-queue.json";
2300
+ const TELEMETRY_ONCE_FILE = ".telemetry-once.json";
2301
+ /** 本地队列上限(0600):超出丢最旧,避免长离线机器把磁盘堆满。 */
2302
+ const TELEMETRY_QUEUE_MAX = 200;
2303
+ /** 单批事件上限 / body 上限(契约冻结)。 */
2304
+ const TELEMETRY_BATCH_MAX = 20;
2305
+ const TELEMETRY_BODY_MAX = 32 * 1024;
2306
+ /** 每 60s 或队列 ≥5 条时批量发送。 */
2307
+ const TELEMETRY_FLUSH_MS = 60_000;
2308
+ const TELEMETRY_FLUSH_THRESHOLD = 5;
2309
+ /** 失败退避(30s → 2m → 10m → 1h;落在 60s 心跳网格上),累计 6 次仍失败则丢弃该批。 */
2310
+ const TELEMETRY_BACKOFF_MS = [30_000, 120_000, 600_000, 3_600_000];
2311
+ const TELEMETRY_MAX_ATTEMPTS = 6;
2312
+
2313
+ /**
2314
+ * 发送心跳间隔(毫秒)。DSH_REMOTE_TELEMETRY_MS 仅供本仓库测试/诊断把节奏压到亚秒级
2315
+ * (生产不必设置,与 DSH_RELAY_SELFHEAL_MS 同一约定);调用时读取,import 之后再改也生效。
2316
+ */
2317
+ function telemetryFlushMs() {
2318
+ const v = Number(process.env.DSH_REMOTE_TELEMETRY_MS);
2319
+ return Number.isFinite(v) && v > 0 ? Math.max(10, v) : TELEMETRY_FLUSH_MS;
2320
+ }
2321
+
2322
+ /**
2323
+ * 遥测开关:DSH_REMOTE_TELEMETRY=0/false/off/no → 完全关闭(不生成 install_id、不落文件、不发请求);
2324
+ * 默认开启(=1/true 显式开启)。
2325
+ * 另外:DSH_RELAY_SKIP_SERVICE=1(测试/诊断隔离,见 skipsSystemOps)时同样不发送——
2326
+ * 本仓库的测试脚本全局设了该变量,用例绝不能把匿名事件发到真实生产端点。
2327
+ */
2328
+ function telemetryEnabled() {
2329
+ if (skipsSystemOps()) return false;
2330
+ const v = String(process.env.DSH_REMOTE_TELEMETRY ?? "").trim().toLowerCase();
2331
+ return !(v === "0" || v === "false" || v === "off" || v === "no");
2332
+ }
2333
+
2334
+ /**
2335
+ * 第 attempt 次失败后到下次重试的退避时长(毫秒):30s → 2m → 10m → 1h(封顶)。
2336
+ * 设置了 DSH_REMOTE_TELEMETRY_MS 时按同一比例缩放(仅供本仓库测试把整条重试链压到亚秒级;
2337
+ * 生产绝不设置该变量,生产值就是上面这串)。
2338
+ */
2339
+ function telemetryBackoffMs(attempt) {
2340
+ const base = TELEMETRY_BACKOFF_MS[Math.min(Math.max(attempt, 0), TELEMETRY_BACKOFF_MS.length - 1)];
2341
+ const v = Number(process.env.DSH_REMOTE_TELEMETRY_MS);
2342
+ const scale = Number.isFinite(v) && v > 0 ? Math.max(10, v) / TELEMETRY_FLUSH_MS : 1;
2343
+ return Math.max(10, Math.round(base * scale));
2344
+ }
2345
+
2346
+ /** 每个 relayDir 的遥测簿:内存队列 + 心跳句柄 + 退避/去重状态(进程重启即重置)。 */
2347
+ const telemetryBooks = new Map();
2348
+ function telemetryBook(relayDir) {
2349
+ let b = telemetryBooks.get(relayDir);
2350
+ if (!b) {
2351
+ b = {
2352
+ queue: null, // null=尚未从磁盘读回
2353
+ timer: null, // 心跳定时器(unref,不阻塞进程退出)
2354
+ kickPending: false, // 已排队一次「立即发送」
2355
+ sending: false,
2356
+ attempts: 0, // 当前批次连续失败次数
2357
+ nextAt: 0, // 退避解禁时间
2358
+ closed: false, // 插件已停摆(dispose)→ 不再调度发送
2359
+ fired: new Set(), // 本进程已发过的事件名(每进程每阶段只发一次)
2360
+ runtimeMissingSeen: false,
2361
+ installStartedAt: 0,
2362
+ tunnelDown: false, // 同一掉线周期只发一条 tunnel_disconnected
2363
+ wasRegistered: false,
2364
+ };
2365
+ telemetryBooks.set(relayDir, b);
2366
+ }
2367
+ return b;
2368
+ }
2369
+
2370
+ /**
2371
+ * 只在配置目录**已存在**时落盘:遥测绝不为自己的统计去创建配置目录(否则会改变
2372
+ * 「本机是否装过运行时」的既有语义——例如彻底卸载用例里 relayDir 本不该存在)。
2373
+ * 目录还没出现时事件只留在内存里等下一次心跳,装好了/登录后目录出现即恢复持久化。
2374
+ */
2375
+ function telemetryDirReady(relayDir) {
2376
+ try { return Boolean(relayDir) && existsSync(relayDir); } catch { return false; }
2377
+ }
2378
+
2379
+ /**
2380
+ * 本机 install_id:<relayDir>/.telemetry-install-id(0600),首次 crypto.randomUUID(),之后复用。
2381
+ * 它是**随机 UUID**(非硬件派生、不含机器信息、换机/重装即变),因此不可跨机器关联到同一个人。
2382
+ * 生成/落盘失败 → 返回 null(本次不发,静默,不阻断任何流程)。
2383
+ */
2384
+ function telemetryInstallId(relayDir) {
2385
+ if (!telemetryEnabled()) return null; // 关闭:连 ID 都不生成(更不落盘)
2386
+ if (!telemetryDirReady(relayDir)) return null;
2387
+ const file = join(relayDir, TELEMETRY_INSTALL_ID_FILE);
2388
+ try {
2389
+ const cur = readFileSync(file, "utf8").trim();
2390
+ if (/^[0-9A-Za-z-]{16,64}$/.test(cur)) return cur;
2391
+ } catch { /* 首次生成 */ }
2392
+ try {
2393
+ const id = randomUUID();
2394
+ try {
2395
+ writeFileSync(file, id, { mode: 0o600, flag: "wx" }); // wx:并发时不会互相覆盖
2396
+ } catch {
2397
+ const cur = readFileSync(file, "utf8").trim(); // 别的进程刚生成 → 复用它
2398
+ if (cur) return cur;
2399
+ throw new Error("install_id 写入失败");
2400
+ }
2401
+ try { chmodSync(file, 0o600); } catch { /* 权限位不生效不影响使用 */ }
2402
+ return id;
2403
+ } catch {
2404
+ return null; // 生成失败 → 本次不发
2405
+ }
2406
+ }
2407
+
2408
+ /** 读回本地队列(懒加载;只接受白名单事件名,杜绝外来/损坏内容被转发)。 */
2409
+ function telemetryLoadQueue(relayDir, book) {
2410
+ if (Array.isArray(book.queue)) return book.queue;
2411
+ book.queue = [];
2412
+ try {
2413
+ const raw = JSON.parse(readFileSync(join(relayDir, TELEMETRY_QUEUE_FILE), "utf8"));
2414
+ const list = Array.isArray(raw?.events) ? raw.events : [];
2415
+ book.queue = list.filter((ev) => ev && typeof ev === "object" && TELEMETRY_EVENT_NAMES.has(String(ev.name)));
2416
+ } catch { book.queue = []; }
2417
+ return book.queue;
2418
+ }
2419
+
2420
+ /** 落盘本地队列(0600;写不进去也不影响主流程——退化为只在内存里排队)。 */
2421
+ function telemetrySaveQueue(relayDir, book) {
2422
+ try {
2423
+ if (!telemetryDirReady(relayDir)) return;
2424
+ const file = join(relayDir, TELEMETRY_QUEUE_FILE);
2425
+ writeFileSync(file, JSON.stringify({ v: 1, events: book.queue || [] }), { mode: 0o600 });
2426
+ try { chmodSync(file, 0o600); } catch { /* 非关键 */ }
2427
+ } catch { /* 非关键 */ }
2428
+ }
2429
+
2430
+ /** 从队列移除已送达/已放弃的一批(按引用比较,避免并发写入被误删)。 */
2431
+ function telemetryDrop(relayDir, book, batch) {
2432
+ const queue = telemetryLoadQueue(relayDir, book);
2433
+ for (const ev of batch) {
2434
+ const i = queue.indexOf(ev);
2435
+ if (i >= 0) queue.splice(i, 1);
2436
+ }
2437
+ telemetrySaveQueue(relayDir, book);
2438
+ }
2439
+
2440
+ /** 一次性事件标记(跨进程、跨重启只发一次,如 first_remote_ok)读/写。 */
2441
+ function telemetryOnceFlags(relayDir) {
2442
+ try {
2443
+ const j = JSON.parse(readFileSync(join(relayDir, TELEMETRY_ONCE_FILE), "utf8"));
2444
+ return j && typeof j === "object" ? j : {};
2445
+ } catch { return {}; }
2446
+ }
2447
+ function telemetryMarkOnce(relayDir, name) {
2448
+ try {
2449
+ if (!telemetryDirReady(relayDir)) return;
2450
+ writeFileSync(join(relayDir, TELEMETRY_ONCE_FILE),
2451
+ JSON.stringify({ ...telemetryOnceFlags(relayDir), [name]: Date.now() }), { mode: 0o600 });
2452
+ } catch { /* 非关键 */ }
2453
+ }
2454
+
2455
+ /**
2456
+ * 【唯一的 payload 构造点】把事件名 + 少量上下文编译成一条遥测事件。
2457
+ * 字段仅限契约白名单:name / fail_code / at / version / os / arch / node。
2458
+ * 这里**绝不**写入手机号、邮箱、账号 ID、会话或文件内容、hostname、用户名、文件路径、
2459
+ * 密码/密钥、machine_fp、IP、地理位置等任何可识别信息(见 docs/telemetry.md「不采集什么」)。
2460
+ * 返回 null = 事件名不在白名单 → 调用方一律不发。
2461
+ */
2462
+ function telemetryEventOf(name, extra = {}) {
2463
+ if (!TELEMETRY_EVENT_NAMES.has(name)) return null;
2464
+ const ev = {
2465
+ name,
2466
+ at: Date.now(),
2467
+ version: PLUGIN_VERSION,
2468
+ os: process.platform, // 仅平台名(darwin/linux/win32),非主机名
2469
+ arch: process.arch, // 仅架构(arm64/x64)
2470
+ node: String(process.versions?.node || "").split(".")[0], // 仅主版本号,如 "22"
2471
+ };
2472
+ if (name === "install_failed" || name === "update_failed") {
2473
+ const code = String(extra.fail_code || "");
2474
+ ev.fail_code = TELEMETRY_FAIL_CODES.has(code) ? code : "unknown"; // 白名单外 → unknown
2475
+ }
2476
+ return ev;
2477
+ }
2478
+
2479
+ /** 从既有日志/错误文本归类 fail_code(白名单内);原始文本绝不外发(可能含路径/主机名)。 */
2480
+ function telemetryFailCodeFromText(text) {
2481
+ const t = String(text || "");
2482
+ if (/EACCES|EPERM|permission denied|权限不足/i.test(t)) return "npm_eacces";
2483
+ if (/platform|不支持的平台/i.test(t)) return "platform_unsupported";
2484
+ if (/too old|版本过低|engine/i.test(t)) return "node_too_old";
2485
+ if (/ENOENT|not found|No such file/i.test(t)) return "node_missing";
2486
+ if (/ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ECONNREFUSED|ENETUNREACH|ECONNRESET|network|registry|超时/i.test(t)) {
2487
+ return "npm_unreachable";
2488
+ }
2489
+ return "unknown";
2490
+ }
2491
+ /** 子进程启动失败(spawn error)→ fail_code。 */
2492
+ function telemetryFailCodeFromError(e) {
2493
+ const code = String(e?.code || "");
2494
+ if (code === "ENOENT") return "node_missing"; // 连 node/npx 可执行文件都找不到 = 本机没有 node
2495
+ if (code === "EACCES") return "npm_eacces";
2496
+ return telemetryFailCodeFromText(e?.message);
2497
+ }
2498
+
2499
+ /** 启动发送心跳(懒启动;unref 不阻塞进程退出)。 */
2500
+ function telemetryTickerStart(relayDir) {
2501
+ const book = telemetryBook(relayDir);
2502
+ if (book.timer || book.closed) return;
2503
+ const t = setInterval(() => { void telemetryFlush(relayDir); }, telemetryFlushMs());
2504
+ t.unref?.();
2505
+ book.timer = t;
2506
+ }
2507
+
2508
+ /** 排队一次「立即发送」(下一轮事件循环执行;不阻塞调用方)。 */
2509
+ function telemetryFlushSoon(relayDir) {
2510
+ const book = telemetryBook(relayDir);
2511
+ if (book.kickPending || book.closed || !telemetryEnabled()) return;
2512
+ book.kickPending = true;
2513
+ const t = setTimeout(() => { book.kickPending = false; void telemetryFlush(relayDir); }, 0);
2514
+ t.unref?.();
2515
+ }
2516
+
2517
+ /**
2518
+ * 入队一条事件:白名单 → 队列(上限 200 丢最旧)→ 落盘 → 队列 ≥5 条立即发送,否则等 60s 心跳。
2519
+ * 全程 try/catch 静默:遥测绝不能影响面板 / bridge / 连接流程或用户可见行为。
2520
+ * @returns {boolean} 是否入队
2521
+ */
2522
+ function telemetryRecord(relayDir, name, extra = {}) {
2523
+ try {
2524
+ if (!telemetryEnabled()) return false; // 关闭:不落文件、不入队、不发请求
2525
+ const ev = telemetryEventOf(name, extra);
2526
+ if (!ev) return false; // 白名单外一律不发
2527
+ const book = telemetryBook(relayDir);
2528
+ if (book.closed) return false;
2529
+ const queue = telemetryLoadQueue(relayDir, book);
2530
+ queue.push(ev);
2531
+ if (queue.length > TELEMETRY_QUEUE_MAX) queue.splice(0, queue.length - TELEMETRY_QUEUE_MAX); // 丢最旧
2532
+ telemetrySaveQueue(relayDir, book);
2533
+ telemetryTickerStart(relayDir);
2534
+ if (queue.length >= TELEMETRY_FLUSH_THRESHOLD) telemetryFlushSoon(relayDir);
2535
+ return true;
2536
+ } catch {
2537
+ return false;
2538
+ }
2539
+ }
2540
+
2541
+ /** 每进程只发一次的事件(plugin_loaded / panel_opened / bridge_started / bridge_registered)。 */
2542
+ function telemetryOnce(relayDir, name, extra) {
2543
+ if (!telemetryEnabled()) return false;
2544
+ const book = telemetryBook(relayDir);
2545
+ if (book.fired.has(name)) return false;
2546
+ book.fired.add(name);
2547
+ return telemetryRecord(relayDir, name, extra);
2548
+ }
2549
+
2550
+ /** 跨进程只发一次的事件(first_remote_ok:持久化标记,先落标记再入队,宁可丢一条也不重复)。 */
2551
+ function telemetryOnceEver(relayDir, name, extra) {
2552
+ if (!telemetryEnabled()) return false;
2553
+ if (telemetryOnceFlags(relayDir)[name]) return false;
2554
+ telemetryMarkOnce(relayDir, name);
2555
+ return telemetryRecord(relayDir, name, extra);
2556
+ }
2557
+
2558
+ /** 运行环境就绪(每进程一次)。 */
2559
+ function telemetryRuntimeReady(relayDir) {
2560
+ if (!telemetryEnabled()) return false;
2561
+ const book = telemetryBook(relayDir);
2562
+ if (book.fired.has("runtime_ready")) return false;
2563
+ book.fired.add("runtime_ready");
2564
+ return telemetryRecord(relayDir, "runtime_ready");
2565
+ }
2566
+
2567
+ /**
2568
+ * 运行环境探针(在补装 watcher 与面板轮询里顺手调用,不额外起定时器):
2569
+ * - 现在缺运行环境 → 记下「本进程见过缺失」,并在超过 INSTALL_STALE_MS 仍缺时归因为
2570
+ * install_failed(runtime_install_timeout)(每进程一次);
2571
+ * - 之前见过缺失、现在已就绪 → runtime_ready(每进程一次)。
2572
+ */
2573
+ function telemetryRuntimeProbe(relayDir) {
2574
+ if (!telemetryEnabled()) return;
2575
+ try {
2576
+ const book = telemetryBook(relayDir);
2577
+ if (runtimeReady(relayDir)) {
2578
+ if (book.runtimeMissingSeen) telemetryRuntimeReady(relayDir);
2579
+ return;
2580
+ }
2581
+ book.runtimeMissingSeen = true;
2582
+ if (book.installStartedAt && Date.now() - book.installStartedAt > INSTALL_STALE_MS && !book.fired.has("install-timeout")) {
2583
+ book.fired.add("install-timeout");
2584
+ telemetryRecord(relayDir, "install_failed", { fail_code: "runtime_install_timeout" });
2585
+ }
2586
+ } catch { /* 静默 */ }
2587
+ }
2588
+
2589
+ /**
2590
+ * 「当前隧道是否断连中」判据(仅供遥测):bridge 日志里最后一次「隧道断开」晚于最后一次
2591
+ * 「隧道已连 / ✅ router 注册成功」即为断连中。只看行序,不读任何内容。
2592
+ */
2593
+ function telemetryTunnelDown(relayDir) {
2594
+ const lines = readTail(join(relayDir, BRIDGE_LOG_FILE)).split("\n");
2595
+ let up = -1;
2596
+ let down = -1;
2597
+ for (let i = 0; i < lines.length; i++) {
2598
+ const line = lines[i].trim();
2599
+ if (!line) continue;
2600
+ if (/✅\s*router 注册成功|隧道已连/.test(line)) up = i;
2601
+ else if (/隧道断开/.test(line)) down = i;
2602
+ }
2603
+ return down > up && down >= 0;
2604
+ }
2605
+
2606
+ /** 「本机设备在账号设备表可见」(first_remote_ok 的第二个条件):状态文件 / 日志 / 账号接口三选一。 */
2607
+ function telemetryAccountVisible(relayDir, conn) {
2608
+ const src = String(conn?.registerSource || "");
2609
+ if (src === "account_api" || src === "account" || src === "state_account") return true;
2610
+ const state = bridgeStateFile(relayDir);
2611
+ if (state && Number(state.account_bound_at) > 0) return true; // bridge POST /api/devices 成功留下的证据
2612
+ return Boolean(bridgeLogState(relayDir).accountBound);
2613
+ }
2614
+
2615
+ /**
2616
+ * 连接阶段观察(复用既有阶段引擎,不新造状态机):阶段推进到 starting / online 时各发一次
2617
+ * (每进程每阶段只发一次,避免 2~3s 轮询把事件刷爆),掉线时发一条 tunnel_disconnected
2618
+ * (同一掉线周期去重)。由持有 conn 的路由调用。
2619
+ */
2620
+ function telemetryObserveConnect(relayDir, conn) {
2621
+ if (!telemetryEnabled() || !conn) return;
2622
+ try {
2623
+ const phase = String(conn.phase || "");
2624
+ // bridge_started = bridge 进程被拉起(面板阶段 starting)
2625
+ if (phase === "starting") telemetryOnce(relayDir, "bridge_started");
2626
+ // bridge_registered = 设备已在中继注册成功(面板阶段 online)——这才是「真正可用」
2627
+ if (phase === "online" || conn.registered) telemetryOnce(relayDir, "bridge_registered");
2628
+ // tunnel_disconnected = bridge 掉线:日志里最后一次是「隧道断开」,或注册证据由真变假
2629
+ const book = telemetryBook(relayDir);
2630
+ const dropped = telemetryTunnelDown(relayDir) || (book.wasRegistered === true && !conn.registered);
2631
+ if (dropped) {
2632
+ if (!book.tunnelDown) {
2633
+ book.tunnelDown = true;
2634
+ telemetryRecord(relayDir, "tunnel_disconnected");
2635
+ }
2636
+ } else {
2637
+ book.tunnelDown = false; // 已恢复/未掉线 → 复位,下一轮掉线可再记一条
2638
+ }
2639
+ if (conn.registered) book.wasRegistered = true;
2640
+ // first_remote_ok = 本机首次观察到 online 且本机设备在账号设备表可见(跨进程只发一次)
2641
+ if (phase === "online" && conn.registered && telemetryAccountVisible(relayDir, conn)) {
2642
+ telemetryOnceEver(relayDir, "first_remote_ok");
2643
+ }
2644
+ } catch { /* 静默 */ }
2645
+ }
2646
+
2647
+ /**
2648
+ * 批量发送一批(≤20 条、≤32KB;调用方一律不 await)。成功出队;失败按 30s→2m→10m→1h 退避,
2649
+ * 累计 6 次仍失败丢弃该批——不永久堆积、不阻塞任何主流程。
2650
+ * 匿名:只有 content-type 与 x-dsh-client,**不带 Authorization**(服务端也不接受账号关联)。
2651
+ */
2652
+ async function telemetryFlush(relayDir) {
2653
+ const book = telemetryBook(relayDir);
2654
+ try {
2655
+ if (!telemetryEnabled() || book.closed) return;
2656
+ if (book.sending) return;
2657
+ if (Date.now() < book.nextAt) return; // 退避中:等下一轮心跳到点再看
2658
+ const queue = telemetryLoadQueue(relayDir, book);
2659
+ if (!queue.length) return;
2660
+ const installId = telemetryInstallId(relayDir);
2661
+ if (!installId) return; // install_id 生成失败 → 本次不发
2662
+ const build = (list) => JSON.stringify({ install_id: installId, source: TELEMETRY_SOURCE, events: list });
2663
+ let batch = queue.slice(0, TELEMETRY_BATCH_MAX);
2664
+ let body = build(batch);
2665
+ while (batch.length > 1 && Buffer.byteLength(body, "utf8") > TELEMETRY_BODY_MAX) {
2666
+ batch = batch.slice(0, batch.length - 1); // body 超 32KB:收缩批次(正常远小于该上限)
2667
+ body = build(batch);
2668
+ }
2669
+ if (Buffer.byteLength(body, "utf8") > TELEMETRY_BODY_MAX) {
2670
+ telemetryDrop(relayDir, book, batch); // 单条就超限 → 丢弃该批(防御性)
2671
+ return;
2672
+ }
2673
+ const cfg = loadConfig(relayDir);
2674
+ const api = String(cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
2675
+ book.sending = true;
2676
+ let ok = false;
2677
+ try {
2678
+ const r = await fetch(`${api}/api/telemetry/events`, {
2679
+ method: "POST",
2680
+ headers: {
2681
+ "content-type": "application/json",
2682
+ "x-dsh-client": `dsh-remote/${PLUGIN_VERSION}`, // 契约冻结:dsh-remote/<version>(非 dsh-remote-web)
2683
+ },
2684
+ body,
2685
+ signal: AbortSignal.timeout(8000),
2686
+ });
2687
+ ok = Boolean(r && r.ok);
2688
+ } catch { ok = false; } // 网络失败 → 静默退避(老服务端 404 同理)
2689
+ book.sending = false;
2690
+ if (ok) {
2691
+ telemetryDrop(relayDir, book, batch);
2692
+ book.attempts = 0;
2693
+ book.nextAt = 0;
2694
+ return;
2695
+ }
2696
+ book.attempts += 1;
2697
+ if (book.attempts >= TELEMETRY_MAX_ATTEMPTS) {
2698
+ telemetryDrop(relayDir, book, batch); // 上限 6 次仍失败 → 丢弃该批
2699
+ book.attempts = 0;
2700
+ book.nextAt = 0;
2701
+ return;
2702
+ }
2703
+ book.nextAt = Date.now() + telemetryBackoffMs(book.attempts - 1);
2704
+ } catch {
2705
+ book.sending = false; // 任何异常:静默
2706
+ }
2707
+ }
2708
+
2709
+ /**
2710
+ * 插件装载时的遥测初始化:读回磁盘队列(上次进程遗留的事件不丢,例如重启前那条 harness_restart)
2711
+ * 并启动发送心跳。DSH_REMOTE_TELEMETRY=0 时连读都不读 → 零文件零请求。
2712
+ */
2713
+ function telemetryStart(relayDir) {
2714
+ try {
2715
+ if (!telemetryEnabled()) return;
2716
+ const book = telemetryBook(relayDir);
2717
+ book.closed = false;
2718
+ const queue = telemetryLoadQueue(relayDir, book);
2719
+ telemetryTickerStart(relayDir);
2720
+ if (queue.length) telemetryFlushSoon(relayDir);
2721
+ } catch { /* 静默 */ }
2722
+ }
2723
+
2724
+ /** 插件停摆(dispose)→ 停掉心跳,之后不再调度发送(磁盘队列留给下次装载)。 */
2725
+ function telemetryStop(relayDir) {
2726
+ try {
2727
+ const b = telemetryBooks.get(relayDir);
2728
+ if (!b) return;
2729
+ b.closed = true;
2730
+ if (b.timer) { clearInterval(b.timer); b.timer = null; }
2731
+ } catch { /* 静默 */ }
2732
+ }
2733
+
2734
+ /** 内部接口(仅供本仓库测试与隐私审计;不属于插件对外契约,也不被面板/浏览器半使用)。 */
2735
+ export const __telemetryInternals = {
2736
+ enabled: telemetryEnabled,
2737
+ record: (relayDir, name, extra) => telemetryRecord(relayDir, name, extra),
2738
+ flush: (relayDir) => telemetryFlush(relayDir),
2739
+ installId: (relayDir) => telemetryInstallId(relayDir),
2740
+ queueOf: (relayDir) => telemetryLoadQueue(relayDir, telemetryBook(relayDir)).slice(),
2741
+ eventOf: (name, extra) => telemetryEventOf(name, extra),
2742
+ eventNames: [...TELEMETRY_EVENT_NAMES],
2743
+ failCodes: [...TELEMETRY_FAIL_CODES],
2744
+ queueMax: TELEMETRY_QUEUE_MAX,
2745
+ batchMax: TELEMETRY_BATCH_MAX,
2746
+ bodyMax: TELEMETRY_BODY_MAX,
2747
+ stateOf: (relayDir) => {
2748
+ const b = telemetryBook(relayDir);
2749
+ return { attempts: b.attempts, nextAt: b.nextAt, sending: b.sending, queueLen: telemetryLoadQueue(relayDir, b).length };
2750
+ },
2751
+ reset: (relayDir) => {
2752
+ const b = telemetryBooks.get(relayDir);
2753
+ if (b && b.timer) clearInterval(b.timer);
2754
+ telemetryBooks.delete(relayDir);
2755
+ },
2756
+ };
2757
+
2758
+ /**
2759
+ * 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
2760
+ * 之类的口径统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
2761
+ * 契约:POST <api_url>/api/install-report,头 `authorization: Bearer <device-login JWT>`,
2762
+ * body { device_id?, install_source, install_version, host_os, host_arch },成功 200 {ok:true};
2763
+ * 404/400 一律忽略(老服务端没有该接口时不能报错)。
2764
+ */
2765
+ const INSTALL_REPORT_FILE = ".dsh-install-report.json";
2766
+ const INSTALL_REPORT_TTL_MS = 24 * 3600 * 1000;
2767
+ const INSTALL_SOURCES = new Set(["npx", "plugin_market"]);
2768
+ /** 本机安装来源判据:环境变量 > 插件自愈记录 > 有运行环境即安装器部署 > 默认插件路径。 */
2769
+ function installSourceOf(relayDir) {
2770
+ const env = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim();
2771
+ if (INSTALL_SOURCES.has(env)) return env;
2772
+ if (existsSync(join(relayDir, PROVISIONED_MARKER))) return "plugin_market";
2773
+ if (runtimeReady(relayDir)) return "npx";
2774
+ return "plugin_market";
2775
+ }
2776
+ async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
2777
+ const cfg = cfgOverride || loadConfig(relayDir);
2778
+ if (!hasAccountCreds(cfg)) return { ok: false, skipped: "no_credentials" };
2779
+ const source = installSourceOf(relayDir);
2780
+ const version = PLUGIN_VERSION;
2781
+ const marker = join(relayDir, INSTALL_REPORT_FILE);
2782
+ if (!opts.force) {
2783
+ try {
2784
+ const prev = JSON.parse(readFileSync(marker, "utf8"));
2785
+ if (prev && prev.version === version && prev.source === source && Date.now() - Number(prev.at || 0) < INSTALL_REPORT_TTL_MS) {
2786
+ return { ok: true, skipped: "already_reported" };
2787
+ }
2788
+ } catch { /* 首次上报 */ }
2789
+ }
2790
+ const token = await relayToken(relayDir).catch(() => "");
2791
+ if (!token) return { ok: false, skipped: "no_token" };
2792
+ const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
2793
+ const body = {
2794
+ ...(cfg.device_id ? { device_id: String(cfg.device_id) } : {}),
2795
+ install_source: source,
2796
+ install_version: version,
2797
+ host_os: process.platform,
2798
+ host_arch: process.arch,
2799
+ };
2800
+ try {
2801
+ const r = await fetch(`${api}/api/install-report`, {
2802
+ method: "POST",
2803
+ headers: {
2804
+ authorization: `Bearer ${token}`,
2805
+ "content-type": "application/json",
2806
+ "x-dsh-client": `dsh-remote-web/${version}`,
2807
+ },
2808
+ body: JSON.stringify(body),
2809
+ signal: AbortSignal.timeout(6000),
2810
+ });
2811
+ // 老服务端没有该接口(404)或字段不认(400)→ 忽略,绝不影响 UI
2812
+ if (r.status === 400 || r.status === 404) return { ok: false, skipped: `http_${r.status}` };
2813
+ if (!r.ok) return { ok: false, status: r.status };
2814
+ try { writeFileSync(marker, JSON.stringify({ at: Date.now(), version, source })); } catch { /* 非关键 */ }
2815
+ return { ok: true, status: r.status, install_source: source, install_version: version };
2816
+ } catch (e) {
2817
+ return { ok: false, skipped: `unreachable: ${e.message}` };
2818
+ }
2819
+ }
2820
+ /** 上报节流入口(fire-and-forget):失败最多 60s 再试一次;成功则按 version/source 去重。 */
2821
+ function maybeReportInstall(relayDir, opts = {}) {
2822
+ const book = connectBook(relayDir);
2823
+ const now = Date.now();
2824
+ if (!opts.force && book.reportAt && now - book.reportAt < 60_000) return;
2825
+ book.reportAt = now;
2826
+ // 认证走 relayTokenWithReason(60s token 缓存 + 并发合并),因此启动/登录/轮询多次调用
2827
+ // 也只会打一次 device-login,不会放大企业端认证请求。
2828
+ reportInstallOnce(relayDir, null, opts).catch(() => { /* 静默失败:不影响 UI */ });
2829
+ }
2830
+
1717
2831
  function registerRoutes(ctx, relayDir) {
1718
2832
  // 本插件所在 profile(由插件自身文件位置推导,覆盖市场 git 安装与本地 include 两种形态)
1719
2833
  let profileDir = join(homedir(), ".dsh", "profiles", "web");
@@ -1793,9 +2907,51 @@ function registerRoutes(ctx, relayDir) {
1793
2907
  method: "GET",
1794
2908
  path: "/dsh-remote/status",
1795
2909
  handler: async (_req, res) => {
2910
+ // 按需补齐浏览器会话 Cookie(不 await:状态接口不能被本地换 Cookie 拖慢)。
2911
+ // 面板轮询会持续触达本接口 → 手机端的 401 会在秒级内自愈。
2912
+ void ensureHarnessCookie(ctx, relayDir).catch(() => {});
1796
2913
  sendJson(res, 200, await composeStatus(relayDir));
1797
2914
  },
1798
2915
  },
2916
+ // 连接阶段短轮询端点(面板 2~3s 一次,非 online 时自动推进;online 后退避到 15s)。
2917
+ // 返回前先执行自动闭环动作(缺环境→补装 / 有账号没进程→拉起 / 卡死标记→清理),
2918
+ // 于是「登录后自动连上」不需要任何按钮或刷新;每步都自带退避,失败不会打成风暴。
2919
+ {
2920
+ method: "GET",
2921
+ path: "/dsh-remote/bridge-status",
2922
+ handler: async (_req, res) => {
2923
+ ensureConnection(relayDir);
2924
+ maybeReportInstall(relayDir);
2925
+ void ensureHarnessCookie(ctx, relayDir).catch(() => {}); // 同上:手机端授权会话自愈
2926
+ telemetryRuntimeProbe(relayDir); // 匿名遥测:运行环境刚补齐 → runtime_ready(不额外起定时器)
2927
+ const connect = await composeConnect(relayDir);
2928
+ telemetryObserveConnect(relayDir, connect); // 匿名遥测:bridge_started / bridge_registered / 掉线
2929
+ sendJson(res, 200, { ok: true, connect });
2930
+ },
2931
+ },
2932
+ // 面板打开(浏览器半在「远程访问」栏目首次渲染时调一次):每进程只记一次。
2933
+ // 只发事件名,不含任何账号/内容/设备信息;DSH_REMOTE_TELEMETRY=0 时静默丢弃。
2934
+ {
2935
+ method: "POST",
2936
+ path: "/dsh-remote/telemetry/panel-opened",
2937
+ handler: async (_req, res) => {
2938
+ telemetryOnce(relayDir, "panel_opened");
2939
+ sendJson(res, 200, { ok: true });
2940
+ },
2941
+ },
2942
+ // 手动重试(error 阶段的「重试」按钮):清掉退避计数立刻再走一遍闭环。
2943
+ {
2944
+ method: "POST",
2945
+ path: "/dsh-remote/connect/retry",
2946
+ handler: async (_req, res) => {
2947
+ const action = ensureConnection(relayDir, { force: true });
2948
+ maybeReportInstall(relayDir, { force: true });
2949
+ await sleep(600); // 给 launchctl 一点进入运行态的时间,重试点下去立刻能看到「正在启动 Bridge…」
2950
+ const connect = await composeConnect(relayDir);
2951
+ telemetryObserveConnect(relayDir, connect); // 匿名遥测:同 bridge-status(去重后不会重复计数)
2952
+ sendJson(res, 200, { ok: true, retried: true, action: action && action.action ? action.action : "none", connect });
2953
+ },
2954
+ },
1799
2955
  {
1800
2956
  method: "GET",
1801
2957
  path: "/dsh-remote/account",
@@ -1919,7 +3075,10 @@ function registerRoutes(ctx, relayDir) {
1919
3075
  }
1920
3076
  }
1921
3077
  saveConfig(relayDir, cfg);
3078
+ invalidateRelayToken(relayDir); // 账号可能变了:丢弃旧 token 缓存,立即用新账号认证
1922
3079
  const bridgeRestart = startBridge(relayDir);
3080
+ // 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」口径);失败静默。
3081
+ maybeReportInstall(relayDir, { force: true });
1923
3082
  sendJson(res, 200, { ok: true, bridgeRestart, ...(await composeStatus(relayDir)) });
1924
3083
  },
1925
3084
  },
@@ -1932,6 +3091,7 @@ function registerRoutes(ctx, relayDir) {
1932
3091
  delete cfg.phone;
1933
3092
  delete cfg.password;
1934
3093
  saveConfig(relayDir, cfg);
3094
+ invalidateRelayToken(relayDir); // 退出登录:token 缓存立即失效
1935
3095
  sendJson(res, 200, { ok: true, ...(await composeStatus(relayDir)) });
1936
3096
  },
1937
3097
  },
@@ -2053,7 +3213,10 @@ function registerRoutes(ctx, relayDir) {
2053
3213
  const smsCode = String(body.sms_code ?? "").trim();
2054
3214
  const password = String(body.password ?? "");
2055
3215
  if (!phone || !smsCode || !password) return sendJson(res, 400, { ok: false, error: "手机号、短信验证码与密码必填" });
2056
- const payload = { phone, sms_code: smsCode, password };
3216
+ // 全量透传 body(不挑字段):注册来源 reg_source(面板=panel_register/手机端网页=remoteweb_register)
3217
+ // 等扩展字段必须原样进企业端,否则「用户从哪注册的」这类增长口径会全部丢失。
3218
+ const payload = { ...body, phone, sms_code: smsCode, password };
3219
+ delete payload.__parseError;
2057
3220
  const captchaId = body.captcha_id ?? body.captchaId;
2058
3221
  const captchaAnswer = body.captcha_answer ?? body.captcha;
2059
3222
  if (captchaId !== void 0) payload.captcha_id = String(captchaId);
@@ -2163,6 +3326,11 @@ export function apply(ctx, config = {}) {
2163
3326
  const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
2164
3327
  // 全新激活(dsh web 重启后插件重新加载,或卸载后再次安装)→ 解除上次的「已卸载」停摆标记
2165
3328
  UNINSTALLED_DIRS.delete(relayDir);
3329
+ // 匿名遥测:装载即初始化(读回上次进程遗留的磁盘队列,例如重启前那条 harness_restart,
3330
+ // 并启动 60s 发送心跳),随后记一条 plugin_loaded(每进程一次)。
3331
+ // DSH_REMOTE_TELEMETRY=0 → 这里连读都不读:零文件、零请求、零 install_id。
3332
+ telemetryStart(relayDir);
3333
+ telemetryOnce(relayDir, "plugin_loaded");
2166
3334
  // 清理上次进程残留的安装/更新 marker(宿主被重启/强杀时子进程清理回调会丢失)
2167
3335
  sweepStaleMarkers(relayDir);
2168
3336
  // 「待重启」状态结清 + 运行时安装检测:
@@ -2174,20 +3342,30 @@ export function apply(ctx, config = {}) {
2174
3342
  ctx.logger?.info?.("dsh-remote-web: 检测到 DeepSeek harness 已重启,撤下「需要重启」提示");
2175
3343
  }
2176
3344
  if (!readRestartState(relayDir).pending && pluginInstalledAfterBoot()) {
2177
- markRestartPending(relayDir, "first-install", "首次安装需要重启 DeepSeek harness");
2178
- ctx.logger?.info?.("dsh-remote-web: 插件文件晚于本次进程启动 → 需要重启 DeepSeek harness");
3345
+ // 唯一还需要用户动一下的情形:插件文件晚于本进程启动才落盘(= 运行中被市场安装/在线更新改写),
3346
+ // 此时本进程里没有本插件的路由与面板。**先让用户刷新页面**(客户端半随之更新);
3347
+ // 只有刷新后仍无面板才需要重启(那时多半是 profile patch 行没被 HMR 读到)。
3348
+ // 不再对"首次安装/在线更新完成"提示重启:插件走 patch 热加载、bridge 是独立进程,重启 harness 无必要。
3349
+ markRestartPending(relayDir, "refresh", "插件已更新,刷新页面即可生效");
3350
+ ctx.logger?.info?.("dsh-remote-web: 插件文件晚于本次进程启动 → 提示刷新页面");
2179
3351
  }
2180
3352
  ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-web: /dsh-remote routes");
2181
3353
  // 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
2182
3354
  ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
2183
3355
  // 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
2184
3356
  ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-web: runtime self-provision");
3357
+ // 匿名遥测:插件停摆(卸载/重载)时停掉发送心跳;磁盘队列留给下次装载补发。
3358
+ ctx.effect(() => () => telemetryStop(relayDir), "dsh-remote-web: telemetry flush ticker");
2185
3359
  // 【0.6.2】市场安装路径的「装到用户电脑上」第一步:插件加载即后台补装桌面运行环境
2186
3360
  // (bridge + 自启动),不再等用户先登录、也不再依赖 launchd 状态判断是否「已在运行」。
2187
3361
  // 已就绪时是零副作用 no-op;正在安装中/已卸载时 ensureRuntime 自身幂等挡下。
2188
3362
  const provisioned = ensureRuntime(relayDir);
2189
3363
  if (!provisioned && !runtimeReady(relayDir)) {
2190
3364
  ctx.logger?.info?.(`dsh-remote-web: 桌面运行环境缺失,已在后台自动安装(relayDir=${relayDir})`);
3365
+ telemetryRuntimeProbe(relayDir); // 匿名遥测:记下「本进程见过运行环境缺失」(装好后发 runtime_ready)
2191
3366
  }
3367
+ // 插件启动即上报本机安装信息(install_source/version + 主机 OS/架构;仅有账号凭据时上报,失败静默)。
3368
+ // 面板登录成功(POST /dsh-remote/config)时会再上报一次,覆盖「已登录用户升级插件」的口径。
3369
+ maybeReportInstall(relayDir);
2192
3370
  ctx.logger?.info?.(`dsh-remote-web: /dsh-remote routes ready (relayDir=${relayDir})`);
2193
3371
  }