33e91e4d00
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>
166 lines
4.2 KiB
TypeScript
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;
|
|
};
|