@mrrisega/dsh-remote 0.6.0-beta.11 → 0.6.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,16 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
28
28
  └→ bridge (电脑端) → 127.0.0.1:3080 (dsh web)
29
29
  ```
30
30
 
31
+ ## 0.6.0-beta.12 速览(用户最关心的新能力)
32
+
33
+ - **扫码即加密,不再要求再输一次密码**:用电脑生成的二维码/一次性链接进入后,由电脑自动授权开启端到端加密;密码登录点设备也自动解锁。全程不输 E2EE 密码、不存服务端。
34
+ - **记住本机:刷新/重开不掉回明文**:解锁一次后,手机 App 刷新、镜像页刷新都自动恢复加密(退出登录即清除;浏览器无痕可不用此功能)。
35
+ - **移动端体验**:选完会话抽屉自动收起;加密状态徽标几秒后自动缩成小 🔒 不挡界面;修复了移动端设置里“模型提供方目录加载失败”。
36
+ - **电脑端侧栏**:官方「设置」旁新增「📱 远程访问」快捷按钮(与官方按钮共存、不遮挡),一键进入远程访问面板。
37
+ - **支付**:关闭收款码时会问“支付完成 / 取消支付”,便于尽快为你开通,不再挽留打扰。
38
+ - **评价只评一次**:按你的账号/设备只收集一次满意度。
39
+ - **更省流量更隐私**:面板不再明文显示手机号、不上报真实主机名;状态轮询间隔降到 15–30s(隐藏页面时暂停)。
40
+
31
41
  ## 界面预览
32
42
 
33
43
  | 手机端进入 dsh web,100% 还原电脑端体验 | 手机端密钥登录后的在线设备列表 |
@@ -63,7 +73,7 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
63
73
  npx @mrrisega/dsh-remote
64
74
  ```
65
75
 
66
- 安装完成后**无需任何命令**:打开 dsh web → 设置 → 「远程控制」,注册/登录手机号即可
76
+ 安装完成后**无需任何命令**:打开 dsh web → 设置 → 「远程访问」,注册/登录手机号即可
67
77
  (注册在手机端完成,登录后 bridge 自动启动;自建用户在同一面板切「自建服务」标签)。
68
78
  原来的独立设置页(`dsh-remote settings`)已移除,避免与插件面板重复造成困惑。
69
79
 
@@ -78,7 +88,7 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
78
88
 
79
89
  > **版本与更新 / 卸载**:dsh 官方插件市场目前不提供更新按钮,也不会改写用户补丁(因此市场
80
90
  > 卸载会提示「仍通过 insert 引用 dsh-remote-web」而拒绝)。插件设置面板里已内置管理入口
81
- > (dsh web → 设置 → 「远程控制」→「🔄 版本与更新」卡片):显示当前版本、自动检测 npm 新版、
91
+ > (dsh web → 设置 → 「远程访问」→「🔄 版本与更新」卡片):显示当前版本、自动检测 npm 新版、
82
92
  > **一键在线更新**(后台补运行环境并重启 bridge,完成后重启 dsh web 生效)、以及**彻底卸载**
83
93
  > (移除补丁 include / 依赖 / bundle 与本地文件,之后市场卸载或直接重启均可完成卸载)。
84
94
 
@@ -139,7 +149,7 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
139
149
  | relay-router | `packages/relay-router/` | 中继服务器:bridge 注册表、实时设备列表、HTTP/WS 透明代理、配额 |
140
150
  | bridge | `clients/dsh-remote/` | 电脑端守护进程:连 router 注册,把转发帧代理到本地 `dsh web`;心跳自愈 |
141
151
  | PWA | `clients/dsh-web/native.html` | 手机端:登录 / 注册 / 设备选择(单文件,零构建) |
142
- | dsh web 插件 | `packages/dsh-remote-web/` | 设置页「远程控制」面板:连接模式 / 账号 / bridge 启停 / 反馈 |
152
+ | dsh web 插件 | `packages/dsh-remote-web/` | 设置页「远程访问」面板:连接模式 / 账号 / bridge 启停 / 反馈 |
143
153
  | 部署脚本 | `deploy/` | `install-open.sh` 自建引导、nginx 参考配置、Dockerfile / compose |
144
154
 
145
155
  测试:`npm test`(router 契约 + 插件 + bridge 全部单测与回归)。
