Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d830b7133d | |||
| 838cdc4339 | |||
| e9a4d3a996 | |||
| f3e80f10ec | |||
| bcff17a718 | |||
| 8856df7e8d | |||
| e6144773d7 | |||
| 899028e20f | |||
| d6cf5f3d36 | |||
| e78df91925 | |||
| e84cc89222 | |||
| 14709d4062 | |||
| 33e91e4d00 |
+55
-28
@@ -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,5 +2,7 @@ node_modules
|
||||
dist
|
||||
ui/dist
|
||||
ui/node_modules
|
||||
ui/storybook-static
|
||||
ui/screenshots
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+3
-4
@@ -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/"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
) {}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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 }),
|
||||
);
|
||||
};
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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", () => {
|
||||
@@ -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"]
|
||||
}
|
||||
Vendored
+6
@@ -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
@@ -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
@@ -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({});
|
||||
@@ -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";
|
||||
@@ -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
@@ -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}`);
|
||||
@@ -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
@@ -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();
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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()),
|
||||
});
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" } };
|
||||
+54
-60
@@ -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)}
|
||||
<PluginShell
|
||||
nav={NAV}
|
||||
active={route}
|
||||
onNavigate={(id) => navigate(id as Route)}
|
||||
standaloneHeader={embedded ? undefined : <StandaloneHeader />}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Page />
|
||||
</div>
|
||||
</ToastHost>
|
||||
<Page navigate={navigate} />
|
||||
</PluginShell>
|
||||
);
|
||||
};
|
||||
|
||||
-162
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}) {}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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] ?? "";
|
||||
@@ -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);
|
||||
};
|
||||
+13
-2
@@ -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>
|
||||
<RegistryProvider>
|
||||
<App />
|
||||
</RegistryProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" } };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" } };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" } };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" } };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user