dsh-plugin-mobile-gateway 0.7.7 → 0.8.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/lib/client.js CHANGED
@@ -49,6 +49,7 @@ window.__ModuleLoader__.load({
49
49
  input: { width: '100%', boxSizing: 'border-box', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 10px', marginBottom: 10, background: 'transparent', color: 'inherit', outline: 'none' },
50
50
  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
51
  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 },
52
53
  primary: { background: colors.accent, color: '#fff', borderColor: colors.accent, width: '100%' },
53
54
  muted: { color: colors.muted, fontSize: 12, lineHeight: 1.55 },
54
55
  row: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 12, alignItems: 'center', padding: '12px 0', borderBottom: `1px solid ${colors.border}` },
@@ -56,6 +57,10 @@ window.__ModuleLoader__.load({
56
57
  dot: { width: 7, height: 7, borderRadius: 99, display: 'inline-block' },
57
58
  error: { color: '#ff8092', background: 'rgba(220,76,100,.12)', borderRadius: 8, padding: 10, marginBottom: 12 },
58
59
  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: `1px solid ${colors.muted}`, background: 'transparent', color: colors.muted, cursor: 'help', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 12, fontWeight: 600, lineHeight: 1 },
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: '1px solid rgba(255,255,255,.18)', background: '#20242c', color: '#f4f4f5', boxShadow: '0 12px 28px rgba(0,0,0,.35)', fontSize: 12, lineHeight: 1.5 },
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: '#20242c', boxShadow: '0 12px 28px rgba(0,0,0,.35)' },
63
+ toolsItem: { display: 'block', width: '100%', padding: '9px 10px', textAlign: 'left', border: 0, borderRadius: 7, background: 'transparent', color: colors.text, font: 'inherit', cursor: 'pointer' },
59
64
  switch: { position: 'relative', display: 'inline-flex', width: 42, height: 24, flexShrink: 0 },
60
65
  switchInput: { position: 'absolute', opacity: 0, width: 1, height: 1 },
61
66
  switchTrack: { position: 'absolute', inset: 0, borderRadius: 99, transition: 'background .18s ease', cursor: 'pointer' },
