commit 3a6c80558d3f639ca2221c8222e3cb3b3d458116 Author: enricobuehler Date: Sat Jul 18 02:40:59 2026 +0200 feat: initial ROM & emulator manager plugin (M0–M5) A punktfunk-plugin-* package that scans ROM directories, maps them to emulators, fetches box art, and reconciles them into the host game library under provider id `rom-manager` — with a console-hosted web UI. Engine (pure, unit-tested core): - Table-driven platform registry (~25 consoles) + emulator registry with best-effort per-OS detection (PATH / Flatpak / known paths) and RetroArch core discovery. - Scanner with disc folding (m3u/cue/gdi), archive gating, excludes. - No-Intro title parsing + optional per-platform region dedupe. - Security-critical quoting seam: POSIX single-quote + Windows double-quote with hostile-name refusal; ROM filenames never reach a shell un-quoted. - Pure desired-state reconcile (stable external_ids, scale guard, fingerprint skip) → full-replace PUT /library/provider/rom-manager. Box art (like Steam ROM Manager): SteamGridDB primary (portrait/hero/logo/ header, fuzzy match, operator API key) behind a provider seam, with keyless libretro-thumbnails as the zero-setup fallback (`auto` default). UI: console-hosted via the SDK `servePluginUi` (zero plugin-side auth) with a plugin-local REST/SSE API and a self-contained React SPA (Setup / Emulators / Games / Sync). Standalone password-gated fallback for host-only installs. CLI: scan / detect / preview / sync / uninstall / set-password. 48 engine tests, typecheck + biome clean, SPA builds. Verified end-to-end: scan → detect → reconcile PUT, fingerprint idempotence, and the standalone UI serving SPA + REST. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..68a2310 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,56 @@ +# CI for punktfunk-plugin-rom-manager (Gitea Actions). `bun install` resolves `@punktfunk/host` from +# the Gitea npm registry (scope mapping in bunfig.toml). Publish runs on a `v*` tag. +name: CI + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install + run: bun install --frozen-lockfile + - name: Lint & format + run: bunx biome check + - name: Typecheck (backend) + run: bunx tsc --noEmit + - name: Test (engine) + run: bun test + - name: Build backend + run: bun run build + - name: Build SPA + run: bun run build:ui + - name: Typecheck (UI) + working-directory: ui + run: bunx tsc --noEmit + - name: Bundle sanity — plugin default export is a valid PluginDef + run: | + bun -e 'import p from "./dist/index.js"; const d = p.default; if (d?.name !== "rom-manager" || typeof d?.main !== "function") { console.error("bad default export", d); process.exit(1); } console.log("ok:", d.name)' + + publish: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install + run: bun install --frozen-lockfile + - name: Build (backend + SPA) + run: bun run build:all + - name: Publish to the Gitea npm registry + env: + NPM_TOKEN: ${{ secrets.GITEA_NPM_TOKEN }} + run: | + npm config set //git.unom.io/api/packages/unom/npm/:_authToken "$NPM_TOKEN" + npm publish --registry https://git.unom.io/api/packages/unom/npm/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61cd80b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +ui/dist +ui/node_modules +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f399ca0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,10 @@ +This project is dual-licensed under either of + + * MIT license (SPDX: MIT) + * Apache License, Version 2.0 (SPDX: Apache-2.0) + +at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in this project by you shall be dual licensed as above, without +any additional terms or conditions. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a562a5 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# punktfunk-plugin-rom-manager + +A [punktfunk](https://git.unom.io/unom/punktfunk) plugin that scans your ROM folders, maps them to +emulators, fetches box art, and reconciles everything into the host game library — so your retro games +appear automatically in the punktfunk web console, native clients, and Moonlight `/applist`, each +launchable and covered in art. It ships a web UI that lives **inside the punktfunk console** (no second +password, no second port). + +Think of it as [Steam ROM Manager](https://github.com/SteamGridDB/steam-rom-manager) for punktfunk: it +even uses the same art source (SteamGridDB) when you give it an API key. + +--- + +## Requirements + +- A punktfunk host (Linux or Windows) with the **scripting runner** (`punktfunk-scripting`) enabled. +- [Bun](https://bun.sh) (the runner is Bun). +- `@punktfunk/host` **with the plugin-UI surface** (`servePluginUi`). This is what powers the + console-hosted UI; the standalone fallback works without it. + +## Install + +```sh +cd "$(punktfunk config-dir)/plugins" # e.g. ~/.config/punktfunk/plugins +bun add punktfunk-plugin-rom-manager --registry https://git.unom.io/api/packages/unom/npm/ +# then enable the runner (opt-in): +systemctl --user enable --now punktfunk-scripting # Linux +Enable-ScheduledTask PunktfunkScripting # Windows +``` + +Open the punktfunk console — a **ROM Manager** entry appears in the nav. That's it. + +> **Headless / host-only boxes** without the console can use the [standalone UI](#standalone-ui) or +> just drop a `config.json` (below) — the sync engine is fully functional from the file alone. + +## Quick start (config file) + +The plugin owns `/rom-manager/config.json`. A minimal example: + +```jsonc +{ + "roots": [ + { "dir": "/mnt/roms/snes", "platform": "snes" }, + { "dir": "/mnt/roms/ps1", "platform": "ps1", "excludes": ["*.sav", "bios/**"] } + ], + "art": { "provider": "auto", "steamGridDbKey": "" } +} +``` + +See [`config.example.json`](./config.example.json) for every option. Then, from the plugin dir: + +```sh +bunx punktfunk-plugin-rom-manager scan # what the scanner finds +bunx punktfunk-plugin-rom-manager detect # which emulators are installed +bunx punktfunk-plugin-rom-manager preview # the desired library (no host write) +bunx punktfunk-plugin-rom-manager sync # reconcile into the live library +``` + +## Box art + +Two sources, selected by `art.provider`: + +| Provider | Key? | Art types | Notes | +|---------------|-------|------------------------------------|-------| +| `steamgriddb` | yes | portrait + hero + logo + header | What Steam ROM Manager uses; best coverage + fuzzy title match. Free key from [steamgriddb.com › profile › preferences](https://www.steamgriddb.com/profile/preferences). | +| `libretro` | no | box-art portrait only | Keyless [libretro-thumbnails](https://thumbnails.libretro.com); works out of the box. | +| `auto` (default) | — | SteamGridDB if a key is set, else libretro | | + +Art URLs are handed to the host as-is; native clients/console fetch them directly (so **clients need +internet for covers**), and Moonlight covers are proxied host-side. Verdicts are cached in +`cache.json` and never re-probed unless you change provider/key. + +## Platforms & emulators + +~25 built-in platforms (NES, SNES, N64, GameCube, Wii, Switch, GB/GBC/GBA, NDS, 3DS, PS1/2/PSP, +Genesis, Master System, Game Gear, Saturn, Dreamcast, PC Engine, Neo Geo Pocket, WonderSwan, Lynx, +Atari 2600/7800, Xbox). Arcade (MAME/FBNeo romset versioning) is intentionally out of scope. + +Emulators are detected best-effort (PATH → Flatpak → known install paths): RetroArch (+ cores), +Dolphin, PCSX2, DuckStation, PPSSPP, mGBA, melonDS, Flycast, xemu, plus Azahar/Ryujinx. Any platform's +emulator, core, and args are overridable per-platform and per-game (config or UI). Missing an +emulator? Add a `custom-*` template. + +Disc games are folded so a set is **one entry**: `.m3u` playlists absorb their discs, `.cue`/`.gdi` +sheets hide their tracks, `.chd` stands alone. + +## The UI + +### Console-hosted (default) + +`servePluginUi` serves the SPA on a loopback ephemeral port behind a per-boot secret and registers it +with the host; the console reverse-proxies it and gates it with your existing console sign-in. Four +pages: **Setup** (ROM roots), **Emulators** (detected + per-platform overrides), **Games** (scan +preview with covers + per-title include toggles), **Sync** (status, art settings, sync options). + +### Standalone UI + +For host-only installs, set `ui.standalone: true` (config). It serves the same SPA on a fixed port: + +```jsonc +"ui": { "standalone": true, "port": 47993, "bind": "127.0.0.1" } +``` + +A **non-loopback bind requires a password** (fail-closed — the UI can edit launch commands, i.e. +host-user code): + +```sh +bunx punktfunk-plugin-rom-manager set-password 'your-password' +``` + +## Security + +- **ROM filenames are untrusted input** that ends up in a `sh -c` / `cmd.exe /c` string run as the host + user. Every path goes through strict per-OS quoting (`src/quote.ts`); on Windows, names containing + `cmd.exe` metacharacters (`" % ! ^ & | < >`) are refused rather than risk injection. +- `config.json` lives in the hardened ``; the plugin refuses a group/world-writable one. +- Outbound network in v1: SteamGridDB (if keyed) and libretro-thumbnails only. No telemetry. + +## Development + +```sh +bun install # resolves @punktfunk/host from the Gitea npm registry +bun test # engine unit tests (pure core: quoting, scanner, reconcile, art) +bun run typecheck +bun run build:all # backend → dist/, SPA → dist/ui/ +cd ui && bun run dev # SPA dev server (point it at a running standalone instance) +``` + +This plugin requires `@punktfunk/host` ≥ 0.1.0 (the version that introduced `servePluginUi`). + +## License + +MIT OR Apache-2.0. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..b4523d7 --- /dev/null +++ b/biome.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["**", "!dist", "!ui/dist", "!**/node_modules"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "suspicious": { + "noArrayIndexKey": "off" + }, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0395627 --- /dev/null +++ b/bun.lock @@ -0,0 +1,93 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "punktfunk-plugin-rom-manager", + "dependencies": { + "@punktfunk/host": "^0.1.0", + "effect": "^4.0.0-beta.98", + }, + "devDependencies": { + "@biomejs/biome": "^2.5.2", + "@types/bun": "^1.3.0", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.5.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.4", "@biomejs/cli-darwin-x64": "2.5.4", "@biomejs/cli-linux-arm64": "2.5.4", "@biomejs/cli-linux-arm64-musl": "2.5.4", "@biomejs/cli-linux-x64": "2.5.4", "@biomejs/cli-linux-x64-musl": "2.5.4", "@biomejs/cli-win32-arm64": "2.5.4", "@biomejs/cli-win32-x64": "2.5.4" }, "bin": { "biome": "bin/biome" } }, "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@punktfunk/host": ["@punktfunk/host@0.1.0", "https://git.unom.io/api/packages/unom/npm/%40punktfunk%2Fhost/-/0.1.0/host-0.1.0.tgz", { "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "dist/runner-cli.js" } }, "sha512-4thxzSN/JNIOULu3x3Zn/RIvmnpB3E03IaqvTrQ96fydK5HkrmRcpvv2JwWP9JbJ04V8GOtKGMNW+gqvRgX5DA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "effect": ["effect@4.0.0-beta.99", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-hP1C61uzINfLl/4kKMwcqksxd34s4sQ3VSjsWjhGrkx9CRlXaqnfOK9dpTEKynQ6rA7wU9rb3c+48eDYw7uzxA=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..0e6e10f --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,6 @@ +# Resolve the `@punktfunk` scope from the Gitea npm registry (like `@punktfunk/host` itself). +# During local development the SDK is consumed via `bun link @punktfunk/host` (the `link:` in +# package.json) so this only matters once the plugin is installed from the registry — but it's +# committed so `bun add punktfunk-plugin-rom-manager` resolves the dependency out of the box. +[install.scopes] +"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/" diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..6f1c5a4 --- /dev/null +++ b/config.example.json @@ -0,0 +1,38 @@ +{ + "roots": [ + { "dir": "/mnt/roms/snes", "platform": "snes" }, + { "dir": "/mnt/roms/gba", "platform": "gba" }, + { + "dir": "/mnt/roms/ps1", + "platform": "ps1", + "excludes": ["*.sav", "bios/**"] + } + ], + "platformLaunch": { + "ps1": { "emulator": "duckstation" } + }, + "gameOverrides": { + "snes/Weird Homebrew (USA).sfc": { "exclude": true }, + "gba/Pokemon - Emerald Version (USA, Europe).gba": { + "title": "Pokémon Emerald" + } + }, + "sync": { + "pollMinutes": 15, + "watch": true, + "debounceMs": 2000, + "dedupeRegions": ["snes"], + "maxEntries": 5000, + "closeOnEnd": false + }, + "art": { + "enabled": true, + "provider": "auto", + "steamGridDbKey": "" + }, + "ui": { + "standalone": false, + "port": 47993, + "bind": "127.0.0.1" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a8bce0c --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "punktfunk-plugin-rom-manager", + "version": "0.1.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.", + "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": "./dist/index.d.ts", + "bin": { + "punktfunk-plugin-rom-manager": "./dist/cli.js" + }, + "files": [ + "dist" + ], + "publishConfig": { + "registry": "https://git.unom.io/api/packages/unom/npm/" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test", + "build": "tsc -p tsconfig.build.json", + "build:ui": "cd ui && bun install --silent && bun run build", + "build:all": "bun run build && bun run build:ui", + "sync": "bun src/cli.ts sync", + "scan": "bun src/cli.ts scan", + "detect": "bun src/cli.ts detect", + "prepublishOnly": "bun run build:all" + }, + "dependencies": { + "@punktfunk/host": "^0.1.0", + "effect": "^4.0.0-beta.98" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.2", + "@types/bun": "^1.3.0", + "typescript": "^5.9.3" + } +} diff --git a/src/art/libretro.ts b/src/art/libretro.ts new file mode 100644 index 0000000..8c8e02e --- /dev/null +++ b/src/art/libretro.ts @@ -0,0 +1,87 @@ +// The keyless fallback art provider: libretro-thumbnails (design §7) — HTTPS, redirect-free, +// CDN-backed, no API key. Box art only (portrait); hero/logo/header are SteamGridDB-only. Thumbnail +// files are named after the No-Intro name with a documented character-substitution set; we walk a +// fallback ladder (exact → region-swap → bare title) and verify each candidate with one probe. + +import type { ParsedTitle } from "../engine/titles.js"; +import type { Platform } from "../platforms.js"; +import type { Artwork } from "../wire.js"; +import type { ArtProvider } from "./provider.js"; + +const BASE = "https://thumbnails.libretro.com"; + +/** + * libretro's filename character substitutions: ampersand, asterisk, slash, colon, backtick, angle + * brackets, question mark, backslash, pipe and double-quote all become `_` (the rest of the name, + * including spaces and parentheses, is kept verbatim and URL-encoded per path segment). + */ +export const sanitizeName = (name: string): string => + name.replace(/[&*/:`<>?\\|"]/g, "_").trim(); + +/** Build the Named_Boxarts URL for a system + already-sanitized name. */ +export const buildUrl = (system: string, sanitizedName: string): string => + `${BASE}/${encodeURIComponent(system)}/Named_Boxarts/${encodeURIComponent(sanitizedName)}.png`; + +/** Common single-region fallbacks tried when the exact-name probe misses. */ +const REGION_FALLBACKS = ["USA", "World", "Europe", "Japan"]; + +/** + * Ordered candidate names for a title (design §7 ladder), de-duplicated: + * 1. the exact No-Intro name (with all tags), + * 2. ` ()` — drops revision/flag tags but keeps region, + * 3. ` (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; + +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 => { + 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 { + if (!platform.libretroSystem) return null; + const portrait = await resolveLibretroUrl( + platform.libretroSystem, + parsed, + this.probe, + ); + return portrait ? { portrait } : null; + } +} diff --git a/src/art/provider.ts b/src/art/provider.ts new file mode 100644 index 0000000..1fb0fd3 --- /dev/null +++ b/src/art/provider.ts @@ -0,0 +1,104 @@ +// The art-provider seam (design §7, revised). Steam ROM Manager sources art from SteamGridDB; we make +// that the preferred provider (full portrait + hero + logo + header, fuzzy title match) and keep the +// keyless libretro-thumbnails source as a zero-setup fallback. Both implement `ArtProvider`, so the +// reconcile/warm code is provider-agnostic; the choice is `config.art.provider` (default `auto` = +// SteamGridDB when an API key is set, else libretro). + +import type { Config } from "../config.js"; +import type { ParsedTitle } from "../engine/titles.js"; +import { log } from "../log.js"; +import type { Platform } from "../platforms.js"; +import type { ArtVerdict } from "../state.js"; +import type { Artwork } from "../wire.js"; +import { LibretroProvider } from "./libretro.js"; +import { SteamGridDbProvider } from "./steamgriddb.js"; + +export interface ArtProvider { + /** Stable id, stored in the cache verdict so a provider switch re-resolves. */ + readonly id: string; + /** Bump to invalidate this provider's cached verdicts after an algorithm change. */ + readonly matcherVersion: number; + /** Resolve artwork for a title, or `null` if nothing was found. Best-effort — never throws. */ + resolve(platform: Platform, parsed: ParsedTitle): Promise; +} + +/** Pick the active provider for a config, or `null` when art is disabled / unavailable. */ +export const selectArtProvider = (config: Config): ArtProvider | null => { + if (!config.art.enabled) return null; + const key = config.art.steamGridDbKey?.trim(); + switch (config.art.provider) { + case "libretro": + return new LibretroProvider(); + case "steamgriddb": + if (key) return new SteamGridDbProvider(key); + log( + "art: provider=steamgriddb but no API key set — no art will be fetched (set art.steamGridDbKey or use provider=auto)", + ); + return null; + default: // "auto" + return key ? new SteamGridDbProvider(key) : new LibretroProvider(); + } +}; + +/** One title needing art — the reconcile enumeration output the warmer iterates. */ +export interface ArtTarget { + externalId: string; + platform: Platform; + parsed: ParsedTitle; +} + +/** Run `fn` over `items` with bounded concurrency (art providers are network-bound; stay polite). */ +const mapPool = async ( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise => { + 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 }, + opts?: { concurrency?: number }, +): Promise => { + 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; +}; diff --git a/src/art/steamgriddb.ts b/src/art/steamgriddb.ts new file mode 100644 index 0000000..e2067b8 --- /dev/null +++ b/src/art/steamgriddb.ts @@ -0,0 +1,132 @@ +// The preferred art provider: SteamGridDB (what Steam ROM Manager uses). Fuzzy-matches a title to a +// SteamGridDB game, then pulls the four art types the host's `Artwork` wants: a 600x900 portrait +// capsule (grid), a horizontal header (grid), a hero background, and a transparent logo. +// +// Auth is a Bearer API key (free, per-user, from steamgriddb.com profile preferences) — so, unlike +// libretro, this is opt-in. The cache verdict is keyed on a hash of the key (`id` below), so changing +// or adding a key re-resolves everything; a bad key fails fast (one 401), logs once, and short-circuits +// the rest of the run instead of hammering the API. + +import { createHash } from "node:crypto"; +import type { ParsedTitle } from "../engine/titles.js"; +import { log } from "../log.js"; +import type { Platform } from "../platforms.js"; +import type { Artwork } from "../wire.js"; +import type { ArtProvider } from "./provider.js"; + +const BASE = "https://www.steamgriddb.com/api/v2"; + +interface SgdbGame { + id: number; + name: string; +} +interface SgdbImage { + url: string; + width: number; + height: number; +} + +/** Injectable fetch (tests) — defaults to global fetch. */ +export type SgdbFetch = typeof fetch; + +export class SteamGridDbProvider implements ArtProvider { + readonly id: string; + readonly matcherVersion = 1; + private authFailed = false; + + constructor( + private readonly apiKey: string, + private readonly fetchImpl: SgdbFetch = fetch, + ) { + // The key fingerprint scopes cached verdicts to this key: change the key → re-resolve, without + // ever storing the key itself in the cache file. + const fp = createHash("sha256").update(apiKey).digest("hex").slice(0, 8); + this.id = `steamgriddb:${fp}`; + } + + /** GET a SteamGridDB endpoint → its `data`. `null` = auth-disabled (skip); throws on transient errors. */ + private async get(pathAndQuery: string): Promise { + if (this.authFailed) return null; + const res = await this.fetchImpl(`${BASE}${pathAndQuery}`, { + headers: { authorization: `Bearer ${this.apiKey}` }, + }); + if (res.status === 401 || res.status === 403) { + this.authFailed = true; + log( + "art: SteamGridDB rejected the API key (401/403) — check art.steamGridDbKey", + ); + return null; + } + if (!res.ok) throw new Error(`SteamGridDB HTTP ${res.status}`); + const body = (await res.json()) as { success?: boolean; data?: unknown }; + return Array.isArray(body.data) ? body.data : []; + } + + /** Like `get`, but a transient failure yields `[]` (used for the nice-to-have hero/logo fetches). */ + private async getSafe(pathAndQuery: string): Promise { + try { + return (await this.get(pathAndQuery)) ?? []; + } catch { + return []; + } + } + + async resolve( + _platform: Platform, + parsed: ParsedTitle, + ): Promise { + 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, + ]); + + const art: Artwork = {}; + const portrait = pickGrid(grids, (i) => i.height > i.width, [ + [600, 900], + [660, 930], + [342, 482], + ]); + const header = pickGrid(grids, (i) => i.width > i.height, [ + [460, 215], + [920, 430], + ]); + if (portrait) art.portrait = portrait; + if (header) art.header = header; + if (heroes[0]?.url) art.hero = heroes[0].url; + if (logos[0]?.url) art.logo = logos[0].url; + + return Object.keys(art).length > 0 ? art : null; + } +} + +/** Pick the best grid matching `orient`, preferring the listed exact dimensions, else the first match. */ +const pickGrid = ( + grids: SgdbImage[], + orient: (i: SgdbImage) => boolean, + preferred: [number, number][], +): string | undefined => { + const matching = grids.filter((g) => g?.url && orient(g)); + for (const [w, h] of preferred) { + const exact = matching.find((g) => g.width === w && g.height === h); + if (exact) return exact.url; + } + return matching[0]?.url; +}; diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..1ce1e64 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env bun +// Dev/debug CLI (design §8) — reuses the engine one-shot. `scan`/`detect`/`preview` are offline (no +// host needed); `sync`/`uninstall` connect to the host and reconcile. Handy for configuring a headless +// box or checking what a `config.json` will produce before pushing it to the library. +// +// bunx punktfunk-plugin-rom-manager scan # what the scanner finds +// bunx punktfunk-plugin-rom-manager detect # which emulators are installed +// bunx punktfunk-plugin-rom-manager preview # the desired library (no host write) +// bunx punktfunk-plugin-rom-manager sync # reconcile into the live library +// bunx punktfunk-plugin-rom-manager uninstall # remove all this plugin's entries +// bunx punktfunk-plugin-rom-manager set-password # standalone-UI password + +import { connect } from "@punktfunk/host"; +import type { Config } from "./config.js"; +import { detectAll, realDetectEnv, resolveEmulators } from "./emulators.js"; +import { Engine } from "./engine/index.js"; +import { computeDesired, enumerateTitles } from "./engine/reconcile.js"; +import { type RomCandidate, realScanFs, scanRoot } from "./engine/scanner.js"; +import { resolvePlatforms } from "./platforms.js"; +import { currentOs } from "./quote.js"; +import { hashPassword } from "./server/password.js"; +import { loadCache, loadConfig, saveConfig } from "./state.js"; + +const scanAll = (config: Config): RomCandidate[] => { + const platforms = resolvePlatforms(config.platforms); + const out: RomCandidate[] = []; + for (const root of config.roots) { + const platform = platforms.get(root.platform); + if (!platform) { + console.error(`! root ${root.dir}: unknown platform "${root.platform}"`); + continue; + } + out.push(...scanRoot(realScanFs, root.dir, platform, root.excludes)); + } + return out; +}; + +const cmdScan = (): void => { + const config = loadConfig(); + if (config.roots.length === 0) { + console.log( + "No ROM roots configured. Add some to config.json or via the UI.", + ); + return; + } + const scan = scanAll(config); + const { survivors, skipped } = enumerateTitles(config, scan); + const perPlatform = new Map(); + for (const s of survivors) + perPlatform.set(s.platform.id, (perPlatform.get(s.platform.id) ?? 0) + 1); + console.log( + `Scanned ${config.roots.length} root(s): ${scan.length} candidate file(s), ${survivors.length} title(s).`, + ); + for (const [id, n] of [...perPlatform].sort()) console.log(` ${id}: ${n}`); + if (skipped.length) + console.log(` (${skipped.length} skipped by exclude/dedupe)`); +}; + +const cmdDetect = (): void => { + const config = loadConfig(); + const detected = detectAll( + realDetectEnv(), + resolveEmulators(config.emulators).values(), + ); + if (detected.length === 0) { + console.log("No emulators detected."); + return; + } + console.log(`Detected ${detected.length} emulator(s):`); + for (const d of detected) { + const cores = d.cores?.length ? ` — ${d.cores.length} core(s)` : ""; + console.log( + ` ${d.id} (${d.via})${d.contested ? " [contested]" : ""}${cores}`, + ); + } +}; + +const cmdPreview = (): void => { + const config = loadConfig(); + const scan = scanAll(config); + const detected = detectAll( + realDetectEnv(), + resolveEmulators(config.emulators).values(), + ); + const cache = loadCache(); + const { entries, report } = computeDesired({ + config, + scan, + detected, + art: cache.art, + os: currentOs(), + }); + console.log( + `Preview: ${entries.length} entr(y/ies) would be reconciled${report.overWarn ? " [scale warning]" : ""}.`, + ); + for (const e of entries.slice(0, 40)) + console.log(` ${e.external_id} → ${e.launch?.value ?? "(no launch)"}`); + if (entries.length > 40) console.log(` … and ${entries.length - 40} more`); + if (report.skipped.length) { + console.log(`\n${report.skipped.length} skipped:`); + for (const s of report.skipped.slice(0, 20)) + console.log(` ${s.title}: ${s.reason}`); + } + for (const w of report.warnings.slice(0, 10)) console.log(`! ${w}`); +}; + +const cmdSync = async (): Promise => { + const pf = await connect(); + try { + const engine = new Engine({ pf }); + const report = await engine.sync("cli"); + if (report) { + console.log( + `Synced: ${report.included} entr(y/ies), ${report.skipped.length} skipped.`, + ); + } else { + console.log("Sync did not complete (see log)."); + } + } finally { + pf.close(); + } +}; + +const cmdUninstall = async (): Promise => { + const pf = await connect(); + try { + await new Engine({ pf }).uninstall(); + console.log("Removed all rom-manager entries from the library."); + } finally { + pf.close(); + } +}; + +const cmdSetPassword = (password: string | undefined): void => { + if (!password) { + console.error("usage: set-password "); + process.exitCode = 2; + return; + } + const config = loadConfig(); + config.ui.passwordHash = hashPassword(password); + saveConfig(config); + console.log("Standalone-UI password set (config.ui.passwordHash)."); +}; + +const main = async (): Promise => { + const [cmd, arg] = process.argv.slice(2); + switch (cmd) { + case "scan": + return cmdScan(); + case "detect": + return cmdDetect(); + case "preview": + return cmdPreview(); + case "sync": + return cmdSync(); + case "uninstall": + return cmdUninstall(); + case "set-password": + return cmdSetPassword(arg); + default: + console.log( + "usage: punktfunk-plugin-rom-manager ", + ); + process.exitCode = cmd ? 2 : 0; + } +}; + +await main(); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..88dbd38 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,151 @@ +// The plugin's configuration model (design §9) — the single source of truth for the sync engine, +// editable by hand (headless boxes) or by the web UI. Everything the engine needs is derivable from +// this file plus a directory scan; the `resolveConfig` helper fills defaults so the rest of the code +// works against a fully-populated shape. + +import type { EmulatorDef } from "./emulators.js"; +import type { Platform } from "./platforms.js"; + +/** A ROM root: a directory paired with the platform its files belong to (design §5). */ +export interface RomRoot { + /** Absolute directory to scan. */ + dir: string; + /** Platform id (a key into the resolved platform registry). */ + platform: string; + /** Per-root glob ignore patterns (`*.sav`, `bios/**`). */ + excludes?: string[]; +} + +/** A per-platform launch override — replaces the platform's built-in default emulator/core. */ +export interface PlatformLaunch { + emulator: string; + core?: string; + extraArgs?: string; +} + +/** A per-game override, keyed by `external_id` (`snes/Chrono Trigger (USA).sfc`). */ +export interface GameOverride { + /** Skip this ROM entirely. */ + exclude?: boolean; + /** Override the resolved emulator/core/args for just this title. */ + emulator?: string; + core?: string; + extraArgs?: string; + /** Override the displayed title. */ + title?: string; + /** Override the box-art portrait URL. */ + art?: string; +} + +export interface SyncOptions { + /** Interval poll in minutes (watchers are unreliable on SMB/NFS — poll is the primary trigger). */ + pollMinutes: number; + /** Enable fs watching (an accelerator on top of the poll). */ + watch: boolean; + /** Debounce window for coalescing watch/poll-triggered rescans (ms). */ + debounceMs: number; + /** Platform ids to region-dedupe ("one entry per title", design §5) — default none. */ + dedupeRegions: string[]; + /** Region preference order for dedupe (best first). */ + regionPriority: string[]; + /** Warn in the UI above this entry count (design §8 scale guard). */ + warnEntries: number; + /** Hard cap — truncate with a loud report above this many entries. */ + maxEntries: number; + /** Attach `prep`/`undo` that kills the emulator at session end ("close emulator when stream ends"). */ + closeOnEnd: boolean; +} + +/** Which art source to use (design §7, revised): SteamGridDB (like Steam ROM Manager) or the keyless + * libretro-thumbnails fallback. `auto` = SteamGridDB when a key is set, else libretro. */ +export type ArtProviderChoice = "auto" | "steamgriddb" | "libretro"; + +export interface ArtOptions { + /** Fetch box art at all. */ + enabled: boolean; + /** Art source selection (default `auto`). */ + provider: ArtProviderChoice; + /** + * SteamGridDB API key (free, from steamgriddb.com profile preferences). Unlocks full portrait + + * hero + logo + header art with fuzzy title matching. Absent → the keyless libretro fallback. + */ + steamGridDbKey?: string; +} + +export interface UiOptions { + /** + * Fallback standalone mode (design §9): serve the UI on a fixed, possibly non-loopback port behind + * a password instead of via the console proxy. For host-only installs without the console. + */ + standalone: boolean; + /** Standalone bind port. */ + port: number; + /** Standalone bind address (defaults to loopback; a non-loopback bind REQUIRES a password). */ + bind: string; + /** scrypt password hash (`scrypt$$`), required for a non-loopback standalone bind. */ + passwordHash?: string; +} + +/** The on-disk config as authored (all optional — `resolveConfig` fills the rest). */ +export interface RawConfig { + roots?: RomRoot[]; + /** Per-platform launch overrides, keyed by platform id. */ + platformLaunch?: Record; + /** Per-game overrides, keyed by `external_id`. */ + gameOverrides?: Record; + /** Operator-added / overriding platform defs (merged over the built-ins by id). */ + platforms?: Platform[]; + /** Operator-added / overriding emulator defs (merged over the built-ins by id). */ + emulators?: EmulatorDef[]; + sync?: Partial; + art?: Partial; + ui?: Partial; + /** Dev/M0 acceptance flag: reconcile one hardcoded entry so the round-trip is observable. */ + devEntry?: boolean; +} + +/** The fully-resolved config the engine consumes. */ +export interface Config { + roots: RomRoot[]; + platformLaunch: Record; + gameOverrides: Record; + platforms?: Platform[]; + emulators?: EmulatorDef[]; + sync: SyncOptions; + art: ArtOptions; + ui: UiOptions; + devEntry: boolean; +} + +export const DEFAULT_SYNC: SyncOptions = { + pollMinutes: 15, + watch: true, + debounceMs: 2000, + dedupeRegions: [], + regionPriority: ["USA", "World", "Europe", "Japan"], + warnEntries: 2000, + maxEntries: 5000, + closeOnEnd: false, +}; + +export const DEFAULT_UI: UiOptions = { + standalone: false, + port: 47993, + bind: "127.0.0.1", +}; + +/** Fill defaults over an authored config so the rest of the engine sees a total shape. */ +export const resolveConfig = (raw: RawConfig): Config => ({ + roots: raw.roots ?? [], + platformLaunch: raw.platformLaunch ?? {}, + gameOverrides: raw.gameOverrides ?? {}, + platforms: raw.platforms, + emulators: raw.emulators, + sync: { ...DEFAULT_SYNC, ...raw.sync }, + art: { enabled: true, provider: "auto", ...raw.art }, + ui: { ...DEFAULT_UI, ...raw.ui }, + devEntry: raw.devEntry ?? false, +}); + +/** The empty starting config for a fresh install (no roots yet). */ +export const emptyConfig = (): Config => resolveConfig({}); diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..b06bb73 --- /dev/null +++ b/src/const.ts @@ -0,0 +1,7 @@ +// Plugin identity (design §4). The package name is `punktfunk-plugin-rom-manager` (unscoped — the +// runner's glob), but the `definePlugin` name, the provider id, and the console-nav id are all the +// short `rom-manager`. +export const PLUGIN_NAME = "rom-manager"; +export const PROVIDER_ID = "rom-manager"; +export const UI_TITLE = "ROM Manager"; +export const UI_ICON = "gamepad-2"; diff --git a/src/emulators.ts b/src/emulators.ts new file mode 100644 index 0000000..7b660b7 --- /dev/null +++ b/src/emulators.ts @@ -0,0 +1,418 @@ +// Emulator registry + detection (design §6). Same data-driven shape as the platform registry: each +// emulator declares how to find it per-OS (PATH command, Flatpak app id, or known install path), the +// RetroArch cores directories to scan, and the launch-command template. Detection is best-effort and +// **injectable** (the `DetectEnv` seam) so it is unit-testable without a real machine. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { type Os, quoteFor } from "./quote.js"; + +/** A platform's default (or a per-platform/per-game override) launcher choice. */ +export interface DefaultLaunch { + /** Emulator id (`retroarch`, `dolphin`, …) — a key into {@link EMULATORS} or a `custom-*` template. */ + emulator: string; + /** RetroArch core base name (`snes9x`) — required for `retroarch`, ignored otherwise. */ + core?: string; + /** Operator-typed extra args, appended verbatim (trusted). */ + extraArgs?: string; +} + +export interface EmulatorDef { + id: string; + name: string; + /** + * How to find the emulator, tried in order. An entry is one of: `"flatpak:"`, a path + * (contains a separator, `~`, or an env var like `%APPDATA%`), or a bare PATH command. + */ + detect: { linux: string[]; windows: string[] }; + /** RetroArch cores directories to scan for available cores (per-OS), first existing wins. */ + coresDir?: { linux: string[]; windows: string[] }; + /** Launch template — `{exe}` (verbatim launcher), `{core}`/`{rom}` (quoted). */ + template: string; + /** Whether the emulator can load `.zip`/`.7z` directly (archive gating, design §5). */ + supportsArchives: boolean; + /** Contested legal status (design Q4): shipped as a built-in def but flagged in the UI. */ + contested?: boolean; +} + +export const EMULATORS: EmulatorDef[] = [ + { + id: "retroarch", + name: "RetroArch", + detect: { + linux: ["retroarch", "flatpak:org.libretro.RetroArch"], + windows: [ + "%APPDATA%\\RetroArch\\retroarch.exe", + "C:\\RetroArch-Win64\\retroarch.exe", + "C:\\RetroArch\\retroarch.exe", + ], + }, + coresDir: { + linux: [ + "~/.config/retroarch/cores", + "~/.var/app/org.libretro.RetroArch/config/retroarch/cores", + ], + windows: ["%APPDATA%\\RetroArch\\cores", "C:\\RetroArch-Win64\\cores"], + }, + template: "{exe} -f -L {core} {rom}", + supportsArchives: true, + }, + { + id: "dolphin", + name: "Dolphin", + detect: { + linux: ["dolphin-emu", "flatpak:org.DolphinEmu.dolphin-emu"], + windows: [ + "C:\\Program Files\\Dolphin-x64\\Dolphin.exe", + "%LOCALAPPDATA%\\Dolphin\\Dolphin.exe", + ], + }, + template: "{exe} -b -e {rom}", + supportsArchives: false, + }, + { + id: "pcsx2", + name: "PCSX2", + detect: { + linux: ["pcsx2-qt", "pcsx2", "flatpak:net.pcsx2.PCSX2"], + windows: [ + "C:\\Program Files\\PCSX2\\pcsx2-qt.exe", + "C:\\Program Files (x86)\\PCSX2\\pcsx2-qt.exe", + ], + }, + template: "{exe} -batch -fullscreen -- {rom}", + supportsArchives: false, + }, + { + id: "duckstation", + name: "DuckStation", + detect: { + linux: [ + "duckstation-qt", + "duckstation", + "flatpak:org.duckstation.DuckStation", + ], + windows: [ + "C:\\Program Files\\DuckStation\\duckstation-qt-x64-ReleaseLTCG.exe", + "C:\\Program Files\\DuckStation\\duckstation.exe", + ], + }, + template: "{exe} -batch -fullscreen -- {rom}", + supportsArchives: true, + }, + { + id: "ppsspp", + name: "PPSSPP", + detect: { + linux: ["PPSSPPSDL", "PPSSPPQt", "ppsspp", "flatpak:org.ppsspp.PPSSPP"], + windows: [ + "C:\\Program Files\\PPSSPP\\PPSSPPWindows64.exe", + "C:\\Program Files\\PPSSPP\\PPSSPPWindows.exe", + ], + }, + template: "{exe} --fullscreen {rom}", + supportsArchives: false, + }, + { + id: "mgba", + name: "mGBA", + detect: { + linux: ["mgba-qt", "mgba", "flatpak:io.mgba.mGBA"], + windows: ["C:\\Program Files\\mGBA\\mGBA.exe"], + }, + template: "{exe} -f {rom}", + supportsArchives: true, + }, + { + id: "melonds", + name: "melonDS", + detect: { + linux: ["melonDS", "melonds", "flatpak:net.kuribo64.melonDS"], + windows: ["C:\\Program Files\\melonDS\\melonDS.exe"], + }, + template: "{exe} {rom}", + supportsArchives: false, + }, + { + id: "flycast", + name: "Flycast", + detect: { + linux: ["flycast", "flatpak:org.flycast.Flycast"], + windows: ["C:\\Program Files\\Flycast\\flycast.exe"], + }, + template: "{exe} {rom}", + supportsArchives: true, + }, + { + id: "xemu", + name: "xemu", + detect: { + linux: ["xemu", "flatpak:app.xemu.xemu"], + windows: ["C:\\Program Files\\xemu\\xemu.exe"], + }, + template: "{exe} -full-screen -dvd_path {rom}", + supportsArchives: false, + }, + { + id: "azahar", + name: "Azahar (3DS)", + detect: { + linux: ["azahar", "flatpak:org.azahar_emu.Azahar"], + windows: ["%LOCALAPPDATA%\\Azahar\\azahar.exe"], + }, + template: "{exe} {rom}", + supportsArchives: false, + contested: true, + }, + { + id: "ryujinx", + name: "Ryujinx (Switch)", + detect: { + linux: ["Ryujinx", "ryujinx", "flatpak:org.ryujinx.Ryujinx"], + windows: ["%APPDATA%\\Ryujinx\\Ryujinx.exe"], + }, + template: "{exe} {rom}", + supportsArchives: false, + contested: true, + }, +]; + +const BY_ID = new Map(EMULATORS.map((e) => [e.id, e])); + +/** Look up a built-in emulator def by id. */ +export const emulatorById = (id: string): EmulatorDef | undefined => + BY_ID.get(id); + +/** Merge built-in emulators with operator-defined ones (config `emulators[]`, `custom-*` templates). */ +export const resolveEmulators = ( + overrides?: EmulatorDef[], +): Map => { + const merged = new Map(BY_ID); + for (const e of overrides ?? []) merged.set(e.id, e); + return merged; +}; + +// ---------------------------------------------------------------- detection + +/** The I/O surface detection needs — injected so detection is unit-testable. */ +export interface DetectEnv { + os: Os; + /** Resolve a bare command on PATH to an absolute path, or `null`. */ + which(cmd: string): string | null; + /** Does this absolute path exist? */ + fileExists(p: string): boolean; + /** Is a Flatpak app installed? */ + flatpakInstalled(appId: string): boolean; + /** List a directory's entries (`[]` if missing/unreadable). */ + listDir(p: string): string[]; + /** Expand `~` and env vars (`%VAR%` on Windows, `$VAR` on POSIX). */ + expandPath(p: string): string; +} + +export interface DetectedEmulator { + id: string; + name: string; + template: string; + supportsArchives: boolean; + contested?: boolean; + /** The `{exe}` token, verbatim: a per-OS-quoted absolute path, or `flatpak run `. */ + exeToken: string; + /** Raw absolute executable path (path/file detection) — for deriving a kill target ("close on end"). */ + exePath?: string; + /** Flatpak app id (flatpak detection) — for `flatpak kill `. */ + appId?: string; + /** How it was located (UI). */ + via: "path" | "file" | "flatpak"; + /** RetroArch cores dir that exists, and the core base names found in it. */ + coresDir?: string; + cores?: string[]; +} + +const FLATPAK_ID_RE = /^[A-Za-z][A-Za-z0-9._-]+$/; + +const looksLikePath = (s: string): boolean => + s.includes("/") || + s.includes("\\") || + s.startsWith("~") || + s.includes("%") || + /^[A-Za-z]:/.test(s); + +/** Quote an absolute path into a verbatim `{exe}` token, or `null` if it can't be safely quoted. */ +const exeTokenFor = (os: Os, absPath: string): string | null => { + const q = quoteFor(os)(absPath); + return q.ok ? q.value : null; +}; + +/** The RetroArch core file suffix for a host OS. */ +const coreSuffix = (o: Os): string => (o === "windows" ? ".dll" : ".so"); + +/** Scan cores directories for `_libretro.` files → sorted core base names. */ +const scanCores = ( + env: DetectEnv, + dirs: string[], +): { coresDir?: string; cores: string[] } => { + const suffix = `_libretro${coreSuffix(env.os)}`; + for (const raw of dirs) { + const dir = env.expandPath(raw); + const names = env + .listDir(dir) + .filter((f) => f.endsWith(suffix)) + .map((f) => f.slice(0, -suffix.length)) + .sort(); + if (names.length > 0) return { coresDir: dir, cores: names }; + } + // No cores found, but report the first candidate dir so launch can still form a path. + const firstExisting = dirs.map((d) => env.expandPath(d)).find(env.fileExists); + return { coresDir: firstExisting, cores: [] }; +}; + +/** Detect a single emulator, or `null` if not installed. Pure over the injected `DetectEnv`. */ +export const detectEmulator = ( + def: EmulatorDef, + env: DetectEnv, +): DetectedEmulator | null => { + const candidates = + env.os === "windows" ? def.detect.windows : def.detect.linux; + let exeToken: string | null = null; + let via: DetectedEmulator["via"] | null = null; + let exePath: string | undefined; + let appId: string | undefined; + for (const entry of candidates) { + if (entry.startsWith("flatpak:")) { + const id = entry.slice("flatpak:".length); + if (FLATPAK_ID_RE.test(id) && env.flatpakInstalled(id)) { + exeToken = `flatpak run ${id}`; + via = "flatpak"; + appId = id; + break; + } + } else if (looksLikePath(entry)) { + const abs = env.expandPath(entry); + if (env.fileExists(abs)) { + const t = exeTokenFor(env.os, abs); + if (t) { + exeToken = t; + via = "file"; + exePath = abs; + break; + } + } + } else { + const abs = env.which(entry); + if (abs) { + const t = exeTokenFor(env.os, abs); + if (t) { + exeToken = t; + via = "path"; + exePath = abs; + break; + } + } + } + } + if (!exeToken || !via) return null; + + const found: DetectedEmulator = { + id: def.id, + name: def.name, + template: def.template, + supportsArchives: def.supportsArchives, + contested: def.contested, + exeToken, + exePath, + appId, + via, + }; + if (def.coresDir) { + const dirs = + env.os === "windows" ? def.coresDir.windows : def.coresDir.linux; + const { coresDir, cores } = scanCores(env, dirs); + found.coresDir = coresDir; + found.cores = cores; + } + return found; +}; + +/** Detect every emulator in the registry that is installed. */ +export const detectAll = ( + env: DetectEnv, + defs: Iterable = 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), + }; +}; diff --git a/src/engine/close.ts b/src/engine/close.ts new file mode 100644 index 0000000..95be0ce --- /dev/null +++ b/src/engine/close.ts @@ -0,0 +1,38 @@ +// "Close the emulator when the stream ends" (design §6, default off). We attach a per-title `prep` +// whose `undo` kills the emulator at session end so a fullscreen emulator doesn't squat the desktop +// after streaming. `do` is a no-op (prep requires one; `undo` is skipped when `do` fails, so the do +// must succeed). Best-effort by process image name / Flatpak app id — targeted PID tracking is a +// stretch item. + +import * as nodePath from "node:path"; +import type { DetectedEmulator } from "../emulators.js"; +import { type Os, quoteFor } from "../quote.js"; +import type { PrepCmd } from "../wire.js"; + +/** A command that always exits 0, for `prep.do`. */ +const noop = (os: Os): string => (os === "windows" ? "cd ." : "true"); + +/** + * A prep step that kills the emulator at session end, or `null` if we can't derive a safe kill target + * (e.g. a detectionless custom emulator). Kill is by image name (`pkill -x` / `taskkill /IM`) or + * `flatpak kill` — coarse but adequate for a single-session streaming box. + */ +export const killPrep = ( + det: DetectedEmulator | undefined, + os: Os, +): PrepCmd | null => { + if (!det) return null; + if (det.via === "flatpak" && det.appId) { + return { do: noop(os), undo: `flatpak kill ${det.appId}` }; + } + if (!det.exePath) return null; + const image = nodePath.basename(det.exePath); + if (os === "windows") { + const q = quoteFor("windows")(image); + if (!q.ok) return null; + return { do: noop(os), undo: `taskkill /IM ${q.value} /F` }; + } + const q = quoteFor("linux")(image); + if (!q.ok) return null; + return { do: noop(os), undo: `pkill -x ${q.value}` }; +}; diff --git a/src/engine/index.ts b/src/engine/index.ts new file mode 100644 index 0000000..9310803 --- /dev/null +++ b/src/engine/index.ts @@ -0,0 +1,363 @@ +// The sync engine orchestrator (design §3/§8): the headless half of the plugin. It ties the pure core +// (scanner → titles → reconcile) to the real world — filesystem scans, emulator detection, box-art +// warming, and the host reconcile PUT — and drives it on start, on an interval poll, on filesystem +// changes (debounced), and on demand from the UI/CLI. Fully functional from a hand-written +// `config.json` alone; the UI just edits the same config and pokes the same `sync()`. + +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import type { Punktfunk } from "@punktfunk/host"; +import { selectArtProvider, warmArt } from "../art/provider.js"; +import type { Config } from "../config.js"; +import { + type DetectEnv, + type DetectedEmulator, + detectAll, + realDetectEnv, + resolveEmulators, +} from "../emulators.js"; +import { deleteProvider, reconcileProvider } from "../host.js"; +import { type Logger, makeLogger } from "../log.js"; +import { resolvePlatforms } from "../platforms.js"; +import { currentOs, type Os } from "../quote.js"; +import { + type Cache, + loadCache, + loadConfig, + saveCache, + statePaths, +} from "../state.js"; +import { + computeDesired, + type DesiredResult, + enumerateTitles, + type SyncReport, +} from "./reconcile.js"; +import { + type RomCandidate, + realScanFs, + type ScanFs, + scanRoot, +} from "./scanner.js"; + +export interface EngineDeps { + pf: Punktfunk; + os?: Os; + scanFs?: ScanFs; + detectEnv?: DetectEnv; + log?: Logger; +} + +export interface EngineStatus { + rootsConfigured: number; + os: Os; + artProvider: string | null; + lastSync?: Cache["lastSync"]; + lastReport?: SyncReport; + detected?: { at: number; emulators: DetectedEmulator[] }; + paths: ReturnType; + syncing: boolean; +} + +/** A stable content fingerprint of the desired entries — lets an unchanged rescan skip the PUT. */ +const fingerprint = (entries: unknown): string => + createHash("sha256").update(JSON.stringify(entries)).digest("hex"); + +/** A synthetic entry proving the reconcile round-trip end-to-end (M0 dev flag / acceptance). */ +const devEntry = (os: Os) => ({ + external_id: "__dev__/hello", + title: "ROM Manager (dev entry)", + launch: { + kind: "command" as const, + value: os === "windows" ? "cmd /c echo hi" : "echo hi", + }, +}); + +export class Engine { + private readonly pf: Punktfunk; + private readonly os: Os; + private readonly scanFs: ScanFs; + private readonly detectEnv: DetectEnv; + private readonly log: Logger; + private cache: Cache; + + private syncing = false; + private pending = false; + private lastReport?: SyncReport; + private pollTimer?: ReturnType; + private debounceTimer?: ReturnType; + private watchers: fs.FSWatcher[] = []; + private readonly listeners = new Set<(status: EngineStatus) => void>(); + + constructor(deps: EngineDeps) { + this.pf = deps.pf; + this.os = deps.os ?? currentOs(); + this.scanFs = deps.scanFs ?? realScanFs; + this.detectEnv = deps.detectEnv ?? realDetectEnv(); + this.log = deps.log ?? makeLogger(); + this.cache = loadCache(); + } + + // ---------------------------------------------------------------- lifecycle + + /** Initial sync, then wire the interval poll and (best-effort) filesystem watchers. */ + async start(): Promise { + await this.sync("startup"); + this.schedulePoll(); + this.setupWatchers(); + } + + /** Tear down timers and watchers (called from the plugin's shutdown finalizer). */ + stop(): void { + if (this.pollTimer) clearInterval(this.pollTimer); + if (this.debounceTimer) clearTimeout(this.debounceTimer); + for (const w of this.watchers) { + try { + w.close(); + } catch { + // already closed + } + } + this.watchers = []; + } + + private schedulePoll(): void { + const config = loadConfig(); + const ms = Math.max(1, config.sync.pollMinutes) * 60_000; + if (this.pollTimer) clearInterval(this.pollTimer); + this.pollTimer = setInterval(() => void this.sync("poll"), ms); + (this.pollTimer as { unref?: () => void }).unref?.(); + } + + private setupWatchers(): void { + for (const w of this.watchers) { + try { + w.close(); + } catch { + // ignore + } + } + this.watchers = []; + const config = loadConfig(); + if (!config.sync.watch) return; + for (const root of config.roots) { + try { + // `recursive` is honored on macOS/Windows; on Linux it throws and we watch the top dir + // only — the interval poll is the real safety net (watchers are unreliable on SMB/NFS). + const watcher = fs.watch(root.dir, { recursive: true }, () => + this.debouncedSync(), + ); + this.watchers.push(watcher); + } catch { + try { + this.watchers.push(fs.watch(root.dir, () => this.debouncedSync())); + } catch { + this.log(`watch: cannot watch ${root.dir} (poll only)`); + } + } + } + } + + private debouncedSync(): void { + const config = loadConfig(); + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout( + () => void this.sync("fs-change"), + config.sync.debounceMs, + ); + (this.debounceTimer as { unref?: () => void }).unref?.(); + } + + // ---------------------------------------------------------------- scan + detect + + private scanAll(config: Config): RomCandidate[] { + const platforms = resolvePlatforms(config.platforms); + const out: RomCandidate[] = []; + for (const root of config.roots) { + const platform = platforms.get(root.platform); + if (!platform) { + this.log( + `scan: root ${root.dir} has unknown platform "${root.platform}" — skipped`, + ); + continue; + } + try { + out.push(...scanRoot(this.scanFs, root.dir, platform, root.excludes)); + } catch (e) { + this.log(`scan: ${root.dir}: ${e}`); + } + } + return out; + } + + /** Emulator detection, cached until `force` (re-probed from the UI's "detect" button). */ + detect(config: Config, force: boolean): DetectedEmulator[] { + if ( + !force && + this.cache.detect && + this.cache.detect.os === this.os && + this.cache.detect.emulators.length >= 0 + ) { + return this.cache.detect.emulators; + } + const defs = resolveEmulators(config.emulators); + const emulators = detectAll(this.detectEnv, defs.values()); + this.cache.detect = { at: Date.now(), os: this.os, emulators }; + saveCache(this.cache); + return emulators; + } + + // ---------------------------------------------------------------- compute + + /** Scan + detect + (cached) art → desired entries, WITHOUT touching the host. Used by the UI preview. */ + async computeDry( + force = true, + ): Promise { + const config = loadConfig(); + const scan = this.scanAll(config); + const detected = this.detect(config, force); + await this.warm(config, scan); + const result = computeDesired({ + config, + scan, + detected, + art: this.cache.art, + os: this.os, + }); + return { ...result, detected }; + } + + /** Warm the art cache for everything the current scan will include (best-effort, provider-agnostic). */ + private async warm(config: Config, scan: RomCandidate[]): Promise { + const provider = selectArtProvider(config); + if (!provider) return; + const { survivors } = enumerateTitles(config, scan); + const targets = survivors.map((s) => ({ + externalId: s.externalId, + platform: s.platform, + parsed: s.parsed, + })); + const n = await warmArt(provider, targets, this.cache); + if (n > 0) { + saveCache(this.cache); + this.log(`art: resolved ${n} title(s) via ${provider.id.split(":")[0]}`); + } + } + + // ---------------------------------------------------------------- sync + + /** The guarded reconcile: coalesces concurrent triggers, skips the PUT when nothing changed. */ + async sync(reason: string): Promise { + if (this.syncing) { + this.pending = true; + return undefined; + } + this.syncing = true; + try { + const config = loadConfig(); + const scan = this.scanAll(config); + const detected = this.detect(config, false); + await this.warm(config, scan); + const result = computeDesired({ + config, + scan, + detected, + art: this.cache.art, + os: this.os, + }); + + const entries = config.devEntry + ? [...result.entries, devEntry(this.os)] + : result.entries; + const fp = fingerprint(entries); + if (this.cache.lastSync?.fingerprint !== fp) { + await reconcileProvider(this.pf, entries); + this.cache.lastSync = { + fingerprint: fp, + count: entries.length, + at: Date.now(), + }; + saveCache(this.cache); + this.log(`sync (${reason}): reconciled ${entries.length} title(s)`); + } else { + this.log(`sync (${reason}): no changes (${entries.length} title(s))`); + } + this.lastReport = result.report; + this.logReport(result.report); + return result.report; + } catch (e) { + this.log(`sync (${reason}) failed: ${e}`); + return undefined; + } finally { + this.syncing = false; + this.notify(); + if (this.pending) { + this.pending = false; + queueMicrotask(() => void this.sync("coalesced")); + } + } + } + + /** Subscribe to status changes (the UI's SSE feed). Returns an unsubscribe function. */ + subscribe(cb: (status: EngineStatus) => void): () => void { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + } + + private notify(): void { + const status = this.status(); + for (const cb of this.listeners) { + try { + cb(status); + } catch { + // a dead SSE listener must not break sync + } + } + } + + private logReport(report: SyncReport): void { + if (report.skipped.length > 0) { + this.log( + ` ${report.skipped.length} skipped (e.g. ${report.skipped[0]?.title}: ${report.skipped[0]?.reason})`, + ); + } + if (report.overWarn) { + this.log( + ` WARNING: ${report.included} entries — large library (design scale guard)`, + ); + } + for (const w of report.warnings.slice(0, 5)) this.log(` ${w}`); + } + + /** Reconfigure timers/watchers after a config change (e.g. the UI saved new roots). */ + async reconfigure(): Promise { + this.schedulePoll(); + this.setupWatchers(); + await this.sync("config-change"); + } + + /** Remove every entry this provider owns (CLI `uninstall`). */ + async uninstall(): Promise { + await deleteProvider(this.pf); + this.cache.lastSync = undefined; + saveCache(this.cache); + this.log("uninstalled: provider entries removed"); + } + + // ---------------------------------------------------------------- status + + status(): EngineStatus { + const config = loadConfig(); + const provider = selectArtProvider(config); + return { + rootsConfigured: config.roots.length, + os: this.os, + artProvider: provider ? (provider.id.split(":")[0] ?? null) : null, + lastSync: this.cache.lastSync, + lastReport: this.lastReport, + detected: this.cache.detect, + paths: statePaths(), + syncing: this.syncing, + }; + } +} diff --git a/src/engine/launch.ts b/src/engine/launch.ts new file mode 100644 index 0000000..16a61ea --- /dev/null +++ b/src/engine/launch.ts @@ -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; + defs: Map; + 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 }; +}; diff --git a/src/engine/reconcile.ts b/src/engine/reconcile.ts new file mode 100644 index 0000000..41ee3d1 --- /dev/null +++ b/src/engine/reconcile.ts @@ -0,0 +1,278 @@ +// The desired-state compute (design §8) — a **pure function** of `config × scan results × detection × +// art cache → ProviderEntryInput[]`, unit-testable with no host and no filesystem. The engine +// orchestrator (index.ts / cli.ts) does the I/O (scan, detect, art-probe) and hands the results here. +// +// `enumerateTitles` is factored out so the orchestrator can learn which titles need art (and warm the +// cache) BEFORE the final `computeDesired`, without re-implementing the exclude/dedupe rules. + +import type { Config, GameOverride } from "../config.js"; +import { type DetectedEmulator, resolveEmulators } from "../emulators.js"; +import { type Platform, resolvePlatforms } from "../platforms.js"; +import type { Os } from "../quote.js"; +import type { ArtVerdict } from "../state.js"; +import type { Artwork, ProviderEntryInput } from "../wire.js"; +import { killPrep } from "./close.js"; +import { type EffectiveLaunch, resolveLaunch } from "./launch.js"; +import type { RomCandidate } from "./scanner.js"; +import { + dedupeKey, + type ParsedTitle, + parseTitle, + pickBestRelease, +} from "./titles.js"; + +/** A candidate the compute rejected, with a human reason (surfaced in the UI/CLI). */ +export interface Skipped { + external_id: string; + title: string; + reason: string; +} + +/** One enumerated title (post-exclude, post-dedupe) — the unit both art-warming and reconcile key on. */ +export interface EnumeratedTitle { + candidate: RomCandidate; + platform: Platform; + externalId: string; + parsed: ParsedTitle; + override: GameOverride | undefined; +} + +/** A summary of one desired-state compute (design §8/§9 Sync page). */ +export interface SyncReport { + /** Candidates considered (post-exclude, post-dedupe). */ + considered: number; + /** Entries produced. */ + included: number; + skipped: Skipped[]; + /** Titles the operator explicitly excluded (per-game override) — surfaced so the UI can re-include. */ + excluded: { external_id: string; title: string }[]; + warnings: string[]; + /** Entries dropped by the hard `maxEntries` cap. */ + truncated: number; + /** Included-entry count per platform. */ + perPlatform: Record; + /** True once `included >= warnEntries` (UI scale warning). */ + overWarn: boolean; +} + +export interface DesiredResult { + entries: ProviderEntryInput[]; + report: SyncReport; +} + +/** Layer the effective launch: platform default ← per-platform override ← per-game override. */ +const effectiveLaunch = ( + platform: Platform, + config: Config, + override: GameOverride | undefined, +): EffectiveLaunch => { + const plat = config.platformLaunch[platform.id]; + const base = plat ?? platform.defaultLaunch; + return { + emulator: override?.emulator ?? base.emulator, + core: override?.core ?? base.core, + extraArgs: override?.extraArgs ?? base.extraArgs, + }; +}; + +/** Whether a platform has more than one root — the trigger for a root ordinal in its `external_id`s. */ +const rootOrdinals = (config: Config): Map> => { + const perPlatform = new Map(); + 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>(); + 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: `/`, with a `/` segment + * inserted only when the platform has multiple roots (design §4). + */ +const externalIdFor = ( + candidate: RomCandidate, + ordinals: Map>, +): 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(); + 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; + os: Os; +}): DesiredResult => { + const { config, scan, detected, art, os } = params; + const defs = resolveEmulators(config.emulators); + const detectedMap = new Map( + detected.map((d) => [d.id, d]), + ); + + const { survivors, skipped, excluded } = enumerateTitles(config, scan); + const warnings: string[] = []; + + const entries: ProviderEntryInput[] = []; + const perPlatform: Record = {}; + for (const p of survivors) { + const launch = effectiveLaunch(p.platform, config, p.override); + const resolution = resolveLaunch({ + platform: p.platform, + candidate: p.candidate, + launch, + detected: detectedMap, + defs, + os, + }); + const title = p.override?.title ?? p.parsed.displayTitle; + if (!resolution.ok) { + skipped.push({ + external_id: p.externalId, + title, + reason: resolution.reason, + }); + continue; + } + for (const w of resolution.warnings) warnings.push(`${title}: ${w}`); + + const entry: ProviderEntryInput = { + external_id: p.externalId, + title, + launch: { kind: "command", value: resolution.value }, + }; + + const artwork = resolveArtwork(art[p.externalId], p.override); + if (artwork && Object.keys(artwork).length > 0) entry.art = artwork; + + // Optional "close emulator when the stream ends" (default off). + if (config.sync.closeOnEnd) { + const prep = killPrep(detectedMap.get(launch.emulator), os); + if (prep) entry.prep = [prep]; + } + + entries.push(entry); + perPlatform[p.platform.id] = (perPlatform[p.platform.id] ?? 0) + 1; + } + + // Deterministic order + scale guard. + entries.sort((a, b) => a.external_id.localeCompare(b.external_id)); + let truncated = 0; + if (entries.length > config.sync.maxEntries) { + truncated = entries.length - config.sync.maxEntries; + entries.length = config.sync.maxEntries; + warnings.push( + `entry count ${truncated + config.sync.maxEntries} exceeds maxEntries=${config.sync.maxEntries}; ${truncated} dropped`, + ); + } + + return { + entries, + report: { + considered: survivors.length, + included: entries.length, + skipped, + excluded, + warnings, + truncated, + perPlatform, + overWarn: entries.length >= config.sync.warnEntries, + }, + }; +}; diff --git a/src/engine/scanner.ts b/src/engine/scanner.ts new file mode 100644 index 0000000..4ce1634 --- /dev/null +++ b/src/engine/scanner.ts @@ -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: ` ` — 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(); + const referencedBySheet = new Set(); + 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"); + }, +}; diff --git a/src/engine/titles.ts b/src/engine/titles.ts new file mode 100644 index 0000000..6afe976 --- /dev/null +++ b/src/engine/titles.ts @@ -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 = ( + candidates: T[], + regionPriority: string[], +): T => { + const rank = (c: T): number => { + const idx = c.parsed.regions + .map((r) => + regionPriority.findIndex((p) => p.toLowerCase() === r.toLowerCase()), + ) + .filter((i) => i >= 0) + .sort((a, b) => a - b)[0]; + return idx === undefined ? regionPriority.length + 1 : idx; + }; + return [...candidates].sort((a, b) => { + const ra = rank(a); + const rb = rank(b); + if (ra !== rb) return ra - rb; + const la = a.parsed.noIntroName.length; + const lb = b.parsed.noIntroName.length; + if (la !== lb) return la - lb; + return a.parsed.noIntroName.localeCompare(b.parsed.noIntroName); + })[0] as T; +}; diff --git a/src/host.ts b/src/host.ts new file mode 100644 index 0000000..f2c5cdd --- /dev/null +++ b/src/host.ts @@ -0,0 +1,20 @@ +// The host side of the reconcile (design §2). We PUT the declarative entry list to the provider +// endpoint and let the host diff by `external_id` (stable ids, orphan drop, manual entries untouched). +// Done through `pf.request` rather than the typed `pf.api.*` deliberately: under the packaged runner +// the facade is built by the runner's *bundled* SDK copy, whose generated client may predate an +// endpoint — the untyped request seam is version-skew-proof (same reasoning as `servePluginUi`). + +import type { Punktfunk } from "@punktfunk/host"; +import { PROVIDER_ID } from "./const.js"; +import type { ProviderEntryInput } from "./wire.js"; + +/** Full-replace reconcile: the host makes the library match `entries` exactly for this provider. */ +export const reconcileProvider = ( + pf: Punktfunk, + entries: ProviderEntryInput[], +): Promise => + pf.request("PUT", `/library/provider/${PROVIDER_ID}`, entries); + +/** Clean uninstall: remove every entry this provider owns. */ +export const deleteProvider = (pf: Punktfunk): Promise => + pf.request("DELETE", `/library/provider/${PROVIDER_ID}`); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f89eb67 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,65 @@ +// The plugin entry (design §11). `main` is the plain-async form (NOT the Effect form): the packaged +// runner bundles its own effect/SDK copy and cross-instance Effect identity is unverified (design D3), +// so the async facade sidesteps it entirely. The runner supervises this with restart-on-crash and a +// structured SIGTERM shutdown; a direct `bun src/index.ts` run works too (bottom of file). +// +// Lifecycle: connect (done by the runner/facade) → start the sync engine → serve the UI (console-hosted +// by default, standalone if configured) → run until SIGTERM → deregister the UI and stop the engine. +// Provider entries are deliberately LEFT in the library on shutdown so the games survive plugin +// restarts and host reboots; a clean uninstall is the explicit `uninstall` CLI command. + +import { connect, definePlugin, type Punktfunk } from "@punktfunk/host"; +import { PLUGIN_NAME } from "./const.js"; +import { Engine } from "./engine/index.js"; +import { log } from "./log.js"; +import { serveConsoleUi } from "./server/index.js"; +import { serveStandaloneUi } from "./server/standalone.js"; +import { loadConfig } from "./state.js"; +import { readVersion } from "./version.js"; + +const plugin = definePlugin({ + name: PLUGIN_NAME, + main: async (pf: Punktfunk) => { + const engine = new Engine({ pf }); + const config = loadConfig(); + + // Headless engine first — the library syncs even if the UI can't start. + await engine.start(); + + let closeUi: () => Promise = async () => {}; + try { + if (config.ui.standalone) { + const handle = serveStandaloneUi(engine, config); + closeUi = handle.close; + } else { + const handle = await serveConsoleUi(pf, engine, { + version: readVersion(), + }); + closeUi = handle.close; + log(`UI registered with the console (nav entry "${PLUGIN_NAME}")`); + } + } catch (e) { + log(`UI server failed to start (engine keeps running headless): ${e}`); + } + + // Run until the runner (or a direct SIGINT) asks us to stop. The runner sends SIGTERM on + // shutdown; both its handler and ours fire, and this resolves. + await new Promise((resolve) => { + process.once("SIGINT", resolve); + process.once("SIGTERM", resolve); + }); + + log("shutting down"); + await closeUi(); + engine.stop(); + }, +}); + +export default plugin; + +// Allow a direct `bun src/index.ts` run outside the managed runner (dev). +if (import.meta.main) { + const pf = await connect(); + await (plugin.main as (pf: Punktfunk) => Promise)(pf); + pf.close(); +} diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..b7c87b1 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,10 @@ +// A tiny stamped logger, matching the runner's line format so a plugin's output reads consistently in +// the runner journal. `child` prefixes a scope (e.g. `[scan]`). +export type Logger = (line: string) => void; + +export const makeLogger = (prefix = "rom-manager"): Logger => { + return (line: string) => + console.log(`${new Date().toISOString()} [${prefix}] ${line}`); +}; + +export const log: Logger = makeLogger(); diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..c267723 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,28 @@ +// Where the plugin's own files live. The host config dir resolution mirrors the SDK's `configDir` +// (`@punktfunk/host` does not re-export it) so we always land in the same place the host and runner +// use; the plugin owns a `rom-manager/` subtree under it (design §9), created 0700. +import * as os from "node:os"; +import * as path from "node:path"; + +/** The host's config dir — the same resolution the host and SDK use. */ +export const hostConfigDir = (): string => { + const explicit = process.env.PUNKTFUNK_CONFIG_DIR; + if (explicit) return explicit; + if (process.platform === "win32") { + const base = process.env.ProgramData ?? process.env.APPDATA ?? "."; + return path.join(base, "punktfunk"); + } + const base = + process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); + return path.join(base, "punktfunk"); +}; + +/** This plugin's private directory: `/rom-manager`. */ +export const pluginDir = (): string => + path.join(hostConfigDir(), "rom-manager"); + +/** `/rom-manager/config.json` — operator-editable, atomic-written. */ +export const configPath = (): string => path.join(pluginDir(), "config.json"); + +/** `/rom-manager/cache.json` — disposable derived state (art verdicts, detection, fingerprint). */ +export const cachePath = (): string => path.join(pluginDir(), "cache.json"); diff --git a/src/platforms.ts b/src/platforms.ts new file mode 100644 index 0000000..ff28b5c --- /dev/null +++ b/src/platforms.ts @@ -0,0 +1,252 @@ +// The console/platform registry (design §5) — shipped as data, operator-extensible via config. Each +// entry declares the file extensions that identify a ROM for that platform, the libretro-thumbnails +// system name used for box art (design §7), and the default emulator/core to launch it with. +// +// Extensions like `.iso` and `.bin` are shared across platforms — that ambiguity is resolved by the +// scanner's unit of work being a **ROM root** `(directory, platform)`: a `.iso` under a `gamecube` +// root is a GameCube disc, under a `ps2` root a PS2 disc. Extensions here are lowercase, dot-prefixed. + +import type { DefaultLaunch } from "./emulators.js"; + +export interface Platform { + /** Stable kebab-case id — the first segment of every `external_id` (`snes/Chrono Trigger.sfc`). */ + id: string; + /** Human-readable name (console nav, UI). */ + name: string; + /** Lowercase, dot-prefixed extensions that mark a file as this platform's ROM. */ + extensions: string[]; + /** + * The libretro-thumbnails system directory name (design §7) — the box-art source key. `undefined` + * for platforms libretro has no thumbnail set for (Switch, Xbox), which simply get no cover in v1. + */ + libretroSystem?: string; + /** Default emulator + core for this platform (operator-overridable per platform / per game). */ + defaultLaunch: DefaultLaunch; + /** + * Disc-based platform: `.cue`/`.gdi`/`.m3u` folding applies and loose `.bin` track files are + * hidden behind their sheet (design §5, M2). Cartridge platforms leave this false. + */ + disc?: boolean; +} + +/** The built-in platform set (~25 at launch). Arcade (MAME/FBNeo romset versioning) is deferred (D6). */ +export const PLATFORMS: Platform[] = [ + // ── Nintendo ────────────────────────────────────────────────────────────────────────────── + { + id: "nes", + name: "Nintendo Entertainment System", + extensions: [".nes", ".unf", ".unif", ".fds"], + libretroSystem: "Nintendo - Nintendo Entertainment System", + defaultLaunch: { emulator: "retroarch", core: "mesen" }, + }, + { + id: "snes", + name: "Super Nintendo", + extensions: [".sfc", ".smc", ".bs"], + libretroSystem: "Nintendo - Super Nintendo Entertainment System", + defaultLaunch: { emulator: "retroarch", core: "snes9x" }, + }, + { + id: "n64", + name: "Nintendo 64", + extensions: [".n64", ".z64", ".v64"], + libretroSystem: "Nintendo - Nintendo 64", + defaultLaunch: { emulator: "retroarch", core: "mupen64plus_next" }, + }, + { + id: "gamecube", + name: "GameCube", + extensions: [".iso", ".rvz", ".gcm", ".gcz", ".ciso"], + libretroSystem: "Nintendo - GameCube", + defaultLaunch: { emulator: "dolphin" }, + disc: true, + }, + { + id: "wii", + name: "Wii", + extensions: [".iso", ".rvz", ".wbfs", ".wad", ".gcz", ".ciso"], + libretroSystem: "Nintendo - Wii", + defaultLaunch: { emulator: "dolphin" }, + disc: true, + }, + { + id: "switch", + name: "Nintendo Switch", + extensions: [".nsp", ".xci", ".nca"], + // libretro has no thumbnail set for Switch — no cover in v1. + defaultLaunch: { emulator: "ryujinx" }, + }, + { + id: "gb", + name: "Game Boy", + extensions: [".gb"], + libretroSystem: "Nintendo - Game Boy", + defaultLaunch: { emulator: "retroarch", core: "gambatte" }, + }, + { + id: "gbc", + name: "Game Boy Color", + extensions: [".gbc"], + libretroSystem: "Nintendo - Game Boy Color", + defaultLaunch: { emulator: "retroarch", core: "gambatte" }, + }, + { + id: "gba", + name: "Game Boy Advance", + extensions: [".gba", ".srl"], + libretroSystem: "Nintendo - Game Boy Advance", + defaultLaunch: { emulator: "retroarch", core: "mgba" }, + }, + { + id: "nds", + name: "Nintendo DS", + extensions: [".nds", ".dsi"], + libretroSystem: "Nintendo - Nintendo DS", + defaultLaunch: { emulator: "retroarch", core: "melonds" }, + }, + { + id: "3ds", + name: "Nintendo 3DS", + extensions: [".3ds", ".cci", ".cxi", ".cia"], + libretroSystem: "Nintendo - Nintendo 3DS", + defaultLaunch: { emulator: "azahar" }, + }, + // ── Sony ────────────────────────────────────────────────────────────────────────────────── + { + id: "ps1", + name: "PlayStation", + extensions: [ + ".cue", + ".bin", + ".img", + ".pbp", + ".chd", + ".ecm", + ".m3u", + ".iso", + ], + libretroSystem: "Sony - PlayStation", + defaultLaunch: { emulator: "duckstation" }, + disc: true, + }, + { + id: "ps2", + name: "PlayStation 2", + extensions: [".iso", ".chd", ".cso", ".gz", ".cue", ".bin", ".m3u"], + libretroSystem: "Sony - PlayStation 2", + defaultLaunch: { emulator: "pcsx2" }, + disc: true, + }, + { + id: "psp", + name: "PlayStation Portable", + extensions: [".iso", ".cso", ".pbp", ".chd"], + libretroSystem: "Sony - PlayStation Portable", + defaultLaunch: { emulator: "ppsspp" }, + disc: true, + }, + // ── Sega ────────────────────────────────────────────────────────────────────────────────── + { + id: "genesis", + name: "Genesis / Mega Drive", + extensions: [".md", ".gen", ".smd", ".bin", ".sgd"], + libretroSystem: "Sega - Mega Drive - Genesis", + defaultLaunch: { emulator: "retroarch", core: "genesis_plus_gx" }, + }, + { + id: "sms", + name: "Master System", + extensions: [".sms"], + libretroSystem: "Sega - Master System - Mark III", + defaultLaunch: { emulator: "retroarch", core: "genesis_plus_gx" }, + }, + { + id: "gamegear", + name: "Game Gear", + extensions: [".gg"], + libretroSystem: "Sega - Game Gear", + defaultLaunch: { emulator: "retroarch", core: "genesis_plus_gx" }, + }, + { + id: "saturn", + name: "Saturn", + extensions: [".cue", ".bin", ".chd", ".ccd", ".mds", ".m3u", ".iso"], + libretroSystem: "Sega - Saturn", + defaultLaunch: { emulator: "retroarch", core: "mednafen_saturn" }, + disc: true, + }, + { + id: "dreamcast", + name: "Dreamcast", + extensions: [".gdi", ".cdi", ".chd", ".cue", ".m3u"], + libretroSystem: "Sega - Dreamcast", + defaultLaunch: { emulator: "flycast" }, + disc: true, + }, + // ── Other ───────────────────────────────────────────────────────────────────────────────── + { + id: "pcengine", + name: "PC Engine / TurboGrafx-16", + extensions: [".pce", ".sgx", ".cue", ".chd", ".ccd", ".m3u"], + libretroSystem: "NEC - PC Engine - TurboGrafx 16", + defaultLaunch: { emulator: "retroarch", core: "mednafen_pce" }, + disc: true, + }, + { + id: "ngp", + name: "Neo Geo Pocket", + extensions: [".ngp", ".ngc"], + libretroSystem: "SNK - Neo Geo Pocket Color", + defaultLaunch: { emulator: "retroarch", core: "mednafen_ngp" }, + }, + { + id: "wonderswan", + name: "WonderSwan", + extensions: [".ws", ".wsc"], + libretroSystem: "Bandai - WonderSwan Color", + defaultLaunch: { emulator: "retroarch", core: "mednafen_wswan" }, + }, + { + id: "lynx", + name: "Atari Lynx", + extensions: [".lnx"], + libretroSystem: "Atari - Lynx", + defaultLaunch: { emulator: "retroarch", core: "mednafen_lynx" }, + }, + { + id: "atari2600", + name: "Atari 2600", + extensions: [".a26"], + libretroSystem: "Atari - 2600", + defaultLaunch: { emulator: "retroarch", core: "stella" }, + }, + { + id: "atari7800", + name: "Atari 7800", + extensions: [".a78"], + libretroSystem: "Atari - 7800", + defaultLaunch: { emulator: "retroarch", core: "prosystem" }, + }, + { + id: "xbox", + name: "Xbox", + extensions: [".iso", ".xbe"], + // No libretro thumbnail set — no cover in v1. + defaultLaunch: { emulator: "xemu" }, + disc: true, + }, +]; + +const BY_ID = new Map(PLATFORMS.map((p) => [p.id, p])); + +/** Look up a built-in platform by id. */ +export const platformById = (id: string): Platform | undefined => BY_ID.get(id); + +/** Merge the built-in registry with operator-defined platforms (config `platforms[]` override by id). */ +export const resolvePlatforms = ( + overrides?: Platform[], +): Map => { + const merged = new Map(BY_ID); + for (const p of overrides ?? []) merged.set(p.id, p); + return merged; +}; diff --git a/src/quote.ts b/src/quote.ts new file mode 100644 index 0000000..d6c5b75 --- /dev/null +++ b/src/quote.ts @@ -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(); + 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 }; +}; diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..e80c16e --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,32 @@ +// The console-hosted UI server (design §9, primary path). `servePluginUi` from the SDK owns the whole +// plugin side — a loopback ephemeral port behind a per-boot secret, registration + lease renewal with +// the host, and the console reverse-proxy contract — so we just hand it the built SPA directory and the +// plugin-local API router. The operator signs into the console once; the "ROM Manager" nav entry +// appears automatically. Zero human auth here. + +import { + type PluginUiHandle, + type Punktfunk, + servePluginUi, +} from "@punktfunk/host"; +import { PLUGIN_NAME, UI_ICON, UI_TITLE } from "../const.js"; +import type { Engine } from "../engine/index.js"; +import { makeRouter } from "./router.js"; + +/** + * Serve the UI through the console. `../../dist/ui` resolves to `/dist/ui` whether this module + * runs from source (`src/server/index.ts`) or built (`dist/server/index.js`). + */ +export const serveConsoleUi = ( + pf: Punktfunk, + engine: Engine, + opts?: { version?: string }, +): Promise => + servePluginUi(pf, { + id: PLUGIN_NAME, + title: UI_TITLE, + icon: UI_ICON, + version: opts?.version, + staticDir: new URL("../../dist/ui", import.meta.url), + fetch: makeRouter(engine), + }); diff --git a/src/server/password.ts b/src/server/password.ts new file mode 100644 index 0000000..0c41711 --- /dev/null +++ b/src/server/password.ts @@ -0,0 +1,30 @@ +// scrypt password hashing for the standalone fallback UI (design §9/§10.2). Format: +// `scrypt$$`. Verification is constant-time. Only used by the standalone +// server; the console-hosted path has no password of its own. + +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; + +const KEYLEN = 32; + +/** Hash a password for storage in `config.ui.passwordHash`. */ +export const hashPassword = (password: string): string => { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, KEYLEN); + return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`; +}; + +/** Constant-time verify a password against a stored `scrypt$...` hash. */ +export const verifyPassword = (password: string, stored: string): boolean => { + const parts = stored.split("$"); + if (parts.length !== 3 || parts[0] !== "scrypt" || !parts[1] || !parts[2]) + return false; + const salt = Buffer.from(parts[1], "base64url"); + const expected = Buffer.from(parts[2], "base64url"); + let actual: Buffer; + try { + actual = scryptSync(password, salt, expected.length); + } catch { + return false; + } + return actual.length === expected.length && timingSafeEqual(actual, expected); +}; diff --git a/src/server/router.ts b/src/server/router.ts new file mode 100644 index 0000000..90f4cfa --- /dev/null +++ b/src/server/router.ts @@ -0,0 +1,108 @@ +// The plugin-local REST/SSE API (design §9) — a pure `(Request) => Response | undefined` the UI talks +// to. Paths arrive prefix-stripped (the console proxy already removed `/plugin-ui/rom-manager`), so we +// match `/api/...` directly; a non-`/api` path returns `undefined` to fall through to the static SPA. +// The same router backs both the console-proxied server and the standalone fallback. + +import { type RawConfig, resolveConfig } from "../config.js"; +import { resolveEmulators } from "../emulators.js"; +import type { Engine, EngineStatus } from "../engine/index.js"; +import { resolvePlatforms } from "../platforms.js"; +import { loadConfig, saveConfig } from "../state.js"; + +const json = (data: unknown, status = 200): Response => + Response.json(data, { status }); + +/** A Server-Sent-Events stream that pushes the engine status on every change (design §9 progress feed). */ +const sse = (engine: Engine): Response => { + const enc = new TextEncoder(); + let unsubscribe = () => {}; + let ping: ReturnType | undefined; + const stream = new ReadableStream({ + start(controller) { + const send = (status: EngineStatus) => { + try { + controller.enqueue( + enc.encode(`event: status\ndata: ${JSON.stringify(status)}\n\n`), + ); + } catch { + // stream already closed + } + }; + send(engine.status()); + unsubscribe = engine.subscribe(send); + // Keep the connection warm through any buffering proxy. + ping = setInterval(() => { + try { + controller.enqueue(enc.encode(": ping\n\n")); + } catch { + // ignore + } + }, 15_000); + (ping as { unref?: () => void }).unref?.(); + }, + cancel() { + unsubscribe(); + if (ping) clearInterval(ping); + }, + }); + return new Response(stream, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); +}; + +/** Build the plugin-local API router bound to an engine. */ +export const makeRouter = + (engine: Engine) => + async (req: Request): Promise => { + const { pathname } = new URL(req.url); + const method = req.method; + if (!pathname.startsWith("/api/")) return undefined; // static SPA handles it + + try { + if (pathname === "/api/status" && method === "GET") { + return json(engine.status()); + } + if (pathname === "/api/config" && method === "GET") { + return json(loadConfig()); + } + if (pathname === "/api/config" && method === "PUT") { + const body = (await req.json()) as RawConfig; + const resolved = resolveConfig(body); + saveConfig(resolved); + await engine.reconfigure(); + return json(resolved); + } + if (pathname === "/api/preview" && method === "GET") { + return json(await engine.computeDry(false)); + } + if (pathname === "/api/detect" && method === "POST") { + return json(engine.detect(loadConfig(), true)); + } + if (pathname === "/api/sync" && method === "POST") { + const report = await engine.sync("ui"); + return report + ? json(report) + : json({ error: "a sync is already running" }, 409); + } + if (pathname === "/api/platforms" && method === "GET") { + return json([...resolvePlatforms(loadConfig().platforms).values()]); + } + if (pathname === "/api/emulators" && method === "GET") { + const config = loadConfig(); + return json({ + defs: [...resolveEmulators(config.emulators).values()], + detected: engine.detect(config, false), + }); + } + if (pathname === "/api/events" && method === "GET") { + return sse(engine); + } + return json({ error: "not found" }, 404); + } catch (e) { + return json({ error: String(e) }, 500); + } + }; diff --git a/src/server/standalone.ts b/src/server/standalone.ts new file mode 100644 index 0000000..4f941c0 --- /dev/null +++ b/src/server/standalone.ts @@ -0,0 +1,151 @@ +// The standalone fallback UI server (design §9/§10.2, D1): for host-only installs without the console, +// or as schedule insurance if the console surface is unavailable. Serves the SAME SPA + router, but on +// a fixed port with its own password/session auth instead of the console proxy. Fail-closed: a +// non-loopback bind REQUIRES a password (a UI that can rewrite launch templates is host-user code). + +import * as nodePath from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Config } from "../config.js"; +import type { Engine } from "../engine/index.js"; +import { log } from "../log.js"; +import { verifyPassword } from "./password.js"; +import { makeRouter } from "./router.js"; + +export interface StandaloneHandle { + url: string; + close(): Promise; +} + +const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]); +const COOKIE = "pf_rm_session"; + +const loginPage = (error?: string): Response => + new Response( + ` +ROM Manager + +

