@mrrisega/dsh-remote 0.6.7 → 0.6.9-beta.1
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/README.md +1 -1
- package/clients/dsh-remote/mobile-adapter.mjs +134 -6
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +77 -0
- package/docs/telemetry.md +5 -1
- package/dsh-setup.mjs +18 -18
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +754 -148
- package/packages/dsh-remote-web/lib/index.js +329 -29
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/update-stall.test.mjs +315 -0
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
|
|
|
60
60
|
|
|
61
61
|
- **登录后 bridge 自动连上,全程零刷新**:面板自动补运行环境 → 拉起 bridge → 显示「正在连接中继…」,连上后自动变成「已连接 ✅」并刷新二维码与设备列表;失败有可读原因、自动重试倒计时和「复制诊断信息」。
|
|
62
62
|
- **手机端自动等待电脑上线**:空设备列表不再让人干等或手动刷新,电脑一上线设备自己出现。
|
|
63
|
-
-
|
|
63
|
+
- **安装引导改版**:有插件市场入口就在市场里搜索 **`dsh-remote-web`** 安装(不用敲命令;搜索词是包名 `dsh-remote-web`,不是 `dsh-remote`);没有就复制一条命令;两条路都收敛到「登录后稍等,电脑会自己出现」。
|
|
64
64
|
- **安装输出更干净**:不再重复打印同一段引导,结尾只给一份汇总(地址 / 自启动服务 / 下一步);装完当下 dsh web 没开着也会如实说明「打开 dsh web 后 bridge 会自动启动」,不再像报错。
|
|
65
65
|
- **macOS 26 自启动死角已修**:macOS 26 会把 `gui/<uid>` 会话域置为 on-demand-only,`RunAtLoad` / `KeepAlive` 失效(服务只登记不启动,`runs = 0`)。现在优先用 `user/<uid>` 域启动(该域仍支持开机自启与崩溃自愈),失败才回退 `gui/<uid>` + `kickstart`(只拉起这一次,会**明确告知**不支持崩溃自愈),再不行退化为后台进程(现在能用、无自启)。自启动服务的 PATH 也补了 `/usr/sbin`、`/sbin`(bridge 要调 `ioreg`)。
|
|
66
66
|
|
|
@@ -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
|
-
|
|
263
|
-
|
|
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:
|
|
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 (
|
|
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();
|
|
@@ -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/docs/telemetry.md
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
`node_missing` · `node_too_old` · `npm_unreachable` · `npm_eacces` · `platform_unsupported` ·
|
|
64
64
|
`runtime_install_timeout` · `launchd_failed` · `bridge_exit` · `bind_conflict` · `bind_device_limit` ·
|
|
65
65
|
`npx_cmd_unavailable` · `registry_timeout` · `install_script_missing` · `npx_exit_nonzero` ·
|
|
66
|
-
`npx_output_encoding` · `unknown`
|
|
66
|
+
`npx_output_encoding` · `update_stalled`(更新进程长时间无输出、判定卡住)· `unknown`
|
|
67
67
|
|
|
68
68
|
> 原始错误文本**绝不外发**(它可能含文件路径、用户名、主机名):只做白名单归类,
|
|
69
69
|
> 归不进去的一律记 `unknown`。
|
|
@@ -72,6 +72,10 @@
|
|
|
72
72
|
> `npx_output_encoding` 是 2026-09 为定位「Windows 上装不上运行环境」而拆细的形态
|
|
73
73
|
> (命令调不起来 / 注册表超时 / 包或安装脚本缺失 / 退出码非零 / 输出乱码)——
|
|
74
74
|
> 它们仍然只是**枚举值**,不包含任何原始文本。
|
|
75
|
+
>
|
|
76
|
+
> `update_stalled`(0.6.9 新增):更新进程**活着但长时间没有任何输出**(中国网络访问 npm 官方源的
|
|
77
|
+
> 典型失败形态是"挂起"而非快速失败)。它被单列出来,是为了让"卡死"这一类在统计里可见 ——
|
|
78
|
+
> 此前它只会落进 `unknown` 或干脆不产生事件,导致规模被持续低估。
|
|
75
79
|
|
|
76
80
|
## 3. 不采集什么(硬边界)
|
|
77
81
|
|
package/dsh-setup.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* dsh-remote settings 提示如何登录(独立设置页已移除,见 dsh web 面板)
|
|
8
8
|
* dsh-remote run 前台运行 bridge(调试/守护)
|
|
9
9
|
* dsh-remote status 查看配置与服务状态
|
|
10
|
-
* dsh-remote plugin [--uninstall] 手动安装/卸载 dsh web
|
|
10
|
+
* dsh-remote plugin [--uninstall] 手动安装/卸载 dsh web 远程访问插件
|
|
11
11
|
*
|
|
12
12
|
* setup 选项(全部可选):
|
|
13
13
|
* --server <wss://host:port> --key <访问密钥> 自建模式(不填则连默认云端服务)
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* 行为:
|
|
19
19
|
* 1. 写入配置 <CONFIG_DIR>/.dsh-config.json(0600;npm 安装时为 ~/.dsh-remote/)
|
|
20
20
|
* 2. 生成自启动服务(macOS launchd / Linux systemd),随 dsh web(3080) 存活自动保活
|
|
21
|
-
* 3.
|
|
22
|
-
* 4. 登录/连接配置在 dsh web → 设置 →
|
|
21
|
+
* 3. 自动把远程访问插件装进 dsh web 设置页(若检测到 profile)
|
|
22
|
+
* 4. 登录/连接配置在 dsh web → 设置 → 「远程访问」面板完成(SaaS 注册登录 / 自建切换)
|
|
23
23
|
* 自建 CLI 用户也可用 `dsh-remote setup --server … --key …`(无需再打开设置页)
|
|
24
24
|
*/
|
|
25
25
|
|
|
@@ -160,14 +160,14 @@ function printHelp() {
|
|
|
160
160
|
dsh-remote settings 显示登录/连接配置指引(独立设置页已移除)
|
|
161
161
|
dsh-remote run 前台运行 bridge(调试)
|
|
162
162
|
dsh-remote status 查看配置与服务状态
|
|
163
|
-
dsh-remote plugin 手动安装 dsh web
|
|
163
|
+
dsh-remote plugin 手动安装 dsh web 远程访问插件(--uninstall 卸载)
|
|
164
164
|
dsh-remote repair 修复插件挂载(dsh web 起不来时用;只碰 profile,不联网)
|
|
165
165
|
dsh-remote --help 显示本用法(等同 dsh-remote help)
|
|
166
166
|
|
|
167
167
|
自建模式(可选):
|
|
168
168
|
dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
|
|
169
169
|
|
|
170
|
-
登录/连接配置: 打开 dsh web → 设置 →
|
|
170
|
+
登录/连接配置: 打开 dsh web → 设置 → 「远程访问」→ 注册或登录手机号即可
|
|
171
171
|
(自建用户切「自建服务」标签或直接用上方 setup 命令,无需另开页面)。
|
|
172
172
|
文档: ${REPO_URL}
|
|
173
173
|
`);
|
|
@@ -848,7 +848,7 @@ function installAutostart() {
|
|
|
848
848
|
}
|
|
849
849
|
|
|
850
850
|
// ---------- settings:旧独立设置页已移除(引导到 dsh web 插件面板) ----------
|
|
851
|
-
// 云服务用户在 dsh web → 设置 →
|
|
851
|
+
// 云服务用户在 dsh web → 设置 → 「远程访问」面板即可注册/登录(自建切换也在此面板);
|
|
852
852
|
// 独立设置页(127.0.0.1:3499)与面板功能完全重复、且暴露自建表单给普通用户造成困惑,已移除。
|
|
853
853
|
// 保留 `dsh-remote settings` 命令为“引导提示”,避免旧脚本/旧文档直接调它时报错。
|
|
854
854
|
function settingsHint() {
|
|
@@ -856,7 +856,7 @@ function settingsHint() {
|
|
|
856
856
|
dsh-remote:独立的本地设置页已移除。
|
|
857
857
|
|
|
858
858
|
请直接在 dsh web 里完成登录/连接配置(无需任何命令):
|
|
859
|
-
打开 dsh web → 设置 →
|
|
859
|
+
打开 dsh web → 设置 → 「远程访问」→ 注册或登录手机号即可(自建模式切换也在该面板)。
|
|
860
860
|
|
|
861
861
|
自建用户若偏好命令行,可用:
|
|
862
862
|
dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
|
|
@@ -928,7 +928,7 @@ async function runBridge() {
|
|
|
928
928
|
if (!saas && !local) {
|
|
929
929
|
if (!warnedNoLogin) {
|
|
930
930
|
warnedNoLogin = true;
|
|
931
|
-
console.log("⚠️ 尚未登录:请打开 dsh web → 设置 →
|
|
931
|
+
console.log("⚠️ 尚未登录:请打开 dsh web → 设置 → 「远程访问」,注册/登录手机号(或切到自建模式)后自动启动。");
|
|
932
932
|
}
|
|
933
933
|
return;
|
|
934
934
|
}
|
|
@@ -1095,7 +1095,7 @@ async function setup(argv) {
|
|
|
1095
1095
|
}
|
|
1096
1096
|
|
|
1097
1097
|
// 插件是**进程启动时**装载的:刚装进 profile 的插件,运行中的 dsh web 里并没有
|
|
1098
|
-
// (既没有 /dsh-remote/* 路由,也没有「设置 →
|
|
1098
|
+
// (既没有 /dsh-remote/* 路由,也没有「设置 → 远程访问」面板项)。
|
|
1099
1099
|
// 这里做一个确定性判断:进程启动时间早于插件落盘时间 → 必须重启 dsh web 才生效。
|
|
1100
1100
|
const pluginDir = path.join(resolveProfileDir(argv), PLUGIN_LOCAL_DIR);
|
|
1101
1101
|
const pluginMtime = fs.existsSync(path.join(pluginDir, "lib", "index.js"))
|
|
@@ -1148,7 +1148,7 @@ async function setup(argv) {
|
|
|
1148
1148
|
if (needRestart && !noRestart && webUp) {
|
|
1149
1149
|
console.log("");
|
|
1150
1150
|
console.log("ℹ 检测到运行中的 dsh web 早于本次插件安装 —— 插件只在进程启动时装载,");
|
|
1151
|
-
console.log(" 所以「设置 →
|
|
1151
|
+
console.log(" 所以「设置 → 远程访问」面板暂时还没出现。正在为你重启 dsh web(2 秒后执行,Ctrl-C 可跳过)…");
|
|
1152
1152
|
sleepSync(2000);
|
|
1153
1153
|
restartResult = restartDshWeb();
|
|
1154
1154
|
if (restartResult.ok) {
|
|
@@ -1193,7 +1193,7 @@ async function setup(argv) {
|
|
|
1193
1193
|
L.push(` 服务器: ${cfg.tunnel_url}`);
|
|
1194
1194
|
L.push(` 手机端: 打开 ${cfg.tunnel_url.replace(/^ws/, "https")}/app/ ,用访问密钥登录`);
|
|
1195
1195
|
} else {
|
|
1196
|
-
L.push(`
|
|
1196
|
+
L.push(` 远程访问地址: ${pub.app_url || DEFAULT_APP_URL}`);
|
|
1197
1197
|
}
|
|
1198
1198
|
L.push(svcSkipped
|
|
1199
1199
|
? ` 自启动服务: ${svcState},可用 \`dsh-remote run\` 手动运行 bridge`
|
|
@@ -1239,10 +1239,10 @@ async function setup(argv) {
|
|
|
1239
1239
|
} else if (hotMounted) {
|
|
1240
1240
|
L.push("");
|
|
1241
1241
|
L.push(" 下一步: 插件已热加载(无需重启 dsh web)——刷新一下浏览器页面,");
|
|
1242
|
-
L.push(" 再打开 设置 →
|
|
1242
|
+
L.push(" 再打开 设置 → 「远程访问」→ 注册/登录手机号即可。");
|
|
1243
1243
|
} else if (needRestart && restartResult && restartResult.ok) {
|
|
1244
1244
|
L.push("");
|
|
1245
|
-
L.push(" 下一步: dsh web 已重启,直接打开 http://127.0.0.1:3080 → 设置 →
|
|
1245
|
+
L.push(" 下一步: dsh web 已重启,直接打开 http://127.0.0.1:3080 → 设置 → 「远程访问」→ 注册/登录手机号。");
|
|
1246
1246
|
} else if (needRestart && pluginResult && pluginResult.hotPatch && pluginResult.changed) {
|
|
1247
1247
|
// patch 已写入但探测窗口内没等到面板接口:热加载很可能还在进行(插件节点半启动+注册路由)。
|
|
1248
1248
|
// 这种情况**不能**直接让用户重启 —— 先让他等几秒刷新,再给兜底方案。
|
|
@@ -1251,7 +1251,7 @@ async function setup(argv) {
|
|
|
1251
1251
|
L.push(`ℹ 插件已按热加载方式登记,但运行中的 dsh web 仍在用旧版本(${runningSeen} → 期望 ${pkgVersion()}):`);
|
|
1252
1252
|
L.push(" 刷新页面即可(浏览器半会跟着更新);若刷新后功能仍不对,再重启一次 dsh web。");
|
|
1253
1253
|
} else {
|
|
1254
|
-
L.push("ℹ 插件已按热加载方式登记(patch 已写入),若「设置 →
|
|
1254
|
+
L.push("ℹ 插件已按热加载方式登记(patch 已写入),若「设置 → 远程访问」还没出现:");
|
|
1255
1255
|
L.push(" 等几秒后刷新页面即可;仍未出现再重启一次 dsh web(命令见 README)。");
|
|
1256
1256
|
}
|
|
1257
1257
|
} else if (needRestart) {
|
|
@@ -1261,15 +1261,15 @@ async function setup(argv) {
|
|
|
1261
1261
|
L.push(" 请手动执行(任选其一):");
|
|
1262
1262
|
L.push(" launchctl kickstart -k gui/$(id -u)/com.dshweb.dev # 用 launchd 托管时");
|
|
1263
1263
|
L.push(" kill $(lsof -ti tcp:3080 -sTCP:LISTEN) && dsh web --no-open # 手动启动时");
|
|
1264
|
-
L.push(" 重启后:打开 http://127.0.0.1:3080 → 设置 →
|
|
1264
|
+
L.push(" 重启后:打开 http://127.0.0.1:3080 → 设置 → 「远程访问」→ 注册/登录手机号。");
|
|
1265
1265
|
} else {
|
|
1266
1266
|
L.push("");
|
|
1267
|
-
L.push(" 下一步: 打开 dsh web → 设置 →
|
|
1267
|
+
L.push(" 下一步: 打开 dsh web → 设置 → 「远程访问」→ 注册/登录手机号即可(无需任何命令)。");
|
|
1268
1268
|
}
|
|
1269
1269
|
console.log("\n" + L.join("\n") + "\n");
|
|
1270
1270
|
}
|
|
1271
1271
|
|
|
1272
|
-
// ---------- plugin:安装/卸载 dsh web
|
|
1272
|
+
// ---------- plugin:安装/卸载 dsh web 远程访问插件 ----------
|
|
1273
1273
|
/** 当前插件名(2026-09 由 dsh-remote-ui 更名;dsh-remote-web = dsh-remote 的 dsh web 插件半)。 */
|
|
1274
1274
|
const PLUGIN_ID = "dsh-remote-web";
|
|
1275
1275
|
/** 更名前的历史插件名(≤0.4.9):升级安装/卸载时一并清理,避免旧 id 残留导致重复激活。 */
|
|
@@ -2113,7 +2113,7 @@ else if (cmd === "status") {
|
|
|
2113
2113
|
console.log("配置文件:", CONFIG_PATH);
|
|
2114
2114
|
console.log("连接模式:", local ? `自建服务(${cfg.tunnel_url || "未设置服务器地址"})` : `SaaS 云端服务(${cfg.phone || "未配置账号"})`);
|
|
2115
2115
|
console.log("API:", cfg.api_url || (local ? "(自建模式无需账号 API)" : DEFAULT_API));
|
|
2116
|
-
console.log("远程地址/登录: 打开 dsh web → 设置 →
|
|
2116
|
+
console.log("远程地址/登录: 打开 dsh web → 设置 → 「远程访问」查看与操作");
|
|
2117
2117
|
// 插件挂载诊断:报障时一眼看出是"链接坏了"还是"别的毛病"
|
|
2118
2118
|
const profileDir = resolveProfileDir([]);
|
|
2119
2119
|
if (fs.existsSync(path.join(profileDir, "package.json"))) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9-beta.1",
|
|
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",
|