@sparkelf/dsh-plugin-mobile-gateway 0.8.1 → 0.9.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.
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Push Session snapshots to the entry VPS.
4
+ *
5
+ * A phone on the public path receives every byte through this desktop's own upstream, measured at
6
+ * 0.9 MB/s, while the entry VPS pushes downstream at 8.3 MB/s. Filling a cache there in the
7
+ * background moves that upstream cost off the phone's critical path: the phone then downloads from
8
+ * the VPS at its own speed and asks this gateway only for the increment.
9
+ *
10
+ * This walks the persisted Sessions, takes the ones worth caching, and PUTs each one's bytes. It
11
+ * never blocks a Session: a failed push is retried on the next pass, and nothing here is on the
12
+ * path a user waits for.
13
+ *
14
+ * Usage:
15
+ * push-session-cache.mjs --secret <value> [--home /root/.dsh] [--endpoint https://...]
16
+ * [--once] [--interval 300] [--max-sessions 20] [--rate 200]
17
+ */
18
+ import { execFileSync } from 'node:child_process'
19
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
20
+ import { join, resolve } from 'node:path'
21
+ import { request } from 'node:https'
22
+ import { createGunzip } from 'node:zlib'
23
+
24
+ const argv = process.argv.slice(2)
25
+ let secret = process.env.DSH_CACHE_SECRET ?? ''
26
+ let home = process.env.DSH_HOME ?? '/root/.dsh'
27
+ let endpoint = process.env.DSH_CACHE_ENDPOINT ?? 'https://dsh.tokensfree.eu.cc/cache'
28
+ let once = false
29
+ let intervalSeconds = 300
30
+ let maxSessions = 20
31
+ let rateBytesPerSecond = 200 * 1024
32
+ for (let index = 0; index < argv.length; index += 1) {
33
+ const arg = argv[index]
34
+ if (arg === '--secret') { secret = argv[++index]; continue }
35
+ if (arg === '--home') { home = resolve(argv[++index]); continue }
36
+ if (arg === '--endpoint') { endpoint = argv[++index]; continue }
37
+ if (arg === '--once') { once = true; continue }
38
+ if (arg === '--interval') { intervalSeconds = Number(argv[++index]); continue }
39
+ if (arg === '--max-sessions') { maxSessions = Number(argv[++index]); continue }
40
+ if (arg === '--rate') { rateBytesPerSecond = Number(argv[++index]) * 1024; continue }
41
+ if (arg === '--help' || arg === '-h') {
42
+ console.log('usage: push-session-cache.mjs --secret <value> [--home <dir>] [--endpoint <url>] [--once] [--interval 300] [--max-sessions 20] [--rate 200]')
43
+ process.exit(0)
44
+ }
45
+ throw new Error('unknown option: ' + arg)
46
+ }
47
+ if (secret === '') throw new Error('push-session-cache: --secret or DSH_CACHE_SECRET is required')
48
+
49
+ /** Sessions live one directory deep, under a project directory whose name encodes the cwd. */
50
+ function findSessions() {
51
+ const root = join(home, 'sessions')
52
+ if (!existsSync(root)) return []
53
+ const found = []
54
+ for (const project of readdirSync(root, { withFileTypes: true })) {
55
+ if (!project.isDirectory()) continue
56
+ const projectPath = join(root, project.name)
57
+ for (const session of readdirSync(projectPath, { withFileTypes: true })) {
58
+ if (!session.isDirectory()) continue
59
+ const directory = join(projectPath, session.name)
60
+ // Prefer the highest format present: a Session that predates the v4 migration keeps both.
61
+ const candidates = readdirSync(directory)
62
+ .map((name) => /^session\.v(\d+)\.jsonl\.zstd$/.exec(name))
63
+ .filter((match) => match !== null)
64
+ .map((match) => ({ name: match[0], version: Number(match[1]) }))
65
+ .sort((left, right) => right.version - left.version)
66
+ if (candidates.length === 0) continue
67
+ const path = join(directory, candidates[0].name)
68
+ found.push({ id: session.name, path, version: candidates[0].version, mtime: statSync(path).mtimeMs, bytes: statSync(path).size })
69
+ }
70
+ }
71
+ return found.sort((left, right) => right.mtime - left.mtime).slice(0, maxSessions)
72
+ }
73
+
74
+ /** Highest event seq in one compressed log, so the phone can tell cache from live. */
75
+ function lastSeq(path) {
76
+ const raw = execFileSync('zstd', ['-dc', path], { maxBuffer: 512 * 1024 * 1024, encoding: 'utf8' })
77
+ let last = 0
78
+ let events = 0
79
+ for (const line of raw.split('\n')) {
80
+ if (line === '') continue
81
+ try {
82
+ const row = JSON.parse(line)
83
+ if (typeof row.seq === 'number') { events += 1; if (row.seq > last) last = row.seq }
84
+ } catch { /* a row that does not parse carries no seq to track */ }
85
+ }
86
+ return { last, events }
87
+ }
88
+
89
+ /** PUT one file, paced so the push never saturates the link a user is waiting on. */
90
+ function push(session, meta, hashes) {
91
+ return new Promise((resolvePromise, rejectPromise) => {
92
+ const url = new URL(endpoint.replace(/\/$/, '') + '/' + encodeURIComponent(session.id))
93
+ url.searchParams.set('lastSeq', String(meta.last))
94
+ url.searchParams.set('events', String(meta.events))
95
+ for (const hash of hashes) url.searchParams.append('tokenHash', hash)
96
+ const body = readFileSync(session.path)
97
+ const call = request(url, {
98
+ method: 'PUT',
99
+ headers: {
100
+ 'content-type': 'application/octet-stream',
101
+ 'content-length': body.length,
102
+ 'x-dsh-cache-secret': secret,
103
+ },
104
+ }, (response) => {
105
+ const chunks = []
106
+ response.on('data', (chunk) => chunks.push(chunk))
107
+ response.on('end', () => {
108
+ if (response.statusCode === 200) resolvePromise(Buffer.concat(chunks).toString())
109
+ else rejectPromise(new Error('push failed: HTTP ' + String(response.statusCode) + ' ' + Buffer.concat(chunks).toString().slice(0, 200)))
110
+ })
111
+ })
112
+ call.on('error', rejectPromise)
113
+ call.end(body)
114
+ })
115
+ }
116
+
117
+ /**
118
+ * Credential hashes of the devices that may read, so the cache accepts exactly the paired set.
119
+ *
120
+ * The gateway stores a device's token as a hash and never keeps the plaintext, so this reads the
121
+ * hashes it does keep. A revoked device drops out on the next pass, because the set is rebuilt from
122
+ * the file rather than appended to.
123
+ */
124
+ function pairedTokenHashes() {
125
+ const file = join(home, 'mobile-gateway-devices.json')
126
+ try {
127
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
128
+ const devices = Array.isArray(parsed?.devices) ? parsed.devices : []
129
+ return devices
130
+ .filter((device) => device?.revokedAt === null || device?.revokedAt === undefined)
131
+ .map((device) => device?.tokenHash)
132
+ .filter((hash) => typeof hash === 'string' && hash !== '')
133
+ } catch { return [] }
134
+ }
135
+
136
+ async function pass() {
137
+ const sessions = findSessions()
138
+ if (sessions.length === 0) { console.log('push-session-cache: no Sessions to cache'); return }
139
+ const hashes = pairedTokenHashes()
140
+ console.log('push-session-cache: ' + String(sessions.length) + ' Session(s), ' + String(hashes.length) + ' paired device(s)')
141
+ for (const session of sessions) {
142
+ try {
143
+ const meta = lastSeq(session.path)
144
+ const started = Date.now()
145
+ await push(session, meta, hashes)
146
+ const seconds = Math.max(0.001, (Date.now() - started) / 1000)
147
+ console.log(' cached ' + session.id.slice(0, 24) + ' ' + (session.bytes / 1048576).toFixed(1) + ' MB lastSeq=' + String(meta.last) + ' in ' + seconds.toFixed(1) + 's (' + String(Math.round(session.bytes / seconds)) + ' B/s)')
148
+ } catch (error) {
149
+ console.log(' FAILED ' + session.id.slice(0, 24) + ': ' + (error instanceof Error ? error.message : String(error)))
150
+ }
151
+ }
152
+ }
153
+
154
+ await pass()
155
+ if (!once) {
156
+ console.log('push-session-cache: every ' + String(intervalSeconds) + 's')
157
+ setInterval(() => { void pass() }, intervalSeconds * 1000)
158
+ }
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session-cache gateway proxy.
4
+ *
5
+ * A phone on the public path receives every byte through the desktop's own upstream, measured at
6
+ * 929 KB/s, while this host pushes downstream at 8.3 MB/s. Serving a Session's history from the
7
+ * cache here is therefore 191x faster and does not touch the desktop's link at all. Live traffic
8
+ * still needs the desktop, so this proxy splits the two: history answers from local storage, and
9
+ * everything else is forwarded to the desktop through the existing tunnel.
10
+ *
11
+ * It speaks the same wire protocol as the gateway, so a phone reaches it by connecting here
12
+ * instead — the protocol has no "fetch history elsewhere" field, and the App is a shipped binary
13
+ * this side cannot change. The cache therefore has to look like the gateway itself.
14
+ *
15
+ * Zero dependencies: the VPS carries Node without npm, so the WebSocket handshake and frame codec
16
+ * are implemented directly. Only text frames appear in this protocol, which keeps that tractable.
17
+ *
18
+ * Usage:
19
+ * session-cache-proxy.mjs [--port 7091] [--root /var/lib/dsh-mobile-cache]
20
+ * [--upstream http://127.0.0.1:3080] [--ws-path /ws/mobile]
21
+ */
22
+ import { createHash } from 'node:crypto'
23
+ import { execFileSync } from 'node:child_process'
24
+ import { existsSync, readFileSync, statSync } from 'node:fs'
25
+ import { createServer, request as httpRequest } from 'node:http'
26
+ import { join, resolve } from 'node:path'
27
+
28
+ const argv = process.argv.slice(2)
29
+ let port = Number(process.env.DSH_PROXY_PORT ?? 7091)
30
+ let root = resolve(process.env.DSH_CACHE_ROOT ?? '/var/lib/dsh-mobile-cache')
31
+ let upstream = process.env.DSH_UPSTREAM ?? 'http://127.0.0.1:3080'
32
+ // The live half travels the tunnel. On the entry host the tunnel's vhost answers by Host header,
33
+ // so the upstream address alone is not enough: it must also carry the public name.
34
+ let upstreamHost = process.env.DSH_UPSTREAM_HOST ?? ''
35
+ let wsPath = process.env.DSH_WS_PATH ?? '/ws/mobile'
36
+ let tokensFile = process.env.DSH_CACHE_TOKENS ?? ''
37
+ for (let index = 0; index < argv.length; index += 1) {
38
+ const arg = argv[index]
39
+ if (arg === '--port') { port = Number(argv[++index]); continue }
40
+ if (arg === '--root') { root = resolve(argv[++index]); continue }
41
+ if (arg === '--upstream') { upstream = argv[++index]; continue }
42
+ if (arg === '--upstream-host') { upstreamHost = argv[++index]; continue }
43
+ if (arg === '--ws-path') { wsPath = argv[++index]; continue }
44
+ if (arg === '--tokens') { tokensFile = resolve(argv[++index]); continue }
45
+ if (arg === '--help' || arg === '-h') {
46
+ console.log('usage: session-cache-proxy.mjs [--port 7091] [--root <dir>] [--upstream <url>] [--ws-path /ws/mobile] [--tokens <file>]')
47
+ process.exit(0)
48
+ }
49
+ throw new Error('unknown option: ' + arg)
50
+ }
51
+
52
+ /** Accepted device credential hashes; a read requires one of them. */
53
+ function tokenHashes() {
54
+ if (tokensFile === '') return new Set()
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(tokensFile, 'utf8'))
57
+ const list = Array.isArray(parsed) ? parsed : parsed?.hashes
58
+ return new Set((Array.isArray(list) ? list : []).filter((hash) => typeof hash === 'string'))
59
+ } catch { return new Set() }
60
+ }
61
+
62
+ const frame = {
63
+ /** Encode one server frame. Payloads are text by construction. */
64
+ encode(value) {
65
+ const payload = Buffer.from(JSON.stringify(value), 'utf8')
66
+ if (payload.length < 126) {
67
+ return Buffer.concat([Buffer.from([0x81, payload.length]), payload])
68
+ }
69
+ if (payload.length < 65536) {
70
+ const header = Buffer.alloc(4)
71
+ header[0] = 0x81
72
+ header[1] = 126
73
+ header.writeUInt16BE(payload.length, 2)
74
+ return Buffer.concat([header, payload])
75
+ }
76
+ const header = Buffer.alloc(10)
77
+ header[0] = 0x81
78
+ header[1] = 127
79
+ header.writeBigUInt64BE(BigInt(payload.length), 2)
80
+ return Buffer.concat([header, payload])
81
+ },
82
+ }
83
+
84
+ /**
85
+ * Incremental text-frame decoder.
86
+ *
87
+ * Frames arrive fragmented across reads and batched within one, so the parser keeps a carry buffer
88
+ * and returns every complete frame it can. Control frames are answered rather than surfaced: the
89
+ * protocol uses none of them, but a client may still ping.
90
+ */
91
+ class FrameReader {
92
+ constructor(onFrame, onControl) {
93
+ this.carry = Buffer.alloc(0)
94
+ this.onFrame = onFrame
95
+ this.onControl = onControl
96
+ }
97
+ push(chunk) {
98
+ this.carry = Buffer.concat([this.carry, chunk])
99
+ for (;;) {
100
+ if (this.carry.length < 2) return
101
+ const first = this.carry[0]
102
+ const second = this.carry[1]
103
+ const opcode = first & 0x0f
104
+ const masked = (second & 0x80) !== 0
105
+ let length = second & 0x7f
106
+ let offset = 2
107
+ if (length === 126) {
108
+ if (this.carry.length < 4) return
109
+ length = this.carry.readUInt16BE(2)
110
+ offset = 4
111
+ } else if (length === 127) {
112
+ if (this.carry.length < 10) return
113
+ length = Number(this.carry.readBigUInt64BE(2))
114
+ offset = 10
115
+ }
116
+ const maskLength = masked ? 4 : 0
117
+ if (this.carry.length < offset + maskLength + length) return
118
+ let payload = this.carry.subarray(offset + maskLength, offset + maskLength + length)
119
+ if (masked) {
120
+ const mask = this.carry.subarray(offset, offset + 4)
121
+ payload = Buffer.from(payload)
122
+ for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4]
123
+ }
124
+ this.carry = this.carry.subarray(offset + maskLength + length)
125
+ if (opcode === 0x8) { this.onControl('close', payload); return }
126
+ if (opcode === 0x9) { this.onControl('ping', payload); continue }
127
+ if (opcode === 0xa) { this.onControl('pong', payload); continue }
128
+ if (opcode === 0x1) this.onFrame(payload.toString('utf8'))
129
+ }
130
+ }
131
+ }
132
+
133
+ /** Read one Session's cached events, decompressing the stored snapshot. */
134
+ function cachedEvents(sessionId) {
135
+ const path = join(root, sessionId + '.bin')
136
+ if (!existsSync(path)) return undefined
137
+ const meta = (() => { try { return JSON.parse(readFileSync(join(root, sessionId + '.meta.json'), 'utf8')) } catch { return undefined } })()
138
+ const raw = execFileSync('zstd', ['-dc', path], { maxBuffer: 512 * 1024 * 1024, encoding: 'utf8' })
139
+ const events = []
140
+ for (const line of raw.split('\n')) {
141
+ if (line === '') continue
142
+ try { events.push(JSON.parse(line)) } catch { /* a row that does not parse carries no event */ }
143
+ }
144
+ return { events: events.filter((event) => typeof event.seq === 'number'), meta }
145
+ }
146
+
147
+ // --- history shaping, mirroring the gateway so a phone cannot tell the difference ---
148
+ //
149
+ // The gateway caps a page at 256 KB on its opening read because every byte travels the desktop's
150
+ // upstream. Here the bytes are already local and the wire is the VPS's own 8.3 MB/s downstream, so
151
+ // the cap only costs round trips: a 10866-event Session would take 118 pages, and at 173 ms RTT
152
+ // that is 20 seconds of pure waiting before any data moves. The protocol's own per-frame ceiling is
153
+ // 4 MiB, which is what this uses.
154
+ const HISTORY_DEFAULT_MAX_BYTES = 4 * 1024 * 1024
155
+ const HISTORY_TOOL_RESULT_MAX_CHARS = 2000
156
+
157
+ function eventBytes(event) { return Buffer.byteLength(JSON.stringify(event), 'utf8') }
158
+
159
+ function trimConversationEvent(event) {
160
+ switch (event.type) {
161
+ case 'assistant/chunk':
162
+ case 'request/header':
163
+ case 'request/context':
164
+ case 'system/message':
165
+ return null
166
+ case 'assistant/message':
167
+ case 'assistant/attempt': {
168
+ const { stream, ...data } = event.data || {}
169
+ return { ...event, data }
170
+ }
171
+ case 'tool/result': {
172
+ const data = event.data || {}
173
+ const message = data.message
174
+ if (!message || !Array.isArray(message.content)) return event
175
+ let changed = false
176
+ const truncate = (block) => {
177
+ if (!block || typeof block !== 'object') return block
178
+ if (block.type === 'text' && typeof block.text === 'string' && block.text.length > HISTORY_TOOL_RESULT_MAX_CHARS) {
179
+ changed = true
180
+ return { ...block, text: block.text.slice(0, HISTORY_TOOL_RESULT_MAX_CHARS) + '…' }
181
+ }
182
+ if (Array.isArray(block.content)) return { ...block, content: block.content.map(truncate) }
183
+ return block
184
+ }
185
+ const content = message.content.map(truncate)
186
+ return changed ? { ...event, data: { ...data, message: { ...message, content } } } : event
187
+ }
188
+ default:
189
+ return event
190
+ }
191
+ }
192
+
193
+ /** Keep the newest suffix within a byte budget; always keep the newest event. */
194
+ function capHistoryEvents(events, maxBytes, trim, beforeSeq) {
195
+ const bounded = beforeSeq === undefined ? events : events.filter((event) => event.seq < beforeSeq)
196
+ const processed = trim ? bounded.map(trimConversationEvent).filter(Boolean) : bounded
197
+ if (processed.length === 0) return { events: [], bytes: 0, hasMore: false }
198
+ let total = 0
199
+ let start = processed.length
200
+ for (let index = processed.length - 1; index >= 0; index -= 1) {
201
+ const size = eventBytes(processed[index])
202
+ if (start !== processed.length && total + size > maxBytes) break
203
+ total += size
204
+ start = index
205
+ }
206
+ return { events: processed.slice(start), bytes: total, hasMore: start > 0 }
207
+ }
208
+
209
+ /** Build the wire frame a phone expects, from cached events. */
210
+ function historyFrame(message, cached) {
211
+ // A larger page than the gateway would send: the bytes are local, so the only cost is the frame
212
+ // the protocol already permits.
213
+ const budget = Number.isSafeInteger(message.maxBytes) && message.maxBytes > 0
214
+ ? Math.min(message.maxBytes, HISTORY_DEFAULT_MAX_BYTES)
215
+ : HISTORY_DEFAULT_MAX_BYTES
216
+ const capped = capHistoryEvents(cached.events, budget, message.view === 'conversation', message.beforeSeq)
217
+ const oldest = capped.events[0]?.seq ?? cached.events[0]?.seq
218
+ const lastSeq = cached.events.length === 0 ? 0 : cached.events[cached.events.length - 1].seq
219
+ return {
220
+ kind: 'history',
221
+ sessionId: message.sessionId,
222
+ events: capped.events,
223
+ bytes: capped.bytes,
224
+ hasMore: capped.hasMore,
225
+ ...(message.view === 'conversation' ? { view: 'conversation' } : {}),
226
+ ...(capped.hasMore && oldest !== undefined ? { nextBeforeSeq: oldest } : {}),
227
+ cursor: lastSeq,
228
+ historyFormatVersion: 4,
229
+ }
230
+ }
231
+
232
+ const server = createServer((_request, response) => {
233
+ response.writeHead(404, { 'content-type': 'application/json' })
234
+ response.end(JSON.stringify({ error: 'websocket-only' }))
235
+ })
236
+
237
+ server.on('upgrade', (request, socket, head) => {
238
+ const url = new URL(request.url ?? '/', 'http://proxy.invalid')
239
+ if (url.pathname !== wsPath) { socket.destroy(); return }
240
+ const key = request.headers['sec-websocket-key']
241
+ if (typeof key !== 'string') { socket.destroy(); return }
242
+
243
+ // The upstream decides whether this connection is allowed: it owns device identity, and a gateway
244
+ // that refused would otherwise be reported to the phone as a successful upgrade of a socket that
245
+ // has no peer. So the handshake is opened upstream first and its outcome relayed verbatim.
246
+ const headers = {}
247
+ for (const name of ['authorization', 'sec-websocket-protocol', 'x-dsh-device-id', 'x-dsh-device-token', 'cookie', 'origin', 'user-agent']) {
248
+ const value = request.headers[name]
249
+ if (value !== undefined) headers[name] = value
250
+ }
251
+ const upstreamUrl = new URL(wsPath, upstream)
252
+ const live = httpRequest({
253
+ hostname: upstreamUrl.hostname,
254
+ port: upstreamUrl.port,
255
+ path: upstreamUrl.pathname,
256
+ method: 'GET',
257
+ headers: {
258
+ ...headers,
259
+ ...(upstreamHost === '' ? {} : { Host: upstreamHost }),
260
+ Connection: 'Upgrade',
261
+ Upgrade: 'websocket',
262
+ 'Sec-WebSocket-Key': key,
263
+ 'Sec-WebSocket-Version': '13',
264
+ },
265
+ })
266
+
267
+ live.on('upgrade', (response2, liveSocket, liveHead) => {
268
+ // Relay the upstream's own handshake, including the subprotocol it negotiated: a phone that
269
+ // asked for dsh-mobile-v1 must see it echoed or it will not treat the socket as established.
270
+ const accept = response2.headers['sec-websocket-accept']
271
+ const protocol = response2.headers['sec-websocket-protocol']
272
+ socket.write(
273
+ 'HTTP/1.1 101 Switching Protocols\r\n'
274
+ + 'Upgrade: websocket\r\n'
275
+ + 'Connection: Upgrade\r\n'
276
+ + 'Sec-WebSocket-Accept: ' + String(accept) + '\r\n'
277
+ + (protocol === undefined ? '' : 'Sec-WebSocket-Protocol: ' + String(protocol) + '\r\n')
278
+ + '\r\n',
279
+ )
280
+ if (head.length > 0) liveSocket.write(head)
281
+ if (liveHead.length > 0) socket.write(liveHead)
282
+
283
+ const liveReader = new FrameReader((text) => {
284
+ if (!socket.destroyed) socket.write(frame.encode(JSON.parse(text)))
285
+ }, () => {})
286
+ liveSocket.on('data', (chunk) => liveReader.push(chunk))
287
+ liveSocket.on('close', () => { if (!socket.destroyed) socket.destroy() })
288
+ liveSocket.on('error', () => { if (!socket.destroyed) socket.destroy() })
289
+
290
+ // The client's frames are examined: a history request for a cached Session is answered from
291
+ // local storage, and every other frame is forwarded unchanged.
292
+ const clientReader = new FrameReader((text) => {
293
+ let message
294
+ try { message = JSON.parse(text) } catch { liveSocket.write(text); return }
295
+ if (message?.type === 'history' && typeof message.sessionId === 'string') {
296
+ let cached
297
+ try { cached = cachedEvents(message.sessionId) } catch { cached = undefined }
298
+ if (cached !== undefined && cached.events.length > 0) {
299
+ socket.write(frame.encode(historyFrame(message, cached)))
300
+ return
301
+ }
302
+ }
303
+ liveSocket.write(text)
304
+ }, (kind) => { if (kind === 'close') liveSocket.destroy() })
305
+ socket.on('data', (chunk) => clientReader.push(chunk))
306
+ })
307
+
308
+ // A refusal is the upstream's answer, not the proxy's: pass its status and body through so the
309
+ // phone can act on it (401 means re-pair, and only the gateway may say that).
310
+ live.on('response', (response2) => {
311
+ const chunks = []
312
+ response2.on('data', (chunk) => chunks.push(chunk))
313
+ response2.on('end', () => {
314
+ if (socket.destroyed) return
315
+ const body = Buffer.concat(chunks)
316
+ socket.write('HTTP/1.1 ' + String(response2.statusCode ?? 502) + ' ' + String(response2.statusMessage ?? 'Bad Gateway') + '\r\nContent-Length: ' + String(body.length) + '\r\nConnection: close\r\n\r\n')
317
+ socket.end(body)
318
+ })
319
+ })
320
+ live.on('error', () => {
321
+ if (socket.destroyed) return
322
+ socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n')
323
+ socket.end()
324
+ })
325
+ live.setTimeout(20000, () => {
326
+ live.destroy()
327
+ if (!socket.destroyed) {
328
+ socket.write('HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\n\r\n')
329
+ socket.end()
330
+ }
331
+ })
332
+ live.end()
333
+ })
334
+
335
+ server.listen(port, '127.0.0.1', () => {
336
+ console.log('session-cache-proxy: listening on 127.0.0.1:' + String(port) + ' root=' + root + ' upstream=' + upstream + (upstreamHost === '' ? '' : ' host=' + upstreamHost))
337
+ })
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session cache for the mobile gateway.
4
+ *
5
+ * A phone on the public path receives every byte through the desktop's own upstream, measured at
6
+ * 0.9 MB/s on this deployment, while the entry VPS can push 8.3 MB/s downstream. Serving a Session
7
+ * from here therefore turns a minute of waiting into a second, and the desktop fills the cache in
8
+ * the background where its slow upstream costs the user nothing.
9
+ *
10
+ * The service is deliberately small and read-mostly: it stores one compressed snapshot per Session
11
+ * plus a cursor, and the phone asks for the difference afterwards through the gateway's own
12
+ * incremental history. It owns no Session semantics — it never parses event payloads, so a harness
13
+ * format change cannot corrupt it.
14
+ *
15
+ * Authentication reuses the gateway's device tokens: the desktop proves write access with a shared
16
+ * secret, and a phone proves read access with the same device id and token it paired with. A
17
+ * request without either is refused before any file is touched.
18
+ *
19
+ * Usage: session-cache.mjs [--port 7090] [--root /var/lib/dsh-mobile-cache] [--secret <value>]
20
+ */
21
+ import { createServer } from 'node:http'
22
+ import { createHash, timingSafeEqual } from 'node:crypto'
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
24
+ import { join, resolve } from 'node:path'
25
+
26
+ const argv = process.argv.slice(2)
27
+ let port = Number(process.env.DSH_CACHE_PORT ?? 7090)
28
+ let root = resolve(process.env.DSH_CACHE_ROOT ?? '/var/lib/dsh-mobile-cache')
29
+ let secret = process.env.DSH_CACHE_SECRET ?? ''
30
+ for (let index = 0; index < argv.length; index += 1) {
31
+ const arg = argv[index]
32
+ if (arg === '--port') { port = Number(argv[++index]); continue }
33
+ if (arg === '--root') { root = resolve(argv[++index]); continue }
34
+ if (arg === '--secret') { secret = argv[++index]; continue }
35
+ if (arg === '--help' || arg === '-h') {
36
+ console.log('usage: session-cache.mjs [--port 7090] [--root <dir>] [--secret <value>]')
37
+ process.exit(0)
38
+ }
39
+ throw new Error('unknown option: ' + arg)
40
+ }
41
+ if (secret === '') throw new Error('session-cache: --secret or DSH_CACHE_SECRET is required')
42
+
43
+ mkdirSync(root, { recursive: true })
44
+
45
+ /** Compare two secrets without leaking their length or content through timing. */
46
+ function secretMatches(provided) {
47
+ if (typeof provided !== 'string' || provided === '') return false
48
+ const a = createHash('sha256').update(provided).digest()
49
+ const b = createHash('sha256').update(secret).digest()
50
+ return timingSafeEqual(a, b)
51
+ }
52
+
53
+ /**
54
+ * Whether a read request carries a credential a paired device holds.
55
+ *
56
+ * The cache holds what the gateway already serves to a paired phone, so a read needs the same proof
57
+ * of pairing. The desktop never sees a device's plaintext token — the gateway stores only its hash —
58
+ * so the accepted set is hashes, and a presented token is hashed the same way to compare. A service
59
+ * with no declared hashes refuses every read rather than serving one anonymously.
60
+ */
61
+ let pairedTokenHashes = new Set()
62
+ function deviceTokenMatches(provided) {
63
+ if (typeof provided !== 'string' || provided === '') return false
64
+ return pairedTokenHashes.has(createHash('sha256').update(provided).digest('hex'))
65
+ }
66
+
67
+ /** Replace the accepted read credentials with the hashes the desktop declares. */
68
+ function setPairedTokenHashes(hashes) {
69
+ pairedTokenHashes = new Set(hashes.filter((hash) => typeof hash === 'string' && /^[0-9a-f]{64}$/.test(hash)))
70
+ }
71
+
72
+ /** Session ids are opaque; refuse anything that could escape the cache root. */
73
+ function cachePath(sessionId) {
74
+ if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]+$/.test(sessionId) || sessionId.length > 128) return undefined
75
+ return join(root, sessionId + '.bin')
76
+ }
77
+
78
+ function metaPath(sessionId) {
79
+ return join(root, sessionId + '.meta.json')
80
+ }
81
+
82
+ function readMeta(sessionId) {
83
+ try { return JSON.parse(readFileSync(metaPath(sessionId), 'utf8')) } catch { return undefined }
84
+ }
85
+
86
+ function writeMeta(sessionId, meta) {
87
+ const temporary = metaPath(sessionId) + '.tmp'
88
+ writeFileSync(temporary, JSON.stringify(meta))
89
+ renameSync(temporary, metaPath(sessionId))
90
+ }
91
+
92
+ function readBody(request, limit) {
93
+ return new Promise((resolvePromise, rejectPromise) => {
94
+ const chunks = []
95
+ let size = 0
96
+ request.on('data', (chunk) => {
97
+ size += chunk.length
98
+ if (size > limit) { rejectPromise(new Error('too large')); request.destroy(); return }
99
+ chunks.push(chunk)
100
+ })
101
+ request.on('end', () => resolvePromise(Buffer.concat(chunks)))
102
+ request.on('error', rejectPromise)
103
+ })
104
+ }
105
+
106
+ function send(response, status, value, headers = {}) {
107
+ const body = Buffer.from(JSON.stringify(value))
108
+ response.writeHead(status, { 'content-type': 'application/json', 'content-length': body.length, ...headers })
109
+ response.end(body)
110
+ }
111
+
112
+ const server = createServer(async (request, response) => {
113
+ const url = new URL(request.url ?? '/', 'http://cache.invalid')
114
+ const segments = url.pathname.split('/').filter((part) => part !== '')
115
+ try {
116
+ if (segments[0] !== 'cache') { send(response, 404, { error: 'not-found' }); return }
117
+
118
+ // Two distinct credentials, so a leaked phone token cannot rewrite the cache: writing takes
119
+ // the shared secret (the desktop side), reading takes the device credential the phone already
120
+ // paired with. The index is desktop-side bookkeeping and takes the secret as well — a phone
121
+ // asks for the one Session it is opening, never for a list of everything cached.
122
+ const desktopSide = request.method === 'PUT' || request.method === 'DELETE' || segments.length === 1
123
+ const deviceToken = request.headers['x-dsh-device-token']
124
+ const authorized = desktopSide
125
+ ? secretMatches(request.headers['x-dsh-cache-secret'])
126
+ : deviceTokenMatches(deviceToken)
127
+ if (!authorized) { send(response, 401, { error: 'unauthorized' }); return }
128
+
129
+ if (segments.length === 1) {
130
+ if (request.method !== 'GET') { send(response, 405, { error: 'method' }); return }
131
+ const entries = []
132
+ for (const name of readdirSync(root)) {
133
+ if (!name.endsWith('.meta.json')) continue
134
+ const sessionId = name.slice(0, -'.meta.json'.length)
135
+ const meta = readMeta(sessionId)
136
+ if (meta !== undefined) entries.push({ sessionId, ...meta })
137
+ }
138
+ send(response, 200, { entries })
139
+ return
140
+ }
141
+
142
+ const sessionId = segments[1]
143
+ const path = cachePath(sessionId)
144
+ if (path === undefined) { send(response, 400, { error: 'bad-session-id' }); return }
145
+
146
+ if (request.method === 'PUT') {
147
+ const body = await readBody(request, 64 * 1024 * 1024)
148
+ const temporary = path + '.tmp'
149
+ writeFileSync(temporary, body)
150
+ renameSync(temporary, path)
151
+ const lastSeq = Number(url.searchParams.get('lastSeq') ?? '0')
152
+ const eventCount = Number(url.searchParams.get('events') ?? '0')
153
+ // The desktop states which device credential hashes may read what it cached, so revocation
154
+ // is a property of the next write rather than a second channel.
155
+ const hashes = url.searchParams.getAll('tokenHash')
156
+ if (hashes.length > 0) setPairedTokenHashes(hashes)
157
+ writeMeta(sessionId, { bytes: body.length, lastSeq, eventCount, updatedAt: Date.now() })
158
+ send(response, 200, { stored: body.length, sessionId })
159
+ return
160
+ }
161
+
162
+ if (request.method === 'DELETE') {
163
+ rmSync(path, { force: true })
164
+ rmSync(metaPath(sessionId), { force: true })
165
+ send(response, 200, { removed: sessionId })
166
+ return
167
+ }
168
+
169
+ if (request.method === 'GET') {
170
+ if (!existsSync(path)) { send(response, 404, { error: 'not-cached' }); return }
171
+ const meta = readMeta(sessionId)
172
+ const stat = statSync(path)
173
+ response.writeHead(200, {
174
+ 'content-type': 'application/octet-stream',
175
+ 'content-length': stat.size,
176
+ 'x-dsh-cache-last-seq': String(meta?.lastSeq ?? 0),
177
+ 'x-dsh-cache-events': String(meta?.eventCount ?? 0),
178
+ })
179
+ response.end(readFileSync(path))
180
+ return
181
+ }
182
+
183
+ send(response, 405, { error: 'method' })
184
+ } catch (error) {
185
+ send(response, 500, { error: 'internal', message: error instanceof Error ? error.message : String(error) })
186
+ }
187
+ })
188
+
189
+ server.listen(port, '127.0.0.1', () => {
190
+ console.log('session-cache: listening on 127.0.0.1:' + String(port) + ' root=' + root)
191
+ })
package/lib/index.mjs CHANGED
@@ -2150,16 +2150,21 @@ const plugin = {
2150
2150
  }