ROM Manager

${error ? `
${error}
` : ""} +
`, + { + status: error ? 401 : 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }, + ); + +/** Resolve a request path to a file inside `root`, or null on traversal (mirrors servePluginUi). */ +const staticFile = (root: string, pathname: string): string | null => { + let rel: string; + try { + rel = decodeURIComponent(pathname); + } catch { + return null; + } + if (rel.endsWith("/")) rel += "index.html"; + if (!rel.startsWith("/")) rel = `/${rel}`; + const abs = nodePath.resolve(root, `.${rel}`); + const rootAbs = nodePath.resolve(root); + if (abs !== rootAbs && !abs.startsWith(rootAbs + nodePath.sep)) return null; + return abs; +}; + +const cookieHas = (req: Request, name: string, value: string): boolean => { + const raw = req.headers.get("cookie") ?? ""; + return raw.split(/;\s*/).some((c) => c === `${name}=${value}`); +}; + +/** Serve the standalone UI. Throws (fail-closed) if a non-loopback bind lacks a password. */ +export const serveStandaloneUi = ( + engine: Engine, + config: Config, +): StandaloneHandle => { + const { port, bind, passwordHash } = config.ui; + const isLoopback = LOOPBACK.has(bind); + if (!isLoopback && !passwordHash) { + throw new Error( + `standalone UI refuses to bind ${bind} without a password (set ui.passwordHash via \`punktfunk-plugin-rom-manager set-password\`, or bind 127.0.0.1)`, + ); + } + const authRequired = Boolean(passwordHash); + // A per-boot session token — any client presenting it in the cookie is authed until the plugin restarts. + const sessionToken = crypto.randomUUID().replace(/-/g, ""); + const root = fileURLToPath(new URL("../../dist/ui", import.meta.url)); + const router = makeRouter(engine); + + const authed = (req: Request) => + !authRequired || cookieHas(req, COOKIE, sessionToken); + + const server = Bun.serve({ + hostname: bind, + port, + async fetch(req) { + const { pathname } = new URL(req.url); + if (pathname === "/__health") return Response.json({ ok: true }); + + if (authRequired && pathname === "/login" && req.method === "POST") { + const form = await req.formData().catch(() => null); + const password = form?.get("password"); + if ( + typeof password === "string" && + passwordHash && + verifyPassword(password, passwordHash) + ) { + return new Response(null, { + status: 303, + headers: { + location: "./", + "set-cookie": `${COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`, + }, + }); + } + return loginPage("Incorrect password"); + } + if (pathname === "/logout" && req.method === "POST") { + return new Response(null, { + status: 303, + headers: { + location: "./", + "set-cookie": `${COOKIE}=; Max-Age=0; Path=/`, + }, + }); + } + + if (!authed(req)) { + if (pathname.startsWith("/api/")) + return Response.json({ error: "unauthorized" }, { status: 401 }); + return loginPage(); + } + + // 1) plugin-local API + const api = await router(req); + if (api) return api; + // 2) static asset + const file = staticFile(root, pathname); + if (file) { + const bf = Bun.file(file); + if (await bf.exists()) return new Response(bf); + } + // 3) SPA fallback + if ( + req.method === "GET" && + (req.headers.get("accept") ?? "").includes("text/html") + ) { + const index = Bun.file(nodePath.join(root, "index.html")); + if (await index.exists()) return new Response(index); + } + return new Response("not found", { status: 404 }); + }, + }); + + const url = `http://${isLoopback ? "127.0.0.1" : bind}:${server.port}`; + log( + `standalone UI on ${url}${authRequired ? " (password-protected)" : " (loopback, no password)"}`, + ); + return { + url, + async close() { + server.stop(true); + }, + }; +}; diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..7eb0ab7 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,107 @@ +// Config/cache persistence (design §9): the plugin owns two files under `/rom-manager/`, +// created 0700. `config.json` is operator-editable and atomically rewritten (temp + rename); the +// plugin refuses a group/world-writable `config.json` (design §10.3 - the same sshd rule the runner +// and hooks enforce, since the UI can rewrite launch templates => config = host-user code). `cache.json` +// is disposable derived state. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { type Config, type RawConfig, resolveConfig } from "./config.js"; +import type { DetectedEmulator } from "./emulators.js"; +import { cachePath, configPath, pluginDir } from "./paths.js"; +import type { Artwork } from "./wire.js"; + +/** One cached art verdict: the resolved artwork (or `null` = nothing found), tagged with the provider + * and its matcher version so a provider switch or an algorithm bump re-resolves (design §7). */ +export interface ArtVerdict { + art: Artwork | null; + /** The provider that produced this verdict (`steamgriddb` | `libretro`). */ + provider: string; + /** The provider's matcher version at resolve time. */ + matcher: number; +} + +export interface Cache { + /** Art verdicts keyed by `external_id` (provider-agnostic, stable across rescans). */ + art: Record; + /** Last emulator detection result (re-probed on demand). */ + detect?: { at: number; os: string; emulators: DetectedEmulator[] }; + /** Fingerprint + count of the last reconcile - lets a rescan skip an unchanged PUT (design §8). */ + lastSync?: { fingerprint: string; count: number; at: number }; +} + +export const emptyCache = (): Cache => ({ art: {} }); + +/** Create the plugin dir 0700 if absent (idempotent). */ +const ensureDir = (): void => { + fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 }); +}; + +/** Refuse a group/world-writable config file (POSIX only; Windows config dir is DACL'd). */ +const assertNotWorldWritable = (file: string): void => { + if (process.platform === "win32") return; + let mode: number; + try { + mode = fs.statSync(file).mode; + } catch { + return; // absent - nothing to guard + } + if ((mode & 0o022) !== 0) { + throw new Error( + `refusing ${file}: it is group/world-writable (chmod go-w it first) - this file controls commands run as the host user`, + ); + } +}; + +/** Atomic write: temp file in the same dir, then rename. */ +const atomicWrite = (file: string, data: string): void => { + ensureDir(); + const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmp, data, { mode: 0o600 }); + fs.renameSync(tmp, file); +}; + +/** Load the authored config (defaults filled). A missing file yields the empty config. */ +export const loadConfig = (): Config => { + const file = configPath(); + let raw = ""; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + return resolveConfig({}); + } + assertNotWorldWritable(file); + const parsed = JSON.parse(raw) as RawConfig; + return resolveConfig(parsed); +}; + +/** Persist a config (atomic). The engine round-trips the *resolved* shape; defaults are re-elided on load. */ +export const saveConfig = (config: Config): void => { + atomicWrite(configPath(), `${JSON.stringify(config, null, 2)}\n`); +}; + +/** Save the raw (operator-authored) config verbatim - the shape the UI edits. */ +export const saveRawConfig = (raw: RawConfig): void => { + atomicWrite(configPath(), `${JSON.stringify(raw, null, 2)}\n`); +}; + +export const loadCache = (): Cache => { + try { + const parsed = JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache; + return { ...parsed, art: parsed.art ?? {} }; + } catch { + return emptyCache(); + } +}; + +export const saveCache = (cache: Cache): void => { + atomicWrite(cachePath(), JSON.stringify(cache)); +}; + +/** Absolute paths, exported for the UI/CLI status view. */ +export const statePaths = () => ({ + dir: pluginDir(), + config: configPath(), + cache: cachePath(), + relConfig: path.basename(configPath()), +}); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..f29e33f --- /dev/null +++ b/src/version.ts @@ -0,0 +1,16 @@ +// Best-effort read of the plugin's own version from package.json (shown in the console page header). +// `../package.json` resolves to the repo root whether this module runs from `src/` or `dist/`. +import * as fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +export const readVersion = (): string | undefined => { + try { + const file = fileURLToPath(new URL("../package.json", import.meta.url)); + const pkg = JSON.parse(fs.readFileSync(file, "utf8")) as { + version?: string; + }; + return pkg.version; + } catch { + return undefined; + } +}; diff --git a/src/wire.ts b/src/wire.ts new file mode 100644 index 0000000..f390808 --- /dev/null +++ b/src/wire.ts @@ -0,0 +1,33 @@ +// The host provider-reconcile wire shapes (mirrors `@punktfunk/host`'s generated `ProviderEntryInput` +// et al. — the facade doesn't re-export them, and we PUT the raw array via `pf.request`, so we own the +// shape here). Kept minimal and stable; the host validates it. + +/** Cover art — all URLs. The console/clients prefer `portrait` for a grid (design §7). */ +export interface Artwork { + portrait?: string | null; + hero?: string | null; + logo?: string | null; + header?: string | null; +} + +/** How the host launches a title. For this plugin always `{ kind: "command", value: }`. */ +export interface LaunchSpec { + kind: "command"; + value: string; +} + +/** A per-title prep/undo step (design §6): `do` runs before launch, `undo` at session end (reverse order). */ +export interface PrepCmd { + do: string; + undo?: string | null; +} + +/** One title in the declarative reconcile payload (`PUT /api/v1/library/provider/rom-manager`). */ +export interface ProviderEntryInput { + /** The provider's stable id for this title — the reconcile diff key (design §4: `/`). */ + external_id: string; + title: string; + art?: Artwork; + launch?: LaunchSpec | null; + prep?: PrepCmd[]; +} diff --git a/test/emulators.test.ts b/test/emulators.test.ts new file mode 100644 index 0000000..3e94bb6 --- /dev/null +++ b/test/emulators.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { + type DetectEnv, + detectEmulator, + emulatorById, +} from "../src/emulators.js"; +import type { Os } from "../src/quote.js"; + +const env = (o: { + os?: Os; + which?: Record; + files?: string[]; + flatpaks?: string[]; + dirs?: Record; +}): 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(); + }); +}); diff --git a/test/libretro.test.ts b/test/libretro.test.ts new file mode 100644 index 0000000..af112c4 --- /dev/null +++ b/test/libretro.test.ts @@ -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/engine/titles.js"; +import { platformById } from "../src/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(); + }); +}); diff --git a/test/quote.test.ts b/test/quote.test.ts new file mode 100644 index 0000000..1dae38a --- /dev/null +++ b/test/quote.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; +import { quotePosix, quoteWindows, renderCommand } from "../src/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", + "ab", + ]) { + 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); + }); +}); diff --git a/test/reconcile.test.ts b/test/reconcile.test.ts new file mode 100644 index 0000000..f06bdda --- /dev/null +++ b/test/reconcile.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test"; +import { resolveConfig } from "../src/config.js"; +import type { DetectedEmulator } from "../src/emulators.js"; +import { computeDesired } from "../src/engine/reconcile.js"; +import type { RomCandidate } from "../src/engine/scanner.js"; +import type { ArtVerdict } from "../src/state.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 => ({ + 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: Parameters[0], + scan: RomCandidate[], + art: Record = {}, + 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); + }); +}); diff --git a/test/scanner.test.ts b/test/scanner.test.ts new file mode 100644 index 0000000..7e8c3ab --- /dev/null +++ b/test/scanner.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; +import { + type DirEnt, + makeExcluder, + type ScanFs, + scanRoot, +} from "../src/engine/scanner.js"; +import { platformById } from "../src/platforms.js"; + +/** A synthetic filesystem from a flat `{ absPath: contents }` map (dirs inferred). */ +const synthFs = (files: Record): ScanFs => { + const norm = (p: string) => p.replace(/\/+$/, ""); + return { + readDir(dir): DirEnt[] { + const base = norm(dir); + const children = new Map(); // 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, + 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); + }); +}); diff --git a/test/steamgriddb.test.ts b/test/steamgriddb.test.ts new file mode 100644 index 0000000..8c326a8 --- /dev/null +++ b/test/steamgriddb.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { SteamGridDbProvider } from "../src/art/steamgriddb.js"; +import { parseTitle } from "../src/engine/titles.js"; +import { platformById } from "../src/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); + }); +}); diff --git a/test/titles.test.ts b/test/titles.test.ts new file mode 100644 index 0000000..3f53aa6 --- /dev/null +++ b/test/titles.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { + dedupeKey, + parseTitle, + pickBestRelease, +} from "../src/engine/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)"); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..92c6a62 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "moduleResolution": "bundler" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "test", "ui", "dist", "node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b75dc4d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "types": ["bun"] + }, + "include": ["src", "test"], + "exclude": ["ui", "dist", "node_modules"] +} diff --git a/ui/bun.lock b/ui/bun.lock new file mode 100644 index 0000000..80f5fdc --- /dev/null +++ b/ui/bun.lock @@ -0,0 +1,257 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "punktfunk-plugin-rom-manager-ui", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.9.3", + "vite": "^7.0.0", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], + + "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..5fd4274 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + ROM Manager + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..c88c687 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "punktfunk-plugin-rom-manager-ui", + "private": true, + "type": "module", + "description": "The ROM Manager plugin SPA — built into ../dist/ui and served by servePluginUi.", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.9.3", + "vite": "^7.0.0" + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..5245808 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { ToastHost } from "./components.js"; +import { Emulators } from "./pages/Emulators.js"; +import { Games } from "./pages/Games.js"; +import { Setup } from "./pages/Setup.js"; +import { Sync } from "./pages/Sync.js"; + +const TABS = [ + { id: "setup", label: "Setup", Page: Setup }, + { id: "emulators", label: "Emulators", Page: Emulators }, + { id: "games", label: "Games", Page: Games }, + { id: "sync", label: "Sync", Page: Sync }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; + +const tabFromHash = (): TabId => { + const h = window.location.hash.replace(/^#\/?/, ""); + return (TABS.find((t) => t.id === h)?.id ?? "setup") as TabId; +}; + +export const App = () => { + const [tab, setTab] = useState(tabFromHash); + + useEffect(() => { + const onHash = () => setTab(tabFromHash()); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); + + const go = (id: TabId) => { + window.location.hash = `/${id}`; + setTab(id); + // Best-effort deep-link sync with the console shell (plugin-ui-surface §5; ignored elsewhere). + try { + window.parent?.postMessage({ type: "pf-ui:navigate", path: id }, "*"); + } catch { + // not embedded + } + }; + + const Page = TABS.find((t) => t.id === tab)?.Page ?? Setup; + + return ( + +
+
+ 🎮 +

