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/router.js
CHANGED
|
@@ -18,6 +18,7 @@ import { GitBridge, isGitMethod } from './git.js'
|
|
|
18
18
|
import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
|
|
19
19
|
import { isHistoryMethod } from './history.js'
|
|
20
20
|
import { trimRecordsForWire } from './history/records.js'
|
|
21
|
+
import { isJobsMethod } from './jobs-roster.js'
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
|
|
@@ -26,6 +27,16 @@ import { trimRecordsForWire } from './history/records.js'
|
|
|
26
27
|
export const FSPUT_BEGIN = 'fsPutBegin'
|
|
27
28
|
export const FSPUT_CHUNK = 'fsPutChunk'
|
|
28
29
|
export const FSPUT_END = 'fsPutEnd'
|
|
30
|
+
/**
|
|
31
|
+
* relay 叫停一次后台上传(占位型前向兼容)。
|
|
32
|
+
*
|
|
33
|
+
* 本仓库两个中转**目前都不发这一帧**:手机掐掉 `PUT /files/up` 时,它们在
|
|
34
|
+
* `finally` 里只摘掉本地的桥接,既不发取消帧、也不断开 agent 的 WebSocket。
|
|
35
|
+
* 因此真正兜住句柄的是 `FileInbox` 的空闲清扫与"重传接管暂存路径";这一帧
|
|
36
|
+
* 让将来的/第三方中转能提前释放。按 bid 寻址,可以不带 `deviceId`
|
|
37
|
+
* (与 `fsGetCancel` 同口径,未知 bid 是空操作)。
|
|
38
|
+
*/
|
|
39
|
+
export const FSPUT_CANCEL = 'fsPutCancel'
|
|
29
40
|
|
|
30
41
|
/**
|
|
31
42
|
* 后台大文件下载的桥接帧(relay → 连接器),同一族的另一半:relay 终结
|
|
@@ -96,13 +107,16 @@ export function trimHistoryPageForWire(method, result) {
|
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
import { DeviceTimeline } from './diagnostics.js'
|
|
99
|
-
import { SESSION_DIGEST_METHOD, digestSessionList, encodeDigest } from './session-digest.js'
|
|
110
|
+
import { SESSION_DIGEST_METHOD, digestSessionList, encodeDigest, digestDelta } from './session-digest.js'
|
|
111
|
+
import { DIR_LISTING_METHOD, dirListing } from './dir-listing.js'
|
|
100
112
|
|
|
101
113
|
/** 慢请求阈值默认值(毫秒);0 = 关闭逐条慢请求记录。 */
|
|
102
114
|
export const DEFAULT_SLOW_REQUEST_MS = 2000
|
|
103
115
|
|
|
104
116
|
/** App 把自己那份阶段记录推上来的方法(契约 connector-diagnostics-method.md)。 */
|
|
105
117
|
export const DIAGNOSTICS_METHOD = '_link/diagnostics'
|
|
118
|
+
/** 手机把运行日志推上来的方法;落盘在 `logs.js`,与 journal 分开。 */
|
|
119
|
+
export const LOGS_METHOD = '_link/logs'
|
|
106
120
|
/** 单批上限:超过就截断并如实报 `accepted`,剩下的由 App 下次再推(水位不推进)。 */
|
|
107
121
|
export const DIAGNOSTICS_BATCH_BYTES = 32 * 1024
|
|
108
122
|
/** 单条事件里字符串字段的保留长度。 */
|
|
@@ -142,9 +156,11 @@ export class DeviceRouter {
|
|
|
142
156
|
constructor({
|
|
143
157
|
dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
|
|
144
158
|
eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {}, onEvent = () => {},
|
|
145
|
-
slowRequestMs = DEFAULT_SLOW_REQUEST_MS, diagnostics = true,
|
|
159
|
+
slowRequestMs = DEFAULT_SLOW_REQUEST_MS, diagnostics = true, logs = null,
|
|
146
160
|
} = {}) {
|
|
147
161
|
this.dsh = dsh
|
|
162
|
+
/** 设备日志落盘(见 logs.js);null = 不接日志(老配置或单测)。 */
|
|
163
|
+
this.logs = logs
|
|
148
164
|
this.eventsBacklog = eventsBacklog
|
|
149
165
|
this.slowRequestMs = Number(slowRequestMs) || 0
|
|
150
166
|
this.diagnosticsEnabled = diagnostics !== false
|
|
@@ -185,6 +201,21 @@ export class DeviceRouter {
|
|
|
185
201
|
// 历史窗口/检索(spec 007):由 index.js 注入宿主 `sessionQuery` 后交给它。
|
|
186
202
|
// 没绑上就回 `link/unsupported`,手机端退回 `session/page`。
|
|
187
203
|
this.history = undefined
|
|
204
|
+
// 活动名单与 `link/jobs` 推帧(spec 018)。订阅与去抖都在 roster 里,router 只负责
|
|
205
|
+
// 转交设备生命周期与把帧广播出去。
|
|
206
|
+
this.jobsRoster = undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 挂上活动名单(spec 018)。
|
|
211
|
+
*
|
|
212
|
+
* 注入可能晚于第一台设备连接(`ctx.inject` 的时序),所以这里要对**已经挂着**的
|
|
213
|
+
* 设备补报一遍:只在 attach 时通知的话,这台连接会一直到下一次 attach 才开始推帧。
|
|
214
|
+
*/
|
|
215
|
+
bindJobsRoster(roster) {
|
|
216
|
+
this.jobsRoster = roster
|
|
217
|
+
if (roster === undefined) return
|
|
218
|
+
for (const deviceId of this.devices.keys()) roster.addDevice(deviceId)
|
|
188
219
|
}
|
|
189
220
|
|
|
190
221
|
#changed() {
|
|
@@ -221,6 +252,11 @@ export class DeviceRouter {
|
|
|
221
252
|
this.handleFsGetCancel(frame)
|
|
222
253
|
return
|
|
223
254
|
}
|
|
255
|
+
// 上传的取消帧同样是 bid 寻址,可能不带 `deviceId`;放在守卫之前,理由同上。
|
|
256
|
+
if (frame.t === FSPUT_CANCEL) {
|
|
257
|
+
this.handleFsPutCancel(frame)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
224
260
|
if (!deviceId) return
|
|
225
261
|
const problem = deviceFrameError(frame)
|
|
226
262
|
if (problem) {
|
|
@@ -305,6 +341,17 @@ export class DeviceRouter {
|
|
|
305
341
|
}
|
|
306
342
|
}
|
|
307
343
|
|
|
344
|
+
/**
|
|
345
|
+
* relay 叫停一次上传:立刻关句柄、删 `.part`。
|
|
346
|
+
*
|
|
347
|
+
* 不回帧:取消本身不是一次请求,也没有可报的错(与 `handleFsGetCancel` 同)。
|
|
348
|
+
*/
|
|
349
|
+
handleFsPutCancel(frame) {
|
|
350
|
+
const bid = String(frame?.bid ?? '')
|
|
351
|
+
if (!bid) return
|
|
352
|
+
this.fileInbox.cancel(bid)
|
|
353
|
+
}
|
|
354
|
+
|
|
308
355
|
/**
|
|
309
356
|
* 开一次下载。**不 await**:分片要在 relay 读响应体时陆续发出去,一个 await
|
|
310
357
|
* 会把这条 socket 上其它帧全挡住(与上传那条流水线的理由相同)。
|
|
@@ -404,6 +451,22 @@ export class DeviceRouter {
|
|
|
404
451
|
return { ok: true, value: { accepted, lastSeq } }
|
|
405
452
|
}
|
|
406
453
|
|
|
454
|
+
/**
|
|
455
|
+
* 收下手机推来的日志行。
|
|
456
|
+
*
|
|
457
|
+
* 与 `acceptDiagnostics` 的区别:这里不产生任何生命周期事件(日志是数据,不是状态),
|
|
458
|
+
* 也不做批量解释;`logs.js` 只负责按天落盘与 7 天清理。失败一律当无事:
|
|
459
|
+
* 手机侧把未确认的条目留在自己的环里,下次再推。
|
|
460
|
+
*/
|
|
461
|
+
acceptLogs(deviceId, args = {}) {
|
|
462
|
+
if (!this.logs) {
|
|
463
|
+
return { ok: false, error: errorObject('logs/disabled', 'log intake is disabled on this connector') }
|
|
464
|
+
}
|
|
465
|
+
const entries = Array.isArray(args.entries) ? args.entries : []
|
|
466
|
+
const result = this.logs.append(deviceId, entries, { full: args.full === true })
|
|
467
|
+
return { ok: true, value: result }
|
|
468
|
+
}
|
|
469
|
+
|
|
407
470
|
/** 起一次新的接入计时(回头客也重起:那确实是一次新连接)。 */
|
|
408
471
|
#startTimeline(deviceId) {
|
|
409
472
|
this.timelines.get(deviceId)?.finish()
|
|
@@ -428,6 +491,9 @@ export class DeviceRouter {
|
|
|
428
491
|
async attachDevice(deviceId, info = {}) {
|
|
429
492
|
if (!nonEmptyString(deviceId)) return
|
|
430
493
|
this.#startTimeline(deviceId)
|
|
494
|
+
// 活动名单(018):第一台设备连上才订阅宿主任务事件;回头客这里也要补一次
|
|
495
|
+
// (detach 时它从名单里撤下过)。
|
|
496
|
+
this.jobsRoster?.addDevice(deviceId)
|
|
431
497
|
const existing = this.devices.get(deviceId)
|
|
432
498
|
if (existing) {
|
|
433
499
|
// 回头客:手机又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
|
|
@@ -484,6 +550,9 @@ export class DeviceRouter {
|
|
|
484
550
|
this.#finishTimeline(deviceId)
|
|
485
551
|
const record = this.devices.get(deviceId)
|
|
486
552
|
if (!record) return
|
|
553
|
+
// 活动名单(018):断了就不再往它那儿推任务帧(离线补发旧任务状态没有意义),
|
|
554
|
+
// 最后一台设备离开时 roster 自己会释放宿主订阅。
|
|
555
|
+
this.jobsRoster?.removeDevice(deviceId)
|
|
487
556
|
if (reason === 'revoked') {
|
|
488
557
|
await this.#dropDevice(deviceId)
|
|
489
558
|
this.onEvent('device.revoked', { deviceId })
|
|
@@ -516,6 +585,7 @@ export class DeviceRouter {
|
|
|
516
585
|
async #dropDevice(deviceId) {
|
|
517
586
|
const record = this.devices.get(deviceId)
|
|
518
587
|
if (!record) return
|
|
588
|
+
this.jobsRoster?.removeDevice(deviceId)
|
|
519
589
|
for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
|
|
520
590
|
this.devices.delete(deviceId)
|
|
521
591
|
this.logger.info?.(
|
|
@@ -555,6 +625,12 @@ export class DeviceRouter {
|
|
|
555
625
|
// 不该有残留:上面的循环已经逐个收过;这里兜底,防止 attach 之后没进 devices 的路径留下脏项。
|
|
556
626
|
this.timelines.clear()
|
|
557
627
|
this._sentBytes.clear()
|
|
628
|
+
// 链路断了:在传的上传与下载都不可能再完成。上传要关句柄、删 `.part`;
|
|
629
|
+
// 下载要停掉还没读完的窗口。
|
|
630
|
+
this.fileInbox.clear()
|
|
631
|
+
this.fileFetcher.clear()
|
|
632
|
+
// 活动名单(018):设备都没了,宿主任务订阅与排队的帧一起放掉。
|
|
633
|
+
this.jobsRoster?.releaseAll()
|
|
558
634
|
return muxIds
|
|
559
635
|
}
|
|
560
636
|
|
|
@@ -575,19 +651,67 @@ export class DeviceRouter {
|
|
|
575
651
|
this.lastError = messageOf(error)
|
|
576
652
|
this.#changed()
|
|
577
653
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
654
|
+
let payload = result
|
|
655
|
+
if (result?.ok === true) {
|
|
656
|
+
const trimmed = digestSessionList(result)
|
|
657
|
+
const items = Array.isArray(trimmed?.items) ? trimmed.items : []
|
|
658
|
+
const since = typeof args.since === 'string' ? args.since : null
|
|
659
|
+
this.digestCursors ??= new Map()
|
|
660
|
+
const previous = since ? this.digestCursors.get(deviceId) : null
|
|
661
|
+
// 版本对得上才认那份指纹表;对不上(首次、连接器重启、设备被清理)就当没有历史。
|
|
662
|
+
// 能不能当增量发由 digestDelta 决定:它持有指纹,也只有它能判断身份是否可识别。
|
|
663
|
+
const usable = previous && previous.version === since ? previous : null
|
|
664
|
+
const delta = digestDelta(usable ? usable.fingerprints : null, items)
|
|
665
|
+
this.digestCursors.set(deviceId, { version: delta.version, fingerprints: delta.fingerprints })
|
|
666
|
+
if (this.digestCursors.size > 32) {
|
|
667
|
+
const oldest = this.digestCursors.keys().next().value
|
|
668
|
+
this.digestCursors.delete(oldest)
|
|
669
|
+
}
|
|
670
|
+
const value = {
|
|
671
|
+
...trimmed,
|
|
672
|
+
items: delta.changed,
|
|
673
|
+
removed: delta.removed,
|
|
674
|
+
version: delta.version,
|
|
675
|
+
full: delta.full,
|
|
676
|
+
}
|
|
677
|
+
payload = { ok: true, value: encodeDigest(value, args.encoding) }
|
|
678
|
+
}
|
|
581
679
|
this.send(deviceId, resultFrame(frame.id, payload, deviceId))
|
|
582
680
|
return
|
|
583
681
|
}
|
|
584
682
|
|
|
683
|
+
// 目录清单的本机增强(设计 20261024):连接器读宿主的 `workspaceFiles/list`,
|
|
684
|
+
// 在电脑本机对每个条目 `fs.stat` 补一个 `mtimeMs`——宿主没有时间字段,而
|
|
685
|
+
// `workspaceFiles/stat` 的 `version` 是 opaque token(推不出时间)。
|
|
686
|
+
// 与 sessionDigest 同款:清单的内容仍是宿主的,连接器只加字段;任何一步出问题
|
|
687
|
+
// 都退回未增强的原始清单(装饰永不弄坏清单)。
|
|
688
|
+
if (frame.method === DIR_LISTING_METHOD) {
|
|
689
|
+
let result
|
|
690
|
+
try {
|
|
691
|
+
// `dirListing` 认宿主信封本身(`{ ok, value: { entries } }`)并原样回同形状的值:
|
|
692
|
+
// 宿主报错时**信封逐字透传**,`resultFrame` 照常把它翻成 `ok:false` + 原错误码。
|
|
693
|
+
// 这里不能再包一层 `{ ok: true, value }`——那会把宿主的失败塞进 `value` 里,
|
|
694
|
+
// 帧上反而成了 `ok:true`(手机就会把"目录不存在"读成"空目录")。
|
|
695
|
+
result = await dirListing({ dsh: this.dsh, args: frame.args ?? {}, logger: this.logger })
|
|
696
|
+
} catch (error) {
|
|
697
|
+
result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
|
|
698
|
+
}
|
|
699
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
|
|
585
703
|
if (frame.method === DIAGNOSTICS_METHOD) {
|
|
586
704
|
const result = this.acceptDiagnostics(deviceId, frame.args ?? {})
|
|
587
705
|
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
588
706
|
return
|
|
589
707
|
}
|
|
590
708
|
|
|
709
|
+
if (frame.method === LOGS_METHOD) {
|
|
710
|
+
const result = this.acceptLogs(deviceId, frame.args ?? {})
|
|
711
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
|
|
591
715
|
// Files the phone sends are answered here rather than forwarded: the Host
|
|
592
716
|
// has no upload endpoint, and carrying them on the existing unary frame
|
|
593
717
|
// means neither the relay nor the protocol has to change.
|
|
@@ -667,6 +791,22 @@ export class DeviceRouter {
|
|
|
667
791
|
return
|
|
668
792
|
}
|
|
669
793
|
|
|
794
|
+
// 活动名单(spec 018)与 history 同一条短路:连接器自己回答,不转发宿主。
|
|
795
|
+
// 没挂上宿主 `ctx.jobs` 时回 `link/unsupported`——**不**回空名单,否则手机
|
|
796
|
+
// 会把"这台连接器不支持"显示成"没有任务在跑"。
|
|
797
|
+
if (isJobsMethod(frame.method)) {
|
|
798
|
+
let result
|
|
799
|
+
try {
|
|
800
|
+
result = this.jobsRoster === undefined
|
|
801
|
+
? { ok: false, error: errorObject('link/unsupported', 'job roster is unavailable on this connector') }
|
|
802
|
+
: await this.jobsRoster.handle(frame.args ?? {})
|
|
803
|
+
} catch (error) {
|
|
804
|
+
result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
|
|
805
|
+
}
|
|
806
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
|
|
670
810
|
let result
|
|
671
811
|
try {
|
|
672
812
|
result = await this.dsh.rpc(frame.method, frame.args ?? {})
|
|
@@ -831,12 +971,38 @@ export class DeviceRouter {
|
|
|
831
971
|
record.readyValue = value
|
|
832
972
|
this.#changed()
|
|
833
973
|
}
|
|
974
|
+
this.#sendEventItem(deviceId, record, value)
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* 向一台设备投递一条 `$events` 形态的项(item,或未开流时的 event 帧)。
|
|
979
|
+
*
|
|
980
|
+
* 抽出来是为了让连接器自造的帧(`link/jobs`,见 `publishEvent`)与宿主事件走**同一段**
|
|
981
|
+
* 投递口径,帧语义不变:开了 `$events` 的按 dlpId 走 item,没开的按 event 广播。
|
|
982
|
+
*/
|
|
983
|
+
#sendEventItem(deviceId, record, value) {
|
|
834
984
|
for (const dlpId of record.dlpIds) this.send(deviceId, { t: 'item', id: dlpId, value })
|
|
835
985
|
// Spec §4.1.5: every $events item is broadcast as an `event` frame until the
|
|
836
986
|
// device opens the stream explicitly, so nothing is delivered twice.
|
|
837
987
|
if (record.dlpIds.size === 0) this.send(deviceId, { t: 'event', value })
|
|
838
988
|
}
|
|
839
989
|
|
|
990
|
+
/**
|
|
991
|
+
* 把一条连接器自造的项广播给**每台在线设备**(活动名单的 `link/jobs`,spec 018)。
|
|
992
|
+
*
|
|
993
|
+
* 与 `forwardEvent` 的区别是来源:那条项来自某台设备自己的 `$events` 流,只回给它;
|
|
994
|
+
* 这条是宿主任务事件的产物,没有归属设备,所以每台在线设备各收一份。
|
|
995
|
+
*
|
|
996
|
+
* **离线设备不推**:`$events` 还替它留着(见 `detachDevice`),但旧任务状态没有补发
|
|
997
|
+
* 意义——重连后手机重新问一次 `_link/jobRoster` 即可(契约 §3)。
|
|
998
|
+
*/
|
|
999
|
+
publishEvent(value) {
|
|
1000
|
+
for (const record of this.devices.values()) {
|
|
1001
|
+
if (record.offlineSince !== undefined) continue
|
|
1002
|
+
this.#sendEventItem(record.deviceId, record, value)
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
840
1006
|
/**
|
|
841
1007
|
* 要不要为这条 `$events` 项报一声(就是「提醒手机」)。
|
|
842
1008
|
*
|
package/lib/routes.js
CHANGED
|
@@ -86,6 +86,31 @@ function ttlFrom(value, fallbackMs) {
|
|
|
86
86
|
: fallbackMs
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Map an enrollment error code onto the HTTP status the panel sees. */
|
|
90
|
+
export function enrollStatus(code) {
|
|
91
|
+
switch (code) {
|
|
92
|
+
case 'enroll/already-registered':
|
|
93
|
+
case 'enroll/disabled':
|
|
94
|
+
return 409
|
|
95
|
+
case 'enroll/timeout':
|
|
96
|
+
return 504
|
|
97
|
+
case 'enroll/rate-limited':
|
|
98
|
+
return 429
|
|
99
|
+
case 'enroll/state-write':
|
|
100
|
+
case 'enroll/internal':
|
|
101
|
+
return 500
|
|
102
|
+
case 'enroll/bad-request':
|
|
103
|
+
case 'enroll/invalid-relay':
|
|
104
|
+
case 'enroll/placeholder-relay':
|
|
105
|
+
case 'enroll/unknown':
|
|
106
|
+
case 'enroll/used':
|
|
107
|
+
case 'enroll/expired':
|
|
108
|
+
return 400
|
|
109
|
+
default:
|
|
110
|
+
return 502
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
89
114
|
/**
|
|
90
115
|
* Register the routes.
|
|
91
116
|
* @returns {Array<() => void>} disposers (already wrapped by the caller in ctx.effect).
|
|
@@ -93,6 +118,9 @@ function ttlFrom(value, fallbackMs) {
|
|
|
93
118
|
export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, history }) {
|
|
94
119
|
const guard = makeGuard(ctx, logger)
|
|
95
120
|
const STATUS_ROUTE = '/mobile-link/status'
|
|
121
|
+
const ENROLL_ROUTE = '/mobile-link/enroll'
|
|
122
|
+
const REVOKE_ROUTE = '/mobile-link/devices/revoke'
|
|
123
|
+
const DEVICES_ROUTE = '/mobile-link/devices'
|
|
96
124
|
const PAIR_ROUTE = '/mobile-link/pair-code'
|
|
97
125
|
const REGENERATE_ROUTE = '/mobile-link/pair-code/regenerate'
|
|
98
126
|
const disposers = []
|
|
@@ -116,6 +144,125 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
|
|
|
116
144
|
},
|
|
117
145
|
}))
|
|
118
146
|
|
|
147
|
+
disposers.push(ctx.webServer.register({
|
|
148
|
+
kind: 'exact',
|
|
149
|
+
path: ENROLL_ROUTE,
|
|
150
|
+
handler: async (req, res) => {
|
|
151
|
+
if (guard(req, res, { requireFence: true })) return
|
|
152
|
+
if (req.method !== 'POST') {
|
|
153
|
+
res.statusCode = 405
|
|
154
|
+
res.setHeader('allow', 'POST')
|
|
155
|
+
res.end()
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const body = await readJsonBody(req)
|
|
159
|
+
if (body === undefined) {
|
|
160
|
+
sendJson(res, 400, { ok: false, code: 'enroll/bad-request', message: 'invalid JSON body' })
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
const relayUrl = typeof body.relayUrl === 'string' ? body.relayUrl.trim() : ''
|
|
164
|
+
const inviteCode = typeof body.inviteCode === 'string' ? body.inviteCode.trim() : ''
|
|
165
|
+
const agentName = typeof body.agentName === 'string' ? body.agentName.trim() : ''
|
|
166
|
+
if (!relayUrl) {
|
|
167
|
+
sendJson(res, 400, {
|
|
168
|
+
ok: false,
|
|
169
|
+
code: 'enroll/bad-request',
|
|
170
|
+
message: 'relayUrl is required',
|
|
171
|
+
})
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
// 开放登记:不需要邀请码;旧调用方仍可带(历史兼容)。
|
|
176
|
+
const settled = await agent.enroll({
|
|
177
|
+
inviteCode: inviteCode || undefined,
|
|
178
|
+
relayUrl,
|
|
179
|
+
agentName: agentName || undefined,
|
|
180
|
+
})
|
|
181
|
+
logger.info?.(`mobile-link: panel enrollment succeeded for ${settled.agentId}`)
|
|
182
|
+
// Never return `agentSecret`: the browser has no use for it, and one
|
|
183
|
+
// XSS in the panel would otherwise hand over the machine's credential.
|
|
184
|
+
sendJson(res, 200, {
|
|
185
|
+
ok: true,
|
|
186
|
+
agentId: settled.agentId,
|
|
187
|
+
agentName: settled.agentName,
|
|
188
|
+
relayUrl: settled.relayUrl,
|
|
189
|
+
})
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// Read the code off the error rather than using `instanceof`: a hot
|
|
192
|
+
// reload can leave the route and the link on different module
|
|
193
|
+
// generations, and an identity check would then downgrade a precise
|
|
194
|
+
// "already registered" into a generic 500.
|
|
195
|
+
const code = typeof error?.code === 'string' && error.code.startsWith('enroll/')
|
|
196
|
+
? error.code
|
|
197
|
+
: 'enroll/internal'
|
|
198
|
+
const status = enrollStatus(code)
|
|
199
|
+
if (code === 'enroll/internal') {
|
|
200
|
+
logger.warn?.(`mobile-link: panel enrollment failed: ${String(error?.message ?? error)}`)
|
|
201
|
+
}
|
|
202
|
+
sendJson(res, status, { ok: false, code, message: String(error?.message ?? error) })
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
}))
|
|
206
|
+
|
|
207
|
+
disposers.push(ctx.webServer.register({
|
|
208
|
+
kind: 'exact',
|
|
209
|
+
path: DEVICES_ROUTE,
|
|
210
|
+
handler: async (req, res) => {
|
|
211
|
+
if (guard(req, res, { requireFence: true })) return
|
|
212
|
+
if (req.method !== 'GET') {
|
|
213
|
+
res.statusCode = 405
|
|
214
|
+
res.setHeader('allow', 'GET')
|
|
215
|
+
res.end()
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const result = await agent.listPairedDevices()
|
|
220
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
221
|
+
} catch (error) {
|
|
222
|
+
sendJson(res, 502, {
|
|
223
|
+
ok: false,
|
|
224
|
+
code: 'devices/list-failed',
|
|
225
|
+
message: String(error?.message ?? error),
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}))
|
|
230
|
+
|
|
231
|
+
disposers.push(ctx.webServer.register({
|
|
232
|
+
kind: 'exact',
|
|
233
|
+
path: REVOKE_ROUTE,
|
|
234
|
+
handler: async (req, res) => {
|
|
235
|
+
if (guard(req, res, { requireFence: true })) return
|
|
236
|
+
if (req.method !== 'POST') {
|
|
237
|
+
res.statusCode = 405
|
|
238
|
+
res.setHeader('allow', 'POST')
|
|
239
|
+
res.end()
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
const body = await readJsonBody(req)
|
|
243
|
+
if (body === undefined) {
|
|
244
|
+
sendJson(res, 400, { ok: false, code: 'request/bad-request', message: 'invalid JSON body' })
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
const deviceId = typeof body.deviceId === 'string' ? body.deviceId.trim() : ''
|
|
248
|
+
if (!deviceId) {
|
|
249
|
+
sendJson(res, 400, { ok: false, code: 'request/device-id', message: 'deviceId is required' })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const result = await agent.revokeDevice(deviceId)
|
|
254
|
+
logger.info?.(`mobile-link: revoked device ${result.deviceId}`)
|
|
255
|
+
sendJson(res, 200, { ok: true, deviceId: result.deviceId })
|
|
256
|
+
} catch (error) {
|
|
257
|
+
sendJson(res, 502, {
|
|
258
|
+
ok: false,
|
|
259
|
+
code: 'devices/revoke-failed',
|
|
260
|
+
message: String(error?.message ?? error),
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}))
|
|
265
|
+
|
|
119
266
|
disposers.push(ctx.webServer.register({
|
|
120
267
|
kind: 'exact',
|
|
121
268
|
path: PAIR_ROUTE,
|
package/lib/session-digest.js
CHANGED
|
@@ -12,25 +12,33 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { deflateRawSync } from 'node:zlib'
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
15
16
|
|
|
16
17
|
/** 方法名(设备发起,中转不校验方法名,所以不需要中转改动)。 */
|
|
17
18
|
export const SESSION_DIGEST_METHOD = '_link/sessionDigest'
|
|
18
19
|
|
|
19
20
|
/** 会话项里剔掉的字段:零读取点(`agentAvailable` 1.8%)。 */
|
|
20
|
-
|
|
21
|
+
// `agentAvailable` 曾是"零读取点",018 之后列表行要用它区分"会话是否还有活实例"
|
|
22
|
+
// (`ActivityStore` 与 `session.activity.*`),因此**不再剔除**。留空数组是为了保住
|
|
23
|
+
// 「剔除机制 + 逐字节稳定」这两条不变式;将来要剔字段仍从这里加。
|
|
24
|
+
export const DROPPED_ITEM_KEYS = Object.freeze([])
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
|
-
*
|
|
27
|
+
* 投影键里剔掉的字段(合计约 55%)。
|
|
24
28
|
*
|
|
25
29
|
* 每一条都能在 App/DSHKit 里复查"零读取点":
|
|
26
30
|
* `turnOutline` 33.4%(每轮 prompt/response 原文)、`imageLimits` 6.2%(上传限制)、
|
|
27
31
|
* `sessionStats` 4.2%、`todos` 2.6%、`subagentCatalog` 2.6%、`contextBreakdown` 2.5%、
|
|
28
32
|
* `sessionListMetadata` 1.9%(唯一被读的 `blank` 在顶层已有同名字段)、
|
|
29
|
-
* `
|
|
33
|
+
* `agentPreset` 0.8%(`SessionCreateRequest.agentPreset` 是请求字段)。
|
|
34
|
+
*
|
|
35
|
+
* **2026-10-20 起不再剔除 `subagentTiming`**(原 1.5%):018 的活动可见性靠它判断
|
|
36
|
+
* "某子会话当前是否在跑"与已跑时长;剔除后手机永远看不到「N 智能体」。
|
|
37
|
+
* 同理 `agentAvailable` 也不再剔除。代价是摘要通道多约 1.5% 字节,换来功能可用。
|
|
30
38
|
*/
|
|
31
39
|
export const DROPPED_PROJECTION_KEYS = Object.freeze([
|
|
32
40
|
'turnOutline', 'imageLimits', 'sessionStats', 'todos', 'subagentCatalog',
|
|
33
|
-
'contextBreakdown', 'sessionListMetadata', '
|
|
41
|
+
'contextBreakdown', 'sessionListMetadata', 'agentPreset',
|
|
34
42
|
])
|
|
35
43
|
|
|
36
44
|
/**
|
|
@@ -115,3 +123,64 @@ export function encodeDigest(value, encoding) {
|
|
|
115
123
|
rawBytes: raw.length,
|
|
116
124
|
}
|
|
117
125
|
}
|
|
126
|
+
|
|
127
|
+
/** 单条会话的稳定指纹:内容不变则指纹不变(用于增量比较)。 */
|
|
128
|
+
export function digestFingerprint(item) {
|
|
129
|
+
try {
|
|
130
|
+
return createHash('sha1').update(JSON.stringify(item)).digest('hex').slice(0, 16)
|
|
131
|
+
} catch {
|
|
132
|
+
return 'unhashable'
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 一批会话的指纹表(sessionId → 指纹)。
|
|
138
|
+
*
|
|
139
|
+
* 键是宿主 `SessionSummary.sessionId`——会话身份在宿主只有这一个名字。
|
|
140
|
+
* 这里不认 `id`:多认一个名字就是第二份身份,正是它让指纹表恒为空(2026-10-27)。
|
|
141
|
+
*/
|
|
142
|
+
export function digestFingerprints(items) {
|
|
143
|
+
const map = new Map()
|
|
144
|
+
for (const item of items ?? []) {
|
|
145
|
+
if (item && typeof item.sessionId === 'string') map.set(item.sessionId, digestFingerprint(item))
|
|
146
|
+
}
|
|
147
|
+
return map
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 指纹表的版本:指纹集合不变则版本不变。 */
|
|
151
|
+
export function fingerprintsVersion(map) {
|
|
152
|
+
const keys = [...map.keys()].sort()
|
|
153
|
+
const digest = createHash('sha1')
|
|
154
|
+
for (const key of keys) digest.update(key).update('\u0000').update(map.get(key)).update('\u0001')
|
|
155
|
+
return digest.digest('hex').slice(0, 16)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 相对上一次指纹表的增量。
|
|
160
|
+
*
|
|
161
|
+
* 入参 `previous` 为 Map 或 null。返回:
|
|
162
|
+
* - `full`:能不能把这份结果当增量发;
|
|
163
|
+
* - `changed`:新增或内容变化的项(`full` 时为整份 items);
|
|
164
|
+
* - `removed`:消失的 sessionId(`full` 时为空);
|
|
165
|
+
* - `version` / `fingerprints`:下一次比较用的版本与指纹表。
|
|
166
|
+
*
|
|
167
|
+
* **身份不可识别时退化为全量**:摘要是优化,不是事实来源。只要有一项没有 `sessionId`,
|
|
168
|
+
* 就没法证明其余项没变;此时宁可多发一份全量,也不能回空增量——后者不报错、不更新,
|
|
169
|
+
* 界面看起来正常却永远冻结(2026-10-27 的现场)。
|
|
170
|
+
*/
|
|
171
|
+
export function digestDelta(previous, items) {
|
|
172
|
+
const list = Array.isArray(items) ? items : []
|
|
173
|
+
const identifiable = list.every((item) => item && typeof item.sessionId === 'string')
|
|
174
|
+
const next = digestFingerprints(list)
|
|
175
|
+
const version = fingerprintsVersion(next)
|
|
176
|
+
if (previous === null || !identifiable) {
|
|
177
|
+
return { full: true, changed: list, removed: [], version, fingerprints: next }
|
|
178
|
+
}
|
|
179
|
+
const changed = []
|
|
180
|
+
for (const item of list) {
|
|
181
|
+
if (previous.get(item.sessionId) !== next.get(item.sessionId)) changed.push(item)
|
|
182
|
+
}
|
|
183
|
+
const removed = []
|
|
184
|
+
for (const sessionId of previous.keys()) if (!next.has(sessionId)) removed.push(sessionId)
|
|
185
|
+
return { full: false, changed, removed, version, fingerprints: next }
|
|
186
|
+
}
|
package/lib/state.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The file holds the long-lived `agentId` / `agentSecret` for this computer.
|
|
5
5
|
* There are two ways it gets there, and they are deliberately indistinguishable
|
|
6
|
-
* afterwards: the relay operator provisions it by hand (relay
|
|
7
|
-
*
|
|
6
|
+
* afterwards: the relay operator provisions it by hand (`dsh-relay admin
|
|
7
|
+
* agent-register --write-config`), or the person running the computer redeems
|
|
8
8
|
* an invite code themselves (`dsh-mobile-link enroll`, see enroll.js).
|
|
9
9
|
* Environment variables and the plugin config block override the file, which is
|
|
10
10
|
* how the integration test injects a throwaway identity.
|
|
@@ -106,10 +106,10 @@ export async function resolveIdentity({ stateFile, agentId, agentSecret, relayUr
|
|
|
106
106
|
}
|
|
107
107
|
if (!resolved.agentId || !resolved.agentSecret) {
|
|
108
108
|
throw new MissingIdentity(
|
|
109
|
-
`mobile-link: this computer is not registered yet.
|
|
110
|
-
+ `
|
|
111
|
-
+ `
|
|
112
|
-
+ ` "
|
|
109
|
+
`mobile-link: this computer is not registered yet. Open the "移动端连接" panel`
|
|
110
|
+
+ ` and click 登记, or run "dsh-mobile-link enroll --relay <relay-url>".`
|
|
111
|
+
+ ` (Provisioning by hand is still possible:`
|
|
112
|
+
+ ` "dsh-relay admin agent-register --account <acc> --name <name>`
|
|
113
113
|
+ ` --write-config ${path}".)`,
|
|
114
114
|
{ stateFile: path },
|
|
115
115
|
)
|
package/lib/status.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* 函数只读 agent 的字段,不持有状态,因此可以直接被单测调用。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { isPlaceholderRelayUrl } from './dlp.js'
|
|
9
10
|
import { SERVER_CAPABILITIES, SERVER_VERSION, hostVersion } from './hello.js'
|
|
10
11
|
|
|
11
12
|
const PROTOCOL_VERSION = 1
|
|
@@ -81,6 +82,26 @@ const STATUS_JOURNAL_ENTRIES = 20
|
|
|
81
82
|
*/
|
|
82
83
|
export function statusSnapshot(agent) {
|
|
83
84
|
const hostEntry = HOST_HINTS[agent.hostForm] ?? HOST_HINTS.unknown
|
|
85
|
+
// One resolved view of the relay address, shared by the form and the panel:
|
|
86
|
+
// a configured address outranks the identity, but the repository placeholder
|
|
87
|
+
// is "unset", never a value to prefill. `relayLocked` says the address came
|
|
88
|
+
// from config/env, so the form must be read-only — otherwise the panel would
|
|
89
|
+
// enroll with one address and the next boot would dial another.
|
|
90
|
+
const configuredRelay = typeof agent.config?.relayUrl === 'string' ? agent.config.relayUrl : ''
|
|
91
|
+
const realConfiguredRelay = configuredRelay && !isPlaceholderRelayUrl(configuredRelay)
|
|
92
|
+
? configuredRelay
|
|
93
|
+
: null
|
|
94
|
+
// `defaultRelayUrl` is a *form default*, not a dialing override: it only
|
|
95
|
+
// prefills the input, so the user can replace it and the identity file still
|
|
96
|
+
// wins on the next boot. A real `relayUrl` is the operator pin and locks it.
|
|
97
|
+
const configuredDefault = typeof agent.config?.defaultRelayUrl === 'string'
|
|
98
|
+
? agent.config.defaultRelayUrl
|
|
99
|
+
: ''
|
|
100
|
+
const defaultRelay = configuredDefault && !isPlaceholderRelayUrl(configuredDefault)
|
|
101
|
+
? configuredDefault
|
|
102
|
+
: null
|
|
103
|
+
const effectiveRelay = agent.identity?.relayUrl ?? realConfiguredRelay ?? null
|
|
104
|
+
const relayPrefill = realConfiguredRelay ?? defaultRelay ?? agent.identity?.relayUrl ?? null
|
|
84
105
|
return {
|
|
85
106
|
ok: true,
|
|
86
107
|
enabled: agent.enabled !== false,
|
|
@@ -102,11 +123,18 @@ export function statusSnapshot(agent) {
|
|
|
102
123
|
// computer half is installed but not yet registered, and what to run.
|
|
103
124
|
registered: !agent.needsEnroll,
|
|
104
125
|
needsEnroll: agent.needsEnroll,
|
|
126
|
+
// `panel` is a host-UI capability, not a phone capability: an older
|
|
127
|
+
// connector omits it, and the panel falls back to the command. It is
|
|
128
|
+
// deliberately not in SERVER_CAPABILITIES (that list is what the app asks
|
|
129
|
+
// for over `_link/hello`; the phone has no use for a panel form).
|
|
130
|
+
panel: true,
|
|
105
131
|
stateFile: agent.identity?.stateFile ?? agent.config.stateFile ?? null,
|
|
106
|
-
relayUrl:
|
|
132
|
+
relayUrl: effectiveRelay,
|
|
133
|
+
relayPrefill,
|
|
134
|
+
relayLocked: Boolean(realConfiguredRelay),
|
|
107
135
|
command: agent.needsEnroll ? agent.enrollHint() : null,
|
|
108
136
|
hint: agent.needsEnroll
|
|
109
|
-
? '
|
|
137
|
+
? '这台电脑还没有登记到中转。在「移动端连接」面板里确认中转地址,然后点「登记」。'
|
|
110
138
|
: null,
|
|
111
139
|
},
|
|
112
140
|
state: agent.state,
|