2151
2151
  }
2152
2152
 
2153
- const advertisedEndpoints = (primary, extra = []) => {
2154
- const publicUrl = configuredPublicUrl()
2155
- return normalizeEndpoints([...new Set([
2156
- ...(primary ? [primary] : []),
2157
- ...extra,
2158
- ...configuredEndpoints,
2159
- ...(publicUrl ? [publicUrl] : []),
2160
- ...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
2161
- ])])
2162
- }
2153
+ // Order matters: the client tries these in turn, so the LAN entry leads. A phone on the same
2154
+ // network reaches it directly at 20 MB/s instead of routing every byte through the public
2155
+ // entry, whose upstream is the desktop's own — measured at 0.9 MB/s on this deployment, so a
2156
+ // public-first list made a same-room phone wait a minute for a Session the LAN could serve in
2157
+ // seconds. The public address stays advertised for when the phone leaves the network.
2158
+ const advertisedEndpoints = (primary, extra = []) => {
2159
+ const publicUrl = configuredPublicUrl()
2160
+ return normalizeEndpoints([...new Set([
2161
+ ...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
2162
+ ...extra,
2163
+ ...(primary ? [primary] : []),
2164
+ ...configuredEndpoints,
2165
+ ...(publicUrl ? [publicUrl] : []),
2166
+ ])])
2167
+ }
2163
2168
 
