@mrrisega/dsh-remote 0.6.4-beta.1 → 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.
- package/README.md +55 -0
- package/clients/dsh-remote/dsh-bridge.mjs +32 -1
- package/clients/dsh-remote/test/harness-cookie-selfheal.test.mjs +118 -0
- package/docs/telemetry.md +145 -0
- package/dsh-setup.mjs +533 -96
- package/package.json +4 -3
- package/packages/dsh-remote-web/lib/client.js +110 -6
- package/packages/dsh-remote-web/lib/index.js +637 -25
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/auto-restart.test.mjs +303 -0
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +106 -40
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +78 -0
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +183 -0
- package/packages/dsh-remote-web/test/setup-output.test.mjs +111 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +607 -0
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, statSync, constants as fsConstants } from "node:fs";
|
|
24
24
|
import { join, dirname, sep } from "node:path";
|
|
25
25
|
import { execSync, spawn } from "node:child_process";
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
26
27
|
import { homedir, platform } from "node:os";
|
|
27
28
|
import { fileURLToPath } from "node:url";
|
|
28
29
|
|
|
@@ -228,9 +229,20 @@ function applySaaSMode(cfg) {
|
|
|
228
229
|
* 老版本(无该鉴权)下 connection 服务没有 authenticatedUrl → 静默跳过,行为不变。
|
|
229
230
|
*/
|
|
230
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
|
+
*/
|
|
231
241
|
const HARNESS_AUTH_RETRY_MS = 3000;
|
|
232
|
-
const
|
|
233
|
-
const HARNESS_AUTH_REFRESH_MS =
|
|
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 时写的标记
|
|
234
246
|
|
|
235
247
|
/** 读取响应里的 set-cookie(兼容 getSetCookie / get 两种实现)。 */
|
|
236
248
|
function setCookieOf(res) {
|
|
@@ -270,24 +282,63 @@ async function mintHarnessCookie(ctx, relayDir) {
|
|
|
270
282
|
}
|
|
271
283
|
}
|
|
272
284
|
|
|
273
|
-
/**
|
|
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 分钟主动刷新(与插件生命周期同进退)。 */
|
|
274
332
|
function scheduleHarnessMint(ctx, relayDir) {
|
|
275
|
-
|
|
276
|
-
let retries = 0;
|
|
277
|
-
const attempt = async () => {
|
|
278
|
-
if (succeeded) return;
|
|
279
|
-
if (await mintHarnessCookie(ctx, relayDir)) succeeded = true;
|
|
280
|
-
};
|
|
333
|
+
const startedAt = Date.now();
|
|
281
334
|
const bootIv = setInterval(() => {
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
void attempt();
|
|
335
|
+
if (Date.now() - startedAt > HARNESS_AUTH_RETRY_WINDOW_MS) { clearInterval(bootIv); return; }
|
|
336
|
+
void ensureHarnessCookie(ctx, relayDir, { force: true }).catch(() => {});
|
|
287
337
|
}, HARNESS_AUTH_RETRY_MS);
|
|
288
338
|
bootIv.unref?.();
|
|
339
|
+
void ensureHarnessCookie(ctx, relayDir, { force: true }).catch(() => {}); // 立刻来一次
|
|
289
340
|
const refreshIv = setInterval(() => {
|
|
290
|
-
void
|
|
341
|
+
void ensureHarnessCookie(ctx, relayDir).catch(() => {});
|
|
291
342
|
}, HARNESS_AUTH_REFRESH_MS);
|
|
292
343
|
refreshIv.unref?.();
|
|
293
344
|
return () => {
|
|
@@ -472,14 +523,22 @@ function ensureRuntime(relayDir) {
|
|
|
472
523
|
JSON.stringify({ at: Date.now(), version: PLUGIN_VERSION, source: "plugin_market" }));
|
|
473
524
|
} catch { /* 非关键:上报时按缺省判据降级 */ }
|
|
474
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");
|
|
475
531
|
child.on("exit", (code) => {
|
|
476
532
|
clear();
|
|
477
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)) });
|
|
478
536
|
});
|
|
479
537
|
child.on("error", (e) => {
|
|
480
538
|
clear();
|
|
481
539
|
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动失败: ${e.message}`);
|
|
482
540
|
console.warn(`[dsh-remote-web] 自动安装子进程启动失败: ${e.message}`);
|
|
541
|
+
telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
483
542
|
});
|
|
484
543
|
child.unref();
|
|
485
544
|
console.log(`[dsh-remote-web] 检测到缺少桌面运行环境,已在后台自动安装(日志: ${log}),完成后将自动启动 bridge`);
|
|
@@ -487,6 +546,7 @@ function ensureRuntime(relayDir) {
|
|
|
487
546
|
} catch (e) {
|
|
488
547
|
console.warn(`[dsh-remote-web] 自动安装启动失败: ${e.message}`);
|
|
489
548
|
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
549
|
+
telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
490
550
|
return false;
|
|
491
551
|
}
|
|
492
552
|
}
|
|
@@ -611,12 +671,17 @@ function scheduleRuntime(relayDir) {
|
|
|
611
671
|
if (!runtimeReady(relayDir)) {
|
|
612
672
|
reapBrokenAutostart(relayDir); // 先摘掉指向不存在脚本的自启动,止住崩溃循环
|
|
613
673
|
ensureRuntime(relayDir); // 后台 npx 补齐(marker 防重入);就绪后下一轮自动拉起
|
|
674
|
+
telemetryRuntimeProbe(relayDir); // 匿名遥测:补装超时归类(runtime_install_timeout)
|
|
614
675
|
return;
|
|
615
676
|
}
|
|
677
|
+
telemetryRuntimeProbe(relayDir); // 匿名遥测:环境刚补齐 → runtime_ready(每进程一次)
|
|
616
678
|
if (awaitingRestartHint) {
|
|
617
679
|
awaitingRestartHint = false;
|
|
618
|
-
//
|
|
619
|
-
|
|
680
|
+
// 运行环境刚在本进程内补齐 —— **不需要重启 harness**:
|
|
681
|
+
// · bridge 是独立 launchd 进程,补齐后自愈调度会直接把它拉起;
|
|
682
|
+
// · 插件本体走 patch 热加载,装完即生效。
|
|
683
|
+
// 以前这里会 markRestartPending("first-install") 弹出"需要重启 DeepSeek harness"横幅,
|
|
684
|
+
// 在热加载落地后已经变成纯粹的误导(用户实测反馈),故移除。
|
|
620
685
|
}
|
|
621
686
|
const cfg = loadConfig(relayDir);
|
|
622
687
|
const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
|
|
@@ -787,7 +852,10 @@ function writeRestartState(relayDir, state) {
|
|
|
787
852
|
} catch { /* 写盘失败不致命:面板退化为不提示 */ }
|
|
788
853
|
}
|
|
789
854
|
|
|
790
|
-
/**
|
|
855
|
+
/**
|
|
856
|
+
* 标记「需要用户动一下」的状态。kind = refresh(插件被运行时改写,刷新页面即可)。
|
|
857
|
+
* 历史 kind(first-install / update)已废弃:热加载落地后,装插件与在线更新都不再需要重启 harness。
|
|
858
|
+
*/
|
|
791
859
|
function markRestartPending(relayDir, kind, reason) {
|
|
792
860
|
const cur = readRestartState(relayDir);
|
|
793
861
|
if (cur.pending && cur.kind === kind) return cur; // 幂等:同一原因不反复重写
|
|
@@ -819,7 +887,15 @@ function pluginInstalledAfterBoot() {
|
|
|
819
887
|
const pkgFile = join(dirname(dirname(here)), "package.json");
|
|
820
888
|
const mtimeMs = statSync(pkgFile).mtimeMs;
|
|
821
889
|
const bootMs = Date.now() - process.uptime() * 1000;
|
|
822
|
-
|
|
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);
|
|
823
899
|
} catch { return false; }
|
|
824
900
|
}
|
|
825
901
|
|
|
@@ -890,6 +966,10 @@ function restartHarness(relayDir, opts = {}) {
|
|
|
890
966
|
if (unit) { mode = "systemd"; target = unit; }
|
|
891
967
|
}
|
|
892
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 秒后就会被替换:尽量当下把这条发出去(发不出去留盘上,下次装载补发)
|
|
893
973
|
if (opts.dryRun || process.env.DSH_RELAY_RESTART_DRYRUN === "1") {
|
|
894
974
|
// 测试/诊断用:只返回将要执行的脚本,不做任何真实动作
|
|
895
975
|
return { ok: true, status: "dry-run", mode, target, script, log: join(relayDir, RESTART_LOG_FILE) };
|
|
@@ -1673,7 +1753,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
1673
1753
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1674
1754
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1675
1755
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
1676
|
-
const PLUGIN_VERSION = "0.6.4-beta.
|
|
1756
|
+
const PLUGIN_VERSION = "0.6.4-beta.10";
|
|
1677
1757
|
const UPDATE_LOG = ".dsh-update.log";
|
|
1678
1758
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
1679
1759
|
|
|
@@ -1726,6 +1806,7 @@ function runOnlineUpdate(relayDir) {
|
|
|
1726
1806
|
const marker = join(relayDir, UPDATE_MARKER);
|
|
1727
1807
|
if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
|
|
1728
1808
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 ${UPDATE_SPEC} (${new Date().toISOString()})`);
|
|
1809
|
+
telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
|
|
1729
1810
|
|
|
1730
1811
|
let retried = false;
|
|
1731
1812
|
const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
|
|
@@ -1742,12 +1823,17 @@ function runOnlineUpdate(relayDir) {
|
|
|
1742
1823
|
}
|
|
1743
1824
|
appendLogLine(relayDir, UPDATE_LOG, `[update] npx 退出 code=${code ?? "?"}(${retried ? "默认源" : "官方源"})`);
|
|
1744
1825
|
clear();
|
|
1745
|
-
// 在线更新改写了插件/运行环境文件 →
|
|
1746
|
-
|
|
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))) });
|
|
1747
1832
|
});
|
|
1748
1833
|
child.on("error", (e) => {
|
|
1749
1834
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
|
|
1750
1835
|
clear();
|
|
1836
|
+
telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
1751
1837
|
});
|
|
1752
1838
|
child.unref();
|
|
1753
1839
|
return child;
|
|
@@ -1756,6 +1842,7 @@ function runOnlineUpdate(relayDir) {
|
|
|
1756
1842
|
return { ok: true, pid: child.pid, log: join(relayDir, UPDATE_LOG) };
|
|
1757
1843
|
} catch (e) {
|
|
1758
1844
|
try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
|
|
1845
|
+
telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
1759
1846
|
return { ok: false, detail: String(e.message || e) };
|
|
1760
1847
|
}
|
|
1761
1848
|
}
|
|
@@ -2173,6 +2260,501 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
2173
2260
|
return { action: "start", result: r };
|
|
2174
2261
|
}
|
|
2175
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
|
+
|
|
2176
2758
|
/**
|
|
2177
2759
|
* 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
|
|
2178
2760
|
* 之类的口径统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
|
|
@@ -2325,6 +2907,9 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2325
2907
|
method: "GET",
|
|
2326
2908
|
path: "/dsh-remote/status",
|
|
2327
2909
|
handler: async (_req, res) => {
|
|
2910
|
+
// 按需补齐浏览器会话 Cookie(不 await:状态接口不能被本地换 Cookie 拖慢)。
|
|
2911
|
+
// 面板轮询会持续触达本接口 → 手机端的 401 会在秒级内自愈。
|
|
2912
|
+
void ensureHarnessCookie(ctx, relayDir).catch(() => {});
|
|
2328
2913
|
sendJson(res, 200, await composeStatus(relayDir));
|
|
2329
2914
|
},
|
|
2330
2915
|
},
|
|
@@ -2337,7 +2922,21 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2337
2922
|
handler: async (_req, res) => {
|
|
2338
2923
|
ensureConnection(relayDir);
|
|
2339
2924
|
maybeReportInstall(relayDir);
|
|
2340
|
-
|
|
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 });
|
|
2341
2940
|
},
|
|
2342
2941
|
},
|
|
2343
2942
|
// 手动重试(error 阶段的「重试」按钮):清掉退避计数立刻再走一遍闭环。
|
|
@@ -2349,6 +2948,7 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2349
2948
|
maybeReportInstall(relayDir, { force: true });
|
|
2350
2949
|
await sleep(600); // 给 launchctl 一点进入运行态的时间,重试点下去立刻能看到「正在启动 Bridge…」
|
|
2351
2950
|
const connect = await composeConnect(relayDir);
|
|
2951
|
+
telemetryObserveConnect(relayDir, connect); // 匿名遥测:同 bridge-status(去重后不会重复计数)
|
|
2352
2952
|
sendJson(res, 200, { ok: true, retried: true, action: action && action.action ? action.action : "none", connect });
|
|
2353
2953
|
},
|
|
2354
2954
|
},
|
|
@@ -2726,6 +3326,11 @@ export function apply(ctx, config = {}) {
|
|
|
2726
3326
|
const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
|
|
2727
3327
|
// 全新激活(dsh web 重启后插件重新加载,或卸载后再次安装)→ 解除上次的「已卸载」停摆标记
|
|
2728
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");
|
|
2729
3334
|
// 清理上次进程残留的安装/更新 marker(宿主被重启/强杀时子进程清理回调会丢失)
|
|
2730
3335
|
sweepStaleMarkers(relayDir);
|
|
2731
3336
|
// 「待重启」状态结清 + 运行时安装检测:
|
|
@@ -2737,20 +3342,27 @@ export function apply(ctx, config = {}) {
|
|
|
2737
3342
|
ctx.logger?.info?.("dsh-remote-web: 检测到 DeepSeek harness 已重启,撤下「需要重启」提示");
|
|
2738
3343
|
}
|
|
2739
3344
|
if (!readRestartState(relayDir).pending && pluginInstalledAfterBoot()) {
|
|
2740
|
-
|
|
2741
|
-
|
|
3345
|
+
// 唯一还需要用户动一下的情形:插件文件晚于本进程启动才落盘(= 运行中被市场安装/在线更新改写),
|
|
3346
|
+
// 此时本进程里没有本插件的路由与面板。**先让用户刷新页面**(客户端半随之更新);
|
|
3347
|
+
// 只有刷新后仍无面板才需要重启(那时多半是 profile patch 行没被 HMR 读到)。
|
|
3348
|
+
// 不再对"首次安装/在线更新完成"提示重启:插件走 patch 热加载、bridge 是独立进程,重启 harness 无必要。
|
|
3349
|
+
markRestartPending(relayDir, "refresh", "插件已更新,刷新页面即可生效");
|
|
3350
|
+
ctx.logger?.info?.("dsh-remote-web: 插件文件晚于本次进程启动 → 提示刷新页面");
|
|
2742
3351
|
}
|
|
2743
3352
|
ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-web: /dsh-remote routes");
|
|
2744
3353
|
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
2745
3354
|
ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
|
|
2746
3355
|
// 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
|
|
2747
3356
|
ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-web: runtime self-provision");
|
|
3357
|
+
// 匿名遥测:插件停摆(卸载/重载)时停掉发送心跳;磁盘队列留给下次装载补发。
|
|
3358
|
+
ctx.effect(() => () => telemetryStop(relayDir), "dsh-remote-web: telemetry flush ticker");
|
|
2748
3359
|
// 【0.6.2】市场安装路径的「装到用户电脑上」第一步:插件加载即后台补装桌面运行环境
|
|
2749
3360
|
// (bridge + 自启动),不再等用户先登录、也不再依赖 launchd 状态判断是否「已在运行」。
|
|
2750
3361
|
// 已就绪时是零副作用 no-op;正在安装中/已卸载时 ensureRuntime 自身幂等挡下。
|
|
2751
3362
|
const provisioned = ensureRuntime(relayDir);
|
|
2752
3363
|
if (!provisioned && !runtimeReady(relayDir)) {
|
|
2753
3364
|
ctx.logger?.info?.(`dsh-remote-web: 桌面运行环境缺失,已在后台自动安装(relayDir=${relayDir})`);
|
|
3365
|
+
telemetryRuntimeProbe(relayDir); // 匿名遥测:记下「本进程见过运行环境缺失」(装好后发 runtime_ready)
|
|
2754
3366
|
}
|
|
2755
3367
|
// 插件启动即上报本机安装信息(install_source/version + 主机 OS/架构;仅有账号凭据时上报,失败静默)。
|
|
2756
3368
|
// 面板登录成功(POST /dsh-remote/config)时会再上报一次,覆盖「已登录用户升级插件」的口径。
|