Files
punktfunk/crates/punktfunk-host/src/library.rs
T
enricobuehler 3d4a659959 feat(host,sdk,kit): store claims, launcher entries, and plugin sources on the wire
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.
2026-08-05 09:39:31 +02:00

317 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Game library (plan: "surface the user's games"). A small adapter layer over the *stores*
//! installed on the host — today **Steam** (read from local files, no API key) and a
//! user-curated **custom** store (CRUD'd via the management API / web console). Every store
//! produces the same [`GameEntry`], so a client renders one uniform grid and never has to know
//! which launcher a title came from. Future stores (Heroic/Epic, GOG, Lutris, EmuDeck) are just
//! more [`LibraryProvider`]s.
//!
//! Artwork is keyed only by Steam appid against the public Steam CDN (no auth) — the client
//! fetches the posters directly. Custom entries carry user-supplied art URLs.
//!
//! This module is read-mostly metadata; *launching* a chosen title (mapping [`LaunchSpec`] onto a
//! gamescope session) is a later step — the launch hint is carried here so that wiring is trivial.
// Shared vocabulary re-exported to the submodules (each is `use super::*`).
pub(crate) use anyhow::{Context, Result};
pub(crate) use serde::{Deserialize, Serialize};
pub(crate) use sha2::{Digest, Sha256};
pub(crate) use std::collections::{BTreeMap, HashSet};
pub(crate) use std::path::{Path, PathBuf};
pub(crate) use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) use utoipa::ToSchema;
mod art;
mod custom;
mod detect;
#[cfg(windows)]
mod epic;
#[cfg(windows)]
mod gog;
#[cfg(target_os = "linux")]
mod heroic;
mod launch;
#[cfg(target_os = "linux")]
mod lutris;
mod scanners;
mod steam;
#[cfg(windows)]
mod xbox;
pub use art::*;
pub use custom::*;
pub use detect::*;
#[cfg(windows)]
pub use epic::*;
#[cfg(windows)]
pub use gog::*;
#[cfg(target_os = "linux")]
pub use heroic::*;
pub use launch::*;
#[cfg(target_os = "linux")]
pub use lutris::*;
pub use scanners::*;
pub use steam::*;
#[cfg(windows)]
pub use xbox::*;
/// Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for
/// custom). The client prefers `portrait` for a grid and falls back to `header` when a title has
/// no 600×900 capsule (common for older Steam apps).
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct Artwork {
/// Vertical capsule / poster (Steam `library_600x900.jpg`). Best for a grid.
#[serde(skip_serializing_if = "Option::is_none")]
pub portrait: Option<String>,
/// Wide background (Steam `library_hero.jpg`).
#[serde(skip_serializing_if = "Option::is_none")]
pub hero: Option<String>,
/// Transparent title logo (Steam `logo.png`).
#[serde(skip_serializing_if = "Option::is_none")]
pub logo: Option<String>,
/// Horizontal header (Steam `header.jpg`) — the universal fallback.
#[serde(skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
}
/// How the host would launch a title (consumed by the session launcher in a later step). Kept
/// open-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;
/// `command` → run `<value>` nested in a gamescope session.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct LaunchSpec {
/// `"steam_appid"` or `"command"`.
#[schema(example = "steam_appid")]
pub kind: String,
/// The appid (for `steam_appid`) or the shell command (for `command`).
pub value: String,
}
/// Descriptive metadata for a title — everything a richer library UI (details pane, platform
/// filter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to
/// absent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is
/// `#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat
/// wire shape everywhere.
///
/// Values are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)
/// each have their own vocabulary and the host has no business normalizing it.
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct GameMeta {
/// The system the title runs on — `"PS2"`, `"Xbox 360"`, `"SNES"`, … Installed-store
/// scanners stamp `"PC"`; `GET /library?platform=` filters on it (case-insensitive).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(example = "PS2")]
pub platform: Option<String>,
/// Short blurb for a details pane.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub developer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
/// Year of first release — the granularity metadata sources reliably agree on.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(example = 2001)]
pub release_year: Option<u16>,
/// Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub genres: Vec<String>,
/// Free-form organizational labels (`"co-op"`, `"kids"`, `"finished"`, …).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Release region — emulation-relevant (`"NTSC-U"`, `"PAL"`, `"NTSC-J"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
/// Maximum simultaneous (local) players.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub players: Option<u8>,
}
impl GameMeta {
/// The one field an installed-store scanner can assert about its own titles: they run on this
/// host, i.e. on a PC. Everything else stays absent (the launchers' local files don't carry it).
pub(crate) fn pc() -> Self {
GameMeta {
platform: Some("PC".into()),
..Default::default()
}
}
}
/// What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big
/// Picture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,
/// leases and lists exactly like a game (design D4), and clients that don't know the field render it
/// as a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged
/// for every entry that doesn't opt in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum GameRole {
/// An ordinary title.
#[default]
Game,
/// The launcher application itself.
Launcher,
}
impl GameRole {
/// Whether this is the serde default (`game`) — the `skip_serializing_if` predicate that keeps
/// the field off the wire for the overwhelming majority of entries.
pub(crate) fn is_game(&self) -> bool {
matches!(self, Self::Game)
}
}
/// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry {
/// Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.
#[schema(example = "steam:570")]
pub id: String,
/// Which store surfaced it: `"steam"` or `"custom"`.
#[schema(example = "steam")]
pub store: String,
pub title: String,
pub art: Artwork,
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
#[serde(default, skip_serializing_if = "GameRole::is_game")]
pub role: GameRole,
/// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
/// The external provider owning this entry (custom-store entries synced by a provider
/// plugin, RFC §8) — `None` for installed-store titles and manual custom entries. The
/// console uses it for attribution; `GET /library?provider=` filters on it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// How to recognize this title's process(es) once it is running ([`DetectSpec`]) — filled in by
/// each provider from paths it already read while scanning.
///
/// **Host-internal: never serialized.** It names local filesystem paths, so it stays out of both
/// the catalog JSON the client renders and the OpenAPI schema; it rides here only so the
/// providers that already hold this data don't have to be re-scanned.
#[serde(skip)]
#[schema(ignore)]
pub detect: DetectSpec,
/// Descriptive metadata, flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
}
/// A store that contributes titles to the library. The trait is the extension point for future
/// launchers; today only [`SteamProvider`] implements it.
pub trait LibraryProvider {
/// Stable store id (`"steam"`, …).
fn store(&self) -> &'static str;
/// Enumerate installed/owned titles. Best-effort: returns empty (not an error) when the store
/// isn't present, so one missing launcher never fails the whole library.
fn list(&self) -> Vec<GameEntry>;
}
/// Steam art, keyed to one of the four [`Artwork`] fields. Newer/recently-updated titles serve
/// their CDN assets from a per-asset-hash path the client can't predict (e.g.
/// `.../apps/<id>/<hash>/header.jpg`), so the flat legacy URL [`steam_art`] guesses 404s for them —
/// [`steam_art_bytes`] is the robust resolver: local Steam cache (exact, no guessing) first, the
/// flat CDN URL as a fallback (still correct for the many titles that haven't been re-hashed).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArtKind {
Portrait,
Hero,
Logo,
Header,
}
impl ArtKind {
pub fn parse(s: &str) -> Option<Self> {
match s {
"portrait" => Some(Self::Portrait),
"hero" => Some(Self::Hero),
"logo" => Some(Self::Logo),
"header" => Some(Self::Header),
_ => None,
}
}
/// Filenames Steam itself caches this kind under in `appcache/librarycache/<appid>/<hash>/`,
/// tried in order (the 2x portrait, when present, is the sharper asset).
fn local_filenames(self) -> &'static [&'static str] {
match self {
Self::Portrait => &["library_600x900_2x.jpg", "library_600x900.jpg"],
Self::Hero => &["library_hero.jpg"],
Self::Logo => &["logo.png"],
// Steam's local cache names the header asset differently from the store CDN's
// `header.jpg` (see `cdn_filename`).
Self::Header => &["library_header.jpg"],
}
}
/// The legacy flat-URL filename on the public Steam CDN (works for any title the CDN hasn't
/// migrated to a per-asset hash path).
fn cdn_filename(self) -> &'static str {
match self {
Self::Portrait => "library_600x900.jpg",
Self::Hero => "library_hero.jpg",
Self::Logo => "logo.png",
Self::Header => "header.jpg",
}
}
}
/// The full library: every *enabled* source's titles merged + the custom entries, sorted by title.
///
/// Two independent gates run here, both at READ time so neither ever mutates stored state:
///
/// * **The operator's source toggles** (`scanners.rs`, persisted as a disabled-set in
/// `library-scanners.json`) hide a source's titles from every surface — this grid, native clients,
/// `/applist`, and launch resolution. They apply to built-in scanners *and* to plugin sources,
/// which is what lets one toggle keep working verbatim across the whole migration: the ids match
/// (provider id = claimed store id = old scanner id).
/// * **Store claims** (D2): while a library plugin holds a store's claim, the matching built-in
/// scanner is skipped so the two never double-list the same titles during the bridge releases.
/// Removing the plugin releases the claim and the built-in comes straight back.
///
/// The user-curated custom store is not a source and always contributes.
pub fn all_games() -> Vec<GameEntry> {
let off = disabled_scanners();
let claimed = claimed_stores();
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
// store out from under it.
let on = |id: &str| !off.contains(id) && !claimed.contains_key(id);
let mut games = Vec::new();
if on("steam") {
games.extend(SteamProvider.list());
}
// The Lutris + Heroic providers are Linux-only (their launchers are); on other hosts the library
// is Steam + custom. Each provider is best-effort (empty when its store isn't present).
#[cfg(target_os = "linux")]
{
if on("lutris") {
games.extend(LutrisProvider.list());
}
if on("heroic") {
games.extend(HeroicProvider.list());
}
}
// Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass.
#[cfg(windows)]
{
if on("epic") {
games.extend(EpicProvider.list());
}
if on("gog") {
games.extend(GogProvider.list());
}
if on("xbox") {
games.extend(XboxProvider.list());
}
}
// Stored entries: manual ones always contribute; a provider's are subject to the same source
// toggle a built-in scanner is (WP2.6). The plugin may keep reconciling while it is off — the
// entries stay stored and simply aren't surfaced, exactly like a disabled scanner's titles.
games.extend(
load_custom()
.into_iter()
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
.map(GameEntry::from),
);
games.sort_by_key(|g| g.title.to_lowercase());
games
}