M4 of design/library-scanner-plugins-implementation-plan.md, plus WP6.2. WP4.1 — SourceToggles and ProvidersCard merge into Library/Sources.tsx. They were two cards because they were two different things: scanners were compiled into the host, plugins were an afterthought. After the extraction they are the same thing — the host reports ONE list of sources whose ids match whether they came from a built-in scanner or the plugin replacing it — so one surface is both simpler and the only honest presentation. Each row carries its toggle, a running/stopped badge for plugin sources, an entry count, filter, settings and an uninstall that offers to remove the games too. An "Add a source" rail lists uncatalogued library plugins with a "Detected" badge; `detected` is deliberately tri-state, so only a POSITIVE probe badges — an entry with no probes for this platform is unknown, and calling that "not installed" would be a lie. The settings drawer (SourceSettings.tsx) renders a generic form from the plugin's own JSON Schema over GET/PUT /__config, through the existing session-gated /plugin-ui/<id>/ proxy — zero new host surface, and the browser never learns the plugin's port or secret. It flattens allOf branches (effect nests a checked schema's annotations there, so a form reading only the top level silently loses every title and default) and falls back to a JSON editor when any field is a shape it cannot express — partial rendering would be worse than none, because a field missing from the form is a setting the operator cannot change. WP4.2 — uiPlugins() now excludes category "library", which covers both the sidebar and the mobile overflow since they share the selector. The /plugins/$pluginId/$ route still resolves, so existing deep links keep working; library plugins are just not advertised. WP4.3 — LibraryGrid groups role:"launcher" entries into a rail above the grid, and the empty state points at the sources surface rather than leaving a bare grid (after extraction, "no games" is the expected first-run state). WP6.2 — a migration banner offering one install per still-built-in scanner whose plugin is catalogued. One button per scanner, never a single "migrate everything" and never a silent auto-install: installing code stays an explicit operator act, and per-scanner is what makes it safe to repeat (the claim suppresses the built-in idempotently, so a half-finished migration is a valid state). WP4.4 — i18n en+de (kept under the existing "Game sources" label rather than minting a third "Plugins"), Storybook stories for the sources card in three states, the launcher rail and the banner. Gates: orval regen, tsc clean, vite build clean, check-i18n green at 595 messages for both locales. Still owed: the browser click-through (the store's Tabs-theme bug shipped through green types and lint), and an AppShell nav story — that one needs the plugins query mocked, which does not exist in this Storybook setup yet.
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
// 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";
|
|
import { boostPluginPolling } from "@/api/plugins";
|
|
|
|
/**
|
|
* 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;
|
|
/**
|
|
* What kind of plugin this is. Browse filters on these, and the Library section's "Add a source"
|
|
* rail shows exactly the `library` ones (design D5/D6). Absent on an index that predates them.
|
|
*/
|
|
categories?: string[];
|
|
/**
|
|
* Whether the launcher this plugin scans looks installed on this host, from the index's own
|
|
* existence probes (design D8). `undefined` = the entry declares no probes for this platform,
|
|
* which is "unknown" and must render differently from "not installed".
|
|
*/
|
|
detected?: boolean;
|
|
}
|
|
|
|
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;
|
|
/** Nullable in the contract (`InstalledView.version`) — a CLI-installed plugin may carry no
|
|
* recorded version. Typed required here, the Installed tab rendered the literal "vundefined". */
|
|
version?: string | null;
|
|
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.
|
|
*
|
|
* The raw-spec branch carries the console `password`: it runs unreviewed code, so the BFF
|
|
* re-confirms it (server/routes/api/v1/store/install.post.ts) and strips it before the host ever
|
|
* sees the request. A catalog install needs no password — that trust decision was made when the
|
|
* source was added.
|
|
*/
|
|
export type InstallBody =
|
|
| { source: string; id: string }
|
|
| { spec: string; accept_unverified: true; password: string };
|
|
|
|
/** Adding or repointing a source is a trust-root change, so it carries the console password too
|
|
* (stripped at the BFF — server/routes/api/v1/store/sources/[name].put.ts). */
|
|
export interface SourceBody {
|
|
url: string;
|
|
public_key?: string;
|
|
password: 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> {
|
|
// The runner restarts AFTER the job reports done, so the plugin registers its UI a few seconds
|
|
// from now — this invalidation would otherwise refetch the pre-install list and stop looking.
|
|
boostPluginPolling();
|
|
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).
|
|
*
|
|
* The interval keys off "not finished yet" rather than off `state === "running"`. `data` is
|
|
* undefined in two live cases — the first poll has not landed, and the first poll FAILED — and
|
|
* treating those as "stop polling" wedged the card: an install whose very first poll lost the race
|
|
* with a busy host never polled again and the operator saw nothing at all, while an install that
|
|
* restarts the runner (every successful one does) could drop a poll mid-flight.
|
|
*
|
|
* The failure count bounds it: jobs live in host memory, so a host restart makes the id 404 for
|
|
* good, and something has to stop asking.
|
|
*/
|
|
const JOB_POLL_MS = 1_000;
|
|
const JOB_MAX_FAILURES = 15;
|
|
|
|
/**
|
|
* The host's recent jobs, used to RE-ATTACH after a reload.
|
|
*
|
|
* The in-flight job id lived only in component state, so reloading the page (or opening the console
|
|
* on another device) lost all trace of a running install while the Install buttons stayed armed —
|
|
* and the host takes one job at a time, so the next click just bounced off a 409. The host keeps
|
|
* the list; ask it rather than remembering.
|
|
*/
|
|
export function useStoreJobs() {
|
|
return useQuery({
|
|
queryKey: [...storeKeys.all, "jobs"] as const,
|
|
queryFn: () => apiFetch<StoreJob[]>(`${BASE}/jobs`),
|
|
// Only needed to find an orphaned job on mount; the job query itself does the live polling.
|
|
staleTime: 5_000,
|
|
});
|
|
}
|
|
|
|
/** The newest job that is still running, if any — what a fresh page should re-attach to. */
|
|
export function runningJob(jobs: StoreJob[] | undefined): StoreJob | undefined {
|
|
if (!jobs) return undefined;
|
|
// The list is oldest-first, so scan from the end for the most recent live one.
|
|
for (let i = jobs.length - 1; i >= 0; i--) {
|
|
const j = jobs[i];
|
|
if (j?.state === "running") return j;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function useStoreJob(id: string | null) {
|
|
return useQuery({
|
|
queryKey: storeKeys.job(id ?? ""),
|
|
queryFn: () =>
|
|
apiFetch<StoreJob>(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`),
|
|
enabled: id !== null,
|
|
refetchInterval: (q) => {
|
|
const state = q.state.data?.state;
|
|
if (state === "done" || state === "failed") return false;
|
|
if (q.state.fetchFailureCount > JOB_MAX_FAILURES) return false;
|
|
return JOB_POLL_MS;
|
|
},
|
|
// A job that vanished with its host is gone for good; a transient blip is not. Retry a few
|
|
// times per poll so a runner restart doesn't surface as an error card.
|
|
retry: 3,
|
|
});
|
|
}
|
|
|
|
/** 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"] });
|
|
},
|
|
});
|
|
}
|