@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.2

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.
Files changed (29) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +148 -9
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  5. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  6. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  7. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
  8. package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
  9. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  10. package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
  11. package/clients/dsh-remote/wechat-channel.mjs +2705 -0
  12. package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
  13. package/docs/telemetry.md +31 -2
  14. package/dsh-setup.mjs +134 -40
  15. package/package.json +6 -5
  16. package/packages/dsh-remote-web/lib/client.js +799 -65
  17. package/packages/dsh-remote-web/lib/index.js +460 -10
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  20. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  21. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  22. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  23. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  24. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  25. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  26. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  27. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
  28. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  29. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
package/docs/telemetry.md CHANGED
@@ -56,7 +56,34 @@
56
56
  `install_started`(开始补装运行环境)· `install_failed`(附 `fail_code`)· `runtime_ready`(运行环境就绪)·
57
57
  `bridge_started`(bridge 进程拉起)· `bridge_registered`(设备已在中继注册成功 = 真正可用)·
58
58
  `tunnel_disconnected` · `first_remote_ok`(首次远程打通)· `plugin_loaded` · `panel_opened` ·
59
- `harness_restart`(自动重启触发)· `update_started` · `update_failed`(附 `fail_code`)
59
+ `harness_restart`(自动重启触发)· `update_started` · `update_failed`(附 `fail_code`)·
60
+ `wechat_bound` / `wechat_unbound`(微信机器人通道的**绑定态跳变**,语义见下)
61
+
62
+ ### 微信机器人通道的绑定 / 解绑(`wechat_bound` / `wechat_unbound`)
63
+
64
+ 微信机器人通道是**两态**的:**未绑定**或**已绑定**(状态存在用户自己电脑上的
65
+ `<relayDir>/.wechat-state.json`,只有 `bound` 这一个布尔量决定态)。为了知道"这个功能有没有人真的用起来",
66
+ 插件会在 `bound` **发生翻转**时各发一条事件:
67
+
68
+ | 事件 | 何时发 |
69
+ | --- | --- |
70
+ | `wechat_bound` | 未绑定 → 已绑定(用户扫码完成了绑定) |
71
+ | `wechat_unbound` | 已绑定 → 未绑定(用户在面板里解绑) |
72
+
73
+ 口径边界(这三条决定了这些数字**能**读成什么、**不能**读成什么):
74
+
75
+ - **只在跳变时发**:连续多次读到同一个 `bound` 值**一条都不发**。面板在扫码/绑定期间会反复轮询状态,
76
+ 若按"读到已绑定就记一条",同一台机器的一次绑定会被刷成几十条。
77
+ - **首次观测不算绑定**:进程启动后第一次读到状态文件时,如果它**已经是** `bound: true`,说明这次绑定
78
+ 可能发生在几天前(宿主与面板过一段时间才会被打开)——此时只**播种基线**、不发 `wechat_bound`。
79
+ 把"启动时就已经绑好"当成新绑定,会把历史存量每天都虚报一遍。基线记在 `.telemetry-once.json`(`0600`)。
80
+ - **这是客户端上报的事件,不是服务端的实时存量**:绑定关系只存在用户自己的电脑上,服务端没有设备侧
81
+ 权威清单。因此统计里出现的 `0` 只代表「这个窗口内没有上报」——老版本客户端不上报、从未打开过面板或
82
+ 没跑过后台服务的机器也会漏报——**不代表「没有人绑定」**;任何"当前绑定数"都只是按 `bound − unbound`
83
+ 的**近似**(且下限为 0:解绑一台更早时期绑定的机器会让差值偏小)。
84
+ - 读取失败不等于"未绑定":状态文件缺失、损坏或写入中断(半写)时,本次**没有观测**,不产生任何事件,
85
+ 基线也不动 —— 否则会凭空造出一次假的"跳变"。
86
+
60
87
 
61
88
  ### 失败码白名单(`fail_code`)
62
89
 
@@ -88,6 +115,8 @@
88
115
  - ❌ 设备指纹 `machine_fp`(同机识别用,**只**上报给账号 API 用于顶替旧设备,不进遥测)
89
116
  - ❌ 原始 IP(服务端只看到 TCP 来源,客户端不上报 IP)、精确地理位置、GPS
