feat(ui): Effect-native data layer + zero-host mock transport
AtomHttpApi.Service derives the typed client straight from the shared RomManagerApi contract — no hand-mirrored types, no react-query. Queries carry reactivity keys; mutations pass them per-call (constants exported beside each mutation). liveStatusAtom merges the kit sseAtom feed over GET /api/status; statusEventsAtom folds frames into the activity feed. useConfigDraft keeps a raw-config working copy (defaults never baked in) with dirty tracking and toast-reporting save. Mock mode is a full in-memory RomManagerApi as an HttpClient layer plus a scenario ticker (healthy/firstRun/syncing/errors), registered through a fixture-free seam so production chunks stay clean — mocks/http.ts is only imported behind import.meta.env.DEV and by Storybook. Scenario and mode atoms are keepAlive: the transport reads them outside the reactive graph and registry-seeded values must survive pages that never subscribe them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
|||||||
|
// Every atom the pages subscribe to. Queries invalidate via reactivity keys; the
|
||||||
|
// mutation constants below document which keys each mutation must pass at call time
|
||||||
|
// (AtomHttpApi invalidation is per-call: `set({ ..., reactivityKeys: [...KEYS] })`).
|
||||||
|
|
||||||
|
import { sseAtom } from "@punktfunk/plugin-kit/react";
|
||||||
|
import { EngineStatus, EVENTS_EVENT, EVENTS_PATH } from "@rom-manager/contract";
|
||||||
|
import { AsyncResult, Atom } from "effect/unstable/reactivity";
|
||||||
|
import { installedMockStatusStream } from "@/mocks/register";
|
||||||
|
import { apiModeAtom } from "@/mocks/scenario";
|
||||||
|
import { Api, prefix } from "./client";
|
||||||
|
|
||||||
|
// ── Queries ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const statusAtom = Api.query("status", "get", {
|
||||||
|
reactivityKeys: ["status"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const configAtom = Api.query("config", "get", {
|
||||||
|
reactivityKeys: ["config"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const previewAtom = Api.query("library", "preview", {
|
||||||
|
reactivityKeys: ["preview"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const platformsAtom = Api.query("catalog", "platforms", {
|
||||||
|
reactivityKeys: ["catalog"],
|
||||||
|
timeToLive: "5 minutes",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const emulatorsAtom = Api.query("catalog", "emulators", {
|
||||||
|
reactivityKeys: ["emulators"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mutations + the keys they invalidate ───────────────────────────────────────
|
||||||
|
|
||||||
|
export const saveConfig = Api.mutation("config", "put");
|
||||||
|
/** A config save changes the desired state everywhere derived from it. */
|
||||||
|
export const SAVE_CONFIG_KEYS = ["config", "preview", "status"] as const;
|
||||||
|
|
||||||
|
export const runSync = Api.mutation("sync", "run");
|
||||||
|
export const RUN_SYNC_KEYS = ["status", "preview"] as const;
|
||||||
|
|
||||||
|
export const runDetect = Api.mutation("catalog", "detect");
|
||||||
|
export const RUN_DETECT_KEYS = ["emulators", "status"] as const;
|
||||||
|
|
||||||
|
// ── Live status (SSE with polling fallback) ────────────────────────────────────
|
||||||
|
|
||||||
|
/** The real SSE feed. Only mounted in live mode (the mock branch never `get`s it). */
|
||||||
|
const liveStatusStream = sseAtom({
|
||||||
|
url: `${prefix}${EVENTS_PATH}`,
|
||||||
|
event: EVENTS_EVENT,
|
||||||
|
schema: EngineStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status frame stream, mode-independent: SSE in live mode, the fixture ticker in
|
||||||
|
* mock mode. `undefined` until the first frame arrives.
|
||||||
|
*/
|
||||||
|
export const statusStreamAtom: Atom.Atom<EngineStatus | undefined> = Atom.make(
|
||||||
|
(get) => {
|
||||||
|
if (get(apiModeAtom) === "mock") {
|
||||||
|
const mock = installedMockStatusStream();
|
||||||
|
return mock === undefined ? undefined : get(mock);
|
||||||
|
}
|
||||||
|
return get(liveStatusStream);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What pages actually render: the freshest engine status — the latest SSE frame once
|
||||||
|
* one has arrived, the plain GET /api/status result before that (and as error/skeleton
|
||||||
|
* source, since the SSE stream never fails).
|
||||||
|
*/
|
||||||
|
export const liveStatusAtom: Atom.Atom<Atom.Type<typeof statusAtom>> =
|
||||||
|
Atom.make((get) => {
|
||||||
|
const frame = get(statusStreamAtom);
|
||||||
|
if (frame !== undefined) {
|
||||||
|
return AsyncResult.success<EngineStatus, Atom.Failure<typeof statusAtom>>(
|
||||||
|
frame,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return get(statusAtom);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Status transitions → activity feed ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export type StatusEvent = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly at: number;
|
||||||
|
readonly text: string;
|
||||||
|
readonly level: "info" | "warn" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_LIMIT = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds the status frame stream into a bounded activity list for the Overview feed —
|
||||||
|
* client-side only, no history endpoint: connect, sync start/finish (with the report's
|
||||||
|
* count), report warnings, detection runs.
|
||||||
|
*/
|
||||||
|
export const statusEventsAtom: Atom.Atom<ReadonlyArray<StatusEvent>> =
|
||||||
|
Atom.make((get) => {
|
||||||
|
let prev: EngineStatus | undefined;
|
||||||
|
let items: ReadonlyArray<StatusEvent> = [];
|
||||||
|
let seq = 0;
|
||||||
|
get.subscribe(
|
||||||
|
statusStreamAtom,
|
||||||
|
(frame) => {
|
||||||
|
if (frame === undefined) return;
|
||||||
|
const next: Array<StatusEvent> = [];
|
||||||
|
const push = (text: string, level: StatusEvent["level"] = "info") =>
|
||||||
|
next.push({ id: `ev-${seq++}`, at: Date.now(), text, level });
|
||||||
|
|
||||||
|
if (prev === undefined) {
|
||||||
|
const n = frame.rootsConfigured;
|
||||||
|
push(`Engine connected — ${n} ROM ${n === 1 ? "source" : "sources"}`);
|
||||||
|
if (frame.syncing) push("Sync running");
|
||||||
|
} else {
|
||||||
|
if (!prev.syncing && frame.syncing) push("Sync started");
|
||||||
|
if (prev.syncing && !frame.syncing) {
|
||||||
|
const report = frame.lastReport;
|
||||||
|
if (report === undefined) {
|
||||||
|
push("Sync finished");
|
||||||
|
} else {
|
||||||
|
push(
|
||||||
|
`Reconciled ${report.included} ${report.included === 1 ? "title" : "titles"}`,
|
||||||
|
);
|
||||||
|
for (const warning of report.warnings) push(warning, "warn");
|
||||||
|
if (report.overWarn) {
|
||||||
|
push(
|
||||||
|
`Library is over the warning threshold (${report.included} entries)`,
|
||||||
|
"warn",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
frame.detectedAt !== undefined &&
|
||||||
|
frame.detectedAt !== prev.detectedAt
|
||||||
|
) {
|
||||||
|
push("Emulator detection finished");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prev = frame;
|
||||||
|
if (next.length > 0) {
|
||||||
|
items = [...items, ...next].slice(-EVENT_LIMIT);
|
||||||
|
get.setSelf(items);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
return items;
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// The one place the UI talks HTTP: a typed client + atom factory derived from the
|
||||||
|
// SHARED HttpApi contract via AtomHttpApi. No hand-mirrored types, no fetch calls in
|
||||||
|
// pages — endpoints, payloads, and errors all flow from @rom-manager/contract.
|
||||||
|
|
||||||
|
import { resolvePluginBase } from "@punktfunk/plugin-kit/react";
|
||||||
|
import { RomManagerApi } from "@rom-manager/contract";
|
||||||
|
import {
|
||||||
|
FetchHttpClient,
|
||||||
|
HttpClient,
|
||||||
|
HttpClientRequest,
|
||||||
|
} from "effect/unstable/http";
|
||||||
|
import { AtomHttpApi } from "effect/unstable/reactivity";
|
||||||
|
import { installedMockHttpLayer } from "@/mocks/register";
|
||||||
|
import { apiModeAtom } from "@/mocks/scenario";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "/plugin-ui/<id>" behind the console proxy, "" in dev — Vite serves at "/", so
|
||||||
|
* relative fetches hit the dev-server proxy (live mode) or the mock layer (mock mode).
|
||||||
|
*/
|
||||||
|
export const prefix = resolvePluginBase();
|
||||||
|
|
||||||
|
export class Api extends AtomHttpApi.Service<Api>()("RomApi", {
|
||||||
|
api: RomManagerApi,
|
||||||
|
// Mock mode swaps the transport UNDER the same typed client, so pages cannot tell
|
||||||
|
// fixtures from a live host. The mock layer arrives via mocks/register (dev-only
|
||||||
|
// import); if it is not installed we fall through to fetch — a loud, honest failure.
|
||||||
|
httpClient: (get) => {
|
||||||
|
if (get(apiModeAtom) === "mock") {
|
||||||
|
const mock = installedMockHttpLayer(get);
|
||||||
|
if (mock !== undefined) return mock;
|
||||||
|
}
|
||||||
|
return FetchHttpClient.layer;
|
||||||
|
},
|
||||||
|
// prependUrl("") breaks URL joining — only install the transform when proxied.
|
||||||
|
...(prefix !== ""
|
||||||
|
? {
|
||||||
|
transformClient: HttpClient.mapRequest(
|
||||||
|
HttpClientRequest.prependUrl(prefix),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}) {}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// The config draft: a local raw-config working copy seeded from GET /api/config.
|
||||||
|
// Edits patch the RAW (authored) shape — defaults are never baked in; `resolved`
|
||||||
|
// decodes the draft through the SHARED RomConfigSchema so controls can display
|
||||||
|
// effective values (e.g. pollMinutes 15 when the file omits it).
|
||||||
|
|
||||||
|
import { useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||||
|
import {
|
||||||
|
type Config,
|
||||||
|
type RawConfig,
|
||||||
|
resolveConfig,
|
||||||
|
} from "@rom-manager/contract";
|
||||||
|
import { toast } from "@unom/ui/toast";
|
||||||
|
import { Cause, Exit } from "effect";
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { errorText } from "@/lib/errors";
|
||||||
|
import { configAtom, SAVE_CONFIG_KEYS, saveConfig } from "./atoms";
|
||||||
|
|
||||||
|
export type ConfigDraft = {
|
||||||
|
/** False until GET /api/config has succeeded once (render a skeleton). */
|
||||||
|
readonly loaded: boolean;
|
||||||
|
/** The working copy of the authored (raw) config. `{}` until loaded. */
|
||||||
|
readonly draft: RawConfig;
|
||||||
|
/** The draft decoded through RomConfigSchema (defaults filled), if valid. */
|
||||||
|
readonly resolved: Config | undefined;
|
||||||
|
readonly dirty: boolean;
|
||||||
|
readonly saving: boolean;
|
||||||
|
readonly patch: (patch: Partial<RawConfig>) => void;
|
||||||
|
readonly save: () => void;
|
||||||
|
readonly discard: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraftState = {
|
||||||
|
readonly baseline: RawConfig;
|
||||||
|
readonly draft: RawConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfigDraft = (): ConfigDraft => {
|
||||||
|
const result = useAtomValue(configAtom);
|
||||||
|
const saveResult = useAtomValue(saveConfig);
|
||||||
|
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
|
||||||
|
const [state, setState] = useState<DraftState | null>(null);
|
||||||
|
|
||||||
|
// Seed once from the first successful load; later refetches (our own saves) must
|
||||||
|
// not clobber in-progress edits.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state === null && AsyncResult.isSuccess(result)) {
|
||||||
|
const raw = result.value.raw as RawConfig;
|
||||||
|
setState({ baseline: raw, draft: raw });
|
||||||
|
}
|
||||||
|
}, [result, state]);
|
||||||
|
|
||||||
|
const draft = state?.draft ?? {};
|
||||||
|
const dirty =
|
||||||
|
state !== null &&
|
||||||
|
JSON.stringify(state.draft) !== JSON.stringify(state.baseline);
|
||||||
|
|
||||||
|
const resolved = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return resolveConfig(draft);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
const patch = useCallback((p: Partial<RawConfig>) => {
|
||||||
|
setState((s) => (s === null ? s : { ...s, draft: { ...s.draft, ...p } }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const discard = useCallback(() => {
|
||||||
|
setState((s) => (s === null ? s : { ...s, draft: s.baseline }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = useCallback(() => {
|
||||||
|
if (state === null) return;
|
||||||
|
const raw = state.draft;
|
||||||
|
void write({ payload: raw, reactivityKeys: [...SAVE_CONFIG_KEYS] }).then(
|
||||||
|
(exit) => {
|
||||||
|
if (Exit.isSuccess(exit)) {
|
||||||
|
setState({ baseline: raw, draft: raw });
|
||||||
|
toast.success("Configuration saved");
|
||||||
|
} else {
|
||||||
|
toast.error("Save failed", {
|
||||||
|
description: errorText(Cause.squash(exit.cause)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [state, write]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loaded: state !== null,
|
||||||
|
draft,
|
||||||
|
resolved,
|
||||||
|
dirty,
|
||||||
|
saving: AsyncResult.isWaiting(saveResult),
|
||||||
|
patch,
|
||||||
|
save,
|
||||||
|
discard,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
// Fixture data for the zero-host dev loop + Storybook. Typed AGAINST THE CONTRACT —
|
||||||
|
// if the contract moves, this file fails typecheck, exactly like the real server would.
|
||||||
|
// Never imported by production app code (see mocks/register.ts for the seam).
|
||||||
|
import type {
|
||||||
|
DetectedEmulator,
|
||||||
|
EmulatorsPayload,
|
||||||
|
EngineStatus,
|
||||||
|
Platform,
|
||||||
|
PreviewResult,
|
||||||
|
RawConfig,
|
||||||
|
SyncReport,
|
||||||
|
} from "@rom-manager/contract";
|
||||||
|
import type { Scenario } from "./scenario";
|
||||||
|
|
||||||
|
type ProviderEntry = PreviewResult["entries"][number];
|
||||||
|
|
||||||
|
// ── Games ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const portrait = (title: string): string =>
|
||||||
|
`https://placehold.co/600x900/1c1530/a79ff8?text=${encodeURIComponent(title).replace(/%20/g, "+")}`;
|
||||||
|
|
||||||
|
const game = (
|
||||||
|
platform: string,
|
||||||
|
file: string,
|
||||||
|
title: string,
|
||||||
|
launch: string,
|
||||||
|
): ProviderEntry => ({
|
||||||
|
external_id: `${platform}/${file}`,
|
||||||
|
title,
|
||||||
|
art: { portrait: portrait(title) },
|
||||||
|
launch: { kind: "command", value: launch },
|
||||||
|
});
|
||||||
|
|
||||||
|
const retroarch = (core: string, platform: string, file: string): string =>
|
||||||
|
`/usr/bin/retroarch -f -L ${core} '/roms/${platform}/${file}'`;
|
||||||
|
|
||||||
|
/** Every game the mock scan finds (13 — one is excluded via config by default). */
|
||||||
|
export const GAMES: ReadonlyArray<ProviderEntry> = [
|
||||||
|
game(
|
||||||
|
"snes",
|
||||||
|
"Chrono Trigger (USA).sfc",
|
||||||
|
"Chrono Trigger",
|
||||||
|
retroarch("snes9x", "snes", "Chrono Trigger (USA).sfc"),
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"snes",
|
||||||
|
"Super Metroid (USA).sfc",
|
||||||
|
"Super Metroid",
|
||||||
|
retroarch("snes9x", "snes", "Super Metroid (USA).sfc"),
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"snes",
|
||||||
|
"The Legend of Zelda - A Link to the Past (USA).sfc",
|
||||||
|
"The Legend of Zelda: A Link to the Past",
|
||||||
|
retroarch(
|
||||||
|
"snes9x",
|
||||||
|
"snes",
|
||||||
|
"The Legend of Zelda - A Link to the Past (USA).sfc",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"snes",
|
||||||
|
"EarthBound (USA).sfc",
|
||||||
|
"EarthBound",
|
||||||
|
retroarch("snes9x", "snes", "EarthBound (USA).sfc"),
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"snes",
|
||||||
|
"Super Mario World (USA).sfc",
|
||||||
|
"Super Mario World",
|
||||||
|
retroarch("snes9x", "snes", "Super Mario World (USA).sfc"),
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"n64",
|
||||||
|
"Super Mario 64 (USA).z64",
|
||||||
|
"Super Mario 64",
|
||||||
|
retroarch("mupen64plus_next", "n64", "Super Mario 64 (USA).z64"),
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"n64",
|
||||||
|
"The Legend of Zelda - Ocarina of Time (USA).z64",
|
||||||
|
"The Legend of Zelda: Ocarina of Time",
|
||||||
|
retroarch(
|
||||||
|
"mupen64plus_next",
|
||||||
|
"n64",
|
||||||
|
"The Legend of Zelda - Ocarina of Time (USA).z64",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"n64",
|
||||||
|
"Mario Kart 64 (USA).z64",
|
||||||
|
"Mario Kart 64",
|
||||||
|
retroarch("mupen64plus_next", "n64", "Mario Kart 64 (USA).z64"),
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"n64",
|
||||||
|
"GoldenEye 007 (USA).z64",
|
||||||
|
"GoldenEye 007",
|
||||||
|
retroarch("mupen64plus_next", "n64", "GoldenEye 007 (USA).z64"),
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"ps1",
|
||||||
|
"Final Fantasy VII (USA).chd",
|
||||||
|
"Final Fantasy VII",
|
||||||
|
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Final Fantasy VII (USA).chd'",
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"ps1",
|
||||||
|
"Castlevania - Symphony of the Night (USA).chd",
|
||||||
|
"Castlevania: Symphony of the Night",
|
||||||
|
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Castlevania - Symphony of the Night (USA).chd'",
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"ps1",
|
||||||
|
"Metal Gear Solid (USA).chd",
|
||||||
|
"Metal Gear Solid",
|
||||||
|
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Metal Gear Solid (USA).chd'",
|
||||||
|
),
|
||||||
|
game(
|
||||||
|
"ps1",
|
||||||
|
"Vagrant Story (USA).chd",
|
||||||
|
"Vagrant Story",
|
||||||
|
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Vagrant Story (USA).chd'",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Catalog ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PLATFORMS: ReadonlyArray<Platform> = [
|
||||||
|
{
|
||||||
|
id: "snes",
|
||||||
|
name: "Super Nintendo",
|
||||||
|
extensions: ["sfc", "smc", "zip"],
|
||||||
|
libretroSystem: "Nintendo - Super Nintendo Entertainment System",
|
||||||
|
defaultLaunch: { emulator: "retroarch", core: "snes9x" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n64",
|
||||||
|
name: "Nintendo 64",
|
||||||
|
extensions: ["z64", "n64", "v64", "zip"],
|
||||||
|
libretroSystem: "Nintendo - Nintendo 64",
|
||||||
|
defaultLaunch: { emulator: "retroarch", core: "mupen64plus_next" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ps1",
|
||||||
|
name: "PlayStation",
|
||||||
|
extensions: ["chd", "cue", "pbp", "m3u"],
|
||||||
|
libretroSystem: "Sony - PlayStation",
|
||||||
|
defaultLaunch: { emulator: "duckstation" },
|
||||||
|
disc: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gamecube",
|
||||||
|
name: "GameCube",
|
||||||
|
extensions: ["iso", "rvz", "gcz"],
|
||||||
|
libretroSystem: "Nintendo - GameCube",
|
||||||
|
defaultLaunch: { emulator: "dolphin" },
|
||||||
|
disc: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DETECTED_EMULATORS: ReadonlyArray<DetectedEmulator> = [
|
||||||
|
{
|
||||||
|
id: "retroarch",
|
||||||
|
name: "RetroArch",
|
||||||
|
template: "{exe} -f -L {core} {rom}",
|
||||||
|
supportsArchives: true,
|
||||||
|
exeToken: "retroarch",
|
||||||
|
exePath: "/usr/bin/retroarch",
|
||||||
|
via: "path",
|
||||||
|
coresDir: "/home/deck/.config/retroarch/cores",
|
||||||
|
cores: ["snes9x", "mupen64plus_next", "mesen", "gambatte", "mgba"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "duckstation",
|
||||||
|
name: "DuckStation",
|
||||||
|
template: "{exe} -batch -fullscreen {rom}",
|
||||||
|
supportsArchives: false,
|
||||||
|
exeToken: "duckstation-qt",
|
||||||
|
exePath: "/usr/bin/duckstation-qt",
|
||||||
|
via: "path",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DETECTED_AT = Date.now() - 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const emulatorsFor = (scenario: Scenario): EmulatorsPayload =>
|
||||||
|
scenario === "firstRun"
|
||||||
|
? { detected: [], detectedAt: null }
|
||||||
|
: { detected: [...DETECTED_EMULATORS], detectedAt: DETECTED_AT };
|
||||||
|
|
||||||
|
// ── Config ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const HEALTHY_CONFIG: RawConfig = {
|
||||||
|
roots: [
|
||||||
|
{ dir: "/roms/snes", platform: "snes" },
|
||||||
|
{ dir: "/roms/n64", platform: "n64" },
|
||||||
|
{ dir: "/roms/ps1", platform: "ps1", excludes: ["**/*.bak"] },
|
||||||
|
],
|
||||||
|
gameOverrides: {
|
||||||
|
"snes/Super Mario World (USA).sfc": { exclude: true },
|
||||||
|
},
|
||||||
|
art: { enabled: true, provider: "steamgriddb", steamGridDbKey: "sgdb-demo" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const configFor = (scenario: Scenario): RawConfig =>
|
||||||
|
scenario === "firstRun" ? {} : HEALTHY_CONFIG;
|
||||||
|
|
||||||
|
// ── Preview + report (derived from the current raw config, so toggles round-trip) ──
|
||||||
|
|
||||||
|
const platformOf = (externalId: string): string =>
|
||||||
|
externalId.split("/")[0] ?? "unknown";
|
||||||
|
|
||||||
|
const SKIPPED = [
|
||||||
|
{
|
||||||
|
external_id: "n64/GoldenEye 007 (Europe).z64",
|
||||||
|
title: "GoldenEye 007 (Europe)",
|
||||||
|
reason: "region duplicate of GoldenEye 007 (USA)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
external_id: "ps1/Final Fantasy VII (USA) (Disc 2).chd",
|
||||||
|
title: "Final Fantasy VII (Disc 2)",
|
||||||
|
reason: "multi-disc: only disc 1 is listed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const previewFor = (
|
||||||
|
scenario: Scenario,
|
||||||
|
raw: RawConfig,
|
||||||
|
): PreviewResult => {
|
||||||
|
if (scenario === "firstRun") {
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
report: {
|
||||||
|
considered: 0,
|
||||||
|
included: 0,
|
||||||
|
skipped: [],
|
||||||
|
excluded: [],
|
||||||
|
warnings: [],
|
||||||
|
truncated: 0,
|
||||||
|
perPlatform: {},
|
||||||
|
overWarn: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const overrides = raw.gameOverrides ?? {};
|
||||||
|
const excludedIds = new Set(
|
||||||
|
Object.entries(overrides)
|
||||||
|
.filter(([, o]) => o.exclude === true)
|
||||||
|
.map(([id]) => id),
|
||||||
|
);
|
||||||
|
const entries = GAMES.filter((g) => !excludedIds.has(g.external_id));
|
||||||
|
const excluded = GAMES.filter((g) => excludedIds.has(g.external_id)).map(
|
||||||
|
(g) => ({ external_id: g.external_id, title: g.title }),
|
||||||
|
);
|
||||||
|
const perPlatform: Record<string, number> = {};
|
||||||
|
for (const entry of entries) {
|
||||||
|
const p = platformOf(entry.external_id);
|
||||||
|
perPlatform[p] = (perPlatform[p] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
const warnings =
|
||||||
|
scenario === "errors"
|
||||||
|
? [
|
||||||
|
"steamgriddb: 401 unauthorized — check the API key",
|
||||||
|
"/roms/n64: 3 files with unknown extensions ignored",
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const report: SyncReport = {
|
||||||
|
considered: GAMES.length + SKIPPED.length,
|
||||||
|
included: entries.length,
|
||||||
|
skipped: SKIPPED,
|
||||||
|
excluded,
|
||||||
|
warnings,
|
||||||
|
truncated: 0,
|
||||||
|
perPlatform,
|
||||||
|
overWarn: scenario === "errors",
|
||||||
|
};
|
||||||
|
return { entries, report };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reportFor = (scenario: Scenario, raw: RawConfig): SyncReport =>
|
||||||
|
previewFor(scenario, raw).report;
|
||||||
|
|
||||||
|
// ── Engine status + the SSE stand-in ticker ────────────────────────────────────
|
||||||
|
|
||||||
|
const PATHS = {
|
||||||
|
dir: "/home/deck/.local/share/punktfunk/plugin-state/rom-manager",
|
||||||
|
config:
|
||||||
|
"/home/deck/.local/share/punktfunk/plugin-state/rom-manager/config.json",
|
||||||
|
cache:
|
||||||
|
"/home/deck/.local/share/punktfunk/plugin-state/rom-manager/cache.json",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const statusFor = (scenario: Scenario): EngineStatus => {
|
||||||
|
const report = reportFor(scenario, configFor(scenario));
|
||||||
|
switch (scenario) {
|
||||||
|
case "firstRun":
|
||||||
|
return {
|
||||||
|
rootsConfigured: 0,
|
||||||
|
os: "linux",
|
||||||
|
artProvider: null,
|
||||||
|
syncing: false,
|
||||||
|
paths: PATHS,
|
||||||
|
};
|
||||||
|
case "syncing":
|
||||||
|
return {
|
||||||
|
rootsConfigured: 3,
|
||||||
|
os: "linux",
|
||||||
|
artProvider: "steamgriddb",
|
||||||
|
syncing: true,
|
||||||
|
lastSync: {
|
||||||
|
fingerprint: "b3c1a94efd02",
|
||||||
|
count: report.included,
|
||||||
|
at: Date.now() - 8 * 60 * 1000,
|
||||||
|
},
|
||||||
|
lastReport: report,
|
||||||
|
detectedAt: DETECTED_AT,
|
||||||
|
paths: PATHS,
|
||||||
|
};
|
||||||
|
case "errors":
|
||||||
|
return {
|
||||||
|
rootsConfigured: 3,
|
||||||
|
os: "linux",
|
||||||
|
artProvider: null,
|
||||||
|
syncing: false,
|
||||||
|
lastSync: {
|
||||||
|
fingerprint: "b3c1a94efd02",
|
||||||
|
count: report.included,
|
||||||
|
at: Date.now() - 45 * 60 * 1000,
|
||||||
|
},
|
||||||
|
lastReport: report,
|
||||||
|
detectedAt: DETECTED_AT,
|
||||||
|
paths: PATHS,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
rootsConfigured: 3,
|
||||||
|
os: "linux",
|
||||||
|
artProvider: "steamgriddb",
|
||||||
|
syncing: false,
|
||||||
|
lastSync: {
|
||||||
|
fingerprint: "b3c1a94efd02",
|
||||||
|
count: report.included,
|
||||||
|
at: Date.now() - 8 * 60 * 1000,
|
||||||
|
},
|
||||||
|
lastReport: report,
|
||||||
|
detectedAt: DETECTED_AT,
|
||||||
|
paths: PATHS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frames for the mock status ticker (~2s steps). Multi-frame scenarios loop
|
||||||
|
* idle → syncing → done so the Overview feed and sync button stay alive without a host.
|
||||||
|
*/
|
||||||
|
export const tickerFrames = (
|
||||||
|
scenario: Scenario,
|
||||||
|
): ReadonlyArray<EngineStatus> => {
|
||||||
|
const base = statusFor(scenario);
|
||||||
|
switch (scenario) {
|
||||||
|
case "firstRun":
|
||||||
|
return [base];
|
||||||
|
case "syncing":
|
||||||
|
return [base];
|
||||||
|
case "errors":
|
||||||
|
return [base, { ...base, syncing: true }, { ...base, syncing: false }];
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
base,
|
||||||
|
{ ...base, syncing: true },
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
syncing: false,
|
||||||
|
lastSync: {
|
||||||
|
fingerprint: "c4d2ba50fe13",
|
||||||
|
count: base.lastReport?.included ?? 0,
|
||||||
|
at: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// The mock transport: a full in-memory implementation of the RomManagerApi surface as
|
||||||
|
// an HttpClient layer, plus the SSE stand-in ticker. Importing this module REGISTERS
|
||||||
|
// both (see mocks/register.ts); only main.tsx (behind import.meta.env.DEV) and
|
||||||
|
// Storybook's preview import it, so production app chunks never contain fixtures.
|
||||||
|
import type { EngineStatus, RawConfig } from "@rom-manager/contract";
|
||||||
|
import { Duration, Effect, Layer } from "effect";
|
||||||
|
import {
|
||||||
|
HttpClient,
|
||||||
|
type HttpClientRequest,
|
||||||
|
HttpClientResponse,
|
||||||
|
} from "effect/unstable/http";
|
||||||
|
import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
|
||||||
|
import * as fx from "./fixtures";
|
||||||
|
import { registerMocks } from "./register";
|
||||||
|
import { type Scenario, scenarioAtom } from "./scenario";
|
||||||
|
|
||||||
|
/** PUT /api/config lands here so config edits round-trip within a registry.
|
||||||
|
* keepAlive: written/read outside the reactive graph — see mocks/scenario.ts. */
|
||||||
|
const mockRawConfigAtom = Atom.keepAlive(Atom.make<RawConfig | null>(null));
|
||||||
|
|
||||||
|
const currentRawConfig = (
|
||||||
|
registry: AtomRegistry.AtomRegistry,
|
||||||
|
scenario: Scenario,
|
||||||
|
): RawConfig => registry.get(mockRawConfigAtom) ?? fx.configFor(scenario);
|
||||||
|
|
||||||
|
const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => {
|
||||||
|
const body = request.body;
|
||||||
|
if (body._tag === "Uint8Array") {
|
||||||
|
return JSON.parse(new TextDecoder().decode(body.body));
|
||||||
|
}
|
||||||
|
if (body._tag === "Raw") return body.body;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockHttpClientLayer = (
|
||||||
|
get: Atom.AtomContext,
|
||||||
|
): Layer.Layer<HttpClient.HttpClient> => {
|
||||||
|
const registry = get.registry;
|
||||||
|
return Layer.succeed(HttpClient.HttpClient)(
|
||||||
|
HttpClient.make((request, url) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
// Real-ish latency so skeletons/spinners are honest in dev + Storybook.
|
||||||
|
yield* Effect.sleep(Duration.millis(150 + Math.random() * 250));
|
||||||
|
const scenario = registry.get(scenarioAtom);
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
HttpClientResponse.fromWeb(request, Response.json(body, { status }));
|
||||||
|
|
||||||
|
switch (`${request.method} ${url.pathname}`) {
|
||||||
|
case "GET /api/status":
|
||||||
|
return json(fx.statusFor(scenario));
|
||||||
|
|
||||||
|
case "GET /api/config":
|
||||||
|
return json({ raw: currentRawConfig(registry, scenario) });
|
||||||
|
|
||||||
|
case "PUT /api/config": {
|
||||||
|
if (scenario === "errors") {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
_tag: "ConfigInvalid",
|
||||||
|
issues: 'roots[1].platform: unknown platform id "n65"',
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const raw = requestJson(request) as RawConfig;
|
||||||
|
registry.set(mockRawConfigAtom, raw);
|
||||||
|
return json({ raw });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GET /api/preview": {
|
||||||
|
if (scenario === "errors") {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
_tag: "ScanFailed",
|
||||||
|
message: "scan failed: /roms/n64: permission denied",
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return json(
|
||||||
|
fx.previewFor(scenario, currentRawConfig(registry, scenario)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GET /api/platforms":
|
||||||
|
return json(fx.PLATFORMS);
|
||||||
|
|
||||||
|
case "GET /api/emulators":
|
||||||
|
return json(fx.emulatorsFor(scenario));
|
||||||
|
|
||||||
|
case "POST /api/detect": {
|
||||||
|
if (scenario === "errors") {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
_tag: "DetectFailed",
|
||||||
|
message: "detection failed: PATH probe timed out",
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return json({
|
||||||
|
...fx.emulatorsFor(scenario),
|
||||||
|
detectedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "POST /api/sync": {
|
||||||
|
if (scenario === "syncing") {
|
||||||
|
return json({ _tag: "SyncInProgress" }, 409);
|
||||||
|
}
|
||||||
|
return json(
|
||||||
|
fx.reportFor(scenario, currentRawConfig(registry, scenario)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return json(
|
||||||
|
{ error: `mock: unhandled ${request.method} ${url.pathname}` },
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The SSE stand-in: cycles fixture frames (~2s steps) per the active scenario. */
|
||||||
|
export const mockStatusStreamAtom: Atom.Atom<EngineStatus | undefined> =
|
||||||
|
Atom.make((get) => {
|
||||||
|
const frames = fx.tickerFrames(get(scenarioAtom));
|
||||||
|
if (frames.length > 1) {
|
||||||
|
let i = 0;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
i += 1;
|
||||||
|
get.setSelf(frames[i % frames.length]);
|
||||||
|
}, 2000);
|
||||||
|
get.addFinalizer(() => clearInterval(timer));
|
||||||
|
}
|
||||||
|
return frames[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
registerMocks({
|
||||||
|
httpLayer: mockHttpClientLayer,
|
||||||
|
statusStream: mockStatusStreamAtom,
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// The dev-only seam between the always-shipped data layer and the mock transport.
|
||||||
|
// client.ts/atoms.ts reference THIS module (tiny, fixture-free); mocks/http.ts calls
|
||||||
|
// `registerMocks` at import time and is only ever imported by main.tsx behind
|
||||||
|
// `import.meta.env.DEV` and by Storybook's preview — so production app chunks stay
|
||||||
|
// free of fixture code without any conditional imports in the data layer.
|
||||||
|
import type { EngineStatus } from "@rom-manager/contract";
|
||||||
|
import type { Layer } from "effect";
|
||||||
|
import type { HttpClient } from "effect/unstable/http";
|
||||||
|
import type { Atom } from "effect/unstable/reactivity";
|
||||||
|
|
||||||
|
export type MockHttpLayerFactory = (
|
||||||
|
get: Atom.AtomContext,
|
||||||
|
) => Layer.Layer<HttpClient.HttpClient>;
|
||||||
|
|
||||||
|
type Registered = {
|
||||||
|
readonly httpLayer: MockHttpLayerFactory;
|
||||||
|
readonly statusStream: Atom.Atom<EngineStatus | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let registered: Registered | undefined;
|
||||||
|
|
||||||
|
export const registerMocks = (mocks: Registered): void => {
|
||||||
|
registered = mocks;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The mock HttpClient layer, when mocks/http.ts has been imported. */
|
||||||
|
export const installedMockHttpLayer = (
|
||||||
|
get: Atom.AtomContext,
|
||||||
|
): Layer.Layer<HttpClient.HttpClient> | undefined => registered?.httpLayer(get);
|
||||||
|
|
||||||
|
/** The mock status ticker (the SSE stand-in), when mocks/http.ts has been imported. */
|
||||||
|
export const installedMockStatusStream = ():
|
||||||
|
| Atom.Atom<EngineStatus | undefined>
|
||||||
|
| undefined => registered?.statusStream;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Mock-mode switches. Fixture-free ON PURPOSE: this module sits in the production
|
||||||
|
// import graph (client.ts reads apiModeAtom), so it must never pull in fixtures.
|
||||||
|
import { Atom } from "effect/unstable/reactivity";
|
||||||
|
|
||||||
|
export type ApiMode = "mock" | "live";
|
||||||
|
|
||||||
|
/** The fixture worlds the mock transport can serve (Storybook picks one per story). */
|
||||||
|
export type Scenario = "healthy" | "firstRun" | "syncing" | "errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev defaults to the zero-host mock loop; `bun run dev:live` (VITE_API_MODE=live) and
|
||||||
|
* production builds hit the real API. Storybook seeds this atom to "mock" per registry.
|
||||||
|
*/
|
||||||
|
export const defaultApiMode: ApiMode =
|
||||||
|
import.meta.env.DEV && import.meta.env.VITE_API_MODE !== "live"
|
||||||
|
? "mock"
|
||||||
|
: "live";
|
||||||
|
|
||||||
|
// keepAlive: both atoms are read OUTSIDE the reactive graph (the mock transport does
|
||||||
|
// a request-time registry.get) — without it, a registry-seeded value on a page that
|
||||||
|
// never subscribes them is collected and the read falls back to the default.
|
||||||
|
export const apiModeAtom: Atom.Writable<ApiMode> = Atom.keepAlive(
|
||||||
|
Atom.make<ApiMode>(defaultApiMode),
|
||||||
|
);
|
||||||
|
|
||||||
|
const SCENARIOS: ReadonlyArray<Scenario> = [
|
||||||
|
"healthy",
|
||||||
|
"firstRun",
|
||||||
|
"syncing",
|
||||||
|
"errors",
|
||||||
|
];
|
||||||
|
|
||||||
|
const isScenario = (value: unknown): value is Scenario =>
|
||||||
|
SCENARIOS.includes(value as Scenario);
|
||||||
|
|
||||||
|
/** Dev knob: `VITE_MOCK_SCENARIO=firstRun bun run dev` starts in another world. */
|
||||||
|
const defaultScenario: Scenario = isScenario(import.meta.env.VITE_MOCK_SCENARIO)
|
||||||
|
? import.meta.env.VITE_MOCK_SCENARIO
|
||||||
|
: "healthy";
|
||||||
|
|
||||||
|
export const scenarioAtom: Atom.Writable<Scenario> = Atom.keepAlive(
|
||||||
|
Atom.make<Scenario>(defaultScenario),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user