aboutsummaryrefslogtreecommitdiff
path: root/libs/nak/src
diff options
context:
space:
mode:
Diffstat (limited to 'libs/nak/src')
-rw-r--r--libs/nak/src/config.rs154
-rw-r--r--libs/nak/src/deps/mod.rs177
-rw-r--r--libs/nak/src/deps/tools.rs174
-rw-r--r--libs/nak/src/dxvk.rs71
-rw-r--r--libs/nak/src/game_finder/bottles.rs147
-rw-r--r--libs/nak/src/game_finder/heroic.rs277
-rw-r--r--libs/nak/src/game_finder/known_games.rs245
-rw-r--r--libs/nak/src/game_finder/mod.rs191
-rw-r--r--libs/nak/src/game_finder/registry.rs227
-rw-r--r--libs/nak/src/game_finder/steam.rs251
-rw-r--r--libs/nak/src/game_finder/vdf.rs254
-rw-r--r--libs/nak/src/installers/mod.rs334
-rw-r--r--libs/nak/src/installers/prefix_setup.rs778
-rw-r--r--libs/nak/src/installers/symlinks.rs359
-rw-r--r--libs/nak/src/lib.rs15
-rw-r--r--libs/nak/src/logging.rs99
-rw-r--r--libs/nak/src/runtime_wrap.rs121
-rw-r--r--libs/nak/src/steam/mod.rs135
-rw-r--r--libs/nak/src/steam/paths.rs259
-rw-r--r--libs/nak/src/steam/proton.rs257
-rw-r--r--libs/nak/src/utils.rs19
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(&reg_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, &reg_envs)
+ .arg("regedit")
+ .arg(&reg_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(&reg_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(&reg_file, &reg_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(&reg_file)
+ .status();
+
+ let _ = fs::remove_file(&reg_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(&reg_file, &reg_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, &reg_envs)
+ .arg("regedit")
+ .arg(&reg_file)
+ .status();
+
+ let _ = fs::remove_file(&reg_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(())
+}