dsh-plugin-mobile-link 0.6.0 → 0.6.6
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 +8 -1
- package/cordis.patch.yml +12 -0
- package/lib/client.js +101 -41
- package/lib/diagnostics.js +172 -0
- package/lib/dlp.js +10 -1
- package/lib/hello.js +35 -3
- package/lib/history/filter-scan.js +145 -0
- package/lib/history/records.js +28 -0
- package/lib/history/session-snapshot.js +119 -0
- package/lib/history.js +135 -24
- package/lib/index.js +16 -0
- package/lib/journal.js +279 -0
- package/lib/link.js +113 -8
- package/lib/pairing.js +60 -9
- package/lib/router.js +215 -2
- package/lib/routes.js +34 -34
- package/lib/session-digest.js +117 -0
- package/lib/status.js +32 -3
- package/package.json +1 -1
- package/lib/history/probe.js +0 -195
package/lib/journal.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 链路与设备的生命周期流水。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它(2026-10-12/13 那次"手机连不上"的教训):连接器的日志走宿主
|
|
5
|
+
* `ctx.logger`,而宿主既不落盘也不保留历史,于是"连接器为什么把这条中转链路拆了"
|
|
6
|
+
* 只能靠中转侧日志反推——`lastError` 只留最后一条,中间发生过几次拆链、每次什么原因,
|
|
7
|
+
* 事后一概查不到。这一族事件就是补这个洞。
|
|
8
|
+
*
|
|
9
|
+
* 两条出口,用途不同:
|
|
10
|
+
* * **内存环**(定长,默认 200 条):随 `/mobile-link/status` 一起读,正在跑的时候看;
|
|
11
|
+
* * **JSONL 文件**(可选,默认写在身份文件旁边,超过上限滚动一次):进程重启后还在,
|
|
12
|
+
* 用于事后复盘——重连风暴的现场往往在重启之后就没了。
|
|
13
|
+
*
|
|
14
|
+
* 硬约束:**这里任何一步都不许抛**。它是诊断设施,不能因为磁盘满、权限错、
|
|
15
|
+
* 路径怪就把链路带崩;写失败只记一条 `journal.error` 然后闭嘴。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
import { defaultStatePath } from './state.js'
|
|
22
|
+
|
|
23
|
+
/** 内存环里保留多少条。 */
|
|
24
|
+
export const DEFAULT_LIMIT = 200
|
|
25
|
+
/** 单个 JSONL 文件的上限;超了就滚动成 `<path>.1`,所以磁盘占用上限约两倍。 */
|
|
26
|
+
export const DEFAULT_MAX_BYTES = 256 * 1024
|
|
27
|
+
/** 单条事件序列化后的上限;见 `#shrink` 的两步处理。 */
|
|
28
|
+
export const MAX_EVENT_BYTES = 2048
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 事件目录(`kind` 词表)——**与契约逐字一致**,由 `npm run check:contracts` 机械核对。
|
|
32
|
+
*
|
|
33
|
+
* 契约:`docs/diagnostics-events.md`(§2 既有 + §3 新增 + 内部)。
|
|
34
|
+
* 为什么把词表放进代码:写入侧需要一个能自检的集合(防止手滑写错 kind 后没人发现),
|
|
35
|
+
* 而插件是独立发布的包,不能运行时去读仓库里的 spec 文件。
|
|
36
|
+
*/
|
|
37
|
+
export const EVENT_KINDS = Object.freeze([
|
|
38
|
+
// §2 既有(本次之前就有,语义不变)
|
|
39
|
+
'link.start',
|
|
40
|
+
'link.open',
|
|
41
|
+
'link.close',
|
|
42
|
+
'link.backoff',
|
|
43
|
+
'link.failed',
|
|
44
|
+
'link.unregistered',
|
|
45
|
+
'link.stop',
|
|
46
|
+
'watchdog.repull',
|
|
47
|
+
'watchdog.drop',
|
|
48
|
+
'dsh.ok',
|
|
49
|
+
'dsh.unreachable',
|
|
50
|
+
'device.attach',
|
|
51
|
+
'device.reattach',
|
|
52
|
+
'device.attach-again',
|
|
53
|
+
'device.detach',
|
|
54
|
+
'device.revoked',
|
|
55
|
+
// §3 本次新增
|
|
56
|
+
'device.phase',
|
|
57
|
+
'request.slow',
|
|
58
|
+
'device.summary',
|
|
59
|
+
'app.dial',
|
|
60
|
+
'app.hello',
|
|
61
|
+
'app.first-request',
|
|
62
|
+
'app.first-response',
|
|
63
|
+
'app.request',
|
|
64
|
+
'app.first-screen',
|
|
65
|
+
'app.first-render',
|
|
66
|
+
'app.scene',
|
|
67
|
+
'app.push',
|
|
68
|
+
// 内部:写失败与超大条目自报(不在契约的对外表里,但同属写入侧词表)
|
|
69
|
+
'journal.error',
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
/** 信封字段:detail 里的同名键一律忽略(否则会把时刻/事件名冲掉)。 */
|
|
73
|
+
const PROTECTED_KEYS = new Set(['at', 'ms', 'kind'])
|
|
74
|
+
|
|
75
|
+
/** 单条事件里字符串值的最长保留长度(`#shrink` 用)。 */
|
|
76
|
+
const MAX_STRING_CHARS = 200
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/** 默认落在身份文件旁边(`$DSH_HOME/mobile-link/`),测试用临时 DSH_HOME 时会自动隔离。 */
|
|
80
|
+
export function defaultJournalPath() {
|
|
81
|
+
return join(dirname(defaultStatePath()), 'link-events.jsonl')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 把一条事件序列化成一行 JSON。
|
|
86
|
+
*
|
|
87
|
+
* 同时给 `at`(给人看的 ISO 8601)与 `ms`(给脚本比对的整数),因为这两种读者都存在:
|
|
88
|
+
* 人 grep 时想直接看到时刻,脚本算间隔时不想解析日期。
|
|
89
|
+
*/
|
|
90
|
+
function toLine(entry) {
|
|
91
|
+
try {
|
|
92
|
+
return `${JSON.stringify(entry)}\n`
|
|
93
|
+
} catch {
|
|
94
|
+
// 循环引用之类的 detail 不该让整条事件丢掉:退化成只留 kind。
|
|
95
|
+
return `${JSON.stringify({ at: entry.at, ms: entry.ms, kind: entry.kind })}\n`
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class LinkJournal {
|
|
100
|
+
/**
|
|
101
|
+
* @param {object} [options]
|
|
102
|
+
* @param {string} [options.path] JSONL 路径;不传=只留内存,不碰磁盘
|
|
103
|
+
* @param {number} [options.limit] 内存环容量
|
|
104
|
+
* @param {number} [options.maxBytes] 单个文件上限
|
|
105
|
+
* @param {() => number} [options.now] 取时刻(测试注入)
|
|
106
|
+
* @param {{ debug?: (message: string) => void }} [options.logger]
|
|
107
|
+
*/
|
|
108
|
+
constructor({
|
|
109
|
+
path, limit = DEFAULT_LIMIT, maxBytes = DEFAULT_MAX_BYTES,
|
|
110
|
+
now = Date.now, logger,
|
|
111
|
+
} = {}) {
|
|
112
|
+
this._path = typeof path === 'string' && path.trim() ? path : undefined
|
|
113
|
+
this.maxBytes = Math.max(1024, Number(maxBytes) || DEFAULT_MAX_BYTES)
|
|
114
|
+
this.limit = Math.max(1, Number(limit) || DEFAULT_LIMIT)
|
|
115
|
+
this.now = now
|
|
116
|
+
this.logger = logger
|
|
117
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
118
|
+
this._entries = []
|
|
119
|
+
/** 因为环满而被挤掉的条数:读写两侧都不是无损的,得让读者知道。 */
|
|
120
|
+
this.dropped = 0
|
|
121
|
+
/** 落盘写坏了几次(第一次就停手,避免刷屏)。 */
|
|
122
|
+
this.fileErrors = 0
|
|
123
|
+
this._bytes = undefined
|
|
124
|
+
this._fileBroken = false
|
|
125
|
+
/** 写入过的总条数(含被挤掉的)。 */
|
|
126
|
+
this.recorded = 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 落盘路径;`null` 表示这份流水只在内存里。 */
|
|
130
|
+
get path() {
|
|
131
|
+
return this._path ?? null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 记一条。**永不抛**。
|
|
136
|
+
*
|
|
137
|
+
* @param {string} kind 事件名(`link.open` / `device.detach` …),未知取值允许写入
|
|
138
|
+
* (老读者必须忽略不认识的 kind),但单测会盯住目录漂移
|
|
139
|
+
* @param {Record<string, unknown>} [detail] 该事件自带的字段(`undefined` 一律丢弃)
|
|
140
|
+
* @returns {Record<string, unknown>} 写进去的那条(测试要断言,所以返回)
|
|
141
|
+
*/
|
|
142
|
+
record(kind, detail) {
|
|
143
|
+
const entry = this.#build(kind, detail)
|
|
144
|
+
const line = toLine(entry)
|
|
145
|
+
// 两条分支必须返回同一形状:小的直接过,大的走两步降级。
|
|
146
|
+
const sized = Buffer.byteLength(line) <= MAX_EVENT_BYTES ? { entry, line } : this.#shrink(entry)
|
|
147
|
+
this.#push(sized.entry)
|
|
148
|
+
this.#append(sized.line)
|
|
149
|
+
return sized.entry
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 组装条目:信封 + 过滤掉 `undefined` 的 detail。
|
|
154
|
+
*
|
|
155
|
+
* **信封字段受保护**:`at`/`ms`/`kind` 不会被 detail 覆盖。这条是 2026-10-13 踩出来的——
|
|
156
|
+
* 当时 `request.slow`/`app.hello` 的 detail 里也有一个 `ms`(含义是**耗时**),
|
|
157
|
+
* 摊平后把事件的**时刻**冲掉了,时间线上那条事件会跑到 1970 年。耗时一律叫 `elapsedMs`。
|
|
158
|
+
*/
|
|
159
|
+
#build(kind, detail) {
|
|
160
|
+
const ms = this.now()
|
|
161
|
+
const entry = { at: new Date(ms).toISOString(), ms, kind }
|
|
162
|
+
if (detail && typeof detail === 'object') {
|
|
163
|
+
for (const [key, value] of Object.entries(detail)) {
|
|
164
|
+
if (value === undefined || PROTECTED_KEYS.has(key)) continue
|
|
165
|
+
entry[key] = value
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return entry
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 记一条**别人钟上**的事件(App 推上来的那些):时刻用给定的值,不用本机时钟。
|
|
173
|
+
*
|
|
174
|
+
* 单独一个入口而不是"让 detail 覆盖 ms":覆盖是隐式的,谁都能不小心改掉时刻;
|
|
175
|
+
* 这里只有显式调用才会保留外部时刻。
|
|
176
|
+
*/
|
|
177
|
+
recordForeign(kind, { at, ms, ...detail } = {}) {
|
|
178
|
+
const stamp = Number.isFinite(ms) ? ms : this.now()
|
|
179
|
+
const entry = { at: typeof at === 'string' && at ? at : new Date(stamp).toISOString(), ms: stamp, kind }
|
|
180
|
+
for (const [key, value] of Object.entries(detail)) {
|
|
181
|
+
if (value === undefined || PROTECTED_KEYS.has(key)) continue
|
|
182
|
+
entry[key] = value
|
|
183
|
+
}
|
|
184
|
+
const line = toLine(entry)
|
|
185
|
+
const sized = Buffer.byteLength(line) <= MAX_EVENT_BYTES ? { entry, line } : this.#shrink(entry)
|
|
186
|
+
this.#push(sized.entry)
|
|
187
|
+
this.#append(sized.line)
|
|
188
|
+
return sized.entry
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 记进内存环(磁盘那半由 `record` 负责)。 */
|
|
192
|
+
#push(entry) {
|
|
193
|
+
this._entries.push(entry)
|
|
194
|
+
this.recorded += 1
|
|
195
|
+
while (this._entries.length > this.limit) {
|
|
196
|
+
this._entries.shift()
|
|
197
|
+
this.dropped += 1
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 超大条目的两步降级:先把长字符串截短(保住这一条),仍超限则丢掉它并自报一条 `journal.error`。
|
|
203
|
+
*
|
|
204
|
+
* 为什么不在写入侧直接抛/直接丢:诊断设施最怕"因为自己太胖把现场弄没了"。
|
|
205
|
+
* 截断会留 `truncated: true`,读者知道这条不完整;真的截不动(例如深嵌套对象)才丢,且丢也要留痕。
|
|
206
|
+
*/
|
|
207
|
+
#shrink(entry) {
|
|
208
|
+
const shortened = { ...entry }
|
|
209
|
+
for (const [key, value] of Object.entries(shortened)) {
|
|
210
|
+
if (typeof value === 'string' && value.length > MAX_STRING_CHARS) {
|
|
211
|
+
shortened[key] = `${value.slice(0, MAX_STRING_CHARS)}…`
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
shortened.truncated = true
|
|
215
|
+
const line = toLine(shortened)
|
|
216
|
+
if (Buffer.byteLength(line) <= MAX_EVENT_BYTES) return { entry: shortened, line }
|
|
217
|
+
const fallback = this.#build('journal.error', {
|
|
218
|
+
// 字段名不能叫 `kind`:`#build` 会把 detail 的键盖到信封上,那样这条自报就冒充成原事件了。
|
|
219
|
+
originalKind: entry.kind,
|
|
220
|
+
bytes: Buffer.byteLength(toLine(entry)),
|
|
221
|
+
reason: 'event too large even after truncation',
|
|
222
|
+
})
|
|
223
|
+
return { entry: fallback, line: toLine(fallback) }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 最近的 `count` 条,最旧在前。 */
|
|
227
|
+
recent(count = this.limit) {
|
|
228
|
+
const size = Math.max(0, Number(count) || 0)
|
|
229
|
+
return this._entries.slice(Math.max(0, this._entries.length - size))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 内存环里现有的全部条目(副本)。 */
|
|
233
|
+
entries() {
|
|
234
|
+
return this._entries.slice()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#append(line) {
|
|
238
|
+
if (!this._path || this._fileBroken) return
|
|
239
|
+
try {
|
|
240
|
+
if (this._bytes === undefined) {
|
|
241
|
+
mkdirSync(dirname(this._path), { recursive: true, mode: 0o700 })
|
|
242
|
+
this._bytes = this.#sizeOf(this._path)
|
|
243
|
+
}
|
|
244
|
+
const bytes = Buffer.byteLength(line)
|
|
245
|
+
if (this._bytes > 0 && this._bytes + bytes > this.maxBytes) this.#rotate()
|
|
246
|
+
appendFileSync(this._path, line, { mode: 0o600 })
|
|
247
|
+
this._bytes += bytes
|
|
248
|
+
} catch (error) {
|
|
249
|
+
this.fileErrors += 1
|
|
250
|
+
this._fileBroken = true
|
|
251
|
+
this.logger?.debug?.(`mobile-link: link journal disabled (${error?.message ?? error})`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#sizeOf(path) {
|
|
256
|
+
try {
|
|
257
|
+
return statSync(path).size
|
|
258
|
+
} catch {
|
|
259
|
+
return 0
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 滚一次:现文件改名成 `<path>.1`,旧的那份直接丢掉(只保两代)。 */
|
|
264
|
+
#rotate() {
|
|
265
|
+
const archived = `${this._path}.1`
|
|
266
|
+
try {
|
|
267
|
+
renameSync(this._path, archived)
|
|
268
|
+
} catch {
|
|
269
|
+
// Windows 上改名到已存在的目标会失败;删掉目标再试一次。
|
|
270
|
+
try {
|
|
271
|
+
unlinkSync(archived)
|
|
272
|
+
renameSync(this._path, archived)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
throw error
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
this._bytes = 0
|
|
278
|
+
}
|
|
279
|
+
}
|
package/lib/link.js
CHANGED
|
@@ -20,13 +20,14 @@ import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl
|
|
|
20
20
|
const LIVE_AGENT = Symbol.for('dsh.mobile-link.live-agent')
|
|
21
21
|
import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
|
|
22
22
|
import { handoffFromEndpoint, writeHandoff } from './handoff.js'
|
|
23
|
+
import { LinkJournal, defaultJournalPath } from './journal.js'
|
|
23
24
|
import { statusSnapshot } from './status.js'
|
|
24
|
-
import { mintPairCode } from './pairing.js'
|
|
25
|
+
import { mintPairCode, regeneratePairCode } from './pairing.js'
|
|
25
26
|
import {
|
|
26
|
-
DEFAULT_EVENTS_BACKLOG,
|
|
27
|
+
DEFAULT_EVENTS_BACKLOG, DEFAULT_SLOW_REQUEST_MS,
|
|
27
28
|
EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
|
|
28
29
|
} from './router.js'
|
|
29
|
-
import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
|
|
30
|
+
import { SERVER_VERSION, SERVER_CAPABILITIES, hostVersion } from './hello.js'
|
|
30
31
|
import { MissingIdentity, resolveIdentity } from './state.js'
|
|
31
32
|
import { connect as wsConnect } from './ws.js'
|
|
32
33
|
|
|
@@ -57,7 +58,14 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
57
58
|
logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
|
|
58
59
|
random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
|
|
59
60
|
watchdogMs = 180000,
|
|
61
|
+
// 慢请求阈值(毫秒);0 = 关闭逐条慢请求记录。见 router.js 的 DEFAULT_SLOW_REQUEST_MS。
|
|
62
|
+
slowRequestMs = DEFAULT_SLOW_REQUEST_MS,
|
|
63
|
+
// 关掉手机推送的接收(回退闸);见 contracts/connector-diagnostics-method.md §4。
|
|
64
|
+
diagnostics = true,
|
|
60
65
|
inviteCode, fetchImpl = fetch, env = process.env,
|
|
66
|
+
// 生命周期流水(见 journal.js):`journal` 传实例(测试注入),`journalPath`
|
|
67
|
+
// 传路径(`defaultJournalPath()` 之外的位置),false 表示只留内存不落盘。
|
|
68
|
+
journal, journalPath,
|
|
61
69
|
// 手机不在时替它攒下来的提问条数上限(见 router.js);环境变量给部署用,
|
|
62
70
|
// 构造参数给测试用。**没有"留多久"这个旋钮**:`$events` 一直留到设备被撤销。
|
|
63
71
|
eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
|
|
@@ -69,6 +77,8 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
69
77
|
this.pongTimeoutMs = pongTimeoutMs
|
|
70
78
|
this.maxBackoffMs = maxBackoffMs
|
|
71
79
|
this.watchdogMs = watchdogMs
|
|
80
|
+
this.slowRequestMs = Number(slowRequestMs) || 0
|
|
81
|
+
this.diagnostics = diagnostics !== false
|
|
72
82
|
this.random = random
|
|
73
83
|
this.now = now
|
|
74
84
|
this.connectImpl = connectImpl
|
|
@@ -79,13 +89,29 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
79
89
|
//: to fix, not a transport problem to retry.
|
|
80
90
|
this.needsEnroll = false
|
|
81
91
|
this.dsh = dshClient ?? new DshClient({ explicitUrl: dshUrl, logger })
|
|
92
|
+
/** 生命周期流水:内存环 +(默认)一份 JSONL,见 journal.js 的文件头。 */
|
|
93
|
+
this.journal = journal ?? new LinkJournal({
|
|
94
|
+
path: journalPath === false
|
|
95
|
+
? undefined
|
|
96
|
+
: (typeof journalPath === 'string' && journalPath ? journalPath : defaultJournalPath()),
|
|
97
|
+
now,
|
|
98
|
+
logger,
|
|
99
|
+
})
|
|
82
100
|
this.router = new DeviceRouter({
|
|
83
101
|
dsh: this.dsh,
|
|
84
102
|
logger,
|
|
85
103
|
now,
|
|
86
104
|
protocolVersion: PROTOCOL_VERSION,
|
|
87
105
|
eventsBacklog,
|
|
106
|
+
slowRequestMs: this.slowRequestMs,
|
|
107
|
+
diagnostics: this.diagnostics,
|
|
88
108
|
send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
|
|
109
|
+
// 设备侧的 attach/detach 与链路侧同源记进流水:事后想把"手机断了"和
|
|
110
|
+
// "连接器拆链"对上时间,两边必须写在同一份文件里。
|
|
111
|
+
// `options.foreign` 表示这条事件用的是**别人的钟**(App 推上来的),必须原样保留时刻。
|
|
112
|
+
onEvent: (kind, detail, options) => (options?.foreign
|
|
113
|
+
? this.journal.recordForeign(kind, detail)
|
|
114
|
+
: this.journal.record(kind, detail)),
|
|
89
115
|
// 「跑完了 / 待你回应」到了就报一声(`{t:'notify'}`)。这是 agent 侧控制帧,
|
|
90
116
|
// **不带 deviceId**:连接器不知道、也不需要知道这台电脑配了哪些手机,
|
|
91
117
|
// 「该不该推给某台手机」由中转判(它手里才有"这台设备此刻在不在线")。
|
|
@@ -108,6 +134,16 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
108
134
|
this.startedAt = undefined
|
|
109
135
|
this.connectedAt = undefined
|
|
110
136
|
this.reconnectAttempts = 0
|
|
137
|
+
/** 最近一次拆链的时刻(用来在 `link.open` 里报"距上次断开多久")。 */
|
|
138
|
+
this._lastClosedAt = undefined
|
|
139
|
+
/**
|
|
140
|
+
* socket 上最后一次系统错误(`onError` 的文本)。
|
|
141
|
+
*
|
|
142
|
+
* 为什么要专门留住它:链路被异常关闭时,`close` 回调只给一个代码(实测常见 1006,
|
|
143
|
+
* 含义是"没有关闭帧"),据此无法判断是网络、代理还是对端。系统错误文本是唯一线索,
|
|
144
|
+
* 而它以前只进了 debug 日志、不落流水(2026-10-13 现场诊断时卡在这里)。
|
|
145
|
+
*/
|
|
146
|
+
this._socketError = undefined
|
|
111
147
|
|
|
112
148
|
this._stopping = false
|
|
113
149
|
this._sessionClosed = undefined
|
|
@@ -233,6 +269,15 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
233
269
|
this._abort = new AbortController()
|
|
234
270
|
this.startedAt = this.now()
|
|
235
271
|
this.state = 'connecting'
|
|
272
|
+
this.journal.record('link.start', {
|
|
273
|
+
// 这里只能报**配置值**:真身份(含实际中转地址)要等 `#session()` 里的
|
|
274
|
+
// `resolveIdentity()` 才读到,那条记在 `link.open` 上。名字带 configured,
|
|
275
|
+
// 免得读者把占位符 `wss://relay.example.com/dsh-link` 当成真的在连的地址。
|
|
276
|
+
configuredRelay: this.config.relayUrl,
|
|
277
|
+
agentId: this.config.agentId || undefined,
|
|
278
|
+
attempt: this.reconnectAttempts,
|
|
279
|
+
host: this.hostForm,
|
|
280
|
+
})
|
|
236
281
|
// 解析器可能在上一次会话里就已经装好;这里补一次本机发现(失败无妨)。
|
|
237
282
|
void this.refreshLocal()
|
|
238
283
|
this._lastProgressAt = this.now()
|
|
@@ -262,6 +307,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
262
307
|
this._loop = undefined
|
|
263
308
|
this.dsh.close()
|
|
264
309
|
this.state = 'stopped'
|
|
310
|
+
this.journal.record('link.stop', {})
|
|
265
311
|
this.emit('status')
|
|
266
312
|
}
|
|
267
313
|
|
|
@@ -278,6 +324,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
278
324
|
this.needsEnroll = true
|
|
279
325
|
this.state = 'unregistered'
|
|
280
326
|
this.lastError = messageOf(error)
|
|
327
|
+
this.journal.record('link.unregistered', { error: this.lastError })
|
|
281
328
|
this.logger.error?.(this.lastError)
|
|
282
329
|
this.emit('status')
|
|
283
330
|
return
|
|
@@ -295,11 +342,13 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
295
342
|
this.needsEnroll = true
|
|
296
343
|
this.state = 'unregistered'
|
|
297
344
|
this.lastError = messageOf(error)
|
|
345
|
+
this.journal.record('link.unregistered', { error: this.lastError })
|
|
298
346
|
this.logger.error?.(`${this.lastError}\n ${this.enrollHint()}`)
|
|
299
347
|
this.emit('status')
|
|
300
348
|
return
|
|
301
349
|
}
|
|
302
350
|
this.lastError = messageOf(error)
|
|
351
|
+
this.journal.record('link.failed', { error: this.lastError, attempt: this.reconnectAttempts })
|
|
303
352
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
304
353
|
}
|
|
305
354
|
if (this._stopping) break
|
|
@@ -307,6 +356,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
307
356
|
this.emit('status')
|
|
308
357
|
const delay = nextBackoff(this.reconnectAttempts, { maxMs: this.maxBackoffMs, random: this.random })
|
|
309
358
|
this.reconnectAttempts += 1
|
|
359
|
+
this.journal.record('link.backoff', { attempt: this.reconnectAttempts, delayMs: Math.round(delay) })
|
|
310
360
|
await sleep(delay, this._abort.signal)
|
|
311
361
|
}
|
|
312
362
|
this.state = 'stopped'
|
|
@@ -365,6 +415,16 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
365
415
|
this.router.lastError = undefined
|
|
366
416
|
this._lastPong = this.now()
|
|
367
417
|
this.logger.info?.(`mobile-link: connected to ${this.relay.wsBase} as ${identity.agentId}`)
|
|
418
|
+
this.journal.record('link.open', {
|
|
419
|
+
relay: this.relay?.wsBase,
|
|
420
|
+
agentId: identity.agentId,
|
|
421
|
+
attempt: this.reconnectAttempts,
|
|
422
|
+
sinceCloseMs: this._lastClosedAt === undefined ? undefined : this.now() - this._lastClosedAt,
|
|
423
|
+
// 这次连接属于哪套版本:连接器 + 宿主 DSH。一连接一行,事后从本机流水就能
|
|
424
|
+
// 认出"出问题的那天跑的是哪两个版本",不必再去翻安装记录。
|
|
425
|
+
serverVersion: SERVER_VERSION,
|
|
426
|
+
hostVersion: hostVersion(),
|
|
427
|
+
})
|
|
368
428
|
this.emit('status')
|
|
369
429
|
|
|
370
430
|
socket.onMessage((text) => {
|
|
@@ -373,7 +433,10 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
373
433
|
.catch((error) => this.logger.debug?.(`mobile-link: frame handler error: ${messageOf(error)}`))
|
|
374
434
|
return this._inbox
|
|
375
435
|
})
|
|
376
|
-
socket.onError((error) =>
|
|
436
|
+
socket.onError((error) => {
|
|
437
|
+
this._socketError = messageOf(error)
|
|
438
|
+
this.logger.debug?.(`mobile-link: relay socket error: ${this._socketError}`)
|
|
439
|
+
})
|
|
377
440
|
socket.onClose((code, reason) => this.#teardownSocket(`relay closed the socket (${code ?? '?'} ${reason ?? ''})`))
|
|
378
441
|
|
|
379
442
|
this.#startHeartbeat()
|
|
@@ -386,9 +449,13 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
386
449
|
// 进程外工具(本机脚本、iOS 集成测试辅助)靠这份交接文件拿带凭据地址;
|
|
387
450
|
// 写入失败不影响链路(`#writeHandoff` 自己吞异常)。
|
|
388
451
|
await this.#writeHandoff()
|
|
452
|
+
this.journal.record('dsh.ok', { endpoint: this.dsh?.endpoint?.base })
|
|
389
453
|
} catch (error) {
|
|
390
454
|
this.dshError = messageOf(error)
|
|
391
455
|
this.lastError = this.dshError
|
|
456
|
+
// 这一条很关键:本机 DSH 不可达时,手机那句 `_link/hello`/`sessions` 探针
|
|
457
|
+
// 必然超时,App 会据此重建整条链路——于是"连接器在、手机却连不上"。
|
|
458
|
+
this.journal.record('dsh.unreachable', { error: this.dshError, kind: this.dsh?.errorKind })
|
|
392
459
|
this.logger.warn?.(`mobile-link: local DSH is not reachable yet: ${this.dshError}`)
|
|
393
460
|
}
|
|
394
461
|
this.emit('status')
|
|
@@ -419,6 +486,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
419
486
|
const idle = this.now() - this._lastProgressAt
|
|
420
487
|
if (!this._loopActive) {
|
|
421
488
|
this.lastError = `链路循环不在了(看门狗重拉,空闲 ${Math.round(idle / 1000)}s)`
|
|
489
|
+
this.journal.record('watchdog.repull', { idleMs: Math.round(idle) })
|
|
422
490
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
423
491
|
this._loop = undefined
|
|
424
492
|
this._lastProgressAt = this.now()
|
|
@@ -427,6 +495,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
427
495
|
}
|
|
428
496
|
if (this.state === 'connected' && idle > this.pongTimeoutMs) {
|
|
429
497
|
this.lastError = `relay 超过 ${Math.round(idle / 1000)}s 没有动静(看门狗拆连接)`
|
|
498
|
+
this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
|
|
430
499
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
431
500
|
this.#teardownSocket(this.lastError)
|
|
432
501
|
this._lastProgressAt = this.now()
|
|
@@ -434,6 +503,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
434
503
|
}
|
|
435
504
|
if (this.state !== 'connected' && idle > this.watchdogMs) {
|
|
436
505
|
this.lastError = `链路 ${Math.round(idle / 1000)}s 没有进展(看门狗重建连接)`
|
|
506
|
+
this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
|
|
437
507
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
438
508
|
this.#teardownSocket(this.lastError)
|
|
439
509
|
this._lastProgressAt = this.now()
|
|
@@ -469,6 +539,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
469
539
|
#teardownSocket(reason) {
|
|
470
540
|
this.#stopHeartbeat()
|
|
471
541
|
const socket = this.socket
|
|
542
|
+
const from = this.state
|
|
472
543
|
this.socket = undefined
|
|
473
544
|
if (socket) {
|
|
474
545
|
try {
|
|
@@ -483,6 +554,18 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
483
554
|
this.dsh.close()
|
|
484
555
|
if (this.state !== 'stopped' && this.state !== 'disabled') this.state = 'disconnected'
|
|
485
556
|
this.lastError = this.lastError ?? reason
|
|
557
|
+
this._lastClosedAt = this.now()
|
|
558
|
+
// 「为什么拆的」是这一族事件唯一要回答的问题:reason 来自调用点,
|
|
559
|
+
// from 是拆之前的状态(connected / backoff / connecting),
|
|
560
|
+
// connectedForMs 用来区分"刚连上就掉"和"挂了很久才掉"。
|
|
561
|
+
this.journal.record('link.close', {
|
|
562
|
+
reason,
|
|
563
|
+
from,
|
|
564
|
+
connectedForMs: this.connectedAt === undefined ? undefined : this.now() - this.connectedAt,
|
|
565
|
+
// 有系统错误才带这个字段(正常关闭时不该凭空多一个 null)。
|
|
566
|
+
socketError: this._socketError,
|
|
567
|
+
})
|
|
568
|
+
this._socketError = undefined
|
|
486
569
|
const resolve = this._sessionClosed
|
|
487
570
|
this._sessionClosed = undefined
|
|
488
571
|
this.emit('status')
|
|
@@ -492,10 +575,15 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
492
575
|
// ── relay I/O ───────────────────────────────────────────────────────────
|
|
493
576
|
|
|
494
577
|
#rawSend(frame) {
|
|
578
|
+
return this.#sendText(encodeFrame(frame))
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** 唯一的发送出口:socket 不在就丢掉,发送异常只记 debug、绝不抛。 */
|
|
582
|
+
#sendText(text) {
|
|
495
583
|
const socket = this.socket
|
|
496
584
|
if (!socket || socket.closed) return false
|
|
497
585
|
try {
|
|
498
|
-
socket.send(
|
|
586
|
+
socket.send(text)
|
|
499
587
|
return true
|
|
500
588
|
} catch (error) {
|
|
501
589
|
this.logger.debug?.(`mobile-link: send failed: ${messageOf(error)}`)
|
|
@@ -507,7 +595,10 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
507
595
|
// `deviceId` 为空=这是一条**不带寻址**的帧(`fsPutAck`/`fsPutDone`/`fsErr` 那一族):
|
|
508
596
|
// relay 靠 `bid` 把回复对回发起的那个 HTTP 请求,多一个 deviceId 反而会让它对不上。
|
|
509
597
|
if (deviceId === undefined || deviceId === null) return this.#rawSend(frame)
|
|
510
|
-
|
|
598
|
+
const text = encodeFrame({ ...frame, deviceId })
|
|
599
|
+
// 记字节给路由层的"慢请求/汇总"用(它按 deviceId 归):只有这里知道真正上线了多少字节。
|
|
600
|
+
this.router.noteSent?.(deviceId, Buffer.byteLength(text))
|
|
601
|
+
return this.#sendText(text)
|
|
511
602
|
}
|
|
512
603
|
|
|
513
604
|
sendHostStatus() {
|
|
@@ -519,6 +610,10 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
519
610
|
name: this.identity?.agentName || undefined,
|
|
520
611
|
dshPort: this.dsh?.endpoint?.port,
|
|
521
612
|
protocol: PROTOCOL_VERSION,
|
|
613
|
+
// 这台电脑的两段版本。中转据此按 agent 记一行(变化才写),手机据此
|
|
614
|
+
// 在「关于」里显示 DSH 版本——只在控制帧上走一次,不进任何业务流。
|
|
615
|
+
version: SERVER_VERSION,
|
|
616
|
+
hostVersion: hostVersion(),
|
|
522
617
|
},
|
|
523
618
|
})
|
|
524
619
|
}
|
|
@@ -566,12 +661,22 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
566
661
|
|
|
567
662
|
// ── relay-side helpers ──────────────────────────────────────────────────
|
|
568
663
|
|
|
569
|
-
/**
|
|
570
|
-
|
|
664
|
+
/**
|
|
665
|
+
* Ask the relay to mint a pairing code for this agent (relay NOTES.md §2).
|
|
666
|
+
*
|
|
667
|
+
* 032:默认持久(`ttlMs: null`);传秒数得到限时码。
|
|
668
|
+
*/
|
|
669
|
+
async mintPairCode({ ttlMs = null, fetchImpl = fetch } = {}) {
|
|
571
670
|
const identity = this.identity ?? await resolveIdentity(this.config)
|
|
572
671
|
return mintPairCode({ identity, ttlMs, fetchImpl })
|
|
573
672
|
}
|
|
574
673
|
|
|
674
|
+
/** 用户点「重新生成」:作废旧码、铸新的,**不碰已配对设备**(032)。 */
|
|
675
|
+
async regeneratePairCode({ fetchImpl = fetch } = {}) {
|
|
676
|
+
const identity = this.identity ?? await resolveIdentity(this.config)
|
|
677
|
+
return regeneratePairCode({ identity, fetchImpl })
|
|
678
|
+
}
|
|
679
|
+
|
|
575
680
|
/** Snapshot for `GET /mobile-link/status`(载荷本身在 status.js,便于守住"只增不改")。 */
|
|
576
681
|
status() {
|
|
577
682
|
return statusSnapshot(this)
|
package/lib/pairing.js
CHANGED
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pairing helper: ask the relay to mint a
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Pairing helper: ask the relay to mint a pairing code and build the QR payload
|
|
3
|
+
* the desktop shows. Spec §2.1 plus docs/RELAY-NOTES.md §2 (the endpoint the spec's
|
|
4
|
+
* table is missing).
|
|
5
|
+
*
|
|
6
|
+
* 032:默认铸**持久码**(不过期,直到用户点「重新生成」)。传 `ttlMs` 仍然得到一张
|
|
7
|
+
* 限时码——那是老行为,留给还用它的调用方与老测试。
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
|
-
import { pairCodeEndpoint, qrPayload } from './dlp.js'
|
|
10
|
+
import { pairCodeEndpoint, pairCodeRegenerateEndpoint, qrPayload } from './dlp.js'
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* @param {object} options
|
|
11
14
|
* @param {{agentId: string, agentSecret: string, relayUrl: string}} options.identity
|
|
12
|
-
* @param {number} [options.ttlMs] requested lifetime
|
|
15
|
+
* @param {number|null} [options.ttlMs] requested lifetime; `null`/省略 = 持久码
|
|
13
16
|
* @param {typeof fetch} [options.fetchImpl] injectable for tests
|
|
14
17
|
*/
|
|
15
|
-
export async function mintPairCode({ identity, ttlMs =
|
|
18
|
+
export async function mintPairCode({ identity, ttlMs = null, fetchImpl = fetch }) {
|
|
19
|
+
// 032:**持久码那条路不能把 `ttlMs` 放进请求体**。中转的判据是 `"ttlMs" not in body`
|
|
20
|
+
// (`relay/api.py` 的 `pair_code`):带一个 `ttlMs: null` 会被读成"限时、但没给时长",
|
|
21
|
+
// 于是落到中转的默认 10 分钟——面板显示倒计时,码十分钟后就废(用户看到的就是这个)。
|
|
22
|
+
const payload = ttlMs == null
|
|
23
|
+
? { agentId: identity.agentId }
|
|
24
|
+
: { agentId: identity.agentId, ttlMs }
|
|
16
25
|
const response = await fetchImpl(pairCodeEndpoint(identity.relayUrl), {
|
|
17
26
|
method: 'POST',
|
|
18
27
|
headers: {
|
|
19
28
|
'content-type': 'application/json',
|
|
20
29
|
authorization: `Bearer ${identity.agentSecret}`,
|
|
21
30
|
},
|
|
22
|
-
body: JSON.stringify(
|
|
31
|
+
body: JSON.stringify(payload),
|
|
23
32
|
})
|
|
24
33
|
let body
|
|
25
34
|
try {
|
|
@@ -32,8 +41,50 @@ export async function mintPairCode({ identity, ttlMs = 10 * 60 * 1000, fetchImpl
|
|
|
32
41
|
}
|
|
33
42
|
return {
|
|
34
43
|
code: body.code,
|
|
35
|
-
expiresAt: body.expiresAt,
|
|
36
|
-
ttlMs: body.ttlMs ?? ttlMs,
|
|
44
|
+
expiresAt: body.expiresAt ?? null,
|
|
45
|
+
ttlMs: body.ttlMs ?? ttlMs ?? null,
|
|
46
|
+
persistent: body.persistent === true || body.expiresAt == null,
|
|
47
|
+
// 「已配对 N/8」以中转的库为准:连接器只知道自己这次见过的设备。
|
|
48
|
+
maxPairedDevices: Number(body.maxPairedDevices ?? 0) || null,
|
|
49
|
+
pairedDevices: Number(body.pairedDevices ?? 0),
|
|
50
|
+
relayUrl: identity.relayUrl,
|
|
51
|
+
agentId: identity.agentId,
|
|
52
|
+
qrPayload: qrPayload({ relay: identity.relayUrl, code: body.code }),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 用户点「重新生成」:让中转作废旧码、铸一枚新的。
|
|
58
|
+
*
|
|
59
|
+
* **不碰已配对的设备**(032 FR-002):那台手机/浏览器靠自己的 deviceToken 活着,
|
|
60
|
+
* 换码只影响"还没来配的人"。所以这个动作可以放心点,不需要提醒"会把手机踢下线"。
|
|
61
|
+
*/
|
|
62
|
+
export async function regeneratePairCode({ identity, fetchImpl = fetch }) {
|
|
63
|
+
const response = await fetchImpl(pairCodeRegenerateEndpoint(identity.relayUrl), {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'content-type': 'application/json',
|
|
67
|
+
authorization: `Bearer ${identity.agentSecret}`,
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({ agentId: identity.agentId }),
|
|
70
|
+
})
|
|
71
|
+
let body
|
|
72
|
+
try {
|
|
73
|
+
body = await response.json()
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error(`relay returned HTTP ${response.status} for /pair/code/regenerate`)
|
|
76
|
+
}
|
|
77
|
+
if (!response.ok || body?.ok !== true) {
|
|
78
|
+
throw new Error(body?.error?.message ?? `relay refused the regeneration (HTTP ${response.status})`)
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
code: body.code,
|
|
82
|
+
expiresAt: null,
|
|
83
|
+
ttlMs: null,
|
|
84
|
+
persistent: true,
|
|
85
|
+
revoked: Number(body.revoked ?? 0),
|
|
86
|
+
maxPairedDevices: Number(body.maxPairedDevices ?? 0) || null,
|
|
87
|
+
pairedDevices: Number(body.pairedDevices ?? 0),
|
|
37
88
|
relayUrl: identity.relayUrl,
|
|
38
89
|
agentId: identity.agentId,
|
|
39
90
|
qrPayload: qrPayload({ relay: identity.relayUrl, code: body.code }),
|