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

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.
@@ -469,7 +469,7 @@ window.__ModuleLoader__.load({
469
469
  ".dru-e2ee-line.ok{color:var(--dru-success)}",
470
470
  // ── 📣 公告(0.6.18)──────────────────────────────────────────────────────
471
471
  // 侧栏入口上的**未读数字徽标**:与首次引导红点(.dru-nav-remote-dot)同一落点。
472
- // 两者同时出现会叠在一起,所以 JS 侧是"徽标优先"(有未读就不挂红点,见 mountNavEntry)。
472
+ // 两者同时出现会叠在一起,所以 JS 侧是"徽标优先"(有未读就不挂红点,见 RemoteNavEntry)。
473
473
  // 数字用白字蓝底(不是红底):红点=「你还没点过我」,未读数=「有多少条内容」,
474
474
  // 语义不同就不该长一样;红色在这个面板里已经被"危险/未读公告本身"占用了。
475
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}",
@@ -515,6 +515,13 @@ window.__ModuleLoader__.load({
515
515
  // 独立 button(非 clone),插在官方「设置」按钮之前,同源同布局不覆盖官方热区。
516
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}",
517
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}",
518
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}",
519
526
  // 侧栏入口与官方按钮同高(30px);用 ::after 把命中区补到 ≥44px,不破坏与原按钮的对齐
520
527
  ".dru-nav-remote::after{content:\"\";position:absolute;left:0;right:0;top:-7px;bottom:-7px}",
@@ -817,20 +824,37 @@ window.__ModuleLoader__.load({
817
824
  * `aria-haspopup="dialog"` 是**语言无关**的语义标记,比匹配文案稳(换语言/换文案都不失效)。
818
825
  * 找不到就**不注入** —— 宁可不显示快捷入口,也绝不把按钮塞到别人家的面板里。
819
826
  */
820
- function pickSettingsTrigger() {
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 = [];
821
838
  try {
822
839
  var cands = document.querySelectorAll('button[aria-haspopup="dialog"], [aria-haspopup="dialog"]');
840
+ var rest = [];
823
841
  for (var i = 0; i < cands.length; i++) {
824
842
  var el = cands[i];
825
- if (!el || el.id === NAV_ENTRY_ID) continue; // 排除注入按钮自身
826
- if (navInDialog(el)) continue; // 弹层里的(含面板内的关闭/栏目)一律不要
827
- if (!navCellLike(el)) continue;
843
+ if (!el || el.id === NAV_ENTRY_ID) continue; // 排除我们自己的入口
844
+ if (navInDialog(el)) continue; // 弹层内的(关闭键/栏目)一律不要
828
845
  if (NAV_BAD_RE.test(String(el.className || ""))) continue;
829
- return el;
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);
830
850
  }
831
- } catch (e) { /* 忽略 */ }
832
- // 兜底:严格等值匹配(**不做子串匹配**,否则又会命中「通用设置」);且必须在弹层外
833
- return pickNavByToken("设置", { exactOnly: true, insideDialog: false });
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;
834
858
  }
835
859
  /**
836
860
  * 按文案/aria-label 找导航项。
@@ -863,78 +887,83 @@ window.__ModuleLoader__.load({
863
887
  } catch (e) { /* 忽略 */ }
864
888
  return null;
865
889
  }
