@mrrisega/dsh-remote 0.7.0-beta.2 → 0.7.0-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.
@@ -199,10 +199,22 @@ const SPECIAL_EVENT_KINDS = Object.freeze([
199
199
  const NOTICE_FORMATTER_KINDS = Object.freeze(["quota", "membership"]);
200
200
 
201
201
  /**
202
- * 主动推送失败后的**待补发队列**上限(见 `#pushProactive`)。
203
- * 为什么是 20:够放下"一轮里所有该告诉用户的事";再多下去,补发就变成刷屏了。
202
+ * 主动推送失败后的**待补发队列**上限(见 `#pushProactive`):只留**最近几条**。
203
+ *
204
+ * 2026-10-09 业主口径:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
205
+ * 从 20 收到 5 的理由:一轮里真正还值得看的就最近那么两三条;攒到 20 条的结果是
206
+ * 平台一恢复就**一次连打十几条** —— 实测正是这种突发批量触发 `ret=-2 prepare failed`
207
+ * 被限流,于是补发又失败、队列再涨,越积越多永远还不上(现场观察到的就是这个循环)。
208
+ */
209
+ const MAX_OUTBOX = 5;
210
+ /**
211
+ * 待补发条目的**保质期**:超过这个时长还没送达就丢弃(先丢最旧的)。
212
+ *
213
+ * 为什么必须有(同一条业主口径):一条"任务跑完了"的通知迟到几小时就**毫无价值** ——
214
+ * 补发出去只会让用户以为刚刚才发生;而队列的价值在于"网络抖动期间的短暂失败",
215
+ * 那是分钟级的。2 小时足够覆盖抖动与短时离线,又不会把半天前的旧事翻出来刷屏。
204
216
  */
205
- const MAX_OUTBOX = 20;
217
+ const OUTBOX_TTL_MS = 2 * 60 * 60 * 1000;
206
218
  /** 补发时的正文前缀(迟到必须如实说,不能让用户以为刚刚才发生)。 */
207
219
  const OUTBOX_MARK = "(补发)";
208
220
 
@@ -337,6 +349,12 @@ function sendJson(res, status, body) {
337
349
  }
338
350
 
339
351
  export class WeChatRuntime {
352
+ /**
353
+ * 「预期内的订阅拒绝」已报过的 (code|sessionId) 集合 —— 见 fault 处理器。
354
+ * 只是防刷屏:每生成一个**子代理**会话都会被订阅层拒一次,不记集合就是每个子代理刷一行 warn。
355
+ */
356
+ #expectedFaultSeen = new Set();
357
+
340
358
  /**
341
359
  * @param {{
342
360
  * relayDir: string,
@@ -508,7 +526,9 @@ export class WeChatRuntime {
508
526
  followed_sessions: this.subscriber && typeof this.subscriber.sessions === "function"
509
527
  ? this.subscriber.sessions()
510
528
  : [],
511
- pending_outbox: Array.isArray(state.pending_outbox) ? state.pending_outbox.length : 0,
529
+ // 报**有效**条数(按保质期过滤后):否则进程重启后,面板会一直显示那几条早已过期、
530
+ // 永远不会被补发的条目,看起来像"队列卡住了"。真正的清理由 #trimOutbox 落盘。
531
+ pending_outbox: this.#pruneOutbox(state.pending_outbox).kept.length,
512
532
  last_fault: state.last_fault || null,
513
533
  last_completed_session_id: state.last_completed_session_id || "",
514
534
  last_completed_at: Number(state.last_completed_at || 0),
@@ -1547,14 +1567,44 @@ export class WeChatRuntime {
1547
1567
  return list;
1548
1568
  }
1549
1569
 
1570
+ /**
1571
+ * 丢掉**过期**条目(返回保留的与丢掉的数量)。
1572
+ * `at` 缺失/非法 = 更早的落盘格式(那时还没有 at 字段)→ 一律视为过期:
1573
+ * 留着只会把"最近几条"挤掉,而它们必然比 TTL 更老。
1574
+ */
1575
+ #pruneOutbox(list) {
1576
+ const now = this.clock();
1577
+ const kept = [];
1578
+ let dropped = 0;
1579
+ for (const item of Array.isArray(list) ? list : []) {
1580
+ const at = Number(item && item.at);
1581
+ if (!Number.isFinite(at) || at <= 0 || now - at > OUTBOX_TTL_MS) { dropped += 1; continue; }
1582
+ kept.push(item);
1583
+ }
1584
+ return { kept, dropped };
1585
+ }
1586
+
1587
+ /** 丢弃过期的、并按上限只留最近几条;丢了就**如实记一行日志**(绝不静默丢)。 */
1588
+ #trimOutbox(list) {
1589
+ const { kept, dropped } = this.#pruneOutbox(list);
1590
+ let overflow = 0;
1591
+ while (kept.length > MAX_OUTBOX) { kept.shift(); overflow += 1; }
1592
+ const lost = dropped + overflow;
1593
+ if (lost > 0) {
1594
+ const mins = Math.round(OUTBOX_TTL_MS / 60000);
1595
+ const why = [dropped ? `${dropped} 条超 ${mins} 分钟未送达` : "", overflow ? `${overflow} 条超出最近 ${MAX_OUTBOX} 条上限` : ""]
1596
+ .filter(Boolean).join("、");
1597
+ this.logger.warn(`[wechat] 待补发队列丢弃 ${lost} 条(${why}),保留最近 ${kept.length} 条`);
1598
+ }
1599
+ return kept;
1600
+ }
1601
+
1550
1602
  #enqueueOutbox(text, kind = "") {
1551
1603
  const body = String(text || "");
1552
1604
  if (!body) return 0;
1553
1605
  const list = this.#outbox();
1554
1606
  list.push({ text: body, kind: String(kind || ""), at: this.clock() });
1555
- // 上限:丢最旧的(用户最需要的是"最近发生了什么",而无限攒下去只会让补发像刷屏)
1556
- while (list.length > MAX_OUTBOX) list.shift();
1557
- return this.#writeOutbox(list).length;
1607
+ return this.#writeOutbox(this.#trimOutbox(list)).length;
1558
1608
  }
