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