dsh-mobile-plugin 0.1.74 → 0.1.76

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.
@@ -640,6 +640,29 @@ function registerControlPlaneRoutes(deps) {
640
640
  }) })
641
641
 
642
642
 
643
+ // 本机调试 OTA:操作设备端插件实现,不经过公网控制面或中转路由。
644
+ registerRoute({ kind: 'exact', path: '/api/relay/update/ota/status', handler: localOnly('GET', async (_req, res) => {
645
+ const runtime = pluginRuntime()
646
+ if (!runtime || typeof runtime.deviceOta?.status !== 'function') return sendJson(res, 404, { ok: false, error: { code: 'ota_unavailable', message: '当前宿主不支持调试 OTA' } })
647
+ return sendJson(res, 200, { ok: true, data: runtime.deviceOta?.status() })
648
+ }) })
649
+ registerRoute({ kind: 'exact', path: '/api/relay/update/ota/check', handler: localOnly('POST', async (_req, res) => {
650
+ const runtime = pluginRuntime()
651
+ if (!runtime || typeof runtime.deviceOta?.check !== 'function') return sendJson(res, 404, { ok: false, error: { code: 'ota_unavailable', message: '当前宿主不支持调试 OTA' } })
652
+ try { return sendJson(res, 200, { ok: true, data: { update: await runtime.deviceOta?.check(), status: runtime.deviceOta?.status() } }) }
653
+ catch (error) { return sendJson(res, 500, { ok: false, error: { code: 'ota_check_failed', message: String(error?.message || error) } }) }
654
+ }) })
655
+ registerRoute({ kind: 'exact', path: '/api/relay/update/ota/apply', handler: localOnly('POST', async (_req, res) => {
656
+ const runtime = pluginRuntime()
657
+ if (!runtime || typeof runtime.deviceOta?.apply !== 'function') return sendJson(res, 404, { ok: false, error: { code: 'ota_unavailable', message: '当前宿主不支持调试 OTA' } })
658
+ try {
659
+ const outcome = await runtime.deviceOta?.apply()
660
+ return sendJson(res, 200, { ok: true, data: { ...outcome, status: runtime.deviceOta?.status() } })
661
+ } catch (error) {
662
+ return sendJson(res, 500, { ok: false, error: { code: 'ota_apply_failed', message: String(error?.message || error) } })
663
+ }
664
+ }) })
665
+
643
666
  // sidecar 自重启:换掉 worker 子进程 + facade 模块,宿主本身不重启。
644
667
  // 走 localOnly——和其余控制面一样,这是本机运维动作。
