Creamlinux Refactor

This commit is contained in:
Novattz
2025-12-22 20:21:06 +01:00
parent 6f4f53f7f5
commit c484c8958c
12 changed files with 2042 additions and 1240 deletions

View File

@@ -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<Vec<crate::dlc_manager::DlcInfoWithState>> {
// 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(())
}

246
src-tauri/src/cache/mod.rs vendored Normal file
View File

@@ -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<UpdateResult, String> {
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<String, crate::installer::Game>,
) -> Result<GameUpdateStats, String> {
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<String>,
pub new_creamlinux_version: Option<String>,
}
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
}
}

292
src-tauri/src/cache/storage.rs vendored Normal file
View File

@@ -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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<CacheVersions, String> {
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<bool, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<Vec<PathBuf>, 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<Vec<PathBuf>, 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)
}

177
src-tauri/src/cache/version.rs vendored Normal file
View File

@@ -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<String>,
pub creamlinux_version: Option<String>,
}
#[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<GameManifest, String> {
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"));
}
}

View File

@@ -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<String> {
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::<Vec<_>>();
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(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -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(())
}

View File

@@ -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<usize>,
}
// 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<String>,
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<InstallationInstructions>,
) {
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<Vec<DlcInfo>, 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<String>>(),
_ => 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<Vec<DlcInfo>, 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::<AppState>();
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<String>>(),
_ => 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(())
}

View File

@@ -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<HashMap<String, Game>>,
dlc_cache: Mutex<HashMap<String, DlcCache>>,
fetch_cancellation: Arc<AtomicBool>,
@@ -49,7 +50,6 @@ fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, 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<AppState>) -> Result<Game, String> {
let games = state.games.lock();
@@ -179,14 +167,12 @@ fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String
.ok_or_else(|| format!("Game with ID {} not found", game_id))
}
// Unified action handler for installation and uninstallation
#[tauri::command]
async fn process_game_action(
game_action: GameAction,
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> Result<Game, String> {
// 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<Vec<DlcInfoWithState>, 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::<Vec<_>>();
// Cache in memory for this session
let state = app_handle.state::<AppState>();
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::<AppState>();
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::<AppState>();
@@ -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::<Vec<_>>();
// Update in-memory
// Update in-memory cache
let state = app_handle.state::<AppState>();
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<Vec<String>, 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<dyn std::error::Error>> {
use log::LevelFilter;
use log4rs::append::file::FileAppender;
@@ -468,30 +441,25 @@ fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
}
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)]
@@ -538,7 +504,6 @@ fn main() {
}
}
// Initialize and manage AppState
let app_handle = app.handle().clone();
let state = AppState {
games: Mutex::new(HashMap::new()),
@@ -547,6 +512,60 @@ fn main() {
};
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::<AppState>();
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!())

View File

@@ -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<String, String> {
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<String, String> {
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"
}
}

View File

@@ -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<String, String>;
// Download the unlocker to the cache directory
async fn download_to_cache() -> Result<String, String>;
// 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;
}

View File

@@ -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<String, String> {
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<String, String> {
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<String> = 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<String> = 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"
}
}