@mrrisega/dsh-remote 0.7.0-beta.5 → 0.7.0-beta.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.0-beta.5",
3
+ "version": "0.7.0-beta.7",
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",
@@ -834,22 +834,42 @@ window.__ModuleLoader__.load({
834
834
  * 其中 **带 aria-keyshortcuts / 文案或 aria-label 含「设置」** 的优先(语言无关 + 精确)。
835
835
  */
836
836
  function settingsTriggerCandidates() {
837
- var out = [];
837
+ var ranked = [];
838
838
  try {
839
+ /* 排名而不是「谁先扫到谁」(2026-10-11 业主实测「点入口弹出的是 token 用量小弹窗」):
840
+ ① 官方设置槽位里的按钮 = 确定是设置触发器(sidebar.settings / settings.launcher)
841
+ ② 带快捷键或文案含「设置」的弹层触发器
842
+ ③ 其它弹层触发器(最后兜底)
843
+ 并且**排除我们自己所在的底部槽**(sidebar.footer.action)—— token 用量、其它插件按钮
844
+ 都在那一块,它们同样带 aria-haspopup=dialog,正是「点错弹窗」的来源。
845
+ 只用 closest 判容器、不做后代选择器:任何 DOM 实现(含测试替身)都支持。 */
839
846
  var cands = document.querySelectorAll('button[aria-haspopup="dialog"], [aria-haspopup="dialog"]');
840
- var rest = [];
841
847
  for (var i = 0; i < cands.length; i++) {
842
848
  var el = cands[i];
843
849
  if (!el || el.id === NAV_ENTRY_ID) continue; // 排除我们自己的入口
844
850
  if (navInDialog(el)) continue; // 弹层内的(关闭键/栏目)一律不要
851
+ try { if (el.closest && el.closest('[data-slot="sidebar.footer.action"]')) continue; } catch (eF) { /* 忽略 */ }
845
852
  if (NAV_BAD_RE.test(String(el.className || ""))) continue;
853
+ /* 🔴 2026-10-11 业主实测「点入口弹出的是本轮用量」:那次弹窗锚在**右上角**,
854
+ 说明候选里混进了**顶栏**的按钮(用量/上下文那些同样带 aria-haspopup="dialog")。
855
+ 这里加几何闸门:只认**侧栏范围内**的候选(左侧窄条),顶栏/内容区的按钮一律不碰。 */
856
+ try {
857
+ var r0 = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
858
+ if (r0 && (r0.width === 0 || r0.height === 0)) continue; // 不可见
859
+ if (r0 && r0.left > 360) continue; // 明显在右侧(顶栏/内容区)
860
+ } catch (e0) { /* 拿不到几何信息就放行(老宿主/测试替身) */ }
861
+ var inSettingsSlot = false;
862
+ try {
863
+ inSettingsSlot = !!(el.closest && el.closest('[data-slot="sidebar.settings"], [data-slot="settings.launcher"]'));
864
+ } catch (e1) { inSettingsSlot = false; }
846
865
  var label = String(navAttrOf(el) || navCleanText(el) || "");
847
866
  var hasShortcut = !!(el.getAttribute && el.getAttribute("aria-keyshortcuts"));
848
- if (hasShortcut || /设置|Settings/i.test(label)) out.push(el);
849
- else rest.push(el);
867
+ var rank = inSettingsSlot ? 0 : ((hasShortcut || /设置|Settings/i.test(label)) ? 1 : 2);
868
+ ranked.push({ el: el, rank: rank, order: i });
850
869
  }
851
- return out.concat(rest); // 兜底也不能空着:实在认不出就逐个试(见 openRemoteSettings)
852
- } catch (e) { return out; }
870
+ ranked.sort(function (a, b) { return (a.rank - b.rank) || (a.order - b.order); });
871
+ return ranked.map(function (x) { return x.el; });
872
+ } catch (e) { return ranked.map(function (x) { return x.el; }); }
853
873
  }
854
874
  /** 官方「设置」触发器(首选候选,保持旧名字给测试与调用方用)。 */
855
875
  function pickSettingsTrigger() {
@@ -887,6 +907,134 @@ window.__ModuleLoader__.load({
887
907
  } catch (e) { /* 忽略 */ }
888
908
  return null;
889
909
  }
910
+ /**
911
+ * 从**账号菜单**里进设置(桌面端的「设置」就是账号菜单里的一项)。
912
+ *
913
+ * 为什么需要:桌面端侧栏底部那颗"齿轮"在 Electron 渲染器里不一定存在 —— 业主截图里
914
+ * 底部只有「远程访问」+ 账号(高昂),「设置」在**账号菜单**里。所以当侧栏候选全部落空时,
915
+ * 必须能"打开账号菜单 → 点设置 → 再点我们的栏目",否则入口点下去就是没反应。
916
+ */
917
+ /** 设置面板是否已经打开(可见的 dialog 且含「通用设置」——它就是设置面板的标志)。 */
918
+ function settingsDialogOpen() {
919
+ try {
920
+ var ds = document.querySelectorAll('[role="dialog"], [aria-modal="true"]');
921
+ for (var i = 0; i < ds.length; i++) {
922
+ var el = ds[i];
923
+ if (!el.getBoundingClientRect || el.getBoundingClientRect().width === 0) continue;
924
+ var t = String(el.textContent || "");
925
+ if (t.indexOf("通用设置") !== -1 || t.indexOf("General") !== -1) return true;
926
+ }
927
+ } catch (e) { /* 忽略 */ }
928
+ return false;
929
+ }
930
+
931
+ /**
932
+ * 这一行是不是账号菜单里的「设置」?
933
+ * ⚠️ **只认"以设置/Settings 开头"** —— 绝不能加"以设置结尾":账号菜单在桌面端真实文案是
934
+ * 「设置 ⌥ ⌘ ,」(带快捷键),而以"设置"结尾的还有「**通用设置**」,
935
+ * 那会让我们把设置面板当成账号菜单、把「通用设置」当成「设置」行(2026-10-11 两个远程访问的根因)。
936
+ */
937
+ function menuRowIsSettings(el) {
938
+ try {
939
+ var t = String(el.textContent || "").replace(/\s+/g, "").trim();
940
+ if (!t || t.indexOf("远程访问") !== -1) return false;
941
+ return /^(设置|Settings)/i.test(t);
942
+ } catch (e) { return false; }
943
+ }
944
+
945
+ /** 账号菜单的触发器:桌面端是左下角那颗"头像 chip"(aria-label="账号菜单")。 */
946
+ function accountMenuTrigger() {
947
+ try {
948
+ var all = document.querySelectorAll('[aria-haspopup="menu"], button, [role="button"]');
949
+ var best = null;
950
+ for (var i = 0; i < all.length; i++) {
951
+ var el = all[i];
952
+ if (!el || el.id === NAV_ENTRY_ID) continue;
953
+ var aria = String((el.getAttribute && el.getAttribute("aria-label")) || "");
954
+ if (/账号菜单|Account menu/i.test(aria)) return el;
955
+ var r = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
956
+ if (!best && r && r.width > 0 && r.left < 360 && r.top > 300
957
+ && String((el.getAttribute && el.getAttribute("aria-haspopup")) || "") === "menu") best = el;
958
+ }
959
+ return best;
960
+ } catch (e) { return null; }
961
+ }
962
+
963
+ /**
964
+ * 兜底:从**账号菜单**进设置(桌面端的「设置」就在账号菜单里)。
965
+ * 这是唯一在真机上验证过能打开设置面板的路径 —— 但账号菜单会闪现一下,所以:
966
+ * · 只在侧栏候选与快捷键都失败后才用(见 openRemoteSettings);
967
+ * · 轮询间隔压到 40ms,菜单可见时间≈一两帧,尽量不打扰。
968
+ */
969
+ function openSettingsViaAccountMenu() {
970
+ try {
971
+ var trigger = accountMenuTrigger();
972
+ navTrace("menu:trigger=" + (trigger ? "found" : "none"));
973
+ if (!trigger) return;
974
+ try { trigger.click(); } catch (e) { return; }
975
+ var tries = 0;
976
+ var iv = setInterval(function () {
977
+ tries += 1;
978
+ var menus = document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"]');
979
+ for (var i = 0; i < menus.length; i++) {
980
+ var m = menus[i];
981
+ if (!m.getBoundingClientRect || m.getBoundingClientRect().width === 0) continue;
982
+ if (String(m.textContent || "").indexOf("通用设置") !== -1) continue; // 那是设置面板,不是账号菜单
983
+ var items = m.querySelectorAll ? m.querySelectorAll('button, [role="menuitem"]') : [];
984
+ for (var k = 0; k < items.length; k++) {
985
+ if (!menuRowIsSettings(items[k])) continue;
986
+ navTrace("menu:settings-row-clicked");
987
+ clearInterval(iv);
988
+ try { items[k].click(); } catch (e2) { /* 忽略 */ }
989
+ var tries2 = 0;
990
+ var iv2 = setInterval(function () {
991
+ tries2 += 1;
992
+ var cell = pickNavByToken("远程访问", { insideDialog: true });
993
+ if (cell) { navTrace("menu:section-clicked"); clearInterval(iv2); cell.click(); }
994
+ else if (tries2 >= NAV_POLL_MAX) { navTrace("menu:section-timeout"); clearInterval(iv2); }
995
+ }, 120);
996
+ try { if (iv2 && typeof iv2.unref === "function") iv2.unref(); } catch (e3) { /* 忽略 */ }
997
+ return;
998
+ }
999
+ }
1000
+ if (tries >= 30) { navTrace("menu:timeout"); clearInterval(iv); } // 30×40ms≈1.2s
1001
+ }, 40);
1002
+ try { if (iv && typeof iv.unref === "function") iv.unref(); } catch (e4) { /* 忽略 */ }
1003
+ } catch (e) { /* 忽略 */ }
1004
+ }
1005
+
1006
+ /**
1007
+ * 用**官方设置快捷键**(⌥⌘, / Alt+Meta+,)打开设置面板。
1008
+ *
1009
+ * 为什么用快捷键而不是"点账号菜单里的设置"(2026-10-11 业主反馈):
1010
+ * 桌面端侧栏没有设置齿轮,只能从账号菜单进 —— 但那会让**账号菜单先弹出来再关掉**,
1011
+ * 业主的原话是「会触发一个小弹窗……有一瞬间会闪出一下,体验不好」。
1012
+ * 实测(探针在桌面渲染器里验证):向 document 派发一次
1013
+ * `keydown {key:",", metaKey:true, altKey:true}` 就能**直接**打开设置面板,没有任何中间弹层。
1014
+ * 快捷键取自设置行自带的 `aria-keyshortcuts="Alt+Meta+,"`(宿主自己的约定)。
1015
+ */
1016
+ function openSettingsViaShortcut() {
1017
+ try {
1018
+ navTrace("open:shortcut-dispatch");
1019
+ var ev = new KeyboardEvent("keydown", {
1020
+ key: ",", code: "Comma", metaKey: true, altKey: true, bubbles: true, cancelable: true
1021
+ });
1022
+ document.dispatchEvent(ev);
1023
+ var tries = 0;
1024
+ var iv = setInterval(function () {
1025
+ tries += 1;
1026
+ var cell = pickNavByToken("远程访问", { insideDialog: true });
1027
+ if (cell) { navTrace("open:shortcut-section-clicked"); clearInterval(iv); cell.click(); return; }
1028
+ if (tries >= 6) { // ~0.6s 还没开 → 桌面端快捷键可能是**原生菜单加速键**(合成事件打不动)
1029
+ clearInterval(iv);
1030
+ if (!settingsDialogOpen()) { navTrace("open:shortcut-no-effect→account-menu"); openSettingsViaAccountMenu(); }
1031
+ else navTrace("open:shortcut-dialog-but-no-section");
1032
+ }
1033
+ }, 100);
1034
+ try { if (iv && typeof iv.unref === "function") iv.unref(); } catch (eV) { /* 忽略 */ }
1035
+ } catch (e) { navTrace("open:shortcut-error:" + (e && e.message)); }
1036
+ }
1037
+
890
1038
  /**
891
1039
  * 打开「远程访问」设置栏目。
892
1040
  * 栏目已渲染 → 直接点;否则**依次试每个"设置"候选**:点一下 → 轮询 ≤3s 看栏目出没出来 →
@@ -896,13 +1044,15 @@ window.__ModuleLoader__.load({
896
1044
  */
897
1045
  function openRemoteSettings() {
898
1046
  try {
1047
+ navTrace("open:enter");
899
1048
  var cell = pickNavByToken("远程访问", { insideDialog: true });
900
- if (cell) { cell.click(); return; }
1049
+ if (cell) { navTrace("open:section-already-visible"); cell.click(); return; }
901
1050
  var cands = settingsTriggerCandidates();
902
- if (!cands.length) return;
1051
+ navTrace("open:cands=" + cands.length);
1052
+ if (!cands.length) { navTrace("open:no-sidebar-cands→shortcut"); return openSettingsViaShortcut(); }
903
1053
  var idx = 0;
904
1054
  var tryNext = function () {
905
- if (idx >= cands.length) return;
1055
+ if (idx >= cands.length) { navTrace("open:sidebar-cands-exhausted→shortcut"); return openSettingsViaShortcut(); } // 侧栏里找不到触发器 → 走账号菜单
906
1056
  var el = cands[idx++];
907
1057
  try { el.click(); } catch (eClick) { return tryNext(); }
908
1058
  var tries = 0;
@@ -923,6 +1073,21 @@ window.__ModuleLoader__.load({
923
1073
  */
924
1074
  var remoteNavOpen = null;
925
1075
 
1076
+ /**
1077
+ * 入口点击链路的**现场追踪**(有界 40 条,挂在 `window.__druNavTrace`)。
1078
+ * 为什么值得常驻:这类"点了没反应"的问题跨了"侧栏候选 → 账号菜单 → 设置面板"三步,
1079
+ * 光看界面分不清卡在哪一步(本次排查就是靠它定位到「设置 ⌥⌘,」文案不匹配)。
1080
+ * 只写内存、不含隐私,出问题时让用户执行 `copy(window.__druNavTrace)` 即可。
1081
+ */
1082
+ function navTrace(step) {
1083
+ try {
1084
+ var w = window;
1085
+ if (!w.__druNavTrace) w.__druNavTrace = [];
1086
+ w.__druNavTrace.push(String(step));
1087
+ if (w.__druNavTrace.length > 40) w.__druNavTrace.shift();
1088
+ } catch (e) { /* 忽略 */ }
1089
+ }
1090
+
926
1091
  /* 📱 侧栏底部的「远程访问」入口(2026-10-10 三修:改用**官方槽位**)。====================
927
1092
  前两次都栽在"自己找 DOM 落点"上:
928
1093
  · 一修前:按文案包含「设置」找锚点 → 命中的是设置面板里的「通用设置」→ 按钮跑到设置面板顶部;
@@ -957,10 +1122,23 @@ window.__ModuleLoader__.load({
957
1122
  ]);
958
1123
  }
959
1124
 
1125
+ /* 📱 账号菜单里的入口(2026-10-11 业主指定位置:「放在设置那一行的上面,小弹窗里面」)。
1126
+ ────────────────────────────────────────────────────────────────────────────
1127
+ 那个「小弹窗」= 桌面端自带的**账号菜单**(设置 / 意见反馈 / 登录 / 更多),由
1128
+ `@deepseek-ai/dsh-client-ui-settings-account` 渲染(它只在桌面渲染器里注册,且有
1129
+ `dshDesktop` 判断)。它**没有给插件留任何菜单槽位**(该包只注册 shell.overlay /
1130
+ settings.models.sign-in / shell.quota-notice),所以放进它只能做 DOM 插入。
1131
+ 因此这里的写法刻意保守:
1132
+ · 只在"确实是账号菜单"(含「意见反馈」这一独有文案)的浮层里动手,别的浮层一律不碰;
1133
+ · 幂等(插过就跳过);找不到「设置」那一行就**什么都不做**(绝不猜位置);
1134
+ · 只用自己创建的 button(**不 cloneNode** —— 克隆会继承官方事件委托,历史事故);
1135
+ · 整段 try/catch,任何异常都不影响面板本身。
1136
+ 失败的可见后果只是"弹窗里没有这一项",侧栏那个官方槽位入口仍然在。 */
960
1137
  function navEnsureStart() {
961
1138
  if (window.__dshRemoteNavStarted) return;
962
1139
  window.__dshRemoteNavStarted = true;
963
1140
  try { window.__dshRemoteNav = { open: openRemoteSettings }; } catch (e) {}
1141
+ navTrace("adapter-ready");
964
1142
  // ⚠️ 这里**不再**做 DOM 注入(见 RemoteNavEntry 的注释):入口由官方槽位渲染。
965
1143
  // 保留本函数只为实现"继续暴露 window.__dshRemoteNav"(老调用方与诊断仍在用)。
966
1144
  try { if (typeof openRemoteSettings === "function") remoteNavOpen = openRemoteSettings; } catch (e) { /* 忽略 */ }
@@ -2718,7 +2896,11 @@ window.__ModuleLoader__.load({
2718
2896
  // 在那边**做不到也不该做**(强行退出会弹原生致命错误)。面板据此:
2719
2897
  // ① 不自动倒计时重启;② 保留提醒但改文案,引导"退出并重开桌面应用"。
2720
2898
  // 旧版 host 的响应里没有该字段 → false,行为与改动前完全一致。
2721
- var desktopHost = !!(st && st.desktopHost);
2899
+ // 🔴 2026-10-11 修:node 半把该字段下在 **connect** 里(`connect.desktopHost`),
2900
+ // 这里以前读顶层 `st.desktopHost` → **永远 false** → 桌面端被当成 Web 版:
2901
+ // 面板照样摆「立即重启」按钮、照样走倒计时自动重启那条路(业主实测"装插件时窗口被突兀关掉")。
2902
+ // 兼容两种位置,桌面端的"不自动重启/只提示用户自己重启"这才真正生效。
2903
+ var desktopHost = !!(st && (st.desktopHost || (st.connect && st.connect.desktopHost)));
2722
2904
  // kind === "refresh" = 插件文件在运行中被改写(装完/更新完),只需刷新页面;
2723
2905
  // 其余情况(极为罕见)才提重启 dsh web。
2724
2906
  var isRefreshOnly = restartPending && restartInfo.kind === "refresh";
@@ -6626,6 +6626,21 @@ function registerRoutes(ctx, relayDir) {
6626
6626
  handler: async (_req, res) => {
6627
6627
  // 首次安装/在线更新后重启 DeepSeek harness:插件本体与浏览器半在进程启动时装载,
6628
6628
  // 不重启则面板入口与 /dsh-remote/* 路由都不会出现。只重启承载本插件的进程。
6629
+ //
6630
+ // 🔴 2026-10-11 业主口径:**桌面端绝不代为重启**。
6631
+ // "自动重启会很突兀地把窗口给关掉,关没了……我们应该提示用户自己重启,不要由我们来重启。"
6632
+ // 桌面端 Host 由 Electron 托管,重启 = 强杀承载窗口的进程(还会弹原生致命错误)。
6633
+ // 这里做成**硬拒绝**(不只是前端藏按钮):任何调用方都拿不到"被重启"的结果,
6634
+ // 只会收到一句明确的"请手动重启",前端据此展示提示。
6635
+ if (IS_ELECTRON_HOST) {
6636
+ sendJson(res, 200, {
6637
+ ...(await composeStatus(relayDir)),
6638
+ ok: false,
6639
+ status: "manual-restart-required",
6640
+ detail: "桌面端不代为重启:请手动退出并重新打开 DeepSeek Harness(自动重启会突兀地关掉窗口)",
6641
+ });
6642
+ return;
6643
+ }
6629
6644
  const r = await restartHarness(relayDir);
6630
6645
  const payload = { ...(await composeStatus(relayDir)), ...r };
6631
6646
  if (!r.ok) payload.error = r.detail || r.status || "重启失败";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.0-beta.5",
3
+ "version": "0.7.0-beta.7",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -159,7 +159,10 @@ test("桌面端:状态接口必须下发宿主形态,面板才能自适应",
159
159
  });
160
160
 
161
161
  test("桌面端:面板不得自动倒计时重启,且不摆点了必然失败的重启按钮", () => {
162
- assert.match(CLIENT_SRC, /var desktopHost = !!\(st && st\.desktopHost\)/, "面板必须读 desktopHost");
162
+ // ⚠️ 2026-10-11 修:node 半把这个字段下在 **connect** 里(`connect.desktopHost`),
163
+ // 只读顶层 `st.desktopHost` 会**永远 false** → 桌面端被当成 Web 版(照样摆重启按钮、走自动倒计时)。
164
+ assert.match(CLIENT_SRC, /var desktopHost = !!\(st && \(st\.desktopHost \|\| \(st\.connect && st\.connect\.desktopHost\)\)\)/,
165
+ "面板必须同时读顶层与 connect.desktopHost(只读顶层等于门禁失效)");
163
166
  assert.match(CLIENT_SRC, /isRestartHard \|\| desktopHost \|\| busy !== ""/,
164
167
  "自动倒计时重启必须在桌面端短路(Host 由 Electron 托管,不能替用户重启)");
165
168
  assert.match(CLIENT_SRC, /\(desktopHost && !isRefreshOnly\) \? null : h\("button"/,
@@ -100,7 +100,7 @@ function makeDocument(roots) {
100
100
  };
101
101
  }
102
102
  function loadNavHelpers(doc) {
103
- const src = navHelpersSource() + "\nreturn { pickSettingsTrigger, pickNavByToken, navInDialog };";
103
+ const src = navHelpersSource() + "\nreturn { pickSettingsTrigger, settingsTriggerCandidates, pickNavByToken, navInDialog };";
104
104
  // eslint-disable-next-line no-new-func
105
105
  return new Function("document", "window", src)(doc, {});
106
106
  }
@@ -135,6 +135,30 @@ test("★ 事故锁:官方「设置」触发器只认语义锚点,且必须
135
135
  assert.equal(pickSettingsTrigger(), trigger, "必须命中侧栏那颗 aria-haspopup=dialog 的齿轮按钮");
136
136
  });
137
137
 
138
+ test("★ 2026-10-11 业主实测:点入口弹出的是 token 用量弹窗 —— 官方设置槽位必须优先于底部槽邻居", () => {
139
+ // 现场:入口挂在 sidebar.footer.action,而同一底部区域的「token 本轮用量」按钮
140
+ // 也带 aria-haspopup="dialog"(甚至带快捷键)→ 旧实现"谁先扫到谁"就点中了它。
141
+ // 现在:① 官方设置槽位里的按钮排第一;② 底部槽(我们所在那块)里的候选直接排除。
142
+ const sidebar = new El("div", { cls: "sidebarRoot" });
143
+ const footSlot = new El("div", { attrs: { "data-slot": "sidebar.footer.action" } });
144
+ const tokenBtn = new El("button", { cls: "tokTrigger", attrs: { "aria-haspopup": "dialog", "aria-keyshortcuts": "Alt+Meta+T", "aria-label": "本轮用量" } });
145
+ const entry = new El("button", { id: "dru-nav-remote", cls: "dru-foot-entry", text: "📱 远程访问" });
146
+ footSlot.append(tokenBtn, entry);
147
+ const settingsArea = new El("div", { cls: "settingsArea" });
148
+ const settingsSlot = new El("div", { attrs: { "data-slot": "sidebar.settings" } });
149
+ const trigger = new El("button", { cls: "gearTrigger", attrs: { "aria-haspopup": "dialog", "aria-label": "设置" } });
150
+ settingsSlot.append(trigger);
151
+ settingsArea.append(settingsSlot);
152
+ sidebar.append(footSlot, settingsArea);
153
+
154
+ const doc = makeDocument([sidebar]);
155
+ const { pickSettingsTrigger, settingsTriggerCandidates } = loadNavHelpers(doc);
156
+ const cands = settingsTriggerCandidates();
157
+ assert.ok(!cands.includes(tokenBtn), "★ 底部槽里的 token 用量按钮必须被排除(点它会弹错窗)");
158
+ assert.ok(!cands.includes(entry), "我们自己的入口不能成为候选(否则点自己→再点自己)");
159
+ assert.equal(pickSettingsTrigger(), trigger, "★ 必须选中官方设置槽位里的齿轮,而不是 token 用量按钮");
160
+ });
161
+
138
162
  test("★ 事故锁:弹层里的「通用设置」永远不能成为落点", () => {
139
163
  const { doc, general } = desktopFixture();
140
164
  const { pickSettingsTrigger, pickNavByToken } = loadNavHelpers(doc);
@@ -347,7 +347,8 @@ test("源码约束:侧栏注入「远程访问」按钮与官方「设置」
347
347
  assert.match(SOURCE, /data-dru-remote/);
348
348
  assert.match(SOURCE, /NAV_ENTRY_ID/);
349
349
  // 安全设计:绝不克隆官方按钮;候选限定 navCell/导航容器 + 短文本;排除自身;排除菜单语义
350
- assert.doesNotMatch(SOURCE, /cloneNode/);
350
+ // 只禁止**调用**(注释里说明"为什么不克隆"是允许的;2026-10-11 因注释里出现该词误报过)
351
+ assert.doesNotMatch(SOURCE, /\.cloneNode\s*\(/);
351
352
  assert.match(SOURCE, /navCell/);
352
353
  assert.match(SOURCE, /NAV_TEXT_MAX/);
353
354
  assert.match(SOURCE, /el\.id === NAV_ENTRY_ID/);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * 「点入口怎么打开设置」契约测试(2026-10-11 业主反馈后的最终形态)。
3
+ *
4
+ * 背景(业主原话):
5
+ * ① 「账号菜单里面的那个删掉。只保留官方槽位的那个。」
6
+ * ② 「设置界面出现了两个远程访问菜单。」—— 因为账号菜单注入的判据里加了"以设置结尾",
7
+ * 而「通用设置」正好以"设置"结尾 → 注入行被插进了**设置面板**里,多出一个"远程访问"。
8
+ * ③ 「点击……会触发一个小弹窗,同时也会把设置反馈的弹窗呼出来,有一瞬间会闪出一下,体验不好。」
9
+ * —— 那是"点账号菜单 → 再点设置"这条路带来的中间弹层。
10
+ *
11
+ * 现在的契约:
12
+ * · **不再往账号菜单里插任何东西**(dru-menu-row / accountMenuInjectTick 一律不得回来);
13
+ * · 侧栏入口在桌面端用**官方设置快捷键**(⌥⌘, / Alt+Meta+,)直接打开设置面板,
14
+ * 不产生任何中间弹层;打开后自动点中我们的栏目;
15
+ * · 面板里只能有**一个**「远程访问」栏目。
16
+ */
17
+
18
+ import { test } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { readFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "client.js"), "utf8");
25
+
26
+ /** 把快捷键打开逻辑切出来,在最小环境里跑一遍。 */
27
+ function loadShortcut({ sectionFound = true } = {}) {
28
+ const start = SOURCE.indexOf("function openSettingsViaShortcut()");
29
+ assert.ok(start > 0, "应能定位 openSettingsViaShortcut");
30
+ const end = SOURCE.indexOf("/**\n * 打开「远程访问」设置栏目。", start);
31
+ assert.ok(end > start, "应能定位函数结尾");
32
+ const src = SOURCE.slice(start, end);
33
+
34
+ const dispatched = [];
35
+ const clicked = [];
36
+ class KeyboardEvent {
37
+ constructor(type, init) { this.type = type; Object.assign(this, init); }
38
+ }
39
+ const document = { dispatchEvent: (e) => { dispatched.push(e); return true; } };
40
+ const pickNavByToken = () => (sectionFound ? { click: () => clicked.push("远程访问") } : null);
41
+ const fn = new Function(
42
+ "document", "KeyboardEvent", "navTrace", "pickNavByToken", "NAV_POLL_MAX", "setInterval", "clearInterval",
43
+ `${src}; return openSettingsViaShortcut;`
44
+ )(document, KeyboardEvent, () => {}, pickNavByToken, 3, (f) => { f(); return 1; }, () => {});
45
+ return { run: fn, dispatched, clicked };
46
+ }
47
+
48
+ test("★ 用官方设置快捷键(⌥⌘,)直接打开:不经过任何中间弹层", () => {
49
+ const { run, dispatched } = loadShortcut();
50
+ run();
51
+ assert.equal(dispatched.length, 1, "应恰好派发一次快捷键事件");
52
+ const ev = dispatched[0];
53
+ assert.equal(ev.type, "keydown");
54
+ assert.equal(ev.key, ",");
55
+ assert.equal(ev.code, "Comma");
56
+ assert.equal(ev.metaKey, true, "必须带 Meta(⌘)");
57
+ assert.equal(ev.altKey, true, "必须带 Alt(⌥)—— 与设置行自带的 aria-keyshortcuts=\"Alt+Meta+,\" 一致");
58
+ });
59
+
60
+ test("★ 设置面板打开后自动点中「远程访问」栏目", () => {
61
+ const { run, clicked } = loadShortcut({ sectionFound: true });
62
+ run();
63
+ assert.deepEqual(clicked, ["远程访问"], "面板开了要顺手切到我们的栏目");
64
+ });
65
+
66
+ test("★ 不再往账号菜单里插任何东西(业主明确要求删掉)", () => {
67
+ for (const banned of ["dru-menu-row", "accountMenuInjectTick", "MENU_ENTRY_SIG"]) {
68
+ assert.ok(!SOURCE.includes(banned), `不得再往账号菜单里插条目:${banned}(已按业主要求删除)`);
69
+ }
70
+ });
71
+
72
+ test("★ 打开设置的顺序:侧栏候选 → 快捷键(验证) → 账号菜单(兜底)", () => {
73
+ // 桌面端的 ⌥⌘, 是**原生菜单加速键**,合成 DOM 事件打不动它(真机实测"点了没反应");
74
+ // 而"设置"只存在于账号菜单里 → 必须保留这条兜底,但只能在前面都失败后才走(它会闪一下)。
75
+ assert.match(SOURCE, /open:shortcut-no-effect→account-menu/, "快捷键无效时必须转投账号菜单兜底");
76
+ const shortcutAt = SOURCE.indexOf("function openSettingsViaShortcut()");
77
+ const menuAt = SOURCE.indexOf("function openSettingsViaAccountMenu()");
78
+ assert.ok(shortcutAt > 0 && menuAt > 0, "两条路径都要在");
79
+ // 菜单兜底里必须带上"不是设置面板"的排除(否则会把设置面板当成账号菜单)
80
+ assert.match(SOURCE, /indexOf\("通用设置"\) !== -1\) continue;/, "识别账号菜单时必须排除设置面板本身");
81
+ });
82
+
83
+ test("★ 「设置」行判据:认「设置 ⌥ ⌘ ,」,绝不认「通用设置」", () => {
84
+ const start = SOURCE.indexOf("function menuRowIsSettings(");
85
+ assert.ok(start > 0, "应有 menuRowIsSettings");
86
+ const end = SOURCE.indexOf("function accountMenuTrigger()", start);
87
+ const fn = new Function(`${SOURCE.slice(start, end)}; return menuRowIsSettings;`)();
88
+ const mk = (t) => ({ textContent: t });
89
+ assert.equal(fn(mk("设置 ⌥ ⌘ ,")), true, "真实文案带快捷键,必须认得");
90
+ assert.equal(fn(mk("设置")), true, "纯文案也要认得");
91
+ assert.equal(fn(mk("Settings")), true, "英文界面");
92
+ assert.equal(fn(mk("通用设置")), false, "★ 绝不能认「通用设置」——2026-10-11 两个『远程访问』就是它造成的");
93
+ assert.equal(fn(mk("📱 远程访问")), false, "不能认我们自己的入口");
94
+ assert.equal(fn(mk("意见反馈")), false);
95
+ });
96
+
97
+ test("★ 反复出现的坑:判据不得用「以设置结尾」(会命中「通用设置」)", () => {
98
+ // 这次设置面板里冒出两个「远程访问」,根因就是结尾匹配把「通用设置」当成了「设置」行。
99
+ // 直接把这种判据钉死:源码里不得再出现「以设置/Settings 结尾」的正则片段。
100
+ assert.ok(!SOURCE.includes("(设置|Settings)$"), "不得再用「以设置结尾」的判据");
101
+ assert.ok(!SOURCE.includes("(设置|Settings)/$"), "不得再用「以设置结尾」的判据(另一种写法)");
102
+ });