diff options
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 + )); + } + } +} |
