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 | 277 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/known_games.rs | 245 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/mod.rs | 191 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/registry.rs | 227 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/steam.rs | 251 | ||||
| -rw-r--r-- | libs/nak/src/game_finder/vdf.rs | 254 |
7 files changed, 1592 insertions, 0 deletions
diff --git a/libs/nak/src/game_finder/bottles.rs b/libs/nak/src/game_finder/bottles.rs new file mode 100644 index 0000000..7d974e8 --- /dev/null +++ b/libs/nak/src/game_finder/bottles.rs @@ -0,0 +1,147 @@ +//! 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 new file mode 100644 index 0000000..7bdda09 --- /dev/null +++ b/libs/nak/src/game_finder/heroic.rs @@ -0,0 +1,277 @@ +//! 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_gog_id; +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 name = game_obj + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or(app_name) + .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: None, + appdata_local_folder: None, + appdata_roaming_folder: None, + registry_path: None, + registry_value: None, + }); + } + } + } + + 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 new file mode 100644 index 0000000..8a30c18 --- /dev/null +++ b/libs/nak/src/game_finder/known_games.rs @@ -0,0 +1,245 @@ +//! Known games configuration +//! +//! Contains metadata for games that NaK supports, including: +//! - Steam App ID +//! - 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) + pub gog_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: 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, + 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 + 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: None, + 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, + 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 + 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 + 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 + 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 + 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 Anniversary Edition + 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, + 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, + 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 + 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"), + 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"), + 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", + }, +]; + +/// 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> { + KNOWN_GAMES + .iter() + .find(|g| g.gog_app_id == Some(app_id)) +} + +/// 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::find_by_steam_id; + + #[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"); + } +} diff --git a/libs/nak/src/game_finder/mod.rs b/libs/nak/src/game_finder/mod.rs new file mode 100644 index 0000000..c383c8a --- /dev/null +++ b/libs/nak/src/game_finder/mod.rs @@ -0,0 +1,191 @@ +//! 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_gog_id, find_by_name, find_by_steam_id, 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 +pub fn detect_all_games() -> GameScanResult { + let mut result = GameScanResult::default(); + + 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); + + result +} + +/// 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 new file mode 100644 index 0000000..e843b6e --- /dev/null +++ b/libs/nak/src/game_finder/registry.rs @@ -0,0 +1,227 @@ +//! 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 new file mode 100644 index 0000000..b88a0b0 --- /dev/null +++ b/libs/nak/src/game_finder/steam.rs @@ -0,0 +1,251 @@ +//! 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); + + for library_path in libraries { + let steamapps = library_path.join("steamapps"); + if !steamapps.exists() { + continue; + } + + // 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, &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, + 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; + } + + // Build the prefix path + let prefix_path = steamapps_path + .join("compatdata") + .join(&manifest.app_id) + .join("pfx"); + + let prefix_path = if prefix_path.exists() { + Some(prefix_path) + } else { + None + }; + + // 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 new file mode 100644 index 0000000..c296f27 --- /dev/null +++ b/libs/nak/src/game_finder/vdf.rs @@ -0,0 +1,254 @@ +//! 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())); + } +} |