866
- /** 打开「远程访问」设置栏目:栏目已渲染直接点;否则先点官方「设置」再 ≤3s 轮询补点。 */
890
+ /**
891
+ * 打开「远程访问」设置栏目。
892
+ * 栏目已渲染 → 直接点;否则**依次试每个"设置"候选**:点一下 → 轮询 ≤3s 看栏目出没出来 →
893
+ * 没出来就换下一个候选。为什么要逐个试(2026-10-10):桌面端侧栏可能同时有多个
894
+ * `aria-haspopup="dialog"`(账号/更多…),认错了就会"点了没反应";逐个试则只要有**一个**
895
+ * 能打开设置面板,入口就一定能用。
896
+ */
867
897
  function openRemoteSettings() {
868
898
  try {
869
- // 栏目项在设置弹层内 → 这里**允许**弹层内匹配(insideDialog: true);
870
- // 精确相等优先,避免命中「通用设置」这类含「设置」的邻居。
871
899
  var cell = pickNavByToken("远程访问", { insideDialog: true });
872
900
  if (cell) { cell.click(); return; }
873
- var st = pickSettingsTrigger(); // 侧栏那颗齿轮(语义锚点,绝不落进弹层)
874
- if (!st) return;
875
- st.click();
876
- var tries = 0;
877
- var iv = setInterval(function () {
878
- tries++;
879
- var c2 = pickNavByToken("远程访问", { insideDialog: true });
880
- if (c2) { clearInterval(iv); c2.click(); }
881
- else if (tries >= NAV_POLL_MAX) clearInterval(iv);
882
- }, 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();
883
918
  } catch (e) { /* 忽略 */ }
884
919
  }
885
- /** 注入侧栏按钮到官方「设置」按钮之前(独立 button;首次未见过引导红点)。 */
886
- function mountNavEntry(host) {
887
- try {
888
- if (document.getElementById(NAV_ENTRY_ID)) return;
889
- // 最后一道防线:无论调用方怎么找的,**绝不**把入口塞进设置弹层内部
890
- // (2026-10-09 桌面端事故:按钮出现在设置面板栏目列表最上面)。
891
- if (navInDialog(host)) return;
892
- var wrap = host && host.parentNode;
893
- if (!wrap || !wrap.insertBefore || !wrap.contains) return;
894
- var btn = document.createElement("button");
895
- btn.type = "button";
896
- btn.id = NAV_ENTRY_ID;
897
- btn.className = "dru-nav-remote";
898
- btn.setAttribute("data-dru-remote", "1");
899
- // 🎨 侧栏入口在面板之外,拿不到面板根上的令牌 → 自己也挂一份主题属性
900
- btn.setAttribute("data-dru-theme", themeGet());
901
- themeState.navEl = btn;
902
- // 入口常驻,所以它自己就是主题观察器的一个订阅者:
903
- // 用户切深色时(哪怕面板没开着)这里也要立刻跟上。
904
- themeStartWatch();
905
- btn.title = "打开「远程访问」设置(快捷入口)";
906
- btn.style.position = "relative";
907
- btn.textContent = "📱 远程访问";
908
- btn.addEventListener("click", function (e) {
909
- try { e.stopPropagation(); e.preventDefault(); } catch (err) {}
910
- dotMarkSeen(); // 用过快捷入口=已了解入口位置 → 关闭首次红点(含官方栏目红点)
911
- openRemoteSettings();
912
- });
913
- wrap.insertBefore(btn, host);
914
- // 徽标优先于首次引导红点:两者落点相同(右上角),同时挂会叠在一起。
915
- // 有未读公告时"有多少条内容"比"你还没点过我"更值得占那个位置;红点本来也只到用户点一次为止。
916
- var badgeOn = !!(annState.ok && annState.bubble && annState.unread > 0);
917
- if (!dotSeen() && !badgeOn) {
918
- var d = document.createElement("span");
919
- d.className = "dru-nav-remote-dot";
920
- 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();
921
952
  }
922
- annRenderNavBadge();
923
- } 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
+ ]);
924
958
  }
925
- /** 常驻确保:官方「设置」按钮出现后在它前面挂一次入口(去重;2s 心跳,成本极低)。 */
959
+
926
960
  function navEnsureStart() {
927
961
  if (window.__dshRemoteNavStarted) return;
928
962
  window.__dshRemoteNavStarted = true;
929
963
  try { window.__dshRemoteNav = { open: openRemoteSettings }; } catch (e) {}
930
- var iv = setInterval(function () {
931
- try {
932
- if (document.getElementById(NAV_ENTRY_ID)) return; // 已注入
933
- var host = pickSettingsTrigger(); // 只认 aria-haspopup="dialog" 的官方「设置」触发器
934
- if (host) mountNavEntry(host);
935
- } catch (e) { /* 忽略 */ }
936
- }, 2000);
937
- 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) { /* 忽略 */ }
938
967
  }
939
968
 
940
969
  // ══ 📣 公告(0.6.18)模块级状态 ═══════════════════════════════════════════