90
117
  - ❌ 用户行为轨迹、页面浏览路径、点击流、崩溃堆栈原文
118
+ - ❌ 微信通道的任何标识:`bot_id`、`bot_token`、被绑定的微信用户标识、绑定时间等
119
+ (`wechat_bound` / `wechat_unbound` 这两条事件**只带事件名**,代码里连读都不读状态文件里的其它字段)
91
120
 
92
121
  遥测请求**不带 `Authorization` 头**:它是一个与账号体系解耦的匿名通道,
93
122
  服务端也不接受用账号凭据关联这些事件。
@@ -118,7 +147,7 @@ export DSH_REMOTE_TELEMETRY=0
118
147
  | --- | --- | --- |
119
148
  | `.telemetry-install-id` | `0600` | 一行随机 UUID(首次生成后复用) |
120
149
  | `.telemetry-queue.json` | `0600` | 待发事件队列(上限 200 条,超出**丢最旧**) |
121
- | `.telemetry-once.json` | `0600` | 一次性事件标记(如 `first_remote_ok` 只发一次) |
150
+ | `.telemetry-once.json` | `0600` | 一次性事件标记(如 `first_remote_ok` 只发一次)+ 微信通道**绑定态的基线**(上次观测到已绑定/未绑定,见第 2 节的跳变规则) |
122
151
 
123
152
  发送行为:
124
153
 
package/dsh-setup.mjs CHANGED
@@ -28,7 +28,7 @@ import os from "node:os";
28
28
  import path from "node:path";
29
29
  import { spawn, spawnSync, execSync } from "node:child_process";
30
30
  import { fileURLToPath } from "node:url";
31
- import { childStopped } from "./clients/dsh-remote/src/lifecycle.mjs";
31
+ import { childStopped, stopChildGracefully } from "./clients/dsh-remote/src/lifecycle.mjs";
32
32
 
33
33
  const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
34
34
  const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
@@ -41,7 +41,9 @@ const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
41
41
  const CONFIG_DIR = process.env.DSH_RELAY_DIR || path.join(os.homedir(), ".dsh-remote");
42
42
  const CONFIG_PATH = path.join(CONFIG_DIR, ".dsh-config.json");
43
43
  // 默认云端服务地址(服务商 SaaS 入口;自建用户用 --server/--key 指向自己的 router)
44
- const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
44
+ // DSH_RELAY_DEFAULT_API 可覆盖(测试脚本指向本地死端口,避免兜底路径打到生产)。
45
+ const DEFAULT_API = String(process.env.DSH_RELAY_DEFAULT_API || "").trim()
46
+ || "https://n.risegao.cn:13443/relay-api";
45
47
  const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
46
48
  const REPO_URL = "https://github.com/mrRisega/dsh-remote";
47
49
 
@@ -87,7 +89,7 @@ function findDepWs(startDir) {
87
89
  }
88
90
 
89
91
  /** 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。 */
90
- const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs"];
92
+ const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs"];
91
93
 
