From a418d2852a4313e7a2129daa2f60996fccf18038 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 09:04:22 +0200 Subject: [PATCH 01/64] refactor(host/library): launch helpers into launch.rs, art proxy resolves any id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 of design/library-scanner-plugins-implementation-plan.md — behavior-frozen groundwork for lifting the six scanners out into plugins. WP1.1: heroic_command/heroic_launch_prefix, epic_launch_uri, gog_spawn, valid_steam_appid and shortcut_gameid move into library/launch.rs with their unit tests. The scanner modules beside it now do enumeration only, so they can be deleted wholesale later without taking launch logic with them (D1). WP1.2: is_local_art_path accepts file:// (the plugin contract) and POSIX absolute paths, excluding the two /-leading shapes the host itself emits (its own /api/ proxy path and protocol-relative CDN URLs). local_art_bytes percent-decodes and converts a file:// value first. The art proxy and fetch_box_art resolve ANY id against library.json before the legacy steam: branch, so a plugin's entries serve art without the host knowing its store. No API change; no user-visible change. --- api/openapi.json | 4 +- crates/punktfunk-host/src/library/art.rs | 203 ++++++++++++++++++-- crates/punktfunk-host/src/library/custom.rs | 56 ++++-- crates/punktfunk-host/src/library/epic.rs | 36 +--- crates/punktfunk-host/src/library/gog.rs | 29 +-- crates/punktfunk-host/src/library/heroic.rs | 44 +---- crates/punktfunk-host/src/library/launch.rs | 166 +++++++++++++++- crates/punktfunk-host/src/library/steam.rs | 15 +- crates/punktfunk-host/src/mgmt/library.rs | 40 ++-- sdk/src/gen/punktfunk.ts | 11 +- 10 files changed, 425 insertions(+), 179 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 867ab315..24d7828e 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.23.0" + "version": "0.24.0" }, "paths": { "/api/v1/clients": { @@ -1052,7 +1052,7 @@ "library" ], "summary": "Fetch one cover-art image for a library entry", - "description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.", + "description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).", "operationId": "getLibraryArt", "parameters": [ { diff --git a/crates/punktfunk-host/src/library/art.rs b/crates/punktfunk-host/src/library/art.rs index 9536c1fa..23be4c3a 100644 --- a/crates/punktfunk-host/src/library/art.rs +++ b/crates/punktfunk-host/src/library/art.rs @@ -147,24 +147,91 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec, String)> { /// A stored [`Artwork`] value that is a **local filesystem path** to an image on the host — as /// opposed to an `http(s)`/`data:` URL or an already-relative host proxy path. Provider plugins that -/// run on the host (e.g. the Playnite sync plugin) set these: the reconcile payload stays tiny -/// (paths, not inlined bytes, so it scales to thousands of titles) and the host serves the bytes -/// through the art proxy, exactly like Steam's cache art. Windows-shaped only (`C:\…`, `C:/…`, or a -/// `\\server\share` UNC) — Playnite, the only local-art provider, is Windows-only, and this keeps the -/// check from ever mistaking the `/api/…` proxy path (or a POSIX abs path) for a local file. +/// run on the host (the Playnite sync plugin, and every library scanner plugin) set these: the +/// reconcile payload stays tiny (paths, not inlined bytes, so it scales to thousands of titles) and +/// the host serves the bytes through the art proxy, exactly like Steam's cache art. +/// +/// Four accepted shapes: +/// * `file://…` — the **documented plugin contract** ([`file_url_to_path`]), unambiguous on every +/// platform, and what `@punktfunk/plugin-kit/library` emits. +/// * `C:\…` / `C:/…` drive-absolute and `\\server\share` UNC — Windows bare paths, kept for +/// Playnite back-compat (it predates the `file://` contract). +/// * POSIX absolute (`/home/u/covers/x.jpg`) — Lutris covers and Steam's `librarycache`. +/// +/// The POSIX widening is why the two `/`-leading shapes the **host itself emits** must be excluded +/// explicitly: its own art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and +/// which must survive a second pass unchanged) and a protocol-relative URL (`//cdn/…`, what GOG's and +/// Microsoft's catalogs return — see [`abs_url`]). Mistaking either for a file would break the proxy +/// round-trip or silently drop CDN art. pub fn is_local_art_path(v: &str) -> bool { if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") { return false; } + if v.starts_with("file://") { + return true; + } let b = v.as_bytes(); - (b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\") + // Windows drive-absolute (`C:\…`, `C:/…`) or UNC (`\\server\share`). + if (b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\") { + return true; + } + // POSIX absolute, minus the host's own `/`-leading shapes (see the doc comment). + v.starts_with('/') && !v.starts_with("//") && !v.starts_with("/api/") +} + +/// Turn a `file://` art value into a plain filesystem path, percent-decoding it. The kit emits +/// properly encoded URLs (`file:///home/u/My%20Cover.jpg`); a raw path that happens to contain no +/// `%` round-trips either way, which keeps hand-written plugin payloads working. +/// +/// `file:///home/u/c.jpg` → `/home/u/c.jpg`; `file:///C:/covers/c.jpg` → `C:/covers/c.jpg` (Windows +/// drive letters arrive after the empty authority's slash); a NON-empty authority +/// (`file://nas/share/c.jpg`) is a UNC reference → `\\nas\share\c.jpg`. Anything without the prefix +/// is returned untouched. +fn file_url_to_path(v: &str) -> std::borrow::Cow<'_, str> { + use std::borrow::Cow; + let Some(rest) = v.strip_prefix("file://") else { + return Cow::Borrowed(v); + }; + let decoded = percent_decode(rest); + match decoded.strip_prefix('/') { + // `file:///…` — the empty-authority form. A Windows drive letter (`/C:/…`) loses the slash; + // a POSIX path keeps it. + Some(after) if after.as_bytes().get(1) == Some(&b':') => Cow::Owned(after.to_string()), + Some(_) => Cow::Owned(decoded), + // `file://server/share/…` — a UNC path in URL clothing. + None => Cow::Owned(format!("\\\\{}", decoded.replace('/', "\\"))), + } +} + +/// Percent-decode `%XX` escapes. Invalid escapes are left verbatim (a bare `%` in a real path is far +/// likelier than a malformed URL from our own kit), and the result is only ever used as a path that +/// must then exist as a regular file — so a wrong decode degrades to "no art", never to a wrong read. +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' && i + 2 < b.len() { + let hex = |c: u8| (c as char).to_digit(16); + if let (Some(hi), Some(lo)) = (hex(b[i + 1]), hex(b[i + 2])) { + out.push((hi * 16 + lo) as u8); + i += 3; + continue; + } + } + out.push(b[i]); + i += 1; + } + String::from_utf8(out).unwrap_or_else(|_| s.to_string()) } /// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an /// existing regular file, is empty, or exceeds 16 MiB (a cover never approaches that; the cap bounds -/// host memory). Content-type is guessed from the extension. +/// host memory). Content-type is guessed from the extension. Accepts every shape +/// [`is_local_art_path`] does — a `file://` value is converted to a path first. pub fn local_art_bytes(path: &str) -> Option<(Vec, String)> { - let p = std::path::Path::new(path); + let path = file_url_to_path(path); + let p = std::path::Path::new(&*path); let meta = std::fs::metadata(p).ok()?; if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 { return None; @@ -221,9 +288,22 @@ pub fn proxy_local_art(id: &str, art: &mut Artwork) { /// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the /// async runtime (e.g. `spawn_blocking`). pub fn fetch_box_art(id: &str) -> Option<(Vec, String)> { - // Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves - // against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve - // those kinds directly instead of going through the URL fields. + // Same resolution order as the management art proxy (WP1.2): the stored catalog first, for ANY + // id, so a library plugin's entries resolve without the warmer knowing its store. + if let Some(entry) = entry_for_library_id(id) { + return [ + ArtKind::Portrait, + ArtKind::Header, + ArtKind::Hero, + ArtKind::Logo, + ] + .into_iter() + .filter_map(|kind| art_field(&entry.art, kind)) + .find_map(|v| resolve_art_bytes(&v)); + } + // Legacy in-host Steam scanner: its `Artwork` fields are relative proxy paths (see `steam_art`) + // the *client* resolves against the host — meaningless to `fetch_image`, which expects an + // absolute URL. Resolve those kinds directly instead of going through the URL fields. if let Some(appid) = id .strip_prefix("steam:") .and_then(|s| s.parse::().ok()) @@ -237,6 +317,7 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec, String)> { .into_iter() .find_map(|kind| steam_art_bytes(appid, kind)); } + // The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs. let g = all_games().into_iter().find(|g| g.id == id)?; [g.art.portrait, g.art.header, g.art.hero, g.art.logo] .into_iter() @@ -335,19 +416,60 @@ mod tests { assert!(fetch_image("data:image/png;base64,").is_none()); } + /// The full accept/exclude table (WP1.2). The exclusions are the load-bearing half: two of the + /// three `/`-leading shapes here are emitted by the host ITSELF, so a POSIX rule that swallowed + /// them would break the proxy round-trip and silently drop CDN art. #[test] fn local_art_path_detection() { // Windows-shaped local paths a provider (Playnite) would store. assert!(is_local_art_path(r"C:\Users\me\cover.jpg")); assert!(is_local_art_path("C:/Users/me/cover.png")); assert!(is_local_art_path(r"\\nas\share\art.jpg")); - // URLs and the host proxy path are NOT local files. + // The `file://` plugin contract, on both platform shapes. + assert!(is_local_art_path("file:///home/u/covers/x.jpg")); + assert!(is_local_art_path("file:///C:/covers/x.jpg")); + // POSIX absolute — lutris covers, steam librarycache. + assert!(is_local_art_path("/home/u/.cache/lutris/coverart/x.jpg")); + assert!(is_local_art_path("/var/lib/steam/librarycache/570/h.jpg")); + // URLs are NOT local files. assert!(!is_local_art_path("https://cdn/x.jpg")); assert!(!is_local_art_path("http://host/x.jpg")); assert!(!is_local_art_path("data:image/png;base64,AAAA")); + // …nor is the host's OWN art-proxy path (it must survive a second `proxy_local_art` pass). assert!(!is_local_art_path( "/api/v1/library/art/custom:abc/portrait" )); + assert!(!is_local_art_path("/api/v1/library/art/steam:570/hero")); + // …nor a protocol-relative CDN URL (what GOG / the MS catalog return — see `abs_url`). + assert!(!is_local_art_path("//images.gog.com/abc_vertical.jpg")); + // A relative path is not absolute — nothing to serve. + assert!(!is_local_art_path("covers/x.jpg")); + assert!(!is_local_art_path("")); + } + + #[test] + fn file_url_converts_to_a_path_and_percent_decodes() { + assert_eq!(file_url_to_path("file:///home/u/c.jpg"), "/home/u/c.jpg"); + // Percent-encoded spaces — what a correct URL encoder emits for a real-world cover path. + assert_eq!( + file_url_to_path("file:///home/u/My%20Games/c%2Bx.jpg"), + "/home/u/My Games/c+x.jpg" + ); + // Windows drive letters arrive after the empty authority's slash and lose it. + assert_eq!( + file_url_to_path("file:///C:/covers/c.jpg"), + "C:/covers/c.jpg" + ); + // A non-empty authority is a UNC reference. + assert_eq!( + file_url_to_path("file://nas/share/c.jpg"), + r"\\nas\share\c.jpg" + ); + // Non-`file://` values are returned untouched (bare paths still work). + assert_eq!(file_url_to_path("/home/u/c.jpg"), "/home/u/c.jpg"); + assert_eq!(file_url_to_path(r"C:\c.jpg"), r"C:\c.jpg"); + // A lone `%` (a legal path character) is not mangled into a decode failure. + assert_eq!(file_url_to_path("file:///home/100%.jpg"), "/home/100%.jpg"); } #[test] @@ -371,6 +493,54 @@ mod tests { ); } + /// A POSIX local cover — the shape the lutris pilot and the steam plugin emit — makes the whole + /// round trip: detected as local, rewritten to the proxy path, and read back as bytes. This is + /// the case G4 blocked (Lutris art was inlined as `data:` URLs and blew the 2 MB body limit). + #[test] + fn posix_local_art_round_trips_through_the_proxy() { + let dir = std::env::temp_dir().join(format!("pf-art-posix-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("cover.jpg"); + std::fs::write(&f, [9u8, 9, 9]).unwrap(); + let path = f.to_str().unwrap().to_string(); + + let mut art = Artwork { + portrait: Some(path.clone()), + hero: Some(format!("file://{path}")), + logo: Some("https://cdn/l.png".into()), + header: None, + }; + // Only on non-Windows is a temp path POSIX-absolute; on Windows it is drive-absolute, which + // the pre-existing rule already accepted — either way both fields are local. + assert!(is_local_art_path(&path)); + proxy_local_art("lutris:42", &mut art); + assert_eq!( + art.portrait.as_deref(), + Some("/api/v1/library/art/lutris:42/portrait") + ); + assert_eq!( + art.hero.as_deref(), + Some("/api/v1/library/art/lutris:42/hero"), + "a file:// value is local art too" + ); + assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png")); + + // Re-running the rewrite is a no-op — the emitted proxy path must not be mistaken for a file. + let before = art.portrait.clone(); + proxy_local_art("lutris:42", &mut art); + assert_eq!(art.portrait, before); + + // Both spellings read back to the same bytes. + assert_eq!(local_art_bytes(&path).expect("bare path").0, vec![9, 9, 9]); + assert_eq!( + local_art_bytes(&format!("file://{path}")) + .expect("file url") + .0, + vec![9, 9, 9] + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn local_art_bytes_reads_a_real_file() { let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id())); @@ -381,6 +551,15 @@ mod tests { assert_eq!(bytes, vec![1, 2, 3, 4]); assert_eq!(ctype, "image/png"); assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none()); + // A directory is not a servable cover, and neither is a traversal that lands on one — the + // "existing REGULAR file" check is the confinement, since a plugin's art values are + // operator-trusted paths but must still never turn the proxy into a directory reader. + assert!(local_art_bytes(dir.to_str().unwrap()).is_none()); + let up = dir.join("..").join(dir.file_name().unwrap()); + assert!( + local_art_bytes(up.to_str().unwrap()).is_none(), + "dir via .." + ); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index de1bbf6c..ab0613fd 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -101,7 +101,7 @@ impl From for GameEntry { .unwrap_or_default() .or_hint(&c.detect); GameEntry { - id: format!("custom:{}", c.id), + id: library_id_for(&c), store: "custom".into(), title: c.title, art: c.art, @@ -133,24 +133,44 @@ pub fn load_custom() -> Vec { } } -/// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam -/// branch of the art proxy (`GET /library/art/custom:/`). `id` is the bare custom id (the -/// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of -/// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches -/// itself). Blocking IO — call off the async runtime. -pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec, String)> { - let entry = load_custom().into_iter().find(|e| e.id == id)?; - let field = match kind { - ArtKind::Portrait => entry.art.portrait, - ArtKind::Hero => entry.art.hero, - ArtKind::Logo => entry.art.logo, - ArtKind::Header => entry.art.header, - }?; +/// The library id a stored entry surfaces as. **The single source of truth for the mapping** — +/// [`From for GameEntry`] and every id→entry lookup go through it, so a change to the +/// id scheme (the store claims of D2 will make claimed entries `:`) lands in one +/// place instead of drifting between the catalog and the art proxy. +pub(crate) fn library_id_for(e: &CustomEntry) -> String { + format!("custom:{}", e.id) +} + +/// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this +/// way before falling back to the legacy per-store branches (WP1.2), which is what lets a plugin's +/// entries be served regardless of what their ids look like. +pub fn entry_for_library_id(library_id: &str) -> Option { + load_custom() + .into_iter() + .find(|e| library_id_for(e) == library_id) +} + +/// Serve a stored entry's **local** art file for one [`ArtKind`] — the `library.json` branch of the +/// art proxy (`GET /library/art//`). `None` if the id names no stored entry, it has +/// no art of that kind, or that art value isn't a servable local file (e.g. an `http` URL the client +/// fetches itself). Blocking IO — call off the async runtime. +pub fn library_local_art_bytes(library_id: &str, kind: ArtKind) -> Option<(Vec, String)> { + let field = art_field(&entry_for_library_id(library_id)?.art, kind)?; is_local_art_path(&field) .then(|| local_art_bytes(&field)) .flatten() } +/// One [`Artwork`] field by kind — the tiny mapping the proxy and the box-art ladder share. +pub(crate) fn art_field(art: &Artwork, kind: ArtKind) -> Option { + match kind { + ArtKind::Portrait => art.portrait.clone(), + ArtKind::Hero => art.hero.clone(), + ArtKind::Logo => art.logo.clone(), + ArtKind::Header => art.header.clone(), + } +} + fn save_custom(entries: &[CustomEntry]) -> Result<()> { let dir = pf_paths::config_dir(); // Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a @@ -375,13 +395,7 @@ fn emit_changed(source: &str) { }); } -/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it -/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax). -/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping -/// ([`windows_launch_for`]). -pub(crate) fn valid_steam_appid(value: &str) -> bool { - !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) -} +// `valid_steam_appid` moved to `launch.rs` (WP1.1) — it validates a launch value, not a store entry. #[cfg(test)] mod tests { diff --git a/crates/punktfunk-host/src/library/epic.rs b/crates/punktfunk-host/src/library/epic.rs index 02fef950..99e9b5b1 100644 --- a/crates/punktfunk-host/src/library/epic.rs +++ b/crates/punktfunk-host/src/library/epic.rs @@ -186,25 +186,8 @@ fn epic_art_index(catcache: &Path) -> std::collections::HashMap map } -/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple -/// `::` (colons URL-encoded), or a bare `` fallback. -/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection. -#[cfg(windows)] -pub(crate) fn epic_launch_uri(value: &str) -> Option { - let ok = |s: &str| { - !s.is_empty() - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) - }; - let inner = match value.split(':').collect::>().as_slice() { - [ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"), - [app] if ok(app) => (*app).to_string(), - _ => return None, - }; - Some(format!( - "com.epicgames.launcher://apps/{inner}?action=launch&silent=true" - )) -} +// The `epic` launch mapping (`epic_launch_uri`) lives in `launch.rs` (WP1.1) — this module +// enumerates, it does not launch. #[cfg(test)] mod tests { @@ -236,19 +219,4 @@ mod tests { assert!(epic_entry(&gone, &empty).is_none()); std::fs::remove_dir_all(&dir).ok(); } - - #[cfg(windows)] - #[test] - fn epic_launch_uri_triple_bare_and_guard() { - assert_eq!( - epic_launch_uri("fn:abc:Fortnite").as_deref(), - Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true") - ); - assert_eq!( - epic_launch_uri("Fortnite").as_deref(), - Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true") - ); - assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected - assert!(epic_launch_uri("").is_none()); - } } diff --git a/crates/punktfunk-host/src/library/gog.rs b/crates/punktfunk-host/src/library/gog.rs index 419ed7d9..0c047e62 100644 --- a/crates/punktfunk-host/src/library/gog.rs +++ b/crates/punktfunk-host/src/library/gog.rs @@ -133,38 +133,13 @@ fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> { )) } -/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`, -/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy. -#[cfg(windows)] -pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option)> { - let mut parts = value.split('\t'); - let exe = parts.next().filter(|s| !s.is_empty())?; - let args = parts.next().unwrap_or(""); - let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from); - let cmdline = if args.trim().is_empty() { - format!("\"{exe}\"") - } else { - format!("\"{exe}\" {args}") - }; - Some((cmdline, workdir)) -} +// The `gog` launch mapping (`gog_spawn`) lives in `launch.rs` (WP1.1) — this module enumerates and +// resolves the spawn triple off disk, but turning that triple into a command line is launch-side. #[cfg(test)] mod tests { use super::*; - #[cfg(windows)] - #[test] - fn gog_spawn_parses_and_guards() { - let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap(); - assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip"); - assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3"))); - let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap(); - assert_eq!(cmd2, "\"C:\\g.exe\""); - assert!(wd2.is_none()); - assert!(gog_spawn("").is_none()); - } - #[cfg(windows)] #[test] fn gog_play_task_picks_primary_filetask() { diff --git a/crates/punktfunk-host/src/library/heroic.rs b/crates/punktfunk-host/src/library/heroic.rs index bc4f92a7..3c215192 100644 --- a/crates/punktfunk-host/src/library/heroic.rs +++ b/crates/punktfunk-host/src/library/heroic.rs @@ -128,48 +128,8 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result:`) to the Heroic launch command, run nested in -/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a -/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which -/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY -/// running on the box, the spawned process forwards the URI and exits, which would tear the session -/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic. -#[cfg(target_os = "linux")] -pub(crate) fn heroic_command(value: &str) -> Option { - let (runner, app) = value.split_once(':')?; - if !matches!(runner, "legendary" | "gog" | "nile") { - return None; - } - // appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token. - if app.is_empty() - || !app - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) - { - return None; - } - let prefix = heroic_launch_prefix()?; - // No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName - // is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed). - Some(format!( - "{prefix} --no-gui heroic://launch?appName={app}&runner={runner}" - )) -} - -/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data -/// root is present. `None` ⇒ Heroic not found, so no launch command. -#[cfg(target_os = "linux")] -fn heroic_launch_prefix() -> Option { - let on_path = std::env::var_os("PATH") - .is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file())); - if on_path { - return Some("heroic".into()); - } - let flatpak = std::env::var_os("HOME") - .map(PathBuf::from) - .is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir()); - flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into()) -} +// The `heroic` launch mapping (`heroic_command` + its launcher-prefix probe) lives in `launch.rs` +// (WP1.1) — this module enumerates, it does not launch. #[cfg(test)] mod tests { diff --git a/crates/punktfunk-host/src/library/launch.rs b/crates/punktfunk-host/src/library/launch.rs index d289bdf0..9d3903a8 100644 --- a/crates/punktfunk-host/src/library/launch.rs +++ b/crates/punktfunk-host/src/library/launch.rs @@ -1,12 +1,14 @@ //! Title launch: resolve a library id / raw command into an executable command line (per-store + //! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5). +//! +//! This module owns the **whole launch side** of the library: the `kind` vocabulary, its per-kind +//! charset validators, and the per-OS resolvers. That split is deliberate and load-bearing — the +//! scanner modules beside it do *enumeration only*, so they can be lifted out into library plugins +//! without taking any launch logic with them (design/library-scanner-plugins.md D1: a client sends +//! only an entry id and the host resolves the [`LaunchSpec`] it holds, which stays true whether the +//! entry was enumerated in-process or reconciled in by a plugin). -use super::custom::valid_steam_appid; -#[cfg(target_os = "linux")] -use super::heroic::heroic_command; use super::*; -#[cfg(windows)] -use super::{epic::epic_launch_uri, gog::gog_spawn}; /// Everything a session needs about the title it is launching, resolved in **one** library scan: /// what to run, what to call it, and how to recognize it once it is running. @@ -191,6 +193,112 @@ fn steam_exe() -> Option { None } +// ------------------------------------------------------- per-kind launch values (host-owned ABI) +// +// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a +// library plugin) supplies — into the URI/command line the host actually runs. They live here rather +// than beside the enumeration that produces the value because the host keeps owning URI construction +// and spawning no matter where the enumeration came from (D1). Every one of them is total and +// validating: an unparseable or hostile value yields `None`, never a partially-interpolated command. + +/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it +/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax). +/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping +/// ([`windows_launch_for`]). +/// +/// Also accepts the 64-bit non-Steam-shortcut game id ([`shortcut_gameid`]), which is likewise +/// digits — the two share the `steam_appid` kind precisely because `rungameid` takes either. +pub(crate) fn valid_steam_appid(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) +} + +/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the +/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the +/// bare 32-bit appid does not launch a shortcut — it must be this composed id.) +pub(crate) fn shortcut_gameid(appid: u32) -> u64 { + ((appid as u64) << 32) | 0x0200_0000 +} + +/// Map a `heroic` LaunchSpec value (`:`) to the Heroic launch command, run nested in +/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a +/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which +/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY +/// running on the box, the spawned process forwards the URI and exits, which would tear the session +/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic. +#[cfg(target_os = "linux")] +pub(crate) fn heroic_command(value: &str) -> Option { + let (runner, app) = value.split_once(':')?; + if !matches!(runner, "legendary" | "gog" | "nile") { + return None; + } + // appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token. + if app.is_empty() + || !app + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + { + return None; + } + let prefix = heroic_launch_prefix()?; + // No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName + // is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed). + Some(format!( + "{prefix} --no-gui heroic://launch?appName={app}&runner={runner}" + )) +} + +/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data +/// root is present. `None` ⇒ Heroic not found, so no launch command. +#[cfg(target_os = "linux")] +fn heroic_launch_prefix() -> Option { + let on_path = std::env::var_os("PATH") + .is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file())); + if on_path { + return Some("heroic".into()); + } + let flatpak = std::env::var_os("HOME") + .map(PathBuf::from) + .is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir()); + flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into()) +} + +/// Map an `epic` LaunchSpec value to the Epic Games Launcher URI. The value is either the full +/// `::` triple (what the manifests carry) or a bare `appName`; +/// every part is charset-checked so the URI stays one safe argv token. +#[cfg(windows)] +pub(crate) fn epic_launch_uri(value: &str) -> Option { + let ok = |s: &str| { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + }; + let inner = match value.split(':').collect::>().as_slice() { + [ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"), + [app] if ok(app) => (*app).to_string(), + _ => return None, + }; + Some(format!( + "com.epicgames.launcher://apps/{inner}?action=launch&silent=true" + )) +} + +/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner +/// derived from `goggame-.info` — to a `(command line, working dir)`. GOG games are spawned +/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim. +#[cfg(windows)] +pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option)> { + let mut parts = value.split('\t'); + let exe = parts.next().filter(|s| !s.is_empty())?; + let args = parts.next().unwrap_or(""); + let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from); + let cmdline = if args.trim().is_empty() { + format!("\"{exe}\"") + } else { + format!("\"{exe}\" {args}") + }; + Some((cmdline, workdir)) +} + /// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the /// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go /// through the compositor-aware [`launch_session_command`] instead. @@ -360,6 +468,54 @@ mod tests { } } + #[test] + fn steam_appid_validation_accepts_appids_and_shortcut_gameids() { + assert!(valid_steam_appid("570")); + // The 64-bit shortcut game id shares the `steam_appid` kind — `rungameid` takes either. + assert!(valid_steam_appid( + &shortcut_gameid(2_456_789_012).to_string() + )); + assert!(!valid_steam_appid("")); + assert!(!valid_steam_appid("570; rm -rf ~")); + assert!(!valid_steam_appid("-1")); + } + + /// Moved here with `shortcut_gameid` (WP1.1): the composed id is launch vocabulary, not + /// enumeration — the scanner only supplies the 32-bit appid it read out of `shortcuts.vdf`. + #[test] + fn shortcut_gameid_composes_appid_and_marker() { + let id = shortcut_gameid(0x8000_0000); + assert_eq!(id >> 32, 0x8000_0000, "high dword is the shortcut appid"); + assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000, "low dword is the marker"); + } + + #[cfg(windows)] + #[test] + fn epic_launch_uri_triple_bare_and_guard() { + assert_eq!( + epic_launch_uri("fn:abc:Fortnite").as_deref(), + Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true") + ); + assert_eq!( + epic_launch_uri("Fortnite").as_deref(), + Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true") + ); + assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected + assert!(epic_launch_uri("").is_none()); + } + + #[cfg(windows)] + #[test] + fn gog_spawn_parses_and_guards() { + let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap(); + assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip"); + assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3"))); + let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap(); + assert_eq!(cmd2, "\"C:\\g.exe\""); + assert!(wd2.is_none()); + assert!(gog_spawn("").is_none()); + } + #[cfg(windows)] #[test] fn windows_launch_for_maps_and_guards() { diff --git a/crates/punktfunk-host/src/library/steam.rs b/crates/punktfunk-host/src/library/steam.rs index d42b1b11..168aa69a 100644 --- a/crates/punktfunk-host/src/library/steam.rs +++ b/crates/punktfunk-host/src/library/steam.rs @@ -426,12 +426,8 @@ fn shortcuts_files() -> Vec { files } -/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the -/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the -/// bare 32-bit appid does not launch a shortcut — it must be this composed id.) -fn shortcut_gameid(appid: u32) -> u64 { - ((appid as u64) << 32) | 0x0200_0000 -} +// `shortcut_gameid` (the 64-bit `rungameid` composition) moved to `launch.rs` (WP1.1) — it is launch +// vocabulary; this module only reads the 32-bit appid out of `shortcuts.vdf`. /// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the /// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern @@ -762,12 +758,7 @@ mod tests { assert!(launch.value.bytes().all(|b| b.is_ascii_digit())); } - #[test] - fn shortcut_gameid_composes_appid_and_marker() { - let id = shortcut_gameid(0x8000_0000); - assert_eq!(id >> 32, 0x8000_0000); // high dword is the appid - assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000); // low dword is the shortcut marker - } + // `shortcut_gameid_composes_appid_and_marker` moved with the function to `launch.rs` (WP1.1). #[test] fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() { diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index e8292d26..5229e59f 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -306,11 +306,12 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path) -> Res /// Fetch one cover-art image for a library entry /// /// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams -/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact — -/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL -/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host -/// can't predict, in which case this 404s and the client falls through to its next art candidate). -/// Only Steam ids are backed today; any other store 404s. +/// the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries, +/// and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back +/// to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the +/// user's Steam client already shows for it), the public Steam CDN's flat URL convention second +/// (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case +/// this 404s and the client falls through to its next art candidate). #[utoipa::path( get, path = "/library/art/{id}/{kind}", @@ -330,7 +331,20 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) -> let Some(kind) = crate::library::ArtKind::parse(&kind) else { return api_error(StatusCode::NOT_FOUND, "unknown art kind"); }; - // Steam: CDN / local-cache proxy (id `steam:`). + // `library.json` FIRST, for ANY id (WP1.2). Stored entries — manual, provider-synced, and (once + // store claims land) a scanner plugin's `steam:570` — all serve their local art file from here, + // so the proxy never has to know which store an id belongs to. Steam ids aren't stored today, so + // this misses and the legacy branch below still answers them. + let stored = { + let id = id.clone(); + tokio::task::spawn_blocking(move || crate::library::library_local_art_bytes(&id, kind)) + .await + }; + if let Ok(Some((bytes, ctype))) = stored { + return ([(header::CONTENT_TYPE, ctype)], bytes).into_response(); + } + // Legacy in-host Steam scanner: local Steam cache, then the flat CDN URL. Retired with the + // scanner itself once the steam plugin claims the store (M6). if let Some(appid) = id .strip_prefix("steam:") .and_then(|s| s.parse::().ok()) @@ -344,17 +358,5 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) -> _ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"), }; } - // Custom/provider entry (id `custom:`): serve its stored LOCAL art file — e.g. the Playnite - // plugin's covers, reconciled as on-host paths rather than inlined bytes. - if let Some(cid) = id.strip_prefix("custom:").map(str::to_owned) { - return match tokio::task::spawn_blocking(move || { - crate::library::custom_local_art_bytes(&cid, kind) - }) - .await - { - Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(), - _ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"), - }; - } - api_error(StatusCode::NOT_FOUND, "no art proxy for this store") + api_error(StatusCode::NOT_FOUND, "no art of that kind for this title") } diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index f2f65bbd..249f84e1 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -1543,11 +1543,12 @@ readonly "getHostInfo": (options: { readonly con readonly "getLibrary": (options: { readonly params?: typeof GetLibraryParams.Encoded | undefined; readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibrary401", typeof GetLibrary401.Type>> /** * Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams -* the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact — -* it's what the user's Steam client already shows for it), the public Steam CDN's flat URL -* convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host -* can't predict, in which case this 404s and the client falls through to its next art candidate). -* Only Steam ids are backed today; any other store 404s. +* the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries, +* and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back +* to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the +* user's Steam client already shows for it), the public Steam CDN's flat URL convention second +* (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case +* this 404s and the client falls through to its next art candidate). */ readonly "getLibraryArt": (id: string, kind: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibraryArt401", typeof GetLibraryArt401.Type> | PunktfunkError<"GetLibraryArt404", typeof GetLibraryArt404.Type>> /** From 3d4a659959343e80fd43abb68df215048f9920ac Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 09:39:06 +0200 Subject: [PATCH 02/64] feat(host,sdk,kit): store claims, launcher entries, and plugin sources on the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= claims a store for a provider: its entries then surface with deterministic : ids and the store's own badge instead of opaque custom: 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. --- api/openapi.json | 162 ++++++- clients/linux/src/cli.rs | 1 + crates/pf-client-core/src/library.rs | 14 + .../src/vdisplay/linux/gamescope.rs | 30 +- crates/punktfunk-host/src/gamestream/apps.rs | 13 + crates/punktfunk-host/src/library.rs | 60 ++- crates/punktfunk-host/src/library/custom.rs | 457 ++++++++++++++++-- crates/punktfunk-host/src/library/detect.rs | 125 ++++- crates/punktfunk-host/src/library/epic.rs | 1 + crates/punktfunk-host/src/library/gog.rs | 1 + crates/punktfunk-host/src/library/heroic.rs | 1 + crates/punktfunk-host/src/library/launch.rs | 76 +++ crates/punktfunk-host/src/library/lutris.rs | 1 + crates/punktfunk-host/src/library/scanners.rs | 117 ++++- crates/punktfunk-host/src/library/steam.rs | 2 + crates/punktfunk-host/src/library/xbox.rs | 1 + crates/punktfunk-host/src/mgmt/library.rs | 53 +- crates/punktfunk-host/src/mgmt/plugins.rs | 77 ++- crates/punktfunk-host/src/mgmt/store.rs | 10 + crates/punktfunk-host/src/store/index.rs | 211 ++++++++ plugin-kit/src/reconcile.ts | 50 +- plugin-kit/src/wire.ts | 58 ++- sdk/src/gen/punktfunk.ts | 48 +- sdk/src/ui.ts | 14 + 24 files changed, 1461 insertions(+), 122 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 24d7828e..b62c484c 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1307,7 +1307,7 @@ "library" ], "summary": "Replace a provider's library entries (declarative reconcile)", - "description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.", + "description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `:` ids and the store's own badge, instead of opaque\n`custom:` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).", "operationId": "reconcileProviderEntries", "parameters": [ { @@ -1318,6 +1318,15 @@ "schema": { "type": "string" } + }, + { + "name": "store", + "in": "query", + "description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -1348,7 +1357,7 @@ } }, "400": { - "description": "Invalid provider id or payload", + "description": "Invalid provider id, store id, or payload", "content": { "application/json": { "schema": { @@ -1367,6 +1376,16 @@ } } }, + "409": { + "description": "That store is already claimed by another provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "500": { "description": "Could not persist the catalog", "content": { @@ -4159,7 +4178,8 @@ "tier", "platforms", "compatible", - "update_available" + "update_available", + "categories" ], "properties": { "author": { @@ -4172,6 +4192,13 @@ ], "description": "A revocation covering the catalogued version — do not offer this without shouting." }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)." + }, "compatible": { "type": "boolean", "description": "Can this host install it?" @@ -4179,6 +4206,13 @@ "description": { "type": "string" }, + "detected": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"." + }, "homepage": { "type": [ "string", @@ -4365,6 +4399,17 @@ ], "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." }, + "role": { + "$ref": "#/components/schemas/GameRole", + "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." + }, + "store": { + "type": [ + "string", + "null" + ], + "description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten." + }, "title": { "type": "string" } @@ -4409,6 +4454,10 @@ }, "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." }, + "role": { + "$ref": "#/components/schemas/GameRole", + "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)." + }, "title": { "type": "string" } @@ -4467,6 +4516,17 @@ "type": "object", "description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.", "properties": { + "env_marker": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/EnvMarker", + "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." + } + ] + }, "exe": { "type": [ "string", @@ -4487,6 +4547,15 @@ "null" ], "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." + }, + "steam_appid": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.", + "minimum": 0 } } }, @@ -4715,6 +4784,27 @@ } } }, + "EnvMarker": { + "type": "object", + "description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.", + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string", + "description": "The variable name (e.g. `HEROIC_GAME_ID`).", + "example": "HEROIC_APP_NAME" + }, + "value": { + "type": [ + "string", + "null" + ], + "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." + } + } + }, "EventKind": { "oneOf": [ { @@ -5165,6 +5255,10 @@ ], "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." }, + "role": { + "$ref": "#/components/schemas/GameRole", + "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." + }, "store": { "type": "string", "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.", @@ -5296,6 +5390,14 @@ } } }, + "GameRole": { + "type": "string", + "description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.", + "enum": [ + "game", + "launcher" + ] + }, "GameSession": { "type": "string", "description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).", @@ -6334,6 +6436,13 @@ "title" ], "properties": { + "category": { + "type": [ + "string", + "null" + ], + "description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category." + }, "title": { "type": "string", "description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)." @@ -6366,6 +6475,13 @@ "title" ], "properties": { + "category": { + "type": [ + "string", + "null" + ], + "description": "The plugin's kind — see [`PluginRegistration::category`]." + }, "id": { "type": "string" }, @@ -6604,6 +6720,10 @@ }, "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." }, + "role": { + "$ref": "#/components/schemas/GameRole", + "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`." + }, "title": { "type": "string" } @@ -6780,26 +6900,46 @@ }, "ScannerInfo": { "type": "object", - "description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.", + "description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.", "required": [ "id", "label", - "enabled" + "enabled", + "origin" ], "properties": { "enabled": { "type": "boolean", - "description": "Whether this host runs the scanner (default true)." + "description": "Whether this host runs the source (default true)." + }, + "entries": { + "type": [ + "integer", + "null" + ], + "description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.", + "minimum": 0 }, "id": { "type": "string", - "description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.", + "description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.", "example": "steam" }, "label": { "type": "string", "description": "Human-facing name for the console toggle.", "example": "Steam" + }, + "origin": { + "$ref": "#/components/schemas/SourceOrigin", + "description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`." + }, + "provider": { + "type": [ + "string", + "null" + ], + "description": "The provider id backing a `plugin` source — absent for a built-in scanner." } } }, @@ -6962,6 +7102,14 @@ } } }, + "SourceOrigin": { + "type": "string", + "description": "Where a [`ScannerInfo`] comes from.", + "enum": [ + "builtin", + "plugin" + ] + }, "SourceView": { "type": "object", "description": "A configured catalog source and how its last refresh went.", diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index f4729d00..ce558a22 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -773,6 +773,7 @@ fn mock_library() -> ( title: title.to_string(), art: crate::library::Artwork::default(), platform: None, + role: None, }; let games = vec![ game("steam:570", "steam", "Dota 2"), diff --git a/crates/pf-client-core/src/library.rs b/crates/pf-client-core/src/library.rs index 6cd40089..84abb420 100644 --- a/crates/pf-client-core/src/library.rs +++ b/crates/pf-client-core/src/library.rs @@ -66,6 +66,20 @@ pub struct GameEntry { /// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it. #[serde(default)] pub platform: Option, + /// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens + /// the launcher itself (Steam Big Picture, Heroic) rather than a title. A UI may group these + /// separately; one that doesn't renders them as ordinary tiles, which is the intended + /// degradation (design D4). Kept a plain string: the host owns the vocabulary, and an unknown + /// future value must never fail the whole library decode. + #[serde(default)] + pub role: Option, +} + +impl GameEntry { + /// Whether this entry opens a launcher rather than a game. + pub fn is_launcher(&self) -> bool { + self.role.as_deref() == Some("launcher") + } } /// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet"). diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 8c9a29f6..d7cf75b5 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -2465,11 +2465,18 @@ pub fn ei_socket_file() -> std::path::PathBuf { crate::with_env_lock(pf_paths::gamescope_ei_socket_file) } -/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's -/// single instance free before a dedicated spawn (B1). Pure + unit-tested. +/// Does this resolved launch command start the Steam **client**? Such a launch needs Steam's single +/// instance free before a dedicated spawn (B1), and wants gamescope's `--steam` integration on. +/// Pure + unit-tested. +/// +/// The test is the first token, NOT the presence of a `steam://` URI. A `steam_ui` launcher entry +/// (design D4) resolves to a bare `steam -gamepadui` / `steam` with no URI at all, and it is *more* +/// exposed to the single-instance problem than a game launch is, not less: on a box that autologged +/// into game mode, the nested second Steam would see the first and exit, taking the spawn down with +/// it. A URI-gated check would silently skip both the instance free and `--steam` for exactly the +/// launch that most needs them. fn is_steam_launch(cmd: &str) -> bool { - let mut it = cmd.split_whitespace(); - it.next() == Some("steam") && cmd.contains("steam://") + cmd.split_whitespace().next() == Some("steam") } /// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch @@ -2865,7 +2872,13 @@ mod tests { assert!(is_steam_launch("steam -silent steam://rungameid/570")); assert!(!is_steam_launch("vkcube")); assert!(!is_steam_launch("lutris lutris:rungameid/42")); - assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch + // A `steam_ui` LAUNCHER entry (design D4) carries no URI, and must still count: it needs the + // single instance freed (B1) and gamescope's `--steam` mode on. Gating on `steam://` would + // have skipped both for the one launch that is Big Picture itself. + assert!(is_steam_launch("steam -gamepadui")); + assert!(is_steam_launch("steam")); + // A command that merely mentions steam elsewhere is not a Steam client launch. + assert!(!is_steam_launch("mygame --steam-overlay")); } #[test] @@ -2891,6 +2904,13 @@ mod tests { shape_dedicated_command("steam -bigpicture"), "steam -bigpicture" ); + // The `steam_ui` launcher entries (design D4) pass through untouched — the shaping only ever + // fires on a `steam://` game launch, so there is no way to end up with `-gamepadui` twice. + assert_eq!( + shape_dedicated_command("steam -gamepadui"), + "steam -gamepadui" + ); + assert_eq!(shape_dedicated_command("steam"), "steam"); } #[test] diff --git a/crates/punktfunk-host/src/gamestream/apps.rs b/crates/punktfunk-host/src/gamestream/apps.rs index a2972357..e74260ae 100644 --- a/crates/punktfunk-host/src/gamestream/apps.rs +++ b/crates/punktfunk-host/src/gamestream/apps.rs @@ -245,6 +245,19 @@ mod tests { } } + /// The migration invariant D2 exists to protect. Moonlight caches app ids (and users pin them), + /// and the id is derived from the LIBRARY ID alone — so a title moving from the in-host scanner + /// to a claimed plugin entry keeps its GameStream id iff the library id is byte-identical. This + /// pins that the claimed shape is that shape, and that an unclaimed one would NOT have been. + #[test] + fn a_claimed_plugin_entry_keeps_the_scanners_gamestream_id() { + // What the built-in scanner produced, and what the steam plugin produces once it claims. + assert_eq!(stable_app_id("steam:440"), stable_app_id("steam:440")); + // The same title reconciled WITHOUT a claim gets an opaque `custom:` id — a different app + // id, i.e. exactly the breakage the claim prevents. + assert_ne!(stable_app_id("steam:440"), stable_app_id("custom:9f2c1a")); + } + #[test] fn append_library_dedups_against_base_ids() { // A base app whose id happens to fall in the library range must not be clobbered by a library diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index a1eb1105..a1a24ffd 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -15,7 +15,7 @@ pub(crate) use anyhow::{Context, Result}; pub(crate) use serde::{Deserialize, Serialize}; pub(crate) use sha2::{Digest, Sha256}; -pub(crate) use std::collections::HashSet; +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; @@ -136,6 +136,29 @@ impl GameMeta { } } +/// 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 { @@ -147,6 +170,9 @@ pub struct GameEntry { 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, @@ -228,12 +254,26 @@ impl ArtKind { } } -/// The full library: every *enabled* store's titles merged + the custom entries, sorted by title. -/// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom -/// store is not a scanner and always contributes. +/// 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 { let off = disabled_scanners(); - let on = |id: &str| !off.contains(id); + 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()); @@ -262,7 +302,15 @@ pub fn all_games() -> Vec { games.extend(XboxProvider.list()); } } - games.extend(load_custom().into_iter().map(GameEntry::from)); + // 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 } diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index ab0613fd..9e5bcf60 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -28,6 +28,17 @@ pub struct CustomEntry { /// host-assigned `id` stays stable across reconciles. Present iff `provider` is. #[serde(default, skip_serializing_if = "Option::is_none")] pub external_id: Option, + /// The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile. + /// `None` = an unclaimed provider entry or a manual one, both of which surface as `custom`. + /// + /// Materialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an + /// entry is self-describing: its id and its `store` badge derive from the entry alone, and stay + /// correct even while the claim map is being rewritten. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub store: Option, + /// Whether this entry is a game or the launcher itself — see [`GameRole`]. + #[serde(default, skip_serializing_if = "GameRole::is_game")] + pub role: GameRole, /// How to recognize this title's process once it is running (design §9) — the one thing a /// provider knows that the host cannot work out for itself. /// @@ -53,6 +64,10 @@ pub struct CustomInput { /// Per-title prep/undo steps — commands run as the host user; operator-privileged config. #[serde(default)] pub prep: Vec, + /// Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher + /// entry is legal (an operator may want a "Steam" tile without installing the steam plugin). + #[serde(default)] + pub role: GameRole, /// How to recognize this title's process — see [`CustomEntry::detect`]. #[serde(default)] pub detect: DetectHint, @@ -76,6 +91,10 @@ pub struct ProviderEntryInput { /// Per-title prep/undo steps — commands run as the host user; operator-privileged config. #[serde(default)] pub prep: Vec, + /// Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin + /// emits its `launchers(cfg)` entries with `role: "launcher"`. + #[serde(default)] + pub role: GameRole, /// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its /// titles' install directories (Playnite does) should send them: it is what lets a game launched /// through the provider's own client still end its session when the player quits. @@ -102,9 +121,12 @@ impl From for GameEntry { .or_hint(&c.detect); GameEntry { id: library_id_for(&c), - store: "custom".into(), + // A claimed entry wears its store's badge; everything else is `custom`. `provider` rides + // along either way, so attribution ("synced by the steam plugin") survives the claim. + store: c.store.clone().unwrap_or_else(|| "custom".into()), title: c.title, art: c.art, + role: c.role, launch: c.launch, provider: c.provider, detect, @@ -122,23 +144,82 @@ fn custom_path() -> PathBuf { pf_paths::config_dir().join("library.json") } -/// Load the custom entries (empty + non-fatal if the file is absent or malformed). -pub fn load_custom() -> Vec { +/// The persisted catalog (`library.json` **v2**): the entries plus the store-claim map (D2). +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Catalog { + #[serde(default)] + pub entries: Vec, + /// `store id → provider id`. One provider per store; a second claimant is refused (409). + /// + /// The map — not the entries — is the authority for a claim, which is exactly why it survives an + /// **empty reconcile**: a store the plugin legitimately owns can have zero installed titles, and + /// the built-in scanner it suppresses must stay suppressed anyway. Releasing is explicit + /// (`DELETE /library/provider/{p}`, or the plugin claiming a different store). + #[serde(default)] + pub claims: BTreeMap, +} + +/// What `library.json` may contain on disk. v1 was a bare array of entries; v2 is the [`Catalog`] +/// object. Untagged, so an existing v1 file loads unchanged — and the host always WRITES v2, so the +/// first mutation after an upgrade migrates the file in place with no separate migration step. +#[derive(Deserialize)] +#[serde(untagged)] +enum LibraryFile { + V2(Catalog), + Legacy(Vec), +} + +/// Load the whole catalog (default + non-fatal if the file is absent or malformed). +pub fn load_catalog() -> Catalog { match std::fs::read_to_string(custom_path()) { - Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| { - tracing::warn!(error = %e, "library.json malformed — ignoring custom entries"); - Vec::new() - }), - Err(_) => Vec::new(), + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(LibraryFile::V2(c)) => c, + Ok(LibraryFile::Legacy(entries)) => Catalog { + entries, + claims: BTreeMap::new(), + }, + Err(e) => { + tracing::warn!(error = %e, "library.json malformed — ignoring custom entries"); + Catalog::default() + } + }, + Err(_) => Catalog::default(), } } +/// Load just the entries — the read path every library surface uses. +pub fn load_custom() -> Vec { + load_catalog().entries +} + +/// The active store claims (`store → provider`). Read per library scan to suppress the built-in +/// scanner a plugin has taken over (D2). +pub fn claimed_stores() -> BTreeMap { + load_catalog().claims +} + /// The library id a stored entry surfaces as. **The single source of truth for the mapping** — -/// [`From for GameEntry`] and every id→entry lookup go through it, so a change to the -/// id scheme (the store claims of D2 will make claimed entries `:`) lands in one -/// place instead of drifting between the catalog and the art proxy. +/// [`From for GameEntry`] and every id→entry lookup go through it, so the id scheme +/// can't drift between the catalog, the art proxy and the launch resolver. +/// +/// A **claimed** entry (D2) gets the deterministic `:` its built-in scanner used +/// to produce — `steam:440`, `heroic:legendary:Quail` — so entry ids, GameStream FNV-1a app ids, +/// client art caches and Moonlight pins all survive the migration to a plugin untouched. That is the +/// whole point of the claim: extraction must be invisible to everything downstream. An unclaimed +/// entry keeps the opaque host-assigned `custom:`. pub(crate) fn library_id_for(e: &CustomEntry) -> String { - format!("custom:{}", e.id) + match (e.store.as_deref(), e.external_id.as_deref()) { + (Some(store), Some(external)) => format!("{store}:{external}"), + _ => format!("custom:{}", e.id), + } +} + +/// The **source id** an entry is toggled by (WP2.6): its claimed store when it has one, else its +/// provider id. `None` for a manual entry — the custom store is not a source and can never be +/// switched off. Since the claimed store id, the provider id and the old scanner id are all the same +/// string by construction, a user's existing disabled state carries over verbatim. +pub(crate) fn source_id_for(e: &CustomEntry) -> Option<&str> { + e.store.as_deref().or(e.provider.as_deref()) } /// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this @@ -171,13 +252,15 @@ pub(crate) fn art_field(art: &Artwork, kind: ArtKind) -> Option { } } -fn save_custom(entries: &[CustomEntry]) -> Result<()> { +/// Persist the catalog in the **v2** shape (write-then-rename, restrictive perms). Every mutation +/// path funnels through here, so a v1 file is upgraded by the first write. +fn save_catalog(catalog: &Catalog) -> Result<()> { let dir = pf_paths::config_dir(); // Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a // library.json whose `prep`/`launch` commands the host would later execute — the same trust // boundary hooks.json and the mgmt token already use. pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?; - let json = serde_json::to_string_pretty(entries)?; + let json = serde_json::to_string_pretty(catalog)?; // Write-then-rename so a crash mid-write never truncates the catalog; `write_secret_file` gives // the temp file its restrictive perms (0600 / SYSTEM+Admins DACL) before the rename carries them // to the final path. @@ -197,19 +280,26 @@ fn new_id(title: &str) -> String { hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6]) } -/// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists, -/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404). +/// Outcome of a mutation — distinguishes "no such entry" from the two conflict cases the mgmt +/// layer maps to 409 rather than 404. pub enum MutateOutcome { Done(T), NotFound, /// The entry belongs to this provider — mutate it through the provider reconcile API /// (or remove the whole provider set); manual edits would be clobbered at the next sync. ProviderOwned(String), + /// The requested store claim is already held by a DIFFERENT provider (D2: one provider per + /// store). Refusing is the point — two plugins both emitting `steam:440` would collide on entry + /// ids, so the second claimant is told who holds it instead of silently taking over. + StoreClaimed { + store: String, + provider: String, + }, } /// Create a custom (manual) entry, returning it with its assigned id. pub fn add_custom(input: CustomInput) -> Result { - let mut entries = load_custom(); + let mut catalog = load_catalog(); let entry = CustomEntry { id: new_id(&input.title), title: input.title, @@ -218,11 +308,13 @@ pub fn add_custom(input: CustomInput) -> Result { prep: input.prep, provider: None, external_id: None, + store: None, + role: input.role, detect: input.detect, meta: input.meta, }; - entries.push(entry.clone()); - save_custom(&entries)?; + catalog.entries.push(entry.clone()); + save_catalog(&catalog)?; emit_changed("manual"); Ok(entry) } @@ -230,8 +322,8 @@ pub fn add_custom(input: CustomInput) -> Result { /// Replace a manual entry's fields (id preserved). Provider-owned entries are refused — /// their state belongs to the provider's reconcile (RFC §8 ownership rule). pub fn update_custom(id: &str, input: CustomInput) -> Result> { - let mut entries = load_custom(); - let Some(slot) = entries.iter_mut().find(|e| e.id == id) else { + let mut catalog = load_catalog(); + let Some(slot) = catalog.entries.iter_mut().find(|e| e.id == id) else { return Ok(MutateOutcome::NotFound); }; if let Some(provider) = &slot.provider { @@ -241,25 +333,26 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result Result> { - let mut entries = load_custom(); - let Some(entry) = entries.iter().find(|e| e.id == id) else { + let mut catalog = load_catalog(); + let Some(entry) = catalog.entries.iter().find(|e| e.id == id) else { return Ok(MutateOutcome::NotFound); }; if let Some(provider) = &entry.provider { return Ok(MutateOutcome::ProviderOwned(provider.clone())); } - entries.retain(|e| e.id != id); - save_custom(&entries)?; + catalog.entries.retain(|e| e.id != id); + save_catalog(&catalog)?; emit_changed("manual"); Ok(MutateOutcome::Done(())) } @@ -285,6 +378,26 @@ pub fn validate_provider_name(provider: &str) -> Result<(), String> { } } +/// Store claims become the **prefix of every claimed entry's library id**, so they are far more +/// constrained than a provider name: no dots (an id is split on the first `:`, and a dotted store +/// would read as a hostname in logs), and the two host-owned namespaces are off-limits — `custom` is +/// the unclaimed-entry namespace and `manual` is the no-provider sentinel in `library.changed`. +pub fn validate_store_claim(store: &str) -> Result<(), String> { + if store == "custom" || store == "manual" { + return Err(format!("store id `{store}` is reserved")); + } + let ok = !store.is_empty() + && store.len() <= 32 + && store + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_')); + if ok { + Ok(()) + } else { + Err("store id must be 1–32 chars of [a-z0-9_-]".into()) + } +} + /// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the /// diff key — a duplicate would make ownership of the surviving entry ambiguous). pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> { @@ -302,6 +415,31 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St e.external_id )); } + // Closed-vocabulary launch kinds are checked on the way IN as well as at launch time, so a + // plugin gets a 400 it can act on rather than a tile that silently refuses to start. + if let Some(launch) = &e.launch { + if launch.kind == "steam_ui" && !valid_steam_ui(&launch.value) { + return Err(format!( + "entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`" + )); + } + } + if let Some(marker) = &e.detect.env_marker { + if !valid_env_key(&marker.key) { + return Err(format!( + "entries[{i}]: `detect.env_marker.key` must be 1–64 chars of [A-Za-z0-9_]" + )); + } + if marker + .value + .as_ref() + .is_some_and(|v| v.len() > MAX_ENV_VALUE) + { + return Err(format!( + "entries[{i}]: `detect.env_marker.value` must be at most {MAX_ENV_VALUE} chars" + )); + } + } } Ok(()) } @@ -313,6 +451,7 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St fn reconcile_entries( entries: &mut Vec, provider: &str, + store: Option<&str>, inputs: Vec, ) -> Vec { // The provider's current entries, keyed by its own stable id. @@ -337,6 +476,10 @@ fn reconcile_entries( prep: input.prep, provider: Some(provider.to_string()), external_id: Some(input.external_id), + // Stamping the claim per entry is what makes the surfaced id deterministic + // (`:`) — see `library_id_for`. + store: store.map(str::to_string), + role: input.role, detect: input.detect, meta: input.meta, }); @@ -346,43 +489,86 @@ fn reconcile_entries( result } -/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`). -/// The caller validates the name and payload first. Emits `library.changed` with the provider -/// as the source. +/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`), optionally +/// under a **store claim** (D2: `?store=steam`). The caller validates the name and payload first. +/// Emits `library.changed` with the provider as the source. +/// +/// Claiming is idempotent for the holder and refused for anyone else. A provider holds at most one +/// store, so claiming a new one releases whatever it held before — otherwise an abandoned claim would +/// go on suppressing a built-in scanner with nothing to replace it. pub fn reconcile_provider( provider: &str, + store: Option<&str>, inputs: Vec, -) -> Result> { - let mut entries = load_custom(); - let result = reconcile_entries(&mut entries, provider, inputs); - save_custom(&entries)?; +) -> Result>> { + let mut catalog = load_catalog(); + if let Some(store) = store { + if let Some(holder) = catalog.claims.get(store) { + if holder != provider { + return Ok(MutateOutcome::StoreClaimed { + store: store.to_string(), + provider: holder.clone(), + }); + } + } + let previous: Vec = catalog + .claims + .iter() + .filter(|(s, p)| p.as_str() == provider && s.as_str() != store) + .map(|(s, _)| s.clone()) + .collect(); + for stale in previous { + tracing::info!(provider, released = %stale, claimed = store, "library: provider moved its store claim"); + catalog.claims.remove(&stale); + } + if catalog + .claims + .insert(store.to_string(), provider.to_string()) + .is_none() + { + tracing::info!(provider, store, "library: store claimed by a provider"); + } + } + let result = reconcile_entries(&mut catalog.entries, provider, store, inputs); + save_catalog(&catalog)?; emit_changed(provider); - Ok(result) + Ok(MutateOutcome::Done(result)) } -/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the -/// clean-uninstall path). Returns how many were removed; no event when nothing was. +/// Remove every entry of `provider` **and release its store claim** (RFC §8: +/// `DELETE /library/provider/{provider}` — the clean-uninstall path). Returns how many entries were +/// removed; no event when nothing changed at all. +/// +/// Releasing here — and only here — is what makes uninstalling a library plugin bring its built-in +/// scanner straight back, with no restart and nothing to undo by hand. pub fn delete_provider(provider: &str) -> Result { - let mut entries = load_custom(); - let before = entries.len(); - entries.retain(|e| e.provider.as_deref() != Some(provider)); - let removed = before - entries.len(); - if removed > 0 { - save_custom(&entries)?; + let mut catalog = load_catalog(); + let before = catalog.entries.len(); + catalog + .entries + .retain(|e| e.provider.as_deref() != Some(provider)); + let removed = before - catalog.entries.len(); + let claims_before = catalog.claims.len(); + catalog.claims.retain(|_, p| p != provider); + let released = claims_before - catalog.claims.len(); + if removed > 0 || released > 0 { + if released > 0 { + tracing::info!(provider, released, "library: store claim released"); + } + save_catalog(&catalog)?; emit_changed(provider); } Ok(removed) } -/// The prep/undo steps for a library id — `custom:` entries only (the other stores have no +/// The prep/undo steps for a library id — any **stored** entry (the in-host scanners have no /// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead). +/// +/// Resolved through [`entry_for_library_id`] rather than by stripping a `custom:` prefix, so a +/// claimed entry's prep still runs: after extraction a `steam:440` entry is a stored one, and +/// per-title prep is exactly the kind of thing an operator sets on a game they play. pub fn prep_for(library_id: &str) -> Vec { - let Some(id) = library_id.strip_prefix("custom:") else { - return Vec::new(); - }; - load_custom() - .into_iter() - .find(|e| e.id == id) + entry_for_library_id(library_id) .map(|e| e.prep) .unwrap_or_default() } @@ -410,6 +596,8 @@ mod tests { prep: Vec::new(), provider: None, external_id: None, + store: None, + role: GameRole::Game, detect: DetectHint::default(), meta: GameMeta::default(), } @@ -422,6 +610,7 @@ mod tests { art: Artwork::default(), launch: None, prep: Vec::new(), + role: GameRole::Game, detect: DetectHint::default(), meta: GameMeta::default(), } @@ -443,6 +632,79 @@ mod tests { assert_eq!(g.meta.platform.as_deref(), Some("PS2")); } + /// D2's core promise: a **claimed** entry is indistinguishable from what the built-in scanner + /// produced. Same id, same store badge — plus the provider attribution the scanner never had. + #[test] + fn a_claimed_entry_reproduces_the_scanner_identity() { + let mut e = manual("host-assigned", "Portal 2"); + e.provider = Some("steam".into()); + e.external_id = Some("620".into()); + e.store = Some("steam".into()); + assert_eq!(library_id_for(&e), "steam:620"); + let g: GameEntry = e.clone().into(); + assert_eq!(g.id, "steam:620", "exactly what the scanner emitted"); + assert_eq!(g.store, "steam", "the store badge, not `custom`"); + assert_eq!( + g.provider.as_deref(), + Some("steam"), + "attribution rides along too" + ); + + // Unclaimed provider entries are untouched by any of this — rom-manager/playnite keep the + // opaque host id they have always had. + let mut u = manual("abc", "Chrono Trigger"); + u.provider = Some("romm".into()); + u.external_id = Some("rom-1".into()); + assert_eq!(library_id_for(&u), "custom:abc"); + assert_eq!(GameEntry::from(u).store, "custom"); + + // The source a toggle addresses: the claimed store when there is one, else the provider. + assert_eq!(source_id_for(&e), Some("steam")); + let mut r = manual("z", "T"); + r.provider = Some("romm".into()); + assert_eq!(source_id_for(&r), Some("romm")); + assert_eq!( + source_id_for(&manual("m", "Manual")), + None, + "never hideable" + ); + } + + /// A claimed entry keeps its `:` id across reconciles no matter what the + /// host-assigned id does — which is what keeps GameStream's FNV-1a app ids, client art caches + /// and Moonlight pins valid through the migration (the whole point of D2). + #[test] + fn claimed_ids_are_deterministic_across_reconciles() { + let mut entries = Vec::new(); + let r1 = reconcile_entries( + &mut entries, + "steam", + Some("steam"), + vec![input("440", "Team Fortress 2"), input("620", "Portal 2")], + ); + let ids: Vec = r1.iter().map(library_id_for).collect(); + assert_eq!(ids, ["steam:440", "steam:620"]); + + // Re-sync with a renamed title and a new entry: the surfaced ids for surviving titles are + // byte-identical, and a brand-new title's id is derived, not random. + let r2 = reconcile_entries( + &mut entries, + "steam", + Some("steam"), + vec![ + input("440", "Team Fortress 2 (2026)"), + input("70", "Half-Life"), + ], + ); + let ids2: Vec = r2.iter().map(library_id_for).collect(); + assert_eq!(ids2, ["steam:440", "steam:70"]); + + // Dropping the claim on a later reconcile reverts them to opaque custom ids — the entries + // are the same rows, so this is exactly the "plugin stopped claiming" degradation. + let r3 = reconcile_entries(&mut entries, "steam", None, vec![input("440", "TF2")]); + assert!(library_id_for(&r3[0]).starts_with("custom:")); + } + /// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting — /// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a /// pre-metadata `library.json` / payload still parses (all-optional). @@ -491,6 +753,7 @@ mod tests { let r1 = reconcile_entries( &mut entries, "romm", + None, vec![input("rom-a", "Game A"), input("rom-b", "Game B")], ); assert_eq!(r1.len(), 2); @@ -502,6 +765,7 @@ mod tests { let r2 = reconcile_entries( &mut entries, "romm", + None, vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")], ); assert_eq!(r2.len(), 2); @@ -520,6 +784,7 @@ mod tests { let r3 = reconcile_entries( &mut entries, "romm", + None, vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")], ); assert_eq!( @@ -540,7 +805,7 @@ mod tests { .any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch"))); // Empty payload = remove everything the provider owns (same as DELETE). - let r4 = reconcile_entries(&mut entries, "romm", Vec::new()); + let r4 = reconcile_entries(&mut entries, "romm", None, Vec::new()); assert!(r4.is_empty()); assert_eq!( entries.len(), @@ -549,6 +814,98 @@ mod tests { ); } + /// `library.json` v1 (a bare array) must keep loading, and v2 (the claims object) must round + /// trip. This is the only migration in the whole program — get it wrong and an existing host + /// silently loses its manual entries on upgrade. + #[test] + fn v1_and_v2_library_files_both_load() { + // v1: exactly what a shipped host has on disk today. + let v1 = r#"[{"id":"abc","title":"Old Manual"}]"#; + let c = match serde_json::from_str::(v1).unwrap() { + LibraryFile::Legacy(entries) => Catalog { + entries, + claims: BTreeMap::new(), + }, + LibraryFile::V2(_) => panic!("an array must not parse as v2"), + }; + assert_eq!(c.entries.len(), 1); + assert_eq!(c.entries[0].title, "Old Manual"); + assert!(c.claims.is_empty()); + + // v2, including a claim. + let v2 = r#"{"entries":[{"id":"abc","title":"New"}],"claims":{"steam":"steam"}}"#; + let c = match serde_json::from_str::(v2).unwrap() { + LibraryFile::V2(c) => c, + LibraryFile::Legacy(_) => panic!("an object must not parse as v1"), + }; + assert_eq!(c.entries.len(), 1); + assert_eq!(c.claims.get("steam").map(String::as_str), Some("steam")); + + // A v2 file with no claims key at all (what the first write after upgrade produces before + // anything is claimed) still loads. + let bare = r#"{"entries":[]}"#; + assert!(matches!( + serde_json::from_str::(bare).unwrap(), + LibraryFile::V2(_) + )); + + // And what we WRITE is v2, so one mutation upgrades the file in place. + let written = serde_json::to_string(&Catalog::default()).unwrap(); + assert!(written.contains("\"entries\"")); + assert!(written.contains("\"claims\"")); + } + + #[test] + fn store_claim_validation() { + assert!(validate_store_claim("steam").is_ok()); + assert!(validate_store_claim("epic-games").is_ok()); + assert!(validate_store_claim("xbox_pc").is_ok()); + // The two host-owned namespaces are off-limits. + assert!(validate_store_claim("custom").is_err()); + assert!(validate_store_claim("manual").is_err()); + assert!(validate_store_claim("").is_err()); + assert!(validate_store_claim("Steam").is_err()); // no uppercase + // A dot would read as a hostname in a log line and muddies the `store:id` split. + assert!(validate_store_claim("my.store").is_err()); + assert!(validate_store_claim(&"s".repeat(33)).is_err()); + } + + /// The closed-vocabulary fields are rejected at the door, so a plugin gets a 400 rather than a + /// tile that silently refuses to launch. + #[test] + fn payload_validation_covers_the_new_closed_vocabularies() { + let with_launch = |kind: &str, value: &str| { + let mut i = input("a", "A"); + i.launch = Some(LaunchSpec { + kind: kind.into(), + value: value.into(), + }); + i + }; + assert!(validate_provider_payload(&[with_launch("steam_ui", "bigpicture")]).is_ok()); + assert!(validate_provider_payload(&[with_launch("steam_ui", "desktop")]).is_ok()); + assert!(validate_provider_payload(&[with_launch("steam_ui", "gamepad")]).is_err()); + assert!(validate_provider_payload(&[with_launch("steam_ui", "")]).is_err()); + // Other kinds are unconstrained here (the host validates them per-kind at launch). + assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok()); + + let with_env = |key: &str, value: Option<&str>| { + let mut i = input("a", "A"); + i.detect.env_marker = Some(EnvMarker { + key: key.into(), + value: value.map(str::to_string), + }); + i + }; + assert!(validate_provider_payload(&[with_env("HEROIC_APP_NAME", Some("Quail"))]).is_ok()); + assert!(validate_provider_payload(&[with_env("BAD-KEY", None)]).is_err()); + assert!(validate_provider_payload(&[with_env("", None)]).is_err()); + assert!( + validate_provider_payload(&[with_env("K", Some(&"x".repeat(MAX_ENV_VALUE + 1)))]) + .is_err() + ); + } + #[test] fn provider_name_and_payload_validation() { assert!(validate_provider_name("romm").is_ok()); diff --git a/crates/punktfunk-host/src/library/detect.rs b/crates/punktfunk-host/src/library/detect.rs index 7b65a518..83d602d4 100644 --- a/crates/punktfunk-host/src/library/detect.rs +++ b/crates/punktfunk-host/src/library/detect.rs @@ -19,15 +19,34 @@ use super::*; /// An environment variable a launcher stamps onto the game's process, identifying it. -#[derive(Clone, Debug, PartialEq, Eq)] +/// +/// Serializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin +/// that knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to +/// be able to say so, since after extraction the host no longer reads that launcher's files itself. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct EnvMarker { /// The variable name (e.g. `HEROIC_GAME_ID`). + #[schema(example = "HEROIC_APP_NAME")] pub key: String, /// The exact value to require, when the launcher's value identifies *this* title. `None` matches /// the key's mere presence — only safe for launchers that run one game at a time. + #[serde(default, skip_serializing_if = "Option::is_none")] pub value: Option, } +/// The env-var name charset a hint may carry: `[A-Za-z0-9_]{1,64}`, POSIX-shaped. An out-of-charset +/// key is not a real environment variable, so accepting one could only ever produce a matcher rule +/// that never fires (or, with an absurd length, a needless per-process comparison cost). +pub(crate) fn valid_env_key(key: &str) -> bool { + !key.is_empty() + && key.len() <= 64 + && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +/// Longest env-var VALUE a hint may pin. Values are compared against every candidate process's +/// environment, so an unbounded one is a (small) DoS lever and never a legitimate game id. +pub(crate) const MAX_ENV_VALUE: usize = 256; + /// The signals that identify a launched title's process(es). Every field is optional and /// independent; an all-`None` spec means "this title can't be tracked" (the lease degrades to /// [`crate::gamelease::LeaseKind::Untracked`] and both lifetime behaviors stay inert for it). @@ -115,6 +134,11 @@ impl DetectSpec { self.install_dir = self.install_dir.or(from.install_dir); self.exe = self.exe.or(from.exe); self.process_name = self.process_name.or(from.process_name); + // D3: the two store-derived signals are fillable from a hint now that the store may live in + // a plugin. Same rule as the other three — the host's own finding wins where it has one, + // which for a provider entry is moot (the host scanned nothing for it). + self.steam_appid = self.steam_appid.or(from.steam_appid); + self.env_marker = self.env_marker.or(from.env_marker); self } } @@ -143,12 +167,31 @@ pub struct DetectHint { /// — see [`DetectSpec::process_name`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub process_name: Option, + /// The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest** + /// signal that exists — Steam wraps every launch, native or Proton, in + /// `reaper SteamLaunch AppId=`, whose lifetime is exactly the game's — so without it a + /// steam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub steam_appid: Option, + /// A launcher-stamped environment marker (D3) — see [`EnvMarker`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_marker: Option, } impl DetectHint { /// Whether the hint says anything at all (all-empty is treated as absent). pub fn is_empty(&self) -> bool { - self.trimmed().is_none() + self.trimmed().is_none() && self.steam_appid.is_none() && self.env_marker().is_none() + } + + /// The env marker, if it is well-formed. A malformed one is dropped rather than rejected, for + /// the same reason a blank `install_dir` is: hint fields are hand-writable plugin input, and the + /// matcher must never be handed a rule it can't honour. + fn env_marker(&self) -> Option<&EnvMarker> { + self.env_marker + .as_ref() + .filter(|m| valid_env_key(&m.key)) + .filter(|m| m.value.as_ref().is_none_or(|v| v.len() <= MAX_ENV_VALUE)) } /// The hint with blank fields dropped, or `None` if nothing is left. Console text inputs and @@ -166,14 +209,13 @@ impl DetectHint { /// A provider's hint becomes a spec — the one inbound path into [`DetectSpec`]. impl From<&DetectHint> for DetectSpec { fn from(h: &DetectHint) -> Self { - let Some((install_dir, exe, process_name)) = h.trimmed() else { - return Self::default(); - }; + let (install_dir, exe, process_name) = h.trimmed().unwrap_or((None, None, None)); Self { install_dir: install_dir.map(PathBuf::from), exe: exe.map(PathBuf::from), process_name: process_name.map(str::to_string), - ..Default::default() + steam_appid: h.steam_appid, + env_marker: h.env_marker().cloned(), } } } @@ -273,6 +315,7 @@ mod tests { install_dir: Some("".into()), exe: Some(" ".into()), process_name: Some("\t".into()), + ..Default::default() }; assert!(blank.is_empty()); assert!(DetectSpec::from(&blank).is_empty(), "nothing to match on"); @@ -281,6 +324,7 @@ mod tests { install_dir: Some(" /games/quail ".into()), exe: None, process_name: Some("quail".into()), + ..Default::default() }; assert!(!hint.is_empty()); let spec = DetectSpec::from(&hint); @@ -299,6 +343,7 @@ mod tests { install_dir: Some("/games/wrong".into()), exe: Some("/games/real/run".into()), process_name: None, + ..Default::default() }; let merged = found.or_hint(&hint); assert_eq!( @@ -317,6 +362,74 @@ mod tests { .is_empty()); } + /// D3: the two store-derived signals now ride the hint, because after extraction the host no + /// longer reads Steam's or Heroic's files itself. Without them a plugin's lease tracking would + /// silently degrade — reaper-exact to dir-prefix on Linux Steam, and gone entirely for Heroic + /// under Proton, where the env marker is the only thing that works. + #[test] + fn a_hint_can_carry_the_store_derived_signals() { + let hint = DetectHint { + steam_appid: Some(440), + env_marker: Some(EnvMarker { + key: "HEROIC_APP_NAME".into(), + value: Some("Quail".into()), + }), + ..Default::default() + }; + assert!(!hint.is_empty(), "either field alone is a real hint"); + let spec = DetectSpec::from(&hint); + assert_eq!(spec.steam_appid, Some(440)); + assert_eq!(spec.env_marker.as_ref().unwrap().key, "HEROIC_APP_NAME"); + + // A steam_appid on its own is enough to be trackable. + let only_appid = DetectHint { + steam_appid: Some(620), + ..Default::default() + }; + assert!(!only_appid.is_empty()); + assert!(!DetectSpec::from(&only_appid).is_empty()); + + // The host's own finding still wins where it has one (unchanged rule). + let found = DetectSpec::steam(70); + assert_eq!(found.or_hint(&hint).steam_appid, Some(70)); + // …but a field the host had nothing for is filled in. + assert_eq!( + DetectSpec::dir("/games/x") + .or_hint(&hint) + .env_marker + .unwrap() + .key, + "HEROIC_APP_NAME" + ); + } + + /// A malformed marker is DROPPED, not honoured — same posture as a blank `install_dir`. The + /// matcher must never be handed a rule it cannot evaluate, and these values reach a code path + /// that can end processes. + #[test] + fn a_malformed_env_marker_says_nothing() { + let bad = |key: &str, value: Option| DetectHint { + env_marker: Some(EnvMarker { + key: key.into(), + value, + }), + ..Default::default() + }; + assert!(bad("", None).is_empty()); + assert!(bad("HAS-DASH", None).is_empty(), "not a POSIX env name"); + assert!(bad("HAS SPACE", None).is_empty()); + assert!(bad(&"K".repeat(65), None).is_empty(), "over the key cap"); + assert!( + bad("K", Some("v".repeat(MAX_ENV_VALUE + 1))).is_empty(), + "over the value cap" + ); + // …and a well-formed one at exactly the caps is kept. + assert!(!bad(&"K".repeat(64), Some("v".repeat(MAX_ENV_VALUE))).is_empty()); + assert!(DetectSpec::from(&bad("HAS-DASH", None)) + .env_marker + .is_none()); + } + #[test] fn first_token_handles_quotes_and_spaces() { assert_eq!( diff --git a/crates/punktfunk-host/src/library/epic.rs b/crates/punktfunk-host/src/library/epic.rs index 99e9b5b1..07e72545 100644 --- a/crates/punktfunk-host/src/library/epic.rs +++ b/crates/punktfunk-host/src/library/epic.rs @@ -100,6 +100,7 @@ fn epic_entry( }; Some(GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id: format!("epic:{app_name}"), store: "epic".into(), diff --git a/crates/punktfunk-host/src/library/gog.rs b/crates/punktfunk-host/src/library/gog.rs index 0c047e62..f7cc373f 100644 --- a/crates/punktfunk-host/src/library/gog.rs +++ b/crates/punktfunk-host/src/library/gog.rs @@ -57,6 +57,7 @@ fn gog_games() -> Vec { let detect = DetectSpec::exe(&exe).with_dir(&path); out.push(GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id, store: "gog".into(), diff --git a/crates/punktfunk-host/src/library/heroic.rs b/crates/punktfunk-host/src/library/heroic.rs index 3c215192..d7badd03 100644 --- a/crates/punktfunk-host/src/library/heroic.rs +++ b/crates/punktfunk-host/src/library/heroic.rs @@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result Option { // Heroic: `:` → the validated heroic://launch command (see heroic_command). #[cfg(target_os = "linux")] "heroic" => heroic_command(&spec.value), + // A launcher entry (D4): open the Steam client itself, in Big Picture or on the desktop. + // Nested in gamescope this is the SteamOS game-mode shape. + "steam_ui" => match spec.value.as_str() { + "bigpicture" => Some("steam -gamepadui".into()), + "desktop" => Some("steam".into()), + _ => None, + }, // Trusted: the command comes from the host's own custom store, never the client. "command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()), _ => None, @@ -140,6 +147,21 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option { + let uri = match spec.value.as_str() { + "bigpicture" => "steam://open/bigpicture", + "desktop" => "steam://open/main", + _ => return None, + }; + let cmdline = match steam_exe() { + Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()), + None => format!("explorer.exe \"{uri}\""), + }; + Some((cmdline, None)) + } // Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a // concrete EXE that resolves the registered protocol handler as the user; the URI is a single // argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback. @@ -219,6 +241,13 @@ pub(crate) fn shortcut_gameid(appid: u32) -> u64 { ((appid as u64) << 32) | 0x0200_0000 } +/// The `steam_ui` launch values (D4) — which Steam UI a launcher entry opens. A closed two-value +/// enum, validated on the way IN (the reconcile payload) as well as on the way out, so an entry can +/// never carry a third value that silently resolves to nothing at launch time. +pub(crate) fn valid_steam_ui(value: &str) -> bool { + matches!(value, "bigpicture" | "desktop") +} + /// Map a `heroic` LaunchSpec value (`:`) to the Heroic launch command, run nested in /// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a /// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which @@ -468,6 +497,53 @@ mod tests { } } + /// The `steam_ui` launcher kind (D4): a closed two-value enum, mapped to the Steam client's own + /// UI on each OS. Nothing from the entry is interpolated — the value only SELECTS between two + /// host-owned literals — so there is no injection surface at all here. + #[test] + fn steam_ui_is_a_closed_two_value_enum() { + assert!(valid_steam_ui("bigpicture")); + assert!(valid_steam_ui("desktop")); + assert!(!valid_steam_ui("gamepadui")); + assert!(!valid_steam_ui("")); + assert!(!valid_steam_ui("bigpicture; rm -rf ~")); + } + + #[cfg(not(windows))] + #[test] + fn steam_ui_resolves_to_the_client_ui_on_linux() { + let ui = |v: &str| { + command_for(&LaunchSpec { + kind: "steam_ui".into(), + value: v.into(), + }) + }; + // Big Picture is the SteamOS game-mode shape; nested in gamescope this is what `--steam` + // integration is built around. + assert_eq!(ui("bigpicture").as_deref(), Some("steam -gamepadui")); + assert_eq!(ui("desktop").as_deref(), Some("steam")); + assert_eq!(ui("nonsense"), None); + assert_eq!(ui(""), None); + } + + #[cfg(windows)] + #[test] + fn steam_ui_resolves_to_the_client_ui_on_windows() { + let ui = |v: &str| { + windows_launch_for(&LaunchSpec { + kind: "steam_ui".into(), + value: v.into(), + }) + }; + let (bp, wd) = ui("bigpicture").expect("bigpicture recipe"); + assert!(bp.contains("steam://open/bigpicture"), "line was {bp:?}"); + assert!(wd.is_none()); + let (desk, _) = ui("desktop").expect("desktop recipe"); + assert!(desk.contains("steam://open/main"), "line was {desk:?}"); + assert!(ui("nonsense").is_none()); + assert!(ui("").is_none()); + } + #[test] fn steam_appid_validation_accepts_appids_and_shortcut_gameids() { assert!(valid_steam_appid("570")); diff --git a/crates/punktfunk-host/src/library/lutris.rs b/crates/punktfunk-host/src/library/lutris.rs index 571aa5e2..3937ccd5 100644 --- a/crates/punktfunk-host/src/library/lutris.rs +++ b/crates/punktfunk-host/src/library/lutris.rs @@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result> { for (id, slug, name, directory) in rows.flatten() { games.push(GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id: format!("lutris:{id}"), store: "lutris".into(), diff --git a/crates/punktfunk-host/src/library/scanners.rs b/crates/punktfunk-host/src/library/scanners.rs index 3279ebe4..0e969860 100644 --- a/crates/punktfunk-host/src/library/scanners.rs +++ b/crates/punktfunk-host/src/library/scanners.rs @@ -12,19 +12,41 @@ use super::*; -/// One installed-store scanner this host build supports, with its enable state — the unit the -/// console renders a toggle for. The list is platform-gated at compile time (the scanners are), -/// so the console never shows a toggle that cannot do anything on this host. +/// One **game source** on this host, with its enable state — the unit the console renders a toggle +/// for. A source is either a scanner compiled into this build or a plugin that reconciles entries in +/// (WP2.6); the console treats them identically, which is what makes the extraction invisible. #[derive(Clone, Debug, Serialize, ToSchema)] pub struct ScannerInfo { - /// Stable scanner id — the same string the scanner's entries carry in their `store` field. + /// Stable source id — the same string this source's entries carry in their `store` field. For a + /// plugin source it is also its provider id and its store claim: one string, by construction, so + /// a user's disabled state survives a built-in scanner being replaced by its plugin. #[schema(example = "steam")] pub id: String, /// Human-facing name for the console toggle. #[schema(example = "Steam")] pub label: String, - /// Whether this host runs the scanner (default true). + /// Whether this host runs the source (default true). pub enabled: bool, + /// Where the source comes from: `builtin` (a scanner in this host build) or `plugin`. + #[schema(example = "builtin")] + pub origin: SourceOrigin, + /// The provider id backing a `plugin` source — absent for a built-in scanner. + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// How many entries this source currently contributes. `None` for a built-in scanner, whose + /// count would mean walking every launcher's files just to render a toggle. + #[serde(skip_serializing_if = "Option::is_none")] + pub entries: Option, +} + +/// Where a [`ScannerInfo`] comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum SourceOrigin { + /// A scanner compiled into this host build. + Builtin, + /// A plugin reconciling entries over the provider API. + Plugin, } /// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are @@ -87,26 +109,93 @@ pub(crate) fn disabled_scanners() -> HashSet { load_settings().disabled.into_iter().collect() } -/// The scanners available on this platform with their current enable state, in the fixed -/// definition order (stable for the console). +/// Every game source on this host with its current enable state (WP2.6): +/// +/// 1. the built-in scanners this build compiled in, **minus** any whose store a plugin has claimed +/// (the plugin replaces it, so showing both would offer two toggles for one thing); +/// 2. the claimed stores themselves, as plugin sources; +/// 3. any other provider that has entries — the *emergent* case (rom-manager, playnite), which has +/// never had a toggle before and gets one for free here. +/// +/// Built-ins keep their fixed definition order (stable for the console); plugin sources follow, +/// sorted by id. pub fn list_scanners() -> Vec { let off = disabled_scanners(); - scanner_defs() + let claims = crate::library::claimed_stores(); + let entries = crate::library::load_custom(); + + let mut out: Vec = scanner_defs() .into_iter() + .filter(|(id, _)| !claims.contains_key(*id)) .map(|(id, label)| ScannerInfo { id: id.to_string(), label: label.to_string(), enabled: !off.contains(id), + origin: SourceOrigin::Builtin, + provider: None, + entries: None, }) - .collect() + .collect(); + + // A claimed store shows under the SCANNER's label where we know one, so the row a user has been + // toggling for releases doesn't rename itself out from under them mid-migration. + let label_for = |id: &str| { + scanner_defs() + .into_iter() + .find(|(sid, _)| *sid == id) + .map(|(_, label)| label.to_string()) + .unwrap_or_else(|| id.to_string()) + }; + + let mut plugin_ids: Vec<(String, String)> = claims + .iter() + .map(|(store, provider)| (store.clone(), provider.clone())) + .collect(); + // Emergent providers: any provider with entries that isn't already listed via a claim. + for e in &entries { + let Some(provider) = e.provider.as_deref() else { + continue; + }; + if e.store.is_none() && !plugin_ids.iter().any(|(id, _)| id == provider) { + plugin_ids.push((provider.to_string(), provider.to_string())); + } + } + plugin_ids.sort(); + plugin_ids.dedup(); + + out.extend(plugin_ids.into_iter().map(|(id, provider)| { + let count = entries + .iter() + .filter(|e| crate::library::source_id_for(e) == Some(id.as_str())) + .count(); + ScannerInfo { + label: label_for(&id), + enabled: !off.contains(&id), + origin: SourceOrigin::Plugin, + provider: Some(provider), + entries: Some(count), + id, + } + })); + out } -/// Enable/disable one scanner. `None` when `id` names no scanner available on this platform (the -/// mgmt layer maps that to 404 — the console only ever sees this host's own list). Persists and -/// emits `library.changed` (source = the scanner id) only when the state actually changed, so a -/// repeated PUT is a cheap no-op. +/// Whether `id` names a source that exists on this host right now — a compiled-in scanner, a claimed +/// store, or a provider with entries. The toggle accepts exactly these (an unknown id still 404s). +fn is_known_source(id: &str) -> bool { + scanner_defs().iter().any(|(sid, _)| *sid == id) || list_scanners().iter().any(|s| s.id == id) +} + +/// Enable/disable one source. `None` when `id` names no source on this host (the mgmt layer maps +/// that to 404 — the console only ever sees this host's own list). Persists and emits +/// `library.changed` (source = the id) only when the state actually changed, so a repeated PUT is a +/// cheap no-op. +/// +/// The **same** `library-scanners.json` disabled-set backs built-in and plugin sources alike, and +/// the ids match by construction — so a user who disabled `steam` before the migration still has it +/// disabled after the steam plugin claims the store, with nothing to carry over. pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result>> { - if !scanner_defs().iter().any(|(sid, _)| *sid == id) { + if !is_known_source(id) { return Ok(None); } let mut settings = load_settings(); diff --git a/crates/punktfunk-host/src/library/steam.rs b/crates/punktfunk-host/src/library/steam.rs index 168aa69a..71425505 100644 --- a/crates/punktfunk-host/src/library/steam.rs +++ b/crates/punktfunk-host/src/library/steam.rs @@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider { .filter(|app| !is_steam_tool(app.appid, &app.name)) .map(|app| GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id: format!("steam:{}", app.appid), store: "steam".into(), @@ -383,6 +384,7 @@ fn shortcut_entry(sc: Shortcut) -> Option { } Some(GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id: format!("steam:{}", sc.appid), store: "steam".into(), diff --git a/crates/punktfunk-host/src/library/xbox.rs b/crates/punktfunk-host/src/library/xbox.rs index 49d60151..80cbcfe7 100644 --- a/crates/punktfunk-host/src/library/xbox.rs +++ b/crates/punktfunk-host/src/library/xbox.rs @@ -70,6 +70,7 @@ fn xbox_games() -> Vec { let art = cached_art(&id).unwrap_or_default(); games.push(GameEntry { provider: None, + role: GameRole::Game, meta: GameMeta::pc(), id, store: "xbox".into(), diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index 5229e59f..e1cc9b74 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -185,6 +185,11 @@ pub(crate) async fn update_custom_game( StatusCode::CONFLICT, &format!("entry is owned by provider `{p}` — update it through its reconcile"), ), + // Store claims are a reconcile-only concern — the manual CRUD never requests one. + Ok(MutateOutcome::StoreClaimed { .. }) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "unexpected claim outcome", + ), Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } @@ -216,6 +221,11 @@ pub(crate) async fn delete_custom_game(Path(id): Path) -> Response { "entry is owned by provider `{p}` — remove it there, or DELETE the provider set" ), ), + // Store claims are a reconcile-only concern — the manual CRUD never requests one. + Ok(MutateOutcome::StoreClaimed { .. }) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "unexpected claim outcome", + ), Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } @@ -227,6 +237,13 @@ pub(crate) struct ProviderRemoved { removed: usize, } +/// Query for `reconcileProviderEntries` — the optional store claim (D2). +#[derive(Deserialize)] +pub(crate) struct ReconcileQuery { + /// Claim this store for the provider, so its entries take the store's own identity. + store: Option, +} + /// Replace a provider's library entries (declarative reconcile) /// /// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the @@ -234,39 +251,67 @@ pub(crate) struct ProviderRemoved { /// surviving title's host id stable across reconciles, drops orphans, and never touches manual /// entries or other providers'. An empty array removes everything the provider owns. Emits /// `library.changed` with the provider as `source`. +/// +/// `?store=` additionally **claims** that store for the provider: its entries then surface with +/// deterministic `:` ids and the store's own badge, instead of opaque +/// `custom:` ones — which is what lets a library plugin reproduce the entries an in-host scanner +/// used to produce, right down to the GameStream app ids and client-side art caches. One provider +/// per store; a second claimant gets 409. While a claim is held the matching built-in scanner is +/// suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty +/// reconcile (a store can legitimately have zero installed titles). #[utoipa::path( put, path = "/library/provider/{provider}", tag = "library", operation_id = "reconcileProviderEntries", - params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")), + params( + ("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)"), + ("store" = Option, Query, description = "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)"), + ), request_body = Vec, responses( (status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]), - (status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError), + (status = BAD_REQUEST, description = "Invalid provider id, store id, or payload", body = ApiError), (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = CONFLICT, description = "That store is already claimed by another provider", body = ApiError), (status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError), ) )] pub(crate) async fn reconcile_provider_entries( Path(provider): Path, + Query(q): Query, ApiJson(inputs): ApiJson>, ) -> Response { if let Err(e) = crate::library::validate_provider_name(&provider) { return api_error(StatusCode::BAD_REQUEST, &e); } + let store = q.store.filter(|s| !s.is_empty()); + if let Some(store) = &store { + if let Err(e) = crate::library::validate_store_claim(store) { + return api_error(StatusCode::BAD_REQUEST, &e); + } + } if let Err(e) = crate::library::validate_provider_payload(&inputs) { return api_error(StatusCode::BAD_REQUEST, &e); } - match crate::library::reconcile_provider(&provider, inputs) { - Ok(entries) => { + match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) { + Ok(crate::library::MutateOutcome::Done(entries)) => { tracing::info!( provider, + store = store.as_deref().unwrap_or("-"), count = entries.len(), "library provider reconciled" ); Json(entries).into_response() } + Ok(crate::library::MutateOutcome::StoreClaimed { store, provider }) => api_error( + StatusCode::CONFLICT, + &format!("store `{store}` is already claimed by provider `{provider}`"), + ), + Ok(_) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "unexpected reconcile outcome", + ), Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } diff --git a/crates/punktfunk-host/src/mgmt/plugins.rs b/crates/punktfunk-host/src/mgmt/plugins.rs index c57ab88f..f4ab317f 100644 --- a/crates/punktfunk-host/src/mgmt/plugins.rs +++ b/crates/punktfunk-host/src/mgmt/plugins.rs @@ -64,6 +64,14 @@ pub(crate) struct PluginRegistration { /// entry only (e.g. a future runner-management listing) and grows no nav entry. #[serde(default, skip_serializing_if = "Option::is_none")] pub ui: Option, + /// What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui` + /// because it describes the plugin, not its surface. The console knows one value today — + /// `library` — which it filters **out of the nav**: six installed scanner plugins would otherwise + /// flood the sidebar, and their real entry point is the Game sources surface (design D5). A + /// library plugin that genuinely wants its own page (rom-manager, which is much more than a + /// scanner) simply omits the category. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, } /// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`). @@ -104,6 +112,9 @@ pub(crate) struct PluginSummary { pub version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ui: Option, + /// The plugin's kind — see [`PluginRegistration::category`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, } /// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback). @@ -129,14 +140,19 @@ struct Stored { title: String, version: Option, ui: Option, + category: Option, expires_at: Instant, } impl Stored { /// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves - /// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does. - fn public_eq(&self, title: &str, version: &Option, ui: &Option) -> bool { - self.title == title && self.version == *version && self.ui == *ui + /// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon/ + /// category) does. + fn public_eq(&self, v: &Valid) -> bool { + self.title == v.title + && self.version == v.version + && self.ui == v.ui + && self.category == v.category } } @@ -150,6 +166,7 @@ struct Valid { title: String, version: Option, ui: Option, + category: Option, } impl PluginRegistry { @@ -167,7 +184,7 @@ impl PluginRegistry { let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner()); let changed = match map.get(id) { // An *expired* prior entry counts as a change (it had stopped listing). - Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui), + Some(prev) => !prev.is_live() || !prev.public_eq(&v), None => true, }; map.insert( @@ -176,6 +193,7 @@ impl PluginRegistry { title: v.title, version: v.version, ui: v.ui, + category: v.category, expires_at, }, ); @@ -207,6 +225,7 @@ impl PluginRegistry { port: u.port, icon: u.icon.clone(), }), + category: s.category.clone(), }) .collect(); live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id))); @@ -333,7 +352,31 @@ fn validate(reg: PluginRegistration) -> Result { Some(u) => Some(validate_ui(u)?), None => None, }; - Ok(Valid { title, version, ui }) + // Categories are grouping keys the console switches on — a closed charset, but deliberately not + // a closed VOCABULARY: an unknown category is stored and simply matches no console rule, so a + // newer plugin registering against an older host degrades to "shows in the nav", never to a + // failed registration. + let category = match reg.category { + Some(c) => { + let ok = (1..=32).contains(&c.len()) + && c.starts_with(|ch: char| ch.is_ascii_lowercase()) + && c.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if !ok { + return Err( + "category must be 1–32 chars of [a-z0-9-], starting with a letter".into(), + ); + } + Some(c) + } + None => None, + }; + Ok(Valid { + title, + version, + ui, + category, + }) } fn validate_ui(u: PluginUi) -> Result { @@ -558,6 +601,7 @@ mod tests { secret: secret.into(), icon: Some("gamepad-2".into()), }), + category: None, } } @@ -584,10 +628,30 @@ mod tests { title: "Ro\u{7}m\n".into(), version: None, ui: None, + category: None, }) .unwrap(); assert_eq!(v.title, "Rom"); - // privileged port rejected + // Category charset (WP2.7): the console's one known value passes; the shapes that would + // break a grouping key don't. An UNKNOWN-but-well-formed category is accepted on purpose — + // a newer plugin must not fail to register against an older host. + let lib = |c: &str| PluginRegistration { + title: "X".into(), + version: None, + ui: None, + category: Some(c.into()), + }; + assert_eq!( + validate(lib("library")).unwrap().category.as_deref(), + Some("library") + ); + assert!(validate(lib("some-future-kind")).is_ok()); + assert!(validate(lib("")).is_err()); + assert!(validate(lib("Library")).is_err()); // no uppercase + assert!(validate(lib("9lives")).is_err()); // must start with a letter + assert!(validate(lib("lib_rary")).is_err()); // no underscore + assert!(validate(lib(&"a".repeat(33))).is_err()); // too long + // privileged port rejected assert!(validate(reg("x", 80, SECRET)).is_err()); // short secret rejected assert!(validate(reg("x", 49321, "tooshort")).is_err()); @@ -641,6 +705,7 @@ mod tests { title: "Headless".into(), version: None, ui: None, + category: None, }) .unwrap(), ); diff --git a/crates/punktfunk-host/src/mgmt/store.rs b/crates/punktfunk-host/src/mgmt/store.rs index 849aa81b..a24d9742 100644 --- a/crates/punktfunk-host/src/mgmt/store.rs +++ b/crates/punktfunk-host/src/mgmt/store.rs @@ -108,6 +108,14 @@ pub(crate) struct CatalogEntry { /// A revocation covering the catalogued version — do not offer this without shouting. #[serde(skip_serializing_if = "Option::is_none")] pub blocked: Option, + /// What kind of plugin this is — the console filters Browse by these, and the Game sources + /// surface's "Add a source" rail shows exactly the `library` ones (design D5/D6). + pub categories: Vec, + /// Whether the launcher this plugin scans looks **installed on this host** (design D8), from the + /// index's own existence probes. `null` = the entry declares no probes for this platform, which + /// the console renders as "unknown" rather than "not installed". + #[serde(skip_serializing_if = "Option::is_none")] + pub detected: Option, } #[derive(Serialize, ToSchema)] @@ -277,6 +285,8 @@ fn build_catalog(force: bool) -> CatalogResponse { update_available: installed_version.as_deref().is_some_and(|v| v != e.version), installed_version, blocked: store::advisory_for(&e.pkg, Some(&e.version)).map(|a| a.reason), + categories: e.categories.clone(), + detected: e.detected(), }); } } diff --git a/crates/punktfunk-host/src/store/index.rs b/crates/punktfunk-host/src/store/index.rs index cecea4c2..2471a772 100644 --- a/crates/punktfunk-host/src/store/index.rs +++ b/crates/punktfunk-host/src/store/index.rs @@ -97,6 +97,31 @@ pub(crate) struct Entry { /// Host platforms this plugin works on (`linux`/`windows`/`macos`). Empty ⇒ all. #[serde(default)] pub platforms: Vec, + /// What kinds of plugin this is (`[a-z][a-z0-9-]{0,31}`, ≤4). The console filters Browse by + /// these, and the Game sources surface's "Add a source" rail lists exactly the entries carrying + /// `library` (design D5/D6). Additive: an older host ignores the field, a newer one just sees no + /// categories on an older index. + #[serde(default)] + pub categories: Vec, + /// Optional per-platform "is this launcher installed here?" probes (design D8). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detect: Option, +} + +/// Existence probes that let the console badge a catalog row "detected on this host" **without the +/// host re-growing per-store knowledge** — the whole point of extracting the scanners. Store +/// knowledge lives in the updatable, signed index; the host stays generic and only evaluates. +/// +/// Deliberately anaemic: a probe is a path or an `HKLM\…` registry key, checked for EXISTENCE only. +/// No reads, no content matching, no globbing beyond a single `*` segment. The index is +/// operator-trusted but remotely updatable, so a probe must never be able to exfiltrate anything or +/// cost more than a stat. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub(crate) struct DetectProbes { + #[serde(default)] + pub linux: Vec, + #[serde(default)] + pub windows: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -228,9 +253,40 @@ impl Entry { self.platforms .retain(|p| matches!(p.as_str(), "linux" | "windows" | "macos")); self.platforms.truncate(4); + // Categories and probes are cosmetic/advisory: a malformed one is dropped, never fatal to + // the entry — a plugin must stay installable even if a future index writes a category this + // host build has never heard of. + self.categories.retain(|c| valid_category(c)); + self.categories.truncate(4); + if let Some(d) = &mut self.detect { + d.linux.retain(|p| valid_probe(p)); + d.windows.retain(|p| valid_probe(p)); + d.linux.truncate(MAX_PROBES); + d.windows.truncate(MAX_PROBES); + if d.linux.is_empty() && d.windows.is_empty() { + self.detect = None; + } + } Ok(()) } + /// Does this entry's platform probe match on the running host? `None` = the entry declares no + /// probes for this platform, i.e. "unknown", which the console renders differently from "no". + pub(crate) fn detected(&self) -> Option { + let probes = self.detect.as_ref()?; + let list = if cfg!(windows) { + &probes.windows + } else if cfg!(target_os = "linux") { + &probes.linux + } else { + return None; + }; + if list.is_empty() { + return None; + } + Some(list.iter().any(|p| probe_matches(p))) + } + /// Is this entry installable on the running host? Returns the operator-facing reason when not. pub(crate) fn incompatible_reason(&self) -> Option { if !self.platforms.is_empty() && !self.platforms.iter().any(|p| p == HOST_PLATFORM) { @@ -372,6 +428,94 @@ fn is_https(url: &str) -> bool { url.starts_with("https://") && url.len() > "https://".len() } +/// A plugin category (design D5): same shape the registration API accepts, so a plugin's declared +/// category and its catalog row can never disagree about spelling. +fn valid_category(c: &str) -> bool { + (1..=32).contains(&c.len()) + && c.starts_with(|ch: char| ch.is_ascii_lowercase()) + && c.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +/// How many probes one platform may declare — a handful of well-chosen paths covers any launcher, +/// and the cap bounds the stat cost of rendering the catalog. +const MAX_PROBES: usize = 8; + +/// Is this a probe the host will evaluate? An **absolute** filesystem path with at most one `*` +/// segment, or an `HKLM\…` registry key. Everything else is dropped. +/// +/// The restrictions are the security model (D8). Absolute: a relative path would resolve against +/// whatever the host's cwd happens to be. One `*` segment: bounded fan-out, so a probe can't walk a +/// tree. `HKLM` only: `HKCU` is unreadable as LocalService anyway, and pointing the host at an +/// arbitrary hive is not something a remote index should be able to ask for. +fn valid_probe(p: &str) -> bool { + if p.is_empty() || p.len() > 260 { + return false; + } + if let Some(key) = p.strip_prefix("HKLM\\") { + return !key.is_empty() + && !key.contains("..") + && key.bytes().all(|b| { + b.is_ascii_alphanumeric() || matches!(b, b'\\' | b' ' | b'-' | b'_' | b'.') + }); + } + let b = p.as_bytes(); + let absolute = p.starts_with('/') || (b.len() >= 3 && b[1] == b':' && b[2] == b'\\'); + // No traversal, and at most ONE wildcard segment (`~` is not expanded — the host runs as a + // service account whose home means nothing to a user's launcher install). + absolute && !p.contains("..") && p.matches('*').count() <= 1 +} + +/// Evaluate one probe: does the path (or registry key) exist? Existence only — never a read. +fn probe_matches(p: &str) -> bool { + #[cfg(windows)] + if let Some(key) = p.strip_prefix("HKLM\\") { + use std::os::windows::process::CommandExt; + // `reg.exe query` rather than a registry crate: dependency-free, and it is exactly what a + // library plugin will use for the same job under LocalService. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + return std::process::Command::new("reg.exe") + .args(["query", &format!("HKLM\\{key}")]) + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + } + #[cfg(not(windows))] + if p.starts_with("HKLM\\") { + return false; // a Windows probe on a POSIX host is simply not a match + } + match p.split_once('*') { + None => std::path::Path::new(p).exists(), + // One wildcard: list the parent of the wildcard segment and match the fixed prefix/suffix + // around it. Bounded to a single directory read. + Some((before, after)) => { + let (dir, prefix) = match before.rfind(['/', '\\']) { + Some(i) => (&before[..=i], &before[i + 1..]), + None => return false, // a wildcard with no directory to anchor it + }; + let (suffix, rest) = match after.find(['/', '\\']) { + Some(i) => (&after[..i], &after[i..]), + None => (after, ""), + }; + let Ok(read) = std::fs::read_dir(dir) else { + return false; + }; + read.flatten().any(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.starts_with(prefix) + && name.ends_with(suffix) + && name.len() >= prefix.len() + suffix.len() + && (rest.is_empty() + || e.path().join(rest.trim_start_matches(['/', '\\'])).exists()) + }) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -401,6 +545,73 @@ mod tests { assert!(Index::parse(b"not json").is_err()); } + /// WP2.8 is additive on purpose — SCHEMA stays 1. An index written by a newer curator must load + /// on an older host (unknown fields ignored) and vice versa (absent fields default), or the + /// signed-index rollout would need a flag day. + #[test] + fn categories_and_probes_are_additive_and_sanitized() { + // An entry with NEITHER field — every index in the wild today. + let e = &Index::parse(&doc(GOOD)).unwrap().plugins[0]; + assert!(e.categories.is_empty()); + assert!(e.detect.is_none()); + assert_eq!(e.detected(), None, "no probes ⇒ unknown, not `false`"); + + // With both, including rows that must be dropped rather than fail the entry. + let rich = GOOD.trim_end_matches('}').to_string() + + r#","categories":["library","Bad Cat","x","y","z","w"], + "detect":{"linux":["/usr/bin/steam","relative/path","/etc/../etc/passwd"], + "windows":["HKLM\\SOFTWARE\\Valve\\Steam","HKCU\\SOFTWARE\\Valve"]}}"#; + let e = &Index::parse(&doc(&rich)).unwrap().plugins[0]; + assert_eq!( + e.categories, + ["library", "x", "y", "z"], + "malformed dropped, capped at 4" + ); + let d = e.detect.as_ref().expect("probes kept"); + assert_eq!(d.linux, ["/usr/bin/steam"], "relative + traversal dropped"); + assert_eq!( + d.windows, + ["HKLM\\SOFTWARE\\Valve\\Steam"], + "HKCU is not evaluable as LocalService — dropped" + ); + } + + #[test] + fn probe_shapes_are_bounded() { + assert!(valid_probe("/usr/bin/steam")); + assert!( + valid_probe("/home/*/.steam"), + "one wildcard segment is fine" + ); + assert!(valid_probe(r"C:\Program Files (x86)\Steam\steam.exe")); + assert!(valid_probe(r"HKLM\SOFTWARE\WOW6432Node\Valve\Steam")); + // Rejected: relative, traversal, more than one wildcard, other hives, absurd length. + assert!(!valid_probe("steam")); + assert!(!valid_probe("/usr/../etc/passwd")); + assert!(!valid_probe("/home/*/games/*/steam")); + assert!(!valid_probe(r"HKCU\SOFTWARE\Valve")); + assert!(!valid_probe("")); + assert!(!valid_probe(&"/x".repeat(200))); + } + + /// The evaluator does existence checks only, against real paths, and never reads a byte. + #[test] + fn probes_evaluate_against_the_filesystem() { + let dir = std::env::temp_dir().join(format!("pf-probe-{}", std::process::id())); + let nested = dir.join("SteamLibrary-42"); + std::fs::create_dir_all(nested.join("steamapps")).unwrap(); + let d = dir.to_string_lossy().into_owned(); + + assert!(probe_matches(&format!("{d}/SteamLibrary-42"))); + assert!(!probe_matches(&format!("{d}/nope"))); + // One wildcard segment, with and without a trailing fixed component. + assert!(probe_matches(&format!("{d}/SteamLibrary-*"))); + assert!(probe_matches(&format!("{d}/SteamLibrary-*/steamapps"))); + assert!(!probe_matches(&format!("{d}/SteamLibrary-*/nope"))); + assert!(!probe_matches(&format!("{d}/Other-*"))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn drops_invalid_entries_but_keeps_the_rest() { let bad_unscoped = GOOD.replace("@punktfunk/plugin-rom-manager", "punktfunk-plugin-x"); diff --git a/plugin-kit/src/reconcile.ts b/plugin-kit/src/reconcile.ts index 4679f4e3..02dddd6c 100644 --- a/plugin-kit/src/reconcile.ts +++ b/plugin-kit/src/reconcile.ts @@ -9,13 +9,36 @@ import type { ProviderEntry } from "./wire.js"; export * from "./wire.js"; +/** What the host echoed back for one reconciled entry — enough to tell whether a claim took. */ +export interface ReconciledEntry { + readonly id: string; + readonly external_id?: string; + /** The store badge the host assigned: the claim when it honoured one, else `"custom"`. */ + readonly store?: string; +} + export interface ProviderClientService { - /** Full-replace reconcile: PUT the desired set; the host diffs by `external_id`. */ + /** + * Full-replace reconcile: PUT the desired set; the host diffs by `external_id`. + * + * `store` claims that store for this provider (design D2), which is what makes the entries carry + * the store's own identity — deterministic `:` ids instead of opaque + * `custom:` ones, the store's badge, and suppression of the host's matching built-in scanner + * so the two never double-list. One provider per store: a second claimant gets a 409. + * + * Returns the host's echoed entries so a caller can verify the claim actually took — a host + * predating claims ignores the query parameter silently, and the only way to notice is that the + * entries come back as `custom`. + */ readonly reconcile: ( providerId: string, entries: ReadonlyArray, - ) => Effect.Effect; - /** Remove every entry this provider owns (the explicit-uninstall path). */ + store?: string, + ) => Effect.Effect, HostRequestError>; + /** + * Remove every entry this provider owns **and release its store claim** (the explicit-uninstall + * path). Releasing is what brings the host's built-in scanner back. + */ readonly remove: (providerId: string) => Effect.Effect; } @@ -28,10 +51,25 @@ export class ProviderClient extends Context.Service< Effect.gen(function* () { const host = yield* HostClient; return { - reconcile: (providerId, entries) => + reconcile: (providerId, entries, store) => host - .request("PUT", `/library/provider/${providerId}`, entries) - .pipe(Effect.asVoid), + .request( + "PUT", + `/library/provider/${providerId}${ + store ? `?store=${encodeURIComponent(store)}` : "" + }`, + entries, + ) + .pipe( + // The host answers with its resulting entries. An older host may answer + // with something else, so treat a non-array as "no echo" rather than + // failing the sync. + Effect.map((body) => + Array.isArray(body) + ? (body as ReadonlyArray) + : [], + ), + ), remove: (providerId) => host .request("DELETE", `/library/provider/${providerId}`) diff --git a/plugin-kit/src/wire.ts b/plugin-kit/src/wire.ts index 566cc851..d68735ea 100644 --- a/plugin-kit/src/wire.ts +++ b/plugin-kit/src/wire.ts @@ -12,12 +12,44 @@ export const Artwork = Schema.Struct({ }); 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 ∈ legendary/gog/nile | linux | + * | `epic` | `::` or a bare appName | windows | + * | `gog` | `exe \t args \t workdir` | windows | + * | `aumid` | `!` | 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.Literal("command"), + 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)), @@ -43,6 +75,28 @@ export const DetectHint = Schema.Struct({ 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=`, + * 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; @@ -76,6 +130,8 @@ export const ProviderEntry = Schema.Struct({ 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; diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index 249f84e1..fd3c90c0 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -33,8 +33,8 @@ export type AvailableCompositor = { readonly "available": boolean, readonly "def export const AvailableCompositor = Schema.Struct({ "available": Schema.Boolean.annotate({ "description": "Usable on this host right now: the live session's own compositor, or gamescope wherever\nits binary is installed." }), "default": Schema.Boolean.annotate({ "description": "True for the backend an `Auto` (unspecified) request resolves to right now." }), "id": Schema.String.annotate({ "description": "Stable identifier (`\"kwin\"` | `\"wlroots\"` | `\"mutter\"` | `\"gamescope\"`) — pass this to a\nclient's `--compositor` flag." }), "label": Schema.String.annotate({ "description": "Human-readable label for UIs." }) }).annotate({ "description": "A compositor backend the host can drive a virtual output on, and whether it's usable now." }) export type CaptureMeta = { readonly "client": string, readonly "codec": string, readonly "duration_ms": number, readonly "encoder_backend"?: string, readonly "fps": number, readonly "gpu"?: string, readonly "height": number, readonly "id": string, readonly "kind": string, readonly "sample_count": number, readonly "started_unix_ms": number, readonly "width": number } export const CaptureMeta = Schema.Struct({ "client": Schema.String.annotate({ "description": "Short label / fingerprint prefix, or `\"\"` if unknown." }), "codec": Schema.String.annotate({ "description": "`\"h264\" | \"hevc\" | \"av1\"`." }), "duration_ms": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "encoder_backend": Schema.optionalKey(Schema.String.annotate({ "description": "The encode backend that ACTUALLY opened for this session — `\"nvenc\"`, `\"vaapi\"`,\n`\"vulkan\"`, `\"amf\"`, `\"qsv\"`, `\"software\"`, … — and the GPU it runs on.\n\nRecorded because the stage split alone can't be read without them. A p50 `submit` of 10 ms\nmeans \"the GPU's CSC+encode throughput is the ceiling\" on one backend and something else\nentirely on another, and every fps-shortfall report so far has cost a round-trip asking\nwhich one it was. Both come from `pf_gpu::active()`, the record the encoder open itself\nwrites, so they name the branch that really opened rather than a re-derived guess.\n\n`\"\"` when nothing was streaming at registration (or on a build without the record)." })), "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "gpu": Schema.optionalKey(Schema.String.annotate({ "description": "Human-readable GPU name (`\"NVIDIA GeForce RTX 4090\"`, `\"CPU (openh264)\"`), or `\"\"`." })), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "id": Schema.String.annotate({ "description": "e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem." }), "kind": Schema.String.annotate({ "description": "`\"native\" | \"gamestream\"`." }), "sample_count": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "started_unix_ms": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head\nof each on-disk recording and listed standalone (without the sample body) by\n[`StatsRecorder::list`]." }) -export type CatalogEntry = { readonly "author": string, readonly "blocked"?: string | null, readonly "compatible": boolean, readonly "description": string, readonly "homepage"?: string | null, readonly "icon"?: string | null, readonly "id": string, readonly "incompatible_reason"?: string | null, readonly "installed_version"?: string | null, readonly "license"?: string | null, readonly "min_host"?: string | null, readonly "pkg": string, readonly "platforms": ReadonlyArray, readonly "reviewed_at"?: string | null, readonly "source": string, readonly "tier": string, readonly "title": string, readonly "update_available": boolean, readonly "version": string } -export const CatalogEntry = Schema.Struct({ "author": Schema.String, "blocked": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "A revocation covering the catalogued version — do not offer this without shouting." })), "compatible": Schema.Boolean.annotate({ "description": "Can this host install it?" }), "description": Schema.String, "homepage": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "id": Schema.String, "incompatible_reason": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "installed_version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The version installed right now, if any." })), "license": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "min_host": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "pkg": Schema.String, "platforms": Schema.Array(Schema.String), "reviewed_at": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "When unom reviewed this exact tarball (built-in source only)." })), "source": Schema.String.annotate({ "description": "Which source listed it." }), "tier": Schema.String.annotate({ "description": "`verified` (built-in source) or `external` (an operator-added source). Never `unverified`:\nunverified installs come from a raw spec and are never listed (D7)." }), "title": Schema.String, "update_available": Schema.Boolean.annotate({ "description": "Installed, but at a different version than the catalog pins." }), "version": Schema.String.annotate({ "description": "The one installable version this entry pins." }) }).annotate({ "description": "One row on the shelf." }) +export type CatalogEntry = { readonly "author": string, readonly "blocked"?: string | null, readonly "categories": ReadonlyArray, readonly "compatible": boolean, readonly "description": string, readonly "detected"?: boolean | null, readonly "homepage"?: string | null, readonly "icon"?: string | null, readonly "id": string, readonly "incompatible_reason"?: string | null, readonly "installed_version"?: string | null, readonly "license"?: string | null, readonly "min_host"?: string | null, readonly "pkg": string, readonly "platforms": ReadonlyArray, readonly "reviewed_at"?: string | null, readonly "source": string, readonly "tier": string, readonly "title": string, readonly "update_available": boolean, readonly "version": string } +export const CatalogEntry = Schema.Struct({ "author": Schema.String, "blocked": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "A revocation covering the catalogued version — do not offer this without shouting." })), "categories": Schema.Array(Schema.String).annotate({ "description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)." }), "compatible": Schema.Boolean.annotate({ "description": "Can this host install it?" }), "description": Schema.String, "detected": Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null]).annotate({ "description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"." })), "homepage": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "id": Schema.String, "incompatible_reason": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "installed_version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The version installed right now, if any." })), "license": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "min_host": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "pkg": Schema.String, "platforms": Schema.Array(Schema.String), "reviewed_at": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "When unom reviewed this exact tarball (built-in source only)." })), "source": Schema.String.annotate({ "description": "Which source listed it." }), "tier": Schema.String.annotate({ "description": "`verified` (built-in source) or `external` (an operator-added source). Never `unverified`:\nunverified installs come from a raw spec and are never listed (D7)." }), "title": Schema.String, "update_available": Schema.Boolean.annotate({ "description": "Installed, but at a different version than the catalog pins." }), "version": Schema.String.annotate({ "description": "The one installable version this entry pins." }) }).annotate({ "description": "One row on the shelf." }) export type DisconnectReason = "quit" | "timeout" | "error" export const DisconnectReason = Schema.Literals(["quit", "timeout", "error"]).annotate({ "description": "Why a client went away. `Quit` is a deliberate user \"stop\" (the typed close code);\n`Timeout` is a transport idle timeout (the client vanished); `Error` is everything else." }) export type EndGameRequest = { readonly "app_id"?: string | null } @@ -85,8 +85,8 @@ export type Plane = "native" | "gamestream" export const Plane = Schema.Literals(["native", "gamestream"]).annotate({ "description": "Which protocol plane an event originated from. Hooks and scripts filter on it — a hook\nthat fires for native clients but not Moonlight clients is a bug, not a v2 feature." }) export type PluginLogLine = { readonly "level": string, readonly "msg": string, readonly "source": string, readonly "ts_ms": number } export const PluginLogLine = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`." }), "msg": Schema.String, "source": Schema.String.annotate({ "description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:`." }), "ts_ms": Schema.Number.annotate({ "description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`)." }) -export type PluginRegistration = { readonly "title": string, readonly "ui"?: null | { readonly "icon"?: string | null, readonly "port": number, readonly "secret": string }, readonly "version"?: string | null } -export const PluginRegistration = Schema.Struct({ "title": Schema.String.annotate({ "description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)." }), "ui": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)." })), "port": Schema.Number.annotate({ "description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:`; a registration can never carry a hostname.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "secret": Schema.String.annotate({ "description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts." }) }).annotate({ "description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry." })], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional plugin version, purely informational (≤32 chars)." })) }).annotate({ "description": "Register/renew body for `PUT /plugins/{id}`." }) +export type PluginRegistration = { readonly "category"?: string | null, readonly "title": string, readonly "ui"?: null | { readonly "icon"?: string | null, readonly "port": number, readonly "secret": string }, readonly "version"?: string | null } +export const PluginRegistration = Schema.Struct({ "category": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category." })), "title": Schema.String.annotate({ "description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)." }), "ui": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)." })), "port": Schema.Number.annotate({ "description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:`; a registration can never carry a hostname.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "secret": Schema.String.annotate({ "description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts." }) }).annotate({ "description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry." })], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Optional plugin version, purely informational (≤32 chars)." })) }).annotate({ "description": "Register/renew body for `PUT /plugins/{id}`." }) export type PluginUiPublic = { readonly "icon"?: string | null, readonly "port": number } export const PluginUiPublic = Schema.Struct({ "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "port": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser." }) export type PortMap = { readonly "audio": number, readonly "control": number, readonly "http": number, readonly "https": number, readonly "mgmt": number, readonly "rtsp": number, readonly "video": number } @@ -107,8 +107,8 @@ export type RuntimeRequest = { readonly "enabled": boolean } export const RuntimeRequest = Schema.Struct({ "enabled": Schema.Boolean }) export type RuntimeView = { readonly "detail"?: string | null, readonly "enabled": boolean, readonly "installed": boolean, readonly "principal"?: string | null, readonly "running": boolean, readonly "unit": string } export const RuntimeView = Schema.Struct({ "detail": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "enabled": Schema.Boolean, "installed": Schema.Boolean.annotate({ "description": "Is the runner payload/unit present at all?" }), "principal": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Windows: the account the task runs as." })), "running": Schema.Boolean, "unit": Schema.String.annotate({ "description": "systemd unit or scheduled-task name." }) }) -export type ScannerInfo = { readonly "enabled": boolean, readonly "id": string, readonly "label": string } -export const ScannerInfo = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether this host runs the scanner (default true)." }), "id": Schema.String.annotate({ "description": "Stable scanner id — the same string the scanner's entries carry in their `store` field." }), "label": Schema.String.annotate({ "description": "Human-facing name for the console toggle." }) }).annotate({ "description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host." }) +export type ScannerInfo = { readonly "enabled": boolean, readonly "entries"?: number, readonly "id": string, readonly "label": string, readonly "origin": "builtin" | "plugin", readonly "provider"?: string | null } +export const ScannerInfo = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether this host runs the source (default true)." }), "entries": Schema.optionalKey(Schema.Union([Schema.Number.check(Schema.isInt()).check(Schema.makeFilterGroup([Schema.isFinite(), Schema.isGreaterThanOrEqualTo(0)], { "description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle." }))])), "id": Schema.String.annotate({ "description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin." }), "label": Schema.String.annotate({ "description": "Human-facing name for the console toggle." }), "origin": Schema.Literals(["builtin", "plugin"]).annotate({ "description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`." }), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider id backing a `plugin` source — absent for a built-in scanner." })) }).annotate({ "description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible." }) export type ScannerToggle = { readonly "enabled": boolean } export const ScannerToggle = Schema.Struct({ "enabled": Schema.Boolean.annotate({ "description": "Whether the scanner should run on this host." }) }).annotate({ "description": "Request body for `setLibraryScanner`." }) export type SessionRef = { readonly "client": string, readonly "hdr": boolean, readonly "id": number, readonly "mode": string } @@ -147,8 +147,8 @@ export type GpuState = { readonly "active"?: null | { readonly "backend": string export const GpuState = Schema.Struct({ "active": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "backend": Schema.String.annotate({ "description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)." }), "id": Schema.String.annotate({ "description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)." }), "name": Schema.String, "sessions": Schema.Number.annotate({ "description": "Number of live encode sessions on it.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU live sessions use right now (absent while nothing is streaming)." })], { mode: "oneOf" })), "encoder_pin": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken." })), "env_override": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it." })), "gpus": Schema.Array(ApiGpu).annotate({ "description": "The host's hardware GPUs." }), "mode": Schema.String.annotate({ "description": "`auto` or `manual`." }), "preferred_available": Schema.Boolean.annotate({ "description": "Whether the preferred GPU is currently present." }), "preferred_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent." })), "preferred_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stored name of the preferred GPU (a usable label even when it is absent)." })), "selected": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "id": Schema.String, "name": Schema.String, "source": Schema.String.annotate({ "description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU the next session will use." })], { mode: "oneOf" })) }).annotate({ "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now." }) export type MonitorsResponse = { readonly "compositor"?: string | null, readonly "error"?: string | null, readonly "monitors": ReadonlyArray, readonly "pin_supported": boolean, readonly "pinned"?: string | null } export const MonitorsResponse = Schema.Struct({ "compositor": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved." })), "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"." })), "monitors": Schema.Array(ApiMonitorInfo).annotate({ "description": "The heads, ordered left-to-right by desktop position." }), "pin_supported": Schema.Boolean.annotate({ "description": "Whether this build can actually STREAM one of these monitors.\n\nEnumeration and capture are separate capabilities, and on Windows only the first exists: the\nheads below are real and worth showing (they explain the topology, and `/display/state`\ncross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a\nframe channel pushed by our OWN IddCx virtual display. There is no desktop-duplication\ncapturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so\n`vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.\n\nThe console renders the picker read-only on `false`. Reported as a capability rather than\nsniffed client-side from the OS so the answer comes from the build that would have to honor\nit — when a Windows mirror backend lands, this flips and the UI needs no change." }), "pinned": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"." })) }).annotate({ "description": "The host's physical monitors + which one capture is pinned to." }) -export type GameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "store": string, readonly "title": string } -export const GameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:` or `custom:`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." }) +export type GameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "role"?: "game" | "launcher", readonly "store": string, readonly "title": string } +export const GameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:` or `custom:`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." }) export type HooksConfig = { readonly "hooks"?: ReadonlyArray } export const HooksConfig = Schema.Struct({ "hooks": Schema.optionalKey(Schema.Array(HookEntry)) }).annotate({ "description": "The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body." }) export type LogPage = { readonly "dropped": boolean, readonly "entries": ReadonlyArray, readonly "next": number } @@ -163,20 +163,20 @@ export type StreamRef = { readonly "app"?: string | null, readonly "client": str export const StreamRef = Schema.Struct({ "app": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The launched app/title for this stream, when one was requested (store-qualified id on\nthe native plane, app title on the GameStream plane)." })), "client": Schema.String.annotate({ "description": "Client-supplied device name; may be empty." }), "hdr": Schema.Boolean, "mode": Schema.String.annotate({ "description": "Negotiated mode, `WxH@Hz`." }), "plane": Plane }).annotate({ "description": "A live video stream (what the stream marker file reflects)." }) export type PluginLogBatch = { readonly "entries": ReadonlyArray } export const PluginLogBatch = Schema.Struct({ "entries": Schema.Array(PluginLogLine) }).annotate({ "description": "A batch of runner log lines." }) -export type PluginSummary = { readonly "id": string, readonly "title": string, readonly "ui"?: null | PluginUiPublic, readonly "version"?: string | null } -export const PluginSummary = Schema.Struct({ "id": Schema.String, "title": Schema.String, "ui": Schema.optionalKey(Schema.Union([Schema.Null, PluginUiPublic], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])) }).annotate({ "description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy)." }) +export type PluginSummary = { readonly "category"?: string | null, readonly "id": string, readonly "title": string, readonly "ui"?: null | PluginUiPublic, readonly "version"?: string | null } +export const PluginSummary = Schema.Struct({ "category": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The plugin's kind — see [`PluginRegistration::category`]." })), "id": Schema.String, "title": Schema.String, "ui": Schema.optionalKey(Schema.Union([Schema.Null, PluginUiPublic], { mode: "oneOf" })), "version": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])) }).annotate({ "description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy)." }) export type HostInfo = { readonly "abi_version": number, readonly "app_version": string, readonly "codecs": ReadonlyArray, readonly "gamestream": boolean, readonly "gfe_version": string, readonly "hostname": string, readonly "local_ip": string, readonly "os": string, readonly "os_name": string, readonly "ports": PortMap, readonly "uniqueid": string, readonly "version": string } export const HostInfo = Schema.Struct({ "abi_version": Schema.Number.annotate({ "description": "`punktfunk-core` C ABI version.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "app_version": Schema.String.annotate({ "description": "GameStream host version advertised to Moonlight clients." }), "codecs": Schema.Array(ApiCodec).annotate({ "description": "Codecs the host can encode (NVENC)." }), "gamestream": Schema.Boolean.annotate({ "description": "Whether the GameStream/Moonlight-compat planes are running (`--gamestream`). `false` on the\nsecure default (native punktfunk/1 only) — a console can hide Moonlight-only UI (e.g. the\nMoonlight PIN pairing card, which could never receive a PIN when this is `false`)." }), "gfe_version": Schema.String.annotate({ "description": "GFE version advertised to Moonlight clients." }), "hostname": Schema.String, "local_ip": Schema.String.annotate({ "description": "Best-effort primary LAN IP." }), "os": Schema.String.annotate({ "description": "OS identity chain, generic → most specific, slash-separated (`windows` | `macos` |\n`linux[/][/]`). A client walks it most-specific-first and shows the first\ntoken it has an icon for, so an unknown distro still degrades to its family's mark." }), "os_name": Schema.String.annotate({ "description": "Human-readable OS name (os-release `PRETTY_NAME`; `\"Windows\"`/`\"macOS\"` elsewhere)." }), "ports": PortMap, "uniqueid": Schema.String.annotate({ "description": "Stable per-host id (persisted across restarts), matched on pairing." }), "version": Schema.String.annotate({ "description": "`punktfunk-host` crate version." }) }).annotate({ "description": "Host identity and advertised capabilities (static for the life of the process)." }) export type DisplayLayoutRequest = { readonly "positions"?: { readonly [x: string]: Position } } export const DisplayLayoutRequest = Schema.Struct({ "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).annotate({ "description": "`{\"\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits." }).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`)." }) export type Layout = { readonly "mode"?: LayoutMode, readonly "positions"?: { readonly [x: string]: Position } } export const Layout = Schema.Struct({ "mode": Schema.optionalKey(LayoutMode), "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON)." }) -export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "provider"?: string | null, readonly "title": string } -export const CustomEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) -export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "title": string } -export const CustomInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." }) -export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "title": string } -export const ProviderEntryInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) +export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "provider"?: string | null, readonly "role"?: "game" | "launcher", readonly "store"?: string | null, readonly "title": string } +export const CustomEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) +export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "role"?: "game" | "launcher", readonly "title": string } +export const CustomInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." }) +export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "role"?: "game" | "launcher", readonly "title": string } +export const ProviderEntryInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) export type CatalogResponse = { readonly "busy": boolean, readonly "host": HostFacts, readonly "plugins": ReadonlyArray, readonly "sources": ReadonlyArray } export const CatalogResponse = Schema.Struct({ "busy": Schema.Boolean.annotate({ "description": "True while a package operation is in flight — the console disables install buttons." }), "host": HostFacts, "plugins": Schema.Array(CatalogEntry), "sources": Schema.Array(SourceView) }) export type StatsSample = { readonly "bitrate_kbps": number, readonly "fec_recovered": number, readonly "fps": number, readonly "frames_dropped": number, readonly "mbps": number, readonly "packets_dropped": number, readonly "repeat_fps": number, readonly "send_dropped": number, readonly "session_id": number, readonly "stages": ReadonlyArray, readonly "t_ms": number } @@ -370,6 +370,8 @@ export type DeleteCustomGame404 = ApiError export const DeleteCustomGame404 = ApiError export type DeleteCustomGame500 = ApiError export const DeleteCustomGame500 = ApiError +export type ReconcileProviderEntriesParams = { readonly "store"?: string } +export const ReconcileProviderEntriesParams = Schema.Struct({ "store": Schema.optionalKey(Schema.String) }) export type ReconcileProviderEntriesRequestJson = ReadonlyArray export const ReconcileProviderEntriesRequestJson = Schema.Array(ProviderEntryInput) export type ReconcileProviderEntries200 = ReadonlyArray @@ -378,6 +380,8 @@ export type ReconcileProviderEntries400 = ApiError export const ReconcileProviderEntries400 = ApiError export type ReconcileProviderEntries401 = ApiError export const ReconcileProviderEntries401 = ApiError +export type ReconcileProviderEntries409 = ApiError +export const ReconcileProviderEntries409 = ApiError export type ReconcileProviderEntries500 = ApiError export const ReconcileProviderEntries500 = ApiError export type DeleteProviderEntries200 = ProviderRemoved @@ -987,11 +991,13 @@ export const make = ( })) ), "reconcileProviderEntries": (provider, options) => HttpClientRequest.put(`/api/v1/library/provider/${provider}`).pipe( + HttpClientRequest.setUrlParams({ "store": options.params?.["store"] as any }), HttpClientRequest.bodyJsonUnsafe(options.payload), withResponse(options.config)(HttpClientResponse.matchStatus({ "2xx": decodeSuccess(ReconcileProviderEntries200), "400": decodeError("ReconcileProviderEntries400", ReconcileProviderEntries400), "401": decodeError("ReconcileProviderEntries401", ReconcileProviderEntries401), + "409": decodeError("ReconcileProviderEntries409", ReconcileProviderEntries409), "500": decodeError("ReconcileProviderEntries500", ReconcileProviderEntries500), orElse: unexpectedStatus })) @@ -1570,8 +1576,16 @@ readonly "deleteCustomGame": (id: string, option * surviving title's host id stable across reconciles, drops orphans, and never touches manual * entries or other providers'. An empty array removes everything the provider owns. Emits * `library.changed` with the provider as `source`. +* +* `?store=` additionally **claims** that store for the provider: its entries then surface with +* deterministic `:` ids and the store's own badge, instead of opaque +* `custom:` ones — which is what lets a library plugin reproduce the entries an in-host scanner +* used to produce, right down to the GameStream app ids and client-side art caches. One provider +* per store; a second claimant gets 409. While a claim is held the matching built-in scanner is +* suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty +* reconcile (a store can legitimately have zero installed titles). */ -readonly "reconcileProviderEntries": (provider: string, options: { readonly payload: typeof ReconcileProviderEntriesRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ReconcileProviderEntries400", typeof ReconcileProviderEntries400.Type> | PunktfunkError<"ReconcileProviderEntries401", typeof ReconcileProviderEntries401.Type> | PunktfunkError<"ReconcileProviderEntries500", typeof ReconcileProviderEntries500.Type>> +readonly "reconcileProviderEntries": (provider: string, options: { readonly params?: typeof ReconcileProviderEntriesParams.Encoded | undefined; readonly payload: typeof ReconcileProviderEntriesRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"ReconcileProviderEntries400", typeof ReconcileProviderEntries400.Type> | PunktfunkError<"ReconcileProviderEntries401", typeof ReconcileProviderEntries401.Type> | PunktfunkError<"ReconcileProviderEntries409", typeof ReconcileProviderEntries409.Type> | PunktfunkError<"ReconcileProviderEntries500", typeof ReconcileProviderEntries500.Type>> /** * Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin * (RFC §8). Emits `library.changed` when anything was removed. diff --git a/sdk/src/ui.ts b/sdk/src/ui.ts index ac466a72..b71f9e77 100644 --- a/sdk/src/ui.ts +++ b/sdk/src/ui.ts @@ -44,6 +44,17 @@ export interface PluginUiOptions { version?: string; /** Optional lucide icon name for the nav entry (`[a-z0-9-]`, e.g. `"gamepad-2"`). */ icon?: string; + /** + * What KIND of plugin this is (`[a-z][a-z0-9-]{0,31}`). The console groups and filters on it — + * and notably keeps `"library"` plugins **out of the nav**, because a scanner's entry point is + * the Library section's Game sources surface, not a sidebar item of its own. Six installed + * scanners would otherwise flood the sidebar. + * + * `@punktfunk/plugin-kit`'s `defineLibraryPlugin` sets this for you. Set it by hand only if you + * are building a library plugin without the kit — and omit it if your plugin wants a full page + * despite also syncing a library (rom-manager does). + */ + category?: string; /** * Directory of the built SPA. Requests are served from here first (with an `index.html` SPA * fallback for navigations); a static miss falls through to [`fetch`]. Accepts a filesystem @@ -182,6 +193,9 @@ export const servePluginUi = async ( secret, ...(opts.icon !== undefined ? { icon: opts.icon } : {}), }, + // Sent through the UNTYPED `pf.request` below, so an older host simply ignores the unknown + // field rather than rejecting the registration — no runner flag, no version gate. + ...(opts.category !== undefined ? { category: opts.category } : {}), }; const register = () => pf.request("PUT", `/plugins/${opts.id}`, body); From 8728d90e01da654a08714c8f850b35175734217c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 09:53:58 +0200 Subject: [PATCH 03/64] =?UTF-8?q?feat(plugin-kit):=20the=20library-plugin?= =?UTF-8?q?=20framework=20=E2=80=94=20parsers,=20=5F=5Fconfig,=20defineLib?= =?UTF-8?q?raryPlugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3 of design/library-scanner-plugins-implementation-plan.md. Target shape: a first-party scanner plugin is its parsers plus a scan function. WP3.1 — a parsers module under the new ./library subpath, porting what the six in-host scanners hand-rolled: text VDF/ACF, the BINARY shortcuts.vdf KeyValues walker with its CRC-32 appid derivation and the 64-bit rungameid composition, read-only SQLite (bun:sqlite, immutable=1 so a scan can never take a lock or spawn WAL sidecars next to a launcher's live database), a reg.exe wrapper, capped readers, the path-confinement join that keeps a crafted goggame-*.info from pointing a launch at an arbitrary program, Steam root/library discovery, art location helpers, and a fetch helper carrying the host's no-redirect anti-SSRF posture. Every parser is total: a missing launcher or a truncated file degrades to "no titles", never to a throw. Two deliberate departures from the Rust originals, both about the Windows runner's account: steam root discovery now also reads HKLM Valve\Steam InstallPath (a non-default install dir was previously uncovered), and the registry wrapper refuses HKCU outright — as LocalService that is not the operator's hive, so reading it would silently look like "not installed". WP3.2 — GET/PUT /__config on the kit's UI server, so a plugin with settings does not ship an SPA (closes G8). GET answers {schema, value}: the derived JSON Schema and the raw operator-authored config. PUT validates by decoding and only then persists RAW, so defaults are never baked into the file. The handler is split out as makeConfigHandler and driven directly in tests. WP3.3 — defineLibraryPlugin wires SyncEngine (poll + fs-watch + debounce), the store-claiming reconcile, launcher entries appended to every sync, a UI server serving only __config under category "library" (which keeps six installed scanners out of the console nav), and the standard detect/scan/uninstall CLI verbs. It warns ONCE when a pre-M2 host silently ignores the store claim — that degradation is otherwise invisible except as duplicated titles. M0/S2 is recorded here as a committed fixture rather than prose. Two findings the original spike missed because deriving a schema does not exercise it: withDecodingDefaultKey takes an Effect, not a thunk — a thunk type-checks, derives fine, and dies at decode time; and a checked schema (Schema.Int) nests its annotations under allOf, so a form must merge those branches. Both are pinned. plugin-kit: version 0.3.0, tsc clean, 46 tests pass (16 ported parser tests, 10 config/derivation). Publishing (WP3.4) is deferred — it needs a tag and a push. --- plugin-kit/package.json | 6 +- plugin-kit/src/index.ts | 9 +- plugin-kit/src/library/define.ts | 264 +++++++++++++++++ plugin-kit/src/library/index.ts | 12 + plugin-kit/src/library/parsers/art.ts | 120 ++++++++ plugin-kit/src/library/parsers/fs.ts | 112 +++++++ plugin-kit/src/library/parsers/http.ts | 94 ++++++ plugin-kit/src/library/parsers/index.ts | 60 ++++ plugin-kit/src/library/parsers/registry.ts | 94 ++++++ plugin-kit/src/library/parsers/shortcuts.ts | 160 ++++++++++ plugin-kit/src/library/parsers/sqlite.ts | 68 +++++ plugin-kit/src/library/parsers/steam-root.ts | 104 +++++++ plugin-kit/src/library/parsers/vdf.ts | 80 +++++ plugin-kit/src/ui-server.ts | 133 ++++++++- plugin-kit/test/library-config.test.ts | 240 +++++++++++++++ plugin-kit/test/library-parsers.test.ts | 289 +++++++++++++++++++ 16 files changed, 1840 insertions(+), 5 deletions(-) create mode 100644 plugin-kit/src/library/define.ts create mode 100644 plugin-kit/src/library/index.ts create mode 100644 plugin-kit/src/library/parsers/art.ts create mode 100644 plugin-kit/src/library/parsers/fs.ts create mode 100644 plugin-kit/src/library/parsers/http.ts create mode 100644 plugin-kit/src/library/parsers/index.ts create mode 100644 plugin-kit/src/library/parsers/registry.ts create mode 100644 plugin-kit/src/library/parsers/shortcuts.ts create mode 100644 plugin-kit/src/library/parsers/sqlite.ts create mode 100644 plugin-kit/src/library/parsers/steam-root.ts create mode 100644 plugin-kit/src/library/parsers/vdf.ts create mode 100644 plugin-kit/test/library-config.test.ts create mode 100644 plugin-kit/test/library-parsers.test.ts diff --git a/plugin-kit/package.json b/plugin-kit/package.json index 855d1a82..c53efb8e 100644 --- a/plugin-kit/package.json +++ b/plugin-kit/package.json @@ -1,6 +1,6 @@ { "name": "@punktfunk/plugin-kit", - "version": "0.2.0", + "version": "0.3.0", "description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.", "type": "module", "license": "MIT OR Apache-2.0", @@ -29,6 +29,10 @@ "types": "./dist/wire.d.ts", "default": "./dist/wire.js" }, + "./library": { + "types": "./dist/library/index.d.ts", + "default": "./dist/library/index.js" + }, "./theme.css": "./dist/theme.css" }, "files": ["dist", "README.md"], diff --git a/plugin-kit/src/index.ts b/plugin-kit/src/index.ts index 3d2758dd..cd4aef44 100644 --- a/plugin-kit/src/index.ts +++ b/plugin-kit/src/index.ts @@ -43,6 +43,13 @@ export { type SyncSettings, type SyncStatus, } from "./sync-engine.js"; -export { httpApiEnv, serveUi, type ServeUiOptions } from "./ui-server.js"; +export { + deriveConfigJsonSchema, + httpApiEnv, + makeConfigHandler, + serveUi, + type ServeUiConfig, + type ServeUiOptions, +} from "./ui-server.js"; export { sseRoute, type SseRouteOptions } from "./sse.js"; export { type CliCommand, runPluginCli } from "./cli.js"; diff --git a/plugin-kit/src/library/define.ts b/plugin-kit/src/library/define.ts new file mode 100644 index 00000000..c66d1a32 --- /dev/null +++ b/plugin-kit/src/library/define.ts @@ -0,0 +1,264 @@ +// `defineLibraryPlugin` — the shared framework behind every library-scanner plugin (design D10). +// +// The point of this module is that a first-party scanner should be **its parsers and a scan +// function**, ~200–400 lines, and nothing else. Everything a scanner needs beyond that is identical +// across all six of them and lives here: claiming the store, reconciling through the sync engine, +// appending launcher entries, serving `__config` so the console renders settings without the plugin +// shipping an SPA, registering under `category: "library"` so it stays out of the nav, and the +// standard CLI verbs. +import type { PluginDef } from "@punktfunk/host"; +import { Duration, Effect, Layer, Schema, Stream } from "effect"; +import { type CliCommand, runPluginCli } from "../cli.js"; +import { type ConfigService, makeConfigService } from "../config.js"; +import { type HostClient, PluginInfo } from "../host-client.js"; +import { ProviderClient, type ProviderClientService } from "../reconcile.js"; +import { definePluginKit, type PluginKitDef } from "../runtime.js"; +import { makeSyncEngine } from "../sync-engine.js"; +import { serveUi } from "../ui-server.js"; +import type { ProviderEntry } from "../wire.js"; + +/** What a scan produced — the status surface and the CLI's `scan` verb both render this. */ +export interface ScanReport { + readonly entries: number; + readonly launchers: number; + /** False when the launcher isn't installed here — the library is legitimately empty. */ + readonly present: boolean; +} + +export interface LibraryPluginDef { + /** + * The plugin id. **This one string is also the provider id, the store claim, and the id of the + * built-in scanner this plugin replaces.** That identity chain is what makes the migration + * invisible: entry ids stay `:`, GameStream app ids and client art caches + * stay valid, and the operator's existing enable/disable state carries over untouched. + */ + readonly name: string; + readonly version?: string; + /** + * The store to claim (design D2). Defaults to {@link name} and should almost never differ — see + * the identity note above. Pass `null` to opt out of claiming entirely, which makes this an + * ordinary unclaimed provider whose entries surface as `custom:`. + */ + readonly store?: string | null; + /** The operator-facing config schema. Drives `__config` and every callback's argument. */ + readonly configSchema: S; + /** + * Is this launcher present on the host at all? Surfaces in the CLI's `detect` verb, and lets the + * plugin report "not installed" rather than silently syncing an empty library. + */ + readonly detect: (cfg: S["Type"]) => Effect.Effect; + /** Enumerate the launcher's installed titles — the only real per-store code. */ + readonly scan: ( + cfg: S["Type"], + ) => Effect.Effect>; + /** + * Entries that open the LAUNCHER itself (design D4) — Steam Big Picture, Heroic, … Appended to + * every reconcile, so toggling one in config takes effect on the next sync. Emit them with + * `role: "launcher"`; the kit does not stamp it for you, because a plugin may legitimately want + * an entry that opens a launcher but still lists as an ordinary game. + */ + readonly launchers?: (cfg: S["Type"]) => ReadonlyArray; + /** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */ + readonly watchDirs?: (cfg: S["Type"]) => ReadonlyArray; + /** How often to re-scan regardless of watches. Default `Duration.minutes(15)`. */ + readonly pollInterval?: Duration.Duration; + /** Debounce on filesystem events. Default `Duration.seconds(3)`. */ + readonly debounce?: Duration.Duration; + /** Display title (the console's sources row falls back to the scanner label). Defaults to `name`. */ + readonly title?: string; + /** Extra CLI verbs beyond the standard `detect` / `scan` / `uninstall` set. */ + readonly commands?: Record>; +} + +/** The pieces a library plugin package wires into its entry points. */ +export interface LibraryPlugin { + /** The runner-discovered default export (`export default plugin.def`). */ + readonly def: PluginDef; + /** The CLI entry (`await plugin.cli()` from the package's bin). */ + readonly cli: (argv?: ReadonlyArray) => Promise; +} + +export const defineLibraryPlugin = ( + def: LibraryPluginDef, +): LibraryPlugin => { + const store = def.store === null ? undefined : (def.store ?? def.name); + const poll = def.pollInterval ?? Duration.minutes(15); + const debounce = def.debounce ?? Duration.seconds(3); + + /** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */ + const config: Effect.Effect, never, PluginInfo> = + makeConfigService({ schema: def.configSchema }); + + /** Scan + launcher entries, in the order they should reach the host. */ + const computeEntries = ( + cfg: S["Type"], + ): Effect.Effect<{ + readonly entries: ReadonlyArray; + readonly report: ScanReport; + }> => + Effect.gen(function* () { + const present = yield* def.detect(cfg); + // A launcher that isn't installed contributes NOTHING — not even its launcher entries. A + // "Steam Big Picture" tile on a box without Steam would only fail to launch. + if (!present) { + return { + entries: [] as ReadonlyArray, + report: { entries: 0, launchers: 0, present: false } as const, + }; + } + const scanned = yield* def.scan(cfg); + const launchers = def.launchers?.(cfg) ?? []; + return { + entries: [...scanned, ...launchers], + report: { + entries: scanned.length, + launchers: launchers.length, + present: true, + } as const, + }; + }); + + /** + * Push one entry set to the host under the store claim, warning **once** if the host is too old + * to honour it. + * + * This degradation is worth the code: a pre-M2 host ignores `?store=` silently, and the only + * symptom would be this plugin's titles appearing as unbadged `custom:` entries *beside* the + * built-in scanner's identical ones — a confusing double-listing with no error anywhere. + * Checking the echoed entries turns that into one actionable log line. + */ + const applyEntries = + (provider: ProviderClientService, state: { warned: boolean }) => + (entries: ReadonlyArray): Effect.Effect => + provider.reconcile(def.name, entries, store).pipe( + Effect.tap((echoed) => { + if (!store || state.warned || echoed.length === 0) return Effect.void; + if (echoed.some((e) => e.store === store)) return Effect.void; + state.warned = true; + return Effect.logWarning( + `host is too old for store claims: this source's games will appear as custom ` + + `entries and the host's own "${store}" scanner is not suppressed, so titles ` + + `may be listed twice. Updating the host resolves it.`, + ); + }), + Effect.asVoid, + ); + + const main = Effect.gen(function* () { + const cfgService = yield* config; + const provider = yield* ProviderClient; + const state = { warned: false }; + + const engine = yield* makeSyncEngine< + ScanReport, + ReadonlyArray, + never + >({ + compute: () => cfgService.load.pipe(Effect.flatMap(computeEntries)), + apply: applyEntries(provider, state), + // The host IS the state: a full-replace reconcile is idempotent, so there is nothing to + // persist between runs. Reporting no previous fingerprint means the first sync after a + // restart always pushes, which is exactly what we want (the host may have been reinstalled + // underneath us). + lastSync: { get: Effect.succeed(undefined), set: () => Effect.void }, + settings: cfgService.load.pipe( + Effect.map((cfg) => def.watchDirs?.(cfg) ?? []), + // A config file that won't decode must not stop the poll loop: fall back to no watch + // dirs, keep syncing on the timer, and let the operator see the parse error in the + // settings drawer (`GET /__config` reports it). + Effect.catch(() => Effect.succeed([] as ReadonlyArray)), + Effect.map((watchDirs) => ({ + pollInterval: poll, + watch: true, + debounce, + watchDirs, + })), + ), + }); + + // The UI server exists ONLY to serve `__config` (and the SDK's `__health`): no `staticDir`, + // no API. That is the whole "settings without an SPA" story (design D7, closing G8), and the + // `library` category is what keeps six installed scanners out of the console's sidebar. + yield* serveUi({ + title: def.title ?? def.name, + category: "library", + config: { schema: def.configSchema, service: cfgService }, + }); + + yield* engine.start; + // A saved settings change is exactly when a user expects the library to update — and it may + // have changed `watchDirs`, so re-read settings rather than just re-syncing. + yield* Effect.forkScoped( + Stream.runForEach(cfgService.changes, () => engine.reconfigure), + ); + yield* Effect.never; + }); + + const kitDef: PluginKitDef = { + name: def.name, + ...(def.version !== undefined ? { version: def.version } : {}), + layer: ProviderClient.layer, + main: main as Effect.Effect< + void, + never, + ProviderClient | HostClient | PluginInfo | never + >, + }; + + const standardCommands: Record> = { + detect: { + summary: "report whether this launcher is installed on the host", + // Offline on purpose: "is Steam here?" must be answerable without a running host. + offline: true, + run: () => + Effect.gen(function* () { + const cfg = yield* (yield* config).load; + console.log((yield* def.detect(cfg)) ? "present" : "absent"); + }), + }, + scan: { + summary: "scan and print what WOULD be synced (--preview for the JSON entries)", + // Also offline: the point is to debug a scanner against real launcher files without + // touching the host's library. + offline: true, + run: (argv) => + Effect.gen(function* () { + const cfg = yield* (yield* config).load; + const { entries, report } = yield* computeEntries(cfg); + if (argv.includes("--preview")) { + console.log(JSON.stringify(entries, null, 2)); + } else { + console.log( + `${report.present ? "present" : "absent"}: ${report.entries} games, ` + + `${report.launchers} launcher entries`, + ); + } + }), + }, + uninstall: { + summary: "remove this source's games from the host and release its store claim", + run: () => + Effect.gen(function* () { + const provider = yield* ProviderClient; + // The empty reconcile clears the entries; DELETE is what releases the CLAIM — and + // releasing is what brings the host's own built-in scanner straight back. + yield* provider.reconcile(def.name, [], undefined); + yield* provider.remove(def.name); + console.log(`${def.name}: entries removed, store claim released`); + }), + }, + }; + + return { + def: definePluginKit(kitDef), + cli: (argv) => + runPluginCli({ + def: kitDef, + commands: { + ...standardCommands, + ...(def.commands ?? {}), + } as Record>, + ...(argv !== undefined ? { argv } : {}), + }), + }; +}; diff --git a/plugin-kit/src/library/index.ts b/plugin-kit/src/library/index.ts new file mode 100644 index 00000000..ce80c675 --- /dev/null +++ b/plugin-kit/src/library/index.ts @@ -0,0 +1,12 @@ +// `@punktfunk/plugin-kit/library` — the shared framework for library-scanner plugins. +// +// A first-party scanner is its parsers plus a scan function; everything else (store claim, sync +// engine wiring, launcher entries, `__config`, nav category, CLI verbs) comes from +// `defineLibraryPlugin`. See design/library-scanner-plugins.md D10. +export { + defineLibraryPlugin, + type LibraryPlugin, + type LibraryPluginDef, + type ScanReport, +} from "./define.js"; +export * from "./parsers/index.js"; diff --git a/plugin-kit/src/library/parsers/art.ts b/plugin-kit/src/library/parsers/art.ts new file mode 100644 index 00000000..f39ec22d --- /dev/null +++ b/plugin-kit/src/library/parsers/art.ts @@ -0,0 +1,120 @@ +// Where a title's cover art lives: Steam's local caches, its per-account `grid/` overrides, and the +// public CDN. Ported from the host scanner's art resolution (steam.rs). +// +// After extraction a plugin emits art VALUES and the host serves them: a `file://` URL for anything +// on disk (the documented local-art contract — the host proxies the bytes), or an absolute CDN URL +// the client fetches itself. `data:` URLs remain legal but are small-logo-only: inlining covers is +// what blew the host's 2 MB body limit at 49 titles during the playnite work. +import * as path from "node:path"; +import { isFile, listDir } from "./fs.js"; + +/** The four art slots the library model carries. */ +export type ArtKind = "portrait" | "hero" | "logo" | "header"; + +export const ART_KINDS: readonly ArtKind[] = [ + "portrait", + "hero", + "logo", + "header", +]; + +/** A `file://` URL for a local path — the shape the host's art proxy understands. */ +export const fileUrl = (p: string): string => { + // Percent-encode, but keep the separators: the host converts this back to a path and expects the + // structure intact. Windows drive paths become `file:///C:/…`. + const abs = path.resolve(p); + const posix = abs.replace(/\\/g, "/"); + const encoded = posix + .split("/") + .map((seg) => encodeURIComponent(seg)) + .join("/"); + return posix.startsWith("/") ? `file://${encoded}` : `file:///${encoded}`; +}; + +/** + * The legacy flat CDN URL for a Steam appid's art kind. Correct for the many titles Valve hasn't + * re-hashed; newer ones serve from an unpredictable per-asset-hash path, where this 404s and the + * client falls through to its next candidate. That degradation is intentional and pre-existing. + */ +export const steamCdnUrl = (appid: number, kind: ArtKind): string | undefined => { + // A non-Steam shortcut's appid has the high bit set and is never a real store appid — the CDN + // would only 404, so don't emit a URL that is guaranteed to fail. + if ((appid & 0x8000_0000) !== 0) return undefined; + const file = + kind === "portrait" + ? "library_600x900.jpg" + : kind === "hero" + ? "library_hero.jpg" + : kind === "logo" + ? "logo.png" + : "header.jpg"; + return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${file}`; +}; + +/** Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper). */ +const localFilenames = (kind: ArtKind): string[] => + kind === "portrait" + ? ["library_600x900_2x.jpg", "library_600x900.jpg"] + : kind === "hero" + ? ["library_hero.jpg"] + : kind === "logo" + ? ["logo.png"] + : // Steam's local cache names the header asset differently from the store CDN's + // `header.jpg` — this trips everyone once. + ["library_header.jpg"]; + +/** + * This kind's file under one Steam root's `appcache/librarycache///`, or `undefined`. + * Steam reuses one hash dir per asset version, so there is normally exactly one candidate. + */ +export const findLocalArtFile = ( + root: string, + appid: number, + kind: ArtKind, +): string | undefined => { + const base = path.join(root, "appcache", "librarycache", String(appid)); + for (const hash of listDir(base)) { + for (const name of localFilenames(kind)) { + const p = path.join(base, hash, name); + if (isFile(p)) return p; + } + } + // Older Steam wrote the files directly under `librarycache/` with the appid in the name. + for (const name of localFilenames(kind)) { + const flat = path.join(root, "appcache", "librarycache", `${appid}_${name}`); + if (isFile(flat)) return flat; + } + return undefined; +}; + +/** + * The `grid/` basenames Steam names each art kind under for an appid: portrait `p`, hero + * `_hero`, logo `_logo`, wide capsule `` — each as `.png` then `.jpg`. + * + * These overrides are the **only** art a non-Steam shortcut ever has. + */ +export const gridFilenames = (appid: number, kind: ArtKind): string[] => { + const base = + kind === "portrait" + ? `${appid}p` + : kind === "hero" + ? `${appid}_hero` + : kind === "logo" + ? `${appid}_logo` + : `${appid}`; + return [`${base}.png`, `${base}.jpg`]; +}; + +/** This kind's user override under a `userdata//config/grid/` dir, or `undefined`. */ +export const findGridArtFile = ( + configDir: string, + appid: number, + kind: ArtKind, +): string | undefined => { + const grid = path.join(configDir, "grid"); + for (const name of gridFilenames(appid, kind)) { + const p = path.join(grid, name); + if (isFile(p)) return p; + } + return undefined; +}; diff --git a/plugin-kit/src/library/parsers/fs.ts b/plugin-kit/src/library/parsers/fs.ts new file mode 100644 index 00000000..288f9243 --- /dev/null +++ b/plugin-kit/src/library/parsers/fs.ts @@ -0,0 +1,112 @@ +// Bounded filesystem reads and path confinement — the posture the in-host scanners established, +// ported so a library plugin inherits it instead of re-deriving it. +// +// The rules here exist because a plugin reads files it does not own: a launcher's manifests, a +// catalog cache, a `goggame-*.info` a user could have edited. None of that is hostile in the normal +// case, and all of it is untrusted in the case that matters. +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** A launcher manifest / `.acf` / `.info`: text, small. Matches `epic.rs`'s posture. */ +export const MAX_MANIFEST_BYTES = 1024 * 1024; +/** A binary catalog cache (Epic's `catcache.bin`, a `shortcuts.vdf`): larger, still bounded. */ +export const MAX_CACHE_BYTES = 32 * 1024 * 1024; + +/** + * Read a file as UTF-8, refusing anything over `max`. `undefined` on any error, a non-regular file, + * or an over-cap file — a plugin scanning a directory must never die on one odd entry. + * + * The size is checked by `stat` BEFORE the read, so an enormous file costs a stat, not the memory. + */ +export const readTextCapped = ( + file: string, + max = MAX_MANIFEST_BYTES, +): string | undefined => { + try { + const st = fs.statSync(file); + if (!st.isFile() || st.size === 0 || st.size > max) return undefined; + return fs.readFileSync(file, "utf8"); + } catch { + return undefined; + } +}; + +/** Read a file as bytes, refusing anything over `max`. Same posture as {@link readTextCapped}. */ +export const readBytesCapped = ( + file: string, + max = MAX_CACHE_BYTES, +): Uint8Array | undefined => { + try { + const st = fs.statSync(file); + if (!st.isFile() || st.size === 0 || st.size > max) return undefined; + return new Uint8Array(fs.readFileSync(file)); + } catch { + return undefined; + } +}; + +/** Read + `JSON.parse` a capped text file. `undefined` on any read or parse failure. */ +export const readJsonCapped = ( + file: string, + max = MAX_MANIFEST_BYTES, +): T | undefined => { + const text = readTextCapped(file, max); + if (text === undefined) return undefined; + try { + return JSON.parse(text) as T; + } catch { + return undefined; + } +}; + +/** List a directory's entry names, or `[]` if it isn't readable. */ +export const listDir = (dir: string): string[] => { + try { + return fs.readdirSync(dir); + } catch { + return []; + } +}; + +/** Does this path exist as a directory? */ +export const isDir = (p: string): boolean => { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +}; + +/** Does this path exist as a regular, non-empty file? */ +export const isFile = (p: string): boolean => { + try { + const st = fs.statSync(p); + return st.isFile() && st.size > 0; + } catch { + return false; + } +}; + +/** + * Join `rel` onto `base` **only if it cannot escape** — the port of the host's `confined_join` + * (gog.rs), which exists because a crafted `goggame-.info` could otherwise point a play task's + * exe at an arbitrary program (security-review 2026-07-17). + * + * Refuses any relative path carrying a drive prefix (`C:`), a root (`/` or `\`), or a `..` + * component — each of which `path.join` would let REPLACE or climb out of `base`. `undefined` ⇒ + * out of bounds, and the caller must refuse the launch rather than fall back to something plausible. + */ +export const confinedJoin = (base: string, rel: string): string | undefined => { + if (rel === "") return undefined; + // Normalize separators so a Windows-shaped relative path is checked on any platform (a plugin + // may parse a Windows manifest while its tests run on Linux). + const parts = rel.split(/[\\/]/); + if (parts[0] === "" ) return undefined; // rooted + if (/^[A-Za-z]:$/.test(parts[0])) return undefined; // drive prefix + if (parts.some((p) => p === "..")) return undefined; // traversal + const joined = path.join(base, ...parts.filter((p) => p !== "" && p !== ".")); + // Belt and braces: the component check above is the real guard, but a symlink-free string check + // costs nothing and catches anything the split missed. + const rootWithSep = base.endsWith(path.sep) ? base : base + path.sep; + return joined === base || joined.startsWith(rootWithSep) ? joined : undefined; +}; diff --git a/plugin-kit/src/library/parsers/http.ts b/plugin-kit/src/library/parsers/http.ts new file mode 100644 index 00000000..d1f629d9 --- /dev/null +++ b/plugin-kit/src/library/parsers/http.ts @@ -0,0 +1,94 @@ +// The one outbound-HTTP helper a library plugin should use, carrying the host's `fetch_image` +// posture verbatim (art.rs): http(s) only, **no redirects**, a size cap, and a short timeout. +// +// The no-redirect rule is the important one and it is not paranoia: a scanner fetches URLs it read +// out of a launcher's cache — data the plugin did not author. A `3xx` chased automatically is an +// SSRF pivot from a process running on the operator's box (`http://169.254.169.254/…`, an internal +// service). The host learned this in the 2026-07-17 security review; a plugin fetching the same +// class of URL inherits the same rule. A rare legitimately-redirecting CDN just yields no art. +import { HostRequestError } from "../../errors.js"; +import { Effect } from "effect"; + +export interface FetchLimits { + /** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */ + readonly maxBytes?: number; + /** Wall-clock timeout in ms. Default 10 000. */ + readonly timeoutMs?: number; +} + +const DEFAULT_MAX = 8 * 1024 * 1024; +const DEFAULT_TIMEOUT = 10_000; + +export interface FetchedBytes { + readonly bytes: Uint8Array; + readonly contentType: string; +} + +/** + * GET an `http(s)` URL under the posture above. Fails with {@link HostRequestError} on any non-2xx, + * a redirect, an over-cap body, a timeout, or a non-http(s) scheme. + * + * Most scanners never need this: they emit CDN URLs and let the CLIENT fetch them, which is both + * faster and keeps the host out of the loop. Reach for it only when a store's art requires an API + * lookup the client cannot do (GOG's product API, Microsoft's display catalog). + */ +export const fetchBytes = ( + url: string, + limits: FetchLimits = {}, +): Effect.Effect => + Effect.tryPromise({ + try: async (): Promise => { + if (!/^https?:\/\//i.test(url)) { + throw new Error("only http(s) URLs may be fetched"); + } + const maxBytes = limits.maxBytes ?? DEFAULT_MAX; + const signal = AbortSignal.timeout(limits.timeoutMs ?? DEFAULT_TIMEOUT); + // `redirect: "manual"` rather than "error": we want to SEE the 3xx and report it as a + // refusal, not have fetch throw something opaque. + const res = await fetch(url, { redirect: "manual", signal }); + if (res.status >= 300 && res.status < 400) { + throw new Error(`refusing to follow a ${res.status} redirect`); + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + // Trust Content-Length when it is there (cheap rejection), but still bound the read: a + // hostile server can lie about it or omit it entirely. + const declared = Number(res.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) { + throw new Error(`body larger than ${maxBytes} bytes`); + } + const buf = new Uint8Array(await res.arrayBuffer()); + if (buf.byteLength === 0) throw new Error("empty body"); + if (buf.byteLength > maxBytes) { + throw new Error(`body larger than ${maxBytes} bytes`); + } + return { + bytes: buf, + contentType: res.headers.get("content-type") ?? "image/jpeg", + }; + }, + catch: (cause) => + new HostRequestError({ + method: "GET", + path: url, + cause, + }), + }); + +/** {@link fetchBytes}, JSON-decoded. Same posture; use for a store's public product API. */ +export const fetchJson = ( + url: string, + limits: FetchLimits = {}, +): Effect.Effect => + fetchBytes(url, limits).pipe( + Effect.flatMap((r) => + Effect.try({ + try: () => JSON.parse(new TextDecoder().decode(r.bytes)) as T, + catch: (cause) => + new HostRequestError({ + method: "GET", + path: url, + cause, + }), + }), + ), + ); diff --git a/plugin-kit/src/library/parsers/index.ts b/plugin-kit/src/library/parsers/index.ts new file mode 100644 index 00000000..56d5f834 --- /dev/null +++ b/plugin-kit/src/library/parsers/index.ts @@ -0,0 +1,60 @@ +// The launcher-file parsing toolkit: what the six in-host scanners hand-rolled, hoisted so a +// library plugin is its scan function and nothing else. +// +// Everything here is total — a missing launcher, a truncated file, a schema drift in a launcher +// upgrade all degrade to "no titles from this source", never to a thrown error. A scanner that dies +// on one odd file takes the user's whole library with it. +export { + ART_KINDS, + type ArtKind, + fileUrl, + findGridArtFile, + findLocalArtFile, + gridFilenames, + steamCdnUrl, +} from "./art.js"; +export { + confinedJoin, + isDir, + isFile, + listDir, + MAX_CACHE_BYTES, + MAX_MANIFEST_BYTES, + readBytesCapped, + readJsonCapped, + readTextCapped, +} from "./fs.js"; +export { + type FetchedBytes, + type FetchLimits, + fetchBytes, + fetchJson, +} from "./http.js"; +export { + parseRegQuery, + regQueryValue, + regQueryValues, + regSubKeys, + type RegValue, + validRegKey, +} from "./registry.js"; +export { + crc32, + parseShortcuts, + type Shortcut, + shortcutAppId, + shortcutGameId, +} from "./shortcuts.js"; +export { + steamLibraryDirs, + steamRoots, + steamUserConfigDirs, +} from "./steam-root.js"; +export { + type AppManifest, + isSteamTool, + parseAppManifest, + vdfField, + vdfPaths, + vdfValue, +} from "./vdf.js"; diff --git a/plugin-kit/src/library/parsers/registry.ts b/plugin-kit/src/library/parsers/registry.ts new file mode 100644 index 00000000..4b420d66 --- /dev/null +++ b/plugin-kit/src/library/parsers/registry.ts @@ -0,0 +1,94 @@ +// Windows registry reads by spawning `reg.exe query` — dependency-free, and (the part that +// matters) it works from the scripting runner's LocalService account. +// +// **HKLM only, by design.** The runner runs as `NT AUTHORITY\LocalService` on Windows, which has no +// user profile: HKCU is not the operator's hive there, it is LocalService's own — so a plugin that +// read HKCU would silently see an empty registry rather than the user's launcher config. Every +// launcher fact a scanner needs (Steam's InstallPath, GOG's game list) lives under HKLM +// `WOW6432Node` anyway. Asking for HKCU is a bug, so this refuses it outright. +import { spawnSync } from "node:child_process"; + +/** One `reg.exe query` value row. */ +export interface RegValue { + readonly name: string; + /** `REG_SZ`, `REG_DWORD`, … */ + readonly type: string; + readonly data: string; +} + +const HKLM = "HKLM\\"; + +/** Is this a key path this module will touch? See the module docs on why HKLM only. */ +export const validRegKey = (key: string): boolean => + key.startsWith(HKLM) && + key.length > HKLM.length && + key.length <= 260 && + !key.includes("..") && + // `reg.exe` takes the key as one argv element (no shell), but keep the charset tame anyway so a + // malformed key can never turn into a switch. + !key.startsWith("/") && + !/[\r\n\0"]/.test(key); + +const run = (args: string[]): string | undefined => { + if (process.platform !== "win32") return undefined; + const r = spawnSync("reg.exe", args, { + encoding: "utf8", + windowsHide: true, + // A registry read is instant; a hang means something is badly wrong and a scan must not + // block on it forever. + timeout: 10_000, + maxBuffer: 4 * 1024 * 1024, + }); + if (r.status !== 0 || typeof r.stdout !== "string") return undefined; + return r.stdout; +}; + +/** + * The values directly under one HKLM key. `[]` when the key is absent, unreadable, or this is not + * Windows — a missing launcher is the normal case, never an error. + */ +export const regQueryValues = (key: string): RegValue[] => { + if (!validRegKey(key)) return []; + const out = run(["query", key]); + if (out === undefined) return []; + return parseRegQuery(out); +}; + +/** One named value under an HKLM key, or `undefined`. */ +export const regQueryValue = (key: string, name: string): string | undefined => + regQueryValues(key).find((v) => v.name.toLowerCase() === name.toLowerCase()) + ?.data; + +/** The immediate SUBKEY paths under one HKLM key (GOG lists one subkey per installed game). */ +export const regSubKeys = (key: string): string[] => { + if (!validRegKey(key)) return []; + const out = run(["query", key]); + if (out === undefined) return []; + const prefix = `${key.toLowerCase()}\\`; + return out + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.toLowerCase().startsWith(prefix)) + .filter((l) => !l.slice(key.length + 1).includes("\\")); +}; + +/** + * Parse `reg.exe query` output rows: ` `, separated by runs of + * whitespace. Data may itself contain spaces (a path), so only the first two columns are split off. + * + * Exported for tests — the format is stable but this is exactly the kind of thing that quietly + * breaks, and a plugin's tests can pin it without a Windows box. + */ +export const parseRegQuery = (stdout: string): RegValue[] => { + const out: RegValue[] = []; + for (const raw of stdout.split(/\r?\n/)) { + // Value rows are indented; the key path header is not. + if (!/^\s/.test(raw)) continue; + const line = raw.trim(); + if (line === "") continue; + const m = line.match(/^(.*?)\s{2,}(REG_[A-Z_]+)\s{2,}([\s\S]*)$/); + if (!m) continue; + out.push({ name: m[1], type: m[2], data: m[3] }); + } + return out; +}; diff --git a/plugin-kit/src/library/parsers/shortcuts.ts b/plugin-kit/src/library/parsers/shortcuts.ts new file mode 100644 index 00000000..b1348952 --- /dev/null +++ b/plugin-kit/src/library/parsers/shortcuts.ts @@ -0,0 +1,160 @@ +// Steam's BINARY `shortcuts.vdf` — the user's "Add a Non-Steam Game to My Library" entries. +// +// Ported from the host's in-tree scanner (crates/punktfunk-host/src/library/steam.rs), together +// with its unit tests, which are the real specification here: the format is undocumented, and the +// two id derivations below (`shortcutAppId`, `shortcutGameId`) are the difference between a +// shortcut that launches and one that silently does nothing. +// +// Format: a 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32, `0x07` uint64), a +// NUL-terminated key, then a type-specific payload; `0x08` closes the current map. The whole file is +// one `shortcuts` map whose children (keyed "0", "1", …) are the individual shortcuts. +// +// Lenient and total by design: a truncated file or an unrecognized tag stops the walk and returns +// whatever parsed so far. A user's shortcuts file is not something to be strict about. + +export interface Shortcut { + /** The 32-bit shortcut appid — always high-bit set. Keys the entry id and its `grid/` art. */ + readonly appid: number; + readonly name: string; + /** The shortcut's target, as Steam stores it (quoted, possibly with trailing arguments). */ + readonly exe: string; + readonly hidden: boolean; +} + +/** A cursor over the buffer — the ported code's `pos` threaded explicitly. */ +interface Cursor { + pos: number; +} + +/** Read a NUL-terminated UTF-8 string, advancing past the terminator. `undefined` if unterminated. */ +const readCStr = (buf: Uint8Array, c: Cursor): string | undefined => { + const start = c.pos; + let end = start; + while (end < buf.length && buf[end] !== 0) end++; + if (end >= buf.length) return undefined; + const s = new TextDecoder("utf-8").decode(buf.subarray(start, end)); + c.pos = end + 1; + return s; +}; + +/** Read a little-endian int32, advancing 4 bytes. `undefined` if fewer than 4 remain. */ +const readI32 = (buf: Uint8Array, c: Cursor): number | undefined => { + if (c.pos + 4 > buf.length) return undefined; + const v = new DataView(buf.buffer, buf.byteOffset + c.pos, 4).getInt32(0, true); + c.pos += 4; + return v; +}; + +/** Skip a nested map's contents (positioned just after its key) up to and including its `0x08`. */ +const skipMap = (buf: Uint8Array, c: Cursor): boolean => { + for (;;) { + if (c.pos >= buf.length) return false; + const tag = buf[c.pos]; + c.pos += 1; + if (tag === 0x08) return true; + if (readCStr(buf, c) === undefined) return false; + if (tag === 0x00) { + if (!skipMap(buf, c)) return false; + } else if (tag === 0x01) { + if (readCStr(buf, c) === undefined) return false; + } else if (tag === 0x02) { + c.pos += 4; + } else if (tag === 0x07) { + c.pos += 8; + } else { + return false; + } + } +}; + +/** Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`. */ +const parseOne = (buf: Uint8Array, c: Cursor): Shortcut | undefined => { + let appid: number | undefined; + let name = ""; + let exe = ""; + let hidden = false; + for (;;) { + if (c.pos >= buf.length) return undefined; + const tag = buf[c.pos]; + c.pos += 1; + if (tag === 0x08) break; + const key = readCStr(buf, c)?.toLowerCase(); + if (key === undefined) return undefined; + if (tag === 0x00) { + if (!skipMap(buf, c)) return undefined; // nested map (e.g. `tags`) — not needed + } else if (tag === 0x01) { + const val = readCStr(buf, c); + if (val === undefined) return undefined; + if (key === "appname") name = val; + else if (key === "exe") exe = val; + } else if (tag === 0x02) { + const val = readI32(buf, c); + if (val === undefined) return undefined; + if (key === "appid") appid = val >>> 0; + else if (key === "ishidden") hidden = val !== 0; + } else if (tag === 0x07) { + c.pos += 8; // uint64 — skip + } else { + return undefined; // unknown tag: payload size unknown, can't continue safely + } + } + if (name.trim() === "") return undefined; // nothing worth showing + // Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing). + const id = appid && appid !== 0 ? appid : shortcutAppId(exe, name); + return { appid: id, name, exe, hidden }; +}; + +/** Parse a binary `shortcuts.vdf` into its shortcuts. Never throws. */ +export const parseShortcuts = (buf: Uint8Array): Shortcut[] => { + const out: Shortcut[] = []; + const c: Cursor = { pos: 0 }; + // Enter the top-level map (`<0x00> "shortcuts" `); tolerate any key name. + if (buf[0] !== 0x00) return out; + c.pos = 1; + if (readCStr(buf, c) === undefined) return out; + while (c.pos < buf.length) { + const tag = buf[c.pos]; + c.pos += 1; + if (tag !== 0x00) break; // `0x08` (end of shortcuts) or anything unexpected + if (readCStr(buf, c) === undefined) break; // the index key ("0", "1", …) + const sc = parseOne(buf, c); + if (!sc) break; + out.push(sc); + } + return out; +}; + +/** Standard reflected (IEEE) CRC-32 — what Steam hashes a shortcut's `exe + name` with. */ +export const crc32 = (data: Uint8Array): number => { + let crc = 0xffff_ffff; + for (const byte of data) { + crc ^= byte; + for (let i = 0; i < 8; i++) { + const mask = -(crc & 1); + crc = (crc >>> 1) ^ (0xedb8_8320 & mask); + } + } + return (~crc) >>> 0; +}; + +/** + * The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the + * high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern + * Steam writes it and the stored value is preferred. + * + * The high bit is load-bearing downstream: it is how a shortcut is told apart from a real store + * appid, which is what makes the CDN art fetch skippable for shortcuts (they only ever have `grid/` + * overrides). + */ +export const shortcutAppId = (exe: string, name: string): number => + (crc32(new TextEncoder().encode(exe + name)) | 0x8000_0000) >>> 0; + +/** + * The 64-bit game id `steam://rungameid/` needs in order to launch a non-Steam shortcut: high dword + * = the 32-bit shortcut appid, low dword = the shortcut marker `0x02000000`. + * + * Handing `rungameid` the bare 32-bit appid does NOT launch a shortcut — it must be this composed + * id. Returned as a decimal string because it exceeds 2^53 and would lose precision as a `number`. + */ +export const shortcutGameId = (appid: number): string => + ((BigInt(appid >>> 0) << 32n) | 0x0200_0000n).toString(); diff --git a/plugin-kit/src/library/parsers/sqlite.ts b/plugin-kit/src/library/parsers/sqlite.ts new file mode 100644 index 00000000..6831796b --- /dev/null +++ b/plugin-kit/src/library/parsers/sqlite.ts @@ -0,0 +1,68 @@ +// Read-only SQLite over `bun:sqlite` — for launcher databases a plugin must never disturb. +// +// Lutris' `pga.db` is the motivating case: it belongs to a running application, and a scanner that +// opened it read-write could take a write lock, create `-wal`/`-shm` sidecars next to it, or (worst +// case) be blamed for a corrupted library. `immutable=1` promises the file will not change while +// open, which makes Bun skip locking entirely — the strictest possible "look, don't touch". +import { Database } from "bun:sqlite"; +import { isFile } from "./fs.js"; + +export interface ReadOnlyDb { + /** Run a query and return its rows. Returns `[]` rather than throwing on a bad query. */ + readonly query: >( + sql: string, + ...params: unknown[] + ) => T[]; + readonly close: () => void; +} + +/** + * Open a launcher database read-only and immutably. `undefined` if the file is absent or not a + * database — the normal "this launcher isn't installed" case, not an error. + * + * Always `close()` when done (or use {@link withReadOnlyDb}, which does it for you). + */ +export const openReadOnly = (file: string): ReadOnlyDb | undefined => { + if (!isFile(file)) return undefined; + let db: Database; + try { + // `readonly` alone still takes locks and can spawn WAL sidecars; `immutable=1` is what makes + // this a pure read. It is safe here precisely because a scan is a point-in-time snapshot — + // if the launcher writes mid-scan we simply pick it up on the next sync. + db = new Database(`file:${encodeURI(file)}?immutable=1`, { readonly: true }); + } catch { + return undefined; + } + return { + query: >(sql: string, ...params: unknown[]) => { + try { + return db.query(sql).all(...(params as never[])) as T[]; + } catch { + // A schema drift (a renamed column in a launcher upgrade) must degrade to "no + // titles from this source", never take the whole plugin down. + return [] as T[]; + } + }, + close: () => { + try { + db.close(); + } catch { + /* already closed */ + } + }, + }; +}; + +/** Open, use, and always close. Returns `undefined` when the database isn't there. */ +export const withReadOnlyDb = ( + file: string, + use: (db: ReadOnlyDb) => T, +): T | undefined => { + const db = openReadOnly(file); + if (!db) return undefined; + try { + return use(db); + } finally { + db.close(); + } +}; diff --git a/plugin-kit/src/library/parsers/steam-root.ts b/plugin-kit/src/library/parsers/steam-root.ts new file mode 100644 index 00000000..02865816 --- /dev/null +++ b/plugin-kit/src/library/parsers/steam-root.ts @@ -0,0 +1,104 @@ +// Where Steam lives on this host, and which `steamapps` dirs hold installed titles. +// +// Ported from the host scanner (steam.rs `steam_roots` / `steam_library_dirs`) with one deliberate +// addition and one deliberate exclusion, both about the Windows runner's account: +// +// * ADDED: HKLM `WOW6432Node\Valve\Steam\InstallPath`, so a non-default Steam install dir is +// found. The host scanner never covered this (it relied on an explorer.exe protocol fallback at +// launch time), but a plugin that can't find the root finds no games at all. +// * EXCLUDED: HKCU `Software\Valve\Steam`. The runner is LocalService, whose HKCU is its own empty +// hive, not the operator's — reading it would look like "Steam isn't installed". +import * as os from "node:os"; +import * as path from "node:path"; +import { isDir, listDir, readTextCapped } from "./fs.js"; +import { regQueryValue } from "./registry.js"; +import { vdfPaths } from "./vdf.js"; + +/** Canonicalize-ish: resolve and drop a trailing separator so dedup is reliable. */ +const norm = (p: string): string => path.resolve(p); + +/** + * Candidate Steam roots that actually exist (have a `steamapps` dir), deduped. + * + * A "root" is the Steam install itself — `userdata/`, `appcache/` and the first `steamapps/` live + * under it. Extra library folders on other drives are NOT roots; see {@link steamLibraryDirs}. + */ +export const steamRoots = (): string[] => { + const candidates: string[] = []; + if (process.platform === "win32") { + for (const v of ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"]) { + const pf = process.env[v]; + if (pf) candidates.push(path.join(pf, "Steam")); + } + // The registry install path — covers a Steam installed somewhere other than Program Files. + for (const key of [ + "HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam", + "HKLM\\SOFTWARE\\Valve\\Steam", + ]) { + const p = regQueryValue(key, "InstallPath"); + if (p) candidates.push(p); + } + } else { + const home = os.homedir(); + if (home) { + candidates.push( + path.join(home, ".local/share/Steam"), + path.join(home, ".steam/steam"), + path.join(home, ".steam/root"), + // Flatpak Steam + path.join(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam"), + ); + } + } + const seen = new Set(); + const roots: string[] = []; + for (const c of candidates) { + const n = norm(c); + if (!seen.has(n) && isDir(path.join(n, "steamapps"))) { + seen.add(n); + roots.push(n); + } + } + return roots; +}; + +/** + * Every `steamapps` dir holding installed titles: each root's own, plus the extra library folders + * listed in its `libraryfolders.vdf` (Steam installs to other drives). + */ +export const steamLibraryDirs = (roots = steamRoots()): string[] => { + const seen = new Set(); + const dirs: string[] = []; + const push = (p: string) => { + const n = norm(p); + if (!seen.has(n) && isDir(n)) { + seen.add(n); + dirs.push(n); + } + }; + for (const root of roots) { + const steamapps = path.join(root, "steamapps"); + const text = readTextCapped(path.join(steamapps, "libraryfolders.vdf")); + if (text !== undefined) { + for (const p of vdfPaths(text)) push(path.join(p, "steamapps")); + } + push(steamapps); + } + return dirs; +}; + +/** + * Every `userdata//config` dir across all roots — one per Steam account that has signed + * in on this host. `shortcuts.vdf` and the `grid/` art overrides live here. + */ +export const steamUserConfigDirs = (roots = steamRoots()): string[] => { + const out: string[] = []; + for (const root of roots) { + const userdata = path.join(root, "userdata"); + for (const acct of listDir(userdata)) { + const cfg = path.join(userdata, acct, "config"); + if (isDir(cfg)) out.push(cfg); + } + } + return out; +}; diff --git a/plugin-kit/src/library/parsers/vdf.ts b/plugin-kit/src/library/parsers/vdf.ts new file mode 100644 index 00000000..370ddeae --- /dev/null +++ b/plugin-kit/src/library/parsers/vdf.ts @@ -0,0 +1,80 @@ +// Valve Data Format (text) — the flat-field reader Steam's `libraryfolders.vdf` and +// `appmanifest_.acf` need, ported from the host's in-tree scanner +// (crates/punktfunk-host/src/library/steam.rs `vdf_value` / `vdf_paths` / `scan_manifests`). +// +// Deliberately NOT a full VDF parser. Every field these files expose that a library plugin cares +// about sits on one line as `"key" "value"`, and a real parser would be a much larger surface to +// keep correct against a format Valve changes without notice. If you need nested values, read the +// file yourself — this is the 90% case, kept small enough to be obviously right. + +/** `"" ""` on a single line → ``. Whitespace between the two is arbitrary. */ +export const vdfValue = (line: string, key: string): string | undefined => { + const rest = line.trimStart(); + const prefix = `"${key}"`; + if (!rest.startsWith(prefix)) return undefined; + const after = rest.slice(prefix.length); + const open = after.indexOf('"'); + if (open === -1) return undefined; + const value = after.slice(open + 1); + const close = value.indexOf('"'); + if (close === -1) return undefined; + return value.slice(0, close); +}; + +/** The first `"" ""` anywhere in a multi-line document. */ +export const vdfField = (text: string, key: string): string | undefined => { + for (const line of text.split("\n")) { + const v = vdfValue(line, key); + if (v !== undefined) return v; + } + return undefined; +}; + +/** + * Every `"path" ""` value in a `libraryfolders.vdf` — the extra drives Steam installs to. + * + * On Windows the values are backslash-escaped (`D:\\SteamLibrary`), so `\\` collapses to `\`. POSIX + * paths need no unescaping, and the collapse is harmless there (a literal `\\` in a Linux path is + * vanishingly rare and was already ambiguous). + */ +export const vdfPaths = (text: string): string[] => + text + .split("\n") + .map((l) => vdfValue(l, "path")) + .filter((p): p is string => p !== undefined) + .map((p) => p.replaceAll("\\\\", "\\")); + +/** One installed title as described by its `appmanifest_.acf`. */ +export interface AppManifest { + readonly appid: number; + readonly name: string; + /** The bare folder name under this library's `common/` — resolve it yourself. */ + readonly installdir?: string; +} + +/** Parse an `.acf` manifest's flat fields. `undefined` when it carries no usable appid+name. */ +export const parseAppManifest = (text: string): AppManifest | undefined => { + const appid = Number(vdfField(text, "appid")); + const name = vdfField(text, "name"); + if (!Number.isInteger(appid) || appid <= 0 || !name) return undefined; + const installdir = vdfField(text, "installdir"); + return installdir ? { appid, name, installdir } : { appid, name }; +}; + +/** + * Steam installs runtimes and redistributables as "apps" too. A *game* library must not list them. + * Ported verbatim from the host scanner so an extracted steam plugin filters identically — the + * parity harness compares entry sets, and a stray Proton row would fail it. + */ +export const isSteamTool = (appid: number, name: string): boolean => { + // Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier). + const TOOL_IDS = [228980, 1070560, 1391110, 1628350, 1493710]; + if (TOOL_IDS.includes(appid)) return true; + const n = name.toLowerCase(); + return ( + n.includes("proton") || + n.startsWith("steam linux runtime") || + n.includes("steamworks common") || + n.includes("steamvr") + ); +}; diff --git a/plugin-kit/src/ui-server.ts b/plugin-kit/src/ui-server.ts index e30a223e..ca12e9b1 100644 --- a/plugin-kit/src/ui-server.ts +++ b/plugin-kit/src/ui-server.ts @@ -3,8 +3,9 @@ // register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike: // core-only env layers, no platform package, SPA fallthrough preserved. import { type PluginUiHandle, servePluginUi } from "@punktfunk/host"; -import { Effect, FileSystem, Layer, Path, Scope } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema, Scope } from "effect"; import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http"; +import type { ConfigService } from "./config.js"; import { UiServeError } from "./errors.js"; import { HostClient, PluginInfo } from "./host-client.js"; @@ -17,6 +18,100 @@ export const httpApiEnv = Layer.provideMerge( FileSystem.layerNoop({}), ); +/** + * Derive a JSON Schema for a config schema, for the console's generic settings form. + * + * Returns `null` when derivation isn't possible, which the console reads as "render the raw JSON + * editor instead" — the fallback that bounds this whole feature's risk. + * + * Authoring rules, verified against effect 4.0.0-beta.99 and pinned by + * `test/library-config.test.ts` — if an effect upgrade changes any of them, that test fails: + * + * * Use `Schema.Finite` / `Schema.Int`, **never `Schema.Number`** — Number's *encoded* form admits + * the strings `"NaN"`/`"Infinity"`/`"-Infinity"`, so it derives a four-way `anyOf` that no sane + * form can render as a number input. + * * A decoding default is an **Effect**: `withDecodingDefaultKey(Effect.succeed(true), …)`. Passing + * a bare thunk (`() => true`) still derives a schema and still type-checks, then dies at DECODE + * time with "Not a valid effect" — deriving is not evidence that the schema works. + * * Annotate every field: `.annotate({ title, description, default })`. The derivation does NOT + * infer `default` from `withDecodingDefaultKey`, so an un-annotated field shows no placeholder. + * * A *checked* schema (`Schema.Int`, or anything with `.check(...)`) nests its annotations and + * constraints under `allOf`, so a form must merge those branches, not read only the top level. + * * `Schema.Literals([...])` derives a clean `enum` — prefer it over a union of strings. A union of + * non-literals derives an `anyOf`, which is the JSON-editor fallback case. + * * Fields carrying `withDecodingDefaultKey(..., { encodingStrategy: "omit" })` correctly drop out + * of `required`, which is what keeps the raw file free of baked-in defaults. + */ +export const deriveConfigJsonSchema = ( + schema: Schema.Top, +): Record | null => { + try { + const doc = Schema.toJsonSchemaDocument(schema as never); + return doc as unknown as Record; + } catch { + // A schema shape the derivation can't express (a transform, a recursive ref). The console + // falls back to the JSON editor; the PUT still validates by decode, so nothing is lost but + // the pretty form. + return null; + } +}; + +/** The plugin config surface the console's settings drawer drives. */ +export interface ServeUiConfig { + /** The schema the raw file is validated against, and the form is derived from. */ + readonly schema: S; + /** The config service (from `makeConfigService`) holding the raw round-trip semantics. */ + readonly service: ConfigService; +} + +/** + * The `/__config` request handler, split out so it can be driven directly in tests (the wire shape + * is the contract the console's settings drawer codes against — it deserves a real round-trip test, + * not a mock). + * + * `ConfigService`'s effects are context-free by construction (the `PluginInfo` was resolved when the + * service was built), so this runs them straight from a plain async handler. + */ +export const makeConfigHandler = ( + cfg: ServeUiConfig, +): ((req: Request) => Promise) => { + // The derivation is stable for the life of the process — do it once, not per request. + const schema = deriveConfigJsonSchema(cfg.schema); + return async (req: Request): Promise => { + if (req.method === "GET") { + // A config file that fails to decode must not blank the whole drawer — answer with a + // null value so the operator can still see (and replace) what is on disk. + const value = await Effect.runPromise(cfg.service.loadRaw).catch( + () => null, + ); + return Response.json({ schema, value }); + } + if (req.method === "PUT") { + let body: unknown; + try { + body = await req.json(); + } catch (cause) { + return Response.json( + { error: "body must be JSON", issue: String(cause) }, + { status: 400 }, + ); + } + try { + // Validate-by-decode, persist RAW: `saveRaw` refuses a body the schema rejects and + // never writes decoded defaults back into the operator's file. + await Effect.runPromise(cfg.service.saveRaw(body)); + return Response.json({ ok: true }); + } catch (cause) { + return Response.json( + { error: "config rejected", issue: String(cause) }, + { status: 400 }, + ); + } + } + return new Response("method not allowed", { status: 405 }); + }; +}; + export interface ServeUiOptions { /** Console nav title. */ readonly title: string; @@ -26,12 +121,33 @@ export interface ServeUiOptions { readonly version?: string; /** Built SPA directory (served with SPA fallback by the SDK). */ readonly staticDir?: string | URL; + /** + * What kind of plugin this is (`[a-z][a-z0-9-]{0,31}`). `"library"` keeps the plugin out of the + * console nav — its entry point is the Library section's Game sources surface instead. + */ + readonly category?: string; + /** + * Serve `GET`/`PUT /__config` for the console's **generic settings form**, so a plugin with + * settings does not need to ship an SPA at all. + * + * `GET` answers `{schema, value}` — the derived JSON Schema (or `null`) and the raw, + * operator-authored config. `PUT` validates by decoding the body against the schema and, only + * then, persists it **raw**; defaults are never baked into the file. A rejected body comes back + * 400 with the decode issue. + * + * Auth is the existing per-boot UI secret — the console reaches this through its session-gated + * `/plugin-ui//…` proxy, so there is no new host surface and nothing new exposed to the LAN. + */ + readonly config?: ServeUiConfig; /** * The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes * (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided * here — only `HttpRouter` may remain open. + * + * Optional: a plugin whose only surface is `__config` (every library scanner) serves no API of + * its own, and omitting this leaves an empty router that 404s under `apiPrefix`. */ - readonly api: Layer.Layer; + readonly api?: Layer.Layer; /** Path prefix owned by the API handler (default "/api/"). */ readonly apiPrefix?: string; } @@ -54,14 +170,22 @@ export const serveUi = ( const prefix = opts.apiPrefix ?? "/api/"; const { handler, dispose } = HttpRouter.toWebHandler( - Layer.provide(opts.api, httpApiEnv), + Layer.provide(opts.api ?? Layer.empty, httpApiEnv), ); yield* Effect.addFinalizer(() => Effect.promise(() => dispose()).pipe(Effect.ignore), ); + const serveConfig = opts.config ? makeConfigHandler(opts.config) : undefined; + const fetch = async (req: Request): Promise => { const url = new URL(req.url); + // `__`-prefixed paths are the kit/SDK's own contract surface (`__health` lives in the + // SDK), deliberately checked BEFORE the API prefix and before any static asset so a + // plugin's own routes can never shadow them. + if (url.pathname === "/__config") { + return serveConfig?.(req) ?? new Response("not found", { status: 404 }); + } if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA return handler(req); }; @@ -79,6 +203,9 @@ export const serveUi = ( ...(opts.staticDir !== undefined ? { staticDir: opts.staticDir } : {}), + ...(opts.category !== undefined + ? { category: opts.category } + : {}), fetch, }), catch: (cause) => new UiServeError({ cause }), diff --git a/plugin-kit/test/library-config.test.ts b/plugin-kit/test/library-config.test.ts new file mode 100644 index 00000000..9c398bf0 --- /dev/null +++ b/plugin-kit/test/library-config.test.ts @@ -0,0 +1,240 @@ +// The `__config` contract — the wire shape the console's generic settings drawer codes against, +// plus the JSON-Schema derivation's committed fixture (design M0/S2). +// +// The derivation fixture is not decoration: it is the record of WHICH schema shapes the generic +// form can render. If an effect upgrade changes any of it, this test fails and the console's form +// needs re-checking before the change ships — far cheaper than discovering it on a user's box. +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Effect, Layer, Schema } from "effect"; +import { makeConfigService } from "../src/config.js"; +import { pluginInfoLayer } from "../src/host-client.js"; +import { deriveConfigJsonSchema, makeConfigHandler } from "../src/ui-server.js"; + +/** A representative scanner config: booleans, a string, a string array, a nested object, an enum. */ +const ScannerConfig = Schema.Struct({ + enabled: Schema.Boolean.annotate({ + title: "Enable scanning", + description: "Whether this source contributes titles.", + default: true, + }).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(true), { + encodingStrategy: "omit", + }), + ), + root: Schema.optionalKey( + Schema.String.annotate({ title: "Launcher root", description: "Absolute path." }), + ), + extraRoots: Schema.Array(Schema.String) + .annotate({ title: "Extra roots" }) + .pipe( + Schema.withDecodingDefaultKey( + Effect.succeed([] as ReadonlyArray), + { encodingStrategy: "omit" }, + ), + ), + launchers: Schema.Struct({ + bigpicture: Schema.Boolean.annotate({ title: "Big Picture", default: true }), + desktop: Schema.Boolean.annotate({ title: "Desktop", default: false }), + }).pipe( + Schema.withDecodingDefaultKey( + Effect.succeed({ bigpicture: true, desktop: false }), + { encodingStrategy: "omit" }, + ), + ), + pollMinutes: Schema.Int.annotate({ + title: "Poll interval (minutes)", + default: 15, + }).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(15), { + encodingStrategy: "omit", + }), + ), + artSource: Schema.Literals(["local", "cdn", "both"]) + .annotate({ title: "Art source", default: "both" }) + .pipe( + Schema.withDecodingDefaultKey(Effect.succeed("both" as const), { + encodingStrategy: "omit", + }), + ), +}); + +const props = (): Record> => { + const doc = deriveConfigJsonSchema(ScannerConfig) as { + schema: { properties: Record> }; + }; + return doc.schema.properties; +}; + +describe("S2 — JSON Schema derivation for __config", () => { + test("derives a renderable form for every shape a scanner config uses", () => { + const p = props(); + expect(p.enabled).toMatchObject({ type: "boolean" }); + expect(p.root).toMatchObject({ type: "string" }); + expect(p.extraRoots).toMatchObject({ + type: "array", + items: { type: "string" }, + }); + // A nested object stays nested — the form renders a fieldset, not a JSON blob. + expect(p.launchers).toMatchObject({ + type: "object", + properties: { bigpicture: { type: "boolean" }, desktop: { type: "boolean" } }, + }); + // A literal union derives a clean enum — prefer it over a union of strings. + expect(p.artSource).toMatchObject({ + type: "string", + enum: ["local", "cdn", "both"], + }); + }); + + test("annotations pass through — they are the ONLY source of labels and defaults", () => { + const p = props(); + expect(p.enabled.title).toBe("Enable scanning"); + expect(p.enabled.description).toBe("Whether this source contributes titles."); + // The derivation does NOT infer `default` from withDecodingDefaultKey, so an un-annotated + // field shows the form no placeholder at all. Annotate every field. + expect(p.enabled.default).toBe(true); + expect(p.artSource.default).toBe("both"); + // A CHECKED schema (Int is String-plus-a-check) nests its annotations under `allOf`, so a + // form reading `default` must merge allOf branches rather than only looking at the top level. + expect(p.pollMinutes.allOf).toEqual([ + { default: 15, title: "Poll interval (minutes)" }, + ]); + }); + + test("a decoding default is an Effect, not a thunk — and it actually applies", () => { + // The trap this pins: `withDecodingDefaultKey` takes an `Effect`, and passing a bare thunk + // (`() => true`) type-checks against the derivation path but blows up at DECODE time with + // "Not a valid effect". Deriving a schema is therefore NOT evidence that it works. + expect(Schema.decodeUnknownSync(ScannerConfig)({})).toMatchObject({ + enabled: true, + pollMinutes: 15, + artSource: "both", + launchers: { bigpicture: true, desktop: false }, + }); + }); + + test("Schema.Int derives a plain integer — Schema.Number does NOT", () => { + expect(props().pollMinutes).toMatchObject({ type: "integer" }); + // The trap, pinned: Schema.Number's ENCODED form admits "NaN"/"Infinity"/"-Infinity", so it + // derives a four-way anyOf that no number input can render. Use Finite or Int. + const bad = deriveConfigJsonSchema( + Schema.Struct({ n: Schema.Number }), + ) as { schema: { properties: { n: { anyOf?: unknown[] } } } }; + expect(Array.isArray(bad.schema.properties.n.anyOf)).toBe(true); + const ok = deriveConfigJsonSchema( + Schema.Struct({ n: Schema.Finite }), + ) as { schema: { properties: { n: { type?: string } } } }; + expect(ok.schema.properties.n.type).toBe("number"); + }); + + test("defaulted fields drop out of `required` — the raw file stays default-free", () => { + const doc = deriveConfigJsonSchema(ScannerConfig) as { + schema: { required?: string[] }; + }; + // Every field here either has a decoding default or is optionalKey, so nothing is required. + expect(doc.schema.required ?? []).toEqual([]); + }); +}); + +describe("__config wire contract", () => { + const withService = async ( + use: (handler: (req: Request) => Promise, file: string) => Promise, + ): Promise => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-cfg-")); + const prev = process.env.PUNKTFUNK_CONFIG_DIR; + process.env.PUNKTFUNK_CONFIG_DIR = dir; + try { + const service = await Effect.runPromise( + makeConfigService({ schema: ScannerConfig }).pipe( + Effect.provide( + Layer.mergeAll(pluginInfoLayer({ name: "steam", version: "0.1.0" })), + ), + ), + ); + return await use( + makeConfigHandler({ schema: ScannerConfig, service }), + service.path, + ); + } finally { + if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR; + else process.env.PUNKTFUNK_CONFIG_DIR = prev; + fs.rmSync(dir, { recursive: true, force: true }); + } + }; + + test("GET answers {schema, value} with an absent file reading as empty", async () => { + await withService(async (handler) => { + const res = await handler(new Request("http://x/__config")); + expect(res.status).toBe(200); + const body = (await res.json()) as { schema: unknown; value: unknown }; + // Both keys are ALWAYS present and never `undefined` — the console decodes this shape, + // and an omitted-vs-null field is the wire trap that bit the rom-manager 0.3.1 release. + expect(body).toHaveProperty("schema"); + expect(body).toHaveProperty("value"); + expect(body.schema).not.toBeNull(); + // A missing config file is an EMPTY config, not an error. + expect(body.value).toEqual({}); + }); + }); + + test("PUT validates by decode, persists RAW, and never bakes in defaults", async () => { + await withService(async (handler, file) => { + const res = await handler( + new Request("http://x/__config", { + method: "PUT", + body: JSON.stringify({ enabled: false }), + }), + ); + expect(res.status).toBe(200); + // The file holds exactly what was authored — the five defaulted fields are NOT written, + // which is what keeps a future change to a default from being silently pinned. + expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({ + enabled: false, + }); + const get = (await ( + await handler(new Request("http://x/__config")) + ).json()) as { value: unknown }; + expect(get.value).toEqual({ enabled: false }); + }); + }); + + test("PUT rejects a body the schema refuses, with the issue, and writes nothing", async () => { + await withService(async (handler, file) => { + const res = await handler( + new Request("http://x/__config", { + method: "PUT", + body: JSON.stringify({ enabled: "yes please" }), + }), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string; issue: string }; + expect(body.error).toBe("config rejected"); + expect(body.issue.length).toBeGreaterThan(0); + expect(fs.existsSync(file)).toBe(false); + }); + }); + + test("PUT rejects a non-JSON body", async () => { + await withService(async (handler) => { + const res = await handler( + new Request("http://x/__config", { method: "PUT", body: "not json" }), + ); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe( + "body must be JSON", + ); + }); + }); + + test("other methods are refused", async () => { + await withService(async (handler) => { + const res = await handler( + new Request("http://x/__config", { method: "DELETE" }), + ); + expect(res.status).toBe(405); + }); + }); +}); diff --git a/plugin-kit/test/library-parsers.test.ts b/plugin-kit/test/library-parsers.test.ts new file mode 100644 index 00000000..8c038cd3 --- /dev/null +++ b/plugin-kit/test/library-parsers.test.ts @@ -0,0 +1,289 @@ +// The parser ports, tested against the SAME cases the host's Rust scanners pin. +// +// These are not "does TypeScript work" tests. The formats here are undocumented and the host's +// versions are the reference implementation; a port that drifts produces a library that looks fine +// and launches nothing. Where a Rust test exists, its assertions are carried over verbatim — the +// per-plugin parity harness (design M5) then checks the whole pipeline against a live host, but +// these catch a drift long before that. +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + confinedJoin, + crc32, + findGridArtFile, + findLocalArtFile, + fileUrl, + gridFilenames, + isSteamTool, + parseAppManifest, + parseRegQuery, + parseShortcuts, + readTextCapped, + shortcutAppId, + shortcutGameId, + steamCdnUrl, + vdfPaths, + vdfValue, +} from "../src/library/parsers/index.js"; + +const tmp = (name: string): string => { + const dir = path.join(os.tmpdir(), `pf-kit-${name}-${process.pid}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +}; + +describe("text VDF / ACF", () => { + test("vdfValue extracts a quoted field", () => { + expect(vdfValue('"path"\t\t"/mnt/games/SteamLibrary"', "path")).toBe( + "/mnt/games/SteamLibrary", + ); + expect(vdfValue('"appid"\t\t"570"', "appid")).toBe("570"); + expect(vdfValue('"name"\t\t"Dota 2"', "name")).toBe("Dota 2"); + // Wrong key → nothing (a prefix match must not leak the neighbouring field). + expect(vdfValue('"installdir"\t\t"x"', "appid")).toBeUndefined(); + }); + + test("vdfPaths pulls every library folder and unescapes Windows separators", () => { + const vdf = ` +"libraryfolders" +{ + "0" + { + "path" "/home/u/.local/share/Steam" + "label" "" + } + "1" + { + "path" "D:\\\\SteamLibrary" + } +}`; + expect(vdfPaths(vdf)).toEqual([ + "/home/u/.local/share/Steam", + "D:\\SteamLibrary", + ]); + }); + + test("parseAppManifest reads the flat fields it needs", () => { + const acf = `"AppState" +{ + "appid" "570" + "name" "Dota 2" + "installdir" "dota 2 beta" +}`; + expect(parseAppManifest(acf)).toEqual({ + appid: 570, + name: "Dota 2", + installdir: "dota 2 beta", + }); + // A manifest missing the essentials is not a title. + expect(parseAppManifest('"AppState" { "name" "x" }')).toBeUndefined(); + }); + + test("isSteamTool keeps runtimes out of a game library", () => { + expect(isSteamTool(228980, "Steamworks Common Redistributables")).toBe(true); + expect(isSteamTool(1628350, "Steam Linux Runtime 3.0 (sniper)")).toBe(true); + expect(isSteamTool(999, "Proton 9.0")).toBe(true); + expect(isSteamTool(999, "SteamVR")).toBe(true); + expect(isSteamTool(570, "Dota 2")).toBe(false); + }); +}); + +describe("binary shortcuts.vdf", () => { + /** Build a binary shortcuts.vdf the way Steam writes one. */ + const buildShortcuts = ( + entries: ReadonlyArray<{ + appid?: number; + appname: string; + exe: string; + hidden?: boolean; + }>, + ): Uint8Array => { + const parts: number[] = []; + const cstr = (s: string) => { + for (const b of new TextEncoder().encode(s)) parts.push(b); + parts.push(0); + }; + const i32 = (v: number) => { + parts.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff); + }; + parts.push(0x00); + cstr("shortcuts"); + entries.forEach((e, i) => { + parts.push(0x00); + cstr(String(i)); + if (e.appid !== undefined) { + parts.push(0x02); + cstr("appid"); + i32(e.appid); + } + parts.push(0x01); + cstr("AppName"); + cstr(e.appname); + parts.push(0x01); + cstr("Exe"); + cstr(e.exe); + parts.push(0x02); + cstr("IsHidden"); + i32(e.hidden ? 1 : 0); + // A nested map the parser must skip wholesale. + parts.push(0x00); + cstr("tags"); + parts.push(0x01); + cstr("0"); + cstr("favourite"); + parts.push(0x08); + parts.push(0x08); // end of this shortcut + }); + parts.push(0x08); // end of shortcuts + parts.push(0x08); // end of document + return new Uint8Array(parts); + }; + + test("parses entries, skips nested maps, and reads the hidden flag", () => { + const buf = buildShortcuts([ + { appid: 2456789012, appname: "My Emulator", exe: '"/usr/bin/foo"' }, + { appid: 3000000000, appname: "Hidden One", exe: '"/x"', hidden: true }, + ]); + const got = parseShortcuts(buf); + expect(got).toHaveLength(2); + expect(got[0]).toMatchObject({ + appid: 2456789012, + name: "My Emulator", + hidden: false, + }); + expect(got[1]).toMatchObject({ name: "Hidden One", hidden: true }); + }); + + test("derives the appid when the file omits it", () => { + const buf = buildShortcuts([{ appname: "No Appid", exe: '"/usr/bin/x"' }]); + const got = parseShortcuts(buf); + expect(got).toHaveLength(1); + // Derived ids always carry the high bit — that is how a shortcut is told apart from a real + // store appid downstream (and why its CDN art fetch is skipped). + expect(got[0].appid & 0x8000_0000).not.toBe(0); + expect(got[0].appid).toBe(shortcutAppId('"/usr/bin/x"', "No Appid")); + }); + + test("is total on a truncated or garbled file", () => { + expect(parseShortcuts(new Uint8Array([]))).toEqual([]); + expect(parseShortcuts(new Uint8Array([0x01, 0x02, 0x03]))).toEqual([]); + const good = buildShortcuts([{ appid: 1, appname: "A", exe: "/a" }]); + // Every truncation of a valid file must return, not throw. + for (let i = 0; i < good.length; i++) { + expect(() => parseShortcuts(good.subarray(0, i))).not.toThrow(); + } + }); + + test("crc32 matches the IEEE check value", () => { + // The canonical CRC-32 check: crc32("123456789") == 0xCBF43926. + expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf4_3926); + }); + + test("shortcutGameId composes the appid and the shortcut marker", () => { + // high dword = appid, low dword = 0x02000000. Handing rungameid the bare 32-bit appid does + // NOT launch a shortcut, which is the entire reason this function exists. + const id = BigInt(shortcutGameId(0x8000_0000)); + expect(id >> 32n).toBe(0x8000_0000n); + expect(id & 0xffff_ffffn).toBe(0x0200_0000n); + // Digits only — it rides the `steam_appid` launch kind, which the host validates as digits. + expect(shortcutGameId(2_456_789_012)).toMatch(/^\d+$/); + }); +}); + +describe("path confinement", () => { + test("confinedJoin refuses anything that could escape the install dir", () => { + const base = path.join(path.sep, "games", "W3"); + expect(confinedJoin(base, "bin/game.exe")).toBe( + path.join(base, "bin", "game.exe"), + ); + expect(confinedJoin(base, "bin\\game.exe")).toBe( + path.join(base, "bin", "game.exe"), + ); + // The three shapes a crafted goggame-*.info would use to point elsewhere. + expect(confinedJoin(base, "../../windows/system32/cmd.exe")).toBeUndefined(); + expect(confinedJoin(base, "/etc/passwd")).toBeUndefined(); + expect(confinedJoin(base, "C:\\Windows\\system32\\cmd.exe")).toBeUndefined(); + expect(confinedJoin(base, "")).toBeUndefined(); + }); +}); + +describe("capped reads", () => { + test("readTextCapped refuses an over-cap file and a missing one", () => { + const dir = tmp("caps"); + const small = path.join(dir, "small.txt"); + fs.writeFileSync(small, "hello"); + expect(readTextCapped(small)).toBe("hello"); + expect(readTextCapped(small, 2)).toBeUndefined(); // over the cap + expect(readTextCapped(path.join(dir, "nope.txt"))).toBeUndefined(); + expect(readTextCapped(dir)).toBeUndefined(); // a directory is not a file + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("art locations", () => { + test("steamCdnUrl skips shortcut appids, which have no CDN entry", () => { + expect(steamCdnUrl(570, "header")).toContain("/570/header.jpg"); + expect(steamCdnUrl(570, "portrait")).toContain("library_600x900.jpg"); + // The local cache names the header asset differently from the CDN — pinned because it is + // the single most common way to get Steam art wrong. + expect(steamCdnUrl(570, "header")).not.toContain("library_header"); + expect(steamCdnUrl(0x8000_0001, "header")).toBeUndefined(); + }); + + test("grid filenames follow Steam's per-kind naming", () => { + expect(gridFilenames(570, "portrait")).toEqual(["570p.png", "570p.jpg"]); + expect(gridFilenames(570, "hero")).toEqual(["570_hero.png", "570_hero.jpg"]); + expect(gridFilenames(570, "logo")).toEqual(["570_logo.png", "570_logo.jpg"]); + expect(gridFilenames(570, "header")).toEqual(["570.png", "570.jpg"]); + }); + + test("finds cached and user-override art on disk", () => { + const dir = tmp("art"); + const hashDir = path.join(dir, "appcache", "librarycache", "570", "abc123"); + fs.mkdirSync(hashDir, { recursive: true }); + fs.writeFileSync(path.join(hashDir, "library_600x900.jpg"), "x"); + expect(findLocalArtFile(dir, 570, "portrait")).toBe( + path.join(hashDir, "library_600x900.jpg"), + ); + expect(findLocalArtFile(dir, 570, "hero")).toBeUndefined(); + + const cfg = path.join(dir, "userdata", "1", "config"); + fs.mkdirSync(path.join(cfg, "grid"), { recursive: true }); + fs.writeFileSync(path.join(cfg, "grid", "570p.jpg"), "x"); + expect(findGridArtFile(cfg, 570, "portrait")).toBe( + path.join(cfg, "grid", "570p.jpg"), + ); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test("fileUrl produces the host's local-art contract shape", () => { + const u = fileUrl(path.join(path.sep, "home", "u", "My Games", "c.jpg")); + expect(u.startsWith("file:///")).toBe(true); + // Spaces are percent-encoded; the separators survive so the host can rebuild the path. + expect(u).toContain("My%20Games"); + expect(u).toContain("/c.jpg"); + }); +}); + +describe("reg.exe output", () => { + test("parses value rows and leaves the key header alone", () => { + const stdout = [ + "", + "HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Valve\\Steam", + " InstallPath REG_SZ C:\\Program Files (x86)\\Steam", + " Language REG_SZ english", + "", + ].join("\r\n"); + expect(parseRegQuery(stdout)).toEqual([ + { + name: "InstallPath", + type: "REG_SZ", + // Data may contain spaces — only the first two columns are split off. + data: "C:\\Program Files (x86)\\Steam", + }, + { name: "Language", type: "REG_SZ", data: "english" }, + ]); + }); +}); From bd383f18202b4297d40a7f7a4c84a0309678b9dc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 10:03:24 +0200 Subject: [PATCH 04/64] feat(web): one Game sources surface, launcher rail, and the migration nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M4 of design/library-scanner-plugins-implementation-plan.md, plus WP6.2. WP4.1 — SourceToggles and ProvidersCard merge into Library/Sources.tsx. They were two cards because they were two different things: scanners were compiled into the host, plugins were an afterthought. After the extraction they are the same thing — the host reports ONE list of sources whose ids match whether they came from a built-in scanner or the plugin replacing it — so one surface is both simpler and the only honest presentation. Each row carries its toggle, a running/stopped badge for plugin sources, an entry count, filter, settings and an uninstall that offers to remove the games too. An "Add a source" rail lists uncatalogued library plugins with a "Detected" badge; `detected` is deliberately tri-state, so only a POSITIVE probe badges — an entry with no probes for this platform is unknown, and calling that "not installed" would be a lie. The settings drawer (SourceSettings.tsx) renders a generic form from the plugin's own JSON Schema over GET/PUT /__config, through the existing session-gated /plugin-ui// proxy — zero new host surface, and the browser never learns the plugin's port or secret. It flattens allOf branches (effect nests a checked schema's annotations there, so a form reading only the top level silently loses every title and default) and falls back to a JSON editor when any field is a shape it cannot express — partial rendering would be worse than none, because a field missing from the form is a setting the operator cannot change. WP4.2 — uiPlugins() now excludes category "library", which covers both the sidebar and the mobile overflow since they share the selector. The /plugins/$pluginId/$ route still resolves, so existing deep links keep working; library plugins are just not advertised. WP4.3 — LibraryGrid groups role:"launcher" entries into a rail above the grid, and the empty state points at the sources surface rather than leaving a bare grid (after extraction, "no games" is the expected first-run state). WP6.2 — a migration banner offering one install per still-built-in scanner whose plugin is catalogued. One button per scanner, never a single "migrate everything" and never a silent auto-install: installing code stays an explicit operator act, and per-scanner is what makes it safe to repeat (the claim suppresses the built-in idempotently, so a half-finished migration is a valid state). WP4.4 — i18n en+de (kept under the existing "Game sources" label rather than minting a third "Plugins"), Storybook stories for the sources card in three states, the launcher rail and the banner. Gates: orval regen, tsc clean, vite build clean, check-i18n green at 595 messages for both locales. Still owed: the browser click-through (the store's Tabs-theme bug shipped through green types and lint), and an AppShell nav story — that one needs the plugins query mocked, which does not exist in this Storybook setup yet. --- web/messages/de.json | 20 +- web/messages/en.json | 20 +- web/src/api/plugins.ts | 26 +- web/src/api/store.ts | 11 + web/src/sections/Library/LibraryGrid.tsx | 66 ++-- web/src/sections/Library/Providers.tsx | 104 ------ web/src/sections/Library/SourceSettings.tsx | 345 +++++++++++++++++++ web/src/sections/Library/SourceToggles.tsx | 85 ----- web/src/sections/Library/Sources.tsx | 361 ++++++++++++++++++++ web/src/sections/Library/index.tsx | 10 +- web/src/stories/Library.stories.tsx | 174 +++++++++- 11 files changed, 991 insertions(+), 231 deletions(-) delete mode 100644 web/src/sections/Library/Providers.tsx create mode 100644 web/src/sections/Library/SourceSettings.tsx delete mode 100644 web/src/sections/Library/SourceToggles.tsx create mode 100644 web/src/sections/Library/Sources.tsx diff --git a/web/messages/de.json b/web/messages/de.json index c1221d92..1b44cb07 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -285,9 +285,25 @@ "library_field_players": "Spieler", "library_details_legend": "Details (optional)", "library_owned_by": "über {provider}", - "library_providers_title": "Von Plugins synchronisiert", - "library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.", "library_provider_count": "{count} Einträge", + "library_launchers_title": "Launcher", + "library_empty_add_source": "Füge unten eine Spielquelle hinzu, damit deine installierten Spiele hier erscheinen.", + "library_add_source": "Quelle hinzufügen", + "library_source_detected": "Erkannt", + "library_source_running": "Läuft", + "library_source_stopped": "Gestoppt", + "library_source_settings": "Einstellungen", + "library_source_settings_title": "Einstellungen für {source}", + "library_source_settings_save": "Einstellungen speichern", + "library_source_settings_saved": "Einstellungen gespeichert.", + "library_source_settings_failed": "Einstellungen konnten nicht gespeichert werden: {issue}", + "library_source_settings_unreachable": "Die Einstellungen dieser Quelle sind nicht erreichbar: {issue}", + "library_source_settings_json_hint": "Die Einstellungen dieser Quelle passen in kein einfaches Formular — bearbeite sie als JSON. Sie werden vor dem Speichern geprüft.", + "library_migrate_title": "Spielquellen werden zu Plugins", + "library_migrate_help": "Jeder Launcher wird ein eigenes Add-on — du installierst nur die, die du nutzt, und jedes bekommt eigene Einstellungen. Installierst du eines, übernimmt es vom eingebauten Scanner; deine Spiele behalten ihre Kacheln. Wenn du nichts tust, ändert sich nichts.", + "library_migrate_install": "Quelle {source} installieren", + "library_source_installing": "{title} wird installiert…", + "library_source_install_failed": "Diese Quelle konnte nicht installiert werden.", "library_provider_filter": "Nur diese zeigen", "library_provider_show_all": "Alle zeigen", "library_provider_purge": "Einträge dieses Anbieters entfernen", diff --git a/web/messages/en.json b/web/messages/en.json index d4e46593..817ff277 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -285,9 +285,25 @@ "library_field_players": "Players", "library_details_legend": "Details (optional)", "library_owned_by": "via {provider}", - "library_providers_title": "Synced by plugins", - "library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.", "library_provider_count": "{count} entries", + "library_launchers_title": "Launchers", + "library_empty_add_source": "Add a game source below to see your installed games here.", + "library_add_source": "Add a source", + "library_source_detected": "Detected", + "library_source_running": "Running", + "library_source_stopped": "Stopped", + "library_source_settings": "Settings", + "library_source_settings_title": "{source} settings", + "library_source_settings_save": "Save settings", + "library_source_settings_saved": "Settings saved.", + "library_source_settings_failed": "Could not save the settings: {issue}", + "library_source_settings_unreachable": "Could not reach this source's settings: {issue}", + "library_source_settings_json_hint": "This source's settings don't fit a simple form, so edit them as JSON. They're checked before saving.", + "library_migrate_title": "Game sources are moving to plugins", + "library_migrate_help": "Each launcher is becoming its own add-on, so you only install the ones you use — and each gets its own settings. Install one and it takes over from the built-in scanner; your games keep the same tiles. Nothing changes if you do nothing yet.", + "library_migrate_install": "Install the {source} source", + "library_source_installing": "Installing {title}…", + "library_source_install_failed": "Could not install this source.", "library_provider_filter": "Show only these", "library_provider_show_all": "Show all", "library_provider_purge": "Remove this provider's entries", diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts index 017ad062..2c113b9b 100644 --- a/web/src/api/plugins.ts +++ b/web/src/api/plugins.ts @@ -29,8 +29,18 @@ export interface PluginSummary { version?: string; /** Present iff the plugin serves a UI (and thus gets a nav entry). */ ui?: PluginUiSummary; + /** + * What kind of plugin this is. The console knows one value — `"library"` — and keeps those OUT + * of the nav: a scanner's entry point is the Library section's Game sources surface, and six + * installed scanners would otherwise flood the sidebar (design D5). Absent on an older host, and + * absent by choice for a plugin that wants its own page anyway (rom-manager). + */ + category?: string; } +/** The one category the console treats specially. */ +export const LIBRARY_CATEGORY = "library"; + // A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat // tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle. const ICONS: Record = { @@ -97,6 +107,18 @@ export function usePlugins() { }); } -/** Only the plugins that surface a UI — the ones that get a nav entry. */ +/** + * The plugins that get a **nav entry**: those serving a UI, minus the library-category ones. + * + * A library plugin still serves a UI port (that is how `__config` is reached) and its + * `/plugins/$pluginId/$` route still resolves, so an existing deep link keeps working — it simply + * isn't advertised in the sidebar. + */ export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] => - (list ?? []).filter((p) => p.ui); + (list ?? []).filter((p) => p.ui && p.category !== LIBRARY_CATEGORY); + +/** The installed library-category plugins — the Game sources surface's own list. */ +export const libraryPlugins = ( + list: PluginSummary[] | undefined, +): PluginSummary[] => + (list ?? []).filter((p) => p.category === LIBRARY_CATEGORY); diff --git a/web/src/api/store.ts b/web/src/api/store.ts index e4a34fb4..77de452a 100644 --- a/web/src/api/store.ts +++ b/web/src/api/store.ts @@ -69,6 +69,17 @@ export interface StoreEntry { installed_version?: string; update_available: boolean; blocked?: string; + /** + * What kind of plugin this is. Browse filters on these, and the Library section's "Add a source" + * rail shows exactly the `library` ones (design D5/D6). Absent on an index that predates them. + */ + categories?: string[]; + /** + * Whether the launcher this plugin scans looks installed on this host, from the index's own + * existence probes (design D8). `undefined` = the entry declares no probes for this platform, + * which is "unknown" and must render differently from "not installed". + */ + detected?: boolean; } export interface StoreCatalog { diff --git a/web/src/sections/Library/LibraryGrid.tsx b/web/src/sections/Library/LibraryGrid.tsx index f51fd6d8..690e54fc 100644 --- a/web/src/sections/Library/LibraryGrid.tsx +++ b/web/src/sections/Library/LibraryGrid.tsx @@ -82,14 +82,42 @@ export const LibraryGrid: FC<{ /** Custom id of the card whose delete is in flight, or null — only that card disables. */ deletingId: string | null; }> = ({ library, onEdit, onDelete, deletingId }) => { - const games = library.data ?? []; + const all = library.data ?? []; + // Launcher entries (design D4) open the launcher itself — Steam Big Picture, Heroic — rather than + // a title. They launch and lease exactly like games; grouping them into their own rail is purely + // so a shelf of 400 games doesn't bury the two or three ways to open a launcher. + const launchers = all.filter((g) => g.role === "launcher"); + const games = all.filter((g) => g.role !== "launcher"); + const card = (game: GameEntry) => ( + onEdit(game)} + onDelete={() => onDelete(game)} + deleting={deletingId === customId(game)} + /> + ); return ( - {games.length === 0 ? ( + {launchers.length > 0 && ( +
+

