From 3dd15624de76df9b4ea225734c0c355ac95c32d5 Mon Sep 17 00:00:00 2001 From: Tickbase Date: Sat, 11 Jul 2026 12:22:17 +0200 Subject: [PATCH] backend stuff lol --- src-tauri/src/cache/mod.rs | 78 +++++++++++++++- src-tauri/src/cache/storage.rs | 16 ++++ src-tauri/src/installer/mod.rs | 69 ++++---------- src-tauri/src/main.rs | 71 +++++++++++++-- src-tauri/src/searcher.rs | 62 +++++++++++++ src-tauri/src/system_info.rs | 130 +++++++++++++++++++++++++++ src-tauri/src/unlockers/koaloader.rs | 122 ++++++++++++++----------- 7 files changed, 434 insertions(+), 114 deletions(-) create mode 100644 src-tauri/src/system_info.rs diff --git a/src-tauri/src/cache/mod.rs b/src-tauri/src/cache/mod.rs index 7d399ba..0610fac 100644 --- a/src-tauri/src/cache/mod.rs +++ b/src-tauri/src/cache/mod.rs @@ -3,9 +3,10 @@ mod version; pub use storage::{ get_creamlinux_version_dir, get_smokeapi_version_dir, - list_creamlinux_files, list_smokeapi_files, read_versions, + list_creamlinux_files, list_smokeapi_files, read_versions, update_creamlinux_version, update_smokeapi_version, validate_smokeapi_cache, validate_creamlinux_cache, get_cache_dir, get_koaloader_version_dir, get_screamapi_version_dir, + clear_unlocker_cache, }; pub use version::{ @@ -81,7 +82,7 @@ pub async fn initialize_cache() -> Result<(), String> { } Ok(false) => { info!("ScreamAPI cache incomplete, re-downloading"); - needs_smokeapi = true; + needs_screamapi = true; } Err(e) => { warn!("Failed to validate ScreamAPI cache: {}, re-downloading", e); @@ -170,7 +171,7 @@ pub async fn initialize_cache() -> Result<(), String> { } } - if !needs_smokeapi && !needs_creamlinux && !needs_smokeapi && !needs_koaloader { + if !needs_smokeapi && !needs_creamlinux && !needs_screamapi && !needs_koaloader { info!("Cache already initialized and validated"); } else { info!("Cache initialization complete"); @@ -247,6 +248,68 @@ pub async fn check_and_update_cache() -> Result { } } + // Check ScreamAPI + let current_screamapi = read_versions()?.screamapi.latest; + match ScreamAPI::get_latest_version().await { + Ok(latest_version) => { + if current_screamapi != latest_version { + info!( + "ScreamAPI update available: {} -> {}", + current_screamapi, latest_version + ); + + match ScreamAPI::download_to_cache().await { + Ok(version) => { + update_screamapi_version(&version)?; + result.screamapi_updated = true; + result.new_screamapi_version = Some(version); + info!("ScreamAPI updated successfully"); + } + Err(e) => { + error!("Failed to download ScreamAPI update: {}", e); + return Err(format!("Failed to download ScreamAPI update: {}", e)); + } + } + } else { + info!("ScreamAPI is up to date: {}", current_screamapi); + } + } + Err(e) => { + warn!("Failed to check ScreamAPI version: {}", e); + } + } + + // Check Koaloader + let current_koaloader = read_versions()?.koaloader.latest; + match Koaloader::get_latest_version().await { + Ok(latest_version) => { + if current_koaloader != latest_version { + info!( + "Koaloader update available: {} -> {}", + current_koaloader, latest_version + ); + + match Koaloader::download_to_cache().await { + Ok(version) => { + update_koaloader_version(&version)?; + result.koaloader_updated = true; + result.new_koaloader_version = Some(version); + info!("Koaloader updated successfully"); + } + Err(e) => { + error!("Failed to download Koaloader update: {}", e); + return Err(format!("Failed to download Koaloader update: {}", e)); + } + } + } else { + info!("Koaloader is up to date: {}", current_koaloader); + } + } + Err(e) => { + warn!("Failed to check Koaloader version: {}", e); + } + } + Ok(result) } @@ -333,13 +396,20 @@ pub async fn update_outdated_games( pub struct UpdateResult { pub smokeapi_updated: bool, pub creamlinux_updated: bool, + pub screamapi_updated: bool, + pub koaloader_updated: bool, pub new_smokeapi_version: Option, pub new_creamlinux_version: Option, + pub new_screamapi_version: Option, + pub new_koaloader_version: Option, } impl UpdateResult { pub fn any_updated(&self) -> bool { - self.smokeapi_updated || self.creamlinux_updated + self.smokeapi_updated + || self.creamlinux_updated + || self.screamapi_updated + || self.koaloader_updated } } diff --git a/src-tauri/src/cache/storage.rs b/src-tauri/src/cache/storage.rs index bd3d5d9..0ae4007 100644 --- a/src-tauri/src/cache/storage.rs +++ b/src-tauri/src/cache/storage.rs @@ -83,6 +83,22 @@ pub fn get_koaloader_dir() -> Result { Ok(dir) } +// Delete all cached unlocker binaries so they're re-downloaded on next +// launch. Deliberately leaves reports.json and the anonymous salt file +// alone. +pub fn clear_unlocker_cache() -> Result<(), String> { + let cache_dir = get_cache_dir()?; + for name in ["smokeapi", "screamapi", "koaloader", "creamlinux"] { + let dir = cache_dir.join(name); + if dir.exists() { + fs::remove_dir_all(&dir) + .map_err(|e| format!("Failed to clear {} cache: {}", name, e))?; + } + } + info!("Cleared cached unlocker binaries"); + Ok(()) +} + // Get the CreamLinux cache directory path pub fn get_creamlinux_dir() -> Result { let cache_dir = get_cache_dir()?; diff --git a/src-tauri/src/installer/mod.rs b/src-tauri/src/installer/mod.rs index ceb74b0..a788d68 100644 --- a/src-tauri/src/installer/mod.rs +++ b/src-tauri/src/installer/mod.rs @@ -1,5 +1,3 @@ -mod file_ops; - use crate::cache::{ remove_creamlinux_version, remove_smokeapi_version, update_game_creamlinux_version, update_game_smokeapi_version, @@ -524,65 +522,46 @@ pub async fn install_koaloader( let exe_path = crate::unlockers::Koaloader::resolve_exe_pub(&game.install_path, &game.executable)?; let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?; - let is_64bit = crate::pe_inspector::is_64bit_exe(&exe_path); - + emit_progress( &app_handle, &format!("Installing Koaloader for {}", title), "Scanning PE imports for best proxy DLL...", 30.0, false, false, None, ); - - let scan = crate::pe_inspector::find_best_proxy(&exe_path); - let proxy_stem = scan.proxy_name.trim_end_matches(".dll").to_string(); + + // Detects bitness, scans PE imports, and copies the matching proxy DLL + let scan = crate::unlockers::Koaloader::install_proxy(&exe_path)?; let is_fallback = scan.is_fallback; - - info!("Selected proxy: {} (fallback={})", scan.proxy_name, is_fallback); - + emit_progress( &app_handle, &format!("Installing Koaloader for {}", title), - &format!("Installing proxy DLL ({})...", scan.proxy_name), + &format!("Installed proxy DLL ({})", scan.proxy_name), 50.0, false, false, None, ); - - let proxy_src = crate::unlockers::Koaloader::get_proxy_dll(&proxy_stem, is_64bit)?; - std::fs::copy(&proxy_src, exe_dir.join(&scan.proxy_name)) - .map_err(|e| format!("Failed to copy Koaloader proxy DLL: {}", e))?; - + emit_progress( &app_handle, &format!("Installing Koaloader for {}", title), "Installing ScreamAPI payload...", 70.0, false, false, None, ); - + let exe_dir_str = exe_dir.to_string_lossy().to_string(); ScreamAPI::install_to_game(&exe_dir_str, "koaloader") .await .map_err(|e| format!("Failed to install ScreamAPI payload: {}", e))?; - + emit_progress( &app_handle, &format!("Installing Koaloader for {}", title), "Writing configuration files...", 88.0, false, false, None, ); - - let exe_name = exe_path.file_name().unwrap_or_default().to_string_lossy().to_string(); - let koa_config = serde_json::json!({ - "logging": false, - "enabled": true, - "auto_load": true, - "targets": [exe_name], - "modules": [] - }); - std::fs::write( - exe_dir.join("Koaloader.config.json"), - serde_json::to_string_pretty(&koa_config).unwrap(), - ) - .map_err(|e| format!("Failed to write Koaloader config: {}", e))?; - + + crate::unlockers::Koaloader::write_koaloader_config(&exe_path)?; + emit_progress( &app_handle, &format!("Installation Complete: {}", title), @@ -608,26 +587,10 @@ pub async fn uninstall_koaloader(game: EpicGame, app_handle: AppHandle) -> Resul let exe_path = crate::unlockers::Koaloader::resolve_exe_pub(&game.install_path, &game.executable)?; let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?; let exe_dir_str = exe_dir.to_string_lossy().to_string(); - - // Remove Koaloader config - let koa_config_path = exe_dir.join("Koaloader.config.json"); - if koa_config_path.exists() { - std::fs::remove_file(&koa_config_path) - .map_err(|e| format!("Failed to remove Koaloader config: {}", e))?; - } - - // Remove any Koaloader proxy DLL - if let Ok(entries) = std::fs::read_dir(exe_dir) { - for entry in entries.filter_map(Result::ok) { - let path = entry.path(); - let name_lower = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); - if crate::unlockers::koaloader::KOA_VARIANTS.contains(&name_lower.as_str()) { - std::fs::remove_file(&path).ok(); - info!("Removed proxy DLL: {}", path.display()); - } - } - } - + + // Removes Koaloader.config.json and any proxy DLL variant + crate::unlockers::Koaloader::remove_proxy_and_config(exe_dir)?; + emit_progress( &app_handle, &format!("Uninstalling Koaloader from {}", title), diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3194769..b6fa2e6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,6 +15,7 @@ mod config; mod epic_scanner; mod pe_inspector; mod screamapi_config; +mod system_info; use crate::config::Config; use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker}; @@ -83,6 +84,49 @@ fn update_config(config_data: Config) -> Result { Ok(config_data) } +// Add a custom Steam library path (validated to contain a steamapps folder) +#[tauri::command] +fn add_custom_steam_path(path: String) -> Result { + let normalized = searcher::normalize_steam_library_path(std::path::Path::new(&path))?; + let normalized_str = normalized.to_string_lossy().to_string(); + + info!("Adding custom Steam library path: {}", normalized_str); + + config::update_config(|cfg| { + if !cfg.custom_steam_paths.contains(&normalized_str) { + cfg.custom_steam_paths.push(normalized_str.clone()); + } + }) +} + +// Remove a custom Steam library path +#[tauri::command] +fn remove_custom_steam_path(path: String) -> Result { + info!("Removing custom Steam library path: {}", path); + + config::update_config(|cfg| { + cfg.custom_steam_paths.retain(|p| p != &path); + }) +} + +// Reset configuration to defaults +#[tauri::command] +fn reset_config() -> Result { + config::reset_config() +} + +// Open the config folder in the system's file manager +#[tauri::command] +fn open_config_folder() -> Result<(), String> { + config::open_config_folder() +} + +// Basic host info (OS/CPU/GPU) shown on the Overview page +#[tauri::command] +fn get_system_info() -> system_info::SystemInfo { + system_info::get_system_info() +} + #[tauri::command] fn get_all_dlcs_command(game_path: String) -> Result, String> { info!("Getting all DLCs (enabled and disabled) for: {}", game_path); @@ -105,7 +149,18 @@ async fn scan_steam_games( info!("Starting Steam games scan"); emit_scan_progress(&app_handle, "Locating Steam libraries...", 10); - let paths = searcher::get_default_steam_paths(); + let mut paths = searcher::get_default_steam_paths(); + + // Add user-configured custom library paths, in case a library lives + // somewhere the auto-detection doesn't know to look. + let cfg = config::load_config()?; + for custom_path in &cfg.custom_steam_paths { + let p = std::path::PathBuf::from(custom_path); + if p.exists() && !paths.contains(&p) { + info!("Adding custom Steam library path: {}", p.display()); + paths.push(p); + } + } emit_scan_progress(&app_handle, "Finding Steam libraries...", 15); let libraries = searcher::find_steam_libraries(&paths); @@ -454,8 +509,8 @@ async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resu #[tauri::command] fn clear_caches() -> Result<(), String> { - info!("Data flush requested - cleaning in-memory state only"); - Ok(()) + info!("Clearing cached unlocker binaries"); + cache::clear_unlocker_cache() } #[tauri::command] @@ -844,6 +899,11 @@ fn main() { resolve_platform_conflict, load_config, update_config, + add_custom_steam_path, + remove_custom_steam_path, + reset_config, + open_config_folder, + get_system_info, set_reporting_opt_in, submit_report, get_local_reports, @@ -891,8 +951,9 @@ fn main() { Ok(result) => { if result.any_updated() { info!( - "Updates found - SmokeAPI: {:?}, CreamLinux: {:?}", - result.new_smokeapi_version, result.new_creamlinux_version + "Updates found - SmokeAPI: {:?}, CreamLinux: {:?}, ScreamAPI: {:?}, Koaloader: {:?}", + result.new_smokeapi_version, result.new_creamlinux_version, + result.new_screamapi_version, result.new_koaloader_version ); // Step 3: Update outdated games diff --git a/src-tauri/src/searcher.rs b/src-tauri/src/searcher.rs index 53a2c56..1e3f5bc 100644 --- a/src-tauri/src/searcher.rs +++ b/src-tauri/src/searcher.rs @@ -72,6 +72,31 @@ pub fn get_default_steam_paths() -> Vec { paths } +/// Validates a user-picked directory and resolves it to the library root +/// `find_steam_libraries` expects (i.e. the folder that directly contains +/// `steamapps`, not the `steamapps` folder itself). +pub fn normalize_steam_library_path(path: &Path) -> Result { + if !path.is_dir() { + return Err(format!("{} is not a valid directory", path.display())); + } + + if path.join("steamapps").is_dir() { + return Ok(path.to_path_buf()); + } + + // User picked the steamapps folder itself - use its parent as the root + if path.file_name().and_then(|n| n.to_str()) == Some("steamapps") { + if let Some(parent) = path.parent() { + return Ok(parent.to_path_buf()); + } + } + + Err(format!( + "No Steam library found at {} (expected a 'steamapps' folder inside it)", + path.display() + )) +} + // Try to read the Steam registry file to find installation paths fn read_steam_registry() -> Option> { let home = match std::env::var("HOME") { @@ -742,4 +767,41 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec info!("Found {} installed games", games.len()); games +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_accepts_folder_containing_steamapps() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("steamapps")).unwrap(); + + let result = normalize_steam_library_path(dir.path()).unwrap(); + assert_eq!(result, dir.path()); + } + + #[test] + fn normalize_accepts_steamapps_folder_itself() { + let dir = tempfile::tempdir().unwrap(); + let steamapps = dir.path().join("steamapps"); + fs::create_dir(&steamapps).unwrap(); + + let result = normalize_steam_library_path(&steamapps).unwrap(); + assert_eq!(result, dir.path()); + } + + #[test] + fn normalize_rejects_folder_without_steamapps() { + let dir = tempfile::tempdir().unwrap(); + assert!(normalize_steam_library_path(dir.path()).is_err()); + } + + #[test] + fn normalize_rejects_nonexistent_path() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("does-not-exist"); + assert!(normalize_steam_library_path(&missing).is_err()); + } } \ No newline at end of file diff --git a/src-tauri/src/system_info.rs b/src-tauri/src/system_info.rs new file mode 100644 index 0000000..19c054a --- /dev/null +++ b/src-tauri/src/system_info.rs @@ -0,0 +1,130 @@ +use serde::Serialize; +use std::fs; +use std::process::Command; + +// Basic host info shown on the Overview page, purely informational. +#[derive(Debug, Clone, Serialize)] +pub struct SystemInfo { + pub os_name: String, + pub cpu_model: String, + pub cpu_cores: usize, + pub gpu_name: String, +} + +fn read_os_name() -> String { + if let Ok(contents) = fs::read_to_string("/etc/os-release") { + for line in contents.lines() { + if let Some(value) = line.strip_prefix("PRETTY_NAME=") { + return value.trim_matches('"').to_string(); + } + } + } + format!("{} (unknown distro)", std::env::consts::OS) +} + +fn read_cpu_model() -> String { + if let Ok(contents) = fs::read_to_string("/proc/cpuinfo") { + for line in contents.lines() { + if let Some((key, value)) = line.split_once(':') { + if key.trim() == "model name" { + return value.trim().to_string(); + } + } + } + } + "Unknown CPU".to_string() +} + +// This can't tell *which* SKU in that family you actually have (lspci +// doesn't expose that either) so it's a best guess, not a precise model. +fn simplify_gpu_name(raw: &str) -> String { + let without_rev = raw.split(" (rev").next().unwrap_or(raw).trim(); + + // The marketing name is almost always the last bracketed group. + let brackets: Vec<&str> = without_rev + .split('[') + .skip(1) + .filter_map(|s| s.split(']').next()) + .collect(); + + let model = brackets + .last() + .and_then(|group| group.split('/').next()) + .unwrap_or(without_rev) + .trim(); + + let vendor = if without_rev.contains("NVIDIA") { + Some("NVIDIA") + } else if without_rev.contains("AMD") || without_rev.contains("ATI") { + Some("AMD") + } else if without_rev.contains("Intel") { + Some("Intel") + } else { + None + }; + + match vendor { + Some(v) if !model.to_uppercase().contains(&v.to_uppercase()) => format!("{} {}", v, model), + _ => model.to_string(), + } +} + +// Best-effort: parse `lspci` for the primary display controller. Not every +// system has `lspci` (minimal containers, some distros), so this degrades +// to "Unknown GPU" instead of failing the whole system-info fetch. +fn read_gpu_name() -> String { + let output = match Command::new("lspci").output() { + Ok(output) if output.status.success() => output, + _ => return "Unknown GPU".to_string(), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.contains("VGA compatible controller") || line.contains("3D controller") { + if let Some((_, rest)) = line.split_once(": ") { + return simplify_gpu_name(rest.trim()); + } + } + } + "Unknown GPU".to_string() +} + +pub fn get_system_info() -> SystemInfo { + SystemInfo { + os_name: read_os_name(), + cpu_model: read_cpu_model(), + cpu_cores: num_cpus::get(), + gpu_name: read_gpu_name(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simplifies_amd_gpu_names() { + assert_eq!( + simplify_gpu_name( + "Advanced Micro Devices, Inc. [AMD/ATI] Navi 33 [Radeon RX 7600/7600 XT/7600M XT/7600S/7700S / PRO W7600] (rev cf)" + ), + "AMD Radeon RX 7600" + ); + } + + #[test] + fn simplifies_nvidia_gpu_names() { + assert_eq!( + simplify_gpu_name("NVIDIA Corporation GA104 [GeForce RTX 3070/3070 Ti] (rev a1)"), + "NVIDIA GeForce RTX 3070" + ); + } + + #[test] + fn simplifies_intel_gpu_names() { + assert_eq!( + simplify_gpu_name("Intel Corporation TigerLake-LP GT2 [Iris Xe Graphics] (rev 01)"), + "Intel Iris Xe Graphics" + ); + } +} diff --git a/src-tauri/src/unlockers/koaloader.rs b/src-tauri/src/unlockers/koaloader.rs index c27eca3..a0dee2d 100644 --- a/src-tauri/src/unlockers/koaloader.rs +++ b/src-tauri/src/unlockers/koaloader.rs @@ -10,14 +10,7 @@ use tempfile::tempdir; use zip::ZipArchive; const KOALOADER_REPO: &str = "acidicoala/Koaloader"; - -pub const KOA_VARIANTS: &[&str] = &[ - "version.dll", "winmm.dll", "winhttp.dll", "iphlpapi.dll", "dinput8.dll", - "d3d11.dll", "dxgi.dll", "d3d9.dll", "d3d10.dll", "dwmapi.dll", "hid.dll", - "msimg32.dll", "mswsock.dll", "opengl32.dll", "profapi.dll", "propsys.dll", - "textshaping.dll", "glu32.dll", "audioses.dll", "msasn1.dll", "wldp.dll", - "xinput9_1_0.dll", -]; +pub use crate::pe_inspector::KOA_VARIANTS; pub struct Koaloader; @@ -159,35 +152,14 @@ impl Unlocker for Koaloader { /// context = relative executable path (e.g. "en_us/Sources/Bin/SnowRunner.exe") /// Progress events are emitted by installer/mod.rs, not here. async fn install_to_game(game_path: &str, context: &str) -> Result<(), String> { - // Install without progress called internally (e.g. from installer/mod.rs - // after it has already emitted its own progress steps) let exe_path = Self::resolve_exe(game_path, context)?; + Self::install_proxy(&exe_path)?; + let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?; - - let is_64bit = crate::pe_inspector::is_64bit_exe(&exe_path); - let scan = crate::pe_inspector::find_best_proxy(&exe_path); - let proxy_stem = scan.proxy_name.trim_end_matches(".dll").to_string(); - - let proxy_src = Self::get_proxy_dll(&proxy_stem, is_64bit)?; - fs::copy(&proxy_src, exe_dir.join(&scan.proxy_name)) - .map_err(|e| format!("Failed to copy Koaloader proxy DLL: {}", e))?; - let exe_dir_str = exe_dir.to_string_lossy().to_string(); crate::unlockers::ScreamAPI::install_to_game(&exe_dir_str, "koaloader").await?; - let exe_name = exe_path.file_name().unwrap_or_default().to_string_lossy().to_string(); - let koa_config = serde_json::json!({ - "logging": false, - "enabled": true, - "auto_load": true, - "targets": [exe_name], - "modules": [] - }); - fs::write( - exe_dir.join("Koaloader.config.json"), - serde_json::to_string_pretty(&koa_config).unwrap(), - ) - .map_err(|e| format!("Failed to write Koaloader config: {}", e))?; + Self::write_koaloader_config(&exe_path)?; info!("Koaloader installation complete for: {}", game_path); Ok(()) @@ -198,26 +170,7 @@ impl Unlocker for Koaloader { let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?; let exe_dir_str = exe_dir.to_string_lossy().to_string(); - let koa_config = exe_dir.join("Koaloader.config.json"); - if koa_config.exists() { - fs::remove_file(&koa_config) - .map_err(|e| format!("Failed to remove Koaloader config: {}", e))?; - } - - if let Ok(entries) = fs::read_dir(exe_dir) { - for entry in entries.filter_map(Result::ok) { - let path = entry.path(); - let name_lower = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_lowercase(); - if KOA_VARIANTS.contains(&name_lower.as_str()) { - fs::remove_file(&path).ok(); - info!("Removed proxy DLL: {}", path.display()); - } - } - } + Self::remove_proxy_and_config(exe_dir)?; crate::unlockers::ScreamAPI::uninstall_from_game(&exe_dir_str, "koaloader").await?; @@ -266,6 +219,71 @@ impl Koaloader { )) } + /// Detects bitness, scans for the best-match Koaloader proxy, and copies + /// it next to `exe_path`. Returns the scan result so callers can report + /// which proxy was used and whether it was a version.dll fallback. + /// Shared by the Unlocker trait impl above and installer/mod.rs (which + /// wraps this with progress events). + pub(crate) fn install_proxy( + exe_path: &Path, + ) -> Result { + let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?; + let is_64bit = crate::pe_inspector::is_64bit_exe(exe_path); + let scan = crate::pe_inspector::find_best_proxy(exe_path); + let proxy_stem = scan.proxy_name.trim_end_matches(".dll").to_string(); + + let proxy_src = Self::get_proxy_dll(&proxy_stem, is_64bit)?; + fs::copy(&proxy_src, exe_dir.join(&scan.proxy_name)) + .map_err(|e| format!("Failed to copy Koaloader proxy DLL: {}", e))?; + + info!("Selected proxy: {} (fallback={})", scan.proxy_name, scan.is_fallback); + Ok(scan) + } + + /// Writes Koaloader.config.json targeting the given executable, next to it. + pub(crate) fn write_koaloader_config(exe_path: &Path) -> Result<(), String> { + let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?; + let exe_name = exe_path.file_name().unwrap_or_default().to_string_lossy().to_string(); + let koa_config = serde_json::json!({ + "logging": false, + "enabled": true, + "auto_load": true, + "targets": [exe_name], + "modules": [] + }); + fs::write( + exe_dir.join("Koaloader.config.json"), + serde_json::to_string_pretty(&koa_config).unwrap(), + ) + .map_err(|e| format!("Failed to write Koaloader config: {}", e)) + } + + /// Removes Koaloader.config.json and any installed proxy DLL from `exe_dir`. + pub(crate) fn remove_proxy_and_config(exe_dir: &Path) -> Result<(), String> { + let koa_config = exe_dir.join("Koaloader.config.json"); + if koa_config.exists() { + fs::remove_file(&koa_config) + .map_err(|e| format!("Failed to remove Koaloader config: {}", e))?; + } + + if let Ok(entries) = fs::read_dir(exe_dir) { + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let name_lower = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + if KOA_VARIANTS.contains(&name_lower.as_str()) { + fs::remove_file(&path).ok(); + info!("Removed proxy DLL: {}", path.display()); + } + } + } + + Ok(()) + } + pub fn get_proxy_dll(proxy_stem: &str, is_64bit: bool) -> Result { let versions = crate::cache::read_versions()?; if versions.koaloader.latest.is_empty() {