@mrrisega/dsh-remote 0.6.4-beta.3 → 0.6.4-beta.4
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 +54 -0
- package/docs/telemetry.md +145 -0
- package/dsh-setup.mjs +55 -32
- package/package.json +4 -3
- package/packages/dsh-remote-web/lib/client.js +32 -0
- package/packages/dsh-remote-web/lib/index.js +539 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/setup-output.test.mjs +72 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +600 -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
|
|
|
@@ -522,14 +523,22 @@ function ensureRuntime(relayDir) {
|
|
|
522
523
|
JSON.stringify({ at: Date.now(), version: PLUGIN_VERSION, source: "plugin_market" }));
|
|
523
524
|
} catch { /* 非关键:上报时按缺省判据降级 */ }
|
|
524
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");
|
|
525
531
|
child.on("exit", (code) => {
|
|
526
532
|
clear();
|
|
527
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)) });
|
|
528
536
|
});
|
|
529
537
|
child.on("error", (e) => {
|
|
530
538
|
clear();
|
|
531
539
|
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动失败: ${e.message}`);
|
|
532
540
|
console.warn(`[dsh-remote-web] 自动安装子进程启动失败: ${e.message}`);
|
|
541
|
+
telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
533
542
|
});
|
|
534
543
|
child.unref();
|
|
535
544
|
console.log(`[dsh-remote-web] 检测到缺少桌面运行环境,已在后台自动安装(日志: ${log}),完成后将自动启动 bridge`);
|
|
@@ -537,6 +546,7 @@ function ensureRuntime(relayDir) {
|
|
|
537
546
|
} catch (e) {
|
|
538
547
|
console.warn(`[dsh-remote-web] 自动安装启动失败: ${e.message}`);
|
|
539
548
|
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
549
|
+
telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
540
550
|
return false;
|
|
541
551
|
}
|
|
542
552
|
}
|
|
@@ -661,8 +671,10 @@ function scheduleRuntime(relayDir) {
|
|
|
661
671
|
if (!runtimeReady(relayDir)) {
|
|
662
672
|
reapBrokenAutostart(relayDir); // 先摘掉指向不存在脚本的自启动,止住崩溃循环
|
|
663
673
|
ensureRuntime(relayDir); // 后台 npx 补齐(marker 防重入);就绪后下一轮自动拉起
|
|
674
|
+
telemetryRuntimeProbe(relayDir); // 匿名遥测:补装超时归类(runtime_install_timeout)
|
|
664
675
|
return;
|
|
665
676
|
}
|
|
677
|
+
telemetryRuntimeProbe(relayDir); // 匿名遥测:环境刚补齐 → runtime_ready(每进程一次)
|
|
666
678
|
if (awaitingRestartHint) {
|
|
667
679
|
awaitingRestartHint = false;
|
|
668
680
|
// 首次安装:运行环境(bridge + 自启动)刚在本进程内补齐 → 提示重启,重启后即完全可用
|
|
@@ -940,6 +952,10 @@ function restartHarness(relayDir, opts = {}) {
|
|
|
940
952
|
if (unit) { mode = "systemd"; target = unit; }
|
|
941
953
|
}
|
|
942
954
|
const script = buildRestartScript(mode, target, cmd, relayDir);
|
|
955
|
+
// 匿名遥测:重启 DeepSeek harness(自动倒计时或手动按钮都经这里)——触发一次记一条。
|
|
956
|
+
// 放在 dry-run 之前:测试/诊断用的 dry-run 也走同一条入口,便于用例锁死这条埋点。
|
|
957
|
+
telemetryRecord(relayDir, "harness_restart");
|
|
958
|
+
telemetryFlushSoon(relayDir); // 本进程约 1~2 秒后就会被替换:尽量当下把这条发出去(发不出去留盘上,下次装载补发)
|
|
943
959
|
if (opts.dryRun || process.env.DSH_RELAY_RESTART_DRYRUN === "1") {
|
|
944
960
|
// 测试/诊断用:只返回将要执行的脚本,不做任何真实动作
|
|
945
961
|
return { ok: true, status: "dry-run", mode, target, script, log: join(relayDir, RESTART_LOG_FILE) };
|
|
@@ -1723,7 +1739,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
1723
1739
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1724
1740
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1725
1741
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
1726
|
-
const PLUGIN_VERSION = "0.6.4-beta.
|
|
1742
|
+
const PLUGIN_VERSION = "0.6.4-beta.4";
|
|
1727
1743
|
const UPDATE_LOG = ".dsh-update.log";
|
|
1728
1744
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
1729
1745
|
|
|
@@ -1776,6 +1792,7 @@ function runOnlineUpdate(relayDir) {
|
|
|
1776
1792
|
const marker = join(relayDir, UPDATE_MARKER);
|
|
1777
1793
|
if (existsSync(marker)) return { ok: false, detail: "已有更新在进行中,请稍候" };
|
|
1778
1794
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 ${UPDATE_SPEC} (${new Date().toISOString()})`);
|
|
1795
|
+
telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
|
|
1779
1796
|
|
|
1780
1797
|
let retried = false;
|
|
1781
1798
|
const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
|
|
@@ -1794,10 +1811,12 @@ function runOnlineUpdate(relayDir) {
|
|
|
1794
1811
|
clear();
|
|
1795
1812
|
// 在线更新改写了插件/运行环境文件 → 必须重启 DeepSeek harness 才装载新版本
|
|
1796
1813
|
if (code === 0) markRestartPending(relayDir, "update", "已在线更新,需要重启 DeepSeek harness 生效");
|
|
1814
|
+
else telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromText(readTail(join(relayDir, UPDATE_LOG))) });
|
|
1797
1815
|
});
|
|
1798
1816
|
child.on("error", (e) => {
|
|
1799
1817
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
|
|
1800
1818
|
clear();
|
|
1819
|
+
telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
1801
1820
|
});
|
|
1802
1821
|
child.unref();
|
|
1803
1822
|
return child;
|
|
@@ -1806,6 +1825,7 @@ function runOnlineUpdate(relayDir) {
|
|
|
1806
1825
|
return { ok: true, pid: child.pid, log: join(relayDir, UPDATE_LOG) };
|
|
1807
1826
|
} catch (e) {
|
|
1808
1827
|
try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
|
|
1828
|
+
telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
1809
1829
|
return { ok: false, detail: String(e.message || e) };
|
|
1810
1830
|
}
|
|
1811
1831
|
}
|
|
@@ -2223,6 +2243,501 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
2223
2243
|
return { action: "start", result: r };
|
|
2224
2244
|
}
|
|
2225
2245
|
|
|
2246
|
+
// ---------- 匿名装机/连接遥测(客户端半;契约与隐私边界见 docs/telemetry.md) ----------
|
|
2247
|
+
//
|
|
2248
|
+
// 背景(2026-09 生产诊断):11 个新注册用户里只有 3 个把设备连上——6 人电脑端从未安装、2 人装了但
|
|
2249
|
+
// bridge 没连上。而「装不上」的人没有任何账号、也就没有任何数据,「本地 OK、新机器失败」因此无法归因。
|
|
2250
|
+
// 本通道只补这一段装机/连接事实,且必须守住开源项目的隐私边界:
|
|
2251
|
+
//
|
|
2252
|
+
// · 完全匿名:不带 Authorization、不带任何账号标识;install_id 是本机随机 UUID(非硬件派生、
|
|
2253
|
+
// 重装即变,不可跨机器关联);
|
|
2254
|
+
// · 只发白名单事件(TELEMETRY_EVENT_NAMES)与白名单 fail_code(TELEMETRY_FAIL_CODES),
|
|
2255
|
+
// 原始错误文本一律不透传(它可能含路径/主机名);
|
|
2256
|
+
// · 允许的字段**只有**:install_id / 事件名 / fail_code / 版本号 / os(process.platform) /
|
|
2257
|
+
// arch(process.arch) / node(仅主版本号)——唯一构造点是 telemetryEventOf();
|
|
2258
|
+
// · 禁止采集(代码与 docs/telemetry.md 双写死):手机号、邮箱、账号 ID、任何会话内容或文件内容、
|
|
2259
|
+
// 真实 hostname / 用户名 / 文件路径、密码与密钥、设备指纹(machine_fp)、原始 IP、精确地理位置;
|
|
2260
|
+
// · 可关闭:DSH_REMOTE_TELEMETRY=0 → 完全关闭(不生成 install_id、不落任何文件、不发任何请求);
|
|
2261
|
+
// · 全程静默:任何异常都被吞掉,绝不影响面板 / bridge / 连接流程与用户可见行为。
|
|
2262
|
+
//
|
|
2263
|
+
// 契约(服务端冻结):POST <api_url>/api/telemetry/events,headers
|
|
2264
|
+
// { content-type: application/json, x-dsh-client: dsh-remote/<PLUGIN_VERSION> }(无 Authorization),
|
|
2265
|
+
// body { install_id, source: "plugin", events: [{ name, at, version, os, arch, node, fail_code? }] },
|
|
2266
|
+
// 单批 ≤ 20 条、body ≤ 32KB。
|
|
2267
|
+
|
|
2268
|
+
/** 事件名白名单:只用这些,其它一律不发。 */
|
|
2269
|
+
const TELEMETRY_EVENT_NAMES = new Set([
|
|
2270
|
+
"install_started", "install_failed", "runtime_ready", "bridge_started", "bridge_registered",
|
|
2271
|
+
"tunnel_disconnected", "first_remote_ok", "plugin_loaded", "panel_opened", "harness_restart",
|
|
2272
|
+
"update_started", "update_failed",
|
|
2273
|
+
]);
|
|
2274
|
+
/** fail_code 白名单(白名单外的分类一律落 unknown)。 */
|
|
2275
|
+
const TELEMETRY_FAIL_CODES = new Set([
|
|
2276
|
+
"node_missing", "node_too_old", "npm_unreachable", "npm_eacces", "platform_unsupported",
|
|
2277
|
+
"runtime_install_timeout", "launchd_failed", "bridge_exit", "bind_conflict", "bind_device_limit", "unknown",
|
|
2278
|
+
]);
|
|
2279
|
+
/** node 半只发 source=plugin。 */
|
|
2280
|
+
const TELEMETRY_SOURCE = "plugin";
|
|
2281
|
+
const TELEMETRY_INSTALL_ID_FILE = ".telemetry-install-id";
|
|
2282
|
+
const TELEMETRY_QUEUE_FILE = ".telemetry-queue.json";
|
|
2283
|
+
const TELEMETRY_ONCE_FILE = ".telemetry-once.json";
|
|
2284
|
+
/** 本地队列上限(0600):超出丢最旧,避免长离线机器把磁盘堆满。 */
|
|
2285
|
+
const TELEMETRY_QUEUE_MAX = 200;
|
|
2286
|
+
/** 单批事件上限 / body 上限(契约冻结)。 */
|
|
2287
|
+
const TELEMETRY_BATCH_MAX = 20;
|
|
2288
|
+
const TELEMETRY_BODY_MAX = 32 * 1024;
|
|
2289
|
+
/** 每 60s 或队列 ≥5 条时批量发送。 */
|
|
2290
|
+
const TELEMETRY_FLUSH_MS = 60_000;
|
|
2291
|
+
const TELEMETRY_FLUSH_THRESHOLD = 5;
|
|
2292
|
+
/** 失败退避(30s → 2m → 10m → 1h;落在 60s 心跳网格上),累计 6 次仍失败则丢弃该批。 */
|
|
2293
|
+
const TELEMETRY_BACKOFF_MS = [30_000, 120_000, 600_000, 3_600_000];
|
|
2294
|
+
const TELEMETRY_MAX_ATTEMPTS = 6;
|
|
2295
|
+
|
|
2296
|
+
/**
|
|
2297
|
+
* 发送心跳间隔(毫秒)。DSH_REMOTE_TELEMETRY_MS 仅供本仓库测试/诊断把节奏压到亚秒级
|
|
2298
|
+
* (生产不必设置,与 DSH_RELAY_SELFHEAL_MS 同一约定);调用时读取,import 之后再改也生效。
|
|
2299
|
+
*/
|
|
2300
|
+
function telemetryFlushMs() {
|
|
2301
|
+
const v = Number(process.env.DSH_REMOTE_TELEMETRY_MS);
|
|
2302
|
+
return Number.isFinite(v) && v > 0 ? Math.max(10, v) : TELEMETRY_FLUSH_MS;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/**
|
|
2306
|
+
* 遥测开关:DSH_REMOTE_TELEMETRY=0/false/off/no → 完全关闭(不生成 install_id、不落文件、不发请求);
|
|
2307
|
+
* 默认开启(=1/true 显式开启)。
|
|
2308
|
+
* 另外:DSH_RELAY_SKIP_SERVICE=1(测试/诊断隔离,见 skipsSystemOps)时同样不发送——
|
|
2309
|
+
* 本仓库的测试脚本全局设了该变量,用例绝不能把匿名事件发到真实生产端点。
|
|
2310
|
+
*/
|
|
2311
|
+
function telemetryEnabled() {
|
|
2312
|
+
if (skipsSystemOps()) return false;
|
|
2313
|
+
const v = String(process.env.DSH_REMOTE_TELEMETRY ?? "").trim().toLowerCase();
|
|
2314
|
+
return !(v === "0" || v === "false" || v === "off" || v === "no");
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
/**
|
|
2318
|
+
* 第 attempt 次失败后到下次重试的退避时长(毫秒):30s → 2m → 10m → 1h(封顶)。
|
|
2319
|
+
* 设置了 DSH_REMOTE_TELEMETRY_MS 时按同一比例缩放(仅供本仓库测试把整条重试链压到亚秒级;
|
|
2320
|
+
* 生产绝不设置该变量,生产值就是上面这串)。
|
|
2321
|
+
*/
|
|
2322
|
+
function telemetryBackoffMs(attempt) {
|
|
2323
|
+
const base = TELEMETRY_BACKOFF_MS[Math.min(Math.max(attempt, 0), TELEMETRY_BACKOFF_MS.length - 1)];
|
|
2324
|
+
const v = Number(process.env.DSH_REMOTE_TELEMETRY_MS);
|
|
2325
|
+
const scale = Number.isFinite(v) && v > 0 ? Math.max(10, v) / TELEMETRY_FLUSH_MS : 1;
|
|
2326
|
+
return Math.max(10, Math.round(base * scale));
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/** 每个 relayDir 的遥测簿:内存队列 + 心跳句柄 + 退避/去重状态(进程重启即重置)。 */
|
|
2330
|
+
const telemetryBooks = new Map();
|
|
2331
|
+
function telemetryBook(relayDir) {
|
|
2332
|
+
let b = telemetryBooks.get(relayDir);
|
|
2333
|
+
if (!b) {
|
|
2334
|
+
b = {
|
|
2335
|
+
queue: null, // null=尚未从磁盘读回
|
|
2336
|
+
timer: null, // 心跳定时器(unref,不阻塞进程退出)
|
|
2337
|
+
kickPending: false, // 已排队一次「立即发送」
|
|
2338
|
+
sending: false,
|
|
2339
|
+
attempts: 0, // 当前批次连续失败次数
|
|
2340
|
+
nextAt: 0, // 退避解禁时间
|
|
2341
|
+
closed: false, // 插件已停摆(dispose)→ 不再调度发送
|
|
2342
|
+
fired: new Set(), // 本进程已发过的事件名(每进程每阶段只发一次)
|
|
2343
|
+
runtimeMissingSeen: false,
|
|
2344
|
+
installStartedAt: 0,
|
|
2345
|
+
tunnelDown: false, // 同一掉线周期只发一条 tunnel_disconnected
|
|
2346
|
+
wasRegistered: false,
|
|
2347
|
+
};
|
|
2348
|
+
telemetryBooks.set(relayDir, b);
|
|
2349
|
+
}
|
|
2350
|
+
return b;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/**
|
|
2354
|
+
* 只在配置目录**已存在**时落盘:遥测绝不为自己的统计去创建配置目录(否则会改变
|
|
2355
|
+
* 「本机是否装过运行时」的既有语义——例如彻底卸载用例里 relayDir 本不该存在)。
|
|
2356
|
+
* 目录还没出现时事件只留在内存里等下一次心跳,装好了/登录后目录出现即恢复持久化。
|
|
2357
|
+
*/
|
|
2358
|
+
function telemetryDirReady(relayDir) {
|
|
2359
|
+
try { return Boolean(relayDir) && existsSync(relayDir); } catch { return false; }
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/**
|
|
2363
|
+
* 本机 install_id:<relayDir>/.telemetry-install-id(0600),首次 crypto.randomUUID(),之后复用。
|
|
2364
|
+
* 它是**随机 UUID**(非硬件派生、不含机器信息、换机/重装即变),因此不可跨机器关联到同一个人。
|
|
2365
|
+
* 生成/落盘失败 → 返回 null(本次不发,静默,不阻断任何流程)。
|
|
2366
|
+
*/
|
|
2367
|
+
function telemetryInstallId(relayDir) {
|
|
2368
|
+
if (!telemetryEnabled()) return null; // 关闭:连 ID 都不生成(更不落盘)
|
|
2369
|
+
if (!telemetryDirReady(relayDir)) return null;
|
|
2370
|
+
const file = join(relayDir, TELEMETRY_INSTALL_ID_FILE);
|
|
2371
|
+
try {
|
|
2372
|
+
const cur = readFileSync(file, "utf8").trim();
|
|
2373
|
+
if (/^[0-9A-Za-z-]{16,64}$/.test(cur)) return cur;
|
|
2374
|
+
} catch { /* 首次生成 */ }
|
|
2375
|
+
try {
|
|
2376
|
+
const id = randomUUID();
|
|
2377
|
+
try {
|
|
2378
|
+
writeFileSync(file, id, { mode: 0o600, flag: "wx" }); // wx:并发时不会互相覆盖
|
|
2379
|
+
} catch {
|
|
2380
|
+
const cur = readFileSync(file, "utf8").trim(); // 别的进程刚生成 → 复用它
|
|
2381
|
+
if (cur) return cur;
|
|
2382
|
+
throw new Error("install_id 写入失败");
|
|
2383
|
+
}
|
|
2384
|
+
try { chmodSync(file, 0o600); } catch { /* 权限位不生效不影响使用 */ }
|
|
2385
|
+
return id;
|
|
2386
|
+
} catch {
|
|
2387
|
+
return null; // 生成失败 → 本次不发
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/** 读回本地队列(懒加载;只接受白名单事件名,杜绝外来/损坏内容被转发)。 */
|
|
2392
|
+
function telemetryLoadQueue(relayDir, book) {
|
|
2393
|
+
if (Array.isArray(book.queue)) return book.queue;
|
|
2394
|
+
book.queue = [];
|
|
2395
|
+
try {
|
|
2396
|
+
const raw = JSON.parse(readFileSync(join(relayDir, TELEMETRY_QUEUE_FILE), "utf8"));
|
|
2397
|
+
const list = Array.isArray(raw?.events) ? raw.events : [];
|
|
2398
|
+
book.queue = list.filter((ev) => ev && typeof ev === "object" && TELEMETRY_EVENT_NAMES.has(String(ev.name)));
|
|
2399
|
+
} catch { book.queue = []; }
|
|
2400
|
+
return book.queue;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
/** 落盘本地队列(0600;写不进去也不影响主流程——退化为只在内存里排队)。 */
|
|
2404
|
+
function telemetrySaveQueue(relayDir, book) {
|
|
2405
|
+
try {
|
|
2406
|
+
if (!telemetryDirReady(relayDir)) return;
|
|
2407
|
+
const file = join(relayDir, TELEMETRY_QUEUE_FILE);
|
|
2408
|
+
writeFileSync(file, JSON.stringify({ v: 1, events: book.queue || [] }), { mode: 0o600 });
|
|
2409
|
+
try { chmodSync(file, 0o600); } catch { /* 非关键 */ }
|
|
2410
|
+
} catch { /* 非关键 */ }
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/** 从队列移除已送达/已放弃的一批(按引用比较,避免并发写入被误删)。 */
|
|
2414
|
+
function telemetryDrop(relayDir, book, batch) {
|
|
2415
|
+
const queue = telemetryLoadQueue(relayDir, book);
|
|
2416
|
+
for (const ev of batch) {
|
|
2417
|
+
const i = queue.indexOf(ev);
|
|
2418
|
+
if (i >= 0) queue.splice(i, 1);
|
|
2419
|
+
}
|
|
2420
|
+
telemetrySaveQueue(relayDir, book);
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/** 一次性事件标记(跨进程、跨重启只发一次,如 first_remote_ok)读/写。 */
|
|
2424
|
+
function telemetryOnceFlags(relayDir) {
|
|
2425
|
+
try {
|
|
2426
|
+
const j = JSON.parse(readFileSync(join(relayDir, TELEMETRY_ONCE_FILE), "utf8"));
|
|
2427
|
+
return j && typeof j === "object" ? j : {};
|
|
2428
|
+
} catch { return {}; }
|
|
2429
|
+
}
|
|
2430
|
+
function telemetryMarkOnce(relayDir, name) {
|
|
2431
|
+
try {
|
|
2432
|
+
if (!telemetryDirReady(relayDir)) return;
|
|
2433
|
+
writeFileSync(join(relayDir, TELEMETRY_ONCE_FILE),
|
|
2434
|
+
JSON.stringify({ ...telemetryOnceFlags(relayDir), [name]: Date.now() }), { mode: 0o600 });
|
|
2435
|
+
} catch { /* 非关键 */ }
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/**
|
|
2439
|
+
* 【唯一的 payload 构造点】把事件名 + 少量上下文编译成一条遥测事件。
|
|
2440
|
+
* 字段仅限契约白名单:name / fail_code / at / version / os / arch / node。
|
|
2441
|
+
* 这里**绝不**写入手机号、邮箱、账号 ID、会话或文件内容、hostname、用户名、文件路径、
|
|
2442
|
+
* 密码/密钥、machine_fp、IP、地理位置等任何可识别信息(见 docs/telemetry.md「不采集什么」)。
|
|
2443
|
+
* 返回 null = 事件名不在白名单 → 调用方一律不发。
|
|
2444
|
+
*/
|
|
2445
|
+
function telemetryEventOf(name, extra = {}) {
|
|
2446
|
+
if (!TELEMETRY_EVENT_NAMES.has(name)) return null;
|
|
2447
|
+
const ev = {
|
|
2448
|
+
name,
|
|
2449
|
+
at: Date.now(),
|
|
2450
|
+
version: PLUGIN_VERSION,
|
|
2451
|
+
os: process.platform, // 仅平台名(darwin/linux/win32),非主机名
|
|
2452
|
+
arch: process.arch, // 仅架构(arm64/x64)
|
|
2453
|
+
node: String(process.versions?.node || "").split(".")[0], // 仅主版本号,如 "22"
|
|
2454
|
+
};
|
|
2455
|
+
if (name === "install_failed" || name === "update_failed") {
|
|
2456
|
+
const code = String(extra.fail_code || "");
|
|
2457
|
+
ev.fail_code = TELEMETRY_FAIL_CODES.has(code) ? code : "unknown"; // 白名单外 → unknown
|
|
2458
|
+
}
|
|
2459
|
+
return ev;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
/** 从既有日志/错误文本归类 fail_code(白名单内);原始文本绝不外发(可能含路径/主机名)。 */
|
|
2463
|
+
function telemetryFailCodeFromText(text) {
|
|
2464
|
+
const t = String(text || "");
|
|
2465
|
+
if (/EACCES|EPERM|permission denied|权限不足/i.test(t)) return "npm_eacces";
|
|
2466
|
+
if (/platform|不支持的平台/i.test(t)) return "platform_unsupported";
|
|
2467
|
+
if (/too old|版本过低|engine/i.test(t)) return "node_too_old";
|
|
2468
|
+
if (/ENOENT|not found|No such file/i.test(t)) return "node_missing";
|
|
2469
|
+
if (/ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ECONNREFUSED|ENETUNREACH|ECONNRESET|network|registry|超时/i.test(t)) {
|
|
2470
|
+
return "npm_unreachable";
|
|
2471
|
+
}
|
|
2472
|
+
return "unknown";
|
|
2473
|
+
}
|
|
2474
|
+
/** 子进程启动失败(spawn error)→ fail_code。 */
|
|
2475
|
+
function telemetryFailCodeFromError(e) {
|
|
2476
|
+
const code = String(e?.code || "");
|
|
2477
|
+
if (code === "ENOENT") return "node_missing"; // 连 node/npx 可执行文件都找不到 = 本机没有 node
|
|
2478
|
+
if (code === "EACCES") return "npm_eacces";
|
|
2479
|
+
return telemetryFailCodeFromText(e?.message);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/** 启动发送心跳(懒启动;unref 不阻塞进程退出)。 */
|
|
2483
|
+
function telemetryTickerStart(relayDir) {
|
|
2484
|
+
const book = telemetryBook(relayDir);
|
|
2485
|
+
if (book.timer || book.closed) return;
|
|
2486
|
+
const t = setInterval(() => { void telemetryFlush(relayDir); }, telemetryFlushMs());
|
|
2487
|
+
t.unref?.();
|
|
2488
|
+
book.timer = t;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
/** 排队一次「立即发送」(下一轮事件循环执行;不阻塞调用方)。 */
|
|
2492
|
+
function telemetryFlushSoon(relayDir) {
|
|
2493
|
+
const book = telemetryBook(relayDir);
|
|
2494
|
+
if (book.kickPending || book.closed || !telemetryEnabled()) return;
|
|
2495
|
+
book.kickPending = true;
|
|
2496
|
+
const t = setTimeout(() => { book.kickPending = false; void telemetryFlush(relayDir); }, 0);
|
|
2497
|
+
t.unref?.();
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
/**
|
|
2501
|
+
* 入队一条事件:白名单 → 队列(上限 200 丢最旧)→ 落盘 → 队列 ≥5 条立即发送,否则等 60s 心跳。
|
|
2502
|
+
* 全程 try/catch 静默:遥测绝不能影响面板 / bridge / 连接流程或用户可见行为。
|
|
2503
|
+
* @returns {boolean} 是否入队
|
|
2504
|
+
*/
|
|
2505
|
+
function telemetryRecord(relayDir, name, extra = {}) {
|
|
2506
|
+
try {
|
|
2507
|
+
if (!telemetryEnabled()) return false; // 关闭:不落文件、不入队、不发请求
|
|
2508
|
+
const ev = telemetryEventOf(name, extra);
|
|
2509
|
+
if (!ev) return false; // 白名单外一律不发
|
|
2510
|
+
const book = telemetryBook(relayDir);
|
|
2511
|
+
if (book.closed) return false;
|
|
2512
|
+
const queue = telemetryLoadQueue(relayDir, book);
|
|
2513
|
+
queue.push(ev);
|
|
2514
|
+
if (queue.length > TELEMETRY_QUEUE_MAX) queue.splice(0, queue.length - TELEMETRY_QUEUE_MAX); // 丢最旧
|
|
2515
|
+
telemetrySaveQueue(relayDir, book);
|
|
2516
|
+
telemetryTickerStart(relayDir);
|
|
2517
|
+
if (queue.length >= TELEMETRY_FLUSH_THRESHOLD) telemetryFlushSoon(relayDir);
|
|
2518
|
+
return true;
|
|
2519
|
+
} catch {
|
|
2520
|
+
return false;
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
/** 每进程只发一次的事件(plugin_loaded / panel_opened / bridge_started / bridge_registered)。 */
|
|
2525
|
+
function telemetryOnce(relayDir, name, extra) {
|
|
2526
|
+
if (!telemetryEnabled()) return false;
|
|
2527
|
+
const book = telemetryBook(relayDir);
|
|
2528
|
+
if (book.fired.has(name)) return false;
|
|
2529
|
+
book.fired.add(name);
|
|
2530
|
+
return telemetryRecord(relayDir, name, extra);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/** 跨进程只发一次的事件(first_remote_ok:持久化标记,先落标记再入队,宁可丢一条也不重复)。 */
|
|
2534
|
+
function telemetryOnceEver(relayDir, name, extra) {
|
|
2535
|
+
if (!telemetryEnabled()) return false;
|
|
2536
|
+
if (telemetryOnceFlags(relayDir)[name]) return false;
|
|
2537
|
+
telemetryMarkOnce(relayDir, name);
|
|
2538
|
+
return telemetryRecord(relayDir, name, extra);
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
/** 运行环境就绪(每进程一次)。 */
|
|
2542
|
+
function telemetryRuntimeReady(relayDir) {
|
|
2543
|
+
if (!telemetryEnabled()) return false;
|
|
2544
|
+
const book = telemetryBook(relayDir);
|
|
2545
|
+
if (book.fired.has("runtime_ready")) return false;
|
|
2546
|
+
book.fired.add("runtime_ready");
|
|
2547
|
+
return telemetryRecord(relayDir, "runtime_ready");
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/**
|
|
2551
|
+
* 运行环境探针(在补装 watcher 与面板轮询里顺手调用,不额外起定时器):
|
|
2552
|
+
* - 现在缺运行环境 → 记下「本进程见过缺失」,并在超过 INSTALL_STALE_MS 仍缺时归因为
|
|
2553
|
+
* install_failed(runtime_install_timeout)(每进程一次);
|
|
2554
|
+
* - 之前见过缺失、现在已就绪 → runtime_ready(每进程一次)。
|
|
2555
|
+
*/
|
|
2556
|
+
function telemetryRuntimeProbe(relayDir) {
|
|
2557
|
+
if (!telemetryEnabled()) return;
|
|
2558
|
+
try {
|
|
2559
|
+
const book = telemetryBook(relayDir);
|
|
2560
|
+
if (runtimeReady(relayDir)) {
|
|
2561
|
+
if (book.runtimeMissingSeen) telemetryRuntimeReady(relayDir);
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
book.runtimeMissingSeen = true;
|
|
2565
|
+
if (book.installStartedAt && Date.now() - book.installStartedAt > INSTALL_STALE_MS && !book.fired.has("install-timeout")) {
|
|
2566
|
+
book.fired.add("install-timeout");
|
|
2567
|
+
telemetryRecord(relayDir, "install_failed", { fail_code: "runtime_install_timeout" });
|
|
2568
|
+
}
|
|
2569
|
+
} catch { /* 静默 */ }
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
/**
|
|
2573
|
+
* 「当前隧道是否断连中」判据(仅供遥测):bridge 日志里最后一次「隧道断开」晚于最后一次
|
|
2574
|
+
* 「隧道已连 / ✅ router 注册成功」即为断连中。只看行序,不读任何内容。
|
|
2575
|
+
*/
|
|
2576
|
+
function telemetryTunnelDown(relayDir) {
|
|
2577
|
+
const lines = readTail(join(relayDir, BRIDGE_LOG_FILE)).split("\n");
|
|
2578
|
+
let up = -1;
|
|
2579
|
+
let down = -1;
|
|
2580
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2581
|
+
const line = lines[i].trim();
|
|
2582
|
+
if (!line) continue;
|
|
2583
|
+
if (/✅\s*router 注册成功|隧道已连/.test(line)) up = i;
|
|
2584
|
+
else if (/隧道断开/.test(line)) down = i;
|
|
2585
|
+
}
|
|
2586
|
+
return down > up && down >= 0;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
/** 「本机设备在账号设备表可见」(first_remote_ok 的第二个条件):状态文件 / 日志 / 账号接口三选一。 */
|
|
2590
|
+
function telemetryAccountVisible(relayDir, conn) {
|
|
2591
|
+
const src = String(conn?.registerSource || "");
|
|
2592
|
+
if (src === "account_api" || src === "account" || src === "state_account") return true;
|
|
2593
|
+
const state = bridgeStateFile(relayDir);
|
|
2594
|
+
if (state && Number(state.account_bound_at) > 0) return true; // bridge POST /api/devices 成功留下的证据
|
|
2595
|
+
return Boolean(bridgeLogState(relayDir).accountBound);
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
/**
|
|
2599
|
+
* 连接阶段观察(复用既有阶段引擎,不新造状态机):阶段推进到 starting / online 时各发一次
|
|
2600
|
+
* (每进程每阶段只发一次,避免 2~3s 轮询把事件刷爆),掉线时发一条 tunnel_disconnected
|
|
2601
|
+
* (同一掉线周期去重)。由持有 conn 的路由调用。
|
|
2602
|
+
*/
|
|
2603
|
+
function telemetryObserveConnect(relayDir, conn) {
|
|
2604
|
+
if (!telemetryEnabled() || !conn) return;
|
|
2605
|
+
try {
|
|
2606
|
+
const phase = String(conn.phase || "");
|
|
2607
|
+
// bridge_started = bridge 进程被拉起(面板阶段 starting)
|
|
2608
|
+
if (phase === "starting") telemetryOnce(relayDir, "bridge_started");
|
|
2609
|
+
// bridge_registered = 设备已在中继注册成功(面板阶段 online)——这才是「真正可用」
|
|
2610
|
+
if (phase === "online" || conn.registered) telemetryOnce(relayDir, "bridge_registered");
|
|
2611
|
+
// tunnel_disconnected = bridge 掉线:日志里最后一次是「隧道断开」,或注册证据由真变假
|
|
2612
|
+
const book = telemetryBook(relayDir);
|
|
2613
|
+
const dropped = telemetryTunnelDown(relayDir) || (book.wasRegistered === true && !conn.registered);
|
|
2614
|
+
if (dropped) {
|
|
2615
|
+
if (!book.tunnelDown) {
|
|
2616
|
+
book.tunnelDown = true;
|
|
2617
|
+
telemetryRecord(relayDir, "tunnel_disconnected");
|
|
2618
|
+
}
|
|
2619
|
+
} else {
|
|
2620
|
+
book.tunnelDown = false; // 已恢复/未掉线 → 复位,下一轮掉线可再记一条
|
|
2621
|
+
}
|
|
2622
|
+
if (conn.registered) book.wasRegistered = true;
|
|
2623
|
+
// first_remote_ok = 本机首次观察到 online 且本机设备在账号设备表可见(跨进程只发一次)
|
|
2624
|
+
if (phase === "online" && conn.registered && telemetryAccountVisible(relayDir, conn)) {
|
|
2625
|
+
telemetryOnceEver(relayDir, "first_remote_ok");
|
|
2626
|
+
}
|
|
2627
|
+
} catch { /* 静默 */ }
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
/**
|
|
2631
|
+
* 批量发送一批(≤20 条、≤32KB;调用方一律不 await)。成功出队;失败按 30s→2m→10m→1h 退避,
|
|
2632
|
+
* 累计 6 次仍失败丢弃该批——不永久堆积、不阻塞任何主流程。
|
|
2633
|
+
* 匿名:只有 content-type 与 x-dsh-client,**不带 Authorization**(服务端也不接受账号关联)。
|
|
2634
|
+
*/
|
|
2635
|
+
async function telemetryFlush(relayDir) {
|
|
2636
|
+
const book = telemetryBook(relayDir);
|
|
2637
|
+
try {
|
|
2638
|
+
if (!telemetryEnabled() || book.closed) return;
|
|
2639
|
+
if (book.sending) return;
|
|
2640
|
+
if (Date.now() < book.nextAt) return; // 退避中:等下一轮心跳到点再看
|
|
2641
|
+
const queue = telemetryLoadQueue(relayDir, book);
|
|
2642
|
+
if (!queue.length) return;
|
|
2643
|
+
const installId = telemetryInstallId(relayDir);
|
|
2644
|
+
if (!installId) return; // install_id 生成失败 → 本次不发
|
|
2645
|
+
const build = (list) => JSON.stringify({ install_id: installId, source: TELEMETRY_SOURCE, events: list });
|
|
2646
|
+
let batch = queue.slice(0, TELEMETRY_BATCH_MAX);
|
|
2647
|
+
let body = build(batch);
|
|
2648
|
+
while (batch.length > 1 && Buffer.byteLength(body, "utf8") > TELEMETRY_BODY_MAX) {
|
|
2649
|
+
batch = batch.slice(0, batch.length - 1); // body 超 32KB:收缩批次(正常远小于该上限)
|
|
2650
|
+
body = build(batch);
|
|
2651
|
+
}
|
|
2652
|
+
if (Buffer.byteLength(body, "utf8") > TELEMETRY_BODY_MAX) {
|
|
2653
|
+
telemetryDrop(relayDir, book, batch); // 单条就超限 → 丢弃该批(防御性)
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
const cfg = loadConfig(relayDir);
|
|
2657
|
+
const api = String(cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
|
|
2658
|
+
book.sending = true;
|
|
2659
|
+
let ok = false;
|
|
2660
|
+
try {
|
|
2661
|
+
const r = await fetch(`${api}/api/telemetry/events`, {
|
|
2662
|
+
method: "POST",
|
|
2663
|
+
headers: {
|
|
2664
|
+
"content-type": "application/json",
|
|
2665
|
+
"x-dsh-client": `dsh-remote/${PLUGIN_VERSION}`, // 契约冻结:dsh-remote/<version>(非 dsh-remote-web)
|
|
2666
|
+
},
|
|
2667
|
+
body,
|
|
2668
|
+
signal: AbortSignal.timeout(8000),
|
|
2669
|
+
});
|
|
2670
|
+
ok = Boolean(r && r.ok);
|
|
2671
|
+
} catch { ok = false; } // 网络失败 → 静默退避(老服务端 404 同理)
|
|
2672
|
+
book.sending = false;
|
|
2673
|
+
if (ok) {
|
|
2674
|
+
telemetryDrop(relayDir, book, batch);
|
|
2675
|
+
book.attempts = 0;
|
|
2676
|
+
book.nextAt = 0;
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
book.attempts += 1;
|
|
2680
|
+
if (book.attempts >= TELEMETRY_MAX_ATTEMPTS) {
|
|
2681
|
+
telemetryDrop(relayDir, book, batch); // 上限 6 次仍失败 → 丢弃该批
|
|
2682
|
+
book.attempts = 0;
|
|
2683
|
+
book.nextAt = 0;
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
book.nextAt = Date.now() + telemetryBackoffMs(book.attempts - 1);
|
|
2687
|
+
} catch {
|
|
2688
|
+
book.sending = false; // 任何异常:静默
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
/**
|
|
2693
|
+
* 插件装载时的遥测初始化:读回磁盘队列(上次进程遗留的事件不丢,例如重启前那条 harness_restart)
|
|
2694
|
+
* 并启动发送心跳。DSH_REMOTE_TELEMETRY=0 时连读都不读 → 零文件零请求。
|
|
2695
|
+
*/
|
|
2696
|
+
function telemetryStart(relayDir) {
|
|
2697
|
+
try {
|
|
2698
|
+
if (!telemetryEnabled()) return;
|
|
2699
|
+
const book = telemetryBook(relayDir);
|
|
2700
|
+
book.closed = false;
|
|
2701
|
+
const queue = telemetryLoadQueue(relayDir, book);
|
|
2702
|
+
telemetryTickerStart(relayDir);
|
|
2703
|
+
if (queue.length) telemetryFlushSoon(relayDir);
|
|
2704
|
+
} catch { /* 静默 */ }
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
/** 插件停摆(dispose)→ 停掉心跳,之后不再调度发送(磁盘队列留给下次装载)。 */
|
|
2708
|
+
function telemetryStop(relayDir) {
|
|
2709
|
+
try {
|
|
2710
|
+
const b = telemetryBooks.get(relayDir);
|
|
2711
|
+
if (!b) return;
|
|
2712
|
+
b.closed = true;
|
|
2713
|
+
if (b.timer) { clearInterval(b.timer); b.timer = null; }
|
|
2714
|
+
} catch { /* 静默 */ }
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
/** 内部接口(仅供本仓库测试与隐私审计;不属于插件对外契约,也不被面板/浏览器半使用)。 */
|
|
2718
|
+
export const __telemetryInternals = {
|
|
2719
|
+
enabled: telemetryEnabled,
|
|
2720
|
+
record: (relayDir, name, extra) => telemetryRecord(relayDir, name, extra),
|
|
2721
|
+
flush: (relayDir) => telemetryFlush(relayDir),
|
|
2722
|
+
installId: (relayDir) => telemetryInstallId(relayDir),
|
|
2723
|
+
queueOf: (relayDir) => telemetryLoadQueue(relayDir, telemetryBook(relayDir)).slice(),
|
|
2724
|
+
eventOf: (name, extra) => telemetryEventOf(name, extra),
|
|
2725
|
+
eventNames: [...TELEMETRY_EVENT_NAMES],
|
|
2726
|
+
failCodes: [...TELEMETRY_FAIL_CODES],
|
|
2727
|
+
queueMax: TELEMETRY_QUEUE_MAX,
|
|
2728
|
+
batchMax: TELEMETRY_BATCH_MAX,
|
|
2729
|
+
bodyMax: TELEMETRY_BODY_MAX,
|
|
2730
|
+
stateOf: (relayDir) => {
|
|
2731
|
+
const b = telemetryBook(relayDir);
|
|
2732
|
+
return { attempts: b.attempts, nextAt: b.nextAt, sending: b.sending, queueLen: telemetryLoadQueue(relayDir, b).length };
|
|
2733
|
+
},
|
|
2734
|
+
reset: (relayDir) => {
|
|
2735
|
+
const b = telemetryBooks.get(relayDir);
|
|
2736
|
+
if (b && b.timer) clearInterval(b.timer);
|
|
2737
|
+
telemetryBooks.delete(relayDir);
|
|
2738
|
+
},
|
|
2739
|
+
};
|
|
2740
|
+
|
|
2226
2741
|
/**
|
|
2227
2742
|
* 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
|
|
2228
2743
|
* 之类的口径统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
|
|
@@ -2391,7 +2906,20 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2391
2906
|
ensureConnection(relayDir);
|
|
2392
2907
|
maybeReportInstall(relayDir);
|
|
2393
2908
|
void ensureHarnessCookie(ctx, relayDir).catch(() => {}); // 同上:手机端授权会话自愈
|
|
2394
|
-
|
|
2909
|
+
telemetryRuntimeProbe(relayDir); // 匿名遥测:运行环境刚补齐 → runtime_ready(不额外起定时器)
|
|
2910
|
+
const connect = await composeConnect(relayDir);
|
|
2911
|
+
telemetryObserveConnect(relayDir, connect); // 匿名遥测:bridge_started / bridge_registered / 掉线
|
|
2912
|
+
sendJson(res, 200, { ok: true, connect });
|
|
2913
|
+
},
|
|
2914
|
+
},
|
|
2915
|
+
// 面板打开(浏览器半在「远程访问」栏目首次渲染时调一次):每进程只记一次。
|
|
2916
|
+
// 只发事件名,不含任何账号/内容/设备信息;DSH_REMOTE_TELEMETRY=0 时静默丢弃。
|
|
2917
|
+
{
|
|
2918
|
+
method: "POST",
|
|
2919
|
+
path: "/dsh-remote/telemetry/panel-opened",
|
|
2920
|
+
handler: async (_req, res) => {
|
|
2921
|
+
telemetryOnce(relayDir, "panel_opened");
|
|
2922
|
+
sendJson(res, 200, { ok: true });
|
|
2395
2923
|
},
|
|
2396
2924
|
},
|
|
2397
2925
|
// 手动重试(error 阶段的「重试」按钮):清掉退避计数立刻再走一遍闭环。
|
|
@@ -2403,6 +2931,7 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2403
2931
|
maybeReportInstall(relayDir, { force: true });
|
|
2404
2932
|
await sleep(600); // 给 launchctl 一点进入运行态的时间,重试点下去立刻能看到「正在启动 Bridge…」
|
|
2405
2933
|
const connect = await composeConnect(relayDir);
|
|
2934
|
+
telemetryObserveConnect(relayDir, connect); // 匿名遥测:同 bridge-status(去重后不会重复计数)
|
|
2406
2935
|
sendJson(res, 200, { ok: true, retried: true, action: action && action.action ? action.action : "none", connect });
|
|
2407
2936
|
},
|
|
2408
2937
|
},
|
|
@@ -2780,6 +3309,11 @@ export function apply(ctx, config = {}) {
|
|
|
2780
3309
|
const relayDir = config.relayDir || process.env.DSH_RELAY_DIR || DEFAULT_RELAY_DIR;
|
|
2781
3310
|
// 全新激活(dsh web 重启后插件重新加载,或卸载后再次安装)→ 解除上次的「已卸载」停摆标记
|
|
2782
3311
|
UNINSTALLED_DIRS.delete(relayDir);
|
|
3312
|
+
// 匿名遥测:装载即初始化(读回上次进程遗留的磁盘队列,例如重启前那条 harness_restart,
|
|
3313
|
+
// 并启动 60s 发送心跳),随后记一条 plugin_loaded(每进程一次)。
|
|
3314
|
+
// DSH_REMOTE_TELEMETRY=0 → 这里连读都不读:零文件、零请求、零 install_id。
|
|
3315
|
+
telemetryStart(relayDir);
|
|
3316
|
+
telemetryOnce(relayDir, "plugin_loaded");
|
|
2783
3317
|
// 清理上次进程残留的安装/更新 marker(宿主被重启/强杀时子进程清理回调会丢失)
|
|
2784
3318
|
sweepStaleMarkers(relayDir);
|
|
2785
3319
|
// 「待重启」状态结清 + 运行时安装检测:
|
|
@@ -2799,12 +3333,15 @@ export function apply(ctx, config = {}) {
|
|
|
2799
3333
|
ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
|
|
2800
3334
|
// 插件市场一键全功能:缺桌面运行环境则自动安装,登录后自动拉起 bridge(不依赖用户跑 npx)
|
|
2801
3335
|
ctx.effect(() => scheduleRuntime(relayDir), "dsh-remote-web: runtime self-provision");
|
|
3336
|
+
// 匿名遥测:插件停摆(卸载/重载)时停掉发送心跳;磁盘队列留给下次装载补发。
|
|
3337
|
+
ctx.effect(() => () => telemetryStop(relayDir), "dsh-remote-web: telemetry flush ticker");
|
|
2802
3338
|
// 【0.6.2】市场安装路径的「装到用户电脑上」第一步:插件加载即后台补装桌面运行环境
|
|
2803
3339
|
// (bridge + 自启动),不再等用户先登录、也不再依赖 launchd 状态判断是否「已在运行」。
|
|
2804
3340
|
// 已就绪时是零副作用 no-op;正在安装中/已卸载时 ensureRuntime 自身幂等挡下。
|
|
2805
3341
|
const provisioned = ensureRuntime(relayDir);
|
|
2806
3342
|
if (!provisioned && !runtimeReady(relayDir)) {
|
|
2807
3343
|
ctx.logger?.info?.(`dsh-remote-web: 桌面运行环境缺失,已在后台自动安装(relayDir=${relayDir})`);
|
|
3344
|
+
telemetryRuntimeProbe(relayDir); // 匿名遥测:记下「本进程见过运行环境缺失」(装好后发 runtime_ready)
|
|
2808
3345
|
}
|
|
2809
3346
|
// 插件启动即上报本机安装信息(install_source/version + 主机 OS/架构;仅有账号凭据时上报,失败静默)。
|
|
2810
3347
|
// 面板登录成功(POST /dsh-remote/config)时会再上报一次,覆盖「已登录用户升级插件」的口径。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.4-beta.
|
|
3
|
+
"version": "0.6.4-beta.4",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|