apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 3m46s
ci / bun-nix (pull_request) Successful in 54s
ci / rust-arm64 (pull_request) Successful in 5m54s
android / android (pull_request) Successful in 7m29s
ci / rust (pull_request) Successful in 21m2s
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"
75 lines
2.9 KiB
TypeScript
75 lines
2.9 KiB
TypeScript
// The `/__launch` wire shape — the plugin half of the `plugin` launch kind, and a contract with the
|
|
// HOST (`library::ask_plugin_launch`), so it is driven end to end here rather than mocked.
|
|
import { describe, expect, test } from "bun:test";
|
|
import { Effect } from "effect";
|
|
import { makeLaunchHandler, type PluginLaunchTarget } from "../src/index.js";
|
|
|
|
const post = (body: unknown, init?: RequestInit): Request =>
|
|
new Request("http://127.0.0.1/__launch", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
...init,
|
|
});
|
|
|
|
/** A resolver that owns exactly one entry — the shape every real plugin's resolver has. */
|
|
const oneEntry = (key: string, target: PluginLaunchTarget) =>
|
|
makeLaunchHandler((entry) => Effect.succeed(entry === key ? target : null));
|
|
|
|
describe("makeLaunchHandler", () => {
|
|
test("answers a known entry with its command", async () => {
|
|
const h = oneEntry("snes/smw.sfc", {
|
|
command: "retroarch -L snes9x.so '/roms/snes/smw.sfc'",
|
|
});
|
|
const res = await h(post({ entry: "snes/smw.sfc" }));
|
|
expect(res.status).toBe(200);
|
|
expect(await res.json()).toEqual({
|
|
command: "retroarch -L snes9x.so '/roms/snes/smw.sfc'",
|
|
});
|
|
});
|
|
|
|
test("carries a working directory only when the plugin set one", async () => {
|
|
const withCwd = oneEntry("k", { command: "run", cwd: "/opt/emu" });
|
|
expect(await (await withCwd(post({ entry: "k" }))).json()).toEqual({
|
|
command: "run",
|
|
cwd: "/opt/emu",
|
|
});
|
|
const without = oneEntry("k", { command: "run" });
|
|
// Absent, not `cwd: undefined` — the host decodes {command, cwd?} and a present-but-null key
|
|
// is the exact shape that broke this plugin's own API once before (v0.3.2).
|
|
expect(
|
|
Object.hasOwn(await (await without(post({ entry: "k" }))).json(), "cwd"),
|
|
).toBe(false);
|
|
});
|
|
|
|
test("404s an entry the plugin does not own — the forged-entry case", async () => {
|
|
const h = oneEntry("mine", { command: "run" });
|
|
const res = await h(post({ entry: "someone-elses" }));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
test("a resolver that dies is a 500, distinct from disowning the entry", async () => {
|
|
const h = makeLaunchHandler(
|
|
() => Effect.die(new Error("cache unreadable")) as Effect.Effect<null>,
|
|
);
|
|
const res = await h(post({ entry: "k" }));
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
test("refuses a body that is not {entry: string}", async () => {
|
|
const h = oneEntry("k", { command: "run" });
|
|
expect((await h(post("not json at all"))).status).toBe(400);
|
|
expect((await h(post({}))).status).toBe(400);
|
|
expect((await h(post({ entry: 42 }))).status).toBe(400);
|
|
expect((await h(post({ entry: "" }))).status).toBe(400);
|
|
});
|
|
|
|
test("only POST", async () => {
|
|
const h = oneEntry("k", { command: "run" });
|
|
const res = await h(
|
|
new Request("http://127.0.0.1/__launch", { method: "GET" }),
|
|
);
|
|
expect(res.status).toBe(405);
|
|
});
|
|
});
|