dsh-plugin-mobile-link 0.4.1 → 0.6.5

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/qr.js CHANGED
@@ -551,11 +551,19 @@ export function qrMatrix(text, { mask } = {}) {
551
551
  * One path element rather than thousands of rects: small enough to print in a
552
552
  * terminal response, and it renders identically. The four-module quiet zone is
553
553
  * not decoration — scanners need it to find the symbol at all.
554
+ *
555
+ * `caption` draws one line under the symbol. It exists because a pairing code
556
+ * expires and the page is only a picture: without the line, "还剩多久" is
557
+ * something a person has to guess, and a guess is what leads to scanning a code
558
+ * that is already gone. The font stack names the CJK faces explicitly — the
559
+ * default SVG font renders the caption as boxes on some systems.
554
560
  */
555
- export function qrSvg(text, { scale = 8, quietZone = 4, dark = '#111111', light = '#ffffff', mask } = {}) {
561
+ export function qrSvg(text, { scale = 8, quietZone = 4, dark = '#111111', light = '#ffffff',
562
+ mask, caption } = {}) {
556
563
  const matrix = qrMatrix(text, mask === undefined ? {} : { mask })
557
564
  const size = matrix.length
558
565
  const extent = (size + quietZone * 2) * scale
566
+ const captionHeight = caption ? Math.round(scale * 4.5) : 0
559
567
  const segments = []
560
568
  for (let row = 0; row < size; row += 1) {
561
569
  for (let column = 0; column < size; column += 1) {
@@ -565,14 +573,35 @@ export function qrSvg(text, { scale = 8, quietZone = 4, dark = '#111111', light
565
573
  segments.push(`M${x} ${y}h${scale}v${scale}h-${scale}z`)
566
574
  }
567
575
  }
568
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${extent}" height="${extent}"`
569
- + ` viewBox="0 0 ${extent} ${extent}" shape-rendering="crispEdges" role="img"`
576
+ const captionMarkup = caption
577
+ ? `<text x="${extent / 2}" y="${extent + Math.round(captionHeight * 0.7)}"`
578
+ + ` text-anchor="middle" font-family="PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif"`
579
+ + ` font-size="${Math.round(scale * 1.75)}" fill="${dark}">${escapeXml(caption)}</text>`
580
+ : ''
581
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${extent}" height="${extent + captionHeight}"`
582
+ + ` viewBox="0 0 ${extent} ${extent + captionHeight}" role="img"`
570
583
  + ` aria-label="DSH 配对二维码">`
571
- + `<rect width="${extent}" height="${extent}" fill="${light}"/>`
572
- + `<path d="${segments.join('')}" fill="${dark}"/>`
584
+ + `<rect width="${extent}" height="${extent + captionHeight}" fill="${light}"/>`
585
+ // crispEdges belongs to the symbol, not to the document: the caption is text,
586
+ // and rendering *that* without antialiasing is what makes it look broken.
587
+ + `<path d="${segments.join('')}" fill="${dark}" shape-rendering="crispEdges"/>`
588
+ + captionMarkup
573
589
  + '</svg>'
574
590
  }
575
591
 
592
+ /**
593
+ * The five characters that cannot appear literally in XML text.
594
+ *
595
+ * The caption is built from a clock and a count, but it is a parameter, so it is
596
+ * escaped here rather than trusted: an unescaped `&` produces an SVG the browser
597
+ * refuses to render at all — a pairing screen that shows nothing.
598
+ */
599
+ function escapeXml(value) {
600
+ return String(value).replace(/[&<>"']/g, (ch) => ({
601
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;',
602
+ }[ch]))
603
+ }
604
+
576
605
  /**
577
606
  * The same symbol for a terminal, two matrix rows per line of text.
578
607
  *
package/lib/router.js CHANGED
@@ -16,6 +16,8 @@ import { FileInbox, FILE_BEGIN, FILE_CHUNK, FILE_END, isFileMethod } from './fil
16
16
  import { FileFetcher, FSGET_CANCEL } from './files-out.js'
17
17
  import { GitBridge, isGitMethod } from './git.js'
18
18
  import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
19
+ import { isHistoryMethod } from './history.js'
20
+ import { trimRecordsForWire } from './history/records.js'
19
21
 
20
22
  /**
21
23
  * 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
@@ -74,6 +76,62 @@ export function errorObject(code, message, details = {}) {
74
76
  return { code, message, details }
75
77
  }
76
78
 
79
+ /**
80
+ * 旧分页通道(`session/page`)的下发形态清洗(016)。
81
+ *
82
+ * 这一路是**降级通道**:宿主自己回一页记录,连接器只是转发。它和精确窗口
83
+ * (`_link/sessionWindow`)必须守同一条口径 —— 只用于诊断的宿主模型输出日志
84
+ * 不下发(见 `specs/016-history-load-perf/contracts/history-record-trim.md`)。
85
+ *
86
+ * 两种形态都要认:宿主 RPC 的信封 `{ ok, value: { records } }`,以及直接给
87
+ * `{ records }` 的桩/测试形态。其余方法或结构一律原样返回,不做推断。
88
+ */
89
+ export function trimHistoryPageForWire(method, result) {
90
+ if (method !== 'session/page') return result
91
+ if (result === null || typeof result !== 'object') return result
92
+ const page = result.value ?? result
93
+ if (page === null || typeof page !== 'object' || !Array.isArray(page.records)) return result
94
+ const trimmed = { ...page, records: trimRecordsForWire(page.records) }
95
+ return result.value === undefined ? trimmed : { ...result, value: trimmed }
96
+ }
97
+
98
+ import { DeviceTimeline } from './diagnostics.js'
99
+ import { SESSION_DIGEST_METHOD, digestSessionList, encodeDigest } from './session-digest.js'
100
+
101
+ /** 慢请求阈值默认值(毫秒);0 = 关闭逐条慢请求记录。 */
102
+ export const DEFAULT_SLOW_REQUEST_MS = 2000
103
+
104
+ /** App 把自己那份阶段记录推上来的方法(契约 connector-diagnostics-method.md)。 */
105
+ export const DIAGNOSTICS_METHOD = '_link/diagnostics'
106
+ /** 单批上限:超过就截断并如实报 `accepted`,剩下的由 App 下次再推(水位不推进)。 */
107
+ export const DIAGNOSTICS_BATCH_BYTES = 32 * 1024
108
+ /** 单条事件里字符串字段的保留长度。 */
109
+ const DIAGNOSTICS_MAX_TEXT = 400
110
+
111
+ /**
112
+ * 只留标量(字符串/数字/布尔)与标量数组。
113
+ *
114
+ * **必须把 App 的 `detail` 摊平到顶层**:App 侧的 JSONL 信封是
115
+ * `{kind, seq, ms, at, detail:{...}}`(见 DSHKit 的 `LinkDiagnostics.Event`),而连接器
116
+ * 自己的流水是**扁平**的。不摊平的话 `phase`/`ms` 这些字段会在清洗时被当成嵌套对象丢掉——
117
+ * 实测症状:`app.dial` 落盘后一个字段都没有(2026-10-13 09:25 的中转运行)。
118
+ */
119
+ function sanitizeDiagnosticEvent(event) {
120
+ if (!event || typeof event !== 'object' || typeof event.kind !== 'string') return null
121
+ const { detail, ...envelope } = event
122
+ const flat = { ...envelope, ...(detail && typeof detail === 'object' ? detail : {}) }
123
+ const clean = { kind: event.kind }
124
+ for (const [key, value] of Object.entries(flat)) {
125
+ if (key === 'kind') continue
126
+ if (typeof value === 'string') clean[key] = value.slice(0, DIAGNOSTICS_MAX_TEXT)
127
+ else if (typeof value === 'number' || typeof value === 'boolean') clean[key] = value
128
+ else if (Array.isArray(value) && value.every((item) => typeof item === 'string' || typeof item === 'number')) {
129
+ clean[key] = value.slice(0, 32)
130
+ }
131
+ }
132
+ return clean
133
+ }
134
+
77
135
  export class DeviceRouter {
78
136
  /**
79
137
  * @param {object} options
@@ -83,13 +141,28 @@ export class DeviceRouter {
83
141
  */
84
142
  constructor({
85
143
  dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
86
- eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {},
144
+ eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {}, onEvent = () => {},
145
+ slowRequestMs = DEFAULT_SLOW_REQUEST_MS, diagnostics = true,
87
146
  } = {}) {
88
147
  this.dsh = dsh
89
148
  this.eventsBacklog = eventsBacklog
149
+ this.slowRequestMs = Number(slowRequestMs) || 0
150
+ this.diagnosticsEnabled = diagnostics !== false
151
+ /** 每个 deviceId 当前这次接入的计时(attach 时建、detach 时收)。 */
152
+ this.timelines = new Map()
153
+ /**
154
+ * 当前这次请求已经发出去的字节数(在 `noteSent` 里累加、在请求收尾时取走)。
155
+ *
156
+ * 同一设备上并发请求会把字节算到一起——诊断只要量级对得上("这次响应是几 KB 还是几 MB"),
157
+ * 不值得为它引入逐请求关联(那要改所有回包点的形状)。
158
+ */
159
+ this._sentBytes = new Map()
90
160
  // 「跑完了 / 待你回应」这类事件到了就报一声(见 `notifyFor`)。默认空函数,
91
161
  // 测试与不关心提醒的调用方可以不管它。帧由 link.js 发(router 不发帧)。
92
162
  this.notify = notify
163
+ // 生命周期事件(设备 attach/detach 等)的去处,由 link.js 接到流水(journal.js)。
164
+ // 默认空函数,所以 router 单独用时(单测、本地脚本)不需要日志设施。
165
+ this.onEvent = onEvent
93
166
  // Reported to the app so it can tell which wire revision it is talking to.
94
167
  this.protocolVersion = protocolVersion
95
168
  this.send = send
@@ -109,6 +182,9 @@ export class DeviceRouter {
109
182
  logger,
110
183
  })
111
184
  this.git = new GitBridge(logger)
185
+ // 历史窗口/检索(spec 007):由 index.js 注入宿主 `sessionQuery` 后交给它。
186
+ // 没绑上就回 `link/unsupported`,手机端退回 `session/page`。
187
+ this.history = undefined
112
188
  }
113
189
 
114
190
  #changed() {
@@ -152,9 +228,12 @@ export class DeviceRouter {
152
228
  return
153
229
  }
154
230
  switch (frame.t) {
155
- case 'req':
231
+ case 'req': {
232
+ const startedAt = this.now()
156
233
  await this.handleRequest(deviceId, frame)
234
+ this.#noteRequest(deviceId, frame.method, startedAt)
157
235
  return
236
+ }
158
237
  case 'open':
159
238
  await this.handleOpen(deviceId, frame)
160
239
  return
@@ -255,16 +334,117 @@ export class DeviceRouter {
255
334
 
256
335
  // ── device lifecycle ────────────────────────────────────────────────────
257
336
 
337
+ /**
338
+ * 记一条请求的耗时与字节(spec 020 的 FR-001–FR-003)。
339
+ *
340
+ * `bytes` 取"这次请求期间发出去的字节"(见 `noteSent`)——它是一个近似值,
341
+ * 只在同一设备上并发请求时才会串味,而诊断要回答的是"这次响应是几 KB 还是几 MB"。
342
+ */
343
+ #noteRequest(deviceId, method, startedAt) {
344
+ const timeline = this.timelines.get(deviceId)
345
+ if (!timeline) return
346
+ const bytes = this._sentBytes.get(deviceId) ?? 0
347
+ this._sentBytes.set(deviceId, 0)
348
+ timeline.noteRequest({ method, startedAt, ms: this.now() - startedAt, bytes })
349
+ }
350
+
351
+ /** 每发一帧给某台设备就累加它的字节数(由 `link.js` 的 `sendToDevice` 调用)。 */
352
+ noteSent(deviceId, bytes) {
353
+ if (!deviceId || !Number.isFinite(bytes)) return
354
+ this._sentBytes.set(deviceId, (this._sentBytes.get(deviceId) ?? 0) + bytes)
355
+ }
356
+
357
+ /**
358
+ * 收下 App 推来的阶段记录(spec 020 的 FR-011 / 契约 §2)。
359
+ *
360
+ * 三条硬规则:
361
+ * 1. **时刻照抄**:App 事件的 `at`/`ms` 是它自己钟上的读数,连接器不许改写
362
+ * (否则"手机侧几点了"这条信息就没了,而它正是区分"App 挂起"与"拨号卡住"的依据);
363
+ * 2. **按序号幂等**:同一批重推不会写两遍(水位记在设备记录上);
364
+ * 3. **超批上限就截断**:只收前若干条并如实报 `accepted`/`lastSeq`,剩下的下次再推——
365
+ * 绝不假装收全了。
366
+ *
367
+ * @returns {{ok: true, value: {accepted: number, lastSeq: number}}}
368
+ */
369
+ acceptDiagnostics(deviceId, args = {}) {
370
+ if (!this.diagnosticsEnabled) {
371
+ // 回退闸:关掉时明确拒绝,手机据此静默忽略(不影响连接)。
372
+ return { ok: false, error: errorObject('diagnostics/disabled', 'diagnostics intake is disabled on this connector') }
373
+ }
374
+ const record = this.devices.get(deviceId)
375
+ const watermark = Number(record?.diagnosticsSeq) || 0
376
+ const events = Array.isArray(args.events) ? args.events : []
377
+ let accepted = 0
378
+ let lastSeq = watermark
379
+ let bytes = 0
380
+ for (const raw of events) {
381
+ const event = sanitizeDiagnosticEvent(raw)
382
+ if (!event) continue
383
+ const seq = Number(event.seq)
384
+ if (Number.isFinite(seq) && seq <= watermark) continue
385
+ const size = JSON.stringify(event).length
386
+ if (bytes > 0 && bytes + size > DIAGNOSTICS_BATCH_BYTES) break
387
+ // 用 recordForeign:App 的时刻照抄(它是"手机侧几点"的唯一来源)。
388
+ // 补 `deviceId`:App 自己不知道它的 id,而多设备时没有这一列就没法把事件归到某台手机上。
389
+ this.onEvent(event.kind, { ...event, deviceId, source: 'app' }, { foreign: true })
390
+ bytes += size
391
+ accepted += 1
392
+ if (Number.isFinite(seq)) lastSeq = Math.max(lastSeq, seq)
393
+ }
394
+ if (record) record.diagnosticsSeq = lastSeq
395
+ const sentAt = Number(args.sentAt)
396
+ this.onEvent('app.push', {
397
+ deviceId,
398
+ count: accepted,
399
+ dropped: Number(args.dropped) || 0,
400
+ sentAt: Number.isFinite(sentAt) ? sentAt : undefined,
401
+ // 手机时钟与本机时钟的差(单向延迟几十毫秒);采集侧的对齐就靠这一个数。
402
+ skewMs: Number.isFinite(sentAt) ? this.now() - sentAt : undefined,
403
+ })
404
+ return { ok: true, value: { accepted, lastSeq } }
405
+ }
406
+
407
+ /** 起一次新的接入计时(回头客也重起:那确实是一次新连接)。 */
408
+ #startTimeline(deviceId) {
409
+ this.timelines.get(deviceId)?.finish()
410
+ this.timelines.set(deviceId, new DeviceTimeline({
411
+ deviceId,
412
+ slowRequestMs: this.slowRequestMs,
413
+ now: this.now,
414
+ onEvent: this.onEvent,
415
+ }))
416
+ this._sentBytes.set(deviceId, 0)
417
+ }
418
+
419
+ /** 收掉一次接入计时(断开、撤销、进程收尾都走这里)。 */
420
+ #finishTimeline(deviceId) {
421
+ const timeline = this.timelines.get(deviceId)
422
+ if (!timeline) return
423
+ timeline.finish()
424
+ this.timelines.delete(deviceId)
425
+ this._sentBytes.delete(deviceId)
426
+ }
427
+
258
428
  async attachDevice(deviceId, info = {}) {
259
429
  if (!nonEmptyString(deviceId)) return
430
+ this.#startTimeline(deviceId)
260
431
  const existing = this.devices.get(deviceId)
261
432
  if (existing) {
262
433
  // 回头客:手机又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
263
434
  if (existing.offlineSince !== undefined) {
435
+ const offlineForMs = this.now() - existing.offlineSince
264
436
  existing.offlineSince = undefined
437
+ this.onEvent('device.reattach', { deviceId, offlineForMs, pending: existing.eventsPending.size })
265
438
  this.logger.info?.(
266
439
  `mobile-link: device ${deviceId} reattached with ${existing.eventsPending.size} unanswered question(s)`)
267
440
  this.#changed()
441
+ } else {
442
+ // **这条是"同一台设备同时又开了一条"**:中转的 `attach_device` 会直接覆盖旧
443
+ // 链路,旧的那条既不会再收到 `deviceDetach`、也不会出现在任何一方的状态里
444
+ // (2026-10-13 01:10 实测:attach 与上一条 detach 之间空了 34 秒)。记下来,
445
+ // 否则事后只看得到"它连上了",看不到"它连了两条"。
446
+ this.onEvent('device.attach-again', { deviceId, connectedForMs: this.now() - existing.connectedAt })
447
+ this.logger.warn?.(`mobile-link: device ${deviceId} attached again while still attached`)
268
448
  }
269
449
  return
270
450
  }
@@ -284,6 +464,7 @@ export class DeviceRouter {
284
464
  offlineSince: undefined,
285
465
  eventsPending: new Map(),
286
466
  })
467
+ this.onEvent('device.attach', { deviceId, name: info?.name, model: info?.model })
287
468
  this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
288
469
  this.#changed()
289
470
  await this.ensureEventsStream(deviceId)
@@ -300,10 +481,12 @@ export class DeviceRouter {
300
481
  * 那段的说明)。这也是「撤销即放下」是唯一出口的原因。
301
482
  */
302
483
  async detachDevice(deviceId, reason) {
484
+ this.#finishTimeline(deviceId)
303
485
  const record = this.devices.get(deviceId)
304
486
  if (!record) return
305
487
  if (reason === 'revoked') {
306
488
  await this.#dropDevice(deviceId)
489
+ this.onEvent('device.revoked', { deviceId })
307
490
  this.logger.info?.(`mobile-link: device ${deviceId} 已被撤销,放下(不再替它值班)`)
308
491
  return
309
492
  }
@@ -322,6 +505,7 @@ export class DeviceRouter {
322
505
  for (const entry of keep) record.dlpIds.add(entry.dlpId)
323
506
 
324
507
  if (record.offlineSince === undefined) record.offlineSince = this.now()
508
+ this.onEvent('device.detach', { deviceId, reason: reason ?? 'closed' })
325
509
  this.logger.info?.(
326
510
  `mobile-link: device ${deviceId} detached (${reason ?? 'closed'});`
327
511
  + '$events 流一直替它留着,直到设备被撤销或进程退出')
@@ -362,16 +546,48 @@ export class DeviceRouter {
362
546
  teardownAll() {
363
547
  const muxIds = []
364
548
  for (const deviceId of [...this.devices.keys()]) {
549
+ // 进程收尾/链路重建也要把这次接入的汇总写掉,否则"连上过、发过请求"这件事没有数字。
550
+ this.#finishTimeline(deviceId)
365
551
  muxIds.push(...this.streams.removeDevice(deviceId))
366
552
  this.devices.delete(deviceId)
367
553
  }
368
554
  this.streams = new StreamTable()
555
+ // 不该有残留:上面的循环已经逐个收过;这里兜底,防止 attach 之后没进 devices 的路径留下脏项。
556
+ this.timelines.clear()
557
+ this._sentBytes.clear()
369
558
  return muxIds
370
559
  }
371
560
 
372
561
  // ── device requests ─────────────────────────────────────────────────────
373
562
 
374
563
  async handleRequest(deviceId, frame) {
564
+ // App 推上来的阶段记录:**只落盘,不影响任何连接行为**(契约
565
+ // connector-diagnostics-method.md §4)。放在最前面,免得被后面的方法族顺序影响。
566
+ // 精简版会话列表(spec 021):同一份宿主数据,去掉 App 侧零读取点的字段,可按要求 gzip。
567
+ // 老方法 `session/list` 一个字节都不改——老版本手机继续走它,这是兼容的前提。
568
+ if (frame.method === SESSION_DIGEST_METHOD) {
569
+ const args = frame.args ?? {}
570
+ let result
571
+ try {
572
+ result = await this.dsh.rpc('session/list', { _request: args._request ?? {} })
573
+ } catch (error) {
574
+ result = { ok: false, error: errorObject('host/unavailable', messageOf(error)) }
575
+ this.lastError = messageOf(error)
576
+ this.#changed()
577
+ }
578
+ const payload = result?.ok === true
579
+ ? { ok: true, value: encodeDigest(digestSessionList(result), args.encoding) }
580
+ : result
581
+ this.send(deviceId, resultFrame(frame.id, payload, deviceId))
582
+ return
583
+ }
584
+
585
+ if (frame.method === DIAGNOSTICS_METHOD) {
586
+ const result = this.acceptDiagnostics(deviceId, frame.args ?? {})
587
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
588
+ return
589
+ }
590
+
375
591
  // Files the phone sends are answered here rather than forwarded: the Host
376
592
  // has no upload endpoint, and carrying them on the existing unary frame
377
593
  // means neither the relay nor the protocol has to change.
@@ -433,9 +649,28 @@ export class DeviceRouter {
433
649
  return
434
650
  }
435
651
 
652
+ // History reads run here too (spec 007): the Host owns a seq-exact reader but
653
+ // does not expose it on the remote surface the phone talks to. Answered
654
+ // locally when the injected `sessionQuery` is present; otherwise the phone is
655
+ // told plainly that the capability is missing, so it can keep using
656
+ // `session/page` instead of reading the failure as "no older history".
657
+ if (isHistoryMethod(frame.method)) {
658
+ let result
659
+ try {
660
+ result = this.history === undefined
661
+ ? { ok: false, error: errorObject('link/unsupported', 'history reads are unavailable on this connector') }
662
+ : await this.history.handle(frame.method, frame.args ?? {})
663
+ } catch (error) {
664
+ result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
665
+ }
666
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
667
+ return
668
+ }
669
+
436
670
  let result
437
671
  try {
438
672
  result = await this.dsh.rpc(frame.method, frame.args ?? {})
673
+ result = trimHistoryPageForWire(frame.method, result)
439
674
  } catch (error) {
440
675
  result = { ok: false, error: errorObject('host/unavailable', messageOf(error)) }
441
676
  this.lastError = messageOf(error)
package/lib/routes.js CHANGED
@@ -2,8 +2,7 @@
2
2
  * HTTP routes under `/mobile-link`, registered on DSH's own web server.
3
3
  *
4
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
5
+ * POST /mobile-link/pair-code mint a pairing code (and its QR payload + SVG)
7
6
  *
8
7
  * All sit behind DSH's own request fence, like every other plugin route:
9
8
  * `ctx.get('connection')?.requestRejection?.(req)` performs the Host/Origin check
@@ -23,13 +22,6 @@ export function sendJson(res, status, payload) {
23
22
  res.end(JSON.stringify(payload))
24
23
  }
25
24
 
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
25
  export async function readJsonBody(req) {
34
26
  const chunks = []
35
27
  let size = 0
@@ -55,9 +47,25 @@ export async function readJsonBody(req) {
55
47
  export function makeGuard(ctx, logger) {
56
48
  const connectionOf = () => (typeof ctx.get === 'function' ? ctx.get('connection') : undefined)
57
49
  return function guard(req, res, { requireFence = false } = {}) {
50
+ const connection = connectionOf()
51
+ const fence = connection?.requestRejection
52
+ // DSH's contract is "a status when refused, **undefined** when the request
53
+ // may proceed" — so undefined is a pass, not an absence. Reading it as an
54
+ // absence is what made every privileged route below answer 403 to everyone
55
+ // with a perfectly authenticated request: the pairing page and
56
+ // `POST /pair-code` never got past the guard. The service's own presence is
57
+ // the only thing that can tell "no fence to consult" apart from "fence says
58
+ // yes", so that is what is checked here.
59
+ if (typeof fence !== 'function') {
60
+ if (requireFence) {
61
+ sendJson(res, 403, { ok: false, message: 'the DSH connection service is unavailable; refusing a privileged request' })
62
+ return true
63
+ }
64
+ return false
65
+ }
58
66
  let rejection
59
67
  try {
60
- rejection = connectionOf()?.requestRejection?.(req)
68
+ rejection = fence.call(connection, req)
61
69
  } catch (error) {
62
70
  logger.warn?.(`mobile-link: request fence failed: ${error}`)
63
71
  sendJson(res, 403, { ok: false, message: 'request rejected by the DSH fence' })
@@ -68,10 +76,6 @@ export function makeGuard(ctx, logger) {
68
76
  res.end()
69
77
  return true
70
78
  }
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
79
  return false
76
80
  }
77
81
  }
@@ -86,11 +90,10 @@ function ttlFrom(value, fallbackMs) {
86
90
  * Register the routes.
87
91
  * @returns {Array<() => void>} disposers (already wrapped by the caller in ctx.effect).
88
92
  */
89
- export function registerMobileLinkRoutes(ctx, { agent, logger, config = {} }) {
93
+ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, history }) {
90
94
  const guard = makeGuard(ctx, logger)
91
95
  const STATUS_ROUTE = '/mobile-link/status'
92
96
  const PAIR_ROUTE = '/mobile-link/pair-code'
93
- const QR_ROUTE = '/mobile-link/qr'
94
97
  const disposers = []
95
98
 
96
99
  disposers.push(ctx.webServer.register({
@@ -132,48 +135,16 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {} }) {
132
135
  try {
133
136
  const minted = await agent.mintPairCode({ ttlMs })
134
137
  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 })
138
+ // The picture travels with the code. It used to be a route of its own
139
+ // (`GET /mobile-link/qr`) that a person opened in a browser; the pairing
140
+ // entry is now the connector's own panel in the DSH client, so the one
141
+ // caller left is that panel, and it wants the code and the symbol in the
142
+ // same answer. No caption: the panel runs its own countdown, and a
143
+ // server-rendered "还剩 N 分钟" freezes the moment the window is up.
144
+ sendJson(res, 200, { ok: true, ...minted, svg: qrSvg(minted.qrPayload) })
165
145
  } catch (error) {
166
146
  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
147
  }
176
- sendSvg(res, 200, qrSvg(payload))
177
148
  },
178
149
  }))
179
150
 
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `_link/sessionDigest`:`session/list` 的精简同形副本(spec 021)。
3
+ *
4
+ * 为什么要有它:`session/list` 占经中转下行字节的 **96%**,一次固定 714 KB,而中转出口实测只有
5
+ * 16–112 KB/s(2026-10-13 现场诊断)。其中 **58.8% 的字节是 App/DSHKit 从来不读的字段**
6
+ * (逐字段扫 79 个源文件核对,清单见 `contracts/session-digest.md` §4)。
7
+ *
8
+ * 三条硬约束:
9
+ * 1. **不动 `session/list`**:老 App 只认它,字段删掉就没有回退路径。
10
+ * 2. **不改入参**:宿主返回什么就基于它生成新对象,方便"同一份数据两条路径"逐字节对比。
11
+ * 3. **只删名单里的**:名单之外的字段(包括以后宿主新增的)一律原样透传,避免"裁剪"变成"猜"。
12
+ */
13
+
14
+ import { deflateRawSync } from 'node:zlib'
15
+
16
+ /** 方法名(设备发起,中转不校验方法名,所以不需要中转改动)。 */
17
+ export const SESSION_DIGEST_METHOD = '_link/sessionDigest'
18
+
19
+ /** 会话项里剔掉的字段:零读取点(`agentAvailable` 1.8%)。 */
20
+ export const DROPPED_ITEM_KEYS = Object.freeze(['agentAvailable'])
21
+
22
+ /**
23
+ * 投影键里剔掉的字段(合计 56.7%)。
24
+ *
25
+ * 每一条都能在 App/DSHKit 里复查"零读取点":
26
+ * `turnOutline` 33.4%(每轮 prompt/response 原文)、`imageLimits` 6.2%(上传限制)、
27
+ * `sessionStats` 4.2%、`todos` 2.6%、`subagentCatalog` 2.6%、`contextBreakdown` 2.5%、
28
+ * `sessionListMetadata` 1.9%(唯一被读的 `blank` 在顶层已有同名字段)、
29
+ * `subagentTiming` 1.5%、`agentPreset` 0.8%(`SessionCreateRequest.agentPreset` 是请求字段)。
30
+ */
31
+ export const DROPPED_PROJECTION_KEYS = Object.freeze([
32
+ 'turnOutline', 'imageLimits', 'sessionStats', 'todos', 'subagentCatalog',
33
+ 'contextBreakdown', 'sessionListMetadata', 'subagentTiming', 'agentPreset',
34
+ ])
35
+
36
+ /**
37
+ * 只接受的压缩编码;其它一律按不压缩处理(保守,不因为多写一个字段就让调用方失败)。
38
+ *
39
+ * `deflate-raw` = RFC1951 原始 DEFLATE,**不带** gzip/zlib 包装。选它而不是 gzip 的原因是
40
+ * 手机侧:Apple 的 `Compression` 框架(`COMPRESSION_ZLIB`)一行 `compression_decode_buffer`
41
+ * 就能解开原始 DEFLATE,而 gzip 包装它不认,得引 libz 的 `inflateInit2(MAX_WBITS+16)` 做 C 互操作。
42
+ * 两者压缩比实测相同(239 KiB → 36.8 KiB)。
43
+ */
44
+ export const DEFLATE_ENCODING = 'deflate-raw'
45
+ export const IDENTITY_ENCODING = 'identity'
46
+
47
+ /** 按原键序复制并跳过剔除名单:键序稳定才能做"逐字节对比"。 */
48
+ function omit(source, dropped) {
49
+ const out = {}
50
+ for (const key of Object.keys(source)) {
51
+ if (dropped.has(key)) continue
52
+ out[key] = source[key]
53
+ }
54
+ return out
55
+ }
56
+
57
+ /**
58
+ * 裁剪一份会话列表的**值**(`{ items: [...] }`)。
59
+ *
60
+ * 认两种输入(与 `trimHistoryPageForWire` 同款):宿主信封 `{ ok, value: { items } }`,
61
+ * 以及直接给的 `{ items }`。**返回的始终是列表值本身**,调用方不需要再猜信封在哪一层
62
+ * (曾经因为这里保留信封、外面又包一层,响应成了 `{encoding, value:{ok, value:{items}}}`)。
63
+ *
64
+ * 结构不是预期的样子就**原样返回入参**——裁剪不许把"看不懂"变成"删掉"。
65
+ *
66
+ * @param {unknown} result 宿主响应或其 `value`
67
+ * @returns {unknown} 裁剪后的列表值;不合法时原样返回入参
68
+ */
69
+ export function digestSessionList(result) {
70
+ if (result === null || typeof result !== 'object') return result
71
+ const container = Array.isArray(result.items) ? result : result.value
72
+ if (container === null || typeof container !== 'object' || !Array.isArray(container.items)) {
73
+ return result
74
+ }
75
+ return { ...container, items: container.items.map(digestItem) }
76
+ }
77
+
78
+ /** 单条会话:剔顶层字段与投影键;`projections` 缺失或形状不对就原样留着。 */
79
+ function digestItem(item) {
80
+ if (item === null || typeof item !== 'object') return item
81
+ const out = omit(item, new Set(DROPPED_ITEM_KEYS))
82
+ const projections = item.projections
83
+ if (projections === null || typeof projections !== 'object') return out
84
+ // `kind: "sequenced"` 是信封常量,App 侧模型没解码它(1.3%)。
85
+ const envelope = omit(projections, new Set(['kind']))
86
+ const values = projections.values
87
+ if (values !== null && typeof values === 'object') {
88
+ envelope.values = omit(values, new Set(DROPPED_PROJECTION_KEYS))
89
+ }
90
+ out.projections = envelope
91
+ return out
92
+ }
93
+
94
+ /**
95
+ * 按调用方要求编码裁剪后的值。
96
+ *
97
+ * **只在应用层压**:中转的 `WebSocketResponse(compress=False)`,手机侧
98
+ * `URLSessionWebSocketTask` 也不支持 permessage-deflate,所以 WS 层压缩救不了手机那一跳。
99
+ * 压缩后的字节 base64 放进 JSON 文本帧(与 `_link/diagnostics` 同款),中转不需要任何改动。
100
+ *
101
+ * `rawBytes` 是**解压后的字节长度**:Apple 的 `compression_decode_buffer` 必须预先给足输出缓冲区,
102
+ * 带上它调用方就能一次分配到位,不必"猜容量、失败再翻倍重试"。
103
+ *
104
+ * @param {unknown} value 裁剪后的值
105
+ * @param {unknown} encoding 调用方要求;只认 `"deflate-raw"`
106
+ * @returns {{encoding: string, value: unknown, rawBytes?: number}} 带回执,调用方据此决定解不解压
107
+ */
108
+ export function encodeDigest(value, encoding) {
109
+ if (encoding !== DEFLATE_ENCODING) return { encoding: IDENTITY_ENCODING, value }
110
+ const json = JSON.stringify(value ?? null)
111
+ const raw = Buffer.from(json, 'utf8')
112
+ return {
113
+ encoding: DEFLATE_ENCODING,
114
+ value: deflateRawSync(raw).toString('base64'),
115
+ rawBytes: raw.length,
116
+ }
117
+ }