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/README.md +10 -2
- package/cordis.patch.yml +35 -0
- package/lib/cli.js +6 -6
- package/lib/client.js +493 -0
- package/lib/diagnostics.js +172 -0
- package/lib/hello.js +19 -2
- package/lib/history/filter-scan.js +145 -0
- package/lib/history/records.js +125 -0
- package/lib/history/session-snapshot.js +119 -0
- package/lib/history/shared.js +25 -0
- package/lib/history/window.js +90 -0
- package/lib/history.js +401 -0
- package/lib/index.js +45 -6
- package/lib/journal.js +279 -0
- package/lib/link.js +179 -4
- package/lib/qr.js +34 -5
- package/lib/router.js +237 -2
- package/lib/routes.js +26 -55
- package/lib/session-digest.js +117 -0
- package/lib/status.js +28 -2
- package/package.json +5 -1
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',
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
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 =
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|