@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.
@@ -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 } }