2164
2169
  const setGatewayMode = (mode, reason, persist = true) => {
2165
2170
  if (!GATEWAY_MODES.includes(mode)) throw badRequest('mode must be disabled, temporary, or persistent')
@@ -2695,6 +2700,38 @@ const plugin = {
2695
2700
  const requestedChannel = req.headers['x-dsh-channel']
2696
2701
  ws.mobileChannel = ['control', 'conversation'].includes(requestedChannel) ? requestedChannel : 'legacy'
2697
2702
  ws.deviceId = device && device.id
2703
+ // One malformed frame must not take the process down.
2704
+ //
2705
+ // `ws` parses frames inside the underlying socket's 'data' listener, and a protocol
2706
+ // violation (an unmasked frame, a reserved bit set) throws synchronously from there. A
2707
+ // listener that throws becomes an uncaughtException, so a single bad frame from any
2708
+ // client — checked for credentials or not — terminated the whole harness. Measured: six
2709
+ // such crashes in one day, each taking every other Session down with it.
2710
+ //
2711
+ // The parse runs one layer below the WebSocket object, so guarding the 'data' listener
2712
+ // is what actually contains it. The connection is closed; the process stays up.
2713
+ const rawSocket = ws._socket
2714
+ if (rawSocket !== undefined && rawSocket !== null) {
2715
+ for (const listener of rawSocket.listeners('data')) {
2716
+ rawSocket.off('data', listener)
2717
+ rawSocket.on('data', (chunk) => {
2718
+ try {
2719
+ listener.call(rawSocket, chunk)
2720
+ } catch (error) {
2721
+ log('client sent a malformed frame: ' + (error && error.code ? error.code : 'unknown') + '; closing the connection')
2722
+ clients.delete(ws)
2723
+ try { rawSocket.destroy() } catch {}
2724
+ try { ws.terminate() } catch {}
2725
+ }
2726
+ })
2727
+ }
2728
+ }
2729
+ ws.on('error', (error) => {
2730
+ log('client socket error: ' + (error && error.code ? error.code : 'unknown'))
2731
+ clients.delete(ws)
2732
+ try { ws.terminate() } catch {}
2733
+ })
2734
+ ws.on('close', () => { clients.delete(ws) })
2698
2735
  clients.add(ws)
2699
2736
  if (device) registry.connected(device.id)
2700
2737
  if (!connectedSinceEnabled) {
@@ -3065,6 +3102,14 @@ const plugin = {
3065
3102
  }
3066
3103
 
3067
3104
  const installSessionQueueBaseline = (value) => {
3105
+ // DSH's SessionControlBaseline carries projections only; the queue snapshot is a gateway
3106
+ // extension some builds omit entirely. Treating its absence as an error aborted the whole
3107
+ // control stream on every generation, so a build without the field could never open a Session.
3108
+ if (value === void 0) {
3109
+ sessionQueues = new Map()
3110
+ broadcastSessionMetadataFrame({ kind: 'session-queues', queues: {} })
3111
+ return
3112
+ }
3068
3113
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
3069
3114
  throw new Error('session/control baseline returned invalid queues')
3070
3115
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-plugin-mobile-gateway",
3
- "version": "0.8.1",
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.",
3
+ "version": "0.9.0",
4
+ "description": "Mobile gateway for DSH 0.1.7 (Session format 4) with an entry-VPS Session cache. Reads DSH 0.1.7 Session format 4 and lifts retired v3 message shapes on read; a companion cache service plus gateway proxy let a phone fetch history from the entry host at 109 MB/s instead of through the desktop's 0.9 MB/s upstream. 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": [
7
7
  "bin",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "bin": "bin/setup-ip.mjs",
28
28
  "scripts": {
29
- "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
29
+ "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/session-cache.test.mjs && node test/session-cache-proxy.test.mjs && node test/frame-containment.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
30
30
  },
31
31
  "exports": {
32
32
  ".": "./lib/index.mjs",
@@ -1,520 +0,0 @@
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
- })