dsh-mobile-plugin 0.1.72 → 0.1.73
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/lib/client.js +2 -2
- package/lib/mobile/archive-clean.js +15 -23
- package/lib/mobile/credentials.js +8 -3
- package/lib/mobile/host-bridge/endpoints.js +1 -0
- package/lib/mobile/workspaces.js +12 -0
- package/lib/panel/index.html +1 -1
- package/lib/relay/lan-proxy.js +4 -1
- package/lib/relay/pairing-policy.js +15 -1
- package/lib/relay/tunnel.js +7 -2
- package/lib/routes/control-plane.js +3 -1
- package/lib/routes/mobile-api.js +3 -2
- package/lib/session-cleanup.cjs +60 -10
- package/lib/sidecar-facade-worker.cjs +6 -2
- package/package.json +1 -1
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -459,7 +459,7 @@ document.getElementById('f').addEventListener('submit', async (e) => {
|
|
|
459
459
|
ip: suppliedDevice.ip || ip,
|
|
460
460
|
}
|
|
461
461
|
onApprove(device)
|
|
462
|
-
.then(({ approved, token }) => {
|
|
462
|
+
.then(({ approved, token, platform }) => {
|
|
463
463
|
if (!approved) {
|
|
464
464
|
// 与「配对码错误」返回同一响应,配对码不再可通过状态码探测。
|
|
465
465
|
pairDenied(res);
|
|
@@ -479,6 +479,9 @@ document.getElementById('f').addEventListener('submit', async (e) => {
|
|
|
479
479
|
deviceId: issued.deviceId,
|
|
480
480
|
deviceCredential: issued.credential,
|
|
481
481
|
credentialExpiresAt: issued.expiresAt,
|
|
482
|
+
// 宿主自己的系统标识,不是手机的。手机把它存进设备记录,
|
|
483
|
+
// 好让设备行在第一次 status 轮询之前就画得出图标。
|
|
484
|
+
...(platform ? { hostPlatform: platform } : {}),
|
|
482
485
|
}));
|
|
483
486
|
})
|
|
484
487
|
.catch((err) => {
|
|
@@ -42,4 +42,18 @@ function evaluate(value, device) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* 宿主的系统标识(`process.platform` 的取值:darwin / win32 / linux …)。
|
|
47
|
+
*
|
|
48
|
+
* 与 status 那条路同源,但**送达时机完全不同**:status 要走轮询,手机刚绑好、
|
|
49
|
+
* 还没轮到第一次探测时读不到;图标却要在设备行出现的那一刻就画得出来。
|
|
50
|
+
* 配对是一台设备「是什么」这个事实唯一的确定时刻,所以在这里就把它交给手机,
|
|
51
|
+
* 由手机存进设备记录。轮询回来的平台值只当补充(老设备补登记用),不再承担首次绘制。
|
|
52
|
+
*
|
|
53
|
+
* @returns {{ platform: string }}
|
|
54
|
+
*/
|
|
55
|
+
function hostPlatform() {
|
|
56
|
+
return { platform: process.platform }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { POLICIES, DEFAULT_POLICY, normalize, evaluate, hostPlatform }
|
package/lib/relay/tunnel.js
CHANGED
|
@@ -381,12 +381,17 @@ class RelayTunnel {
|
|
|
381
381
|
if (this.connected) this.sendJson(T.updatePairing, 0, { pairingCode: code });
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
-
/**
|
|
385
|
-
|
|
384
|
+
/**
|
|
385
|
+
* 回传桌面端弹窗授权结果:approved + 桌面签发的移动 token(拒绝时无 token)。
|
|
386
|
+
* `hostPlatform` 是**宿主自己的** `process.platform`,经中转原样回给手机,
|
|
387
|
+
* 让配对那一刻就把「这台是什么系统」落盘(设备行不等 status 轮询)。
|
|
388
|
+
*/
|
|
389
|
+
sendPairConfirm(requestId, approved, token, hostPlatform) {
|
|
386
390
|
this.sendJson(T.pairConfirm, 0, {
|
|
387
391
|
requestId,
|
|
388
392
|
approved,
|
|
389
393
|
...(approved && token ? { token } : {}),
|
|
394
|
+
...(approved && typeof hostPlatform === 'string' && hostPlatform ? { hostPlatform } : {}),
|
|
390
395
|
});
|
|
391
396
|
}
|
|
392
397
|
|
|
@@ -260,7 +260,9 @@ function registerControlPlaneRoutes(deps) {
|
|
|
260
260
|
const decision = pairingApproval.evaluate(state.pairingPolicy, body.device)
|
|
261
261
|
// Audit trail: the removed prompt showed the device before letting it in.
|
|
262
262
|
process.stderr.write(`[dsh-mobile-plugin] pairing ${decision.approved ? 'approved' : 'denied'} policy=${decision.policy} device=${decision.device}${decision.ip ? ` ip=${decision.ip}` : ''}\n`)
|
|
263
|
-
|
|
263
|
+
// `hostPlatform` 一并回给中转:手机要靠它把「这台宿主是什么系统」在配对那一刻就落盘,
|
|
264
|
+
// 不能等到第一次 status 轮询(那之前设备行画不出图标)。被拒时不给,没有意义。
|
|
265
|
+
return sendJson(res, 200, { data: { approved: decision.approved, reason: decision.reason, ...(decision.approved ? pairingApproval.hostPlatform() : {}) } })
|
|
264
266
|
} })
|
|
265
267
|
// 控制面状态里额外带上面板页的绝对地址:客户端半边(lib/client.js)那一项现在只是
|
|
266
268
|
// 一个「打开配置页面」的入口,链接必须是绝对的(原因见 panelUrlOf)。地址只在这里
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -162,6 +162,7 @@ function registerMobileRoutes(deps) {
|
|
|
162
162
|
route('/api/mobile/v1/directory/create', workspaces.handleCreateDirectory)
|
|
163
163
|
route('/api/mobile/v1/workspace/create', workspaces.handleCreateWorkspace)
|
|
164
164
|
route('/api/mobile/v1/workspace/delete', workspaces.handleDeleteWorkspace)
|
|
165
|
+
route('/api/mobile/v1/workspace/move', workspaces.handleMoveWorkspace)
|
|
165
166
|
route('/api/mobile/v1/workspace/move-session', workspaces.handleMoveSession)
|
|
166
167
|
// App 的 `renameWorkspace()` / `archiveSession()` 早就在打这两个地址
|
|
167
168
|
// (`app/src/services/apiPaths.ts` 的 workspaceRename / workspaceArchiveSession),
|
|
@@ -173,8 +174,8 @@ function registerMobileRoutes(deps) {
|
|
|
173
174
|
route('/api/mobile/v1/workspace/unarchive-session', workspaces.handleUnarchiveSession)
|
|
174
175
|
route('/api/mobile/v1/workspace/pin-session', workspaces.handlePinSession)
|
|
175
176
|
route('/api/mobile/v1/workspace/unpin-session', workspaces.handleUnpinSession)
|
|
176
|
-
//
|
|
177
|
-
//
|
|
177
|
+
// 「清理所有已归档的对话」(侧栏归档视图的文字按钮):按 workspace.list 的归档集合全量清理,
|
|
178
|
+
// 且 body 必须带 confirm: true(磁盘删除不可恢复)。
|
|
178
179
|
route('/api/mobile/v1/workspace/clear-archived', archiveClean.handleClearArchived)
|
|
179
180
|
// 桌面端 `/export` 其实是两步:命令本身只留一行 transcript 标记,真正的下载由
|
|
180
181
|
// @deepseek-ai/dsh-session-log-export 在浏览器侧监听 command/executed 后拉
|
package/lib/session-cleanup.cjs
CHANGED
|
@@ -3,17 +3,34 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 两个入口共用 deleteSessionData:DSH agent 工具 registerSessionCleanupTool(action=delete
|
|
5
5
|
* 必须 confirm:true 且只针对已归档)与移动 API /workspace/clear-archived(archive-clean.js)。
|
|
6
|
+
*
|
|
7
|
+
* 认哪份文件、怎么读 id 都以宿主自己的规则为准,不按目录名猜:
|
|
8
|
+
* - 文件名 = `session[.vN].jsonl[.zstd]`(宿主 `dsh-session-format` 的
|
|
9
|
+
* CANONICAL_LOG_FILENAME + compressionSuffix:0 代不带 vN,压缩时才带 .zstd)。
|
|
10
|
+
* 曾经只认 `session.jsonl.zstd`,于是 0.2.0 真在用的 `session.v4.jsonl.zstd`
|
|
11
|
+
* 一个都认不到 —— 表现是「清理已归档」恒回 `deleted: 0`,静默不删。
|
|
12
|
+
* - 删之前先从宿主归档集合里摘掉这些 id:宿主不会自己修剪,删完还留着就是「已归档」
|
|
13
|
+
* 里一排点不开的死行,且每清一次只增不减。
|
|
14
|
+
* - 解压用 Node 自带的 zstd(DSH 自己就是这么读写日志的,宿主并不需要系统 zstd
|
|
15
|
+
* 命令),不再 shell 出去:没装 zstd 命令的机器上它一缺就是 readHeader 静默返回
|
|
16
|
+
* undefined,整条删除路径空转。id 仍然取自日志第一行那条 header。
|
|
6
17
|
*/
|
|
7
18
|
'use strict'
|
|
8
19
|
|
|
9
20
|
const fs = require('node:fs')
|
|
10
21
|
const path = require('node:path')
|
|
11
|
-
const
|
|
22
|
+
const zlib = require('node:zlib')
|
|
12
23
|
|
|
13
24
|
const DEFAULT_OLDER_THAN_MINUTES = 30
|
|
14
25
|
const MINUTES = 60 * 1000
|
|
15
26
|
|
|
16
|
-
|
|
27
|
+
// 宿主认可的会话日志名。非规范名(迁移中途的临时文件、大写、前导零、带 v0)宿主自己
|
|
28
|
+
// 也不认,这里同样不认 —— 认多了会把没提交的代次当会话删掉。
|
|
29
|
+
const CANONICAL_LOG_FILENAME = /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/
|
|
30
|
+
// 只需要第一行那条 header;给个上限,别为了读一行把整份日志解进内存。
|
|
31
|
+
const HEADER_MAX_BYTES = 64 * 1024
|
|
32
|
+
|
|
33
|
+
const { envelopeValue, unwrap } = require('./mobile/envelope.js')
|
|
17
34
|
|
|
18
35
|
function asItems(response) {
|
|
19
36
|
const value = envelopeValue(response)
|
|
@@ -48,13 +65,28 @@ function projectCount(items, cwd) {
|
|
|
48
65
|
|
|
49
66
|
const { dshHome } = require('./shared/paths')
|
|
50
67
|
|
|
51
|
-
|
|
68
|
+
/** 读日志第一行那条 header(id 的真源)。压缩件用 Node 自带的 zstd 流式解,读到换行就停。 */
|
|
69
|
+
async function readHeader(file) {
|
|
70
|
+
const source = fs.createReadStream(file)
|
|
71
|
+
const stream = file.endsWith('.zstd') ? source.pipe(zlib.createZstdDecompress()) : source
|
|
72
|
+
const chunks = []
|
|
73
|
+
let size = 0
|
|
52
74
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
for await (const chunk of stream) {
|
|
76
|
+
chunks.push(chunk)
|
|
77
|
+
size += chunk.length
|
|
78
|
+
const joined = Buffer.concat(chunks, size)
|
|
79
|
+
const end = joined.indexOf(0x0a)
|
|
80
|
+
if (end >= 0) return JSON.parse(joined.subarray(0, end).toString('utf8'))
|
|
81
|
+
if (size >= HEADER_MAX_BYTES) return undefined
|
|
82
|
+
}
|
|
55
83
|
} catch {
|
|
56
84
|
return undefined
|
|
85
|
+
} finally {
|
|
86
|
+
stream.destroy()
|
|
87
|
+
source.destroy()
|
|
57
88
|
}
|
|
89
|
+
return undefined
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
function sessionFiles(root) {
|
|
@@ -65,19 +97,19 @@ function sessionFiles(root) {
|
|
|
65
97
|
for (const entry of entries) {
|
|
66
98
|
const file = path.join(directory, entry.name)
|
|
67
99
|
if (entry.isDirectory()) visit(file)
|
|
68
|
-
else if (entry.name
|
|
100
|
+
else if (CANONICAL_LOG_FILENAME.test(entry.name)) result.push(file)
|
|
69
101
|
}
|
|
70
102
|
}
|
|
71
103
|
visit(root)
|
|
72
104
|
return result
|
|
73
105
|
}
|
|
74
106
|
|
|
75
|
-
function deleteSessionData(sessionIds) {
|
|
107
|
+
async function deleteSessionData(sessionIds) {
|
|
76
108
|
const ids = new Set(sessionIds)
|
|
77
109
|
const removed = []
|
|
78
110
|
const root = path.join(dshHome(), 'sessions')
|
|
79
111
|
for (const file of sessionFiles(root)) {
|
|
80
|
-
const header = readHeader(file)
|
|
112
|
+
const header = await readHeader(file)
|
|
81
113
|
if (!header || !ids.has(header.id)) continue
|
|
82
114
|
try {
|
|
83
115
|
fs.rmSync(path.dirname(file), { recursive: true, force: true })
|
|
@@ -95,6 +127,23 @@ function deleteSessionData(sessionIds) {
|
|
|
95
127
|
return removed
|
|
96
128
|
}
|
|
97
129
|
|
|
130
|
+
/**
|
|
131
|
+
* 把马上要从磁盘上删掉的 id 从宿主归档集合里摘掉。
|
|
132
|
+
*
|
|
133
|
+
* 宿主不会自己修剪这份集合:日志删了它照样留着那些 id,于是「已归档」里是一排点不开的
|
|
134
|
+
* 死行,而且每清一次就多几条、只增不减。摘的动作放在删**之前**:摘失败就什么都没丢,
|
|
135
|
+
* 删失败则退化成「已取消归档但没删掉」(会话重新可见、还能再清一次)——比「删干净了却
|
|
136
|
+
* 还挂在归档集合里」好收场。宿主对本来就未归档的 id 是 no-op。
|
|
137
|
+
*/
|
|
138
|
+
async function forgetArchivedSessions(ctx, sessionIds, rpcId) {
|
|
139
|
+
const unarchive = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.unarchiveSession : undefined
|
|
140
|
+
if (typeof unarchive !== 'function') throw new Error('会话清理能力不可用:workspace.unarchiveSession')
|
|
141
|
+
for (const sessionId of sessionIds) {
|
|
142
|
+
const { error } = unwrap(await unarchive({ rpcId, method: 'workspace.unarchiveSession', payload: { sessionId } }))
|
|
143
|
+
if (error) throw new Error(`取消归档 ${sessionId} 失败:${error.message || error.code || '未知错误'}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
98
147
|
async function cleanupSessions(ctx, args) {
|
|
99
148
|
const list = ctx.apiProxy?.sessions?.list
|
|
100
149
|
const workspaceList = ctx.apiProxy?.workspace?.list
|
|
@@ -129,7 +178,8 @@ async function cleanupSessions(ctx, args) {
|
|
|
129
178
|
}
|
|
130
179
|
if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
|
|
131
180
|
const ids = selected.map(item => item.sessionId)
|
|
132
|
-
|
|
181
|
+
await forgetArchivedSessions(ctx, ids, 'dsh-mobile-plugin-session-cleanup')
|
|
182
|
+
const deletedIds = await deleteSessionData(ids)
|
|
133
183
|
return { action: args.action, cwd, selected: ids.length, archived: 0, deleted: deletedIds.length, failed: ids.length - deletedIds.length, sessionIds: deletedIds }
|
|
134
184
|
}
|
|
135
185
|
|
|
@@ -156,4 +206,4 @@ function registerSessionCleanupTool(ctx) {
|
|
|
156
206
|
}))
|
|
157
207
|
}
|
|
158
208
|
|
|
159
|
-
module.exports = { cleanupSessions, registerSessionCleanupTool, deleteSessionData }
|
|
209
|
+
module.exports = { cleanupSessions, registerSessionCleanupTool, deleteSessionData, forgetArchivedSessions }
|
|
@@ -211,7 +211,7 @@ function startTunnel() {
|
|
|
211
211
|
try {
|
|
212
212
|
const result = await parentJson('/api/relay/internal/approval', { device: message.device || {} })
|
|
213
213
|
if (tunnel !== current) return
|
|
214
|
-
if (result.approved) current.sendPairConfirm(message.requestId, true, core.signLanToken(config.lanSecret, config.instanceId, core.MOBILE_BEARER_TTL_SEC))
|
|
214
|
+
if (result.approved) current.sendPairConfirm(message.requestId, true, core.signLanToken(config.lanSecret, config.instanceId, core.MOBILE_BEARER_TTL_SEC), result.platform)
|
|
215
215
|
else current.sendPairConfirm(message.requestId, false)
|
|
216
216
|
} catch { if (tunnel === current) current.sendPairConfirm(message.requestId, false) }
|
|
217
217
|
},
|
|
@@ -234,7 +234,11 @@ function startLan() {
|
|
|
234
234
|
onApprove: async (device) => {
|
|
235
235
|
try {
|
|
236
236
|
const result = await parentJson('/api/relay/internal/approval', { device: device || {} })
|
|
237
|
-
return {
|
|
237
|
+
return {
|
|
238
|
+
approved: Boolean(result.approved),
|
|
239
|
+
token: result.approved ? core.signLanToken(config.lanSecret, config.instanceId, core.MOBILE_BEARER_TTL_SEC) : null,
|
|
240
|
+
platform: typeof result.platform === 'string' ? result.platform : null,
|
|
241
|
+
}
|
|
238
242
|
} catch { return { approved: false, token: null } }
|
|
239
243
|
},
|
|
240
244
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.73",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|