dsh-plugin-mobile-link 0.6.0 → 0.6.6

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/journal.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * 链路与设备的生命周期流水。
3
+ *
4
+ * 为什么需要它(2026-10-12/13 那次"手机连不上"的教训):连接器的日志走宿主
5
+ * `ctx.logger`,而宿主既不落盘也不保留历史,于是"连接器为什么把这条中转链路拆了"
6
+ * 只能靠中转侧日志反推——`lastError` 只留最后一条,中间发生过几次拆链、每次什么原因,
7
+ * 事后一概查不到。这一族事件就是补这个洞。
8
+ *
9
+ * 两条出口,用途不同:
10
+ * * **内存环**(定长,默认 200 条):随 `/mobile-link/status` 一起读,正在跑的时候看;
11
+ * * **JSONL 文件**(可选,默认写在身份文件旁边,超过上限滚动一次):进程重启后还在,
12
+ * 用于事后复盘——重连风暴的现场往往在重启之后就没了。
13
+ *
14
+ * 硬约束:**这里任何一步都不许抛**。它是诊断设施,不能因为磁盘满、权限错、
15
+ * 路径怪就把链路带崩;写失败只记一条 `journal.error` 然后闭嘴。
16
+ */
17
+
18
+ import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
19
+ import { dirname, join } from 'node:path'
20
+
21
+ import { defaultStatePath } from './state.js'
22
+
23
+ /** 内存环里保留多少条。 */
24
+ export const DEFAULT_LIMIT = 200
25
+ /** 单个 JSONL 文件的上限;超了就滚动成 `<path>.1`,所以磁盘占用上限约两倍。 */
26
+ export const DEFAULT_MAX_BYTES = 256 * 1024
27
+ /** 单条事件序列化后的上限;见 `#shrink` 的两步处理。 */
28
+ export const MAX_EVENT_BYTES = 2048
29
+
30
+ /**
31
+ * 事件目录(`kind` 词表)——**与契约逐字一致**,由 `npm run check:contracts` 机械核对。
32
+ *
33
+ * 契约:`docs/diagnostics-events.md`(§2 既有 + §3 新增 + 内部)。
34
+ * 为什么把词表放进代码:写入侧需要一个能自检的集合(防止手滑写错 kind 后没人发现),
35
+ * 而插件是独立发布的包,不能运行时去读仓库里的 spec 文件。
36
+ */
37
+ export const EVENT_KINDS = Object.freeze([
38
+ // §2 既有(本次之前就有,语义不变)
39
+ 'link.start',
40
+ 'link.open',
41
+ 'link.close',
42
+ 'link.backoff',
43
+ 'link.failed',
44
+ 'link.unregistered',
45
+ 'link.stop',
46
+ 'watchdog.repull',
47
+ 'watchdog.drop',
48
+ 'dsh.ok',
49
+ 'dsh.unreachable',
50
+ 'device.attach',
51
+ 'device.reattach',
52
+ 'device.attach-again',
53
+ 'device.detach',
54
+ 'device.revoked',
55
+ // §3 本次新增
56
+ 'device.phase',
57
+ 'request.slow',
58
+ 'device.summary',
59
+ 'app.dial',
60
+ 'app.hello',
61
+ 'app.first-request',
62
+ 'app.first-response',
63
+ 'app.request',
64
+ 'app.first-screen',
65
+ 'app.first-render',
66
+ 'app.scene',
67
+ 'app.push',
68
+ // 内部:写失败与超大条目自报(不在契约的对外表里,但同属写入侧词表)
69
+ 'journal.error',
70
+ ])
71
+
72
+ /** 信封字段:detail 里的同名键一律忽略(否则会把时刻/事件名冲掉)。 */
73
+ const PROTECTED_KEYS = new Set(['at', 'ms', 'kind'])
74
+
75
+ /** 单条事件里字符串值的最长保留长度(`#shrink` 用)。 */
76
+ const MAX_STRING_CHARS = 200
77
+
78
+
79
+ /** 默认落在身份文件旁边(`$DSH_HOME/mobile-link/`),测试用临时 DSH_HOME 时会自动隔离。 */
80
+ export function defaultJournalPath() {
81
+ return join(dirname(defaultStatePath()), 'link-events.jsonl')
82
+ }
83
+
84
+ /**
85
+ * 把一条事件序列化成一行 JSON。
86
+ *
87
+ * 同时给 `at`(给人看的 ISO 8601)与 `ms`(给脚本比对的整数),因为这两种读者都存在:
88
+ * 人 grep 时想直接看到时刻,脚本算间隔时不想解析日期。
89
+ */
90
+ function toLine(entry) {
91
+ try {
92
+ return `${JSON.stringify(entry)}\n`
93
+ } catch {
94
+ // 循环引用之类的 detail 不该让整条事件丢掉:退化成只留 kind。
95
+ return `${JSON.stringify({ at: entry.at, ms: entry.ms, kind: entry.kind })}\n`
96
+ }
97
+ }
98
+
99
+ export class LinkJournal {
100
+ /**
101
+ * @param {object} [options]
102
+ * @param {string} [options.path] JSONL 路径;不传=只留内存,不碰磁盘
103
+ * @param {number} [options.limit] 内存环容量
104
+ * @param {number} [options.maxBytes] 单个文件上限
105
+ * @param {() => number} [options.now] 取时刻(测试注入)
106
+ * @param {{ debug?: (message: string) => void }} [options.logger]
107
+ */
108
+ constructor({
109
+ path, limit = DEFAULT_LIMIT, maxBytes = DEFAULT_MAX_BYTES,
110
+ now = Date.now, logger,
111
+ } = {}) {
112
+ this._path = typeof path === 'string' && path.trim() ? path : undefined
113
+ this.maxBytes = Math.max(1024, Number(maxBytes) || DEFAULT_MAX_BYTES)
114
+ this.limit = Math.max(1, Number(limit) || DEFAULT_LIMIT)
115
+ this.now = now
116
+ this.logger = logger
117
+ /** @type {Array<Record<string, unknown>>} */
118
+ this._entries = []
119
+ /** 因为环满而被挤掉的条数:读写两侧都不是无损的,得让读者知道。 */
120
+ this.dropped = 0
121
+ /** 落盘写坏了几次(第一次就停手,避免刷屏)。 */
122
+ this.fileErrors = 0
123
+ this._bytes = undefined
124
+ this._fileBroken = false
125
+ /** 写入过的总条数(含被挤掉的)。 */
126
+ this.recorded = 0
127
+ }
128
+
129
+ /** 落盘路径;`null` 表示这份流水只在内存里。 */
130
+ get path() {
131
+ return this._path ?? null
132
+ }
133
+
134
+ /**
135
+ * 记一条。**永不抛**。
136
+ *
137
+ * @param {string} kind 事件名(`link.open` / `device.detach` …),未知取值允许写入
138
+ * (老读者必须忽略不认识的 kind),但单测会盯住目录漂移
139
+ * @param {Record<string, unknown>} [detail] 该事件自带的字段(`undefined` 一律丢弃)
140
+ * @returns {Record<string, unknown>} 写进去的那条(测试要断言,所以返回)
141
+ */
142
+ record(kind, detail) {
143
+ const entry = this.#build(kind, detail)
144
+ const line = toLine(entry)
145
+ // 两条分支必须返回同一形状:小的直接过,大的走两步降级。
146
+ const sized = Buffer.byteLength(line) <= MAX_EVENT_BYTES ? { entry, line } : this.#shrink(entry)
147
+ this.#push(sized.entry)
148
+ this.#append(sized.line)
149
+ return sized.entry
150
+ }
151
+
152
+ /**
153
+ * 组装条目:信封 + 过滤掉 `undefined` 的 detail。
154
+ *
155
+ * **信封字段受保护**:`at`/`ms`/`kind` 不会被 detail 覆盖。这条是 2026-10-13 踩出来的——
156
+ * 当时 `request.slow`/`app.hello` 的 detail 里也有一个 `ms`(含义是**耗时**),
157
+ * 摊平后把事件的**时刻**冲掉了,时间线上那条事件会跑到 1970 年。耗时一律叫 `elapsedMs`。
158
+ */
159
+ #build(kind, detail) {
160
+ const ms = this.now()
161
+ const entry = { at: new Date(ms).toISOString(), ms, kind }
162
+ if (detail && typeof detail === 'object') {
163
+ for (const [key, value] of Object.entries(detail)) {
164
+ if (value === undefined || PROTECTED_KEYS.has(key)) continue
165
+ entry[key] = value
166
+ }
167
+ }
168
+ return entry
169
+ }
170
+
171
+ /**
172
+ * 记一条**别人钟上**的事件(App 推上来的那些):时刻用给定的值,不用本机时钟。
173
+ *
174
+ * 单独一个入口而不是"让 detail 覆盖 ms":覆盖是隐式的,谁都能不小心改掉时刻;
175
+ * 这里只有显式调用才会保留外部时刻。
176
+ */
177
+ recordForeign(kind, { at, ms, ...detail } = {}) {
178
+ const stamp = Number.isFinite(ms) ? ms : this.now()
179
+ const entry = { at: typeof at === 'string' && at ? at : new Date(stamp).toISOString(), ms: stamp, kind }
180
+ for (const [key, value] of Object.entries(detail)) {
181
+ if (value === undefined || PROTECTED_KEYS.has(key)) continue
182
+ entry[key] = value
183
+ }
184
+ const line = toLine(entry)
185
+ const sized = Buffer.byteLength(line) <= MAX_EVENT_BYTES ? { entry, line } : this.#shrink(entry)
186
+ this.#push(sized.entry)
187
+ this.#append(sized.line)
188
+ return sized.entry
189
+ }
190
+
191
+ /** 记进内存环(磁盘那半由 `record` 负责)。 */
192
+ #push(entry) {
193
+ this._entries.push(entry)
194
+ this.recorded += 1
195
+ while (this._entries.length > this.limit) {
196
+ this._entries.shift()
197
+ this.dropped += 1
198
+ }
199
+ }
200
+
201
+ /**
202
+ * 超大条目的两步降级:先把长字符串截短(保住这一条),仍超限则丢掉它并自报一条 `journal.error`。
203
+ *
204
+ * 为什么不在写入侧直接抛/直接丢:诊断设施最怕"因为自己太胖把现场弄没了"。
205
+ * 截断会留 `truncated: true`,读者知道这条不完整;真的截不动(例如深嵌套对象)才丢,且丢也要留痕。
206
+ */
207
+ #shrink(entry) {
208
+ const shortened = { ...entry }
209
+ for (const [key, value] of Object.entries(shortened)) {
210
+ if (typeof value === 'string' && value.length > MAX_STRING_CHARS) {
211
+ shortened[key] = `${value.slice(0, MAX_STRING_CHARS)}…`
212
+ }
213
+ }
214
+ shortened.truncated = true
215
+ const line = toLine(shortened)
216
+ if (Buffer.byteLength(line) <= MAX_EVENT_BYTES) return { entry: shortened, line }
217
+ const fallback = this.#build('journal.error', {
218
+ // 字段名不能叫 `kind`:`#build` 会把 detail 的键盖到信封上,那样这条自报就冒充成原事件了。
219
+ originalKind: entry.kind,
220
+ bytes: Buffer.byteLength(toLine(entry)),
221
+ reason: 'event too large even after truncation',
222
+ })
223
+ return { entry: fallback, line: toLine(fallback) }
224
+ }
225
+
226
+ /** 最近的 `count` 条,最旧在前。 */
227
+ recent(count = this.limit) {
228
+ const size = Math.max(0, Number(count) || 0)
229
+ return this._entries.slice(Math.max(0, this._entries.length - size))
230
+ }
231
+
232
+ /** 内存环里现有的全部条目(副本)。 */
233
+ entries() {
234
+ return this._entries.slice()
235
+ }
236
+
237
+ #append(line) {
238
+ if (!this._path || this._fileBroken) return
239
+ try {
240
+ if (this._bytes === undefined) {
241
+ mkdirSync(dirname(this._path), { recursive: true, mode: 0o700 })
242
+ this._bytes = this.#sizeOf(this._path)
243
+ }
244
+ const bytes = Buffer.byteLength(line)
245
+ if (this._bytes > 0 && this._bytes + bytes > this.maxBytes) this.#rotate()
246
+ appendFileSync(this._path, line, { mode: 0o600 })
247
+ this._bytes += bytes
248
+ } catch (error) {
249
+ this.fileErrors += 1
250
+ this._fileBroken = true
251
+ this.logger?.debug?.(`mobile-link: link journal disabled (${error?.message ?? error})`)
252
+ }
253
+ }
254
+
255
+ #sizeOf(path) {
256
+ try {
257
+ return statSync(path).size
258
+ } catch {
259
+ return 0
260
+ }
261
+ }
262
+
263
+ /** 滚一次:现文件改名成 `<path>.1`,旧的那份直接丢掉(只保两代)。 */
264
+ #rotate() {
265
+ const archived = `${this._path}.1`
266
+ try {
267
+ renameSync(this._path, archived)
268
+ } catch {
269
+ // Windows 上改名到已存在的目标会失败;删掉目标再试一次。
270
+ try {
271
+ unlinkSync(archived)
272
+ renameSync(this._path, archived)
273
+ } catch (error) {
274
+ throw error
275
+ }
276
+ }
277
+ this._bytes = 0
278
+ }
279
+ }
package/lib/link.js CHANGED
@@ -20,13 +20,14 @@ import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl
20
20
  const LIVE_AGENT = Symbol.for('dsh.mobile-link.live-agent')
