From 3a6c80558d3f639ca2221c8222e3cb3b3d458116 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 18 Jul 2026 02:40:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20ROM=20&=20emulator=20manager?= =?UTF-8?q?=20plugin=20(M0=E2=80=93M5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/ci.yml | 56 +++++ .gitignore | 6 + LICENSE | 10 + README.md | 133 ++++++++++++ biome.json | 40 ++++ bun.lock | 93 +++++++++ bunfig.toml | 6 + config.example.json | 38 ++++ package.json | 53 +++++ src/art/libretro.ts | 87 ++++++++ src/art/provider.ts | 104 +++++++++ src/art/steamgriddb.ts | 132 ++++++++++++ src/cli.ts | 169 +++++++++++++++ src/config.ts | 151 ++++++++++++++ src/const.ts | 7 + src/emulators.ts | 418 +++++++++++++++++++++++++++++++++++++ src/engine/close.ts | 38 ++++ src/engine/index.ts | 363 ++++++++++++++++++++++++++++++++ src/engine/launch.ts | 119 +++++++++++ src/engine/reconcile.ts | 278 ++++++++++++++++++++++++ src/engine/scanner.ts | 270 ++++++++++++++++++++++++ src/engine/titles.ts | 145 +++++++++++++ src/host.ts | 20 ++ src/index.ts | 65 ++++++ src/log.ts | 10 + src/paths.ts | 28 +++ src/platforms.ts | 252 ++++++++++++++++++++++ src/quote.ts | 130 ++++++++++++ src/server/index.ts | 32 +++ src/server/password.ts | 30 +++ src/server/router.ts | 108 ++++++++++ src/server/standalone.ts | 151 ++++++++++++++ src/state.ts | 107 ++++++++++ src/version.ts | 16 ++ src/wire.ts | 33 +++ test/emulators.test.ts | 88 ++++++++ test/libretro.test.ts | 56 +++++ test/quote.test.ts | 119 +++++++++++ test/reconcile.test.ts | 137 ++++++++++++ test/scanner.test.ts | 131 ++++++++++++ test/steamgriddb.test.ts | 72 +++++++ test/titles.test.ts | 67 ++++++ tsconfig.build.json | 12 ++ tsconfig.json | 16 ++ ui/bun.lock | 257 +++++++++++++++++++++++ ui/index.html | 12 ++ ui/package.json | 22 ++ ui/src/App.tsx | 70 +++++++ ui/src/api.ts | 162 ++++++++++++++ ui/src/components.tsx | 61 ++++++ ui/src/hooks.ts | 36 ++++ ui/src/main.tsx | 10 + ui/src/pages/Emulators.tsx | 200 ++++++++++++++++++ ui/src/pages/Games.tsx | 156 ++++++++++++++ ui/src/pages/Setup.tsx | 117 +++++++++++ ui/src/pages/Sync.tsx | 255 ++++++++++++++++++++++ ui/src/styles.css | 267 +++++++++++++++++++++++ ui/tsconfig.json | 15 ++ ui/vite.config.ts | 15 ++ 59 files changed, 6051 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 config.example.json create mode 100644 package.json create mode 100644 src/art/libretro.ts create mode 100644 src/art/provider.ts create mode 100644 src/art/steamgriddb.ts create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/const.ts create mode 100644 src/emulators.ts create mode 100644 src/engine/close.ts create mode 100644 src/engine/index.ts create mode 100644 src/engine/launch.ts create mode 100644 src/engine/reconcile.ts create mode 100644 src/engine/scanner.ts create mode 100644 src/engine/titles.ts create mode 100644 src/host.ts create mode 100644 src/index.ts create mode 100644 src/log.ts create mode 100644 src/paths.ts create mode 100644 src/platforms.ts create mode 100644 src/quote.ts create mode 100644 src/server/index.ts create mode 100644 src/server/password.ts create mode 100644 src/server/router.ts create mode 100644 src/server/standalone.ts create mode 100644 src/state.ts create mode 100644 src/version.ts create mode 100644 src/wire.ts create mode 100644 test/emulators.test.ts create mode 100644 test/libretro.test.ts create mode 100644 test/quote.test.ts create mode 100644 test/reconcile.test.ts create mode 100644 test/scanner.test.ts create mode 100644 test/steamgriddb.test.ts create mode 100644 test/titles.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 ui/bun.lock create mode 100644 ui/index.html create mode 100644 ui/package.json create mode 100644 ui/src/App.tsx create mode 100644 ui/src/api.ts create mode 100644 ui/src/components.tsx create mode 100644 ui/src/hooks.ts create mode 100644 ui/src/main.tsx create mode 100644 ui/src/pages/Emulators.tsx create mode 100644 ui/src/pages/Games.tsx create mode 100644 ui/src/pages/Setup.tsx create mode 100644 ui/src/pages/Sync.tsx create mode 100644 ui/src/styles.css create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts 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 }, +});