Files
punktfunk/plugin-kit/src/wire.ts
T
enricobuehler 6f07bd94d3
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m56s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m35s
feat(library): launcher tiles a plugin can actually publish
Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.

D4 said the other launchers would ride the `command` kind. The 2026-08-05 review
then made `launch.kind = "command"` operator-only (it is handed to a shell), so a
plugin publishing one is refused with a 403. The two changes are individually
right and jointly leave a hole: `steam_ui` was the only launcher kind a plugin
could publish, so a Heroic or Lutris tile was unreachable.

New `launcher_ui` kind, valued by store id. One kind rather than one per store
because every launcher except Steam has exactly a single UI to open; Steam keeps
its own kind because it genuinely has two. D1 is preserved — the plugin names a
launcher, the host builds the command, and no shell string crosses the wire:

  heroic -> the same native-or-Flatpak resolution the `heroic` game kind uses,
            minus --no-gui and minus the URI, so the window itself opens
  lutris -> bare `lutris`, which opens the window (the URI form is `lutris_id`)

Platform-gated to what this host can actually resolve, and validated INBOUND: a
value naming a launcher this OS cannot open is a 400 the plugin author can act
on, not a tile that silently does nothing when a user clicks it. Windows
launchers (Epic, GOG Galaxy, Xbox app) are deliberately absent — each needs its
own verified activation and a guess would ship exactly that dead tile.

Also closes a WP4.3 item I under-delivered and did not flag: the console's
add/edit form had no way to mark an entry as a launcher, so even hand-adding one
was impossible. It now has the checkbox — and `formFrom` round-trips it, without
which editing a launcher entry would silently demote it to a game, which is the
precise bug that file's own comment warns about.

Gates on .21: punktfunk-host 435 passed / 0 failed (two new), workspace clippy
-D warnings clean, cargo fmt --all --check clean, OpenAPI drift green. Console:
orval + paraglide regen, tsc clean, check-i18n at 604 messages for en + de.

Still unproven on hardware: no launcher tile has been clicked on a real host.
The steam plugin (the first to emit one) is not built yet.
2026-08-05 21:12:19 +02:00

139 lines
6.6 KiB
TypeScript

