dsh-mobile-plugin 0.4.0 → 0.4.4

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 (111) hide show
  1. package/README.md +71 -74
  2. package/dsh-mobile-credentials/lib/credentials.js +47 -24
  3. package/host.js +22 -36
  4. package/lib/client.js +29 -82
  5. package/lib/desktop-view.cjs +5 -10
  6. package/lib/git/read.js +6 -8
  7. package/lib/host-compat.cjs +13 -25
  8. package/lib/host-guard.cjs +9 -39
  9. package/lib/host-patches.cjs +5 -16
  10. package/lib/http/routing.js +7 -27
  11. package/lib/impl.js +6 -12
  12. package/lib/index.js +1 -1
  13. package/lib/mirror/pusher.cjs +33 -85
  14. package/lib/mirror-snapshot.cjs +6 -24
  15. package/lib/mobile/agent-presets.js +6 -26
  16. package/lib/mobile/allowed-zone.js +100 -0
  17. package/lib/mobile/approvals.js +18 -44
  18. package/lib/mobile/archive-clean.js +13 -21
  19. package/lib/mobile/attachments.js +16 -37
  20. package/lib/mobile/channels.js +15 -41
  21. package/lib/mobile/credential-upgrade.js +7 -16
  22. package/lib/mobile/credentials.js +5 -21
  23. package/lib/mobile/directory-browse.js +8 -27
  24. package/lib/mobile/directory-listing.js +10 -23
  25. package/lib/mobile/drive-roots.js +59 -0
  26. package/lib/mobile/envelope.js +4 -15
  27. package/lib/mobile/events.js +35 -65
  28. package/lib/mobile/extensions.js +5 -8
  29. package/lib/mobile/file-read.js +168 -0
  30. package/lib/mobile/git-repos.js +6 -15
  31. package/lib/mobile/git.js +11 -19
  32. package/lib/mobile/history-derive.js +85 -134
  33. package/lib/mobile/host-bridge/endpoints.js +4 -6
  34. package/lib/mobile/host-bridge/event-synthesis.js +21 -35
  35. package/lib/mobile/host-bridge/shims.js +12 -22
  36. package/lib/mobile/host-bridge.js +8 -20
  37. package/lib/mobile/host-settings.js +9 -19
  38. package/lib/mobile/inbox-clean.js +7 -24
  39. package/lib/mobile/local-images.js +48 -23
  40. package/lib/mobile/model-vendors.js +105 -0
  41. package/lib/mobile/monitor.js +8 -17
  42. package/lib/mobile/pairing.js +7 -19
  43. package/lib/mobile/plugin-market.js +475 -0
  44. package/lib/mobile/plugin-self.js +6 -23
  45. package/lib/mobile/plugin-update.js +8 -27
  46. package/lib/mobile/plugins.js +6 -24
  47. package/lib/mobile/questions.js +15 -40
  48. package/lib/mobile/references.js +11 -31
  49. package/lib/mobile/relay-control.js +136 -33
  50. package/lib/mobile/rpc.js +5 -13
  51. package/lib/mobile/schedule.js +8 -33
  52. package/lib/mobile/self-check.cjs +9 -36
  53. package/lib/mobile/session-cache.js +13 -19
  54. package/lib/mobile/session-cwd.js +28 -0
  55. package/lib/mobile/session-file.js +9 -33
  56. package/lib/mobile/session-flags.js +7 -13
  57. package/lib/mobile/session-mirror.js +8 -15
  58. package/lib/mobile/session-models.js +3 -3
  59. package/lib/mobile/session-schedule-mark.js +8 -14
  60. package/lib/mobile/session-send.js +13 -21
  61. package/lib/mobile/session-snapshot.js +4 -6
  62. package/lib/mobile/sessions.js +7 -23
  63. package/lib/mobile/stats-collect.cjs +154 -37
  64. package/lib/mobile/stats-ledger.cjs +258 -75
  65. package/lib/mobile/stats-scheduler.cjs +41 -0
  66. package/lib/mobile/stream.js +39 -130
  67. package/lib/mobile/subagents.js +5 -18
  68. package/lib/mobile/uploads.js +75 -21
  69. package/lib/mobile/vendor-logos.cjs +30 -0
  70. package/lib/mobile/wire-cut.js +212 -0
  71. package/lib/mobile/workspaces.js +20 -8
  72. package/lib/panel/index.html +7 -7
  73. package/lib/read-receipt.cjs +10 -21
  74. package/lib/relay/apply-ledger.cjs +52 -0
  75. package/lib/relay/core.js +14 -3
  76. package/lib/relay/lan-network.cjs +14 -28
  77. package/lib/relay/lan-proxy.js +14 -21
  78. package/lib/relay/migrate.js +90 -0
  79. package/lib/relay/pairing-policy.js +6 -9
  80. package/lib/relay/preview.js +8 -25
  81. package/lib/relay/protocol.js +9 -12
  82. package/lib/relay/status.js +5 -8
  83. package/lib/relay/tunnel.js +9 -14
  84. package/lib/relay/verify.js +52 -0
  85. package/lib/routes/control-plane.js +39 -47
  86. package/lib/routes/mobile-api.js +47 -34
  87. package/lib/session-cleanup.cjs +26 -27
  88. package/lib/shared/credentials-entry.js +4 -11
  89. package/lib/shared/device-headers.js +12 -4
  90. package/lib/shared/dshtoken.js +4 -4
  91. package/lib/shared/e2ee-request.js +1 -1
  92. package/lib/shared/e2ee.js +8 -14
  93. package/lib/shared/fresh-request.js +6 -22
  94. package/lib/shared/http-util.js +3 -4
  95. package/lib/shared/list-revision.js +5 -14
  96. package/lib/shared/session-signal.js +7 -16
  97. package/lib/shared/sse-write.js +14 -20
  98. package/lib/shared/workspace-guard.js +7 -15
  99. package/lib/shell/device-ota.js +4 -6
  100. package/lib/shell/manager.js +12 -27
  101. package/lib/shell/ota-package.js +7 -12
  102. package/lib/shell/runtime.js +8 -16
  103. package/lib/shell/self-check-after-swap.js +9 -23
  104. package/lib/shell/updater.js +15 -28
  105. package/lib/sidecar-facade-worker.cjs +75 -41
  106. package/lib/sidecar-facade.cjs +18 -8
  107. package/lib/sidecar-lifecycle.cjs +21 -10
  108. package/lib/update-install.cjs +24 -62
  109. package/lib/update-registry.cjs +6 -18
  110. package/package.json +1 -1
  111. 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
