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.
- package/README.md +15 -1
- package/lib/index.js +15 -15
- package/lib/mobile/archive-clean.js +24 -6
- package/lib/mobile/attachments.js +15 -2
- package/lib/mobile/deepseek-account.js +125 -0
- package/lib/mobile/host-bridge/endpoints.js +5 -0
- package/lib/mobile/session-cache.js +36 -0
- package/lib/mobile/session-send.js +30 -1
- package/lib/mobile/sessions.js +10 -10
- package/lib/mobile/uploads.js +220 -0
- package/lib/mobile/workspaces.js +3 -2
- package/lib/panel/index.html +8 -8
- package/lib/routes/control-plane.js +26 -0
- package/lib/routes/mobile-api.js +14 -0
- package/lib/shell/device-ota.js +126 -0
- package/lib/shell/manager.js +31 -32
- package/lib/shell/runtime.js +23 -11
- package/package.json +1 -1
|
@@ -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
|
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -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 }
|
package/lib/shell/manager.js
CHANGED
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
* DSH**(Node 缓存 realpath,换 profile 链接必须重启进程)。手机那边的主机是 systemd
|
|
6
6
|
* 拉起来的,重启一条命令的事;桌面这边用户得自己退出再打开,而他不想每次都做这件事。
|
|
7
7
|
*
|
|
8
|
-
* ## 2026-10
|
|
8
|
+
* ## 2026-10 改:npm 正式代与紧急 OTA 代并存
|
|
9
9
|
*
|
|
10
|
-
*
|
|
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
|
|
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
|
|
166
|
-
const generations = listGenerations()
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
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
|
|
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(
|
|
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.
|
|
283
|
-
|
|
281
|
+
if (!version) return imported.clear()
|
|
282
|
+
for (const [key, loaded] of imported) if (loaded.version === version) imported.delete(key)
|
|
284
283
|
}
|
package/lib/shell/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
191
|
-
if (!
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
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",
|