// 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; /** 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; 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 (path: string, opts?: RequestInit): Promise => { 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("api/status"); export const getConfig = () => api("api/config"); export const putConfig = (config: Config) => api("api/config", { method: "PUT", body: JSON.stringify(config) }); export const getPreview = () => api("api/preview"); export const runDetect = () => api("api/detect", { method: "POST" }); export const runSync = () => api("api/sync", { method: "POST" }); export const getPlatforms = () => api("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(); 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; };