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/README.md +27 -12
- package/cordis.patch.yml +5 -0
- package/lib/cli.js +11 -13
- package/lib/client.js +1453 -91
- package/lib/dlp.js +15 -0
- package/lib/enroll.js +249 -35
- package/lib/handoff.js +1 -1
- package/lib/hello.js +1 -1
- package/lib/history/fold-summary.js +85 -32
- package/lib/history/fold.js +11 -1
- package/lib/history/question-record.js +186 -0
- package/lib/i18n.js +177 -0
- package/lib/image-originals.js +121 -0
- package/lib/image-variants.js +315 -0
- package/lib/index.js +59 -2
- package/lib/journal.js +2 -0
- package/lib/link.js +274 -17
- package/lib/router.js +70 -8
- package/lib/routes.js +147 -0
- package/lib/session-digest.js +24 -10
- package/lib/session-scope.js +89 -0
- package/lib/state.js +3 -3
- package/lib/status.js +42 -11
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
const
|
|
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:
|
|
672
|
-
removed:
|
|
704
|
+
items: delta.changed,
|
|
705
|
+
removed: delta.removed,
|
|
673
706
|
version: delta.version,
|
|
674
|
-
full:
|
|
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
|
|
1091
|
+
if (this.#shouldNotify(value.agentId)) {
|
|
1092
|
+
this.notify({ kind: 'attention', sid: value.agentId, eid: value.eventId })
|
|
1093
|
+
}
|
|
1032
1094
|
}
|
|
1033
1095
|
}
|
|
1034
1096
|
|