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.
@@ -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,15 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
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/impl.js",
8
- "bin": "host.js",
7
+ "main": "lib/index.js",
8
+ "bin": {
9
+ "dsh-mobile-plugin": "host.js"
10
+ },
9
11
  "exports": {
10
- ".": "./lib/impl.js",
12
+ ".": "./lib/index.js",
11
13
  "./client": "./lib/client.js",
12
14
  "./package.json": "./package.json"
13
15
  },