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
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useState } from "react";
import {
type Detected,
type EmulatorDef,
getEmulators,
getPlatforms,
type Platform,
runDetect,
} from "../api.js";
import { Badge, Button, Card, useToast } from "../components.js";
import { useConfig } from "../hooks.js";
export const Emulators = () => {
const { config, setConfig, save, saving } = useConfig();
const [defs, setDefs] = useState<EmulatorDef[]>([]);
const [detected, setDetected] = useState<Detected[]>([]);
const [platforms, setPlatforms] = useState<Platform[]>([]);
const [detecting, setDetecting] = useState(false);
const toast = useToast();
const load = () => {
getEmulators().then((e) => {
setDefs(e.defs);
setDetected(e.detected);
});
};
useEffect(load, []);
useEffect(() => {
getPlatforms()
.then(setPlatforms)
.catch(() => {});
}, []);
const detectedById = new Map(detected.map((d) => [d.id, d]));
return (
<>
<Card
title="Detected emulators"
hint="Best-effort detection: PATH, Flatpak, then known install paths. Missing ones can still be launched via a custom template."
right={
<Button
disabled={detecting}
onClick={async () => {
setDetecting(true);
try {
setDetected(await runDetect());
toast("Re-detected emulators");
} finally {
setDetecting(false);
}
}}
>
{detecting ? "Detecting…" : "Re-detect"}
</Button>
}
>
<table>
<thead>
<tr>
<th>Emulator</th>
<th>Status</th>
<th>Cores</th>
<th />
</tr>
</thead>
<tbody>
{defs.map((def) => {
const d = detectedById.get(def.id);
return (
<tr key={def.id}>
<td>
{def.name} <span className="mono">{def.id}</span>
</td>
<td>
{d ? (
<Badge tone="ok">detected · {d.via}</Badge>
) : (
<Badge>not found</Badge>
)}
</td>
<td>{d?.cores?.length ? d.cores.length : "—"}</td>
<td>
{def.contested && <Badge tone="warn">contested</Badge>}
</td>
</tr>
);
})}
</tbody>
</table>
</Card>
{config && (
<Card
title="Per-platform emulator"
hint="The default emulator + core for each platform. Override here to prefer a different emulator or RetroArch core."
right={
<Button
primary
disabled={saving}
onClick={async () => {
if (await save(config)) toast("Saved — syncing library");
}}
>
{saving ? "Saving…" : "Save & sync"}
</Button>
}
>
<div className="scroll">
<table>
<thead>
<tr>
<th>Platform</th>
<th>Emulator</th>
<th>Core (RetroArch)</th>
</tr>
</thead>
<tbody>
{platforms.map((p) => {
const override = config.platformLaunch[p.id];
const emulator =
override?.emulator ?? p.defaultLaunch.emulator;
const core = override?.core ?? p.defaultLaunch.core ?? "";
const detCores = detectedById.get(emulator)?.cores ?? [];
const setLaunch = (patch: {
emulator?: string;
core?: string;
}) => {
const next = {
emulator: patch.emulator ?? emulator,
core: patch.core ?? core,
};
setConfig({
...config,
platformLaunch: {
...config.platformLaunch,
[p.id]: next,
},
});
};
return (
<tr key={p.id}>
<td>{p.name}</td>
<td>
<select
value={emulator}
onChange={(e) =>
setLaunch({ emulator: e.target.value })
}
>
{defs.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
{detectedById.has(d.id) ? " ✓" : ""}
</option>
))}
</select>
</td>
<td>
{emulator === "retroarch" ? (
detCores.length ? (
<select
value={core}
onChange={(e) =>
setLaunch({ core: e.target.value })
}
>
{!detCores.includes(core) && (
<option value={core}>{core || "—"}</option>
)}
{detCores.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
) : (
<input
value={core}
placeholder="snes9x"
onChange={(e) =>
setLaunch({ core: e.target.value })
}
/>
)
) : (
<span className="subtle"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
</>
);
};