mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2025-12-05 19:45:36 -05:00
Initial commit
This commit is contained in:
4
src-tauri/.gitignore
vendored
Normal file
4
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
6144
src-tauri/Cargo.lock
generated
Normal file
6144
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
src-tauri/Cargo.toml
Normal file
42
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "DLC Manager for Steam games on Linux"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
bincode = "1.3"
|
||||
regex = "1"
|
||||
xdg = "2"
|
||||
log = "0.4"
|
||||
log4rs = "1.2"
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
zip = "0.6"
|
||||
tempfile = "3.8"
|
||||
walkdir = "2.3"
|
||||
parking_lot = "0.12"
|
||||
tauri = { version = "2.5.0", features = [] }
|
||||
tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2.0.0-rc"
|
||||
tauri-plugin-dialog = "2.0.0-rc"
|
||||
tauri-plugin-fs = "2.0.0-rc"
|
||||
num_cpus = "1.16.0"
|
||||
futures = "0.3.31"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
# DO NOT REMOVE!!
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal file
11
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
176
src-tauri/src/cache.rs
Normal file
176
src-tauri/src/cache.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
// src/cache.rs
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde_json::json;
|
||||
use std::path::{PathBuf};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::time::{SystemTime};
|
||||
use log::{info, warn};
|
||||
use crate::dlc_manager::DlcInfoWithState;
|
||||
|
||||
// Cache entry with timestamp for expiration
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CacheEntry<T> {
|
||||
data: T,
|
||||
timestamp: u64, // Unix timestamp in seconds
|
||||
}
|
||||
|
||||
// Get the cache directory
|
||||
fn get_cache_dir() -> io::Result<PathBuf> {
|
||||
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
|
||||
let cache_dir = xdg_dirs.get_cache_home();
|
||||
|
||||
// Make sure the cache directory exists
|
||||
if !cache_dir.exists() {
|
||||
fs::create_dir_all(&cache_dir)?;
|
||||
}
|
||||
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
// Save data to cache file
|
||||
pub fn save_to_cache<T>(key: &str, data: &T, _ttl_hours: u64) -> io::Result<()>
|
||||
where
|
||||
T: Serialize + ?Sized,
|
||||
{
|
||||
let cache_dir = get_cache_dir()?;
|
||||
let cache_file = cache_dir.join(format!("{}.cache", key));
|
||||
|
||||
// Get current timestamp
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Create a JSON object with timestamp and data directly
|
||||
let json_data = json!({
|
||||
"timestamp": now,
|
||||
"data": data // No clone needed here
|
||||
});
|
||||
|
||||
// Serialize and write to file
|
||||
let serialized = serde_json::to_string(&json_data)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
|
||||
fs::write(cache_file, serialized)?;
|
||||
info!("Saved cache for key: {}", key);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Load data from cache file if it exists and is not expired
|
||||
pub fn load_from_cache<T>(key: &str, ttl_hours: u64) -> Option<T>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let cache_dir = match get_cache_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
warn!("Failed to get cache directory: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let cache_file = cache_dir.join(format!("{}.cache", key));
|
||||
|
||||
// Check if cache file exists
|
||||
if !cache_file.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Read and deserialize
|
||||
let cached_data = match fs::read_to_string(&cache_file) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
warn!("Failed to read cache file {}: {}", cache_file.display(), e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse the JSON
|
||||
let json_value: serde_json::Value = match serde_json::from_str(&cached_data) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse cache file {}: {}", cache_file.display(), e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract timestamp
|
||||
let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) {
|
||||
Some(ts) => ts,
|
||||
None => {
|
||||
warn!("Invalid timestamp in cache file {}", cache_file.display());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Check expiration
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let age_hours = (now - timestamp) / 3600;
|
||||
|
||||
if age_hours > ttl_hours {
|
||||
info!("Cache for key {} is expired ({} hours old)", key, age_hours);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract data
|
||||
let data: T = match serde_json::from_value(json_value["data"].clone()) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse data in cache file {}: {}", cache_file.display(), e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
info!("Using cache for key {} ({} hours old)", key, age_hours);
|
||||
Some(data)
|
||||
}
|
||||
|
||||
// Cache game scanning results
|
||||
pub fn cache_games(games: &[crate::installer::Game]) -> io::Result<()> {
|
||||
save_to_cache("games", games, 24) // Cache games for 24 hours
|
||||
}
|
||||
|
||||
// Load cached game scanning results
|
||||
pub fn load_cached_games() -> Option<Vec<crate::installer::Game>> {
|
||||
load_from_cache("games", 24)
|
||||
}
|
||||
|
||||
// Cache DLC list for a game
|
||||
pub fn cache_dlcs(game_id: &str, dlcs: &[DlcInfoWithState]) -> io::Result<()> {
|
||||
save_to_cache(&format!("dlc_{}", game_id), dlcs, 168) // Cache DLCs for 7 days (168 hours)
|
||||
}
|
||||
|
||||
// Load cached DLC list
|
||||
pub fn load_cached_dlcs(game_id: &str) -> Option<Vec<DlcInfoWithState>> {
|
||||
load_from_cache(&format!("dlc_{}", game_id), 168)
|
||||
}
|
||||
|
||||
// Clear all caches
|
||||
pub fn clear_all_caches() -> io::Result<()> {
|
||||
let cache_dir = get_cache_dir()?;
|
||||
|
||||
for entry in fs::read_dir(cache_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().map_or(false, |ext| ext == "cache") {
|
||||
if let Err(e) = fs::remove_file(&path) {
|
||||
warn!("Failed to remove cache file {}: {}", path.display(), e);
|
||||
} else {
|
||||
info!("Removed cache file: {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("All caches cleared");
|
||||
Ok(())
|
||||
}
|
||||
339
src-tauri/src/dlc_manager.rs
Normal file
339
src-tauri/src/dlc_manager.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
// src/dlc_manager.rs
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use log::{info, error};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tauri::Manager;
|
||||
|
||||
/// More detailed DLC information with enabled state
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct DlcInfoWithState {
|
||||
pub appid: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Parse the cream_api.ini file to extract both enabled and disabled DLCs
|
||||
pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
|
||||
info!("Reading enabled DLCs from {}", game_path);
|
||||
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
if !cream_api_path.exists() {
|
||||
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(&cream_api_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
|
||||
};
|
||||
|
||||
// Extract DLCs - they are in the [dlc] section with format "appid = name"
|
||||
let mut in_dlc_section = false;
|
||||
let mut enabled_dlcs = Vec::new();
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Check if we're in the DLC section
|
||||
if trimmed == "[dlc]" {
|
||||
in_dlc_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the DLC section (another section begins)
|
||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
in_dlc_section = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty lines and non-DLC comments
|
||||
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
|
||||
// Extract the DLC app ID
|
||||
if let Some(appid) = trimmed.split('=').next() {
|
||||
let appid_clean = appid.trim();
|
||||
// Check if the line is commented out (indicating a disabled DLC)
|
||||
if !appid_clean.starts_with("#") {
|
||||
enabled_dlcs.push(appid_clean.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} enabled DLCs", enabled_dlcs.len());
|
||||
Ok(enabled_dlcs)
|
||||
}
|
||||
|
||||
/// Get all DLCs (both enabled and disabled) from cream_api.ini
|
||||
pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
info!("Reading all DLCs from {}", game_path);
|
||||
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
if !cream_api_path.exists() {
|
||||
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(&cream_api_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
|
||||
};
|
||||
|
||||
// Extract DLCs - both enabled and disabled
|
||||
let mut in_dlc_section = false;
|
||||
let mut all_dlcs = Vec::new();
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Check if we're in the DLC section
|
||||
if trimmed == "[dlc]" {
|
||||
in_dlc_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the DLC section (another section begins)
|
||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
in_dlc_section = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process DLC entries (both enabled and commented/disabled)
|
||||
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
|
||||
let is_commented = trimmed.starts_with("#");
|
||||
let actual_line = if is_commented {
|
||||
trimmed.trim_start_matches('#').trim()
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let appid = parts[0].trim();
|
||||
let name = parts[1].trim();
|
||||
|
||||
all_dlcs.push(DlcInfoWithState {
|
||||
appid: appid.to_string(),
|
||||
name: name.to_string().trim_matches('"').to_string(),
|
||||
enabled: !is_commented,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} total DLCs ({} enabled, {} disabled)",
|
||||
all_dlcs.len(),
|
||||
all_dlcs.iter().filter(|d| d.enabled).count(),
|
||||
all_dlcs.iter().filter(|d| !d.enabled).count());
|
||||
|
||||
Ok(all_dlcs)
|
||||
}
|
||||
|
||||
/// Update the cream_api.ini file with the user's DLC selections
|
||||
pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> {
|
||||
info!("Updating DLC configuration for {}", game_path);
|
||||
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
if !cream_api_path.exists() {
|
||||
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
|
||||
}
|
||||
|
||||
// Read the current file contents
|
||||
let current_contents = match fs::read_to_string(&cream_api_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
|
||||
};
|
||||
|
||||
// Create a mapping of DLC appid to its state for easy lookup
|
||||
let dlc_states: HashMap<String, (bool, String)> = dlcs.iter()
|
||||
.map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone())))
|
||||
.collect();
|
||||
|
||||
// Keep track of processed DLCs to avoid duplicates
|
||||
let mut processed_dlcs = HashSet::new();
|
||||
|
||||
// Process the file line by line to retain most of the original structure
|
||||
let mut new_contents = Vec::new();
|
||||
let mut in_dlc_section = false;
|
||||
|
||||
for line in current_contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Add section markers directly
|
||||
if trimmed == "[dlc]" {
|
||||
in_dlc_section = true;
|
||||
new_contents.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the DLC section (another section begins)
|
||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
in_dlc_section = false;
|
||||
|
||||
// Before leaving the DLC section, add any DLCs that weren't processed yet
|
||||
for (appid, (enabled, name)) in &dlc_states {
|
||||
if !processed_dlcs.contains(appid) {
|
||||
if *enabled {
|
||||
new_contents.push(format!("{} = {}", appid, name));
|
||||
} else {
|
||||
new_contents.push(format!("# {} = {}", appid, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the section marker
|
||||
new_contents.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_dlc_section && !trimmed.is_empty() {
|
||||
let is_comment_line = trimmed.starts_with(';');
|
||||
|
||||
// If it's a regular comment line (not a DLC), keep it as is
|
||||
if is_comment_line {
|
||||
new_contents.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a commented-out DLC line or a regular DLC line
|
||||
let is_commented = trimmed.starts_with("#");
|
||||
let actual_line = if is_commented {
|
||||
trimmed.trim_start_matches('#').trim()
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
|
||||
// Extract appid and name
|
||||
let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let appid = parts[0].trim();
|
||||
let name = parts[1].trim();
|
||||
|
||||
// Check if this DLC exists in our updated list
|
||||
if let Some((enabled, _)) = dlc_states.get(appid) {
|
||||
// Add the DLC with its updated state
|
||||
if *enabled {
|
||||
new_contents.push(format!("{} = {}", appid, name));
|
||||
} else {
|
||||
new_contents.push(format!("# {} = {}", appid, name));
|
||||
}
|
||||
processed_dlcs.insert(appid.to_string());
|
||||
} else {
|
||||
// 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
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
} else if !in_dlc_section || trimmed.is_empty() {
|
||||
// Not a DLC line or empty line - keep as is
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// If we never left the DLC section, make sure we add any unprocessed DLCs
|
||||
if in_dlc_section {
|
||||
for (appid, (enabled, name)) in &dlc_states {
|
||||
if !processed_dlcs.contains(appid) {
|
||||
if *enabled {
|
||||
new_contents.push(format!("{} = {}", appid, name));
|
||||
} else {
|
||||
new_contents.push(format!("# {} = {}", appid, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write the updated file
|
||||
match fs::write(&cream_api_path, new_contents.join("\n")) {
|
||||
Ok(_) => {
|
||||
info!("Successfully updated DLC configuration at {}", cream_api_path.display());
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to write updated cream_api.ini: {}", e);
|
||||
Err(format!("Failed to write updated cream_api.ini: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
app_handle: tauri::AppHandle,
|
||||
selected_dlcs: Vec<DlcInfoWithState>
|
||||
) -> Result<(), String> {
|
||||
use crate::AppState;
|
||||
|
||||
// Count enabled DLCs for logging
|
||||
let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count();
|
||||
info!("Starting installation of CreamLinux with {} selected DLCs", enabled_dlc_count);
|
||||
|
||||
// Get the game from state
|
||||
let game = {
|
||||
let state = app_handle.state::<AppState>();
|
||||
let games = state.games.lock();
|
||||
match games.get(&game_id) {
|
||||
Some(g) => g.clone(),
|
||||
None => return Err(format!("Game with ID {} not found", game_id))
|
||||
}
|
||||
};
|
||||
|
||||
info!("Installing CreamLinux for game: {} ({})", 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()
|
||||
.filter(|dlc| dlc.enabled)
|
||||
.map(|dlc| crate::installer::DlcInfo {
|
||||
appid: dlc.appid.clone(),
|
||||
name: dlc.name.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let game_title = game.title.clone();
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
997
src-tauri/src/installer.rs
Normal file
997
src-tauri/src/installer.rs
Normal file
@@ -0,0 +1,997 @@
|
||||
// src/installer.rs
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use log::{info, error, warn};
|
||||
use reqwest;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tempfile::tempdir;
|
||||
use zip::ZipArchive;
|
||||
use std::time::Duration;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::Ordering;
|
||||
use crate::AppState;
|
||||
use tauri::Manager;
|
||||
|
||||
// Constants for API endpoints and downloads
|
||||
const CREAMLINUX_RELEASE_URL: &str = "https://github.com/anticitizn/creamlinux/releases/latest/download/creamlinux.zip";
|
||||
const SMOKEAPI_REPO: &str = "acidicoala/SmokeAPI";
|
||||
|
||||
// Type of installer
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum InstallerType {
|
||||
Cream,
|
||||
Smoke
|
||||
}
|
||||
|
||||
// Action to perform
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum InstallerAction {
|
||||
Install,
|
||||
Uninstall
|
||||
}
|
||||
|
||||
// Error type combining all possible errors
|
||||
#[derive(Debug)]
|
||||
pub enum InstallerError {
|
||||
IoError(io::Error),
|
||||
ReqwestError(reqwest::Error),
|
||||
ZipError(zip::result::ZipError),
|
||||
InstallationError(String),
|
||||
}
|
||||
|
||||
impl From<io::Error> for InstallerError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
InstallerError::IoError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for InstallerError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
InstallerError::ReqwestError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<zip::result::ZipError> for InstallerError {
|
||||
fn from(err: zip::result::ZipError) -> Self {
|
||||
InstallerError::ZipError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InstallerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
InstallerError::IoError(e) => write!(f, "IO error: {}", e),
|
||||
InstallerError::ReqwestError(e) => write!(f, "Network error: {}", e),
|
||||
InstallerError::ZipError(e) => write!(f, "Zip extraction error: {}", e),
|
||||
InstallerError::InstallationError(e) => write!(f, "Installation error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InstallerError {}
|
||||
|
||||
/// DLC Information structure
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct DlcInfo {
|
||||
pub appid: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Struct to hold installation instructions for the frontend
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct InstallationInstructions {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub command: String,
|
||||
pub game_title: String,
|
||||
pub dlc_count: Option<usize>,
|
||||
}
|
||||
|
||||
/// Game information structure from searcher module
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Game {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
pub native: bool,
|
||||
pub api_files: Vec<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) => {
|
||||
// We only allow CreamLinux for native games
|
||||
if !game.native {
|
||||
return Err("CreamLinux can only be installed on native Linux games".to_string());
|
||||
}
|
||||
|
||||
info!("Installing CreamLinux for game: {}", game.title);
|
||||
let game_title = game.title.clone();
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing CreamLinux for {}", game_title),
|
||||
"Fetching DLC list...",
|
||||
10.0,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
);
|
||||
|
||||
// Fetch DLC list
|
||||
let dlcs = match fetch_dlc_details(&game.id).await {
|
||||
Ok(dlcs) => dlcs,
|
||||
Err(e) => {
|
||||
error!("Failed to fetch DLC details: {}", e);
|
||||
return Err(format!("Failed to fetch DLC details: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let dlc_count = dlcs.len();
|
||||
info!("Found {} DLCs for {}", dlc_count, game_title);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing CreamLinux for {}", game_title),
|
||||
"Downloading CreamLinux...",
|
||||
30.0,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
);
|
||||
|
||||
// Install CreamLinux
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let game_title_clone = game_title.clone();
|
||||
|
||||
match install_creamlinux(&game.path, &game.id, dlcs, move |progress, message| {
|
||||
// Emit progress updates during installation
|
||||
emit_progress(
|
||||
&app_handle_clone,
|
||||
&format!("Installing CreamLinux for {}", game_title_clone),
|
||||
message,
|
||||
30.0 + (progress * 60.0), // Scale progress from 30% to 90%
|
||||
false,
|
||||
false,
|
||||
None
|
||||
);
|
||||
}).await {
|
||||
Ok(_) => {
|
||||
// Emit completion with instructions
|
||||
let instructions = InstallationInstructions {
|
||||
type_: "cream_install".to_string(),
|
||||
command: "sh ./cream.sh %command%".to_string(),
|
||||
game_title: game_title.clone(),
|
||||
dlc_count: Some(dlc_count)
|
||||
};
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Completed: {}", game_title),
|
||||
"CreamLinux has been installed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
true,
|
||||
Some(instructions)
|
||||
);
|
||||
|
||||
info!("CreamLinux installation completed for: {}", game_title);
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to install CreamLinux: {}", e);
|
||||
Err(format!("Failed to install CreamLinux: {}", e))
|
||||
}
|
||||
}
|
||||
},
|
||||
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
||||
// Ensure this is a native game
|
||||
if !game.native {
|
||||
return Err("CreamLinux can only be uninstalled from native Linux games".to_string());
|
||||
}
|
||||
|
||||
let game_title = game.title.clone();
|
||||
info!("Uninstalling CreamLinux from game: {}", game_title);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstalling CreamLinux from {}", game_title),
|
||||
"Removing CreamLinux files...",
|
||||
30.0,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
);
|
||||
|
||||
// Uninstall CreamLinux
|
||||
match uninstall_creamlinux(&game.path) {
|
||||
Ok(_) => {
|
||||
// Emit completion with instructions
|
||||
let instructions = InstallationInstructions {
|
||||
type_: "cream_uninstall".to_string(),
|
||||
command: "sh ./cream.sh %command%".to_string(),
|
||||
game_title: game_title.clone(),
|
||||
dlc_count: None
|
||||
};
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstallation Completed: {}", game_title),
|
||||
"CreamLinux has been uninstalled successfully!",
|
||||
100.0,
|
||||
true,
|
||||
true,
|
||||
Some(instructions)
|
||||
);
|
||||
|
||||
info!("CreamLinux uninstallation completed for: {}", game_title);
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to uninstall CreamLinux: {}", e);
|
||||
Err(format!("Failed to uninstall CreamLinux: {}", e))
|
||||
}
|
||||
}
|
||||
},
|
||||
(InstallerType::Smoke, InstallerAction::Install) => {
|
||||
// We only allow SmokeAPI for Proton/Windows games
|
||||
if game.native {
|
||||
return Err("SmokeAPI can only be installed on Proton/Windows games".to_string());
|
||||
}
|
||||
|
||||
// Check if we have any Steam API DLLs to patch
|
||||
if game.api_files.is_empty() {
|
||||
return Err("No Steam API DLLs found to patch. SmokeAPI cannot be installed.".to_string());
|
||||
}
|
||||
|
||||
let game_title = game.title.clone();
|
||||
info!("Installing SmokeAPI for game: {}", game_title);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing SmokeAPI for {}", game_title),
|
||||
"Fetching SmokeAPI release information...",
|
||||
10.0,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
);
|
||||
|
||||
// Create clones for the closure
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let game_title_clone = game_title.clone();
|
||||
let api_files = game.api_files.clone();
|
||||
|
||||
// Call the SmokeAPI installation with progress updates
|
||||
match install_smokeapi(&game.path, &api_files, move |progress, message| {
|
||||
// Emit progress updates during installation
|
||||
emit_progress(
|
||||
&app_handle_clone,
|
||||
&format!("Installing SmokeAPI for {}", game_title_clone),
|
||||
message,
|
||||
10.0 + (progress * 90.0), // Scale progress from 10% to 100%
|
||||
false,
|
||||
false,
|
||||
None
|
||||
);
|
||||
}).await {
|
||||
Ok(_) => {
|
||||
// Emit completion with instructions
|
||||
let instructions = InstallationInstructions {
|
||||
type_: "smoke_install".to_string(),
|
||||
command: "No additional steps needed. SmokeAPI will work automatically.".to_string(),
|
||||
game_title: game_title.clone(),
|
||||
dlc_count: Some(game.api_files.len())
|
||||
};
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Completed: {}", game_title),
|
||||
"SmokeAPI has been installed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
true,
|
||||
Some(instructions)
|
||||
);
|
||||
|
||||
info!("SmokeAPI installation completed for: {}", game_title);
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to install SmokeAPI: {}", e);
|
||||
Err(format!("Failed to install SmokeAPI: {}", e))
|
||||
}
|
||||
}
|
||||
},
|
||||
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
||||
// Ensure this is a non-native game
|
||||
if game.native {
|
||||
return Err("SmokeAPI can only be uninstalled from Proton/Windows games".to_string());
|
||||
}
|
||||
|
||||
let game_title = game.title.clone();
|
||||
info!("Uninstalling SmokeAPI from game: {}", game_title);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstalling SmokeAPI from {}", game_title),
|
||||
"Restoring original files...",
|
||||
30.0,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
);
|
||||
|
||||
// Uninstall SmokeAPI
|
||||
match uninstall_smokeapi(&game.path, &game.api_files) {
|
||||
Ok(_) => {
|
||||
// Emit completion with instructions
|
||||
let instructions = InstallationInstructions {
|
||||
type_: "smoke_uninstall".to_string(),
|
||||
command: "Original Steam API files have been restored.".to_string(),
|
||||
game_title: game_title.clone(),
|
||||
dlc_count: None
|
||||
};
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstallation Completed: {}", game_title),
|
||||
"SmokeAPI has been uninstalled successfully!",
|
||||
100.0,
|
||||
true,
|
||||
true,
|
||||
Some(instructions)
|
||||
);
|
||||
|
||||
info!("SmokeAPI uninstallation completed for: {}", game_title);
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to uninstall SmokeAPI: {}", e);
|
||||
Err(format!("Failed to uninstall SmokeAPI: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// CreamLinux specific functions
|
||||
//
|
||||
|
||||
/// Install CreamLinux for a game
|
||||
async fn install_creamlinux<F>(
|
||||
game_path: &str,
|
||||
app_id: &str,
|
||||
dlcs: Vec<DlcInfo>,
|
||||
progress_callback: F
|
||||
) -> Result<(), InstallerError>
|
||||
where
|
||||
F: Fn(f32, &str) + Send + 'static
|
||||
{
|
||||
// Progress update
|
||||
progress_callback(0.1, "Preparing to download CreamLinux...");
|
||||
|
||||
// Download CreamLinux zip
|
||||
let client = reqwest::Client::new();
|
||||
progress_callback(0.2, "Downloading CreamLinux...");
|
||||
|
||||
let response = client.get(CREAMLINUX_RELEASE_URL)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(InstallerError::InstallationError(
|
||||
format!("Failed to download CreamLinux: HTTP {}", response.status())
|
||||
));
|
||||
}
|
||||
|
||||
// Save to temporary file
|
||||
progress_callback(0.4, "Saving downloaded files...");
|
||||
let temp_dir = tempdir()?;
|
||||
let zip_path = temp_dir.path().join("creamlinux.zip");
|
||||
let content = response.bytes().await?;
|
||||
fs::write(&zip_path, &content)?;
|
||||
|
||||
// Extract the zip
|
||||
progress_callback(0.5, "Extracting CreamLinux files...");
|
||||
let file = fs::File::open(&zip_path)?;
|
||||
let mut archive = ZipArchive::new(file)?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let outpath = Path::new(game_path).join(file.name());
|
||||
|
||||
if file.name().ends_with('/') {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
if !p.exists() {
|
||||
fs::create_dir_all(p)?;
|
||||
}
|
||||
}
|
||||
let mut outfile = fs::File::create(&outpath)?;
|
||||
io::copy(&mut file, &mut outfile)?;
|
||||
}
|
||||
|
||||
// Set executable permissions for cream.sh
|
||||
if file.name() == "cream.sh" {
|
||||
progress_callback(0.6, "Setting executable permissions...");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&outpath)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&outpath, perms)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create cream_api.ini with DLC info
|
||||
progress_callback(0.8, "Creating configuration file...");
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
let mut config = String::new();
|
||||
|
||||
config.push_str(&format!("APPID = {}\n[config]\n", app_id));
|
||||
config.push_str("issubscribedapp_on_false_use_real = true\n");
|
||||
config.push_str("[methods]\n");
|
||||
config.push_str("disable_steamapps_issubscribedapp = false\n");
|
||||
config.push_str("[dlc]\n");
|
||||
|
||||
for dlc in dlcs {
|
||||
config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name));
|
||||
}
|
||||
|
||||
fs::write(cream_api_path, config)?;
|
||||
progress_callback(1.0, "Installation completed successfully!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install CreamLinux for a game with pre-fetched DLC list
|
||||
/// This avoids the redundant network calls to Steam API
|
||||
pub async fn install_creamlinux_with_dlcs<F>(
|
||||
game_path: &str,
|
||||
app_id: &str,
|
||||
dlcs: Vec<DlcInfo>,
|
||||
progress_callback: F
|
||||
) -> Result<(), InstallerError>
|
||||
where
|
||||
F: Fn(f32, &str) + Send + 'static
|
||||
{
|
||||
// Progress update
|
||||
progress_callback(0.1, "Preparing to download CreamLinux...");
|
||||
|
||||
// Download CreamLinux zip
|
||||
let client = reqwest::Client::new();
|
||||
progress_callback(0.2, "Downloading CreamLinux...");
|
||||
|
||||
let response = client.get(CREAMLINUX_RELEASE_URL)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(InstallerError::InstallationError(
|
||||
format!("Failed to download CreamLinux: HTTP {}", response.status())
|
||||
));
|
||||
}
|
||||
|
||||
// Save to temporary file
|
||||
progress_callback(0.4, "Saving downloaded files...");
|
||||
let temp_dir = tempdir()?;
|
||||
let zip_path = temp_dir.path().join("creamlinux.zip");
|
||||
let content = response.bytes().await?;
|
||||
fs::write(&zip_path, &content)?;
|
||||
|
||||
// Extract the zip
|
||||
progress_callback(0.5, "Extracting CreamLinux files...");
|
||||
let file = fs::File::open(&zip_path)?;
|
||||
let mut archive = ZipArchive::new(file)?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let outpath = Path::new(game_path).join(file.name());
|
||||
|
||||
if file.name().ends_with('/') {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
if !p.exists() {
|
||||
fs::create_dir_all(p)?;
|
||||
}
|
||||
}
|
||||
let mut outfile = fs::File::create(&outpath)?;
|
||||
io::copy(&mut file, &mut outfile)?;
|
||||
}
|
||||
|
||||
// Set executable permissions for cream.sh
|
||||
if file.name() == "cream.sh" {
|
||||
progress_callback(0.6, "Setting executable permissions...");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&outpath)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&outpath, perms)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create cream_api.ini with DLC info - using the provided DLCs directly
|
||||
progress_callback(0.8, "Creating configuration file...");
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
let mut config = String::new();
|
||||
|
||||
config.push_str(&format!("APPID = {}\n[config]\n", app_id));
|
||||
config.push_str("issubscribedapp_on_false_use_real = true\n");
|
||||
config.push_str("[methods]\n");
|
||||
config.push_str("disable_steamapps_issubscribedapp = false\n");
|
||||
config.push_str("[dlc]\n");
|
||||
|
||||
for dlc in dlcs {
|
||||
config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name));
|
||||
}
|
||||
|
||||
fs::write(cream_api_path, config)?;
|
||||
progress_callback(1.0, "Installation completed successfully!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall CreamLinux from a game
|
||||
fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> {
|
||||
info!("Uninstalling CreamLinux from: {}", game_path);
|
||||
|
||||
// Files to remove during uninstallation
|
||||
let files_to_remove = [
|
||||
"cream.sh",
|
||||
"cream_api.ini",
|
||||
"cream_api.so",
|
||||
"lib32Creamlinux.so",
|
||||
"lib64Creamlinux.so"
|
||||
];
|
||||
|
||||
for file in &files_to_remove {
|
||||
let file_path = Path::new(game_path).join(file);
|
||||
if file_path.exists() {
|
||||
match fs::remove_file(&file_path) {
|
||||
Ok(_) => info!("Removed file: {}", file_path.display()),
|
||||
Err(e) => {
|
||||
error!("Failed to remove {}: {}", file_path.display(), e);
|
||||
// Continue with other files even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("CreamLinux uninstallation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch DLC details from Steam API
|
||||
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerError> {
|
||||
let client = reqwest::Client::new();
|
||||
let base_url = format!("https://store.steampowered.com/api/appdetails?appids={}", app_id);
|
||||
|
||||
let response = client.get(&base_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(InstallerError::InstallationError(
|
||||
format!("Failed to fetch game details: HTTP {}", response.status())
|
||||
));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await?;
|
||||
let dlc_ids = match data.get(app_id)
|
||||
.and_then(|app| app.get("data"))
|
||||
.and_then(|data| data.get("dlc"))
|
||||
{
|
||||
Some(dlc_array) => {
|
||||
match dlc_array.as_array() {
|
||||
Some(array) => array.iter()
|
||||
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
|
||||
.collect::<Vec<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?;
|
||||
|
||||
if dlc_response.status().is_success() {
|
||||
let dlc_data: serde_json::Value = dlc_response.json().await?;
|
||||
|
||||
let dlc_name = match dlc_data.get(&dlc_id)
|
||||
.and_then(|app| app.get("data"))
|
||||
.and_then(|data| data.get("name"))
|
||||
{
|
||||
Some(name) => {
|
||||
match name.as_str() {
|
||||
Some(s) => s.to_string(),
|
||||
_ => "Unknown DLC".to_string(),
|
||||
}
|
||||
},
|
||||
_ => "Unknown DLC".to_string(),
|
||||
};
|
||||
|
||||
info!("Found DLC: {} ({})", dlc_name, dlc_id);
|
||||
dlc_details.push(DlcInfo {
|
||||
appid: dlc_id,
|
||||
name: dlc_name,
|
||||
});
|
||||
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
// If rate limited, wait longer
|
||||
error!("Rate limited by Steam API, waiting 10 seconds");
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
info!("Successfully retrieved details for {} DLCs", dlc_details.len());
|
||||
Ok(dlc_details)
|
||||
}
|
||||
|
||||
/// Fetch DLC details from Steam API with progress updates
|
||||
pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::AppHandle) -> Result<Vec<DlcInfo>, InstallerError> {
|
||||
info!("Starting DLC details fetch with progress for game ID: {}", app_id);
|
||||
|
||||
// Get a reference to a cancellation flag from app state
|
||||
let state = app_handle.state::<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?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_msg = format!("Failed to fetch game details: HTTP {}", response.status());
|
||||
error!("{}", error_msg);
|
||||
return Err(InstallerError::InstallationError(error_msg));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await?;
|
||||
let dlc_ids = match data.get(app_id)
|
||||
.and_then(|app| app.get("data"))
|
||||
.and_then(|data| data.get("dlc"))
|
||||
{
|
||||
Some(dlc_array) => {
|
||||
match dlc_array.as_array() {
|
||||
Some(array) => array.iter()
|
||||
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
|
||||
.collect::<Vec<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(InstallerError::InstallationError("Operation cancelled by user".to_string()));
|
||||
}
|
||||
let progress_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0;
|
||||
let progress_rounded = progress_percent as u32;
|
||||
let remaining_dlcs = total_dlcs - index;
|
||||
|
||||
// Estimate time remaining (rough calculation - 300ms per DLC)
|
||||
let est_time_left = if remaining_dlcs > 0 {
|
||||
let seconds = (remaining_dlcs as f32 * 0.3).ceil() as u32;
|
||||
if seconds < 60 {
|
||||
format!("~{} seconds", seconds)
|
||||
} else {
|
||||
format!("~{} minute(s)", (seconds as f32 / 60.0).ceil() as u32)
|
||||
}
|
||||
} else {
|
||||
"almost done".to_string()
|
||||
};
|
||||
|
||||
info!("Processing DLC {}/{} - Progress: {}%", index + 1, total_dlcs, progress_rounded);
|
||||
emit_dlc_progress(
|
||||
app_handle,
|
||||
&format!("Processing DLC {}/{}", index + 1, total_dlcs),
|
||||
progress_rounded,
|
||||
Some(&est_time_left)
|
||||
);
|
||||
|
||||
let dlc_url = format!("https://store.steampowered.com/api/appdetails?appids={}", dlc_id);
|
||||
|
||||
// Add a small delay to avoid rate limiting
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let dlc_response = client.get(&dlc_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if dlc_response.status().is_success() {
|
||||
let dlc_data: serde_json::Value = dlc_response.json().await?;
|
||||
|
||||
let dlc_name = match dlc_data.get(&dlc_id)
|
||||
.and_then(|app| app.get("data"))
|
||||
.and_then(|data| data.get("name"))
|
||||
{
|
||||
Some(name) => {
|
||||
match name.as_str() {
|
||||
Some(s) => s.to_string(),
|
||||
_ => "Unknown DLC".to_string(),
|
||||
}
|
||||
},
|
||||
_ => "Unknown DLC".to_string(),
|
||||
};
|
||||
|
||||
info!("Found DLC: {} ({})", dlc_name, dlc_id);
|
||||
let dlc_info = DlcInfo {
|
||||
appid: dlc_id.clone(),
|
||||
name: dlc_name,
|
||||
};
|
||||
|
||||
// Emit each DLC as we find it
|
||||
if let Ok(json) = serde_json::to_string(&dlc_info) {
|
||||
if let Err(e) = app_handle.emit("dlc-found", json) {
|
||||
warn!("Failed to emit dlc-found event: {}", e);
|
||||
} else {
|
||||
info!("Emitted dlc-found event for DLC: {}", dlc_id);
|
||||
}
|
||||
}
|
||||
|
||||
dlc_details.push(dlc_info);
|
||||
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
// If rate limited, wait longer
|
||||
error!("Rate limited by Steam API, waiting 10 seconds");
|
||||
emit_dlc_progress(app_handle, "Rate limited by Steam. Waiting...", progress_rounded, None);
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress update
|
||||
info!("Completed DLC fetch. Found {} DLCs in total", dlc_details.len());
|
||||
emit_dlc_progress(app_handle, &format!("Completed! Found {} DLCs", dlc_details.len()), 100, None);
|
||||
info!("Emitted final DLC progress: 100%");
|
||||
|
||||
Ok(dlc_details)
|
||||
}
|
||||
|
||||
/// Emit DLC progress updates to the frontend
|
||||
fn emit_dlc_progress(
|
||||
app_handle: &tauri::AppHandle,
|
||||
message: &str,
|
||||
progress: u32,
|
||||
time_left: Option<&str>
|
||||
) {
|
||||
let mut payload = json!({
|
||||
"message": message,
|
||||
"progress": progress
|
||||
});
|
||||
|
||||
if let Some(time) = time_left {
|
||||
payload["timeLeft"] = json!(time);
|
||||
}
|
||||
|
||||
if let Err(e) = app_handle.emit("dlc-progress", payload) {
|
||||
warn!("Failed to emit dlc-progress event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// SmokeAPI specific functions
|
||||
//
|
||||
|
||||
/// Install SmokeAPI for a game
|
||||
async fn install_smokeapi<F>(
|
||||
game_path: &str,
|
||||
api_files: &[String],
|
||||
progress_callback: F
|
||||
) -> Result<(), InstallerError>
|
||||
where
|
||||
F: Fn(f32, &str) + Send + 'static
|
||||
{
|
||||
// 1. Get the latest SmokeAPI release
|
||||
progress_callback(0.1, "Fetching latest SmokeAPI release...");
|
||||
let client = reqwest::Client::new();
|
||||
let releases_url = format!("https://api.github.com/repos/{}/releases/latest", SMOKEAPI_REPO);
|
||||
|
||||
let response = client.get(&releases_url)
|
||||
.header("User-Agent", "CreamLinux")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(InstallerError::InstallationError(
|
||||
format!("Failed to fetch SmokeAPI releases: HTTP {}", response.status())
|
||||
));
|
||||
}
|
||||
|
||||
let release_info: serde_json::Value = response.json().await?;
|
||||
let latest_version = match release_info.get("tag_name") {
|
||||
Some(tag) => tag.as_str().unwrap_or("latest"),
|
||||
_ => "latest",
|
||||
};
|
||||
|
||||
info!("Latest SmokeAPI version: {}", latest_version);
|
||||
|
||||
// 2. Construct download URL
|
||||
let zip_url = format!(
|
||||
"https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip",
|
||||
SMOKEAPI_REPO, latest_version, latest_version
|
||||
);
|
||||
|
||||
// 3. Download the zip
|
||||
progress_callback(0.3, "Downloading SmokeAPI...");
|
||||
let response = client.get(&zip_url)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(InstallerError::InstallationError(
|
||||
format!("Failed to download SmokeAPI: HTTP {}", response.status())
|
||||
));
|
||||
}
|
||||
|
||||
// 4. Save to temporary file
|
||||
progress_callback(0.5, "Saving downloaded files...");
|
||||
let temp_dir = tempdir()?;
|
||||
let zip_path = temp_dir.path().join("smokeapi.zip");
|
||||
let content = response.bytes().await?;
|
||||
fs::write(&zip_path, &content)?;
|
||||
|
||||
// 5. Extract and install for each API file
|
||||
progress_callback(0.6, "Extracting SmokeAPI files...");
|
||||
let file = fs::File::open(&zip_path)?;
|
||||
let mut archive = ZipArchive::new(file)?;
|
||||
|
||||
for (i, api_file) in api_files.iter().enumerate() {
|
||||
let progress = 0.6 + (i as f32 / api_files.len() as f32) * 0.3;
|
||||
progress_callback(progress, &format!("Installing SmokeAPI for {}", api_file));
|
||||
|
||||
let api_dir = Path::new(game_path).join(Path::new(api_file).parent().unwrap_or_else(|| Path::new("")));
|
||||
let api_name = Path::new(api_file).file_name().unwrap_or_default();
|
||||
|
||||
// Backup original file
|
||||
let original_path = api_dir.join(api_name);
|
||||
let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll"));
|
||||
|
||||
info!("Processing: {}", original_path.display());
|
||||
info!("Backup path: {}", backup_path.display());
|
||||
|
||||
// Only backup if not already backed up
|
||||
if !backup_path.exists() && original_path.exists() {
|
||||
fs::copy(&original_path, &backup_path)?;
|
||||
info!("Created backup: {}", backup_path.display());
|
||||
}
|
||||
|
||||
// Extract the appropriate DLL directly to the game directory
|
||||
if let Ok(mut file) = archive.by_name(&api_name.to_string_lossy()) {
|
||||
let mut outfile = fs::File::create(&original_path)?;
|
||||
io::copy(&mut file, &mut outfile)?;
|
||||
info!("Installed SmokeAPI as: {}", original_path.display());
|
||||
} else {
|
||||
return Err(InstallerError::InstallationError(
|
||||
format!("Could not find {} in the SmokeAPI zip file", api_name.to_string_lossy())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
progress_callback(1.0, "SmokeAPI installation completed!");
|
||||
info!("SmokeAPI installation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall SmokeAPI from a game
|
||||
fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), InstallerError> {
|
||||
info!("Uninstalling SmokeAPI from: {}", game_path);
|
||||
|
||||
for api_file in api_files {
|
||||
let api_path = Path::new(game_path).join(api_file);
|
||||
let api_dir = api_path.parent().unwrap_or_else(|| Path::new(game_path));
|
||||
let api_name = api_path.file_name().unwrap_or_default();
|
||||
|
||||
let original_path = api_dir.join(api_name);
|
||||
let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll"));
|
||||
|
||||
info!("Processing: {}", original_path.display());
|
||||
info!("Backup path: {}", backup_path.display());
|
||||
|
||||
if backup_path.exists() {
|
||||
// Remove the SmokeAPI version
|
||||
if original_path.exists() {
|
||||
match fs::remove_file(&original_path) {
|
||||
Ok(_) => info!("Removed SmokeAPI file: {}", original_path.display()),
|
||||
Err(e) => error!("Failed to remove SmokeAPI file: {}, error: {}", original_path.display(), e)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the original file
|
||||
match fs::rename(&backup_path, &original_path) {
|
||||
Ok(_) => info!("Restored original file: {}", original_path.display()),
|
||||
Err(e) => {
|
||||
error!("Failed to restore original file: {}, error: {}", original_path.display(), e);
|
||||
// Try to copy instead if rename fails
|
||||
if let Err(copy_err) = fs::copy(&backup_path, &original_path).and_then(|_| fs::remove_file(&backup_path)) {
|
||||
error!("Failed to copy backup file: {}", copy_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("No backup found for: {}", api_file);
|
||||
}
|
||||
}
|
||||
|
||||
info!("SmokeAPI uninstallation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
487
src-tauri/src/main.rs
Normal file
487
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,487 @@
|
||||
// src/main.rs
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
mod searcher;
|
||||
mod installer;
|
||||
mod dlc_manager;
|
||||
mod cache; // Keep the module for now, but we won't use its functionality
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::HashMap;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::Duration;
|
||||
use tauri::State;
|
||||
use tauri::{Manager, Emitter};
|
||||
use log::{info, warn, error, debug};
|
||||
use installer::{InstallerType, InstallerAction, Game};
|
||||
use dlc_manager::DlcInfoWithState;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GameAction {
|
||||
game_id: String,
|
||||
action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DlcCache {
|
||||
data: Vec<DlcInfoWithState>,
|
||||
timestamp: Instant,
|
||||
}
|
||||
|
||||
// Structure to hold the state of installed games
|
||||
struct AppState {
|
||||
games: Mutex<HashMap<String, Game>>,
|
||||
dlc_cache: Mutex<HashMap<String, DlcCache>>,
|
||||
fetch_cancellation: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
|
||||
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>, app_handle: tauri::AppHandle) -> Result<Vec<Game>, String> {
|
||||
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());
|
||||
}
|
||||
|
||||
info!("Found {} Steam library directories:", unique_libraries.len());
|
||||
for (i, lib) in unique_libraries.iter().enumerate() {
|
||||
info!(" Library {}: {}", i+1, lib);
|
||||
}
|
||||
|
||||
emit_scan_progress(&app_handle, &format!("Found {} Steam libraries. Starting game scan...", unique_libraries.len()), 20);
|
||||
|
||||
// Find installed games
|
||||
let games_info = searcher::find_installed_games(&libraries).await;
|
||||
|
||||
emit_scan_progress(&app_handle, &format!("Found {} games. Processing...", games_info.len()), 90);
|
||||
|
||||
// Log summary of games found
|
||||
info!("Games scan complete - Found {} games", games_info.len());
|
||||
info!("Native games: {}", games_info.iter().filter(|g| g.native).count());
|
||||
info!("Proton games: {}", games_info.iter().filter(|g| !g.native).count());
|
||||
info!("Games with CreamLinux: {}", games_info.iter().filter(|g| g.cream_installed).count());
|
||||
info!("Games with SmokeAPI: {}", 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);
|
||||
|
||||
let game = Game {
|
||||
id: game_info.id,
|
||||
title: game_info.title,
|
||||
path: game_info.path.to_string_lossy().to_string(),
|
||||
native: game_info.native,
|
||||
api_files: game_info.api_files,
|
||||
cream_installed: game_info.cream_installed,
|
||||
smoke_installed: game_info.smoke_installed,
|
||||
installing: false,
|
||||
};
|
||||
|
||||
result.push(game.clone());
|
||||
|
||||
// Store in state for later use
|
||||
state.games.lock().insert(game.id.clone(), game);
|
||||
}
|
||||
|
||||
emit_scan_progress(&app_handle, &format!("Scan complete. Found {} games.", result.len()), 100);
|
||||
|
||||
info!("Game scan completed successfully");
|
||||
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!({
|
||||
"message": message,
|
||||
"progress": progress
|
||||
});
|
||||
|
||||
if let Err(e) = app_handle.emit("scan-progress", payload) {
|
||||
warn!("Failed to emit scan-progress event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
games.get(&game_id)
|
||||
.cloned()
|
||||
.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.get(&game_action.game_id)
|
||||
.cloned()
|
||||
.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),
|
||||
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
|
||||
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
|
||||
_ => return Err(format!("Invalid action: {}", game_action.action))
|
||||
};
|
||||
|
||||
// Execute the action
|
||||
installer::process_action(
|
||||
game_action.game_id.clone(),
|
||||
installer_type,
|
||||
action,
|
||||
game.clone(),
|
||||
app_handle.clone()
|
||||
).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(|| format!("Game with ID {} not found after action", game_action.game_id))?;
|
||||
|
||||
// Update installation status
|
||||
match (installer_type, action) {
|
||||
(InstallerType::Cream, InstallerAction::Install) => {
|
||||
game.cream_installed = true;
|
||||
},
|
||||
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
||||
game.cream_installed = false;
|
||||
},
|
||||
(InstallerType::Smoke, InstallerAction::Install) => {
|
||||
game.smoke_installed = true;
|
||||
},
|
||||
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
||||
game.smoke_installed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset installing flag
|
||||
game.installing = false;
|
||||
|
||||
// Return updated game info
|
||||
game.clone()
|
||||
};
|
||||
|
||||
// Removed cache update
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
Ok(updated_game)
|
||||
}
|
||||
|
||||
// Fetch DLC list for a game
|
||||
#[tauri::command]
|
||||
async fn fetch_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
info!("Fetching DLCs for game ID: {}", game_id);
|
||||
|
||||
// Removed cache checking
|
||||
|
||||
// Always fetch fresh DLC data instead of using cache
|
||||
match installer::fetch_dlc_details(&game_id).await {
|
||||
Ok(dlcs) => {
|
||||
// Convert to DlcInfoWithState (all enabled by default)
|
||||
let dlcs_with_state = dlcs.into_iter()
|
||||
.map(|dlc| DlcInfoWithState {
|
||||
appid: dlc.appid,
|
||||
name: dlc.name,
|
||||
enabled: true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Cache in memory for this session (but not on disk)
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut cache = state.dlc_cache.lock();
|
||||
cache.insert(game_id.clone(), DlcCache {
|
||||
data: dlcs_with_state.clone(),
|
||||
timestamp: Instant::now(),
|
||||
});
|
||||
|
||||
Ok(dlcs_with_state)
|
||||
},
|
||||
Err(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>();
|
||||
state.fetch_cancellation.store(true, Ordering::SeqCst);
|
||||
|
||||
// Reset after a short delay
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let state = app_handle.state::<AppState>();
|
||||
state.fetch_cancellation.store(false, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Removed cached DLC check - always fetch fresh data
|
||||
|
||||
// Always fetch fresh DLC data from API
|
||||
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
|
||||
Ok(dlcs) => {
|
||||
info!("Successfully streamed {} DLCs for game {}", dlcs.len(), game_id);
|
||||
|
||||
// Convert to DLCInfoWithState for in-memory caching only
|
||||
let dlcs_with_state = dlcs.into_iter()
|
||||
.map(|dlc| DlcInfoWithState {
|
||||
appid: dlc.appid,
|
||||
name: dlc.name,
|
||||
enabled: true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Update in-memory cache without storing to disk
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut dlc_cache = state.dlc_cache.lock();
|
||||
dlc_cache.insert(game_id.clone(), DlcCache {
|
||||
data: dlcs_with_state,
|
||||
timestamp: tokio::time::Instant::now(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to stream DLC details: {}", e);
|
||||
// Emit error event
|
||||
let error_payload = serde_json::json!({
|
||||
"error": format!("Failed to fetch DLC details: {}", e)
|
||||
});
|
||||
|
||||
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
|
||||
warn!("Failed to emit dlc-error event: {}", emit_err);
|
||||
}
|
||||
|
||||
Err(format!("Failed to fetch DLC details: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> {
|
||||
info!("Updating DLC configuration for: {}", game_path);
|
||||
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,
|
||||
selected_dlcs: Vec<DlcInfoWithState>,
|
||||
app_handle: tauri::AppHandle
|
||||
) -> Result<Game, String> {
|
||||
info!("Installing CreamLinux with selected DLCs for game: {}", game_id);
|
||||
|
||||
// Clone selected_dlcs for later use
|
||||
let selected_dlcs_clone = selected_dlcs.clone();
|
||||
|
||||
// Install CreamLinux with the selected DLCs
|
||||
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs).await {
|
||||
Ok(_) => {
|
||||
// Return updated game info
|
||||
let state = app_handle.state::<AppState>();
|
||||
|
||||
// Get a mutable reference and update the game
|
||||
let game = {
|
||||
let mut games_map = state.games.lock();
|
||||
let game = games_map.get_mut(&game_id)
|
||||
.ok_or_else(|| format!("Game with ID {} not found after installation", game_id))?;
|
||||
|
||||
// Update installation status
|
||||
game.cream_installed = true;
|
||||
game.installing = false;
|
||||
|
||||
// Clone the game for returning later
|
||||
game.clone()
|
||||
}; // mutable borrow ends here
|
||||
|
||||
// Removed game caching
|
||||
|
||||
// Emit an event to update the UI
|
||||
if let Err(e) = app_handle.emit("game-updated", &game) {
|
||||
warn!("Failed to emit game-updated event: {}", e);
|
||||
}
|
||||
|
||||
// Show installation complete dialog with instructions
|
||||
let instructions = installer::InstallationInstructions {
|
||||
type_: "cream_install".to_string(),
|
||||
command: "sh ./cream.sh %command%".to_string(),
|
||||
game_title: game.title.clone(),
|
||||
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count())
|
||||
};
|
||||
|
||||
installer::emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Completed: {}", game.title),
|
||||
"CreamLinux has been installed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
true,
|
||||
Some(instructions)
|
||||
);
|
||||
|
||||
Ok(game)
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to install CreamLinux with selected DLCs: {}", e);
|
||||
Err(format!("Failed to install CreamLinux with selected DLCs: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup logging
|
||||
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use log::LevelFilter;
|
||||
use log4rs::append::file::FileAppender;
|
||||
use log4rs::config::{Appender, Config, Root};
|
||||
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 with improved log format
|
||||
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");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Set up logging first
|
||||
if let Err(e) = setup_logging() {
|
||||
eprintln!("Warning: Failed to initialize logging: {}", e);
|
||||
}
|
||||
|
||||
info!("Initializing CreamLinux application");
|
||||
|
||||
let app_state = AppState {
|
||||
games: Mutex::new(HashMap::new()),
|
||||
dlc_cache: Mutex::new(HashMap::new()),
|
||||
fetch_cancellation: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
scan_steam_games,
|
||||
get_game_info,
|
||||
process_game_action,
|
||||
fetch_game_dlcs,
|
||||
stream_game_dlcs,
|
||||
get_enabled_dlcs_command,
|
||||
update_dlc_configuration_command,
|
||||
install_cream_with_dlcs_command,
|
||||
get_all_dlcs_command,
|
||||
clear_caches,
|
||||
abort_dlc_fetch,
|
||||
])
|
||||
.setup(|app| {
|
||||
// Add a setup handler to do any initialization work
|
||||
info!("Tauri application setup");
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
587
src-tauri/src/searcher.rs
Normal file
587
src-tauri/src/searcher.rs
Normal file
@@ -0,0 +1,587 @@
|
||||
// src/searcher.rs
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::HashSet;
|
||||
use log::{info, debug, warn, error};
|
||||
use regex::Regex;
|
||||
use walkdir::WalkDir;
|
||||
use tokio::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Game information structure
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameInfo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub path: PathBuf,
|
||||
pub native: bool,
|
||||
pub api_files: Vec<String>,
|
||||
pub cream_installed: bool,
|
||||
pub smoke_installed: bool,
|
||||
}
|
||||
|
||||
/// Find potential Steam installation directories
|
||||
pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Get user's home directory
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
info!("Searching for Steam in home directory: {}", home);
|
||||
|
||||
// Common Steam installation locations on Linux
|
||||
let common_paths = [
|
||||
".steam/steam", // Steam symlink directory
|
||||
".steam/root", // Alternative symlink
|
||||
".local/share/Steam", // Flatpak Steam installation
|
||||
".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path
|
||||
".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path
|
||||
"/run/media/mmcblk0p1", // Removable Storage path
|
||||
];
|
||||
|
||||
for path in &common_paths {
|
||||
let full_path = PathBuf::from(&home).join(path);
|
||||
if full_path.exists() {
|
||||
debug!("Found Steam directory: {}", full_path.display());
|
||||
paths.push(full_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Steam Deck paths if they exist (these don't rely on HOME)
|
||||
let deck_paths = [
|
||||
"/home/deck/.steam/steam",
|
||||
"/home/deck/.local/share/Steam",
|
||||
];
|
||||
|
||||
for path in &deck_paths {
|
||||
let p = PathBuf::from(path);
|
||||
if p.exists() && !paths.contains(&p) {
|
||||
debug!("Found Steam Deck path: {}", p.display());
|
||||
paths.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract paths from Steam registry file
|
||||
if let Some(registry_paths) = read_steam_registry() {
|
||||
for path in registry_paths {
|
||||
if !paths.contains(&path) && path.exists() {
|
||||
debug!("Adding Steam path from registry: {}", path.display());
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} potential Steam directories", paths.len());
|
||||
paths
|
||||
}
|
||||
|
||||
/// Try to read the Steam registry file to find installation paths
|
||||
fn read_steam_registry() -> Option<Vec<PathBuf>> {
|
||||
let home = match std::env::var("HOME") {
|
||||
Ok(h) => h,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
let registry_paths = [
|
||||
format!("{}/.steam/registry.vdf", home),
|
||||
format!("{}/.steam/steam/registry.vdf", home),
|
||||
format!("{}/.local/share/Steam/registry.vdf", home),
|
||||
];
|
||||
|
||||
for registry_path in registry_paths {
|
||||
let path = Path::new(®istry_path);
|
||||
if path.exists() {
|
||||
debug!("Found Steam registry at: {}", path.display());
|
||||
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Extract Steam installation paths
|
||||
let re_steam_path = Regex::new(r#""SteamPath"\s+"([^"]+)""#).unwrap();
|
||||
if let Some(cap) = re_steam_path.captures(&content) {
|
||||
let steam_path = PathBuf::from(&cap[1]);
|
||||
paths.push(steam_path);
|
||||
}
|
||||
|
||||
// Look for install path
|
||||
let re_install_path = Regex::new(r#""InstallPath"\s+"([^"]+)""#).unwrap();
|
||||
if let Some(cap) = re_install_path.captures(&content) {
|
||||
let install_path = PathBuf::from(&cap[1]);
|
||||
if !paths.contains(&install_path) {
|
||||
paths.push(install_path);
|
||||
}
|
||||
}
|
||||
|
||||
if !paths.is_empty() {
|
||||
return Some(paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Find all Steam library folders from base Steam installation paths
|
||||
pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut libraries = HashSet::new();
|
||||
|
||||
for base_path in base_paths {
|
||||
debug!("Looking for Steam libraries in: {}", base_path.display());
|
||||
|
||||
// Check if this path contains a steamapps directory
|
||||
let steamapps_path = base_path.join("steamapps");
|
||||
if steamapps_path.exists() && steamapps_path.is_dir() {
|
||||
debug!("Found steamapps directory: {}", steamapps_path.display());
|
||||
libraries.insert(steamapps_path.clone());
|
||||
|
||||
// Check for additional libraries in libraryfolders.vdf
|
||||
parse_library_folders_vdf(&steamapps_path, &mut libraries);
|
||||
}
|
||||
|
||||
// Also check for steamapps in common locations relative to this path
|
||||
let possible_steamapps = [
|
||||
base_path.join("steam/steamapps"),
|
||||
base_path.join("Steam/steamapps"),
|
||||
];
|
||||
|
||||
for path in &possible_steamapps {
|
||||
if path.exists() && path.is_dir() && !libraries.contains(path) {
|
||||
debug!("Found steamapps directory: {}", path.display());
|
||||
libraries.insert(path.clone());
|
||||
|
||||
// Check for additional libraries in libraryfolders.vdf
|
||||
parse_library_folders_vdf(path, &mut libraries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result: Vec<PathBuf> = libraries.into_iter().collect();
|
||||
info!("Found {} Steam library directories", result.len());
|
||||
for (i, lib) in result.iter().enumerate() {
|
||||
info!(" Library {}: {}", i+1, lib.display());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse libraryfolders.vdf to extract additional library paths
|
||||
fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<PathBuf>) {
|
||||
// Check both possible locations of the VDF file
|
||||
let vdf_paths = [
|
||||
steamapps_path.join("libraryfolders.vdf"),
|
||||
steamapps_path.join("config/libraryfolders.vdf"),
|
||||
];
|
||||
|
||||
for vdf_path in &vdf_paths {
|
||||
if vdf_path.exists() {
|
||||
debug!("Found library folders VDF: {}", vdf_path.display());
|
||||
|
||||
if let Ok(content) = fs::read_to_string(vdf_path) {
|
||||
// Extract library paths using regex for both new and old format VDFs
|
||||
let re_path = Regex::new(r#""path"\s+"([^"]+)""#).unwrap();
|
||||
for cap in re_path.captures_iter(&content) {
|
||||
let path_str = &cap[1];
|
||||
let lib_path = PathBuf::from(path_str).join("steamapps");
|
||||
|
||||
if lib_path.exists() && lib_path.is_dir() && !libraries.contains(&lib_path) {
|
||||
debug!("Found library from VDF: {}", lib_path.display());
|
||||
// Clone lib_path before inserting to avoid ownership issues
|
||||
let lib_path_clone = lib_path.clone();
|
||||
libraries.insert(lib_path_clone);
|
||||
|
||||
// Recursively check this library for more libraries
|
||||
parse_library_folders_vdf(&lib_path, libraries);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an appmanifest ACF file to extract game information
|
||||
fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(content) => {
|
||||
// Use regex to extract the app ID, name, and install directory
|
||||
let re_appid = Regex::new(r#""appid"\s+"(\d+)""#).unwrap();
|
||||
let re_name = Regex::new(r#""name"\s+"([^"]+)""#).unwrap();
|
||||
let re_installdir = Regex::new(r#""installdir"\s+"([^"]+)""#).unwrap();
|
||||
|
||||
if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = (
|
||||
re_appid.captures(&content),
|
||||
re_name.captures(&content),
|
||||
re_installdir.captures(&content)
|
||||
) {
|
||||
let app_id = app_id_cap[1].to_string();
|
||||
let name = name_cap[1].to_string();
|
||||
let install_dir = dir_cap[1].to_string();
|
||||
|
||||
return Some((app_id, name, install_dir));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read ACF file {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a file is a Linux ELF binary
|
||||
fn is_elf_binary(path: &Path) -> bool {
|
||||
if let Ok(mut file) = fs::File::open(path) {
|
||||
let mut buffer = [0; 4];
|
||||
if file.read_exact(&mut buffer).is_ok() {
|
||||
// Check for ELF magic number (0x7F 'E' 'L' 'F')
|
||||
return buffer[0] == 0x7F && buffer[1] == b'E' && buffer[2] == b'L' && buffer[3] == b'F';
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a game has CreamLinux installed
|
||||
fn check_creamlinux_installed(game_path: &Path) -> bool {
|
||||
let cream_files = [
|
||||
"cream.sh",
|
||||
"cream_api.ini",
|
||||
"cream_api.so",
|
||||
];
|
||||
|
||||
for file in &cream_files {
|
||||
if game_path.join(file).exists() {
|
||||
debug!("CreamLinux installation detected: {}", file);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a game has SmokeAPI installed
|
||||
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
|
||||
if api_files.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// SmokeAPI creates backups with _o.dll suffix
|
||||
for api_file in api_files {
|
||||
let api_path = game_path.join(api_file);
|
||||
let api_dir = api_path.parent().unwrap_or(game_path);
|
||||
let api_filename = api_path.file_name().unwrap_or_default();
|
||||
|
||||
// Check for backup file (original file renamed with _o.dll suffix)
|
||||
let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll");
|
||||
let backup_path = api_dir.join(backup_name);
|
||||
|
||||
if backup_path.exists() {
|
||||
debug!("SmokeAPI backup file found: {}", backup_path.display());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Scan a game directory to determine if it's native or needs Proton
|
||||
/// Also collect any Steam API DLLs for potential SmokeAPI installation
|
||||
fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
||||
let mut found_exe = false;
|
||||
let mut found_linux_binary = false;
|
||||
let mut steam_api_files = Vec::new();
|
||||
|
||||
// Directories to skip for better performance
|
||||
let skip_dirs = [
|
||||
"videos", "video", "movies", "movie",
|
||||
"sound", "sounds", "audio",
|
||||
"textures", "music", "localization",
|
||||
"shaders", "logs", "assets/audio",
|
||||
"assets/video", "assets/textures"
|
||||
];
|
||||
|
||||
// Only scan to a reasonable depth (avoid extreme recursion)
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
// File extensions to check for (executable and Steam API files)
|
||||
let exe_extensions = ["exe", "bat", "cmd", "msi"];
|
||||
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
|
||||
|
||||
// Recursively walk through the game directory with optimized settings
|
||||
for entry in WalkDir::new(game_path)
|
||||
.max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
|
||||
.follow_links(false) // Don't follow symlinks to prevent cycles
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
// Skip certain directories for performance
|
||||
if e.file_type().is_dir() {
|
||||
let file_name = e.file_name().to_string_lossy().to_lowercase();
|
||||
if skip_dirs.iter().any(|&dir| file_name == dir) {
|
||||
debug!("Skipping directory: {}", e.path().display());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.filter_map(Result::ok) {
|
||||
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if let Some(ext) = path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
|
||||
// Check for Windows executables
|
||||
if exe_extensions.iter().any(|&e| ext_str == e) {
|
||||
found_exe = true;
|
||||
}
|
||||
|
||||
// Check for Steam API DLLs
|
||||
if ext_str == "dll" {
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase();
|
||||
if filename == "steam_api.dll" || filename == "steam_api64.dll" {
|
||||
if let Ok(rel_path) = path.strip_prefix(game_path) {
|
||||
let rel_path_str = rel_path.to_string_lossy().to_string();
|
||||
debug!("Found Steam API DLL: {}", rel_path_str);
|
||||
steam_api_files.push(rel_path_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Linux binary files
|
||||
if binary_extensions.iter().any(|&e| ext_str == e) {
|
||||
found_linux_binary = true;
|
||||
|
||||
// Check if it's actually an ELF binary for more certainty
|
||||
if ext_str == "so" && is_elf_binary(path) {
|
||||
found_linux_binary = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Linux executables (no extension)
|
||||
#[cfg(unix)]
|
||||
if !path.extension().is_some() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if let Ok(metadata) = path.metadata() {
|
||||
let is_executable = metadata.permissions().mode() & 0o111 != 0;
|
||||
|
||||
// Check executable permission and ELF format
|
||||
if is_executable && is_elf_binary(path) {
|
||||
found_linux_binary = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop
|
||||
// This early break greatly improves performance for large game directories
|
||||
if found_exe && found_linux_binary && !steam_api_files.is_empty() {
|
||||
debug!("Found sufficient evidence, breaking scan early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// A game is considered native if it has Linux binaries but no Windows executables
|
||||
let is_native = found_linux_binary && !found_exe;
|
||||
|
||||
debug!("Game scan results: native={}, exe={}, api_dlls={}", is_native, found_exe, steam_api_files.len());
|
||||
(is_native, steam_api_files)
|
||||
}
|
||||
|
||||
/// Find all installed Steam games from library folders
|
||||
pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> {
|
||||
|
||||
let mut games = Vec::new();
|
||||
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
|
||||
|
||||
// IDs to skip (tools, redistributables, etc.)
|
||||
let skip_ids = Arc::new([
|
||||
"228980", // Steamworks Common Redistributables
|
||||
"1070560", // Steam Linux Runtime
|
||||
"1391110", // Steam Linux Runtime - Soldier
|
||||
"1628350", // Steam Linux Runtime - Sniper
|
||||
"1493710", // Proton Experimental
|
||||
"2180100", // Steam Linux Runtime - Scout
|
||||
].iter().copied().collect::<HashSet<&str>>());
|
||||
|
||||
// Name patterns to skip (case insensitive)
|
||||
let skip_patterns = Arc::new(
|
||||
[
|
||||
r"(?i)steam linux runtime",
|
||||
r"(?i)proton",
|
||||
r"(?i)steamworks common",
|
||||
r"(?i)redistributable",
|
||||
r"(?i)dotnet",
|
||||
r"(?i)vc redist",
|
||||
]
|
||||
.iter()
|
||||
.map(|pat| Regex::new(pat).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
info!("Scanning for installed games in parallel...");
|
||||
|
||||
// Create a channel to collect results
|
||||
let (tx, mut rx) = mpsc::channel(32);
|
||||
|
||||
// First collect all appmanifest files to process
|
||||
let mut app_manifests = Vec::new();
|
||||
for steamapps_dir in steamapps_paths {
|
||||
if let Ok(entries) = fs::read_dir(steamapps_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Check for appmanifest files
|
||||
if filename.starts_with("appmanifest_") && filename.ends_with(".acf") {
|
||||
app_manifests.push((path, steamapps_dir.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} appmanifest files to process", app_manifests.len());
|
||||
|
||||
// Process each appmanifest file in parallel with a maximum concurrency
|
||||
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
|
||||
info!("Using {} concurrent scanners", max_concurrent);
|
||||
|
||||
// Use a semaphore to limit concurrency
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
|
||||
|
||||
// Create a Vec to store all our task handles
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Process each manifest file
|
||||
for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() {
|
||||
// Clone what we need for the task
|
||||
let path = path.clone();
|
||||
let steamapps_dir = steamapps_dir.clone();
|
||||
let skip_patterns = Arc::clone(&skip_patterns);
|
||||
let tx = tx.clone();
|
||||
let seen_ids = Arc::clone(&seen_ids);
|
||||
let semaphore = Arc::clone(&semaphore);
|
||||
let skip_ids = Arc::clone(&skip_ids);
|
||||
|
||||
// Create a new task
|
||||
let handle = tokio::spawn(async move {
|
||||
// Acquire a permit from the semaphore
|
||||
let _permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
// Parse the appmanifest file
|
||||
if let Some((id, name, install_dir)) = parse_appmanifest(&path) {
|
||||
// Skip if in exclusion list
|
||||
if skip_ids.contains(id.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a guard against duplicates
|
||||
{
|
||||
let mut seen = seen_ids.lock().await;
|
||||
if seen.contains(&id) {
|
||||
return;
|
||||
}
|
||||
seen.insert(id.clone());
|
||||
}
|
||||
|
||||
// Skip if the name matches any exclusion patterns
|
||||
if skip_patterns.iter().any(|re| re.is_match(&name)) {
|
||||
debug!("Skipping runtime/tool: {} ({})", name, id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Full path to the game directory
|
||||
let game_path = steamapps_dir.join("common").join(&install_dir);
|
||||
|
||||
// Skip if game directory doesn't exist
|
||||
if !game_path.exists() {
|
||||
warn!("Game directory not found: {}", game_path.display());
|
||||
return;
|
||||
}
|
||||
|
||||
// Scan the game directory to determine platform and find Steam API DLLs
|
||||
info!("Scanning game: {} at {}", name, game_path.display());
|
||||
|
||||
// Scanning is I/O heavy but not CPU heavy, so we can just do it directly
|
||||
let (is_native, api_files) = scan_game_directory(&game_path);
|
||||
|
||||
// Check for CreamLinux installation
|
||||
let cream_installed = check_creamlinux_installed(&game_path);
|
||||
|
||||
// Check for SmokeAPI installation (only for non-native games with Steam API DLLs)
|
||||
let smoke_installed = if !is_native && !api_files.is_empty() {
|
||||
check_smokeapi_installed(&game_path, &api_files)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Create the game info
|
||||
let game_info = GameInfo {
|
||||
id,
|
||||
title: name,
|
||||
path: game_path,
|
||||
native: is_native,
|
||||
api_files,
|
||||
cream_installed,
|
||||
smoke_installed,
|
||||
};
|
||||
|
||||
// Send the game info through the channel
|
||||
if tx.send(game_info).await.is_err() {
|
||||
error!("Failed to send game info through channel");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
|
||||
// Every 10 files, yield to allow progress updates
|
||||
if manifest_idx % 10 == 0 {
|
||||
// We would update progress here in a full implementation
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the original sender so the receiver knows when we're done
|
||||
drop(tx);
|
||||
|
||||
// Spawn a task to collect all the results
|
||||
let receiver_task = tokio::spawn(async move {
|
||||
let mut results = Vec::new();
|
||||
while let Some(game) = rx.recv().await {
|
||||
info!("Found game: {} ({})", game.title, game.id);
|
||||
info!(" Path: {}", game.path.display());
|
||||
info!(" Status: Native={}, Cream={}, Smoke={}",
|
||||
game.native, game.cream_installed, game.smoke_installed);
|
||||
|
||||
// Log Steam API DLLs if any
|
||||
if !game.api_files.is_empty() {
|
||||
info!(" Steam API files:");
|
||||
for api_file in &game.api_files {
|
||||
info!(" - {}", api_file);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(game);
|
||||
}
|
||||
results
|
||||
});
|
||||
|
||||
// Wait for all scan tasks to complete - but don't wait for the results yet
|
||||
for handle in handles {
|
||||
// Ignore errors - the receiver task will just get fewer results
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
// Now wait for all results to be collected
|
||||
if let Ok(results) = receiver_task.await {
|
||||
games = results;
|
||||
}
|
||||
|
||||
info!("Found {} installed games", games.len());
|
||||
games
|
||||
}
|
||||
41
src-tauri/tauri.conf.json
Normal file
41
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"category": "Utility",
|
||||
"icon": [
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png"
|
||||
]
|
||||
},
|
||||
"productName": "Creamlinux",
|
||||
"mainBinaryName": "creamlinux",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.creamlinux.dev",
|
||||
"plugins": {},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Creamlinux",
|
||||
"width": 1000,
|
||||
"height": 700,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user