dsh-mobile-plugin 0.1.96 → 0.1.98
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 +73 -50
- package/config/agent-presets/standard/agent.cordis.yml +13 -0
- package/lib/git/exec.js +129 -0
- package/lib/git/package.json +3 -0
- package/lib/git/parse.js +247 -0
- package/lib/git/read.js +617 -0
- package/lib/index.js +23 -29
- package/lib/mobile/archive-clean.js +31 -1
- package/lib/mobile/channels.js +583 -0
- package/lib/mobile/events.js +10 -8
- package/lib/mobile/git-repos.js +210 -0
- package/lib/mobile/git.js +603 -0
- package/lib/mobile/history-derive.js +79 -1
- package/lib/mobile/host-bridge/endpoints.js +3 -0
- package/lib/mobile/host-bridge/event-synthesis.js +109 -1
- package/lib/mobile/host-bridge/shims.js +9 -2
- package/lib/mobile/host-settings.js +41 -0
- package/lib/mobile/inbox-clean.js +337 -0
- package/lib/mobile/pairing.js +18 -1
- package/lib/mobile/questions.js +31 -9
- package/lib/mobile/schedule.js +440 -0
- package/lib/mobile/session-cache.js +18 -0
- package/lib/mobile/session-file.js +192 -0
- package/lib/mobile/session-model-options.js +184 -0
- package/lib/mobile/session-models.js +24 -2
- package/lib/mobile/session-schedule-mark.js +70 -0
- package/lib/mobile/session-snapshot.js +12 -1
- package/lib/mobile/sessions.js +8 -0
- package/lib/mobile/stats-collect.cjs +257 -0
- package/lib/mobile/stats-ledger.cjs +407 -0
- package/lib/mobile/stats.js +38 -0
- package/lib/mobile/stream.js +18 -10
- package/lib/mobile/uploads.js +24 -6
- package/lib/mobile/workspaces.js +30 -6
- package/lib/panel/index.html +4 -4
- package/lib/relay/lan-proxy.js +10 -1
- package/lib/routes/control-plane.js +38 -0
- package/lib/routes/mobile-api.js +70 -1
- package/lib/session-cleanup.cjs +8 -0
- package/lib/shared/sse-write.js +17 -6
- package/lib/shell/device-ota.js +52 -2
- package/lib/shell/manager.js +15 -1
- package/lib/shell/updater.js +153 -6
- package/lib/update-install.cjs +11 -0
- package/package.json +3 -2
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
const http = require('node:http');
|
|
15
15
|
const { WebSocketServer, WebSocket } = require('ws');
|
|
16
16
|
const core = require('./core.js');
|
|
17
|
-
const { isGlobalUnicastV6 } = require('./lan-network.cjs');
|
|
17
|
+
const { advertisedCandidates, ipv6Candidates, isGlobalUnicastV6 } = require('./lan-network.cjs');
|
|
18
18
|
const { safeEqual } = require('../shared/dshtoken');
|
|
19
19
|
const { isFreshRequest } = require('../shared/fresh-request');
|
|
20
20
|
const { encodeJsonBody } = require('../shared/http-util');
|
|
@@ -519,6 +519,13 @@ function createLanProxy(opts) {
|
|
|
519
519
|
const issued = credentials.issue(instanceId, device, token);
|
|
520
520
|
const cookieValue = core.signLanToken(lanSecret, instanceId, core.MOBILE_TOKEN_TTL_SEC);
|
|
521
521
|
setCookie(res);
|
|
522
|
+
// 宿主自己那批直连候选一起回给手机:手输公网地址(域名 / 公网 IP)配对时,
|
|
523
|
+
// 二维码那条路没有、也就只有这一处能拿到内网地址。回家后如果那个公网地址
|
|
524
|
+
// 打不通(路由器不支持回环 / 域名只在外网解析),手机还能靠这批候选找到这台
|
|
525
|
+
// 机器,否则只能删设备重配。App 的 `doPair` 本来就合并响应里的 `lanUrls`
|
|
526
|
+
// 与 `ipv6Candidates`,这里不回它就只能记住手输的那一个地址。
|
|
527
|
+
const directCandidates = advertisedCandidates(actualPort).map((candidate) => candidate.url).filter(Boolean);
|
|
528
|
+
const v6Candidates = ipv6Candidates(actualPort).map((candidate) => candidate.url).filter(Boolean);
|
|
522
529
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
523
530
|
// cookie 同时放进 body:RN fetch 读不到 Set-Cookie 响应头,App 需自行携带
|
|
524
531
|
res.end(JSON.stringify({
|
|
@@ -526,6 +533,8 @@ function createLanProxy(opts) {
|
|
|
526
533
|
instanceId,
|
|
527
534
|
token: token || '',
|
|
528
535
|
cookie: `dsh_lan=${cookieValue}`,
|
|
536
|
+
...(directCandidates.length ? { lanUrls: directCandidates } : {}),
|
|
537
|
+
...(v6Candidates.length ? { ipv6Candidates: v6Candidates } : {}),
|
|
529
538
|
deviceId: issued.deviceId,
|
|
530
539
|
deviceCredential: issued.credential,
|
|
531
540
|
credentialExpiresAt: issued.expiresAt,
|
|
@@ -49,6 +49,8 @@ const { checkForUpdate } = require('../update-registry.cjs');
|
|
|
49
49
|
const { declaredSource } = require('../update-install.cjs');
|
|
50
50
|
const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobile/session-cache.js');
|
|
51
51
|
const { recordSessionRead } = require('../mobile/session-last-turn.js');
|
|
52
|
+
// 收件目录的占用与清理:和移动端那条路由是同一份实现(`lib/mobile/inbox-clean.js`)。
|
|
53
|
+
const { scanInbox, clearInbox, usageView, setAutoClear } = require('../mobile/inbox-clean.js');
|
|
52
54
|
|
|
53
55
|
/** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
|
|
54
56
|
const MAX_DESKTOP_READ_IDS = 200;
|
|
@@ -770,6 +772,38 @@ function registerControlPlaneRoutes(deps) {
|
|
|
770
772
|
})
|
|
771
773
|
}
|
|
772
774
|
}) })
|
|
775
|
+
// ── 收件目录(手机分享进来的文件)──
|
|
776
|
+
//
|
|
777
|
+
// 同一份实现在移动端也有两条(`/api/mobile/v1/inbox/{status,clear}`),这里只是把它
|
|
778
|
+
// 开到网页面板上:面板与 App 读同一份扫描结果,两边不各算一套口径。
|
|
779
|
+
//
|
|
780
|
+
// 走 localOnly:清的是本机上那个固定收件目录,属于本机运维动作,和其余控制面一样。
|
|
781
|
+
// 面板侧也因此在网页管理窗口打不开时仍可由本机操作。
|
|
782
|
+
registerRoute({ kind: 'exact', path: '/api/relay/inbox/status', handler: localOnly('GET', async (_req, res) => {
|
|
783
|
+
sendJson(res, 200, usageView(await scanInbox()))
|
|
784
|
+
}) })
|
|
785
|
+
// 自动清理开关:默认关。打开后宿主每次启动顺手清一次(见 mobile/inbox-clean.js 的
|
|
786
|
+
// clearInboxOnce)。手动那颗「清理收件文件」不受它管 —— 那个是用户当场按的。
|
|
787
|
+
registerRoute({ kind: 'exact', path: '/api/relay/inbox/auto', handler: localOnly('POST', async (req, res) => {
|
|
788
|
+
let body = {}
|
|
789
|
+
try {
|
|
790
|
+
body = (await readJson(req)) || {}
|
|
791
|
+
} catch {
|
|
792
|
+
body = {}
|
|
793
|
+
}
|
|
794
|
+
if (typeof body.enabled !== 'boolean') return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'enabled 必须是布尔值' } })
|
|
795
|
+
try {
|
|
796
|
+
return sendJson(res, 200, { ok: true, data: { autoClear: setAutoClear(body.enabled) } })
|
|
797
|
+
} catch (error) {
|
|
798
|
+
return sendJson(res, 500, { ok: false, error: { code: 'write-failed', message: String(error?.message || error) } })
|
|
799
|
+
}
|
|
800
|
+
}) })
|
|
801
|
+
registerRoute({ kind: 'exact', path: '/api/relay/inbox/clear', handler: localOnly('POST', async (_req, res) => {
|
|
802
|
+
// 面板上那颗按钮自带二次确认;这里不额外要 `confirm` 字段 —— 移动端那条要求它,
|
|
803
|
+
// 是因为那里没有既有的确认口径,而这一页的确认是界面自己的事。
|
|
804
|
+
const result = await clearInbox()
|
|
805
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
806
|
+
}) })
|
|
773
807
|
}
|
|
774
808
|
|
|
775
809
|
// 路由字符串清单放在注册区之后,避免被按路由源码切片的契约测试误认为是真正的 handler。
|
|
@@ -801,6 +835,10 @@ const WEB_ACCESS_ROUTES = new Set([
|
|
|
801
835
|
'/api/relay/restart-sidecar',
|
|
802
836
|
// 开发机的「就地重载」:能换掉本机跑着的实现,属于最该只允许本机调的那一类。
|
|
803
837
|
'/api/relay/reload-implementation',
|
|
838
|
+
// 收件目录的占用与清理(面板那张「收件文件」卡)。
|
|
839
|
+
'/api/relay/inbox/status',
|
|
840
|
+
'/api/relay/inbox/clear',
|
|
841
|
+
'/api/relay/inbox/auto',
|
|
804
842
|
]);
|
|
805
843
|
|
|
806
844
|
// `panelDiagnosis` 一并导出,只为让 test/panel-artifact.test.cjs 能拿临时目录跑那三条分支:
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -23,6 +23,9 @@ const { sendJson } = require('../http/routing.js');
|
|
|
23
23
|
const { orderCandidates } = require('../relay/lan-network.cjs');
|
|
24
24
|
|
|
25
25
|
const sessions = require('../mobile/sessions.js');
|
|
26
|
+
const sessionModelOptions = require('../mobile/session-model-options.js');
|
|
27
|
+
const sessionFile = require('../mobile/session-file.js');
|
|
28
|
+
const stats = require('../mobile/stats.js');
|
|
26
29
|
const agentPresets = require('../mobile/agent-presets.js');
|
|
27
30
|
const stream = require('../mobile/stream.js');
|
|
28
31
|
const events = require('../mobile/events.js');
|
|
@@ -32,15 +35,19 @@ const workspaces = require('../mobile/workspaces.js');
|
|
|
32
35
|
const archiveClean = require('../mobile/archive-clean.js');
|
|
33
36
|
const sessionLog = require('../mobile/session-log.js');
|
|
34
37
|
const hostSettings = require('../mobile/host-settings.js');
|
|
38
|
+
const channels = require('../mobile/channels.js');
|
|
35
39
|
const deepSeekAccount = require('../mobile/deepseek-account.js');
|
|
36
40
|
const skills = require('../mobile/skills.js');
|
|
37
41
|
const mobileCredentials = require('../mobile/credentials.js');
|
|
38
42
|
const subagents = require('../mobile/subagents.js');
|
|
39
43
|
const monitor = require('../mobile/monitor.js');
|
|
40
44
|
const references = require('../mobile/references.js');
|
|
45
|
+
const schedule = require('../mobile/schedule.js');
|
|
41
46
|
const plugins = require('../mobile/plugins.js');
|
|
42
47
|
const pluginUpdate = require('../mobile/plugin-update.js');
|
|
43
48
|
const uploads = require('../mobile/uploads.js');
|
|
49
|
+
const inboxClean = require('../mobile/inbox-clean.js');
|
|
50
|
+
const git = require('../mobile/git.js');
|
|
44
51
|
const pairing = require('../mobile/pairing.js');
|
|
45
52
|
const { credentialsEntry } = require('../shared/credentials-entry.js');
|
|
46
53
|
// 体检接口要自报身份:版本号只有一个来源,就是包自己的 package.json(与 relay/status.js 同)。
|
|
@@ -118,7 +125,18 @@ function registerMobileRoutes(deps) {
|
|
|
118
125
|
registerRoute({
|
|
119
126
|
kind: 'exact',
|
|
120
127
|
path: '/api/mobile/v1/pair',
|
|
121
|
-
handler: pairing.createPairHandler({
|
|
128
|
+
handler: pairing.createPairHandler({
|
|
129
|
+
state,
|
|
130
|
+
core,
|
|
131
|
+
credentials: credentialsEntry(),
|
|
132
|
+
pairingApproval,
|
|
133
|
+
// 配对响应顺手把宿主当前的直连候选带回去(与 `/connection-info` 同一份取值):
|
|
134
|
+
// 手输公网地址配对时没有二维码,这是手机拿到内网地址的唯一机会。
|
|
135
|
+
lanCandidates: safeLanCandidates,
|
|
136
|
+
ipv6Candidates: safeIpv6Candidates,
|
|
137
|
+
lanPort: lanPortInUse,
|
|
138
|
+
log: (...args) => console.warn('[dsh-mobile-plugin] [pair]', ...args),
|
|
139
|
+
}),
|
|
122
140
|
})
|
|
123
141
|
// 局域网/中转连接信息。**这条由宿主自己回答**:中转只转发,不再代答。
|
|
124
142
|
// 纯局域网配对(App 只扫到局域网二维码、完全不经中转)需要一个宿主自己回答的地址,
|
|
@@ -170,6 +188,8 @@ function registerMobileRoutes(deps) {
|
|
|
170
188
|
route('/api/mobile/v1/session', sessions.handleSession)
|
|
171
189
|
route('/api/mobile/v1/session/messages', sessions.handleMessages)
|
|
172
190
|
route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
|
|
191
|
+
// 交付文件卡要的字节:只放行本会话 present 声明过的路径(见 mobile/session-file.js)。
|
|
192
|
+
route('/api/mobile/v1/session/file', sessionFile.handleSessionFile)
|
|
173
193
|
route('/api/mobile/v1/session/send', sessions.handleSend)
|
|
174
194
|
// 正文水位探针:App 进对话前先问一句「这条会话的正文到哪个水位了」,跟本地缓存水位
|
|
175
195
|
// 一比就知道要不要重拉全量历史(几十字节换几兆)。**只读信号快照**:信号里没有就如实
|
|
@@ -182,7 +202,12 @@ function registerMobileRoutes(deps) {
|
|
|
182
202
|
route('/api/mobile/v1/session/fork', sessions.handleFork)
|
|
183
203
|
route('/api/mobile/v1/session/models', sessions.handleModels)
|
|
184
204
|
route('/api/mobile/v1/session/select-model', sessions.handleSelectModel)
|
|
205
|
+
// 模型之外的附加选项(速度、输出详略……):写一条已声明的行,落地是宿主自己的设置。
|
|
206
|
+
route('/api/mobile/v1/session/model-option', sessionModelOptions.handleModelOption)
|
|
185
207
|
route('/api/mobile/v1/session/stats', sessions.handleStats)
|
|
208
|
+
// 统计总览(统计页)。账本落在宿主家里(`$DSH_HOME/mobile-stats.json`),与会话日志
|
|
209
|
+
// 无关:归档只是挪位置,清理删的是日志,历史数字都还在 —— 见 mobile/stats-ledger.cjs。
|
|
210
|
+
route('/api/mobile/v1/stats/overview', stats.handleStatsOverview)
|
|
186
211
|
// 正文检索(宿主 session.search 的移动端代理):只回 { sessionId, snippet },
|
|
187
212
|
// 标题 / 工作区由 App 用自己轮询到的会话列表拼接,见 mobile/sessions.js。
|
|
188
213
|
route('/api/mobile/v1/session/search', sessions.handleSessionSearch)
|
|
@@ -226,6 +251,24 @@ function registerMobileRoutes(deps) {
|
|
|
226
251
|
// 「清理所有已归档的对话」(侧栏归档视图的文字按钮):按 workspace.list 的归档集合全量清理,
|
|
227
252
|
// 且 body 必须带 confirm: true(磁盘删除不可恢复)。
|
|
228
253
|
route('/api/mobile/v1/workspace/clear-archived', archiveClean.handleClearArchived)
|
|
254
|
+
// Git 管理器(docs/git-manager.md):手机上的仓库概览 / 变更 / 历史 / 分支都是读,
|
|
255
|
+
// 暂存 / 提交 / 切分支是写。能力面是一组固定命名的动词,不接受透传命令;每个请求都带
|
|
256
|
+
// workspaceId 与(可选的)仓库根,插件再校验它确实属于这个项目识别出来的仓库。
|
|
257
|
+
route('/api/mobile/v1/git/repo', git.handleGitRepo)
|
|
258
|
+
route('/api/mobile/v1/git/status', git.handleGitStatus)
|
|
259
|
+
route('/api/mobile/v1/git/diff', git.handleGitDiff)
|
|
260
|
+
route('/api/mobile/v1/git/log', git.handleGitLog)
|
|
261
|
+
route('/api/mobile/v1/git/show', git.handleGitShow)
|
|
262
|
+
route('/api/mobile/v1/git/branches', git.handleGitBranches)
|
|
263
|
+
route('/api/mobile/v1/git/overview', git.handleGitOverview)
|
|
264
|
+
route('/api/mobile/v1/git/stage', git.handleGitStage)
|
|
265
|
+
route('/api/mobile/v1/git/unstage', git.handleGitUnstage)
|
|
266
|
+
route('/api/mobile/v1/git/commit', git.handleGitCommit)
|
|
267
|
+
route('/api/mobile/v1/git/checkout', git.handleGitCheckout)
|
|
268
|
+
// 推当前分支到它的上游:只做这一档,不收分支名 / 远端名 / force 开关。
|
|
269
|
+
route('/api/mobile/v1/git/push', git.handleGitPush)
|
|
270
|
+
// AI 写提交信息:只读 `git diff --cached`,调宿主 llm 服务,回一行文案;不提交、不推送。
|
|
271
|
+
route('/api/mobile/v1/git/commit-message', git.handleGitCommitMessage)
|
|
229
272
|
// 桌面端 `/export` 其实是两步:命令本身只留一行 transcript 标记,真正的下载由
|
|
230
273
|
// @deepseek-ai/dsh-session-log-export 在浏览器侧监听 command/executed 后拉
|
|
231
274
|
// `GET /api/session.export` 完成。手机端没有浏览器下载管理器,而那个端点绑在宿主
|
|
@@ -242,6 +285,16 @@ function registerMobileRoutes(deps) {
|
|
|
242
285
|
// 改设置:与桌面端同一个 settings.mutate(按路径寻址的 op + expectedRevision 乐观并发)。
|
|
243
286
|
route('/api/mobile/v1/host-settings/mutate', hostSettings.handleHostSettingsMutate)
|
|
244
287
|
route('/api/mobile/v1/llm/providers', hostSettings.handleLlmProviders)
|
|
288
|
+
// 宿主的模型目录(`llm.models`):每个提供方下面真正可请求的模型 id,给「用哪个模型做
|
|
289
|
+
// AI commit」这类选择器用 —— 它不需要活动会话,与 `/session/models` 不是一回事。
|
|
290
|
+
route('/api/mobile/v1/llm/models', hostSettings.handleLlmModels)
|
|
291
|
+
// 渠道管理(见 mobile/channels.js):只对「本机自建」那一类(`llm-pi-ai` 里的路由)
|
|
292
|
+
// 提供增删;插件 / 宿主注册的提供方只列出来,一个写操作都不做。获取模型由宿主代问
|
|
293
|
+
// (端点常在内网,手机直连未必通),与桌面面板的 `discoverModels` 同一条路。
|
|
294
|
+
route('/api/mobile/v1/channels', channels.handleChannels)
|
|
295
|
+
route('/api/mobile/v1/channels/discover', channels.handleChannelDiscover)
|
|
296
|
+
route('/api/mobile/v1/channels/save', channels.handleChannelSave)
|
|
297
|
+
route('/api/mobile/v1/channels/delete', channels.handleChannelDelete)
|
|
245
298
|
// 技能目录(宿主 skill.list 的移动端只读代理)。桌面端敲「/」时命令候选菜单里
|
|
246
299
|
// 混着技能(@deepseek-ai/dsh-client-ui-skill 注册的 inputTriggers source),
|
|
247
300
|
// 手机端此前只有 commands.list 的命令目录,技能一个都看不到,见 mobile/skills.js。
|
|
@@ -268,6 +321,15 @@ function registerMobileRoutes(deps) {
|
|
|
268
321
|
route('/api/mobile/v1/file-references', references.handleFileReferences)
|
|
269
322
|
route('/api/mobile/v1/session-references', references.handleSessionReferences)
|
|
270
323
|
|
|
324
|
+
// 定时任务(宿主 ScheduleService 的移动端代理):目录、会话内 active 任务、运行记录、
|
|
325
|
+
// 删除。桌面端有一套完整的定时任务面板,手机端此前一条都看不到;服务同进程直查
|
|
326
|
+
// `ctx.get('schedule')`,宿主没有它时这四条统一回 503 而不是空列表,
|
|
327
|
+
// 见 mobile/schedule.js。`update` 这一批没接:手机端还没有编辑入口。
|
|
328
|
+
route('/api/mobile/v1/schedule/catalog', schedule.handleScheduleCatalog)
|
|
329
|
+
route('/api/mobile/v1/schedule/list', schedule.handleScheduleList)
|
|
330
|
+
route('/api/mobile/v1/schedule/history', schedule.handleScheduleHistory)
|
|
331
|
+
route('/api/mobile/v1/schedule/delete', schedule.handleScheduleDelete)
|
|
332
|
+
|
|
271
333
|
// 桌面端 设置 → 插件 的只读部署快照,由 dsh-host-plugin-inventory 拥有,
|
|
272
334
|
// 经 typertGateway 命名空间 pluginInventory 取,见 mobile/plugins.js。
|
|
273
335
|
route('/api/mobile/v1/plugins', plugins.handlePlugins)
|
|
@@ -288,6 +350,13 @@ function registerMobileRoutes(deps) {
|
|
|
288
350
|
route('/api/mobile/v1/upload/chunk', uploads.handleUploadChunk)
|
|
289
351
|
route('/api/mobile/v1/upload/finish', uploads.handleUploadFinish)
|
|
290
352
|
route('/api/mobile/v1/upload/abort', uploads.handleUploadAbort)
|
|
353
|
+
// 同一个收件目录的出口:上面四条只往里放,这两条报占用、按龄清掉(见 mobile/inbox-clean.js)。
|
|
354
|
+
// 落盘的文件不认识会话,所以没有「删掉这条对话就顺带清掉它传上来的文件」这条路 ——
|
|
355
|
+
// 唯一的出口只能按龄,口径写在那个文件头。
|
|
356
|
+
route('/api/mobile/v1/inbox/status', inboxClean.handleInboxStatus)
|
|
357
|
+
route('/api/mobile/v1/inbox/clear', inboxClean.handleInboxClear)
|
|
358
|
+
// 自动清理开关:默认关,打开后宿主每次启动顺手清一次(面板那颗按钮不受它管)。
|
|
359
|
+
route('/api/mobile/v1/inbox/auto', inboxClean.handleInboxAuto)
|
|
291
360
|
|
|
292
361
|
// 内置浏览器预览(App 侧「点 localhost 链接在内置浏览器里打开」的宿主端一半)。
|
|
293
362
|
//
|
package/lib/session-cleanup.cjs
CHANGED
|
@@ -46,6 +46,8 @@ const HEADER_MAX_BYTES = 64 * 1024
|
|
|
46
46
|
|
|
47
47
|
const { envelopeValue, unwrap } = require('./mobile/envelope.js')
|
|
48
48
|
const { rememberDeletedSessionIds } = require('./mobile/session-cache.js')
|
|
49
|
+
// 收件目录的启动清理与这里共用同一个「宿主刚起来」的窗口,实现见 mobile/inbox-clean.js。
|
|
50
|
+
const { clearInboxOnce } = require('./mobile/inbox-clean.js')
|
|
49
51
|
|
|
50
52
|
function asItems(response) {
|
|
51
53
|
const value = envelopeValue(response)
|
|
@@ -469,6 +471,12 @@ function installSessionCleanup(ctx) {
|
|
|
469
471
|
void pruneOrphanedArchivedSessionsOnce(ctx, (message) => {
|
|
470
472
|
try { ctx.logger?.info?.(message) } catch { /* 日志失败不影响修剪 */ }
|
|
471
473
|
})
|
|
474
|
+
// 收件目录(手机分享进来的文件)也在这里顺手清一次:它只进不出,不点按钮就没人管。
|
|
475
|
+
// 与上面那条同一个「宿主刚起来」的窗口,但两者互不依赖 —— 收件目录的口径只看 mtime,
|
|
476
|
+
// 碰不到任何会话日志(见 mobile/inbox-clean.js 的文件头)。
|
|
477
|
+
void clearInboxOnce((message) => {
|
|
478
|
+
try { ctx.logger?.info?.(message) } catch { /* 日志失败不影响清理 */ }
|
|
479
|
+
})
|
|
472
480
|
}
|
|
473
481
|
registerSessionCleanupTool(ctx)
|
|
474
482
|
}
|
package/lib/shared/sse-write.js
CHANGED
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* 事件 id 怎么算」「写不动时怎么等 drain」都只在这里,两条流只在调用点上声明自己的策略。
|
|
12
12
|
*
|
|
13
13
|
* 两条流刻意保留的差异(不是遗漏,改之前先看各自的理由):
|
|
14
|
-
* -
|
|
15
|
-
* 收尾的 `assistant/message` 带完整正文、重连 `replaySince`
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* - 丢帧口径:会话流的软水位只丢**明确可丢的增量**(只有 `assistant/chunk` 草稿:同一轮
|
|
15
|
+
* 收尾的 `assistant/message` 带完整正文、重连 `replaySince` 能补齐)。`session/queue`、
|
|
16
|
+
* `session/jobs`、`session/resync-required` 这些控制帧没有 seq,但绝不能丢;设备级流的
|
|
17
|
+
* 帧全是「有东西变了」的幂等信号,软水位丢弃后由调用方**断流重连**并用
|
|
18
|
+
* `replayKnownSignals()` 补发。由调用方传 `shedWhenBuffered`。
|
|
18
19
|
* - 写不动时:会话流 `await` 一次 drain(20s 超时再收尾);设备级流直接收尾让客户端
|
|
19
20
|
* 重连 —— 它没有 Last-Event-ID 续传,但连上时会 `replayKnownSignals()` 补发当前状态,
|
|
20
21
|
* 所以「断开重连」是它的正常收敛路径。
|
|
@@ -42,8 +43,18 @@ const SSE_CLOSE_BUFFERED_BYTES = 16 * 1024 * 1024;
|
|
|
42
43
|
*/
|
|
43
44
|
const SSE_DRAIN_TIMEOUT_MS = 20_000;
|
|
44
45
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* 默认丢帧口径:只有**明确可丢**的无 seq 增量帧可以丢;有 seq 的一律不能丢。
|
|
48
|
+
*
|
|
49
|
+
* 旧实现是「没有 seq 就丢」,结果把 `session/queue` / `session/jobs` /
|
|
50
|
+
* `session/resync-required` 这些控制帧一起丢了 —— 它们没有 seq,但丢了没有回放路径,
|
|
51
|
+
* 队列/任务面板会永久停在旧状态,gap 提示也到不了客户端。
|
|
52
|
+
*/
|
|
53
|
+
const SHEDDABLE_INCREMENTAL_TYPES = new Set(['assistant/chunk']);
|
|
54
|
+
const shedWithoutSeq = (payload) => {
|
|
55
|
+
if (!payload || !SHEDDABLE_INCREMENTAL_TYPES.has(payload.type)) return false;
|
|
56
|
+
return !Number.isFinite(payload.seq);
|
|
57
|
+
};
|
|
47
58
|
|
|
48
59
|
/** 每条记录后面补的 SSE 注释(客户端忽略内容,只用来把小 chunk 撑过 RN 的缓冲阈值)。 */
|
|
49
60
|
function ssePadding(bytes) {
|
package/lib/shell/device-ota.js
CHANGED
|
@@ -72,20 +72,70 @@ function clearSuperseded(version) {
|
|
|
72
72
|
if (!validVersion(version)) return
|
|
73
73
|
for (const release of availableOta()) if (compareVersion(version, release.version) >= 0) fs.rmSync(path.dirname(release.pkgDir), { recursive: true, force: true })
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* 把**正在跑的那一代**复制成一份能从盘上回退的包(`<root>/<version>/dsh-mobile-plugin`)。
|
|
77
|
+
*
|
|
78
|
+
* 为什么必须复制:包管理器装新版会把旧的 store 目录整个带走,旧代码从此只剩内存里那一份。
|
|
79
|
+
* 新版装上了却起不来时,外壳当场回滚到内存里那份(看着一切正常);可**下次重启**就只能拿
|
|
80
|
+
* 盘上那份坏的去装载,所有代都失败 = 插件整个不在(手机连不上、面板也没有)。回退目录本来
|
|
81
|
+
* 就是干这个的(`availableOta` 列出来,启动时按版本从高到低逐个试),缺的只是「有人往里放
|
|
82
|
+
* 一份能跑的」。
|
|
83
|
+
*
|
|
84
|
+
* 调用点是「真正要装之前」(见 `updater.js` 的 `beforeInstall`),所以拿到的就是那份还在跑、
|
|
85
|
+
* 还装得上的代码。`node_modules` 不复制:那是 profile 的依赖树,复制既慢又会被相对布局骗到;
|
|
86
|
+
* 落在 profile 里的这一代与 OTA 代走同一条解析链(见 `manager.js` 的 `moveOtaRootIntoProfile`)。
|
|
87
|
+
*
|
|
88
|
+
* 不抛错:备份是「多一层保险」,它失败不该挡住一次更新 —— 原因如实回给调用方去记日志。
|
|
89
|
+
*/
|
|
90
|
+
export function snapshotGeneration(sourceDir, version) {
|
|
91
|
+
if (!validVersion(version) || typeof sourceDir !== 'string' || !sourceDir) {
|
|
92
|
+
return { created: false, error: '版本号或目录不可用' }
|
|
93
|
+
}
|
|
94
|
+
const target = path.join(ROOT(), version, PACKAGE)
|
|
95
|
+
if (fs.existsSync(path.join(target, 'lib', 'impl.js'))) return { created: false, existing: true, dir: target }
|
|
96
|
+
let source = sourceDir
|
|
97
|
+
try {
|
|
98
|
+
source = fs.realpathSync(sourceDir)
|
|
99
|
+
} catch {
|
|
100
|
+
return { created: false, error: '要备份的那一代已经读不到了' }
|
|
101
|
+
}
|
|
102
|
+
if (!fs.existsSync(path.join(source, 'lib', 'impl.js'))) return { created: false, error: '要备份的那一代缺 lib/impl.js' }
|
|
103
|
+
fs.mkdirSync(ROOT(), { recursive: true })
|
|
104
|
+
// 先落到 `.staging-*`(`availableOta` 与 `sweepStaging` 都按这个前缀跳过/回收),
|
|
105
|
+
// 复制完整了再一次性改名为 `<version>`:半个包不能被当成一代。
|
|
106
|
+
const staging = fs.mkdtempSync(path.join(ROOT(), '.staging-fallback-'))
|
|
107
|
+
try {
|
|
108
|
+
fs.cpSync(source, path.join(staging, PACKAGE), {
|
|
109
|
+
recursive: true,
|
|
110
|
+
// 软链接按实体复制:旧布局里凭据包有时是「与插件包同级」的链接,照着链子复制会在旧目录
|
|
111
|
+
// 被删之后悬空 —— 这一份必须自己就装得起来,所以把链接指向的内容抄实。
|
|
112
|
+
dereference: true,
|
|
113
|
+
filter: entry => path.basename(entry) !== 'node_modules',
|
|
114
|
+
})
|
|
115
|
+
fs.rmSync(path.join(ROOT(), version), { recursive: true, force: true })
|
|
116
|
+
movePath(staging, path.join(ROOT(), version))
|
|
117
|
+
return { created: true, dir: target }
|
|
118
|
+
} catch (error) {
|
|
119
|
+
fs.rmSync(staging, { recursive: true, force: true })
|
|
120
|
+
return { created: false, error: String((error && error.message) || error) }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
75
123
|
export function createDeviceOta({ fetchImpl = fetch, installed = () => null, platform = PLATFORM, channel = CHANNEL, runtime = null } = {}) {
|
|
76
124
|
async function reconcileOfficial() {
|
|
77
125
|
const active = runtime?.status()
|
|
78
126
|
const officialVersion = installed()
|
|
79
127
|
if (!runtime || !validVersion(officialVersion)) return { switched: false, status: active ?? null }
|
|
80
128
|
if (active?.source === 'installed') {
|
|
81
|
-
|
|
129
|
+
// 只有「跑着的就是他盘上那一版」才算接管成功,这时才回收回退包。
|
|
130
|
+
// 换代失败时跑着的仍是旧版、盘上已是新版 —— 那份副本是下次重启唯一的活路,不能删。
|
|
131
|
+
if (active.version === officialVersion) clearSuperseded(officialVersion)
|
|
82
132
|
return { switched: false, status: active }
|
|
83
133
|
}
|
|
84
134
|
if (active?.source !== 'ota' || !validVersion(active.version) || compareVersion(officialVersion, active.version) < 0) {
|
|
85
135
|
return { switched: false, status: active ?? null }
|
|
86
136
|
}
|
|
87
137
|
const status = await runtime.reload()
|
|
88
|
-
if (status?.source === 'installed') clearSuperseded(officialVersion)
|
|
138
|
+
if (status?.source === 'installed' && status.version === officialVersion) clearSuperseded(officialVersion)
|
|
89
139
|
return { switched: status?.source === 'installed', status }
|
|
90
140
|
}
|
|
91
141
|
/** 问一次中转「我该装哪个版本」;没有就返回 null(不抛)。 */
|
package/lib/shell/manager.js
CHANGED
|
@@ -32,7 +32,7 @@ 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, configureOtaRoot } from './device-ota.js'
|
|
35
|
+
import { availableOta, configureOtaRoot, snapshotGeneration } from './device-ota.js'
|
|
36
36
|
import { movePath } from './ota-package.js'
|
|
37
37
|
import { dshHome } from '../shared/paths.js'
|
|
38
38
|
|
|
@@ -161,6 +161,20 @@ export function installedVersion() {
|
|
|
161
161
|
return dir ? readPackageVersion(path.join(dir, 'package.json')) : null
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* 装之前把**正在跑的那一代**备份进回退目录(换代的保险,见 `device-ota.js` 的
|
|
166
|
+
* `snapshotGeneration`:包管理器一跑就把旧目录带走了)。
|
|
167
|
+
*
|
|
168
|
+
* 只有「跑着的就是盘上装着的那一代」才备份 —— 跑 OTA 代时盘上那份不是活代码,复制它对不上号。
|
|
169
|
+
* 返回 `snapshotGeneration` 的结果,拿不到就回 null(备份失败不该挡住更新)。
|
|
170
|
+
*/
|
|
171
|
+
export function snapshotRunningGeneration(running) {
|
|
172
|
+
const dir = installedPackageDir()
|
|
173
|
+
if (!dir || !running || !running.version || running.source !== 'installed') return null
|
|
174
|
+
if (installedVersion() !== running.version) return null
|
|
175
|
+
return snapshotGeneration(dir, running.version)
|
|
176
|
+
}
|
|
177
|
+
|
|
164
178
|
/**
|
|
165
179
|
* 取版本号最高的代。
|
|
166
180
|
*
|
package/lib/shell/updater.js
CHANGED
|
@@ -31,7 +31,7 @@ import path from 'node:path'
|
|
|
31
31
|
import { createHash } from 'node:crypto'
|
|
32
32
|
import { createRequire } from 'node:module'
|
|
33
33
|
import { pathToFileURL } from 'node:url'
|
|
34
|
-
import { installedPackageDir, installedVersion, profileNodeModules } from './manager.js'
|
|
34
|
+
import { installedPackageDir, installedVersion, profileNodeModules, compareVersion } from './manager.js'
|
|
35
35
|
|
|
36
36
|
const require_ = createRequire(import.meta.url)
|
|
37
37
|
const { dshHome } = require_('../shared/paths.js')
|
|
@@ -82,7 +82,7 @@ function installedPackageDirSafe() {
|
|
|
82
82
|
*/
|
|
83
83
|
export function shellFingerprint(pkgDir) {
|
|
84
84
|
if (!pkgDir) return null
|
|
85
|
-
const files = ['lib/index.js', 'lib/shell/runtime.js', 'lib/shell/manager.js', 'lib/shell/updater.js']
|
|
85
|
+
const files = ['lib/index.js', 'lib/shell/runtime.js', 'lib/shell/manager.js', 'lib/shell/updater.js', 'lib/shell/device-ota.js', 'lib/shell/ota-package.js']
|
|
86
86
|
const hash = createHash('sha256')
|
|
87
87
|
let seen = 0
|
|
88
88
|
for (const rel of files) {
|
|
@@ -227,7 +227,7 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'rolled-back'])
|
|
|
227
227
|
* 装完了但盘上版本没变(市场报成功、文件没落),这里如实返回 `installed: false`,不让调用方
|
|
228
228
|
* 以为换过代码 —— 「状态说成功、代码没换」是这个仓库吃过最贵的一次亏。
|
|
229
229
|
*/
|
|
230
|
-
export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, onEvent = () => {}, packageManager = () => null, agents = () => null, now = Date.now } = {}) {
|
|
230
|
+
export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, onEvent = () => {}, packageManager = () => null, agents = () => null, beforeInstall = null, now = Date.now } = {}) {
|
|
231
231
|
const before = installedVersion()
|
|
232
232
|
// 装归实现自己(2026-10-11 起):盘上那一代里有 `update-install.cjs` 就走它。判定(有没有
|
|
233
233
|
// 新版、`link:` 来源不碰、有 agent 在跑就先别换文件)也一并交给它,别在这里抄第二份 ——
|
|
@@ -242,6 +242,8 @@ export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false
|
|
|
242
242
|
// 实现在代里,它没有宿主 ctx,只有外壳有。
|
|
243
243
|
packageManager: packageManager(),
|
|
244
244
|
agents: agents(),
|
|
245
|
+
// 装之前把正在跑的那一代备份走:包管理器一跑,旧目录就没了。
|
|
246
|
+
beforeInstall,
|
|
245
247
|
})
|
|
246
248
|
return {
|
|
247
249
|
installed: outcome.installed === true,
|
|
@@ -307,18 +309,163 @@ export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false
|
|
|
307
309
|
}
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
/**
|
|
313
|
+
* 换代尝试的记账:同一个版本自动只试一次。
|
|
314
|
+
*
|
|
315
|
+
* 一个起不来的新版每 10 分钟把手机抖一下没有任何意义 —— 第一次失败之后就该停下,等人按
|
|
316
|
+
* 那颗按钮(手动不受这个限制:那是「我知道,再来一次」)。进程重启后这份记录清零,
|
|
317
|
+
* 而重启本来就会走一遍正常装载,不需要它。
|
|
318
|
+
*/
|
|
319
|
+
const swapAttempts = new Map()
|
|
320
|
+
|
|
321
|
+
/** 同一时刻只跑一次更新:自动轮询与手按按钮撞一起时,第二个直接等第一次的结果。 */
|
|
322
|
+
let applying = null
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 一次完整的更新:装(若需要)→ 换代 → 落账。
|
|
326
|
+
*
|
|
327
|
+
* 手动与自动走的是同一个函数 —— 自动只是「没人按」的那一种(`auto: true`)。
|
|
328
|
+
* 账必须**成功失败都落**:这套东西最贵的一次教训是「状态说成功、代码没换」,第二贵的是
|
|
329
|
+
* 「失败了但没人记得」,面板与手机都靠这本账判结果,漏落一次它们就永远停在上一次。
|
|
330
|
+
*/
|
|
331
|
+
export function performUpdate(options = {}) {
|
|
332
|
+
if (applying) return applying
|
|
333
|
+
applying = runUpdate(options).finally(() => { applying = null })
|
|
334
|
+
return applying
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function runUpdate({ runtime, base, hostService = () => null, updateState, beforeInstall = null, log = () => {}, force = false, auto = false } = {}) {
|
|
338
|
+
const status = () => {
|
|
339
|
+
try { return runtime.status() ?? {} } catch { return {} }
|
|
340
|
+
}
|
|
341
|
+
const beforeVersion = installedVersion() ?? status().version ?? null
|
|
342
|
+
const runningBefore = status().version ?? null
|
|
343
|
+
let outcome
|
|
344
|
+
try {
|
|
345
|
+
outcome = await installIfNeeded(base, {
|
|
346
|
+
force,
|
|
347
|
+
beforeInstall,
|
|
348
|
+
packageManager: () => hostService('profileContext')?.packageManager ?? null,
|
|
349
|
+
agents: () => hostService('agents'),
|
|
350
|
+
})
|
|
351
|
+
} catch (error) {
|
|
352
|
+
// 装那步就炸了(注册表 / 网络 / 权限):账照样要落,否则面板停在上一轮、手机那边
|
|
353
|
+
// 只能等到超时才说得出「没成」。
|
|
354
|
+
const message = String(error?.message || error)
|
|
355
|
+
updateState.lastApply = {
|
|
356
|
+
installed: false,
|
|
357
|
+
skipped: false,
|
|
358
|
+
state: 'failed',
|
|
359
|
+
beforeVersion,
|
|
360
|
+
installedVersion: beforeVersion,
|
|
361
|
+
swapped: false,
|
|
362
|
+
restartRequired: false,
|
|
363
|
+
refreshRequired: false,
|
|
364
|
+
retryable: false,
|
|
365
|
+
failure: { code: 'install_failed', message },
|
|
366
|
+
}
|
|
367
|
+
updateState.appliedAt = new Date().toISOString()
|
|
368
|
+
updateState.lastError = message
|
|
369
|
+
throw error
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const disk = installedVersion()
|
|
373
|
+
// 要换代时换到**盘上那一份**:装成功了是它;「早就装上了、只是没换过来」也是它。
|
|
374
|
+
const newerOnDisk = disk !== null && runningBefore !== null && compareVersion(disk, runningBefore) > 0
|
|
375
|
+
let swapped = false
|
|
376
|
+
let swapFailure = null
|
|
377
|
+
let deferred = false
|
|
378
|
+
if (outcome.installed === true || newerOnDisk) {
|
|
379
|
+
const attempt = await swapTo(runtime, disk ?? runningBefore, { auto, log })
|
|
380
|
+
swapped = attempt.swapped
|
|
381
|
+
swapFailure = attempt.failure
|
|
382
|
+
deferred = attempt.deferred === true
|
|
383
|
+
}
|
|
384
|
+
const lastApply = {
|
|
385
|
+
...outcome,
|
|
386
|
+
installed: outcome.installed === true,
|
|
387
|
+
swapped,
|
|
388
|
+
state: swapFailure ? 'installed-not-swapped' : (outcome.state ?? (outcome.installed || outcome.skipped ? 'succeeded' : 'failed')),
|
|
389
|
+
failure: swapFailure ?? outcome.failure ?? null,
|
|
390
|
+
// 换代由外壳就地做,市场那套「装完要重启」在本项目一律不照做。只为如实上报。
|
|
391
|
+
restartRequired: false,
|
|
392
|
+
refreshRequired: false,
|
|
393
|
+
}
|
|
394
|
+
updateState.lastApply = lastApply
|
|
395
|
+
updateState.appliedAt = new Date().toISOString()
|
|
396
|
+
if (lastApply.installed && !swapFailure) updateState.lastError = null
|
|
397
|
+
if (swapFailure && !deferred) {
|
|
398
|
+
// 装上了、换不过来:抛给路由(桌面按 500 + `swap_failed` 说人话),账已经落了,
|
|
399
|
+
// 手机读 status 也能立刻判出结果,不必等到超时。
|
|
400
|
+
throw new Error(swapFailure.message)
|
|
401
|
+
}
|
|
402
|
+
return { ...lastApply, status: status() }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 换到盘上那一代。
|
|
407
|
+
*
|
|
408
|
+
* 失败只回错误、不抛:调用方要先落账再决定要不要抛。`auto` 为真时同一版本只试一次
|
|
409
|
+
* (见 `swapAttempts`),手动调用不受限制。
|
|
410
|
+
*/
|
|
411
|
+
async function swapTo(runtime, target, { auto = false, log = () => {} } = {}) {
|
|
412
|
+
const key = String(target ?? '')
|
|
413
|
+
const tried = swapAttempts.get(key) ?? 0
|
|
414
|
+
if (auto && tried >= 1) {
|
|
415
|
+
return {
|
|
416
|
+
swapped: false,
|
|
417
|
+
deferred: true,
|
|
418
|
+
failure: {
|
|
419
|
+
code: 'swap_deferred',
|
|
420
|
+
message: `盘上的 ${key} 已经自动试过一次换代、没成:重启一次 DSH 才会用它,或者再按一次更新重试。`,
|
|
421
|
+
},
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
swapAttempts.set(key, Math.max(tried, 1))
|
|
425
|
+
try {
|
|
426
|
+
const status = await runtime.reload()
|
|
427
|
+
swapAttempts.delete(key)
|
|
428
|
+
log('info', 'dsh-mobile-plugin: 已就地换到 %s(无需重启 DSH)', status?.version ?? key)
|
|
429
|
+
return { swapped: true, status }
|
|
430
|
+
} catch (error) {
|
|
431
|
+
return {
|
|
432
|
+
swapped: false,
|
|
433
|
+
failure: { code: 'swap_failed', message: `新版已经装到盘上(${key}),但换代码没成:${String(error?.message || error)}` },
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 盘上比运行中新:补一次换代。
|
|
440
|
+
*
|
|
441
|
+
* 什么时候会走到这里:上一次换代失败了(回滚到旧代继续跑,盘上已是新版),或者新版是别的
|
|
442
|
+
* 途径装进来的(市场界面、手工装)。这两种情况下盘上的代码不会自己生效 —— 而这个插件存在
|
|
443
|
+
* 的理由就是「不用重启 DSH」,所以它得自己补上这一步。
|
|
444
|
+
*/
|
|
445
|
+
export async function reconcileInstalledGeneration({ runtime, log = () => {} } = {}) {
|
|
446
|
+
let running = null
|
|
447
|
+
try { running = runtime.status()?.version ?? null } catch { return null }
|
|
448
|
+
const disk = installedVersion()
|
|
449
|
+
if (!disk || !running || compareVersion(disk, running) <= 0) return null
|
|
450
|
+
// 不在这里喊话:真去换时 `swapTo` 会记成功、失败那条下面自己记。卡住的机器每 10 分钟
|
|
451
|
+
// 一行「补一次换代」纯属噪声,而真相在 `lastApply`(面板「运行中 / 盘上」两列也在报)。
|
|
452
|
+
const attempt = await swapTo(runtime, disk, { auto: true, log })
|
|
453
|
+
if (attempt.failure && !attempt.deferred) log('warn', 'dsh-mobile-plugin: %s', attempt.failure.message)
|
|
454
|
+
return attempt.status ?? null
|
|
455
|
+
}
|
|
456
|
+
|
|
310
457
|
/** 自动更新开关的落盘文件:`<DSH_HOME>/storages/mobile-plugin/auto-update.json`。 */
|
|
311
458
|
function autoUpdateFile() {
|
|
312
459
|
return path.join(dshHome(), 'storages', 'mobile-plugin', 'auto-update.json')
|
|
313
460
|
}
|
|
314
461
|
|
|
315
|
-
/**
|
|
462
|
+
/** 未设置过默认自动更新;显式关闭保持关闭,配置损坏时不改动盘上插件。 */
|
|
316
463
|
export function autoUpdateEnabled() {
|
|
317
464
|
try {
|
|
318
465
|
const raw = JSON.parse(fs.readFileSync(autoUpdateFile(), 'utf8'))
|
|
319
466
|
return raw?.enabled === true
|
|
320
|
-
} catch {
|
|
321
|
-
return
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return error?.code === 'ENOENT'
|
|
322
469
|
}
|
|
323
470
|
}
|
|
324
471
|
|
package/lib/update-install.cjs
CHANGED
|
@@ -281,6 +281,8 @@ function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
|
|
|
281
281
|
* @param {string} options.profileNodeModules profile 的 `node_modules` 目录
|
|
282
282
|
* @param {string} options.version 要装的版本(注册表给的)
|
|
283
283
|
* @param {boolean} [options.force] true = 跳过「新发布先晾一会儿」的门槛
|
|
284
|
+
* @param {() => any} [options.beforeInstall] 真正跑包管理器**之前**调一次(外壳用它把正在跑的
|
|
285
|
+
* 那一代复制进回退目录)。包管理器一跑就把旧的 store 目录清掉了,晚一步没得复制。
|
|
284
286
|
* @returns {Promise<{installed, skipped, beforeVersion, installedVersion, source, command, output, failure}>}
|
|
285
287
|
*/
|
|
286
288
|
async function installLatest({
|
|
@@ -296,6 +298,7 @@ async function installLatest({
|
|
|
296
298
|
// 宿主发布的调用方式(`profileContext.packageManager`)。**优先用它**,理由见
|
|
297
299
|
// `normalizeInvocation` 的文件注释(版本对不上会把 profile 的 store 认错)。
|
|
298
300
|
packageManager = null,
|
|
301
|
+
beforeInstall = null,
|
|
299
302
|
onEvent = () => {},
|
|
300
303
|
} = {}) {
|
|
301
304
|
const beforeVersion = installedVersion(profileNodeModules, name);
|
|
@@ -356,6 +359,14 @@ async function installLatest({
|
|
|
356
359
|
);
|
|
357
360
|
}
|
|
358
361
|
|
|
362
|
+
// 备份正在跑的那一代:包的活就落在下面那一行 spawn 上,它一跑旧目录就没了。
|
|
363
|
+
// 备份失败不该挡住更新(那是多一层保险),所以这里吞掉它的异常,由调用方记日志。
|
|
364
|
+
if (typeof beforeInstall === 'function') {
|
|
365
|
+
try {
|
|
366
|
+
await beforeInstall({ beforeVersion, command: null });
|
|
367
|
+
} catch { /* 备份失败不挡更新 */ }
|
|
368
|
+
}
|
|
369
|
+
|
|
359
370
|
const spec = `${name}@${version}`;
|
|
360
371
|
const releaseAgeFlag = '--config.minimum-release-age=0';
|
|
361
372
|
const args = picked.manager === 'pnpm'
|