dsh-mobile-plugin 0.1.97 → 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/lib/index.js +23 -29
- package/lib/shell/device-ota.js +52 -2
- package/lib/shell/manager.js +15 -1
- package/lib/shell/updater.js +150 -3
- package/lib/update-install.cjs +11 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -26,7 +26,7 @@ import path from 'node:path'
|
|
|
26
26
|
import { fileURLToPath } from 'node:url'
|
|
27
27
|
import { createRuntime } from './shell/runtime.js'
|
|
28
28
|
import * as updater from './shell/updater.js'
|
|
29
|
-
import { installedPackageDir, installedVersion, profileNodeModules } from './shell/manager.js'
|
|
29
|
+
import { installedPackageDir, installedVersion, profileNodeModules, snapshotRunningGeneration } from './shell/manager.js'
|
|
30
30
|
import { createDeviceOta } from './shell/device-ota.js'
|
|
31
31
|
|
|
32
32
|
export const name = 'dsh-mobile-plugin'
|
|
@@ -81,33 +81,25 @@ export function apply(ctx) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* 手动与自动走的是同一条路:手动只是「现在就做一次」。装的那一步报的 `restartRequired` 一律
|
|
87
|
-
* 不照做 —— 外壳自己换代码,这正是这个文件存在的全部理由。
|
|
84
|
+
* 装之前把正在跑的那一代备份进回退目录:包管理器一跑就没得备份了。失败不挡更新,只记一句。
|
|
88
85
|
*/
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
86
|
+
const snapshotRunning = () => {
|
|
87
|
+
const status = runtime.status()
|
|
88
|
+
const result = snapshotRunningGeneration(status)
|
|
89
|
+
if (result?.created) log('info', 'dsh-mobile-plugin: 更新前已把 %s 复制进回退目录', status.version)
|
|
90
|
+
else if (result?.error) log('warn', 'dsh-mobile-plugin: 回退副本没做成:%s', result.error)
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 装新版(实现自己装,见 `shell/updater.js`),装完**就地换代**。编排、落账与「换不过来
|
|
96
|
+
* 就重试」都在 `updater.performUpdate` 里 —— 入口只留装载逻辑,行数钉着。
|
|
97
|
+
*/
|
|
98
|
+
async function applyUpdate({ force = false, auto = false } = {}) {
|
|
99
|
+
return updater.performUpdate({
|
|
100
|
+
runtime, base, hostService, updateState, force, auto, log,
|
|
101
|
+
beforeInstall: snapshotRunning,
|
|
97
102
|
})
|
|
98
|
-
let swapped = false
|
|
99
|
-
let status = runtime.status()
|
|
100
|
-
if (outcome.installed) {
|
|
101
|
-
status = await runtime.reload()
|
|
102
|
-
swapped = true
|
|
103
|
-
log('info', 'dsh-mobile-plugin: 已就地换到 %s(无需重启 DSH)', status.version)
|
|
104
|
-
} else if (outcome.state && outcome.state !== 'succeeded') {
|
|
105
|
-
log('warn', 'dsh-mobile-plugin: 更新没有成功(%s)%s', outcome.state, outcome.failure?.message || '')
|
|
106
|
-
}
|
|
107
|
-
updateState.lastApply = { ...outcome, swapped }
|
|
108
|
-
updateState.appliedAt = new Date().toISOString()
|
|
109
|
-
if (outcome.installed) updateState.lastError = null
|
|
110
|
-
return { ...outcome, swapped, status }
|
|
111
103
|
}
|
|
112
104
|
|
|
113
105
|
// 换代前要知道有没有请求在飞(见 runtime.drain)。宿主 webServer 没有中间件机制,所以
|
|
@@ -165,10 +157,12 @@ export function apply(ctx) {
|
|
|
165
157
|
.start(ctx)
|
|
166
158
|
.then(() => {
|
|
167
159
|
log('info', 'dsh-mobile-plugin: 已装载 %s(来源 %s)', runtime.status().version, runtime.status().source)
|
|
168
|
-
// 每 10 分钟让已落盘的正式版接管 OTA
|
|
160
|
+
// 每 10 分钟让已落盘的正式版接管 OTA,并补上「盘上比运行中新」的那一次换代;
|
|
161
|
+
// 自动安装仍由开关控制。补换代不受开关管:它不下载任何东西,只是让已经在盘上的代码生效。
|
|
169
162
|
const timer = setInterval(() => {
|
|
170
163
|
deviceOta.reconcileOfficial()
|
|
171
|
-
.then(() => updater.
|
|
164
|
+
.then(() => updater.reconcileInstalledGeneration({ runtime, log }))
|
|
165
|
+
.then(() => updater.autoUpdateEnabled() ? applyUpdate({ auto: true }) : null)
|
|
172
166
|
.catch(error => {
|
|
173
167
|
updateState.lastError = String(error?.message || error)
|
|
174
168
|
log('warn', 'dsh-mobile-plugin: 更新检查失败:%s', updateState.lastError)
|
|
@@ -179,7 +173,7 @@ export function apply(ctx) {
|
|
|
179
173
|
// 启动后延后一次:别和绑端口、连隧道抢带宽。
|
|
180
174
|
if (updater.autoUpdateEnabled()) {
|
|
181
175
|
const kick = setTimeout(() => {
|
|
182
|
-
applyUpdate().catch(error => {
|
|
176
|
+
applyUpdate({ auto: true }).catch(error => {
|
|
183
177
|
updateState.lastError = String(error?.message || error)
|
|
184
178
|
log('warn', 'dsh-mobile-plugin: 启动时的自动更新失败:%s', updateState.lastError)
|
|
185
179
|
})
|
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,6 +309,151 @@ 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')
|
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'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.98",
|
|
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",
|