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
+151
View File
@@ -0,0 +1,151 @@
// The standalone fallback UI server (design §9/§10.2, D1): for host-only installs without the console,
// or as schedule insurance if the console surface is unavailable. Serves the SAME SPA + router, but on
// a fixed port with its own password/session auth instead of the console proxy. Fail-closed: a
// non-loopback bind REQUIRES a password (a UI that can rewrite launch templates is host-user code).
import * as nodePath from "node:path";
import { fileURLToPath } from "node:url";
import type { Config } from "../config.js";
import type { Engine } from "../engine/index.js";
import { log } from "../log.js";
import { verifyPassword } from "./password.js";
import { makeRouter } from "./router.js";
export interface StandaloneHandle {
url: string;
close(): Promise<void>;
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
const COOKIE = "pf_rm_session";
const loginPage = (error?: string): Response =>
new Response(
`<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>ROM Manager</title>
<style>body{font:15px system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0b0b0f;color:#e5e7eb}
form{display:grid;gap:.75rem;width:260px}input{padding:.5rem;border-radius:.5rem;border:1px solid #333;background:#111;color:#eee}
button{padding:.5rem;border-radius:.5rem;border:0;background:#6d28d9;color:#fff;cursor:pointer}.e{color:#f87171;font-size:13px}</style>
<form method=post action="./login"><h1>ROM Manager</h1>${error ? `<div class=e>${error}</div>` : ""}
<input type=password name=password placeholder="Password" autofocus><button>Sign in</button></form>`,
{
status: error ? 401 : 200,
headers: { "content-type": "text/html; charset=utf-8" },
},
);
/** Resolve a request path to a file inside `root`, or null on traversal (mirrors servePluginUi). */
const staticFile = (root: string, pathname: string): string | null => {
let rel: string;
try {
rel = decodeURIComponent(pathname);
} catch {
return null;
}
if (rel.endsWith("/")) rel += "index.html";
if (!rel.startsWith("/")) rel = `/${rel}`;
const abs = nodePath.resolve(root, `.${rel}`);
const rootAbs = nodePath.resolve(root);
if (abs !== rootAbs && !abs.startsWith(rootAbs + nodePath.sep)) return null;
return abs;
};
const cookieHas = (req: Request, name: string, value: string): boolean => {
const raw = req.headers.get("cookie") ?? "";
return raw.split(/;\s*/).some((c) => c === `${name}=${value}`);
};
/** Serve the standalone UI. Throws (fail-closed) if a non-loopback bind lacks a password. */
export const serveStandaloneUi = (
engine: Engine,
config: Config,
): StandaloneHandle => {
const { port, bind, passwordHash } = config.ui;
const isLoopback = LOOPBACK.has(bind);
if (!isLoopback && !passwordHash) {
throw new Error(
`standalone UI refuses to bind ${bind} without a password (set ui.passwordHash via \`punktfunk-plugin-rom-manager set-password\`, or bind 127.0.0.1)`,
);
}
const authRequired = Boolean(passwordHash);
// A per-boot session token — any client presenting it in the cookie is authed until the plugin restarts.
const sessionToken = crypto.randomUUID().replace(/-/g, "");
const root = fileURLToPath(new URL("../../dist/ui", import.meta.url));
const router = makeRouter(engine);
const authed = (req: Request) =>
!authRequired || cookieHas(req, COOKIE, sessionToken);
const server = Bun.serve({
hostname: bind,
port,
async fetch(req) {
const { pathname } = new URL(req.url);
if (pathname === "/__health") return Response.json({ ok: true });
if (authRequired && pathname === "/login" && req.method === "POST") {
const form = await req.formData().catch(() => null);
const password = form?.get("password");
if (
typeof password === "string" &&
passwordHash &&
verifyPassword(password, passwordHash)
) {
return new Response(null, {
status: 303,
headers: {
location: "./",
"set-cookie": `${COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
},
});
}
return loginPage("Incorrect password");
}
if (pathname === "/logout" && req.method === "POST") {
return new Response(null, {
status: 303,
headers: {
location: "./",
"set-cookie": `${COOKIE}=; Max-Age=0; Path=/`,
},
});
}
if (!authed(req)) {
if (pathname.startsWith("/api/"))
return Response.json({ error: "unauthorized" }, { status: 401 });
return loginPage();
}
// 1) plugin-local API
const api = await router(req);
if (api) return api;
// 2) static asset
const file = staticFile(root, pathname);
if (file) {
const bf = Bun.file(file);
if (await bf.exists()) return new Response(bf);
}
// 3) SPA fallback
if (
req.method === "GET" &&
(req.headers.get("accept") ?? "").includes("text/html")
) {
const index = Bun.file(nodePath.join(root, "index.html"));
if (await index.exists()) return new Response(index);
}
return new Response("not found", { status: 404 });
},
});
const url = `http://${isLoopback ? "127.0.0.1" : bind}:${server.port}`;
log(
`standalone UI on ${url}${authRequired ? " (password-protected)" : " (loopback, no password)"}`,
);
return {
url,
async close() {
server.stop(true);
},
};
};