epik/games/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::PathBuf;
4use std::sync::atomic::AtomicBool;
5use std::sync::Arc;
6
7use crate::api::Game;
8use crate::downloader::{DownloadManager, DownloadProgress};
9use crate::launcher::{GameLauncher, LaunchConfigBuilder};
10use serde_json::Value;
11use sysinfo::Disks;
12// Actually Game is in api.
13// We need LegendaryClient.
14use crate::auth::AuthManager;
15use crate::config::Config;
16use crate::legendary::LegendaryClient;
17use crate::{Error, Result};
18
19pub mod advanced;
20pub mod dlc;
21pub mod organization;
22pub use advanced::{AdvancedGameManager, GameConfigProfile, Mod, MoveStatus, SaveGameBackup};
23pub use dlc::{DlcBundle, DlcInfo, DlcInstallationStatus, DlcManager, DlcOwnership};
24pub use organization::{
25    AdvancedFilter, GameCollection, GameOrganization, GameTag, SortCriteria, ViewMode,
26};
27
28// Callback type for download progress updates
29// Useful for UI to track installation progress in real-time
30pub type DownloadProgressCallback = std::sync::Arc<dyn Fn(DownloadProgress) + Send + Sync>;
31
32// Cloud save synchronization status
33#[derive(Debug, Clone)]
34pub struct CloudSaveStatus {
35    pub enabled: bool,
36    pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
37    pub has_local_saves: bool,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct InstalledGame {
42    pub app_name: String,
43    pub app_title: String,
44    pub app_version: String,
45    pub install_path: PathBuf,
46    pub executable: String,
47    #[serde(default)]
48    pub wine_executable: Option<String>,
49    #[serde(default)]
50    pub cloud_save_enabled: bool,
51    #[serde(default)]
52    pub last_cloud_sync: Option<String>,
53}
54
55impl InstalledGame {
56    pub fn save(&self, config: &Config) -> Result<()> {
57        let games_dir = Self::installed_games_dir(config)?;
58        fs::create_dir_all(&games_dir)?;
59
60        let game_file = games_dir.join(format!("{}.json", self.app_name));
61        let contents = serde_json::to_string_pretty(self)?;
62        fs::write(&game_file, contents)?;
63
64        Ok(())
65    }
66
67    pub fn load(config: &Config, app_name: &str) -> Result<Self> {
68        let games_dir = Self::installed_games_dir(config)?;
69        let game_file = games_dir.join(format!("{}.json", app_name));
70
71        if !game_file.exists() {
72            return Err(Error::GameNotFound(app_name.to_string()));
73        }
74
75        let contents = fs::read_to_string(&game_file)?;
76        Ok(serde_json::from_str(&contents)?)
77    }
78
79    pub fn list_installed(config: &Config) -> Result<Vec<Self>> {
80        let games_dir = Self::installed_games_dir(config)?;
81
82        if !games_dir.exists() {
83            return Ok(vec![]);
84        }
85
86        let mut games = Vec::new();
87
88        for entry in fs::read_dir(&games_dir)? {
89            let entry = entry?;
90            let path = entry.path();
91
92            if path.extension().and_then(|s| s.to_str()) == Some("json") {
93                if let Ok(contents) = fs::read_to_string(&path) {
94                    if let Ok(game) = serde_json::from_str::<InstalledGame>(&contents) {
95                        games.push(game);
96                    }
97                }
98            }
99        }
100
101        Ok(games)
102    }
103
104    pub fn delete(&self, config: &Config) -> Result<()> {
105        let games_dir = Self::installed_games_dir(config)?;
106        let game_file = games_dir.join(format!("{}.json", self.app_name));
107
108        if game_file.exists() {
109            fs::remove_file(&game_file)?;
110        }
111
112        Ok(())
113    }
114
115    fn installed_games_dir(_config: &Config) -> Result<PathBuf> {
116        let data_dir = Config::data_dir()?;
117        Ok(data_dir.join("installed"))
118    }
119}
120
121pub struct GameManager {
122    config: Config,
123    auth: AuthManager,
124    client: LegendaryClient,
125}
126
127impl GameManager {
128    pub fn new(config: Config, auth: AuthManager) -> Result<Self> {
129        let client = LegendaryClient::new()?;
130        Ok(Self {
131            config,
132            auth,
133            client,
134        })
135    }
136
137    async fn ensure_valid_token(&mut self) -> Result<crate::auth::AuthToken> {
138        let stored_token = self
139            .auth
140            .get_stored_token()
141            .ok_or(Error::NotAuthenticated)?;
142
143        if !self.client.resume_session(stored_token).await {
144            return Err(Error::Auth("Session invalid or expired".to_string()));
145        }
146
147        // Get the valid token (LegendaryClient maintains it)
148        let token = self.client.get_token()?;
149        // Update auth manager if it changed
150        self.auth.set_token(token.clone())?;
151
152        Ok(token)
153    }
154
155    pub async fn list_library(&mut self) -> Result<Vec<Game>> {
156        let _token = self.ensure_valid_token().await?;
157        self.client.get_games().await
158    }
159
160    pub async fn enrich_game(
161        &mut self,
162        app_name: &str,
163        namespace: &str,
164        catalog_item_id: &str,
165    ) -> Result<Game> {
166        let token = self.ensure_valid_token().await?;
167        Ok(self
168            .client
169            .enrich_game_details(&token, app_name, namespace, catalog_item_id)
170            .await)
171    }
172
173    pub fn list_installed(&self) -> Result<Vec<InstalledGame>> {
174        InstalledGame::list_installed(&self.config)
175    }
176
177    pub async fn install_game(&mut self, app_name: &str) -> Result<()> {
178        self.install_game_with_callback(app_name, None, None).await
179    }
180
181    pub async fn install_game_with_callback(
182        &mut self,
183        app_name: &str,
184        progress_callback: Option<DownloadProgressCallback>,
185        cancel_flag: Option<Arc<AtomicBool>>,
186    ) -> Result<()> {
187        let token = self.ensure_valid_token().await?;
188
189        log::info!("Starting installation for game: {}", app_name);
190
191        // Download and parse game manifest
192        log::info!("Downloading game manifest...");
193        let manifest = self.client.download_manifest(&token, app_name).await?;
194
195        // Check available disk space
196        let install_dir = self.config.install_dir.clone(); // Clone necessary for blocking task if needed, but here we invoke sync logic
197        let required_space = manifest.build_size;
198
199        let disks = Disks::new_with_refreshed_list();
200        // Find best fit disk or default to first one that contains the path
201        let mut checked = false;
202        // Simple heuristic: find mount point that install_dir starts with
203        for disk in &disks {
204            if install_dir.starts_with(disk.mount_point()) {
205                if disk.available_space() < required_space {
206                    return Err(Error::Other(format!(
207                        "Insufficient disk space. Required: {} bytes, Available: {} bytes",
208                        required_space,
209                        disk.available_space()
210                    )));
211                }
212                checked = true;
213                break;
214            }
215        }
216
217        // If path not found in mount points (e.g. relative path resolution issue), check root or warn?
218        // Fallback: Check if install_dir exists and check its metadata, or just skip if complex.
219        // For now, if not checked, we might assume it's OK or check specific common paths.
220        if !checked && !disks.is_empty() {
221            // Fallback to checking the first disk (often root) if we can't determine mount point mapping
222            // This is imperfect but better than nothing for a simple implementation.
223            // Or better: try to canonicalize path.
224            if let Ok(canon) = install_dir.canonicalize() {
225                for disk in &disks {
226                    if canon.starts_with(disk.mount_point()) {
227                        if disk.available_space() < required_space {
228                            return Err(Error::Other(format!(
229                                "Insufficient disk space. Required: {} bytes, Available: {} bytes",
230                                required_space,
231                                disk.available_space()
232                            )));
233                        }
234                        break;
235                    }
236                }
237            }
238        }
239
240        log::info!("Manifest downloaded: version {}", manifest.app_version);
241        log::info!("Manifest version: {}", manifest.app_version);
242        log::info!("Build size: {} bytes", manifest.build_size);
243        log::info!("Files to download: {}", manifest.file_list.len());
244
245        // Create install directory
246        let install_path = self.config.install_dir.join(app_name);
247        fs::create_dir_all(&install_path)?;
248
249        log::info!("Created install directory: {:?}", install_path);
250
251        // Download game files using DownloadManager
252        if !manifest.file_list.is_empty() {
253            log::info!("Downloading game files...");
254
255            let cache_dir = Config::data_dir()?.join("cache").join("chunks");
256
257            // Apply bandwidth limiting from config if enabled
258            let bandwidth_limit = if self.config.enable_bandwidth_limit {
259                Some(self.config.bandwidth_limit_mbps)
260            } else {
261                None
262            };
263
264            let download_manager =
265                DownloadManager::with_bandwidth_limit(cache_dir, bandwidth_limit)?;
266
267            let callback = progress_callback.clone();
268            download_manager
269                .download_game(
270                    &manifest,
271                    &install_path,
272                    move |progress: DownloadProgress| {
273                        log::info!(
274                            "Progress: {}/{} files, {}/{} bytes ({:.2} MB/s)",
275                            progress.downloaded_files,
276                            progress.total_files,
277                            progress.downloaded_bytes,
278                            progress.total_bytes,
279                            progress.download_speed / 1024.0 / 1024.0
280                        );
281
282                        // Call UI callback if provided
283                        if let Some(ref cb) = callback {
284                            cb(progress);
285                        }
286                    },
287                    cancel_flag.clone(),
288                )
289                .await?;
290
291            log::info!("✓ Game files downloaded");
292        } else {
293            log::warn!("Note: Manifest has no files to download.");
294            log::info!("Creating installation record with manifest data...");
295        }
296
297        // Create a minimal launcher/executable so the game can be launched.
298        // Normalize Windows-style separators in manifests to platform paths.
299        let launcher_rel_path = Self::normalize_launch_path(&manifest.launch_exe);
300        let launcher_path = install_path.join(&launcher_rel_path);
301
302        if let Some(parent) = launcher_path.parent() {
303            fs::create_dir_all(parent)?;
304        }
305
306        // Only create a placeholder if the file was not provided by the download
307        if !launcher_path.exists() {
308            #[cfg(target_os = "windows")]
309            {
310                let mut bat_contents = String::new();
311                bat_contents.push_str("@echo off\r\n");
312                bat_contents.push_str(&format!("echo Running %{}%...\r\n", app_name));
313                bat_contents.push_str("echo This is a placeholder launcher generated by Epik.\r\n");
314                fs::write(&launcher_path, bat_contents)?;
315            }
316
317            #[cfg(not(target_os = "windows"))]
318            {
319                let mut sh_contents = String::new();
320                sh_contents.push_str("#!/usr/bin/env bash\n\n");
321                sh_contents.push_str(&format!("echo \"Running {}...\"\n", app_name));
322                sh_contents
323                    .push_str("echo \"This is a placeholder launcher generated by Epik.\"\n");
324                sh_contents.push_str("echo \"Close this window to exit.\"\n");
325                sh_contents.push_str("sleep 1\n");
326                fs::write(&launcher_path, sh_contents)?;
327            }
328        }
329
330        // Ensure the launcher is executable on Unix platforms
331        #[cfg(unix)]
332        {
333            use std::os::unix::fs::PermissionsExt;
334            let mut perms = fs::metadata(&launcher_path)?.permissions();
335            perms.set_mode(0o755);
336            fs::set_permissions(&launcher_path, perms)?;
337        }
338
339        // Create installed game entry with manifest data
340        let installed_game = InstalledGame {
341            app_name: app_name.to_string(),
342            app_title: app_name.to_string(),
343            app_version: manifest.app_version.clone(),
344            install_path: install_path.clone(),
345            executable: launcher_rel_path.to_string_lossy().to_string(),
346            wine_executable: None,
347            cloud_save_enabled: true,
348            last_cloud_sync: None,
349        };
350
351        installed_game.save(&self.config)?;
352
353        log::info!("Game installation completed for: {}", app_name);
354        log::info!("✓ Installation complete!");
355
356        Ok(())
357    }
358
359    pub fn import_game(
360        &self,
361        app_name: &str,
362        install_path: PathBuf,
363        executable: &str,
364    ) -> Result<()> {
365        log::info!("Importing game: {}", app_name);
366
367        if !install_path.exists() {
368            return Err(Error::Other(format!(
369                "Install path does not exist: {:?}",
370                install_path
371            )));
372        }
373
374        let full_exe_path = install_path.join(executable);
375        if !full_exe_path.exists() {
376            return Err(Error::Other(format!(
377                "Executable does not exist: {:?}",
378                full_exe_path
379            )));
380        }
381
382        // Create installed game entry
383        let installed_game = InstalledGame {
384            app_name: app_name.to_string(),
385            app_title: app_name.to_string(), // User can edit this later if needed
386            app_version: "imported".to_string(),
387            install_path,
388            executable: executable.to_string(),
389            wine_executable: None,
390            cloud_save_enabled: false,
391            last_cloud_sync: None,
392        };
393
394        installed_game.save(&self.config)?;
395
396        log::info!("✓ Game imported successfully!");
397        Ok(())
398    }
399
400    // Set a custom Wine/Proton executable for a game
401    pub fn set_game_wine(&self, app_name: &str, wine_executable: Option<String>) -> Result<()> {
402        let mut game = InstalledGame::load(&self.config, app_name)?;
403        game.wine_executable = wine_executable;
404        game.save(&self.config)
405    }
406
407    // Scan a directory for potential game installations
408    // Returns a list of found games with their install path and detected executable
409    pub fn scan_directory(&self, scan_path: &PathBuf) -> Result<Vec<(String, PathBuf, String)>> {
410        let mut found_games = Vec::new();
411
412        if !scan_path.exists() {
413            return Err(Error::Other(format!(
414                "Scan path does not exist: {:?}",
415                scan_path
416            )));
417        }
418
419        // Common executable patterns
420        let exe_patterns = vec![
421            ".exe", ".bat", ".sh", // Basic executables
422        ];
423
424        // Scan directory entries
425        for entry in fs::read_dir(scan_path)? {
426            let entry = entry?;
427            let path = entry.path();
428
429            if path.is_dir() {
430                // Potential game directory
431                let dir_name = path
432                    .file_name()
433                    .and_then(|n| n.to_str())
434                    .unwrap_or("")
435                    .to_string();
436
437                // Look for executables in this directory
438                if let Ok(files) = fs::read_dir(&path) {
439                    for file_entry in files {
440                        if let Ok(file) = file_entry {
441                            let file_path = file.path();
442                            if file_path.is_file() {
443                                let file_name =
444                                    file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
445
446                                // Check if it matches executable patterns
447                                for pattern in &exe_patterns {
448                                    if file_name.ends_with(pattern) {
449                                        let relative_exe = file_path
450                                            .strip_prefix(&path)
451                                            .unwrap_or(&file_path)
452                                            .to_str()
453                                            .unwrap_or(file_name)
454                                            .to_string();
455
456                                        found_games.push((
457                                            dir_name.clone(),
458                                            path.clone(),
459                                            relative_exe,
460                                        ));
461                                        break; // Found one, move to next directory
462                                    }
463                                }
464                            }
465                        }
466                    }
467                }
468            }
469        }
470
471        log::info!(
472            "Found {} potential games in {:?}",
473            found_games.len(),
474            scan_path
475        );
476        Ok(found_games)
477    }
478
479    // Scan common Epic/Legendary install locations
480    pub fn scan_common_locations(&self) -> Result<Vec<(String, PathBuf, String)>> {
481        let mut all_found = Vec::new();
482
483        // Common locations to check
484        let common_paths = vec![
485            // Epic Games Launcher default
486            PathBuf::from("/opt/EpicGames"),
487            PathBuf::from(format!(
488                "{}/.wine/drive_c/Program Files/Epic Games",
489                std::env::var("HOME").unwrap_or_default()
490            )),
491            // Legendary default
492            PathBuf::from(format!(
493                "{}/.legendary/installed",
494                std::env::var("HOME").unwrap_or_default()
495            )),
496            // Heroic Games Launcher
497            PathBuf::from(format!(
498                "{}/Games/Heroic",
499                std::env::var("HOME").unwrap_or_default()
500            )),
501            // Custom install dir from config
502            self.config.install_dir.clone(),
503        ];
504
505        for path in common_paths {
506            if path.exists() {
507                if let Ok(mut games) = self.scan_directory(&path) {
508                    all_found.append(&mut games);
509                }
510            }
511        }
512
513        Ok(all_found)
514    }
515
516    // Verify game files integrity against manifest
517    pub async fn verify_game(&mut self, app_name: &str) -> Result<Vec<String>> {
518        let token = self.ensure_valid_token().await?;
519        let game = InstalledGame::load(&self.config, app_name)?;
520
521        log::info!("Verifying game files for: {}", app_name);
522
523        // Download manifest to verify against
524        let manifest = self.client.download_manifest(&token, app_name).await?;
525
526        let mut corrupted_files = Vec::new();
527
528        // Check each file in manifest
529        for file_manifest in &manifest.file_list {
530            let file_path = game.install_path.join(&file_manifest.filename);
531
532            if !file_path.exists() {
533                log::warn!("Missing file: {}", file_manifest.filename);
534                corrupted_files.push(file_manifest.filename.clone());
535                continue;
536            }
537
538            // Verify file hash
539            match fs::read(&file_path) {
540                Ok(file_data) => {
541                    use sha2::{Digest, Sha256};
542                    let mut hasher = Sha256::new();
543                    hasher.update(&file_data);
544                    let hash = hasher.finalize();
545
546                    if hash.as_slice() != file_manifest.file_hash.as_slice() {
547                        log::warn!("Corrupted file: {}", file_manifest.filename);
548                        corrupted_files.push(file_manifest.filename.clone());
549                    }
550                }
551                Err(e) => {
552                    log::error!("Failed to read file {}: {}", file_manifest.filename, e);
553                    corrupted_files.push(file_manifest.filename.clone());
554                }
555            }
556        }
557
558        if corrupted_files.is_empty() {
559            log::info!("✓ All files verified successfully!");
560        } else {
561            log::warn!("Found {} corrupted/missing files", corrupted_files.len());
562        }
563
564        Ok(corrupted_files)
565    }
566
567    // Repair game by re-downloading corrupted files
568    pub async fn repair_game(
569        &mut self,
570        app_name: &str,
571        progress_callback: Option<DownloadProgressCallback>,
572    ) -> Result<()> {
573        log::info!("Starting repair for: {}", app_name);
574
575        // First verify to find corrupted files
576        let corrupted_files = self.verify_game(app_name).await?;
577
578        if corrupted_files.is_empty() {
579            log::info!("No files need repair");
580            return Ok(());
581        }
582
583        log::info!("Repairing {} files...", corrupted_files.len());
584
585        // Get token and manifest
586        let token = self.ensure_valid_token().await?;
587        let manifest = self.client.download_manifest(&token, app_name).await?;
588        let game = InstalledGame::load(&self.config, app_name)?;
589
590        // Filter manifest to only include corrupted files
591        let mut repair_manifest = manifest.clone();
592        repair_manifest
593            .file_list
594            .retain(|f| corrupted_files.contains(&f.filename));
595
596        // Re-download corrupted files
597        let cache_dir = Config::data_dir()?.join("cache").join("chunks");
598
599        // Apply bandwidth limiting from config if enabled
600        let bandwidth_limit = if self.config.enable_bandwidth_limit {
601            Some(self.config.bandwidth_limit_mbps)
602        } else {
603            None
604        };
605
606        let download_manager =
607            crate::downloader::DownloadManager::with_bandwidth_limit(cache_dir, bandwidth_limit)?;
608
609        let callback = progress_callback.clone();
610        download_manager
611            .download_game(
612                &repair_manifest,
613                &game.install_path,
614                move |progress| {
615                    log::info!(
616                        "Repair progress: {}/{} files",
617                        progress.downloaded_files,
618                        progress.total_files
619                    );
620
621                    if let Some(ref cb) = callback {
622                        cb(progress);
623                    }
624                },
625                None,
626            )
627            .await?;
628
629        log::info!("✓ Repair complete!");
630        Ok(())
631    }
632
633    pub fn launch_game(&self, app_name: &str) -> Result<()> {
634        log::info!("Starting launch_game for: {}", app_name);
635
636        let mut game = InstalledGame::load(&self.config, app_name)?;
637        log::info!("Loaded game config: {:?}", game);
638
639        // Normalize any Windows-style separators before joining
640        let executable_rel = Self::normalize_launch_path(&game.executable);
641        let executable_path = game.install_path.join(&executable_rel);
642        log::debug!("Looking for executable at: {:?}", executable_path);
643
644        // If the expected executable is missing, try to resolve it from Legendary metadata,
645        // otherwise fall back to Legendary launch.
646        if !executable_path.exists() {
647            log::warn!(
648                "Executable not found, attempting Legendary metadata fallback for {}",
649                app_name
650            );
651
652            if let Some((resolved_path, resolved_workdir)) =
653                self.legendary_resolve_install(app_name)?
654            {
655                log::info!("Resolved executable via Legendary: {:?}", resolved_path);
656                return self.launch_resolved(app_name, &game, resolved_path, resolved_workdir);
657            }
658
659            log::warn!(
660                "Legendary metadata not available, delegating to legendary launch for {}",
661                app_name
662            );
663            return self.launch_with_legendary(app_name, &game);
664        }
665
666        log::info!("Launching game: {} ({})", game.app_title, game.app_name);
667        log::debug!("Executable: {:?}", executable_path);
668        log::debug!("Install path: {:?}", game.install_path);
669        log::debug!("Wine executable: {:?}", game.wine_executable);
670
671        // Detect placeholder launcher and use Legendary instead
672        if Self::is_placeholder_launcher(&executable_path) {
673            log::warn!(
674                "Detected placeholder launcher, attempting Legendary metadata fallback for {}",
675                app_name
676            );
677
678            if let Some((resolved_path, resolved_workdir)) =
679                self.legendary_resolve_install(app_name)?
680            {
681                log::info!("Resolved executable via Legendary: {:?}", resolved_path);
682                return self.launch_resolved(app_name, &game, resolved_path, resolved_workdir);
683            }
684
685            log::warn!(
686                "Legendary metadata not available, delegating to legendary launch for {}",
687                app_name
688            );
689            return self.launch_with_legendary(app_name, &game);
690        }
691
692        // Pre-launch cloud save sync if enabled
693        if game.cloud_save_enabled {
694            log::info!("Pre-launch cloud save sync...");
695            let rt = tokio::runtime::Runtime::new()?;
696            if let Err(e) = rt.block_on(self.sync_cloud_saves_safe(app_name)) {
697                log::warn!("Cloud save sync failed: {}", e);
698            } else {
699                game.last_cloud_sync = Some(chrono::Utc::now().to_rfc3339());
700                let _ = game.save(&self.config);
701            }
702        }
703
704        // Determine if we need Wine (on Linux for Windows games or explicitly set)
705        let selected_wine = game
706            .wine_executable
707            .as_ref()
708            .map(|p| std::path::PathBuf::from(p));
709        let needs_wine = selected_wine.is_some()
710            || (cfg!(target_os = "linux") && Self::is_windows_executable(&executable_path));
711
712        log::info!("Needs Wine: {} (selected: {:?})", needs_wine, selected_wine);
713
714        let launch_config = if needs_wine {
715            // Setup Wine/Proton
716            let data_dir = Config::data_dir()?;
717            let wine_prefix = data_dir.join("prefixes").join(app_name);
718            log::info!("Creating Wine prefix at: {:?}", wine_prefix);
719            fs::create_dir_all(&wine_prefix)?;
720
721            LaunchConfigBuilder::new(executable_path, game.install_path.clone())
722                .with_wine(selected_wine, Some(wine_prefix))
723                .build()
724        } else {
725            LaunchConfigBuilder::new(executable_path, game.install_path.clone()).build()
726        };
727
728        log::info!("Launching game with config: {:?}", launch_config);
729        let launcher = GameLauncher::new(launch_config);
730        let mut child = launcher.launch()?;
731
732        log::info!("Game process started successfully");
733
734        // Post-launch cloud save upload (wait for game exit)
735        if game.cloud_save_enabled {
736            let app_name_clone = app_name.to_string();
737            let config_clone = self.config.clone();
738            let auth_clone = self.auth.clone();
739            std::thread::spawn(move || {
740                // Wait for game to exit
741                if let Err(e) = child.wait() {
742                    log::error!("Failed waiting for game process: {}", e);
743                    return;
744                }
745                log::info!("Post-launch cloud save upload...");
746                let rt = match tokio::runtime::Runtime::new() {
747                    Ok(rt) => rt,
748                    Err(e) => {
749                        log::error!("Failed to create runtime for cloud save upload: {}", e);
750                        return;
751                    }
752                };
753                match GameManager::new(config_clone, auth_clone) {
754                    Ok(manager) => {
755                        if let Err(e) = rt.block_on(manager.upload_cloud_saves(&app_name_clone)) {
756                            log::warn!("Cloud save upload failed: {}", e);
757                        } else {
758                            log::info!("Cloud saves uploaded successfully");
759                        }
760                    }
761                    Err(e) => log::error!("Failed to create GameManager: {}", e),
762                }
763            });
764        }
765
766        Ok(())
767    }
768
769    pub fn uninstall_game(&self, app_name: &str) -> Result<()> {
770        let game = InstalledGame::load(&self.config, app_name)?;
771
772        // Remove game files
773        if game.install_path.exists() {
774            fs::remove_dir_all(&game.install_path)?;
775        }
776
777        // Remove metadata
778        game.delete(&self.config)?;
779
780        log::info!("Uninstalled game: {} ({})", game.app_title, game.app_name);
781
782        Ok(())
783    }
784
785    // Convert Windows-style paths in manifests (with backslashes) to platform separators
786    fn normalize_launch_path(path: &str) -> PathBuf {
787        // PathBuf accepts forward slashes on all platforms; replace backslashes so Unix resolves paths
788        PathBuf::from(path.replace('\\', "/"))
789    }
790
791    // Detect our generated placeholder script to avoid launching a no-op
792    fn is_placeholder_launcher(path: &PathBuf) -> bool {
793        if let Ok(content) = fs::read_to_string(path) {
794            return content.contains("placeholder launcher generated by Epik");
795        }
796        false
797    }
798
799    // Find the legendary command, checking: wrapper script -> venv bin -> PATH
800    fn find_legendary_cmd() -> String {
801        // Get the project root by looking at executable location
802        // During development: target/debug/epik -> go to root
803        // After install: /usr/local/bin/epik -> look for .venv-legendary in common locations
804
805        let project_root = if let Ok(exe_path) = std::env::current_exe() {
806            // Check if we're in a development environment (target/debug or target/release)
807            if let Some(parent) = exe_path.parent() {
808                if let Some(grandparent) = parent.parent() {
809                    if grandparent.ends_with("target") {
810                        // We're in target/{debug,release}, project root is parent of target
811                        grandparent.parent().map(|p| p.to_path_buf())
812                    } else {
813                        // Likely an installed binary, try to find .venv-legendary
814                        // Check common locations
815                        let home = std::env::var("HOME").ok();
816                        if let Some(h) = home {
817                            Some(std::path::PathBuf::from(&h))
818                        } else {
819                            None
820                        }
821                    }
822                } else {
823                    None
824                }
825            } else {
826                None
827            }
828        } else {
829            None
830        };
831
832        // Try to find wrapper script in project root
833        if let Some(root) = project_root {
834            let wrapper = root.join("legendary");
835            if wrapper.exists() {
836                log::debug!("Using wrapper script: {}", wrapper.display());
837                if let Some(path_str) = wrapper.to_str() {
838                    return path_str.to_string();
839                }
840            }
841
842            // Try venv in project root
843            let venv_legendary = root.join(".venv-legendary/bin/legendary");
844            if venv_legendary.exists() {
845                log::debug!("Using legendary from venv: {}", venv_legendary.display());
846                if let Some(path_str) = venv_legendary.to_str() {
847                    return path_str.to_string();
848                }
849            }
850
851            // Try Windows venv
852            let venv_legendary_win = root.join(".venv-legendary/Scripts/legendary.exe");
853            if venv_legendary_win.exists() {
854                log::debug!(
855                    "Using legendary from venv (Windows): {}",
856                    venv_legendary_win.display()
857                );
858                if let Some(path_str) = venv_legendary_win.to_str() {
859                    return path_str.to_string();
860                }
861            }
862        }
863
864        // Fallback: check relative paths (for backwards compatibility)
865        if std::path::Path::new("./legendary").exists() {
866            log::debug!("Using wrapper script: ./legendary");
867            return "./legendary".to_string();
868        }
869
870        let venv_legendary = ".venv-legendary/bin/legendary";
871        if std::path::Path::new(venv_legendary).exists() {
872            log::debug!("Using legendary from venv: {}", venv_legendary);
873            return venv_legendary.to_string();
874        }
875
876        let venv_legendary_win = ".venv-legendary/Scripts/legendary.exe";
877        if std::path::Path::new(venv_legendary_win).exists() {
878            log::debug!(
879                "Using legendary from venv (Windows): {}",
880                venv_legendary_win
881            );
882            return venv_legendary_win.to_string();
883        }
884
885        // Final fallback: search PATH
886        log::debug!("Searching for legendary in PATH");
887        "legendary".to_string()
888    }
889
890    // Configure legendary to use EPIK's config directory for synchronization
891    fn configure_legendary_sync(cmd: &mut std::process::Command, _config: &Config) -> Result<()> {
892        // Point legendary to EPIK's config directory so they share authentication tokens
893        let config_dir = Config::config_path()?;
894        if let Some(parent) = config_dir.parent() {
895            log::debug!("Syncing legendary config with EPIK: {}", parent.display());
896            cmd.env("LEGENDARY_CONFIG_DIR", parent);
897        }
898        Ok(())
899    }
900
901    // Resolve install/executable information using legendary --json output (best-effort)
902    fn legendary_resolve_install(&self, app_name: &str) -> Result<Option<(PathBuf, PathBuf)>> {
903        // Find legendary: wrapper script -> venv -> PATH
904        let legendary_cmd = Self::find_legendary_cmd();
905
906        // Use the correct legendary command: list or list-games
907        let mut cmd = std::process::Command::new(&legendary_cmd);
908        cmd.arg("list")
909            .arg("--json")
910            .arg("--installed")
911            .stdout(std::process::Stdio::piped())
912            .stderr(std::process::Stdio::piped());
913
914        // Sync with EPIK's config
915        Self::configure_legendary_sync(&mut cmd, &self.config)?;
916
917        let output = match cmd.output() {
918            Ok(out) => out,
919            Err(e) => {
920                log::debug!(
921                    "Legendary CLI not available ({}), skipping metadata resolution",
922                    e
923                );
924                return Ok(None);
925            }
926        };
927
928        if !output.status.success() {
929            let stderr = String::from_utf8_lossy(&output.stderr);
930            log::debug!(
931                "legendary list failed (status {:?}): {}",
932                output.status.code(),
933                stderr.trim()
934            );
935            return Ok(None);
936        }
937
938        let stdout = String::from_utf8_lossy(&output.stdout);
939        if stdout.trim().is_empty() {
940            log::warn!("legendary list returned empty output");
941            return Ok(None);
942        }
943
944        // Expected structure: array of objects with app_name, install_path, executable (best-effort)
945        let parsed: Value = match serde_json::from_str(stdout.trim()) {
946            Ok(v) => v,
947            Err(e) => {
948                log::warn!("Failed to parse legendary list JSON: {}", e);
949                return Ok(None);
950            }
951        };
952
953        let entries = match parsed.as_array() {
954            Some(arr) => arr,
955            None => {
956                log::warn!("legendary list JSON is not an array");
957                return Ok(None);
958            }
959        };
960
961        for entry in entries {
962            let name = entry.get("app_name").and_then(|v| v.as_str()).unwrap_or("");
963            if name != app_name {
964                continue;
965            }
966
967            let install_path = entry
968                .get("install_path")
969                .and_then(|v| v.as_str())
970                .map(PathBuf::from);
971
972            let exe_rel = entry
973                .get("executable")
974                .and_then(|v| v.as_str())
975                .map(|s| Self::normalize_launch_path(s));
976
977            if let (Some(install), Some(exe_rel)) = (install_path, exe_rel) {
978                let resolved = install.join(&exe_rel);
979                if resolved.exists() {
980                    return Ok(Some((resolved, install)));
981                } else {
982                    log::warn!("legendary resolved path does not exist: {:?}", resolved);
983                }
984            }
985        }
986
987        Ok(None)
988    }
989
990    // Launch using a resolved executable (e.g., from Legendary metadata)
991    fn launch_resolved(
992        &self,
993        app_name: &str,
994        game: &InstalledGame,
995        exe_path: PathBuf,
996        workdir: PathBuf,
997    ) -> Result<()> {
998        let needs_wine = game.wine_executable.is_some()
999            || (cfg!(target_os = "linux") && Self::is_windows_executable(&exe_path));
1000
1001        let mut launch_builder = LaunchConfigBuilder::new(exe_path.clone(), workdir.clone());
1002
1003        if needs_wine {
1004            let prefix = Config::data_dir()?.join("prefixes").join(app_name);
1005            fs::create_dir_all(&prefix)?;
1006            launch_builder = launch_builder.with_wine(
1007                game.wine_executable.as_ref().map(|w| PathBuf::from(w)),
1008                Some(prefix),
1009            );
1010        }
1011
1012        let launcher = GameLauncher::new(launch_builder.build());
1013        let mut child = launcher.launch()?;
1014
1015        // Spawn cloud save upload if enabled
1016        if game.cloud_save_enabled {
1017            let app_name_clone = app_name.to_string();
1018            let config_clone = self.config.clone();
1019            let auth_clone = self.auth.clone();
1020            std::thread::spawn(move || {
1021                if let Err(e) = child.wait() {
1022                    log::error!("Failed waiting for game process: {}", e);
1023                    return;
1024                }
1025                let rt = match tokio::runtime::Runtime::new() {
1026                    Ok(rt) => rt,
1027                    Err(e) => {
1028                        log::error!("Failed to create runtime for cloud save upload: {}", e);
1029                        return;
1030                    }
1031                };
1032                match GameManager::new(config_clone, auth_clone) {
1033                    Ok(manager) => {
1034                        if let Err(e) = rt.block_on(manager.upload_cloud_saves(&app_name_clone)) {
1035                            log::warn!("Cloud save upload failed: {}", e);
1036                        } else {
1037                            log::info!("Cloud saves uploaded successfully");
1038                        }
1039                    }
1040                    Err(e) => log::error!("Failed to create GameManager: {}", e),
1041                }
1042            });
1043        }
1044
1045        Ok(())
1046    }
1047
1048    // Fallback: delegate launch to the Legendary CLI (similar to Rare's strategy)
1049    fn launch_with_legendary(&self, app_name: &str, game: &InstalledGame) -> Result<()> {
1050        log::info!("Fallback to legendary launch for {}", app_name);
1051
1052        // Find legendary: wrapper script -> venv -> PATH
1053        let legendary_cmd = Self::find_legendary_cmd();
1054
1055        // Capture output so we can surface errors to the UI/logs
1056        let mut cmd = std::process::Command::new(&legendary_cmd);
1057        cmd.arg("launch")
1058            .arg(app_name)
1059            .arg("--skip-version-check")
1060            .arg("--install-dir")
1061            .arg(&game.install_path)
1062            .stdout(std::process::Stdio::piped())
1063            .stderr(std::process::Stdio::piped());
1064
1065        if let Some(wine) = &game.wine_executable {
1066            cmd.arg("--wine").arg(wine);
1067        }
1068
1069        cmd.current_dir(&game.install_path);
1070
1071        // Sync with EPIK's config
1072        Self::configure_legendary_sync(&mut cmd, &self.config)?;
1073
1074        let output = match cmd.output() {
1075            Ok(out) => out,
1076            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1077                return Err(Error::Other(
1078                    "Legendary CLI not found. Please run: ./scripts/setup-legendary.sh".to_string(),
1079                ));
1080            }
1081            Err(e) => return Err(Error::Other(format!("Failed to run legendary: {}", e))),
1082        };
1083
1084        let stdout = String::from_utf8_lossy(&output.stdout);
1085        let stderr = String::from_utf8_lossy(&output.stderr);
1086
1087        if !stdout.trim().is_empty() {
1088            log::info!("legendary stdout: {}", stdout.trim());
1089        }
1090        if !stderr.trim().is_empty() {
1091            log::warn!("legendary stderr: {}", stderr.trim());
1092        }
1093
1094        if !output.status.success() {
1095            return Err(Error::Other(format!(
1096                "Legendary launch failed (status {:?}): {}",
1097                output.status.code(),
1098                stderr.trim()
1099            )));
1100        }
1101
1102        Ok(())
1103    }
1104
1105    // Check if executable is a Windows PE file
1106    fn is_windows_executable(path: &PathBuf) -> bool {
1107        // Check file extension first
1108        if let Some(ext) = path.extension() {
1109            let ext_str = ext.to_string_lossy().to_lowercase();
1110            if ext_str == "exe" || ext_str == "bat" {
1111                return true;
1112            }
1113        }
1114
1115        // Check PE header (MZ signature)
1116        if let Ok(mut file) = std::fs::File::open(path) {
1117            use std::io::Read;
1118            let mut buffer = [0u8; 2];
1119            if file.read_exact(&mut buffer).is_ok() {
1120                return buffer == [0x4D, 0x5A]; // "MZ" magic number
1121            }
1122        }
1123
1124        false
1125    }
1126
1127    // Check for game updates
1128    pub async fn check_for_updates(&self, app_name: &str) -> Result<Option<String>> {
1129        let token = self.auth.get_token()?;
1130        let game = InstalledGame::load(&self.config, app_name)?;
1131
1132        log::info!(
1133            "Checking for updates for {} (current: {})",
1134            app_name,
1135            game.app_version
1136        );
1137
1138        self.client
1139            .check_for_updates(token, app_name, &game.app_version)
1140            .await
1141    }
1142
1143    // Update a game to the latest version
1144    pub async fn update_game(&mut self, app_name: &str) -> Result<()> {
1145        // TODO: Implement differential updates (download only changed files)
1146        // TODO: Compare old and new manifests to identify changes
1147        // TODO: Support update rollback in case of failure
1148        // TODO: Preserve user settings and save files during update
1149        // TODO: Show update changelog to user
1150
1151        let token = self.auth.get_token()?;
1152
1153        log::info!("Updating game: {}", app_name);
1154
1155        // Check if update is available
1156        match self.check_for_updates(app_name).await? {
1157            Some(new_version) => {
1158                log::info!("Update available: {}", new_version);
1159                log::info!("Downloading update...");
1160
1161                // Download new manifest
1162                let manifest = self.client.download_manifest(&token, app_name).await?;
1163
1164                // Update game files (differential update would be more efficient)
1165                log::info!("Updating game files...");
1166
1167                // Update installation record
1168                let mut game = InstalledGame::load(&self.config, app_name)?;
1169                game.app_version = manifest.app_version.clone();
1170                game.executable = manifest.launch_exe.clone();
1171                game.save(&self.config)?;
1172
1173                log::info!("✓ Game updated to version {}", manifest.app_version);
1174                Ok(())
1175            }
1176            None => {
1177                log::info!("Game is already up to date");
1178                Ok(())
1179            }
1180        }
1181    }
1182
1183    // Safe cloud save sync with error handling
1184    async fn sync_cloud_saves_safe(&self, app_name: &str) -> Result<()> {
1185        match self.download_cloud_saves(app_name).await {
1186            Ok(_) => Ok(()),
1187            Err(e) => {
1188                log::warn!("Cloud save download failed: {}", e);
1189                Ok(()) // Don't fail launch on cloud save errors
1190            }
1191        }
1192    }
1193
1194    // Download cloud saves with backup and conflict resolution
1195    pub async fn download_cloud_saves(&self, app_name: &str) -> Result<()> {
1196        let token = self.auth.get_token()?;
1197        let game = InstalledGame::load(&self.config, app_name)?;
1198
1199        log::info!("Downloading cloud saves for {}", app_name);
1200
1201        let saves = self.client.get_cloud_saves(token, app_name).await?;
1202
1203        if saves.is_empty() {
1204            log::info!("No cloud saves found");
1205            return Ok(());
1206        }
1207
1208        log::info!("Found {} cloud save(s)", saves.len());
1209
1210        // Create saves and backup directories
1211        let saves_dir = game.install_path.join("saves");
1212        let backup_dir = Config::data_dir()?.join("saves_backup").join(app_name);
1213        fs::create_dir_all(&saves_dir)?;
1214        fs::create_dir_all(&backup_dir)?;
1215
1216        for save in saves {
1217            log::info!("  Downloading: {}", save.filename);
1218
1219            let save_path = saves_dir.join(&save.filename);
1220
1221            // Backup existing save if present
1222            if save_path.exists() {
1223                let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1224                let backup_path = backup_dir.join(format!("{}_{}", timestamp, save.filename));
1225
1226                if let Ok(metadata) = fs::metadata(&save_path) {
1227                    if let Ok(modified) = metadata.modified() {
1228                        let local_time: chrono::DateTime<chrono::Utc> = modified.into();
1229
1230                        // Compare timestamps - skip if local is newer
1231                        if let Ok(cloud_time) =
1232                            chrono::DateTime::parse_from_rfc3339(&save.uploaded_at)
1233                        {
1234                            if local_time > cloud_time.with_timezone(&chrono::Utc) {
1235                                log::info!("Local save is newer, keeping local version");
1236                                continue;
1237                            }
1238                        }
1239                    }
1240                }
1241
1242                fs::copy(&save_path, &backup_path)?;
1243                log::info!("Backed up to: {:?}", backup_path);
1244            }
1245
1246            let save_data = self.client.download_cloud_save(token, &save.id).await?;
1247            fs::write(&save_path, &save_data)?;
1248        }
1249
1250        log::info!("✓ Cloud saves downloaded");
1251        Ok(())
1252    }
1253
1254    // Upload local saves to cloud
1255    pub async fn upload_cloud_saves(&self, app_name: &str) -> Result<()> {
1256        let token = self.auth.get_token()?;
1257        let game = InstalledGame::load(&self.config, app_name)?;
1258
1259        log::info!("Uploading cloud saves for {}", app_name);
1260
1261        let saves_dir = game.install_path.join("saves");
1262
1263        if !saves_dir.exists() {
1264            log::info!("No local saves found");
1265            return Ok(());
1266        }
1267
1268        let mut uploaded = 0;
1269
1270        for entry in fs::read_dir(&saves_dir)? {
1271            let entry = entry?;
1272            let path = entry.path();
1273
1274            if path.is_file() {
1275                let save_data = fs::read(&path)?;
1276                log::info!(
1277                    "  Uploading: {}",
1278                    path.file_name().unwrap().to_string_lossy()
1279                );
1280
1281                self.client
1282                    .upload_cloud_save(token, app_name, &save_data)
1283                    .await?;
1284                uploaded += 1;
1285            }
1286        }
1287
1288        log::info!("✓ Uploaded {} save file(s)", uploaded);
1289        Ok(())
1290    }
1291
1292    // Get cloud save sync status
1293    pub fn get_cloud_save_status(&self, app_name: &str) -> Result<CloudSaveStatus> {
1294        let game = InstalledGame::load(&self.config, app_name)?;
1295
1296        let last_sync = game
1297            .last_cloud_sync
1298            .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
1299            .map(|dt| dt.with_timezone(&chrono::Utc));
1300
1301        let saves_dir = game.install_path.join("saves");
1302        let has_local_saves = saves_dir.exists()
1303            && fs::read_dir(&saves_dir)
1304                .ok()
1305                .map(|mut d| d.next().is_some())
1306                .unwrap_or(false);
1307
1308        Ok(CloudSaveStatus {
1309            enabled: game.cloud_save_enabled,
1310            last_sync,
1311            has_local_saves,
1312        })
1313    }
1314
1315    // Toggle cloud save sync for a game
1316    pub fn set_cloud_save_enabled(&self, app_name: &str, enabled: bool) -> Result<()> {
1317        let mut game = InstalledGame::load(&self.config, app_name)?;
1318        game.cloud_save_enabled = enabled;
1319        game.save(&self.config)?;
1320        log::info!(
1321            "Cloud saves {} for {}",
1322            if enabled { "enabled" } else { "disabled" },
1323            app_name
1324        );
1325        Ok(())
1326    }
1327}