diff options
Diffstat (limited to 'libs/nak/src/installers/symlinks.rs')
| -rw-r--r-- | libs/nak/src/installers/symlinks.rs | 359 |
1 files changed, 359 insertions, 0 deletions
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 + )); + } + } +} |
