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