ROM Manager

+ + — scan ROMs into your punktfunk library + +
+
+ {TABS.map((t) => ( + + ))} +
+ +
+
+ ); +}; diff --git a/ui/src/api.ts b/ui/src/api.ts new file mode 100644 index 0000000..eb824a9 --- /dev/null +++ b/ui/src/api.ts @@ -0,0 +1,162 @@ +// Typed client for the plugin-local API (relative paths — the SPA is mounted under the console proxy +// prefix, so `api/...` resolves to `/plugin-ui/rom-manager/api/...`). Types mirror the backend. +import { useEffect, useState } from "react"; + +export interface Platform { + id: string; + name: string; + extensions: string[]; + libretroSystem?: string; + defaultLaunch: { emulator: string; core?: string; extraArgs?: string }; + disc?: boolean; +} +export interface EmulatorDef { + id: string; + name: string; + template: string; + supportsArchives: boolean; + contested?: boolean; +} +export interface Detected { + id: string; + name: string; + via: "path" | "file" | "flatpak"; + exeToken: string; + contested?: boolean; + coresDir?: string; + cores?: string[]; +} +export interface RomRoot { + dir: string; + platform: string; + excludes?: string[]; +} +export interface GameOverride { + exclude?: boolean; + emulator?: string; + core?: string; + extraArgs?: string; + title?: string; + art?: string; +} +export interface Config { + roots: RomRoot[]; + platformLaunch: Record< + string, + { emulator: string; core?: string; extraArgs?: string } + >; + gameOverrides: Record; + sync: { + pollMinutes: number; + watch: boolean; + debounceMs: number; + dedupeRegions: string[]; + regionPriority: string[]; + warnEntries: number; + maxEntries: number; + closeOnEnd: boolean; + }; + art: { + enabled: boolean; + provider: "auto" | "steamgriddb" | "libretro"; + steamGridDbKey?: string; + }; + ui: { + standalone: boolean; + port: number; + bind: string; + passwordHash?: string; + }; + devEntry: boolean; +} +export interface Artwork { + portrait?: string | null; + hero?: string | null; + logo?: string | null; + header?: string | null; +} +export interface Entry { + external_id: string; + title: string; + launch?: { kind: string; value: string } | null; + art?: Artwork; + prep?: { do: string; undo?: string | null }[]; +} +export interface Skipped { + external_id: string; + title: string; + reason: string; +} +export interface Report { + considered: number; + included: number; + skipped: Skipped[]; + excluded: { external_id: string; title: string }[]; + warnings: string[]; + truncated: number; + perPlatform: Record; + overWarn: boolean; +} +export interface Preview { + entries: Entry[]; + report: Report; + detected: Detected[]; +} +export interface Status { + rootsConfigured: number; + os: "linux" | "windows"; + artProvider: string | null; + lastSync?: { fingerprint: string; count: number; at: number }; + lastReport?: Report; + detected?: { at: number; emulators: Detected[] }; + paths: { dir: string; config: string; cache: string; relConfig: string }; + syncing: boolean; +} + +const api = async (path: string, opts?: RequestInit): Promise => { + const res = await fetch(path, { + headers: { "content-type": "application/json" }, + ...opts, + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error ?? `HTTP ${res.status}`); + } + return (await res.json()) as T; +}; + +export const getStatus = () => api("api/status"); +export const getConfig = () => api("api/config"); +export const putConfig = (config: Config) => + api("api/config", { method: "PUT", body: JSON.stringify(config) }); +export const getPreview = () => api("api/preview"); +export const runDetect = () => + api("api/detect", { method: "POST" }); +export const runSync = () => api("api/sync", { method: "POST" }); +export const getPlatforms = () => api("api/platforms"); +export const getEmulators = () => + api<{ defs: EmulatorDef[]; detected: Detected[] }>("api/emulators"); + +/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */ +export const useStatusStream = (): Status | undefined => { + const [status, setStatus] = useState(); + useEffect(() => { + getStatus() + .then(setStatus) + .catch(() => {}); + try { + const es = new EventSource("api/events"); + es.addEventListener("status", (e) => { + try { + setStatus(JSON.parse((e as MessageEvent).data)); + } catch { + // ignore malformed frame + } + }); + return () => es.close(); + } catch { + return; + } + }, []); + return status; +}; diff --git a/ui/src/components.tsx b/ui/src/components.tsx new file mode 100644 index 0000000..33f8066 --- /dev/null +++ b/ui/src/components.tsx @@ -0,0 +1,61 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { createContext, useCallback, useContext, useState } from "react"; + +export const Card = ({ + title, + hint, + right, + children, +}: { + title?: string; + hint?: string; + right?: ReactNode; + children: ReactNode; +}) => ( +
+ {(title || right) && ( +
+ {title &&

{title}

} + {right} +
+ )} + {hint &&

{hint}

} + {children} +
+); + +export const Button = ({ + primary, + children, + ...rest +}: ButtonHTMLAttributes & { primary?: boolean }) => ( + +); + +export const Badge = ({ + tone, + children, +}: { + tone?: "ok" | "warn" | "accent"; + children: ReactNode; +}) => {children}; + +// ── Toasts ──────────────────────────────────────────────────────────────────────────────────── +const ToastCtx = createContext<(msg: string) => void>(() => {}); +export const useToast = () => useContext(ToastCtx); + +export const ToastHost = ({ children }: { children: ReactNode }) => { + const [msg, setMsg] = useState(); + const show = useCallback((m: string) => { + setMsg(m); + window.setTimeout(() => setMsg(undefined), 2600); + }, []); + return ( + + {children} + {msg &&
{msg}
} +
+ ); +}; diff --git a/ui/src/hooks.ts b/ui/src/hooks.ts new file mode 100644 index 0000000..8db5ac3 --- /dev/null +++ b/ui/src/hooks.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useState } from "react"; +import { type Config, getConfig, putConfig } from "./api.js"; + +/** Load + save the plugin config, with a local editable copy. */ +export const useConfig = () => { + const [config, setConfig] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + + const reload = useCallback(() => { + getConfig() + .then(setConfig) + .catch((e) => setError(String(e))); + }, []); + useEffect(reload, [reload]); + + const save = useCallback(async (next: Config): Promise => { + setSaving(true); + setError(undefined); + try { + setConfig(await putConfig(next)); + return true; + } catch (e) { + setError(String(e)); + return false; + } finally { + setSaving(false); + } + }, []); + + return { config, setConfig, reload, save, saving, error }; +}; + +/** The platform id out of an `external_id` (`snes/Foo.sfc` → `snes`; `snes/0/Foo.sfc` → `snes`). */ +export const platformOf = (externalId: string): string => + externalId.split("/")[0] ?? ""; diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..15f25a8 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/ui/src/pages/Emulators.tsx b/ui/src/pages/Emulators.tsx new file mode 100644 index 0000000..2cf8177 --- /dev/null +++ b/ui/src/pages/Emulators.tsx @@ -0,0 +1,200 @@ +import { useEffect, useState } from "react"; +import { + type Detected, + type EmulatorDef, + getEmulators, + getPlatforms, + type Platform, + runDetect, +} from "../api.js"; +import { Badge, Button, Card, useToast } from "../components.js"; +import { useConfig } from "../hooks.js"; + +export const Emulators = () => { + const { config, setConfig, save, saving } = useConfig(); + const [defs, setDefs] = useState([]); + const [detected, setDetected] = useState([]); + const [platforms, setPlatforms] = useState([]); + const [detecting, setDetecting] = useState(false); + const toast = useToast(); + + const load = () => { + getEmulators().then((e) => { + setDefs(e.defs); + setDetected(e.detected); + }); + }; + useEffect(load, []); + useEffect(() => { + getPlatforms() + .then(setPlatforms) + .catch(() => {}); + }, []); + + const detectedById = new Map(detected.map((d) => [d.id, d])); + + return ( + <> + { + setDetecting(true); + try { + setDetected(await runDetect()); + toast("Re-detected emulators"); + } finally { + setDetecting(false); + } + }} + > + {detecting ? "Detecting…" : "Re-detect"} + + } + > + + + + + + + + + + {defs.map((def) => { + const d = detectedById.get(def.id); + return ( + + + + + + + ); + })} + +
EmulatorStatusCores +
+ {def.name} {def.id} + + {d ? ( + detected · {d.via} + ) : ( + not found + )} + {d?.cores?.length ? d.cores.length : "—"} + {def.contested && contested} +
+
+ + {config && ( + { + if (await save(config)) toast("Saved — syncing library"); + }} + > + {saving ? "Saving…" : "Save & sync"} + + } + > +
+ + + + + + + + + + {platforms.map((p) => { + const override = config.platformLaunch[p.id]; + const emulator = + override?.emulator ?? p.defaultLaunch.emulator; + const core = override?.core ?? p.defaultLaunch.core ?? ""; + const detCores = detectedById.get(emulator)?.cores ?? []; + const setLaunch = (patch: { + emulator?: string; + core?: string; + }) => { + const next = { + emulator: patch.emulator ?? emulator, + core: patch.core ?? core, + }; + setConfig({ + ...config, + platformLaunch: { + ...config.platformLaunch, + [p.id]: next, + }, + }); + }; + return ( + + + + + + ); + })} + +
PlatformEmulatorCore (RetroArch)
{p.name} + + + {emulator === "retroarch" ? ( + detCores.length ? ( + + ) : ( + + setLaunch({ core: e.target.value }) + } + /> + ) + ) : ( + + )} +
+
+
+ )} + + ); +}; diff --git a/ui/src/pages/Games.tsx b/ui/src/pages/Games.tsx new file mode 100644 index 0000000..93b1330 --- /dev/null +++ b/ui/src/pages/Games.tsx @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useState } from "react"; +import { getConfig, getPreview, type Preview, putConfig } from "../api.js"; +import { Badge, Button, Card, useToast } from "../components.js"; +import { platformOf } from "../hooks.js"; + +export const Games = () => { + const [preview, setPreview] = useState(); + const [loading, setLoading] = useState(false); + const [showSkipped, setShowSkipped] = useState(false); + const toast = useToast(); + + const refresh = useCallback(() => { + setLoading(true); + getPreview() + .then(setPreview) + .catch((e) => toast(String(e))) + .finally(() => setLoading(false)); + }, [toast]); + useEffect(refresh, [refresh]); + + // Toggle a title's exclude override, persist, and refresh the preview. + const setExcluded = async (externalId: string, exclude: boolean) => { + const config = await getConfig(); + const overrides = { ...config.gameOverrides }; + const current = overrides[externalId] ?? {}; + if (exclude) overrides[externalId] = { ...current, exclude: true }; + else { + const { exclude: _drop, ...rest } = current; + if (Object.keys(rest).length) overrides[externalId] = rest; + else delete overrides[externalId]; + } + await putConfig({ ...config, gameOverrides: overrides }); + toast(exclude ? "Excluded" : "Included"); + refresh(); + }; + + if (!preview) return {loading ? "Scanning…" : "Loading…"}; + + const { entries, report } = preview; + const excluded = report.excluded ?? []; + + return ( + <> + + {loading ? "Scanning…" : "Rescan"} + + } + > + {entries.length === 0 && excluded.length === 0 ? ( +
+ No games found. Add ROM roots in Setup, then + rescan. +
+ ) : ( +
+ + + + + + + + + + + {entries.map((e) => ( + + + + + + + + ))} + {excluded.map((x) => ( + + + + + + + + ))} + +
+ TitlePlatformLaunch commandInclude
+ {e.art?.portrait ? ( + {`${e.title} + ) : ( + + )} + {e.title} + {platformOf(e.external_id)} + + {truncate(e.launch?.value ?? "", 70)} + + setExcluded(e.external_id, true)} + /> +
+ + {x.title} + {platformOf(x.external_id)} + excluded + setExcluded(x.external_id, false)} + /> +
+
+ )} +
+ + {report.skipped.length > 0 && ( + setShowSkipped((s) => !s)}> + {showSkipped ? "Hide" : "Show"} + + } + > + {showSkipped && ( +
+ + + {report.skipped.map((s) => ( + + + + + ))} + +
{s.title}{s.reason}
+
+ )} +
+ )} + + ); +}; + +const truncate = (s: string, n: number): string => + s.length > n ? `${s.slice(0, n)}…` : s; diff --git a/ui/src/pages/Setup.tsx b/ui/src/pages/Setup.tsx new file mode 100644 index 0000000..1f35485 --- /dev/null +++ b/ui/src/pages/Setup.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from "react"; +import { getPlatforms, type Platform, type RomRoot } from "../api.js"; +import { Button, Card, useToast } from "../components.js"; +import { useConfig } from "../hooks.js"; + +export const Setup = () => { + const { config, setConfig, save, saving } = useConfig(); + const [platforms, setPlatforms] = useState([]); + const toast = useToast(); + + useEffect(() => { + getPlatforms() + .then(setPlatforms) + .catch(() => {}); + }, []); + + if (!config) return Loading…; + + const roots = config.roots; + const setRoots = (next: RomRoot[]) => setConfig({ ...config, roots: next }); + const update = (i: number, patch: Partial) => + setRoots(roots.map((r, j) => (j === i ? { ...r, ...patch } : r))); + + return ( + { + if (await save(config)) toast("Saved — syncing library"); + }} + > + {saving ? "Saving…" : "Save & sync"} + + } + > + {roots.length === 0 && ( +

+ No roots yet. Add a folder of ROMs to get started. +

+ )} + + {roots.length > 0 && ( + + + + + + + + + + {roots.map((root, i) => ( + + + + + + + ))} + +
FolderPlatformExcludes (comma-separated globs) +
+ update(i, { dir: e.target.value })} + /> + + + + + update(i, { + excludes: e.target.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + }) + } + /> + + +
+ )} + +
+ +
+
+ ); +}; diff --git a/ui/src/pages/Sync.tsx b/ui/src/pages/Sync.tsx new file mode 100644 index 0000000..f9a8ec9 --- /dev/null +++ b/ui/src/pages/Sync.tsx @@ -0,0 +1,255 @@ +import { useState } from "react"; +import { type Config, runSync, useStatusStream } from "../api.js"; +import { Badge, Button, Card, useToast } from "../components.js"; +import { useConfig } from "../hooks.js"; + +export const Sync = () => { + const status = useStatusStream(); + const { config, setConfig, save, saving } = useConfig(); + const [syncing, setSyncing] = useState(false); + const toast = useToast(); + + const report = status?.lastReport; + + return ( + <> + { + setSyncing(true); + try { + await runSync(); + toast("Synced"); + } catch (e) { + toast(String(e)); + } finally { + setSyncing(false); + } + }} + > + {syncing || status?.syncing ? "Syncing…" : "Sync now"} + + } + > +
+
+ {status?.rootsConfigured ?? "—"} + ROM roots +
+
+ {status?.lastSync?.count ?? "—"} + titles in library +
+
+ {report?.skipped.length ?? "—"} + skipped +
+
+ + {status?.artProvider ? ( + {status.artProvider} + ) : ( + "off" + )} + + art provider +
+
+ {status?.lastSync && ( +

+ Last sync {new Date(status.lastSync.at).toLocaleString()} · OS{" "} + {status.os} +

+ )} + {report?.overWarn && ( +

+ Large library ({report.included} entries) — reconcile is a single + full-body PUT; watch host memory. +

+ )} + {report && report.warnings.length > 0 && ( +
+ + {report.warnings.length} warning(s) + +
    + {report.warnings.slice(0, 20).map((w) => ( +
  • + {w} +
  • + ))} +
+
+ )} +
+ + {config && ( + <> + + + {status && ( + +

{status.paths.config}

+

{status.paths.cache}

+
+ )} + + )} + + ); +}; + +interface CardProps { + config: Config; + setConfig: (c: Config) => void; + save: (c: Config) => Promise; + saving: boolean; +} + +const ArtCard = ({ config, setConfig, save, saving }: CardProps) => { + const toast = useToast(); + const art = config.art; + const set = (patch: Partial) => + setConfig({ ...config, art: { ...art, ...patch } }); + return ( + (await save(config)) && toast("Saved")} + > + {saving ? "Saving…" : "Save"} + + } + > +
+ + + +
+
+ ); +}; + +const SyncOptionsCard = ({ config, setConfig, save, saving }: CardProps) => { + const toast = useToast(); + const sync = config.sync; + const set = (patch: Partial) => + setConfig({ ...config, sync: { ...sync, ...patch } }); + return ( + (await save(config)) && toast("Saved — syncing")} + > + {saving ? "Saving…" : "Save & sync"} + + } + > +
+ + + + + +
+
+ ); +}; diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 0000000..6df9a33 --- /dev/null +++ b/ui/src/styles.css @@ -0,0 +1,267 @@ +/* A self-contained dark theme (the console renders plugin UIs on a dark canvas — plugin-ui-surface + §5). Purple accent to sit alongside the punktfunk console; no external UI library so the SPA builds + anywhere. */ +:root { + --bg: #0b0b0f; + --panel: #14141b; + --panel-2: #1b1b24; + --border: #2a2a36; + --text: #e5e7eb; + --muted: #9ca3af; + --accent: #7c3aed; + --accent-hover: #6d28d9; + --danger: #ef4444; + --ok: #22c55e; + --warn: #f59e0b; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} +html, +body, +#root { + height: 100%; +} +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: + 14px / 1.5 system-ui, + -apple-system, + "Segoe UI", + Roboto, + sans-serif; +} +h1, +h2, +h3 { + margin: 0; + font-weight: 600; +} +a { + color: var(--accent); +} +code { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; +} + +.app { + max-width: 1100px; + margin: 0 auto; + padding: 20px 24px 64px; +} +.topbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 4px; +} +.topbar .logo { + font-size: 20px; +} +.subtle { + color: var(--muted); +} +.tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--border); + margin: 18px 0 20px; + flex-wrap: wrap; +} +.tab { + padding: 8px 14px; + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + border-bottom: 2px solid transparent; + font-size: 14px; +} +.tab:hover { + color: var(--text); +} +.tab.active { + color: var(--text); + border-bottom-color: var(--accent); +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px 18px; + margin-bottom: 16px; +} +.card h2 { + font-size: 15px; + margin-bottom: 4px; +} +.card .hint { + color: var(--muted); + font-size: 13px; + margin: 2px 0 12px; +} +.row { + display: flex; + gap: 8px; + align-items: center; +} +.row.wrap { + flex-wrap: wrap; +} +.spread { + justify-content: space-between; +} +.grow { + flex: 1; +} + +button.btn { + padding: 7px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + cursor: pointer; + font-size: 13px; +} +button.btn:hover { + border-color: var(--accent); +} +button.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +button.btn.primary:hover { + background: var(--accent-hover); +} +button.btn:disabled { + opacity: 0.5; + cursor: default; +} +button.btn.icon { + padding: 4px 8px; +} + +input, +select { + padding: 7px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: #0f0f16; + color: var(--text); + font-size: 13px; +} +input:focus, +select:focus { + outline: none; + border-color: var(--accent); +} +label.field { + display: grid; + gap: 4px; + font-size: 12px; + color: var(--muted); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +th, +td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +th { + color: var(--muted); + font-weight: 500; + font-size: 12px; +} + +.badge { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 11px; + background: var(--panel-2); + border: 1px solid var(--border); + color: var(--muted); +} +.badge.ok { + color: var(--ok); + border-color: color-mix(in srgb, var(--ok) 40%, var(--border)); +} +.badge.warn { + color: var(--warn); + border-color: color-mix(in srgb, var(--warn) 40%, var(--border)); +} +.badge.accent { + color: #c4b5fd; + border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); +} + +.cover { + width: 42px; + height: 60px; + object-fit: cover; + border-radius: 4px; + background: var(--panel-2); + border: 1px solid var(--border); +} +.cover.empty { + display: inline-block; +} +.mono { + font-family: ui-monospace, Menlo, monospace; + font-size: 12px; + color: var(--muted); + word-break: break-all; +} +.empty-state { + text-align: center; + color: var(--muted); + padding: 32px; +} +.toast { + position: fixed; + bottom: 18px; + left: 50%; + transform: translateX(-50%); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 16px; + font-size: 13px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); +} +.stat { + display: grid; + gap: 2px; +} +.stat .n { + font-size: 22px; + font-weight: 600; +} +.stat .l { + color: var(--muted); + font-size: 12px; +} +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; +} +.scroll { + max-height: 420px; + overflow: auto; +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..a8b335d --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..eaef0fb --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,15 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// `base: "./"` (relative asset URLs) + hash routing is the plugin-ui-surface §6 contract: the SPA is +// mounted under `/plugin-ui/rom-manager/` behind the console proxy, so assets must resolve relative to +// that prefix. Built into `../dist/ui`, which `servePluginUi`'s staticDir points at. +export default defineConfig({ + base: "./", + plugins: [react()], + build: { + outDir: "../dist/ui", + emptyOutDir: true, + }, + server: { port: 5599 }, +});