dsh-mobile-plugin 0.1.57 → 0.1.59
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 +45 -4
- package/lib/impl.js +3 -0
- package/lib/index.js +180 -0
- package/lib/panel/index.html +2 -2
- package/lib/routes/control-plane.js +138 -12
- package/lib/shell/manager.js +284 -0
- package/lib/shell/runtime.js +249 -0
- package/lib/shell/updater.js +267 -0
- package/package.json +3 -3
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 代际运行时:把「外壳」和「某一代实现」缝在一起,并负责换代那一刻的秩序。
|
|
3
|
+
*
|
|
4
|
+
* 外壳(`lib/index.js`)只做两件事:启动时激活版本最高的一代,以及把换代请求转给这里。
|
|
5
|
+
* 实现侧完全不知道自己是被外壳装进来的 —— 它拿到的是一个普通的 cordis `Context`,
|
|
6
|
+
* 照常 `ctx.effect(...)` / `ctx.webServer.register(...)` / `ctx.reflect.provide(...)`。
|
|
7
|
+
*
|
|
8
|
+
* ## 换代为什么必须串行、而且先停后起
|
|
9
|
+
*
|
|
10
|
+
* 一个代在 `apply` 里会往宿主注册**同一条路径**的路由(`ctx.webServer.register` 撞路径会抛
|
|
11
|
+
* `webserver: duplicate exact route`),还会绑 sidecar、起镜像推送。所以不能「先装新的再
|
|
12
|
+
* 卸旧的」——只能先把旧代的 fiber dispose 掉(它的每个 `ctx.effect` 会反向执行清理:路由
|
|
13
|
+
* 摘掉、子进程收掉),再装新的。这个文件里的 `swapping` 锁就是保证同一时刻只有一次换代。
|
|
14
|
+
*
|
|
15
|
+
* ## 让路
|
|
16
|
+
*
|
|
17
|
+
* 手机那边随时可能有一个请求在读事件流。直接 dispose 会把 `ctx.effect` 的清理跑在别人的
|
|
18
|
+
* 请求中间。所以换代前先等 `inFlight` 归零,最多等 `DRAIN_TIMEOUT_MS`(等不到也照换——
|
|
19
|
+
* 卡住的请求不该拖住一次修复性更新)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { importGeneration, resolveGeneration } from './manager.js'
|
|
23
|
+
|
|
24
|
+
/** 换代前等在飞请求归零的上限。 */
|
|
25
|
+
const DRAIN_TIMEOUT_MS = 2000
|
|
26
|
+
/** 等一代装载完成的上限。装载卡住(依赖永远不来)时不能把整个外壳拖死。 */
|
|
27
|
+
const ACTIVATE_TIMEOUT_MS = 20000
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 代没有声明 `inject` 时的兜底(只有测试里的假代会走这里)。
|
|
31
|
+
*
|
|
32
|
+
* 真实实现 `lib/impl.js` 一定声明 inject,而且是按运行时二选一的,所以这里**不能**
|
|
33
|
+
* 拿它去覆盖代自己的声明 —— 那会把旧运行时要用的 `apiProxy` 抹掉。
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_INJECT = ['webServer', 'sessions']
|
|
36
|
+
|
|
37
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
38
|
+
|
|
39
|
+
export function createRuntime() {
|
|
40
|
+
/** 外壳那份 `apply` 拿到的 context。装载完成前是 null。 */
|
|
41
|
+
let ctx = null
|
|
42
|
+
/** 当前活跃的 fiber(旧代)。 */
|
|
43
|
+
let fiber = null
|
|
44
|
+
/**
|
|
45
|
+
* 当前这一代**已经 import 进来的模块本体**。
|
|
46
|
+
*
|
|
47
|
+
* 回滚必须用它,不能靠「按版本号再挑一次」:市场装新版会把旧的 store 目录整个带走,
|
|
48
|
+
* 那一刻盘上已经找不到旧代了 —— 照版本号找会拿到 null,于是回滚失败、插件彻底没有
|
|
49
|
+
* 活跃实现(手机连不上,日志里只剩一行 pending)。模块本体在内存里,重新 `apply`
|
|
50
|
+
* 一次就是完整的旧代。
|
|
51
|
+
*/
|
|
52
|
+
let loadedCurrent = null
|
|
53
|
+
/** 当前代的描述(`{version, source, dir, hostPath}` 的子集)。 */
|
|
54
|
+
let current = null
|
|
55
|
+
/** 上一次成功激活过的代,供回滚。 */
|
|
56
|
+
let previous = null
|
|
57
|
+
/** 串行化所有换代动作。 */
|
|
58
|
+
let swapping = null
|
|
59
|
+
/** 在飞请求计数(`beginRequest` / `endRequest` 维护)。 */
|
|
60
|
+
let inFlight = 0
|
|
61
|
+
let lastError = null
|
|
62
|
+
let activatedAt = null
|
|
63
|
+
let detached = false
|
|
64
|
+
|
|
65
|
+
/** 对外状态快照。控制面接口直接把它交给客户端。 */
|
|
66
|
+
function status() {
|
|
67
|
+
return {
|
|
68
|
+
version: current?.version ?? null,
|
|
69
|
+
source: current?.source ?? null,
|
|
70
|
+
previousVersion: previous?.version ?? null,
|
|
71
|
+
activatedAt,
|
|
72
|
+
lastError,
|
|
73
|
+
inFlight,
|
|
74
|
+
swapping: swapping !== null,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 装载一代并等它真正 ACTIVE。失败抛错,由调用方决定是否回滚。 */
|
|
79
|
+
async function activate(generation) {
|
|
80
|
+
return activateLoaded(await importGeneration(generation))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 把已经 import 进来的那代装上去(回滚走这条)。
|
|
85
|
+
*
|
|
86
|
+
* 回滚**不重新 import**:新代装失败时旧代的目录可能已经被市场删掉了,重新读盘会
|
|
87
|
+
* MODULE_NOT_FOUND;而旧代的模块本体一直在内存里,重新 apply 一次就够了。
|
|
88
|
+
*/
|
|
89
|
+
async function activateLoaded(loaded) {
|
|
90
|
+
let timer = null
|
|
91
|
+
// 用 `{ inject, apply }` 而不是裸函数:cordis 只有在**声明过** inject 的 fiber 上
|
|
92
|
+
// 才允许 `ctx.webServer` 这种取值(裸函数会以 "cannot get property without inject"
|
|
93
|
+
// 失败)。代里的实现自己声明了 `inject`,但它作为插件对象被装进来时那份声明不会
|
|
94
|
+
// 自动生效,所以外壳在这里转交给 cordis。
|
|
95
|
+
//
|
|
96
|
+
// 用代自己声明的 inject,不写死:`lib/impl.js` 的 inject 是**按运行时二选一**的
|
|
97
|
+
// (旧运行时多一个 `apiProxy`,0.2.0 没有)。写死成 `['webServer','sessions']` 会让
|
|
98
|
+
// 旧运行时上读 `ctx.apiProxy` 抛 "cannot get property apiProxy without inject"。
|
|
99
|
+
// 测试里的假代没声明 inject 时退回外壳默认值。
|
|
100
|
+
const inject = Array.isArray(loaded.inject) && loaded.inject.length > 0 ? loaded.inject : DEFAULT_INJECT
|
|
101
|
+
const result = await Promise.race([
|
|
102
|
+
Promise.resolve().then(() => ctx.plugin({ inject, apply: loaded.apply })),
|
|
103
|
+
new Promise((_, reject) => {
|
|
104
|
+
timer = setTimeout(() => reject(new Error(`装载 ${loaded.version} 超时(${ACTIVATE_TIMEOUT_MS}ms):它可能卡在等一个永远不来的依赖服务上`)), ACTIVATE_TIMEOUT_MS)
|
|
105
|
+
}),
|
|
106
|
+
]).finally(() => {
|
|
107
|
+
if (timer) clearTimeout(timer)
|
|
108
|
+
})
|
|
109
|
+
return { loaded, fiber: result }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function describe(loaded, dir) {
|
|
113
|
+
return { version: loaded.version, source: loaded.source, hostPath: loaded.hostPath ?? dir ?? null }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 换到指定版本(默认版本最高的那一代)。
|
|
118
|
+
*
|
|
119
|
+
* 旧代先停、新代后起;新代起不来就回滚把旧代装回去,并把错误留在 `lastError` 里——
|
|
120
|
+
* 「更新把自己弄挂了」必须能被看见,而不是静默少个插件。
|
|
121
|
+
*/
|
|
122
|
+
async function reload(preferVersion) {
|
|
123
|
+
if (detached) throw new Error('插件已经卸载,不能再换代')
|
|
124
|
+
if (swapping) return swapping
|
|
125
|
+
swapping = (async () => {
|
|
126
|
+
const target = resolveGeneration(preferVersion)
|
|
127
|
+
if (!target) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
|
|
128
|
+
if (current && target.version === current.version) return status()
|
|
129
|
+
|
|
130
|
+
await drain()
|
|
131
|
+
const oldFiber = fiber
|
|
132
|
+
const oldCurrent = current
|
|
133
|
+
fiber = null
|
|
134
|
+
current = null
|
|
135
|
+
if (oldFiber) {
|
|
136
|
+
try {
|
|
137
|
+
await oldFiber.dispose()
|
|
138
|
+
} catch (error) {
|
|
139
|
+
// dispose 里的清理动作自己炸了:记下来,但不能因此不装新代。
|
|
140
|
+
lastError = `卸载 ${oldCurrent?.version ?? '旧代'} 时出错:${error?.message || error}`
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const { loaded, fiber: nextFiber } = await activate(target)
|
|
145
|
+
fiber = nextFiber
|
|
146
|
+
loadedCurrent = loaded
|
|
147
|
+
current = describe(loaded, target.dir)
|
|
148
|
+
if (oldCurrent) previous = oldCurrent
|
|
149
|
+
activatedAt = new Date().toISOString()
|
|
150
|
+
lastError = null
|
|
151
|
+
return status()
|
|
152
|
+
} catch (error) {
|
|
153
|
+
lastError = `装载 ${target.version} 失败:${error?.message || error}`
|
|
154
|
+
if (oldCurrent) {
|
|
155
|
+
// 回滚:把上一代装回去,尽量让插件保持可用。
|
|
156
|
+
//
|
|
157
|
+
// 顺序是刻意的:**先用内存里那份**(`loadedCurrent`),因为它一定还在;盘上那份
|
|
158
|
+
// 可能已经被市场这次安装带走了(pnpm 换 store 目录),照版本号找会找空。
|
|
159
|
+
try {
|
|
160
|
+
if (loadedCurrent) {
|
|
161
|
+
const { fiber: nextFiber } = await activateLoaded(loadedCurrent)
|
|
162
|
+
fiber = nextFiber
|
|
163
|
+
current = describe(loadedCurrent, oldCurrent.dir ?? null)
|
|
164
|
+
activatedAt = new Date().toISOString()
|
|
165
|
+
} else {
|
|
166
|
+
const fallback = resolveGeneration(oldCurrent.version)
|
|
167
|
+
if (fallback) {
|
|
168
|
+
const { loaded, fiber: nextFiber } = await activate(fallback)
|
|
169
|
+
fiber = nextFiber
|
|
170
|
+
loadedCurrent = loaded
|
|
171
|
+
current = describe(loaded, fallback.dir)
|
|
172
|
+
activatedAt = new Date().toISOString()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (rollbackError) {
|
|
176
|
+
lastError = `${lastError};回滚到 ${oldCurrent.version} 也失败:${rollbackError?.message || rollbackError}`
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
throw error
|
|
180
|
+
}
|
|
181
|
+
})().finally(() => {
|
|
182
|
+
swapping = null
|
|
183
|
+
})
|
|
184
|
+
return swapping
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 第一次激活。 */
|
|
188
|
+
async function start(context) {
|
|
189
|
+
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()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 等在飞请求结束(最多 `DRAIN_TIMEOUT_MS`)。 */
|
|
201
|
+
async function drain() {
|
|
202
|
+
const deadline = Date.now() + DRAIN_TIMEOUT_MS
|
|
203
|
+
while (inFlight > 0 && Date.now() < deadline) {
|
|
204
|
+
await sleep(25)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 外壳被卸载时调用:停当前代,之后不再接受换代。 */
|
|
209
|
+
async function detach() {
|
|
210
|
+
detached = true
|
|
211
|
+
const oldFiber = fiber
|
|
212
|
+
fiber = null
|
|
213
|
+
current = null
|
|
214
|
+
if (oldFiber) await oldFiber.dispose()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
start,
|
|
219
|
+
reload,
|
|
220
|
+
detach,
|
|
221
|
+
status,
|
|
222
|
+
beginRequest: () => {
|
|
223
|
+
inFlight += 1
|
|
224
|
+
},
|
|
225
|
+
endRequest: () => {
|
|
226
|
+
inFlight = Math.max(0, inFlight - 1)
|
|
227
|
+
},
|
|
228
|
+
/**
|
|
229
|
+
* 把一个路由 handler 包成「进来 +1、出去 -1」。
|
|
230
|
+
*
|
|
231
|
+
* 宿主 webServer 没有 Koa 式中间件(只有 `register(route)` / `registerUpgrade` /
|
|
232
|
+
* `registerFallback` / `tapIndex`),所以「在飞请求」只能在 handler 外面数。包错
|
|
233
|
+
* 形状时原样返回,绝不因为统计把一条路由弄丢。
|
|
234
|
+
*/
|
|
235
|
+
wrapHandler: (route) => {
|
|
236
|
+
if (!route || typeof route.handler !== 'function') return route
|
|
237
|
+
const handler = route.handler
|
|
238
|
+
route.handler = async (req, res) => {
|
|
239
|
+
inFlight += 1
|
|
240
|
+
try {
|
|
241
|
+
return await handler(req, res)
|
|
242
|
+
} finally {
|
|
243
|
+
inFlight = Math.max(0, inFlight - 1)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return route
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 更新编排:替用户去问 DSH 插件市场要新版,装完**就地换代**(不重启 DSH)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这件事归外壳做,而不是归面板(浏览器)
|
|
5
|
+
*
|
|
6
|
+
* 面板里那颗「更新」按钮只能覆盖「用户正开着页面点一下」这一种情形;自动更新的定义是
|
|
7
|
+
* **没人看着也要发生**。所以轮询和「装完切换」都必须在宿主进程里做,面板只是给它一个开关。
|
|
8
|
+
*
|
|
9
|
+
* ## 为什么下载交给市场、切换留给自己
|
|
10
|
+
*
|
|
11
|
+
* 市场在 profile 里跑 pnpm,还会做版本核验、release-age 策略、回滚点、重启守卫 —— 抄一遍
|
|
12
|
+
* 只会抄漏。它装完会报 `restartRequired: true`(因为绝大多数插件换版本确实得重启 DSH),
|
|
13
|
+
* 而本插件有外壳,可以自己把运行中的实现换掉,所以这里**故意不调它的重启**。
|
|
14
|
+
*
|
|
15
|
+
* 装完新一代是**新目录**(pnpm 换链接),外壳照着新 realpath 重新 import 一代即可,见
|
|
16
|
+
* `lib/shell/manager.js` 与 `lib/shell/runtime.js`。
|
|
17
|
+
*
|
|
18
|
+
* ## 这个模块不做的事
|
|
19
|
+
*
|
|
20
|
+
* 不下载、不解包、不校验、不写 profile —— 全是市场的职责。它只回答四个问题:有没有新版、
|
|
21
|
+
* 请它装、装完了没、盘上现在是什么版本。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
import { createRequire } from 'node:module'
|
|
28
|
+
import { installedVersion } from './manager.js'
|
|
29
|
+
|
|
30
|
+
const require_ = createRequire(import.meta.url)
|
|
31
|
+
const { dshHome } = require_('../shared/paths.js')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 外壳自己的指纹(`lib/index.js` + `lib/shell/*.js` 的内容哈希)。
|
|
36
|
+
*
|
|
37
|
+
* **外壳换不掉**:它在进程启动时被 Node 缓存,改它必须重启 DSH —— 而重启是被明令禁止的。
|
|
38
|
+
* 所以「新版本连外壳一起改了」这件事**不会**在这次更新里生效:实现换成新的了,装载它的
|
|
39
|
+
* 那层还是旧的。这本身通常无害(外壳的接口是加法式的),但用户有权知道,所以这里算一个
|
|
40
|
+
* 指纹,把「盘上的外壳和跑着的外壳不一样」如实报给面板。
|
|
41
|
+
*
|
|
42
|
+
* 缺文件按空串算:拿不到指纹时报「没变」,免得每次开面板都喊一句要重启。
|
|
43
|
+
*/
|
|
44
|
+
export function shellFingerprint(pkgDir) {
|
|
45
|
+
if (!pkgDir) return null
|
|
46
|
+
const files = ['lib/index.js', 'lib/shell/runtime.js', 'lib/shell/manager.js', 'lib/shell/updater.js']
|
|
47
|
+
const hash = createHash('sha256')
|
|
48
|
+
let seen = 0
|
|
49
|
+
for (const rel of files) {
|
|
50
|
+
try {
|
|
51
|
+
hash.update(rel).update(fs.readFileSync(path.join(pkgDir, rel)))
|
|
52
|
+
seen += 1
|
|
53
|
+
} catch {
|
|
54
|
+
// 缺一个文件就少算一个:宁愿指纹弱一点,也不要在这里抛。
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return seen === 0 ? null : hash.digest('hex').slice(0, 16)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 本插件的 npm 包名(市场按它找依赖)。 */
|
|
61
|
+
export const PACKAGE_NAME = 'dsh-mobile-plugin'
|
|
62
|
+
|
|
63
|
+
/** 自动检查的间隔与「检查一次」的超时。 */
|
|
64
|
+
export const AUTO_INTERVAL_MS = 10 * 60 * 1000
|
|
65
|
+
const REQUEST_TIMEOUT_MS = 15000
|
|
66
|
+
/** 等一次安装走完的上限:pnpm 装一个包 + 校验,给足但别无限等。 */
|
|
67
|
+
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000
|
|
68
|
+
const POLL_INTERVAL_MS = 1000
|
|
69
|
+
|
|
70
|
+
/** 宿主自己的 web 端口 → 市场的本机地址。端口拿不到就别问(自动更新如实报不可用)。 */
|
|
71
|
+
export function marketBase(port) {
|
|
72
|
+
const value = Number(port)
|
|
73
|
+
if (!Number.isFinite(value) || value <= 0) return null
|
|
74
|
+
return `http://127.0.0.1:${value}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 市场接口的错误:把状态码、它给的稳定 code 与给人看的话一起带出来。 */
|
|
78
|
+
export class MarketError extends Error {
|
|
79
|
+
constructor(status, code, message) {
|
|
80
|
+
super(message)
|
|
81
|
+
this.name = 'MarketError'
|
|
82
|
+
this.status = status
|
|
83
|
+
this.code = code
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 调一次市场接口。
|
|
89
|
+
*
|
|
90
|
+
* 形状是**外部契约**,一律防御式读:拿不到就当「没这个能力」,绝不让外壳因此崩掉
|
|
91
|
+
* (外壳崩掉等于插件从宿主里消失、手机连不上,是最贵的失败)。
|
|
92
|
+
*
|
|
93
|
+
* 不带 `Origin` 头:请求来自宿主进程而不是浏览器,本机来源由对端按 socket 判定。
|
|
94
|
+
*/
|
|
95
|
+
async function marketJson(base, route, init) {
|
|
96
|
+
if (!base) throw new MarketError(0, 'market-unavailable', '拿不到本机 web 端口,问不了市场')
|
|
97
|
+
const controller = new AbortController()
|
|
98
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
|
99
|
+
let response
|
|
100
|
+
try {
|
|
101
|
+
response = await fetch(`${base}/dsh-market/api/v1${route}`, {
|
|
102
|
+
...init,
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
headers: { 'content-type': 'application/json', ...(init?.headers || {}) },
|
|
105
|
+
})
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new MarketError(0, 'market-unreachable', `插件市场不可达:${error?.message || error}`)
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer)
|
|
110
|
+
}
|
|
111
|
+
let body = null
|
|
112
|
+
try {
|
|
113
|
+
body = await response.json()
|
|
114
|
+
} catch {
|
|
115
|
+
body = null
|
|
116
|
+
}
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const code = body?.error?.code || body?.code || `http-${response.status}`
|
|
119
|
+
const message = body?.error?.message || body?.message || `市场返回 ${response.status}`
|
|
120
|
+
throw new MarketError(response.status, code, message)
|
|
121
|
+
}
|
|
122
|
+
return body
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 市场在不在、能不能代劳重启(我们不用后者,只为与面板口径一致)。 */
|
|
126
|
+
export async function capabilities(base) {
|
|
127
|
+
const body = await marketJson(base, '/capabilities', { method: 'GET' })
|
|
128
|
+
return {
|
|
129
|
+
available: true,
|
|
130
|
+
restartSupported: body?.restart?.supported === true,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 问有没有新版。`installedVersion` 为 null = 市场看不见这个包(多半不是 npm 装的)。
|
|
136
|
+
*
|
|
137
|
+
* 路径与字段与面板那份客户端(`React/src/lib/market.ts`)逐字对齐:同一个市场接口,
|
|
138
|
+
* 两处写法不一致时踩的是「宿主能装、面板说看不见」这类最难查的偏差。
|
|
139
|
+
*/
|
|
140
|
+
export async function checkUpdate(base, name = PACKAGE_NAME, { force = false } = {}) {
|
|
141
|
+
const body = await marketJson(base, `/updates?name=${encodeURIComponent(name)}${force ? '&force=1' : ''}`, { method: 'GET' })
|
|
142
|
+
const update = body?.package || body || {}
|
|
143
|
+
return {
|
|
144
|
+
installedVersion: typeof update.installedVersion === 'string' ? update.installedVersion : null,
|
|
145
|
+
latestVersion: typeof update.latestVersion === 'string' ? update.latestVersion : null,
|
|
146
|
+
updateAvailable: update.updateAvailable === true,
|
|
147
|
+
source: typeof update.source === 'string' ? update.source : null,
|
|
148
|
+
channelSwitch: update.channelSwitch === true,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 这条链能不能自动更新。
|
|
154
|
+
*
|
|
155
|
+
* 市场只从注册表装包:本地 link / `file:` 装的那些(`source: 'linked'`)它一概拒绝
|
|
156
|
+
* (`locally linked plugins update from their checkout`)。所以「自动更新」在那种机器上
|
|
157
|
+
* **做不到**,而且必须**说出来** —— 否则用户开着开关等一晚上,什么都不会发生。
|
|
158
|
+
*/
|
|
159
|
+
export function updatableSource(source) {
|
|
160
|
+
return source === 'npm'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 请市场去装。返回它的 operationId,供轮询。 */
|
|
164
|
+
export async function startUpdate(base, name = PACKAGE_NAME, { force = false } = {}) {
|
|
165
|
+
const body = await marketJson(base, '/updates', {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
body: JSON.stringify({ packageName: name, ...(force ? { force: true } : {}) }),
|
|
168
|
+
})
|
|
169
|
+
const operation = body?.operation || body || {}
|
|
170
|
+
if (typeof operation.operationId !== 'string') {
|
|
171
|
+
throw new MarketError(200, 'market-bad-response', '市场没有给出 operationId,无法跟踪这次更新')
|
|
172
|
+
}
|
|
173
|
+
return operation
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 查一次操作结果。 */
|
|
177
|
+
export async function operation(base, operationId) {
|
|
178
|
+
const body = await marketJson(base, `/operations?operationId=${encodeURIComponent(operationId)}`, { method: 'GET' })
|
|
179
|
+
return body?.operation || body || {}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'rolled-back'])
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 一套完整的「装新版」:问市场、请它装、盯到终态。
|
|
186
|
+
*
|
|
187
|
+
* **不重启 DSH**:市场报的 `restartRequired` 只往上传,装完由外壳换代解决(见 index.js)。
|
|
188
|
+
* 装完了但盘上版本没变(市场报成功、文件没落),这里如实返回 `installed: false`,不让调用方
|
|
189
|
+
* 以为换过代码 —— 「状态说成功、代码没换」是这个仓库吃过最贵的一次亏。
|
|
190
|
+
*/
|
|
191
|
+
export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, onEvent = () => {}, now = Date.now } = {}) {
|
|
192
|
+
const before = installedVersion()
|
|
193
|
+
const check = await checkUpdate(base, name, { force })
|
|
194
|
+
if (check.source !== null && !updatableSource(check.source)) {
|
|
195
|
+
// 本地 link / file: 装的:市场不会碰它(见 updatableSource)。如实返回,别去试。
|
|
196
|
+
return {
|
|
197
|
+
installed: false,
|
|
198
|
+
skipped: true,
|
|
199
|
+
reason: 'linked-source',
|
|
200
|
+
detail: `这台机器上的插件是 ${check.source} 来源装的(本地目录 / tgz),市场只从注册表更新;要自动更新得先在插件市场里按包名重装一次切到 npm 来源`,
|
|
201
|
+
beforeVersion: before,
|
|
202
|
+
installedVersion: before,
|
|
203
|
+
check,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!check.updateAvailable && !force) {
|
|
207
|
+
return { installed: false, skipped: true, beforeVersion: before, installedVersion: before, check }
|
|
208
|
+
}
|
|
209
|
+
const started = await startUpdate(base, name, { force })
|
|
210
|
+
onEvent({ phase: 'started', operationId: started.operationId })
|
|
211
|
+
const deadline = now() + INSTALL_TIMEOUT_MS
|
|
212
|
+
let last = started
|
|
213
|
+
while (!TERMINAL.has(last.state) && now() < deadline) {
|
|
214
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
215
|
+
last = await operation(base, started.operationId)
|
|
216
|
+
onEvent({ phase: 'progress', state: last.state, operationId: started.operationId })
|
|
217
|
+
}
|
|
218
|
+
if (!TERMINAL.has(last.state)) {
|
|
219
|
+
throw new MarketError(0, 'market-operation-timeout', `更新操作 ${started.operationId} 在 ${Math.round(INSTALL_TIMEOUT_MS / 1000)}s 内没有结束`)
|
|
220
|
+
}
|
|
221
|
+
// 市场同一时刻只允许一个操作,有 agent 在跑时也会直接拒(`AGENTS_RUNNING`)—— 这类
|
|
222
|
+
// 失败是「现在不行」,不是「坏了」:自动更新下个 tick 再试就好,别当成故障喊。
|
|
223
|
+
const RETRYABLE_CODES = new Set(['OPERATION_BUSY', 'AGENTS_RUNNING', 'UPDATE_TIMEOUT', 'RELEASE_TOO_FRESH'])
|
|
224
|
+
const retryable = typeof last.failure?.code === 'string' ? RETRYABLE_CODES.has(last.failure.code) : false
|
|
225
|
+
const after = installedVersion()
|
|
226
|
+
return {
|
|
227
|
+
installed: last.state === 'succeeded' && after !== before && after !== null,
|
|
228
|
+
state: last.state,
|
|
229
|
+
operationId: started.operationId,
|
|
230
|
+
beforeVersion: last.beforeVersion ?? before,
|
|
231
|
+
installedVersion: after,
|
|
232
|
+
// 市场认为要重启:本插件自己换代,**故意不照做**。留着上报给面板做说明。
|
|
233
|
+
restartRequired: last.restartRequired === true,
|
|
234
|
+
refreshRequired: last.refreshRequired === true,
|
|
235
|
+
failure: last.failure || null,
|
|
236
|
+
retryable,
|
|
237
|
+
check,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 自动更新开关的落盘文件:`<DSH_HOME>/storages/mobile-plugin/auto-update.json`。 */
|
|
242
|
+
function autoUpdateFile() {
|
|
243
|
+
return path.join(dshHome(), 'storages', 'mobile-plugin', 'auto-update.json')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 读开关。读不到 / 读坏了都当「关」——自动装东西这种事,缺省必须在用户这一侧。 */
|
|
247
|
+
export function autoUpdateEnabled() {
|
|
248
|
+
try {
|
|
249
|
+
const raw = JSON.parse(fs.readFileSync(autoUpdateFile(), 'utf8'))
|
|
250
|
+
return raw?.enabled === true
|
|
251
|
+
} catch {
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 写开关。 */
|
|
257
|
+
export function setAutoUpdate(enabled) {
|
|
258
|
+
const file = autoUpdateFile()
|
|
259
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
260
|
+
fs.writeFileSync(file, `${JSON.stringify({ enabled: enabled === true }, null, 2)}\n`)
|
|
261
|
+
return autoUpdateEnabled()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 开关文件的路径(诊断用)。 */
|
|
265
|
+
export function autoUpdateFilePath() {
|
|
266
|
+
return autoUpdateFile()
|
|
267
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59",
|
|
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",
|
|
7
|
-
"main": "lib/
|
|
7
|
+
"main": "lib/index.js",
|
|
8
8
|
"bin": "host.js",
|
|
9
9
|
"exports": {
|
|
10
|
-
".": "./lib/
|
|
10
|
+
".": "./lib/index.js",
|
|
11
11
|
"./client": "./lib/client.js",
|
|
12
12
|
"./package.json": "./package.json"
|
|
13
13
|
},
|