From 2d524de661a8bef01d0bca5d56d357706ead8010 Mon Sep 17 00:00:00 2001 From: Tickbase Date: Thu, 30 Apr 2026 21:00:32 +0200 Subject: [PATCH] koaloader + screamapi #93 --- src-tauri/src/unlockers/koaloader.rs | 289 +++++++++++++++++++++++ src-tauri/src/unlockers/screamapi.rs | 339 +++++++++++++++++++++++++++ 2 files changed, 628 insertions(+) create mode 100644 src-tauri/src/unlockers/koaloader.rs create mode 100644 src-tauri/src/unlockers/screamapi.rs diff --git a/src-tauri/src/unlockers/koaloader.rs b/src-tauri/src/unlockers/koaloader.rs new file mode 100644 index 0000000..c27eca3 --- /dev/null +++ b/src-tauri/src/unlockers/koaloader.rs @@ -0,0 +1,289 @@ +use super::Unlocker; +use async_trait::async_trait; +use log::info; +use reqwest; +use std::fs; +use std::io; +use std::path::Path; +use std::time::Duration; +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 struct Koaloader; + +#[async_trait] +impl Unlocker for Koaloader { + async fn get_latest_version() -> Result { + info!("Fetching latest Koaloader version..."); + + let client = reqwest::Client::new(); + let releases_url = format!( + "https://api.github.com/repos/{}/releases/latest", + KOALOADER_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 Koaloader releases: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to fetch Koaloader 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 Koaloader version: {}", version); + Ok(version) + } + + async fn download_to_cache() -> Result { + let version = Self::get_latest_version().await?; + info!("Downloading Koaloader version {}...", version); + + let client = reqwest::Client::new(); + + let releases_url = format!( + "https://api.github.com/repos/{}/releases/latest", + KOALOADER_REPO + ); + let release_info: serde_json::Value = client + .get(&releases_url) + .header("User-Agent", "CreamLinux") + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Failed to fetch Koaloader release: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse release info: {}", e))?; + + let zip_url = release_info + .get("assets") + .and_then(|a| a.as_array()) + .and_then(|assets| { + assets.iter().find(|asset| { + asset + .get("name") + .and_then(|n| n.as_str()) + .map(|n| n.ends_with(".zip")) + .unwrap_or(false) + }) + }) + .and_then(|asset| asset.get("browser_download_url")) + .and_then(|u| u.as_str()) + .ok_or_else(|| "No zip asset found in Koaloader release".to_string())? + .to_string(); + + let response = client + .get(&zip_url) + .timeout(Duration::from_secs(60)) + .send() + .await + .map_err(|e| format!("Failed to download Koaloader: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to download Koaloader: HTTP {}", + response.status() + )); + } + + let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?; + let zip_path = temp_dir.path().join("koaloader.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))?; + + let version_dir = crate::cache::get_koaloader_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))?; + + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("Failed to access zip entry: {}", e))?; + + let zip_entry = file.name().to_string(); + if zip_entry.ends_with('/') { + continue; + } + + let out_path = version_dir.join(&zip_entry); + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory: {}", e))?; + } + + let mut outfile = fs::File::create(&out_path).map_err(|e| { + format!("Failed to create output file {}: {}", out_path.display(), e) + })?; + io::copy(&mut file, &mut outfile) + .map_err(|e| format!("Failed to extract file: {}", e))?; + } + + info!("Koaloader version {} downloaded to cache successfully", version); + Ok(version) + } + + /// 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)?; + 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))?; + + info!("Koaloader installation complete for: {}", game_path); + Ok(()) + } + + async fn uninstall_from_game(game_path: &str, context: &str) -> Result<(), String> { + let exe_path = Self::resolve_exe(game_path, context)?; + 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()); + } + } + } + + crate::unlockers::ScreamAPI::uninstall_from_game(&exe_dir_str, "koaloader").await?; + + info!("Koaloader uninstallation complete for: {}", game_path); + Ok(()) + } + + fn name() -> &'static str { + "Koaloader" + } +} + +impl Koaloader { + /// Public wrapper for installer/mod.rs to call. + pub fn resolve_exe_pub(game_path: &str, exe_relative: &str) -> Result { + Self::resolve_exe(game_path, exe_relative) + } + + fn resolve_exe(game_path: &str, exe_relative: &str) -> Result { + use walkdir::WalkDir; + + let full = Path::new(game_path).join(exe_relative); + if full.exists() { + return Ok(full); + } + + let exe_name = Path::new(exe_relative) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + for entry in WalkDir::new(game_path) + .max_depth(8) + .into_iter() + .filter_map(Result::ok) + { + if entry.file_name().to_string_lossy() == exe_name { + return Ok(entry.path().to_path_buf()); + } + } + + Err(format!( + "Executable not found: {} (searched in {})", + exe_relative, game_path + )) + } + + pub fn get_proxy_dll(proxy_stem: &str, is_64bit: bool) -> Result { + let versions = crate::cache::read_versions()?; + if versions.koaloader.latest.is_empty() { + return Err("Koaloader is not cached. Please restart the app.".to_string()); + } + + let version_dir = crate::cache::get_koaloader_version_dir(&versions.koaloader.latest)?; + let bitness = if is_64bit { "64" } else { "32" }; + let folder = format!("{}-{}", proxy_stem, bitness); + let dll_path = version_dir.join(&folder).join(format!("{}.dll", proxy_stem)); + + if !dll_path.exists() { + return Err(format!( + "Koaloader proxy DLL not found in cache: {}", + dll_path.display() + )); + } + + Ok(dll_path) + } +} \ No newline at end of file diff --git a/src-tauri/src/unlockers/screamapi.rs b/src-tauri/src/unlockers/screamapi.rs new file mode 100644 index 0000000..2dc70bb --- /dev/null +++ b/src-tauri/src/unlockers/screamapi.rs @@ -0,0 +1,339 @@ +use super::Unlocker; +use async_trait::async_trait; +use log::info; +use reqwest; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tempfile::tempdir; +use walkdir::WalkDir; +use zip::ZipArchive; + +const SCREAMAPI_REPO: &str = "acidicoala/ScreamAPI"; + +pub struct ScreamAPI; + +#[async_trait] +impl Unlocker for ScreamAPI { + async fn get_latest_version() -> Result { + info!("Fetching latest ScreamAPI version..."); + + let client = reqwest::Client::new(); + let releases_url = format!( + "https://api.github.com/repos/{}/releases/latest", + SCREAMAPI_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 ScreamAPI releases: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to fetch ScreamAPI 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 ScreamAPI version: {}", version); + Ok(version) + } + + async fn download_to_cache() -> Result { + let version = Self::get_latest_version().await?; + info!("Downloading ScreamAPI version {}...", version); + + let client = reqwest::Client::new(); + + let releases_url = format!( + "https://api.github.com/repos/{}/releases/latest", + SCREAMAPI_REPO + ); + let release_info: serde_json::Value = client + .get(&releases_url) + .header("User-Agent", "CreamLinux") + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Failed to fetch ScreamAPI release: {}", e))? + .json() + .await + .map_err(|e| format!("Failed to parse release info: {}", e))?; + + let zip_url = release_info + .get("assets") + .and_then(|a| a.as_array()) + .and_then(|assets| { + assets.iter().find(|asset| { + asset + .get("name") + .and_then(|n| n.as_str()) + .map(|n| n.ends_with(".zip")) + .unwrap_or(false) + }) + }) + .and_then(|asset| asset.get("browser_download_url")) + .and_then(|u| u.as_str()) + .ok_or_else(|| "No zip asset found in ScreamAPI release".to_string())? + .to_string(); + + info!("Downloading ScreamAPI from: {}", zip_url); + + let response = client + .get(&zip_url) + .timeout(Duration::from_secs(60)) + .send() + .await + .map_err(|e| format!("Failed to download ScreamAPI: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to download ScreamAPI: HTTP {}", + response.status() + )); + } + + let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?; + let zip_path = temp_dir.path().join("screamapi.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))?; + + let version_dir = crate::cache::get_screamapi_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))?; + + 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(); + let base_name = Path::new(&file_name) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let should_extract = base_name.to_lowercase().ends_with(".dll") + || base_name == "ScreamAPI.config.json"; + + if should_extract { + let output_path = version_dir.join(&base_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!("ScreamAPI version {} downloaded to cache successfully", version); + Ok(version) + } + + /// context = "" -> direct install (replace EOSSDK DLLs) + /// context = "koaloader" -> payload install (drop DLL in exe dir) + async fn install_to_game(game_path: &str, context: &str) -> Result<(), String> { + if context == "koaloader" { + Self::install_as_koaloader_payload(game_path).await + } else { + Self::install_direct(game_path).await + } + } + + async fn uninstall_from_game(game_path: &str, context: &str) -> Result<(), String> { + if context == "koaloader" { + Self::uninstall_as_koaloader_payload(game_path).await + } else { + Self::uninstall_direct(game_path).await + } + } + + fn name() -> &'static str { + "ScreamAPI" + } +} + +impl ScreamAPI { + // Direct install + + async fn install_direct(game_path: &str) -> Result<(), String> { + info!("Installing ScreamAPI (direct) to: {}", game_path); + + let install_path = Path::new(game_path); + let eos_dlls = Self::find_eossdk_dlls(install_path); + + if eos_dlls.is_empty() { + return Err(format!( + "No EOSSDK-Win*-Shipping.dll found in {}", + game_path + )); + } + + info!("Found {} EOSSDK DLL(s)", eos_dlls.len()); + + let versions = crate::cache::read_versions()?; + if versions.screamapi.latest.is_empty() { + return Err("ScreamAPI is not cached. Please restart the app.".to_string()); + } + let scream_dir = crate::cache::get_screamapi_version_dir(&versions.screamapi.latest)?; + + for eos_dll in &eos_dlls { + let filename = eos_dll.file_name().unwrap_or_default().to_string_lossy(); + let is_64bit = filename.to_lowercase().contains("64"); + + let stem = filename.trim_end_matches(".dll"); + let backup = eos_dll.with_file_name(format!("{}_o.dll", stem)); + + if !backup.exists() && eos_dll.exists() { + fs::copy(eos_dll, &backup) + .map_err(|e| format!("Failed to backup {}: {}", filename, e))?; + info!("Backed up {} -> {}", eos_dll.display(), backup.display()); + } + + let scream_dll_name = if is_64bit { "ScreamAPI64.dll" } else { "ScreamAPI32.dll" }; + let src = scream_dir.join(scream_dll_name); + if !src.exists() { + return Err(format!("ScreamAPI DLL not found in cache: {}", src.display())); + } + + fs::copy(&src, eos_dll) + .map_err(|e| format!("Failed to install ScreamAPI DLL: {}", e))?; + info!("Installed {} as {}", scream_dll_name, eos_dll.display()); + } + + let config_dir = eos_dlls[0].parent().ok_or("Failed to get parent of EOS DLL")?; + crate::screamapi_config::write_default_config(config_dir)?; + + info!("ScreamAPI (direct) installation complete for: {}", game_path); + Ok(()) + } + + async fn uninstall_direct(game_path: &str) -> Result<(), String> { + info!("Uninstalling ScreamAPI (direct) from: {}", game_path); + + let install_path = Path::new(game_path); + + for entry in WalkDir::new(install_path) + .max_depth(8) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + let filename = path.file_name().unwrap_or_default().to_string_lossy(); + let lower = filename.to_lowercase(); + + if lower.starts_with("eossdk-win") && lower.ends_with("_o.dll") { + let original_name = filename.trim_end_matches("_o.dll").to_string() + ".dll"; + let original = path.parent().unwrap_or(install_path).join(&original_name); + + fs::copy(path, &original) + .map_err(|e| format!("Failed to restore {}: {}", original_name, e))?; + fs::remove_file(path) + .map_err(|e| format!("Failed to remove backup file: {}", e))?; + info!("Restored {} from backup", original.display()); + } + } + + crate::screamapi_config::delete_config(game_path)?; + info!("ScreamAPI (direct) uninstallation complete for: {}", game_path); + Ok(()) + } + + // Koaloader payload + + async fn install_as_koaloader_payload(exe_dir: &str) -> Result<(), String> { + info!("Installing ScreamAPI as Koaloader payload in: {}", exe_dir); + + let versions = crate::cache::read_versions()?; + if versions.screamapi.latest.is_empty() { + return Err("ScreamAPI is not cached. Please restart the app.".to_string()); + } + let scream_dir = crate::cache::get_screamapi_version_dir(&versions.screamapi.latest)?; + let exe_dir_path = Path::new(exe_dir); + + for dll_name in &["ScreamAPI32.dll", "ScreamAPI64.dll"] { + let src = scream_dir.join(dll_name); + if src.exists() { + let dest = exe_dir_path.join(dll_name); + fs::copy(&src, &dest) + .map_err(|e| format!("Failed to copy {}: {}", dll_name, e))?; + info!("Placed {} in exe dir", dll_name); + } + } + + crate::screamapi_config::write_default_config(exe_dir_path)?; + info!("ScreamAPI (Koaloader payload) install complete"); + Ok(()) + } + + async fn uninstall_as_koaloader_payload(exe_dir: &str) -> Result<(), String> { + info!("Removing ScreamAPI Koaloader payload from: {}", exe_dir); + + let exe_dir_path = Path::new(exe_dir); + for dll_name in &["ScreamAPI32.dll", "ScreamAPI64.dll"] { + let path = exe_dir_path.join(dll_name); + if path.exists() { + fs::remove_file(&path) + .map_err(|e| format!("Failed to remove {}: {}", dll_name, e))?; + info!("Removed {}", dll_name); + } + } + + let cfg = exe_dir_path.join("ScreamAPI.config.json"); + if cfg.exists() { + fs::remove_file(&cfg).ok(); + } + + info!("ScreamAPI (Koaloader payload) uninstall complete"); + Ok(()) + } + + // Helpers + + pub fn find_eossdk_dlls(root: &Path) -> Vec { + let mut found = Vec::new(); + for entry in WalkDir::new(root) + .max_depth(8) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + let lower = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + + if lower.starts_with("eossdk-win") + && lower.ends_with("-shipping.dll") + && !lower.contains("_o") + { + found.push(path.to_path_buf()); + } + } + found + } +} \ No newline at end of file