645
668
  registerRoute({ kind: 'exact', path: '/api/relay/restart-sidecar', handler: localOnly('POST', async (_req, res) => {
@@ -670,6 +693,9 @@ const WEB_ACCESS_ROUTES = new Set([
670
693
  '/api/relay/update/check',
671
694
  '/api/relay/update/apply',
672
695
  '/api/relay/update/auto',
696
+ '/api/relay/update/ota/status',
697
+ '/api/relay/update/ota/check',
698
+ '/api/relay/update/ota/apply',
673
699
  '/api/relay/restart-sidecar',
674
700
  ]);
675
701
 
@@ -31,11 +31,13 @@ const workspaces = require('../mobile/workspaces.js');
31
31
  const archiveClean = require('../mobile/archive-clean.js');
32
32
  const sessionLog = require('../mobile/session-log.js');
33
33
  const hostSettings = require('../mobile/host-settings.js');
34
+ const deepSeekAccount = require('../mobile/deepseek-account.js');
34
35
  const skills = require('../mobile/skills.js');
35
36
  const mobileCredentials = require('../mobile/credentials.js');
36
37
  const subagents = require('../mobile/subagents.js');
37
38
  const references = require('../mobile/references.js');
38
39
  const plugins = require('../mobile/plugins.js');
40
+ const uploads = require('../mobile/uploads.js');
39
41
  // 体检接口要自报身份:版本号只有一个来源,就是包自己的 package.json(与 relay/status.js 同)。
40
42
  const pkg = require('../../package.json');
41
43
  const desktopView = require('../desktop-view.cjs');
@@ -125,6 +127,7 @@ function registerMobileRoutes(deps) {
125
127
  route('/api/mobile/v1/sessions', sessions.handleSessions)
126
128
  route('/api/mobile/v1/session', sessions.handleSession)
127
129
  route('/api/mobile/v1/session/messages', sessions.handleMessages)
130
+ route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
128
131
  route('/api/mobile/v1/session/send', sessions.handleSend)
129
132
  route('/api/mobile/v1/commands', sessions.handleCommands)
130
133
  route('/api/mobile/v1/session/read', sessions.handleSessionRead)
@@ -188,6 +191,8 @@ function registerMobileRoutes(deps) {
188
191
  // 只能看到 App 自己的偏好项。两个宿主接口本身已脱敏(secrets 只给 path 与 set),
189
192
  // 插件再丢掉对手机没用的 schema 树,见 mobile/host-settings.js。
190
193
  route('/api/mobile/v1/host-settings', hostSettings.handleHostSettings)
194
+ // DeepSeek 账号只读投影:账户资料、钱包余额与官方用量/充值链接;不开放登录操作或凭据。
195
+ route('/api/mobile/v1/deepseek-account', deepSeekAccount.handleDeepSeekAccount)
191
196
  // 改设置:与桌面端同一个 settings.mutate(按路径寻址的 op + expectedRevision 乐观并发)。
192
197
  route('/api/mobile/v1/host-settings/mutate', hostSettings.handleHostSettingsMutate)
193
198
  route('/api/mobile/v1/llm/providers', hostSettings.handleLlmProviders)
@@ -221,6 +226,15 @@ function registerMobileRoutes(deps) {
221
226
  // 经 typertGateway 命名空间 pluginInventory 取,见 mobile/plugins.js。
222
227
  route('/api/mobile/v1/plugins', plugins.handlePlugins)
223
228
 
229
+ // 分享文件的落地通道(App 把手机分享进来的文件分片传上来,见 mobile/uploads.js)。
230
+ // 手机端没有「把文件交给宿主」的路:图片能进 session.send 的 base64 内容块,其它格式
231
+ // 一律进不去。四条端点只做「收字节 → 落盘到 ~/.dsh/mobile-inbox/<日期>/<文件名>」,
232
+ // 落盘路径由 App 写成 @提及 再随消息发给宿主,所以这里不认识工作区、也不认识会话。
233
+ route('/api/mobile/v1/upload/begin', uploads.handleUploadBegin)
234
+ route('/api/mobile/v1/upload/chunk', uploads.handleUploadChunk)
235
+ route('/api/mobile/v1/upload/finish', uploads.handleUploadFinish)
236
+ route('/api/mobile/v1/upload/abort', uploads.handleUploadAbort)
237
+
224
238
  // 内置浏览器预览(App 侧「点 localhost 链接在内置浏览器里打开」的宿主端一半)。
225
239
  //
226
240
  // 这是一条 **WebSocket 升级**路由,不是普通 HTTP 路由:手机原生模块按端口在环回监听、
@@ -0,0 +1,126 @@
1
+ /** 设备端临时插件 OTA:npm 保持正式来源,OTA 仅保存于 DSH home。 */
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { createRequire } from 'node:module'
6
+ import { dshHome } from '../shared/paths.js'
7
+ import { compareVersion } from './manager.js'
8
+
9
+ const require_ = createRequire(import.meta.url)
10
+ const zlib = require_('node:zlib')
11
+ const ROOT = () => path.join(dshHome(), 'plugin-ota')
12
+ const PACKAGE = 'dsh-mobile-plugin'
13
+ const RELAY = 'https://dsh-hub.wnluo.com/api/desktop-plugins/ota'
14
+ const CHANNEL = 'stable'
15
+ const PLATFORM = process.platform
16
+ function validVersion(value) { return typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value) }
17
+ function validPackageUrl(value) {
18
+ try { return new URL(value).protocol === 'https:' } catch { return false }
19
+ }
20
+ function availableOta() {
21
+ try {
22
+ return fs.readdirSync(ROOT(), { withFileTypes: true }).filter(item => item.isDirectory()).map(item => {
23
+ const pkgDir = path.join(ROOT(), item.name, PACKAGE)
24
+ const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'))
25
+ if (pkg.name !== PACKAGE || !validVersion(pkg.version) || !fs.existsSync(path.join(pkgDir, 'lib', 'impl.js'))) return null
26
+ return { version: pkg.version, entry: path.join(pkgDir, 'lib', 'impl.js'), dir: pkgDir, pkgDir, source: 'ota' }
27
+ }).filter(Boolean).sort((a, b) => compareVersion(b.version, a.version))
28
+ } catch { return [] }
29
+ }
30
+ function clearSuperseded(version) {
31
+ if (!validVersion(version)) return
32
+ for (const release of availableOta()) if (compareVersion(version, release.version) >= 0) fs.rmSync(path.dirname(release.pkgDir), { recursive: true, force: true })
33
+ }
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
+ export function createDeviceOta({ fetchImpl = fetch, installed = () => null, platform = PLATFORM, channel = CHANNEL, runtime = null } = {}) {
61
+ async function reconcileOfficial() {
62
+ const active = runtime?.status()
63
+ const officialVersion = installed()
64
+ if (!runtime || !validVersion(officialVersion)) return { switched: false, status: active ?? null }
65
+ if (active?.source === 'installed') {
66
+ clearSuperseded(officialVersion)
67
+ return { switched: false, status: active }
68
+ }
69
+ if (active?.source !== 'ota' || !validVersion(active.version) || compareVersion(officialVersion, active.version) < 0) {
70
+ return { switched: false, status: active ?? null }
71
+ }
72
+ const status = await runtime.reload()
73
+ if (status?.source === 'installed') clearSuperseded(officialVersion)
74
+ return { switched: status?.source === 'installed', status }
75
+ }
76
+ async function check() {
77
+ await reconcileOfficial()
78
+ const url = `${RELAY}/latest?platform=${encodeURIComponent(platform)}&channel=${encodeURIComponent(channel)}`
79
+ const response = await fetchImpl(url, { headers: { accept: 'application/json' } })
80
+ if (!response.ok) throw new Error(`OTA relay 返回 HTTP ${response.status}`)
81
+ const body = await response.json()
82
+ const release = body?.data
83
+ if (body?.ok !== true || !validVersion(release?.version) || release.platform !== platform || release.channel !== channel || !validPackageUrl(release.packageUrl)) throw new Error('OTA relay 没有提供匹配平台、stable channel、有效版本或 HTTPS 下载地址')
84
+ const installedVersion = installed()
85
+ const officialVersion = installedVersion
86
+ const ota = availableOta()[0] || null
87
+ const otaVersion = validVersion(release.version) ? release.version : null
88
+ const newest = [ota?.version, otaVersion].filter(validVersion).sort((a, b) => compareVersion(b, a))[0] || null
89
+ 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 }
91
+ }
92
+ async function apply() {
93
+ const info = await check()
94
+ if (info.otaVersion && compareVersion(info.otaVersion, info.version) === 0) return { installed: true, version: info.version, source: 'ota', alreadyDownloaded: true }
95
+ if (validVersion(info.officialVersion) && compareVersion(info.officialVersion, info.version) >= 0) return { installed: false, reason: 'npm-current-or-newer', version: info.officialVersion }
96
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-plugin-ota-'))
97
+ try {
98
+ const response = await fetchImpl(info.packageUrl)
99
+ if (!response.ok) throw new Error(`OTA 下载失败:HTTP ${response.status}`)
100
+ const version = unpackTarGz(Buffer.from(await response.arrayBuffer()), staging)
101
+ if (version !== info.version) throw new Error(`OTA 版本不匹配:预期 ${info.version},实际 ${version}`)
102
+ const releaseDir = path.join(ROOT(), version)
103
+ fs.mkdirSync(ROOT(), { recursive: true })
104
+ fs.rmSync(releaseDir, { recursive: true, force: true })
105
+ fs.renameSync(staging, releaseDir)
106
+ return { installed: true, version, source: 'ota' }
107
+ } catch (error) {
108
+ fs.rmSync(staging, { recursive: true, force: true })
109
+ throw error
110
+ }
111
+ }
112
+ const status = () => {
113
+ const installedVersion = installed()
114
+ const active = runtime?.status() ?? null
115
+ 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 }
117
+ }
118
+ async function activate() {
119
+ const outcome = await apply()
120
+ if (!outcome.installed || !runtime) return { ...outcome, status: runtime?.status() ?? null }
121
+ const next = await runtime.reload(outcome.version)
122
+ return { installed: true, version: outcome.version, status: next }
123
+ }
124
+ return { status, check, apply: activate, reconcileOfficial }
125
+ }
126
+ export { availableOta, clearSuperseded }
@@ -5,10 +5,9 @@
5
5
  * DSH**(Node 缓存 realpath,换 profile 链接必须重启进程)。手机那边的主机是 systemd
6
6
  * 拉起来的,重启一条命令的事;桌面这边用户得自己退出再打开,而他不想每次都做这件事。
7
7
  *
8
- * ## 2026-10 改:代从哪儿来(从「OTA 写进 .live」改成「市场装进 profile」)
8
+ * ## 2026-10 改:npm 正式代与紧急 OTA 代并存
9
9
  *
10
- * 从前一代是 OTA 下载、解包、写进 `<pkg>/lib/.live/<releaseId>/`;分发交给 DSH 插件市场
11
- * 之后那套没了,但**换代的判据反而更简单**:市场在 profile 里跑 pnpm,装新版会把
10
+ * 正式版本由 DSH 插件市场装进 profile;紧急 OTA 将完整包落在 `$DSH_HOME/plugin-ota/`,两者都由代际装载器切换。正式版本安装仍通过 profile 里的 pnpm 完成,安装新版会把
12
11
  * `<profile>/node_modules/dsh-mobile-plugin` 这个链接指向**一个全新的 store 目录**:
13
12
  *
14
13
  * <profile>/node_modules/dsh-mobile-plugin -> .pnpm/dsh-mobile-plugin@0.1.58/node_modules/dsh-mobile-plugin
@@ -33,6 +32,7 @@ import fs from 'node:fs'
33
32
  import { createRequire } from 'node:module'
34
33
  import path from 'node:path'
35
34
  import { fileURLToPath, pathToFileURL } from 'node:url'
35
+ import { availableOta } from './device-ota.js'
36
36
 
37
37
  /** CJS 模块缓存。实现树里 `lib/relay`、`lib/routes`、`lib/mobile` 都是 CJS。 */
38
38
  const require_ = createRequire(import.meta.url)
@@ -100,18 +100,20 @@ function readPackageVersion(file) {
100
100
  /**
101
101
  * 列出所有可用的代。
102
102
  *
103
- * 两个来源:
103
+ * 三种来源:
104
104
  * · `bundled` —— 外壳自己所在的包(进程启动时那一代)。它可能在运行期间被市场的 pnpm
105
105
  * 整个换掉甚至删掉,所以**盘上读不到就列不出来**;那种时候只剩内存里那份
106
106
  * (`imported` 里缓存着),回滚也只能靠它。
107
- * · `installed` —— profile 里当前装着的那一份(`node_modules/dsh-mobile-plugin` 的
107
+ * · `installed` —— profile 里当前装着的正式版本(`node_modules/dsh-mobile-plugin` 的
108
108
  * realpath)。市场装新版之后这里立刻就是新版本,而且它的 realpath 每次都可能不同,
109
109
  * 天然绕开 ESM 缓存(这正是当年要靠 `.live/<releaseId>` 才能拿到的东西)。
110
+ * · `ota` —— `$DSH_HOME/plugin-ota/<version>/dsh-mobile-plugin` 的完整紧急包;正式版成功
111
+ * 接管后才清理已覆盖的 OTA,以便激活失败时重启仍能从磁盘回退。
110
112
  *
111
113
  * 只做「结构齐不齐」的粗筛:`package.json` 与 `lib/impl.js` 缺一不可。半份拷贝(安装被
112
114
  * 打断)如果被当成可用代,下次换代就会用一份残缺的代码上线。
113
115
  *
114
- * @returns {Array<{version: string, entry: string, dir: string|null, pkgDir: string, source: 'bundled'|'installed'}>}
116
+ * @returns {Array<{version: string, entry: string, dir: string|null, pkgDir: string, source: 'bundled'|'installed'|'ota'}>}
115
117
  */
116
118
  export function listGenerations() {
117
119
  const generations = []
@@ -127,6 +129,8 @@ export function listGenerations() {
127
129
  generations.push({ version, entry, dir: installed, pkgDir: installed, source: 'installed' })
128
130
  }
129
131
  }
132
+ const otaGenerations = availableOta()
133
+ generations.push(...otaGenerations)
130
134
  return generations
131
135
  }
132
136
 
@@ -158,30 +162,23 @@ export function installedVersion() {
158
162
  /**
159
163
  * 取版本号最高的代。
160
164
  *
161
- * `preferVersion` 命中时优先(用于回滚到指定的旧代)。**版本号相同时取装进来的那一代**
162
- * (`source: 'live'`),理由见下面 `delta === 0` 那一段 —— 这不是审美问题,是「同样报
163
- * 0.1.16,一个跑新代码一个跑旧代码」的分叉点。
165
+ * `preferVersion` 命中时优先(用于回滚到指定的旧代)。**版本号相同时优先 installed,其次 ota,最后 bundled**;来源与目录也参与运行时代身份判定,不能只比版本号。
164
166
  */
165
- export function resolveGeneration(preferVersion) {
166
- const generations = listGenerations()
167
- if (!generations.length) return null
168
- if (preferVersion) {
169
- const preferred = generations.find(item => item.version === preferVersion)
170
- if (preferred) return preferred
171
- }
172
- return generations.reduce((best, item) => {
173
- const delta = compareVersion(item.version, best.version)
174
- if (delta > 0) return item
175
- // 同版本时必须取 `installed`。基础代(包内 `lib/impl.js`)的路径是**固定**的,而
176
- // `importGeneration` 的 `?v=` 只骗得过**入口** —— 入口里 `require('./relay/…')` 那些
177
- // CJS 子模块按解析后的真实路径命中缓存,query 传不下去;于是「profile 里的插件包被
178
- // pnpm 换过版本」之后再激活固定路径那一代,拿到的仍是进程启动时那份**旧**实现。
179
- // 实测过:完整安装之后再自更新,接口回 `installed: true, version: <新版>`,而实际跑
180
- // 的还是旧代码 —— 状态说成功、代码没换,是最坏的那种失败。
181
- // `installed` 那一代的 realpath 是新目录 = 新 URL,整棵树必然读到新代码。
182
- if (delta === 0 && item.source === 'installed' && best.source !== 'installed') return item
183
- return best
167
+ export function resolveGenerations(preferVersion) {
168
+ const generations = listGenerations().sort((a, b) => {
169
+ const delta = compareVersion(b.version, a.version)
170
+ if (delta !== 0) return delta
171
+ const priority = { installed: 2, ota: 1, bundled: 0 }
172
+ return priority[b.source] - priority[a.source]
184
173
  })
174
+ if (!preferVersion) return generations
175
+ const preferred = generations.find(item => item.version === preferVersion)
176
+ return preferred ? [preferred, ...generations.filter(item => item !== preferred)] : generations
177
+ }
178
+
179
+ /** 取优先级最高的一代;同版正式安装胜过 OTA,再胜过随包版本。 */
180
+ export function resolveGeneration(preferVersion) {
181
+ return resolveGenerations(preferVersion)[0] ?? null
185
182
  }
186
183
 
187
184
  /**
@@ -241,12 +238,14 @@ function evictCommonJsTree(roots) {
241
238
  * import 之前必须先把这棵树的 CJS 缓存清掉(见 `evictCommonJsTree`)。
242
239
  */
243
240
  const imported = new Map()
241
+ const generationCacheKey = generation => JSON.stringify([generation.source, generation.version, generation.entry])
244
242
 
245
243
  /** `?v=` 里的序号:同一毫秒内连换两代时,光有时间戳会撞进同一个 URL。 */
246
244
  let importSeq = 0
247
245
 
248
246
  export async function importGeneration(generation) {
249
- const cached = imported.get(generation.version)
247
+ const cacheKey = generationCacheKey(generation)
248
+ const cached = imported.get(cacheKey)
250
249
  if (cached) return cached
251
250
  // 先把 CJS 缓存清干净再 import,否则「入口是新的、子模块还是旧的」,等于换了个半成品上去。
252
251
  evictCommonJsTree(evictionRoots(generation))
@@ -268,7 +267,7 @@ export async function importGeneration(generation) {
268
267
  inject: Array.isArray(mod?.inject) ? mod.inject : null,
269
268
  hostPath: generation.dir,
270
269
  }
271
- imported.set(generation.version, loaded)
270
+ imported.set(cacheKey, loaded)
272
271
  return loaded
273
272
  }
274
273
 
@@ -279,6 +278,6 @@ export async function importGeneration(generation) {
279
278
  * 重读目录,所以这里只需要把该版本可能存在的旧缓存丢掉,强制下次 import 读新文件。
280
279
  */
281
280
  export function invalidateGeneration(version) {
282
- if (version) imported.delete(version)
283
- else imported.clear()
281
+ if (!version) return imported.clear()
282
+ for (const [key, loaded] of imported) if (loaded.version === version) imported.delete(key)
284
283
  }
@@ -19,7 +19,7 @@
19
19
  * 卡住的请求不该拖住一次修复性更新)。
20
20
  */
21
21
 
22
- import { importGeneration, resolveGeneration } from './manager.js'
22
+ import { importGeneration, resolveGeneration, resolveGenerations } from './manager.js'
23
23
 
24
24
  /** 换代前等在飞请求归零的上限。 */
25
25
  const DRAIN_TIMEOUT_MS = 2000
@@ -125,7 +125,8 @@ export function createRuntime() {
125
125
  swapping = (async () => {
126
126
  const target = resolveGeneration(preferVersion)
127
127
  if (!target) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
128
- if (current && target.version === current.version) return status()
128
+ // 同版本也可能从 OTA 切到 npm:来源与 realpath 不同就必须重新装载。
129
+ if (current && target.version === current.version && target.source === current.source && target.dir === current.hostPath) return status()
129
130
 
130
131
  await drain()
131
132
  const oldFiber = fiber
@@ -184,17 +185,28 @@ export function createRuntime() {
184
185
  return swapping
185
186
  }
186
187
 
187
- /** 第一次激活。 */
188
+ /** 第一次激活;最高代装载失败时按优先级尝试保留的回退代。 */
188
189
  async function start(context) {
189
190
  ctx = context
190
- const target = resolveGeneration()
191
- if (!target) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
192
- const { loaded, fiber: startedFiber } = await activate(target)
193
- fiber = startedFiber
194
- loadedCurrent = loaded
195
- current = describe(loaded, target.dir)
196
- activatedAt = new Date().toISOString()
197
- return status()
191
+ const candidates = resolveGenerations()
192
+ if (!candidates.length) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
193
+ const failures = []
194
+ for (const target of candidates) {
195
+ try {
196
+ const { loaded, fiber: startedFiber } = await activate(target)
197
+ fiber = startedFiber
198
+ loadedCurrent = loaded
199
+ current = describe(loaded, target.dir)
200
+ activatedAt = new Date().toISOString()
201
+ lastError = failures.length
202
+ ? `${failures.join(';')};已回退到 ${target.version}(${target.source})`
203
+ : null
204
+ return status()
205
+ } catch (error) {
206
+ failures.push(`装载 ${target.version}(${target.source})失败:${error?.message || error}`)
207
+ }
208
+ }
209
+ throw new Error(`所有可用插件实现均装载失败:${failures.join(';')}`)
198
210
  }
199
211
 
200
212
  /** 等在飞请求结束(最多 `DRAIN_TIMEOUT_MS`)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
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",