Files
punktfunk-plugin-rom-manager/ui/src/pages/Emulators.tsx
T
enricobuehler 33e91e4d00
CI / publish (push) Has been cancelled
CI / build (push) Has been cancelled
feat: scope + un-bundle the plugin; @unom/ui SPA; custom-emulator editor
Reliability + correctness pass on the packaging and UI.

Packaging — drop the self-contained bundle:
- Rename to `@punktfunk/plugin-rom-manager` (scoped). An unscoped package can't
  be split across registries, which is the only reason bundling effect + the SDK
  into every plugin seemed necessary. Scoped names resolve from one scope-map, so
  the plugin depends on `@punktfunk/host` + `effect` as SHARED (hoisted) deps —
  no duplication, no ~1 MB effect copy per plugin. Build is back to plain `tsc`.
  (Runner-side discovery of `@punktfunk/plugin-*` shipped in @punktfunk/host 0.1.1.)

UI — use the console's design system so it reads as family:
- SPA rebuilt on @unom/style tokens + Tailwind v4 + the console's exact theme
  (violet chrome, Geist, card/button vocabulary). Build-time only → static assets
  in dist/ui, no @unom runtime dep. Skips @unom/ui's AnimatedButton/Card, which
  statically import ~7 MB of UI sound assets — same look, 416 KB dist/ui.
- Add a custom-emulator editor (Emulators page) writing config.emulators — you can
  now add a launcher Punktfunk doesn't ship a definition for.
- Fix brand capitalization to "Punktfunk" in all UI copy + docs.

Install docs + CI updated for the scoped `bun add @punktfunk/plugin-rom-manager`
flow. 48 engine tests green; backend + UI typecheck + biome clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 12:16:04 +02:00

382 lines
11 KiB
TypeScript

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>
);
};