@mrrisega/dsh-remote 0.6.18-beta.2 → 0.6.18-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 +84 -185
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +124 -50
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +25 -4
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +4 -2
- package/packages/dsh-remote-web/lib/index.js +96 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +84 -185
- package/packages/dsh-remote-web/test/local-route-guard.test.mjs +86 -0
- package/packages/dsh-remote-web/test/upstream-url-guard.test.mjs +49 -0
|
@@ -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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
env.
|
|
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");
|
|
586
|
+
obs.fire("data-details-collapsed");
|
|
587
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
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");
|
|
580
611
|
obs.fire("data-details-collapsed");
|
|
581
|
-
assert.equal(env.html.classList.contains("dsh-ma-details-open"), true,
|
|
582
|
-
"用户点过正文 + 官方展开详情 → 必须能滑入(不能为了防白框把功能废掉)");
|
|
583
|
-
// 详情列真的滑进视口后,遮罩才允许拦住它背后 —— 正常行为不能被改坏
|
|
584
612
|
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
585
|
-
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"),
|
|
613
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false,
|
|
614
|
+
"★ 第三列在视口里 ≠ 我们要开遮罩:官方右栏(尤其窄屏全屏浮层)不需要我们的遮罩");
|
|
586
615
|
});
|
|
587
616
|
|
|
588
617
|
test("白框:没有用户动作时官方自己翻转成展开(改名/重渲染)不得弹白框", () => {
|
|
@@ -597,38 +626,74 @@ 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
|
|
604
|
-
|
|
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
|
-
|
|
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-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
+
// (不要求"清掉" —— 我们连碰都不该碰第三列。)
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("★ ③ CSS 打底:JS 没跑(或中途挂了)时,页面必须是官方原样、绝不被困住", () => {
|
|
645
|
+
// 这是「③ 纯 CSS 打底」唯一可被机器验证的硬性质:
|
|
646
|
+
// 适配层的**所有**劫持(把官方列改成浮层/移出屏外/隐藏/拦截点击)都必须挂在
|
|
647
|
+
// 我们自己 add 的 `dsh-ma-*` 类上 —— 那些类只有 JS 跑起来才会有。
|
|
648
|
+
// 于是"JS 没执行/中途抛错"⇒ 一个劫持类都不存在 ⇒ 页面就是官方原样。
|
|
649
|
+
// 反过来,任何**不依赖我们类名**就去 translate/隐藏官方内容列的规则,都是"JS 挂了也会困住用户"的地雷。
|
|
650
|
+
const { css } = injected();
|
|
651
|
+
const code = String(css).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
652
|
+
const OFFICIAL = /pI_x6G_|hHd-Xa_|VOzbGW_|YDXeBa_|_2ctAZa_|P3OORG_|wSkVaW_|uV2eYG_/;
|
|
653
|
+
const HIJACK = /(transform\s*:\s*[^;]*translate|visibility\s*:\s*hidden|display\s*:\s*none|pointer-events\s*:\s*none|translateX\s*\()/;
|
|
654
|
+
const bad = [];
|
|
655
|
+
for (const chunk of code.split("}")) {
|
|
656
|
+
const i = chunk.lastIndexOf("{");
|
|
657
|
+
if (i < 0) continue;
|
|
658
|
+
const sel = chunk.slice(0, i).replace(/\s+/g, " ").trim();
|
|
659
|
+
const body = chunk.slice(i);
|
|
660
|
+
if (!OFFICIAL.test(sel)) continue; // 只关心"碰官方元素"的规则
|
|
661
|
+
if (/dsh-ma-/.test(sel)) continue; // 有我们的类做闸门 ⇒ JS 不跑就不会生效
|
|
662
|
+
if (/::/.test(sel)) continue; // 伪元素(如 ::-webkit-scrollbar)困不住人,不算劫持
|
|
663
|
+
if (HIJACK.test(body)) bad.push(sel.slice(0, 120));
|
|
664
|
+
}
|
|
665
|
+
assert.deepEqual(bad, [],
|
|
666
|
+
"★ 这些规则没有 dsh-ma-* 闸门却会移动/隐藏/拦截官方元素 —— JS 一旦不跑,用户就被困住了");
|
|
616
667
|
});
|
|
617
668
|
|
|
618
|
-
test("
|
|
669
|
+
test("★ 源码契约:适配层代码里不许再出现任何「驱动第三列」的符号(去注释后扫)", () => {
|
|
670
|
+
const src = readFileSync(ADAPTER, "utf8");
|
|
671
|
+
// 只看**代码**:STYLE 是模板字符串、注释也是文本,直接扫会命中"历史说明"
|
|
672
|
+
const code = src
|
|
673
|
+
.replace(/\/\*[\s\S]*?\*\//g, "") // 块注释
|
|
674
|
+
.split("\n").map((l) => l.replace(/\/\/(?![^"'\n]*["']).*$/, "")) // 行注释(粗略:不动 http:// 之类)
|
|
675
|
+
.join("\n");
|
|
676
|
+
for (const sym of ["dsh-ma-details-open", "detailsSticky", "centerTouchedAt", "rightPaneWanted",
|
|
677
|
+
"rightPaneHasContent", "detailsAttrLive", "detailsCollapsed", "detailsSeenCollapsed",
|
|
678
|
+
"maRightPaneRequest", "maOpenRightPane", "maRightPaneExpandBtn"]) {
|
|
679
|
+
assert.ok(!code.includes(sym),
|
|
680
|
+
`★ 代码里不得再出现 ${sym} —— 第三列的显隐已完全交还官方(2026-09-27 根治)`);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("★ 回归锁:第三列在视口里 + 官方收起,遮罩都不为它动一下", () => {
|
|
619
685
|
const { js } = injected();
|
|
620
686
|
const env = run(harness({ detailsAttr: "true" }), js);
|
|
621
687
|
const obs = MutationObserverStub.instances.find((o) => o.targets.some((t) => t.t === env.frame));
|
|
622
|
-
env.
|
|
688
|
+
env.third.getBoundingClientRect = () => ({ left: 40, top: 0, right: 380, bottom: 800, width: 340, height: 800 });
|
|
623
689
|
env.frame.removeAttribute("data-details-collapsed");
|
|
624
690
|
obs.fire("data-details-collapsed");
|
|
625
691
|
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
626
|
-
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"),
|
|
692
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "第三列展开 → 遮罩不为它下闸");
|
|
627
693
|
env.frame.setAttribute("data-details-collapsed", "true");
|
|
628
694
|
obs.fire("data-details-collapsed");
|
|
629
695
|
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
630
|
-
assert.equal(env.html.classList.contains("dsh-ma-
|
|
631
|
-
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "官方收起 → 遮罩必须一起撤掉");
|
|
696
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "第三列收起 → 遮罩也不为它变化");
|
|
632
697
|
});
|
|
633
698
|
|
|
634
699
|
test("遮罩不变量:遮罩在拦点击但视口内没有任何可交互抽屉 → 看门狗立刻摘掉", () => {
|
|
@@ -664,9 +729,9 @@ test("抽屉可点:抽屉展开时抽屉内点击命中抽屉自身(不被
|
|
|
664
729
|
const z = zIndexes(css);
|
|
665
730
|
assert.ok(z.sidebarOpen > z.scrim,
|
|
666
731
|
`抽屉展开时的层级(${z.sidebarOpen})必须高于遮罩(${z.scrim}) —— 否则抽屉里所有点击都被遮罩吃掉`);
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
assert.ok(z.hamburger > z.
|
|
732
|
+
// ❌ 2026-09-27 根治:不再断言「抽屉必须高于详情列」 —— 适配层已不给第三列设 z-index,
|
|
733
|
+
// 它回到官方布局里,不再是我们自绘的浮层,也就不可能压住抽屉。
|
|
734
|
+
assert.ok(z.hamburger > z.sidebarOpen,
|
|
670
735
|
"汉堡按钮必须始终在最上层(判定怎么错都点得到 = 唯一自救出口)");
|
|
671
736
|
|
|
672
737
|
const env = run(harness({ collapsedAttr: "true", detailsAttr: "true" }), js);
|
|
@@ -677,7 +742,7 @@ test("抽屉可点:抽屉展开时抽屉内点击命中抽屉自身(不被
|
|
|
677
742
|
const r = env.row.getBoundingClientRect();
|
|
678
743
|
const hit = hitTest(env, z, r.left + r.width / 2, r.top + r.height / 2);
|
|
679
744
|
assert.equal(hit, env.row, "抽屉展开时,抽屉内的点击必须落在抽屉内容上");
|
|
680
|
-
//
|
|
745
|
+
// 最坏情况:即便有人(旧版本/官方)把 dsh-ma-details-open 挂上,抽屉内的点击也必须有效
|
|
681
746
|
env.html.classList.add("dsh-ma-details-open");
|
|
682
747
|
const hit2 = hitTest(env, z, r.left + r.width / 2, r.top + r.height / 2);
|
|
683
748
|
assert.equal(hit2, env.row, "即便详情列同时展开,抽屉内的点击也必须有效(不能把用户困住)");
|
|
@@ -976,31 +1041,40 @@ test("白柱:汉堡按钮不再无条件点官方 toggle(两态不得互相
|
|
|
976
1041
|
assert.equal(env.html.classList.contains("dsh-ma-sidebar-open"), true, "第二次点击应把抽屉打开(意图生效)");
|
|
977
1042
|
});
|
|
978
1043
|
|
|
979
|
-
test("
|
|
1044
|
+
test("★ 官方右栏里有内容时:适配层**不插手**(交还官方 openRightbar),菜单里也不再有「打开右侧栏」按钮", () => {
|
|
980
1045
|
const { js } = injected();
|
|
981
|
-
//
|
|
982
|
-
//
|
|
1046
|
+
// 现场(用户 2026-09-27 反馈):better-sidebar 把 tab 注册进官方右栏,官方 cols.rightbar 仍为 0。
|
|
1047
|
+
// 旧做法是我们自绘浮层 + 手动按钮 → 祖先 transform 把官方全屏面板推出屏外 + 我们再开遮罩
|
|
1048
|
+
// → 用户看到「整屏一层黑」。
|
|
983
1049
|
const env = harness({ collapsedAttr: "true", variant: "rc2", detailsAttr: null, rightbarAttr: "true" });
|
|
984
|
-
env.third.appendChild(env.doc.createElement("div"));
|
|
1050
|
+
env.third.appendChild(env.doc.createElement("div"));
|
|
985
1051
|
run(env, js);
|
|
986
|
-
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false, "前提:默认不显示右栏");
|
|
987
1052
|
const menu = env.body.children.find((c) => c._cls.has("dsh-ma-menu"));
|
|
988
1053
|
assert.ok(menu, "应有悬浮菜单");
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
assert.equal(menu.hidden, true, "点完应关闭菜单(让用户看见滑入的面板)");
|
|
1054
|
+
assert.equal(menu.querySelector('[data-role="right-pane"]'), null,
|
|
1055
|
+
"★ 菜单里不该再有「打开右侧栏」按钮 —— 官方右栏自己的 tab / 会话头按钮就能开");
|
|
1056
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
|
|
1057
|
+
"★ 适配层不得替官方决定右栏显隐");
|
|
1058
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "★ 更不得为它开遮罩");
|
|
995
1059
|
});
|
|
996
1060
|
|
|
997
|
-
test("
|
|
1061
|
+
test("★ 2026-09-19 白框事故的**结构性**回归锁:适配层任何情况下都不滑出第三列", () => {
|
|
998
1062
|
const { js } = injected();
|
|
999
|
-
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
"
|
|
1063
|
+
// 当年白框的三种触发现场各来一遍,断言"我们什么都不做" ⇒ 白框在结构上不可能再出现
|
|
1064
|
+
for (const opts of [
|
|
1065
|
+
{ collapsedAttr: "true", variant: "rc2", detailsAttr: null, rightbarAttr: "true" }, // 官方说收起 + 右栏有内容
|
|
1066
|
+
{ collapsedAttr: "true", variant: "rc2", detailsAttr: null, rightbarAttr: null }, // 官方属性说不清 + 有内容
|
|
1067
|
+
{ collapsedAttr: "true", detailsAttr: "false" } // 官方说第三列展开(左抽屉仍收起)
|
|
1068
|
+
]) {
|
|
1069
|
+
const env = harness(opts);
|
|
1070
|
+
env.third.appendChild(env.doc.createElement("div"));
|
|
1071
|
+
run(env, js);
|
|
1072
|
+
env.doc.dispatchEvent(new Ev("resize", {}));
|
|
1073
|
+
assert.equal(env.html.classList.contains("dsh-ma-details-open"), false,
|
|
1074
|
+
"适配层不得滑出第三列(白框的结构性杜绝): " + JSON.stringify(opts));
|
|
1075
|
+
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false,
|
|
1076
|
+
"也不得为它开遮罩: " + JSON.stringify(opts));
|
|
1077
|
+
}
|
|
1004
1078
|
});
|
|
1005
1079
|
|
|
1006
1080
|
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
|
-
|
|
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 () => {
|
|
@@ -506,12 +509,30 @@ test("网络延迟:只在**已有**的 fetch 包装里采样,成功样本显
|
|
|
506
509
|
assert.equal(calls.length, 1, "适配层不得额外发探测请求(只包装、不加请求)");
|
|
507
510
|
assert.equal(rtt.hidden, false, "有成功样本后角标应出现");
|
|
508
511
|
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 上色");
|
|
512
|
+
assert.ok(["ok", "mid", "slow", "bad"].includes(rtt.getAttribute("data-rtt")), "角标应带分档(ok/mid/slow/bad)供 CSS 上色");
|
|
510
513
|
assert.equal(rtt.hidden === false, true);
|
|
511
514
|
// 菜单里的那一行也要跟着更新(同一个 renderRtt)
|
|
512
515
|
const menu = findMenu(env);
|
|
513
516
|
const net = menu.querySelector(".dsh-ma-menu-net");
|
|
514
|
-
assert.ok(["ok", "mid", "bad"].includes(net.getAttribute("data-rtt")), "菜单行也应按同一分档着色");
|
|
517
|
+
assert.ok(["ok", "mid", "slow", "bad"].includes(net.getAttribute("data-rtt")), "菜单行也应按同一分档着色");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("网络延迟分档:500ms 以内=顺畅、1000ms 以内=正常(远程文本窗口的判定尺度,不得再苛)", async () => {
|
|
521
|
+
// 用户 2026-09-27 明确:这类远程访问的文本窗口,500ms 以内不叫事,1000ms 用起来也正常流畅。
|
|
522
|
+
// 这条锁把「顺畅/正常/偏慢/很慢」的边界钉死,防止以后再被改苛。
|
|
523
|
+
const cases = [[5, "ok"], [600, "mid"], [1500, "slow"], [2600, "bad"]];
|
|
524
|
+
for (const [delay, want] of cases) {
|
|
525
|
+
const env = harness({
|
|
526
|
+
width: 390,
|
|
527
|
+
fetchImpl: async () => { await new Promise((r) => setTimeout(r, delay)); return { status: 200, ok: true }; },
|
|
528
|
+
});
|
|
529
|
+
runAdapter(env);
|
|
530
|
+
const rtt = findFab(env).querySelector('[data-role="rtt"]');
|
|
531
|
+
await env.win.fetch("/api/session/list");
|
|
532
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
533
|
+
assert.equal(rtt.getAttribute("data-rtt"), want,
|
|
534
|
+
`≈${delay}ms 应判为 ${want},实际 ${rtt.getAttribute("data-rtt")}(显示 ${rtt.textContent})`);
|
|
535
|
+
}
|
|
515
536
|
});
|
|
516
537
|
|
|
517
538
|
test("网络延迟:无样本 / 失败请求绝不显示 0ms(显示「测速中…」是唯一正确的空态)", async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.18-beta.
|
|
3
|
+
"version": "0.6.18-beta.4",
|
|
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",
|
|
@@ -1167,12 +1167,14 @@ window.__ModuleLoader__.load({
|
|
|
1167
1167
|
/**
|
|
1168
1168
|
* 延迟 → 人话。
|
|
1169
1169
|
* 🔒 无样本时必须说「测速中…」而**不是 0ms**:0ms 是句谎话(这个页面根本还没量过),
|
|
1170
|
-
* 而"测速中"如实表达了"还没有样本"
|
|
1170
|
+
* 而"测速中"如实表达了"还没有样本"。
|
|
1171
|
+
* 分档(2026-09-27 按业主口径放宽):≤500 顺畅 / ≤1000 正常 / ≤2000 偏慢 / >2000 很慢 ——
|
|
1172
|
+
* 文本窗口的远程操作,500ms 内都算顺畅、1000ms 内正常流畅;旧 120/300 会把正常网络误报成"较慢"。
|
|
1171
1173
|
*/
|
|
1172
1174
|
function netLatText() {
|
|
1173
1175
|
if (!netLat.samples) return "测速中…";
|
|
1174
1176
|
var ms = Math.max(1, Math.round(netLat.ms));
|
|
1175
|
-
var tone = ms <=
|
|
1177
|
+
var tone = ms <= 500 ? "顺畅" : ms <= 1000 ? "正常" : ms <= 2000 ? "偏慢" : "很慢";
|
|
1176
1178
|
return "网络延迟 " + ms + "ms · " + tone;
|
|
1177
1179
|
}
|
|
1178
1180
|
|
|
@@ -2884,13 +2884,45 @@ async function feedbackAuthToken(relayDir) {
|
|
|
2884
2884
|
else fbTokenCache = { token: "", exp: 0 };
|
|
2885
2885
|
return t;
|
|
2886
2886
|
}
|
|
2887
|
+
/**
|
|
2888
|
+
* 把「请求路径后缀」安全地拼到上游基址上(2026-09-27 安全审计 S4-C0)。
|
|
2889
|
+
*
|
|
2890
|
+
* ⚠️ 为什么不能只 `new URL(suffix, base)`:`suffix` 来自**请求路径**,而
|
|
2891
|
+
* `new URL("//evil.com/x", base)` 是**协议相对 URL** —— origin 直接跳到 evil.com。
|
|
2892
|
+
* 实测逃逸形态是**三斜杠**:请求 `/dsh-remote/feedback///evil.com/collect`,
|
|
2893
|
+
* 路由前缀匹配通过(pathname 原样保留三斜杠),旧代码只 `replace(/^\//,"")` 剥掉一个,
|
|
2894
|
+
* 于是剩下 `//evil.com/collect` → 解析成 `https://evil.com/collect`,
|
|
2895
|
+
* 而 proxyFeedback 会**自动带上本机账号 JWT + 真实手机号 + device_id** ——
|
|
2896
|
+
* 攻击者凭这个 JWT 就能签发设备访问密钥、配对到自己手机上 = 用户电脑被完整接管。
|
|
2897
|
+
* 两斜杠/`%2f`/`..` 都被限制在中继域内,**唯独三斜杠会逃逸**。
|
|
2898
|
+
*
|
|
2899
|
+
* 两道断言(不是二选一,缺一不可):
|
|
2900
|
+
* ① origin 必须与基址一致 → 挡住协议相对/绝对 URL 逃逸;
|
|
2901
|
+
* ② pathname 必须以基址路径为前缀 → 挡住 `..` 逃出基址路径(如 /relay-api/ → /api/)。
|
|
2902
|
+
* 任何一条不过就抛,调用方按 4xx/5xx 处理,**绝不在这种情况下把凭据发出去**。
|
|
2903
|
+
*/
|
|
2904
|
+
export function safeUpstreamUrl(base, suffix) {
|
|
2905
|
+
const b = new URL(base);
|
|
2906
|
+
const u = new URL(String(suffix == null ? "" : suffix).replace(/^\/+/, ""), b);
|
|
2907
|
+
if (u.origin !== b.origin) throw new Error(`上游地址逃逸(origin): ${u.origin} ≠ ${b.origin}`);
|
|
2908
|
+
const prefix = b.pathname.endsWith("/") ? b.pathname : `${b.pathname}/`;
|
|
2909
|
+
if (!u.pathname.startsWith(prefix)) throw new Error(`上游路径逃逸(path): ${u.pathname} 不在 ${prefix} 之下`);
|
|
2910
|
+
return u;
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2887
2913
|
async function proxyFeedback(relayDir, req, res, pathname) {
|
|
2888
2914
|
const cfg = loadConfig(relayDir);
|
|
2889
2915
|
const api = feedbackApiOf(cfg);
|
|
2890
2916
|
const suffix = pathname.replace(/^\/dsh-remote\/feedback/, "") || "/";
|
|
2891
|
-
// 相对路径解析:保留基址的路径前缀(如 /relay-api),避免 new URL 绝对路径吞掉 base path
|
|
2917
|
+
// 相对路径解析:保留基址的路径前缀(如 /relay-api),避免 new URL 绝对路径吞掉 base path。
|
|
2918
|
+
// 必须走 safeUpstreamUrl:拼错的/恶意的后缀会带着账号 JWT 跑到别的域去(S4-C0)。
|
|
2892
2919
|
const base = api.endsWith("/") ? api : `${api}/`;
|
|
2893
|
-
|
|
2920
|
+
let url;
|
|
2921
|
+
try {
|
|
2922
|
+
url = safeUpstreamUrl(base, suffix);
|
|
2923
|
+
} catch (e) {
|
|
2924
|
+
return sendJson(res, 400, { ok: false, error: `非法的反馈接口路径: ${e.message}` });
|
|
2925
|
+
}
|
|
2894
2926
|
const headers = {
|
|
2895
2927
|
"x-dsh-device": cfg.device_id || "",
|
|
2896
2928
|
"x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
|
|
@@ -5654,6 +5686,53 @@ async function sendWeChatProxy(relayDir, res, routePath, init) {
|
|
|
5654
5686
|
return sendJson(res, r.status, { ok: false, code: r.code, error: r.error });
|
|
5655
5687
|
}
|
|
5656
5688
|
|
|
5689
|
+
// ============================================================
|
|
5690
|
+
// 本地宿主路由的跨站防护(2026-09-27 安全审计)
|
|
5691
|
+
//
|
|
5692
|
+
// 背景:本插件把 /dsh-remote/* 注册在**本机** dsh web 上,这一层没有任何鉴权
|
|
5693
|
+
// (设计如此:桌面端面板与页面同源,靠"只有本机能访问"来保证安全)。
|
|
5694
|
+
// 但"只有本机能访问"不等于"同源" —— 浏览器里任何网页都能向 127.0.0.1:3080 发请求:
|
|
5695
|
+
// · 经典 CSRF:evil.com 表单/fetch(no-cors) POST /dsh-remote/self/uninstall、/config
|
|
5696
|
+
// · DNS rebinding:把 evil.com 解析到 127.0.0.1,浏览器视为同源,于是能**读到**
|
|
5697
|
+
// /dsh-remote/diag/bundle(诊断包,含本机路径与配置)与
|
|
5698
|
+
// /dsh-remote/access-key(能签发设备访问密钥 = 拿到这台电脑的远程控制权)
|
|
5699
|
+
// 这些路由里有卸载插件、改配置、重启 harness、签发访问密钥,必须挡住。
|
|
5700
|
+
//
|
|
5701
|
+
// 判据(两条独立):
|
|
5702
|
+
// ① Host 白名单:只认 loopback authority。rebinding 时 Host 是攻击者域名 → 拒;
|
|
5703
|
+
// ② Origin / Sec-Fetch-Site:带 Origin 且与 Host 不同源 → 拒;sec-fetch-site: cross-site → 拒。
|
|
5704
|
+
//
|
|
5705
|
+
// 为什么不会误伤手机端:隧道请求由 dsh-bridge 剥掉 host/origin/sec-fetch-*
|
|
5706
|
+
// (clients/dsh-remote/dsh-bridge.mjs 的 STRIP_REQ_HEADERS),再由 undici 补上
|
|
5707
|
+
// Host: 127.0.0.1:<port>。所以隧道流量天然过 ①、且不带 Origin 而过 ②。
|
|
5708
|
+
//
|
|
5709
|
+
// 逃生舱:DSH_REMOTE_LOCAL_GUARD=0 关闭整层;
|
|
5710
|
+
// DSH_REMOTE_TRUSTED_HOSTS=a.com,b(放在 dsh web 前面的反向代理需要它)。
|
|
5711
|
+
// ============================================================
|
|
5712
|
+
const LOOPBACK_HOST_RE = /^(127\.0\.0\.1|localhost|\[::1\]|::1)(:\d+)?$/i;
|
|
5713
|
+
const LOCAL_GUARD_LOGGED = new Map(); // reason -> 上次告警时间(同一原因 60s 只报一次,避免刷日志)
|
|
5714
|
+
|
|
5715
|
+
/** 只读地判断一个请求是否来自"本机同源页面"。返回 null=放行,否则返回拒绝原因。
|
|
5716
|
+
* 导出仅为可测(见 test/local-route-guard.test.mjs)。 */
|
|
5717
|
+
export function localRequestBlocked(req) {
|
|
5718
|
+
if (String(process.env.DSH_REMOTE_LOCAL_GUARD || "") === "0") return null;
|
|
5719
|
+
const extra = String(process.env.DSH_REMOTE_TRUSTED_HOSTS || "")
|
|
5720
|
+
.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
5721
|
+
const headers = (req && req.headers) || {};
|
|
5722
|
+
const host = String(headers.host || "").trim().toLowerCase();
|
|
5723
|
+
if (!host) return "missing-host";
|
|
5724
|
+
if (!LOOPBACK_HOST_RE.test(host) && !extra.includes(host)) return "bad-host";
|
|
5725
|
+
const origin = String(headers.origin || "").trim().toLowerCase();
|
|
5726
|
+
// Origin: null(file:// 或 sandbox iframe)不在这里判,交给下面的 sec-fetch-site
|
|
5727
|
+
if (origin && origin !== "null") {
|
|
5728
|
+
let oh = "";
|
|
5729
|
+
try { oh = new URL(origin).host.toLowerCase(); } catch { return "bad-origin"; }
|
|
5730
|
+
if (oh !== host && !extra.includes(oh)) return "origin-mismatch";
|
|
5731
|
+
}
|
|
5732
|
+
if (String(headers["sec-fetch-site"] || "").trim().toLowerCase() === "cross-site") return "cross-site";
|
|
5733
|
+
return null;
|
|
5734
|
+
}
|
|
5735
|
+
|
|
5657
5736
|
function registerRoutes(ctx, relayDir) {
|
|
5658
5737
|
// 本插件所在 profile(由插件自身文件位置推导,覆盖市场 git 安装与本地 include 两种形态)
|
|
5659
5738
|
let profileDir = join(homedir(), ".dsh", "profiles", "web");
|
|
@@ -6390,6 +6469,21 @@ function registerRoutes(ctx, relayDir) {
|
|
|
6390
6469
|
res.end();
|
|
6391
6470
|
return;
|
|
6392
6471
|
}
|
|
6472
|
+
// 跨站防护:详见 localRequestBlocked 的注释(DNS rebinding / CSRF)
|
|
6473
|
+
const blocked = localRequestBlocked(req);
|
|
6474
|
+
if (blocked) {
|
|
6475
|
+
const now = Date.now();
|
|
6476
|
+
const last = LOCAL_GUARD_LOGGED.get(blocked) || 0;
|
|
6477
|
+
if (now - last > 60_000) {
|
|
6478
|
+
LOCAL_GUARD_LOGGED.set(blocked, now);
|
|
6479
|
+
ctx.logger?.warn?.(
|
|
6480
|
+
`dsh-remote-web: 已拦截跨站请求 ${route.method} ${route.path}(${blocked})` +
|
|
6481
|
+
` host=${req.headers?.host || "-"} origin=${req.headers?.origin || "-"}`
|
|
6482
|
+
);
|
|
6483
|
+
}
|
|
6484
|
+
sendJson(res, 403, { ok: false, error: "forbidden", reason: blocked });
|
|
6485
|
+
return;
|
|
6486
|
+
}
|
|
6393
6487
|
Promise.resolve(route.handler(req, res)).catch((e) => {
|
|
6394
6488
|
ctx.logger?.warn?.(`dsh-remote-web: ${route.method} ${route.path} failed: ${e?.stack || e}`);
|
|
6395
6489
|
if (!res.headersSent) sendJson(res, 500, { ok: false, error: String(e?.message || e) });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.18-beta.
|
|
3
|
+
"version": "0.6.18-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",
|