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;
12use 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
28pub type DownloadProgressCallback = std::sync::Arc<dyn Fn(DownloadProgress) + Send + Sync>;
31
32#[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 let token = self.client.get_token()?;
149 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 log::info!("Downloading game manifest...");
193 let manifest = self.client.download_manifest(&token, app_name).await?;
194
195 let install_dir = self.config.install_dir.clone(); let required_space = manifest.build_size;
198
199 let disks = Disks::new_with_refreshed_list();
200 let mut checked = false;
202 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 !checked && !disks.is_empty() {
221 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 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 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 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 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 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 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 #[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 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 let installed_game = InstalledGame {
384 app_name: app_name.to_string(),
385 app_title: app_name.to_string(), 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 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 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 let exe_patterns = vec![
421 ".exe", ".bat", ".sh", ];
423
424 for entry in fs::read_dir(scan_path)? {
426 let entry = entry?;
427 let path = entry.path();
428
429 if path.is_dir() {
430 let dir_name = path
432 .file_name()
433 .and_then(|n| n.to_str())
434 .unwrap_or("")
435 .to_string();
436
437 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 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; }
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 pub fn scan_common_locations(&self) -> Result<Vec<(String, PathBuf, String)>> {
481 let mut all_found = Vec::new();
482
483 let common_paths = vec![
485 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 PathBuf::from(format!(
493 "{}/.legendary/installed",
494 std::env::var("HOME").unwrap_or_default()
495 )),
496 PathBuf::from(format!(
498 "{}/Games/Heroic",
499 std::env::var("HOME").unwrap_or_default()
500 )),
501 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 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 let manifest = self.client.download_manifest(&token, app_name).await?;
525
526 let mut corrupted_files = Vec::new();
527
528 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 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 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 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 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 let mut repair_manifest = manifest.clone();
592 repair_manifest
593 .file_list
594 .retain(|f| corrupted_files.contains(&f.filename));
595
596 let cache_dir = Config::data_dir()?.join("cache").join("chunks");
598
599 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 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 !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 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 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 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 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 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 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 if game.install_path.exists() {
774 fs::remove_dir_all(&game.install_path)?;
775 }
776
777 game.delete(&self.config)?;
779
780 log::info!("Uninstalled game: {} ({})", game.app_title, game.app_name);
781
782 Ok(())
783 }
784
785 fn normalize_launch_path(path: &str) -> PathBuf {
787 PathBuf::from(path.replace('\\', "/"))
789 }
790
791 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 fn find_legendary_cmd() -> String {
801 let project_root = if let Ok(exe_path) = std::env::current_exe() {
806 if let Some(parent) = exe_path.parent() {
808 if let Some(grandparent) = parent.parent() {
809 if grandparent.ends_with("target") {
810 grandparent.parent().map(|p| p.to_path_buf())
812 } else {
813 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 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 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 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 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 log::debug!("Searching for legendary in PATH");
887 "legendary".to_string()
888 }
889
890 fn configure_legendary_sync(cmd: &mut std::process::Command, _config: &Config) -> Result<()> {
892 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 fn legendary_resolve_install(&self, app_name: &str) -> Result<Option<(PathBuf, PathBuf)>> {
903 let legendary_cmd = Self::find_legendary_cmd();
905
906 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 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 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 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 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 fn launch_with_legendary(&self, app_name: &str, game: &InstalledGame) -> Result<()> {
1050 log::info!("Fallback to legendary launch for {}", app_name);
1051
1052 let legendary_cmd = Self::find_legendary_cmd();
1054
1055 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 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 fn is_windows_executable(path: &PathBuf) -> bool {
1107 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 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]; }
1122 }
1123
1124 false
1125 }
1126
1127 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 pub async fn update_game(&mut self, app_name: &str) -> Result<()> {
1145 let token = self.auth.get_token()?;
1152
1153 log::info!("Updating game: {}", app_name);
1154
1155 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 let manifest = self.client.download_manifest(&token, app_name).await?;
1163
1164 log::info!("Updating game files...");
1166
1167 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 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(()) }
1191 }
1192 }
1193
1194 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 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 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 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 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 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 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}