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
+87
View File
@@ -0,0 +1,87 @@
// The keyless fallback art provider: libretro-thumbnails (design §7) — HTTPS, redirect-free,
// CDN-backed, no API key. Box art only (portrait); hero/logo/header are SteamGridDB-only. Thumbnail
// files are named after the No-Intro name with a documented character-substitution set; we walk a
// fallback ladder (exact → region-swap → bare title) and verify each candidate with one probe.
import type { ParsedTitle } from "../engine/titles.js";
import type { Platform } from "../platforms.js";
import type { Artwork } from "../wire.js";
import type { ArtProvider } from "./provider.js";
const BASE = "https://thumbnails.libretro.com";
/**
* libretro's filename character substitutions: ampersand, asterisk, slash, colon, backtick, angle
* brackets, question mark, backslash, pipe and double-quote all become `_` (the rest of the name,
* including spaces and parentheses, is kept verbatim and URL-encoded per path segment).
*/
export const sanitizeName = (name: string): string =>
name.replace(/[&*/:`<>?\\|"]/g, "_").trim();
/** Build the Named_Boxarts URL for a system + already-sanitized name. */
export const buildUrl = (system: string, sanitizedName: string): string =>
`${BASE}/${encodeURIComponent(system)}/Named_Boxarts/${encodeURIComponent(sanitizedName)}.png`;
/** Common single-region fallbacks tried when the exact-name probe misses. */
const REGION_FALLBACKS = ["USA", "World", "Europe", "Japan"];
/**
* Ordered candidate names for a title (design §7 ladder), de-duplicated:
* 1. the exact No-Intro name (with all tags),
* 2. `<base> (<its own region>)` — drops revision/flag tags but keeps region,
* 3. `<base> (USA|World|Europe|Japan)` — region-relaxed,
* 4. the bare base title.
*/
export const candidateNames = (parsed: ParsedTitle): string[] => {
const names: string[] = [parsed.noIntroName];
const base = parsed.displayTitle;
if (parsed.region) names.push(`${base} (${parsed.region})`);
for (const r of REGION_FALLBACKS) names.push(`${base} (${r})`);
names.push(base);
return [...new Set(names.map((n) => n.trim()).filter(Boolean))];
};
/** Probe a URL for existence. Returns true on a 2xx. Injected in tests; the default uses HEAD. */
export type ArtProbe = (url: string) => Promise<boolean>;
export const defaultProbe: ArtProbe = async (url) => {
try {
const res = await fetch(url, { method: "HEAD", redirect: "manual" });
return res.status >= 200 && res.status < 300;
} catch {
return false;
}
};
/** Resolve a portrait URL for a title, or `null` if no candidate exists (walks the candidate ladder). */
export const resolveLibretroUrl = async (
system: string,
parsed: ParsedTitle,
probe: ArtProbe = defaultProbe,
): Promise<string | null> => {
for (const name of candidateNames(parsed)) {
const url = buildUrl(system, sanitizeName(name));
if (await probe(url)) return url;
}
return null;
};
/** The libretro-thumbnails provider (portrait only; needs a platform with a mapped libretro system). */
export class LibretroProvider implements ArtProvider {
readonly id = "libretro";
readonly matcherVersion = 1;
constructor(private readonly probe: ArtProbe = defaultProbe) {}
async resolve(
platform: Platform,
parsed: ParsedTitle,
): Promise<Artwork | null> {
if (!platform.libretroSystem) return null;
const portrait = await resolveLibretroUrl(
platform.libretroSystem,
parsed,
this.probe,
);
return portrait ? { portrait } : null;
}
}
+104
View File
@@ -0,0 +1,104 @@
// The art-provider seam (design §7, revised). Steam ROM Manager sources art from SteamGridDB; we make
// that the preferred provider (full portrait + hero + logo + header, fuzzy title match) and keep the
// keyless libretro-thumbnails source as a zero-setup fallback. Both implement `ArtProvider`, so the
// reconcile/warm code is provider-agnostic; the choice is `config.art.provider` (default `auto` =
// SteamGridDB when an API key is set, else libretro).
import type { Config } from "../config.js";
import type { ParsedTitle } from "../engine/titles.js";
import { log } from "../log.js";
import type { Platform } from "../platforms.js";
import type { ArtVerdict } from "../state.js";
import type { Artwork } from "../wire.js";
import { LibretroProvider } from "./libretro.js";
import { SteamGridDbProvider } from "./steamgriddb.js";
export interface ArtProvider {
/** Stable id, stored in the cache verdict so a provider switch re-resolves. */
readonly id: string;
/** Bump to invalidate this provider's cached verdicts after an algorithm change. */
readonly matcherVersion: number;
/** Resolve artwork for a title, or `null` if nothing was found. Best-effort — never throws. */
resolve(platform: Platform, parsed: ParsedTitle): Promise<Artwork | null>;
}
/** Pick the active provider for a config, or `null` when art is disabled / unavailable. */
export const selectArtProvider = (config: Config): ArtProvider | null => {
if (!config.art.enabled) return null;
const key = config.art.steamGridDbKey?.trim();
switch (config.art.provider) {
case "libretro":
return new LibretroProvider();
case "steamgriddb":
if (key) return new SteamGridDbProvider(key);
log(
"art: provider=steamgriddb but no API key set — no art will be fetched (set art.steamGridDbKey or use provider=auto)",
);
return null;
default: // "auto"
return key ? new SteamGridDbProvider(key) : new LibretroProvider();
}
};
/** One title needing art — the reconcile enumeration output the warmer iterates. */
export interface ArtTarget {
externalId: string;
platform: Platform;
parsed: ParsedTitle;
}
/** Run `fn` over `items` with bounded concurrency (art providers are network-bound; stay polite). */
const mapPool = async <T>(
items: T[],
limit: number,
fn: (item: T) => Promise<void>,
): Promise<void> => {
let i = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (i < items.length) {
const idx = i++;
const item = items[idx];
if (item !== undefined) await fn(item);
}
},
);
await Promise.all(workers);
};
/**
* Populate `cache.art` (keyed by `external_id`) for every target missing a fresh verdict — a verdict is
* fresh when it was produced by the *current* provider at its current matcher version. Returns the
* number of titles (re)resolved. Verdicts (including "no art") are cached so we never re-probe.
*/
export const warmArt = async (
provider: ArtProvider,
targets: ArtTarget[],
cache: { art: Record<string, ArtVerdict> },
opts?: { concurrency?: number },
): Promise<number> => {
const stale = targets.filter((t) => {
const v = cache.art[t.externalId];
return (
!v || v.provider !== provider.id || v.matcher !== provider.matcherVersion
);
});
let resolved = 0;
await mapPool(stale, opts?.concurrency ?? 4, async (t) => {
try {
// A definitive result (artwork OR "no art") is cached; a *transient* failure throws and is
// NOT cached, so the next sync retries it rather than pinning a false "no art".
const art: Artwork | null = await provider.resolve(t.platform, t.parsed);
cache.art[t.externalId] = {
art,
provider: provider.id,
matcher: provider.matcherVersion,
};
resolved++;
} catch (e) {
log(`art: ${t.parsed.displayTitle}: ${e}`);
}
});
return resolved;
};
+132
View File
@@ -0,0 +1,132 @@
// The preferred art provider: SteamGridDB (what Steam ROM Manager uses). Fuzzy-matches a title to a
// SteamGridDB game, then pulls the four art types the host's `Artwork` wants: a 600x900 portrait
// capsule (grid), a horizontal header (grid), a hero background, and a transparent logo.
//
// Auth is a Bearer API key (free, per-user, from steamgriddb.com profile preferences) — so, unlike
// libretro, this is opt-in. The cache verdict is keyed on a hash of the key (`id` below), so changing
// or adding a key re-resolves everything; a bad key fails fast (one 401), logs once, and short-circuits
// the rest of the run instead of hammering the API.
import { createHash } from "node:crypto";
import type { ParsedTitle } from "../engine/titles.js";
import { log } from "../log.js";
import type { Platform } from "../platforms.js";
import type { Artwork } from "../wire.js";
import type { ArtProvider } from "./provider.js";
const BASE = "https://www.steamgriddb.com/api/v2";
interface SgdbGame {
id: number;
name: string;
}
interface SgdbImage {
url: string;
width: number;
height: number;
}
/** Injectable fetch (tests) — defaults to global fetch. */
export type SgdbFetch = typeof fetch;
export class SteamGridDbProvider implements ArtProvider {
readonly id: string;
readonly matcherVersion = 1;
private authFailed = false;
constructor(
private readonly apiKey: string,
private readonly fetchImpl: SgdbFetch = fetch,
) {
// The key fingerprint scopes cached verdicts to this key: change the key → re-resolve, without
// ever storing the key itself in the cache file.
const fp = createHash("sha256").update(apiKey).digest("hex").slice(0, 8);
this.id = `steamgriddb:${fp}`;
}
/** GET a SteamGridDB endpoint → its `data`. `null` = auth-disabled (skip); throws on transient errors. */
private async get(pathAndQuery: string): Promise<unknown[] | null> {
if (this.authFailed) return null;
const res = await this.fetchImpl(`${BASE}${pathAndQuery}`, {
headers: { authorization: `Bearer ${this.apiKey}` },
});
if (res.status === 401 || res.status === 403) {
this.authFailed = true;
log(
"art: SteamGridDB rejected the API key (401/403) — check art.steamGridDbKey",
);
return null;
}
if (!res.ok) throw new Error(`SteamGridDB HTTP ${res.status}`);
const body = (await res.json()) as { success?: boolean; data?: unknown };
return Array.isArray(body.data) ? body.data : [];
}
/** Like `get`, but a transient failure yields `[]` (used for the nice-to-have hero/logo fetches). */
private async getSafe(pathAndQuery: string): Promise<unknown[]> {
try {
return (await this.get(pathAndQuery)) ?? [];
} catch {
return [];
}
}
async resolve(
_platform: Platform,
parsed: ParsedTitle,
): Promise<Artwork | null> {
const term = (parsed.displayTitle || parsed.noIntroName).trim();
if (!term) return null;
const games = (await this.get(
`/search/autocomplete/${encodeURIComponent(term)}`,
)) as SgdbGame[] | null;
if (games === null) return null; // auth disabled
const game = games[0];
if (!game) return null; // definitive: no match
// Portrait + header both come from grids (classified by orientation); hero/logo are separate,
// best-effort endpoints. Grids failing transiently should retry, so it isn't wrapped in getSafe.
const grids = (await this.get(
`/grids/game/${game.id}?types=static&nsfw=false&humor=false`,
)) as SgdbImage[] | null;
if (grids === null) return null; // auth disabled mid-run
const [heroes, logos] = await Promise.all([
this.getSafe(`/heroes/game/${game.id}?types=static`) as Promise<
SgdbImage[]
>,
this.getSafe(`/logos/game/${game.id}`) as Promise<SgdbImage[]>,
]);
const art: Artwork = {};
const portrait = pickGrid(grids, (i) => i.height > i.width, [
[600, 900],
[660, 930],
[342, 482],
]);
const header = pickGrid(grids, (i) => i.width > i.height, [
[460, 215],
[920, 430],
]);
if (portrait) art.portrait = portrait;
if (header) art.header = header;
if (heroes[0]?.url) art.hero = heroes[0].url;
if (logos[0]?.url) art.logo = logos[0].url;
return Object.keys(art).length > 0 ? art : null;
}
}
/** Pick the best grid matching `orient`, preferring the listed exact dimensions, else the first match. */
const pickGrid = (
grids: SgdbImage[],
orient: (i: SgdbImage) => boolean,
preferred: [number, number][],
): string | undefined => {
const matching = grids.filter((g) => g?.url && orient(g));
for (const [w, h] of preferred) {
const exact = matching.find((g) => g.width === w && g.height === h);
if (exact) return exact.url;
}
return matching[0]?.url;
};
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bun
// Dev/debug CLI (design §8) — reuses the engine one-shot. `scan`/`detect`/`preview` are offline (no
// host needed); `sync`/`uninstall` connect to the host and reconcile. Handy for configuring a headless
// box or checking what a `config.json` will produce before pushing it to the library.
//
// bunx punktfunk-plugin-rom-manager scan # what the scanner finds
// bunx punktfunk-plugin-rom-manager detect # which emulators are installed
// bunx punktfunk-plugin-rom-manager preview # the desired library (no host write)
// bunx punktfunk-plugin-rom-manager sync # reconcile into the live library
// bunx punktfunk-plugin-rom-manager uninstall # remove all this plugin's entries
// bunx punktfunk-plugin-rom-manager set-password <pw> # standalone-UI password
import { connect } from "@punktfunk/host";
import type { Config } from "./config.js";
import { detectAll, realDetectEnv, resolveEmulators } from "./emulators.js";
import { Engine } from "./engine/index.js";
import { computeDesired, enumerateTitles } from "./engine/reconcile.js";
import { type RomCandidate, realScanFs, scanRoot } from "./engine/scanner.js";
import { resolvePlatforms } from "./platforms.js";
import { currentOs } from "./quote.js";
import { hashPassword } from "./server/password.js";
import { loadCache, loadConfig, saveConfig } from "./state.js";
const scanAll = (config: Config): RomCandidate[] => {
const platforms = resolvePlatforms(config.platforms);
const out: RomCandidate[] = [];
for (const root of config.roots) {
const platform = platforms.get(root.platform);
if (!platform) {
console.error(`! root ${root.dir}: unknown platform "${root.platform}"`);
continue;
}
out.push(...scanRoot(realScanFs, root.dir, platform, root.excludes));
}
return out;
};
const cmdScan = (): void => {
const config = loadConfig();
if (config.roots.length === 0) {
console.log(
"No ROM roots configured. Add some to config.json or via the UI.",
);
return;
}
const scan = scanAll(config);
const { survivors, skipped } = enumerateTitles(config, scan);
const perPlatform = new Map<string, number>();
for (const s of survivors)
perPlatform.set(s.platform.id, (perPlatform.get(s.platform.id) ?? 0) + 1);
console.log(
`Scanned ${config.roots.length} root(s): ${scan.length} candidate file(s), ${survivors.length} title(s).`,
);
for (const [id, n] of [...perPlatform].sort()) console.log(` ${id}: ${n}`);
if (skipped.length)
console.log(` (${skipped.length} skipped by exclude/dedupe)`);
};
const cmdDetect = (): void => {
const config = loadConfig();
const detected = detectAll(
realDetectEnv(),
resolveEmulators(config.emulators).values(),
);
if (detected.length === 0) {
console.log("No emulators detected.");
return;
}
console.log(`Detected ${detected.length} emulator(s):`);
for (const d of detected) {
const cores = d.cores?.length ? `${d.cores.length} core(s)` : "";
console.log(
` ${d.id} (${d.via})${d.contested ? " [contested]" : ""}${cores}`,
);
}
};
const cmdPreview = (): void => {
const config = loadConfig();
const scan = scanAll(config);
const detected = detectAll(
realDetectEnv(),
resolveEmulators(config.emulators).values(),
);
const cache = loadCache();
const { entries, report } = computeDesired({
config,
scan,
detected,
art: cache.art,
os: currentOs(),
});
console.log(
`Preview: ${entries.length} entr(y/ies) would be reconciled${report.overWarn ? " [scale warning]" : ""}.`,
);
for (const e of entries.slice(0, 40))
console.log(` ${e.external_id}${e.launch?.value ?? "(no launch)"}`);
if (entries.length > 40) console.log(` … and ${entries.length - 40} more`);
if (report.skipped.length) {
console.log(`\n${report.skipped.length} skipped:`);
for (const s of report.skipped.slice(0, 20))
console.log(` ${s.title}: ${s.reason}`);
}
for (const w of report.warnings.slice(0, 10)) console.log(`! ${w}`);
};
const cmdSync = async (): Promise<void> => {
const pf = await connect();
try {
const engine = new Engine({ pf });
const report = await engine.sync("cli");
if (report) {
console.log(
`Synced: ${report.included} entr(y/ies), ${report.skipped.length} skipped.`,
);
} else {
console.log("Sync did not complete (see log).");
}
} finally {
pf.close();
}
};
const cmdUninstall = async (): Promise<void> => {
const pf = await connect();
try {
await new Engine({ pf }).uninstall();
console.log("Removed all rom-manager entries from the library.");
} finally {
pf.close();
}
};
const cmdSetPassword = (password: string | undefined): void => {
if (!password) {
console.error("usage: set-password <password>");
process.exitCode = 2;
return;
}
const config = loadConfig();
config.ui.passwordHash = hashPassword(password);
saveConfig(config);
console.log("Standalone-UI password set (config.ui.passwordHash).");
};
const main = async (): Promise<void> => {
const [cmd, arg] = process.argv.slice(2);
switch (cmd) {
case "scan":
return cmdScan();
case "detect":
return cmdDetect();
case "preview":
return cmdPreview();
case "sync":
return cmdSync();
case "uninstall":
return cmdUninstall();
case "set-password":
return cmdSetPassword(arg);
default:
console.log(
"usage: punktfunk-plugin-rom-manager <scan|detect|preview|sync|uninstall|set-password>",
);
process.exitCode = cmd ? 2 : 0;
}
};
await main();
+151
View File
@@ -0,0 +1,151 @@
// The plugin's configuration model (design §9) — the single source of truth for the sync engine,
// editable by hand (headless boxes) or by the web UI. Everything the engine needs is derivable from
// this file plus a directory scan; the `resolveConfig` helper fills defaults so the rest of the code
// works against a fully-populated shape.
import type { EmulatorDef } from "./emulators.js";
import type { Platform } from "./platforms.js";
/** A ROM root: a directory paired with the platform its files belong to (design §5). */
export interface RomRoot {
/** Absolute directory to scan. */
dir: string;
/** Platform id (a key into the resolved platform registry). */
platform: string;
/** Per-root glob ignore patterns (`*.sav`, `bios/**`). */
excludes?: string[];
}
/** A per-platform launch override — replaces the platform's built-in default emulator/core. */
export interface PlatformLaunch {
emulator: string;
core?: string;
extraArgs?: string;
}
/** A per-game override, keyed by `external_id` (`snes/Chrono Trigger (USA).sfc`). */
export interface GameOverride {
/** Skip this ROM entirely. */
exclude?: boolean;
/** Override the resolved emulator/core/args for just this title. */
emulator?: string;
core?: string;
extraArgs?: string;
/** Override the displayed title. */
title?: string;
/** Override the box-art portrait URL. */
art?: string;
}
export interface SyncOptions {
/** Interval poll in minutes (watchers are unreliable on SMB/NFS — poll is the primary trigger). */
pollMinutes: number;
/** Enable fs watching (an accelerator on top of the poll). */
watch: boolean;
/** Debounce window for coalescing watch/poll-triggered rescans (ms). */
debounceMs: number;
/** Platform ids to region-dedupe ("one entry per title", design §5) — default none. */
dedupeRegions: string[];
/** Region preference order for dedupe (best first). */
regionPriority: string[];
/** Warn in the UI above this entry count (design §8 scale guard). */
warnEntries: number;
/** Hard cap — truncate with a loud report above this many entries. */
maxEntries: number;
/** Attach `prep`/`undo` that kills the emulator at session end ("close emulator when stream ends"). */
closeOnEnd: boolean;
}
/** Which art source to use (design §7, revised): SteamGridDB (like Steam ROM Manager) or the keyless
* libretro-thumbnails fallback. `auto` = SteamGridDB when a key is set, else libretro. */
export type ArtProviderChoice = "auto" | "steamgriddb" | "libretro";
export interface ArtOptions {
/** Fetch box art at all. */
enabled: boolean;
/** Art source selection (default `auto`). */
provider: ArtProviderChoice;
/**
* SteamGridDB API key (free, from steamgriddb.com profile preferences). Unlocks full portrait +
* hero + logo + header art with fuzzy title matching. Absent → the keyless libretro fallback.
*/
steamGridDbKey?: string;
}
export interface UiOptions {
/**
* Fallback standalone mode (design §9): serve the UI on a fixed, possibly non-loopback port behind
* a password instead of via the console proxy. For host-only installs without the console.
*/
standalone: boolean;
/** Standalone bind port. */
port: number;
/** Standalone bind address (defaults to loopback; a non-loopback bind REQUIRES a password). */
bind: string;
/** scrypt password hash (`scrypt$<saltB64>$<hashB64>`), required for a non-loopback standalone bind. */
passwordHash?: string;
}
/** The on-disk config as authored (all optional — `resolveConfig` fills the rest). */
export interface RawConfig {
roots?: RomRoot[];
/** Per-platform launch overrides, keyed by platform id. */
platformLaunch?: Record<string, PlatformLaunch>;
/** Per-game overrides, keyed by `external_id`. */
gameOverrides?: Record<string, GameOverride>;
/** Operator-added / overriding platform defs (merged over the built-ins by id). */
platforms?: Platform[];
/** Operator-added / overriding emulator defs (merged over the built-ins by id). */
emulators?: EmulatorDef[];
sync?: Partial<SyncOptions>;
art?: Partial<ArtOptions>;
ui?: Partial<UiOptions>;
/** Dev/M0 acceptance flag: reconcile one hardcoded entry so the round-trip is observable. */
devEntry?: boolean;
}
/** The fully-resolved config the engine consumes. */
export interface Config {
roots: RomRoot[];
platformLaunch: Record<string, PlatformLaunch>;
gameOverrides: Record<string, GameOverride>;
platforms?: Platform[];
emulators?: EmulatorDef[];
sync: SyncOptions;
art: ArtOptions;
ui: UiOptions;
devEntry: boolean;
}
export const DEFAULT_SYNC: SyncOptions = {
pollMinutes: 15,
watch: true,
debounceMs: 2000,
dedupeRegions: [],
regionPriority: ["USA", "World", "Europe", "Japan"],
warnEntries: 2000,
maxEntries: 5000,
closeOnEnd: false,
};
export const DEFAULT_UI: UiOptions = {
standalone: false,
port: 47993,
bind: "127.0.0.1",
};
/** Fill defaults over an authored config so the rest of the engine sees a total shape. */
export const resolveConfig = (raw: RawConfig): Config => ({
roots: raw.roots ?? [],
platformLaunch: raw.platformLaunch ?? {},
gameOverrides: raw.gameOverrides ?? {},
platforms: raw.platforms,
emulators: raw.emulators,
sync: { ...DEFAULT_SYNC, ...raw.sync },
art: { enabled: true, provider: "auto", ...raw.art },
ui: { ...DEFAULT_UI, ...raw.ui },
devEntry: raw.devEntry ?? false,
});
/** The empty starting config for a fresh install (no roots yet). */
export const emptyConfig = (): Config => resolveConfig({});
+7
View File
@@ -0,0 +1,7 @@
// Plugin identity (design §4). The package name is `punktfunk-plugin-rom-manager` (unscoped — the
// runner's glob), but the `definePlugin` name, the provider id, and the console-nav id are all the
// short `rom-manager`.
export const PLUGIN_NAME = "rom-manager";
export const PROVIDER_ID = "rom-manager";
export const UI_TITLE = "ROM Manager";
export const UI_ICON = "gamepad-2";
+418
View File
@@ -0,0 +1,418 @@
// Emulator registry + detection (design §6). Same data-driven shape as the platform registry: each
// emulator declares how to find it per-OS (PATH command, Flatpak app id, or known install path), the
// RetroArch cores directories to scan, and the launch-command template. Detection is best-effort and
// **injectable** (the `DetectEnv` seam) so it is unit-testable without a real machine.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { type Os, quoteFor } from "./quote.js";
/** A platform's default (or a per-platform/per-game override) launcher choice. */
export interface DefaultLaunch {
/** Emulator id (`retroarch`, `dolphin`, …) — a key into {@link EMULATORS} or a `custom-*` template. */
emulator: string;
/** RetroArch core base name (`snes9x`) — required for `retroarch`, ignored otherwise. */
core?: string;
/** Operator-typed extra args, appended verbatim (trusted). */
extraArgs?: string;
}
export interface EmulatorDef {
id: string;
name: string;
/**
* How to find the emulator, tried in order. An entry is one of: `"flatpak:<app-id>"`, a path
* (contains a separator, `~`, or an env var like `%APPDATA%`), or a bare PATH command.
*/
detect: { linux: string[]; windows: string[] };
/** RetroArch cores directories to scan for available cores (per-OS), first existing wins. */
coresDir?: { linux: string[]; windows: string[] };
/** Launch template — `{exe}` (verbatim launcher), `{core}`/`{rom}` (quoted). */
template: string;
/** Whether the emulator can load `.zip`/`.7z` directly (archive gating, design §5). */
supportsArchives: boolean;
/** Contested legal status (design Q4): shipped as a built-in def but flagged in the UI. */
contested?: boolean;
}
export const EMULATORS: EmulatorDef[] = [
{
id: "retroarch",
name: "RetroArch",
detect: {
linux: ["retroarch", "flatpak:org.libretro.RetroArch"],
windows: [
"%APPDATA%\\RetroArch\\retroarch.exe",
"C:\\RetroArch-Win64\\retroarch.exe",
"C:\\RetroArch\\retroarch.exe",
],
},
coresDir: {
linux: [
"~/.config/retroarch/cores",
"~/.var/app/org.libretro.RetroArch/config/retroarch/cores",
],
windows: ["%APPDATA%\\RetroArch\\cores", "C:\\RetroArch-Win64\\cores"],
},
template: "{exe} -f -L {core} {rom}",
supportsArchives: true,
},
{
id: "dolphin",
name: "Dolphin",
detect: {
linux: ["dolphin-emu", "flatpak:org.DolphinEmu.dolphin-emu"],
windows: [
"C:\\Program Files\\Dolphin-x64\\Dolphin.exe",
"%LOCALAPPDATA%\\Dolphin\\Dolphin.exe",
],
},
template: "{exe} -b -e {rom}",
supportsArchives: false,
},
{
id: "pcsx2",
name: "PCSX2",
detect: {
linux: ["pcsx2-qt", "pcsx2", "flatpak:net.pcsx2.PCSX2"],
windows: [
"C:\\Program Files\\PCSX2\\pcsx2-qt.exe",
"C:\\Program Files (x86)\\PCSX2\\pcsx2-qt.exe",
],
},
template: "{exe} -batch -fullscreen -- {rom}",
supportsArchives: false,
},
{
id: "duckstation",
name: "DuckStation",
detect: {
linux: [
"duckstation-qt",
"duckstation",
"flatpak:org.duckstation.DuckStation",
],
windows: [
"C:\\Program Files\\DuckStation\\duckstation-qt-x64-ReleaseLTCG.exe",
"C:\\Program Files\\DuckStation\\duckstation.exe",
],
},
template: "{exe} -batch -fullscreen -- {rom}",
supportsArchives: true,
},
{
id: "ppsspp",
name: "PPSSPP",
detect: {
linux: ["PPSSPPSDL", "PPSSPPQt", "ppsspp", "flatpak:org.ppsspp.PPSSPP"],
windows: [
"C:\\Program Files\\PPSSPP\\PPSSPPWindows64.exe",
"C:\\Program Files\\PPSSPP\\PPSSPPWindows.exe",
],
},
template: "{exe} --fullscreen {rom}",
supportsArchives: false,
},
{
id: "mgba",
name: "mGBA",
detect: {
linux: ["mgba-qt", "mgba", "flatpak:io.mgba.mGBA"],
windows: ["C:\\Program Files\\mGBA\\mGBA.exe"],
},
template: "{exe} -f {rom}",
supportsArchives: true,
},
{
id: "melonds",
name: "melonDS",
detect: {
linux: ["melonDS", "melonds", "flatpak:net.kuribo64.melonDS"],
windows: ["C:\\Program Files\\melonDS\\melonDS.exe"],
},
template: "{exe} {rom}",
supportsArchives: false,
},
{
id: "flycast",
name: "Flycast",
detect: {
linux: ["flycast", "flatpak:org.flycast.Flycast"],
windows: ["C:\\Program Files\\Flycast\\flycast.exe"],
},
template: "{exe} {rom}",
supportsArchives: true,
},
{
id: "xemu",
name: "xemu",
detect: {
linux: ["xemu", "flatpak:app.xemu.xemu"],
windows: ["C:\\Program Files\\xemu\\xemu.exe"],
},
template: "{exe} -full-screen -dvd_path {rom}",
supportsArchives: false,
},
{
id: "azahar",
name: "Azahar (3DS)",
detect: {
linux: ["azahar", "flatpak:org.azahar_emu.Azahar"],
windows: ["%LOCALAPPDATA%\\Azahar\\azahar.exe"],
},
template: "{exe} {rom}",
supportsArchives: false,
contested: true,
},
{
id: "ryujinx",
name: "Ryujinx (Switch)",
detect: {
linux: ["Ryujinx", "ryujinx", "flatpak:org.ryujinx.Ryujinx"],
windows: ["%APPDATA%\\Ryujinx\\Ryujinx.exe"],
},
template: "{exe} {rom}",
supportsArchives: false,
contested: true,
},
];
const BY_ID = new Map(EMULATORS.map((e) => [e.id, e]));
/** Look up a built-in emulator def by id. */
export const emulatorById = (id: string): EmulatorDef | undefined =>
BY_ID.get(id);
/** Merge built-in emulators with operator-defined ones (config `emulators[]`, `custom-*` templates). */
export const resolveEmulators = (
overrides?: EmulatorDef[],
): Map<string, EmulatorDef> => {
const merged = new Map(BY_ID);
for (const e of overrides ?? []) merged.set(e.id, e);
return merged;
};
// ---------------------------------------------------------------- detection
/** The I/O surface detection needs — injected so detection is unit-testable. */
export interface DetectEnv {
os: Os;
/** Resolve a bare command on PATH to an absolute path, or `null`. */
which(cmd: string): string | null;
/** Does this absolute path exist? */
fileExists(p: string): boolean;
/** Is a Flatpak app installed? */
flatpakInstalled(appId: string): boolean;
/** List a directory's entries (`[]` if missing/unreadable). */
listDir(p: string): string[];
/** Expand `~` and env vars (`%VAR%` on Windows, `$VAR` on POSIX). */
expandPath(p: string): string;
}
export interface DetectedEmulator {
id: string;
name: string;
template: string;
supportsArchives: boolean;
contested?: boolean;
/** The `{exe}` token, verbatim: a per-OS-quoted absolute path, or `flatpak run <app-id>`. */
exeToken: string;
/** Raw absolute executable path (path/file detection) — for deriving a kill target ("close on end"). */
exePath?: string;
/** Flatpak app id (flatpak detection) — for `flatpak kill <appId>`. */
appId?: string;
/** How it was located (UI). */
via: "path" | "file" | "flatpak";
/** RetroArch cores dir that exists, and the core base names found in it. */
coresDir?: string;
cores?: string[];
}
const FLATPAK_ID_RE = /^[A-Za-z][A-Za-z0-9._-]+$/;
const looksLikePath = (s: string): boolean =>
s.includes("/") ||
s.includes("\\") ||
s.startsWith("~") ||
s.includes("%") ||
/^[A-Za-z]:/.test(s);
/** Quote an absolute path into a verbatim `{exe}` token, or `null` if it can't be safely quoted. */
const exeTokenFor = (os: Os, absPath: string): string | null => {
const q = quoteFor(os)(absPath);
return q.ok ? q.value : null;
};
/** The RetroArch core file suffix for a host OS. */
const coreSuffix = (o: Os): string => (o === "windows" ? ".dll" : ".so");
/** Scan cores directories for `<name>_libretro.<ext>` files → sorted core base names. */
const scanCores = (
env: DetectEnv,
dirs: string[],
): { coresDir?: string; cores: string[] } => {
const suffix = `_libretro${coreSuffix(env.os)}`;
for (const raw of dirs) {
const dir = env.expandPath(raw);
const names = env
.listDir(dir)
.filter((f) => f.endsWith(suffix))
.map((f) => f.slice(0, -suffix.length))
.sort();
if (names.length > 0) return { coresDir: dir, cores: names };
}
// No cores found, but report the first candidate dir so launch can still form a path.
const firstExisting = dirs.map((d) => env.expandPath(d)).find(env.fileExists);
return { coresDir: firstExisting, cores: [] };
};
/** Detect a single emulator, or `null` if not installed. Pure over the injected `DetectEnv`. */
export const detectEmulator = (
def: EmulatorDef,
env: DetectEnv,
): DetectedEmulator | null => {
const candidates =
env.os === "windows" ? def.detect.windows : def.detect.linux;
let exeToken: string | null = null;
let via: DetectedEmulator["via"] | null = null;
let exePath: string | undefined;
let appId: string | undefined;
for (const entry of candidates) {
if (entry.startsWith("flatpak:")) {
const id = entry.slice("flatpak:".length);
if (FLATPAK_ID_RE.test(id) && env.flatpakInstalled(id)) {
exeToken = `flatpak run ${id}`;
via = "flatpak";
appId = id;
break;
}
} else if (looksLikePath(entry)) {
const abs = env.expandPath(entry);
if (env.fileExists(abs)) {
const t = exeTokenFor(env.os, abs);
if (t) {
exeToken = t;
via = "file";
exePath = abs;
break;
}
}
} else {
const abs = env.which(entry);
if (abs) {
const t = exeTokenFor(env.os, abs);
if (t) {
exeToken = t;
via = "path";
exePath = abs;
break;
}
}
}
}
if (!exeToken || !via) return null;
const found: DetectedEmulator = {
id: def.id,
name: def.name,
template: def.template,
supportsArchives: def.supportsArchives,
contested: def.contested,
exeToken,
exePath,
appId,
via,
};
if (def.coresDir) {
const dirs =
env.os === "windows" ? def.coresDir.windows : def.coresDir.linux;
const { coresDir, cores } = scanCores(env, dirs);
found.coresDir = coresDir;
found.cores = cores;
}
return found;
};
/** Detect every emulator in the registry that is installed. */
export const detectAll = (
env: DetectEnv,
defs: Iterable<EmulatorDef> = EMULATORS,
): DetectedEmulator[] => {
const out: DetectedEmulator[] = [];
for (const def of defs) {
const d = detectEmulator(def, env);
if (d) out.push(d);
}
return out;
};
// ---------------------------------------------------------------- default DetectEnv (Bun/node)
const expandPathReal = (o: Os, p: string): string => {
let out = p;
if (out.startsWith("~")) out = path.join(os.homedir(), out.slice(1));
if (o === "windows") {
out = out.replace(
/%([^%]+)%/g,
(_m, name: string) => process.env[name] ?? "",
);
} else {
out = out.replace(
/\$(\w+)/g,
(_m, name: string) => process.env[name] ?? "",
);
}
return out;
};
/** Run a command and return whether it exited 0 (used for `flatpak info`). Bun-first. */
const runOk = (cmd: string, args: string[]): boolean => {
try {
const bun = (globalThis as { Bun?: typeof import("bun") }).Bun;
if (bun) {
const r = bun.spawnSync([cmd, ...args], {
stdout: "ignore",
stderr: "ignore",
});
return r.exitCode === 0;
}
} catch {
return false;
}
return false;
};
/** The production detection environment for the current host. */
export const realDetectEnv = (): DetectEnv => {
const o: Os = process.platform === "win32" ? "windows" : "linux";
return {
os: o,
which(cmd) {
const bun = (
globalThis as { Bun?: { which?: (c: string) => string | null } }
).Bun;
if (bun?.which) return bun.which(cmd);
return null;
},
fileExists(p) {
try {
return fs.existsSync(p);
} catch {
return false;
}
},
flatpakInstalled(appId) {
if (o === "windows") return false;
return runOk("flatpak", ["info", appId]);
},
listDir(p) {
try {
return fs.readdirSync(p);
} catch {
return [];
}
},
expandPath: (p) => expandPathReal(o, p),
};
};
+38
View File
@@ -0,0 +1,38 @@
// "Close the emulator when the stream ends" (design §6, default off). We attach a per-title `prep`
// whose `undo` kills the emulator at session end so a fullscreen emulator doesn't squat the desktop
// after streaming. `do` is a no-op (prep requires one; `undo` is skipped when `do` fails, so the do
// must succeed). Best-effort by process image name / Flatpak app id — targeted PID tracking is a
// stretch item.
import * as nodePath from "node:path";
import type { DetectedEmulator } from "../emulators.js";
import { type Os, quoteFor } from "../quote.js";
import type { PrepCmd } from "../wire.js";
/** A command that always exits 0, for `prep.do`. */
const noop = (os: Os): string => (os === "windows" ? "cd ." : "true");
/**
* A prep step that kills the emulator at session end, or `null` if we can't derive a safe kill target
* (e.g. a detectionless custom emulator). Kill is by image name (`pkill -x` / `taskkill /IM`) or
* `flatpak kill` — coarse but adequate for a single-session streaming box.
*/
export const killPrep = (
det: DetectedEmulator | undefined,
os: Os,
): PrepCmd | null => {
if (!det) return null;
if (det.via === "flatpak" && det.appId) {
return { do: noop(os), undo: `flatpak kill ${det.appId}` };
}
if (!det.exePath) return null;
const image = nodePath.basename(det.exePath);
if (os === "windows") {
const q = quoteFor("windows")(image);
if (!q.ok) return null;
return { do: noop(os), undo: `taskkill /IM ${q.value} /F` };
}
const q = quoteFor("linux")(image);
if (!q.ok) return null;
return { do: noop(os), undo: `pkill -x ${q.value}` };
};
+363
View File
@@ -0,0 +1,363 @@
// The sync engine orchestrator (design §3/§8): the headless half of the plugin. It ties the pure core
// (scanner → titles → reconcile) to the real world — filesystem scans, emulator detection, box-art
// warming, and the host reconcile PUT — and drives it on start, on an interval poll, on filesystem
// changes (debounced), and on demand from the UI/CLI. Fully functional from a hand-written
// `config.json` alone; the UI just edits the same config and pokes the same `sync()`.
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import type { Punktfunk } from "@punktfunk/host";
import { selectArtProvider, warmArt } from "../art/provider.js";
import type { Config } from "../config.js";
import {
type DetectEnv,
type DetectedEmulator,
detectAll,
realDetectEnv,
resolveEmulators,
} from "../emulators.js";
import { deleteProvider, reconcileProvider } from "../host.js";
import { type Logger, makeLogger } from "../log.js";
import { resolvePlatforms } from "../platforms.js";
import { currentOs, type Os } from "../quote.js";
import {
type Cache,
loadCache,
loadConfig,
saveCache,
statePaths,
} from "../state.js";
import {
computeDesired,
type DesiredResult,
enumerateTitles,
type SyncReport,
} from "./reconcile.js";
import {
type RomCandidate,
realScanFs,
type ScanFs,
scanRoot,
} from "./scanner.js";
export interface EngineDeps {
pf: Punktfunk;
os?: Os;
scanFs?: ScanFs;
detectEnv?: DetectEnv;
log?: Logger;
}
export interface EngineStatus {
rootsConfigured: number;
os: Os;
artProvider: string | null;
lastSync?: Cache["lastSync"];
lastReport?: SyncReport;
detected?: { at: number; emulators: DetectedEmulator[] };
paths: ReturnType<typeof statePaths>;
syncing: boolean;
}
/** A stable content fingerprint of the desired entries — lets an unchanged rescan skip the PUT. */
const fingerprint = (entries: unknown): string =>
createHash("sha256").update(JSON.stringify(entries)).digest("hex");
/** A synthetic entry proving the reconcile round-trip end-to-end (M0 dev flag / acceptance). */
const devEntry = (os: Os) => ({
external_id: "__dev__/hello",
title: "ROM Manager (dev entry)",
launch: {
kind: "command" as const,
value: os === "windows" ? "cmd /c echo hi" : "echo hi",
},
});
export class Engine {
private readonly pf: Punktfunk;
private readonly os: Os;
private readonly scanFs: ScanFs;
private readonly detectEnv: DetectEnv;
private readonly log: Logger;
private cache: Cache;
private syncing = false;
private pending = false;
private lastReport?: SyncReport;
private pollTimer?: ReturnType<typeof setInterval>;
private debounceTimer?: ReturnType<typeof setTimeout>;
private watchers: fs.FSWatcher[] = [];
private readonly listeners = new Set<(status: EngineStatus) => void>();
constructor(deps: EngineDeps) {
this.pf = deps.pf;
this.os = deps.os ?? currentOs();
this.scanFs = deps.scanFs ?? realScanFs;
this.detectEnv = deps.detectEnv ?? realDetectEnv();
this.log = deps.log ?? makeLogger();
this.cache = loadCache();
}
// ---------------------------------------------------------------- lifecycle
/** Initial sync, then wire the interval poll and (best-effort) filesystem watchers. */
async start(): Promise<void> {
await this.sync("startup");
this.schedulePoll();
this.setupWatchers();
}
/** Tear down timers and watchers (called from the plugin's shutdown finalizer). */
stop(): void {
if (this.pollTimer) clearInterval(this.pollTimer);
if (this.debounceTimer) clearTimeout(this.debounceTimer);
for (const w of this.watchers) {
try {
w.close();
} catch {
// already closed
}
}
this.watchers = [];
}
private schedulePoll(): void {
const config = loadConfig();
const ms = Math.max(1, config.sync.pollMinutes) * 60_000;
if (this.pollTimer) clearInterval(this.pollTimer);
this.pollTimer = setInterval(() => void this.sync("poll"), ms);
(this.pollTimer as { unref?: () => void }).unref?.();
}
private setupWatchers(): void {
for (const w of this.watchers) {
try {
w.close();
} catch {
// ignore
}
}
this.watchers = [];
const config = loadConfig();
if (!config.sync.watch) return;
for (const root of config.roots) {
try {
// `recursive` is honored on macOS/Windows; on Linux it throws and we watch the top dir
// only — the interval poll is the real safety net (watchers are unreliable on SMB/NFS).
const watcher = fs.watch(root.dir, { recursive: true }, () =>
this.debouncedSync(),
);
this.watchers.push(watcher);
} catch {
try {
this.watchers.push(fs.watch(root.dir, () => this.debouncedSync()));
} catch {
this.log(`watch: cannot watch ${root.dir} (poll only)`);
}
}
}
}
private debouncedSync(): void {
const config = loadConfig();
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(
() => void this.sync("fs-change"),
config.sync.debounceMs,
);
(this.debounceTimer as { unref?: () => void }).unref?.();
}
// ---------------------------------------------------------------- scan + detect
private scanAll(config: Config): RomCandidate[] {
const platforms = resolvePlatforms(config.platforms);
const out: RomCandidate[] = [];
for (const root of config.roots) {
const platform = platforms.get(root.platform);
if (!platform) {
this.log(
`scan: root ${root.dir} has unknown platform "${root.platform}" — skipped`,
);
continue;
}
try {
out.push(...scanRoot(this.scanFs, root.dir, platform, root.excludes));
} catch (e) {
this.log(`scan: ${root.dir}: ${e}`);
}
}
return out;
}
/** Emulator detection, cached until `force` (re-probed from the UI's "detect" button). */
detect(config: Config, force: boolean): DetectedEmulator[] {
if (
!force &&
this.cache.detect &&
this.cache.detect.os === this.os &&
this.cache.detect.emulators.length >= 0
) {
return this.cache.detect.emulators;
}
const defs = resolveEmulators(config.emulators);
const emulators = detectAll(this.detectEnv, defs.values());
this.cache.detect = { at: Date.now(), os: this.os, emulators };
saveCache(this.cache);
return emulators;
}
// ---------------------------------------------------------------- compute
/** Scan + detect + (cached) art → desired entries, WITHOUT touching the host. Used by the UI preview. */
async computeDry(
force = true,
): Promise<DesiredResult & { detected: DetectedEmulator[] }> {
const config = loadConfig();
const scan = this.scanAll(config);
const detected = this.detect(config, force);
await this.warm(config, scan);
const result = computeDesired({
config,
scan,
detected,
art: this.cache.art,
os: this.os,
});
return { ...result, detected };
}
/** Warm the art cache for everything the current scan will include (best-effort, provider-agnostic). */
private async warm(config: Config, scan: RomCandidate[]): Promise<void> {
const provider = selectArtProvider(config);
if (!provider) return;
const { survivors } = enumerateTitles(config, scan);
const targets = survivors.map((s) => ({
externalId: s.externalId,
platform: s.platform,
parsed: s.parsed,
}));
const n = await warmArt(provider, targets, this.cache);
if (n > 0) {
saveCache(this.cache);
this.log(`art: resolved ${n} title(s) via ${provider.id.split(":")[0]}`);
}
}
// ---------------------------------------------------------------- sync
/** The guarded reconcile: coalesces concurrent triggers, skips the PUT when nothing changed. */
async sync(reason: string): Promise<SyncReport | undefined> {
if (this.syncing) {
this.pending = true;
return undefined;
}
this.syncing = true;
try {
const config = loadConfig();
const scan = this.scanAll(config);
const detected = this.detect(config, false);
await this.warm(config, scan);
const result = computeDesired({
config,
scan,
detected,
art: this.cache.art,
os: this.os,
});
const entries = config.devEntry
? [...result.entries, devEntry(this.os)]
: result.entries;
const fp = fingerprint(entries);
if (this.cache.lastSync?.fingerprint !== fp) {
await reconcileProvider(this.pf, entries);
this.cache.lastSync = {
fingerprint: fp,
count: entries.length,
at: Date.now(),
};
saveCache(this.cache);
this.log(`sync (${reason}): reconciled ${entries.length} title(s)`);
} else {
this.log(`sync (${reason}): no changes (${entries.length} title(s))`);
}
this.lastReport = result.report;
this.logReport(result.report);
return result.report;
} catch (e) {
this.log(`sync (${reason}) failed: ${e}`);
return undefined;
} finally {
this.syncing = false;
this.notify();
if (this.pending) {
this.pending = false;
queueMicrotask(() => void this.sync("coalesced"));
}
}
}
/** Subscribe to status changes (the UI's SSE feed). Returns an unsubscribe function. */
subscribe(cb: (status: EngineStatus) => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
private notify(): void {
const status = this.status();
for (const cb of this.listeners) {
try {
cb(status);
} catch {
// a dead SSE listener must not break sync
}
}
}
private logReport(report: SyncReport): void {
if (report.skipped.length > 0) {
this.log(
` ${report.skipped.length} skipped (e.g. ${report.skipped[0]?.title}: ${report.skipped[0]?.reason})`,
);
}
if (report.overWarn) {
this.log(
` WARNING: ${report.included} entries — large library (design scale guard)`,
);
}
for (const w of report.warnings.slice(0, 5)) this.log(` ${w}`);
}
/** Reconfigure timers/watchers after a config change (e.g. the UI saved new roots). */
async reconfigure(): Promise<void> {
this.schedulePoll();
this.setupWatchers();
await this.sync("config-change");
}
/** Remove every entry this provider owns (CLI `uninstall`). */
async uninstall(): Promise<void> {
await deleteProvider(this.pf);
this.cache.lastSync = undefined;
saveCache(this.cache);
this.log("uninstalled: provider entries removed");
}
// ---------------------------------------------------------------- status
status(): EngineStatus {
const config = loadConfig();
const provider = selectArtProvider(config);
return {
rootsConfigured: config.roots.length,
os: this.os,
artProvider: provider ? (provider.id.split(":")[0] ?? null) : null,
lastSync: this.cache.lastSync,
lastReport: this.lastReport,
detected: this.cache.detect,
paths: statePaths(),
syncing: this.syncing,
};
}
}
+119
View File
@@ -0,0 +1,119 @@
// Resolve a candidate ROM + its effective emulator choice into a `LaunchSpec.value` — the one place a
// ROM path becomes a shell command, so it goes through the quoting seam (design §6/§10). A resolution
// either succeeds (a command string) or is skipped with a reason the UI/CLI surfaces; `warnings`
// carries non-fatal notes (e.g. the RetroArch core isn't installed yet).
import type { DetectedEmulator, EmulatorDef } from "../emulators.js";
import type { Platform } from "../platforms.js";
import { type Os, renderCommand } from "../quote.js";
import type { RomCandidate } from "./scanner.js";
/** The effective emulator choice for a title after layering platform default → override → per-game. */
export interface EffectiveLaunch {
emulator: string;
core?: string;
extraArgs?: string;
}
export type LaunchResolution =
| { ok: true; value: string; warnings: string[] }
| { ok: false; reason: string };
const coreFileSuffix = (os: Os): string => (os === "windows" ? ".dll" : ".so");
const joinPath = (os: Os, dir: string, name: string): string =>
os === "windows" ? `${dir}\\${name}` : `${dir}/${name}`;
/** Does a template reference a placeholder? */
const usesPlaceholder = (template: string, name: string): boolean =>
template.includes(`{${name}}`);
/**
* Resolve one candidate to a launch command. `detected` is the live emulator detection map; `defs` is
* the emulator registry (built-ins + operator `custom-*` templates) for detectionless custom emulators.
*/
export const resolveLaunch = (params: {
platform: Platform;
candidate: RomCandidate;
launch: EffectiveLaunch;
detected: Map<string, DetectedEmulator>;
defs: Map<string, EmulatorDef>;
os: Os;
}): LaunchResolution => {
const { candidate, launch, detected, defs, os } = params;
const warnings: string[] = [];
const det = detected.get(launch.emulator);
const def = defs.get(launch.emulator);
if (!det && !def) {
return { ok: false, reason: `unknown emulator "${launch.emulator}"` };
}
const template = det?.template ?? def?.template;
if (!template)
return {
ok: false,
reason: `emulator "${launch.emulator}" has no launch template`,
};
const supportsArchives =
det?.supportsArchives ?? def?.supportsArchives ?? false;
// Archive gating (design §5): a `.zip`/`.7z` is only launchable if the emulator can read it.
if (candidate.isArchive && !supportsArchives) {
return {
ok: false,
reason: `${candidate.ext} archive but ${launch.emulator} does not support archives`,
};
}
// The launcher token. Detected emulators carry it; a detectionless custom emulator must bake its
// executable into the template (no `{exe}`), else we cannot launch it.
let exe = det?.exeToken;
if (usesPlaceholder(template, "exe")) {
if (!exe)
return {
ok: false,
reason: `emulator "${launch.emulator}" not detected`,
};
} else {
exe = ""; // template supplies its own launcher
}
// The core (RetroArch). Build the full core path when we know the cores dir; fall back to the bare
// name (RetroArch can resolve it from its own config) with a warning.
let core: string | undefined;
if (usesPlaceholder(template, "core")) {
const coreName = launch.core;
if (!coreName) {
return {
ok: false,
reason: `no core configured for ${params.platform.id}`,
};
}
if (det?.coresDir) {
core = joinPath(
os,
det.coresDir,
`${coreName}_libretro${coreFileSuffix(os)}`,
);
if (det.cores && !det.cores.includes(coreName)) {
warnings.push(
`core "${coreName}" not found in ${det.coresDir} (install it in RetroArch)`,
);
}
} else {
core = coreName;
warnings.push(
`RetroArch cores dir unknown — passing core name "${coreName}" directly`,
);
}
}
const rendered = renderCommand(
template,
{ exe: exe ?? "", rom: candidate.absPath, core },
os,
launch.extraArgs,
);
if (!rendered.ok) return { ok: false, reason: rendered.reason };
return { ok: true, value: rendered.command, warnings };
};
+278
View File
@@ -0,0 +1,278 @@
// The desired-state compute (design §8) — a **pure function** of `config × scan results × detection ×
// art cache → ProviderEntryInput[]`, unit-testable with no host and no filesystem. The engine
// orchestrator (index.ts / cli.ts) does the I/O (scan, detect, art-probe) and hands the results here.
//
// `enumerateTitles` is factored out so the orchestrator can learn which titles need art (and warm the
// cache) BEFORE the final `computeDesired`, without re-implementing the exclude/dedupe rules.
import type { Config, GameOverride } from "../config.js";
import { type DetectedEmulator, resolveEmulators } from "../emulators.js";
import { type Platform, resolvePlatforms } from "../platforms.js";
import type { Os } from "../quote.js";
import type { ArtVerdict } from "../state.js";
import type { Artwork, ProviderEntryInput } from "../wire.js";
import { killPrep } from "./close.js";
import { type EffectiveLaunch, resolveLaunch } from "./launch.js";
import type { RomCandidate } from "./scanner.js";
import {
dedupeKey,
type ParsedTitle,
parseTitle,
pickBestRelease,
} from "./titles.js";
/** A candidate the compute rejected, with a human reason (surfaced in the UI/CLI). */
export interface Skipped {
external_id: string;
title: string;
reason: string;
}
/** One enumerated title (post-exclude, post-dedupe) — the unit both art-warming and reconcile key on. */
export interface EnumeratedTitle {
candidate: RomCandidate;
platform: Platform;
externalId: string;
parsed: ParsedTitle;
override: GameOverride | undefined;
}
/** A summary of one desired-state compute (design §8/§9 Sync page). */
export interface SyncReport {
/** Candidates considered (post-exclude, post-dedupe). */
considered: number;
/** Entries produced. */
included: number;
skipped: Skipped[];
/** Titles the operator explicitly excluded (per-game override) — surfaced so the UI can re-include. */
excluded: { external_id: string; title: string }[];
warnings: string[];
/** Entries dropped by the hard `maxEntries` cap. */
truncated: number;
/** Included-entry count per platform. */
perPlatform: Record<string, number>;
/** True once `included >= warnEntries` (UI scale warning). */
overWarn: boolean;
}
export interface DesiredResult {
entries: ProviderEntryInput[];
report: SyncReport;
}
/** Layer the effective launch: platform default ← per-platform override ← per-game override. */
const effectiveLaunch = (
platform: Platform,
config: Config,
override: GameOverride | undefined,
): EffectiveLaunch => {
const plat = config.platformLaunch[platform.id];
const base = plat ?? platform.defaultLaunch;
return {
emulator: override?.emulator ?? base.emulator,
core: override?.core ?? base.core,
extraArgs: override?.extraArgs ?? base.extraArgs,
};
};
/** Whether a platform has more than one root — the trigger for a root ordinal in its `external_id`s. */
const rootOrdinals = (config: Config): Map<string, Map<string, number>> => {
const perPlatform = new Map<string, string[]>();
for (const r of config.roots) {
const list = perPlatform.get(r.platform) ?? [];
if (!list.includes(r.dir)) list.push(r.dir);
perPlatform.set(r.platform, list);
}
const out = new Map<string, Map<string, number>>();
for (const [platform, dirs] of perPlatform) {
if (dirs.length <= 1) continue; // no disambiguation needed
out.set(platform, new Map(dirs.map((d, i) => [d, i])));
}
return out;
};
/**
* The stable `external_id` for a candidate: `<platform>/<relpath>`, with a `<root-ordinal>/` segment
* inserted only when the platform has multiple roots (design §4).
*/
const externalIdFor = (
candidate: RomCandidate,
ordinals: Map<string, Map<string, number>>,
): string => {
const ord = ordinals.get(candidate.platform)?.get(candidate.rootDir);
return ord === undefined
? `${candidate.platform}/${candidate.relPath}`
: `${candidate.platform}/${ord}/${candidate.relPath}`;
};
/**
* Enumerate the titles a scan yields after excludes and optional region dedupe. Pure and cheap
* (no launch resolution / no art) — the orchestrator calls this to know what art to warm.
*/
export const enumerateTitles = (
config: Config,
scan: RomCandidate[],
): {
survivors: EnumeratedTitle[];
skipped: Skipped[];
excluded: { external_id: string; title: string }[];
} => {
const platforms = resolvePlatforms(config.platforms);
const ordinals = rootOrdinals(config);
const skipped: Skipped[] = [];
const excluded: { external_id: string; title: string }[] = [];
// 1) Prepare: external_id, parsed title, resolve platform, drop excluded.
const prepared: EnumeratedTitle[] = [];
for (const candidate of scan) {
const externalId = externalIdFor(candidate, ordinals);
const parsed = parseTitle(candidate.stem);
const platform = platforms.get(candidate.platform);
if (!platform) {
skipped.push({
external_id: externalId,
title: parsed.displayTitle,
reason: `unknown platform "${candidate.platform}"`,
});
continue;
}
const override = config.gameOverrides[externalId];
if (override?.exclude) {
// Surfaced (not silent) so the Games UI can list and re-include it.
excluded.push({
external_id: externalId,
title: override.title ?? parsed.displayTitle,
});
continue;
}
prepared.push({ candidate, platform, externalId, parsed, override });
}
// 2) Optional region dedupe (per platform, opt-in).
const dedupeSet = new Set(config.sync.dedupeRegions);
const survivors: EnumeratedTitle[] = [];
const byGroup = new Map<string, EnumeratedTitle[]>();
for (const p of prepared) {
if (!dedupeSet.has(p.platform.id)) {
survivors.push(p);
continue;
}
const key = `${p.platform.id} ${dedupeKey(p.parsed)}`;
const list = byGroup.get(key) ?? [];
list.push(p);
byGroup.set(key, list);
}
for (const group of byGroup.values()) {
const best = pickBestRelease(group, config.sync.regionPriority);
survivors.push(best);
for (const p of group) {
if (p !== best) {
skipped.push({
external_id: p.externalId,
title: p.parsed.displayTitle,
reason: "deduped (another region preferred)",
});
}
}
}
return { survivors, skipped, excluded };
};
/** Merge cached art with a per-game portrait override; `undefined` if there is nothing to attach. */
const resolveArtwork = (
verdict: ArtVerdict | undefined,
override: GameOverride | undefined,
): Artwork | undefined => {
const base = verdict?.art ?? undefined;
if (!override?.art) return base ?? undefined;
return { ...(base ?? {}), portrait: override.art };
};
/**
* Compute the desired provider entries. Deterministic: entries are emitted in `external_id` order, so
* the `maxEntries` truncation is stable across runs.
*/
export const computeDesired = (params: {
config: Config;
scan: RomCandidate[];
detected: DetectedEmulator[];
art: Record<string, ArtVerdict>;
os: Os;
}): DesiredResult => {
const { config, scan, detected, art, os } = params;
const defs = resolveEmulators(config.emulators);
const detectedMap = new Map<string, DetectedEmulator>(
detected.map((d) => [d.id, d]),
);
const { survivors, skipped, excluded } = enumerateTitles(config, scan);
const warnings: string[] = [];
const entries: ProviderEntryInput[] = [];
const perPlatform: Record<string, number> = {};
for (const p of survivors) {
const launch = effectiveLaunch(p.platform, config, p.override);
const resolution = resolveLaunch({
platform: p.platform,
candidate: p.candidate,
launch,
detected: detectedMap,
defs,
os,
});
const title = p.override?.title ?? p.parsed.displayTitle;
if (!resolution.ok) {
skipped.push({
external_id: p.externalId,
title,
reason: resolution.reason,
});
continue;
}
for (const w of resolution.warnings) warnings.push(`${title}: ${w}`);
const entry: ProviderEntryInput = {
external_id: p.externalId,
title,
launch: { kind: "command", value: resolution.value },
};
const artwork = resolveArtwork(art[p.externalId], p.override);
if (artwork && Object.keys(artwork).length > 0) entry.art = artwork;
// Optional "close emulator when the stream ends" (default off).
if (config.sync.closeOnEnd) {
const prep = killPrep(detectedMap.get(launch.emulator), os);
if (prep) entry.prep = [prep];
}
entries.push(entry);
perPlatform[p.platform.id] = (perPlatform[p.platform.id] ?? 0) + 1;
}
// Deterministic order + scale guard.
entries.sort((a, b) => a.external_id.localeCompare(b.external_id));
let truncated = 0;
if (entries.length > config.sync.maxEntries) {
truncated = entries.length - config.sync.maxEntries;
entries.length = config.sync.maxEntries;
warnings.push(
`entry count ${truncated + config.sync.maxEntries} exceeds maxEntries=${config.sync.maxEntries}; ${truncated} dropped`,
);
}
return {
entries,
report: {
considered: survivors.length,
included: entries.length,
skipped,
excluded,
warnings,
truncated,
perPlatform,
overWarn: entries.length >= config.sync.warnEntries,
},
};
};
+270
View File
@@ -0,0 +1,270 @@
// The ROM scanner (design §5): walk a ROM root, apply excludes, and reduce the file tree to launchable
// candidates — folding multi-disc `.m3u` playlists and `.cue`/`.gdi` sheets so a disc set is ONE entry,
// not one per track. The filesystem is injected (`ScanFs`) so the whole thing is unit-testable against
// a synthetic tree with zero real I/O.
import * as fs from "node:fs";
import * as nodePath from "node:path";
import type { Platform } from "../platforms.js";
/** A directory entry the walker sees. */
export interface DirEnt {
name: string;
isDir: boolean;
isFile: boolean;
}
/** The filesystem surface the scanner needs — injected for tests. */
export interface ScanFs {
readDir(dir: string): DirEnt[];
readText(file: string): string;
}
/** One launchable candidate produced by the scan. */
export interface RomCandidate {
platform: string;
/** The root directory this file was found under. */
rootDir: string;
/** Absolute path to the launchable file (the `.m3u`/`.cue`/`.gdi`, or the ROM itself). */
absPath: string;
/** POSIX path of `absPath` relative to `rootDir` — the `external_id` suffix. */
relPath: string;
/** Lowercase extension of `absPath` (e.g. `.sfc`). */
ext: string;
/** Basename of `absPath` without extension — fed to the title parser. */
stem: string;
/** True for `.zip`/`.7z` — gated on emulator archive support at reconcile time. */
isArchive: boolean;
}
const ARCHIVE_EXT = new Set([".zip", ".7z"]);
const SHEET_EXT = new Set([".cue", ".gdi", ".ccd", ".mds"]);
const TRACK_EXT = new Set([".bin", ".img", ".sub", ".raw"]);
const lc = (s: string): string => s.toLowerCase();
const extOf = (name: string): string => lc(nodePath.extname(name));
const stemOf = (name: string): string =>
nodePath.basename(name, nodePath.extname(name));
const toPosix = (p: string): string => p.split(nodePath.sep).join("/");
// ---------------------------------------------------------------- glob excludes
/** Convert a gitignore-ish glob to an anchored regex (`**` = any depth, `*` = within a segment, `?`). */
const globToRegex = (glob: string): RegExp => {
let re = "";
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === "*") {
if (glob[i + 1] === "*") {
re += ".*";
i++;
if (glob[i + 1] === "/") i++; // `**/` swallows the slash
} else {
re += "[^/]*";
}
} else if (c === "?") {
re += "[^/]";
} else if (c && "\\^$.|+()[]{}".includes(c)) {
re += `\\${c}`;
} else {
re += c;
}
}
return new RegExp(`^${re}$`, "i");
};
/**
* gitignore-ish evaluation: patterns are excludes; a `!`-prefixed pattern re-includes. Last match
* wins. A bare pattern with no `/` matches the basename at any depth (like gitignore).
*/
export const makeExcluder = (
patterns: string[] | undefined,
): ((relPath: string) => boolean) => {
if (!patterns || patterns.length === 0) return () => false;
const compiled = patterns.map((p) => {
const negate = p.startsWith("!");
const body = negate ? p.slice(1) : p;
const anchored = body.includes("/");
return {
negate,
re: globToRegex(anchored ? body : `**/${body}`),
bare: body,
};
});
return (relPath: string): boolean => {
let excluded = false;
for (const { negate, re } of compiled) {
if (re.test(relPath)) excluded = !negate;
}
return excluded;
};
};
// ---------------------------------------------------------------- walk
interface FileRec {
absPath: string;
relPath: string; // posix, relative to root
dir: string; // absolute dir
name: string;
ext: string;
}
/** Recursively enumerate files under `rootDir`, applying excludes. Symlinked dirs are not followed. */
const walk = (
fsx: ScanFs,
rootDir: string,
exclude: (rel: string) => boolean,
): FileRec[] => {
const out: FileRec[] = [];
const recurse = (dir: string): void => {
let ents: DirEnt[];
try {
ents = fsx.readDir(dir);
} catch {
return;
}
for (const ent of ents) {
const abs = nodePath.join(dir, ent.name);
const rel = toPosix(nodePath.relative(rootDir, abs));
if (ent.isDir) {
if (exclude(`${rel}/`)) continue;
recurse(abs);
} else if (ent.isFile) {
if (exclude(rel)) continue;
out.push({
absPath: abs,
relPath: rel,
dir,
name: ent.name,
ext: extOf(ent.name),
});
}
}
};
recurse(rootDir);
return out;
};
// ---------------------------------------------------------------- disc folding
/** Parse the disc/track paths referenced by an `.m3u` (non-comment lines) or a `.cue`/`.gdi` sheet. */
const parseReferences = (fsx: ScanFs, file: FileRec): string[] => {
let text: string;
try {
text = fsx.readText(file.absPath);
} catch {
return [];
}
const refs: string[] = [];
if (file.ext === ".m3u") {
for (const line of text.split(/\r?\n/)) {
const t = line.trim();
if (t && !t.startsWith("#")) refs.push(t);
}
} else if (
file.ext === ".cue" ||
file.ext === ".ccd" ||
file.ext === ".mds"
) {
for (const m of text.matchAll(/FILE\s+"([^"]+)"/gi)) {
if (m[1]) refs.push(m[1]);
}
} else if (file.ext === ".gdi") {
// GDI: `<track> <lba> <type> <sectorSize> <filename> <offset>` — filename may be quoted.
for (const line of text.split(/\r?\n/)) {
const m = /(?:"([^"]+)"|(\S+\.(?:bin|raw|iso)))/i.exec(line.trim());
const f = m?.[1] ?? m?.[2];
if (f) refs.push(f);
}
}
// Resolve each reference relative to the sheet's directory, as a posix path key.
return refs.map((r) => nodePath.join(file.dir, r));
};
/**
* Fold disc images: `.m3u` playlists become one entry (their discs hidden); `.cue`/`.gdi` sheets become
* one entry (their tracks hidden); sheets referenced by a playlist are hidden too. Everything else that
* matches the platform's extensions is a loose candidate.
*/
const foldDiscs = (
fsx: ScanFs,
files: FileRec[],
platform: Platform,
): FileRec[] => {
const referencedByM3u = new Set<string>();
const referencedBySheet = new Set<string>();
for (const f of files) {
if (f.ext === ".m3u") {
for (const r of parseReferences(fsx, f)) referencedByM3u.add(r);
}
}
// Parse EVERY sheet for its tracks (even a sheet that a playlist references) so a `.bin` behind a
// cue is always hidden — whether the cue stands alone or lives inside an `.m3u`.
for (const f of files) {
if (SHEET_EXT.has(f.ext)) {
for (const r of parseReferences(fsx, f)) referencedBySheet.add(r);
}
}
const extSet = new Set(platform.extensions.map(lc));
const out: FileRec[] = [];
for (const f of files) {
if (referencedByM3u.has(f.absPath)) continue; // disc within a playlist
if (referencedBySheet.has(f.absPath)) continue; // track behind a sheet
if (f.ext === ".m3u") {
out.push(f);
continue;
}
if (SHEET_EXT.has(f.ext)) {
out.push(f); // a standalone sheet (its tracks are hidden above)
continue;
}
if (TRACK_EXT.has(f.ext) && !extSet.has(f.ext)) continue; // orphan track, not a platform ROM ext
if (ARCHIVE_EXT.has(f.ext) || extSet.has(f.ext)) out.push(f);
}
return out;
};
// ---------------------------------------------------------------- scan
/** Scan one ROM root into candidates. `platform` is the resolved platform for `rootDir`. */
export const scanRoot = (
fsx: ScanFs,
rootDir: string,
platform: Platform,
excludes: string[] | undefined,
): RomCandidate[] => {
const exclude = makeExcluder(excludes);
const files = walk(fsx, rootDir, exclude);
const extSet = new Set(platform.extensions.map(lc));
const chosen = platform.disc
? foldDiscs(fsx, files, platform)
: files.filter((f) => ARCHIVE_EXT.has(f.ext) || extSet.has(f.ext));
return chosen.map((f) => ({
platform: platform.id,
rootDir,
absPath: f.absPath,
relPath: f.relPath,
ext: f.ext,
stem: stemOf(f.name),
isArchive: ARCHIVE_EXT.has(f.ext),
}));
};
// ---------------------------------------------------------------- real ScanFs
/** The production filesystem, backed by node fs. Directory reads are cheap and non-recursive here. */
export const realScanFs: ScanFs = {
readDir(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).map((d) => ({
name: d.name,
isDir: d.isDirectory(),
isFile: d.isFile(),
}));
},
readText(file) {
return fs.readFileSync(file, "utf8");
},
};
+145
View File
@@ -0,0 +1,145 @@
// No-Intro / TOSEC title parsing (design §5, M2). A ROM filename like
// `Chrono Trigger (USA) (Rev 1) [!].sfc` carries a base title plus parenthetical/bracket tags. We
// derive two names from it:
// - **displayTitle** — the human title for the library grid (`Chrono Trigger`, article-normalized).
// - **noIntroName** — the original stem WITH tags, which is exactly how libretro-thumbnails names its
// box-art files, so the art matcher (design §7) keys on it.
// Region / revision are kept as metadata for the art matcher's fallback ladder and for optional
// per-title region dedupe (default off).
/** Recognised No-Intro region tokens (a parenthetical is a "region tag" iff every comma-part is one). */
const REGIONS = new Set([
"usa",
"europe",
"japan",
"world",
"australia",
"canada",
"brazil",
"korea",
"china",
"taiwan",
"hong kong",
"asia",
"germany",
"france",
"spain",
"italy",
"netherlands",
"sweden",
"norway",
"denmark",
"finland",
"portugal",
"russia",
"uk",
"greece",
"poland",
"scandinavia",
"latin america",
"unknown",
]);
export interface ParsedTitle {
/** Human display title (article-normalized, whitespace-collapsed). */
displayTitle: string;
/** The original filename stem (with tags) — the libretro-thumbnails art key. */
noIntroName: string;
/** The first recognised region, if any (best for art matching). */
region?: string;
/** All recognised regions across the tags. */
regions: string[];
/** Revision token (`Rev 1`, `v1.1`), if present. */
revision?: string;
/** Every parenthetical/bracket token, verbatim. */
tags: string[];
}
const REVISION_RE = /^(rev\b.*|v\d.*|beta\b.*|proto\b.*|alpha\b.*)/i;
/** Is a `(...)`/`[...]` token a region tag (each comma-separated part is a known region)? */
const isRegionTag = (token: string): boolean => {
const parts = token.split(",").map((p) => p.trim().toLowerCase());
return parts.length > 0 && parts.every((p) => REGIONS.has(p));
};
/** "Legend of Zelda, The" → "The Legend of Zelda"; "Games, The Best of" left alone. */
const normalizeArticle = (title: string): string => {
const m = /^(.*),\s+(The|A|An|Le|La|Les|Der|Die|Das|El|Los)$/i.exec(title);
return m?.[1] && m[2] ? `${m[2]} ${m[1]}` : title;
};
/**
* Parse a ROM basename (WITHOUT its extension) into a display title + metadata. `stem` is the file
* name minus extension; for disc sets it is the `.m3u`/`.cue` stem.
*/
export const parseTitle = (stem: string): ParsedTitle => {
const noIntroName = stem.trim();
// Collect every ( ) and [ ] token.
const tags: string[] = [];
for (const m of noIntroName.matchAll(/[([]([^)\]]*)[)\]]/g)) {
if (m[1] !== undefined) tags.push(m[1].trim());
}
// Base title = everything before the first tag opener.
const openIdx = noIntroName.search(/\s*[([]/);
let base = openIdx >= 0 ? noIntroName.slice(0, openIdx) : noIntroName;
base = base.replace(/_/g, " ").replace(/\s+/g, " ").trim();
base = normalizeArticle(base);
const regions: string[] = [];
let revision: string | undefined;
for (const t of tags) {
if (isRegionTag(t)) {
for (const p of t.split(",")) {
const r = p.trim();
if (r) regions.push(r);
}
} else if (revision === undefined && REVISION_RE.test(t)) {
revision = t;
}
}
return {
displayTitle: base || noIntroName,
noIntroName,
region: regions[0],
regions,
revision,
tags,
};
};
/**
* A dedupe grouping key: the base title lower-cased, ignoring region/revision — titles sharing it are
* the "same game, different release" for the optional region-dedupe pass (design §5).
*/
export const dedupeKey = (parsed: ParsedTitle): string =>
parsed.displayTitle.toLowerCase();
/**
* Pick the best release among duplicates by region priority (best-first list, e.g. `["USA","World",…]`);
* ties fall back to the shortest No-Intro name (fewest extra tags), then lexicographic for stability.
*/
export const pickBestRelease = <T extends { parsed: ParsedTitle }>(
candidates: T[],
regionPriority: string[],
): T => {
const rank = (c: T): number => {
const idx = c.parsed.regions
.map((r) =>
regionPriority.findIndex((p) => p.toLowerCase() === r.toLowerCase()),
)
.filter((i) => i >= 0)
.sort((a, b) => a - b)[0];
return idx === undefined ? regionPriority.length + 1 : idx;
};
return [...candidates].sort((a, b) => {
const ra = rank(a);
const rb = rank(b);
if (ra !== rb) return ra - rb;
const la = a.parsed.noIntroName.length;
const lb = b.parsed.noIntroName.length;
if (la !== lb) return la - lb;
return a.parsed.noIntroName.localeCompare(b.parsed.noIntroName);
})[0] as T;
};
+20
View File
@@ -0,0 +1,20 @@
// The host side of the reconcile (design §2). We PUT the declarative entry list to the provider
// endpoint and let the host diff by `external_id` (stable ids, orphan drop, manual entries untouched).
// Done through `pf.request` rather than the typed `pf.api.*` deliberately: under the packaged runner
// the facade is built by the runner's *bundled* SDK copy, whose generated client may predate an
// endpoint — the untyped request seam is version-skew-proof (same reasoning as `servePluginUi`).
import type { Punktfunk } from "@punktfunk/host";
import { PROVIDER_ID } from "./const.js";
import type { ProviderEntryInput } from "./wire.js";
/** Full-replace reconcile: the host makes the library match `entries` exactly for this provider. */
export const reconcileProvider = (
pf: Punktfunk,
entries: ProviderEntryInput[],
): Promise<unknown> =>
pf.request("PUT", `/library/provider/${PROVIDER_ID}`, entries);
/** Clean uninstall: remove every entry this provider owns. */
export const deleteProvider = (pf: Punktfunk): Promise<unknown> =>
pf.request("DELETE", `/library/provider/${PROVIDER_ID}`);
+65
View File
@@ -0,0 +1,65 @@
// The plugin entry (design §11). `main` is the plain-async form (NOT the Effect form): the packaged
// runner bundles its own effect/SDK copy and cross-instance Effect identity is unverified (design D3),
// so the async facade sidesteps it entirely. The runner supervises this with restart-on-crash and a
// structured SIGTERM shutdown; a direct `bun src/index.ts` run works too (bottom of file).
//
// Lifecycle: connect (done by the runner/facade) → start the sync engine → serve the UI (console-hosted
// by default, standalone if configured) → run until SIGTERM → deregister the UI and stop the engine.
// Provider entries are deliberately LEFT in the library on shutdown so the games survive plugin
// restarts and host reboots; a clean uninstall is the explicit `uninstall` CLI command.
import { connect, definePlugin, type Punktfunk } from "@punktfunk/host";
import { PLUGIN_NAME } from "./const.js";
import { Engine } from "./engine/index.js";
import { log } from "./log.js";
import { serveConsoleUi } from "./server/index.js";
import { serveStandaloneUi } from "./server/standalone.js";
import { loadConfig } from "./state.js";
import { readVersion } from "./version.js";
const plugin = definePlugin({
name: PLUGIN_NAME,
main: async (pf: Punktfunk) => {
const engine = new Engine({ pf });
const config = loadConfig();
// Headless engine first — the library syncs even if the UI can't start.
await engine.start();
let closeUi: () => Promise<void> = async () => {};
try {
if (config.ui.standalone) {
const handle = serveStandaloneUi(engine, config);
closeUi = handle.close;
} else {
const handle = await serveConsoleUi(pf, engine, {
version: readVersion(),
});
closeUi = handle.close;
log(`UI registered with the console (nav entry "${PLUGIN_NAME}")`);
}
} catch (e) {
log(`UI server failed to start (engine keeps running headless): ${e}`);
}
// Run until the runner (or a direct SIGINT) asks us to stop. The runner sends SIGTERM on
// shutdown; both its handler and ours fire, and this resolves.
await new Promise<void>((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
log("shutting down");
await closeUi();
engine.stop();
},
});
export default plugin;
// Allow a direct `bun src/index.ts` run outside the managed runner (dev).
if (import.meta.main) {
const pf = await connect();
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
pf.close();
}
+10
View File
@@ -0,0 +1,10 @@
// A tiny stamped logger, matching the runner's line format so a plugin's output reads consistently in
// the runner journal. `child` prefixes a scope (e.g. `[scan]`).
export type Logger = (line: string) => void;
export const makeLogger = (prefix = "rom-manager"): Logger => {
return (line: string) =>
console.log(`${new Date().toISOString()} [${prefix}] ${line}`);
};
export const log: Logger = makeLogger();
+28
View File
@@ -0,0 +1,28 @@
// Where the plugin's own files live. The host config dir resolution mirrors the SDK's `configDir`
// (`@punktfunk/host` does not re-export it) so we always land in the same place the host and runner
// use; the plugin owns a `rom-manager/` subtree under it (design §9), created 0700.
import * as os from "node:os";
import * as path from "node:path";
/** The host's config dir — the same resolution the host and SDK use. */
export const hostConfigDir = (): string => {
const explicit = process.env.PUNKTFUNK_CONFIG_DIR;
if (explicit) return explicit;
if (process.platform === "win32") {
const base = process.env.ProgramData ?? process.env.APPDATA ?? ".";
return path.join(base, "punktfunk");
}
const base =
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
return path.join(base, "punktfunk");
};
/** This plugin's private directory: `<config_dir>/rom-manager`. */
export const pluginDir = (): string =>
path.join(hostConfigDir(), "rom-manager");
/** `<config_dir>/rom-manager/config.json` — operator-editable, atomic-written. */
export const configPath = (): string => path.join(pluginDir(), "config.json");
/** `<config_dir>/rom-manager/cache.json` — disposable derived state (art verdicts, detection, fingerprint). */
export const cachePath = (): string => path.join(pluginDir(), "cache.json");
+252
View File
@@ -0,0 +1,252 @@
// The console/platform registry (design §5) — shipped as data, operator-extensible via config. Each
// entry declares the file extensions that identify a ROM for that platform, the libretro-thumbnails
// system name used for box art (design §7), and the default emulator/core to launch it with.
//
// Extensions like `.iso` and `.bin` are shared across platforms — that ambiguity is resolved by the
// scanner's unit of work being a **ROM root** `(directory, platform)`: a `.iso` under a `gamecube`
// root is a GameCube disc, under a `ps2` root a PS2 disc. Extensions here are lowercase, dot-prefixed.
import type { DefaultLaunch } from "./emulators.js";
export interface Platform {
/** Stable kebab-case id — the first segment of every `external_id` (`snes/Chrono Trigger.sfc`). */
id: string;
/** Human-readable name (console nav, UI). */
name: string;
/** Lowercase, dot-prefixed extensions that mark a file as this platform's ROM. */
extensions: string[];
/**
* The libretro-thumbnails system directory name (design §7) — the box-art source key. `undefined`
* for platforms libretro has no thumbnail set for (Switch, Xbox), which simply get no cover in v1.
*/
libretroSystem?: string;
/** Default emulator + core for this platform (operator-overridable per platform / per game). */
defaultLaunch: DefaultLaunch;
/**
* Disc-based platform: `.cue`/`.gdi`/`.m3u` folding applies and loose `.bin` track files are
* hidden behind their sheet (design §5, M2). Cartridge platforms leave this false.
*/
disc?: boolean;
}
/** The built-in platform set (~25 at launch). Arcade (MAME/FBNeo romset versioning) is deferred (D6). */
export const PLATFORMS: Platform[] = [
// ── Nintendo ──────────────────────────────────────────────────────────────────────────────
{
id: "nes",
name: "Nintendo Entertainment System",
extensions: [".nes", ".unf", ".unif", ".fds"],
libretroSystem: "Nintendo - Nintendo Entertainment System",
defaultLaunch: { emulator: "retroarch", core: "mesen" },
},
{
id: "snes",
name: "Super Nintendo",
extensions: [".sfc", ".smc", ".bs"],
libretroSystem: "Nintendo - Super Nintendo Entertainment System",
defaultLaunch: { emulator: "retroarch", core: "snes9x" },
},
{
id: "n64",
name: "Nintendo 64",
extensions: [".n64", ".z64", ".v64"],
libretroSystem: "Nintendo - Nintendo 64",
defaultLaunch: { emulator: "retroarch", core: "mupen64plus_next" },
},
{
id: "gamecube",
name: "GameCube",
extensions: [".iso", ".rvz", ".gcm", ".gcz", ".ciso"],
libretroSystem: "Nintendo - GameCube",
defaultLaunch: { emulator: "dolphin" },
disc: true,
},
{
id: "wii",
name: "Wii",
extensions: [".iso", ".rvz", ".wbfs", ".wad", ".gcz", ".ciso"],
libretroSystem: "Nintendo - Wii",
defaultLaunch: { emulator: "dolphin" },
disc: true,
},
{
id: "switch",
name: "Nintendo Switch",
extensions: [".nsp", ".xci", ".nca"],
// libretro has no thumbnail set for Switch — no cover in v1.
defaultLaunch: { emulator: "ryujinx" },
},
{
id: "gb",
name: "Game Boy",
extensions: [".gb"],
libretroSystem: "Nintendo - Game Boy",
defaultLaunch: { emulator: "retroarch", core: "gambatte" },
},
{
id: "gbc",
name: "Game Boy Color",
extensions: [".gbc"],
libretroSystem: "Nintendo - Game Boy Color",
defaultLaunch: { emulator: "retroarch", core: "gambatte" },
},
{
id: "gba",
name: "Game Boy Advance",
extensions: [".gba", ".srl"],
libretroSystem: "Nintendo - Game Boy Advance",
defaultLaunch: { emulator: "retroarch", core: "mgba" },
},
{
id: "nds",
name: "Nintendo DS",
extensions: [".nds", ".dsi"],
libretroSystem: "Nintendo - Nintendo DS",
defaultLaunch: { emulator: "retroarch", core: "melonds" },
},
{
id: "3ds",
name: "Nintendo 3DS",
extensions: [".3ds", ".cci", ".cxi", ".cia"],
libretroSystem: "Nintendo - Nintendo 3DS",
defaultLaunch: { emulator: "azahar" },
},
// ── Sony ──────────────────────────────────────────────────────────────────────────────────
{
id: "ps1",
name: "PlayStation",
extensions: [
".cue",
".bin",
".img",
".pbp",
".chd",
".ecm",
".m3u",
".iso",
],
libretroSystem: "Sony - PlayStation",
defaultLaunch: { emulator: "duckstation" },
disc: true,
},
{
id: "ps2",
name: "PlayStation 2",
extensions: [".iso", ".chd", ".cso", ".gz", ".cue", ".bin", ".m3u"],
libretroSystem: "Sony - PlayStation 2",
defaultLaunch: { emulator: "pcsx2" },
disc: true,
},
{
id: "psp",
name: "PlayStation Portable",
extensions: [".iso", ".cso", ".pbp", ".chd"],
libretroSystem: "Sony - PlayStation Portable",
defaultLaunch: { emulator: "ppsspp" },
disc: true,
},
// ── Sega ──────────────────────────────────────────────────────────────────────────────────
{
id: "genesis",
name: "Genesis / Mega Drive",
extensions: [".md", ".gen", ".smd", ".bin", ".sgd"],
libretroSystem: "Sega - Mega Drive - Genesis",
defaultLaunch: { emulator: "retroarch", core: "genesis_plus_gx" },
},
{
id: "sms",
name: "Master System",
extensions: [".sms"],
libretroSystem: "Sega - Master System - Mark III",
defaultLaunch: { emulator: "retroarch", core: "genesis_plus_gx" },
},
{
id: "gamegear",
name: "Game Gear",
extensions: [".gg"],
libretroSystem: "Sega - Game Gear",
defaultLaunch: { emulator: "retroarch", core: "genesis_plus_gx" },
},
{
id: "saturn",
name: "Saturn",
extensions: [".cue", ".bin", ".chd", ".ccd", ".mds", ".m3u", ".iso"],
libretroSystem: "Sega - Saturn",
defaultLaunch: { emulator: "retroarch", core: "mednafen_saturn" },
disc: true,
},
{
id: "dreamcast",
name: "Dreamcast",
extensions: [".gdi", ".cdi", ".chd", ".cue", ".m3u"],
libretroSystem: "Sega - Dreamcast",
defaultLaunch: { emulator: "flycast" },
disc: true,
},
// ── Other ─────────────────────────────────────────────────────────────────────────────────
{
id: "pcengine",
name: "PC Engine / TurboGrafx-16",
extensions: [".pce", ".sgx", ".cue", ".chd", ".ccd", ".m3u"],
libretroSystem: "NEC - PC Engine - TurboGrafx 16",
defaultLaunch: { emulator: "retroarch", core: "mednafen_pce" },
disc: true,
},
{
id: "ngp",
name: "Neo Geo Pocket",
extensions: [".ngp", ".ngc"],
libretroSystem: "SNK - Neo Geo Pocket Color",
defaultLaunch: { emulator: "retroarch", core: "mednafen_ngp" },
},
{
id: "wonderswan",
name: "WonderSwan",
extensions: [".ws", ".wsc"],
libretroSystem: "Bandai - WonderSwan Color",
defaultLaunch: { emulator: "retroarch", core: "mednafen_wswan" },
},
{
id: "lynx",
name: "Atari Lynx",
extensions: [".lnx"],
libretroSystem: "Atari - Lynx",
defaultLaunch: { emulator: "retroarch", core: "mednafen_lynx" },
},
{
id: "atari2600",
name: "Atari 2600",
extensions: [".a26"],
libretroSystem: "Atari - 2600",
defaultLaunch: { emulator: "retroarch", core: "stella" },
},
{
id: "atari7800",
name: "Atari 7800",
extensions: [".a78"],
libretroSystem: "Atari - 7800",
defaultLaunch: { emulator: "retroarch", core: "prosystem" },
},
{
id: "xbox",
name: "Xbox",
extensions: [".iso", ".xbe"],
// No libretro thumbnail set — no cover in v1.
defaultLaunch: { emulator: "xemu" },
disc: true,
},
];
const BY_ID = new Map(PLATFORMS.map((p) => [p.id, p]));
/** Look up a built-in platform by id. */
export const platformById = (id: string): Platform | undefined => BY_ID.get(id);
/** Merge the built-in registry with operator-defined platforms (config `platforms[]` override by id). */
export const resolvePlatforms = (
overrides?: Platform[],
): Map<string, Platform> => {
const merged = new Map(BY_ID);
for (const p of overrides ?? []) merged.set(p.id, p);
return merged;
};
+130
View File
@@ -0,0 +1,130 @@
// The security-critical seam (design §10.1). ROM *filenames* are untrusted input that ends up inside
// a `sh -c` / `cmd.exe /c` string executed as the host user. Every path that reaches a
// `LaunchSpec.value` MUST go through here — never string-concatenate an un-quoted path.
//
// - POSIX (`sh -c`): single-quote wrapping is *fully general* — inside single quotes every byte is
// literal, and the one escape needed is the single quote itself (`'` → `'\''`). Nothing is ever
// refused (a NUL can't appear in an argv anyway; we reject it defensively).
// - Windows (`cmd.exe /c`): there is **no** fully-safe general quoting, so we double-quote AND
// refuse a hostile-character list (`" % ! ^ & | < >` + control chars). A refused ROM is skipped
// with a loud warning rather than risking command injection (design §6).
export type Os = "linux" | "windows";
/** The current host OS in this module's terms (macOS is not a punktfunk host, so anything non-win32 is POSIX). */
export const currentOs = (): Os =>
process.platform === "win32" ? "windows" : "linux";
export type QuoteResult =
| { ok: true; value: string }
| { ok: false; reason: string };
/** POSIX single-quote escaping — total, never refuses (except an impossible embedded NUL). */
export const quotePosix = (s: string): QuoteResult => {
if (s.includes("\0"))
return { ok: false, reason: "path contains a NUL byte" };
// Close the quote, emit an escaped literal quote, reopen: 'it'\''s' → it's
return { ok: true, value: `'${s.replace(/'/g, "'\\''")}'` };
};
// `cmd.exe /c` metacharacters we cannot neutralise by quoting: env expansion (`%`), delayed
// expansion (`!`), the escape char (`^`), redirection/piping (`& | < >`), and the quote itself.
const WINDOWS_HOSTILE = /["%!^&|<>]/;
/** True if `s` contains any C0 control character (< 0x20) — refused on Windows command lines. */
const hasControlChar = (s: string): boolean => {
for (let i = 0; i < s.length; i++) {
if (s.charCodeAt(i) < 0x20) return true;
}
return false;
};
/**
* Windows double-quoting with a hostile-name refusal list. Returns `{ok:false}` for any name that
* `cmd.exe` could interpret — the caller skips that ROM and surfaces the reason.
*/
export const quoteWindows = (s: string): QuoteResult => {
if (hasControlChar(s)) {
return { ok: false, reason: "path contains control characters" };
}
const m = WINDOWS_HOSTILE.exec(s);
if (m) {
return {
ok: false,
reason: `path contains a character unsafe for cmd.exe (${JSON.stringify(m[0])})`,
};
}
// A run of backslashes immediately before the closing quote must be doubled, or the CRT argv
// parser reads `\"` as an escaped quote and the argument never terminates (MSDN "Parsing C
// Command-Line Arguments"). Paths rarely end in `\`, but be correct anyway.
const trailing = /\\+$/.exec(s);
const body = trailing
? s.slice(0, -trailing[0].length) + trailing[0].repeat(2)
: s;
return { ok: true, value: `"${body}"` };
};
/** The quoting function for a given host OS. */
export const quoteFor = (os: Os): ((s: string) => QuoteResult) =>
os === "windows" ? quoteWindows : quotePosix;
export type RenderResult =
| { ok: true; command: string }
| { ok: false; reason: string };
export interface RenderVars {
/**
* The launcher token, inserted **verbatim** (already a valid, trusted command fragment): either a
* per-OS-quoted absolute path or a wrapper like `flatpak run org.libretro.RetroArch`. Produced by
* the emulator detector (§ `emulators.ts`), never from untrusted ROM data — so it is not re-quoted
* here (quoting a multi-word `flatpak run …` would break it).
*/
exe: string;
/** The ROM path — **untrusted** filename data; quoted per-OS (or refused on Windows). */
rom: string;
/** The core path (RetroArch) — semi-trusted; quoted per-OS. */
core?: string;
}
/**
* Render a launch command from a template with `{exe}`, `{core}`, `{rom}` placeholders. `{rom}` and
* `{core}` are quoted per-OS (a hostile Windows value fails the whole render → the ROM is skipped);
* `{exe}` is a trusted pre-formed token inserted verbatim. `extraArgs` is operator-typed trusted
* input, appended verbatim (design §6).
*/
export const renderCommand = (
template: string,
vars: RenderVars,
os: Os,
extraArgs?: string,
): RenderResult => {
const quote = quoteFor(os);
const subs = new Map<string, string>();
subs.set("exe", vars.exe); // verbatim — trusted launcher token
for (const [key, value] of [
["rom", vars.rom],
["core", vars.core],
] as const) {
if (value === undefined) continue;
const q = quote(value);
if (!q.ok) return { ok: false, reason: `${key}: ${q.reason}` };
subs.set(key, q.value);
}
// Every `{placeholder}` in the template must resolve — an unknown one is a template bug, not a
// runtime skip.
for (const match of template.matchAll(/\{(\w+)\}/g)) {
const key = match[1];
if (key !== undefined && !subs.has(key)) {
return {
ok: false,
reason: `template placeholder {${key}} has no value`,
};
}
}
const command = template.replace(
/\{(\w+)\}/g,
(_all, key: string) => subs.get(key) ?? "",
);
const extra = extraArgs?.trim();
return { ok: true, command: extra ? `${command} ${extra}` : command };
};
+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);
},
};
};
+107
View File
@@ -0,0 +1,107 @@
// Config/cache persistence (design §9): the plugin owns two files under `<config_dir>/rom-manager/`,
// created 0700. `config.json` is operator-editable and atomically rewritten (temp + rename); the
// plugin refuses a group/world-writable `config.json` (design §10.3 - the same sshd rule the runner
// and hooks enforce, since the UI can rewrite launch templates => config = host-user code). `cache.json`
// is disposable derived state.
import * as fs from "node:fs";
import * as path from "node:path";
import { type Config, type RawConfig, resolveConfig } from "./config.js";
import type { DetectedEmulator } from "./emulators.js";
import { cachePath, configPath, pluginDir } from "./paths.js";
import type { Artwork } from "./wire.js";
/** One cached art verdict: the resolved artwork (or `null` = nothing found), tagged with the provider
* and its matcher version so a provider switch or an algorithm bump re-resolves (design §7). */
export interface ArtVerdict {
art: Artwork | null;
/** The provider that produced this verdict (`steamgriddb` | `libretro`). */
provider: string;
/** The provider's matcher version at resolve time. */
matcher: number;
}
export interface Cache {
/** Art verdicts keyed by `external_id` (provider-agnostic, stable across rescans). */
art: Record<string, ArtVerdict>;
/** Last emulator detection result (re-probed on demand). */
detect?: { at: number; os: string; emulators: DetectedEmulator[] };
/** Fingerprint + count of the last reconcile - lets a rescan skip an unchanged PUT (design §8). */
lastSync?: { fingerprint: string; count: number; at: number };
}
export const emptyCache = (): Cache => ({ art: {} });
/** Create the plugin dir 0700 if absent (idempotent). */
const ensureDir = (): void => {
fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 });
};
/** Refuse a group/world-writable config file (POSIX only; Windows config dir is DACL'd). */
const assertNotWorldWritable = (file: string): void => {
if (process.platform === "win32") return;
let mode: number;
try {
mode = fs.statSync(file).mode;
} catch {
return; // absent - nothing to guard
}
if ((mode & 0o022) !== 0) {
throw new Error(
`refusing ${file}: it is group/world-writable (chmod go-w it first) - this file controls commands run as the host user`,
);
}
};
/** Atomic write: temp file in the same dir, then rename. */
const atomicWrite = (file: string, data: string): void => {
ensureDir();
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, data, { mode: 0o600 });
fs.renameSync(tmp, file);
};
/** Load the authored config (defaults filled). A missing file yields the empty config. */
export const loadConfig = (): Config => {
const file = configPath();
let raw = "";
try {
raw = fs.readFileSync(file, "utf8");
} catch {
return resolveConfig({});
}
assertNotWorldWritable(file);
const parsed = JSON.parse(raw) as RawConfig;
return resolveConfig(parsed);
};
/** Persist a config (atomic). The engine round-trips the *resolved* shape; defaults are re-elided on load. */
export const saveConfig = (config: Config): void => {
atomicWrite(configPath(), `${JSON.stringify(config, null, 2)}\n`);
};
/** Save the raw (operator-authored) config verbatim - the shape the UI edits. */
export const saveRawConfig = (raw: RawConfig): void => {
atomicWrite(configPath(), `${JSON.stringify(raw, null, 2)}\n`);
};
export const loadCache = (): Cache => {
try {
const parsed = JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache;
return { ...parsed, art: parsed.art ?? {} };
} catch {
return emptyCache();
}
};
export const saveCache = (cache: Cache): void => {
atomicWrite(cachePath(), JSON.stringify(cache));
};
/** Absolute paths, exported for the UI/CLI status view. */
export const statePaths = () => ({
dir: pluginDir(),
config: configPath(),
cache: cachePath(),
relConfig: path.basename(configPath()),
});
+16
View File
@@ -0,0 +1,16 @@
// Best-effort read of the plugin's own version from package.json (shown in the console page header).
// `../package.json` resolves to the repo root whether this module runs from `src/` or `dist/`.
import * as fs from "node:fs";
import { fileURLToPath } from "node:url";
export const readVersion = (): string | undefined => {
try {
const file = fileURLToPath(new URL("../package.json", import.meta.url));
const pkg = JSON.parse(fs.readFileSync(file, "utf8")) as {
version?: string;
};
return pkg.version;
} catch {
return undefined;
}
};
+33
View File
@@ -0,0 +1,33 @@
// The host provider-reconcile wire shapes (mirrors `@punktfunk/host`'s generated `ProviderEntryInput`
// et al. — the facade doesn't re-export them, and we PUT the raw array via `pf.request`, so we own the
// shape here). Kept minimal and stable; the host validates it.
/** Cover art — all URLs. The console/clients prefer `portrait` for a grid (design §7). */
export interface Artwork {
portrait?: string | null;
hero?: string | null;
logo?: string | null;
header?: string | null;
}
/** How the host launches a title. For this plugin always `{ kind: "command", value: <shell command> }`. */
export interface LaunchSpec {
kind: "command";
value: string;
}
/** A per-title prep/undo step (design §6): `do` runs before launch, `undo` at session end (reverse order). */
export interface PrepCmd {
do: string;
undo?: string | null;
}
/** One title in the declarative reconcile payload (`PUT /api/v1/library/provider/rom-manager`). */
export interface ProviderEntryInput {
/** The provider's stable id for this title — the reconcile diff key (design §4: `<platform>/<relpath>`). */
external_id: string;
title: string;
art?: Artwork;
launch?: LaunchSpec | null;
prep?: PrepCmd[];
}