//! Game detection module //! //! Provides unified game detection across multiple launchers: //! - Steam (native, Flatpak, Snap) //! - Heroic (GOG, Epic) //! - Bottles // Allow unused items - this is a public API module #![allow(dead_code)] #![allow(unused_imports)] mod bottles; mod heroic; pub mod known_games; mod registry; mod steam; mod vdf; use std::path::PathBuf; pub use bottles::detect_bottles_games; pub use heroic::detect_heroic_games; pub use known_games::{ find_by_epic_id, find_by_gog_id, find_by_name, find_by_steam_id, find_by_title, KnownGame, KNOWN_GAMES, }; pub use registry::{read_registry_value, wine_path_to_linux}; pub use steam::{detect_steam_games, find_game_install_path, find_game_prefix_path, get_known_game}; // ============================================================================ // Core Types // ============================================================================ /// The launcher/store a game was installed from #[derive(Debug, Clone, PartialEq, Eq)] pub enum Launcher { Steam { is_flatpak: bool, is_snap: bool }, Heroic { store: HeroicStore }, Bottles, } impl Launcher { pub fn display_name(&self) -> &'static str { match self { Launcher::Steam { is_flatpak: true, .. } => "Steam (Flatpak)", Launcher::Steam { is_snap: true, .. } => "Steam (Snap)", Launcher::Steam { .. } => "Steam", Launcher::Heroic { store: HeroicStore::GOG } => "Heroic (GOG)", Launcher::Heroic { store: HeroicStore::Epic } => "Heroic (Epic)", Launcher::Bottles => "Bottles", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HeroicStore { GOG, Epic, } /// A detected game installation #[derive(Debug, Clone)] pub struct Game { pub name: String, pub app_id: String, pub install_path: PathBuf, pub prefix_path: Option, pub launcher: Launcher, pub my_games_folder: Option, pub appdata_local_folder: Option, pub appdata_roaming_folder: Option, pub registry_path: Option, pub registry_value: Option, } impl Game { pub fn has_prefix(&self) -> bool { self.prefix_path.is_some() } pub fn get_prefix_user_path(&self) -> Option { let prefix = self.prefix_path.as_ref()?; let users_dir = prefix.join("drive_c/users"); if let Ok(entries) = std::fs::read_dir(&users_dir) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); if name != "Public" && name != "root" { return Some(users_dir.join(name)); } } } Some(users_dir.join("steamuser")) } pub fn get_prefix_documents_path(&self) -> Option { self.get_prefix_user_path().map(|p| p.join("Documents")) } pub fn get_prefix_my_games_path(&self) -> Option { let docs = self.get_prefix_documents_path()?; let folder = self.my_games_folder.as_ref()?; Some(docs.join("My Games").join(folder)) } pub fn get_prefix_appdata_local_path(&self) -> Option { let user = self.get_prefix_user_path()?; let folder = self.appdata_local_folder.as_ref()?; Some(user.join("AppData/Local").join(folder)) } pub fn get_prefix_appdata_roaming_path(&self) -> Option { let user = self.get_prefix_user_path()?; let folder = self.appdata_roaming_folder.as_ref()?; Some(user.join("AppData/Roaming").join(folder)) } } // ============================================================================ // Scan Results // ============================================================================ #[derive(Debug, Default)] pub struct GameScanResult { pub games: Vec, pub steam_count: usize, pub heroic_count: usize, pub bottles_count: usize, } impl GameScanResult { pub fn games_with_prefixes(&self) -> impl Iterator { self.games.iter().filter(|g| g.has_prefix()) } pub fn games_by_launcher(&self, launcher_type: &str) -> Vec<&Game> { self.games .iter() .filter(|g| { matches!( (&g.launcher, launcher_type), (Launcher::Steam { .. }, "steam") | (Launcher::Heroic { .. }, "heroic") | (Launcher::Bottles, "bottles") ) }) .collect() } pub fn find_by_name(&self, name: &str) -> Option<&Game> { let name_lower = name.to_lowercase(); self.games .iter() .find(|g| g.name.to_lowercase() == name_lower) } pub fn find_by_app_id(&self, app_id: &str) -> Option<&Game> { self.games.iter().find(|g| g.app_id == app_id) } } // ============================================================================ // Public API // ============================================================================ /// Detect all installed games from all supported launchers. /// /// Games are detected in priority order: Steam -> Heroic (GOG -> Epic) -> Bottles. /// When the same game is found from multiple launchers, the higher-priority /// detection is kept and duplicates are skipped. This ensures registry entries /// (which are shared across storefronts) prefer Steam paths, then GOG, then Epic. pub fn detect_all_games() -> GameScanResult { let mut result = GameScanResult::default(); // Detect in priority order: Steam first, then GOG (via Heroic), then Epic, then Bottles. let steam_games = detect_steam_games(); result.steam_count = steam_games.len(); result.games.extend(steam_games); let heroic_games = detect_heroic_games(); result.heroic_count = heroic_games.len(); result.games.extend(heroic_games); let bottles_games = detect_bottles_games(); result.bottles_count = bottles_games.len(); result.games.extend(bottles_games); // Deduplicate: keep the first occurrence of each game (by registry_path), // which respects the detection order (Steam > GOG > Epic > Bottles). deduplicate_games(&mut result); result } /// Remove duplicate game detections, keeping the first (highest-priority) entry. /// Two games are considered duplicates if they have the same registry_path. fn deduplicate_games(result: &mut GameScanResult) { let mut seen_registry_paths = std::collections::HashSet::new(); result.games.retain(|game| { if let Some(ref reg_path) = game.registry_path { seen_registry_paths.insert(reg_path.clone()) } else { // Games without registry paths are always kept (no risk of conflict) true } }); } /// Detect only Steam games pub fn detect_steam_only() -> GameScanResult { let steam_games = detect_steam_games(); GameScanResult { steam_count: steam_games.len(), games: steam_games, ..Default::default() } }