dsh-remote-web 0.7.0-beta.2 → 0.7.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +167 -63
- package/lib/index.js +162 -10
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +20 -0
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +115 -31
- package/runtime/clients/dsh-remote/wechat-runtime.mjs +75 -11
- package/runtime/dsh-setup.mjs +53 -4
package/lib/client.js
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
// ① 官方「设置」→「远程访问」栏目(settings.section,位于「Agent 预设」下方,官方扩展点);
|
|
10
10
|
// ② 左侧官方「设置」按钮旁注入独立「📱 远程访问」快捷按钮(id=dru-nav-remote,非 clone,
|
|
11
11
|
// 插在其前方、同布局不覆盖官方按钮;点击直达该栏目,首次带引导红点)
|
|
12
|
+
// ⚠️ 只认**语义锚点**(`button[aria-haspopup="dialog"]`,且必须在设置弹层之外):
|
|
13
|
+
// 0.2 桌面端的设置面板本身就是 role="dialog" 的弹层,它的栏目也叫「通用设置」,
|
|
14
|
+
// 旧代码用「文本包含『设置』」去找锚点 → 按钮被塞进设置面板栏目列表最上面(业主反馈的
|
|
15
|
+
// 「植入错了地方」)。现在找不到锚点就**不注入**,绝不将就一个错误的落点。
|
|
12
16
|
// - 栏目内联渲染配置面板(浅色高对比 UI,遵循主流登录体验)
|
|
13
17
|
// · 登录态:已登录显示账号 + 退出登录;未登录显示 登录/注册 tabs
|
|
14
18
|
// · 🔒 修改密码(账号卡):已登录账号经 图形验证码+短信验证码 重置(/dsh-remote/password/reset 代理);
|
|
@@ -465,7 +469,7 @@ window.__ModuleLoader__.load({
|
|
|
465
469
|
".dru-e2ee-line.ok{color:var(--dru-success)}",
|
|
466
470
|
// ── 📣 公告(0.6.18)──────────────────────────────────────────────────────
|
|
467
471
|
// 侧栏入口上的**未读数字徽标**:与首次引导红点(.dru-nav-remote-dot)同一落点。
|
|
468
|
-
// 两者同时出现会叠在一起,所以 JS 侧是"徽标优先"(有未读就不挂红点,见
|
|
472
|
+
// 两者同时出现会叠在一起,所以 JS 侧是"徽标优先"(有未读就不挂红点,见 RemoteNavEntry)。
|
|
469
473
|
// 数字用白字蓝底(不是红底):红点=「你还没点过我」,未读数=「有多少条内容」,
|
|
470
474
|
// 语义不同就不该长一样;红色在这个面板里已经被"危险/未读公告本身"占用了。
|
|
471
475
|
".dru-nav-remote-badge{position:absolute;top:-7px;right:-7px;min-width:16px;height:16px;box-sizing:border-box;padding:0 4px;border-radius:999px;background:var(--dru-accent-fill);color:var(--dru-on-accent);font:700 10.5px/16px inherit;text-align:center;box-shadow:0 0 0 2px var(--dru-surface-2);pointer-events:none}",
|
|
@@ -511,6 +515,13 @@ window.__ModuleLoader__.load({
|
|
|
511
515
|
// 独立 button(非 clone),插在官方「设置」按钮之前,同源同布局不覆盖官方热区。
|
|
512
516
|
".dru-nav-remote{display:inline-flex;align-items:center;gap:6px;height:30px;margin:0 0 2px;padding:0 10px;border:1px solid var(--dru-border-ctl);border-radius:8px;background:var(--dru-surface-2);color:var(--dru-accent);font:500 12.5px/1 inherit;cursor:pointer;white-space:nowrap;user-select:none}",
|
|
513
517
|
".dru-nav-remote:hover:not(:disabled){background:var(--dru-accent-tint-2);border-color:var(--dru-accent)}",
|
|
518
|
+
// 📱 侧栏底部入口(官方槽位 sidebar.footer.action 内,2026-10-10 三修):
|
|
519
|
+
// 原生底部项是「整行、无边框、hover 浅底、14px 字」,之前那个自绘胶囊按钮与邻居格格不入。
|
|
520
|
+
".dru-foot-entry{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;min-height:32px;padding:6px 10px;border:none;border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:14px;text-align:left;cursor:pointer;position:relative;white-space:nowrap;user-select:none}",
|
|
521
|
+
".dru-foot-entry:hover{background:var(--dru-hover)}",
|
|
522
|
+
".dru-foot-entry:focus-visible{outline:2px solid var(--dru-focus);outline-offset:1px}",
|
|
523
|
+
".dru-foot-entry .ico{flex:none;width:18px;text-align:center;font-size:15px;line-height:1}",
|
|
524
|
+
".dru-foot-entry .lbl{overflow:hidden;text-overflow:ellipsis}",
|
|
514
525
|
".dru-nav-remote-dot{position:absolute;top:-2px;right:-2px;width:8px;height:8px;border-radius:50%;background:var(--dru-reddot);box-shadow:0 0 0 2px var(--dru-surface-2);pointer-events:none}",
|
|
515
526
|
// 侧栏入口与官方按钮同高(30px);用 ::after 把命中区补到 ≥44px,不破坏与原按钮的对齐
|
|
516
527
|
".dru-nav-remote::after{content:\"\";position:absolute;left:0;right:0;top:-7px;bottom:-7px}",
|
|
@@ -792,90 +803,167 @@ window.__ModuleLoader__.load({
|
|
|
792
803
|
return !!el.closest('[role="navigation"], nav, [class*="sidebar" i], [class*="sidenav" i], [class*="appnav" i], [class*="navRail" i]');
|
|
793
804
|
} catch (e) { return false; }
|
|
794
805
|
}
|
|
795
|
-
|
|
806
|
+
/**
|
|
807
|
+
* 是否位于「设置」弹层内部。
|
|
808
|
+
*
|
|
809
|
+
* 🔴 这一条是 2026-10-09 桌面端事故的根治点。官方设置面板本身就是一个
|
|
810
|
+
* `role="dialog" aria-modal="true"` 的弹层(0.2 实测,见 ui-settings-general 的 SettingsRoot),
|
|
811
|
+
* 它的左侧栏目同样长得像「导航」(`nav` + `navCell`),于是会被 navCellLike 命中。
|
|
812
|
+
* 旧代码用「文本包含『设置』」去找侧栏那颗齿轮按钮 —— 而弹层里的第一项叫
|
|
813
|
+
* **「通用设置」**,`indexOf("设置") !== -1` 直接命中,注入按钮就被塞进了
|
|
814
|
+
* 设置面板的栏目列表最上面(用户反馈的「植入错了地方」)。
|
|
815
|
+
*/
|
|
816
|
+
function navInDialog(el) {
|
|
817
|
+
try { return !!el.closest('[role="dialog"], [aria-modal="true"]'); } catch (e) { return false; }
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* 官方「设置」触发器(侧栏那颗齿轮按钮)—— **只认语义锚点**。
|
|
821
|
+
*
|
|
822
|
+
* 两个版本(Web 0.1.x / 桌面端 0.2)的触发器都是同一个形状(各自实测源码一致):
|
|
823
|
+
* `<button aria-label="设置" aria-haspopup="dialog" aria-expanded=…>`,外层 `…triggerRow`。
|
|
824
|
+
* `aria-haspopup="dialog"` 是**语言无关**的语义标记,比匹配文案稳(换语言/换文案都不失效)。
|
|
825
|
+
* 找不到就**不注入** —— 宁可不显示快捷入口,也绝不把按钮塞到别人家的面板里。
|
|
826
|
+
*/
|
|
827
|
+
/**
|
|
828
|
+
* 官方「设置」触发器的**候选列表**(设置-like 的排前面)。
|
|
829
|
+
*
|
|
830
|
+
* 🔴 2026-10-10 修(业主实测"点入口没反应"):这里以前还要求 `navCellLike(el)`
|
|
831
|
+
* —— 而桌面端侧栏那套 class 里**没有** sidebar/nav 字样(都是 `_3WPZCG_*`),
|
|
832
|
+
* 于是候选全被挡掉 → 触发器 null → 点击静默返回,表现就是"点了没反应"。
|
|
833
|
+
* 现在只认语义特征:`aria-haspopup="dialog"` + 不在弹层内 + 不是菜单类;
|
|
834
|
+
* 其中 **带 aria-keyshortcuts / 文案或 aria-label 含「设置」** 的优先(语言无关 + 精确)。
|
|
835
|
+
*/
|
|
836
|
+
function settingsTriggerCandidates() {
|
|
837
|
+
var out = [];
|
|
838
|
+
try {
|
|
839
|
+
var cands = document.querySelectorAll('button[aria-haspopup="dialog"], [aria-haspopup="dialog"]');
|
|
840
|
+
var rest = [];
|
|
841
|
+
for (var i = 0; i < cands.length; i++) {
|
|
842
|
+
var el = cands[i];
|
|
843
|
+
if (!el || el.id === NAV_ENTRY_ID) continue; // 排除我们自己的入口
|
|
844
|
+
if (navInDialog(el)) continue; // 弹层内的(关闭键/栏目)一律不要
|
|
845
|
+
if (NAV_BAD_RE.test(String(el.className || ""))) continue;
|
|
846
|
+
var label = String(navAttrOf(el) || navCleanText(el) || "");
|
|
847
|
+
var hasShortcut = !!(el.getAttribute && el.getAttribute("aria-keyshortcuts"));
|
|
848
|
+
if (hasShortcut || /设置|Settings/i.test(label)) out.push(el);
|
|
849
|
+
else rest.push(el);
|
|
850
|
+
}
|
|
851
|
+
return out.concat(rest); // 兜底也不能空着:实在认不出就逐个试(见 openRemoteSettings)
|
|
852
|
+
} catch (e) { return out; }
|
|
853
|
+
}
|
|
854
|
+
/** 官方「设置」触发器(首选候选,保持旧名字给测试与调用方用)。 */
|
|
855
|
+
function pickSettingsTrigger() {
|
|
856
|
+
var list = settingsTriggerCandidates();
|
|
857
|
+
return list.length ? list[0] : null;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* 按文案/aria-label 找导航项。
|
|
861
|
+
* @param {string} token 目标文案(如「远程访问」「设置」)
|
|
862
|
+
* @param {{exactOnly?:boolean, insideDialog?:boolean}} [opts]
|
|
863
|
+
* exactOnly=true 只接受完全相等;insideDialog 限定是否必须在设置弹层内。
|
|
864
|
+
* 无论哪种模式:**精确相等优先于子串**(老代码只要子串命中就返回,误伤面很大)。
|
|
865
|
+
*/
|
|
866
|
+
function pickNavByToken(token, opts) {
|
|
867
|
+
var exactOnly = !!(opts && opts.exactOnly);
|
|
868
|
+
var insideDialog = opts ? opts.insideDialog : undefined;
|
|
796
869
|
try {
|
|
797
870
|
var els = document.querySelectorAll("button, [role='tab'], [role='menuitem'], [role='button'], a, [aria-label]");
|
|
871
|
+
var loose = null;
|
|
798
872
|
for (var i = 0; i < els.length; i++) {
|
|
799
873
|
var el = els[i];
|
|
800
874
|
if (!el || el.id === NAV_ENTRY_ID) continue; // 排除注入按钮自身(防递归)
|
|
801
875
|
if (!navCellLike(el)) continue;
|
|
802
876
|
if (NAV_BAD_RE.test(String(el.className || ""))) continue;
|
|
877
|
+
var inDlg = navInDialog(el);
|
|
878
|
+
if (insideDialog === true && !inDlg) continue;
|
|
879
|
+
if (insideDialog === false && inDlg) continue;
|
|
803
880
|
var t = navCleanText(el);
|
|
804
|
-
if (t && !/\r|\n/.test(t) && t.length <= NAV_TEXT_MAX &&
|
|
805
|
-
(t === token || t.indexOf(token) === 0 || t.indexOf(token) !== -1)) return el;
|
|
806
881
|
var at = navAttrOf(el);
|
|
807
|
-
|
|
882
|
+
var textOK = !!(t && !/\r|\n/.test(t) && t.length <= NAV_TEXT_MAX);
|
|
883
|
+
if ((textOK && t === token) || (at && at === token)) return el; // 精确命中:立即返回
|
|
884
|
+
if (!loose && ((textOK && t.indexOf(token) !== -1) || (at && at.indexOf(token) === 0))) loose = el;
|
|
808
885
|
}
|
|
886
|
+
return exactOnly ? null : loose;
|
|
809
887
|
} catch (e) { /* 忽略 */ }
|
|
810
888
|
return null;
|
|
811
889
|
}
|
|
812
|
-
/**
|
|
890
|
+
/**
|
|
891
|
+
* 打开「远程访问」设置栏目。
|
|
892
|
+
* 栏目已渲染 → 直接点;否则**依次试每个"设置"候选**:点一下 → 轮询 ≤3s 看栏目出没出来 →
|
|
893
|
+
* 没出来就换下一个候选。为什么要逐个试(2026-10-10):桌面端侧栏可能同时有多个
|
|
894
|
+
* `aria-haspopup="dialog"`(账号/更多…),认错了就会"点了没反应";逐个试则只要有**一个**
|
|
895
|
+
* 能打开设置面板,入口就一定能用。
|
|
896
|
+
*/
|
|
813
897
|
function openRemoteSettings() {
|
|
814
898
|
try {
|
|
815
|
-
var cell = pickNavByToken("远程访问");
|
|
899
|
+
var cell = pickNavByToken("远程访问", { insideDialog: true });
|
|
816
900
|
if (cell) { cell.click(); return; }
|
|
817
|
-
var
|
|
818
|
-
if (!
|
|
819
|
-
|
|
820
|
-
var
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
901
|
+
var cands = settingsTriggerCandidates();
|
|
902
|
+
if (!cands.length) return;
|
|
903
|
+
var idx = 0;
|
|
904
|
+
var tryNext = function () {
|
|
905
|
+
if (idx >= cands.length) return;
|
|
906
|
+
var el = cands[idx++];
|
|
907
|
+
try { el.click(); } catch (eClick) { return tryNext(); }
|
|
908
|
+
var tries = 0;
|
|
909
|
+
var iv = setInterval(function () {
|
|
910
|
+
tries++;
|
|
911
|
+
var c2 = pickNavByToken("远程访问", { insideDialog: true });
|
|
912
|
+
if (c2) { clearInterval(iv); c2.click(); return; }
|
|
913
|
+
if (tries >= NAV_POLL_MAX) { clearInterval(iv); tryNext(); } // 这个候选没打开设置 → 换下一个
|
|
914
|
+
}, 150);
|
|
915
|
+
try { if (iv && typeof iv.unref === "function") iv.unref(); } catch (eIv) { /* 忽略 */ }
|
|
916
|
+
};
|
|
917
|
+
tryNext();
|
|
827
918
|
} catch (e) { /* 忽略 */ }
|
|
828
919
|
}
|
|
829
|
-
/**
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
btn.appendChild(d);
|
|
920
|
+
/**
|
|
921
|
+
* 打开「远程访问」栏目的入口(供官方槽位里的按钮调用)。
|
|
922
|
+
* 由下面的导航模块赋值 —— 让"入口在哪渲染"与"点了怎么打开"彻底解耦。
|
|
923
|
+
*/
|
|
924
|
+
var remoteNavOpen = null;
|
|
925
|
+
|
|
926
|
+
/* 📱 侧栏底部的「远程访问」入口(2026-10-10 三修:改用**官方槽位**)。====================
|
|
927
|
+
前两次都栽在"自己找 DOM 落点"上:
|
|
928
|
+
· 一修前:按文案包含「设置」找锚点 → 命中的是设置面板里的「通用设置」→ 按钮跑到设置面板顶部;
|
|
929
|
+
· 二修:改成只认 aria-haspopup="dialog" 且必须在弹层外 → 桌面端侧栏那颗齿轮不满足
|
|
930
|
+
navCellLike,于是**干脆不注入**(业主看到的就是"那个位置是空的")。
|
|
931
|
+
而宿主本来就为这个位置提供了官方列表槽 `sidebar.footer.action`(0.1.x 与 0.2 都有;
|
|
932
|
+
0.2 实测该槽就在侧栏底部、且**当前是空的**)—— 位置、宽度、折叠态全由宿主负责。
|
|
933
|
+
所以这里不再注入任何 DOM,直接注册进槽位。id 仍保留 `dru-nav-remote`:
|
|
934
|
+
公告徽标 / 首次红点那套按 id 取节点的既有逻辑因此不用改。 */
|
|
935
|
+
function RemoteNavEntry(props) {
|
|
936
|
+
var wide = !(props && props.wide === false);
|
|
937
|
+
var seenArr = useState(false);
|
|
938
|
+
var clicked = seenArr[0];
|
|
939
|
+
var setClicked = seenArr[1];
|
|
940
|
+
var showDot = !clicked && !dotSeen();
|
|
941
|
+
return h("button", {
|
|
942
|
+
type: "button",
|
|
943
|
+
id: NAV_ENTRY_ID,
|
|
944
|
+
className: "dru-foot-entry",
|
|
945
|
+
title: "打开「远程访问」设置",
|
|
946
|
+
"data-dru-remote": "1",
|
|
947
|
+
onClick: function (e) {
|
|
948
|
+
try { e.preventDefault(); } catch (err) { /* 忽略 */ }
|
|
949
|
+
setClicked(true);
|
|
950
|
+
dotMarkSeen(); // 用过入口 = 已了解位置 → 收起首次红点
|
|
951
|
+
if (remoteNavOpen) remoteNavOpen();
|
|
862
952
|
}
|
|
863
|
-
|
|
864
|
-
|
|
953
|
+
}, [
|
|
954
|
+
h("span", { className: "ico", key: "ico" }, "📱"),
|
|
955
|
+
wide ? h("span", { className: "lbl", key: "lbl" }, "远程访问") : null,
|
|
956
|
+
showDot ? h("span", { className: "dru-nav-remote-dot", key: "dot" }) : null
|
|
957
|
+
]);
|
|
865
958
|
}
|
|
866
|
-
|
|
959
|
+
|
|
867
960
|
function navEnsureStart() {
|
|
868
961
|
if (window.__dshRemoteNavStarted) return;
|
|
869
962
|
window.__dshRemoteNavStarted = true;
|
|
870
963
|
try { window.__dshRemoteNav = { open: openRemoteSettings }; } catch (e) {}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
var host = pickNavByToken("设置");
|
|
875
|
-
if (host) mountNavEntry(host);
|
|
876
|
-
} catch (e) { /* 忽略 */ }
|
|
877
|
-
}, 2000);
|
|
878
|
-
try { if (iv && typeof iv.unref === "function") iv.unref(); } catch (e) {} // node 测试环境不阻塞退出
|
|
964
|
+
// ⚠️ 这里**不再**做 DOM 注入(见 RemoteNavEntry 的注释):入口由官方槽位渲染。
|
|
965
|
+
// 保留本函数只为实现"继续暴露 window.__dshRemoteNav"(老调用方与诊断仍在用)。
|
|
966
|
+
try { if (typeof openRemoteSettings === "function") remoteNavOpen = openRemoteSettings; } catch (e) { /* 忽略 */ }
|
|
879
967
|
}
|
|
880
968
|
|
|
881
969
|
// ══ 📣 公告(0.6.18)模块级状态 ═══════════════════════════════════════════
|
|
@@ -5673,6 +5761,22 @@ window.__ModuleLoader__.load({
|
|
|
5673
5761
|
// 【0.6.11】本插件在这个槽里**只有一个栏目**(📱 远程访问):🤖 微信机器人原先以 order 31
|
|
5674
5762
|
// 注册成第二个栏目,业主口径「不要给它单独弄一个菜单,直接放到面板里面」→ 已改成面板里的
|
|
5675
5763
|
// 第三个 tab(renderMainTabs / renderWeChat)。不要再往这个槽里加第二个 register。
|
|
5764
|
+
/* 📱 侧栏底部的快捷入口 —— 官方列表槽 `sidebar.footer.action`(2026-10-10 三修)。
|
|
5765
|
+
这个槽由 `@deepseek-ai/dsh-client-ui-sidebar` 声明,渲染在侧栏底部的 footerActions 里
|
|
5766
|
+
(在「设置」那一行的上方、与「意见反馈/登录/更多」同属底部区域)。0.1.x 与 0.2 都有该槽,
|
|
5767
|
+
0.2 实测此槽当前为空 —— 正合业主"就要放那个位置"的要求,而且不用我们再猜任何 DOM。
|
|
5768
|
+
order 60:排在该槽内既有条目之后(宿主自己的条目不带 order,按注册序在前)。
|
|
5769
|
+
老宿主若没有这个槽,`inject` 会静默不生效(不会抛)——面板内的 settings.section 入口仍在。 */
|
|
5770
|
+
try {
|
|
5771
|
+
ctx.slots.inject("sidebar.footer.action", function () {
|
|
5772
|
+
return ctx.slots.register({
|
|
5773
|
+
name: "sidebar.footer.action",
|
|
5774
|
+
id: "dsh-remote-nav",
|
|
5775
|
+
order: 60,
|
|
5776
|
+
label: function () { return "远程访问"; }
|
|
5777
|
+
}, RemoteNavEntry);
|
|
5778
|
+
});
|
|
5779
|
+
} catch (eFootSlot) { /* 老宿主没有这个槽:忽略,面板入口仍然可用 */ }
|
|
5676
5780
|
ctx.slots.inject("settings.section", function () {
|
|
5677
5781
|
var d1 = ctx.slots.register({
|
|
5678
5782
|
name: "settings.section",
|
|
@@ -5708,7 +5812,7 @@ window.__ModuleLoader__.load({
|
|
|
5708
5812
|
try {
|
|
5709
5813
|
// 侧栏徽标的 DOM 订阅者(非 React):store 一变就重画徽标。
|
|
5710
5814
|
// 即便入口按钮此刻还没注入(官方「设置」按钮尚未出现),订阅本身也是零成本的 ——
|
|
5711
|
-
//
|
|
5815
|
+
// RemoteNavEntry 挂上按钮时会主动画一次。
|
|
5712
5816
|
annSubscribe(annRenderNavBadge);
|
|
5713
5817
|
annPullOnce();
|
|
5714
5818
|
} catch (e) { /* 静默降级:不显示公告入口/徽标,页面照常 */ }
|
|
@@ -5718,7 +5822,7 @@ window.__ModuleLoader__.load({
|
|
|
5718
5822
|
// 侧栏「远程访问」快捷入口(2026-09 恢复注入:与官方「设置」按钮共存不遮挡)
|
|
5719
5823
|
navEnsureStart();
|
|
5720
5824
|
// 🎨 主题:**不在这里**建 observer —— 观察器只在真有订阅者时才创建
|
|
5721
|
-
// (面板/弹窗挂载 → themeSubscribe;侧栏入口挂上 →
|
|
5825
|
+
// (面板/弹窗挂载 → themeSubscribe;侧栏入口挂上 → RemoteNavEntry)。
|
|
5722
5826
|
// 这样「没有任何东西需要跟随主题」时不会白留一个 MutationObserver,
|
|
5723
5827
|
// 也避免与本插件已有的红点观察器互相干扰。
|
|
5724
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 } 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()` **同一路径**)。
|
|
@@ -1075,6 +1118,7 @@ Environment=PATH=${SERVICE_PATH}
|
|
|
1075
1118
|
${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_UPSTREAM=${upstreamUrl()}
|
|
1076
1119
|
Environment=DSH_BRIDGE_INSTALL_SOURCE=${installSourceOf(relayDir)}
|
|
1077
1120
|
Environment=DSH_BRIDGE_INSTALL_VERSION=${PLUGIN_VERSION}
|
|
1121
|
+
Environment=DSH_BRIDGE_HARNESS_VERSION=${harnessVersion()}
|
|
1078
1122
|
|
|
1079
1123
|
[Install]
|
|
1080
1124
|
WantedBy=default.target
|
|
@@ -1557,7 +1601,8 @@ function ensureRuntime(relayDir) {
|
|
|
1557
1601
|
windowsHide: true,
|
|
1558
1602
|
// DSH_BRIDGE_INSTALL_SOURCE:本机运行环境是插件自愈补的 → 安装器原样透传到 plist/bridge env,
|
|
1559
1603
|
// 于是设备行上的 install_source 如实记成 plugin_market(而不是安装器默认的 npx)。
|
|
1560
|
-
|
|
1604
|
+
// DSH_BRIDGE_HARNESS_VERSION 同理:安装器会把它写进 plist;不给的话,桥重启后就丢了宿主版本。
|
|
1605
|
+
env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org", DSH_BRIDGE_INSTALL_SOURCE: "plugin_market", DSH_BRIDGE_HARNESS_VERSION: harnessVersion() }),
|
|
1561
1606
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
|
|
1562
1607
|
onError: (e) => failSpawn(e, npx.kind),
|
|
1563
1608
|
});
|
|
@@ -1653,7 +1698,7 @@ function writeAutostartFile(relayDir) {
|
|
|
1653
1698
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
1654
1699
|
<key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
1655
1700
|
<key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
1656
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string></dict>
|
|
1701
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${harnessVersion()}</string></dict>
|
|
1657
1702
|
</dict></plist>`;
|
|
1658
1703
|
mkdirSync(dirname(plistPath), { recursive: true });
|
|
1659
1704
|
writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
@@ -1689,6 +1734,8 @@ function reapBrokenAutostart(relayDir) {
|
|
|
1689
1734
|
// 只有 macOS 有 launchd 自启动项可摘;Windows/Linux 走的是 systemd/无自启动,判据不适用。
|
|
1690
1735
|
if (!isDarwin()) return false;
|
|
1691
1736
|
if (!plistEntryMissing()) return false;
|
|
1737
|
+
// 归属校验:这份 plist 不是给本 relayDir 装的 → 一个字节都不动(见 plistOwnedByRelayDir)
|
|
1738
|
+
if (!plistOwnedByRelayDir(relayDir)) return false;
|
|
1692
1739
|
const st = launchdStatus();
|
|
1693
1740
|
if (!st.running && !st.crashing && !st.state) return false; // 作业未被 launchd 加载、无可摘的东西
|
|
1694
1741
|
for (const target of launchTargets()) sh(`launchctl bootout ${target}`);
|
|
@@ -1763,6 +1810,8 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
|
|
|
1763
1810
|
env: spawnEnv({
|
|
1764
1811
|
DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
|
|
1765
1812
|
DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
|
|
1813
|
+
// 宿主版本:bridge 自己推不出来(它是另一个进程),只能由插件在宿主进程内问出来再注入。
|
|
1814
|
+
DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
|
|
1766
1815
|
// 上游端口**必须**透传:watcher 用它判断「dsh web 在线吗」,bridge 用它做转发目标。
|
|
1767
1816
|
// 不传的话两边都回落到写死的 3080 —— dsh web 不在默认端口时 bridge 永远不启动(0.6.12 修)。
|
|
1768
1817
|
DSH_BRIDGE_UPSTREAM: upstreamUrl(),
|
|
@@ -2016,9 +2065,36 @@ function uninstallRuntime(relayDir, protectedPath) {
|
|
|
2016
2065
|
// a) 停服务 + 移除自启动文件
|
|
2017
2066
|
try {
|
|
2018
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
|
+
}
|
|
2019
2085
|
// 只处理「plist 位于当前 HOME」的服务:本插件/dsh-setup.mjs 安装的服务一定在此
|
|
2020
2086
|
const plistPath = launchAgentPath();
|
|
2021
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
|
+
}
|
|
2022
2098
|
// crashing 也要 bootout:崩溃循环中的作业在 launchd 里仍是「已加载」,
|
|
2023
2099
|
// 只删 plist 文件它照样被 KeepAlive 反复重拉(见 launchdStatus 注释)。
|
|
2024
2100
|
const st = launchdStatus();
|
|
@@ -3112,6 +3188,12 @@ async function proxyFeedback(relayDir, req, res, pathname) {
|
|
|
3112
3188
|
const headers = {
|
|
3113
3189
|
"x-dsh-device": cfg.device_id || "",
|
|
3114
3190
|
"x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
|
|
3191
|
+
// 真实 UA 必须**转发**:以前不转发,上游 fetch 就用 undici 默认 UA(`node`),
|
|
3192
|
+
// 后台「机型」列对每条真实反馈都显示同一个没用值(生产实测 fb_* 全是 user_agent=node),
|
|
3193
|
+
// 于是「Windows 还是 Linux 出的问题」只能靠翻安装日志考古。同时补一个结构化平台字段
|
|
3194
|
+
// (x-dsh-platform),比让后台解析 UA 可靠得多。
|
|
3195
|
+
"user-agent": String(req.headers["user-agent"] || `dsh-remote-web/${PLUGIN_VERSION}`),
|
|
3196
|
+
"x-dsh-platform": process.platform,
|
|
3115
3197
|
};
|
|
3116
3198
|
if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
|
|
3117
3199
|
const auth = req.headers.authorization;
|
|
@@ -3234,7 +3316,7 @@ async function proxyAnnouncements(relayDir, req, res, pathname) {
|
|
|
3234
3316
|
res.end(text);
|
|
3235
3317
|
} catch (e) {
|
|
3236
3318
|
// 网络不可达 / 超时 / JSON 异常:一律 502 + 人话,绝不把异常抛回 webServer(那会变成 500 堆栈)
|
|
3237
|
-
return sendJson(res, 502, { ok: false, error: `公告服务不可达: ${e
|
|
3319
|
+
return sendJson(res, 502, { ok: false, error: `公告服务不可达: ${fetchFailReason(e)}` });
|
|
3238
3320
|
}
|
|
3239
3321
|
}
|
|
3240
3322
|
|
|
@@ -3527,7 +3609,14 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
|
|
|
3527
3609
|
windowsHide: true, // Windows:不弹控制台窗口(POSIX 上该选项被忽略)
|
|
3528
3610
|
// PATH 补 node 目录:App 最小 PATH 下也能跑 npx;同时保持本机既有的安装来源口径
|
|
3529
3611
|
// (在线更新会重跑安装器并重写 plist,若不带来源就会把 plugin_market 误记成 npx)。
|
|
3530
|
-
env: spawnEnv({
|
|
3612
|
+
env: spawnEnv({
|
|
3613
|
+
DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
|
|
3614
|
+
// 安装器会把这些环境变量原样写进 plist/systemd 单元,所以宿主版本也要一起给它 ——
|
|
3615
|
+
// 否则安装器重启桥之后,新写的自启动项里就没有 harness 版本了。
|
|
3616
|
+
DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
|
|
3617
|
+
DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
|
|
3618
|
+
...(extraEnv || {}),
|
|
3619
|
+
}),
|
|
3531
3620
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
|
|
3532
3621
|
onError,
|
|
3533
3622
|
});
|
|
@@ -3607,6 +3696,7 @@ async function runOnlineUpdate(relayDir) {
|
|
|
3607
3696
|
env: spawnEnv({
|
|
3608
3697
|
DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
|
|
3609
3698
|
DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
|
|
3699
|
+
DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
|
|
3610
3700
|
npm_config_registry: "https://registry.npmjs.org",
|
|
3611
3701
|
}),
|
|
3612
3702
|
stdio: ["ignore", out, out],
|
|
@@ -3869,13 +3959,22 @@ function writePatchDocument(patchFile, text) {
|
|
|
3869
3959
|
* 助手做的是「宁可少做也不卡住」的有界清理:杀残留进程 → 删自启动 → 清配置目录,每步独立 try/catch。
|
|
3870
3960
|
*/
|
|
3871
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
|
+
})();
|
|
3872
3971
|
const plan = JSON.stringify({
|
|
3873
3972
|
relayDir,
|
|
3874
3973
|
protectedPath: protectedPath || "",
|
|
3875
3974
|
platform: osPlatform(),
|
|
3876
3975
|
taskName: WIN_TASK_NAME,
|
|
3877
3976
|
pidFiles: [WATCHER_PID_FILE, BRIDGE_PID_FILE],
|
|
3878
|
-
plistPath:
|
|
3977
|
+
plistPath: ownsHomePlist ? homePlist : "",
|
|
3879
3978
|
unitPath: join(homedir(), ".config", "systemd", "user", "dsh-bridge.service"),
|
|
3880
3979
|
log: join(tmpdir(), "dsh-remote-uninstall.log"),
|
|
3881
3980
|
});
|
|
@@ -4613,6 +4712,8 @@ function buildConnectDiagnostics(relayDir, conn, log) {
|
|
|
4613
4712
|
const rows = [
|
|
4614
4713
|
"dsh-remote 连接诊断",
|
|
4615
4714
|
`插件版本: ${PLUGIN_VERSION}`,
|
|
4715
|
+
// 宿主版本:生产反馈里最缺的就是这个数 —— 只报插件版本时,「是不是 DSH 升级导致的兼容故障」答不出来
|
|
4716
|
+
`宿主版本: ${harnessVersion() || "未知"}`,
|
|
4616
4717
|
`配置目录: ${relayDir}`,
|
|
4617
4718
|
`连接阶段: ${conn.phase}(${conn.text})`,
|
|
4618
4719
|
`设备 ID: ${conn.deviceId || "(未生成)"}`,
|
|
@@ -4678,7 +4779,7 @@ async function buildDiagReport(relayDir, opts = {}) {
|
|
|
4678
4779
|
const conn = await composeConnect(relayDir, { manual });
|
|
4679
4780
|
const cfg = loadConfig(relayDir);
|
|
4680
4781
|
const head = redactForDiagnostics([
|
|
4681
|
-
`【插件】v${PLUGIN_VERSION} 运行时 ${runtimeVersionOf(relayDir) || "未知"} ${osPlatform()}/${process.arch} node ${String(process.versions.node).split(".")[0]}`,
|
|
4782
|
+
`【插件】v${PLUGIN_VERSION} 运行时 ${runtimeVersionOf(relayDir) || "未知"} 宿主 ${harnessVersion() || "未知"} ${osPlatform()}/${process.arch} node ${String(process.versions.node).split(".")[0]}`,
|
|
4682
4783
|
`【阶段】${conn.phase}(${conn.text})`,
|
|
4683
4784
|
`【设备】ID=${conn.deviceId || "(未生成)"} 运行环境=${conn.runtimeReady ? "就绪" : `缺失${conn.runtimeMissing && conn.runtimeMissing.length ? `(缺 ${conn.runtimeMissing.slice(0, 3).join("、")})` : ""}`}`,
|
|
4684
4785
|
`【bridge】${conn.bridgeRunning ? `在运行 pid=${conn.bridgePids.join(",")}` : "未运行"} 守护=${conn.watcherRunning ? `在运行 pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `(${Math.round(conn.watcherAgeMs / 1000)}s)` : ""}` : "未运行"}${conn.watcherWedged ? " ⚠️活着但拉不起 bridge" : ""}${conn.watcherRestarts ? ` 已自动重启 ${conn.watcherRestarts} 次` : ""}`,
|
|
@@ -4714,6 +4815,10 @@ async function submitFeedback(relayDir, payload) {
|
|
|
4714
4815
|
"content-type": "application/json",
|
|
4715
4816
|
"x-dsh-device": cfg.device_id || "",
|
|
4716
4817
|
"x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
|
|
4818
|
+
// 与 proxyFeedback 同一口径:结构化平台字段 + 真实 UA(此处没有浏览器请求可转发,
|
|
4819
|
+
// 就如实写本机平台,绝不冒充浏览器 UA)。
|
|
4820
|
+
"user-agent": `dsh-remote-web/${PLUGIN_VERSION} (${process.platform}; ${process.arch})`,
|
|
4821
|
+
"x-dsh-platform": process.platform,
|
|
4717
4822
|
};
|
|
4718
4823
|
if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
|
|
4719
4824
|
const t = await feedbackAuthToken(relayDir).catch(() => "");
|
|
@@ -5180,9 +5285,51 @@ function harnessVersionFromEntry(entry) {
|
|
|
5180
5285
|
}
|
|
5181
5286
|
}
|
|
5182
5287
|
|
|
5288
|
+
/**
|
|
5289
|
+
* 由**宿主根目录**解析版本:`<root>/node_modules/@deepseek-ai/dsh/package.json`。
|
|
5290
|
+
*
|
|
5291
|
+
* 【为什么还要这一条(2026-10-09 真机)】桌面端(DeepSeek Harness 0.2,Electron)的宿主进程 argv 是:
|
|
5292
|
+
* [0] …/MacOS/DeepSeek Harness
|
|
5293
|
+
* [1] --expose-internals ← **是开关,不是路径**
|
|
5294
|
+
* [2] …/app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/index.js
|
|
5295
|
+
* [3] …/app.asar/dsh ← **宿主根目录**
|
|
5296
|
+
* 于是「取 argv[1] 的上一级 package.json」在桌面端**必然拿不到**(argv[1] 是 `--expose-internals`),
|
|
5297
|
+
* 这就是桌面用户全部上报 harness 版本为空的原因。好在 argv[3] 就是宿主根,其
|
|
5298
|
+
* `node_modules/@deepseek-ai/dsh/package.json` 正是宿主版本(实测 0.2.0-rc.2)。
|
|
5299
|
+
* 桌面端是 Electron,`readFileSync` 能透明读 asar 内的路径,所以这条在真实宿主里可读。
|
|
5300
|
+
*/
|
|
5301
|
+
function harnessVersionFromRoot(root) {
|
|
5302
|
+
try {
|
|
5303
|
+
const manifest = JSON.parse(readFileSync(join(String(root), "node_modules", HARNESS_PACKAGE_NAME, "package.json"), "utf8"));
|
|
5304
|
+
if (!manifest || manifest.name !== HARNESS_PACKAGE_NAME) return "";
|
|
5305
|
+
return telemetryToken(manifest.version);
|
|
5306
|
+
} catch {
|
|
5307
|
+
return ""; // 不是宿主根 / 读不到 → 交给下一条候选,绝不抛
|
|
5308
|
+
}
|
|
5309
|
+
}
|
|
5310
|
+
|
|
5311
|
+
/**
|
|
5312
|
+
* 从整条 argv 里找出宿主版本(**顺序试探,先命中先返回**)。三条来源都不联网、不起子进程:
|
|
5313
|
+
* ① 目录形态:某参数本身就是宿主根(桌面端 argv[3]);
|
|
5314
|
+
* ② 文件形态:某参数是 DSH 主模块(Web 版 argv[1]),取它上一级的 package.json;
|
|
5315
|
+
* 开关(`-` 开头)与非绝对路径一律跳过 —— 绝不把 `--expose-internals` 当路径去 realpath。
|
|
5316
|
+
*/
|
|
5317
|
+
function harnessVersionFromArgv(argv) {
|
|
5318
|
+
const list = Array.isArray(argv) ? argv : [];
|
|
5319
|
+
for (const raw of list) {
|
|
5320
|
+
const p = String(raw ?? "");
|
|
5321
|
+
if (p === "" || p.startsWith("-") || !isAbsolute(p)) continue;
|
|
5322
|
+
const viaRoot = harnessVersionFromRoot(p);
|
|
5323
|
+
if (viaRoot) return viaRoot;
|
|
5324
|
+
const viaEntry = harnessVersionFromEntry(p);
|
|
5325
|
+
if (viaEntry) return viaEntry;
|
|
5326
|
+
}
|
|
5327
|
+
return "";
|
|
5328
|
+
}
|
|
5329
|
+
|
|
5183
5330
|
/** 本进程运行所在的宿主版本(未知 = "")。每次调用现读:事件量很小,且绝不该缓存出过期结论。 */
|
|
5184
5331
|
function harnessVersion() {
|
|
5185
|
-
return
|
|
5332
|
+
return harnessVersionFromArgv(process.argv);
|
|
5186
5333
|
}
|
|
5187
5334
|
|
|
5188
5335
|
/**
|
|
@@ -5556,6 +5703,8 @@ export const __telemetryInternals = {
|
|
|
5556
5703
|
// 必须能被用例逐条锁住(含未知/非法值的降级行为)。
|
|
5557
5704
|
harnessVersion: () => harnessVersion(),
|
|
5558
5705
|
harnessVersionFromEntry: (entry) => harnessVersionFromEntry(entry),
|
|
5706
|
+
harnessVersionFromRoot: (root) => harnessVersionFromRoot(root),
|
|
5707
|
+
harnessVersionFromArgv: (argv) => harnessVersionFromArgv(argv),
|
|
5559
5708
|
harnessToken: (v) => telemetryToken(v),
|
|
5560
5709
|
updateTag: UPDATE_TAG,
|
|
5561
5710
|
eventNames: [...TELEMETRY_EVENT_NAMES],
|
|
@@ -5582,7 +5731,7 @@ export const __telemetryInternals = {
|
|
|
5582
5731
|
* 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
|
|
5583
5732
|
* 之类的口径统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
|
|
5584
5733
|
* 契约:POST <api_url>/api/install-report,头 `authorization: Bearer <device-login JWT>`,
|
|
5585
|
-
* body { device_id?, install_source, install_version, host_os, host_arch },成功 200 {ok:true};
|
|
5734
|
+
* body { device_id?, install_source, install_version, harness_version, host_os, host_arch },成功 200 {ok:true};
|
|
5586
5735
|
* 404/400 一律忽略(老服务端没有该接口时不能报错)。
|
|
5587
5736
|
*/
|
|
5588
5737
|
const INSTALL_REPORT_FILE = ".dsh-install-report.json";
|
|
@@ -5617,6 +5766,9 @@ async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
|
|
|
5617
5766
|
...(cfg.device_id ? { device_id: String(cfg.device_id) } : {}),
|
|
5618
5767
|
install_source: source,
|
|
5619
5768
|
install_version: version,
|
|
5769
|
+
// 宿主(DeepSeek Harness)版本:与 install_version(插件版本)是两个数,
|
|
5770
|
+
// 只报插件版本时「是不是 DSH 升级导致的故障」答不出来(2026-10 生产反馈的普遍缺口)。
|
|
5771
|
+
harness_version: harnessVersion(),
|
|
5620
5772
|
host_os: process.platform,
|
|
5621
5773
|
host_arch: process.arch,
|
|
5622
5774
|
};
|
|
@@ -6572,7 +6724,7 @@ function registerRoutes(ctx, relayDir) {
|
|
|
6572
6724
|
res.writeHead(200, { "content-type": type || "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
6573
6725
|
res.end(buf);
|
|
6574
6726
|
} catch (e) {
|
|
6575
|
-
sendJson(res, 502, { ok: false, error: `验证码服务不可达: ${e
|
|
6727
|
+
sendJson(res, 502, { ok: false, error: `验证码服务不可达: ${fetchFailReason(e)}` });
|
|
6576
6728
|
}
|
|
6577
6729
|
},
|
|
6578
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.4",
|
|
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",
|
|
@@ -254,8 +254,25 @@ function resolveInstallVersion() {
|
|
|
254
254
|
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version || "";
|
|
255
255
|
} catch { return ""; }
|
|
256
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* 宿主(DeepSeek Harness)版本:**由插件注入**,bridge 自己算不出来。
|
|
259
|
+
*
|
|
260
|
+
* 为什么必须是注入:宿主版本要从"宿主进程的 argv / 安装目录"推出来,而 bridge 是插件
|
|
261
|
+
* **另外拉起的独立进程**(脱离宿主,见 dsh-setup.mjs 的守护),它的 argv 与宿主毫无关系。
|
|
262
|
+
* 插件跑在宿主进程内,能拿到真实版本(见 packages/dsh-remote-web/lib/index.js 的
|
|
263
|
+
* harnessVersion()),所以由它经 DSH_BRIDGE_HARNESS_VERSION 传下来。
|
|
264
|
+
*
|
|
265
|
+
* 与 INSTALL_VERSION 的区别(曾经把这两个数混为一谈,导致版本问题答不出来):
|
|
266
|
+
* install_version = **本插件**的版本(用户装的 dsh-remote-web 是哪个版本)
|
|
267
|
+
* harness_version = **宿主**的版本(用户跑的 DeepSeek Harness 是哪个版本)
|
|
268
|
+
* 只知其一都定位不了"是不是宿主升级导致的兼容性故障"。
|
|
269
|
+
*/
|
|
270
|
+
function resolveHarnessVersion() {
|
|
271
|
+
return String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
|
|
272
|
+
}
|
|
257
273
|
const INSTALL_SOURCE = resolveInstallSource();
|
|
258
274
|
const INSTALL_VERSION = resolveInstallVersion();
|
|
275
|
+
const HARNESS_VERSION = resolveHarnessVersion();
|
|
259
276
|
|
|
260
277
|
// ---------- 稳定设备身份(.dsh-config.json) ----------
|
|
261
278
|
|
|
@@ -1399,6 +1416,9 @@ async function registerDeviceInAccount(token) {
|
|
|
1399
1416
|
// install_source: npx(安装器注入) | plugin_market(插件拉起/自愈,默认) | unknown(白名单外)
|
|
1400
1417
|
install_source: INSTALL_SOURCE,
|
|
1401
1418
|
install_version: INSTALL_VERSION,
|
|
1419
|
+
// v15 宿主版本(由插件注入;老插件不注入 → 空串 = 未上报)。服务端 devices.harness_version。
|
|
1420
|
+
// 与 install_version 是两个不同的数:前者是"插件版本",这个是"DeepSeek Harness 版本"。
|
|
1421
|
+
harness_version: HARNESS_VERSION,
|
|
1402
1422
|
host_os: process.platform,
|
|
1403
1423
|
host_arch: process.arch
|
|
1404
1424
|
})
|
|
@@ -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,9 +1312,11 @@ const SCRIPT = `(() => {
|
|
|
1244
1312
|
旧的 120/300 分档是照"网页首屏"的体感定的,会把大量正常可用的移动网络误报成"较慢",
|
|
1245
1313
|
反而让用户以为哪里坏了。 */
|
|
1246
1314
|
const RTT_KEEP = 8, RTT_MAX_MS = 20000;
|
|
1247
|
-
|
|
1315
|
+
/* 建菜单时的引用只作**兜底**:正常路径每次渲染都现查实时 DOM(见 liveNetEl/liveRttEl)。 */
|
|
1248
1316
|
const rttEl = fab.querySelector('[data-role="rtt"]');
|
|
1249
1317
|
const netEl = menu.querySelector('[data-role="net"]');
|
|
1318
|
+
/* 样本一律读 window 上那一份(由上面的 maNoteRtt 写入,跨"菜单被重建"共享)。 */
|
|
1319
|
+
const rttSamples = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
|
|
1250
1320
|
const rttValue = () => {
|
|
1251
1321
|
if (!rttSamples.length) return null;
|
|
1252
1322
|
const s = rttSamples.slice().sort((a, b) => a - b);
|
|
@@ -1258,37 +1328,45 @@ const SCRIPT = `(() => {
|
|
|
1258
1328
|
const rttGrade = (ms) => (ms <= 500 ? "ok" : ms <= 1000 ? "mid" : ms <= 2000 ? "slow" : "bad");
|
|
1259
1329
|
const rttLabel = (g) => (g === "ok" ? "顺畅" : g === "mid" ? "正常" : g === "slow" ? "偏慢" : "很慢");
|
|
1260
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; } };
|
|
1261
1337
|
const renderRtt = () => {
|
|
1262
1338
|
try {
|
|
1263
1339
|
const ms = rttValue();
|
|
1264
1340
|
const g = ms === null ? "" : rttGrade(ms);
|
|
1265
|
-
|
|
1266
|
-
|
|
1341
|
+
// 0ms 是句谎话(不足 1ms 的真实往返会被四舍五入成 0)→ 展示层夹到 ≥1ms。
|
|
1342
|
+
const shown = ms === null ? null : Math.max(1, Math.round(ms));
|
|
1343
|
+
const rEl = liveRttEl();
|
|
1344
|
+
if (rEl) {
|
|
1345
|
+
if (shown === null) { rEl.hidden = true; rEl.textContent = ""; rEl.removeAttribute("data-rtt"); }
|
|
1267
1346
|
else {
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1347
|
+
rEl.hidden = false;
|
|
1348
|
+
rEl.textContent = shown + "ms";
|
|
1349
|
+
rEl.setAttribute("data-rtt", g);
|
|
1271
1350
|
}
|
|
1272
1351
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
const
|
|
1277
|
-
|
|
1278
|
-
if (
|
|
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");
|
|
1357
|
+
if (ico) ico.textContent = shown === null ? "📶" : (g === "bad" ? "📵" : "📶");
|
|
1358
|
+
if (txt) {
|
|
1359
|
+
/* 空态**说清原因**(2026-10-10):以前只有一句「等一次网络请求后显示」,
|
|
1360
|
+
于是"采样器压根没接上"和"接上了但还没有请求"长得一模一样,业主两次反馈
|
|
1361
|
+
我们都只能猜。现在一眼可分。 */
|
|
1362
|
+
txt.textContent = shown !== null ? (shown + " ms · " + rttLabel(g))
|
|
1363
|
+
: (maNetPatch.ok ? "测速中…(已接上,等一次请求)" : "测速中…(采样器未接上)");
|
|
1364
|
+
}
|
|
1279
1365
|
}
|
|
1280
|
-
try { fab.title =
|
|
1366
|
+
try { fab.title = shown === null ? FAB_TITLE_BASE : ("远程控制:网络延迟 " + shown + "ms(" + rttLabel(g) + ") / 字号 / 加密状态 / 返回设备列表(可拖动)"); } catch (e) { /* 忽略 */ }
|
|
1281
1367
|
} catch (e) { /* 延迟显示失败绝不影响主功能 */ }
|
|
1282
1368
|
};
|
|
1283
|
-
|
|
1284
|
-
const noteRtt = (ms) => {
|
|
1285
|
-
if (typeof ms !== "number" || !isFinite(ms)) return;
|
|
1286
|
-
const r = Math.round(ms);
|
|
1287
|
-
if (r < 1 || r > RTT_MAX_MS) return; // 亚毫秒=同帧缓存命中;>20s=卡住/超时 → 都不是"网络延迟"
|
|
1288
|
-
rttSamples.push(r);
|
|
1289
|
-
while (rttSamples.length > RTT_KEEP) rttSamples.shift();
|
|
1290
|
-
renderRtt();
|
|
1291
|
-
};
|
|
1369
|
+
maNetRender = renderRtt; // 注册给采样器(样品到达时回调它)
|
|
1292
1370
|
renderRtt();
|
|
1293
1371
|
|
|
1294
1372
|
/* ❌ 这里曾有「打开右侧栏」这一段与 maRightPane* 三件套 —— 2026-09-27 根治时整条删除。
|
|
@@ -1437,14 +1515,9 @@ const SCRIPT = `(() => {
|
|
|
1437
1515
|
window.fetch = function (input, init) {
|
|
1438
1516
|
let url = "";
|
|
1439
1517
|
try { url = typeof input === "string" ? input : (input && input.url) || ""; } catch (e5) { url = ""; }
|
|
1440
|
-
/*
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
静态壳/图片/字体不该代表"网络延迟";telemetry 那条 /dsh-remote/telemetry/... 也
|
|
1444
|
-
不在 apiish 里(它既不含 "/api/" 也不含 "/remote/",只有 "-remote/"),
|
|
1445
|
-
所以一次性的遥测上报不会污染样本。
|
|
1446
|
-
⚠️ 失败(reject)/超时(>20s)不进样本 —— 那不是"延迟",是"没通"(noteRtt 里已过滤)。 */
|
|
1447
|
-
const t0 = maNowMs();
|
|
1518
|
+
/* ⚠️ 2026-10-10:这里**不再**做网络延迟采样 —— 采样已独立成 maInstallNetSampler()
|
|
1519
|
+
(与菜单 DOM 解耦、幂等、自愈)。本包装只负责「会话过期探测」这一件事,
|
|
1520
|
+
否则同一趟请求会被量两次。 */
|
|
1448
1521
|
const p = origFetch.call(this, input, init);
|
|
1449
1522
|
try {
|
|
1450
1523
|
const abs = url.slice(0, 8).toLowerCase();
|
|
@@ -1453,7 +1526,7 @@ const SCRIPT = `(() => {
|
|
|
1453
1526
|
if (sameOrigin && apiish && p && typeof p.then === "function") {
|
|
1454
1527
|
p.then((res) => {
|
|
1455
1528
|
try { noteStatus(res && res.status); } catch (e6) { /* 忽略 */ }
|
|
1456
|
-
|
|
1529
|
+
/* 延迟采样已移到 maInstallNetSampler,这里只报会话过期 */
|
|
1457
1530
|
}, () => { /* 网络错误不算会话过期,也不算延迟样本 */ });
|
|
1458
1531
|
}
|
|
1459
1532
|
} catch (e7) { /* 忽略 */ }
|
|
@@ -1497,6 +1570,11 @@ const SCRIPT = `(() => {
|
|
|
1497
1570
|
let done = false;
|
|
1498
1571
|
const boot = () => {
|
|
1499
1572
|
if (!HOSTISH()) return;
|
|
1573
|
+
/* 📶 **先**把延迟采样器挂上(2026-10-10):它必须在"任何请求发生之前"就位。
|
|
1574
|
+
即便 buildRemoteMenu() 因为任何原因没能建出菜单(或抛错),采样器也照挂不误 ——
|
|
1575
|
+
旧实现把包装写在 buildRemoteMenu 内部的一次性分支里,那正是"请求明明在打、
|
|
1576
|
+
界面却永远测速中"的结构性原因。 */
|
|
1577
|
+
try { maInstallNetSampler(); } catch (eSampler0) { /* 忽略 */ }
|
|
1500
1578
|
/* 悬浮按钮/菜单:任何宽度都要有(电脑端访问镜像页时同样需要返回设备列表的出口)。
|
|
1501
1579
|
失败也不影响手机端布局适配 —— 两条链路各自独立。 */
|
|
1502
1580
|
if (!fabDone) { fabDone = true; try { buildRemoteMenu(); } catch (eFabDone) { /* 忽略 */ } }
|
|
@@ -1964,6 +2042,12 @@ const SCRIPT = `(() => {
|
|
|
1964
2042
|
const pump = () => {
|
|
1965
2043
|
if (maTorn) return;
|
|
1966
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) { /* 忽略 */ }
|
|
1967
2051
|
try { ensureDrawerChrome(); } catch (eChrome2) { /* 忽略 */ }
|
|
1968
2052
|
/* 镜像页就绪探测(mirror_load 的量测终点):pump 本来就被 MutationObserver(子树) + 1s 兜底表
|
|
1969
2053
|
驱动,官方 frame/输入框一挂上就会在**这一拍或下一拍**(≤1s)命中 —— 这正是契约要求的
|
|
@@ -199,10 +199,22 @@ const SPECIAL_EVENT_KINDS = Object.freeze([
|
|
|
199
199
|
const NOTICE_FORMATTER_KINDS = Object.freeze(["quota", "membership"]);
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
* 主动推送失败后的**待补发队列**上限(见 `#pushProactive
|
|
203
|
-
*
|
|
202
|
+
* 主动推送失败后的**待补发队列**上限(见 `#pushProactive`):只留**最近几条**。
|
|
203
|
+
*
|
|
204
|
+
* 2026-10-09 业主口径:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
|
|
205
|
+
* 从 20 收到 5 的理由:一轮里真正还值得看的就最近那么两三条;攒到 20 条的结果是
|
|
206
|
+
* 平台一恢复就**一次连打十几条** —— 实测正是这种突发批量触发 `ret=-2 prepare failed`
|
|
207
|
+
* 被限流,于是补发又失败、队列再涨,越积越多永远还不上(现场观察到的就是这个循环)。
|
|
208
|
+
*/
|
|
209
|
+
const MAX_OUTBOX = 5;
|
|
210
|
+
/**
|
|
211
|
+
* 待补发条目的**保质期**:超过这个时长还没送达就丢弃(先丢最旧的)。
|
|
212
|
+
*
|
|
213
|
+
* 为什么必须有(同一条业主口径):一条"任务跑完了"的通知迟到几小时就**毫无价值** ——
|
|
214
|
+
* 补发出去只会让用户以为刚刚才发生;而队列的价值在于"网络抖动期间的短暂失败",
|
|
215
|
+
* 那是分钟级的。2 小时足够覆盖抖动与短时离线,又不会把半天前的旧事翻出来刷屏。
|
|
204
216
|
*/
|
|
205
|
-
const
|
|
217
|
+
const OUTBOX_TTL_MS = 2 * 60 * 60 * 1000;
|
|
206
218
|
/** 补发时的正文前缀(迟到必须如实说,不能让用户以为刚刚才发生)。 */
|
|
207
219
|
const OUTBOX_MARK = "(补发)";
|
|
208
220
|
|
|
@@ -337,6 +349,12 @@ function sendJson(res, status, body) {
|
|
|
337
349
|
}
|
|
338
350
|
|
|
339
351
|
export class WeChatRuntime {
|
|
352
|
+
/**
|
|
353
|
+
* 「预期内的订阅拒绝」已报过的 (code|sessionId) 集合 —— 见 fault 处理器。
|
|
354
|
+
* 只是防刷屏:每生成一个**子代理**会话都会被订阅层拒一次,不记集合就是每个子代理刷一行 warn。
|
|
355
|
+
*/
|
|
356
|
+
#expectedFaultSeen = new Set();
|
|
357
|
+
|
|
340
358
|
/**
|
|
341
359
|
* @param {{
|
|
342
360
|
* relayDir: string,
|
|
@@ -508,7 +526,9 @@ export class WeChatRuntime {
|
|
|
508
526
|
followed_sessions: this.subscriber && typeof this.subscriber.sessions === "function"
|
|
509
527
|
? this.subscriber.sessions()
|
|
510
528
|
: [],
|
|
511
|
-
|
|
529
|
+
// 报**有效**条数(按保质期过滤后):否则进程重启后,面板会一直显示那几条早已过期、
|
|
530
|
+
// 永远不会被补发的条目,看起来像"队列卡住了"。真正的清理由 #trimOutbox 落盘。
|
|
531
|
+
pending_outbox: this.#pruneOutbox(state.pending_outbox).kept.length,
|
|
512
532
|
last_fault: state.last_fault || null,
|
|
513
533
|
last_completed_session_id: state.last_completed_session_id || "",
|
|
514
534
|
last_completed_at: Number(state.last_completed_at || 0),
|
|
@@ -1547,14 +1567,44 @@ export class WeChatRuntime {
|
|
|
1547
1567
|
return list;
|
|
1548
1568
|
}
|
|
1549
1569
|
|
|
1570
|
+
/**
|
|
1571
|
+
* 丢掉**过期**条目(返回保留的与丢掉的数量)。
|
|
1572
|
+
* `at` 缺失/非法 = 更早的落盘格式(那时还没有 at 字段)→ 一律视为过期:
|
|
1573
|
+
* 留着只会把"最近几条"挤掉,而它们必然比 TTL 更老。
|
|
1574
|
+
*/
|
|
1575
|
+
#pruneOutbox(list) {
|
|
1576
|
+
const now = this.clock();
|
|
1577
|
+
const kept = [];
|
|
1578
|
+
let dropped = 0;
|
|
1579
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
1580
|
+
const at = Number(item && item.at);
|
|
1581
|
+
if (!Number.isFinite(at) || at <= 0 || now - at > OUTBOX_TTL_MS) { dropped += 1; continue; }
|
|
1582
|
+
kept.push(item);
|
|
1583
|
+
}
|
|
1584
|
+
return { kept, dropped };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/** 丢弃过期的、并按上限只留最近几条;丢了就**如实记一行日志**(绝不静默丢)。 */
|
|
1588
|
+
#trimOutbox(list) {
|
|
1589
|
+
const { kept, dropped } = this.#pruneOutbox(list);
|
|
1590
|
+
let overflow = 0;
|
|
1591
|
+
while (kept.length > MAX_OUTBOX) { kept.shift(); overflow += 1; }
|
|
1592
|
+
const lost = dropped + overflow;
|
|
1593
|
+
if (lost > 0) {
|
|
1594
|
+
const mins = Math.round(OUTBOX_TTL_MS / 60000);
|
|
1595
|
+
const why = [dropped ? `${dropped} 条超 ${mins} 分钟未送达` : "", overflow ? `${overflow} 条超出最近 ${MAX_OUTBOX} 条上限` : ""]
|
|
1596
|
+
.filter(Boolean).join("、");
|
|
1597
|
+
this.logger.warn(`[wechat] 待补发队列丢弃 ${lost} 条(${why}),保留最近 ${kept.length} 条`);
|
|
1598
|
+
}
|
|
1599
|
+
return kept;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1550
1602
|
#enqueueOutbox(text, kind = "") {
|
|
1551
1603
|
const body = String(text || "");
|
|
1552
1604
|
if (!body) return 0;
|
|
1553
1605
|
const list = this.#outbox();
|
|
1554
1606
|
list.push({ text: body, kind: String(kind || ""), at: this.clock() });
|
|
1555
|
-
|
|
1556
|
-
while (list.length > MAX_OUTBOX) list.shift();
|
|
1557
|
-
return this.#writeOutbox(list).length;
|
|
1607
|
+
return this.#writeOutbox(this.#trimOutbox(list)).length;
|
|
1558
1608
|
}
|
|
1559
1609
|
|
|
1560
1610
|
/**
|
|
@@ -1562,7 +1612,11 @@ export class WeChatRuntime {
|
|
|
1562
1612
|
* @returns {Promise<number>} 这次补发成功的条数
|
|
1563
1613
|
*/
|
|
1564
1614
|
async #flushOutbox(to) {
|
|
1565
|
-
|
|
1615
|
+
// 补发前先按保质期清一遍:**过期的不发**(迟到几小时的"任务跑完了"只会误导用户),
|
|
1616
|
+
// 也避免一次性连打十几条把平台打限流(那正是队列越积越多的成因)。
|
|
1617
|
+
const raw = this.#outbox();
|
|
1618
|
+
const list = this.#trimOutbox(raw); // 只在真丢了东西时才落盘(避免每轮都写状态文件)
|
|
1619
|
+
if (list.length !== raw.length) this.#writeOutbox(list);
|
|
1566
1620
|
if (!list.length || !to) return 0;
|
|
1567
1621
|
let sent = 0;
|
|
1568
1622
|
const rest = [...list];
|
|
@@ -1645,10 +1699,20 @@ export class WeChatRuntime {
|
|
|
1645
1699
|
const code = String((info && info.code) || "unknown");
|
|
1646
1700
|
const message = String((info && info.message) || "").slice(0, 160);
|
|
1647
1701
|
const sessionId = String((info && info.sessionId) || "");
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1702
|
+
// 预期内的拒绝(子代理会话必然被拒 `session/agent-busy` / 会话刚被删 `session/not-found`)
|
|
1703
|
+
// **不是故障**:每生成一个子代理就会来一条,写成 warn 会把真正的故障淹掉
|
|
1704
|
+
// (2026-10-09 现场:日志里成片 `订阅异常 session/agent-busy … require their durable parent address`)。
|
|
1705
|
+
// 但也不能完全静默(2026-09-25 的教训:fault 以前没人接,全丢)→ 降为 info,且同一对只报一次。
|
|
1706
|
+
if (info && info.expected === true) {
|
|
1707
|
+
const key = `${code}|${sessionId}`;
|
|
1708
|
+
if (this.#expectedFaultSeen.size < 200 && !this.#expectedFaultSeen.has(key)) {
|
|
1709
|
+
this.#expectedFaultSeen.add(key);
|
|
1710
|
+
this.logger.info(`[wechat/events] 订阅跳过(预期,不重试) ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
|
|
1711
|
+
}
|
|
1712
|
+
return;
|
|
1651
1713
|
}
|
|
1714
|
+
this.logger.warn(`[wechat/events] 订阅异常 ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
|
|
1715
|
+
this.channel.writeState({ last_fault: { code, message, session_id: sessionId, at: this.clock() } });
|
|
1652
1716
|
});
|
|
1653
1717
|
}
|
|
1654
1718
|
|
package/runtime/dsh-setup.mjs
CHANGED
|
@@ -362,9 +362,13 @@ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/
|
|
|
362
362
|
* 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
|
|
363
363
|
* DSH_BRIDGE_INSTALL_SOURCE=plugin_market,这里原样透传(外部注入 > 硬编码默认)。
|
|
364
364
|
* install_version:优先取外部注入(插件/在线更新器),否则取本包 package.json 版本。
|
|
365
|
+
* harness_version:**只能由外部注入** —— 宿主(DeepSeek Harness)版本要从宿主进程的 argv /
|
|
366
|
+
* 安装目录推出来,而安装器是独立进程,推不出来。插件会在宿主进程内问出来再传进来;
|
|
367
|
+
* 用户直接跑 `npx @mrrisega/dsh-remote` 时没有这个值 → 留空(= 未上报,绝不猜一个错的)。
|
|
365
368
|
*/
|
|
366
369
|
const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
|
|
367
370
|
const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
|
|
371
|
+
const HARNESS_VERSION = String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
|
|
368
372
|
|
|
369
373
|
/**
|
|
370
374
|
* 把文件权限收紧到「只有本人可读写」。
|
|
@@ -708,7 +712,7 @@ function writeAutostartFile() {
|
|
|
708
712
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
709
713
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
710
714
|
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
711
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
|
|
715
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${HARNESS_VERSION}</string></dict>
|
|
712
716
|
</dict></plist>`;
|
|
713
717
|
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
714
718
|
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
@@ -724,7 +728,7 @@ function writeAutostartFile() {
|
|
|
724
728
|
if (process.platform === "linux") {
|
|
725
729
|
const dir = path.join(os.homedir(), ".config/systemd/user");
|
|
726
730
|
fs.mkdirSync(dir, { recursive: true });
|
|
727
|
-
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
731
|
+
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\nEnvironment=DSH_BRIDGE_HARNESS_VERSION=${HARNESS_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
728
732
|
const unitPath = autostartFilePath();
|
|
729
733
|
fs.writeFileSync(unitPath, unit);
|
|
730
734
|
return unitPath; // 路径在最终汇总里统一展示
|
|
@@ -803,6 +807,28 @@ function spawnDetachedBridge() {
|
|
|
803
807
|
* 但实测 kickstart 只解决"这一次",进程被杀后不会重建(KeepAlive 已被系统绕过)→ 如实告知
|
|
804
808
|
* ③ 后台进程 ← 连 launchd 都托管不了时保证可用
|
|
805
809
|
*/
|
|
810
|
+
/**
|
|
811
|
+
* 🔴 标签占用保护(2026-10-10 事故):`com.dshremote.bridge` 这个 launchd label 是**全局唯一**的,
|
|
812
|
+
* 谁 bootstrap 谁就接管它。当天跑测试时,某个用例用**假 HOME + 真 launchctl** 把测试临时目录的
|
|
813
|
+
* plist 登记到了同一个 label 上 → **真桥被顶掉**、远程访问中断,而日志里看不出任何异常。
|
|
814
|
+
*
|
|
815
|
+
* 所以 bootstrap 之前先问一句:这个 label 现在登记的 plist 是**本 relayDir 的**那份吗?
|
|
816
|
+
* · 不是(别人家的)→ **拒绝接管**并明确报错。要强行接管得显式设
|
|
817
|
+
* `DSH_BRIDGE_ALLOW_LABEL_TAKEOVER=1`(给人留后门,但默认安全)。
|
|
818
|
+
* · 查不出来(launchctl 不可用/未登记)→ 照旧继续,不误伤正常安装。
|
|
819
|
+
*/
|
|
820
|
+
function labelOwnedByOtherDir(plistPath, domain) {
|
|
821
|
+
try {
|
|
822
|
+
const r = sh(`launchctl print ${domain}/${BRIDGE_LABEL}`);
|
|
823
|
+
if (!r.ok) return null; // 未登记 → 不是"别人家的"
|
|
824
|
+
const cur = String(r.stdout || "").match(/^\s*path = (.+)$/m);
|
|
825
|
+
if (!cur) return null; // 读不出路径 → 不拦
|
|
826
|
+
const a = cur[1].trim();
|
|
827
|
+
const b = String(plistPath).trim();
|
|
828
|
+
return a === b ? false : a; // 返回"占用者的 plist 路径"= 别人家的
|
|
829
|
+
} catch { return null; }
|
|
830
|
+
}
|
|
831
|
+
|
|
806
832
|
function startBridgeDarwin(plistPath) {
|
|
807
833
|
const uid = process.getuid();
|
|
808
834
|
const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
@@ -813,6 +839,16 @@ function startBridgeDarwin(plistPath) {
|
|
|
813
839
|
for (const d of [`user/${uid}`, `gui/${uid}`]) {
|
|
814
840
|
if (d !== domain) sh(`launchctl bootout ${d}/${BRIDGE_LABEL}`);
|
|
815
841
|
}
|
|
842
|
+
// 先确认这个 label 不是别人(别的 relayDir)的,否则**拒绝接管**——见 labelOwnedByOtherDir
|
|
843
|
+
const foreign = labelOwnedByOtherDir(plistPath, domain);
|
|
844
|
+
if (foreign && String(process.env.DSH_BRIDGE_ALLOW_LABEL_TAKEOVER || "") !== "1") {
|
|
845
|
+
return {
|
|
846
|
+
ok: false,
|
|
847
|
+
status: "label-owned-by-other",
|
|
848
|
+
detail: `${domain}/${BRIDGE_LABEL} 已被另一个 relayDir 占用(${foreign})—— 拒绝接管。`
|
|
849
|
+
+ `真要用本目录接管,请先停掉那边的桥,或显式设 DSH_BRIDGE_ALLOW_LABEL_TAKEOVER=1。`,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
816
852
|
sh(`launchctl bootout ${target}`);
|
|
817
853
|
let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
818
854
|
if (!boot.ok) {
|
|
@@ -1505,6 +1541,8 @@ async function runBridge() {
|
|
|
1505
1541
|
// 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
|
|
1506
1542
|
DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
|
|
1507
1543
|
DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
|
|
1544
|
+
// 宿主版本:外部注入(插件在宿主进程内问出来的),安装器只是搬运工;没有就是空串
|
|
1545
|
+
DSH_BRIDGE_HARNESS_VERSION: HARNESS_VERSION,
|
|
1508
1546
|
...(saas ? { DSH_BRIDGE_PHONE: (cfg.phone || cfg.email || ""), DSH_BRIDGE_PASSWORD: cfg.password, DSH_BRIDGE_API: apiUrl, DSH_BRIDGE_SECRET: cfg.bridge_secret || "" } : {}),
|
|
1509
1547
|
...(local ? { DSH_BRIDGE_LOCAL_KEY: cfg.local_key } : {})
|
|
1510
1548
|
};
|
|
@@ -2191,10 +2229,21 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
2191
2229
|
// 注意:插件源(npx 缓存 / npm 全局)本身就位于某个 node_modules 之下,
|
|
2192
2230
|
// 过滤必须基于“相对插件目录”的路径片段,绝不能整条 src 路径包含判断,
|
|
2193
2231
|
// 否则会把插件文件全过滤掉、拷出空目录(此前反复“插件入口缺失”的总根因)。
|
|
2232
|
+
//
|
|
2233
|
+
// 🔴 2026-10-10 修:**插件自带的 runtime/node_modules 不能被剔掉**。
|
|
2234
|
+
// 旧规则是"任何一段叫 node_modules 就过滤",这条原本只想剔掉插件自己的依赖目录
|
|
2235
|
+
// (那些由 profile 的包管理器管),却把 `runtime/node_modules/ws` 一起剔了 ——
|
|
2236
|
+
// 而那份运行时正是"只有插件半"的机器**自愈补装时的来源**。剔掉它 = 自愈出来的运行环境
|
|
2237
|
+
// 缺 ws(bridge 顶层 `import WebSocket from "ws"`)→ 一启动就崩,还"自检通过"。
|
|
2238
|
+
// 现在:`runtime/` 之内一律保留;只过滤 runtime 之外的 node_modules。
|
|
2194
2239
|
filter: (src) => {
|
|
2195
2240
|
if (src === pluginDir) return true;
|
|
2196
|
-
const rel = path.relative(pluginDir, src);
|
|
2197
|
-
|
|
2241
|
+
const rel = String(path.relative(pluginDir, src));
|
|
2242
|
+
const segs = rel.split(path.sep);
|
|
2243
|
+
if (segs.some((seg) => seg === ".git" || seg === ".DS_Store")) return false;
|
|
2244
|
+
const inBundledRuntime = segs[0] === "runtime";
|
|
2245
|
+
if (!inBundledRuntime && segs.some((seg) => seg === "node_modules")) return false;
|
|
2246
|
+
return true;
|
|
2198
2247
|
}
|
|
2199
2248
|
};
|
|
2200
2249
|
|