//! Game library (plan: "surface the user's games"). A small adapter layer over the *stores* //! installed on the host — today **Steam** (read from local files, no API key) and a //! user-curated **custom** store (CRUD'd via the management API / web console). Every store //! produces the same [`GameEntry`], so a client renders one uniform grid and never has to know //! which launcher a title came from. Future stores (Heroic/Epic, GOG, Lutris, EmuDeck) are just //! more [`LibraryProvider`]s. //! //! Artwork is keyed only by Steam appid against the public Steam CDN (no auth) — the client //! fetches the posters directly. Custom entries carry user-supplied art URLs. //! //! This module is read-mostly metadata; *launching* a chosen title (mapping [`LaunchSpec`] onto a //! gamescope session) is a later step — the launch hint is carried here so that wiring is trivial. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use utoipa::ToSchema; /// Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for /// custom). The client prefers `portrait` for a grid and falls back to `header` when a title has /// no 600×900 capsule (common for older Steam apps). #[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)] pub struct Artwork { /// Vertical capsule / poster (Steam `library_600x900.jpg`). Best for a grid. #[serde(skip_serializing_if = "Option::is_none")] pub portrait: Option, /// Wide background (Steam `library_hero.jpg`). #[serde(skip_serializing_if = "Option::is_none")] pub hero: Option, /// Transparent title logo (Steam `logo.png`). #[serde(skip_serializing_if = "Option::is_none")] pub logo: Option, /// Horizontal header (Steam `header.jpg`) — the universal fallback. #[serde(skip_serializing_if = "Option::is_none")] pub header: Option, } /// How the host would launch a title (consumed by the session launcher in a later step). Kept /// open-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/`; /// `command` → run `` nested in a gamescope session. #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct LaunchSpec { /// `"steam_appid"` or `"command"`. #[schema(example = "steam_appid")] pub kind: String, /// The appid (for `steam_appid`) or the shell command (for `command`). pub value: String, } /// One title in the unified library, regardless of which store it came from. #[derive(Clone, Debug, Serialize, ToSchema)] pub struct GameEntry { /// Stable, store-qualified id: `steam:` or `custom:`. #[schema(example = "steam:570")] pub id: String, /// Which store surfaced it: `"steam"` or `"custom"`. #[schema(example = "steam")] pub store: String, pub title: String, pub art: Artwork, /// How the host would launch it, when known. #[serde(skip_serializing_if = "Option::is_none")] pub launch: Option, } /// A store that contributes titles to the library. The trait is the extension point for future /// launchers; today only [`SteamProvider`] implements it. pub trait LibraryProvider { /// Stable store id (`"steam"`, …). fn store(&self) -> &'static str; /// Enumerate installed/owned titles. Best-effort: returns empty (not an error) when the store /// isn't present, so one missing launcher never fails the whole library. fn list(&self) -> Vec; } // --------------------------------------------------------------------------------------- // Steam // --------------------------------------------------------------------------------------- /// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come /// from `steamapps/appmanifest_.acf`; extra library folders from /// `steamapps/libraryfolders.vdf`; artwork from the public Steam CDN by appid. pub struct SteamProvider; impl LibraryProvider for SteamProvider { fn store(&self) -> &'static str { "steam" } fn list(&self) -> Vec { let mut by_appid: std::collections::BTreeMap = Default::default(); for steamapps in steam_library_dirs() { for (appid, name) in scan_manifests(&steamapps) { by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids } } by_appid .into_iter() .filter(|(appid, name)| !is_steam_tool(*appid, name)) .map(|(appid, title)| GameEntry { id: format!("steam:{appid}"), store: "steam".into(), title, art: steam_art(appid), launch: Some(LaunchSpec { kind: "steam_appid".into(), value: appid.to_string(), }), }) .collect() } } /// The Steam CDN poster/hero/logo/header for an appid (public, no auth). Not every appid has a /// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it. fn steam_art(appid: u32) -> Artwork { let base = format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}"); Artwork { portrait: Some(format!("{base}/library_600x900.jpg")), hero: Some(format!("{base}/library_hero.jpg")), logo: Some(format!("{base}/logo.png")), header: Some(format!("{base}/header.jpg")), } } /// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped. #[cfg(not(target_os = "windows"))] fn steam_roots() -> Vec { let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { return Vec::new(); }; let candidates = [ home.join(".local/share/Steam"), home.join(".steam/steam"), home.join(".steam/root"), home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), // Flatpak Steam ]; steam_roots_existing(candidates) } /// Windows Steam roots: the default install dirs under Program Files. Games installed on other /// drives are still found via each root's `libraryfolders.vdf` (see [`steam_library_dirs`]). A /// non-default Steam install dir (registry `Valve\Steam\InstallPath`) isn't covered yet. #[cfg(target_os = "windows")] fn steam_roots() -> Vec { let mut candidates = Vec::new(); for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] { if let Some(pf) = std::env::var_os(var) { candidates.push(PathBuf::from(pf).join("Steam")); } } steam_roots_existing(candidates) } /// Keep only the candidate roots that exist (have a `steamapps` dir), canonicalized + deduped. fn steam_roots_existing(candidates: impl IntoIterator) -> Vec { let mut seen = HashSet::new(); let mut roots = Vec::new(); for c in candidates { if let Ok(canon) = c.canonicalize() { if canon.join("steamapps").is_dir() && seen.insert(canon.clone()) { roots.push(canon); } } } roots } /// Every `steamapps` dir holding installed titles: each root's own, plus the extra library /// folders listed in `libraryfolders.vdf` (Steam lets you install games on other drives). fn steam_library_dirs() -> Vec { let mut seen = HashSet::new(); let mut dirs = Vec::new(); let mut push = |steamapps: PathBuf, dirs: &mut Vec| { if let Ok(canon) = steamapps.canonicalize() { if canon.is_dir() && seen.insert(canon.clone()) { dirs.push(canon); } } }; for root in steam_roots() { let steamapps = root.join("steamapps"); if let Ok(text) = std::fs::read_to_string(steamapps.join("libraryfolders.vdf")) { for path in vdf_paths(&text) { push(PathBuf::from(path).join("steamapps"), &mut dirs); } } push(steamapps, &mut dirs); } dirs } /// Pull every `"path" ""` value out of a `libraryfolders.vdf`. We don't need a full VDF /// parser for the two flat fields we read. On Windows the values are backslash-escaped /// (`D:\\SteamLibrary`), so unescape `\\` → `\`; Linux paths need no unescaping. fn vdf_paths(text: &str) -> Vec { text.lines() .filter_map(|l| vdf_value(l.trim(), "path")) .map(|p| { #[cfg(target_os = "windows")] { p.replace("\\\\", "\\") } #[cfg(not(target_os = "windows"))] { p.to_string() } }) .collect() } /// `"" ""` on a single line → ``. Used for both VDF and ACF flat fields. fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> { let rest = line.strip_prefix(&format!("\"{key}\""))?; let after = &rest[rest.find('"')? + 1..]; Some(&after[..after.find('"')?]) } /// Scan a `steamapps` dir for `appmanifest_*.acf` files → (appid, name) of installed titles. fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> { let Ok(rd) = std::fs::read_dir(steamapps) else { return Vec::new(); }; let mut out = Vec::new(); for entry in rd.flatten() { let fname = entry.file_name(); let fname = fname.to_string_lossy(); if !(fname.starts_with("appmanifest_") && fname.ends_with(".acf")) { continue; } if let Ok(text) = std::fs::read_to_string(entry.path()) { let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid")); let name = text.lines().find_map(|l| vdf_value(l.trim(), "name")); if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::), name) { out.push((appid, name.to_string())); } } } out } /// Steam installs runtimes/redistributables as "apps" too — keep them out of a *game* library. fn is_steam_tool(appid: u32, name: &str) -> bool { // Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier). const TOOL_IDS: &[u32] = &[228980, 1070560, 1391110, 1628350, 1493710]; if TOOL_IDS.contains(&appid) { return true; } let n = name.to_ascii_lowercase(); n.contains("proton") || n.starts_with("steam linux runtime") || n.contains("steamworks common") || n.contains("steamvr") } // --------------------------------------------------------------------------------------- // Lutris (Linux) — reads the local `pga.db` (no auth, no network). One provider covers // everything Lutris manages: Wine/Proton games, GOG/Epic/Battle.net installs, emulators. // --------------------------------------------------------------------------------------- /// Reads the **local** Lutris library DB (`pga.db`) — no network. Installed titles only; cover art /// from Lutris's on-disk cache, inlined as `data:` URLs. Linux-only (Lutris is Linux-only). #[cfg(target_os = "linux")] pub struct LutrisProvider; #[cfg(target_os = "linux")] impl LibraryProvider for LutrisProvider { fn store(&self) -> &'static str { "lutris" } fn list(&self) -> Vec { let Some(db) = lutris_db() else { return Vec::new(); }; lutris_games(&db).unwrap_or_else(|e| { tracing::warn!(error = %e, db = %db.display(), "lutris pga.db read failed — skipping"); Vec::new() }) } } /// The first existing Lutris `pga.db`: XDG data dir, the classic `~/.local/share`, or Flatpak. #[cfg(target_os = "linux")] fn lutris_db() -> Option { let mut candidates = Vec::new(); if let Some(d) = std::env::var_os("XDG_DATA_HOME") { candidates.push(PathBuf::from(d).join("lutris/pga.db")); } if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { candidates.push(home.join(".local/share/lutris/pga.db")); candidates.push(home.join(".var/app/net.lutris.Lutris/data/lutris/pga.db")); } candidates.into_iter().find(|p| p.is_file()) } /// Installed games from a Lutris `pga.db`. Opened **read-only + immutable** (via a SQLite URI) so a /// running Lutris holding the file can't make us block or fail, and we never write to it. #[cfg(target_os = "linux")] fn lutris_games(db: &Path) -> rusqlite::Result> { use rusqlite::OpenFlags; // `immutable=1` treats the DB as read-only-and-unchanging → no locking against a live Lutris. The // path goes into the URI literally; a `?`/`#` in it (vanishingly rare on Linux) would mis-parse, // so fall back to a plain read-only open in that case. let path = db.to_string_lossy(); let conn = if path.contains('?') || path.contains('#') { rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)? } else { rusqlite::Connection::open_with_flags( format!("file:{path}?immutable=1"), OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, )? }; let mut stmt = conn.prepare( "SELECT id, slug, name FROM games \ WHERE installed = 1 AND name IS NOT NULL AND name <> '' \ ORDER BY name COLLATE NOCASE", )?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, String>(2)?, )) })?; let mut games = Vec::new(); for (id, slug, name) in rows.flatten() { games.push(GameEntry { id: format!("lutris:{id}"), store: "lutris".into(), title: name, art: slug.as_deref().map(lutris_art).unwrap_or_default(), launch: Some(LaunchSpec { kind: "lutris_id".into(), value: id.to_string(), }), }); } Ok(games) } /// Lutris cover art (local files keyed by slug) inlined as `data:` URLs — Lutris has no public CDN /// keyed by a stable id (unlike Steam/Heroic), and `Artwork` fields are URLs the client fetches, so a /// self-contained `data:` URL needs no host-served endpoint. `coverart` → portrait, `banners` → header. #[cfg(target_os = "linux")] fn lutris_art(slug: &str) -> Artwork { Artwork { portrait: lutris_image("coverart", slug), header: lutris_image("banners", slug), ..Default::default() } } /// Find `/.jpg` across the current (0.5.18+), legacy (`~/.cache`), and Flatpak Lutris /// dirs and inline it as `data:image/jpeg;base64,…`. Skips a missing or implausibly large file (a /// 1 MiB cap bounds the catalog JSON so a few big files can't bloat it). #[cfg(target_os = "linux")] fn lutris_image(kind: &str, slug: &str) -> Option { use base64::Engine as _; let home = std::env::var_os("HOME").map(PathBuf::from)?; let roots = [ home.join(".local/share/lutris"), home.join(".cache/lutris"), home.join(".var/app/net.lutris.Lutris/data/lutris"), home.join(".var/app/net.lutris.Lutris/cache/lutris"), ]; for root in roots { let p = root.join(kind).join(format!("{slug}.jpg")); let Ok(meta) = std::fs::metadata(&p) else { continue; }; if meta.len() == 0 || meta.len() > 1024 * 1024 { continue; } if let Ok(bytes) = std::fs::read(&p) { let enc = base64::engine::general_purpose::STANDARD.encode(&bytes); return Some(format!("data:image/jpeg;base64,{enc}")); } } None } // --------------------------------------------------------------------------------------- // Heroic (Linux) — Epic + GOG + Amazon in one provider. Reads Heroic's `store_cache` JSON // (no auth); cover art is already public Epic/GOG/Amazon CDN URLs the client fetches directly. // --------------------------------------------------------------------------------------- /// Reads Heroic Games Launcher's local library cache. One provider surfaces all three of Heroic's /// backends (legendary=Epic, gog=GOG, nile=Amazon). Linux-only for now (Heroic on Windows uses a /// different config path and the launch path isn't wired there yet). #[cfg(target_os = "linux")] pub struct HeroicProvider; #[cfg(target_os = "linux")] impl LibraryProvider for HeroicProvider { fn store(&self) -> &'static str { "heroic" } fn list(&self) -> Vec { let Some(root) = heroic_root() else { return Vec::new(); }; let mut games = Vec::new(); // (cache file, runner id, the electron-store data key holding the games array) for (file, runner, key) in [ ("legendary_library.json", "legendary", "library"), ("gog_library.json", "gog", "games"), ("nile_library.json", "nile", "library"), ] { let path = root.join("store_cache").join(file); match heroic_games(&path, runner, key) { Ok(mut g) => games.append(&mut g), Err(e) => { tracing::debug!(error = %e, file, "heroic store_cache not read (store unused?)") } } } games } } /// The first existing Heroic config root: `$XDG_CONFIG_HOME/heroic`, classic `~/.config/heroic`, or /// the Flatpak path. #[cfg(target_os = "linux")] fn heroic_root() -> Option { let mut candidates = Vec::new(); if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") { candidates.push(PathBuf::from(d).join("heroic")); } if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { candidates.push(home.join(".config/heroic")); candidates.push(home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic")); } candidates.into_iter().find(|p| p.is_dir()) } /// Parse one runner's `store_cache/*_library.json` (an electron-store object whose `key` holds the /// games array). Keeps only installed titles whose install dir still exists (the latter works around /// Heroic's gog `is_installed` bug, #2691). Art comes straight from the cached public CDN URLs. #[cfg(target_os = "linux")] fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result> { let raw = std::fs::read_to_string(path)?; let root: serde_json::Value = serde_json::from_str(&raw)?; let arr = root .get(key) .and_then(|v| v.as_array()) .ok_or_else(|| anyhow::anyhow!("no '{key}' array in {}", path.display()))?; let mut games = Vec::new(); for g in arr { if !g .get("is_installed") .and_then(|v| v.as_bool()) .unwrap_or(false) { continue; // the cache also lists owned-but-not-installed titles } let install_ok = g .get("install") .and_then(|i| i.get("install_path")) .and_then(|p| p.as_str()) .is_some_and(|p| Path::new(p).is_dir()); if !install_ok { continue; } let Some(app_name) = g .get("app_name") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) else { continue; }; let title = g .get("title") .and_then(|v| v.as_str()) .unwrap_or(app_name) .to_string(); // Only emit http(s) art (sideloaded titles can carry local file:// paths the client can't fetch). let http = |k: &str| { g.get(k) .and_then(|v| v.as_str()) .filter(|s| s.starts_with("http://") || s.starts_with("https://")) .map(String::from) }; let art = Artwork { portrait: http("art_square"), header: http("art_cover"), hero: http("art_background").or_else(|| http("art_cover")), logo: http("art_logo"), }; games.push(GameEntry { id: format!("heroic:{runner}:{app_name}"), store: "heroic".into(), title, art, launch: Some(LaunchSpec { kind: "heroic".into(), value: format!("{runner}:{app_name}"), }), }); } Ok(games) } /// 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")] 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()) } // --------------------------------------------------------------------------------------- // Epic Games Store (Windows) — reads the launcher's local `.item` manifests under ProgramData // (no auth, launcher need not run). Cover art from the base64 `catcache.bin` (public Epic CDN). // --------------------------------------------------------------------------------------- /// Reads the Epic Games Launcher's local install manifests. Windows-only. Best-effort: empty when /// the launcher (or its manifest dir) isn't present. #[cfg(windows)] pub struct EpicProvider; #[cfg(windows)] impl LibraryProvider for EpicProvider { fn store(&self) -> &'static str { "epic" } fn list(&self) -> Vec { let data = epic_data_dir(); let Ok(rd) = std::fs::read_dir(data.join("Manifests")) else { return Vec::new(); }; // Parse the (best-effort) artwork cache ONCE: catalogItemId -> Artwork. let art = epic_art_index(&data.join("Catalog").join("catcache.bin")); let mut games = Vec::new(); for entry in rd.flatten() { let p = entry.path(); if p.extension().and_then(|e| e.to_str()) != Some("item") { continue; } // `.item` manifests are small JSON; cap the read so a planted giant can't OOM the host. let Some(bytes) = read_capped(&p, 1024 * 1024) else { continue; }; let Ok(v) = serde_json::from_slice::(&bytes) else { continue; }; if let Some(g) = epic_entry(&v, &art) { games.push(g); } } games } } /// `%ProgramData%\Epic\EpicGamesLauncher\Data` (machine-wide, SYSTEM-readable). #[cfg(windows)] fn epic_data_dir() -> PathBuf { std::env::var_os("ProgramData") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("C:\\ProgramData")) .join("Epic") .join("EpicGamesLauncher") .join("Data") } /// Map one `.item` manifest to a [`GameEntry`], or `None` if it isn't a launchable game. Uses /// Playnite's proven EXCLUSION filter (skip `UE_*` Unreal components; skip a DLC/addon unless it is /// `addons/launchable`) rather than a positive `games`-category match, which can drop legit titles. #[cfg(windows)] fn epic_entry( v: &serde_json::Value, art: &std::collections::HashMap, ) -> Option { let s = |k: &str| v.get(k).and_then(|x| x.as_str()); let app_name = s("AppName")?.to_string(); if app_name.starts_with("UE_") { return None; // Unreal Engine component, not a game } let cats: Vec<&str> = v .get("AppCategories") .and_then(|c| c.as_array()) .map(|a| a.iter().filter_map(|x| x.as_str()).collect()) .unwrap_or_default(); if cats.contains(&"addons") && !cats.contains(&"addons/launchable") { return None; // non-launchable DLC/addon } // Drop stale records whose install dir is gone. let install = s("InstallLocation")?; if !Path::new(install).is_dir() { return None; } let title = s("DisplayName").unwrap_or(&app_name).to_string(); let namespace = s("CatalogNamespace").unwrap_or(""); let catalog = s("CatalogItemId").unwrap_or(""); // The robust launch form is the namespace:catalogItemId:appName triple; fall back to the bare // appName when those ids are absent (some manifests lack them) — never drop the launch entirely. let value = if !namespace.is_empty() && !catalog.is_empty() { format!("{namespace}:{catalog}:{app_name}") } else { app_name.clone() }; Some(GameEntry { id: format!("epic:{app_name}"), store: "epic".into(), title, art: art.get(catalog).cloned().unwrap_or_default(), launch: Some(LaunchSpec { kind: "epic".into(), value, }), }) } /// Read a launcher cache/manifest with a hard size cap, so a local unprivileged user can't plant a /// multi-GB file under the launcher's (Users-writable) data dir that OOMs the privileged host when /// it's loaded — then base64/JSON-decoded into further copies — during library enumeration /// (security-review 2026-06-28 S4). Returns `None` if missing, empty, or over `max`. Mirrors the /// Linux lutris-art reader's 1 MiB cap. #[cfg(windows)] fn read_capped(path: &Path, max: u64) -> Option> { let meta = std::fs::metadata(path).ok()?; if meta.len() == 0 || meta.len() > max { if meta.len() > max { tracing::warn!(path = %path.display(), len = meta.len(), max, "launcher cache exceeds size cap — skipping"); } return None; } std::fs::read(path).ok() } /// Best-effort parse of `catcache.bin` (base64-encoded JSON array of catalog items) into /// catalogItemId → [`Artwork`] from each item's `keyImages`. Empty map on any read/decode failure /// (the format is community-reverse-engineered + can lag a fresh install → titles just show no art). #[cfg(windows)] fn epic_art_index(catcache: &Path) -> std::collections::HashMap { use base64::Engine as _; let mut map = std::collections::HashMap::new(); // 32 MiB cap: comfortably fits a real catalog cache, blocks a planted giant (S4). let Some(raw) = read_capped(catcache, 32 * 1024 * 1024) else { return map; }; let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw) else { return map; }; let Ok(items) = serde_json::from_slice::(&decoded) else { return map; }; let Some(arr) = items.as_array() else { return map; }; for item in arr { let Some(cat) = item .get("id") .or_else(|| item.get("catalogItemId")) .and_then(|v| v.as_str()) else { continue; }; let Some(images) = item.get("keyImages").and_then(|v| v.as_array()) else { continue; }; let mut art = Artwork::default(); for img in images { let (Some(ty), Some(url)) = ( img.get("type").and_then(|v| v.as_str()), img.get("url").and_then(|v| v.as_str()), ) else { continue; }; if !(url.starts_with("http://") || url.starts_with("https://")) { continue; } match ty { "DieselGameBoxTall" => art.portrait = Some(url.to_string()), "DieselGameBox" => art.hero = Some(url.to_string()), "DieselGameBoxLogo" => art.logo = Some(url.to_string()), _ => {} } } if art.portrait.is_some() || art.hero.is_some() || art.logo.is_some() { map.insert(cat.to_string(), art); } } 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)] 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" )) } // --------------------------------------------------------------------------------------- // GOG (Windows) — registry-indexed installs + each game's `goggame-.info` for a direct-exe // launch (no Galaxy needed, dodges its cold-start/anti-cheat). Art (api.gog.com) is a follow-up. // --------------------------------------------------------------------------------------- /// Reads the GOG.com install registry + per-game `.info` files. Windows-only. Best-effort: empty /// when GOG isn't installed. #[cfg(windows)] pub struct GogProvider; #[cfg(windows)] impl LibraryProvider for GogProvider { fn store(&self) -> &'static str { "gog" } fn list(&self) -> Vec { gog_games() } } #[cfg(windows)] fn gog_games() -> Vec { use winreg::enums::HKEY_LOCAL_MACHINE; use winreg::RegKey; // 32-bit GOG writes under WOW6432Node; a 64-bit process reads the explicit path directly. let Ok(games_key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey("SOFTWARE\\WOW6432Node\\GOG.com\\Games") else { return Vec::new(); }; let mut out = Vec::new(); for sub in games_key.enum_keys().flatten() { // The subkey name IS the GOG product id. let Ok(k) = games_key.open_subkey(&sub) else { continue; }; let Ok(path) = k.get_value::("PATH") else { continue; }; if !Path::new(&path).is_dir() { continue; } let title = k .get_value::("GAMENAME") .unwrap_or_else(|_| sub.clone()); // Resolve the primary play task (exe + args + workdir) from goggame-.info; skip if absent. let Some((exe, args, workdir)) = gog_play_task(&path, &sub) else { continue; }; let id = format!("gog:{sub}"); // Art (public api.gog.com) is resolved off the hot path by the background warmer; read // whatever it has cached (title-only until warmed). let art = cached_art(&id).unwrap_or_default(); out.push(GameEntry { id, store: "gog".into(), title, art, launch: Some(LaunchSpec { kind: "gog".into(), value: format!("{exe}\t{args}\t{workdir}"), }), }); } out } /// The primary play task from `\goggame-.info`: `(absolute exe, args, working dir)`. /// Prefers `isPrimary` + `FileTask`, else the first `FileTask`. Paths are resolved against `install`. #[cfg(windows)] fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> { let text = std::fs::read_to_string(Path::new(install).join(format!("goggame-{id}.info"))).ok()?; let v: serde_json::Value = serde_json::from_str(&text).ok()?; let tasks = v.get("playTasks")?.as_array()?; let is_file = |t: &serde_json::Value| t.get("type").and_then(|s| s.as_str()) == Some("FileTask"); let pick = tasks .iter() .find(|t| { t.get("isPrimary") .and_then(|b| b.as_bool()) .unwrap_or(false) && is_file(t) }) .or_else(|| tasks.iter().find(|t| is_file(t)))?; let rel = pick.get("path").and_then(|s| s.as_str())?; let exe = Path::new(install).join(rel); let args = pick .get("arguments") .and_then(|s| s.as_str()) .unwrap_or("") .to_string(); let workdir = pick .get("workingDir") .and_then(|s| s.as_str()) .map(|w| Path::new(install).join(w)) .unwrap_or_else(|| Path::new(install).to_path_buf()); Some(( exe.to_string_lossy().into_owned(), args, workdir.to_string_lossy().into_owned(), )) } /// 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)] 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)) } // --------------------------------------------------------------------------------------- // Xbox / Microsoft Store / Game Pass (Windows) — scans the flat-file `XboxGames` install dirs // (no auth) for GDK games (each has a Content\MicrosoftGame.config). Launch via the AUMID // (shell:AppsFolder\!) in the interactive session. Cover art (displaycatalog) deferred. // --------------------------------------------------------------------------------------- /// Reads installed Xbox / Game Pass / Store GDK games from the flat-file install dirs. Windows-only. /// Best-effort: empty when no `XboxGames` dir exists. #[cfg(windows)] pub struct XboxProvider; #[cfg(windows)] impl LibraryProvider for XboxProvider { fn store(&self) -> &'static str { "xbox" } fn list(&self) -> Vec { xbox_games() } } /// Scan each fixed drive's default `:\XboxGames` for GDK games — the presence of /// `Content\MicrosoftGame.config` is the game marker (so we list games, not ordinary UWP apps). A /// custom install folder (set via the undocumented `.GamingRoot`) isn't covered; the default folder /// is the common case. Non-GDK pure-UWP Store games (under the ACL-locked WindowsApps) are missed too. #[cfg(windows)] fn xbox_games() -> Vec { let mut games = Vec::new(); for letter in b'C'..=b'Z' { let root = PathBuf::from(format!("{}:\\XboxGames", letter as char)); let Ok(rd) = std::fs::read_dir(&root) else { continue; }; for entry in rd.flatten() { let title_dir = entry.path(); let cfg = title_dir.join("Content").join("MicrosoftGame.config"); if !cfg.is_file() { continue; } let Ok(text) = std::fs::read_to_string(&cfg) else { continue; }; let folder = title_dir .file_name() .map(|f| f.to_string_lossy().into_owned()); let Some((name, app_id, title, store_id)) = xbox_parse_config(&text, folder.as_deref()) else { continue; }; let Some(pfn) = xbox_pfn(&name) else { tracing::debug!(package = %name, "xbox: no AppRepository entry → can't resolve PFN, skipping"); continue; }; let id_key = if store_id.is_empty() { pfn.clone() } else { store_id }; let id = format!("xbox:{id_key}"); // Art (unofficial displaycatalog, keyed by StoreId) is resolved off the hot path by the // background warmer; read whatever it has cached (title-only until warmed / if no StoreId). let art = cached_art(&id).unwrap_or_default(); games.push(GameEntry { id, store: "xbox".into(), title, art, launch: Some(LaunchSpec { kind: "aumid".into(), value: format!("{pfn}!{app_id}"), }), }); } } games.sort_by(|a, b| a.id.cmp(&b.id)); games.dedup_by(|a, b| a.id == b.id); // same game on two drives → one entry games } /// Parse the fields we need from a `MicrosoftGame.config`: `(Identity Name, AppId, title, StoreId)`. /// AppId is the ``'s `Id` (the AUMID app id, typically "Game"). The title prefers /// `ShellVisuals@DefaultDisplayName`, but that can be an unresolved `ms-resource:` ref → fall back to /// the install folder name, then the package name. #[cfg(windows)] fn xbox_parse_config(text: &str, folder: Option<&str>) -> Option<(String, String, String, String)> { let doc = roxmltree::Document::parse(text).ok()?; let root = doc.root_element(); let name = root .children() .find(|n| n.has_tag_name("Identity"))? .attribute("Name")? .to_string(); let app_id = root .children() .find(|n| n.has_tag_name("ExecutableList")) .and_then(|el| { el.children() .filter(|n| n.has_tag_name("Executable")) .find_map(|e| e.attribute("Id")) })? .to_string(); let ddn = root .children() .find(|n| n.has_tag_name("ShellVisuals")) .and_then(|sv| sv.attribute("DefaultDisplayName")) .filter(|s| !s.is_empty() && !s.starts_with("ms-resource")); let title = ddn .map(String::from) .or_else(|| folder.map(String::from)) .unwrap_or_else(|| name.clone()); let store_id = root .children() .find(|n| n.has_tag_name("StoreId")) .and_then(|n| n.text()) .unwrap_or("") .to_string(); Some((name, app_id, title, store_id)) } /// Resolve a package's PackageFamilyName by finding its /// `AppRepository\Packages\` dir (machine-wide, SYSTEM-readable) and reducing the /// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash. #[cfg(windows)] fn xbox_pfn(identity: &str) -> Option { let pkgs = PathBuf::from(std::env::var_os("ProgramData")?) .join("Microsoft") .join("Windows") .join("AppRepository") .join("Packages"); let prefix = format!("{identity}_"); for e in std::fs::read_dir(&pkgs).ok()?.flatten() { let dn = e.file_name().to_string_lossy().into_owned(); if dn.starts_with(&prefix) { if let Some(pfn) = pfn_from_full(&dn, identity) { return Some(pfn); } } } None } /// PackageFamilyName from a PackageFullName dir name /// (`Name_Version_Arch_ResourceId_PublisherHash`) → `Name_PublisherHash`. The hash is the last /// `_`-segment; `Name` is the caller's identity. #[cfg(windows)] fn pfn_from_full(dir_name: &str, identity: &str) -> Option { let hash = dir_name.rsplit('_').next()?; (!hash.is_empty() && hash != dir_name).then(|| format!("{identity}_{hash}")) } // --------------------------------------------------------------------------------------- // Cover-art resolver + cache (shared by the Windows GOG + Xbox providers, which have no local // art). A disk cache is the source of truth read by all_games() (so the list/launch path never // blocks on the network); a host-lifetime background warmer fetches uncached art (GOG's public // api.gog.com + Xbox's displaycatalog, both no-auth) and persists it. Cross-platform so the // HTTP/JSON code is compiled + checked everywhere; the warmer simply finds nothing to fetch on a // host whose stores all carry their own art (Steam CDN / Heroic CDN / Lutris data: URLs). // --------------------------------------------------------------------------------------- /// The persisted art cache: GameEntry id → resolved [`Artwork`]. An entry's PRESENCE means "already /// resolved" (even an empty Artwork = fetched, none found) so the warmer never re-fetches it. fn art_cache() -> &'static std::sync::Mutex> { static CACHE: std::sync::OnceLock< std::sync::Mutex>, > = std::sync::OnceLock::new(); CACHE.get_or_init(|| { let loaded = std::fs::read_to_string(art_cache_path()) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); std::sync::Mutex::new(loaded) }) } /// The art cache lives in the canonical HOST config dir (`%ProgramData%\punktfunk` on Windows / /// `~/.config/punktfunk` on Linux — gamestream::config_dir, NOT the legacy XDG/HOME `config_dir` /// below that the custom store still uses). fn art_cache_path() -> PathBuf { crate::gamestream::config_dir().join("library-art-cache.json") } /// The cached art for a library id, if it has been resolved (positive or negative). `None` = not yet /// warmed → the provider shows title-only until the warmer fills it in. fn cached_art(id: &str) -> Option { art_cache().lock().unwrap().get(id).cloned() } /// Record resolved art for a library id + persist the cache (write-then-rename; best-effort). fn store_art(id: &str, art: Artwork) { let mut cache = art_cache().lock().unwrap(); cache.insert(id.to_string(), art); if let Ok(json) = serde_json::to_string(&*cache) { let path = art_cache_path(); if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } let tmp = path.with_extension("json.tmp"); if std::fs::write(&tmp, json).is_ok() { let _ = std::fs::rename(&tmp, &path); } } } /// Start the host-lifetime cover-art warmer: every few minutes, fetch + cache art for any library /// entry whose store needs a network lookup (GOG / Xbox) and isn't cached yet. Idempotent — once /// everything is cached a pass makes no network calls (and a host with only self-art stores never /// fetches at all). Call once from `serve()`; the returned handle can be dropped to detach it. pub fn start_art_warmer() -> std::thread::JoinHandle<()> { std::thread::Builder::new() .name("pf-art-warmer".into()) .spawn(|| loop { warm_art_once(); std::thread::sleep(std::time::Duration::from_secs(300)); }) .expect("spawn art warmer thread") } /// One warming pass: resolve uncached GOG/Xbox art. Other stores carry their own art (Steam CDN /// template, Heroic CDN URLs, Lutris data: URLs, custom user URLs) and are skipped. fn warm_art_once() { for g in all_games() { if cached_art(&g.id).is_some() { continue; } let Some((store, localid)) = g.id.split_once(':') else { continue; }; let art = match store { "gog" => fetch_gog_art(localid), // The xbox id is the StoreId when present, else the PFN (contains '_', no displaycatalog // entry) → cache empty for those so they aren't retried every pass. "xbox" if !localid.contains('_') => fetch_xbox_art(localid), "xbox" => Artwork::default(), _ => continue, // steam/heroic/lutris/custom resolve their own art }; store_art(&g.id, art); } } /// HTTP GET + parse JSON with a bounded timeout. `None` on any network/parse failure (best-effort — /// art is non-essential, so a failure just leaves the title-only card). fn fetch_json(url: &str) -> Option { let agent = ureq::AgentBuilder::new() .timeout(std::time::Duration::from_secs(10)) .build(); let body = agent.get(url).call().ok()?.into_string().ok()?; serde_json::from_str(&body).ok() } /// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https. fn abs_url(u: &str) -> String { u.strip_prefix("//") .map(|rest| format!("https://{rest}")) .unwrap_or_else(|| u.to_string()) } /// GOG cover art via the public (no-auth) product API. Field names / URL shapes are GOG-specific and /// best-effort (worth on-box confirmation); a wrong URL just degrades to the title card client-side. fn fetch_gog_art(product_id: &str) -> Artwork { let Some(v) = fetch_json(&format!( "https://api.gog.com/products/{product_id}?expand=images" )) else { return Artwork::default(); }; let img = |k: &str| { v.get("images") .and_then(|i| i.get(k)) .and_then(|u| u.as_str()) .map(abs_url) }; Artwork { portrait: img("verticalCover"), hero: img("background"), logo: img("logo2x"), header: img("logo"), } } /// Xbox cover art via the (unofficial, no-auth) Microsoft display catalog, keyed by StoreId. Best- /// effort: the endpoint is internal/unstable, so on drift this just yields no art (title-only). fn fetch_xbox_art(store_id: &str) -> Artwork { let Some(v) = fetch_json(&format!( "https://displaycatalog.mp.microsoft.com/v7.0/products/{store_id}?market=US&languages=en-us&fieldsTemplate=Details" )) else { return Artwork::default(); }; let images = v .get("Products") .and_then(|p| p.as_array()) .and_then(|a| a.first()) .and_then(|p| p.get("LocalizedProperties")) .and_then(|l| l.as_array()) .and_then(|a| a.first()) .and_then(|lp| lp.get("Images")) .and_then(|i| i.as_array()); let mut art = Artwork::default(); for img in images.into_iter().flatten() { let (Some(purpose), Some(uri)) = ( img.get("ImagePurpose").and_then(|v| v.as_str()), img.get("Uri").and_then(|v| v.as_str()), ) else { continue; }; let url = abs_url(uri); match purpose { "Poster" => art.portrait = Some(url), "SuperHeroArt" | "Hero" => art.hero = Some(url), "Logo" => art.logo = Some(url), "BoxArt" => art.header = Some(url), _ => {} } } art } // --------------------------------------------------------------------------------------- // Custom store (user-curated entries, persisted + CRUD'd via the mgmt API) // --------------------------------------------------------------------------------------- /// A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API /// returns and the web console edits. #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct CustomEntry { /// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path). pub id: String, pub title: String, #[serde(default)] pub art: Artwork, #[serde(default, skip_serializing_if = "Option::is_none")] pub launch: Option, } /// Request body to create or replace a custom entry (no `id` — the host owns it). #[derive(Clone, Debug, Deserialize, ToSchema)] pub struct CustomInput { pub title: String, #[serde(default)] pub art: Artwork, #[serde(default)] pub launch: Option, } impl From for GameEntry { fn from(c: CustomEntry) -> Self { GameEntry { id: format!("custom:{}", c.id), store: "custom".into(), title: c.title, art: c.art, launch: c.launch, } } } fn config_dir() -> PathBuf { std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) .unwrap_or_else(|| PathBuf::from(".")) .join("punktfunk") } fn custom_path() -> PathBuf { config_dir().join("library.json") } /// Load the custom entries (empty + non-fatal if the file is absent or malformed). pub fn load_custom() -> Vec { 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(), } } fn save_custom(entries: &[CustomEntry]) -> Result<()> { let dir = config_dir(); std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; let json = serde_json::to_string_pretty(entries)?; // Write-then-rename so a crash mid-write never truncates the catalog. let tmp = custom_path().with_extension("json.tmp"); std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?; std::fs::rename(&tmp, custom_path()).context("rename library.json")?; Ok(()) } /// 12 hex chars from the title + wall-clock nanos — collision-free in practice, no uuid dep. fn new_id(title: &str) -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6]) } /// Create a custom entry, returning it with its assigned id. pub fn add_custom(input: CustomInput) -> Result { let mut entries = load_custom(); let entry = CustomEntry { id: new_id(&input.title), title: input.title, art: input.art, launch: input.launch, }; entries.push(entry.clone()); save_custom(&entries)?; Ok(entry) } /// Replace a custom entry's fields (id preserved). `None` ⇒ no entry with that id. 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 { return Ok(None); }; slot.title = input.title; slot.art = input.art; slot.launch = input.launch; let updated = slot.clone(); save_custom(&entries)?; Ok(Some(updated)) } /// Delete a custom entry. `false` ⇒ no entry with that id. pub fn delete_custom(id: &str) -> Result { let mut entries = load_custom(); let before = entries.len(); entries.retain(|e| e.id != id); if entries.len() == before { return Ok(false); } save_custom(&entries)?; Ok(true) } // --------------------------------------------------------------------------------------- // Unified library // --------------------------------------------------------------------------------------- /// 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`]). fn valid_steam_appid(value: &str) -> bool { !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) } /// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell /// command the host should run for it — looked up in the host's OWN library so a client can only /// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a /// malformed Steam appid. /// /// **Linux only**: the resolved command is run nested inside the per-session gamescope. On Windows /// there is no gamescope to nest into; the host launches a title into the interactive user session /// via [`launch_title`] instead. /// /// - `steam_appid` → `steam steam://rungameid/` (appid validated as digits). /// - `command` → the stored command verbatim. This string comes from the host's own custom store /// (added by the host operator via the admin UI), never from the client, so it is trusted. #[cfg(not(windows))] pub fn launch_command(id: &str) -> Option { let spec = all_games().into_iter().find(|g| g.id == id)?.launch?; command_for(&spec) } /// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of /// [`launch_command`], split out so the appid-validation can be tested without a Steam install). #[cfg(not(windows))] fn command_for(spec: &LaunchSpec) -> Option { match spec.kind.as_str() { "steam_appid" => valid_steam_appid(&spec.value) .then(|| format!("steam steam://rungameid/{}", spec.value)), // Lutris: a digits-only pga.db game id (same guard as steam_appid) → its run URI. #[cfg(target_os = "linux")] "lutris_id" => (!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit())) .then(|| format!("lutris lutris:rungameid/{}", spec.value)), // Heroic: `:` → the validated heroic://launch command (see heroic_command). #[cfg(target_os = "linux")] "heroic" => heroic_command(&spec.value), // 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, } } /// Windows: launch a store-qualified library id into the **interactive user session** — the Windows /// analogue of the Linux gamescope-nested [`launch_command`]. The id is resolved against the host's /// OWN library (the client never sends a command), mapped to a concrete process by /// [`windows_launch_for`], and spawned via [`crate::interactive::spawn_in_active_session`]. /// /// Wired into the data plane *after* capture is live, so the title renders onto the already-captured /// desktop and grabs foreground. #[cfg(windows)] pub fn launch_title(id: &str) -> Result<()> { let spec = all_games() .into_iter() .find(|g| g.id == id) .and_then(|g| g.launch) .ok_or_else(|| anyhow::anyhow!("no launchable library entry '{id}'"))?; let (cmdline, workdir) = windows_launch_for(&spec).ok_or_else(|| { anyhow::anyhow!( "library entry '{id}' has no Windows launch recipe (kind '{}')", spec.kind ) })?; let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref()) .with_context(|| format!("launch '{id}' in the interactive session"))?; tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session"); Ok(()) } /// Windows: map a resolved [`LaunchSpec`] to a `(command line, working dir)` to spawn into the /// interactive session. Pure + unit-testable. `None` = no Windows recipe for this kind. /// /// CreateProcessAsUserW does NO shell or protocol resolution, so the URI/flags are handed to a /// concrete EXE as plain arguments — a (host-derived) URI string can never reach a command interpreter. #[cfg(windows)] fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option)> { match spec.kind.as_str() { "steam_appid" => { if !valid_steam_appid(&spec.value) { return None; } let uri = format!("steam://rungameid/{}", spec.value); // Prefer launching Steam.exe with the URI as an argument; fall back to explorer.exe, which // resolves the steam:// handler from the user hive. (The appid is digits-validated, so the // only variable part of the line is a number either way.) 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. "epic" => epic_launch_uri(&spec.value).map(|uri| (format!("explorer.exe \"{uri}\""), None)), // GOG: spawn the resolved game exe directly (host-derived from goggame-.info), no Galaxy. "gog" => gog_spawn(&spec.value), // Xbox/Game Pass: activate the UWP/GDK package by its AUMID (!) via explorer's // shell:AppsFolder — which runs in the interactive user session (UWP activation fails as // SYSTEM/session-0; spawn_in_active_session uses the user token). Guard the charset (the value // is host-derived from MicrosoftGame.config + AppRepository, but belt-and-suspenders). "aumid" => { let valid = spec.value.split_once('!').is_some_and(|(pfn, app)| { let part = |s: &str| { !s.is_empty() && s.bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) }; part(pfn) && part(app) }); valid.then(|| { ( format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value), None, ) }) } // Operator-typed custom command (host-owned, never client-set): run it through the shell in the // interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator // input — the same trust as the operator typing it — not a client-influenced string. "command" => { let v = spec.value.trim(); (!v.is_empty()).then(|| (format!("cmd.exe /c {v}"), None)) } _ => None, } } /// Windows: the default Steam install's `steam.exe`, if present. A non-default Steam install dir /// (registry `Valve\Steam\InstallPath`) isn't covered — the explorer.exe protocol fallback handles /// that case. Mirrors [`steam_roots`]' "default Program Files dirs" approach. #[cfg(windows)] fn steam_exe() -> Option { for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] { if let Some(pf) = std::env::var_os(var) { let p = std::path::PathBuf::from(pf).join("Steam").join("steam.exe"); if p.is_file() { return Some(p); } } } None } /// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the live /// session, AFTER capture is up. Used by the GameStream path for the backends that DON'T nest the /// command via [`VirtualDisplay::set_launch_command`]: Windows (no gamescope) and Linux /// kwin/mutter/wlroots (which stream the existing desktop). The caller skips this for Linux gamescope, /// which already nested it. On Windows it runs in the interactive USER session (the host is SYSTEM); /// on Linux the host is already inside the user's graphical session, so a plain spawn lands the app on /// the streamed (primary) output. #[cfg(any(windows, target_os = "linux"))] pub fn launch_gamestream_command(cmd: &str) -> Result<()> { let cmd = cmd.trim(); anyhow::ensure!(!cmd.is_empty(), "empty command"); #[cfg(windows)] { // cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a // client-influenced string (same trust as the custom-store `command` kind). let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None) .context("spawn gamestream command in the interactive session")?; tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session"); Ok(()) } #[cfg(target_os = "linux")] { let child = std::process::Command::new("sh") .arg("-c") .arg(cmd) .spawn() .context("spawn gamestream command")?; tracing::info!(command = %cmd, pid = child.id(), "gamestream: launched app into the session"); Ok(()) } } /// The full library: every store's titles merged + the custom entries, sorted by title. pub fn all_games() -> Vec { let mut games = SteamProvider.list(); // The Lutris + Heroic providers are Linux-only (their launchers are); on other hosts the library // is Steam + custom. Each provider is best-effort (empty when its store isn't present). #[cfg(target_os = "linux")] { games.extend(LutrisProvider.list()); games.extend(HeroicProvider.list()); } // Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass. #[cfg(windows)] { games.extend(EpicProvider.list()); games.extend(GogProvider.list()); games.extend(XboxProvider.list()); } games.extend(load_custom().into_iter().map(GameEntry::from)); games.sort_by_key(|g| g.title.to_lowercase()); games } #[cfg(test)] mod tests { use super::*; #[test] fn vdf_value_extracts_quoted_field() { assert_eq!( vdf_value("\"path\"\t\t\"/mnt/games/SteamLibrary\"", "path"), Some("/mnt/games/SteamLibrary") ); assert_eq!(vdf_value("\"appid\"\t\t\"570\"", "appid"), Some("570")); assert_eq!(vdf_value("\"name\"\t\t\"Dota 2\"", "name"), Some("Dota 2")); assert_eq!(vdf_value("\"installdir\"\t\t\"x\"", "appid"), None); } #[test] fn vdf_paths_pulls_all_library_folders() { let vdf = r#" "libraryfolders" { "0" { "path" "/home/u/.local/share/Steam" "apps" { "570" "123" } } "1" { "path" "/mnt/ssd/SteamLibrary" } } "#; assert_eq!( vdf_paths(vdf), vec![ "/home/u/.local/share/Steam".to_string(), "/mnt/ssd/SteamLibrary".to_string() ] ); } #[test] fn tools_are_filtered_but_games_kept() { assert!(is_steam_tool(228980, "Steamworks Common Redistributables")); assert!(is_steam_tool(1493710, "Proton Experimental")); assert!(is_steam_tool(0, "Steam Linux Runtime 3.0 (sniper)")); assert!(!is_steam_tool(570, "Dota 2")); assert!(!is_steam_tool(1245620, "ELDEN RING")); } #[test] fn steam_art_uses_cdn_by_appid() { let art = steam_art(570); assert_eq!( art.portrait.as_deref(), Some("https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_600x900.jpg") ); assert!(art.header.unwrap().ends_with("/570/header.jpg")); } #[cfg(not(windows))] #[test] fn launch_command_resolves_and_guards() { let steam = LaunchSpec { kind: "steam_appid".into(), value: "570".into(), }; assert_eq!( command_for(&steam).as_deref(), Some("steam steam://rungameid/570") ); // A non-numeric "appid" (e.g. a client trying to inject) is rejected, never interpolated. let evil = LaunchSpec { kind: "steam_appid".into(), value: "570; rm -rf ~".into(), }; assert_eq!(command_for(&evil), None); // Custom commands (from the host's own store) pass through verbatim. let custom = LaunchSpec { kind: "command".into(), value: "dolphin-emu --batch".into(), }; assert_eq!(command_for(&custom).as_deref(), Some("dolphin-emu --batch")); // Empty / unknown kinds → no command. assert_eq!( command_for(&LaunchSpec { kind: "command".into(), value: " ".into() }), None ); assert_eq!( command_for(&LaunchSpec { kind: "wat".into(), value: "x".into() }), None ); } #[test] fn custom_entry_maps_to_game_entry() { let g: GameEntry = CustomEntry { id: "abc123".into(), title: "My ROM".into(), art: Artwork::default(), launch: None, } .into(); assert_eq!(g.id, "custom:abc123"); assert_eq!(g.store, "custom"); } #[cfg(target_os = "linux")] #[test] fn lutris_games_reads_installed_only() { use rusqlite::Connection; let dir = std::env::temp_dir().join(format!("pf-lutris-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let db = dir.join("pga.db"); { let c = Connection::open(&db).unwrap(); c.execute_batch( "CREATE TABLE games (id INTEGER PRIMARY KEY, slug TEXT, name TEXT, installed INTEGER); INSERT INTO games (id,slug,name,installed) VALUES (42,'elden-ring','ELDEN RING',1); INSERT INTO games (id,slug,name,installed) VALUES (7,'owned','Owned Only',0); INSERT INTO games (id,slug,name,installed) VALUES (9,'noname',NULL,1);", ) .unwrap(); } let games = lutris_games(&db).unwrap(); std::fs::remove_dir_all(&dir).ok(); // Only the installed, named row; the uninstalled + NULL-name rows are filtered out. assert_eq!(games.len(), 1); assert_eq!(games[0].id, "lutris:42"); assert_eq!(games[0].store, "lutris"); assert_eq!(games[0].title, "ELDEN RING"); let l = games[0].launch.as_ref().unwrap(); assert_eq!((l.kind.as_str(), l.value.as_str()), ("lutris_id", "42")); } #[cfg(target_os = "linux")] #[test] fn heroic_games_parses_installed_with_cdn_art() { let dir = std::env::temp_dir().join(format!("pf-heroic-test-{}", std::process::id())); let install = dir.join("game-install"); std::fs::create_dir_all(&install).unwrap(); let path = dir.join("legendary_library.json"); let json = format!( r#"{{"library":[ {{"app_name":"Quail","title":"Quail","is_installed":true, "install":{{"install_path":"{inst}"}}, "art_square":"https://cdn/quail_tall.jpg","art_cover":"https://cdn/quail_wide.jpg", "art_logo":"file:///local/logo.png"}}, {{"app_name":"Owned","title":"Owned Only","is_installed":false, "install":{{"install_path":"{inst}"}}}} ]}}"#, inst = install.display() ); std::fs::write(&path, json).unwrap(); let games = heroic_games(&path, "legendary", "library").unwrap(); std::fs::remove_dir_all(&dir).ok(); assert_eq!(games.len(), 1); // the uninstalled title is filtered out assert_eq!(games[0].id, "heroic:legendary:Quail"); assert_eq!(games[0].title, "Quail"); assert_eq!( games[0].art.portrait.as_deref(), Some("https://cdn/quail_tall.jpg") ); assert_eq!( games[0].art.header.as_deref(), Some("https://cdn/quail_wide.jpg") ); assert!(games[0].art.logo.is_none()); // file:// art is dropped (client can't fetch it) let l = games[0].launch.as_ref().unwrap(); assert_eq!( (l.kind.as_str(), l.value.as_str()), ("heroic", "legendary:Quail") ); } #[cfg(target_os = "linux")] #[test] fn command_for_lutris_and_heroic_guards() { // Lutris: digits → its run URI; a non-numeric id (injection attempt) is rejected. assert_eq!( command_for(&LaunchSpec { kind: "lutris_id".into(), value: "42".into() }) .as_deref(), Some("lutris lutris:rungameid/42") ); assert_eq!( command_for(&LaunchSpec { kind: "lutris_id".into(), value: "42; rm -rf ~".into() }), None ); // Heroic guards (independent of whether Heroic is installed): bad runner / appName → None. assert_eq!(heroic_command("badrunner:Quail"), None); assert_eq!(heroic_command("legendary:bad name"), None); assert_eq!(heroic_command("nile:"), None); // When Heroic IS resolvable (a dev box), a valid id yields the launch URI; on CI (no Heroic) // it's None — assert the URI shape only when a launcher prefix exists. if let Some(cmd) = heroic_command("legendary:Quail-1.2_x") { assert!(cmd.contains("heroic://launch?appName=Quail-1.2_x&runner=legendary")); assert!(cmd.contains("--no-gui")); } } #[cfg(windows)] #[test] fn windows_launch_for_maps_and_guards() { // Steam: a digits-only appid → a steam:// URI line (via Steam.exe or explorer.exe, depending // on the box) with no working dir. let steam = LaunchSpec { kind: "steam_appid".into(), value: "570".into(), }; let (line, wd) = windows_launch_for(&steam).expect("steam recipe"); assert!(line.contains("steam://rungameid/570"), "line was {line:?}"); assert!(wd.is_none()); // A non-numeric "appid" (a client trying to inject) is rejected, never interpolated. let evil = LaunchSpec { kind: "steam_appid".into(), value: "570\" & calc".into(), }; assert!(windows_launch_for(&evil).is_none()); // Operator command → cmd /c passthrough (trusted host input). let cmd = LaunchSpec { kind: "command".into(), value: "notepad.exe".into(), }; assert_eq!( windows_launch_for(&cmd).unwrap().0, "cmd.exe /c notepad.exe" ); // Xbox AUMID → explorer shell:AppsFolder activation; a value without '!' is rejected. let aumid = LaunchSpec { kind: "aumid".into(), value: "Microsoft.X_8wekyb3d8bbwe!Game".into(), }; assert_eq!( windows_launch_for(&aumid).unwrap().0, "explorer.exe \"shell:AppsFolder\\Microsoft.X_8wekyb3d8bbwe!Game\"" ); assert!(windows_launch_for(&LaunchSpec { kind: "aumid".into(), value: "no-bang".into() }) .is_none()); // Empty / unknown kinds → no recipe. assert!(windows_launch_for(&LaunchSpec { kind: "command".into(), value: " ".into() }) .is_none()); assert!(windows_launch_for(&LaunchSpec { kind: "wat".into(), value: "x".into() }) .is_none()); } #[cfg(windows)] #[test] fn epic_filters_and_builds_launch() { let dir = std::env::temp_dir().join(format!("pf-epic-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let inst = dir.to_string_lossy().into_owned(); let empty = std::collections::HashMap::new(); // Normal game with the full triple → kept, triple launch value. let game = serde_json::json!({ "AppName": "Fortnite", "DisplayName": "Fortnite", "CatalogNamespace": "fn", "CatalogItemId": "abc123", "InstallLocation": inst.clone(), "AppCategories": ["public", "games", "applications"] }); let e = epic_entry(&game, &empty).expect("game kept"); assert_eq!(e.id, "epic:Fortnite"); assert_eq!(e.launch.as_ref().unwrap().value, "fn:abc123:Fortnite"); // UE component, non-launchable addon, and a missing install dir are all skipped. let ue = serde_json::json!({"AppName":"UE_5.3","InstallLocation":inst.clone(),"AppCategories":["engines"]}); assert!(epic_entry(&ue, &empty).is_none()); let dlc = serde_json::json!({"AppName":"DLC","InstallLocation":inst,"AppCategories":["addons"]}); assert!(epic_entry(&dlc, &empty).is_none()); let gone = serde_json::json!({"AppName":"Gone","InstallLocation":"C:\\nope-xyz","AppCategories":["games"]}); 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()); } #[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() { let dir = std::env::temp_dir().join(format!("pf-gog-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let id = "1207658924"; std::fs::write( dir.join(format!("goggame-{id}.info")), r#"{"playTasks":[ {"isPrimary":false,"type":"FileTask","path":"other.exe"}, {"isPrimary":true,"type":"FileTask","path":"bin\\game.exe","arguments":"-w","workingDir":"bin"} ]}"#, ) .unwrap(); let (exe, args, wd) = gog_play_task(&dir.to_string_lossy(), id).unwrap(); std::fs::remove_dir_all(&dir).ok(); assert!(exe.ends_with("bin\\game.exe"), "exe={exe}"); assert_eq!(args, "-w"); assert!(wd.ends_with("bin"), "wd={wd}"); } #[cfg(windows)] #[test] fn xbox_parse_config_and_pfn() { let xml = r#" 9NBLGGH4R315 "#; let (name, app_id, title, store_id) = xbox_parse_config(xml, Some("HaloInfinite")).unwrap(); assert_eq!(name, "Microsoft.624F8B84B80"); assert_eq!(app_id, "Game"); assert_eq!(title, "Halo Infinite"); assert_eq!(store_id, "9NBLGGH4R315"); // An ms-resource DefaultDisplayName is unresolvable → fall back to the install folder name. let xml2 = r#" "#; let (_, app2, title2, sid2) = xbox_parse_config(xml2, Some("MyGameFolder")).unwrap(); assert_eq!(app2, "App"); assert_eq!(title2, "MyGameFolder"); assert_eq!(sid2, ""); // PackageFamilyName reduced from a PackageFullName dir name (the hash is the last segment). assert_eq!( pfn_from_full( "Microsoft.624F8B84B80_1.0.0.0_x64__8wekyb3d8bbwe", "Microsoft.624F8B84B80" ) .as_deref(), Some("Microsoft.624F8B84B80_8wekyb3d8bbwe") ); assert!(pfn_from_full("NoUnderscore", "NoUnderscore").is_none()); } }