Reviewed-on: #7
@punktfunk/plugin-rom-manager
A Punktfunk plugin that scans your ROM folders, maps them to
emulators, fetches box art, and reconciles everything into the host game library — so your retro games
appear automatically in the Punktfunk web console, native clients, and Moonlight /applist, each
launchable and covered in art. It ships a web UI that lives inside the Punktfunk console (no second
password, no second port).
Think of it as Steam ROM Manager for Punktfunk: it even uses the same art source (SteamGridDB) when you give it an API key.
Requirements
- A Punktfunk host (Linux or Windows) with the scripting runner (
punktfunk-scripting) enabled. - Bun (the runner is Bun).
@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:
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.
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>/plugin-state/rom-manager/config.json. A minimal example:
{
"roots": [
{ "dir": "/mnt/roms/snes", "platform": "snes" },
{ "dir": "/mnt/roms/ps1", "platform": "ps1", "excludes": ["*.sav", "bios/**"] }
],
"art": { "provider": "auto", "steamGridDbKey": "" }
}
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:
bunx punktfunk-plugin-rom-manager scan # what the scanner finds
bunx punktfunk-plugin-rom-manager detect # which emulators are installed
bunx punktfunk-plugin-rom-manager preview # the desired library (no host write)
bunx punktfunk-plugin-rom-manager sync # reconcile into the live library
Box art
Two sources, selected by art.provider:
| Provider | Key? | Art types | Notes |
|---|---|---|---|
steamgriddb |
yes | portrait + hero + logo + header | What Steam ROM Manager uses; best coverage + fuzzy title match. Free key from steamgriddb.com › profile › preferences. |
libretro |
no | box-art portrait only | Keyless libretro-thumbnails; works out of the box. |
auto (default) |
— | SteamGridDB if a key is set, else libretro |
Art URLs are handed to the host as-is; native clients/console fetch them directly (so clients need
internet for covers), and Moonlight covers are proxied host-side. Verdicts are cached in
cache.json and never re-probed unless you change provider/key.
Platforms & emulators
~25 built-in platforms (NES, SNES, N64, GameCube, Wii, Switch, GB/GBC/GBA, NDS, 3DS, PS1/2/PSP, Genesis, Master System, Game Gear, Saturn, Dreamcast, PC Engine, Neo Geo Pocket, WonderSwan, Lynx, Atari 2600/7800, Xbox). Arcade (MAME/FBNeo romset versioning) is intentionally out of scope.
Emulators are detected best-effort (PATH → Flatpak → known install paths): RetroArch (+ cores),
Dolphin, PCSX2, DuckStation, PPSSPP, mGBA, melonDS, Flycast, xemu, plus Azahar/Ryujinx. Any platform's
emulator, core, and args are overridable per-platform and per-game (config or UI). Missing an
emulator? Add a custom-* template.
Disc games are folded so a set is one entry: .m3u playlists absorb their discs, .cue/.gdi
sheets hide their tracks, .chd stands alone.
The UI
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. 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
- ROM filenames are untrusted input that ends up in a
sh -c/cmd.exe /cstring run as the host user. Every path goes through strict per-OS quoting (src/quote.ts); on Windows, names containingcmd.exemetacharacters (" % ! ^ & | < >) are refused rather than risk injection. config.jsonlives 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 — this repo is the plugin blueprint
rom-manager is the reference consumer of
@punktfunk/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. |
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)
bun run storybook # the UI workbench on the same fixtures (port 6013)
bun run screenshots # build Storybook, then shoot every story in both themes
cd plugin && bun run dev # auth-free dev API on :5885 …
cd ui && bun run dev:live # … and the UI proxied against it
The UI workbench
bun run storybook is where the UI is designed and reviewed. Every story renders the real
component or page against the same in-browser mock transport the dev loop uses — no host, no
network, no fixtures duplicated per story.
| Foundations/ | Tokens and primitives on one page, read live off the DOM. The palette is the console's (@punktfunk/plugin-kit/theme.css), not @unom/ui's own — check a colour here before using it. |
| Components/ | Gate (the one loading/error convention), SaveBar, and the two editor dialogs, each in its interesting states. |
| Pages/ | The five pages, one story per fixture scenario. |
| Shell/App | The whole plugin inside PluginShell, as the console embeds it. |
Two toolbar controls change what every story shows without editing it: Theme (dark/light — the
console pins dark, but the light half of the palette is real and nothing else exercises it) and
Scenario (healthy / firstRun / syncing / errors, overriding the story's own default).
The Viewport control carries the widths that actually exist in the field — the plugin is an
iframe inside the console, so its viewport is the console's content column, never the window.
bun run screenshots writes ui/screenshots/<theme>/<story-id>.png for every story. Shoot it
before a UI change and after: the diff is the review. Both the stories and the shots are fully
offline — fixture cover art is inline SVG precisely so a missing network cannot masquerade as a
visual regression. CI builds Storybook on every push, so a story that stops rendering fails there.
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
MIT OR Apache-2.0.