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([]); const [detected, setDetected] = useState([]); const [platforms, setPlatforms] = useState([]); 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 ( <>
Detected emulators Best-effort detection: PATH, Flatpak, then known install paths.
{defs.map((def) => { const d = detectedById.get(def.id); return (
{def.name} {def.id} {def.contested && contested} {d?.cores?.length ? ( {d.cores.length} cores ) : null} {d ? ( detected · {d.via} ) : ( not found )}
); })}
{config && ( )} {config && (
Per-platform emulator The default emulator + core for each platform. Override to prefer a different emulator (including a custom one) or RetroArch core.
{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 (
{p.name} {emulator === "retroarch" && detCores.length ? ( ) : emulator === "retroarch" ? ( setLaunch({ core: e.target.value })} /> ) : ( )}
); })}
)} ); }; // ── 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; saving: boolean; reloadDefs: () => Promise; } 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 ( Custom emulators Add an emulator Punktfunk doesn't ship a definition for. The template is trusted, run as the host user — use{" "} {"{exe}"},{" "} {"{rom}"}, optionally{" "} {"{core}"}. Leave detection blank and bake the executable into the template if it isn't on PATH. {emulators.length > 0 && (
{emulators.map((e) => (
{e.name} {e.id} {e.template}
))}
)}
); };