forked from unom/punktfunk
M2 of design/library-scanner-plugins-implementation-plan.md. Everything a
library scanner plugin needs is now expressible over the API; all additive.
WP2.1/2.2 — store claims (D2). library.json gains a v2 shape ({entries, claims})
that loads the v1 bare array unchanged and is written on the first mutation.
PUT /library/provider/{p}?store=<s> claims a store for a provider: its entries
then surface with deterministic <store>:<external_id> ids and the store's own
badge instead of opaque custom:<id> ones. That identity is the whole point —
entry ids, GameStream FNV app ids, client art caches and Moonlight pins all
survive a title moving from an in-host scanner to a plugin. One provider per
store (409 otherwise); DELETE releases; an empty reconcile does NOT (a store can
legitimately have zero titles). While a claim is held, all_games() skips the
matching built-in scanner, so the two never double-list during the bridge.
WP2.3 — DetectHint gains steam_appid and env_marker, the two store-derived
signals the host used to read for itself. Without them a steam plugin's lease
tracking would drop from reaper-exact to dir-prefix, and Heroic-under-Proton
would lose the only signal that works. Malformed markers are dropped, not
honoured — this feeds a path that can end processes.
WP2.4/2.5 — role: game|launcher on the entry shapes (serde-default, skipped when
default), and a steam_ui launch kind valued bigpicture|desktop that opens the
Steam client itself. Validated inbound as well as at launch.
WP2.6 — GET/PUT /library/scanners generalizes to SOURCES: built-in scanners
minus claimed ones, plus claimed stores, plus any provider with entries. The
same library-scanners.json disabled-set backs all of them and the ids match by
construction, so a user's disabled state carries over verbatim through the whole
migration. A disabled plugin source has its entries filtered at read time,
exactly like a disabled scanner.
WP2.7/2.8 — plugin registration gains a category field (the console keeps
library plugins out of the nav); index entries gain categories and per-platform
detect probes, evaluated existence-only into CatalogEntry.detected so the host
never re-grows per-store knowledge. Index SCHEMA stays 1 — additive.
WP2.9 — OpenAPI + SDK regenerated on Linux; kit wire widened (LaunchSpec.kind is
now a plain string documented against the host's vocabulary — closes G3), and
ProviderClient.reconcile takes an optional store and returns the host's echoed
entries so a caller can detect a pre-M2 host silently ignoring the claim.
Also fixes a bug the S3 spike turned up: is_steam_launch gated on a steam:// URI,
so a steam_ui launcher entry would have skipped BOTH gamescope's --steam mode and
the B1 single-instance free — on a box autologged into game mode, the nested
second Steam would see the first and exit, crashing the spawn. It now tests the
first token.
Gates on .21: workspace tests green (punktfunk-host 425 passed), workspace
clippy -D warnings clean, cargo fmt --all --check clean, OpenAPI drift test
green. plugin-kit: tsc clean, 20 tests pass.
138 lines
6.6 KiB
TypeScript
138 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 |
|
|
* | `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;
|