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>> /**