Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94f18e38b4 | |||
| d830b7133d | |||
| 838cdc4339 | |||
| e9a4d3a996 | |||
| f3e80f10ec | |||
| bcff17a718 | |||
| 8856df7e8d | |||
| e6144773d7 | |||
| 899028e20f | |||
| d6cf5f3d36 | |||
| e78df91925 | |||
| e84cc89222 | |||
| 14709d4062 |
+52
-24
@@ -1,55 +1,83 @@
|
||||
# CI for @punktfunk/plugin-rom-manager (Gitea Actions). Installs resolve `@punktfunk/*` and `@unom/*`
|
||||
# (the SPA's design system) from the Gitea npm registry via the bunfig scope maps; `effect` comes from
|
||||
# npm. The GITEA_NPM_TOKEN secret authenticates registry reads. 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: Registry auth
|
||||
run: echo "//git.unom.io/api/packages/unom/npm/:_authToken=${{ secrets.GITEA_NPM_TOKEN }}" > ~/.npmrc
|
||||
- name: Install
|
||||
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 + SPA
|
||||
- name: Build backend bundle + SPA
|
||||
working-directory: plugin
|
||||
run: bun run build:all
|
||||
- name: Typecheck (UI)
|
||||
working-directory: ui
|
||||
run: bunx tsc --noEmit
|
||||
- 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: Registry auth
|
||||
run: echo "//git.unom.io/api/packages/unom/npm/:_authToken=${{ secrets.GITEA_NPM_TOKEN }}" > ~/.npmrc
|
||||
- name: Install
|
||||
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: Build (backend + SPA)
|
||||
run: bun run build:all
|
||||
- name: Publish to the Gitea npm registry
|
||||
run: npm publish --registry https://git.unom.io/api/packages/unom/npm/
|
||||
- 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
|
||||
|
||||
@@ -15,7 +15,7 @@ even uses the same art source (SteamGridDB) when you give it an API key.
|
||||
|
||||
- A Punktfunk host (Linux or Windows) with the **scripting runner** (`punktfunk-scripting`) enabled.
|
||||
- [Bun](https://bun.sh) (the runner is Bun).
|
||||
- `@punktfunk/host` **≥ 0.1.1** — it provides the plugin-UI surface (`servePluginUi`) and discovers
|
||||
- `@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
|
||||
@@ -39,12 +39,12 @@ Enable-ScheduledTask PunktfunkScripting # Windows
|
||||
|
||||
Open the Punktfunk console — a **ROM Manager** entry appears in the nav. That's it.
|
||||
|
||||
> **Headless / host-only boxes** without the console can use the [standalone UI](#standalone-ui) or
|
||||
> just drop a `config.json` (below) — the sync engine is fully functional from the file alone.
|
||||
> **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
|
||||
{
|
||||
@@ -56,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
|
||||
@@ -95,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
|
||||
|
||||
@@ -125,24 +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 # backend deps (@punktfunk/host + effect) from the Gitea registry
|
||||
bun test # engine unit tests (pure core: quoting, scanner, reconcile, art)
|
||||
bun run typecheck
|
||||
bun run build:all # backend → dist/ (tsc), SPA → dist/ui/ (Vite)
|
||||
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
|
||||
```
|
||||
|
||||
- The backend is plain `tsc` output — it depends on `@punktfunk/host` + `effect` at runtime (shared
|
||||
with the runner and other plugins, not bundled).
|
||||
- The **SPA** (`ui/`) uses the console's design system (`@unom/ui` + `@unom/style` + Tailwind v4) so it
|
||||
reads as family. Those are **build-time only** — the SPA compiles to static assets in `dist/ui`, so
|
||||
the published plugin carries no `@unom` runtime dependency. Installing them needs the `@unom` scope in
|
||||
your `~/.npmrc` (with the Gitea token); CI needs the same token as a secret.
|
||||
|
||||
Requires `@punktfunk/host` ≥ 0.1.1.
|
||||
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
|
||||
|
||||
|
||||
+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,130 @@
|
||||
// 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.
|
||||
//
|
||||
// TRAP, measured (see plugin/test/wire.test.ts, which pins it): the HttpApi RESPONSE path
|
||||
// serialises a present-but-`undefined` key as `null`, where the plain Schema encoder omits
|
||||
// it. So a `Schema.optional(X)` field the server assembles as `{ k: undefined }` goes out
|
||||
// as `"k":null` — which `X | undefined` then REFUSES when the client decodes it. It fails
|
||||
// silently in the SSE feed (`sseAtom` drops schema-invalid frames, so the page just stops
|
||||
// updating) and loudly in the typed AtomHttpApi client.
|
||||
//
|
||||
// The rule here: **nothing on this wire is `undefined`.** Server-assembled fields are
|
||||
// `Schema.NullOr` with an explicit null; fields that also round-trip through cache.json use
|
||||
// `Schema.optional(Schema.NullOr(...))` so an older cache (absent keys) still decodes while
|
||||
// the wire's null is accepted too.
|
||||
import { Schema } from "effect";
|
||||
|
||||
/** Absent | undefined | null on the way in, `null` on the way out — see the trap above. */
|
||||
const nullish = <S extends Schema.Top>(schema: S) =>
|
||||
Schema.optional(Schema.NullOr(schema));
|
||||
|
||||
/** 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,
|
||||
// `nullish`, not plain NullOr: these also live in cache.json, and a cache written by
|
||||
// 0.3.1 has the keys ABSENT — requiring them would fail the whole cache decode and
|
||||
// silently discard every art verdict on upgrade.
|
||||
contested: nullish(Schema.Boolean),
|
||||
exeToken: Schema.String,
|
||||
exePath: nullish(Schema.String),
|
||||
appId: nullish(Schema.String),
|
||||
via: Schema.Literals(["path", "file", "flatpak"]),
|
||||
coresDir: nullish(Schema.String),
|
||||
cores: nullish(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,
|
||||
// Assembled fresh by the engine on every read and never persisted, so these can be
|
||||
// strict: an explicit null on the wire, never an absent or undefined key.
|
||||
lastSync: Schema.NullOr(LastSync),
|
||||
lastReport: Schema.NullOr(SyncReport),
|
||||
detectedAt: Schema.NullOr(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
-44
@@ -1,53 +1,19 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-rom-manager",
|
||||
"version": "0.2.0",
|
||||
"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",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"punktfunk-plugin-rom-manager": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"publishConfig": {
|
||||
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"build:ui": "cd ui && bun install --silent && bun run build",
|
||||
"build:all": "bun run build && bun run build:ui",
|
||||
"sync": "bun src/cli.ts sync",
|
||||
"scan": "bun src/cli.ts scan",
|
||||
"detect": "bun src/cli.ts detect",
|
||||
"prepublishOnly": "bun run build:all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@punktfunk/host": "^0.1.0",
|
||||
"effect": "^4.0.0-beta.98"
|
||||
"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 ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.2",
|
||||
"@types/bun": "^1.3.0",
|
||||
"typescript": "^5.9.3"
|
||||
"@biomejs/biome": "^2.5.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-rom-manager",
|
||||
"version": "0.3.2",
|
||||
"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 \u2014 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,242 @@
|
||||
// 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,
|
||||
// Explicit nulls, never undefined — see the trap note in contract/domain.ts.
|
||||
lastSync: engineStatus.lastSync ?? null,
|
||||
lastReport: engineStatus.lastReport ?? null,
|
||||
detectedAt: c.detect?.at ?? null,
|
||||
paths: {
|
||||
dir: nodePath.dirname(cfg.path),
|
||||
config: cfg.path,
|
||||
cache: cache.path,
|
||||
},
|
||||
} satisfies EngineStatus;
|
||||
});
|
||||
|
||||
return {
|
||||
engine,
|
||||
preview: computeWith(false),
|
||||
detect,
|
||||
status,
|
||||
// A fresh subscriber gets the CURRENT status immediately, then one frame per
|
||||
// engine transition — so the console's live view is right the moment it
|
||||
// connects, rather than stale until the next sync. Each frame is a full
|
||||
// snapshot, so the sliver between the two stages costs nothing.
|
||||
statusChanges: Stream.concat(
|
||||
Stream.fromEffect(status),
|
||||
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,178 @@
|
||||
// The wire contract: whatever the server ENCODES, the client must be able to DECODE.
|
||||
//
|
||||
// This exists because a whole class of bug is invisible to unit tests of either side.
|
||||
// `Schema.optional(X)` describes `X | undefined`, and the plain Schema encoder omits a
|
||||
// present-but-undefined key — but the HttpApi RESPONSE path serialises it as `null`, which
|
||||
// `X | undefined` then refuses on the way back in. The result shipped in 0.3.1: a
|
||||
// never-synced host's `GET /api/status` returned `"lastSync":null` and the Overview page
|
||||
// could not decode it, and `POST /api/detect` on a box with any emulator installed returned
|
||||
// `"contested":null` and the Emulators page could not decode that.
|
||||
//
|
||||
// So these tests drive the REAL `makeApi` handler stack through `toWebHandler` and decode
|
||||
// the bytes with the shared contract schemas. A unit test of the engine, or of the schema
|
||||
// in isolation, passes happily while the product is broken.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
CacheStore,
|
||||
ConfigService,
|
||||
SyncEngine,
|
||||
} from "@punktfunk/plugin-kit";
|
||||
import {
|
||||
type DetectedEmulator,
|
||||
EmulatorsPayload,
|
||||
EngineStatus,
|
||||
type PreviewResult,
|
||||
type RomConfigSchema,
|
||||
resolveConfig,
|
||||
type SyncReport,
|
||||
} from "@rom-manager/contract";
|
||||
import { Effect, Schema, Stream } from "effect";
|
||||
import { HttpRouter } from "effect/unstable/http";
|
||||
import { makeApi } from "../src/services/api.js";
|
||||
import type { CacheSchema } from "../src/services/cache.js";
|
||||
import type { RomSyncService } from "../src/services/engine.js";
|
||||
|
||||
const EMPTY_REPORT: SyncReport = {
|
||||
considered: 0,
|
||||
included: 0,
|
||||
skipped: [],
|
||||
excluded: [],
|
||||
warnings: [],
|
||||
truncated: 0,
|
||||
perPlatform: {},
|
||||
overWarn: false,
|
||||
};
|
||||
|
||||
/** A never-synced, never-detected host — what every fresh install looks like. */
|
||||
const FIRST_RUN_STATUS: EngineStatus = {
|
||||
rootsConfigured: 0,
|
||||
os: "linux",
|
||||
artProvider: null,
|
||||
syncing: false,
|
||||
lastSync: null,
|
||||
lastReport: null,
|
||||
detectedAt: null,
|
||||
paths: { dir: "/s", config: "/s/config.json", cache: "/s/cache.json" },
|
||||
};
|
||||
|
||||
/**
|
||||
* A detection result exactly as `detectAll` builds it: the optional keys are PRESENT with
|
||||
* `undefined` values, not absent. That distinction is the whole bug — a value round-tripped
|
||||
* through cache.json has them absent instead, and absent keys encode correctly, which is
|
||||
* why this only ever broke right after a live detect.
|
||||
*/
|
||||
const FRESHLY_DETECTED: DetectedEmulator = {
|
||||
id: "duckstation",
|
||||
name: "DuckStation",
|
||||
template: "{exe} -batch {rom}",
|
||||
supportsArchives: true,
|
||||
contested: undefined,
|
||||
exeToken: "duckstation-qt",
|
||||
exePath: "/usr/bin/duckstation-qt",
|
||||
appId: undefined,
|
||||
via: "path",
|
||||
coresDir: undefined,
|
||||
cores: undefined,
|
||||
};
|
||||
|
||||
const stubDeps = () => {
|
||||
const engine = {
|
||||
sync: () => Effect.succeed({ _tag: "Unchanged", report: EMPTY_REPORT }),
|
||||
status: Effect.succeed({ syncing: false }),
|
||||
changes: Stream.empty,
|
||||
start: Effect.void,
|
||||
reconfigure: Effect.void,
|
||||
} as unknown as SyncEngine<SyncReport>;
|
||||
|
||||
const sync: RomSyncService = {
|
||||
engine,
|
||||
preview: Effect.succeed({
|
||||
entries: [],
|
||||
report: EMPTY_REPORT,
|
||||
} satisfies PreviewResult),
|
||||
detect: () => Effect.succeed([FRESHLY_DETECTED]),
|
||||
status: Effect.succeed(FIRST_RUN_STATUS),
|
||||
statusChanges: Stream.empty,
|
||||
};
|
||||
|
||||
const config = {
|
||||
load: Effect.succeed(resolveConfig({})),
|
||||
loadRaw: Effect.succeed({}),
|
||||
saveRaw: () => Effect.succeed(resolveConfig({})),
|
||||
changes: Stream.empty,
|
||||
path: "/s/config.json",
|
||||
} as unknown as ConfigService<typeof RomConfigSchema>;
|
||||
|
||||
const cache = {
|
||||
get: Effect.succeed({ art: {} }),
|
||||
modify: () => Effect.succeed(undefined),
|
||||
update: () => Effect.void,
|
||||
path: "/s/cache.json",
|
||||
} as unknown as CacheStore<typeof CacheSchema>;
|
||||
|
||||
return { sync, config, cache };
|
||||
};
|
||||
|
||||
/** Drive one request through the real handler stack and hand back the parsed body. */
|
||||
const call = async (method: string, path: string): Promise<unknown> => {
|
||||
const { handler, dispose } = HttpRouter.toWebHandler(makeApi(stubDeps()));
|
||||
try {
|
||||
const response = await handler(
|
||||
new Request(`http://localhost${path}`, { method }),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
return await response.json();
|
||||
} finally {
|
||||
await dispose();
|
||||
}
|
||||
};
|
||||
|
||||
describe("the status endpoint survives a round trip", () => {
|
||||
test("a never-synced host's status decodes on the client", async () => {
|
||||
const body = await call("GET", "/api/status");
|
||||
// The assertion that matters: the UI's own decoder accepts the server's bytes.
|
||||
expect(() => Schema.decodeUnknownSync(EngineStatus)(body)).not.toThrow();
|
||||
});
|
||||
|
||||
test("absent values arrive as null, not as a missing key", async () => {
|
||||
// Pinning the shape, not just its decodability: the UI branches on `=== null`.
|
||||
const body = (await call("GET", "/api/status")) as Record<string, unknown>;
|
||||
expect(body.lastSync).toBeNull();
|
||||
expect(body.lastReport).toBeNull();
|
||||
expect(body.detectedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the emulator endpoints survive a round trip", () => {
|
||||
test("a freshly detected emulator decodes on the client", async () => {
|
||||
const body = await call("POST", "/api/detect");
|
||||
expect(() =>
|
||||
Schema.decodeUnknownSync(EmulatorsPayload)(body),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("the cached-read path decodes too", async () => {
|
||||
const body = await call("GET", "/api/emulators");
|
||||
expect(() =>
|
||||
Schema.decodeUnknownSync(EmulatorsPayload)(body),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the trap itself", () => {
|
||||
test("the HttpApi response path turns undefined into null", async () => {
|
||||
// Documents WHY the contract avoids `Schema.optional(X)` on this API. If a future
|
||||
// effect release stops doing this, the schemas can relax — this test will say so.
|
||||
const body = (await call("POST", "/api/detect")) as {
|
||||
detected: Array<Record<string, unknown>>;
|
||||
};
|
||||
const emulator = body.detected[0]!;
|
||||
expect(emulator.contested).toBeNull();
|
||||
expect(emulator.appId).toBeNull();
|
||||
// …while the plain Schema encoder omits the very same keys.
|
||||
const encoded = Schema.encodeUnknownSync(EngineStatus)({
|
||||
...FIRST_RUN_STATUS,
|
||||
}) as Record<string, unknown>;
|
||||
expect(encoded).toHaveProperty("lastSync");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
-1373
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
# The @unom design-system packages (@unom/ui, @unom/style) live on the Gitea registry. Auth comes
|
||||
# from your ~/.npmrc (`//git.unom.io/api/packages/unom/npm/:_authToken`); CI needs the same token as a
|
||||
# secret. These are BUILD-time only — the SPA compiles to static assets in ../dist/ui, so the published
|
||||
# plugin has no @unom runtime dependency.
|
||||
[install.scopes]
|
||||
"@unom" = "https://git.unom.io/api/packages/unom/npm/"
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
<!doctype html>
|
||||
<!-- 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>
|
||||
|
||||
+22
-13
@@ -2,33 +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. Uses @unom/ui so it reads as family with the punktfunk console.",
|
||||
"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": {
|
||||
"@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.8.16",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"@unom/ui": "^0.9.1",
|
||||
"effect": "4.0.0-beta.99",
|
||||
"lucide-react": "^0.469.0",
|
||||
"motion": "^12.42.2",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^2.6.1"
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rom-manager/contract": "workspace:*",
|
||||
"@storybook/react-vite": "^10.4.6",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@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" } };
|
||||
+55
-73
@@ -1,82 +1,64 @@
|
||||
import { Gamepad2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "./lib/utils.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 (
|
||||
<div className="mx-auto max-w-5xl px-6 pb-16 pt-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<span className="grid size-9 place-items-center rounded-card bg-primary/15 text-primary">
|
||||
<Gamepad2 className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">ROM Manager</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan ROMs into your Punktfunk library
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="mt-6 flex flex-wrap gap-1 border-b border-border">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => go(t.id)}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors",
|
||||
t.id === tab
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className="mt-6 flex flex-col gap-6">
|
||||
<Page />
|
||||
</main>
|
||||
</div>
|
||||
<PluginShell
|
||||
nav={NAV}
|
||||
active={route}
|
||||
onNavigate={(id) => navigate(id as Route)}
|
||||
standaloneHeader={embedded ? undefined : <StandaloneHeader />}
|
||||
>
|
||||
<Page navigate={navigate} />
|
||||
</PluginShell>
|
||||
);
|
||||
};
|
||||
|
||||
-165
@@ -1,165 +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;
|
||||
detect?: { linux: string[]; windows: string[] };
|
||||
}
|
||||
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>;
|
||||
/** Operator-added / overriding emulator defs (custom launchers). */
|
||||
emulators?: EmulatorDef[];
|
||||
sync: {
|
||||
pollMinutes: number;
|
||||
watch: boolean;
|
||||
debounceMs: number;
|
||||
dedupeRegions: string[];
|
||||
regionPriority: string[];
|
||||
warnEntries: number;
|
||||
maxEntries: number;
|
||||
closeOnEnd: boolean;
|
||||
};
|
||||
art: {
|
||||
enabled: boolean;
|
||||
provider: "auto" | "steamgriddb" | "libretro";
|
||||
steamGridDbKey?: string;
|
||||
};
|
||||
ui: {
|
||||
standalone: boolean;
|
||||
port: number;
|
||||
bind: string;
|
||||
passwordHash?: string;
|
||||
};
|
||||
devEntry: boolean;
|
||||
}
|
||||
export interface Artwork {
|
||||
portrait?: string | null;
|
||||
hero?: string | null;
|
||||
logo?: string | null;
|
||||
header?: string | null;
|
||||
}
|
||||
export interface Entry {
|
||||
external_id: string;
|
||||
title: string;
|
||||
launch?: { kind: string; value: string } | null;
|
||||
art?: Artwork;
|
||||
prep?: { do: string; undo?: string | null }[];
|
||||
}
|
||||
export interface Skipped {
|
||||
external_id: string;
|
||||
title: string;
|
||||
reason: string;
|
||||
}
|
||||
export interface Report {
|
||||
considered: number;
|
||||
included: number;
|
||||
skipped: Skipped[];
|
||||
excluded: { external_id: string; title: string }[];
|
||||
warnings: string[];
|
||||
truncated: number;
|
||||
perPlatform: Record<string, number>;
|
||||
overWarn: boolean;
|
||||
}
|
||||
export interface Preview {
|
||||
entries: Entry[];
|
||||
report: Report;
|
||||
detected: Detected[];
|
||||
}
|
||||
export interface Status {
|
||||
rootsConfigured: number;
|
||||
os: "linux" | "windows";
|
||||
artProvider: string | null;
|
||||
lastSync?: { fingerprint: string; count: number; at: number };
|
||||
lastReport?: Report;
|
||||
detected?: { at: number; emulators: Detected[] };
|
||||
paths: { dir: string; config: string; cache: string; relConfig: string };
|
||||
syncing: boolean;
|
||||
}
|
||||
|
||||
const api = async <T>(path: string, opts?: RequestInit): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
headers: { "content-type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
|
||||
export const getStatus = () => api<Status>("api/status");
|
||||
export const getConfig = () => api<Config>("api/config");
|
||||
export const putConfig = (config: Config) =>
|
||||
api<Config>("api/config", { method: "PUT", body: JSON.stringify(config) });
|
||||
export const getPreview = () => api<Preview>("api/preview");
|
||||
export const runDetect = () =>
|
||||
api<Detected[]>("api/detect", { method: "POST" });
|
||||
export const runSync = () => api<Report>("api/sync", { method: "POST" });
|
||||
export const getPlatforms = () => api<Platform[]>("api/platforms");
|
||||
export const getEmulators = () =>
|
||||
api<{ defs: EmulatorDef[]; detected: Detected[] }>("api/emulators");
|
||||
|
||||
/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */
|
||||
export const useStatusStream = (): Status | undefined => {
|
||||
const [status, setStatus] = useState<Status>();
|
||||
useEffect(() => {
|
||||
getStatus()
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
try {
|
||||
const es = new EventSource("api/events");
|
||||
es.addEventListener("status", (e) => {
|
||||
try {
|
||||
setStatus(JSON.parse((e as MessageEvent).data));
|
||||
} catch {
|
||||
// ignore malformed frame
|
||||
}
|
||||
});
|
||||
return () => es.close();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
return status;
|
||||
};
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
@@ -1,28 +0,0 @@
|
||||
// A small shadcn-style badge on the shared tokens (pill, brand/muted/state variants).
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-secondary text-secondary-foreground",
|
||||
brand: "border-transparent bg-primary/15 text-primary",
|
||||
outline: "text-muted-foreground",
|
||||
success: "border-success/40 text-success",
|
||||
warn: "border-amber-500/40 text-amber-400",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export const Badge = ({ className, variant, ...props }: BadgeProps) => (
|
||||
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
@@ -1,52 +0,0 @@
|
||||
// Button — the console's exact variant/size vocabulary + brand tokens (pill shape, `--primary` fill),
|
||||
// but a plain <button> rather than @unom/ui's AnimatedButton: the animated one statically imports ~7 MB
|
||||
// of UI click/hover sound assets (fine for the full console, absurd for an embedded iframe). Same look,
|
||||
// no audio. `buttonVariants` here mirrors @unom/ui's so it reads identically.
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-secondary hover:text-secondary-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-secondary hover:text-secondary-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 px-3 text-xs",
|
||||
lg: "h-10 px-8",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = ({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonProps) => (
|
||||
<button
|
||||
type={type}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -1,56 +0,0 @@
|
||||
// Card — the console's card surface (bg-card, brand-violet ring, rounded-card) as a plain element set,
|
||||
// on the shared @unom tokens. We skip @unom/ui's AnimatedCard (motion + material) to keep the bundle
|
||||
// lean; the look matches because the tokens (colour/radius/border) are identical.
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
export const Card = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-card border border-border bg-card text-card-foreground shadow-sm ring-1 ring-accent/30",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CardHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
);
|
||||
|
||||
export const CardTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<h2
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CardDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
|
||||
export const CardContent = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export const CardFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
// shadcn-style input + select on the shared tokens, so form controls match the console's inputs.
|
||||
import type { InputHTMLAttributes, SelectHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
const base =
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50";
|
||||
|
||||
export const Input = ({
|
||||
className,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement>) => (
|
||||
<input className={cn(base, className)} {...props} />
|
||||
);
|
||||
|
||||
export const Select = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectHTMLAttributes<HTMLSelectElement>) => (
|
||||
<select className={cn(base, "cursor-pointer", className)} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
@@ -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 === null) {
|
||||
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 !== null &&
|
||||
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);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts (same as the console). */
|
||||
export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs));
|
||||
+14
-6
@@ -1,13 +1,21 @@
|
||||
import "@fontsource-variable/geist";
|
||||
import { Toaster } from "@unom/ui/toast";
|
||||
import "./styles.css";
|
||||
import { RegistryProvider } from "@effect/atom-react";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.js";
|
||||
import "./styles.css";
|
||||
import { App } from "./App";
|
||||
import { defaultApiMode } from "./mocks/scenario";
|
||||
|
||||
// Zero-host dev loop: install the mock transport BEFORE the first render so the very
|
||||
// first atom reads hit fixtures. The whole branch is dead-code-eliminated from
|
||||
// production builds (import.meta.env.DEV is false), so no fixture code ships.
|
||||
if (import.meta.env.DEV && defaultApiMode === "mock") {
|
||||
await import("./mocks/http");
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<Toaster />
|
||||
<RegistryProvider>
|
||||
<App />
|
||||
</RegistryProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
// 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":
|
||||
// Explicit nulls, exactly as the server sends them — a fixture that omitted
|
||||
// these would no longer resemble the real wire shape.
|
||||
return {
|
||||
rootsConfigured: 0,
|
||||
os: "linux",
|
||||
artProvider: null,
|
||||
syncing: false,
|
||||
lastSync: null,
|
||||
lastReport: null,
|
||||
detectedAt: null,
|
||||
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,381 +0,0 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type Detected,
|
||||
type EmulatorDef,
|
||||
getEmulators,
|
||||
getPlatforms,
|
||||
type Platform,
|
||||
runDetect,
|
||||
} from "../api.js";
|
||||
import { Badge } from "../components/ui/badge.js";
|
||||
import { Button } from "../components/ui/button.js";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card.js";
|
||||
import { Input, Select } from "../components/ui/input.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 load = () =>
|
||||
getEmulators().then((e) => {
|
||||
setDefs(e.defs);
|
||||
setDetected(e.detected);
|
||||
});
|
||||
useEffect(() => {
|
||||
load();
|
||||
getPlatforms()
|
||||
.then(setPlatforms)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const detectedById = new Map(detected.map((d) => [d.id, d]));
|
||||
const onSave = async (next = config) => {
|
||||
if (next && (await save(next))) toast.success("Saved — syncing library");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Detected emulators</CardTitle>
|
||||
<CardDescription>
|
||||
Best-effort detection: PATH, Flatpak, then known install paths.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={detecting}
|
||||
onClick={async () => {
|
||||
setDetecting(true);
|
||||
try {
|
||||
setDetected(await runDetect());
|
||||
toast.success("Re-detected emulators");
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{detecting ? "Detecting…" : "Re-detect"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y divide-border">
|
||||
{defs.map((def) => {
|
||||
const d = detectedById.get(def.id);
|
||||
return (
|
||||
<div
|
||||
key={def.id}
|
||||
className="flex items-center gap-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium">{def.name}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{def.id}
|
||||
</span>
|
||||
{def.contested && <Badge variant="warn">contested</Badge>}
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
{d?.cores?.length ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{d.cores.length} cores
|
||||
</span>
|
||||
) : null}
|
||||
{d ? (
|
||||
<Badge variant="success">detected · {d.via}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">not found</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{config && (
|
||||
<CustomEmulators
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
reloadDefs={load}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Per-platform emulator</CardTitle>
|
||||
<CardDescription>
|
||||
The default emulator + core for each platform. Override to
|
||||
prefer a different emulator (including a custom one) or
|
||||
RetroArch core.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" disabled={saving} onClick={() => onSave()}>
|
||||
{saving ? "Saving…" : "Save & sync"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[420px] overflow-auto">
|
||||
<div className="divide-y divide-border">
|
||||
{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;
|
||||
}) =>
|
||||
setConfig({
|
||||
...config,
|
||||
platformLaunch: {
|
||||
...config.platformLaunch,
|
||||
[p.id]: {
|
||||
emulator: patch.emulator ?? emulator,
|
||||
core: patch.core ?? core,
|
||||
},
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="grid grid-cols-[1fr_180px_180px] items-center gap-3 py-2 text-sm"
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
<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>
|
||||
{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>
|
||||
) : emulator === "retroarch" ? (
|
||||
<Input
|
||||
value={core}
|
||||
placeholder="snes9x"
|
||||
onChange={(e) => setLaunch({ core: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Custom emulator editor (design §6 escape hatch) ────────────────────────────────────────────
|
||||
interface CustomProps {
|
||||
config: import("../api.js").Config;
|
||||
setConfig: (c: import("../api.js").Config) => void;
|
||||
onSave: (c?: import("../api.js").Config) => Promise<void>;
|
||||
saving: boolean;
|
||||
reloadDefs: () => Promise<void>;
|
||||
}
|
||||
|
||||
const blankDraft = () => ({
|
||||
id: "",
|
||||
name: "",
|
||||
template: "{exe} {rom}",
|
||||
supportsArchives: false,
|
||||
detectLinux: "",
|
||||
detectWindows: "",
|
||||
});
|
||||
|
||||
const CustomEmulators = ({
|
||||
config,
|
||||
setConfig,
|
||||
onSave,
|
||||
saving,
|
||||
reloadDefs,
|
||||
}: CustomProps) => {
|
||||
const emulators = config.emulators ?? [];
|
||||
const [draft, setDraft] = useState(blankDraft());
|
||||
|
||||
const add = async () => {
|
||||
const id = draft.id.trim();
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(id)) {
|
||||
toast.error("Emulator id must be kebab-case (e.g. custom-dosbox)");
|
||||
return;
|
||||
}
|
||||
if (emulators.some((e) => e.id === id)) {
|
||||
toast.error(`An emulator with id "${id}" already exists`);
|
||||
return;
|
||||
}
|
||||
if (!draft.template.includes("{rom}")) {
|
||||
toast.error("Template must include {rom}");
|
||||
return;
|
||||
}
|
||||
const def: EmulatorDef = {
|
||||
id,
|
||||
name: draft.name.trim() || id,
|
||||
template: draft.template.trim(),
|
||||
supportsArchives: draft.supportsArchives,
|
||||
detect: {
|
||||
linux: draft.detectLinux
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
windows: draft.detectWindows
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
};
|
||||
const next = { ...config, emulators: [...emulators, def] };
|
||||
setConfig(next);
|
||||
await onSave(next);
|
||||
await reloadDefs();
|
||||
setDraft(blankDraft());
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
const next = { ...config, emulators: emulators.filter((e) => e.id !== id) };
|
||||
setConfig(next);
|
||||
await onSave(next);
|
||||
await reloadDefs();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1.5">
|
||||
<CardTitle>Custom emulators</CardTitle>
|
||||
<CardDescription>
|
||||
Add an emulator Punktfunk doesn't ship a definition for. The template
|
||||
is trusted, run as the host user — use{" "}
|
||||
<code className="font-mono">{"{exe}"}</code>,{" "}
|
||||
<code className="font-mono">{"{rom}"}</code>, optionally{" "}
|
||||
<code className="font-mono">{"{core}"}</code>. Leave detection blank
|
||||
and bake the executable into the template if it isn't on PATH.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{emulators.length > 0 && (
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{emulators.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-3 p-2.5 text-sm">
|
||||
<span className="font-medium">{e.name}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{e.id}
|
||||
</span>
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
||||
{e.template}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto shrink-0"
|
||||
onClick={() => remove(e.id)}
|
||||
aria-label={`Remove ${e.id}`}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 rounded-md border border-dashed border-border p-3">
|
||||
<label className="space-y-1 text-xs text-muted-foreground">
|
||||
id
|
||||
<Input
|
||||
value={draft.id}
|
||||
placeholder="custom-dosbox"
|
||||
onChange={(e) => setDraft({ ...draft, id: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs text-muted-foreground">
|
||||
Name
|
||||
<Input
|
||||
value={draft.name}
|
||||
placeholder="DOSBox"
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="col-span-2 space-y-1 text-xs text-muted-foreground">
|
||||
Template
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={draft.template}
|
||||
placeholder="dosbox {rom} -fullscreen"
|
||||
onChange={(e) => setDraft({ ...draft, template: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs text-muted-foreground">
|
||||
Detect · Linux (comma)
|
||||
<Input
|
||||
value={draft.detectLinux}
|
||||
placeholder="dosbox, flatpak:io.dosbox"
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, detectLinux: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs text-muted-foreground">
|
||||
Detect · Windows (comma)
|
||||
<Input
|
||||
value={draft.detectWindows}
|
||||
placeholder="C:\DOSBox\dosbox.exe"
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, detectWindows: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="col-span-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.supportsArchives}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, supportsArchives: e.target.checked })
|
||||
}
|
||||
/>
|
||||
Can load .zip / .7z archives directly
|
||||
</label>
|
||||
<div className="col-span-2">
|
||||
<Button size="sm" disabled={saving} onClick={add}>
|
||||
<Plus className="size-4" /> Add emulator
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,189 +0,0 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getConfig, getPreview, type Preview, putConfig } from "../api.js";
|
||||
import { Badge } from "../components/ui/badge.js";
|
||||
import { Button } from "../components/ui/button.js";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card.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 refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
getPreview()
|
||||
.then(setPreview)
|
||||
.catch((e) => toast.error(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
useEffect(refresh, [refresh]);
|
||||
|
||||
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.success(exclude ? "Excluded" : "Included");
|
||||
refresh();
|
||||
};
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<Card className="p-6 text-muted-foreground">
|
||||
{loading ? "Scanning…" : "Loading…"}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { entries, report } = preview;
|
||||
const excluded = report.excluded ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Games ({entries.length})</CardTitle>
|
||||
<CardDescription>
|
||||
What will be reconciled into the library. Untick a title to
|
||||
exclude it; covers come from your art provider.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={refresh}
|
||||
>
|
||||
<RefreshCw className="size-4" /> {loading ? "Scanning…" : "Rescan"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entries.length === 0 && excluded.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No games found. Add ROM roots in Setup, then rescan.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[520px] space-y-1 overflow-auto">
|
||||
{entries.map((e) => (
|
||||
<GameRow
|
||||
key={e.external_id}
|
||||
cover={e.art?.portrait ?? undefined}
|
||||
title={e.title}
|
||||
platform={platformOf(e.external_id)}
|
||||
detail={e.launch?.value}
|
||||
included
|
||||
onToggle={() => setExcluded(e.external_id, true)}
|
||||
/>
|
||||
))}
|
||||
{excluded.map((x) => (
|
||||
<GameRow
|
||||
key={x.external_id}
|
||||
title={x.title}
|
||||
platform={platformOf(x.external_id)}
|
||||
detail="excluded"
|
||||
included={false}
|
||||
muted
|
||||
onToggle={() => setExcluded(x.external_id, false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{report.skipped.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle>Skipped ({report.skipped.length})</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowSkipped((s) => !s)}
|
||||
>
|
||||
{showSkipped ? "Hide" : "Show"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
{showSkipped && (
|
||||
<CardContent className="max-h-[320px] space-y-1 overflow-auto text-sm">
|
||||
{report.skipped.map((s) => (
|
||||
<div
|
||||
key={s.external_id}
|
||||
className="flex justify-between gap-4 border-b border-border py-1.5"
|
||||
>
|
||||
<span>{s.title}</span>
|
||||
<span className="text-muted-foreground">{s.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GameRow = ({
|
||||
cover,
|
||||
title,
|
||||
platform,
|
||||
detail,
|
||||
included,
|
||||
muted,
|
||||
onToggle,
|
||||
}: {
|
||||
cover?: string;
|
||||
title: string;
|
||||
platform: string;
|
||||
detail?: string;
|
||||
included: boolean;
|
||||
muted?: boolean;
|
||||
onToggle: () => void;
|
||||
}) => (
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-secondary/40 ${muted ? "opacity-55" : ""}`}
|
||||
>
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt={`${title} cover`}
|
||||
loading="lazy"
|
||||
className="h-14 w-10 shrink-0 rounded object-cover ring-1 ring-border"
|
||||
/>
|
||||
) : (
|
||||
<span className="h-14 w-10 shrink-0 rounded bg-secondary ring-1 ring-border" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{title}</span>
|
||||
<span
|
||||
className="block truncate font-mono text-xs text-muted-foreground"
|
||||
title={detail}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant="outline">{platform}</Badge>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="ml-1"
|
||||
checked={included}
|
||||
onChange={onToggle}
|
||||
aria-label={included ? "Exclude" : "Include"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1,115 +0,0 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPlatforms, type Platform, type RomRoot } from "../api.js";
|
||||
import { Button } from "../components/ui/button.js";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card.js";
|
||||
import { Input, Select } from "../components/ui/input.js";
|
||||
import { useConfig } from "../hooks.js";
|
||||
|
||||
export const Setup = () => {
|
||||
const { config, setConfig, save, saving } = useConfig();
|
||||
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getPlatforms()
|
||||
.then(setPlatforms)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!config)
|
||||
return <Card className="p-6 text-muted-foreground">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)));
|
||||
|
||||
const onSave = async () => {
|
||||
if (await save(config)) toast.success("Saved — syncing library");
|
||||
else toast.error("Save failed");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>ROM roots</CardTitle>
|
||||
<CardDescription>
|
||||
Point each folder at the console platform its files belong to.
|
||||
Shared extensions (.iso, .bin) are resolved by the folder's
|
||||
platform.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" disabled={saving} onClick={onSave}>
|
||||
{saving ? "Saving…" : "Save & sync"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{roots.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No roots yet. Add a folder of ROMs to get started.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{roots.map((root, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-[1fr_180px_1fr_auto] items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
value={root.dir}
|
||||
placeholder="/home/you/roms/snes"
|
||||
onChange={(e) => update(i, { dir: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
value={root.platform}
|
||||
onChange={(e) => update(i, { platform: e.target.value })}
|
||||
>
|
||||
<option value="">— platform —</option>
|
||||
{platforms.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
value={(root.excludes ?? []).join(", ")}
|
||||
placeholder="*.sav, bios/**"
|
||||
onChange={(e) =>
|
||||
update(i, {
|
||||
excludes: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRoots(roots.filter((_, j) => j !== i))}
|
||||
aria-label="Remove root"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRoots([...roots, { dir: "", platform: "" }])}
|
||||
>
|
||||
<Plus className="size-4" /> Add root
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,279 +0,0 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { type Config, runSync, useStatusStream } from "../api.js";
|
||||
import { Badge } from "../components/ui/badge.js";
|
||||
import { Button } from "../components/ui/button.js";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card.js";
|
||||
import { Input, Select } from "../components/ui/input.js";
|
||||
import { useConfig } from "../hooks.js";
|
||||
|
||||
const Stat = ({ n, label }: { n: ReactNode; label: string }) => (
|
||||
<div className="rounded-card bg-secondary/40 px-4 py-3">
|
||||
<div className="text-2xl font-semibold">{n}</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Sync = () => {
|
||||
const status = useStatusStream();
|
||||
const { config, setConfig, save, saving } = useConfig();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const report = status?.lastReport;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Status</CardTitle>
|
||||
<CardDescription>
|
||||
{status?.lastSync
|
||||
? `Last sync ${new Date(status.lastSync.at).toLocaleString()} · ${status.os}`
|
||||
: "Not synced yet."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={syncing || status?.syncing}
|
||||
onClick={async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await runSync();
|
||||
toast.success("Synced");
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
{syncing || status?.syncing ? "Syncing…" : "Sync now"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat n={status?.rootsConfigured ?? "—"} label="ROM roots" />
|
||||
<Stat n={status?.lastSync?.count ?? "—"} label="in library" />
|
||||
<Stat n={report?.skipped.length ?? "—"} label="skipped" />
|
||||
<Stat
|
||||
n={
|
||||
status?.artProvider ? (
|
||||
<Badge variant="brand">{status.artProvider}</Badge>
|
||||
) : (
|
||||
<span className="text-base text-muted-foreground">off</span>
|
||||
)
|
||||
}
|
||||
label="art provider"
|
||||
/>
|
||||
</div>
|
||||
{report?.overWarn && (
|
||||
<p className="text-sm text-amber-400">
|
||||
Large library ({report.included} entries) — reconcile is one
|
||||
full-body PUT; watch host memory.
|
||||
</p>
|
||||
)}
|
||||
{report && report.warnings.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">
|
||||
{report.warnings.length} warning(s)
|
||||
</summary>
|
||||
<ul className="mt-1 list-disc pl-5 text-xs text-muted-foreground">
|
||||
{report.warnings.slice(0, 20).map((w) => (
|
||||
<li key={w}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{config && (
|
||||
<>
|
||||
<ArtCard
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
save={save}
|
||||
saving={saving}
|
||||
/>
|
||||
<SyncOptionsCard
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
save={save}
|
||||
saving={saving}
|
||||
/>
|
||||
{status && (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1.5">
|
||||
<CardTitle>Files</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
{status.paths.config}
|
||||
<br />
|
||||
{status.paths.cache}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardProps {
|
||||
config: Config;
|
||||
setConfig: (c: Config) => void;
|
||||
save: (c: Config) => Promise<boolean>;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
const Field = ({ label, children }: { label: string; children: ReactNode }) => (
|
||||
<label className="space-y-1 text-xs text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
const ArtCard = ({ config, setConfig, save, saving }: CardProps) => {
|
||||
const art = config.art;
|
||||
const set = (patch: Partial<Config["art"]>) =>
|
||||
setConfig({ ...config, art: { ...art, ...patch } });
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Box art</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={async () => (await save(config)) && toast.success("Saved")}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={art.enabled}
|
||||
onChange={(e) => set({ enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<Field label="Provider">
|
||||
<Select
|
||||
className="w-56"
|
||||
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>
|
||||
</Field>
|
||||
<Field label="SteamGridDB API key">
|
||||
<Input
|
||||
className="w-80"
|
||||
type="password"
|
||||
value={art.steamGridDbKey ?? ""}
|
||||
placeholder="steamgriddb.com › profile › preferences"
|
||||
onChange={(e) =>
|
||||
set({ steamGridDbKey: e.target.value || undefined })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SyncOptionsCard = ({ config, setConfig, save, saving }: CardProps) => {
|
||||
const sync = config.sync;
|
||||
const set = (patch: Partial<Config["sync"]>) =>
|
||||
setConfig({ ...config, sync: { ...sync, ...patch } });
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<CardTitle>Sync options</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={async () =>
|
||||
(await save(config)) && toast.success("Saved — syncing")
|
||||
}
|
||||
>
|
||||
{saving ? "Saving…" : "Save & sync"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end gap-4">
|
||||
<Field label="Poll (minutes)">
|
||||
<Input
|
||||
className="w-24"
|
||||
type="number"
|
||||
min={1}
|
||||
value={sync.pollMinutes}
|
||||
onChange={(e) => set({ pollMinutes: Number(e.target.value) || 15 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max entries">
|
||||
<Input
|
||||
className="w-28"
|
||||
type="number"
|
||||
min={1}
|
||||
value={sync.maxEntries}
|
||||
onChange={(e) =>
|
||||
set({ maxEntries: Number(e.target.value) || 5000 })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 pb-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sync.watch}
|
||||
onChange={(e) => set({ watch: e.target.checked })}
|
||||
/>
|
||||
Watch filesystem
|
||||
</label>
|
||||
<label className="flex items-center gap-2 pb-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sync.closeOnEnd}
|
||||
onChange={(e) => set({ closeOnEnd: e.target.checked })}
|
||||
/>
|
||||
Close emulator on stream end
|
||||
</label>
|
||||
<Field label="Region-dedupe platforms (comma ids)">
|
||||
<Input
|
||||
className="w-64"
|
||||
value={sync.dedupeRegions.join(", ")}
|
||||
placeholder="snes, genesis"
|
||||
onChange={(e) =>
|
||||
set({
|
||||
dedupeRegions: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</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,349 @@
|
||||
// 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?.length ?? 0) > 0 ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
{row.detected?.cores?.length} cores
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{row.detected?.exePath ? (
|
||||
<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 === null) 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 === null) 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" } };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user