3a6c80558d
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>
157 lines
4.3 KiB
TypeScript
157 lines
4.3 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { getConfig, getPreview, type Preview, putConfig } from "../api.js";
|
|
import { Badge, Button, Card, useToast } from "../components.js";
|
|
import { platformOf } from "../hooks.js";
|
|
|
|
export const Games = () => {
|
|
const [preview, setPreview] = useState<Preview>();
|
|
const [loading, setLoading] = useState(false);
|
|
const [showSkipped, setShowSkipped] = useState(false);
|
|
const toast = useToast();
|
|
|
|
const refresh = useCallback(() => {
|
|
setLoading(true);
|
|
getPreview()
|
|
.then(setPreview)
|
|
.catch((e) => toast(String(e)))
|
|
.finally(() => setLoading(false));
|
|
}, [toast]);
|
|
useEffect(refresh, [refresh]);
|
|
|
|
// Toggle a title's exclude override, persist, and refresh the preview.
|
|
const setExcluded = async (externalId: string, exclude: boolean) => {
|
|
const config = await getConfig();
|
|
const overrides = { ...config.gameOverrides };
|
|
const current = overrides[externalId] ?? {};
|
|
if (exclude) overrides[externalId] = { ...current, exclude: true };
|
|
else {
|
|
const { exclude: _drop, ...rest } = current;
|
|
if (Object.keys(rest).length) overrides[externalId] = rest;
|
|
else delete overrides[externalId];
|
|
}
|
|
await putConfig({ ...config, gameOverrides: overrides });
|
|
toast(exclude ? "Excluded" : "Included");
|
|
refresh();
|
|
};
|
|
|
|
if (!preview) return <Card>{loading ? "Scanning…" : "Loading…"}</Card>;
|
|
|
|
const { entries, report } = preview;
|
|
const excluded = report.excluded ?? [];
|
|
|
|
return (
|
|
<>
|
|
<Card
|
|
title={`Games (${entries.length})`}
|
|
hint="What will be reconciled into the library. Untick a title to exclude it; covers come from your art provider."
|
|
right={
|
|
<Button disabled={loading} onClick={refresh}>
|
|
{loading ? "Scanning…" : "Rescan"}
|
|
</Button>
|
|
}
|
|
>
|
|
{entries.length === 0 && excluded.length === 0 ? (
|
|
<div className="empty-state">
|
|
No games found. Add ROM roots in <strong>Setup</strong>, then
|
|
rescan.
|
|
</div>
|
|
) : (
|
|
<div className="scroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 52 }} />
|
|
<th>Title</th>
|
|
<th style={{ width: 90 }}>Platform</th>
|
|
<th>Launch command</th>
|
|
<th style={{ width: 60 }}>Include</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map((e) => (
|
|
<tr key={e.external_id}>
|
|
<td>
|
|
{e.art?.portrait ? (
|
|
<img
|
|
className="cover"
|
|
src={e.art.portrait}
|
|
alt={`${e.title} cover`}
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<span className="cover empty" />
|
|
)}
|
|
</td>
|
|
<td>{e.title}</td>
|
|
<td>
|
|
<Badge>{platformOf(e.external_id)}</Badge>
|
|
</td>
|
|
<td className="mono" title={e.launch?.value}>
|
|
{truncate(e.launch?.value ?? "", 70)}
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="checkbox"
|
|
checked
|
|
onChange={() => setExcluded(e.external_id, true)}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{excluded.map((x) => (
|
|
<tr key={x.external_id} style={{ opacity: 0.55 }}>
|
|
<td>
|
|
<span className="cover empty" />
|
|
</td>
|
|
<td>{x.title}</td>
|
|
<td>
|
|
<Badge>{platformOf(x.external_id)}</Badge>
|
|
</td>
|
|
<td className="subtle">excluded</td>
|
|
<td>
|
|
<input
|
|
type="checkbox"
|
|
checked={false}
|
|
onChange={() => setExcluded(x.external_id, false)}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{report.skipped.length > 0 && (
|
|
<Card
|
|
title={`Skipped (${report.skipped.length})`}
|
|
right={
|
|
<Button onClick={() => setShowSkipped((s) => !s)}>
|
|
{showSkipped ? "Hide" : "Show"}
|
|
</Button>
|
|
}
|
|
>
|
|
{showSkipped && (
|
|
<div className="scroll">
|
|
<table>
|
|
<tbody>
|
|
{report.skipped.map((s) => (
|
|
<tr key={s.external_id}>
|
|
<td>{s.title}</td>
|
|
<td className="subtle">{s.reason}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const truncate = (s: string, n: number): string =>
|
|
s.length > n ? `${s.slice(0, n)}…` : s;
|