dsh-plugin-mobile-link 0.2.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/lib/router.js ADDED
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Device-side routing for one agent connection.
3
+ *
4
+ * Owns the device registry, the DLP-id ↔ mux-stream table, the waterfall
5
+ * dedupe set and every per-device `$events` stream. The transport concerns
6
+ * live in `link.js`; everything here is driven by frames and by DSH mux events,
7
+ * so `node --test` can exercise it without a socket.
8
+ *
9
+ * Spec: docs/RELAY-PROTOCOL.md §4.
10
+ */
11
+
12
+ import {
13
+ StreamTable, WaterfallDedupe, deviceFrameError, deviceIdOf, nonEmptyString, resultFrame,
14
+ } from './dlp.js'
15
+ import { FileInbox, isFileMethod } from './files.js'
16
+ import { isHelloMethod, helloPayload } from './hello.js'
17
+
18
+ export const EVENTS_ENDPOINT = '$events'
19
+ export const EVENTS_RESULT = '$events/result'
20
+
21
+ export function messageOf(error) {
22
+ return error instanceof Error ? error.message : String(error)
23
+ }
24
+
25
+ export function errorObject(code, message, details = {}) {
26
+ return { code, message, details }
27
+ }
28
+
29
+ export class DeviceRouter {
30
+ /**
31
+ * @param {object} options
32
+ * @param {object} options.dsh local DSH client (rpc / ensureMux / openStream / cancelStream)
33
+ * @param {(deviceId: string, frame: object) => boolean} options.send deliver one frame to one device
34
+ * @param {() => void} [options.onChange] called whenever the status snapshot changes
35
+ */
36
+ constructor({ dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1 } = {}) {
37
+ this.dsh = dsh
38
+ // Reported to the app so it can tell which wire revision it is talking to.
39
+ this.protocolVersion = protocolVersion
40
+ this.send = send
41
+ this.logger = logger
42
+ this.onChange = onChange
43
+ this.now = now
44
+ this.devices = new Map()
45
+ this.streams = new StreamTable()
46
+ this.dedupe = new WaterfallDedupe({ now })
47
+ this.lastError = undefined
48
+ this.fileInbox = new FileInbox(logger)
49
+ }
50
+
51
+ #changed() {
52
+ this.onChange()
53
+ }
54
+
55
+ #fail(message) {
56
+ this.lastError = message
57
+ this.logger.warn?.(`mobile-link: ${message}`)
58
+ this.#changed()
59
+ }
60
+
61
+ /** Route one validated frame that the relay addressed to a device. */
62
+ async handleRelayFrame(frame) {
63
+ if (!frame || typeof frame !== 'object') return
64
+ const deviceId = deviceIdOf(frame)
65
+ if (frame.t === 'deviceAttach') {
66
+ await this.attachDevice(deviceId, frame.device)
67
+ return
68
+ }
69
+ if (frame.t === 'deviceDetach') {
70
+ await this.detachDevice(deviceId, frame.reason)
71
+ return
72
+ }
73
+ if (!deviceId) return
74
+ const problem = deviceFrameError(frame)
75
+ if (problem) {
76
+ this.send(deviceId, { t: 'error', code: 'protocol/bad-frame', message: problem })
77
+ return
78
+ }
79
+ switch (frame.t) {
80
+ case 'req':
81
+ await this.handleRequest(deviceId, frame)
82
+ return
83
+ case 'open':
84
+ await this.handleOpen(deviceId, frame)
85
+ return
86
+ case 'cancel':
87
+ this.handleCancel(deviceId, frame)
88
+ return
89
+ case 'eventResult':
90
+ await this.handleEventResult(deviceId, frame)
91
+ return
92
+ default:
93
+ /* Unknown frame types are ignored by design (spec §3). */
94
+ return
95
+ }
96
+ }
97
+
98
+ // ── device lifecycle ────────────────────────────────────────────────────
99
+
100
+ async attachDevice(deviceId, info = {}) {
101
+ if (!nonEmptyString(deviceId) || this.devices.has(deviceId)) return
102
+ this.devices.set(deviceId, {
103
+ deviceId,
104
+ name: info?.name,
105
+ model: info?.model,
106
+ connectedAt: this.now(),
107
+ clientId: undefined,
108
+ readyValue: undefined,
109
+ eventsMuxId: undefined,
110
+ dlpIds: new Set(),
111
+ })
112
+ this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
113
+ this.#changed()
114
+ await this.ensureEventsStream(deviceId)
115
+ }
116
+
117
+ async detachDevice(deviceId, reason) {
118
+ const record = this.devices.get(deviceId)
119
+ if (!record) return
120
+ for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
121
+ this.devices.delete(deviceId)
122
+ this.logger.info?.(`mobile-link: device ${deviceId} detached (${reason ?? 'closed'})`)
123
+ this.#changed()
124
+ }
125
+
126
+ /** Each device gets its own `$events` stream, and therefore its own `clientId`. */
127
+ async ensureEventsStream(deviceId) {
128
+ const record = this.devices.get(deviceId)
129
+ if (!record || record.eventsMuxId) return
130
+ const muxId = `ev:${deviceId}`
131
+ record.eventsMuxId = muxId
132
+ this.streams.add({ deviceId, dlpId: '', muxId, endpoint: EVENTS_ENDPOINT })
133
+ try {
134
+ await this.dsh.ensureMux()
135
+ this.dsh.openStream(muxId, EVENTS_ENDPOINT, {})
136
+ this.logger.debug?.(`mobile-link: opened per-device $events stream ${muxId}`)
137
+ } catch (error) {
138
+ record.eventsMuxId = undefined
139
+ record.readyValue = undefined
140
+ this.streams.removeByMux(muxId)
141
+ this.#fail(`$events stream unavailable: ${messageOf(error)}`)
142
+ }
143
+ }
144
+
145
+ /** Drop every device and return the mux streams the caller must cancel. */
146
+ teardownAll() {
147
+ const muxIds = []
148
+ for (const deviceId of [...this.devices.keys()]) {
149
+ muxIds.push(...this.streams.removeDevice(deviceId))
150
+ this.devices.delete(deviceId)
151
+ }
152
+ this.streams = new StreamTable()
153
+ return muxIds
154
+ }
155
+
156
+ // ── device requests ─────────────────────────────────────────────────────
157
+
158
+ async handleRequest(deviceId, frame) {
159
+ // Files the phone sends are answered here rather than forwarded: the Host
160
+ // has no upload endpoint, and carrying them on the existing unary frame
161
+ // means neither the relay nor the protocol has to change.
162
+ // What this connector is and can do. Answered here for the same reason as
163
+ // the file calls: the HTTP status route exists only on the direct path.
164
+ if (isHelloMethod(frame.method)) {
165
+ const value = helloPayload({
166
+ protocolVersion: this.protocolVersion,
167
+ agentId: this.identity?.agentId,
168
+ name: this.identity?.agentName,
169
+ })
170
+ this.send(deviceId, resultFrame(frame.id, { ok: true, value }, deviceId))
171
+ return
172
+ }
173
+
174
+ if (isFileMethod(frame.method)) {
175
+ let result
176
+ try {
177
+ result = { ok: true, value: this.fileInbox.handle(frame.method, frame.args ?? {}) }
178
+ } catch (error) {
179
+ result = { ok: false, error: errorObject('file/rejected', messageOf(error)) }
180
+ }
181
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
182
+ return
183
+ }
184
+
185
+ let result
186
+ try {
187
+ result = await this.dsh.rpc(frame.method, frame.args ?? {})
188
+ } catch (error) {
189
+ result = { ok: false, error: errorObject('host/unavailable', messageOf(error)) }
190
+ this.lastError = messageOf(error)
191
+ this.#changed()
192
+ }
193
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
194
+ }
195
+
196
+ async handleOpen(deviceId, frame) {
197
+ const record = this.devices.get(deviceId)
198
+ if (!record) {
199
+ this.send(deviceId, { t: 'streamError', id: frame.id, error: errorObject('stream/unavailable', 'device is not attached') })
200
+ return
201
+ }
202
+ if (frame.endpoint === EVENTS_ENDPOINT) {
203
+ if (!record.eventsMuxId) await this.ensureEventsStream(deviceId)
204
+ if (!record.eventsMuxId) {
205
+ this.send(deviceId, {
206
+ t: 'streamError', id: frame.id,
207
+ error: errorObject('stream/unavailable', this.lastError ?? 'DSH is unavailable'),
208
+ })
209
+ return
210
+ }
211
+ // Items from this device's $events stream are already flowing as `event`
212
+ // frames; this binding re-delivers them as `item` frames for the id.
213
+ record.dlpIds.add(frame.id)
214
+ // A device that opens $events after the ready item already went by would
215
+ // otherwise never learn its clientId, so replay it for the new binding.
216
+ if (record.readyValue !== undefined) {
217
+ this.send(deviceId, { t: 'item', id: frame.id, value: record.readyValue })
218
+ }
219
+ return
220
+ }
221
+ const muxId = `s:${deviceId}:${frame.id}`
222
+ if (!this.streams.add({ deviceId, dlpId: frame.id, muxId, endpoint: frame.endpoint })) {
223
+ this.send(deviceId, {
224
+ t: 'streamError', id: frame.id,
225
+ error: errorObject('stream/duplicate', `stream ${frame.id} is already open`),
226
+ })
227
+ return
228
+ }
229
+ try {
230
+ await this.dsh.ensureMux()
231
+ } catch (error) {
232
+ this.streams.removeByMux(muxId)
233
+ this.#fail(messageOf(error))
234
+ this.send(deviceId, {
235
+ t: 'streamError', id: frame.id, error: errorObject('host/unavailable', messageOf(error)),
236
+ })
237
+ return
238
+ }
239
+ this.dsh.openStream(muxId, frame.endpoint, frame.args ?? {})
240
+ }
241
+
242
+ handleCancel(deviceId, frame) {
243
+ const record = this.devices.get(deviceId)
244
+ if (record) record.dlpIds.delete(frame.id)
245
+ const muxId = this.streams.removeByDlp(deviceId, frame.id)
246
+ if (muxId) this.dsh.cancelStream(muxId)
247
+ }
248
+
249
+ async handleEventResult(deviceId, frame) {
250
+ const record = this.devices.get(deviceId)
251
+ if (!record) return
252
+ const payload = frame.result ?? {}
253
+ const eventId = payload.eventId
254
+ if (!nonEmptyString(eventId) || payload.outcome === null || typeof payload.outcome !== 'object') {
255
+ this.send(deviceId, {
256
+ t: 'error', code: 'protocol/bad-frame',
257
+ message: 'eventResult needs result.eventId and result.outcome',
258
+ })
259
+ return
260
+ }
261
+ if (!nonEmptyString(record.clientId)) {
262
+ this.send(deviceId, {
263
+ t: 'error', code: 'events/not-ready',
264
+ message: 'this device has no $events clientId yet',
265
+ })
266
+ return
267
+ }
268
+ // The clientId is always the one from *this device's* $events ready frame
269
+ // (spec §4.1.5); any clientId the device echoed is ignored on purpose.
270
+ if (!this.dedupe.claim(eventId)) return
271
+ let result
272
+ try {
273
+ result = await this.dsh.rpc(EVENTS_RESULT, {
274
+ clientId: record.clientId, eventId, outcome: payload.outcome,
275
+ })
276
+ } catch (error) {
277
+ this.dedupe.release(eventId)
278
+ this.logger.warn?.(`mobile-link: $events/result failed: ${messageOf(error)}`)
279
+ return
280
+ }
281
+ if (result?.ok !== true) {
282
+ this.dedupe.release(eventId)
283
+ this.logger.warn?.(`mobile-link: $events/result rejected: ${result?.error?.code ?? 'unknown'}`)
284
+ }
285
+ }
286
+
287
+ // ── DSH mux events ──────────────────────────────────────────────────────
288
+
289
+ onStreamItem(muxId, value) {
290
+ const entry = this.streams.entryOfMux(muxId)
291
+ if (!entry) {
292
+ this.logger.debug?.(`mobile-link: item for unknown mux stream ${muxId}`)
293
+ return
294
+ }
295
+ if (entry.endpoint === EVENTS_ENDPOINT) {
296
+ this.forwardEvent(entry.deviceId, value)
297
+ return
298
+ }
299
+ this.send(entry.deviceId, { t: 'item', id: entry.dlpId, value })
300
+ }
301
+
302
+ forwardEvent(deviceId, value) {
303
+ const record = this.devices.get(deviceId)
304
+ if (!record) return
305
+ if (value && value.type === 'ready' && nonEmptyString(value.clientId)) {
306
+ record.clientId = value.clientId
307
+ record.readyValue = value
308
+ this.#changed()
309
+ }
310
+ for (const dlpId of record.dlpIds) this.send(deviceId, { t: 'item', id: dlpId, value })
311
+ // Spec §4.1.5: every $events item is broadcast as an `event` frame until the
312
+ // device opens the stream explicitly, so nothing is delivered twice.
313
+ if (record.dlpIds.size === 0) this.send(deviceId, { t: 'event', value })
314
+ }
315
+
316
+ onStreamError(muxId, error) {
317
+ const entry = this.streams.entryOfMux(muxId)
318
+ if (!entry) return
319
+ if (entry.endpoint === EVENTS_ENDPOINT) {
320
+ this.logger.warn?.(`mobile-link: $events stream failed: ${error?.code ?? 'unknown'}`)
321
+ this.#forgetEvents(entry.deviceId)
322
+ this.streams.removeByMux(muxId)
323
+ return
324
+ }
325
+ this.streams.removeByMux(muxId)
326
+ this.send(entry.deviceId, {
327
+ t: 'streamError', id: entry.dlpId,
328
+ error: errorObject(error?.code ?? 'gateway/failed', error?.message ?? 'DSH stream failed',
329
+ error?.details ?? {}),
330
+ })
331
+ }
332
+
333
+ onStreamEnd(muxId) {
334
+ const entry = this.streams.removeByMux(muxId)
335
+ if (!entry) return
336
+ if (entry.endpoint === EVENTS_ENDPOINT) {
337
+ this.#forgetEvents(entry.deviceId)
338
+ return
339
+ }
340
+ this.send(entry.deviceId, { t: 'end', id: entry.dlpId })
341
+ }
342
+
343
+ onMuxDown(reason) {
344
+ // Every logical stream is gone; tell their devices so they can reopen.
345
+ this.logger.warn?.(`mobile-link: DSH mux down (${reason})`)
346
+ for (const entry of [...this.streams.byMux.values()]) {
347
+ if (entry.endpoint === EVENTS_ENDPOINT) continue
348
+ this.send(entry.deviceId, {
349
+ t: 'streamError', id: entry.dlpId,
350
+ error: errorObject('host/unavailable', 'the local DSH connection dropped'),
351
+ })
352
+ }
353
+ for (const record of this.devices.values()) this.#forgetEvents(record.deviceId)
354
+ this.streams = new StreamTable()
355
+ // Re-arm each device's $events stream against the fresh mux.
356
+ for (const deviceId of this.devices.keys()) void this.ensureEventsStream(deviceId)
357
+ this.#changed()
358
+ }
359
+
360
+ #forgetEvents(deviceId) {
361
+ const record = this.devices.get(deviceId)
362
+ if (!record) return
363
+ record.eventsMuxId = undefined
364
+ record.clientId = undefined
365
+ record.readyValue = undefined
366
+ }
367
+
368
+ /** Device inventory for `GET /mobile-link/status`. */
369
+ snapshot() {
370
+ return [...this.devices.values()].map((record) => ({
371
+ deviceId: record.deviceId,
372
+ name: record.name,
373
+ model: record.model,
374
+ connectedAt: record.connectedAt,
375
+ eventsReady: Boolean(record.clientId),
376
+ openStreams: record.dlpIds.size,
377
+ }))
378
+ }
379
+ }
package/lib/routes.js ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * HTTP routes under `/mobile-link`, registered on DSH's own web server.
3
+ *
4
+ * GET /mobile-link/status connection + device inventory + setup state
5
+ * POST /mobile-link/pair-code mint a pairing code (and its QR payload)
6
+ * GET /mobile-link/qr mint a pairing code and render it as a QR SVG
7
+ *
8
+ * All sit behind DSH's own request fence, exactly like dsh-plugin-desktop-shell:
9
+ * `ctx.get('connection')?.requestRejection?.(req)` performs the Host/Origin check
10
+ * and browser authentication. `/status` stays readable when the fence service is
11
+ * missing (it is diagnostic); the other two mint a credential, so they fail
12
+ * closed.
13
+ */
14
+
15
+ import { qrSvg } from './qr.js'
16
+
17
+ const MAX_BODY_BYTES = 64 * 1024
18
+
19
+ export function sendJson(res, status, payload) {
20
+ res.statusCode = status
21
+ res.setHeader('content-type', 'application/json; charset=utf-8')
22
+ res.setHeader('cache-control', 'no-store')
23
+ res.end(JSON.stringify(payload))
24
+ }
25
+
26
+ export function sendSvg(res, status, svg) {
27
+ res.statusCode = status
28
+ res.setHeader('content-type', 'image/svg+xml; charset=utf-8')
29
+ res.setHeader('cache-control', 'no-store')
30
+ res.end(svg)
31
+ }
32
+
33
+ export async function readJsonBody(req) {
34
+ const chunks = []
35
+ let size = 0
36
+ for await (const chunk of req) {
37
+ size += chunk.byteLength
38
+ if (size > MAX_BODY_BYTES) {
39
+ req.resume()
40
+ return undefined
41
+ }
42
+ chunks.push(chunk)
43
+ }
44
+ if (size === 0) return {}
45
+ try {
46
+ return JSON.parse(Buffer.concat(chunks, size).toString('utf8'))
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @returns {boolean} true when the request was rejected and the response ended.
54
+ */
55
+ export function makeGuard(ctx, logger) {
56
+ const connectionOf = () => (typeof ctx.get === 'function' ? ctx.get('connection') : undefined)
57
+ return function guard(req, res, { requireFence = false } = {}) {
58
+ let rejection
59
+ try {
60
+ rejection = connectionOf()?.requestRejection?.(req)
61
+ } catch (error) {
62
+ logger.warn?.(`mobile-link: request fence failed: ${error}`)
63
+ sendJson(res, 403, { ok: false, message: 'request rejected by the DSH fence' })
64
+ return true
65
+ }
66
+ if (rejection !== undefined && rejection !== null) {
67
+ res.statusCode = Number(rejection) || 403
68
+ res.end()
69
+ return true
70
+ }
71
+ if (requireFence && rejection === undefined) {
72
+ sendJson(res, 403, { ok: false, message: 'the DSH connection service is unavailable; refusing a privileged request' })
73
+ return true
74
+ }
75
+ return false
76
+ }
77
+ }
78
+
79
+ function ttlFrom(value, fallbackMs) {
80
+ return Number.isFinite(Number(value)) && Number(value) > 0
81
+ ? Math.min(Number(value), 60 * 60 * 1000)
82
+ : fallbackMs
83
+ }
84
+
85
+ /**
86
+ * Register the routes.
87
+ * @returns {Array<() => void>} disposers (already wrapped by the caller in ctx.effect).
88
+ */
89
+ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {} }) {
90
+ const guard = makeGuard(ctx, logger)
91
+ const STATUS_ROUTE = '/mobile-link/status'
92
+ const PAIR_ROUTE = '/mobile-link/pair-code'
93
+ const QR_ROUTE = '/mobile-link/qr'
94
+ const disposers = []
95
+
96
+ disposers.push(ctx.webServer.register({
97
+ kind: 'exact',
98
+ path: STATUS_ROUTE,
99
+ handler: async (req, res) => {
100
+ if (guard(req, res)) return
101
+ if (req.method !== 'GET') {
102
+ res.statusCode = 405
103
+ res.setHeader('allow', 'GET')
104
+ res.end()
105
+ return
106
+ }
107
+ try {
108
+ sendJson(res, 200, agent.status())
109
+ } catch (error) {
110
+ sendJson(res, 500, { ok: false, message: String(error?.message ?? error) })
111
+ }
112
+ },
113
+ }))
114
+
115
+ disposers.push(ctx.webServer.register({
116
+ kind: 'exact',
117
+ path: PAIR_ROUTE,
118
+ handler: async (req, res) => {
119
+ if (guard(req, res, { requireFence: true })) return
120
+ if (req.method !== 'POST') {
121
+ res.statusCode = 405
122
+ res.setHeader('allow', 'POST')
123
+ res.end()
124
+ return
125
+ }
126
+ const body = await readJsonBody(req)
127
+ if (body === undefined) {
128
+ sendJson(res, 400, { ok: false, message: 'invalid JSON body' })
129
+ return
130
+ }
131
+ const ttlMs = ttlFrom(body.ttlMs, Number(config.pairTtlMs) || 10 * 60 * 1000)
132
+ try {
133
+ const minted = await agent.mintPairCode({ ttlMs })
134
+ logger.info?.(`mobile-link: minted a pairing code for ${minted.agentId}`)
135
+ sendJson(res, 200, { ok: true, ...minted })
136
+ } catch (error) {
137
+ sendJson(res, 502, { ok: false, code: 'relay/unavailable', message: String(error?.message ?? error) })
138
+ }
139
+ },
140
+ }))
141
+
142
+ // The QR a phone camera can actually read. Rendered here rather than in the
143
+ // desktop UI so the pairing screen is one URL: anyone can open
144
+ // `http://127.0.0.1:<port>/mobile-link/qr` (with DSH's own authentication)
145
+ // and see a scannable code, with no client-side work and no extra dependency.
146
+ //
147
+ // `?format=json` returns the payload and the code without the picture, which
148
+ // is what the automated tests use.
149
+ disposers.push(ctx.webServer.register({
150
+ kind: 'exact',
151
+ path: QR_ROUTE,
152
+ handler: async (req, res) => {
153
+ if (guard(req, res, { requireFence: true })) return
154
+ if (req.method !== 'GET') {
155
+ res.statusCode = 405
156
+ res.setHeader('allow', 'GET')
157
+ res.end()
158
+ return
159
+ }
160
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
161
+ const ttlMs = ttlFrom(url.searchParams.get('ttlMs'), Number(config.pairTtlMs) || 10 * 60 * 1000)
162
+ let minted
163
+ try {
164
+ minted = await agent.mintPairCode({ ttlMs })
165
+ } catch (error) {
166
+ sendJson(res, 502, { ok: false, code: 'relay/unavailable', message: String(error?.message ?? error) })
167
+ return
168
+ }
169
+ const payload = minted.qrPayload
170
+ const wantsJson = (url.searchParams.get('format') ?? '').toLowerCase() === 'json'
171
+ logger.info?.(`mobile-link: rendered a pairing QR for ${minted.agentId}`)
172
+ if (wantsJson) {
173
+ sendJson(res, 200, { ok: true, ...minted, svg: qrSvg(payload) })
174
+ return
175
+ }
176
+ sendSvg(res, 200, qrSvg(payload))
177
+ },
178
+ }))
179
+
180
+ return disposers
181
+ }