+ };
@@ -3,29 +3,12 @@
3
3
  /**
4
4
  * 移动端「这台设备上的移动端插件」自我管理:版本、来源、运行状态、更新与回滚结果。
5
5
  *
6
- * ## 范围:只管**我们自己这一个插件**
6
+ * 只管 dsh-mobile-plugin 自己在这台电脑上的版本,不碰电脑上装的全部插件:动别人的插件会把
7
+ * 承载 App 连接的那条链一起带走,而手机没有在电脑前的那双手。判定只有一份真源(外壳的
8
+ * `dshMobilePluginRuntime`),这里只做投影与瘦身。
7
9
  *
8
- * 手机管的是 `dsh-mobile-plugin` 自己在这台电脑上的版本 —— 不是电脑上装的全部插件
9
- * (那是桌面「设置 → 插件 / 插件市场」的事,App 这边不碰)。理由很直接:动别人的插件会
10
- * 把承载 App 连接的那条链一起带走,而手机没有在电脑前的那双手。
11
- *
12
- * ## 与 `/plugin/update/*` 那三条的关系
13
- *
14
- * 那三条是上一版给「应用更新」页做的,只有 `status / check / apply`。这里补的是同一件事
15
- * 在**设备页**里需要的另外几格:来源与版本要不要并成一行、更新完**有没有自动退回去**、
16
- * 以及「这一台能不能更新」为什么不给按钮的原因。判定仍然只有一份真源(外壳的
17
- * `dshMobilePluginRuntime`),这里只做投影与瘦身,不自己算。
18
- *
19
- * ## 回滚是宿主自己做的,不是手机按的
20
- *
21
- * 装坏了 App 就连不上这台设备 —— 那时候手机上任何「回滚」按钮都发不出去。所以外壳在
22
- * 装之前备份正在跑的那一代、装完自检(见 `lib/shell/self-check-after-swap.js`),
23
- * 不过就自己退回去。手机端只做两件事:
24
- * · 更新后断了就按现有逻辑重连(`usePluginUpdate` 的轮询本身);
25
- * · 连上之后读 `rolledBack`,如实显示「上次装失败了,已自动退回 X」。
26
- *
27
- * `rolledBack` 由外壳落进 `lastApply`,这里只认它、**不重新判**:判据写在会真去医院的那
28
- * 一头,手机端各写一套只会出现两套口径。
10
+ * 回滚是宿主自己做的,不是手机按的:装坏了 App 就连不上,那时手机上的回滚按钮发不出去。
11
+ * 外壳装前备份、装完自检,不过就自己退回去;手机只读 `rolledBack` 如实显示,不重新判。
29
12
  */
