@kinocode/dsh-mobile-bridge 0.0.0-stage → 0.3.2
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/LICENSE +21 -0
- package/README.md +47 -2
- package/cordis.patch.yml +16 -0
- package/lib/auth.mjs +180 -0
- package/lib/client.js +219 -0
- package/lib/control.mjs +226 -0
- package/lib/desktop-api.mjs +102 -0
- package/lib/gateway-client.mjs +236 -0
- package/lib/gateway.mjs +319 -0
- package/lib/index.mjs +246 -0
- package/lib/preview.mjs +149 -0
- package/lib/push.mjs +173 -0
- package/lib/relay-tunnel.mjs +256 -0
- package/lib/sessions.mjs +131 -0
- package/lib/state.mjs +187 -0
- package/lib/tls-store.mjs +107 -0
- package/lib/upstream.mjs +61 -0
- package/lib/vendor/qrcode.mjs +2291 -0
- package/lib/web-sessions.mjs +113 -0
- package/lib/x509.mjs +228 -0
- package/package.json +63 -4
package/lib/gateway.mjs
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// HTTPS gateway for phones (docs/PROTOCOL.md v2 §4): serves pairing and direct control, and
|
|
2
|
+
// proxies the stock DSH Web UI to WebViews that hold a device session. Replaces the third-party
|
|
3
|
+
// dsh-mobile gateway (ADR-0002).
|
|
4
|
+
|
|
5
|
+
import { request as httpRequest } from 'node:http'
|
|
6
|
+
import { createServer } from 'node:https'
|
|
7
|
+
import { connect } from 'node:net'
|
|
8
|
+
import { reachableAddresses } from './tls-store.mjs'
|
|
9
|
+
import { SESSION_COOKIE, withoutCookie } from './web-sessions.mjs'
|
|
10
|
+
|
|
11
|
+
const REFRESH_MS = 30_000
|
|
12
|
+
const MAX_CONTROL_BODY = 64 * 1024
|
|
13
|
+
const MAX_PAIR_BODY = 16 * 1024
|
|
14
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade', 'te', 'trailer'])
|
|
15
|
+
|
|
16
|
+
const UNAUTHORIZED_HTML = `<!doctype html><html lang="ja"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
17
|
+
<title>DSH Mobile</title><body style="font-family:system-ui;padding:24px;line-height:1.7">
|
|
18
|
+
<h1 style="font-size:20px">DSH Mobile アプリから開いてください</h1>
|
|
19
|
+
<p>この接続口は、DSH デスクトップの「スマホを接続」でペアリングした DSH Mobile アプリ専用です。</p></body></html>`
|
|
20
|
+
|
|
21
|
+
export class Gateway {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} deps
|
|
24
|
+
* @param {number} deps.port
|
|
25
|
+
* @param {string[]|undefined} deps.hosts - fixed listen addresses, or undefined for auto
|
|
26
|
+
* @param {import('./tls-store.mjs').TlsStore} deps.tls
|
|
27
|
+
* @param {import('./web-sessions.mjs').WebSessions} deps.webSessions
|
|
28
|
+
* @param {(request: {method: string, path: string, headers: Record<string, string>, body: string, direct: boolean}) => Promise<{status: number, body: unknown}>} deps.handleControl
|
|
29
|
+
* @param {(body: string, ip: string) => Promise<{status: number, body: unknown}>} deps.pair
|
|
30
|
+
* @param {import('./upstream.mjs').Upstream} deps.upstream
|
|
31
|
+
* @param {() => string} deps.hostId
|
|
32
|
+
* @param {(...a: unknown[]) => void} deps.log
|
|
33
|
+
*/
|
|
34
|
+
constructor(deps) {
|
|
35
|
+
this.deps = deps
|
|
36
|
+
/** @type {Map<string, import('node:https').Server>} */
|
|
37
|
+
this.servers = new Map()
|
|
38
|
+
this.addresses = []
|
|
39
|
+
this.pairAttempts = new Map()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Origins phones can use, LAN first. */
|
|
43
|
+
origins() {
|
|
44
|
+
return this.addresses.filter(a => this.servers.has(a)).map(a => `https://${a}:${this.deps.port}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get running() { return this.servers.size > 0 }
|
|
48
|
+
|
|
49
|
+
/** @param {AbortSignal} signal */
|
|
50
|
+
async run(signal) {
|
|
51
|
+
await this.deps.tls.loadCa()
|
|
52
|
+
while (!signal.aborted) {
|
|
53
|
+
try {
|
|
54
|
+
await this.refresh()
|
|
55
|
+
} catch (error) {
|
|
56
|
+
this.deps.log('gateway refresh failed:', error?.message ?? String(error))
|
|
57
|
+
}
|
|
58
|
+
await sleep(REFRESH_MS, signal)
|
|
59
|
+
}
|
|
60
|
+
await this.close()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async refresh() {
|
|
64
|
+
const wanted = this.deps.hosts ?? reachableAddresses()
|
|
65
|
+
const same = wanted.length === this.addresses.length && wanted.every((a, i) => a === this.addresses[i])
|
|
66
|
+
if (same && wanted.every(a => this.servers.has(a))) return
|
|
67
|
+
this.addresses = wanted
|
|
68
|
+
if (wanted.length === 0) {
|
|
69
|
+
await this.close()
|
|
70
|
+
this.deps.log('gateway: no private IPv4 address to listen on (connect to Wi-Fi or Tailscale)')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const material = await this.deps.tls.serverFor(wanted)
|
|
74
|
+
for (const [address, server] of this.servers) {
|
|
75
|
+
if (!wanted.includes(address)) {
|
|
76
|
+
server.close()
|
|
77
|
+
server.closeAllConnections?.()
|
|
78
|
+
this.servers.delete(address)
|
|
79
|
+
} else {
|
|
80
|
+
server.setSecureContext({ cert: material.certPem, key: material.keyPem })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const address of wanted) {
|
|
84
|
+
if (this.servers.has(address)) continue
|
|
85
|
+
const server = createServer({ cert: material.certPem, key: material.keyPem, minVersion: 'TLSv1.2' }, (req, res) => {
|
|
86
|
+
this.onRequest(req, res).catch((error) => {
|
|
87
|
+
this.deps.log('gateway request failed:', error?.message ?? String(error))
|
|
88
|
+
if (!res.headersSent) sendJson(res, 500, { error: 'internal_error' })
|
|
89
|
+
else res.destroy()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
server.on('upgrade', (req, socket, head) => this.onUpgrade(req, socket, head))
|
|
93
|
+
server.on('clientError', (_error, socket) => socket.destroy())
|
|
94
|
+
await new Promise((resolve) => {
|
|
95
|
+
server.once('error', (error) => {
|
|
96
|
+
this.deps.log(`gateway cannot listen on ${address}:${this.deps.port}:`, error?.code ?? error?.message)
|
|
97
|
+
resolve()
|
|
98
|
+
})
|
|
99
|
+
server.listen(this.deps.port, address, () => {
|
|
100
|
+
this.servers.set(address, server)
|
|
101
|
+
resolve()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
this.deps.log('gateway listening', JSON.stringify({ origins: this.origins() }))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async close() {
|
|
109
|
+
for (const server of this.servers.values()) {
|
|
110
|
+
server.close()
|
|
111
|
+
server.closeAllConnections?.()
|
|
112
|
+
}
|
|
113
|
+
this.servers.clear()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {import('node:http').IncomingMessage} req
|
|
118
|
+
* @param {import('node:http').ServerResponse} res
|
|
119
|
+
*/
|
|
120
|
+
async onRequest(req, res) {
|
|
121
|
+
const url = new URL(req.url ?? '/', 'https://gateway')
|
|
122
|
+
const path = url.pathname
|
|
123
|
+
if (path === '/_dshmb/v1/health' && req.method === 'GET') {
|
|
124
|
+
return sendJson(res, 200, { ok: true, hostId: this.deps.hostId(), caFingerprint: this.deps.tls.ca.fingerprint })
|
|
125
|
+
}
|
|
126
|
+
if (path === '/_dshmb/v1/ca' && req.method === 'GET') {
|
|
127
|
+
res.writeHead(200, { 'content-type': 'application/pkix-cert', 'cache-control': 'no-store', 'content-length': this.deps.tls.ca.der.length })
|
|
128
|
+
return res.end(this.deps.tls.ca.der)
|
|
129
|
+
}
|
|
130
|
+
if (path === '/_dshmb/v1/pair') {
|
|
131
|
+
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method_not_allowed' })
|
|
132
|
+
const ip = req.socket.remoteAddress ?? 'unknown'
|
|
133
|
+
if (!this.takePairAttempt(ip)) return sendJson(res, 429, { error: 'rate_limited' })
|
|
134
|
+
const body = await readBody(req, MAX_PAIR_BODY)
|
|
135
|
+
if (body === undefined) return sendJson(res, 413, { error: 'payload_too_large' })
|
|
136
|
+
const result = await this.deps.pair(body, ip)
|
|
137
|
+
return sendJson(res, result.status, result.body)
|
|
138
|
+
}
|
|
139
|
+
if (path.startsWith('/_dshmb/control/')) {
|
|
140
|
+
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method_not_allowed' })
|
|
141
|
+
const body = await readBody(req, MAX_CONTROL_BODY)
|
|
142
|
+
if (body === undefined) return sendJson(res, 413, { error: 'payload_too_large' })
|
|
143
|
+
/** @type {Record<string, string>} */
|
|
144
|
+
const headers = {}
|
|
145
|
+
for (const [key, value] of Object.entries(req.headers)) if (typeof value === 'string') headers[key] = value
|
|
146
|
+
const result = await this.deps.handleControl({ method: req.method, path: path.slice('/_dshmb/control'.length), headers, body, direct: true })
|
|
147
|
+
return sendJson(res, result.status, result.body)
|
|
148
|
+
}
|
|
149
|
+
if (path.startsWith('/_dshmb/')) return sendJson(res, 404, { error: 'not_found' })
|
|
150
|
+
|
|
151
|
+
const deviceId = this.deps.webSessions.check(req.headers.cookie)
|
|
152
|
+
if (deviceId === undefined) {
|
|
153
|
+
res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
154
|
+
return res.end(UNAUTHORIZED_HTML)
|
|
155
|
+
}
|
|
156
|
+
return this.proxy(req, res, false)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Forward one HTTP request to DSH Web, authenticating upstream as the desktop owner.
|
|
161
|
+
* @param {import('node:http').IncomingMessage} req
|
|
162
|
+
* @param {import('node:http').ServerResponse} res
|
|
163
|
+
* @param {boolean} retried
|
|
164
|
+
*/
|
|
165
|
+
async proxy(req, res, retried) {
|
|
166
|
+
const upstream = await this.deps.upstream.target()
|
|
167
|
+
if (upstream === undefined) {
|
|
168
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' })
|
|
169
|
+
return res.end('DSH Web is not available')
|
|
170
|
+
}
|
|
171
|
+
const headers = this.upstreamHeaders(req.headers, upstream)
|
|
172
|
+
const replayable = req.method === 'GET' || req.method === 'HEAD'
|
|
173
|
+
await new Promise((resolve) => {
|
|
174
|
+
const outgoing = httpRequest({ host: '127.0.0.1', port: upstream.port, method: req.method, path: req.url, headers }, (incoming) => {
|
|
175
|
+
if (incoming.statusCode === 401 && replayable && !retried) {
|
|
176
|
+
incoming.resume()
|
|
177
|
+
this.deps.upstream.invalidate()
|
|
178
|
+
resolve(this.proxy(req, res, true))
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const responseHeaders = {}
|
|
182
|
+
for (const [key, value] of Object.entries(incoming.headers)) {
|
|
183
|
+
const lower = key.toLowerCase()
|
|
184
|
+
if (lower === 'set-cookie' || HOP_BY_HOP.has(lower)) continue
|
|
185
|
+
if (lower === 'location' && typeof value === 'string') {
|
|
186
|
+
responseHeaders[key] = value.replace(`http://127.0.0.1:${upstream.port}`, '')
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
responseHeaders[key] = value
|
|
190
|
+
}
|
|
191
|
+
res.writeHead(incoming.statusCode ?? 502, responseHeaders)
|
|
192
|
+
incoming.pipe(res)
|
|
193
|
+
incoming.on('end', resolve)
|
|
194
|
+
incoming.on('error', resolve)
|
|
195
|
+
})
|
|
196
|
+
outgoing.on('error', (error) => {
|
|
197
|
+
if (!res.headersSent) sendJson(res, 502, { error: 'upstream_unavailable' })
|
|
198
|
+
else res.destroy()
|
|
199
|
+
this.deps.log('gateway upstream error:', error?.code ?? error?.message)
|
|
200
|
+
resolve()
|
|
201
|
+
})
|
|
202
|
+
if (replayable) outgoing.end()
|
|
203
|
+
else req.pipe(outgoing)
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Proxy a WebSocket (DSH uses /api/remote.mux and friends).
|
|
209
|
+
* @param {import('node:http').IncomingMessage} req
|
|
210
|
+
* @param {import('node:stream').Duplex} socket
|
|
211
|
+
* @param {Buffer} head
|
|
212
|
+
*/
|
|
213
|
+
async onUpgrade(req, socket, head) {
|
|
214
|
+
socket.on('error', () => socket.destroy())
|
|
215
|
+
const deviceId = this.deps.webSessions.check(req.headers.cookie)
|
|
216
|
+
if (deviceId === undefined) {
|
|
217
|
+
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
const upstream = await this.deps.upstream.target()
|
|
221
|
+
if (upstream === undefined) {
|
|
222
|
+
socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
const headers = this.upstreamHeaders(req.headers, upstream, true)
|
|
226
|
+
const target = connect(upstream.port, '127.0.0.1', () => {
|
|
227
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`]
|
|
228
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
229
|
+
for (const v of Array.isArray(value) ? value : [value]) lines.push(`${key}: ${v}`)
|
|
230
|
+
}
|
|
231
|
+
target.write(`${lines.join('\r\n')}\r\n\r\n`)
|
|
232
|
+
if (head.length > 0) target.write(head)
|
|
233
|
+
socket.pipe(target)
|
|
234
|
+
target.pipe(socket)
|
|
235
|
+
})
|
|
236
|
+
this.deps.webSessions.track(deviceId, socket)
|
|
237
|
+
target.on('error', () => socket.destroy())
|
|
238
|
+
target.on('close', () => socket.destroy())
|
|
239
|
+
socket.on('close', () => target.destroy())
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {import('node:http').IncomingHttpHeaders} incoming
|
|
244
|
+
* @param {{port: number, cookie: string}} upstream
|
|
245
|
+
* @param {boolean} [upgrade]
|
|
246
|
+
*/
|
|
247
|
+
upstreamHeaders(incoming, upstream, upgrade = false) {
|
|
248
|
+
const authority = `127.0.0.1:${upstream.port}`
|
|
249
|
+
/** @type {Record<string, string|string[]>} */
|
|
250
|
+
const headers = {}
|
|
251
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
252
|
+
if (value === undefined) continue
|
|
253
|
+
const lower = key.toLowerCase()
|
|
254
|
+
if (!upgrade && HOP_BY_HOP.has(lower)) continue
|
|
255
|
+
if (lower.startsWith('x-forwarded-') || lower === 'forwarded') continue
|
|
256
|
+
headers[lower] = value
|
|
257
|
+
}
|
|
258
|
+
headers.host = authority
|
|
259
|
+
if (typeof headers.origin === 'string') headers.origin = `http://${authority}`
|
|
260
|
+
if (typeof headers.referer === 'string') {
|
|
261
|
+
try {
|
|
262
|
+
const ref = new URL(headers.referer)
|
|
263
|
+
headers.referer = `http://${authority}${ref.pathname}${ref.search}`
|
|
264
|
+
} catch {
|
|
265
|
+
delete headers.referer
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const rest = withoutCookie(typeof headers.cookie === 'string' ? headers.cookie : '', SESSION_COOKIE)
|
|
269
|
+
headers.cookie = [rest, upstream.cookie].filter(Boolean).join('; ')
|
|
270
|
+
return headers
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
takePairAttempt(ip) {
|
|
274
|
+
const now = Date.now()
|
|
275
|
+
const window = this.pairAttempts.get(ip)
|
|
276
|
+
if (window === undefined || now - window.start > 60_000) {
|
|
277
|
+
this.pairAttempts.set(ip, { start: now, count: 1 })
|
|
278
|
+
if (this.pairAttempts.size > 1000) this.pairAttempts.clear()
|
|
279
|
+
return true
|
|
280
|
+
}
|
|
281
|
+
window.count += 1
|
|
282
|
+
return window.count <= 10
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function sendJson(res, status, body) {
|
|
287
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
288
|
+
res.end(JSON.stringify(body))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function readBody(req, limit) {
|
|
292
|
+
return new Promise((resolve, reject) => {
|
|
293
|
+
const chunks = []
|
|
294
|
+
let size = 0
|
|
295
|
+
req.on('data', (chunk) => {
|
|
296
|
+
size += chunk.length
|
|
297
|
+
if (size > limit) {
|
|
298
|
+
resolve(undefined)
|
|
299
|
+
req.destroy()
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
chunks.push(chunk)
|
|
303
|
+
})
|
|
304
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
305
|
+
req.on('error', reject)
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function sleep(ms, signal) {
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
const timer = setTimeout(done, ms)
|
|
312
|
+
function done() {
|
|
313
|
+
signal?.removeEventListener('abort', done)
|
|
314
|
+
clearTimeout(timer)
|
|
315
|
+
resolve()
|
|
316
|
+
}
|
|
317
|
+
signal?.addEventListener('abort', done, { once: true })
|
|
318
|
+
})
|
|
319
|
+
}
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// dsh-mobile-bridge — DeepSeek Harness host plugin.
|
|
2
|
+
//
|
|
3
|
+
// Lets the DSH Mobile Android app answer approvals and ask_user_question prompts for the same
|
|
4
|
+
// live session the desktop shows, and pushes "approval needed" / "turn finished" to the phone.
|
|
5
|
+
// It also serves its own HTTPS gateway for the phone (pairing QR, WebView proxy of the stock DSH
|
|
6
|
+
// Web UI, direct control), so no third-party plugin is needed.
|
|
7
|
+
// Architecture: docs/ADR-0001-architecture.md, docs/ADR-0002-own-gateway.md. Wire: docs/PROTOCOL.md v2.
|
|
8
|
+
|
|
9
|
+
import { isIP } from 'node:net'
|
|
10
|
+
import { homedir, hostname } from 'node:os'
|
|
11
|
+
import { isAbsolute, join } from 'node:path'
|
|
12
|
+
import { NonceCache, RegistrationCodes } from './auth.mjs'
|
|
13
|
+
import { createControlHandler } from './control.mjs'
|
|
14
|
+
import { desktopRoutes } from './desktop-api.mjs'
|
|
15
|
+
import { Gateway } from './gateway.mjs'
|
|
16
|
+
import { GatewayClient } from './gateway-client.mjs'
|
|
17
|
+
import { Notifier } from './push.mjs'
|
|
18
|
+
import { RelayTunnel, tunnelUrl } from './relay-tunnel.mjs'
|
|
19
|
+
import { SessionTracker } from './sessions.mjs'
|
|
20
|
+
import { StateStore } from './state.mjs'
|
|
21
|
+
import { TlsStore } from './tls-store.mjs'
|
|
22
|
+
import { Upstream } from './upstream.mjs'
|
|
23
|
+
import { WebSessions } from './web-sessions.mjs'
|
|
24
|
+
|
|
25
|
+
export const name = 'dsh-mobile-bridge'
|
|
26
|
+
export const inject = ['connection', 'typertGateway']
|
|
27
|
+
|
|
28
|
+
export const VERSION = '0.3.2'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} BridgeConfig
|
|
32
|
+
* @property {string} stateDir - absolute directory for state.json (host identity, devices); default $DSH_HOME/mobile-bridge
|
|
33
|
+
* @property {boolean} gatewayEnabled - serve the phone gateway (default true)
|
|
34
|
+
* @property {number} gatewayPort - gateway TLS port on every private IPv4 address (default 3543)
|
|
35
|
+
* @property {string[]|undefined} gatewayHosts - fixed IPv4 listen addresses instead of auto-detection
|
|
36
|
+
* @property {string} relayUrl - https:// relay base, or '' to run without push / native control
|
|
37
|
+
* @property {string} relayEnrollToken - optional token for a relay that restricts new hosts
|
|
38
|
+
* @property {string} hostLabel - computer name shown on the phone
|
|
39
|
+
* @property {number} previewMaxChars - max characters of command preview shown in the app
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** DSH home as the `dsh` launcher resolves it (`DSH_HOME`, else ~/.dsh). */
|
|
43
|
+
function dshHome(env = process.env) {
|
|
44
|
+
const configured = env.DSH_HOME
|
|
45
|
+
return typeof configured === 'string' && configured.trim() !== '' ? configured : join(homedir(), '.dsh')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate the profile configuration; misconfiguration fails loudly at load.
|
|
50
|
+
* Every field is optional because a profile patch replaces the whole bundle config:
|
|
51
|
+
* a user who only writes `relayUrl` still gets working defaults.
|
|
52
|
+
* @param {Record<string, unknown>} [raw]
|
|
53
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
54
|
+
* @returns {BridgeConfig}
|
|
55
|
+
*/
|
|
56
|
+
export function parseConfig(raw = {}, env = process.env) {
|
|
57
|
+
raw = raw ?? {}
|
|
58
|
+
const stateDir = raw.stateDir === undefined || raw.stateDir === '' ? join(dshHome(env), 'mobile-bridge') : raw.stateDir
|
|
59
|
+
if (typeof stateDir !== 'string' || !isAbsolute(stateDir)) throw new Error('dsh-mobile-bridge: config.stateDir must be an absolute path')
|
|
60
|
+
const gatewayEnabled = raw.gatewayEnabled !== false
|
|
61
|
+
const gatewayPort = raw.gatewayPort === undefined ? 3543 : Number(raw.gatewayPort)
|
|
62
|
+
if (!Number.isInteger(gatewayPort) || gatewayPort < 1024 || gatewayPort > 65535) throw new Error('dsh-mobile-bridge: config.gatewayPort must be an integer 1024..65535')
|
|
63
|
+
let gatewayHosts
|
|
64
|
+
if (raw.gatewayHosts !== undefined && raw.gatewayHosts !== null) {
|
|
65
|
+
if (!Array.isArray(raw.gatewayHosts) || !raw.gatewayHosts.every(h => typeof h === 'string' && isIP(h) === 4)) {
|
|
66
|
+
throw new Error('dsh-mobile-bridge: config.gatewayHosts must be a list of IPv4 addresses')
|
|
67
|
+
}
|
|
68
|
+
gatewayHosts = [...raw.gatewayHosts]
|
|
69
|
+
}
|
|
70
|
+
const relayUrl = raw.relayUrl === undefined || raw.relayUrl === null ? '' : String(raw.relayUrl).trim()
|
|
71
|
+
if (relayUrl !== '') tunnelUrl(relayUrl, '00000000-0000-4000-8000-000000000000')
|
|
72
|
+
const relayEnrollToken = raw.relayEnrollToken === undefined || raw.relayEnrollToken === null ? '' : String(raw.relayEnrollToken)
|
|
73
|
+
const hostLabel = typeof raw.hostLabel === 'string' && raw.hostLabel.trim() !== '' ? raw.hostLabel.trim().slice(0, 80) : hostname().replace(/\.(local|lan|home)$/, '')
|
|
74
|
+
const previewMaxChars = raw.previewMaxChars === undefined ? 400 : Number(raw.previewMaxChars)
|
|
75
|
+
if (!Number.isInteger(previewMaxChars) || previewMaxChars < 50 || previewMaxChars > 4000) throw new Error('dsh-mobile-bridge: config.previewMaxChars must be an integer 50..4000')
|
|
76
|
+
return { stateDir, gatewayEnabled, gatewayPort, gatewayHosts, relayUrl, relayEnrollToken, hostLabel, previewMaxChars }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {any} ctx - Cordis context
|
|
81
|
+
* @param {Record<string, unknown>} rawConfig
|
|
82
|
+
*/
|
|
83
|
+
export function apply(ctx, rawConfig) {
|
|
84
|
+
const config = parseConfig(rawConfig)
|
|
85
|
+
const log = (...args) => console.log('[mobile-bridge]', ...args)
|
|
86
|
+
const logError = (label) => (error) => log(label, error?.message ?? String(error))
|
|
87
|
+
|
|
88
|
+
const state = new StateStore(config.stateDir, { log })
|
|
89
|
+
const codes = new RegistrationCodes()
|
|
90
|
+
const nonces = new NonceCache()
|
|
91
|
+
const tls = new TlsStore(join(config.stateDir, 'tls'), { log })
|
|
92
|
+
const webSessions = new WebSessions({ secret: () => state.hostSecret, isActive: id => state.device(id) !== undefined })
|
|
93
|
+
/** @type {RelayTunnel | undefined} */
|
|
94
|
+
let tunnel
|
|
95
|
+
/** @type {Notifier | undefined} */
|
|
96
|
+
let notifier
|
|
97
|
+
|
|
98
|
+
const sessions = new SessionTracker({
|
|
99
|
+
onTurnEnd: (sessionId, reason) => {
|
|
100
|
+
ensureTitle(sessionId)
|
|
101
|
+
.then(() => notifier?.turnEnded(sessionId, reason))
|
|
102
|
+
.catch(logError('turn push failed:'))
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
ctx.on('session/event', (session, event) => sessions.observe(session, event))
|
|
106
|
+
|
|
107
|
+
const listSessions = async () => {
|
|
108
|
+
const controller = ctx.get('sessionController')
|
|
109
|
+
if (controller === undefined) return []
|
|
110
|
+
const value = await controller.list({}, AbortSignal.timeout(5000))
|
|
111
|
+
return Array.isArray(value?.items) ? value.items : []
|
|
112
|
+
}
|
|
113
|
+
// Sessions titled before this plugin started have no `session/title` event here; look them up
|
|
114
|
+
// (at most every 30 s) before a push needs the name.
|
|
115
|
+
let titlesFetchedAt = 0
|
|
116
|
+
const ensureTitle = async (sessionId) => {
|
|
117
|
+
if (sessions.title(sessionId) !== '' || Date.now() - titlesFetchedAt < 30_000) return
|
|
118
|
+
titlesFetchedAt = Date.now()
|
|
119
|
+
try {
|
|
120
|
+
sessions.learnTitles(await listSessions())
|
|
121
|
+
} catch (error) {
|
|
122
|
+
log('session titles unavailable:', error?.message ?? String(error))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const gateway = new GatewayClient({
|
|
127
|
+
connection: () => ctx.connection,
|
|
128
|
+
gateway: () => ctx.typertGateway,
|
|
129
|
+
log,
|
|
130
|
+
hooks: {
|
|
131
|
+
onPending: (pending) => {
|
|
132
|
+
ensureTitle(pending.sessionId)
|
|
133
|
+
.then(() => notifier?.pending(pending))
|
|
134
|
+
.catch(logError('push failed:'))
|
|
135
|
+
},
|
|
136
|
+
onResolved: (eventId, { by }) => {
|
|
137
|
+
const deviceId = by.startsWith('device:') ? by.slice('device:'.length) : undefined
|
|
138
|
+
notifier?.resolved(eventId, deviceId).catch(logError('resolve push failed:'))
|
|
139
|
+
},
|
|
140
|
+
onReadyChange: (ready) => log(ready ? 'joined DSH event stream' : 'left DSH event stream'),
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
/** @type {Gateway | undefined} */
|
|
145
|
+
let phoneGateway
|
|
146
|
+
const control = createControlHandler({
|
|
147
|
+
state,
|
|
148
|
+
nonces,
|
|
149
|
+
codes,
|
|
150
|
+
gateway,
|
|
151
|
+
sessions,
|
|
152
|
+
webSessions,
|
|
153
|
+
origins: () => phoneGateway?.origins() ?? [],
|
|
154
|
+
relayUrl: config.relayUrl,
|
|
155
|
+
hostLabel: config.hostLabel,
|
|
156
|
+
version: VERSION,
|
|
157
|
+
previewMaxChars: config.previewMaxChars,
|
|
158
|
+
log,
|
|
159
|
+
listSessions,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
/** Resolves once state is loaded; the extension action waits on it. */
|
|
163
|
+
let started
|
|
164
|
+
ctx.effect(() => {
|
|
165
|
+
const lifetime = new AbortController()
|
|
166
|
+
started = (async () => {
|
|
167
|
+
await state.load()
|
|
168
|
+
notifier = new Notifier({ state, sessions, tunnel: () => tunnel, previewMaxChars: config.previewMaxChars, log })
|
|
169
|
+
gateway.run(lifetime.signal).catch(logError('event stream stopped:'))
|
|
170
|
+
if (config.relayUrl !== '') {
|
|
171
|
+
tunnel = new RelayTunnel({
|
|
172
|
+
relayUrl: config.relayUrl,
|
|
173
|
+
hostId: state.hostId,
|
|
174
|
+
hostSecret: state.hostSecret,
|
|
175
|
+
enrollToken: config.relayEnrollToken,
|
|
176
|
+
label: config.hostLabel,
|
|
177
|
+
version: VERSION,
|
|
178
|
+
handle: control.handle,
|
|
179
|
+
log,
|
|
180
|
+
})
|
|
181
|
+
tunnel.run(lifetime.signal).catch(logError('relay tunnel stopped:'))
|
|
182
|
+
} else {
|
|
183
|
+
log('relayUrl is not set: phones can use the WebView, but push and notification actions are off')
|
|
184
|
+
}
|
|
185
|
+
if (config.gatewayEnabled) {
|
|
186
|
+
const webServer = ctx.get('webServer')
|
|
187
|
+
if (webServer === undefined) {
|
|
188
|
+
log('gateway disabled: this DSH has no local web server (webServer service) to proxy')
|
|
189
|
+
} else {
|
|
190
|
+
phoneGateway = new Gateway({
|
|
191
|
+
port: config.gatewayPort,
|
|
192
|
+
hosts: config.gatewayHosts,
|
|
193
|
+
tls,
|
|
194
|
+
webSessions,
|
|
195
|
+
handleControl: control.handle,
|
|
196
|
+
pair: body => control.pair(body),
|
|
197
|
+
upstream: new Upstream({ port: () => ctx.get('webServer')?.port, authenticatedUrl: url => ctx.connection.authenticatedUrl(url), log }),
|
|
198
|
+
hostId: () => state.hostId,
|
|
199
|
+
log,
|
|
200
|
+
})
|
|
201
|
+
phoneGateway.run(lifetime.signal).catch(logError('gateway stopped:'))
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
log('started', JSON.stringify({ hostId: state.hostId, relay: config.relayUrl !== '', gateway: phoneGateway !== undefined }))
|
|
205
|
+
})()
|
|
206
|
+
started.catch(logError('start failed:'))
|
|
207
|
+
return async () => {
|
|
208
|
+
// Requests die with this process: take their notifications off the phones while the relay
|
|
209
|
+
// tunnel is still up (bounded, so a dead relay cannot hold up DSH's shutdown).
|
|
210
|
+
const withdrawn = await Promise.race([
|
|
211
|
+
notifier?.withdrawAll().catch(() => 0) ?? 0,
|
|
212
|
+
new Promise(resolve => setTimeout(resolve, 3000, -1)),
|
|
213
|
+
])
|
|
214
|
+
if (withdrawn !== 0) log('withdrew open notifications on shutdown', JSON.stringify({ count: withdrawn }))
|
|
215
|
+
lifetime.abort()
|
|
216
|
+
await started.catch(() => {})
|
|
217
|
+
await state.close()
|
|
218
|
+
}
|
|
219
|
+
}, 'mobile-bridge: runtime')
|
|
220
|
+
|
|
221
|
+
// Desktop sidebar panel: QR, status and device list, reachable only by the DSH owner.
|
|
222
|
+
ctx.effect(() => {
|
|
223
|
+
const routes = desktopRoutes({
|
|
224
|
+
state,
|
|
225
|
+
codes,
|
|
226
|
+
get gateway() { return phoneGateway ?? emptyGateway },
|
|
227
|
+
tls,
|
|
228
|
+
webSessions,
|
|
229
|
+
relay: () => ({ configured: config.relayUrl !== '', connected: tunnel?.connected === true }),
|
|
230
|
+
hostLabel: config.hostLabel,
|
|
231
|
+
version: VERSION,
|
|
232
|
+
log,
|
|
233
|
+
})
|
|
234
|
+
const disposers = routes.map(route => ctx.connection.fetch.register({
|
|
235
|
+
...route,
|
|
236
|
+
fetch: async (request) => {
|
|
237
|
+
await started
|
|
238
|
+
return route.fetch(request)
|
|
239
|
+
},
|
|
240
|
+
}))
|
|
241
|
+
return () => { for (const dispose of disposers) void dispose() }
|
|
242
|
+
}, 'mobile-bridge: desktop api')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Stand-in while the gateway is disabled or not started. */
|
|
246
|
+
const emptyGateway = { origins: () => [], running: false, deps: { port: 0 } }
|