@mrrisega/dsh-remote 0.6.17 → 0.6.18-beta.2
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/e2ee-shim-script.js +203 -11
- package/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +43 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +145 -4
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +95 -6
- package/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/docs/telemetry.md +7 -1
- package/dsh-setup.mjs +152 -3
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +754 -39
- package/packages/dsh-remote-web/lib/index.js +279 -6
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +203 -11
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +152 -3
- package/packages/dsh-remote-web/test/account-switch-device.test.mjs +20 -2
- package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +310 -0
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +524 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +4 -1
- package/packages/dsh-remote-web/test/diag-report.test.mjs +4 -1
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +5 -1
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +26 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +32 -2
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +3 -1
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +4 -1
- package/packages/dsh-remote-web/test/setup-output.test.mjs +22 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +29 -10
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +3 -3
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +8 -3
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +7 -4
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
maybeInjectE2eeShim,
|
|
40
40
|
e2eeShimEnabled
|
|
41
41
|
} from "../e2ee-shim.mjs";
|
|
42
|
-
import { loadShimCore, extractShimCore } from "./lib-e2ee-shim.mjs";
|
|
42
|
+
import { loadShimCore, extractShimCore, readShimScript } from "./lib-e2ee-shim.mjs";
|
|
43
43
|
import { loadNativeHandoverCore, extractNativeHandoverCore, loadNativeWcCore } from "./lib-native-e2ee.mjs";
|
|
44
44
|
|
|
45
45
|
const OFFICIAL_HTML = `<!doctype html>
|
|
@@ -562,3 +562,45 @@ test("fetch 包装:真实字节往返(shim 密封 → 桥端打开 → 封回
|
|
|
562
562
|
assert.equal(res.headers.get("x-round"), String(i));
|
|
563
563
|
}
|
|
564
564
|
});
|
|
565
|
+
|
|
566
|
+
// ─────────────── 2026-09-27:P0-1 收尾(设备作用域必须在"没有会话"时也生效) ───────────────
|
|
567
|
+
|
|
568
|
+
test("★ WS 设备作用域:不改写就走放行分支时,也必须用**改写后**的 URL", () => {
|
|
569
|
+
// 事故现场:镜像页**没有会话**(未解锁)→ 不加密 → 走放行分支。
|
|
570
|
+
// 官方客户端把 mux 拼成根路径 `/api/remote.mux`,中继只认会过期的 dsh_device cookie → 404
|
|
571
|
+
// (rejectUpgrade 写状态行后立刻 destroy → nginx 只看到 009)→ e2ee-hello 送不到 →
|
|
572
|
+
// 会话永远建不起来 → 每个受保护请求 unknown_session 502。死循环。
|
|
573
|
+
const ORIGIN = "http://relay.local";
|
|
574
|
+
const patched = shim.seMakeWsCtor(FakeNative, { sess: null, origin: ORIGIN, deviceId: "dev-8e5fb209cdc8" });
|
|
575
|
+
const ws = patched(ORIGIN + "/api/remote.mux?e2ee=abc&w=def");
|
|
576
|
+
assert.ok(ws instanceof FakeNative, "无会话 → 不套加密代理(放行)");
|
|
577
|
+
assert.equal(ws.url, ORIGIN + "/remote/dev-8e5fb209cdc8/api/remote.mux?e2ee=abc&w=def",
|
|
578
|
+
"🔴 放行分支必须带**改写后**的设备作用域 URL —— 否则改写算了又被丢掉,等于没做");
|
|
579
|
+
|
|
580
|
+
// 反向:本来就在设备作用域里 → 一个字节都不改
|
|
581
|
+
const ws2 = patched(ORIGIN + "/remote/dev-x/api/remote.mux?w=1");
|
|
582
|
+
assert.equal(ws2.url, ORIGIN + "/remote/dev-x/api/remote.mux?w=1", "已在设备作用域 → 原样放行");
|
|
583
|
+
|
|
584
|
+
// 反向:非受保护路径(如静态资源)不做设备改写,保持原样
|
|
585
|
+
const ws3 = patched(ORIGIN + "/assets/index.js");
|
|
586
|
+
assert.equal(ws3.url, ORIGIN + "/assets/index.js", "非受保护前缀不改写");
|
|
587
|
+
|
|
588
|
+
// 反向:拿不到 deviceId(例如旧入口 `/`)→ 不改写,且不得崩
|
|
589
|
+
const patchedNoDev = shim.seMakeWsCtor(FakeNative, { sess: null, origin: ORIGIN, deviceId: "" });
|
|
590
|
+
const ws4 = patchedNoDev(ORIGIN + "/api/remote.mux");
|
|
591
|
+
assert.equal(ws4.url, ORIGIN + "/api/remote.mux", "没有 deviceId 时保持原样(宁可 404 也不要乱拼)");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("★ 补丁必须在 boot 阶段**无条件**安装(没会话也要装,否则建不了会话)", () => {
|
|
595
|
+
const src = readShimScript();
|
|
596
|
+
// 三条"放弃会话"的早退分支,都必须在 return 之前先装补丁
|
|
597
|
+
for (const marker of ["未加密:未解锁(明文)", "未加密:交接单无效", "未加密:环境不支持"]) {
|
|
598
|
+
const i = src.indexOf(marker);
|
|
599
|
+
assert.ok(i > 0, `找不到分支 ${marker}`);
|
|
600
|
+
const before = src.slice(Math.max(0, i - 320), i);
|
|
601
|
+
assert.match(before, /_install\(null\)/, `🔴 「${marker}」分支必须在 return 之前先 _install(null),否则 WS 改写根本不生效`);
|
|
602
|
+
}
|
|
603
|
+
// 会话后到时只填 ctx,不重复包装(否则 fetch/WS 会被套多层)
|
|
604
|
+
assert.match(src, /if \(sess\) \{[\s\S]{0,200}?_ctx\.sess = sess; return;/,
|
|
605
|
+
"同一页面重复 _install(sess) 只能更新会话,不得重复包装 window.fetch/WebSocket");
|
|
606
|
+
});
|
|
@@ -125,10 +125,11 @@ class El {
|
|
|
125
125
|
this.children = [];
|
|
126
126
|
for (const m of String(html).matchAll(/<(\w+)([^>]*)>/g)) {
|
|
127
127
|
const el = new El(m[1]);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
/* 任意 `attr="v"` 都要收进 attrs:0.6.18 新增的 FAB 延迟角标(data-role="rtt")、
|
|
129
|
+
菜单里的网络延迟行(data-role="net")与「打开右侧栏」按钮(data-role="right-pane")
|
|
130
|
+
都是靠属性找的 —— 只认 class 的旧解析器会让它们恒为 null,护栏就变成假绿灯。 */
|
|
131
|
+
for (const a of (m[2] || "").matchAll(/([\w-]+)="([^"]*)"/g)) el.attrs[a[1]] = a[2];
|
|
132
|
+
if (el.attrs.class) el.className = el.attrs.class;
|
|
132
133
|
this.appendChild(el);
|
|
133
134
|
}
|
|
134
135
|
}
|
|
@@ -897,3 +898,143 @@ test("★ 加载疑似失败时的提示:只在**已有的悬浮菜单**里多
|
|
|
897
898
|
assert.match(script, /dsh-remote 手机端加载疑似卡住/, "必须给一句可直接发给客服的话");
|
|
898
899
|
assert.match(script, /maLoadCopyLine/, "要能复制(否则用户只能截图)");
|
|
899
900
|
});
|
|
901
|
+
|
|
902
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
903
|
+
// Bug 4(0.6.18,用户实测):「点官方抽屉里的收起按钮 → 左半边一片白色占位,只有刷新能出去」
|
|
904
|
+
//
|
|
905
|
+
// 根因:userWantsOpen 一旦为 true 就**永不复位** → html.dsh-ma-sidebar-open 常亮 →
|
|
906
|
+
// CSS 把 sidebarCol 钉成 fixed + translateX(0) + z-index:310 的**空壳**(官方已把列宽压成 0、
|
|
907
|
+
// 内容收起,只剩一个没有内容的白色固定柱盖住左半屏);它几何上"在视口里"(width≈340 < 0.98vw),
|
|
908
|
+
// 遮罩不变量也跟着误判 → 整屏被困;而汉堡按钮又**无条件**点官方 toggle → 两态互相打架。
|
|
909
|
+
// 四层修复:① sync() 里放弃过期意图;② 汉堡按钮仅在"官方态 ≠ 期望态"时才点 toggle;
|
|
910
|
+
// ③ 官方折叠态镜像到 <html> + CSS 硬兜底;④ 遮罩不变量把"空壳"排除在外。
|
|
911
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
912
|
+
|
|
913
|
+
/** 模拟"用户点了官方抽屉内的收起按钮":官方写回折叠属性(真机就是这个动作)。 */
|
|
914
|
+
function officialCollapse(env) {
|
|
915
|
+
env.frame.setAttribute("data-sidebar-collapsed", "true");
|
|
916
|
+
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
917
|
+
if (obs) obs.fire("data-sidebar-collapsed");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
test("白柱:官方抽屉被收起后,适配层必须放弃自己的展开意图(不留空壳、不困住用户)", () => {
|
|
921
|
+
const { js, css } = injected();
|
|
922
|
+
// 现场:官方初始是**展开**的(属性缺席)+ 抽屉已在视口里 → 用户点过正文让详情列滑入过;
|
|
923
|
+
// 随后官方把侧栏收起来(用户点了抽屉内的收起按钮)
|
|
924
|
+
// ⚠️ 2026-09-27 修(本用例原先的前提不成立,是**假红**):
|
|
925
|
+
// 原来先用 `collapsedAttr: null` 造"官方不写属性",再中途改成 `="false"` 并 fire 观察者回调,
|
|
926
|
+
// 然后点汉堡 —— 但 `collapsed()` 对"从没见过这个属性"的返回值是**收起**(`return !sidebarAttrSeen`),
|
|
927
|
+
// 于是那次 fire 恰好构成一次"官方由收起→展开"的边沿,`lastOfficialCollapsed` 的记录与
|
|
928
|
+
// 随后的点击时序耦合,前提断言 `dsh-ma-sidebar-open === true` 就不成立了。
|
|
929
|
+
// 现在直接照**已通过的**兄弟用例(「汉堡按钮不再无条件点官方 toggle」)的写法:官方一开始就说"展开"。
|
|
930
|
+
const env = run(harness({ collapsedAttr: "false", detailsAttr: "true" }), js);
|
|
931
|
+
const ham = findHamburger(env);
|
|
932
|
+
// 官方一开始说"展开" ⇒ 启动时 class 就是开的(`open = expanded()`)。要点出"**用户**的展开意图",
|
|
933
|
+
// 需要走一次"关 → 开":第一次点=关(并让官方 toggle 走一次),第二次点=用户明确要开。
|
|
934
|
+
ham.dispatchEvent(new Ev("click", {})); // 关
|
|
935
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false, "第一次点:关掉");
|
|
936
|
+
ham.dispatchEvent(new Ev("click", {})); // 开(建立用户意图)
|
|
937
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "前提:抽屉是展开的(用户意图已建立)");
|
|
938
|
+
|
|
939
|
+
officialCollapse(env); // ← 用户点了**官方抽屉内**的收起按钮
|
|
940
|
+
assert.equal(env.html.classList.contains("dsh-ma-official-collapsed"), true,
|
|
941
|
+
"官方折叠态必须镜像到 <html>(CSS 据此做不依赖 JS 判定的硬兜底)");
|
|
942
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false,
|
|
943
|
+
"官方收起后适配层必须放弃展开意图 —— 否则就是那根白色空壳柱(只有刷新能出去)");
|
|
944
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "不得留下任何拦截层");
|
|
945
|
+
// CSS 硬兜底必须在(哪怕 JS 判定全部失手,也不会留下可见空壳)
|
|
946
|
+
assert.match(css, /html\.dsh-ma-official-collapsed[^{]*\{\s*transform:\s*translateX\(-103%\)\s*!important/,
|
|
947
|
+
"必须有 html.dsh-ma-official-collapsed 的 translateX(-103%) 兜底规则(不依赖任何 JS 判定)");
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
test("白柱:官方已收起时不留下「白色空壳」——空壳不算可交互抽屉,遮罩必须摘掉", () => {
|
|
951
|
+
const { js } = injected();
|
|
952
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
953
|
+
// 造出"空壳":这一列被判成展开、几何也在视口里,但里面一个可交互内容都没有
|
|
954
|
+
env.html.classList.add("dsh-ma-sidebar-open", "dsh-ma-scrim-on");
|
|
955
|
+
env.sidebar.children = []; // 官方把内容收走了(卸载)
|
|
956
|
+
env.sidebar.getBoundingClientRect = () => IN_LEFT;
|
|
957
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
958
|
+
env.tickTimers(); // 800ms 看门狗
|
|
959
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false,
|
|
960
|
+
"空壳在视口里不等于「可交互抽屉」→ 遮罩必须被摘掉(用户不能被一张看不见的遮罩困住)");
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("白柱:汉堡按钮不再无条件点官方 toggle(两态不得互相打架)", () => {
|
|
964
|
+
const { js } = injected();
|
|
965
|
+
const env = run(harness({ collapsedAttr: "false", detailsAttr: "true" }), js);
|
|
966
|
+
// 官方明确说"展开",而我们这边还没表达意图 → 点汉堡 = 要关(官方态 ≠ 期望态 → 该点 toggle)
|
|
967
|
+
const clicks = toggleClicks(env);
|
|
968
|
+
const ham = findHamburger(env);
|
|
969
|
+
ham.dispatchEvent(new Ev("click", {}));
|
|
970
|
+
assert.equal(clicks(), 1, "期望态与官方态不一致时必须点官方 toggle(走官方 store)");
|
|
971
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false, "点击后应关掉抽屉");
|
|
972
|
+
// 再点一次:官方此刻仍是"展开"(假 DOM 的 toggle 不改属性)→ 期望是展开、官方已是展开
|
|
973
|
+
// → 不得再点官方 toggle(那会把官方推到**相反**方向,正是"两个状态互相打架"的来源)
|
|
974
|
+
ham.dispatchEvent(new Ev("click", {}));
|
|
975
|
+
assert.equal(clicks(), 1, "官方态与期望态一致时**不得**再点官方 toggle(否则交替打架、白柱反复重现)");
|
|
976
|
+
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "第二次点击应把抽屉打开(意图生效)");
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("打开右侧栏:官方右栏里有宿主注册的内容时,FAB 菜单那颗按钮必须能把它滑进来", () => {
|
|
980
|
+
const { js } = injected();
|
|
981
|
+
// 现场(用户报的「右侧按钮区看不见」):better-sidebar 这类插件把 tab 注册进官方右栏,
|
|
982
|
+
// 但它只翻自己的 store → 官方 cols.rightbar 永远是 0(data-rightbar-collapsed 恒为 true)。
|
|
983
|
+
const env = harness({ collapsedAttr: "true", variant: "rc2", detailsAttr: null, rightbarAttr: "true" });
|
|
984
|
+
env.third.appendChild(env.doc.createElement("div")); // 官方右栏里确实有内容(插件注册的 tab 面板)
|
|
985
|
+
run(env, js);
|
|
986
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "前提:默认不显示右栏");
|
|
987
|
+
const menu = env.body.children.find((c) => c._cls.has("dsh-ma-menu"));
|
|
988
|
+
assert.ok(menu, "应有悬浮菜单");
|
|
989
|
+
const btn = menu.querySelector('[data-role="right-pane"]');
|
|
990
|
+
assert.ok(btn, "菜单里必须有「打开右侧栏」按钮(用户唯一的明路)");
|
|
991
|
+
btn.dispatchEvent(new Ev("click", {}));
|
|
992
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), true,
|
|
993
|
+
"点「打开右侧栏」后,即便官方说收起(第三方插件不会让官方展开),也必须把有内容的右栏滑进来");
|
|
994
|
+
assert.equal(menu.hidden, true, "点完应关闭菜单(让用户看见滑入的面板)");
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
test("打开右侧栏:右栏里没有任何内容时不得滑出一个空白面板(2026-09-19 白框事故的回归)", () => {
|
|
998
|
+
const { js } = injected();
|
|
999
|
+
const env = run(harness({ collapsedAttr: "true", variant: "rc2", detailsAttr: null, rightbarAttr: "true" }), js);
|
|
1000
|
+
const menu = env.body.children.find((c) => c._cls.has("dsh-ma-menu"));
|
|
1001
|
+
menu.querySelector('[data-role="right-pane"]').dispatchEvent(new Ev("click", {}));
|
|
1002
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
|
|
1003
|
+
"官方右栏里什么都没有 → 宁可什么都不发生,也不弹一个空白白面板(那正是白框事故)");
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test("打开右侧栏:绝不允许「载入即滑出白框」——必须由用户动作解锁", () => {
|
|
1007
|
+
const { js } = injected();
|
|
1008
|
+
// 载入时官方右栏就是"有内容"的(面板挂载着)+ 官方属性说不清 → 旧实现在这里弹白框
|
|
1009
|
+
const env = harness({ collapsedAttr: "true", variant: "rc2", detailsAttr: null, rightbarAttr: null });
|
|
1010
|
+
env.third.appendChild(env.doc.createElement("div"));
|
|
1011
|
+
run(env, js);
|
|
1012
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
|
|
1013
|
+
"没有任何用户动作时,绝不允许因为「右栏有内容」就把面板滑出来(白框必须是用户要的才出现)");
|
|
1014
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "同理:遮罩不得跟着拦点击");
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
test("护栏:延迟角标必须是 FAB 内的子元素(不得新增 body 级固定层)", () => {
|
|
1018
|
+
const { js, css } = injected();
|
|
1019
|
+
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
1020
|
+
const fab = env.body.children.find((c) => c._cls.has("dsh-ma-fab"));
|
|
1021
|
+
assert.ok(fab, "应有悬浮按钮");
|
|
1022
|
+
const rtt = fab.querySelector('[data-role="rtt"]');
|
|
1023
|
+
assert.ok(rtt, "延迟角标必须在 FAB 内部(绝对定位子元素)");
|
|
1024
|
+
// body 下不得多出"角标层"这种节点。
|
|
1025
|
+
// ⚠️ 2026-09-27 修:`body.children` 里**还有官方 frame**(`pI_x6G_frame`,测试脚手架自己放进去的),
|
|
1026
|
+
// 它不是适配层注入的。原来的写法拿"白名单命中数 === body.children.length"去比,
|
|
1027
|
+
// 等于要求官方 frame 也得命中白名单 → 恒差 1(假红)。现在改成**指名道姓找漏网节点**,
|
|
1028
|
+
// 既排除了官方 frame,又比原来更严:多出来的节点会被打印出来。
|
|
1029
|
+
const allowed = ["dsh-ma-fab", "dsh-ma-menu", "dsh-ma-scrim", "dsh-ma-hamburger", "dsh-ma-sessionbar"];
|
|
1030
|
+
const stray = env.body.children
|
|
1031
|
+
.filter((c) => !c._cls.has("pI_x6G_frame")) // 官方 frame 不是我们注入的
|
|
1032
|
+
.filter((c) => !allowed.some((k) => c._cls.has(k)));
|
|
1033
|
+
assert.deepEqual(stray.map((c) => [...c._cls].join(".")), [],
|
|
1034
|
+
"适配层注入到 body 的节点只能是既有那几个(角标必须挂在 FAB 里,不许新建浮层)");
|
|
1035
|
+
// CSS 侧:角标规则不得出现 fixed / 超高 z-index
|
|
1036
|
+
assert.match(css, /button\.dsh-ma-fab \.dsh-ma-fab-rtt\s*\{[^}]*position:\s*absolute/, "角标应绝对定位在 FAB 内");
|
|
1037
|
+
assert.ok(!/\.dsh-ma-fab-rtt\s*\{[^}]*position:\s*fixed/.test(css), "角标不得是 fixed 浮层");
|
|
1038
|
+
assert.ok(!/\.dsh-ma-fab-rtt\s*\{[^}]*z-index/.test(css), "角标不得自带 z-index(它活在 FAB 的层叠上下文里)");
|
|
1039
|
+
void js;
|
|
1040
|
+
});
|
|
@@ -45,7 +45,11 @@ class El {
|
|
|
45
45
|
toggle: (c, on) => { if (on === undefined) { this._cls.has(c) ? this._cls.delete(c) : this._cls.add(c); } else if (on) this._cls.add(c); else this._cls.delete(c); return this._cls.has(c); },
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
-
// 极简 innerHTML:只认本用例需要的那点结构(class / input[type=range]
|
|
48
|
+
// 极简 innerHTML:只认本用例需要的那点结构(class / 任意 attr="v" / input[type=range])
|
|
49
|
+
// ⚠️ 必须把**任意** `attr="v"` 都收进 el.attrs:0.6.18 新增的 FAB 延迟角标
|
|
50
|
+
// (`<span class="dsh-ma-fab-rtt" data-role="rtt" hidden>`)就是靠 data-role 找的,
|
|
51
|
+
// 只认 class 的旧解析器会让 fab.querySelector('[data-role="rtt"]') 恒为 null,
|
|
52
|
+
// "角标到底有没有出现"就永远测不出来(假 DOM 失真 → 假绿灯)。
|
|
49
53
|
set innerHTML(html) {
|
|
50
54
|
this._html = String(html);
|
|
51
55
|
this.children = [];
|
|
@@ -53,10 +57,8 @@ class El {
|
|
|
53
57
|
const tag = m[1];
|
|
54
58
|
const attrs = m[2] || "";
|
|
55
59
|
const el = new El(tag);
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
const type = /type="([^"]*)"/.exec(attrs);
|
|
59
|
-
if (type) el.attrs.type = type[1];
|
|
60
|
+
for (const a of attrs.matchAll(/([\w-]+)="([^"]*)"/g)) el.attrs[a[1]] = a[2];
|
|
61
|
+
if (el.attrs.class) el.className = el.attrs.class;
|
|
60
62
|
this.children.push(el);
|
|
61
63
|
}
|
|
62
64
|
}
|
|
@@ -81,6 +83,9 @@ class El {
|
|
|
81
83
|
if (s2.includes('input[type="range"]')) return el.tagName === "INPUT" && el.attrs.type === "range";
|
|
82
84
|
if (s2.startsWith(".")) return el._cls.has(s2.slice(1));
|
|
83
85
|
if (s2.startsWith("input")) return el.tagName === "INPUT";
|
|
86
|
+
// `[attr="v"]` / `[attr]`(0.6.18 的 FAB 角标、菜单里的网络延迟行都靠它找)
|
|
87
|
+
const at = /^\[([\w-]+)(?:="([^"]*)")?\]$/.exec(s2);
|
|
88
|
+
if (at) return at[2] === undefined ? at[1] in el.attrs : String(el.attrs[at[1]]) === at[2];
|
|
84
89
|
return false;
|
|
85
90
|
};
|
|
86
91
|
return this.children.find(match) || null;
|
|
@@ -100,7 +105,7 @@ class El {
|
|
|
100
105
|
* 折叠时**没有拖拽把手**——这正是 2026-09-19 线上"整屏遮罩"的现场结构。
|
|
101
106
|
*/
|
|
102
107
|
function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRect = null, width = 390, withToggle = true,
|
|
103
|
-
variant = "rc1", rightbarAttr = undefined, overlayRect = null, rightbarRect = null } = {}) {
|
|
108
|
+
variant = "rc1", rightbarAttr = undefined, overlayRect = null, rightbarRect = null, fetchImpl = null } = {}) {
|
|
104
109
|
const html = new El("html");
|
|
105
110
|
// HOSTISH 门禁会读 documentElement.outerHTML 找官方特征(__ModuleLoader__ / DeepSeek Harness)
|
|
106
111
|
html.outerHTML = '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
|
|
@@ -168,6 +173,9 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
|
|
|
168
173
|
removeEventListener() {},
|
|
169
174
|
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
|
170
175
|
requestAnimationFrame: (fn) => { const id = setImmediate(fn); return id; },
|
|
176
|
+
/* 0.6.18:适配层会在**已有的** window.fetch 包装里采网络延迟样本(零新增请求)。
|
|
177
|
+
不传 fetchImpl 时保持"页面没有 fetch"的旧现场(适配层会跳过包装,零行为)。 */
|
|
178
|
+
fetch: fetchImpl || undefined,
|
|
171
179
|
// unref:同上 —— 适配层的 4 分钟兜底计时器会让测试进程空等到期(本来 240s,只剩 0.2s)。
|
|
172
180
|
// 用例里的 `_pendingTimers` 仍会被 runAdapter() 主动跑一轮,行为不变。
|
|
173
181
|
setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); try { id.unref?.(); } catch { /* 忽略 */ } timers.set(id, fn); return id; },
|
|
@@ -441,3 +449,84 @@ test("悬浮按钮:菜单跟随按钮位置(按钮在上半屏 → 菜单挂
|
|
|
441
449
|
assert.equal(menu.style.left, "20px", "按钮靠左 → 菜单左对齐到按钮");
|
|
442
450
|
assert.equal(menu.style.right, "auto", "左对齐时不应残留 right");
|
|
443
451
|
});
|
|
452
|
+
|
|
453
|
+
// ── 0.6.18:悬浮按钮图标化 + 去掉预览行 + 网络延迟(用户需求 1 / 1a) ────────────
|
|
454
|
+
// 需求原话:①「手机端的悬浮按钮,把那个『字』改成我们自己用的那个手机的图标」;
|
|
455
|
+
// ②「设置面板里那行预览的字去掉」;③「要能看见网络延迟」。
|
|
456
|
+
|
|
457
|
+
test("悬浮按钮:图标化为手机 SVG(不再是「字」),且延迟角标在 FAB 内部", () => {
|
|
458
|
+
const env = runAdapter(harness({ width: 390 }));
|
|
459
|
+
const fab = findFab(env);
|
|
460
|
+
assert.ok(fab, "悬浮按钮应存在");
|
|
461
|
+
const html = fab.innerHTML;
|
|
462
|
+
assert.ok(!/>字</.test(html), "按钮里不得再有原来的「字」");
|
|
463
|
+
assert.match(html, /<svg[^>]*viewBox="0 0 24 24"/, "应内联一个 24×24 的 SVG 图标(照汉堡按钮的写法)");
|
|
464
|
+
assert.match(html, /stroke="currentColor"/, "图标颜色必须继承 currentColor(暗色主题/官方变量变化都跟着走)");
|
|
465
|
+
assert.match(html, /class="dsh-ma-fab-ico"/, "图标应包在 .dsh-ma-fab-ico 里(CSS 负责居中)");
|
|
466
|
+
// 延迟角标:必须是 FAB 的**子元素**(护栏:适配层不得新增 body 级固定层)
|
|
467
|
+
const rtt = fab.querySelector('[data-role="rtt"]');
|
|
468
|
+
assert.ok(rtt, "延迟角标必须是 FAB 内的绝对定位子元素(不是新的浮层)");
|
|
469
|
+
assert.equal(rtt.hidden, true, "还没有任何网络样本时角标必须隐藏(显示 0ms 是谎言)");
|
|
470
|
+
// 锁图标仍在(且仍在右上角样式里,角标不许抢它的位置)
|
|
471
|
+
assert.ok(fab.querySelector('[data-role="lock"]'), "加密锁图标不能被图标化改掉");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("悬浮菜单:预览行已彻底删除(DOM 里不得再有 .dsh-ma-menu-preview)", () => {
|
|
475
|
+
const env = runAdapter(harness({ width: 390 }));
|
|
476
|
+
const menu = findMenu(env);
|
|
477
|
+
assert.equal(menu.querySelector(".dsh-ma-menu-preview"), null, "「预览:手机远程控制,随手可用。」那一行必须删除");
|
|
478
|
+
assert.ok(!/预览:手机远程控制/.test(menu.innerHTML), "菜单 HTML 里不得再出现那行预览文案");
|
|
479
|
+
// 删的是"那一行",不是功能:字号滑块与档位文案必须还在(这条回归守着误删)
|
|
480
|
+
assert.ok(menu.querySelector('input[type="range"]'), "字号滑块必须还在");
|
|
481
|
+
assert.match(menu.innerHTML, /只影响这台设备的远程页面/, "字号说明文案应保留");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("悬浮菜单:新增「网络延迟」一行与「打开右侧栏」按钮(只读文案 + 显式出口)", () => {
|
|
485
|
+
const env = runAdapter(harness({ width: 390 }));
|
|
486
|
+
const menu = findMenu(env);
|
|
487
|
+
assert.ok(menu.querySelector(".dsh-ma-menu-net"), "菜单里应有网络延迟块");
|
|
488
|
+
assert.match(menu.innerHTML, /网络延迟/, "应有「网络延迟」小标题");
|
|
489
|
+
assert.ok(menu.querySelector('[data-role="net"]'), "延迟块要带 data-role=net(供渲染函数更新)");
|
|
490
|
+
assert.ok(menu.querySelector('[data-role="right-pane"]'), "菜单里应有「打开右侧栏」按钮");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("网络延迟:只在**已有**的 fetch 包装里采样,成功样本显示成角标(绝不主动测速)", async () => {
|
|
494
|
+
const calls = [];
|
|
495
|
+
// 刻意让响应"慢" 5ms:亚毫秒(同帧缓存命中)会被刻意丢弃,见 noteRtt 的注释
|
|
496
|
+
const env = harness({
|
|
497
|
+
width: 390,
|
|
498
|
+
fetchImpl: async (input) => { calls.push(String(input)); await new Promise((r) => setTimeout(r, 5)); return { status: 200, ok: true }; },
|
|
499
|
+
});
|
|
500
|
+
runAdapter(env);
|
|
501
|
+
const fab = findFab(env);
|
|
502
|
+
const rtt = fab.querySelector('[data-role="rtt"]');
|
|
503
|
+
assert.equal(rtt.hidden, true, "前提:还没有样本");
|
|
504
|
+
await env.win.fetch("/api/session/list");
|
|
505
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
506
|
+
assert.equal(calls.length, 1, "适配层不得额外发探测请求(只包装、不加请求)");
|
|
507
|
+
assert.equal(rtt.hidden, false, "有成功样本后角标应出现");
|
|
508
|
+
assert.match(String(rtt.textContent), /^\d+ms$/, `角标应是「<数字>ms」,实际:${rtt.textContent}`);
|
|
509
|
+
assert.ok(["ok", "mid", "bad"].includes(rtt.getAttribute("data-rtt")), "角标应带分档(ok/mid/bad)供 CSS 上色");
|
|
510
|
+
assert.equal(rtt.hidden === false, true);
|
|
511
|
+
// 菜单里的那一行也要跟着更新(同一个 renderRtt)
|
|
512
|
+
const menu = findMenu(env);
|
|
513
|
+
const net = menu.querySelector(".dsh-ma-menu-net");
|
|
514
|
+
assert.ok(["ok", "mid", "bad"].includes(net.getAttribute("data-rtt")), "菜单行也应按同一分档着色");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("网络延迟:无样本 / 失败请求绝不显示 0ms(显示「测速中…」是唯一正确的空态)", async () => {
|
|
518
|
+
const env = harness({
|
|
519
|
+
width: 390,
|
|
520
|
+
fetchImpl: async () => { throw new Error("network down"); },
|
|
521
|
+
});
|
|
522
|
+
runAdapter(env);
|
|
523
|
+
const fab = findFab(env);
|
|
524
|
+
const rtt = fab.querySelector('[data-role="rtt"]');
|
|
525
|
+
// 失败的请求:连会话过期探测都不该被触发,更不该产生延迟样本
|
|
526
|
+
await env.win.fetch("/api/session/list").catch(() => {});
|
|
527
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
528
|
+
assert.equal(rtt.hidden, true, "只有失败/超时样本时角标必须保持隐藏");
|
|
529
|
+
assert.notEqual(String(rtt.textContent), "0ms", "绝不能显示 0ms(那是谎言)");
|
|
530
|
+
const menu = findMenu(env);
|
|
531
|
+
assert.match(menu.innerHTML, /测速中…/, "空态文案必须是「测速中…」");
|
|
532
|
+
});
|
|
@@ -1173,12 +1173,35 @@ export class WeChatRuntime {
|
|
|
1173
1173
|
} catch { return false; }
|
|
1174
1174
|
}
|
|
1175
1175
|
|
|
1176
|
-
/**
|
|
1176
|
+
/**
|
|
1177
|
+
* 可切换会话列表(`/ls` 与 `/use` **共用同一份**)—— 过滤必须收口在这里。
|
|
1178
|
+
*
|
|
1179
|
+
* 为什么(业主反馈「/ls 列出一堆(未命名)会话」):`listSessions` 返回的会话里有大量**空白会话**
|
|
1180
|
+
* (新建后一句话没说的坑),官方已经在数据里标了 `blank`(`dsh-events.mjs:1654` 解析出来却
|
|
1181
|
+
* 一直没人用)。规则:只留 **有名字 / 正在跑 / 当前选中** 的会话;`blank === true` 一律剔除。
|
|
1182
|
+
*
|
|
1183
|
+
* ⚠️ 过滤**只能**放在这里(或同步过滤 sessionIndex):`/ls` 渲染 1..N 编号、`/use <n>` 按下标取
|
|
1184
|
+
* 会话、而 `sessionIndex`(/use 的列表来源)就是本函数的返回值 —— 任何一处单独过滤都会让
|
|
1185
|
+
* 两边编号分叉(用户 `/ls` 看到第 2 条是 A,`/use 2` 却切到 B)。
|
|
1186
|
+
* ⚠️ 老版本 DSH 不带 `blank` 字段:`undefined === true` 为假 → 自动退化成"有名字/在跑/当前",
|
|
1187
|
+
* 不会因为升级或降级把整张列表清空。
|
|
1188
|
+
*/
|
|
1189
|
+
#usableSessions(list) {
|
|
1190
|
+
return (Array.isArray(list) ? list : []).filter((s) => {
|
|
1191
|
+
if (!s || typeof s !== "object") return false;
|
|
1192
|
+
if (s.blank === true) return false; // 官方明确标记的空白会话
|
|
1193
|
+
if (s.sessionId === this.currentSessionId) return true; // 当前选中:永远留在列表里
|
|
1194
|
+
if (s.running === true) return true; // 正在跑
|
|
1195
|
+
return Boolean(String(s.title || "").trim()); // 有名字
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** 拉一次会话列表(带标题),并记住 1-based 序号供 /use 使用(列表已按 #usableSessions 过滤)。 */
|
|
1177
1200
|
async #sessions() {
|
|
1178
1201
|
if (!this.subscriber || typeof this.subscriber.listSessions !== "function") return null;
|
|
1179
1202
|
const r = await this.subscriber.listSessions();
|
|
1180
1203
|
if (!r || !r.ok) return null;
|
|
1181
|
-
this.sessionIndex = r.sessions || [];
|
|
1204
|
+
this.sessionIndex = this.#usableSessions(r.sessions || []);
|
|
1182
1205
|
return this.sessionIndex;
|
|
1183
1206
|
}
|
|
1184
1207
|
|
package/docs/telemetry.md
CHANGED
|
@@ -57,7 +57,13 @@
|
|
|
57
57
|
`bridge_started`(bridge 进程拉起)· `bridge_registered`(设备已在中继注册成功 = 真正可用)·
|
|
58
58
|
`tunnel_disconnected` · `first_remote_ok`(首次远程打通)· `plugin_loaded` · `panel_opened` ·
|
|
59
59
|
`harness_restart`(自动重启触发)· `update_started` · `update_failed`(附 `fail_code`)·
|
|
60
|
-
`wechat_bound` / `wechat_unbound
|
|
60
|
+
`wechat_bound` / `wechat_unbound`(微信机器人通道的**绑定态跳变**,语义见下)·
|
|
61
|
+
`mirror_load`(手机镜像页**就绪耗时**;附 `ms` 耗时毫秒、`at` 客户端事件时刻、`outcome` = `ok` / `slow`)
|
|
62
|
+
|
|
63
|
+
> `mirror_load` 是唯一带**数值**的事件:`ms` 由客户端量测(口径见下),服务端把它落在
|
|
64
|
+
> `telemetry_events.value_ms`,`at` 落在 `at_ms`(`created_at` 仍是服务端收包时刻,语义不变)。
|
|
65
|
+
> 量测起点是导航开始(`performance.timeOrigin` 对齐)、终点是页面真正可用;`outcome` 为 `slow`
|
|
66
|
+
> 表示触发了看门狗兜底。**不采集**任何页面内容、URL、设备信息。
|
|
61
67
|
|
|
62
68
|
### 微信机器人通道的绑定 / 解绑(`wechat_bound` / `wechat_unbound`)
|
|
63
69
|
|
package/dsh-setup.mjs
CHANGED
|
@@ -1125,6 +1125,61 @@ function loginRateLimitUntil() {
|
|
|
1125
1125
|
return 0;
|
|
1126
1126
|
}
|
|
1127
1127
|
|
|
1128
|
+
/* ── 守护启动锁:把"一堆守护同时被拉起"的惊群收敛成串行 ─────────────────────────────
|
|
1129
|
+
*
|
|
1130
|
+
* 🔴 2026-09-26 真机事故(dsh web 启动故障的连带伤害):一次重启窗口里同时出现了
|
|
1131
|
+
* **6 个 `dsh-setup.mjs run` 守护 + 6 个 bridge**,全部用同一个 deviceId 去 router 注册,
|
|
1132
|
+
* 互相把对方顶掉 —— bridge 日志刷满
|
|
1133
|
+
* `隧道断开(code=4000, replaced by new connection)` → `2s 后重连` → 再被顶掉,
|
|
1134
|
+
* 手机侧表现为隧道无限抖动、随机不可用。
|
|
1135
|
+
* 成因是 `runBridge()` 的去重是**先读 pid 文件、后写 pid 文件**:N 个实例在同一瞬间启动时,
|
|
1136
|
+
* 每个都读到"还没有别人" → 一起活下来。launchd 的 `com.dshremote.bridge`(KeepAlive) 与
|
|
1137
|
+
* 插件自愈拉起的脱离进程又都会各自拉一个,正好构成惊群。
|
|
1138
|
+
*
|
|
1139
|
+
* 修法:进入去重逻辑**之前**先抢一把原子锁(`wx` = 排他创建)。抢到的人才继续走原来的
|
|
1140
|
+
* supervisor 判定;抢不到的人只在"被监管者"时才稍等(它必须能接管),否则直接让位。
|
|
1141
|
+
* 锁文件带 `pid:时间戳`,超过 STALE 就视为残留(进程被强杀时留下的),可安全抢占 ——
|
|
1142
|
+
* **绝不允许一把陈旧的锁把 bridge 永久挡在门外**。
|
|
1143
|
+
*/
|
|
1144
|
+
const WATCHER_SPAWN_LOCK = ".dsh-watcher-spawn.lock";
|
|
1145
|
+
const SPAWN_LOCK_STALE_MS = 30_000;
|
|
1146
|
+
const SPAWN_LOCK_WAIT_MS = 15_000;
|
|
1147
|
+
|
|
1148
|
+
function tryAcquireSpawnLock() {
|
|
1149
|
+
const f = path.join(CONFIG_DIR, WATCHER_SPAWN_LOCK);
|
|
1150
|
+
try {
|
|
1151
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1152
|
+
const fd = fs.openSync(f, "wx"); // 排他创建:要么拿到,要么 EEXIST
|
|
1153
|
+
fs.writeSync(fd, `${process.pid}:${Date.now()}`);
|
|
1154
|
+
fs.closeSync(fd);
|
|
1155
|
+
return true;
|
|
1156
|
+
} catch {
|
|
1157
|
+
// 已存在:可能是残留(强杀留下的),也可能是真有人在启动
|
|
1158
|
+
try {
|
|
1159
|
+
const [pidStr, tsStr] = String(fs.readFileSync(f, "utf8")).split(":");
|
|
1160
|
+
const pid = Number(pidStr);
|
|
1161
|
+
const ts = Number(tsStr);
|
|
1162
|
+
const stale = !Number.isFinite(ts) || Date.now() - ts > SPAWN_LOCK_STALE_MS || !pidAlive(pid);
|
|
1163
|
+
if (stale) {
|
|
1164
|
+
fs.rmSync(f, { force: true });
|
|
1165
|
+
const fd = fs.openSync(f, "wx");
|
|
1166
|
+
fs.writeSync(fd, `${process.pid}:${Date.now()}`);
|
|
1167
|
+
fs.closeSync(fd);
|
|
1168
|
+
return true;
|
|
1169
|
+
}
|
|
1170
|
+
} catch { /* 读不到/抢不到 → 当作没拿到 */ }
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function releaseSpawnLock() {
|
|
1176
|
+
const f = path.join(CONFIG_DIR, WATCHER_SPAWN_LOCK);
|
|
1177
|
+
try {
|
|
1178
|
+
const owner = Number(String(fs.readFileSync(f, "utf8")).split(":")[0]);
|
|
1179
|
+
if (owner === process.pid) fs.rmSync(f, { force: true }); // 只删自己那把
|
|
1180
|
+
} catch { /* 没锁/已被抢 → 忽略 */ }
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1128
1183
|
// ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
|
|
1129
1184
|
async function runBridge() {
|
|
1130
1185
|
// 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
|
|
@@ -1138,6 +1193,18 @@ async function runBridge() {
|
|
|
1138
1193
|
// 只剩插件面板的「启动」能救回来。
|
|
1139
1194
|
// 被监管者拉起 = 我们才是那个该活下来的实例 → 请游离实例让位,然后接管。
|
|
1140
1195
|
{
|
|
1196
|
+
// 抢启动锁(见上方长注释):把同时被拉起的多个实例串行化,杜绝"大家都读到没有别人"。
|
|
1197
|
+
const supervised = String(process.env.XPC_SERVICE_NAME || "");
|
|
1198
|
+
let locked = tryAcquireSpawnLock();
|
|
1199
|
+
if (!locked && supervised) {
|
|
1200
|
+
// 被监管者必须能接管:等一会儿再抢(游离实例写完 pid 文件就会释放)
|
|
1201
|
+
const deadline = Date.now() + SPAWN_LOCK_WAIT_MS;
|
|
1202
|
+
while (!locked && Date.now() < deadline) { sleepSync(150); locked = tryAcquireSpawnLock(); }
|
|
1203
|
+
}
|
|
1204
|
+
if (!locked) {
|
|
1205
|
+
console.log("[dsh-remote] 另一个守护正在启动(启动锁被占用),本进程直接退出,避免重复实例。");
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1141
1208
|
const other = readPidFile(WATCHER_PID_FILE);
|
|
1142
1209
|
if (other && other !== process.pid && pidAlive(other)) {
|
|
1143
1210
|
const supervisor = String(process.env.XPC_SERVICE_NAME || "");
|
|
@@ -1157,9 +1224,15 @@ async function runBridge() {
|
|
|
1157
1224
|
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
1158
1225
|
} else {
|
|
1159
1226
|
console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
|
|
1227
|
+
releaseSpawnLock();
|
|
1160
1228
|
return;
|
|
1161
1229
|
}
|
|
1162
1230
|
}
|
|
1231
|
+
// 🔒 关键顺序:**先把自己的 pid 落盘,再释放启动锁**。
|
|
1232
|
+
// 否则"释放锁 → 下面 1373 行才 writePidFile"之间又是一个空窗,第二个实例会读到
|
|
1233
|
+
// "没有别人" → 惊群只是被缩小、并没有消除。1373 行那次写入保持原样(它还负责注册退出清理)。
|
|
1234
|
+
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
1235
|
+
releaseSpawnLock();
|
|
1163
1236
|
// ── 回收「上一任留下的 bridge 子进程」────────────────────────────────────
|
|
1164
1237
|
//
|
|
1165
1238
|
// 🔴 真机 bug(2026-09-23 用户实测):一条微信指令**回两条**。
|
|
@@ -1899,10 +1972,50 @@ function normalizePatchBase(patch) {
|
|
|
1899
1972
|
.trimEnd();
|
|
1900
1973
|
}
|
|
1901
1974
|
|
|
1975
|
+
/**
|
|
1976
|
+
* 插件目录里**必须存在且非空**的文件。缺任何一个都说明这次拷贝是"半个插件"。
|
|
1977
|
+
*
|
|
1978
|
+
* 🔴 为什么必须有这一步(2026-09-26 23:46 真实事故):当时是"先 rmSync 整个 dest、再逐文件 cpSync",
|
|
1979
|
+
* 而 dsh 恰好在这个空窗里启动 → `package.json` 已经声明 bundle,但 `cordis.patch.yml` 还没拷到
|
|
1980
|
+
* → dsh 读 overlay 直接 `ENOENT: no such file or directory` 退出,**整个 GUI 起不来**:
|
|
1981
|
+
*
|
|
1982
|
+
* Error: dsh: failed to read overlay
|
|
1983
|
+
* .../node_modules/dsh-remote-web/cordis.patch.yml: ENOENT
|
|
1984
|
+
*
|
|
1985
|
+
* 拷贝完成时间 23:46:05,dsh 报错时间 23:46:03 —— 差 2 秒。
|
|
1986
|
+
* 所以"拷完必须先自检,再切换"不是优化,是**防止宿主起不来**的硬闸门。
|
|
1987
|
+
*/
|
|
1988
|
+
const PLUGIN_REQUIRED_FILES = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
|
|
1989
|
+
|
|
1990
|
+
/** 校验某个插件目录是否"完整可加载";返回缺失/为空的相对路径数组(空数组 = 通过)。 */
|
|
1991
|
+
function missingPluginFiles(dir) {
|
|
1992
|
+
const missing = [];
|
|
1993
|
+
for (const rel of PLUGIN_REQUIRED_FILES) {
|
|
1994
|
+
try {
|
|
1995
|
+
const st = fs.statSync(path.join(dir, rel));
|
|
1996
|
+
if (!st.isFile() || st.size === 0) missing.push(`${rel}(空)`);
|
|
1997
|
+
} catch {
|
|
1998
|
+
missing.push(rel);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
return missing;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1902
2004
|
/**
|
|
1903
2005
|
* 把插件整目录拷贝进 profile 的固定子目录并返回该目录。
|
|
1904
2006
|
* 源(本包 packages/${PLUGIN_ID})可能在 npx 临时缓存里,因此必须拷到 profile 内持久化,
|
|
1905
2007
|
* include 用相对路径直接指向拷贝出的入口,不写依赖、不跑任何包管理器。
|
|
2008
|
+
*
|
|
2009
|
+
* 🔴 **原子覆盖(2026-09-26 事故后的硬要求)**:
|
|
2010
|
+
* 1. 先整份拷到同级的 `.staging-<pid>`,**全程不碰 dest** —— dsh 任何时刻读到的
|
|
2011
|
+
* 要么是完整的旧版、要么是完整的新版;
|
|
2012
|
+
* 2. 拷完**校验**必需文件(见 `PLUGIN_REQUIRED_FILES`),不通过就丢弃 staging、抛错、
|
|
2013
|
+
* **保留旧版可用**;
|
|
2014
|
+
* 3. 再 `renameSync` 原子切换(同目录内 rename 是原子操作,空窗只有两次 syscall 之间,
|
|
2015
|
+
* 相比旧实现"整个拷贝过程都处于半空状态"是数量级的改善);
|
|
2016
|
+
* 4. 切换失败 → 立刻把 backup 改回 dest;成功 → 删 backup。
|
|
2017
|
+
*
|
|
2018
|
+
* 另外清掉上次被强杀留下的 `.staging-*` / `.backup-*`,不留垃圾。
|
|
1906
2019
|
*/
|
|
1907
2020
|
function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
1908
2021
|
// 清理历史名残留目录(≤0.4.9 的 dsh-remote-ui-plugin),避免与新版并存
|
|
@@ -1910,8 +2023,9 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
1910
2023
|
try { fs.rmSync(path.join(profileDir, `${id}-plugin`), { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1911
2024
|
}
|
|
1912
2025
|
const dest = path.join(profileDir, PLUGIN_LOCAL_DIR);
|
|
1913
|
-
|
|
1914
|
-
|
|
2026
|
+
const stage = `${dest}.staging-${process.pid}`;
|
|
2027
|
+
const backup = `${dest}.backup-${process.pid}`;
|
|
2028
|
+
const cpOpts = {
|
|
1915
2029
|
recursive: true,
|
|
1916
2030
|
// 注意:插件源(npx 缓存 / npm 全局)本身就位于某个 node_modules 之下,
|
|
1917
2031
|
// 过滤必须基于“相对插件目录”的路径片段,绝不能整条 src 路径包含判断,
|
|
@@ -1921,7 +2035,42 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
1921
2035
|
const rel = path.relative(pluginDir, src);
|
|
1922
2036
|
return !String(rel).split(path.sep).some((seg) => seg === "node_modules" || seg === ".git" || seg === ".DS_Store");
|
|
1923
2037
|
}
|
|
1924
|
-
}
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
// 0) 清掉上次被强杀(或本次同 pid 重入)留下的临时目录
|
|
2041
|
+
for (const d of [stage, backup]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
2042
|
+
|
|
2043
|
+
// 1) 拷到 staging —— **绝不动 dest**
|
|
2044
|
+
try {
|
|
2045
|
+
fs.cpSync(pluginDir, stage, cpOpts);
|
|
2046
|
+
} catch (e) {
|
|
2047
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2048
|
+
throw new Error(`插件拷贝失败(已保留原目录不动):${e && e.message ? e.message : e}`);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// 2) 自检:必需文件一个都不能少(这一步就是 23:46 那次事故的闸门)
|
|
2052
|
+
const missing = missingPluginFiles(stage);
|
|
2053
|
+
if (missing.length > 0) {
|
|
2054
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2055
|
+
throw new Error(
|
|
2056
|
+
`插件拷贝不完整,缺少必需文件:${missing.join("、")}(已保留原目录不动,避免宿主读 overlay 失败起不来)`
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// 3) 原子切换
|
|
2061
|
+
try {
|
|
2062
|
+
if (fs.existsSync(dest)) fs.renameSync(dest, backup);
|
|
2063
|
+
try {
|
|
2064
|
+
fs.renameSync(stage, dest);
|
|
2065
|
+
} catch (e) {
|
|
2066
|
+
if (fs.existsSync(backup)) { try { fs.renameSync(backup, dest); } catch { /* 尽力回滚 */ } }
|
|
2067
|
+
throw e;
|
|
2068
|
+
}
|
|
2069
|
+
try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2070
|
+
} catch (e) {
|
|
2071
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2072
|
+
throw new Error(`插件切换失败(旧版仍在原位):${e && e.message ? e.message : e}`);
|
|
2073
|
+
}
|
|
1925
2074
|
return dest;
|
|
1926
2075
|
}
|
|
1927
2076
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.18-beta.2",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|