dsh-plugin-mobile-link 0.6.6 → 0.8.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/link.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { EventEmitter } from 'node:events'
8
8
 
9
9
  import { DshClient } from './dsh-client.js'
10
- import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
10
+ import { agentEndpoint, decodeFrame, deviceListEndpoint, deviceRevokeEndpoint, encodeFrameChecked, isPlaceholderRelayUrl, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
11
11
 
12
12
  /**
13
13
  * 进程级登记表:**同一个进程里只允许有一条到中转的 agent 连接**。
@@ -18,9 +18,10 @@ import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl
18
18
  * 挂在 `globalThis` 上是因为模块重载会把模块作用域清掉,只有进程级才留得住。
19
19
  */
20
20
  const LIVE_AGENT = Symbol.for('dsh.mobile-link.live-agent')
21
- import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
21
+ import { EnrollError, enrollAgent, enrollCommand, inviteFrom, reportMachineAddress } from './enroll.js'
22
22
  import { handoffFromEndpoint, writeHandoff } from './handoff.js'
23
23
  import { LinkJournal, defaultJournalPath } from './journal.js'
24
+ import { LogSpool, defaultLogsRoot } from './logs.js'
24
25
  import { statusSnapshot } from './status.js'
25
26
  import { mintPairCode, regeneratePairCode } from './pairing.js'
26
27
  import {
@@ -28,7 +29,8 @@ import {
28
29
  EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
29
30
  } from './router.js'
30
31
  import { SERVER_VERSION, SERVER_CAPABILITIES, hostVersion } from './hello.js'
31
- import { MissingIdentity, resolveIdentity } from './state.js'
32
+ import { createJobsRoster } from './jobs-roster.js'
33
+ import { MissingIdentity, defaultStatePath, readState, resolveIdentity } from './state.js'
32
34
  import { connect as wsConnect } from './ws.js'
33
35
 
34
36
  const PROTOCOL_VERSION = 1
@@ -62,16 +64,19 @@ export class MobileLinkAgent extends EventEmitter {
62
64
  slowRequestMs = DEFAULT_SLOW_REQUEST_MS,
63
65
  // 关掉手机推送的接收(回退闸);见 contracts/connector-diagnostics-method.md §4。
64
66
  diagnostics = true,
65
- inviteCode, fetchImpl = fetch, env = process.env,
67
+ inviteCode, defaultRelayUrl, fetchImpl = fetch, env = process.env,
66
68
  // 生命周期流水(见 journal.js):`journal` 传实例(测试注入),`journalPath`
67
69
  // 传路径(`defaultJournalPath()` 之外的位置),false 表示只留内存不落盘。
68
70
  journal, journalPath,
71
+ // 设备日志落盘(见 logs.js):`logs` 传实例(测试注入),`logsRoot` 换目录,
72
+ // `logs: false` 关掉接收(手机会把它当旧连接器,静默忽略)。
73
+ logs = true, logsRoot,
69
74
  // 手机不在时替它攒下来的提问条数上限(见 router.js);环境变量给部署用,
70
75
  // 构造参数给测试用。**没有"留多久"这个旋钮**:`$events` 一直留到设备被撤销。
71
76
  eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
72
77
  } = {}) {
73
78
  super()
74
- this.config = { relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, inviteCode }
79
+ this.config = { relayUrl, defaultRelayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, inviteCode }
75
80
  this.logger = logger
76
81
  this.heartbeatMs = heartbeatMs
77
82
  this.pongTimeoutMs = pongTimeoutMs
@@ -97,6 +102,12 @@ export class MobileLinkAgent extends EventEmitter {
97
102
  now,
98
103
  logger,
99
104
  })
105
+ /** 设备日志落盘:默认开着,目录在身份文件旁边,保留 7 天(清理在写入路径上)。 */
106
+ this.logsSpool = logs === false
107
+ ? null
108
+ : (logs && typeof logs === 'object'
109
+ ? logs
110
+ : new LogSpool({ root: logsRoot, logger, now }))
100
111
  this.router = new DeviceRouter({
101
112
  dsh: this.dsh,
102
113
  logger,
@@ -105,6 +116,7 @@ export class MobileLinkAgent extends EventEmitter {
105
116
  eventsBacklog,
106
117
  slowRequestMs: this.slowRequestMs,
107
118
  diagnostics: this.diagnostics,
119
+ logs: this.logsSpool,
108
120
  send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
109
121
  // 设备侧的 attach/detach 与链路侧同源记进流水:事后想把"手机断了"和
110
122
  // "连接器拆链"对上时间,两边必须写在同一份文件里。
@@ -126,6 +138,15 @@ export class MobileLinkAgent extends EventEmitter {
126
138
  /// 历史读取(spec 007):宿主 `sessionQuery` 就绪后由插件挂上;没挂上时
127
139
  /// 路由回 `link/unsupported`,手机继续走 `session/page`。
128
140
  this.setHistoryService = (service) => { this.router.history = service }
141
+ /// 活动名单与 `link/jobs` 推帧(spec 018):帧由 router 广播给在线设备,
142
+ /// 订阅与去抖都在 roster 里。宿主服务由 `setJobsServices` 挂上。
143
+ this.jobsRoster = createJobsRoster({
144
+ deliver: (value) => this.router.publishEvent(value),
145
+ logger,
146
+ now,
147
+ })
148
+ this.router.bindJobsRoster(this.jobsRoster)
149
+ this.setJobsServices = (services) => this.jobsRoster.bindServices(services)
129
150
  this.relay = undefined
130
151
  this.socket = undefined
131
152
  this.state = 'idle'
@@ -152,6 +173,8 @@ export class MobileLinkAgent extends EventEmitter {
152
173
  this._lastPong = 0
153
174
  this._loop = undefined
154
175
  this._loopActive = false
176
+ /** Single-flight guard for panel enrollment: two clicks redeem one code. */
177
+ this._enrolling = undefined
155
178
  this._watchdog = undefined
156
179
  this._lastProgressAt = 0
157
180
  this._abort = new AbortController()
@@ -264,7 +287,26 @@ export class MobileLinkAgent extends EventEmitter {
264
287
  }
265
288
  }
266
289
  globalThis[LIVE_AGENT] = this
267
- if (this._loop) return this
290
+ // 只认"正在跑"的 loop:loop 可能因为未登记而结束,此时 `_loop` 还留着已完成的
291
+ // promise;拿它当"已在运行"会让面板登记完之后再也起不来。
292
+ if (this._loopActive) return this
293
+ // 显式 start 表示"要跑":把 stop() 留下的栅栏放下来;不要靠 #launch 里
294
+ // 那个"正在停止就别启动"的保护来代劳。
295
+ this._stopping = false
296
+ this.#launch()
297
+ return this
298
+ }
299
+
300
+ /**
301
+ * Start one run of the reconnect loop.
302
+ *
303
+ * The single owner of "how a loop starts": `start()` calls it on boot and
304
+ * `enroll()` calls it after the identity is written. Never copy the loop
305
+ * bootstrapping into a second path — a divergent copy would skip the watchdog,
306
+ * the journal entry or the LIVE_AGENT bookkeeping.
307
+ */
308
+ #launch() {
309
+ if (this._stopping) return
268
310
  this._stopping = false
269
311
  this._abort = new AbortController()
270
312
  this.startedAt = this.now()
@@ -290,7 +332,6 @@ export class MobileLinkAgent extends EventEmitter {
290
332
  }).finally(() => {
291
333
  this._loopActive = false
292
334
  })
293
- return this
294
335
  }
295
336
 
296
337
  async stop() {
@@ -376,7 +417,13 @@ export class MobileLinkAgent extends EventEmitter {
376
417
  * starts) and because `status()` reports whether it is still outstanding.
377
418
  */
378
419
  async enrollNow() {
379
- const relayUrl = this.config.relayUrl
420
+ // A packaged install may pin only `defaultRelayUrl` (the panel prefill) and
421
+ // leave `relayUrl` at the placeholder; boot-time enrollment should still use
422
+ // the default rather than dial example.com.
423
+ const configured = this.config.relayUrl
424
+ const relayUrl = configured && !isPlaceholderRelayUrl(configured)
425
+ ? configured
426
+ : (this.config.defaultRelayUrl || configured)
380
427
  const settled = await enrollAgent({
381
428
  relayUrl,
382
429
  inviteCode: inviteFrom({ inviteCode: this.config.inviteCode, env: this.env }),
@@ -385,15 +432,151 @@ export class MobileLinkAgent extends EventEmitter {
385
432
  fetchImpl: this.fetchImpl,
386
433
  logger: this.logger,
387
434
  })
435
+ this.identity = settled
436
+ this.needsEnroll = false
437
+ this.lastError = undefined
438
+ this.emit('status')
439
+ return settled
440
+ }
441
+
442
+ /**
443
+ * Redeem an invite code supplied by the panel, then start the link in place.
444
+ *
445
+ * This is the only path that may enroll *while the process is running*. It is
446
+ * single-flight (a double click must not consume two codes), refuses to replace
447
+ * an existing identity, and writes nothing on failure. On success the loop is
448
+ * relaunched here, so the person does not have to restart DSH.
449
+ *
450
+ * @param {{inviteCode: string, relayUrl: string, agentName?: string}} options
451
+ */
452
+ enroll({ inviteCode, relayUrl, agentName } = {}) {
453
+ if (this._enrolling) return this._enrolling
454
+ this._enrolling = this.#enrollOnce({ inviteCode, relayUrl, agentName })
455
+ .finally(() => { this._enrolling = undefined })
456
+ return this._enrolling
457
+ }
458
+
459
+ async #enrollOnce({ inviteCode, relayUrl, agentName }) {
460
+ if (this.enabled === false) {
461
+ throw new EnrollError('enroll/disabled', 'mobile-link: the connector is disabled; enable it before enrolling')
462
+ }
463
+ if (this._stopping) {
464
+ throw new EnrollError('enroll/unavailable', 'mobile-link: the connector is stopping; try again')
465
+ }
466
+ const stateFile = this.config.stateFile || defaultStatePath()
467
+ const existing = await readState(stateFile)
468
+ if (existing?.agentId && existing?.agentSecret) {
469
+ // Never replace a working identity behind the user's back: that would
470
+ // silently unpair every phone that is already attached.
471
+ throw new EnrollError('enroll/already-registered',
472
+ `mobile-link: this computer is already registered (${existing.agentId}); refusing to replace it`,
473
+ { status: 409 })
474
+ }
475
+
476
+ const settled = await enrollAgent({
477
+ relayUrl,
478
+ inviteCode,
479
+ name: agentName,
480
+ stateFile,
481
+ fetchImpl: this.fetchImpl,
482
+ logger: this.logger,
483
+ })
484
+
485
+ // Publish the fresh identity before relaunching: `#enrollPending()` consults
486
+ // `this.identity`, and a stale value would make the new loop redeem the
487
+ // configured/env invite a second time on top of what the panel just wrote.
488
+ this.identity = settled
388
489
  this.needsEnroll = false
389
490
  this.lastError = undefined
491
+ this.reconnectAttempts = 0
390
492
  this.emit('status')
493
+
494
+ if (this.enabled !== false && !this._stopping) {
495
+ // If the previous loop is unwinding, let it finish before starting a new
496
+ // one; two loops would fight over the same relay agent session.
497
+ if (this._loopActive) await this._loop?.catch(() => {})
498
+ if (!this._loopActive && !this._stopping) this.#launch()
499
+ }
391
500
  return settled
392
501
  }
393
502
 
503
+ /**
504
+ * List every paired device for this agent, including ones that are offline.
505
+ * The router only knows attached devices; the panel needs the full roster so
506
+ * "已配对 N/M" and the visible rows describe the same set.
507
+ */
508
+ async listPairedDevices() {
509
+ const identity = this.identity ?? await resolveIdentity(this.config)
510
+ let response
511
+ try {
512
+ response = await this.fetchImpl(deviceListEndpoint(identity.relayUrl), {
513
+ method: 'GET',
514
+ headers: { authorization: `Bearer ${identity.agentSecret}` },
515
+ signal: AbortSignal.timeout?.(15_000),
516
+ })
517
+ } catch (error) {
518
+ throw new Error(`mobile-link: cannot reach the relay to list devices: ${messageOf(error)}`)
519
+ }
520
+ let body
521
+ try {
522
+ body = await response.json()
523
+ } catch {
524
+ throw new Error(`mobile-link: the relay answered HTTP ${response.status} to /devices`)
525
+ }
526
+ if (!response.ok || body?.ok !== true) {
527
+ throw new Error(body?.error?.message ?? `mobile-link: the relay rejected the device list (HTTP ${response.status})`)
528
+ }
529
+ return {
530
+ devices: Array.isArray(body.devices) ? body.devices : [],
531
+ maxPairedDevices: body.maxPairedDevices ?? null,
532
+ pairedDevices: body.pairedDevices ?? null,
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Revoke one paired device through the relay, authenticated with this agent's
538
+ * secret. The relay enforces that the device belongs to this agent and drops
539
+ * its socket; the local router learns about it via `deviceDetach`.
540
+ *
541
+ * @param {string} deviceId
542
+ */
543
+ async revokeDevice(deviceId) {
544
+ const id = typeof deviceId === 'string' ? deviceId.trim() : ''
545
+ if (!id) throw new Error('mobile-link: a deviceId is required')
546
+ const identity = this.identity ?? await resolveIdentity(this.config)
547
+ let response
548
+ try {
549
+ response = await this.fetchImpl(deviceRevokeEndpoint(identity.relayUrl), {
550
+ method: 'POST',
551
+ headers: {
552
+ 'content-type': 'application/json',
553
+ authorization: `Bearer ${identity.agentSecret}`,
554
+ },
555
+ body: JSON.stringify({ deviceId: id }),
556
+ signal: AbortSignal.timeout?.(15_000),
557
+ })
558
+ } catch (error) {
559
+ throw new Error(`mobile-link: cannot reach the relay to revoke ${id}: ${messageOf(error)}`)
560
+ }
561
+ let body
562
+ try {
563
+ body = await response.json()
564
+ } catch {
565
+ throw new Error(`mobile-link: the relay answered HTTP ${response.status} to /devices/revoke`)
566
+ }
567
+ if (!response.ok || body?.ok !== true) {
568
+ throw new Error(body?.error?.message ?? `mobile-link: the relay rejected the revoke (HTTP ${response.status})`)
569
+ }
570
+ return { deviceId: body.deviceId ?? id }
571
+ }
572
+
394
573
  /** What a person has to run to register this computer. */
395
574
  enrollHint() {
396
- return enrollCommand(this.config.relayUrl, this.config.agentName)
575
+ const configured = this.config.relayUrl
576
+ const relay = configured && !isPlaceholderRelayUrl(configured)
577
+ ? configured
578
+ : (this.config.defaultRelayUrl || configured)
579
+ return enrollCommand(relay, this.config.agentName)
397
580
  }
398
581
 
399
582
  /** One relay connection: resolves when the socket closes. */
@@ -401,6 +584,7 @@ export class MobileLinkAgent extends EventEmitter {
401
584
  const identity = await resolveIdentity(this.config)
402
585
  this.identity = identity
403
586
  this.needsEnroll = false
587
+ this.#backfillMachine(identity)
404
588
  this.relay = normalizeRelayUrl(identity.relayUrl)
405
589
  const url = `${agentEndpoint(identity.relayUrl)}?agentId=${encodeURIComponent(identity.agentId)}`
406
590
  const socket = await this.connectImpl(url, {
@@ -574,8 +758,30 @@ export class MobileLinkAgent extends EventEmitter {
574
758
 
575
759
  // ── relay I/O ───────────────────────────────────────────────────────────
576
760
 
761
+ /**
762
+ * 序列化一条出站帧,并把"修掉了落单代理项"记下来。
763
+ *
764
+ * 上游(宿主预览)切坏了 emoji 时,出口把它修成 U+FFFD 再发:帧合法,解码方
765
+ * 不会整条失败;同时留一条本机流水,出问题的源头仍然查得到。日志限流一分钟一次,
766
+ * 累计值不丢。
767
+ */
768
+ #encodeForWire(frame) {
769
+ const { text, repaired } = encodeFrameChecked(frame)
770
+ if (repaired <= 0) return text
771
+ this.surrogateRepairs = (this.surrogateRepairs ?? 0) + repaired
772
+ this.journal.record('frame.surrogate_repaired', { repaired, total: this.surrogateRepairs })
773
+ const now = this.now()
774
+ if (now - (this._lastSurrogateWarnAt ?? -Infinity) >= 60_000) {
775
+ this._lastSurrogateWarnAt = now
776
+ this.logger.warn?.(
777
+ `mobile-link: repaired ${repaired} unpaired surrogate(s) in an outbound frame ` +
778
+ `(a host preview was cut inside an emoji); ${this.surrogateRepairs} so far`)
779
+ }
780
+ return text
781
+ }
782
+
577
783
  #rawSend(frame) {
578
- return this.#sendText(encodeFrame(frame))
784
+ return this.#sendText(this.#encodeForWire(frame))
579
785
  }
580
786
 
581
787
  /** 唯一的发送出口:socket 不在就丢掉,发送异常只记 debug、绝不抛。 */
@@ -595,7 +801,7 @@ export class MobileLinkAgent extends EventEmitter {
595
801
  // `deviceId` 为空=这是一条**不带寻址**的帧(`fsPutAck`/`fsPutDone`/`fsErr` 那一族):
596
802
  // relay 靠 `bid` 把回复对回发起的那个 HTTP 请求,多一个 deviceId 反而会让它对不上。
597
803
  if (deviceId === undefined || deviceId === null) return this.#rawSend(frame)
598
- const text = encodeFrame({ ...frame, deviceId })
804
+ const text = this.#encodeForWire({ ...frame, deviceId })
599
805
  // 记字节给路由层的"慢请求/汇总"用(它按 deviceId 归):只有这里知道真正上线了多少字节。
600
806
  this.router.noteSent?.(deviceId, Buffer.byteLength(text))
601
807
  return this.#sendText(text)
@@ -643,6 +849,27 @@ export class MobileLinkAgent extends EventEmitter {
643
849
 
644
850
  // ── mux-event delegates ─────────────────────────────────────────────────
645
851
 
852
+ /// 老身份在登记时还没有 MAC 字段:连接成功后补一次。只填一次,失败下轮再试,
853
+ /// 旧中转 404 视为不支持而不是链路故障。
854
+ #backfillMachine(identity) {
855
+ if (this._machineReportedFor === identity.agentId || this._machineReporting) return
856
+ this._machineReporting = true
857
+ void reportMachineAddress({
858
+ relayUrl: identity.relayUrl,
859
+ agentSecret: identity.agentSecret,
860
+ fetchImpl: this.fetchImpl,
861
+ }).then((result) => {
862
+ if (result.ok || result.unsupported) this._machineReportedFor = identity.agentId
863
+ if (result.ok && result.updated) {
864
+ this.logger.info?.(`mobile-link: recorded machine MAC ${result.macAddress}`)
865
+ }
866
+ }).catch((error) => {
867
+ this.logger.debug?.(`mobile-link: machine MAC backfill deferred: ${messageOf(error)}`)
868
+ }).finally(() => {
869
+ this._machineReporting = false
870
+ })
871
+ }
872
+
646
873
  onStreamItem(muxId, value) {
647
874
  this.router.onStreamItem(muxId, value)
648
875
  }
package/lib/logs.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * 设备日志落盘(`_link/logs`)。
3
+ *
4
+ * 与 `journal.js` 的分工:journal 记的是**连接器自己**的生命周期事件(几百条、可以随手丢),
5
+ * 这里记的是**手机端**产生的日志行(量级大、需要按时间窗回看)。两者共用"诊断设施不许抛"
6
+ * 这条硬约束,但容量与保留期完全不同。
7
+ *
8
+ * 保留期 **7 天**,清理不靠定时器、也不新增任何进程:每次 `append` 顺手做一次,
9
+ * 且 60 秒内最多一次。理由是不引入定时任务就不会有"任务没跑起来、盘悄悄满了"这种状态。
10
+ *
11
+ * 目录布局:`<root>/<deviceId>/<YYYYMMDD>.jsonl`。按天分文件有三个好处:
12
+ * 清理只需 unlink 整个文件,不必重写;`tail` 只需盯当天的文件;超限时按天淘汰。
13
+ */
14
+
15
+ import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ import { defaultStatePath } from './state.js'
19
+
20
+ /** 日志保留期:7 天。 */
21
+ export const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
22
+ /** 单台设备磁盘上限;超了按天淘汰最旧的。 */
23
+ export const DEFAULT_MAX_DEVICE_BYTES = 200 * 1024 * 1024
24
+ /** 单条日志正文上限;超长截断而不是丢弃(截断仍能看出是哪一行)。 */
25
+ export const MAX_TEXT_CHARS = 4000
26
+ /** 每批最多收多少条、多少字节;超了如实报 `accepted`,剩下的由手机下次推。 */
27
+ export const MAX_BATCH_ENTRIES = 500
28
+ export const MAX_BATCH_BYTES = 64 * 1024
29
+ /** 清理的最小间隔:清理一次最多 60 秒一次。 */
30
+ const PRUNE_INTERVAL_MS = 60 * 1000
31
+
32
+ /** 默认落在身份文件旁边:`$DSH_HOME/mobile-link/logs`。测试用临时 DSH_HOME 自动隔离。 */
33
+ export function defaultLogsRoot() {
34
+ return join(dirname(defaultStatePath()), 'logs')
35
+ }
36
+
37
+ /** 本地日 `YYYYMMDD`,与 journal 的 `local_day` 口径一致。 */
38
+ export function dayStamp(ms) {
39
+ const date = new Date(ms)
40
+ const pad = (value) => String(value).padStart(2, '0')
41
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
42
+ }
43
+
44
+ /** 设备 id 会直接进路径:只留安全字符,避免 `../` 之类的路径穿越。 */
45
+ export function safeDeviceId(deviceId) {
46
+ const text = String(deviceId ?? '').trim()
47
+ if (!text) return 'unknown'
48
+ return text.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 120)
49
+ }
50
+
51
+ /** 一条上报 → 一行落盘 JSON;非法条目返回 null(调用方计入拒收)。 */
52
+ export function normalizeEntry(raw) {
53
+ if (!raw || typeof raw !== 'object') return null
54
+ const ms = Number(raw.ms)
55
+ const text = typeof raw.text === 'string' ? raw.text : ''
56
+ if (!Number.isFinite(ms) || !text) return null
57
+ const seq = Number(raw.seq)
58
+ return {
59
+ seq: Number.isFinite(seq) ? seq : undefined,
60
+ ms,
61
+ at: typeof raw.at === 'string' && raw.at ? raw.at : new Date(ms).toISOString(),
62
+ level: typeof raw.level === 'string' ? raw.level.slice(0, 16) : 'info',
63
+ source: typeof raw.source === 'string' ? raw.source.slice(0, 32) : 'app',
64
+ text: text.slice(0, MAX_TEXT_CHARS),
65
+ }
66
+ }
67
+
68
+ export class LogSpool {
69
+ /**
70
+ * @param {object} [options]
71
+ * @param {string} [options.root] 目录(默认 `$DSH_HOME/mobile-link/logs`)
72
+ * @param {number} [options.retentionMs] 保留期
73
+ * @param {number} [options.maxDeviceBytes] 单设备上限
74
+ * @param {() => number} [options.now]
75
+ * @param {{ debug?: (message: string) => void }} [options.logger]
76
+ */
77
+ constructor({
78
+ root, retentionMs = DEFAULT_RETENTION_MS, maxDeviceBytes = DEFAULT_MAX_DEVICE_BYTES,
79
+ now = Date.now, logger,
80
+ } = {}) {
81
+ this.root = typeof root === 'string' && root.trim() ? root : defaultLogsRoot()
82
+ this.retentionMs = Math.max(60_000, Number(retentionMs) || DEFAULT_RETENTION_MS)
83
+ this.maxDeviceBytes = Math.max(1024, Number(maxDeviceBytes) || DEFAULT_MAX_DEVICE_BYTES)
84
+ this.now = now
85
+ this.logger = logger
86
+ /** 每台设备已接受的最高序号(幂等:同批重推不会写两遍)。 */
87
+ this.watermarks = new Map()
88
+ this._lastPruneAt = 0
89
+ }
90
+
91
+ /** 一台设备当天的文件路径。 */
92
+ pathFor(deviceId, ms = this.now()) {
93
+ return join(this.root, safeDeviceId(deviceId), `${dayStamp(ms)}.jsonl`)
94
+ }
95
+
96
+ /**
97
+ * 收一批并落盘。
98
+ *
99
+ * @returns {{accepted: number, lastSeq: number, dropped: number}}
100
+ * `accepted` 是真正写下去的条数;被水位挡掉的重复条目不计入。
101
+ */
102
+ append(deviceId, entries, { full = false } = {}) {
103
+ const result = { accepted: 0, lastSeq: this.watermarks.get(deviceId) ?? 0, dropped: 0 }
104
+ if (!Array.isArray(entries) || entries.length === 0) return result
105
+ let bytes = 0
106
+ const byDay = new Map()
107
+ for (const raw of entries) {
108
+ const entry = normalizeEntry(raw)
109
+ if (!entry) { result.dropped += 1; continue }
110
+ const seq = Number(entry.seq)
111
+ const watermark = this.watermarks.get(deviceId) ?? 0
112
+ // `full` 是"整段重推"(App 冷启动回填或 `dsh://diag export`):
113
+ // 水位不参与判断,但仍然照常推进,避免之后的增量重复。
114
+ if (!full && Number.isFinite(seq) && seq <= watermark) continue
115
+ const line = `${JSON.stringify({ ...entry, deviceId, full: full || undefined })}\n`
116
+ if (result.accepted > 0 && bytes + line.length > MAX_BATCH_BYTES) break
117
+ if (result.accepted >= MAX_BATCH_ENTRIES) break
118
+ const day = dayStamp(entry.ms)
119
+ const bucket = byDay.get(day) ?? []
120
+ bucket.push(line)
121
+ byDay.set(day, bucket)
122
+ bytes += line.length
123
+ result.accepted += 1
124
+ result.lastSeq = Math.max(result.lastSeq, Number.isFinite(seq) ? seq : result.lastSeq)
125
+ }
126
+ if (!byDay.size) return result
127
+ for (const [day, lines] of byDay) this.#write(deviceId, day, lines)
128
+ this.watermarks.set(deviceId, result.lastSeq)
129
+ this.#pruneIfDue()
130
+ return result
131
+ }
132
+
133
+ #write(deviceId, day, lines) {
134
+ const path = join(this.root, safeDeviceId(deviceId), `${day}.jsonl`)
135
+ try {
136
+ mkdirSync(dirname(path), { recursive: true })
137
+ appendFileSync(path, lines.join(''))
138
+ } catch (error) {
139
+ // 诊断设施不许把链路带崩:写不进去就只报一声。
140
+ this.logger?.debug?.(`mobile-link: logs spool write failed (${error?.message ?? error})`)
141
+ }
142
+ }
143
+
144
+ /** 60 秒内最多清理一次;清理本身不许抛。 */
145
+ #pruneIfDue() {
146
+ const now = this.now()
147
+ if (now - this._lastPruneAt < PRUNE_INTERVAL_MS) return
148
+ this._lastPruneAt = now
149
+ try {
150
+ this.prune(now)
151
+ } catch (error) {
152
+ this.logger?.debug?.(`mobile-link: logs prune failed (${error?.message ?? error})`)
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 清理:先按保留期删整天文件,再按单设备上限删最旧的。
158
+ * 返回删掉的字节数(测试用;生产只看副作用)。
159
+ */
160
+ prune(now = this.now()) {
161
+ let freed = 0
162
+ let devices = []
163
+ try {
164
+ devices = readdirSync(this.root, { withFileTypes: true }).filter((item) => item.isDirectory())
165
+ } catch {
166
+ return 0
167
+ }
168
+ for (const device of devices) {
169
+ const dir = join(this.root, device.name)
170
+ let files = []
171
+ try {
172
+ files = readdirSync(dir).filter((name) => name.endsWith('.jsonl'))
173
+ } catch {
174
+ continue
175
+ }
176
+ if (!files.length) {
177
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* 目录删不掉无所谓 */ }
178
+ continue
179
+ }
180
+ const stats = []
181
+ for (const name of files.sort()) {
182
+ const path = join(dir, name)
183
+ try {
184
+ const info = statSync(path)
185
+ stats.push({ path, day: name.slice(0, 8), size: info.size, mtimeMs: info.mtimeMs })
186
+ } catch { /* 文件刚被删掉 */ }
187
+ }
188
+ // 保留期按**文件名里的日**判定,不看 mtime:mtime 会被备份、拷贝、恢复改动,
189
+ // 而"这是哪一天的日志"只写在名字里,判定必须与名字一致。
190
+ const cutoff = dayStamp(now - this.retentionMs)
191
+ for (const file of stats) {
192
+ if (file.day >= cutoff) continue
193
+ freed += this.#unlink(file)
194
+ }
195
+ let kept = stats.filter((file) => file.day >= cutoff)
196
+ let total = kept.reduce((sum, file) => sum + file.size, 0)
197
+ for (const file of kept) {
198
+ if (total <= this.maxDeviceBytes) break
199
+ total -= file.size
200
+ freed += this.#unlink(file)
201
+ }
202
+ }
203
+ return freed
204
+ }
205
+
206
+ #unlink(file) {
207
+ try {
208
+ unlinkSync(file.path)
209
+ return file.size
210
+ } catch {
211
+ return 0
212
+ }
213
+ }
214
+
215
+ /** 某台设备(或全部)的磁盘占用,给人看的。 */
216
+ usage() {
217
+ const rows = []
218
+ let devices = []
219
+ try {
220
+ devices = readdirSync(this.root, { withFileTypes: true }).filter((item) => item.isDirectory())
221
+ } catch {
222
+ return rows
223
+ }
224
+ for (const device of devices) {
225
+ const dir = join(this.root, device.name)
226
+ let bytes = 0
227
+ let files = 0
228
+ let newest = 0
229
+ try {
230
+ for (const name of readdirSync(dir)) {
231
+ if (!name.endsWith('.jsonl')) continue
232
+ const info = statSync(join(dir, name))
233
+ bytes += info.size
234
+ files += 1
235
+ newest = Math.max(newest, info.mtimeMs)
236
+ }
237
+ } catch { /* 忽略单台设备的读失败 */ }
238
+ rows.push({ deviceId: device.name, bytes, files, newestMs: newest })
239
+ }
240
+ return rows.sort((a, b) => b.bytes - a.bytes)
241
+ }
242
+ }
package/lib/pairing.js CHANGED
@@ -17,7 +17,7 @@ import { pairCodeEndpoint, pairCodeRegenerateEndpoint, qrPayload } from './dlp.j
17
17
  */
18
18
  export async function mintPairCode({ identity, ttlMs = null, fetchImpl = fetch }) {
19
19
  // 032:**持久码那条路不能把 `ttlMs` 放进请求体**。中转的判据是 `"ttlMs" not in body`
20
- // (`relay/api.py` 的 `pair_code`):带一个 `ttlMs: null` 会被读成"限时、但没给时长",
20
+ // (`relay-go/internal/httpapi` 的 pair-code 处理):带一个 `ttlMs: null` 会被读成"限时、但没给时长",
21
21
  // 于是落到中转的默认 10 分钟——面板显示倒计时,码十分钟后就废(用户看到的就是这个)。
22
22
  const payload = ttlMs == null
23
23
  ? { agentId: identity.agentId }