dsh-plugin-mobile-gateway 0.9.0 → 0.10.0
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/PROTOCOL.md +6 -1
- package/README.md +12 -2
- package/lib/client.js +407 -132
- package/lib/cloudflare-tunnel.mjs +29 -2
- package/lib/devices.js +23 -7
- package/lib/gateway-probe.mjs +6 -0
- package/lib/index.mjs +19 -7
- package/lib/public-endpoint-check.mjs +61 -0
- package/package.json +3 -3
package/PROTOCOL.md
CHANGED
|
@@ -1130,7 +1130,8 @@ DSH Web 的“深度求索中,用时 22 秒 ···”是客户端文案,不
|
|
|
1130
1130
|
|
|
1131
1131
|
| 版本 | 新增 |
|
|
1132
1132
|
|---|---|
|
|
1133
|
-
| v0.
|
|
1133
|
+
| v0.10.0(当前源码) | 响应式居中控制面板与主题同步;自定义下拉菜单;独立配对模态窗口与缩放动画;按运行实例关闭重启提醒;在线设备折叠列表与移动端设备名称上报;Tunnel 配置交互优化 |
|
|
1134
|
+
| v0.9.0 | DSH 0.2.0-rc.2 插件管理;PC 端 Cloudflare Quick / 命名 Tunnel 及独立重启;插件私有 `cloudflared` 缓存及校验;配对入口选择指引;网关重启与 DSH Web 停止工具 |
|
|
1134
1135
|
| v0.7.2 | 独立对话/控制连接;空 Session 创建;停止生成与稍后继续;排队消息同步及编辑/删除/Steer;App 归档/重命名 Session;WebUI 归档集合和名称变化实时同步到 App |
|
|
1135
1136
|
| v0.1.5 | workspace-create / directories / host |
|
|
1136
1137
|
| v0.1.6 | 修复消息分发器遗漏(host/directories/workspace-create 未路由) |
|
|
@@ -1234,3 +1235,7 @@ does not put file responses behind conversation frames. This isolates socket
|
|
|
1234
1235
|
queues; the underlying network link and Host resources remain shared. On connection
|
|
1235
1236
|
failure clients reconnect and authenticate both lanes, then resubscribe and catch up
|
|
1236
1237
|
history. Older gateways without the capability continue using one connection.
|
|
1238
|
+
|
|
1239
|
+
### 移动端设备名称
|
|
1240
|
+
|
|
1241
|
+
移动端在 WebSocket 握手中通过 `X-DSH-Device-Name` 上报 UTF-8 设备名称的标准 Base64 编码;配对和凭据重连时均发送。网关不使用控制面板指定的名称,未上报时使用“未命名设备”。可信设备接口保留原始 `name`,并提供 `displayName`;同名设备显示编号后缀,例如“我的手机 (1)”“我的手机 (2)”。
|
package/README.md
CHANGED
|
@@ -10,7 +10,9 @@ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排
|
|
|
10
10
|
>
|
|
11
11
|
> 实时流已改为独立 `assistant-stream` 帧,移动端需要按 [rc.2 接入说明](docs/dsh-rc2-mobile-integration.md) 更新订阅、缓存与分页处理。当前修改尚未发布。
|
|
12
12
|
>
|
|
13
|
-
> v0.
|
|
13
|
+
> v0.10.0(当前源码):移动设备面板改为响应式居中设置窗口,跟随主应用深浅色主题;统一自定义下拉菜单与连接状态高亮;配对二维码使用独立模态窗口并支持缩放进出动画;安装重启提醒可按网关运行实例关闭;可信设备默认仅显示在线设备,支持展开离线设备、移动端名称上报与同名编号;Tunnel 切换自动应用,命名配置编辑后保存。
|
|
14
|
+
>
|
|
15
|
+
> v0.9.0:新增 DSH 0.2.0-rc.2 插件管理能力;PC 端可在面板开启、重启 Cloudflare Quick / 命名 Tunnel,使用插件私有缓存中的 `cloudflared` 并校验文件;配对面板增加局域网与外网入口选择指引;工具菜单可重启网关或停止 DSH Web,重启时不再打开新标签页。
|
|
14
16
|
>
|
|
15
17
|
> v0.7.3:优化移动网关运行模式下拉框的箭头间距。
|
|
16
18
|
>
|
|
@@ -200,11 +202,19 @@ Quick Tunnel 无需 Cloudflare 账户、Token 或自己的公网域名,但地
|
|
|
200
202
|
### Cloudflare 命名 Tunnel(固定地址)
|
|
201
203
|
|
|
202
204
|
1. 在 [Cloudflare 控制台创建远程管理的 Tunnel](https://developers.cloudflare.com/tunnel/get-started/),添加一个公开域名(例如 `gateway.example.com`),将该路由的 **Service URL** 设为 `http://127.0.0.1:3082`。无需安装 Cloudflare 系统服务,也无需开放路由器入站端口。
|
|
203
|
-
2. 从 Tunnel 的「Add a replica」安装命令中复制 Token,在 DSH「移动设备 → Cloudflare Tunnel」选择「命名 Tunnel · 固定域名」,输入公开域名和 Token 后开启。插件会将网关设为常驻模式,启动本机专用入口和 `cloudflared
|
|
205
|
+
2. 从 Tunnel 的「Add a replica」安装命令中复制 Token,在 DSH「移动设备 → Cloudflare Tunnel」选择「命名 Tunnel · 固定域名」,输入公开域名和 Token 后开启。插件会将网关设为常驻模式,启动本机专用入口和 `cloudflared`。隧道传输连接后,面板会独立检测本机到公网入口的 DNS、TLS 和移动网关路由;检测通过后使用自动填入的 `wss://gateway.example.com/ws/mobile` 生成配对二维码。
|
|
204
206
|
3. 以后启动 `dsh web` 会自动恢复已开启的 Tunnel;在面板点击「关闭 Cloudflare Tunnel」会停止插件管理的 `cloudflared` 并关闭本机专用入口。若手动关闭网关,Tunnel 会暂停,重新开启网关后恢复。
|
|
205
207
|
|
|
206
208
|
两种 Cloudflare 入口都只接受 `/ws/mobile` WebSocket,其他 HTTP 路径(包括 DSH WebUI 和 `/mgw` 管理接口)返回 404;设备凭证始终必需,即使本机 Debug 鉴权被关闭也一样。命名 Tunnel 的 Token 保存在 `<deviceFile>.cloudflare.json`(默认 `~/.dsh/mobile-gateway-devices.json.cloudflare.json`),权限为 `0600`,不会返回给浏览器。默认使用 DSH 私有的 `cloudflared-bin` 缓存,不读取系统或其他 App 的 `cloudflared`;每次启动 Tunnel 都校验缓存文件,只有缓存缺失或损坏时才下载固定版本并核对 Cloudflare 官方发布资产的 SHA-256。仅在显式配置 `cloudflaredPath` 时使用外部程序。使用自定义 `cloudflarePort` 时,命名 Tunnel 路由的 Service URL 端口也要相应修改。
|
|
207
209
|
|
|
210
|
+
#### 公网入口检测与状态含义
|
|
211
|
+
|
|
212
|
+
`state: online` 只表示 cloudflared 已注册隧道连接,不保证公开域名、TLS 或源站路由可用。状态 API 另外提供 `endpointHealth`(状态、提示、检测时间及错误码)和 `publicReady`。面板在连接后自动检测一次,也可点击「检测公网 DNS / TLS / WebSocket」重试;该按钮调用受现有管理鉴权保护的 `POST /mgw/cloudflare/check`,不会重启隧道。
|
|
213
|
+
|
|
214
|
+
检测使用本机系统 DNS 和严格证书校验的 HTTPS WebSocket 请求,不携带配对码或设备 Token,不跟随重定向。只有 HTTP 401 且移动网关回显本次随机探测标记时,才确认受鉴权保护的网关路由可达;代理或 Cloudflare Access 的普通 401 不会被当作成功。该标记只在缺少有效设备凭证的拒绝响应中回显,不授予任何访问权限。结果是一次本机网络快照,不保证手机网络、设备鉴权或会话同步成功。
|
|
215
|
+
|
|
216
|
+
本机解析失败不代表公共 DNS 记录必然不存在:刚创建记录时可能仍有 NXDOMAIN 缓存,也可能受本机 DNS、VPN 或代理影响。核对公开主机名和已代理 CNAME,再稍后重试,不要关闭 TLS 校验。插件目前使用 Tunnel Token 运行远程管理的隧道,该 Token 不会自动创建域名路由或 DNS;具备额外账户授权的 cloudflared CLI / Cloudflare API 可以配置这些资源,但不属于本检测功能。
|
|
217
|
+
|
|
208
218
|
### Tailscale(推荐长期使用)
|
|
209
219
|
|
|
210
220
|
1. 在电脑和 iPhone 安装 [Tailscale](https://tailscale.com/download),并登录同一个 Tailnet。
|
package/lib/client.js
CHANGED
|
@@ -20,46 +20,55 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
}
|
|
21
21
|
const getOpen = () => open
|
|
22
22
|
|
|
23
|
+
// Shared Web UI / Desktop semantic tokens update with the host theme.
|
|
24
|
+
const restartNoticeStorageKey = 'mobile-gateway-dismissed-restart-notice'
|
|
25
|
+
let dismissedRestartInstance = null
|
|
26
|
+
try { dismissedRestartInstance = window.localStorage.getItem(restartNoticeStorageKey) } catch { /* storage unavailable */ }
|
|
27
|
+
|
|
23
28
|
const colors = {
|
|
24
|
-
panel: 'var(--color-bg,
|
|
25
|
-
card: 'var(--color-bg-elevated,
|
|
26
|
-
text: 'var(--color-text,
|
|
27
|
-
muted: 'var(--color-text-muted,
|
|
28
|
-
border: 'var(--color-border, rgba(127,127,127,.24))',
|
|
29
|
-
accent: 'var(--color-primary, #4f7cff)',
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
panel: 'var(--dsw-alias-bg-layer-2, var(--color-bg, Canvas))',
|
|
30
|
+
card: 'var(--dsw-alias-bg-module-platform, var(--color-bg-elevated, Canvas))',
|
|
31
|
+
text: 'var(--dsw-alias-label-primary, var(--color-text, CanvasText))',
|
|
32
|
+
muted: 'var(--dsw-alias-label-secondary, var(--color-text-muted, GrayText))',
|
|
33
|
+
border: 'var(--dsw-alias-border-l3, var(--color-border, rgba(127,127,127,.24)))',
|
|
34
|
+
accent: 'var(--dsw-alias-button-primary-fill, var(--color-primary, #4f7cff))',
|
|
35
|
+
accentText: 'var(--dsw-alias-label-primary-foreground, #fff)',
|
|
36
|
+
danger: 'var(--dsw-alias-state-error-primary, #dc4c64)',
|
|
37
|
+
success: 'var(--dsw-alias-state-success-primary, #2fb171)',
|
|
32
38
|
}
|
|
33
39
|
const styles = {
|
|
34
40
|
backdrop: {
|
|
35
41
|
position: 'fixed', inset: 0, zIndex: 1200, pointerEvents: 'auto',
|
|
36
|
-
background: 'rgba(0,0,0,.32)', display: 'flex',
|
|
42
|
+
background: 'var(--dsw-alias-bg-mask-1, rgba(0,0,0,.32))', display: 'flex',
|
|
43
|
+
justifyContent: 'center', alignItems: 'center',
|
|
44
|
+
padding: 'max(16px, var(--dsh-frame-overlay-top, 16px)) 16px', boxSizing: 'border-box',
|
|
37
45
|
},
|
|
38
46
|
panel: {
|
|
39
|
-
width:
|
|
47
|
+
width: 'clamp(640px, 84vw, 1120px)', maxWidth: '100%', maxHeight: '100%', minHeight: 0, height: 'min(780px, calc(100dvh - 48px))', overflow: 'hidden',
|
|
48
|
+
scrollbarWidth: 'none', msOverflowStyle: 'none',
|
|
40
49
|
display: 'flex', flexDirection: 'column',
|
|
41
|
-
background: colors.panel, color: colors.text,
|
|
42
|
-
boxShadow: '-
|
|
43
|
-
fontFamily: 'system-ui, sans-serif', fontSize: 13, boxSizing: 'border-box',
|
|
50
|
+
background: colors.panel, color: colors.text, border: `1px solid ${colors.border}`, borderRadius: 'var(--dsw-radius-panel, 16px)',
|
|
51
|
+
boxShadow: 'var(--dsw-elevation-prominent, 0 20px 60px rgba(0,0,0,.22))', padding: 24,
|
|
52
|
+
fontFamily: 'var(--dsw-font-family, system-ui, sans-serif)', fontSize: 13, boxSizing: 'border-box',
|
|
44
53
|
},
|
|
45
54
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18, flexShrink: 0 },
|
|
46
|
-
title: { margin: 0, fontSize:
|
|
47
|
-
card: { background:
|
|
55
|
+
title: { margin: 0, fontSize: 22, fontWeight: 650 },
|
|
56
|
+
card: { background: 'transparent', border: 0, borderBottom: `1px solid ${colors.border}`, borderRadius: 0, padding: '16px 0', marginBottom: 0, flexShrink: 0 },
|
|
48
57
|
label: { display: 'block', color: colors.muted, fontSize: 12, marginBottom: 6 },
|
|
49
|
-
input: { width: '100%', boxSizing: 'border-box', border: `1px solid ${colors.border}`, borderRadius:
|
|
58
|
+
input: { width: '100%', boxSizing: 'border-box', border: `1px solid ${colors.border}`, borderRadius: 10, padding: '8px 12px', marginBottom: 8, background: colors.card, color: 'inherit', outline: 'none' },
|
|
50
59
|
pairingText: { width: '100%', minHeight: 86, boxSizing: 'border-box', resize: 'vertical', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '9px 10px', margin: '10px 0', background: 'transparent', color: 'inherit', outline: 'none', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, lineHeight: 1.45, overflowWrap: 'anywhere' },
|
|
51
60
|
button: { cursor: 'pointer', border: `1px solid ${colors.border}`, background: 'transparent', borderRadius: 8, padding: '7px 11px', color: 'inherit', font: 'inherit' },
|
|
52
|
-
headerButton: { width: 34, height: 34, boxSizing: 'border-box', padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
|
|
53
|
-
primary: { background: colors.accent, color:
|
|
54
|
-
muted: { color: colors.muted, fontSize: 12, lineHeight: 1.
|
|
61
|
+
headerButton: { width: 34, height: 34, boxSizing: 'border-box', border: 0, padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
|
|
62
|
+
primary: { background: colors.accent, color: colors.accentText, borderColor: colors.accent, width: '100%' },
|
|
63
|
+
muted: { color: colors.muted, fontSize: 12, lineHeight: 1.45 },
|
|
55
64
|
row: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 12, alignItems: 'center', padding: '12px 0', borderBottom: `1px solid ${colors.border}` },
|
|
56
65
|
badge: { display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, marginLeft: 8, color: colors.muted },
|
|
57
66
|
dot: { width: 7, height: 7, borderRadius: 99, display: 'inline-block' },
|
|
58
|
-
error: { color:
|
|
67
|
+
error: { color: colors.danger, background: 'rgba(220,76,100,.12)', borderRadius: 8, padding: 10, marginBottom: 12 },
|
|
59
68
|
qr: { display: 'block', width: 220, height: 220, margin: '12px auto', background: '#fff', borderRadius: 10, padding: 8 },
|
|
60
|
-
helpButton: { width: 20, height: 20, boxSizing: 'border-box', padding: 0, borderRadius: '50%', border:
|
|
61
|
-
helpBubble: { position: 'absolute', top: '100%', left: 0, zIndex: 20, width: 'min(300px, calc(100vw - 88px))', maxHeight: 'min(440px, calc(100vh - 160px))', overflowY: 'auto', boxSizing: 'border-box', padding: 12, borderRadius: 10, border:
|
|
62
|
-
toolsMenu: { position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 30, width: 250, maxWidth: 'calc(100vw - 48px)', border: `1px solid ${colors.border}`, borderRadius: 10, padding: 5, background:
|
|
69
|
+
helpButton: { width: 20, height: 20, boxSizing: 'border-box', padding: 0, borderRadius: '50%', border: 0, background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.1))', color: 'var(--dsw-alias-label-tertiary, #888)', cursor: 'help', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 12, fontWeight: 600, lineHeight: 1 },
|
|
70
|
+
helpBubble: { position: 'absolute', top: '100%', left: 0, zIndex: 20, width: 'min(300px, calc(100vw - 88px))', maxHeight: 'min(440px, calc(100vh - 160px))', overflowY: 'auto', boxSizing: 'border-box', padding: 12, borderRadius: 10, border: `1px solid ${colors.border}`, background: colors.panel, color: colors.text, boxShadow: '0 12px 28px rgba(0,0,0,.35)', fontSize: 12, lineHeight: 1.5 },
|
|
71
|
+
toolsMenu: { position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 30, width: 250, maxWidth: 'calc(100vw - 48px)', border: `1px solid ${colors.border}`, borderRadius: 10, padding: 5, background: colors.panel, boxShadow: '0 12px 28px rgba(0,0,0,.35)' },
|
|
63
72
|
toolsItem: { display: 'block', width: '100%', padding: '9px 10px', textAlign: 'left', border: 0, borderRadius: 7, background: 'transparent', color: colors.text, font: 'inherit', cursor: 'pointer' },
|
|
64
73
|
switch: { position: 'relative', display: 'inline-flex', width: 42, height: 24, flexShrink: 0 },
|
|
65
74
|
switchInput: { position: 'absolute', opacity: 0, width: 1, height: 1 },
|
|
@@ -68,6 +77,154 @@ window.__ModuleLoader__.load({
|
|
|
68
77
|
version: { flexShrink: 0, marginTop: 'auto', padding: '18px 2px 0', color: colors.muted, fontSize: 11, lineHeight: 1.4, textAlign: 'right', letterSpacing: '.02em' },
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
const panelCss = `
|
|
81
|
+
@keyframes mgw-pairing-enter {
|
|
82
|
+
from { opacity: 0; transform: scale(.82); }
|
|
83
|
+
to { opacity: 1; transform: scale(1); }
|
|
84
|
+
}
|
|
85
|
+
.mgw-pairing-dialog {
|
|
86
|
+
transform-origin: center;
|
|
87
|
+
animation: mgw-pairing-enter 240ms cubic-bezier(.2, .8, .2, 1);
|
|
88
|
+
}
|
|
89
|
+
@keyframes mgw-pairing-exit {
|
|
90
|
+
from { opacity: 1; transform: scale(1); }
|
|
91
|
+
to { opacity: 0; transform: scale(.82); }
|
|
92
|
+
}
|
|
93
|
+
.mgw-pairing-dialog[data-closing="true"] {
|
|
94
|
+
animation: mgw-pairing-exit 180ms ease-in forwards;
|
|
95
|
+
pointer-events: none;
|
|
96
|
+
}
|
|
97
|
+
@media (prefers-reduced-motion: reduce) {
|
|
98
|
+
.mgw-pairing-dialog { animation: none; }
|
|
99
|
+
}
|
|
100
|
+
.mobile-gateway-panel, .mobile-gateway-panel *, .mgw-pairing-dialog, .mgw-pairing-dialog *, .mgw-select-menu {
|
|
101
|
+
scrollbar-width: none !important;
|
|
102
|
+
-ms-overflow-style: none;
|
|
103
|
+
}
|
|
104
|
+
.mobile-gateway-panel::-webkit-scrollbar, .mobile-gateway-panel *::-webkit-scrollbar,
|
|
105
|
+
.mgw-pairing-dialog::-webkit-scrollbar, .mgw-pairing-dialog *::-webkit-scrollbar, .mgw-select-menu::-webkit-scrollbar {
|
|
106
|
+
display: none !important;
|
|
107
|
+
width: 0;
|
|
108
|
+
height: 0;
|
|
109
|
+
}
|
|
110
|
+
.mobile-gateway-panel .mgw-body { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 28px; flex: 1; min-height: 0; }
|
|
111
|
+
.mobile-gateway-panel .mgw-nav { display: flex; flex-direction: column; gap: 4px; }
|
|
112
|
+
.mobile-gateway-panel .mgw-nav button { text-align: left; border: 0; padding: 12px 14px; border-radius: 10px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
|
113
|
+
.mobile-gateway-panel .mgw-nav button[aria-current="page"] { background: var(--dsw-alias-bg-overlay, rgba(127,127,127,.12)); font-weight: 500; }
|
|
114
|
+
.mobile-gateway-panel .mgw-nav button:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.08)); }
|
|
115
|
+
.mobile-gateway-panel .mgw-content { min-width: 0; overflow-y: auto; scrollbar-width: none; padding-right: 4px; }
|
|
116
|
+
.mobile-gateway-panel .mgw-content::-webkit-scrollbar { display: none; }
|
|
117
|
+
.mobile-gateway-panel .mgw-content p { margin: 6px 0 10px; }
|
|
118
|
+
.mobile-gateway-panel strong { font-weight: 550; }
|
|
119
|
+
.mobile-gateway-panel .mgw-section-title { margin: 0 0 10px; font-size: 17px; line-height: 1.4; font-weight: 650; color: inherit; }
|
|
120
|
+
.mobile-gateway-panel .mgw-setting-row { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; margin-bottom: 12px; }
|
|
121
|
+
.mobile-gateway-panel .mgw-setting-row select { width: auto !important; max-width: 100%; min-width: 150px; margin-bottom: 0 !important; flex-shrink: 1; }
|
|
122
|
+
.mobile-gateway-panel .mgw-cloudflare-block input, .mobile-gateway-panel .mgw-cloudflare-block .mgw-select-trigger { background: var(--dsw-alias-bg-layer-2, Canvas) !important; }
|
|
123
|
+
.mobile-gateway-panel .mgw-setting-label { font-size: 14px; font-weight: 550; color: inherit; }
|
|
124
|
+
|
|
125
|
+
.mobile-gateway-panel input:focus-visible, .mobile-gateway-panel select:focus-visible, .mobile-gateway-panel button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary, currentColor); outline-offset: 2px; }
|
|
126
|
+
@media (max-width: 640px) {
|
|
127
|
+
.mobile-gateway-panel .mgw-body { grid-template-columns: minmax(0, 1fr); gap: 8px; }
|
|
128
|
+
.mobile-gateway-panel .mgw-nav { flex-direction: row; }
|
|
129
|
+
.mobile-gateway-panel .mgw-nav button { flex: 1; padding: 10px 6px; text-align: center; }
|
|
130
|
+
}
|
|
131
|
+
`
|
|
132
|
+
|
|
133
|
+
function GatewaySelect({ style, children, value, onChange, disabled, ...props }) {
|
|
134
|
+
const options = React.Children.toArray(children).filter((child) => React.isValidElement(child))
|
|
135
|
+
const [menu, setMenu] = React.useState(null)
|
|
136
|
+
const [active, setActive] = React.useState(0)
|
|
137
|
+
const buttonRef = React.useRef(null)
|
|
138
|
+
const menuRef = React.useRef(null)
|
|
139
|
+
const menuId = React.useId()
|
|
140
|
+
const selected = options.find((option) => option.props.value === value)
|
|
141
|
+
const enabled = options.map((option, index) => option.props.disabled ? -1 : index).filter((index) => index >= 0)
|
|
142
|
+
const close = (restoreFocus = false) => {
|
|
143
|
+
setMenu(null)
|
|
144
|
+
if (restoreFocus) buttonRef.current?.focus()
|
|
145
|
+
}
|
|
146
|
+
const openMenu = () => {
|
|
147
|
+
if (disabled) return
|
|
148
|
+
const rect = buttonRef.current.getBoundingClientRect()
|
|
149
|
+
const computed = window.getComputedStyle(buttonRef.current)
|
|
150
|
+
const fontSize = parseFloat(computed.fontSize) || 14
|
|
151
|
+
const lineHeight = parseFloat(computed.lineHeight) || fontSize * 1.5
|
|
152
|
+
const measure = document.createElement('canvas').getContext('2d')
|
|
153
|
+
if (measure) measure.font = computed.font || `${fontSize}px sans-serif`
|
|
154
|
+
const longest = Math.max(...options.map((option) => measure
|
|
155
|
+
? measure.measureText(String(option.props.children)).width : String(option.props.children).length * fontSize))
|
|
156
|
+
const height = Math.min(options.length * Math.max(40, lineHeight + 16) + 14, window.innerHeight - 24)
|
|
157
|
+
const width = Math.min(Math.max(rect.width, longest + 72, 200), window.innerWidth - 24)
|
|
158
|
+
setActive(Math.max(0, options.findIndex((option) => option.props.value === value && !option.props.disabled)))
|
|
159
|
+
setMenu({ left: Math.max(12, Math.min(rect.left, window.innerWidth - width - 12)),
|
|
160
|
+
top: Math.max(12, rect.bottom + height + 6 <= window.innerHeight - 12 ? rect.bottom + 6 : rect.top - height - 6), width, maxHeight: window.innerHeight - 24 })
|
|
161
|
+
}
|
|
162
|
+
const choose = (index) => {
|
|
163
|
+
const option = options[index]
|
|
164
|
+
if (!option || option.props.disabled) return
|
|
165
|
+
onChange?.({ target: { value: option.props.value } })
|
|
166
|
+
close(true)
|
|
167
|
+
}
|
|
168
|
+
React.useEffect(() => {
|
|
169
|
+
if (!menu) return
|
|
170
|
+
menuRef.current?.focus()
|
|
171
|
+
const outside = (event) => {
|
|
172
|
+
if (!buttonRef.current?.contains(event.target) && !menuRef.current?.contains(event.target)) close()
|
|
173
|
+
}
|
|
174
|
+
const scrolled = (event) => { if (!menuRef.current?.contains(event.target)) close() }
|
|
175
|
+
const resized = () => close()
|
|
176
|
+
document.addEventListener('pointerdown', outside)
|
|
177
|
+
document.addEventListener('scroll', scrolled, true)
|
|
178
|
+
window.addEventListener('resize', resized)
|
|
179
|
+
return () => {
|
|
180
|
+
document.removeEventListener('pointerdown', outside)
|
|
181
|
+
document.removeEventListener('scroll', scrolled, true)
|
|
182
|
+
window.removeEventListener('resize', resized)
|
|
183
|
+
}
|
|
184
|
+
}, [menu])
|
|
185
|
+
React.useEffect(() => {
|
|
186
|
+
if (menu) document.getElementById(`${menuId}-${active}`)?.scrollIntoView({ block: 'nearest' })
|
|
187
|
+
}, [active, menu])
|
|
188
|
+
return React.createElement('span', { style: { display: 'inline-flex', maxWidth: '100%', minWidth: 0 } },
|
|
189
|
+
React.createElement('button', { ...props, className: 'mgw-select-trigger', ref: buttonRef, type: 'button', disabled,
|
|
190
|
+
'aria-haspopup': 'listbox', 'aria-expanded': !!menu, 'aria-controls': menu ? menuId : undefined,
|
|
191
|
+
onClick: () => menu ? close() : openMenu(),
|
|
192
|
+
onKeyDown: (event) => {
|
|
193
|
+
if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); openMenu() }
|
|
194
|
+
},
|
|
195
|
+
style: { ...style, width: 'auto', maxWidth: '100%', marginBottom: 0, border: 0, borderRadius: 12,
|
|
196
|
+
padding: '10px 16px', background: colors.card, color: colors.text, font: 'inherit', cursor: disabled ? 'default' : 'pointer',
|
|
197
|
+
display: 'inline-flex', alignItems: 'center', gap: 18, opacity: disabled ? .5 : 1 } },
|
|
198
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, selected?.props.children || value),
|
|
199
|
+
React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
|
|
200
|
+
strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, style: { flexShrink: 0 } },
|
|
201
|
+
React.createElement('path', { d: 'M6 9l6 6 6-6' }))),
|
|
202
|
+
menu ? React.createElement('div', { className: 'mgw-select-menu', ref: menuRef, id: menuId, role: 'listbox', tabIndex: -1,
|
|
203
|
+
'aria-label': props['aria-label'], 'aria-activedescendant': `${menuId}-${active}`,
|
|
204
|
+
style: { ...menu, position: 'fixed', zIndex: 1250, boxSizing: 'border-box', overflowY: 'auto', padding: 6,
|
|
205
|
+
background: colors.panel, color: colors.text, border: `1px solid ${colors.border}`, borderRadius: 14,
|
|
206
|
+
boxShadow: 'var(--dsw-elevation-prominent, 0 8px 28px rgba(0,0,0,.12))', outline: 'none' },
|
|
207
|
+
onKeyDown: (event) => {
|
|
208
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true) }
|
|
209
|
+
else if (event.key === 'Tab') close()
|
|
210
|
+
else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(active) }
|
|
211
|
+
else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
|
212
|
+
event.preventDefault()
|
|
213
|
+
const index = enabled.indexOf(active)
|
|
214
|
+
setActive(event.key === 'Home' ? enabled[0] : event.key === 'End' ? enabled[enabled.length - 1]
|
|
215
|
+
: enabled[(index + (event.key === 'ArrowDown' ? 1 : -1) + enabled.length) % enabled.length])
|
|
216
|
+
}
|
|
217
|
+
} }, options.map((option, index) => React.createElement('div', { key: option.props.value,
|
|
218
|
+
id: `${menuId}-${index}`, role: 'option', 'aria-selected': option.props.value === value, 'aria-disabled': !!option.props.disabled,
|
|
219
|
+
onPointerMove: () => { if (!option.props.disabled) setActive(index) }, onClick: () => choose(index),
|
|
220
|
+
style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, minHeight: 40, whiteSpace: 'nowrap',
|
|
221
|
+
boxSizing: 'border-box', padding: '8px 12px', borderRadius: 8, cursor: option.props.disabled ? 'default' : 'pointer',
|
|
222
|
+
opacity: option.props.disabled ? .4 : 1, background: active === index ? 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.08))' : 'transparent' } },
|
|
223
|
+
React.createElement('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }, title: String(option.props.children) }, option.props.children), option.props.value === value ? React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 24 24',
|
|
224
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, style: { flexShrink: 0 } },
|
|
225
|
+
React.createElement('path', { d: 'M5 12l4 4L19 6' })) : null))) : null)
|
|
226
|
+
}
|
|
227
|
+
|
|
71
228
|
async function request(path, options) {
|
|
72
229
|
const response = await fetch(path, { credentials: 'same-origin', ...options })
|
|
73
230
|
const body = await response.json().catch(() => ({}))
|
|
@@ -99,10 +256,10 @@ window.__ModuleLoader__.load({
|
|
|
99
256
|
const system = status.platform === 'darwin' ? 'macOS 电脑' : status.platform === 'win32' ? 'Windows 电脑' : status.platform === 'linux' ? 'Linux 服务器' : '当前设备'
|
|
100
257
|
const lanReady = !!lanPairingUrl(status)
|
|
101
258
|
const tunnel = status.cloudflare
|
|
102
|
-
const tunnelReady = !!(tunnel?.enabled && tunnel.
|
|
259
|
+
const tunnelReady = !!(tunnel?.enabled && tunnel.publicReady === true && tunnel.publicUrl)
|
|
103
260
|
const serverReady = status.platform === 'linux' && !!(
|
|
104
261
|
(publicSetup?.configured && publicSetup.publicUrl) || /^wss:\/\//.test(status.publicUrl || ''))
|
|
105
|
-
const detected = `当前检测:${system};局域网入口${lanReady ? '已就绪' : '未就绪'};${serverReady ? '公网服务器入口已配置' : tunnelReady ? `${tunnel.mode === 'quick' ? 'Quick' : '命名'} Tunnel 已连接` : tunnel?.enabled ? 'Cloudflare Tunnel 连接中' : '外网入口未连接'}。`
|
|
262
|
+
const detected = `当前检测:${system};局域网入口${lanReady ? '已就绪' : '未就绪'};${serverReady ? '公网服务器入口已配置' : tunnelReady ? `${tunnel.mode === 'quick' ? 'Quick' : '命名'} Tunnel 已连接` : tunnel?.enabled ? (tunnel.state === 'online' ? '隧道已连接,公网入口未验证或检测失败' : 'Cloudflare Tunnel 连接中') : '外网入口未连接'}。`
|
|
106
263
|
const local = lanReady
|
|
107
264
|
? '手机与 DSH 处于可互访的同一局域网时,建议选“局域网直连”。'
|
|
108
265
|
: '如果手机和 DSH 在同一局域网,先确认局域网入口已开启并监听成功。'
|
|
@@ -125,10 +282,10 @@ window.__ModuleLoader__.load({
|
|
|
125
282
|
return React.createElement('div', { style: styles.row },
|
|
126
283
|
React.createElement('div', { style: { minWidth: 0, overflow: 'hidden' } },
|
|
127
284
|
React.createElement('div', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
128
|
-
device.name,
|
|
285
|
+
device.displayName || device.name,
|
|
129
286
|
React.createElement('span', { style: styles.badge },
|
|
130
287
|
React.createElement('span', { style: { ...styles.dot, background: device.online ? colors.success : colors.muted } }),
|
|
131
|
-
device.online ?
|
|
288
|
+
device.online ? '在线' : '离线')),
|
|
132
289
|
React.createElement('div', { style: { ...styles.muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginTop: 3 } },
|
|
133
290
|
device.lastSeenAt ? `最近连接:${new Date(device.lastSeenAt).toLocaleString()}` : '尚未连接')),
|
|
134
291
|
React.createElement('button', {
|
|
@@ -143,7 +300,7 @@ window.__ModuleLoader__.load({
|
|
|
143
300
|
minWidth: 54,
|
|
144
301
|
opacity: revoking ? .6 : 1,
|
|
145
302
|
},
|
|
146
|
-
title: `吊销 ${device.name}`,
|
|
303
|
+
title: `吊销 ${device.displayName || device.name}`,
|
|
147
304
|
// Pointer-up is more reliable than click inside the shell overlay on
|
|
148
305
|
// touch devices. The click handler remains for keyboard activation.
|
|
149
306
|
onPointerUp: triggerRevoke,
|
|
@@ -154,8 +311,11 @@ window.__ModuleLoader__.load({
|
|
|
154
311
|
}
|
|
155
312
|
|
|
156
313
|
function DevicePanel() {
|
|
314
|
+
const [activeSection, setActiveSection] = React.useState('general')
|
|
157
315
|
const [devices, setDevices] = React.useState(null)
|
|
316
|
+
const [showAllDevices, setShowAllDevices] = React.useState(false)
|
|
158
317
|
const [status, setStatus] = React.useState(null)
|
|
318
|
+
const [dismissedNotice, setDismissedNotice] = React.useState(() => dismissedRestartInstance)
|
|
159
319
|
const [publicUrl, setPublicUrl] = React.useState('')
|
|
160
320
|
const [pairingRoute, setPairingRoute] = React.useState('auto')
|
|
161
321
|
const [connectionHelpOpen, setConnectionHelpOpen] = React.useState(false)
|
|
@@ -170,8 +330,32 @@ window.__ModuleLoader__.load({
|
|
|
170
330
|
const [cloudflareToken, setCloudflareToken] = React.useState('')
|
|
171
331
|
const [cloudflareMode, setCloudflareMode] = React.useState('quick')
|
|
172
332
|
const [cloudflareBusy, setCloudflareBusy] = React.useState(false)
|
|
173
|
-
const [deviceName, setDeviceName] = React.useState('iPhone')
|
|
174
333
|
const [qr, setQr] = React.useState(null)
|
|
334
|
+
const pairingDialogRef = React.useRef(null)
|
|
335
|
+
const [pairingClosing, setPairingClosing] = React.useState(false)
|
|
336
|
+
const pairingCloseTimer = React.useRef(null)
|
|
337
|
+
const closePairingDialog = () => {
|
|
338
|
+
if (pairingCloseTimer.current !== null) return
|
|
339
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
340
|
+
setQr(null)
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
setPairingClosing(true)
|
|
344
|
+
pairingCloseTimer.current = window.setTimeout(() => {
|
|
345
|
+
pairingCloseTimer.current = null
|
|
346
|
+
setQr(null)
|
|
347
|
+
setPairingClosing(false)
|
|
348
|
+
}, 180)
|
|
349
|
+
}
|
|
350
|
+
React.useEffect(() => {
|
|
351
|
+
setPairingClosing(false)
|
|
352
|
+
return () => {
|
|
353
|
+
if (pairingCloseTimer.current !== null) {
|
|
354
|
+
window.clearTimeout(pairingCloseTimer.current)
|
|
355
|
+
pairingCloseTimer.current = null
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}, [qr])
|
|
175
359
|
const [error, setError] = React.useState(null)
|
|
176
360
|
const [busy, setBusy] = React.useState(false)
|
|
177
361
|
const [refreshing, setRefreshing] = React.useState(false)
|
|
@@ -288,6 +472,13 @@ window.__ModuleLoader__.load({
|
|
|
288
472
|
return () => { active = false; window.clearInterval(timer) }
|
|
289
473
|
}, [refresh])
|
|
290
474
|
|
|
475
|
+
React.useEffect(() => {
|
|
476
|
+
if (!qr) return
|
|
477
|
+
const previousFocus = document.activeElement
|
|
478
|
+
pairingDialogRef.current?.querySelector('button')?.focus()
|
|
479
|
+
return () => previousFocus?.focus()
|
|
480
|
+
}, [qr])
|
|
481
|
+
|
|
291
482
|
const choosePairingRoute = (route) => {
|
|
292
483
|
pairingRouteRef.current = route
|
|
293
484
|
setPairingRoute(route)
|
|
@@ -325,7 +516,7 @@ window.__ModuleLoader__.load({
|
|
|
325
516
|
}
|
|
326
517
|
}
|
|
327
518
|
|
|
328
|
-
const configureCloudflare = async (enabled) => {
|
|
519
|
+
const configureCloudflare = async (enabled, mode = cloudflareMode) => {
|
|
329
520
|
if (cloudflareBusy) return
|
|
330
521
|
setCloudflareBusy(true)
|
|
331
522
|
setError(null)
|
|
@@ -334,8 +525,8 @@ window.__ModuleLoader__.load({
|
|
|
334
525
|
method: 'POST',
|
|
335
526
|
headers: { 'Content-Type': 'application/json' },
|
|
336
527
|
body: JSON.stringify({ enabled, ...(enabled ? {
|
|
337
|
-
mode
|
|
338
|
-
...(
|
|
528
|
+
mode,
|
|
529
|
+
...(mode === 'named' ? { hostname: cloudflareHost, token: cloudflareToken } : {}),
|
|
339
530
|
} : {}) }),
|
|
340
531
|
})
|
|
341
532
|
setCloudflareToken('')
|
|
@@ -352,6 +543,17 @@ window.__ModuleLoader__.load({
|
|
|
352
543
|
}
|
|
353
544
|
}
|
|
354
545
|
|
|
546
|
+
const checkCloudflare = async () => {
|
|
547
|
+
if (cloudflareBusy) return
|
|
548
|
+
setCloudflareBusy(true)
|
|
549
|
+
setError(null)
|
|
550
|
+
try {
|
|
551
|
+
const data = await request('/mgw/cloudflare/check', { method: 'POST' })
|
|
552
|
+
setStatus((current) => ({ ...(current || {}), cloudflare: data }))
|
|
553
|
+
} catch (cause) { setError(cause.message) }
|
|
554
|
+
finally { setCloudflareBusy(false) }
|
|
555
|
+
}
|
|
556
|
+
|
|
355
557
|
const restartCloudflare = async () => {
|
|
356
558
|
if (cloudflareBusy) return
|
|
357
559
|
setCloudflareBusy(true)
|
|
@@ -371,7 +573,7 @@ window.__ModuleLoader__.load({
|
|
|
371
573
|
const createPairing = async (endpoint) => request('/mgw/pair', {
|
|
372
574
|
method: 'POST',
|
|
373
575
|
headers: { 'Content-Type': 'application/json' },
|
|
374
|
-
body: JSON.stringify({
|
|
576
|
+
body: JSON.stringify({ publicUrl: endpoint }),
|
|
375
577
|
})
|
|
376
578
|
|
|
377
579
|
const pair = async () => {
|
|
@@ -451,7 +653,7 @@ window.__ModuleLoader__.load({
|
|
|
451
653
|
try {
|
|
452
654
|
await request(`/mgw/devices/${encodeURIComponent(device.id)}/revoke`, { method: 'POST' })
|
|
453
655
|
setDevices((current) => Array.isArray(current) ? current.filter((item) => item.id !== device.id) : current)
|
|
454
|
-
setDeviceNotice(`已吊销 ${device.name}`)
|
|
656
|
+
setDeviceNotice(`已吊销 ${device.displayName || device.name}`)
|
|
455
657
|
window.setTimeout(() => setDeviceNotice(null), 2200)
|
|
456
658
|
} catch (cause) {
|
|
457
659
|
setDeviceNoticeError(true)
|
|
@@ -468,23 +670,59 @@ window.__ModuleLoader__.load({
|
|
|
468
670
|
}
|
|
469
671
|
}
|
|
470
672
|
|
|
673
|
+
const restartNoticeInstance = status?.gatewayInstanceId || (status?.webPid ? String(status.webPid) : null)
|
|
674
|
+
const dismissRestartNotice = () => {
|
|
675
|
+
dismissedRestartInstance = restartNoticeInstance
|
|
676
|
+
setDismissedNotice(restartNoticeInstance)
|
|
677
|
+
try { window.localStorage.setItem(restartNoticeStorageKey, restartNoticeInstance) } catch { /* keep in-memory dismissal */ }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const onlineDevices = (devices || []).filter((device) => device.online)
|
|
681
|
+
const offlineDevices = (devices || []).filter((device) => !device.online)
|
|
682
|
+
const visibleDevices = showAllDevices ? [...onlineDevices, ...offlineDevices] : onlineDevices
|
|
683
|
+
|
|
471
684
|
const advice = connectionAdvice(status, publicSetup)
|
|
472
685
|
const helpLine = (name, description) => React.createElement('div', { style: { marginTop: 7 } },
|
|
473
686
|
React.createElement('strong', null, name), ':', description)
|
|
474
687
|
|
|
475
688
|
return React.createElement('div', { style: styles.backdrop, onMouseDown: () => setOpen(false) },
|
|
476
|
-
React.createElement('
|
|
689
|
+
React.createElement('style', null, panelCss),
|
|
690
|
+
qr ? React.createElement('div', { style: { ...styles.backdrop, zIndex: 1201 },
|
|
691
|
+
onMouseDown: (event) => { event.stopPropagation(); closePairingDialog() } },
|
|
692
|
+
React.createElement('div', { className: 'mgw-pairing-dialog', 'data-closing': pairingClosing, ref: pairingDialogRef, role: 'dialog', 'aria-modal': true, 'aria-label': '设备配对二维码', tabIndex: -1,
|
|
693
|
+
style: { ...styles.panel, width: 'min(520px, 100%)', height: 'auto', overflowY: 'auto', scrollbarWidth: 'none' },
|
|
694
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
695
|
+
onKeyDown: (event) => {
|
|
696
|
+
if (event.key === 'Escape') { event.stopPropagation(); closePairingDialog() }
|
|
697
|
+
if (event.key === 'Tab') {
|
|
698
|
+
const items = [...event.currentTarget.querySelectorAll('button, textarea')]
|
|
699
|
+
const first = items[0], last = items[items.length - 1]
|
|
700
|
+
if (event.shiftKey && (document.activeElement === first || document.activeElement === event.currentTarget)) { event.preventDefault(); last?.focus() }
|
|
701
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
|
|
702
|
+
}
|
|
703
|
+
} },
|
|
704
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end' } },
|
|
705
|
+
React.createElement('button', { type: 'button', style: { ...styles.button, ...styles.headerButton }, 'aria-label': '关闭配对窗口', onClick: closePairingDialog }, '✕')),
|
|
706
|
+
React.createElement('section', { style: { textAlign: 'center' } },
|
|
707
|
+
React.createElement('strong', null, '使用移动客户端扫码'),
|
|
708
|
+
React.createElement('img', { style: styles.qr, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.svg)}`, alt: '一次性设备配对二维码' }),
|
|
709
|
+
React.createElement('label', { style: { ...styles.label, textAlign: 'left' } }, 'Base64URL 配对字符串'),
|
|
710
|
+
React.createElement('textarea', { style: styles.pairingText, value: qr.qrPayload, readOnly: true, spellCheck: false, onFocus: (event) => event.target.select() }),
|
|
711
|
+
React.createElement('button', { style: { ...styles.button, width: '100%', marginBottom: 8, borderColor: pairingTokenCopied ? colors.success : colors.border, color: pairingTokenCopied ? colors.success : colors.text }, onClick: copyPairingText }, pairingTokenCopied ? '✓ 已复制' : '复制配对 Token'),
|
|
712
|
+
pairingTokenCopied ? React.createElement('div', { style: { ...styles.muted, color: colors.success, marginBottom: 8 } }, '已复制到剪贴板') : null,
|
|
713
|
+
React.createElement('div', { style: styles.muted }, `有效期至 ${new Date(qr.pairing.expiresAt).toLocaleTimeString()};扫码成功后此二维码立即失效。`)))) : null,
|
|
714
|
+
React.createElement('aside', { className: 'mobile-gateway-panel', inert: qr ? true : undefined, 'aria-hidden': qr ? true : undefined, style: styles.panel, onMouseDown: (event) => { event.stopPropagation(); setToolsOpen(false) }, role: 'dialog', 'aria-modal': true, 'aria-label': '移动设备管理' },
|
|
477
715
|
React.createElement('div', { style: styles.header },
|
|
478
716
|
React.createElement('div', null,
|
|
479
717
|
React.createElement('h2', { style: styles.title }, '移动设备'),
|
|
480
|
-
React.createElement('div', { style: styles.muted }, status
|
|
718
|
+
React.createElement('div', { style: styles.muted }, !status ? '正在加载网关状态…' : status.gatewayEnabled ? `移动网关已开启 · ${status.requireAuth ? '设备鉴权已开启' : '本机设备鉴权已关闭'}` : '移动网关已关闭 · 普通 WebUI 模式')),
|
|
481
719
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
482
720
|
React.createElement('div', { style: { position: 'relative' }, onMouseDown: (event) => event.stopPropagation() },
|
|
483
721
|
React.createElement('button', {
|
|
484
722
|
type: 'button', style: { ...styles.button, ...styles.headerButton, borderRadius: '50%' }, title: '工具', 'aria-label': '工具',
|
|
485
723
|
'aria-haspopup': 'menu', 'aria-expanded': toolsOpen,
|
|
486
724
|
onClick: () => setToolsOpen((value) => !value),
|
|
487
|
-
}, React.createElement('svg', { width:
|
|
725
|
+
}, React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
488
726
|
React.createElement('path', { d: 'M10.3 2.8h3.4l.5 2.2a7.6 7.6 0 0 1 1.7.7l1.9-1.2 2.4 2.4L19 8.8c.3.5.5 1.1.7 1.7l2.2.5v3.4l-2.2.5a7.6 7.6 0 0 1-.7 1.7l1.2 1.9-2.4 2.4-1.9-1.2a7.6 7.6 0 0 1-1.7.7l-.5 2.2h-3.4l-.5-2.2a7.6 7.6 0 0 1-1.7-.7l-1.9 1.2-2.4-2.4L5 16.6a7.6 7.6 0 0 1-.7-1.7l-2.2-.5V11l2.2-.5A7.6 7.6 0 0 1 5 8.8L3.8 6.9l2.4-2.4 1.9 1.2a7.6 7.6 0 0 1 1.7-.7l.5-2.2Z' }),
|
|
489
727
|
React.createElement('circle', { cx: 12, cy: 12.7, r: 3.1 }))),
|
|
490
728
|
toolsOpen ? React.createElement('div', { style: styles.toolsMenu, role: 'menu', 'aria-label': '网关工具' },
|
|
@@ -501,22 +739,40 @@ window.__ModuleLoader__.load({
|
|
|
501
739
|
}, '停止 DSH Web',
|
|
502
740
|
React.createElement('span', { style: { ...styles.muted, display: 'block', marginTop: 2 } }, '结束后台进程,所有连接将断开')))
|
|
503
741
|
: null),
|
|
504
|
-
React.createElement('button', { style: { ...styles.button, ...styles.headerButton }, onClick: () => setOpen(false), 'aria-label': '关闭' },
|
|
742
|
+
React.createElement('button', { style: { ...styles.button, ...styles.headerButton }, onClick: () => setOpen(false), 'aria-label': '关闭' },
|
|
743
|
+
React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round', 'aria-hidden': true },
|
|
744
|
+
React.createElement('path', { d: 'M6 6l12 12M18 6L6 18' }))))),
|
|
745
|
+
React.createElement('div', { className: 'mgw-body' },
|
|
746
|
+
React.createElement('nav', { className: 'mgw-nav', 'aria-label': '移动网关设置分组' },
|
|
747
|
+
[['general', '通用设置'], ['pairing', '连接与配对'], ['devices', '可信设备']].map(([id, label]) =>
|
|
748
|
+
React.createElement('button', { key: id, type: 'button', 'aria-current': activeSection === id ? 'page' : undefined,
|
|
749
|
+
onClick: () => { setActiveSection(id); setConnectionHelpOpen(false); setToolsOpen(false) } }, label))),
|
|
750
|
+
React.createElement('div', { className: 'mgw-content', key: activeSection },
|
|
751
|
+
restartNoticeInstance && dismissedNotice !== restartNoticeInstance ? React.createElement('section', { style: { ...styles.card, position: 'relative', padding: '10px 40px 10px 12px', border: '1px solid', borderRadius: 10, marginBottom: 8, background: 'var(--dsw-alias-state-warn-tertiary, #fff7df)', borderColor: 'color-mix(in srgb, #d99a16 35%, transparent)', fontSize: 12, lineHeight: 1.6 }, role: 'note' },
|
|
752
|
+
React.createElement('button', { type: 'button', 'aria-label': '关闭重启提醒', title: '关闭提醒',
|
|
753
|
+
style: { ...styles.button, position: 'absolute', right: 8, top: 8, border: 0, width: 24, height: 24, padding: 0, color: colors.muted },
|
|
754
|
+
onClick: dismissRestartNotice }, '✕'),
|
|
755
|
+
React.createElement('strong', null, '若安装或更新后连接异常,请重启应用'),
|
|
756
|
+
React.createElement('div', { style: { color: colors.muted, marginTop: 4 } },
|
|
757
|
+
'完全退出并重启 APP/Desktop;Web UI 请重启 DSH Web 服务。'),
|
|
758
|
+
React.createElement('div', { style: { color: colors.muted, marginTop: 4 } },
|
|
759
|
+
'端口仍被占用时,请关闭其他 Harness 实例。')) : null,
|
|
505
760
|
toolNotice ? React.createElement('div', { style: { ...styles.muted, marginBottom: 10 } }, toolNotice) : null,
|
|
506
761
|
error ? React.createElement('div', { style: styles.error }, error) : null,
|
|
762
|
+
React.createElement('div', { hidden: activeSection !== 'general' },
|
|
507
763
|
React.createElement('section', { style: styles.card },
|
|
508
764
|
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
|
|
509
765
|
React.createElement('div', null,
|
|
510
766
|
React.createElement('strong', null, '网关运行模式'),
|
|
511
767
|
React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.gatewayEnabled
|
|
512
768
|
? status.gatewayMode === 'persistent'
|
|
513
|
-
? '
|
|
769
|
+
? '重启后保持开启'
|
|
514
770
|
: status.waitExpiresAt
|
|
515
771
|
? `等待可信设备连接,约 ${Math.max(1, Math.ceil((status.waitExpiresAt - Date.now()) / 60000))} 分钟后自动关闭`
|
|
516
772
|
: '设备已连接过,本次运行保持开启;重启后重新计时'
|
|
517
|
-
: '
|
|
773
|
+
: '不接受移动设备连接')),
|
|
518
774
|
React.createElement('span', { style: { position: 'relative', display: 'inline-flex', flexShrink: 0 } },
|
|
519
|
-
React.createElement(
|
|
775
|
+
React.createElement(GatewaySelect, {
|
|
520
776
|
style: { ...styles.input, width: 'auto', marginBottom: 0, padding: '8px 40px 8px 12px', appearance: 'none', WebkitAppearance: 'none' },
|
|
521
777
|
'aria-label': '网关运行模式',
|
|
522
778
|
value: status ? status.gatewayMode || (status.gatewayEnabled ? 'temporary' : 'disabled') : 'disabled',
|
|
@@ -526,16 +782,9 @@ window.__ModuleLoader__.load({
|
|
|
526
782
|
React.createElement('option', { value: 'disabled' }, '关闭'),
|
|
527
783
|
React.createElement('option', { value: 'temporary' }, '临时开启'),
|
|
528
784
|
React.createElement('option', { value: 'persistent' }, '常驻开启')),
|
|
529
|
-
|
|
530
|
-
'aria-hidden': true,
|
|
531
|
-
style: {
|
|
532
|
-
position: 'absolute', right: 15, top: '50%', width: 7, height: 7,
|
|
533
|
-
borderRight: `1.5px solid ${colors.text}`, borderBottom: `1.5px solid ${colors.text}`,
|
|
534
|
-
transform: 'translateY(-65%) rotate(45deg)', pointerEvents: 'none',
|
|
535
|
-
},
|
|
536
|
-
}))),
|
|
785
|
+
)),
|
|
537
786
|
status && status.gatewayId ? React.createElement('div', { style: { ...styles.muted, marginTop: 12, overflowWrap: 'anywhere' } },
|
|
538
|
-
|
|
787
|
+
status.gatewayName) : null),
|
|
539
788
|
React.createElement('section', { style: styles.card },
|
|
540
789
|
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
|
|
541
790
|
React.createElement('div', null,
|
|
@@ -557,39 +806,115 @@ window.__ModuleLoader__.load({
|
|
|
557
806
|
status && !status.requireAuth
|
|
558
807
|
? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:本机 DSH 入口将跳过设备凭证校验;独立局域网入口仍强制鉴权。')
|
|
559
808
|
: null),
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
809
|
+
),
|
|
810
|
+
React.createElement('div', { hidden: activeSection !== 'pairing' },
|
|
811
|
+
React.createElement('section', { style: styles.card },
|
|
812
|
+
React.createElement('div', { className: 'mgw-setting-row' },
|
|
813
|
+
React.createElement('div', { style: { position: 'relative', display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }, onMouseLeave: () => setConnectionHelpOpen(false) },
|
|
814
|
+
React.createElement('h3', { className: 'mgw-section-title', style: { marginBottom: 0 } }, '配对连接方式'),
|
|
815
|
+
React.createElement('button', {
|
|
816
|
+
type: 'button',
|
|
817
|
+
style: styles.helpButton,
|
|
818
|
+
'aria-label': '连接方式说明与场景建议',
|
|
819
|
+
'aria-expanded': connectionHelpOpen,
|
|
820
|
+
'aria-describedby': connectionHelpOpen ? 'mgw-route-help' : undefined,
|
|
821
|
+
onMouseEnter: () => setConnectionHelpOpen(true),
|
|
822
|
+
onFocus: () => setConnectionHelpOpen(true),
|
|
823
|
+
onBlur: () => setConnectionHelpOpen(false),
|
|
824
|
+
onClick: () => setConnectionHelpOpen(true),
|
|
825
|
+
onKeyDown: (event) => { if (event.key === 'Escape') setConnectionHelpOpen(false) },
|
|
826
|
+
}, '?'),
|
|
827
|
+
connectionHelpOpen ? React.createElement('div', { id: 'mgw-route-help', role: 'tooltip', style: styles.helpBubble },
|
|
828
|
+
React.createElement('strong', null, '怎么选连接方式'),
|
|
829
|
+
helpLine('局域网直连', '手机与 DSH 在同一个可互访的局域网,使用 ws:// 私有地址;离开该网络就无法直连。'),
|
|
830
|
+
helpLine('Cloudflare Channel(命名 Tunnel)', 'PC 主动连接 Cloudflare,外网用固定域名的 wss://;需要 Cloudflare 账户和域名。'),
|
|
831
|
+
helpLine('Quick Channel(Quick Tunnel)', '无需账户或域名,Cloudflare 分配临时 wss:// 地址;隧道重建后地址可能变化。'),
|
|
832
|
+
helpLine('公网服务器连接', 'DSH 运行在有固定公网 IPv4 的 Linux 服务器上,配置公网入口后用 wss:// 公网 IP 连接。'),
|
|
833
|
+
React.createElement('div', { style: { borderTop: `1px solid ${colors.border}`, marginTop: 10, paddingTop: 8 } },
|
|
834
|
+
React.createElement('strong', null, '结合当前环境'),
|
|
835
|
+
React.createElement('div', { style: { color: colors.muted, marginTop: 4 } }, advice.detected),
|
|
836
|
+
React.createElement('div', { style: { marginTop: 6 } }, advice.local),
|
|
837
|
+
React.createElement('div', { style: { marginTop: 6 } }, advice.remote),
|
|
838
|
+
React.createElement('div', { style: { color: colors.muted, marginTop: 6 } }, '面板无法判断手机当前网络;请按手机所在位置选择,提示不会替你修改选择。')))
|
|
839
|
+
: null),
|
|
840
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px 14px', maxWidth: '100%' } },
|
|
841
|
+
React.createElement(GatewaySelect, { style: styles.input, value: pairingRoute, onChange: (event) => choosePairingRoute(event.target.value), disabled: !status, 'aria-label': '配对连接方式' },
|
|
842
|
+
React.createElement('option', { value: 'auto' }, '自动选择 · 优先外网'),
|
|
843
|
+
lanPairingUrl(status) || pairingRoute === 'lan'
|
|
844
|
+
? React.createElement('option', { value: 'lan', disabled: !lanPairingUrl(status) }, lanPairingUrl(status)
|
|
845
|
+
? '局域网直连'
|
|
846
|
+
: '局域网直连 · 当前不可用')
|
|
847
|
+
: null,
|
|
848
|
+
status?.cloudflare?.enabled ? React.createElement('option', { value: 'cloudflare' }, 'Cloudflare 外网') : null,
|
|
849
|
+
React.createElement('option', { value: 'custom' }, '手动输入地址')),
|
|
850
|
+
(pairingRoute === 'auto' || pairingRoute === 'cloudflare') && status?.cloudflare?.supported
|
|
851
|
+
? React.createElement('span', { style: { fontSize: 12, lineHeight: 1.5, fontWeight: 500,
|
|
852
|
+
color: 'var(--dsw-alias-link, #4176e6)' } }, '外网使用 Cloudflare Tunnel 进行连接,请在下方进行设置。') : null)),
|
|
853
|
+
React.createElement('label', { style: styles.label }, 'WebSocket 地址'),
|
|
854
|
+
React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => { choosePairingRoute('custom'); setPublicUrl(event.target.value) }, placeholder: 'ws://192.168.1.10:3081/ws/mobile', spellCheck: false }),
|
|
855
|
+
status && status.lan && status.lan.enabled
|
|
856
|
+
? React.createElement('div', { style: { ...styles.muted, margin: '-7px 0 10px', fontWeight: 500, color: status.lan.error ? colors.danger : status.lan.listening ? colors.success : 'var(--dsw-alias-link, #4176e6)' } },
|
|
857
|
+
status.lan.error
|
|
858
|
+
? `局域网监听失败:${status.lan.error}`
|
|
859
|
+
: status.lan.listening
|
|
860
|
+
? `局域网入口已开启${status.lan.urls && status.lan.urls.length ? `:${status.lan.urls.join('、')}` : `,端口 ${status.lan.port}`}`
|
|
861
|
+
: '局域网入口启动中…')
|
|
862
|
+
: null,
|
|
863
|
+
React.createElement('button', {
|
|
864
|
+
style: { ...styles.button, ...styles.primary, opacity: busy || !(status && status.gatewayEnabled) || !publicUrl.trim() ? .65 : 1 },
|
|
865
|
+
disabled: busy || !(status && status.gatewayEnabled) || !publicUrl.trim(),
|
|
866
|
+
onClick: pair,
|
|
867
|
+
}, busy ? '正在生成…' : !(status && status.gatewayEnabled) ? '请先开启移动网关' : !publicUrl.trim() ? '等待连接地址' : '生成配对二维码'),
|
|
868
|
+
React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '配对码仅可使用一次,5 分钟内有效。')),
|
|
869
|
+
(pairingRoute === 'auto' || pairingRoute === 'cloudflare') && status?.cloudflare?.supported
|
|
870
|
+
? React.createElement('section', { className: 'mgw-cloudflare-block', style: { ...styles.card, background: colors.card, border: 0, borderRadius: 16, padding: 18, marginTop: 16 } },
|
|
871
|
+
React.createElement('h3', { className: 'mgw-section-title' }, 'Cloudflare Tunnel · 外网接入'),
|
|
872
|
+
React.createElement('p', { style: styles.muted }, '首次开启自动准备 Tunnel 组件。'),
|
|
873
|
+
React.createElement('div', { className: 'mgw-setting-row' },
|
|
874
|
+
React.createElement('span', { className: 'mgw-setting-label' }, '接入方式'),
|
|
875
|
+
React.createElement(GatewaySelect, { style: styles.input, value: cloudflareMode, 'aria-label': 'Cloudflare 接入方式', onChange: (event) => {
|
|
876
|
+
const mode = event.target.value
|
|
877
|
+
setCloudflareMode(mode)
|
|
878
|
+
if (status.cloudflare.enabled && mode !== status.cloudflare.mode &&
|
|
879
|
+
(mode === 'quick' || (status.cloudflare.configured && cloudflareHost === status.cloudflare.hostname && !cloudflareToken))) {
|
|
880
|
+
void configureCloudflare(true, mode)
|
|
881
|
+
}
|
|
882
|
+
}, disabled: cloudflareBusy },
|
|
566
883
|
React.createElement('option', { value: 'quick' }, 'Quick Tunnel · 无需域名'),
|
|
567
|
-
React.createElement('option', { value: 'named' }, '命名 Tunnel · 固定域名')),
|
|
884
|
+
React.createElement('option', { value: 'named' }, '命名 Tunnel · 固定域名'))),
|
|
568
885
|
cloudflareMode === 'quick'
|
|
569
|
-
? React.createElement('p', { style: styles.muted }, '
|
|
886
|
+
? React.createElement('p', { style: styles.muted }, '临时地址可能变化,变化后需重新扫码。')
|
|
570
887
|
: React.createElement(React.Fragment, null,
|
|
571
|
-
React.createElement('p', { style: styles.muted }, '先在 Cloudflare 创建命名 Tunnel,把公开域名的 Service URL 设为 ', React.createElement('code', null, `http://127.0.0.1:${status.cloudflare.port}`), '。'),
|
|
888
|
+
React.createElement('p', { style: styles.muted }, '先在 Cloudflare 创建命名 Tunnel,把公开域名的 Service URL 设为 ', React.createElement('code', null, `http://127.0.0.1:${status.cloudflare.port}`), '。Tunnel Token 仅授权连接,不能自动创建公开主机名或 DNS;还需确认域名存在指向 Tunnel UUID.cfargotunnel.com 的已代理 CNAME 记录。'),
|
|
572
889
|
React.createElement('label', { style: styles.label }, '公开域名'),
|
|
573
890
|
React.createElement('input', { style: styles.input, value: cloudflareHost, onChange: (event) => setCloudflareHost(event.target.value.trim()), placeholder: 'gateway.example.com', spellCheck: false }),
|
|
574
891
|
React.createElement('label', { style: styles.label }, 'Tunnel Token'),
|
|
575
892
|
React.createElement('input', { style: styles.input, type: 'password', value: cloudflareToken, onChange: (event) => setCloudflareToken(event.target.value.trim()), placeholder: status.cloudflare.configured ? '已保存;留空则保持原 Token' : '粘贴 eyJ… Token', autoComplete: 'off', spellCheck: false })),
|
|
576
|
-
|
|
893
|
+
(!status.cloudflare.enabled || (cloudflareMode === 'named' &&
|
|
894
|
+
(status.cloudflare.mode !== 'named' || cloudflareHost !== status.cloudflare.hostname || !!cloudflareToken)))
|
|
895
|
+
? React.createElement('button', {
|
|
577
896
|
style: { ...styles.button, ...styles.primary, opacity: cloudflareBusy || (cloudflareMode === 'named' && (!cloudflareHost || (!cloudflareToken && !status.cloudflare.configured))) ? .65 : 1 },
|
|
578
897
|
disabled: cloudflareBusy || (cloudflareMode === 'named' && (!cloudflareHost || (!cloudflareToken && !status.cloudflare.configured))),
|
|
579
898
|
onClick: () => configureCloudflare(true),
|
|
580
|
-
}, cloudflareBusy ? '正在处理…' : status.cloudflare.enabled
|
|
899
|
+
}, cloudflareBusy ? '正在处理…' : status.cloudflare.enabled ? '保存并连接' : cloudflareMode === 'quick' ? '一键开启 Quick Tunnel' : '开启命名 Tunnel') : null,
|
|
581
900
|
status.cloudflare.enabled
|
|
582
901
|
? React.createElement(React.Fragment, null,
|
|
902
|
+
React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy || status.cloudflare.state !== 'online', onClick: checkCloudflare }, '检测公网 DNS / TLS / WebSocket'),
|
|
583
903
|
React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy || !status.gatewayEnabled, onClick: restartCloudflare }, '重启 Tunnel'),
|
|
584
904
|
React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy, onClick: () => configureCloudflare(false) }, '关闭 Cloudflare Tunnel'),
|
|
585
|
-
React.createElement('p', { style: styles.muted }, '
|
|
905
|
+
React.createElement('p', { style: styles.muted }, '重启会短暂断开连接。'))
|
|
586
906
|
: null,
|
|
587
|
-
React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere', color: status.cloudflare.error ? colors.danger : colors.muted } },
|
|
588
|
-
status.cloudflare.error || ({ disabled: '未开启', starting: '正在启动本机入口…', preparing: '正在准备 cloudflared(首次需下载)…', connecting: '正在连接 Cloudflare…', online: '
|
|
589
|
-
status.cloudflare.publicUrl ? ` · ${status.cloudflare.publicUrl}` : '')
|
|
907
|
+
React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere', fontWeight: 500, color: status.cloudflare.error ? colors.danger : status.cloudflare.state === 'online' ? colors.success : ['starting', 'preparing', 'connecting'].includes(status.cloudflare.state) ? 'var(--dsw-alias-link, #4176e6)' : colors.muted } },
|
|
908
|
+
status.cloudflare.error || ({ disabled: '未开启', starting: '正在启动本机入口…', preparing: '正在准备 cloudflared(首次需下载)…', connecting: '正在连接 Cloudflare…', online: '隧道传输已连接(不等于公网入口可达)', 'waiting-for-gateway': '等待移动网关开启' }[status.cloudflare.state] || status.cloudflare.state),
|
|
909
|
+
status.cloudflare.publicUrl ? ` · ${status.cloudflare.publicUrl}` : ''),
|
|
910
|
+
status.cloudflare.enabled && status.cloudflare.endpointHealth
|
|
911
|
+
? React.createElement('p', { style: { ...styles.muted, overflowWrap: 'anywhere', fontWeight: 500, color: status.cloudflare.endpointHealth.state === 'reachable' ? colors.success : status.cloudflare.endpointHealth.state === 'checking' ? 'var(--dsw-alias-link, #4176e6)' : status.cloudflare.endpointHealth.state === 'unchecked' ? colors.muted : colors.danger } },
|
|
912
|
+
status.cloudflare.endpointHealth.message,
|
|
913
|
+
status.cloudflare.endpointHealth.checkedAt ? `(检测时间:${status.cloudflare.endpointHealth.checkedAt})` : '')
|
|
914
|
+
: null)
|
|
590
915
|
: null,
|
|
591
916
|
status && status.platform === 'linux' ? React.createElement('section', { style: styles.card },
|
|
592
|
-
React.createElement('
|
|
917
|
+
React.createElement('h3', { className: 'mgw-section-title' }, '公网接入'),
|
|
593
918
|
publicSetup === null
|
|
594
919
|
? React.createElement('p', { style: styles.muted }, '正在检查系统组件…')
|
|
595
920
|
: publicSetup.installed
|
|
@@ -619,72 +944,12 @@ window.__ModuleLoader__.load({
|
|
|
619
944
|
: React.createElement(React.Fragment, null,
|
|
620
945
|
React.createElement('p', { style: { ...styles.muted, marginBottom: 8 } }, '尚未安装系统 Helper。请在服务器执行一次初始化:'),
|
|
621
946
|
React.createElement('code', { style: { display: 'block', fontSize: 10, lineHeight: 1.5, overflowWrap: 'anywhere', userSelect: 'all' } }, 'npx --yes dsh-plugin-mobile-gateway@latest init'))) : null,
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
React.createElement('button', {
|
|
626
|
-
type: 'button',
|
|
627
|
-
style: styles.helpButton,
|
|
628
|
-
'aria-label': '连接方式说明与场景建议',
|
|
629
|
-
'aria-expanded': connectionHelpOpen,
|
|
630
|
-
'aria-describedby': connectionHelpOpen ? 'mgw-route-help' : undefined,
|
|
631
|
-
onMouseEnter: () => setConnectionHelpOpen(true),
|
|
632
|
-
onFocus: () => setConnectionHelpOpen(true),
|
|
633
|
-
onBlur: () => setConnectionHelpOpen(false),
|
|
634
|
-
onClick: () => setConnectionHelpOpen(true),
|
|
635
|
-
onKeyDown: (event) => { if (event.key === 'Escape') setConnectionHelpOpen(false) },
|
|
636
|
-
}, '?'),
|
|
637
|
-
connectionHelpOpen ? React.createElement('div', { id: 'mgw-route-help', role: 'tooltip', style: styles.helpBubble },
|
|
638
|
-
React.createElement('strong', null, '怎么选连接方式'),
|
|
639
|
-
helpLine('局域网直连', '手机与 DSH 在同一个可互访的局域网,使用 ws:// 私有地址;离开该网络就无法直连。'),
|
|
640
|
-
helpLine('Cloudflare Channel(命名 Tunnel)', 'PC 主动连接 Cloudflare,外网用固定域名的 wss://;需要 Cloudflare 账户和域名。'),
|
|
641
|
-
helpLine('Quick Channel(Quick Tunnel)', '无需账户或域名,Cloudflare 分配临时 wss:// 地址;隧道重建后地址可能变化。'),
|
|
642
|
-
helpLine('公网服务器连接', 'DSH 运行在有固定公网 IPv4 的 Linux 服务器上,配置公网入口后用 wss:// 公网 IP 连接。'),
|
|
643
|
-
React.createElement('div', { style: { borderTop: '1px solid rgba(255,255,255,.18)', marginTop: 10, paddingTop: 8 } },
|
|
644
|
-
React.createElement('strong', null, '结合当前环境'),
|
|
645
|
-
React.createElement('div', { style: { color: '#c6cbd4', marginTop: 4 } }, advice.detected),
|
|
646
|
-
React.createElement('div', { style: { marginTop: 6 } }, advice.local),
|
|
647
|
-
React.createElement('div', { style: { marginTop: 6 } }, advice.remote),
|
|
648
|
-
React.createElement('div', { style: { color: '#aeb6c3', marginTop: 6 } }, '面板无法判断手机当前网络;请按手机所在位置选择,提示不会替你修改选择。')))
|
|
649
|
-
: null),
|
|
650
|
-
React.createElement('select', { style: styles.input, value: pairingRoute, onChange: (event) => choosePairingRoute(event.target.value), disabled: !status, 'aria-label': '配对连接方式' },
|
|
651
|
-
React.createElement('option', { value: 'auto' }, '自动选择 · 优先外网'),
|
|
652
|
-
lanPairingUrl(status) || pairingRoute === 'lan'
|
|
653
|
-
? React.createElement('option', { value: 'lan', disabled: !lanPairingUrl(status) }, lanPairingUrl(status)
|
|
654
|
-
? '局域网直连 · ' + lanPairingUrl(status).replace(/^wss?:\/\//, '').split('/')[0]
|
|
655
|
-
: '局域网直连 · 当前不可用')
|
|
656
|
-
: null,
|
|
657
|
-
status?.cloudflare?.enabled ? React.createElement('option', { value: 'cloudflare' }, 'Cloudflare 外网') : null,
|
|
658
|
-
React.createElement('option', { value: 'custom' }, '手动输入地址')),
|
|
659
|
-
React.createElement('label', { style: styles.label }, 'WebSocket 地址'),
|
|
660
|
-
React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => { choosePairingRoute('custom'); setPublicUrl(event.target.value) }, placeholder: 'ws://192.168.1.10:3081/ws/mobile', spellCheck: false }),
|
|
661
|
-
status && status.lan && status.lan.enabled
|
|
662
|
-
? React.createElement('div', { style: { ...styles.muted, margin: '-7px 0 10px', color: status.lan.error ? colors.danger : colors.muted } },
|
|
663
|
-
status.lan.error
|
|
664
|
-
? `局域网监听失败:${status.lan.error}`
|
|
665
|
-
: status.lan.listening
|
|
666
|
-
? `局域网入口已开启${status.lan.urls && status.lan.urls.length ? `:${status.lan.urls.join('、')}` : `,端口 ${status.lan.port}`}`
|
|
667
|
-
: '局域网入口启动中…')
|
|
668
|
-
: null,
|
|
669
|
-
React.createElement('label', { style: styles.label }, '设备名称'),
|
|
670
|
-
React.createElement('input', { style: styles.input, value: deviceName, onChange: (event) => setDeviceName(event.target.value), maxLength: 80, placeholder: 'iPhone' }),
|
|
671
|
-
React.createElement('button', {
|
|
672
|
-
style: { ...styles.button, ...styles.primary, opacity: busy || !(status && status.gatewayEnabled) || !publicUrl.trim() ? .65 : 1 },
|
|
673
|
-
disabled: busy || !(status && status.gatewayEnabled) || !publicUrl.trim(),
|
|
674
|
-
onClick: pair,
|
|
675
|
-
}, busy ? '正在生成…' : !(status && status.gatewayEnabled) ? '请先开启移动网关' : !publicUrl.trim() ? '等待连接地址' : '生成配对二维码'),
|
|
676
|
-
React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '同一局域网选择“局域网直连”,无需关闭 Cloudflare Tunnel。局域网使用私有地址的 ws://;公网地址必须使用 wss://。配对码只能使用一次,并在 5 分钟内过期。')),
|
|
677
|
-
qr ? React.createElement('section', { style: { ...styles.card, textAlign: 'center' } },
|
|
678
|
-
React.createElement('strong', null, '使用 iOS 客户端扫码'),
|
|
679
|
-
React.createElement('img', { style: styles.qr, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.svg)}`, alt: '一次性设备配对二维码' }),
|
|
680
|
-
React.createElement('label', { style: { ...styles.label, textAlign: 'left' } }, 'Base64URL 配对字符串'),
|
|
681
|
-
React.createElement('textarea', { style: styles.pairingText, value: qr.qrPayload, readOnly: true, spellCheck: false, onFocus: (event) => event.target.select() }),
|
|
682
|
-
React.createElement('button', { style: { ...styles.button, width: '100%', marginBottom: 8, borderColor: pairingTokenCopied ? colors.success : colors.border, color: pairingTokenCopied ? colors.success : colors.text }, onClick: copyPairingText }, pairingTokenCopied ? '✓ 已复制' : '复制配对 Token'),
|
|
683
|
-
pairingTokenCopied ? React.createElement('div', { style: { ...styles.muted, color: colors.success, marginBottom: 8 } }, '已复制到剪贴板') : null,
|
|
684
|
-
React.createElement('div', { style: styles.muted }, `有效期至 ${new Date(qr.pairing.expiresAt).toLocaleTimeString()};扫码成功后此二维码立即失效。`)) : null,
|
|
947
|
+
|
|
948
|
+
),
|
|
949
|
+
React.createElement('div', { hidden: activeSection !== 'devices' },
|
|
685
950
|
React.createElement('section', { style: styles.card },
|
|
686
951
|
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
687
|
-
React.createElement('
|
|
952
|
+
React.createElement('h3', { className: 'mgw-section-title', style: { marginBottom: 0 } }, '可信设备'),
|
|
688
953
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
|
|
689
954
|
deviceNotice || refreshNotice ? React.createElement('span', {
|
|
690
955
|
style: { ...styles.muted, color: deviceNoticeError ? colors.danger : colors.success },
|
|
@@ -698,12 +963,22 @@ window.__ModuleLoader__.load({
|
|
|
698
963
|
? React.createElement('p', { style: styles.muted }, '加载中…')
|
|
699
964
|
: devices.length === 0
|
|
700
965
|
? React.createElement('p', { style: styles.muted }, '暂无已配对设备。')
|
|
701
|
-
:
|
|
966
|
+
: React.createElement(React.Fragment, null,
|
|
967
|
+
visibleDevices.length === 0 ? React.createElement('p', { style: styles.muted }, '当前没有在线设备。') : null,
|
|
968
|
+
visibleDevices.map((device) => React.createElement(DeviceRow, {
|
|
702
969
|
key: device.id,
|
|
703
970
|
device,
|
|
704
971
|
onRevoke: revoke,
|
|
705
972
|
revoking: revokingIds.has(device.id),
|
|
706
|
-
}))
|
|
973
|
+
})),
|
|
974
|
+
offlineDevices.length > 0 ? React.createElement('button', {
|
|
975
|
+
type: 'button',
|
|
976
|
+
style: { ...styles.button, marginTop: 10, color: colors.muted },
|
|
977
|
+
'aria-expanded': showAllDevices,
|
|
978
|
+
onClick: () => setShowAllDevices((value) => !value),
|
|
979
|
+
}, showAllDevices ? '收起离线设备' : `展开其他已配对设备(${offlineDevices.length})`) : null)),
|
|
980
|
+
)),
|
|
981
|
+
),
|
|
707
982
|
status && status.version
|
|
708
983
|
? React.createElement('div', { style: styles.version, 'aria-label': `插件版本 ${status.version}` }, `v${status.version}`)
|
|
709
984
|
: null))
|
|
@@ -4,6 +4,7 @@ import path from 'node:path'
|
|
|
4
4
|
import crypto from 'node:crypto'
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
6
|
import { ensureCloudflared } from './cloudflared-binary.mjs'
|
|
7
|
+
import { checkPublicEndpoint } from './public-endpoint-check.mjs'
|
|
7
8
|
|
|
8
9
|
function hostnameOf(value) {
|
|
9
10
|
if (typeof value !== 'string') throw new TypeError('Cloudflare hostname is required')
|
|
@@ -61,7 +62,7 @@ function writeState(file, value) {
|
|
|
61
62
|
// Cloudflare can never reach the DSH WebUI or the /mgw management API through it.
|
|
62
63
|
export function createCloudflareTunnel({ file, port = 3082, wsPath, executable = 'cloudflared',
|
|
63
64
|
supported = true, isGatewayEnabled, onUpgrade, log = () => {}, spawnProcess = spawn,
|
|
64
|
-
prepareExecutable = ensureCloudflared }) {
|
|
65
|
+
prepareExecutable = ensureCloudflared, probeEndpoint = checkPublicEndpoint }) {
|
|
65
66
|
let settings = supported ? readState(file) : { version: 1, enabled: false, mode: 'quick', hostname: '', token: '' }
|
|
66
67
|
let server = null
|
|
67
68
|
let child = null
|
|
@@ -73,6 +74,22 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
73
74
|
let quickUrl = null
|
|
74
75
|
let queue = Promise.resolve()
|
|
75
76
|
const sockets = new Set()
|
|
77
|
+
let endpointHealth = { state: 'unchecked', message: '尚未检测公网 DNS、TLS 和 WebSocket 路由。' }
|
|
78
|
+
let healthGeneration = 0
|
|
79
|
+
let healthPending = null
|
|
80
|
+
const checkEndpoint = () => {
|
|
81
|
+
const endpoint = publicUrl()
|
|
82
|
+
if (!shouldRun() || !endpoint || phase !== 'online') return Promise.resolve(snapshot())
|
|
83
|
+
if (healthPending) return healthPending
|
|
84
|
+
const generation = healthGeneration
|
|
85
|
+
endpointHealth = { state: 'checking', message: '正在检测公网 DNS、TLS 和 WebSocket 路由…' }
|
|
86
|
+
const pending = Promise.resolve().then(() => probeEndpoint(endpoint)).catch(() => ({ state: 'network-error', message: '公网检测失败,请稍后重试。', checkedAt: new Date().toISOString() })).then((health) => {
|
|
87
|
+
if (generation === healthGeneration) endpointHealth = health
|
|
88
|
+
return snapshot()
|
|
89
|
+
}).finally(() => { if (healthPending === pending) healthPending = null })
|
|
90
|
+
healthPending = pending
|
|
91
|
+
return pending
|
|
92
|
+
}
|
|
76
93
|
|
|
77
94
|
const shouldRun = () => supported && !disposed && settings.enabled && isGatewayEnabled()
|
|
78
95
|
const publicUrl = () => settings.mode === 'quick'
|
|
@@ -88,9 +105,14 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
88
105
|
port,
|
|
89
106
|
state: !settings.enabled ? 'disabled' : !isGatewayEnabled() ? 'waiting-for-gateway' : phase,
|
|
90
107
|
error,
|
|
108
|
+
endpointHealth,
|
|
109
|
+
publicReady: phase === 'online' && shouldRun() && endpointHealth.state === 'reachable',
|
|
91
110
|
})
|
|
92
111
|
|
|
93
112
|
const stop = async () => {
|
|
113
|
+
healthGeneration += 1
|
|
114
|
+
healthPending = null
|
|
115
|
+
endpointHealth = { state: 'unchecked', message: '尚未检测公网 DNS、TLS 和 WebSocket 路由。' }
|
|
94
116
|
if (retryTimer) clearTimeout(retryTimer)
|
|
95
117
|
retryTimer = null
|
|
96
118
|
const previous = child
|
|
@@ -147,8 +169,10 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
147
169
|
.replace(/eyJ[A-Za-z0-9._=-]+/g, '[redacted]').trim().slice(-500)
|
|
148
170
|
}
|
|
149
171
|
if (registered && (!quick || quickUrl)) {
|
|
172
|
+
const firstOnline = phase !== 'online'
|
|
150
173
|
phase = 'online'
|
|
151
174
|
error = null
|
|
175
|
+
if (firstOnline) void checkEndpoint()
|
|
152
176
|
}
|
|
153
177
|
}
|
|
154
178
|
running.stderr?.on('data', onOutput)
|
|
@@ -156,6 +180,9 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
156
180
|
const exited = (reason) => {
|
|
157
181
|
if (child !== running) return
|
|
158
182
|
child = null
|
|
183
|
+
healthGeneration += 1
|
|
184
|
+
healthPending = null
|
|
185
|
+
endpointHealth = { state: 'unchecked', message: '隧道已断开,需重新检测公网入口。' }
|
|
159
186
|
quickUrl = null
|
|
160
187
|
phase = 'error'
|
|
161
188
|
error = reason || diagnostics || 'cloudflared exited unexpectedly'
|
|
@@ -278,5 +305,5 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
278
305
|
}
|
|
279
306
|
|
|
280
307
|
const dispose = () => { disposed = true; void stop() }
|
|
281
|
-
return { snapshot, configure, reconcile, restart, dispose }
|
|
308
|
+
return { snapshot, configure, reconcile, restart, checkEndpoint, dispose }
|
|
282
309
|
}
|
package/lib/devices.js
CHANGED
|
@@ -99,9 +99,18 @@ function createRegistry(file, options = {}) {
|
|
|
99
99
|
|
|
100
100
|
return {
|
|
101
101
|
list() {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
const active = devices.filter((device) => !device.revokedAt)
|
|
103
|
+
const counts = new Map()
|
|
104
|
+
const indices = new Map()
|
|
105
|
+
for (const device of active) counts.set(device.name, (counts.get(device.name) || 0) + 1)
|
|
106
|
+
return active.map((device) => {
|
|
107
|
+
const index = (indices.get(device.name) || 0) + 1
|
|
108
|
+
indices.set(device.name, index)
|
|
109
|
+
return {
|
|
110
|
+
...publicDevice(device, online.get(device.id) || 0),
|
|
111
|
+
displayName: counts.get(device.name) > 1 ? `${device.name} (${index})` : device.name,
|
|
112
|
+
}
|
|
113
|
+
})
|
|
105
114
|
},
|
|
106
115
|
|
|
107
116
|
count() {
|
|
@@ -125,7 +134,7 @@ function createRegistry(file, options = {}) {
|
|
|
125
134
|
return typeof code === 'string' && pairings.has(digest(code))
|
|
126
135
|
},
|
|
127
136
|
|
|
128
|
-
claimPairing(code, clientDeviceId) {
|
|
137
|
+
claimPairing(code, clientDeviceId, clientDeviceName) {
|
|
129
138
|
prunePairings()
|
|
130
139
|
if (typeof code !== 'string' || code === '') return undefined
|
|
131
140
|
const codeHash = digest(code)
|
|
@@ -143,13 +152,13 @@ function createRegistry(file, options = {}) {
|
|
|
143
152
|
// A valid one-time pairing code authorizes credential rotation. Keep
|
|
144
153
|
// the stable server-side device record while replacing its token, so
|
|
145
154
|
// re-pairing one iOS installation never creates duplicate rows.
|
|
146
|
-
device.name =
|
|
155
|
+
device.name = typeof clientDeviceName === 'string' && clientDeviceName.trim() ? clientDeviceName.trim().slice(0, 80) : '未命名设备'
|
|
147
156
|
device.tokenHash = digest(token)
|
|
148
157
|
device.clientDeviceId = normalizedClientDeviceId
|
|
149
158
|
} else {
|
|
150
159
|
device = {
|
|
151
160
|
id: pairing.id,
|
|
152
|
-
name:
|
|
161
|
+
name: typeof clientDeviceName === 'string' && clientDeviceName.trim() ? clientDeviceName.trim().slice(0, 80) : '未命名设备',
|
|
153
162
|
clientDeviceId: normalizedClientDeviceId,
|
|
154
163
|
tokenHash: digest(token),
|
|
155
164
|
createdAt: Date.now(),
|
|
@@ -162,7 +171,7 @@ function createRegistry(file, options = {}) {
|
|
|
162
171
|
return { device: publicDevice(device, 0), token }
|
|
163
172
|
},
|
|
164
173
|
|
|
165
|
-
authenticate(token, clientDeviceId) {
|
|
174
|
+
authenticate(token, clientDeviceId, clientDeviceName) {
|
|
166
175
|
if (typeof token !== 'string' || token === '') return undefined
|
|
167
176
|
const tokenHash = digest(token)
|
|
168
177
|
const device = devices.find((candidate) => !candidate.revokedAt && safeEqualHex(candidate.tokenHash, tokenHash))
|
|
@@ -177,6 +186,13 @@ function createRegistry(file, options = {}) {
|
|
|
177
186
|
save()
|
|
178
187
|
}
|
|
179
188
|
}
|
|
189
|
+
if (device && typeof clientDeviceName === 'string' && clientDeviceName.trim()) {
|
|
190
|
+
const name = clientDeviceName.trim().slice(0, 80)
|
|
191
|
+
if (device.name !== name) {
|
|
192
|
+
device.name = name
|
|
193
|
+
save()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
180
196
|
return device ? publicDevice(device, online.get(device.id) || 0) : undefined
|
|
181
197
|
},
|
|
182
198
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Echo only a strictly bounded, non-secret challenge on an unauthenticated
|
|
2
|
+
// rejection. This does not authorize a device or expose management data.
|
|
3
|
+
export function gatewayProbeHeader(value) {
|
|
4
|
+
return typeof value === 'string' && /^[a-f0-9]{32}$/.test(value)
|
|
5
|
+
? `X-DSH-Gateway-Probe: ${value}\r\n` : ''
|
|
6
|
+
}
|
package/lib/index.mjs
CHANGED
|
@@ -136,6 +136,7 @@ import { handlePluginQuery, PLUGIN_REQUEST_TYPES } from './plugin-management.mjs
|
|
|
136
136
|
import { createSessionFollower } from './session-follower.mjs'
|
|
137
137
|
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
138
138
|
import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
|
|
139
|
+
import { gatewayProbeHeader } from './gateway-probe.mjs'
|
|
139
140
|
import { webRestartAvailable, prepareWebRestart } from './gateway-tools.mjs'
|
|
140
141
|
import QRCode from 'qrcode'
|
|
141
142
|
|
|
@@ -1938,9 +1939,16 @@ function extractClientDeviceId(req) {
|
|
|
1938
1939
|
return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
|
|
1939
1940
|
}
|
|
1940
1941
|
|
|
1941
|
-
function
|
|
1942
|
+
function extractClientDeviceName(req) {
|
|
1943
|
+
const value = req.headers['x-dsh-device-name']
|
|
1944
|
+
if (typeof value !== 'string' || value.length > 512 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return undefined
|
|
1945
|
+
const name = Buffer.from(value, 'base64').toString('utf8').trim()
|
|
1946
|
+
return name && !/[\u0000-\u001f\u007f\ufffd]/.test(name) ? name.slice(0, 80) : undefined
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function rejectUpgrade(socket, status, message, probe) {
|
|
1942
1950
|
const reason = status === 401 ? 'Unauthorized' : status === 403 ? 'Forbidden' : status === 404 ? 'Not Found' : status === 503 ? 'Service Unavailable' : 'Bad Request'
|
|
1943
|
-
socket.write(`HTTP/1.1 ${status} ${reason}\r\
|
|
1951
|
+
socket.write(`HTTP/1.1 ${status} ${reason}\r\n${gatewayProbeHeader(probe)}Connection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`)
|
|
1944
1952
|
socket.destroy()
|
|
1945
1953
|
}
|
|
1946
1954
|
|
|
@@ -2215,6 +2223,7 @@ const plugin = {
|
|
|
2215
2223
|
let sessionProjectionBaselines = {}
|
|
2216
2224
|
let counter = 0
|
|
2217
2225
|
let gatewayMode = gatewayState.mode ?? options.gatewayMode ?? (options.gatewayEnabled === true ? 'persistent' : 'disabled')
|
|
2226
|
+
const gatewayInstanceId = crypto.randomUUID()
|
|
2218
2227
|
let gatewayEnabled = gatewayMode !== 'disabled'
|
|
2219
2228
|
let waitExpiresAt = null
|
|
2220
2229
|
let waitTimer = null
|
|
@@ -2643,6 +2652,7 @@ const plugin = {
|
|
|
2643
2652
|
connectedClients: clients.size,
|
|
2644
2653
|
webPort: webServer.port,
|
|
2645
2654
|
webPid: process.pid,
|
|
2655
|
+
gatewayInstanceId,
|
|
2646
2656
|
wsPath,
|
|
2647
2657
|
publicUrl: tunnel?.snapshot().enabled && tunnel.snapshot().mode === 'quick'
|
|
2648
2658
|
? tunnel.snapshot().publicUrl
|
|
@@ -2727,6 +2737,9 @@ const plugin = {
|
|
|
2727
2737
|
if (body.enabled && gatewayMode !== 'persistent') setGatewayMode('persistent', 'Cloudflare Tunnel enabled from management UI')
|
|
2728
2738
|
await tunnel.reconcile()
|
|
2729
2739
|
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
2740
|
+
} else if (req.method === 'POST' && p === '/mgw/cloudflare/check') {
|
|
2741
|
+
await tunnel.checkEndpoint()
|
|
2742
|
+
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
2730
2743
|
} else if (req.method === 'POST' && p === '/mgw/cloudflare/restart') {
|
|
2731
2744
|
await tunnel.restart()
|
|
2732
2745
|
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
@@ -2754,10 +2767,9 @@ const plugin = {
|
|
|
2754
2767
|
throw error
|
|
2755
2768
|
}
|
|
2756
2769
|
const body = await readBody(req)
|
|
2757
|
-
const name = typeof body.name === 'string' ? body.name : undefined
|
|
2758
2770
|
const publicUrl = normalizePublicUrl(body.publicUrl || configuredPublicUrl(), req, wsPath)
|
|
2759
2771
|
const endpoints = advertisedEndpoints(publicUrl, normalizeEndpoints(body.endpoints ?? []))
|
|
2760
|
-
const pairing = registry.createPairing(
|
|
2772
|
+
const pairing = registry.createPairing()
|
|
2761
2773
|
const payload = {
|
|
2762
2774
|
version: 2,
|
|
2763
2775
|
publicUrl,
|
|
@@ -2820,7 +2832,7 @@ const plugin = {
|
|
|
2820
2832
|
rejectUpgrade(socket, 400, 'pairing requires X-DSH-Device-ID; update the iOS client')
|
|
2821
2833
|
return
|
|
2822
2834
|
}
|
|
2823
|
-
paired = registry.claimPairing(credential.value, clientDeviceId)
|
|
2835
|
+
paired = registry.claimPairing(credential.value, clientDeviceId, extractClientDeviceName(req))
|
|
2824
2836
|
device = paired && paired.device
|
|
2825
2837
|
if (!device) {
|
|
2826
2838
|
log('pairing rejected: invalid, expired, or already-used code')
|
|
@@ -2828,7 +2840,7 @@ const plugin = {
|
|
|
2828
2840
|
return
|
|
2829
2841
|
}
|
|
2830
2842
|
} else if (credential && credential.kind === 'token') {
|
|
2831
|
-
device = registry.authenticate(credential.value, extractClientDeviceId(req))
|
|
2843
|
+
device = registry.authenticate(credential.value, extractClientDeviceId(req), extractClientDeviceName(req))
|
|
2832
2844
|
}
|
|
2833
2845
|
|
|
2834
2846
|
// The separately exposed LAN listener is always authenticated. The
|
|
@@ -2837,7 +2849,7 @@ const plugin = {
|
|
|
2837
2849
|
// plane.
|
|
2838
2850
|
if ((requireAuth || transport.lan === true) && !device) {
|
|
2839
2851
|
logAuthRejected(req)
|
|
2840
|
-
rejectUpgrade(socket, 401, 'missing or invalid device credential')
|
|
2852
|
+
rejectUpgrade(socket, 401, 'missing or invalid device credential', req.headers['x-dsh-gateway-probe'])
|
|
2841
2853
|
return
|
|
2842
2854
|
}
|
|
2843
2855
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import dns from 'node:dns/promises'
|
|
2
|
+
import https from 'node:https'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
// No credentials, redirects or certificate exceptions. A 401 alone is ambiguous:
|
|
6
|
+
// require the gateway to echo our random probe nonce in its auth rejection.
|
|
7
|
+
export async function checkPublicEndpoint(endpoint, { timeoutMs = 8000, lookup = dns.lookup, request = https.request } = {}) {
|
|
8
|
+
const checkedAt = new Date().toISOString()
|
|
9
|
+
const result = (state, message, extra = {}) => ({ state, message, checkedAt, ...extra })
|
|
10
|
+
let url
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(endpoint)
|
|
13
|
+
if (url.protocol !== 'wss:' || url.username || url.password || url.search || url.hash) throw new Error()
|
|
14
|
+
} catch { return result('invalid-url', '公网入口必须是无凭证的 wss:// 地址。') }
|
|
15
|
+
let timer
|
|
16
|
+
try {
|
|
17
|
+
await Promise.race([
|
|
18
|
+
lookup(url.hostname),
|
|
19
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error(), { code: 'ETIMEDOUT' })), timeoutMs) }),
|
|
20
|
+
])
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return result('dns-error', ['ENOTFOUND', 'ENODATA'].includes(error.code)
|
|
23
|
+
? '本机无法解析公网域名,不代表公共 DNS 记录一定不存在。请核对域名、DNS 缓存、网络代理及 Cloudflare 已代理的 CNAME 记录;若刚保存配置,请稍后重试。'
|
|
24
|
+
: 'DNS 查询失败或超时,请检查本机网络和 DNS 后重试。', { code: error.code || 'DNS_ERROR' })
|
|
25
|
+
} finally { clearTimeout(timer) }
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
let done = false
|
|
28
|
+
let deadline
|
|
29
|
+
const finish = (value) => {
|
|
30
|
+
if (done) return
|
|
31
|
+
done = true
|
|
32
|
+
clearTimeout(deadline)
|
|
33
|
+
resolve(value)
|
|
34
|
+
}
|
|
35
|
+
let req
|
|
36
|
+
try {
|
|
37
|
+
const key = crypto.randomBytes(16).toString('base64')
|
|
38
|
+
const nonce = crypto.randomBytes(16).toString('hex')
|
|
39
|
+
req = request({ hostname: url.hostname, port: url.port || 443, path: url.pathname || '/', method: 'GET', rejectUnauthorized: true,
|
|
40
|
+
headers: { Upgrade: 'websocket', Connection: 'Upgrade', 'Sec-WebSocket-Key': key, 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Protocol': 'dsh-mobile-v1', 'X-DSH-Gateway-Probe': nonce } })
|
|
41
|
+
deadline = setTimeout(() => { finish(result('network-error', '公网 TLS/WebSocket 检测超时;请检查网络、代理和 Cloudflare 源站配置。', { code: 'ETIMEDOUT' })); req.destroy() }, timeoutMs)
|
|
42
|
+
req.on('response', (res) => {
|
|
43
|
+
const status = res.statusCode
|
|
44
|
+
finish(status === 401 && res.headers['x-dsh-gateway-probe'] === nonce ? result('reachable', '本机检测:TLS 校验通过,移动网关路由已确认且要求鉴权;不代表手机网络可达或会话同步成功。', { httpStatus: status })
|
|
45
|
+
: result('route-error', `公网返回 HTTP ${status},未确认移动网关路由。命名 Tunnel 的 Service 应为 HTTP,指向本机专用源站端口;同时检查 Cloudflare Access/安全规则。`, { httpStatus: status }))
|
|
46
|
+
res.destroy()
|
|
47
|
+
})
|
|
48
|
+
req.on('upgrade', (res, socket) => {
|
|
49
|
+
finish(result('auth-warning', '公网 WebSocket 未提供凭证即被接受,请检查设备鉴权设置。', { httpStatus: res.statusCode }))
|
|
50
|
+
socket.destroy()
|
|
51
|
+
})
|
|
52
|
+
req.on('error', (error) => {
|
|
53
|
+
const tlsError = /CERT|TLS|SSL|SELF_SIGNED|UNABLE_TO_VERIFY/.test(error.code || '')
|
|
54
|
+
finish(result(tlsError ? 'tls-error' : 'network-error', tlsError
|
|
55
|
+
? '公网 TLS 握手或证书校验失败;请检查域名证书、系统时间及网络代理,不要关闭证书校验。'
|
|
56
|
+
: '公网连接失败;请检查 DNS、网络、代理及隧道源站配置。', { code: error.code || 'CONNECT_ERROR' }))
|
|
57
|
+
})
|
|
58
|
+
req.end()
|
|
59
|
+
} catch { finish(result('network-error', '无法发起公网检测。')); req?.destroy() }
|
|
60
|
+
})
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Mobile gateway for DeepSeek Harness with secure device pairing, realtime session sync, plugin management, LAN and Cloudflare Tunnel access, and a responsive theme-aware control panel",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
7
7
|
"bin",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"bin": "bin/setup-ip.mjs",
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/plugin-management.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node test/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
|
|
29
|
+
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/plugin-management.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./lib/index.mjs",
|