@mrrisega/dsh-remote 0.6.18-beta.1 → 0.6.18-beta.3

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.
@@ -37,6 +37,9 @@ import test from "node:test";
37
37
  import vm from "node:vm";
38
38
  import { injectMobileAdapter } from "../mobile-adapter.mjs";
39
39
 
40
+ /** 适配层源码路径(源码契约类断言直接读它)。 */
41
+ const ADAPTER = new URL("../mobile-adapter.mjs", import.meta.url);
42
+
40
43
  /** 取注入结果里的真实 <script> / <style> 内容(不加壳,原样执行)。 */
41
44
  function injected() {
42
45
  const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
@@ -218,8 +221,8 @@ function zIndexes(css) {
218
221
  // 抽屉规则都带 :not(.dsh-ma-details) 兜底(防止一列被同时标成抽屉与详情列)
219
222
  sidebar: num(/div\.pI_x6G_sidebarCol(?::not\([^)]*\))?,\s*div\.dsh-ma-sidebar(?::not\([^)]*\))?\s*\{[\s\S]{0,500}?z-index:\s*(\d+)/),
220
223
  sidebarOpen: num(/html\.dsh-ma-sidebar-open\s+div\.pI_x6G_sidebarCol(?::not\([^)]*\))?,[\s\S]{0,200}?\{\s*z-index:\s*(\d+)/),
221
- details: num(/div\.pI_x6G_detailsCol,[\s\S]{0,300}?z-index:\s*(\d+)/),
222
224
  hamburger: num(/button\.dsh-ma-hamburger\s*\{[\s\S]{0,700}?z-index:\s*(\d+)/),
225
+ // ❌ 2026-09-27 根治:不再解析 details 的 z-index —— 适配层已不给第三列写任何 CSS。
223
226
  };
224
227
  }
225
228
 
@@ -568,21 +571,47 @@ test("白框:官方属性说「展开」但页面刚载入时不得自动滑
568
571
  assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "没有抽屉 → 遮罩不得拦点击");
569
572
  });
570
573
 
571
- test("白框:官方 收起→展开 的实际切换(用户点了工具行)仍然要能滑入详情", () => {
572
- const { js } = injected();
574
+ test("★ 第三列(官方右栏)的显隐**完全交还官方**:适配层一个字节都不许干预", () => {
575
+ const { js, css } = injected();
573
576
  const env = run(harness({ detailsAttr: "true" }), js);
574
577
  const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
575
578
  assert.ok(obs, "应挂了 frame 属性观察器");
576
- assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "前提:初始为收起");
577
- env.toolRow.dispatchEvent(new Ev("click", {})); // 用户点正文里的工具行
578
- assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "官方此刻还说收起 → 不得弹面板");
579
- env.frame.removeAttribute("data-details-collapsed"); // 官方展开详情(属性被移除)
579
+
580
+ // 反例一:用户点了正文里的工具行(旧实现在这里"解锁"详情列的滑入)
581
+ env.toolRow.dispatchEvent(new Ev("click", {}));
582
+ assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "适配层不得再驱动第三列显隐");
583
+
584
+ // 反例二:官方把属性移除 = 官方自己展开了右栏 —— 我们**既不滑入也不拦截**,只是什么都不做
585
+ env.frame.removeAttribute("data-details-collapsed");
580
586
  obs.fire("data-details-collapsed");
581
- assert.equal(env.html.classList.contains("dsh-ma-details-open"), true,
582
- "用户点过正文 + 官方展开详情 → 必须能滑入(不能为了防白框把功能废掉)");
583
- // 详情列真的滑进视口后,遮罩才允许拦住它背后 —— 正常行为不能被改坏
584
587
  env.doc.dispatchEvent(new Ev("resize", {}));
585
- assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "详情列真的在视口里时,遮罩应正常生效");
588
+ assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
589
+ "官方展开右栏时,适配层不得再插一脚(官方的全屏浮层由官方自己摆)");
590
+ assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false,
591
+ "★ 也**绝对不得**为第三列开遮罩 —— 那正是「点开变整屏黑」的直接成因");
592
+
593
+ // 样式侧:不得给第三列设定位/位移/层级(祖先 transform 会给 fixed 后代创建包含块 → 把官方全屏面板推出屏外)
594
+ // ⚠️ 必须**只看生效的 CSS**:STYLE 是一个模板字符串,注释也是文本,直接扫会命中「历史说明」里的字样。
595
+ const cssCode = String(css).replace(/\/\*[\s\S]*?\*\//g, "");
596
+ assert.ok(!/translateX\(103%\)/.test(cssCode), "★ 不得再把第三列推到屏外(官方窄屏是全屏浮层,会被一起推走)");
597
+ assert.ok(!/dsh-ma-details-open/.test(cssCode), "★ 不得再有 dsh-ma-details-open 驱动的样式");
598
+ assert.equal(/div\.pI_x6G_detailsCol[^{]*\{[^}]*\}/.exec(cssCode), null,
599
+ "★ 适配层不得给第三列写任何 CSS 规则(position/transform/width/z-index 一律不许)");
600
+ assert.equal(/div\.dsh-ma-details[^{]*\{[^}]*\}/.exec(cssCode), null,
601
+ "★ 也不得给 dsh-ma-details 标记写规则");
602
+ });
603
+
604
+ test("★ 遮罩只跟左抽屉走:第三列(右栏)在视口里也绝不让遮罩下闸", () => {
605
+ const { js } = injected();
606
+ const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
607
+ const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
608
+ // 人为把第三列摆进视口(模拟官方自己展开了右栏)
609
+ env.third.getBoundingClientRect = () => ({ left: 40, top: 0, right: 380, bottom: 800, width: 340, height: 800 });
610
+ env.frame.removeAttribute("data-details-collapsed");
611
+ obs.fire("data-details-collapsed");
612
+ env.doc.dispatchEvent(new Ev("resize", {}));
613
+ assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false,
614
+ "★ 第三列在视口里 ≠ 我们要开遮罩:官方右栏(尤其窄屏全屏浮层)不需要我们的遮罩");
586
615
  });