@@ -494,6 +494,10 @@ export async function handleHttpFrame(dchOrSend, frame) {
494
494
  const send = toSender(dchOrSend);
495
495
  const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
496
496
  const t0 = Date.now();
497
+ // 桌面授权引导(方案A):POST /_e2ee/intro(device/channel 形态归一)—— 本地应答,不连上游
498
+ if (method === "POST" && path === "/_e2ee/intro") {
499
+ return answerIntro(send, id);
500
+ }
497
501
  // E2EE 信封标记即信号(§4.3):有标记=加密,无标记=明文 v1。
498
502
  // 解封失败绝不静默降级 → 明文错误 + x-dsh-e2ee-error 头 + 日志。
499
503
  if (hasEnvelopeMarker(headers)) {
@@ -548,6 +552,32 @@ export async function handleHttpFrame(dchOrSend, frame) {
548
552
  }
549
553
  }
550
554
 
555
+ /** 桌面授权引导(方案A):POST /_e2ee/intro —— 桥端本地应答,不连上游。
556
+ * 路由层已按 dsh_token cookie 完成同账号授权(device/channel 形态都只到本设备);
557
+ * 这里仅当桥端 E2EE 启用时一次性下发派生 MK(内存转发,不落盘不写日志)。 */
558
+ function answerIntro(send, id) {
559
+ let ok = false;
560
+ let body;
561
+ try {
562
+ const g = e2ee.introGrant();
563
+ body = { ok: true, v: 2, grant: "desktop-intro", mk: g.mk, profile: g.profile, epoch: g.epoch, ts: Date.now() };
564
+ ok = true;
565
+ console.log("[bridge] e2ee intro → 手机(同一账号,已授权;MK 仅内存一次性下发)");
566
+ } catch (e) {
567
+ const code = e instanceof E2eeError ? e.code : "e2ee_disabled";
568
+ body = { ok: false, v: 2, error: { code, message: e?.message || "电脑端未启用端到端加密,无法授权引导" } };
569
+ console.log(`[bridge] e2ee intro 被拒(${code}): ${e?.message || ""}`);
570
+ }
571
+ send({
572
+ id,
573
+ type: "http",
574
+ status: ok ? 200 : 409,
575
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
576
+ body: Buffer.from(JSON.stringify(body)).toString("base64"),
577
+ bodyBase64: true
578
+ });
579
+ }
580
+
551
581
  /** E2EE 失败回包:明文(无信封标记)+ x-dsh-e2ee-error,绝不把无法解密的密文当正文转发。 */
552
582
  function sendE2eeHttpError(send, id, err) {
553
583
  const code = err instanceof E2eeError ? err.code : "e2ee_failed";
@@ -590,6 +590,23 @@ export class E2eeService {
590
590
  return this.enabled ? [E2EE_CAP] : [];
591
591
  }
592
592
 
593
+ /**
594
+ * 桌面授权引导(方案A,2026-09 用户决策):供同账号手机端在「从未持有密码」的场景(扫码/
595
+ * 一次性链接登录)下免输密码建立 E2EE。桥端在内存中持有派生 MK,此处一次性下发 MK
596
+ * (base64url,仅内存转发;不落盘、不写日志)。调用前提:请求已由 relay-router 按
597
+ * dsh_token cookie 完成「同账号」授权(device/channel 形态都只到本设备)——
598
+ * 信任模型见 docs/e2ee-protocol.md §5.4 [DECISION]:引导瞬间以「持有效扫码会话=账号本人」
599
+ * 为准,存在被中继主动冒充的理论窗口(与扫码登录产品一致);引导后数据面照常走
600
+ * hello/probe 探针(双方已同持 MK)与 AEAD 信封。
601
+ * @returns {{mk:string, profile:string, epoch:number}}
602
+ */
603
+ introGrant() {
604
+ if (!this.enabled || !Buffer.isBuffer(this.mk) || this.mk.length !== MK_BYTES) {
605
+ throw new E2eeError(this.reason || "e2ee_disabled", "电脑端未启用端到端加密,无法授权引导(请先在电脑端登录并确认密码已生效)");
606
+ }
607
+ return { mk: this.mk.toString("base64url"), profile: this.profile, epoch: this.epoch };
608
+ }
609
+
593
610
  state() {
594
611
  return { enabled: this.enabled, reason: this.reason, profile: this.profile, epoch: this.epoch };
595
612
  }
@@ -598,6 +598,19 @@
598
598
  }
599
599
 
600
600
  /* ---- 状态徽标 ---- */
601
+ var _badgeCollapseTimer = null; // 自动收缩(仅 ok 态):展示几秒后缩成仅 🔒,不挡界面
602
+ function _badgeClearCollapse() {
603
+ try { if (_badgeCollapseTimer) { clearTimeout(_badgeCollapseTimer); _badgeCollapseTimer = null; } } catch (e) {}
604
+ }
605
+ function _badgeArmCollapse(el) {
606
+ _badgeClearCollapse();
607
+ _badgeCollapseTimer = setTimeout(function () {
608
+ try {
609
+ if (!el.classList.contains("expanded") && el.classList.contains("ok")) el.classList.add("collapsed");
610
+ } catch (e) {}
611
+ _badgeCollapseTimer = null;
612
+ }, 5000);
613
+ }
601
614
  function _badgeEl() {
602
615
  var el = document.getElementById("dsh-e2ee-badge");
603
616
  if (el) return el;
@@ -613,7 +626,18 @@
613
626
  var note = document.createElement("div");
614
627
  note.className = "dsh-e2ee-badge-note";
615
628
  el.appendChild(ico); el.appendChild(txt); el.appendChild(note);
616
- el.addEventListener("click", function () { el.classList.toggle("expanded"); });
629
+ el.addEventListener("click", function () {
630
+ if (el.classList.contains("collapsed")) {
631
+ // 从仅🔒芯片展开:展示完整文案(文本),不再自动收缩
632
+ el.classList.remove("collapsed");
633
+ el.classList.add("expanded");
634
+ _badgeClearCollapse();
635
+ } else {
636
+ el.classList.toggle("expanded");
637
+ _badgeClearCollapse();
638
+ }
639
+ });
640
+ el.addEventListener("pointerenter", function () { _badgeClearCollapse(); el.classList.remove("collapsed"); });
617
641
  host.appendChild(el);
618
642
  return el;
619
643
  }
@@ -624,7 +648,7 @@
624
648
  if (mode !== "ok" && window.innerWidth > 820) return;
625
649
  var el = _badgeEl();
626
650
  if (!el) return;
627
- el.className = mode; // ok | warn | err
651
+ el.className = mode; // ok | warn | err(重置 expanded/collapsed 由下方状态机管理)
628
652
  el.querySelector(".dsh-e2ee-badge-ico").textContent = mode === "ok" ? "🔒" : "⚠";
629
653
  el.querySelector(".dsh-e2ee-badge-txt").textContent = text;
630
654
  var noteEl = el.querySelector(".dsh-e2ee-badge-note");
@@ -639,6 +663,8 @@
639
663
  }
640
664
  }
641
665
  el.title = text;
666
+ // 自动收缩:仅加密态(ok)几秒后缩成纯 🔒 芯片(warn/err 属安全提示保持文本,不静默)
667
+ if (mode === "ok") _badgeArmCollapse(el);
642
668
  } catch (e) { /* 徽标失败不阻断页面 */ }
643
669
  }
644
670
  function _appUrl() {
@@ -669,6 +695,7 @@
669
695
  } catch (e) { /* ignore */ }
670
696
  }
671
697
 
698
+ /* (刷新恢复见 _boot:优先 localStorage 持久交接单,2026-09) */
672
699
  /* ---- 启动 ---- */