92
94
  /**
93
95
  * 把"客户端脚本"补齐到配置目录。
@@ -273,7 +275,15 @@ function hardenFile(file) {
273
275
  try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
274
276
  if (process.platform !== "win32") return;
275
277
  const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
276
- if (!who) return;
278
+ // 两个环境变量都取不到时**绝不能静默返回**:那会让人以为文件已加固,实际仍是继承 ACL。
279
+ // 正常 Windows 会话不会走到这里(USERNAME 必然存在),但受限令牌/服务账户下有可能。
280
+ if (!who) {
281
+ if (!hardenWarned) {
282
+ hardenWarned = true;
283
+ console.warn(`⚠️ 无法收紧文件权限(USERDOMAIN/USERNAME 均未设置):${file}`);
284
+ }
285
+ return;
286
+ }
277
287
  let r;
278
288
  try {
279
289
  r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
@@ -406,22 +416,74 @@ function pidAlive(pid) {
406
416
  * 注册 Windows 登录任务(幂等:先删再建)。
407
417
  * /DELAY 0000:15:登录后等 15 秒再起,避免与桌面环境抢启动时机
408
418
  * (watcher 本身就会等 dsh web 的 3080 端口,晚起没有任何副作用)。
409
- * ⚠️ 已知限制(如实告知,不假装完美):ONLOGON 任务在用户会话里运行,登录时会有一个控制台窗口;
410
- * 想彻底隐藏需要"不管用户是否登录都运行"(存密码/S4U) 或第三方隐藏器,都需要额外凭据或依赖,
411
- * 故这里只做「能自启」,并在安装汇总里说明用户可在任务计划程序里勾「隐藏」。
419
+ *
420
+ * 【修复「黑色空白命令行窗口」】早期版本把 `node dsh-setup.mjs run` 直接交给任务计划程序,
421
+ * 并把它当成"只能忍受"的限制 —— 这不是限制,是 bug:
422
+ * · 任务计划程序在**交互会话**里启动进程,不会给 CREATE_NO_WINDOW,
423
+ * 控制台程序(node.exe)于是必然弹出一个黑色空白窗口;用户关掉它 = 杀掉 watcher,
424
+ * 而 dsh web 的自愈轮次又会把它拉回来 → 表现为"窗口关不掉、一直重开",体验极差。
425
+ * · 零依赖的彻底隐藏办法:任务只负责起 `wscript.exe`,脚本里用
426
+ * `WScript.Shell.Run cmd, 0, False`(窗口样式 0 = 不显示;不等待 = node 成为独立进程)。
427
+ * wscript.exe 与 VBScript 是 Windows 自带组件,**不需要凭据、不需要 S4U/存密码、
428
+ * 不需要第三方隐藏器**(旧注释误判为必须付出这些代价,实测不成立)。
429
+ * · watcher 仍由它自己写 `.dsh-watcher.pid`,所以 pid 判活/幂等完全不受这层包装影响。
430
+ * · 极少数机器被组策略禁用 Windows Script Host → 注册失败时自动退回直接启动,
431
+ * 保证「能自启」这条底线不丢(代价是那种机器上仍会有窗口,但不至于完全没自启)。
432
+ * 旧版本注册的可见任务会被这里的 /Delete + /Create 一并迁移成隐藏形式(重装/一键更新即生效)。
412
433
  */
413
434
  let winTaskCache = null;
414
435
 
