//! 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 { log_info(&format!( "Symlink skip '{}' (app_id {}): no prefix_path", game.name, game.app_id )); 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); let my_games_dir = game_user_dir.join("Documents/My Games"); log_info(&format!( "Symlink checking '{}' (app_id {}): prefix={}, user='{}', my_games_exists={}", game.name, game.app_id, game_prefix.display(), game_username, my_games_dir.is_dir() )); // 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/) 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//Documents /// - NaK Tools/Prefix AppData Local -> prefix/drive_c/users//AppData/Local /// - NaK Tools/Prefix AppData Roaming -> prefix/drive_c/users//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", "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() { log_info(&format!( " scan_and_link_all: {} not a dir for '{}', skipping", game_base.display(), game_name )); return 0; } let Ok(entries) = fs::read_dir(game_base) else { log_info(&format!( " scan_and_link_all: cannot read {} for '{}'", game_base.display(), game_name )); 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 )); } } }