dsh-plugin-mobile-link 0.6.5 → 0.7.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 +1 -1
- package/cordis.patch.yml +6 -0
- package/lib/client.js +101 -41
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +28 -2
- package/lib/enroll.js +2 -2
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +53 -3
- 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 +243 -16
- package/lib/index.js +29 -0
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +68 -7
- package/lib/logs.js +242 -0
- package/lib/pairing.js +60 -9
- package/lib/router.js +170 -5
- package/lib/routes.js +34 -2
- package/lib/session-digest.js +59 -4
- package/lib/state.js +3 -3
- package/lib/status.js +4 -1
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 活动名单:本地方法 `_link/jobRoster` 与推帧 `link/jobs`(spec 018)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么由连接器聚合:宿主没有 host-wide 的任务读法——`JobRegistry.list(caller)`
|
|
5
|
+
* 只回该会话自己的 + 所有无主任务,`session/control` 的基线在本版本也只带
|
|
6
|
+
* projections。连接器与宿主同进程,逐会话读是内存过滤,既没有网络往返,也不需要
|
|
7
|
+
* 手机为每个会话各开一条 `job/list` 流。
|
|
8
|
+
*
|
|
9
|
+
* 契约:`specs/018-mobile-activity-visibility/contracts/link-job-roster.md`。
|
|
10
|
+
* 与 `_link/sessionWindow` / `_link/sessionFind` 同族:连接器自己回答,不转发宿主。
|
|
11
|
+
* 特性检测而不是版本比较——拿不到 `ctx.jobs` 时回 `link/unsupported`,手机整块不显示。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 连接器自答的方法名(`_link/` 前缀表示"不转发宿主",与 history 一族一致)。 */
|
|
15
|
+
export const ROSTER_METHOD = '_link/jobRoster'
|
|
16
|
+
|
|
17
|
+
/** 推帧的 `type`。包在 per-device `$events` 的 item 里,旧 App 当未知类型忽略。 */
|
|
18
|
+
export const JOBS_FRAME = 'link/jobs'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 还算"活着"的状态。其余(`completed` / `killed` / `failed`,以及宿主将来可能
|
|
22
|
+
* 新增的终态)一律只计入 `settled`,不下发——否则每个会话的历史任务都会跟着名单
|
|
23
|
+
* 一起上手机。
|
|
24
|
+
*/
|
|
25
|
+
export const LIVE_STATUSES = Object.freeze(['running', 'stopping'])
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 合并窗口(毫秒)。一次任务生命周期里宿主会在毫秒级连发 registered / progress /
|
|
29
|
+
* stopping / settled 几个事件;按会话合并成最后一帧,手机只看到稳定后的那一格。
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_DEBOUNCE_MS = 120
|
|
32
|
+
|
|
33
|
+
const LIVE = new Set(LIVE_STATUSES)
|
|
34
|
+
|
|
35
|
+
/** 会改变名单的宿主事件。`output` 只影响输出流,不影响这一格。 */
|
|
36
|
+
const LIFECYCLE_TYPES = new Set(['registered', 'progress', 'stopping', 'settled', 'removed'])
|
|
37
|
+
|
|
38
|
+
function messageOf(error) {
|
|
39
|
+
return error instanceof Error ? error.message : String(error)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function failure(code, message, details = {}) {
|
|
43
|
+
return { ok: false, error: { code, message, details } }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 默认调度器:`unref` 掉,去抖窗口里没有别的事情时不该把进程按住不让退出。 */
|
|
47
|
+
function defaultSchedule(fn, ms) {
|
|
48
|
+
const handle = setTimeout(fn, ms)
|
|
49
|
+
handle.unref?.()
|
|
50
|
+
return handle
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultCancel(handle) {
|
|
54
|
+
clearTimeout(handle)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 请求参数只允许空对象(契约 §2)。未知字段一律拒绝:放宽了,将来某个字段改名
|
|
59
|
+
* 就会静默变成"没有过滤条件",而不是一条能看到的 bad-request。
|
|
60
|
+
* @returns {object|undefined} 失败时的回包,成功时 undefined
|
|
61
|
+
*/
|
|
62
|
+
function checkArgs(args) {
|
|
63
|
+
if (args === undefined || args === null) return undefined
|
|
64
|
+
if (typeof args !== 'object' || Array.isArray(args)) {
|
|
65
|
+
return failure('gateway/bad-request', 'arguments must be an empty object')
|
|
66
|
+
}
|
|
67
|
+
const keys = Object.keys(args)
|
|
68
|
+
if (keys.length > 0) return failure('gateway/bad-request', `unknown field "${keys[0]}"`)
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 宿主 `JobView` → 契约的 `JobLite`。
|
|
74
|
+
*
|
|
75
|
+
* 只减不增:手机不读 `outputLimitBytes` / `spillPaths`,就不下发。`progress` /
|
|
76
|
+
* `detail` / `finishedAt` 按宿主现值原样带上,缺省即不带(不补 null,手机按"没有"
|
|
77
|
+
* 处理)。`outputLost` 是唯一重命名:`output.earliest > 0` 表示输出环丢过头部。
|
|
78
|
+
*/
|
|
79
|
+
export function jobLite(job) {
|
|
80
|
+
const output = job?.output ?? {}
|
|
81
|
+
const lite = {
|
|
82
|
+
id: job?.id,
|
|
83
|
+
kind: job?.kind,
|
|
84
|
+
label: job?.label,
|
|
85
|
+
status: job?.status,
|
|
86
|
+
startedAt: job?.startedAt,
|
|
87
|
+
outputTotal: Number.isFinite(output.total) ? output.total : 0,
|
|
88
|
+
}
|
|
89
|
+
if (typeof job?.owner === 'string' && job.owner) lite.owner = job.owner
|
|
90
|
+
if (typeof job?.progress === 'string') lite.progress = job.progress
|
|
91
|
+
if (typeof job?.detail === 'string') lite.detail = job.detail
|
|
92
|
+
if (Number.isFinite(job?.finishedAt)) lite.finishedAt = job.finishedAt
|
|
93
|
+
if (Number.isFinite(output.earliest) && output.earliest > 0) lite.outputLost = true
|
|
94
|
+
return lite
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 会话对象 → id。宿主的 `Session` 把 id 放在 `id` 上;裸 id 只在测试与将来的读法里出现。 */
|
|
98
|
+
function sessionIdOf(entry) {
|
|
99
|
+
const id = typeof entry === 'string' ? entry : entry?.id
|
|
100
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {{
|
|
105
|
+
* getJobs?: () => object | undefined,
|
|
106
|
+
* getSessions?: () => object | undefined,
|
|
107
|
+
* deliver?: (value: object) => void,
|
|
108
|
+
* logger?: { debug?: Function, info?: Function, warn?: Function },
|
|
109
|
+
* now?: () => number,
|
|
110
|
+
* debounceMs?: number,
|
|
111
|
+
* schedule?: (fn: Function, ms: number) => unknown,
|
|
112
|
+
* cancel?: (handle: unknown) => void,
|
|
113
|
+
* }} deps
|
|
114
|
+
*/
|
|
115
|
+
export function createJobsRoster({
|
|
116
|
+
getJobs = () => undefined,
|
|
117
|
+
getSessions = () => undefined,
|
|
118
|
+
deliver = () => {},
|
|
119
|
+
logger = console,
|
|
120
|
+
now = Date.now,
|
|
121
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
122
|
+
schedule = defaultSchedule,
|
|
123
|
+
cancel = defaultCancel,
|
|
124
|
+
} = {}) {
|
|
125
|
+
/**
|
|
126
|
+
* 服务来自两处:构造参数是读取函数(`link.js` 用它把 roster 接在 agent 上),
|
|
127
|
+
* `bindServices` 是插件注入时直接把服务实例交进来。**后者优先**:注入晚于第一台
|
|
128
|
+
* 设备连接时,正是它让订阅能补上。
|
|
129
|
+
*/
|
|
130
|
+
const bound = { jobs: undefined, sessions: undefined }
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 已在线、且需要收 `link/jobs` 的设备。**不**包含"断了但 `$events` 还替它值班"的
|
|
134
|
+
* 设备:离线期间的旧任务状态没有补发意义(契约 §3),重连后手机重新问一次名单。
|
|
135
|
+
*/
|
|
136
|
+
const devices = new Set()
|
|
137
|
+
/** 宿主 `ctx.jobs.events.subscribe(...)` 的 disposer;undefined = 现在没订阅。 */
|
|
138
|
+
let unsubscribe
|
|
139
|
+
/** 去抖定时器的句柄;undefined = 没有排队的帧。 */
|
|
140
|
+
let timer
|
|
141
|
+
/** 窗口内变过的会话;flush 时逐个重算那一格。 */
|
|
142
|
+
const pending = new Set()
|
|
143
|
+
/** 已发出的最大 `at`:同一连接内单调不减,手机据此丢弃乱序帧(契约 §4.5)。 */
|
|
144
|
+
let lastAt = 0
|
|
145
|
+
|
|
146
|
+
function jobs() {
|
|
147
|
+
return bound.jobs ?? getJobs()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sessions() {
|
|
151
|
+
return bound.sessions ?? getSessions()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function available() {
|
|
155
|
+
return jobs() !== undefined && sessions() !== undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 挂上(或摘下)宿主服务。注入可能晚于第一台设备连上,所以绑定后要补一次订阅:
|
|
160
|
+
* 只在 attach 时订阅的话,这台连接会一直到下一次设备 attach 才开始推帧。
|
|
161
|
+
*/
|
|
162
|
+
function bindServices(next = {}) {
|
|
163
|
+
bound.jobs = next.jobs
|
|
164
|
+
bound.sessions = next.sessions
|
|
165
|
+
if (devices.size > 0) {
|
|
166
|
+
if (available()) subscribe()
|
|
167
|
+
else releaseSubscription()
|
|
168
|
+
}
|
|
169
|
+
return available()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function nextAt() {
|
|
173
|
+
const value = Number(now())
|
|
174
|
+
if (Number.isFinite(value) && value > lastAt) lastAt = value
|
|
175
|
+
return lastAt
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 一个会话那一格:只留活的任务,其余计数。 */
|
|
179
|
+
function cellOf(sessionId) {
|
|
180
|
+
const running = []
|
|
181
|
+
let settled = 0
|
|
182
|
+
for (const job of jobs().list(sessionId) ?? []) {
|
|
183
|
+
if (LIVE.has(job?.status)) running.push(jobLite(job))
|
|
184
|
+
else settled += 1
|
|
185
|
+
}
|
|
186
|
+
return { running, settled }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 宿主活会话列表(`ctx.sessions.list()`)。
|
|
191
|
+
*
|
|
192
|
+
* 形状不可信:`undefined` / 非数组 / `list` 不是函数(宿主换实现、依赖没装全时都可能)
|
|
193
|
+
* 一律当空名单。名单只是一份旁路读数,为它把整个 `_link/jobRoster` 变成
|
|
194
|
+
* `link/failed`,等于跟手机说"这台连接器不支持活动名单"(契约 §2 的失败口径只留给
|
|
195
|
+
* **真的读不出来**的情形)。读的时候**抛错**仍然向上抛,由 `handle` 转成 `link/failed`。
|
|
196
|
+
* 元素缺 `id` 不是异常形状,在 `sample` 里逐条跳过。
|
|
197
|
+
*/
|
|
198
|
+
function sessionEntries() {
|
|
199
|
+
const store = sessions()
|
|
200
|
+
if (typeof store?.list !== 'function') return []
|
|
201
|
+
const listed = store.list()
|
|
202
|
+
return Array.isArray(listed) ? listed : []
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 整份名单(`_link/jobRoster` 的 `value`)。 */
|
|
206
|
+
function sample() {
|
|
207
|
+
const at = nextAt()
|
|
208
|
+
const out = {}
|
|
209
|
+
for (const entry of sessionEntries()) {
|
|
210
|
+
const sessionId = sessionIdOf(entry)
|
|
211
|
+
if (sessionId === undefined || out[sessionId] !== undefined) continue
|
|
212
|
+
let cell
|
|
213
|
+
try {
|
|
214
|
+
cell = cellOf(sessionId)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// 会话在枚举与读取之间消失,或宿主拒绝这次读:少一格好过整份名单失败。
|
|
217
|
+
logger.debug?.(`mobile-link: job roster skipped ${sessionId}: ${messageOf(error)}`)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
// 一件任务都没有的会话不占位:手机把"缺席"读成"没有活"(契约 §2)。
|
|
221
|
+
// 有已结算任务的会话仍然占位——`settled` 是面板「已结束 N 个」的口径。
|
|
222
|
+
if (cell.running.length === 0 && cell.settled === 0) continue
|
|
223
|
+
out[sessionId] = cell
|
|
224
|
+
}
|
|
225
|
+
return { at, sessions: out }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function subscribe() {
|
|
229
|
+
if (unsubscribe !== undefined) return
|
|
230
|
+
const events = jobs()?.events
|
|
231
|
+
if (typeof events?.subscribe !== 'function') {
|
|
232
|
+
logger.debug?.('mobile-link: this Host has no jobs event stream; link/jobs frames stay off')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
// `{owners:'scope'}`:连接器挂在宿主全局层,作用域就是整个进程,等价于"全部会话"。
|
|
237
|
+
//
|
|
238
|
+
// 不用 `{owners:'all'}`:本机实测(0.2.0-rc.2 桌面版宿主)该过滤器的订阅装上了
|
|
239
|
+
// 但**一条事件都收不到**(subscribe 成功、onChange 从不触发),于是 `link/jobs`
|
|
240
|
+
// 永远不推、手机只能显示建连那一刻的名单(2026-10-17 owner 报"任务结束了手机还显示在跑")。
|
|
241
|
+
// `scope` 在同一宿主上按预期收到 registered/settled/removed,因此改用它。
|
|
242
|
+
unsubscribe = events.subscribe({ owners: 'scope' }, onChange)
|
|
243
|
+
} catch (error) {
|
|
244
|
+
logger.warn?.(`mobile-link: subscribing to jobs events failed: ${messageOf(error)}`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function releaseSubscription() {
|
|
249
|
+
const dispose = unsubscribe
|
|
250
|
+
unsubscribe = undefined
|
|
251
|
+
if (typeof dispose !== 'function') return
|
|
252
|
+
try {
|
|
253
|
+
dispose()
|
|
254
|
+
} catch (error) {
|
|
255
|
+
logger.debug?.(`mobile-link: releasing jobs events failed: ${messageOf(error)}`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function onChange(event) {
|
|
260
|
+
if (!LIFECYCLE_TYPES.has(event?.type)) return
|
|
261
|
+
// 帧只描述"一个会话的那一格";无主任务没有归属会话,放不进任何一格。
|
|
262
|
+
// 它仍然会在 `_link/jobRoster` 的首答里出现(宿主对每个会话都可见)。
|
|
263
|
+
const sessionId = event?.job?.owner
|
|
264
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return
|
|
265
|
+
pending.add(sessionId)
|
|
266
|
+
arm()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function arm() {
|
|
270
|
+
if (timer !== undefined) return
|
|
271
|
+
timer = schedule(flush, debounceMs)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function flush() {
|
|
275
|
+
timer = undefined
|
|
276
|
+
const dirty = [...pending]
|
|
277
|
+
pending.clear()
|
|
278
|
+
// 没有在线设备:不产帧、也不把变化攒着(契约 §3「设备未连接时不产生帧,不缓存」)。
|
|
279
|
+
if (devices.size === 0 || dirty.length === 0) return
|
|
280
|
+
const at = nextAt()
|
|
281
|
+
for (const sessionId of dirty) {
|
|
282
|
+
let cell
|
|
283
|
+
try {
|
|
284
|
+
cell = cellOf(sessionId)
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// 读不到就不推:推一份空的 `running` 会把手机上正在跑的任务擦掉(比漏一帧更糟)。
|
|
287
|
+
// 记 warn——这是一次手机可见的漏帧,不是无关噪音;这一格在下一轮事件到来时重算。
|
|
288
|
+
logger.warn?.(`mobile-link: link/jobs skipped ${sessionId}: ${messageOf(error)}`)
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
deliver({
|
|
293
|
+
type: JOBS_FRAME,
|
|
294
|
+
value: { at, sessionId, running: cell.running, settled: cell.settled },
|
|
295
|
+
})
|
|
296
|
+
} catch (error) {
|
|
297
|
+
// 投递失败只算这一帧丢了:若让异常掀掉整个 flush,同一窗口里其它会话的帧会一起
|
|
298
|
+
// 发不出去,而且它会冲进定时器回调,把进程级异常变成无人接的错误。
|
|
299
|
+
logger.warn?.(`mobile-link: link/jobs delivery failed: ${messageOf(error)}`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 帧处理(`_link/jobRoster`)。回包形状与 history 一族一致:`{ok:true,value}` 或
|
|
306
|
+
* `{ok:false,error:{code,message,details}}`。
|
|
307
|
+
*/
|
|
308
|
+
async function handle(args) {
|
|
309
|
+
const rejected = checkArgs(args)
|
|
310
|
+
if (rejected !== undefined) return rejected
|
|
311
|
+
// 没有 ctx.jobs 时**不能**回空名单:空名单会被手机读成"没有活",
|
|
312
|
+
// 把"这台连接器不支持"说成了事实(契约 §4.3)。
|
|
313
|
+
if (!available()) return failure('link/unsupported', 'this Host does not expose the jobs service')
|
|
314
|
+
try {
|
|
315
|
+
return { ok: true, value: sample() }
|
|
316
|
+
} catch (error) {
|
|
317
|
+
logger.warn?.(`mobile-link: jobRoster failed: ${messageOf(error)}`)
|
|
318
|
+
return failure('link/failed', messageOf(error))
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function addDevice(deviceId) {
|
|
323
|
+
if (typeof deviceId !== 'string' || deviceId.length === 0) return false
|
|
324
|
+
if (devices.has(deviceId)) return false
|
|
325
|
+
devices.add(deviceId)
|
|
326
|
+
// 第一台设备连上才订阅:没人收的帧不值得算(契约 §3)。
|
|
327
|
+
if (devices.size === 1) subscribe()
|
|
328
|
+
return true
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function removeDevice(deviceId) {
|
|
332
|
+
if (!devices.delete(deviceId)) return false
|
|
333
|
+
if (devices.size === 0) {
|
|
334
|
+
// 最后一台设备走了:订阅与服务一起放掉,窗口里的变化也不留。
|
|
335
|
+
releaseSubscription()
|
|
336
|
+
pending.clear()
|
|
337
|
+
if (timer !== undefined) {
|
|
338
|
+
cancel(timer)
|
|
339
|
+
timer = undefined
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return true
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 进程收尾(`router.teardownAll`):全部设备、订阅、定时器都不留。 */
|
|
346
|
+
function releaseAll() {
|
|
347
|
+
devices.clear()
|
|
348
|
+
releaseSubscription()
|
|
349
|
+
pending.clear()
|
|
350
|
+
if (timer !== undefined) {
|
|
351
|
+
cancel(timer)
|
|
352
|
+
timer = undefined
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
bindServices,
|
|
358
|
+
handle,
|
|
359
|
+
addDevice,
|
|
360
|
+
removeDevice,
|
|
361
|
+
releaseAll,
|
|
362
|
+
dispose: releaseAll,
|
|
363
|
+
get available() {
|
|
364
|
+
return available()
|
|
365
|
+
},
|
|
366
|
+
get subscribed() {
|
|
367
|
+
return unsubscribe !== undefined
|
|
368
|
+
},
|
|
369
|
+
get deviceCount() {
|
|
370
|
+
return devices.size
|
|
371
|
+
},
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function isJobsMethod(method) {
|
|
376
|
+
return method === ROSTER_METHOD
|
|
377
|
+
}
|
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, encodeFrameChecked, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* 进程级登记表:**同一个进程里只允许有一条到中转的 agent 连接**。
|
|
@@ -21,13 +21,15 @@ 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
23
|
import { LinkJournal, defaultJournalPath } from './journal.js'
|
|
24
|
+
import { LogSpool, defaultLogsRoot } from './logs.js'
|
|
24
25
|
import { statusSnapshot } from './status.js'
|
|
25
|
-
import { mintPairCode } from './pairing.js'
|
|
26
|
+
import { mintPairCode, regeneratePairCode } from './pairing.js'
|
|
26
27
|
import {
|
|
27
28
|
DEFAULT_EVENTS_BACKLOG, DEFAULT_SLOW_REQUEST_MS,
|
|
28
29
|
EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
|
|
29
30
|
} from './router.js'
|
|
30
|
-
import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
|
|
31
|
+
import { SERVER_VERSION, SERVER_CAPABILITIES, hostVersion } from './hello.js'
|
|
32
|
+
import { createJobsRoster } from './jobs-roster.js'
|
|
31
33
|
import { MissingIdentity, resolveIdentity } from './state.js'
|
|
32
34
|
import { connect as wsConnect } from './ws.js'
|
|
33
35
|
|
|
@@ -66,6 +68,9 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
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),
|
|
@@ -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'
|
|
@@ -420,6 +441,10 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
420
441
|
agentId: identity.agentId,
|
|
421
442
|
attempt: this.reconnectAttempts,
|
|
422
443
|
sinceCloseMs: this._lastClosedAt === undefined ? undefined : this.now() - this._lastClosedAt,
|
|
444
|
+
// 这次连接属于哪套版本:连接器 + 宿主 DSH。一连接一行,事后从本机流水就能
|
|
445
|
+
// 认出"出问题的那天跑的是哪两个版本",不必再去翻安装记录。
|
|
446
|
+
serverVersion: SERVER_VERSION,
|
|
447
|
+
hostVersion: hostVersion(),
|
|
423
448
|
})
|
|
424
449
|
this.emit('status')
|
|
425
450
|
|
|
@@ -570,8 +595,30 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
570
595
|
|
|
571
596
|
// ── relay I/O ───────────────────────────────────────────────────────────
|
|
572
597
|
|
|
598
|
+
/**
|
|
599
|
+
* 序列化一条出站帧,并把"修掉了落单代理项"记下来。
|
|
600
|
+
*
|
|
601
|
+
* 上游(宿主预览)切坏了 emoji 时,出口把它修成 U+FFFD 再发:帧合法,解码方
|
|
602
|
+
* 不会整条失败;同时留一条本机流水,出问题的源头仍然查得到。日志限流一分钟一次,
|
|
603
|
+
* 累计值不丢。
|
|
604
|
+
*/
|
|
605
|
+
#encodeForWire(frame) {
|
|
606
|
+
const { text, repaired } = encodeFrameChecked(frame)
|
|
607
|
+
if (repaired <= 0) return text
|
|
608
|
+
this.surrogateRepairs = (this.surrogateRepairs ?? 0) + repaired
|
|
609
|
+
this.journal.record('frame.surrogate_repaired', { repaired, total: this.surrogateRepairs })
|
|
610
|
+
const now = this.now()
|
|
611
|
+
if (now - (this._lastSurrogateWarnAt ?? -Infinity) >= 60_000) {
|
|
612
|
+
this._lastSurrogateWarnAt = now
|
|
613
|
+
this.logger.warn?.(
|
|
614
|
+
`mobile-link: repaired ${repaired} unpaired surrogate(s) in an outbound frame ` +
|
|
615
|
+
`(a host preview was cut inside an emoji); ${this.surrogateRepairs} so far`)
|
|
616
|
+
}
|
|
617
|
+
return text
|
|
618
|
+
}
|
|
619
|
+
|
|
573
620
|
#rawSend(frame) {
|
|
574
|
-
return this.#sendText(
|
|
621
|
+
return this.#sendText(this.#encodeForWire(frame))
|
|
575
622
|
}
|
|
576
623
|
|
|
577
624
|
/** 唯一的发送出口:socket 不在就丢掉,发送异常只记 debug、绝不抛。 */
|
|
@@ -591,7 +638,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
591
638
|
// `deviceId` 为空=这是一条**不带寻址**的帧(`fsPutAck`/`fsPutDone`/`fsErr` 那一族):
|
|
592
639
|
// relay 靠 `bid` 把回复对回发起的那个 HTTP 请求,多一个 deviceId 反而会让它对不上。
|
|
593
640
|
if (deviceId === undefined || deviceId === null) return this.#rawSend(frame)
|
|
594
|
-
const text =
|
|
641
|
+
const text = this.#encodeForWire({ ...frame, deviceId })
|
|
595
642
|
// 记字节给路由层的"慢请求/汇总"用(它按 deviceId 归):只有这里知道真正上线了多少字节。
|
|
596
643
|
this.router.noteSent?.(deviceId, Buffer.byteLength(text))
|
|
597
644
|
return this.#sendText(text)
|
|
@@ -606,6 +653,10 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
606
653
|
name: this.identity?.agentName || undefined,
|
|
607
654
|
dshPort: this.dsh?.endpoint?.port,
|
|
608
655
|
protocol: PROTOCOL_VERSION,
|
|
656
|
+
// 这台电脑的两段版本。中转据此按 agent 记一行(变化才写),手机据此
|
|
657
|
+
// 在「关于」里显示 DSH 版本——只在控制帧上走一次,不进任何业务流。
|
|
658
|
+
version: SERVER_VERSION,
|
|
659
|
+
hostVersion: hostVersion(),
|
|
609
660
|
},
|
|
610
661
|
})
|
|
611
662
|
}
|
|
@@ -653,12 +704,22 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
653
704
|
|
|
654
705
|
// ── relay-side helpers ──────────────────────────────────────────────────
|
|
655
706
|
|
|
656
|
-
/**
|
|
657
|
-
|
|
707
|
+
/**
|
|
708
|
+
* Ask the relay to mint a pairing code for this agent (relay NOTES.md §2).
|
|
709
|
+
*
|
|
710
|
+
* 032:默认持久(`ttlMs: null`);传秒数得到限时码。
|
|
711
|
+
*/
|
|
712
|
+
async mintPairCode({ ttlMs = null, fetchImpl = fetch } = {}) {
|
|
658
713
|
const identity = this.identity ?? await resolveIdentity(this.config)
|
|
659
714
|
return mintPairCode({ identity, ttlMs, fetchImpl })
|
|
660
715
|
}
|
|
661
716
|
|
|
717
|
+
/** 用户点「重新生成」:作废旧码、铸新的,**不碰已配对设备**(032)。 */
|
|
718
|
+
async regeneratePairCode({ fetchImpl = fetch } = {}) {
|
|
719
|
+
const identity = this.identity ?? await resolveIdentity(this.config)
|
|
720
|
+
return regeneratePairCode({ identity, fetchImpl })
|
|
721
|
+
}
|
|
722
|
+
|
|
662
723
|
/** Snapshot for `GET /mobile-link/status`(载荷本身在 status.js,便于守住"只增不改")。 */
|
|
663
724
|
status() {
|
|
664
725
|
return statusSnapshot(this)
|