aboutsummaryrefslogtreecommitdiff
path: root/libs/nak/src/installers/symlinks.rs
blob: e8e477be5380eb2b285a874059b122e4293f3318 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
//! 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/<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", "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
            ));
        }
    }
}