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
+32
View File
@@ -0,0 +1,32 @@
// The console-hosted UI server (design §9, primary path). `servePluginUi` from the SDK owns the whole
// plugin side — a loopback ephemeral port behind a per-boot secret, registration + lease renewal with
// the host, and the console reverse-proxy contract — so we just hand it the built SPA directory and the
// plugin-local API router. The operator signs into the console once; the "ROM Manager" nav entry
// appears automatically. Zero human auth here.
import {
type PluginUiHandle,
type Punktfunk,
servePluginUi,
} from "@punktfunk/host";
import { PLUGIN_NAME, UI_ICON, UI_TITLE } from "../const.js";
import type { Engine } from "../engine/index.js";
import { makeRouter } from "./router.js";
/**
* Serve the UI through the console. `../../dist/ui` resolves to `<repo>/dist/ui` whether this module
* runs from source (`src/server/index.ts`) or built (`dist/server/index.js`).
*/
export const serveConsoleUi = (
pf: Punktfunk,
engine: Engine,
opts?: { version?: string },
): Promise<PluginUiHandle> =>
servePluginUi(pf, {
id: PLUGIN_NAME,
title: UI_TITLE,
icon: UI_ICON,
version: opts?.version,
staticDir: new URL("../../dist/ui", import.meta.url),
fetch: makeRouter(engine),
});
+30
View File
@@ -0,0 +1,30 @@
// scrypt password hashing for the standalone fallback UI (design §9/§10.2). Format:
// `scrypt$<saltB64url>$<hashB64url>`. Verification is constant-time. Only used by the standalone
// server; the console-hosted path has no password of its own.
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const KEYLEN = 32;
/** Hash a password for storage in `config.ui.passwordHash`. */
export const hashPassword = (password: string): string => {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, KEYLEN);
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
};
/** Constant-time verify a password against a stored `scrypt$...` hash. */
export const verifyPassword = (password: string, stored: string): boolean => {
const parts = stored.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt" || !parts[1] || !parts[2])
return false;
const salt = Buffer.from(parts[1], "base64url");
const expected = Buffer.from(parts[2], "base64url");
let actual: Buffer;
try {
actual = scryptSync(password, salt, expected.length);
} catch {
return false;
}
return actual.length === expected.length && timingSafeEqual(actual, expected);
};
+108
View File
@@ -0,0 +1,108 @@
// The plugin-local REST/SSE API (design §9) — a pure `(Request) => Response | undefined` the UI talks
// to. Paths arrive prefix-stripped (the console proxy already removed `/plugin-ui/rom-manager`), so we
// match `/api/...` directly; a non-`/api` path returns `undefined` to fall through to the static SPA.
// The same router backs both the console-proxied server and the standalone fallback.
import { type RawConfig, resolveConfig } from "../config.js";
import { resolveEmulators } from "../emulators.js";
import type { Engine, EngineStatus } from "../engine/index.js";
import { resolvePlatforms } from "../platforms.js";
import { loadConfig, saveConfig } from "../state.js";
const json = (data: unknown, status = 200): Response =>
Response.json(data, { status });
/** A Server-Sent-Events stream that pushes the engine status on every change (design §9 progress feed). */
const sse = (engine: Engine): Response => {
const enc = new TextEncoder();
let unsubscribe = () => {};
let ping: ReturnType<typeof setInterval> | undefined;
const stream = new ReadableStream({
start(controller) {
const send = (status: EngineStatus) => {
try {
controller.enqueue(
enc.encode(`event: status\ndata: ${JSON.stringify(status)}\n\n`),
);
} catch {
// stream already closed
}
};
send(engine.status());
unsubscribe = engine.subscribe(send);
// Keep the connection warm through any buffering proxy.
ping = setInterval(() => {
try {
controller.enqueue(enc.encode(": ping\n\n"));
} catch {
// ignore
}
}, 15_000);
(ping as { unref?: () => void }).unref?.();
},
cancel() {
unsubscribe();
if (ping) clearInterval(ping);
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
},
});
};
/** Build the plugin-local API router bound to an engine. */
export const makeRouter =
(engine: Engine) =>
async (req: Request): Promise<Response | undefined> => {
const { pathname } = new URL(req.url);
const method = req.method;
if (!pathname.startsWith("/api/")) return undefined; // static SPA handles it
try {
if (pathname === "/api/status" && method === "GET") {
return json(engine.status());
}
if (pathname === "/api/config" && method === "GET") {
return json(loadConfig());
}
if (pathname === "/api/config" && method === "PUT") {
const body = (await req.json()) as RawConfig;
const resolved = resolveConfig(body);
saveConfig(resolved);
await engine.reconfigure();
return json(resolved);
}
if (pathname === "/api/preview" && method === "GET") {
return json(await engine.computeDry(false));
}
if (pathname === "/api/detect" && method === "POST") {
return json(engine.detect(loadConfig(), true));
}
if (pathname === "/api/sync" && method === "POST") {
const report = await engine.sync("ui");
return report
? json(report)
: json({ error: "a sync is already running" }, 409);
}
if (pathname === "/api/platforms" && method === "GET") {
return json([...resolvePlatforms(loadConfig().platforms).values()]);
}
if (pathname === "/api/emulators" && method === "GET") {
const config = loadConfig();
return json({
defs: [...resolveEmulators(config.emulators).values()],
detected: engine.detect(config, false),
});
}
if (pathname === "/api/events" && method === "GET") {
return sse(engine);
}
return json({ error: "not found" }, 404);
} catch (e) {
return json({ error: String(e) }, 500);
}
};
+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);
},
};
};