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 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
- * 装新版(实现自己装,见 `shell/updater.js`),装完**就地换代**。
85
- *
86
- * 手动与自动走的是同一条路:手动只是「现在就做一次」。装的那一步报的 `restartRequired` 一律
87
- * 不照做 —— 外壳自己换代码,这正是这个文件存在的全部理由。
84
+ * 装之前把正在跑的那一代备份进回退目录:包管理器一跑就没得备份了。失败不挡更新,只记一句。
88
85
  */
89
- async function applyUpdate({ force = false } = {}) {
90
- const outcome = await updater.installIfNeeded(base(), {
91
- force,
92
- // 宿主发布的包管理器优先(PATH 上那个的版本可能和 profile 的 store 对不上,实测
93
- // 10.x 装 11.x 的 profile 直接 `ERR_PNPM_UNEXPECTED_STORE`);agents 是「有对话在跑
94
- // 就别换文件」那道闸的判据。自动更新永远不带 force。
95
- packageManager: () => hostService('profileContext')?.packageManager ?? null,
96
- agents: () => hostService('agents'),
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.autoUpdateEnabled() ? applyUpdate() : null)
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
  })
@@ -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
- clearSuperseded(officialVersion)
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(不抛)。 */
@@ -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
  *
@@ -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')
@@ -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.97",
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",