dsh-mobile-plugin 0.4.1 → 0.4.5

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.
Files changed (43) hide show
  1. package/README.md +71 -74
  2. package/dsh-mobile-credentials/lib/credentials.js +39 -16
  3. package/lib/impl.js +4 -4
  4. package/lib/index.js +1 -1
  5. package/lib/mobile/allowed-zone.js +100 -0
  6. package/lib/mobile/archive-clean.js +4 -3
  7. package/lib/mobile/attachments.js +5 -1
  8. package/lib/mobile/file-read.js +168 -0
  9. package/lib/mobile/history-derive.js +67 -98
  10. package/lib/mobile/host-bridge/event-synthesis.js +14 -0
  11. package/lib/mobile/local-images.js +38 -7
  12. package/lib/mobile/model-vendors.js +105 -0
  13. package/lib/mobile/plugin-market.js +475 -0
  14. package/lib/mobile/plugin-self.js +1 -1
  15. package/lib/mobile/plugin-update.js +1 -1
  16. package/lib/mobile/relay-control.js +159 -7
  17. package/lib/mobile/session-cwd.js +28 -0
  18. package/lib/mobile/session-delivery.js +68 -0
  19. package/lib/mobile/session-file.js +1 -12
  20. package/lib/mobile/stats-collect.cjs +146 -15
  21. package/lib/mobile/stats-ledger.cjs +249 -59
  22. package/lib/mobile/stats-scheduler.cjs +41 -0
  23. package/lib/mobile/stream.js +30 -110
  24. package/lib/mobile/uploads.js +68 -5
  25. package/lib/mobile/vendor-logos.cjs +30 -0
  26. package/lib/mobile/wire-cut.js +212 -0
  27. package/lib/panel/index.html +6 -6
  28. package/lib/relay/apply-ledger.cjs +52 -0
  29. package/lib/relay/core.js +14 -3
  30. package/lib/relay/migrate.js +90 -0
  31. package/lib/relay/protocol.js +4 -0
  32. package/lib/relay/verify.js +52 -0
  33. package/lib/routes/control-plane.js +12 -3
  34. package/lib/routes/mobile-api.js +32 -2
  35. package/lib/session-cleanup.cjs +10 -0
  36. package/lib/shared/device-headers.js +12 -4
  37. package/lib/shell/updater.js +2 -2
  38. package/lib/sidecar-facade-worker.cjs +55 -8
  39. package/lib/sidecar-facade.cjs +11 -1
  40. package/lib/sidecar-lifecycle.cjs +14 -1
  41. package/lib/update-install.cjs +13 -6
  42. package/package.json +1 -1
  43. package/lib/auto-archive.cjs +0 -98
