aboutsummaryrefslogtreecommitdiff
path: root/libs/nak/src
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-14 02:45:12 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-14 02:45:25 -0600
commit817e8f5cd26739c69d930d21cd9dc4c0b6e4984e (patch)
tree52aed11d6751bb7d20b06acf197d56fac113203d /libs/nak/src
parent51a9f8f197727f00896e5de44569b098923527dd (diff)
Add FUSE external mapping support, BG3/Oblivion Remastered fixes, fomod-plus and NaK integration
FUSE VFS now deploys non-data-dir mod mappings (Paks, OBSE, UE4SS, etc.) via real symlinks and injects file-level data-dir mappings (plugins.txt, loadorder.txt) into the VFS tree. Fixes game launches for Oblivion Remastered (Root Builder path resolution, script extender support) and BG3 (Wine prefix documents directory, file mapper symlinks on Linux). Vendors mo2-fomod-plus plugin and NaK crate for FOMOD installer and game finder/runtime support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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(())
+}