+ {m.library_launchers_title()} +

+ + {launchers.map(card)} + +
+ )} + {all.length === 0 ? ( {/* `flush`, not a bare `p-8`: the default `sm:pt-0` would survive the override (tailwind-merge only resolves conflicts within a variant) and eat the top @@ -98,27 +126,25 @@ export const LibraryGrid: FC<{ flush className="p-8 text-center text-sm text-muted-foreground" > - {m.library_empty()} + {/* After extraction a fresh host has NO scanners at all, so "no games" is the + expected first-run state rather than a fault. Point at the fix (design D9) + instead of leaving a bare empty grid. */} +

{m.library_empty()}

+

{m.library_empty_add_source()}

) : ( -
- - {games.map((game) => ( - onEdit(game)} - onDelete={() => onDelete(game)} - deleting={deletingId === customId(game)} - /> - ))} - -
+ games.length > 0 && ( +
+ + {games.map(card)} + +
+ ) )}
); diff --git a/web/src/sections/Library/Providers.tsx b/web/src/sections/Library/Providers.tsx deleted file mode 100644 index b00b6c05..00000000 --- a/web/src/sections/Library/Providers.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "@unom/ui/toast"; -import { Trash2 } from "lucide-react"; -import type { FC } from "react"; -import { - getGetLibraryQueryKey, - useDeleteProviderEntries, -} from "@/api/gen/library/library"; -import type { GameEntry } from "@/api/gen/model/gameEntry"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { apiErrorMessage } from "@/lib/errors"; -import { m } from "@/paraglide/messages"; - -/** - * Provider-owned entries: who put them there, and how to get rid of them. - * - * A plugin can sync entries into the library (RFC §8) and they are then refused to hand-edit or - * delete individually — the host answers 409 and points at the provider's own reconcile. Which is - * correct, and completely opaque if the plugin is gone: uninstalling it leaves its games in the - * library with no console-side way to remove them. `DELETE /library/provider/{provider}` is the - * documented clean-uninstall path and nothing called it. - * - * Renders nothing when no entry carries a provider, so an ordinary library sees no extra chrome. - */ -export const ProvidersCard: FC<{ - entries: GameEntry[]; - /** The provider currently filtered to, or null for "everything". */ - active: string | null; - onFilter: (provider: string | null) => void; -}> = ({ entries, active, onFilter }) => { - const qc = useQueryClient(); - const purge = useDeleteProviderEntries(); - - // Count per provider, in first-seen order — the list is small and operator-facing. - const counts = new Map(); - for (const e of entries) { - if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1); - } - if (counts.size === 0) return null; - - const onPurge = async (provider: string, count: number) => { - if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return; - try { - await purge.mutateAsync({ provider }); - // The host emits `library.changed`, but don't wait for the round trip to redraw. - qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }); - if (active === provider) onFilter(null); - toast.success(m.library_provider_purged({ provider })); - } catch (e) { - toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed()); - } - }; - - return ( - - - {m.library_providers_title()} - - -

- {m.library_providers_help()} -

-
- {[...counts.entries()].map(([provider, count]) => ( -
- {provider} - - {m.library_provider_count({ count })} - -
- - -
-
- ))} -
-
-
- ); -}; diff --git a/web/src/sections/Library/SourceSettings.tsx b/web/src/sections/Library/SourceSettings.tsx new file mode 100644 index 00000000..2ff5dd7a --- /dev/null +++ b/web/src/sections/Library/SourceSettings.tsx @@ -0,0 +1,345 @@ +import { toast } from "@unom/ui/toast"; +import { type FC, useEffect, useState } from "react"; +import type { ScannerInfo } from "@/api/gen/model/scannerInfo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { m } from "@/paraglide/messages"; + +/** + * A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema. + * + * The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves + * `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything + * goes through the existing session-gated `/plugin-ui//…` proxy, so there is **zero new host + * surface** — the browser never learns the plugin's port or secret. + * + * Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds + * the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still + * validates by decode host-side either way. + */ +export const SourceSettingsDialog: FC<{ + source: ScannerInfo; + onClose: () => void; +}> = ({ source, onClose }) => { + const pluginId = source.provider ?? source.id; + const [state, setState] = useState< + | { tag: "loading" } + | { tag: "error"; message: string } + | { tag: "ready"; schema: JsonSchemaDoc | null; value: JsonObject } + >({ tag: "loading" }); + const [raw, setRaw] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(`/plugin-ui/${pluginId}/__config`, { + credentials: "same-origin", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as { + schema: JsonSchemaDoc | null; + value: JsonObject | null; + }; + if (cancelled) return; + const value = body.value ?? {}; + setState({ tag: "ready", schema: body.schema, value }); + setRaw(JSON.stringify(value, null, 2)); + } catch (e) { + if (!cancelled) { + setState({ tag: "error", message: String(e) }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [pluginId]); + + const save = async (value: JsonObject) => { + setSaving(true); + try { + const res = await fetch(`/plugin-ui/${pluginId}/__config`, { + method: "PUT", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify(value), + }); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { + issue?: string; + } | null; + throw new Error(body?.issue ?? `HTTP ${res.status}`); + } + toast.success(m.library_source_settings_saved()); + onClose(); + } catch (e) { + toast.error(m.library_source_settings_failed({ issue: String(e) })); + } finally { + setSaving(false); + } + }; + + return ( + !open && onClose()}> + + + + {m.library_source_settings_title({ source: source.label })} + + + {state.tag === "loading" && } + {state.tag === "error" && ( +

