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