Files
punktfunk-plugin-rom-manager/ui/src/api.ts
T
enricobuehler 33e91e4d00
CI / publish (push) Has been cancelled
CI / build (push) Has been cancelled
feat: scope + un-bundle the plugin; @unom/ui SPA; custom-emulator editor
Reliability + correctness pass on the packaging and UI.

Packaging — drop the self-contained bundle:
- Rename to `@punktfunk/plugin-rom-manager` (scoped). An unscoped package can't
  be split across registries, which is the only reason bundling effect + the SDK
  into every plugin seemed necessary. Scoped names resolve from one scope-map, so
  the plugin depends on `@punktfunk/host` + `effect` as SHARED (hoisted) deps —
  no duplication, no ~1 MB effect copy per plugin. Build is back to plain `tsc`.
  (Runner-side discovery of `@punktfunk/plugin-*` shipped in @punktfunk/host 0.1.1.)

UI — use the console's design system so it reads as family:
- SPA rebuilt on @unom/style tokens + Tailwind v4 + the console's exact theme
  (violet chrome, Geist, card/button vocabulary). Build-time only → static assets
  in dist/ui, no @unom runtime dep. Skips @unom/ui's AnimatedButton/Card, which
  statically import ~7 MB of UI sound assets — same look, 416 KB dist/ui.
- Add a custom-emulator editor (Emulators page) writing config.emulators — you can
  now add a launcher Punktfunk doesn't ship a definition for.
- Fix brand capitalization to "Punktfunk" in all UI copy + docs.

Install docs + CI updated for the scoped `bun add @punktfunk/plugin-rom-manager`
flow. 48 engine tests green; backend + UI typecheck + biome clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 12:16:04 +02:00

166 lines
4.2 KiB
TypeScript

// Typed client for the plugin-local API (relative paths — the SPA is mounted under the console proxy
// prefix, so `api/...` resolves to `/plugin-ui/rom-manager/api/...`). Types mirror the backend.
import { useEffect, useState } from "react";
export interface Platform {
id: string;
name: string;
extensions: string[];
libretroSystem?: string;
defaultLaunch: { emulator: string; core?: string; extraArgs?: string };
disc?: boolean;
}
export interface EmulatorDef {
id: string;
name: string;
template: string;
supportsArchives: boolean;
contested?: boolean;
detect?: { linux: string[]; windows: string[] };
}
export interface Detected {
id: string;
name: string;
via: "path" | "file" | "flatpak";
exeToken: string;
contested?: boolean;
coresDir?: string;
cores?: string[];
}
export interface RomRoot {
dir: string;
platform: string;
excludes?: string[];
}
export interface GameOverride {
exclude?: boolean;
emulator?: string;
core?: string;
extraArgs?: string;
title?: string;
art?: string;
}
export interface Config {
roots: RomRoot[];
platformLaunch: Record<
string,
{ emulator: string; core?: string; extraArgs?: string }
>;
gameOverrides: Record<string, GameOverride>;
/** Operator-added / overriding emulator defs (custom launchers). */
emulators?: EmulatorDef[];
sync: {
pollMinutes: number;
watch: boolean;
debounceMs: number;
dedupeRegions: string[];
regionPriority: string[];
warnEntries: number;
maxEntries: number;
closeOnEnd: boolean;
};
art: {
enabled: boolean;
provider: "auto" | "steamgriddb" | "libretro";
steamGridDbKey?: string;
};
ui: {
standalone: boolean;
port: number;
bind: string;
passwordHash?: string;
};
devEntry: boolean;
}
export interface Artwork {
portrait?: string | null;
hero?: string | null;
logo?: string | null;
header?: string | null;
}
export interface Entry {
external_id: string;
title: string;
launch?: { kind: string; value: string } | null;
art?: Artwork;
prep?: { do: string; undo?: string | null }[];
}
export interface Skipped {
external_id: string;
title: string;
reason: string;
}
export interface Report {
considered: number;
included: number;
skipped: Skipped[];
excluded: { external_id: string; title: string }[];
warnings: string[];
truncated: number;
perPlatform: Record<string, number>;
overWarn: boolean;
}
export interface Preview {
entries: Entry[];
report: Report;
detected: Detected[];
}
export interface Status {
rootsConfigured: number;
os: "linux" | "windows";
artProvider: string | null;
lastSync?: { fingerprint: string; count: number; at: number };
lastReport?: Report;
detected?: { at: number; emulators: Detected[] };
paths: { dir: string; config: string; cache: string; relConfig: string };
syncing: boolean;
}
const api = async <T>(path: string, opts?: RequestInit): Promise<T> => {
const res = await fetch(path, {
headers: { "content-type": "application/json" },
...opts,
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return (await res.json()) as T;
};
export const getStatus = () => api<Status>("api/status");
export const getConfig = () => api<Config>("api/config");
export const putConfig = (config: Config) =>
api<Config>("api/config", { method: "PUT", body: JSON.stringify(config) });
export const getPreview = () => api<Preview>("api/preview");
export const runDetect = () =>
api<Detected[]>("api/detect", { method: "POST" });
export const runSync = () => api<Report>("api/sync", { method: "POST" });
export const getPlatforms = () => api<Platform[]>("api/platforms");
export const getEmulators = () =>
api<{ defs: EmulatorDef[]; detected: Detected[] }>("api/emulators");
/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */
export const useStatusStream = (): Status | undefined => {
const [status, setStatus] = useState<Status>();
useEffect(() => {
getStatus()
.then(setStatus)
.catch(() => {});
try {
const es = new EventSource("api/events");
es.addEventListener("status", (e) => {
try {
setStatus(JSON.parse((e as MessageEvent).data));
} catch {
// ignore malformed frame
}
});
return () => es.close();
} catch {
return;
}
}, []);
return status;
};