From c484c8958c0d7b82ca11dd909e587a6548f00296 Mon Sep 17 00:00:00 2001 From: Novattz Date: Mon, 22 Dec 2025 20:21:06 +0100 Subject: [PATCH] Creamlinux Refactor --- src-tauri/src/cache.rs | 21 - src-tauri/src/cache/mod.rs | 246 ++++++ src-tauri/src/cache/storage.rs | 292 +++++++ src-tauri/src/cache/version.rs | 177 ++++ src-tauri/src/dlc_manager.rs | 91 +- src-tauri/src/installer.rs | 1107 ------------------------- src-tauri/src/installer/file_ops.rs | 44 + src-tauri/src/installer/mod.rs | 655 +++++++++++++++ src-tauri/src/main.rs | 137 +-- src-tauri/src/unlockers/creamlinux.rs | 225 +++++ src-tauri/src/unlockers/mod.rs | 27 + src-tauri/src/unlockers/smokeapi.rs | 260 ++++++ 12 files changed, 2042 insertions(+), 1240 deletions(-) delete mode 100644 src-tauri/src/cache.rs create mode 100644 src-tauri/src/cache/mod.rs create mode 100644 src-tauri/src/cache/storage.rs create mode 100644 src-tauri/src/cache/version.rs delete mode 100644 src-tauri/src/installer.rs create mode 100644 src-tauri/src/installer/file_ops.rs create mode 100644 src-tauri/src/installer/mod.rs create mode 100644 src-tauri/src/unlockers/creamlinux.rs create mode 100644 src-tauri/src/unlockers/mod.rs create mode 100644 src-tauri/src/unlockers/smokeapi.rs diff --git a/src-tauri/src/cache.rs b/src-tauri/src/cache.rs deleted file mode 100644 index b8d2815..0000000 --- a/src-tauri/src/cache.rs +++ /dev/null @@ -1,21 +0,0 @@ -// This is a placeholder file - cache functionality has been removed -// and now only exists in memory within the App state - -pub fn cache_dlcs(_game_id: &str, _dlcs: &[crate::dlc_manager::DlcInfoWithState]) -> std::io::Result<()> { - // This function is kept only for compatibility, but now does nothing - // The DLCs are only cached in memory - log::info!("Cache functionality has been removed - DLCs are only stored in memory"); - Ok(()) -} - -pub fn load_cached_dlcs(_game_id: &str) -> Option> { - // This function is kept only for compatibility, but now always returns None - log::info!("Cache functionality has been removed - DLCs are only stored in memory"); - None -} - -pub fn clear_all_caches() -> std::io::Result<()> { - // This function is kept only for compatibility, but now does nothing - log::info!("Cache functionality has been removed - DLCs are only stored in memory"); - Ok(()) -} \ No newline at end of file diff --git a/src-tauri/src/cache/mod.rs b/src-tauri/src/cache/mod.rs new file mode 100644 index 0000000..d48f85a --- /dev/null +++ b/src-tauri/src/cache/mod.rs @@ -0,0 +1,246 @@ +mod storage; +mod version; + +pub use storage::{ + get_creamlinux_version_dir, get_smokeapi_version_dir, is_cache_initialized, + list_creamlinux_files, list_smokeapi_dlls, read_versions, update_creamlinux_version, + update_smokeapi_version, +}; + +pub use version::{ + read_manifest, remove_creamlinux_version, remove_smokeapi_version, + update_creamlinux_version as update_game_creamlinux_version, + update_smokeapi_version as update_game_smokeapi_version, +}; + +use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker}; +use log::{error, info, warn}; +use std::collections::HashMap; + +// Initialize the cache on app startup +// Downloads both unlockers if they don't exist +pub async fn initialize_cache() -> Result<(), String> { + info!("Initializing cache..."); + + // Check if cache is already initialized + if is_cache_initialized()? { + info!("Cache already initialized"); + return Ok(()); + } + + info!("Cache not initialized, downloading unlockers..."); + + // Download SmokeAPI + match SmokeAPI::download_to_cache().await { + Ok(version) => { + info!("Downloaded SmokeAPI version: {}", version); + update_smokeapi_version(&version)?; + } + Err(e) => { + error!("Failed to download SmokeAPI: {}", e); + return Err(format!("Failed to download SmokeAPI: {}", e)); + } + } + + // Download CreamLinux + match CreamLinux::download_to_cache().await { + Ok(version) => { + info!("Downloaded CreamLinux version: {}", version); + update_creamlinux_version(&version)?; + } + Err(e) => { + error!("Failed to download CreamLinux: {}", e); + return Err(format!("Failed to download CreamLinux: {}", e)); + } + } + + info!("Cache initialization complete"); + Ok(()) +} + +// Check for updates and download new versions if available +pub async fn check_and_update_cache() -> Result { + info!("Checking for unlocker updates..."); + + let mut result = UpdateResult::default(); + + // Check SmokeAPI + let current_smokeapi = read_versions()?.smokeapi.latest; + match SmokeAPI::get_latest_version().await { + Ok(latest_version) => { + if current_smokeapi != latest_version { + info!( + "SmokeAPI update available: {} -> {}", + current_smokeapi, latest_version + ); + + match SmokeAPI::download_to_cache().await { + Ok(version) => { + update_smokeapi_version(&version)?; + result.smokeapi_updated = true; + result.new_smokeapi_version = Some(version); + info!("SmokeAPI updated successfully"); + } + Err(e) => { + error!("Failed to download SmokeAPI update: {}", e); + return Err(format!("Failed to download SmokeAPI update: {}", e)); + } + } + } else { + info!("SmokeAPI is up to date: {}", current_smokeapi); + } + } + Err(e) => { + warn!("Failed to check SmokeAPI version: {}", e); + } + } + + // Check CreamLinux + let current_creamlinux = read_versions()?.creamlinux.latest; + match CreamLinux::get_latest_version().await { + Ok(latest_version) => { + if current_creamlinux != latest_version { + info!( + "CreamLinux update available: {} -> {}", + current_creamlinux, latest_version + ); + + match CreamLinux::download_to_cache().await { + Ok(version) => { + update_creamlinux_version(&version)?; + result.creamlinux_updated = true; + result.new_creamlinux_version = Some(version); + info!("CreamLinux updated successfully"); + } + Err(e) => { + error!("Failed to download CreamLinux update: {}", e); + return Err(format!("Failed to download CreamLinux update: {}", e)); + } + } + } else { + info!("CreamLinux is up to date: {}", current_creamlinux); + } + } + Err(e) => { + warn!("Failed to check CreamLinux version: {}", e); + } + } + + Ok(result) +} + +// Update all games that have outdated unlocker versions +pub async fn update_outdated_games( + games: &HashMap, +) -> Result { + info!("Checking for outdated game installations..."); + + let cached_versions = read_versions()?; + let mut stats = GameUpdateStats::default(); + + for (game_id, game) in games { + // Read the game's manifest + let manifest = match read_manifest(&game.path) { + Ok(m) => m, + Err(e) => { + warn!("Failed to read manifest for {}: {}", game.title, e); + continue; + } + }; + + // Check if SmokeAPI needs updating + if manifest.has_smokeapi() + && manifest.is_smokeapi_outdated(&cached_versions.smokeapi.latest) + { + info!( + "Game '{}' has outdated SmokeAPI, updating...", + game.title + ); + + // Convert api_files Vec to comma-separated string + let api_files_str = game.api_files.join(","); + match SmokeAPI::install_to_game(&game.path, &api_files_str).await { + Ok(_) => { + update_game_smokeapi_version(&game.path, cached_versions.smokeapi.latest.clone())?; + stats.smokeapi_updated += 1; + info!("Updated SmokeAPI for '{}'", game.title); + } + Err(e) => { + error!("Failed to update SmokeAPI for '{}': {}", game.title, e); + stats.smokeapi_failed += 1; + } + } + } + + // Check if CreamLinux needs updating + if manifest.has_creamlinux() + && manifest.is_creamlinux_outdated(&cached_versions.creamlinux.latest) + { + info!( + "Game '{}' has outdated CreamLinux, updating...", + game.title + ); + + // For CreamLinux, we need to preserve the DLC configuration + match CreamLinux::install_to_game(&game.path, game_id).await { + Ok(_) => { + update_game_creamlinux_version(&game.path, cached_versions.creamlinux.latest.clone())?; + stats.creamlinux_updated += 1; + info!("Updated CreamLinux for '{}'", game.title); + } + Err(e) => { + error!("Failed to update CreamLinux for '{}': {}", game.title, e); + stats.creamlinux_failed += 1; + } + } + } + } + + info!( + "Game update complete - SmokeAPI: {} updated, {} failed | CreamLinux: {} updated, {} failed", + stats.smokeapi_updated, + stats.smokeapi_failed, + stats.creamlinux_updated, + stats.creamlinux_failed + ); + + Ok(stats) +} + +// Result of checking for cache updates +#[derive(Debug, Default, Clone)] +pub struct UpdateResult { + pub smokeapi_updated: bool, + pub creamlinux_updated: bool, + pub new_smokeapi_version: Option, + pub new_creamlinux_version: Option, +} + +impl UpdateResult { + pub fn any_updated(&self) -> bool { + self.smokeapi_updated || self.creamlinux_updated + } +} + +// Statistics about game updates +#[derive(Debug, Default, Clone)] +pub struct GameUpdateStats { + pub smokeapi_updated: u32, + pub smokeapi_failed: u32, + pub creamlinux_updated: u32, + pub creamlinux_failed: u32, +} + +impl GameUpdateStats { + pub fn total_updated(&self) -> u32 { + self.smokeapi_updated + self.creamlinux_updated + } + + pub fn total_failed(&self) -> u32 { + self.smokeapi_failed + self.creamlinux_failed + } + + pub fn has_failures(&self) -> bool { + self.total_failed() > 0 + } +} \ No newline at end of file diff --git a/src-tauri/src/cache/storage.rs b/src-tauri/src/cache/storage.rs new file mode 100644 index 0000000..77ce354 --- /dev/null +++ b/src-tauri/src/cache/storage.rs @@ -0,0 +1,292 @@ +use log::{info, warn}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +// Represents the versions.json file in the cache root +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CacheVersions { + pub smokeapi: VersionInfo, + pub creamlinux: VersionInfo, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct VersionInfo { + pub latest: String, +} + +impl Default for CacheVersions { + fn default() -> Self { + Self { + smokeapi: VersionInfo { + latest: String::new(), + }, + creamlinux: VersionInfo { + latest: String::new(), + }, + } + } +} + +// Get the cache directory path (~/.cache/creamlinux) +pub fn get_cache_dir() -> Result { + let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux") + .map_err(|e| format!("Failed to get XDG directories: {}", e))?; + + let cache_dir = xdg_dirs + .get_cache_home() + .parent() + .ok_or_else(|| "Failed to get cache parent directory".to_string())? + .join("creamlinux"); + + // Create the directory if it doesn't exist + if !cache_dir.exists() { + fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + info!("Created cache directory: {}", cache_dir.display()); + } + + Ok(cache_dir) +} + +// Get the SmokeAPI cache directory path +pub fn get_smokeapi_dir() -> Result { + let cache_dir = get_cache_dir()?; + let smokeapi_dir = cache_dir.join("smokeapi"); + + if !smokeapi_dir.exists() { + fs::create_dir_all(&smokeapi_dir) + .map_err(|e| format!("Failed to create SmokeAPI directory: {}", e))?; + info!("Created SmokeAPI directory: {}", smokeapi_dir.display()); + } + + Ok(smokeapi_dir) +} + +// Get the CreamLinux cache directory path +pub fn get_creamlinux_dir() -> Result { + let cache_dir = get_cache_dir()?; + let creamlinux_dir = cache_dir.join("creamlinux"); + + if !creamlinux_dir.exists() { + fs::create_dir_all(&creamlinux_dir) + .map_err(|e| format!("Failed to create CreamLinux directory: {}", e))?; + info!("Created CreamLinux directory: {}", creamlinux_dir.display()); + } + + Ok(creamlinux_dir) +} + +// Get the path to a versioned SmokeAPI directory +pub fn get_smokeapi_version_dir(version: &str) -> Result { + let smokeapi_dir = get_smokeapi_dir()?; + let version_dir = smokeapi_dir.join(version); + + if !version_dir.exists() { + fs::create_dir_all(&version_dir) + .map_err(|e| format!("Failed to create SmokeAPI version directory: {}", e))?; + info!( + "Created SmokeAPI version directory: {}", + version_dir.display() + ); + } + + Ok(version_dir) +} + +// Get the path to a versioned CreamLinux directory +pub fn get_creamlinux_version_dir(version: &str) -> Result { + let creamlinux_dir = get_creamlinux_dir()?; + let version_dir = creamlinux_dir.join(version); + + if !version_dir.exists() { + fs::create_dir_all(&version_dir) + .map_err(|e| format!("Failed to create CreamLinux version directory: {}", e))?; + info!( + "Created CreamLinux version directory: {}", + version_dir.display() + ); + } + + Ok(version_dir) +} + +// Read the versions.json file from cache +pub fn read_versions() -> Result { + let cache_dir = get_cache_dir()?; + let versions_path = cache_dir.join("versions.json"); + + if !versions_path.exists() { + info!("versions.json doesn't exist, creating default"); + return Ok(CacheVersions::default()); + } + + let content = fs::read_to_string(&versions_path) + .map_err(|e| format!("Failed to read versions.json: {}", e))?; + + let versions: CacheVersions = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse versions.json: {}", e))?; + + info!( + "Read cached versions - SmokeAPI: {}, CreamLinux: {}", + versions.smokeapi.latest, versions.creamlinux.latest + ); + + Ok(versions) +} + +// Write the versions.json file to cache +pub fn write_versions(versions: &CacheVersions) -> Result<(), String> { + let cache_dir = get_cache_dir()?; + let versions_path = cache_dir.join("versions.json"); + + let content = serde_json::to_string_pretty(versions) + .map_err(|e| format!("Failed to serialize versions: {}", e))?; + + fs::write(&versions_path, content) + .map_err(|e| format!("Failed to write versions.json: {}", e))?; + + info!( + "Wrote versions.json - SmokeAPI: {}, CreamLinux: {}", + versions.smokeapi.latest, versions.creamlinux.latest + ); + + Ok(()) +} + +// Update the SmokeAPI version in versions.json and clean old version directories +pub fn update_smokeapi_version(new_version: &str) -> Result<(), String> { + let mut versions = read_versions()?; + let old_version = versions.smokeapi.latest.clone(); + + versions.smokeapi.latest = new_version.to_string(); + write_versions(&versions)?; + + // Delete old version directory if it exists and is different + if !old_version.is_empty() && old_version != new_version { + let old_dir = get_smokeapi_dir()?.join(&old_version); + if old_dir.exists() { + match fs::remove_dir_all(&old_dir) { + Ok(_) => info!("Deleted old SmokeAPI version directory: {}", old_version), + Err(e) => warn!( + "Failed to delete old SmokeAPI version directory: {}", + e + ), + } + } + } + + Ok(()) +} + +// Update the CreamLinux version in versions.json and clean old version directories +pub fn update_creamlinux_version(new_version: &str) -> Result<(), String> { + let mut versions = read_versions()?; + let old_version = versions.creamlinux.latest.clone(); + + versions.creamlinux.latest = new_version.to_string(); + write_versions(&versions)?; + + // Delete old version directory if it exists and is different + if !old_version.is_empty() && old_version != new_version { + let old_dir = get_creamlinux_dir()?.join(&old_version); + if old_dir.exists() { + match fs::remove_dir_all(&old_dir) { + Ok(_) => info!("Deleted old CreamLinux version directory: {}", old_version), + Err(e) => warn!( + "Failed to delete old CreamLinux version directory: {}", + e + ), + } + } + } + + Ok(()) +} + +// Check if the cache is initialized (has both unlockers cached) +pub fn is_cache_initialized() -> Result { + let versions = read_versions()?; + Ok(!versions.smokeapi.latest.is_empty() && !versions.creamlinux.latest.is_empty()) +} + +// Get the SmokeAPI DLL path for the latest cached version +#[allow(dead_code)] +pub fn get_smokeapi_dll_path() -> Result { + let versions = read_versions()?; + if versions.smokeapi.latest.is_empty() { + return Err("SmokeAPI is not cached".to_string()); + } + + let version_dir = get_smokeapi_version_dir(&versions.smokeapi.latest)?; + Ok(version_dir.join("SmokeAPI.dll")) +} + +// Get the CreamLinux files directory path for the latest cached version +#[allow(dead_code)] +pub fn get_creamlinux_files_dir() -> Result { + let versions = read_versions()?; + if versions.creamlinux.latest.is_empty() { + return Err("CreamLinux is not cached".to_string()); + } + + get_creamlinux_version_dir(&versions.creamlinux.latest) +} + +// List all SmokeAPI DLL files in the cached version directory +pub fn list_smokeapi_dlls() -> Result, String> { + let versions = read_versions()?; + if versions.smokeapi.latest.is_empty() { + return Ok(Vec::new()); + } + + let version_dir = get_smokeapi_version_dir(&versions.smokeapi.latest)?; + + if !version_dir.exists() { + return Ok(Vec::new()); + } + + let entries = fs::read_dir(&version_dir) + .map_err(|e| format!("Failed to read SmokeAPI directory: {}", e))?; + + let mut dlls = Vec::new(); + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("dll") { + dlls.push(path); + } + } + } + + Ok(dlls) +} + +// List all CreamLinux files in the cached version directory +pub fn list_creamlinux_files() -> Result, String> { + let versions = read_versions()?; + if versions.creamlinux.latest.is_empty() { + return Ok(Vec::new()); + } + + let version_dir = get_creamlinux_version_dir(&versions.creamlinux.latest)?; + + if !version_dir.exists() { + return Ok(Vec::new()); + } + + let entries = fs::read_dir(&version_dir) + .map_err(|e| format!("Failed to read CreamLinux directory: {}", e))?; + + let mut files = Vec::new(); + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_file() { + files.push(path); + } + } + } + + Ok(files) +} \ No newline at end of file diff --git a/src-tauri/src/cache/version.rs b/src-tauri/src/cache/version.rs new file mode 100644 index 0000000..eeab9ab --- /dev/null +++ b/src-tauri/src/cache/version.rs @@ -0,0 +1,177 @@ +use log::{info}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +// Represents the version manifest stored in each game directory +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct GameManifest { + pub smokeapi_version: Option, + pub creamlinux_version: Option, +} + +#[allow(dead_code)] +impl GameManifest { + // Create a new manifest with SmokeAPI version + pub fn with_smokeapi(version: String) -> Self { + Self { + smokeapi_version: Some(version), + creamlinux_version: None, + } + } + + // Create a new manifest with CreamLinux version + pub fn with_creamlinux(version: String) -> Self { + Self { + smokeapi_version: None, + creamlinux_version: Some(version), + } + } + + // Check if SmokeAPI is installed + pub fn has_smokeapi(&self) -> bool { + self.smokeapi_version.is_some() + } + + // Check if CreamLinux is installed + pub fn has_creamlinux(&self) -> bool { + self.creamlinux_version.is_some() + } + + // Check if SmokeAPI version is outdated + pub fn is_smokeapi_outdated(&self, latest_version: &str) -> bool { + match &self.smokeapi_version { + Some(version) => version != latest_version, + None => false, + } + } + + // Check if CreamLinux version is outdated + pub fn is_creamlinux_outdated(&self, latest_version: &str) -> bool { + match &self.creamlinux_version { + Some(version) => version != latest_version, + None => false, + } + } +} + +// Read the creamlinux.json manifest from a game directory +pub fn read_manifest(game_path: &str) -> Result { + let manifest_path = Path::new(game_path).join("creamlinux.json"); + + if !manifest_path.exists() { + return Ok(GameManifest::default()); + } + + let content = fs::read_to_string(&manifest_path) + .map_err(|e| format!("Failed to read manifest: {}", e))?; + + let manifest: GameManifest = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse manifest: {}", e))?; + + info!( + "Read manifest from {}: SmokeAPI: {:?}, CreamLinux: {:?}", + game_path, manifest.smokeapi_version, manifest.creamlinux_version + ); + + Ok(manifest) +} + +// Write the creamlinux.json manifest to a game directory +pub fn write_manifest(game_path: &str, manifest: &GameManifest) -> Result<(), String> { + let manifest_path = Path::new(game_path).join("creamlinux.json"); + + let content = serde_json::to_string_pretty(manifest) + .map_err(|e| format!("Failed to serialize manifest: {}", e))?; + + fs::write(&manifest_path, content) + .map_err(|e| format!("Failed to write manifest: {}", e))?; + + info!( + "Wrote manifest to {}: SmokeAPI: {:?}, CreamLinux: {:?}", + game_path, manifest.smokeapi_version, manifest.creamlinux_version + ); + + Ok(()) +} + +// Update the SmokeAPI version in the manifest +pub fn update_smokeapi_version(game_path: &str, version: String) -> Result<(), String> { + let mut manifest = read_manifest(game_path)?; + manifest.smokeapi_version = Some(version); + write_manifest(game_path, &manifest) +} + +// Update the CreamLinux version in the manifest +pub fn update_creamlinux_version(game_path: &str, version: String) -> Result<(), String> { + let mut manifest = read_manifest(game_path)?; + manifest.creamlinux_version = Some(version); + write_manifest(game_path, &manifest) +} + +// Remove SmokeAPI version from the manifest +pub fn remove_smokeapi_version(game_path: &str) -> Result<(), String> { + let mut manifest = read_manifest(game_path)?; + manifest.smokeapi_version = None; + + // If both versions are None, delete the manifest file + if manifest.smokeapi_version.is_none() && manifest.creamlinux_version.is_none() { + let manifest_path = Path::new(game_path).join("creamlinux.json"); + if manifest_path.exists() { + fs::remove_file(&manifest_path) + .map_err(|e| format!("Failed to delete manifest: {}", e))?; + info!("Deleted empty manifest from {}", game_path); + } + } else { + write_manifest(game_path, &manifest)?; + } + + Ok(()) +} + +// Remove CreamLinux version from the manifest +pub fn remove_creamlinux_version(game_path: &str) -> Result<(), String> { + let mut manifest = read_manifest(game_path)?; + manifest.creamlinux_version = None; + + // If both versions are None, delete the manifest file + if manifest.smokeapi_version.is_none() && manifest.creamlinux_version.is_none() { + let manifest_path = Path::new(game_path).join("creamlinux.json"); + if manifest_path.exists() { + fs::remove_file(&manifest_path) + .map_err(|e| format!("Failed to delete manifest: {}", e))?; + info!("Deleted empty manifest from {}", game_path); + } + } else { + write_manifest(game_path, &manifest)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_manifest_creation() { + let manifest = GameManifest::with_smokeapi("v1.0.0".to_string()); + assert_eq!(manifest.smokeapi_version, Some("v1.0.0".to_string())); + assert_eq!(manifest.creamlinux_version, None); + + let manifest = GameManifest::with_creamlinux("v2.0.0".to_string()); + assert_eq!(manifest.smokeapi_version, None); + assert_eq!(manifest.creamlinux_version, Some("v2.0.0".to_string())); + } + + #[test] + fn test_outdated_check() { + let mut manifest = GameManifest::with_smokeapi("v1.0.0".to_string()); + assert!(manifest.is_smokeapi_outdated("v2.0.0")); + assert!(!manifest.is_smokeapi_outdated("v1.0.0")); + + manifest.creamlinux_version = Some("v1.5.0".to_string()); + assert!(manifest.is_creamlinux_outdated("v2.0.0")); + assert!(!manifest.is_creamlinux_outdated("v1.5.0")); + } +} \ No newline at end of file diff --git a/src-tauri/src/dlc_manager.rs b/src-tauri/src/dlc_manager.rs index ba91e41..0b46045 100644 --- a/src-tauri/src/dlc_manager.rs +++ b/src-tauri/src/dlc_manager.rs @@ -232,15 +232,15 @@ pub fn update_dlc_configuration( } processed_dlcs.insert(appid.to_string()); } else { - // Not in our list keep the original line + // Not in our list, keep the original line new_contents.push(line.to_string()); } } else { - // Invalid format or not a DLC line keep as is + // Invalid format or not a DLC line, keep as is new_contents.push(line.to_string()); } } else if !in_dlc_section || trimmed.is_empty() { - // Not a DLC line or empty line keep as is + // Not a DLC line or empty line, keep as is new_contents.push(line.to_string()); } } @@ -274,18 +274,6 @@ pub fn update_dlc_configuration( } } -// Get app ID from game path by reading cream_api.ini -#[allow(dead_code)] -fn extract_app_id_from_config(game_path: &str) -> Option { - if let Ok(contents) = fs::read_to_string(Path::new(game_path).join("cream_api.ini")) { - let re = regex::Regex::new(r"APPID\s*=\s*(\d+)").unwrap(); - if let Some(cap) = re.captures(&contents) { - return Some(cap[1].to_string()); - } - } - None -} - // Create a custom installation with selected DLCs pub async fn install_cream_with_dlcs( game_id: String, @@ -316,9 +304,6 @@ pub async fn install_cream_with_dlcs( game.title, game_id ); - // Install CreamLinux first - but provide the DLCs directly instead of fetching them again - use crate::installer::install_creamlinux_with_dlcs; - // Convert DlcInfoWithState to installer::DlcInfo for those that are enabled let enabled_dlcs = selected_dlcs .iter() @@ -329,40 +314,40 @@ pub async fn install_cream_with_dlcs( }) .collect::>(); - let app_handle_clone = app_handle.clone(); - let game_title = game.title.clone(); + // Install CreamLinux binaries from cache + use crate::unlockers::{CreamLinux, Unlocker}; - // Use direct installation with provided DLCs instead of re-fetching - match install_creamlinux_with_dlcs( - &game.path, - &game_id, - enabled_dlcs, - move |progress, message| { - // Emit progress updates during installation - use crate::installer::emit_progress; - emit_progress( - &app_handle_clone, - &format!("Installing CreamLinux for {}", game_title), - message, - progress * 100.0, // Scale progress from 0 to 100% - false, - false, - None, - ); - }, - ) - .await - { - Ok(_) => { - info!( - "CreamLinux installation completed successfully for game: {}", - game.title - ); - Ok(()) - } - Err(e) => { - error!("Failed to install CreamLinux: {}", e); - Err(format!("Failed to install CreamLinux: {}", e)) - } + let game_path = game.path.clone(); + + // Install binaries + CreamLinux::install_to_game(&game.path, &game_id) + .await + .map_err(|e| format!("Failed to install CreamLinux binaries: {}", e))?; + + // Write cream_api.ini with DLCs + let cream_api_path = Path::new(&game_path).join("cream_api.ini"); + let mut config = String::new(); + + config.push_str(&format!("APPID = {}\n[config]\n", game_id)); + config.push_str("issubscribedapp_on_false_use_real = true\n"); + config.push_str("[methods]\n"); + config.push_str("disable_steamapps_issubscribedapp = false\n"); + config.push_str("[dlc]\n"); + + for dlc in &enabled_dlcs { + config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name)); } -} + + fs::write(&cream_api_path, config) + .map_err(|e| format!("Failed to write cream_api.ini: {}", e))?; + + // Update version manifest + let cached_versions = crate::cache::read_versions()?; + crate::cache::update_game_creamlinux_version(&game_path, cached_versions.creamlinux.latest)?; + + info!( + "CreamLinux installation completed successfully for game: {}", + game.title + ); + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/installer.rs b/src-tauri/src/installer.rs deleted file mode 100644 index 49af4a9..0000000 --- a/src-tauri/src/installer.rs +++ /dev/null @@ -1,1107 +0,0 @@ -use crate::AppState; -use log::{error, info, warn}; -use reqwest; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::fs; -use std::io; -use std::path::Path; -use std::sync::atomic::Ordering; -use std::time::Duration; -use tauri::Manager; -use tauri::{AppHandle, Emitter}; -use tempfile::tempdir; -use zip::ZipArchive; - -// Constants for API endpoints and downloads -const CREAMLINUX_RELEASE_URL: &str = - "https://github.com/anticitizn/creamlinux/releases/latest/download/creamlinux.zip"; -const SMOKEAPI_REPO: &str = "acidicoala/SmokeAPI"; - -// Type of installer -#[derive(Debug, Clone, Copy)] -pub enum InstallerType { - Cream, - Smoke, -} - -// Action to perform -#[derive(Debug, Clone, Copy)] -pub enum InstallerAction { - Install, - Uninstall, -} - -// Error type combining all possible errors -#[derive(Debug)] -pub enum InstallerError { - IoError(io::Error), - ReqwestError(reqwest::Error), - ZipError(zip::result::ZipError), - InstallationError(String), -} - -impl From for InstallerError { - fn from(err: io::Error) -> Self { - InstallerError::IoError(err) - } -} - -impl From for InstallerError { - fn from(err: reqwest::Error) -> Self { - InstallerError::ReqwestError(err) - } -} - -impl From for InstallerError { - fn from(err: zip::result::ZipError) -> Self { - InstallerError::ZipError(err) - } -} - -impl std::fmt::Display for InstallerError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - InstallerError::IoError(e) => write!(f, "IO error: {}", e), - InstallerError::ReqwestError(e) => write!(f, "Network error: {}", e), - InstallerError::ZipError(e) => write!(f, "Zip extraction error: {}", e), - InstallerError::InstallationError(e) => write!(f, "Installation error: {}", e), - } - } -} - -impl std::error::Error for InstallerError {} - -// DLC Information structure -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct DlcInfo { - pub appid: String, - pub name: String, -} - -// Struct to hold installation instructions for the frontend -#[derive(Serialize, Debug, Clone)] -pub struct InstallationInstructions { - #[serde(rename = "type")] - pub type_: String, - pub command: String, - pub game_title: String, - pub dlc_count: Option, -} - -// Game information structure from searcher module -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Game { - pub id: String, - pub title: String, - pub path: String, - pub native: bool, - pub api_files: Vec, - pub cream_installed: bool, - pub smoke_installed: bool, - pub installing: bool, -} - -// Emit a progress update to the frontend -pub fn emit_progress( - app_handle: &AppHandle, - title: &str, - message: &str, - progress: f32, - complete: bool, - show_instructions: bool, - instructions: Option, -) { - let mut payload = json!({ - "title": title, - "message": message, - "progress": progress, - "complete": complete, - "show_instructions": show_instructions - }); - - if let Some(inst) = instructions { - payload["instructions"] = serde_json::to_value(inst).unwrap_or_default(); - } - - if let Err(e) = app_handle.emit("installation-progress", payload) { - warn!("Failed to emit progress event: {}", e); - } -} - -// Process a single game action (install/uninstall Cream/Smoke) -pub async fn process_action( - _game_id: String, - installer_type: InstallerType, - action: InstallerAction, - game: Game, - app_handle: AppHandle, -) -> Result<(), String> { - match (installer_type, action) { - (InstallerType::Cream, InstallerAction::Install) => { - // We only allow CreamLinux for native games - if !game.native { - return Err("CreamLinux can only be installed on native Linux games".to_string()); - } - - info!("Installing CreamLinux for game: {}", game.title); - let game_title = game.title.clone(); - - emit_progress( - &app_handle, - &format!("Installing CreamLinux for {}", game_title), - "Fetching DLC list...", - 10.0, - false, - false, - None, - ); - - // Fetch DLC list - let dlcs = match fetch_dlc_details(&game.id).await { - Ok(dlcs) => dlcs, - Err(e) => { - error!("Failed to fetch DLC details: {}", e); - return Err(format!("Failed to fetch DLC details: {}", e)); - } - }; - - let dlc_count = dlcs.len(); - info!("Found {} DLCs for {}", dlc_count, game_title); - - emit_progress( - &app_handle, - &format!("Installing CreamLinux for {}", game_title), - "Downloading CreamLinux...", - 30.0, - false, - false, - None, - ); - - // Install CreamLinux - let app_handle_clone = app_handle.clone(); - let game_title_clone = game_title.clone(); - - match install_creamlinux(&game.path, &game.id, dlcs, move |progress, message| { - // Emit progress updates during installation - emit_progress( - &app_handle_clone, - &format!("Installing CreamLinux for {}", game_title_clone), - message, - 30.0 + (progress * 60.0), // Scale progress from 30% to 90% - false, - false, - None, - ); - }) - .await - { - Ok(_) => { - // Emit completion with instructions - let instructions = InstallationInstructions { - type_: "cream_install".to_string(), - command: "sh ./cream.sh %command%".to_string(), - game_title: game_title.clone(), - dlc_count: Some(dlc_count), - }; - - emit_progress( - &app_handle, - &format!("Installation Completed: {}", game_title), - "CreamLinux has been installed successfully!", - 100.0, - true, - true, - Some(instructions), - ); - - info!("CreamLinux installation completed for: {}", game_title); - Ok(()) - } - Err(e) => { - error!("Failed to install CreamLinux: {}", e); - Err(format!("Failed to install CreamLinux: {}", e)) - } - } - } - (InstallerType::Cream, InstallerAction::Uninstall) => { - // Ensure this is a native game - if !game.native { - return Err( - "CreamLinux can only be uninstalled from native Linux games".to_string() - ); - } - - let game_title = game.title.clone(); - info!("Uninstalling CreamLinux from game: {}", game_title); - - emit_progress( - &app_handle, - &format!("Uninstalling CreamLinux from {}", game_title), - "Removing CreamLinux files...", - 30.0, - false, - false, - None, - ); - - // Uninstall CreamLinux - match uninstall_creamlinux(&game.path) { - Ok(_) => { - // Emit completion with instructions - let instructions = InstallationInstructions { - type_: "cream_uninstall".to_string(), - command: "sh ./cream.sh %command%".to_string(), - game_title: game_title.clone(), - dlc_count: None, - }; - - emit_progress( - &app_handle, - &format!("Uninstallation Completed: {}", game_title), - "CreamLinux has been uninstalled successfully!", - 100.0, - true, - true, - Some(instructions), - ); - - info!("CreamLinux uninstallation completed for: {}", game_title); - Ok(()) - } - Err(e) => { - error!("Failed to uninstall CreamLinux: {}", e); - Err(format!("Failed to uninstall CreamLinux: {}", e)) - } - } - } - (InstallerType::Smoke, InstallerAction::Install) => { - // We only allow SmokeAPI for Proton/Windows games - if game.native { - return Err("SmokeAPI can only be installed on Proton/Windows games".to_string()); - } - - // Check if we have any Steam API DLLs to patch - if game.api_files.is_empty() { - return Err( - "No Steam API DLLs found to patch. SmokeAPI cannot be installed.".to_string(), - ); - } - - let game_title = game.title.clone(); - info!("Installing SmokeAPI for game: {}", game_title); - - emit_progress( - &app_handle, - &format!("Installing SmokeAPI for {}", game_title), - "Fetching SmokeAPI release information...", - 10.0, - false, - false, - None, - ); - - // Create clones for the closure - let app_handle_clone = app_handle.clone(); - let game_title_clone = game_title.clone(); - let api_files = game.api_files.clone(); - - // Call the SmokeAPI installation with progress updates - match install_smokeapi(&game.path, &api_files, move |progress, message| { - // Emit progress updates during installation - emit_progress( - &app_handle_clone, - &format!("Installing SmokeAPI for {}", game_title_clone), - message, - 10.0 + (progress * 90.0), // Scale progress from 10% to 100% - false, - false, - None, - ); - }) - .await - { - Ok(_) => { - // Emit completion with instructions - let instructions = InstallationInstructions { - type_: "smoke_install".to_string(), - command: "No additional steps needed. SmokeAPI will work automatically." - .to_string(), - game_title: game_title.clone(), - dlc_count: Some(game.api_files.len()), - }; - - emit_progress( - &app_handle, - &format!("Installation Completed: {}", game_title), - "SmokeAPI has been installed successfully!", - 100.0, - true, - true, - Some(instructions), - ); - - info!("SmokeAPI installation completed for: {}", game_title); - Ok(()) - } - Err(e) => { - error!("Failed to install SmokeAPI: {}", e); - Err(format!("Failed to install SmokeAPI: {}", e)) - } - } - } - (InstallerType::Smoke, InstallerAction::Uninstall) => { - // Ensure this is a non-native game - if game.native { - return Err( - "SmokeAPI can only be uninstalled from Proton/Windows games".to_string() - ); - } - - let game_title = game.title.clone(); - info!("Uninstalling SmokeAPI from game: {}", game_title); - - emit_progress( - &app_handle, - &format!("Uninstalling SmokeAPI from {}", game_title), - "Restoring original files...", - 30.0, - false, - false, - None, - ); - - // Uninstall SmokeAPI - match uninstall_smokeapi(&game.path, &game.api_files) { - Ok(_) => { - // Emit completion with instructions - let instructions = InstallationInstructions { - type_: "smoke_uninstall".to_string(), - command: "Original Steam API files have been restored.".to_string(), - game_title: game_title.clone(), - dlc_count: None, - }; - - emit_progress( - &app_handle, - &format!("Uninstallation Completed: {}", game_title), - "SmokeAPI has been uninstalled successfully!", - 100.0, - true, - true, - Some(instructions), - ); - - info!("SmokeAPI uninstallation completed for: {}", game_title); - Ok(()) - } - Err(e) => { - error!("Failed to uninstall SmokeAPI: {}", e); - Err(format!("Failed to uninstall SmokeAPI: {}", e)) - } - } - } - } -} - -// Install CreamLinux for a game -async fn install_creamlinux( - game_path: &str, - app_id: &str, - dlcs: Vec, - progress_callback: F, -) -> Result<(), InstallerError> -where - F: Fn(f32, &str) + Send + 'static, -{ - // Progress update - progress_callback(0.1, "Preparing to download CreamLinux..."); - - // Download CreamLinux zip - let client = reqwest::Client::new(); - progress_callback(0.2, "Downloading CreamLinux..."); - - let response = client - .get(CREAMLINUX_RELEASE_URL) - .timeout(Duration::from_secs(30)) - .send() - .await?; - - if !response.status().is_success() { - return Err(InstallerError::InstallationError(format!( - "Failed to download CreamLinux: HTTP {}", - response.status() - ))); - } - - // Save to temporary file - progress_callback(0.4, "Saving downloaded files..."); - let temp_dir = tempdir()?; - let zip_path = temp_dir.path().join("creamlinux.zip"); - let content = response.bytes().await?; - fs::write(&zip_path, &content)?; - - // Extract the zip - progress_callback(0.5, "Extracting CreamLinux files..."); - let file = fs::File::open(&zip_path)?; - let mut archive = ZipArchive::new(file)?; - - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let outpath = Path::new(game_path).join(file.name()); - - if file.name().ends_with('/') { - fs::create_dir_all(&outpath)?; - } else { - if let Some(p) = outpath.parent() { - if !p.exists() { - fs::create_dir_all(p)?; - } - } - let mut outfile = fs::File::create(&outpath)?; - io::copy(&mut file, &mut outfile)?; - } - - // Set executable permissions for cream.sh - if file.name() == "cream.sh" { - progress_callback(0.6, "Setting executable permissions..."); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&outpath)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&outpath, perms)?; - } - } - } - - // Create cream_api.ini with DLC info - progress_callback(0.8, "Creating configuration file..."); - let cream_api_path = Path::new(game_path).join("cream_api.ini"); - let mut config = String::new(); - - config.push_str(&format!("APPID = {}\n[config]\n", app_id)); - config.push_str("issubscribedapp_on_false_use_real = true\n"); - config.push_str("[methods]\n"); - config.push_str("disable_steamapps_issubscribedapp = false\n"); - config.push_str("[dlc]\n"); - - for dlc in dlcs { - config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name)); - } - - fs::write(cream_api_path, config)?; - progress_callback(1.0, "Installation completed successfully!"); - - Ok(()) -} - -// Install CreamLinux for a game with pre-fetched DLC list -pub async fn install_creamlinux_with_dlcs( - game_path: &str, - app_id: &str, - dlcs: Vec, - progress_callback: F, -) -> Result<(), InstallerError> -where - F: Fn(f32, &str) + Send + 'static, -{ - // Progress update - progress_callback(0.1, "Preparing to download CreamLinux..."); - - // Download CreamLinux zip - let client = reqwest::Client::new(); - progress_callback(0.2, "Downloading CreamLinux..."); - - let response = client - .get(CREAMLINUX_RELEASE_URL) - .timeout(Duration::from_secs(30)) - .send() - .await?; - - if !response.status().is_success() { - return Err(InstallerError::InstallationError(format!( - "Failed to download CreamLinux: HTTP {}", - response.status() - ))); - } - - // Save to temporary file - progress_callback(0.4, "Saving downloaded files..."); - let temp_dir = tempdir()?; - let zip_path = temp_dir.path().join("creamlinux.zip"); - let content = response.bytes().await?; - fs::write(&zip_path, &content)?; - - // Extract the zip - progress_callback(0.5, "Extracting CreamLinux files..."); - let file = fs::File::open(&zip_path)?; - let mut archive = ZipArchive::new(file)?; - - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let outpath = Path::new(game_path).join(file.name()); - - if file.name().ends_with('/') { - fs::create_dir_all(&outpath)?; - } else { - if let Some(p) = outpath.parent() { - if !p.exists() { - fs::create_dir_all(p)?; - } - } - let mut outfile = fs::File::create(&outpath)?; - io::copy(&mut file, &mut outfile)?; - } - - // Set executable permissions for cream.sh - if file.name() == "cream.sh" { - progress_callback(0.6, "Setting executable permissions..."); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&outpath)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&outpath, perms)?; - } - } - } - - // Create cream_api.ini with DLC info - using the provided DLCs directly - progress_callback(0.8, "Creating configuration file..."); - let cream_api_path = Path::new(game_path).join("cream_api.ini"); - let mut config = String::new(); - - config.push_str(&format!("APPID = {}\n[config]\n", app_id)); - config.push_str("issubscribedapp_on_false_use_real = true\n"); - config.push_str("[methods]\n"); - config.push_str("disable_steamapps_issubscribedapp = false\n"); - config.push_str("[dlc]\n"); - - for dlc in dlcs { - config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name)); - } - - fs::write(cream_api_path, config)?; - progress_callback(1.0, "Installation completed successfully!"); - - Ok(()) -} - -// Uninstall CreamLinux from a game -fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> { - info!("Uninstalling CreamLinux from: {}", game_path); - - // Files to remove during uninstallation - let files_to_remove = [ - "cream.sh", - "cream_api.ini", - "cream_api.so", - "lib32Creamlinux.so", - "lib64Creamlinux.so", - ]; - - for file in &files_to_remove { - let file_path = Path::new(game_path).join(file); - if file_path.exists() { - match fs::remove_file(&file_path) { - Ok(_) => info!("Removed file: {}", file_path.display()), - Err(e) => { - error!("Failed to remove {}: {}", file_path.display(), e); - // Continue with other files even if one fails - } - } - } - } - - info!("CreamLinux uninstallation completed for: {}", game_path); - Ok(()) -} - -// Fetch DLC details from Steam API -pub async fn fetch_dlc_details(app_id: &str) -> Result, InstallerError> { - let client = reqwest::Client::new(); - let base_url = format!( - "https://store.steampowered.com/api/appdetails?appids={}", - app_id - ); - - let response = client - .get(&base_url) - .timeout(Duration::from_secs(10)) - .send() - .await?; - - if !response.status().is_success() { - return Err(InstallerError::InstallationError(format!( - "Failed to fetch game details: HTTP {}", - response.status() - ))); - } - - let data: serde_json::Value = response.json().await?; - let dlc_ids = match data - .get(app_id) - .and_then(|app| app.get("data")) - .and_then(|data| data.get("dlc")) - { - Some(dlc_array) => match dlc_array.as_array() { - Some(array) => array - .iter() - .filter_map(|id| id.as_u64().map(|n| n.to_string())) - .collect::>(), - _ => Vec::new(), - }, - _ => Vec::new(), - }; - - info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id); - - let mut dlc_details = Vec::new(); - - for dlc_id in dlc_ids { - let dlc_url = format!( - "https://store.steampowered.com/api/appdetails?appids={}", - dlc_id - ); - - // Add a small delay to avoid rate limiting - tokio::time::sleep(Duration::from_millis(300)).await; - - let dlc_response = client - .get(&dlc_url) - .timeout(Duration::from_secs(10)) - .send() - .await?; - - if dlc_response.status().is_success() { - let dlc_data: serde_json::Value = dlc_response.json().await?; - - let dlc_name = match dlc_data - .get(&dlc_id) - .and_then(|app| app.get("data")) - .and_then(|data| data.get("name")) - { - Some(name) => match name.as_str() { - Some(s) => s.to_string(), - _ => "Unknown DLC".to_string(), - }, - _ => "Unknown DLC".to_string(), - }; - - info!("Found DLC: {} ({})", dlc_name, dlc_id); - dlc_details.push(DlcInfo { - appid: dlc_id, - name: dlc_name, - }); - } else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { - // If rate limited, wait longer - error!("Rate limited by Steam API, waiting 10 seconds"); - tokio::time::sleep(Duration::from_secs(10)).await; - } - } - - info!( - "Successfully retrieved details for {} DLCs", - dlc_details.len() - ); - Ok(dlc_details) -} - -// Fetch DLC details from Steam API with progress updates -pub async fn fetch_dlc_details_with_progress( - app_id: &str, - app_handle: &tauri::AppHandle, -) -> Result, InstallerError> { - info!( - "Starting DLC details fetch with progress for game ID: {}", - app_id - ); - - // Get a reference to a cancellation flag from app state - let state = app_handle.state::(); - let should_cancel = state.fetch_cancellation.clone(); - - let client = reqwest::Client::new(); - let base_url = format!( - "https://store.steampowered.com/api/appdetails?appids={}", - app_id - ); - - // Emit initial progress - emit_dlc_progress(app_handle, "Looking up game details...", 5, None); - info!("Emitted initial DLC progress: 5%"); - - let response = client - .get(&base_url) - .timeout(Duration::from_secs(10)) - .send() - .await?; - - if !response.status().is_success() { - let error_msg = format!("Failed to fetch game details: HTTP {}", response.status()); - error!("{}", error_msg); - return Err(InstallerError::InstallationError(error_msg)); - } - - let data: serde_json::Value = response.json().await?; - let dlc_ids = match data - .get(app_id) - .and_then(|app| app.get("data")) - .and_then(|data| data.get("dlc")) - { - Some(dlc_array) => match dlc_array.as_array() { - Some(array) => array - .iter() - .filter_map(|id| id.as_u64().map(|n| n.to_string())) - .collect::>(), - _ => Vec::new(), - }, - _ => Vec::new(), - }; - - info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id); - emit_dlc_progress( - app_handle, - &format!("Found {} DLCs. Fetching details...", dlc_ids.len()), - 10, - None, - ); - info!("Emitted DLC progress: 10%, found {} DLCs", dlc_ids.len()); - - let mut dlc_details = Vec::new(); - let total_dlcs = dlc_ids.len(); - - for (index, dlc_id) in dlc_ids.iter().enumerate() { - // Check if cancellation was requested - if should_cancel.load(Ordering::SeqCst) { - info!("DLC fetch cancelled for game {}", app_id); - return Err(InstallerError::InstallationError( - "Operation cancelled by user".to_string(), - )); - } - let progress_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0; - let progress_rounded = progress_percent as u32; - let remaining_dlcs = total_dlcs - index; - - // Estimate time remaining (rough calculation - 300ms per DLC) - let est_time_left = if remaining_dlcs > 0 { - let seconds = (remaining_dlcs as f32 * 0.3).ceil() as u32; - if seconds < 60 { - format!("~{} seconds", seconds) - } else { - format!("~{} minute(s)", (seconds as f32 / 60.0).ceil() as u32) - } - } else { - "almost done".to_string() - }; - - info!( - "Processing DLC {}/{} - Progress: {}%", - index + 1, - total_dlcs, - progress_rounded - ); - emit_dlc_progress( - app_handle, - &format!("Processing DLC {}/{}", index + 1, total_dlcs), - progress_rounded, - Some(&est_time_left), - ); - - let dlc_url = format!( - "https://store.steampowered.com/api/appdetails?appids={}", - dlc_id - ); - - // Add a small delay to avoid rate limiting - tokio::time::sleep(Duration::from_millis(300)).await; - - let dlc_response = client - .get(&dlc_url) - .timeout(Duration::from_secs(10)) - .send() - .await?; - - if dlc_response.status().is_success() { - let dlc_data: serde_json::Value = dlc_response.json().await?; - - let dlc_name = match dlc_data - .get(&dlc_id) - .and_then(|app| app.get("data")) - .and_then(|data| data.get("name")) - { - Some(name) => match name.as_str() { - Some(s) => s.to_string(), - _ => "Unknown DLC".to_string(), - }, - _ => "Unknown DLC".to_string(), - }; - - info!("Found DLC: {} ({})", dlc_name, dlc_id); - let dlc_info = DlcInfo { - appid: dlc_id.clone(), - name: dlc_name, - }; - - // Emit each DLC as we find it - if let Ok(json) = serde_json::to_string(&dlc_info) { - if let Err(e) = app_handle.emit("dlc-found", json) { - warn!("Failed to emit dlc-found event: {}", e); - } else { - info!("Emitted dlc-found event for DLC: {}", dlc_id); - } - } - - dlc_details.push(dlc_info); - } else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { - // If rate limited, wait longer - error!("Rate limited by Steam API, waiting 10 seconds"); - emit_dlc_progress( - app_handle, - "Rate limited by Steam. Waiting...", - progress_rounded, - None, - ); - tokio::time::sleep(Duration::from_secs(10)).await; - } - } - - // Final progress update - info!( - "Completed DLC fetch. Found {} DLCs in total", - dlc_details.len() - ); - emit_dlc_progress( - app_handle, - &format!("Completed! Found {} DLCs", dlc_details.len()), - 100, - None, - ); - info!("Emitted final DLC progress: 100%"); - - Ok(dlc_details) -} - -// Emit DLC progress updates to the frontend -fn emit_dlc_progress( - app_handle: &tauri::AppHandle, - message: &str, - progress: u32, - time_left: Option<&str>, -) { - let mut payload = json!({ - "message": message, - "progress": progress - }); - - if let Some(time) = time_left { - payload["timeLeft"] = json!(time); - } - - if let Err(e) = app_handle.emit("dlc-progress", payload) { - warn!("Failed to emit dlc-progress event: {}", e); - } -} - -// Install SmokeAPI for a game -async fn install_smokeapi( - game_path: &str, - api_files: &[String], - progress_callback: F, -) -> Result<(), InstallerError> -where - F: Fn(f32, &str) + Send + 'static, -{ - // Get the latest SmokeAPI release - progress_callback(0.1, "Fetching latest SmokeAPI release..."); - let client = reqwest::Client::new(); - let releases_url = format!( - "https://api.github.com/repos/{}/releases/latest", - SMOKEAPI_REPO - ); - - let response = client - .get(&releases_url) - .header("User-Agent", "CreamLinux") - .timeout(Duration::from_secs(10)) - .send() - .await?; - - if !response.status().is_success() { - return Err(InstallerError::InstallationError(format!( - "Failed to fetch SmokeAPI releases: HTTP {}", - response.status() - ))); - } - - let release_info: serde_json::Value = response.json().await?; - let latest_version = match release_info.get("tag_name") { - Some(tag) => tag.as_str().unwrap_or("latest"), - _ => "latest", - }; - - info!("Latest SmokeAPI version: {}", latest_version); - - // Construct download URL - let zip_url = format!( - "https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip", - SMOKEAPI_REPO, latest_version, latest_version - ); - - // Download the zip - progress_callback(0.3, "Downloading SmokeAPI..."); - let response = client - .get(&zip_url) - .timeout(Duration::from_secs(30)) - .send() - .await?; - - if !response.status().is_success() { - return Err(InstallerError::InstallationError(format!( - "Failed to download SmokeAPI: HTTP {}", - response.status() - ))); - } - - // Save to temporary file - progress_callback(0.5, "Saving downloaded files..."); - let temp_dir = tempdir()?; - let zip_path = temp_dir.path().join("smokeapi.zip"); - let content = response.bytes().await?; - fs::write(&zip_path, &content)?; - - // Extract and install for each API file - progress_callback(0.6, "Extracting SmokeAPI files..."); - let file = fs::File::open(&zip_path)?; - let mut archive = ZipArchive::new(file)?; - - for (i, api_file) in api_files.iter().enumerate() { - let progress = 0.6 + (i as f32 / api_files.len() as f32) * 0.3; - progress_callback(progress, &format!("Installing SmokeAPI for {}", api_file)); - - let api_dir = Path::new(game_path).join( - Path::new(api_file) - .parent() - .unwrap_or_else(|| Path::new("")), - ); - let api_name = Path::new(api_file).file_name().unwrap_or_default(); - - // Backup original file - let original_path = api_dir.join(api_name); - let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll")); - - info!("Processing: {}", original_path.display()); - info!("Backup path: {}", backup_path.display()); - - // Only backup if not already backed up - if !backup_path.exists() && original_path.exists() { - fs::copy(&original_path, &backup_path)?; - info!("Created backup: {}", backup_path.display()); - } - - // Determine if we need 32-bit or 64-bit SmokeAPI DLL based on the original Steam API DLL - let is_64bit = api_name.to_string_lossy().contains("64"); - let target_arch = if is_64bit { "64" } else { "32" }; - - // Search through all files in the archive to find the matching SmokeAPI DLL - let mut found_dll = false; - let mut tried_files = Vec::new(); - let mut matching_dll_name: Option = None; - - // First pass: find the matching DLL name - for i in 0..archive.len() { - if let Ok(file) = archive.by_index(i) { - let file_name = file.name(); - tried_files.push(file_name.to_string()); - - // Check if this is SmokeAPI DLL file with the correct architecture - if file_name.to_lowercase().ends_with(".dll") - && file_name.to_lowercase().contains("smoke") - && file_name.to_lowercase().contains(&format!("{}.dll", target_arch)) { - - matching_dll_name = Some(file_name.to_string()); - break; - } - } - } - - // Second pass: extract the matching DLL if found - if let Some(dll_name) = matching_dll_name { - if let Ok(mut smoke_file) = archive.by_name(&dll_name) { - let mut outfile = fs::File::create(&original_path)?; - io::copy(&mut smoke_file, &mut outfile)?; - info!("Installed {} as: {}", dll_name, original_path.display()); - found_dll = true; - } - } - - if !found_dll { - return Err(InstallerError::InstallationError(format!( - "Could not find {}-bit SmokeAPI DLL for {} in the zip file. Archive contains: {}", - target_arch, - api_name.to_string_lossy(), - tried_files.join(", ") - ))); - } - } - - progress_callback(1.0, "SmokeAPI installation completed!"); - info!("SmokeAPI installation completed for: {}", game_path); - Ok(()) -} - -// Uninstall SmokeAPI from a game -fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), InstallerError> { - info!("Uninstalling SmokeAPI from: {}", game_path); - - for api_file in api_files { - let api_path = Path::new(game_path).join(api_file); - let api_dir = api_path.parent().unwrap_or_else(|| Path::new(game_path)); - let api_name = api_path.file_name().unwrap_or_default(); - - let original_path = api_dir.join(api_name); - let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll")); - - info!("Processing: {}", original_path.display()); - info!("Backup path: {}", backup_path.display()); - - if backup_path.exists() { - // Remove the SmokeAPI version - if original_path.exists() { - match fs::remove_file(&original_path) { - Ok(_) => info!("Removed SmokeAPI file: {}", original_path.display()), - Err(e) => error!( - "Failed to remove SmokeAPI file: {}, error: {}", - original_path.display(), - e - ), - } - } - - // Restore the original file - match fs::rename(&backup_path, &original_path) { - Ok(_) => info!("Restored original file: {}", original_path.display()), - Err(e) => { - error!( - "Failed to restore original file: {}, error: {}", - original_path.display(), - e - ); - // Try to copy instead if rename fails - if let Err(copy_err) = fs::copy(&backup_path, &original_path) - .and_then(|_| fs::remove_file(&backup_path)) - { - error!("Failed to copy backup file: {}", copy_err); - } - } - } - } else { - info!("No backup found for: {}", api_file); - } - } - - info!("SmokeAPI uninstallation completed for: {}", game_path); - Ok(()) -} diff --git a/src-tauri/src/installer/file_ops.rs b/src-tauri/src/installer/file_ops.rs new file mode 100644 index 0000000..be07a7c --- /dev/null +++ b/src-tauri/src/installer/file_ops.rs @@ -0,0 +1,44 @@ +// This module contains helper functions for file operations during installation + +use std::fs; +use std::io; +use std::path::Path; + +// Copy a file with backup +#[allow(dead_code)] +pub fn copy_with_backup(src: &Path, dest: &Path) -> io::Result<()> { + // If destination exists, create a backup + if dest.exists() { + let backup = dest.with_extension("bak"); + fs::copy(dest, &backup)?; + } + + fs::copy(src, dest)?; + Ok(()) +} + +// Safely remove a file (doesn't error if it doesn't exist) +#[allow(dead_code)] +pub fn safe_remove(path: &Path) -> io::Result<()> { + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) +} + +// Make a file executable (Unix only) +#[cfg(unix)] +#[allow(dead_code)] +pub fn make_executable(path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms)?; + Ok(()) +} + +#[cfg(not(unix))] +pub fn make_executable(_path: &Path) -> io::Result<()> { + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/installer/mod.rs b/src-tauri/src/installer/mod.rs new file mode 100644 index 0000000..6255185 --- /dev/null +++ b/src-tauri/src/installer/mod.rs @@ -0,0 +1,655 @@ +mod file_ops; + +use crate::cache::{ + remove_creamlinux_version, remove_smokeapi_version, + update_game_creamlinux_version, update_game_smokeapi_version, +}; +use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker}; +use crate::AppState; +use log::{error, info, warn}; +use reqwest; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fs; +use std::path::Path; +use std::sync::atomic::Ordering; +use std::time::Duration; +use tauri::Manager; +use tauri::{AppHandle, Emitter}; + +// Type of installer +#[derive(Debug, Clone, Copy)] +pub enum InstallerType { + Cream, + Smoke, +} + +// Action to perform +#[derive(Debug, Clone, Copy)] +pub enum InstallerAction { + Install, + Uninstall, +} + +// DLC Information structure +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct DlcInfo { + pub appid: String, + pub name: String, +} + +// Struct to hold installation instructions for the frontend +#[derive(Serialize, Debug, Clone)] +pub struct InstallationInstructions { + #[serde(rename = "type")] + pub type_: String, + pub command: String, + pub game_title: String, + pub dlc_count: Option, +} + +// Game information structure +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Game { + pub id: String, + pub title: String, + pub path: String, + pub native: bool, + pub api_files: Vec, + pub cream_installed: bool, + pub smoke_installed: bool, + pub installing: bool, +} + +// Emit a progress update to the frontend +pub fn emit_progress( + app_handle: &AppHandle, + title: &str, + message: &str, + progress: f32, + complete: bool, + show_instructions: bool, + instructions: Option, +) { + let mut payload = json!({ + "title": title, + "message": message, + "progress": progress, + "complete": complete, + "show_instructions": show_instructions + }); + + if let Some(inst) = instructions { + payload["instructions"] = serde_json::to_value(inst).unwrap_or_default(); + } + + if let Err(e) = app_handle.emit("installation-progress", payload) { + warn!("Failed to emit progress event: {}", e); + } +} + +// Process a single game action (install/uninstall Cream/Smoke) +pub async fn process_action( + game_id: String, + installer_type: InstallerType, + action: InstallerAction, + game: Game, + app_handle: AppHandle, +) -> Result<(), String> { + match (installer_type, action) { + (InstallerType::Cream, InstallerAction::Install) => { + install_creamlinux(game_id, game, app_handle).await + } + (InstallerType::Cream, InstallerAction::Uninstall) => { + uninstall_creamlinux(game, app_handle).await + } + (InstallerType::Smoke, InstallerAction::Install) => { + install_smokeapi(game, app_handle).await + } + (InstallerType::Smoke, InstallerAction::Uninstall) => { + uninstall_smokeapi(game, app_handle).await + } + } +} + +// Install CreamLinux to a game +async fn install_creamlinux( + game_id: String, + game: Game, + app_handle: AppHandle, +) -> Result<(), String> { + if !game.native { + return Err("CreamLinux can only be installed on native Linux games".to_string()); + } + + info!("Installing CreamLinux for game: {}", game.title); + let game_title = game.title.clone(); + + emit_progress( + &app_handle, + &format!("Installing CreamLinux for {}", game_title), + "Fetching DLC list...", + 10.0, + false, + false, + None, + ); + + // Fetch DLC list + let dlcs = match fetch_dlc_details(&game_id).await { + Ok(dlcs) => dlcs, + Err(e) => { + error!("Failed to fetch DLC details: {}", e); + return Err(format!("Failed to fetch DLC details: {}", e)); + } + }; + + let dlc_count = dlcs.len(); + info!("Found {} DLCs for {}", dlc_count, game_title); + + emit_progress( + &app_handle, + &format!("Installing CreamLinux for {}", game_title), + "Installing from cache...", + 50.0, + false, + false, + None, + ); + + // Install CreamLinux binaries from cache + CreamLinux::install_to_game(&game.path, &game_id) + .await + .map_err(|e| format!("Failed to install CreamLinux: {}", e))?; + + emit_progress( + &app_handle, + &format!("Installing CreamLinux for {}", game_title), + "Writing DLC configuration...", + 80.0, + false, + false, + None, + ); + + // Write cream_api.ini with DLCs + write_cream_api_ini(&game.path, &game_id, &dlcs)?; + + // Update version manifest + let cached_versions = crate::cache::read_versions()?; + update_game_creamlinux_version(&game.path, cached_versions.creamlinux.latest)?; + + // Emit completion with instructions + let instructions = InstallationInstructions { + type_: "cream_install".to_string(), + command: "sh ./cream.sh %command%".to_string(), + game_title: game_title.clone(), + dlc_count: Some(dlc_count), + }; + + emit_progress( + &app_handle, + &format!("Installation Completed: {}", game_title), + "CreamLinux has been installed successfully!", + 100.0, + true, + true, + Some(instructions), + ); + + info!("CreamLinux installation completed for: {}", game_title); + Ok(()) +} + +// Uninstall CreamLinux from a game +async fn uninstall_creamlinux(game: Game, app_handle: AppHandle) -> Result<(), String> { + if !game.native { + return Err("CreamLinux can only be uninstalled from native Linux games".to_string()); + } + + let game_title = game.title.clone(); + info!("Uninstalling CreamLinux from game: {}", game_title); + + emit_progress( + &app_handle, + &format!("Uninstalling CreamLinux from {}", game_title), + "Removing CreamLinux files...", + 50.0, + false, + false, + None, + ); + + CreamLinux::uninstall_from_game(&game.path, &game.id) + .await + .map_err(|e| format!("Failed to uninstall CreamLinux: {}", e))?; + + // Remove version from manifest + remove_creamlinux_version(&game.path)?; + + emit_progress( + &app_handle, + &format!("Uninstallation Completed: {}", game_title), + "CreamLinux has been removed successfully!", + 100.0, + true, + false, + None, + ); + + info!("CreamLinux uninstallation completed for: {}", game_title); + Ok(()) +} + +// Install SmokeAPI to a game +async fn install_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> { + if game.native { + return Err("SmokeAPI can only be installed on Proton/Windows games".to_string()); + } + + info!("Installing SmokeAPI for game: {}", game.title); + let game_title = game.title.clone(); + + emit_progress( + &app_handle, + &format!("Installing SmokeAPI for {}", game_title), + "Installing from cache...", + 50.0, + false, + false, + None, + ); + + // Join api_files into a comma-separated string for the context + let api_files_str = game.api_files.join(","); + + // Install SmokeAPI from cache + SmokeAPI::install_to_game(&game.path, &api_files_str) + .await + .map_err(|e| format!("Failed to install SmokeAPI: {}", e))?; + + // Update version manifest + let cached_versions = crate::cache::read_versions()?; + update_game_smokeapi_version(&game.path, cached_versions.smokeapi.latest)?; + + emit_progress( + &app_handle, + &format!("Installation Completed: {}", game_title), + "SmokeAPI has been installed successfully!", + 100.0, + true, + false, + None, + ); + + info!("SmokeAPI installation completed for: {}", game_title); + Ok(()) +} + +// Uninstall SmokeAPI from a game +async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> { + if game.native { + return Err("SmokeAPI can only be uninstalled from Proton/Windows games".to_string()); + } + + let game_title = game.title.clone(); + info!("Uninstalling SmokeAPI from game: {}", game_title); + + emit_progress( + &app_handle, + &format!("Uninstalling SmokeAPI from {}", game_title), + "Removing SmokeAPI files...", + 50.0, + false, + false, + None, + ); + + // Join api_files into a comma-separated string for the context + let api_files_str = game.api_files.join(","); + + SmokeAPI::uninstall_from_game(&game.path, &api_files_str) + .await + .map_err(|e| format!("Failed to uninstall SmokeAPI: {}", e))?; + + // Remove version from manifest + remove_smokeapi_version(&game.path)?; + + emit_progress( + &app_handle, + &format!("Uninstallation Completed: {}", game_title), + "SmokeAPI has been removed successfully!", + 100.0, + true, + false, + None, + ); + + info!("SmokeAPI uninstallation completed for: {}", game_title); + Ok(()) +} + +// Fetch DLC details from Steam API (simple version without progress) +pub async fn fetch_dlc_details(app_id: &str) -> Result, String> { + let client = reqwest::Client::new(); + let base_url = format!( + "https://store.steampowered.com/api/appdetails?appids={}", + app_id + ); + + let response = client + .get(&base_url) + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Failed to fetch game details: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to fetch game details: HTTP {}", + response.status() + )); + } + + let data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let dlc_ids = match data + .get(app_id) + .and_then(|app| app.get("data")) + .and_then(|data| data.get("dlc")) + { + Some(dlc_array) => match dlc_array.as_array() { + Some(array) => array + .iter() + .filter_map(|id| id.as_u64().map(|n| n.to_string())) + .collect::>(), + _ => Vec::new(), + }, + _ => Vec::new(), + }; + + info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id); + + let mut dlc_details = Vec::new(); + + for dlc_id in dlc_ids { + let dlc_url = format!( + "https://store.steampowered.com/api/appdetails?appids={}", + dlc_id + ); + + // Add a small delay to avoid rate limiting + tokio::time::sleep(Duration::from_millis(300)).await; + + let dlc_response = client + .get(&dlc_url) + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Failed to fetch DLC details: {}", e))?; + + if dlc_response.status().is_success() { + let dlc_data: serde_json::Value = dlc_response + .json() + .await + .map_err(|e| format!("Failed to parse DLC response: {}", e))?; + + let dlc_name = match dlc_data + .get(&dlc_id) + .and_then(|app| app.get("data")) + .and_then(|data| data.get("name")) + { + Some(name) => match name.as_str() { + Some(s) => s.to_string(), + _ => "Unknown DLC".to_string(), + }, + _ => "Unknown DLC".to_string(), + }; + + info!("Found DLC: {} ({})", dlc_name, dlc_id); + dlc_details.push(DlcInfo { + appid: dlc_id, + name: dlc_name, + }); + } else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { + // If rate limited, wait longer + error!("Rate limited by Steam API, waiting 10 seconds"); + tokio::time::sleep(Duration::from_secs(10)).await; + } + } + + info!( + "Successfully retrieved details for {} DLCs", + dlc_details.len() + ); + Ok(dlc_details) +} + +// Fetch DLC details from Steam API with progress updates +pub async fn fetch_dlc_details_with_progress( + app_id: &str, + app_handle: &tauri::AppHandle, +) -> Result, String> { + info!( + "Starting DLC details fetch with progress for game ID: {}", + app_id + ); + + // Get a reference to a cancellation flag from app state + let state = app_handle.state::(); + let should_cancel = state.fetch_cancellation.clone(); + + let client = reqwest::Client::new(); + let base_url = format!( + "https://store.steampowered.com/api/appdetails?appids={}", + app_id + ); + + // Emit initial progress + emit_dlc_progress(app_handle, "Looking up game details...", 5, None); + info!("Emitted initial DLC progress: 5%"); + + let response = client + .get(&base_url) + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Failed to fetch game details: {}", e))?; + + if !response.status().is_success() { + let error_msg = format!("Failed to fetch game details: HTTP {}", response.status()); + error!("{}", error_msg); + return Err(error_msg); + } + + let data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let dlc_ids = match data + .get(app_id) + .and_then(|app| app.get("data")) + .and_then(|data| data.get("dlc")) + { + Some(dlc_array) => match dlc_array.as_array() { + Some(array) => array + .iter() + .filter_map(|id| id.as_u64().map(|n| n.to_string())) + .collect::>(), + _ => Vec::new(), + }, + _ => Vec::new(), + }; + + info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id); + emit_dlc_progress( + app_handle, + &format!("Found {} DLCs. Fetching details...", dlc_ids.len()), + 10, + None, + ); + info!("Emitted DLC progress: 10%, found {} DLCs", dlc_ids.len()); + + let mut dlc_details = Vec::new(); + let total_dlcs = dlc_ids.len(); + + for (index, dlc_id) in dlc_ids.iter().enumerate() { + // Check if cancellation was requested + if should_cancel.load(Ordering::SeqCst) { + info!("DLC fetch cancelled for game {}", app_id); + return Err("Operation cancelled by user".to_string()); + } + + let progress_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0; + let progress_rounded = progress_percent as u32; + let remaining_dlcs = total_dlcs - index; + + // Estimate time remaining (rough calculation - 300ms per DLC) + let est_time_left = if remaining_dlcs > 0 { + let seconds = (remaining_dlcs as f32 * 0.3).ceil() as u32; + if seconds < 60 { + format!("~{} seconds", seconds) + } else { + format!("~{} minute(s)", (seconds as f32 / 60.0).ceil() as u32) + } + } else { + "almost done".to_string() + }; + + info!( + "Processing DLC {}/{} - Progress: {}%", + index + 1, + total_dlcs, + progress_rounded + ); + emit_dlc_progress( + app_handle, + &format!("Processing DLC {}/{}", index + 1, total_dlcs), + progress_rounded, + Some(&est_time_left), + ); + + let dlc_url = format!( + "https://store.steampowered.com/api/appdetails?appids={}", + dlc_id + ); + + // Add a small delay to avoid rate limiting + tokio::time::sleep(Duration::from_millis(300)).await; + + let dlc_response = client + .get(&dlc_url) + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Failed to fetch DLC details: {}", e))?; + + if dlc_response.status().is_success() { + let dlc_data: serde_json::Value = dlc_response + .json() + .await + .map_err(|e| format!("Failed to parse DLC response: {}", e))?; + + let dlc_name = match dlc_data + .get(&dlc_id) + .and_then(|app| app.get("data")) + .and_then(|data| data.get("name")) + { + Some(name) => match name.as_str() { + Some(s) => s.to_string(), + _ => "Unknown DLC".to_string(), + }, + _ => "Unknown DLC".to_string(), + }; + + info!("Found DLC: {} ({})", dlc_name, dlc_id); + let dlc_info = DlcInfo { + appid: dlc_id.clone(), + name: dlc_name, + }; + + // Emit each DLC as we find it + if let Ok(json) = serde_json::to_string(&dlc_info) { + if let Err(e) = app_handle.emit("dlc-found", json) { + warn!("Failed to emit dlc-found event: {}", e); + } else { + info!("Emitted dlc-found event for DLC: {}", dlc_id); + } + } + + dlc_details.push(dlc_info); + } else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { + // If rate limited, wait longer + error!("Rate limited by Steam API, waiting 10 seconds"); + emit_dlc_progress( + app_handle, + "Rate limited by Steam. Waiting...", + progress_rounded, + None, + ); + tokio::time::sleep(Duration::from_secs(10)).await; + } + } + + // Final progress update + info!( + "Completed DLC fetch. Found {} DLCs in total", + dlc_details.len() + ); + emit_dlc_progress( + app_handle, + &format!("Completed! Found {} DLCs", dlc_details.len()), + 100, + None, + ); + info!("Emitted final DLC progress: 100%"); + + Ok(dlc_details) +} + +// Emit DLC progress updates to the frontend +fn emit_dlc_progress( + app_handle: &tauri::AppHandle, + message: &str, + progress: u32, + time_left: Option<&str>, +) { + let mut payload = json!({ + "message": message, + "progress": progress + }); + + if let Some(time) = time_left { + payload["timeLeft"] = json!(time); + } + + if let Err(e) = app_handle.emit("dlc-progress", payload) { + warn!("Failed to emit dlc-progress event: {}", e); + } +} + +// Write cream_api.ini configuration file +fn write_cream_api_ini(game_path: &str, app_id: &str, dlcs: &[DlcInfo]) -> Result<(), String> { + let cream_api_path = Path::new(game_path).join("cream_api.ini"); + let mut config = String::new(); + + config.push_str(&format!("APPID = {}\n[config]\n", app_id)); + config.push_str("issubscribedapp_on_false_use_real = true\n"); + config.push_str("[methods]\n"); + config.push_str("disable_steamapps_issubscribedapp = false\n"); + config.push_str("[dlc]\n"); + + for dlc in dlcs { + config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name)); + } + + fs::write(&cream_api_path, config) + .map_err(|e| format!("Failed to write cream_api.ini: {}", e))?; + + info!("Wrote cream_api.ini to {}", cream_api_path.display()); + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4d0f18b..fa06810 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,12 +3,13 @@ windows_subsystem = "windows" )] +mod cache; mod dlc_manager; mod installer; mod searcher; +mod unlockers; use dlc_manager::DlcInfoWithState; -use tauri_plugin_updater::Builder as UpdaterBuilder; use installer::{Game, InstallerAction, InstallerType}; use log::{debug, error, info, warn}; use parking_lot::Mutex; @@ -19,6 +20,7 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use tauri::State; use tauri::{Emitter, Manager}; +use tauri_plugin_updater::Builder as UpdaterBuilder; use tokio::time::Instant; #[derive(Serialize, Deserialize, Debug, Clone)] @@ -27,7 +29,6 @@ pub struct GameAction { action: String, } -// Mark fields with # to allow unused fields #[derive(Debug, Clone)] struct DlcCache { #[allow(dead_code)] @@ -37,7 +38,7 @@ struct DlcCache { } // Structure to hold the state of installed games -struct AppState { +pub struct AppState { games: Mutex>, dlc_cache: Mutex>, fetch_cancellation: Arc, @@ -49,7 +50,6 @@ fn get_all_dlcs_command(game_path: String) -> Result, Stri dlc_manager::get_all_dlcs(&game_path) } -// Scan and get the list of Steam games #[tauri::command] async fn scan_steam_games( state: State<'_, AppState>, @@ -58,14 +58,11 @@ async fn scan_steam_games( info!("Starting Steam games scan"); emit_scan_progress(&app_handle, "Locating Steam libraries...", 10); - // Get default Steam paths let paths = searcher::get_default_steam_paths(); - // Find Steam libraries emit_scan_progress(&app_handle, "Finding Steam libraries...", 15); let libraries = searcher::find_steam_libraries(&paths); - // Group libraries by path to avoid duplicates in logs let mut unique_libraries = std::collections::HashSet::new(); for lib in &libraries { unique_libraries.insert(lib.to_string_lossy().to_string()); @@ -88,7 +85,6 @@ async fn scan_steam_games( 20, ); - // Find installed games let games_info = searcher::find_installed_games(&libraries).await; emit_scan_progress( @@ -97,7 +93,6 @@ async fn scan_steam_games( 90, ); - // Log summary of games found info!("Games scan complete - Found {} games", games_info.len()); info!( "Native games: {}", @@ -116,12 +111,10 @@ async fn scan_steam_games( games_info.iter().filter(|g| g.smoke_installed).count() ); - // Convert to our Game struct let mut result = Vec::new(); info!("Processing games into application state..."); for game_info in games_info { - // Only log detailed game info at Debug level to keep Info logs cleaner debug!( "Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}", game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed @@ -139,8 +132,6 @@ async fn scan_steam_games( }; result.push(game.clone()); - - // Store in state for later use state.games.lock().insert(game.id.clone(), game); } @@ -154,9 +145,7 @@ async fn scan_steam_games( Ok(result) } -// Helper function to emit scan progress events fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) { - // Log first, then emit the event info!("Scan progress: {}% - {}", progress, message); let payload = serde_json::json!({ @@ -169,7 +158,6 @@ fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u3 } } -// Fetch game info by ID - useful for single game updates #[tauri::command] fn get_game_info(game_id: String, state: State) -> Result { let games = state.games.lock(); @@ -179,14 +167,12 @@ fn get_game_info(game_id: String, state: State) -> Result, app_handle: tauri::AppHandle, ) -> Result { - // Clone the information we need from state to avoid lifetime issues let game = { let games = state.games.lock(); games @@ -195,7 +181,6 @@ async fn process_game_action( .ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))? }; - // Parse the action string to determine type and operation let (installer_type, action) = match game_action.action.as_str() { "install_cream" => (InstallerType::Cream, InstallerAction::Install), "uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall), @@ -204,7 +189,6 @@ async fn process_game_action( _ => return Err(format!("Invalid action: {}", game_action.action)), }; - // Execute the action installer::process_action( game_action.game_id.clone(), installer_type, @@ -214,7 +198,6 @@ async fn process_game_action( ) .await?; - // Update game status in state based on the action let updated_game = { let mut games_map = state.games.lock(); let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| { @@ -224,7 +207,6 @@ async fn process_game_action( ) })?; - // Update installation status match (installer_type, action) { (InstallerType::Cream, InstallerAction::Install) => { game.cream_installed = true; @@ -240,14 +222,10 @@ async fn process_game_action( } } - // Reset installing flag game.installing = false; - - // Return updated game info game.clone() }; - // Emit an event to update the UI for this specific game if let Err(e) = app_handle.emit("game-updated", &updated_game) { warn!("Failed to emit game-updated event: {}", e); } @@ -255,18 +233,19 @@ async fn process_game_action( Ok(updated_game) } -// Fetch DLC list for a game #[tauri::command] async fn fetch_game_dlcs( game_id: String, - app_handle: tauri::AppHandle, + state: State<'_, AppState>, ) -> Result, String> { - info!("Fetching DLCs for game ID: {}", game_id); + info!("Fetching DLC list for game ID: {}", game_id); - // Fetch DLC data + // Fetch DLC data from API match installer::fetch_dlc_details(&game_id).await { Ok(dlcs) => { - // Convert to DlcInfoWithState + info!("Successfully fetched {} DLCs for game {}", dlcs.len(), game_id); + + // Convert to DLCInfoWithState for in-memory caching let dlcs_with_state = dlcs .into_iter() .map(|dlc| DlcInfoWithState { @@ -276,31 +255,31 @@ async fn fetch_game_dlcs( }) .collect::>(); - // Cache in memory for this session - let state = app_handle.state::(); - let mut cache = state.dlc_cache.lock(); - cache.insert( + // Update in-memory cache + let mut dlc_cache = state.dlc_cache.lock(); + dlc_cache.insert( game_id.clone(), DlcCache { data: dlcs_with_state.clone(), - timestamp: Instant::now(), + timestamp: tokio::time::Instant::now(), }, ); Ok(dlcs_with_state) } - Err(e) => Err(format!("Failed to fetch DLC details: {}", e)), + Err(e) => { + error!("Failed to fetch DLC details: {}", e); + Err(format!("Failed to fetch DLC details: {}", e)) + } } } #[tauri::command] -fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> { - info!("Request to abort DLC fetch for game ID: {}", game_id); - - let state = app_handle.state::(); +fn abort_dlc_fetch(state: State<'_, AppState>, app_handle: tauri::AppHandle) -> Result<(), String> { + info!("Aborting DLC fetch request received"); state.fetch_cancellation.store(true, Ordering::SeqCst); - // Reset after a short delay + // Reset cancellation flag after a short delay std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(500)); let state = app_handle.state::(); @@ -310,7 +289,6 @@ fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(), Ok(()) } -// Fetch DLC list with progress updates (streaming) #[tauri::command] async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> { info!("Streaming DLCs for game ID: {}", game_id); @@ -334,7 +312,7 @@ async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resu }) .collect::>(); - // Update in-memory + // Update in-memory cache let state = app_handle.state::(); let mut dlc_cache = state.dlc_cache.lock(); dlc_cache.insert( @@ -363,21 +341,18 @@ async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resu } } -// Clear caches command renamed to flush_data for clarity #[tauri::command] fn clear_caches() -> Result<(), String> { info!("Data flush requested - cleaning in-memory state only"); Ok(()) } -// Get the list of enabled DLCs for a game #[tauri::command] fn get_enabled_dlcs_command(game_path: String) -> Result, String> { info!("Getting enabled DLCs for: {}", game_path); dlc_manager::get_enabled_dlcs(&game_path) } -// Update the DLC configuration for a game #[tauri::command] fn update_dlc_configuration_command( game_path: String, @@ -387,7 +362,6 @@ fn update_dlc_configuration_command( dlc_manager::update_dlc_configuration(&game_path, dlcs) } -// Install CreamLinux with selected DLCs #[tauri::command] async fn install_cream_with_dlcs_command( game_id: String, @@ -460,7 +434,6 @@ async fn install_cream_with_dlcs_command( } } -// Setup logging fn setup_logging() -> Result<(), Box> { use log::LevelFilter; use log4rs::append::file::FileAppender; @@ -468,30 +441,25 @@ fn setup_logging() -> Result<(), Box> { use log4rs::encode::pattern::PatternEncoder; use std::fs; - // Get XDG cache directory let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?; let log_path = xdg_dirs.place_cache_file("creamlinux.log")?; - // Clear the log file on startup if log_path.exists() { if let Err(e) = fs::write(&log_path, "") { eprintln!("Warning: Failed to clear log file: {}", e); } } - // Create a file appender let file = FileAppender::builder() .encoder(Box::new(PatternEncoder::new( "[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n", ))) .build(log_path)?; - // Build the config let config = Config::builder() .appender(Appender::builder().build("file", Box::new(file))) .build(Root::builder().appender("file").build(LevelFilter::Info))?; - // Initialize log4rs with this config log4rs::init_config(config)?; info!("CreamLinux started with a clean log file"); @@ -499,7 +467,6 @@ fn setup_logging() -> Result<(), Box> { } fn main() { - // Set up logging first if let Err(e) = setup_logging() { eprintln!("Warning: Failed to initialize logging: {}", e); } @@ -526,7 +493,6 @@ fn main() { abort_dlc_fetch, ]) .setup(|app| { - // Add a setup handler to do any initialization work info!("Tauri application setup"); #[cfg(debug_assertions)] @@ -537,8 +503,7 @@ fn main() { } } } - - // Initialize and manage AppState + let app_handle = app.handle().clone(); let state = AppState { games: Mutex::new(HashMap::new()), @@ -546,7 +511,61 @@ fn main() { fetch_cancellation: Arc::new(AtomicBool::new(false)), }; app.manage(state); - + + // Initialize cache on startup in a background task + tauri::async_runtime::spawn(async move { + info!("Starting cache initialization..."); + + // Step 1: Initialize cache if needed (downloads unlockers) + if let Err(e) = cache::initialize_cache().await { + error!("Failed to initialize cache: {}", e); + return; + } + + info!("Cache initialized successfully"); + + // Step 2: Check for updates + match cache::check_and_update_cache().await { + Ok(result) => { + if result.any_updated() { + info!( + "Updates found - SmokeAPI: {:?}, CreamLinux: {:?}", + result.new_smokeapi_version, result.new_creamlinux_version + ); + + // Step 3: Update outdated games + let state_for_update = app_handle.state::(); + let games = state_for_update.games.lock().clone(); + + match cache::update_outdated_games(&games).await { + Ok(stats) => { + info!( + "Game updates complete - {} games updated, {} failed", + stats.total_updated(), + stats.total_failed() + ); + + if stats.has_failures() { + warn!( + "Some game updates failed: SmokeAPI failed: {}, CreamLinux failed: {}", + stats.smokeapi_failed, stats.creamlinux_failed + ); + } + } + Err(e) => { + error!("Failed to update games: {}", e); + } + } + } else { + info!("All unlockers are up to date"); + } + } + Err(e) => { + error!("Failed to check for updates: {}", e); + } + } + }); + Ok(()) }) .run(tauri::generate_context!()) diff --git a/src-tauri/src/unlockers/creamlinux.rs b/src-tauri/src/unlockers/creamlinux.rs new file mode 100644 index 0000000..4d91cdf --- /dev/null +++ b/src-tauri/src/unlockers/creamlinux.rs @@ -0,0 +1,225 @@ +use super::Unlocker; +use async_trait::async_trait; +use log::{info, warn}; +use reqwest; +use std::fs; +use std::io; +use std::path::Path; +use std::time::Duration; +use tempfile::tempdir; +use zip::ZipArchive; + +pub struct CreamLinux; + +#[async_trait] +impl Unlocker for CreamLinux { + async fn get_latest_version() -> Result { + info!("Fetching latest CreamLinux version..."); + + let client = reqwest::Client::new(); + + // Fetch the latest release from GitHub API + let api_url = "https://api.github.com/repos/anticitizn/creamlinux/releases/latest"; + + let response = client + .get(api_url) + .header("User-Agent", "CreamLinux-Installer") + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Failed to fetch CreamLinux releases: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to fetch CreamLinux releases: HTTP {}", + response.status() + )); + } + + let release_info: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse release info: {}", e))?; + + let version = release_info + .get("tag_name") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Failed to extract version from release info".to_string())? + .to_string(); + + info!("Latest CreamLinux version: {}", version); + Ok(version) + } + + async fn download_to_cache() -> Result { + let version = Self::get_latest_version().await?; + info!("Downloading CreamLinux version {}...", version); + + let client = reqwest::Client::new(); + + // Construct the download URL using the version + let download_url = format!( + "https://github.com/anticitizn/creamlinux/releases/download/{}/creamlinux.zip", + version + ); + + // Download the zip + let response = client + .get(&download_url) + .timeout(Duration::from_secs(30)) + .send() + .await + .map_err(|e| format!("Failed to download CreamLinux: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to download CreamLinux: HTTP {}", + response.status() + )); + } + + // Save to temporary file + let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?; + let zip_path = temp_dir.path().join("creamlinux.zip"); + let content = response + .bytes() + .await + .map_err(|e| format!("Failed to read response bytes: {}", e))?; + fs::write(&zip_path, &content).map_err(|e| format!("Failed to write zip file: {}", e))?; + + // Extract to cache directory + let version_dir = crate::cache::get_creamlinux_version_dir(&version)?; + let file = fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?; + let mut archive = + ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?; + + // Extract all files + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("Failed to access zip entry: {}", e))?; + + let file_name = file.name().to_string(); // Clone the name early + + // Skip directories + if file_name.ends_with('/') { + continue; + } + + let output_path = version_dir.join( + Path::new(&file_name) + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new(&file_name)), + ); + + let mut outfile = fs::File::create(&output_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + io::copy(&mut file, &mut outfile) + .map_err(|e| format!("Failed to extract file: {}", e))?; + + // Make .sh files executable + if file_name.ends_with(".sh") { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&output_path) + .map_err(|e| format!("Failed to get file metadata: {}", e))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&output_path, perms) + .map_err(|e| format!("Failed to set permissions: {}", e))?; + } + } + + info!("Extracted: {}", output_path.display()); + } + + info!( + "CreamLinux version {} downloaded to cache successfully", + version + ); + Ok(version) + } + + async fn install_to_game(game_path: &str, _game_id: &str) -> Result<(), String> { + info!("Installing CreamLinux to {}", game_path); + + // Get the cached CreamLinux files + let cached_files = crate::cache::list_creamlinux_files()?; + if cached_files.is_empty() { + return Err("No CreamLinux files found in cache".to_string()); + } + + let game_path_obj = Path::new(game_path); + + // Copy all files to the game directory + for file in &cached_files { + let file_name = file.file_name().ok_or_else(|| { + format!("Failed to get filename from: {}", file.display()) + })?; + + let dest_path = game_path_obj.join(file_name); + + fs::copy(file, &dest_path) + .map_err(|e| format!("Failed to copy {} to game directory: {}", file_name.to_string_lossy(), e))?; + + // Make .sh files executable + if file_name.to_string_lossy().ends_with(".sh") { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&dest_path) + .map_err(|e| format!("Failed to get file metadata: {}", e))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&dest_path, perms) + .map_err(|e| format!("Failed to set permissions: {}", e))?; + } + } + + info!("Installed: {}", dest_path.display()); + } + + // Note: cream_api.ini is managed separately by dlc_manager + // This function only installs the binaries + + info!("CreamLinux installation completed for: {}", game_path); + Ok(()) + } + + async fn uninstall_from_game(game_path: &str, _game_id: &str) -> Result<(), String> { + info!("Uninstalling CreamLinux from: {}", game_path); + + let game_path_obj = Path::new(game_path); + + // List of CreamLinux files to remove + let files_to_remove = vec![ + "cream.sh", + "lib32Creamlinux.so", + "lib64Creamlinux.so", + "cream_api.ini", + ]; + + for file_name in files_to_remove { + let file_path = game_path_obj.join(file_name); + + if file_path.exists() { + match fs::remove_file(&file_path) { + Ok(_) => info!("Removed: {}", file_path.display()), + Err(e) => warn!( + "Failed to remove {}: {}", + file_path.display(), + e + ), + } + } + } + + info!("CreamLinux uninstallation completed for: {}", game_path); + Ok(()) + } + + fn name() -> &'static str { + "CreamLinux" + } +} \ No newline at end of file diff --git a/src-tauri/src/unlockers/mod.rs b/src-tauri/src/unlockers/mod.rs new file mode 100644 index 0000000..1efcc48 --- /dev/null +++ b/src-tauri/src/unlockers/mod.rs @@ -0,0 +1,27 @@ +mod creamlinux; +mod smokeapi; + +pub use creamlinux::CreamLinux; +pub use smokeapi::SmokeAPI; + +use async_trait::async_trait; + +// Common trait for all unlockers (CreamLinux, SmokeAPI) +#[async_trait] +pub trait Unlocker { + // Get the latest version from the remote source + async fn get_latest_version() -> Result; + + // Download the unlocker to the cache directory + async fn download_to_cache() -> Result; + + // Install the unlocker from cache to a game directory + async fn install_to_game(game_path: &str, context: &str) -> Result<(), String>; + + // Uninstall the unlocker from a game directory + async fn uninstall_from_game(game_path: &str, context: &str) -> Result<(), String>; + + // Get the name of the unlocker + #[allow(dead_code)] + fn name() -> &'static str; +} \ No newline at end of file diff --git a/src-tauri/src/unlockers/smokeapi.rs b/src-tauri/src/unlockers/smokeapi.rs new file mode 100644 index 0000000..2fb73dd --- /dev/null +++ b/src-tauri/src/unlockers/smokeapi.rs @@ -0,0 +1,260 @@ +use super::Unlocker; +use async_trait::async_trait; +use log::{error, info, warn}; +use reqwest; +use std::fs; +use std::io; +use std::path::Path; +use std::time::Duration; +use tempfile::tempdir; +use zip::ZipArchive; + +const SMOKEAPI_REPO: &str = "acidicoala/SmokeAPI"; + +pub struct SmokeAPI; + +#[async_trait] +impl Unlocker for SmokeAPI { + async fn get_latest_version() -> Result { + info!("Fetching latest SmokeAPI version..."); + + let client = reqwest::Client::new(); + let releases_url = format!( + "https://api.github.com/repos/{}/releases/latest", + SMOKEAPI_REPO + ); + + let response = client + .get(&releases_url) + .header("User-Agent", "CreamLinux") + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Failed to fetch SmokeAPI releases: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to fetch SmokeAPI releases: HTTP {}", + response.status() + )); + } + + let release_info: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse release info: {}", e))?; + + let version = release_info + .get("tag_name") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Failed to extract version from release info".to_string())? + .to_string(); + + info!("Latest SmokeAPI version: {}", version); + Ok(version) + } + + async fn download_to_cache() -> Result { + let version = Self::get_latest_version().await?; + info!("Downloading SmokeAPI version {}...", version); + + let client = reqwest::Client::new(); + let zip_url = format!( + "https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip", + SMOKEAPI_REPO, version, version + ); + + // Download the zip + let response = client + .get(&zip_url) + .timeout(Duration::from_secs(30)) + .send() + .await + .map_err(|e| format!("Failed to download SmokeAPI: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to download SmokeAPI: HTTP {}", + response.status() + )); + } + + // Save to temporary file + let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?; + let zip_path = temp_dir.path().join("smokeapi.zip"); + let content = response + .bytes() + .await + .map_err(|e| format!("Failed to read response bytes: {}", e))?; + fs::write(&zip_path, &content).map_err(|e| format!("Failed to write zip file: {}", e))?; + + // Extract to cache directory + let version_dir = crate::cache::get_smokeapi_version_dir(&version)?; + let file = fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?; + let mut archive = + ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?; + + // Extract all DLL files + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("Failed to access zip entry: {}", e))?; + + let file_name = file.name(); + + // Only extract DLL files + if file_name.to_lowercase().ends_with(".dll") { + let output_path = version_dir.join( + Path::new(file_name) + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new(file_name)), + ); + + let mut outfile = fs::File::create(&output_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + io::copy(&mut file, &mut outfile) + .map_err(|e| format!("Failed to extract file: {}", e))?; + + info!("Extracted: {}", output_path.display()); + } + } + + info!( + "SmokeAPI version {} downloaded to cache successfully", + version + ); + Ok(version) + } + + async fn install_to_game(game_path: &str, api_files_str: &str) -> Result<(), String> { + // Parse api_files from the context string (comma-separated) + let api_files: Vec = api_files_str.split(',').map(|s| s.to_string()).collect(); + + info!( + "Installing SmokeAPI to {} for {} API files", + game_path, + api_files.len() + ); + + // Get the cached SmokeAPI DLLs + let cached_dlls = crate::cache::list_smokeapi_dlls()?; + if cached_dlls.is_empty() { + return Err("No SmokeAPI DLLs found in cache".to_string()); + } + + for api_file in &api_files { + let api_dir = Path::new(game_path).join( + Path::new(api_file) + .parent() + .unwrap_or_else(|| Path::new("")), + ); + let api_name = Path::new(api_file).file_name().unwrap_or_default(); + + // Backup original file + let original_path = api_dir.join(api_name); + let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll")); + + info!("Processing: {}", original_path.display()); + + // Only backup if not already backed up + if !backup_path.exists() && original_path.exists() { + fs::copy(&original_path, &backup_path) + .map_err(|e| format!("Failed to backup original file: {}", e))?; + info!("Created backup: {}", backup_path.display()); + } + + // Determine if we need 32-bit or 64-bit SmokeAPI DLL + let is_64bit = api_name.to_string_lossy().contains("64"); + let target_arch = if is_64bit { "64" } else { "32" }; + + // Find the matching DLL + let matching_dll = cached_dlls + .iter() + .find(|dll| { + let dll_name = dll.file_name().unwrap_or_default().to_string_lossy(); + dll_name.to_lowercase().contains("smoke") + && dll_name + .to_lowercase() + .contains(&format!("{}.dll", target_arch)) + }) + .ok_or_else(|| { + format!( + "No matching {}-bit SmokeAPI DLL found in cache", + target_arch + ) + })?; + + // Copy the DLL to the game directory + fs::copy(matching_dll, &original_path) + .map_err(|e| format!("Failed to install SmokeAPI DLL: {}", e))?; + + info!( + "Installed {} as: {}", + matching_dll.display(), + original_path.display() + ); + } + + info!("SmokeAPI installation completed for: {}", game_path); + Ok(()) + } + + async fn uninstall_from_game(game_path: &str, api_files_str: &str) -> Result<(), String> { + // Parse api_files from the context string (comma-separated) + let api_files: Vec = api_files_str.split(',').map(|s| s.to_string()).collect(); + + info!("Uninstalling SmokeAPI from: {}", game_path); + + for api_file in &api_files { + let api_path = Path::new(game_path).join(api_file); + let api_dir = api_path.parent().unwrap_or_else(|| Path::new(game_path)); + let api_name = api_path.file_name().unwrap_or_default(); + + let original_path = api_dir.join(api_name); + let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll")); + + info!("Processing: {}", original_path.display()); + + if backup_path.exists() { + // Remove the SmokeAPI version + if original_path.exists() { + match fs::remove_file(&original_path) { + Ok(_) => info!("Removed SmokeAPI file: {}", original_path.display()), + Err(e) => warn!( + "Failed to remove SmokeAPI file: {}, error: {}", + original_path.display(), + e + ), + } + } + + // Restore the original file + match fs::rename(&backup_path, &original_path) { + Ok(_) => info!("Restored original file: {}", original_path.display()), + Err(e) => { + warn!( + "Failed to restore original file: {}, error: {}", + original_path.display(), + e + ); + // Try to copy instead if rename fails + if let Err(copy_err) = fs::copy(&backup_path, &original_path) + .and_then(|_| fs::remove_file(&backup_path)) + { + error!("Failed to copy backup file: {}", copy_err); + } + } + } + } else { + info!("No backup found for: {}", api_file); + } + } + + info!("SmokeAPI uninstallation completed for: {}", game_path); + Ok(()) + } + + fn name() -> &'static str { + "SmokeAPI" + } +} \ No newline at end of file