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
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { ToastHost } from "./components.js";
import { Emulators } from "./pages/Emulators.js";
import { Games } from "./pages/Games.js";
import { Setup } from "./pages/Setup.js";
import { Sync } from "./pages/Sync.js";
const TABS = [
{ id: "setup", label: "Setup", Page: Setup },
{ id: "emulators", label: "Emulators", Page: Emulators },
{ id: "games", label: "Games", Page: Games },
{ id: "sync", label: "Sync", Page: Sync },
] as const;
type TabId = (typeof TABS)[number]["id"];
const tabFromHash = (): TabId => {
const h = window.location.hash.replace(/^#\/?/, "");
return (TABS.find((t) => t.id === h)?.id ?? "setup") as TabId;
};
export const App = () => {
const [tab, setTab] = useState<TabId>(tabFromHash);
useEffect(() => {
const onHash = () => setTab(tabFromHash());
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
const go = (id: TabId) => {
window.location.hash = `/${id}`;
setTab(id);
// Best-effort deep-link sync with the console shell (plugin-ui-surface §5; ignored elsewhere).
try {
window.parent?.postMessage({ type: "pf-ui:navigate", path: id }, "*");
} catch {
// not embedded
}
};
const Page = TABS.find((t) => t.id === tab)?.Page ?? Setup;
return (
<ToastHost>
<div className="app">
<div className="topbar">
<span className="logo">🎮</span>
<h1>ROM Manager</h1>
<span className="subtle">
scan ROMs into your punktfunk library
</span>
</div>
<div className="tabs">
{TABS.map((t) => (
<button
type="button"
key={t.id}
className={`tab${t.id === tab ? " active" : ""}`}
onClick={() => go(t.id)}
>
{t.label}
</button>
))}
</div>
<Page />
</div>
</ToastHost>
);
};