feat: initial ROM & emulator manager plugin (M0–M5)
CI / build (push) Has been cancelled
CI / publish (push) Has been cancelled

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:
2026-07-18 02:40:59 +02:00
commit 3a6c80558d
59 changed files with 6051 additions and 0 deletions
+162
View File
@@ -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;
};