refactor: workspace restructure — contract package + pure domain ported, tests green
- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-rom-manager",
|
||||
"version": "0.3.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"description": "Punktfunk plugin: scans ROM directories, maps them to emulators, and reconciles them into the host game library as a provider — with a console-hosted web UI. The reference plugin built on @punktfunk/plugin-kit.",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.unom.io/unom/punktfunk-plugin-rom-manager.git"
|
||||
},
|
||||
"keywords": [
|
||||
"punktfunk",
|
||||
"plugin",
|
||||
"roms",
|
||||
"emulators",
|
||||
"retroarch",
|
||||
"game-streaming"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./types/index.d.ts",
|
||||
"bin": {
|
||||
"punktfunk-plugin-rom-manager": "./dist/cli.js"
|
||||
},
|
||||
"files": ["dist", "types"],
|
||||
"publishConfig": {
|
||||
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"build": "bun build src/index.ts src/cli.ts --target=bun --outdir dist --external effect --external '@punktfunk/*'",
|
||||
"build:ui": "cd ../ui && bun run build",
|
||||
"build:all": "bun run build && bun run build:ui",
|
||||
"dev": "bun src/dev.ts",
|
||||
"prepublishOnly": "bun run build:all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@punktfunk/host": "^0.1.2",
|
||||
"@punktfunk/plugin-kit": "^0.1.1",
|
||||
"effect": "^4.0.0-beta.99"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rom-manager/contract": "workspace:*",
|
||||
"@types/bun": "^1.3.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -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 "../domain/titles.js";
|
||||
import type { Platform } from "../domain/platforms.js";
|
||||
import type { Artwork } from "@punktfunk/plugin-kit/wire";
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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 { Artwork } from "@punktfunk/plugin-kit/wire";
|
||||
import type { Config } from "@rom-manager/contract";
|
||||
import type { Platform } from "../domain/platforms.js";
|
||||
import type { ParsedTitle } from "../domain/titles.js";
|
||||
import { LibretroProvider } from "./libretro.js";
|
||||
import { SteamGridDbProvider } from "./steamgriddb.js";
|
||||
|
||||
export type ArtLog = (line: string) => void;
|
||||
const noopLog: ArtLog = () => {};
|
||||
|
||||
/** One cached art verdict: the resolved artwork (or `null` = nothing found), tagged with
|
||||
* the provider and its matcher version so a provider switch or algorithm bump re-resolves. */
|
||||
export interface ArtVerdict {
|
||||
art: Artwork | null;
|
||||
provider: string;
|
||||
matcher: number;
|
||||
}
|
||||
|
||||
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,
|
||||
log: ArtLog = noopLog,
|
||||
): 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, fetch, log);
|
||||
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, fetch, log)
|
||||
: 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; log?: ArtLog },
|
||||
): Promise<number> => {
|
||||
const log = opts?.log ?? noopLog;
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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 "../domain/titles.js";
|
||||
|
||||
import type { Platform } from "../domain/platforms.js";
|
||||
import type { Artwork } from "@punktfunk/plugin-kit/wire";
|
||||
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,
|
||||
private readonly log: (line: string) => void = () => {},
|
||||
) {
|
||||
// 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;
|
||||
this.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[]>,
|
||||
]);
|
||||
|
||||
// Built mutable, returned as the (readonly) wire type.
|
||||
const art: {
|
||||
portrait?: string;
|
||||
hero?: string;
|
||||
logo?: string;
|
||||
header?: string;
|
||||
} = {};
|
||||
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;
|
||||
};
|
||||
@@ -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 { PrepStep } from "@punktfunk/plugin-kit/wire";
|
||||
|
||||
/** 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,
|
||||
): PrepStep | 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}` };
|
||||
};
|
||||
@@ -0,0 +1,384 @@
|
||||
// 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";
|
||||
// The DefaultLaunch/EmulatorDef/DetectedEmulator TYPES are contract-owned (shared with
|
||||
// the UI); this module owns the DATA and the detection logic (DetectEnv stays an
|
||||
// injectable IO seam local to the plugin).
|
||||
import type {
|
||||
DefaultLaunch,
|
||||
DetectedEmulator,
|
||||
EmulatorDef,
|
||||
} from "@rom-manager/contract";
|
||||
import { type Os, quoteFor } from "./quote.js";
|
||||
|
||||
export type { DefaultLaunch, DetectedEmulator, EmulatorDef };
|
||||
|
||||
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?: ReadonlyArray<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;
|
||||
}
|
||||
|
||||
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: ReadonlyArray<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;
|
||||
|
||||
// Built mutable, returned as the (readonly) contract type.
|
||||
const found: {
|
||||
-readonly [K in keyof DetectedEmulator]: DetectedEmulator[K];
|
||||
} = {
|
||||
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),
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
// 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.
|
||||
|
||||
// The Platform TYPE is contract-owned (shared with the UI); this module owns the DATA.
|
||||
import type { Platform } from "@rom-manager/contract";
|
||||
|
||||
export type { Platform };
|
||||
|
||||
/** 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?: ReadonlyArray<Platform>,
|
||||
): Map<string, Platform> => {
|
||||
const merged = new Map(BY_ID);
|
||||
for (const p of overrides ?? []) merged.set(p.id, p);
|
||||
return merged;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
// The desired-state compute (design §8) — a **pure function** of `config × scan results × detection ×
|
||||
// art cache → ProviderEntry[]`, 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 "@rom-manager/contract";
|
||||
import { type DetectedEmulator, resolveEmulators } from "./emulators.js";
|
||||
import { type Platform, resolvePlatforms } from "./platforms.js";
|
||||
import type { Os } from "./quote.js";
|
||||
import type { ArtVerdict } from "../art/provider.js";
|
||||
import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
|
||||
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: ProviderEntry[];
|
||||
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: ProviderEntry[] = [];
|
||||
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}`);
|
||||
|
||||
// Built mutable, collected as the (readonly) wire type.
|
||||
const entry: {
|
||||
-readonly [K in keyof ProviderEntry]: ProviderEntry[K];
|
||||
} = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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: ReadonlyArray<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;
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type DetectEnv,
|
||||
detectEmulator,
|
||||
emulatorById,
|
||||
} from "../src/domain/emulators.js";
|
||||
import type { Os } from "../src/domain/quote.js";
|
||||
|
||||
const env = (o: {
|
||||
os?: Os;
|
||||
which?: Record<string, string>;
|
||||
files?: string[];
|
||||
flatpaks?: string[];
|
||||
dirs?: Record<string, string[]>;
|
||||
}): DetectEnv => ({
|
||||
os: o.os ?? "linux",
|
||||
which: (c) => o.which?.[c] ?? null,
|
||||
fileExists: (p) => (o.files ?? []).includes(p),
|
||||
flatpakInstalled: (id) => (o.flatpaks ?? []).includes(id),
|
||||
listDir: (p) => o.dirs?.[p] ?? [],
|
||||
expandPath: (p) =>
|
||||
p
|
||||
.replace(/^~/, "/home/u")
|
||||
.replace(/%APPDATA%/, "C:\\Users\\u\\AppData\\Roaming")
|
||||
.replace(/%LOCALAPPDATA%/, "C:\\Users\\u\\AppData\\Local"),
|
||||
});
|
||||
|
||||
const retroarch = emulatorById("retroarch")!;
|
||||
const ppsspp = emulatorById("ppsspp")!;
|
||||
const dolphin = emulatorById("dolphin")!;
|
||||
|
||||
describe("detectEmulator", () => {
|
||||
test("RetroArch on PATH, cores scanned + quoted exe token", () => {
|
||||
const d = detectEmulator(
|
||||
retroarch,
|
||||
env({
|
||||
which: { retroarch: "/usr/bin/retroarch" },
|
||||
dirs: {
|
||||
"/home/u/.config/retroarch/cores": [
|
||||
"snes9x_libretro.so",
|
||||
"mgba_libretro.so",
|
||||
"notes.txt",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(d?.via).toBe("path");
|
||||
expect(d?.exeToken).toBe("'/usr/bin/retroarch'");
|
||||
expect(d?.coresDir).toBe("/home/u/.config/retroarch/cores");
|
||||
expect(d?.cores).toEqual(["mgba", "snes9x"]);
|
||||
});
|
||||
|
||||
test("RetroArch via Flatpak when not on PATH", () => {
|
||||
const d = detectEmulator(
|
||||
retroarch,
|
||||
env({
|
||||
flatpaks: ["org.libretro.RetroArch"],
|
||||
dirs: {
|
||||
"/home/u/.var/app/org.libretro.RetroArch/config/retroarch/cores": [
|
||||
"snes9x_libretro.so",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(d?.via).toBe("flatpak");
|
||||
expect(d?.exeToken).toBe("flatpak run org.libretro.RetroArch");
|
||||
expect(d?.appId).toBe("org.libretro.RetroArch");
|
||||
expect(d?.cores).toEqual(["snes9x"]);
|
||||
});
|
||||
|
||||
test("Windows path with spaces is double-quoted", () => {
|
||||
const d = detectEmulator(
|
||||
ppsspp,
|
||||
env({
|
||||
os: "windows",
|
||||
files: ["C:\\Program Files\\PPSSPP\\PPSSPPWindows64.exe"],
|
||||
}),
|
||||
);
|
||||
expect(d?.via).toBe("file");
|
||||
expect(d?.exeToken).toBe(
|
||||
'"C:\\Program Files\\PPSSPP\\PPSSPPWindows64.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null when not installed", () => {
|
||||
expect(detectEmulator(dolphin, env({}))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildUrl,
|
||||
candidateNames,
|
||||
LibretroProvider,
|
||||
sanitizeName,
|
||||
} from "../src/art/libretro.js";
|
||||
import { parseTitle } from "../src/domain/titles.js";
|
||||
import { platformById } from "../src/domain/platforms.js";
|
||||
|
||||
const snes = platformById("snes")!;
|
||||
const nswitch = platformById("switch")!;
|
||||
|
||||
describe("libretro name handling", () => {
|
||||
test("sanitizeName replaces the forbidden characters with underscore", () => {
|
||||
expect(sanitizeName("Ratchet & Clank")).toBe("Ratchet _ Clank");
|
||||
expect(sanitizeName("Where in the World?")).toBe("Where in the World_");
|
||||
});
|
||||
|
||||
test("candidateNames walks exact → region → base", () => {
|
||||
const names = candidateNames(parseTitle("Chrono Trigger (USA) (Rev 1)"));
|
||||
expect(names[0]).toBe("Chrono Trigger (USA) (Rev 1)"); // exact
|
||||
expect(names).toContain("Chrono Trigger (USA)"); // region-kept
|
||||
expect(names).toContain("Chrono Trigger (Europe)"); // region-relaxed
|
||||
expect(names.at(-1)).toBe("Chrono Trigger"); // bare
|
||||
});
|
||||
});
|
||||
|
||||
describe("LibretroProvider", () => {
|
||||
test("returns the first probe hit as a portrait", async () => {
|
||||
const hit = buildUrl(
|
||||
snes.libretroSystem!,
|
||||
sanitizeName("Chrono Trigger (USA)"),
|
||||
);
|
||||
const provider = new LibretroProvider((url) =>
|
||||
Promise.resolve(url === hit),
|
||||
);
|
||||
const art = await provider.resolve(
|
||||
snes,
|
||||
parseTitle("Chrono Trigger (USA)"),
|
||||
);
|
||||
expect(art).toEqual({ portrait: hit });
|
||||
});
|
||||
|
||||
test("returns null when nothing matches", async () => {
|
||||
const provider = new LibretroProvider(() => Promise.resolve(false));
|
||||
expect(
|
||||
await provider.resolve(snes, parseTitle("Nonexistent Game")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a platform with no libretro system (Switch)", async () => {
|
||||
const provider = new LibretroProvider(() => Promise.resolve(true));
|
||||
expect(await provider.resolve(nswitch, parseTitle("Zelda"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { quotePosix, quoteWindows, renderCommand } from "../src/domain/quote.js";
|
||||
|
||||
describe("quotePosix", () => {
|
||||
test("wraps a plain path in single quotes", () => {
|
||||
const r = quotePosix("/roms/snes/Chrono Trigger (USA).sfc");
|
||||
expect(r).toEqual({
|
||||
ok: true,
|
||||
value: "'/roms/snes/Chrono Trigger (USA).sfc'",
|
||||
});
|
||||
});
|
||||
|
||||
test("escapes embedded single quotes", () => {
|
||||
const r = quotePosix("it's a rom.sfc");
|
||||
expect(r.ok && r.value).toBe("'it'\\''s a rom.sfc'");
|
||||
});
|
||||
|
||||
test("neutralises shell metacharacters (no expansion possible)", () => {
|
||||
for (const evil of [
|
||||
"$(rm -rf ~).sfc",
|
||||
"`id`.sfc",
|
||||
"; rm x.sfc",
|
||||
"a && b.sfc",
|
||||
"$HOME.sfc",
|
||||
]) {
|
||||
const r = quotePosix(evil);
|
||||
expect(r.ok).toBe(true);
|
||||
// Everything stays inside a single-quoted literal.
|
||||
if (r.ok)
|
||||
expect(r.value.startsWith("'") && r.value.endsWith("'")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a NUL byte", () => {
|
||||
expect(quotePosix("a\0b").ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteWindows", () => {
|
||||
test("double-quotes a normal path", () => {
|
||||
const r = quoteWindows("C:\\Games\\Chrono Trigger (USA).sfc");
|
||||
expect(r).toEqual({
|
||||
ok: true,
|
||||
value: '"C:\\Games\\Chrono Trigger (USA).sfc"',
|
||||
});
|
||||
});
|
||||
|
||||
test("refuses each cmd.exe metacharacter", () => {
|
||||
for (const bad of [
|
||||
'a"b',
|
||||
"a%b",
|
||||
"a!b",
|
||||
"a^b",
|
||||
"a&b",
|
||||
"a|b",
|
||||
"a<b",
|
||||
"a>b",
|
||||
]) {
|
||||
expect(quoteWindows(bad).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses control characters", () => {
|
||||
expect(quoteWindows("a\nb").ok).toBe(false);
|
||||
expect(quoteWindows("a\tb").ok).toBe(false);
|
||||
});
|
||||
|
||||
test("doubles a trailing backslash run so the closing quote is not escaped", () => {
|
||||
const r = quoteWindows("C:\\dir\\");
|
||||
expect(r.ok && r.value).toBe('"C:\\dir\\\\"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderCommand", () => {
|
||||
test("POSIX: quotes rom + core, inserts exe verbatim", () => {
|
||||
const r = renderCommand(
|
||||
"{exe} -f -L {core} {rom}",
|
||||
{
|
||||
exe: "flatpak run org.libretro.RetroArch",
|
||||
rom: "/roms/Chrono Trigger (USA).sfc",
|
||||
core: "/cores/snes9x_libretro.so",
|
||||
},
|
||||
"linux",
|
||||
);
|
||||
expect(r).toEqual({
|
||||
ok: true,
|
||||
command:
|
||||
"flatpak run org.libretro.RetroArch -f -L '/cores/snes9x_libretro.so' '/roms/Chrono Trigger (USA).sfc'",
|
||||
});
|
||||
});
|
||||
|
||||
test("Windows: a hostile ROM name fails the whole render", () => {
|
||||
const r = renderCommand(
|
||||
"{exe} {rom}",
|
||||
{ exe: '"C:\\ra.exe"', rom: "C:\\roms\\a&b.sfc" },
|
||||
"windows",
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("appends trusted extra args verbatim", () => {
|
||||
const r = renderCommand(
|
||||
"{exe} {rom}",
|
||||
{ exe: "mgba", rom: "/x.gba" },
|
||||
"linux",
|
||||
"--scale 4",
|
||||
);
|
||||
expect(r.ok && r.command).toBe("mgba '/x.gba' --scale 4");
|
||||
});
|
||||
|
||||
test("errors on an unfilled placeholder", () => {
|
||||
const r = renderCommand(
|
||||
"{exe} -L {core} {rom}",
|
||||
{ exe: "ra", rom: "/x.sfc" },
|
||||
"linux",
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolveConfig } from "@rom-manager/contract";
|
||||
import type { DetectedEmulator } from "../src/domain/emulators.js";
|
||||
import { computeDesired } from "../src/domain/reconcile.js";
|
||||
import type { RomCandidate } from "../src/domain/scanner.js";
|
||||
import type { ArtVerdict } from "../src/art/provider.js";
|
||||
|
||||
const RA: DetectedEmulator = {
|
||||
id: "retroarch",
|
||||
name: "RetroArch",
|
||||
template: "{exe} -f -L {core} {rom}",
|
||||
supportsArchives: true,
|
||||
exeToken: "'/usr/bin/retroarch'",
|
||||
via: "path",
|
||||
coresDir: "/cores",
|
||||
cores: ["snes9x"],
|
||||
};
|
||||
|
||||
const cand = (rel: string, over: Partial<RomCandidate> = {}): RomCandidate => ({
|
||||
platform: "snes",
|
||||
rootDir: "/roms/snes",
|
||||
absPath: `/roms/snes/${rel}`,
|
||||
relPath: rel,
|
||||
ext: ".sfc",
|
||||
stem: rel.replace(/\.[^.]+$/, ""),
|
||||
isArchive: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const base = { roots: [{ dir: "/roms/snes", platform: "snes" }] };
|
||||
const run = (
|
||||
raw: Record<string, unknown>,
|
||||
scan: RomCandidate[],
|
||||
art: Record<string, ArtVerdict> = {},
|
||||
detected = [RA],
|
||||
) =>
|
||||
computeDesired({
|
||||
config: resolveConfig({ ...base, ...raw }),
|
||||
scan,
|
||||
detected,
|
||||
art,
|
||||
os: "linux",
|
||||
});
|
||||
|
||||
describe("computeDesired", () => {
|
||||
test("produces a quoted launch command with the resolved core path", () => {
|
||||
const { entries } = run({}, [cand("Chrono Trigger (USA).sfc")]);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
external_id: "snes/Chrono Trigger (USA).sfc",
|
||||
title: "Chrono Trigger",
|
||||
launch: {
|
||||
kind: "command",
|
||||
value:
|
||||
"'/usr/bin/retroarch' -f -L '/cores/snes9x_libretro.so' '/roms/snes/Chrono Trigger (USA).sfc'",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("per-game exclude drops the entry", () => {
|
||||
const { entries } = run(
|
||||
{ gameOverrides: { "snes/A.sfc": { exclude: true } } },
|
||||
[cand("A.sfc"), cand("B.sfc")],
|
||||
);
|
||||
expect(entries.map((e) => e.external_id)).toEqual(["snes/B.sfc"]);
|
||||
});
|
||||
|
||||
test("an undetected emulator is skipped with a reason", () => {
|
||||
const { entries, report } = run({}, [cand("A.sfc")], {}, []);
|
||||
expect(entries).toHaveLength(0);
|
||||
expect(report.skipped[0]?.reason).toContain("not detected");
|
||||
});
|
||||
|
||||
test("region dedupe keeps the preferred release", () => {
|
||||
const { entries, report } = run({ sync: { dedupeRegions: ["snes"] } }, [
|
||||
cand("Sonic (USA).sfc"),
|
||||
cand("Sonic (Europe).sfc"),
|
||||
]);
|
||||
expect(entries.map((e) => e.external_id)).toEqual(["snes/Sonic (USA).sfc"]);
|
||||
expect(report.skipped.some((s) => s.reason.includes("deduped"))).toBe(true);
|
||||
});
|
||||
|
||||
test("attaches cached artwork, keyed by external_id", () => {
|
||||
const art = {
|
||||
"snes/A.sfc": {
|
||||
art: { portrait: "http://x/p.png", hero: "http://x/h.png" },
|
||||
provider: "steamgriddb:ab",
|
||||
matcher: 1,
|
||||
},
|
||||
};
|
||||
const { entries } = run({}, [cand("A.sfc")], art);
|
||||
expect(entries[0]?.art).toEqual({
|
||||
portrait: "http://x/p.png",
|
||||
hero: "http://x/h.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("per-game art override wins on portrait but keeps other cached art", () => {
|
||||
const art = {
|
||||
"snes/A.sfc": {
|
||||
art: { portrait: "http://x/p.png", hero: "http://x/h.png" },
|
||||
provider: "p",
|
||||
matcher: 1,
|
||||
},
|
||||
};
|
||||
const { entries } = run(
|
||||
{ gameOverrides: { "snes/A.sfc": { art: "http://o/p.png" } } },
|
||||
[cand("A.sfc")],
|
||||
art,
|
||||
);
|
||||
expect(entries[0]?.art).toEqual({
|
||||
portrait: "http://o/p.png",
|
||||
hero: "http://x/h.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("archive gating skips a zip when the emulator can't read archives", () => {
|
||||
const noArc = { ...RA, supportsArchives: false };
|
||||
const { entries, report } = run(
|
||||
{},
|
||||
[cand("A.zip", { ext: ".zip", isArchive: true })],
|
||||
{},
|
||||
[noArc],
|
||||
);
|
||||
expect(entries).toHaveLength(0);
|
||||
expect(report.skipped[0]?.reason).toContain("archive");
|
||||
});
|
||||
|
||||
test("scale guard truncates deterministically and reports it", () => {
|
||||
const { entries, report } = run({ sync: { maxEntries: 1 } }, [
|
||||
cand("B.sfc"),
|
||||
cand("A.sfc"),
|
||||
]);
|
||||
expect(entries.map((e) => e.external_id)).toEqual(["snes/A.sfc"]); // sorted, first kept
|
||||
expect(report.truncated).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type DirEnt,
|
||||
makeExcluder,
|
||||
type ScanFs,
|
||||
scanRoot,
|
||||
} from "../src/domain/scanner.js";
|
||||
import { platformById } from "../src/domain/platforms.js";
|
||||
|
||||
/** A synthetic filesystem from a flat `{ absPath: contents }` map (dirs inferred). */
|
||||
const synthFs = (files: Record<string, string>): ScanFs => {
|
||||
const norm = (p: string) => p.replace(/\/+$/, "");
|
||||
return {
|
||||
readDir(dir): DirEnt[] {
|
||||
const base = norm(dir);
|
||||
const children = new Map<string, boolean>(); // name → isDir
|
||||
for (const f of Object.keys(files)) {
|
||||
if (!f.startsWith(`${base}/`)) continue;
|
||||
const rest = f.slice(base.length + 1);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash === -1) children.set(rest, false);
|
||||
else children.set(rest.slice(0, slash), true);
|
||||
}
|
||||
return [...children].map(([name, isDir]) => ({
|
||||
name,
|
||||
isDir,
|
||||
isFile: !isDir,
|
||||
}));
|
||||
},
|
||||
readText(file) {
|
||||
const c = files[norm(file)];
|
||||
if (c === undefined) throw new Error(`no such file: ${file}`);
|
||||
return c;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const snes = platformById("snes")!;
|
||||
const ps1 = platformById("ps1")!;
|
||||
|
||||
const rels = (
|
||||
root: string,
|
||||
files: Record<string, string>,
|
||||
platform = snes,
|
||||
excludes?: string[],
|
||||
) =>
|
||||
scanRoot(synthFs(files), root, platform, excludes)
|
||||
.map((c) => c.relPath)
|
||||
.sort();
|
||||
|
||||
describe("scanRoot — cartridge platform", () => {
|
||||
test("matches platform extensions, recurses, ignores others", () => {
|
||||
const got = rels("/roms/snes", {
|
||||
"/roms/snes/Chrono Trigger (USA).sfc": "",
|
||||
"/roms/snes/Super Metroid.smc": "",
|
||||
"/roms/snes/readme.txt": "",
|
||||
"/roms/snes/sub/Zelda.sfc": "",
|
||||
});
|
||||
expect(got).toEqual([
|
||||
"Chrono Trigger (USA).sfc",
|
||||
"Super Metroid.smc",
|
||||
"sub/Zelda.sfc",
|
||||
]);
|
||||
});
|
||||
|
||||
test("accepts archives regardless of extension list", () => {
|
||||
const got = rels("/roms/snes", {
|
||||
"/roms/snes/Game.zip": "",
|
||||
"/roms/snes/x.foo": "",
|
||||
});
|
||||
expect(got).toEqual(["Game.zip"]);
|
||||
});
|
||||
|
||||
test("excludes apply (glob + directory)", () => {
|
||||
const got = rels(
|
||||
"/roms/snes",
|
||||
{
|
||||
"/roms/snes/A.sfc": "",
|
||||
"/roms/snes/A.sav": "",
|
||||
"/roms/snes/bios/x.sfc": "",
|
||||
},
|
||||
snes,
|
||||
["*.sav", "bios/**"],
|
||||
);
|
||||
expect(got).toEqual(["A.sfc"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanRoot — disc folding", () => {
|
||||
test("m3u playlist folds its cues and their bins into one entry", () => {
|
||||
const got = rels(
|
||||
"/roms/ps1",
|
||||
{
|
||||
"/roms/ps1/FF7.m3u": "FF7 (Disc 1).cue\nFF7 (Disc 2).cue\n",
|
||||
"/roms/ps1/FF7 (Disc 1).cue":
|
||||
'FILE "FF7 (Disc 1).bin" BINARY\n TRACK 01 MODE2/2352',
|
||||
"/roms/ps1/FF7 (Disc 1).bin": "",
|
||||
"/roms/ps1/FF7 (Disc 2).cue": 'FILE "FF7 (Disc 2).bin" BINARY',
|
||||
"/roms/ps1/FF7 (Disc 2).bin": "",
|
||||
},
|
||||
ps1,
|
||||
);
|
||||
expect(got).toEqual(["FF7.m3u"]);
|
||||
});
|
||||
|
||||
test("a standalone cue hides its bin", () => {
|
||||
const got = rels(
|
||||
"/roms/ps1",
|
||||
{
|
||||
"/roms/ps1/Tekken.cue": 'FILE "Tekken.bin" BINARY',
|
||||
"/roms/ps1/Tekken.bin": "",
|
||||
},
|
||||
ps1,
|
||||
);
|
||||
expect(got).toEqual(["Tekken.cue"]);
|
||||
});
|
||||
|
||||
test("chd stands alone", () => {
|
||||
const got = rels("/roms/ps1", { "/roms/ps1/Crash.chd": "" }, ps1);
|
||||
expect(got).toEqual(["Crash.chd"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeExcluder", () => {
|
||||
test("bare pattern matches at any depth; negation re-includes", () => {
|
||||
const ex = makeExcluder(["*.sav", "!keep/**"]);
|
||||
expect(ex("a/b/x.sav")).toBe(true);
|
||||
expect(ex("keep/x.sav")).toBe(false);
|
||||
expect(ex("a/b/x.sfc")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SteamGridDbProvider } from "../src/art/steamgriddb.js";
|
||||
import { parseTitle } from "../src/domain/titles.js";
|
||||
import { platformById } from "../src/domain/platforms.js";
|
||||
|
||||
const snes = platformById("snes")!;
|
||||
|
||||
/** A fake SteamGridDB backend keyed by URL path fragments. */
|
||||
const fakeFetch = (opts: {
|
||||
status?: number;
|
||||
games?: unknown[];
|
||||
grids?: unknown[];
|
||||
heroes?: unknown[];
|
||||
logos?: unknown[];
|
||||
}): typeof fetch =>
|
||||
((input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const path = new URL(url).pathname;
|
||||
if (opts.status && opts.status !== 200) {
|
||||
return Promise.resolve(new Response("nope", { status: opts.status }));
|
||||
}
|
||||
let data: unknown[] = [];
|
||||
if (path.includes("/search/autocomplete/")) data = opts.games ?? [];
|
||||
else if (path.includes("/grids/game/")) data = opts.grids ?? [];
|
||||
else if (path.includes("/heroes/game/")) data = opts.heroes ?? [];
|
||||
else if (path.includes("/logos/game/")) data = opts.logos ?? [];
|
||||
return Promise.resolve(Response.json({ success: true, data }));
|
||||
}) as typeof fetch;
|
||||
|
||||
describe("SteamGridDbProvider", () => {
|
||||
test("classifies grids into portrait + header and pulls hero + logo", async () => {
|
||||
const provider = new SteamGridDbProvider(
|
||||
"key",
|
||||
fakeFetch({
|
||||
games: [{ id: 1, name: "Chrono Trigger" }],
|
||||
grids: [
|
||||
{ url: "http://p600.png", width: 600, height: 900 },
|
||||
{ url: "http://h460.png", width: 460, height: 215 },
|
||||
],
|
||||
heroes: [{ url: "http://hero.png", width: 1920, height: 620 }],
|
||||
logos: [{ url: "http://logo.png", width: 400, height: 200 }],
|
||||
}),
|
||||
);
|
||||
const art = await provider.resolve(
|
||||
snes,
|
||||
parseTitle("Chrono Trigger (USA)"),
|
||||
);
|
||||
expect(art).toEqual({
|
||||
portrait: "http://p600.png",
|
||||
header: "http://h460.png",
|
||||
hero: "http://hero.png",
|
||||
logo: "http://logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("no game match → null", async () => {
|
||||
const provider = new SteamGridDbProvider("key", fakeFetch({ games: [] }));
|
||||
expect(await provider.resolve(snes, parseTitle("Nope"))).toBeNull();
|
||||
});
|
||||
|
||||
test("a bad key (401) disables the provider and returns null", async () => {
|
||||
const provider = new SteamGridDbProvider("bad", fakeFetch({ status: 401 }));
|
||||
expect(await provider.resolve(snes, parseTitle("Anything"))).toBeNull();
|
||||
});
|
||||
|
||||
test("the cache id is scoped to the key (so a key change re-resolves)", () => {
|
||||
const a = new SteamGridDbProvider("k1").id;
|
||||
const b = new SteamGridDbProvider("k2").id;
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.startsWith("steamgriddb:")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
dedupeKey,
|
||||
parseTitle,
|
||||
pickBestRelease,
|
||||
} from "../src/domain/titles.js";
|
||||
|
||||
describe("parseTitle", () => {
|
||||
test("strips No-Intro tags and keeps region + revision metadata", () => {
|
||||
const p = parseTitle("Chrono Trigger (USA) (Rev 1) [!]");
|
||||
expect(p.displayTitle).toBe("Chrono Trigger");
|
||||
expect(p.noIntroName).toBe("Chrono Trigger (USA) (Rev 1) [!]");
|
||||
expect(p.region).toBe("USA");
|
||||
expect(p.revision).toBe("Rev 1");
|
||||
});
|
||||
|
||||
test("normalises a trailing article", () => {
|
||||
expect(parseTitle("Legend of Zelda, The (USA)").displayTitle).toBe(
|
||||
"The Legend of Zelda",
|
||||
);
|
||||
});
|
||||
|
||||
test("recognises multi-region tags", () => {
|
||||
const p = parseTitle("Sonic (USA, Europe)");
|
||||
expect(p.regions).toEqual(["USA", "Europe"]);
|
||||
expect(p.region).toBe("USA");
|
||||
});
|
||||
|
||||
test("converts underscores and collapses whitespace", () => {
|
||||
expect(parseTitle("Super_Mario__World").displayTitle).toBe(
|
||||
"Super Mario World",
|
||||
);
|
||||
});
|
||||
|
||||
test("a tagless name is its own title", () => {
|
||||
const p = parseTitle("Tetris");
|
||||
expect(p.displayTitle).toBe("Tetris");
|
||||
expect(p.region).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("region dedupe", () => {
|
||||
test("groups by base title", () => {
|
||||
expect(dedupeKey(parseTitle("Sonic (USA)"))).toBe(
|
||||
dedupeKey(parseTitle("Sonic (Europe)")),
|
||||
);
|
||||
});
|
||||
|
||||
test("pickBestRelease honors region priority, then shortest name", () => {
|
||||
const items = [
|
||||
{ parsed: parseTitle("Sonic (Europe)") },
|
||||
{ parsed: parseTitle("Sonic (USA)") },
|
||||
{ parsed: parseTitle("Sonic (Japan)") },
|
||||
];
|
||||
const best = pickBestRelease(items, ["USA", "World", "Europe", "Japan"]);
|
||||
expect(best.parsed.region).toBe("USA");
|
||||
});
|
||||
|
||||
test("falls back to shortest name when no region matches priority", () => {
|
||||
const items = [
|
||||
{ parsed: parseTitle("Game (Korea) (Beta)") },
|
||||
{ parsed: parseTitle("Game (Korea)") },
|
||||
];
|
||||
const best = pickBestRelease(items, ["USA"]);
|
||||
expect(best.parsed.noIntroName).toBe("Game (Korea)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user