dsh-plugin-mobile-link 0.7.0 → 0.8.1

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/link.js CHANGED
@@ -5,9 +5,10 @@
5
5
  */
6
6
 
7
7
  import { EventEmitter } from 'node:events'
8
+ import { dirname, join } from 'node:path'
8
9
 
9
10
  import { DshClient } from './dsh-client.js'
10
- import { agentEndpoint, decodeFrame, encodeFrameChecked, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
11
+ import { agentEndpoint, decodeFrame, deviceListEndpoint, deviceRevokeEndpoint, encodeFrameChecked, isPlaceholderRelayUrl, nextBackoff, normalizeRelayUrl, pongFor, qrPayload } from './dlp.js'
11
12
 
12
13
  /**
13
14
  * 进程级登记表:**同一个进程里只允许有一条到中转的 agent 连接**。
@@ -18,7 +19,7 @@ import { agentEndpoint, decodeFrame, encodeFrameChecked, nextBackoff, normalizeR
18
19
  * 挂在 `globalThis` 上是因为模块重载会把模块作用域清掉,只有进程级才留得住。
19
20
  */
20
21
  const LIVE_AGENT = Symbol.for('dsh.mobile-link.live-agent')
21
- import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
22
+ import { EnrollError, enrollAgent, enrollCommand, inviteFrom, reportMachineAddress } from './enroll.js'
22
23
  import { handoffFromEndpoint, writeHandoff } from './handoff.js'
23
24
  import { LinkJournal, defaultJournalPath } from './journal.js'
24
25
  import { LogSpool, defaultLogsRoot } from './logs.js'
@@ -30,11 +31,17 @@ import {
30
31
  } from './router.js'
31
32
  import { SERVER_VERSION, SERVER_CAPABILITIES, hostVersion } from './hello.js'
32
33
  import { createJobsRoster } from './jobs-roster.js'
33
- import { MissingIdentity, resolveIdentity } from './state.js'
34
+ import { MissingIdentity, defaultStatePath, readState, resolveIdentity, writeState } from './state.js'
34
35
  import { connect as wsConnect } from './ws.js'
35
36
 
36
37
  const PROTOCOL_VERSION = 1
37
38
 
39
+ // WebSocket 关闭码:中转据此区分"计划内重连"和"需要处理的异常"(SR-08.08)。
40
+ // 4002 已用于中转协议错误;这里补上两类自愈失败的码,不要再把 1000 复用给它们。
41
+ const CLOSE_NORMAL = 1000
42
+ const CLOSE_WATCHDOG = 4003
43
+ const CLOSE_HEARTBEAT = 4004
44
+
38
45
 
39
46
  function sleep(ms, signal) {
40
47
  return new Promise((resolve) => {
@@ -54,6 +61,55 @@ function numberFromEnv(raw, fallback) {
54
61
  return Number.isFinite(value) && value >= 0 ? value : fallback
55
62
  }
56
63
 
64
+ // ── 持久码缓存 ──────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * 上次铸出来的持久码放在身份文件旁边(0600)。
68
+ *
69
+ * 中转只存码的哈希,铸码接口不会把同一张码再给一次,所以「小窗复用同一张持久码」
70
+ * (docs/PAIRING.md「二维码」一行)只能由连接器自己记住:面板每次打开都会请求一次码,
71
+ * 没有这份缓存就变成每开一次、每重载一次都多一张永久有效的码。
72
+ */
73
+ function pairCodeCachePath(config) {
74
+ if (config?.pairCodePath) return config.pairCodePath
75
+ return join(dirname(config?.stateFile || defaultStatePath()), 'pair-code.json')
76
+ }
77
+
78
+ async function rememberPairCode(config, minted) {
79
+ if (!minted?.code) return
80
+ try {
81
+ await writeState(pairCodeCachePath(config), {
82
+ code: minted.code,
83
+ agentId: minted.agentId,
84
+ relayUrl: minted.relayUrl,
85
+ maxPairedDevices: minted.maxPairedDevices ?? null,
86
+ pairedDevices: Number(minted.pairedDevices ?? 0),
87
+ savedAt: Date.now(),
88
+ })
89
+ } catch (error) {
90
+ // 缓存写不进去只影响"下次还会不会换码",不该让这次取码失败。
91
+ config?.logger?.warn?.(`mobile-link: could not remember the pairing code: ${messageOf(error)}`)
92
+ }
93
+ }
94
+
95
+ async function readRememberedPairCode(config, identity) {
96
+ const file = await readState(pairCodeCachePath(config))
97
+ if (!file || typeof file.code !== 'string' || !file.code) return null
98
+ // 身份变了(重新登记、换了中转)就不是同一台电脑的码,不能拿来复用。
99
+ if (file.agentId !== identity.agentId || file.relayUrl !== identity.relayUrl) return null
100
+ return {
101
+ code: file.code,
102
+ expiresAt: null,
103
+ ttlMs: null,
104
+ persistent: true,
105
+ maxPairedDevices: Number(file.maxPairedDevices ?? 0) || null,
106
+ pairedDevices: Number(file.pairedDevices ?? 0),
107
+ relayUrl: identity.relayUrl,
108
+ agentId: identity.agentId,
109
+ qrPayload: qrPayload({ relay: identity.relayUrl, code: file.code }),
110
+ }
111
+ }
112
+
57
113
  export class MobileLinkAgent extends EventEmitter {
58
114
  constructor({
59
115
  relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl,
@@ -64,7 +120,7 @@ export class MobileLinkAgent extends EventEmitter {
64
120
  slowRequestMs = DEFAULT_SLOW_REQUEST_MS,
65
121
  // 关掉手机推送的接收(回退闸);见 contracts/connector-diagnostics-method.md §4。
66
122
  diagnostics = true,
67
- inviteCode, fetchImpl = fetch, env = process.env,
123
+ inviteCode, defaultRelayUrl, fetchImpl = fetch, env = process.env,
68
124
  // 生命周期流水(见 journal.js):`journal` 传实例(测试注入),`journalPath`
69
125
  // 传路径(`defaultJournalPath()` 之外的位置),false 表示只留内存不落盘。
70
126
  journal, journalPath,
@@ -76,7 +132,7 @@ export class MobileLinkAgent extends EventEmitter {
76
132
  eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
77
133
  } = {}) {
78
134
  super()
79
- this.config = { relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, inviteCode }
135
+ this.config = { relayUrl, defaultRelayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, inviteCode }
80
136
  this.logger = logger
81
137
  this.heartbeatMs = heartbeatMs
82
138
  this.pongTimeoutMs = pongTimeoutMs
@@ -138,6 +194,9 @@ export class MobileLinkAgent extends EventEmitter {
138
194
  /// 历史读取(spec 007):宿主 `sessionQuery` 就绪后由插件挂上;没挂上时
139
195
  /// 路由回 `link/unsupported`,手机继续走 `session/page`。
140
196
  this.setHistoryService = (service) => { this.router.history = service }
197
+ /// 图片两变体(预览 + 可选原图):宿主 `attachments` / `fileUploads` 就绪后由插件挂上;
198
+ /// 没挂上时三个 `_link/image*` 方法回 `link/unsupported`(手机显示"原图不可用")。
199
+ this.setImageVariants = (service) => { this.router.bindImageVariants(service) }
141
200
  /// 活动名单与 `link/jobs` 推帧(spec 018):帧由 router 广播给在线设备,
142
201
  /// 订阅与去抖都在 roster 里。宿主服务由 `setJobsServices` 挂上。
143
202
  this.jobsRoster = createJobsRoster({
@@ -147,6 +206,9 @@ export class MobileLinkAgent extends EventEmitter {
147
206
  })
148
207
  this.router.bindJobsRoster(this.jobsRoster)
149
208
  this.setJobsServices = (services) => this.jobsRoster.bindServices(services)
209
+ /// 提醒口径(SR-02.08):只报正式会话。宿主 `ctx.sessions` / `ctx.workspaceRegistry`
210
+ /// 由插件在 `wireSessionScope` 里挂上;缺一个就维持旧行为(全报)。
211
+ this.setSessionScopeServices = (services) => this.router.bindSessionScope(services)
150
212
  this.relay = undefined
151
213
  this.socket = undefined
152
214
  this.state = 'idle'
@@ -173,6 +235,8 @@ export class MobileLinkAgent extends EventEmitter {
173
235
  this._lastPong = 0
174
236
  this._loop = undefined
175
237
  this._loopActive = false
238
+ /** Single-flight guard for panel enrollment: two clicks redeem one code. */
239
+ this._enrolling = undefined
176
240
  this._watchdog = undefined
177
241
  this._lastProgressAt = 0
178
242
  this._abort = new AbortController()
@@ -285,7 +349,26 @@ export class MobileLinkAgent extends EventEmitter {
285
349
  }
286
350
  }
287
351
  globalThis[LIVE_AGENT] = this
288
- if (this._loop) return this
352
+ // 只认"正在跑"的 loop:loop 可能因为未登记而结束,此时 `_loop` 还留着已完成的
353
+ // promise;拿它当"已在运行"会让面板登记完之后再也起不来。
354
+ if (this._loopActive) return this
355
+ // 显式 start 表示"要跑":把 stop() 留下的栅栏放下来;不要靠 #launch 里
356
+ // 那个"正在停止就别启动"的保护来代劳。
357
+ this._stopping = false
358
+ this.#launch()
359
+ return this
360
+ }
361
+
362
+ /**
363
+ * Start one run of the reconnect loop.
364
+ *
365
+ * The single owner of "how a loop starts": `start()` calls it on boot and
366
+ * `enroll()` calls it after the identity is written. Never copy the loop
367
+ * bootstrapping into a second path — a divergent copy would skip the watchdog,
368
+ * the journal entry or the LIVE_AGENT bookkeeping.
369
+ */
370
+ #launch() {
371
+ if (this._stopping) return
289
372
  this._stopping = false
290
373
  this._abort = new AbortController()
291
374
  this.startedAt = this.now()
@@ -311,7 +394,6 @@ export class MobileLinkAgent extends EventEmitter {
311
394
  }).finally(() => {
312
395
  this._loopActive = false
313
396
  })
314
- return this
315
397
  }
316
398
 
317
399
  async stop() {
@@ -397,7 +479,13 @@ export class MobileLinkAgent extends EventEmitter {
397
479
  * starts) and because `status()` reports whether it is still outstanding.
398
480
  */
399
481
  async enrollNow() {
400
- const relayUrl = this.config.relayUrl
482
+ // A packaged install may pin only `defaultRelayUrl` (the panel prefill) and
483
+ // leave `relayUrl` at the placeholder; boot-time enrollment should still use
484
+ // the default rather than dial example.com.
485
+ const configured = this.config.relayUrl
486
+ const relayUrl = configured && !isPlaceholderRelayUrl(configured)
487
+ ? configured
488
+ : (this.config.defaultRelayUrl || configured)
401
489
  const settled = await enrollAgent({
402
490
  relayUrl,
403
491
  inviteCode: inviteFrom({ inviteCode: this.config.inviteCode, env: this.env }),
@@ -406,15 +494,151 @@ export class MobileLinkAgent extends EventEmitter {
406
494
  fetchImpl: this.fetchImpl,
407
495
  logger: this.logger,
408
496
  })
497
+ this.identity = settled
409
498
  this.needsEnroll = false
410
499
  this.lastError = undefined
411
500
  this.emit('status')
412
501
  return settled
413
502
  }
414
503
 
504
+ /**
505
+ * Redeem an invite code supplied by the panel, then start the link in place.
506
+ *
507
+ * This is the only path that may enroll *while the process is running*. It is
508
+ * single-flight (a double click must not consume two codes), refuses to replace
509
+ * an existing identity, and writes nothing on failure. On success the loop is
510
+ * relaunched here, so the person does not have to restart DSH.
511
+ *
512
+ * @param {{inviteCode: string, relayUrl: string, agentName?: string}} options
513
+ */
514
+ enroll({ inviteCode, relayUrl, agentName } = {}) {
515
+ if (this._enrolling) return this._enrolling
516
+ this._enrolling = this.#enrollOnce({ inviteCode, relayUrl, agentName })
517
+ .finally(() => { this._enrolling = undefined })
518
+ return this._enrolling
519
+ }
520
+
521
+ async #enrollOnce({ inviteCode, relayUrl, agentName }) {
522
+ if (this.enabled === false) {
523
+ throw new EnrollError('enroll/disabled', 'mobile-link: the connector is disabled; enable it before enrolling')
524
+ }
525
+ if (this._stopping) {
526
+ throw new EnrollError('enroll/unavailable', 'mobile-link: the connector is stopping; try again')
527
+ }
528
+ const stateFile = this.config.stateFile || defaultStatePath()
529
+ const existing = await readState(stateFile)
530
+ if (existing?.agentId && existing?.agentSecret) {
531
+ // Never replace a working identity behind the user's back: that would
532
+ // silently unpair every phone that is already attached.
533
+ throw new EnrollError('enroll/already-registered',
534
+ `mobile-link: this computer is already registered (${existing.agentId}); refusing to replace it`,
535
+ { status: 409 })
536
+ }
537
+
538
+ const settled = await enrollAgent({
539
+ relayUrl,
540
+ inviteCode,
541
+ name: agentName,
542
+ stateFile,
543
+ fetchImpl: this.fetchImpl,
544
+ logger: this.logger,
545
+ })
546
+
547
+ // Publish the fresh identity before relaunching: `#enrollPending()` consults
548
+ // `this.identity`, and a stale value would make the new loop redeem the
549
+ // configured/env invite a second time on top of what the panel just wrote.
550
+ this.identity = settled
551
+ this.needsEnroll = false
552
+ this.lastError = undefined
553
+ this.reconnectAttempts = 0
554
+ this.emit('status')
555
+
556
+ if (this.enabled !== false && !this._stopping) {
557
+ // If the previous loop is unwinding, let it finish before starting a new
558
+ // one; two loops would fight over the same relay agent session.
559
+ if (this._loopActive) await this._loop?.catch(() => {})
560
+ if (!this._loopActive && !this._stopping) this.#launch()
561
+ }
562
+ return settled
563
+ }
564
+
565
+ /**
566
+ * List every paired device for this agent, including ones that are offline.
567
+ * The router only knows attached devices; the panel needs the full roster so
568
+ * "已配对 N/M" and the visible rows describe the same set.
569
+ */
570
+ async listPairedDevices() {
571
+ const identity = this.identity ?? await resolveIdentity(this.config)
572
+ let response
573
+ try {
574
+ response = await this.fetchImpl(deviceListEndpoint(identity.relayUrl), {
575
+ method: 'GET',
576
+ headers: { authorization: `Bearer ${identity.agentSecret}` },
577
+ signal: AbortSignal.timeout?.(15_000),
578
+ })
579
+ } catch (error) {
580
+ throw new Error(`mobile-link: cannot reach the relay to list devices: ${messageOf(error)}`)
581
+ }
582
+ let body
583
+ try {
584
+ body = await response.json()
585
+ } catch {
586
+ throw new Error(`mobile-link: the relay answered HTTP ${response.status} to /devices`)
587
+ }
588
+ if (!response.ok || body?.ok !== true) {
589
+ throw new Error(body?.error?.message ?? `mobile-link: the relay rejected the device list (HTTP ${response.status})`)
590
+ }
591
+ return {
592
+ devices: Array.isArray(body.devices) ? body.devices : [],
593
+ maxPairedDevices: body.maxPairedDevices ?? null,
594
+ pairedDevices: body.pairedDevices ?? null,
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Revoke one paired device through the relay, authenticated with this agent's
600
+ * secret. The relay enforces that the device belongs to this agent and drops
601
+ * its socket; the local router learns about it via `deviceDetach`.
602
+ *
603
+ * @param {string} deviceId
604
+ */
605
+ async revokeDevice(deviceId) {
606
+ const id = typeof deviceId === 'string' ? deviceId.trim() : ''
607
+ if (!id) throw new Error('mobile-link: a deviceId is required')
608
+ const identity = this.identity ?? await resolveIdentity(this.config)
609
+ let response
610
+ try {
611
+ response = await this.fetchImpl(deviceRevokeEndpoint(identity.relayUrl), {
612
+ method: 'POST',
613
+ headers: {
614
+ 'content-type': 'application/json',
615
+ authorization: `Bearer ${identity.agentSecret}`,
616
+ },
617
+ body: JSON.stringify({ deviceId: id }),
618
+ signal: AbortSignal.timeout?.(15_000),
619
+ })
620
+ } catch (error) {
621
+ throw new Error(`mobile-link: cannot reach the relay to revoke ${id}: ${messageOf(error)}`)
622
+ }
623
+ let body
624
+ try {
625
+ body = await response.json()
626
+ } catch {
627
+ throw new Error(`mobile-link: the relay answered HTTP ${response.status} to /devices/revoke`)
628
+ }
629
+ if (!response.ok || body?.ok !== true) {
630
+ throw new Error(body?.error?.message ?? `mobile-link: the relay rejected the revoke (HTTP ${response.status})`)
631
+ }
632
+ return { deviceId: body.deviceId ?? id }
633
+ }
634
+
415
635
  /** What a person has to run to register this computer. */
416
636
  enrollHint() {
417
- return enrollCommand(this.config.relayUrl, this.config.agentName)
637
+ const configured = this.config.relayUrl
638
+ const relay = configured && !isPlaceholderRelayUrl(configured)
639
+ ? configured
640
+ : (this.config.defaultRelayUrl || configured)
641
+ return enrollCommand(relay, this.config.agentName)
418
642
  }
419
643
 
420
644
  /** One relay connection: resolves when the socket closes. */
@@ -422,6 +646,7 @@ export class MobileLinkAgent extends EventEmitter {
422
646
  const identity = await resolveIdentity(this.config)
423
647
  this.identity = identity
424
648
  this.needsEnroll = false
649
+ this.#backfillMachine(identity)
425
650
  this.relay = normalizeRelayUrl(identity.relayUrl)
426
651
  const url = `${agentEndpoint(identity.relayUrl)}?agentId=${encodeURIComponent(identity.agentId)}`
427
652
  const socket = await this.connectImpl(url, {
@@ -518,7 +743,7 @@ export class MobileLinkAgent extends EventEmitter {
518
743
  this.lastError = `relay 超过 ${Math.round(idle / 1000)}s 没有动静(看门狗拆连接)`
519
744
  this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
520
745
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
521
- this.#teardownSocket(this.lastError)
746
+ this.#teardownSocket(this.lastError, CLOSE_HEARTBEAT, 'heartbeat timeout')
522
747
  this._lastProgressAt = this.now()
523
748
  return
524
749
  }
@@ -526,7 +751,7 @@ export class MobileLinkAgent extends EventEmitter {
526
751
  this.lastError = `链路 ${Math.round(idle / 1000)}s 没有进展(看门狗重建连接)`
527
752
  this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
528
753
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
529
- this.#teardownSocket(this.lastError)
754
+ this.#teardownSocket(this.lastError, CLOSE_WATCHDOG, 'watchdog timeout')
530
755
  this._lastProgressAt = this.now()
531
756
  }
532
757
  }, every)
@@ -544,7 +769,7 @@ export class MobileLinkAgent extends EventEmitter {
544
769
  if (this.now() - this._lastPong > this.pongTimeoutMs) {
545
770
  this.lastError = `relay stopped answering pings (>${this.pongTimeoutMs}ms)`
546
771
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
547
- this.#teardownSocket(this.lastError)
772
+ this.#teardownSocket(this.lastError, CLOSE_HEARTBEAT, 'heartbeat timeout')
548
773
  return
549
774
  }
550
775
  this.#rawSend({ t: 'ping', ts: this.now() })
@@ -557,14 +782,14 @@ export class MobileLinkAgent extends EventEmitter {
557
782
  this._heartbeat = undefined
558
783
  }
559
784
 
560
- #teardownSocket(reason) {
785
+ #teardownSocket(reason, closeCode = CLOSE_NORMAL, closeReason = 'reconnecting') {
561
786
  this.#stopHeartbeat()
562
787
  const socket = this.socket
563
788
  const from = this.state
564
789
  this.socket = undefined
565
790
  if (socket) {
566
791
  try {
567
- socket.close(1000, 'reconnecting')
792
+ socket.close(closeCode, closeReason)
568
793
  } catch {
569
794
  /* already gone */
570
795
  }
@@ -581,6 +806,7 @@ export class MobileLinkAgent extends EventEmitter {
581
806
  // connectedForMs 用来区分"刚连上就掉"和"挂了很久才掉"。
582
807
  this.journal.record('link.close', {
583
808
  reason,
809
+ closeCode,
584
810
  from,
585
811
  connectedForMs: this.connectedAt === undefined ? undefined : this.now() - this.connectedAt,
586
812
  // 有系统错误才带这个字段(正常关闭时不该凭空多一个 null)。
@@ -686,6 +912,27 @@ export class MobileLinkAgent extends EventEmitter {
686
912
 
687
913
  // ── mux-event delegates ─────────────────────────────────────────────────
688
914
 
915
+ /// 老身份在登记时还没有 MAC 字段:连接成功后补一次。只填一次,失败下轮再试,
916
+ /// 旧中转 404 视为不支持而不是链路故障。
917
+ #backfillMachine(identity) {
918
+ if (this._machineReportedFor === identity.agentId || this._machineReporting) return
919
+ this._machineReporting = true
920
+ void reportMachineAddress({
921
+ relayUrl: identity.relayUrl,
922
+ agentSecret: identity.agentSecret,
923
+ fetchImpl: this.fetchImpl,
924
+ }).then((result) => {
925
+ if (result.ok || result.unsupported) this._machineReportedFor = identity.agentId
926
+ if (result.ok && result.updated) {
927
+ this.logger.info?.(`mobile-link: recorded machine MAC ${result.macAddress}`)
928
+ }
929
+ }).catch((error) => {
930
+ this.logger.debug?.(`mobile-link: machine MAC backfill deferred: ${messageOf(error)}`)
931
+ }).finally(() => {
932
+ this._machineReporting = false
933
+ })
934
+ }
935
+
689
936
  onStreamItem(muxId, value) {
690
937
  this.router.onStreamItem(muxId, value)
691
938
  }
@@ -702,7 +949,7 @@ export class MobileLinkAgent extends EventEmitter {
702
949
  this.router.onMuxDown(reason)
703
950
  }
704
951
 
705
- // ── relay-side helpers ──────────────────────────────────────────────────
952
+ // ── relay-side helpers ──────────────────────────────────────────────────
706
953
 
707
954
  /**
708
955
  * Ask the relay to mint a pairing code for this agent (relay NOTES.md §2).
@@ -711,13 +958,23 @@ export class MobileLinkAgent extends EventEmitter {
711
958
  */
712
959
  async mintPairCode({ ttlMs = null, fetchImpl = fetch } = {}) {
713
960
  const identity = this.identity ?? await resolveIdentity(this.config)
714
- return mintPairCode({ identity, ttlMs, fetchImpl })
961
+ // 持久码:手上已经有一张就直接给它。中转那边只有哈希,重铸只会多出一张有效码。
962
+ if (ttlMs == null) {
963
+ const remembered = await readRememberedPairCode(this.config, identity)
964
+ if (remembered) return remembered
965
+ }
966
+ const minted = await mintPairCode({ identity, ttlMs, fetchImpl })
967
+ if (minted.persistent) await rememberPairCode(this.config, minted)
968
+ return minted
715
969
  }
716
970
 
717
971
  /** 用户点「重新生成」:作废旧码、铸新的,**不碰已配对设备**(032)。 */
718
972
  async regeneratePairCode({ fetchImpl = fetch } = {}) {
719
973
  const identity = this.identity ?? await resolveIdentity(this.config)
720
- return regeneratePairCode({ identity, fetchImpl })
974
+ const minted = await regeneratePairCode({ identity, fetchImpl })
975
+ // 中转已经把旧码全部作废,这里换成新的那张。
976
+ await rememberPairCode(this.config, minted)
977
+ return minted
721
978
  }
722
979
 
723
980
  /** Snapshot for `GET /mobile-link/status`(载荷本身在 status.js,便于守住"只增不改")。 */
package/lib/router.js CHANGED
@@ -16,9 +16,11 @@ 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 { isImageVariantMethod } from './image-variants.js'
19
20
  import { isHistoryMethod } from './history.js'
20
21
  import { trimRecordsForWire } from './history/records.js'
21
22
  import { isJobsMethod } from './jobs-roster.js'
23
+ import { createSessionScope } from './session-scope.js'
22
24
 
23
25
  /**
24
26
  * 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
@@ -176,6 +178,12 @@ export class DeviceRouter {
176
178
  // 「跑完了 / 待你回应」这类事件到了就报一声(见 `notifyFor`)。默认空函数,
177
179
  // 测试与不关心提醒的调用方可以不管它。帧由 link.js 发(router 不发帧)。
178
180
  this.notify = notify
181
+ /**
182
+ * 提醒口径(SR-02.08):只报正式会话。`undefined` = 宿主服务还没绑上,按旧行为
183
+ * 全报;绑上之后由 `session-scope.js` 判定。列表顶部的待答计数在 App 侧另算,
184
+ * 走同一份「正式会话」定义。
185
+ */
186
+ this.sessionScope = undefined
179
187
  // 生命周期事件(设备 attach/detach 等)的去处,由 link.js 接到流水(journal.js)。
180
188
  // 默认空函数,所以 router 单独用时(单测、本地脚本)不需要日志设施。
181
189
  this.onEvent = onEvent
@@ -204,6 +212,15 @@ export class DeviceRouter {
204
212
  // 活动名单与 `link/jobs` 推帧(spec 018)。订阅与去抖都在 roster 里,router 只负责
205
213
  // 转交设备生命周期与把帧广播出去。
206
214
  this.jobsRoster = undefined
215
+ // 图片的两变体能力(预览 + 可选原图):由 index.js 注入宿主 `attachments` /
216
+ // `fileUploads` 后挂上。没挂上时三个保留方法回 `link/unsupported`,
217
+ // 手机显示"原图不可用",预览照旧。
218
+ this.imageVariants = undefined
219
+ }
220
+
221
+ /** 挂上图片变体能力;`undefined` 表示宿主服务缺失(如实报不支持)。 */
222
+ bindImageVariants(service) {
223
+ this.imageVariants = service
207
224
  }
208
225
 
209
226
  /**
@@ -218,6 +235,21 @@ export class DeviceRouter {
218
235
  for (const deviceId of this.devices.keys()) roster.addDevice(deviceId)
219
236
  }
220
237
 
238
+ /**
239
+ * 挂上提醒口径所需的宿主账本(`ctx.sessions` / `ctx.workspaceRegistry`)。
240
+ *
241
+ * 传 `undefined` 摘掉:宿主换代或插件卸载后不能拿着失效服务对象去判定。
242
+ */
243
+ bindSessionScope(services) {
244
+ this.sessionScope = services === undefined ? undefined : createSessionScope(services)
245
+ }
246
+
247
+ /** 这条会话是否值得报一声(只看正式会话;服务未绑上时保持旧行为)。 */
248
+ #shouldNotify(sessionId) {
249
+ if (typeof sessionId !== 'string' || sessionId === '') return false
250
+ return this.sessionScope === undefined || this.sessionScope.isFormal(sessionId)
251
+ }
252
+
221
253
  #changed() {
222
254
  this.onChange()
223
255
  }
@@ -658,9 +690,10 @@ export class DeviceRouter {
658
690
  const since = typeof args.since === 'string' ? args.since : null
659
691
  this.digestCursors ??= new Map()
660
692
  const previous = since ? this.digestCursors.get(deviceId) : null
661
- // 版本对得上才发增量;对不上(首次、连接器重启、设备被清理)发全量。
662
- const incremental = Boolean(previous && previous.version === since)
663
- const delta = digestDelta(incremental ? previous.fingerprints : null, items)
693
+ // 版本对得上才认那份指纹表;对不上(首次、连接器重启、设备被清理)就当没有历史。
694
+ // 能不能当增量发由 digestDelta 决定:它持有指纹,也只有它能判断身份是否可识别。
695
+ const usable = previous && previous.version === since ? previous : null
696
+ const delta = digestDelta(usable ? usable.fingerprints : null, items)
664
697
  this.digestCursors.set(deviceId, { version: delta.version, fingerprints: delta.fingerprints })
665
698
  if (this.digestCursors.size > 32) {
666
699
  const oldest = this.digestCursors.keys().next().value
@@ -668,10 +701,10 @@ export class DeviceRouter {
668
701
  }
669
702
  const value = {
670
703
  ...trimmed,
671
- items: incremental ? delta.changed : items,
672
- removed: incremental ? delta.removed : [],
704
+ items: delta.changed,
705
+ removed: delta.removed,
673
706
  version: delta.version,
674
- full: !incremental,
707
+ full: delta.full,
675
708
  }
676
709
  payload = { ok: true, value: encodeDigest(value, args.encoding) }
677
710
  }
@@ -754,6 +787,30 @@ export class DeviceRouter {
754
787
  return
755
788
  }
756
789
 
790
+ // 图片变体(预览 + 原图):手机先经 `_link/file*` 把字节落进收件箱,再来这里
791
+ // 让连接器把它们交给 Host 附件服务并登记关联。自己回答而不是转发:Host
792
+ // 没有对应的 RPC,而这正是保留方法存在的理由。
793
+ if (isImageVariantMethod(frame.method)) {
794
+ let result
795
+ try {
796
+ if (this.imageVariants === undefined) {
797
+ result = {
798
+ ok: false,
799
+ error: errorObject('link/unsupported', 'image variants are unavailable on this connector'),
800
+ }
801
+ } else {
802
+ result = { ok: true, value: await this.imageVariants.handle(frame.method, frame.args ?? {}) }
803
+ }
804
+ } catch (error) {
805
+ result = {
806
+ ok: false,
807
+ error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}),
808
+ }
809
+ }
810
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
811
+ return
812
+ }
813
+
757
814
  // Git also runs here, on the machine that holds the work tree: the Host has
758
815
  // no endpoint that runs a command, and the review a person wants — what
759
816
  // changed, what the diff says, what the last commits were — is all local.
@@ -1017,6 +1074,9 @@ export class DeviceRouter {
1017
1074
  * `api-session/error`(`:3150`)**有意不推**:它不一定是"跑完了"(也可能是一轮中途
1018
1075
  * 报错后继续),拿它当"跑完了"会发出一堆噪音提醒。这是决定,不是漏做。
1019
1076
  *
1077
+ * **只报正式会话**(SR-02.08):未分组、子会话与已归档的运行结束和待确认不报。
1078
+ * 这就是 `#shouldNotify` 的唯一判据来源;主机服务绑上之前维持旧行为。
1079
+ *
1020
1080
  * 不做去抖、不做队列、不做重试:一轮才一条,发失败(中转断了)就丢。`turnEnd` 丢一条
1021
1081
  * 不值得为它做重试;`attention` 丢不了,它同时被记账,手机回来会补发。
1022
1082
  */
@@ -1024,11 +1084,13 @@ export class DeviceRouter {
1024
1084
  if (!value || typeof value !== 'object') return
1025
1085
  if (value.type === 'emit' && value.event === 'api-session/status' && value.args?.[1] === false) {
1026
1086
  const sid = String(value.args[0] ?? '')
1027
- if (sid) this.notify({ kind: 'turnEnd', sid })
1087
+ if (this.#shouldNotify(sid)) this.notify({ kind: 'turnEnd', sid })
1028
1088
  return
1029
1089
  }
1030
1090
  if (value.type === 'waterfall') {
1031
- this.notify({ kind: 'attention', sid: value.agentId, eid: value.eventId })
1091
+ if (this.#shouldNotify(value.agentId)) {
1092
+ this.notify({ kind: 'attention', sid: value.agentId, eid: value.eventId })
1093
+ }
1032
1094
  }
1033
1095
  }
1034
1096