diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4b3887977..09e90df47 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -531,7 +531,12 @@ export const adminApi = { apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data), pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), - pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data), + // `version` pins the exact version to install (the rollback path); omitted, the server + // resolves the newest TREK-compatible version itself. + pluginUpdate: (id: string, version?: string) => + apiClient.post(`/admin/plugins/${id}/update`, version ? { version } : {}).then(r => r.data), + // Release a per-plugin update hold (set by a deliberate non-latest install). + pluginResumeUpdates: (id: string) => apiClient.post(`/admin/plugins/${id}/resume-updates`).then(r => r.data), // Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the // full key the admin was shown (not a fingerprint): the server compares it exactly, so // it can refuse if the registry entry was re-keyed again since the dialog rendered. diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 371df1556..693925b0c 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -201,6 +201,9 @@ function registryEntry(over: Record = {}) { id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify', type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null, screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + // The real browse response always carries latestCompatible (server-side hostCompat); + // mirror `latest` by default, exactly like a fully-compatible entry. + latestCompatible: (over.latest as string | undefined) ?? '2.0.0', ...over, } } @@ -1087,6 +1090,235 @@ describe('AdminPluginsPanel — updates', () => { }) }) +/** + * Update availability must follow what this TREK can actually INSTALL (latestCompatible, + * computed server-side), never the absolute newest published version — a banner counting + * versions the update endpoint would refuse nags the admin toward a guaranteed 400. + */ +describe('AdminPluginsPanel — compatible updates only', () => { + const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false }) + + it('FE-COMP-PLUGINS-UPD-001: an update this TREK cannot install is neither offered nor counted', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })], + }) + render() + await screen.findByText('Gotify') + + expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument() + expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-002: the update offer is the newest compatible version, not the absolute latest', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })], + }) + render() + + expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument() + expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-003: a newer version needing a newer TREK leaves a passive hint on the row', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })], + }) + render() + + expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-004: a legacy entry with only minTrekVersion still gets a hint range', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: null, minTrekVersion: '4.0.0', hostVersion: '3.3.0' })], + }) + render() + + expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-005: no hint when the latest version is the one on offer', async () => { + panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] }) + render() + + expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument() + expect(screen.queryByText(/needs TREK/i)).not.toBeInTheDocument() + }) +}) + +/** + * The update hold (#plugins): a deliberate non-latest install sets updateHold on the row, + * which takes it out of the banner count and "Update all" — a rollback the admin just + * made must not be nagged straight back. The row says so and offers to resume. + */ +describe('AdminPluginsPanel — update hold', () => { + const held = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false, updateHold: true }) + + it('FE-COMP-PLUGINS-HOLD-001: a held plugin leaves the banner and loses its update button', async () => { + panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] }) + render() + await screen.findByText('Gotify') + + expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument() + expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-HOLD-002: Resume updates releases the hold on the server', async () => { + let resumed = false + panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => { + resumed = true + return HttpResponse.json({ updateHold: false }) + })) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /resume updates/i })) + + await waitFor(() => expect(resumed).toBe(true)) + expect(await screen.findByText('Updates resumed')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-HOLD-003: Update all skips held plugins', async () => { + const updated: string[] = [] + panelWith( + [held, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })], + { registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] }, + ) + server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => { + updated.push(String(params.id)) + return HttpResponse.json({ version: '3.0.0', activated: true, newPermissions: [], newEgress: [] }) + })) + withToast() + + expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /update all/i })) + + await waitFor(() => expect(updated).toEqual(['trek-ntfy'])) + }) +}) + +/** + * The version picker (#plugins): install any published version from the Discover detail + * modal, and roll an installed plugin back through the update pipeline (which owns the + * stop-child + re-consent machinery). Compat verdicts are SERVER-computed per version — + * the picker only renders them, it never re-derives range logic. + */ +describe('AdminPluginsPanel — version picker', () => { + const VERSIONS = [ + { version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false }, + { version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true }, + { version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true }, + ] + const detailWithVersions = () => ({ + ...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }), + size: 1024, publishedAt: null, manifest: null, versions: VERSIONS, + }) + + function mockDiscoverDetail() { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })), + http.get('*/api/admin/plugins/registry', () => + HttpResponse.json([registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })])), + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())), + ) + } + + it('FE-COMP-PLUGINS-VPICK-001: the detail modal lists every version and installs the picked one', async () => { + let body: unknown = null + mockDiscoverDetail() + server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' }) + })) + withToast() + const tabs = await screen.findAllByRole('tab', { name: /^discover/i }) + fireEvent.click(tabs[0]) + fireEvent.click(await screen.findByText('Gotify')) + + fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i })) + + await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' })) + }) + + it('FE-COMP-PLUGINS-VPICK-002: an incompatible version is greyed with its TREK requirement, not installable', async () => { + mockDiscoverDetail() + render() + const tabs = await screen.findAllByRole('tab', { name: /^discover/i }) + fireEvent.click(tabs[0]) + fireEvent.click(await screen.findByText('Gotify')) + + expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-VPICK-003: Change version rolls back through update, after an explicit data warning', async () => { + let body: unknown = null + panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false })], { + registry: [registryEntry({ latest: '2.0.0' })], + }) + server.use( + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())), + http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] }) + }), + ) + withToast() + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + fireEvent.click(await screen.findByRole('button', { name: /change version/i })) + fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i })) + + // The rollback keeps the plugin's data — the older code may not read it. Informed consent, no snapshot. + expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /roll back/i })) + + await waitFor(() => expect(body).toEqual({ version: '1.5.0' })) + }) + + it('FE-COMP-PLUGINS-VPICK-004: picking a NEWER version in the picker updates without the downgrade warning', async () => { + let body: unknown = null + panelWith([plugin({ source_repo: 'acme/gotify', version: '1.5.0', operatorEgress: false })], { + registry: [registryEntry({ latest: '2.0.0' })], + }) + server.use( + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())), + http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }) + }), + ) + withToast() + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + fireEvent.click(await screen.findByRole('button', { name: /change version/i })) + fireEvent.click(await screen.findByRole('button', { name: /^switch to 2\.0\.0$/i })) + + await waitFor(() => expect(body).toEqual({ version: '2.0.0' })) + expect(screen.queryByText(/data written by the newer version stays in place/i)).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-VPICK-005: the installed version is marked and not switchable; sideloads get no picker', async () => { + panelWith([ + plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false }), + plugin({ id: 'side-load', name: 'Sideload', source_repo: 'local:upload', version: '1.0.0', operatorEgress: false }), + ], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions()))) + withToast() + + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-side-load')) + expect(screen.queryByRole('button', { name: /change version/i })).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('plugin-row-menu-btn-side-load')) // close + + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + fireEvent.click(await screen.findByRole('button', { name: /change version/i })) + + const current = await screen.findByTestId('version-row-2.0.0') + expect(within(current).getByText('Installed')).toBeInTheDocument() + expect(within(current).queryByRole('button', { name: /switch/i })).not.toBeInTheDocument() + }) +}) + describe('AdminPluginsPanel — capability and dependency chips', () => { it('FE-COMP-PLUGINS-PANEL-035: a permission-rich plugin renders one chip per capability', async () => { panelWith([plugin({ @@ -1405,7 +1637,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { it('FE-W5PLG-005: the update filter and the updates-first sort both use the registry version', async () => { const user = userEvent.setup() - mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }]) + mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }]) render() await screen.findByText('Alpha Widget') await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument()) @@ -1423,7 +1655,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { it('FE-W5PLG-006: switching tabs snaps a tab-only sort key back to name', async () => { const user = userEvent.setup() - mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }]) + mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }]) render() await screen.findByText('Alpha Widget') @@ -1880,8 +2112,8 @@ describe('AdminPluginsPanel — update all', () => { plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })], })), http.get('*/api/admin/plugins/registry', () => HttpResponse.json([ - { id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' }, - { id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' }, + { id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' }, + { id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' }, ])), http.post('*/api/admin/plugins/:id/update', async ({ params }) => { started.push(String(params.id)) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index b0c468d65..30983414a 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -5,7 +5,7 @@ import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, - Radio, Luggage, Globe, Image, CalendarDays, Bell, + Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' @@ -63,6 +63,8 @@ interface PluginRow { keyFingerprint?: string | null /** Why an update was refused, if one was. `version` is the version that was refused. */ updateBlock?: { code: string; detail: string | null; version: string | null } | null + /** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */ + updateHold?: boolean } interface RegistryItem { id: string @@ -93,9 +95,21 @@ interface RegistryItem { /** The full key — public, and carried in full because re-trust compares it exactly. */ authorPublicKey?: string | null } +/** One published version, with its SERVER-computed compat verdict (the client has no semver). */ +interface VersionInfo { + version: string + publishedAt: string | null + size: number | null + signed: boolean + /** The declared TREK requirement, or null when the entry carries no bounds at all. */ + trek: string | null + compatible: boolean +} interface RegistryDetail extends RegistryItem { size: number | null publishedAt: string | null + /** Every published version, newest first — the version picker's data. */ + versions?: VersionInfo[] manifest: { // Optional because a registry may omit an empty list — the detail view has to // degrade rather than throw halfway through its render. @@ -392,6 +406,12 @@ export default function AdminPluginsPanel() { const [egressSaving, setEgressSaving] = useState(false) const [egressError, setEgressError] = useState('') const [confirmUninstall, setConfirmUninstall] = useState(null) + // The version picker for an INSTALLED plugin ("Change version…"); versions land async + // from the registry detail endpoint, which computes each version's compat verdict. + const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null) + // A picked version OLDER than the installed one — held here until the admin consents + // to the data risk (the plugin's data dir stays; older code may not understand it). + const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null) // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — // each must be shown, not silently overwritten by the last one. const [consentQueue, setConsentQueue] = useState>([]) @@ -420,10 +440,13 @@ export default function AdminPluginsPanel() { // Index the registry once per fetch: the version map the update badges read, plus the // whole entry (author key + signed flag) the trust badges and re-trust dialog need. + // The map holds latestCompatible — the newest version THIS TREK can install (computed + // server-side) — not the absolute latest, so the banner never counts an update the + // update endpoint would refuse. const indexRegistry = (items: RegistryItem[]) => { const vers: Record = {} const byId: Record = {} - items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest }) + items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible }) setLatest(vers) setRegById(byId) } @@ -551,7 +574,21 @@ export default function AdminPluginsPanel() { .catch(() => setErrorsFor({ id, rows: [] })) } - const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version)) + // A held plugin (deliberate non-latest install) is deliberately NOT an update candidate: + // the admin just rolled it back, and the banner nagging them straight back would make + // the rollback fight the UI. The row carries its own "paused" marker + resume instead. + const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version)) + const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed')) + // A newer version exists but this TREK can't install it (and it isn't the one on offer): + // said passively on the row, so the admin learns a TREK upgrade unlocks it instead of + // wondering why no update shows. Null when the registry gives no range to point at. + const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => { + const reg = regById[p.id] + if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null + if (latest[p.id] === reg.latest) return null + const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null) + return range ? { version: reg.latest, range } : null + } const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed')) const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) // Dev-link: register a plugin from a local built directory (dev only). Reuses the @@ -639,9 +676,11 @@ export default function AdminPluginsPanel() { .finally(() => { setBusy(null); refresh() }) } - const runUpdate = (p: PluginRow) => { + // `version` pins the exact version to install (rollback / explicit switch); omitted, + // the server resolves the newest TREK-compatible version itself. + const runUpdate = (p: PluginRow, version?: string) => { setBusy(p.id); setMenu(null) - return adminApi.pluginUpdate(p.id) + return adminApi.pluginUpdate(p.id, version) .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) @@ -673,6 +712,27 @@ export default function AdminPluginsPanel() { .finally(() => { setRetrusting(false); refresh() }) } + // "Change version…" on an installed row: fetch the per-version list (with the server's + // compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale + // response can't populate a picker that was reopened for another row. + const openVersionPicker = (p: PluginRow) => { + setMenu(null) + setVersionPicker({ plugin: p, versions: null, failed: false }) + adminApi.pluginDetail(p.id) + .then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur))) + .catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur))) + } + + // Switching DOWN keeps the plugin's data dir, which the older code may not understand — + // that asks for explicit consent first. Any other switch is the ordinary update path. + const pickVersion = (version: string) => { + if (!versionPicker) return + const p = versionPicker.plugin + setVersionPicker(null) + if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version }) + else void runUpdate(p, version) + } + // eslint-disable-next-line react-hooks/exhaustive-deps const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest]) @@ -885,9 +945,12 @@ export default function AdminPluginsPanel() { ) : shownInstalled.map(p => ( toggle(p)} onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)} + onChangeVersion={() => openVersionPicker(p)} + onResume={() => void resumeUpdates(p)} onReviewBlock={() => setSignatureBlock({ subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, code: p.updateBlock!.code, detail: p.updateBlock!.detail, @@ -990,6 +1053,20 @@ export default function AdminPluginsPanel() { message={t('admin.plugins.uninstallBody')} /> + {versionPicker && ( + setVersionPicker(null)} /> + )} + + setConfirmDowngrade(null)} + onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); void runUpdate(d.plugin, d.version) }} + title={t('admin.plugins.downgradeTitle')} + message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })} + confirmLabel={t('admin.plugins.downgradeConfirm')} + /> + {signatureBlock && ( void }) { ) } -function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked, onToggle, onUpdate, onRestart, onReviewBlock, onErrors, onEgress, onUninstall }: { +function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void - hasUpdate: boolean; latestVer?: string; blocked: boolean - onToggle: () => void; onUpdate: () => void; onRestart: () => void; onReviewBlock: () => void + hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean + onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void onErrors: () => void; onEgress: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) @@ -1164,6 +1241,26 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked )} + {/* A newer version this TREK can't run — informational, never a button: the fix + is a TREK upgrade, so the row must not nag or offer a doomed install. */} + {newerIncompatible && ( +
+ + {t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })} +
+ )} + {/* Held: the admin deliberately installed a non-latest version, so updates are + paused rather than nagged — resuming is one click, right where the pause shows. */} + {p.updateHold && ( +
+ + {t('admin.plugins.updatesHeld', { version: p.version ?? '' })} + +
+ )} {p.status === 'error' && p.last_error ? (
{p.last_error} @@ -1233,6 +1330,10 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> + {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} + {isRegistrySourced(p.source_repo) && ( + } label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} /> + )} {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( <> setMenu(null)} @@ -1521,6 +1622,34 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local )}
+ + {/* Every published version, installable individually. The compat verdict per + version is SERVER-computed — an incompatible one is explained, never offered. */} + {(detail?.versions?.length ?? 0) > 1 && ( +
+

{t('admin.plugins.versionsTitle')}

+
+ {detail!.versions!.map(v => ( +
+ v{v.version} + {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} + {v.signed && } + + {v.compatible ? ( + !installed && ( + + ) + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )} +
+ ))} +
+
+ )}
@@ -1548,6 +1677,55 @@ function Meta({ k, v }: { k: string; v: string }) { return
{k}
{v}
} +/** + * "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat + * verdict: the installed version is marked, a compatible one gets a Switch action (the + * caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is + * explained and never offered. + */ +function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: { + plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null + t: T; locale: string; onPick: (version: string) => void; onClose: () => void +}) { + return ( +
+
e.stopPropagation()}> +
+

{t('admin.plugins.versionPickerTitle', { name: plugin.name })}

+ +
+
+ {failed &&

{t('admin.plugins.detailError')}

} + {!failed && !versions &&

{t('common.loading')}

} + {versions?.length === 0 &&

{t('admin.plugins.noVersions')}

} + {versions?.map(v => { + const current = v.version === plugin.version + return ( +
+ v{v.version} + {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} + {v.signed && } + + {current ? ( + {t('admin.plugins.installed')} + ) : v.compatible ? ( + + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )} +
+ ) + })} +
+
+
+ ) +} + /** * A signature check refused an install/update. * diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index e713e2158..41d173d71 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -4,7 +4,7 @@ import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, - Radio, Luggage, Globe, Image, CalendarDays, Bell, + Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, } from 'lucide-react' import PluginIcon from '../../../components/shared/PluginIcon' @@ -65,6 +65,8 @@ interface PluginRow { keyFingerprint?: string | null /** Why an update was refused, if one was. `version` is the version that was refused. */ updateBlock?: { code: string; detail: string | null; version: string | null } | null + /** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */ + updateHold?: boolean } interface RegistryItem { id: string @@ -95,9 +97,21 @@ interface RegistryItem { /** The full key — public, and carried in full because re-trust compares it exactly. */ authorPublicKey?: string | null } +/** One published version, with its SERVER-computed compat verdict (the client has no semver). */ +interface VersionInfo { + version: string + publishedAt: string | null + size: number | null + signed: boolean + /** The declared TREK requirement, or null when the entry carries no bounds at all. */ + trek: string | null + compatible: boolean +} interface RegistryDetail extends RegistryItem { size: number | null publishedAt: string | null + /** Every published version, newest first — the version picker's data. */ + versions?: VersionInfo[] manifest: { permissions: string[] egress: string[] @@ -399,6 +413,12 @@ export default function MAdminPluginsPanel() { const [egressSaving, setEgressSaving] = useState(false) const [egressError, setEgressError] = useState('') const [confirmUninstall, setConfirmUninstall] = useState(null) + // The version picker for an INSTALLED plugin ("Change version…"); versions land async + // from the registry detail endpoint, which computes each version's compat verdict. + const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null) + // A picked version OLDER than the installed one — held here until the admin consents + // to the data risk (the plugin's data dir stays; older code may not understand it). + const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null) // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — // each must be shown, not silently overwritten by the last one. const [consentQueue, setConsentQueue] = useState>([]) @@ -427,10 +447,13 @@ export default function MAdminPluginsPanel() { // Index the registry once per fetch: the version map the update badges read, plus the // whole entry (author key + signed flag) the trust badges and re-trust dialog need. + // The map holds latestCompatible — the newest version THIS TREK can install (computed + // server-side) — not the absolute latest, so the banner never counts an update the + // update endpoint would refuse. const indexRegistry = (items: RegistryItem[]) => { const vers: Record = {} const byId: Record = {} - items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest }) + items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible }) setLatest(vers) setRegById(byId) } @@ -563,7 +586,21 @@ export default function MAdminPluginsPanel() { .catch(() => setErrorsFor({ id, rows: [] })) } - const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version)) + // A held plugin (deliberate non-latest install) is deliberately NOT an update candidate: + // the admin just rolled it back, and the banner nagging them straight back would make + // the rollback fight the UI. The row carries its own "paused" marker + resume instead. + const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version)) + const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed')) + // A newer version exists but this TREK can't install it (and it isn't the one on offer): + // said passively on the row, so the admin learns a TREK upgrade unlocks it instead of + // wondering why no update shows. Null when the registry gives no range to point at. + const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => { + const reg = regById[p.id] + if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null + if (latest[p.id] === reg.latest) return null + const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null) + return range ? { version: reg.latest, range } : null + } const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed')) const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) // Dev-link: register a plugin from a local built directory (dev only). Reuses the @@ -651,9 +688,11 @@ export default function MAdminPluginsPanel() { .finally(() => { setBusy(null); refresh() }) } - const runUpdate = (p: PluginRow) => { + // `version` pins the exact version to install (rollback / explicit switch); omitted, + // the server resolves the newest TREK-compatible version itself. + const runUpdate = (p: PluginRow, version?: string) => { setBusy(p.id); setMenu(null) - adminApi.pluginUpdate(p.id) + adminApi.pluginUpdate(p.id, version) .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) @@ -685,6 +724,27 @@ export default function MAdminPluginsPanel() { .finally(() => { setRetrusting(false); refresh() }) } + // "Change version…" on an installed row: fetch the per-version list (with the server's + // compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale + // response can't populate a picker that was reopened for another row. + const openVersionPicker = (p: PluginRow) => { + setMenu(null) + setVersionPicker({ plugin: p, versions: null, failed: false }) + adminApi.pluginDetail(p.id) + .then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur))) + .catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur))) + } + + // Switching DOWN keeps the plugin's data dir, which the older code may not understand — + // that asks for explicit consent first. Any other switch is the ordinary update path. + const pickVersion = (version: string) => { + if (!versionPicker) return + const p = versionPicker.plugin + setVersionPicker(null) + if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version }) + else runUpdate(p, version) + } + // eslint-disable-next-line react-hooks/exhaustive-deps const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest]) @@ -850,7 +910,9 @@ export default function MAdminPluginsPanel() {
{t('admin.plugins.updatesAvailable', { count: updatable.length })} - @@ -864,9 +926,11 @@ export default function MAdminPluginsPanel() { ) : shownInstalled.map(p => ( toggle(p)} onUpdate={() => runUpdate(p)} + onResume={() => void resumeUpdates(p)} onReviewBlock={() => setSignatureBlock({ subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, code: p.updateBlock!.code, detail: p.updateBlock!.detail, @@ -891,9 +955,28 @@ export default function MAdminPluginsPanel() { onRestart={() => restart(rowMenuPlugin.id)} onErrors={() => openErrors(rowMenuPlugin.id)} onEgress={() => openEgress(rowMenuPlugin.id)} + onChangeVersion={() => openVersionPicker(rowMenuPlugin)} onUninstall={() => { setMenu(null); setConfirmUninstall(rowMenuPlugin) }} /> )} + {/* Version picker sheet */} + {versionPicker && ( + setVersionPicker(null)} /> + )} + + {/* Downgrade confirm */} + setConfirmDowngrade(null)} + onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); runUpdate(d.plugin, d.version) }} + title={t('admin.plugins.downgradeTitle')} + message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })} + confirmLabel={t('admin.plugins.downgradeConfirm')} + cancelLabel={t('common.cancel')} + danger + /> + {/* Filter picker sheets */} setMenu(null)} title={t('admin.plugins.filterType')} value={typeFilter} @@ -1113,10 +1196,10 @@ function PickerSheet({ open, onClose, title, options, value, onPick }: { // ── Installed row ────────────────────────────────────────────────────────── -function InstalledRow({ p, t, busy, hasUpdate, latestVer, blocked, onToggle, onUpdate, onReviewBlock, onEgress, onMenu }: { +function InstalledRow({ p, t, busy, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onResume, onReviewBlock, onEgress, onMenu }: { p: PluginRow; t: T; busy: string | null - hasUpdate: boolean; latestVer?: string; blocked: boolean - onToggle: () => void; onUpdate: () => void; onReviewBlock: () => void + hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean + onToggle: () => void; onUpdate: () => void; onResume: () => void; onReviewBlock: () => void onEgress: () => void; onMenu: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) @@ -1170,6 +1253,28 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, blocked, onToggle, onU
)} + {/* A newer version this TREK can't run — informational, never a button: the fix + is a TREK upgrade, so the row must not nag or offer a doomed install. */} + {newerIncompatible && ( +
+ + {t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })} +
+ )} + + {/* Held: the admin deliberately installed a non-latest version, so updates are + paused rather than nagged — resuming is one click, right where the pause shows. */} + {p.updateHold && ( +
+ + {t('admin.plugins.updatesHeld', { version: p.version ?? '' })} + +
+ )} + {p.status === 'error' && p.last_error ? (
{p.last_error} @@ -1223,9 +1328,9 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, blocked, onToggle, onU } // Row ⋯ action sheet — the desktop portal menu re-expressed as a bottom sheet. -function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onUninstall }: { +function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onChangeVersion, onUninstall }: { p: PluginRow; t: T; onClose: () => void - onRestart: () => void; onErrors: () => void; onEgress: () => void; onUninstall: () => void + onRestart: () => void; onErrors: () => void; onEgress: () => void; onChangeVersion: () => void; onUninstall: () => void }) { const linkable = p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' const rowClass = 'flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-left text-[0.8125rem] font-semibold text-m-ink' @@ -1238,6 +1343,10 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onUnins )} + {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} + {isRegistrySourced(p.source_repo) && ( + + )} {linkable && ( <> @@ -1258,6 +1367,51 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onUnins ) } +/** + * "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat + * verdict: the installed version is marked, a compatible one gets a Switch action (the + * caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is + * explained and never offered. + */ +function VersionPickerSheet({ plugin, versions, failed, busy, t, locale, onPick, onClose }: { + plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null + t: T; locale: string; onPick: (version: string) => void; onClose: () => void +}) { + return ( + + +
+ {failed &&

{t('admin.plugins.detailError')}

} + {!failed && !versions &&

{t('common.loading')}

} + {versions?.length === 0 &&

{t('admin.plugins.noVersions')}

} + {versions?.map(v => { + const current = v.version === plugin.version + return ( +
+ v{v.version} + {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} + {v.signed && } + + {current ? ( + {t('admin.plugins.installed')} + ) : v.compatible ? ( + + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )} +
+ ) + })} +
+
+
+ ) +} + // ── Registry (Discover) ──────────────────────────────────────────────────── function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) { @@ -1511,6 +1665,34 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local )}
+ + {/* Every published version, installable individually. The compat verdict per + version is SERVER-computed — an incompatible one is explained, never offered. */} + {(detail?.versions?.length ?? 0) > 1 && ( +
+

{t('admin.plugins.versionsTitle')}

+
+ {detail!.versions!.map(v => ( +
+ v{v.version} + {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} + {v.signed && } + + {v.compatible ? ( + !installed && ( + + ) + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )} +
+ ))} +
+
+ )} diff --git a/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx index cf3fa5356..1ab15b531 100644 --- a/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx @@ -40,6 +40,9 @@ function registryEntry(over: Row = {}): Row { repo: 'acme/gotify', type: 'integration', latest: '2.0.0', + // The real browse response always carries latestCompatible (server-side hostCompat); + // mirror `latest` by default, exactly like a fully-compatible entry. + latestCompatible: (over.latest as string | undefined) ?? '2.0.0', minTrekVersion: null, reviewedAt: null, screenshotUrl: null, @@ -742,6 +745,124 @@ describe('MAdminPluginsPanel — updates and consent', () => { await waitFor(() => expect(toastMessages()).toContain('Plugin updated')); }); + it('FE-MOB-PLUGP-046a: an update this TREK cannot install is neither offered nor counted', async () => { + mockPanel( + [plugin({ source_repo: 'acme/gotify', version: '1.0.0' })], + [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })], + ); + render(); + await screen.findByText('Gotify'); + + expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-046b: a newer version needing a newer TREK leaves a passive hint on the row', async () => { + mockPanel( + [plugin({ source_repo: 'acme/gotify', version: '1.0.0' })], + [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })], + ); + render(); + + expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-046f: a held plugin leaves the banner and offers Resume updates instead', async () => { + let resumed = false; + mockPanel( + [plugin({ source_repo: 'acme/gotify', version: '1.0.0', updateHold: true })], + [registryEntry({ latest: '2.0.0' })], + ); + server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => { + resumed = true; + return HttpResponse.json({ updateHold: false }); + })); + render(); + await screen.findByText('Gotify'); + + expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument(); + expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /resume updates/i })); + await waitFor(() => expect(resumed).toBe(true)); + }); + + it('FE-MOB-PLUGP-046d: the detail sheet lists every version and installs the picked one', async () => { + const bodies: unknown[] = []; + mockPanel([], [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })]); + server.use( + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json({ + ...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }), + size: 1024, publishedAt: null, manifest: null, + versions: [ + { version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false }, + { version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true }, + { version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true }, + ], + })), + http.post('*/api/admin/plugins/install', async ({ request }) => { + bodies.push(await request.json()); + return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' }); + }), + ); + render(); + fireEvent.click(await screen.findByRole('tab', { name: /Discover/ })); + fireEvent.click(await screen.findByText('Acme')); + + // The incompatible latest is explained, never offered. + expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument(); + + fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i })); + await waitFor(() => expect(bodies).toEqual([{ id: 'trek-gotify', version: '1.5.0' }])); + }); + + it('FE-MOB-PLUGP-046e: Change version rolls back through update, after an explicit data warning', async () => { + let body: unknown = null; + mockPanel( + [plugin({ source_repo: 'acme/gotify', version: '2.0.0' })], + [registryEntry()], + ); + server.use( + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json({ + ...registryEntry(), size: 1024, publishedAt: null, manifest: null, + versions: [ + { version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true }, + { version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true }, + ], + })), + http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] }); + }), + ); + render(); + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')); + fireEvent.click(await screen.findByRole('button', { name: /change version/i })); + + // The installed version is marked, not switchable. + const current = await screen.findByTestId('version-row-2.0.0'); + expect(within(current).getByText('Installed')).toBeInTheDocument(); + + fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i })); + expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /roll back/i })); + + await waitFor(() => expect(body).toEqual({ version: '1.5.0' })); + }); + + it('FE-MOB-PLUGP-046c: the update offer is the newest compatible version, not the absolute latest', async () => { + mockPanel( + [plugin({ source_repo: 'acme/gotify', version: '1.0.0' })], + [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })], + ); + render(); + + expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument(); + }); + it('FE-MOB-PLUGP-047: a per-row update that widens rights opens the consent sheet and approving activates with consent', async () => { let consentBody: unknown = null; mockPanel([plugin({ source_repo: 'acme/gotify', signed: true, version: '1.0.0' })], [registryEntry()]); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 55383d398..bc77e7304 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4123,6 +4123,19 @@ function runMigrations(db: Database.Database): void { } } }, + + // A deliberate non-latest plugin install sets `update_hold`: the row leaves the + // update banner and "Update all" until the admin resumes updates, or until an + // install lands back on the newest compatible version. Only an EXPLICIT version + // pick ever sets it — dependency resolution pins versions too, but never + // deliberately. Appended LAST — the array is index-addressed against schema_version. + () => { + try { + db.exec('ALTER TABLE plugins ADD COLUMN update_hold INTEGER NOT NULL DEFAULT 0;'); + } catch (err) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index 2ccb1d11c..189951bad 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -11,7 +11,7 @@ import { CurrentUser } from '../auth/current-user.decorator'; import { getClientIp } from '../audit/client-ip'; import { pluginsEnabled } from './kill-switch'; import { devLinkEnabled } from './dev-link'; -import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto } from './plugins.dto'; +import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto, PluginUpdateDto } from './plugins.dto'; import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; @@ -81,8 +81,12 @@ export class PluginsController { // withDependencies (used by the "resolve missing dependency" admin flow) pulls // the target + its transitive plugin deps, resolving each to its latest // compatible version and reporting addons the admin still has to enable. + // Dependency resolution pins versions internally but never DELIBERATELY, so + // only the plain path below recomputes the update hold. if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint); - return await this.registry.install(body.id, { version: body.version, constraint: body.constraint }); + const res = await this.registry.install(body.id, { version: body.version, constraint: body.constraint }); + await this.registry.recomputeUpdateHold(res.id, res.version, !!body.version); + return res; } catch (e) { throw registryFailure(e, 'install failed'); } @@ -221,15 +225,31 @@ export class PluginsController { @Post(':id/update') @HttpCode(200) - async update(@Param('id') id: string) { + async update(@Param('id') id: string, @Body() body?: PluginUpdateDto) { if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); try { - return await this.runtime.update(id); + // An explicit version is the rollback path: install exactly what the admin picked + // (the TREK-compat gate still refuses in selectVersion). Absent, the runtime + // resolves the newest compatible version itself. + const res = await this.runtime.update(id, { version: body?.version }); + // A deliberate non-latest pick holds future updates; landing on the newest + // (any path) releases a stale hold. Only after success — a failed update + // changed nothing and must not touch the flag. + await this.registry.recomputeUpdateHold(id, res.version, !!body?.version); + return res; } catch (e) { throw registryFailure(e, 'update failed'); } } + /** Release a per-plugin update hold (set by a deliberate non-latest install). */ + @Post(':id/resume-updates') + @HttpCode(200) + resumeUpdates(@Param('id') id: string) { + if (!this.plugins.resumeUpdates(id)) throw new HttpException({ error: `plugin ${id} not found` }, 404); + return { updateHold: false }; + } + /** * Re-trust a plugin whose author signing key ROTATED, and update it in the same call. * diff --git a/server/src/nest/plugins/plugins.dto.ts b/server/src/nest/plugins/plugins.dto.ts index 877ee59d2..4f530a9bc 100644 --- a/server/src/nest/plugins/plugins.dto.ts +++ b/server/src/nest/plugins/plugins.dto.ts @@ -41,6 +41,14 @@ export class PluginRetrustDto extends createZodDto( z.looseObject({ version: z.string().optional(), publicKey: z.string().optional() }), ) {} +/** + * `version` pins the exact version to install — the rollback path. Omitted, the + * runtime resolves the newest TREK-compatible version itself (the classic update). + */ +export class PluginUpdateDto extends createZodDto( + z.looseObject({ version: z.string().optional() }), +) {} + /** * `hosts` is deliberately unknown, not `string[]`. * diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index 4522b6ba6..b2f0463f3 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -43,6 +43,7 @@ interface PluginRawRow { update_block_code: string | null; update_block_detail: string | null; update_block_version: string | null; + update_hold: number; } export interface PluginListItem { @@ -87,6 +88,9 @@ export interface PluginListItem { /** Why an update was refused, if one was. `version` is the registry version that was * refused, so a caller can treat the block as stale once a newer one is on offer. */ updateBlock: { code: string; detail: string | null; version: string | null } | null; + /** A deliberate non-latest install paused updates: excluded from the banner/Update all + * until the admin resumes, or an install lands back on the newest compatible version. */ + updateHold: boolean; } @Injectable() @@ -114,7 +118,7 @@ export class PluginsService { .prepare( `SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo, permissions, capabilities, dependencies, operator_egress, trek_range, - author_pubkey, update_block_code, update_block_detail, update_block_version + author_pubkey, update_block_code, update_block_detail, update_block_version, update_hold FROM plugins ORDER BY sort_order, name`, ) @@ -143,6 +147,7 @@ export class PluginsService { update_block_code, update_block_detail, update_block_version, + update_hold, ...rest } = r as PluginRawRow & { operator_egress?: number }; return { @@ -159,11 +164,20 @@ export class PluginsService { updateBlock: update_block_code ? { code: update_block_code, detail: update_block_detail, version: update_block_version } : null, + updateHold: update_hold === 1, }; }); return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), plugins }; } + /** + * Release a per-plugin update hold (set by a deliberate non-latest install). + * Returns whether the plugin row existed — the controller answers 404 otherwise. + */ + resumeUpdates(id: string): boolean { + return this.db.prepare('UPDATE plugins SET update_hold = 0 WHERE id = ?').run(id).changes > 0; + } + /** * Merge instance-scope settings into the plugin's config, encrypting fields * declared secret (unless the value is the unchanged mask sentinel). Only keys diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts index 8243d5a18..c56935ca7 100644 --- a/server/src/nest/plugins/registry/registry.service.ts +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -304,6 +304,17 @@ export class PluginRegistryService { screenshotUrl: entry.screenshotUrl ?? (latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null), signed: !!entry.authorPublicKey && !!latest?.signature, authorPublicKey: entry.authorPublicKey ?? null, + // The version picker's data: every published version with its OWN server-computed + // compat verdict. The client has no semver and must never re-derive range logic — + // a picker that disagreed with the install gate would offer a version that 400s. + versions: entry.versions.map((v) => ({ + version: v.version, + publishedAt: v.publishedAt ?? null, + size: v.size ?? null, + signed: !!entry.authorPublicKey && !!v.signature, + trek: trekRequirementOrNull(v), + compatible: hostCompatible(v, normalizedHost()), + })), ...this.hostCompat(entry), manifest, }; @@ -508,6 +519,30 @@ export class PluginRegistryService { return { installed, requiredAddons: [...requiredAddons] }; } + /** + * Recompute the per-plugin update hold after a successful install/update. + * + * The hold exists so a DELIBERATE rollback isn't immediately nagged away by the + * update banner. It is set only when `explicit` (the admin picked this exact + * version) AND a newer TREK-compatible version exists; every other outcome writes + * 0, so landing back on the newest compatible version — by any path — releases a + * stale hold. An unresolvable registry never sets a hold: refusing to pin on + * missing information beats silently muting future updates. + */ + async recomputeUpdateHold(id: string, installedVersion: string, explicit: boolean): Promise { + let hold = false; + if (explicit) { + try { + const newest = await this.resolveVersion(id); + hold = semver.lt(installedVersion, newest.version); + } catch { + hold = false; + } + } + this.db.prepare('UPDATE plugins SET update_hold = ? WHERE id = ?').run(hold ? 1 : 0, id); + return hold; + } + /** * Sideload step 1: extract + validate an uploaded archive into a staging dir, * WITHOUT touching the live plugin. Returns the manifest id/version + the staged @@ -710,17 +745,23 @@ export function assertHostCompatible(range: string | null, id: string): void { } /** - * How a version's TREK requirement reads in an error/UI string. Each bound is optional — - * an entry may declare only a ceiling, or (once `trek` is absent too) nothing at all — so - * this composes whatever bounds exist rather than interpolating a missing one as "null". + * A version's declared TREK requirement as a range string, or null when the entry + * carries no bounds at all. Each bound is optional — an entry may declare only a + * ceiling, or (once `trek` is absent too) nothing — so this composes whatever + * bounds exist rather than interpolating a missing one as "null". */ -function trekRequirement(v: RegistryVersion): string { +function trekRequirementOrNull(v: RegistryVersion): string | null { if (v.trek) return v.trek; const bounds = [ v.minTrekVersion ? `>=${v.minTrekVersion}` : null, v.maxTrekVersion ? `<=${v.maxTrekVersion}` : null, ].filter(Boolean); - return bounds.length ? bounds.join(' ') : 'any version'; + return bounds.length ? bounds.join(' ') : null; +} + +/** How a version's TREK requirement reads in an error/UI string. */ +function trekRequirement(v: RegistryVersion): string { + return trekRequirementOrNull(v) ?? 'any version'; } /** diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index 51df63d98..8facfd85f 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -23,7 +23,7 @@ const { testDb } = vi.hoisted(() => { CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT, source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT, - update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT); + update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT, update_hold INTEGER NOT NULL DEFAULT 0); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`); return { testDb: db }; @@ -257,6 +257,123 @@ describe('PluginRegistryService', () => { await expect(svc.detail('ghost')).rejects.toThrow(RegistryError); }); + // The per-plugin update hold: a DELIBERATE non-latest install excludes the plugin from + // the update banner until the admin resumes updates or lands back on the newest + // compatible version. Dependency resolution pins versions too, but never deliberately — + // the `explicit` flag is what separates the two. + describe('recomputeUpdateHold', () => { + const twoVersions = { + schemaVersion: 1, + plugins: [ + { + id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight', type: 'widget', + versions: [ + { version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/2', sha256: '2'.repeat(64) }, + { version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'd'.repeat(40), downloadUrl: 'https://x/1', sha256: '0'.repeat(64) }, + ], + }, + ], + }; + const seedRow = (hold = 0) => + testDb.prepare("INSERT INTO plugins (id, status, enabled, update_hold) VALUES ('flight-tracker','inactive',0,?)").run(hold); + const holdInDb = () => + (testDb.prepare("SELECT update_hold FROM plugins WHERE id='flight-tracker'").get() as { update_hold: number }).update_hold; + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => twoVersions }) as unknown as Response)); + __clearRegistryCacheForTests(); + }); + + it('holds when the admin explicitly picked an older version', async () => { + seedRow(); + await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(true); + expect(holdInDb()).toBe(1); + }); + + it('does not hold when the explicit pick IS the newest compatible version', async () => { + seedRow(1); + await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false); + expect(holdInDb()).toBe(0); + }); + + // The comparison is against the newest version THIS TREK can run, not the newest + // published one. Both fixtures above leave `trek` unset, so every version is + // compatible and the two readings agree; only a version this host must skip tells + // them apart. Getting it wrong stamps a hold on an admin who picked the best + // available version, and the row silently leaves the banner and "Update all". + it('does not hold when a newer version exists but this TREK cannot run it', async () => { + const withIncompatibleNewest = { + schemaVersion: 1, + plugins: [ + { + ...twoVersions.plugins[0], + versions: [ + { version: '3.0.0', gitTag: 'v3.0.0', commitSha: 'c'.repeat(40), downloadUrl: 'https://x/3', sha256: '3'.repeat(64), trek: '>=4.0.0' }, + ...twoVersions.plugins[0].versions, + ], + }, + ], + }; + process.env.APP_VERSION = '3.3.0'; + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => withIncompatibleNewest }) as unknown as Response)); + __clearRegistryCacheForTests(); + try { + seedRow(1); + await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false); + expect(holdInDb()).toBe(0); + } finally { + delete process.env.APP_VERSION; + } + }); + + it('a non-deliberate update clears a stale hold', async () => { + seedRow(1); + await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', false)).resolves.toBe(false); + expect(holdInDb()).toBe(0); + }); + + it('an unresolvable registry never sets a hold', async () => { + seedRow(); + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); + __clearRegistryCacheForTests(); + await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(false); + expect(holdInDb()).toBe(0); + }); + }); + + // The version picker's data: every published version, each with its OWN server-computed + // compat verdict — the client has no semver and must never re-derive range logic. + it('detail lists every published version with its own compat verdict', async () => { + process.env.APP_VERSION = '3.3.0'; + const multi = { + schemaVersion: 1, + plugins: [ + { + id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight', + type: 'widget', authorPublicKey: 'K'.repeat(44), + versions: [ + { version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/2', sha256: '2'.repeat(64), trek: '>=4.0.0', publishedAt: '2026-08-01', size: 2048, signature: 'sig2' }, + { version: '1.5.0', gitTag: 'v1.5.0', commitSha: 'c'.repeat(40), downloadUrl: 'https://x/15', sha256: '1'.repeat(64), trek: '>=3.0.0 <4.0.0', publishedAt: '2026-07-01', size: 1024 }, + { version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'd'.repeat(40), downloadUrl: 'https://x/1', sha256: '0'.repeat(64), minTrekVersion: '3.0.0' }, + ], + }, + ], + }; + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => multi }) as unknown as Response)); + __clearRegistryCacheForTests(); + try { + const d = (await svc.detail('flight-tracker')) as { versions: unknown }; + expect(d.versions).toEqual([ + { version: '2.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false }, + { version: '1.5.0', publishedAt: '2026-07-01', size: 1024, signed: false, trek: '>=3.0.0 <4.0.0', compatible: true }, + // A legacy entry composes its requirement from the min/max bounds it has. + { version: '1.0.0', publishedAt: null, size: null, signed: false, trek: '>=3.0.0', compatible: true }, + ]); + } finally { + delete process.env.APP_VERSION; + } + }); + it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => { __clearRegistryCacheForTests(); vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); diff --git a/server/tests/unit/db/budget-ticket-json-migration.test.ts b/server/tests/unit/db/budget-ticket-json-migration.test.ts index 4d5c091db..ec47799bb 100644 --- a/server/tests/unit/db/budget-ticket-json-migration.test.ts +++ b/server/tests/unit/db/budget-ticket-json-migration.test.ts @@ -63,8 +63,11 @@ describe('budget_items ticket_json migration', () => { describe('recovering what the case-insensitive match destroyed', () => { /** * A database that already ran the buggy step: migrated to the tip, then wound - * back one version so only the recovery step is left to run — which is exactly - * what an existing install does on the next boot. + * back to just before the recovery step so it runs again — which is exactly + * what an existing install does on the next boot. The wind-back target is the + * recovery step's ABSOLUTE version (the array is append-only and + * index-addressed, so it never moves); anything appended after it replays too, + * which every later migration must tolerate anyway. */ function makeDamagedDb(): Database.Database { const db = new Database(':memory:'); @@ -85,8 +88,8 @@ describe('recovering what the case-insensitive match destroyed', () => { db.prepare('INSERT INTO budget_items (id, trip_id, name, note, ticket_json) VALUES (?, ?, ?, ?, ?)') .run(3, 1, 'Taxi', 'split at the hotel', '{"items":[]}'); - const version = (db.prepare('SELECT version FROM schema_version LIMIT 1').get() as { version: number }).version; - db.prepare('UPDATE schema_version SET version = ?').run(version - 1); + // 196 = the version just before the recovery step (migration #197). + db.prepare('UPDATE schema_version SET version = ?').run(196); return db; } diff --git a/server/tests/unit/plugins/plugin-retrust.controller.test.ts b/server/tests/unit/plugins/plugin-retrust.controller.test.ts index f644ef3b3..47797de17 100644 --- a/server/tests/unit/plugins/plugin-retrust.controller.test.ts +++ b/server/tests/unit/plugins/plugin-retrust.controller.test.ts @@ -49,11 +49,14 @@ let runtime: PluginRuntimeService; let registry: PluginRegistryService; let controller: PluginsController; +let plugins: PluginsService; + beforeEach(() => { process.env.TREK_PLUGINS_ENABLED = 'true'; runtime = { update: vi.fn(), retrust: vi.fn() } as unknown as PluginRuntimeService; - registry = { install: vi.fn() } as unknown as PluginRegistryService; - controller = new PluginsController({} as PluginsService, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService); + registry = { install: vi.fn(), installWithDependencies: vi.fn(), recomputeUpdateHold: vi.fn() } as unknown as PluginRegistryService; + plugins = { resumeUpdates: vi.fn(() => true) } as unknown as PluginsService; + controller = new PluginsController(plugins, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService); }); describe('signature refusal codes survive to the client', () => { @@ -98,6 +101,87 @@ describe('signature refusal codes survive to the client', () => { }); }); +// The rollback path: an admin picks a specific (possibly older) version, and the runtime +// must install exactly that one — the compat gate stays server-side in selectVersion. +describe('POST :id/update — explicit version', () => { + it('forwards the requested version to the runtime', async () => { + vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] }); + + const res = await controller.update('flight-tracker', { version: '1.2.0' }); + + expect(vi.mocked(runtime.update).mock.calls[0]).toEqual(['flight-tracker', { version: '1.2.0' }]); + expect(res).toEqual({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] }); + }); + + it('an omitted body keeps the default newest-compatible resolution', async () => { + vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }); + + await controller.update('flight-tracker'); + + expect(vi.mocked(runtime.update).mock.calls[0]).toEqual(['flight-tracker', { version: undefined }]); + }); +}); + +// The update hold: only a DELIBERATE version pick may set it, every successful +// install/update recomputes it (so landing back on the newest clears a stale one), +// and dependency resolution — which pins versions too, but never deliberately — +// must not touch it. +describe('update hold wiring', () => { + it('an explicit-version update recomputes the hold as deliberate', async () => { + vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] }); + + await controller.update('flight-tracker', { version: '1.2.0' }); + + expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '1.2.0', true); + }); + + it('a default update recomputes as non-deliberate (clears a stale hold)', async () => { + vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }); + + await controller.update('flight-tracker'); + + expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '2.0.0', false); + }); + + it('an explicit-version install recomputes the hold as deliberate', async () => { + vi.mocked(registry.install).mockResolvedValue({ id: 'flight-tracker', version: '1.2.0' }); + + await controller.install({ id: 'flight-tracker', version: '1.2.0' }); + + expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '1.2.0', true); + }); + + it('a dependency-resolution install does NOT touch the hold', async () => { + vi.mocked(registry.installWithDependencies).mockResolvedValue({ installed: ['flight-tracker'], requiredAddons: [] }); + + await controller.install({ id: 'flight-tracker', withDependencies: true }); + + expect(registry.recomputeUpdateHold).not.toHaveBeenCalled(); + }); + + it('a failed update leaves the hold untouched', async () => { + vi.mocked(runtime.update).mockRejectedValue(new Error('network unreachable')); + + await expect(controller.update('flight-tracker', { version: '1.2.0' })).rejects.toThrow(HttpException); + + expect(registry.recomputeUpdateHold).not.toHaveBeenCalled(); + }); + + it('POST :id/resume-updates clears the hold', async () => { + const res = await controller.resumeUpdates('flight-tracker'); + + expect(plugins.resumeUpdates).toHaveBeenCalledWith('flight-tracker'); + expect(res).toEqual({ updateHold: false }); + }); + + it('POST :id/resume-updates is a 404 for an unknown plugin', async () => { + vi.mocked(plugins.resumeUpdates).mockReturnValue(false); + + const { status } = await wireFailure(async () => controller.resumeUpdates('ghost')); + expect(status).toBe(404); + }); +}); + describe('POST :id/retrust', () => { it('re-pins AND installs in one call, returning the update result', async () => { vi.mocked(runtime.retrust).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index a3d931427..c0db0d8a1 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -11,7 +11,7 @@ const { testDb } = vi.hoisted(() => { id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT, - trek_range TEXT, sort_order INTEGER DEFAULT 0); + trek_range TEXT, sort_order INTEGER DEFAULT 0, update_hold INTEGER NOT NULL DEFAULT 0); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`); return { testDb: db }; @@ -48,6 +48,30 @@ describe('PluginsService.list', () => { expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' }); }); + it('surfaces updateHold as a boolean (held plugins leave the update banner)', () => { + testDb + .prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)") + .run(); + testDb + .prepare("INSERT INTO plugins (id, name, type, status, version) VALUES ('free','Free','widget','inactive','1.0.0')") + .run(); + + const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list(); + expect(out.plugins.find((p) => p.id === 'held')).toMatchObject({ updateHold: true }); + expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false }); + }); + + it('resumeUpdates clears the hold and reports whether the plugin existed', () => { + testDb + .prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)") + .run(); + const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))); + + expect(svc.resumeUpdates('held')).toBe(true); + expect(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 }); + expect(svc.resumeUpdates('ghost')).toBe(false); + }); + it('reports enabled by default (no kill switch set)', () => { testDb .prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)') diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index a078f7762..0265093b6 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -428,6 +428,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'التحديثات أولًا', 'admin.plugins.sortDownloads': 'الأكثر تنزيلًا', 'admin.plugins.updatesAvailable': '{count} تحديثات متاحة لإضافاتك.', + 'admin.plugins.newerNeedsTrek': 'الإصدار v{version} متوفر — يتطلب TREK {range}', + 'admin.plugins.versionsTitle': 'الإصدارات', + 'admin.plugins.versionPickerTitle': 'تغيير الإصدار — {name}', + 'admin.plugins.versionSwitch': 'التبديل إلى {version}', + 'admin.plugins.versionNeedsTrek': 'يتطلب TREK {range}', + 'admin.plugins.changeVersion': 'تغيير الإصدار…', + 'admin.plugins.noVersions': 'لم يتم العثور على إصدارات منشورة في السجل.', + 'admin.plugins.downgradeTitle': 'التراجع عن هذه الإضافة؟', + 'admin.plugins.downgradeBody': 'التبديل من v{from} إلى v{to}: تبقى البيانات المكتوبة بواسطة الإصدار الأحدث في مكانها، وقد لا يفهمها الإصدار الأقدم.', + 'admin.plugins.downgradeConfirm': 'تراجع', + 'admin.plugins.updatesHeld': 'التحديثات متوقفة عند v{version}', + 'admin.plugins.resumeUpdates': 'استئناف التحديثات', + 'admin.plugins.updatesResumed': 'تم استئناف التحديثات', 'admin.plugins.updateAll': 'تحديث الكل', 'admin.plugins.noMatchInstalled': 'لا توجد إضافات مثبّتة تطابق بحثك.', 'admin.plugins.noMatchRegistry': 'لا توجد إضافات في السجل تطابق بحثك.', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 3817d4274..32f3ea386 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -417,6 +417,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Atualizações primeiro', 'admin.plugins.sortDownloads': 'Mais baixados', 'admin.plugins.updatesAvailable': '{count} atualizações disponíveis para seus plugins.', + 'admin.plugins.newerNeedsTrek': 'v{version} disponível — requer TREK {range}', + 'admin.plugins.versionsTitle': 'Versões', + 'admin.plugins.versionPickerTitle': 'Alterar versão — {name}', + 'admin.plugins.versionSwitch': 'Mudar para {version}', + 'admin.plugins.versionNeedsTrek': 'requer TREK {range}', + 'admin.plugins.changeVersion': 'Alterar versão…', + 'admin.plugins.noVersions': 'Nenhuma versão publicada encontrada no registro.', + 'admin.plugins.downgradeTitle': 'Reverter este plugin?', + 'admin.plugins.downgradeBody': 'Mudando de v{from} para v{to}: os dados gravados pela versão mais recente permanecem no lugar, e a versão mais antiga pode não entendê-los.', + 'admin.plugins.downgradeConfirm': 'Reverter', + 'admin.plugins.updatesHeld': 'Atualizações pausadas em v{version}', + 'admin.plugins.resumeUpdates': 'Retomar atualizações', + 'admin.plugins.updatesResumed': 'Atualizações retomadas', 'admin.plugins.updateAll': 'Atualizar todos', 'admin.plugins.noMatchInstalled': 'Nenhum plugin instalado corresponde à sua busca.', 'admin.plugins.noMatchRegistry': 'Nenhum plugin no registro corresponde à sua busca.', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index c31809098..df4084d65 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -542,6 +542,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Primer les actualitzacions', 'admin.plugins.sortDownloads': 'Més baixades', 'admin.plugins.updatesAvailable': '{count} actualitzacions disponibles per als teus connectors.', + 'admin.plugins.newerNeedsTrek': 'v{version} disponible — necessita TREK {range}', + 'admin.plugins.versionsTitle': 'Versions', + 'admin.plugins.versionPickerTitle': 'Canvia la versió — {name}', + 'admin.plugins.versionSwitch': 'Canvia a {version}', + 'admin.plugins.versionNeedsTrek': 'necessita TREK {range}', + 'admin.plugins.changeVersion': 'Canvia la versió…', + 'admin.plugins.noVersions': 'No s\'han trobat versions publicades al registre.', + 'admin.plugins.downgradeTitle': 'Vols revertir aquest connector?', + 'admin.plugins.downgradeBody': 'Canvi de v{from} a v{to}: les dades escrites per la versió més nova es mantenen, i la versió antiga pot no entendre-les.', + 'admin.plugins.downgradeConfirm': 'Reverteix', + 'admin.plugins.updatesHeld': 'Actualitzacions en pausa a la v{version}', + 'admin.plugins.resumeUpdates': 'Reprèn les actualitzacions', + 'admin.plugins.updatesResumed': 'Actualitzacions represes', 'admin.plugins.updateAll': 'Actualitza-ho tot', 'admin.plugins.noMatchInstalled': 'Cap connector instal·lat coincideix amb la teva cerca.', 'admin.plugins.noMatchRegistry': 'Cap connector del registre coincideix amb la teva cerca.', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 83ccc83ce..f69d9274b 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -415,6 +415,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Nejdřív aktualizace', 'admin.plugins.sortDownloads': 'Nejstahovanější', 'admin.plugins.updatesAvailable': 'Pro vaše pluginy je k dispozici {count} aktualizací.', + 'admin.plugins.newerNeedsTrek': 'v{version} k dispozici — vyžaduje TREK {range}', + 'admin.plugins.versionsTitle': 'Verze', + 'admin.plugins.versionPickerTitle': 'Změnit verzi — {name}', + 'admin.plugins.versionSwitch': 'Přepnout na {version}', + 'admin.plugins.versionNeedsTrek': 'vyžaduje TREK {range}', + 'admin.plugins.changeVersion': 'Změnit verzi…', + 'admin.plugins.noVersions': 'V registru nebyly nalezeny žádné publikované verze.', + 'admin.plugins.downgradeTitle': 'Vrátit tento plugin na starší verzi?', + 'admin.plugins.downgradeBody': 'Přechod z v{from} na v{to}: data zapsaná novější verzí zůstanou na místě a starší verze jim nemusí rozumět.', + 'admin.plugins.downgradeConfirm': 'Vrátit zpět', + 'admin.plugins.updatesHeld': 'Aktualizace pozastaveny na v{version}', + 'admin.plugins.resumeUpdates': 'Obnovit aktualizace', + 'admin.plugins.updatesResumed': 'Aktualizace obnoveny', 'admin.plugins.updateAll': 'Aktualizovat vše', 'admin.plugins.noMatchInstalled': 'Žádné nainstalované pluginy neodpovídají vašemu hledání.', 'admin.plugins.noMatchRegistry': 'Žádné pluginy v registru neodpovídají vašemu hledání.', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index a78fe7a5b..075f7d649 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -424,6 +424,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Updates zuerst', 'admin.plugins.sortDownloads': 'Meiste Downloads', 'admin.plugins.updatesAvailable': '{count} Updates für deine Plugins verfügbar.', + 'admin.plugins.newerNeedsTrek': 'v{version} verfügbar — benötigt TREK {range}', + 'admin.plugins.versionsTitle': 'Versionen', + 'admin.plugins.versionPickerTitle': 'Version wechseln — {name}', + 'admin.plugins.versionSwitch': 'Zu {version} wechseln', + 'admin.plugins.versionNeedsTrek': 'benötigt TREK {range}', + 'admin.plugins.changeVersion': 'Version wechseln…', + 'admin.plugins.noVersions': 'Keine veröffentlichten Versionen in der Registry gefunden.', + 'admin.plugins.downgradeTitle': 'Dieses Plugin zurücksetzen?', + 'admin.plugins.downgradeBody': 'Wechsel von v{from} zu v{to}: Von der neueren Version geschriebene Daten bleiben erhalten, und die ältere Version versteht sie möglicherweise nicht.', + 'admin.plugins.downgradeConfirm': 'Zurücksetzen', + 'admin.plugins.updatesHeld': 'Updates pausiert bei v{version}', + 'admin.plugins.resumeUpdates': 'Updates fortsetzen', + 'admin.plugins.updatesResumed': 'Updates fortgesetzt', 'admin.plugins.updateAll': 'Alle aktualisieren', 'admin.plugins.noMatchInstalled': 'Keine installierten Plugins passen zu deiner Suche.', 'admin.plugins.noMatchRegistry': 'Keine Plugins in der Registry passen zu deiner Suche.', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 679a0da55..0634b2ffd 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -459,7 +459,20 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Updates first', 'admin.plugins.sortDownloads': 'Most downloads', 'admin.plugins.updatesAvailable': '{count} updates available for your plugins.', + 'admin.plugins.newerNeedsTrek': 'v{version} available — needs TREK {range}', 'admin.plugins.updateAll': 'Update all', + 'admin.plugins.versionsTitle': 'Versions', + 'admin.plugins.versionPickerTitle': 'Change version — {name}', + 'admin.plugins.versionSwitch': 'Switch to {version}', + 'admin.plugins.versionNeedsTrek': 'needs TREK {range}', + 'admin.plugins.changeVersion': 'Change version…', + 'admin.plugins.noVersions': 'No published versions found in the registry.', + 'admin.plugins.downgradeTitle': 'Roll back this plugin?', + 'admin.plugins.downgradeBody': 'Switching from v{from} to v{to}: data written by the newer version stays in place, and the older version may not understand it.', + 'admin.plugins.downgradeConfirm': 'Roll back', + 'admin.plugins.updatesHeld': 'Updates paused at v{version}', + 'admin.plugins.resumeUpdates': 'Resume updates', + 'admin.plugins.updatesResumed': 'Updates resumed', 'admin.plugins.noMatchInstalled': 'No installed plugins match your search.', 'admin.plugins.noMatchRegistry': 'No plugins in the registry match your search.', 'admin.plugins.restart': 'Restart', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index d421f6405..c76db1d92 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -426,6 +426,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Actualizaciones primero', 'admin.plugins.sortDownloads': 'Más descargados', 'admin.plugins.updatesAvailable': '{count} actualizaciones disponibles para tus plugins.', + 'admin.plugins.newerNeedsTrek': 'v{version} disponible — necesita TREK {range}', + 'admin.plugins.versionsTitle': 'Versiones', + 'admin.plugins.versionPickerTitle': 'Cambiar versión — {name}', + 'admin.plugins.versionSwitch': 'Cambiar a {version}', + 'admin.plugins.versionNeedsTrek': 'necesita TREK {range}', + 'admin.plugins.changeVersion': 'Cambiar versión…', + 'admin.plugins.noVersions': 'No se encontraron versiones publicadas en el registro.', + 'admin.plugins.downgradeTitle': '¿Revertir este plugin?', + 'admin.plugins.downgradeBody': 'Cambio de v{from} a v{to}: los datos escritos por la versión más reciente se conservan, y la versión anterior podría no entenderlos.', + 'admin.plugins.downgradeConfirm': 'Revertir', + 'admin.plugins.updatesHeld': 'Actualizaciones en pausa en v{version}', + 'admin.plugins.resumeUpdates': 'Reanudar actualizaciones', + 'admin.plugins.updatesResumed': 'Actualizaciones reanudadas', 'admin.plugins.updateAll': 'Actualizar todo', 'admin.plugins.noMatchInstalled': 'Ningún plugin instalado coincide con tu búsqueda.', 'admin.plugins.noMatchRegistry': 'Ningún plugin del registro coincide con tu búsqueda.', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 8699e0496..af857033e 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -429,6 +429,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Mises à jour en premier', 'admin.plugins.sortDownloads': 'Plus téléchargés', 'admin.plugins.updatesAvailable': '{count} mises à jour disponibles pour vos plugins.', + 'admin.plugins.newerNeedsTrek': 'v{version} disponible — nécessite TREK {range}', + 'admin.plugins.versionsTitle': 'Versions', + 'admin.plugins.versionPickerTitle': 'Changer de version — {name}', + 'admin.plugins.versionSwitch': 'Passer à {version}', + 'admin.plugins.versionNeedsTrek': 'nécessite TREK {range}', + 'admin.plugins.changeVersion': 'Changer de version…', + 'admin.plugins.noVersions': 'Aucune version publiée trouvée dans le registre.', + 'admin.plugins.downgradeTitle': 'Revenir à une version antérieure de ce plugin ?', + 'admin.plugins.downgradeBody': 'Passage de v{from} à v{to} : les données écrites par la version plus récente restent en place, et la version plus ancienne pourrait ne pas les comprendre.', + 'admin.plugins.downgradeConfirm': 'Rétrograder', + 'admin.plugins.updatesHeld': 'Mises à jour en pause à la v{version}', + 'admin.plugins.resumeUpdates': 'Reprendre les mises à jour', + 'admin.plugins.updatesResumed': 'Mises à jour reprises', 'admin.plugins.updateAll': 'Tout mettre à jour', 'admin.plugins.noMatchInstalled': 'Aucun plugin installé ne correspond à votre recherche.', 'admin.plugins.noMatchRegistry': 'Aucun plugin du registre ne correspond à votre recherche.', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 3e7f0ada4..2ae99ca57 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -477,6 +477,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Ενημερώσεις πρώτα', 'admin.plugins.sortDownloads': 'Περισσότερες λήψεις', 'admin.plugins.updatesAvailable': 'Διαθέσιμες {count} ενημερώσεις για τα plugins σας.', + 'admin.plugins.newerNeedsTrek': 'Διαθέσιμη η v{version} — απαιτεί TREK {range}', + 'admin.plugins.versionsTitle': 'Εκδόσεις', + 'admin.plugins.versionPickerTitle': 'Αλλαγή έκδοσης — {name}', + 'admin.plugins.versionSwitch': 'Μετάβαση σε {version}', + 'admin.plugins.versionNeedsTrek': 'απαιτεί TREK {range}', + 'admin.plugins.changeVersion': 'Αλλαγή έκδοσης…', + 'admin.plugins.noVersions': 'Δεν βρέθηκαν δημοσιευμένες εκδόσεις στο μητρώο.', + 'admin.plugins.downgradeTitle': 'Επαναφορά αυτού του πρόσθετου;', + 'admin.plugins.downgradeBody': 'Μετάβαση από v{from} σε v{to}: τα δεδομένα της νεότερης έκδοσης παραμένουν, και η παλαιότερη έκδοση ίσως να μην τα κατανοεί.', + 'admin.plugins.downgradeConfirm': 'Επαναφορά', + 'admin.plugins.updatesHeld': 'Οι ενημερώσεις είναι σε παύση στην v{version}', + 'admin.plugins.resumeUpdates': 'Συνέχιση ενημερώσεων', + 'admin.plugins.updatesResumed': 'Οι ενημερώσεις συνεχίστηκαν', 'admin.plugins.updateAll': 'Ενημέρωση όλων', 'admin.plugins.noMatchInstalled': 'Κανένα εγκατεστημένο plugin δεν ταιριάζει με την αναζήτησή σας.', 'admin.plugins.noMatchRegistry': 'Κανένα plugin στο μητρώο δεν ταιριάζει με την αναζήτησή σας.', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 0bcc382c0..24e6beaf6 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -429,6 +429,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Frissítések elöl', 'admin.plugins.sortDownloads': 'Legtöbb letöltés', 'admin.plugins.updatesAvailable': '{count} frissítés érhető el a pluginjaidhoz.', + 'admin.plugins.newerNeedsTrek': 'v{version} elérhető — TREK {range} szükséges', + 'admin.plugins.versionsTitle': 'Verziók', + 'admin.plugins.versionPickerTitle': 'Verzióváltás — {name}', + 'admin.plugins.versionSwitch': 'Váltás erre: {version}', + 'admin.plugins.versionNeedsTrek': 'TREK {range} szükséges', + 'admin.plugins.changeVersion': 'Verzióváltás…', + 'admin.plugins.noVersions': 'Nem található publikált verzió a jegyzékben.', + 'admin.plugins.downgradeTitle': 'Visszaállítod ezt a bővítményt?', + 'admin.plugins.downgradeBody': 'Váltás v{from} verzióról v{to} verzióra: az újabb verzió által írt adatok megmaradnak, és a régebbi verzió lehet, hogy nem érti őket.', + 'admin.plugins.downgradeConfirm': 'Visszaállítás', + 'admin.plugins.updatesHeld': 'Frissítések szüneteltetve a v{version} verzión', + 'admin.plugins.resumeUpdates': 'Frissítések folytatása', + 'admin.plugins.updatesResumed': 'Frissítések folytatva', 'admin.plugins.updateAll': 'Összes frissítése', 'admin.plugins.noMatchInstalled': 'Nincs a keresésnek megfelelő telepített plugin.', 'admin.plugins.noMatchRegistry': 'Nincs a keresésnek megfelelő plugin a tárban.', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 0ec4aacb3..2b441f399 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -471,6 +471,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Pembaruan dulu', 'admin.plugins.sortDownloads': 'Paling banyak diunduh', 'admin.plugins.updatesAvailable': '{count} pembaruan tersedia untuk plugin kamu.', + 'admin.plugins.newerNeedsTrek': 'v{version} tersedia — membutuhkan TREK {range}', + 'admin.plugins.versionsTitle': 'Versi', + 'admin.plugins.versionPickerTitle': 'Ubah versi — {name}', + 'admin.plugins.versionSwitch': 'Beralih ke {version}', + 'admin.plugins.versionNeedsTrek': 'membutuhkan TREK {range}', + 'admin.plugins.changeVersion': 'Ubah versi…', + 'admin.plugins.noVersions': 'Tidak ada versi terpublikasi yang ditemukan di registri.', + 'admin.plugins.downgradeTitle': 'Kembalikan plugin ini?', + 'admin.plugins.downgradeBody': 'Beralih dari v{from} ke v{to}: data yang ditulis oleh versi yang lebih baru tetap ada, dan versi yang lebih lama mungkin tidak memahaminya.', + 'admin.plugins.downgradeConfirm': 'Kembalikan', + 'admin.plugins.updatesHeld': 'Pembaruan dijeda di v{version}', + 'admin.plugins.resumeUpdates': 'Lanjutkan pembaruan', + 'admin.plugins.updatesResumed': 'Pembaruan dilanjutkan', 'admin.plugins.updateAll': 'Perbarui semua', 'admin.plugins.noMatchInstalled': 'Tidak ada plugin terpasang yang cocok dengan pencarianmu.', 'admin.plugins.noMatchRegistry': 'Tidak ada plugin di registri yang cocok dengan pencarianmu.', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 83ab41636..29fd73d22 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -424,6 +424,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Prima gli aggiornamenti', 'admin.plugins.sortDownloads': 'Più scaricati', 'admin.plugins.updatesAvailable': '{count} aggiornamenti disponibili per i tuoi plugin.', + 'admin.plugins.newerNeedsTrek': 'v{version} disponibile — richiede TREK {range}', + 'admin.plugins.versionsTitle': 'Versioni', + 'admin.plugins.versionPickerTitle': 'Cambia versione — {name}', + 'admin.plugins.versionSwitch': 'Passa a {version}', + 'admin.plugins.versionNeedsTrek': 'richiede TREK {range}', + 'admin.plugins.changeVersion': 'Cambia versione…', + 'admin.plugins.noVersions': 'Nessuna versione pubblicata trovata nel registro.', + 'admin.plugins.downgradeTitle': 'Ripristinare questo plugin?', + 'admin.plugins.downgradeBody': 'Passaggio da v{from} a v{to}: i dati scritti dalla versione più recente restano, e la versione precedente potrebbe non comprenderli.', + 'admin.plugins.downgradeConfirm': 'Ripristina', + 'admin.plugins.updatesHeld': 'Aggiornamenti in pausa alla v{version}', + 'admin.plugins.resumeUpdates': 'Riprendi aggiornamenti', + 'admin.plugins.updatesResumed': 'Aggiornamenti ripresi', 'admin.plugins.updateAll': 'Aggiorna tutti', 'admin.plugins.noMatchInstalled': 'Nessun plugin installato corrisponde alla tua ricerca.', 'admin.plugins.noMatchRegistry': 'Nessun plugin nel registro corrisponde alla tua ricerca.', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 6d0a70b41..fc54be6c5 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -436,6 +436,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'アップデート優先', 'admin.plugins.sortDownloads': 'ダウンロード数順', 'admin.plugins.updatesAvailable': 'プラグインに{count}件のアップデートがあります。', + 'admin.plugins.newerNeedsTrek': 'v{version} が利用可能 — TREK {range} が必要', + 'admin.plugins.versionsTitle': 'バージョン', + 'admin.plugins.versionPickerTitle': 'バージョン変更 — {name}', + 'admin.plugins.versionSwitch': '{version} に切り替え', + 'admin.plugins.versionNeedsTrek': 'TREK {range} が必要', + 'admin.plugins.changeVersion': 'バージョン変更…', + 'admin.plugins.noVersions': 'レジストリに公開バージョンが見つかりません。', + 'admin.plugins.downgradeTitle': 'このプラグインをロールバックしますか?', + 'admin.plugins.downgradeBody': 'v{from} から v{to} への切り替え:新しいバージョンが書き込んだデータはそのまま残り、古いバージョンはそれを認識できない可能性があります。', + 'admin.plugins.downgradeConfirm': 'ロールバック', + 'admin.plugins.updatesHeld': 'v{version} で更新を一時停止中', + 'admin.plugins.resumeUpdates': '更新を再開', + 'admin.plugins.updatesResumed': '更新を再開しました', 'admin.plugins.updateAll': 'すべて更新', 'admin.plugins.noMatchInstalled': '検索条件に一致するインストール済みプラグインはありません。', 'admin.plugins.noMatchRegistry': '検索条件に一致するプラグインはレジストリにありません。', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 30de344b5..fe36bad8e 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -436,6 +436,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': '업데이트 우선', 'admin.plugins.sortDownloads': '다운로드순', 'admin.plugins.updatesAvailable': '플러그인 업데이트 {count}건이 있습니다.', + 'admin.plugins.newerNeedsTrek': 'v{version} 사용 가능 — TREK {range} 필요', + 'admin.plugins.versionsTitle': '버전', + 'admin.plugins.versionPickerTitle': '버전 변경 — {name}', + 'admin.plugins.versionSwitch': '{version}(으)로 전환', + 'admin.plugins.versionNeedsTrek': 'TREK {range} 필요', + 'admin.plugins.changeVersion': '버전 변경…', + 'admin.plugins.noVersions': '레지스트리에서 게시된 버전을 찾을 수 없습니다.', + 'admin.plugins.downgradeTitle': '이 플러그인을 롤백하시겠습니까?', + 'admin.plugins.downgradeBody': 'v{from}에서 v{to}(으)로 전환: 최신 버전이 기록한 데이터는 그대로 유지되며, 이전 버전이 이를 인식하지 못할 수 있습니다.', + 'admin.plugins.downgradeConfirm': '롤백', + 'admin.plugins.updatesHeld': 'v{version}에서 업데이트 일시 중지됨', + 'admin.plugins.resumeUpdates': '업데이트 재개', + 'admin.plugins.updatesResumed': '업데이트가 재개되었습니다', 'admin.plugins.updateAll': '모두 업데이트', 'admin.plugins.noMatchInstalled': '검색과 일치하는 설치된 플러그인이 없습니다.', 'admin.plugins.noMatchRegistry': '레지스트리에 검색과 일치하는 플러그인이 없습니다.', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index d4927bad9..b803a3e4d 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -423,6 +423,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Updates eerst', 'admin.plugins.sortDownloads': 'Meest gedownload', 'admin.plugins.updatesAvailable': '{count} updates beschikbaar voor je plugins.', + 'admin.plugins.newerNeedsTrek': 'v{version} beschikbaar — vereist TREK {range}', + 'admin.plugins.versionsTitle': 'Versies', + 'admin.plugins.versionPickerTitle': 'Versie wijzigen — {name}', + 'admin.plugins.versionSwitch': 'Overschakelen naar {version}', + 'admin.plugins.versionNeedsTrek': 'vereist TREK {range}', + 'admin.plugins.changeVersion': 'Versie wijzigen…', + 'admin.plugins.noVersions': 'Geen gepubliceerde versies gevonden in het register.', + 'admin.plugins.downgradeTitle': 'Deze plugin terugdraaien?', + 'admin.plugins.downgradeBody': 'Overschakelen van v{from} naar v{to}: gegevens die door de nieuwere versie zijn geschreven blijven staan, en de oudere versie begrijpt ze mogelijk niet.', + 'admin.plugins.downgradeConfirm': 'Terugdraaien', + 'admin.plugins.updatesHeld': 'Updates gepauzeerd op v{version}', + 'admin.plugins.resumeUpdates': 'Updates hervatten', + 'admin.plugins.updatesResumed': 'Updates hervat', 'admin.plugins.updateAll': 'Alles bijwerken', 'admin.plugins.noMatchInstalled': 'Geen geïnstalleerde plugins komen overeen met je zoekopdracht.', 'admin.plugins.noMatchRegistry': 'Geen plugins in het register komen overeen met je zoekopdracht.', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index a3d90216d..59cccf9e9 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -421,6 +421,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Najpierw aktualizacje', 'admin.plugins.sortDownloads': 'Najczęściej pobierane', 'admin.plugins.updatesAvailable': 'Dostępne aktualizacje dla Twoich wtyczek: {count}.', + 'admin.plugins.newerNeedsTrek': 'v{version} dostępna — wymaga TREK {range}', + 'admin.plugins.versionsTitle': 'Wersje', + 'admin.plugins.versionPickerTitle': 'Zmień wersję — {name}', + 'admin.plugins.versionSwitch': 'Przełącz na {version}', + 'admin.plugins.versionNeedsTrek': 'wymaga TREK {range}', + 'admin.plugins.changeVersion': 'Zmień wersję…', + 'admin.plugins.noVersions': 'Nie znaleziono opublikowanych wersji w rejestrze.', + 'admin.plugins.downgradeTitle': 'Przywrócić starszą wersję tej wtyczki?', + 'admin.plugins.downgradeBody': 'Przejście z v{from} na v{to}: dane zapisane przez nowszą wersję pozostają, a starsza wersja może ich nie rozumieć.', + 'admin.plugins.downgradeConfirm': 'Przywróć', + 'admin.plugins.updatesHeld': 'Aktualizacje wstrzymane na v{version}', + 'admin.plugins.resumeUpdates': 'Wznów aktualizacje', + 'admin.plugins.updatesResumed': 'Wznowiono aktualizacje', 'admin.plugins.updateAll': 'Zaktualizuj wszystkie', 'admin.plugins.noMatchInstalled': 'Żadna zainstalowana wtyczka nie pasuje do wyszukiwania.', 'admin.plugins.noMatchRegistry': 'Żadna wtyczka w rejestrze nie pasuje do wyszukiwania.', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index c4ec1e4fb..ef613decd 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -425,6 +425,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Сначала с обновлениями', 'admin.plugins.sortDownloads': 'По загрузкам', 'admin.plugins.updatesAvailable': 'Доступно обновлений для ваших плагинов: {count}.', + 'admin.plugins.newerNeedsTrek': 'Доступна v{version} — требуется TREK {range}', + 'admin.plugins.versionsTitle': 'Версии', + 'admin.plugins.versionPickerTitle': 'Сменить версию — {name}', + 'admin.plugins.versionSwitch': 'Перейти на {version}', + 'admin.plugins.versionNeedsTrek': 'требуется TREK {range}', + 'admin.plugins.changeVersion': 'Сменить версию…', + 'admin.plugins.noVersions': 'В реестре не найдено опубликованных версий.', + 'admin.plugins.downgradeTitle': 'Откатить этот плагин?', + 'admin.plugins.downgradeBody': 'Переход с v{from} на v{to}: данные, записанные более новой версией, остаются на месте, и старая версия может их не понять.', + 'admin.plugins.downgradeConfirm': 'Откатить', + 'admin.plugins.updatesHeld': 'Обновления приостановлены на v{version}', + 'admin.plugins.resumeUpdates': 'Возобновить обновления', + 'admin.plugins.updatesResumed': 'Обновления возобновлены', 'admin.plugins.updateAll': 'Обновить все', 'admin.plugins.noMatchInstalled': 'Нет установленных плагинов по вашему запросу.', 'admin.plugins.noMatchRegistry': 'В реестре нет плагинов по вашему запросу.', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 473e23dc8..d222807cf 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -479,6 +479,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Uppdateringar först', 'admin.plugins.sortDownloads': 'Mest nedladdade', 'admin.plugins.updatesAvailable': '{count} uppdateringar tillgängliga för dina plugins.', + 'admin.plugins.newerNeedsTrek': 'v{version} tillgänglig — kräver TREK {range}', + 'admin.plugins.versionsTitle': 'Versioner', + 'admin.plugins.versionPickerTitle': 'Byt version — {name}', + 'admin.plugins.versionSwitch': 'Byt till {version}', + 'admin.plugins.versionNeedsTrek': 'kräver TREK {range}', + 'admin.plugins.changeVersion': 'Byt version…', + 'admin.plugins.noVersions': 'Inga publicerade versioner hittades i registret.', + 'admin.plugins.downgradeTitle': 'Rulla tillbaka detta tillägg?', + 'admin.plugins.downgradeBody': 'Byte från v{from} till v{to}: data som skrivits av den nyare versionen finns kvar, och den äldre versionen kanske inte förstår dem.', + 'admin.plugins.downgradeConfirm': 'Rulla tillbaka', + 'admin.plugins.updatesHeld': 'Uppdateringar pausade vid v{version}', + 'admin.plugins.resumeUpdates': 'Återuppta uppdateringar', + 'admin.plugins.updatesResumed': 'Uppdateringar återupptagna', 'admin.plugins.updateAll': 'Uppdatera alla', 'admin.plugins.noMatchInstalled': 'Inga installerade plugins matchar din sökning.', 'admin.plugins.noMatchRegistry': 'Inga plugins i registret matchar din sökning.', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 0cbeb6721..21b427b28 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -468,6 +468,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Önce güncellemeler', 'admin.plugins.sortDownloads': 'En çok indirilen', 'admin.plugins.updatesAvailable': 'Eklentileriniz için {count} güncelleme mevcut.', + 'admin.plugins.newerNeedsTrek': 'v{version} mevcut — TREK {range} gerektirir', + 'admin.plugins.versionsTitle': 'Sürümler', + 'admin.plugins.versionPickerTitle': 'Sürüm değiştir — {name}', + 'admin.plugins.versionSwitch': '{version} sürümüne geç', + 'admin.plugins.versionNeedsTrek': 'TREK {range} gerektirir', + 'admin.plugins.changeVersion': 'Sürüm değiştir…', + 'admin.plugins.noVersions': 'Kayıt defterinde yayımlanmış sürüm bulunamadı.', + 'admin.plugins.downgradeTitle': 'Bu eklenti geri alınsın mı?', + 'admin.plugins.downgradeBody': 'v{from} sürümünden v{to} sürümüne geçiş: daha yeni sürümün yazdığı veriler yerinde kalır ve eski sürüm bunları anlamayabilir.', + 'admin.plugins.downgradeConfirm': 'Geri al', + 'admin.plugins.updatesHeld': 'Güncellemeler v{version} sürümünde duraklatıldı', + 'admin.plugins.resumeUpdates': 'Güncellemeleri sürdür', + 'admin.plugins.updatesResumed': 'Güncellemeler sürdürüldü', 'admin.plugins.updateAll': 'Tümünü güncelle', 'admin.plugins.noMatchInstalled': 'Aramanızla eşleşen yüklü eklenti yok.', 'admin.plugins.noMatchRegistry': 'Dizinde aramanızla eşleşen eklenti yok.', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 6634fda54..42441282d 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -422,6 +422,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Спочатку оновлення', 'admin.plugins.sortDownloads': 'За завантаженнями', 'admin.plugins.updatesAvailable': 'Для ваших плагінів доступно оновлень: {count}.', + 'admin.plugins.newerNeedsTrek': 'Доступна v{version} — потрібен TREK {range}', + 'admin.plugins.versionsTitle': 'Версії', + 'admin.plugins.versionPickerTitle': 'Змінити версію — {name}', + 'admin.plugins.versionSwitch': 'Перейти на {version}', + 'admin.plugins.versionNeedsTrek': 'потрібен TREK {range}', + 'admin.plugins.changeVersion': 'Змінити версію…', + 'admin.plugins.noVersions': 'У реєстрі не знайдено опублікованих версій.', + 'admin.plugins.downgradeTitle': 'Відкотити цей плагін?', + 'admin.plugins.downgradeBody': 'Перехід з v{from} на v{to}: дані, записані новішою версією, залишаються, і старіша версія може їх не розуміти.', + 'admin.plugins.downgradeConfirm': 'Відкотити', + 'admin.plugins.updatesHeld': 'Оновлення призупинено на v{version}', + 'admin.plugins.resumeUpdates': 'Відновити оновлення', + 'admin.plugins.updatesResumed': 'Оновлення відновлено', 'admin.plugins.updateAll': 'Оновити все', 'admin.plugins.noMatchInstalled': 'Жоден встановлений плагін не відповідає вашому запиту.', 'admin.plugins.noMatchRegistry': 'Жоден плагін у реєстрі не відповідає вашому запиту.', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 170c10c49..e3c00665d 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -421,6 +421,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': 'Ưu tiên bản cập nhật', 'admin.plugins.sortDownloads': 'Tải nhiều nhất', 'admin.plugins.updatesAvailable': 'Có {count} bản cập nhật cho các plugin của bạn.', + 'admin.plugins.newerNeedsTrek': 'Đã có v{version} — cần TREK {range}', + 'admin.plugins.versionsTitle': 'Phiên bản', + 'admin.plugins.versionPickerTitle': 'Đổi phiên bản — {name}', + 'admin.plugins.versionSwitch': 'Chuyển sang {version}', + 'admin.plugins.versionNeedsTrek': 'cần TREK {range}', + 'admin.plugins.changeVersion': 'Đổi phiên bản…', + 'admin.plugins.noVersions': 'Không tìm thấy phiên bản đã xuất bản trong sổ đăng ký.', + 'admin.plugins.downgradeTitle': 'Quay lại phiên bản cũ của plugin này?', + 'admin.plugins.downgradeBody': 'Chuyển từ v{from} sang v{to}: dữ liệu do phiên bản mới hơn ghi vẫn còn nguyên, và phiên bản cũ hơn có thể không hiểu được.', + 'admin.plugins.downgradeConfirm': 'Quay lại', + 'admin.plugins.updatesHeld': 'Đã tạm dừng cập nhật ở v{version}', + 'admin.plugins.resumeUpdates': 'Tiếp tục cập nhật', + 'admin.plugins.updatesResumed': 'Đã tiếp tục cập nhật', 'admin.plugins.updateAll': 'Cập nhật tất cả', 'admin.plugins.noMatchInstalled': 'Không có plugin đã cài đặt nào khớp với tìm kiếm của bạn.', 'admin.plugins.noMatchRegistry': 'Không có plugin nào trong kho đăng ký khớp với tìm kiếm của bạn.', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index ed1274b46..47d4845f3 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -418,6 +418,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': '更新優先', 'admin.plugins.sortDownloads': '下載最多', 'admin.plugins.updatesAvailable': '你的外掛有 {count} 項可用更新。', + 'admin.plugins.newerNeedsTrek': 'v{version} 可用 — 需要 TREK {range}', + 'admin.plugins.versionsTitle': '版本', + 'admin.plugins.versionPickerTitle': '更換版本 — {name}', + 'admin.plugins.versionSwitch': '切換到 {version}', + 'admin.plugins.versionNeedsTrek': '需要 TREK {range}', + 'admin.plugins.changeVersion': '更換版本…', + 'admin.plugins.noVersions': '在登錄檔中未找到已發布的版本。', + 'admin.plugins.downgradeTitle': '回滾此外掛?', + 'admin.plugins.downgradeBody': '從 v{from} 切換到 v{to}:較新版本寫入的資料將保留,較舊版本可能無法識別這些資料。', + 'admin.plugins.downgradeConfirm': '回滾', + 'admin.plugins.updatesHeld': '更新已暫停在 v{version}', + 'admin.plugins.resumeUpdates': '恢復更新', + 'admin.plugins.updatesResumed': '已恢復更新', 'admin.plugins.updateAll': '全部更新', 'admin.plugins.noMatchInstalled': '沒有符合搜尋條件的已安裝外掛。', 'admin.plugins.noMatchRegistry': '外掛庫中沒有符合搜尋條件的外掛。', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 66f49b88e..4277fa72b 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -378,6 +378,19 @@ const admin: TranslationStrings = { 'admin.plugins.sortUpdates': '优先显示有更新的', 'admin.plugins.sortDownloads': '下载最多', 'admin.plugins.updatesAvailable': '你的插件有 {count} 个可用更新。', + 'admin.plugins.newerNeedsTrek': 'v{version} 可用 — 需要 TREK {range}', + 'admin.plugins.versionsTitle': '版本', + 'admin.plugins.versionPickerTitle': '更换版本 — {name}', + 'admin.plugins.versionSwitch': '切换到 {version}', + 'admin.plugins.versionNeedsTrek': '需要 TREK {range}', + 'admin.plugins.changeVersion': '更换版本…', + 'admin.plugins.noVersions': '在注册表中未找到已发布的版本。', + 'admin.plugins.downgradeTitle': '回滚此插件?', + 'admin.plugins.downgradeBody': '从 v{from} 切换到 v{to}:较新版本写入的数据将保留,较旧版本可能无法识别这些数据。', + 'admin.plugins.downgradeConfirm': '回滚', + 'admin.plugins.updatesHeld': '更新已暂停在 v{version}', + 'admin.plugins.resumeUpdates': '恢复更新', + 'admin.plugins.updatesResumed': '已恢复更新', 'admin.plugins.updateAll': '全部更新', 'admin.plugins.noMatchInstalled': '没有已安装的插件与你的搜索匹配。', 'admin.plugins.noMatchRegistry': '插件库中没有与你的搜索匹配的插件。',