dsh-remote-web 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/lib/client.js +110 -6
- package/lib/index.js +637 -25
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -142,6 +142,8 @@ window.__ModuleLoader__.load({
|
|
|
142
142
|
".dru-popup-foot button:hover{color:#0969da}",
|
|
143
143
|
".dru-popup .dru-msg{text-align:left}",
|
|
144
144
|
".dru-community-qr{display:block;width:220px;max-width:62vw;margin:0 auto;background:#ffffff;padding:10px;border-radius:10px;border:1px solid #d0d7de;box-sizing:content-box}",
|
|
145
|
+
".dru-restart-auto{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:12.5px;color:#1a7f37;background:#dafbe1;border-radius:8px;padding:8px 10px}",
|
|
146
|
+
".dru-restart-auto.muted{color:#57606a;background:#f6f8fa}",
|
|
145
147
|
".dru-fb-community{margin-top:16px;padding-top:14px;border-top:1px dashed #d0d7de;text-align:center}",
|
|
146
148
|
".dru-fb-community .dru-community-qr{width:180px;max-width:56vw}",
|
|
147
149
|
// ── 自管理:版本与更新(插件面板内提供在线更新/彻底卸载,市场无更新按钮) ──
|
|
@@ -1129,6 +1131,8 @@ window.__ModuleLoader__.load({
|
|
|
1129
1131
|
}
|
|
1130
1132
|
|
|
1131
1133
|
// ── 面板主体(渲染于设置页 settings.section 栏目内) ─────────────────────
|
|
1134
|
+
// 匿名装机统计的「面板打开」只上报一次/每页(node 半还会每进程去重)。
|
|
1135
|
+
var panelOpenedSent = false;
|
|
1132
1136
|
function RemoteControlSection(props) {
|
|
1133
1137
|
|
|
1134
1138
|
var statusArr = useState(null); var st = statusArr[0]; var setSt = statusArr[1];
|
|
@@ -1189,6 +1193,10 @@ window.__ModuleLoader__.load({
|
|
|
1189
1193
|
// ── 💬 交流群(「加入交流群」按钮 + 弹窗):同样放在全部既有字段之后 ──
|
|
1190
1194
|
var communityArr = useState(null); var community = communityArr[0]; var setCommunity = communityArr[1]; // {qrcode,wechat};null=未加载
|
|
1191
1195
|
var commOpenArr = useState(false); var commOpen = commOpenArr[0]; var setCommOpen = commOpenArr[1];
|
|
1196
|
+
// 【自动化】重启倒计时 / 是否已被用户取消(初值按本次 pending 事件的持久化标记,跨刷新生效)
|
|
1197
|
+
var restartCancelArr = useState(function () { return autoRestartCancelled((st && st.restart && st.restart.at) || 0); });
|
|
1198
|
+
var restartCancelled = restartCancelArr[0]; var setRestartCancelled = restartCancelArr[1];
|
|
1199
|
+
var restartCountArr = useState(null); var restartCountdown = restartCountArr[0]; var setRestartCountdown = restartCountArr[1];
|
|
1192
1200
|
// ── 🔗 登录后自动闭环:连接阶段(运行环境 → bridge 进程 → 设备已在中继注册=online) ──
|
|
1193
1201
|
// conn 来自 GET /dsh-remote/bridge-status(node 半在返回前会自动补装/拉起/退避重试),
|
|
1194
1202
|
// 面板据它显示「正在准备运行环境…/正在启动 Bridge…/正在连接中继…/已连接 ✅」,
|
|
@@ -1235,6 +1243,9 @@ window.__ModuleLoader__.load({
|
|
|
1235
1243
|
// 是否需要重启 DeepSeek harness(首次安装/在线更新后置顶提醒 + 底部常驻按钮)
|
|
1236
1244
|
var restartInfo = (st && st.restart) || {};
|
|
1237
1245
|
var restartPending = !!restartInfo.pending;
|
|
1246
|
+
// kind === "refresh" = 插件文件在运行中被改写(装完/更新完),只需刷新页面;
|
|
1247
|
+
// 其余情况(极为罕见)才提重启 dsh web。
|
|
1248
|
+
var isRefreshOnly = restartPending && restartInfo.kind === "refresh";
|
|
1238
1249
|
// 连接阶段(node 半下发):轮询拿到的新鲜结果优先,其次用 /status 里带的那一份;
|
|
1239
1250
|
// 旧版 host(响应里没有 connect 字段)→ 回退到原来的 service.running 文案(行为不变)。
|
|
1240
1251
|
var connInfo = conn || (st && st.connect) || null;
|
|
@@ -1551,6 +1562,16 @@ window.__ModuleLoader__.load({
|
|
|
1551
1562
|
};
|
|
1552
1563
|
}, [view, mode, loggedIn, phoneKey, serviceRunning]);
|
|
1553
1564
|
|
|
1565
|
+
// ── 匿名装机统计(面板打开) ──────────────────────────────────────────────
|
|
1566
|
+
// 只上报「面板被打开过」这一个事件名(node 半每进程去重、DSH_REMOTE_TELEMETRY=0 直接丢弃);
|
|
1567
|
+
// 不含任何账号、手机号、设备指纹、会话内容或文件内容——隐私边界见 docs/telemetry.md。
|
|
1568
|
+
useEffect(function () {
|
|
1569
|
+
if (panelOpenedSent) return undefined;
|
|
1570
|
+
panelOpenedSent = true;
|
|
1571
|
+
post("/dsh-remote/telemetry/panel-opened", {}).catch(function () { /* 静默:统计失败绝不影响面板 */ });
|
|
1572
|
+
return undefined;
|
|
1573
|
+
}, []);
|
|
1574
|
+
|
|
1554
1575
|
// ── 登录后自动闭环(0.6.4):连接阶段短轮询 ──────────────────────────────
|
|
1555
1576
|
// 登录成功(含从别处已登录、被面板读到)后立刻开始推进连接,之后自调度:
|
|
1556
1577
|
// 非 online → 2.5s 一次(阶段自动前进,用户零操作);online → 15s 一次(退避,只做保活/新设备发现)。
|
|
@@ -1861,6 +1882,25 @@ window.__ModuleLoader__.load({
|
|
|
1861
1882
|
});
|
|
1862
1883
|
};
|
|
1863
1884
|
|
|
1885
|
+
/**
|
|
1886
|
+
* 【自动化】首次安装/在线更新后自动重启 DeepSeek harness —— 用户不需要点任何按钮。
|
|
1887
|
+
*
|
|
1888
|
+
* 为什么能自动:① 重启只是重启 dsh web(bridge 与手机端连接不受影响);② 重启后
|
|
1889
|
+
* waitHarnessBack 会轮询到它回来并**自动刷新页面**,面板与「连接中→已连接」流程继续跑完。
|
|
1890
|
+
* 安全阀:① 面板不可见(用户没在看)→ 不计时,回来再继续;② 15 秒倒计时内可一键取消,
|
|
1891
|
+
* 取消标记按本次 pending 事件(at 时间戳)持久化,同一事件不再自动重启(下次安装/更新会重新触发);
|
|
1892
|
+
* ③ 面板上有其他操作在跑(busy≠"")→ 暂停计时,避免打断用户正在做的事。
|
|
1893
|
+
*/
|
|
1894
|
+
var AUTO_RESTART_DELAY_MS = 15000;
|
|
1895
|
+
var RESTART_CANCEL_KEY = "dsh-remote-auto-restart-cancelled";
|
|
1896
|
+
|
|
1897
|
+
function autoRestartCancelled(at) {
|
|
1898
|
+
try { return localStorage.getItem(RESTART_CANCEL_KEY) === String(at || ""); } catch (e) { return false; }
|
|
1899
|
+
}
|
|
1900
|
+
function markAutoRestartCancelled(at) {
|
|
1901
|
+
try { localStorage.setItem(RESTART_CANCEL_KEY, String(at || "")); } catch (e) { /* 忽略 */ }
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1864
1904
|
/** 轮询等待 harness 回来;观察到「断过又恢复」或等待足够久后自动刷新页面。 */
|
|
1865
1905
|
var waitHarnessBack = function (tries, sawDown) {
|
|
1866
1906
|
if (tries > 40) { setMsg("err", "重启后仍未就绪,请手动刷新页面或重启 DeepSeek harness。"); setBusy(""); return; }
|
|
@@ -2385,6 +2425,13 @@ window.__ModuleLoader__.load({
|
|
|
2385
2425
|
h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🛠 电脑端一键安装:bridge 与「远程访问」面板一次到位——云端/自建切换、账号登录、bridge 启停、一次性扫码访问、已授权设备管理、意见反馈都在这里。"),
|
|
2386
2426
|
h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表;服务端可配置流量配额。"),
|
|
2387
2427
|
h("div", { className: "dru-hint" }, "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。"),
|
|
2428
|
+
// 匿名装机统计(隐私披露):开源项目必须把「采什么 / 不采什么 / 怎么关」写在用户看得到的地方。
|
|
2429
|
+
// 只保留结论式的说明句:不再单列「隐私说明: README… · docs/telemetry.md」那一行文档链接
|
|
2430
|
+
// (面板里堆文档链接既占地方又不像产品文案;完整字段清单在仓库 docs/telemetry.md)。
|
|
2431
|
+
h("div", { className: "dru-hint", style: { marginTop: 6 } },
|
|
2432
|
+
"📊 匿名装机统计:只上报「装机/连接是否成功」这类事件(安装开始与失败原因、bridge 是否注册成功、是否首次远程打通)——"
|
|
2433
|
+
+ "不含任何账号、手机号、会话或文件内容、主机名、路径、设备指纹与 IP;标识是本机随机 ID(重装即变)。"
|
|
2434
|
+
+ "用环境变量 DSH_REMOTE_TELEMETRY=0 可完全关闭(不生成 ID、不发任何请求)。"),
|
|
2388
2435
|
// 加入交流群(后台上传二维码后出现;点击弹出二维码大图便于扫码)
|
|
2389
2436
|
community && community.qrcode
|
|
2390
2437
|
? h("div", { className: "dru-actions", style: { marginTop: 10 } },
|
|
@@ -2402,6 +2449,33 @@ window.__ModuleLoader__.load({
|
|
|
2402
2449
|
);
|
|
2403
2450
|
}
|
|
2404
2451
|
|
|
2452
|
+
// 【自动化】待重启时自动倒计时重启(见 autoRestartCancelled 说明);倒计时只在面板可见且无其他操作时推进。
|
|
2453
|
+
// ⚠️ isRefreshOnly(插件更新后只需刷新)时**不**自动重启:刷新是零风险动作,重启 dsh web 会打断
|
|
2454
|
+
// 用户正在进行的会话,不能替他做这个决定 —— 让他点「刷新页面」即可。
|
|
2455
|
+
useEffect(function () {
|
|
2456
|
+
if (!restartPending || isRefreshOnly || busy !== "" || restartCancelled) { setRestartCountdown(null); return undefined; }
|
|
2457
|
+
var at = restartInfo.at || 0;
|
|
2458
|
+
if (autoRestartCancelled(at)) { setRestartCountdown(null); return undefined; }
|
|
2459
|
+
var left = Math.round(AUTO_RESTART_DELAY_MS / 1000);
|
|
2460
|
+
setRestartCountdown(left);
|
|
2461
|
+
var iv = setInterval(function () {
|
|
2462
|
+
if (document.hidden) return; // 用户没在看 → 暂停计时
|
|
2463
|
+
left -= 1;
|
|
2464
|
+
if (left > 0) { setRestartCountdown(left); return; }
|
|
2465
|
+
clearInterval(iv);
|
|
2466
|
+
setRestartCountdown(0);
|
|
2467
|
+
restartHarness(); // 已有实现:重启 + 轮询等它回来 + 自动刷新页面
|
|
2468
|
+
}, 1000);
|
|
2469
|
+
return function () { clearInterval(iv); };
|
|
2470
|
+
}, [restartPending, isRefreshOnly, restartInfo.at, busy, restartCancelled]);
|
|
2471
|
+
|
|
2472
|
+
/** 取消本次自动重启(按事件持久化:同一 pending 事件不再自动重启)。 */
|
|
2473
|
+
var cancelAutoRestart = function () {
|
|
2474
|
+
markAutoRestartCancelled(restartInfo.at || 0);
|
|
2475
|
+
setRestartCancelled(true);
|
|
2476
|
+
setRestartCountdown(null);
|
|
2477
|
+
};
|
|
2478
|
+
|
|
2405
2479
|
/** 交流群弹窗(复用满意度弹窗的 dru-popup 视觉;点遮罩/关闭即收起)。 */
|
|
2406
2480
|
function renderCommunityModal() {
|
|
2407
2481
|
if (!commOpen || !(community && community.qrcode)) return null;
|
|
@@ -2439,16 +2513,46 @@ window.__ModuleLoader__.load({
|
|
|
2439
2513
|
h("span", { className: "dru-restart-alert-icon" }, "🔔"),
|
|
2440
2514
|
h("div", { style: { flex: "1" } },
|
|
2441
2515
|
h("div", { className: "dru-restart-alert-title" },
|
|
2442
|
-
|
|
2516
|
+
// 插件已改为 patch 热加载:装插件/在线更新都不再需要重启 harness,
|
|
2517
|
+
// 唯一要用户动一下的情形是"插件文件在运行中被改写"→ 刷新页面即可。
|
|
2518
|
+
isRefreshOnly ? "插件已更新,刷新页面即可生效" : "需要重启 dsh web 才能载入新插件"),
|
|
2443
2519
|
h("div", { className: "dru-restart-alert-sub" },
|
|
2444
|
-
|
|
2520
|
+
isRefreshOnly
|
|
2521
|
+
? "刚装好/刚更新的插件已在磁盘上,浏览器里的面板还是旧版本。刷新一下这个页面即可(手机端连接与已登录状态都不受影响)。"
|
|
2522
|
+
: "当前运行中的 dsh web 里还没有载入这个插件。先刷新页面;如果刷新后仍然看不到面板,再重启一次 dsh web。"),
|
|
2523
|
+
// 【自动化】待重启时自动倒计时重启(用户零操作);倒计时期间可一键取消
|
|
2524
|
+
restartCountdown !== null && restartCountdown > 0
|
|
2525
|
+
? h("div", { className: "dru-restart-auto", role: "status" },
|
|
2526
|
+
h("span", { className: "loading" }),
|
|
2527
|
+
h("span", null, "安装已完成,将在 " + restartCountdown + " 秒后自动重启 DeepSeek harness(页面会自动恢复,无需操作)"))
|
|
2528
|
+
: null,
|
|
2529
|
+
restartCountdown === 0
|
|
2530
|
+
? h("div", { className: "dru-restart-auto", role: "status" }, h("span", { className: "loading" }), h("span", null, "正在自动重启…"))
|
|
2531
|
+
: null,
|
|
2532
|
+
restartCancelled && restartCountdown === null
|
|
2533
|
+
? h("div", { className: "dru-restart-auto muted" }, "已取消自动重启 —— 你也可以随时点下面的按钮完成安装")
|
|
2534
|
+
: null,
|
|
2445
2535
|
h("div", { className: "dru-actions", style: { marginTop: 10 } },
|
|
2536
|
+
isRefreshOnly
|
|
2537
|
+
? h("button", {
|
|
2538
|
+
type: "button",
|
|
2539
|
+
className: "dru-btn dru-btn-primary",
|
|
2540
|
+
onClick: function () { try { location.reload(); } catch (e) { /* 忽略 */ } }
|
|
2541
|
+
}, "刷新页面")
|
|
2542
|
+
: null,
|
|
2446
2543
|
h("button", {
|
|
2447
2544
|
type: "button",
|
|
2448
|
-
className: "dru-btn dru-btn-primary",
|
|
2545
|
+
className: "dru-btn " + (isRefreshOnly ? "dru-btn-ghost" : "dru-btn-primary"),
|
|
2449
2546
|
disabled: busy !== "",
|
|
2450
2547
|
onClick: function () { restartHarness(); }
|
|
2451
|
-
}, busy === "restart" ? "重启中…" : "重启
|
|
2548
|
+
}, busy === "restart" ? "重启中…" : (restartCountdown !== null && restartCountdown > 0 ? "立即重启(不用等)" : "重启 dsh web")),
|
|
2549
|
+
restartCountdown !== null && restartCountdown > 0
|
|
2550
|
+
? h("button", {
|
|
2551
|
+
type: "button",
|
|
2552
|
+
className: "dru-btn dru-btn-ghost",
|
|
2553
|
+
onClick: cancelAutoRestart
|
|
2554
|
+
}, "取消自动重启")
|
|
2555
|
+
: null
|
|
2452
2556
|
)
|
|
2453
2557
|
)
|
|
2454
2558
|
)
|
|
@@ -2457,13 +2561,13 @@ window.__ModuleLoader__.load({
|
|
|
2457
2561
|
),
|
|
2458
2562
|
// 常驻入口最底部:「重启 DeepSeek harness」按钮始终可达(首次安装/更新/排查都用它)
|
|
2459
2563
|
h("div", { className: "dru-restart-foot" },
|
|
2460
|
-
h("div", { className: "dru-hint" }, "重启
|
|
2564
|
+
h("div", { className: "dru-hint" }, "重启 dsh web:仅在「刷新页面后仍看不到面板」时才需要。日常安装/更新插件已自动热加载,装完刷新页面即可;重启只影响 dsh web 本身,不影响 bridge 与手机端连接。"),
|
|
2461
2565
|
h("button", {
|
|
2462
2566
|
type: "button",
|
|
2463
2567
|
className: "dru-btn " + (restartPending ? "dru-btn-primary" : "dru-btn-ghost"),
|
|
2464
2568
|
disabled: busy !== "",
|
|
2465
2569
|
onClick: function () { restartHarness(); }
|
|
2466
|
-
}, busy === "restart" ? "重启中…" : "🔄 重启
|
|
2570
|
+
}, busy === "restart" ? "重启中…" : "🔄 重启 dsh web")
|
|
2467
2571
|
),
|
|
2468
2572
|
renderCommunityModal()
|
|
2469
2573
|
);
|
package/lib/index.js
CHANGED
|
@@ -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)时会再上报一次,覆盖「已登录用户升级插件」的口径。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.4-beta.
|
|
3
|
+
"version": "0.6.4-beta.10",
|
|
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",
|