dsh-plugin-mobile-link 0.4.0 → 0.6.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/history.js ADDED
@@ -0,0 +1,355 @@
1
+ import { HOST_CALL_TIMEOUT_MS, withTimeout, messageOf } from './history/shared.js'
2
+ import { createProbeRunner } from './history/probe.js'
3
+ import {
4
+ DEFAULT_LIMIT, DEFAULT_MAX_BYTES, WINDOW_CHUNK,
5
+ bytesOfRecords, displayRoleOf, roleOf, toRecord,
6
+ } from './history/records.js'
7
+ import { pageFrom, scanOlder } from './history/window.js'
8
+
9
+ // Re-exported for the caller/tests that read them off this module (single source
10
+ // is `history/records.js`; the call ceiling lives in `history/shared.js`).
11
+ export { DEFAULT_MAX_BYTES, DEFAULT_LIMIT, WINDOW_CHUNK } from './history/records.js'
12
+ export { HOST_CALL_TIMEOUT_MS } from './history/shared.js'
13
+
14
+ /**
15
+ * History window reads and in-session search, answered **locally** by the connector.
16
+ *
17
+ * Why local: the Host already owns a correct, seq-exact reader
18
+ * (`ctx.sessionQuery`, see @deepseek-ai/dsh-session-query) but does **not** expose
19
+ * it over the remote API the phone talks to. The only history method on that
20
+ * surface is `session/page`, whose `beforeSeq` lands in log-offset space, so a
21
+ * session containing offset-only events silently skips a stretch of history —
22
+ * measured at 235 seqs on a real session (see DSHKit's TranscriptPageBoundary).
23
+ *
24
+ * So the connector reads the Host's own state through the injected service and
25
+ * answers two extra methods:
26
+ *
27
+ * _link/sessionWindow exact window ending before a given seq
28
+ * _link/sessionFind literal text search within one session
29
+ *
30
+ * Feature detection is the switch, not version comparison: a connector that
31
+ * lacks the injected service answers `link/unsupported`, and the phone keeps
32
+ * using `session/page`. Nothing about the existing methods changes.
33
+ *
34
+ * SPIKE NOTE (spec 007, task T008): `probe()` below is a temporary read-only
35
+ * measurement used to pick the exact Host API mapping. It is wired to
36
+ * `GET /mobile-link/history-probe` and is removed once the mapping is locked.
37
+ */
38
+
39
+ export const WINDOW_METHOD = '_link/sessionWindow'
40
+ export const FIND_METHOD = '_link/sessionFind'
41
+
42
+ /// Declared in `_link/hello`; the phone switches channels on these.
43
+ /// `history-role-filter`: `_link/sessionWindow` accepts an optional `roles`
44
+ /// array (e.g. `["me"]`) and returns only matching records; the seq cursor
45
+ /// semantics are unchanged (spec 009 T031).
46
+ /// `history-scan-boundary`: a `roles`-filtered page also carries `scanBoundary`,
47
+ /// the seq the scan really reached, so a caller can advance its cursor when a
48
+ /// whole page is filtered empty (spec 009 T039). Purely additive: a client that
49
+ /// does not read the field keeps the old cursor rule.
50
+ export const HISTORY_CAPABILITIES = [
51
+ 'history-window', 'session-search', 'history-role-filter', 'history-scan-boundary',
52
+ ]
53
+
54
+ export function isHistoryMethod(method) {
55
+ return method === WINDOW_METHOD || method === FIND_METHOD
56
+ }
57
+
58
+ /// One search chunk, and how many of them one call may scan. Measured on a
59
+ /// 12968-event session: ~320ms per 2000-event chunk, so four chunks keep a call
60
+ /// near 1.3s — well inside the 8s host-call budget and short enough not to hold
61
+ /// the connector's single inbound chain for long (spec 007 FR-019).
62
+ export const SEARCH_CHUNK = 2000
63
+ export const MAX_SEARCH_CHUNKS_PER_CALL = 4
64
+ export const DEFAULT_SEARCH_LIMIT = 50
65
+ export const MIN_QUERY_LENGTH = 2
66
+
67
+ function idOfAddress(address) {
68
+ if (address === null || typeof address !== 'object') return undefined
69
+ if (address.kind === 'session') return typeof address.sessionId === 'string' ? address.sessionId : undefined
70
+ if (address.kind === 'subagent') {
71
+ return typeof address.childSessionId === 'string' ? address.childSessionId : undefined
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ /// Validate the optional `roles` argument: unknown values would silently match
77
+ /// nothing, so the caller gets a bad-request instead of an empty page.
78
+ const KNOWN_ROLES = new Set(['me', 'agent', 'tool'])
79
+
80
+ function parseRoles(value) {
81
+ if (value === undefined) return { roles: undefined }
82
+ if (!Array.isArray(value) || value.length === 0) return { roles: undefined }
83
+ const roles = new Set(value)
84
+ for (const role of roles) {
85
+ if (!KNOWN_ROLES.has(role)) {
86
+ return { error: failure('gateway/bad-request', `unknown role "${String(role)}"`) }
87
+ }
88
+ }
89
+ return { roles }
90
+ }
91
+
92
+ /// A hit snippet with the highlight offsets the phone's list already renders.
93
+ ///
94
+ /// Offsets, not ranges: the value crosses the wire and a character offset stays
95
+ /// meaningful on the other side.
96
+ function makeSnippet(text, query, radius = 36) {
97
+ const haystack = String(text ?? '')
98
+ const at = haystack.toLowerCase().indexOf(query.toLowerCase())
99
+ if (at < 0) {
100
+ const head = haystack.slice(0, radius * 2).replace(/\s+/g, ' ').trim()
101
+ return { snippet: head, highlightStart: 0, highlightLength: 0 }
102
+ }
103
+ const start = Math.max(0, at - radius)
104
+ const end = Math.min(haystack.length, at + query.length + radius)
105
+ const snippet = haystack.slice(start, end).replace(/\s+/g, ' ').trim()
106
+ // Re-find the match inside the flattened snippet so the offsets line up with
107
+ // what is actually displayed.
108
+ const flatAt = snippet.toLowerCase().indexOf(query.toLowerCase())
109
+ return {
110
+ snippet,
111
+ highlightStart: flatAt < 0 ? 0 : flatAt,
112
+ highlightLength: flatAt < 0 ? 0 : query.length,
113
+ }
114
+ }
115
+
116
+ function failure(code, message, details = {}) {
117
+ return { ok: false, error: { code, message, details } }
118
+ }
119
+
120
+ /// Map a thrown Host/gateway error onto the contract's codes.
121
+ function hostFailure(error) {
122
+ const message = messageOf(error)
123
+ if (/timed out after/.test(message)) return failure('link/timeout', message)
124
+ if (/not found|no such session/i.test(message)) return failure('session/unknown', message)
125
+ return failure('gateway/failed', message)
126
+ }
127
+
128
+ /**
129
+ * @param {{ logger?: { warn?: Function, info?: Function } }} deps
130
+ */
131
+ export function createHistoryService({ logger = console } = {}) {
132
+ let sessionQuery
133
+ const probeRunner = createProbeRunner({ getQuery: () => sessionQuery, logger })
134
+ /// Set to false after the Host has told us its full-text index is closed
135
+ /// (`openAt: "never"`), so later searches go straight to the scan.
136
+ let indexSearchable = true
137
+ const inFlight = new Set()
138
+ const lastSeqCache = new Map()
139
+
140
+ /// The log's newest seq, cached briefly: it is only needed to clamp a request
141
+ /// that runs past the end (a session can be trimmed between two pages).
142
+ async function newestSeq(sessionId) {
143
+ const cached = lastSeqCache.get(sessionId)
144
+ if (cached !== undefined && Date.now() - cached.at < 5000) return cached.seq
145
+ const records = await withTimeout(sessionQuery.listEvents(sessionId), HOST_CALL_TIMEOUT_MS, 'listEvents')
146
+ const seq = records.length > 0 ? records[records.length - 1].seq : -1
147
+ lastSeqCache.set(sessionId, { at: Date.now(), seq })
148
+ return seq
149
+ }
150
+
151
+ /// One exact backward window ending at `seq` (inclusive).
152
+ ///
153
+ /// `clamped` is reported upward: when the session was trimmed, the page cannot
154
+ /// abut the phone's window, and the phone needs to know that the gap is the
155
+ /// log's doing rather than a hole it should treat as a bug (FR-002/FR-041).
156
+ async function readChunk(sessionId, seq) {
157
+ try {
158
+ const window = await withTimeout(
159
+ sessionQuery.readEvent({ sessionId, seq, before: WINDOW_CHUNK, after: 0 }),
160
+ HOST_CALL_TIMEOUT_MS,
161
+ 'readEvent',
162
+ )
163
+ return { window, clamped: false }
164
+ } catch (error) {
165
+ // Past the end of a trimmed log: clamp once and retry, never guess.
166
+ if (!/has no event at seq/.test(messageOf(error))) throw error
167
+ const newest = await newestSeq(sessionId)
168
+ if (newest < 0 || newest >= seq) throw error
169
+ lastSeqCache.delete(sessionId)
170
+ const window = await withTimeout(
171
+ sessionQuery.readEvent({ sessionId, seq: newest, before: WINDOW_CHUNK, after: 0 }),
172
+ HOST_CALL_TIMEOUT_MS,
173
+ 'readEvent(clamped)',
174
+ )
175
+ return { window, clamped: true }
176
+ }
177
+ }
178
+
179
+ /// One page of in-session literal search (spec 007 FR-010…FR-012).
180
+ ///
181
+ /// Preferred path is the Host's indexed `searchEvents`; this deployment ships it
182
+ /// disabled (`openAt: "never"`), so the working path is a bounded scan: chunks of
183
+ /// `SEARCH_CHUNK` seqs, filtered by seq range **and** the literal text, newest
184
+ /// first. Each call scans at most `MAX_SEARCH_CHUNKS_PER_CALL` chunks and hands
185
+ /// back a cursor, so "keep looking older" is the reader's next request rather
186
+ /// than one call that holds the tunnel.
187
+ async function find(args) {
188
+ const sessionId = idOfAddress(args.address)
189
+ if (sessionId === undefined) return failure('gateway/bad-request', 'address must name a session')
190
+ const query = typeof args.query === 'string' ? args.query.trim() : ''
191
+ const roles = Array.isArray(args.roles) && args.roles.length > 0 ? new Set(args.roles) : undefined
192
+ // 浏览模式:**没有关键词但带了 roles**(读者要看"我问过的全部提问")。
193
+ // 这时不按文本过滤,只按 seq 段扫,按角色筛。
194
+ const browsing = query.length === 0 && roles !== undefined
195
+ if (!browsing && query.length < MIN_QUERY_LENGTH) {
196
+ return failure('link/query-too-short', `query must be at least ${MIN_QUERY_LENGTH} characters`)
197
+ }
198
+ const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0
199
+ ? Math.min(Number(args.limit), 200) : DEFAULT_SEARCH_LIMIT
200
+ // Advanced knob (also used by the measurement route and the tests): a smaller
201
+ // chunk makes the cursor path reachable on short sessions. Clamped, never 0.
202
+ const chunk = Number.isSafeInteger(Number(args.chunk)) && Number(args.chunk) > 0
203
+ ? Math.min(Number(args.chunk), SEARCH_CHUNK) : SEARCH_CHUNK
204
+ const key = `find:${sessionId}`
205
+ if (inFlight.has(key)) return failure('link/busy', 'another search is in flight for this session')
206
+ inFlight.add(key)
207
+ try {
208
+ // Fast path when the deployment has the index open. One attempt, and a
209
+ // refusal is remembered: the scan below is the path that always works.
210
+ if (indexSearchable && args.cursor === undefined && typeof sessionQuery.searchEvents === 'function') {
211
+ try {
212
+ const page = await withTimeout(
213
+ sessionQuery.searchEvents({ sessionId, query, limit }),
214
+ HOST_CALL_TIMEOUT_MS,
215
+ 'searchEvents',
216
+ )
217
+ const indexed = (page.items ?? [])
218
+ .map((item) => {
219
+ const role = roleOf(item)
220
+ if (roles !== undefined && (role === undefined || !roles.has(role))) return undefined
221
+ const display = displayRoleOf(item)
222
+ if (display === undefined) return undefined
223
+ return { seq: item.seq, role: display, ...makeSnippet(item.snippet ?? item.text ?? '', query) }
224
+ })
225
+ .filter(Boolean)
226
+ return {
227
+ ok: true,
228
+ value: {
229
+ hits: indexed,
230
+ cursor: page.hasMore === true && page.cursor !== undefined ? String(page.cursor) : null,
231
+ hasMore: page.hasMore === true,
232
+ source: 'index',
233
+ },
234
+ }
235
+ } catch (error) {
236
+ indexSearchable = false
237
+ logger.info?.(`mobile-link: full-text index unavailable, scanning instead: ${messageOf(error)}`)
238
+ }
239
+ }
240
+ const from = parseSearchCursor(args.cursor)
241
+ const ceiling = from !== undefined ? from : await newestSeq(sessionId)
242
+ const hits = []
243
+ let to = ceiling
244
+ let scanned = 0
245
+ while (to >= 0 && hits.length < limit && scanned < MAX_SEARCH_CHUNKS_PER_CALL) {
246
+ const chunkFrom = Math.max(0, to - chunk + 1)
247
+ const filters = [{ kind: 'seq', from: chunkFrom, to }]
248
+ if (!browsing) filters.push({ kind: 'text', text: query })
249
+ const docs = await withTimeout(
250
+ sessionQuery.filterEvents(sessionId, filters),
251
+ HOST_CALL_TIMEOUT_MS,
252
+ 'filterEvents(search)',
253
+ )
254
+ scanned += 1
255
+ // Newest first inside the chunk: the reader asked about something recent
256
+ // far more often than about something old.
257
+ for (let index = docs.length - 1; index >= 0; index -= 1) {
258
+ const doc = docs[index]
259
+ const role = roleOf(doc)
260
+ if (roles !== undefined && (role === undefined || !roles.has(role))) continue
261
+ const display = displayRoleOf(doc)
262
+ if (display === undefined) continue
263
+ hits.push({ seq: doc.seq, role: display, ...(query.length > 0
264
+ ? makeSnippet(doc.text, query)
265
+ : { snippet: String(doc.text ?? '').slice(0, 72).replace(/\s+/g, ' ').trim(),
266
+ highlightStart: 0, highlightLength: 0 }) })
267
+ if (hits.length >= limit) break
268
+ }
269
+ to = chunkFrom - 1
270
+ }
271
+ const hasMore = to >= 0 && hits.length >= limit ? true : to >= 0
272
+ return {
273
+ ok: true,
274
+ value: {
275
+ hits,
276
+ cursor: hasMore ? String(to) : null,
277
+ hasMore,
278
+ scannedChunks: scanned,
279
+ source: 'scan',
280
+ },
281
+ }
282
+ } catch (error) {
283
+ logger.warn?.(`mobile-link: sessionFind failed: ${messageOf(error)}`)
284
+ return hostFailure(error)
285
+ } finally {
286
+ inFlight.delete(key)
287
+ }
288
+ }
289
+
290
+ /// The cursor is the next `to` seq — opaque to the phone, validated here.
291
+ function parseSearchCursor(cursor) {
292
+ if (typeof cursor !== 'string' || cursor.length === 0) return undefined
293
+ const value = Number(cursor)
294
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined
295
+ }
296
+
297
+ async function window(args) {
298
+ const sessionId = idOfAddress(args.address)
299
+ const before = Number(args.before)
300
+ if (sessionId === undefined) return failure('gateway/bad-request', 'address must name a session')
301
+ if (!Number.isSafeInteger(before) || before < 0) return failure('gateway/bad-request', 'before must be a non-negative integer')
302
+ const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0 ? Number(args.limit) : DEFAULT_LIMIT
303
+ const maxBytes = Number.isSafeInteger(Number(args.maxBytes)) && Number(args.maxBytes) > 0
304
+ ? Number(args.maxBytes) : DEFAULT_MAX_BYTES
305
+ const through = Number.isSafeInteger(Number(args.through)) ? Number(args.through) : undefined
306
+ // Optional role filter (spec 009 T031): e.g. `roles: ["me"]` returns only
307
+ // the caller's own questions. Filtering affects **which records come back,
308
+ // never the cursor**: the window is still read as contiguous seqs and
309
+ // `before` still bounds the newest edge exactly. Since T039 a filtered page
310
+ // also carries `scanBoundary` (see history/window.js) so a caller can keep
311
+ // moving when a whole page is filtered empty.
312
+ const { roles, error: rolesError } = parseRoles(args.roles)
313
+ if (rolesError) return rolesError
314
+
315
+ const key = `window:${sessionId}`
316
+ if (inFlight.has(key)) return failure('link/busy', 'another window read is in flight for this session')
317
+ inFlight.add(key)
318
+ try {
319
+ const scan = await scanOlder({ readChunk, sessionId, before, through, limit, maxBytes, roles })
320
+ return { ok: true, value: pageFrom(scan, { limit, maxBytes, roles }) }
321
+ } catch (error) {
322
+ logger.warn?.(`mobile-link: sessionWindow failed: ${messageOf(error)}`)
323
+ return hostFailure(error)
324
+ } finally {
325
+ inFlight.delete(key)
326
+ }
327
+ }
328
+
329
+ return {
330
+ /// Called from an optional injection; absent on Hosts that predate the service.
331
+ bindQuery(service) {
332
+ sessionQuery = service
333
+ logger.info?.('mobile-link: history service bound to ctx.sessionQuery')
334
+ },
335
+ get available() {
336
+ return sessionQuery !== undefined
337
+ },
338
+
339
+ /// Frame handler for the two methods.
340
+ async handle(method, args = {}) {
341
+ if (sessionQuery === undefined) {
342
+ return failure('link/unsupported', 'this Host does not expose the session query service')
343
+ }
344
+ if (method === WINDOW_METHOD) return window(args)
345
+ if (method === FIND_METHOD) return find(args)
346
+ return failure('link/unknown-method', String(method))
347
+ },
348
+
349
+ /// Exposed for the unit tests and the (gated) measurement route.
350
+ /// 只读测量(spec 007 T008/T047):实现见 `history/probe.js`。
351
+ async probe(args = {}) {
352
+ return probeRunner(args)
353
+ },
354
+ }
355
+ }
package/lib/index.js CHANGED
@@ -7,12 +7,15 @@
7
7
  * never been registered, redeem the invite code from the environment (or
8
8
  * the config) first, so a fresh install becomes usable without the relay
9
9
  * operator provisioning anything by hand.
10
- * 2. Three routes under `/mobile-link` behind DSH's own connection fence:
10
+ * 2. Two routes under `/mobile-link` behind DSH's own connection fence:
11
11
  * GET /status — is the link up, which devices are attached, and is
12
12
  * this computer registered yet
13
- * POST /pair-code — mint a pairing code + QR payload through the relay
14
- * GET /qr — the same code rendered as a scannable QR SVG
13
+ * POST /pair-code — mint a pairing code through the relay, with the QR
14
+ * payload and the symbol rendered alongside it
15
15
  *
16
+ * The pairing screen itself lives in the client half (`lib/client.js`, spec 010):
17
+ * a standing button in the sidebar plus a panel that shows the code, its QR and
18
+ * the paired phones. There is no browser page for pairing any more.
16
19
  * Deliberate design choices:
17
20
  * * The plugin declares **no required services** (`inject` is empty). The DLP
18
21
  * agent must run even if a future DSH renames or drops `webServer` /
@@ -28,6 +31,7 @@
28
31
 
29
32
  import { MobileLinkAgent } from './link.js'
30
33
  import { registerMobileLinkRoutes } from './routes.js'
34
+ import { createHistoryService } from './history.js'
31
35
 
32
36
  /** Cordis function-plugin name (the loader row id is separate). */
33
37
  export const name = 'mobile-link'
@@ -116,10 +120,10 @@ function startAgent(ctx, agent, logger) {
116
120
  }
117
121
 
118
122
  /** 三条本机 HTTP 路由;只依赖 `webServer`,缺了也不影响链路。 */
119
- function registerRoutes(ctx, agent, logger, config) {
123
+ function registerRoutes(ctx, agent, logger, config, history) {
120
124
  try {
121
125
  ctx.inject(['webServer'], (scope) => {
122
- const disposers = registerMobileLinkRoutes(scope, { agent, logger, config })
126
+ const disposers = registerMobileLinkRoutes(scope, { agent, logger, config, history })
123
127
  scope.effect?.(() => () => {
124
128
  for (const dispose of disposers) {
125
129
  try {
@@ -154,6 +158,22 @@ function wireEndpointResolver(ctx, agent, logger) {
154
158
  }
155
159
  }
156
160
 
161
+ /**
162
+ * 把宿主的会话查询服务交给历史能力(**可选注入**)。
163
+ *
164
+ * 拿不到就保持"不支持":宿主里没有这个服务(旧版本)时,历史能力回 `link/unsupported`,
165
+ * 手机端继续走 `session/page`。这条注入刻意独立于链路本身——注入失败不影响连接器运行。
166
+ */
167
+ function wireSessionQuery(ctx, history, logger) {
168
+ try {
169
+ ctx.inject(['sessionQuery'], (scope) => {
170
+ history.bindQuery(scope.sessionQuery)
171
+ })
172
+ } catch (error) {
173
+ logger.warn?.(`mobile-link: session query unavailable (history falls back): ${error}`)
174
+ }
175
+ }
176
+
157
177
  /**
158
178
  * @param {import('@deepseek-ai/cordis').Context} ctx
159
179
  * @param {Record<string, unknown>} rawConfig
@@ -162,10 +182,13 @@ export function apply(ctx, rawConfig) {
162
182
  const config = { ...DEFAULTS, ...asRecord(rawConfig) }
163
183
  const logger = ctx.logger ?? console
164
184
  const agent = buildAgent(config, logger)
185
+ const history = createHistoryService({ logger })
186
+ agent.setHistoryService?.(history)
165
187
 
166
188
  startAgent(ctx, agent, logger)
167
- registerRoutes(ctx, agent, logger, config)
189
+ registerRoutes(ctx, agent, logger, config, history)
168
190
  wireEndpointResolver(ctx, agent, logger)
191
+ wireSessionQuery(ctx, history, logger)
169
192
  logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
170
193
  }
171
194
 
package/lib/link.js CHANGED
@@ -8,6 +8,16 @@ import { EventEmitter } from 'node:events'
8
8
 
9
9
  import { DshClient } from './dsh-client.js'
10
10
  import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
11
+
12
+ /**
13
+ * 进程级登记表:**同一个进程里只允许有一条到中转的 agent 连接**。
14
+ *
15
+ * 为什么需要它:插件热更会重新求值模块、再建一个 Link,而旧的实例还占着那条连接
16
+ * (中转只认一个 agent 会话)。新实例于是连不上、手机那侧"连上了却没有链路"。
17
+ * 2026-10-09 00:00–00:42 真机连不上就是这个:连接器 43 分钟没回来。
18
+ * 挂在 `globalThis` 上是因为模块重载会把模块作用域清掉,只有进程级才留得住。
19
+ */
20
+ const LIVE_AGENT = Symbol.for('dsh.mobile-link.live-agent')
11
21
  import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
12
22
  import { handoffFromEndpoint, writeHandoff } from './handoff.js'
13
23
  import { statusSnapshot } from './status.js'
@@ -46,6 +56,7 @@ export class MobileLinkAgent extends EventEmitter {
46
56
  relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl,
47
57
  logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
48
58
  random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
59
+ watchdogMs = 180000,
49
60
  inviteCode, fetchImpl = fetch, env = process.env,
50
61
  // 手机不在时替它攒下来的提问条数上限(见 router.js);环境变量给部署用,
51
62
  // 构造参数给测试用。**没有"留多久"这个旋钮**:`$events` 一直留到设备被撤销。
@@ -57,6 +68,7 @@ export class MobileLinkAgent extends EventEmitter {
57
68
  this.heartbeatMs = heartbeatMs
58
69
  this.pongTimeoutMs = pongTimeoutMs
59
70
  this.maxBackoffMs = maxBackoffMs
71
+ this.watchdogMs = watchdogMs
60
72
  this.random = random
61
73
  this.now = now
62
74
  this.connectImpl = connectImpl
@@ -85,6 +97,9 @@ export class MobileLinkAgent extends EventEmitter {
85
97
  this.identity = undefined
86
98
  /** Which host form we are running in (`desktop` / `web` / `unknown`); set by the plugin. */
87
99
  this.hostForm = 'unknown'
100
+ /// 历史读取(spec 007):宿主 `sessionQuery` 就绪后由插件挂上;没挂上时
101
+ /// 路由回 `link/unsupported`,手机继续走 `session/page`。
102
+ this.setHistoryService = (service) => { this.router.history = service }
88
103
  this.relay = undefined
89
104
  this.socket = undefined
90
105
  this.state = 'idle'
@@ -100,6 +115,9 @@ export class MobileLinkAgent extends EventEmitter {
100
115
  this._inbox = Promise.resolve()
101
116
  this._lastPong = 0
102
117
  this._loop = undefined
118
+ this._loopActive = false
119
+ this._watchdog = undefined
120
+ this._lastProgressAt = 0
103
121
  this._abort = new AbortController()
104
122
 
105
123
  // The mux is shared by every device, so its events are wired once here.
@@ -199,6 +217,17 @@ export class MobileLinkAgent extends EventEmitter {
199
217
  this.emit('status')
200
218
  return this
201
219
  }
220
+ // 先把上一个实例停掉(热更留下的那条连接):不停的话两个实例会抢同一个 agent 会话,
221
+ // 表现就是"手机连上中转、却等不到任何回复"。
222
+ const previous = globalThis[LIVE_AGENT]
223
+ if (previous && previous !== this) {
224
+ try {
225
+ await previous.stop()
226
+ } catch {
227
+ /* 旧实例停不掉也不能挡住新的 */
228
+ }
229
+ }
230
+ globalThis[LIVE_AGENT] = this
202
231
  if (this._loop) return this
203
232
  this._stopping = false
204
233
  this._abort = new AbortController()
@@ -206,15 +235,22 @@ export class MobileLinkAgent extends EventEmitter {
206
235
  this.state = 'connecting'
207
236
  // 解析器可能在上一次会话里就已经装好;这里补一次本机发现(失败无妨)。
208
237
  void this.refreshLocal()
238
+ this._lastProgressAt = this.now()
239
+ this.#startWatchdog()
240
+ this._loopActive = true
209
241
  this._loop = this.#run().catch((error) => {
210
242
  this.lastError = messageOf(error)
211
243
  this.state = 'failed'
212
244
  this.emit('status')
245
+ }).finally(() => {
246
+ this._loopActive = false
213
247
  })
214
248
  return this
215
249
  }
216
250
 
217
251
  async stop() {
252
+ this.#stopWatchdog()
253
+ if (globalThis[LIVE_AGENT] === this) globalThis[LIVE_AGENT] = undefined
218
254
  this._stopping = true
219
255
  this._abort.abort()
220
256
  this.#teardownSocket('agent stopped')
@@ -324,6 +360,7 @@ export class MobileLinkAgent extends EventEmitter {
324
360
  this.socket = socket
325
361
  this.state = 'connected'
326
362
  this.connectedAt = this.now()
363
+ this._lastProgressAt = this.now()
327
364
  this.lastError = undefined
328
365
  this.router.lastError = undefined
329
366
  this._lastPong = this.now()
@@ -362,6 +399,54 @@ export class MobileLinkAgent extends EventEmitter {
362
399
  })
363
400
  }
364
401
 
402
+ /**
403
+ * 看门狗:**与单次会话的心跳无关**的兜底。
404
+ *
405
+ * 心跳只在"连上之后"跑,而且会话卡死时它依赖 `setInterval` 还在(热更把它换掉就没了)。
406
+ * 看门狗每 `heartbeatMs` 看一次"链路最近有没有进展":
407
+ * * 已连上但超过 `pongTimeoutMs` 没动静 ⇒ 强制拆掉这条 socket(会话随即返回、进重连);
408
+ * * **循环已经不在了**(`_loopActive === false`,典型是热更换掉了实例)⇒ 重新拉起循环。
409
+ * 正常重连退避期间(循环还在、状态是 connecting/backoff)它什么都不做,只在
410
+ * 超过 `watchdogMs` 仍无进展时把 socket 拆掉重来一次,避免"半开连接永远挂着"。
411
+ */
412
+ #startWatchdog() {
413
+ this.#stopWatchdog()
414
+ // 检查频率:至少在"判定超时"的窗口里看两次,最多 5 秒一次、最少 1 秒一次。
415
+ // 不能跟着 `heartbeatMs` 走——心跳间隔设得大(例如测试里 60s)会让看门狗也 20s 才醒一次。
416
+ const every = Math.max(1000, Math.min(Math.floor(this.pongTimeoutMs / 2) || 1000, 5000))
417
+ this._watchdog = setInterval(() => {
418
+ if (this._stopping || this.enabled === false) return
419
+ const idle = this.now() - this._lastProgressAt
420
+ if (!this._loopActive) {
421
+ this.lastError = `链路循环不在了(看门狗重拉,空闲 ${Math.round(idle / 1000)}s)`
422
+ this.logger.warn?.(`mobile-link: ${this.lastError}`)
423
+ this._loop = undefined
424
+ this._lastProgressAt = this.now()
425
+ void this.start()
426
+ return
427
+ }
428
+ if (this.state === 'connected' && idle > this.pongTimeoutMs) {
429
+ this.lastError = `relay 超过 ${Math.round(idle / 1000)}s 没有动静(看门狗拆连接)`
430
+ this.logger.warn?.(`mobile-link: ${this.lastError}`)
431
+ this.#teardownSocket(this.lastError)
432
+ this._lastProgressAt = this.now()
433
+ return
434
+ }
435
+ if (this.state !== 'connected' && idle > this.watchdogMs) {
436
+ this.lastError = `链路 ${Math.round(idle / 1000)}s 没有进展(看门狗重建连接)`
437
+ this.logger.warn?.(`mobile-link: ${this.lastError}`)
438
+ this.#teardownSocket(this.lastError)
439
+ this._lastProgressAt = this.now()
440
+ }
441
+ }, every)
442
+ this._watchdog.unref?.()
443
+ }
444
+
445
+ #stopWatchdog() {
446
+ if (this._watchdog) clearInterval(this._watchdog)
447
+ this._watchdog = undefined
448
+ }
449
+
365
450
  #startHeartbeat() {
366
451
  this.#stopHeartbeat()
367
452
  this._heartbeat = setInterval(() => {
@@ -440,9 +525,12 @@ export class MobileLinkAgent extends EventEmitter {
440
525
 
441
526
  /** Route one frame from the relay. */
442
527
  async handleRelayFrame(frame) {
528
+ // 中转来的任何一帧都算"链路有进展"——看门狗据此区分"半开连接"与"正在正常工作"。
529
+ this._lastProgressAt = this.now()
443
530
  if (!frame || typeof frame !== 'object') return
444
531
  if (frame.t === 'pong') {
445
532
  this._lastPong = this.now()
533
+ this._lastProgressAt = this.now()
446
534
  return
447
535
  }
448
536
  if (frame.t === 'ping') {
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
  *