587
616
 
588
617
  test("白框:没有用户动作时官方自己翻转成展开(改名/重渲染)不得弹白框", () => {
@@ -597,38 +626,49 @@ test("白框:没有用户动作时官方自己翻转成展开(改名/重渲
597
626
  assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "同理:遮罩不得跟着拦点击");
598
627
  });
599
628
 
600
- test("白框:详情面板显示后点空白(遮罩)能关掉,且不会自己弹回来", () => {
629
+ test("遮罩点击:只关左抽屉,不碰第三列", () => {
601
630
  const { js } = injected();
602
- const env = run(harness({ detailsAttr: "true" }), js);
603
- const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
604
- env.toolRow.dispatchEvent(new Ev("click", {}));
605
- env.frame.removeAttribute("data-details-collapsed");
606
- obs.fire("data-details-collapsed");
631
+ const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
632
+ const ham = findHamburger(env);
633
+ ham.dispatchEvent(new Ev("click", {}));
607
634
  env.doc.dispatchEvent(new Ev("resize", {}));
608
- assert.equal(env.html.classList.contains("dsh-ma-details-open"), true, "前提:用户点过正文后详情已显示");
635
+ env.html.classList.add("dsh-ma-details-open"); // 人为制造"第三列也开着"的遗留状态
636
+ assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "前提:抽屉展开、遮罩在拦");
609
637
  findScrim(env).dispatchEvent(new Ev("click", {}));
610
- assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "点空白应关掉详情面板");
611
- // 后续任何一次兜底 re-sync 都不得把用户明确关掉的面板又弹回来
612
- env.doc.dispatchEvent(new Ev("resize", {}));
613
- obs.fire("data-rightbar-collapsed");
614
- assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
615
- "用户点空白关掉之后,「要看详情」的意图必须被收回,不得自己弹回");
638
+ assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), false, "点空白应关掉左抽屉");
639
+ assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "遮罩也要撤掉");
640
+ // 我们**根本不设置** dsh-ma-details-open,所以这里断言的是"源码里不许出现它"(见下面的源码契约)。
641
+ // (不要求"清掉" —— 我们连碰都不该碰第三列。)
616
642
  });
617
643
 