+ {m.library_source_settings_unreachable({ issue: state.message })} +

+ )} + {state.tag === "ready" && ( + + )} +
+
+ ); +}; + +type JsonObject = Record; + +interface JsonSchemaNode { + type?: string; + title?: string; + description?: string; + default?: unknown; + enum?: string[]; + properties?: Record; + items?: JsonSchemaNode; + allOf?: JsonSchemaNode[]; +} + +interface JsonSchemaDoc { + schema?: JsonSchemaNode; +} + +/** + * Flatten a node's `allOf` branches into it. A *checked* schema (effect's `Schema.Int`, or anything + * with `.check(...)`) nests its annotations and constraints there rather than at the top level, so + * a form that only reads the top level silently loses every title and default on those fields. + */ +const flatten = (node: JsonSchemaNode): JsonSchemaNode => + (node.allOf ?? []).reduce( + (acc, branch) => ({ ...acc, ...branch }), + { ...node }, + ); + +/** Can this field be rendered as a real input? Anything else sends the whole form to the editor. */ +const renderable = (node: JsonSchemaNode): boolean => { + const n = flatten(node); + if (n.enum) return true; + if (n.type === "boolean" || n.type === "string") return true; + if (n.type === "number" || n.type === "integer") return true; + if (n.type === "array" && flatten(n.items ?? {}).type === "string") return true; + if (n.type === "object" && n.properties) { + return Object.values(n.properties).every(renderable); + } + return false; +}; + +const ConfigForm: FC<{ + schema: JsonSchemaDoc | null; + value: JsonObject; + raw: string; + onRaw: (v: string) => void; + saving: boolean; + onSave: (value: JsonObject) => void; +}> = ({ schema, value, raw, onRaw, saving, onSave }) => { + const [draft, setDraft] = useState(value); + const root = schema?.schema ? flatten(schema.schema) : undefined; + const props = root?.properties; + // Fall back to the JSON editor when there is no schema, or any field is a shape the generic + // form can't express (a non-enum union, a $ref). Partial rendering would be worse than none: + // a field silently missing from the form is a setting the operator cannot change. + const canRender = props !== undefined && Object.values(props).every(renderable); + + if (!canRender) { + return ( +
+

+ {m.library_source_settings_json_hint()} +

+