aboutsummaryrefslogtreecommitdiff
path: root/libs/nak/src/installers
diff options
context:
space:
mode:
Diffstat (limited to 'libs/nak/src/installers')
-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
3 files changed, 1471 insertions, 0 deletions
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
+ ));
+ }
+ }
+}