436
+ /** 隐藏启动器(.vbs)路径:放配置目录,随重装覆盖。 */
437
+ function winHiddenLauncherPath() {
438
+ return path.join(CONFIG_DIR, "launch-hidden.vbs");
439
+ }
440
+
441
+ /**
442
+ * 生成隐藏启动器 —— `wscript.exe //B <此文件>` 会在完全隐藏的窗口里执行
443
+ * `"<node>" "<setup>" run`。
444
+ * 转义要点:VBScript 的字符串字面量内嵌 `"` 要写成 `""`,故先把整个命令行的引号翻倍。
445
+ */
446
+ function writeWinHiddenLauncher(node, setup) {
447
+ const vbsPath = winHiddenLauncherPath();
448
+ const commandLine = `"${node}" "${setup}" run`; // 真正要执行的命令行
449
+ const literal = commandLine.replace(/"/g, '""'); // 嵌进 VBS 字符串字面量后
450
+ const src = [
451
+ "' dsh-remote 自动生成(勿手改,重装会覆盖)",
452
+ "' 隐藏启动 bridge 守护:窗口样式 0 = 不显示窗口;False = 不等待,node 成为独立进程。",
453
+ `CreateObject("WScript.Shell").Run "${literal}", 0, False`,
454
+ "",
455
+ ].join("\r\n");
456
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
457
+ fs.writeFileSync(vbsPath, src, "utf8");
458
+ return vbsPath;
459
+ }
460
+
415
461
  function writeWindowsTask() {
416
462
  const node = NODE_BIN;
417
463
  const setup = runtimeSetupPath();
418
- const tr = `"${node}" "${setup}" run`;
464
+ // 首选 wscript 包装(无黑窗);写不了 .vbs 就退回直接启动(有窗,但保底能自启)
465
+ let direct = `"${node}" "${setup}" run`;
466
+ let tr = direct;
467
+ let hidden = false;
468
+ try {
469
+ tr = `wscript.exe //B "${writeWinHiddenLauncher(node, setup)}"`;
470
+ hidden = true;
471
+ } catch (e) {
472
+ console.warn(`⚠️ 无法生成隐藏启动器(${e.message}),自启动时会显示命令行窗口。`);
473
+ }
419
474
  if (tr.length > 255) {
420
475
  console.log(`⚠️ 自启动命令过长(${tr.length} 字符,schtasks 上限 261):${tr}`);
421
476
  console.log(" 建议把 dsh-remote 装到更短的路径,或用 `dsh-remote run` 手动运行 bridge。");
422
477
  }
423
478
  schtasks(["/Delete", "/TN", WIN_TASK_NAME, "/F"]); // 幂等清理(不存在也只是非 0 退出,不影响后续
424
- const created = schtasks(["/Create", "/TN", WIN_TASK_NAME, "/TR", tr, "/SC", "ONLOGON", "/DELAY", "0000:15", "/F"]);
479
+ let created = schtasks(["/Create", "/TN", WIN_TASK_NAME, "/TR", tr, "/SC", "ONLOGON", "/DELAY", "0000:15", "/F"]);
480
+ if (!created.ok && hidden) {
481
+ // 组策略禁用 Windows Script Host 等 → 退回直接启动,别让"自启"整个失效
482
+ console.warn(`⚠️ 隐藏方式注册失败(${(created.stderr || created.stdout).trim() || `退出码 ${created.code}`}),改用直接启动。`);
483
+ tr = direct;
484
+ hidden = false;
485
+ created = schtasks(["/Create", "/TN", WIN_TASK_NAME, "/TR", tr, "/SC", "ONLOGON", "/DELAY", "0000:15", "/F"]);
486
+ }
425
487
  if (!created.ok) {
426
488
  winTaskCache = false;
427
489
  console.warn(`⚠️ 自启动任务注册失败: ${(created.stderr || created.stdout).trim() || `schtasks 退出码 ${created.code}`}`);
@@ -542,7 +604,11 @@ function spawnDetachedBridge() {
542
604
  const child = spawn(NODE_BIN, [runtimeSetupPath(), "run"], {
543
605
  detached: true,
544
606
  stdio: ["ignore", out, out],
545
- env: process.env
607
+ env: process.env,
608
+ // Windows 上 detached 即 DETACHED_PROCESS:子进程**不继承父控制台**,
609
+ // 控制台程序(node.exe)会因此拿到一个**全新的可见窗口**。
610
+ // 必须显式 windowsHide 才会带上 CREATE_NO_WINDOW(POSIX 上该选项被忽略)。
611
+ windowsHide: true
546
612
  });
547
613
  child.unref();
548
614
  fs.closeSync(out);
@@ -953,7 +1019,7 @@ async function runBridge() {
953
1019
  const fp = accountFingerprint(cfg);
954
1020
  if (!childStopped(bridgeProc) && bridgeAccountFp && bridgeAccountFp !== fp) {
955
1021
  console.log("[dsh-remote] 账号配置已变化 → 重启 bridge 让新账号生效...");
956
- try { bridgeProc.kill(); } catch { /* 已退出 */ }
1022
+ await stopChildGracefully(bridgeProc); // 先请它优雅退出,跑完微信通道下线通知
957
1023
  bridgeAccountFp = "";
958
1024
  return; // 下一轮(10s 内)用新配置重新拉起
959
1025
  }
@@ -987,9 +1053,11 @@ async function runBridge() {
987
1053
  for (const k of ["http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "no_proxy", "NO_PROXY"]) {
988
1054
  delete childEnv[k];
989
1055
  }
1056
+ // stdio 的第 4 个 fd 是 IPC 通道:Windows 上 kill() 是**无条件终止**,信号处理函数不会执行,
1057
+ // 只有它能让我们在停 bridge 之前请它跑完退出钩子(notifystop)—— 见 stopChildGracefully。
990
1058
  bridgeProc = spawn(NODE_BIN,
991
1059
  [path.join(THIS_DIR, "clients/dsh-remote/dsh-bridge.mjs")],
992
- { env: childEnv, stdio: "inherit" });
1060
+ { env: childEnv, stdio: ["inherit", "inherit", "inherit", "ipc"] });
993
1061
  bridgeProc.on("exit", () => {
994
1062
  removePidFile(BRIDGE_PID_FILE);
995
1063
  console.log("[dsh-remote] bridge 退出,等待重启...");
@@ -1001,7 +1069,7 @@ async function runBridge() {
1001
1069
  setTimeout(() => { starting = false; }, 5000);
1002
1070
  } else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
1003
1071
  console.log("[dsh-remote] dsh web 离线,停止 bridge...");
1004
- bridgeProc.kill();
1072
+ await stopChildGracefully(bridgeProc); // 同上:Windows 上 kill() 跑不到退出钩子
1005
1073
  }
1006
1074
  };
1007
1075
 
@@ -1011,7 +1079,17 @@ async function runBridge() {
1011
1079
  const cleanup = () => { removePidFile(WATCHER_PID_FILE); removePidFile(BRIDGE_PID_FILE); };
1012
1080
  process.on("exit", cleanup);
1013
1081
  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]) {
1014
- try { process.on(sig, () => { cleanup(); process.exit(0); }); } catch { /* 该信号在本平台不可注册 */ }
1082
+ try {
1083
+ process.on(sig, () => {
1084
+ // 先请 bridge 优雅退出,再给自己留出它跑完退出钩子(notifystop)的时间。
1085
+ // 尤其重要:Windows 上 kill() 是无条件终止,bridge 根本没有机会做收尾。
1086
+ if (bridgeProc && bridgeProc.connected) {
1087
+ try { bridgeProc.send({ type: "shutdown" }); } catch { /* 通道已断,只能随父进程一起被回收 */ }
1088
+ }
1089
+ cleanup();
1090
+ setTimeout(() => process.exit(0), 3000);
1091
+ });
1092
+ } catch { /* 该信号在本平台不可注册 */ }
1015
1093
  }
1016
1094
  }
1017
1095
 
@@ -1478,27 +1556,39 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
1478
1556
  ? pkg.dsh.profile.bundles.filter((b) => PLUGIN_ALL_IDS.includes(b)) : [];
1479
1557
  } catch { return []; }
1480
1558
  };
1481
- // 插件已在 bundles 时默认**不**写 patch 行(两条激活路径互斥,否则 duplicate id 崩树)。
1482
- // 但有两个例外必须主动改写激活点:
1483
- // · forcePatch:源码已被我们换成本地这份(升级场景)→ 必须转 patch 行才能热加载;
1484
- // · 插件市场形态(bundles 里的条目由包管理器管)→ 保持 bundles,不碰。
1559
+ // ★★ 稳定形态 = **只由 `dsh.profile.bundles` 激活**,我们**不再往 patch 里写激活行**。
1560
+ //
1561
+ // 2026-09-22 实测事故:`duplicate loader entry id: dsh-remote-web` →
1562
+ // `dsh: plugin tree failed to load` → **dsh web 启动即挂**(用真实启动复现)。
1563
+ // 根因是**两处激活同时存在**:
1564
+ // (a) 插件自带的 bundle 在 `package.json` 的 `dsh.profile.bundles` 里;
1565
+ // (b) 我们写进 profile `cordis.patch.yml` 的激活行又声明了同一个 id。
1566
+ //
1567
+ // 旧实现走的是**反方向**——"删 bundles 条目、留 patch 行"(见下方已删的 removeBundleEntry 调用),
1568
+ // 还为此写了复杂的回滚。但**自动维护流程会把插件自带 bundle 周期性写回**,
1569
+ // 于是"删了又回来"→ 又变两处 → 再次崩。只要方向是反的,这个 bug 就会反复出现。
1570
+ //
1571
+ // 所以现在改成:**bundle 归包管理器/维护流程管,我们绝不动它**;
1572
+ // 我们只保证自己**不再制造第二个激活点**,并顺手把历史遗留的重复 patch 行清掉。
1485
1573
  const inBundles = pkgBundles();
1486
- if (inBundles.length && !opts.forcePatch) {
1487
- return { ok: false, reason: "插件已由 dsh.profile.bundles 声明(保持单一激活点,不写 patch 行)", changed: false, bundleOnly: true, basePatch: original };
1488
- }
1489
- if (inBundles.length && opts.forcePatch) {
1490
- // 自己动手摘掉 bundles 条目(不依赖调用方的执行顺序),再走 patch 行激活
1491
- removeBundleEntry(pkgFile);
1492
- if (pkgBundles().length) {
1493
- return { ok: false, reason: "无法移除 dsh.profile.bundles 中的本插件条目", changed: false, basePatch: original };
1574
+ if (inBundles.length) {
1575
+ const stripped = stripPluginEntries(original);
1576
+ if (stripped !== original) {
1577
+ writePatchDocument(patchFile, stripped);
1578
+ console.log("✅ 已移除 profile patch 中与本插件重复的激活行(激活统一走 dsh.profile.bundles,避免 duplicate id 崩树)");
1579
+ return { ok: true, changed: true, bundleOnly: true, basePatch: original };
1494
1580
  }
1581
+ return { ok: false, reason: "插件已由 dsh.profile.bundles 声明(保持单一激活点,不写 patch 行)", changed: false, bundleOnly: true, basePatch: original };
1495
1582
  }
1583
+ // 到这里说明 bundles 里没有本插件 —— 此时 patch 行是**唯一**激活点,必须写(升级/热加载场景)。
1584
+ // `forcePatch` 仍然保留语义:它只影响"bundles 为空时"的行为,不再能覆盖上面那条硬约束。
1496
1585
  const base = validatePatchBase(original);
1497
1586
  if (!base.ok) return { ok: false, reason: base.reason, changed: false, basePatch: original };
1498
1587
  const hasOurRow = base.insertedIds.some((id) => PLUGIN_ALL_IDS.includes(id));
1499
1588
  if (hasOurRow) {
1500
- const bundleRemoved = removeBundleEntry(pkgFile);
1501
- if (bundleRemoved) console.log("✅ 已从 dsh.profile.bundles 移除重复激活点(激活统一走 patch 行)");
1589
+ // ⚠️ 走到这里说明 **bundles 里没有本插件**(上面 inBundles 为空的早退已排除两处激活的形态),
1590
+ // 所以 patch 行就是唯一激活点,可以安心保留它 —— 这里**不再**去动 bundles。
1591
+ // (旧实现在这里调 removeBundleEntry,方向是反的:见本函数顶部注释。)
1502
1592
  // 已有条目也要**自我修正**:配置目录变了(换安装方式 / 之前写错)时 relayDir 必须跟着更新,
1503
1593
  // 否则面板会一直去旧目录找 .dsh-config.json(表现就是"未登录 / 空配置")。
1504
1594
  const want = pluginBlock(CONFIG_DIR);
@@ -1516,7 +1606,7 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
1516
1606
  }
1517
1607
  console.warn("⚠️ 激活行的 relayDir 与当前配置目录不一致,但未能安全改写(保持原样)");
1518
1608
  }
1519
- return { ok: true, changed: bundleRemoved, basePatch: original };
1609
+ return { ok: true, changed: false, basePatch: original };
1520
1610
  }
1521
1611
  // 写前再次校验我们的块本身可解析(id 行必须能被 parsePatchInsertIds 认出来)
1522
1612
  // ⚠️ relayDir 必须传**配置目录**(CONFIG_DIR, 即 ~/.dsh-remote),不是插件安装目录:
@@ -1539,13 +1629,9 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
1539
1629
  writePatchDocument(patchFile, original); // 回滚
1540
1630
  return { ok: false, reason: "写入后校验失败,已回滚", changed: false, basePatch: original };
1541
1631
  }
1542
- removeBundleEntry(pkgFile); // 单一激活点:bundles 里不能再有本插件
1543
- // 双保险:上面那次移除若没生效(并发写 package.json / 解析失败),**回滚本次 patch 行**,
1544
- // 宁可退回 bundles 形态(需重启)也绝不留"两处激活"→ dsh web 启动会 duplicate id 崩掉。
1545
- if (pkgBundles().length) {
1546
- writePatchDocument(patchFile, original);
1547
- return { ok: false, reason: "bundles 条目未能移除,已回滚 patch 行以免重复激活", changed: false, bundleOnly: true, basePatch: original };
1548
- }
1632
+ // ⚠️ 这里**不再** removeBundleEntry:能走到这行说明 bundles 里本来就没有本插件
1633
+ // (inBundles 为空的早退已保证),删是空操作;而且方向是反的 —— 见函数顶部注释。
1634
+ // 写前/写后双校验已经确保"我们的 id 在 patch 里恰好一次",这就是唯一激活点。
1549
1635
  return { ok: true, changed: true, basePatch: original };
1550
1636
  }
