@sparkelf/dsh-plugin-mobile-gateway 0.8.0 → 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
@@ -2,7 +2,10 @@
2
2
  // bundle part of DSH's client-module graph; it contributes one sidebar footer
3
3
  // action and one shell overlay without replacing shipped UI seats.
4
4
  window.__ModuleLoader__.load({
5
- id: 'dsh-plugin-mobile-gateway',
5
+ // Must equal the package name: DSH derives the boot-manifest entry id from the package name,
6
+ // and a client bundle that registers a different id makes the same factory register twice,
7
+ // failing the whole browser boot with "Failed to load plugins".
8
+ id: '@sparkelf/dsh-plugin-mobile-gateway',
6
9
  factory: (require) => {
7
10
  const React = require('react')
8
11
  const module = { exports: {} }
@@ -0,0 +1,520 @@
1
+ // Browser half of dsh-plugin-mobile-gateway. The package manifest makes this
2
+ // bundle part of DSH's client-module graph; it contributes one sidebar footer
3
+ // action and one shell overlay without replacing shipped UI seats.
4
+ window.__ModuleLoader__.load({
5
+ id: 'dsh-plugin-mobile-gateway',
6
+ factory: (require) => {
7
+ const React = require('react')
8
+ const module = { exports: {} }
9
+ const exports = module.exports
10
+
11
+ let open = false
12
+ const listeners = new Set()
13
+ const setOpen = (value) => {
14
+ open = value
15
+ for (const listener of listeners) listener()
16
+ }
17
+ const subscribe = (listener) => {
18
+ listeners.add(listener)
19
+ return () => listeners.delete(listener)
20
+ }
21
+ const getOpen = () => open
22
+
23
+ const colors = {
24
+ panel: 'var(--color-bg, #111318)',
25
+ card: 'var(--color-bg-elevated, rgba(127,127,127,.08))',
26
+ text: 'var(--color-text, #f4f4f5)',
27
+ muted: 'var(--color-text-muted, #9297a2)',
28
+ border: 'var(--color-border, rgba(127,127,127,.24))',
29
+ accent: 'var(--color-primary, #4f7cff)',
30
+ danger: '#dc4c64',
31
+ success: '#2fb171',
32
+ }
33
+ const styles = {
34
+ backdrop: {
35
+ position: 'fixed', inset: 0, zIndex: 1200, pointerEvents: 'auto',
36
+ background: 'rgba(0,0,0,.32)', display: 'flex', justifyContent: 'flex-end',
37
+ },
38
+ panel: {
39
+ width: 380, maxWidth: 'calc(100vw - 24px)', height: '100%', overflowY: 'auto',
40
+ display: 'flex', flexDirection: 'column',
41
+ background: colors.panel, color: colors.text, borderLeft: `1px solid ${colors.border}`,
42
+ boxShadow: '-12px 0 40px rgba(0,0,0,.22)', padding: 20,
43
+ fontFamily: 'system-ui, sans-serif', fontSize: 13, boxSizing: 'border-box',
44
+ },
45
+ header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18, flexShrink: 0 },
46
+ title: { margin: 0, fontSize: 18, fontWeight: 650 },
47
+ card: { background: colors.card, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 14, marginBottom: 12, flexShrink: 0 },
48
+ label: { display: 'block', color: colors.muted, fontSize: 12, marginBottom: 6 },
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
+ 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
+ button: { cursor: 'pointer', border: `1px solid ${colors.border}`, background: 'transparent', borderRadius: 8, padding: '7px 11px', color: 'inherit', font: 'inherit' },
52
+ primary: { background: colors.accent, color: '#fff', borderColor: colors.accent, width: '100%' },
53
+ muted: { color: colors.muted, fontSize: 12, lineHeight: 1.55 },
54
+ row: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 12, alignItems: 'center', padding: '12px 0', borderBottom: `1px solid ${colors.border}` },
55
+ badge: { display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, marginLeft: 8, color: colors.muted },
56
+ dot: { width: 7, height: 7, borderRadius: 99, display: 'inline-block' },
57
+ error: { color: '#ff8092', background: 'rgba(220,76,100,.12)', borderRadius: 8, padding: 10, marginBottom: 12 },
58
+ qr: { display: 'block', width: 220, height: 220, margin: '12px auto', background: '#fff', borderRadius: 10, padding: 8 },
59
+ switch: { position: 'relative', display: 'inline-flex', width: 42, height: 24, flexShrink: 0 },
60
+ switchInput: { position: 'absolute', opacity: 0, width: 1, height: 1 },
61
+ switchTrack: { position: 'absolute', inset: 0, borderRadius: 99, transition: 'background .18s ease', cursor: 'pointer' },
62
+ switchKnob: { position: 'absolute', top: 3, width: 18, height: 18, borderRadius: 99, background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,.35)', transition: 'left .18s ease', pointerEvents: 'none' },
63
+ version: { flexShrink: 0, marginTop: 'auto', padding: '18px 2px 0', color: colors.muted, fontSize: 11, lineHeight: 1.4, textAlign: 'right', letterSpacing: '.02em' },
64
+ }
65
+
66
+ async function request(path, options) {
67
+ const response = await fetch(path, { credentials: 'same-origin', ...options })
68
+ const body = await response.json().catch(() => ({}))
69
+ if (!response.ok) throw new Error(body.message || body.error || `HTTP ${response.status}`)
70
+ return body
71
+ }
72
+
73
+ function inferredUrl(wsPath) {
74
+ const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
75
+ return `${scheme}//${window.location.host}${wsPath || '/ws/mobile'}`
76
+ }
77
+
78
+ function DeviceRow({ device, onRevoke, revoking }) {
79
+ const triggerRevoke = (event) => {
80
+ event.preventDefault()
81
+ event.stopPropagation()
82
+ if (!revoking) onRevoke(device)
83
+ }
84
+ return React.createElement('div', { style: styles.row },
85
+ React.createElement('div', { style: { minWidth: 0, overflow: 'hidden' } },
86
+ React.createElement('div', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
87
+ device.name,
88
+ React.createElement('span', { style: styles.badge },
89
+ React.createElement('span', { style: { ...styles.dot, background: device.online ? colors.success : colors.muted } }),
90
+ device.online ? `在线${device.connections > 1 ? ` · ${device.connections} 个连接` : ''}` : '离线')),
91
+ React.createElement('div', { style: { ...styles.muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginTop: 3 } },
92
+ device.lastSeenAt ? `最近连接:${new Date(device.lastSeenAt).toLocaleString()}` : '尚未连接')),
93
+ React.createElement('button', {
94
+ type: 'button',
95
+ disabled: revoking,
96
+ style: {
97
+ ...styles.button,
98
+ color: colors.danger,
99
+ background: 'transparent',
100
+ borderColor: 'rgba(220,76,100,.55)',
101
+ flexShrink: 0,
102
+ minWidth: 54,
103
+ opacity: revoking ? .6 : 1,
104
+ },
105
+ title: `吊销 ${device.name}`,
106
+ // Pointer-up is more reliable than click inside the shell overlay on
107
+ // touch devices. The click handler remains for keyboard activation.
108
+ onPointerUp: triggerRevoke,
109
+ onClick: (event) => {
110
+ if (event.detail === 0) triggerRevoke(event)
111
+ },
112
+ }, revoking ? '吊销中…' : '吊销'))
113
+ }
114
+
115
+ function DevicePanel() {
116
+ const [devices, setDevices] = React.useState(null)
117
+ const [status, setStatus] = React.useState(null)
118
+ const [publicUrl, setPublicUrl] = React.useState('')
119
+ const [publicIp, setPublicIp] = React.useState('')
120
+ const [publicSetup, setPublicSetup] = React.useState(null)
121
+ const [publicSetupBusy, setPublicSetupBusy] = React.useState(false)
122
+ const [deviceName, setDeviceName] = React.useState('iPhone')
123
+ const [qr, setQr] = React.useState(null)
124
+ const [error, setError] = React.useState(null)
125
+ const [busy, setBusy] = React.useState(false)
126
+ const [refreshing, setRefreshing] = React.useState(false)
127
+ const [refreshNotice, setRefreshNotice] = React.useState(null)
128
+ const [pairingTokenCopied, setPairingTokenCopied] = React.useState(false)
129
+ const [deviceNotice, setDeviceNotice] = React.useState(null)
130
+ const [deviceNoticeError, setDeviceNoticeError] = React.useState(false)
131
+ const [revokingIds, setRevokingIds] = React.useState(() => new Set())
132
+ const revokingIdsRef = React.useRef(new Set())
133
+ const automaticUrlRef = React.useRef('')
134
+
135
+ const refresh = React.useCallback(async () => {
136
+ try {
137
+ const [deviceData, statusData] = await Promise.all([
138
+ request('/mgw/devices'),
139
+ request('/mgw/status'),
140
+ ])
141
+ const nextDevices = deviceData.devices || []
142
+ // Keep existing DOM rows alive when polling returns identical data.
143
+ // Replacing them every three seconds can swallow a pointer/click that
144
+ // started on the old button and ended after React replaced the row.
145
+ if (revokingIdsRef.current.size === 0) {
146
+ setDevices((current) => JSON.stringify(current) === JSON.stringify(nextDevices) ? current : nextDevices)
147
+ }
148
+ 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
+ })
158
+ return true
159
+ } catch (cause) {
160
+ setError(cause.message)
161
+ return false
162
+ }
163
+ }, [])
164
+
165
+ const manualRefresh = async () => {
166
+ if (refreshing) return
167
+ setRefreshing(true)
168
+ setRefreshNotice(null)
169
+ const succeeded = await refresh()
170
+ setRefreshing(false)
171
+ if (succeeded) {
172
+ setError(null)
173
+ setRefreshNotice(`已刷新 · ${new Date().toLocaleTimeString()}`)
174
+ window.setTimeout(() => setRefreshNotice(null), 2200)
175
+ }
176
+ }
177
+
178
+ React.useEffect(() => {
179
+ let active = true
180
+ refresh()
181
+ request('/mgw/public-setup').then((data) => {
182
+ if (!active) return
183
+ setPublicSetup(data)
184
+ if (data.publicUrl) {
185
+ const match = /^wss:\/\/([^/]+)\/ws\/mobile$/.exec(data.publicUrl)
186
+ if (match) setPublicIp(match[1])
187
+ }
188
+ }).catch((cause) => active && setPublicSetup({ installed: false, configured: false, error: cause.message }))
189
+ const timer = window.setInterval(refresh, 3000)
190
+ return () => { active = false; window.clearInterval(timer) }
191
+ }, [refresh])
192
+
193
+ const configurePublicAccess = async () => {
194
+ if (publicSetupBusy) return
195
+ setPublicSetupBusy(true)
196
+ setError(null)
197
+ try {
198
+ const data = await request('/mgw/public-setup', {
199
+ method: 'POST',
200
+ headers: { 'Content-Type': 'application/json' },
201
+ body: JSON.stringify({ publicIp }),
202
+ })
203
+ setPublicSetup(data)
204
+ if (data.publicUrl) {
205
+ automaticUrlRef.current = data.publicUrl
206
+ setPublicUrl(data.publicUrl)
207
+ }
208
+ } catch (cause) {
209
+ setError(cause.message)
210
+ } finally {
211
+ setPublicSetupBusy(false)
212
+ }
213
+ }
214
+
215
+ const createPairing = async (endpoint) => request('/mgw/pair', {
216
+ method: 'POST',
217
+ headers: { 'Content-Type': 'application/json' },
218
+ body: JSON.stringify({ name: deviceName, publicUrl: endpoint }),
219
+ })
220
+
221
+ const pair = async () => {
222
+ setBusy(true)
223
+ setError(null)
224
+ setPairingTokenCopied(false)
225
+ try {
226
+ const data = await createPairing(publicUrl)
227
+ setQr(data)
228
+ } catch (cause) {
229
+ setError(cause.message)
230
+ } finally {
231
+ setBusy(false)
232
+ }
233
+ }
234
+
235
+ const copyPairingText = async () => {
236
+ if (!qr || !qr.qrPayload) return
237
+ try {
238
+ if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
239
+ throw new Error('当前浏览器不允许直接写入剪贴板,请选中文本后手动复制')
240
+ }
241
+ await navigator.clipboard.writeText(qr.qrPayload)
242
+ setError(null)
243
+ setPairingTokenCopied(true)
244
+ window.setTimeout(() => setPairingTokenCopied(false), 2200)
245
+ } catch (cause) {
246
+ setPairingTokenCopied(false)
247
+ setError(cause.message)
248
+ }
249
+ }
250
+
251
+ const changeGatewayMode = async (mode) => {
252
+ setBusy(true)
253
+ setError(null)
254
+ try {
255
+ const data = await request('/mgw/gateway', {
256
+ method: 'POST',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({ mode }),
259
+ })
260
+ setStatus((current) => ({ ...(current || {}), ...data }))
261
+ if (mode === 'disabled') setQr(null)
262
+ } catch (cause) {
263
+ setError(cause.message)
264
+ } finally {
265
+ setBusy(false)
266
+ }
267
+ }
268
+
269
+ const toggleAuth = async () => {
270
+ const enabled = !(status && status.requireAuth)
271
+ if (!enabled && !window.confirm('关闭设备鉴权后,任何能访问移动网关的人都可以控制 DSH。此选项仅限 Debug 阶段使用,确定继续吗?')) return
272
+ setBusy(true)
273
+ setError(null)
274
+ try {
275
+ const data = await request('/mgw/auth', {
276
+ method: 'POST',
277
+ headers: { 'Content-Type': 'application/json' },
278
+ body: JSON.stringify({ enabled }),
279
+ })
280
+ setStatus((current) => ({ ...(current || {}), requireAuth: data.requireAuth }))
281
+ } catch (cause) {
282
+ setError(cause.message)
283
+ } finally {
284
+ setBusy(false)
285
+ }
286
+ }
287
+
288
+ const revoke = async (device) => {
289
+ if (revokingIdsRef.current.has(device.id)) return
290
+ setDeviceNotice(null)
291
+ setDeviceNoticeError(false)
292
+ setError(null)
293
+ revokingIdsRef.current.add(device.id)
294
+ setRevokingIds((current) => new Set(current).add(device.id))
295
+ try {
296
+ await request(`/mgw/devices/${encodeURIComponent(device.id)}/revoke`, { method: 'POST' })
297
+ setDevices((current) => Array.isArray(current) ? current.filter((item) => item.id !== device.id) : current)
298
+ setDeviceNotice(`已吊销 ${device.name}`)
299
+ window.setTimeout(() => setDeviceNotice(null), 2200)
300
+ } catch (cause) {
301
+ setDeviceNoticeError(true)
302
+ setDeviceNotice(`吊销失败:${cause.message}`)
303
+ setError(cause.message)
304
+ } finally {
305
+ revokingIdsRef.current.delete(device.id)
306
+ setRevokingIds((current) => {
307
+ const next = new Set(current)
308
+ next.delete(device.id)
309
+ return next
310
+ })
311
+ await refresh()
312
+ }
313
+ }
314
+
315
+ 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': '移动设备管理' },
317
+ React.createElement('div', { style: styles.header },
318
+ React.createElement('div', null,
319
+ React.createElement('h2', { style: styles.title }, '移动设备'),
320
+ React.createElement('div', { style: styles.muted }, status && status.gatewayEnabled ? '移动网关已开启 · 设备鉴权生效中' : '移动网关已关闭 · 普通 WebUI 模式')),
321
+ React.createElement('button', { style: styles.button, onClick: () => setOpen(false), 'aria-label': '关闭' }, '✕')),
322
+ error ? React.createElement('div', { style: styles.error }, error) : null,
323
+ React.createElement('section', { style: styles.card },
324
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
325
+ React.createElement('div', null,
326
+ React.createElement('strong', null, '网关运行模式'),
327
+ React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.gatewayEnabled
328
+ ? status.gatewayMode === 'persistent'
329
+ ? '常驻开启,重启后保持;无需设备在线'
330
+ : status.waitExpiresAt
331
+ ? `等待可信设备连接,约 ${Math.max(1, Math.ceil((status.waitExpiresAt - Date.now()) / 60000))} 分钟后自动关闭`
332
+ : '设备已连接过,本次运行保持开启;重启后重新计时'
333
+ : '关闭时不会接受移动设备连接,重启后保持关闭')),
334
+ React.createElement('span', { style: { position: 'relative', display: 'inline-flex', flexShrink: 0 } },
335
+ React.createElement('select', {
336
+ style: { ...styles.input, width: 'auto', marginBottom: 0, padding: '8px 40px 8px 12px', appearance: 'none', WebkitAppearance: 'none' },
337
+ 'aria-label': '网关运行模式',
338
+ value: status ? status.gatewayMode || (status.gatewayEnabled ? 'temporary' : 'disabled') : 'disabled',
339
+ disabled: busy || !status,
340
+ onChange: (event) => changeGatewayMode(event.target.value),
341
+ },
342
+ React.createElement('option', { value: 'disabled' }, '关闭'),
343
+ React.createElement('option', { value: 'temporary' }, '临时开启'),
344
+ React.createElement('option', { value: 'persistent' }, '常驻开启')),
345
+ React.createElement('span', {
346
+ 'aria-hidden': true,
347
+ style: {
348
+ position: 'absolute', right: 15, top: '50%', width: 7, height: 7,
349
+ borderRight: `1.5px solid ${colors.text}`, borderBottom: `1.5px solid ${colors.text}`,
350
+ transform: 'translateY(-65%) rotate(45deg)', pointerEvents: 'none',
351
+ },
352
+ }))),
353
+ status && status.gatewayId ? React.createElement('div', { style: { ...styles.muted, marginTop: 12, overflowWrap: 'anywhere' } },
354
+ `${status.gatewayName} · ${status.gatewayId}`) : null),
355
+ React.createElement('section', { style: styles.card },
356
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
357
+ React.createElement('div', null,
358
+ React.createElement('strong', null, '设备鉴权'),
359
+ React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.requireAuth ? '已开启,仅允许可信设备连接' : '已关闭,仅影响本机 Debug 入口')),
360
+ React.createElement('label', { style: { ...styles.switch, opacity: busy || !status ? .55 : 1 }, title: status && status.requireAuth ? '关闭设备鉴权' : '开启设备鉴权' },
361
+ React.createElement('input', {
362
+ type: 'checkbox',
363
+ role: 'switch',
364
+ 'aria-label': '设备鉴权',
365
+ 'aria-checked': !!(status && status.requireAuth),
366
+ checked: !!(status && status.requireAuth),
367
+ disabled: busy || !status,
368
+ onChange: toggleAuth,
369
+ style: styles.switchInput,
370
+ }),
371
+ React.createElement('span', { style: { ...styles.switchTrack, background: status && status.requireAuth ? colors.success : colors.danger } }),
372
+ React.createElement('span', { style: { ...styles.switchKnob, left: status && status.requireAuth ? 21 : 3 } }))),
373
+ status && !status.requireAuth
374
+ ? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:本机 DSH 入口将跳过设备凭证校验;独立局域网入口仍强制鉴权。')
375
+ : null),
376
+ React.createElement('section', { style: styles.card },
377
+ React.createElement('strong', null, '公网接入'),
378
+ publicSetup === null
379
+ ? React.createElement('p', { style: styles.muted }, '正在检查系统组件…')
380
+ : publicSetup.installed
381
+ ? React.createElement(React.Fragment, null,
382
+ React.createElement('div', { style: { ...styles.muted, margin: '5px 0 12px' } }, publicSetup.configured
383
+ ? publicSetup.backendPort && status && status.webPort && publicSetup.backendPort !== status.webPort
384
+ ? `端口需要更新:Nginx 当前为 ${publicSetup.backendPort},DSH 当前为 ${status.webPort}`
385
+ : `已配置${publicSetup.backendPort ? `,Nginx 转发至 DSH 端口 ${publicSetup.backendPort}` : ''}`
386
+ : `Helper 已就绪,将自动使用当前 DSH 端口${status && status.webPort ? ` ${status.webPort}` : ''}`),
387
+ React.createElement('label', { style: styles.label }, '服务器公网 IPv4'),
388
+ React.createElement('input', {
389
+ style: styles.input,
390
+ value: publicIp,
391
+ onChange: (event) => setPublicIp(event.target.value.trim()),
392
+ placeholder: '203.0.113.10',
393
+ inputMode: 'decimal',
394
+ spellCheck: false,
395
+ }),
396
+ React.createElement('button', {
397
+ style: { ...styles.button, ...styles.primary, opacity: publicSetupBusy || !publicIp ? .65 : 1 },
398
+ disabled: publicSetupBusy || !publicIp,
399
+ onClick: configurePublicAccess,
400
+ }, publicSetupBusy ? '正在配置 Nginx 与证书…' : publicSetup.configured ? '更新公网配置' : '配置公网接入'),
401
+ publicSetup.publicUrl
402
+ ? React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere' } }, publicSetup.publicUrl)
403
+ : null)
404
+ : React.createElement(React.Fragment, null,
405
+ 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'))),
407
+ React.createElement('section', { style: styles.card },
408
+ 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 }),
410
+ status && status.lan && status.lan.enabled
411
+ ? React.createElement('div', { style: { ...styles.muted, margin: '-7px 0 10px', color: status.lan.error ? colors.danger : colors.muted } },
412
+ status.lan.error
413
+ ? `局域网监听失败:${status.lan.error}`
414
+ : status.lan.listening
415
+ ? `局域网入口已开启${status.lan.urls && status.lan.urls.length ? `:${status.lan.urls.join('、')}` : `,端口 ${status.lan.port}`}`
416
+ : '局域网入口启动中…')
417
+ : null,
418
+ React.createElement('label', { style: styles.label }, '设备名称'),
419
+ 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 分钟内过期。')),
422
+ qr ? React.createElement('section', { style: { ...styles.card, textAlign: 'center' } },
423
+ React.createElement('strong', null, '使用 iOS 客户端扫码'),
424
+ React.createElement('img', { style: styles.qr, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.svg)}`, alt: '一次性设备配对二维码' }),
425
+ React.createElement('label', { style: { ...styles.label, textAlign: 'left' } }, 'Base64URL 配对字符串'),
426
+ React.createElement('textarea', { style: styles.pairingText, value: qr.qrPayload, readOnly: true, spellCheck: false, onFocus: (event) => event.target.select() }),
427
+ 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'),
428
+ pairingTokenCopied ? React.createElement('div', { style: { ...styles.muted, color: colors.success, marginBottom: 8 } }, '已复制到剪贴板') : null,
429
+ React.createElement('div', { style: styles.muted }, `有效期至 ${new Date(qr.pairing.expiresAt).toLocaleTimeString()};扫码成功后此二维码立即失效。`)) : null,
430
+ React.createElement('section', { style: styles.card },
431
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
432
+ React.createElement('strong', null, '可信设备'),
433
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
434
+ deviceNotice || refreshNotice ? React.createElement('span', {
435
+ style: { ...styles.muted, color: deviceNoticeError ? colors.danger : colors.success },
436
+ }, deviceNotice || refreshNotice) : null,
437
+ React.createElement('button', {
438
+ style: { ...styles.button, opacity: refreshing ? .6 : 1 },
439
+ disabled: refreshing,
440
+ onClick: manualRefresh,
441
+ }, refreshing ? '刷新中…' : '刷新'))),
442
+ devices === null
443
+ ? React.createElement('p', { style: styles.muted }, '加载中…')
444
+ : devices.length === 0
445
+ ? React.createElement('p', { style: styles.muted }, '暂无已配对设备。')
446
+ : devices.map((device) => React.createElement(DeviceRow, {
447
+ key: device.id,
448
+ device,
449
+ onRevoke: revoke,
450
+ revoking: revokingIds.has(device.id),
451
+ }))),
452
+ status && status.version
453
+ ? React.createElement('div', { style: styles.version, 'aria-label': `插件版本 ${status.version}` }, `v${status.version}`)
454
+ : null))
455
+ }
456
+
457
+ function FooterButton(props) {
458
+ const isOpen = React.useSyncExternalStore(subscribe, getOpen)
459
+ const wide = !!props.wide
460
+ const iconSize = wide ? 16 : 18
461
+ const icon = React.createElement('svg', {
462
+ width: iconSize,
463
+ height: iconSize,
464
+ viewBox: '0 0 24 24',
465
+ fill: 'none',
466
+ stroke: 'currentColor',
467
+ strokeWidth: 1.8,
468
+ strokeLinecap: 'round',
469
+ strokeLinejoin: 'round',
470
+ 'aria-hidden': true,
471
+ },
472
+ React.createElement('rect', { x: 6, y: 2, width: 12, height: 20, rx: 2.5 }),
473
+ React.createElement('path', { d: 'M10 18h4' }))
474
+ return React.createElement('button', {
475
+ title: '移动设备管理',
476
+ 'aria-label': '移动设备管理',
477
+ 'aria-pressed': isOpen,
478
+ onClick: () => setOpen(!isOpen),
479
+ style: {
480
+ cursor: 'pointer',
481
+ background: 'transparent',
482
+ border: 'none',
483
+ color: 'inherit',
484
+ font: 'inherit',
485
+ display: 'flex',
486
+ alignItems: 'center',
487
+ justifyContent: wide ? 'flex-start' : 'center',
488
+ gap: wide ? 8 : 0,
489
+ boxSizing: 'border-box',
490
+ width: wide ? 'calc(100% + 4px)' : 36,
491
+ height: wide ? 42 : 36,
492
+ margin: wide ? '4px -2px' : '3px 0',
493
+ padding: wide ? '0 10px 0 8px' : 0,
494
+ lineHeight: wide ? '22px' : 'normal',
495
+ },
496
+ }, icon, wide ? React.createElement('span', null, '移动设备') : null)
497
+ }
498
+
499
+ function OverlayEntry() {
500
+ const isOpen = React.useSyncExternalStore(subscribe, getOpen)
501
+ return isOpen ? React.createElement(DevicePanel) : null
502
+ }
503
+
504
+ const inject = ['slots']
505
+ function apply(ctx) {
506
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
507
+ { name: 'sidebar.footer.action', id: 'mobile-gateway-devices', order: 80, label: '移动设备' },
508
+ (props) => React.createElement(FooterButton, props),
509
+ ))
510
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register(
511
+ { name: 'shell.overlay', id: 'mobile-gateway-devices-panel', order: 100, label: '移动设备' },
512
+ () => React.createElement(OverlayEntry),
513
+ ))
514
+ }
515
+
516
+ exports.apply = apply
517
+ exports.inject = inject
518
+ return module.exports
519
+ },
520
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-plugin-mobile-gateway",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Mobile gateway for DSH 0.1.7 (Session format 4), carrying the fork's format adaptation: v3 rows are lifted on read so a Session written by an earlier release still opens. Per-session Agent preset selection, live preset updates, and the upstream LAN/pairing protocol are unchanged.",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
@@ -46,5 +46,8 @@
46
46
  ]
47
47
  }
48
48
  },
49
- "license": "MIT"
49
+ "license": "MIT",
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
50
53
  }