30
13
 
31
14
  const { sendJson, readJsonBody } = require('../shared/http-util');
@@ -140,7 +123,7 @@ async function handlePluginSelf(ctx, _req, res) {
140
123
  }
141
124
 
142
125
  /**
143
- * `POST /api/mobile/v1/plugin/self/auto` —— 自动更新开关(每 10 分钟自己查一次)。
126
+ * `POST /api/mobile/v1/plugin/self/auto` —— 自动更新开关(每 2 分钟自己查一次)。
144
127
  *
145
128
  * 只收布尔:这个开关意味着「没人看着也换掉正在跑的代码」,它必须是一个明确的选择。
146
129
  * 给不出外壳(`setAutoUpdate` 不在)时回 409,不假装已经打开 —— 开了却什么都不做的开关
@@ -3,35 +3,16 @@
3
3
  /**
4
4
  * 手机端「插件更新」:APP 的「应用更新」页直接给这台电脑上的插件升级。
5
5
  *
6
- * ## 为什么手机端要有这条路
6
+ * 自动更新(外壳的 2 分钟轮询)有闸:有对话在跑就不换文件。用户在手机上聊天时这道闸几乎
7
+ * 永远关着,而更新入口此前只在电脑面板(`localOnly`),手机打不到。这三条接口把它开给已配对
8
+ * 的手机,能力面不新增:来源判据照旧(只放行注册表来源,force 也跳不过),换代仍就地做。
7
9
  *
8
- * 自动更新(外壳里那个 10 分钟轮询)有一道闸:**有对话在跑就不换文件**,等下一轮。用户在
9
- * 手机上聊天时,这道闸几乎永远关着,而更新入口此前只在桌面控制面(`localOnly`)—— 手机
10
- * 根本打不到。于是「插件有新版」这件事对手机用户等于不存在。这三条接口就是把它开给已配对
11
- * 的手机。
10
+ * apply 只回一句「开始了」:装一次要跑包管理器,装完换代会把当前这一代实现连同这条路由一起
11
+ * 卸掉,旧代码手里的响应发不出去,结果只能靠前端轮询 status。状态里给身份而不是时间戳,
12
+ * 两台机器的钟不一定对得上。
12
13
  *
13
- * 能力面是**有意放开**的:手机本来就握着这台宿主的凭据、能发消息,而「换掉宿主上跑的插件
14
- * 代码」比发消息重。所以入口只做一件事、且沿用已有的两道既有判据,不新增任何能力:
15
- *
16
- * · **来源判据不放开**:`update-install.cjs` 的 `installAllowedFor` 只放行注册表来源
17
- * (`npm`)。本地目录 / `file:` tgz / `workspace:` 装的机器(开发机就是)即便带
18
- * `force` 也会被拒 —— force 只跳过「有对话在跑」和「新发布先晾一会儿」,跳不过来源。
19
- * · **换代仍然就地做**:装完由外壳 `runtime.reload()` 换实现代,不重启 DSH(见 lib/index.js)。
20
- *
21
- * ## 为什么 apply 只回一句「开始了」
22
- *
23
- * 装一次要跑包管理器,几十秒到几分钟;装完换代会把**当前这一代实现连同这条路由一起卸掉**,
24
- * 旧代码手里的响应发不出去。所以结果不能靠这条请求的返回值,只能靠前端轮询 status。
25
- * 顺带一个好处:apply 期间没有在飞请求,换代时 `runtime.drain()` 不用等谁。
26
- *
27
- * 状态里给的是**身份**而不是时间戳:前端用「运行中版本变了」或「appliedAt 变了」判完成,
28
- * 手机与电脑的钟不一定对得上,比较时间会误判。
29
- *
30
- * ## 三条接口
31
- *
32
- * · `GET /api/mobile/v1/plugin/update/status` 运行中/盘上版本、安装来源、上一轮更新结果
33
- * · `POST /api/mobile/v1/plugin/update/check` 问 npm 注册表有没有新版(与桌面面板同一个实现)
34
- * · `POST /api/mobile/v1/plugin/update/apply` 开始一次强制更新,立即返回
14
+ * 三条接口:status(版本 / 来源 / 上一轮结果)、check(问 npm,与桌面同一实现)、
15
+ * apply(强制更新,立即返回)。
35
16
  */