1551
1637
 
@@ -1785,8 +1871,14 @@ function removeBundleEntry(pkgFile) {
1785
1871
  }
1786
1872
 
1787
1873
  /**
1788
- * 把我们手上这份插件包落到 profile 并激活(唯一激活点 = patch 行 → 热加载)。
1789
- * 两条路径共用:① 无依赖或 file: 依赖(我们自己管源码);② 市场装法但本地版本更新。
1874
+ * 把我们手上这份插件包落到 profile 并激活。
1875
+ *
1876
+ * ★ 2026-09-22 方向反转:激活点**优先由 `dsh.profile.bundles` 承担**,我们不再写 patch 行。
1877
+ * 旧实现为了"装完即热加载"而摘掉 bundles、改走 patch 行;但**自动维护流程会把插件自带
1878
+ * bundle 周期性写回** bundles,于是"删了又回来"→ 两处激活 → dsh web 启动即
1879
+ * `duplicate loader entry id: dsh-remote-web` 崩树。
1880
+ * 现在稳定方向 = **bundle 归包管理器管、我们不动它**;代价是失去热加载,
1881
+ * 由**安装器随后触发的重启**来让新版生效(`hotPatch:false` → 调用方会重启)。
1790
1882
  * @returns {{hotPatch:boolean, changed:boolean, basePatch?:string}|undefined}
1791
1883
  */