@@ -0,0 +1,475 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 移动端「插件管理」:这台电脑上 DSH 装了哪些插件、装 / 启用 / 升级。
5
+ *
6
+ * 不自己跑包管理器:DSH 宿主里本来就有一套插件管理器(`pluginManager` 服务,桌面端
7
+ * 设置 → 插件 那个页面就是它的界面),装包、版本兼容检查、依赖脚本授权、装失败回滚、
8
+ * 「要不要重启才生效」全由它判断。这里只做三件事:把它的结果削成手机要的形状、把手机
9
+ * 发来的动作转过去、把一次安装的进度留在内存里给轮询。
10
+ *
11
+ * 能力面收在三条上(与桌面端同一个判据,不另开):
12
+ * 1. **只经 typertGateway 走 `pluginManager`**,不走 shell、不碰系统设置;
13
+ * 2. **没有卸载入口**:卸载要重启电脑上的 DSH,而手机没有在电脑前的那双手;承载本 App
14
+ * 连接的那个包(dsh-mobile-plugin)另外**不给停用**,停掉它等于把这台手机变成砖。它的
15
+ * 更新仍走原有的 `/plugin/update/*`(那条有装后自检与回滚,比通用卸载可靠)。「升级 =
16
+ * 卸载再装」那条照旧由宿主自己的 `removeBundle` 完成,不是这里开的口子;
17
+ * 3. 安装/启用**不改 DSH 的启动方式**:新装的插件要不要重启才生效由宿主自己回报,界面照实说。
18
+ *
19
+ * 接口(都挂 `/api/mobile/v1/plugin/market`,走 token 守卫):
20
+ * GET `/plugin/market` → { bundles }
21
+ * GET `/plugin/market/registries` → { registry, fallbackRegistries, resolved }:安装时能选的 npm 源
22
+ * POST `/plugin/market/inspect` { spec } → 读一下这个包是什么
23
+ * POST `/plugin/market/install` { spec, registry?, requestId?, approvedBuilds? } → 开始装(立即返回)
24
+ * POST `/plugin/market/update` { name, requestId? } → 卸载后重装成最新 / 官方给的目标版本
25
+ * POST `/plugin/market/cancel` { requestId } → 停一次正在跑的安装
26
+ * GET `/plugin/market/progress?requestId=` → 那一次装完没有
27
+ * POST `/plugin/market/enabled` { name, enabled } → 启用 / 停用(依赖留着)
28
+ */
29
+
30
+ const { sendJson, readJsonBody } = require('../shared/http-util');
31
+ const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
32
+ const pkg = require('../../package.json');
33
+
34
+ /** 缺了插件管理器(老宿主 / 极简 profile):对客户端就是「这台没有这个能力」。 */
35
+ const UNAVAILABLE = Object.freeze({
36
+ code: 'plugin-market-unavailable',
37
+ message: '这台宿主的版本太旧,没有插件管理服务;请到电脑上的 DSH 里管理插件',
38
+ });
39
+
40
+ /** 承载 App 连接的那个包:只可更新,不可停用(卸载没有入口)。 */
41
+ const SELF_NAME = pkg.name;
42
+
43
+ const SPEC_MAX_CHARS = 300;
44
+ const NAME_MAX_CHARS = 300;
45
+ const REQUEST_ID_MAX_CHARS = 120;
46
+ const BUNDLES_MAX_ITEMS = 300;
47
+ /** npm 源是一个 URL,与 spec 同一个量级;备用源按顺序全报,20 条封顶。 */
48
+ const REGISTRY_MAX_CHARS = 300;
49
+ const REGISTRIES_MAX_ITEMS = 20;
50
+ /** 内存里最多留几条安装记录:手机只会盯手上这一条,多的是断线前留下的。 */
51
+ const TASKS_MAX = 20;
52
+ const BODY_MAX_BYTES = 8 * 1024;
53
+
54
+ /** 安装结果里对手机有意义的几格(宿主的 `application` / `stage` / `error` 原样透出)。 */
55
+ const APPLICATIONS = new Set(['cancelled', 'failed', 'applied', 'restart-required', 'overridden']);
56
+ const RESTART_REASONS = new Set(['no-hmr', 'package-moved', 'host-module', 'unmanaged-plugin', 'module-comparison']);
57
+
58
+ /** 一次安装的进度账(只活在本进程里;宿主重启后手机按 unknown 处理,重新读列表)。 */
59
+ const tasks = new Map();
60
+
61
+ function textOrNull(value, max = 300) {
62
+ if (typeof value !== 'string') return null;
63
+ const text = value.trim();
64
+ return text ? text.slice(0, max) : null;
65
+ }
66
+
67
+ function errorText(error) {
68
+ if (!error) return '未知错误';
69
+ if (typeof error.message === 'string' && error.message) return error.message;
70
+ return String(error);
71
+ }
72
+
73
+ /** 网关 / 服务缺失统一回同一个码,客户端据此说「这台宿主版本太旧」。 */
74
+ function normalizeCallError(error) {
75
+ if (!error) return { ...UNAVAILABLE };
76
+ if (error.code === GATEWAY_UNAVAILABLE.code || error.code === UNAVAILABLE.code) return { ...UNAVAILABLE };
77
+ const code = typeof error.code === 'string' && error.code ? error.code : 'plugin-market-failed';
78
+ return { code, message: errorText(error) };
79
+ }
80
+
81
+ function statusOf(error) {
82
+ return error && error.code === UNAVAILABLE.code ? 503 : 400;
83
+ }
84
+
85
+ /** 调一次插件管理器。args 的键名必须与宿主 descriptor 逐字一致(多一个少一个都 400)。 */
86
+ async function call(ctx, method, args, signal) {
87
+ const result = await invoke(ctx, 'pluginManager', method, args || {}, signal);
88
+ return result.error ? { error: normalizeCallError(result.error) } : { value: result.value };
89
+ }
90
+
91
+ /**
92
+ * 一次管理动作的结果投影。
93
+ *
94
+ * `application` 说这一步到底生效没有:`applied` 当场生效、`restart-required` 要等电脑上
95
+ * 下次启动 DSH、`failed` 失败、`cancelled` 被取消。手机不做二次判断,照它说。
96
+ */
97
+ function operationView(value) {
98
+ if (!value || typeof value !== 'object') return null;
99
+ const error = value.error && typeof value.error === 'object'
100
+ ? {
101
+ code: textOrNull(value.error.code, 64) || 'operation-error',
102
+ message: typeof value.error.diagnostic === 'string' && value.error.diagnostic
103
+ ? value.error.diagnostic
104
+ : '',
105
+ }
106
+ : null;
107
+ return {
108
+ changed: value.changed === true,
109
+ application: APPLICATIONS.has(value.application) ? value.application : 'failed',
110
+ restartReason: RESTART_REASONS.has(value.restartReason) ? value.restartReason : null,
111
+ stage: textOrNull(value.stage, 16),
112
+ target: textOrNull(value.target, NAME_MAX_CHARS),
113
+ version: textOrNull(value.version, 64),
114
+ pendingBuilds: Array.isArray(value.pendingBuilds)
115
+ ? value.pendingBuilds.filter((name) => typeof name === 'string').slice(0, 20)
116
+ : [],
117
+ error,
118
+ };
119
+ }
120
+
121
+ /** 显示名:宿主给的 meta 是「一句话或多语言表」,两种都原样转给 App 由它按语言挑。 */
122
+ function displayField(value) {
123
+ if (typeof value === 'string') return value;
124
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
125
+ return null;
126
+ }
127
+
128
+ function normalizeBundle(row) {
129
+ if (!row || typeof row !== 'object') return null;
130
+ const name = textOrNull(row.name, NAME_MAX_CHARS);
131
+ if (!name) return null;
132
+ const meta = row.meta && typeof row.meta === 'object' ? row.meta : null;
133
+ const installTarget = row.installTarget && typeof row.installTarget === 'object'
134
+ ? { spec: textOrNull(row.installTarget.spec, SPEC_MAX_CHARS), version: textOrNull(row.installTarget.version, 64) }
135
+ : null;
136
+ const error = row.error && typeof row.error === 'object'
137
+ ? {
138
+ code: textOrNull(row.error.code, 64) || 'operation-error',
139
+ message: typeof row.error.diagnostic === 'string' && row.error.diagnostic
140
+ ? row.error.diagnostic
141
+ : '',
142
+ }
143
+ : null;
144
+ return {
145
+ name,
146
+ version: textOrNull(row.version, 64),
147
+ title: meta ? displayField(meta.title) : null,
148
+ description: (meta ? displayField(meta.description) : null) || textOrNull(row.description, 400),
149
+ source: textOrNull(row.source, SPEC_MAX_CHARS),
150
+ enabled: row.enabled === true,
151
+ installed: row.installed === true,
152
+ official: row.official === true,
153
+ optional: row.optional === true,
154
+ removable: row.removable === true,
155
+ availability: textOrNull(row.availability, 16),
156
+ installTarget: installTarget && installTarget.spec ? installTarget : null,
157
+ /** 承载 App 连接的自己:停用一律拒绝、卸载没有入口,只留更新(见文件头)。 */
158
+ protectedSelf: name === SELF_NAME,
159
+ rowCount: Array.isArray(row.rows) ? row.rows.length : 0,
160
+ error: error || (textOrNull(row.readOnlyReason, 32) ? { code: textOrNull(row.readOnlyReason, 32), message: '' } : null),
161
+ };
162
+ }
163
+
164
+ function pruneTasks() {
165
+ if (tasks.size <= TASKS_MAX) return;
166
+ const done = [...tasks.entries()]
167
+ .filter(([, task]) => task.state !== 'running')
168
+ .sort((a, b) => a[1].startedAt - b[1].startedAt);
169
+ for (const [id] of done) {
170
+ tasks.delete(id);
171
+ if (tasks.size <= TASKS_MAX) break;
172
+ }
173
+ }
174
+
175
+ /** 新开一条安装记录:同一个 requestId 重复进来时覆盖上一条(手机重发就是重来一次)。 */
176
+ function beginTask(requestId, kind, target) {
177
+ const task = { requestId, kind, target, state: 'running', result: null, startedAt: Date.now() };
178
+ tasks.set(requestId, task);
179
+ pruneTasks();
180
+ return task;
181
+ }
182
+
183
+ function finishTask(requestId, result) {
184
+ const task = tasks.get(requestId);
185
+ if (!task) return;
186
+ task.state = 'done';
187
+ task.result = result;
188
+ task.finishedAt = Date.now();
189
+ }
190
+
191
+ /** requestId 缺省时由宿主取:手机那条路一般自己带,重发时才好对账。 */
192
+ function readRequestId(value, fallback) {
193
+ const text = textOrNull(value, REQUEST_ID_MAX_CHARS);
194
+ return text || fallback();
195
+ }
196
+
197
+ let counter = 0;
198
+ function localRequestId(prefix) {
199
+ counter += 1;
200
+ return `mobile-${prefix}-${Date.now()}-${counter}`;
201
+ }
202
+
203
+ function badRequest(res, message) {
204
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message } });
205
+ }
206
+
207
+ function normalizeSpec(value) {
208
+ const text = typeof value === 'string' ? value.trim() : '';
209
+ if (!text) return { problem: '需要包名(也可以填 Git 地址或提示里的安装命令)' };
210
+ if (text.length > SPEC_MAX_CHARS) return { problem: `包名太长(上限 ${SPEC_MAX_CHARS} 个字符)` };
211
+ if (/[\r\n\t]/.test(text)) return { problem: '包名里不能有换行或制表符' };
212
+ return { value: text };
213
+ }
214
+
215
+ function normalizeName(value) {
216
+ const text = textOrNull(value, NAME_MAX_CHARS);
217
+ if (!text) return { problem: '需要插件包名' };
218
+ if (/[\r\n\t]/.test(text)) return { problem: '包名里不能有换行或制表符' };
219
+ return { value: text };
220
+ }
221
+
222
+ /**
223
+ * 安装时指定的 npm 源。**空着与给空串都不是一件事**:宿主那边「不传 registry」= 跟随它
224
+ * 自己配置的那个源,所以没给就一个键都不留;给了就必须是一段像样的 URL 文本。
225
+ */
226
+ function normalizeRegistry(value) {
227
+ if (value === undefined || value === null) return { value: null };
228
+ if (typeof value !== 'string') return { problem: 'registry 必须是一个字符串' };
229
+ const text = value.trim();
230
+ if (!text) return { value: null };
231
+ if (text.length > REGISTRY_MAX_CHARS) return { problem: `registry 太长(上限 ${REGISTRY_MAX_CHARS} 个字符)` };
232
+ return { value: text };
233
+ }
234
+
235
+ /**
236
+ * 升级这一步的目标:官方按需插件由宿主给出**确切**的 installTarget(版本钉死在运行中的
237
+ * DSH 上);第三方插件回到注册表最新的同名包。`file:` / `link:` / git 地址这类来源没有
238
+ * 「最新版」可言,如实拒绝,让用户回电脑上换来源。
239
+ */
240
+ /**
241
+ * 从一条依赖声明里取回 npm 包名:`name@^1.2.3` / `@scope/name@~2` / 别名都收,纯版本范围
242
+ * (`^1.2.0` / `latest` / `*`)回落到包自己的名字。
243
+ */
244
+ function registryNameOf(source, fallback) {
245
+ const value = String(source || '').trim();
246
+ if (!value) return fallback;
247
+ if (/^(?:[\^~<>=*]|\d|latest$|next$|beta$|alpha$|rc$)/i.test(value)) return fallback;
248
+ const at = value.startsWith('@') ? value.indexOf('@', 1) : value.indexOf('@');
249
+ return at > 0 ? value.slice(0, at) : value;
250
+ }
251
+
252
+ function updateTargetOf(bundle) {
253
+ if (!bundle) return { problem: '这台宿主上没有这个插件' };
254
+ // 自己那条更新路不在这里:`/plugin/update/*` 有装前备份、装后自检与回滚,而通用那条
255
+ // 是先卸载再装 —— 一旦装坏,手机连发一句话的机会都没有。
256
+ if (bundle.name === SELF_NAME) return { problem: '这个插件要在「版本与更新」那一块更新(那条会自检、失败会自己退回)' };
257
+ if (bundle.installTarget && bundle.installTarget.spec) return { spec: bundle.installTarget.spec };
258
+ const source = bundle.source || '';
259
+ if (!source) return { problem: '这个插件的来源不是一个可以从 npm 升级的名字;要换版本请在电脑上重装' };
260
+ if (/^(?:file|link|workspace|git|github|gitlab|bitbucket|https?):/i.test(source) || /^\.{0,2}\//.test(source)) {
261
+ return { problem: '这个插件是从本地目录或压缩包/仓库装的,没有「最新版」可升;要换版本请在电脑上重装' };
262
+ }
263
+ // 依赖声明里的 npm spec:宿主的 `source` 可能是「name@^1.2.3」这种整条 spec,也可能只是
264
+ // 版本范围 / 标签(`^1.2.0`、`latest`)—— 后者按包本身的 name 走。
265
+ return { spec: registryNameOf(source, bundle.name) };
266
+ }
267
+
268
+ /**
269
+ * GET /api/mobile/v1/plugin/market
270
+ * 成功:{ ok:true, bundles:[...] };宿主没有插件管理器时 503。
271
+ */
272
+ async function handlePluginMarket(ctx, _req, res) {
273
+ const result = await call(ctx, 'listBundles', {});
274
+ if (result.error) return sendJson(res, statusOf(result.error), { ok: false, error: result.error });
275
+ const raw = Array.isArray(result.value) ? result.value : [];
276
+ const bundles = [];
277
+ for (const row of raw) {
278
+ const bundle = normalizeBundle(row);
279
+ if (!bundle) continue;
280
+ bundles.push(bundle);
281
+ if (bundles.length >= BUNDLES_MAX_ITEMS) break;
282
+ }
283
+ sendJson(res, 200, { ok: true, bundles, self: SELF_NAME });
284
+ }
285
+
286
+ /**
287
+ * GET /api/mobile/v1/plugin/market/registries
288
+ * 安装时能选的 npm 源:配置里的第一个源(`registry`,null = 用 pnpm 自己配置的那个)、
289
+ * 备用源(`fallbackRegistries`,按顺序)、以及 pnpm 实际读到的那个 URL(`resolved`)。
290
+ * 手机拿它做一个选择器,选中的值原样回到 `/install` 的 `registry`。
291
+ */
292
+ async function handlePluginMarketRegistries(ctx, _req, res) {
293
+ const result = await call(ctx, 'registries', {});
294
+ if (result.error) return sendJson(res, statusOf(result.error), { ok: false, error: result.error });
295
+ const value = result.value && typeof result.value === 'object' ? result.value : {};
296
+ const fallbackRegistries = [];
297
+ const raw = Array.isArray(value.fallbackRegistries) ? value.fallbackRegistries : [];
298
+ for (const item of raw) {
299
+ const text = textOrNull(item, REGISTRY_MAX_CHARS);
300
+ if (!text) continue;
301
+ fallbackRegistries.push(text);
302
+ if (fallbackRegistries.length >= REGISTRIES_MAX_ITEMS) break;
303
+ }
304
+ sendJson(res, 200, {
305
+ ok: true,
306
+ registry: textOrNull(value.registry, REGISTRY_MAX_CHARS),
307
+ fallbackRegistries,
308
+ resolved: textOrNull(value.resolved, REGISTRY_MAX_CHARS),
309
+ });
310
+ }
311
+
312
+ /**
313
+ * POST /api/mobile/v1/plugin/market/inspect { spec }
314
+ * 装之前先问一句「这个 spec 是什么」:包名、版本、一句话、有没有插件本体。
315
+ */
316
+ async function handlePluginMarketInspect(ctx, req, res) {
317
+ const body = await readJsonBody(req, BODY_MAX_BYTES);
318
+ const spec = normalizeSpec(body && body.spec);
319
+ if (!spec.value) return badRequest(res, spec.problem);
320
+ const result = await call(ctx, 'inspect', { spec: spec.value });
321
+ if (result.error) return sendJson(res, statusOf(result.error), { ok: false, error: result.error });
322
+ const value = result.value && typeof result.value === 'object' ? result.value : {};
323
+ if (value.status !== 'accepted') {
324
+ return sendJson(res, 200, {
325
+ ok: true,
326
+ accepted: false,
327
+ problem: textOrNull(value.problem, 32) || 'unknown',
328
+ reason: typeof value.reason === 'string' ? value.reason : '',
329
+ registries: Array.isArray(value.registries) ? value.registries.filter((item) => typeof item === 'string') : [],
330
+ });
331
+ }
332
+ sendJson(res, 200, {
333
+ ok: true,
334
+ accepted: true,
335
+ kind: textOrNull(value.kind, 16),
336
+ name: textOrNull(value.name, NAME_MAX_CHARS),
337
+ version: textOrNull(value.version, 64),
338
+ description: textOrNull(value.description, 400),
339
+ bundle: value.bundle === true,
340
+ registry: textOrNull(value.registry, 300),
341
+ host: textOrNull(value.host, 200),
342
+ });
343
+ }
344
+
345
+ /**
346
+ * POST /api/mobile/v1/plugin/market/install { spec, registry?, requestId?, approvedBuilds? }
347
+ * 立即返回 `{ ok:true, requestId }`;装没装完由 `/progress` 问。装一次可能几十秒到几分钟,
348
+ * 手机上不该挂一条长请求等它。
349
+ */
350
+ async function handlePluginMarketInstall(ctx, req, res) {
351
+ const body = await readJsonBody(req, BODY_MAX_BYTES);
352
+ const spec = normalizeSpec(body && body.spec);
353
+ if (!spec.value) return badRequest(res, spec.problem);
354
+ const registry = normalizeRegistry(body && body.registry);
355
+ if (registry.problem) return badRequest(res, registry.problem);
356
+ const approvedBuilds = Array.isArray(body && body.approvedBuilds)
357
+ ? body.approvedBuilds.filter((name) => typeof name === 'string' && name.trim()).slice(0, 40)
358
+ : [];
359
+ const requestId = readRequestId(body && body.requestId, () => localRequestId('install'));
360
+ beginTask(requestId, 'install', spec.value);
361
+ // 不在请求里等:宿主的 installBundle 是长任务,手机断线/切后台不该把它拖断。
362
+ void (async () => {
363
+ const options = { requestId, enabled: true };
364
+ if (approvedBuilds.length) options.approvedBuilds = approvedBuilds;
365
+ if (registry.value) options.registry = registry.value;
366
+ const result = await call(ctx, 'installBundle', { spec: spec.value, options });
367
+ finishTask(requestId, result.error
368
+ ? { ok: false, error: result.error }
369
+ : { ok: true, operation: operationView(result.value), spec: spec.value });
370
+ })();
371
+ sendJson(res, 202, { ok: true, requestId });
372
+ }
373
+
374
+ /**
375
+ * POST /api/mobile/v1/plugin/market/update { name, requestId? }
376
+ * 升级 = **卸载再装**(DSH 自己的规矩:第三方插件没有原地换代,官方按需插件则由宿主
377
+ * 给出确切目标版本)。失败时插件会停在「已卸载」,界面必须把这一点写在确认框里。
378
+ */
379
+ async function handlePluginMarketUpdate(ctx, req, res) {
380
+ const body = await readJsonBody(req, BODY_MAX_BYTES);
381
+ const name = normalizeName(body && body.name);
382
+ if (!name.value) return badRequest(res, name.problem);
383
+ const listed = await call(ctx, 'listBundles', {});
384
+ if (listed.error) return sendJson(res, statusOf(listed.error), { ok: false, error: listed.error });
385
+ const bundle = Array.isArray(listed.value) ? listed.value.map(normalizeBundle).find((item) => item && item.name === name.value) : null;
386
+ const target = updateTargetOf(bundle);
387
+ if (target.problem) return sendJson(res, 400, { ok: false, error: { code: 'no-update-target', message: target.problem } });
388
+ const requestId = readRequestId(body && body.requestId, () => localRequestId('update'));
389
+ beginTask(requestId, 'update', target.spec);
390
+ void (async () => {
391
+ const removed = await call(ctx, 'removeBundle', { name: name.value });
392
+ if (removed.error) {
393
+ finishTask(requestId, { ok: false, error: removed.error, stage: 'remove' });
394
+ return;
395
+ }
396
+ const removedView = operationView(removed.value);
397
+ if (removedView && (removedView.application === 'failed' || removedView.application === 'cancelled')) {
398
+ finishTask(requestId, { ok: true, operation: removedView, stage: 'remove' });
399
+ return;
400
+ }
401
+ const installed = await call(ctx, 'installBundle', { spec: target.spec, options: { requestId, enabled: true } });
402
+ finishTask(requestId, installed.error
403
+ ? { ok: false, error: installed.error, stage: 'install' }
404
+ : { ok: true, operation: operationView(installed.value), spec: target.spec, stage: 'install' });
405
+ })();
406
+ sendJson(res, 202, { ok: true, requestId, spec: target.spec });
407
+ }
408
+
409
+ /**
410
+ * GET /api/mobile/v1/plugin/market/progress?requestId=
411
+ * `state`:running(还在装)/ done(附 result)/ unknown(本进程没有这条记录,多半是宿主重启过)。
412
+ */
413
+ async function handlePluginMarketProgress(_ctx, req, res) {
414
+ const params = new URL(req.url || '/', 'http://x').searchParams;
415
+ const requestId = textOrNull(params.get('requestId'), REQUEST_ID_MAX_CHARS);
416
+ if (!requestId) return badRequest(res, '需要 requestId');
417
+ const task = tasks.get(requestId);
418
+ if (!task) return sendJson(res, 200, { ok: true, state: 'unknown' });
419
+ if (task.state === 'running') return sendJson(res, 200, { ok: true, state: 'running', kind: task.kind });
420
+ sendJson(res, 200, { ok: true, state: 'done', kind: task.kind, result: task.result });
421
+ }
422
+
423
+ /** POST /api/mobile/v1/plugin/market/cancel { requestId } → { status } */
424
+ async function handlePluginMarketCancel(ctx, req, res) {
425
+ const body = await readJsonBody(req, BODY_MAX_BYTES);
426
+ const requestId = textOrNull(body && body.requestId, REQUEST_ID_MAX_CHARS);
427
+ if (!requestId) return badRequest(res, '需要 requestId');
428
+ const result = await call(ctx, 'cancelInstall', { requestId });
429
+ if (result.error) return sendJson(res, statusOf(result.error), { ok: false, error: result.error });
430
+ const status = result.value && typeof result.value === 'object' ? textOrNull(result.value.status, 16) : null;
431
+ sendJson(res, 200, { ok: true, status: status || 'not-running' });
432
+ }
433
+
434
+ /** POST /api/mobile/v1/plugin/market/enabled { name, enabled } */
435
+ async function handlePluginMarketEnabled(ctx, req, res) {
436
+ const body = await readJsonBody(req, BODY_MAX_BYTES);
437
+ const name = normalizeName(body && body.name);
438
+ if (!name.value) return badRequest(res, name.problem);
439
+ const enabled = body && body.enabled === true;
440
+ if (name.value === SELF_NAME && enabled !== true) {
441
+ return sendJson(res, 403, {
442
+ ok: false,
443
+ error: {
444
+ code: 'self-protected',
445
+ message: '关掉这个插件等于断开这台手机;要停用到电脑上的设置里操作',
446
+ },
447
+ });
448
+ }
449
+ const result = await call(ctx, 'setBundleEnabled', { name: name.value, enabled });
450
+ if (result.error) return sendJson(res, statusOf(result.error), { ok: false, error: result.error });
451
+ sendJson(res, 200, { ok: true, operation: operationView(result.value) });
452
+ }
453
+
454
+ module.exports = {
455
+ handlePluginMarket,
456
+ handlePluginMarketRegistries,
457
+ handlePluginMarketInspect,
458
+ handlePluginMarketInstall,
459
+ handlePluginMarketUpdate,
460
+ handlePluginMarketProgress,
461
+ handlePluginMarketCancel,
462
+ handlePluginMarketEnabled,
463
+ // 纯逻辑(守卫直接加载)
464
+ normalizeBundle,
465
+ normalizeSpec,
466
+ normalizeName,
467
+ normalizeRegistry,
468
+ operationView,
469
+ updateTargetOf,
470
+ registryNameOf,
471
+ normalizeCallError,
472
+ SELF_NAME,
473
+ UNAVAILABLE,
474
+ tasks,
475
+ };
@@ -123,7 +123,7 @@ async function handlePluginSelf(ctx, _req, res) {
123
123
  }
124
124
 
125
125
  /**
126
- * `POST /api/mobile/v1/plugin/self/auto` —— 自动更新开关(每 10 分钟自己查一次)。
126
+ * `POST /api/mobile/v1/plugin/self/auto` —— 自动更新开关(每 2 分钟自己查一次)。
127
127
  *
128
128
  * 只收布尔:这个开关意味着「没人看着也换掉正在跑的代码」,它必须是一个明确的选择。
129
129
  * 给不出外壳(`setAutoUpdate` 不在)时回 409,不假装已经打开 —— 开了却什么都不做的开关
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * 手机端「插件更新」:APP 的「应用更新」页直接给这台电脑上的插件升级。
5
5
  *
6
- * 自动更新(外壳的 10 分钟轮询)有闸:有对话在跑就不换文件。用户在手机上聊天时这道闸几乎
6
+ * 自动更新(外壳的 2 分钟轮询)有闸:有对话在跑就不换文件。用户在手机上聊天时这道闸几乎
7
7
  * 永远关着,而更新入口此前只在电脑面板(`localOnly`),手机打不到。这三条接口把它开给已配对
8
8
  * 的手机,能力面不新增:来源判据照旧(只放行注册表来源,force 也跳不过),换代仍就地做。
9
9
  *