dsh-plugin-mobile-gateway 0.1.17 → 0.3.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 +109 -6
- package/README.md +180 -30
- package/cordis.patch.yml +8 -0
- package/docs/assets/mobile-device-management.png +0 -0
- package/lib/client.js +423 -0
- package/lib/devices.js +209 -0
- package/lib/{index.js → index.mjs} +412 -18
- package/package.json +28 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
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
|
+
background: colors.panel, color: colors.text, borderLeft: `1px solid ${colors.border}`,
|
|
41
|
+
boxShadow: '-12px 0 40px rgba(0,0,0,.22)', padding: 20,
|
|
42
|
+
fontFamily: 'system-ui, sans-serif', fontSize: 13, boxSizing: 'border-box',
|
|
43
|
+
},
|
|
44
|
+
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 },
|
|
45
|
+
title: { margin: 0, fontSize: 18, fontWeight: 650 },
|
|
46
|
+
card: { background: colors.card, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 14, marginBottom: 12 },
|
|
47
|
+
label: { display: 'block', color: colors.muted, fontSize: 12, marginBottom: 6 },
|
|
48
|
+
input: { width: '100%', boxSizing: 'border-box', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 10px', marginBottom: 10, background: 'transparent', color: 'inherit', outline: 'none' },
|
|
49
|
+
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' },
|
|
50
|
+
button: { cursor: 'pointer', border: `1px solid ${colors.border}`, background: 'transparent', borderRadius: 8, padding: '7px 11px', color: 'inherit', font: 'inherit' },
|
|
51
|
+
primary: { background: colors.accent, color: '#fff', borderColor: colors.accent, width: '100%' },
|
|
52
|
+
muted: { color: colors.muted, fontSize: 12, lineHeight: 1.55 },
|
|
53
|
+
row: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 12, alignItems: 'center', padding: '12px 0', borderBottom: `1px solid ${colors.border}` },
|
|
54
|
+
badge: { display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, marginLeft: 8, color: colors.muted },
|
|
55
|
+
dot: { width: 7, height: 7, borderRadius: 99, display: 'inline-block' },
|
|
56
|
+
error: { color: '#ff8092', background: 'rgba(220,76,100,.12)', borderRadius: 8, padding: 10, marginBottom: 12 },
|
|
57
|
+
qr: { display: 'block', width: 220, height: 220, margin: '12px auto', background: '#fff', borderRadius: 10, padding: 8 },
|
|
58
|
+
switch: { position: 'relative', display: 'inline-flex', width: 42, height: 24, flexShrink: 0 },
|
|
59
|
+
switchInput: { position: 'absolute', opacity: 0, width: 1, height: 1 },
|
|
60
|
+
switchTrack: { position: 'absolute', inset: 0, borderRadius: 99, transition: 'background .18s ease', cursor: 'pointer' },
|
|
61
|
+
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' },
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function request(path, options) {
|
|
65
|
+
const response = await fetch(path, { credentials: 'same-origin', ...options })
|
|
66
|
+
const body = await response.json().catch(() => ({}))
|
|
67
|
+
if (!response.ok) throw new Error(body.message || body.error || `HTTP ${response.status}`)
|
|
68
|
+
return body
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function inferredUrl(wsPath) {
|
|
72
|
+
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
73
|
+
return `${scheme}//${window.location.host}${wsPath || '/ws/mobile'}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function DeviceRow({ device, onRevoke, revoking }) {
|
|
77
|
+
const triggerRevoke = (event) => {
|
|
78
|
+
event.preventDefault()
|
|
79
|
+
event.stopPropagation()
|
|
80
|
+
if (!revoking) onRevoke(device)
|
|
81
|
+
}
|
|
82
|
+
return React.createElement('div', { style: styles.row },
|
|
83
|
+
React.createElement('div', { style: { minWidth: 0, overflow: 'hidden' } },
|
|
84
|
+
React.createElement('div', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
85
|
+
device.name,
|
|
86
|
+
React.createElement('span', { style: styles.badge },
|
|
87
|
+
React.createElement('span', { style: { ...styles.dot, background: device.online ? colors.success : colors.muted } }),
|
|
88
|
+
device.online ? `在线${device.connections > 1 ? ` · ${device.connections} 个连接` : ''}` : '离线')),
|
|
89
|
+
React.createElement('div', { style: { ...styles.muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginTop: 3 } },
|
|
90
|
+
device.lastSeenAt ? `最近连接:${new Date(device.lastSeenAt).toLocaleString()}` : '尚未连接')),
|
|
91
|
+
React.createElement('button', {
|
|
92
|
+
type: 'button',
|
|
93
|
+
disabled: revoking,
|
|
94
|
+
style: {
|
|
95
|
+
...styles.button,
|
|
96
|
+
color: colors.danger,
|
|
97
|
+
background: 'transparent',
|
|
98
|
+
borderColor: 'rgba(220,76,100,.55)',
|
|
99
|
+
flexShrink: 0,
|
|
100
|
+
minWidth: 54,
|
|
101
|
+
opacity: revoking ? .6 : 1,
|
|
102
|
+
},
|
|
103
|
+
title: `吊销 ${device.name}`,
|
|
104
|
+
// Pointer-up is more reliable than click inside the shell overlay on
|
|
105
|
+
// touch devices. The click handler remains for keyboard activation.
|
|
106
|
+
onPointerUp: triggerRevoke,
|
|
107
|
+
onClick: (event) => {
|
|
108
|
+
if (event.detail === 0) triggerRevoke(event)
|
|
109
|
+
},
|
|
110
|
+
}, revoking ? '吊销中…' : '吊销'))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function DevicePanel() {
|
|
114
|
+
const [devices, setDevices] = React.useState(null)
|
|
115
|
+
const [status, setStatus] = React.useState(null)
|
|
116
|
+
const [publicUrl, setPublicUrl] = React.useState('')
|
|
117
|
+
const [deviceName, setDeviceName] = React.useState('iPhone')
|
|
118
|
+
const [qr, setQr] = React.useState(null)
|
|
119
|
+
const [error, setError] = React.useState(null)
|
|
120
|
+
const [busy, setBusy] = React.useState(false)
|
|
121
|
+
const [refreshing, setRefreshing] = React.useState(false)
|
|
122
|
+
const [refreshNotice, setRefreshNotice] = React.useState(null)
|
|
123
|
+
const [pairingTokenCopied, setPairingTokenCopied] = React.useState(false)
|
|
124
|
+
const [deviceNotice, setDeviceNotice] = React.useState(null)
|
|
125
|
+
const [deviceNoticeError, setDeviceNoticeError] = React.useState(false)
|
|
126
|
+
const [revokingIds, setRevokingIds] = React.useState(() => new Set())
|
|
127
|
+
const revokingIdsRef = React.useRef(new Set())
|
|
128
|
+
|
|
129
|
+
const refresh = React.useCallback(async () => {
|
|
130
|
+
try {
|
|
131
|
+
const [deviceData, statusData] = await Promise.all([
|
|
132
|
+
request('/mgw/devices'),
|
|
133
|
+
request('/mgw/status'),
|
|
134
|
+
])
|
|
135
|
+
const nextDevices = deviceData.devices || []
|
|
136
|
+
// Keep existing DOM rows alive when polling returns identical data.
|
|
137
|
+
// Replacing them every three seconds can swallow a pointer/click that
|
|
138
|
+
// started on the old button and ended after React replaced the row.
|
|
139
|
+
if (revokingIdsRef.current.size === 0) {
|
|
140
|
+
setDevices((current) => JSON.stringify(current) === JSON.stringify(nextDevices) ? current : nextDevices)
|
|
141
|
+
}
|
|
142
|
+
setStatus((current) => JSON.stringify(current) === JSON.stringify(statusData) ? current : statusData)
|
|
143
|
+
setPublicUrl((current) => current || statusData.publicUrl || inferredUrl(statusData.wsPath))
|
|
144
|
+
return true
|
|
145
|
+
} catch (cause) {
|
|
146
|
+
setError(cause.message)
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
}, [])
|
|
150
|
+
|
|
151
|
+
const manualRefresh = async () => {
|
|
152
|
+
if (refreshing) return
|
|
153
|
+
setRefreshing(true)
|
|
154
|
+
setRefreshNotice(null)
|
|
155
|
+
const succeeded = await refresh()
|
|
156
|
+
setRefreshing(false)
|
|
157
|
+
if (succeeded) {
|
|
158
|
+
setError(null)
|
|
159
|
+
setRefreshNotice(`已刷新 · ${new Date().toLocaleTimeString()}`)
|
|
160
|
+
window.setTimeout(() => setRefreshNotice(null), 2200)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
React.useEffect(() => {
|
|
165
|
+
let active = true
|
|
166
|
+
refresh()
|
|
167
|
+
const timer = window.setInterval(refresh, 3000)
|
|
168
|
+
return () => { active = false; window.clearInterval(timer) }
|
|
169
|
+
}, [refresh])
|
|
170
|
+
|
|
171
|
+
const pair = async () => {
|
|
172
|
+
setBusy(true)
|
|
173
|
+
setError(null)
|
|
174
|
+
setPairingTokenCopied(false)
|
|
175
|
+
try {
|
|
176
|
+
const data = await request('/mgw/pair', {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: { 'Content-Type': 'application/json' },
|
|
179
|
+
body: JSON.stringify({ name: deviceName, publicUrl }),
|
|
180
|
+
})
|
|
181
|
+
setQr(data)
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
setError(cause.message)
|
|
184
|
+
} finally {
|
|
185
|
+
setBusy(false)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const copyPairingText = async () => {
|
|
190
|
+
if (!qr || !qr.qrPayload) return
|
|
191
|
+
try {
|
|
192
|
+
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
|
|
193
|
+
throw new Error('当前浏览器不允许直接写入剪贴板,请选中文本后手动复制')
|
|
194
|
+
}
|
|
195
|
+
await navigator.clipboard.writeText(qr.qrPayload)
|
|
196
|
+
setError(null)
|
|
197
|
+
setPairingTokenCopied(true)
|
|
198
|
+
window.setTimeout(() => setPairingTokenCopied(false), 2200)
|
|
199
|
+
} catch (cause) {
|
|
200
|
+
setPairingTokenCopied(false)
|
|
201
|
+
setError(cause.message)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const toggleGateway = async () => {
|
|
206
|
+
const enabled = !(status && status.gatewayEnabled)
|
|
207
|
+
setBusy(true)
|
|
208
|
+
setError(null)
|
|
209
|
+
try {
|
|
210
|
+
const data = await request('/mgw/gateway', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: { 'Content-Type': 'application/json' },
|
|
213
|
+
body: JSON.stringify({ enabled }),
|
|
214
|
+
})
|
|
215
|
+
setStatus((current) => ({ ...(current || {}), ...data }))
|
|
216
|
+
if (!enabled) setQr(null)
|
|
217
|
+
} catch (cause) {
|
|
218
|
+
setError(cause.message)
|
|
219
|
+
} finally {
|
|
220
|
+
setBusy(false)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const toggleAuth = async () => {
|
|
225
|
+
const enabled = !(status && status.requireAuth)
|
|
226
|
+
if (!enabled && !window.confirm('关闭设备鉴权后,任何能访问移动网关的人都可以控制 DSH。此选项仅限 Debug 阶段使用,确定继续吗?')) return
|
|
227
|
+
setBusy(true)
|
|
228
|
+
setError(null)
|
|
229
|
+
try {
|
|
230
|
+
const data = await request('/mgw/auth', {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { 'Content-Type': 'application/json' },
|
|
233
|
+
body: JSON.stringify({ enabled }),
|
|
234
|
+
})
|
|
235
|
+
setStatus((current) => ({ ...(current || {}), requireAuth: data.requireAuth }))
|
|
236
|
+
} catch (cause) {
|
|
237
|
+
setError(cause.message)
|
|
238
|
+
} finally {
|
|
239
|
+
setBusy(false)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const revoke = async (device) => {
|
|
244
|
+
if (revokingIdsRef.current.has(device.id)) return
|
|
245
|
+
setDeviceNotice(null)
|
|
246
|
+
setDeviceNoticeError(false)
|
|
247
|
+
setError(null)
|
|
248
|
+
revokingIdsRef.current.add(device.id)
|
|
249
|
+
setRevokingIds((current) => new Set(current).add(device.id))
|
|
250
|
+
try {
|
|
251
|
+
await request(`/mgw/devices/${encodeURIComponent(device.id)}/revoke`, { method: 'POST' })
|
|
252
|
+
setDevices((current) => Array.isArray(current) ? current.filter((item) => item.id !== device.id) : current)
|
|
253
|
+
setDeviceNotice(`已吊销 ${device.name}`)
|
|
254
|
+
window.setTimeout(() => setDeviceNotice(null), 2200)
|
|
255
|
+
} catch (cause) {
|
|
256
|
+
setDeviceNoticeError(true)
|
|
257
|
+
setDeviceNotice(`吊销失败:${cause.message}`)
|
|
258
|
+
setError(cause.message)
|
|
259
|
+
} finally {
|
|
260
|
+
revokingIdsRef.current.delete(device.id)
|
|
261
|
+
setRevokingIds((current) => {
|
|
262
|
+
const next = new Set(current)
|
|
263
|
+
next.delete(device.id)
|
|
264
|
+
return next
|
|
265
|
+
})
|
|
266
|
+
await refresh()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return React.createElement('div', { style: styles.backdrop, onMouseDown: () => setOpen(false) },
|
|
271
|
+
React.createElement('aside', { style: styles.panel, onMouseDown: (event) => event.stopPropagation(), role: 'dialog', 'aria-modal': true, 'aria-label': '移动设备管理' },
|
|
272
|
+
React.createElement('div', { style: styles.header },
|
|
273
|
+
React.createElement('div', null,
|
|
274
|
+
React.createElement('h2', { style: styles.title }, '移动设备'),
|
|
275
|
+
React.createElement('div', { style: styles.muted }, status && status.gatewayEnabled ? '移动网关已开启 · 设备鉴权生效中' : '移动网关已关闭 · 普通 WebUI 模式')),
|
|
276
|
+
React.createElement('button', { style: styles.button, onClick: () => setOpen(false), 'aria-label': '关闭' }, '✕')),
|
|
277
|
+
error ? React.createElement('div', { style: styles.error }, error) : null,
|
|
278
|
+
React.createElement('section', { style: styles.card },
|
|
279
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
|
|
280
|
+
React.createElement('div', null,
|
|
281
|
+
React.createElement('strong', null, '允许移动设备连接'),
|
|
282
|
+
React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.gatewayEnabled
|
|
283
|
+
? status.connectedClients > 0
|
|
284
|
+
? `${status.connectedClients} 个连接,网关保持开启`
|
|
285
|
+
: `等待可信设备连接${status.waitExpiresAt ? `,约 ${Math.max(1, Math.ceil((status.waitExpiresAt - Date.now()) / 60000))} 分钟后自动关闭` : ''}`
|
|
286
|
+
: '关闭时不会接受移动设备连接')),
|
|
287
|
+
React.createElement('label', { style: { ...styles.switch, opacity: busy || !status ? .55 : 1 }, title: status && status.gatewayEnabled ? '关闭移动网关' : '开启移动网关' },
|
|
288
|
+
React.createElement('input', {
|
|
289
|
+
type: 'checkbox',
|
|
290
|
+
role: 'switch',
|
|
291
|
+
'aria-label': '允许移动设备连接',
|
|
292
|
+
'aria-checked': !!(status && status.gatewayEnabled),
|
|
293
|
+
checked: !!(status && status.gatewayEnabled),
|
|
294
|
+
disabled: busy || !status,
|
|
295
|
+
onChange: toggleGateway,
|
|
296
|
+
style: styles.switchInput,
|
|
297
|
+
}),
|
|
298
|
+
React.createElement('span', { style: { ...styles.switchTrack, background: status && status.gatewayEnabled ? colors.success : colors.muted } }),
|
|
299
|
+
React.createElement('span', { style: { ...styles.switchKnob, left: status && status.gatewayEnabled ? 21 : 3 } })))),
|
|
300
|
+
React.createElement('section', { style: styles.card },
|
|
301
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
|
|
302
|
+
React.createElement('div', null,
|
|
303
|
+
React.createElement('strong', null, '设备鉴权'),
|
|
304
|
+
React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.requireAuth ? '已开启,仅允许可信设备连接' : '已关闭,仅限 Debug 阶段使用')),
|
|
305
|
+
React.createElement('label', { style: { ...styles.switch, opacity: busy || !status ? .55 : 1 }, title: status && status.requireAuth ? '关闭设备鉴权' : '开启设备鉴权' },
|
|
306
|
+
React.createElement('input', {
|
|
307
|
+
type: 'checkbox',
|
|
308
|
+
role: 'switch',
|
|
309
|
+
'aria-label': '设备鉴权',
|
|
310
|
+
'aria-checked': !!(status && status.requireAuth),
|
|
311
|
+
checked: !!(status && status.requireAuth),
|
|
312
|
+
disabled: busy || !status,
|
|
313
|
+
onChange: toggleAuth,
|
|
314
|
+
style: styles.switchInput,
|
|
315
|
+
}),
|
|
316
|
+
React.createElement('span', { style: { ...styles.switchTrack, background: status && status.requireAuth ? colors.success : colors.danger } }),
|
|
317
|
+
React.createElement('span', { style: { ...styles.switchKnob, left: status && status.requireAuth ? 21 : 3 } }))),
|
|
318
|
+
status && !status.requireAuth
|
|
319
|
+
? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:移动网关连接将跳过设备凭证校验,请勿暴露到公网。')
|
|
320
|
+
: null),
|
|
321
|
+
React.createElement('section', { style: styles.card },
|
|
322
|
+
React.createElement('label', { style: styles.label }, '公网 WebSocket 地址'),
|
|
323
|
+
React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => setPublicUrl(event.target.value), placeholder: 'wss://gateway.example.com/ws/mobile', spellCheck: false }),
|
|
324
|
+
React.createElement('label', { style: styles.label }, '设备名称'),
|
|
325
|
+
React.createElement('input', { style: styles.input, value: deviceName, onChange: (event) => setDeviceName(event.target.value), maxLength: 80, placeholder: 'iPhone' }),
|
|
326
|
+
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 ? '生成配对二维码' : '请先开启移动网关'),
|
|
327
|
+
React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '公网地址必须使用 wss://。二维码中的配对码只能使用一次,并在 5 分钟内过期。')),
|
|
328
|
+
qr ? React.createElement('section', { style: { ...styles.card, textAlign: 'center' } },
|
|
329
|
+
React.createElement('strong', null, '使用 iOS 客户端扫码'),
|
|
330
|
+
React.createElement('img', { style: styles.qr, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.svg)}`, alt: '一次性设备配对二维码' }),
|
|
331
|
+
React.createElement('label', { style: { ...styles.label, textAlign: 'left' } }, 'Base64URL 配对字符串'),
|
|
332
|
+
React.createElement('textarea', { style: styles.pairingText, value: qr.qrPayload, readOnly: true, spellCheck: false, onFocus: (event) => event.target.select() }),
|
|
333
|
+
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'),
|
|
334
|
+
pairingTokenCopied ? React.createElement('div', { style: { ...styles.muted, color: colors.success, marginBottom: 8 } }, '已复制到剪贴板') : null,
|
|
335
|
+
React.createElement('div', { style: styles.muted }, `有效期至 ${new Date(qr.pairing.expiresAt).toLocaleTimeString()};扫码成功后此二维码立即失效。`)) : null,
|
|
336
|
+
React.createElement('section', { style: styles.card },
|
|
337
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
338
|
+
React.createElement('strong', null, '可信设备'),
|
|
339
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
|
|
340
|
+
deviceNotice || refreshNotice ? React.createElement('span', {
|
|
341
|
+
style: { ...styles.muted, color: deviceNoticeError ? colors.danger : colors.success },
|
|
342
|
+
}, deviceNotice || refreshNotice) : null,
|
|
343
|
+
React.createElement('button', {
|
|
344
|
+
style: { ...styles.button, opacity: refreshing ? .6 : 1 },
|
|
345
|
+
disabled: refreshing,
|
|
346
|
+
onClick: manualRefresh,
|
|
347
|
+
}, refreshing ? '刷新中…' : '刷新'))),
|
|
348
|
+
devices === null
|
|
349
|
+
? React.createElement('p', { style: styles.muted }, '加载中…')
|
|
350
|
+
: devices.length === 0
|
|
351
|
+
? React.createElement('p', { style: styles.muted }, '暂无已配对设备。')
|
|
352
|
+
: devices.map((device) => React.createElement(DeviceRow, {
|
|
353
|
+
key: device.id,
|
|
354
|
+
device,
|
|
355
|
+
onRevoke: revoke,
|
|
356
|
+
revoking: revokingIds.has(device.id),
|
|
357
|
+
})))))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function FooterButton(props) {
|
|
361
|
+
const isOpen = React.useSyncExternalStore(subscribe, getOpen)
|
|
362
|
+
const wide = !!props.wide
|
|
363
|
+
const iconSize = wide ? 16 : 18
|
|
364
|
+
const icon = React.createElement('svg', {
|
|
365
|
+
width: iconSize,
|
|
366
|
+
height: iconSize,
|
|
367
|
+
viewBox: '0 0 24 24',
|
|
368
|
+
fill: 'none',
|
|
369
|
+
stroke: 'currentColor',
|
|
370
|
+
strokeWidth: 1.8,
|
|
371
|
+
strokeLinecap: 'round',
|
|
372
|
+
strokeLinejoin: 'round',
|
|
373
|
+
'aria-hidden': true,
|
|
374
|
+
},
|
|
375
|
+
React.createElement('rect', { x: 6, y: 2, width: 12, height: 20, rx: 2.5 }),
|
|
376
|
+
React.createElement('path', { d: 'M10 18h4' }))
|
|
377
|
+
return React.createElement('button', {
|
|
378
|
+
title: '移动设备管理',
|
|
379
|
+
'aria-label': '移动设备管理',
|
|
380
|
+
'aria-pressed': isOpen,
|
|
381
|
+
onClick: () => setOpen(!isOpen),
|
|
382
|
+
style: {
|
|
383
|
+
cursor: 'pointer',
|
|
384
|
+
background: 'transparent',
|
|
385
|
+
border: 'none',
|
|
386
|
+
color: 'inherit',
|
|
387
|
+
font: 'inherit',
|
|
388
|
+
display: 'flex',
|
|
389
|
+
alignItems: 'center',
|
|
390
|
+
justifyContent: wide ? 'flex-start' : 'center',
|
|
391
|
+
gap: wide ? 8 : 0,
|
|
392
|
+
boxSizing: 'border-box',
|
|
393
|
+
width: wide ? 'calc(100% + 4px)' : 36,
|
|
394
|
+
height: wide ? 42 : 36,
|
|
395
|
+
margin: wide ? '4px -2px' : '3px 0',
|
|
396
|
+
padding: wide ? '0 10px 0 8px' : 0,
|
|
397
|
+
lineHeight: wide ? '22px' : 'normal',
|
|
398
|
+
},
|
|
399
|
+
}, icon, wide ? React.createElement('span', null, '移动设备') : null)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function OverlayEntry() {
|
|
403
|
+
const isOpen = React.useSyncExternalStore(subscribe, getOpen)
|
|
404
|
+
return isOpen ? React.createElement(DevicePanel) : null
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const inject = ['slots']
|
|
408
|
+
function apply(ctx) {
|
|
409
|
+
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
|
|
410
|
+
{ name: 'sidebar.footer.action', id: 'mobile-gateway-devices', order: 80, label: '移动设备' },
|
|
411
|
+
(props) => React.createElement(FooterButton, props),
|
|
412
|
+
))
|
|
413
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register(
|
|
414
|
+
{ name: 'shell.overlay', id: 'mobile-gateway-devices-panel', order: 100, label: '移动设备' },
|
|
415
|
+
() => React.createElement(OverlayEntry),
|
|
416
|
+
))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
exports.apply = apply
|
|
420
|
+
exports.inject = inject
|
|
421
|
+
return module.exports
|
|
422
|
+
},
|
|
423
|
+
})
|
package/lib/devices.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Persistent paired-device registry for mobile-gateway authentication.
|
|
2
|
+
//
|
|
3
|
+
// Long-lived device tokens are generated with 256 bits of entropy and are
|
|
4
|
+
// never written to disk. Only their SHA-256 digests are persisted. Pairing
|
|
5
|
+
// codes are one-time, short-lived, and memory-only, so a restart invalidates
|
|
6
|
+
// every outstanding QR code without affecting already paired devices.
|
|
7
|
+
'use strict'
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs')
|
|
10
|
+
const crypto = require('node:crypto')
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
|
|
13
|
+
const STORE_VERSION = 3
|
|
14
|
+
const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
function digest(secret) {
|
|
17
|
+
return crypto.createHash('sha256').update(secret, 'utf8').digest('hex')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeEqualHex(left, right) {
|
|
21
|
+
if (typeof left !== 'string' || typeof right !== 'string') return false
|
|
22
|
+
const a = Buffer.from(left, 'hex')
|
|
23
|
+
const b = Buffer.from(right, 'hex')
|
|
24
|
+
return a.length === 32 && b.length === 32 && crypto.timingSafeEqual(a, b)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function publicDevice(device, connections) {
|
|
28
|
+
return {
|
|
29
|
+
id: device.id,
|
|
30
|
+
name: device.name,
|
|
31
|
+
createdAt: device.createdAt,
|
|
32
|
+
lastSeenAt: device.lastSeenAt || null,
|
|
33
|
+
online: connections > 0,
|
|
34
|
+
connections,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeClientDeviceId(value) {
|
|
39
|
+
if (typeof value !== 'string') return undefined
|
|
40
|
+
const normalized = value.trim()
|
|
41
|
+
return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createRegistry(file, options = {}) {
|
|
45
|
+
const pairingTtlMs = Number.isSafeInteger(options.pairingTtlMs) && options.pairingTtlMs > 0
|
|
46
|
+
? options.pairingTtlMs
|
|
47
|
+
: DEFAULT_PAIRING_TTL_MS
|
|
48
|
+
let devices = []
|
|
49
|
+
const pairings = new Map()
|
|
50
|
+
const online = new Map()
|
|
51
|
+
|
|
52
|
+
const save = () => {
|
|
53
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
54
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
55
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: STORE_VERSION, devices }, null, 2), { mode: 0o600 })
|
|
56
|
+
fs.chmodSync(tmp, 0o600)
|
|
57
|
+
fs.renameSync(tmp, file)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const load = () => {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
63
|
+
if (!Array.isArray(parsed.devices)) return
|
|
64
|
+
let migrated = false
|
|
65
|
+
devices = parsed.devices.flatMap((row) => {
|
|
66
|
+
if (!row || typeof row.id !== 'string' || typeof row.name !== 'string') return []
|
|
67
|
+
let tokenHash = typeof row.tokenHash === 'string' ? row.tokenHash : undefined
|
|
68
|
+
// Migrate the v1 development format without invalidating an existing
|
|
69
|
+
// device. The plaintext is removed on the next atomic save.
|
|
70
|
+
if (!tokenHash && typeof row.token === 'string' && row.token !== '') {
|
|
71
|
+
tokenHash = digest(row.token)
|
|
72
|
+
migrated = true
|
|
73
|
+
}
|
|
74
|
+
if (!tokenHash || !/^[a-f0-9]{64}$/.test(tokenHash)) return []
|
|
75
|
+
return [{
|
|
76
|
+
id: row.id,
|
|
77
|
+
name: row.name.slice(0, 80),
|
|
78
|
+
clientDeviceId: normalizeClientDeviceId(row.clientDeviceId),
|
|
79
|
+
tokenHash,
|
|
80
|
+
createdAt: Number.isFinite(row.createdAt) ? row.createdAt : Date.now(),
|
|
81
|
+
lastSeenAt: Number.isFinite(row.lastSeenAt) ? row.lastSeenAt : null,
|
|
82
|
+
revokedAt: Number.isFinite(row.revokedAt) ? row.revokedAt : (row.revoked ? Date.now() : null),
|
|
83
|
+
}]
|
|
84
|
+
})
|
|
85
|
+
if (migrated || parsed.version !== STORE_VERSION) save()
|
|
86
|
+
else fs.chmodSync(file, 0o600)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error && error.code !== 'ENOENT') throw new Error(`failed to load device registry: ${error.message}`, { cause: error })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const prunePairings = (now = Date.now()) => {
|
|
93
|
+
for (const [codeHash, pairing] of pairings) {
|
|
94
|
+
if (pairing.expiresAt <= now) pairings.delete(codeHash)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
load()
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
list() {
|
|
102
|
+
return devices
|
|
103
|
+
.filter((device) => !device.revokedAt)
|
|
104
|
+
.map((device) => publicDevice(device, online.get(device.id) || 0))
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
count() {
|
|
108
|
+
return devices.filter((device) => !device.revokedAt).length
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
createPairing(name) {
|
|
112
|
+
prunePairings()
|
|
113
|
+
const code = crypto.randomBytes(32).toString('base64url')
|
|
114
|
+
const pairing = {
|
|
115
|
+
id: crypto.randomUUID(),
|
|
116
|
+
name: typeof name === 'string' && name.trim() !== '' ? name.trim().slice(0, 80) : '未命名设备',
|
|
117
|
+
expiresAt: Date.now() + pairingTtlMs,
|
|
118
|
+
}
|
|
119
|
+
pairings.set(digest(code), pairing)
|
|
120
|
+
return { ...pairing, code }
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
hasPairing(code) {
|
|
124
|
+
prunePairings()
|
|
125
|
+
return typeof code === 'string' && pairings.has(digest(code))
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
claimPairing(code, clientDeviceId) {
|
|
129
|
+
prunePairings()
|
|
130
|
+
if (typeof code !== 'string' || code === '') return undefined
|
|
131
|
+
const codeHash = digest(code)
|
|
132
|
+
const pairing = pairings.get(codeHash)
|
|
133
|
+
if (!pairing) return undefined
|
|
134
|
+
// Delete before doing any further work: claiming is single-use even if a
|
|
135
|
+
// later socket upgrade fails.
|
|
136
|
+
pairings.delete(codeHash)
|
|
137
|
+
const token = crypto.randomBytes(32).toString('base64url')
|
|
138
|
+
const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
|
|
139
|
+
let device = normalizedClientDeviceId
|
|
140
|
+
? devices.find((candidate) => !candidate.revokedAt && candidate.clientDeviceId === normalizedClientDeviceId)
|
|
141
|
+
: undefined
|
|
142
|
+
if (device) {
|
|
143
|
+
// A valid one-time pairing code authorizes credential rotation. Keep
|
|
144
|
+
// the stable server-side device record while replacing its token, so
|
|
145
|
+
// re-pairing one iOS installation never creates duplicate rows.
|
|
146
|
+
device.name = pairing.name
|
|
147
|
+
device.tokenHash = digest(token)
|
|
148
|
+
device.clientDeviceId = normalizedClientDeviceId
|
|
149
|
+
} else {
|
|
150
|
+
device = {
|
|
151
|
+
id: pairing.id,
|
|
152
|
+
name: pairing.name,
|
|
153
|
+
clientDeviceId: normalizedClientDeviceId,
|
|
154
|
+
tokenHash: digest(token),
|
|
155
|
+
createdAt: Date.now(),
|
|
156
|
+
lastSeenAt: null,
|
|
157
|
+
revokedAt: null,
|
|
158
|
+
}
|
|
159
|
+
devices.push(device)
|
|
160
|
+
}
|
|
161
|
+
save()
|
|
162
|
+
return { device: publicDevice(device, 0), token }
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
authenticate(token, clientDeviceId) {
|
|
166
|
+
if (typeof token !== 'string' || token === '') return undefined
|
|
167
|
+
const tokenHash = digest(token)
|
|
168
|
+
const device = devices.find((candidate) => !candidate.revokedAt && safeEqualHex(candidate.tokenHash, tokenHash))
|
|
169
|
+
const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
|
|
170
|
+
if (device && normalizedClientDeviceId && !device.clientDeviceId) {
|
|
171
|
+
// Migration path for devices paired before installation IDs existed:
|
|
172
|
+
// a valid long-lived token proves ownership of this record, so it is
|
|
173
|
+
// safe to bind the client's stable ID without creating a new device.
|
|
174
|
+
const conflict = devices.some((candidate) => candidate !== device && !candidate.revokedAt && candidate.clientDeviceId === normalizedClientDeviceId)
|
|
175
|
+
if (!conflict) {
|
|
176
|
+
device.clientDeviceId = normalizedClientDeviceId
|
|
177
|
+
save()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return device ? publicDevice(device, online.get(device.id) || 0) : undefined
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
connected(deviceId) {
|
|
184
|
+
const device = devices.find((candidate) => candidate.id === deviceId && !candidate.revokedAt)
|
|
185
|
+
if (!device) return false
|
|
186
|
+
online.set(deviceId, (online.get(deviceId) || 0) + 1)
|
|
187
|
+
device.lastSeenAt = Date.now()
|
|
188
|
+
save()
|
|
189
|
+
return true
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
disconnected(deviceId) {
|
|
193
|
+
const count = online.get(deviceId) || 0
|
|
194
|
+
if (count <= 1) online.delete(deviceId)
|
|
195
|
+
else online.set(deviceId, count - 1)
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
revoke(deviceId) {
|
|
199
|
+
const index = devices.findIndex((candidate) => candidate.id === deviceId && !candidate.revokedAt)
|
|
200
|
+
if (index < 0) return false
|
|
201
|
+
devices.splice(index, 1)
|
|
202
|
+
online.delete(deviceId)
|
|
203
|
+
save()
|
|
204
|
+
return true
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { createRegistry, digest }
|