13 Commits

Author SHA1 Message Date
enricobuehler d830b7133d fix: SSE keepalive via plugin-kit 0.1.4 (v0.3.1)
CI / publish (push) Failing after 28s
CI / build (push) Successful in 37s
Live-verified against a running host: status frames (including a syncing
transition) now stream through the plugin UI server. The previous keepalive
outlived Bun's default idle timeout, so an idle feed was closed before its
first ping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:01:29 +02:00
enricobuehler 838cdc4339 fix(ui): inherit the theme collision fixes from plugin-kit 0.1.3
CI / publish (push) Failing after 22s
CI / build (push) Successful in 35s
The text-secondary re-point moved into the kit's theme.css (every plugin hits
it), joined there by the card-ring softening the console does in its own Card
wrapper. The SPA's stylesheet is back to imports only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:50:00 +02:00
enricobuehler e9a4d3a996 feat(ui): storybook 10 over the real pages + screenshot harness
Preview imports the mock transport statically and wraps every story in
a fresh RegistryProvider seeded with mock mode + the story's scenario
parameter (dark default, light via toolbar). One story file per page
(Default + FirstRun/Syncing/Errors where meaningful) plus the whole
shell. tools/screenshots.mjs mirrors the console's harness: headless
Chromium over storybook-static, every Pages/* + Shell/* story.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:41:59 +02:00
enricobuehler f3e80f10ec feat(ui): the five pages + shell — console-grade @unom/app-ui chrome
PluginShell over the kit router (flat routes, console deep-link bridge),
standalone header only outside the iframe. Every subscription renders
through a shared Gate (kit ResultGate + colocated PageSkeleton + retry
error EmptyState). Overview: first-run hero, tone-coded StatCards, sync
button that tracks engine state, warnings accordion, live EventFeed.
Library: @container-responsive cover grid with whileInView stagger,
include Switches saving gameOverrides immediately, skipped/excluded
card with re-include. Sources: PathListEditor over draft.roots with
platform Select + excludes globs. Emulators: detected roster with via/
contested/cores badges + re-detect, per-platform launch overrides.
Settings: artwork/sync/files groups over resolved defaults, sticky
SaveBar (motion slide-up on dirty; entry variants neutralized inside
the object-form motion wrapper so the button isn't stuck at opacity 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:41:49 +02:00
enricobuehler bcff17a718 feat(ui): Effect-native data layer + zero-host mock transport
AtomHttpApi.Service derives the typed client straight from the shared
RomManagerApi contract — no hand-mirrored types, no react-query. Queries
carry reactivity keys; mutations pass them per-call (constants exported
beside each mutation). liveStatusAtom merges the kit sseAtom feed over
GET /api/status; statusEventsAtom folds frames into the activity feed.
useConfigDraft keeps a raw-config working copy (defaults never baked in)
with dirty tracking and toast-reporting save.

Mock mode is a full in-memory RomManagerApi as an HttpClient layer plus
a scenario ticker (healthy/firstRun/syncing/errors), registered through
a fixture-free seam so production chunks stay clean — mocks/http.ts is
only imported behind import.meta.env.DEV and by Storybook. Scenario and
mode atoms are keepAlive: the transport reads them outside the reactive
graph and registry-seeded values must survive pages that never subscribe
them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:41:36 +02:00
enricobuehler 8856df7e8d feat(ui): scaffold the blueprint SPA — vite 7 + tailwind 4 + kit theme
New workspace member built on @unom/ui 0.9.1 + @unom/app-ui 0.2.0 +
@punktfunk/plugin-kit/react, effect 4.0.0-beta.99 exact. base:'./' with
build straight into ../plugin/dist/ui; :5599 dev server proxies /api to
the plugin dev API only in live mode. styles.css = tailwind + tw-animate
+ the kit's violet theme (NOT @unom/ui's own neutral palette) + bare-dir
@source globs over the design-system dist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:41:20 +02:00
enricobuehler e6144773d7 docs: README for the blueprint structure; drop standalone-UI sections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:07:36 +02:00
enricobuehler 899028e20f feat: Effect services + HttpApi implementation + kit entry/CLI/dev server
- RomConfig/RomCache on the kit's ConfigService/CacheStore; RomSync wires the
  pure domain into the kit SyncEngine (poll/watch/coalesce/fingerprint) and
  ProviderClient; EngineStatus assembly shared by REST + SSE
- makeApi: HttpApiBuilder groups over live service values (handler runtime
  shares the plugin runtime's singletons) + sseRoute status feed
- index.ts: definePluginKit entry (async-main boundary); headless-first (UI
  serve failure logged, engine continues)
- cli.ts on the kit dispatcher: scan/detect/preview offline, sync/uninstall
  online; set-password is gone with the standalone server
- dev.ts: auth-free loopback API server (:5885) — the dev:live proxy target;
  never bundled
- verified live host-less: raw config round-trip on disk, status/platforms/
  preview endpoints, soft-fail reconcile without a host
- CI: workspace install, per-package typecheck, publish from plugin/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:04:25 +02:00
enricobuehler d6cf5f3d36 refactor: workspace restructure — contract package + pure domain ported, tests green
- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
  domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
  imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
  hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
  gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:54:38 +02:00
enricobuehler e78df91925 ci: fix the PluginDef sanity check (default import, not p.default)
CI / publish (push) Successful in 1m52s
CI / build (push) Successful in 29s
The check imported the default export as `p` and then read `p.default` —
a double-default that is always undefined for a module using
`export default plugin`, so the build job failed on every push (publish
skipped) and v0.2.x never actually published through CI. Read the default
import directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:38:20 +02:00
enricobuehler e84cc89222 feat: persist state under plugin-state/ (runner de-privilege)
CI / publish (push) Has been skipped
CI / build (push) Failing after 1m50s
The managed runner is de-privileged on Windows now (runs as LocalService),
and %ProgramData%\punktfunk is locked read-only to it — so writing config/
cache straight under the config dir fails EPERM (proven on-glass). Move the
plugin's state to <config_dir>/plugin-state/rom-manager, which
`punktfunk-host plugins enable` grants the runner write on. On Linux the
runner owns the config dir, so the path is writable there too — one path, no
branch.

Migrate a pre-0.2.1 config/cache from the old <config_dir>/rom-manager on
first load (copy, guarded, best-effort) so an existing operator's roots +
launch templates survive the move instead of silently resetting to defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:27:33 +02:00
enricobuehler 14709d4062 ci: use ubuntu-24.04 + oven/bun:1 + REGISTRY_TOKEN (match the repo's runners)
CI / build (push) Failing after 30s
CI / publish (push) Has been skipped
The workflow used runs-on: ubuntu-latest, which no Gitea runner is labeled with,
so nothing ran. Mirror the main repo's sdk-publish.yml: ubuntu-24.04 runner, the
oven/bun:1 container (install git+CA certs for checkout), and auth via the shared
REGISTRY_TOKEN secret written to ~/.npmrc (covers @punktfunk + @unom).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:38:45 +02:00
enricobuehler 33e91e4d00 feat: scope + un-bundle the plugin; @unom/ui SPA; custom-emulator editor
CI / publish (push) Has been cancelled
CI / build (push) Has been cancelled
Reliability + correctness pass on the packaging and UI.

Packaging — drop the self-contained bundle:
- Rename to `@punktfunk/plugin-rom-manager` (scoped). An unscoped package can't
  be split across registries, which is the only reason bundling effect + the SDK
  into every plugin seemed necessary. Scoped names resolve from one scope-map, so
  the plugin depends on `@punktfunk/host` + `effect` as SHARED (hoisted) deps —
  no duplication, no ~1 MB effect copy per plugin. Build is back to plain `tsc`.
  (Runner-side discovery of `@punktfunk/plugin-*` shipped in @punktfunk/host 0.1.1.)

UI — use the console's design system so it reads as family:
- SPA rebuilt on @unom/style tokens + Tailwind v4 + the console's exact theme
  (violet chrome, Geist, card/button vocabulary). Build-time only → static assets
  in dist/ui, no @unom runtime dep. Skips @unom/ui's AnimatedButton/Card, which
  statically import ~7 MB of UI sound assets — same look, 416 KB dist/ui.
- Add a custom-emulator editor (Emulators page) writing config.emulators — you can
  now add a launcher Punktfunk doesn't ship a definition for.
- Fix brand capitalization to "Punktfunk" in all UI copy + docs.

Install docs + CI updated for the scoped `bun add @punktfunk/plugin-rom-manager`
flow. 48 engine tests green; backend + UI typecheck + biome clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 12:16:04 +02:00
101 changed files with 5795 additions and 3204 deletions
+55 -28
View File
@@ -1,56 +1,83 @@
# 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.
# CI for the rom-manager workspace (Gitea Actions). Mirrors the main repo's TS workflows:
# ubuntu-24.04 runner + the oven/bun:1 container, auth via the shared REGISTRY_TOKEN secret.
# One root install covers all workspaces (contract + plugin + ui); @punktfunk/* and
# @unom/* resolve from the Gitea registry via the root bunfig scope maps, effect and
# @effect/atom-react from npm. Publish runs on a `v*` tag, from plugin/.
name: CI
on:
push:
branches: [main]
tags: ["v*"]
tags: ['v*']
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
steps:
# oven/bun's slim base ships neither git nor a CA bundle — actions/checkout's HTTPS fetch needs both.
- name: Install git + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install
- name: Registry auth
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc"
- name: Install (workspace)
run: bun install --frozen-lockfile
- name: Lint & format
run: bunx biome check
- name: Typecheck (backend)
- name: Typecheck (contract)
working-directory: contract
run: bunx tsc --noEmit
- name: Test (engine)
- name: Typecheck (plugin)
working-directory: plugin
run: bunx tsc --noEmit
- name: Test (domain + services)
working-directory: plugin
run: bun test
- name: Build backend
run: bun run build
- name: Build SPA
run: bun run build:ui
- name: Build backend bundle + SPA
working-directory: plugin
run: bun run build:all
- name: Typecheck (UI)
working-directory: ui
run: bunx tsc --noEmit
- name: Bundle sanity — plugin default export is a valid PluginDef
- name: Sanity — plugin default export is a valid PluginDef
working-directory: plugin
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)'
bun -e 'import plugin from "./dist/index.js"; if (plugin?.name !== "rom-manager" || typeof plugin?.main !== "function") { console.error("bad default export", plugin); process.exit(1); } console.log("ok:", plugin.name)'
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
steps:
- name: Install git + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- 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
- name: Registry auth
env:
NPM_TOKEN: ${{ secrets.GITEA_NPM_TOKEN }}
TOKEN: ${{ secrets.REGISTRY_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/
test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc"
- name: Install (workspace)
run: bun install --frozen-lockfile
- name: Tag matches package version
working-directory: plugin
run: |
TAG="${GITHUB_REF_NAME#v}"
PKG="$(node -p "require('./package.json').version")"
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; }
- name: Publish (prepublishOnly builds backend + SPA)
working-directory: plugin
run: bun publish
+2
View File
@@ -2,5 +2,7 @@ node_modules
dist
ui/dist
ui/node_modules
ui/storybook-static
ui/screenshots
*.log
.DS_Store
+53 -42
View File
@@ -1,41 +1,50 @@
# punktfunk-plugin-rom-manager
# @punktfunk/plugin-rom-manager
A [punktfunk](https://git.unom.io/unom/punktfunk) plugin that scans your ROM folders, maps them to
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
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
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.
- 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.
- `@punktfunk/host` **≥ 0.1.2** + `@punktfunk/plugin-kit` **≥ 0.1.1** — it provides the plugin-UI surface (`servePluginUi`) and discovers
scoped `@punktfunk/plugin-*` packages.
## Install
The plugin is scoped under `@punktfunk`, so one bunfig line points that scope at the Punktfunk registry
while everything else (like `effect`) resolves from npm — no bundling, and `@punktfunk/host` + `effect`
are shared across all your plugins:
```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/
mkdir -p "$(punktfunk config-dir)/plugins" && cd "$(punktfunk config-dir)/plugins"
cat > bunfig.toml <<'EOF'
[install.scopes]
"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/"
EOF
bun add @punktfunk/plugin-rom-manager
# 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.
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.
> **Headless / host-only boxes** without the console can just drop a `config.json` (below) and use
> the CLI — 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:
The plugin owns `<config_dir>/plugin-state/rom-manager/config.json`. A minimal example:
```jsonc
{
@@ -47,7 +56,8 @@ The plugin owns `<config_dir>/rom-manager/config.json`. A minimal example:
}
```
See [`config.example.json`](./config.example.json) for every option. Then, from the plugin dir:
Only the keys you author are stored — defaults live in the schema (`contract/src/config.ts`) and are
never baked into your file. Then, from the plugin dir:
```sh
bunx punktfunk-plugin-rom-manager scan # what the scanner finds
@@ -86,27 +96,12 @@ 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'
```
with the host; the console reverse-proxies it and gates it with your existing console sign-in. Five
pages: **Overview** (health, stats, live sync activity), **Library** (scan preview with covers
+ include toggles), **Sources** (ROM roots), **Emulators** (detection + per-platform overrides),
**Settings** (art, sync, file paths). The console surface is the only UI — the old standalone
password server was removed in 0.3.0 (headless boxes drive the plugin via the CLI).
## Security
@@ -116,17 +111,33 @@ bunx punktfunk-plugin-rom-manager set-password 'your-password'
- `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
## Development — this repo is the plugin blueprint
rom-manager is the reference consumer of
[`@punktfunk/plugin-kit`](https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit) — copy this
repo's structure to start a new plugin. Three bun workspaces:
| Workspace | What it is |
|---|---|
| `contract/` | **The single source of truth**: Effect Schemas for the config (raw↔resolved in one schema), domain DTOs, the `HttpApi` contract, API errors. Never published — bundled into the plugin, imported source-level by the UI. No hand-mirrored types anywhere. |
| `plugin/` | The published package. `src/domain` + `src/art` are the pure, injectable, unit-tested core; `src/services` wires them into the kit (ConfigService, CacheStore, SyncEngine, ProviderClient, HttpApi handlers + SSE); `src/index.ts` is the `definePluginKit` entry (async at the runner boundary, Effect inside). |
| `ui/` | The SPA: React 19 + `@unom/ui` + `@unom/app-ui` + the kit's `/react` helpers + `/theme.css`, with an Effect-native data layer (`AtomHttpApi` atoms derived from the contract). Builds into `plugin/dist/ui`. |
```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)
bun install # one workspace install (Gitea registry for @punktfunk/@unom scopes)
bun test # domain + service tests (plugin/)
bun run typecheck # contract → plugin → ui
bun run build # backend bundle + SPA → plugin/dist
cd ui && bun run dev # UI with ZERO host running (in-browser mock layer + scenarios)
cd ui && bun run storybook # per-page stories on the same fixtures (port 6013)
cd plugin && bun run dev # auth-free dev API on :5885 …
cd ui && bun run dev:live # … and the UI proxied against it
```
This plugin requires `@punktfunk/host` ≥ 0.1.0 (the version that introduced `servePluginUi`).
Requires `@punktfunk/host` ≥ 0.1.2 and `@punktfunk/plugin-kit` ≥ 0.1.1 at runtime (shared with the
runner, not bundled). `@unom/*` + `@effect/atom-react` are build-time only — the SPA ships as static
assets, so the published plugin carries no UI runtime dependencies.
## License
+11
View File
@@ -13,6 +13,11 @@
"enabled": true,
"indentStyle": "tab"
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {
@@ -29,6 +34,12 @@
},
"style": {
"noNonNullAssertion": "off"
},
"a11y": {
"noLabelWithoutControl": "off"
},
"correctness": {
"useExhaustiveDependencies": "warn"
}
}
},
+1696 -6
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,6 +1,5 @@
# 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.
# Resolve the @punktfunk and @unom scopes from the Gitea npm registry for the whole
# workspace (plugin deps + UI design system).
[install.scopes]
"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/"
"@unom" = "https://git.unom.io/api/packages/unom/npm/"
-38
View File
@@ -1,38 +0,0 @@
{
"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"
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@rom-manager/contract",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "The single source of truth shared by the plugin server and the UI: config schema, domain DTOs, the HttpApi contract, and API errors. Never published — bundled into the plugin, aliased into the UI.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"effect": "^4.0.0-beta.99",
"@punktfunk/plugin-kit": "^0.1.4"
},
"devDependencies": {
"effect": "4.0.0-beta.99",
"@punktfunk/plugin-kit": "^0.1.4",
"typescript": "^5.9.3",
"@types/bun": "^1.3.0"
}
}
+101
View File
@@ -0,0 +1,101 @@
// The one HttpApi contract — HttpApiBuilder implements it server-side, AtomHttpApi
// derives the UI's typed client + atoms from it. No hand-mirrored types anywhere.
import { ProviderEntry } from "@punktfunk/plugin-kit/wire";
import { Schema } from "effect";
import {
HttpApi,
HttpApiEndpoint,
HttpApiGroup,
HttpApiSchema,
} from "effect/unstable/httpapi";
import {
DetectedEmulator,
EngineStatus,
Platform,
SyncReport,
} from "./domain.js";
import {
ConfigInvalid,
DetectFailed,
ScanFailed,
SyncInProgress,
} from "./errors.js";
/** GET /api/config + PUT /api/config response: the authored raw shape, verbatim.
* The UI resolves it locally by decoding through the SHARED RomConfigSchema. */
export const ConfigPayload = Schema.Struct({ raw: Schema.Unknown });
export type ConfigPayload = typeof ConfigPayload.Type;
/** GET /api/preview: the dry-run desired state (cached art only — no warming). */
export const PreviewResult = Schema.Struct({
entries: Schema.Array(ProviderEntry),
report: SyncReport,
});
export type PreviewResult = typeof PreviewResult.Type;
export const EmulatorsPayload = Schema.Struct({
detected: Schema.Array(DetectedEmulator),
detectedAt: Schema.NullOr(Schema.Number),
});
export type EmulatorsPayload = typeof EmulatorsPayload.Type;
export const RomManagerApi = HttpApi.make("rom-manager")
.add(
HttpApiGroup.make("status").add(
HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }),
),
)
.add(
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }),
)
.add(
HttpApiEndpoint.put("put", "/api/config", {
payload: Schema.Unknown,
success: ConfigPayload,
error: ConfigInvalid.pipe(HttpApiSchema.status(400)),
}),
),
)
.add(
HttpApiGroup.make("library").add(
HttpApiEndpoint.get("preview", "/api/preview", {
success: PreviewResult,
error: ScanFailed.pipe(HttpApiSchema.status(500)),
}),
),
)
.add(
HttpApiGroup.make("catalog")
.add(
HttpApiEndpoint.get("platforms", "/api/platforms", {
success: Schema.Array(Platform),
}),
)
.add(
HttpApiEndpoint.get("emulators", "/api/emulators", {
success: EmulatorsPayload,
}),
)
.add(
HttpApiEndpoint.post("detect", "/api/detect", {
success: EmulatorsPayload,
error: DetectFailed.pipe(HttpApiSchema.status(500)),
}),
),
)
.add(
HttpApiGroup.make("sync").add(
HttpApiEndpoint.post("run", "/api/sync", {
success: SyncReport,
error: SyncInProgress.pipe(HttpApiSchema.status(409)),
}),
),
);
/** The SSE status feed — outside the HttpApi (no event-stream media type in httpapi).
* Frames: `event: status`, data = EngineStatus JSON. */
export const EVENTS_PATH = "/api/events";
export const EVENTS_EVENT = "status";
+102
View File
@@ -0,0 +1,102 @@
// The config model. ONE schema: the Encoded side IS the authored file shape (RawConfig),
// the Type side is the fully-resolved shape the engine consumes (Config). Defaults live
// ONLY here (`withDecodingDefaultKey` + encodingStrategy "omit") — the kit's ConfigService
// persists the raw shape verbatim, so a UI save never bakes defaults into the file.
//
// Dropped vs 0.2.x: `ui.*` (the standalone password server is gone — console surface +
// CLI only) and `devEntry` (dev flag out of the prod shape; use `preview` instead).
// Stale keys in existing files are tolerated on decode and dropped by the next save.
import { Effect, Schema } from "effect";
import { EmulatorDef, Platform } from "./domain.js";
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
schema.pipe(
Schema.withDecodingDefaultKey(Effect.succeed(value), {
encodingStrategy: "omit",
}),
);
/** A ROM root: a directory paired with the platform its files belong to (design §5). */
export const RomRoot = Schema.Struct({
dir: Schema.String,
platform: Schema.String,
excludes: Schema.optionalKey(Schema.Array(Schema.String)),
});
export type RomRoot = typeof RomRoot.Type;
/** A per-platform launch override — replaces the platform's built-in default. */
export const PlatformLaunch = Schema.Struct({
emulator: Schema.String,
core: Schema.optionalKey(Schema.String),
extraArgs: Schema.optionalKey(Schema.String),
});
export type PlatformLaunch = typeof PlatformLaunch.Type;
/** A per-game override, keyed by `external_id` (`snes/Chrono Trigger (USA).sfc`). */
export const GameOverride = Schema.Struct({
exclude: Schema.optionalKey(Schema.Boolean),
emulator: Schema.optionalKey(Schema.String),
core: Schema.optionalKey(Schema.String),
extraArgs: Schema.optionalKey(Schema.String),
title: Schema.optionalKey(Schema.String),
art: Schema.optionalKey(Schema.String),
});
export type GameOverride = typeof GameOverride.Type;
export const ArtProviderChoice = Schema.Literals([
"auto",
"steamgriddb",
"libretro",
]);
export type ArtProviderChoice = typeof ArtProviderChoice.Type;
export const SyncOptions = Schema.Struct({
/** Interval poll in minutes (watchers are unreliable on SMB/NFS — poll is primary). */
pollMinutes: withDefault(Schema.Number, 15),
watch: withDefault(Schema.Boolean, true),
debounceMs: withDefault(Schema.Number, 2000),
/** Platform ids to region-dedupe ("one entry per title", design §5). */
dedupeRegions: withDefault(Schema.Array(Schema.String), []),
regionPriority: withDefault(Schema.Array(Schema.String), [
"USA",
"World",
"Europe",
"Japan",
]),
warnEntries: withDefault(Schema.Number, 2000),
maxEntries: withDefault(Schema.Number, 5000),
closeOnEnd: withDefault(Schema.Boolean, false),
});
export type SyncOptions = typeof SyncOptions.Type;
export const ArtOptions = Schema.Struct({
enabled: withDefault(Schema.Boolean, true),
provider: withDefault(ArtProviderChoice, "auto"),
steamGridDbKey: Schema.optionalKey(Schema.String),
});
export type ArtOptions = typeof ArtOptions.Type;
export const RomConfigSchema = Schema.Struct({
roots: withDefault(Schema.Array(RomRoot), []),
platformLaunch: withDefault(Schema.Record(Schema.String, PlatformLaunch), {}),
gameOverrides: withDefault(Schema.Record(Schema.String, GameOverride), {}),
/** Operator-added / overriding platform defs (merged over built-ins by id). */
platforms: Schema.optionalKey(Schema.Array(Platform)),
/** Operator-added / overriding emulator defs (merged over built-ins by id). */
emulators: Schema.optionalKey(Schema.Array(EmulatorDef)),
sync: withDefault(SyncOptions, {}),
art: withDefault(ArtOptions, {}),
});
/** The fully-resolved config the engine consumes. */
export type Config = typeof RomConfigSchema.Type;
/** The on-disk config as authored (all optional). */
export type RawConfig = typeof RomConfigSchema.Encoded;
/**
* Resolve an authored raw config to the total shape (defaults filled). Throws on an
* invalid shape — the sync path for tests and the UI; the server side uses the kit's
* ConfigService (Effect + typed errors) instead.
*/
export const resolveConfig = (raw: unknown): Config =>
Schema.decodeUnknownSync(RomConfigSchema)(raw);
+109
View File
@@ -0,0 +1,109 @@
// Domain DTOs shared by the plugin server and the UI — Schemas are the source of truth,
// the derived types keep the original domain names so the pure core reads unchanged.
import { Schema } from "effect";
/** Host OS as the launch/quoting domain sees it (macOS hosts use the linux lane). */
export const Os = Schema.Literals(["linux", "windows"]);
export type Os = typeof Os.Type;
/** Default emulator + core for a platform (operator-overridable per platform / per game). */
export const DefaultLaunch = Schema.Struct({
emulator: Schema.String,
core: Schema.optionalKey(Schema.String),
extraArgs: Schema.optionalKey(Schema.String),
});
export type DefaultLaunch = typeof DefaultLaunch.Type;
/** A console platform: extension set, art key, default launch (design §5). */
export const Platform = Schema.Struct({
id: Schema.String,
name: Schema.String,
extensions: Schema.Array(Schema.String),
libretroSystem: Schema.optionalKey(Schema.String),
defaultLaunch: DefaultLaunch,
disc: Schema.optionalKey(Schema.Boolean),
});
export type Platform = typeof Platform.Type;
/** An emulator definition: per-OS detection, launch template, archive support. */
export const EmulatorDef = Schema.Struct({
id: Schema.String,
name: Schema.String,
detect: Schema.Struct({
linux: Schema.Array(Schema.String),
windows: Schema.Array(Schema.String),
}),
coresDir: Schema.optionalKey(
Schema.Struct({
linux: Schema.Array(Schema.String),
windows: Schema.Array(Schema.String),
}),
),
template: Schema.String,
supportsArchives: Schema.Boolean,
contested: Schema.optionalKey(Schema.Boolean),
});
export type EmulatorDef = typeof EmulatorDef.Type;
/** A detected emulator install (UI's Emulators page + launch resolution). */
export const DetectedEmulator = Schema.Struct({
id: Schema.String,
name: Schema.String,
template: Schema.String,
supportsArchives: Schema.Boolean,
contested: Schema.optional(Schema.Boolean),
exeToken: Schema.String,
exePath: Schema.optional(Schema.String),
appId: Schema.optional(Schema.String),
via: Schema.Literals(["path", "file", "flatpak"]),
coresDir: Schema.optional(Schema.String),
cores: Schema.optional(Schema.Array(Schema.String)),
});
export type DetectedEmulator = typeof DetectedEmulator.Type;
/** A candidate the compute rejected, with a human reason (Library page, CLI). */
export const Skipped = Schema.Struct({
external_id: Schema.String,
title: Schema.String,
reason: Schema.String,
});
export type Skipped = typeof Skipped.Type;
/** A summary of one desired-state compute (Overview + Library pages). */
export const SyncReport = Schema.Struct({
considered: Schema.Number,
included: Schema.Number,
skipped: Schema.Array(Skipped),
excluded: Schema.Array(
Schema.Struct({ external_id: Schema.String, title: Schema.String }),
),
warnings: Schema.Array(Schema.String),
truncated: Schema.Number,
perPlatform: Schema.Record(Schema.String, Schema.Number),
overWarn: Schema.Boolean,
});
export type SyncReport = typeof SyncReport.Type;
export const LastSync = Schema.Struct({
fingerprint: Schema.String,
count: Schema.Number,
at: Schema.Number,
});
export type LastSync = typeof LastSync.Type;
/** The engine status the Overview page renders and the SSE feed streams. */
export const EngineStatus = Schema.Struct({
rootsConfigured: Schema.Number,
os: Os,
artProvider: Schema.NullOr(Schema.String),
syncing: Schema.Boolean,
lastSync: Schema.optional(LastSync),
lastReport: Schema.optional(SyncReport),
detectedAt: Schema.optional(Schema.Number),
paths: Schema.Struct({
dir: Schema.String,
config: Schema.String,
cache: Schema.String,
}),
});
export type EngineStatus = typeof EngineStatus.Type;
+27
View File
@@ -0,0 +1,27 @@
// API errors — Schema-backed so they cross the wire typed; status set per-endpoint via
// HttpApiSchema.status in api.ts.
import { Schema } from "effect";
/** PUT /api/config body failed schema validation. → 400 */
export class ConfigInvalid extends Schema.TaggedErrorClass<ConfigInvalid>()(
"ConfigInvalid",
{ issues: Schema.String },
) {}
/** A sync pass is already running (the trigger was coalesced). → 409 */
export class SyncInProgress extends Schema.TaggedErrorClass<SyncInProgress>()(
"SyncInProgress",
{},
) {}
/** Scan/compute failed. → 500 */
export class ScanFailed extends Schema.TaggedErrorClass<ScanFailed>()(
"ScanFailed",
{ message: Schema.String },
) {}
/** Emulator detection failed. → 500 */
export class DetectFailed extends Schema.TaggedErrorClass<DetectFailed>()(
"DetectFailed",
{ message: Schema.String },
) {}
+6
View File
@@ -0,0 +1,6 @@
// @rom-manager/contract — the shared source of truth (config schema, domain DTOs,
// HttpApi contract, API errors). Bundled into the plugin, aliased into the UI.
export * from "./api.js";
export * from "./config.js";
export * from "./domain.js";
export * from "./errors.js";
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun"]
},
"include": ["src"]
}
+10 -42
View File
@@ -1,51 +1,19 @@
{
"name": "punktfunk-plugin-rom-manager",
"version": "0.1.1",
"private": false,
"name": "rom-manager-workspace",
"private": true,
"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",
"workspaces": [
"contract",
"plugin",
"roms",
"emulators",
"retroarch",
"game-streaming"
"ui"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"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": "rm -rf dist && bun build src/index.ts src/cli.ts --target=bun --outdir dist --external undici",
"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"
"typecheck": "cd contract && bun run typecheck && cd ../plugin && bun run typecheck && cd ../ui && bun run typecheck",
"test": "cd plugin && bun test",
"build": "cd plugin && bun run build:all",
"check": "bunx biome check ."
},
"dependencies": {},
"devDependencies": {
"@biomejs/biome": "^2.5.2",
"@punktfunk/host": "^0.1.0",
"@types/bun": "^1.3.0",
"effect": "^4.0.0-beta.98",
"typescript": "^5.9.3"
"@biomejs/biome": "^2.5.2"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@punktfunk/plugin-rom-manager",
"version": "0.3.1",
"private": false,
"type": "module",
"description": "Punktfunk plugin: scans ROM directories, maps them to emulators, and reconciles them into the host game library as a provider — with a console-hosted web UI. The reference plugin built on @punktfunk/plugin-kit.",
"license": "MIT OR Apache-2.0",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
"repository": {
"type": "git",
"url": "https://git.unom.io/unom/punktfunk-plugin-rom-manager.git"
},
"keywords": [
"punktfunk",
"plugin",
"roms",
"emulators",
"retroarch",
"game-streaming"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./types/index.d.ts",
"bin": {
"punktfunk-plugin-rom-manager": "./dist/cli.js"
},
"files": [
"dist",
"types"
],
"publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test",
"build": "bun build src/index.ts src/cli.ts --target=bun --outdir dist --external effect --external '@punktfunk/*'",
"build:ui": "cd ../ui && bun run build",
"build:all": "bun run build && bun run build:ui",
"dev": "bun src/dev.ts",
"prepublishOnly": "bun run build:all"
},
"dependencies": {
"@punktfunk/host": "^0.1.2",
"@punktfunk/plugin-kit": "^0.1.4",
"effect": "^4.0.0-beta.99"
},
"devDependencies": {
"@rom-manager/contract": "workspace:*",
"@types/bun": "^1.3.0",
"typescript": "^5.9.3"
}
}
@@ -3,9 +3,9 @@
// 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 { Artwork } from "@punktfunk/plugin-kit/wire";
import type { Platform } from "../domain/platforms.js";
import type { ParsedTitle } from "../domain/titles.js";
import type { ArtProvider } from "./provider.js";
const BASE = "https://thumbnails.libretro.com";
@@ -4,15 +4,24 @@
// 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 type { Artwork } from "@punktfunk/plugin-kit/wire";
import type { Config } from "@rom-manager/contract";
import type { Platform } from "../domain/platforms.js";
import type { ParsedTitle } from "../domain/titles.js";
import { LibretroProvider } from "./libretro.js";
import { SteamGridDbProvider } from "./steamgriddb.js";
export type ArtLog = (line: string) => void;
const noopLog: ArtLog = () => {};
/** One cached art verdict: the resolved artwork (or `null` = nothing found), tagged with
* the provider and its matcher version so a provider switch or algorithm bump re-resolves. */
export interface ArtVerdict {
art: Artwork | null;
provider: string;
matcher: number;
}
export interface ArtProvider {
/** Stable id, stored in the cache verdict so a provider switch re-resolves. */
readonly id: string;
@@ -23,20 +32,25 @@ export interface ArtProvider {
}
/** Pick the active provider for a config, or `null` when art is disabled / unavailable. */
export const selectArtProvider = (config: Config): ArtProvider | null => {
export const selectArtProvider = (
config: Config,
log: ArtLog = noopLog,
): ArtProvider | null => {
if (!config.art.enabled) return null;
const key = config.art.steamGridDbKey?.trim();
switch (config.art.provider) {
case "libretro":
return new LibretroProvider();
case "steamgriddb":
if (key) return new SteamGridDbProvider(key);
if (key) return new SteamGridDbProvider(key, fetch, log);
log(
"art: provider=steamgriddb but no API key set — no art will be fetched (set art.steamGridDbKey or use provider=auto)",
);
return null;
default: // "auto"
return key ? new SteamGridDbProvider(key) : new LibretroProvider();
return key
? new SteamGridDbProvider(key, fetch, log)
: new LibretroProvider();
}
};
@@ -76,8 +90,9 @@ export const warmArt = async (
provider: ArtProvider,
targets: ArtTarget[],
cache: { art: Record<string, ArtVerdict> },
opts?: { concurrency?: number },
opts?: { concurrency?: number; log?: ArtLog },
): Promise<number> => {
const log = opts?.log ?? noopLog;
const stale = targets.filter((t) => {
const v = cache.art[t.externalId];
return (
@@ -8,10 +8,10 @@
// 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 { Artwork } from "@punktfunk/plugin-kit/wire";
import type { Platform } from "../domain/platforms.js";
import type { ParsedTitle } from "../domain/titles.js";
import type { ArtProvider } from "./provider.js";
const BASE = "https://www.steamgriddb.com/api/v2";
@@ -37,6 +37,7 @@ export class SteamGridDbProvider implements ArtProvider {
constructor(
private readonly apiKey: string,
private readonly fetchImpl: SgdbFetch = fetch,
private readonly log: (line: string) => void = () => {},
) {
// The key fingerprint scopes cached verdicts to this key: change the key → re-resolve, without
// ever storing the key itself in the cache file.
@@ -52,7 +53,7 @@ export class SteamGridDbProvider implements ArtProvider {
});
if (res.status === 401 || res.status === 403) {
this.authFailed = true;
log(
this.log(
"art: SteamGridDB rejected the API key (401/403) — check art.steamGridDbKey",
);
return null;
@@ -98,7 +99,13 @@ export class SteamGridDbProvider implements ArtProvider {
this.getSafe(`/logos/game/${game.id}`) as Promise<SgdbImage[]>,
]);
const art: Artwork = {};
// Built mutable, returned as the (readonly) wire type.
const art: {
portrait?: string;
hero?: string;
logo?: string;
header?: string;
} = {};
const portrait = pickGrid(grids, (i) => i.height > i.width, [
[600, 900],
[660, 930],
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bun
// Ops CLI on the kit's dispatcher — the same layer graph as the plugin entry, so every
// command sees the exact services the runner runs. scan/detect/preview work offline;
// sync/uninstall talk to the host.
import {
type CliCommand,
ProviderClient,
runPluginCli,
} from "@punktfunk/plugin-kit";
import { Effect } from "effect";
import pkg from "../package.json" with { type: "json" };
import { PLUGIN_NAME } from "./index.js";
import { type RomServices, romLayer } from "./layer.js";
import { PROVIDER_ID, RomSync } from "./services/engine.js";
const print = (line: string) => Effect.sync(() => console.log(line));
const commands: Record<string, CliCommand<RomServices>> = {
scan: {
summary: "Scan configured ROM roots and list candidates",
offline: true,
run: () =>
Effect.gen(function* () {
const sync = yield* RomSync;
const { report } = yield* sync.preview;
yield* print(
`${report.considered} candidate(s), ${report.included} would be included`,
);
for (const [platform, n] of Object.entries(report.perPlatform)) {
yield* print(` ${platform}: ${n}`);
}
}),
},
detect: {
summary: "Probe for installed emulators",
offline: true,
run: () =>
Effect.gen(function* () {
const sync = yield* RomSync;
const detected = yield* sync.detect(true);
for (const d of detected) {
yield* print(
`${d.id.padEnd(14)} ${d.via.padEnd(8)} ${d.exePath ?? d.exeToken}${d.cores ? ` (${d.cores.length} cores)` : ""}`,
);
}
if (detected.length === 0) yield* print("no emulators found");
}),
},
preview: {
summary: "Dry-run: show the entries a sync would reconcile",
offline: true,
run: () =>
Effect.gen(function* () {
const sync = yield* RomSync;
const { entries, report } = yield* sync.preview;
for (const e of entries) yield* print(`${e.external_id} ${e.title}`);
for (const s of report.skipped) {
yield* print(`SKIP ${s.external_id}: ${s.reason}`);
}
yield* print(
`${report.included} included, ${report.skipped.length} skipped`,
);
}),
},
sync: {
summary: "Reconcile the library provider now",
run: () =>
Effect.gen(function* () {
const sync = yield* RomSync;
const outcome = yield* sync.engine.sync("manual");
yield* print(
outcome._tag === "AlreadyRunning"
? "a sync is already running"
: `${outcome._tag}: ${outcome.report.included} entries`,
);
}),
},
uninstall: {
summary: "Remove every rom-manager entry from the host library",
run: () =>
Effect.gen(function* () {
const provider = yield* ProviderClient;
yield* provider.remove(PROVIDER_ID);
yield* print("provider entries removed");
}),
},
};
await runPluginCli({
def: {
name: PLUGIN_NAME,
version: pkg.version,
layer: romLayer,
main: Effect.void,
},
commands,
});
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bun
// Dev-only API server: the plugin's HttpApi on a plain loopback port (:5885), no auth,
// no static files — the `bun run dev:live` proxy target for the Vite UI. Connects to a
// running host when one is up (full sync path); otherwise runs host-less (scan, preview,
// detect, and config all work; reconcile fails softly in the engine's safeSync).
// This is NOT part of the shipped plugin (never bundled into dist/index.js or cli.js).
import { connect, type Punktfunk } from "@punktfunk/host";
import {
hostClientFromFacade,
loggingLayer,
pluginInfoLayer,
} from "@punktfunk/plugin-kit";
import { Effect, Layer, ManagedRuntime } from "effect";
import { HttpRouter } from "effect/unstable/http";
import pkg from "../package.json" with { type: "json" };
import { PLUGIN_NAME } from "./index.js";
import { romLayer } from "./layer.js";
import { makeApi } from "./services/api.js";
import { RomCache } from "./services/cache.js";
import { RomConfig } from "./services/config.js";
import { RomSync } from "./services/engine.js";
const PORT = Number(process.env.ROM_MANAGER_DEV_PORT ?? 5885);
const offlineFacade = (): Punktfunk =>
({
request: async (method: string, path: string) => {
throw new Error(`dev: no host running (tried ${method} ${path})`);
},
close: () => {},
}) as unknown as Punktfunk;
const pf = await connect().catch(() => {
console.log("dev: no punktfunk host reachable — running host-less");
return offlineFacade();
});
const base = Layer.mergeAll(
hostClientFromFacade(pf),
pluginInfoLayer({ name: PLUGIN_NAME, version: pkg.version }),
loggingLayer(PLUGIN_NAME),
);
const rt = ManagedRuntime.make(Layer.provideMerge(romLayer, base));
// No Effect.scoped here: the engine's loops belong to the runtime's layer scope and
// must outlive this acquisition effect (they close on rt.dispose()).
const { handler, dispose } = await rt.runPromise(
Effect.gen(function* () {
const sync = yield* RomSync;
const config = yield* RomConfig;
const cache = yield* RomCache;
yield* sync.engine.start;
return HttpRouter.toWebHandler(makeApi({ sync, config, cache }));
}),
);
const server = Bun.serve({
hostname: "127.0.0.1",
port: PORT,
idleTimeout: 0, // SSE
fetch: (req) => handler(req),
});
console.log(
`rom-manager dev API on http://127.0.0.1:${server.port}/api/status`,
);
process.once("SIGINT", () => {
void dispose()
.then(() => rt.dispose())
.finally(() => process.exit(0));
});
@@ -5,9 +5,9 @@
// 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";
import type { PrepStep } from "@punktfunk/plugin-kit/wire";
import type { DetectedEmulator } from "./emulators.js";
import { type Os, quoteFor } from "./quote.js";
/** A command that always exits 0, for `prep.do`. */
const noop = (os: Os): string => (os === "windows" ? "cd ." : "true");
@@ -20,7 +20,7 @@ const noop = (os: Os): string => (os === "windows" ? "cd ." : "true");
export const killPrep = (
det: DetectedEmulator | undefined,
os: Os,
): PrepCmd | null => {
): PrepStep | null => {
if (!det) return null;
if (det.via === "flatpak" && det.appId) {
return { do: noop(os), undo: `flatpak kill ${det.appId}` };
@@ -6,35 +6,17 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
// The DefaultLaunch/EmulatorDef/DetectedEmulator TYPES are contract-owned (shared with
// the UI); this module owns the DATA and the detection logic (DetectEnv stays an
// injectable IO seam local to the plugin).
import type {
DefaultLaunch,
DetectedEmulator,
EmulatorDef,
} from "@rom-manager/contract";
import { type Os, quoteFor } from "./quote.js";
/** 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 type { DefaultLaunch, DetectedEmulator, EmulatorDef };
export const EMULATORS: EmulatorDef[] = [
{
@@ -186,7 +168,7 @@ export const emulatorById = (id: string): EmulatorDef | undefined =>
/** Merge built-in emulators with operator-defined ones (config `emulators[]`, `custom-*` templates). */
export const resolveEmulators = (
overrides?: EmulatorDef[],
overrides?: ReadonlyArray<EmulatorDef>,
): Map<string, EmulatorDef> => {
const merged = new Map(BY_ID);
for (const e of overrides ?? []) merged.set(e.id, e);
@@ -210,25 +192,6 @@ export interface DetectEnv {
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 =>
@@ -250,7 +213,7 @@ 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[],
dirs: ReadonlyArray<string>,
): { coresDir?: string; cores: string[] } => {
const suffix = `_libretro${coreSuffix(env.os)}`;
for (const raw of dirs) {
@@ -313,7 +276,10 @@ export const detectEmulator = (
}
if (!exeToken || !via) return null;
const found: DetectedEmulator = {
// Built mutable, returned as the (readonly) contract type.
const found: {
-readonly [K in keyof DetectedEmulator]: DetectedEmulator[K];
} = {
id: def.id,
name: def.name,
template: def.template,
@@ -3,9 +3,9 @@
// 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 { 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. */
@@ -6,28 +6,10 @@
// 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";
// The Platform TYPE is contract-owned (shared with the UI); this module owns the DATA.
import type { Platform } from "@rom-manager/contract";
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;
}
export type { Platform };
/** The built-in platform set (~25 at launch). Arcade (MAME/FBNeo romset versioning) is deferred (D6). */
export const PLATFORMS: Platform[] = [
@@ -244,7 +226,7 @@ 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[],
overrides?: ReadonlyArray<Platform>,
): Map<string, Platform> => {
const merged = new Map(BY_ID);
for (const p of overrides ?? []) merged.set(p.id, p);
@@ -1,18 +1,18 @@
// 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
// art cache → ProviderEntry[]`, unit-testable with no host and no filesystem. The engine
// orchestrator (index.ts / cli.ts) does the I/O (scan, detect, art-probe) and hands the results here.
//
// `enumerateTitles` is factored out so the orchestrator can learn which titles need art (and warm the
// cache) BEFORE the final `computeDesired`, without re-implementing the exclude/dedupe rules.
import type { Config, GameOverride } from "../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 type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
import type { Config, GameOverride } from "@rom-manager/contract";
import type { ArtVerdict } from "../art/provider.js";
import { killPrep } from "./close.js";
import { type DetectedEmulator, resolveEmulators } from "./emulators.js";
import { type EffectiveLaunch, resolveLaunch } from "./launch.js";
import { type Platform, resolvePlatforms } from "./platforms.js";
import type { Os } from "./quote.js";
import type { RomCandidate } from "./scanner.js";
import {
dedupeKey,
@@ -56,7 +56,7 @@ export interface SyncReport {
}
export interface DesiredResult {
entries: ProviderEntryInput[];
entries: ProviderEntry[];
report: SyncReport;
}
@@ -209,7 +209,7 @@ export const computeDesired = (params: {
const { survivors, skipped, excluded } = enumerateTitles(config, scan);
const warnings: string[] = [];
const entries: ProviderEntryInput[] = [];
const entries: ProviderEntry[] = [];
const perPlatform: Record<string, number> = {};
for (const p of survivors) {
const launch = effectiveLaunch(p.platform, config, p.override);
@@ -232,7 +232,10 @@ export const computeDesired = (params: {
}
for (const w of resolution.warnings) warnings.push(`${title}: ${w}`);
const entry: ProviderEntryInput = {
// Built mutable, collected as the (readonly) wire type.
const entry: {
-readonly [K in keyof ProviderEntry]: ProviderEntry[K];
} = {
external_id: p.externalId,
title,
launch: { kind: "command", value: resolution.value },
@@ -5,7 +5,7 @@
import * as fs from "node:fs";
import * as nodePath from "node:path";
import type { Platform } from "../platforms.js";
import type { Platform } from "./platforms.js";
/** A directory entry the walker sees. */
export interface DirEnt {
@@ -122,7 +122,7 @@ export const dedupeKey = (parsed: ParsedTitle): string =>
*/
export const pickBestRelease = <T extends { parsed: ParsedTitle }>(
candidates: T[],
regionPriority: string[],
regionPriority: ReadonlyArray<string>,
): T => {
const rank = (c: T): number => {
const idx = c.parsed.regions
+48
View File
@@ -0,0 +1,48 @@
// The plugin entry — the default export the runner discovers. Everything is Effect
// inside definePluginKit's async-main boundary (see @punktfunk/plugin-kit): migrate,
// start the sync engine, serve the console UI, park until interruption.
import { definePluginKit, serveUi } from "@punktfunk/plugin-kit";
import { Effect } from "effect";
import pkg from "../package.json" with { type: "json" };
import { romLayer } from "./layer.js";
import { migrateLegacyState } from "./migrate.js";
import { makeApi } from "./services/api.js";
import { RomCache } from "./services/cache.js";
import { RomConfig } from "./services/config.js";
import { RomSync } from "./services/engine.js";
export const PLUGIN_NAME = "rom-manager";
export const UI_TITLE = "ROM Manager";
export const UI_ICON = "gamepad-2";
const plugin = definePluginKit({
name: PLUGIN_NAME,
version: pkg.version,
layer: romLayer,
main: Effect.gen(function* () {
yield* migrateLegacyState(PLUGIN_NAME);
const sync = yield* RomSync;
const config = yield* RomConfig;
const cache = yield* RomCache;
// Headless sync first — the library reconciles even if the UI can't come up.
yield* sync.engine.start;
yield* serveUi({
title: UI_TITLE,
icon: UI_ICON,
staticDir: new URL("./ui/", import.meta.url),
api: makeApi({ sync, config, cache }),
}).pipe(
Effect.catch((e) =>
Effect.logWarning(
`ui: could not serve the console surface (${e.cause}) — engine continues headless`,
),
),
);
yield* Effect.never;
}),
});
export default plugin;
+21
View File
@@ -0,0 +1,21 @@
// The plugin's service graph over the kit base (HostClient | PluginInfo) — shared by the
// runner entry (index.ts), the CLI, and the dev server so they all run the same engine.
import type { HostClient } from "@punktfunk/plugin-kit";
import { type PluginInfo, ProviderClient } from "@punktfunk/plugin-kit";
import { Layer } from "effect";
import { RomCache } from "./services/cache.js";
import { RomConfig } from "./services/config.js";
import { RomSync } from "./services/engine.js";
export type RomServices = RomConfig | RomCache | RomSync | ProviderClient;
export const romLayer: Layer.Layer<
RomServices,
never,
HostClient | PluginInfo
> = RomSync.layer.pipe(
Layer.provideMerge(
Layer.mergeAll(RomConfig.layer, RomCache.layer, ProviderClient.layer),
),
);
+26
View File
@@ -0,0 +1,26 @@
// One-shot legacy-state migration (pre-0.2.1 → plugin-state), OUT of the hot load path.
// Copy (not move): the old dir may be unwritable, and leaving it is harmless. Guarded —
// only fills files the new dir does not already have — and best-effort.
import * as fs from "node:fs";
import * as path from "node:path";
import { pluginStateDir } from "@punktfunk/plugin-kit";
import { Effect } from "effect";
export const migrateLegacyState = (name: string): Effect.Effect<void> =>
Effect.sync(() => {
const dir = pluginStateDir(name);
// <config_dir>/plugin-state/<name> → <config_dir>/<name> was the pre-0.2.1 home.
const legacy = path.join(path.dirname(path.dirname(dir)), name);
try {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
for (const file of ["config.json", "cache.json"]) {
const dst = path.join(dir, file);
const src = path.join(legacy, file);
if (!fs.existsSync(dst) && fs.existsSync(src)) {
fs.copyFileSync(src, dst);
}
}
} catch {
// best-effort — a failed copy just means the plugin starts from defaults
}
});
+129
View File
@@ -0,0 +1,129 @@
// The HttpApi implementation + SSE route, composed as the Layer `serveUi` mounts.
// Built from live service VALUES (not layers) so the handler runtime shares the plugin
// runtime's singletons (one engine, one config service).
import {
type CacheStore,
type ConfigService,
httpApiEnv,
sseRoute,
} from "@punktfunk/plugin-kit";
import {
ConfigInvalid,
DetectFailed,
type EmulatorsPayload,
EVENTS_EVENT,
EVENTS_PATH,
type Platform,
type RomConfigSchema,
RomManagerApi,
resolveConfig,
ScanFailed,
SyncInProgress,
} from "@rom-manager/contract";
import { Effect, Layer } from "effect";
import type { HttpRouter } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { resolvePlatforms } from "../domain/platforms.js";
import type { CacheSchema } from "./cache.js";
import type { RomSyncService } from "./engine.js";
export interface ApiDeps {
readonly sync: RomSyncService;
readonly config: ConfigService<typeof RomConfigSchema>;
readonly cache: CacheStore<typeof CacheSchema>;
}
const emulatorsPayload = (
deps: ApiDeps,
force: boolean,
): Effect.Effect<EmulatorsPayload> =>
Effect.gen(function* () {
const detected = yield* deps.sync.detect(force);
const at = (yield* deps.cache.get).detect?.at ?? null;
return { detected, detectedAt: at };
});
export const makeApi = (
deps: ApiDeps,
): Layer.Layer<never, never, HttpRouter.HttpRouter> => {
const statusGroup = HttpApiBuilder.group(RomManagerApi, "status", (h) =>
h.handle("get", () => deps.sync.status),
);
const configGroup = HttpApiBuilder.group(RomManagerApi, "config", (h) =>
h
.handle("get", () =>
deps.config.loadRaw.pipe(
Effect.map((raw) => ({ raw: raw as unknown })),
Effect.orDie,
),
)
.handle("put", ({ payload }) =>
deps.config.saveRaw(payload).pipe(
Effect.mapError((e) => new ConfigInvalid({ issues: String(e) })),
// Loops restart + resync in the background; the PUT answers immediately.
Effect.tap(() => Effect.forkDetach(deps.sync.engine.reconfigure)),
Effect.map(() => ({ raw: payload as unknown })),
),
),
);
const libraryGroup = HttpApiBuilder.group(RomManagerApi, "library", (h) =>
h.handle("preview", () =>
deps.sync.preview.pipe(
Effect.mapError((e) => new ScanFailed({ message: String(e) })),
),
),
);
const catalogGroup = HttpApiBuilder.group(RomManagerApi, "catalog", (h) =>
h
.handle("platforms", () =>
deps.config.load.pipe(
Effect.orElseSucceed(() => resolveConfig({})),
Effect.map(
(config) =>
[
...resolvePlatforms(config.platforms).values(),
] as Array<Platform>,
),
),
)
.handle("emulators", () => emulatorsPayload(deps, false))
.handle("detect", () =>
emulatorsPayload(deps, true).pipe(
Effect.mapError((e) => new DetectFailed({ message: String(e) })),
),
),
);
const syncGroup = HttpApiBuilder.group(RomManagerApi, "sync", (h) =>
h.handle("run", () =>
deps.sync.engine.sync("manual").pipe(
Effect.orDie, // a SyncError is a 500; the 409 stays typed below
Effect.flatMap((outcome) =>
outcome._tag === "AlreadyRunning"
? Effect.fail(new SyncInProgress())
: Effect.succeed(outcome.report),
),
),
),
);
return Layer.mergeAll(
HttpApiBuilder.layer(RomManagerApi).pipe(
Layer.provide(
Layer.mergeAll(
statusGroup,
configGroup,
libraryGroup,
catalogGroup,
syncGroup,
),
),
Layer.provide(httpApiEnv),
),
// EngineStatus is an identity codec (plain JSON shape) — default JSON encode.
sseRoute(EVENTS_PATH, deps.sync.statusChanges, { event: EVENTS_EVENT }),
);
};
+46
View File
@@ -0,0 +1,46 @@
// The plugin's disposable derived state (cache.json), on the kit's CacheStore: art
// verdicts keyed by external_id, the last detection result, and the last-sync
// fingerprint. Corrupt/absent → empty; every mutation is write-through.
import {
type CacheStore,
makeCacheStore,
type PluginInfo,
} from "@punktfunk/plugin-kit";
import { Artwork } from "@punktfunk/plugin-kit/wire";
import { DetectedEmulator, LastSync, Os } from "@rom-manager/contract";
import { Context, Effect, Layer, Schema } from "effect";
export const ArtVerdictSchema = Schema.Struct({
art: Schema.NullOr(Artwork),
provider: Schema.String,
matcher: Schema.Number,
});
export const CacheSchema = Schema.Struct({
art: Schema.Record(Schema.String, ArtVerdictSchema).pipe(
Schema.withDecodingDefaultKey(Effect.succeed({}), {
encodingStrategy: "passthrough",
}),
),
detect: Schema.optionalKey(
Schema.Struct({
at: Schema.Number,
os: Os,
emulators: Schema.Array(DetectedEmulator),
}),
),
lastSync: Schema.optionalKey(LastSync),
});
export type Cache = typeof CacheSchema.Type;
export const emptyCache: Cache = { art: {} };
export class RomCache extends Context.Service<
RomCache,
CacheStore<typeof CacheSchema>
>()("rom-manager/RomCache") {
static readonly layer: Layer.Layer<RomCache, never, PluginInfo> =
Layer.effect(RomCache)(
makeCacheStore({ schema: CacheSchema, empty: emptyCache }),
);
}
+17
View File
@@ -0,0 +1,17 @@
// The plugin's config service — the kit's ConfigService over the contract schema.
// Raw round-trip by construction: the file on disk is always the authored shape.
import {
type ConfigService,
makeConfigService,
type PluginInfo,
} from "@punktfunk/plugin-kit";
import { RomConfigSchema } from "@rom-manager/contract";
import { Context, Layer } from "effect";
export class RomConfig extends Context.Service<
RomConfig,
ConfigService<typeof RomConfigSchema>
>()("rom-manager/RomConfig") {
static readonly layer: Layer.Layer<RomConfig, never, PluginInfo> =
Layer.effect(RomConfig)(makeConfigService({ schema: RomConfigSchema }));
}
+234
View File
@@ -0,0 +1,234 @@
// RomSync — the plugin's engine service: wires the pure domain (scan → detect → warm →
// computeDesired) into the kit's generic SyncEngine (poll/watch/debounce/coalesce/
// fingerprint) and the ProviderClient reconcile. Also owns the EngineStatus assembly the
// API and the SSE feed share, and the side-effect-light preview the Library page uses.
import * as nodePath from "node:path";
import {
type LastSync,
makeSyncEngine,
ProviderClient,
type SyncEngine,
} from "@punktfunk/plugin-kit";
import type { ProviderEntry } from "@punktfunk/plugin-kit/wire";
import {
type Config,
type DetectedEmulator,
type EngineStatus,
resolveConfig,
type SyncReport,
} from "@rom-manager/contract";
import { Context, Duration, Effect, Layer, type Scope, Stream } from "effect";
import {
type ArtVerdict,
selectArtProvider,
warmArt,
} from "../art/provider.js";
import {
detectAll,
realDetectEnv,
resolveEmulators,
} from "../domain/emulators.js";
import { resolvePlatforms } from "../domain/platforms.js";
import { currentOs } from "../domain/quote.js";
import { computeDesired, enumerateTitles } from "../domain/reconcile.js";
import { type RomCandidate, realScanFs, scanRoot } from "../domain/scanner.js";
import { RomCache } from "./cache.js";
import { RomConfig } from "./config.js";
export const PROVIDER_ID = "rom-manager";
export interface RomSyncService {
readonly engine: SyncEngine<SyncReport>;
/** Scan + cached detect + cached art → desired entries. NO warming, NO reconcile. */
readonly preview: Effect.Effect<
{ entries: ReadonlyArray<ProviderEntry>; report: SyncReport },
unknown
>;
/** Emulator detection (cached; `force` re-probes — the UI's Detect button). */
readonly detect: (
force: boolean,
) => Effect.Effect<ReadonlyArray<DetectedEmulator>>;
readonly status: Effect.Effect<EngineStatus>;
/** The SSE feed: a full EngineStatus on every engine transition. */
readonly statusChanges: Stream.Stream<EngineStatus>;
}
export class RomSync extends Context.Service<RomSync, RomSyncService>()(
"rom-manager/RomSync",
) {
// Layer.effect runs `make` in the LAYER's scope (Scope is excluded from R) — the
// engine's watch/poll loops live exactly as long as the plugin runtime.
static readonly layer: Layer.Layer<
RomSync,
never,
RomConfig | RomCache | ProviderClient
> = Layer.effect(RomSync)(make());
}
function make(): Effect.Effect<
RomSyncService,
never,
RomConfig | RomCache | ProviderClient | Scope.Scope
> {
return Effect.gen(function* () {
const cfg = yield* RomConfig;
const cache = yield* RomCache;
const provider = yield* ProviderClient;
const os = currentOs();
const detectEnv = realDetectEnv();
// Pure helpers collect log lines; callers flush them through Effect.log so they
// go through the runtime's journal-format logger (never console directly).
const flush = (lines: ReadonlyArray<string>) =>
Effect.forEach(lines, (l) => Effect.log(l), { discard: true });
const scanAll = (config: Config, lines: string[]): RomCandidate[] => {
const platforms = resolvePlatforms(config.platforms);
const out: RomCandidate[] = [];
for (const root of config.roots) {
const platform = platforms.get(root.platform);
if (!platform) {
lines.push(
`scan: root ${root.dir} has unknown platform "${root.platform}" — skipped`,
);
continue;
}
try {
out.push(
...scanRoot(realScanFs, root.dir, platform, [
...(root.excludes ?? []),
]),
);
} catch (e) {
lines.push(`scan: ${root.dir}: ${e}`);
}
}
return out;
};
const loadOrDefault = cfg.load.pipe(
Effect.orElseSucceed(() => resolveConfig({})),
);
const detect = (force: boolean) =>
Effect.gen(function* () {
const config = yield* loadOrDefault;
const cached = (yield* cache.get).detect;
if (!force && cached && cached.os === os) {
return cached.emulators;
}
const defs = resolveEmulators(config.emulators);
const emulators = detectAll(detectEnv, defs.values());
yield* cache
.update((c) => ({
...c,
detect: { at: Date.now(), os, emulators },
}))
.pipe(Effect.ignore);
return emulators as ReadonlyArray<DetectedEmulator>;
});
/** Warm the art cache for everything the current scan will include (best-effort). */
const warm = (config: Config, scan: RomCandidate[]) =>
Effect.gen(function* () {
const lines: string[] = [];
const artProvider = selectArtProvider(config, (l) => lines.push(l));
if (artProvider) {
const { survivors } = enumerateTitles(config, scan);
const targets = survivors.map((s) => ({
externalId: s.externalId,
platform: s.platform,
parsed: s.parsed,
}));
// warmArt mutates a working copy; the store persists it atomically.
const working: { art: Record<string, ArtVerdict> } = {
art: { ...(yield* cache.get).art },
};
const n = yield* Effect.promise(() =>
warmArt(artProvider, targets, working, {
log: (l) => lines.push(l),
}),
);
if (n > 0) {
yield* cache
.update((c) => ({ ...c, art: working.art }))
.pipe(Effect.ignore);
lines.push(
`art: resolved ${n} title(s) via ${artProvider.id.split(":")[0]}`,
);
}
}
yield* flush(lines);
});
const computeWith = (withWarm: boolean) =>
Effect.gen(function* () {
const config = yield* cfg.load;
const lines: string[] = [];
const scan = scanAll(config, lines);
yield* flush(lines);
const detected = yield* detect(false);
if (withWarm) yield* warm(config, scan);
const art = (yield* cache.get).art;
const result = computeDesired({
config,
scan,
detected: [...detected],
art,
os,
});
return { entries: result.entries, report: result.report };
});
const engine = yield* makeSyncEngine<
SyncReport,
ReadonlyArray<ProviderEntry>,
never
>({
compute: () => computeWith(true),
apply: (entries) => provider.reconcile(PROVIDER_ID, entries),
lastSync: {
get: cache.get.pipe(Effect.map((c) => c.lastSync)),
set: (last: LastSync) =>
cache.update((c) => ({ ...c, lastSync: last })).pipe(Effect.ignore),
},
settings: loadOrDefault.pipe(
Effect.map((config) => ({
pollInterval: Duration.minutes(Math.max(1, config.sync.pollMinutes)),
watch: config.sync.watch,
debounce: Duration.millis(config.sync.debounceMs),
watchDirs: config.roots.map((r) => r.dir),
})),
),
});
const status: Effect.Effect<EngineStatus> = Effect.gen(function* () {
const config = yield* loadOrDefault;
const engineStatus = yield* engine.status;
const c = yield* cache.get;
return {
rootsConfigured: config.roots.length,
os,
artProvider: selectArtProvider(config)?.id ?? null,
syncing: engineStatus.syncing,
lastSync: engineStatus.lastSync,
lastReport: engineStatus.lastReport,
detectedAt: c.detect?.at,
paths: {
dir: nodePath.dirname(cfg.path),
config: cfg.path,
cache: cache.path,
},
} satisfies EngineStatus;
});
return {
engine,
preview: computeWith(false),
detect,
status,
statusChanges: engine.changes.pipe(Stream.mapEffect(() => status)),
} satisfies RomSyncService;
});
}
@@ -3,8 +3,8 @@ import {
type DetectEnv,
detectEmulator,
emulatorById,
} from "../src/emulators.js";
import type { Os } from "../src/quote.js";
} from "../src/domain/emulators.js";
import type { Os } from "../src/domain/quote.js";
const env = (o: {
os?: Os;
@@ -5,8 +5,8 @@ import {
LibretroProvider,
sanitizeName,
} from "../src/art/libretro.js";
import { parseTitle } from "../src/engine/titles.js";
import { platformById } from "../src/platforms.js";
import { platformById } from "../src/domain/platforms.js";
import { parseTitle } from "../src/domain/titles.js";
const snes = platformById("snes")!;
const nswitch = platformById("switch")!;
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test";
import { quotePosix, quoteWindows, renderCommand } from "../src/quote.js";
import {
quotePosix,
quoteWindows,
renderCommand,
} from "../src/domain/quote.js";
describe("quotePosix", () => {
test("wraps a plain path in single quotes", () => {
@@ -1,9 +1,9 @@
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";
import { resolveConfig } from "@rom-manager/contract";
import type { ArtVerdict } from "../src/art/provider.js";
import type { DetectedEmulator } from "../src/domain/emulators.js";
import { computeDesired } from "../src/domain/reconcile.js";
import type { RomCandidate } from "../src/domain/scanner.js";
const RA: DetectedEmulator = {
id: "retroarch",
@@ -29,7 +29,7 @@ const cand = (rel: string, over: Partial<RomCandidate> = {}): RomCandidate => ({
const base = { roots: [{ dir: "/roms/snes", platform: "snes" }] };
const run = (
raw: Parameters<typeof resolveConfig>[0],
raw: Record<string, unknown>,
scan: RomCandidate[],
art: Record<string, ArtVerdict> = {},
detected = [RA],
@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test";
import { platformById } from "../src/domain/platforms.js";
import {
type DirEnt,
makeExcluder,
type ScanFs,
scanRoot,
} from "../src/engine/scanner.js";
import { platformById } from "../src/platforms.js";
} from "../src/domain/scanner.js";
/** A synthetic filesystem from a flat `{ absPath: contents }` map (dirs inferred). */
const synthFs = (files: Record<string, string>): ScanFs => {
@@ -1,7 +1,7 @@
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";
import { platformById } from "../src/domain/platforms.js";
import { parseTitle } from "../src/domain/titles.js";
const snes = platformById("snes")!;
@@ -3,7 +3,7 @@ import {
dedupeKey,
parseTitle,
pickBestRelease,
} from "../src/engine/titles.js";
} from "../src/domain/titles.js";
describe("parseTitle", () => {
test("strips No-Intro tags and keeps region + revision metadata", () => {
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"types": ["bun"]
},
"include": ["src", "test"]
}
+6
View File
@@ -0,0 +1,6 @@
// The published type surface: a plugin is a runtime artifact — the only thing anyone
// imports from the package is the default PluginDef (the runner's discovery contract).
import type { PluginDef } from "@punktfunk/host";
declare const plugin: PluginDef;
export default plugin;
-169
View File
@@ -1,169 +0,0 @@
#!/usr/bin/env bun
// Dev/debug CLI (design §8) — reuses the engine one-shot. `scan`/`detect`/`preview` are offline (no
// host needed); `sync`/`uninstall` connect to the host and reconcile. Handy for configuring a headless
// box or checking what a `config.json` will produce before pushing it to the library.
//
// bunx punktfunk-plugin-rom-manager scan # what the scanner finds
// bunx punktfunk-plugin-rom-manager detect # which emulators are installed
// bunx punktfunk-plugin-rom-manager preview # the desired library (no host write)
// bunx punktfunk-plugin-rom-manager sync # reconcile into the live library
// bunx punktfunk-plugin-rom-manager uninstall # remove all this plugin's entries
// bunx punktfunk-plugin-rom-manager set-password <pw> # standalone-UI password
import { connect } from "@punktfunk/host";
import type { Config } from "./config.js";
import { detectAll, realDetectEnv, resolveEmulators } from "./emulators.js";
import { Engine } from "./engine/index.js";
import { computeDesired, enumerateTitles } from "./engine/reconcile.js";
import { type RomCandidate, realScanFs, scanRoot } from "./engine/scanner.js";
import { resolvePlatforms } from "./platforms.js";
import { currentOs } from "./quote.js";
import { hashPassword } from "./server/password.js";
import { loadCache, loadConfig, saveConfig } from "./state.js";
const scanAll = (config: Config): RomCandidate[] => {
const platforms = resolvePlatforms(config.platforms);
const out: RomCandidate[] = [];
for (const root of config.roots) {
const platform = platforms.get(root.platform);
if (!platform) {
console.error(`! root ${root.dir}: unknown platform "${root.platform}"`);
continue;
}
out.push(...scanRoot(realScanFs, root.dir, platform, root.excludes));
}
return out;
};
const cmdScan = (): void => {
const config = loadConfig();
if (config.roots.length === 0) {
console.log(
"No ROM roots configured. Add some to config.json or via the UI.",
);
return;
}
const scan = scanAll(config);
const { survivors, skipped } = enumerateTitles(config, scan);
const perPlatform = new Map<string, number>();
for (const s of survivors)
perPlatform.set(s.platform.id, (perPlatform.get(s.platform.id) ?? 0) + 1);
console.log(
`Scanned ${config.roots.length} root(s): ${scan.length} candidate file(s), ${survivors.length} title(s).`,
);
for (const [id, n] of [...perPlatform].sort()) console.log(` ${id}: ${n}`);
if (skipped.length)
console.log(` (${skipped.length} skipped by exclude/dedupe)`);
};
const cmdDetect = (): void => {
const config = loadConfig();
const detected = detectAll(
realDetectEnv(),
resolveEmulators(config.emulators).values(),
);
if (detected.length === 0) {
console.log("No emulators detected.");
return;
}
console.log(`Detected ${detected.length} emulator(s):`);
for (const d of detected) {
const cores = d.cores?.length ? `${d.cores.length} core(s)` : "";
console.log(
` ${d.id} (${d.via})${d.contested ? " [contested]" : ""}${cores}`,
);
}
};
const cmdPreview = (): void => {
const config = loadConfig();
const scan = scanAll(config);
const detected = detectAll(
realDetectEnv(),
resolveEmulators(config.emulators).values(),
);
const cache = loadCache();
const { entries, report } = computeDesired({
config,
scan,
detected,
art: cache.art,
os: currentOs(),
});
console.log(
`Preview: ${entries.length} entr(y/ies) would be reconciled${report.overWarn ? " [scale warning]" : ""}.`,
);
for (const e of entries.slice(0, 40))
console.log(` ${e.external_id}${e.launch?.value ?? "(no launch)"}`);
if (entries.length > 40) console.log(` … and ${entries.length - 40} more`);
if (report.skipped.length) {
console.log(`\n${report.skipped.length} skipped:`);
for (const s of report.skipped.slice(0, 20))
console.log(` ${s.title}: ${s.reason}`);
}
for (const w of report.warnings.slice(0, 10)) console.log(`! ${w}`);
};
const cmdSync = async (): Promise<void> => {
const pf = await connect();
try {
const engine = new Engine({ pf });
const report = await engine.sync("cli");
if (report) {
console.log(
`Synced: ${report.included} entr(y/ies), ${report.skipped.length} skipped.`,
);
} else {
console.log("Sync did not complete (see log).");
}
} finally {
pf.close();
}
};
const cmdUninstall = async (): Promise<void> => {
const pf = await connect();
try {
await new Engine({ pf }).uninstall();
console.log("Removed all rom-manager entries from the library.");
} finally {
pf.close();
}
};
const cmdSetPassword = (password: string | undefined): void => {
if (!password) {
console.error("usage: set-password <password>");
process.exitCode = 2;
return;
}
const config = loadConfig();
config.ui.passwordHash = hashPassword(password);
saveConfig(config);
console.log("Standalone-UI password set (config.ui.passwordHash).");
};
const main = async (): Promise<void> => {
const [cmd, arg] = process.argv.slice(2);
switch (cmd) {
case "scan":
return cmdScan();
case "detect":
return cmdDetect();
case "preview":
return cmdPreview();
case "sync":
return cmdSync();
case "uninstall":
return cmdUninstall();
case "set-password":
return cmdSetPassword(arg);
default:
console.log(
"usage: punktfunk-plugin-rom-manager <scan|detect|preview|sync|uninstall|set-password>",
);
process.exitCode = cmd ? 2 : 0;
}
};
await main();
-151
View File
@@ -1,151 +0,0 @@
// The plugin's configuration model (design §9) — the single source of truth for the sync engine,
// editable by hand (headless boxes) or by the web UI. Everything the engine needs is derivable from
// this file plus a directory scan; the `resolveConfig` helper fills defaults so the rest of the code
// works against a fully-populated shape.
import type { EmulatorDef } from "./emulators.js";
import type { Platform } from "./platforms.js";
/** A ROM root: a directory paired with the platform its files belong to (design §5). */
export interface RomRoot {
/** Absolute directory to scan. */
dir: string;
/** Platform id (a key into the resolved platform registry). */
platform: string;
/** Per-root glob ignore patterns (`*.sav`, `bios/**`). */
excludes?: string[];
}
/** A per-platform launch override — replaces the platform's built-in default emulator/core. */
export interface PlatformLaunch {
emulator: string;
core?: string;
extraArgs?: string;
}
/** A per-game override, keyed by `external_id` (`snes/Chrono Trigger (USA).sfc`). */
export interface GameOverride {
/** Skip this ROM entirely. */
exclude?: boolean;
/** Override the resolved emulator/core/args for just this title. */
emulator?: string;
core?: string;
extraArgs?: string;
/** Override the displayed title. */
title?: string;
/** Override the box-art portrait URL. */
art?: string;
}
export interface SyncOptions {
/** Interval poll in minutes (watchers are unreliable on SMB/NFS — poll is the primary trigger). */
pollMinutes: number;
/** Enable fs watching (an accelerator on top of the poll). */
watch: boolean;
/** Debounce window for coalescing watch/poll-triggered rescans (ms). */
debounceMs: number;
/** Platform ids to region-dedupe ("one entry per title", design §5) — default none. */
dedupeRegions: string[];
/** Region preference order for dedupe (best first). */
regionPriority: string[];
/** Warn in the UI above this entry count (design §8 scale guard). */
warnEntries: number;
/** Hard cap — truncate with a loud report above this many entries. */
maxEntries: number;
/** Attach `prep`/`undo` that kills the emulator at session end ("close emulator when stream ends"). */
closeOnEnd: boolean;
}
/** Which art source to use (design §7, revised): SteamGridDB (like Steam ROM Manager) or the keyless
* libretro-thumbnails fallback. `auto` = SteamGridDB when a key is set, else libretro. */
export type ArtProviderChoice = "auto" | "steamgriddb" | "libretro";
export interface ArtOptions {
/** Fetch box art at all. */
enabled: boolean;
/** Art source selection (default `auto`). */
provider: ArtProviderChoice;
/**
* SteamGridDB API key (free, from steamgriddb.com profile preferences). Unlocks full portrait +
* hero + logo + header art with fuzzy title matching. Absent → the keyless libretro fallback.
*/
steamGridDbKey?: string;
}
export interface UiOptions {
/**
* Fallback standalone mode (design §9): serve the UI on a fixed, possibly non-loopback port behind
* a password instead of via the console proxy. For host-only installs without the console.
*/
standalone: boolean;
/** Standalone bind port. */
port: number;
/** Standalone bind address (defaults to loopback; a non-loopback bind REQUIRES a password). */
bind: string;
/** scrypt password hash (`scrypt$<saltB64>$<hashB64>`), required for a non-loopback standalone bind. */
passwordHash?: string;
}
/** The on-disk config as authored (all optional — `resolveConfig` fills the rest). */
export interface RawConfig {
roots?: RomRoot[];
/** Per-platform launch overrides, keyed by platform id. */
platformLaunch?: Record<string, PlatformLaunch>;
/** Per-game overrides, keyed by `external_id`. */
gameOverrides?: Record<string, GameOverride>;
/** Operator-added / overriding platform defs (merged over the built-ins by id). */
platforms?: Platform[];
/** Operator-added / overriding emulator defs (merged over the built-ins by id). */
emulators?: EmulatorDef[];
sync?: Partial<SyncOptions>;
art?: Partial<ArtOptions>;
ui?: Partial<UiOptions>;
/** Dev/M0 acceptance flag: reconcile one hardcoded entry so the round-trip is observable. */
devEntry?: boolean;
}
/** The fully-resolved config the engine consumes. */
export interface Config {
roots: RomRoot[];
platformLaunch: Record<string, PlatformLaunch>;
gameOverrides: Record<string, GameOverride>;
platforms?: Platform[];
emulators?: EmulatorDef[];
sync: SyncOptions;
art: ArtOptions;
ui: UiOptions;
devEntry: boolean;
}
export const DEFAULT_SYNC: SyncOptions = {
pollMinutes: 15,
watch: true,
debounceMs: 2000,
dedupeRegions: [],
regionPriority: ["USA", "World", "Europe", "Japan"],
warnEntries: 2000,
maxEntries: 5000,
closeOnEnd: false,
};
export const DEFAULT_UI: UiOptions = {
standalone: false,
port: 47993,
bind: "127.0.0.1",
};
/** Fill defaults over an authored config so the rest of the engine sees a total shape. */
export const resolveConfig = (raw: RawConfig): Config => ({
roots: raw.roots ?? [],
platformLaunch: raw.platformLaunch ?? {},
gameOverrides: raw.gameOverrides ?? {},
platforms: raw.platforms,
emulators: raw.emulators,
sync: { ...DEFAULT_SYNC, ...raw.sync },
art: { enabled: true, provider: "auto", ...raw.art },
ui: { ...DEFAULT_UI, ...raw.ui },
devEntry: raw.devEntry ?? false,
});
/** The empty starting config for a fresh install (no roots yet). */
export const emptyConfig = (): Config => resolveConfig({});
-7
View File
@@ -1,7 +0,0 @@
// 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";
-363
View File
@@ -1,363 +0,0 @@
// 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,
};
}
}
-20
View File
@@ -1,20 +0,0 @@
// The host side of the reconcile (design §2). We PUT the declarative entry list to the provider
// endpoint and let the host diff by `external_id` (stable ids, orphan drop, manual entries untouched).
// Done through `pf.request` rather than the typed `pf.api.*` deliberately: under the packaged runner
// the facade is built by the runner's *bundled* SDK copy, whose generated client may predate an
// endpoint — the untyped request seam is version-skew-proof (same reasoning as `servePluginUi`).
import type { Punktfunk } from "@punktfunk/host";
import { PROVIDER_ID } from "./const.js";
import type { ProviderEntryInput } from "./wire.js";
/** Full-replace reconcile: the host makes the library match `entries` exactly for this provider. */
export const reconcileProvider = (
pf: Punktfunk,
entries: ProviderEntryInput[],
): Promise<unknown> =>
pf.request("PUT", `/library/provider/${PROVIDER_ID}`, entries);
/** Clean uninstall: remove every entry this provider owns. */
export const deleteProvider = (pf: Punktfunk): Promise<unknown> =>
pf.request("DELETE", `/library/provider/${PROVIDER_ID}`);
-65
View File
@@ -1,65 +0,0 @@
// The plugin entry (design §11). `main` is the plain-async form (NOT the Effect form): the packaged
// runner bundles its own effect/SDK copy and cross-instance Effect identity is unverified (design D3),
// so the async facade sidesteps it entirely. The runner supervises this with restart-on-crash and a
// structured SIGTERM shutdown; a direct `bun src/index.ts` run works too (bottom of file).
//
// Lifecycle: connect (done by the runner/facade) → start the sync engine → serve the UI (console-hosted
// by default, standalone if configured) → run until SIGTERM → deregister the UI and stop the engine.
// Provider entries are deliberately LEFT in the library on shutdown so the games survive plugin
// restarts and host reboots; a clean uninstall is the explicit `uninstall` CLI command.
import { connect, definePlugin, type Punktfunk } from "@punktfunk/host";
import { PLUGIN_NAME } from "./const.js";
import { Engine } from "./engine/index.js";
import { log } from "./log.js";
import { serveConsoleUi } from "./server/index.js";
import { serveStandaloneUi } from "./server/standalone.js";
import { loadConfig } from "./state.js";
import { readVersion } from "./version.js";
const plugin = definePlugin({
name: PLUGIN_NAME,
main: async (pf: Punktfunk) => {
const engine = new Engine({ pf });
const config = loadConfig();
// Headless engine first — the library syncs even if the UI can't start.
await engine.start();
let closeUi: () => Promise<void> = async () => {};
try {
if (config.ui.standalone) {
const handle = serveStandaloneUi(engine, config);
closeUi = handle.close;
} else {
const handle = await serveConsoleUi(pf, engine, {
version: readVersion(),
});
closeUi = handle.close;
log(`UI registered with the console (nav entry "${PLUGIN_NAME}")`);
}
} catch (e) {
log(`UI server failed to start (engine keeps running headless): ${e}`);
}
// Run until the runner (or a direct SIGINT) asks us to stop. The runner sends SIGTERM on
// shutdown; both its handler and ours fire, and this resolves.
await new Promise<void>((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
log("shutting down");
await closeUi();
engine.stop();
},
});
export default plugin;
// Allow a direct `bun src/index.ts` run outside the managed runner (dev).
if (import.meta.main) {
const pf = await connect();
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
pf.close();
}
-10
View File
@@ -1,10 +0,0 @@
// 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();
-55
View File
@@ -1,55 +0,0 @@
// 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 fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
/** 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");
/**
* Locate the built SPA directory (`dist/ui`) at runtime. The package ships as ONE self-contained bundle
* (`dist/index.js`) so it installs from the Gitea registry with no external deps — which means
* `import.meta.url` can be `dist/index.js` (bundled) or `dist/server/index.js` (unbundled dev). We walk
* up from the calling module looking for a `ui/index.html`, so both layouts resolve. `fromUrl` is the
* caller's `import.meta.url`.
*/
export const resolveUiDir = (fromUrl: string): string => {
let dir = path.dirname(fileURLToPath(fromUrl));
for (let i = 0; i < 6; i++) {
for (const cand of [path.join(dir, "ui"), path.join(dir, "dist", "ui")]) {
try {
if (fs.existsSync(path.join(cand, "index.html"))) return cand;
} catch {
// keep walking
}
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
// Fallback to the bundled layout (dist/index.js → dist/ui) even if unbuilt.
return path.join(path.dirname(fileURLToPath(fromUrl)), "ui");
};
-30
View File
@@ -1,30 +0,0 @@
// 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 { resolveUiDir } from "../paths.js";
import { makeRouter } from "./router.js";
/** Serve the UI through the console. The SPA dir resolves whether we run bundled or from source. */
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: resolveUiDir(import.meta.url),
fetch: makeRouter(engine),
});
-30
View File
@@ -1,30 +0,0 @@
// scrypt password hashing for the standalone fallback UI (design §9/§10.2). Format:
// `scrypt$<saltB64url>$<hashB64url>`. Verification is constant-time. Only used by the standalone
// server; the console-hosted path has no password of its own.
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const KEYLEN = 32;
/** Hash a password for storage in `config.ui.passwordHash`. */
export const hashPassword = (password: string): string => {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, KEYLEN);
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
};
/** Constant-time verify a password against a stored `scrypt$...` hash. */
export const verifyPassword = (password: string, stored: string): boolean => {
const parts = stored.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt" || !parts[1] || !parts[2])
return false;
const salt = Buffer.from(parts[1], "base64url");
const expected = Buffer.from(parts[2], "base64url");
let actual: Buffer;
try {
actual = scryptSync(password, salt, expected.length);
} catch {
return false;
}
return actual.length === expected.length && timingSafeEqual(actual, expected);
};
-108
View File
@@ -1,108 +0,0 @@
// The plugin-local REST/SSE API (design §9) — a pure `(Request) => Response | undefined` the UI talks
// to. Paths arrive prefix-stripped (the console proxy already removed `/plugin-ui/rom-manager`), so we
// match `/api/...` directly; a non-`/api` path returns `undefined` to fall through to the static SPA.
// The same router backs both the console-proxied server and the standalone fallback.
import { type RawConfig, resolveConfig } from "../config.js";
import { resolveEmulators } from "../emulators.js";
import type { Engine, EngineStatus } from "../engine/index.js";
import { resolvePlatforms } from "../platforms.js";
import { loadConfig, saveConfig } from "../state.js";
const json = (data: unknown, status = 200): Response =>
Response.json(data, { status });
/** A Server-Sent-Events stream that pushes the engine status on every change (design §9 progress feed). */
const sse = (engine: Engine): Response => {
const enc = new TextEncoder();
let unsubscribe = () => {};
let ping: ReturnType<typeof setInterval> | undefined;
const stream = new ReadableStream({
start(controller) {
const send = (status: EngineStatus) => {
try {
controller.enqueue(
enc.encode(`event: status\ndata: ${JSON.stringify(status)}\n\n`),
);
} catch {
// stream already closed
}
};
send(engine.status());
unsubscribe = engine.subscribe(send);
// Keep the connection warm through any buffering proxy.
ping = setInterval(() => {
try {
controller.enqueue(enc.encode(": ping\n\n"));
} catch {
// ignore
}
}, 15_000);
(ping as { unref?: () => void }).unref?.();
},
cancel() {
unsubscribe();
if (ping) clearInterval(ping);
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
},
});
};
/** Build the plugin-local API router bound to an engine. */
export const makeRouter =
(engine: Engine) =>
async (req: Request): Promise<Response | undefined> => {
const { pathname } = new URL(req.url);
const method = req.method;
if (!pathname.startsWith("/api/")) return undefined; // static SPA handles it
try {
if (pathname === "/api/status" && method === "GET") {
return json(engine.status());
}
if (pathname === "/api/config" && method === "GET") {
return json(loadConfig());
}
if (pathname === "/api/config" && method === "PUT") {
const body = (await req.json()) as RawConfig;
const resolved = resolveConfig(body);
saveConfig(resolved);
await engine.reconfigure();
return json(resolved);
}
if (pathname === "/api/preview" && method === "GET") {
return json(await engine.computeDry(false));
}
if (pathname === "/api/detect" && method === "POST") {
return json(engine.detect(loadConfig(), true));
}
if (pathname === "/api/sync" && method === "POST") {
const report = await engine.sync("ui");
return report
? json(report)
: json({ error: "a sync is already running" }, 409);
}
if (pathname === "/api/platforms" && method === "GET") {
return json([...resolvePlatforms(loadConfig().platforms).values()]);
}
if (pathname === "/api/emulators" && method === "GET") {
const config = loadConfig();
return json({
defs: [...resolveEmulators(config.emulators).values()],
detected: engine.detect(config, false),
});
}
if (pathname === "/api/events" && method === "GET") {
return sse(engine);
}
return json({ error: "not found" }, 404);
} catch (e) {
return json({ error: String(e) }, 500);
}
};
-151
View File
@@ -1,151 +0,0 @@
// 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 type { Config } from "../config.js";
import type { Engine } from "../engine/index.js";
import { log } from "../log.js";
import { resolveUiDir } from "../paths.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 = resolveUiDir(import.meta.url);
const router = makeRouter(engine);
const authed = (req: Request) =>
!authRequired || cookieHas(req, COOKIE, sessionToken);
const server = Bun.serve({
hostname: bind,
port,
async fetch(req) {
const { pathname } = new URL(req.url);
if (pathname === "/__health") return Response.json({ ok: true });
if (authRequired && pathname === "/login" && req.method === "POST") {
const form = await req.formData().catch(() => null);
const password = form?.get("password");
if (
typeof password === "string" &&
passwordHash &&
verifyPassword(password, passwordHash)
) {
return new Response(null, {
status: 303,
headers: {
location: "./",
"set-cookie": `${COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
},
});
}
return loginPage("Incorrect password");
}
if (pathname === "/logout" && req.method === "POST") {
return new Response(null, {
status: 303,
headers: {
location: "./",
"set-cookie": `${COOKIE}=; Max-Age=0; Path=/`,
},
});
}
if (!authed(req)) {
if (pathname.startsWith("/api/"))
return Response.json({ error: "unauthorized" }, { status: 401 });
return loginPage();
}
// 1) plugin-local API
const api = await router(req);
if (api) return api;
// 2) static asset
const file = staticFile(root, pathname);
if (file) {
const bf = Bun.file(file);
if (await bf.exists()) return new Response(bf);
}
// 3) SPA fallback
if (
req.method === "GET" &&
(req.headers.get("accept") ?? "").includes("text/html")
) {
const index = Bun.file(nodePath.join(root, "index.html"));
if (await index.exists()) return new Response(index);
}
return new Response("not found", { status: 404 });
},
});
const url = `http://${isLoopback ? "127.0.0.1" : bind}:${server.port}`;
log(
`standalone UI on ${url}${authRequired ? " (password-protected)" : " (loopback, no password)"}`,
);
return {
url,
async close() {
server.stop(true);
},
};
};
-107
View File
@@ -1,107 +0,0 @@
// Config/cache persistence (design §9): the plugin owns two files under `<config_dir>/rom-manager/`,
// created 0700. `config.json` is operator-editable and atomically rewritten (temp + rename); the
// plugin refuses a group/world-writable `config.json` (design §10.3 - the same sshd rule the runner
// and hooks enforce, since the UI can rewrite launch templates => config = host-user code). `cache.json`
// is disposable derived state.
import * as fs from "node:fs";
import * as path from "node:path";
import { type Config, type RawConfig, resolveConfig } from "./config.js";
import type { DetectedEmulator } from "./emulators.js";
import { cachePath, configPath, pluginDir } from "./paths.js";
import type { Artwork } from "./wire.js";
/** One cached art verdict: the resolved artwork (or `null` = nothing found), tagged with the provider
* and its matcher version so a provider switch or an algorithm bump re-resolves (design §7). */
export interface ArtVerdict {
art: Artwork | null;
/** The provider that produced this verdict (`steamgriddb` | `libretro`). */
provider: string;
/** The provider's matcher version at resolve time. */
matcher: number;
}
export interface Cache {
/** Art verdicts keyed by `external_id` (provider-agnostic, stable across rescans). */
art: Record<string, ArtVerdict>;
/** Last emulator detection result (re-probed on demand). */
detect?: { at: number; os: string; emulators: DetectedEmulator[] };
/** Fingerprint + count of the last reconcile - lets a rescan skip an unchanged PUT (design §8). */
lastSync?: { fingerprint: string; count: number; at: number };
}
export const emptyCache = (): Cache => ({ art: {} });
/** Create the plugin dir 0700 if absent (idempotent). */
const ensureDir = (): void => {
fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 });
};
/** Refuse a group/world-writable config file (POSIX only; Windows config dir is DACL'd). */
const assertNotWorldWritable = (file: string): void => {
if (process.platform === "win32") return;
let mode: number;
try {
mode = fs.statSync(file).mode;
} catch {
return; // absent - nothing to guard
}
if ((mode & 0o022) !== 0) {
throw new Error(
`refusing ${file}: it is group/world-writable (chmod go-w it first) - this file controls commands run as the host user`,
);
}
};
/** Atomic write: temp file in the same dir, then rename. */
const atomicWrite = (file: string, data: string): void => {
ensureDir();
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, data, { mode: 0o600 });
fs.renameSync(tmp, file);
};
/** Load the authored config (defaults filled). A missing file yields the empty config. */
export const loadConfig = (): Config => {
const file = configPath();
let raw = "";
try {
raw = fs.readFileSync(file, "utf8");
} catch {
return resolveConfig({});
}
assertNotWorldWritable(file);
const parsed = JSON.parse(raw) as RawConfig;
return resolveConfig(parsed);
};
/** Persist a config (atomic). The engine round-trips the *resolved* shape; defaults are re-elided on load. */
export const saveConfig = (config: Config): void => {
atomicWrite(configPath(), `${JSON.stringify(config, null, 2)}\n`);
};
/** Save the raw (operator-authored) config verbatim - the shape the UI edits. */
export const saveRawConfig = (raw: RawConfig): void => {
atomicWrite(configPath(), `${JSON.stringify(raw, null, 2)}\n`);
};
export const loadCache = (): Cache => {
try {
const parsed = JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache;
return { ...parsed, art: parsed.art ?? {} };
} catch {
return emptyCache();
}
};
export const saveCache = (cache: Cache): void => {
atomicWrite(cachePath(), JSON.stringify(cache));
};
/** Absolute paths, exported for the UI/CLI status view. */
export const statePaths = () => ({
dir: pluginDir(),
config: configPath(),
cache: cachePath(),
relConfig: path.basename(configPath()),
});
-16
View File
@@ -1,16 +0,0 @@
// Best-effort read of the plugin's own version from package.json (shown in the console page header).
// `../package.json` resolves to the repo root whether this module runs from `src/` or `dist/`.
import * as fs from "node:fs";
import { fileURLToPath } from "node:url";
export const readVersion = (): string | undefined => {
try {
const file = fileURLToPath(new URL("../package.json", import.meta.url));
const pkg = JSON.parse(fs.readFileSync(file, "utf8")) as {
version?: string;
};
return pkg.version;
} catch {
return undefined;
}
};
-33
View File
@@ -1,33 +0,0 @@
// 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[];
}
-12
View File
@@ -1,12 +0,0 @@
{
"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"]
}
+12
View File
@@ -0,0 +1,12 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.tsx"],
addons: [],
framework: {
name: "@storybook/react-vite",
options: {},
},
};
export default config;
+65
View File
@@ -0,0 +1,65 @@
// Storybook renders the REAL pages on the mock transport: the static import below
// registers the fixture HttpClient layer + status ticker (Storybook bundles are never
// shipped, so fixtures in them are fine), and the decorator gives every story a fresh
// atom registry seeded with mock mode + the story's scenario.
import "../src/styles.css";
import "../src/mocks/http";
import { RegistryProvider } from "@effect/atom-react";
import { definePreview } from "@storybook/react-vite";
import { useEffect } from "react";
import {
apiModeAtom,
type Scenario,
scenarioAtom,
} from "../src/mocks/scenario";
export default definePreview({
addons: [],
// The console pins dark; default the canvas to dark with a toolbar light switch.
initialGlobals: { theme: "dark" },
globalTypes: {
theme: {
description: "Light/dark color scheme",
toolbar: {
title: "Theme",
icon: "circlehollow",
items: [
{ value: "dark", icon: "moon", title: "Dark" },
{ value: "light", icon: "sun", title: "Light" },
],
dynamicTitle: true,
},
},
},
decorators: [
(Story, context) => {
const dark = (context.globals.theme as string) !== "light";
const scenario =
(context.parameters.scenario as Scenario | undefined) ?? "healthy";
const fullscreen = context.parameters.layout === "fullscreen";
// Mirror `.dark` onto <html> so portal-mounted content (selects, dialogs,
// toasts) picks up the palette — the theme keys everything off `html.dark`.
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
return (
<RegistryProvider
key={`${scenario}-${dark}`}
initialValues={[
[apiModeAtom, "mock"],
[scenarioAtom, scenario],
]}
>
<div
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
>
<Story />
</div>
</RegistryProvider>
);
},
],
parameters: {
layout: "padded",
},
});
-257
View File
@@ -1,257 +0,0 @@
{
"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=="],
}
}
+3 -2
View File
@@ -1,9 +1,10 @@
<!doctype html>
<html lang="en">
<!-- The console pins dark; the standalone dev tab matches it. Removing `dark` yields light. -->
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ROM Manager</title>
<title>ROM Manager — Punktfunk</title>
</head>
<body>
<div id="root"></div>
+29 -8
View File
@@ -2,21 +2,42 @@
"name": "punktfunk-plugin-rom-manager-ui",
"private": true,
"type": "module",
"description": "The ROM Manager plugin SPA — built into ../dist/ui and served by servePluginUi.",
"description": "The ROM Manager plugin SPA — the blueprint UI every Punktfunk plugin copies: @unom/ui + @unom/app-ui console polish, an Effect-native data layer derived from the shared HttpApi contract, and a zero-host mock dev loop.",
"scripts": {
"dev": "vite",
"dev:live": "VITE_API_MODE=live vite",
"build": "vite build",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"storybook": "storybook dev -p 6013",
"build-storybook": "storybook build",
"screenshots": "node tools/screenshots.mjs"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
"@effect/atom-react": "4.0.0-beta.99",
"@fontsource-variable/geist": "^5.2.9",
"@punktfunk/plugin-kit": "^0.1.4",
"@unom/app-ui": "^0.2.0",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.9.1",
"effect": "4.0.0-beta.99",
"lucide-react": "^0.469.0",
"motion": "^12.42.2",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"@rom-manager/contract": "workspace:*",
"@storybook/react-vite": "^10.4.6",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^22.20.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"playwright": "^1.61.1",
"storybook": "^10.4.6",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.0.0"
"vite": "^7.3.6"
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { App } from "./App";
const meta = {
title: "Shell/App",
component: App,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof App>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
+55 -61
View File
@@ -1,70 +1,64 @@
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";
// The app frame: kit router (flat routes, console deep-link bridge) inside the shared
// PluginShell chrome. Pages own their data; the shell owns navigation + the toaster.
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;
import { createPluginRouter, useIsEmbedded } from "@punktfunk/plugin-kit/react";
import {
PluginShell,
type PluginShellNavItem,
} from "@unom/app-ui/plugin-shell";
import {
FolderOpen,
Gamepad2,
LayoutDashboard,
Library,
Settings2,
} from "lucide-react";
import type { FC } from "react";
import { EmulatorsPage } from "@/pages/emulators";
import { LibraryPage } from "@/pages/library";
import { OverviewPage } from "@/pages/overview";
import { SettingsPage } from "@/pages/settings";
import { SourcesPage } from "@/pages/sources";
import { type PageProps, ROUTES, type Route } from "@/routes";
type TabId = (typeof TABS)[number]["id"];
const { usePluginRoute } = createPluginRouter(ROUTES, "overview");
const tabFromHash = (): TabId => {
const h = window.location.hash.replace(/^#\/?/, "");
return (TABS.find((t) => t.id === h)?.id ?? "setup") as TabId;
const NAV: ReadonlyArray<PluginShellNavItem> = [
{ id: "overview", label: "Overview", icon: LayoutDashboard },
{ id: "library", label: "Library", icon: Library },
{ id: "sources", label: "Sources", icon: FolderOpen },
{ id: "emulators", label: "Emulators", icon: Gamepad2 },
{ id: "settings", label: "Settings", icon: Settings2 },
];
const PAGES: Record<Route, FC<PageProps>> = {
overview: OverviewPage,
library: LibraryPage,
sources: SourcesPage,
emulators: EmulatorsPage,
settings: SettingsPage,
};
/** Only shown in a standalone tab — embedded in the console, the console is the chrome. */
const StandaloneHeader = () => (
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Gamepad2 className="size-4 text-primary" />
ROM Manager
</div>
);
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;
const { route, navigate } = usePluginRoute();
const embedded = useIsEmbedded();
const Page = PAGES[route];
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>
<PluginShell
nav={NAV}
active={route}
onNavigate={(id) => navigate(id as Route)}
standaloneHeader={embedded ? undefined : <StandaloneHeader />}
>
<Page navigate={navigate} />
</PluginShell>
);
};
-162
View File
@@ -1,162 +0,0 @@
// 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;
};
-61
View File
@@ -1,61 +0,0 @@
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>
);
};
+45
View File
@@ -0,0 +1,45 @@
// The one loading/error convention for every subscription on every page: the kit's
// ResultGate wired to this plugin's skeleton + error visuals. Pages pass their own
// colocated PageSkeleton; failures render a retryable error EmptyState.
import { ResultGate } from "@punktfunk/plugin-kit/react";
import { Button } from "@unom/ui/button";
import { EmptyState } from "@unom/ui/empty-state";
import type { AsyncResult } from "effect/unstable/reactivity";
import { TriangleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { errorText } from "@/lib/errors";
export type GateProps<A, E> = {
readonly result: AsyncResult.AsyncResult<A, E>;
/** The page's colocated skeleton, shown while the first value loads. */
readonly skeleton: ReactNode;
/** Usually `useAtomRefresh(theAtom)`. */
readonly retry?: () => void;
readonly children: (value: A) => ReactNode;
};
export const Gate = <A, E>(props: GateProps<A, E>): ReactNode => (
<ResultGate
result={props.result}
waiting={props.skeleton}
retry={props.retry}
failure={(error, retry) => (
<EmptyState
variant="error"
icon={TriangleAlert}
title="Something went wrong"
description={errorText(error)}
action={
retry === undefined ? undefined : (
<Button variant="outline" onClick={retry}>
Try again
</Button>
)
}
/>
)}
>
{props.children}
</ResultGate>
);
+47
View File
@@ -0,0 +1,47 @@
// The sticky save bar every draft-editing page shares: slides up when the draft goes
// dirty, pins to the viewport bottom, offers Save + Discard.
import { AnimatedButton, Button } from "@unom/ui/button";
import { Spinner } from "@unom/ui/spinner";
import { AnimatePresence, motion } from "motion/react";
export type SaveBarProps = {
readonly dirty: boolean;
readonly saving: boolean;
readonly onSave: () => void;
readonly onDiscard: () => void;
};
export const SaveBar = ({ dirty, saving, onSave, onDiscard }: SaveBarProps) => (
<AnimatePresence>
{dirty ? (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={{ duration: 0.3, ease: [0.165, 0.84, 0.44, 1] }}
className="sticky bottom-4 z-20 mt-6"
>
<div className="flex items-center justify-between gap-4 rounded-card border border-border bg-popover/90 px-5 py-3 shadow-lg backdrop-blur">
<span className="text-sm text-muted-foreground">Unsaved changes</span>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onDiscard} disabled={saving}>
Discard
</Button>
{/* variants={{}} neutralizes the button's Section-stagger entry variants
inside this object-form motion wrapper the `enter` label never
propagates, which would leave the button stuck at opacity 0. */}
<AnimatedButton
onClick={onSave}
disabled={saving}
variants={{}}
whileTap={{ scale: 0.97 }}
>
{saving ? <Spinner className="size-4" /> : null}
Save changes
</AnimatedButton>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
+155
View File
@@ -0,0 +1,155 @@
// Every atom the pages subscribe to. Queries invalidate via reactivity keys; the
// mutation constants below document which keys each mutation must pass at call time
// (AtomHttpApi invalidation is per-call: `set({ ..., reactivityKeys: [...KEYS] })`).
import { sseAtom } from "@punktfunk/plugin-kit/react";
import { EngineStatus, EVENTS_EVENT, EVENTS_PATH } from "@rom-manager/contract";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { installedMockStatusStream } from "@/mocks/register";
import { apiModeAtom } from "@/mocks/scenario";
import { Api, prefix } from "./client";
// ── Queries ────────────────────────────────────────────────────────────────────
export const statusAtom = Api.query("status", "get", {
reactivityKeys: ["status"],
});
export const configAtom = Api.query("config", "get", {
reactivityKeys: ["config"],
});
export const previewAtom = Api.query("library", "preview", {
reactivityKeys: ["preview"],
});
export const platformsAtom = Api.query("catalog", "platforms", {
reactivityKeys: ["catalog"],
timeToLive: "5 minutes",
});
export const emulatorsAtom = Api.query("catalog", "emulators", {
reactivityKeys: ["emulators"],
});
// ── Mutations + the keys they invalidate ───────────────────────────────────────
export const saveConfig = Api.mutation("config", "put");
/** A config save changes the desired state everywhere derived from it. */
export const SAVE_CONFIG_KEYS = ["config", "preview", "status"] as const;
export const runSync = Api.mutation("sync", "run");
export const RUN_SYNC_KEYS = ["status", "preview"] as const;
export const runDetect = Api.mutation("catalog", "detect");
export const RUN_DETECT_KEYS = ["emulators", "status"] as const;
// ── Live status (SSE with polling fallback) ────────────────────────────────────
/** The real SSE feed. Only mounted in live mode (the mock branch never `get`s it). */
const liveStatusStream = sseAtom({
url: `${prefix}${EVENTS_PATH}`,
event: EVENTS_EVENT,
schema: EngineStatus,
});
/**
* The status frame stream, mode-independent: SSE in live mode, the fixture ticker in
* mock mode. `undefined` until the first frame arrives.
*/
export const statusStreamAtom: Atom.Atom<EngineStatus | undefined> = Atom.make(
(get) => {
if (get(apiModeAtom) === "mock") {
const mock = installedMockStatusStream();
return mock === undefined ? undefined : get(mock);
}
return get(liveStatusStream);
},
);
/**
* What pages actually render: the freshest engine status the latest SSE frame once
* one has arrived, the plain GET /api/status result before that (and as error/skeleton
* source, since the SSE stream never fails).
*/
export const liveStatusAtom: Atom.Atom<Atom.Type<typeof statusAtom>> =
Atom.make((get) => {
const frame = get(statusStreamAtom);
if (frame !== undefined) {
return AsyncResult.success<EngineStatus, Atom.Failure<typeof statusAtom>>(
frame,
);
}
return get(statusAtom);
});
// ── Status transitions → activity feed ─────────────────────────────────────────
export type StatusEvent = {
readonly id: string;
readonly at: number;
readonly text: string;
readonly level: "info" | "warn" | "error";
};
const EVENT_LIMIT = 50;
/**
* Folds the status frame stream into a bounded activity list for the Overview feed
* client-side only, no history endpoint: connect, sync start/finish (with the report's
* count), report warnings, detection runs.
*/
export const statusEventsAtom: Atom.Atom<ReadonlyArray<StatusEvent>> =
Atom.make((get) => {
let prev: EngineStatus | undefined;
let items: ReadonlyArray<StatusEvent> = [];
let seq = 0;
get.subscribe(
statusStreamAtom,
(frame) => {
if (frame === undefined) return;
const next: Array<StatusEvent> = [];
const push = (text: string, level: StatusEvent["level"] = "info") =>
next.push({ id: `ev-${seq++}`, at: Date.now(), text, level });
if (prev === undefined) {
const n = frame.rootsConfigured;
push(`Engine connected — ${n} ROM ${n === 1 ? "source" : "sources"}`);
if (frame.syncing) push("Sync running");
} else {
if (!prev.syncing && frame.syncing) push("Sync started");
if (prev.syncing && !frame.syncing) {
const report = frame.lastReport;
if (report === undefined) {
push("Sync finished");
} else {
push(
`Reconciled ${report.included} ${report.included === 1 ? "title" : "titles"}`,
);
for (const warning of report.warnings) push(warning, "warn");
if (report.overWarn) {
push(
`Library is over the warning threshold (${report.included} entries)`,
"warn",
);
}
}
}
if (
frame.detectedAt !== undefined &&
frame.detectedAt !== prev.detectedAt
) {
push("Emulator detection finished");
}
}
prev = frame;
if (next.length > 0) {
items = [...items, ...next].slice(-EVENT_LIMIT);
get.setSelf(items);
}
},
{ immediate: true },
);
return items;
});
+42
View File
@@ -0,0 +1,42 @@
// The one place the UI talks HTTP: a typed client + atom factory derived from the
// SHARED HttpApi contract via AtomHttpApi. No hand-mirrored types, no fetch calls in
// pages — endpoints, payloads, and errors all flow from @rom-manager/contract.
import { resolvePluginBase } from "@punktfunk/plugin-kit/react";
import { RomManagerApi } from "@rom-manager/contract";
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
} from "effect/unstable/http";
import { AtomHttpApi } from "effect/unstable/reactivity";
import { installedMockHttpLayer } from "@/mocks/register";
import { apiModeAtom } from "@/mocks/scenario";
/**
* "/plugin-ui/<id>" behind the console proxy, "" in dev Vite serves at "/", so
* relative fetches hit the dev-server proxy (live mode) or the mock layer (mock mode).
*/
export const prefix = resolvePluginBase();
export class Api extends AtomHttpApi.Service<Api>()("RomApi", {
api: RomManagerApi,
// Mock mode swaps the transport UNDER the same typed client, so pages cannot tell
// fixtures from a live host. The mock layer arrives via mocks/register (dev-only
// import); if it is not installed we fall through to fetch — a loud, honest failure.
httpClient: (get) => {
if (get(apiModeAtom) === "mock") {
const mock = installedMockHttpLayer(get);
if (mock !== undefined) return mock;
}
return FetchHttpClient.layer;
},
// prependUrl("") breaks URL joining — only install the transform when proxied.
...(prefix !== ""
? {
transformClient: HttpClient.mapRequest(
HttpClientRequest.prependUrl(prefix),
),
}
: {}),
}) {}
+101
View File
@@ -0,0 +1,101 @@
// The config draft: a local raw-config working copy seeded from GET /api/config.
// Edits patch the RAW (authored) shape — defaults are never baked in; `resolved`
// decodes the draft through the SHARED RomConfigSchema so controls can display
// effective values (e.g. pollMinutes 15 when the file omits it).
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import {
type Config,
type RawConfig,
resolveConfig,
} from "@rom-manager/contract";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useState } from "react";
import { errorText } from "@/lib/errors";
import { configAtom, SAVE_CONFIG_KEYS, saveConfig } from "./atoms";
export type ConfigDraft = {
/** False until GET /api/config has succeeded once (render a skeleton). */
readonly loaded: boolean;
/** The working copy of the authored (raw) config. `{}` until loaded. */
readonly draft: RawConfig;
/** The draft decoded through RomConfigSchema (defaults filled), if valid. */
readonly resolved: Config | undefined;
readonly dirty: boolean;
readonly saving: boolean;
readonly patch: (patch: Partial<RawConfig>) => void;
readonly save: () => void;
readonly discard: () => void;
};
type DraftState = {
readonly baseline: RawConfig;
readonly draft: RawConfig;
};
export const useConfigDraft = (): ConfigDraft => {
const result = useAtomValue(configAtom);
const saveResult = useAtomValue(saveConfig);
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
const [state, setState] = useState<DraftState | null>(null);
// Seed once from the first successful load; later refetches (our own saves) must
// not clobber in-progress edits.
useEffect(() => {
if (state === null && AsyncResult.isSuccess(result)) {
const raw = result.value.raw as RawConfig;
setState({ baseline: raw, draft: raw });
}
}, [result, state]);
const draft = state?.draft ?? {};
const dirty =
state !== null &&
JSON.stringify(state.draft) !== JSON.stringify(state.baseline);
const resolved = useMemo(() => {
try {
return resolveConfig(draft);
} catch {
return undefined;
}
}, [draft]);
const patch = useCallback((p: Partial<RawConfig>) => {
setState((s) => (s === null ? s : { ...s, draft: { ...s.draft, ...p } }));
}, []);
const discard = useCallback(() => {
setState((s) => (s === null ? s : { ...s, draft: s.baseline }));
}, []);
const save = useCallback(() => {
if (state === null) return;
const raw = state.draft;
void write({ payload: raw, reactivityKeys: [...SAVE_CONFIG_KEYS] }).then(
(exit) => {
if (Exit.isSuccess(exit)) {
setState({ baseline: raw, draft: raw });
toast.success("Configuration saved");
} else {
toast.error("Save failed", {
description: errorText(Cause.squash(exit.cause)),
});
}
},
);
}, [state, write]);
return {
loaded: state !== null,
draft,
resolved,
dirty,
saving: AsyncResult.isWaiting(saveResult),
patch,
save,
discard,
};
};
-36
View File
@@ -1,36 +0,0 @@
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] ?? "";
+18
View File
@@ -0,0 +1,18 @@
// One place to turn a typed API error (or any transport failure) into a sentence.
export const errorText = (error: unknown): string => {
if (typeof error === "object" && error !== null) {
const e = error as {
_tag?: string;
issues?: string;
message?: string;
reason?: unknown;
};
if (e._tag === "ConfigInvalid" && typeof e.issues === "string") {
return e.issues;
}
if (e._tag === "SyncInProgress") return "A sync pass is already running";
if (typeof e.message === "string" && e.message.length > 0) return e.message;
if (typeof e._tag === "string") return e._tag;
}
return String(error);
};
+14 -3
View File
@@ -1,10 +1,21 @@
import "./styles.css";
import { RegistryProvider } from "@effect/atom-react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
import { App } from "./App";
import { defaultApiMode } from "./mocks/scenario";
// Zero-host dev loop: install the mock transport BEFORE the first render so the very
// first atom reads hit fixtures. The whole branch is dead-code-eliminated from
// production builds (import.meta.env.DEV is false), so no fixture code ships.
if (import.meta.env.DEV && defaultApiMode === "mock") {
await import("./mocks/http");
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<RegistryProvider>
<App />
</RegistryProvider>
</StrictMode>,
);
+383
View File
@@ -0,0 +1,383 @@
// Fixture data for the zero-host dev loop + Storybook. Typed AGAINST THE CONTRACT —
// if the contract moves, this file fails typecheck, exactly like the real server would.
// Never imported by production app code (see mocks/register.ts for the seam).
import type {
DetectedEmulator,
EmulatorsPayload,
EngineStatus,
Platform,
PreviewResult,
RawConfig,
SyncReport,
} from "@rom-manager/contract";
import type { Scenario } from "./scenario";
type ProviderEntry = PreviewResult["entries"][number];
// ── Games ──────────────────────────────────────────────────────────────────────
const portrait = (title: string): string =>
`https://placehold.co/600x900/1c1530/a79ff8?text=${encodeURIComponent(title).replace(/%20/g, "+")}`;
const game = (
platform: string,
file: string,
title: string,
launch: string,
): ProviderEntry => ({
external_id: `${platform}/${file}`,
title,
art: { portrait: portrait(title) },
launch: { kind: "command", value: launch },
});
const retroarch = (core: string, platform: string, file: string): string =>
`/usr/bin/retroarch -f -L ${core} '/roms/${platform}/${file}'`;
/** Every game the mock scan finds (13 — one is excluded via config by default). */
export const GAMES: ReadonlyArray<ProviderEntry> = [
game(
"snes",
"Chrono Trigger (USA).sfc",
"Chrono Trigger",
retroarch("snes9x", "snes", "Chrono Trigger (USA).sfc"),
),
game(
"snes",
"Super Metroid (USA).sfc",
"Super Metroid",
retroarch("snes9x", "snes", "Super Metroid (USA).sfc"),
),
game(
"snes",
"The Legend of Zelda - A Link to the Past (USA).sfc",
"The Legend of Zelda: A Link to the Past",
retroarch(
"snes9x",
"snes",
"The Legend of Zelda - A Link to the Past (USA).sfc",
),
),
game(
"snes",
"EarthBound (USA).sfc",
"EarthBound",
retroarch("snes9x", "snes", "EarthBound (USA).sfc"),
),
game(
"snes",
"Super Mario World (USA).sfc",
"Super Mario World",
retroarch("snes9x", "snes", "Super Mario World (USA).sfc"),
),
game(
"n64",
"Super Mario 64 (USA).z64",
"Super Mario 64",
retroarch("mupen64plus_next", "n64", "Super Mario 64 (USA).z64"),
),
game(
"n64",
"The Legend of Zelda - Ocarina of Time (USA).z64",
"The Legend of Zelda: Ocarina of Time",
retroarch(
"mupen64plus_next",
"n64",
"The Legend of Zelda - Ocarina of Time (USA).z64",
),
),
game(
"n64",
"Mario Kart 64 (USA).z64",
"Mario Kart 64",
retroarch("mupen64plus_next", "n64", "Mario Kart 64 (USA).z64"),
),
game(
"n64",
"GoldenEye 007 (USA).z64",
"GoldenEye 007",
retroarch("mupen64plus_next", "n64", "GoldenEye 007 (USA).z64"),
),
game(
"ps1",
"Final Fantasy VII (USA).chd",
"Final Fantasy VII",
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Final Fantasy VII (USA).chd'",
),
game(
"ps1",
"Castlevania - Symphony of the Night (USA).chd",
"Castlevania: Symphony of the Night",
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Castlevania - Symphony of the Night (USA).chd'",
),
game(
"ps1",
"Metal Gear Solid (USA).chd",
"Metal Gear Solid",
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Metal Gear Solid (USA).chd'",
),
game(
"ps1",
"Vagrant Story (USA).chd",
"Vagrant Story",
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Vagrant Story (USA).chd'",
),
];
// ── Catalog ────────────────────────────────────────────────────────────────────
export const PLATFORMS: ReadonlyArray<Platform> = [
{
id: "snes",
name: "Super Nintendo",
extensions: ["sfc", "smc", "zip"],
libretroSystem: "Nintendo - Super Nintendo Entertainment System",
defaultLaunch: { emulator: "retroarch", core: "snes9x" },
},
{
id: "n64",
name: "Nintendo 64",
extensions: ["z64", "n64", "v64", "zip"],
libretroSystem: "Nintendo - Nintendo 64",
defaultLaunch: { emulator: "retroarch", core: "mupen64plus_next" },
},
{
id: "ps1",
name: "PlayStation",
extensions: ["chd", "cue", "pbp", "m3u"],
libretroSystem: "Sony - PlayStation",
defaultLaunch: { emulator: "duckstation" },
disc: true,
},
{
id: "gamecube",
name: "GameCube",
extensions: ["iso", "rvz", "gcz"],
libretroSystem: "Nintendo - GameCube",
defaultLaunch: { emulator: "dolphin" },
disc: true,
},
];
export const DETECTED_EMULATORS: ReadonlyArray<DetectedEmulator> = [
{
id: "retroarch",
name: "RetroArch",
template: "{exe} -f -L {core} {rom}",
supportsArchives: true,
exeToken: "retroarch",
exePath: "/usr/bin/retroarch",
via: "path",
coresDir: "/home/deck/.config/retroarch/cores",
cores: ["snes9x", "mupen64plus_next", "mesen", "gambatte", "mgba"],
},
{
id: "duckstation",
name: "DuckStation",
template: "{exe} -batch -fullscreen {rom}",
supportsArchives: false,
exeToken: "duckstation-qt",
exePath: "/usr/bin/duckstation-qt",
via: "path",
},
];
const DETECTED_AT = Date.now() - 2 * 60 * 60 * 1000;
export const emulatorsFor = (scenario: Scenario): EmulatorsPayload =>
scenario === "firstRun"
? { detected: [], detectedAt: null }
: { detected: [...DETECTED_EMULATORS], detectedAt: DETECTED_AT };
// ── Config ─────────────────────────────────────────────────────────────────────
const HEALTHY_CONFIG: RawConfig = {
roots: [
{ dir: "/roms/snes", platform: "snes" },
{ dir: "/roms/n64", platform: "n64" },
{ dir: "/roms/ps1", platform: "ps1", excludes: ["**/*.bak"] },
],
gameOverrides: {
"snes/Super Mario World (USA).sfc": { exclude: true },
},
art: { enabled: true, provider: "steamgriddb", steamGridDbKey: "sgdb-demo" },
};
export const configFor = (scenario: Scenario): RawConfig =>
scenario === "firstRun" ? {} : HEALTHY_CONFIG;
// ── Preview + report (derived from the current raw config, so toggles round-trip) ──
const platformOf = (externalId: string): string =>
externalId.split("/")[0] ?? "unknown";
const SKIPPED = [
{
external_id: "n64/GoldenEye 007 (Europe).z64",
title: "GoldenEye 007 (Europe)",
reason: "region duplicate of GoldenEye 007 (USA)",
},
{
external_id: "ps1/Final Fantasy VII (USA) (Disc 2).chd",
title: "Final Fantasy VII (Disc 2)",
reason: "multi-disc: only disc 1 is listed",
},
];
export const previewFor = (
scenario: Scenario,
raw: RawConfig,
): PreviewResult => {
if (scenario === "firstRun") {
return {
entries: [],
report: {
considered: 0,
included: 0,
skipped: [],
excluded: [],
warnings: [],
truncated: 0,
perPlatform: {},
overWarn: false,
},
};
}
const overrides = raw.gameOverrides ?? {};
const excludedIds = new Set(
Object.entries(overrides)
.filter(([, o]) => o.exclude === true)
.map(([id]) => id),
);
const entries = GAMES.filter((g) => !excludedIds.has(g.external_id));
const excluded = GAMES.filter((g) => excludedIds.has(g.external_id)).map(
(g) => ({ external_id: g.external_id, title: g.title }),
);
const perPlatform: Record<string, number> = {};
for (const entry of entries) {
const p = platformOf(entry.external_id);
perPlatform[p] = (perPlatform[p] ?? 0) + 1;
}
const warnings =
scenario === "errors"
? [
"steamgriddb: 401 unauthorized — check the API key",
"/roms/n64: 3 files with unknown extensions ignored",
]
: [];
const report: SyncReport = {
considered: GAMES.length + SKIPPED.length,
included: entries.length,
skipped: SKIPPED,
excluded,
warnings,
truncated: 0,
perPlatform,
overWarn: scenario === "errors",
};
return { entries, report };
};
export const reportFor = (scenario: Scenario, raw: RawConfig): SyncReport =>
previewFor(scenario, raw).report;
// ── Engine status + the SSE stand-in ticker ────────────────────────────────────
const PATHS = {
dir: "/home/deck/.local/share/punktfunk/plugin-state/rom-manager",
config:
"/home/deck/.local/share/punktfunk/plugin-state/rom-manager/config.json",
cache:
"/home/deck/.local/share/punktfunk/plugin-state/rom-manager/cache.json",
} as const;
export const statusFor = (scenario: Scenario): EngineStatus => {
const report = reportFor(scenario, configFor(scenario));
switch (scenario) {
case "firstRun":
return {
rootsConfigured: 0,
os: "linux",
artProvider: null,
syncing: false,
paths: PATHS,
};
case "syncing":
return {
rootsConfigured: 3,
os: "linux",
artProvider: "steamgriddb",
syncing: true,
lastSync: {
fingerprint: "b3c1a94efd02",
count: report.included,
at: Date.now() - 8 * 60 * 1000,
},
lastReport: report,
detectedAt: DETECTED_AT,
paths: PATHS,
};
case "errors":
return {
rootsConfigured: 3,
os: "linux",
artProvider: null,
syncing: false,
lastSync: {
fingerprint: "b3c1a94efd02",
count: report.included,
at: Date.now() - 45 * 60 * 1000,
},
lastReport: report,
detectedAt: DETECTED_AT,
paths: PATHS,
};
default:
return {
rootsConfigured: 3,
os: "linux",
artProvider: "steamgriddb",
syncing: false,
lastSync: {
fingerprint: "b3c1a94efd02",
count: report.included,
at: Date.now() - 8 * 60 * 1000,
},
lastReport: report,
detectedAt: DETECTED_AT,
paths: PATHS,
};
}
};
/**
* Frames for the mock status ticker (~2s steps). Multi-frame scenarios loop
* idle syncing done so the Overview feed and sync button stay alive without a host.
*/
export const tickerFrames = (
scenario: Scenario,
): ReadonlyArray<EngineStatus> => {
const base = statusFor(scenario);
switch (scenario) {
case "firstRun":
return [base];
case "syncing":
return [base];
case "errors":
return [base, { ...base, syncing: true }, { ...base, syncing: false }];
default:
return [
base,
{ ...base, syncing: true },
{
...base,
syncing: false,
lastSync: {
fingerprint: "c4d2ba50fe13",
count: base.lastReport?.included ?? 0,
at: Date.now(),
},
},
];
}
};
+145
View File
@@ -0,0 +1,145 @@
// The mock transport: a full in-memory implementation of the RomManagerApi surface as
// an HttpClient layer, plus the SSE stand-in ticker. Importing this module REGISTERS
// both (see mocks/register.ts); only main.tsx (behind import.meta.env.DEV) and
// Storybook's preview import it, so production app chunks never contain fixtures.
import type { EngineStatus, RawConfig } from "@rom-manager/contract";
import { Duration, Effect, Layer } from "effect";
import {
HttpClient,
type HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http";
import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
import * as fx from "./fixtures";
import { registerMocks } from "./register";
import { type Scenario, scenarioAtom } from "./scenario";
/** PUT /api/config lands here so config edits round-trip within a registry.
* keepAlive: written/read outside the reactive graph see mocks/scenario.ts. */
const mockRawConfigAtom = Atom.keepAlive(Atom.make<RawConfig | null>(null));
const currentRawConfig = (
registry: AtomRegistry.AtomRegistry,
scenario: Scenario,
): RawConfig => registry.get(mockRawConfigAtom) ?? fx.configFor(scenario);
const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => {
const body = request.body;
if (body._tag === "Uint8Array") {
return JSON.parse(new TextDecoder().decode(body.body));
}
if (body._tag === "Raw") return body.body;
return undefined;
};
export const mockHttpClientLayer = (
get: Atom.AtomContext,
): Layer.Layer<HttpClient.HttpClient> => {
const registry = get.registry;
return Layer.succeed(HttpClient.HttpClient)(
HttpClient.make((request, url) =>
Effect.gen(function* () {
// Real-ish latency so skeletons/spinners are honest in dev + Storybook.
yield* Effect.sleep(Duration.millis(150 + Math.random() * 250));
const scenario = registry.get(scenarioAtom);
const json = (body: unknown, status = 200) =>
HttpClientResponse.fromWeb(request, Response.json(body, { status }));
switch (`${request.method} ${url.pathname}`) {
case "GET /api/status":
return json(fx.statusFor(scenario));
case "GET /api/config":
return json({ raw: currentRawConfig(registry, scenario) });
case "PUT /api/config": {
if (scenario === "errors") {
return json(
{
_tag: "ConfigInvalid",
issues: 'roots[1].platform: unknown platform id "n65"',
},
400,
);
}
const raw = requestJson(request) as RawConfig;
registry.set(mockRawConfigAtom, raw);
return json({ raw });
}
case "GET /api/preview": {
if (scenario === "errors") {
return json(
{
_tag: "ScanFailed",
message: "scan failed: /roms/n64: permission denied",
},
500,
);
}
return json(
fx.previewFor(scenario, currentRawConfig(registry, scenario)),
);
}
case "GET /api/platforms":
return json(fx.PLATFORMS);
case "GET /api/emulators":
return json(fx.emulatorsFor(scenario));
case "POST /api/detect": {
if (scenario === "errors") {
return json(
{
_tag: "DetectFailed",
message: "detection failed: PATH probe timed out",
},
500,
);
}
return json({
...fx.emulatorsFor(scenario),
detectedAt: Date.now(),
});
}
case "POST /api/sync": {
if (scenario === "syncing") {
return json({ _tag: "SyncInProgress" }, 409);
}
return json(
fx.reportFor(scenario, currentRawConfig(registry, scenario)),
);
}
default:
return json(
{ error: `mock: unhandled ${request.method} ${url.pathname}` },
404,
);
}
}),
),
);
};
/** The SSE stand-in: cycles fixture frames (~2s steps) per the active scenario. */
export const mockStatusStreamAtom: Atom.Atom<EngineStatus | undefined> =
Atom.make((get) => {
const frames = fx.tickerFrames(get(scenarioAtom));
if (frames.length > 1) {
let i = 0;
const timer = setInterval(() => {
i += 1;
get.setSelf(frames[i % frames.length]);
}, 2000);
get.addFinalizer(() => clearInterval(timer));
}
return frames[0];
});
registerMocks({
httpLayer: mockHttpClientLayer,
statusStream: mockStatusStreamAtom,
});
+34
View File
@@ -0,0 +1,34 @@
// The dev-only seam between the always-shipped data layer and the mock transport.
// client.ts/atoms.ts reference THIS module (tiny, fixture-free); mocks/http.ts calls
// `registerMocks` at import time and is only ever imported by main.tsx behind
// `import.meta.env.DEV` and by Storybook's preview — so production app chunks stay
// free of fixture code without any conditional imports in the data layer.
import type { EngineStatus } from "@rom-manager/contract";
import type { Layer } from "effect";
import type { HttpClient } from "effect/unstable/http";
import type { Atom } from "effect/unstable/reactivity";
export type MockHttpLayerFactory = (
get: Atom.AtomContext,
) => Layer.Layer<HttpClient.HttpClient>;
type Registered = {
readonly httpLayer: MockHttpLayerFactory;
readonly statusStream: Atom.Atom<EngineStatus | undefined>;
};
let registered: Registered | undefined;
export const registerMocks = (mocks: Registered): void => {
registered = mocks;
};
/** The mock HttpClient layer, when mocks/http.ts has been imported. */
export const installedMockHttpLayer = (
get: Atom.AtomContext,
): Layer.Layer<HttpClient.HttpClient> | undefined => registered?.httpLayer(get);
/** The mock status ticker (the SSE stand-in), when mocks/http.ts has been imported. */
export const installedMockStatusStream = ():
| Atom.Atom<EngineStatus | undefined>
| undefined => registered?.statusStream;
+43
View File
@@ -0,0 +1,43 @@
// Mock-mode switches. Fixture-free ON PURPOSE: this module sits in the production
// import graph (client.ts reads apiModeAtom), so it must never pull in fixtures.
import { Atom } from "effect/unstable/reactivity";
export type ApiMode = "mock" | "live";
/** The fixture worlds the mock transport can serve (Storybook picks one per story). */
export type Scenario = "healthy" | "firstRun" | "syncing" | "errors";
/**
* Dev defaults to the zero-host mock loop; `bun run dev:live` (VITE_API_MODE=live) and
* production builds hit the real API. Storybook seeds this atom to "mock" per registry.
*/
export const defaultApiMode: ApiMode =
import.meta.env.DEV && import.meta.env.VITE_API_MODE !== "live"
? "mock"
: "live";
// keepAlive: both atoms are read OUTSIDE the reactive graph (the mock transport does
// a request-time registry.get) — without it, a registry-seeded value on a page that
// never subscribes them is collected and the read falls back to the default.
export const apiModeAtom: Atom.Writable<ApiMode> = Atom.keepAlive(
Atom.make<ApiMode>(defaultApiMode),
);
const SCENARIOS: ReadonlyArray<Scenario> = [
"healthy",
"firstRun",
"syncing",
"errors",
];
const isScenario = (value: unknown): value is Scenario =>
SCENARIOS.includes(value as Scenario);
/** Dev knob: `VITE_MOCK_SCENARIO=firstRun bun run dev` starts in another world. */
const defaultScenario: Scenario = isScenario(import.meta.env.VITE_MOCK_SCENARIO)
? import.meta.env.VITE_MOCK_SCENARIO
: "healthy";
export const scenarioAtom: Atom.Writable<Scenario> = Atom.keepAlive(
Atom.make<Scenario>(defaultScenario),
);
-200
View File
@@ -1,200 +0,0 @@
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>
)}
</>
);
};
-156
View File
@@ -1,156 +0,0 @@
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;
-117
View File
@@ -1,117 +0,0 @@
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>
);
};
-255
View File
@@ -1,255 +0,0 @@
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>
);
};
+14
View File
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { EmulatorsPage } from "./emulators";
const meta = {
title: "Pages/Emulators",
component: EmulatorsPage,
args: { navigate: () => {} },
} satisfies Meta<typeof EmulatorsPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
+350
View File
@@ -0,0 +1,350 @@
// Emulators — subscribes emulatorsAtom + platformsAtom + the config draft; mutates
// runDetect (re-detect) and saveConfig (per-platform launch overrides, via the draft).
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import type {
DetectedEmulator,
EmulatorsPayload,
Platform,
RawConfig,
} from "@rom-manager/contract";
import { SettingsGroup, SettingsRow } from "@unom/app-ui/settings";
import { Badge } from "@unom/ui/badge";
import { Button } from "@unom/ui/button";
import Card from "@unom/ui/card";
import { InputText } from "@unom/ui/form/input-text";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unom/ui/form/select";
import { Skeleton } from "@unom/ui/skeleton";
import { Spinner } from "@unom/ui/spinner";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { RefreshCw } from "lucide-react";
import { useMemo } from "react";
import { Gate } from "@/components/gate";
import { SaveBar } from "@/components/save-bar";
import {
emulatorsAtom,
platformsAtom,
RUN_DETECT_KEYS,
runDetect,
} from "@/data/atoms";
import { useConfigDraft } from "@/data/drafts";
import { errorText } from "@/lib/errors";
import type { PageProps } from "@/routes";
type PlatformLaunchRaw = NonNullable<RawConfig["platformLaunch"]>[string];
const PageSkeleton = () => (
<div className="space-y-6">
<Skeleton className="h-64" />
<Skeleton className="h-80" />
</div>
);
/** Display names for known emulator ids that are not currently detected (id fallback). */
const EMULATOR_LABELS: Record<string, string> = {
retroarch: "RetroArch",
dolphin: "Dolphin",
pcsx2: "PCSX2",
duckstation: "DuckStation",
ppsspp: "PPSSPP",
mgba: "mGBA",
melonds: "melonDS",
flycast: "Flycast",
xemu: "xemu",
azahar: "Azahar",
ryujinx: "Ryujinx",
};
const emulatorLabel = (id: string, detected?: DetectedEmulator): string =>
detected?.name ?? EMULATOR_LABELS[id] ?? id;
type RosterRow = {
readonly id: string;
readonly name: string;
readonly detected: DetectedEmulator | undefined;
};
/**
* The emulator roster the page renders: every detected emulator plus every emulator
* any platform's default launch references (the API only ships detected ones the
* full definition catalog is plugin-internal).
*/
const useRoster = (
emulators: EmulatorsPayload,
platforms: ReadonlyArray<Platform>,
): ReadonlyArray<RosterRow> =>
useMemo(() => {
const detectedById = new Map(
emulators.detected.map((emulator) => [emulator.id, emulator]),
);
const ids = new Set<string>(detectedById.keys());
for (const platform of platforms) ids.add(platform.defaultLaunch.emulator);
return [...ids]
.map((id) => ({
id,
name: emulatorLabel(id, detectedById.get(id)),
detected: detectedById.get(id),
}))
.sort((a, b) =>
a.detected === b.detected || (a.detected && b.detected)
? a.name.localeCompare(b.name)
: a.detected
? -1
: 1,
);
}, [emulators, platforms]);
const DetectButton = () => {
const detectResult = useAtomValue(runDetect);
const detect = useAtomSet(runDetect, { mode: "promiseExit" });
const pending = AsyncResult.isWaiting(detectResult);
const onDetect = () => {
void detect({ reactivityKeys: [...RUN_DETECT_KEYS] }).then((exit) => {
if (Exit.isSuccess(exit)) {
toast.success(
`Detection finished — ${exit.value.detected.length} ${exit.value.detected.length === 1 ? "emulator" : "emulators"} found`,
);
} else {
toast.error("Detection failed", {
description: errorText(Cause.squash(exit.cause)),
});
}
});
};
return (
<Button variant="outline" onClick={onDetect} disabled={pending}>
{pending ? (
<Spinner className="size-4" />
) : (
<RefreshCw className="size-4" />
)}
Re-detect
</Button>
);
};
const DetectedList = ({
emulators,
platforms,
}: {
emulators: EmulatorsPayload;
platforms: ReadonlyArray<Platform>;
}) => {
const roster = useRoster(emulators, platforms);
return (
<Card>
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold tracking-tight">
Detected emulators
</h2>
<p className="text-sm text-muted-foreground">
{emulators.detectedAt === null
? "Not scanned yet"
: `Last scan ${new Date(emulators.detectedAt).toLocaleString()}`}
</p>
</div>
<DetectButton />
</div>
<div className="divide-y divide-border">
{roster.map((row) => (
<div key={row.id} className="flex items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{row.name}</span>
{row.detected !== undefined ? (
<Badge variant="success" size="sm" dot>
detected · {row.detected.via}
</Badge>
) : (
<Badge variant="outline" size="sm">
not found
</Badge>
)}
{row.detected?.contested === true ? (
<Badge variant="warn" size="sm">
contested
</Badge>
) : null}
{row.detected?.cores !== undefined &&
row.detected.cores.length > 0 ? (
<Badge variant="outline" size="sm">
{row.detected.cores.length} cores
</Badge>
) : null}
</div>
{row.detected?.exePath !== undefined ? (
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{row.detected.exePath}
</p>
) : null}
</div>
</div>
))}
</div>
</Card>
);
};
const PLATFORM_DEFAULT = "__default";
const LaunchRow = ({
platform,
emulators,
launch,
onChange,
}: {
platform: Platform;
emulators: EmulatorsPayload;
launch: PlatformLaunchRaw | undefined;
onChange: (launch: PlatformLaunchRaw | undefined) => void;
}) => {
const effectiveEmulator = launch?.emulator ?? platform.defaultLaunch.emulator;
const detected = emulators.detected.find(
(emulator) => emulator.id === effectiveEmulator,
);
const cores = detected?.cores ?? [];
const defaultCore =
launch?.emulator === undefined ||
launch.emulator === platform.defaultLaunch.emulator
? platform.defaultLaunch.core
: undefined;
const description =
platform.defaultLaunch.core === undefined
? `default: ${emulatorLabel(platform.defaultLaunch.emulator)}`
: `default: ${emulatorLabel(platform.defaultLaunch.emulator)} · ${platform.defaultLaunch.core}`;
return (
<SettingsRow label={platform.name} description={description}>
<div className="flex flex-wrap items-center justify-end gap-2">
<Select
value={launch?.emulator ?? PLATFORM_DEFAULT}
onValueChange={(value) =>
onChange(
value === PLATFORM_DEFAULT ? undefined : { emulator: value },
)
}
>
<SelectTrigger size="sm" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={PLATFORM_DEFAULT}>Platform default</SelectItem>
{emulators.detected.map((emulator) => (
<SelectItem key={emulator.id} value={emulator.id}>
{emulator.name}
</SelectItem>
))}
</SelectContent>
</Select>
{cores.length > 0 ? (
<Select
value={launch?.core ?? defaultCore}
onValueChange={(core) =>
onChange({ emulator: effectiveEmulator, ...launch, core })
}
>
<SelectTrigger size="sm" className="w-44">
<SelectValue placeholder="Core" />
</SelectTrigger>
<SelectContent>
{cores.map((core) => (
<SelectItem key={core} value={core}>
{core}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<InputText
value={launch?.extraArgs ?? ""}
placeholder="extra args"
className="w-36"
onChange={(event) => {
const extraArgs = event.target.value;
// `extraArgs` is an optionalKey — omit it when cleared.
const { extraArgs: _drop, ...rest } = launch ?? {
emulator: effectiveEmulator,
};
onChange(
extraArgs.length > 0 ? { ...rest, extraArgs } : { ...rest },
);
}}
/>
</div>
</SettingsRow>
);
};
const EmulatorsContent = ({
emulators,
platforms,
}: {
emulators: EmulatorsPayload;
platforms: ReadonlyArray<Platform>;
}) => {
const draft = useConfigDraft();
const setLaunch = (
platformId: string,
launch: PlatformLaunchRaw | undefined,
) => {
const platformLaunch = { ...(draft.draft.platformLaunch ?? {}) };
if (launch === undefined) delete platformLaunch[platformId];
else platformLaunch[platformId] = launch;
draft.patch({ platformLaunch });
};
return (
<div className="space-y-6">
<DetectedList emulators={emulators} platforms={platforms} />
<SettingsGroup
title="Per-platform launch"
description="Override which emulator (and core) launches each platform's games."
>
{platforms.map((platform) => (
<LaunchRow
key={platform.id}
platform={platform}
emulators={emulators}
launch={draft.draft.platformLaunch?.[platform.id]}
onChange={(launch) => setLaunch(platform.id, launch)}
/>
))}
</SettingsGroup>
<SaveBar
dirty={draft.dirty}
saving={draft.saving}
onSave={draft.save}
onDiscard={draft.discard}
/>
</div>
);
};
export const EmulatorsPage = (_: PageProps) => {
const emulators = useAtomValue(emulatorsAtom);
const platforms = useAtomValue(platformsAtom);
const retryEmulators = useAtomRefresh(emulatorsAtom);
const retryPlatforms = useAtomRefresh(platformsAtom);
const combined = AsyncResult.all({ emulators, platforms });
return (
<Gate
result={combined}
skeleton={<PageSkeleton />}
retry={() => {
retryEmulators();
retryPlatforms();
}}
>
{({ emulators: e, platforms: p }) => (
<EmulatorsContent emulators={e} platforms={p} />
)}
</Gate>
);
};
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LibraryPage } from "./library";
const meta = {
title: "Pages/Library",
component: LibraryPage,
args: { navigate: () => {} },
} satisfies Meta<typeof LibraryPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
/** The preview endpoint fails (ScanFailed 500) — exercises the error gate. */
export const Errors: Story = { parameters: { scenario: "errors" } };
+269
View File
@@ -0,0 +1,269 @@
// Library — subscribes previewAtom (the dry-run desired state) + configAtom (for the
// include toggles); mutates saveConfig directly (no draft — a toggle saves immediately).
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import type {
PreviewResult,
RawConfig,
SyncReport,
} from "@rom-manager/contract";
import { Badge } from "@unom/ui/badge";
import { Button } from "@unom/ui/button";
import Card from "@unom/ui/card";
import { EmptyState } from "@unom/ui/empty-state";
import { Switch } from "@unom/ui/form/switch";
import { Skeleton } from "@unom/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@unom/ui/table";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { Gamepad2, Library as LibraryIcon } from "lucide-react";
import { motion } from "motion/react";
import { useCallback, useState } from "react";
import { Gate } from "@/components/gate";
import {
configAtom,
previewAtom,
SAVE_CONFIG_KEYS,
saveConfig,
} from "@/data/atoms";
import { errorText } from "@/lib/errors";
import type { PageProps } from "@/routes";
type Entry = PreviewResult["entries"][number];
const PageSkeleton = () => (
<div className="grid grid-cols-2 gap-card md:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }, (_, i) => (
<Skeleton key={i} className="aspect-[2/3] w-full rounded-card" />
))}
</div>
);
/** Toggle helper: rewrites gameOverrides on the CURRENT raw config and saves. */
const useSetIncluded = () => {
const config = useAtomValue(configAtom);
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
return useCallback(
(externalId: string, included: boolean) => {
if (!AsyncResult.isSuccess(config)) return;
const raw = config.value.raw as RawConfig;
const overrides = { ...(raw.gameOverrides ?? {}) };
// `exclude` is an optionalKey — the key must be OMITTED, never set undefined.
const { exclude: _drop, ...rest } = overrides[externalId] ?? {};
if (included) {
if (Object.keys(rest).length === 0) delete overrides[externalId];
else overrides[externalId] = rest;
} else {
overrides[externalId] = { ...rest, exclude: true };
}
void write({
payload: { ...raw, gameOverrides: overrides },
reactivityKeys: [...SAVE_CONFIG_KEYS],
}).then((exit) => {
if (!Exit.isSuccess(exit)) {
toast.error("Couldn't update the game", {
description: errorText(Cause.squash(exit.cause)),
});
}
});
},
[config, write],
);
};
const GameCard = ({
entry,
index,
onToggle,
}: {
entry: Entry;
index: number;
onToggle: (externalId: string, included: boolean) => void;
}) => {
const [imgState, setImgState] = useState<"loading" | "ok" | "error">(
"loading",
);
const platform = entry.external_id.split("/")[0] ?? "unknown";
const portrait = entry.art?.portrait ?? undefined;
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
whileHover={{ scale: 1.02 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: Math.min(index, 24) * 0.04,
}}
>
<Card
padding={false}
className="overflow-hidden ring-0 transition-shadow duration-300 hover:ring-2 hover:ring-accent/40"
>
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gradient-to-br from-secondary to-muted">
{portrait !== undefined && imgState !== "error" ? (
<>
{imgState === "loading" ? (
<Skeleton className="absolute inset-0 rounded-none" />
) : null}
<img
src={portrait}
alt={entry.title}
loading="lazy"
onLoad={() => setImgState("ok")}
onError={() => setImgState("error")}
className="h-full w-full object-cover"
/>
</>
) : (
<div className="flex h-full items-center justify-center">
<Gamepad2 className="size-8 text-muted-foreground/50" />
</div>
)}
</div>
<div className="space-y-2 p-3">
<div className="flex items-start justify-between gap-2">
<p className="line-clamp-2 text-sm font-medium leading-tight">
{entry.title}
</p>
<Switch
checked
onCheckedChange={() => onToggle(entry.external_id, false)}
aria-label={`Include ${entry.title}`}
/>
</div>
<Badge variant="outline" size="sm">
{platform}
</Badge>
{entry.launch != null ? (
<p
title={entry.launch.value}
className="line-clamp-1 font-mono text-xs text-muted-foreground"
>
{entry.launch.value}
</p>
) : null}
</div>
</Card>
</motion.div>
);
};
const SkippedCard = ({
report,
onToggle,
}: {
report: SyncReport;
onToggle: (externalId: string, included: boolean) => void;
}) => {
const [open, setOpen] = useState(false);
const total = report.skipped.length + report.excluded.length;
if (total === 0) return null;
return (
<Card padding={false} className="overflow-hidden">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium hover:bg-secondary/40"
>
<span>Skipped ({total})</span>
<span className="text-muted-foreground">{open ? "Hide" : "Show"}</span>
</button>
{open ? (
<div className="space-y-5 px-5 pb-5">
{report.skipped.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.skipped.map((skip) => (
<TableRow key={skip.external_id}>
<TableCell>{skip.title}</TableCell>
<TableCell className="text-muted-foreground">
{skip.reason}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
{report.excluded.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">
Excluded by you
</p>
{report.excluded.map((excluded) => (
<div
key={excluded.external_id}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="truncate">{excluded.title}</span>
<Button
variant="outline"
size="sm"
onClick={() => onToggle(excluded.external_id, true)}
>
Re-include
</Button>
</div>
))}
</div>
) : null}
</div>
) : null}
</Card>
);
};
const LibraryContent = ({ preview }: { preview: PreviewResult }) => {
const setIncluded = useSetIncluded();
if (preview.entries.length === 0 && preview.report.excluded.length === 0) {
return (
<EmptyState
icon={LibraryIcon}
title="Nothing to show yet"
description="Add a ROM folder under Sources and the scan preview appears here."
/>
);
}
return (
<div className="space-y-6">
<div className="@container">
<div className="grid grid-cols-2 gap-card @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
{preview.entries.map((entry, index) => (
<GameCard
key={entry.external_id}
entry={entry}
index={index}
onToggle={setIncluded}
/>
))}
</div>
</div>
<SkippedCard report={preview.report} onToggle={setIncluded} />
</div>
);
};
export const LibraryPage = (_: PageProps) => {
const preview = useAtomValue(previewAtom);
const retry = useAtomRefresh(previewAtom);
return (
<Gate result={preview} skeleton={<PageSkeleton />} retry={retry}>
{(p) => <LibraryContent preview={p} />}
</Gate>
);
};
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { OverviewPage } from "./overview";
const meta = {
title: "Pages/Overview",
component: OverviewPage,
args: { navigate: () => {} },
} satisfies Meta<typeof OverviewPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
export const Syncing: Story = { parameters: { scenario: "syncing" } };
export const Errors: Story = { parameters: { scenario: "errors" } };
+231
View File
@@ -0,0 +1,231 @@
// Overview — subscribes liveStatusAtom (SSE-fresh engine status) + statusEventsAtom;
// mutates runSync. First run (no roots) renders the onboarding hero instead.
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import type { EngineStatus } from "@rom-manager/contract";
import { EventFeed } from "@unom/app-ui/event-feed";
import { StatCard } from "@unom/app-ui/stat-card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@unom/ui/accordion";
import { AnimatedButton } from "@unom/ui/button";
import Card from "@unom/ui/card";
import { EmptyState } from "@unom/ui/empty-state";
import { Skeleton } from "@unom/ui/skeleton";
import { Spinner } from "@unom/ui/spinner";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import {
FolderOpen,
FolderSearch,
Image,
Library,
RefreshCw,
TriangleAlert,
} from "lucide-react";
import { Gate } from "@/components/gate";
import {
liveStatusAtom,
RUN_SYNC_KEYS,
runSync,
statusAtom,
statusEventsAtom,
} from "@/data/atoms";
import { errorText } from "@/lib/errors";
import type { PageProps } from "@/routes";
const PageSkeleton = () => (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-card xl:grid-cols-4">
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
</div>
<Skeleton className="h-10 w-40" />
<Skeleton className="h-56" />
</div>
);
const FirstRun = ({ navigate }: PageProps) => (
<EmptyState
icon={FolderSearch}
title="No ROM sources yet"
description={
"Three steps to a streaming retro library: add a ROM folder, pick the platform its files belong to, then sync — every game lands in your Punktfunk library with art and a launch command."
}
action={
<AnimatedButton
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
onClick={() => navigate("sources")}
>
Add your first ROM folder
</AnimatedButton>
}
className="py-20"
/>
);
const lastSyncHint = (status: EngineStatus): string => {
if (status.lastSync === undefined) return "never synced";
const minutes = Math.max(
0,
Math.round((Date.now() - status.lastSync.at) / 60_000),
);
if (minutes === 0) return "synced just now";
if (minutes < 60) return `synced ${minutes} min ago`;
return `synced ${Math.round(minutes / 60)} h ago`;
};
const SyncButton = ({ status }: { status: EngineStatus }) => {
const syncResult = useAtomValue(runSync);
const sync = useAtomSet(runSync, { mode: "promiseExit" });
const pending = status.syncing || AsyncResult.isWaiting(syncResult);
const onSync = () => {
void sync({ reactivityKeys: [...RUN_SYNC_KEYS] }).then((exit) => {
if (Exit.isSuccess(exit)) {
toast.success(
`Sync complete — ${exit.value.included} ${exit.value.included === 1 ? "title" : "titles"} in the library`,
);
} else {
toast.error("Sync failed", {
description: errorText(Cause.squash(exit.cause)),
});
}
});
};
return (
<AnimatedButton
onClick={onSync}
disabled={pending}
whileTap={{ scale: 0.97 }}
>
{pending ? (
<Spinner className="size-4" />
) : (
<RefreshCw className="size-4" />
)}
{pending ? "Syncing…" : "Sync now"}
</AnimatedButton>
);
};
const Warnings = ({ status }: { status: EngineStatus }) => {
const report = status.lastReport;
if (report === undefined) return null;
const count = report.warnings.length + (report.overWarn ? 1 : 0);
if (count === 0) return null;
return (
<Card padding={false} className="overflow-hidden">
<Accordion type="single" collapsible>
<AccordionItem value="warnings" className="border-b-0">
<AccordionTrigger className="px-5 py-4 text-warn">
<span className="flex items-center gap-2">
<TriangleAlert className="size-4" />
{count} {count === 1 ? "warning" : "warnings"} from the last sync
</span>
</AccordionTrigger>
<AccordionContent className="px-5 pb-4">
<ul className="space-y-1.5 text-sm text-muted-foreground">
{report.warnings.map((warning) => (
<li key={warning} className="flex gap-2">
<span aria-hidden className="text-warn">
</span>
{warning}
</li>
))}
{report.overWarn ? (
<li className="flex gap-2">
<span aria-hidden className="text-warn">
</span>
The library is over the configured warning threshold (
{report.included} entries) consider region dedupe or
excludes.
</li>
) : null}
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
</Card>
);
};
const ActivityFeed = () => {
const events = useAtomValue(statusEventsAtom);
return (
<Card>
<h2 className="mb-3 text-lg font-semibold tracking-tight">Activity</h2>
<EventFeed
items={events}
maxHeight="16rem"
empty="Waiting for engine events…"
/>
</Card>
);
};
const Dashboard = ({ status }: { status: EngineStatus }) => {
const inLibrary = status.lastSync?.count ?? status.lastReport?.included ?? 0;
const skipped = status.lastReport?.skipped.length ?? 0;
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-card xl:grid-cols-4">
<StatCard
label="ROM sources"
value={status.rootsConfigured}
icon={FolderOpen}
hint={`on ${status.os}`}
/>
<StatCard
label="In library"
value={inLibrary}
icon={Library}
hint={lastSyncHint(status)}
/>
<StatCard
label="Skipped"
value={skipped}
icon={TriangleAlert}
tone={skipped > 0 ? "warn" : "default"}
hint={skipped > 0 ? "see Library for reasons" : "nothing skipped"}
/>
<StatCard
label="Art provider"
value={status.artProvider ?? "off"}
icon={Image}
tone={status.artProvider === null ? "default" : "success"}
hint={status.artProvider === null ? "covers disabled" : "covers on"}
/>
</div>
<div className="flex items-center gap-3">
<SyncButton status={status} />
</div>
<Warnings status={status} />
<ActivityFeed />
</div>
);
};
export const OverviewPage = ({ navigate }: PageProps) => {
const status = useAtomValue(liveStatusAtom);
const retry = useAtomRefresh(statusAtom);
return (
<Gate result={status} skeleton={<PageSkeleton />} retry={retry}>
{(s) =>
s.rootsConfigured === 0 ? (
<FirstRun navigate={navigate} />
) : (
<Dashboard status={s} />
)
}
</Gate>
);
};
+13
View File
@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SettingsPage } from "./settings";
const meta = {
title: "Pages/Settings",
component: SettingsPage,
args: { navigate: () => {} },
} satisfies Meta<typeof SettingsPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
+222
View File
@@ -0,0 +1,222 @@
// Settings — subscribes the config draft (raw edits, resolved display values) +
// liveStatusAtom (the Files paths); mutates saveConfig via the sticky SaveBar.
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
import type { RawConfig } from "@rom-manager/contract";
import { SettingsGroup, SettingsRow } from "@unom/app-ui/settings";
import { CodeBlock } from "@unom/ui/code-block";
import { InputNumber } from "@unom/ui/form/input-number";
import { InputText } from "@unom/ui/form/input-text";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unom/ui/form/select";
import { Switch } from "@unom/ui/form/switch";
import { Skeleton } from "@unom/ui/skeleton";
import { Gate } from "@/components/gate";
import { SaveBar } from "@/components/save-bar";
import { configAtom, liveStatusAtom, statusAtom } from "@/data/atoms";
import { useConfigDraft } from "@/data/drafts";
import type { PageProps } from "@/routes";
type ArtRaw = NonNullable<RawConfig["art"]>;
type SyncRaw = NonNullable<RawConfig["sync"]>;
const PageSkeleton = () => (
<div className="space-y-6">
<Skeleton className="h-52" />
<Skeleton className="h-72" />
<Skeleton className="h-40" />
</div>
);
const PROVIDERS = ["auto", "steamgriddb", "libretro"] as const;
export const SettingsPage = (_: PageProps) => {
const config = useAtomValue(configAtom);
const retry = useAtomRefresh(configAtom);
const status = useAtomValue(liveStatusAtom);
const retryStatus = useAtomRefresh(statusAtom);
const draft = useConfigDraft();
const { resolved } = draft;
const patchArt = (patch: Partial<ArtRaw>) =>
draft.patch({ art: { ...(draft.draft.art ?? {}), ...patch } });
const patchSync = (patch: Partial<SyncRaw>) =>
draft.patch({ sync: { ...(draft.draft.sync ?? {}), ...patch } });
const provider = resolved?.art.provider ?? "auto";
return (
<Gate result={config} skeleton={<PageSkeleton />} retry={retry}>
{() => (
<div className="space-y-6">
<SettingsGroup
title="Artwork"
description="Cover art for library entries — fetched at sync time, cached on disk."
>
<SettingsRow
label="Fetch artwork"
description="Look up portraits, heroes, and logos for scanned games."
>
<Switch
checked={resolved?.art.enabled ?? true}
onCheckedChange={(checked) =>
patchArt({ enabled: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Provider"
description="auto prefers SteamGridDB when a key is set, libretro otherwise."
>
<Select
value={provider}
onValueChange={(value) =>
patchArt({ provider: value as ArtRaw["provider"] })
}
>
<SelectTrigger size="sm" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
{provider !== "libretro" ? (
<SettingsRow
label="SteamGridDB API key"
description="From steamgriddb.com — stored in the plugin's config file."
>
<InputText
type="password"
autoComplete="off"
className="w-64"
value={draft.draft.art?.steamGridDbKey ?? ""}
placeholder="API key"
onChange={(event) => {
const key = event.target.value;
// optionalKey — omit when cleared.
const { steamGridDbKey: _drop, ...rest } =
draft.draft.art ?? {};
draft.patch({
art:
key.length > 0
? { ...rest, steamGridDbKey: key }
: rest,
});
}}
/>
</SettingsRow>
) : null}
</SettingsGroup>
<SettingsGroup
title="Sync"
description="When and how the scanner reconciles your folders into the library."
>
<SettingsRow
label="Watch folders"
description="React to file changes immediately (polling stays on as the fallback)."
>
<Switch
checked={resolved?.sync.watch ?? true}
onCheckedChange={(checked) =>
patchSync({ watch: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Poll interval"
description="Minutes between full re-scans."
>
<InputNumber
className="w-28"
min={1}
value={resolved?.sync.pollMinutes ?? 15}
onChange={(pollMinutes) => patchSync({ pollMinutes })}
/>
</SettingsRow>
<SettingsRow
label="Debounce"
description="Milliseconds to wait after a file event before re-scanning."
>
<InputNumber
className="w-28"
min={0}
step={100}
value={resolved?.sync.debounceMs ?? 2000}
onChange={(debounceMs) => patchSync({ debounceMs })}
/>
</SettingsRow>
<SettingsRow
label="Warn threshold"
description="Warn when the library grows past this many entries."
>
<InputNumber
className="w-28"
min={1}
value={resolved?.sync.warnEntries ?? 2000}
onChange={(warnEntries) => patchSync({ warnEntries })}
/>
</SettingsRow>
<SettingsRow
label="Max entries"
description="Hard cap — entries beyond it are truncated."
>
<InputNumber
className="w-28"
min={1}
value={resolved?.sync.maxEntries ?? 5000}
onChange={(maxEntries) => patchSync({ maxEntries })}
/>
</SettingsRow>
<SettingsRow
label="Close game on stream end"
description="Stop the emulator when the streaming session ends."
>
<Switch
checked={resolved?.sync.closeOnEnd ?? false}
onCheckedChange={(checked) =>
patchSync({ closeOnEnd: checked === true })
}
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup
title="Files"
description="Where this plugin keeps its state on the host."
>
<Gate
result={status}
skeleton={<Skeleton className="h-20" />}
retry={retryStatus}
>
{(s) => (
<CodeBlock copy>
{`dir: ${s.paths.dir}\nconfig: ${s.paths.config}\ncache: ${s.paths.cache}`}
</CodeBlock>
)}
</Gate>
</SettingsGroup>
<SaveBar
dirty={draft.dirty}
saving={draft.saving}
onSave={draft.save}
onDiscard={draft.discard}
/>
</div>
)}
</Gate>
);
};
+14
View File
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SourcesPage } from "./sources";
const meta = {
title: "Pages/Sources",
component: SourcesPage,
args: { navigate: () => {} },
} satisfies Meta<typeof SourcesPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
+153
View File
@@ -0,0 +1,153 @@
// Sources — subscribes configAtom (via the draft) + platformsAtom; mutates saveConfig
// through the draft's save(). PathListEditor edits draft.roots in place.
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
import type { RawConfig } from "@rom-manager/contract";
import { PathListEditor } from "@unom/app-ui/path-list-editor";
import { SettingsGroup } from "@unom/app-ui/settings";
import { AnimatedButton } from "@unom/ui/button";
import { EmptyState } from "@unom/ui/empty-state";
import { InputText } from "@unom/ui/form/input-text";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unom/ui/form/select";
import { Skeleton } from "@unom/ui/skeleton";
import { Spinner } from "@unom/ui/spinner";
import { AsyncResult } from "effect/unstable/reactivity";
import { FolderOpen } from "lucide-react";
import { useState } from "react";
import { Gate } from "@/components/gate";
import { configAtom, platformsAtom } from "@/data/atoms";
import { useConfigDraft } from "@/data/drafts";
import type { PageProps } from "@/routes";
type RomRootRaw = NonNullable<RawConfig["roots"]>[number];
const PageSkeleton = () => (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64" />
</div>
);
const PlatformSelect = ({
value,
onChange,
}: {
value: string;
onChange: (platform: string) => void;
}) => {
const platforms = useAtomValue(platformsAtom);
const items = AsyncResult.isSuccess(platforms) ? platforms.value : [];
return (
<Select value={value === "" ? undefined : value} onValueChange={onChange}>
<SelectTrigger size="sm" className="w-44 shrink-0">
<SelectValue placeholder="Platform" />
</SelectTrigger>
<SelectContent>
{items.map((platform) => (
<SelectItem key={platform.id} value={platform.id}>
{platform.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
/**
* Comma-separated glob editor. Local text state so typing "a, b" isn't normalized
* under the cursor; every keystroke still commits the parsed globs to the draft.
*/
const ExcludesInput = ({
value,
onCommit,
}: {
value: ReadonlyArray<string>;
onCommit: (globs: ReadonlyArray<string>) => void;
}) => {
const [text, setText] = useState(() => value.join(", "));
return (
<InputText
value={text}
placeholder="exclude globs, comma-separated"
className="w-56"
onChange={(event) => {
setText(event.target.value);
onCommit(
event.target.value
.split(",")
.map((glob) => glob.trim())
.filter((glob) => glob.length > 0),
);
}}
/>
);
};
export const SourcesPage = (_: PageProps) => {
const config = useAtomValue(configAtom);
const retry = useAtomRefresh(configAtom);
const draft = useConfigDraft();
return (
<Gate result={config} skeleton={<PageSkeleton />} retry={retry}>
{() => (
<SettingsGroup
title="ROM folders"
description="Each folder is scanned for one platform's ROMs. Exclude globs skip files inside it."
footer={
<AnimatedButton
disabled={!draft.dirty || draft.saving}
onClick={draft.save}
whileTap={{ scale: 0.97 }}
>
{draft.saving ? <Spinner className="size-4" /> : null}
Save
</AnimatedButton>
}
>
<PathListEditor<RomRootRaw>
items={draft.draft.roots ?? []}
onChange={(roots) => draft.patch({ roots })}
path={{
get: (root) => root.dir,
set: (root, dir) => ({ ...root, dir }),
placeholder: "/path/to/roms",
}}
makeItem={(dir) => ({ dir, platform: "" })}
addLabel="Add ROM folder"
empty={
<EmptyState
icon={FolderOpen}
title="No ROM folders"
description="Add the folder your ROMs live in — one folder per platform."
/>
}
renderExtras={(root, _index, update) => (
<>
<PlatformSelect
value={root.platform}
onChange={(platform) => update({ ...root, platform })}
/>
<ExcludesInput
value={root.excludes ?? []}
onCommit={(globs) => {
// `excludes` is an optionalKey — omit it entirely when empty.
const { excludes: _drop, ...rest } = root;
update(
globs.length > 0 ? { ...rest, excludes: globs } : rest,
);
}}
/>
</>
)}
/>
</SettingsGroup>
)}
</Gate>
);
};
+15
View File
@@ -0,0 +1,15 @@
// The flat route list, shared by App.tsx and the pages (kept out of App.tsx so pages
// can type their `navigate` prop without importing the shell).
export const ROUTES = [
"overview",
"library",
"sources",
"emulators",
"settings",
] as const;
export type Route = (typeof ROUTES)[number];
export type PageProps = {
readonly navigate: (route: Route) => void;
};
+16 -264
View File
@@ -1,267 +1,19 @@
/* 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;
}
/* The plugin UI's single stylesheet entry. The kit theme.css carries the console's
* violet identity (shadcn + @unom token vocabularies, Penner easings, `.dark` palette)
* so the embedded iframe is indistinguishable from the console around it. Do NOT import
* @unom/ui/styles/theme.css that is the library's own neutral palette. */
@import "tailwindcss";
@import "tw-animate-css";
@import "@punktfunk/plugin-kit/theme.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
/* Scan the design system's built output so its class names survive Tailwind's purge.
* Bare directory globs on purpose rebased file globs proved flaky. */
@source "../node_modules/@unom/ui/dist";
@source "../node_modules/@unom/app-ui/dist";
* {
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;
@apply bg-background font-sans text-foreground antialiased;
}
+144
View File
@@ -0,0 +1,144 @@
// Capture page screenshots from the built Storybook — the same harness the console
// uses (punktfunk/web/tools/screenshots.mjs): headless Chromium over storybook-static,
// every Pages/* + Shell/* story, rendered entirely from fixtures. No host required.
//
// bun run build-storybook # produce ./storybook-static
// bun run screenshots # → ./screenshots/<story-id>.png
//
// Env knobs: OUT (output dir), STORYBOOK_STATIC (input dir), SETTLE (ms after the
// page looks ready, default 600), WIDTH/HEIGHT/SCALE (viewport, default 1440x900@2x),
// ONLY (comma-separated story-id substring filter).
import { existsSync } from "node:fs";
import { mkdir, readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { extname, join, normalize, resolve } from "node:path";
import { chromium } from "playwright";
const ROOT = resolve(process.env.STORYBOOK_STATIC ?? "storybook-static");
const OUT = resolve(process.env.OUT ?? "screenshots");
const SETTLE = Number(process.env.SETTLE ?? 600);
const WIDTH = Number(process.env.WIDTH ?? 1440);
const HEIGHT = Number(process.env.HEIGHT ?? 900);
const SCALE = Number(process.env.SCALE ?? 2);
const ONLY = (process.env.ONLY ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Only the page-level + shell stories make sense as screenshots.
const TITLE_PREFIXES = ["Pages/", "Shell/"];
const MIME = {
".html": "text/html",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json",
".ico": "image/x-icon",
};
function staticServer(rootDir) {
return createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
let path = decodeURIComponent(url.pathname);
if (path.endsWith("/")) path += "index.html";
// Contain the path to rootDir (no traversal).
const filePath = normalize(join(rootDir, path));
if (!filePath.startsWith(rootDir)) {
res.writeHead(403).end();
return;
}
const body = await readFile(filePath);
res.writeHead(200, {
"content-type": MIME[extname(filePath)] ?? "application/octet-stream",
});
res.end(body);
} catch {
res.writeHead(404).end();
}
});
}
async function listStories(rootDir) {
const indexPath = join(rootDir, "index.json");
if (!existsSync(indexPath)) {
throw new Error(
`${indexPath} not found — run \`bun run build-storybook\` first`,
);
}
const index = JSON.parse(await readFile(indexPath, "utf8"));
const entries = Object.values(index.entries ?? index.stories ?? {});
return entries
.filter((e) => e.type === "story" || e.type === undefined)
.filter((e) => TITLE_PREFIXES.some((p) => (e.title ?? "").startsWith(p)))
.filter((e) => ONLY.length === 0 || ONLY.some((f) => e.id.includes(f)))
.sort((a, b) => a.id.localeCompare(b.id));
}
async function main() {
if (!existsSync(ROOT)) {
throw new Error(
`${ROOT} not found — run \`bun run build-storybook\` first`,
);
}
const stories = await listStories(ROOT);
if (stories.length === 0)
throw new Error("no Pages/* or Shell/* stories found");
await mkdir(OUT, { recursive: true });
const server = staticServer(ROOT);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
const browser = await chromium.launch({
args: ["--force-color-profile=srgb"],
});
const context = await browser.newContext({
viewport: { width: WIDTH, height: HEIGHT },
deviceScaleFactor: SCALE,
colorScheme: "dark",
});
let ok = 0;
for (const story of stories) {
const page = await context.newPage();
const url = `http://127.0.0.1:${port}/iframe.html?id=${encodeURIComponent(
story.id,
)}&viewMode=story`;
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
// Story root mounted with real content.
await page.waitForSelector("#storybook-root > *", { timeout: 20_000 });
// Web fonts settled (else text reflows / falls back in the shot).
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(SETTLE);
const file = join(OUT, `${story.id}.png`);
await page.screenshot({ path: file });
console.log(`ok ${story.id} -> ${file}`);
ok++;
} catch (e) {
console.warn(`FAIL ${story.id}: ${e.message}`);
} finally {
await page.close();
}
}
await browser.close();
await new Promise((r) => server.close(r));
console.log(`\n${ok}/${stories.length} stories captured -> ${OUT}`);
if (ok === 0) process.exit(1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+9 -4
View File
@@ -1,15 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": []
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
"include": ["src", ".storybook", "vite.config.ts"]
}

Some files were not shown because too many files have changed in this diff Show More