//! Lutris store provider: installed games from the Lutris SQLite DB + lutris.net CDN art. Split out of the `library` facade (plan §W5). use super::*; /// 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, )? }; // `directory` (the game's install dir — our detect signal) is not load-bearing for the library, so // a pga.db schema without it must not cost the whole Lutris store: try the richer query first and // fall back to the historical one on any prepare error. const SELECT_WITH_DIR: &str = "SELECT id, slug, name, directory FROM games \ WHERE installed = 1 AND name IS NOT NULL AND name <> '' \ ORDER BY name COLLATE NOCASE"; const SELECT_PLAIN: &str = "SELECT id, slug, name, NULL FROM games \ WHERE installed = 1 AND name IS NOT NULL AND name <> '' \ ORDER BY name COLLATE NOCASE"; let mut stmt = match conn.prepare(SELECT_WITH_DIR) { Ok(s) => s, Err(e) => { tracing::warn!(error = %e, "lutris pga.db has no `directory` column — listing without \ install dirs (game-exit detection unavailable for Lutris titles)"); conn.prepare(SELECT_PLAIN)? } }; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, String>(2)?, row.get::<_, Option>(3)?, )) })?; let mut games = Vec::new(); for (id, slug, name, directory) in rows.flatten() { games.push(GameEntry { provider: None, meta: GameMeta::pc(), 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(), }), // Lutris stamps no per-game env marker we can rely on, so the install dir is the whole // recipe; a game with none (an emulator entry pointing at a bare ROM) stays untracked. detect: directory .filter(|d| !d.trim().is_empty()) .map(DetectSpec::dir) .unwrap_or_default(), }); } 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 _; // `slug` comes verbatim from Lutris's `pga.db` (untrusted at this layer). Reject any path // separator, parent ref, or NUL so a crafted slug can't escape the art roots and read an // arbitrary `.jpg` off disk — the bytes are base64-inlined into the `/api/v1/library` // JSON a paired client can GET, so an escape is an arbitrary-file-read exfil primitive // (security-review 2026-07-17). Real Lutris slugs are `[a-z0-9-]`. if slug.is_empty() || slug.contains('/') || slug.contains('\\') || slug.contains("..") || slug.contains('\0') { return None; } 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 } #[cfg(test)] mod tests { use super::*; #[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")); } }