aboutsummaryrefslogtreecommitdiff
path: root/libs/nak/src/game_finder
diff options
context:
space:
mode:
Diffstat (limited to 'libs/nak/src/game_finder')
-rw-r--r--libs/nak/src/game_finder/bottles.rs147
-rw-r--r--libs/nak/src/game_finder/heroic.rs282
-rw-r--r--libs/nak/src/game_finder/known_games.rs386
-rw-r--r--libs/nak/src/game_finder/mod.rs218
-rw-r--r--libs/nak/src/game_finder/registry.rs227
-rw-r--r--libs/nak/src/game_finder/steam.rs253
-rw-r--r--libs/nak/src/game_finder/vdf.rs254
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()));
- }
-}