diff options
Diffstat (limited to 'libs/nak/src')
| -rw-r--r-- | libs/nak/src/config.rs | 154 | ||||
| -rw-r--r-- | libs/nak/src/deps/mod.rs | 177 | ||||
| -rw-r--r-- | libs/nak/src/deps/tools.rs | 174 | ||||
| -rw-r--r-- | libs/nak/src/dxvk.rs | 71 | ||||
| -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 | ||||
| -rw-r--r-- | libs/nak/src/installers/mod.rs | 334 | ||||
| -rw-r--r-- | libs/nak/src/installers/prefix_setup.rs | 778 | ||||
| -rw-r--r-- | libs/nak/src/installers/symlinks.rs | 359 | ||||
| -rw-r--r-- | libs/nak/src/lib.rs | 15 | ||||
| -rw-r--r-- | libs/nak/src/logging.rs | 99 | ||||
| -rw-r--r-- | libs/nak/src/runtime_wrap.rs | 121 | ||||
| -rw-r--r-- | libs/nak/src/steam/mod.rs | 135 | ||||
| -rw-r--r-- | libs/nak/src/steam/paths.rs | 259 | ||||
| -rw-r--r-- | libs/nak/src/steam/proton.rs | 257 | ||||
| -rw-r--r-- | libs/nak/src/utils.rs | 19 |
21 files changed, 4544 insertions, 0 deletions
diff --git a/libs/nak/src/config.rs b/libs/nak/src/config.rs new file mode 100644 index 0000000..62f9fc5 --- /dev/null +++ b/libs/nak/src/config.rs @@ -0,0 +1,154 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +fn get_home() -> String { + std::env::var("HOME").unwrap_or_default() +} + +/// Normalize a path for compatibility with pressure-vessel/Steam container. +/// +/// On Fedora Atomic/Bazzite/Silverblue, $HOME is `/var/home/user` but `/home` +/// is a symlink to `/var/home`. Pressure-vessel exposes `/home` but may not +/// properly handle paths that explicitly use `/var/home/`. This function +/// converts such paths to use `/home/` instead for maximum compatibility. +pub fn normalize_path_for_steam(path: &str) -> String { + // Convert /var/home/user/... to /home/user/... + if let Some(stripped) = path.strip_prefix("/var/home/") { + format!("/home/{}", stripped) + } else { + path.to_string() + } +} + +fn default_data_path() -> String { + format!("{}/NaK", get_home()) +} + +// ============================================================================ +// Main App Config - stored in ~/.config/nak/config.json +// ============================================================================ + +#[derive(Serialize, Deserialize, Clone)] +pub struct AppConfig { + pub selected_proton: Option<String>, + /// Whether the first-run setup has been completed + #[serde(default)] + pub first_run_completed: bool, + /// Path to NaK data folder (legacy ~/NaK - used for migration detection) + #[serde(default = "default_data_path")] + pub data_path: String, + /// Whether the Steam-native migration popup has been shown + #[serde(default)] + pub steam_migration_shown: bool, + /// Custom cache location (for downloads, tmp files during install) + /// If empty/not set, uses ~/.cache/nak/ + #[serde(default)] + pub cache_location: String, + /// Selected Steam account ID (Steam3 format, e.g., "910757758") + /// If empty/not set, uses the most recently active account + #[serde(default)] + pub selected_steam_account: String, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + selected_proton: None, + first_run_completed: false, + data_path: default_data_path(), + steam_migration_shown: false, + cache_location: String::new(), + selected_steam_account: String::new(), + } + } +} + +impl AppConfig { + /// Config file path: ~/.config/nak/config.json + fn get_config_path() -> PathBuf { + PathBuf::from(format!("{}/.config/nak/config.json", get_home())) + } + + /// Legacy config path for migration: ~/NaK/config.json + fn get_legacy_path() -> PathBuf { + PathBuf::from(format!("{}/NaK/config.json", get_home())) + } + + pub fn load() -> Self { + let config_path = Self::get_config_path(); + let legacy_path = Self::get_legacy_path(); + + // Try new location first + if config_path.exists() { + if let Ok(content) = fs::read_to_string(&config_path) { + if let Ok(config) = serde_json::from_str(&content) { + return config; + } + } + } + + // Try legacy location and migrate if found + if legacy_path.exists() { + if let Ok(content) = fs::read_to_string(&legacy_path) { + if let Ok(mut config) = serde_json::from_str::<AppConfig>(&content) { + // Ensure data_path is set (old configs won't have it) + if config.data_path.is_empty() { + config.data_path = default_data_path(); + } + // Save to new location + config.save(); + // Remove old config + let _ = fs::remove_file(&legacy_path); + return config; + } + } + } + + Self::default() + } + + pub fn save(&self) { + let path = Self::get_config_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = fs::write(path, json); + } + } + + /// Get the NaK data directory path (legacy ~/NaK - used for migration detection) + pub fn get_data_path(&self) -> PathBuf { + PathBuf::from(&self.data_path) + } + + /// Get the NaK config directory (~/.config/nak/) + pub fn get_config_dir() -> PathBuf { + PathBuf::from(format!("{}/.config/nak", get_home())) + } + + /// Get the default cache directory (~/.cache/nak/) + pub fn get_default_cache_dir() -> PathBuf { + PathBuf::from(format!("{}/.cache/nak", get_home())) + } + + /// Get the cache directory (custom location or default ~/.cache/nak/) + pub fn get_cache_dir(&self) -> PathBuf { + if self.cache_location.is_empty() { + Self::get_default_cache_dir() + } else { + PathBuf::from(&self.cache_location) + } + } + + /// Get path to tmp directory (~/.cache/nak/tmp/) + pub fn get_tmp_path() -> PathBuf { + Self::get_default_cache_dir().join("tmp") + } + + /// Get path to Prefixes directory (legacy - for migration detection only) + pub fn get_prefixes_path(&self) -> PathBuf { + self.get_data_path().join("Prefixes") + } +} diff --git a/libs/nak/src/deps/mod.rs b/libs/nak/src/deps/mod.rs new file mode 100644 index 0000000..a055eb7 --- /dev/null +++ b/libs/nak/src/deps/mod.rs @@ -0,0 +1,177 @@ +//! Dependency management via winetricks +//! +//! Uses winetricks for all Windows dependency installation. + +pub mod tools; + +use std::error::Error; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::config::AppConfig; +use crate::logging::{log_error, log_install}; +use crate::runtime_wrap; +use crate::steam::SteamProton; + +// Re-export tools +pub use tools::{check_command_available, ensure_cabextract, ensure_winetricks, get_winetricks_path}; + +/// Standard winetricks verbs for MO2 prefix +pub const STANDARD_VERBS: &[&str] = &[ + "vcrun2022", // Visual C++ 2015-2022 Runtime + "dotnet6", // .NET 6.0 + "dotnet7", // .NET 7.0 + "dotnet8", // .NET 8.0 + "dotnetdesktop6", // .NET Desktop Runtime 6.0 + "d3dcompiler_47", // DirectX Compiler 47 + "d3dcompiler_43", // DirectX Compiler 43 + "d3dx9", // DirectX 9 (all versions) + "d3dx11_43", // DirectX 11 + "xact", // XACT Audio (32-bit) + "xact_x64", // XACT Audio (64-bit) +]; + + +/// Run winetricks to install dependencies +pub fn run_winetricks( + prefix_path: &Path, + proton: &SteamProton, + verbs: &[&str], + log_callback: impl Fn(String), +) -> Result<(), Box<dyn Error>> { + if verbs.is_empty() { + return Ok(()); + } + + let winetricks_path = ensure_winetricks()?; + ensure_cabextract()?; + + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found in Proton".into()); + }; + + let Some(wineserver_bin) = proton.wineserver_binary() else { + return Err("Wineserver binary not found in Proton".into()); + }; + + let cache_dir = AppConfig::get_default_cache_dir(); + std::fs::create_dir_all(&cache_dir)?; + + let verbs_str = verbs.join(" "); + log_callback(format!("Installing dependencies via winetricks: {}", verbs_str)); + log_install(&format!("Running winetricks with verbs: {}", verbs_str)); + + let nak_bin = tools::get_nak_bin_path(); + let current_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", nak_bin.display(), current_path); + + let envs: Vec<(&str, String)> = vec![ + ("PATH", new_path), + ("WINE", wine_bin.display().to_string()), + ("WINESERVER", wineserver_bin.display().to_string()), + ("WINEPREFIX", prefix_path.display().to_string()), + ("WINETRICKS_CACHE", cache_dir.display().to_string()), + ]; + let status = runtime_wrap::build_command(&winetricks_path, &envs) + .arg("-q") + .args(verbs) + .status()?; + + if !status.success() { + let err_msg = format!("Winetricks failed with exit code: {:?}", status.code()); + log_error(&err_msg); + return Err(err_msg.into()); + } + + log_install("Winetricks completed successfully"); + Ok(()) +} + +/// Install all standard dependencies to a prefix +pub fn install_standard_deps( + prefix_path: &Path, + proton: &SteamProton, + log_callback: impl Fn(String), +) -> Result<(), Box<dyn Error>> { + run_winetricks(prefix_path, proton, STANDARD_VERBS, log_callback) +} + +/// Run winetricks with cancellation support. +pub fn run_winetricks_cancellable( + prefix_path: &Path, + proton: &SteamProton, + verbs: &[&str], + log_callback: impl Fn(String), + cancel_flag: &Arc<AtomicBool>, +) -> Result<(), Box<dyn Error>> { + if verbs.is_empty() { + return Ok(()); + } + + let winetricks_path = ensure_winetricks()?; + ensure_cabextract()?; + + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found in Proton".into()); + }; + + let Some(wineserver_bin) = proton.wineserver_binary() else { + return Err("Wineserver binary not found in Proton".into()); + }; + + let cache_dir = AppConfig::get_default_cache_dir(); + std::fs::create_dir_all(&cache_dir)?; + + let verbs_str = verbs.join(" "); + log_callback(format!("Installing dependencies via winetricks: {}", verbs_str)); + log_install(&format!("Running winetricks with verbs: {}", verbs_str)); + + let nak_bin = tools::get_nak_bin_path(); + let current_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", nak_bin.display(), current_path); + + let envs: Vec<(&str, String)> = vec![ + ("PATH", new_path), + ("WINE", wine_bin.display().to_string()), + ("WINESERVER", wineserver_bin.display().to_string()), + ("WINEPREFIX", prefix_path.display().to_string()), + ("WINETRICKS_CACHE", cache_dir.display().to_string()), + ]; + let mut child = runtime_wrap::build_command(&winetricks_path, &envs) + .arg("-q") + .args(verbs) + .spawn()?; + + loop { + match child.try_wait()? { + Some(status) => { + if !status.success() { + let err_msg = format!("Winetricks failed with exit code: {:?}", status.code()); + log_error(&err_msg); + return Err(err_msg.into()); + } + log_install("Winetricks completed successfully"); + return Ok(()); + } + None => { + if cancel_flag.load(Ordering::Relaxed) { + let _ = child.kill(); + let _ = child.wait(); + return Err("Cancelled".into()); + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + } +} + +/// Install standard deps with cancellation support +pub fn install_standard_deps_cancellable( + prefix_path: &Path, + proton: &SteamProton, + log_callback: impl Fn(String), + cancel_flag: &Arc<AtomicBool>, +) -> Result<(), Box<dyn Error>> { + run_winetricks_cancellable(prefix_path, proton, STANDARD_VERBS, log_callback, cancel_flag) +} diff --git a/libs/nak/src/deps/tools.rs b/libs/nak/src/deps/tools.rs new file mode 100644 index 0000000..c97247c --- /dev/null +++ b/libs/nak/src/deps/tools.rs @@ -0,0 +1,174 @@ +//! Linux tool management (winetricks, cabextract) +//! +//! Handles downloading and managing Linux CLI tools. +//! Tools are stored in ~/.var/app/com.fluorine.manager/bin/ for Fluorine Manager. + +use std::error::Error; +use std::fs; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::Command; + +use crate::logging::{log_error, log_info, log_warning}; + +// ============================================================================ +// NaK Bin Directory (~/.var/app/com.fluorine.manager/bin/) +// ============================================================================ + +/// Get the tool bin directory path (~/.var/app/com.fluorine.manager/bin/) +/// This is accessible from both native and Flatpak environments. +pub fn get_nak_bin_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".var/app/com.fluorine.manager/bin") +} + +/// Check if a command exists (either in system PATH or tool bin) +pub fn check_command_available(cmd: &str) -> bool { + // Check system PATH first + if Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return true; + } + + // Check tool bin directory + let nak_bin = get_nak_bin_path().join(cmd); + nak_bin.exists() +} + +// ============================================================================ +// Winetricks +// ============================================================================ + +const WINETRICKS_URL: &str = + "https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks"; + +/// Get the path to winetricks (without downloading) +pub fn get_winetricks_path() -> PathBuf { + get_nak_bin_path().join("winetricks") +} + +/// Ensures winetricks is downloaded and up-to-date. +pub fn ensure_winetricks() -> Result<PathBuf, Box<dyn Error>> { + let bin_dir = get_nak_bin_path(); + let winetricks_path = bin_dir.join("winetricks"); + + fs::create_dir_all(&bin_dir)?; + + log_info("Checking for winetricks updates..."); + + match ureq::get(WINETRICKS_URL).call() { + Ok(response) => { + let mut new_content = Vec::new(); + response.into_reader().read_to_end(&mut new_content)?; + + let should_update = if winetricks_path.exists() { + let existing = fs::read(&winetricks_path).unwrap_or_default(); + existing != new_content + } else { + true + }; + + if should_update { + fs::write(&winetricks_path, &new_content)?; + + let mut perms = fs::metadata(&winetricks_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&winetricks_path, perms)?; + + if winetricks_path.exists() { + log_info("Winetricks updated to latest version"); + } else { + log_info(&format!("Winetricks downloaded to {:?}", winetricks_path)); + } + } + } + Err(e) => { + if winetricks_path.exists() { + log_warning(&format!("Failed to check winetricks updates: {}", e)); + } else { + return Err(format!("Failed to download winetricks: {}", e).into()); + } + } + } + + Ok(winetricks_path) +} + +// ============================================================================ +// Cabextract (required by winetricks for DirectX cabs) +// ============================================================================ + +const CABEXTRACT_URL: &str = + "https://github.com/SulfurNitride/NaK/releases/download/Cabextract/cabextract-linux-x86_64.zip"; + +/// Ensures cabextract is available (either system or downloaded). +pub fn ensure_cabextract() -> Result<PathBuf, Box<dyn Error>> { + // First check if system has cabextract + if Command::new("which") + .arg("cabextract") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return Ok(PathBuf::from("cabextract")); + } + + // Check if we already downloaded it + let bin_dir = get_nak_bin_path(); + let cabextract_path = bin_dir.join("cabextract"); + + if cabextract_path.exists() { + return Ok(cabextract_path); + } + + // Download cabextract zip + log_warning("System cabextract not found, downloading..."); + fs::create_dir_all(&bin_dir)?; + + let response = ureq::get(CABEXTRACT_URL).call().map_err(|e| { + format!( + "Failed to download cabextract: {}. Please install cabextract manually.", + e + ) + })?; + + let zip_path = bin_dir.join("cabextract.zip"); + let mut zip_file = fs::File::create(&zip_path)?; + std::io::copy(&mut response.into_reader(), &mut zip_file)?; + + let status = Command::new("unzip") + .arg("-o") + .arg(&zip_path) + .arg("-d") + .arg(&bin_dir) + .status()?; + + if !status.success() { + let _ = Command::new("python3") + .arg("-c") + .arg(format!( + "import zipfile; zipfile.ZipFile('{}').extractall('{}')", + zip_path.display(), + bin_dir.display() + )) + .status(); + } + + let _ = fs::remove_file(&zip_path); + + if cabextract_path.exists() { + let mut perms = fs::metadata(&cabextract_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&cabextract_path, perms)?; + log_info(&format!("cabextract downloaded to {:?}", cabextract_path)); + Ok(cabextract_path) + } else { + log_error("Failed to extract cabextract from zip"); + Err("Failed to extract cabextract from zip".into()) + } +} diff --git a/libs/nak/src/dxvk.rs b/libs/nak/src/dxvk.rs new file mode 100644 index 0000000..97e4222 --- /dev/null +++ b/libs/nak/src/dxvk.rs @@ -0,0 +1,71 @@ +//! DXVK configuration management for Fluorine Manager. +//! +//! Downloads dxvk.conf from upstream, appends Fluorine-specific settings, +//! and stores at `~/.var/app/com.fluorine.manager/config/dxvk.conf`. + +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::logging::{log_info, log_warning}; + +const DXVK_CONF_URL: &str = + "https://raw.githubusercontent.com/doitsujin/dxvk/master/dxvk.conf"; + +const DXVK_CUSTOM_SETTINGS: &str = r#" +# Fluorine Custom Settings +# Disable Graphics Pipeline Library (can cause issues with modded games) +dxvk.enableGraphicsPipelineLibrary = False +"#; + +/// Get the path where the DXVK config will be stored. +pub fn get_dxvk_conf_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home) + .join(".var/app/com.fluorine.manager/config/dxvk.conf") +} + +/// Ensure the dxvk.conf file exists, downloading if necessary. +/// +/// Returns the path to the config file. +pub fn ensure_dxvk_conf() -> Result<PathBuf, Box<dyn Error>> { + let conf_path = get_dxvk_conf_path(); + + // If it already exists, return it + if conf_path.exists() { + return Ok(conf_path); + } + + download_and_create_dxvk_conf(&conf_path) +} + +/// Download the upstream dxvk.conf, append custom settings, and write to `dest`. +pub fn download_and_create_dxvk_conf(dest: &Path) -> Result<PathBuf, Box<dyn Error>> { + // Ensure parent directory exists + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + + log_info("Downloading dxvk.conf from upstream..."); + + let upstream_content = match ureq::get(DXVK_CONF_URL).call() { + Ok(response) => { + let mut body = String::new(); + response.into_reader().read_to_string(&mut body)?; + body + } + Err(e) => { + log_warning(&format!("Failed to download dxvk.conf: {}", e)); + // Create with just custom settings if download fails + String::new() + } + }; + + let full_content = format!("{}\n{}", upstream_content, DXVK_CUSTOM_SETTINGS); + fs::write(dest, &full_content)?; + + log_info(&format!("Created dxvk.conf at {:?}", dest)); + Ok(dest.to_path_buf()) +} + +use std::io::Read as _; 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())); + } +} diff --git a/libs/nak/src/installers/mod.rs b/libs/nak/src/installers/mod.rs new file mode 100644 index 0000000..cf3eb85 --- /dev/null +++ b/libs/nak/src/installers/mod.rs @@ -0,0 +1,334 @@ +//! Mod manager installation logic +//! +//! Stripped for Fluorine: no common.rs, mo2.rs, plugin.rs, compatdata_scanner.rs. + +pub mod symlinks; + +mod prefix_setup; + +pub use prefix_setup::{ + apply_dpi, apply_registry_for_game_path, auto_apply_game_registries, cleanup_prefix_drives, + install_all_dependencies, kill_wineserver, known_game_names, launch_dpi_test_app, DPI_PRESETS, +}; + +use std::error::Error; +use std::fs; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use crate::logging::log_install; +use crate::steam::SteamProton; + +// ============================================================================ +// Shared Types +// ============================================================================ + +/// Context for background installation tasks +#[derive(Clone)] +pub struct TaskContext { + pub status_callback: Arc<dyn Fn(String) + Send + Sync>, + pub log_callback: Arc<dyn Fn(String) + Send + Sync>, + pub progress_callback: Arc<dyn Fn(f32) + Send + Sync>, + pub cancel_flag: Arc<AtomicBool>, +} + +impl TaskContext { + pub fn new( + status: impl Fn(String) + Send + Sync + 'static, + log: impl Fn(String) + Send + Sync + 'static, + progress: impl Fn(f32) + Send + Sync + 'static, + cancel: Arc<AtomicBool>, + ) -> Self { + Self { + status_callback: Arc::new(status), + log_callback: Arc::new(log), + progress_callback: Arc::new(progress), + cancel_flag: cancel, + } + } + + pub fn set_status(&self, msg: String) { + (self.status_callback)(msg); + } + + pub fn log(&self, msg: String) { + (self.log_callback)(msg); + } + + pub fn set_progress(&self, p: f32) { + (self.progress_callback)(p); + } + + pub fn is_cancelled(&self) -> bool { + self.cancel_flag.load(std::sync::atomic::Ordering::Relaxed) + } + + /// Run a command that can be killed if the user cancels. + pub fn run_cancellable(&self, mut cmd: std::process::Command) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> { + let mut child = cmd.spawn()?; + + loop { + match child.try_wait()? { + Some(status) => return Ok(status), + None => { + if self.is_cancelled() { + let _ = child.kill(); + let _ = child.wait(); + return Err("Cancelled".into()); + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + } + } +} + +// ============================================================================ +// Shared Wine Registry Settings +// ============================================================================ + +/// Wine registry settings +pub const WINE_SETTINGS_REG: &str = r#"Windows Registry Editor Version 5.00 + +[HKEY_CURRENT_USER\Software\Wine\DllOverrides] +"dwrite.dll"="native,builtin" +"dwrite"="native,builtin" +"winmm.dll"="native,builtin" +"winmm"="native,builtin" +"version.dll"="native,builtin" +"version"="native,builtin" +"ArchiveXL.dll"="native,builtin" +"ArchiveXL"="native,builtin" +"Codeware.dll"="native,builtin" +"Codeware"="native,builtin" +"TweakXL.dll"="native,builtin" +"TweakXL"="native,builtin" +"input_loader.dll"="native,builtin" +"input_loader"="native,builtin" +"RED4ext.dll"="native,builtin" +"RED4ext"="native,builtin" +"mod_settings.dll"="native,builtin" +"mod_settings"="native,builtin" +"scc_lib.dll"="native,builtin" +"scc_lib"="native,builtin" +"dxgi.dll"="native,builtin" +"dxgi"="native,builtin" +"dbghelp.dll"="native,builtin" +"dbghelp"="native,builtin" +"d3d12.dll"="native,builtin" +"d3d12"="native,builtin" +"wininet.dll"="native,builtin" +"wininet"="native,builtin" +"winhttp.dll"="native,builtin" +"winhttp"="native,builtin" +"dinput.dll"="native,builtin" +"dinput8"="native,builtin" +"dinput8.dll"="native,builtin" + +[HKEY_CURRENT_USER\Software\Wine] +"ShowDotFiles"="Y" + +[HKEY_CURRENT_USER\Control Panel\Desktop] +"FontSmoothing"="2" +"FontSmoothingGamma"=dword:00000578 +"FontSmoothingOrientation"=dword:00000001 +"FontSmoothingType"=dword:00000002 + +[HKEY_CURRENT_USER\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers] +@="~ HIGHDPIAWARE" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\Pandora Behaviour Engine+.exe\X11 Driver] +"Decorated"="N" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\Vortex.exe\X11 Driver] +"Decorated"="N" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\SSEEdit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\SSEEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FO4Edit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FO4Edit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\TES4Edit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\TES4Edit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\SF1Edit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FNVEdit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FNVEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xFOEdit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xFOEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xSFEEdit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xSFEEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xTESEdit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\xTESEdit64.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FO3Edit.exe] +"Version"="winxp" + +[HKEY_CURRENT_USER\Software\Wine\AppDefaults\FO3Edit64.exe] +"Version"="winxp" + +; ============================================================================= +; Native file browser integration (opens folders in native file manager) +; ============================================================================= +[HKEY_CLASSES_ROOT\Folder\shell\explore\command] +@="C:\\windows\\system32\\winebrowser.exe -nohome \"%1\"" + +[HKEY_CLASSES_ROOT\Directory\shell\explore\command] +@="C:\\windows\\system32\\winebrowser.exe -nohome \"%1\"" + +[HKEY_CLASSES_ROOT\Folder\shell\open\command] +@="C:\\windows\\system32\\winebrowser.exe -nohome \"%1\"" + +[HKEY_CLASSES_ROOT\Directory\shell\open\command] +@="C:\\windows\\system32\\winebrowser.exe -nohome \"%1\"" + +; ============================================================================= +; Native text editor integration (opens text files in native editor) +; ============================================================================= +[HKEY_CLASSES_ROOT\txtfile\shell\open\command] +@="C:\\windows\\system32\\winebrowser.exe \"%1\"" + +[HKEY_CLASSES_ROOT\inifile\shell\open\command] +@="C:\\windows\\system32\\winebrowser.exe \"%1\"" + +[HKEY_CLASSES_ROOT\.txt] +@="txtfile" + +[HKEY_CLASSES_ROOT\.ini] +@="inifile" + +[HKEY_CLASSES_ROOT\.cfg] +@="txtfile" + +[HKEY_CLASSES_ROOT\.log] +@="txtfile" + +[HKEY_CLASSES_ROOT\.xml] +@="txtfile" + +[HKEY_CLASSES_ROOT\.json] +@="txtfile" + +[HKEY_CLASSES_ROOT\.yml] +@="txtfile" + +[HKEY_CLASSES_ROOT\.yaml] +@="txtfile" +"#; + +// ============================================================================ +// Shared Functions +// ============================================================================ + +/// Apply Wine registry settings to a prefix +pub fn apply_wine_registry_settings( + prefix_path: &std::path::Path, + proton: &SteamProton, + log_callback: &impl Fn(String), + _app_id: Option<u32>, +) -> Result<(), Box<dyn Error>> { + use std::io::Write; + use crate::config::AppConfig; + use crate::logging::{log_error, log_warning}; + use crate::runtime_wrap; + + let tmp_dir = AppConfig::get_tmp_path(); + fs::create_dir_all(&tmp_dir)?; + let reg_file = tmp_dir.join("wine_settings.reg"); + + let mut file = fs::File::create(®_file)?; + file.write_all(WINE_SETTINGS_REG.as_bytes())?; + + let wine_bin = proton.wine_binary().ok_or_else(|| { + let err_msg = format!( + "Wine binary not found for Proton '{}' (checked files/bin/wine and dist/bin/wine)", + proton.name + ); + log_callback(format!("Error: {}", err_msg)); + err_msg + })?; + + let wineserver_bin = proton.wineserver_binary().unwrap_or_else(|| { + wine_bin.with_file_name("wineserver") + }); + + let bin_dir = proton.bin_dir().ok_or_else(|| { + let err_msg = "Could not determine Proton bin directory"; + log_callback(format!("Error: {}", err_msg)); + err_msg + })?; + + let path_env = format!( + "{}:{}", + bin_dir.to_string_lossy(), + std::env::var("PATH").unwrap_or_default() + ); + + log_callback("Applying Wine registry settings...".to_string()); + log_install("Running wine regedit..."); + + let reg_envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_path.display().to_string()), + ("WINE", wine_bin.display().to_string()), + ("WINESERVER", wineserver_bin.display().to_string()), + ("PATH", path_env), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let regedit_status = runtime_wrap::build_command(&wine_bin, ®_envs) + .arg("regedit") + .arg(®_file) + .status(); + + match regedit_status { + Ok(status) => { + if status.success() { + log_callback("Registry settings applied successfully".to_string()); + log_install("Wine registry settings applied successfully"); + } else { + let msg = format!("regedit exited with code {:?}", status.code()); + log_callback(format!("Warning: {}", msg)); + log_warning(&msg); + } + } + Err(e) => { + let msg = format!("Failed to run regedit: {}", e); + log_callback(format!("Error: {}", msg)); + log_error(&msg); + return Err(msg.into()); + } + } + + let _ = fs::remove_file(®_file); + Ok(()) +} diff --git a/libs/nak/src/installers/prefix_setup.rs b/libs/nak/src/installers/prefix_setup.rs new file mode 100644 index 0000000..4f27606 --- /dev/null +++ b/libs/nak/src/installers/prefix_setup.rs @@ -0,0 +1,778 @@ +//! Unified prefix setup for MO2 +//! +//! This module handles all the dependency installation logic. +//! +//! Key approach (ORDER MATTERS): +//! 1. Install dependencies via winetricks (handles wineboot internally) +//! 2. Install custom dotnet runtimes (dotnet9sdk, dotnetdesktop10) +//! 3. Auto-detect installed games and apply registry entries +//! 4. Apply Wine registry settings (LAST - after prefix is fully set up) + +use std::error::Error; +use std::fs; +use std::path::Path; +use std::process::Child; + +use super::{apply_wine_registry_settings, TaskContext}; +use crate::config::AppConfig; +use crate::deps::{install_standard_deps_cancellable, STANDARD_VERBS}; +use crate::game_finder::{detect_all_games, known_games, Game, Launcher}; +use crate::logging::{log_install, log_warning}; +use crate::runtime_wrap; +use crate::steam::{detect_steam_path_checked, SteamProton}; + +// ============================================================================= +// Constants +// ============================================================================= + +/// .NET 9 SDK download URL +const DOTNET9_SDK_URL: &str = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.exe"; + +/// .NET Desktop Runtime 10 download URL +const DOTNET_DESKTOP10_URL: &str = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.exe"; + +/// Drive letters to keep in the prefix (c: is Windows root, z: maps to Linux /) +const ALLOWED_DRIVE_LETTERS: &[&str] = &["c:", "z:"]; + +/// Install all dependencies to a prefix. +/// +/// Order: proton init → winetricks → custom dotnet → game detection → registry → win11 → dotnet fixes +/// +/// # Arguments +/// * `app_id` - Steam AppID (used for registry operations) +pub fn install_all_dependencies( + prefix_root: &Path, + install_proton: &SteamProton, + ctx: &TaskContext, + start_progress: f32, + end_progress: f32, + app_id: u32, +) -> Result<(), Box<dyn Error>> { + fs::create_dir_all(AppConfig::get_tmp_path())?; + + // Progress distribution + let init_end = start_progress + (end_progress - start_progress) * 0.10; + let winetricks_end = start_progress + (end_progress - start_progress) * 0.50; + let dotnet_end = start_progress + (end_progress - start_progress) * 0.65; + let games_end = start_progress + (end_progress - start_progress) * 0.75; + + // ========================================================================= + // 0. Initialize prefix with Proton wrapper (creates proper prefix structure) + // ========================================================================= + ctx.set_status("Setting up Windows compatibility layer...".to_string()); + ctx.log("Initializing Wine prefix with Proton...".to_string()); + log_install("Running proton wineboot to initialize prefix"); + + if let Err(e) = initialize_prefix_with_proton(prefix_root, install_proton, app_id, ctx) { + ctx.log(format!("Warning: Proton prefix init failed: {}", e)); + log_warning(&format!("Proton prefix init failed: {}", e)); + // Continue anyway - winetricks might still work + } + + ctx.set_progress(init_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 0.5. Clean up unwanted drive letters (keep only C: and Z:) + // ========================================================================= + ctx.set_status("Optimizing prefix configuration...".to_string()); + ctx.log("Removing unwanted drive letters (keeping C: and Z:)...".to_string()); + log_install("Cleaning up Wine drive letters"); + + if let Err(e) = cleanup_wine_drives(prefix_root, install_proton) { + ctx.log(format!("Warning: Drive cleanup had issues: {}", e)); + log_warning(&format!("Drive cleanup failed: {}", e)); + } + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 1. Standard Dependencies via Winetricks + // ========================================================================= + ctx.set_status("Installing required Windows components (this may take several minutes)...".to_string()); + ctx.log(format!( + "Installing {} dependencies via winetricks: {}", + STANDARD_VERBS.len(), + STANDARD_VERBS.join(", ") + )); + log_install(&format!("Running winetricks with {} verbs", STANDARD_VERBS.len())); + + let winetricks_log_cb = { + let ctx = ctx.clone(); + move |msg: String| { + ctx.log(msg.clone()); + ctx.set_status(msg); + } + }; + + if let Err(e) = install_standard_deps_cancellable(prefix_root, install_proton, winetricks_log_cb, &ctx.cancel_flag) { + let msg = format!("Winetricks installation had issues: {}", e); + ctx.log(format!("Warning: {}", msg)); + log_warning(&msg); + } + + ctx.set_progress(winetricks_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 2. Custom .NET Runtimes (not in winetricks yet) + // ========================================================================= + ctx.set_status("Installing .NET runtime (1 of 2)...".to_string()); + ctx.log("Installing .NET 9 SDK...".to_string()); + + if let Err(e) = install_dotnet_runtime(prefix_root, install_proton, DOTNET9_SDK_URL, "dotnet-sdk-9", ctx) { + ctx.log(format!("Warning: .NET 9 SDK install failed: {}", e)); + log_warning(&format!(".NET 9 SDK install failed: {}", e)); + } + + ctx.set_status("Installing .NET runtime (2 of 2)...".to_string()); + ctx.log("Installing .NET Desktop Runtime 10...".to_string()); + + if let Err(e) = install_dotnet_runtime(prefix_root, install_proton, DOTNET_DESKTOP10_URL, "dotnet-desktop-10", ctx) { + ctx.log(format!("Warning: .NET Desktop 10 install failed: {}", e)); + log_warning(&format!(".NET Desktop 10 install failed: {}", e)); + } + + ctx.set_progress(dotnet_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 3. Auto-detect and register installed games + // ========================================================================= + ctx.set_status("Detecting your installed games...".to_string()); + ctx.log("Auto-detecting installed Steam games...".to_string()); + log_install("Auto-detecting installed games for registry"); + + let game_log_cb = { + let ctx = ctx.clone(); + move |msg: String| ctx.log(msg) + }; + auto_apply_game_registries(prefix_root, install_proton, &game_log_cb, Some(app_id)); + + ctx.set_progress(games_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 4. Registry Settings (after prefix is fully initialized) + // ========================================================================= + ctx.set_status("Configuring Windows registry...".to_string()); + ctx.log("Applying Wine Registry Settings...".to_string()); + log_install("Applying Wine registry settings"); + + let log_cb = { + let ctx = ctx.clone(); + move |msg: String| ctx.log(msg) + }; + apply_wine_registry_settings(prefix_root, install_proton, &log_cb, Some(app_id))?; + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 5. Set Windows 11 Mode + // ========================================================================= + ctx.set_status("Finalizing compatibility settings...".to_string()); + ctx.log("Setting Windows 11 mode...".to_string()); + log_install("Setting Windows 11 mode via winetricks"); + + if let Err(e) = set_windows_11_mode(prefix_root, install_proton, ctx) { + ctx.log(format!("Warning: Failed to set Windows 11 mode: {}", e)); + log_warning(&format!("Failed to set Windows 11 mode: {}", e)); + } + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + ctx.set_progress(end_progress); + ctx.set_status("Dependencies installed".to_string()); + Ok(()) +} + +/// Install a .NET runtime via direct exe download and wine execution +fn install_dotnet_runtime( + prefix_root: &Path, + proton: &SteamProton, + url: &str, + name: &str, + ctx: &TaskContext, +) -> Result<(), Box<dyn Error>> { + let cache_dir = AppConfig::get_default_cache_dir(); + fs::create_dir_all(&cache_dir)?; + + let filename = url.split('/').next_back().unwrap_or("dotnet-installer.exe"); + let installer_path = cache_dir.join(filename); + + // Download if not cached + if !installer_path.exists() { + log_install(&format!("Downloading {}...", name)); + let response = ureq::get(url) + .set("User-Agent", "NaK-Rust") + .call() + .map_err(|e| format!("Failed to download {}: {}", name, e))?; + + let mut file = fs::File::create(&installer_path)?; + std::io::copy(&mut response.into_reader(), &mut file)?; + } + + // Run installer with wine + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found".into()); + }; + + log_install(&format!("Running {} installer...", name)); + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ]; + let mut cmd = runtime_wrap::build_command(&wine_bin, &envs); + cmd.arg(&installer_path) + .arg("/install") + .arg("/quiet") + .arg("/norestart"); + + let status = ctx.run_cancellable(cmd)?; + + if !status.success() { + return Err(format!("{} installer exited with code {:?}", name, status.code()).into()); + } + + log_install(&format!("{} installed successfully", name)); + Ok(()) +} + +/// Initialize prefix with Proton wrapper +/// +/// Runs `proton run wineboot -u` to properly initialize the prefix with all +/// the Steam/Proton environment variables. This creates a proper prefix +/// structure that Steam recognizes. +fn initialize_prefix_with_proton( + prefix_root: &Path, + proton: &SteamProton, + app_id: u32, + ctx: &TaskContext, +) -> Result<(), Box<dyn Error>> { + // Find the proton wrapper script (not the wine binary) + let proton_script = proton.path.join("proton"); + if !proton_script.exists() { + return Err(format!("Proton wrapper script not found at {:?}", proton_script).into()); + } + + // Get Steam root path + let steam_root = detect_steam_path_checked() + .ok_or("Could not find Steam installation")?; + + // The compatdata path is the PARENT of the pfx directory + let compat_data_path = prefix_root.parent() + .ok_or("Could not determine compatdata path")?; + + log_install(&format!("STEAM_COMPAT_DATA_PATH={:?}", compat_data_path)); + + // Collect all env vars upfront so build_command can forward them + // via --env= flags in Flatpak mode. + let mut envs: Vec<(&str, String)> = vec![ + ("STEAM_COMPAT_CLIENT_INSTALL_PATH", steam_root.clone()), + ("STEAM_COMPAT_DATA_PATH", compat_data_path.display().to_string()), + ("SteamAppId", app_id.to_string()), + ("SteamGameId", app_id.to_string()), + ("DISPLAY", String::new()), // Suppress GUI + ("WAYLAND_DISPLAY", String::new()), // Suppress GUI + ("WINEDEBUG", "-all".to_string()), + ("WINEDLLOVERRIDES", "msdia80.dll=n;conhost.exe=d;cmd.exe=d".to_string()), + ]; + + let (exe, args): (std::path::PathBuf, Vec<&str>) = if runtime_wrap::use_umu_for_prefix() { + if let Some(umu_run) = runtime_wrap::resolve_umu_run() { + log_install(&format!("Initializing prefix with umu-run: {:?}", umu_run)); + envs.push(("PROTONPATH", proton.path.display().to_string())); + envs.push(("WINEPREFIX", prefix_root.display().to_string())); + envs.push(("GAMEID", app_id.to_string())); + (umu_run, vec!["wineboot", "-u"]) + } else { + log_warning( + "UMU prefix mode enabled but no umu-run was found; falling back to proton wrapper", + ); + (proton_script.clone(), vec!["run", "wineboot", "-u"]) + } + } else { + log_install(&format!("Initializing prefix with proton wrapper: {:?}", proton_script)); + (proton_script.clone(), vec!["run", "wineboot", "-u"]) + }; + + let mut cmd = runtime_wrap::build_command(&exe, &envs); + cmd.args(&args); + + let status = ctx.run_cancellable(cmd)?; + + if !status.success() { + return Err(format!("proton wineboot failed with exit code: {:?}", status.code()).into()); + } + + // Give it a moment for files to land + std::thread::sleep(std::time::Duration::from_secs(2)); + + // Verify prefix was created + if prefix_root.exists() { + log_install("Proton prefix initialized successfully"); + Ok(()) + } else { + Err("Prefix directory not created after wineboot".into()) + } +} + +/// Clean up unwanted Wine drive letters from prefix +/// +/// Removes both symbolic links in dosdevices/ and registry entries for +/// drive letters other than C: and Z:. This prevents Wine from mounting +/// other users' drives as E:, F:, G:, etc. +fn cleanup_wine_drives( + prefix_root: &Path, + proton: &SteamProton, +) -> Result<(), Box<dyn Error>> { + let dosdevices = prefix_root.join("dosdevices"); + + if !dosdevices.exists() { + log_install("dosdevices directory not found, skipping drive cleanup"); + return Ok(()); + } + + // ========================================================================= + // 1. Remove unwanted symlinks from dosdevices/ + // ========================================================================= + let mut removed_drives = Vec::new(); + + if let Ok(entries) = fs::read_dir(&dosdevices) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_lowercase(); + + // Skip allowed drives and non-drive entries (like com1, lpt1, etc.) + if ALLOWED_DRIVE_LETTERS.contains(&name.as_str()) { + continue; + } + + // Only process drive letters (single letter followed by colon) + if name.len() == 2 && name.ends_with(':') && name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) { + let path = entry.path(); + if let Err(e) = fs::remove_file(&path) { + log_warning(&format!("Failed to remove drive symlink {}: {}", name, e)); + } else { + removed_drives.push(name.to_uppercase()); + } + } + } + } + + if !removed_drives.is_empty() { + log_install(&format!("Removed drive symlinks: {}", removed_drives.join(", "))); + } + + // ========================================================================= + // 2. Clean up registry entries for removed drives + // ========================================================================= + let Some(wine_bin) = proton.wine_binary() else { + log_warning("Wine binary not found, skipping registry cleanup"); + return Ok(()); + }; + + // Create a .reg file to remove drive type entries + let tmp_dir = AppConfig::get_tmp_path(); + fs::create_dir_all(&tmp_dir)?; + + let mut reg_content = String::from("Windows Registry Editor Version 5.00\n\n"); + + // Remove entries from HKLM\Software\Wine\Drives + for drive in &removed_drives { + // Remove both uppercase and lowercase variants + reg_content.push_str(&format!( + "[HKEY_LOCAL_MACHINE\\Software\\Wine\\Drives]\n\"{drive}\"=-\n\n" + )); + } + + if !removed_drives.is_empty() { + let reg_file = tmp_dir.join("drive_cleanup.reg"); + fs::write(®_file, ®_content)?; + + let drive_envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let status = runtime_wrap::build_command(&wine_bin, &drive_envs) + .arg("regedit") + .arg(®_file) + .status(); + + let _ = fs::remove_file(®_file); + + match status { + Ok(s) if s.success() => { + log_install("Registry drive entries cleaned up"); + } + Ok(s) => { + log_warning(&format!("Registry cleanup may have failed (exit code: {:?})", s.code())); + } + Err(e) => { + log_warning(&format!("Failed to run registry cleanup: {}", e)); + } + } + } + + Ok(()) +} + +/// Public wrapper to clean up Wine drives on an existing prefix +/// +/// This can be called from the UI to fix drive letter issues on existing prefixes. +pub fn cleanup_prefix_drives( + prefix_root: &Path, + proton: &SteamProton, +) -> Result<Vec<String>, Box<dyn Error>> { + let dosdevices = prefix_root.join("dosdevices"); + + if !dosdevices.exists() { + return Err("dosdevices directory not found - is this a valid Wine prefix?".into()); + } + + // Collect drives before cleanup + let mut removed = Vec::new(); + + if let Ok(entries) = fs::read_dir(&dosdevices) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_lowercase(); + if name.len() == 2 && name.ends_with(':') && name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) + && !ALLOWED_DRIVE_LETTERS.contains(&name.as_str()) { + removed.push(name.to_uppercase()); + } + } + } + + // Run the actual cleanup + cleanup_wine_drives(prefix_root, proton)?; + + Ok(removed) +} + +/// Set Windows 11 mode for the prefix using winetricks +/// +/// This should be called AFTER all components are installed. +/// Sets the Windows version to Windows 11 which is required for MO2 to work properly. +fn set_windows_11_mode( + prefix_root: &Path, + proton: &SteamProton, + ctx: &TaskContext, +) -> Result<(), Box<dyn Error>> { + use crate::deps::ensure_winetricks; + + let winetricks_path = ensure_winetricks()?; + + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found".into()); + }; + + let Some(wineserver_bin) = proton.wineserver_binary() else { + return Err("Wineserver binary not found".into()); + }; + + log_install("Running winetricks win11..."); + + let envs: Vec<(&str, String)> = vec![ + ("WINE", wine_bin.display().to_string()), + ("WINESERVER", wineserver_bin.display().to_string()), + ("WINEPREFIX", prefix_root.display().to_string()), + ]; + let mut cmd = runtime_wrap::build_command(&winetricks_path, &envs); + cmd.arg("-q").arg("win11"); + + let status = ctx.run_cancellable(cmd)?; + + if !status.success() { + return Err(format!("winetricks win11 failed with exit code: {:?}", status.code()).into()); + } + + log_install("Windows 11 mode set successfully"); + Ok(()) +} + +// ============================================================================= +// DPI Configuration +// ============================================================================= + +/// Common DPI presets with their percentage labels +pub const DPI_PRESETS: &[(u32, &str)] = &[ + (96, "100%"), + (120, "125%"), + (144, "150%"), + (192, "200%"), +]; + +/// Apply DPI setting to a Wine prefix via registry +pub fn apply_dpi( + prefix_root: &Path, + proton: &SteamProton, + dpi_value: u32, +) -> Result<(), Box<dyn Error>> { + log_install(&format!("Applying DPI {} to prefix", dpi_value)); + + let wine_bin = proton.wine_binary().ok_or_else(|| { + format!("Wine binary not found for Proton '{}'", proton.name) + })?; + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let status = runtime_wrap::build_command(&wine_bin, &envs) + .arg("reg") + .arg("add") + .arg(r"HKCU\Control Panel\Desktop") + .arg("/v") + .arg("LogPixels") + .arg("/t") + .arg("REG_DWORD") + .arg("/d") + .arg(dpi_value.to_string()) + .arg("/f") + .status()?; + + if !status.success() { + return Err(format!("Failed to apply DPI setting: exit code {:?}", status.code()).into()); + } + + log_install(&format!("DPI {} applied successfully", dpi_value)); + Ok(()) +} + +/// Launch a test application (winecfg, regedit, notepad, control) and return its PID +pub fn launch_dpi_test_app( + prefix_root: &Path, + proton: &SteamProton, + app_name: &str, +) -> Result<Child, Box<dyn Error>> { + let wine_bin = proton.wine_binary().ok_or_else(|| { + format!("Wine binary not found for Proton '{}'", proton.name) + })?; + + log_install(&format!( + "Launching {} with wine={:?} prefix={:?}", + app_name, wine_bin, prefix_root + )); + + if !prefix_root.exists() { + return Err(format!("Prefix not found: {:?}", prefix_root).into()); + } + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let child = runtime_wrap::build_command(&wine_bin, &envs) + .arg(app_name) + .spawn()?; + + Ok(child) +} + +/// Kill the wineserver for a prefix (terminates all Wine processes in that prefix) +pub fn kill_wineserver(prefix_root: &Path, proton: &SteamProton) { + log_install("Killing wineserver for prefix"); + + let Some(wineserver_bin) = proton.wineserver_binary() else { + log_install("Wineserver binary not found, skipping kill"); + return; + }; + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ]; + let _ = runtime_wrap::build_command(&wineserver_bin, &envs) + .arg("-k") + .status(); +} + +// ============================================================================ +// Game Registry Detection (uses game_finder module) +// ============================================================================ + +/// Auto-detect installed games and apply registry entries +/// +/// This uses the game_finder module to detect installed games across all +/// supported launchers (Steam, Heroic, Bottles) and automatically adds +/// the registry entries so mod managers can detect them. +pub fn auto_apply_game_registries( + prefix_path: &Path, + proton: &SteamProton, + log_callback: &impl Fn(String), + _app_id: Option<u32>, +) { + let Some(wine_bin) = proton.wine_binary() else { + log_warning("Wine binary not found, skipping game registry auto-detection"); + return; + }; + + // Use the new game_finder module to detect all games + let scan_result = detect_all_games(); + let mut applied_count = 0; + + for game in &scan_result.games { + // Only process games that have registry info + let (Some(reg_path), Some(reg_value)) = (&game.registry_path, &game.registry_value) else { + continue; + }; + + // Apply registry for this game + if apply_game_registry( + prefix_path, + &wine_bin, + game, + reg_path, + reg_value, + log_callback, + ) { + applied_count += 1; + } + } + + if applied_count > 0 { + log_callback(format!("Auto-configured {} game(s) in registry", applied_count)); + log_install(&format!("Auto-applied registry for {} detected game(s)", applied_count)); + } +} + +/// Apply a game's registry entry with a custom install path. +/// +/// Looks up the game by name in KNOWN_GAMES, then writes the registry entry +/// pointing to `install_path`. Use this when the game is in a custom/stock +/// folder that auto-detection won't find. +pub fn apply_registry_for_game_path( + prefix_path: &Path, + proton: &SteamProton, + game_name: &str, + install_path: &Path, + log_callback: &impl Fn(String), +) -> Result<(), String> { + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found".to_string()); + }; + + let known = known_games::find_by_name(game_name); + let (reg_path, reg_value) = if let Some(kg) = known { + (kg.registry_path, kg.registry_value) + } else { + return Err(format!("Unknown game: {game_name}")); + }; + + let fake_game = Game { + name: game_name.to_string(), + install_path: install_path.to_path_buf(), + app_id: known.map(|k| k.steam_app_id.to_string()).unwrap_or_default(), + prefix_path: None, + launcher: Launcher::Steam { is_flatpak: false, is_snap: false }, + my_games_folder: known.and_then(|k| k.my_games_folder.map(String::from)), + appdata_local_folder: known.and_then(|k| k.appdata_local_folder.map(String::from)), + appdata_roaming_folder: known.and_then(|k| k.appdata_roaming_folder.map(String::from)), + registry_path: Some(reg_path.to_string()), + registry_value: Some(reg_value.to_string()), + }; + + if apply_game_registry(prefix_path, &wine_bin, &fake_game, reg_path, reg_value, log_callback) { + Ok(()) + } else { + Err(format!("Failed to apply registry for {game_name}")) + } +} + +/// Return the list of known game names for UI display. +pub fn known_game_names() -> Vec<&'static str> { + known_games::KNOWN_GAMES.iter().map(|g| g.name).collect() +} + +/// Apply registry entry for a single game +fn apply_game_registry( + prefix_path: &Path, + wine_bin: &Path, + game: &Game, + reg_path: &str, + reg_value: &str, + log_callback: &impl Fn(String), +) -> bool { + log_callback(format!("Found {}, applying registry...", game.name)); + + // Convert Linux path to Wine Z: drive path with escaped backslashes for .reg file + let linux_path = game.install_path.to_string_lossy(); + let wine_path_reg = format!("Z:{}", linux_path.replace('/', "\\\\")); + + // Create .reg file content + let reg_content = format!( + r#"Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\{}] +"{}"="{}" + +[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\{}] +"{}"="{}" +"#, + reg_path, + reg_value, + wine_path_reg, + reg_path.strip_prefix("Software\\").unwrap_or(reg_path), + reg_value, + wine_path_reg, + ); + + // Write temp .reg file + let tmp_dir = AppConfig::get_tmp_path(); + let reg_file = tmp_dir.join(format!("game_reg_{}.reg", game.app_id)); + + if let Err(e) = fs::write(®_file, ®_content) { + log_warning(&format!("Failed to write registry file for {}: {}", game.name, e)); + return false; + } + + // Apply registry + let reg_envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_path.display().to_string()), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let status = runtime_wrap::build_command(wine_bin, ®_envs) + .arg("regedit") + .arg(®_file) + .status(); + + let _ = fs::remove_file(®_file); + + match status { + Ok(s) if s.success() => { + log_install(&format!("Applied registry for {} -> {:?}", game.name, game.install_path)); + true + } + Ok(s) => { + log_warning(&format!( + "Registry for {} may have failed (exit code: {:?})", + game.name, + s.code() + )); + false + } + Err(e) => { + log_warning(&format!("Failed to apply registry for {}: {}", game.name, e)); + false + } + } +} diff --git a/libs/nak/src/installers/symlinks.rs b/libs/nak/src/installers/symlinks.rs new file mode 100644 index 0000000..ef8cbb4 --- /dev/null +++ b/libs/nak/src/installers/symlinks.rs @@ -0,0 +1,359 @@ +//! Symlink management for NaK prefixes +//! +//! Creates symlinks FROM NaK prefix TO game prefixes. +//! Data stays in the game prefix, NaK just has links pointing to it. +//! +//! This inverted approach means: +//! - Game saves remain in their original location (Steam cloud sync works) +//! - NaK prefix provides unified access to all game data +//! - No data duplication or sync issues +//! +//! NaK Tools folder contains convenience symlinks pointing INTO the prefix +//! for easy access to Documents, AppData, etc. + +// Allow unused items - some functions are public API for future use +#![allow(dead_code)] + +use std::fs; +use std::path::Path; + +use crate::game_finder::{detect_all_games, Game, GameScanResult}; +use crate::logging::{log_info, log_warning}; + +// ============================================================================ +// Public API +// ============================================================================ + +/// Create symlinks from NaK prefix to game prefixes for all detected games +/// +/// This creates symlinks in the NaK prefix pointing to the actual save/config +/// folders in each game's own prefix. The symlink direction is: +/// +/// NaK/Documents/My Games/Skyrim -> game_prefix/Documents/My Games/Skyrim +/// NaK/AppData/Local/Skyrim -> game_prefix/AppData/Local/Skyrim +/// +/// Data stays in the game prefix (preserving Steam Cloud sync), while NaK +/// provides unified access through symlinks. +pub fn create_game_symlinks(nak_prefix: &Path, games: &[Game]) { + let users_dir = nak_prefix.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let user_dir = users_dir.join(&username); + + let documents = user_dir.join("Documents"); + let my_games = documents.join("My Games"); + let appdata_local = user_dir.join("AppData/Local"); + let appdata_roaming = user_dir.join("AppData/Roaming"); + + // Ensure base directories exist (real folders in NaK prefix) + let _ = fs::create_dir_all(&my_games); + let _ = fs::create_dir_all(&appdata_local); + let _ = fs::create_dir_all(&appdata_roaming); + + let mut linked_count = 0; + + for game in games { + // Skip games without prefixes + let Some(game_prefix) = &game.prefix_path else { + continue; + }; + + // Discover the game prefix's user directory + let game_users_dir = game_prefix.join("drive_c/users"); + let game_username = find_prefix_username(&game_users_dir); + let game_user_dir = game_users_dir.join(&game_username); + + // Scan and symlink ALL folders in the game prefix's Documents/My Games/ + linked_count += scan_and_link_all( + &my_games, + &game_user_dir.join("Documents/My Games"), + "Documents/My Games", + &game.name, + game_prefix, + ); + + // Also link the Documents folder itself for non-My Games entries + // (some games put saves directly in Documents/<GameName>) + linked_count += scan_and_link_all( + &documents, + &game_user_dir.join("Documents"), + "Documents", + &game.name, + game_prefix, + ); + + // Scan and symlink ALL folders in AppData/Local/ + linked_count += scan_and_link_all( + &appdata_local, + &game_user_dir.join("AppData/Local"), + "AppData/Local", + &game.name, + game_prefix, + ); + + // Scan and symlink ALL folders in AppData/Roaming/ + linked_count += scan_and_link_all( + &appdata_roaming, + &game_user_dir.join("AppData/Roaming"), + "AppData/Roaming", + &game.name, + game_prefix, + ); + } + + if linked_count > 0 { + log_info(&format!( + "Created {} symlinks to game prefixes", + linked_count + )); + } + + // Create "My Documents" symlink for compatibility + let my_documents = user_dir.join("My Documents"); + if !my_documents.exists() && fs::symlink_metadata(&my_documents).is_err() { + if let Err(e) = std::os::unix::fs::symlink("Documents", &my_documents) { + log_warning(&format!("Failed to create My Documents symlink: {}", e)); + } + } +} + +/// Create NaK Tools convenience symlinks pointing INTO the prefix +/// +/// Creates symlinks in NaK Tools folder for easy access: +/// - NaK Tools/Prefix Documents -> prefix/drive_c/users/<user>/Documents +/// - NaK Tools/Prefix AppData Local -> prefix/drive_c/users/<user>/AppData/Local +/// - NaK Tools/Prefix AppData Roaming -> prefix/drive_c/users/<user>/AppData/Roaming +pub fn create_nak_tools_symlinks(tools_dir: &Path, prefix_path: &Path) { + let users_dir = prefix_path.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let user_dir = users_dir.join(&username); + + // Symlink: NaK Tools/Prefix Documents -> prefix Documents + let documents_link = tools_dir.join("Prefix Documents"); + let documents_target = user_dir.join("Documents"); + create_or_update_symlink(&documents_link, &documents_target, "Prefix Documents"); + + // Symlink: NaK Tools/Prefix AppData Local -> prefix AppData/Local + let appdata_local_link = tools_dir.join("Prefix AppData Local"); + let appdata_local_target = user_dir.join("AppData/Local"); + create_or_update_symlink(&appdata_local_link, &appdata_local_target, "Prefix AppData Local"); + + // Symlink: NaK Tools/Prefix AppData Roaming -> prefix AppData/Roaming + let appdata_roaming_link = tools_dir.join("Prefix AppData Roaming"); + let appdata_roaming_target = user_dir.join("AppData/Roaming"); + create_or_update_symlink(&appdata_roaming_link, &appdata_roaming_target, "Prefix AppData Roaming"); + + log_info("Created NaK Tools convenience symlinks to prefix folders"); +} + +/// Create or update a symlink +fn create_or_update_symlink(link_path: &Path, target: &Path, name: &str) { + // Remove existing symlink or file + if link_path.exists() || fs::symlink_metadata(link_path).is_ok() { + let _ = fs::remove_file(link_path); + let _ = fs::remove_dir_all(link_path); + } + + // Create symlink + if let Err(e) = std::os::unix::fs::symlink(target, link_path) { + log_warning(&format!("Failed to create {} symlink: {}", name, e)); + } +} + +/// Create symlinks for all detected games +/// +/// Convenience function that detects games and creates symlinks in one call. +pub fn create_game_symlinks_auto(nak_prefix: &Path) -> GameScanResult { + let result = detect_all_games(); + create_game_symlinks(nak_prefix, &result.games); + result +} + +/// Ensure only the Temp directory exists in AppData/Local +/// +/// MO2 and other tools require AppData/Local/Temp to exist. +/// We create only this essential directory, leaving other game-specific +/// folders to be symlinked from game prefixes. +pub fn ensure_temp_directory(prefix_path: &Path) { + let users_dir = prefix_path.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let user_dir = users_dir.join(&username); + + let temp_dir = user_dir.join("AppData/Local/Temp"); + if let Err(e) = fs::create_dir_all(&temp_dir) { + log_warning(&format!("Failed to create Temp directory: {}", e)); + } else { + log_info("Ensured AppData/Local/Temp directory exists"); + } +} + +// ============================================================================ +// Internal Functions +// ============================================================================ + +/// Directories to skip when scanning prefix folders for symlinking. +/// These are Wine/Proton internal or system dirs, not game data. +const SKIP_DIRS: &[&str] = &[ + "Temp", "Microsoft", "wine", "Public", "root", + "Application Data", "Cookies", "Local Settings", + "NetHood", "PrintHood", "Recent", "SendTo", + "Start Menu", "Templates", "My Documents", "My Music", + "My Pictures", "My Videos", "Desktop", "Downloads", + "Favorites", "Links", "Saved Games", "Searches", + "Contacts", "3D Objects", +]; + +/// Scan all subdirectories in a game prefix folder and create symlinks +/// for each one in the corresponding NaK prefix folder. +/// +/// Returns the number of symlinks created. +fn scan_and_link_all( + nak_base: &Path, + game_base: &Path, + label: &str, + game_name: &str, + _game_prefix: &Path, +) -> usize { + if !game_base.is_dir() { + return 0; + } + + let Ok(entries) = fs::read_dir(game_base) else { + return 0; + }; + + let mut count = 0; + for entry in entries.flatten() { + // Only symlink directories (game folders), not loose files + if !entry.path().is_dir() { + continue; + } + + let folder_name = entry.file_name().to_string_lossy().to_string(); + + // Skip Wine/system internal directories + if SKIP_DIRS.iter().any(|&s| s.eq_ignore_ascii_case(&folder_name)) { + continue; + } + + // Skip if it's "My Games" and we're scanning Documents (handled separately) + if label == "Documents" && folder_name == "My Games" { + continue; + } + + let nak_path = nak_base.join(&folder_name); + let source_path = entry.path(); + + if create_symlink_if_needed(&nak_path, &source_path, game_name, label, &folder_name) { + count += 1; + } + } + + count +} + +/// Create a symlink if the target doesn't already exist or is already correct. +/// +/// Returns true if a symlink was created (or already existed correctly). +fn create_symlink_if_needed( + nak_path: &Path, + source_path: &Path, + game_name: &str, + label: &str, + folder_name: &str, +) -> bool { + // Check if target already exists + if nak_path.exists() || fs::symlink_metadata(nak_path).is_ok() { + // Check if it's already a symlink to the correct location + if let Ok(target) = fs::read_link(nak_path) { + if target == source_path { + return true; // Already correctly linked + } + } + // Something else exists here, don't overwrite + return false; + } + + // Ensure parent directory exists + if let Some(parent) = nak_path.parent() { + let _ = fs::create_dir_all(parent); + } + + // Create the symlink + match std::os::unix::fs::symlink(source_path, nak_path) { + Ok(()) => { + log_info(&format!( + "Linked {}/{} -> {} ({})", + label, + folder_name, + source_path.display(), + game_name, + )); + true + } + Err(e) => { + log_warning(&format!( + "Failed to create symlink for {} ({}/{}): {}", + game_name, label, folder_name, e + )); + false + } + } +} + +/// Find the username from a Wine prefix users directory +fn find_prefix_username(users_dir: &Path) -> String { + if let Ok(entries) = 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 name; + } + } + } + "steamuser".to_string() +} + +// ============================================================================ +// Oblivion Lowercase INI Symlinks +// ============================================================================ + +/// Create lowercase INI symlinks for Oblivion (some tools expect lowercase) +pub fn create_oblivion_ini_symlinks(prefix_path: &Path) { + let users_dir = prefix_path.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let oblivion_dir = users_dir + .join(&username) + .join("Documents/My Games/Oblivion"); + + if !oblivion_dir.exists() { + return; + } + + create_lowercase_ini_symlink(&oblivion_dir, "Oblivion.ini", "oblivion.ini"); + create_lowercase_ini_symlink(&oblivion_dir, "OblivionPrefs.ini", "oblivionprefs.ini"); +} + +/// Create a lowercase symlink for an INI file +fn create_lowercase_ini_symlink(dir: &Path, original: &str, lowercase: &str) { + let original_path = dir.join(original); + let lowercase_path = dir.join(lowercase); + + // Only create if original exists and lowercase doesn't + if original_path.exists() + && !lowercase_path.exists() + && fs::symlink_metadata(&lowercase_path).is_err() + { + // Create relative symlink (just the filename) + if let Err(e) = std::os::unix::fs::symlink(original, &lowercase_path) { + log_warning(&format!( + "Failed to create lowercase symlink {} -> {}: {}", + lowercase, original, e + )); + } else { + log_info(&format!( + "Created lowercase INI symlink: {} -> {}", + lowercase, original + )); + } + } +} diff --git a/libs/nak/src/lib.rs b/libs/nak/src/lib.rs new file mode 100644 index 0000000..c93f07b --- /dev/null +++ b/libs/nak/src/lib.rs @@ -0,0 +1,15 @@ +//! NaK - Vendored library for Fluorine Manager +//! +//! Stripped version: no GUI, CLI, marketplace, updater, NXM handler, +//! Steam shortcuts, or managed prefix tracking. + +pub mod config; +pub mod dxvk; +pub mod game_finder; +pub mod logging; +pub mod runtime_wrap; +pub mod steam; +pub mod utils; + +pub mod deps; +pub mod installers; diff --git a/libs/nak/src/logging.rs b/libs/nak/src/logging.rs new file mode 100644 index 0000000..8c0f91a --- /dev/null +++ b/libs/nak/src/logging.rs @@ -0,0 +1,99 @@ +//! Logging for Fluorine Manager. +//! +//! All log messages are: +//! 1. Written to `~/.var/app/com.fluorine.manager/logs/nak.log` (always) +//! 2. Forwarded to an optional callback set via `set_log_callback()` (for MOBase::log) + +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; + +/// Signature for the log callback: (level, message) +/// +/// Levels: "info", "warning", "error", "install", "action", "download" +type LogCallback = Box<dyn Fn(&str, &str) + Send + Sync>; + +static LOG_CALLBACK: Mutex<Option<LogCallback>> = Mutex::new(None); + +/// Set the global log callback. Call once at startup from FFI. +pub fn set_log_callback(cb: impl Fn(&str, &str) + Send + Sync + 'static) { + if let Ok(mut guard) = LOG_CALLBACK.lock() { + *guard = Some(Box::new(cb)); + } +} + +/// Get the log directory path. +fn log_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".var/app/com.fluorine.manager/logs") +} + +/// Get the log file path. +fn log_file_path() -> PathBuf { + log_dir().join("nak.log") +} + +fn emit(level: &str, message: &str) { + // Write to file (always, even before callback is set) + write_to_file(level, message); + + // Forward to callback if set + if let Ok(guard) = LOG_CALLBACK.lock() { + if let Some(ref cb) = *guard { + cb(level, message); + } + } +} + +fn write_to_file(level: &str, message: &str) { + let path = log_file_path(); + + // Ensure log directory exists (only try once per message, cheap no-op if exists) + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + + // Rotate if over 2MB + if let Ok(meta) = fs::metadata(&path) { + if meta.len() > 2 * 1024 * 1024 { + let old = path.with_extension("log.old"); + let _ = fs::rename(&path, &old); + } + } + + let Ok(mut file) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + else { + return; + }; + + let timestamp = chrono::Local::now().format("%H:%M:%S"); + let _ = writeln!(file, "[{}] [{}] {}", timestamp, level.to_uppercase(), message); +} + +pub fn log_info(message: &str) { + emit("info", message); +} + +pub fn log_warning(message: &str) { + emit("warning", message); +} + +pub fn log_error(message: &str) { + emit("error", message); +} + +pub fn log_install(message: &str) { + emit("install", message); +} + +pub fn log_action(message: &str) { + emit("action", message); +} + +pub fn log_download(message: &str) { + emit("download", message); +} diff --git a/libs/nak/src/runtime_wrap.rs b/libs/nak/src/runtime_wrap.rs new file mode 100644 index 0000000..d33bd59 --- /dev/null +++ b/libs/nak/src/runtime_wrap.rs @@ -0,0 +1,121 @@ +use std::env; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn env_flag(name: &str) -> bool { + matches!( + env::var(name) + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str(), + "1" | "true" | "yes" | "on" + ) +} + +pub fn use_steam_run() -> bool { + env_flag("NAK_USE_STEAM_RUN") +} + +pub fn use_umu_for_prefix() -> bool { + env_flag("NAK_USE_UMU_FOR_PREFIX") +} + +pub fn prefer_system_umu() -> bool { + env_flag("NAK_PREFER_SYSTEM_UMU") +} + +fn find_in_path(binary: &str) -> Option<PathBuf> { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|entry| entry.join(binary)) + .find(|candidate| candidate.exists()) +} + +pub fn resolve_umu_run() -> Option<PathBuf> { + if is_flatpak() { + // In Flatpak, umu-run must run on the host via flatpak-spawn --host. + let host_copy = env::var("XDG_DATA_HOME") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".local/share") + }) + .join("fluorine/umu-run"); + if host_copy.exists() { + return Some(host_copy); + } + // Fall back to bare name for host PATH resolution. + return Some(PathBuf::from("umu-run")); + } + + let bundled = env::var("NAK_BUNDLED_UMU_RUN") + .ok() + .map(PathBuf::from) + .filter(|p| p.exists()); + let system = find_in_path("umu-run"); + + if prefer_system_umu() { + system.or(bundled) + } else { + bundled.or(system) + } +} + +pub fn is_flatpak() -> bool { + Path::new("/.flatpak-info").exists() +} + +/// Build a command to run `exe` with the given environment variables. +/// +/// In Flatpak mode, `flatpak-spawn --host` is used and env vars are passed as +/// `--env=KEY=VALUE` flags (the host process does NOT inherit the sandbox env). +/// In steam-run mode, the command is wrapped with `steam-run`. +/// Otherwise the command runs directly. +pub fn build_command<S: AsRef<OsStr>>( + exe: impl AsRef<OsStr>, + envs: &[(&str, S)], +) -> Command { + if use_steam_run() { + let mut cmd = Command::new("steam-run"); + cmd.arg(exe.as_ref()); + for (key, value) in envs { + cmd.env(key, value.as_ref()); + } + return cmd; + } + if is_flatpak() { + let mut cmd = Command::new("flatpak-spawn"); + cmd.arg("--host"); + for (key, value) in envs { + cmd.arg(format!( + "--env={}={}", + key, + value.as_ref().to_string_lossy() + )); + } + cmd.arg(exe.as_ref()); + return cmd; + } + let mut cmd = Command::new(exe); + for (key, value) in envs { + cmd.env(key, value.as_ref()); + } + cmd +} + +/// Build a command with no extra environment variables. +pub fn command_for(exe: impl AsRef<OsStr>) -> Command { + build_command::<&str>(exe, &[]) +} + +pub fn bundled_umu_path_from_appdir(appdir: &Path) -> Option<PathBuf> { + let path = appdir.join("umu-run"); + if path.exists() { + Some(path) + } else { + None + } +} diff --git a/libs/nak/src/steam/mod.rs b/libs/nak/src/steam/mod.rs new file mode 100644 index 0000000..c5964aa --- /dev/null +++ b/libs/nak/src/steam/mod.rs @@ -0,0 +1,135 @@ +//! Steam integration module +//! +//! Handles Proton detection, Steam path detection, and mount point discovery. +//! Shortcuts and config.vdf manipulation removed (handled by C++ side). + +mod paths; +mod proton; + +// Re-export path detection utilities +pub use paths::{ + detect_steam_path_checked, find_steam_path, find_userdata_path, + get_steam_accounts, +}; + +// Re-export Proton detection +pub use proton::{find_steam_protons, SteamProton}; + +use std::fs; + +/// Kill Steam process gracefully, then force if needed +pub fn kill_steam() -> Result<(), Box<dyn std::error::Error>> { + use std::process::Command; + + // Try steam -shutdown first (graceful) + let _ = Command::new("steam") + .arg("-shutdown") + .status(); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + // Then force kill if still running + let _ = Command::new("pkill") + .arg("-9") + .arg("steam") + .status(); + + // Brief wait for Steam to fully exit + std::thread::sleep(std::time::Duration::from_secs(2)); + + Ok(()) +} + +/// Start Steam in background +pub fn start_steam() -> Result<(), Box<dyn std::error::Error>> { + use std::process::{Command, Stdio}; + + Command::new("setsid") + .arg("steam") + .arg("-silent") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + Ok(()) +} + +/// Restart Steam (kill then start) +pub fn restart_steam() -> Result<(), Box<dyn std::error::Error>> { + kill_steam()?; + start_steam()?; + Ok(()) +} + +// ============================================================================ +// STEAM_COMPAT_MOUNTS Detection +// ============================================================================ + +/// Directories that pressure-vessel already exposes by default +const ALREADY_EXPOSED: &[&str] = &[ + "bin", "etc", "home", "lib", "lib32", "lib64", + "overrides", "run", "sbin", "tmp", "usr", "var", +]; + +/// System directories that shouldn't be mounted +const SYSTEM_DIRS: &[&str] = &[ + "proc", "sys", "dev", "boot", "root", "lost+found", "snap", +]; + +/// Detect directories at root that need to be added to STEAM_COMPAT_MOUNTS +pub fn detect_extra_mounts() -> Vec<String> { + let mut mounts = Vec::new(); + + let Ok(entries) = fs::read_dir("/") else { + return mounts; + }; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + + if ALREADY_EXPOSED.contains(&name.as_str()) { + continue; + } + + if SYSTEM_DIRS.contains(&name.as_str()) { + continue; + } + + if name.starts_with('.') { + continue; + } + + if entry.path().is_dir() { + mounts.push(format!("/{}", name)); + } + } + + mounts.sort(); + mounts +} + +/// Generate launch options string with DXVK config file and STEAM_COMPAT_MOUNTS +pub fn generate_launch_options(dxvk_conf_path: Option<&std::path::Path>, is_electron_app: bool) -> String { + let mounts = detect_extra_mounts(); + + let dxvk_part = match dxvk_conf_path { + Some(path) => format!( + "DXVK_CONFIG_FILE=\"{}\"", + crate::config::normalize_path_for_steam(&path.to_string_lossy()) + ), + None => String::new(), + }; + + let electron_flags = if is_electron_app { + " --disable-gpu --no-sandbox" + } else { + "" + }; + + match (dxvk_part.is_empty(), mounts.is_empty()) { + (true, true) => format!("%command%{}", electron_flags), + (true, false) => format!("STEAM_COMPAT_MOUNTS={} %command%{}", mounts.join(":"), electron_flags), + (false, true) => format!("{} %command%{}", dxvk_part, electron_flags), + (false, false) => format!("{} STEAM_COMPAT_MOUNTS={} %command%{}", dxvk_part, mounts.join(":"), electron_flags), + } +} diff --git a/libs/nak/src/steam/paths.rs b/libs/nak/src/steam/paths.rs new file mode 100644 index 0000000..8ef5013 --- /dev/null +++ b/libs/nak/src/steam/paths.rs @@ -0,0 +1,259 @@ +//! Steam path detection utilities + +use std::fs; +use std::path::PathBuf; + +use crate::logging::{log_info, log_warning}; + +// ============================================================================ +// Core Path Detection +// ============================================================================ + +/// Find the Steam installation path. +#[must_use] +pub fn find_steam_path() -> Option<PathBuf> { + let home = std::env::var("HOME").ok()?; + + let steam_paths = [ + format!("{}/.steam/steam", home), + format!("{}/.local/share/Steam", home), + format!("{}/.var/app/com.valvesoftware.Steam/.steam/steam", home), + format!("{}/snap/steam/common/.steam/steam", home), + ]; + + steam_paths + .iter() + .map(PathBuf::from) + .find(|p| p.exists()) +} + +/// Find the Steam userdata directory. +#[must_use] +pub fn find_userdata_path() -> Option<PathBuf> { + let config = crate::config::AppConfig::load(); + if !config.selected_steam_account.is_empty() { + if let Some(path) = find_userdata_path_for_account(&config.selected_steam_account) { + return Some(path); + } + } + + let steam_path = find_steam_path()?; + let userdata = steam_path.join("userdata"); + + if !userdata.exists() { + return None; + } + + let accounts = get_steam_accounts(); + + if let Some(most_recent) = accounts.iter().find(|a| a.most_recent) { + let path = userdata.join(&most_recent.account_id); + if path.exists() { + log_info(&format!( + "Using Steam account from loginusers.vdf (MostRecent): {} ({})", + most_recent.persona_name, most_recent.account_id + )); + return Some(path); + } + } + + if let Some(first_account) = accounts.first() { + let path = userdata.join(&first_account.account_id); + if path.exists() { + log_info(&format!( + "Using Steam account from loginusers.vdf (most recent timestamp): {} ({})", + first_account.persona_name, first_account.account_id + )); + return Some(path); + } + } + + log_warning("Could not determine active Steam account from loginusers.vdf, falling back to directory modification time"); + + let mut user_dirs: Vec<PathBuf> = Vec::new(); + + if let Ok(entries) = fs::read_dir(&userdata) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name() { + let name_str = name.to_string_lossy(); + if name_str != "0" && name_str.chars().all(|c| c.is_ascii_digit()) { + user_dirs.push(path); + } + } + } + } + } + + user_dirs.sort_by(|a, b| { + let a_time = fs::metadata(a).and_then(|m| m.modified()).ok(); + let b_time = fs::metadata(b).and_then(|m| m.modified()).ok(); + b_time.cmp(&a_time) + }); + + user_dirs.into_iter().next() +} + +// ============================================================================ +// Steam Account Detection (loginusers.vdf parsing) +// ============================================================================ + +/// Information about a Steam user account +#[derive(Debug, Clone)] +pub struct SteamAccount { + pub account_id: String, + pub persona_name: String, + pub most_recent: bool, + pub timestamp: u64, +} + +/// Get all Steam accounts from loginusers.vdf with their display names +#[must_use] +pub fn get_steam_accounts() -> Vec<SteamAccount> { + let Some(steam_path) = find_steam_path() else { + return Vec::new(); + }; + + let loginusers_path = steam_path.join("config/loginusers.vdf"); + let userdata_path = steam_path.join("userdata"); + + let Ok(content) = fs::read_to_string(&loginusers_path) else { + return Vec::new(); + }; + + let mut accounts = Vec::new(); + + let mut current_steam_id: Option<String> = None; + let mut current_account: Option<SteamAccountBuilder> = None; + + for line in content.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with('"') && trimmed.ends_with('"') { + let id = trimmed.trim_matches('"'); + if id.len() == 17 && id.starts_with("7656") && id.chars().all(|c| c.is_ascii_digit()) { + if let (Some(steam_id), Some(builder)) = (current_steam_id.take(), current_account.take()) { + if let Some(account) = builder.build(&steam_id, &userdata_path) { + accounts.push(account); + } + } + current_steam_id = Some(id.to_string()); + current_account = Some(SteamAccountBuilder::default()); + } + } + + if let Some(ref mut builder) = current_account { + if let Some((key, value)) = parse_vdf_kv(trimmed) { + match key.to_lowercase().as_str() { + "accountname" => builder.account_name = Some(value), + "personaname" => builder.persona_name = Some(value), + "mostrecent" => builder.most_recent = value == "1", + "timestamp" => builder.timestamp = value.parse().unwrap_or(0), + _ => {} + } + } + } + } + + if let (Some(steam_id), Some(builder)) = (current_steam_id, current_account) { + if let Some(account) = builder.build(&steam_id, &userdata_path) { + accounts.push(account); + } + } + + accounts.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + accounts +} + +#[derive(Default)] +struct SteamAccountBuilder { + account_name: Option<String>, + persona_name: Option<String>, + most_recent: bool, + timestamp: u64, +} + +impl SteamAccountBuilder { + fn build(self, steam_id: &str, userdata_base: &std::path::Path) -> Option<SteamAccount> { + let account_name = self.account_name?; + let persona_name = self.persona_name.unwrap_or_else(|| account_name.clone()); + + let steam64: u64 = steam_id.parse().ok()?; + let account_id = (steam64 - 76561197960265728).to_string(); + + let userdata_path = userdata_base.join(&account_id); + + if !userdata_path.exists() { + return None; + } + + Some(SteamAccount { + account_id, + persona_name, + most_recent: self.most_recent, + timestamp: self.timestamp, + }) + } +} + +/// Parse a VDF key-value pair like: "Key" "Value" +fn parse_vdf_kv(line: &str) -> Option<(String, String)> { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + + for c in line.chars() { + match c { + '"' => { + if in_quotes { + parts.push(current.clone()); + current.clear(); + } + in_quotes = !in_quotes; + } + _ if in_quotes => current.push(c), + _ => {} + } + } + + if parts.len() >= 2 { + Some((parts[0].clone(), parts[1].clone())) + } else { + None + } +} + +/// Find the userdata path for a specific Steam account +#[must_use] +pub fn find_userdata_path_for_account(account_id: &str) -> Option<PathBuf> { + let steam_path = find_steam_path()?; + let userdata = steam_path.join("userdata").join(account_id); + + if userdata.exists() { + Some(userdata) + } else { + None + } +} + +// ============================================================================ +// Convenience Wrappers +// ============================================================================ + +/// Detect the Steam installation path with logging. +#[must_use] +pub fn detect_steam_path_checked() -> Option<String> { + match find_steam_path() { + Some(path) => { + let path_str = path.to_string_lossy().to_string(); + log_info(&format!("Steam detected at: {}", path_str)); + Some(path_str) + } + None => { + log_warning("Steam installation not detected! NaK requires Steam to be installed."); + None + } + } +} diff --git a/libs/nak/src/steam/proton.rs b/libs/nak/src/steam/proton.rs new file mode 100644 index 0000000..064fd96 --- /dev/null +++ b/libs/nak/src/steam/proton.rs @@ -0,0 +1,257 @@ +//! Steam Proton detection and management +//! +//! Finds Protons that Steam can see and use for non-Steam games. +//! This includes Steam's built-in Protons and custom Protons in compatibilitytools.d. + +use std::fs; +use std::path::PathBuf; + +use super::find_steam_path; + +/// Information about an installed Proton version +#[derive(Debug, Clone)] +pub struct SteamProton { + /// Display name (e.g., "GE-Proton9-20", "Proton Experimental") + pub name: String, + /// Internal name used in config.vdf (e.g., "proton_experimental", "GE-Proton9-20") + pub config_name: String, + /// Full path to the Proton installation + pub path: PathBuf, + /// Whether this is a Steam-provided Proton (vs custom) + pub is_steam_proton: bool, + /// Whether this is Proton Experimental + pub is_experimental: bool, +} + +impl SteamProton { + /// Get the path to the wine binary. + pub fn wine_binary(&self) -> Option<PathBuf> { + let paths = [ + self.path.join("files/bin/wine"), + self.path.join("dist/bin/wine"), + ]; + paths.into_iter().find(|p| p.exists()) + } + + /// Get the path to the wineserver binary. + pub fn wineserver_binary(&self) -> Option<PathBuf> { + let paths = [ + self.path.join("files/bin/wineserver"), + self.path.join("dist/bin/wineserver"), + ]; + paths.into_iter().find(|p| p.exists()) + } + + /// Get the bin directory containing wine executables. + pub fn bin_dir(&self) -> Option<PathBuf> { + self.wine_binary().and_then(|p| p.parent().map(|p| p.to_path_buf())) + } +} + +/// Find all Protons that Steam can use (Proton 10+ only) +pub fn find_steam_protons() -> Vec<SteamProton> { + let mut protons = Vec::new(); + + let Some(steam_path) = find_steam_path() else { + return protons; + }; + + let is_flatpak = steam_path.to_string_lossy().contains(".var/app/com.valvesoftware.Steam"); + + // 1. Steam's built-in Protons (steamapps/common/Proton*) + protons.extend(find_builtin_protons(&steam_path)); + + // 2. Custom Protons in user's compatibilitytools.d + protons.extend(find_custom_protons(&steam_path)); + + // 3. System-level Protons in /usr/share/steam/compatibilitytools.d/ + if is_flatpak { + crate::logging::log_info("Flatpak Steam detected - skipping system protons in /usr/share/steam/compatibilitytools.d/"); + } else { + protons.extend(find_system_protons()); + } + + // Filter to only include Proton 10+ (required for Steam-native integration) + protons.retain(is_proton_10_or_newer); + + // Filter to only include Protons with valid wine binaries + protons.retain(|p| { + let has_wine = p.wine_binary().is_some(); + if !has_wine { + crate::logging::log_warning(&format!( + "Skipping Proton '{}': wine binary not found at expected paths (files/bin/wine or dist/bin/wine)", + p.name + )); + } + has_wine + }); + + // Sort: Experimental first, then by name descending (newest first) + protons.sort_by(|a, b| { + if a.is_experimental != b.is_experimental { + return b.is_experimental.cmp(&a.is_experimental); + } + b.name.cmp(&a.name) + }); + + protons +} + +/// Check if a Proton version is 10 or newer +fn is_proton_10_or_newer(proton: &SteamProton) -> bool { + let name = &proton.name; + + if proton.is_experimental || name.contains("Experimental") { + return true; + } + + if name.contains("CachyOS") { + return true; + } + + if name == "LegacyRuntime" || name.contains("Runtime") { + return false; + } + + if name.starts_with("GE-Proton") { + if let Some(version_part) = name.strip_prefix("GE-Proton") { + let major: Option<u32> = version_part + .split('-') + .next() + .and_then(|s| s.parse().ok()); + return major.map(|v| v >= 10).unwrap_or(false); + } + } + + if name.starts_with("Proton ") { + if let Some(version_part) = name.strip_prefix("Proton ") { + let major: Option<u32> = version_part + .split('.') + .next() + .and_then(|s| s.parse().ok()); + return major.map(|v| v >= 10).unwrap_or(false); + } + } + + if name.starts_with("EM-") { + if let Some(version_part) = name.strip_prefix("EM-") { + let major: Option<u32> = version_part + .split('.') + .next() + .and_then(|s| s.parse().ok()); + return major.map(|v| v >= 10).unwrap_or(false); + } + } + + // Unknown format - allow it + true +} + +/// Find Steam's built-in Proton versions +fn find_builtin_protons(steam_path: &std::path::Path) -> Vec<SteamProton> { + let mut found = Vec::new(); + let common_dir = steam_path.join("steamapps/common"); + + let Ok(entries) = fs::read_dir(&common_dir) else { + return found; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + + if name.starts_with("Proton") && path.join("proton").exists() { + let is_experimental = name.contains("Experimental"); + + let config_name = if is_experimental { + "proton_experimental".to_string() + } else { + let version = name.replace("Proton ", ""); + let major = version.split('.').next().unwrap_or(&version); + format!("proton_{}", major) + }; + + found.push(SteamProton { + name: name.clone(), + config_name, + path, + is_steam_proton: true, + is_experimental, + }); + } + } + + found +} + +/// Find custom Protons in compatibilitytools.d +fn find_custom_protons(steam_path: &std::path::Path) -> Vec<SteamProton> { + let mut found = Vec::new(); + let compat_dir = steam_path.join("compatibilitytools.d"); + + let Ok(entries) = fs::read_dir(&compat_dir) else { + return found; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + + let has_proton = path.join("proton").exists(); + let has_vdf = path.join("compatibilitytool.vdf").exists(); + + if has_proton || has_vdf { + found.push(SteamProton { + name: name.clone(), + config_name: name.clone(), + path, + is_steam_proton: false, + is_experimental: false, + }); + } + } + + found +} + +/// Find system-level Protons in /usr/share/steam/compatibilitytools.d/ +fn find_system_protons() -> Vec<SteamProton> { + let mut found = Vec::new(); + let system_compat_dir = PathBuf::from("/usr/share/steam/compatibilitytools.d"); + + let Ok(entries) = fs::read_dir(&system_compat_dir) else { + return found; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + + let has_proton = path.join("proton").exists(); + let has_vdf = path.join("compatibilitytool.vdf").exists(); + + if has_proton || has_vdf { + found.push(SteamProton { + name: name.clone(), + config_name: name.clone(), + path, + is_steam_proton: false, + is_experimental: false, + }); + } + } + + found +} diff --git a/libs/nak/src/utils.rs b/libs/nak/src/utils.rs new file mode 100644 index 0000000..7d7886c --- /dev/null +++ b/libs/nak/src/utils.rs @@ -0,0 +1,19 @@ +//! Shared utility functions used across the application + +use std::error::Error; +use std::fs; +use std::path::Path; + +/// Download a file from URL to the specified path +pub fn download_file(url: &str, path: &Path) -> Result<(), Box<dyn Error>> { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let resp = ureq::get(url).call()?; + let mut reader = resp.into_reader(); + let mut file = fs::File::create(path)?; + std::io::copy(&mut reader, &mut file)?; + Ok(()) +} |
