@mrrisega/dsh-remote 0.6.7-beta.4 → 0.6.8

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.
@@ -259,8 +259,9 @@ html[data-dsh-ma-fs="3"] { --dsh-ma-fs-scale: 1.32; -webkit-text-size-adjust: 13
259
259
  用户至少还能从这里"返回设备列表"自救(这是 2026-09-19 死局的兜底出口)。 */
260
260
  button.dsh-ma-fab {
261
261
  position: fixed;
262
- right: max(10px, env(safe-area-inset-right));
263
- bottom: calc(16px + env(safe-area-inset-bottom));
262
+ /* 位置由 JS 计算后写 inline left/top(默认右上角,可拖动;见 SCRIPT 里的 fabPos);
263
+ CSS 只给尺寸/层级/指针语义 —— 用 left/top 而不是 right/bottom,拖动才不会被"贴边"钉住。 */
264
+ left: auto; top: auto; right: 10px; bottom: auto;
264
265
  z-index: 400;
265
266
  width: 46px; height: 46px; padding: 0;
266
267
  display: flex; align-items: center; justify-content: center;
@@ -270,10 +271,12 @@ button.dsh-ma-fab {
270
271
  border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.3));
271
272
  box-shadow: 0 2px 12px rgba(0,0,0,.2);
272
273
  color: var(--dsw-alias-label-primary, #111);
273
- cursor: pointer; touch-action: manipulation;
274
+ cursor: grab; touch-action: none; /* 触屏拖动不被页面滚动抢走 */
275
+ user-select: none; -webkit-user-select: none;
274
276
  -webkit-tap-highlight-color: transparent;
275
277
  }
276
278
  button.dsh-ma-fab:active { transform: scale(.94); }
279
+ button.dsh-ma-fab.dsh-ma-fab-dragging { cursor: grabbing; transform: scale(1.06); }
277
280
  button.dsh-ma-fab .dsh-ma-fab-lock {
278
281
  position: absolute; top: -3px; right: -3px;
279
282
  font-size: 12px; line-height: 1; padding: 2px 3px; border-radius: 999px;
@@ -283,6 +286,7 @@ button.dsh-ma-fab .dsh-ma-fab-lock {
283
286
  /* 菜单卡片:fixed 贴右下,最大高度受限可滚动 */
284
287
  div.dsh-ma-menu {
285
288
  position: fixed;
289
+ /* 打开时按悬浮按钮的位置重算(按钮在上半屏→菜单挂下方;靠左→左对齐),见 placeMenu() */
286
290
  right: max(10px, env(safe-area-inset-right));
287
291
  bottom: calc(70px + env(safe-area-inset-bottom));
288
292
  z-index: 401;
@@ -688,11 +692,64 @@ const SCRIPT = `(() => {
688
692
  '<div class="dsh-ma-menu-note">返回后可重新选择设备;端到端加密需在设备列表页重新解锁(会话密钥只存在本机内存里,刷新即失效)。</div>';
689
693
  document.body.appendChild(menu);
690
694
 
695
+ /* ---- 悬浮按钮的位置:默认**右上角**(原来在右下角,正好压住输入框的发送按钮);
696
+ 可拖动,位置按"最近的角 + 距边偏移"记忆 —— 旋转屏幕/换窗口宽度都不会跑到屏幕外。 ---- */
697
+ const FAB_KEY = "dsh-ma-fab-pos";
698
+ const FAB_SIZE = 46, FAB_EDGE = 10;
699
+ const readFabPos = () => {
700
+ try {
701
+ const raw = window.localStorage && window.localStorage.getItem(FAB_KEY);
702
+ const p = raw ? JSON.parse(raw) : null;
703
+ if (p && (p.h === "left" || p.h === "right") && (p.v === "top" || p.v === "bottom")
704
+ && typeof p.dx === "number" && typeof p.dy === "number") return p;
705
+ } catch (e) { /* 忽略 */ }
706
+ return null;
707
+ };
708
+ const defaultFabPos = () => ({ h: "right", v: "top", dx: FAB_EDGE, dy: FAB_EDGE });
709
+ let fabPos = readFabPos() || defaultFabPos();
710
+ const fabXY = (pos) => {
711
+ const vw = window.innerWidth || 360, vh = window.innerHeight || 640;
712
+ return {
713
+ left: pos.h === "left" ? pos.dx : vw - FAB_SIZE - pos.dx,
714
+ top: pos.v === "top" ? pos.dy : vh - FAB_SIZE - pos.dy,
715
+ };
716
+ };
717
+ const clampFabXY = (xy) => {
718
+ const vw = window.innerWidth || 360, vh = window.innerHeight || 640;
719
+ return {
720
+ left: Math.max(4, Math.min(xy.left, vw - FAB_SIZE - 4)),
721
+ top: Math.max(4, Math.min(xy.top, vh - FAB_SIZE - 4)),
722
+ };
723
+ };
724
+ const applyFabPos = () => {
725
+ const xy = clampFabXY(fabXY(fabPos));
726
+ fab.style.left = Math.round(xy.left) + "px";
727
+ fab.style.top = Math.round(xy.top) + "px";
728
+ return xy;
729
+ };
730
+ const saveFabPos = () => {
731
+ try { window.localStorage && window.localStorage.setItem(FAB_KEY, JSON.stringify(fabPos)); } catch (e) { /* 忽略 */ }
732
+ };
733
+ /** 拖动落点 → "最近的角 + 偏移"(保留用户的意图,同时保证换尺寸后仍在屏内)。 */
734
+ const fabPosFromXY = (xy) => {
735
+ const vw = window.innerWidth || 360, vh = window.innerHeight || 640;
736
+ const h = xy.left + FAB_SIZE / 2 < vw / 2 ? "left" : "right";
737
+ const v = xy.top + FAB_SIZE / 2 < vh / 2 ? "top" : "bottom";
738
+ return {
739
+ h: h, v: v,
740
+ dx: h === "left" ? Math.max(0, xy.left) : Math.max(0, vw - FAB_SIZE - xy.left),
741
+ dy: v === "top" ? Math.max(0, xy.top) : Math.max(0, vh - FAB_SIZE - xy.top),
742
+ };
743
+ };
744
+
691
745
  const fab = document.createElement("button");
692
746
  fab.type = "button";
693
747
  fab.className = "dsh-ma-fab";
694
- fab.setAttribute("aria-label", "远程控制菜单");
748
+ fab.setAttribute("aria-label", "远程控制菜单(可拖动)");
749
+ fab.title = "远程控制:字号 / 加密状态 / 返回设备列表(可拖动移动位置)";
695
750
  fab.innerHTML = '<span class="dsh-ma-fab-ico">字</span><span class="dsh-ma-fab-lock" data-role="lock">🔒</span>';
751
+ fab.style.left = Math.round(clampFabXY(fabXY(fabPos)).left) + "px";
752
+ fab.style.top = Math.round(clampFabXY(fabXY(fabPos)).top) + "px";
696
753
  document.body.appendChild(fab);
697
754
 
698
755
  const fsRange = menu.querySelector('input[type="range"]');
@@ -743,13 +800,80 @@ const SCRIPT = `(() => {
743
800
  [600, 2000, 5000].forEach((ms) => setTimeout(renderE2ee, ms)); // shim 可能晚于本层挂牌
744
801
 
745
802
  /* ---- 交互 ---- */
803
+ /* 菜单跟随按钮:按钮在上半屏 → 菜单挂在它下方;按钮靠左 → 菜单左对齐(都不超出视口)。 */
804
+ const placeMenu = () => {
805
+ try {
806
+ const r = fab.getBoundingClientRect();
807
+ const vw = window.innerWidth || 360, vh = window.innerHeight || 640;
808
+ const mr = menu.getBoundingClientRect();
809
+ const mw = mr.width || Math.min(vw * 0.86, 320);
810
+ const mh = mr.height || 300;
811
+ const GAP = 8, EDGE = 8;
812
+ menu.style.top = "auto"; menu.style.bottom = "auto";
813
+ if (r.top + r.height / 2 < vh / 2) {
814
+ menu.style.top = Math.round(Math.max(EDGE, Math.min(r.bottom + GAP, vh - mh - EDGE))) + "px";
815
+ } else {
816
+ menu.style.bottom = Math.round(Math.max(EDGE, vh - r.top + GAP)) + "px";
817
+ }
818
+ menu.style.left = "auto"; menu.style.right = "auto";
819
+ if (r.left + r.width / 2 < vw / 2) {
820
+ menu.style.left = Math.round(Math.max(EDGE, Math.min(r.left, vw - mw - EDGE))) + "px";
821
+ } else {
822
+ menu.style.right = Math.round(Math.max(EDGE, Math.min(vw - r.right, vw - mw - EDGE))) + "px";
823
+ }
824
+ } catch (e) { /* 定位失败不影响功能(退回 CSS 默认位置) */ }
825
+ };
746
826
  const closeMenu = () => { menu.hidden = true; };
747
- const openMenu = () => { renderE2ee(); menu.hidden = false; };
827
+ const openMenu = () => { renderE2ee(); menu.hidden = false; placeMenu(); };
828
+ let suppressClickUntil = 0; // 拖完的那一下不要当成"点击打开菜单"
829
+ let tapHandledAt = 0; // 轻点已在 pointerup 里处理过(避免 click 再翻一次)
830
+ const toggleMenu = () => { if (menu.hidden) openMenu(); else closeMenu(); };
748
831
  fab.addEventListener("click", (e) => {
749
832
  e.stopPropagation(); e.preventDefault();
750
- if (menu.hidden) openMenu(); else closeMenu();
833
+ if (Date.now() < suppressClickUntil) return; // 刚拖完 → 不算点击
834
+ if (Date.now() - tapHandledAt < 800) return; // 轻点已在 pointerup 处理 → 不重复开合
835
+ toggleMenu(); // 键盘/老浏览器兜底
751
836
  });
752
837
  menu.addEventListener("click", (e) => e.stopPropagation());
838
+
839
+ /* ---- 拖动:pointer 事件 + 指针捕获(手指拖出按钮范围也不断线);拖动阈值 6px 区分"点击"与"拖动" ---- */
840
+ let fabDrag = null;
841
+ fab.addEventListener("pointerdown", (e) => {
842
+ if (e.button !== undefined && e.button !== 0) return; // 只认左键/单指
843
+ const xy = clampFabXY(fabXY(fabPos));
844
+ fabDrag = { id: e.pointerId, sx: e.clientX, sy: e.clientY, ox: xy.left, oy: xy.top, moved: false };
845
+ try { if (fab.setPointerCapture) fab.setPointerCapture(e.pointerId); } catch (e2) { /* 老浏览器忽略 */ }
846
+ // ⚠️ 这里**不能** preventDefault:实测它会连带掐掉随后的 click 事件(Chrome 触摸语义),
847
+ // 结果是"按钮点了没反应"。防滚动交给 CSS 的 touch-action:none,轻点交给 pointerup 处理。
848
+ });
849
+ fab.addEventListener("pointermove", (e) => {
850
+ if (!fabDrag || e.pointerId !== fabDrag.id) return;
851
+ const dx = e.clientX - fabDrag.sx, dy = e.clientY - fabDrag.sy;
852
+ if (!fabDrag.moved && Math.abs(dx) + Math.abs(dy) < 6) return; // 阈值内视为点击(细微抖动不挪位)
853
+ if (!fabDrag.moved) { fabDrag.moved = true; fab.classList.add("dsh-ma-fab-dragging"); closeMenu(); }
854
+ const xy = clampFabXY({ left: fabDrag.ox + dx, top: fabDrag.oy + dy });
855
+ fab.style.left = Math.round(xy.left) + "px";
856
+ fab.style.top = Math.round(xy.top) + "px";
857
+ });
858
+ const endFabDrag = (e) => {
859
+ if (!fabDrag || (e && e.pointerId !== undefined && e.pointerId !== fabDrag.id)) return;
860
+ const moved = fabDrag.moved;
861
+ fabDrag = null;
862
+ try { fab.classList.remove("dsh-ma-fab-dragging"); } catch (e2) { /* 忽略 */ }
863
+ if (!moved) {
864
+ // 轻点(没超过拖动阈值)→ 在这里开合菜单:pointerdown 不再 preventDefault 之后,
865
+ // 触摸场景的 click 并不总是可靠地跟上来,pointerup 才是稳的那个点。
866
+ tapHandledAt = Date.now();
867
+ toggleMenu();
868
+ return;
869
+ }
870
+ fabPos = fabPosFromXY(clampFabXY({ left: parseFloat(fab.style.left) || 0, top: parseFloat(fab.style.top) || 0 }));
871
+ saveFabPos();
872
+ suppressClickUntil = Date.now() + 400; // 拖动结束后的 click 事件不算"点开菜单"
873
+ };
874
+ fab.addEventListener("pointerup", endFabDrag);
875
+ fab.addEventListener("pointercancel", endFabDrag);
876
+ fab.addEventListener("lostpointercapture", endFabDrag);
753
877
  if (fsRange) {
754
878
  fsRange.addEventListener("input", (e) => { e.stopPropagation(); setLevel(Number(fsRange.value), true); });
755
879
  fsRange.addEventListener("click", (e) => e.stopPropagation());
@@ -770,6 +894,10 @@ const SCRIPT = `(() => {
770
894
  if (t === fab || (t && t.closest && t.closest(".dsh-ma-menu, .dsh-ma-fab"))) return;
771
895
  closeMenu();
772
896
  });
897
+ try {
898
+ window.addEventListener("resize", () => { applyFabPos(); if (!menu.hidden) placeMenu(); }, { passive: true });
899
+ window.addEventListener("orientationchange", () => { applyFabPos(); if (!menu.hidden) placeMenu(); }, { passive: true });
900
+ } catch (e9) { /* 忽略 */ }
773
901
  const devEl = menu.querySelector(".dsh-ma-menu-dev");
774
902
  if (devEl) {
775
903
  const d = deviceIdOfPath();
@@ -799,7 +927,9 @@ const SCRIPT = `(() => {
799
927
  });
800
928
  let strikes = 0;
801
929
  const noteStatus = (status) => {
802
- if (status !== 401 && status !== 403) { strikes = 0; return; }
930
+ // 只认 401:中继对"会话失效"回 401,对"设备不属于本账号"回 403 —— 后者不是会话过期,
931
+ // 拿它提示"请重新登录"会把人引到错误的动作上。
932
+ if (status !== 401) { strikes = 0; return; }
803
933
  strikes += 1;
804
934
  if (strikes >= 2) bar.hidden = false;
805
935
  };
@@ -362,3 +362,80 @@ test("多端适配:悬浮按钮与字号样式的 CSS 必须在 @media(max-wid
362
362
  assert.match(out, /html\[data-dsh-ma-fs="3"\]/, "字号档位样式也应在媒体查询之外(电脑端同样生效)");
363
363
  assert.match(out, /z-index:\s*40[01]/, "悬浮按钮/菜单层级要高于遮罩(290),遮罩异常时仍是自救出口");
364
364
  });
365
+
366
+ // ── 悬浮按钮位置:默认右上角 + 可拖动(2026-09-19 用户反馈) ──────────────────────
367
+ // 现场:按钮原来固定在右下角,**正好压住输入框的发送按钮**;用户要求默认挪到右上角并支持拖动。
368
+
369
+ test("悬浮按钮:默认在右上角(不再压住输入区的发送按钮)", () => {
370
+ const env = runAdapter(harness({ width: 390 }));
371
+ const fab = findFab(env);
372
+ assert.ok(fab, "悬浮按钮应存在");
373
+ // 默认 = 距右上角各 10px:left = 390 - 46 - 10 = 334,top = 10
374
+ assert.equal(fab.style.top, "10px", "默认应贴右上角(top 小)");
375
+ assert.equal(fab.style.left, "334px", "默认应贴右上角(left 靠右)");
376
+ });
377
+
378
+ test("悬浮按钮:拖动后位置改变并持久化,且拖完那一下不算点击", () => {
379
+ const env = runAdapter(harness({ width: 390 }));
380
+ const fab = findFab(env);
381
+ const menu = findMenu(env);
382
+ // 起始为默认右上角 (334,10);按下点 (340,20) → 拖到 (140,420):位移 -200/+400
383
+ fab.dispatch("pointerdown", { pointerId: 7, clientX: 340, clientY: 20 });
384
+ fab.dispatch("pointermove", { pointerId: 7, clientX: 140, clientY: 420 }); // 位移远超 6px 阈值
385
+ fab.dispatch("pointerup", { pointerId: 7, clientX: 140, clientY: 420 });
386
+ assert.equal(fab.style.left, "134px", "按手指位移平移(抓住哪就从哪拖,不瞬移到指尖)");
387
+ assert.equal(fab.style.top, "410px", "按手指位移平移");
388
+ const saved = JSON.parse(env.win.localStorage.getItem("dsh-ma-fab-pos") || "null");
389
+ assert.ok(saved && saved.h && saved.v, "位置应被持久化(按最近的角 + 偏移)");
390
+ assert.equal(saved.h, "left", "落点在左半屏 → 记成左锚");
391
+ assert.equal(saved.v, "bottom", "落点在下半屏 → 记成下锚");
392
+ assert.equal(saved.dx, 134, "左锚的偏移 = 距左边距离");
393
+ assert.equal(saved.dy, 344, "下锚的偏移 = 距下边距离(800-46-410)");
394
+ // 拖完紧接着的 click 不应打开菜单(否则拖到一半就弹菜单,很难用)
395
+ fab.dispatch("click");
396
+ assert.equal(menu.hidden, true, "拖动结束后的 click 不得当作点击打开菜单");
397
+ });
398
+
399
+ test("悬浮按钮:轻点(pointerdown+pointerup 不移动)开合菜单 —— 触屏的主路径", () => {
400
+ const env = runAdapter(harness({ width: 390 }));
401
+ const fab = findFab(env);
402
+ const menu = findMenu(env);
403
+ assert.equal(menu.hidden, true, "初始应收起");
404
+ // 触摸路径:pointerdown → pointerup(无位移)→ 菜单应当开;不能再依赖 click(曾被 preventDefault 掐掉)
405
+ fab.dispatch("pointerdown", { pointerId: 3, clientX: 340, clientY: 30 });
406
+ fab.dispatch("pointerup", { pointerId: 3, clientX: 340, clientY: 30 });
407
+ assert.equal(menu.hidden, false, "轻点应打开菜单");
408
+ // 再轻点一次 → 收起
409
+ fab.dispatch("pointerdown", { pointerId: 4, clientX: 340, clientY: 30 });
410
+ fab.dispatch("pointerup", { pointerId: 4, clientX: 340, clientY: 30 });
411
+ assert.equal(menu.hidden, true, "再轻点应收起");
412
+ // 位置不应被"轻点"改动
413
+ assert.equal(fab.style.left, "334px", "轻点不得移动按钮");
414
+ });
415
+
416
+ test("悬浮按钮:位置跨会话记忆(读 localStorage),越界会被夹回视口内", () => {
417
+ const env = harness({ width: 390 });
418
+ env.win.localStorage.setItem("dsh-ma-fab-pos", JSON.stringify({ h: "left", v: "top", dx: 12, dy: 34 }));
419
+ runAdapter(env);
420
+ const fab = findFab(env);
421
+ assert.equal(fab.style.left, "12px", "左锚 → 用 dx");
422
+ assert.equal(fab.style.top, "34px", "上锚 → 用 dy");
423
+
424
+ // 越界(dx 巨大)→ 夹回屏内:left ≤ 390-46-4
425
+ const env2 = harness({ width: 390 });
426
+ env2.win.localStorage.setItem("dsh-ma-fab-pos", JSON.stringify({ h: "left", v: "top", dx: 9999, dy: 0 }));
427
+ runAdapter(env2);
428
+ assert.equal(findFab(env2).style.left, "340px", "越界位置必须被夹进视口(不能跑到屏幕外)");
429
+ });
430
+
431
+ test("悬浮按钮:菜单跟随按钮位置(按钮在上半屏 → 菜单挂下方;靠左 → 左对齐)", () => {
432
+ const env = runAdapter(harness({ width: 390 }));
433
+ const fab = findFab(env);
434
+ const menu = findMenu(env);
435
+ fab._rect = { left: 20, top: 12, right: 66, bottom: 58, width: 46, height: 46 }; // 左上角
436
+ fab.dispatch("click");
437
+ assert.equal(menu.hidden, false, "点击应打开菜单");
438
+ assert.equal(menu.style.top, "66px", "按钮在上半屏 → 菜单挂在按钮下方(bottom+8)");
439
+ assert.equal(menu.style.left, "20px", "按钮靠左 → 菜单左对齐到按钮");
440
+ assert.equal(menu.style.right, "auto", "左对齐时不应残留 right");
441
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.7-beta.4",
3
+ "version": "0.6.8",
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",
@@ -39,7 +39,8 @@
39
39
  "clients/dsh-remote",
40
40
  "packages/dsh-remote-web",
41
41
  "docs/self-hosting.md",
42
- "docs/telemetry.md"
42
+ "docs/telemetry.md",
43
+ "scripts/test-net-guard.cjs"
43
44
  ],
44
45
  "workspaces": [
45
46
  "packages/*",
@@ -50,10 +51,10 @@
50
51
  },
51
52
  "scripts": {
52
53
  "test": "npm run test:router && npm run test:plugin && npm run test:bridge",
53
- "test:router": "node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs",
54
+ "test:router": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs",
54
55
  "_comment:test:plugin": "DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)",
55
- "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
56
- "test:bridge": "node --test clients/dsh-remote/test/*.test.mjs",
56
+ "test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
57
+ "test:bridge": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test clients/dsh-remote/test/*.test.mjs",
57
58
  "check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
58
59
  "release:tarballs": "bash scripts/release-tarballs.sh"
59
60
  },
@@ -2379,7 +2379,7 @@ const PLUGIN_ID = "dsh-remote-web";
2379
2379
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
2380
2380
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
2381
2381
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
2382
- const PLUGIN_VERSION = "0.6.7-beta.4";
2382
+ const PLUGIN_VERSION = "0.6.8";
2383
2383
  const UPDATE_LOG = ".dsh-update.log";
2384
2384
  const UPDATE_MARKER = ".dsh-update-running";
2385
2385
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.7-beta.4",
3
+ "version": "0.6.8",
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",
@@ -87,7 +87,11 @@ async function plantProfile(home, { extraPatch = "", unmarked = false } = {}) {
87
87
  fs.writeFileSync(path.join(local, "lib", "index.js"), "export const x = 1;\n");
88
88
  fs.symlinkSync(local, path.join(profile, "node_modules", PLUGIN_ID), "dir");
89
89
  fs.mkdirSync(path.join(home, "relay"), { recursive: true });
90
- fs.writeFileSync(path.join(home, "relay", ".dsh-config.json"), JSON.stringify({ phone: "13800000000", password: "pw" }));
90
+ // ⚠️ api_url 必须指向本地死端口:这些是**假账号**,一旦按默认地址请求生产,
91
+ // /api/device-login 会带着假密码失败,而服务端登录限流是按出口 IP 计的(5 次/15 分钟)——
92
+ // 实测结果是把**本机真实的 bridge** 连坐进限流(2026-09-19 事故)。
93
+ fs.writeFileSync(path.join(home, "relay", ".dsh-config.json"),
94
+ JSON.stringify({ phone: "13800000000", password: "pw", api_url: "http://127.0.0.1:1" }));
91
95
  return { profile, patchFile: path.join(profile, "cordis.patch.yml") };
92
96
  }
93
97
 
@@ -460,6 +460,24 @@ test("前端:bridge-status 轮询失败不再静默(旧版让面板永久停
460
460
  assert.ok(/connFailRef\.v = 0/.test(CLIENT_SRC), "成功必须清零(一次抖动不该把面板染红)");
461
461
  });
462
462
 
463
+ test("测试隔离:套件不得访问任何外部地址(生产限流会被测试污染)", () => {
464
+ // 事故(2026-09-19):有用例在配置里写了假账号却没覆盖 api_url → 按默认地址请求**生产中继**,
465
+ // 其中 /api/device-login 带假密码必然失败,而服务端登录限流按**出口 IP** 计(5 次/15 分钟)——
466
+ // 结果不仅污染生产审计,还让**本机自己的 bridge 被连坐**(拿不到 JWT),
467
+ // 真机表现成"切换账号后设备一直登记不上去",排查方向被彻底带偏。
468
+ const repoRoot = path.join(HERE, "..", "..", "..");
469
+ const guard = path.join(repoRoot, "scripts", "test-net-guard.cjs");
470
+ assert.ok(existsSync(guard), "必须有测试期网络护栏脚本");
471
+ const pkg = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8"));
472
+ for (const name of ["test:router", "test:plugin", "test:bridge"]) {
473
+ assert.match(String(pkg.scripts[name]), /test-net-guard\.cjs/, `${name} 必须注入网络护栏`);
474
+ }
475
+ // 护栏本体:只放行本地地址,其余一律阻断
476
+ const src = readFileSync(guard, "utf8");
477
+ assert.ok(/ENETUNREACH/.test(src), "护栏应阻断外部请求");
478
+ assert.ok(/LOCAL_HOST/.test(src), "护栏应只放行本地地址");
479
+ });
480
+
463
481
  test("前端:平台文案不再默认 macOS(Windows 用户看不懂「自启动服务」)", () => {
464
482
  assert.ok(/serviceManager === "detached"/.test(CLIENT_SRC), "面板应识别 Windows 的 detached 模式");
465
483
  assert.match(CLIENT_SRC, /Windows 任务计划程序 dsh-remote-bridge/, "卸载说明要写明 Windows 侧会删掉什么");
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 测试期网络护栏(NODE_OPTIONS=--require 注入,见 package.json 的 test 脚本)。
3
+ *
4
+ * 为什么必须有(2026-09-19 实测事故):
5
+ * 有测试用例在 profile 配置里写了**假账号**(phone/password)却没覆盖 `api_url`,
6
+ * 于是插件按默认地址去请求**生产中继** `https://n.risegao.cn:…`。其中
7
+ * `/api/device-login` 带着假密码必然失败,而服务端的登录限流是**按出口 IP**
8
+ * 统计「5 次失败 / 15 分钟」——结果是:
9
+ * · 生产审计日志被测试污染;
10
+ * · **本机自己的 bridge 被连坐**:限流窗口内它拿不到 JWT(429),
11
+ * 真机表现为「切换账号后设备一直登记不上去」,排查方向被彻底带偏。
12
+ *
13
+ * 现在的行为:测试期**一律阻断**任何非本地请求(throw ENETUNREACH),
14
+ * 并在进程退出时把被拦下的地址打印出来,提醒用例作者去补 `api_url`。
15
+ * 这既不改变用例结论(需要断网的分支照常走失败路径),又保证测试永远碰不到外部服务。
16
+ */
17
+ const realFetch = globalThis.fetch;
18
+ const blocked = new Set();
19
+
20
+ const LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\]|0\.0\.0\.0|::1)(:\d+)?$/i;
21
+
22
+ function hostOf(input) {
23
+ try {
24
+ const url = typeof input === "string" ? input : input && input.url ? input.url : String(input);
25
+ return new URL(url).host;
26
+ } catch {
27
+ return "";
28
+ }
29
+ }
30
+
31
+ globalThis.fetch = function guardedFetch(input, init) {
32
+ const host = hostOf(input);
33
+ if (host && !LOCAL_HOST.test(host)) {
34
+ const raw = typeof input === "string" ? input : (input && input.url) || "";
35
+ blocked.add(String(raw).replace(/(token|password|secret)=[^&]*/gi, "$1=***").slice(0, 160));
36
+ const err = new Error(`test-net-guard: 测试期禁止访问外部地址 ${host}(请在用例里把 api_url 指向本地假服务)`);
37
+ err.code = "ENETUNREACH";
38
+ return Promise.reject(err);
39
+ }
40
+ return realFetch.call(this, input, init);
41
+ };
42
+
43
+ // 子进程(安装器 / 清理助手等)继承 NODE_OPTIONS,也要能看到告警
44
+ process.on("exit", () => {
45
+ if (!blocked.size) return;
46
+ process.stderr.write(
47
+ `\n[test-net-guard] ⚠️ 有 ${blocked.size} 个外部请求被拦下(用例应改用本地假服务,别打生产):\n `
48
+ + [...blocked].join("\n ") + "\n\n");
49
+ });