Files
punktfunk/web/src/api/store.ts
T
enricobuehlerandClaude Fable 5 833f3348a0
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
feat(store): console plugin store, index repo, and the fixes on-glass found
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>
2026-07-20 20:48:02 +02:00

283 lines
8.2 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";
/**
* 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"] });
},
});
}