feat(store): console plugin store, index repo, and the fixes on-glass found
apple / swift (push) Successful in 1m22s
release / apple (push) Successful in 9m59s
windows-host / package (push) Successful in 9m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m20s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
audit / cargo-audit (push) Successful in 2m58s
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 49s
flatpak / build-publish (push) Successful in 7m53s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Successful in 11m41s
deb / build-publish-host (push) Successful in 13m32s
apple / swift (push) Successful in 1m22s
release / apple (push) Successful in 9m59s
windows-host / package (push) Successful in 9m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m20s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
audit / cargo-audit (push) Successful in 2m58s
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 49s
flatpak / build-publish (push) Successful in 7m53s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Successful in 11m41s
deb / build-publish-host (push) Successful in 13m32s
Console: a Plugins section (Browse / Installed / Sources) on a static nav entry, with install friction proportional to trust — a plain confirm for a verified entry, a warning naming the curator for an external one, and a danger dialog that makes you retype the spec for a raw package. Tier badges are permanent and follow the plugin onto its own UI page. Index: unom/punktfunk-plugin-index published and served from Gitea's raw endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is publish, which resolves the design's open hosting question. Four things only running it could find: - runner discovery matched @punktfunk/plugin-* only, so a third-party scoped plugin (which D8 requires) would install and never run - ...and that convention also matches @punktfunk/plugin-kit, a plugin's own framework: it listed as installed and would have been imported as a unit. Both now key off the plugins dir's top-level dependencies, with an emptied dependency list meaning 'nothing installed' rather than falling back to the naming convention - the store must not pass new flags to the runner: the scripting package ships separately and an older one reads an unknown flag's value as a package name. The host writes the bunfig scope mapping itself - ureq reports only >= 400 as Err, so a conditional request's 304 arrives as Ok with an empty body — handled as an error it made every refresh after the first verify a signature over zero bytes and sit stale Also: the console's first Tabs use exposed an @unom/ui theme gap that rendered inactive tabs invisible (caught in a browser pass, not by types). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+108
-1
@@ -267,5 +267,112 @@
|
||||
"stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"stats_detail_title": "Aufnahme-Details",
|
||||
"stats_close": "Schließen",
|
||||
"stats_no_samples": "Diese Aufnahme enthält keine Proben."
|
||||
"stats_no_samples": "Diese Aufnahme enthält keine Proben.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Durchstöbere den Plugin-Katalog, installiere und entferne Plugins und lege fest, welchen Katalogen dieser Host vertraut.",
|
||||
"store_tab_browse": "Entdecken",
|
||||
"store_tab_installed": "Installiert",
|
||||
"store_tab_sources": "Quellen",
|
||||
"store_tier_verified": "Geprüft",
|
||||
"store_tier_verified_hint": "Aus dem eingebauten unom-Katalog — unom hat genau dieses Paket geprüft.",
|
||||
"store_tier_external": "Externe Quelle",
|
||||
"store_tier_external_hint": "Festgepinnt und auf Integrität geprüft, aber von jemand anderem als unom kuratiert. Niemand bei unom hat diesen Code geprüft.",
|
||||
"store_tier_unverified": "Ungeprüft",
|
||||
"store_tier_unverified_hint": "Aus einer rohen Paketangabe installiert. Kein Katalog, keine Prüfung — diesen Code hat nie jemand kontrolliert.",
|
||||
"store_tier_cli": "Über die CLI installiert",
|
||||
"store_tier_cli_hint": "Von der Kommandozeile installiert — der Host weiß daher nicht, woher es stammt.",
|
||||
"store_from_source": "von",
|
||||
"store_search_placeholder": "Plugins durchsuchen…",
|
||||
"store_filter_all": "Alle Quellen",
|
||||
"store_empty": "Noch keine Plugins im Katalog.",
|
||||
"store_no_match": "Kein Plugin passt zur Suche.",
|
||||
"store_by_author": "von {author}",
|
||||
"store_homepage": "Website",
|
||||
"store_install": "Installieren",
|
||||
"store_installed_label": "Installiert",
|
||||
"store_update_to": "Auf {version} aktualisieren",
|
||||
"store_incompatible": "Nicht kompatibel mit diesem Host.",
|
||||
"store_blocked": "Vom Host blockiert: {reason}",
|
||||
"store_spec_open": "Aus Paketangabe installieren…",
|
||||
"store_runner_title": "Plugin-Runner",
|
||||
"store_runner_help": "Der Dienst, in dem jedes Plugin läuft. Ihn abzuschalten stoppt alle Plugins auf einmal, ohne etwas zu deinstallieren.",
|
||||
"store_runner_not_installed": "Der Plugin-Runner ist auf diesem Host nicht installiert — Plugins können daher nicht starten.",
|
||||
"store_runner_banner": "Der Plugin-Runner ist ausgeschaltet — Plugins starten erst, wenn du ihn aktivierst.",
|
||||
"store_runner_enable": "Runner aktivieren",
|
||||
"store_runner_disable": "Runner deaktivieren",
|
||||
"store_runner_state_running": "Läuft",
|
||||
"store_runner_state_stopped": "Gestoppt",
|
||||
"store_runner_state_disabled": "Deaktiviert",
|
||||
"store_runner_state_missing": "Nicht installiert",
|
||||
"store_runner_unit": "Dienst",
|
||||
"store_runner_principal": "Läuft als",
|
||||
"store_runner_failed": "Der Plugin-Runner konnte nicht umgeschaltet werden.",
|
||||
"store_installed_title": "Installierte Plugins",
|
||||
"store_installed_empty": "Noch keine Plugins installiert.",
|
||||
"store_running": "Läuft",
|
||||
"store_stopped": "Läuft nicht",
|
||||
"store_uninstall": "Deinstallieren",
|
||||
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
|
||||
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
|
||||
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
|
||||
"store_sources_title": "Katalogquellen",
|
||||
"store_sources_help": "Wo dieser Host nach Plugins sucht. Der eingebaute unom-Katalog ist immer dabei; jede weitere Quelle hast du selbst hinzugefügt und stehst selbst dafür ein.",
|
||||
"store_refresh_all": "Alle aktualisieren",
|
||||
"store_refresh_failed": "Die Kataloge konnten nicht aktualisiert werden.",
|
||||
"store_source_builtin": "Eingebaut",
|
||||
"store_source_signed": "Signiert",
|
||||
"store_source_unsigned": "Unsigniert",
|
||||
"store_source_stale": "Veraltet",
|
||||
"store_source_entries": "{count} Plugins",
|
||||
"store_source_fetched": "zuletzt geladen {when}",
|
||||
"store_source_never": "nie",
|
||||
"store_source_remove": "Quelle entfernen",
|
||||
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
|
||||
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
|
||||
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
|
||||
"store_add_source_title": "Katalogquelle hinzufügen",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index-URL",
|
||||
"store_field_source_key": "Öffentlicher Schlüssel (optional)",
|
||||
"store_field_source_key_help": "Ein ed25519:…-Schlüssel. Ist er gesetzt, akzeptiert der Host von dieser Quelle nur einen signierten Index.",
|
||||
"store_add_source": "Quelle hinzufügen",
|
||||
"store_add_source_failed": "Die Quelle konnte nicht gespeichert werden — prüfe Name und URL.",
|
||||
"store_source_trust_title": "Dieser Quelle vertrauen?",
|
||||
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
|
||||
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
|
||||
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
|
||||
"store_install_title": "{title} installieren?",
|
||||
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
|
||||
"store_install_confirm": "Installieren",
|
||||
"store_install_external_title": "{title} aus einer externen Quelle installieren?",
|
||||
"store_install_external_body": "Version {version} stammt aus „{source}“ — einem Katalog, den du selbst hinzugefügt hast.",
|
||||
"store_install_external_note": "unom hat diesen Code nicht geprüft. Das Paket ist festgepinnt und auf Integrität geprüft, läuft auf diesem Host aber mit den Rechten des Plugin-Runners.",
|
||||
"store_install_external_confirm": "Trotzdem installieren",
|
||||
"store_install_failed": "Die Installation konnte nicht gestartet werden.",
|
||||
"store_busy": "Der Host installiert oder entfernt gerade schon ein Plugin. Versuche es, sobald das durch ist.",
|
||||
"store_spec_title": "Aus einer Paketangabe installieren",
|
||||
"store_spec_lead": "Das installiert Code direkt aus einer Paket-Registry. Er steht in keinem Katalog, niemand hat ihn geprüft, und er läuft auf diesem Host mit den Rechten des Plugin-Runners — mit demselben Zugriff auf deine Dateien und deine Sitzung.",
|
||||
"store_spec_permanent": "Ein so installiertes Plugin bleibt als „Ungeprüft“ markiert, solange es installiert ist.",
|
||||
"store_spec_field": "Paketangabe",
|
||||
"store_spec_field_help": "Zum Beispiel @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
|
||||
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
|
||||
"store_spec_confirm": "Ungeprüft installieren",
|
||||
"store_job_install": "{target} wird installiert",
|
||||
"store_job_uninstall": "{target} wird entfernt",
|
||||
"store_job_done_install": "Installiert.",
|
||||
"store_job_done_uninstall": "Entfernt.",
|
||||
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
|
||||
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
|
||||
"store_job_log": "Log anzeigen",
|
||||
"store_job_dismiss": "Ausblenden",
|
||||
"store_phase_queued": "In der Warteschlange",
|
||||
"store_phase_verifying": "Paket wird verifiziert",
|
||||
"store_phase_installing": "Wird installiert",
|
||||
"store_phase_removing": "Wird entfernt",
|
||||
"store_phase_checking": "Installation wird geprüft",
|
||||
"store_phase_rolling_back": "Wird zurückgerollt",
|
||||
"store_phase_recording": "Herkunft wird vermerkt",
|
||||
"store_phase_restarting": "Plugin-Runner startet neu",
|
||||
"store_phase_done": "Fertig"
|
||||
}
|
||||
|
||||
+108
-1
@@ -267,5 +267,112 @@
|
||||
"stats_delete_confirm": "Delete this recording? This can't be undone.",
|
||||
"stats_detail_title": "Recording detail",
|
||||
"stats_close": "Close",
|
||||
"stats_no_samples": "This recording has no samples."
|
||||
"stats_no_samples": "This recording has no samples.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Browse the plugin catalog, install and remove plugins, and choose which catalogs this host trusts.",
|
||||
"store_tab_browse": "Browse",
|
||||
"store_tab_installed": "Installed",
|
||||
"store_tab_sources": "Sources",
|
||||
"store_tier_verified": "Verified",
|
||||
"store_tier_verified_hint": "From the built-in unom catalog — unom reviewed this exact package.",
|
||||
"store_tier_external": "External source",
|
||||
"store_tier_external_hint": "Pinned and integrity-checked, but curated by someone other than unom. Nobody at unom reviewed this code.",
|
||||
"store_tier_unverified": "Unverified",
|
||||
"store_tier_unverified_hint": "Installed from a raw package spec. No catalog, no review — nobody checked this code.",
|
||||
"store_tier_cli": "Installed via CLI",
|
||||
"store_tier_cli_hint": "Installed from the command line, so the host has no record of where it came from.",
|
||||
"store_from_source": "from",
|
||||
"store_search_placeholder": "Search plugins…",
|
||||
"store_filter_all": "All sources",
|
||||
"store_empty": "No plugins in the catalog yet.",
|
||||
"store_no_match": "No plugin matches your search.",
|
||||
"store_by_author": "by {author}",
|
||||
"store_homepage": "Homepage",
|
||||
"store_install": "Install",
|
||||
"store_installed_label": "Installed",
|
||||
"store_update_to": "Update to {version}",
|
||||
"store_incompatible": "Not compatible with this host.",
|
||||
"store_blocked": "Blocked by the host: {reason}",
|
||||
"store_spec_open": "Install from package spec…",
|
||||
"store_runner_title": "Plugin runner",
|
||||
"store_runner_help": "The service every plugin runs inside. Disabling it stops all plugins at once without uninstalling anything.",
|
||||
"store_runner_not_installed": "The plugin runner isn't installed on this host, so plugins can't start.",
|
||||
"store_runner_banner": "The plugin runner is switched off — plugins won't start until you enable it.",
|
||||
"store_runner_enable": "Enable runner",
|
||||
"store_runner_disable": "Disable runner",
|
||||
"store_runner_state_running": "Running",
|
||||
"store_runner_state_stopped": "Stopped",
|
||||
"store_runner_state_disabled": "Disabled",
|
||||
"store_runner_state_missing": "Not installed",
|
||||
"store_runner_unit": "Service",
|
||||
"store_runner_principal": "Runs as",
|
||||
"store_runner_failed": "Could not change the plugin runner.",
|
||||
"store_installed_title": "Installed plugins",
|
||||
"store_installed_empty": "No plugins installed yet.",
|
||||
"store_running": "Running",
|
||||
"store_stopped": "Not running",
|
||||
"store_uninstall": "Uninstall",
|
||||
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
|
||||
"store_uninstall_failed": "Could not start the removal.",
|
||||
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
|
||||
"store_sources_title": "Catalog sources",
|
||||
"store_sources_help": "Where this host looks for plugins. The built-in unom catalog is always present; every other source is one you added and vouch for yourself.",
|
||||
"store_refresh_all": "Refresh all",
|
||||
"store_refresh_failed": "Could not refresh the catalogs.",
|
||||
"store_source_builtin": "Built in",
|
||||
"store_source_signed": "Signed",
|
||||
"store_source_unsigned": "Unsigned",
|
||||
"store_source_stale": "Out of date",
|
||||
"store_source_entries": "{count} plugins",
|
||||
"store_source_fetched": "last fetched {when}",
|
||||
"store_source_never": "never",
|
||||
"store_source_remove": "Remove source",
|
||||
"store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
|
||||
"store_source_remove_failed": "Could not remove the source.",
|
||||
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
|
||||
"store_add_source_title": "Add a catalog source",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index URL",
|
||||
"store_field_source_key": "Public key (optional)",
|
||||
"store_field_source_key_help": "An ed25519:… key. With a key set, the host only accepts a signed index from this source.",
|
||||
"store_add_source": "Add source",
|
||||
"store_add_source_failed": "Could not save the source — check the name and the URL.",
|
||||
"store_source_trust_title": "Trust this source?",
|
||||
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
|
||||
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
|
||||
"store_source_trust_confirm": "I understand — add the source",
|
||||
"store_install_title": "Install {title}?",
|
||||
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
|
||||
"store_install_confirm": "Install",
|
||||
"store_install_external_title": "Install {title} from an external source?",
|
||||
"store_install_external_body": "Version {version} comes from “{source}”, a catalog you added yourself.",
|
||||
"store_install_external_note": "unom has not reviewed this code. The package is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
|
||||
"store_install_external_confirm": "Install anyway",
|
||||
"store_install_failed": "Could not start the install.",
|
||||
"store_busy": "The host is already installing or removing a plugin. Try again once it's finished.",
|
||||
"store_spec_title": "Install from a package spec",
|
||||
"store_spec_lead": "This installs code straight from a package registry. It is in no catalog, nobody has reviewed it, and it will run on this host with the plugin runner's privileges — the same access it has to your files and your session.",
|
||||
"store_spec_permanent": "A plugin installed this way stays marked Unverified for as long as it is installed.",
|
||||
"store_spec_field": "Package spec",
|
||||
"store_spec_field_help": "For example @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Type the package spec again to confirm",
|
||||
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
|
||||
"store_spec_confirm": "Install unverified",
|
||||
"store_job_install": "Installing {target}",
|
||||
"store_job_uninstall": "Removing {target}",
|
||||
"store_job_done_install": "Installed.",
|
||||
"store_job_done_uninstall": "Removed.",
|
||||
"store_job_failed": "The job failed.",
|
||||
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
|
||||
"store_job_log": "Show log",
|
||||
"store_job_dismiss": "Dismiss",
|
||||
"store_phase_queued": "Queued",
|
||||
"store_phase_verifying": "Verifying the package",
|
||||
"store_phase_installing": "Installing",
|
||||
"store_phase_removing": "Removing",
|
||||
"store_phase_checking": "Checking the install",
|
||||
"store_phase_rolling_back": "Rolling back",
|
||||
"store_phase_recording": "Recording provenance",
|
||||
"store_phase_restarting": "Restarting the plugin runner",
|
||||
"store_phase_done": "Done"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// The plugin store: catalog, install/uninstall jobs, catalog sources, and the scripting runner.
|
||||
// Like `api/plugins.ts` this is a hand-written client rather than an orval-generated one — the
|
||||
// OpenAPI document is regenerated on a Linux box (punktfunk-host doesn't build on macOS), so the
|
||||
// console must not wait on a regen to talk to these endpoints. It rides the same `/api` BFF path as
|
||||
// every other call, so the bearer token is injected server-side and the browser only ever sends its
|
||||
// session cookie.
|
||||
import {
|
||||
type QueryClient,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { apiFetch } from "@/api/fetcher";
|
||||
|
||||
/**
|
||||
* How much a plugin's provenance is worth, from most to least trustworthy:
|
||||
*
|
||||
* - `verified` — from the built-in `unom` source; unom reviewed that exact tarball.
|
||||
* - `external` — from an operator-added source; pinned and integrity-checked, but curated by
|
||||
* somebody else. It is NOT reviewed by unom and never wears the verified badge.
|
||||
* - `unverified` — installed from a raw package spec through the high-friction dialog. No catalog,
|
||||
* no review, no pinning. Stays badged unverified forever.
|
||||
* - `cli` — installed with the CLI, so the host holds no provenance record at all.
|
||||
*/
|
||||
export type StoreTier = "verified" | "external" | "unverified" | "cli";
|
||||
|
||||
/** The tier a *catalog* entry can carry — the raw-spec/CLI tiers never appear in a catalog. */
|
||||
export type CatalogTier = Extract<StoreTier, "verified" | "external">;
|
||||
|
||||
export interface StoreHostInfo {
|
||||
version: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface StoreSource {
|
||||
name: string;
|
||||
url: string;
|
||||
/** The `unom` source ships with the host: it can't be edited or removed. */
|
||||
builtin: boolean;
|
||||
signed: boolean;
|
||||
/** The last fetch failed or is too old — entries may be out of date. */
|
||||
stale: boolean;
|
||||
/** Unix seconds of the last successful fetch. */
|
||||
fetched_at: number;
|
||||
error?: string;
|
||||
entry_count: number;
|
||||
public_key?: string;
|
||||
}
|
||||
|
||||
export interface StoreEntry {
|
||||
id: string;
|
||||
pkg: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
author: string;
|
||||
homepage?: string;
|
||||
license?: string;
|
||||
version: string;
|
||||
/** Name of the source this entry came from — the attribution an external entry shows. */
|
||||
source: string;
|
||||
tier: CatalogTier;
|
||||
reviewed_at?: string;
|
||||
platforms: string[];
|
||||
min_host?: string;
|
||||
compatible: boolean;
|
||||
incompatible_reason?: string;
|
||||
installed_version?: string;
|
||||
update_available: boolean;
|
||||
blocked?: string;
|
||||
}
|
||||
|
||||
export interface StoreCatalog {
|
||||
host: StoreHostInfo;
|
||||
sources: StoreSource[];
|
||||
plugins: StoreEntry[];
|
||||
/** An install/uninstall is already running — the host takes one at a time. */
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
pkg: string;
|
||||
version: string;
|
||||
tier: StoreTier;
|
||||
source?: string;
|
||||
entry_id?: string;
|
||||
/** The plugin's runtime id — how it's keyed in the plugin directory (`api/plugins.ts`). */
|
||||
plugin_id?: string;
|
||||
title?: string;
|
||||
installed_at?: string;
|
||||
running: boolean;
|
||||
/** The version available to update to, if any. */
|
||||
update_available?: string;
|
||||
blocked?: string;
|
||||
}
|
||||
|
||||
export type JobKind = "install" | "uninstall";
|
||||
export type JobState = "running" | "done" | "failed";
|
||||
|
||||
export interface StoreJob {
|
||||
id: string;
|
||||
kind: JobKind;
|
||||
target: string;
|
||||
state: JobState;
|
||||
phase: string;
|
||||
log: string[];
|
||||
error?: string;
|
||||
started_at: number;
|
||||
finished_at?: number;
|
||||
}
|
||||
|
||||
export interface RuntimeStatus {
|
||||
installed: boolean;
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
unit: string;
|
||||
principal?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** What `POST /store/install` and `POST /store/uninstall` answer with (202). */
|
||||
export interface JobAccepted {
|
||||
job: string;
|
||||
}
|
||||
|
||||
/** Install a curated catalog entry, or — deliberately awkward — a raw package spec. */
|
||||
export type InstallBody =
|
||||
| { source: string; id: string }
|
||||
| { spec: string; accept_unverified: true };
|
||||
|
||||
export interface SourceBody {
|
||||
url: string;
|
||||
public_key?: string;
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/store";
|
||||
|
||||
/** Query keys, in one place so any mutation can invalidate precisely. */
|
||||
export const storeKeys = {
|
||||
all: ["store"] as const,
|
||||
catalog: ["store", "catalog"] as const,
|
||||
installed: ["store", "installed"] as const,
|
||||
sources: ["store", "sources"] as const,
|
||||
runtime: ["store", "runtime"] as const,
|
||||
job: (id: string) => ["store", "job", id] as const,
|
||||
};
|
||||
|
||||
const json = (method: string, body: unknown): RequestInit => ({
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh everything a completed install/uninstall touches — the catalog (installed markers), the
|
||||
* installed list, the runner state (it restarts), and the plugin directory the nav is built from.
|
||||
*/
|
||||
export function invalidateStore(qc: QueryClient): Promise<void> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: storeKeys.catalog }),
|
||||
qc.invalidateQueries({ queryKey: storeKeys.installed }),
|
||||
qc.invalidateQueries({ queryKey: storeKeys.sources }),
|
||||
qc.invalidateQueries({ queryKey: storeKeys.runtime }),
|
||||
qc.invalidateQueries({ queryKey: ["plugins"] }),
|
||||
]).then(() => undefined);
|
||||
}
|
||||
|
||||
/** The merged catalog across every source, plus the sources' own health. */
|
||||
export function useStoreCatalog() {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.catalog,
|
||||
queryFn: () => apiFetch<StoreCatalog>(`${BASE}/catalog`),
|
||||
});
|
||||
}
|
||||
|
||||
/** What's installed right now, with each plugin's permanent provenance tier. */
|
||||
export function useInstalledPlugins() {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.installed,
|
||||
queryFn: () => apiFetch<InstalledPlugin[]>(`${BASE}/installed`),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStoreSources() {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.sources,
|
||||
queryFn: () => apiFetch<StoreSource[]>(`${BASE}/sources`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStoreRuntime() {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.runtime,
|
||||
queryFn: () => apiFetch<RuntimeStatus>(`${BASE}/runtime`),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A single install/uninstall job, polled once a second while it runs and left alone once it
|
||||
* settles. Pass `null` to park the query (no job in flight).
|
||||
*/
|
||||
export function useStoreJob(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.job(id ?? ""),
|
||||
queryFn: () =>
|
||||
apiFetch<StoreJob>(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`),
|
||||
enabled: id !== null,
|
||||
refetchInterval: (q) => (q.state.data?.state === "running" ? 1_000 : false),
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-fetch every source's index; answers with the freshly merged catalog. */
|
||||
export function useRefreshCatalog() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<StoreCatalog>(`${BASE}/refresh`, { method: "POST" }),
|
||||
onSuccess: (catalog) => {
|
||||
qc.setQueryData(storeKeys.catalog, catalog);
|
||||
qc.setQueryData(storeKeys.sources, catalog.sources);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Start an install. Answers 202 with the job to poll; 409 means another op is already running. */
|
||||
export function useInstallPlugin() {
|
||||
return useMutation({
|
||||
mutationFn: (body: InstallBody) =>
|
||||
apiFetch<JobAccepted>(`${BASE}/install`, json("POST", body)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUninstallPlugin() {
|
||||
return useMutation({
|
||||
mutationFn: (pkg: string) =>
|
||||
apiFetch<JobAccepted>(`${BASE}/uninstall`, json("POST", { pkg })),
|
||||
});
|
||||
}
|
||||
|
||||
/** Add or update an operator source (the built-in one is not editable). */
|
||||
export function useSetSource() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, ...body }: SourceBody & { name: string }) =>
|
||||
apiFetch<void>(
|
||||
`${BASE}/sources/${encodeURIComponent(name)}`,
|
||||
json("PUT", body),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: storeKeys.sources });
|
||||
qc.invalidateQueries({ queryKey: storeKeys.catalog });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSource() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
apiFetch<void>(`${BASE}/sources/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: storeKeys.sources });
|
||||
qc.invalidateQueries({ queryKey: storeKeys.catalog });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Enable or disable the plugin/script runner service. */
|
||||
export function useSetRuntime() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (enabled: boolean) =>
|
||||
apiFetch<RuntimeStatus>(`${BASE}/runtime`, json("POST", { enabled })),
|
||||
onSuccess: (status) => {
|
||||
qc.setQueryData(storeKeys.runtime, status);
|
||||
qc.invalidateQueries({ queryKey: ["plugins"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LibraryBig,
|
||||
MonitorPlay,
|
||||
MoreHorizontal,
|
||||
Puzzle,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -29,9 +30,15 @@ const NAV = [
|
||||
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
||||
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
|
||||
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
|
||||
{ to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() },
|
||||
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
||||
] as const;
|
||||
|
||||
// "/plugins" is the store's index route and "/plugins/<id>" a plugin's own UI, so the store entry
|
||||
// only counts as active on an exact match — otherwise it would light up alongside the plugin's own
|
||||
// nav entry below. Same reason "/" is exact.
|
||||
const EXACT: readonly string[] = ["/", "/plugins"];
|
||||
|
||||
// On phones a flat 8-tab bar is too cramped, so the first four are pinned and the rest live behind a
|
||||
// "More" tab that opens a sheet above the bar. Keep it ≤ 5 slots including "More".
|
||||
const MOBILE_PRIMARY = NAV.slice(0, 4);
|
||||
@@ -74,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
to={to}
|
||||
activeOptions={{ exact: to === "/" }}
|
||||
activeOptions={{ exact: EXACT.includes(to) }}
|
||||
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
activeProps={{
|
||||
className: "bg-primary/15 text-foreground font-medium",
|
||||
@@ -200,6 +207,7 @@ function MobileNav() {
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setMoreOpen(false)}
|
||||
activeOptions={{ exact: EXACT.includes(to) }}
|
||||
className={cn(tab, "rounded-md")}
|
||||
activeProps={{ className: "text-[var(--brand-light)]" }}
|
||||
>
|
||||
@@ -232,7 +240,7 @@ function MobileNav() {
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setMoreOpen(false)}
|
||||
activeOptions={{ exact: to === "/" }}
|
||||
activeOptions={{ exact: EXACT.includes(to) }}
|
||||
className={tab}
|
||||
activeProps={{ className: "text-[var(--brand-light)]" }}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// The console's Dialog IS @unom/ui's radix dialog — brand surface (material gloss + card radius)
|
||||
// with the shared close/overlay behaviour. @unom/ui ships the SURFACE only and leaves placement to
|
||||
// the app, so `DialogContent` here is the surface already wrapped in its portal + overlay and
|
||||
// centred in the viewport; everything else is re-exported unchanged.
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogContent as DialogSurface,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@unom/ui/dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DialogContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof DialogSurface>) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogSurface
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-100 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto p-6",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPortal>
|
||||
);
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// The console's Tabs ARE @unom/ui's radix tabs, with one correction: the inactive trigger colour.
|
||||
//
|
||||
// @unom/ui styles inactive triggers `text-secondary` and the active one `data-[state=active]:
|
||||
// text-main`. Those tokens come from @unom/ui's own palette; in the console's theme `text-secondary`
|
||||
// lands on (near) the tab strip's own background, so every inactive tab renders as an invisible
|
||||
// gap — the tab bar looks like a single lonely label with dead space beside it. Caught in a browser
|
||||
// pass, not by types: the markup and the a11y roles are entirely correct.
|
||||
//
|
||||
// Same shape as the other `components/ui/*` wrappers: adapt the shared primitive to this app's
|
||||
// tokens rather than restyling it at every call site.
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger as TabsTriggerBase,
|
||||
} from "@unom/ui/tabs";
|
||||
import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TabsTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof TabsTriggerBase>) => (
|
||||
<TabsTriggerBase
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground data-[state=active]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
TabsTrigger.displayName = "TabsTrigger";
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SectionStore } from "@/sections/Store";
|
||||
|
||||
// The plugin store. Sits at /plugins as an index route, so it coexists with the per-plugin UI route
|
||||
// (`plugins.$pluginId.$.tsx`) without either shadowing the other.
|
||||
export const Route = createFileRoute("/plugins/")({ component: SectionStore });
|
||||
@@ -8,9 +8,11 @@ import { getRouteApi, useNavigate } from "@tanstack/react-router";
|
||||
import { ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { type FC, useEffect, useMemo, useRef } from "react";
|
||||
import { pluginIcon, usePlugins } from "@/api/plugins";
|
||||
import { useInstalledPlugins } from "@/api/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { TierBadge } from "@/sections/Store/TierBadge";
|
||||
|
||||
const route = getRouteApi("/plugins/$pluginId/$");
|
||||
|
||||
@@ -26,6 +28,12 @@ export const SectionPlugin: FC = () => {
|
||||
const Icon = pluginIcon(meta?.ui?.icon);
|
||||
const title = meta?.title ?? pluginId;
|
||||
|
||||
// Provenance follows the plugin into its own page: an unverified plugin must stay visibly
|
||||
// unverified WHILE you use it, not only in the store listing. The store keys installations by
|
||||
// package, so the runtime id is matched through `plugin_id`.
|
||||
const { data: installed } = useInstalledPlugins();
|
||||
const provenance = installed?.find((p) => p.plugin_id === pluginId);
|
||||
|
||||
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
|
||||
// offline card (the manual Retry re-probes).
|
||||
const health = useQuery({
|
||||
@@ -77,6 +85,7 @@ export const SectionPlugin: FC = () => {
|
||||
v{meta.version}
|
||||
</span>
|
||||
)}
|
||||
{provenance && <TierBadge tier={provenance.tier} />}
|
||||
<a
|
||||
href={`/plugin-ui/${pluginId}/`}
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { AlertTriangle, Ban, Check, Download, Search } from "lucide-react";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { pluginIcon } from "@/api/plugins";
|
||||
import { type StoreEntry, useStoreCatalog } from "@/api/store";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { RunnerBanner } from "./Runner";
|
||||
import { SourceChip, TierBadge } from "./TierBadge";
|
||||
|
||||
/** Case-insensitive substring match across the fields an operator would actually search by. */
|
||||
function matches(entry: StoreEntry, needle: string): boolean {
|
||||
if (!needle) return true;
|
||||
const q = needle.toLowerCase();
|
||||
return [entry.title, entry.description, entry.pkg, entry.author].some((f) =>
|
||||
f.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Container: the catalog. Owns the catalog query plus the local search/source filter; installing is
|
||||
* escalated to the parent, which owns the tier-appropriate dialog and the resulting job — so this
|
||||
* subsection never installs anything itself.
|
||||
*/
|
||||
export const BrowseTab: FC<{
|
||||
onInstall: (entry: StoreEntry) => void;
|
||||
onInstallSpec: () => void;
|
||||
}> = ({ onInstall, onInstallSpec }) => {
|
||||
const catalog = useStoreCatalog();
|
||||
const [query, setQuery] = useState("");
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
|
||||
const entries = catalog.data?.plugins ?? [];
|
||||
const sources = catalog.data?.sources ?? [];
|
||||
const shown = useMemo(
|
||||
() =>
|
||||
entries.filter(
|
||||
(e) => (source === null || e.source === source) && matches(e, query),
|
||||
),
|
||||
[entries, source, query],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-card">
|
||||
<RunnerBanner />
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative sm:max-w-xs sm:flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
className="pl-9"
|
||||
aria-label={m.store_search_placeholder()}
|
||||
placeholder={m.store_search_placeholder()}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* One chip per source, so an operator can see a third-party catalog's entries alone. */}
|
||||
{sources.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={source === null ? "default" : "outline"}
|
||||
aria-pressed={source === null}
|
||||
onClick={() => setSource(null)}
|
||||
>
|
||||
{m.store_filter_all()}
|
||||
</Button>
|
||||
{sources.map((s) => (
|
||||
<Button
|
||||
key={s.name}
|
||||
size="sm"
|
||||
variant={source === s.name ? "default" : "outline"}
|
||||
aria-pressed={source === s.name}
|
||||
onClick={() => setSource(s.name)}
|
||||
>
|
||||
{s.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<QueryState
|
||||
isLoading={catalog.isLoading}
|
||||
error={catalog.error}
|
||||
refetch={catalog.refetch}
|
||||
>
|
||||
{shown.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
||||
{entries.length === 0 ? m.store_empty() : m.store_no_match()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="@container">
|
||||
<div className="grid grid-cols-1 gap-card @xl:grid-cols-2 @4xl:grid-cols-3">
|
||||
{shown.map((entry) => (
|
||||
<StoreCard
|
||||
key={`${entry.source}/${entry.id}`}
|
||||
entry={entry}
|
||||
onInstall={() => onInstall(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
{/* The ONLY way to the raw-spec install. Deliberately a quiet footer link, not a button on
|
||||
a card: an unverified install should take a decision, never a stray click. */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInstallSpec}
|
||||
className="text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
|
||||
>
|
||||
{m.store_spec_open()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** One catalog entry. Blocked entries shout; incompatible ones grey out; neither can be installed. */
|
||||
export const StoreCard: FC<{ entry: StoreEntry; onInstall: () => void }> = ({
|
||||
entry,
|
||||
onInstall,
|
||||
}) => {
|
||||
const Icon = pluginIcon(entry.icon);
|
||||
const blocked = entry.blocked !== undefined;
|
||||
const installed = entry.installed_version !== undefined;
|
||||
const installable = !blocked && entry.compatible;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
blocked && "ring-2 ring-destructive/60",
|
||||
!entry.compatible && !blocked && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex flex-1 flex-col gap-3 p-card">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-md bg-primary/15">
|
||||
<Icon className="size-5 text-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-medium" title={entry.title}>
|
||||
{entry.title}
|
||||
</h3>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{m.store_by_author({ author: entry.author })} · v{entry.version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TierBadge tier={entry.tier} />
|
||||
{/* Attribution, never verification: an external entry names who curated it. */}
|
||||
{entry.tier === "external" && <SourceChip source={entry.source} />}
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-3 text-sm text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entry.platforms.map((p) => (
|
||||
<Badge key={p} variant="secondary" className="font-normal">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{blocked && (
|
||||
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
||||
<Ban className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{m.store_blocked({ reason: entry.blocked ?? "" })}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!entry.compatible && !blocked && (
|
||||
<p className="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="mt-px size-3.5 shrink-0" />
|
||||
<span>{entry.incompatible_reason ?? m.store_incompatible()}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer pinned to the bottom so cards in a row line their actions up. */}
|
||||
<div className="mt-auto flex items-center gap-3 pt-1">
|
||||
{entry.update_available ? (
|
||||
<Button size="sm" disabled={!installable} onClick={onInstall}>
|
||||
<Download className="size-4" />
|
||||
{m.store_update_to({ version: entry.version })}
|
||||
</Button>
|
||||
) : installed ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Check className="size-4" />
|
||||
{m.store_installed_label()}
|
||||
</span>
|
||||
) : (
|
||||
<Button size="sm" disabled={!installable} onClick={onInstall}>
|
||||
<Download className="size-4" />
|
||||
{m.store_install()}
|
||||
</Button>
|
||||
)}
|
||||
{entry.homepage && (
|
||||
<a
|
||||
href={entry.homepage}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
|
||||
>
|
||||
{m.store_homepage()}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Checkbox } from "@unom/ui/form/checkbox";
|
||||
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { StoreEntry } from "@/api/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
// Friction proportional to trust. The three install paths deliberately do NOT share a confirm
|
||||
// button: a verified install is one click on a plain dialog, an external one makes you read that
|
||||
// unom didn't review the code and names who did curate it, and a raw package spec makes you retype
|
||||
// the spec and tick a box. The last one is never reachable by accident — no card, no button on a
|
||||
// listing, only the small footer link on Browse.
|
||||
|
||||
/**
|
||||
* Tiers 1 and 2: install a curated catalog entry. Verified entries get an ordinary confirm; an
|
||||
* entry from an operator-added source gets the warning treatment and names its source, because
|
||||
* "pinned and integrity-checked" is not the same claim as "reviewed".
|
||||
*/
|
||||
export const InstallDialog: FC<{
|
||||
/** The entry being confirmed, or null when the dialog is closed. */
|
||||
entry: StoreEntry | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: (entry: StoreEntry) => void;
|
||||
isPending: boolean;
|
||||
}> = ({ entry, onCancel, onConfirm, isPending }) => {
|
||||
const external = entry?.tier === "external";
|
||||
return (
|
||||
<Dialog open={entry !== null} onOpenChange={(open) => !open && onCancel()}>
|
||||
{entry && (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{external ? (
|
||||
<ShieldQuestion className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
|
||||
) : (
|
||||
<BadgeCheck className="size-5 shrink-0 text-[var(--success)]" />
|
||||
)}
|
||||
{external
|
||||
? m.store_install_external_title({ title: entry.title })
|
||||
: m.store_install_title({ title: entry.title })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{external
|
||||
? m.store_install_external_body({
|
||||
version: entry.version,
|
||||
source: entry.source,
|
||||
})
|
||||
: m.store_install_verified_body({ version: entry.version })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{entry.pkg}@{entry.version}
|
||||
</p>
|
||||
|
||||
{external && (
|
||||
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
|
||||
{m.store_install_external_note()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isPending}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
{/* Red is reserved for the raw-spec install; an external one escalates through
|
||||
its copy and its amber panel, not by borrowing the danger colour. */}
|
||||
<Button disabled={isPending} onClick={() => onConfirm(entry)}>
|
||||
{external
|
||||
? m.store_install_external_confirm()
|
||||
: m.store_install_confirm()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tier 3: install a raw package spec. No catalog, no review, no pinning — so the dialog spells out
|
||||
* exactly what that means and asks for two independent confirmations (retype the spec, tick the
|
||||
* box) before it will even enable its button.
|
||||
*/
|
||||
export const SpecInstallDialog: FC<{
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (spec: string) => void;
|
||||
isPending: boolean;
|
||||
}> = ({ open, onCancel, onConfirm, isPending }) => {
|
||||
const [spec, setSpec] = useState("");
|
||||
const [echo, setEcho] = useState("");
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
|
||||
// Both confirmations are cleared on every exit, cancel AND confirm alike: reopening this dialog
|
||||
// must never find it pre-armed with the last spec and a ticked box.
|
||||
const reset = () => {
|
||||
setSpec("");
|
||||
setEcho("");
|
||||
setAccepted(false);
|
||||
};
|
||||
const close = () => {
|
||||
reset();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const wanted = spec.trim();
|
||||
// Both gates must pass: the retyped spec matches exactly, AND the box is ticked.
|
||||
const ready = wanted.length > 0 && echo.trim() === wanted && accepted;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && close()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-5 shrink-0 text-destructive" />
|
||||
{m.store_spec_title()}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{m.store_spec_lead()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{m.store_spec_permanent()}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="store-spec">{m.store_spec_field()}</Label>
|
||||
<Input
|
||||
id="store-spec"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="@scope/plugin-name@1.2.3"
|
||||
value={spec}
|
||||
onChange={(e) => setSpec(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.store_spec_field_help()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="store-spec-echo">
|
||||
{m.store_spec_confirm_field()}
|
||||
</Label>
|
||||
<Input
|
||||
id="store-spec-echo"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={echo}
|
||||
onChange={(e) => setEcho(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Label className="flex items-start gap-3 text-sm font-normal">
|
||||
<Checkbox
|
||||
checked={accepted}
|
||||
onCheckedChange={(next) => setAccepted(next === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>{m.store_spec_checkbox()}</span>
|
||||
</Label>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={close} disabled={isPending}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!ready || isPending}
|
||||
onClick={() => {
|
||||
reset();
|
||||
onConfirm(wanted);
|
||||
}}
|
||||
>
|
||||
{m.store_spec_confirm()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ArrowUpCircle, Ban, Circle, Trash2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { type InstalledPlugin, useInstalledPlugins } from "@/api/store";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { RunnerCardSection } from "./Runner";
|
||||
import { SourceChip, TierBadge } from "./TierBadge";
|
||||
|
||||
/**
|
||||
* Container: what's installed. Owns the installed query; the runner switch above it owns its own.
|
||||
* Updating and uninstalling are escalated to the parent, which owns the confirm dialogs and the
|
||||
* resulting job card.
|
||||
*/
|
||||
export const InstalledTab: FC<{
|
||||
onUpdate: (plugin: InstalledPlugin) => void;
|
||||
onUninstall: (plugin: InstalledPlugin) => void;
|
||||
/** Package whose install/uninstall is in flight, or null — only that row's actions disable. */
|
||||
busyPkg: string | null;
|
||||
}> = ({ onUpdate, onUninstall, busyPkg }) => {
|
||||
const installed = useInstalledPlugins();
|
||||
return (
|
||||
<div className="flex flex-col gap-card">
|
||||
<RunnerCardSection />
|
||||
<InstalledList
|
||||
installed={installed}
|
||||
onUpdate={onUpdate}
|
||||
onUninstall={onUninstall}
|
||||
busyPkg={busyPkg}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* One row per installed plugin. The tier badge is permanent and non-negotiable: a plugin installed
|
||||
* from a raw spec stays marked unverified here for as long as it's on the host, no matter what it
|
||||
* later reports about itself.
|
||||
*/
|
||||
export const InstalledList: FC<{
|
||||
installed: Loadable<InstalledPlugin[]>;
|
||||
onUpdate: (plugin: InstalledPlugin) => void;
|
||||
onUninstall: (plugin: InstalledPlugin) => void;
|
||||
busyPkg: string | null;
|
||||
}> = ({ installed, onUpdate, onUninstall, busyPkg }) => {
|
||||
const rows = installed.data ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<CardHeader>
|
||||
<CardTitle>{m.store_installed_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<QueryState
|
||||
isLoading={installed.isLoading}
|
||||
error={installed.error}
|
||||
refetch={installed.refetch}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="p-card pt-0 text-sm text-muted-foreground">
|
||||
{m.store_installed_empty()}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableBody>
|
||||
{rows.map((p) => (
|
||||
<TableRow key={p.pkg} className="align-top">
|
||||
<TableCell className="py-4">
|
||||
<div className="font-medium">{p.title ?? p.pkg}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{p.pkg}
|
||||
</div>
|
||||
{p.blocked !== undefined && (
|
||||
<p className="mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">
|
||||
<Ban className="mt-px size-3.5 shrink-0" />
|
||||
<span>{m.store_blocked({ reason: p.blocked })}</span>
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<TierBadge tier={p.tier} />
|
||||
{p.tier === "external" && p.source && (
|
||||
<SourceChip source={p.source} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-4 text-sm tabular-nums text-muted-foreground">
|
||||
v{p.version}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Circle
|
||||
className={
|
||||
p.running
|
||||
? "size-2 fill-[var(--success)] text-[var(--success)]"
|
||||
: "size-2 fill-muted-foreground text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
{p.running ? m.store_running() : m.store_stopped()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{p.update_available !== undefined && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busyPkg === p.pkg}
|
||||
onClick={() => onUpdate(p)}
|
||||
>
|
||||
<ArrowUpCircle className="size-4" />
|
||||
{m.store_update_to({
|
||||
version: p.update_available,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.store_uninstall()}
|
||||
disabled={busyPkg === p.pkg}
|
||||
onClick={() => onUninstall(p)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, RotateCw, X, XCircle } from "lucide-react";
|
||||
import { type FC, useEffect, useRef } from "react";
|
||||
import { invalidateStore, type StoreJob, useStoreJob } from "@/api/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
// The host reports a job's progress as a free-form phase string; these are the ones it emits, in
|
||||
// order. Anything unrecognised falls through to the raw phase rather than an empty label, so a new
|
||||
// host phase degrades to English-but-visible instead of disappearing.
|
||||
const PHASES: Record<string, () => string> = {
|
||||
queued: () => m.store_phase_queued(),
|
||||
verifying: () => m.store_phase_verifying(),
|
||||
installing: () => m.store_phase_installing(),
|
||||
removing: () => m.store_phase_removing(),
|
||||
checking: () => m.store_phase_checking(),
|
||||
"rolling back": () => m.store_phase_rolling_back(),
|
||||
recording: () => m.store_phase_recording(),
|
||||
"restarting runner": () => m.store_phase_restarting(),
|
||||
done: () => m.store_phase_done(),
|
||||
};
|
||||
|
||||
const phaseLabel = (phase: string): string => PHASES[phase]?.() ?? phase;
|
||||
|
||||
/** Keep the tail — an install log can run long and only the end is ever interesting. */
|
||||
const LOG_TAIL = 200;
|
||||
|
||||
/**
|
||||
* Container: the in-flight install/uninstall. Polls the job once a second while it runs (the query
|
||||
* stops polling itself once the job settles), and refreshes everything the job touched — catalog,
|
||||
* installed list, runner state, plugin directory — the moment it finishes.
|
||||
*/
|
||||
export const JobProgressSection: FC<{
|
||||
jobId: string;
|
||||
onDismiss: () => void;
|
||||
}> = ({ jobId, onDismiss }) => {
|
||||
const qc = useQueryClient();
|
||||
const job = useStoreJob(jobId);
|
||||
const settled = job.data?.state === "done" || job.data?.state === "failed";
|
||||
// Refresh once per job, not on every re-render while the finished card sits there.
|
||||
const refreshed = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settled || refreshed.current === jobId) return;
|
||||
refreshed.current = jobId;
|
||||
invalidateStore(qc);
|
||||
}, [settled, jobId, qc]);
|
||||
|
||||
if (!job.data) return null;
|
||||
return <JobProgressCard job={job.data} onDismiss={onDismiss} />;
|
||||
};
|
||||
|
||||
/** The progress card: phase (or outcome), a collapsible log tail, and the failure reason if any. */
|
||||
export const JobProgressCard: FC<{
|
||||
job: StoreJob;
|
||||
onDismiss: () => void;
|
||||
}> = ({ job, onDismiss }) => {
|
||||
const running = job.state === "running";
|
||||
const failed = job.state === "failed";
|
||||
const log = job.log.slice(-LOG_TAIL);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={failed ? "ring-2 ring-destructive/60" : undefined}
|
||||
aria-live="polite"
|
||||
>
|
||||
<CardContent className="space-y-3 p-card">
|
||||
<div className="flex items-start gap-3">
|
||||
{running ? (
|
||||
<Spinner className="mt-0.5 size-5 shrink-0" />
|
||||
) : failed ? (
|
||||
<XCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--success)]" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{job.kind === "uninstall"
|
||||
? m.store_job_uninstall({ target: job.target })
|
||||
: m.store_job_install({ target: job.target })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{running
|
||||
? phaseLabel(job.phase)
|
||||
: failed
|
||||
? m.store_job_failed()
|
||||
: job.kind === "uninstall"
|
||||
? m.store_job_done_uninstall()
|
||||
: m.store_job_done_install()}
|
||||
</p>
|
||||
</div>
|
||||
{!running && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.store_job_dismiss()}
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failed && job.error && (
|
||||
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{job.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The runner is bounced at the end of every successful job, so the plugin's nav entry
|
||||
(and its UI) takes a moment longer to appear than the job's "done". Say so. */}
|
||||
{job.state === "done" && (
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<RotateCw className="size-3.5" />
|
||||
{m.store_job_restarting()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{log.length > 0 && (
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer list-none text-xs text-muted-foreground transition-colors hover:text-foreground">
|
||||
{m.store_job_log()}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-left text-xs text-muted-foreground">
|
||||
<code>{log.join("\n")}</code>
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Play, Power, PowerOff } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
type RuntimeStatus,
|
||||
useSetRuntime,
|
||||
useStoreRuntime,
|
||||
} from "@/api/store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
// The plugin/script runner is the service every plugin actually executes inside. Installing a plugin
|
||||
// while it's switched off silently gets you nothing running, so Browse carries a banner and the
|
||||
// Installed tab carries the full switch.
|
||||
|
||||
/** Small helper both surfaces share: fire the toggle, surface a failure as a toast. */
|
||||
function useRunnerToggle() {
|
||||
const set = useSetRuntime();
|
||||
const toggle = (enabled: boolean) => {
|
||||
set.mutate(enabled, {
|
||||
onError: () => toast.error(m.store_runner_failed()),
|
||||
});
|
||||
};
|
||||
return { toggle, isPending: set.isPending };
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse-tab banner: the runner is installed but off, so nothing an operator installs here would
|
||||
* start. Renders nothing in every other state (including "not installed" — the Installed tab's card
|
||||
* explains that case properly).
|
||||
*/
|
||||
export const RunnerBanner: FC = () => {
|
||||
const runtime = useStoreRuntime();
|
||||
const { toggle, isPending } = useRunnerToggle();
|
||||
const s = runtime.data;
|
||||
if (!s?.installed || s.enabled) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-amber-600/40 bg-amber-500/10 p-4 text-sm text-amber-600 sm:flex-row sm:items-center dark:border-amber-500/40 dark:text-amber-500">
|
||||
<PowerOff className="size-5 shrink-0" />
|
||||
<p className="flex-1">{m.store_runner_banner()}</p>
|
||||
<Button size="sm" disabled={isPending} onClick={() => toggle(true)}>
|
||||
<Play className="size-4" />
|
||||
{m.store_runner_enable()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Container: the runner switch at the top of the Installed tab. Like the library's source toggles
|
||||
* this is a secondary control — it stays out of the way until the query resolves rather than
|
||||
* stacking a second error banner on top of the installed list's own.
|
||||
*/
|
||||
export const RunnerCardSection: FC = () => {
|
||||
const runtime = useStoreRuntime();
|
||||
const { toggle, isPending } = useRunnerToggle();
|
||||
if (!runtime.data) return null;
|
||||
return (
|
||||
<RunnerCard status={runtime.data} busy={isPending} onToggle={toggle} />
|
||||
);
|
||||
};
|
||||
|
||||
/** The runner card: what the service is, whether it's up, and the one switch that changes it. */
|
||||
export const RunnerCard: FC<{
|
||||
status: RuntimeStatus;
|
||||
busy: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}> = ({ status, busy, onToggle }) => (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between gap-3 text-base">
|
||||
<span>{m.store_runner_title()}</span>
|
||||
{!status.installed ? (
|
||||
<Badge variant="outline">{m.store_runner_state_missing()}</Badge>
|
||||
) : status.running ? (
|
||||
<Badge variant="success">{m.store_runner_state_running()}</Badge>
|
||||
) : status.enabled ? (
|
||||
<Badge variant="outline">{m.store_runner_state_stopped()}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">{m.store_runner_state_disabled()}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{status.installed
|
||||
? m.store_runner_help()
|
||||
: m.store_runner_not_installed()}
|
||||
</p>
|
||||
<dl className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
|
||||
<div className="flex gap-2">
|
||||
<dt>{m.store_runner_unit()}</dt>
|
||||
<dd className="font-mono text-foreground">{status.unit}</dd>
|
||||
</div>
|
||||
{status.principal && (
|
||||
<div className="flex gap-2">
|
||||
<dt>{m.store_runner_principal()}</dt>
|
||||
<dd className="font-mono text-foreground">{status.principal}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{status.detail && (
|
||||
<p className="text-xs text-muted-foreground">{status.detail}</p>
|
||||
)}
|
||||
{status.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={status.enabled ? "outline" : "default"}
|
||||
disabled={busy}
|
||||
onClick={() => onToggle(!status.enabled)}
|
||||
>
|
||||
{status.enabled ? (
|
||||
<PowerOff className="size-4" />
|
||||
) : (
|
||||
<Power className="size-4" />
|
||||
)}
|
||||
{status.enabled ? m.store_runner_disable() : m.store_runner_enable()}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -0,0 +1,347 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { type FC, type FormEvent, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import {
|
||||
type SourceBody,
|
||||
type StoreSource,
|
||||
useDeleteSource,
|
||||
useRefreshCatalog,
|
||||
useSetSource,
|
||||
useStoreSources,
|
||||
} from "@/api/store";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/** A source the operator has filled in but not yet agreed to trust. */
|
||||
type SourceDraft = SourceBody & { name: string };
|
||||
|
||||
/** Unix seconds → a locale date-time, or "never" for a source that has never fetched. */
|
||||
const fmtFetched = (secs: number): string =>
|
||||
secs > 0 ? new Date(secs * 1000).toLocaleString() : m.store_source_never();
|
||||
|
||||
/**
|
||||
* Container: the catalog sources. Owns the source listing, the refresh-all action, and add/remove.
|
||||
* Adding is a two-step: the form collects the source, and a one-time trust dialog states plainly
|
||||
* what trusting a third-party catalog means before anything is written to the host.
|
||||
*/
|
||||
export const SourcesTab: FC = () => {
|
||||
const sources = useStoreSources();
|
||||
const refresh = useRefreshCatalog();
|
||||
const save = useSetSource();
|
||||
const remove = useDeleteSource();
|
||||
// The draft waiting on the trust dialog, and a key that re-mounts (and so clears) the form.
|
||||
const [draft, setDraft] = useState<SourceDraft | null>(null);
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
|
||||
const onRefresh = () =>
|
||||
refresh.mutate(undefined, {
|
||||
onError: () => toast.error(m.store_refresh_failed()),
|
||||
});
|
||||
|
||||
const onConfirmAdd = async () => {
|
||||
if (!draft) return;
|
||||
try {
|
||||
await save.mutateAsync(draft);
|
||||
setDraft(null);
|
||||
setFormKey((k) => k + 1);
|
||||
} catch {
|
||||
toast.error(m.store_add_source_failed());
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async (source: StoreSource) => {
|
||||
if (!confirm(m.store_source_remove_confirm({ name: source.name }))) return;
|
||||
try {
|
||||
await remove.mutateAsync(source.name);
|
||||
} catch (e) {
|
||||
// 403 is the host refusing to drop its built-in catalog — say exactly that.
|
||||
toast.error(
|
||||
e instanceof ApiError && e.status === 403
|
||||
? m.store_source_builtin_locked()
|
||||
: m.store_source_remove_failed(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-card">
|
||||
<SourceList
|
||||
sources={sources}
|
||||
busyName={remove.isPending ? (remove.variables ?? null) : null}
|
||||
isRefreshing={refresh.isPending}
|
||||
onRefresh={onRefresh}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
|
||||
<AddSourceForm
|
||||
key={formKey}
|
||||
onSubmit={setDraft}
|
||||
isSaving={save.isPending}
|
||||
/>
|
||||
|
||||
<TrustSourceDialog
|
||||
draft={draft}
|
||||
isSaving={save.isPending}
|
||||
onCancel={() => setDraft(null)}
|
||||
onConfirm={onConfirmAdd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The source table: health per source, with the built-in one locked. */
|
||||
export const SourceList: FC<{
|
||||
sources: {
|
||||
data?: StoreSource[];
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
refetch?: () => void;
|
||||
};
|
||||
/** Name of the source whose delete is in flight, or null. */
|
||||
busyName: string | null;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
onRemove: (source: StoreSource) => void;
|
||||
}> = ({ sources, busyName, isRefreshing, onRefresh, onRemove }) => {
|
||||
const rows = sources.data ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>{m.store_sources_title()}</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isRefreshing}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
className={isRefreshing ? "size-4 animate-spin" : "size-4"}
|
||||
/>
|
||||
{m.store_refresh_all()}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.store_sources_help()}
|
||||
</p>
|
||||
|
||||
<QueryState
|
||||
isLoading={sources.isLoading}
|
||||
error={sources.error}
|
||||
refetch={sources.refetch}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map((s) => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-start"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
{s.builtin && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Lock className="size-3" />
|
||||
{m.store_source_builtin()}
|
||||
</Badge>
|
||||
)}
|
||||
{s.signed ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<ShieldCheck className="size-3" />
|
||||
{m.store_source_signed()}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500"
|
||||
>
|
||||
<ShieldOff className="size-3" />
|
||||
{m.store_source_unsigned()}
|
||||
</Badge>
|
||||
)}
|
||||
{s.stale && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500"
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
{m.store_source_stale()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{s.url}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.store_source_entries({ count: s.entry_count })} ·{" "}
|
||||
{m.store_source_fetched({ when: fmtFetched(s.fetched_at) })}
|
||||
</p>
|
||||
{s.error && (
|
||||
<p className="text-xs text-destructive">{s.error}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* The built-in catalog gets no delete button at all — not a disabled one. */}
|
||||
{!s.builtin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.store_source_remove()}
|
||||
disabled={busyName === s.name}
|
||||
onClick={() => onRemove(s)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/** The add-source form. Reports a draft; the parent takes it through the trust dialog. */
|
||||
export const AddSourceForm: FC<{
|
||||
onSubmit: (draft: SourceDraft) => void;
|
||||
isSaving: boolean;
|
||||
}> = ({ onSubmit, isSaving }) => {
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [publicKey, setPublicKey] = useState("");
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const key = publicKey.trim();
|
||||
if (!name.trim() || !url.trim()) return;
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
public_key: key ? key : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{m.store_add_source_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="store-source-name">
|
||||
{m.store_field_source_name()}
|
||||
</Label>
|
||||
<Input
|
||||
id="store-source-name"
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="store-source-url">
|
||||
{m.store_field_source_url()}
|
||||
</Label>
|
||||
<Input
|
||||
id="store-source-url"
|
||||
required
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="store-source-key">
|
||||
{m.store_field_source_key()}
|
||||
</Label>
|
||||
<Input
|
||||
id="store-source-key"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="ed25519:…"
|
||||
value={publicKey}
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.store_field_source_key_help()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || !name.trim() || !url.trim()}
|
||||
>
|
||||
{m.store_add_source()}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/** The one-time trust warning shown before a third-party catalog is written to the host. */
|
||||
export const TrustSourceDialog: FC<{
|
||||
draft: SourceDraft | null;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}> = ({ draft, isSaving, onCancel, onConfirm }) => (
|
||||
<Dialog open={draft !== null} onOpenChange={(open) => !open && onCancel()}>
|
||||
{draft && (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
|
||||
{m.store_source_trust_title()}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{m.store_source_trust_body({ name: draft.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs break-all text-muted-foreground">
|
||||
{draft.url}
|
||||
</p>
|
||||
|
||||
{!draft.public_key && (
|
||||
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
|
||||
{m.store_source_trust_unsigned()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button disabled={isSaving} onClick={onConfirm}>
|
||||
{m.store_source_trust_confirm()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
ShieldAlert,
|
||||
ShieldQuestion,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { StoreTier } from "@/api/store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
// The trust model, rendered. These badges are the only place the console makes a claim about where
|
||||
// a plugin's code came from, so they are deliberately literal: ONLY the built-in `unom` source earns
|
||||
// the check mark, everything else says out loud that nobody at unom looked at the code. An entry
|
||||
// from an operator-added source additionally carries a <SourceChip> naming that source — attribution
|
||||
// instead of a verification it hasn't got.
|
||||
|
||||
/** The short, permanent provenance badge for a tier. */
|
||||
export const TierBadge: FC<{ tier: StoreTier; className?: string }> = ({
|
||||
tier,
|
||||
className,
|
||||
}) => {
|
||||
switch (tier) {
|
||||
case "verified":
|
||||
return (
|
||||
<Badge
|
||||
variant="success"
|
||||
className={cn("gap-1", className)}
|
||||
title={m.store_tier_verified_hint()}
|
||||
>
|
||||
<BadgeCheck className="size-3.5" />
|
||||
{m.store_tier_verified()}
|
||||
</Badge>
|
||||
);
|
||||
case "external":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500",
|
||||
className,
|
||||
)}
|
||||
title={m.store_tier_external_hint()}
|
||||
>
|
||||
<ShieldQuestion className="size-3.5" />
|
||||
{m.store_tier_external()}
|
||||
</Badge>
|
||||
);
|
||||
case "unverified":
|
||||
return (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className={cn("gap-1", className)}
|
||||
title={m.store_tier_unverified_hint()}
|
||||
>
|
||||
<ShieldAlert className="size-3.5" />
|
||||
{m.store_tier_unverified()}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("gap-1", className)}
|
||||
title={m.store_tier_cli_hint()}
|
||||
>
|
||||
<Terminal className="size-3.5" />
|
||||
{m.store_tier_cli()}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** "from <source>" — who curated this entry. Shown for anything not from the built-in source. */
|
||||
export const SourceChip: FC<{ source: string; className?: string }> = ({
|
||||
source,
|
||||
className,
|
||||
}) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{m.store_from_source()}
|
||||
<span className="font-medium text-foreground">{source}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -0,0 +1,149 @@
|
||||
import Section from "@unom/ui/section";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { type FC, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import {
|
||||
type InstallBody,
|
||||
type InstalledPlugin,
|
||||
type StoreEntry,
|
||||
useInstallPlugin,
|
||||
useStoreCatalog,
|
||||
useUninstallPlugin,
|
||||
} from "@/api/store";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { BrowseTab } from "./Browse";
|
||||
import { InstallDialog, SpecInstallDialog } from "./InstallDialogs";
|
||||
import { InstalledTab } from "./Installed";
|
||||
import { JobProgressSection } from "./JobProgress";
|
||||
import { SourcesTab } from "./Sources";
|
||||
|
||||
type StoreTab = "browse" | "installed" | "sources";
|
||||
|
||||
/**
|
||||
* The plugin store: browse a catalog, manage what's installed, and choose which catalogs this host
|
||||
* trusts. Each tab owns its own queries; this container owns only what genuinely spans them — the
|
||||
* install/uninstall mutations, the confirm dialogs their trust tier dictates, and the job the host
|
||||
* hands back (which must stay visible whichever tab you switch to while it runs).
|
||||
*/
|
||||
export const SectionStore: FC = () => {
|
||||
useLocale();
|
||||
const [tab, setTab] = useState<StoreTab>("browse");
|
||||
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
|
||||
const [target, setTarget] = useState<StoreEntry | null>(null);
|
||||
const [specOpen, setSpecOpen] = useState(false);
|
||||
// The job the host is running for us, if any. Cleared by the operator, not by completion — a
|
||||
// finished job's log is the only record of what happened.
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
|
||||
const catalog = useStoreCatalog();
|
||||
const install = useInstallPlugin();
|
||||
const uninstall = useUninstallPlugin();
|
||||
|
||||
/** Turn a failed 202-request into a message: 409 means the host is busy, not that we're broken. */
|
||||
const failed = (e: unknown, fallback: string) =>
|
||||
toast.error(
|
||||
e instanceof ApiError && e.status === 409 ? m.store_busy() : fallback,
|
||||
);
|
||||
|
||||
const start = async (body: InstallBody) => {
|
||||
try {
|
||||
const { job } = await install.mutateAsync(body);
|
||||
setJobId(job);
|
||||
} catch (e) {
|
||||
failed(e, m.store_install_failed());
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmEntry = async (entry: StoreEntry) => {
|
||||
setTarget(null);
|
||||
await start({ source: entry.source, id: entry.id });
|
||||
};
|
||||
|
||||
const onConfirmSpec = async (spec: string) => {
|
||||
setSpecOpen(false);
|
||||
await start({ spec, accept_unverified: true });
|
||||
};
|
||||
|
||||
// An update from the Installed tab installs the CATALOG version — so it goes through the very
|
||||
// same tier-appropriate dialog a fresh install would, warning included.
|
||||
const onUpdate = (plugin: InstalledPlugin) => {
|
||||
const entry = catalog.data?.plugins.find((e) => e.pkg === plugin.pkg);
|
||||
if (!entry) {
|
||||
toast.error(m.store_update_no_entry());
|
||||
return;
|
||||
}
|
||||
setTarget(entry);
|
||||
};
|
||||
|
||||
const onUninstall = async (plugin: InstalledPlugin) => {
|
||||
if (
|
||||
!confirm(m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const { job } = await uninstall.mutateAsync(plugin.pkg);
|
||||
setJobId(job);
|
||||
} catch (e) {
|
||||
failed(e, m.store_uninstall_failed());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">{m.store_title()}</h1>
|
||||
<p className="text-sm text-muted-foreground">{m.store_subtitle()}</p>
|
||||
</div>
|
||||
|
||||
{jobId && (
|
||||
<JobProgressSection jobId={jobId} onDismiss={() => setJobId(null)} />
|
||||
)}
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as StoreTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="browse">{m.store_tab_browse()}</TabsTrigger>
|
||||
<TabsTrigger value="installed">
|
||||
{m.store_tab_installed()}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sources">{m.store_tab_sources()}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="browse">
|
||||
<BrowseTab
|
||||
onInstall={setTarget}
|
||||
onInstallSpec={() => setSpecOpen(true)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="installed">
|
||||
<InstalledTab
|
||||
onUpdate={onUpdate}
|
||||
onUninstall={onUninstall}
|
||||
busyPkg={
|
||||
uninstall.isPending ? (uninstall.variables ?? null) : null
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="sources">
|
||||
<SourcesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<InstallDialog
|
||||
entry={target}
|
||||
isPending={install.isPending}
|
||||
onCancel={() => setTarget(null)}
|
||||
onConfirm={onConfirmEntry}
|
||||
/>
|
||||
<SpecInstallDialog
|
||||
open={specOpen}
|
||||
isPending={install.isPending}
|
||||
onCancel={() => setSpecOpen(false)}
|
||||
onConfirm={onConfirmSpec}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user