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
package/lib/logs.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设备日志落盘(`_link/logs`)。
|
|
3
|
+
*
|
|
4
|
+
* 与 `journal.js` 的分工:journal 记的是**连接器自己**的生命周期事件(几百条、可以随手丢),
|
|
5
|
+
* 这里记的是**手机端**产生的日志行(量级大、需要按时间窗回看)。两者共用"诊断设施不许抛"
|
|
6
|
+
* 这条硬约束,但容量与保留期完全不同。
|
|
7
|
+
*
|
|
8
|
+
* 保留期 **7 天**,清理不靠定时器、也不新增任何进程:每次 `append` 顺手做一次,
|
|
9
|
+
* 且 60 秒内最多一次。理由是不引入定时任务就不会有"任务没跑起来、盘悄悄满了"这种状态。
|
|
10
|
+
*
|
|
11
|
+
* 目录布局:`<root>/<deviceId>/<YYYYMMDD>.jsonl`。按天分文件有三个好处:
|
|
12
|
+
* 清理只需 unlink 整个文件,不必重写;`tail` 只需盯当天的文件;超限时按天淘汰。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import { defaultStatePath } from './state.js'
|
|
19
|
+
|
|
20
|
+
/** 日志保留期:7 天。 */
|
|
21
|
+
export const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
|
22
|
+
/** 单台设备磁盘上限;超了按天淘汰最旧的。 */
|
|
23
|
+
export const DEFAULT_MAX_DEVICE_BYTES = 200 * 1024 * 1024
|
|
24
|
+
/** 单条日志正文上限;超长截断而不是丢弃(截断仍能看出是哪一行)。 */
|
|
25
|
+
export const MAX_TEXT_CHARS = 4000
|
|
26
|
+
/** 每批最多收多少条、多少字节;超了如实报 `accepted`,剩下的由手机下次推。 */
|
|
27
|
+
export const MAX_BATCH_ENTRIES = 500
|
|
28
|
+
export const MAX_BATCH_BYTES = 64 * 1024
|
|
29
|
+
/** 清理的最小间隔:清理一次最多 60 秒一次。 */
|
|
30
|
+
const PRUNE_INTERVAL_MS = 60 * 1000
|
|
31
|
+
|
|
32
|
+
/** 默认落在身份文件旁边:`$DSH_HOME/mobile-link/logs`。测试用临时 DSH_HOME 自动隔离。 */
|
|
33
|
+
export function defaultLogsRoot() {
|
|
34
|
+
return join(dirname(defaultStatePath()), 'logs')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 本地日 `YYYYMMDD`,与 journal 的 `local_day` 口径一致。 */
|
|
38
|
+
export function dayStamp(ms) {
|
|
39
|
+
const date = new Date(ms)
|
|
40
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
41
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 设备 id 会直接进路径:只留安全字符,避免 `../` 之类的路径穿越。 */
|
|
45
|
+
export function safeDeviceId(deviceId) {
|
|
46
|
+
const text = String(deviceId ?? '').trim()
|
|
47
|
+
if (!text) return 'unknown'
|
|
48
|
+
return text.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 120)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 一条上报 → 一行落盘 JSON;非法条目返回 null(调用方计入拒收)。 */
|
|
52
|
+
export function normalizeEntry(raw) {
|
|
53
|
+
if (!raw || typeof raw !== 'object') return null
|
|
54
|
+
const ms = Number(raw.ms)
|
|
55
|
+
const text = typeof raw.text === 'string' ? raw.text : ''
|
|
56
|
+
if (!Number.isFinite(ms) || !text) return null
|
|
57
|
+
const seq = Number(raw.seq)
|
|
58
|
+
return {
|
|
59
|
+
seq: Number.isFinite(seq) ? seq : undefined,
|
|
60
|
+
ms,
|
|
61
|
+
at: typeof raw.at === 'string' && raw.at ? raw.at : new Date(ms).toISOString(),
|
|
62
|
+
level: typeof raw.level === 'string' ? raw.level.slice(0, 16) : 'info',
|
|
63
|
+
source: typeof raw.source === 'string' ? raw.source.slice(0, 32) : 'app',
|
|
64
|
+
text: text.slice(0, MAX_TEXT_CHARS),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class LogSpool {
|
|
69
|
+
/**
|
|
70
|
+
* @param {object} [options]
|
|
71
|
+
* @param {string} [options.root] 目录(默认 `$DSH_HOME/mobile-link/logs`)
|
|
72
|
+
* @param {number} [options.retentionMs] 保留期
|
|
73
|
+
* @param {number} [options.maxDeviceBytes] 单设备上限
|
|
74
|
+
* @param {() => number} [options.now]
|
|
75
|
+
* @param {{ debug?: (message: string) => void }} [options.logger]
|
|
76
|
+
*/
|
|
77
|
+
constructor({
|
|
78
|
+
root, retentionMs = DEFAULT_RETENTION_MS, maxDeviceBytes = DEFAULT_MAX_DEVICE_BYTES,
|
|
79
|
+
now = Date.now, logger,
|
|
80
|
+
} = {}) {
|
|
81
|
+
this.root = typeof root === 'string' && root.trim() ? root : defaultLogsRoot()
|
|
82
|
+
this.retentionMs = Math.max(60_000, Number(retentionMs) || DEFAULT_RETENTION_MS)
|
|
83
|
+
this.maxDeviceBytes = Math.max(1024, Number(maxDeviceBytes) || DEFAULT_MAX_DEVICE_BYTES)
|
|
84
|
+
this.now = now
|
|
85
|
+
this.logger = logger
|
|
86
|
+
/** 每台设备已接受的最高序号(幂等:同批重推不会写两遍)。 */
|
|
87
|
+
this.watermarks = new Map()
|
|
88
|
+
this._lastPruneAt = 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 一台设备当天的文件路径。 */
|
|
92
|
+
pathFor(deviceId, ms = this.now()) {
|
|
93
|
+
return join(this.root, safeDeviceId(deviceId), `${dayStamp(ms)}.jsonl`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 收一批并落盘。
|
|
98
|
+
*
|
|
99
|
+
* @returns {{accepted: number, lastSeq: number, dropped: number}}
|
|
100
|
+
* `accepted` 是真正写下去的条数;被水位挡掉的重复条目不计入。
|
|
101
|
+
*/
|
|
102
|
+
append(deviceId, entries, { full = false } = {}) {
|
|
103
|
+
const result = { accepted: 0, lastSeq: this.watermarks.get(deviceId) ?? 0, dropped: 0 }
|
|
104
|
+
if (!Array.isArray(entries) || entries.length === 0) return result
|
|
105
|
+
let bytes = 0
|
|
106
|
+
const byDay = new Map()
|
|
107
|
+
for (const raw of entries) {
|
|
108
|
+
const entry = normalizeEntry(raw)
|
|
109
|
+
if (!entry) { result.dropped += 1; continue }
|
|
110
|
+
const seq = Number(entry.seq)
|
|
111
|
+
const watermark = this.watermarks.get(deviceId) ?? 0
|
|
112
|
+
// `full` 是"整段重推"(App 冷启动回填或 `dsh://diag export`):
|
|
113
|
+
// 水位不参与判断,但仍然照常推进,避免之后的增量重复。
|
|
114
|
+
if (!full && Number.isFinite(seq) && seq <= watermark) continue
|
|
115
|
+
const line = `${JSON.stringify({ ...entry, deviceId, full: full || undefined })}\n`
|
|
116
|
+
if (result.accepted > 0 && bytes + line.length > MAX_BATCH_BYTES) break
|
|
117
|
+
if (result.accepted >= MAX_BATCH_ENTRIES) break
|
|
118
|
+
const day = dayStamp(entry.ms)
|
|
119
|
+
const bucket = byDay.get(day) ?? []
|
|
120
|
+
bucket.push(line)
|
|
121
|
+
byDay.set(day, bucket)
|
|
122
|
+
bytes += line.length
|
|
123
|
+
result.accepted += 1
|
|
124
|
+
result.lastSeq = Math.max(result.lastSeq, Number.isFinite(seq) ? seq : result.lastSeq)
|
|
125
|
+
}
|
|
126
|
+
if (!byDay.size) return result
|
|
127
|
+
for (const [day, lines] of byDay) this.#write(deviceId, day, lines)
|
|
128
|
+
this.watermarks.set(deviceId, result.lastSeq)
|
|
129
|
+
this.#pruneIfDue()
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#write(deviceId, day, lines) {
|
|
134
|
+
const path = join(this.root, safeDeviceId(deviceId), `${day}.jsonl`)
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
137
|
+
appendFileSync(path, lines.join(''))
|
|
138
|
+
} catch (error) {
|
|
139
|
+
// 诊断设施不许把链路带崩:写不进去就只报一声。
|
|
140
|
+
this.logger?.debug?.(`mobile-link: logs spool write failed (${error?.message ?? error})`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 60 秒内最多清理一次;清理本身不许抛。 */
|
|
145
|
+
#pruneIfDue() {
|
|
146
|
+
const now = this.now()
|
|
147
|
+
if (now - this._lastPruneAt < PRUNE_INTERVAL_MS) return
|
|
148
|
+
this._lastPruneAt = now
|
|
149
|
+
try {
|
|
150
|
+
this.prune(now)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this.logger?.debug?.(`mobile-link: logs prune failed (${error?.message ?? error})`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 清理:先按保留期删整天文件,再按单设备上限删最旧的。
|
|
158
|
+
* 返回删掉的字节数(测试用;生产只看副作用)。
|
|
159
|
+
*/
|
|
160
|
+
prune(now = this.now()) {
|
|
161
|
+
let freed = 0
|
|
162
|
+
let devices = []
|
|
163
|
+
try {
|
|
164
|
+
devices = readdirSync(this.root, { withFileTypes: true }).filter((item) => item.isDirectory())
|
|
165
|
+
} catch {
|
|
166
|
+
return 0
|
|
167
|
+
}
|
|
168
|
+
for (const device of devices) {
|
|
169
|
+
const dir = join(this.root, device.name)
|
|
170
|
+
let files = []
|
|
171
|
+
try {
|
|
172
|
+
files = readdirSync(dir).filter((name) => name.endsWith('.jsonl'))
|
|
173
|
+
} catch {
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (!files.length) {
|
|
177
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 目录删不掉无所谓 */ }
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
const stats = []
|
|
181
|
+
for (const name of files.sort()) {
|
|
182
|
+
const path = join(dir, name)
|
|
183
|
+
try {
|
|
184
|
+
const info = statSync(path)
|
|
185
|
+
stats.push({ path, day: name.slice(0, 8), size: info.size, mtimeMs: info.mtimeMs })
|
|
186
|
+
} catch { /* 文件刚被删掉 */ }
|
|
187
|
+
}
|
|
188
|
+
// 保留期按**文件名里的日**判定,不看 mtime:mtime 会被备份、拷贝、恢复改动,
|
|
189
|
+
// 而"这是哪一天的日志"只写在名字里,判定必须与名字一致。
|
|
190
|
+
const cutoff = dayStamp(now - this.retentionMs)
|
|
191
|
+
for (const file of stats) {
|
|
192
|
+
if (file.day >= cutoff) continue
|
|
193
|
+
freed += this.#unlink(file)
|
|
194
|
+
}
|
|
195
|
+
let kept = stats.filter((file) => file.day >= cutoff)
|
|
196
|
+
let total = kept.reduce((sum, file) => sum + file.size, 0)
|
|
197
|
+
for (const file of kept) {
|
|
198
|
+
if (total <= this.maxDeviceBytes) break
|
|
199
|
+
total -= file.size
|
|
200
|
+
freed += this.#unlink(file)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return freed
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#unlink(file) {
|
|
207
|
+
try {
|
|
208
|
+
unlinkSync(file.path)
|
|
209
|
+
return file.size
|
|
210
|
+
} catch {
|
|
211
|
+
return 0
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 某台设备(或全部)的磁盘占用,给人看的。 */
|
|
216
|
+
usage() {
|
|
217
|
+
const rows = []
|
|
218
|
+
let devices = []
|
|
219
|
+
try {
|
|
220
|
+
devices = readdirSync(this.root, { withFileTypes: true }).filter((item) => item.isDirectory())
|
|
221
|
+
} catch {
|
|
222
|
+
return rows
|
|
223
|
+
}
|
|
224
|
+
for (const device of devices) {
|
|
225
|
+
const dir = join(this.root, device.name)
|
|
226
|
+
let bytes = 0
|
|
227
|
+
let files = 0
|
|
228
|
+
let newest = 0
|
|
229
|
+
try {
|
|
230
|
+
for (const name of readdirSync(dir)) {
|
|
231
|
+
if (!name.endsWith('.jsonl')) continue
|
|
232
|
+
const info = statSync(join(dir, name))
|
|
233
|
+
bytes += info.size
|
|
234
|
+
files += 1
|
|
235
|
+
newest = Math.max(newest, info.mtimeMs)
|
|
236
|
+
}
|
|
237
|
+
} catch { /* 忽略单台设备的读失败 */ }
|
|
238
|
+
rows.push({ deviceId: device.name, bytes, files, newestMs: newest })
|
|
239
|
+
}
|
|
240
|
+
return rows.sort((a, b) => b.bytes - a.bytes)
|
|
241
|
+
}
|
|
242
|
+
}
|
package/lib/pairing.js
CHANGED
|
@@ -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-go/internal/httpapi` 的 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 }),
|
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,66 @@ 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
|
+
const incremental = Boolean(previous && previous.version === since)
|
|
663
|
+
const delta = digestDelta(incremental ? previous.fingerprints : null, items)
|
|
664
|
+
this.digestCursors.set(deviceId, { version: delta.version, fingerprints: delta.fingerprints })
|
|
665
|
+
if (this.digestCursors.size > 32) {
|
|
666
|
+
const oldest = this.digestCursors.keys().next().value
|
|
667
|
+
this.digestCursors.delete(oldest)
|
|
668
|
+
}
|
|
669
|
+
const value = {
|
|
670
|
+
...trimmed,
|
|
671
|
+
items: incremental ? delta.changed : items,
|
|
672
|
+
removed: incremental ? delta.removed : [],
|
|
673
|
+
version: delta.version,
|
|
674
|
+
full: !incremental,
|
|
675
|
+
}
|
|
676
|
+
payload = { ok: true, value: encodeDigest(value, args.encoding) }
|
|
677
|
+
}
|
|
581
678
|
this.send(deviceId, resultFrame(frame.id, payload, deviceId))
|
|
582
679
|
return
|
|
583
680
|
}
|
|
584
681
|
|
|
682
|
+
// 目录清单的本机增强(设计 20261024):连接器读宿主的 `workspaceFiles/list`,
|
|
683
|
+
// 在电脑本机对每个条目 `fs.stat` 补一个 `mtimeMs`——宿主没有时间字段,而
|
|
684
|
+
// `workspaceFiles/stat` 的 `version` 是 opaque token(推不出时间)。
|
|
685
|
+
// 与 sessionDigest 同款:清单的内容仍是宿主的,连接器只加字段;任何一步出问题
|
|
686
|
+
// 都退回未增强的原始清单(装饰永不弄坏清单)。
|
|
687
|
+
if (frame.method === DIR_LISTING_METHOD) {
|
|
688
|
+
let result
|
|
689
|
+
try {
|
|
690
|
+
// `dirListing` 认宿主信封本身(`{ ok, value: { entries } }`)并原样回同形状的值:
|
|
691
|
+
// 宿主报错时**信封逐字透传**,`resultFrame` 照常把它翻成 `ok:false` + 原错误码。
|
|
692
|
+
// 这里不能再包一层 `{ ok: true, value }`——那会把宿主的失败塞进 `value` 里,
|
|
693
|
+
// 帧上反而成了 `ok:true`(手机就会把"目录不存在"读成"空目录")。
|
|
694
|
+
result = await dirListing({ dsh: this.dsh, args: frame.args ?? {}, logger: this.logger })
|
|
695
|
+
} catch (error) {
|
|
696
|
+
result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
|
|
697
|
+
}
|
|
698
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
|
|
585
702
|
if (frame.method === DIAGNOSTICS_METHOD) {
|
|
586
703
|
const result = this.acceptDiagnostics(deviceId, frame.args ?? {})
|
|
587
704
|
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
588
705
|
return
|
|
589
706
|
}
|
|
590
707
|
|
|
708
|
+
if (frame.method === LOGS_METHOD) {
|
|
709
|
+
const result = this.acceptLogs(deviceId, frame.args ?? {})
|
|
710
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
|
|
591
714
|
// Files the phone sends are answered here rather than forwarded: the Host
|
|
592
715
|
// has no upload endpoint, and carrying them on the existing unary frame
|
|
593
716
|
// means neither the relay nor the protocol has to change.
|
|
@@ -667,6 +790,22 @@ export class DeviceRouter {
|
|
|
667
790
|
return
|
|
668
791
|
}
|
|
669
792
|
|
|
793
|
+
// 活动名单(spec 018)与 history 同一条短路:连接器自己回答,不转发宿主。
|
|
794
|
+
// 没挂上宿主 `ctx.jobs` 时回 `link/unsupported`——**不**回空名单,否则手机
|
|
795
|
+
// 会把"这台连接器不支持"显示成"没有任务在跑"。
|
|
796
|
+
if (isJobsMethod(frame.method)) {
|
|
797
|
+
let result
|
|
798
|
+
try {
|
|
799
|
+
result = this.jobsRoster === undefined
|
|
800
|
+
? { ok: false, error: errorObject('link/unsupported', 'job roster is unavailable on this connector') }
|
|
801
|
+
: await this.jobsRoster.handle(frame.args ?? {})
|
|
802
|
+
} catch (error) {
|
|
803
|
+
result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
|
|
804
|
+
}
|
|
805
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
|
|
670
809
|
let result
|
|
671
810
|
try {
|
|
672
811
|
result = await this.dsh.rpc(frame.method, frame.args ?? {})
|
|
@@ -831,12 +970,38 @@ export class DeviceRouter {
|
|
|
831
970
|
record.readyValue = value
|
|
832
971
|
this.#changed()
|
|
833
972
|
}
|
|
973
|
+
this.#sendEventItem(deviceId, record, value)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* 向一台设备投递一条 `$events` 形态的项(item,或未开流时的 event 帧)。
|
|
978
|
+
*
|
|
979
|
+
* 抽出来是为了让连接器自造的帧(`link/jobs`,见 `publishEvent`)与宿主事件走**同一段**
|
|
980
|
+
* 投递口径,帧语义不变:开了 `$events` 的按 dlpId 走 item,没开的按 event 广播。
|
|
981
|
+
*/
|
|
982
|
+
#sendEventItem(deviceId, record, value) {
|
|
834
983
|
for (const dlpId of record.dlpIds) this.send(deviceId, { t: 'item', id: dlpId, value })
|
|
835
984
|
// Spec §4.1.5: every $events item is broadcast as an `event` frame until the
|
|
836
985
|
// device opens the stream explicitly, so nothing is delivered twice.
|
|
837
986
|
if (record.dlpIds.size === 0) this.send(deviceId, { t: 'event', value })
|
|
838
987
|
}
|
|
839
988
|
|
|
989
|
+
/**
|
|
990
|
+
* 把一条连接器自造的项广播给**每台在线设备**(活动名单的 `link/jobs`,spec 018)。
|
|
991
|
+
*
|
|
992
|
+
* 与 `forwardEvent` 的区别是来源:那条项来自某台设备自己的 `$events` 流,只回给它;
|
|
993
|
+
* 这条是宿主任务事件的产物,没有归属设备,所以每台在线设备各收一份。
|
|
994
|
+
*
|
|
995
|
+
* **离线设备不推**:`$events` 还替它留着(见 `detachDevice`),但旧任务状态没有补发
|
|
996
|
+
* 意义——重连后手机重新问一次 `_link/jobRoster` 即可(契约 §3)。
|
|
997
|
+
*/
|
|
998
|
+
publishEvent(value) {
|
|
999
|
+
for (const record of this.devices.values()) {
|
|
1000
|
+
if (record.offlineSince !== undefined) continue
|
|
1001
|
+
this.#sendEventItem(record.deviceId, record, value)
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
840
1005
|
/**
|
|
841
1006
|
* 要不要为这条 `$events` 项报一声(就是「提醒手机」)。
|
|
842
1007
|
*
|