diff options
Diffstat (limited to 'libs/nak/src/game_finder')
| -rw-r--r-- | libs/nak/src/game_finder/bottles.rs | 147 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/heroic.rs | 282 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/known_games.rs | 386 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/mod.rs | 218 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/registry.rs | 227 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/steam.rs | 253 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/vdf.rs | 254 |
7 files changed, 0 insertions, 1767 deletions
diff --git a/libs/nak/src/game_finder/bottles.rs b/libs/nak/src/game_finder/bottles.rs deleted file mode 100644 index 7d974e8..0000000 --- a/libs/nak/src/game_finder/bottles.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Bottles prefix detection -//! -//! Detects games installed in Bottles prefixes by scanning registry files -//! for known game registry entries. - -use std::fs; -use std::path::{Path, PathBuf}; - -use super::known_games::KNOWN_GAMES; -use super::registry::{read_registry_value, wine_path_to_linux}; -use super::{Game, Launcher}; -use crate::logging::log_info; - -/// Possible Bottles data paths -const BOTTLES_PATHS: &[&str] = &[ - ".local/share/bottles/bottles", - ".var/app/com.usebottles.bottles/data/bottles/bottles", -]; - -/// Detect all games in Bottles prefixes -pub fn detect_bottles_games() -> Vec<Game> { - let mut games = Vec::new(); - let home = match std::env::var("HOME") { - Ok(h) => h, - Err(_) => return games, - }; - - for relative_path in BOTTLES_PATHS { - let bottles_path = PathBuf::from(&home).join(relative_path); - if !bottles_path.exists() { - continue; - } - - log_info(&format!("Found Bottles installation: {}", bottles_path.display())); - - // Scan each bottle - let Ok(entries) = fs::read_dir(&bottles_path) else { - continue; - }; - - for entry in entries.flatten() { - let bottle_path = entry.path(); - if !bottle_path.is_dir() { - continue; - } - - // Each bottle might have games - let bottle_games = scan_bottle(&bottle_path); - games.extend(bottle_games); - } - } - - log_info(&format!("Bottles: Found {} installed games", games.len())); - games -} - -/// Scan a single Bottles bottle for known games -fn scan_bottle(bottle_path: &Path) -> Vec<Game> { - let mut games = Vec::new(); - - // The prefix is directly in the bottle folder (not in pfx subfolder like Steam) - // Check for drive_c to confirm it's a valid Wine prefix - let drive_c = bottle_path.join("drive_c"); - if !drive_c.exists() { - return games; - } - - let bottle_name = bottle_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("Unknown"); - - // Check for each known game by registry entry - for known_game in KNOWN_GAMES { - if let Some(install_path_wine) = - read_registry_value(bottle_path, known_game.registry_path, known_game.registry_value) - { - // Convert Wine path to Linux path - let install_path = match wine_path_to_linux(&install_path_wine) { - Some(p) => p, - None => { - // Try as a relative path within drive_c - if install_path_wine.starts_with("C:") || install_path_wine.starts_with("c:") { - let relative = install_path_wine[2..].replace('\\', "/"); - drive_c.join(relative.trim_start_matches('/')) - } else { - continue; - } - } - }; - - if !install_path.exists() { - continue; - } - - log_info(&format!( - "Found {} in Bottles bottle '{}'", - known_game.name, bottle_name - )); - - games.push(Game { - name: known_game.name.to_string(), - app_id: format!("bottles-{}", known_game.steam_app_id), - install_path, - prefix_path: Some(bottle_path.to_path_buf()), - launcher: Launcher::Bottles, - my_games_folder: known_game.my_games_folder.map(String::from), - appdata_local_folder: known_game.appdata_local_folder.map(String::from), - appdata_roaming_folder: known_game.appdata_roaming_folder.map(String::from), - registry_path: Some(known_game.registry_path.to_string()), - registry_value: Some(known_game.registry_value.to_string()), - }); - } - } - - games -} - -/// Find all Bottles prefixes (for manual prefix selection) -pub fn find_bottles_prefixes() -> Vec<PathBuf> { - let mut prefixes = Vec::new(); - let home = match std::env::var("HOME") { - Ok(h) => h, - Err(_) => return prefixes, - }; - - for relative_path in BOTTLES_PATHS { - let bottles_path = PathBuf::from(&home).join(relative_path); - if !bottles_path.exists() { - continue; - } - - let Ok(entries) = fs::read_dir(&bottles_path) else { - continue; - }; - - for entry in entries.flatten() { - let bottle_path = entry.path(); - // Verify it's a valid Wine prefix - if bottle_path.is_dir() && bottle_path.join("drive_c").exists() { - prefixes.push(bottle_path); - } - } - } - - prefixes -} diff --git a/libs/nak/src/game_finder/heroic.rs b/libs/nak/src/game_finder/heroic.rs deleted file mode 100644 index e80c1e1..0000000 --- a/libs/nak/src/game_finder/heroic.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! Heroic Games Launcher detection -//! -//! Detects games installed via Heroic (GOG and Epic Games). -//! Parses installed.json and GamesConfig/*.json for game and prefix info. - -use std::fs; -use std::path::{Path, PathBuf}; - -use serde::Deserialize; - -use super::known_games::{find_by_epic_id, find_by_gog_id, find_by_title}; -use super::{Game, HeroicStore, Launcher}; -use crate::logging::{log_info, log_warning}; - -/// Possible Heroic configuration paths -const HEROIC_PATHS: &[&str] = &[ - ".config/heroic", // Native - ".var/app/com.heroicgameslauncher.hgl/config/heroic", // Flatpak -]; - -/// Detect all Heroic games -pub fn detect_heroic_games() -> Vec<Game> { - let mut games = Vec::new(); - let home = match std::env::var("HOME") { - Ok(h) => h, - Err(_) => return games, - }; - - for relative_path in HEROIC_PATHS { - let heroic_path = PathBuf::from(&home).join(relative_path); - if !heroic_path.exists() { - continue; - } - - log_info(&format!("Found Heroic installation: {}", heroic_path.display())); - - // Detect GOG games - let gog_games = detect_gog_games(&heroic_path); - games.extend(gog_games); - - // Detect Epic games - let epic_games = detect_epic_games(&heroic_path); - games.extend(epic_games); - } - - log_info(&format!("Heroic: Found {} installed games", games.len())); - games -} - -// ============================================================================ -// GOG Detection -// ============================================================================ - -/// GOG installed game entry from installed.json -#[derive(Debug, Deserialize)] -struct GogInstalledGame { - #[serde(rename = "appName")] - app_name: String, - title: Option<String>, - #[serde(rename = "install_path")] - install_path: Option<String>, - platform: Option<String>, -} - -/// Wrapper for Heroic's GOG installed.json format: {"installed": [...]} -#[derive(Debug, Deserialize)] -struct GogInstalledWrapper { - installed: Vec<GogInstalledGame>, -} - -/// Detect GOG games from Heroic -fn detect_gog_games(heroic_path: &Path) -> Vec<Game> { - let mut games = Vec::new(); - let installed_json = heroic_path.join("gog_store/installed.json"); - - let Ok(content) = fs::read_to_string(&installed_json) else { - return games; - }; - - // Heroic wraps GOG games in {"installed": [...]}, but also handle bare arrays - let installed: Vec<GogInstalledGame> = - if let Ok(wrapper) = serde_json::from_str::<GogInstalledWrapper>(&content) { - wrapper.installed - } else if let Ok(list) = serde_json::from_str::<Vec<GogInstalledGame>>(&content) { - list - } else { - log_warning("Failed to parse Heroic GOG installed.json"); - return games; - }; - - for gog_game in installed { - // Skip non-Windows games (we only care about Wine prefixes) - if gog_game.platform.as_deref() != Some("windows") { - continue; - } - - let Some(install_path_str) = gog_game.install_path else { - continue; - }; - - let install_path = PathBuf::from(&install_path_str); - if !install_path.exists() { - continue; - } - - // Get the game config for Wine prefix info - let prefix_path = get_heroic_game_prefix(heroic_path, &gog_game.app_name); - - // Look up known game info - let known_game = find_by_gog_id(&gog_game.app_name); - - let name = gog_game - .title - .unwrap_or_else(|| gog_game.app_name.clone()); - - games.push(Game { - name, - app_id: gog_game.app_name, - install_path, - prefix_path, - launcher: Launcher::Heroic { - store: HeroicStore::GOG, - }, - my_games_folder: known_game.and_then(|g| g.my_games_folder.map(String::from)), - appdata_local_folder: known_game.and_then(|g| g.appdata_local_folder.map(String::from)), - appdata_roaming_folder: known_game.and_then(|g| g.appdata_roaming_folder.map(String::from)), - registry_path: known_game.map(|g| g.registry_path.to_string()), - registry_value: known_game.map(|g| g.registry_value.to_string()), - }); - } - - games -} - -// ============================================================================ -// Epic Detection -// ============================================================================ - -/// Epic installed game entry from installed.json -#[derive(Debug, Deserialize)] -struct EpicInstalledGame { - #[serde(rename = "app_name")] - app_name: String, - title: Option<String>, - install_path: Option<String>, - platform: Option<String>, - is_installed: Option<bool>, -} - -/// Detect Epic games from Heroic -fn detect_epic_games(heroic_path: &Path) -> Vec<Game> { - let mut games = Vec::new(); - let installed_json = heroic_path.join("store_cache/legendary_library.json"); - - // Also try the older location - let installed_json = if installed_json.exists() { - installed_json - } else { - heroic_path.join("legendaryConfig/legendary/installed.json") - }; - - let Ok(content) = fs::read_to_string(&installed_json) else { - return games; - }; - - // The Epic library format can vary - try parsing as an object with game keys - if let Ok(library) = serde_json::from_str::<serde_json::Value>(&content) { - if let Some(obj) = library.as_object() { - for (app_name, game_data) in obj { - let Some(game_obj) = game_data.as_object() else { - continue; - }; - - // Check if installed - let is_installed = game_obj - .get("is_installed") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !is_installed { - continue; - } - - // Get platform - let platform = game_obj - .get("platform") - .and_then(|v| v.as_str()); - if platform != Some("Windows") && platform != Some("windows") { - continue; - } - - // Get install path - let Some(install_path_str) = game_obj - .get("install_path") - .and_then(|v| v.as_str()) - else { - continue; - }; - - let install_path = PathBuf::from(install_path_str); - if !install_path.exists() { - continue; - } - - // Get title - let title = game_obj - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or(app_name); - - // Look up known game info: try Epic AppName first, then title match - let known_game = find_by_epic_id(app_name) - .or_else(|| find_by_title(title)); - - let name = title.to_string(); - - // Get Wine prefix - let prefix_path = get_heroic_game_prefix(heroic_path, app_name); - - games.push(Game { - name, - app_id: app_name.clone(), - install_path, - prefix_path, - launcher: Launcher::Heroic { - store: HeroicStore::Epic, - }, - my_games_folder: known_game.and_then(|g| g.my_games_folder.map(String::from)), - appdata_local_folder: known_game.and_then(|g| g.appdata_local_folder.map(String::from)), - appdata_roaming_folder: known_game.and_then(|g| g.appdata_roaming_folder.map(String::from)), - registry_path: known_game.map(|g| g.registry_path.to_string()), - registry_value: known_game.map(|g| g.registry_value.to_string()), - }); - } - } - } - - games -} - -// ============================================================================ -// Shared Utilities -// ============================================================================ - -/// Game config entry for Wine settings -#[derive(Debug, Deserialize)] -struct HeroicGameConfig { - #[serde(rename = "winePrefix")] - wine_prefix: Option<String>, - #[serde(rename = "wineVersion")] - wine_version: Option<WineVersion>, -} - -#[derive(Debug, Deserialize)] -struct WineVersion { - bin: Option<String>, - name: Option<String>, - #[serde(rename = "type")] - wine_type: Option<String>, -} - -/// Get the Wine prefix for a Heroic game from its config file -fn get_heroic_game_prefix(heroic_path: &Path, app_name: &str) -> Option<PathBuf> { - let config_path = heroic_path.join(format!("GamesConfig/{}.json", app_name)); - - let content = fs::read_to_string(&config_path).ok()?; - - // The config can be either a direct object or wrapped in the app_name key - let config: serde_json::Value = serde_json::from_str(&content).ok()?; - - // Try to get winePrefix from the object or from a nested object - let wine_prefix = config - .get("winePrefix") - .or_else(|| config.get(app_name).and_then(|v| v.get("winePrefix"))) - .and_then(|v| v.as_str())?; - - let prefix_path = PathBuf::from(wine_prefix); - if prefix_path.exists() { - Some(prefix_path) - } else { - None - } -} diff --git a/libs/nak/src/game_finder/known_games.rs b/libs/nak/src/game_finder/known_games.rs deleted file mode 100644 index 117b0c7..0000000 --- a/libs/nak/src/game_finder/known_games.rs +++ /dev/null @@ -1,386 +0,0 @@ -//! Known games configuration -//! -//! Contains metadata for games that NaK supports, including: -//! - Steam App ID -//! - GOG App ID -//! - Epic Games Store App Name (Legendary/Heroic internal identifier) -//! - My Games folder name (Documents/My Games/*) -//! - AppData/Local folder name -//! - Registry path for game detection - -/// Configuration for a known game -#[derive(Debug, Clone)] -pub struct KnownGame { - /// Display name - pub name: &'static str, - /// Steam App ID - pub steam_app_id: &'static str, - /// GOG App ID (if available on GOG) - pub gog_app_id: Option<&'static str>, - /// Epic Games Store AppName / Legendary internal ID (if available on Epic) - pub epic_app_id: Option<&'static str>, - /// Folder name in Documents/My Games (if applicable) - pub my_games_folder: Option<&'static str>, - /// Folder name in AppData/Local (if applicable) - pub appdata_local_folder: Option<&'static str>, - /// Folder name in AppData/Roaming (if applicable) - pub appdata_roaming_folder: Option<&'static str>, - /// Registry path under HKLM\Software\ (for game detection) - pub registry_path: &'static str, - /// Registry value name for install path - pub registry_value: &'static str, - /// Expected folder name in steamapps/common/ - pub steam_folder: &'static str, -} - -/// All known games that NaK supports -pub const KNOWN_GAMES: &[KnownGame] = &[ - // Bethesda Games - KnownGame { - name: "Enderal", - steam_app_id: "933480", - gog_app_id: Some("1708684988"), // Enderal: Forgotten Stories on GOG - epic_app_id: None, - my_games_folder: Some("Enderal"), - appdata_local_folder: None, - appdata_roaming_folder: None, - registry_path: r"Software\SureAI\Enderal", - registry_value: "Install_Path", - steam_folder: "Enderal", - }, - KnownGame { - name: "Enderal Special Edition", - steam_app_id: "976620", - gog_app_id: None, // Not on GOG (only original Enderal) - epic_app_id: None, - my_games_folder: Some("Enderal Special Edition"), - appdata_local_folder: None, - appdata_roaming_folder: None, - registry_path: r"Software\SureAI\Enderal SE", - registry_value: "installed path", - steam_folder: "Enderal Special Edition", - }, - KnownGame { - name: "Fallout 3", - steam_app_id: "22300", - gog_app_id: Some("1454315831"), // Fallout 3 GOTY - epic_app_id: Some("adeae8bbfc94427db57c7dfecce3f1d4"), - my_games_folder: Some("Fallout3"), - appdata_local_folder: Some("Fallout3"), - appdata_roaming_folder: None, - registry_path: r"Software\Bethesda Softworks\Fallout3", - registry_value: "Installed Path", - steam_folder: "Fallout 3", - }, - KnownGame { - name: "Fallout 4", - steam_app_id: "377160", - gog_app_id: Some("1998527297"), // Fallout 4 GOTY on GOG - epic_app_id: Some("61d52ce4d09d41e48800c22784d13ae8"), - my_games_folder: Some("Fallout4"), - appdata_local_folder: Some("Fallout4"), - appdata_roaming_folder: None, - registry_path: r"Software\Bethesda Softworks\Fallout4", - registry_value: "Installed Path", - steam_folder: "Fallout 4", - }, - KnownGame { - name: "Fallout 4 VR", - steam_app_id: "611660", - gog_app_id: None, - epic_app_id: None, - my_games_folder: Some("Fallout4VR"), - appdata_local_folder: None, - appdata_roaming_folder: None, - registry_path: r"Software\Bethesda Softworks\Fallout 4 VR", - registry_value: "Installed Path", - steam_folder: "Fallout 4 VR", - }, - KnownGame { - name: "Fallout New Vegas", - steam_app_id: "22380", - gog_app_id: Some("1454587428"), // Fallout NV Ultimate - epic_app_id: Some("5daeb974a22a435988892319b3a4f476"), - my_games_folder: Some("FalloutNV"), - appdata_local_folder: Some("FalloutNV"), - appdata_roaming_folder: None, - registry_path: r"Software\Bethesda Softworks\FalloutNV", - registry_value: "Installed Path", - steam_folder: "Fallout New Vegas", - }, - KnownGame { - name: "Morrowind", - steam_app_id: "22320", - gog_app_id: Some("1440163901"), // Morrowind GOTY - epic_app_id: None, // On Epic but internal AppName not yet known - my_games_folder: Some("Morrowind"), - appdata_local_folder: None, - appdata_roaming_folder: None, - registry_path: r"Software\Bethesda Softworks\Morrowind", - registry_value: "Installed Path", - steam_folder: "Morrowind", - }, - KnownGame { - name: "Oblivion", - steam_app_id: "22330", - gog_app_id: Some("1458058109"), // Oblivion GOTY Deluxe - epic_app_id: None, // On Epic but internal AppName not yet known - my_games_folder: Some("Oblivion"), - appdata_local_folder: Some("Oblivion"), - appdata_roaming_folder: None, - registry_path: r"Software\Bethesda Softworks\Oblivion", - registry_value: "Installed Path", - steam_folder: "Oblivion", - }, - KnownGame { - name: "Skyrim", - steam_app_id: "72850", - gog_app_id: None, // Not on GOG (only SE/AE) - epic_app_id: None, // Not on Epic (only SE/AE) - my_games_folder: Some("Skyrim"), - appdata_local_folder: Some("Skyrim"), - appdata_roaming_folder: None, - registry_path: r"Software\Bethesda Softworks\Skyrim", - registry_value: "Installed Path", - steam_folder: "Skyrim", - }, - KnownGame { - name: "Skyrim Special Edition", - steam_app_id: "489830", - gog_app_id: Some("1711230643"), // Skyrim SE on GOG - epic_app_id: Some("ac82db5035584c7f8a2c548d98c86b2c"), - my_games_folder: Some("Skyrim Special Edition"), - appdata_local_folder: Some("Skyrim Special Edition"), - appdata_roaming_folder: None, - registry_path: r"Software\Bethesda Softworks\Skyrim Special Edition", - registry_value: "Installed Path", - steam_folder: "Skyrim Special Edition", - }, - KnownGame { - name: "Skyrim VR", - steam_app_id: "611670", - gog_app_id: None, - epic_app_id: None, - my_games_folder: Some("Skyrim VR"), - appdata_local_folder: None, - appdata_roaming_folder: None, - registry_path: r"Software\Bethesda Softworks\Skyrim VR", - registry_value: "Installed Path", - steam_folder: "Skyrim VR", - }, - KnownGame { - name: "Starfield", - steam_app_id: "1716740", - gog_app_id: None, // Xbox/Steam exclusive - epic_app_id: None, - my_games_folder: Some("Starfield"), - appdata_local_folder: None, - appdata_roaming_folder: None, - registry_path: r"Software\Bethesda Softworks\Starfield", - registry_value: "Installed Path", - steam_folder: "Starfield", - }, - // CD Projekt RED Games - KnownGame { - name: "The Witcher 3", - steam_app_id: "292030", - gog_app_id: Some("1495134320"), // Witcher 3 GOTY - epic_app_id: None, - my_games_folder: Some("The Witcher 3"), - appdata_local_folder: None, - appdata_roaming_folder: None, - registry_path: r"Software\CD Projekt Red\The Witcher 3", - registry_value: "InstallFolder", - steam_folder: "The Witcher 3 Wild Hunt", - }, - KnownGame { - name: "Cyberpunk 2077", - steam_app_id: "1091500", - gog_app_id: Some("1423049311"), - epic_app_id: None, - my_games_folder: None, - appdata_local_folder: Some("CD Projekt Red/Cyberpunk 2077"), - appdata_roaming_folder: None, - registry_path: r"Software\CD Projekt Red\Cyberpunk 2077", - registry_value: "InstallFolder", - steam_folder: "Cyberpunk 2077", - }, - // Other popular moddable games - KnownGame { - name: "Baldur's Gate 3", - steam_app_id: "1086940", - gog_app_id: Some("1456460669"), - epic_app_id: None, - my_games_folder: None, - appdata_local_folder: Some("Larian Studios/Baldur's Gate 3"), - appdata_roaming_folder: None, - registry_path: r"Software\Larian Studios\Baldur's Gate 3", - registry_value: "InstallDir", - steam_folder: "Baldurs Gate 3", - }, -]; - -/// Alternative GOG App IDs that should map to the same game. -/// Some games have multiple GOG product IDs (e.g. different editions). -const GOG_ID_ALIASES: &[(&str, &str)] = &[ - // Morrowind: Vortex uses 1435828767, NaK primary is 1440163901 - ("1435828767", "1440163901"), - // Skyrim Anniversary Edition is a separate GOG product but same game as SE - ("1801825368", "1711230643"), -]; - -/// Find a known game by Steam App ID -pub fn find_by_steam_id(app_id: &str) -> Option<&'static KnownGame> { - let normalized_id = normalize_steam_id(app_id); - KNOWN_GAMES.iter().find(|g| g.steam_app_id == normalized_id) -} - -/// Find a known game by GOG App ID -pub fn find_by_gog_id(app_id: &str) -> Option<&'static KnownGame> { - // Direct match first - if let Some(game) = KNOWN_GAMES.iter().find(|g| g.gog_app_id == Some(app_id)) { - return Some(game); - } - // Check aliases: resolve alternate GOG ID to primary, then look up - for &(alias, primary) in GOG_ID_ALIASES { - if app_id == alias { - return KNOWN_GAMES.iter().find(|g| g.gog_app_id == Some(primary)); - } - } - None -} - -/// Find a known game by Epic Games Store AppName (Legendary/Heroic internal ID) -pub fn find_by_epic_id(app_id: &str) -> Option<&'static KnownGame> { - KNOWN_GAMES - .iter() - .find(|g| g.epic_app_id == Some(app_id)) -} - -/// Strip punctuation and collapse whitespace for fuzzy title matching. -fn normalize_for_matching(s: &str) -> String { - s.chars() - .map(|c| if c.is_alphanumeric() || c == ' ' { c } else { ' ' }) - .collect::<String>() - .split_whitespace() - .collect::<Vec<_>>() - .join(" ") -} - -/// Find a known game by title, using fuzzy matching. -/// Strips common suffixes like "Game of the Year Edition" for comparison. -pub fn find_by_title(title: &str) -> Option<&'static KnownGame> { - let title_lower = title.to_lowercase(); - - // Exact match first - if let Some(game) = KNOWN_GAMES.iter().find(|g| g.name.to_lowercase() == title_lower) { - return Some(game); - } - - // Check if the title starts with or contains a known game name - // Sort by name length descending so "Skyrim Special Edition" matches before "Skyrim" - let mut games_by_name_len: Vec<&KnownGame> = KNOWN_GAMES.iter().collect(); - games_by_name_len.sort_by(|a, b| b.name.len().cmp(&a.name.len())); - - // Normalize both title and game names: strip colons and extra whitespace for comparison - let title_normalized = normalize_for_matching(&title_lower); - - for game in games_by_name_len { - let game_lower = game.name.to_lowercase(); - let game_normalized = normalize_for_matching(&game_lower); - - // Direct containment (e.g. "Fallout 3: Game of the Year Edition" contains "Fallout 3") - if title_lower.contains(&game_lower) { - return Some(game); - } - - // Normalized containment (e.g. "Fallout: New Vegas" normalized to "fallout new vegas" - // matches game name "Fallout New Vegas" normalized to "fallout new vegas") - if title_normalized.contains(&game_normalized) { - return Some(game); - } - - // After-colon match: "The Elder Scrolls III: Morrowind" -> check "Morrowind" - if let Some(after_colon) = title_lower.split(':').nth(1) { - let after_colon = after_colon.trim(); - if after_colon.starts_with(&game_lower) { - return Some(game); - } - } - } - - None -} - -/// Find a known game by name (case-insensitive) -pub fn find_by_name(name: &str) -> Option<&'static KnownGame> { - let name_lower = name.to_lowercase(); - KNOWN_GAMES - .iter() - .find(|g| g.name.to_lowercase() == name_lower) -} - -/// Normalize Steam App IDs that have equivalent variants. -fn normalize_steam_id(app_id: &str) -> &str { - match app_id { - // Fallout 3 often appears as GOTY App ID 22370. - // We treat it as Fallout 3 for shared metadata/registry mapping. - "22370" => "22300", - _ => app_id, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fallout_3_goty_alias_maps_to_fallout_3() { - let game = find_by_steam_id("22370").expect("22370 should map to Fallout 3"); - assert_eq!(game.name, "Fallout 3"); - assert_eq!(game.steam_app_id, "22300"); - } - - #[test] - fn find_by_epic_id_works() { - let game = find_by_epic_id("ac82db5035584c7f8a2c548d98c86b2c") - .expect("Should find Skyrim SE by Epic ID"); - assert_eq!(game.name, "Skyrim Special Edition"); - } - - #[test] - fn gog_alias_maps_correctly() { - // Skyrim Anniversary Edition GOG ID maps to Skyrim SE - let game = find_by_gog_id("1801825368") - .expect("Skyrim AE GOG ID should map to Skyrim SE"); - assert_eq!(game.name, "Skyrim Special Edition"); - } - - #[test] - fn find_by_title_matches_full_titles() { - let game = find_by_title("The Elder Scrolls III: Morrowind Game of the Year Edition") - .expect("Should find Morrowind by full Epic title"); - assert_eq!(game.name, "Morrowind"); - } - - #[test] - fn find_by_title_matches_colon_titles() { - let game = find_by_title("The Elder Scrolls IV: Oblivion Game of the Year Edition") - .expect("Should find Oblivion by full title"); - assert_eq!(game.name, "Oblivion"); - } - - #[test] - fn find_by_title_prefers_longer_match() { - let game = find_by_title("Skyrim Special Edition") - .expect("Should find Skyrim SE, not Skyrim"); - assert_eq!(game.name, "Skyrim Special Edition"); - } - - #[test] - fn find_by_title_fallout_nv() { - let game = find_by_title("Fallout: New Vegas Ultimate Edition") - .expect("Should find Fallout NV"); - assert_eq!(game.name, "Fallout New Vegas"); - } -} diff --git a/libs/nak/src/game_finder/mod.rs b/libs/nak/src/game_finder/mod.rs deleted file mode 100644 index 600751a..0000000 --- a/libs/nak/src/game_finder/mod.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! 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<PathBuf>, - pub launcher: Launcher, - pub my_games_folder: Option<String>, - pub appdata_local_folder: Option<String>, - pub appdata_roaming_folder: Option<String>, - pub registry_path: Option<String>, - pub registry_value: Option<String>, -} - -impl Game { - pub fn has_prefix(&self) -> bool { - self.prefix_path.is_some() - } - - pub fn get_prefix_user_path(&self) -> Option<PathBuf> { - 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<PathBuf> { - self.get_prefix_user_path().map(|p| p.join("Documents")) - } - - pub fn get_prefix_my_games_path(&self) -> Option<PathBuf> { - 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<PathBuf> { - 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<PathBuf> { - 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<Game>, - pub steam_count: usize, - pub heroic_count: usize, - pub bottles_count: usize, -} - -impl GameScanResult { - pub fn games_with_prefixes(&self) -> impl Iterator<Item = &Game> { - 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() - } -} diff --git a/libs/nak/src/game_finder/registry.rs b/libs/nak/src/game_finder/registry.rs deleted file mode 100644 index e843b6e..0000000 --- a/libs/nak/src/game_finder/registry.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Wine registry parsing utilities -//! -//! Parses Wine registry files (system.reg, user.reg) to find game install paths. - -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::logging::log_warning; - -/// Read a registry value from a Wine prefix registry file -/// -/// # Arguments -/// * `prefix_path` - Path to the Wine prefix (containing system.reg, user.reg) -/// * `key_path` - Registry key path (e.g., "Software\\Bethesda Softworks\\Skyrim") -/// * `value_name` - Name of the value to read (e.g., "Installed Path") -/// -/// Returns the value as a string, or None if not found -pub fn read_registry_value( - prefix_path: &Path, - key_path: &str, - value_name: &str, -) -> Option<String> { - // Try system.reg first (HKEY_LOCAL_MACHINE) - let system_reg = prefix_path.join("system.reg"); - if let Some(value) = read_value_from_reg_file(&system_reg, key_path, value_name) { - return Some(value); - } - - // Try user.reg (HKEY_CURRENT_USER) - let user_reg = prefix_path.join("user.reg"); - if let Some(value) = read_value_from_reg_file(&user_reg, key_path, value_name) { - return Some(value); - } - - None -} - -/// Read a value from a specific .reg file -fn read_value_from_reg_file(reg_file: &Path, key_path: &str, value_name: &str) -> Option<String> { - let content = fs::read_to_string(reg_file).ok()?; - - // Convert the key path to Wine's format - // Wine uses lowercase keys with escaped backslashes - // e.g., [Software\\Bethesda Softworks\\Skyrim] becomes [software\\\\bethesda softworks\\\\skyrim] - let wine_key = format!( - "[{}]", - key_path.to_lowercase().replace('\\', "\\\\") - ); - - // Also try with the Wow6432Node variant for 32-bit apps on 64-bit Wine - let wine_key_wow64 = format!( - "[software\\\\wow6432node\\\\{}]", - key_path - .strip_prefix("Software\\") - .unwrap_or(key_path) - .to_lowercase() - .replace('\\', "\\\\") - ); - - // Find the key section and extract the value - for key_to_find in [&wine_key, &wine_key_wow64] { - if let Some(value) = find_value_in_content(&content, key_to_find, value_name) { - return Some(value); - } - } - - None -} - -/// Find a value within registry file content -fn find_value_in_content(content: &str, key: &str, value_name: &str) -> Option<String> { - let mut in_target_key = false; - let value_name_lower = value_name.to_lowercase(); - - for line in content.lines() { - let trimmed = line.trim(); - - // Check for key header - if trimmed.starts_with('[') && trimmed.ends_with(']') { - in_target_key = trimmed.to_lowercase() == key.to_lowercase(); - continue; - } - - // If we're in the target key, look for the value - if in_target_key { - // Empty line or new section means we've left the key - if trimmed.is_empty() { - continue; - } - if trimmed.starts_with('[') { - break; - } - - // Parse value line: "ValueName"="value" or @="default value" - if let Some((name, value)) = parse_reg_value_line(trimmed) { - if name.to_lowercase() == value_name_lower { - return Some(value); - } - } - } - } - - None -} - -/// Parse a registry value line like "ValueName"="value" -fn parse_reg_value_line(line: &str) -> Option<(String, String)> { - // Format: "name"="value" or "name"=dword:00000000 or @="default" - let line = line.trim(); - - // Find the = separator - let eq_pos = line.find('=')?; - let (name_part, value_part) = line.split_at(eq_pos); - let value_part = &value_part[1..]; // Skip the '=' - - // Extract name (remove quotes) - let name = if name_part == "@" { - "@".to_string() - } else { - name_part.trim().trim_matches('"').to_string() - }; - - // Extract value - let value = if value_part.starts_with('"') { - // String value - need to handle escapes - parse_quoted_reg_value(value_part)? - } else if value_part.starts_with("dword:") { - // DWORD value - convert to decimal string - let hex = value_part.strip_prefix("dword:")?; - let num = u32::from_str_radix(hex, 16).ok()?; - num.to_string() - } else { - // Other types - return as-is - value_part.to_string() - }; - - Some((name, value)) -} - -/// Parse a quoted registry value, handling Wine's escape sequences -fn parse_quoted_reg_value(s: &str) -> Option<String> { - let s = s.trim(); - if !s.starts_with('"') { - return None; - } - - let mut result = String::new(); - let chars = s[1..].chars(); - let mut prev_was_backslash = false; - - for c in chars { - if prev_was_backslash { - match c { - 'n' => result.push('\n'), - 'r' => result.push('\r'), - 't' => result.push('\t'), - '\\' => result.push('\\'), - '"' => result.push('"'), - _ => { - result.push('\\'); - result.push(c); - } - } - prev_was_backslash = false; - } else if c == '\\' { - prev_was_backslash = true; - } else if c == '"' { - break; // End of string - } else { - result.push(c); - } - } - - Some(result) -} - -/// Convert a Wine path (Z:\path\to\file) to a Linux path -pub fn wine_path_to_linux(wine_path: &str) -> Option<PathBuf> { - let path = wine_path.trim(); - - // Handle Z: drive (maps to /) - if path.starts_with("Z:") || path.starts_with("z:") { - let linux_path = path[2..].replace('\\', "/"); - return Some(PathBuf::from(linux_path)); - } - - // Handle C: drive (maps to prefix/drive_c) - // Note: This requires knowing the prefix path, so we can't convert it here - // For now, we'll just return None for C: paths - if path.starts_with("C:") || path.starts_with("c:") { - log_warning(&format!( - "Cannot convert C: drive path without prefix: {}", - path - )); - return None; - } - - None -} - -/// Check if a Wine prefix contains a specific game by registry key -pub fn has_game_registry( - prefix_path: &Path, - registry_path: &str, - registry_value: &str, -) -> bool { - read_registry_value(prefix_path, registry_path, registry_value).is_some() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_reg_value_line() { - let (name, value) = - parse_reg_value_line(r#""Installed Path"="Z:\\mnt\\games\\Skyrim""#).unwrap(); - assert_eq!(name, "Installed Path"); - assert_eq!(value, r"Z:\mnt\games\Skyrim"); - } - - #[test] - fn test_wine_path_to_linux() { - let linux = wine_path_to_linux(r"Z:\mnt\games\Skyrim").unwrap(); - assert_eq!(linux, PathBuf::from("/mnt/games/Skyrim")); - } -} diff --git a/libs/nak/src/game_finder/steam.rs b/libs/nak/src/game_finder/steam.rs deleted file mode 100644 index d403cdf..0000000 --- a/libs/nak/src/game_finder/steam.rs +++ /dev/null @@ -1,253 +0,0 @@ -//! Steam game detection -//! -//! Detects games installed via Steam by parsing appmanifest_*.acf files. -//! Supports native, Flatpak, and Snap Steam installations. - -use std::fs; -use std::path::{Path, PathBuf}; - -use super::known_games::{find_by_steam_id, KnownGame}; -use super::vdf::{parse_library_folders, AppManifest}; -use super::{Game, Launcher}; -use crate::logging::log_info; - -/// All possible Steam installation paths to check -const STEAM_PATHS: &[&str] = &[ - ".local/share/Steam", - ".steam/debian-installation", - ".steam/steam", - ".var/app/com.valvesoftware.Steam/data/Steam", - ".var/app/com.valvesoftware.Steam/.local/share/Steam", - "snap/steam/common/.local/share/Steam", -]; - -/// Detect all Steam games across all installations -pub fn detect_steam_games() -> Vec<Game> { - let mut games = Vec::new(); - let home = match std::env::var("HOME") { - Ok(h) => h, - Err(_) => return games, - }; - - // Find all Steam installations - for steam_info in find_steam_installations(&home) { - let libraries = get_library_folders(&steam_info.path); - - // Collect all steamapps paths so we can search across them for compatdata. - // Steam can place compatdata in a different library folder than the game. - let all_steamapps: Vec<PathBuf> = libraries - .iter() - .map(|l| l.join("steamapps")) - .filter(|s| s.exists()) - .collect(); - - for steamapps in &all_steamapps { - // Scan for appmanifest_*.acf files - let Ok(entries) = fs::read_dir(steamapps) else { - continue; - }; - - for entry in entries.flatten() { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - - if name.starts_with("appmanifest_") && name.ends_with(".acf") { - if let Some(game) = - parse_appmanifest(&path, steamapps, &all_steamapps, &steam_info) - { - games.push(game); - } - } - } - } - } - - log_info(&format!("Steam: Found {} installed games", games.len())); - games -} - -/// Information about a Steam installation -struct SteamInstallation { - path: PathBuf, - is_flatpak: bool, - is_snap: bool, -} - -/// Find all Steam installations on the system -fn find_steam_installations(home: &str) -> Vec<SteamInstallation> { - let mut installations = Vec::new(); - - for relative_path in STEAM_PATHS { - let full_path = PathBuf::from(home).join(relative_path); - - // Check if this is a valid Steam installation - if full_path.join("steamapps").exists() || full_path.join("steam.pid").exists() { - let is_flatpak = relative_path.contains(".var/app/com.valvesoftware.Steam"); - let is_snap = relative_path.contains("snap/steam"); - - // Avoid duplicates (symlinks can cause the same installation to appear twice) - let canonical = full_path.canonicalize().unwrap_or(full_path.clone()); - if !installations.iter().any(|i: &SteamInstallation| { - i.path.canonicalize().unwrap_or(i.path.clone()) == canonical - }) { - log_info(&format!( - "Found Steam installation: {} (flatpak={}, snap={})", - full_path.display(), - is_flatpak, - is_snap - )); - installations.push(SteamInstallation { - path: full_path, - is_flatpak, - is_snap, - }); - } - } - } - - installations -} - -/// Get all library folders for a Steam installation -fn get_library_folders(steam_path: &Path) -> Vec<PathBuf> { - let mut folders = Vec::new(); - - // The Steam installation directory itself is always a library - folders.push(steam_path.to_path_buf()); - - // Parse libraryfolders.vdf for additional libraries - let vdf_path = steam_path.join("steamapps/libraryfolders.vdf"); - if let Ok(content) = fs::read_to_string(&vdf_path) { - for path_str in parse_library_folders(&content) { - let path = PathBuf::from(&path_str); - if path.exists() && !folders.contains(&path) { - folders.push(path); - } - } - } - - // Also check the older config/libraryfolders.vdf location - let old_vdf_path = steam_path.join("config/libraryfolders.vdf"); - if old_vdf_path != vdf_path { - if let Ok(content) = fs::read_to_string(&old_vdf_path) { - for path_str in parse_library_folders(&content) { - let path = PathBuf::from(&path_str); - if path.exists() && !folders.contains(&path) { - folders.push(path); - } - } - } - } - - folders -} - -/// Parse an appmanifest_*.acf file and create a Game struct -fn parse_appmanifest( - manifest_path: &Path, - steamapps_path: &Path, - all_steamapps: &[PathBuf], - steam_info: &SteamInstallation, -) -> Option<Game> { - let content = fs::read_to_string(manifest_path).ok()?; - let manifest = AppManifest::from_vdf(&content)?; - - // Only consider fully installed games - if !manifest.is_installed() { - return None; - } - - // Build the install path - let install_path = steamapps_path.join("common").join(&manifest.install_dir); - if !install_path.exists() { - return None; - } - - // Search ALL library folders for the compatdata — Steam can place it in a - // different library than the game itself (e.g. game on SD card, compatdata - // on internal storage or vice-versa). - let prefix_path = all_steamapps - .iter() - .map(|sa| sa.join("compatdata").join(&manifest.app_id).join("pfx")) - .find(|p| p.exists()); - - // Look up known game info - let known_game = find_by_steam_id(&manifest.app_id); - - Some(Game { - name: manifest.name, - app_id: manifest.app_id, - install_path, - prefix_path, - launcher: Launcher::Steam { - is_flatpak: steam_info.is_flatpak, - is_snap: steam_info.is_snap, - }, - my_games_folder: known_game.and_then(|g| g.my_games_folder.map(String::from)), - appdata_local_folder: known_game.and_then(|g| g.appdata_local_folder.map(String::from)), - appdata_roaming_folder: known_game.and_then(|g| g.appdata_roaming_folder.map(String::from)), - registry_path: known_game.map(|g| g.registry_path.to_string()), - registry_value: known_game.map(|g| g.registry_value.to_string()), - }) -} - -/// Find the installation path for a specific Steam game by App ID -pub fn find_game_install_path(app_id: &str) -> Option<PathBuf> { - let home = std::env::var("HOME").ok()?; - - for steam_info in find_steam_installations(&home) { - let libraries = get_library_folders(&steam_info.path); - - for library_path in libraries { - let manifest_path = library_path - .join("steamapps") - .join(format!("appmanifest_{}.acf", app_id)); - - if manifest_path.exists() { - let content = fs::read_to_string(&manifest_path).ok()?; - let manifest = AppManifest::from_vdf(&content)?; - - if manifest.is_installed() { - let install_path = library_path - .join("steamapps/common") - .join(&manifest.install_dir); - - if install_path.exists() { - return Some(install_path); - } - } - } - } - } - - None -} - -/// Find the Wine prefix for a specific Steam game by App ID -pub fn find_game_prefix_path(app_id: &str) -> Option<PathBuf> { - let home = std::env::var("HOME").ok()?; - - for steam_info in find_steam_installations(&home) { - let libraries = get_library_folders(&steam_info.path); - - for library_path in libraries { - let prefix_path = library_path - .join("steamapps/compatdata") - .join(app_id) - .join("pfx"); - - if prefix_path.exists() { - return Some(prefix_path); - } - } - } - - None -} - -/// Get the known game configuration for a Steam App ID -pub fn get_known_game(app_id: &str) -> Option<&'static KnownGame> { - find_by_steam_id(app_id) -} diff --git a/libs/nak/src/game_finder/vdf.rs b/libs/nak/src/game_finder/vdf.rs deleted file mode 100644 index c296f27..0000000 --- a/libs/nak/src/game_finder/vdf.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! Hand-rolled VDF (Valve Data Format) parser -//! -//! Parses VDF files like appmanifest_*.acf and libraryfolders.vdf -//! without external dependencies. - -use std::collections::HashMap; - -/// A VDF value - either a string or a nested object -#[derive(Debug, Clone)] -pub enum VdfValue { - String(String), - Object(HashMap<String, VdfValue>), -} - -impl VdfValue { - /// Get as string reference - pub fn as_str(&self) -> Option<&str> { - match self { - VdfValue::String(s) => Some(s), - VdfValue::Object(_) => None, - } - } - - /// Get as object reference - pub fn as_object(&self) -> Option<&HashMap<String, VdfValue>> { - match self { - VdfValue::String(_) => None, - VdfValue::Object(o) => Some(o), - } - } - - /// Get a nested value by key - pub fn get(&self, key: &str) -> Option<&VdfValue> { - self.as_object()?.get(key) - } - - /// Get a string value by key - pub fn get_str(&self, key: &str) -> Option<&str> { - self.get(key)?.as_str() - } -} - -/// Parse a VDF file content into a root object -pub fn parse_vdf(content: &str) -> Option<VdfValue> { - let mut chars = content.chars().peekable(); - parse_object(&mut chars) -} - -/// Parse an object (including the root level) -fn parse_object<I: Iterator<Item = char>>(chars: &mut std::iter::Peekable<I>) -> Option<VdfValue> { - let mut map = HashMap::new(); - - loop { - skip_whitespace_and_comments(chars); - - match chars.peek() { - None => break, - Some('}') => { - chars.next(); - break; - } - Some('"') => { - // Parse key - let key = parse_quoted_string(chars)?; - skip_whitespace_and_comments(chars); - - // Check what follows - string value or object - match chars.peek() { - Some('"') => { - // String value - let value = parse_quoted_string(chars)?; - map.insert(key, VdfValue::String(value)); - } - Some('{') => { - // Object value - chars.next(); // consume '{' - let value = parse_object(chars)?; - map.insert(key, value); - } - _ => return None, // Unexpected token - } - } - _ => { - // Skip unexpected characters - chars.next(); - } - } - } - - Some(VdfValue::Object(map)) -} - -/// Parse a quoted string "..." -fn parse_quoted_string<I: Iterator<Item = char>>( - chars: &mut std::iter::Peekable<I>, -) -> Option<String> { - // Expect opening quote - if chars.next() != Some('"') { - return None; - } - - let mut result = String::new(); - - loop { - match chars.next() { - None => return None, // Unterminated string - Some('"') => break, - Some('\\') => { - // Handle escape sequences - match chars.next() { - Some('n') => result.push('\n'), - Some('t') => result.push('\t'), - Some('\\') => result.push('\\'), - Some('"') => result.push('"'), - Some(c) => { - result.push('\\'); - result.push(c); - } - None => return None, - } - } - Some(c) => result.push(c), - } - } - - Some(result) -} - -/// Skip whitespace and // comments -fn skip_whitespace_and_comments<I: Iterator<Item = char>>(chars: &mut std::iter::Peekable<I>) { - loop { - // Skip whitespace - while chars.peek().is_some_and(|c| c.is_whitespace()) { - chars.next(); - } - - // Check for // comment - // Need to peek ahead without cloning - consume first / and check second - if chars.peek() == Some(&'/') { - // Consume first / - chars.next(); - if chars.peek() == Some(&'/') { - // It's a comment - skip to end of line - chars.next(); - while chars.peek().is_some_and(|c| *c != '\n') { - chars.next(); - } - continue; - } - // Not a comment, but we consumed a '/' - this shouldn't happen in valid VDF - // Just continue processing - } - - break; - } -} - -/// Parse an appmanifest_*.acf file and extract app info -#[derive(Debug, Clone)] -pub struct AppManifest { - pub app_id: String, - pub name: String, - pub install_dir: String, - pub state_flags: u32, -} - -impl AppManifest { - /// Parse from VDF content - pub fn from_vdf(content: &str) -> Option<Self> { - let root = parse_vdf(content)?; - let app_state = root.get("AppState")?; - - Some(Self { - app_id: app_state.get_str("appid")?.to_string(), - name: app_state.get_str("name")?.to_string(), - install_dir: app_state.get_str("installdir")?.to_string(), - state_flags: app_state.get_str("StateFlags")?.parse().unwrap_or(0), - }) - } - - /// Check if the game is fully installed (StateFlags == 4) - pub fn is_installed(&self) -> bool { - self.state_flags == 4 - } -} - -/// Parse libraryfolders.vdf and extract library paths -pub fn parse_library_folders(content: &str) -> Vec<String> { - let mut paths = Vec::new(); - - let Some(root) = parse_vdf(content) else { - return paths; - }; - - let Some(library_folders) = root.get("libraryfolders").and_then(|v| v.as_object()) else { - return paths; - }; - - // Library folders are keyed by index: "0", "1", "2", etc. - for value in library_folders.values() { - if let Some(path) = value.get_str("path") { - paths.push(path.to_string()); - } - } - - paths -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_appmanifest() { - let content = r#" -"AppState" -{ - "appid" "489830" - "Universe" "1" - "name" "Skyrim Special Edition" - "StateFlags" "4" - "installdir" "Skyrim Special Edition" -} -"#; - let manifest = AppManifest::from_vdf(content).unwrap(); - assert_eq!(manifest.app_id, "489830"); - assert_eq!(manifest.name, "Skyrim Special Edition"); - assert_eq!(manifest.install_dir, "Skyrim Special Edition"); - assert!(manifest.is_installed()); - } - - #[test] - fn test_parse_library_folders() { - let content = r#" -"libraryfolders" -{ - "0" - { - "path" "/home/user/.local/share/Steam" - "label" "" - } - "1" - { - "path" "/mnt/games/SteamLibrary" - "label" "Games" - } -} -"#; - let paths = parse_library_folders(content); - assert_eq!(paths.len(), 2); - assert!(paths.contains(&"/home/user/.local/share/Steam".to_string())); - assert!(paths.contains(&"/mnt/games/SteamLibrary".to_string())); - } -} |