618
- test("白框:详情列在视口里时遮罩生效;官方收起来后遮罩立刻撤掉", () => {
644
+ test("★ 源码契约:适配层代码里不许再出现任何「驱动第三列」的符号(去注释后扫)", () => {
645
+ const src = readFileSync(ADAPTER, "utf8");
646
+ // 只看**代码**:STYLE 是模板字符串、注释也是文本,直接扫会命中"历史说明"
647
+ const code = src
648
+ .replace(/\/\*[\s\S]*?\*\//g, "") // 块注释
649
+ .split("\n").map((l) => l.replace(/\/\/(?![^"'\n]*["']).*$/, "")) // 行注释(粗略:不动 http:// 之类)
650
+ .join("\n");
651
+ for (const sym of ["dsh-ma-details-open", "detailsSticky", "centerTouchedAt", "rightPaneWanted",
652
+ "rightPaneHasContent", "detailsAttrLive", "detailsCollapsed", "detailsSeenCollapsed",
653
+ "maRightPaneRequest", "maOpenRightPane", "maRightPaneExpandBtn"]) {
654
+ assert.ok(!code.includes(sym),
655
+ `★ 代码里不得再出现 ${sym} —— 第三列的显隐已完全交还官方(2026-09-27 根治)`);
656
+ }
657
+ });
658
+
659
+ test("★ 回归锁:第三列在视口里 + 官方收起,遮罩都不为它动一下", () => {
619
660
  const { js } = injected();
620
661
  const env = run(harness({ detailsAttr: "true" }), js);
621
662
  const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
622
- env.toolRow.dispatchEvent(new Ev("click", {}));
663
+ env.third.getBoundingClientRect = () => ({ left: 40, top: 0, right: 380, bottom: 800, width: 340, height: 800 });
623
664
  env.frame.removeAttribute("data-details-collapsed");
624
665
  obs.fire("data-details-collapsed");
625
666
  env.doc.dispatchEvent(new Ev("resize", {}));
626
- assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), true, "前提:详情列展开且遮罩生效");
667
+ assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "第三列展开 → 遮罩不为它下闸");
627
668
  env.frame.setAttribute("data-details-collapsed", "true");
628
669
  obs.fire("data-details-collapsed");
629
670
  env.doc.dispatchEvent(new Ev("resize", {}));
630
- assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "官方收起 → 详情 class 撤掉");
631
- assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "官方收起 → 遮罩必须一起撤掉");
671
+ assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "第三列收起 → 遮罩也不为它变化");
632
672
  });
633
673
 
634
674
  test("遮罩不变量:遮罩在拦点击但视口内没有任何可交互抽屉 → 看门狗立刻摘掉", () => {
@@ -664,9 +704,9 @@ test("抽屉可点:抽屉展开时抽屉内点击命中抽屉自身(不被
664
704
  const z = zIndexes(css);
665
705
  assert.ok(z.sidebarOpen > z.scrim,
666
706
  `抽屉展开时的层级(${z.sidebarOpen})必须高于遮罩(${z.scrim}) —— 否则抽屉里所有点击都被遮罩吃掉`);
667
- assert.ok(z.sidebarOpen > z.details,
668
- `抽屉展开时的层级(${z.sidebarOpen})必须高于详情列(${z.details}) —— 详情列在 frame 里排在抽屉之后,同级会压住抽屉`);
669
- assert.ok(z.hamburger > z.details && z.hamburger > z.sidebarOpen,
707
+ // ❌ 2026-09-27 根治:不再断言「抽屉必须高于详情列」 —— 适配层已不给第三列设 z-index,
708
+ // 它回到官方布局里,不再是我们自绘的浮层,也就不可能压住抽屉。
709
+ assert.ok(z.hamburger > z.sidebarOpen,
670
710
  "汉堡按钮必须始终在最上层(判定怎么错都点得到 = 唯一自救出口)");
671
711
 
672
712
  const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
@@ -677,7 +717,7 @@ test("抽屉可点:抽屉展开时抽屉内点击命中抽屉自身(不被
677
717
  const r = env.row.getBoundingClientRect();
678
718
  const hit = hitTest(env, z, r.left + r.width / 2, r.top + r.height / 2);
679
719
  assert.equal(hit, env.row, "抽屉展开时,抽屉内的点击必须落在抽屉内容上");
680
- // 最坏情况:详情列也同时被判成展开 —— 抽屉仍然必须压得住(能在抽屉里点选)
720
+ // 最坏情况:即便有人(旧版本/官方)把 dsh-ma-details-open 挂上,抽屉内的点击也必须有效
681
721
  env.html.classList.add("dsh-ma-details-open");
682
722
  const hit2 = hitTest(env, z, r.left + r.width / 2, r.top + r.height / 2);
683
723
  assert.equal(hit2, env.row, "即便详情列同时展开,抽屉内的点击也必须有效(不能把用户困住)");
@@ -976,31 +1016,40 @@ test("白柱:汉堡按钮不再无条件点官方 toggle(两态不得互相
976
1016
  assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "第二次点击应把抽屉打开(意图生效)");
977
1017
  });
978
1018
 
979
- test("打开右侧栏:官方右栏里有宿主注册的内容时,FAB 菜单那颗按钮必须能把它滑进来", () => {
1019
+ test("★ 官方右栏里有内容时:适配层**不插手**(交还官方 openRightbar),菜单里也不再有「打开右侧栏」按钮", () => {
980
1020
  const { js } = injected();
981
- // 现场(用户报的「右侧按钮区看不见」):better-sidebar 这类插件把 tab 注册进官方右栏,
982
- // 但它只翻自己的 store → 官方 cols.rightbar 永远是 0(data-rightbar-collapsed 恒为 true)。
1021
+ // 现场(用户 2026-09-27 反馈):better-sidebar 把 tab 注册进官方右栏,官方 cols.rightbar 仍为 0。
1022
+ // 旧做法是我们自绘浮层 + 手动按钮 → 祖先 transform 把官方全屏面板推出屏外 + 我们再开遮罩
1023
+ // → 用户看到「整屏一层黑」。
983
1024
  const env = harness({ collapsedAttr: "true", variant: "rc2", detailsAttr: null, rightbarAttr: "true" });
984
- env.third.appendChild(env.doc.createElement("div")); // 官方右栏里确实有内容(插件注册的 tab 面板)
1025
+ env.third.appendChild(env.doc.createElement("div"));
985
1026
  run(env, js);
986
- assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "前提:默认不显示右栏");
987
1027
  const menu = env.body.children.find((c) => c._cls.has("dsh-ma-menu"));
988
1028
  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, "点完应关闭菜单(让用户看见滑入的面板)");
1029
+ assert.equal(menu.querySelector('[data-role="right-pane"]'), null,
1030
+ "★ 菜单里不该再有「打开右侧栏」按钮 —— 官方右栏自己的 tab / 会话头按钮就能开");
1031
+ assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
1032
+ "★ 适配层不得替官方决定右栏显隐");
1033
+ assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "★ 更不得为它开遮罩");
995
1034
  });
996
1035
 
997
- test("打开右侧栏:右栏里没有任何内容时不得滑出一个空白面板(2026-09-19 白框事故的回归)", () => {
1036
+ test("★ 2026-09-19 白框事故的**结构性**回归锁:适配层任何情况下都不滑出第三列", () => {
998
1037
  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
- "官方右栏里什么都没有 → 宁可什么都不发生,也不弹一个空白白面板(那正是白框事故)");
1038
+ // 当年白框的三种触发现场各来一遍,断言"我们什么都不做" ⇒ 白框在结构上不可能再出现
1039
+ for (const opts of [
1040
+ { collapsedAttr: "true", variant: "rc2", detailsAttr: null, rightbarAttr: "true" }, // 官方说收起 + 右栏有内容
1041
+ { collapsedAttr: "true", variant: "rc2", detailsAttr: null, rightbarAttr: null }, // 官方属性说不清 + 有内容
1042
+ { collapsedAttr: "true", detailsAttr: "false" } // 官方说第三列展开(左抽屉仍收起)
1043
+ ]) {
1044
+ const env = harness(opts);
1045
+ env.third.appendChild(env.doc.createElement("div"));
1046
+ run(env, js);
1047
+ env.doc.dispatchEvent(new Ev("resize", {}));
1048
+ assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
1049
+ "适配层不得滑出第三列(白框的结构性杜绝): " + JSON.stringify(opts));
1050
+ assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false,
1051
+ "也不得为它开遮罩: " + JSON.stringify(opts));
1052
+ }
1004
1053
  });
1005
1054
 
1006
1055
  test("打开右侧栏:绝不允许「载入即滑出白框」——必须由用户动作解锁", () => {
@@ -481,13 +481,16 @@ test("悬浮菜单:预览行已彻底删除(DOM 里不得再有 .dsh-ma-menu
481
481
  assert.match(menu.innerHTML, /只影响这台设备的远程页面/, "字号说明文案应保留");
482
482
  });
483
483
 
484
- test("悬浮菜单:新增「网络延迟」一行与「打开右侧栏」按钮(只读文案 + 显式出口)", () => {
484
+ test("悬浮菜单:有「网络延迟」一行(只读文案);**没有**「打开右侧栏」按钮", () => {
485
485
  const env = runAdapter(harness({ width: 390 }));
486
486
  const menu = findMenu(env);
487
487
  assert.ok(menu.querySelector(".dsh-ma-menu-net"), "菜单里应有网络延迟块");
488
488
  assert.match(menu.innerHTML, /网络延迟/, "应有「网络延迟」小标题");
489
489
  assert.ok(menu.querySelector('[data-role="net"]'), "延迟块要带 data-role=net(供渲染函数更新)");
490
- assert.ok(menu.querySelector('[data-role="right-pane"]'), "菜单里应有「打开右侧栏」按钮");
490
+ // ❌ 2026-09-27 根治:那颗按钮已删 —— 官方右栏自己的 tab / 会话头按钮就会调 ctx.layout.openRightbar,
491
+ // 我们再造一个手动入口只会把官方推到相反方向(真机症状:点开变整屏黑遮罩)。
492
+ assert.equal(menu.querySelector('[data-role="right-pane"]'), null,
493
+ "菜单里不该再有「打开右侧栏」按钮(官方右栏自己能开)");
491
494
  });
492
495
 
493
496
  test("网络延迟:只在**已有**的 fetch 包装里采样,成功样本显示成角标(绝不主动测速)", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.18-beta.1",
3
+ "version": "0.6.18-beta.3",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.18-beta.1",
3
+ "version": "0.6.18-beta.3",
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",
@@ -710,7 +710,7 @@
710
710
  /** 生成 window.WebSocket 的替换构造函数:命中策略才套 SeE2eeWs,否则原样放行。 */
711
711
  function seMakeWsCtor(NativeWS, ctx) {
712
712
  function SeWs(url, protocols) {
713
- var full = "", should = false, scoped = String(url);
713
+ var full = "", should = false, scoped = String(url), passUrl = url;
714
714
  try {
715
715
  var base = ctx.origin || (typeof window !== "undefined" ? window.location.href : undefined);
716
716
  var uo = new URL(url, base);
@@ -727,12 +727,19 @@
727
727
  scoped = seScopeToDevice(uo.pathname + uo.search, ctx.deviceId);
728
728
  var so = scoped === (uo.pathname + uo.search) ? uo : new URL(scoped, base);
729
729
  full = so.pathname + so.search;
730
- should = seShouldEncryptWs(full);
730
+ /* ★ 关键:改写结果要**留住**。原来不改写分支 return 的是原始 url,等于把
731
+ `/remote/<dev>/api/remote.mux` 又丢回根路径 —— 那正是 404 的来源。 */
732
+ passUrl = so.href;
733
+ /* ⚠️ 必须同时要求"有会话":URL 改写在无会话时也要做(这是 2026-09-26 事故的正题),
734
+ 但**加密**没有会话就做不了 —— 少了这个判断,未解锁的页面上任何
735
+ `new WebSocket("/api/…")` 都会在 ctx.sess.sessId 上抛 TypeError,
736
+ 把整个页面打挂(比原来"不装补丁"还糟)。 */
737
+ should = !!ctx.sess && seShouldEncryptWs(full);
731
738
  }
732
739
  } catch (e) { should = false; }
733
- if (!should) return new NativeWS(url, protocols);
740
+ if (!should) return new NativeWS(passUrl, protocols);
734
741
  var target = seBuildWsTarget(scoped, ctx.sess.sessId, seRandomHex(8), base);
735
- if (!target) return new NativeWS(url, protocols);
742
+ if (!target) return new NativeWS(passUrl, protocols);
736
743
  return new SeE2eeWs(NativeWS, target.href, protocols, { sess: ctx.sess, wsLabel: target.wsLabel });
737
744
  }
738
745
  SeWs.OPEN = 1; SeWs.CONNECTING = 0; SeWs.CLOSING = 2; SeWs.CLOSED = 3;
@@ -875,9 +882,28 @@
875
882
  + "一直这样请返回设备列表重新解锁(会自动重建加密通道)。");
876
883
  }
877
884
 
878
- /* ---- 安装 fetch/WS 加密补丁(有会话才安装) ---- */
885
+ /* ---- 补丁安装:**URL 改写永远装,加密按会话开关** ----
886
+ *
887
+ * 🔴 2026-09-27 修(P0-1 收尾):原来 fetch/WS 补丁只在"有会话"时安装(`_install(sess)`),
888
+ * 可**这正是它最需要生效的场景**:镜像页没有会话 → 不装补丁 → 官方客户端把 mux WS 拼成根路径
889
+ * `/api/remote.mux` → 中继 `resolveRoute` 只认 `dsh_device` cookie,cookie 过期/缺失就 404
890
+ * (`rejectUpgrade` 写状态行后立刻 destroy → nginx 只看到不透明的 009)→
891
+ * `e2ee-hello` 永远送不到宿主机 → **会话永远建不起来** → 之后每个受保护请求都是
892
+ * `unknown_session` 502。**死循环**:没会话→不装补丁→建不了会话。
893
+ * 生产已有实锤:上线 reject 分支日志后中继立刻打出
894
+ * `WS 升级被拒: reason=route_unresolved status=404 path=/api/remote.mux`。
895
+ *
896
+ * 现在:`_install()` 在 boot 阶段**无条件**安装(`ctx.sess` 先为 null),会话到了再填进去。
897
+ * 补丁里"不改写就原样放行"的分支也会带上改写后的 URL(见 seMakeWsCtor 的 passUrl)——
898
+ * 否则改写算出来又被丢掉,等于没做。
899
+ */
879
900
  var _installed = false;
901
+ var _ctx = null;
880
902
  function _install(sess) {
903
+ if (sess) {
904
+ // 会话后到:只更新 ctx(补丁已装好,闭包读的是同一个对象)
905
+ if (_ctx) { _ctx.sess = sess; return; }
906
+ }
881
907
  if (_installed) return;
882
908
  _installed = true;
883
909
  seFailureHook = _reportFail;
@@ -886,7 +912,8 @@
886
912
  /* deviceId 取自**页面路径**(`/remote/<deviceId>/…`,2026-09-19 起的标准入口):
887
913
  WS 侧用它把根路径的 mux 改写成设备作用域形态,从而不再依赖会过期的 dsh_device cookie
888
914
  (见 seScopeToDevice 的长注释与 2026-09-26 的 nginx 日志证据)。读不到就留空 = 不改写。 */
889
- var ctx = { sess: sess, origin: window.location.origin, deviceId: seDeviceIdOfPath(window.location.pathname) };
915
+ _ctx = { sess: sess || null, origin: window.location.origin, deviceId: seDeviceIdOfPath(window.location.pathname) };
916
+ var ctx = _ctx;
890
917
  try {
891
918
  if (window.fetch) {
892
919
  var origFetch = window.fetch.bind(window);
@@ -906,6 +933,7 @@
906
933
  function _boot() {
907
934
  try {
908
935
  if (!(window.crypto && window.crypto.subtle && window.crypto.getRandomValues)) {
936
+ _install(null);
909
937
  _badge("warn", "未加密:环境不支持", "当前浏览器环境不支持 WebCrypto(需 HTTPS 安全上下文),镜像页数据流保持明文。");
910
938
  return;
911
939
  }
@@ -936,12 +964,16 @@
936
964
  return;
937
965
  }
938
966
  }
967
+ /* ⚠️ 即使没有会话也必须先装补丁:WS 的设备作用域改写与加密无关(见 _install 长注释)。
968
+ 否则这里 return 掉 → mux 打根路径 → 404 → 会话永远建不起来(死循环)。 */
969
+ _install(null);
939
970
  _badge("warn", "未加密:未解锁(明文)", "本次为明文连接。密钥只存内存;请返回设备列表重新点选设备解锁后,镜像页的数据请求与 WebSocket 将自动加密。");
940
971
  return;
941
972
  }
942
973
  var sess = null;
943
974
  try { sess = seSessionOfHandover(raw); } catch (e) { sess = null; }
944
975
  if (!sess) {
976
+ _install(null);
945
977
  _badge("warn", "未加密:交接单无效", "一次性交接单无法解析(可能已损坏或过期)。请返回设备列表重新解锁。");
946
978
  return;
947
979
  }