21
21
  import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
22
22
  import { handoffFromEndpoint, writeHandoff } from './handoff.js'
23
+ import { LinkJournal, defaultJournalPath } from './journal.js'
23
24
  import { statusSnapshot } from './status.js'
24
- import { mintPairCode } from './pairing.js'
25
+ import { mintPairCode, regeneratePairCode } from './pairing.js'
25
26
  import {
26
- DEFAULT_EVENTS_BACKLOG,
27
+ DEFAULT_EVENTS_BACKLOG, DEFAULT_SLOW_REQUEST_MS,
27
28
  EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
28
29
  } from './router.js'
29
- import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
30
+ import { SERVER_VERSION, SERVER_CAPABILITIES, hostVersion } from './hello.js'
30
31
  import { MissingIdentity, resolveIdentity } from './state.js'
31
32
  import { connect as wsConnect } from './ws.js'
32
33
 
@@ -57,7 +58,14 @@ export class MobileLinkAgent extends EventEmitter {
57
58
  logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
58
59
  random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
59
60
  watchdogMs = 180000,
61
+ // 慢请求阈值(毫秒);0 = 关闭逐条慢请求记录。见 router.js 的 DEFAULT_SLOW_REQUEST_MS。
62
+ slowRequestMs = DEFAULT_SLOW_REQUEST_MS,
63
+ // 关掉手机推送的接收(回退闸);见 contracts/connector-diagnostics-method.md §4。
64
+ diagnostics = true,
60
65
  inviteCode, fetchImpl = fetch, env = process.env,
66
+ // 生命周期流水(见 journal.js):`journal` 传实例(测试注入),`journalPath`
67
+ // 传路径(`defaultJournalPath()` 之外的位置),false 表示只留内存不落盘。
68
+ journal, journalPath,
61
69
  // 手机不在时替它攒下来的提问条数上限(见 router.js);环境变量给部署用,
62
70
  // 构造参数给测试用。**没有"留多久"这个旋钮**:`$events` 一直留到设备被撤销。
63
71
  eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
@@ -69,6 +77,8 @@ export class MobileLinkAgent extends EventEmitter {
69
77
  this.pongTimeoutMs = pongTimeoutMs
70
78
  this.maxBackoffMs = maxBackoffMs
71
79
  this.watchdogMs = watchdogMs
80
+ this.slowRequestMs = Number(slowRequestMs) || 0
81
+ this.diagnostics = diagnostics !== false
72
82
  this.random = random
73
83
  this.now = now
74
84
  this.connectImpl = connectImpl
@@ -79,13 +89,29 @@ export class MobileLinkAgent extends EventEmitter {
79
89
  //: to fix, not a transport problem to retry.
80
90
  this.needsEnroll = false
81
91
  this.dsh = dshClient ?? new DshClient({ explicitUrl: dshUrl, logger })
92
+ /** 生命周期流水:内存环 +(默认)一份 JSONL,见 journal.js 的文件头。 */
93
+ this.journal = journal ?? new LinkJournal({
94
+ path: journalPath === false
95
+ ? undefined
96
+ : (typeof journalPath === 'string' && journalPath ? journalPath : defaultJournalPath()),
97
+ now,
98
+ logger,
99
+ })
82
100
  this.router = new DeviceRouter({
83
101
  dsh: this.dsh,
84
102
  logger,
85
103
  now,
86
104
  protocolVersion: PROTOCOL_VERSION,
87
105
  eventsBacklog,
106
+ slowRequestMs: this.slowRequestMs,
107
+ diagnostics: this.diagnostics,
88
108
  send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
109
+ // 设备侧的 attach/detach 与链路侧同源记进流水:事后想把"手机断了"和
110
+ // "连接器拆链"对上时间,两边必须写在同一份文件里。
111
+ // `options.foreign` 表示这条事件用的是**别人的钟**(App 推上来的),必须原样保留时刻。
112
+ onEvent: (kind, detail, options) => (options?.foreign
113
+ ? this.journal.recordForeign(kind, detail)
114
+ : this.journal.record(kind, detail)),
89
115
  // 「跑完了 / 待你回应」到了就报一声(`{t:'notify'}`)。这是 agent 侧控制帧,
90
116
  // **不带 deviceId**:连接器不知道、也不需要知道这台电脑配了哪些手机,
91
117
  // 「该不该推给某台手机」由中转判(它手里才有"这台设备此刻在不在线")。
@@ -108,6 +134,16 @@ export class MobileLinkAgent extends EventEmitter {
108
134
  this.startedAt = undefined
109
135
  this.connectedAt = undefined
110
136
  this.reconnectAttempts = 0
137
+ /** 最近一次拆链的时刻(用来在 `link.open` 里报"距上次断开多久")。 */
138
+ this._lastClosedAt = undefined
139
+ /**
140
+ * socket 上最后一次系统错误(`onError` 的文本)。
141
+ *
142
+ * 为什么要专门留住它:链路被异常关闭时,`close` 回调只给一个代码(实测常见 1006,
143
+ * 含义是"没有关闭帧"),据此无法判断是网络、代理还是对端。系统错误文本是唯一线索,
144
+ * 而它以前只进了 debug 日志、不落流水(2026-10-13 现场诊断时卡在这里)。
145
+ */
146
+ this._socketError = undefined
111
147
 
112
148
  this._stopping = false
113
149
  this._sessionClosed = undefined
@@ -233,6 +269,15 @@ export class MobileLinkAgent extends EventEmitter {
233
269
  this._abort = new AbortController()
234
270
  this.startedAt = this.now()
235
271
  this.state = 'connecting'
272
+ this.journal.record('link.start', {
273
+ // 这里只能报**配置值**:真身份(含实际中转地址)要等 `#session()` 里的
274
+ // `resolveIdentity()` 才读到,那条记在 `link.open` 上。名字带 configured,
275
+ // 免得读者把占位符 `wss://relay.example.com/dsh-link` 当成真的在连的地址。
276
+ configuredRelay: this.config.relayUrl,
277
+ agentId: this.config.agentId || undefined,
278
+ attempt: this.reconnectAttempts,
279
+ host: this.hostForm,
280
+ })
236
281
  // 解析器可能在上一次会话里就已经装好;这里补一次本机发现(失败无妨)。
237
282
  void this.refreshLocal()
238
283
  this._lastProgressAt = this.now()
@@ -262,6 +307,7 @@ export class MobileLinkAgent extends EventEmitter {
262
307
  this._loop = undefined
263
308
  this.dsh.close()
264
309
  this.state = 'stopped'
310
+ this.journal.record('link.stop', {})
265
311
  this.emit('status')
266
312
  }
267
313
 
@@ -278,6 +324,7 @@ export class MobileLinkAgent extends EventEmitter {
278
324
  this.needsEnroll = true
279
325
  this.state = 'unregistered'
280
326
  this.lastError = messageOf(error)
327
+ this.journal.record('link.unregistered', { error: this.lastError })
281
328
  this.logger.error?.(this.lastError)
282
329
  this.emit('status')
283
330
  return
@@ -295,11 +342,13 @@ export class MobileLinkAgent extends EventEmitter {
295
342
  this.needsEnroll = true
296
343
  this.state = 'unregistered'
297
344
  this.lastError = messageOf(error)
345
+ this.journal.record('link.unregistered', { error: this.lastError })
298
346
  this.logger.error?.(`${this.lastError}\n ${this.enrollHint()}`)
299
347
  this.emit('status')
300
348
  return
301
349
  }
302
350
  this.lastError = messageOf(error)
351
+ this.journal.record('link.failed', { error: this.lastError, attempt: this.reconnectAttempts })
303
352
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
304
353
  }
305
354
  if (this._stopping) break
@@ -307,6 +356,7 @@ export class MobileLinkAgent extends EventEmitter {
307
356
  this.emit('status')
308
357
  const delay = nextBackoff(this.reconnectAttempts, { maxMs: this.maxBackoffMs, random: this.random })
309
358
  this.reconnectAttempts += 1
359
+ this.journal.record('link.backoff', { attempt: this.reconnectAttempts, delayMs: Math.round(delay) })
310
360
  await sleep(delay, this._abort.signal)
311
361
  }
312
362
  this.state = 'stopped'
@@ -365,6 +415,16 @@ export class MobileLinkAgent extends EventEmitter {
365
415
  this.router.lastError = undefined
366
416
  this._lastPong = this.now()
367
417
  this.logger.info?.(`mobile-link: connected to ${this.relay.wsBase} as ${identity.agentId}`)
418
+ this.journal.record('link.open', {
419
+ relay: this.relay?.wsBase,
420
+ agentId: identity.agentId,
421
+ attempt: this.reconnectAttempts,
422
+ sinceCloseMs: this._lastClosedAt === undefined ? undefined : this.now() - this._lastClosedAt,
423
+ // 这次连接属于哪套版本:连接器 + 宿主 DSH。一连接一行,事后从本机流水就能
424
+ // 认出"出问题的那天跑的是哪两个版本",不必再去翻安装记录。
425
+ serverVersion: SERVER_VERSION,
426
+ hostVersion: hostVersion(),
427
+ })
368
428
  this.emit('status')
369
429
 
370
430
  socket.onMessage((text) => {
@@ -373,7 +433,10 @@ export class MobileLinkAgent extends EventEmitter {
373
433
  .catch((error) => this.logger.debug?.(`mobile-link: frame handler error: ${messageOf(error)}`))
374
434
  return this._inbox
375
435
  })
376
- socket.onError((error) => this.logger.debug?.(`mobile-link: relay socket error: ${messageOf(error)}`))
436
+ socket.onError((error) => {
437
+ this._socketError = messageOf(error)
438
+ this.logger.debug?.(`mobile-link: relay socket error: ${this._socketError}`)
439
+ })
377
440
  socket.onClose((code, reason) => this.#teardownSocket(`relay closed the socket (${code ?? '?'} ${reason ?? ''})`))
378
441
 
379
442
  this.#startHeartbeat()
@@ -386,9 +449,13 @@ export class MobileLinkAgent extends EventEmitter {
386
449
  // 进程外工具(本机脚本、iOS 集成测试辅助)靠这份交接文件拿带凭据地址;
387
450
  // 写入失败不影响链路(`#writeHandoff` 自己吞异常)。
388
451
  await this.#writeHandoff()
452
+ this.journal.record('dsh.ok', { endpoint: this.dsh?.endpoint?.base })
389
453
  } catch (error) {
390
454
  this.dshError = messageOf(error)
391
455
  this.lastError = this.dshError
456
+ // 这一条很关键:本机 DSH 不可达时,手机那句 `_link/hello`/`sessions` 探针
457
+ // 必然超时,App 会据此重建整条链路——于是"连接器在、手机却连不上"。
458
+ this.journal.record('dsh.unreachable', { error: this.dshError, kind: this.dsh?.errorKind })
392
459
  this.logger.warn?.(`mobile-link: local DSH is not reachable yet: ${this.dshError}`)
393
460
  }
394
461
  this.emit('status')
@@ -419,6 +486,7 @@ export class MobileLinkAgent extends EventEmitter {
419
486
  const idle = this.now() - this._lastProgressAt
420
487
  if (!this._loopActive) {
421
488
  this.lastError = `链路循环不在了(看门狗重拉,空闲 ${Math.round(idle / 1000)}s)`
489
+ this.journal.record('watchdog.repull', { idleMs: Math.round(idle) })
422
490
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
423
491
  this._loop = undefined
424
492
  this._lastProgressAt = this.now()
@@ -427,6 +495,7 @@ export class MobileLinkAgent extends EventEmitter {
427
495
  }
428
496
  if (this.state === 'connected' && idle > this.pongTimeoutMs) {
429
497
  this.lastError = `relay 超过 ${Math.round(idle / 1000)}s 没有动静(看门狗拆连接)`
498
+ this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
430
499
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
431
500
  this.#teardownSocket(this.lastError)
432
501
  this._lastProgressAt = this.now()
@@ -434,6 +503,7 @@ export class MobileLinkAgent extends EventEmitter {
434
503
  }
435
504
  if (this.state !== 'connected' && idle > this.watchdogMs) {
436
505
  this.lastError = `链路 ${Math.round(idle / 1000)}s 没有进展(看门狗重建连接)`
506
+ this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
437
507
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
438
508
  this.#teardownSocket(this.lastError)
439
509
  this._lastProgressAt = this.now()
@@ -469,6 +539,7 @@ export class MobileLinkAgent extends EventEmitter {
469
539
  #teardownSocket(reason) {
470
540
  this.#stopHeartbeat()
471
541
  const socket = this.socket
542
+ const from = this.state
472
543
  this.socket = undefined
473
544
  if (socket) {
474
545
  try {
@@ -483,6 +554,18 @@ export class MobileLinkAgent extends EventEmitter {
483
554
  this.dsh.close()
484
555
  if (this.state !== 'stopped' && this.state !== 'disabled') this.state = 'disconnected'
485
556
  this.lastError = this.lastError ?? reason
557
+ this._lastClosedAt = this.now()
558
+ // 「为什么拆的」是这一族事件唯一要回答的问题:reason 来自调用点,
559
+ // from 是拆之前的状态(connected / backoff / connecting),
560
+ // connectedForMs 用来区分"刚连上就掉"和"挂了很久才掉"。
561
+ this.journal.record('link.close', {
562
+ reason,
563
+ from,
564
+ connectedForMs: this.connectedAt === undefined ? undefined : this.now() - this.connectedAt,
565
+ // 有系统错误才带这个字段(正常关闭时不该凭空多一个 null)。
566
+ socketError: this._socketError,
567
+ })
568
+ this._socketError = undefined
486
569
  const resolve = this._sessionClosed
487
570
  this._sessionClosed = undefined
488
571
  this.emit('status')
@@ -492,10 +575,15 @@ export class MobileLinkAgent extends EventEmitter {
492
575
  // ── relay I/O ───────────────────────────────────────────────────────────
493
576
 
494
577
  #rawSend(frame) {
578
+ return this.#sendText(encodeFrame(frame))
579
+ }
580
+
581
+ /** 唯一的发送出口:socket 不在就丢掉,发送异常只记 debug、绝不抛。 */
582
+ #sendText(text) {
495
583
  const socket = this.socket
496
584
  if (!socket || socket.closed) return false
497
585
  try {
498
- socket.send(encodeFrame(frame))
586
+ socket.send(text)
499
587
  return true
500
588
  } catch (error) {
501
589
  this.logger.debug?.(`mobile-link: send failed: ${messageOf(error)}`)
@@ -507,7 +595,10 @@ export class MobileLinkAgent extends EventEmitter {
507
595
  // `deviceId` 为空=这是一条**不带寻址**的帧(`fsPutAck`/`fsPutDone`/`fsErr` 那一族):
508
596
  // relay 靠 `bid` 把回复对回发起的那个 HTTP 请求,多一个 deviceId 反而会让它对不上。
509
597
  if (deviceId === undefined || deviceId === null) return this.#rawSend(frame)
510
- return this.#rawSend({ ...frame, deviceId })
598
+ const text = encodeFrame({ ...frame, deviceId })
599
+ // 记字节给路由层的"慢请求/汇总"用(它按 deviceId 归):只有这里知道真正上线了多少字节。
600
+ this.router.noteSent?.(deviceId, Buffer.byteLength(text))
601
+ return this.#sendText(text)
511
602
  }
512
603
 
513
604
  sendHostStatus() {
@@ -519,6 +610,10 @@ export class MobileLinkAgent extends EventEmitter {
519
610
  name: this.identity?.agentName || undefined,
520
611
  dshPort: this.dsh?.endpoint?.port,
521
612
  protocol: PROTOCOL_VERSION,
613
+ // 这台电脑的两段版本。中转据此按 agent 记一行(变化才写),手机据此
614
+ // 在「关于」里显示 DSH 版本——只在控制帧上走一次,不进任何业务流。
615
+ version: SERVER_VERSION,
616
+ hostVersion: hostVersion(),
522
617
  },
523
618
  })
524
619
  }
@@ -566,12 +661,22 @@ export class MobileLinkAgent extends EventEmitter {
566
661
 
567
662
  // ── relay-side helpers ──────────────────────────────────────────────────
568
663
 
569
- /** Ask the relay to mint a pairing code for this agent (relay NOTES.md §2). */
570
- async mintPairCode({ ttlMs = 10 * 60 * 1000, fetchImpl = fetch } = {}) {
664
+ /**
665
+ * Ask the relay to mint a pairing code for this agent (relay NOTES.md §2).
666
+ *
667
+ * 032:默认持久(`ttlMs: null`);传秒数得到限时码。
668
+ */
669
+ async mintPairCode({ ttlMs = null, fetchImpl = fetch } = {}) {
571
670
  const identity = this.identity ?? await resolveIdentity(this.config)
572
671
  return mintPairCode({ identity, ttlMs, fetchImpl })
573
672
  }
574
673
 
674
+ /** 用户点「重新生成」:作废旧码、铸新的,**不碰已配对设备**(032)。 */
675
+ async regeneratePairCode({ fetchImpl = fetch } = {}) {
676
+ const identity = this.identity ?? await resolveIdentity(this.config)
677
+ return regeneratePairCode({ identity, fetchImpl })
678
+ }
679
+
575
680
  /** Snapshot for `GET /mobile-link/status`(载荷本身在 status.js,便于守住"只增不改")。 */
576
681
  status() {
577
682
  return statusSnapshot(this)
package/lib/pairing.js CHANGED
@@ -1,25 +1,34 @@
1
1
  /**
2
- * Pairing helper: ask the relay to mint a one-time code and build the QR
3
- * payload the desktop shows. Spec §2.1 plus docs/RELAY-NOTES.md §2 (the endpoint the
4
- * spec's table is missing).
2
+ * Pairing helper: ask the relay to mint a pairing code and build the QR payload
3
+ * the desktop shows. Spec §2.1 plus docs/RELAY-NOTES.md §2 (the endpoint the spec's
4
+ * table is missing).
5
+ *
6
+ * 032:默认铸**持久码**(不过期,直到用户点「重新生成」)。传 `ttlMs` 仍然得到一张
7
+ * 限时码——那是老行为,留给还用它的调用方与老测试。
5
8
  */
6
9
 
7
- import { pairCodeEndpoint, qrPayload } from './dlp.js'
10
+ import { pairCodeEndpoint, pairCodeRegenerateEndpoint, qrPayload } from './dlp.js'
8
11
 
9
12
  /**
10
13
  * @param {object} options
11
14
  * @param {{agentId: string, agentSecret: string, relayUrl: string}} options.identity
12
- * @param {number} [options.ttlMs] requested lifetime (the relay caps it at 1 hour)
15
+ * @param {number|null} [options.ttlMs] requested lifetime; `null`/省略 = 持久码
13
16
  * @param {typeof fetch} [options.fetchImpl] injectable for tests
14
17
  */
15
- export async function mintPairCode({ identity, ttlMs = 10 * 60 * 1000, fetchImpl = fetch }) {
18
+ export async function mintPairCode({ identity, ttlMs = null, fetchImpl = fetch }) {
19
+ // 032:**持久码那条路不能把 `ttlMs` 放进请求体**。中转的判据是 `"ttlMs" not in body`
20
+ // (`relay/api.py` 的 `pair_code`):带一个 `ttlMs: null` 会被读成"限时、但没给时长",
21
+ // 于是落到中转的默认 10 分钟——面板显示倒计时,码十分钟后就废(用户看到的就是这个)。
22
+ const payload = ttlMs == null
23
+ ? { agentId: identity.agentId }
24
+ : { agentId: identity.agentId, ttlMs }
16
25
  const response = await fetchImpl(pairCodeEndpoint(identity.relayUrl), {
17
26
  method: 'POST',
18
27
  headers: {
19
28
  'content-type': 'application/json',
20
29
  authorization: `Bearer ${identity.agentSecret}`,
21
30
  },
22
- body: JSON.stringify({ agentId: identity.agentId, ttlMs }),
31
+ body: JSON.stringify(payload),
23
32
  })
24
33
  let body
25
34
  try {
@@ -32,8 +41,50 @@ export async function mintPairCode({ identity, ttlMs = 10 * 60 * 1000, fetchImpl
32
41
  }
33
42
  return {
34
43
  code: body.code,
35
- expiresAt: body.expiresAt,
36
- ttlMs: body.ttlMs ?? ttlMs,
44
+ expiresAt: body.expiresAt ?? null,
45
+ ttlMs: body.ttlMs ?? ttlMs ?? null,
46
+ persistent: body.persistent === true || body.expiresAt == null,
47
+ // 「已配对 N/8」以中转的库为准:连接器只知道自己这次见过的设备。
48
+ maxPairedDevices: Number(body.maxPairedDevices ?? 0) || null,
49
+ pairedDevices: Number(body.pairedDevices ?? 0),
50
+ relayUrl: identity.relayUrl,
51
+ agentId: identity.agentId,
52
+ qrPayload: qrPayload({ relay: identity.relayUrl, code: body.code }),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 用户点「重新生成」:让中转作废旧码、铸一枚新的。
58
+ *
59
+ * **不碰已配对的设备**(032 FR-002):那台手机/浏览器靠自己的 deviceToken 活着,
60
+ * 换码只影响"还没来配的人"。所以这个动作可以放心点,不需要提醒"会把手机踢下线"。
61
+ */
62
+ export async function regeneratePairCode({ identity, fetchImpl = fetch }) {
63
+ const response = await fetchImpl(pairCodeRegenerateEndpoint(identity.relayUrl), {
64
+ method: 'POST',
65
+ headers: {
66
+ 'content-type': 'application/json',
67
+ authorization: `Bearer ${identity.agentSecret}`,
68
+ },
69
+ body: JSON.stringify({ agentId: identity.agentId }),
70
+ })
71
+ let body
72
+ try {
73
+ body = await response.json()
74
+ } catch {
75
+ throw new Error(`relay returned HTTP ${response.status} for /pair/code/regenerate`)
76
+ }
77
+ if (!response.ok || body?.ok !== true) {
78
+ throw new Error(body?.error?.message ?? `relay refused the regeneration (HTTP ${response.status})`)
79
+ }
80
+ return {
81
+ code: body.code,
82
+ expiresAt: null,
83
+ ttlMs: null,
84
+ persistent: true,
85
+ revoked: Number(body.revoked ?? 0),
86
+ maxPairedDevices: Number(body.maxPairedDevices ?? 0) || null,
87
+ pairedDevices: Number(body.pairedDevices ?? 0),
37
88
  relayUrl: identity.relayUrl,
38
89
  agentId: identity.agentId,
39
90
  qrPayload: qrPayload({ relay: identity.relayUrl, code: body.code }),