feat: initial ROM & emulator manager plugin (M0–M5)
A punktfunk-plugin-* package that scans ROM directories, maps them to emulators, fetches box art, and reconciles them into the host game library under provider id `rom-manager` — with a console-hosted web UI. Engine (pure, unit-tested core): - Table-driven platform registry (~25 consoles) + emulator registry with best-effort per-OS detection (PATH / Flatpak / known paths) and RetroArch core discovery. - Scanner with disc folding (m3u/cue/gdi), archive gating, excludes. - No-Intro title parsing + optional per-platform region dedupe. - Security-critical quoting seam: POSIX single-quote + Windows double-quote with hostile-name refusal; ROM filenames never reach a shell un-quoted. - Pure desired-state reconcile (stable external_ids, scale guard, fingerprint skip) → full-replace PUT /library/provider/rom-manager. Box art (like Steam ROM Manager): SteamGridDB primary (portrait/hero/logo/ header, fuzzy match, operator API key) behind a provider seam, with keyless libretro-thumbnails as the zero-setup fallback (`auto` default). UI: console-hosted via the SDK `servePluginUi` (zero plugin-side auth) with a plugin-local REST/SSE API and a self-contained React SPA (Setup / Emulators / Games / Sync). Standalone password-gated fallback for host-only installs. CLI: scan / detect / preview / sync / uninstall / set-password. 48 engine tests, typecheck + biome clean, SPA builds. Verified end-to-end: scan → detect → reconcile PUT, fingerprint idempotence, and the standalone UI serving SPA + REST. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
// 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;
|
||||
}
|
||||
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>;
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user