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 CHANGED
@@ -77,14 +77,55 @@ 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
- - **装完/更新完要完整重启 DSH**:插件是 profile 层的 cordis bundle,进程起来时依赖树就定死了。
81
- 市场会把这件事报成 `restartRequired`,页面上有对应提示与按钮。
82
- - 发版:抬版本号(`node scripts/bump-version.mjs`)后 `npm publish`。插件不再自带发布脚本,
83
- 也没有「中转 OTA」这条分发源了。
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 来源。
92
+ - 如果「设置 → 移动端 → 打开配置页面」打不开、页面吐一段 `panel_missing` JSON:那段文案会
93
+ 写清是**哪一种**读不到 —— 「装完没重启」(旧实例还挂在这次安装删掉的那个目录上,最常见)、
94
+ 「装了老版本」(0.1.47 及更早还没有这一页)还是「真没构建」(源码树里才会遇到)。三种解法
95
+ 不同,别照着「没重启」的现场去跑 React 构建。同一句话也会进宿主 stderr。
84
96
  - 从前那套的教训记在这里:热更新只换宿主侧的实现代,**换不掉设置菜单那一半**(渲染器直接从
85
97
  安装目录读它),于是出现过「更新报成功、界面一动不动」,重启 DSH、重启 sidecar 都没用。
86
98
  现在只有一条更新路径,这类「哪一半是旧的」不再存在。
87
99
 
100
+ ### 发版流程(自己发一版更新)
101
+
102
+ 1. **抬版本号**:`node scripts/bump-version.mjs`(三段各 `0..999`)。
103
+ 2. **改过 `React/` 就先重建面板**:`cd React && pnpm run build`。产物 `lib/panel/index.html`
104
+ 是**提交物**,要和源码一起提交 —— 忘了重建,发出去的就是旧页面,而仓库里没有任何东西能
105
+ 替你发现(守卫只比字节形状,不比新旧)。
106
+ 3. **自检**:`npm test`(其中 `panel-artifact` 管产物形状与「读不到时报什么」,
107
+ `publish-payload` 管发布清单收得住每个会被加载的文件);`npm pack --dry-run` 扫一眼发了什么。
108
+ 4. **发布**:`npm publish`。
109
+ 5. **核对**:`npm view dsh-mobile-plugin version`。
110
+ 6. **到机器上更新**:DSH → 设置 → 插件 里更新,**然后完整重启 DSH**。
111
+
112
+ **发布这一步现在要你现场过二次验证**(2026-10 起,别以为是配错了):
113
+
114
+ - `npm publish` 会打印一条 `https://www.npmjs.com/auth/cli/…` 链接(或直接开浏览器),用账号
115
+ 绑定的**安全密钥**确认一下,它自己接着把包发出去。
116
+ - 所以这一步**必须能打开 www.npmjs.com**:机房/代理出口 IP 会被它的反机器人拦成 403
117
+ (页面写「访问暂时受限」并带上你的 IP)。换个节点或直连再发。
118
+ - npm 已经关掉了「命令行建号」和「验证器 App(TOTP)」两条路:建号只能在网页上做,2FA 只能挂
119
+ 安全密钥。这两件事上试命令是白费时间,认它就行。
120
+ - 想少点一次确认,可以建一把「允许绕过 2FA」的细粒度令牌少输几次验证:
121
+ `npm token create --bypass-2fa --packages dsh-mobile-plugin --packages-and-scopes-permission read-write --expires 90`
122
+ (**最长 90 天**)。代价:这类令牌 **2027-01 起不能再直接发布**,到期或到点都得回到上面
123
+ 那条交互式路径。
124
+ - 还有个死结记在这里:令牌不能指向**还不存在**的包(会报
125
+ `You do not have access to the requested package(s)`),所以**第一版只能用浏览器验证发**,
126
+ 令牌是「包已经存在之后」才有的便利。
127
+
128
+
88
129
  插件没有 GUI 外壳,配置入口是插件注册在 `webServer` 上的**仅限本机访问**(`localOnly` 守卫)
89
130
  的 HTTP 端点。`host.js` 启动后,其 `webServer.port` 就是下面 URL 里的 `<port>`
90
131
  (无头部署下固定 `3080`):
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
+ }