From 5f8c6b6147aad482f1293da7670f91339ec56084 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 26 Jun 2026 07:20:58 +0000 Subject: [PATCH] feat(library): Lutris + Heroic store providers (Linux) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LutrisProvider reads the local pga.db (rusqlite, read-only/immutable so a running Lutris can't block us) → installed games, launch via `lutris lutris:rungameid/`, cover art from Lutris's on-disk cache inlined as data: URLs (no public CDN keyed by a stable id, unlike Steam/Heroic). HeroicProvider parses Heroic's store_cache JSON — legendary/gog/nile = Epic+GOG+Amazon in one provider — installed-only with an install-dir existence cross-check (works around Heroic's gog is_installed bug #2691), free public CDN cover art, launch via `heroic --no-gui heroic://launch?...` (the single-instance-Electron gamescope-escape caveat is documented; needs live confirm). New command_for arms (lutris_id digits-guard, heroic runner+appName-guard) + both providers wired into all_games(); everything Linux-gated (the launchers are Linux-only), so the Windows/macOS host build is unaffected. Deps rusqlite (bundled SQLite, no system dep) + base64 added to the Linux target only. Unit tests with sqlite/json fixtures (installed-only filtering, CDN-art mapping, launch guards); live `library` enumeration returns [] gracefully on a box without the launchers. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 91 +++++- crates/punktfunk-host/Cargo.toml | 7 + crates/punktfunk-host/src/library.rs | 405 +++++++++++++++++++++++++++ 3 files changed, 502 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 4fd2d4b..12b07ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1010,6 +1010,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastbloom" version = "0.14.1" @@ -1111,6 +1123,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1586,7 +1604,16 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", ] [[package]] @@ -1594,6 +1621,18 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -1966,6 +2005,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2655,6 +2705,7 @@ dependencies = [ "audiopus_sys", "axum", "axum-server", + "base64", "bytemuck", "cbc", "ffmpeg-next", @@ -2678,6 +2729,7 @@ dependencies = [ "rcgen", "reis", "rsa", + "rusqlite", "rustls", "rustls-pemfile", "rusty_enet", @@ -3028,6 +3080,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3548,6 +3625,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index aae871f..478e49b 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -85,6 +85,13 @@ wayland-scanner = "0.31" wayland-backend = "0.3" # Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend). serde_json = "1" +# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles +# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency — +# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open). +rusqlite = { version = "0.40", features = ["bundled"] } +# Inline Lutris's local cover-art JPEGs as `data:` URLs in the library (Lutris has no public CDN +# keyed by a stable id, unlike Steam/Heroic; a `data:` URL is self-contained — no host-served endpoint). +base64 = "0.22" # Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state. xkbcommon = "0.8" # The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream* diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index 2df0e1b..8a0e9f4 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -256,6 +256,298 @@ fn is_steam_tool(appid: u32, name: &str) -> bool { || 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()) +} + // --------------------------------------------------------------------------------------- // Custom store (user-curated entries, persisted + CRUD'd via the mgmt API) // --------------------------------------------------------------------------------------- @@ -415,6 +707,13 @@ 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, @@ -499,6 +798,13 @@ fn steam_exe() -> Option { /// 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()); + } games.extend(load_custom().into_iter().map(GameEntry::from)); games.sort_by_key(|g| g.title.to_lowercase()); games @@ -616,6 +922,105 @@ mod tests { 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() {