Files
punktfunk/web/src/api/store.ts
T
enricobuehler bd383f1820 feat(web): one Game sources surface, launcher rail, and the migration nudge
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.
2026-08-05 10:03:24 +02:00

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"] });
},
});
}