dsh-mobile-plugin 0.1.78 → 0.1.84
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 +8 -5
- package/lib/client.js +43 -50
- package/lib/impl.js +2 -2
- package/lib/mirror/pusher.cjs +2 -0
- package/lib/mobile/archive-clean.js +15 -38
- package/lib/mobile/completion.js +58 -36
- package/lib/mobile/host-bridge/transport.js +33 -5
- package/lib/mobile/session-cache.js +31 -24
- package/lib/mobile/session-send.js +3 -2
- package/lib/relay/tunnel.js +30 -9
- package/lib/session-cleanup.cjs +96 -17
- package/lib/shell/device-ota.js +96 -52
- package/lib/shell/manager.js +99 -1
- package/lib/shell/ota-package.js +237 -0
- package/lib/sidecar-facade-worker.cjs +65 -9
- package/package.json +1 -1
package/lib/relay/tunnel.js
CHANGED
|
@@ -381,6 +381,35 @@ class RelayTunnel {
|
|
|
381
381
|
if (this.connected) this.sendJson(T.updatePairing, 0, { pairingCode: code });
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/** 注册帧的载荷。连接建立时发一次,之后元信息变了再由 updateRegistration 重发。 */
|
|
385
|
+
registrationPayload() {
|
|
386
|
+
return {
|
|
387
|
+
instanceId: this.instanceId,
|
|
388
|
+
pairingCode: this.pairingCode,
|
|
389
|
+
name: this.name,
|
|
390
|
+
// 可选:与中转端 TUNNEL_REGISTER_SECRET 对应,防止他人抢注 instanceId。
|
|
391
|
+
...(this.registerSecret ? { secret: this.registerSecret } : {}),
|
|
392
|
+
...(this.lanUrls.length ? { lanUrls: this.lanUrls } : {}),
|
|
393
|
+
...(this.lanCandidates.length ? { lanCandidates: this.lanCandidates } : {}),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 在**现有连接上**重报一次注册信息(配对码 / 名字 / 局域网候选),不断连接。
|
|
399
|
+
*
|
|
400
|
+
* 中转那边同一个实例重复注册是幂等的(不会把自己顶掉),所以这条只是「更新名片」。
|
|
401
|
+
* 以前这类变化是把整条隧道拆了重建,代价是手机所有在途请求一起断——真机现场就是
|
|
402
|
+
* 「切个网络,接下来几秒点什么都失败」。真正决定「连到哪台」的字段(实例、中转
|
|
403
|
+
* 地址、注册密钥)不在这里更新,它们变了必须重建,由 worker 的 configure 判定。
|
|
404
|
+
*/
|
|
405
|
+
updateRegistration(patch = {}) {
|
|
406
|
+
if (typeof patch.pairingCode === 'string' && patch.pairingCode) this.pairingCode = patch.pairingCode;
|
|
407
|
+
if (typeof patch.name === 'string') this.name = patch.name;
|
|
408
|
+
if (Array.isArray(patch.lanUrls)) this.lanUrls = patch.lanUrls.slice();
|
|
409
|
+
if (Array.isArray(patch.lanCandidates)) this.lanCandidates = patch.lanCandidates.slice();
|
|
410
|
+
if (this.connected) this.sendJson(T.register, 0, this.registrationPayload());
|
|
411
|
+
}
|
|
412
|
+
|
|
384
413
|
/**
|
|
385
414
|
* 回传桌面端弹窗授权结果:approved + 桌面签发的移动 token(拒绝时无 token)。
|
|
386
415
|
* `hostPlatform` 是**宿主自己的** `process.platform`,经中转原样回给手机,
|
|
@@ -445,15 +474,7 @@ class RelayTunnel {
|
|
|
445
474
|
});
|
|
446
475
|
|
|
447
476
|
ws.on('open', () => {
|
|
448
|
-
this.sendJson(T.register, 0,
|
|
449
|
-
instanceId: this.instanceId,
|
|
450
|
-
pairingCode: this.pairingCode,
|
|
451
|
-
name: this.name,
|
|
452
|
-
// 可选:与中转端 TUNNEL_REGISTER_SECRET 对应,防止他人抢注 instanceId。
|
|
453
|
-
...(this.registerSecret ? { secret: this.registerSecret } : {}),
|
|
454
|
-
...(this.lanUrls.length ? { lanUrls: this.lanUrls } : {}),
|
|
455
|
-
...(this.lanCandidates.length ? { lanCandidates: this.lanCandidates } : {}),
|
|
456
|
-
});
|
|
477
|
+
this.sendJson(T.register, 0, this.registrationPayload());
|
|
457
478
|
});
|
|
458
479
|
ws.on('message', (data, isBinary) => push(Buffer.isBuffer(data) ? data : Buffer.from(data)));
|
|
459
480
|
ws.on('close', (code, reason) => {
|
package/lib/session-cleanup.cjs
CHANGED
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
* CANONICAL_LOG_FILENAME + compressionSuffix:0 代不带 vN,压缩时才带 .zstd)。
|
|
10
10
|
* 曾经只认 `session.jsonl.zstd`,于是 0.2.0 真在用的 `session.v4.jsonl.zstd`
|
|
11
11
|
* 一个都认不到 —— 表现是「清理已归档」恒回 `deleted: 0`,静默不删。
|
|
12
|
-
* - 删之前先从宿主归档集合里摘掉这些 id:宿主不会自己修剪,删完还留着就是「已归档」
|
|
13
|
-
* 里一排点不开的死行,且每清一次只增不减。
|
|
14
12
|
* - 解压用 Node 自带的 zstd(DSH 自己就是这么读写日志的,宿主并不需要系统 zstd
|
|
15
13
|
* 命令),不再 shell 出去:没装 zstd 命令的机器上它一缺就是 readHeader 静默返回
|
|
16
14
|
* undefined,整条删除路径空转。id 仍然取自日志第一行那条 header。
|
|
15
|
+
* - **进程活着的时候不擦宿主的归档集合**,只删日志;擦集合放到宿主启动时
|
|
16
|
+
* (pruneOrphanedArchivedSessionsOnce)。理由写在那个函数上:桌面端的会话列表是
|
|
17
|
+
* 连上宿主时拉的一份快照 + 之后的事件增量,进程里擦集合等于把已经删掉、却还留在
|
|
18
|
+
* 快照里的行放回正常列表。
|
|
17
19
|
*/
|
|
18
20
|
'use strict'
|
|
19
21
|
|
|
@@ -24,6 +26,9 @@ const zlib = require('node:zlib')
|
|
|
24
26
|
const DEFAULT_OLDER_THAN_MINUTES = 30
|
|
25
27
|
const MINUTES = 60 * 1000
|
|
26
28
|
|
|
29
|
+
/** 认为「宿主刚起来」的上限:装载插件只在这段时间里算启动,之后出现的调用只能是热替换。 */
|
|
30
|
+
const BOOT_PRUNE_WINDOW_SECONDS = 60
|
|
31
|
+
|
|
27
32
|
// 宿主认可的会话日志名。非规范名(迁移中途的临时文件、大写、前导零、带 v0)宿主自己
|
|
28
33
|
// 也不认,这里同样不认 —— 认多了会把没提交的代次当会话删掉。
|
|
29
34
|
const CANONICAL_LOG_FILENAME = /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/
|
|
@@ -128,18 +133,12 @@ async function deleteSessionData(sessionIds) {
|
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* 宿主不会自己修剪这份集合:日志删了它照样留着那些 id,于是「已归档」里是一排点不开的
|
|
134
|
-
* 死行,而且每清一次就多几条、只增不减。它排在 `deleteSessionData` **之后**:日志没删成
|
|
135
|
-
* 前先擦集合,中途失败就会留下「已取消归档、日志还在」的会话——用户看到的是清理只把会话
|
|
136
|
-
* 移出了归档,还要重启宿主才恢复。先删后擦则日志一没就是真的删掉了,立刻生效;擦失败最多
|
|
137
|
-
* 留一行死行,重试即可。宿主对本来就未归档的 id 是 no-op。
|
|
136
|
+
* 把一批 id 从宿主归档集合里擦掉(宿主对本来就未归档的 id 是 no-op)。
|
|
138
137
|
*
|
|
139
|
-
*
|
|
138
|
+
* 唯一的调用方是宿主启动时的孤儿修剪:进程活着的时候擦集合会把桌面端快照里那些已经
|
|
139
|
+
* 删掉的行放回正常列表,理由见 pruneOrphanedArchivedSessionsOnce。
|
|
140
140
|
*
|
|
141
|
-
* 返回擦失败的 id(`failed
|
|
142
|
-
* 报错——那等于告诉用户「没删掉」,而事实上已经删掉了。
|
|
141
|
+
* 返回擦失败的 id(`failed`),**不抛异常**:修剪是收尾,一条擦不动不该让整轮修剪停摆。
|
|
143
142
|
*/
|
|
144
143
|
async function forgetArchivedSessions(ctx, sessionIds, rpcId) {
|
|
145
144
|
const failed = []
|
|
@@ -154,6 +153,69 @@ async function forgetArchivedSessions(ctx, sessionIds, rpcId) {
|
|
|
154
153
|
return failed
|
|
155
154
|
}
|
|
156
155
|
|
|
156
|
+
/**
|
|
157
|
+
* 归档集合里那些日志确实已经不在磁盘上的 id。
|
|
158
|
+
*
|
|
159
|
+
* 判据是磁盘而不是宿主的会话列表:`sessions.list` 会不会漏一条(比如没有 cwd 的会话、
|
|
160
|
+
* 宿主服务还没装好)不是这里能判断的,而「归档集合里挂着、磁盘上已经没有日志」正是
|
|
161
|
+
* 清理真正删掉的那个状态。宿主把会话 id 原样当目录名(`encodeSegment` 对正常 id 恒等),
|
|
162
|
+
* 所以先用目录名筛掉绝大多数,只对候选读一遍 header 复核 —— 别为了修剪把整库日志解一遍。
|
|
163
|
+
*/
|
|
164
|
+
async function archivedIdsWithoutLogs(archivedSessionIds) {
|
|
165
|
+
const orphans = new Set(archivedSessionIds)
|
|
166
|
+
if (orphans.size === 0) return []
|
|
167
|
+
for (const file of sessionFiles(path.join(dshHome(), 'sessions'))) {
|
|
168
|
+
const name = path.basename(path.dirname(file))
|
|
169
|
+
if (!orphans.has(name)) continue
|
|
170
|
+
const header = await readHeader(file)
|
|
171
|
+
// 日志读不出来也算「还在」:宁可这一轮不修剪,也不能把一条还有日志的会话移出归档。
|
|
172
|
+
orphans.delete(name)
|
|
173
|
+
if (header && typeof header.id === 'string') orphans.delete(header.id)
|
|
174
|
+
if (orphans.size === 0) break
|
|
175
|
+
}
|
|
176
|
+
return [...orphans]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function pruneOrphanedArchivedSessions(ctx) {
|
|
180
|
+
const workspaceList = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.list : undefined
|
|
181
|
+
if (typeof workspaceList !== 'function') return 0
|
|
182
|
+
const rpcId = 'dsh-mobile-plugin-archive-prune'
|
|
183
|
+
const { value, error } = unwrap(await workspaceList({ rpcId, method: 'workspace.list', payload: {} }))
|
|
184
|
+
if (error) throw new Error(`workspace.list 失败:${error.message || error.code || '未知错误'}`)
|
|
185
|
+
const archived = value && Array.isArray(value.archivedSessionIds)
|
|
186
|
+
? value.archivedSessionIds.filter((id) => typeof id === 'string' && id)
|
|
187
|
+
: []
|
|
188
|
+
const orphans = await archivedIdsWithoutLogs(archived)
|
|
189
|
+
if (orphans.length === 0) return 0
|
|
190
|
+
const failed = await forgetArchivedSessions(ctx, orphans, rpcId)
|
|
191
|
+
return orphans.length - failed.length
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 宿主进程启动时修剪一次归档集合,然后整个进程不再碰。
|
|
196
|
+
*
|
|
197
|
+
* 这件事只能在启动这一刻做:桌面端的会话列表是「连上宿主时拉的一份快照 + 之后的事件
|
|
198
|
+
* 增量」,宿主本身没有「删除会话」的接口,插件在磁盘上删日志它也看不见。进程活着的时候
|
|
199
|
+
* 擦归档集合,宿主会把「归档集合变了」广播给桌面端,桌面端就把它快照里那些其实已经
|
|
200
|
+
* 没有日志、本来被归档藏着的行放回正常列表 —— 用户报的正是「清理后它们又回来了,重启
|
|
201
|
+
* DSH 才真的消失」。启动时桌面端还没连上(或正要重连,快照本来就从磁盘重新拉),擦掉
|
|
202
|
+
* 不会放出任何行。留在集合里的死 id 不显示在任何地方:「已归档」取的是会话列表与归档
|
|
203
|
+
* 集合的交集,没有日志就没有那一行。
|
|
204
|
+
*
|
|
205
|
+
* 用进程级标记而不是模块变量:代际热替换会重跑 apply,而桌面端那条快照挂在同一个进程上。
|
|
206
|
+
*/
|
|
207
|
+
const PRUNE_ONCE = Symbol.for('dsh-mobile-plugin.archived-prune')
|
|
208
|
+
async function pruneOrphanedArchivedSessionsOnce(ctx, log) {
|
|
209
|
+
if (globalThis[PRUNE_ONCE]) return
|
|
210
|
+
globalThis[PRUNE_ONCE] = true
|
|
211
|
+
try {
|
|
212
|
+
const pruned = await pruneOrphanedArchivedSessions(ctx)
|
|
213
|
+
if (pruned > 0) log?.(`archived session prune: dropped ${pruned} deleted session ids`)
|
|
214
|
+
} catch (error) {
|
|
215
|
+
log?.(`archived session prune failed: ${error && error.message ? error.message : String(error)}`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
157
219
|
async function cleanupSessions(ctx, args) {
|
|
158
220
|
const list = ctx.apiProxy?.sessions?.list
|
|
159
221
|
const workspaceList = ctx.apiProxy?.workspace?.list
|
|
@@ -188,11 +250,9 @@ async function cleanupSessions(ctx, args) {
|
|
|
188
250
|
}
|
|
189
251
|
if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
|
|
190
252
|
const ids = selected.map(item => item.sessionId)
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// 才恢复);先删则日志一没就是真的删掉了,立刻生效。擦集合是收尾,失败不改变结果。
|
|
253
|
+
// 与移动端那条路由同一口径:**只删日志**,归档集合留到宿主下次启动时修剪。进程里擦
|
|
254
|
+
// 集合会把桌面端快照里那些已经删掉的行放回正常列表(见 pruneOrphanedArchivedSessionsOnce)。
|
|
194
255
|
const deletedIds = await deleteSessionData(ids)
|
|
195
|
-
await forgetArchivedSessions(ctx, deletedIds, 'dsh-mobile-plugin-session-cleanup')
|
|
196
256
|
return { action: args.action, cwd, selected: ids.length, archived: 0, deleted: deletedIds.length, failed: ids.length - deletedIds.length, sessionIds: deletedIds }
|
|
197
257
|
}
|
|
198
258
|
|
|
@@ -219,4 +279,23 @@ function registerSessionCleanupTool(ctx) {
|
|
|
219
279
|
}))
|
|
220
280
|
}
|
|
221
281
|
|
|
222
|
-
|
|
282
|
+
/**
|
|
283
|
+
* 装配层的入口:注册 agent 的清理工具,同时在「宿主刚起来」时修剪一遍归档集合。
|
|
284
|
+
*
|
|
285
|
+
* 两件事都是会话清理的一部分,所以共用装配层那一处 effect;impl.js 有行数兜底守卫,
|
|
286
|
+
* 别把实现搬回去。
|
|
287
|
+
*/
|
|
288
|
+
function installSessionCleanup(ctx) {
|
|
289
|
+
// 「宿主启动时」不能只看进程级标记:升级到这份实现那次是**热替换**进来的(进程早就在跑),
|
|
290
|
+
// 标记还没被谁设过。那一刻桌面端的会话列表快照里可能正挂着之前删掉的行,擦集合就会把它们
|
|
291
|
+
// 放回列表。所以只在进程刚起的这点窗口里修剪;晚于它就留给下次启动。
|
|
292
|
+
if (process.uptime() <= BOOT_PRUNE_WINDOW_SECONDS) {
|
|
293
|
+
// 修剪先挂:没有 tools 注入的 profile 注册不了工具(外层会吞掉这条错),但归档集合照样要收尾。
|
|
294
|
+
void pruneOrphanedArchivedSessionsOnce(ctx, (message) => {
|
|
295
|
+
try { ctx.logger?.info?.(message) } catch { /* 日志失败不影响修剪 */ }
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
registerSessionCleanupTool(ctx)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = { cleanupSessions, registerSessionCleanupTool, installSessionCleanup, deleteSessionData, forgetArchivedSessions, pruneOrphanedArchivedSessions, pruneOrphanedArchivedSessionsOnce }
|
package/lib/shell/device-ota.js
CHANGED
|
@@ -1,62 +1,77 @@
|
|
|
1
|
-
/** 设备端临时插件 OTA:npm 保持正式来源,OTA
|
|
1
|
+
/** 设备端临时插件 OTA:npm 保持正式来源,OTA 只落在 profile 里(见 storageRoot)。 */
|
|
2
2
|
import fs from 'node:fs'
|
|
3
|
-
import os from 'node:os'
|
|
4
3
|
import path from 'node:path'
|
|
5
|
-
import { createRequire } from 'node:module'
|
|
6
4
|
import { dshHome } from '../shared/paths.js'
|
|
7
|
-
import { compareVersion } from './manager.js'
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
import { compareVersion, generationModulesError } from './manager.js'
|
|
6
|
+
import { movePath, readManifest, sha256Hex, unpackTarGz, verifyManifest } from './ota-package.js'
|
|
7
|
+
/**
|
|
8
|
+
* OTA 包落在哪。
|
|
9
|
+
*
|
|
10
|
+
* 默认 `<DSH_HOME>/plugin-ota`,但外壳知道 profile 在哪之后会把它挪到**profile 里面**
|
|
11
|
+
* (`<profile>/.dsh-mobile-ota`)。原因不是洁癖:DSH 宿主的 profile-resolution 拦截层
|
|
12
|
+
* 只认 `profiles/` 树内的模块,树外的裸包名走的是原生解析 —— 而宿主提供的那批 peer
|
|
13
|
+
* (`@deepseek-ai/dsh-tools` 之类)压根不在 profile 的 node_modules 里,于是 OTA 代
|
|
14
|
+
* 第一句 `require('@deepseek-ai/dsh-tools')` 就 MODULE_NOT_FOUND(2026-10-17 真机:
|
|
15
|
+
* 0.1.80 装载失败)。落在 profile 里之后,OTA 代与装进来的那一代走**同一条**解析链。
|
|
16
|
+
*/
|
|
17
|
+
let storageRoot = ''
|
|
18
|
+
export function configureOtaRoot(dir) {
|
|
19
|
+
if (typeof dir === 'string' && dir.length > 0) storageRoot = dir
|
|
20
|
+
}
|
|
21
|
+
const ROOT = () => storageRoot || path.join(dshHome(), 'plugin-ota')
|
|
12
22
|
const PACKAGE = 'dsh-mobile-plugin'
|
|
13
23
|
const RELAY = 'https://dsh-hub.wnluo.com/api/desktop-plugins/ota'
|
|
14
24
|
const CHANNEL = 'stable'
|
|
15
25
|
const PLATFORM = process.platform
|
|
26
|
+
/**
|
|
27
|
+
* 平台回落的兜底标记。
|
|
28
|
+
*
|
|
29
|
+
* 这个插件是纯 JS,同一份包在 Linux/macOS/Windows 上都能跑;但中转是按平台分发的,以前
|
|
30
|
+
* 只发过 darwin 就意味着 Windows 机器永远查不到更新(`ok:false` 被当成「没有新版」)。
|
|
31
|
+
* 约定:自己平台没包时再问一次 `any`,发布方也可以直接只发一份 `any`。
|
|
32
|
+
*/
|
|
33
|
+
const ANY_PLATFORM = 'any'
|
|
16
34
|
function validVersion(value) { return typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value) }
|
|
17
35
|
function validPackageUrl(value) {
|
|
18
36
|
try { return new URL(value).protocol === 'https:' } catch { return false }
|
|
19
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* 列出盘上可用的 OTA 代。
|
|
40
|
+
*
|
|
41
|
+
* 两条容错都是必要的:**逐个条目自己 try**(一个残目录不能让整台机器的 OTA 全部不可见——
|
|
42
|
+
* 旧实现把整个 map 包在一个 try 里,任何一份半拷贝都会让 `availableOta()` 返回空,表现为
|
|
43
|
+
* 「更新装了但界面上永远没有新版」),以及**跳过点开头的目录**(`.staging-*` 是解包中途的
|
|
44
|
+
* 暂存,不能当成一代)。
|
|
45
|
+
*/
|
|
20
46
|
function availableOta() {
|
|
21
|
-
|
|
22
|
-
|
|
47
|
+
let items
|
|
48
|
+
try { items = fs.readdirSync(ROOT(), { withFileTypes: true }) } catch { return [] }
|
|
49
|
+
const found = []
|
|
50
|
+
for (const item of items) {
|
|
51
|
+
if (!item.isDirectory() || item.name.startsWith('.')) continue
|
|
52
|
+
try {
|
|
23
53
|
const pkgDir = path.join(ROOT(), item.name, PACKAGE)
|
|
24
54
|
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'))
|
|
25
|
-
if (pkg.name !== PACKAGE || !validVersion(pkg.version)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
55
|
+
if (pkg.name !== PACKAGE || !validVersion(pkg.version)) continue
|
|
56
|
+
if (!fs.existsSync(path.join(pkgDir, 'lib', 'impl.js'))) continue
|
|
57
|
+
found.push({ version: pkg.version, entry: path.join(pkgDir, 'lib', 'impl.js'), dir: pkgDir, pkgDir, source: 'ota' })
|
|
58
|
+
} catch { /* 半份包 / 残目录:只跳过它自己 */ }
|
|
59
|
+
}
|
|
60
|
+
return found.sort((a, b) => compareVersion(b.version, a.version))
|
|
61
|
+
}
|
|
62
|
+
/** 清掉上次解包中途退出留下的暂存目录(断电 / 进程被杀时的残骸)。 */
|
|
63
|
+
function sweepStaging() {
|
|
64
|
+
let items
|
|
65
|
+
try { items = fs.readdirSync(ROOT(), { withFileTypes: true }) } catch { return }
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
if (!item.isDirectory() || !item.name.startsWith('.staging-')) continue
|
|
68
|
+
try { fs.rmSync(path.join(ROOT(), item.name), { recursive: true, force: true }) } catch { /* 下次再清 */ }
|
|
69
|
+
}
|
|
29
70
|
}
|
|
30
71
|
function clearSuperseded(version) {
|
|
31
72
|
if (!validVersion(version)) return
|
|
32
73
|
for (const release of availableOta()) if (compareVersion(version, release.version) >= 0) fs.rmSync(path.dirname(release.pkgDir), { recursive: true, force: true })
|
|
33
74
|
}
|
|
34
|
-
function unpackTarGz(buffer, target) {
|
|
35
|
-
const tar = zlib.gunzipSync(buffer)
|
|
36
|
-
let offset = 0
|
|
37
|
-
while (offset + 512 <= tar.length) {
|
|
38
|
-
const header = tar.subarray(offset, offset + 512)
|
|
39
|
-
if (header.every(byte => byte === 0)) break
|
|
40
|
-
const name = header.subarray(0, 100).toString().replace(/\0.*$/, '')
|
|
41
|
-
const prefix = header.subarray(345, 500).toString().replace(/\0.*$/, '')
|
|
42
|
-
const rel = path.posix.join(prefix, name)
|
|
43
|
-
const size = parseInt(header.subarray(124, 136).toString().replace(/\0.*$/, '').trim() || '0', 8)
|
|
44
|
-
const type = header[156]
|
|
45
|
-
const start = offset + 512
|
|
46
|
-
if (!rel.startsWith('package/') || rel.split('/').includes('..')) throw new Error('OTA 包含无效路径')
|
|
47
|
-
if (type === 0 || type === 48) {
|
|
48
|
-
const dest = path.join(target, PACKAGE, ...rel.split('/').slice(1))
|
|
49
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
50
|
-
fs.writeFileSync(dest, tar.subarray(start, start + size))
|
|
51
|
-
} else if (type !== 53) throw new Error(`OTA 包包含不支持的 tar 项:${rel}`)
|
|
52
|
-
offset = start + Math.ceil(size / 512) * 512
|
|
53
|
-
}
|
|
54
|
-
const pkgDir = path.join(target, PACKAGE)
|
|
55
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'))
|
|
56
|
-
if (pkg.name !== PACKAGE || !validVersion(pkg.version) || !fs.existsSync(path.join(pkgDir, 'lib', 'impl.js'))) throw new Error('OTA 包结构不完整')
|
|
57
|
-
return pkg.version
|
|
58
|
-
}
|
|
59
|
-
|
|
60
75
|
export function createDeviceOta({ fetchImpl = fetch, installed = () => null, platform = PLATFORM, channel = CHANNEL, runtime = null } = {}) {
|
|
61
76
|
async function reconcileOfficial() {
|
|
62
77
|
const active = runtime?.status()
|
|
@@ -73,37 +88,66 @@ export function createDeviceOta({ fetchImpl = fetch, installed = () => null, pla
|
|
|
73
88
|
if (status?.source === 'installed') clearSuperseded(officialVersion)
|
|
74
89
|
return { switched: status?.source === 'installed', status }
|
|
75
90
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const url = `${RELAY}/latest?platform=${encodeURIComponent(
|
|
91
|
+
/** 问一次中转「我该装哪个版本」;没有就返回 null(不抛)。 */
|
|
92
|
+
async function latestFor(wanted) {
|
|
93
|
+
const url = `${RELAY}/latest?platform=${encodeURIComponent(wanted)}&channel=${encodeURIComponent(channel)}`
|
|
79
94
|
const response = await fetchImpl(url, { headers: { accept: 'application/json' } })
|
|
80
95
|
if (!response.ok) throw new Error(`OTA relay 返回 HTTP ${response.status}`)
|
|
81
96
|
const body = await response.json()
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
if (body?.ok !== true) return null
|
|
98
|
+
const release = body.data
|
|
99
|
+
if (!release || !validVersion(release.version)) return null
|
|
100
|
+
// 回落来的包平台标记是 `any`;自己平台那份必须正好对上。
|
|
101
|
+
if (release.platform !== wanted || release.channel !== channel) return null
|
|
102
|
+
if (!validPackageUrl(release.packageUrl)) return null
|
|
103
|
+
return release
|
|
104
|
+
}
|
|
105
|
+
async function check() {
|
|
106
|
+
await reconcileOfficial()
|
|
107
|
+
const release = (await latestFor(platform)) ?? (platform === ANY_PLATFORM ? null : await latestFor(ANY_PLATFORM))
|
|
108
|
+
if (!release) throw new Error('OTA relay 没有提供匹配平台、stable channel、有效版本或 HTTPS 下载地址')
|
|
84
109
|
const installedVersion = installed()
|
|
85
110
|
const officialVersion = installedVersion
|
|
86
111
|
const ota = availableOta()[0] || null
|
|
87
112
|
const otaVersion = validVersion(release.version) ? release.version : null
|
|
88
113
|
const newest = [ota?.version, otaVersion].filter(validVersion).sort((a, b) => compareVersion(b, a))[0] || null
|
|
89
114
|
const absorbed = Boolean(validVersion(officialVersion) && newest && compareVersion(officialVersion, newest) >= 0 && runtime?.status()?.source !== 'ota')
|
|
90
|
-
return { available: Boolean(newest && !absorbed && compareVersion(newest, installedVersion || '0.0.0') > 0), version: newest, platform: release.platform || platform, channel: release.channel || channel, installedVersion, otaVersion: ota?.version ?? null, officialVersion, absorbed, packageUrl: release.packageUrl }
|
|
115
|
+
return { available: Boolean(newest && !absorbed && compareVersion(newest, installedVersion || '0.0.0') > 0), version: newest, platform: release.platform || platform, channel: release.channel || channel, installedVersion, otaVersion: ota?.version ?? null, officialVersion, absorbed, packageUrl: release.packageUrl, sha256: typeof release.sha256 === 'string' && /^[0-9a-f]{64}$/i.test(release.sha256) ? release.sha256.toLowerCase() : null }
|
|
91
116
|
}
|
|
92
117
|
async function apply() {
|
|
93
118
|
const info = await check()
|
|
94
119
|
if (info.otaVersion && compareVersion(info.otaVersion, info.version) === 0) return { installed: true, version: info.version, source: 'ota', alreadyDownloaded: true }
|
|
95
120
|
if (validVersion(info.officialVersion) && compareVersion(info.officialVersion, info.version) >= 0) return { installed: false, reason: 'npm-current-or-newer', version: info.officialVersion }
|
|
96
|
-
|
|
121
|
+
// 暂存目录**建在目标根里面**,不是系统临时目录:最后一步要 rename 到 `<root>/<version>`,
|
|
122
|
+
// 跨盘的 rename 在 Windows 上直接 EXDEV(系统临时目录常在 C:,而 profile 可能在别的盘)。
|
|
123
|
+
fs.mkdirSync(ROOT(), { recursive: true })
|
|
124
|
+
sweepStaging()
|
|
125
|
+
const staging = fs.mkdtempSync(path.join(ROOT(), '.staging-'))
|
|
97
126
|
try {
|
|
98
127
|
const response = await fetchImpl(info.packageUrl)
|
|
99
128
|
if (!response.ok) throw new Error(`OTA 下载失败:HTTP ${response.status}`)
|
|
100
|
-
const
|
|
129
|
+
const raw = Buffer.from(await response.arrayBuffer())
|
|
130
|
+
// 中转记了哈希就一定要对上:半个包、被中间层改过的包都在这里被挡住,而不是装上去再炸。
|
|
131
|
+
if (info.sha256 && sha256Hex(raw) !== info.sha256) {
|
|
132
|
+
throw new Error(`OTA 包校验失败:下载内容的 sha256 与中转记录不一致(预期 ${info.sha256},实际 ${sha256Hex(raw)})`)
|
|
133
|
+
}
|
|
134
|
+
unpackTarGz(raw, staging)
|
|
135
|
+
const pkgDir = path.join(staging, PACKAGE)
|
|
136
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'))
|
|
137
|
+
const version = pkg && pkg.version
|
|
138
|
+
if (pkg.name !== PACKAGE || !validVersion(version)) throw new Error('OTA 包结构不完整:package.json 里的包名或版本不对')
|
|
101
139
|
if (version !== info.version) throw new Error(`OTA 版本不匹配:预期 ${info.version},实际 ${version}`)
|
|
140
|
+
if (!fs.existsSync(path.join(pkgDir, 'lib', 'impl.js'))) throw new Error('OTA 包结构不完整:缺 lib/impl.js')
|
|
141
|
+
// 有清单就逐项核对(缺文件 / 大小不符在**激活之前**失败);老包没有清单,退回上面那两条判据。
|
|
142
|
+
const manifest = readManifest(pkgDir)
|
|
143
|
+
if (manifest) {
|
|
144
|
+
if (manifest.version !== version) throw new Error(`OTA 包清单版本与 package.json 不一致(${manifest.version} ≠ ${version})`)
|
|
145
|
+
verifyManifest(pkgDir, manifest)
|
|
146
|
+
}
|
|
102
147
|
const releaseDir = path.join(ROOT(), version)
|
|
103
|
-
fs.mkdirSync(ROOT(), { recursive: true })
|
|
104
148
|
fs.rmSync(releaseDir, { recursive: true, force: true })
|
|
105
|
-
|
|
106
|
-
return { installed: true, version, source: 'ota' }
|
|
149
|
+
movePath(staging, releaseDir)
|
|
150
|
+
return { installed: true, version, source: 'ota', verified: Boolean(manifest) }
|
|
107
151
|
} catch (error) {
|
|
108
152
|
fs.rmSync(staging, { recursive: true, force: true })
|
|
109
153
|
throw error
|
|
@@ -113,7 +157,7 @@ export function createDeviceOta({ fetchImpl = fetch, installed = () => null, pla
|
|
|
113
157
|
const installedVersion = installed()
|
|
114
158
|
const active = runtime?.status() ?? null
|
|
115
159
|
const ota = availableOta()[0] || null
|
|
116
|
-
return { available: Boolean(ota && compareVersion(ota.version, installedVersion || '0.0.0') > 0), activeVersion: active?.version ?? installedVersion, otaVersion: ota?.version ?? null, officialVersion: installedVersion, absorbed: Boolean(installedVersion && ota && compareVersion(installedVersion, ota.version) >= 0 && active?.source !== 'ota'), channel }
|
|
160
|
+
return { available: Boolean(ota && compareVersion(ota.version, installedVersion || '0.0.0') > 0), activeVersion: active?.version ?? installedVersion, otaVersion: ota?.version ?? null, officialVersion: installedVersion, absorbed: Boolean(installedVersion && ota && compareVersion(installedVersion, ota.version) >= 0 && active?.source !== 'ota'), channel, dependencyLinkError: generationModulesError() }
|
|
117
161
|
}
|
|
118
162
|
async function activate() {
|
|
119
163
|
const outcome = await apply()
|
package/lib/shell/manager.js
CHANGED
|
@@ -32,7 +32,9 @@ import fs from 'node:fs'
|
|
|
32
32
|
import { createRequire } from 'node:module'
|
|
33
33
|
import path from 'node:path'
|
|
34
34
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
35
|
-
import { availableOta } from './device-ota.js'
|
|
35
|
+
import { availableOta, configureOtaRoot } from './device-ota.js'
|
|
36
|
+
import { movePath } from './ota-package.js'
|
|
37
|
+
import { dshHome } from '../shared/paths.js'
|
|
36
38
|
|
|
37
39
|
/** CJS 模块缓存。实现树里 `lib/relay`、`lib/routes`、`lib/mobile` 都是 CJS。 */
|
|
38
40
|
const require_ = createRequire(import.meta.url)
|
|
@@ -227,6 +229,101 @@ function evictCommonJsTree(roots) {
|
|
|
227
229
|
}
|
|
228
230
|
}
|
|
229
231
|
|
|
232
|
+
/**
|
|
233
|
+
* OTA 包落到 `<profile>/.dsh-mobile-ota`(不再放 `<DSH_HOME>/plugin-ota`)。
|
|
234
|
+
*
|
|
235
|
+
* 宿主对模块解析有一层拦截:**`profiles/` 树内**的模块,裸包名会先按 profile 的
|
|
236
|
+
* 本地包解析、再回落到宿主自己那份;树外的模块走原生解析。而宿主提供的那批 peer
|
|
237
|
+
* (`@deepseek-ai/dsh-tools` 等)并不在 profile 的 `node_modules` 里 —— 所以 OTA 包
|
|
238
|
+
* 一旦落在树外,那一代 `require('@deepseek-ai/dsh-tools')` 就是 MODULE_NOT_FOUND
|
|
239
|
+
* (2026-10-17 真机:0.1.80 装载失败)。位置挪进 profile 后,OTA 代与装进来的那一代
|
|
240
|
+
* 走同一条解析链,不必再靠调用点逐个打补丁。
|
|
241
|
+
*
|
|
242
|
+
* 老位置残留的包顺手搬过来:换代失败时它还是磁盘上的回退包,不能在升级路径上丢。
|
|
243
|
+
*/
|
|
244
|
+
function moveOtaRootIntoProfile() {
|
|
245
|
+
if (path.basename(PROFILE_NODE_MODULES) !== 'node_modules') return
|
|
246
|
+
const next = path.join(path.dirname(PROFILE_NODE_MODULES), '.dsh-mobile-ota')
|
|
247
|
+
const legacy = path.join(dshHome(), 'plugin-ota')
|
|
248
|
+
configureOtaRoot(next)
|
|
249
|
+
try {
|
|
250
|
+
if (!fs.existsSync(legacy) || fs.lstatSync(legacy).isSymbolicLink()) return
|
|
251
|
+
fs.mkdirSync(next, { recursive: true })
|
|
252
|
+
for (const entry of fs.readdirSync(legacy)) {
|
|
253
|
+
const target = path.join(next, entry)
|
|
254
|
+
if (fs.existsSync(target)) continue
|
|
255
|
+
// 跨盘也能搬:`<DSH_HOME>` 与 profile 在 Windows 上很可能不在同一个盘,
|
|
256
|
+
// 那时 rename 抛 EXDEV,整段迁移会静默失败、OTA 留在树外(依赖就解析不到了)。
|
|
257
|
+
movePath(path.join(legacy, entry), target)
|
|
258
|
+
}
|
|
259
|
+
fs.rmSync(legacy, { recursive: true, force: true })
|
|
260
|
+
} catch {
|
|
261
|
+
// 搬不动就用老位置:那是「OTA 仍可下载但可能装不起来」,比让外壳启动失败强
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 给 OTA 代补齐「裸包名怎么解析」。
|
|
267
|
+
*
|
|
268
|
+
* Node 只沿**自己的祖先目录**找 `node_modules`,而 OTA 落在
|
|
269
|
+
* `<DSH_HOME>/plugin-ota/<version>/` —— 这条路径上没有任何一份 `node_modules`。于是
|
|
270
|
+
* `lib/impl.js` 第一句 `import { z } from '@deepseek-ai/schemastery'` 就报
|
|
271
|
+
* `Cannot find package`,一代整个装不起来(2026-10-17 真机:0.1.79 的 OTA 装载失败,
|
|
272
|
+
* 外壳按规矩回滚到旧代,机器上什么都没变)。
|
|
273
|
+
*
|
|
274
|
+
* 修法是补上基础代本来就有、而 OTA 路径上没有的那两层软链:
|
|
275
|
+
* `<version>/node_modules` → profile 的 `node_modules`
|
|
276
|
+
* `<version>/dsh-mobile-plugin/node_modules` → profile 里那份插件的 `node_modules`(pnpm 严格布局才有)
|
|
277
|
+
* 从 `<version>/dsh-mobile-plugin/lib` 往上第一站就命中,解析到的是**同一份** peer 与依赖,
|
|
278
|
+
* 与基础代的 real path 一致 —— 不会出现两份 cordis/schemastery。
|
|
279
|
+
*/
|
|
280
|
+
/** 外壳一启动就把 OTA 落盘位置定下来(见 moveOtaRootIntoProfile 的注释)。 */
|
|
281
|
+
moveOtaRootIntoProfile()
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 最近一次「给 OTA 代补依赖软链」的失败原因。
|
|
285
|
+
*
|
|
286
|
+
* 软链只是兜底(落在 profile 里的一代本来就能沿祖先目录解析到 profile 的依赖),可它
|
|
287
|
+
* **在 Windows 默认没有权限建**。旧实现把失败静默吞掉,于是现场只剩一句
|
|
288
|
+
* `Cannot find module`,看不出是缺依赖还是补链失败。记在这里,由状态接口如实报出来。
|
|
289
|
+
*/
|
|
290
|
+
let generationLinkError = null
|
|
291
|
+
export function generationModulesError() {
|
|
292
|
+
return generationLinkError
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function ensureGenerationModules(generation) {
|
|
296
|
+
if (!generation || generation.source !== 'ota' || !generation.dir) return
|
|
297
|
+
// 只认真正的 `node_modules`:`findProfileNodeModules()` 找不到时会回落到 profile 根,
|
|
298
|
+
// 把那个当依赖目录链过去等于凭空造一层错的解析路径。
|
|
299
|
+
if (path.basename(PROFILE_NODE_MODULES) !== 'node_modules' || !fs.existsSync(PROFILE_NODE_MODULES)) return
|
|
300
|
+
const versionDir = path.dirname(generation.dir)
|
|
301
|
+
const link = (target, at) => {
|
|
302
|
+
try {
|
|
303
|
+
if (fs.lstatSync(at).isSymbolicLink()) {
|
|
304
|
+
if (fs.readlinkSync(at) === target) return
|
|
305
|
+
fs.unlinkSync(at)
|
|
306
|
+
} else {
|
|
307
|
+
return // 已经是实体目录(有人手工铺过依赖):不动它
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// 还不存在,往下建
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
fs.symlinkSync(target, at, 'dir')
|
|
314
|
+
generationLinkError = null
|
|
315
|
+
} catch (error) {
|
|
316
|
+
// Windows 默认没有建软链的权限(要开发者模式或管理员),这类失败**必须留痕**:
|
|
317
|
+
// 否则现场只有一句 `Cannot find module`,看不出是缺依赖还是补链被拒。
|
|
318
|
+
generationLinkError = `补依赖软链失败(${at} → ${target}):${error && typeof error === 'object' ? error.code || error.message : error}`
|
|
319
|
+
try { process.stderr.write(`[dsh-mobile-plugin] ${generationLinkError}\n`) } catch { /* ignore */ }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
link(PROFILE_NODE_MODULES, path.join(versionDir, 'node_modules'))
|
|
323
|
+
const nested = path.join(PROFILE_NODE_MODULES, PLUGIN_PKG, 'node_modules')
|
|
324
|
+
if (fs.existsSync(nested)) link(nested, path.join(versionDir, PLUGIN_PKG, 'node_modules'))
|
|
325
|
+
}
|
|
326
|
+
|
|
230
327
|
/**
|
|
231
328
|
* 动态 import 一个代。
|
|
232
329
|
*
|
|
@@ -247,6 +344,7 @@ export async function importGeneration(generation) {
|
|
|
247
344
|
const cacheKey = generationCacheKey(generation)
|
|
248
345
|
const cached = imported.get(cacheKey)
|
|
249
346
|
if (cached) return cached
|
|
347
|
+
ensureGenerationModules(generation)
|
|
250
348
|
// 先把 CJS 缓存清干净再 import,否则「入口是新的、子模块还是旧的」,等于换了个半成品上去。
|
|
251
349
|
evictCommonJsTree(evictionRoots(generation))
|
|
252
350
|
importSeq += 1
|