dsh-mobile-plugin 0.1.92 → 0.1.94
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 +3 -1
- package/config/agent-presets/standard/agent.cordis.yml +7 -4
- package/lib/desktop-view.cjs +20 -0
- package/lib/impl.js +7 -2
- package/lib/mirror/pusher.cjs +40 -0
- package/lib/mobile/archive-clean.js +17 -18
- package/lib/mobile/attachments.js +23 -6
- package/lib/mobile/events.js +28 -3
- package/lib/mobile/history-derive.js +53 -7
- package/lib/mobile/host-bridge/endpoints.js +0 -1
- package/lib/mobile/host-bridge/event-synthesis.js +33 -3
- package/lib/mobile/host-bridge/shims.js +28 -4
- package/lib/mobile/monitor.js +135 -0
- package/lib/mobile/session-flags.js +63 -0
- package/lib/mobile/session-models.js +6 -0
- package/lib/mobile/session-preview.js +70 -0
- package/lib/mobile/session-send.js +8 -2
- package/lib/mobile/session-snapshot.js +13 -19
- package/lib/mobile/sessions.js +48 -91
- package/lib/mobile/stream.js +113 -16
- package/lib/mobile/uploads.js +14 -7
- package/lib/mobile/workspaces.js +17 -3
- package/lib/panel/index.html +7 -7
- package/lib/reconnect-scheduler.cjs +31 -4
- package/lib/relay/lan-network.cjs +156 -12
- package/lib/relay/lan-proxy.js +85 -7
- package/lib/relay/protocol.js +3 -3
- package/lib/relay/tunnel.js +17 -4
- package/lib/routes/control-plane.js +11 -5
- package/lib/routes/mobile-api.js +22 -1
- package/lib/session-cleanup.cjs +160 -27
- package/lib/shared/desktop-view-signal.js +66 -0
- package/lib/shared/http-util.js +50 -4
- package/lib/shared/list-revision.js +20 -1
- package/lib/shared/sse-write.js +52 -2
- package/lib/sidecar-facade-worker.cjs +16 -2
- package/lib/update-install.cjs +71 -13
- package/package.json +2 -2
package/lib/session-cleanup.cjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 会话数据清理:按 sessionId 删除 dshHome 下的 sessions/** 目录与 project cache。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 两个入口共用同一套落地代码:DSH agent 工具 registerSessionCleanupTool(action=delete
|
|
5
5
|
* 必须 confirm:true 且只针对已归档)与移动 API /workspace/clear-archived(archive-clean.js)。
|
|
6
6
|
*
|
|
7
|
+
* **两个入口都不在进程里删磁盘**:只把 id 排进待删队列(dshHome 下的
|
|
8
|
+
* mobile-archived-clear.json),真正的删除放在宿主下次启动时做,见
|
|
9
|
+
* deleteQueuedArchivedSessions。宿主没有「删除会话」接口,进程活着的时候它手上那些会话
|
|
10
|
+
* 随时可能被读或写,文件不在就是一次未捕获的 ENOENT —— 宿主对未捕获异常是零容忍的,
|
|
11
|
+
* 整个进程退出,桌面端跟着消失(2026-10-20 就是这么闪退的)。
|
|
12
|
+
*
|
|
7
13
|
* 认哪份文件、怎么读 id 都以宿主自己的规则为准,不按目录名猜:
|
|
8
14
|
* - 文件名 = `session[.vN].jsonl[.zstd]`(宿主 `dsh-session-format` 的
|
|
9
15
|
* CANONICAL_LOG_FILENAME + compressionSuffix:0 代不带 vN,压缩时才带 .zstd)。
|
|
@@ -12,10 +18,10 @@
|
|
|
12
18
|
* - 解压用 Node 自带的 zstd(DSH 自己就是这么读写日志的,宿主并不需要系统 zstd
|
|
13
19
|
* 命令),不再 shell 出去:没装 zstd 命令的机器上它一缺就是 readHeader 静默返回
|
|
14
20
|
* undefined,整条删除路径空转。id 仍然取自日志第一行那条 header。
|
|
15
|
-
* -
|
|
16
|
-
* (pruneOrphanedArchivedSessionsOnce
|
|
17
|
-
*
|
|
18
|
-
*
|
|
21
|
+
* - **进程活着的时候不删日志、也不擦宿主的归档集合**;两件事都放到宿主启动时
|
|
22
|
+
* (pruneOrphanedArchivedSessionsOnce)。擦集合的理由写在那个函数上:桌面端的会话
|
|
23
|
+
* 列表是连上宿主时拉的一份快照 + 之后的事件增量,进程里擦集合等于把已经删掉、却还
|
|
24
|
+
* 留在快照里的行放回正常列表。删日志的理由见上面那段:进程里删 = 宿主随时可能踩空。
|
|
19
25
|
* - **Windows 上目录删不掉 ≠ 会话没删掉**:宿主对它打开过的会话一直握着 `session.lock`
|
|
20
26
|
* 的写句柄,日志 unlink 成功后那个名字要等句柄关闭才消失(delete-pending),rmdir
|
|
21
27
|
* 于是报 ENOTEMPTY。判据改成「目录里还有没有规范名日志」:没有就是删成了。
|
|
@@ -39,6 +45,7 @@ const CANONICAL_LOG_FILENAME = /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/
|
|
|
39
45
|
const HEADER_MAX_BYTES = 64 * 1024
|
|
40
46
|
|
|
41
47
|
const { envelopeValue, unwrap } = require('./mobile/envelope.js')
|
|
48
|
+
const { rememberDeletedSessionIds } = require('./mobile/session-cache.js')
|
|
42
49
|
|
|
43
50
|
function asItems(response) {
|
|
44
51
|
const value = envelopeValue(response)
|
|
@@ -218,6 +225,104 @@ function sessionIdsWithoutAnyData(sessionIds) {
|
|
|
218
225
|
return [...candidates, ...withoutData]
|
|
219
226
|
}
|
|
220
227
|
|
|
228
|
+
/**
|
|
229
|
+
* 「清理所有已归档」的待删队列:dshHome 下的一份小 JSON(`mobile-archived-clear.json`)。
|
|
230
|
+
*
|
|
231
|
+
* 用户点清理的那一刻**不动磁盘**,只把 id 记进这里;真正的删除在宿主下次启动时做,见
|
|
232
|
+
* deleteQueuedArchivedSessions。原因是宿主没有「删除会话」这个接口:进程活着的时候,
|
|
233
|
+
* 它内存里那些会话随时可能被读或写(列一遍会话、续写一条事件、开一条还没落盘的空白会话),
|
|
234
|
+
* 文件不在就是一次没人接的 ENOENT —— 宿主对未捕获异常零容忍,进程直接退出,桌面端窗口
|
|
235
|
+
* 跟着消失(2026-10-20 真机就是这么闪退的:清理删到一条 0 轮次的空白归档会话)。
|
|
236
|
+
*
|
|
237
|
+
* 队列是**并集**语义:同一批 id 点两次、两次清理之间换了批次,都只是往同一张表里加。
|
|
238
|
+
*/
|
|
239
|
+
const PENDING_ARCHIVED_CLEAR_FILE = 'mobile-archived-clear.json'
|
|
240
|
+
|
|
241
|
+
function pendingArchivedClearFile() {
|
|
242
|
+
return path.join(dshHome(), PENDING_ARCHIVED_CLEAR_FILE)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 队列里的 id。文件不在 / 读坏了都当空:下一次点清理会把该排的重新排一遍。 */
|
|
246
|
+
function readPendingArchivedClearIds() {
|
|
247
|
+
let text
|
|
248
|
+
try { text = fs.readFileSync(pendingArchivedClearFile(), 'utf8') } catch { return [] }
|
|
249
|
+
try {
|
|
250
|
+
const parsed = JSON.parse(text)
|
|
251
|
+
const list = parsed && Array.isArray(parsed.sessionIds) ? parsed.sessionIds : []
|
|
252
|
+
return [...new Set(list.filter((id) => typeof id === 'string' && id))]
|
|
253
|
+
} catch {
|
|
254
|
+
return []
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 整份覆写队列;空队列就删文件。
|
|
260
|
+
*
|
|
261
|
+
* 写失败**向上抛**:排不上队就不能回「已清理」——那等于告诉用户删掉了,重启之后它们
|
|
262
|
+
* 原样回来。调用方(清理路由 / agent 工具)自带失败通道,不需要在这里兜。
|
|
263
|
+
*/
|
|
264
|
+
function writePendingArchivedClearIds(sessionIds) {
|
|
265
|
+
const file = pendingArchivedClearFile()
|
|
266
|
+
const unique = [...new Set((Array.isArray(sessionIds) ? sessionIds : []).filter((id) => typeof id === 'string' && id))]
|
|
267
|
+
if (unique.length === 0) {
|
|
268
|
+
fs.rmSync(file, { force: true })
|
|
269
|
+
return []
|
|
270
|
+
}
|
|
271
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
272
|
+
// 先写临时文件再改名:宿主随时可能被杀,半截 JSON 会让下一次启动整队读不出来。
|
|
273
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
274
|
+
fs.writeFileSync(temp, `${JSON.stringify({ sessionIds: unique }, null, 2)}\n`)
|
|
275
|
+
fs.renameSync(temp, file)
|
|
276
|
+
return unique
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 把一批 id 排进队列(并集、幂等),返回排完之后的整队。 */
|
|
280
|
+
function queueArchivedClearIds(sessionIds) {
|
|
281
|
+
const incoming = Array.isArray(sessionIds) ? sessionIds : []
|
|
282
|
+
return writePendingArchivedClearIds([...readPendingArchivedClearIds(), ...incoming])
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 宿主刚启动时把队里排着的日志真正删掉,删成的从队列里摘掉。
|
|
287
|
+
*
|
|
288
|
+
* 只在这个时刻删:桌面端还没连上(或正要重连,快照本来就从磁盘重新拉),宿主手上也没有
|
|
289
|
+
* 任何热会话,不存在「正读着 / 正写着被删掉的那条」。某一条删不掉(日志还在盘上)不算整轮
|
|
290
|
+
* 失败 —— 它的 id 留在队里等下次启动,其余照删。deleteSessionData 中途抛错时已经删掉的
|
|
291
|
+
* 那批靠 sessionIdsWithoutAnyData 补账(返回值随异常丢了)。
|
|
292
|
+
*
|
|
293
|
+
* @returns {Promise<{deleted: string[], kept: string[]}>}
|
|
294
|
+
*/
|
|
295
|
+
async function deleteQueuedArchivedSessions(log, ctx) {
|
|
296
|
+
const queued = readPendingArchivedClearIds()
|
|
297
|
+
if (queued.length === 0) return { deleted: [], kept: [] }
|
|
298
|
+
let removed = []
|
|
299
|
+
try {
|
|
300
|
+
removed = await deleteSessionData(queued)
|
|
301
|
+
} catch (error) {
|
|
302
|
+
log?.(`archived clear queue: ${error && error.message ? error.message : String(error)}`)
|
|
303
|
+
}
|
|
304
|
+
const done = new Set([...removed, ...sessionIdsWithoutAnyData(queued)])
|
|
305
|
+
const deleted = queued.filter((id) => done.has(id))
|
|
306
|
+
const kept = queued.filter((id) => !done.has(id))
|
|
307
|
+
writePendingArchivedClearIds(kept)
|
|
308
|
+
// 出队之后还得在本进程继续压住:宿主那份会话清单可能是清理之前建好的,行还在里面。
|
|
309
|
+
// 没有这一步,手机上会冒出一条点进去什么都没有的归档行(修剪成功时它甚至会被当成
|
|
310
|
+
// 未归档的普通行露出来)。
|
|
311
|
+
if (ctx && deleted.length > 0) rememberDeletedSessionIds(ctx, deleted)
|
|
312
|
+
return { deleted, kept }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 队列里的 id 在手机上必须继续看不到:插件的删除集是**进程内**的,宿主重启、代际热替换
|
|
317
|
+
* 都会丢,而队列里的会话在宿主那边还活着(归档列表照样列得出它们)。每次装载都从队列
|
|
318
|
+
* 里补一遍,两次启动之间也不会复活。
|
|
319
|
+
*/
|
|
320
|
+
function restoreQueuedArchivedClearIds(ctx) {
|
|
321
|
+
const queued = readPendingArchivedClearIds()
|
|
322
|
+
if (queued.length > 0) rememberDeletedSessionIds(ctx, queued)
|
|
323
|
+
return queued
|
|
324
|
+
}
|
|
325
|
+
|
|
221
326
|
async function pruneOrphanedArchivedSessions(ctx) {
|
|
222
327
|
const workspaceList = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.list : undefined
|
|
223
328
|
if (typeof workspaceList !== 'function') return 0
|
|
@@ -234,27 +339,47 @@ async function pruneOrphanedArchivedSessions(ctx) {
|
|
|
234
339
|
}
|
|
235
340
|
|
|
236
341
|
/**
|
|
237
|
-
*
|
|
342
|
+
* 宿主进程启动时做一次收尾,然后整个进程不再碰:先删队里排着的日志,再修剪归档集合。
|
|
238
343
|
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
344
|
+
* 这两件事都只能在启动这一刻做:
|
|
345
|
+
* - **删日志**:宿主没有「删除会话」接口,进程活着时它随时可能去开那些文件;文件不在
|
|
346
|
+
* 就是一次未捕获的 ENOENT,宿主直接退出(2026-10-20 桌面端闪退就是这条)。
|
|
347
|
+
* - **擦归档集合**:桌面端的会话列表是「连上宿主时拉的一份快照 + 之后的事件增量」,
|
|
348
|
+
* 进程活着的时候擦集合,宿主会把「归档集合变了」广播给桌面端,桌面端就把它快照里
|
|
349
|
+
* 那些其实已经没有日志、本来被归档藏着的行放回正常列表 —— 用户报的正是「清理后
|
|
350
|
+
* 它们又回来了,重启 DSH 才真的消失」。启动时快照本来就从磁盘重新拉,擦掉不会放出
|
|
351
|
+
* 任何行。留在集合里的死 id 不显示在任何地方:「已归档」取的是会话列表与归档集合
|
|
352
|
+
* 的交集,没有日志就没有那一行。
|
|
246
353
|
*
|
|
247
354
|
* 用进程级标记而不是模块变量:代际热替换会重跑 apply,而桌面端那条快照挂在同一个进程上。
|
|
248
355
|
*/
|
|
249
356
|
const PRUNE_ONCE = Symbol.for('dsh-mobile-plugin.archived-prune')
|
|
357
|
+
const PRUNE_ATTEMPTS = 3
|
|
358
|
+
const PRUNE_RETRY_MS = 500
|
|
250
359
|
async function pruneOrphanedArchivedSessionsOnce(ctx, log) {
|
|
251
360
|
if (globalThis[PRUNE_ONCE]) return
|
|
252
361
|
globalThis[PRUNE_ONCE] = true
|
|
362
|
+
// 删日志不碰宿主,先做:越早越好,别等宿主把 workspace 服务挂齐(那时桌面端可能已经连上)。
|
|
253
363
|
try {
|
|
254
|
-
const
|
|
255
|
-
if (
|
|
364
|
+
const { deleted, kept } = await deleteQueuedArchivedSessions(log, ctx)
|
|
365
|
+
if (deleted.length > 0) log?.(`archived clear queue: deleted ${deleted.length} session logs`)
|
|
366
|
+
if (kept.length > 0) log?.(`archived clear queue: ${kept.length} session logs kept for the next start`)
|
|
256
367
|
} catch (error) {
|
|
257
|
-
log?.(`archived
|
|
368
|
+
log?.(`archived clear queue failed: ${error && error.message ? error.message : String(error)}`)
|
|
369
|
+
}
|
|
370
|
+
// 修剪要等宿主的 workspace 服务就绪:0.2.x 的 workspace.list 就是 workspace.follow 的
|
|
371
|
+
// 首帧,刚启动那一下常常还没开出来。以前一次失败就把整轮收尾丢掉,结果是归档集合里
|
|
372
|
+
// 的死 id 一直没人清(实测宿主重启过一次,315 个没有日志的 id 原样挂着)。
|
|
373
|
+
for (let attempt = 1; attempt <= PRUNE_ATTEMPTS; attempt += 1) {
|
|
374
|
+
try {
|
|
375
|
+
const pruned = await pruneOrphanedArchivedSessions(ctx)
|
|
376
|
+
if (pruned > 0) log?.(`archived session prune: dropped ${pruned} deleted session ids`)
|
|
377
|
+
return
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const message = error && error.message ? error.message : String(error)
|
|
380
|
+
if (attempt === PRUNE_ATTEMPTS) log?.(`archived session prune failed: ${message}`)
|
|
381
|
+
else await new Promise((resolve) => setTimeout(resolve, PRUNE_RETRY_MS * attempt))
|
|
382
|
+
}
|
|
258
383
|
}
|
|
259
384
|
}
|
|
260
385
|
|
|
@@ -292,19 +417,20 @@ async function cleanupSessions(ctx, args) {
|
|
|
292
417
|
}
|
|
293
418
|
if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
|
|
294
419
|
const ids = selected.map(item => item.sessionId)
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
420
|
+
// 与移动端那条路由同一口径:进程里只排队,**日志留到宿主下次启动时删**。进程活着时
|
|
421
|
+
// 删日志,宿主随时可能去开一条已经不存在的文件,那是未捕获异常(会带走整个宿主)。
|
|
422
|
+
queueArchivedClearIds(ids)
|
|
423
|
+
rememberDeletedSessionIds(ctx, ids)
|
|
424
|
+
return { action: args.action, cwd, selected: ids.length, archived: 0, deleted: ids.length, failed: 0, sessionIds: ids }
|
|
299
425
|
}
|
|
300
426
|
|
|
301
427
|
function registerSessionCleanupTool(ctx) {
|
|
302
428
|
const { defineTool } = require('@deepseek-ai/dsh-tools')
|
|
303
429
|
ctx.tools.register(defineTool({
|
|
304
430
|
name: 'session_cleanup',
|
|
305
|
-
description: 'Archive or permanently delete old DSH sessions. Delete only targets already archived sessions
|
|
431
|
+
description: 'Archive or permanently delete old DSH sessions. Delete only targets already archived sessions, requires confirm=true, and drops their logs when the host next starts.',
|
|
306
432
|
parameters: {
|
|
307
|
-
action: { type: 'string', required: true, enum: ['archive', 'delete'], description: 'archive hides sessions; delete
|
|
433
|
+
action: { type: 'string', required: true, enum: ['archive', 'delete'], description: 'archive hides sessions; delete schedules permanent removal of their logs and local projection cache at the next host start.' },
|
|
308
434
|
cwd: { type: 'string', description: 'Project directory to target. Required when selecting by age across multiple projects.' },
|
|
309
435
|
olderThanMinutes: { type: 'number', description: 'Only target sessions not updated within this many minutes. Defaults to 30.' },
|
|
310
436
|
minProjectSessions: { type: 'integer', description: 'Skip the operation when the selected project has fewer sessions than this number.' },
|
|
@@ -315,24 +441,31 @@ function registerSessionCleanupTool(ctx) {
|
|
|
315
441
|
schema: { type: 'object', additionalProperties: false, properties: {
|
|
316
442
|
action: { type: 'string' }, cwd: { type: 'string' }, selected: { type: 'integer' }, archived: { type: 'integer' }, deleted: { type: 'integer' }, failed: { type: 'integer' }, sessionIds: { type: 'array', items: { type: 'string' } }, failures: { type: 'array', items: { type: 'object' } },
|
|
317
443
|
} },
|
|
318
|
-
render: (_args, value) => [{ type: 'text', text:
|
|
444
|
+
render: (_args, value) => [{ type: 'text', text: value.action === 'archive' ? `Archived ${value.archived} sessions.` : `Scheduled ${value.deleted} sessions for removal; their logs are deleted the next time the host starts.` }],
|
|
319
445
|
},
|
|
320
446
|
execute: (args) => cleanupSessions(ctx, args),
|
|
321
447
|
}))
|
|
322
448
|
}
|
|
323
449
|
|
|
324
450
|
/**
|
|
325
|
-
* 装配层的入口:注册 agent
|
|
451
|
+
* 装配层的入口:注册 agent 的清理工具,同时在「宿主刚起来」时删掉待删队列里的日志、
|
|
452
|
+
* 修剪一遍归档集合。
|
|
326
453
|
*
|
|
327
|
-
*
|
|
454
|
+
* 三件事都是会话清理的一部分,所以共用装配层那一处 effect;impl.js 有行数兜底守卫,
|
|
328
455
|
* 别把实现搬回去。
|
|
329
456
|
*/
|
|
330
457
|
function installSessionCleanup(ctx) {
|
|
458
|
+
// 队列里的 id 每次装载都要补回删除集:宿主重启、代际热替换都会丢掉进程内那份,
|
|
459
|
+
// 而它们在宿主那边还活着(归档列表照样列得出),漏一次手机上就「又回来了」。
|
|
460
|
+
try {
|
|
461
|
+
restoreQueuedArchivedClearIds(ctx)
|
|
462
|
+
} catch { /* 读不出来就当没有:用户再点一次清理会重新排队 */ }
|
|
331
463
|
// 「宿主启动时」不能只看进程级标记:升级到这份实现那次是**热替换**进来的(进程早就在跑),
|
|
332
464
|
// 标记还没被谁设过。那一刻桌面端的会话列表快照里可能正挂着之前删掉的行,擦集合就会把它们
|
|
333
465
|
// 放回列表。所以只在进程刚起的这点窗口里修剪;晚于它就留给下次启动。
|
|
334
466
|
if (process.uptime() <= BOOT_PRUNE_WINDOW_SECONDS) {
|
|
335
|
-
//
|
|
467
|
+
// 收尾先挂:没有 tools 注入的 profile 注册不了工具(外层会吞掉这条错),但队列里的日志
|
|
468
|
+
// 与归档集合照样要收尾。
|
|
336
469
|
void pruneOrphanedArchivedSessionsOnce(ctx, (message) => {
|
|
337
470
|
try { ctx.logger?.info?.(message) } catch { /* 日志失败不影响修剪 */ }
|
|
338
471
|
})
|
|
@@ -340,4 +473,4 @@ function installSessionCleanup(ctx) {
|
|
|
340
473
|
registerSessionCleanupTool(ctx)
|
|
341
474
|
}
|
|
342
475
|
|
|
343
|
-
module.exports = { cleanupSessions, registerSessionCleanupTool, installSessionCleanup, deleteSessionData, sessionIdsWithoutAnyData, forgetArchivedSessions, pruneOrphanedArchivedSessions, pruneOrphanedArchivedSessionsOnce }
|
|
476
|
+
module.exports = { cleanupSessions, registerSessionCleanupTool, installSessionCleanup, deleteSessionData, sessionIdsWithoutAnyData, forgetArchivedSessions, pruneOrphanedArchivedSessions, pruneOrphanedArchivedSessionsOnce, deleteQueuedArchivedSessions, queueArchivedClearIds, readPendingArchivedClearIds, restoreQueuedArchivedClearIds }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「桌面侧栏折叠态变了」的信号总线。
|
|
5
|
+
*
|
|
6
|
+
* 桌面端那份 groupExpansion 只活在浏览器 localStorage 里,插件靠客户端半边轮询上报
|
|
7
|
+
* (lib/client.js 每 2 秒读一次,变了才 POST /api/relay/desktop-view),落到
|
|
8
|
+
* lib/desktop-view.cjs。手机要看这份镜像只有一条路:GET /api/mobile/v1/desktop-view。
|
|
9
|
+
*
|
|
10
|
+
* 过去 App 按一个 5 分钟最短间隔去问它 —— 因为没有任何「它变了」的信号可用:列表
|
|
11
|
+
* revision 只哈希会话与工作区,桌面改折叠不动它。那套是「猜一个足够长的周期」,
|
|
12
|
+
* 稳态下一天白问几百次。本模块给出真正的出口:上报落盘且**内容真变了**时广播一次,
|
|
13
|
+
* 设备级流立刻推给手机,App 收到才拉。
|
|
14
|
+
*
|
|
15
|
+
* 与 list-signal 同款模块级单例:生产端(desktop-view.cjs)与消费端
|
|
16
|
+
* (mobile/events.js)不需要互相认识;测试用 createDesktopViewSignal() 造独立实例。
|
|
17
|
+
*
|
|
18
|
+
* 内容没变不广播:客户端半边会重复 POST 同一份(页面重载、切窗口都会重来一次),
|
|
19
|
+
* 每次都给每台手机推一帧,等于把「5 分钟一问」换成「一上报一帧」。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const NOOP_RELEASE = () => {};
|
|
23
|
+
|
|
24
|
+
function createDesktopViewSignal() {
|
|
25
|
+
const listeners = new Set();
|
|
26
|
+
let updatedAt = 0;
|
|
27
|
+
let at = 0;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
/**
|
|
31
|
+
* 广播一次折叠态变更。同一个 updatedAt 连着广播两次只算一次(幂等)。
|
|
32
|
+
*
|
|
33
|
+
* @param {number} nextUpdatedAt 快照的 receivedAt(desktop-view.cjs 落盘时的时间)
|
|
34
|
+
* @returns {boolean} 是否真的广播了
|
|
35
|
+
*/
|
|
36
|
+
bump(nextUpdatedAt) {
|
|
37
|
+
const value = Number(nextUpdatedAt) || 0;
|
|
38
|
+
if (value && value === updatedAt) return false;
|
|
39
|
+
updatedAt = value;
|
|
40
|
+
at = Date.now();
|
|
41
|
+
for (const listener of [...listeners]) {
|
|
42
|
+
try { listener({ updatedAt, at }); } catch { /* 订阅者自己的异常不该影响上报链路 */ }
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/** 订阅变更;返回退订函数(可重复调用)。 */
|
|
48
|
+
onBump(listener) {
|
|
49
|
+
if (typeof listener !== 'function') return NOOP_RELEASE;
|
|
50
|
+
listeners.add(listener);
|
|
51
|
+
let released = false;
|
|
52
|
+
return () => {
|
|
53
|
+
if (released) return;
|
|
54
|
+
released = true;
|
|
55
|
+
listeners.delete(listener);
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/** 最近一次变更(供断流重连后的补发与诊断)。 */
|
|
60
|
+
snapshot() { return { updatedAt, at }; },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const desktopViewSignal = createDesktopViewSignal();
|
|
65
|
+
|
|
66
|
+
module.exports = { createDesktopViewSignal, desktopViewSignal };
|
package/lib/shared/http-util.js
CHANGED
|
@@ -1,21 +1,66 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 插件侧 HTTP 小工具共享实现(sendJson / 读取 body
|
|
4
|
+
* 插件侧 HTTP 小工具共享实现(sendJson / 读取 body / JSON 压缩)。
|
|
5
5
|
*
|
|
6
6
|
* 中转服务器(dsh-mobile-server)独立部署,保留自身实现,仅作参考。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
const zlib = require('node:zlib');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 小于这个字节数不压:gzip 的头与 CPU 换不回那点字节,小响应明文更划算。
|
|
13
|
+
*
|
|
14
|
+
* 手机侧的列表 / 工作区 / 消息 / 统计都是几十到几百 KB 的 JSON,压缩后一般掉到原来的
|
|
15
|
+
* 一到二成;而这些请求各条腿(宿主→中转隧道、中转→手机)都是按字节算的。
|
|
16
|
+
*/
|
|
17
|
+
const JSON_GZIP_MIN_BYTES = 1024;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 对方是否声明支持 gzip。
|
|
21
|
+
*
|
|
22
|
+
* 安卓的 OkHttp 与 iOS 的 NSURLSession 自己就会带上这个头、收到后自己解压,所以插件
|
|
23
|
+
* 这边只要如实标 `content-encoding: gzip`,App 一行代码都不用改。没声明的绝不压 ——
|
|
24
|
+
* 赌客户端会解压,赌输就是整页 JSON 变成乱码。
|
|
25
|
+
*/
|
|
26
|
+
function wantsGzip(acceptEncoding) {
|
|
27
|
+
return typeof acceptEncoding === 'string' && /\bgzip\b/i.test(acceptEncoding);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 把一段 JSON 文本按需压成 gzip,返回要写出去的字节与「压没压」。
|
|
32
|
+
*
|
|
33
|
+
* @param {string|Buffer} text
|
|
34
|
+
* @param {string|undefined} acceptEncoding 请求里的 accept-encoding 原值
|
|
35
|
+
* @returns {{ body: Buffer, gzip: boolean }}
|
|
36
|
+
*/
|
|
37
|
+
function encodeJsonBody(text, acceptEncoding) {
|
|
38
|
+
const raw = Buffer.isBuffer(text) ? text : Buffer.from(text == null ? '' : String(text));
|
|
39
|
+
if (raw.length >= JSON_GZIP_MIN_BYTES && wantsGzip(acceptEncoding)) {
|
|
40
|
+
try { return { body: zlib.gzipSync(raw), gzip: true }; } catch { /* 压不了就发明文,别让一次压缩失败把响应吞掉 */ }
|
|
41
|
+
}
|
|
42
|
+
return { body: raw, gzip: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
9
45
|
/**
|
|
10
46
|
* 写 JSON 响应(UTF-8)。连接已关闭时静默忽略。
|
|
47
|
+
*
|
|
48
|
+
* 大响应在客户端声明 gzip 时压缩。`vary` 无论压没压都带上:中转前面通常还有一层 CDN,
|
|
49
|
+
* 缓存按它区分表示形态,缺了它会把压缩版回给不认 gzip 的客户端。
|
|
50
|
+
*
|
|
11
51
|
* @param {object} res node:http ServerResponse
|
|
12
52
|
* @param {number} status HTTP 状态码
|
|
13
53
|
* @param {object} body 响应体(JSON.stringify)
|
|
14
54
|
*/
|
|
15
55
|
function sendJson(res, status, body) {
|
|
16
56
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
57
|
+
const text = JSON.stringify(body);
|
|
58
|
+
const headers = { 'content-type': 'application/json; charset=utf-8', vary: 'accept-encoding' };
|
|
59
|
+
const acceptEncoding = res && res.req && res.req.headers ? res.req.headers['accept-encoding'] : undefined;
|
|
60
|
+
const encoded = encodeJsonBody(text == null ? '' : text, acceptEncoding);
|
|
61
|
+
if (encoded.gzip) headers['content-encoding'] = 'gzip';
|
|
62
|
+
res.writeHead(status, headers);
|
|
63
|
+
res.end(encoded.body);
|
|
19
64
|
} catch { /* 连接已关闭 */ }
|
|
20
65
|
}
|
|
21
66
|
|
|
@@ -42,6 +87,7 @@ function sendHtml(res, status, html) {
|
|
|
42
87
|
|
|
43
88
|
/**
|
|
44
89
|
* 读取请求体并 JSON.parse;超限/非法 JSON/IO 错误 → null。
|
|
90
|
+
*
|
|
45
91
|
* @param {object} req node:http IncomingMessage
|
|
46
92
|
* @param {number} [maxBytes] 请求体上限(字节),缺省 256KB
|
|
47
93
|
* @returns {Promise<object|null>}
|
|
@@ -68,4 +114,4 @@ function readJsonBody(req, maxBytes = 256 * 1024) {
|
|
|
68
114
|
});
|
|
69
115
|
}
|
|
70
116
|
|
|
71
|
-
module.exports = { sendJson, sendHtml, readJsonBody };
|
|
117
|
+
module.exports = { sendJson, sendHtml, readJsonBody, encodeJsonBody, wantsGzip, JSON_GZIP_MIN_BYTES };
|
|
@@ -48,4 +48,23 @@ function listRevision(items) {
|
|
|
48
48
|
return stableRevision(Array.isArray(items) ? items : []);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* 条件刷新的极简响应(sessions / workspaces 共用)。
|
|
53
|
+
*
|
|
54
|
+
* 客户端带上上次拿到的 `revision` 来问「还是那一份吗」:**恰好相等**时只回一句
|
|
55
|
+
* `{ok, unchanged:true, revision, generatedAt}`,不带 items —— 几十字节换几十 KB。
|
|
56
|
+
*
|
|
57
|
+
* 调用方负责决定「这次请求该不该走这条」(`?fresh=1` 的写后刷新、归档子集、子代理等
|
|
58
|
+
* 一律不走);这里只管形状与相等判定。不相等返回 null,调用方回完整 body。
|
|
59
|
+
*
|
|
60
|
+
* @param {object} body 最终要回的那份完整清单体
|
|
61
|
+
* @param {string} rev 客户端带来的 revision
|
|
62
|
+
*/
|
|
63
|
+
function unchangedListBody(body, rev) {
|
|
64
|
+
if (!body || body.ok !== true) return null;
|
|
65
|
+
if (typeof rev !== 'string' || rev === '') return null;
|
|
66
|
+
if (typeof body.revision !== 'string' || body.revision !== rev) return null;
|
|
67
|
+
return { ok: true, unchanged: true, revision: body.revision, generatedAt: body.generatedAt };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { stableRevision, listRevision, unchangedListBody };
|
package/lib/shared/sse-write.js
CHANGED
|
@@ -89,6 +89,48 @@ function stableEventId(payload, frame, namespace = 'session') {
|
|
|
89
89
|
return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* 每条连接上「距离上次补齐注释」的状态:上次补齐的时刻、之后写出去的字节数。
|
|
94
|
+
*
|
|
95
|
+
* 键是响应对象,连接收掉后自然被回收,不需要显式清理。
|
|
96
|
+
*/
|
|
97
|
+
const PAD_STATES = new WeakMap();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 决定这一条记录要不要补注释、补多少。
|
|
101
|
+
*
|
|
102
|
+
* 补注释的**唯一理由**是让安卓 RN 的 XHR 攒够字节尽快把进度事件吐给界面;补多少与补
|
|
103
|
+
* 多勤都不影响这条理由,只影响代价 —— 一条记录补整份 2048 字节时,一次几十秒的流式
|
|
104
|
+
* 回复(每秒几十条增量)光注释就要几 MB。所以改成按窗口补齐:
|
|
105
|
+
* - 距离上次补齐不到 `padEveryMs` 且累计字节还没越过 `padBytes` → 先不补,攒着;
|
|
106
|
+
* - 到了窗口边界或单条记录自己就够大 → 补到「自上次补齐以来累计 ≥ padBytes」为止。
|
|
107
|
+
* 客户端看到的仍然是「每 ≥padBytes 就有一条补过注释的记录」,界面的出字节奏不变。
|
|
108
|
+
*/
|
|
109
|
+
function takePadding(res, written, padBytes, padEveryMs) {
|
|
110
|
+
let state = PAD_STATES.get(res);
|
|
111
|
+
if (!state) {
|
|
112
|
+
state = { at: 0, bytes: 0 };
|
|
113
|
+
PAD_STATES.set(res, state);
|
|
114
|
+
}
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const pending = state.bytes + written;
|
|
117
|
+
const due = padEveryMs > 0 ? now - state.at >= padEveryMs : true;
|
|
118
|
+
if (pending >= padBytes) {
|
|
119
|
+
// 这一发自己就越过了阈值,注释是纯浪费。
|
|
120
|
+
state.bytes = 0;
|
|
121
|
+
state.at = now;
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
if (!due) {
|
|
125
|
+
state.bytes = pending;
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
const padding = ssePadding(padBytes - pending);
|
|
129
|
+
state.bytes = 0;
|
|
130
|
+
state.at = now;
|
|
131
|
+
return padding;
|
|
132
|
+
}
|
|
133
|
+
|
|
92
134
|
/**
|
|
93
135
|
* 写一条 SSE 记录。
|
|
94
136
|
*
|
|
@@ -97,7 +139,9 @@ function stableEventId(payload, frame, namespace = 'session') {
|
|
|
97
139
|
* @param {object} [options]
|
|
98
140
|
* @param {object} [options.frame] 宿主 mux 帧,只用于算稳定 id
|
|
99
141
|
* @param {string} [options.namespace='session'] 兜底 id 的前缀
|
|
100
|
-
* @param {string} [options.padding='']
|
|
142
|
+
* @param {string} [options.padding=''] 固定的 SSE 注释行(调用方自己算好时用,见 ssePadding)
|
|
143
|
+
* @param {number} [options.paddingBytes=0] 让这条流维持的最小出字节数:>0 时按窗口补齐(见 takePadding)
|
|
144
|
+
* @param {number} [options.padEveryMs=0] 补齐窗口;0 表示每条记录都补到 paddingBytes
|
|
101
145
|
* @param {number} [options.retryMs=SSE_RETRY_MS] SSE 重连间隔
|
|
102
146
|
* @param {(payload: object) => boolean} [options.shedWhenBuffered] 超过软水位时这一帧能不能丢
|
|
103
147
|
* @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
|
|
@@ -110,6 +154,8 @@ function writeSse(res, payload, options = {}) {
|
|
|
110
154
|
frame = null,
|
|
111
155
|
namespace = 'session',
|
|
112
156
|
padding = '',
|
|
157
|
+
paddingBytes = 0,
|
|
158
|
+
padEveryMs = 0,
|
|
113
159
|
retryMs = SSE_RETRY_MS,
|
|
114
160
|
shedWhenBuffered = shedWithoutSeq,
|
|
115
161
|
} = options;
|
|
@@ -120,7 +166,11 @@ function writeSse(res, payload, options = {}) {
|
|
|
120
166
|
const fields = [`retry: ${retryMs}`];
|
|
121
167
|
if (id) fields.push(`id: ${id}`);
|
|
122
168
|
fields.push(`data: ${JSON.stringify(payload)}`);
|
|
123
|
-
|
|
169
|
+
const extra = padding || (paddingBytes > 0
|
|
170
|
+
// 按字节算:正文是中文时字符数只有字节数三分之一,按字符算会低估、补多。
|
|
171
|
+
? takePadding(res, Buffer.byteLength(`${fields.join('\n')}\n`, 'utf8'), Math.floor(paddingBytes), Math.floor(padEveryMs))
|
|
172
|
+
: '');
|
|
173
|
+
if (extra) fields.push(extra);
|
|
124
174
|
fields.push('', '');
|
|
125
175
|
try {
|
|
126
176
|
// `write()` 返回 false 才是背压的正信号:数据已经堆到高水位,继续写只会把内存
|
|
@@ -68,8 +68,18 @@ const NETWORK_POLL_MS = 5000
|
|
|
68
68
|
// 隧道重连间隔。onClose 与 start().catch 两条失败路径共用这个幂等调度器,
|
|
69
69
|
// 否则每次失败会排两个定时器(重连风暴);幂等性由
|
|
70
70
|
// test/reconnect-scheduler.test.cjs 精确验证。
|
|
71
|
+
//
|
|
72
|
+
// 抖动 2s:中转重启后所有宿主在同一刻掉线,固定间隔会让它们踩着同一个点回来。
|
|
73
|
+
// 退避到 30s:中转没起来时不该每 5 秒敲一次;注册成功由 onRegistered 里的 reset 清账。
|
|
71
74
|
const RECONNECT_DELAY_MS = 5000
|
|
72
|
-
const
|
|
75
|
+
const RECONNECT_JITTER_MS = 2000
|
|
76
|
+
const RECONNECT_MAX_DELAY_MS = 30_000
|
|
77
|
+
const reconnect = createReconnectScheduler({
|
|
78
|
+
delayMs: RECONNECT_DELAY_MS,
|
|
79
|
+
jitterMs: RECONNECT_JITTER_MS,
|
|
80
|
+
maxDelayMs: RECONNECT_MAX_DELAY_MS,
|
|
81
|
+
onFire: () => startTunnel(),
|
|
82
|
+
})
|
|
73
83
|
|
|
74
84
|
function send(res, code, value) {
|
|
75
85
|
if (res.headersSent) return res.end()
|
|
@@ -239,7 +249,11 @@ function startTunnel() {
|
|
|
239
249
|
// 是新实例、它的 ws 还停在 CONNECTING。于是 /status 报「已连接」、宿主据此放行镜像
|
|
240
250
|
// 推送,worker 却按真值回 503,镜像一条也发不出去(现场:host.log 里
|
|
241
251
|
// 「bg queue: sidecar facade request failed: 503」刷屏,而隧道其实在正常转发会话)。
|
|
242
|
-
onRegistered: () => {
|
|
252
|
+
onRegistered: () => {
|
|
253
|
+
// 注册成功 = 链路真的通了:连续失败账清零,下次掉线重新从 5s 起算。
|
|
254
|
+
if (tunnel !== current) return; reconnect.reset()
|
|
255
|
+
tunnelState = 'connected'; tunnelError = null; publishState()
|
|
256
|
+
},
|
|
243
257
|
onClose: () => { if (tunnel === current && config.enabled) { tunnelState = 'error'; tunnelError = '连接断开'; publishState(); scheduleReconnect() } },
|
|
244
258
|
onPairRequest: async (message) => {
|
|
245
259
|
try {
|