diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-14 02:45:12 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-14 02:45:25 -0600 |
| commit | 817e8f5cd26739c69d930d21cd9dc4c0b6e4984e (patch) | |
| tree | 52aed11d6751bb7d20b06acf197d56fac113203d /libs/nak/src/installers | |
| parent | 51a9f8f197727f00896e5de44569b098923527dd (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/installers')
| -rw-r--r-- | libs/nak/src/installers/mod.rs | 334 | ||||
| -rw-r--r-- | libs/nak/src/installers/prefix_setup.rs | 778 | ||||
| -rw-r--r-- | libs/nak/src/installers/symlinks.rs | 359 |
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(®_file)?; + file.write_all(WINE_SETTINGS_REG.as_bytes())?; + + let wine_bin = proton.wine_binary().ok_or_else(|| { + let err_msg = format!( + "Wine binary not found for Proton '{}' (checked files/bin/wine and dist/bin/wine)", + proton.name + ); + log_callback(format!("Error: {}", err_msg)); + err_msg + })?; + + let wineserver_bin = proton.wineserver_binary().unwrap_or_else(|| { + wine_bin.with_file_name("wineserver") + }); + + let bin_dir = proton.bin_dir().ok_or_else(|| { + let err_msg = "Could not determine Proton bin directory"; + log_callback(format!("Error: {}", err_msg)); + err_msg + })?; + + let path_env = format!( + "{}:{}", + bin_dir.to_string_lossy(), + std::env::var("PATH").unwrap_or_default() + ); + + log_callback("Applying Wine registry settings...".to_string()); + log_install("Running wine regedit..."); + + let reg_envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_path.display().to_string()), + ("WINE", wine_bin.display().to_string()), + ("WINESERVER", wineserver_bin.display().to_string()), + ("PATH", path_env), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let regedit_status = runtime_wrap::build_command(&wine_bin, ®_envs) + .arg("regedit") + .arg(®_file) + .status(); + + match regedit_status { + Ok(status) => { + if status.success() { + log_callback("Registry settings applied successfully".to_string()); + log_install("Wine registry settings applied successfully"); + } else { + let msg = format!("regedit exited with code {:?}", status.code()); + log_callback(format!("Warning: {}", msg)); + log_warning(&msg); + } + } + Err(e) => { + let msg = format!("Failed to run regedit: {}", e); + log_callback(format!("Error: {}", msg)); + log_error(&msg); + return Err(msg.into()); + } + } + + let _ = fs::remove_file(®_file); + Ok(()) +} diff --git a/libs/nak/src/installers/prefix_setup.rs b/libs/nak/src/installers/prefix_setup.rs new file mode 100644 index 0000000..4f27606 --- /dev/null +++ b/libs/nak/src/installers/prefix_setup.rs @@ -0,0 +1,778 @@ +//! Unified prefix setup for MO2 +//! +//! This module handles all the dependency installation logic. +//! +//! Key approach (ORDER MATTERS): +//! 1. Install dependencies via winetricks (handles wineboot internally) +//! 2. Install custom dotnet runtimes (dotnet9sdk, dotnetdesktop10) +//! 3. Auto-detect installed games and apply registry entries +//! 4. Apply Wine registry settings (LAST - after prefix is fully set up) + +use std::error::Error; +use std::fs; +use std::path::Path; +use std::process::Child; + +use super::{apply_wine_registry_settings, TaskContext}; +use crate::config::AppConfig; +use crate::deps::{install_standard_deps_cancellable, STANDARD_VERBS}; +use crate::game_finder::{detect_all_games, known_games, Game, Launcher}; +use crate::logging::{log_install, log_warning}; +use crate::runtime_wrap; +use crate::steam::{detect_steam_path_checked, SteamProton}; + +// ============================================================================= +// Constants +// ============================================================================= + +/// .NET 9 SDK download URL +const DOTNET9_SDK_URL: &str = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.exe"; + +/// .NET Desktop Runtime 10 download URL +const DOTNET_DESKTOP10_URL: &str = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.exe"; + +/// Drive letters to keep in the prefix (c: is Windows root, z: maps to Linux /) +const ALLOWED_DRIVE_LETTERS: &[&str] = &["c:", "z:"]; + +/// Install all dependencies to a prefix. +/// +/// Order: proton init → winetricks → custom dotnet → game detection → registry → win11 → dotnet fixes +/// +/// # Arguments +/// * `app_id` - Steam AppID (used for registry operations) +pub fn install_all_dependencies( + prefix_root: &Path, + install_proton: &SteamProton, + ctx: &TaskContext, + start_progress: f32, + end_progress: f32, + app_id: u32, +) -> Result<(), Box<dyn Error>> { + fs::create_dir_all(AppConfig::get_tmp_path())?; + + // Progress distribution + let init_end = start_progress + (end_progress - start_progress) * 0.10; + let winetricks_end = start_progress + (end_progress - start_progress) * 0.50; + let dotnet_end = start_progress + (end_progress - start_progress) * 0.65; + let games_end = start_progress + (end_progress - start_progress) * 0.75; + + // ========================================================================= + // 0. Initialize prefix with Proton wrapper (creates proper prefix structure) + // ========================================================================= + ctx.set_status("Setting up Windows compatibility layer...".to_string()); + ctx.log("Initializing Wine prefix with Proton...".to_string()); + log_install("Running proton wineboot to initialize prefix"); + + if let Err(e) = initialize_prefix_with_proton(prefix_root, install_proton, app_id, ctx) { + ctx.log(format!("Warning: Proton prefix init failed: {}", e)); + log_warning(&format!("Proton prefix init failed: {}", e)); + // Continue anyway - winetricks might still work + } + + ctx.set_progress(init_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 0.5. Clean up unwanted drive letters (keep only C: and Z:) + // ========================================================================= + ctx.set_status("Optimizing prefix configuration...".to_string()); + ctx.log("Removing unwanted drive letters (keeping C: and Z:)...".to_string()); + log_install("Cleaning up Wine drive letters"); + + if let Err(e) = cleanup_wine_drives(prefix_root, install_proton) { + ctx.log(format!("Warning: Drive cleanup had issues: {}", e)); + log_warning(&format!("Drive cleanup failed: {}", e)); + } + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 1. Standard Dependencies via Winetricks + // ========================================================================= + ctx.set_status("Installing required Windows components (this may take several minutes)...".to_string()); + ctx.log(format!( + "Installing {} dependencies via winetricks: {}", + STANDARD_VERBS.len(), + STANDARD_VERBS.join(", ") + )); + log_install(&format!("Running winetricks with {} verbs", STANDARD_VERBS.len())); + + let winetricks_log_cb = { + let ctx = ctx.clone(); + move |msg: String| { + ctx.log(msg.clone()); + ctx.set_status(msg); + } + }; + + if let Err(e) = install_standard_deps_cancellable(prefix_root, install_proton, winetricks_log_cb, &ctx.cancel_flag) { + let msg = format!("Winetricks installation had issues: {}", e); + ctx.log(format!("Warning: {}", msg)); + log_warning(&msg); + } + + ctx.set_progress(winetricks_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 2. Custom .NET Runtimes (not in winetricks yet) + // ========================================================================= + ctx.set_status("Installing .NET runtime (1 of 2)...".to_string()); + ctx.log("Installing .NET 9 SDK...".to_string()); + + if let Err(e) = install_dotnet_runtime(prefix_root, install_proton, DOTNET9_SDK_URL, "dotnet-sdk-9", ctx) { + ctx.log(format!("Warning: .NET 9 SDK install failed: {}", e)); + log_warning(&format!(".NET 9 SDK install failed: {}", e)); + } + + ctx.set_status("Installing .NET runtime (2 of 2)...".to_string()); + ctx.log("Installing .NET Desktop Runtime 10...".to_string()); + + if let Err(e) = install_dotnet_runtime(prefix_root, install_proton, DOTNET_DESKTOP10_URL, "dotnet-desktop-10", ctx) { + ctx.log(format!("Warning: .NET Desktop 10 install failed: {}", e)); + log_warning(&format!(".NET Desktop 10 install failed: {}", e)); + } + + ctx.set_progress(dotnet_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 3. Auto-detect and register installed games + // ========================================================================= + ctx.set_status("Detecting your installed games...".to_string()); + ctx.log("Auto-detecting installed Steam games...".to_string()); + log_install("Auto-detecting installed games for registry"); + + let game_log_cb = { + let ctx = ctx.clone(); + move |msg: String| ctx.log(msg) + }; + auto_apply_game_registries(prefix_root, install_proton, &game_log_cb, Some(app_id)); + + ctx.set_progress(games_end); + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 4. Registry Settings (after prefix is fully initialized) + // ========================================================================= + ctx.set_status("Configuring Windows registry...".to_string()); + ctx.log("Applying Wine Registry Settings...".to_string()); + log_install("Applying Wine registry settings"); + + let log_cb = { + let ctx = ctx.clone(); + move |msg: String| ctx.log(msg) + }; + apply_wine_registry_settings(prefix_root, install_proton, &log_cb, Some(app_id))?; + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + // ========================================================================= + // 5. Set Windows 11 Mode + // ========================================================================= + ctx.set_status("Finalizing compatibility settings...".to_string()); + ctx.log("Setting Windows 11 mode...".to_string()); + log_install("Setting Windows 11 mode via winetricks"); + + if let Err(e) = set_windows_11_mode(prefix_root, install_proton, ctx) { + ctx.log(format!("Warning: Failed to set Windows 11 mode: {}", e)); + log_warning(&format!("Failed to set Windows 11 mode: {}", e)); + } + + if ctx.is_cancelled() { + return Err("Cancelled".into()); + } + + ctx.set_progress(end_progress); + ctx.set_status("Dependencies installed".to_string()); + Ok(()) +} + +/// Install a .NET runtime via direct exe download and wine execution +fn install_dotnet_runtime( + prefix_root: &Path, + proton: &SteamProton, + url: &str, + name: &str, + ctx: &TaskContext, +) -> Result<(), Box<dyn Error>> { + let cache_dir = AppConfig::get_default_cache_dir(); + fs::create_dir_all(&cache_dir)?; + + let filename = url.split('/').next_back().unwrap_or("dotnet-installer.exe"); + let installer_path = cache_dir.join(filename); + + // Download if not cached + if !installer_path.exists() { + log_install(&format!("Downloading {}...", name)); + let response = ureq::get(url) + .set("User-Agent", "NaK-Rust") + .call() + .map_err(|e| format!("Failed to download {}: {}", name, e))?; + + let mut file = fs::File::create(&installer_path)?; + std::io::copy(&mut response.into_reader(), &mut file)?; + } + + // Run installer with wine + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found".into()); + }; + + log_install(&format!("Running {} installer...", name)); + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ]; + let mut cmd = runtime_wrap::build_command(&wine_bin, &envs); + cmd.arg(&installer_path) + .arg("/install") + .arg("/quiet") + .arg("/norestart"); + + let status = ctx.run_cancellable(cmd)?; + + if !status.success() { + return Err(format!("{} installer exited with code {:?}", name, status.code()).into()); + } + + log_install(&format!("{} installed successfully", name)); + Ok(()) +} + +/// Initialize prefix with Proton wrapper +/// +/// Runs `proton run wineboot -u` to properly initialize the prefix with all +/// the Steam/Proton environment variables. This creates a proper prefix +/// structure that Steam recognizes. +fn initialize_prefix_with_proton( + prefix_root: &Path, + proton: &SteamProton, + app_id: u32, + ctx: &TaskContext, +) -> Result<(), Box<dyn Error>> { + // Find the proton wrapper script (not the wine binary) + let proton_script = proton.path.join("proton"); + if !proton_script.exists() { + return Err(format!("Proton wrapper script not found at {:?}", proton_script).into()); + } + + // Get Steam root path + let steam_root = detect_steam_path_checked() + .ok_or("Could not find Steam installation")?; + + // The compatdata path is the PARENT of the pfx directory + let compat_data_path = prefix_root.parent() + .ok_or("Could not determine compatdata path")?; + + log_install(&format!("STEAM_COMPAT_DATA_PATH={:?}", compat_data_path)); + + // Collect all env vars upfront so build_command can forward them + // via --env= flags in Flatpak mode. + let mut envs: Vec<(&str, String)> = vec![ + ("STEAM_COMPAT_CLIENT_INSTALL_PATH", steam_root.clone()), + ("STEAM_COMPAT_DATA_PATH", compat_data_path.display().to_string()), + ("SteamAppId", app_id.to_string()), + ("SteamGameId", app_id.to_string()), + ("DISPLAY", String::new()), // Suppress GUI + ("WAYLAND_DISPLAY", String::new()), // Suppress GUI + ("WINEDEBUG", "-all".to_string()), + ("WINEDLLOVERRIDES", "msdia80.dll=n;conhost.exe=d;cmd.exe=d".to_string()), + ]; + + let (exe, args): (std::path::PathBuf, Vec<&str>) = if runtime_wrap::use_umu_for_prefix() { + if let Some(umu_run) = runtime_wrap::resolve_umu_run() { + log_install(&format!("Initializing prefix with umu-run: {:?}", umu_run)); + envs.push(("PROTONPATH", proton.path.display().to_string())); + envs.push(("WINEPREFIX", prefix_root.display().to_string())); + envs.push(("GAMEID", app_id.to_string())); + (umu_run, vec!["wineboot", "-u"]) + } else { + log_warning( + "UMU prefix mode enabled but no umu-run was found; falling back to proton wrapper", + ); + (proton_script.clone(), vec!["run", "wineboot", "-u"]) + } + } else { + log_install(&format!("Initializing prefix with proton wrapper: {:?}", proton_script)); + (proton_script.clone(), vec!["run", "wineboot", "-u"]) + }; + + let mut cmd = runtime_wrap::build_command(&exe, &envs); + cmd.args(&args); + + let status = ctx.run_cancellable(cmd)?; + + if !status.success() { + return Err(format!("proton wineboot failed with exit code: {:?}", status.code()).into()); + } + + // Give it a moment for files to land + std::thread::sleep(std::time::Duration::from_secs(2)); + + // Verify prefix was created + if prefix_root.exists() { + log_install("Proton prefix initialized successfully"); + Ok(()) + } else { + Err("Prefix directory not created after wineboot".into()) + } +} + +/// Clean up unwanted Wine drive letters from prefix +/// +/// Removes both symbolic links in dosdevices/ and registry entries for +/// drive letters other than C: and Z:. This prevents Wine from mounting +/// other users' drives as E:, F:, G:, etc. +fn cleanup_wine_drives( + prefix_root: &Path, + proton: &SteamProton, +) -> Result<(), Box<dyn Error>> { + let dosdevices = prefix_root.join("dosdevices"); + + if !dosdevices.exists() { + log_install("dosdevices directory not found, skipping drive cleanup"); + return Ok(()); + } + + // ========================================================================= + // 1. Remove unwanted symlinks from dosdevices/ + // ========================================================================= + let mut removed_drives = Vec::new(); + + if let Ok(entries) = fs::read_dir(&dosdevices) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_lowercase(); + + // Skip allowed drives and non-drive entries (like com1, lpt1, etc.) + if ALLOWED_DRIVE_LETTERS.contains(&name.as_str()) { + continue; + } + + // Only process drive letters (single letter followed by colon) + if name.len() == 2 && name.ends_with(':') && name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) { + let path = entry.path(); + if let Err(e) = fs::remove_file(&path) { + log_warning(&format!("Failed to remove drive symlink {}: {}", name, e)); + } else { + removed_drives.push(name.to_uppercase()); + } + } + } + } + + if !removed_drives.is_empty() { + log_install(&format!("Removed drive symlinks: {}", removed_drives.join(", "))); + } + + // ========================================================================= + // 2. Clean up registry entries for removed drives + // ========================================================================= + let Some(wine_bin) = proton.wine_binary() else { + log_warning("Wine binary not found, skipping registry cleanup"); + return Ok(()); + }; + + // Create a .reg file to remove drive type entries + let tmp_dir = AppConfig::get_tmp_path(); + fs::create_dir_all(&tmp_dir)?; + + let mut reg_content = String::from("Windows Registry Editor Version 5.00\n\n"); + + // Remove entries from HKLM\Software\Wine\Drives + for drive in &removed_drives { + // Remove both uppercase and lowercase variants + reg_content.push_str(&format!( + "[HKEY_LOCAL_MACHINE\\Software\\Wine\\Drives]\n\"{drive}\"=-\n\n" + )); + } + + if !removed_drives.is_empty() { + let reg_file = tmp_dir.join("drive_cleanup.reg"); + fs::write(®_file, ®_content)?; + + let drive_envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let status = runtime_wrap::build_command(&wine_bin, &drive_envs) + .arg("regedit") + .arg(®_file) + .status(); + + let _ = fs::remove_file(®_file); + + match status { + Ok(s) if s.success() => { + log_install("Registry drive entries cleaned up"); + } + Ok(s) => { + log_warning(&format!("Registry cleanup may have failed (exit code: {:?})", s.code())); + } + Err(e) => { + log_warning(&format!("Failed to run registry cleanup: {}", e)); + } + } + } + + Ok(()) +} + +/// Public wrapper to clean up Wine drives on an existing prefix +/// +/// This can be called from the UI to fix drive letter issues on existing prefixes. +pub fn cleanup_prefix_drives( + prefix_root: &Path, + proton: &SteamProton, +) -> Result<Vec<String>, Box<dyn Error>> { + let dosdevices = prefix_root.join("dosdevices"); + + if !dosdevices.exists() { + return Err("dosdevices directory not found - is this a valid Wine prefix?".into()); + } + + // Collect drives before cleanup + let mut removed = Vec::new(); + + if let Ok(entries) = fs::read_dir(&dosdevices) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_lowercase(); + if name.len() == 2 && name.ends_with(':') && name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) + && !ALLOWED_DRIVE_LETTERS.contains(&name.as_str()) { + removed.push(name.to_uppercase()); + } + } + } + + // Run the actual cleanup + cleanup_wine_drives(prefix_root, proton)?; + + Ok(removed) +} + +/// Set Windows 11 mode for the prefix using winetricks +/// +/// This should be called AFTER all components are installed. +/// Sets the Windows version to Windows 11 which is required for MO2 to work properly. +fn set_windows_11_mode( + prefix_root: &Path, + proton: &SteamProton, + ctx: &TaskContext, +) -> Result<(), Box<dyn Error>> { + use crate::deps::ensure_winetricks; + + let winetricks_path = ensure_winetricks()?; + + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found".into()); + }; + + let Some(wineserver_bin) = proton.wineserver_binary() else { + return Err("Wineserver binary not found".into()); + }; + + log_install("Running winetricks win11..."); + + let envs: Vec<(&str, String)> = vec![ + ("WINE", wine_bin.display().to_string()), + ("WINESERVER", wineserver_bin.display().to_string()), + ("WINEPREFIX", prefix_root.display().to_string()), + ]; + let mut cmd = runtime_wrap::build_command(&winetricks_path, &envs); + cmd.arg("-q").arg("win11"); + + let status = ctx.run_cancellable(cmd)?; + + if !status.success() { + return Err(format!("winetricks win11 failed with exit code: {:?}", status.code()).into()); + } + + log_install("Windows 11 mode set successfully"); + Ok(()) +} + +// ============================================================================= +// DPI Configuration +// ============================================================================= + +/// Common DPI presets with their percentage labels +pub const DPI_PRESETS: &[(u32, &str)] = &[ + (96, "100%"), + (120, "125%"), + (144, "150%"), + (192, "200%"), +]; + +/// Apply DPI setting to a Wine prefix via registry +pub fn apply_dpi( + prefix_root: &Path, + proton: &SteamProton, + dpi_value: u32, +) -> Result<(), Box<dyn Error>> { + log_install(&format!("Applying DPI {} to prefix", dpi_value)); + + let wine_bin = proton.wine_binary().ok_or_else(|| { + format!("Wine binary not found for Proton '{}'", proton.name) + })?; + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let status = runtime_wrap::build_command(&wine_bin, &envs) + .arg("reg") + .arg("add") + .arg(r"HKCU\Control Panel\Desktop") + .arg("/v") + .arg("LogPixels") + .arg("/t") + .arg("REG_DWORD") + .arg("/d") + .arg(dpi_value.to_string()) + .arg("/f") + .status()?; + + if !status.success() { + return Err(format!("Failed to apply DPI setting: exit code {:?}", status.code()).into()); + } + + log_install(&format!("DPI {} applied successfully", dpi_value)); + Ok(()) +} + +/// Launch a test application (winecfg, regedit, notepad, control) and return its PID +pub fn launch_dpi_test_app( + prefix_root: &Path, + proton: &SteamProton, + app_name: &str, +) -> Result<Child, Box<dyn Error>> { + let wine_bin = proton.wine_binary().ok_or_else(|| { + format!("Wine binary not found for Proton '{}'", proton.name) + })?; + + log_install(&format!( + "Launching {} with wine={:?} prefix={:?}", + app_name, wine_bin, prefix_root + )); + + if !prefix_root.exists() { + return Err(format!("Prefix not found: {:?}", prefix_root).into()); + } + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let child = runtime_wrap::build_command(&wine_bin, &envs) + .arg(app_name) + .spawn()?; + + Ok(child) +} + +/// Kill the wineserver for a prefix (terminates all Wine processes in that prefix) +pub fn kill_wineserver(prefix_root: &Path, proton: &SteamProton) { + log_install("Killing wineserver for prefix"); + + let Some(wineserver_bin) = proton.wineserver_binary() else { + log_install("Wineserver binary not found, skipping kill"); + return; + }; + + let envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_root.display().to_string()), + ]; + let _ = runtime_wrap::build_command(&wineserver_bin, &envs) + .arg("-k") + .status(); +} + +// ============================================================================ +// Game Registry Detection (uses game_finder module) +// ============================================================================ + +/// Auto-detect installed games and apply registry entries +/// +/// This uses the game_finder module to detect installed games across all +/// supported launchers (Steam, Heroic, Bottles) and automatically adds +/// the registry entries so mod managers can detect them. +pub fn auto_apply_game_registries( + prefix_path: &Path, + proton: &SteamProton, + log_callback: &impl Fn(String), + _app_id: Option<u32>, +) { + let Some(wine_bin) = proton.wine_binary() else { + log_warning("Wine binary not found, skipping game registry auto-detection"); + return; + }; + + // Use the new game_finder module to detect all games + let scan_result = detect_all_games(); + let mut applied_count = 0; + + for game in &scan_result.games { + // Only process games that have registry info + let (Some(reg_path), Some(reg_value)) = (&game.registry_path, &game.registry_value) else { + continue; + }; + + // Apply registry for this game + if apply_game_registry( + prefix_path, + &wine_bin, + game, + reg_path, + reg_value, + log_callback, + ) { + applied_count += 1; + } + } + + if applied_count > 0 { + log_callback(format!("Auto-configured {} game(s) in registry", applied_count)); + log_install(&format!("Auto-applied registry for {} detected game(s)", applied_count)); + } +} + +/// Apply a game's registry entry with a custom install path. +/// +/// Looks up the game by name in KNOWN_GAMES, then writes the registry entry +/// pointing to `install_path`. Use this when the game is in a custom/stock +/// folder that auto-detection won't find. +pub fn apply_registry_for_game_path( + prefix_path: &Path, + proton: &SteamProton, + game_name: &str, + install_path: &Path, + log_callback: &impl Fn(String), +) -> Result<(), String> { + let Some(wine_bin) = proton.wine_binary() else { + return Err("Wine binary not found".to_string()); + }; + + let known = known_games::find_by_name(game_name); + let (reg_path, reg_value) = if let Some(kg) = known { + (kg.registry_path, kg.registry_value) + } else { + return Err(format!("Unknown game: {game_name}")); + }; + + let fake_game = Game { + name: game_name.to_string(), + install_path: install_path.to_path_buf(), + app_id: known.map(|k| k.steam_app_id.to_string()).unwrap_or_default(), + prefix_path: None, + launcher: Launcher::Steam { is_flatpak: false, is_snap: false }, + my_games_folder: known.and_then(|k| k.my_games_folder.map(String::from)), + appdata_local_folder: known.and_then(|k| k.appdata_local_folder.map(String::from)), + appdata_roaming_folder: known.and_then(|k| k.appdata_roaming_folder.map(String::from)), + registry_path: Some(reg_path.to_string()), + registry_value: Some(reg_value.to_string()), + }; + + if apply_game_registry(prefix_path, &wine_bin, &fake_game, reg_path, reg_value, log_callback) { + Ok(()) + } else { + Err(format!("Failed to apply registry for {game_name}")) + } +} + +/// Return the list of known game names for UI display. +pub fn known_game_names() -> Vec<&'static str> { + known_games::KNOWN_GAMES.iter().map(|g| g.name).collect() +} + +/// Apply registry entry for a single game +fn apply_game_registry( + prefix_path: &Path, + wine_bin: &Path, + game: &Game, + reg_path: &str, + reg_value: &str, + log_callback: &impl Fn(String), +) -> bool { + log_callback(format!("Found {}, applying registry...", game.name)); + + // Convert Linux path to Wine Z: drive path with escaped backslashes for .reg file + let linux_path = game.install_path.to_string_lossy(); + let wine_path_reg = format!("Z:{}", linux_path.replace('/', "\\\\")); + + // Create .reg file content + let reg_content = format!( + r#"Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\{}] +"{}"="{}" + +[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\{}] +"{}"="{}" +"#, + reg_path, + reg_value, + wine_path_reg, + reg_path.strip_prefix("Software\\").unwrap_or(reg_path), + reg_value, + wine_path_reg, + ); + + // Write temp .reg file + let tmp_dir = AppConfig::get_tmp_path(); + let reg_file = tmp_dir.join(format!("game_reg_{}.reg", game.app_id)); + + if let Err(e) = fs::write(®_file, ®_content) { + log_warning(&format!("Failed to write registry file for {}: {}", game.name, e)); + return false; + } + + // Apply registry + let reg_envs: Vec<(&str, String)> = vec![ + ("WINEPREFIX", prefix_path.display().to_string()), + ("WINEDLLOVERRIDES", "mshtml=d".to_string()), + ("PROTON_USE_XALIA", "0".to_string()), + ]; + let status = runtime_wrap::build_command(wine_bin, ®_envs) + .arg("regedit") + .arg(®_file) + .status(); + + let _ = fs::remove_file(®_file); + + match status { + Ok(s) if s.success() => { + log_install(&format!("Applied registry for {} -> {:?}", game.name, game.install_path)); + true + } + Ok(s) => { + log_warning(&format!( + "Registry for {} may have failed (exit code: {:?})", + game.name, + s.code() + )); + false + } + Err(e) => { + log_warning(&format!("Failed to apply registry for {}: {}", game.name, e)); + false + } + } +} diff --git a/libs/nak/src/installers/symlinks.rs b/libs/nak/src/installers/symlinks.rs new file mode 100644 index 0000000..ef8cbb4 --- /dev/null +++ b/libs/nak/src/installers/symlinks.rs @@ -0,0 +1,359 @@ +//! Symlink management for NaK prefixes +//! +//! Creates symlinks FROM NaK prefix TO game prefixes. +//! Data stays in the game prefix, NaK just has links pointing to it. +//! +//! This inverted approach means: +//! - Game saves remain in their original location (Steam cloud sync works) +//! - NaK prefix provides unified access to all game data +//! - No data duplication or sync issues +//! +//! NaK Tools folder contains convenience symlinks pointing INTO the prefix +//! for easy access to Documents, AppData, etc. + +// Allow unused items - some functions are public API for future use +#![allow(dead_code)] + +use std::fs; +use std::path::Path; + +use crate::game_finder::{detect_all_games, Game, GameScanResult}; +use crate::logging::{log_info, log_warning}; + +// ============================================================================ +// Public API +// ============================================================================ + +/// Create symlinks from NaK prefix to game prefixes for all detected games +/// +/// This creates symlinks in the NaK prefix pointing to the actual save/config +/// folders in each game's own prefix. The symlink direction is: +/// +/// NaK/Documents/My Games/Skyrim -> game_prefix/Documents/My Games/Skyrim +/// NaK/AppData/Local/Skyrim -> game_prefix/AppData/Local/Skyrim +/// +/// Data stays in the game prefix (preserving Steam Cloud sync), while NaK +/// provides unified access through symlinks. +pub fn create_game_symlinks(nak_prefix: &Path, games: &[Game]) { + let users_dir = nak_prefix.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let user_dir = users_dir.join(&username); + + let documents = user_dir.join("Documents"); + let my_games = documents.join("My Games"); + let appdata_local = user_dir.join("AppData/Local"); + let appdata_roaming = user_dir.join("AppData/Roaming"); + + // Ensure base directories exist (real folders in NaK prefix) + let _ = fs::create_dir_all(&my_games); + let _ = fs::create_dir_all(&appdata_local); + let _ = fs::create_dir_all(&appdata_roaming); + + let mut linked_count = 0; + + for game in games { + // Skip games without prefixes + let Some(game_prefix) = &game.prefix_path else { + continue; + }; + + // Discover the game prefix's user directory + let game_users_dir = game_prefix.join("drive_c/users"); + let game_username = find_prefix_username(&game_users_dir); + let game_user_dir = game_users_dir.join(&game_username); + + // Scan and symlink ALL folders in the game prefix's Documents/My Games/ + linked_count += scan_and_link_all( + &my_games, + &game_user_dir.join("Documents/My Games"), + "Documents/My Games", + &game.name, + game_prefix, + ); + + // Also link the Documents folder itself for non-My Games entries + // (some games put saves directly in Documents/<GameName>) + linked_count += scan_and_link_all( + &documents, + &game_user_dir.join("Documents"), + "Documents", + &game.name, + game_prefix, + ); + + // Scan and symlink ALL folders in AppData/Local/ + linked_count += scan_and_link_all( + &appdata_local, + &game_user_dir.join("AppData/Local"), + "AppData/Local", + &game.name, + game_prefix, + ); + + // Scan and symlink ALL folders in AppData/Roaming/ + linked_count += scan_and_link_all( + &appdata_roaming, + &game_user_dir.join("AppData/Roaming"), + "AppData/Roaming", + &game.name, + game_prefix, + ); + } + + if linked_count > 0 { + log_info(&format!( + "Created {} symlinks to game prefixes", + linked_count + )); + } + + // Create "My Documents" symlink for compatibility + let my_documents = user_dir.join("My Documents"); + if !my_documents.exists() && fs::symlink_metadata(&my_documents).is_err() { + if let Err(e) = std::os::unix::fs::symlink("Documents", &my_documents) { + log_warning(&format!("Failed to create My Documents symlink: {}", e)); + } + } +} + +/// Create NaK Tools convenience symlinks pointing INTO the prefix +/// +/// Creates symlinks in NaK Tools folder for easy access: +/// - NaK Tools/Prefix Documents -> prefix/drive_c/users/<user>/Documents +/// - NaK Tools/Prefix AppData Local -> prefix/drive_c/users/<user>/AppData/Local +/// - NaK Tools/Prefix AppData Roaming -> prefix/drive_c/users/<user>/AppData/Roaming +pub fn create_nak_tools_symlinks(tools_dir: &Path, prefix_path: &Path) { + let users_dir = prefix_path.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let user_dir = users_dir.join(&username); + + // Symlink: NaK Tools/Prefix Documents -> prefix Documents + let documents_link = tools_dir.join("Prefix Documents"); + let documents_target = user_dir.join("Documents"); + create_or_update_symlink(&documents_link, &documents_target, "Prefix Documents"); + + // Symlink: NaK Tools/Prefix AppData Local -> prefix AppData/Local + let appdata_local_link = tools_dir.join("Prefix AppData Local"); + let appdata_local_target = user_dir.join("AppData/Local"); + create_or_update_symlink(&appdata_local_link, &appdata_local_target, "Prefix AppData Local"); + + // Symlink: NaK Tools/Prefix AppData Roaming -> prefix AppData/Roaming + let appdata_roaming_link = tools_dir.join("Prefix AppData Roaming"); + let appdata_roaming_target = user_dir.join("AppData/Roaming"); + create_or_update_symlink(&appdata_roaming_link, &appdata_roaming_target, "Prefix AppData Roaming"); + + log_info("Created NaK Tools convenience symlinks to prefix folders"); +} + +/// Create or update a symlink +fn create_or_update_symlink(link_path: &Path, target: &Path, name: &str) { + // Remove existing symlink or file + if link_path.exists() || fs::symlink_metadata(link_path).is_ok() { + let _ = fs::remove_file(link_path); + let _ = fs::remove_dir_all(link_path); + } + + // Create symlink + if let Err(e) = std::os::unix::fs::symlink(target, link_path) { + log_warning(&format!("Failed to create {} symlink: {}", name, e)); + } +} + +/// Create symlinks for all detected games +/// +/// Convenience function that detects games and creates symlinks in one call. +pub fn create_game_symlinks_auto(nak_prefix: &Path) -> GameScanResult { + let result = detect_all_games(); + create_game_symlinks(nak_prefix, &result.games); + result +} + +/// Ensure only the Temp directory exists in AppData/Local +/// +/// MO2 and other tools require AppData/Local/Temp to exist. +/// We create only this essential directory, leaving other game-specific +/// folders to be symlinked from game prefixes. +pub fn ensure_temp_directory(prefix_path: &Path) { + let users_dir = prefix_path.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let user_dir = users_dir.join(&username); + + let temp_dir = user_dir.join("AppData/Local/Temp"); + if let Err(e) = fs::create_dir_all(&temp_dir) { + log_warning(&format!("Failed to create Temp directory: {}", e)); + } else { + log_info("Ensured AppData/Local/Temp directory exists"); + } +} + +// ============================================================================ +// Internal Functions +// ============================================================================ + +/// Directories to skip when scanning prefix folders for symlinking. +/// These are Wine/Proton internal or system dirs, not game data. +const SKIP_DIRS: &[&str] = &[ + "Temp", "Microsoft", "wine", "Public", "root", + "Application Data", "Cookies", "Local Settings", + "NetHood", "PrintHood", "Recent", "SendTo", + "Start Menu", "Templates", "My Documents", "My Music", + "My Pictures", "My Videos", "Desktop", "Downloads", + "Favorites", "Links", "Saved Games", "Searches", + "Contacts", "3D Objects", +]; + +/// Scan all subdirectories in a game prefix folder and create symlinks +/// for each one in the corresponding NaK prefix folder. +/// +/// Returns the number of symlinks created. +fn scan_and_link_all( + nak_base: &Path, + game_base: &Path, + label: &str, + game_name: &str, + _game_prefix: &Path, +) -> usize { + if !game_base.is_dir() { + return 0; + } + + let Ok(entries) = fs::read_dir(game_base) else { + return 0; + }; + + let mut count = 0; + for entry in entries.flatten() { + // Only symlink directories (game folders), not loose files + if !entry.path().is_dir() { + continue; + } + + let folder_name = entry.file_name().to_string_lossy().to_string(); + + // Skip Wine/system internal directories + if SKIP_DIRS.iter().any(|&s| s.eq_ignore_ascii_case(&folder_name)) { + continue; + } + + // Skip if it's "My Games" and we're scanning Documents (handled separately) + if label == "Documents" && folder_name == "My Games" { + continue; + } + + let nak_path = nak_base.join(&folder_name); + let source_path = entry.path(); + + if create_symlink_if_needed(&nak_path, &source_path, game_name, label, &folder_name) { + count += 1; + } + } + + count +} + +/// Create a symlink if the target doesn't already exist or is already correct. +/// +/// Returns true if a symlink was created (or already existed correctly). +fn create_symlink_if_needed( + nak_path: &Path, + source_path: &Path, + game_name: &str, + label: &str, + folder_name: &str, +) -> bool { + // Check if target already exists + if nak_path.exists() || fs::symlink_metadata(nak_path).is_ok() { + // Check if it's already a symlink to the correct location + if let Ok(target) = fs::read_link(nak_path) { + if target == source_path { + return true; // Already correctly linked + } + } + // Something else exists here, don't overwrite + return false; + } + + // Ensure parent directory exists + if let Some(parent) = nak_path.parent() { + let _ = fs::create_dir_all(parent); + } + + // Create the symlink + match std::os::unix::fs::symlink(source_path, nak_path) { + Ok(()) => { + log_info(&format!( + "Linked {}/{} -> {} ({})", + label, + folder_name, + source_path.display(), + game_name, + )); + true + } + Err(e) => { + log_warning(&format!( + "Failed to create symlink for {} ({}/{}): {}", + game_name, label, folder_name, e + )); + false + } + } +} + +/// Find the username from a Wine prefix users directory +fn find_prefix_username(users_dir: &Path) -> String { + if let Ok(entries) = fs::read_dir(users_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name != "Public" && name != "root" { + return name; + } + } + } + "steamuser".to_string() +} + +// ============================================================================ +// Oblivion Lowercase INI Symlinks +// ============================================================================ + +/// Create lowercase INI symlinks for Oblivion (some tools expect lowercase) +pub fn create_oblivion_ini_symlinks(prefix_path: &Path) { + let users_dir = prefix_path.join("drive_c/users"); + let username = find_prefix_username(&users_dir); + let oblivion_dir = users_dir + .join(&username) + .join("Documents/My Games/Oblivion"); + + if !oblivion_dir.exists() { + return; + } + + create_lowercase_ini_symlink(&oblivion_dir, "Oblivion.ini", "oblivion.ini"); + create_lowercase_ini_symlink(&oblivion_dir, "OblivionPrefs.ini", "oblivionprefs.ini"); +} + +/// Create a lowercase symlink for an INI file +fn create_lowercase_ini_symlink(dir: &Path, original: &str, lowercase: &str) { + let original_path = dir.join(original); + let lowercase_path = dir.join(lowercase); + + // Only create if original exists and lowercase doesn't + if original_path.exists() + && !lowercase_path.exists() + && fs::symlink_metadata(&lowercase_path).is_err() + { + // Create relative symlink (just the filename) + if let Err(e) = std::os::unix::fs::symlink(original, &lowercase_path) { + log_warning(&format!( + "Failed to create lowercase symlink {} -> {}: {}", + lowercase, original, e + )); + } else { + log_info(&format!( + "Created lowercase INI symlink: {} -> {}", + lowercase, original + )); + } + } +} |