1559
1609
 
1560
1610
  /**
@@ -1562,7 +1612,11 @@ export class WeChatRuntime {
1562
1612
  * @returns {Promise<number>} 这次补发成功的条数
1563
1613
  */
1564
1614
  async #flushOutbox(to) {
1565
- const list = this.#outbox();
1615
+ // 补发前先按保质期清一遍:**过期的不发**(迟到几小时的"任务跑完了"只会误导用户),
1616
+ // 也避免一次性连打十几条把平台打限流(那正是队列越积越多的成因)。
1617
+ const raw = this.#outbox();
1618
+ const list = this.#trimOutbox(raw); // 只在真丢了东西时才落盘(避免每轮都写状态文件)
1619
+ if (list.length !== raw.length) this.#writeOutbox(list);
1566
1620
  if (!list.length || !to) return 0;
1567
1621
  let sent = 0;
1568
1622
  const rest = [...list];
@@ -1645,10 +1699,20 @@ export class WeChatRuntime {
1645
1699
  const code = String((info && info.code) || "unknown");
1646
1700
  const message = String((info && info.message) || "").slice(0, 160);
1647
1701
  const sessionId = String((info && info.sessionId) || "");
1648
- this.logger.warn(`[wechat/events] 订阅异常 ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
1649
- if (!(info && info.expected === true)) {
1650
- this.channel.writeState({ last_fault: { code, message, session_id: sessionId, at: this.clock() } });
1702
+ // 预期内的拒绝(子代理会话必然被拒 `session/agent-busy` / 会话刚被删 `session/not-found`)
1703
+ // **不是故障**:每生成一个子代理就会来一条,写成 warn 会把真正的故障淹掉
1704
+ // (2026-10-09 现场:日志里成片 `订阅异常 session/agent-busy … require their durable parent address`)。
1705
+ // 但也不能完全静默(2026-09-25 的教训:fault 以前没人接,全丢)→ 降为 info,且同一对只报一次。
1706
+ if (info && info.expected === true) {
1707
+ const key = `${code}|${sessionId}`;
1708
+ if (this.#expectedFaultSeen.size < 200 && !this.#expectedFaultSeen.has(key)) {
1709
+ this.#expectedFaultSeen.add(key);
1710
+ this.logger.info(`[wechat/events] 订阅跳过(预期,不重试) ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
1711
+ }
1712
+ return;
1651
1713
  }
