@mrrisega/dsh-remote 0.7.0-beta.3 → 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/clients/dsh-remote/mobile-adapter.mjs +111 -39
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +36 -3
- package/dsh-setup.mjs +45 -2
- package/package.json +7 -6
- package/packages/dsh-remote-web/lib/client.js +116 -71
- package/packages/dsh-remote-web/lib/index.js +85 -4
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +111 -39
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +45 -2
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +15 -8
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +22 -8
- package/packages/dsh-remote-web/test/setup-output.test.mjs +37 -0
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +9 -1
|
@@ -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)命中 —— 这正是契约要求的
|
|
@@ -807,6 +807,28 @@ function spawnDetachedBridge() {
|
|
|
807
807
|
* 但实测 kickstart 只解决"这一次",进程被杀后不会重建(KeepAlive 已被系统绕过)→ 如实告知
|
|
808
808
|
* ③ 后台进程 ← 连 launchd 都托管不了时保证可用
|
|
809
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
|
+
|
|
810
832
|
function startBridgeDarwin(plistPath) {
|
|
811
833
|
const uid = process.getuid();
|
|
812
834
|
const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
@@ -817,6 +839,16 @@ function startBridgeDarwin(plistPath) {
|
|
|
817
839
|
for (const d of [`user/${uid}`, `gui/${uid}`]) {
|
|
818
840
|
if (d !== domain) sh(`launchctl bootout ${d}/${BRIDGE_LABEL}`);
|
|
819
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
|
+
}
|
|
820
852
|
sh(`launchctl bootout ${target}`);
|
|
821
853
|
let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
822
854
|
if (!boot.ok) {
|
|
@@ -2197,10 +2229,21 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
2197
2229
|
// 注意:插件源(npx 缓存 / npm 全局)本身就位于某个 node_modules 之下,
|
|
2198
2230
|
// 过滤必须基于“相对插件目录”的路径片段,绝不能整条 src 路径包含判断,
|
|
2199
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。
|
|
2200
2239
|
filter: (src) => {
|
|
2201
2240
|
if (src === pluginDir) return true;
|
|
2202
|
-
const rel = path.relative(pluginDir, src);
|
|
2203
|
-
|
|
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;
|
|
2204
2247
|
}
|
|
2205
2248
|
};
|
|
2206
2249
|
|
|
@@ -19,7 +19,8 @@ const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8"
|
|
|
19
19
|
/** 把 `var NAV_ENTRY_ID` 到 `openRemoteSettings` 之前的那段辅助函数切出来(含依赖的常量)。 */
|
|
20
20
|
function navHelpersSource() {
|
|
21
21
|
const start = SOURCE.indexOf('var NAV_ENTRY_ID = "dru-nav-remote";');
|
|
22
|
-
|
|
22
|
+
// 末端锚点用**函数声明**而不是注释(注释会被重写,2026-10-10 就因此失效过一次)
|
|
23
|
+
const end = SOURCE.indexOf("function openRemoteSettings()");
|
|
23
24
|
assert.ok(start > 0 && end > start, "切片锚点失效:侧栏入口辅助函数结构变了");
|
|
24
25
|
return SOURCE.slice(start, end);
|
|
25
26
|
}
|
|
@@ -168,11 +169,17 @@ test("精确相等优先于子串:给了 exactOnly 时子串一律不算", ()
|
|
|
168
169
|
assert.equal(pickNavByToken("设置", { exactOnly: true, insideDialog: false }), settings);
|
|
169
170
|
});
|
|
170
171
|
|
|
171
|
-
test("
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
assert.match(SOURCE, /
|
|
175
|
-
|
|
176
|
-
assert.match(SOURCE, /
|
|
177
|
-
assert.
|
|
172
|
+
test("源码约束:入口不再自己找 DOM 落点,而是注册官方槽位(2026-10-10 三修后的事实)", () => {
|
|
173
|
+
// 一修/二修都是"自己找落点",先后落错到设置面板顶部、以及"找不到锚点干脆不注入"。
|
|
174
|
+
// 三修起改用官方槽位 sidebar.footer.action —— 本用例把这条设计锁住,防止有人再退回 DOM 注入。
|
|
175
|
+
assert.match(SOURCE, /ctx\.slots\.inject\("sidebar\.footer\.action"/,
|
|
176
|
+
"侧栏入口必须注册进官方槽位 sidebar.footer.action");
|
|
177
|
+
assert.match(SOURCE, /id:\s*"dsh-remote-nav"/, "槽位条目 id 应为 dsh-remote-nav");
|
|
178
|
+
assert.ok(!/var host = pickSettingsTrigger\(\)/.test(SOURCE),
|
|
179
|
+
"不得再出现「每 2s 找锚点注入」的老写法(那正是两次落错位置的来源)");
|
|
180
|
+
assert.ok(!/if \(navInDialog\(host\)\) return;/.test(SOURCE),
|
|
181
|
+
"老的 DOM 注入兜底应已删除(入口改由槽位渲染)");
|
|
182
|
+
// 仍保留的:打开栏目的点击路径必须还能工作,且不会把「通用设置」当成设置触发器
|
|
183
|
+
assert.match(SOURCE, /aria-haspopup="dialog"/, "打开栏目时仍按 aria-haspopup=dialog 认官方设置触发器");
|
|
184
|
+
assert.match(SOURCE, /\[role="dialog"\], \[aria-modal="true"\]/, "必须保留「在设置弹层内」的判据");
|
|
178
185
|
});
|
|
@@ -162,11 +162,22 @@ function loadPlugin(opts = {}) {
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
test("
|
|
165
|
+
test("入口:settings.section 栏目(id/order/label)+ 侧栏底部官方槽 sidebar.footer.action", () => {
|
|
166
166
|
const plugin = loadPlugin();
|
|
167
167
|
|
|
168
|
-
//
|
|
169
|
-
|
|
168
|
+
// 2026-10-10 三修(业主口径「就要放侧栏底部那个位置」):入口改注册进**官方列表槽**
|
|
169
|
+
// `sidebar.footer.action`(宿主为侧栏底部区域提供的扩展点;0.2 实测该槽当前为空)。
|
|
170
|
+
// 不再自己注入 DOM —— 前两次都是"自己找落点"找错的。
|
|
171
|
+
assert.ok(plugin.injects.has("sidebar.footer.action"), "应注入官方侧栏底部槽");
|
|
172
|
+
const disposeNav = plugin.injects.get("sidebar.footer.action")();
|
|
173
|
+
assert.equal(typeof disposeNav, "function", "register 应返回 disposer");
|
|
174
|
+
const navMeta = plugin.metas.get("dsh-remote-nav");
|
|
175
|
+
assert.ok(navMeta, "侧栏入口应以 id=dsh-remote-nav 注册");
|
|
176
|
+
assert.equal(navMeta.name, "sidebar.footer.action");
|
|
177
|
+
assert.equal(typeof navMeta.label, "function");
|
|
178
|
+
assert.ok(String(navMeta.label()).includes("远程访问"), `槽位 label 应为「远程访问」,实际 ${navMeta.label()}`);
|
|
179
|
+
// 按钮 id 必须仍复用 NAV_ENTRY_ID(=dru-nav-remote):公告徽标/首次红点按 id 取节点的逻辑依赖它
|
|
180
|
+
assert.match(SOURCE, /id:\s*NAV_ENTRY_ID/, "入口按钮的 id 必须复用 NAV_ENTRY_ID(=dru-nav-remote)");
|
|
170
181
|
|
|
171
182
|
// settings.section 官方扩展点:id=dsh-remote、order=30(> Agent 预设 20,位于其下方)、label=📱 远程访问
|
|
172
183
|
assert.ok(plugin.injects.has("settings.section"), "应注入 settings.section 扩展点");
|
|
@@ -202,8 +213,9 @@ test("入口迁移:settings.section 里**只有一个**栏目(id=dsh-remote
|
|
|
202
213
|
// 搬进去之后仍然存在:面板 tab 条里的第三个 tab + 微信绿泡泡图标(行为断言见 wechat-bot-ui.test.mjs)
|
|
203
214
|
assert.match(SOURCE, /"微信机器人通道"/, "微信机器人 tab 必须在面板里");
|
|
204
215
|
assert.match(SOURCE, /dru-wx-ico/, "微信 tab 必须带绿泡泡图标 class");
|
|
205
|
-
//
|
|
206
|
-
assert.equal(plugin.injects.size,
|
|
216
|
+
// 槽注入次数:settings.section + shell.overlay + sidebar.footer.action(2026-10-10 加的侧栏入口)
|
|
217
|
+
assert.equal(plugin.injects.size, 3,
|
|
218
|
+
`inject 的槽名应是 settings.section + shell.overlay + sidebar.footer.action,实际:${[...plugin.injects.keys()].join(",")}`);
|
|
207
219
|
});
|
|
208
220
|
|
|
209
221
|
test("登录态账号区:无「切换账号」,有「退出登录」,头部/关于卡文案为「远程访问」", () => {
|
|
@@ -345,9 +357,11 @@ test("源码约束:侧栏注入「远程访问」按钮与官方「设置」
|
|
|
345
357
|
assert.match(SOURCE, /dsh-remote-seen-dot/);
|
|
346
358
|
});
|
|
347
359
|
|
|
348
|
-
test("
|
|
349
|
-
//
|
|
350
|
-
|
|
360
|
+
test("源码约束:侧栏入口走官方槽位/无浮动面板/无切换账号;命名统一为「远程访问」", () => {
|
|
361
|
+
// 2026-10-10 三修:侧栏入口**改回**官方槽位(业主口径「就要放侧栏底部那个位置」),
|
|
362
|
+
// 所以这条从"不得出现 footer.action"翻转为"必须走官方槽位、不得自己注入 DOM"。
|
|
363
|
+
assert.match(SOURCE, /slots\.inject\("sidebar\.footer\.action"/);
|
|
364
|
+
assert.doesNotMatch(SOURCE, /insertBefore\(btn, host\)/, "不得再自己插 DOM 找落点");
|
|
351
365
|
assert.doesNotMatch(SOURCE, /dru-backdrop\{/);
|
|
352
366
|
assert.doesNotMatch(SOURCE, /切换账号/);
|
|
353
367
|
assert.doesNotMatch(SOURCE, /dsh-remote-panel/);
|
|
@@ -156,3 +156,40 @@ test("★ 插件覆盖必须原子:staging → 必需文件自检 → rename
|
|
|
156
156
|
"🔴 不得再出现「先删 dest 再原地逐文件拷贝」——那正是这次事故的成因");
|
|
157
157
|
assert.match(fn, /\.staging-\$\{process\.pid\}|\.staging-/, "被强杀留下的 staging 要能清理");
|
|
158
158
|
});
|
|
159
|
+
|
|
160
|
+
test("★ 插件拷贝必须保留**自带的 runtime/node_modules**(ws 是桥唯一的裸依赖)", () => {
|
|
161
|
+
// 2026-10-10 事故形态:过滤规则写的是"任何一段叫 node_modules 就剔除",
|
|
162
|
+
// 本意只想去掉插件自己的依赖目录(那些由 profile 的包管理器管),
|
|
163
|
+
// 却把 `runtime/node_modules/ws` 一起剔了 —— 而这份运行时正是"只有插件半"的机器
|
|
164
|
+
// **自愈补装时的唯一来源**。剔掉它 → 自愈出来的运行环境缺 ws
|
|
165
|
+
// (bridge 顶层 `import WebSocket from "ws"`)→ 一启动就崩,而且自检还说"已就绪"。
|
|
166
|
+
const src = readFileSync(SETUP, "utf8");
|
|
167
|
+
const i = src.indexOf("const cpOpts = {");
|
|
168
|
+
assert.ok(i > 0, "应能定位 cpOpts");
|
|
169
|
+
const j = src.indexOf("};", i);
|
|
170
|
+
const objSrc = src.slice(i, j + 2);
|
|
171
|
+
const pluginDir = "/some/node_modules/dsh-remote-web"; // 源本身就在 node_modules 之下(历史坑)
|
|
172
|
+
const path = require$path();
|
|
173
|
+
// eslint-disable-next-line no-new-func
|
|
174
|
+
const make = new Function("path", "pluginDir", `${objSrc}\nreturn cpOpts;`);
|
|
175
|
+
const cpOpts = make(path, pluginDir);
|
|
176
|
+
const keep = (p) => cpOpts.filter(p) === true;
|
|
177
|
+
|
|
178
|
+
assert.equal(keep(pluginDir), true, "插件根目录本身必须保留");
|
|
179
|
+
assert.equal(keep(path.join(pluginDir, "lib", "index.js")), true, "普通文件必须保留");
|
|
180
|
+
// ① 自带运行时的 node_modules **必须保留**(本次修复的核心)
|
|
181
|
+
assert.equal(keep(path.join(pluginDir, "runtime", "node_modules")), true, "runtime/node_modules 必须保留");
|
|
182
|
+
assert.equal(keep(path.join(pluginDir, "runtime", "node_modules", "ws", "package.json")), true,
|
|
183
|
+
"★ runtime/node_modules/ws 必须被拷进 profile —— 否则自愈出来的运行环境起不来");
|
|
184
|
+
// ② runtime 之外的 node_modules 仍然要剔除(profile 的包管理器管那部分)
|
|
185
|
+
assert.equal(keep(path.join(pluginDir, "node_modules", "foo", "index.js")), false,
|
|
186
|
+
"runtime 之外的依赖目录仍应剔除(避免把解析器裁决权抢过来)");
|
|
187
|
+
// ③ 垃圾照旧剔除
|
|
188
|
+
assert.equal(keep(path.join(pluginDir, ".git", "config")), false, ".git 必须剔除");
|
|
189
|
+
assert.equal(keep(path.join(pluginDir, "lib", ".DS_Store")), false, ".DS_Store 必须剔除");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** 只在本用例里用到的 path 注入(避免为一行断言引入模块级 require 语义)。 */
|
|
193
|
+
function require$path() {
|
|
194
|
+
return { join: (...a) => a.join("/"), sep: "/", relative: (from, to) => to.startsWith(from + "/") ? to.slice(from.length + 1) : to };
|
|
195
|
+
}
|
|
@@ -264,7 +264,15 @@ exit 0
|
|
|
264
264
|
// 当前 HOME 的 LaunchAgents 里有 plist(服务此前装过,但现在未运行)
|
|
265
265
|
const plist = path.join(tempHome, "Library", "LaunchAgents", "com.dshremote.bridge.plist");
|
|
266
266
|
await mkdir(path.dirname(plist), { recursive: true });
|
|
267
|
-
|
|
267
|
+
/* ⚠️ 2026-10-10:夹具必须写成**真实形态的 plist** —— 内容里要含 `<relayDir>/dsh-setup.mjs`。
|
|
268
|
+
清理助手(与插件里的两条路径)现在会先校验"这份 plist 是不是给本 relayDir 装的":
|
|
269
|
+
只有含本 relayDir 入口脚本的 plist 才允许删。以前这里写的是空 `<dict/>`,
|
|
270
|
+
于是它同时**掩盖**了两件事:① 夹具不像真 plist;② 真实世界里那个守卫若缺失,
|
|
271
|
+
这条件会真的去删开发者的 plist(当天就发生了)。 */
|
|
272
|
+
await writeFile(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
273
|
+
<plist version="1.0"><dict>
|
|
274
|
+
<key>ProgramArguments</key><array><string>/usr/bin/node</string><string>${path.join(relayDir, "dsh-setup.mjs")}</string><string>run</string></array>
|
|
275
|
+
</dict></plist>`);
|
|
268
276
|
|
|
269
277
|
const routes = boot(relayDir);
|
|
270
278
|
const { host, base } = await serve(routes);
|