36
17
 
37
18
  const path = require('node:path');
@@ -3,32 +3,14 @@
3
3
  /**
4
4
  * 移动端「插件清单」API:把桌面端 设置 → 插件 的只读快照搬到手机上。
5
5
  *
6
- * 桌面端由 dsh-client-ui-settings-plugin-inventory 渲染:一个可搜索的紧凑卡片列表,
7
- * 每张卡片用模块短名作标题、用小标签表示有效启停、用彩色圆点表示根 fiber 状态,
8
- * 展开后展示 Loader 条目 id 与 Cordis 状态;选中该标签页时才懒调用一次
9
- * `ctx.remote.pluginInventory.list()`,不订阅 Loader 变化(重新打开设置才取新快照)。
6
+ * 宿主服务 pluginInventory 不在 apiProxy 上,是 Typert Remote 服务,手机侧经
7
+ * host-bridge/transport.js 的 `invoke` 到达(由它补 Peer 身份与错误收敛)。
10
8
  *
11
- * 关键:这个宿主服务 **不在 apiProxy 上** —— 它是 Typert Remote 服务
12
- * (dsh-host-plugin-inventory 的 pluginInventory)。手机侧本来就能到它:
13
- * lib/mobile/sessions.js 早已派发 commands.execute,lib/mobile/references.js 通了
14
- * fileReferences / sessionReferenceResolver,这里照搬同一条路 —— 都经
15
- * host-bridge/transport.js 的 `invoke`,由它统一补 Peer 身份与网关错误收敛。
9
+ * 这个方法没有声明任何参数,args 必须是空对象,多给一个字段就会被拒。返回值是 decode 后的
10
+ * 业务结果本体(`{ entries: […] }`),不是 `{ok,value}` 外壳。语义按宿主原文透传:moduleName
11
+ * 是 npm 包名,enabled 是有效启停(与 fiberPhase 不是一回事),fiberPhase 可能为 null。
16
12
  *
17
- * 接口(只转发宿主的部署快照,不做任何加工):
18
- * - GET /api/mobile/v1/plugins → pluginInventory.list
19
- *
20
- * 参数:这个方法 **没有声明任何参数**,args 必须是空对象——assertExactArguments
21
- * 按声明逐位比对,多给一个字段就会被拒。
22
- *
23
- * 返回值形状:`gateway.invoke()` 回的是 decode 后的业务结果本体,不是
24
- * {ok:true,value} 外壳(那层壳是浏览器传输加的,见 lib/mobile/references.js 里的长注释)。
25
- * pluginInventory.list 声明的是 { entries: […] } 对象,所以 invoke 回来就是这个对象。
26
- *
27
- * 语义按宿主原文透传:
28
- * - moduleName 是 npm 包名(如 @deepseek-ai/dsh-host-directory-picker-native),
29
- * 展示用的短名截断属于客户端的事。
30
- * - enabled 是「有效启停状态」,和 fiberPhase 不是一回事:停用的条目 fiberPhase 是 null。
31
- * - fiberPhase 只有 failed/pending/active/loading/unloading 五种,宿主也可能给 null。
13
+ * 接口:GET /api/mobile/v1/plugins → pluginInventory.list。
32
14
  */
33
15
 
34
16
  const { sendJson } = require('../shared/http-util');