// The library-provider wire schemas — a browser-safe module (no node imports) so plugin
// CONTRACTS can share these types with their UIs. Mirrors the host's `ProviderEntryInput`
// (crates/punktfunk-host mgmt/library.rs). Identity codecs: plain JSON shapes, so values
// pass through unencoded; the value is the shared type + authoring validation.
import { Schema } from "effect";
export const Artwork = Schema.Struct({
portrait: Schema.optionalKey(Schema.NullOr(Schema.String)),
hero: Schema.optionalKey(Schema.NullOr(Schema.String)),
logo: Schema.optionalKey(Schema.NullOr(Schema.String)),
header: Schema.optionalKey(Schema.NullOr(Schema.String)),
});
export type Artwork = typeof Artwork.Type;
/**
* How the host should launch a title. **The host owns this vocabulary** — it validates the value
* per kind and builds the actual URI / command line itself, so a plugin only ever supplies a
* validated value, never a command. That is the security invariant behind the whole provider lane:
* a client sends an entry id, and the host resolves what to run.
*
* `kind` is a plain string rather than a union so the kit never has to ship a release to keep up
* with a host that grew a new kind. The kinds the host understands today:
*
* | kind | value | platforms |
* |---|---|---|
* | `command` | a shell command (operator-trust tier) | both |
* | `steam_appid` | digits — an appid, or a 64-bit non-Steam-shortcut game id | both |
* | `steam_ui` | `bigpicture` \| `desktop` — opens the Steam client itself | both |
* | `launcher_ui` | a store id (`heroic`, `lutris`) — opens that launcher's own UI | linux |
* | `lutris_id` | digits — a pga.db game id | linux |
* | `heroic` | `<runner>:<appName>`, runner ∈ legendary/gog/nile | linux |
* | `epic` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |
* | `gog` | `exe \t args \t workdir` | windows |
* | `aumid` | `<PFN>!<AppId>` | windows |
*
* An unknown kind is accepted on the wire and simply yields no launch recipe on that host, so a
* plugin targeting a newer host degrades to an unlaunchable tile rather than a failed reconcile.
*/
export const LaunchSpec = Schema.Struct({
kind: Schema.String,
value: Schema.String,
});
export type LaunchSpec = typeof LaunchSpec.Type;
/**
* Whether an entry is an ordinary title or the launcher application itself (Steam Big Picture,
* Heroic, Playnite fullscreen). Launcher entries launch, lease and list exactly like games; a
* console or client that knows the field groups them into their own rail, and one that doesn't
* renders them as plain tiles.
*/
export const GameRole = Schema.Literals(["game", "launcher"]);
export type GameRole = typeof GameRole.Type;
export const PrepStep = Schema.Struct({
do: Schema.String,
undo: Schema.optionalKey(Schema.NullOr(Schema.String)),
});
export type PrepStep = typeof PrepStep.Type;
/**
* How the host should recognize a title's process once it is running.
*
* Every field is optional, and omitting the whole thing is fine: the host tracks the process it
* spawns for the entry anyway. It matters when your launch command hands off and exits — a launcher
* client, a `flatpak run`, a front-end that starts an emulator — because then the host has nothing
* left to watch, and the two behaviors this feeds ("end the session when the game exits" and "end the
* game when the session ends") go quiet for that title.
*
* Send whatever you actually know. `install_dir` is the one worth sending if you send only one: any
* process running from under it counts as the game.
*/
export const DetectHint = Schema.Struct({
/** Where the title is installed (absolute path on the host). */
install_dir: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** The game's own executable (absolute path on the host). */
exe: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest signal. */
process_name: Schema.optionalKey(Schema.NullOr(Schema.String)),
/**
* The Steam appid, for a title Steam itself installed. On Linux this is the **sharpest** signal
* there is: Steam wraps every launch — native or Proton — in `reaper SteamLaunch AppId=<appid>`,
* whose lifetime is exactly the game's. Send it if you have it.
*/
steam_appid: Schema.optionalKey(Schema.NullOr(Schema.Number)),
/**
* An environment variable the launcher stamps on the game's process. Load-bearing for launchers
* that run games under Proton/Wine, where the process tree tells you very little (Heroic's
* `HEROIC_APP_NAME` is the verified case). Omit `value` to match on the key's mere presence —
* only safe for a launcher that runs one game at a time.
*/
env_marker: Schema.optionalKey(
Schema.NullOr(
Schema.Struct({
/** `[A-Za-z0-9_]{1,64}` — the host rejects anything else. */
key: Schema.String,
/** At most 256 chars. */
value: Schema.optionalKey(Schema.NullOr(Schema.String)),
}),
),
),
});
export type DetectHint = typeof DetectHint.Type;
/** Descriptive metadata, flat on the wire beside `title` (mirrors the host's flattened
* `GameMeta`). All fields optional; values are free-form display strings — the host does not
* normalize platform/genre vocabularies. */
export const GameMeta = Schema.Struct({
/** The system the title runs on — `"PS2"`, `"Xbox 360"`, `"SNES"`, … */
platform: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** Short blurb for a details pane. */
description: Schema.optionalKey(Schema.NullOr(Schema.String)),
developer: Schema.optionalKey(Schema.NullOr(Schema.String)),
publisher: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** Year of first release. */
release_year: Schema.optionalKey(Schema.NullOr(Schema.Number)),
/** Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …). */
genres: Schema.optionalKey(Schema.Array(Schema.String)),
/** Free-form organizational labels (`"co-op"`, `"kids"`, …). */
tags: Schema.optionalKey(Schema.Array(Schema.String)),
/** Release region — `"NTSC-U"`, `"PAL"`, `"NTSC-J"`. */
region: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** Maximum simultaneous (local) players. */
players: Schema.optionalKey(Schema.NullOr(Schema.Number)),
});
export type GameMeta = typeof GameMeta.Type;
export const ProviderEntry = Schema.Struct({
external_id: Schema.String,
title: Schema.String,
art: Schema.optionalKey(Artwork),
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
prep: Schema.optionalKey(Schema.Array(PrepStep)),
detect: Schema.optionalKey(DetectHint),
/** `"game"` (default) or `"launcher"` — see {@link GameRole}. */
role: Schema.optionalKey(GameRole),
...GameMeta.fields,
});
export type ProviderEntry = typeof ProviderEntry.Type;