Files
punktfunk/plugin-kit/src/errors.ts
T
enricobuehler 5872dfc649 feat(library): a plugin launch kind, so a scanner can publish tiles the host cannot name
The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses
on the FIRST offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`,
stopped putting anything in the library at all. Playnite hit the same wall and was rescued
with a typed kind the host resolves itself; there is no fixed scheme for "whichever emulator
the operator configured, with the core and flags they chose", so that trick does not
generalise.

So the entry now carries an opaque key and nothing executable, and the host asks the plugin
that owns it what to run — at launch time, over the loopback UI port and per-boot secret it
already registered. A stolen plugin token stops being command execution: planting an entry is
not enough, because the live plugin answers 404 for a key it never published. Nothing
executable is persisted or served to a client, and an emulator that moved is picked up on the
next launch instead of leaving a dead tile (the same reasoning as `xbox` resolving its AUMID
at launch time).

The host still SPAWNS it, because only the host can put the process where the stream can see
it: on Linux the line is either gamescope's own argv or a spawn carrying the session's
compositor env, and the returned child is what session-game-lifetime tracks to know the game
exited. A plugin spawning the emulator itself would land it outside both.

- library/plugin_launch.rs — the ask: blocking ureq, bounded body, absolute cwd, no control
  characters, and a log line for every way it can come back empty
- library/launch.rs — `plugin_recipe` tried before both per-OS resolvers, plus
  `launch_is_resolvable` so the async handshake probe never makes the blocking call
- native.rs — the session's `resolve_launch` moves onto `spawn_blocking`
- plugin-kit — `serveUi({launch})` serves `POST /__launch`; and `SyncError` finally renders
  its cause, which is why a host refusal with a fully explanatory 403 could reach a plugin's
  own UI as nothing but "Decode error"
2026-08-08 23:46:05 +02:00

98 lines
3.8 KiB
TypeScript

// Kit-level error taxonomy. `Data.TaggedError` (matching the SDK's idiom in
// sdk/src/client.ts) — these never cross HTTP; a plugin's UI-API contract defines its own
// Schema-based errors with status annotations.
import { Data } from "effect";
/**
* A management-API call through the pf facade failed.
*
* The `message` getter is load-bearing, not decoration. `Data.TaggedError`'s default string form is
* the bare tag, and the sync engine logs `sync (${reason}) failed: ${e.cause}` — so a host that
* refused a reconcile with a perfectly clear 400 surfaced in the plugin log as exactly
* `sync (startup) failed: HostRequestError`, with the method, the path and the host's own
* explanation all discarded. Diagnosing the 2026-08-08 Lutris/Steam art rejection meant reading the
* HOST's journal instead, because the plugin's own log could not distinguish a validation refusal
* from the host being down.
*/
export class HostRequestError extends Data.TaggedError("HostRequestError")<{
readonly method: string;
readonly path: string;
readonly cause: unknown;
}> {
override get message(): string {
return `${this.method} ${this.path} failed: ${describeCause(this.cause)}`;
}
}
/**
* Render whatever `pf.request` rejected with into one line.
*
* An `Error` stringifies usefully already; a plain object (the host's `{error: "…"}` body, which is
* what a rejected reconcile actually carries) stringifies to `[object Object]`, which is how the
* useful half of the message got lost. JSON is the fallback so a body-shaped cause survives, and a
* cycle or a BigInt degrades to `String(cause)` rather than throwing inside error formatting.
*/
const describeCause = (cause: unknown): string => {
if (cause instanceof Error) return cause.message;
if (typeof cause === "object" && cause !== null) {
try {
return JSON.stringify(cause);
} catch {
return String(cause);
}
}
return String(cause);
};
/** config.json exists but does not parse/decode. */
export class ConfigParseError extends Data.TaggedError("ConfigParseError")<{
readonly path: string;
readonly issue: string;
}> {}
/**
* config.json is group/world-writable (POSIX). This file controls commands run as the
* host user, so the kit refuses it — the same sshd rule the runner applies to unit files.
*/
export class ConfigPermissionError extends Data.TaggedError(
"ConfigPermissionError",
)<{
readonly path: string;
readonly mode: number;
}> {
override get message(): string {
return `refusing ${this.path}: it is group/world-writable (chmod go-w it first) — this file controls commands run as the host user`;
}
}
/** Persisting config/state failed. */
export class ConfigWriteError extends Data.TaggedError("ConfigWriteError")<{
readonly path: string;
readonly cause: unknown;
}> {}
/** The plugin UI server could not be started/registered. */
export class UiServeError extends Data.TaggedError("UiServeError")<{
readonly cause: unknown;
}> {}
/** A sync pass failed (compute or apply). */
export class SyncError extends Data.TaggedError("SyncError")<{
readonly reason: string;
readonly cause: unknown;
}> {
/**
* Same load-bearing getter as {@link HostRequestError}, and for the same reason one step further
* out: without it `String(e)` is the bare tag `SyncError`, so a plugin that renders its sync
* failure into an API error or a toast shows the operator a word instead of the refusal.
*
* That is how a rom-manager sync refused with a fully explanatory 403 reached its own UI as
* "Decode error" and nothing else — the reason existed at every layer and was dropped at this
* one. `describeCause` unwraps a nested `HostRequestError` through its own message getter, so
* the host's sentence survives the whole way to the surface.
*/
override get message(): string {
return `sync (${this.reason}) failed: ${describeCause(this.cause)}`;
}
}