@snap-rail/framework-shadcn 0.0.0-stage → 0.2.0

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 (97) hide show
  1. package/README.md +20 -2
  2. package/docs/framework-authoring.md +59 -0
  3. package/lib/fallback.js +307 -0
  4. package/lib/index.js +3036 -0
  5. package/lib/runtime.js +2827 -0
  6. package/lib/semantic.js +96 -0
  7. package/lib/session.js +48 -0
  8. package/lib/settings-pages.js +68 -0
  9. package/lib/slots.js +95 -0
  10. package/lib/types/Shell.d.ts +13 -0
  11. package/lib/types/Shell.js +17 -0
  12. package/lib/types/client.d.ts +41 -0
  13. package/lib/types/client.js +39 -0
  14. package/lib/types/fallback.d.ts +15 -0
  15. package/lib/types/fallback.js +52 -0
  16. package/lib/types/index.d.ts +17 -0
  17. package/lib/types/index.js +17 -0
  18. package/lib/types/runtime.d.ts +53 -0
  19. package/lib/types/runtime.js +75 -0
  20. package/lib/types/semantic.d.ts +55 -0
  21. package/lib/types/semantic.js +63 -0
  22. package/lib/types/session.d.ts +43 -0
  23. package/lib/types/session.js +76 -0
  24. package/lib/types/settings-dialog.d.ts +17 -0
  25. package/lib/types/settings-dialog.js +138 -0
  26. package/lib/types/settings-pages.d.ts +68 -0
  27. package/lib/types/settings-pages.js +85 -0
  28. package/lib/types/settings-plugins-page.d.ts +29 -0
  29. package/lib/types/settings-plugins-page.js +466 -0
  30. package/lib/types/settings-update-page.d.ts +17 -0
  31. package/lib/types/settings-update-page.js +124 -0
  32. package/lib/types/slots.d.ts +114 -0
  33. package/lib/types/slots.js +119 -0
  34. package/lib/types/ui/badge.d.ts +17 -0
  35. package/lib/types/ui/badge.js +29 -0
  36. package/lib/types/ui/button.d.ts +20 -0
  37. package/lib/types/ui/button.js +39 -0
  38. package/lib/types/ui/card.d.ts +14 -0
  39. package/lib/types/ui/card.js +18 -0
  40. package/lib/types/ui/checkbox.d.ts +10 -0
  41. package/lib/types/ui/checkbox.js +13 -0
  42. package/lib/types/ui/collapsible.d.ts +24 -0
  43. package/lib/types/ui/collapsible.js +31 -0
  44. package/lib/types/ui/dialog.d.ts +22 -0
  45. package/lib/types/ui/dialog.js +29 -0
  46. package/lib/types/ui/drag-scroll.d.ts +13 -0
  47. package/lib/types/ui/drag-scroll.js +85 -0
  48. package/lib/types/ui/hooks.d.ts +16 -0
  49. package/lib/types/ui/hooks.js +26 -0
  50. package/lib/types/ui/icons.d.ts +21 -0
  51. package/lib/types/ui/icons.js +18 -0
  52. package/lib/types/ui/index.d.ts +32 -0
  53. package/lib/types/ui/index.js +32 -0
  54. package/lib/types/ui/input.d.ts +8 -0
  55. package/lib/types/ui/input.js +6 -0
  56. package/lib/types/ui/label.d.ts +9 -0
  57. package/lib/types/ui/label.js +12 -0
  58. package/lib/types/ui/led.d.ts +13 -0
  59. package/lib/types/ui/led.js +11 -0
  60. package/lib/types/ui/number-input.d.ts +35 -0
  61. package/lib/types/ui/number-input.js +48 -0
  62. package/lib/types/ui/progress.d.ts +10 -0
  63. package/lib/types/ui/progress.js +14 -0
  64. package/lib/types/ui/radio-group.d.ts +12 -0
  65. package/lib/types/ui/radio-group.js +17 -0
  66. package/lib/types/ui/schema-form.d.ts +51 -0
  67. package/lib/types/ui/schema-form.js +124 -0
  68. package/lib/types/ui/scroll-area.d.ts +10 -0
  69. package/lib/types/ui/scroll-area.js +13 -0
  70. package/lib/types/ui/switch.d.ts +10 -0
  71. package/lib/types/ui/switch.js +13 -0
  72. package/lib/types/ui/table.d.ts +20 -0
  73. package/lib/types/ui/table.js +28 -0
  74. package/lib/types/ui/tabs.d.ts +16 -0
  75. package/lib/types/ui/tabs.js +23 -0
  76. package/lib/types/ui/textarea.d.ts +10 -0
  77. package/lib/types/ui/textarea.js +6 -0
  78. package/lib/types/ui/theme.d.ts +30 -0
  79. package/lib/types/ui/theme.js +33 -0
  80. package/lib/types/ui/tooltip.d.ts +23 -0
  81. package/lib/types/ui/tooltip.js +15 -0
  82. package/lib/types/ui/touch-select.d.ts +29 -0
  83. package/lib/types/ui/touch-select.js +37 -0
  84. package/lib/types/ui/utils.d.ts +13 -0
  85. package/lib/types/ui/utils.js +16 -0
  86. package/lib/types/variables.d.ts +168 -0
  87. package/lib/types/variables.js +282 -0
  88. package/lib/types/workflows.d.ts +98 -0
  89. package/lib/types/workflows.js +95 -0
  90. package/lib/ui/index.js +1101 -0
  91. package/lib/ui/theme.css +271 -0
  92. package/lib/variables.js +260 -0
  93. package/lib/workflows.js +85 -0
  94. package/package.json +138 -3
  95. package/src/ui/theme.css +271 -0
  96. package/templates/ui-extension/README.md +17 -0
  97. package/templates/ui-view/README.md +17 -0
