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/README.md +28 -13
- package/cordis.patch.yml +11 -0
- package/lib/cli.js +3 -4
- package/lib/client.js +560 -75
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +33 -1
- package/lib/enroll.js +249 -35
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +23 -1
- package/lib/history/compress.js +40 -0
- package/lib/history/coverage.js +21 -0
- package/lib/history/fold-summary.js +472 -0
- package/lib/history/fold.js +184 -0
- package/lib/history/session-snapshot.js +75 -24
- package/lib/history/summary-window.js +168 -0
- package/lib/history.js +171 -9
- package/lib/index.js +38 -2
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +238 -11
- package/lib/logs.js +242 -0
- package/lib/pairing.js +1 -1
- package/lib/router.js +171 -5
- package/lib/routes.js +147 -0
- package/lib/session-digest.js +73 -4
- package/lib/state.js +6 -6
- package/lib/status.js +30 -2
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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/
|
|
20
|
+
// (`relay-go/internal/httpapi` 的 pair-code 处理):带一个 `ttlMs: null` 会被读成"限时、但没给时长",
|
|
21
21
|
// 于是落到中转的默认 10 分钟——面板显示倒计时,码十分钟后就废(用户看到的就是这个)。
|
|
22
22
|
const payload = ttlMs == null
|
|
23
23
|
? { agentId: identity.agentId }
|