@@ -75,6 +80,42 @@ window.__ModuleLoader__.load({
75
80
  return `${scheme}//${window.location.host}${wsPath || '/ws/mobile'}`
76
81
  }
77
82
 
83
+ function lanPairingUrl(status) {
84
+ const urls = status?.lan?.listening && Array.isArray(status.lan.urls) ? status.lan.urls : []
85
+ return urls.find((url) => !/^ws:\/\/169\.254\./.test(url)) || urls[0] || ''
86
+ }
87
+
88
+ function pairingUrlFor(route, status) {
89
+ const lanUrl = lanPairingUrl(status)
90
+ const tunnelUrl = status.cloudflare?.enabled ? status.cloudflare.publicUrl || '' : ''
91
+ if (route === 'lan') return lanUrl
92
+ if (route === 'cloudflare') return tunnelUrl
93
+ if (status.cloudflare?.enabled && status.cloudflare.mode === 'quick') return tunnelUrl
94
+ return tunnelUrl || status.publicUrl || lanUrl || inferredUrl(status.wsPath)
95
+ }
96
+
97
+ function connectionAdvice(status, publicSetup) {
98
+ if (!status) return { detected: '正在读取网关环境…', local: '', remote: '' }
99
+ const system = status.platform === 'darwin' ? 'macOS 电脑' : status.platform === 'win32' ? 'Windows 电脑' : status.platform === 'linux' ? 'Linux 服务器' : '当前设备'
100
+ const lanReady = !!lanPairingUrl(status)
101
+ const tunnel = status.cloudflare
102
+ const tunnelReady = !!(tunnel?.enabled && tunnel.state === 'online' && tunnel.publicUrl)
103
+ const serverReady = status.platform === 'linux' && !!(
104
+ (publicSetup?.configured && publicSetup.publicUrl) || /^wss:\/\//.test(status.publicUrl || ''))
105
+ const detected = `当前检测:${system};局域网入口${lanReady ? '已就绪' : '未就绪'};${serverReady ? '公网服务器入口已配置' : tunnelReady ? `${tunnel.mode === 'quick' ? 'Quick' : '命名'} Tunnel 已连接` : tunnel?.enabled ? 'Cloudflare Tunnel 连接中' : '外网入口未连接'}。`
106
+ const local = lanReady
107
+ ? '手机与 DSH 处于可互访的同一局域网时,建议选“局域网直连”。'
108
+ : '如果手机和 DSH 在同一局域网,先确认局域网入口已开启并监听成功。'
109
+ let remote
110
+ if (serverReady) remote = '手机在外网时,建议使用已配置的公网服务器地址(“自动选择”)。'
111
+ else if (tunnelReady && tunnel.mode === 'named') remote = '手机在外网时,建议选“Cloudflare 外网”;当前命名 Tunnel 有固定域名。'
112
+ else if (tunnelReady) remote = '手机在外网临时使用时,建议选“Cloudflare 外网”;Quick 地址变化后需要重新确认。'
113
+ else if (status.platform === 'darwin' || status.platform === 'win32') remote = '手机在外网时,无自有域名可开启 Quick Tunnel;有域名且需要固定地址可配置命名 Tunnel。'
114
+ else if (status.platform === 'linux') remote = '手机在外网时,若使用受支持的 Ubuntu/Debian 服务器且有固定公网 IPv4,可先配置“公网接入”,再选“自动选择”。'
115
+ else remote = '手机在外网时,需要一个可访问的 wss:// 地址;可在“手动输入地址”中填写。'
116
+ return { detected, local, remote }
117
+ }
118
+
78
119
  function DeviceRow({ device, onRevoke, revoking }) {
79
120
  const triggerRevoke = (event) => {
80
121
  event.preventDefault()
@@ -116,9 +157,19 @@ window.__ModuleLoader__.load({
116
157
  const [devices, setDevices] = React.useState(null)
117
158
  const [status, setStatus] = React.useState(null)
118
159
  const [publicUrl, setPublicUrl] = React.useState('')
160
+ const [pairingRoute, setPairingRoute] = React.useState('auto')
161
+ const [connectionHelpOpen, setConnectionHelpOpen] = React.useState(false)
162
+ const [toolsOpen, setToolsOpen] = React.useState(false)
163
+ const [toolBusy, setToolBusy] = React.useState(false)
164
+ const [toolNotice, setToolNotice] = React.useState('')
165
+ const stoppedRef = React.useRef(false)
119
166
  const [publicIp, setPublicIp] = React.useState('')
120
167
  const [publicSetup, setPublicSetup] = React.useState(null)
121
168
  const [publicSetupBusy, setPublicSetupBusy] = React.useState(false)
169
+ const [cloudflareHost, setCloudflareHost] = React.useState('')
170
+ const [cloudflareToken, setCloudflareToken] = React.useState('')
171
+ const [cloudflareMode, setCloudflareMode] = React.useState('quick')
172
+ const [cloudflareBusy, setCloudflareBusy] = React.useState(false)
122
173
  const [deviceName, setDeviceName] = React.useState('iPhone')
123
174
  const [qr, setQr] = React.useState(null)
124
175
  const [error, setError] = React.useState(null)
@@ -131,6 +182,8 @@ window.__ModuleLoader__.load({
131
182
  const [revokingIds, setRevokingIds] = React.useState(() => new Set())
132
183
  const revokingIdsRef = React.useRef(new Set())
133
184
  const automaticUrlRef = React.useRef('')
185
+ const pairingRouteRef = React.useRef('auto')
186
+ const cloudflareModeLoadedRef = React.useRef(false)
134
187
 
135
188
  const refresh = React.useCallback(async () => {
136
189
  try {
@@ -146,15 +199,23 @@ window.__ModuleLoader__.load({
146
199
  setDevices((current) => JSON.stringify(current) === JSON.stringify(nextDevices) ? current : nextDevices)
147
200
  }
148
201
  setStatus((current) => JSON.stringify(current) === JSON.stringify(statusData) ? current : statusData)
149
- const lanUrl = statusData.lan && Array.isArray(statusData.lan.urls) ? statusData.lan.urls[0] : ''
150
- const preferredUrl = statusData.publicUrl || lanUrl || inferredUrl(statusData.wsPath)
151
- setPublicUrl((current) => {
152
- if (!current || current === automaticUrlRef.current) {
153
- automaticUrlRef.current = preferredUrl
154
- return preferredUrl
155
- }
156
- return current
157
- })
202
+ if (statusData.cloudflare && statusData.cloudflare.hostname) {
203
+ setCloudflareHost((current) => current || statusData.cloudflare.hostname)
204
+ }
205
+ if (statusData.cloudflare && !cloudflareModeLoadedRef.current) {
206
+ cloudflareModeLoadedRef.current = true
207
+ setCloudflareMode(statusData.cloudflare.mode || 'quick')
208
+ }
209
+ if (pairingRouteRef.current === 'cloudflare' && !statusData.cloudflare?.enabled) {
210
+ pairingRouteRef.current = 'auto'
211
+ setPairingRoute('auto')
212
+ }
213
+ if (pairingRouteRef.current !== 'custom') {
214
+ const preferredUrl = pairingUrlFor(pairingRouteRef.current, statusData)
215
+ if (automaticUrlRef.current && preferredUrl !== automaticUrlRef.current) setQr(null)
216
+ automaticUrlRef.current = preferredUrl
217
+ setPublicUrl(preferredUrl)
218
+ }
158
219
  return true
159
220
  } catch (cause) {
160
221
  setError(cause.message)
@@ -175,6 +236,43 @@ window.__ModuleLoader__.load({
175
236
  }
176
237
  }
177
238
 
239
+ const runTool = async (tool) => {
240
+ if (toolBusy) return
241
+ setToolsOpen(false)
242
+ setToolBusy(true)
243
+ setToolNotice('')
244
+ setError(null)
245
+ try {
246
+ const oldPid = status?.webPid
247
+ await request(`/mgw/tools/${tool}`, { method: 'POST' })
248
+ if (tool === 'stop-web') {
249
+ stoppedRef.current = true
250
+ setToolNotice('DSH Web 已收到停止指令。需要时可在 Terminal 运行 dsh web 重新启动。')
251
+ return
252
+ }
253
+ if (tool === 'restart-web') {
254
+ setToolNotice('正在重启 DSH Web,当前连接会短暂中断…')
255
+ const deadline = Date.now() + 60_000
256
+ while (Date.now() < deadline) {
257
+ await new Promise((resolve) => window.setTimeout(resolve, 700))
258
+ try {
259
+ const next = await request('/mgw/status')
260
+ if (next.webPid && next.webPid !== oldPid) {
261
+ window.location.reload()
262
+ return
263
+ }
264
+ } catch { /* old process is shutting down */ }
265
+ }
266
+ throw new Error('DSH Web 重启超时,请检查 /tmp/mobile-gateway.log')
267
+ }
268
+ } catch (cause) {
269
+ setError(cause.message)
270
+ setToolNotice('')
271
+ } finally {
272
+ setToolBusy(false)
273
+ }
274
+ }
275
+
178
276
  React.useEffect(() => {
179
277
  let active = true
180
278
  refresh()
@@ -186,10 +284,25 @@ window.__ModuleLoader__.load({
186
284
  if (match) setPublicIp(match[1])
187
285
  }
188
286
  }).catch((cause) => active && setPublicSetup({ installed: false, configured: false, error: cause.message }))
189
- const timer = window.setInterval(refresh, 3000)
287
+ const timer = window.setInterval(() => { if (!stoppedRef.current) refresh() }, 3000)
190
288
  return () => { active = false; window.clearInterval(timer) }
191
289
  }, [refresh])
192
290
 
291
+ const choosePairingRoute = (route) => {
292
+ pairingRouteRef.current = route
293
+ setPairingRoute(route)
294
+ setQr(null)
295
+ if (route === 'custom') {
296
+ automaticUrlRef.current = ''
297
+ return
298
+ }
299
+ if (status) {
300
+ const nextUrl = pairingUrlFor(route, status)
301
+ automaticUrlRef.current = nextUrl
302
+ setPublicUrl(nextUrl)
303
+ }
304
+ }
305
+
193
306
  const configurePublicAccess = async () => {
194
307
  if (publicSetupBusy) return
195
308
  setPublicSetupBusy(true)
@@ -201,7 +314,7 @@ window.__ModuleLoader__.load({
201
314
  body: JSON.stringify({ publicIp }),
202
315
  })
203
316
  setPublicSetup(data)
204
- if (data.publicUrl) {
317
+ if (data.publicUrl && pairingRouteRef.current === 'auto') {
205
318
  automaticUrlRef.current = data.publicUrl
206
319
  setPublicUrl(data.publicUrl)
207
320
  }
@@ -212,6 +325,33 @@ window.__ModuleLoader__.load({
212
325
  }
213
326
  }
214
327
 
328
+ const configureCloudflare = async (enabled) => {
329
+ if (cloudflareBusy) return
330
+ setCloudflareBusy(true)
331
+ setError(null)
332
+ try {
333
+ const data = await request('/mgw/cloudflare', {
334
+ method: 'POST',
335
+ headers: { 'Content-Type': 'application/json' },
336
+ body: JSON.stringify({ enabled, ...(enabled ? {
337
+ mode: cloudflareMode,
338
+ ...(cloudflareMode === 'named' ? { hostname: cloudflareHost, token: cloudflareToken } : {}),
339
+ } : {}) }),
340
+ })
341
+ setCloudflareToken('')
342
+ setStatus((current) => ({ ...(current || {}), cloudflare: data, gatewayEnabled: data.gatewayEnabled, gatewayMode: data.gatewayMode, publicUrl: data.publicUrl || null }))
343
+ if (!enabled && pairingRouteRef.current === 'cloudflare') {
344
+ pairingRouteRef.current = 'auto'
345
+ setPairingRoute('auto')
346
+ }
347
+ await refresh()
348
+ } catch (cause) {
349
+ setError(cause.message)
350
+ } finally {
351
+ setCloudflareBusy(false)
352
+ }
353
+ }
354
+
215
355
  const createPairing = async (endpoint) => request('/mgw/pair', {
216
356
  method: 'POST',
217
357
  headers: { 'Content-Type': 'application/json' },
@@ -312,13 +452,41 @@ window.__ModuleLoader__.load({
312
452
  }
313
453
  }
314
454
 
455
+ const advice = connectionAdvice(status, publicSetup)
456
+ const helpLine = (name, description) => React.createElement('div', { style: { marginTop: 7 } },
457
+ React.createElement('strong', null, name), ':', description)
458
+
315
459
  return React.createElement('div', { style: styles.backdrop, onMouseDown: () => setOpen(false) },
316
- React.createElement('aside', { style: styles.panel, onMouseDown: (event) => event.stopPropagation(), role: 'dialog', 'aria-modal': true, 'aria-label': '移动设备管理' },
460
+ React.createElement('aside', { style: styles.panel, onMouseDown: (event) => { event.stopPropagation(); setToolsOpen(false) }, role: 'dialog', 'aria-modal': true, 'aria-label': '移动设备管理' },
317
461
  React.createElement('div', { style: styles.header },
318
462
  React.createElement('div', null,
319
463
  React.createElement('h2', { style: styles.title }, '移动设备'),
320
464
  React.createElement('div', { style: styles.muted }, status && status.gatewayEnabled ? '移动网关已开启 · 设备鉴权生效中' : '移动网关已关闭 · 普通 WebUI 模式')),
321
- React.createElement('button', { style: styles.button, onClick: () => setOpen(false), 'aria-label': '关闭' }, '✕')),
465
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
466
+ React.createElement('div', { style: { position: 'relative' }, onMouseDown: (event) => event.stopPropagation() },
467
+ React.createElement('button', {
468
+ type: 'button', style: { ...styles.button, ...styles.headerButton, borderRadius: '50%' }, title: '工具', 'aria-label': '工具',
469
+ 'aria-haspopup': 'menu', 'aria-expanded': toolsOpen,
470
+ onClick: () => setToolsOpen((value) => !value),
471
+ }, React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
472
+ 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' }),
473
+ React.createElement('circle', { cx: 12, cy: 12.7, r: 3.1 }))),
474
+ toolsOpen ? React.createElement('div', { style: styles.toolsMenu, role: 'menu', 'aria-label': '网关工具' },
475
+ React.createElement('button', {
476
+ type: 'button', role: 'menuitem', disabled: toolBusy || stoppedRef.current || !status?.tools?.restartWeb,
477
+ style: { ...styles.toolsItem, opacity: status?.tools?.restartWeb ? 1 : .5 },
478
+ onClick: () => runTool('restart-web'),
479
+ }, '重启当前网关',
480
+ React.createElement('span', { style: { ...styles.muted, display: 'block', marginTop: 2 } }, '结束当前进程并重新启动 DSH Web')),
481
+ React.createElement('button', {
482
+ type: 'button', role: 'menuitem', disabled: toolBusy || stoppedRef.current || !status?.tools?.stopWeb,
483
+ style: { ...styles.toolsItem, color: colors.danger, opacity: status?.tools?.stopWeb ? 1 : .5 },
484
+ onClick: () => runTool('stop-web'),
485
+ }, '停止 DSH Web',
486
+ React.createElement('span', { style: { ...styles.muted, display: 'block', marginTop: 2 } }, '结束后台进程,所有连接将断开')))
487
+ : null),
488
+ React.createElement('button', { style: { ...styles.button, ...styles.headerButton }, onClick: () => setOpen(false), 'aria-label': '关闭' }, '✕'))),
489
+ toolNotice ? React.createElement('div', { style: { ...styles.muted, marginBottom: 10 } }, toolNotice) : null,
322
490
  error ? React.createElement('div', { style: styles.error }, error) : null,
323
491
  React.createElement('section', { style: styles.card },
324
492
  React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
@@ -373,7 +541,35 @@ window.__ModuleLoader__.load({
373
541
  status && !status.requireAuth
374
542
  ? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:本机 DSH 入口将跳过设备凭证校验;独立局域网入口仍强制鉴权。')
375
543
  : null),
376
- React.createElement('section', { style: styles.card },
544
+ status && status.cloudflare && status.cloudflare.supported
545
+ ? React.createElement('section', { style: styles.card },
546
+ React.createElement('strong', null, 'Cloudflare Tunnel · PC 外网接入'),
547
+ React.createElement('p', { style: styles.muted }, '首次开启时自动下载并校验 cloudflared;公网入口只接受已鉴权的移动 WebSocket。'),
548
+ React.createElement('label', { style: styles.label }, '接入方式'),
549
+ React.createElement('select', { style: styles.input, value: cloudflareMode, onChange: (event) => setCloudflareMode(event.target.value), disabled: cloudflareBusy },
550
+ React.createElement('option', { value: 'quick' }, 'Quick Tunnel · 无需域名'),
551
+ React.createElement('option', { value: 'named' }, '命名 Tunnel · 固定域名')),
552
+ cloudflareMode === 'quick'
553
+ ? React.createElement('p', { style: styles.muted }, 'Cloudflare 会分配随机地址;DSH 重启或隧道重连后地址可能改变,手机需重新扫码确认新地址。适合临时使用。')
554
+ : React.createElement(React.Fragment, null,
555
+ React.createElement('p', { style: styles.muted }, '先在 Cloudflare 创建命名 Tunnel,把公开域名的 Service URL 设为 ', React.createElement('code', null, `http://127.0.0.1:${status.cloudflare.port}`), '。'),
556
+ React.createElement('label', { style: styles.label }, '公开域名'),
557
+ React.createElement('input', { style: styles.input, value: cloudflareHost, onChange: (event) => setCloudflareHost(event.target.value.trim()), placeholder: 'gateway.example.com', spellCheck: false }),
558
+ React.createElement('label', { style: styles.label }, 'Tunnel Token'),
559
+ 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 })),
560
+ React.createElement('button', {
561
+ style: { ...styles.button, ...styles.primary, opacity: cloudflareBusy || (cloudflareMode === 'named' && (!cloudflareHost || (!cloudflareToken && !status.cloudflare.configured))) ? .65 : 1 },
562
+ disabled: cloudflareBusy || (cloudflareMode === 'named' && (!cloudflareHost || (!cloudflareToken && !status.cloudflare.configured))),
563
+ onClick: () => configureCloudflare(true),
564
+ }, cloudflareBusy ? '正在处理…' : status.cloudflare.enabled && cloudflareMode === status.cloudflare.mode ? '更新 Tunnel 配置' : cloudflareMode === 'quick' ? '一键开启 Quick Tunnel' : '开启命名 Tunnel'),
565
+ status.cloudflare.enabled
566
+ ? React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy, onClick: () => configureCloudflare(false) }, '关闭 Cloudflare Tunnel')
567
+ : null,
568
+ React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere', color: status.cloudflare.error ? colors.danger : colors.muted } },
569
+ status.cloudflare.error || ({ disabled: '未开启', starting: '正在启动本机入口…', preparing: '正在准备 cloudflared(首次需下载)…', connecting: '正在连接 Cloudflare…', online: '已连接 Cloudflare', 'waiting-for-gateway': '等待移动网关开启' }[status.cloudflare.state] || status.cloudflare.state),
570
+ status.cloudflare.publicUrl ? ` · ${status.cloudflare.publicUrl}` : ''))
571
+ : null,
572
+ status && status.platform === 'linux' ? React.createElement('section', { style: styles.card },
377
573
  React.createElement('strong', null, '公网接入'),
378
574
  publicSetup === null
379
575
  ? React.createElement('p', { style: styles.muted }, '正在检查系统组件…')
@@ -403,10 +599,46 @@ window.__ModuleLoader__.load({
403
599
  : null)
404
600
  : React.createElement(React.Fragment, null,
405
601
  React.createElement('p', { style: { ...styles.muted, marginBottom: 8 } }, '尚未安装系统 Helper。请在服务器执行一次初始化:'),
406
- React.createElement('code', { style: { display: 'block', fontSize: 10, lineHeight: 1.5, overflowWrap: 'anywhere', userSelect: 'all' } }, 'npx --yes dsh-plugin-mobile-gateway@latest init'))),
602
+ React.createElement('code', { style: { display: 'block', fontSize: 10, lineHeight: 1.5, overflowWrap: 'anywhere', userSelect: 'all' } }, 'npx --yes dsh-plugin-mobile-gateway@latest init'))) : null,
407
603
  React.createElement('section', { style: styles.card },
604
+ React.createElement('div', { style: { position: 'relative', display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }, onMouseLeave: () => setConnectionHelpOpen(false) },
605
+ React.createElement('span', { style: { ...styles.label, marginBottom: 0 } }, '配对连接方式'),
606
+ React.createElement('button', {
607
+ type: 'button',
608
+ style: styles.helpButton,
609
+ 'aria-label': '连接方式说明与场景建议',
610
+ 'aria-expanded': connectionHelpOpen,
611
+ 'aria-describedby': connectionHelpOpen ? 'mgw-route-help' : undefined,
612
+ onMouseEnter: () => setConnectionHelpOpen(true),
613
+ onFocus: () => setConnectionHelpOpen(true),
614
+ onBlur: () => setConnectionHelpOpen(false),
615
+ onClick: () => setConnectionHelpOpen(true),
616
+ onKeyDown: (event) => { if (event.key === 'Escape') setConnectionHelpOpen(false) },
617
+ }, '?'),
618
+ connectionHelpOpen ? React.createElement('div', { id: 'mgw-route-help', role: 'tooltip', style: styles.helpBubble },
619
+ React.createElement('strong', null, '怎么选连接方式'),
620
+ helpLine('局域网直连', '手机与 DSH 在同一个可互访的局域网,使用 ws:// 私有地址;离开该网络就无法直连。'),
621
+ helpLine('Cloudflare Channel(命名 Tunnel)', 'PC 主动连接 Cloudflare,外网用固定域名的 wss://;需要 Cloudflare 账户和域名。'),
622
+ helpLine('Quick Channel(Quick Tunnel)', '无需账户或域名,Cloudflare 分配临时 wss:// 地址;隧道重建后地址可能变化。'),
623
+ helpLine('公网服务器连接', 'DSH 运行在有固定公网 IPv4 的 Linux 服务器上,配置公网入口后用 wss:// 公网 IP 连接。'),
624
+ React.createElement('div', { style: { borderTop: '1px solid rgba(255,255,255,.18)', marginTop: 10, paddingTop: 8 } },
625
+ React.createElement('strong', null, '结合当前环境'),
626
+ React.createElement('div', { style: { color: '#c6cbd4', marginTop: 4 } }, advice.detected),
627
+ React.createElement('div', { style: { marginTop: 6 } }, advice.local),
628
+ React.createElement('div', { style: { marginTop: 6 } }, advice.remote),
629
+ React.createElement('div', { style: { color: '#aeb6c3', marginTop: 6 } }, '面板无法判断手机当前网络;请按手机所在位置选择,提示不会替你修改选择。')))
630
+ : null),
631
+ React.createElement('select', { style: styles.input, value: pairingRoute, onChange: (event) => choosePairingRoute(event.target.value), disabled: !status, 'aria-label': '配对连接方式' },
632
+ React.createElement('option', { value: 'auto' }, '自动选择 · 优先外网'),
633
+ lanPairingUrl(status) || pairingRoute === 'lan'
634
+ ? React.createElement('option', { value: 'lan', disabled: !lanPairingUrl(status) }, lanPairingUrl(status)
635
+ ? '局域网直连 · ' + lanPairingUrl(status).replace(/^wss?:\/\//, '').split('/')[0]
636
+ : '局域网直连 · 当前不可用')
637
+ : null,
638
+ status?.cloudflare?.enabled ? React.createElement('option', { value: 'cloudflare' }, 'Cloudflare 外网') : null,
639
+ React.createElement('option', { value: 'custom' }, '手动输入地址')),
408
640
  React.createElement('label', { style: styles.label }, 'WebSocket 地址'),
409
- React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => { automaticUrlRef.current = ''; setPublicUrl(event.target.value) }, placeholder: 'ws://192.168.1.10:3081/ws/mobile', spellCheck: false }),
641
+ 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 }),
410
642
  status && status.lan && status.lan.enabled
411
643
  ? React.createElement('div', { style: { ...styles.muted, margin: '-7px 0 10px', color: status.lan.error ? colors.danger : colors.muted } },
412
644
  status.lan.error
@@ -417,8 +649,12 @@ window.__ModuleLoader__.load({
417
649
  : null,
418
650
  React.createElement('label', { style: styles.label }, '设备名称'),
419
651
  React.createElement('input', { style: styles.input, value: deviceName, onChange: (event) => setDeviceName(event.target.value), maxLength: 80, placeholder: 'iPhone' }),
420
- React.createElement('button', { style: { ...styles.button, ...styles.primary, opacity: busy || !(status && status.gatewayEnabled) ? .65 : 1 }, disabled: busy || !(status && status.gatewayEnabled), onClick: pair }, busy ? '正在生成…' : status && status.gatewayEnabled ? '生成配对二维码' : '请先开启移动网关'),
421
- React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '同一局域网可直接使用私有地址的 ws://;公网地址仍必须使用 wss://。配对码只能使用一次,并在 5 分钟内过期。')),
652
+ React.createElement('button', {
653
+ style: { ...styles.button, ...styles.primary, opacity: busy || !(status && status.gatewayEnabled) || !publicUrl.trim() ? .65 : 1 },
654
+ disabled: busy || !(status && status.gatewayEnabled) || !publicUrl.trim(),
655
+ onClick: pair,
656
+ }, busy ? '正在生成…' : !(status && status.gatewayEnabled) ? '请先开启移动网关' : !publicUrl.trim() ? '等待连接地址' : '生成配对二维码'),
657
+ React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '同一局域网选择“局域网直连”,无需关闭 Cloudflare Tunnel。局域网使用私有地址的 ws://;公网地址必须使用 wss://。配对码只能使用一次,并在 5 分钟内过期。')),
422
658
  qr ? React.createElement('section', { style: { ...styles.card, textAlign: 'center' } },
423
659
  React.createElement('strong', null, '使用 iOS 客户端扫码'),
424
660
  React.createElement('img', { style: styles.qr, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.svg)}`, alt: '一次性设备配对二维码' }),
@@ -0,0 +1,268 @@
1
+ import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import path from 'node:path'
4
+ import crypto from 'node:crypto'
5
+ import { spawn } from 'node:child_process'
6
+ import { ensureCloudflared } from './cloudflared-binary.mjs'
7
+
8
+ function hostnameOf(value) {
9
+ if (typeof value !== 'string') throw new TypeError('Cloudflare hostname is required')
10
+ const hostname = value.trim().toLowerCase()
11
+ if (hostname.length > 253 || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(hostname) ||
12
+ !hostname.includes('.') || hostname.includes('..') ||
13
+ hostname.split('.').some((label) => label.length > 63 || label.startsWith('-') || label.endsWith('-'))) {
14
+ throw new TypeError('Cloudflare hostname must be a domain name, for example gateway.example.com')
15
+ }
16
+ return hostname
17
+ }
18
+
19
+ function tokenOf(value) {
20
+ if (typeof value !== 'string' || !value.trim() || value.length > 8192 || /\s/.test(value)) {
21
+ throw new TypeError('Cloudflare Tunnel Token must be a nonempty token without whitespace')
22
+ }
23
+ return value
24
+ }
25
+
26
+ function readState(file) {
27
+ try {
28
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'))
29
+ if (value?.version !== 1 || typeof value.enabled !== 'boolean' ||
30
+ typeof value.hostname !== 'string' || typeof value.token !== 'string') {
31
+ throw new TypeError('invalid Cloudflare Tunnel settings')
32
+ }
33
+ if (value.hostname) hostnameOf(value.hostname)
34
+ if (value.token) tokenOf(value.token)
35
+ value.mode ??= 'named'
36
+ if (!['named', 'quick'].includes(value.mode)) throw new TypeError('invalid Cloudflare Tunnel mode')
37
+ if (value.enabled && value.mode === 'named' && (!value.hostname || !value.token)) {
38
+ throw new TypeError('enabled named tunnel has no hostname or token')
39
+ }
40
+ fs.chmodSync(file, 0o600)
41
+ return value
42
+ } catch (error) {
43
+ if (error.code === 'ENOENT') return { version: 1, enabled: false, mode: 'quick', hostname: '', token: '' }
44
+ throw new Error(`failed to load Cloudflare Tunnel settings ${file}: ${error.message}`, { cause: error })
45
+ }
46
+ }
47
+
48
+ function writeState(file, value) {
49
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
50
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
51
+ try {
52
+ fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' })
53
+ fs.renameSync(temporary, file)
54
+ fs.chmodSync(file, 0o600)
55
+ } finally {
56
+ fs.rmSync(temporary, { force: true })
57
+ }
58
+ }
59
+
60
+ // A separate loopback listener exposes only the mobile WebSocket. In particular,
61
+ // Cloudflare can never reach the DSH WebUI or the /mgw management API through it.
62
+ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable = 'cloudflared',
63
+ supported = true, isGatewayEnabled, onUpgrade, log = () => {}, spawnProcess = spawn,
64
+ prepareExecutable = ensureCloudflared }) {
65
+ let settings = supported ? readState(file) : { version: 1, enabled: false, mode: 'quick', hostname: '', token: '' }
66
+ let server = null
67
+ let child = null
68
+ let preparing = null
69
+ let retryTimer = null
70
+ let disposed = false
71
+ let phase = 'disabled'
72
+ let error = null
73
+ let quickUrl = null
74
+ let queue = Promise.resolve()
75
+ const sockets = new Set()
76
+
77
+ const shouldRun = () => supported && !disposed && settings.enabled && isGatewayEnabled()
78
+ const publicUrl = () => settings.mode === 'quick'
79
+ ? quickUrl
80
+ : settings.hostname ? `wss://${settings.hostname}${wsPath}` : null
81
+ const snapshot = () => ({
82
+ supported,
83
+ enabled: settings.enabled,
84
+ mode: settings.mode,
85
+ configured: !!(settings.hostname && settings.token),
86
+ hostname: settings.hostname || null,
87
+ publicUrl: settings.enabled ? publicUrl() : null,
88
+ port,
89
+ state: !settings.enabled ? 'disabled' : !isGatewayEnabled() ? 'waiting-for-gateway' : phase,
90
+ error,
91
+ })
92
+
93
+ const stop = async () => {
94
+ if (retryTimer) clearTimeout(retryTimer)
95
+ retryTimer = null
96
+ const previous = child
97
+ child = null
98
+ if (previous && !previous.killed) previous.kill()
99
+ quickUrl = null
100
+ for (const socket of sockets) socket.destroy()
101
+ sockets.clear()
102
+ const listener = server
103
+ server = null
104
+ phase = 'disabled'
105
+ error = null
106
+ if (listener) await new Promise((resolve) => listener.close(resolve))
107
+ }
108
+
109
+ const startChild = () => {
110
+ if (!shouldRun() || !server || child || preparing) return
111
+ phase = 'preparing'
112
+ error = null
113
+ preparing = prepareExecutable({ cacheDir: path.join(path.dirname(file), 'cloudflared-bin'), executable })
114
+ .then((binary) => {
115
+ preparing = null
116
+ if (!shouldRun() || !server || child) return
117
+ phase = 'connecting'
118
+ const quick = settings.mode === 'quick'
119
+ const args = quick
120
+ ? ['tunnel', '--no-autoupdate', '--loglevel', 'info', '--url', `http://127.0.0.1:${port}`]
121
+ : ['tunnel', '--no-autoupdate', '--loglevel', 'info', 'run']
122
+ const childEnv = { ...process.env }
123
+ delete childEnv.TUNNEL_TOKEN
124
+ if (!quick) childEnv.TUNNEL_TOKEN = settings.token
125
+ const running = spawnProcess(binary, args, {
126
+ env: childEnv,
127
+ stdio: ['ignore', 'pipe', 'pipe'],
128
+ windowsHide: true,
129
+ })
130
+ child = running
131
+ let diagnostics = ''
132
+ let outputTail = ''
133
+ let registered = false
134
+ const onOutput = (chunk) => {
135
+ if (child !== running) return
136
+ const lines = chunk.toString().slice(0, 4096)
137
+ outputTail = (outputTail + lines).slice(-4096)
138
+ if (quick) {
139
+ const match = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com\b/i.exec(outputTail)
140
+ if (match) quickUrl = `wss://${match[1].toLowerCase()}.trycloudflare.com${wsPath}`
141
+ }
142
+ if (/Registered tunnel connection/i.test(outputTail)) {
143
+ registered = true
144
+ outputTail = ''
145
+ } else if (/ERR|error/i.test(lines)) {
146
+ diagnostics = (settings.token ? outputTail.replaceAll(settings.token, '[redacted]') : outputTail)
147
+ .replace(/eyJ[A-Za-z0-9._=-]+/g, '[redacted]').trim().slice(-500)
148
+ }
149
+ if (registered && (!quick || quickUrl)) {
150
+ phase = 'online'
151
+ error = null
152
+ }
153
+ }
154
+ running.stderr?.on('data', onOutput)
155
+ running.stdout?.on('data', onOutput)
156
+ const exited = (reason) => {
157
+ if (child !== running) return
158
+ child = null
159
+ quickUrl = null
160
+ phase = 'error'
161
+ error = reason || diagnostics || 'cloudflared exited unexpectedly'
162
+ log(`Cloudflare Tunnel stopped: ${error}`)
163
+ if (shouldRun()) retryTimer = setTimeout(() => { retryTimer = null; startChild() }, 10_000)
164
+ }
165
+ running.once('error', (cause) => exited(cause.code === 'ENOENT'
166
+ ? 'cloudflared is not installed or is not on PATH'
167
+ : `cloudflared failed: ${cause.message}`))
168
+ running.once('exit', (code, signal) => exited(diagnostics || `cloudflared exited (${signal || code})`))
169
+ })
170
+ .catch((cause) => {
171
+ preparing = null
172
+ if (!shouldRun()) return
173
+ phase = 'error'
174
+ error = `Cloudflare binary preparation failed: ${cause.message}`
175
+ log(error)
176
+ retryTimer = setTimeout(() => { retryTimer = null; startChild() }, 30_000)
177
+ })
178
+ }
179
+
180
+ const start = async () => {
181
+ if (server || !shouldRun()) return
182
+ phase = 'starting'
183
+ error = null
184
+ const listener = http.createServer((_req, res) => {
185
+ res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' })
186
+ res.end('Not found')
187
+ })
188
+ listener.on('connection', (socket) => {
189
+ sockets.add(socket)
190
+ socket.on('close', () => sockets.delete(socket))
191
+ })
192
+ listener.on('upgrade', (req, socket, head) => {
193
+ let pathname
194
+ try { pathname = new URL(req.url || '/', 'http://localhost').pathname } catch { pathname = '' }
195
+ if (pathname !== wsPath) {
196
+ socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n')
197
+ return
198
+ }
199
+ onUpgrade(req, socket, head, { lan: true, port })
200
+ })
201
+ try {
202
+ await new Promise((resolve, reject) => {
203
+ listener.once('error', reject)
204
+ listener.listen(port, '127.0.0.1', () => {
205
+ listener.off('error', reject)
206
+ resolve()
207
+ })
208
+ })
209
+ if (!shouldRun()) {
210
+ listener.close()
211
+ return
212
+ }
213
+ server = listener
214
+ listener.on('error', (cause) => {
215
+ const message = `Cloudflare origin listener failed: ${cause.message}`
216
+ void stop().then(() => {
217
+ if (!shouldRun()) return
218
+ phase = 'error'
219
+ error = message
220
+ log(message)
221
+ retryTimer = setTimeout(() => { retryTimer = null; void reconcile() }, 10_000)
222
+ })
223
+ })
224
+ startChild()
225
+ } catch (cause) {
226
+ try { listener.close() } catch { /* The listener never opened. */ }
227
+ if (shouldRun()) {
228
+ phase = 'error'
229
+ error = `Cloudflare origin listener failed: ${cause.message}`
230
+ log(error)
231
+ retryTimer = setTimeout(() => { retryTimer = null; void reconcile() }, 10_000)
232
+ }
233
+ }
234
+ }
235
+
236
+ const reconcile = () => {
237
+ queue = queue.catch(() => {}).then(async () => {
238
+ if (shouldRun()) await start()
239
+ else await stop()
240
+ })
241
+ return queue
242
+ }
243
+
244
+ const configure = async ({ enabled, mode, hostname, token }) => {
245
+ if (!supported) throw new TypeError('Cloudflare Tunnel is available on PC only')
246
+ if (typeof enabled !== 'boolean') throw new TypeError('enabled must be a boolean')
247
+ const next = {
248
+ version: 1,
249
+ enabled,
250
+ mode: mode === undefined ? settings.mode : mode,
251
+ hostname: hostname === undefined ? settings.hostname : hostnameOf(hostname),
252
+ token: token === undefined || token === '' ? settings.token : tokenOf(token),
253
+ }
254
+ if (!['named', 'quick'].includes(next.mode)) throw new TypeError('mode must be named or quick')
255
+ if (enabled && next.mode === 'named' && (!next.hostname || !next.token)) {
256
+ throw new TypeError('hostname and Tunnel Token are required')
257
+ }
258
+ const changed = next.mode !== settings.mode || next.hostname !== settings.hostname || next.token !== settings.token
259
+ writeState(file, next)
260
+ settings = next
261
+ if (changed) await stop()
262
+ await reconcile()
263
+ return snapshot()
264
+ }
265
+
266
+ const dispose = () => { disposed = true; void stop() }
267
+ return { snapshot, configure, reconcile, dispose }
268
+ }