dsh-mobile-plugin 0.1.58 → 0.1.60
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 +12 -2
- 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 +65 -5
- package/lib/shell/manager.js +284 -0
- package/lib/shell/runtime.js +249 -0
- package/lib/shell/updater.js +267 -0
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -77,8 +77,18 @@ DSH_HOME=/opt/dsh-mobile/.dsh DSH_MOBILE_PROFILE=mobile node host.js
|
|
|
77
77
|
跑一次 pnpm。装了之后「设置 → 移动端 → 打开配置页面」那一页的更新卡片会直接调市场的公共
|
|
78
78
|
更新接口(`/dsh-market/api/v1/*`),能在页面上检查更新、开始更新、看进度,并在需要时请
|
|
79
79
|
市场代劳重启 DSH。
|
|
80
|
-
-
|
|
81
|
-
|
|
80
|
+
- **更新后不需要重启 DSH**(2026-10-10 起):插件入口是一个稳定外壳,市场在 profile 里装完新版,
|
|
81
|
+
外壳会**就地换到新代码**(先把旧实现卸干净、再装新实现),面板那句「重启 DSH 才生效」不再是必须的。
|
|
82
|
+
代价与边界照实说:① **装了外壳的那一版本身仍需重启一次**(旧版本入口就是实现本身,没有外壳可换),
|
|
83
|
+
从再下一版起才真正免重启;② **外壳自己**(`lib/index.js` + `lib/shell/*`)在进程启动时就被 Node
|
|
84
|
+
缓存了,改它要等下次启动 —— 面板会用 `shellChanged` 把这件事说出来,不让你猜;③ 客户端那半个文件
|
|
85
|
+
(设置菜单里的入口)仍然是渲染器直读安装目录的,如果它改了需要刷新浏览器页面(所以这一半刻意保持
|
|
86
|
+
不变,更新界面都放在宿主吐的面板里)。
|
|
87
|
+
- **自动更新**(面板 → 插件更新卡片里那个开关,默认关):打开后每 10 分钟问一次 DSH 插件市场,
|
|
88
|
+
有新版就自动装好并**立即切换**,全程不需要重启。手动点「更新」走的是同一条路。
|
|
89
|
+
- 有一个前提要认:市场**只从注册表更新**。如果这台机器上的插件是本地目录 / `file:` tgz 装的
|
|
90
|
+
(市场把它报成 `linked`),自动更新会如实跳过并说明原因 —— 要享受这条链,得先在插件市场里按包名
|
|
91
|
+
重装一次,切到 npm 来源。
|
|
82
92
|
- 如果「设置 → 移动端 → 打开配置页面」打不开、页面吐一段 `panel_missing` JSON:那段文案会
|
|
83
93
|
写清是**哪一种**读不到 —— 「装完没重启」(旧实例还挂在这次安装删掉的那个目录上,最常见)、
|
|
84
94
|
「装了老版本」(0.1.47 及更早还没有这一页)还是「真没构建」(源码树里才会遇到)。三种解法
|
package/lib/impl.js
CHANGED
|
@@ -318,6 +318,9 @@ function apply(ctx) {
|
|
|
318
318
|
|
|
319
319
|
registerControlPlaneRoutes({
|
|
320
320
|
registerRoute,
|
|
321
|
+
// 更新的机制住在外壳(`lib/index.js`)里,代这边只把它开成本机接口。取不到就如实报
|
|
322
|
+
// 不可用(`lib/impl.js` 被单独挂载时就是这样)。
|
|
323
|
+
getPluginRuntime: () => (typeof ctx.get === 'function' ? ctx.get('dshMobilePluginRuntime') : null),
|
|
321
324
|
localOnly,
|
|
322
325
|
readJson,
|
|
323
326
|
sendJson,
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件入口(**稳定外壳**)。
|
|
3
|
+
*
|
|
4
|
+
* 这个文件是整条自更新链路的锚点,所以它必须尽量不变。真正的实现在**代**里:随包发布的
|
|
5
|
+
* 那份是 `lib/impl.js`,市场装新版之后 profile 里那份是新一代(新 realpath)。
|
|
6
|
+
* 外壳负责挑一代、装上去,并在更新到位后换过去,见 `lib/shell/runtime.js`。
|
|
7
|
+
*
|
|
8
|
+
* ## 为什么入口非得是「外壳 + 代」两半
|
|
9
|
+
*
|
|
10
|
+
* 宿主进程启动时就把 profile 的插件树定死了(Node 按 realpath 缓存模块)。要换实现,
|
|
11
|
+
* 只能换**这个文件 import 的东西** —— 换掉它自己就得重启进程,而重启桌面端 DSH 会在用户
|
|
12
|
+
* 不知情的情况下掐掉他正在跑的对话,这正是要避免的事。所以这里只留不会变的装载逻辑,
|
|
13
|
+
* 变动频繁的部分全部搬进代。
|
|
14
|
+
*
|
|
15
|
+
* 这一层同时是**故障边界**:代装载失败时外壳回滚到上一代并把错误暴露出去,而不是让整个
|
|
16
|
+
* 插件从宿主里消失(那会让手机连不上,且日志里只剩一行 pending)。
|
|
17
|
+
*
|
|
18
|
+
* ## 更新从哪儿来(2026-10 改)
|
|
19
|
+
*
|
|
20
|
+
* 分发交给 DSH 插件市场:它在 profile 里跑 pnpm,装完会把 `<profile>/node_modules/
|
|
21
|
+
* dsh-mobile-plugin` 指向一个新的 store 目录。外壳照着新 realpath 重新 import 一代即可,
|
|
22
|
+
* **不需要重启 DSH**。自动更新(默认关)按 `AUTO_INTERVAL_MS` 轮询,见 `shell/updater.js`。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { createRuntime } from './shell/runtime.js'
|
|
28
|
+
import * as updater from './shell/updater.js'
|
|
29
|
+
import { installedPackageDir, installedVersion, profileNodeModules } from './shell/manager.js'
|
|
30
|
+
|
|
31
|
+
export const name = 'dsh-mobile-plugin'
|
|
32
|
+
export const inject = ['webServer', 'sessions']
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 装载当前代,并把「问市场 / 换代码」这套动作暴露成服务。
|
|
36
|
+
*
|
|
37
|
+
* 对照 `lib/impl.js` 的 `apply`:那边签名也是 `(ctx)`,只是它注册的是真正的移动 API;
|
|
38
|
+
* 这里注册的是「装载它」。两者的 effect 各自归属自己那层 fiber —— 外壳被卸载时,代的
|
|
39
|
+
* fiber 作为它的子节点一起被 dispose,代里那些 `ctx.effect` 的清理动作照常执行。
|
|
40
|
+
*/
|
|
41
|
+
export function apply(ctx) {
|
|
42
|
+
const runtime = createRuntime()
|
|
43
|
+
// 跑着的外壳的指纹。它必须在**启动时**算:之后盘上那份可能已经被市场换掉了。
|
|
44
|
+
const runningShellHash = updater.shellFingerprint(path.dirname(path.dirname(fileURLToPath(import.meta.url))))
|
|
45
|
+
const market = {
|
|
46
|
+
checking: false,
|
|
47
|
+
lastCheckedAt: null,
|
|
48
|
+
lastError: null,
|
|
49
|
+
result: null,
|
|
50
|
+
lastApply: null,
|
|
51
|
+
appliedAt: null,
|
|
52
|
+
}
|
|
53
|
+
const base = () => updater.marketBase(ctx.webServer?.port)
|
|
54
|
+
const log = (level, message, ...rest) => {
|
|
55
|
+
const fn = ctx.logger?.[level]
|
|
56
|
+
if (typeof fn === 'function') fn.call(ctx.logger, message, ...rest)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 查一次有没有新版。只记结果,装不装由开关或人来决定。 */
|
|
60
|
+
async function check({ force = false } = {}) {
|
|
61
|
+
if (market.checking) return market.result
|
|
62
|
+
market.checking = true
|
|
63
|
+
try {
|
|
64
|
+
market.result = await updater.checkUpdate(base())
|
|
65
|
+
market.lastError = null
|
|
66
|
+
} catch (error) {
|
|
67
|
+
market.result = null
|
|
68
|
+
market.lastError = String(error?.message || error)
|
|
69
|
+
} finally {
|
|
70
|
+
market.checking = false
|
|
71
|
+
market.lastCheckedAt = new Date().toISOString()
|
|
72
|
+
}
|
|
73
|
+
return market.result
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 请市场装新版,装完**就地换代**。
|
|
78
|
+
*
|
|
79
|
+
* 手动与自动走的是同一条路:手动只是「现在就做一次」。市场报的 `restartRequired` 一律
|
|
80
|
+
* 不照做 —— 外壳自己换代码,这正是这个文件存在的全部理由。
|
|
81
|
+
*/
|
|
82
|
+
async function applyUpdate({ force = false } = {}) {
|
|
83
|
+
const outcome = await updater.installIfNeeded(base(), { force })
|
|
84
|
+
let swapped = false
|
|
85
|
+
let status = runtime.status()
|
|
86
|
+
if (outcome.installed) {
|
|
87
|
+
status = await runtime.reload()
|
|
88
|
+
swapped = true
|
|
89
|
+
log('info', 'dsh-mobile-plugin: 已就地换到 %s(无需重启 DSH)', status.version)
|
|
90
|
+
} else if (outcome.state && outcome.state !== 'succeeded') {
|
|
91
|
+
log('warn', 'dsh-mobile-plugin: 更新没有成功(%s)%s', outcome.state, outcome.failure?.message || '')
|
|
92
|
+
}
|
|
93
|
+
market.lastApply = { ...outcome, swapped }
|
|
94
|
+
market.appliedAt = new Date().toISOString()
|
|
95
|
+
if (outcome.installed) market.lastError = null
|
|
96
|
+
return { ...outcome, swapped, status }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 换代前要知道有没有请求在飞(见 runtime.drain)。宿主 webServer 没有中间件机制,所以
|
|
100
|
+
// 这里包一层 register,把每条路由的 handler 变成「进 +1 / 出 -1」。包的时机在任何一代
|
|
101
|
+
// 装载之前,且 `wrapHandler` 遇到不认识的形状会原样放行。
|
|
102
|
+
ctx.effect(() => {
|
|
103
|
+
const server = ctx.webServer
|
|
104
|
+
const original = server?.register
|
|
105
|
+
if (typeof original !== 'function') return () => {}
|
|
106
|
+
server.register = function register(route) {
|
|
107
|
+
return original.call(this, runtime.wrapHandler(route))
|
|
108
|
+
}
|
|
109
|
+
return () => {
|
|
110
|
+
server.register = original
|
|
111
|
+
return runtime.detach()
|
|
112
|
+
}
|
|
113
|
+
}, 'dsh-mobile-plugin: generation runtime')
|
|
114
|
+
|
|
115
|
+
ctx.reflect.provide('dshMobilePluginRuntime', {
|
|
116
|
+
status: () => ({
|
|
117
|
+
...runtime.status(),
|
|
118
|
+
/** 盘上装着的版本(市场装完立刻变,和运行中的版本对照就能看出「要不要换」)。 */
|
|
119
|
+
installedVersion: installedVersion(),
|
|
120
|
+
/** 盘上那份的外壳指纹与跑着的对不上 = 新版本连外壳一起改了,那部分要等下次启动才生效。 */
|
|
121
|
+
runningShellHash,
|
|
122
|
+
installedShellHash: updater.shellFingerprint(installedPackageDir()),
|
|
123
|
+
shellChanged: (() => {
|
|
124
|
+
const installed = updater.shellFingerprint(installedPackageDir())
|
|
125
|
+
return installed !== null && runningShellHash !== null && installed !== runningShellHash
|
|
126
|
+
})(),
|
|
127
|
+
profileNodeModules: profileNodeModules(),
|
|
128
|
+
autoUpdate: updater.autoUpdateEnabled(),
|
|
129
|
+
autoIntervalMs: updater.AUTO_INTERVAL_MS,
|
|
130
|
+
marketBase: base(),
|
|
131
|
+
marketLastCheckedAt: market.lastCheckedAt,
|
|
132
|
+
marketLastError: market.lastError,
|
|
133
|
+
marketResult: market.result,
|
|
134
|
+
lastApply: market.lastApply,
|
|
135
|
+
appliedAt: market.appliedAt,
|
|
136
|
+
}),
|
|
137
|
+
check,
|
|
138
|
+
apply: applyUpdate,
|
|
139
|
+
reload: version => runtime.reload(version),
|
|
140
|
+
setAutoUpdate: enabled => {
|
|
141
|
+
const value = updater.setAutoUpdate(enabled)
|
|
142
|
+
log('info', 'dsh-mobile-plugin: 自动更新已%s', value ? '开启' : '关闭')
|
|
143
|
+
return value
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
runtime
|
|
148
|
+
.start(ctx)
|
|
149
|
+
.then(() => {
|
|
150
|
+
log('info', 'dsh-mobile-plugin: 已装载 %s(来源 %s)', runtime.status().version, runtime.status().source)
|
|
151
|
+
// 自动更新:默认关。开了才轮询;每次都是「问市场 → 装 → 就地换代」。
|
|
152
|
+
const timer = setInterval(() => {
|
|
153
|
+
if (!updater.autoUpdateEnabled()) return
|
|
154
|
+
applyUpdate().catch(error => {
|
|
155
|
+
market.lastError = String(error?.message || error)
|
|
156
|
+
log('warn', 'dsh-mobile-plugin: 自动更新失败:%s', market.lastError)
|
|
157
|
+
})
|
|
158
|
+
}, updater.AUTO_INTERVAL_MS)
|
|
159
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
160
|
+
ctx.effect(() => () => clearInterval(timer), 'dsh-mobile-plugin: auto update timer')
|
|
161
|
+
// 启动后延后一次:别和绑端口、连隧道抢带宽。
|
|
162
|
+
if (updater.autoUpdateEnabled()) {
|
|
163
|
+
const kick = setTimeout(() => {
|
|
164
|
+
applyUpdate().catch(error => {
|
|
165
|
+
market.lastError = String(error?.message || error)
|
|
166
|
+
log('warn', 'dsh-mobile-plugin: 启动时的自动更新失败:%s', market.lastError)
|
|
167
|
+
})
|
|
168
|
+
}, 10000)
|
|
169
|
+
if (typeof kick.unref === 'function') kick.unref()
|
|
170
|
+
} else {
|
|
171
|
+
// 没开自动也查一次:用户打开面板之前就知道有没有新版。
|
|
172
|
+
const kick = setTimeout(() => { check().catch(() => {}) }, 5000)
|
|
173
|
+
if (typeof kick.unref === 'function') kick.unref()
|
|
174
|
+
}
|
|
175
|
+
return undefined
|
|
176
|
+
})
|
|
177
|
+
.catch(error => {
|
|
178
|
+
log('error', 'dsh-mobile-plugin: 装载插件实现失败:%s', error?.message || error)
|
|
179
|
+
})
|
|
180
|
+
}
|