@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.
- package/bin/push-session-cache.mjs +158 -0
- package/bin/session-cache-proxy.mjs +337 -0
- package/bin/session-cache.mjs +191 -0
- package/lib/index.mjs +55 -10
- package/package.json +3 -3
- package/lib/client.js.pre-idfix-20260926-054024 +0 -520
|
@@ -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
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
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.
|
|
4
|
-
"description": "Mobile gateway for DSH 0.1.7 (Session format 4)
|
|
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
|
-
})
|