dsh-mobile-plugin 0.1.72 → 0.1.74
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 +57 -166
- package/lib/client.js +2 -2
- package/lib/mobile/archive-clean.js +23 -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 +4 -4
- 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 +73 -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,33 @@ function deleteSessionData(sessionIds) {
|
|
|
95
127
|
return removed
|
|
96
128
|
}
|
|
97
129
|
|
|
130
|
+
/**
|
|
131
|
+
* 把已经从磁盘删掉的 id 从宿主归档集合里擦掉(删除路径的最后一步,best-effort)。
|
|
132
|
+
*
|
|
133
|
+
* 宿主不会自己修剪这份集合:日志删了它照样留着那些 id,于是「已归档」里是一排点不开的
|
|
134
|
+
* 死行,而且每清一次就多几条、只增不减。它排在 `deleteSessionData` **之后**:日志没删成
|
|
135
|
+
* 前先擦集合,中途失败就会留下「已取消归档、日志还在」的会话——用户看到的是清理只把会话
|
|
136
|
+
* 移出了归档,还要重启宿主才恢复。先删后擦则日志一没就是真的删掉了,立刻生效;擦失败最多
|
|
137
|
+
* 留一行死行,重试即可。宿主对本来就未归档的 id 是 no-op。
|
|
138
|
+
*
|
|
139
|
+
* 只传**真删掉**的 id:没匹配到日志的还留着日志,擦掉它的名字只会让那一行消失而没有东西被删。
|
|
140
|
+
*
|
|
141
|
+
* 返回擦失败的 id(`failed`),**不抛异常**:日志已经删成功,擦集合坏了不该让整条清理
|
|
142
|
+
* 报错——那等于告诉用户「没删掉」,而事实上已经删掉了。
|
|
143
|
+
*/
|
|
144
|
+
async function forgetArchivedSessions(ctx, sessionIds, rpcId) {
|
|
145
|
+
const failed = []
|
|
146
|
+
const unarchive = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.unarchiveSession : undefined
|
|
147
|
+
if (typeof unarchive !== 'function') return failed
|
|
148
|
+
for (const sessionId of sessionIds) {
|
|
149
|
+
try {
|
|
150
|
+
const { error } = unwrap(await unarchive({ rpcId, method: 'workspace.unarchiveSession', payload: { sessionId } }))
|
|
151
|
+
if (error) failed.push(sessionId)
|
|
152
|
+
} catch { failed.push(sessionId) }
|
|
153
|
+
}
|
|
154
|
+
return failed
|
|
155
|
+
}
|
|
156
|
+
|
|
98
157
|
async function cleanupSessions(ctx, args) {
|
|
99
158
|
const list = ctx.apiProxy?.sessions?.list
|
|
100
159
|
const workspaceList = ctx.apiProxy?.workspace?.list
|
|
@@ -129,7 +188,11 @@ async function cleanupSessions(ctx, args) {
|
|
|
129
188
|
}
|
|
130
189
|
if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
|
|
131
190
|
const ids = selected.map(item => item.sessionId)
|
|
132
|
-
|
|
191
|
+
// 与移动端那条路由同一口径:**先删日志、再擦归档集合**。反过来会在链路中途失败时
|
|
192
|
+
// 留下「名字已从归档集合消失、日志还在」的会话(看着像只是被移出了归档,得重启宿主
|
|
193
|
+
// 才恢复);先删则日志一没就是真的删掉了,立刻生效。擦集合是收尾,失败不改变结果。
|
|
194
|
+
const deletedIds = await deleteSessionData(ids)
|
|
195
|
+
await forgetArchivedSessions(ctx, deletedIds, 'dsh-mobile-plugin-session-cleanup')
|
|
133
196
|
return { action: args.action, cwd, selected: ids.length, archived: 0, deleted: deletedIds.length, failed: ids.length - deletedIds.length, sessionIds: deletedIds }
|
|
134
197
|
}
|
|
135
198
|
|
|
@@ -156,4 +219,4 @@ function registerSessionCleanupTool(ctx) {
|
|
|
156
219
|
}))
|
|
157
220
|
}
|
|
158
221
|
|
|
159
|
-
module.exports = { cleanupSessions, registerSessionCleanupTool, deleteSessionData }
|
|
222
|
+
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.74",
|
|
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",
|