@@ -5732,6 +5761,22 @@ window.__ModuleLoader__.load({
5732
5761
  // 【0.6.11】本插件在这个槽里**只有一个栏目**(📱 远程访问):🤖 微信机器人原先以 order 31
5733
5762
  // 注册成第二个栏目,业主口径「不要给它单独弄一个菜单,直接放到面板里面」→ 已改成面板里的
5734
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) { /* 老宿主没有这个槽:忽略,面板入口仍然可用 */ }
5735
5780
  ctx.slots.inject("settings.section", function () {
5736
5781
  var d1 = ctx.slots.register({
5737
5782
  name: "settings.section",
@@ -5767,7 +5812,7 @@ window.__ModuleLoader__.load({
5767
5812
  try {
5768
5813
  // 侧栏徽标的 DOM 订阅者(非 React):store 一变就重画徽标。
5769
5814
  // 即便入口按钮此刻还没注入(官方「设置」按钮尚未出现),订阅本身也是零成本的 ——
5770
- // mountNavEntry 挂上按钮时会主动画一次。
5815
+ // RemoteNavEntry 挂上按钮时会主动画一次。
5771
5816
  annSubscribe(annRenderNavBadge);
5772
5817
  annPullOnce();
5773
5818
  } catch (e) { /* 静默降级:不显示公告入口/徽标,页面照常 */ }
@@ -5777,7 +5822,7 @@ window.__ModuleLoader__.load({
5777
5822
  // 侧栏「远程访问」快捷入口(2026-09 恢复注入:与官方「设置」按钮共存不遮挡)
5778
5823
  navEnsureStart();
5779
5824
  // 🎨 主题:**不在这里**建 observer —— 观察器只在真有订阅者时才创建
5780
- // (面板/弹窗挂载 → themeSubscribe;侧栏入口挂上 → mountNavEntry)。
5825
+ // (面板/弹窗挂载 → themeSubscribe;侧栏入口挂上 → RemoteNavEntry)。
5781
5826
  // 这样「没有任何东西需要跟随主题」时不会白留一个 MutationObserver,
5782
5827
  // 也避免与本插件已有的红点观察器互相干扰。
5783
5828
 
@@ -21,7 +21,7 @@
21
21
  //
22
22
  // 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
23
23
  import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, copyFileSync, cpSync, constants as fsConstants } from "node:fs";
24
- import { join, dirname, sep, delimiter, isAbsolute } from "node:path";
24
+ import { join, dirname, sep, delimiter, isAbsolute, relative } from "node:path";
25
25
  import { execSync, spawn, spawnSync } from "node:child_process";
26
26
  import { randomUUID } from "node:crypto";
27
27
  import { homedir, tmpdir } from "node:os";
@@ -740,6 +740,24 @@ function applySaaSMode(cfg) {
740
740
  * 老版本(无该鉴权)下 connection 服务没有 authenticatedUrl → 静默跳过,行为不变。
741
741
  */
742
742
  const HARNESS_COOKIE_FILE = ".harness-cookie.json";
743
+ /**
744
+ * fetch 失败时**把真实原因带出来**(2026-10-10)。
745
+ * undici 只给一句 "fetch failed",真因藏在 `e.cause`(ECONNREFUSED / ENOTFOUND /
746
+ * CERT_HAS_EXPIRED / UND_ERR_CONNECT_TIMEOUT / 代理相关…)。以前这层被吞掉,
747
+ * 于是面板上只有"验证码服务不可达: fetch failed",排查只能猜。
748
+ */
749
+ function fetchFailReason(e) {
750
+ const parts = [String((e && e.message) || e)];
751
+ let c = e && e.cause;
752
+ for (let i = 0; c && i < 3; i += 1) {
753
+ const bit = c.code || c.message || c.name;
754
+ if (bit) parts.push(String(bit));
755
+ c = c.cause;
756
+ }
757
+ try { if (e && e.name && !parts.includes(e.name)) parts.push(String(e.name)); } catch { /* 忽略 */ }
758
+ return [...new Set(parts)].join(" ← ");
759
+ }
760
+
743
761
  /**
744
762
  * Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
745
763
  *
@@ -995,6 +1013,14 @@ function runtimeManifest(relayDir) {
995
1013
  */
996
1014
  function missingRuntimeFiles(relayDir) {
997
1015
  if (!existsSync(join(relayDir, "dsh-setup.mjs"))) return ["dsh-setup.mjs"];
1016
+ /* ⚠️ 已知盲区(2026-10-10 发现,**暂不做 gating**):bridge 顶层 `import WebSocket from "ws"` 是裸依赖,
1017
+ 而 runtimeManifest 只跟**相对路径 import**,所以"运行环境里有没有 ws"从未进过这份自检。
1018
+ 试过把 `node_modules/ws` 加进必需清单,但真实语义是 **Node 会向上找 node_modules**
1019
+ (~/.dsh-remote/node_modules/ws 与宿主/父级 node_modules 都可能提供它),
1020
+ 按"某个固定路径必须存在"判会误伤 —— 实测直接打挂 17 个连接流程用例,且真实机器上
1021
+ ws 本来就解析得到(已验证)。所以这里保持"只判入口点名的相对文件",
1022
+ 根治放在**源头**:安装器拷贝插件时必须保留 `runtime/node_modules`(已修 + 有用例),
1023
+ 以及 `npm run sync:local` 会把 ws 一并同步到 relayDir。 */
998
1024
  return runtimeManifest(relayDir).filter((rel) => !existsSync(join(relayDir, rel)));
999
1025
  }
1000
1026
 
@@ -1020,6 +1046,23 @@ function launchAgentPath() {
1020
1046
  if (isDarwin()) return join(homedir(), "Library/LaunchAgents/com.dshremote.bridge.plist");
1021
1047
  return null;
1022
1048
  }
1049
+ /**
1050
+ * ★ 这份自启动 plist 是否**确实是为本 relayDir 装的**(内容里写着 `<relayDir>/dsh-setup.mjs`)。
1051
+ *
1052
+ * 🔴 2026-10-10 事故(两次,同一根因):`reapBrokenAutostart()` 用**当前 relayDir** 判断
1053
+ * "入口脚本缺失"(测试的临时 relayDir 当然没有 dsh-setup.mjs → 判 true),
1054
+ * 但删除的却是 `launchAgentPath()` = **真实 HOME 下那份 plist** → 把开发者本机的自启动
1055
+ * 连 bootout 带删文件一起干掉了(远程访问中断,且日志里看不出异常)。
1056
+ * 判据与删除目标必须是**同一个东西**:先确认这份 plist 属于本 relayDir,再动手。
1057
+ */
1058
+ function plistOwnedByRelayDir(relayDir) {
1059
+ try {
1060
+ const p = launchAgentPath();
1061
+ if (!p || !existsSync(p)) return false;
1062
+ return readFileSync(p, "utf8").includes(join(relayDir, "dsh-setup.mjs"));
1063
+ } catch { return false; }
1064
+ }
1065
+
1023
1066
 
1024
1067
  /**
1025
1068
  * Linux 的 systemd user unit 路径(与 dsh-setup.mjs 的 `autostartFilePath()` **同一路径**)。
@@ -1691,6 +1734,8 @@ function reapBrokenAutostart(relayDir) {
1691
1734
  // 只有 macOS 有 launchd 自启动项可摘;Windows/Linux 走的是 systemd/无自启动,判据不适用。
1692
1735
  if (!isDarwin()) return false;
1693
1736
  if (!plistEntryMissing()) return false;
1737
+ // 归属校验:这份 plist 不是给本 relayDir 装的 → 一个字节都不动(见 plistOwnedByRelayDir)
1738
+ if (!plistOwnedByRelayDir(relayDir)) return false;
1694
1739
  const st = launchdStatus();
1695
1740
  if (!st.running && !st.crashing && !st.state) return false; // 作业未被 launchd 加载、无可摘的东西
1696
1741
  for (const target of launchTargets()) sh(`launchctl bootout ${target}`);
@@ -2020,9 +2065,36 @@ function uninstallRuntime(relayDir, protectedPath) {
2020
2065
  // a) 停服务 + 移除自启动文件
2021
2066
  try {
2022
2067
  if (isDarwin()) {
2068
+ /* 🔴 2026-10-10 事故护栏:**relayDir 不在当前 HOME 之下时,绝不碰真实自启动**。
2069
+ 当天某个用例用「真 HOME + 临时 relayDir」调到了这里:plist 路径解析到真实的
2070
+ ~/Library/LaunchAgents/com.dshremote.bridge.plist → 先 bootout 真桥、再把 plist 删了
2071
+ → **开发者本机的远程访问被测试整个卸掉**(job 消失、桥停摆)。
2072
+ 判据:生产部署里 relayDir 一定在 HOME 之下(~/.dsh-remote);
2073
+ 测试夹具的 relayDir 在 /var/folders/... 而 HOME 是真实的 —— 这个不一致必须拒绝。
2074
+ 显式要跨目录操作时设 DSH_RELAY_ALLOW_FOREIGN_SERVICE=1。 */
2075
+ const underHome = (() => {
2076
+ try {
2077
+ const rel = relative(homedir(), relayDir);
2078
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
2079
+ } catch { return false; }
2080
+ })();
2081
+ if (!underHome && process.env.DSH_RELAY_ALLOW_FOREIGN_SERVICE !== "1") {
2082
+ out.serviceSkipped = `relayDir 不在 HOME 之下(${relayDir}),拒绝改动真实自启动服务`;
2083
+ return out;
2084
+ }
2023
2085
  // 只处理「plist 位于当前 HOME」的服务:本插件/dsh-setup.mjs 安装的服务一定在此
2024
2086
  const plistPath = launchAgentPath();
2025
2087
  if (plistPath && existsSync(plistPath)) {
2088
+ /* ★★ 归属校验(2026-10-10 事故的最终护栏):
2089
+ 这份 plist 必须**确实是为本 relayDir 装的**(内容里写着 <relayDir>/dsh-setup.mjs)。
2090
+ 不校验的话,只要有用例拿「真 HOME + 临时 relayDir」走到这里,就会把开发者本机
2091
+ 真实的服务**先 bootout 再删文件**(当天实测发生:桥停摆、远程访问中断)。
2092
+ 归属不符 → 整个服务分支跳过(并如实说明),绝不越界。 */
2093
+ const ownedByThisDir = plistOwnedByRelayDir(relayDir);
2094
+ if (!ownedByThisDir) {
2095
+ out.serviceSkipped = `plist 不属于本 relayDir,拒绝改动:${plistPath}`;
2096
+ return out;
2097
+ }
2026
2098
  // crashing 也要 bootout:崩溃循环中的作业在 launchd 里仍是「已加载」,
2027
2099
  // 只删 plist 文件它照样被 KeepAlive 反复重拉(见 launchdStatus 注释)。
2028
2100
  const st = launchdStatus();
@@ -3244,7 +3316,7 @@ async function proxyAnnouncements(relayDir, req, res, pathname) {
3244
3316
  res.end(text);
3245
3317
  } catch (e) {
3246
3318
  // 网络不可达 / 超时 / JSON 异常:一律 502 + 人话,绝不把异常抛回 webServer(那会变成 500 堆栈)
3247
- return sendJson(res, 502, { ok: false, error: `公告服务不可达: ${e.message}` });
3319
+ return sendJson(res, 502, { ok: false, error: `公告服务不可达: ${fetchFailReason(e)}` });
3248
3320
  }
3249
3321
  }
3250
3322
 
@@ -3887,13 +3959,22 @@ function writePatchDocument(patchFile, text) {
3887
3959
  * 助手做的是「宁可少做也不卡住」的有界清理:杀残留进程 → 删自启动 → 清配置目录,每步独立 try/catch。
3888
3960
  */
3889
3961
  function buildRuntimeCleanupHelper(relayDir, protectedPath) {
3962
+ /* ★ 归属校验(2026-10-10 事故第三条路径):计划里的 plistPath 用的是**当前 homedir**,
3963
+ 而 relayDir 来自调用方 —— 两者不必是同一个目录。曾有测试用「真 HOME + 临时 relayDir」
3964
+ 走到这里,于是这个 detached 助手把**开发者本机真实的自启动 plist 删了**(异步删,
3965
+ 所以在进程内插桩根本抓不到)。现在:plist 里没写着本 relayDir 的入口脚本 → **不给路径**,
3966
+ 助手自然一个字节都不动。 */
3967
+ const homePlist = join(homedir(), "Library", "LaunchAgents", "com.dshremote.bridge.plist");
3968
+ const ownsHomePlist = (() => {
3969
+ try { return existsSync(homePlist) && readFileSync(homePlist, "utf8").includes(join(relayDir, "dsh-setup.mjs")); } catch { return false; }
3970
+ })();
3890
3971
  const plan = JSON.stringify({
3891
3972
  relayDir,
3892
3973
  protectedPath: protectedPath || "",
3893
3974
  platform: osPlatform(),
3894
3975
  taskName: WIN_TASK_NAME,
3895
3976
  pidFiles: [WATCHER_PID_FILE, BRIDGE_PID_FILE],
3896
- plistPath: join(homedir(), "Library", "LaunchAgents", "com.dshremote.bridge.plist"),
3977
+ plistPath: ownsHomePlist ? homePlist : "",
3897
3978
  unitPath: join(homedir(), ".config", "systemd", "user", "dsh-bridge.service"),
3898
3979
  log: join(tmpdir(), "dsh-remote-uninstall.log"),
3899
3980
  });
@@ -6643,7 +6724,7 @@ function registerRoutes(ctx, relayDir) {
6643
6724
  res.writeHead(200, { "content-type": type || "application/json; charset=utf-8", "cache-control": "no-store" });
6644
6725
  res.end(buf);
6645
6726
  } catch (e) {
6646
- sendJson(res, 502, { ok: false, error: `验证码服务不可达: ${e.message}` });
6727
+ sendJson(res, 502, { ok: false, error: `验证码服务不可达: ${fetchFailReason(e)}` });
6647
6728
  }
6648
6729
  },
6649
6730
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.0-beta.3",
3
+ "version": "0.7.0-beta.5",
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",