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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user