673
700
  function _boot() {
674
701
  try {
@@ -678,7 +705,20 @@
678
705
  }
679
706
  var raw = _readHandover();
680
707
  if (!raw) {
681
- _badge("warn", "未加密:未解锁(明文)", "本次为明文连接。密钥只存内存,刷新/离开后即丢失;请返回设备列表重新点选设备并输入密码解锁后,镜像页的数据请求与 WebSocket 将自动加密。");
708
+ // 刷新/重开:sessionStorage 一次性交接单已被读走 尝试 localStorage「记住本机」持久交接单
709
+ // (2026-09 用户决策;同一解锁会话的 SHK,刷新镜像页后仍能恢复加密)。
710
+ var lsRaw = null;
711
+ try { lsRaw = localStorage.getItem(DSH_E2EE_HANDOVER_KEY); } catch (e) { lsRaw = null; }
712
+ if (lsRaw) {
713
+ var lsSess = null;
714
+ try { lsSess = seSessionOfHandover(lsRaw); } catch (e) { lsSess = null; }
715
+ if (lsSess) {
716
+ _install(lsSess);
717
+ _badge("ok", "端到端加密已开启", "刷新后已自动恢复端到端加密(记住本机);密钥仅存内存。");
718
+ return;
719
+ }
720
+ }
721
+ _badge("warn", "未加密:未解锁(明文)", "本次为明文连接。密钥只存内存;请返回设备列表重新点选设备解锁后,镜像页的数据请求与 WebSocket 将自动加密。");
682
722
  return;
683
723
  }
684
724
  var sess = null;
@@ -84,6 +84,11 @@ const STYLE = `
84
84
  }
85
85
  #dsh-e2ee-badge.expanded .dsh-e2ee-badge-note { display: block; }
86
86
  #dsh-e2ee-badge .dsh-e2ee-badge-note a { color: #0b57d0; text-decoration: underline; }
87
+ /* 自动收缩态(2026-09):加密态展示数秒后缩成仅 🔒 芯片,不挡右上界面;悬停/点击即展开 */
88
+ #dsh-e2ee-badge.collapsed { padding: 5px 7px; gap: 0; max-width: 40px; }
89
+ #dsh-e2ee-badge.collapsed .dsh-e2ee-badge-txt,
90
+ #dsh-e2ee-badge.collapsed .dsh-e2ee-badge-note { display: none; }
91
+ #dsh-e2ee-badge { transition: padding .25s ease, max-width .25s ease; }
87
92
  @media (max-width: 480px) {
88
93
  #dsh-e2ee-badge { padding: 5px 10px; font-size: 11px; }
89
94
  }
@@ -221,6 +221,41 @@ const STYLE = `
221
221
 
222
222
  const SCRIPT = `(() => {
223
223
  "use strict";
224
+
225
+ /* —— 主机门解除(2026-09):镜像页(如 n.risegao.cn)经设备流认证回连同一台 127.0.0.1:3080,
226
+ 桥已把 Host 回环化并剥 Origin(不扩大信任面)。官方客户端以 transport.ownsHost 判定
227
+ 「自有主机」,非回环域名下不置 true 会把设置页置为 memory(不可用)镜像 →
228
+ 模型/提供方设置无法加载(“Settings are unavailable in this browser”)。
229
+ 在模块执行前安装定义陷阱:任何 __DSH_TRANSPORT__ 赋值都带 ownsHost=true。 —— */
230
+ (function () {
231
+ function forceOwns(v) {
232
+ try {
233
+ if (v && typeof v === "object") {
234
+ const d = Object.getOwnPropertyDescriptor(v, "ownsHost");
235
+ if (!d || d.configurable || d.writable) {
236
+ try {
237
+ Object.defineProperty(v, "ownsHost", { configurable: true, enumerable: true, get() { return true; }, set() {} });
238
+ } catch (e) { v.ownsHost = true; }
239
+ } else { v.ownsHost = true; }
240
+ }
241
+ } catch (e) { /* 忽略 */ }
242
+ return v;
243
+ }
244
+ let tr = (() => { try { return globalThis.__DSH_TRANSPORT__ || null; } catch (e) { return null; } })();
245
+ if (tr) { tr = forceOwns(tr); }
246
+ else { tr = { ownsHost: true }; } // 壳未注入 transport 的镜像页也按「自有主机」处理
247
+ try {
248
+ Object.defineProperty(globalThis, "__DSH_TRANSPORT__", {
249
+ configurable: true,
250
+ enumerable: true,
251
+ get() { return tr; },
252
+ set(v) { tr = v ? forceOwns(v) : tr; }
253
+ });
254
+ } catch (e) {
255
+ try { globalThis.__DSH_TRANSPORT__ = tr; } catch (e2) { /* 忽略 */ }
256
+ }
257
+ })();
258
+
224
259
  try {
225
260
  const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
226
261
  const NARROW = () => window.innerWidth <= 820;
@@ -246,8 +281,8 @@ const SCRIPT = `(() => {
246
281
 
247
282
  /* ================= 鲸鱼挂件手机辅助(仅 ≤820 生效;挂件类名 .dshwv-* 稳定) ========= */
248
283
  let whaleGuardsOn = false;
249
- let whaleHitMap = null;
250
- let whaleUnstuckDone = false;
284
+ let whaleHitMap = null; let whaleUnstuckDone = false;
285
+ let drawerAutoCloseOn = false; // 窄屏“选中会话后自动收抽屉”委托只挂一次
251
286
 
252
287
  const WHALE_IMG = () => document.querySelector(".dshwv-img");
253
288
  const WHALE_ROOT = () => document.querySelector(".dshwv-root");
@@ -477,6 +512,25 @@ const SCRIPT = `(() => {
477
512
  new MutationObserver(sync).observe(frame, { attributes: true, attributeFilter: ["data-sidebar-collapsed", "data-details-collapsed"] });
478
513
  } catch (e2) { /* 退化:仅在下次 boot 同步 */ }
479
514
  sync();
515
+ /* 窄屏抽屉:点选会话(激活行)/点侧栏内「新建会话」后自动收起(官方不做;适配层点官方 toggle)。
516
+ 仅当:窄屏 && 抽屉展开 && 点击发生在侧栏抽屉内;行内「⋯」按钮 stopPropagation 不会误触。 */
517
+ if (!drawerAutoCloseOn) {
518
+ drawerAutoCloseOn = true;
519
+ document.addEventListener("click", (e) => {
520
+ try {
521
+ if (!NARROW() || !expanded()) return;
522
+ const t = e.target;
523
+ if (!t || !t.closest) return;
524
+ if (t.closest(".dsh-ma-scrim, .dsh-ma-hamburger")) return;
525
+ if (!t.closest(".dsh-ma-sidebar")) return; // 只处理抽屉内的点选
526
+ const row = t.closest('[role="treeitem"][aria-selected]');
527
+ const isNew = t.closest('[aria-label*="新建会话"], [aria-label*="New session" i]');
528
+ if (!row && !isNew) return;
529
+ const togg = toggleOf();
530
+ if (togg) setTimeout(() => { try { togg.click(); } catch (e5) { /* ignore */ } }, 0);
531
+ } catch (e6) { /* 忽略 */ }
532
+ });
533
+ }
480
534
  whaleHookInit();
481
535
  watchSettings();
482
536
  };
@@ -294,6 +294,19 @@ test("E2eeService:disabled 状态 caps 为空;enabled 后 caps=['e2ee-v2']", ()
294
294
  assert.equal(on.state().reason, "ok");
295
295
  });
296
296
 
297
+ test("E2eeService.introGrant:启用时一次性下发派生 MK(32B b64url);禁用时抛 e2ee_disabled", () => {
298
+ const off = new E2eeService({ enabled: false, reason: "server_disabled" });
299
+ assert.throws(() => off.introGrant(), (e) => e.code === "server_disabled" || e.code === "e2ee_disabled");
300
+ const mk = deriveMasterKey("unit-password", SALT_B64);
301
+ const on = new E2eeService({ enabled: true, mk, salt: SALT_B64, profile: "pbkdf2-sha256-600k", epoch: 3 });
302
+ const g = on.introGrant();
303
+ assert.equal(g.profile, "pbkdf2-sha256-600k");
304
+ assert.equal(g.epoch, 3);
305
+ assert.equal(typeof g.mk, "string");
306
+ assert.equal(Buffer.from(g.mk, "base64url").length, 32);
307
+ assert.equal(Buffer.from(g.mk, "base64url").toString("hex"), mk.toString("hex"), "引导下发的 MK 应与桥端派生 MK 一致(否则手机无法通过探针)");
308
+ });
309
+
297
310
  test("E2eeService.init:服务端 enabled=false/401/无密码/禁用开关 → 明文回退原因", async () => {
298
311
  // 无密码
299
312
  const noPw = await E2eeService.init({ apiBase: "http://127.0.0.1:1", token: "t", password: "" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.0-beta.11",
3
+ "version": "0.6.0-beta.12",
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",
@@ -5,8 +5,10 @@
5
5
  // factory 内只能 require shell 种子模块(react / react/jsx-runtime 等)。
6
6
  //
7
7
  // 功能:
8
- // - 入口=官方「设置」页内的 settings.section「远程访问」栏目(位于「Agent 预设」下方,
9
- // 官方扩展点);不向官方侧边栏注入任何按钮,官方「设置」按钮原样、不被遮挡
8
+ // - 双入口(2026-09 恢复侧栏注入,与官方共存不遮挡):
9
+ // ① 官方「设置」→「远程访问」栏目(settings.section,位于「Agent 预设」下方,官方扩展点);
10
+ // ② 左侧官方「设置」按钮旁注入独立「📱 远程访问」快捷按钮(id=dru-nav-remote,非 clone,
11
+ // 插在其前方、同布局不覆盖官方按钮;点击直达该栏目,首次带引导红点)
10
12
  // - 栏目内联渲染配置面板(浅色高对比 UI,遵循主流登录体验)
11
13
  // · 登录态:已登录显示账号 + 退出登录;未登录显示 登录/注册 tabs
12
14
  // · 🔒 修改密码(账号卡):已登录账号经 图形验证码+短信验证码 重置(/dsh-remote/password/reset 代理);
@@ -156,6 +158,11 @@ window.__ModuleLoader__.load({
156
158
  // Phase-5:端到端加密(E2EE)状态行 —— 启用=绿字绿点,未启用=灰字(纯文字状态行,不打扰)
157
159
  ".dru-e2ee-line{display:flex;align-items:center;gap:7px;margin-top:6px;font-size:12px;line-height:1.5;color:#57606a}",
158
160
  ".dru-e2ee-line.ok{color:#1a7f37}",
161
+ // 侧栏「远程访问」快捷按钮(2026-09 恢复注入,与官方「设置」按钮共存不遮挡):
162
+ // 独立 button(非 clone),插在官方「设置」按钮之前,同源同布局不覆盖官方热区。
163
+ ".dru-nav-remote{display:inline-flex;align-items:center;gap:6px;height:30px;margin:0 0 2px;padding:0 10px;border:1px solid #d0d7de;border-radius:8px;background:#ffffff;color:#0969da;font:500 12.5px/1 inherit;cursor:pointer;white-space:nowrap;user-select:none}",
164
+ ".dru-nav-remote:hover:not(:disabled){background:#f0f6ff;border-color:#0969da}",
165
+ ".dru-nav-remote-dot{position:absolute;top:-2px;right:-2px;width:8px;height:8px;border-radius:50%;background:#e5484d;box-shadow:0 0 0 2px #fff;pointer-events:none}",
159
166
  ].join("\n");
160
167
  document.head.appendChild(styleEl);
161
168
 
@@ -222,6 +229,104 @@ window.__ModuleLoader__.load({
222
229
  dotScan();
223
230
  }
224
231
 
232
+ // ── 侧栏「远程访问」快捷按钮(2026-09 恢复注入:与官方「设置」按钮共存、不遮挡) ──
233
+ // 用户要求:左侧官方「设置」按钮旁保留「远程访问」入口。实现为独立 <button id=dru-nav-remote>
234
+ // (绝不 clone 官方按钮 → 不继承官方事件委托/点击热区),插在官方「设置」按钮之前;
235
+ // 点击 → 打开官方设置页「远程访问」栏目。全程不点工作区会话内容:
236
+ // - 候选一律限定为官方导航(class 含 navCell / 位于导航容器内)+ 短文本/aria 精确匹配;
237
+ // - 命中排除自身与“菜单/更多”语义节点;
238
+ // - 栏目未开时先点官方「设置」,再轮询(≤3s)补点「远程访问」栏目项。
239
+ var NAV_ENTRY_ID = "dru-nav-remote";
240
+ var NAV_TEXT_MAX = 10;
241
+ var NAV_POLL_MAX = 20;
242
+ var NAV_BAD_RE = /(^|[^a-z0-9])(menu|context|more|kebab|dropdown|popover|toolbar)($|[^a-z0-9])/i;
243
+ function navCleanText(el) { try { return (el.textContent || "").replace(/\s+/g, " ").trim(); } catch (e) { return ""; } }
244
+ function navAttrOf(el) {
245
+ try { return String(el.getAttribute("aria-label") || el.getAttribute("title") || "").trim(); } catch (e) { return ""; }
246
+ }
247
+ function navCellLike(el) {
248
+ try {
249
+ var cls = String(el.className || "");
250
+ if (cls.indexOf("navCell") !== -1) return true;
251
+ return !!el.closest('[role="navigation"], nav, [class*="sidebar" i], [class*="sidenav" i], [class*="appnav" i], [class*="navRail" i]');
252
+ } catch (e) { return false; }
253
+ }
254
+ function pickNavByToken(token) {
255
+ try {
256
+ var els = document.querySelectorAll("button, [role='tab'], [role='menuitem'], [role='button'], a, [aria-label]");
257
+ for (var i = 0; i < els.length; i++) {
258
+ var el = els[i];
259
+ if (!el || el.id === NAV_ENTRY_ID) continue; // 排除注入按钮自身(防递归)
260
+ if (!navCellLike(el)) continue;
261
+ if (NAV_BAD_RE.test(String(el.className || ""))) continue;
262
+ var t = navCleanText(el);
263
+ if (t && !/\r|\n/.test(t) && t.length <= NAV_TEXT_MAX &&
264
+ (t === token || t.indexOf(token) === 0 || t.indexOf(token) !== -1)) return el;
265
+ var at = navAttrOf(el);
266
+ if (at && (at === token || at.indexOf(token) === 0)) return el;
267
+ }
268
+ } catch (e) { /* 忽略 */ }
269
+ return null;
270
+ }
271
+ /** 打开「远程访问」设置栏目:栏目已渲染直接点;否则先点官方「设置」再 ≤3s 轮询补点。 */
272
+ function openRemoteSettings() {
273
+ try {
274
+ var cell = pickNavByToken("远程访问");
275
+ if (cell) { cell.click(); return; }
276
+ var st = pickNavByToken("设置");
277
+ if (!st) return;
278
+ st.click();
279
+ var tries = 0;
280
+ var iv = setInterval(function () {
281
+ tries++;
282
+ var c2 = pickNavByToken("远程访问");
283
+ if (c2) { clearInterval(iv); c2.click(); }
284
+ else if (tries >= NAV_POLL_MAX) clearInterval(iv);
285
+ }, 150);
286
+ } catch (e) { /* 忽略 */ }
287
+ }
288
+ /** 注入侧栏按钮到官方「设置」按钮之前(独立 button;首次未见过引导红点)。 */
289
+ function mountNavEntry(host) {
290
+ try {
291
+ if (document.getElementById(NAV_ENTRY_ID)) return;
292
+ var wrap = host && host.parentNode;
293
+ if (!wrap || !wrap.insertBefore || !wrap.contains) return;
294
+ var btn = document.createElement("button");
295
+ btn.type = "button";
296
+ btn.id = NAV_ENTRY_ID;
297
+ btn.className = "dru-nav-remote";
298
+ btn.setAttribute("data-dru-remote", "1");
299
+ btn.title = "打开「远程访问」设置(快捷入口)";
300
+ btn.style.position = "relative";
301
+ btn.textContent = "📱 远程访问";
302
+ btn.addEventListener("click", function (e) {
303
+ try { e.stopPropagation(); e.preventDefault(); } catch (err) {}
304
+ dotMarkSeen(); // 用过快捷入口=已了解入口位置 → 关闭首次红点(含官方栏目红点)
305
+ openRemoteSettings();
306
+ });
307
+ wrap.insertBefore(btn, host);
308
+ if (!dotSeen()) {
309
+ var d = document.createElement("span");
310
+ d.className = "dru-nav-remote-dot";
311
+ btn.appendChild(d);
312
+ }
313
+ } catch (e) { /* 忽略 */ }
314
+ }
315
+ /** 常驻确保:官方「设置」按钮出现后在它前面挂一次入口(去重;2s 心跳,成本极低)。 */
316
+ function navEnsureStart() {
317
+ if (window.__dshRemoteNavStarted) return;
318
+ window.__dshRemoteNavStarted = true;
319
+ try { window.__dshRemoteNav = { open: openRemoteSettings }; } catch (e) {}
320
+ var iv = setInterval(function () {
321
+ try {
322
+ if (document.getElementById(NAV_ENTRY_ID)) return; // 已注入
323
+ var host = pickNavByToken("设置");
324
+ if (host) mountNavEntry(host);
325
+ } catch (e) { /* 忽略 */ }
326
+ }, 2000);
327
+ try { if (iv && typeof iv.unref === "function") iv.unref(); } catch (e) {} // node 测试环境不阻塞退出
328
+ }
329
+
225
330
  // ── 宿主 API ────────────────────────────────────────────────────────────
226
331
  function api(path, options) {
227
332
  return fetch(path, options).then(function (res) {
@@ -260,6 +365,10 @@ window.__ModuleLoader__.load({
260
365
  var FB_THREADS_KEY = "dsh-feedback-threads";
261
366
  var FB_POPUP_KEY = "dsh-feedback-popup";
262
367
  var FB_POPUP_DELAY_MS = 60 * 60 * 1000; // 安装/体验后至少 1 小时才弹窗(只弹一次)
368
+ // 评价去重(2026-09):弹窗状态按「账号(掩码手机号)或设备」分作用域 —— 换账号/换机不重复打扰;
369
+ // 服务端另有 user_id/device_id 409 already_rated 兜底(同一账号/设备只收集一次 rating)。
370
+ var fbPopScope = "anon";
371
+ function setFbPopScope(scope) { fbPopScope = String(scope || "anon"); }
263
372
  var FB_CATEGORIES = [
264
373
  { value: "feature", label: "功能类" },
265
374
  { value: "bug", label: "Bug 类" },
@@ -286,10 +395,17 @@ window.__ModuleLoader__.load({
286
395
  try { localStorage.removeItem(FB_THREADS_KEY); } catch (e) {}
287
396
  }
288
397
  function fbPopState() {
289
- try { return JSON.parse(localStorage.getItem(FB_POPUP_KEY) || "null"); } catch (e) { return null; }
398
+ try { return JSON.parse(localStorage.getItem(FB_POPUP_KEY + ":" + fbPopScope) || "null"); } catch (e) { return null; }
290
399
  }
291
400
  function fbSavePopState(s) {
292
- try { localStorage.setItem(FB_POPUP_KEY, JSON.stringify(s)); } catch (e) {}
401
+ try { localStorage.setItem(FB_POPUP_KEY + ":" + fbPopScope, JSON.stringify(s)); } catch (e) {}
402
+ }
403
+ /** 作用域清理:旧版无作用域键也一并视为已完成,避免老用户再次被打扰。 */
404
+ function fbClearPopScope(scopes) {
405
+ try {
406
+ localStorage.removeItem(FB_POPUP_KEY); // 兼容旧键(无后缀)
407
+ if (Array.isArray(scopes)) scopes.forEach(function (sc) { localStorage.removeItem(FB_POPUP_KEY + ":" + sc); });
408
+ } catch (e) {}
293
409
  }
294
410
 
295
411
  /** 同源反馈代理调用(浏览器 → /dsh-remote/feedback/* → 独立反馈服务)。 */
@@ -415,7 +531,7 @@ window.__ModuleLoader__.load({
415
531
  category: cat,
416
532
  title: fbTitle.trim(),
417
533
  content: fbContent.trim(),
418
- contact: contact.trim() || (cfg && cfg.phone ? cfg.phone : ""),
534
+ contact: contact.trim() || ((cfg && cfg.phone && cfg.phone.indexOf("*") === -1) ? cfg.phone : ""),
419
535
  };
420
536
  fbApi("/feedback", { method: "POST", body: JSON.stringify(payload) })
421
537
  .then(function (b) {
@@ -670,6 +786,7 @@ window.__ModuleLoader__.load({
670
786
  .catch(function (e) {
671
787
  var errBody = e.body && e.body.error;
672
788
  if (errBody && errBody.code === "rate_limited") { fbPopupLater(); setMessage({ kind: "err", text: "今日提交已达上限,明天再来吧" }); }
789
+ else if (errBody && errBody.code === "already_rated") { fbPopupDone(); setMessage({ kind: "ok", text: "你已评价过,感谢支持(同一账号/设备只收集一次)" }); }
673
790
  else setMessage({ kind: "err", text: "提交失败:" + ((errBody && errBody.message) || e.message) + "(可稍后再试)" });
674
791
  })
675
792
  .finally(function () { setBusy(""); });
@@ -1027,10 +1144,11 @@ window.__ModuleLoader__.load({
1027
1144
 
1028
1145
  // ── 📱 远程访问卡:常量 / 轮询 / 一次性访问密钥 / 已授权设备 ─────────────────
1029
1146
  var KEY_AUTO_REFRESH_MS = 25000; // 停留栏目时约每 25s 自动轮换一把新的一次性密钥(防已用/过期)
1030
- var STATUS_POLL_MS = 5000; // 连接状态行轮询间隔
1147
+ var STATUS_POLL_MS = 30000; // 连接状态行轮询间隔(审计降频:原 5s→30s;页面隐藏时暂停)
1031
1148
 
1032
1149
  /** 轻量状态轮询:只更新 st,不改 mode(mode 由用户 Tab 选择决定,避免轮询把自建/云端来回切)。 */
1033
1150
  function pollStatus() {
1151
+ if (document.hidden) return; // 页面隐藏时不空转(审计:降低请求频率)
1034
1152
  api("/dsh-remote/status").then(function (body) {
1035
1153
  if (!body || !body.ok) return;
1036
1154
  setSt(body);
@@ -1212,14 +1330,17 @@ window.__ModuleLoader__.load({
1212
1330
  }).finally(function () { setBusy(""); });
1213
1331
  };
1214
1332
 
1215
- // 停留主视图(home)时:每 5s 轻量轮询连接状态 + 每秒刷新倒计时
1333
+ // 停留主视图(home)时:轮询连接状态(15s,审计降频) + 每秒刷新倒计时;隐藏暂停、回前台立即刷新
1216
1334
  useEffect(function () {
1217
1335
  if (view !== "home") return;
1218
1336
  var pollIv = setInterval(function () { pollStatus(); }, STATUS_POLL_MS);
1219
1337
  var tickIv = setInterval(function () { setNowTick(Date.now()); }, 1000);
1338
+ var visFn = function () { if (!document.hidden) pollStatus(); };
1339
+ document.addEventListener("visibilitychange", visFn);
1220
1340
  return function () {
1221
1341
  clearInterval(pollIv);
1222
1342
  clearInterval(tickIv);
1343
+ document.removeEventListener("visibilitychange", visFn);
1223
1344
  };
1224
1345
  }, [view]);
1225
1346
 
@@ -1360,7 +1481,8 @@ window.__ModuleLoader__.load({
1360
1481
  * 账号卡=当前登录手机号;登录卡「忘记密码」=表单输入手机号。 */
1361
1482
  var sendPwdSms = function (ph) {
1362
1483
  ph = String(ph || "").trim();
1363
- if (!/^1\d{10}$/.test(ph)) { setMsg("err", "请输入正确的手机号"); return; }
1484
+ // 账号卡改密(隐私契约):phone 为空 = 由服务端取本机账号真号发码;仅当显式填了非法号才报错
1485
+ if (ph && !/^1\d{10}$/.test(ph)) { setMsg("err", "请输入正确的手机号"); return; }
1364
1486
  if (!pwdCap || !pwdCap.id || !pwdCapTxt.trim()) { setMsg("err", "请输入图中验证码(点击图片可刷新)"); return; }
1365
1487
  setBusy("pwd-sms");
1366
1488
  post("/dsh-remote/sms-code", { phone: ph, captcha_id: pwdCap.id, captcha_answer: pwdCapTxt.trim() })
@@ -1388,7 +1510,8 @@ window.__ModuleLoader__.load({
1388
1510
  */
1389
1511
  var doResetPwd = function (ph, fromLogin) {
1390
1512
  ph = String(ph || "").trim();
1391
- if (!/^1\d{10}$/.test(ph)) { setMsg("err", fromLogin ? "请输入正确的手机号" : "请先登录手机号账号(修改密码以当前账号为准)"); return; }
1513
+ if (ph && !/^1\d{10}$/.test(ph)) { setMsg("err", "请输入正确的手机号"); return; }
1514
+ if (!ph && fromLogin) { setMsg("err", "请输入正确的手机号"); return; } // 登录卡忘记密码必须显式填号
1392
1515
  if (!pwdSms.trim()) { setMsg("err", "请填写短信验证码"); return; }
1393
1516
  if (pwdNew.length < 8) { setMsg("err", "新密码至少 8 位"); return; }
1394
1517
  setBusy("pwd-reset");
@@ -1521,7 +1644,10 @@ window.__ModuleLoader__.load({
1521
1644
  // fromLogin=false:账号卡「修改密码」手机号以当前登录账号为准(只读);
1522
1645
  // fromLogin=true:登录卡「忘记密码」手机号预填登录输入、可改(成功后返回登录表单请用新密码登录)。
1523
1646
  function renderResetPwdForm(fromLogin) {
1524
- var ph = fromLogin ? phone : ((st && st.config && st.config.phone) || "");
1647
+ // 隐私审计(2026-09):账号卡「修改密码」手机号只展示掩码;发送/重置时由服务端取本机账号真实号,
1648
+ // 浏览器端不再出现明文手机号。登录卡「忘记密码」为用户自行输入,原样使用。
1649
+ var curPhone = ((st && st.config && st.config.phone) || "");
1650
+ var ph = fromLogin ? phone : "";
1525
1651
  return h("div", { className: "dru-card", style: { marginTop: 10, border: "1px dashed #d0d7de" } },
1526
1652
  h("h3", null, fromLogin ? "忘记密码(短信重置)" : "🔒 修改密码(短信验证)"),
1527
1653
  h("div", { className: "dru-hint", style: { marginBottom: 8 } },
@@ -1530,7 +1656,7 @@ window.__ModuleLoader__.load({
1530
1656
  : PWD_RESET_WARN + "(E2EE 用新密码重新派生)。"),
1531
1657
  field("手机号", fromLogin
1532
1658
  ? input({ type: "tel", value: ph, placeholder: "11 位手机号", autoComplete: "tel", onChange: function (e) { setPhone(e.target.value); } })
1533
- : h("input", { className: "dru-input", type: "tel", value: ph, disabled: true, readOnly: true, title: "以当前登录账号为准" })),
1659
+ : h("input", { className: "dru-input", type: "tel", value: curPhone, disabled: true, readOnly: true, title: "以当前登录账号为准" })),
1534
1660
  field("图形验证码", h("div", { className: "dru-captcha" },
1535
1661
  input({ value: pwdCapTxt, placeholder: "图中数字", autoComplete: "off", inputMode: "numeric", maxLength: 6, onChange: function (e) { setPwdCapTxt(e.target.value); } }),
1536
1662
  h("div", { className: "dru-captcha-box", title: "看不清?点击刷新", onClick: function () { pwdLoadCaptcha(); }, dangerouslySetInnerHTML: pwdCap && pwdCap.svg && pwdCap.svg.indexOf("<svg") === 0 ? { __html: pwdCap.svg } : void 0 },
@@ -1911,10 +2037,18 @@ window.__ModuleLoader__.load({
1911
2037
 
1912
2038
  // 首次安装引导小红点(localStorage dsh-remote-seen-dot;点击后不再显示)
1913
2039
  dotWatch();
2040
+ // 侧栏「远程访问」快捷入口(2026-09 恢复注入:与官方「设置」按钮共存不遮挡)
2041
+ navEnsureStart();
1914
2042
 
1915
2043
  // 满意度弹窗调度:首次观察到 bridge 运行(即“安装完成并体验”)后约 10 分钟弹出;
1916
2044
  // 每 60 秒复查一次,避免 dsh web 启动晚于到点时间。
2045
+ // 弹窗状态按「账号/设备」分作用域(见 fbPopScope);旧版无后缀键视为已完成并迁移,不再打扰老用户。
2046
+ var legacyPopState = null;
2047
+ try { legacyPopState = JSON.parse(localStorage.getItem(FB_POPUP_KEY) || "null"); } catch (e) { legacyPopState = null; }
2048
+ try { localStorage.removeItem(FB_POPUP_KEY); } catch (e) {}
1917
2049
  api("/dsh-remote/status").then(function (body) {
2050
+ if (body && body.config) setFbPopScope(body.config.phone || body.config.deviceId || "anon");
2051
+ if (legacyPopState && legacyPopState.state === "done") fbSavePopState({ state: "done", firstSeen: legacyPopState.firstSeen || Date.now(), nextAt: 0 });
1918
2052
  if (body && body.service && body.service.running) {
1919
2053
  fbEnsureFirstSeen();
1920
2054
  fbMaybeOpenPopup();
@@ -1925,6 +2059,7 @@ window.__ModuleLoader__.load({
1925
2059
  if (!s || s.state !== "pending") { clearInterval(popupTimer); return; }
1926
2060
  if (!fbFirstSeenAt) {
1927
2061
  api("/dsh-remote/status").then(function (body) {
2062
+ if (body && body.config) setFbPopScope(body.config.phone || body.config.deviceId || "anon");
1928
2063
  if (body && body.service && body.service.running) {
1929
2064
  fbEnsureFirstSeen();
1930
2065
  fbMaybeOpenPopup();
@@ -17,7 +17,7 @@
17
17
  import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, constants as fsConstants } from "node:fs";
18
18
  import { join, dirname, sep } from "node:path";
19
19
  import { execSync, spawn } from "node:child_process";
20
- import { homedir, hostname, platform } from "node:os";
20
+ import { homedir, platform } from "node:os";
21
21
  import { fileURLToPath } from "node:url";
22
22
 
23
23
  /** 本插件在 host 侧的服务依赖。 */
@@ -653,7 +653,7 @@ async function relayAccount(relayDir) {
653
653
  if (!r.ok || !r.body || !r.body.user) return null;
654
654
  const u = r.body.user;
655
655
  return {
656
- phone: u.phone || "",
656
+ phone: maskPhone(u.phone || ""),
657
657
  plan: u.plan || "free",
658
658
  plan_source: u.plan_source || "plan",
659
659
  plan_ends_at: u.plan_ends_at ?? null,
@@ -875,6 +875,15 @@ export function readE2eeStateFile(relayDir) {
875
875
  }
876
876
  }
877
877
 
878
+ /**
879
+ * 手机号脱敏(隐私审计 2026-09):面板/镜像 UI 一律不下发明文手机号。
880
+ * 明文只在 bridge 本机 config / 服务端账号体系内流转,浏览器侧仅见掩码。
881
+ */
882
+ function maskPhone(p) {
883
+ const s = String(p || "");
884
+ return s.length >= 7 ? s.slice(0, 3) + "****" + s.slice(-4) : (s ? s.slice(0, 1) + "****" : "");
885
+ }
886
+
878
887
  async function composeStatus(relayDir) {
879
888
  const cfg = loadConfig(relayDir);
880
889
  const launchd = launchdStatus();
@@ -893,7 +902,8 @@ async function composeStatus(relayDir) {
893
902
  return {
894
903
  ok: true,
895
904
  config: {
896
- phone: cfg.phone || "",
905
+ phone: maskPhone(cfg.phone || ""),
906
+ hasPhone: Boolean(cfg.phone),
897
907
  hasPassword: Boolean(cfg.password),
898
908
  deviceId: cfg.device_id || "",
899
909
  apiUrl,
@@ -913,7 +923,7 @@ async function composeStatus(relayDir) {
913
923
  // {enabled, reason, profile, epoch, caps},供面板「📱 远程访问」卡展示加密状态。
914
924
  e2ee: readE2eeStateFile(relayDir),
915
925
  },
916
- host: hostname(),
926
+ // 隐私审计(2026-09):不再下发真实 hostname(移除 host 字段)——设备标识统一走 deviceId/服务端登记名
917
927
  };
918
928
  }
919
929
 
@@ -1020,7 +1030,7 @@ const PLUGIN_ID = "dsh-remote-web";
1020
1030
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1021
1031
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1022
1032
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1023
- const PLUGIN_VERSION = "0.6.0-beta.11";
1033
+ const PLUGIN_VERSION = "0.6.0-beta.12";
1024
1034
  const UPDATE_LOG = ".dsh-update.log";
1025
1035
  const UPDATE_MARKER = ".dsh-update-running";
1026
1036
 
@@ -1382,7 +1392,11 @@ function registerRoutes(ctx, relayDir) {
1382
1392
  handler: async (req, res) => {
1383
1393
  const body = await readJsonBody(req);
1384
1394
  if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
1385
- const phone = String(body.phone ?? "").trim();
1395
+ let phone = String(body.phone ?? "").trim();
1396
+ if (!phone) {
1397
+ // 隐私审计(2026-09):账号卡修改密码不再下发明文手机号——服务端以本机账号为准
1398
+ phone = String((loadConfig(relayDir).phone) || "").trim();
1399
+ }
1386
1400
  if (!phone) return sendJson(res, 400, { ok: false, error: "手机号必填" });
1387
1401
  const r = await relayFetch(relayDir, "/api/sms-code", {
1388
1402
  method: "POST", headers: { "content-type": "application/json" },
@@ -1399,7 +1413,11 @@ function registerRoutes(ctx, relayDir) {
1399
1413
  // 成功 → 企业端使该账号全部授权设备/会话失效(含 E2EE 派生口令)。
1400
1414
  const body = await readJsonBody(req);
1401
1415
  if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
1402
- const phone = String(body.phone ?? "").trim();
1416
+ let phone = String(body.phone ?? "").trim();
1417
+ if (!phone) {
1418
+ // 隐私审计(2026-09):同上,账号卡改密不传明文手机号,服务端回填本机账号
1419
+ phone = String((loadConfig(relayDir).phone) || "").trim();
1420
+ }
1403
1421
  const smsCode = String(body.sms_code ?? "").trim();
1404
1422
  const newPassword = String(body.new_password ?? body.password ?? "");
1405
1423
  if (!phone || !smsCode || !newPassword) return sendJson(res, 400, { ok: false, error: "手机号、短信验证码与新密码必填" });
@@ -1504,7 +1522,7 @@ function registerRoutes(ctx, relayDir) {
1504
1522
  feedbackUrl: api,
1505
1523
  reachable,
1506
1524
  deviceId: cfg.device_id || "",
1507
- phone: cfg.phone || "",
1525
+ phone: maskPhone(cfg.phone || ""), // 隐私:浏览器端只见掩码(代理提交时服务端另附真号)
1508
1526
  // 登录态(已配置账号或自建密钥)→ 节点半自动附加 JWT,免图形验证码
1509
1527
  auth: cfg.local_key || (cfg.phone && cfg.password) ? "account" : "anonymous"
1510
1528
  });
@@ -104,7 +104,8 @@ test("反馈配置:默认走 api_url(生产 relay-api 同源反馈端点)
104
104
  assert.equal(body.ok, true);
105
105
  assert.equal(body.reachable, true);
106
106
  assert.equal(body.deviceId, "dev-cfgcheck");
107
- assert.equal(body.phone, "13900000000");
107
+ // 隐私契约(2026-09):反馈配置不下发明文手机号,只给掩码(提交时服务端另附 x-dsh-phone 真号)
108
+ assert.equal(body.phone, "139****0000");
108
109
  // 代理把 /dsh-remote/feedback/api/feedback/captcha 映射到 {api_url}/api/feedback/captcha
109
110
  const cap = await fetch(`http://127.0.0.1:${host.address().port}/dsh-remote/feedback/api/feedback/captcha`);
110
111
  assert.equal(cap.status, 201);
@@ -255,7 +255,8 @@ async function runResetFlow(opts = {}) {
255
255
 
256
256
  const smsReq = plugin.requests.find((r) => r.path === "/dsh-remote/sms-code");
257
257
  assert.ok(smsReq, "应请求 /dsh-remote/sms-code(复用短信防刷路径)");
258
- assert.deepEqual(smsReq.body, { phone: "13800000000", captcha_id: "cap-pwd-1", captcha_answer: "654321" });
258
+ // 隐私契约(2026-09):账号卡改密不再把明文手机号发给浏览器/请求体 —— client 传空,由插件节点半回填本机账号
259
+ assert.deepEqual(smsReq.body, { phone: "", captcha_id: "cap-pwd-1", captcha_answer: "654321" });
259
260
 
260
261
  // 填写短信验证码 + 新密码(≥8) → 确认修改
261
262
  tree = plugin.render();
@@ -277,8 +278,8 @@ test("修改密码成功流:sms-code → password/reset → 本地登出(清
277
278
  const resetReq = plugin.requests.find((r) => r.path === "/dsh-remote/password/reset");
278
279
  assert.ok(resetReq, "应 POST /dsh-remote/password/reset");
279
280
  assert.equal(resetReq.method, "POST");
280
- assert.deepEqual(resetReq.body, { phone: "13800000000", sms_code: "123456", new_password: "newpass123" },
281
- "应提交 {phone,sms_code,new_password}");
281
+ assert.deepEqual(resetReq.body, { phone: "", sms_code: "123456", new_password: "newpass123" },
282
+ "应提交 {phone,sms_code,new_password}(账号卡改密 phone 为空,由服务端回填本机账号)");
282
283
 
283
284
  assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/logout"), "重置成功后应本地登出(清旧口令配置)");
284
285
  assert.ok(plugin.removed.includes("dsh-feedback-threads"), "重置成功登出应清除本机反馈线程凭据");
@@ -323,7 +323,7 @@ test("二维码缺失容错 + 未登录引导文案(源码级约束)", () =>
323
323
  assert.match(SOURCE, /window\.open/);
324
324
  // 自动刷新/状态轮询定时器与清理
325
325
  assert.match(SOURCE, /KEY_AUTO_REFRESH_MS = 25000/);
326
- assert.match(SOURCE, /STATUS_POLL_MS = 5000/);
326
+ assert.match(SOURCE, /STATUS_POLL_MS = 30000/); // 审计降频:原 5s→30s,页面隐藏暂停
327
327
  assert.match(SOURCE, /clearInterval\(rotateIv\)/);
328
328
  assert.match(SOURCE, /clearInterval\(pollIv\)/);
329
329
  // 文案与行为关键词
@@ -300,30 +300,31 @@ test("红点只挂在设置页「远程访问」栏目导航(navCell):普
300
300
  assert.equal(plainBtn.children.length, 0, "非栏目导航按钮不得注入红点(不遮挡官方按钮)");
301
301
  });
302
302
 
303
- // ---------- 入口收敛:不再向官方侧栏注入任何按钮 / 导航点击机器 ----------
304
- // 用户从官方「设置」进入 settings.section「远程访问」栏目(官方扩展点,天然不冲突),
305
- // 官方「设置」按钮保持原样、不被遮挡。旧 Bug A 侧栏注入实现(容器限定点击流 + 独立按钮 +
306
- // 红点 + openRemoteSettings/__dshRemoteNav)已整段移除,红点只保留在设置页栏目导航 navCell 上。
307
-
308
- test("源码约束:侧栏注入按钮/导航点击机器已移除,官方「设置」不被抢、红点仅保留在设置页栏目导航", () => {
309
- // 不再存在:侧栏 DOM 注入 / 导航容器点击 / 暴露给宿主与自动化的 open 入口
310
- assert.doesNotMatch(SOURCE, /injectSidebarRemoteEntry/);
311
- assert.doesNotMatch(SOURCE, /dru-nav-remote/);
312
- assert.doesNotMatch(SOURCE, /dsh-remote-nav-seen/);
313
- assert.doesNotMatch(SOURCE, /openRemoteSettings/);
314
- assert.doesNotMatch(SOURCE, /clickNavToken/);
315
- assert.doesNotMatch(SOURCE, /remoteSectionVisible/);
316
- assert.doesNotMatch(SOURCE, /__dshRemoteNav/);
317
- assert.doesNotMatch(SOURCE, /data-dru-remote/);
318
- assert.doesNotMatch(SOURCE, /NAV_ENTRY_ID/);
319
- assert.doesNotMatch(SOURCE, /NAV_CONTAINER_SEL/);
320
- assert.doesNotMatch(SOURCE, /NAV_TEXT_MAX/);
321
- assert.doesNotMatch(SOURCE, /dru-fab/);
322
- // 官方按钮不被克隆 / 包装 / 挪位(克隆曾继承官方事件委托与热区 → Bug A)
303
+ // ---------- 侧栏「远程访问」快捷入口(2026-09 恢复注入:与官方「设置」共存、不遮挡) ----------
304
+ // 用户要求:左侧官方「设置」按钮旁保留「📱 远程访问」快捷按钮。实现要点(防 Bug A 复发):
305
+ // - 独立 <button id=dru-nav-remote>(绝不 cloneNode 不继承官方事件委托/点击热区),
306
+ // 插在官方「设置」按钮之前;
307
+ // - 候选限定官方导航(class 含 navCell 或位于导航容器)+ 短文本/aria 精确匹配;
308
+ // - 命中排除自身(防递归)与“菜单/更多”语义节点;不点工作区会话行;
309
+ // - openRemoteSettings:栏目已开直接点;未开先点官方「设置」再 ≤3s 轮询补点;
310
+ // - 红点(.dru-reddot / .dru-nav-remote-dot)用同一 dsh-remote-seen-dot key,点击即收起。
311
+
312
+ test("源码约束:侧栏注入「远程访问」按钮与官方「设置」共存;独立按钮非克隆;导航点击安全", () => {
313
+ // 注入存在:入口 id / 挂载函数 / 打开函数 / 心跳保证
314
+ assert.match(SOURCE, /dru-nav-remote/);
315
+ assert.match(SOURCE, /navEnsureStart/);
316
+ assert.match(SOURCE, /openRemoteSettings/);
317
+ assert.match(SOURCE, /__dshRemoteNav/);
318
+ assert.match(SOURCE, /data-dru-remote/);
319
+ assert.match(SOURCE, /NAV_ENTRY_ID/);
320
+ // 安全设计:绝不克隆官方按钮;候选限定 navCell/导航容器 + 短文本;排除自身;排除菜单语义
323
321
  assert.doesNotMatch(SOURCE, /cloneNode/);
324
- // 唯一保留的 DOM 注入 = 设置页栏目导航红点(.dru-reddot,navCell 右上角,pointer-events:none)
325
- assert.match(SOURCE, /dru-reddot/);
326
322
  assert.match(SOURCE, /navCell/);
323
+ assert.match(SOURCE, /NAV_TEXT_MAX/);
324
+ assert.match(SOURCE, /el\.id === NAV_ENTRY_ID/);
325
+ assert.match(SOURCE, /NAV_BAD_RE/);
326
+ // 红点仍在(官方栏目 navCell + 快捷按钮首用同 key)
327
+ assert.match(SOURCE, /dru-reddot/);
327
328
  assert.match(SOURCE, /dsh-remote-seen-dot/);
328
329
  });
329
330