1714
+ this.logger.warn(`[wechat/events] 订阅异常 ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
1715
+ this.channel.writeState({ last_fault: { code, message, session_id: sessionId, at: this.clock() } });
1652
1716
  });
1653
1717
  }
1654
1718
 
package/dsh-setup.mjs CHANGED
@@ -362,9 +362,13 @@ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/
362
362
  * 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
363
363
  * DSH_BRIDGE_INSTALL_SOURCE=plugin_market,这里原样透传(外部注入 > 硬编码默认)。
364
364
  * install_version:优先取外部注入(插件/在线更新器),否则取本包 package.json 版本。
365
+ * harness_version:**只能由外部注入** —— 宿主(DeepSeek Harness)版本要从宿主进程的 argv /
366
+ * 安装目录推出来,而安装器是独立进程,推不出来。插件会在宿主进程内问出来再传进来;
367
+ * 用户直接跑 `npx @mrrisega/dsh-remote` 时没有这个值 → 留空(= 未上报,绝不猜一个错的)。
365
368
  */
366
369
  const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
367
370
  const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
371
+ const HARNESS_VERSION = String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
368
372
 
369
373
  /**
370
374
  * 把文件权限收紧到「只有本人可读写」。
@@ -708,7 +712,7 @@ function writeAutostartFile() {
708
712
  <key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
709
713
  <key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
710
714
  <key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
711
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
715
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${HARNESS_VERSION}</string></dict>
712
716
  </dict></plist>`;
713
717
  // ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
714
718
  // 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
@@ -724,7 +728,7 @@ function writeAutostartFile() {
724
728
  if (process.platform === "linux") {
725
729
  const dir = path.join(os.homedir(), ".config/systemd/user");
726
730
  fs.mkdirSync(dir, { recursive: true });
727
- const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
731
+ const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\nEnvironment=DSH_BRIDGE_HARNESS_VERSION=${HARNESS_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
728
732
  const unitPath = autostartFilePath();
729
733
  fs.writeFileSync(unitPath, unit);
730
734
  return unitPath; // 路径在最终汇总里统一展示
@@ -803,6 +807,28 @@ function spawnDetachedBridge() {
803
807
  * 但实测 kickstart 只解决"这一次",进程被杀后不会重建(KeepAlive 已被系统绕过)→ 如实告知
804
808
  * ③ 后台进程 ← 连 launchd 都托管不了时保证可用
805
809
  */
810
+ /**
811
+ * 🔴 标签占用保护(2026-10-10 事故):`com.dshremote.bridge` 这个 launchd label 是**全局唯一**的,
812
+ * 谁 bootstrap 谁就接管它。当天跑测试时,某个用例用**假 HOME + 真 launchctl** 把测试临时目录的
813
+ * plist 登记到了同一个 label 上 → **真桥被顶掉**、远程访问中断,而日志里看不出任何异常。
814
+ *
815
+ * 所以 bootstrap 之前先问一句:这个 label 现在登记的 plist 是**本 relayDir 的**那份吗?
816
+ * · 不是(别人家的)→ **拒绝接管**并明确报错。要强行接管得显式设
817
+ * `DSH_BRIDGE_ALLOW_LABEL_TAKEOVER=1`(给人留后门,但默认安全)。
818
+ * · 查不出来(launchctl 不可用/未登记)→ 照旧继续,不误伤正常安装。
819
+ */
820
+ function labelOwnedByOtherDir(plistPath, domain) {
821
+ try {
822
+ const r = sh(`launchctl print ${domain}/${BRIDGE_LABEL}`);
823
+ if (!r.ok) return null; // 未登记 → 不是"别人家的"
824
+ const cur = String(r.stdout || "").match(/^\s*path = (.+)$/m);
825
+ if (!cur) return null; // 读不出路径 → 不拦
826
+ const a = cur[1].trim();
827
+ const b = String(plistPath).trim();
828
+ return a === b ? false : a; // 返回"占用者的 plist 路径"= 别人家的
829
+ } catch { return null; }
830
+ }
831
+
806
832
  function startBridgeDarwin(plistPath) {
807
833
  const uid = process.getuid();
808
834
  const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
@@ -813,6 +839,16 @@ function startBridgeDarwin(plistPath) {
813
839
  for (const d of [`user/${uid}`, `gui/${uid}`]) {
814
840
  if (d !== domain) sh(`launchctl bootout ${d}/${BRIDGE_LABEL}`);
815
841
  }
842
+ // 先确认这个 label 不是别人(别的 relayDir)的,否则**拒绝接管**——见 labelOwnedByOtherDir
843
+ const foreign = labelOwnedByOtherDir(plistPath, domain);
844
+ if (foreign && String(process.env.DSH_BRIDGE_ALLOW_LABEL_TAKEOVER || "") !== "1") {
845
+ return {
846
+ ok: false,
847
+ status: "label-owned-by-other",
848
+ detail: `${domain}/${BRIDGE_LABEL} 已被另一个 relayDir 占用(${foreign})—— 拒绝接管。`
849
+ + `真要用本目录接管,请先停掉那边的桥,或显式设 DSH_BRIDGE_ALLOW_LABEL_TAKEOVER=1。`,
850
+ };
851
+ }
816
852
  sh(`launchctl bootout ${target}`);
817
853
  let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
818
854
  if (!boot.ok) {
@@ -1505,6 +1541,8 @@ async function runBridge() {
1505
1541
  // 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
1506
1542
  DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
1507
1543
  DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
1544
+ // 宿主版本:外部注入(插件在宿主进程内问出来的),安装器只是搬运工;没有就是空串
1545
+ DSH_BRIDGE_HARNESS_VERSION: HARNESS_VERSION,
1508
1546
  ...(saas ? { DSH_BRIDGE_PHONE: (cfg.phone || cfg.email || ""), DSH_BRIDGE_PASSWORD: cfg.password, DSH_BRIDGE_API: apiUrl, DSH_BRIDGE_SECRET: cfg.bridge_secret || "" } : {}),
1509
1547
  ...(local ? { DSH_BRIDGE_LOCAL_KEY: cfg.local_key } : {})
1510
1548
  };
@@ -2191,10 +2229,21 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
2191
2229
  // 注意:插件源(npx 缓存 / npm 全局)本身就位于某个 node_modules 之下,
2192
2230
  // 过滤必须基于“相对插件目录”的路径片段,绝不能整条 src 路径包含判断,
2193
2231
  // 否则会把插件文件全过滤掉、拷出空目录(此前反复“插件入口缺失”的总根因)。
2232
+ //
2233
+ // 🔴 2026-10-10 修:**插件自带的 runtime/node_modules 不能被剔掉**。
2234
+ // 旧规则是"任何一段叫 node_modules 就过滤",这条原本只想剔掉插件自己的依赖目录
2235
+ // (那些由 profile 的包管理器管),却把 `runtime/node_modules/ws` 一起剔了 ——
2236
+ // 而那份运行时正是"只有插件半"的机器**自愈补装时的来源**。剔掉它 = 自愈出来的运行环境
2237
+ // 缺 ws(bridge 顶层 `import WebSocket from "ws"`)→ 一启动就崩,还"自检通过"。
2238
+ // 现在:`runtime/` 之内一律保留;只过滤 runtime 之外的 node_modules。
2194
2239
  filter: (src) => {
2195
2240
  if (src === pluginDir) return true;
2196
- const rel = path.relative(pluginDir, src);
2197
- return !String(rel).split(path.sep).some((seg) => seg === "node_modules" || seg === ".git" || seg === ".DS_Store");
2241
+ const rel = String(path.relative(pluginDir, src));
2242
+ const segs = rel.split(path.sep);
2243
+ if (segs.some((seg) => seg === ".git" || seg === ".DS_Store")) return false;
2244
+ const inBundledRuntime = segs[0] === "runtime";
2245
+ if (!inBundledRuntime && segs.some((seg) => seg === "node_modules")) return false;
2246
+ return true;
2198
2247
  }
2199
2248
  };
2200
2249
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.0-beta.2",
3
+ "version": "0.7.0-beta.4",
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",
@@ -51,13 +51,14 @@
51
51
  },
52
52
  "scripts": {
53
53
  "test": "npm run test:router && npm run test:plugin && npm run test:bridge",
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 packages/relay-router/test/device-free-assets.test.mjs packages/relay-router/test/sse-streaming-contract.test.mjs",
55
- "_comment:test:plugin": "DSH_RELAY_BUNDLED_RUNTIME=/nonexistent = 强制走 npx 兜底分支(否则本机跑过 npm pack 后 packages/dsh-remote-web/runtime 存在,用例会改成走本地拷贝)。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 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
54
+ "test:router": "DSH_PROFILE_DIR= 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 packages/relay-router/test/device-free-assets.test.mjs packages/relay-router/test/sse-streaming-contract.test.mjs",
55
+ "_comment:test:plugin": "DSH_PROFILE_DIR= = 测试隔离铁律:本套件跑在 DSH 宿主里时环境**天然带着** DSH_PROFILE_DIR(指向真实 ~/.dsh/profiles/desktop),而多数用例只隔离了 HOME —— 于是它们会去写**真实** profile 的 cordis.patch.yml(2026-10-10 事故:测试 relayDir 被写进真 profile,面板连到假配置、验证码/公告全 502)。这里显式清空,让 profile 解析回落到各用例自己的假 HOME。 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent = 强制走 npx 兜底分支(否则本机跑过 npm pack 后 packages/dsh-remote-web/runtime 存在,用例会改成走本地拷贝)。DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)。DSH_RELAY_DEFAULT_API= 死端口:用例拆掉临时目录后,仍在飞的后台请求(装机上报重试等)会读到空配置并回落「默认云端地址」—— 指到本地死端口才不会打生产。",
56
+ "test:plugin": "DSH_PROFILE_DIR= DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
57
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",
58
+ "test:bridge": "DSH_PROFILE_DIR= 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
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",
60
- "release:tarballs": "bash scripts/release-tarballs.sh"
60
+ "release:tarballs": "bash scripts/release-tarballs.sh",
61
+ "sync:local": "node scripts/sync-local.mjs"
61
62
  },
62
63
  "dependencies": {
63
64
  "ws": "^8.18.0"
@@ -9,6 +9,10 @@
9
9
  // ① 官方「设置」→「远程访问」栏目(settings.section,位于「Agent 预设」下方,官方扩展点);
10
10
  // ② 左侧官方「设置」按钮旁注入独立「📱 远程访问」快捷按钮(id=dru-nav-remote,非 clone,
11
11
  // 插在其前方、同布局不覆盖官方按钮;点击直达该栏目,首次带引导红点)
12
+ // ⚠️ 只认**语义锚点**(`button[aria-haspopup="dialog"]`,且必须在设置弹层之外):
13
+ // 0.2 桌面端的设置面板本身就是 role="dialog" 的弹层,它的栏目也叫「通用设置」,
14
+ // 旧代码用「文本包含『设置』」去找锚点 → 按钮被塞进设置面板栏目列表最上面(业主反馈的
15
+ // 「植入错了地方」)。现在找不到锚点就**不注入**,绝不将就一个错误的落点。
12
16
  // - 栏目内联渲染配置面板(浅色高对比 UI,遵循主流登录体验)
13
17
  // · 登录态:已登录显示账号 + 退出登录;未登录显示 登录/注册 tabs
14
18
  // · 🔒 修改密码(账号卡):已登录账号经 图形验证码+短信验证码 重置(/dsh-remote/password/reset 代理);
@@ -465,7 +469,7 @@ window.__ModuleLoader__.load({
465
469
  ".dru-e2ee-line.ok{color:var(--dru-success)}",
466
470
  // ── 📣 公告(0.6.18)──────────────────────────────────────────────────────
467
471
  // 侧栏入口上的**未读数字徽标**:与首次引导红点(.dru-nav-remote-dot)同一落点。
468
- // 两者同时出现会叠在一起,所以 JS 侧是"徽标优先"(有未读就不挂红点,见 mountNavEntry)。
472
+ // 两者同时出现会叠在一起,所以 JS 侧是"徽标优先"(有未读就不挂红点,见 RemoteNavEntry)。
469
473
  // 数字用白字蓝底(不是红底):红点=「你还没点过我」,未读数=「有多少条内容」,
470
474
  // 语义不同就不该长一样;红色在这个面板里已经被"危险/未读公告本身"占用了。
471
475
  ".dru-nav-remote-badge{position:absolute;top:-7px;right:-7px;min-width:16px;height:16px;box-sizing:border-box;padding:0 4px;border-radius:999px;background:var(--dru-accent-fill);color:var(--dru-on-accent);font:700 10.5px/16px inherit;text-align:center;box-shadow:0 0 0 2px var(--dru-surface-2);pointer-events:none}",
@@ -511,6 +515,13 @@ window.__ModuleLoader__.load({
511
515
  // 独立 button(非 clone),插在官方「设置」按钮之前,同源同布局不覆盖官方热区。
512
516
  ".dru-nav-remote{display:inline-flex;align-items:center;gap:6px;height:30px;margin:0 0 2px;padding:0 10px;border:1px solid var(--dru-border-ctl);border-radius:8px;background:var(--dru-surface-2);color:var(--dru-accent);font:500 12.5px/1 inherit;cursor:pointer;white-space:nowrap;user-select:none}",
513
517
  ".dru-nav-remote:hover:not(:disabled){background:var(--dru-accent-tint-2);border-color:var(--dru-accent)}",
518
+ // 📱 侧栏底部入口(官方槽位 sidebar.footer.action 内,2026-10-10 三修):
519
+ // 原生底部项是「整行、无边框、hover 浅底、14px 字」,之前那个自绘胶囊按钮与邻居格格不入。
520
+ ".dru-foot-entry{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;min-height:32px;padding:6px 10px;border:none;border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:14px;text-align:left;cursor:pointer;position:relative;white-space:nowrap;user-select:none}",
521
+ ".dru-foot-entry:hover{background:var(--dru-hover)}",
522
+ ".dru-foot-entry:focus-visible{outline:2px solid var(--dru-focus);outline-offset:1px}",
523
+ ".dru-foot-entry .ico{flex:none;width:18px;text-align:center;font-size:15px;line-height:1}",
524
+ ".dru-foot-entry .lbl{overflow:hidden;text-overflow:ellipsis}",
514
525
  ".dru-nav-remote-dot{position:absolute;top:-2px;right:-2px;width:8px;height:8px;border-radius:50%;background:var(--dru-reddot);box-shadow:0 0 0 2px var(--dru-surface-2);pointer-events:none}",
515
526
  // 侧栏入口与官方按钮同高(30px);用 ::after 把命中区补到 ≥44px,不破坏与原按钮的对齐
516
527
  ".dru-nav-remote::after{content:\"\";position:absolute;left:0;right:0;top:-7px;bottom:-7px}",
@@ -792,90 +803,167 @@ window.__ModuleLoader__.load({
792
803
  return !!el.closest('[role="navigation"], nav, [class*="sidebar" i], [class*="sidenav" i], [class*="appnav" i], [class*="navRail" i]');
793
804
  } catch (e) { return false; }
794
805
  }
795
- function pickNavByToken(token) {
806
+ /**
807
+ * 是否位于「设置」弹层内部。
808
+ *
809
+ * 🔴 这一条是 2026-10-09 桌面端事故的根治点。官方设置面板本身就是一个
810
+ * `role="dialog" aria-modal="true"` 的弹层(0.2 实测,见 ui-settings-general 的 SettingsRoot),
811
+ * 它的左侧栏目同样长得像「导航」(`nav` + `navCell`),于是会被 navCellLike 命中。
812
+ * 旧代码用「文本包含『设置』」去找侧栏那颗齿轮按钮 —— 而弹层里的第一项叫
813
+ * **「通用设置」**,`indexOf("设置") !== -1` 直接命中,注入按钮就被塞进了
814
+ * 设置面板的栏目列表最上面(用户反馈的「植入错了地方」)。
815
+ */
816
+ function navInDialog(el) {
817
+ try { return !!el.closest('[role="dialog"], [aria-modal="true"]'); } catch (e) { return false; }
818
+ }
819
+ /**
820
+ * 官方「设置」触发器(侧栏那颗齿轮按钮)—— **只认语义锚点**。
821
+ *
822
+ * 两个版本(Web 0.1.x / 桌面端 0.2)的触发器都是同一个形状(各自实测源码一致):
823
+ * `<button aria-label="设置" aria-haspopup="dialog" aria-expanded=…>`,外层 `…triggerRow`。
824
+ * `aria-haspopup="dialog"` 是**语言无关**的语义标记,比匹配文案稳(换语言/换文案都不失效)。
825
+ * 找不到就**不注入** —— 宁可不显示快捷入口,也绝不把按钮塞到别人家的面板里。
826
+ */
827
+ /**
828
+ * 官方「设置」触发器的**候选列表**(设置-like 的排前面)。
829
+ *
830
+ * 🔴 2026-10-10 修(业主实测"点入口没反应"):这里以前还要求 `navCellLike(el)`
831
+ * —— 而桌面端侧栏那套 class 里**没有** sidebar/nav 字样(都是 `_3WPZCG_*`),
832
+ * 于是候选全被挡掉 → 触发器 null → 点击静默返回,表现就是"点了没反应"。
833
+ * 现在只认语义特征:`aria-haspopup="dialog"` + 不在弹层内 + 不是菜单类;
834
+ * 其中 **带 aria-keyshortcuts / 文案或 aria-label 含「设置」** 的优先(语言无关 + 精确)。
835
+ */
836
+ function settingsTriggerCandidates() {
837
+ var out = [];
838
+ try {
839
+ var cands = document.querySelectorAll('button[aria-haspopup="dialog"], [aria-haspopup="dialog"]');
840
+ var rest = [];
841
+ for (var i = 0; i < cands.length; i++) {
842
+ var el = cands[i];
843
+ if (!el || el.id === NAV_ENTRY_ID) continue; // 排除我们自己的入口
844
+ if (navInDialog(el)) continue; // 弹层内的(关闭键/栏目)一律不要
845
+ if (NAV_BAD_RE.test(String(el.className || ""))) continue;
846
+ var label = String(navAttrOf(el) || navCleanText(el) || "");
847
+ var hasShortcut = !!(el.getAttribute && el.getAttribute("aria-keyshortcuts"));
848
+ if (hasShortcut || /设置|Settings/i.test(label)) out.push(el);
849
+ else rest.push(el);
850
+ }
851
+ return out.concat(rest); // 兜底也不能空着:实在认不出就逐个试(见 openRemoteSettings)
852
+ } catch (e) { return out; }
853
+ }
854
+ /** 官方「设置」触发器(首选候选,保持旧名字给测试与调用方用)。 */
855
+ function pickSettingsTrigger() {
856
+ var list = settingsTriggerCandidates();
857
+ return list.length ? list[0] : null;
858
+ }
859
+ /**
860
+ * 按文案/aria-label 找导航项。
861
+ * @param {string} token 目标文案(如「远程访问」「设置」)
862
+ * @param {{exactOnly?:boolean, insideDialog?:boolean}} [opts]
863
+ * exactOnly=true 只接受完全相等;insideDialog 限定是否必须在设置弹层内。
864
+ * 无论哪种模式:**精确相等优先于子串**(老代码只要子串命中就返回,误伤面很大)。
865
+ */
866
+ function pickNavByToken(token, opts) {
867
+ var exactOnly = !!(opts && opts.exactOnly);
868
+ var insideDialog = opts ? opts.insideDialog : undefined;
796
869
  try {
797
870
  var els = document.querySelectorAll("button, [role='tab'], [role='menuitem'], [role='button'], a, [aria-label]");
871
+ var loose = null;
798
872
  for (var i = 0; i < els.length; i++) {
799
873
  var el = els[i];
800
874
  if (!el || el.id === NAV_ENTRY_ID) continue; // 排除注入按钮自身(防递归)
801
875
  if (!navCellLike(el)) continue;
802
876
  if (NAV_BAD_RE.test(String(el.className || ""))) continue;
877
+ var inDlg = navInDialog(el);
878
+ if (insideDialog === true && !inDlg) continue;
879
+ if (insideDialog === false && inDlg) continue;
803
880
  var t = navCleanText(el);
804
- if (t && !/\r|\n/.test(t) && t.length <= NAV_TEXT_MAX &&
805
- (t === token || t.indexOf(token) === 0 || t.indexOf(token) !== -1)) return el;
806
881
  var at = navAttrOf(el);
807
- if (at && (at === token || at.indexOf(token) === 0)) return el;
882
+ var textOK = !!(t && !/\r|\n/.test(t) && t.length <= NAV_TEXT_MAX);
883
+ if ((textOK && t === token) || (at && at === token)) return el; // 精确命中:立即返回
884
+ if (!loose && ((textOK && t.indexOf(token) !== -1) || (at && at.indexOf(token) === 0))) loose = el;
808
885
  }
886
+ return exactOnly ? null : loose;
809
887
  } catch (e) { /* 忽略 */ }
810
888
  return null;
811
889
  }
812
- /** 打开「远程访问」设置栏目:栏目已渲染直接点;否则先点官方「设置」再 ≤3s 轮询补点。 */
890
+ /**
891
+ * 打开「远程访问」设置栏目。
892
+ * 栏目已渲染 → 直接点;否则**依次试每个"设置"候选**:点一下 → 轮询 ≤3s 看栏目出没出来 →
893
+ * 没出来就换下一个候选。为什么要逐个试(2026-10-10):桌面端侧栏可能同时有多个
894
+ * `aria-haspopup="dialog"`(账号/更多…),认错了就会"点了没反应";逐个试则只要有**一个**
895
+ * 能打开设置面板,入口就一定能用。
896
+ */
813
897
  function openRemoteSettings() {
814
898
  try {
815
- var cell = pickNavByToken("远程访问");
899
+ var cell = pickNavByToken("远程访问", { insideDialog: true });
816
900
  if (cell) { cell.click(); return; }
817
- var st = pickNavByToken("设置");
818
- if (!st) return;
819
- st.click();
820
- var tries = 0;
821
- var iv = setInterval(function () {
822
- tries++;
823
- var c2 = pickNavByToken("远程访问");
824
- if (c2) { clearInterval(iv); c2.click(); }
825
- else if (tries >= NAV_POLL_MAX) clearInterval(iv);
826
- }, 150);
901
+ var cands = settingsTriggerCandidates();
902
+ if (!cands.length) return;
903
+ var idx = 0;
904
+ var tryNext = function () {
905
+ if (idx >= cands.length) return;
906
+ var el = cands[idx++];
907
+ try { el.click(); } catch (eClick) { return tryNext(); }
908
+ var tries = 0;
909
+ var iv = setInterval(function () {
910
+ tries++;
911
+ var c2 = pickNavByToken("远程访问", { insideDialog: true });
912
+ if (c2) { clearInterval(iv); c2.click(); return; }
913
+ if (tries >= NAV_POLL_MAX) { clearInterval(iv); tryNext(); } // 这个候选没打开设置 → 换下一个
914
+ }, 150);
915
+ try { if (iv && typeof iv.unref === "function") iv.unref(); } catch (eIv) { /* 忽略 */ }
916
+ };
917
+ tryNext();
827
918
  } catch (e) { /* 忽略 */ }
828
919
  }
829
- /** 注入侧栏按钮到官方「设置」按钮之前(独立 button;首次未见过引导红点)。 */
830
- function mountNavEntry(host) {
831
- try {
832
- if (document.getElementById(NAV_ENTRY_ID)) return;
833
- var wrap = host && host.parentNode;
834
- if (!wrap || !wrap.insertBefore || !wrap.contains) return;
835
- var btn = document.createElement("button");
836
- btn.type = "button";
837
- btn.id = NAV_ENTRY_ID;
838
- btn.className = "dru-nav-remote";
839
- btn.setAttribute("data-dru-remote", "1");
840
- // 🎨 侧栏入口在面板之外,拿不到面板根上的令牌 → 自己也挂一份主题属性
841
- btn.setAttribute("data-dru-theme", themeGet());
842
- themeState.navEl = btn;
843
- // 入口常驻,所以它自己就是主题观察器的一个订阅者:
844
- // 用户切深色时(哪怕面板没开着)这里也要立刻跟上。
845
- themeStartWatch();
846
- btn.title = "打开「远程访问」设置(快捷入口)";
847
- btn.style.position = "relative";
848
- btn.textContent = "📱 远程访问";
849
- btn.addEventListener("click", function (e) {
850
- try { e.stopPropagation(); e.preventDefault(); } catch (err) {}
851
- dotMarkSeen(); // 用过快捷入口=已了解入口位置 → 关闭首次红点(含官方栏目红点)
852
- openRemoteSettings();
853
- });
854
- wrap.insertBefore(btn, host);
855
- // 徽标优先于首次引导红点:两者落点相同(右上角),同时挂会叠在一起。
856
- // 有未读公告时"有多少条内容"比"你还没点过我"更值得占那个位置;红点本来也只到用户点一次为止。
857
- var badgeOn = !!(annState.ok && annState.bubble && annState.unread > 0);
858
- if (!dotSeen() && !badgeOn) {
859
- var d = document.createElement("span");
860
- d.className = "dru-nav-remote-dot";
861
- btn.appendChild(d);
920
+ /**
921
+ * 打开「远程访问」栏目的入口(供官方槽位里的按钮调用)。
922
+ * 由下面的导航模块赋值 —— 让"入口在哪渲染"与"点了怎么打开"彻底解耦。
923
+ */
924
+ var remoteNavOpen = null;
925
+
926
+ /* 📱 侧栏底部的「远程访问」入口(2026-10-10 三修:改用**官方槽位**)。====================
927
+ 前两次都栽在"自己找 DOM 落点"上:
928
+ · 一修前:按文案包含「设置」找锚点 → 命中的是设置面板里的「通用设置」→ 按钮跑到设置面板顶部;
929
+ · 二修:改成只认 aria-haspopup="dialog" 且必须在弹层外 → 桌面端侧栏那颗齿轮不满足
930
+ navCellLike,于是**干脆不注入**(业主看到的就是"那个位置是空的")。
931
+ 而宿主本来就为这个位置提供了官方列表槽 `sidebar.footer.action`(0.1.x 与 0.2 都有;
932
+ 0.2 实测该槽就在侧栏底部、且**当前是空的**)—— 位置、宽度、折叠态全由宿主负责。
933
+ 所以这里不再注入任何 DOM,直接注册进槽位。id 仍保留 `dru-nav-remote`:
934
+ 公告徽标 / 首次红点那套按 id 取节点的既有逻辑因此不用改。 */
935
+ function RemoteNavEntry(props) {
936
+ var wide = !(props && props.wide === false);
937
+ var seenArr = useState(false);
938
+ var clicked = seenArr[0];
939
+ var setClicked = seenArr[1];
940
+ var showDot = !clicked && !dotSeen();
941
+ return h("button", {
942
+ type: "button",
943
+ id: NAV_ENTRY_ID,
944
+ className: "dru-foot-entry",
945
+ title: "打开「远程访问」设置",
946
+ "data-dru-remote": "1",
947
+ onClick: function (e) {
948
+ try { e.preventDefault(); } catch (err) { /* 忽略 */ }
949
+ setClicked(true);
950
+ dotMarkSeen(); // 用过入口 = 已了解位置 → 收起首次红点
951
+ if (remoteNavOpen) remoteNavOpen();
862
952
  }
863
- annRenderNavBadge();
864
- } catch (e) { /* 忽略 */ }
953
+ }, [
954
+ h("span", { className: "ico", key: "ico" }, "📱"),
955
+ wide ? h("span", { className: "lbl", key: "lbl" }, "远程访问") : null,
956
+ showDot ? h("span", { className: "dru-nav-remote-dot", key: "dot" }) : null
957
+ ]);
865
958
  }
866
- /** 常驻确保:官方「设置」按钮出现后在它前面挂一次入口(去重;2s 心跳,成本极低)。 */
959
+
867
960
  function navEnsureStart() {
868
961
  if (window.__dshRemoteNavStarted) return;
869
962
  window.__dshRemoteNavStarted = true;
870
963
  try { window.__dshRemoteNav = { open: openRemoteSettings }; } catch (e) {}
871
- var iv = setInterval(function () {
872
- try {
873
- if (document.getElementById(NAV_ENTRY_ID)) return; // 已注入
874
- var host = pickNavByToken("设置");
875
- if (host) mountNavEntry(host);
876
- } catch (e) { /* 忽略 */ }
877
- }, 2000);
878
- try { if (iv && typeof iv.unref === "function") iv.unref(); } catch (e) {} // node 测试环境不阻塞退出
964
+ // ⚠️ 这里**不再**做 DOM 注入(见 RemoteNavEntry 的注释):入口由官方槽位渲染。
965
+ // 保留本函数只为实现"继续暴露 window.__dshRemoteNav"(老调用方与诊断仍在用)。
966
+ try { if (typeof openRemoteSettings === "function") remoteNavOpen = openRemoteSettings; } catch (e) { /* 忽略 */ }
879
967
  }
880
968
 
881
969
  // ══ 📣 公告(0.6.18)模块级状态 ═══════════════════════════════════════════
@@ -5673,6 +5761,22 @@ window.__ModuleLoader__.load({
5673
5761
  // 【0.6.11】本插件在这个槽里**只有一个栏目**(📱 远程访问):🤖 微信机器人原先以 order 31
5674
5762
  // 注册成第二个栏目,业主口径「不要给它单独弄一个菜单,直接放到面板里面」→ 已改成面板里的
5675
5763
  // 第三个 tab(renderMainTabs / renderWeChat)。不要再往这个槽里加第二个 register。
5764
+ /* 📱 侧栏底部的快捷入口 —— 官方列表槽 `sidebar.footer.action`(2026-10-10 三修)。
5765
+ 这个槽由 `@deepseek-ai/dsh-client-ui-sidebar` 声明,渲染在侧栏底部的 footerActions 里
5766
+ (在「设置」那一行的上方、与「意见反馈/登录/更多」同属底部区域)。0.1.x 与 0.2 都有该槽,
5767
+ 0.2 实测此槽当前为空 —— 正合业主"就要放那个位置"的要求,而且不用我们再猜任何 DOM。
5768
+ order 60:排在该槽内既有条目之后(宿主自己的条目不带 order,按注册序在前)。
5769
+ 老宿主若没有这个槽,`inject` 会静默不生效(不会抛)——面板内的 settings.section 入口仍在。 */
5770
+ try {
5771
+ ctx.slots.inject("sidebar.footer.action", function () {
5772
+ return ctx.slots.register({
5773
+ name: "sidebar.footer.action",
5774
+ id: "dsh-remote-nav",
5775
+ order: 60,
5776
+ label: function () { return "远程访问"; }
5777
+ }, RemoteNavEntry);
5778
+ });
5779
+ } catch (eFootSlot) { /* 老宿主没有这个槽:忽略,面板入口仍然可用 */ }
5676
5780
  ctx.slots.inject("settings.section", function () {
5677
5781
  var d1 = ctx.slots.register({
5678
5782
  name: "settings.section",
@@ -5708,7 +5812,7 @@ window.__ModuleLoader__.load({
5708
5812
  try {
5709
5813
  // 侧栏徽标的 DOM 订阅者(非 React):store 一变就重画徽标。
5710
5814
  // 即便入口按钮此刻还没注入(官方「设置」按钮尚未出现),订阅本身也是零成本的 ——
5711
- // mountNavEntry 挂上按钮时会主动画一次。
5815
+ // RemoteNavEntry 挂上按钮时会主动画一次。
5712
5816
  annSubscribe(annRenderNavBadge);
5713
5817
  annPullOnce();
5714
5818
  } catch (e) { /* 静默降级:不显示公告入口/徽标,页面照常 */ }
@@ -5718,7 +5822,7 @@ window.__ModuleLoader__.load({
5718
5822
  // 侧栏「远程访问」快捷入口(2026-09 恢复注入:与官方「设置」按钮共存不遮挡)
5719
5823
  navEnsureStart();
5720
5824
  // 🎨 主题:**不在这里**建 observer —— 观察器只在真有订阅者时才创建
5721
- // (面板/弹窗挂载 → themeSubscribe;侧栏入口挂上 → mountNavEntry)。
5825
+ // (面板/弹窗挂载 → themeSubscribe;侧栏入口挂上 → RemoteNavEntry)。
5722
5826
  // 这样「没有任何东西需要跟随主题」时不会白留一个 MutationObserver,
5723
5827
  // 也避免与本插件已有的红点观察器互相干扰。
5724
5828