Files
punktfunk/plugin-kit/src/wire.ts
T
enricobuehler f62a48d4a9
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 5m40s
feat(library): launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client
A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.

Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).

A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.

assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.

scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.

All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.

Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.

api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).

Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
2026-08-10 23:26:47 +02:00

189 lines
9.4 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. (`plugin`, below, is the one kind
* whose command the plugin composes — but it is still never *stored*: the host asks the live plugin
* at launch time, so an entry on its own executes nothing.)
*
* `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 |
* | `plugin` | an opaque key in THIS plugin's namespace — see below | both |
*
* `plugin` is the escape hatch for a tile the host cannot name on its own (a ROM through whichever
* emulator the operator configured). The value is meaningless to the host: it hands the key back to
* the plugin that published the entry, on its own loopback UI port, and runs the command line that
* comes back. Serve it with `serveUi({launch})`; a plugin that publishes this kind without serving
* `/__launch` grows unlaunchable tiles.
*
* 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;
/**
* The brand marks the shipped clients draw for a launcher tile. A plugin puts one of these in an
* entry's `icon` and every client resolves it against the art it bundles
* (`assets/launcher-icons` — provenance and licensing in that directory's README).
*
* Not a union type on purpose, exactly like {@link LaunchSpec}'s `kind`: a client that has never
* heard of a token falls back to naming the launcher on an accent face — which is what every
* launcher tile looked like before icons existed — so a plugin naming a mark a *newer* client
* ships must not fail to typecheck against an older kit.
*/
export const LAUNCHER_ICONS = [
"steam",
"lutris",
"heroic",
"playnite",
"epic",
"gog",
"xbox",
] as const;
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),
/**
* Which brand mark a client should draw for this entry — a **token** ({@link LAUNCHER_ICONS}),
* never image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`; the host rejects anything else.
*
* This is what makes a launcher tile look like its launcher. Launcher entries ship no cover art
* by design — a launcher's own icon is square, clients cover-crop a 2:3 poster, and the crop
* turns a mark into a strip — so before this they were the launcher's name on a flat accent
* face. Naming the mark instead of sending it keeps the glyph vector at any tile size, lets it
* take the tile's ink, and adds nothing to a reconcile payload that is already body-limited.
*
* Sending art instead is not an option the host leaves open: its art proxy serves raster
* containers only and refuses SVG outright, because SVG is script-capable XML and the web
* console renders library art in a browser.
*
* A token no client bundles is not an error — that tile just falls back to its name. To get a
* new launcher's mark shipped, open a PR adding the master to `assets/launcher-icons`.
*
* Set it on your `launchers(cfg)` entries. Ordinary titles may carry one, but shouldn't: a game
* has real cover art, which beats a brand mark every time.
*/
icon: Schema.optionalKey(Schema.String),
...GameMeta.fields,
});
export type ProviderEntry = typeof ProviderEntry.Type;