dsh-remote-web 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.
- package/lib/client.js +116 -71
- package/lib/index.js +85 -4
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +111 -39
- package/runtime/dsh-setup.mjs +201 -14
package/lib/client.js
CHANGED
|
@@ -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 侧是"徽标优先"(有未读就不挂红点,见
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
/**
|
|
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
|
|
874
|
-
if (!
|
|
875
|
-
|
|
876
|
-
var
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
/**
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
923
|
-
|
|
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
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
//
|
|
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;侧栏入口挂上 →
|
|
5825
|
+
// (面板/弹窗挂载 → themeSubscribe;侧栏入口挂上 → RemoteNavEntry)。
|
|
5781
5826
|
// 这样「没有任何东西需要跟随主题」时不会白留一个 MutationObserver,
|
|
5782
5827
|
// 也避免与本插件已有的红点观察器互相干扰。
|
|
5783
5828
|
|
package/lib/index.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
6727
|
+
sendJson(res, 502, { ok: false, error: `验证码服务不可达: ${fetchFailReason(e)}` });
|
|
6647
6728
|
}
|
|
6648
6729
|
},
|
|
6649
6730
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.0-beta.
|
|
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",
|
|
@@ -1028,6 +1028,74 @@ const SCRIPT = `(() => {
|
|
|
1028
1028
|
窄屏还会自动收起左栏(viewportWidth < 1024 → narrowExpanded = false)。
|
|
1029
1029
|
我们插一脚只会把官方推到相反方向(这正是「点开变黑遮罩」那条链的起点)。 */
|
|
1030
1030
|
let fabDone = false;
|
|
1031
|
+
|
|
1032
|
+
/* ===== 📶 网络延迟采样器(2026-10-10 二修,与"某一代菜单 DOM"彻底解耦)=================
|
|
1033
|
+
一修(0.7.0-beta.3)改掉了"亚毫秒样本被丢弃",但业主实测**仍然一直「测速中」**,
|
|
1034
|
+
而同一时段镜像页打了 69 次 /api/agentPresets/list 这类**完全合格**的请求 ——
|
|
1035
|
+
说明真根因不是样本被过滤,而是**采样根本没接上**(或渲染写进了已被替换的旧节点)。
|
|
1036
|
+
|
|
1037
|
+
所以这次不再依赖"建菜单时顺手包装一次 fetch":
|
|
1038
|
+
· 安装独立成 maInstallNetSampler(),幂等、**自愈**(发现 window.fetch 上不是我们的采样器
|
|
1039
|
+
就重新包一层)—— 谁在这之后把 fetch 换掉(我们自己注入的 E2EE shim 会在解锁时再包一次)
|
|
1040
|
+
都能兜住;
|
|
1041
|
+
· 样本挂 window(跨"菜单被 SPA 重建"共享);
|
|
1042
|
+
· 渲染**每次现查实时 DOM**,不再抱着建菜单时的旧引用(旧引用可能已脱离文档,
|
|
1043
|
+
于是样本明明在涨、界面却永远停在初始文案);
|
|
1044
|
+
· 空态**直接说明原因**,下次再出问题不用猜:一眼看出是"没接上"还是"接上了但没请求"。 */
|
|
1045
|
+
const maNetPatch = { ok: false, samples: 0, lastUrl: "", lastAt: 0 };
|
|
1046
|
+
try { window.__dshMaNet = maNetPatch; } catch (eNet0) { /* 忽略 */ }
|
|
1047
|
+
/** 当前这一代菜单注册的渲染器(可能为 null:菜单还没建)。 */
|
|
1048
|
+
let maNetRender = null;
|
|
1049
|
+
const maNoteRtt = (ms, url) => {
|
|
1050
|
+
try {
|
|
1051
|
+
if (typeof ms !== "number" || !isFinite(ms)) return;
|
|
1052
|
+
const r = Math.round(ms);
|
|
1053
|
+
// 只拒绝负数与超时;0(本机/回环的真实往返不足 1ms)照收,展示层再夹到 ≥1ms。
|
|
1054
|
+
if (r < 0 || r > 20000) return;
|
|
1055
|
+
const bag = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
|
|
1056
|
+
bag.push(r);
|
|
1057
|
+
while (bag.length > 8) bag.shift();
|
|
1058
|
+
maNetPatch.samples = bag.length;
|
|
1059
|
+
maNetPatch.lastUrl = String(url || "").slice(0, 80);
|
|
1060
|
+
maNetPatch.lastAt = Date.now();
|
|
1061
|
+
if (maNetRender) { try { maNetRender(); } catch (eR) { /* 忽略 */ } }
|
|
1062
|
+
} catch (eN) { /* 忽略 */ }
|
|
1063
|
+
};
|
|
1064
|
+
/** 把采样器挂到 window.fetch 上(**只挂一次**)。返回是否已就绪。 */
|
|
1065
|
+
const maInstallNetSampler = () => {
|
|
1066
|
+
try {
|
|
1067
|
+
if (typeof window.fetch !== "function") return false;
|
|
1068
|
+
/* ⚠️ 幂等判据必须是**稳定标志**,不能拿 window.fetch.__dshMaNetSampler 当判据(2026-10-10 自测抓到):
|
|
1069
|
+
"会话过期探测"那层包装会在本采样器**外面**再包一层(它建菜单时才装,晚于这里),
|
|
1070
|
+
于是最外层 window.fetch 上没有我们的标记 → 若用函数标记当判据,每一拍都会再包一层,
|
|
1071
|
+
秒级泄漏(请求穿过 N 层、同一趟被记 N 个样本)。 */
|
|
1072
|
+
if (maNetPatch.installed === true) { maNetPatch.ok = true; return true; }
|
|
1073
|
+
const orig = window.fetch;
|
|
1074
|
+
const wrapped = function (input, init) {
|
|
1075
|
+
let url = "";
|
|
1076
|
+
try { url = typeof input === "string" ? input : (input && input.url) || ""; } catch (eU) { url = ""; }
|
|
1077
|
+
const t0 = maNowMs();
|
|
1078
|
+
const p = orig.apply(this, arguments);
|
|
1079
|
+
try {
|
|
1080
|
+
const abs = url.slice(0, 8).toLowerCase();
|
|
1081
|
+
const absolute = abs.indexOf("http://") === 0 || abs.indexOf("https://") === 0;
|
|
1082
|
+
const sameOrigin = !absolute || url.indexOf(window.location.origin) === 0;
|
|
1083
|
+
const apiish = url.indexOf("/api/") >= 0 || url.indexOf("/remote/") >= 0
|
|
1084
|
+
|| url.indexOf("_devices") >= 0 || url.indexOf("_quota") >= 0;
|
|
1085
|
+
if (sameOrigin && apiish && p && typeof p.then === "function") {
|
|
1086
|
+
p.then(() => { maNoteRtt(maNowMs() - t0, url); }, () => { /* 网络错误不算延迟 */ });
|
|
1087
|
+
}
|
|
1088
|
+
} catch (eW) { /* 忽略 */ }
|
|
1089
|
+
return p;
|
|
1090
|
+
};
|
|
1091
|
+
wrapped.__dshMaNetSampler = true; // 只作记号便于人造排查(**不作**幂等判据)
|
|
1092
|
+
maNetPatch.installed = true; // 稳定幂等判据
|
|
1093
|
+
window.fetch = wrapped;
|
|
1094
|
+
maNetPatch.ok = true;
|
|
1095
|
+
return true;
|
|
1096
|
+
} catch (eI) { return false; }
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1031
1099
|
const buildRemoteMenu = () => {
|
|
1032
1100
|
/* ===== 统一「远程控制」悬浮按钮:字号 + 加密状态 + 返回设备列表 =====
|
|
1033
1101
|
2026-09-19 用户要求原话:「把字体的菜单和加密小锁的菜单合并成一个小悬浮按钮;点击后可以弹菜单
|
|
@@ -1244,14 +1312,11 @@ const SCRIPT = `(() => {
|
|
|
1244
1312
|
旧的 120/300 分档是照"网页首屏"的体感定的,会把大量正常可用的移动网络误报成"较慢",
|
|
1245
1313
|
反而让用户以为哪里坏了。 */
|
|
1246
1314
|
const RTT_KEEP = 8, RTT_MAX_MS = 20000;
|
|
1247
|
-
/*
|
|
1248
|
-
为什么:fetch 包装只装一次(window.__dshMaSessionWatch 一次性闸门),而菜单/悬浮按钮
|
|
1249
|
-
在官方 SPA 重渲染时可能被**重建**。若样本数组长在 buildRemoteMenu 里,包装就会一直喂给
|
|
1250
|
-
"上一代"渲染器(那个节点已经不在文档里),新界面读自己的空数组 → **永远停在「测速中…」**。
|
|
1251
|
-
挂在 window 上则任何一代渲染器都读同一份样本。 */
|
|
1252
|
-
const rttSamples = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
|
|
1315
|
+
/* 建菜单时的引用只作**兜底**:正常路径每次渲染都现查实时 DOM(见 liveNetEl/liveRttEl)。 */
|
|
1253
1316
|
const rttEl = fab.querySelector('[data-role="rtt"]');
|
|
1254
1317
|
const netEl = menu.querySelector('[data-role="net"]');
|
|
1318
|
+
/* 样本一律读 window 上那一份(由上面的 maNoteRtt 写入,跨"菜单被重建"共享)。 */
|
|
1319
|
+
const rttSamples = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
|
|
1255
1320
|
const rttValue = () => {
|
|
1256
1321
|
if (!rttSamples.length) return null;
|
|
1257
1322
|
const s = rttSamples.slice().sort((a, b) => a - b);
|
|
@@ -1263,44 +1328,45 @@ const SCRIPT = `(() => {
|
|
|
1263
1328
|
const rttGrade = (ms) => (ms <= 500 ? "ok" : ms <= 1000 ? "mid" : ms <= 2000 ? "slow" : "bad");
|
|
1264
1329
|
const rttLabel = (g) => (g === "ok" ? "顺畅" : g === "mid" ? "正常" : g === "slow" ? "偏慢" : "很慢");
|
|
1265
1330
|
const FAB_TITLE_BASE = "远程控制:字号 / 网络延迟 / 加密状态 / 返回设备列表(可拖动移动位置)";
|
|
1331
|
+
/* 🔴 每次渲染都**现查实时 DOM**(2026-10-10 二修)。
|
|
1332
|
+
抱着建菜单时的旧引用是"样本在涨、界面却永远停在初始文案"的经典成因:官方 SPA 换掉 body
|
|
1333
|
+
子树后旧引用脱离文档,更新写进了看不见的节点。现查则永远写用户真正看得见的那一个;
|
|
1334
|
+
查不到才退回旧引用(宽屏/菜单未开时本来就没有可写的节点)。 */
|
|
1335
|
+
const liveNetEl = () => { try { return document.querySelector(".dsh-ma-menu-net") || netEl; } catch (eQ) { return netEl; } };
|
|
1336
|
+
const liveRttEl = () => { try { return fab.querySelector('[data-role="rtt"]') || rttEl; } catch (eQ2) { return rttEl; } };
|
|
1266
1337
|
const renderRtt = () => {
|
|
1267
1338
|
try {
|
|
1268
1339
|
const ms = rttValue();
|
|
1269
1340
|
const g = ms === null ? "" : rttGrade(ms);
|
|
1270
1341
|
// 0ms 是句谎话(不足 1ms 的真实往返会被四舍五入成 0)→ 展示层夹到 ≥1ms。
|
|
1271
1342
|
const shown = ms === null ? null : Math.max(1, Math.round(ms));
|
|
1272
|
-
|
|
1273
|
-
|
|
1343
|
+
const rEl = liveRttEl();
|
|
1344
|
+
if (rEl) {
|
|
1345
|
+
if (shown === null) { rEl.hidden = true; rEl.textContent = ""; rEl.removeAttribute("data-rtt"); }
|
|
1274
1346
|
else {
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1347
|
+
rEl.hidden = false;
|
|
1348
|
+
rEl.textContent = shown + "ms";
|
|
1349
|
+
rEl.setAttribute("data-rtt", g);
|
|
1278
1350
|
}
|
|
1279
1351
|
}
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
const
|
|
1352
|
+
const nEl = liveNetEl();
|
|
1353
|
+
if (nEl) {
|
|
1354
|
+
nEl.setAttribute("data-rtt", g || "");
|
|
1355
|
+
const ico = nEl.querySelector(".ico");
|
|
1356
|
+
const txt = nEl.querySelector(".txt");
|
|
1284
1357
|
if (ico) ico.textContent = shown === null ? "📶" : (g === "bad" ? "📵" : "📶");
|
|
1285
|
-
if (txt)
|
|
1358
|
+
if (txt) {
|
|
1359
|
+
/* 空态**说清原因**(2026-10-10):以前只有一句「等一次网络请求后显示」,
|
|
1360
|
+
于是"采样器压根没接上"和"接上了但还没有请求"长得一模一样,业主两次反馈
|
|
1361
|
+
我们都只能猜。现在一眼可分。 */
|
|
1362
|
+
txt.textContent = shown !== null ? (shown + " ms · " + rttLabel(g))
|
|
1363
|
+
: (maNetPatch.ok ? "测速中…(已接上,等一次请求)" : "测速中…(采样器未接上)");
|
|
1364
|
+
}
|
|
1286
1365
|
}
|
|
1287
1366
|
try { fab.title = shown === null ? FAB_TITLE_BASE : ("远程控制:网络延迟 " + shown + "ms(" + rttLabel(g) + ") / 字号 / 加密状态 / 返回设备列表(可拖动)"); } catch (e) { /* 忽略 */ }
|
|
1288
1367
|
} catch (e) { /* 延迟显示失败绝不影响主功能 */ }
|
|
1289
1368
|
};
|
|
1290
|
-
|
|
1291
|
-
const noteRtt = (ms) => {
|
|
1292
|
-
if (typeof ms !== "number" || !isFinite(ms)) return;
|
|
1293
|
-
const r = Math.round(ms);
|
|
1294
|
-
/* 🔴 旧写法是 if (r < 1 || ...) return; ,注释说"亚毫秒=同帧缓存命中"。这个假设是**错的**:
|
|
1295
|
-
本机/回环访问(桌面端自测、在同一台机器上打开镜像页)一次真实往返往往就是 < 1ms,
|
|
1296
|
-
于是**每一个**样本都被丢掉,界面永远停在「测速中…(等一次网络请求后显示)」——
|
|
1297
|
-
业主 2026-10-09 实测反馈的正是这个现象。只拒绝负数与超时;0 → 展示层夹到 1ms。
|
|
1298
|
-
(面板侧 chip 从一开始就是 v <= 0 + Math.max(1, round),两边口径由此对齐。) */
|
|
1299
|
-
if (r < 0 || r > RTT_MAX_MS) return;
|
|
1300
|
-
rttSamples.push(r);
|
|
1301
|
-
while (rttSamples.length > RTT_KEEP) rttSamples.shift();
|
|
1302
|
-
renderRtt();
|
|
1303
|
-
};
|
|
1369
|
+
maNetRender = renderRtt; // 注册给采样器(样品到达时回调它)
|
|
1304
1370
|
renderRtt();
|
|
1305
1371
|
|
|
1306
1372
|
/* ❌ 这里曾有「打开右侧栏」这一段与 maRightPane* 三件套 —— 2026-09-27 根治时整条删除。
|
|
@@ -1449,14 +1515,9 @@ const SCRIPT = `(() => {
|
|
|
1449
1515
|
window.fetch = function (input, init) {
|
|
1450
1516
|
let url = "";
|
|
1451
1517
|
try { url = typeof input === "string" ? input : (input && input.url) || ""; } catch (e5) { url = ""; }
|
|
1452
|
-
/*
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
静态壳/图片/字体不该代表"网络延迟";telemetry 那条 /dsh-remote/telemetry/... 也
|
|
1456
|
-
不在 apiish 里(它既不含 "/api/" 也不含 "/remote/",只有 "-remote/"),
|
|
1457
|
-
所以一次性的遥测上报不会污染样本。
|
|
1458
|
-
⚠️ 失败(reject)/超时(>20s)不进样本 —— 那不是"延迟",是"没通"(noteRtt 里已过滤)。 */
|
|
1459
|
-
const t0 = maNowMs();
|
|
1518
|
+
/* ⚠️ 2026-10-10:这里**不再**做网络延迟采样 —— 采样已独立成 maInstallNetSampler()
|
|
1519
|
+
(与菜单 DOM 解耦、幂等、自愈)。本包装只负责「会话过期探测」这一件事,
|
|
1520
|
+
否则同一趟请求会被量两次。 */
|
|
1460
1521
|
const p = origFetch.call(this, input, init);
|
|
1461
1522
|
try {
|
|
1462
1523
|
const abs = url.slice(0, 8).toLowerCase();
|
|
@@ -1465,7 +1526,7 @@ const SCRIPT = `(() => {
|
|
|
1465
1526
|
if (sameOrigin && apiish && p && typeof p.then === "function") {
|
|
1466
1527
|
p.then((res) => {
|
|
1467
1528
|
try { noteStatus(res && res.status); } catch (e6) { /* 忽略 */ }
|
|
1468
|
-
|
|
1529
|
+
/* 延迟采样已移到 maInstallNetSampler,这里只报会话过期 */
|
|
1469
1530
|
}, () => { /* 网络错误不算会话过期,也不算延迟样本 */ });
|
|
1470
1531
|
}
|
|
1471
1532
|
} catch (e7) { /* 忽略 */ }
|
|
@@ -1509,6 +1570,11 @@ const SCRIPT = `(() => {
|
|
|
1509
1570
|
let done = false;
|
|
1510
1571
|
const boot = () => {
|
|
1511
1572
|
if (!HOSTISH()) return;
|
|
1573
|
+
/* 📶 **先**把延迟采样器挂上(2026-10-10):它必须在"任何请求发生之前"就位。
|
|
1574
|
+
即便 buildRemoteMenu() 因为任何原因没能建出菜单(或抛错),采样器也照挂不误 ——
|
|
1575
|
+
旧实现把包装写在 buildRemoteMenu 内部的一次性分支里,那正是"请求明明在打、
|
|
1576
|
+
界面却永远测速中"的结构性原因。 */
|
|
1577
|
+
try { maInstallNetSampler(); } catch (eSampler0) { /* 忽略 */ }
|
|
1512
1578
|
/* 悬浮按钮/菜单:任何宽度都要有(电脑端访问镜像页时同样需要返回设备列表的出口)。
|
|
1513
1579
|
失败也不影响手机端布局适配 —— 两条链路各自独立。 */
|
|
1514
1580
|
if (!fabDone) { fabDone = true; try { buildRemoteMenu(); } catch (eFabDone) { /* 忽略 */ } }
|
|
@@ -1976,6 +2042,12 @@ const SCRIPT = `(() => {
|
|
|
1976
2042
|
const pump = () => {
|
|
1977
2043
|
if (maTorn) return;
|
|
1978
2044
|
if (bootUseful()) { try { boot(); } catch (eBoot) { /* 幂等重试 */ } }
|
|
2045
|
+
/* 📶 采样器**自愈**(2026-10-10):本函数每拍都跑(MutationObserver/1s 兜底),
|
|
2046
|
+
所以只要 window.fetch 被谁换掉(我们自己注入的 E2EE shim 会在"解锁"时再包一层、
|
|
2047
|
+
官方若在运行期替换 fetch 同理),下一拍就会把采样器重新挂回去 —— 幂等,已挂上时零开销。
|
|
2048
|
+
同时顺手重绘一次:官方 SPA 换掉菜单子树后,数字不必等下一次请求才回来。 */
|
|
2049
|
+
try { maInstallNetSampler(); } catch (eSampler) { /* 忽略 */ }
|
|
2050
|
+
try { if (maNetRender) maNetRender(); } catch (eNetRender) { /* 忽略 */ }
|
|
1979
2051
|
try { ensureDrawerChrome(); } catch (eChrome2) { /* 忽略 */ }
|
|
1980
2052
|
/* 镜像页就绪探测(mirror_load 的量测终点):pump 本来就被 MutationObserver(子树) + 1s 兜底表
|
|
1981
2053
|
驱动,官方 frame/输入框一挂上就会在**这一拍或下一拍**(≤1s)命中 —— 这正是契约要求的
|
package/runtime/dsh-setup.mjs
CHANGED
|
@@ -173,10 +173,13 @@ function printHelp() {
|
|
|
173
173
|
dsh-remote doctor 一条命令自检「为什么连不上」(只读;含上游/进程/微信/日志尾部)
|
|
174
174
|
dsh-remote --help 显示本用法(等同 dsh-remote help)
|
|
175
175
|
|
|
176
|
-
目标 profile
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
目标 profile(默认:**本机 Web 版 + 桌面端一起装**):
|
|
177
|
+
不带 --profile 时,会把插件装进本机**已有的** Web 版与桌面端 profile(谁在用就装给谁,
|
|
178
|
+
避免"命令跑完了、桌面端却没有面板"这类装错地方的坑);机器上的实验/自动化 profile 不会被动。
|
|
179
|
+
dsh-remote --profile desktop 只装进 DeepSeek Harness **桌面端**
|
|
180
|
+
(~/.dsh/profiles/desktop)
|
|
181
|
+
dsh-remote --profile web 只装进 Web 版
|
|
182
|
+
桌面端也可以在应用内「插件」页直接安装 dsh-remote-web —— 那是官方路径,也最省事。
|
|
180
183
|
⚠️ 桌面端与 Web 版共享 ~/.dsh(会话/设置/凭据),但**插件互相隔离**,两边要各自安装。
|
|
181
184
|
|
|
182
185
|
自建模式(可选):
|
|
@@ -712,7 +715,7 @@ function writeAutostartFile() {
|
|
|
712
715
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
713
716
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
714
717
|
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
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>
|
|
718
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}${PINNED_UPSTREAM ? `<key>DSH_BRIDGE_UPSTREAM</key><string>${PINNED_UPSTREAM}</string>` : ""}<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>
|
|
716
719
|
</dict></plist>`;
|
|
717
720
|
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
718
721
|
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
@@ -760,19 +763,46 @@ function launchdState(target) {
|
|
|
760
763
|
};
|
|
761
764
|
}
|
|
762
765
|
|
|
766
|
+
/**
|
|
767
|
+
* 等 label **真的从 launchd 里摘干净**。
|
|
768
|
+
*
|
|
769
|
+
* 🔴 2026-10-11 实测的坑:`launchctl bootout` 是**异步**的 —— 紧接着 bootstrap 同一个 label 会
|
|
770
|
+
* 随机失败(`Bootstrap failed: 5: Input/output error`),表现为"安装时灵时不灵":
|
|
771
|
+
* 手工在两条命令之间加个 sleep 就 100% 成功,安装器里没等就大约一半失败 →
|
|
772
|
+
* 失败后走 `spawnDetachedBridge()` 兜底 → 用户看到"⚠️ 未被系统托管 / 不会开机自启"。
|
|
773
|
+
* 所以 bootout 之后必须轮询到 `launchctl print` 真的查不到,再 bootstrap。
|
|
774
|
+
*/
|
|
775
|
+
function waitLaunchdGone(target, tries = 12, gapMs = 200) {
|
|
776
|
+
for (let i = 0; i < tries; i += 1) {
|
|
777
|
+
if (!sh(`launchctl print ${target}`).ok) return true;
|
|
778
|
+
sleepSync(gapMs);
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
|
|
763
783
|
/**
|
|
764
784
|
* 轮询等待 job 起来。
|
|
765
785
|
* launchd 派生是异步的:bootstrap 成功后立刻 print 必然看到 not running(旧实现因此误报"启动失败")。
|
|
766
786
|
* 明确"被挂起且没有派生"时提前放弃(等满也没用),否则给足 ~8 秒。
|
|
767
787
|
*/
|
|
768
|
-
function waitLaunchdRunning(target, tries =
|
|
788
|
+
function waitLaunchdRunning(target, tries = 25, gapMs = 400) {
|
|
769
789
|
let st = launchdState(target);
|
|
770
790
|
for (let i = 0; i < tries; i += 1) {
|
|
771
791
|
st = launchdState(target);
|
|
772
792
|
if (st.running) return st;
|
|
773
|
-
|
|
793
|
+
/* 🔴 2026-10-11 修:刚 bootstrap/kickstart 后 launchd 会先进入**过渡态**
|
|
794
|
+
(`state = xpcproxy` / `spawn scheduled` / `pending`),此时 runs 还是 0。
|
|
795
|
+
旧判据"登记了却一次都没派生 → 判定被挂起"在 1.2 秒后就放弃,于是
|
|
796
|
+
`installAutostart()` 误报"launchd 无法托管本机 bridge",转而拉起一个**脱离进程** ——
|
|
797
|
+
用户看到的是"安装没装好/不会开机自启",而实际上 launchd 下一秒就把它拉起来了
|
|
798
|
+
(现场实测:state=xpcproxy,几秒后 state=running)。
|
|
799
|
+
现在:过渡态一律继续等;只有"确实派生过又退出"或"等满窗口都没起来"才算失败。 */
|
|
800
|
+
const transitional = /state\s*=\s*(xpcproxy|spawn scheduled|pending|running\s*\()/i.test(String(st.raw || ""));
|
|
801
|
+
if (transitional) { sleepSync(gapMs); continue; }
|
|
802
|
+
// 已派生过、现在没在跑、也没被挂起 → 是启动后立刻退出(真失败),不必等满
|
|
774
803
|
if (st.exists && st.runs > 0 && !st.deferred) break;
|
|
775
|
-
|
|
804
|
+
// 登记了却一次都没派生,且已经等了足够久(给足 4 秒让 launchd 调度)→ 判为被挂起
|
|
805
|
+
if (i >= 10 && st.exists && !st.deferred && st.runs === 0) break;
|
|
776
806
|
sleepSync(gapMs);
|
|
777
807
|
}
|
|
778
808
|
return st;
|
|
@@ -807,6 +837,28 @@ function spawnDetachedBridge() {
|
|
|
807
837
|
* 但实测 kickstart 只解决"这一次",进程被杀后不会重建(KeepAlive 已被系统绕过)→ 如实告知
|
|
808
838
|
* ③ 后台进程 ← 连 launchd 都托管不了时保证可用
|
|
809
839
|
*/
|
|
840
|
+
/**
|
|
841
|
+
* 🔴 标签占用保护(2026-10-10 事故):`com.dshremote.bridge` 这个 launchd label 是**全局唯一**的,
|
|
842
|
+
* 谁 bootstrap 谁就接管它。当天跑测试时,某个用例用**假 HOME + 真 launchctl** 把测试临时目录的
|
|
843
|
+
* plist 登记到了同一个 label 上 → **真桥被顶掉**、远程访问中断,而日志里看不出任何异常。
|
|
844
|
+
*
|
|
845
|
+
* 所以 bootstrap 之前先问一句:这个 label 现在登记的 plist 是**本 relayDir 的**那份吗?
|
|
846
|
+
* · 不是(别人家的)→ **拒绝接管**并明确报错。要强行接管得显式设
|
|
847
|
+
* `DSH_BRIDGE_ALLOW_LABEL_TAKEOVER=1`(给人留后门,但默认安全)。
|
|
848
|
+
* · 查不出来(launchctl 不可用/未登记)→ 照旧继续,不误伤正常安装。
|
|
849
|
+
*/
|
|
850
|
+
function labelOwnedByOtherDir(plistPath, domain) {
|
|
851
|
+
try {
|
|
852
|
+
const r = sh(`launchctl print ${domain}/${BRIDGE_LABEL}`);
|
|
853
|
+
if (!r.ok) return null; // 未登记 → 不是"别人家的"
|
|
854
|
+
const cur = String(r.stdout || "").match(/^\s*path = (.+)$/m);
|
|
855
|
+
if (!cur) return null; // 读不出路径 → 不拦
|
|
856
|
+
const a = cur[1].trim();
|
|
857
|
+
const b = String(plistPath).trim();
|
|
858
|
+
return a === b ? false : a; // 返回"占用者的 plist 路径"= 别人家的
|
|
859
|
+
} catch { return null; }
|
|
860
|
+
}
|
|
861
|
+
|
|
810
862
|
function startBridgeDarwin(plistPath) {
|
|
811
863
|
const uid = process.getuid();
|
|
812
864
|
const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
@@ -817,8 +869,25 @@ function startBridgeDarwin(plistPath) {
|
|
|
817
869
|
for (const d of [`user/${uid}`, `gui/${uid}`]) {
|
|
818
870
|
if (d !== domain) sh(`launchctl bootout ${d}/${BRIDGE_LABEL}`);
|
|
819
871
|
}
|
|
872
|
+
// 先确认这个 label 不是别人(别的 relayDir)的,否则**拒绝接管**——见 labelOwnedByOtherDir
|
|
873
|
+
const foreign = labelOwnedByOtherDir(plistPath, domain);
|
|
874
|
+
if (foreign && String(process.env.DSH_BRIDGE_ALLOW_LABEL_TAKEOVER || "") !== "1") {
|
|
875
|
+
return {
|
|
876
|
+
ok: false,
|
|
877
|
+
status: "label-owned-by-other",
|
|
878
|
+
detail: `${domain}/${BRIDGE_LABEL} 已被另一个 relayDir 占用(${foreign})—— 拒绝接管。`
|
|
879
|
+
+ `真要用本目录接管,请先停掉那边的桥,或显式设 DSH_BRIDGE_ALLOW_LABEL_TAKEOVER=1。`,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
820
882
|
sh(`launchctl bootout ${target}`);
|
|
883
|
+
waitLaunchdGone(target); // 关键:不等干净就 bootstrap → 随机 "5: Input/output error"
|
|
821
884
|
let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
885
|
+
if (!boot.ok) {
|
|
886
|
+
// 摘除竞态可能还没结束:再等一拍重试一次(仍失败才走老写法兜底)
|
|
887
|
+
sleepSync(600);
|
|
888
|
+
waitLaunchdGone(target, 6, 200);
|
|
889
|
+
boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
890
|
+
}
|
|
822
891
|
if (!boot.ok) {
|
|
823
892
|
// 老写法兜底(部分系统上 load -w 仍可用)
|
|
824
893
|
sh(`launchctl unload ${q(plistPath)}`);
|
|
@@ -927,6 +996,33 @@ function restartBridgeService() {
|
|
|
927
996
|
* 统一在 clients/dsh-remote/upstream-discovery.mjs,watcher 与 bridge 共用同一份实现。
|
|
928
997
|
*/
|
|
929
998
|
const UPSTREAM_FILE = ".dsh-upstream";
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* 本次安装要**钉住**的上游(null = 不钉,运行时按 env → .dsh-upstream → 探测 取值)。
|
|
1002
|
+
* 见 desktopAppPort() 的注释:插件装进多个 profile 后,各宿主会互相覆盖 `.dsh-upstream`。
|
|
1003
|
+
*/
|
|
1004
|
+
let PINNED_UPSTREAM = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "") || null;
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* 探测桌面端(Electron 应用)当前监听的**回环端口**。
|
|
1008
|
+
*
|
|
1009
|
+
* 为什么需要(2026-10-11 实测事故):插件同时装进 web 与 desktop 后,**每个宿主**都会在装载时
|
|
1010
|
+
* 把自己 `ctx.webServer.port` 写进 `<relayDir>/.dsh-upstream` —— 谁最后写谁生效。于是桥的上游
|
|
1011
|
+
* 会在两个应用之间来回跳:那次桥跑去连了 Web 版的 3080,而业主面对的是桌面端 19387,
|
|
1012
|
+
* 表现就是"命令跑完了但没装上 / 手机连上看到的不是我在用的界面"。
|
|
1013
|
+
* 装桌面端时把端口**钉进自启动配置**(环境变量优先级最高),两个宿主就抢不动了。
|
|
1014
|
+
*/
|
|
1015
|
+
function desktopAppPort() {
|
|
1016
|
+
if (process.platform !== "darwin") return null;
|
|
1017
|
+
try {
|
|
1018
|
+
const r = sh("lsof -nP -iTCP -sTCP:LISTEN -a -c DeepSeek 2>/dev/null");
|
|
1019
|
+
for (const line of String(r.stdout || "").split("\n")) {
|
|
1020
|
+
const m = line.match(/(?:127\.0\.0\.1|\[::1\]):(\d+)\s/);
|
|
1021
|
+
if (m) return Number(m[1]);
|
|
1022
|
+
}
|
|
1023
|
+
} catch { /* 拿不到就算了,退回运行时探测 */ }
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
930
1026
|
/** 运行时动态发现到的上游(优于可能过期的端口文件;进程内缓存)。 */
|
|
931
1027
|
let discoveredUpstream = "";
|
|
932
1028
|
function upstreamUrl() {
|
|
@@ -1043,6 +1139,50 @@ function resolveProfileDir(argv = []) {
|
|
|
1043
1139
|
* `pluginCmd()` 时原本传的是空数组 —— 于是"指定 desktop profile"会被静默忽略、
|
|
1044
1140
|
* 插件仍然装进 Web 版 profile(桌面端用户装了却看不到面板,且极难自查)。
|
|
1045
1141
|
*/
|
|
1142
|
+
/**
|
|
1143
|
+
* 探测本机**已存在**的 profile(可装载 = 同时有 package.json 与 cordis.patch.yml)。
|
|
1144
|
+
* desktop 排在前面:业主主用的是桌面应用,多个 profile 一起装时它是"首选/权威"那个。
|
|
1145
|
+
*/
|
|
1146
|
+
function detectProfileDirs() {
|
|
1147
|
+
const root = path.join(os.homedir(), ".dsh", "profiles");
|
|
1148
|
+
let names = [];
|
|
1149
|
+
try {
|
|
1150
|
+
names = fs.readdirSync(root, { withFileTypes: true })
|
|
1151
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
1152
|
+
.map((e) => e.name)
|
|
1153
|
+
.filter((n) => fs.existsSync(path.join(root, n, "package.json")) && fs.existsSync(path.join(root, n, "cordis.patch.yml")));
|
|
1154
|
+
} catch { return []; }
|
|
1155
|
+
names.sort((a, b) => (a === "desktop" ? -1 : b === "desktop" ? 1 : a.localeCompare(b)));
|
|
1156
|
+
return names.map((n) => ({ name: n, dir: profileDirOfName(n) }));
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* 本次要操作的 profile 列表。
|
|
1161
|
+
*
|
|
1162
|
+
* 业主口径(2026-10-11):「默认安装的时候也能同步安装给 desktop」。
|
|
1163
|
+
* 以前不带 `--profile` 时**写死装 web** —— 桌面端用户执行官方那条 `npx @mrrisega/dsh-remote@beta`
|
|
1164
|
+
* 之后,插件全进了 Web 版 profile,而用户面对的是桌面应用 → 面板永远不出现,且极难自查。
|
|
1165
|
+
*
|
|
1166
|
+
* · `--profile X` / `DSH_PROFILE_DIR` → 只动那一个(脚本化与既有用法不变)
|
|
1167
|
+
* · 都不给 → **本机所有已存在的 profile**(desktop 优先)
|
|
1168
|
+
* · 一个都没探测到 → 退回 web(保留"首次安装"的引导文案)
|
|
1169
|
+
*/
|
|
1170
|
+
function targetProfileDirs(argv = []) {
|
|
1171
|
+
const i = argv.indexOf("--profile");
|
|
1172
|
+
if (i > -1 && argv[i + 1]) return [{ name: argv[i + 1], dir: resolveProfileDir(argv) }];
|
|
1173
|
+
if (process.env.DSH_PROFILE_DIR) return [{ name: "env", dir: process.env.DSH_PROFILE_DIR }];
|
|
1174
|
+
const all = detectProfileDirs();
|
|
1175
|
+
if (!all.length) return [{ name: "web", dir: profileDirOfName("web") }];
|
|
1176
|
+
/* 默认装哪些?**不是"全部"** —— 机器上常有过实验/自动化用的 profile(headless、web02…),
|
|
1177
|
+
往那些里面塞插件是越界。规则:
|
|
1178
|
+
· 已知的面向用户的 profile(web / desktop):**有就装**(这是业主的核心诉求:默认也要给 desktop)
|
|
1179
|
+
· 其他 profile:**只有本来就已经装了插件**才继续管(升级不能悄悄漏掉它)
|
|
1180
|
+
· 上面并集为空(例如只有 headless 一种)→ 退回"全部已存在",避免什么都不装 */
|
|
1181
|
+
const chosen = all.filter((p) => KNOWN_PROFILE_NAMES.includes(p.name)
|
|
1182
|
+
|| fs.existsSync(path.join(p.dir, PLUGIN_LOCAL_DIR)));
|
|
1183
|
+
return chosen.length ? chosen : all;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1046
1186
|
function profileArgvOf(argv = []) {
|
|
1047
1187
|
const i = argv.indexOf("--profile");
|
|
1048
1188
|
return i > -1 && argv[i + 1] ? ["--profile", argv[i + 1]] : [];
|
|
@@ -1634,6 +1774,19 @@ async function setup(argv) {
|
|
|
1634
1774
|
|
|
1635
1775
|
let svc = { path: null, status: { ok: true, status: "skipped", detail: "--no-autostart" } };
|
|
1636
1776
|
if (!noAutostart) {
|
|
1777
|
+
// ⚠️ 顺序要紧:**先**探测并钉住上游,**再**写自启动文件 ——
|
|
1778
|
+
// 否则 plist 里不会带上 DSH_BRIDGE_UPSTREAM(实测踩到:打印说已钉住,plist 里却没有)。
|
|
1779
|
+
try {
|
|
1780
|
+
const pluginTargets = targetProfileDirs(argv);
|
|
1781
|
+
if (!PINNED_UPSTREAM && pluginTargets.some((t) => t.name === "desktop")) {
|
|
1782
|
+
const p = desktopAppPort();
|
|
1783
|
+
if (p) {
|
|
1784
|
+
PINNED_UPSTREAM = `http://127.0.0.1:${p}`;
|
|
1785
|
+
try { fs.writeFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), PINNED_UPSTREAM + "\n"); } catch { /* 忽略 */ }
|
|
1786
|
+
console.log(`ℹ 桌面端在 127.0.0.1:${p} 监听 → 桥的上游已钉到它(避免与 Web 版互抢)`);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
} catch { /* 探测失败不阻断安装 */ }
|
|
1637
1790
|
svc = installAutostart();
|
|
1638
1791
|
} else {
|
|
1639
1792
|
console.log("ℹ --no-autostart:跳过自启动服务安装(可用 `dsh-remote run` 手动运行 bridge)。");
|
|
@@ -1646,7 +1799,9 @@ async function setup(argv) {
|
|
|
1646
1799
|
try {
|
|
1647
1800
|
// ⚠️ 必须把 `--profile` **透传下去**:否则 `--profile desktop`(桌面端)会被静默忽略,
|
|
1648
1801
|
// 插件装进 Web 版 profile,而用户面对的是桌面端 —— 面板永远不出现。
|
|
1649
|
-
|
|
1802
|
+
// 未指定 --profile 时目标是本机全部 profile:桌面端提醒与后续判定都要按同一份列表来
|
|
1803
|
+
const pluginTargets = targetProfileDirs(argv);
|
|
1804
|
+
for (const t of pluginTargets) warnIfDesktopProfile(t.dir);
|
|
1650
1805
|
pluginResult = await pluginCmd(profileArgvOf(argv));
|
|
1651
1806
|
} catch (e) {
|
|
1652
1807
|
console.warn(`⚠️ 插件安装未完成:${e.message}`);
|
|
@@ -1656,7 +1811,8 @@ async function setup(argv) {
|
|
|
1656
1811
|
// 插件是**进程启动时**装载的:刚装进 profile 的插件,运行中的 dsh web 里并没有
|
|
1657
1812
|
// (既没有 /dsh-remote/* 路由,也没有「设置 → 远程访问」面板项)。
|
|
1658
1813
|
// 这里做一个确定性判断:进程启动时间早于插件落盘时间 → 必须重启 dsh web 才生效。
|
|
1659
|
-
const
|
|
1814
|
+
const primaryProfileDir = targetProfileDirs(argv)[0].dir; // desktop 优先 = 用户面对的那个宿主
|
|
1815
|
+
const pluginDir = path.join(primaryProfileDir, PLUGIN_LOCAL_DIR);
|
|
1660
1816
|
const pluginMtime = fs.existsSync(path.join(pluginDir, "lib", "index.js"))
|
|
1661
1817
|
? fs.statSync(path.join(pluginDir, "lib", "index.js")).mtimeMs
|
|
1662
1818
|
: 0;
|
|
@@ -2197,10 +2353,21 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
2197
2353
|
// 注意:插件源(npx 缓存 / npm 全局)本身就位于某个 node_modules 之下,
|
|
2198
2354
|
// 过滤必须基于“相对插件目录”的路径片段,绝不能整条 src 路径包含判断,
|
|
2199
2355
|
// 否则会把插件文件全过滤掉、拷出空目录(此前反复“插件入口缺失”的总根因)。
|
|
2356
|
+
//
|
|
2357
|
+
// 🔴 2026-10-10 修:**插件自带的 runtime/node_modules 不能被剔掉**。
|
|
2358
|
+
// 旧规则是"任何一段叫 node_modules 就过滤",这条原本只想剔掉插件自己的依赖目录
|
|
2359
|
+
// (那些由 profile 的包管理器管),却把 `runtime/node_modules/ws` 一起剔了 ——
|
|
2360
|
+
// 而那份运行时正是"只有插件半"的机器**自愈补装时的来源**。剔掉它 = 自愈出来的运行环境
|
|
2361
|
+
// 缺 ws(bridge 顶层 `import WebSocket from "ws"`)→ 一启动就崩,还"自检通过"。
|
|
2362
|
+
// 现在:`runtime/` 之内一律保留;只过滤 runtime 之外的 node_modules。
|
|
2200
2363
|
filter: (src) => {
|
|
2201
2364
|
if (src === pluginDir) return true;
|
|
2202
|
-
const rel = path.relative(pluginDir, src);
|
|
2203
|
-
|
|
2365
|
+
const rel = String(path.relative(pluginDir, src));
|
|
2366
|
+
const segs = rel.split(path.sep);
|
|
2367
|
+
if (segs.some((seg) => seg === ".git" || seg === ".DS_Store")) return false;
|
|
2368
|
+
const inBundledRuntime = segs[0] === "runtime";
|
|
2369
|
+
if (!inBundledRuntime && segs.some((seg) => seg === "node_modules")) return false;
|
|
2370
|
+
return true;
|
|
2204
2371
|
}
|
|
2205
2372
|
};
|
|
2206
2373
|
|
|
@@ -2613,13 +2780,32 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
|
|
|
2613
2780
|
|
|
2614
2781
|
async function pluginCmd(argv) {
|
|
2615
2782
|
const uninstall = hasFlag(argv, "--uninstall");
|
|
2616
|
-
const
|
|
2783
|
+
const targets = targetProfileDirs(argv);
|
|
2784
|
+
const multi = targets.length > 1;
|
|
2785
|
+
if (multi) {
|
|
2786
|
+
console.log(`ℹ 未指定 --profile:同步到本机**全部 ${targets.length} 个 profile**(${targets.map((t) => t.name).join(" → ")})`);
|
|
2787
|
+
}
|
|
2788
|
+
let last = null;
|
|
2789
|
+
let ok = 0;
|
|
2790
|
+
for (const t of targets) {
|
|
2791
|
+
if (multi) console.log(`\n── profile: ${t.name}(${t.dir})`);
|
|
2792
|
+
const r = await pluginApplyTo(t.dir, uninstall, multi);
|
|
2793
|
+
if (r === "skip") continue;
|
|
2794
|
+
ok += 1;
|
|
2795
|
+
if (r !== undefined && r !== null) last = r;
|
|
2796
|
+
}
|
|
2797
|
+
if (ok === 0) process.exit(1);
|
|
2798
|
+
return last;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
/** 对**单个** profile 执行插件安装/卸载(原 pluginCmd 主体,逐字保留行为)。 */
|
|
2802
|
+
async function pluginApplyTo(profileDir, uninstall, multi) {
|
|
2617
2803
|
const pkgFile = path.join(profileDir, "package.json");
|
|
2618
2804
|
const patchFile = path.join(profileDir, "cordis.patch.yml");
|
|
2619
2805
|
const pluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
|
|
2620
2806
|
|
|
2621
2807
|
if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
|
|
2622
|
-
console.error(
|
|
2808
|
+
console.error(`${multi ? "⚠️ 跳过(未找到 profile)" : "❌ 未找到 profile"}(${profileDir})。`);
|
|
2623
2809
|
if (isDesktopProfileDir(profileDir)) {
|
|
2624
2810
|
// 桌面端 profile 由 Electron 应用**自己初始化**:npm 版 `dsh` 明确拒绝管理它
|
|
2625
2811
|
// (apps/cli 的 rejectElectronProfile),所以 Web 版那句 "npx @deepseek-ai/dsh web"
|
|
@@ -2632,6 +2818,7 @@ async function pluginCmd(argv) {
|
|
|
2632
2818
|
} else {
|
|
2633
2819
|
console.error(" 请先安装 DeepSeek Harness(npx @deepseek-ai/dsh web)并初始化默认 profile。");
|
|
2634
2820
|
}
|
|
2821
|
+
if (multi) return "skip";
|
|
2635
2822
|
process.exit(1);
|
|
2636
2823
|
}
|
|
2637
2824
|
if (!fs.existsSync(pluginDir)) {
|