1792
1884
  function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg, opts = {}) {
@@ -1821,11 +1913,13 @@ function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg
1821
1913
  declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
1822
1914
  const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, { forcePatch: !!opts.forcePatch });
1823
1915
  if (r.ok) {
1824
- console.log(`✅ 插件已就绪: ${pluginLocalDir}${opts.forcePatch ? "(激活点已转为 patch 行,装完即热加载)" : ""}`);
1916
+ // 走到这里 = 插件**不在** bundles,所以 patch 行是唯一激活点,可以热加载。
1917
+ console.log(`✅ 插件已就绪: ${pluginLocalDir}(激活点为本插件的 patch 行,装完即热加载)`);
1825
1918
  return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
1826
1919
  }
1827
1920
  if (r.bundleOnly) {
1828
- console.log("ℹ 该插件已由 dsh.profile.bundles 声明(保持单一激活点,不重复写入 patch 行)");
1921
+ // 稳定形态:bundle 归包管理器管,我们不写第二个激活点。代价是无热加载 → 调用方会重启。
1922
+ console.log("ℹ 插件已由 dsh.profile.bundles 激活(保持单一激活点,不重复写 patch 行;重启后生效)");
1829
1923
  } else {
1830
1924
  console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
1831
1925
  ensureBundleEntry(pkgFile);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.9",
3
+ "version": "0.6.10-beta.2",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -52,10 +52,11 @@
52
52
  "scripts": {
53
53
  "test": "npm run test:router && npm run test:plugin && npm run test:bridge",
54
54
  "test:router": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs",
55
- "_comment:test:plugin": "DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)",
56
- "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
57
- "test:bridge": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test clients/dsh-remote/test/*.test.mjs",
58
- "check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
55
+ "_comment:test:plugin": "DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)。DSH_RELAY_DEFAULT_API= 死端口:用例拆掉临时目录后,仍在飞的后台请求(装机上报重试等)会读到空配置并回落「默认云端地址」—— 指到本地死端口才不会打生产。",
56
+ "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
57
+ "_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
58
+ "test:bridge": "DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 clients/dsh-remote/test/*.test.mjs",
59
+ "check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
59
60
  "release:tarballs": "bash scripts/release-tarballs.sh"
60
61
  },
61
62
  "dependencies": {