@@ -0,0 +1,466 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * The 插件 page — plugin management and discovery fused into one surface. The
4
+ * top row is a single tri-form input: a local path or an http(s) address
5
+ * inspects that zip (a plugin, or an offline combo package with `recipe.yml`
6
+ * at its root; both preview → confirm → install), a dropped file does the
7
+ * same, anything else searches every configured plugin feed (merged, rows
8
+ * carry their source). Below, every installed (non-builtin) row lists in
9
+ * one table — 名称/标识 split into columns, the version cell doubling as
10
+ * the update affordance (a muted pill normally; a success-colored pill with
11
+ * a refresh glyph and the feed version when an update is pending — clicking
12
+ * it updates directly, behind a key-change confirmation when the feed entry
13
+ * is signed by a different key than the installed copy). The preview shows
14
+ * the three-state signature verdict per plugin (a combo shows each member's
15
+ * own verdict — the archive itself is unsigned); an unverified member takes
16
+ * one extra explicit risk step before it installs. The feed sources hide
17
+ * behind a small icon button before the input; they default from the app's
18
+ * shipped `defaults.json` until the user writes their own list. Every write
19
+ * lands in `plugins.yml`/`settings.json` through the plugins and settings
20
+ * domains; the files stay the single interface.
21
+ *
22
+ * @module @snap-rail/framework-shadcn/settings-plugins-page
23
+ */
24
+ // Wire rows for the plugins-domain methods this page calls.
25
+ import '@snap-rail/app-boot/contract';
26
+ import { rpcErrorText, subscribeFrame } from '@snap-rail/connection';
27
+ import { PLUGINS_FEED_URLS_KEY, pluginUpdatesChangedSchema, } from '@snap-rail/app-boot/contract';
28
+ import { Badge, Button, Dialog, DialogContent, DialogFooter, DialogTitle, Input, Switch, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "./ui/index.js";
29
+ import { RefreshCw, Rss, Search } from 'lucide-react';
30
+ import { useCallback, useEffect, useState } from 'react';
31
+ /** The preview's signature row: one line, color carries the verdict. */
32
+ function SignatureRow({ signature, directUrl }) {
33
+ if (signature.state === 'verified') {
34
+ return (_jsxs("div", { className: "flex justify-between gap-4", "data-signature-row": "verified", children: [_jsx("span", { className: "text-muted-foreground", children: "\u7B7E\u540D" }), _jsxs("span", { children: ["\u2713 \u5DF2\u9A8C\u8BC1", signature.publisher !== undefined ? ` · ${signature.publisher}` : ` · ${signature.keyId}`] })] }));
35
+ }
36
+ const detail = signature.keyId !== undefined
37
+ ? `未验证 · keyId ${signature.keyId}(未登记)`
38
+ : '未签名';
39
+ return (_jsxs("div", { className: "flex justify-between gap-4 text-warning", "data-signature-row": "unverified", children: [_jsx("span", { className: "text-muted-foreground", children: "\u7B7E\u540D" }), _jsxs("span", { children: ["\u26A0 ", detail, directUrl ? ' · 直链无完整性校验' : ''] })] }));
40
+ }
41
+ /** Kind → display label; shared with the search results' rows. */
42
+ const KIND_LABELS = {
43
+ suite: '业务套件',
44
+ driver: '通讯驱动',
45
+ plugin: '插件',
46
+ };
47
+ /** The section a package groups under: a declared category wins whatever the
48
+ * source (dev's overlay mounts installable packages as protected builtin
49
+ * rows, and they keep their author markers); without one, installed and
50
+ * preset-carried rows fall into 其他 and the protected shipped set — which
51
+ * declares no category — pins into the single 内置 section. */
52
+ const sectionOf = (row) => row.category ?? (row.source === 'pool' || row.source === 'preset' ? '其他' : '内置');
53
+ /** The drag-and-drop bridge: turn a dropped `File` back into its host-side
54
+ * path through the preload's `zipPathOf` (`webUtils.getPathForFile`). Null
55
+ * when the carrier offers no bridge (test hosts, non-Electron shells). */
56
+ function droppedZipPath(file) {
57
+ const zipPathOf = globalThis.snapRail?.zipPathOf;
58
+ if (typeof zipPathOf !== 'function')
59
+ return null;
60
+ const path = zipPathOf(file);
61
+ return typeof path === 'string' && path !== '' ? path : null;
62
+ }
63
+ /** Does the input name an http(s) address? */
64
+ const isHttpUrl = (text) => /^https?:\/\//i.test(text);
65
+ /** Does the input look like a filesystem path (drive letter, UNC, posix
66
+ * root, or a .zip name with a separator in it)? */
67
+ const looksLikePath = (text) => /^[a-zA-Z]:[\\/]/.test(text) || text.startsWith('\\\\') || text.startsWith('/')
68
+ || (/\.zip$/i.test(text) && /[/\\]/.test(text));
69
+ /** The version cell: a muted pill normally; when a feed update is pending,
70
+ * a success-colored pill carrying the refresh glyph and the feed version —
71
+ * clicking it updates directly (a key change confirmation intercepts first
72
+ * when the feed entry is signed by another key). */
73
+ function VersionPill(props) {
74
+ const { update } = props;
75
+ if (update !== undefined) {
76
+ return (_jsxs(Button, { size: "sm", variant: "outline", "data-update-version": update.name, disabled: props.busy, title: update.keyChanged === true
77
+ ? `当前 ${update.installedVersion} → ${update.feedVersion},发布者密钥已变更,点击确认更新`
78
+ : `当前 ${update.installedVersion} → ${update.feedVersion},点击更新`, className: "h-7 bg-success/10 px-2 font-mono text-xs text-success", onClick: () => props.onUpdate(update), children: [_jsx(RefreshCw, { className: "h-4 w-4" }), props.busy ? '更新中…' : update.feedVersion] }));
79
+ }
80
+ return (_jsx("span", { className: "inline-flex h-7 items-center rounded-md border border-border bg-muted px-2 font-mono text-xs text-muted-foreground", children: props.version ?? '—' }));
81
+ }
82
+ /** The plugins page. */
83
+ export function PluginsPage({ ctx }) {
84
+ const [rows, setRows] = useState([]);
85
+ const [failed, setFailed] = useState(false);
86
+ const [rendererNames, setRendererNames] = useState([]);
87
+ /** Rows changed since page open — the restart-required surface. */
88
+ const [touched, setTouched] = useState(new Set());
89
+ const [restartPrompt, setRestartPrompt] = useState(false);
90
+ const [uninstall, setUninstall] = useState({ phase: 'idle' });
91
+ const [installPhase, setInstallPhase] = useState({ phase: 'idle' });
92
+ // The tri-form input and its one non-address outcome: a feed search.
93
+ const [query, setQuery] = useState('');
94
+ const [searchResults, setSearchResults] = useState(undefined);
95
+ const [searching, setSearching] = useState(false);
96
+ const [actionError, setActionError] = useState(undefined);
97
+ /** The name whose update click is in flight (version pill busy state). */
98
+ const [updatingName, setUpdatingName] = useState(undefined);
99
+ /** The pending update whose click hit a key change — confirmed before the
100
+ * install fires. */
101
+ const [keyChange, setKeyChange] = useState(undefined);
102
+ // The feed-source dialog: the draft list, its input, and its own error line.
103
+ const [feedDialogOpen, setFeedDialogOpen] = useState(false);
104
+ const [feedUrls, setFeedUrls] = useState([]);
105
+ const [feedDraft, setFeedDraft] = useState('');
106
+ const [feedError, setFeedError] = useState(undefined);
107
+ /** Pending updates by package name (drives the version pills). */
108
+ const [updates, setUpdates] = useState(new Map());
109
+ const [dragging, setDragging] = useState(false);
110
+ const link = ctx.get('client').link;
111
+ const reload = useCallback(() => {
112
+ void link.call('plugins.list', {}).then(result => {
113
+ if (result.ok) {
114
+ setRows(result.value.plugins.map(info => ({
115
+ name: info.name, source: info.source, enabled: info.enabled,
116
+ packageName: info.packageName,
117
+ ...info.kind !== undefined ? { kind: info.kind } : {},
118
+ ...info.version !== undefined ? { version: info.version } : {},
119
+ ...info.title !== undefined ? { title: info.title } : {},
120
+ ...info.category !== undefined ? { category: info.category } : {},
121
+ ...info.clientOnly ? { clientOnly: true } : {},
122
+ })));
123
+ }
124
+ else
125
+ setFailed(true);
126
+ }).catch(() => setFailed(true));
127
+ // Rides every reload: installs and uninstalls change which pool faces
128
+ // exist, and the restart prompt keys off this list.
129
+ void link.call('client-config.list', {}).then(result => {
130
+ if (result.ok)
131
+ setRendererNames(result.value.rows.map(row => row.name));
132
+ }).catch(() => undefined);
133
+ // The pending-update set keys the version pills.
134
+ void link.call('plugins.updates-check', {}).then(result => {
135
+ if (result.ok) {
136
+ setUpdates(new Map(result.value.updates.map(update => [update.name, update])));
137
+ }
138
+ }).catch(() => undefined);
139
+ // eslint-disable-next-line react-hooks/exhaustive-deps
140
+ }, [link]);
141
+ useEffect(() => {
142
+ reload();
143
+ // The user's feed list rides settings.json — read once for the dialog
144
+ // (an empty read means the factory default list is in force).
145
+ void link.call('settings.get', { key: PLUGINS_FEED_URLS_KEY }).then(result => {
146
+ if (result.ok && Array.isArray(result.value.value)) {
147
+ setFeedUrls(result.value.value.filter((entry) => typeof entry === 'string'));
148
+ }
149
+ }).catch(() => undefined);
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ }, [reload]);
152
+ useEffect(() => {
153
+ // The host sweep announces pending updates on the frame; the page folds
154
+ // them straight into the version pills.
155
+ const detach = subscribeFrame(link, 'plugins/updates-changed', pluginUpdatesChangedSchema, change => {
156
+ setUpdates(new Map(change.updates.map(update => [update.name, update])));
157
+ });
158
+ return () => { detach(); };
159
+ // eslint-disable-next-line react-hooks/exhaustive-deps
160
+ }, [link]);
161
+ const toggle = (name, enabled) => {
162
+ setRows(current => current.map(row => row.name === name ? { ...row, enabled } : row));
163
+ if (rendererNames.includes(name)) {
164
+ setTouched(current => new Set([...current, name]));
165
+ setRestartPrompt(true);
166
+ }
167
+ void link.call('plugins.set-enabled', { name, enabled })
168
+ .then(result => {
169
+ if (!result.ok) {
170
+ setRows(current => current.map(row => row.name === name ? { ...row, enabled: !enabled } : row));
171
+ setTouched(current => { const next = new Set(current); next.delete(name); return next; });
172
+ }
173
+ })
174
+ .catch(() => {
175
+ setRows(current => current.map(row => row.name === name ? { ...row, enabled: !enabled } : row));
176
+ setTouched(current => { const next = new Set(current); next.delete(name); return next; });
177
+ });
178
+ };
179
+ /** The uninstall affordance opens a confirm first — the package and its
180
+ * data are deleted for real. */
181
+ const requestUninstall = (pkg) => {
182
+ setUninstall({ phase: 'confirm', pkg });
183
+ };
184
+ const runUninstall = (pkg) => {
185
+ void link.call('plugins.uninstall', { name: pkg }).then(result => {
186
+ if (!result.ok) {
187
+ setUninstall({ phase: 'error', message: rpcErrorText(result.error) });
188
+ return;
189
+ }
190
+ setUninstall({ phase: 'idle' });
191
+ reload();
192
+ // The renderer may still carry the uninstalled face until it reboots.
193
+ if (rendererNames.includes(pkg)) {
194
+ setTouched(current => new Set([...current, pkg]));
195
+ setRestartPrompt(true);
196
+ }
197
+ }).catch(cause => setUninstall({ phase: 'error', message: String(cause) }));
198
+ };
199
+ const markRestartIfNeeded = (name, updated) => {
200
+ if (updated && rendererNames.includes(name)) {
201
+ setTouched(current => new Set([...current, name]));
202
+ setRestartPrompt(true);
203
+ }
204
+ };
205
+ /** Inspect a zip the host can already reach (a local path, a picked file,
206
+ * or a dropped one). */
207
+ const inspectPath = (zipPath) => {
208
+ setInstallPhase({ phase: 'idle' });
209
+ void link.call('plugins.inspect', { zipPath }).then(result => {
210
+ if (!result.ok) {
211
+ setInstallPhase({ phase: 'error', message: rpcErrorText(result.error) });
212
+ return;
213
+ }
214
+ setInstallPhase({ phase: 'confirm', zipPath, archive: result.value.archive, viaUrl: false, risk: false });
215
+ }).catch(cause => setInstallPhase({ phase: 'error', message: String(cause) }));
216
+ };
217
+ /** Download and inspect a direct http(s) address — the host owns the
218
+ * temp file; its path feeds the confirm → install legs. */
219
+ const inspectFromUrl = (url, source) => {
220
+ setInstallPhase({ phase: 'idle' });
221
+ void link.call('plugins.inspect-from-url', { url }).then(result => {
222
+ if (!result.ok) {
223
+ setInstallPhase({ phase: 'error', message: rpcErrorText(result.error) });
224
+ return;
225
+ }
226
+ setInstallPhase({
227
+ phase: 'confirm',
228
+ zipPath: result.value.zipPath,
229
+ archive: result.value.archive,
230
+ viaUrl: true,
231
+ ...(source !== undefined ? { source } : {}),
232
+ risk: false,
233
+ });
234
+ }).catch(cause => setInstallPhase({ phase: 'error', message: String(cause) }));
235
+ };
236
+ const runInstall = (zipPath, archive) => {
237
+ setInstallPhase({ phase: 'installing' });
238
+ if (archive.kind === 'combo') {
239
+ void link.call('plugins.install-combo', { zipPath }).then(result => {
240
+ if (!result.ok) {
241
+ setInstallPhase({ phase: 'error', message: rpcErrorText(result.error) });
242
+ return;
243
+ }
244
+ setInstallPhase({ phase: 'idle' });
245
+ reload();
246
+ // A combo lands a preset that mounts its rows immediately; a renderer
247
+ // face may wait for the next boot, same surface as an in-place update.
248
+ if (archive.combo.hasClient)
249
+ setRestartPrompt(true);
250
+ }).catch(cause => setInstallPhase({ phase: 'error', message: String(cause) }));
251
+ return;
252
+ }
253
+ void link.call('plugins.install', { zipPath }).then(result => {
254
+ if (!result.ok) {
255
+ setInstallPhase({ phase: 'error', message: rpcErrorText(result.error) });
256
+ return;
257
+ }
258
+ setInstallPhase({ phase: 'idle' });
259
+ reload();
260
+ // A fresh install lands disabled (no restart prompt). An update swaps
261
+ // the package under a possibly-running face, so the renderer may carry
262
+ // the old bundle until it reboots — same surface as the uninstall path.
263
+ markRestartIfNeeded(result.value.installed.name, result.value.installed.updated);
264
+ }).catch(cause => setInstallPhase({ phase: 'error', message: String(cause) }));
265
+ };
266
+ const pickZip = () => {
267
+ void link.call('window.pick-zip', { title: '选择插件包或组合包' }).then(result => {
268
+ if (!result.ok || result.value === null)
269
+ return;
270
+ inspectPath(result.value);
271
+ }).catch(() => undefined);
272
+ };
273
+ /** The tri-form input: address/path → inspect, anything else → feed search. */
274
+ const submitQuery = () => {
275
+ const text = query.trim();
276
+ if (text === '')
277
+ return;
278
+ setActionError(undefined);
279
+ if (isHttpUrl(text)) {
280
+ inspectFromUrl(text);
281
+ return;
282
+ }
283
+ if (looksLikePath(text)) {
284
+ inspectPath(text);
285
+ return;
286
+ }
287
+ runSearch(text);
288
+ };
289
+ const runSearch = (text) => {
290
+ setSearching(true);
291
+ setSearchResults(undefined);
292
+ void link.call('plugins.remote-list', {}).then(result => {
293
+ setSearching(false);
294
+ if (!result.ok) {
295
+ setActionError(rpcErrorText(result.error));
296
+ return;
297
+ }
298
+ const needle = text.toLowerCase();
299
+ setSearchResults(result.value.plugins.filter(item => item.name.toLowerCase().includes(needle)
300
+ || (item.title ?? '').toLowerCase().includes(needle)
301
+ || (item.description ?? '').toLowerCase().includes(needle)));
302
+ }).catch(cause => {
303
+ setSearching(false);
304
+ setActionError(String(cause));
305
+ });
306
+ };
307
+ /** A search-result entry that is not installed yet: preview first. The
308
+ * entry's own coordinates decide where the zip comes from — a federation
309
+ * row downloads from the author's release, a same-directory row from its
310
+ * feed base. */
311
+ const previewRemote = (item) => {
312
+ if (item.url !== undefined) {
313
+ inspectFromUrl(item.url, item.source);
314
+ return;
315
+ }
316
+ if (item.feedUrl === undefined || item.file === undefined)
317
+ return;
318
+ const base = item.feedUrl.endsWith('/') ? item.feedUrl : `${item.feedUrl}/`;
319
+ inspectFromUrl(`${base}${item.file}`, item.source);
320
+ };
321
+ /** The version pill's click: a plain update installs directly; a key
322
+ * change confirmation intercepts first. */
323
+ const requestUpdate = (update) => {
324
+ if (update.keyChanged === true) {
325
+ setKeyChange(update);
326
+ return;
327
+ }
328
+ updateNow(update.name);
329
+ };
330
+ /** The update itself: the feed's entry, no preview. */
331
+ const updateNow = (name) => {
332
+ setKeyChange(undefined);
333
+ setUpdatingName(name);
334
+ setActionError(undefined);
335
+ void link.call('plugins.remote-install', { name }).then(result => {
336
+ setUpdatingName(undefined);
337
+ if (!result.ok) {
338
+ setActionError(rpcErrorText(result.error));
339
+ return;
340
+ }
341
+ reload();
342
+ markRestartIfNeeded(result.value.installed.name, result.value.installed.updated);
343
+ }).catch(() => setUpdatingName(undefined));
344
+ };
345
+ const saveFeedUrls = () => {
346
+ for (const url of feedUrls) {
347
+ try {
348
+ const parsed = new URL(url);
349
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
350
+ throw new Error('protocol');
351
+ }
352
+ catch {
353
+ setFeedError('插件源地址需要以 http(s) 开头');
354
+ return;
355
+ }
356
+ }
357
+ setFeedError(undefined);
358
+ void link.call('settings.set', { key: PLUGINS_FEED_URLS_KEY, value: feedUrls }).then(result => {
359
+ if (!result.ok) {
360
+ setFeedError(rpcErrorText(result.error));
361
+ return;
362
+ }
363
+ setFeedDialogOpen(false);
364
+ }).catch(cause => setFeedError(String(cause)));
365
+ };
366
+ const appendFeedUrl = () => {
367
+ const value = feedDraft.trim();
368
+ if (value === '')
369
+ return;
370
+ try {
371
+ const parsed = new URL(value);
372
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
373
+ throw new Error('protocol');
374
+ }
375
+ catch {
376
+ setFeedError('请填写 http(s) 开头的插件源地址');
377
+ return;
378
+ }
379
+ setFeedError(undefined);
380
+ setFeedDraft('');
381
+ setFeedUrls(current => current.includes(value) ? current : [...current, value]);
382
+ };
383
+ const onDrop = (event) => {
384
+ event.preventDefault();
385
+ setDragging(false);
386
+ const file = event.dataTransfer.files[0];
387
+ if (file === undefined)
388
+ return;
389
+ if (!file.name.toLowerCase().endsWith('.zip')) {
390
+ setActionError('只支持 .zip 插件包或组合包');
391
+ return;
392
+ }
393
+ const zipPath = droppedZipPath(file);
394
+ if (zipPath === null) {
395
+ setActionError('当前环境不支持拖拽安装(缺少路径桥)');
396
+ return;
397
+ }
398
+ inspectPath(zipPath);
399
+ };
400
+ const relaunch = () => {
401
+ void link.call('window.relaunch', {}).catch(() => undefined);
402
+ };
403
+ if (failed)
404
+ return _jsx("div", { className: "text-sm text-muted-foreground", children: "\u63D2\u4EF6\u5217\u8868\u8BFB\u53D6\u5931\u8D25\u3002" });
405
+ const needsRestart = (name) => touched.has(name);
406
+ // Built-ins never list: the shipped set is read-only by design (every
407
+ // operation on it is refused), so listing un-actionable rows is noise.
408
+ // Dev's overlay mounts installable packages as protected builtin rows —
409
+ // they hide here too; the layer files remain the development levers.
410
+ const listed = rows.filter(row => row.source !== 'builtin');
411
+ const confirmed = installPhase.phase === 'confirm' ? installPhase : undefined;
412
+ const confirmedPlugin = confirmed?.archive.kind === 'plugin' ? confirmed.archive.plugin : undefined;
413
+ const confirmedCombo = confirmed?.archive.kind === 'combo' ? confirmed.archive.combo : undefined;
414
+ return (_jsxs("div", { "data-region": "plugins-page", className: "flex min-h-0 flex-1 flex-col gap-4", onDragOver: event => {
415
+ event.preventDefault();
416
+ setDragging(true);
417
+ }, onDragLeave: () => setDragging(false), onDrop: event => onDrop(event), children: [dragging && (_jsx("div", { className: "pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-background", children: _jsx("div", { className: "rounded-lg border-2 border-dashed border-primary px-8 py-10 text-base text-primary", children: "\u677E\u5F00\u4EE5\u5B89\u88C5\u63D2\u4EF6\u5305\u6216\u7EC4\u5408\u5305\uFF08.zip\uFF09" }) })), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "ghost", size: "icon", "aria-label": "\u914D\u7F6E\u63D2\u4EF6\u6E90", "data-feed-config": true, className: "h-9 w-9 shrink-0 text-muted-foreground", onClick: () => setFeedDialogOpen(true), children: _jsx(Rss, { className: "h-4 w-4" }) }), _jsx(Input, { value: query, placeholder: "\u7C98\u8D34\u672C\u5730/\u7F51\u7EDC\u5730\u5740\u5B89\u88C5\uFF0C\u6216\u8F93\u5165\u540D\u79F0\u641C\u7D22\u2026", "data-plugin-query": true, className: "flex-1", onChange: event => setQuery(event.target.value), onKeyDown: event => { if (event.key === 'Enter')
418
+ submitQuery(); } }), _jsxs(Button, { variant: "outline", "data-plugin-search": true, disabled: searching, onClick: submitQuery, children: [_jsx(Search, { className: "h-4 w-4" }), searching ? '搜索中…' : '搜索'] }), _jsx(Button, { variant: "outline", "data-install-plugin": true, onClick: pickZip, children: "\u9009\u62E9\u6587\u4EF6" })] }), actionError !== undefined && (_jsx("p", { "data-plugin-action-error": true, className: "text-sm text-destructive", children: actionError })), searchResults !== undefined && (_jsxs("div", { "data-region": "plugin-search-results", className: "flex flex-col gap-2 rounded-md border border-border p-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("span", { className: "text-sm font-medium", children: ["\u641C\u7D22\u201C", query.trim(), "\u201D\uFF08", searchResults.length, "\uFF09"] }), _jsx(Button, { variant: "ghost", size: "sm", "aria-label": "\u5173\u95ED\u641C\u7D22\u7ED3\u679C", onClick: () => setSearchResults(undefined), children: "\u2715" })] }), searchResults.length === 0 && (_jsx("p", { className: "py-2 text-center text-sm text-muted-foreground", children: "\u6CA1\u6709\u5339\u914D\u7684\u63D2\u4EF6\uFF08\u63D2\u4EF6\u6E90\u4E2D\u6CA1\u6709\u8BE5\u540D\u79F0\u7684\u6761\u76EE\uFF09\u3002" })), searchResults.map(item => {
419
+ const pillUpdate = item.action === 'update' && item.installed !== undefined
420
+ ? {
421
+ name: item.name,
422
+ installedVersion: item.installed.version,
423
+ feedVersion: item.version,
424
+ ...(item.source !== undefined ? { title: item.source } : {}),
425
+ }
426
+ : undefined;
427
+ return (_jsxs("div", { "data-search-row": item.name, className: "flex items-center justify-between gap-3 rounded-md border border-border px-4 py-3", children: [_jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [_jsx("span", { className: "truncate text-sm font-medium", children: item.title ?? item.name }), item.kind !== undefined && _jsx(Badge, { variant: "secondary", children: KIND_LABELS[item.kind] ?? item.kind }), item.source !== undefined && _jsx(Badge, { variant: "outline", "data-search-source": item.name, children: item.source })] }), _jsxs("span", { className: "truncate font-mono text-xs text-muted-foreground", children: [item.name, " \u00B7 ", item.version] }), item.description !== undefined && item.description !== '' && (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: item.description }))] }), item.action === 'install'
428
+ ? _jsx(Button, { size: "sm", "data-search-install": item.name, onClick: () => previewRemote(item), children: "\u5B89\u88C5" })
429
+ : pillUpdate !== undefined
430
+ ? _jsx(VersionPill, { update: pillUpdate, busy: updatingName === item.name, onUpdate: requestUpdate })
431
+ : _jsx(Badge, { variant: "secondary", children: item.action === 'current' ? '已是最新' : '本地更新' })] }, item.name));
432
+ })] })), _jsxs("div", { className: "shrink-0 text-sm font-medium text-muted-foreground", children: ["\u5DF2\u5B89\u88C5\u63D2\u4EF6\uFF08", listed.length, "\uFF09"] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto rounded-md border border-border", children: _jsxs(Table, { "data-region": "plugins-table", children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { className: "w-14", children: "\u542F\u7528" }), _jsx(TableHead, { children: "\u540D\u79F0" }), _jsx(TableHead, { children: "\u6807\u8BC6" }), _jsx(TableHead, { className: "w-24", children: "\u5206\u7C7B" }), _jsx(TableHead, { className: "w-36", children: "\u7248\u672C" }), _jsx(TableHead, { className: "w-24", children: "\u64CD\u4F5C" })] }) }), _jsxs(TableBody, { children: [listed.map(row => {
433
+ const label = row.title ?? row.name;
434
+ return (_jsxs(TableRow, { "data-plugin-row": row.name, children: [_jsx(TableCell, { children: _jsx(Switch, { "aria-label": `启用 ${label}`, checked: row.enabled, onCheckedChange: checked => toggle(row.name, checked) }) }), _jsx(TableCell, { children: _jsxs("span", { className: "flex items-center gap-2 truncate text-sm font-medium", children: [label, needsRestart(row.name) && _jsx(Badge, { variant: "destructive", children: "\u5F85\u91CD\u542F" })] }) }), _jsx(TableCell, { className: "font-mono text-xs text-muted-foreground", children: row.name }), _jsx(TableCell, { className: "text-sm text-muted-foreground", children: row.category ?? '其他' }), _jsx(TableCell, { children: _jsx(VersionPill, { version: row.version, update: updates.get(row.packageName), busy: updatingName === row.packageName, onUpdate: requestUpdate }) }), _jsx(TableCell, { children: row.source === 'pool'
435
+ ? (_jsx(Button, { "data-uninstall-plugin": row.packageName, variant: "ghost", size: "sm", className: "text-destructive hover:text-destructive", onClick: () => requestUninstall(row.packageName), children: "\u5378\u8F7D" }))
436
+ : _jsx("span", { className: "text-xs text-muted-foreground", children: "\u2014" }) })] }, row.name));
437
+ }), listed.length === 0 && (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 6, className: "py-8 text-center text-sm text-muted-foreground", children: "\u6682\u65E0\u5DF2\u5B89\u88C5\u63D2\u4EF6 \u2014\u2014 \u7C98\u8D34\u5730\u5740\u3001\u62D6\u5165\u63D2\u4EF6/\u7EC4\u5408 .zip\uFF0C\u6216\u8F93\u5165\u540D\u79F0\u4ECE\u63D2\u4EF6\u6E90\u641C\u7D22\u5B89\u88C5\u3002" }) }))] })] }) }), restartPrompt && (_jsx(Dialog, { open: true, onOpenChange: next => { if (!next)
438
+ setRestartPrompt(false); }, children: _jsxs(DialogContent, { "aria-describedby": undefined, className: "max-w-sm", children: [_jsx(DialogTitle, { className: "text-base font-medium", children: "\u53D8\u66F4\u5F85\u91CD\u542F\u751F\u6548" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "\u4E1A\u52A1\u5957\u4EF6\u4E0E\u9875\u9762\u7C7B\u63D2\u4EF6\u7684\u542F\u7528\u53D8\u66F4\u5C06\u5728\u4E0B\u6B21\u542F\u52A8\u65F6\u751F\u6548\uFF1B\u63D2\u4EF6\u66F4\u65B0\u6216\u5378\u8F7D\u540E\u7684\u754C\u9762\u6B8B\u7559\u4E5F\u4F1A\u968F\u4E4B\u6D88\u5931\u3002" }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setRestartPrompt(false), children: "\u7A0D\u540E" }), _jsx(Button, { "data-restart-now": true, onClick: () => relaunch(), children: "\u7ACB\u5373\u91CD\u542F" })] })] }) })), confirmed !== undefined && (_jsx(Dialog, { open: true, onOpenChange: next => { if (!next)
439
+ setInstallPhase({ phase: 'idle' }); }, children: _jsx(DialogContent, { "aria-describedby": undefined, className: "max-w-sm", children: confirmed.risk ? (_jsxs(_Fragment, { children: [_jsx(DialogTitle, { className: "text-base font-medium", "data-install-risk": true, children: confirmedCombo !== undefined ? '⚠ 此组合含未经发布者验证的插件' : '⚠ 此插件未经发布者验证' }), _jsxs("div", { className: "grid gap-2 text-sm", children: [confirmedPlugin !== undefined && (_jsxs("div", { className: "rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm", children: [_jsx("p", { "data-risk-package": true, className: "font-mono", children: confirmedPlugin.name }), _jsx("p", { className: "mt-1 text-muted-foreground", children: confirmedPlugin.signature.keyId !== undefined
440
+ ? `签名 keyId ${confirmedPlugin.signature.keyId} 不在本机信任名单中,无法验证发布者。`
441
+ : '这个包没有携带任何签名,也没有任何完整性校验。' })] })), confirmedCombo !== undefined && (_jsxs("div", { className: "rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm", children: [_jsxs("p", { "data-risk-package": true, className: "font-mono", children: [confirmedCombo.title, " \u00B7 ", confirmedCombo.id] }), _jsx("p", { className: "mt-1 text-muted-foreground", children: "\u7EC4\u5408\u5305\u672C\u8EAB\u6CA1\u6709\u7B7E\u540D\uFF0C\u4EE5\u4E0B\u6210\u5458\u65E0\u6CD5\u9A8C\u8BC1\u53D1\u5E03\u8005\uFF1A" }), _jsx("ul", { className: "mt-1 grid gap-1", children: confirmedCombo.plugins
442
+ .filter(plugin => plugin.signature.state === 'unverified')
443
+ .map(plugin => (_jsxs("li", { className: "font-mono text-xs", children: [plugin.name, " \u00B7 ", plugin.signature.keyId ?? '未签名'] }, plugin.name))) })] })), _jsx("p", { className: "text-muted-foreground", children: "\u00B7 \u8BF7\u786E\u8BA4\u63D2\u4EF6\u6765\u6E90\u53EF\u4FE1\u540E\u518D\u7EE7\u7EED\uFF1B\u5B89\u88C5\u540E\u53EF\u5728\u672C\u673A\u4FE1\u4EFB\u6587\u4EF6\u767B\u8BB0\u5176\u516C\u94A5\u3002" })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setInstallPhase({ ...confirmed, risk: false }), children: "\u8FD4\u56DE" }), _jsx(Button, { "data-confirm-risk-install": true, onClick: () => runInstall(confirmed.zipPath, confirmed.archive), children: "\u6211\u77E5\u6653\u98CE\u9669\uFF0C\u4ECD\u8981\u5B89\u88C5" })] })] })) : confirmedCombo !== undefined ? (_jsxs(_Fragment, { children: [_jsx(DialogTitle, { className: "text-base font-medium", children: "\u5B89\u88C5\u7EC4\u5408\u5957\u4EF6" }), _jsxs("div", { className: "grid gap-2 text-sm", children: [_jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u7EC4\u5408\u540D" }), _jsx("span", { className: "truncate", children: confirmedCombo.title })] }), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u7EC4\u5408 ID" }), _jsx("span", { className: "truncate font-mono", children: confirmedCombo.id })] }), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u7248\u672C" }), _jsx("span", { className: "font-mono", children: confirmedCombo.version })] }), confirmedCombo.description !== undefined && (_jsx("p", { className: "text-xs text-muted-foreground", children: confirmedCombo.description })), confirmed.source !== undefined && (_jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u6765\u6E90" }), _jsx("span", { children: confirmed.source })] })), _jsx("div", { className: "grid max-h-48 gap-1 overflow-auto rounded-md border border-border px-3 py-2", children: confirmedCombo.plugins.map(plugin => {
444
+ const action = plugin.action === 'install' ? '安装' : plugin.action === 'update' ? '更新' : '保留';
445
+ const unverified = plugin.signature.state === 'unverified';
446
+ return (_jsxs("div", { "data-combo-member": plugin.name, className: "flex items-baseline justify-between gap-3 text-xs", children: [_jsx("span", { className: "truncate", children: plugin.title ?? plugin.name }), _jsxs("span", { className: `shrink-0 font-mono ${unverified ? 'text-warning' : 'text-muted-foreground'}`, children: [plugin.version, " \u00B7 ", action, unverified ? ' · ⚠ 未验证' : ''] })] }, plugin.name));
447
+ }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: "\u7EC4\u5408\u5305\u672C\u8EAB\u4E0D\u7B7E\u540D\uFF1B\u6BCF\u4E2A\u6210\u5458\u90FD\u7ECF\u4E0E\u5355\u63D2\u4EF6\u76F8\u540C\u7684\u7B7E\u540D\u6821\u9A8C\u3002" }), confirmedCombo.missing.length > 0 && (_jsxs("p", { "data-combo-missing": true, className: "rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive", children: ["\u7F3A\u5C11\u4F9D\u8D56\uFF1A", confirmedCombo.missing.join('、'), "\u2014\u2014\u7EC4\u5408\u4F1A\u7ACB\u5373\u542F\u7528\u5168\u90E8\u884C\uFF0C\u88C5\u9F50\u540E\u518D\u5B89\u88C5\u3002"] })), confirmedCombo.hasClient && (_jsx("p", { className: "text-xs text-muted-foreground", children: "\u7EC4\u5408\u542B\u754C\u9762\u63D2\u4EF6\uFF1B\u88C5\u597D\u540E\u91CD\u542F\u4E00\u6B21\u8BA9\u754C\u9762\u751F\u6548\u3002" }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setInstallPhase({ phase: 'idle' }), children: "\u53D6\u6D88" }), _jsx(Button, { "data-confirm-install-combo": true, disabled: confirmedCombo.missing.length > 0, onClick: () => {
448
+ if (confirmedCombo.plugins.some(plugin => plugin.signature.state === 'unverified')) {
449
+ setInstallPhase({ ...confirmed, risk: true });
450
+ return;
451
+ }
452
+ runInstall(confirmed.zipPath, confirmed.archive);
453
+ }, children: "\u5B89\u88C5\u7EC4\u5408" })] })] })) : confirmedPlugin !== undefined ? (_jsxs(_Fragment, { children: [_jsx(DialogTitle, { className: "text-base font-medium", children: confirmedPlugin.action === 'update' ? '更新插件' : '安装插件' }), _jsxs("div", { className: "grid gap-2 text-sm", children: [confirmedPlugin.title !== undefined && (_jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u540D\u79F0" }), _jsx("span", { className: "truncate", children: confirmedPlugin.title })] })), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u5305\u540D" }), _jsx("span", { className: "truncate font-mono", children: confirmedPlugin.name })] }), confirmedPlugin.action === 'update' ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u5DF2\u5B89\u88C5\u7248\u672C" }), _jsx("span", { className: "font-mono", children: confirmedPlugin.installed?.version })] }), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u5C06\u66F4\u65B0\u81F3" }), _jsx("span", { className: "font-mono", children: confirmedPlugin.version })] })] })) : (_jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u7248\u672C" }), _jsx("span", { className: "font-mono", children: confirmedPlugin.version })] })), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u5206\u7C7B" }), _jsx("span", { children: confirmedPlugin.category ?? '其他' })] }), confirmed.source !== undefined && (_jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u6765\u6E90" }), _jsx("span", { children: confirmed.source })] })), _jsx(SignatureRow, { signature: confirmedPlugin.signature, directUrl: confirmed.viaUrl }), confirmedPlugin.hasClient && _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u754C\u9762" }), _jsx("span", { children: "\u542B\u9875\u9762" })] }), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-muted-foreground", children: "\u6743\u9650" }), _jsx("span", { children: confirmedPlugin.permissions.length === 0 ? '无' : confirmedPlugin.permissions.join('、') })] }), confirmedPlugin.missing.length > 0 && (_jsxs("p", { className: "rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive", children: ["\u5C1A\u672A\u5B89\u88C5\u7684\u4F9D\u8D56\uFF1A", confirmedPlugin.missing.join('、'), "\u2014\u2014\u53EF\u5148\u5B89\u88C5\u672C\u5305\uFF0C\u542F\u7528\u524D\u9700\u88C5\u9F50\u3002"] })), confirmedPlugin.action === 'update' && (_jsx("p", { className: "text-xs text-muted-foreground", children: "\u8986\u76D6\u540E\u4FDD\u7559\u8BE5\u63D2\u4EF6\u7684\u914D\u7F6E\u4E0E\u6570\u636E\uFF1B\u8FD0\u884C\u4E2D\u7684\u754C\u9762\u9700\u91CD\u542F\u540E\u751F\u6548\u3002" })), confirmedPlugin.action === 'blocked' && (_jsxs("p", { "data-install-blocked": true, className: "text-sm text-destructive", children: ["\u5DF2\u5B89\u88C5 v", confirmedPlugin.installed?.version, "\uFF0C\u5B89\u88C5\u5305 v", confirmedPlugin.version, " \u4E0D\u9AD8\u4E8E\u5DF2\u88C5\u7248\u672C\uFF0C\u65E0\u6CD5\u8986\u76D6\uFF08\u5378\u8F7D\u540E\u53EF\u964D\u7EA7\u5B89\u88C5\uFF09\u3002"] }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setInstallPhase({ phase: 'idle' }), children: "\u53D6\u6D88" }), _jsx(Button, { "data-confirm-install": true, disabled: confirmedPlugin.action === 'blocked', onClick: () => {
454
+ if (confirmedPlugin.signature.state === 'unverified') {
455
+ setInstallPhase({ ...confirmed, risk: true });
456
+ return;
457
+ }
458
+ runInstall(confirmed.zipPath, confirmed.archive);
459
+ }, children: confirmedPlugin.action === 'update' ? '更新' : '安装' })] })] })) : null }) })), keyChange !== undefined && (_jsx(Dialog, { open: true, onOpenChange: next => { if (!next)
460
+ setKeyChange(undefined); }, children: _jsxs(DialogContent, { "aria-describedby": undefined, className: "max-w-sm", children: [_jsx(DialogTitle, { className: "text-base font-medium", children: "\u53D1\u5E03\u8005\u5BC6\u94A5\u5DF2\u53D8\u66F4" }), _jsxs("div", { className: "grid gap-2 text-sm", children: [_jsxs("p", { className: "text-muted-foreground", children: [_jsx("span", { className: "font-mono text-foreground", children: keyChange.name }), ' ', "\u7684\u65B0\u7248\u672C\u7531\u4E0D\u540C\u7684\u5BC6\u94A5\u7B7E\u540D\u3002\u82E5\u53D1\u5E03\u8005\u786E\u5B9E\u8F6E\u6362\u4E86\u5BC6\u94A5\uFF0C\u66F4\u65B0\u662F\u5B89\u5168\u7684\uFF1B\u5426\u5219\u4E0D\u8981\u7EE7\u7EED\u3002"] }), _jsxs("div", { className: "rounded-md border border-warning/30 bg-warning/10 px-3 py-2", children: [_jsxs("p", { children: ["\u5DF2\u5B89\u88C5 v", keyChange.installedVersion, " \u2192 \u6E90\u4E0A v", keyChange.feedVersion] }), _jsx("p", { className: "mt-1 text-muted-foreground", children: "\u8BF7\u6838\u5BF9\u53D1\u5E03\u8005\u516C\u544A\u4E2D\u7684\u65B0 keyId \u540E\u518D\u51B3\u5B9A\u3002" })] })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setKeyChange(undefined), children: "\u53D6\u6D88" }), _jsx(Button, { "data-confirm-key-change": true, onClick: () => updateNow(keyChange.name), children: "\u4ECD\u8981\u66F4\u65B0" })] })] }) })), installPhase.phase === 'error' && (_jsx(Dialog, { open: true, onOpenChange: next => { if (!next)
461
+ setInstallPhase({ phase: 'idle' }); }, children: _jsxs(DialogContent, { "aria-describedby": undefined, className: "max-w-sm", children: [_jsx(DialogTitle, { className: "text-base font-medium", children: "\u5B89\u88C5\u5931\u8D25" }), _jsx("p", { "data-install-error": true, className: "text-sm text-destructive", children: installPhase.message }), _jsx(DialogFooter, { children: _jsx(Button, { onClick: () => setInstallPhase({ phase: 'idle' }), children: "\u77E5\u9053\u4E86" }) })] }) })), uninstall.phase === 'confirm' && (_jsx(Dialog, { open: true, onOpenChange: next => { if (!next)
462
+ setUninstall({ phase: 'idle' }); }, children: _jsxs(DialogContent, { "aria-describedby": undefined, className: "max-w-sm", children: [_jsx(DialogTitle, { className: "text-base font-medium", children: "\u5378\u8F7D\u63D2\u4EF6" }), _jsxs("p", { className: "text-sm text-muted-foreground", children: ["\u5C06\u4ECE\u63D2\u4EF6\u6C60\u79FB\u9664 ", _jsx("span", { className: "font-mono text-foreground", children: uninstall.pkg }), "\uFF0C\u5176\u6570\u636E\u4E00\u5E76\u5220\u9664\uFF0C\u4E0D\u53EF\u6062\u590D\u3002"] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setUninstall({ phase: 'idle' }), children: "\u53D6\u6D88" }), _jsx(Button, { variant: "destructive", "data-confirm-uninstall": true, onClick: () => runUninstall(uninstall.pkg), children: "\u5378\u8F7D" })] })] }) })), uninstall.phase === 'error' && (_jsx(Dialog, { open: true, onOpenChange: next => { if (!next)
463
+ setUninstall({ phase: 'idle' }); }, children: _jsxs(DialogContent, { "aria-describedby": undefined, className: "max-w-sm", children: [_jsx(DialogTitle, { className: "text-base font-medium", children: "\u5378\u8F7D\u5931\u8D25" }), _jsx("p", { "data-uninstall-error": true, className: "text-sm text-destructive", children: uninstall.message }), _jsx(DialogFooter, { children: _jsx(Button, { onClick: () => setUninstall({ phase: 'idle' }), children: "\u77E5\u9053\u4E86" }) })] }) })), feedDialogOpen && (_jsx(Dialog, { open: true, onOpenChange: next => { if (!next)
464
+ setFeedDialogOpen(false); }, children: _jsxs(DialogContent, { "aria-describedby": undefined, className: "max-w-md", children: [_jsx(DialogTitle, { className: "text-base font-medium", children: "\u63D2\u4EF6\u6E90" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("label", { className: "text-sm text-muted-foreground", htmlFor: "plugin-feed-url", children: "\u641C\u7D22\u4E0E\u5728\u7EBF\u66F4\u65B0\u4F7F\u7528\u7684\u63D2\u4EF6\u6E90\u5217\u8868\uFF1A" }), feedUrls.length === 0 && (_jsx("p", { className: "text-xs text-muted-foreground", children: "\u5C1A\u672A\u6DFB\u52A0\u81EA\u5B9A\u4E49\u6E90\uFF08\u5F53\u524D\u4F7F\u7528\u51FA\u5382\u9ED8\u8BA4\u6E90\uFF09\u3002" })), feedUrls.map((url, index) => (_jsxs("div", { "data-feed-row": url, className: "flex items-center gap-2 rounded-md border border-border px-3 py-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate font-mono text-xs", children: url }), _jsx(Button, { variant: "ghost", size: "sm", "aria-label": `移除 ${url}`, "data-feed-remove": index, onClick: () => setFeedUrls(current => current.filter((_, at) => at !== index)), children: "\u2715" })] }, url))), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { id: "plugin-feed-url", value: feedDraft, placeholder: "https://\u2026\uFF08\u6307\u5411\u542B index.json \u7684\u76EE\u5F55\uFF09", "data-feed-url-input": true, onChange: event => setFeedDraft(event.target.value), onKeyDown: event => { if (event.key === 'Enter')
465
+ appendFeedUrl(); } }), _jsx(Button, { variant: "outline", "data-feed-add": true, onClick: appendFeedUrl, children: "\u6DFB\u52A0\u6E90" })] }), feedError !== undefined && _jsx("p", { className: "text-sm text-destructive", children: feedError }), _jsx("p", { className: "text-xs text-muted-foreground", children: "\u672A\u4FEE\u6539\u65F6\u4F7F\u7528\u51FA\u5382\u9ED8\u8BA4\u6E90\uFF1B\u4FEE\u6539\u540E\u4FDD\u5B58\u5728\u672C\u673A\uFF0C\u4EC5\u5F71\u54CD\u672C\u673A\u3002\u793E\u533A\u63D2\u4EF6\u6E90\u5C31\u662F\u63D2\u4EF6\u4F5C\u8005\u81EA\u5DF1\u4ED3\u5E93\u7684 Release \u5730\u5740\u3002" })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setFeedDialogOpen(false), children: "\u53D6\u6D88" }), _jsx(Button, { "data-feed-save": true, onClick: saveFeedUrls, children: "\u4FDD\u5B58" })] })] }) }))] }));
466
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The 软件更新 settings page: one live snapshot of the update channel plus
3
+ * its two configuration seats. The phase (and progress) arrive as full
4
+ * `update/status` snapshots, so the view is a pure projection — check /
5
+ * download / restart-to-install buttons map one-to-one onto the update
6
+ * domain's methods, and the feed URL / auto-check seats write
7
+ * `settings.json` keys (hot-applied, no restart).
8
+ *
9
+ * @module @snap-rail/framework-shadcn/settings-update-page
10
+ */
11
+ import '@snap-rail/app-boot/contract';
12
+ import type { Context } from '@snap-rail/cordis';
13
+ import { type ReactNode } from 'react';
14
+ /** The 软件更新 page. */
15
+ export declare function UpdatePage({ ctx }: {
16
+ ctx: Context;
17
+ }): ReactNode;