first pass, cli, gui, installer
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
57532bd23fc50213e2af31c80e4df6a56423f75c- Parents
-
ea4ad0a - Tree
b570c6d
57532bd
57532bd23fc50213e2af31c80e4df6a56423f75cea4ad0a
b570c6dcrates/wanda-cli/Cargo.tomladded@@ -0,0 +1,24 @@ | ||
| 1 | +[package] | |
| 2 | +name = "wanda-cli" | |
| 3 | +version.workspace = true | |
| 4 | +edition.workspace = true | |
| 5 | +license.workspace = true | |
| 6 | +description = "CLI for WANDA - WeMod launcher for Linux" | |
| 7 | + | |
| 8 | +[[bin]] | |
| 9 | +name = "wanda" | |
| 10 | +path = "src/main.rs" | |
| 11 | + | |
| 12 | +[dependencies] | |
| 13 | +wanda-core = { path = "../wanda-core" } | |
| 14 | + | |
| 15 | +tokio.workspace = true | |
| 16 | +serde.workspace = true | |
| 17 | +serde_json.workspace = true | |
| 18 | +thiserror.workspace = true | |
| 19 | +anyhow.workspace = true | |
| 20 | +tracing.workspace = true | |
| 21 | +tracing-subscriber.workspace = true | |
| 22 | +clap.workspace = true | |
| 23 | +indicatif.workspace = true | |
| 24 | +console.workspace = true | |
crates/wanda-cli/src/commands/config.rsadded@@ -0,0 +1,202 @@ | ||
| 1 | +//! wanda config - Manage configuration | |
| 2 | + | |
| 3 | +use clap::Subcommand; | |
| 4 | +use console::style; | |
| 5 | +use std::path::PathBuf; | |
| 6 | +use wanda_core::{config::WandaConfig, Result}; | |
| 7 | + | |
| 8 | +#[derive(Subcommand)] | |
| 9 | +pub enum ConfigCommands { | |
| 10 | + /// Display current configuration | |
| 11 | + Show, | |
| 12 | + | |
| 13 | + /// Open config file in editor | |
| 14 | + Edit, | |
| 15 | + | |
| 16 | + /// Set a configuration value | |
| 17 | + Set { | |
| 18 | + /// Configuration key (e.g., "proton.preferred_version") | |
| 19 | + key: String, | |
| 20 | + /// Value to set | |
| 21 | + value: String, | |
| 22 | + }, | |
| 23 | + | |
| 24 | + /// Reset configuration to defaults | |
| 25 | + Reset, | |
| 26 | + | |
| 27 | + /// Show config file path | |
| 28 | + Path, | |
| 29 | +} | |
| 30 | + | |
| 31 | +pub async fn run(cmd: ConfigCommands, config_path: Option<PathBuf>) -> Result<()> { | |
| 32 | + match cmd { | |
| 33 | + ConfigCommands::Show => show(config_path), | |
| 34 | + ConfigCommands::Edit => edit(config_path).await, | |
| 35 | + ConfigCommands::Set { key, value } => set(key, value, config_path), | |
| 36 | + ConfigCommands::Reset => reset(config_path), | |
| 37 | + ConfigCommands::Path => path(config_path), | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +fn show(config_path: Option<PathBuf>) -> Result<()> { | |
| 42 | + let config = match &config_path { | |
| 43 | + Some(path) => WandaConfig::load_from(path)?, | |
| 44 | + None => WandaConfig::load()?, | |
| 45 | + }; | |
| 46 | + | |
| 47 | + println!("WANDA Configuration:\n"); | |
| 48 | + | |
| 49 | + // Steam settings | |
| 50 | + println!("[steam]"); | |
| 51 | + if let Some(ref path) = config.steam.install_path { | |
| 52 | + println!(" install_path = \"{}\"", path.display()); | |
| 53 | + } | |
| 54 | + if !config.steam.additional_libraries.is_empty() { | |
| 55 | + println!(" additional_libraries = ["); | |
| 56 | + for lib in &config.steam.additional_libraries { | |
| 57 | + println!(" \"{}\",", lib.display()); | |
| 58 | + } | |
| 59 | + println!(" ]"); | |
| 60 | + } | |
| 61 | + println!(" scan_flatpak = {}", config.steam.scan_flatpak); | |
| 62 | + | |
| 63 | + // Proton settings | |
| 64 | + println!("\n[proton]"); | |
| 65 | + if let Some(ref version) = config.proton.preferred_version { | |
| 66 | + println!(" preferred_version = \"{}\"", version); | |
| 67 | + } | |
| 68 | + if !config.proton.search_paths.is_empty() { | |
| 69 | + println!(" search_paths = ["); | |
| 70 | + for path in &config.proton.search_paths { | |
| 71 | + println!(" \"{}\",", path.display()); | |
| 72 | + } | |
| 73 | + println!(" ]"); | |
| 74 | + } | |
| 75 | + | |
| 76 | + // WeMod settings | |
| 77 | + println!("\n[wemod]"); | |
| 78 | + println!(" auto_update = {}", config.wemod.auto_update); | |
| 79 | + if let Some(ref version) = config.wemod.version { | |
| 80 | + println!(" version = \"{}\"", version); | |
| 81 | + } | |
| 82 | + | |
| 83 | + // Prefix settings | |
| 84 | + println!("\n[prefix]"); | |
| 85 | + if let Some(ref path) = config.prefix.base_path { | |
| 86 | + println!(" base_path = \"{}\"", path.display()); | |
| 87 | + } | |
| 88 | + println!(" auto_repair = {}", config.prefix.auto_repair); | |
| 89 | + | |
| 90 | + Ok(()) | |
| 91 | +} | |
| 92 | + | |
| 93 | +async fn edit(config_path: Option<PathBuf>) -> Result<()> { | |
| 94 | + let path = config_path.unwrap_or_else(WandaConfig::default_path); | |
| 95 | + | |
| 96 | + // Ensure config file exists | |
| 97 | + if !path.exists() { | |
| 98 | + println!("Config file doesn't exist, creating default..."); | |
| 99 | + let config = WandaConfig::default(); | |
| 100 | + config.save_to(&path)?; | |
| 101 | + } | |
| 102 | + | |
| 103 | + // Try common editors | |
| 104 | + let editors = ["$EDITOR", "code", "nano", "vim", "vi"]; | |
| 105 | + | |
| 106 | + for editor in &editors { | |
| 107 | + let editor_cmd = if *editor == "$EDITOR" { | |
| 108 | + std::env::var("EDITOR").ok() | |
| 109 | + } else { | |
| 110 | + Some(editor.to_string()) | |
| 111 | + }; | |
| 112 | + | |
| 113 | + if let Some(cmd) = editor_cmd { | |
| 114 | + let status = tokio::process::Command::new(&cmd) | |
| 115 | + .arg(&path) | |
| 116 | + .status() | |
| 117 | + .await; | |
| 118 | + | |
| 119 | + if status.is_ok() { | |
| 120 | + return Ok(()); | |
| 121 | + } | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + println!("Could not open editor. Config file is at:"); | |
| 126 | + println!(" {}", path.display()); | |
| 127 | + | |
| 128 | + Ok(()) | |
| 129 | +} | |
| 130 | + | |
| 131 | +fn set(key: String, value: String, config_path: Option<PathBuf>) -> Result<()> { | |
| 132 | + let path = config_path.unwrap_or_else(WandaConfig::default_path); | |
| 133 | + let mut config = if path.exists() { | |
| 134 | + WandaConfig::load_from(&path)? | |
| 135 | + } else { | |
| 136 | + WandaConfig::default() | |
| 137 | + }; | |
| 138 | + | |
| 139 | + match key.as_str() { | |
| 140 | + "steam.install_path" => { | |
| 141 | + config.steam.install_path = Some(PathBuf::from(&value)); | |
| 142 | + } | |
| 143 | + "steam.scan_flatpak" => { | |
| 144 | + config.steam.scan_flatpak = value.parse().unwrap_or(false); | |
| 145 | + } | |
| 146 | + "proton.preferred_version" => { | |
| 147 | + config.proton.preferred_version = Some(value.clone()); | |
| 148 | + } | |
| 149 | + "wemod.auto_update" => { | |
| 150 | + config.wemod.auto_update = value.parse().unwrap_or(true); | |
| 151 | + } | |
| 152 | + "wemod.version" => { | |
| 153 | + config.wemod.version = Some(value.clone()); | |
| 154 | + } | |
| 155 | + "prefix.base_path" => { | |
| 156 | + config.prefix.base_path = Some(PathBuf::from(&value)); | |
| 157 | + } | |
| 158 | + "prefix.auto_repair" => { | |
| 159 | + config.prefix.auto_repair = value.parse().unwrap_or(true); | |
| 160 | + } | |
| 161 | + _ => { | |
| 162 | + println!("Unknown configuration key: {}", key); | |
| 163 | + println!("\nAvailable keys:"); | |
| 164 | + println!(" steam.install_path"); | |
| 165 | + println!(" steam.scan_flatpak"); | |
| 166 | + println!(" proton.preferred_version"); | |
| 167 | + println!(" wemod.auto_update"); | |
| 168 | + println!(" wemod.version"); | |
| 169 | + println!(" prefix.base_path"); | |
| 170 | + println!(" prefix.auto_repair"); | |
| 171 | + return Ok(()); | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + config.save_to(&path)?; | |
| 176 | + println!("{} {} = {}", style("Set").green(), key, value); | |
| 177 | + | |
| 178 | + Ok(()) | |
| 179 | +} | |
| 180 | + | |
| 181 | +fn reset(config_path: Option<PathBuf>) -> Result<()> { | |
| 182 | + let path = config_path.unwrap_or_else(WandaConfig::default_path); | |
| 183 | + | |
| 184 | + println!( | |
| 185 | + "This will reset all configuration to defaults. Are you sure?" | |
| 186 | + ); | |
| 187 | + println!("Config file: {}", path.display()); | |
| 188 | + println!("\nRun with --force to confirm (not implemented yet)."); | |
| 189 | + | |
| 190 | + // For now, just create a default config | |
| 191 | + let config = WandaConfig::default(); | |
| 192 | + config.save_to(&path)?; | |
| 193 | + println!("\nConfiguration reset to defaults."); | |
| 194 | + | |
| 195 | + Ok(()) | |
| 196 | +} | |
| 197 | + | |
| 198 | +fn path(config_path: Option<PathBuf>) -> Result<()> { | |
| 199 | + let path = config_path.unwrap_or_else(WandaConfig::default_path); | |
| 200 | + println!("{}", path.display()); | |
| 201 | + Ok(()) | |
| 202 | +} | |
crates/wanda-cli/src/commands/doctor.rsadded@@ -0,0 +1,243 @@ | ||
| 1 | +//! wanda doctor - Diagnose issues | |
| 2 | + | |
| 3 | +use clap::Args; | |
| 4 | +use console::style; | |
| 5 | +use std::path::PathBuf; | |
| 6 | +use wanda_core::{ | |
| 7 | + config::WandaConfig, | |
| 8 | + prefix::{PrefixHealth, PrefixManager}, | |
| 9 | + steam::{ProtonCompatibility, ProtonManager, SteamInstallation}, | |
| 10 | + Result, | |
| 11 | +}; | |
| 12 | + | |
| 13 | +#[derive(Args)] | |
| 14 | +pub struct DoctorArgs { | |
| 15 | + /// Generate full diagnostic report | |
| 16 | + #[arg(long)] | |
| 17 | + full: bool, | |
| 18 | + | |
| 19 | + /// Export report to file | |
| 20 | + #[arg(long)] | |
| 21 | + export: Option<PathBuf>, | |
| 22 | + | |
| 23 | + /// Attempt to fix detected issues | |
| 24 | + #[arg(long)] | |
| 25 | + fix: bool, | |
| 26 | +} | |
| 27 | + | |
| 28 | +pub async fn run(args: DoctorArgs, config_path: Option<PathBuf>) -> Result<()> { | |
| 29 | + let config = match &config_path { | |
| 30 | + Some(path) => WandaConfig::load_from(path)?, | |
| 31 | + None => WandaConfig::load()?, | |
| 32 | + }; | |
| 33 | + | |
| 34 | + println!("WANDA Diagnostic Report\n"); | |
| 35 | + println!("{}\n", "=".repeat(50)); | |
| 36 | + | |
| 37 | + let mut issues = Vec::new(); | |
| 38 | + | |
| 39 | + // Check Steam | |
| 40 | + print_section("Steam Installation"); | |
| 41 | + match SteamInstallation::discover(&config) { | |
| 42 | + Ok(steam) => { | |
| 43 | + println!( | |
| 44 | + " {} Found at {}{}", | |
| 45 | + style("OK").green(), | |
| 46 | + steam.root_path.display(), | |
| 47 | + if steam.is_flatpak { " (Flatpak)" } else { "" } | |
| 48 | + ); | |
| 49 | + println!(" {} {} libraries", style("OK").green(), steam.libraries.len()); | |
| 50 | + | |
| 51 | + let total_games: usize = steam.libraries.iter().map(|l| l.apps.len()).sum(); | |
| 52 | + let proton_games = steam | |
| 53 | + .get_all_games() | |
| 54 | + .iter() | |
| 55 | + .filter(|g| g.uses_proton()) | |
| 56 | + .count(); | |
| 57 | + println!(" {} {} games ({} using Proton)", style("OK").green(), total_games, proton_games); | |
| 58 | + | |
| 59 | + // Check Proton | |
| 60 | + print_section("Proton"); | |
| 61 | + match ProtonManager::discover(&steam, &config) { | |
| 62 | + Ok(proton_mgr) => { | |
| 63 | + if proton_mgr.versions.is_empty() { | |
| 64 | + println!(" {} No Proton versions found", style("ERROR").red()); | |
| 65 | + issues.push("No Proton versions installed".to_string()); | |
| 66 | + } else { | |
| 67 | + println!( | |
| 68 | + " {} {} versions found", | |
| 69 | + style("OK").green(), | |
| 70 | + proton_mgr.versions.len() | |
| 71 | + ); | |
| 72 | + | |
| 73 | + // List versions in full mode | |
| 74 | + if args.full { | |
| 75 | + for v in &proton_mgr.versions { | |
| 76 | + let compat = match v.compatibility { | |
| 77 | + ProtonCompatibility::Recommended => { | |
| 78 | + style("RECOMMENDED").green() | |
| 79 | + } | |
| 80 | + ProtonCompatibility::Supported => style("SUPPORTED").blue(), | |
| 81 | + ProtonCompatibility::Experimental => { | |
| 82 | + style("EXPERIMENTAL").yellow() | |
| 83 | + } | |
| 84 | + ProtonCompatibility::Unsupported => style("UNSUPPORTED").red(), | |
| 85 | + }; | |
| 86 | + println!(" {} {}", v.name, compat); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + if let Some(recommended) = proton_mgr.get_recommended() { | |
| 91 | + println!( | |
| 92 | + " {} Recommended: {}", | |
| 93 | + style("OK").green(), | |
| 94 | + recommended.name | |
| 95 | + ); | |
| 96 | + } else { | |
| 97 | + println!( | |
| 98 | + " {} No recommended Proton version", | |
| 99 | + style("WARN").yellow() | |
| 100 | + ); | |
| 101 | + issues.push("No recommended Proton version found".to_string()); | |
| 102 | + } | |
| 103 | + } | |
| 104 | + } | |
| 105 | + Err(e) => { | |
| 106 | + println!(" {} Error: {}", style("ERROR").red(), e); | |
| 107 | + issues.push(format!("Proton detection failed: {}", e)); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | + Err(e) => { | |
| 112 | + println!(" {} Not found: {}", style("ERROR").red(), e); | |
| 113 | + issues.push("Steam installation not found".to_string()); | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + // Check WANDA prefix | |
| 118 | + print_section("WANDA Prefix"); | |
| 119 | + let mut prefix_manager = PrefixManager::new(&config); | |
| 120 | + prefix_manager.load()?; | |
| 121 | + | |
| 122 | + if let Some(prefix) = prefix_manager.get("default") { | |
| 123 | + println!(" {} Found at {}", style("OK").green(), prefix.path.display()); | |
| 124 | + | |
| 125 | + let health = prefix_manager.validate("default")?; | |
| 126 | + match health { | |
| 127 | + PrefixHealth::Healthy => { | |
| 128 | + println!(" {} Prefix is healthy", style("OK").green()); | |
| 129 | + } | |
| 130 | + PrefixHealth::NeedsRepair(ref issue_list) => { | |
| 131 | + println!(" {} Prefix needs repair:", style("WARN").yellow()); | |
| 132 | + for issue in issue_list { | |
| 133 | + println!(" - {}", issue); | |
| 134 | + issues.push(format!("Prefix issue: {}", issue)); | |
| 135 | + } | |
| 136 | + } | |
| 137 | + PrefixHealth::Corrupted(reason) => { | |
| 138 | + println!(" {} Prefix is corrupted: {}", style("ERROR").red(), reason); | |
| 139 | + issues.push(format!("Prefix corrupted: {}", reason)); | |
| 140 | + } | |
| 141 | + PrefixHealth::NotCreated => { | |
| 142 | + println!(" {} Prefix not initialized", style("WARN").yellow()); | |
| 143 | + issues.push("WANDA prefix not created".to_string()); | |
| 144 | + } | |
| 145 | + } | |
| 146 | + | |
| 147 | + // Check WeMod | |
| 148 | + print_section("WeMod"); | |
| 149 | + if prefix.wemod_installed { | |
| 150 | + println!( | |
| 151 | + " {} Installed (version {})", | |
| 152 | + style("OK").green(), | |
| 153 | + prefix.wemod_version.as_deref().unwrap_or("unknown") | |
| 154 | + ); | |
| 155 | + | |
| 156 | + if prefix.wemod_exe().exists() { | |
| 157 | + println!(" {} Executable found", style("OK").green()); | |
| 158 | + } else { | |
| 159 | + println!(" {} Executable missing", style("ERROR").red()); | |
| 160 | + issues.push("WeMod executable not found".to_string()); | |
| 161 | + } | |
| 162 | + } else { | |
| 163 | + println!(" {} Not installed", style("WARN").yellow()); | |
| 164 | + issues.push("WeMod not installed".to_string()); | |
| 165 | + } | |
| 166 | + } else { | |
| 167 | + println!(" {} Not initialized", style("WARN").yellow()); | |
| 168 | + issues.push("WANDA not initialized".to_string()); | |
| 169 | + } | |
| 170 | + | |
| 171 | + // Check system dependencies | |
| 172 | + print_section("System Dependencies"); | |
| 173 | + check_command("wine", "Wine", &mut issues).await; | |
| 174 | + check_command("winetricks", "Winetricks", &mut issues).await; | |
| 175 | + check_command("steam", "Steam CLI", &mut issues).await; | |
| 176 | + check_command("xdg-open", "XDG utilities", &mut issues).await; | |
| 177 | + | |
| 178 | + // Summary | |
| 179 | + println!("\n{}", "=".repeat(50)); | |
| 180 | + if issues.is_empty() { | |
| 181 | + println!( | |
| 182 | + "\n{} All checks passed! WANDA is ready to use.", | |
| 183 | + style("SUCCESS").green().bold() | |
| 184 | + ); | |
| 185 | + } else { | |
| 186 | + println!( | |
| 187 | + "\n{} Found {} issue(s):\n", | |
| 188 | + style("ISSUES").yellow().bold(), | |
| 189 | + issues.len() | |
| 190 | + ); | |
| 191 | + for (i, issue) in issues.iter().enumerate() { | |
| 192 | + println!(" {}. {}", i + 1, issue); | |
| 193 | + } | |
| 194 | + | |
| 195 | + println!("\nSuggestions:"); | |
| 196 | + if issues.iter().any(|i| i.contains("not initialized")) { | |
| 197 | + println!(" - Run 'wanda init' to initialize WANDA"); | |
| 198 | + } | |
| 199 | + if issues.iter().any(|i| i.contains("WeMod")) { | |
| 200 | + println!(" - Run 'wanda wemod install' to install WeMod"); | |
| 201 | + } | |
| 202 | + if issues.iter().any(|i| i.contains("Prefix")) { | |
| 203 | + println!(" - Run 'wanda prefix repair' to fix prefix issues"); | |
| 204 | + } | |
| 205 | + if issues.iter().any(|i| i.contains("Proton")) { | |
| 206 | + println!(" - Install GE-Proton via ProtonUp-Qt or Steam"); | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + // Export if requested | |
| 211 | + if let Some(export_path) = args.export { | |
| 212 | + let report = format!( | |
| 213 | + "WANDA Diagnostic Report\n\nIssues:\n{}\n", | |
| 214 | + issues.join("\n") | |
| 215 | + ); | |
| 216 | + std::fs::write(&export_path, report)?; | |
| 217 | + println!("\nReport exported to: {}", export_path.display()); | |
| 218 | + } | |
| 219 | + | |
| 220 | + Ok(()) | |
| 221 | +} | |
| 222 | + | |
| 223 | +fn print_section(name: &str) { | |
| 224 | + println!("\n{}", style(format!("[{}]", name)).bold()); | |
| 225 | +} | |
| 226 | + | |
| 227 | +async fn check_command(cmd: &str, name: &str, issues: &mut Vec<String>) { | |
| 228 | + let result = tokio::process::Command::new("which") | |
| 229 | + .arg(cmd) | |
| 230 | + .output() | |
| 231 | + .await; | |
| 232 | + | |
| 233 | + match result { | |
| 234 | + Ok(output) if output.status.success() => { | |
| 235 | + let path = String::from_utf8_lossy(&output.stdout); | |
| 236 | + println!(" {} {} ({})", style("OK").green(), name, path.trim()); | |
| 237 | + } | |
| 238 | + _ => { | |
| 239 | + println!(" {} {} not found", style("WARN").yellow(), name); | |
| 240 | + issues.push(format!("{} not found in PATH", name)); | |
| 241 | + } | |
| 242 | + } | |
| 243 | +} | |
crates/wanda-cli/src/commands/init.rsadded@@ -0,0 +1,175 @@ | ||
| 1 | +//! wanda init - Initialize WANDA | |
| 2 | + | |
| 3 | +use clap::Args; | |
| 4 | +use indicatif::{ProgressBar, ProgressStyle}; | |
| 5 | +use std::path::PathBuf; | |
| 6 | +use wanda_core::{ | |
| 7 | + config::WandaConfig, | |
| 8 | + prefix::PrefixManager, | |
| 9 | + steam::{ProtonManager, SteamInstallation}, | |
| 10 | + wemod::{WemodDownloader, WemodInstaller}, | |
| 11 | + Result, WandaError, | |
| 12 | +}; | |
| 13 | + | |
| 14 | +#[derive(Args)] | |
| 15 | +pub struct InitArgs { | |
| 16 | + /// Override Steam installation path | |
| 17 | + #[arg(long)] | |
| 18 | + steam_path: Option<PathBuf>, | |
| 19 | + | |
| 20 | + /// Specify Proton version to use | |
| 21 | + #[arg(long)] | |
| 22 | + proton: Option<String>, | |
| 23 | + | |
| 24 | + /// Skip WeMod installation | |
| 25 | + #[arg(long)] | |
| 26 | + skip_wemod: bool, | |
| 27 | + | |
| 28 | + /// Force reinitialization even if already set up | |
| 29 | + #[arg(long, short)] | |
| 30 | + force: bool, | |
| 31 | +} | |
| 32 | + | |
| 33 | +pub async fn run(args: InitArgs, config_path: Option<PathBuf>) -> Result<()> { | |
| 34 | + println!("Initializing WANDA...\n"); | |
| 35 | + | |
| 36 | + // Load or create config | |
| 37 | + let mut config = match &config_path { | |
| 38 | + Some(path) => WandaConfig::load_from(path)?, | |
| 39 | + None => WandaConfig::load()?, | |
| 40 | + }; | |
| 41 | + | |
| 42 | + // Override steam path if provided | |
| 43 | + if let Some(ref path) = args.steam_path { | |
| 44 | + config.steam.install_path = Some(path.clone()); | |
| 45 | + } | |
| 46 | + | |
| 47 | + // Discover Steam | |
| 48 | + println!("Looking for Steam installation..."); | |
| 49 | + let steam = SteamInstallation::discover(&config)?; | |
| 50 | + println!( | |
| 51 | + " Found Steam at: {}{}", | |
| 52 | + steam.root_path.display(), | |
| 53 | + if steam.is_flatpak { " (Flatpak)" } else { "" } | |
| 54 | + ); | |
| 55 | + println!(" Libraries: {}", steam.libraries.len()); | |
| 56 | + | |
| 57 | + let total_games: usize = steam.libraries.iter().map(|l| l.apps.len()).sum(); | |
| 58 | + println!(" Games: {}", total_games); | |
| 59 | + | |
| 60 | + // Discover Proton | |
| 61 | + println!("\nLooking for Proton versions..."); | |
| 62 | + let proton_manager = ProtonManager::discover(&steam, &config)?; | |
| 63 | + | |
| 64 | + if proton_manager.versions.is_empty() { | |
| 65 | + return Err(WandaError::ProtonNotFound); | |
| 66 | + } | |
| 67 | + | |
| 68 | + // Select Proton version | |
| 69 | + let proton = if let Some(ref name) = args.proton { | |
| 70 | + proton_manager.find_by_name(name).ok_or_else(|| { | |
| 71 | + eprintln!("Available Proton versions:"); | |
| 72 | + for v in &proton_manager.versions { | |
| 73 | + eprintln!(" - {}", v.name); | |
| 74 | + } | |
| 75 | + WandaError::ProtonNotFound | |
| 76 | + })? | |
| 77 | + } else { | |
| 78 | + proton_manager.get_recommended().ok_or(WandaError::ProtonNotFound)? | |
| 79 | + }; | |
| 80 | + | |
| 81 | + println!(" Using: {} ({:?})", proton.name, proton.compatibility); | |
| 82 | + | |
| 83 | + // Set up prefix | |
| 84 | + println!("\nSetting up Wine prefix..."); | |
| 85 | + let mut prefix_manager = PrefixManager::new(&config); | |
| 86 | + prefix_manager.load()?; | |
| 87 | + | |
| 88 | + let existing = prefix_manager.get("default"); | |
| 89 | + if existing.is_some() && !args.force { | |
| 90 | + println!(" Prefix already exists. Use --force to reinitialize."); | |
| 91 | + } else { | |
| 92 | + if existing.is_some() && args.force { | |
| 93 | + println!(" Removing existing prefix..."); | |
| 94 | + prefix_manager.delete("default")?; | |
| 95 | + } | |
| 96 | + | |
| 97 | + println!(" Creating prefix (this may take several minutes)..."); | |
| 98 | + let pb = ProgressBar::new_spinner(); | |
| 99 | + pb.set_style( | |
| 100 | + ProgressStyle::default_spinner() | |
| 101 | + .template("{spinner:.green} {msg}") | |
| 102 | + .unwrap(), | |
| 103 | + ); | |
| 104 | + pb.set_message("Initializing Wine prefix..."); | |
| 105 | + pb.enable_steady_tick(std::time::Duration::from_millis(100)); | |
| 106 | + | |
| 107 | + prefix_manager.create("default", proton).await?; | |
| 108 | + | |
| 109 | + pb.finish_with_message("Prefix created successfully"); | |
| 110 | + } | |
| 111 | + | |
| 112 | + // Reload prefix after creation | |
| 113 | + prefix_manager.load()?; | |
| 114 | + let prefix = prefix_manager.get("default").unwrap(); | |
| 115 | + | |
| 116 | + // Install WeMod | |
| 117 | + if !args.skip_wemod { | |
| 118 | + println!("\nInstalling WeMod..."); | |
| 119 | + | |
| 120 | + let downloader = WemodDownloader::new(&config); | |
| 121 | + | |
| 122 | + // Get latest release info | |
| 123 | + let release = downloader.get_latest().await?; | |
| 124 | + if let Some(ref version) = release.version { | |
| 125 | + println!(" Latest version: {}", version); | |
| 126 | + } | |
| 127 | + | |
| 128 | + // Download with progress | |
| 129 | + let pb = ProgressBar::new(release.size.unwrap_or(0)); | |
| 130 | + pb.set_style( | |
| 131 | + ProgressStyle::default_bar() | |
| 132 | + .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})") | |
| 133 | + .unwrap() | |
| 134 | + .progress_chars("#>-"), | |
| 135 | + ); | |
| 136 | + | |
| 137 | + let installer_path = downloader | |
| 138 | + .download(&release, |downloaded, total| { | |
| 139 | + pb.set_length(total); | |
| 140 | + pb.set_position(downloaded); | |
| 141 | + }) | |
| 142 | + .await?; | |
| 143 | + | |
| 144 | + pb.finish_with_message("Download complete"); | |
| 145 | + | |
| 146 | + // Install | |
| 147 | + println!(" Installing WeMod..."); | |
| 148 | + let installer = WemodInstaller::new(prefix, proton); | |
| 149 | + installer.install(&installer_path).await?; | |
| 150 | + | |
| 151 | + // Update prefix metadata | |
| 152 | + let version = release.version.clone(); | |
| 153 | + prefix_manager.update_metadata("default", |p| { | |
| 154 | + p.wemod_installed = true; | |
| 155 | + p.wemod_version = version; | |
| 156 | + })?; | |
| 157 | + | |
| 158 | + println!(" WeMod installed successfully"); | |
| 159 | + } | |
| 160 | + | |
| 161 | + // Save config | |
| 162 | + config.proton.preferred_version = Some(proton.name.clone()); | |
| 163 | + match &config_path { | |
| 164 | + Some(path) => config.save_to(path)?, | |
| 165 | + None => config.save()?, | |
| 166 | + } | |
| 167 | + | |
| 168 | + println!("\nWANDA initialization complete!"); | |
| 169 | + println!("\nNext steps:"); | |
| 170 | + println!(" wanda scan - View available games"); | |
| 171 | + println!(" wanda launch <game> - Launch a game with WeMod"); | |
| 172 | + println!(" wanda doctor - Check for issues"); | |
| 173 | + | |
| 174 | + Ok(()) | |
| 175 | +} | |
crates/wanda-cli/src/commands/launch.rsadded@@ -0,0 +1,149 @@ | ||
| 1 | +//! wanda launch - Launch a game with WeMod | |
| 2 | + | |
| 3 | +use clap::Args; | |
| 4 | +use console::style; | |
| 5 | +use std::path::PathBuf; | |
| 6 | +use wanda_core::{ | |
| 7 | + config::WandaConfig, | |
| 8 | + launcher::{GameLauncher, LaunchConfig}, | |
| 9 | + prefix::PrefixManager, | |
| 10 | + steam::{ProtonManager, SteamInstallation}, | |
| 11 | + Result, WandaError, | |
| 12 | +}; | |
| 13 | + | |
| 14 | +#[derive(Args)] | |
| 15 | +pub struct LaunchArgs { | |
| 16 | + /// Game name or App ID | |
| 17 | + game: String, | |
| 18 | + | |
| 19 | + /// Launch without WeMod | |
| 20 | + #[arg(long)] | |
| 21 | + no_wemod: bool, | |
| 22 | + | |
| 23 | + /// Delay in seconds before launching game (after WeMod starts) | |
| 24 | + #[arg(long, default_value = "3")] | |
| 25 | + delay: u64, | |
| 26 | + | |
| 27 | + /// Additional arguments to pass to the game | |
| 28 | + #[arg(long)] | |
| 29 | + args: Option<String>, | |
| 30 | + | |
| 31 | + /// Wait for the game to exit | |
| 32 | + #[arg(long, short)] | |
| 33 | + wait: bool, | |
| 34 | +} | |
| 35 | + | |
| 36 | +pub async fn run(args: LaunchArgs, config_path: Option<PathBuf>) -> Result<()> { | |
| 37 | + let config = match &config_path { | |
| 38 | + Some(path) => WandaConfig::load_from(path)?, | |
| 39 | + None => WandaConfig::load()?, | |
| 40 | + }; | |
| 41 | + | |
| 42 | + // Discover Steam and find the game | |
| 43 | + let steam = SteamInstallation::discover(&config)?; | |
| 44 | + | |
| 45 | + // Try to parse as App ID first | |
| 46 | + let game = if let Ok(app_id) = args.game.parse::<u32>() { | |
| 47 | + steam.find_game(app_id).ok_or(WandaError::GameNotFound { app_id })? | |
| 48 | + } else { | |
| 49 | + // Search by name | |
| 50 | + let matches = steam.find_game_by_name(&args.game); | |
| 51 | + match matches.len() { | |
| 52 | + 0 => { | |
| 53 | + return Err(WandaError::LaunchFailed { | |
| 54 | + reason: format!("No game found matching '{}'", args.game), | |
| 55 | + }) | |
| 56 | + } | |
| 57 | + 1 => matches[0], | |
| 58 | + _ => { | |
| 59 | + println!("Multiple games found matching '{}':", args.game); | |
| 60 | + for game in &matches { | |
| 61 | + println!(" {} - {}", game.app_id, game.name); | |
| 62 | + } | |
| 63 | + return Err(WandaError::LaunchFailed { | |
| 64 | + reason: "Please specify the exact App ID".to_string(), | |
| 65 | + }); | |
| 66 | + } | |
| 67 | + } | |
| 68 | + }; | |
| 69 | + | |
| 70 | + println!( | |
| 71 | + "Launching {} {}", | |
| 72 | + style(&game.name).bold(), | |
| 73 | + style(format!("({})", game.app_id)).dim() | |
| 74 | + ); | |
| 75 | + | |
| 76 | + // Check if launching with WeMod | |
| 77 | + let with_wemod = !args.no_wemod; | |
| 78 | + | |
| 79 | + if with_wemod { | |
| 80 | + // Load WANDA prefix | |
| 81 | + let mut prefix_manager = PrefixManager::new(&config); | |
| 82 | + prefix_manager.load()?; | |
| 83 | + | |
| 84 | + let prefix = prefix_manager.get("default").ok_or_else(|| WandaError::LaunchFailed { | |
| 85 | + reason: "WANDA not initialized. Run 'wanda init' first.".to_string(), | |
| 86 | + })?; | |
| 87 | + | |
| 88 | + // Check if WeMod is installed | |
| 89 | + if !prefix.wemod_installed { | |
| 90 | + return Err(WandaError::WemodNotInstalled); | |
| 91 | + } | |
| 92 | + | |
| 93 | + // Get Proton version | |
| 94 | + let proton_manager = ProtonManager::discover(&steam, &config)?; | |
| 95 | + let proton = proton_manager.get_preferred(&config)?; | |
| 96 | + | |
| 97 | + println!(" Using WeMod with {}", proton.name); | |
| 98 | + | |
| 99 | + // Create launcher | |
| 100 | + let launcher = GameLauncher::new(&steam, prefix, proton); | |
| 101 | + | |
| 102 | + let launch_config = LaunchConfig { | |
| 103 | + app_id: game.app_id, | |
| 104 | + with_wemod: true, | |
| 105 | + wemod_delay: args.delay, | |
| 106 | + extra_args: args | |
| 107 | + .args | |
| 108 | + .map(|a| a.split_whitespace().map(String::from).collect()) | |
| 109 | + .unwrap_or_default(), | |
| 110 | + ..Default::default() | |
| 111 | + }; | |
| 112 | + | |
| 113 | + let mut handle = launcher.launch(launch_config).await?; | |
| 114 | + | |
| 115 | + println!( | |
| 116 | + "\n{} Game launched with WeMod!", | |
| 117 | + style("SUCCESS").green().bold() | |
| 118 | + ); | |
| 119 | + println!("\nWeMod should appear in a separate window."); | |
| 120 | + println!("Select your game in WeMod and click Play to activate trainers.\n"); | |
| 121 | + | |
| 122 | + if args.wait { | |
| 123 | + println!("Waiting for session to end..."); | |
| 124 | + handle.wait().await?; | |
| 125 | + println!( | |
| 126 | + "Session ended. Play time: {:?}", | |
| 127 | + handle.elapsed() | |
| 128 | + ); | |
| 129 | + } | |
| 130 | + } else { | |
| 131 | + // Launch without WeMod (just use Steam) | |
| 132 | + println!(" Launching via Steam (without WeMod)..."); | |
| 133 | + | |
| 134 | + let steam_url = format!("steam://rungameid/{}", game.app_id); | |
| 135 | + let status = tokio::process::Command::new("xdg-open") | |
| 136 | + .arg(&steam_url) | |
| 137 | + .status() | |
| 138 | + .await | |
| 139 | + .map_err(|e| WandaError::LaunchFailed { | |
| 140 | + reason: format!("Failed to launch: {}", e), | |
| 141 | + })?; | |
| 142 | + | |
| 143 | + if status.success() { | |
| 144 | + println!("\n{} Game launched!", style("SUCCESS").green().bold()); | |
| 145 | + } | |
| 146 | + } | |
| 147 | + | |
| 148 | + Ok(()) | |
| 149 | +} | |
crates/wanda-cli/src/commands/mod.rsadded@@ -0,0 +1,9 @@ | ||
| 1 | +//! CLI command implementations | |
| 2 | + | |
| 3 | +pub mod config; | |
| 4 | +pub mod doctor; | |
| 5 | +pub mod init; | |
| 6 | +pub mod launch; | |
| 7 | +pub mod prefix; | |
| 8 | +pub mod scan; | |
| 9 | +pub mod wemod; | |
crates/wanda-cli/src/commands/prefix.rsadded@@ -0,0 +1,301 @@ | ||
| 1 | +//! wanda prefix - Manage Wine/Proton prefixes | |
| 2 | + | |
| 3 | +use clap::{Args, Subcommand}; | |
| 4 | +use console::style; | |
| 5 | +use std::path::PathBuf; | |
| 6 | +use wanda_core::{ | |
| 7 | + config::WandaConfig, | |
| 8 | + prefix::{PrefixHealth, PrefixManager}, | |
| 9 | + steam::{ProtonManager, SteamInstallation}, | |
| 10 | + Result, WandaError, | |
| 11 | +}; | |
| 12 | + | |
| 13 | +#[derive(Subcommand)] | |
| 14 | +pub enum PrefixCommands { | |
| 15 | + /// List all WANDA prefixes | |
| 16 | + List, | |
| 17 | + | |
| 18 | + /// Create a new prefix | |
| 19 | + Create(CreateArgs), | |
| 20 | + | |
| 21 | + /// Delete a prefix | |
| 22 | + Delete(DeleteArgs), | |
| 23 | + | |
| 24 | + /// Repair a prefix | |
| 25 | + Repair(RepairArgs), | |
| 26 | + | |
| 27 | + /// Validate prefix health | |
| 28 | + Validate(ValidateArgs), | |
| 29 | + | |
| 30 | + /// Show prefix details | |
| 31 | + Info(InfoArgs), | |
| 32 | +} | |
| 33 | + | |
| 34 | +#[derive(Args)] | |
| 35 | +pub struct CreateArgs { | |
| 36 | + /// Name for the new prefix | |
| 37 | + name: String, | |
| 38 | +} | |
| 39 | + | |
| 40 | +#[derive(Args)] | |
| 41 | +pub struct DeleteArgs { | |
| 42 | + /// Name of the prefix to delete | |
| 43 | + name: String, | |
| 44 | + | |
| 45 | + /// Skip confirmation | |
| 46 | + #[arg(long, short)] | |
| 47 | + force: bool, | |
| 48 | +} | |
| 49 | + | |
| 50 | +#[derive(Args)] | |
| 51 | +pub struct RepairArgs { | |
| 52 | + /// Name of the prefix to repair (default: "default") | |
| 53 | + #[arg(default_value = "default")] | |
| 54 | + name: String, | |
| 55 | +} | |
| 56 | + | |
| 57 | +#[derive(Args)] | |
| 58 | +pub struct ValidateArgs { | |
| 59 | + /// Name of the prefix to validate (default: "default") | |
| 60 | + #[arg(default_value = "default")] | |
| 61 | + name: String, | |
| 62 | +} | |
| 63 | + | |
| 64 | +#[derive(Args)] | |
| 65 | +pub struct InfoArgs { | |
| 66 | + /// Name of the prefix (default: "default") | |
| 67 | + #[arg(default_value = "default")] | |
| 68 | + name: String, | |
| 69 | +} | |
| 70 | + | |
| 71 | +pub async fn run(cmd: PrefixCommands, config_path: Option<PathBuf>) -> Result<()> { | |
| 72 | + let config = match &config_path { | |
| 73 | + Some(path) => WandaConfig::load_from(path)?, | |
| 74 | + None => WandaConfig::load()?, | |
| 75 | + }; | |
| 76 | + | |
| 77 | + let mut prefix_manager = PrefixManager::new(&config); | |
| 78 | + prefix_manager.load()?; | |
| 79 | + | |
| 80 | + match cmd { | |
| 81 | + PrefixCommands::List => list(&prefix_manager), | |
| 82 | + PrefixCommands::Create(args) => create(&mut prefix_manager, &config, args).await, | |
| 83 | + PrefixCommands::Delete(args) => delete(&mut prefix_manager, args), | |
| 84 | + PrefixCommands::Repair(args) => repair(&mut prefix_manager, &config, args).await, | |
| 85 | + PrefixCommands::Validate(args) => validate(&prefix_manager, args), | |
| 86 | + PrefixCommands::Info(args) => info(&prefix_manager, args), | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +fn list(manager: &PrefixManager) -> Result<()> { | |
| 91 | + let prefixes = manager.list(); | |
| 92 | + | |
| 93 | + if prefixes.is_empty() { | |
| 94 | + println!("No prefixes found."); | |
| 95 | + println!("Run 'wanda init' to create the default prefix."); | |
| 96 | + return Ok(()); | |
| 97 | + } | |
| 98 | + | |
| 99 | + println!("WANDA Prefixes:\n"); | |
| 100 | + | |
| 101 | + for prefix in prefixes { | |
| 102 | + let health = manager.validate(&prefix.name)?; | |
| 103 | + let health_indicator = match health { | |
| 104 | + PrefixHealth::Healthy => style("HEALTHY").green(), | |
| 105 | + PrefixHealth::NeedsRepair(_) => style("NEEDS REPAIR").yellow(), | |
| 106 | + PrefixHealth::Corrupted(_) => style("CORRUPTED").red(), | |
| 107 | + PrefixHealth::NotCreated => style("NOT CREATED").dim(), | |
| 108 | + }; | |
| 109 | + | |
| 110 | + let wemod_status = if prefix.wemod_installed { | |
| 111 | + format!( | |
| 112 | + "WeMod {}", | |
| 113 | + prefix.wemod_version.as_deref().unwrap_or("(unknown version)") | |
| 114 | + ) | |
| 115 | + } else { | |
| 116 | + "WeMod not installed".to_string() | |
| 117 | + }; | |
| 118 | + | |
| 119 | + println!( | |
| 120 | + " {} {}", | |
| 121 | + style(&prefix.name).bold(), | |
| 122 | + health_indicator | |
| 123 | + ); | |
| 124 | + println!(" Path: {}", prefix.path.display()); | |
| 125 | + println!(" {}", wemod_status); | |
| 126 | + if let Some(ref proton) = prefix.proton_version { | |
| 127 | + println!(" Proton: {}", proton); | |
| 128 | + } | |
| 129 | + println!(); | |
| 130 | + } | |
| 131 | + | |
| 132 | + Ok(()) | |
| 133 | +} | |
| 134 | + | |
| 135 | +async fn create( | |
| 136 | + manager: &mut PrefixManager, | |
| 137 | + config: &WandaConfig, | |
| 138 | + args: CreateArgs, | |
| 139 | +) -> Result<()> { | |
| 140 | + if manager.get(&args.name).is_some() { | |
| 141 | + return Err(WandaError::PrefixCreationFailed { | |
| 142 | + path: manager.base_path.join(&args.name), | |
| 143 | + reason: "Prefix already exists".to_string(), | |
| 144 | + }); | |
| 145 | + } | |
| 146 | + | |
| 147 | + println!("Creating prefix '{}'...", args.name); | |
| 148 | + | |
| 149 | + // Get Proton version | |
| 150 | + let steam = SteamInstallation::discover(config)?; | |
| 151 | + let proton_manager = ProtonManager::discover(&steam, config)?; | |
| 152 | + let proton = proton_manager.get_preferred(config)?; | |
| 153 | + | |
| 154 | + println!(" Using Proton: {}", proton.name); | |
| 155 | + println!(" This may take several minutes..."); | |
| 156 | + | |
| 157 | + manager.create(&args.name, proton).await?; | |
| 158 | + | |
| 159 | + println!("\n{} Prefix '{}' created successfully!", | |
| 160 | + style("SUCCESS").green().bold(), | |
| 161 | + args.name | |
| 162 | + ); | |
| 163 | + | |
| 164 | + Ok(()) | |
| 165 | +} | |
| 166 | + | |
| 167 | +fn delete(manager: &mut PrefixManager, args: DeleteArgs) -> Result<()> { | |
| 168 | + let prefix = manager.get(&args.name).ok_or_else(|| WandaError::PrefixNotFound { | |
| 169 | + path: manager.base_path.join(&args.name), | |
| 170 | + })?; | |
| 171 | + | |
| 172 | + if !args.force { | |
| 173 | + println!( | |
| 174 | + "Are you sure you want to delete prefix '{}'?", | |
| 175 | + args.name | |
| 176 | + ); | |
| 177 | + println!(" Path: {}", prefix.path.display()); | |
| 178 | + println!("\nThis action cannot be undone."); | |
| 179 | + println!("Use --force to skip this confirmation."); | |
| 180 | + return Ok(()); | |
| 181 | + } | |
| 182 | + | |
| 183 | + manager.delete(&args.name)?; | |
| 184 | + println!("Prefix '{}' deleted.", args.name); | |
| 185 | + | |
| 186 | + Ok(()) | |
| 187 | +} | |
| 188 | + | |
| 189 | +async fn repair( | |
| 190 | + manager: &mut PrefixManager, | |
| 191 | + config: &WandaConfig, | |
| 192 | + args: RepairArgs, | |
| 193 | +) -> Result<()> { | |
| 194 | + let health = manager.validate(&args.name)?; | |
| 195 | + | |
| 196 | + match health { | |
| 197 | + PrefixHealth::Healthy => { | |
| 198 | + println!("Prefix '{}' is healthy, no repair needed.", args.name); | |
| 199 | + return Ok(()); | |
| 200 | + } | |
| 201 | + PrefixHealth::NotCreated => { | |
| 202 | + println!("Prefix '{}' doesn't exist.", args.name); | |
| 203 | + return Ok(()); | |
| 204 | + } | |
| 205 | + PrefixHealth::Corrupted(reason) => { | |
| 206 | + println!( | |
| 207 | + "Prefix '{}' is corrupted: {}", | |
| 208 | + args.name, reason | |
| 209 | + ); | |
| 210 | + println!("Consider deleting and recreating the prefix."); | |
| 211 | + return Err(WandaError::PrefixCorrupted { | |
| 212 | + path: manager.base_path.join(&args.name), | |
| 213 | + reason, | |
| 214 | + }); | |
| 215 | + } | |
| 216 | + PrefixHealth::NeedsRepair(issues) => { | |
| 217 | + println!("Repairing prefix '{}'...", args.name); | |
| 218 | + for issue in &issues { | |
| 219 | + println!(" - {}", issue); | |
| 220 | + } | |
| 221 | + } | |
| 222 | + } | |
| 223 | + | |
| 224 | + let steam = SteamInstallation::discover(config)?; | |
| 225 | + let proton_manager = ProtonManager::discover(&steam, config)?; | |
| 226 | + let proton = proton_manager.get_preferred(config)?; | |
| 227 | + | |
| 228 | + manager.repair(&args.name, proton).await?; | |
| 229 | + | |
| 230 | + println!("\n{} Prefix repaired!", style("SUCCESS").green().bold()); | |
| 231 | + | |
| 232 | + Ok(()) | |
| 233 | +} | |
| 234 | + | |
| 235 | +fn validate(manager: &PrefixManager, args: ValidateArgs) -> Result<()> { | |
| 236 | + let health = manager.validate(&args.name)?; | |
| 237 | + | |
| 238 | + println!("Prefix '{}' health check:\n", args.name); | |
| 239 | + | |
| 240 | + match health { | |
| 241 | + PrefixHealth::Healthy => { | |
| 242 | + println!(" {} All checks passed!", style("HEALTHY").green().bold()); | |
| 243 | + } | |
| 244 | + PrefixHealth::NeedsRepair(issues) => { | |
| 245 | + println!(" {} Issues found:", style("NEEDS REPAIR").yellow().bold()); | |
| 246 | + for issue in issues { | |
| 247 | + println!(" - {}", issue); | |
| 248 | + } | |
| 249 | + println!("\nRun 'wanda prefix repair {}' to fix.", args.name); | |
| 250 | + } | |
| 251 | + PrefixHealth::Corrupted(reason) => { | |
| 252 | + println!(" {} {}", style("CORRUPTED").red().bold(), reason); | |
| 253 | + println!("\nConsider deleting and recreating the prefix."); | |
| 254 | + } | |
| 255 | + PrefixHealth::NotCreated => { | |
| 256 | + println!(" {} Prefix has not been created yet.", style("NOT CREATED").dim()); | |
| 257 | + println!("\nRun 'wanda init' to create the default prefix."); | |
| 258 | + } | |
| 259 | + } | |
| 260 | + | |
| 261 | + Ok(()) | |
| 262 | +} | |
| 263 | + | |
| 264 | +fn info(manager: &PrefixManager, args: InfoArgs) -> Result<()> { | |
| 265 | + let prefix = manager.get(&args.name).ok_or_else(|| WandaError::PrefixNotFound { | |
| 266 | + path: manager.base_path.join(&args.name), | |
| 267 | + })?; | |
| 268 | + | |
| 269 | + let health = manager.validate(&args.name)?; | |
| 270 | + | |
| 271 | + println!("Prefix: {}\n", style(&args.name).bold()); | |
| 272 | + println!(" Path: {}", prefix.path.display()); | |
| 273 | + println!( | |
| 274 | + " Health: {:?}", | |
| 275 | + match health { | |
| 276 | + PrefixHealth::Healthy => style("Healthy").green(), | |
| 277 | + PrefixHealth::NeedsRepair(_) => style("Needs Repair").yellow(), | |
| 278 | + PrefixHealth::Corrupted(_) => style("Corrupted").red(), | |
| 279 | + PrefixHealth::NotCreated => style("Not Created").dim(), | |
| 280 | + } | |
| 281 | + ); | |
| 282 | + println!( | |
| 283 | + " WeMod: {}", | |
| 284 | + if prefix.wemod_installed { | |
| 285 | + format!( | |
| 286 | + "Installed ({})", | |
| 287 | + prefix.wemod_version.as_deref().unwrap_or("unknown") | |
| 288 | + ) | |
| 289 | + } else { | |
| 290 | + "Not installed".to_string() | |
| 291 | + } | |
| 292 | + ); | |
| 293 | + if let Some(ref proton) = prefix.proton_version { | |
| 294 | + println!(" Proton: {}", proton); | |
| 295 | + } | |
| 296 | + if let Some(ref created) = prefix.created_at { | |
| 297 | + println!(" Created: {}", created); | |
| 298 | + } | |
| 299 | + | |
| 300 | + Ok(()) | |
| 301 | +} | |
crates/wanda-cli/src/commands/scan.rsadded@@ -0,0 +1,136 @@ | ||
| 1 | +//! wanda scan - Scan for Steam games | |
| 2 | + | |
| 3 | +use clap::Args; | |
| 4 | +use console::style; | |
| 5 | +use std::path::PathBuf; | |
| 6 | +use wanda_core::{config::WandaConfig, steam::SteamInstallation, Result}; | |
| 7 | + | |
| 8 | +#[derive(Args)] | |
| 9 | +pub struct ScanArgs { | |
| 10 | + /// Filter games by name | |
| 11 | + #[arg(short, long)] | |
| 12 | + filter: Option<String>, | |
| 13 | + | |
| 14 | + /// Show all games, including non-Proton | |
| 15 | + #[arg(long)] | |
| 16 | + all: bool, | |
| 17 | + | |
| 18 | + /// Show detailed information | |
| 19 | + #[arg(short, long)] | |
| 20 | + long: bool, | |
| 21 | +} | |
| 22 | + | |
| 23 | +pub async fn run(args: ScanArgs, config_path: Option<PathBuf>) -> Result<()> { | |
| 24 | + let config = match &config_path { | |
| 25 | + Some(path) => WandaConfig::load_from(path)?, | |
| 26 | + None => WandaConfig::load()?, | |
| 27 | + }; | |
| 28 | + | |
| 29 | + let steam = SteamInstallation::discover(&config)?; | |
| 30 | + let mut games: Vec<_> = steam.get_all_games(); | |
| 31 | + | |
| 32 | + // Filter by name if specified | |
| 33 | + if let Some(ref filter) = args.filter { | |
| 34 | + let filter_lower = filter.to_lowercase(); | |
| 35 | + games.retain(|g| g.name.to_lowercase().contains(&filter_lower)); | |
| 36 | + } | |
| 37 | + | |
| 38 | + // Filter to Proton games unless --all | |
| 39 | + if !args.all { | |
| 40 | + games.retain(|g| g.uses_proton()); | |
| 41 | + } | |
| 42 | + | |
| 43 | + // Sort by name | |
| 44 | + games.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); | |
| 45 | + | |
| 46 | + if games.is_empty() { | |
| 47 | + println!("No games found."); | |
| 48 | + if !args.all { | |
| 49 | + println!("(Use --all to show non-Proton games)"); | |
| 50 | + } | |
| 51 | + return Ok(()); | |
| 52 | + } | |
| 53 | + | |
| 54 | + println!( | |
| 55 | + "Found {} games{}:\n", | |
| 56 | + games.len(), | |
| 57 | + if !args.all { | |
| 58 | + " (Proton/Windows)" | |
| 59 | + } else { | |
| 60 | + "" | |
| 61 | + } | |
| 62 | + ); | |
| 63 | + | |
| 64 | + if args.long { | |
| 65 | + // Detailed view | |
| 66 | + for game in &games { | |
| 67 | + let proton_indicator = if game.uses_proton() { | |
| 68 | + style("PROTON").green() | |
| 69 | + } else { | |
| 70 | + style("NATIVE").blue() | |
| 71 | + }; | |
| 72 | + | |
| 73 | + println!( | |
| 74 | + "{} {} {}", | |
| 75 | + style(format!("[{}]", game.app_id)).dim(), | |
| 76 | + style(&game.name).bold(), | |
| 77 | + proton_indicator | |
| 78 | + ); | |
| 79 | + println!(" Path: {}", game.install_path.display()); | |
| 80 | + println!(" Size: {}", game.size_human()); | |
| 81 | + if let Some(ref compat) = game.compat_data_path { | |
| 82 | + println!(" Prefix: {}", compat.display()); | |
| 83 | + } | |
| 84 | + println!(); | |
| 85 | + } | |
| 86 | + } else { | |
| 87 | + // Compact view | |
| 88 | + let max_name_len = games | |
| 89 | + .iter() | |
| 90 | + .map(|g| g.name.len()) | |
| 91 | + .max() | |
| 92 | + .unwrap_or(20) | |
| 93 | + .min(50); | |
| 94 | + | |
| 95 | + println!( | |
| 96 | + "{:>8} {:<width$} {:>10} {}", | |
| 97 | + "App ID", | |
| 98 | + "Name", | |
| 99 | + "Size", | |
| 100 | + "Type", | |
| 101 | + width = max_name_len | |
| 102 | + ); | |
| 103 | + println!("{}", "-".repeat(8 + 2 + max_name_len + 2 + 10 + 2 + 8)); | |
| 104 | + | |
| 105 | + for game in &games { | |
| 106 | + let name = if game.name.len() > max_name_len { | |
| 107 | + format!("{}...", &game.name[..max_name_len - 3]) | |
| 108 | + } else { | |
| 109 | + game.name.clone() | |
| 110 | + }; | |
| 111 | + | |
| 112 | + let game_type = if game.uses_proton() { | |
| 113 | + style("Proton").green() | |
| 114 | + } else { | |
| 115 | + style("Native").blue() | |
| 116 | + }; | |
| 117 | + | |
| 118 | + println!( | |
| 119 | + "{:>8} {:<width$} {:>10} {}", | |
| 120 | + game.app_id, | |
| 121 | + name, | |
| 122 | + game.size_human(), | |
| 123 | + game_type, | |
| 124 | + width = max_name_len | |
| 125 | + ); | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + println!(); | |
| 130 | + println!( | |
| 131 | + "Launch a game with: {} <name or app_id>", | |
| 132 | + style("wanda launch").cyan() | |
| 133 | + ); | |
| 134 | + | |
| 135 | + Ok(()) | |
| 136 | +} | |
crates/wanda-cli/src/commands/wemod.rsadded@@ -0,0 +1,299 @@ | ||
| 1 | +//! wanda wemod - Manage WeMod installation | |
| 2 | + | |
| 3 | +use clap::Subcommand; | |
| 4 | +use console::style; | |
| 5 | +use indicatif::{ProgressBar, ProgressStyle}; | |
| 6 | +use std::path::PathBuf; | |
| 7 | +use wanda_core::{ | |
| 8 | + config::WandaConfig, | |
| 9 | + prefix::PrefixManager, | |
| 10 | + steam::{ProtonManager, SteamInstallation}, | |
| 11 | + wemod::{WemodDownloader, WemodInstaller}, | |
| 12 | + Result, WandaError, | |
| 13 | +}; | |
| 14 | + | |
| 15 | +#[derive(Subcommand)] | |
| 16 | +pub enum WemodCommands { | |
| 17 | + /// Show WeMod installation status | |
| 18 | + Status, | |
| 19 | + | |
| 20 | + /// Update WeMod to the latest version | |
| 21 | + Update, | |
| 22 | + | |
| 23 | + /// Install WeMod into a prefix | |
| 24 | + Install, | |
| 25 | + | |
| 26 | + /// Uninstall WeMod from a prefix | |
| 27 | + Uninstall, | |
| 28 | + | |
| 29 | + /// Run WeMod standalone (for testing) | |
| 30 | + Run, | |
| 31 | +} | |
| 32 | + | |
| 33 | +pub async fn run(cmd: WemodCommands, config_path: Option<PathBuf>) -> Result<()> { | |
| 34 | + let config = match &config_path { | |
| 35 | + Some(path) => WandaConfig::load_from(path)?, | |
| 36 | + None => WandaConfig::load()?, | |
| 37 | + }; | |
| 38 | + | |
| 39 | + match cmd { | |
| 40 | + WemodCommands::Status => status(&config).await, | |
| 41 | + WemodCommands::Update => update(&config).await, | |
| 42 | + WemodCommands::Install => install(&config).await, | |
| 43 | + WemodCommands::Uninstall => uninstall(&config).await, | |
| 44 | + WemodCommands::Run => run_wemod(&config).await, | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +async fn status(config: &WandaConfig) -> Result<()> { | |
| 49 | + let mut prefix_manager = PrefixManager::new(config); | |
| 50 | + prefix_manager.load()?; | |
| 51 | + | |
| 52 | + let prefix = prefix_manager.get("default"); | |
| 53 | + | |
| 54 | + println!("WeMod Status:\n"); | |
| 55 | + | |
| 56 | + match prefix { | |
| 57 | + Some(p) if p.wemod_installed => { | |
| 58 | + println!( | |
| 59 | + " Status: {} Installed", | |
| 60 | + style("OK").green().bold() | |
| 61 | + ); | |
| 62 | + if let Some(ref version) = p.wemod_version { | |
| 63 | + println!(" Version: {}", version); | |
| 64 | + } | |
| 65 | + println!(" Path: {}", p.wemod_path().display()); | |
| 66 | + | |
| 67 | + // Check for updates | |
| 68 | + println!("\nChecking for updates..."); | |
| 69 | + let downloader = WemodDownloader::new(config); | |
| 70 | + match downloader.get_latest().await { | |
| 71 | + Ok(release) => { | |
| 72 | + if let Some(ref latest) = release.version { | |
| 73 | + let current = p.wemod_version.as_deref().unwrap_or("unknown"); | |
| 74 | + if current != latest { | |
| 75 | + println!( | |
| 76 | + " {} Update available: {} -> {}", | |
| 77 | + style("UPDATE").yellow(), | |
| 78 | + current, | |
| 79 | + latest | |
| 80 | + ); | |
| 81 | + println!("\n Run 'wanda wemod update' to update."); | |
| 82 | + } else { | |
| 83 | + println!(" {} Up to date", style("OK").green()); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + } | |
| 87 | + Err(e) => { | |
| 88 | + println!(" Could not check for updates: {}", e); | |
| 89 | + } | |
| 90 | + } | |
| 91 | + } | |
| 92 | + Some(_) => { | |
| 93 | + println!( | |
| 94 | + " Status: {} Not installed", | |
| 95 | + style("WARNING").yellow().bold() | |
| 96 | + ); | |
| 97 | + println!("\n Run 'wanda wemod install' to install WeMod."); | |
| 98 | + } | |
| 99 | + None => { | |
| 100 | + println!( | |
| 101 | + " Status: {} WANDA not initialized", | |
| 102 | + style("ERROR").red().bold() | |
| 103 | + ); | |
| 104 | + println!("\n Run 'wanda init' first."); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + Ok(()) | |
| 109 | +} | |
| 110 | + | |
| 111 | +async fn update(config: &WandaConfig) -> Result<()> { | |
| 112 | + let mut prefix_manager = PrefixManager::new(config); | |
| 113 | + prefix_manager.load()?; | |
| 114 | + | |
| 115 | + let prefix = prefix_manager.get("default").ok_or_else(|| WandaError::LaunchFailed { | |
| 116 | + reason: "WANDA not initialized. Run 'wanda init' first.".to_string(), | |
| 117 | + })?; | |
| 118 | + | |
| 119 | + println!("Checking for WeMod updates...\n"); | |
| 120 | + | |
| 121 | + let downloader = WemodDownloader::new(config); | |
| 122 | + let release = downloader.get_latest().await?; | |
| 123 | + | |
| 124 | + let current = prefix.wemod_version.as_deref().unwrap_or("unknown"); | |
| 125 | + let latest = release.version.as_deref().unwrap_or("unknown"); | |
| 126 | + | |
| 127 | + if current == latest { | |
| 128 | + println!("WeMod is already up to date (version {}).", current); | |
| 129 | + return Ok(()); | |
| 130 | + } | |
| 131 | + | |
| 132 | + println!("Current version: {}", current); | |
| 133 | + println!("Latest version: {}", latest); | |
| 134 | + println!("\nDownloading update..."); | |
| 135 | + | |
| 136 | + let pb = ProgressBar::new(release.size.unwrap_or(0)); | |
| 137 | + pb.set_style( | |
| 138 | + ProgressStyle::default_bar() | |
| 139 | + .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})") | |
| 140 | + .unwrap() | |
| 141 | + .progress_chars("#>-"), | |
| 142 | + ); | |
| 143 | + | |
| 144 | + let installer_path = downloader | |
| 145 | + .download(&release, |downloaded, total| { | |
| 146 | + pb.set_length(total); | |
| 147 | + pb.set_position(downloaded); | |
| 148 | + }) | |
| 149 | + .await?; | |
| 150 | + | |
| 151 | + pb.finish_with_message("Download complete"); | |
| 152 | + | |
| 153 | + println!("\nInstalling update..."); | |
| 154 | + | |
| 155 | + let steam = SteamInstallation::discover(config)?; | |
| 156 | + let proton_manager = ProtonManager::discover(&steam, config)?; | |
| 157 | + let proton = proton_manager.get_preferred(config)?; | |
| 158 | + | |
| 159 | + let installer = WemodInstaller::new(prefix, proton); | |
| 160 | + installer.install(&installer_path).await?; | |
| 161 | + | |
| 162 | + // Update metadata | |
| 163 | + let version = release.version.clone(); | |
| 164 | + prefix_manager.update_metadata("default", |p| { | |
| 165 | + p.wemod_version = version; | |
| 166 | + })?; | |
| 167 | + | |
| 168 | + println!( | |
| 169 | + "\n{} WeMod updated to version {}!", | |
| 170 | + style("SUCCESS").green().bold(), | |
| 171 | + latest | |
| 172 | + ); | |
| 173 | + | |
| 174 | + Ok(()) | |
| 175 | +} | |
| 176 | + | |
| 177 | +async fn install(config: &WandaConfig) -> Result<()> { | |
| 178 | + let mut prefix_manager = PrefixManager::new(config); | |
| 179 | + prefix_manager.load()?; | |
| 180 | + | |
| 181 | + let prefix = prefix_manager.get("default").ok_or_else(|| WandaError::LaunchFailed { | |
| 182 | + reason: "WANDA not initialized. Run 'wanda init' first.".to_string(), | |
| 183 | + })?; | |
| 184 | + | |
| 185 | + if prefix.wemod_installed { | |
| 186 | + println!("WeMod is already installed."); | |
| 187 | + println!("Use 'wanda wemod update' to update to the latest version."); | |
| 188 | + return Ok(()); | |
| 189 | + } | |
| 190 | + | |
| 191 | + println!("Installing WeMod...\n"); | |
| 192 | + | |
| 193 | + let downloader = WemodDownloader::new(config); | |
| 194 | + let release = downloader.get_latest().await?; | |
| 195 | + | |
| 196 | + if let Some(ref version) = release.version { | |
| 197 | + println!("Version: {}", version); | |
| 198 | + } | |
| 199 | + | |
| 200 | + let pb = ProgressBar::new(release.size.unwrap_or(0)); | |
| 201 | + pb.set_style( | |
| 202 | + ProgressStyle::default_bar() | |
| 203 | + .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})") | |
| 204 | + .unwrap() | |
| 205 | + .progress_chars("#>-"), | |
| 206 | + ); | |
| 207 | + | |
| 208 | + let installer_path = downloader | |
| 209 | + .download(&release, |downloaded, total| { | |
| 210 | + pb.set_length(total); | |
| 211 | + pb.set_position(downloaded); | |
| 212 | + }) | |
| 213 | + .await?; | |
| 214 | + | |
| 215 | + pb.finish_with_message("Download complete"); | |
| 216 | + | |
| 217 | + println!("\nInstalling..."); | |
| 218 | + | |
| 219 | + let steam = SteamInstallation::discover(config)?; | |
| 220 | + let proton_manager = ProtonManager::discover(&steam, config)?; | |
| 221 | + let proton = proton_manager.get_preferred(config)?; | |
| 222 | + | |
| 223 | + let installer = WemodInstaller::new(prefix, proton); | |
| 224 | + installer.install(&installer_path).await?; | |
| 225 | + | |
| 226 | + let version = release.version.clone(); | |
| 227 | + prefix_manager.update_metadata("default", |p| { | |
| 228 | + p.wemod_installed = true; | |
| 229 | + p.wemod_version = version; | |
| 230 | + })?; | |
| 231 | + | |
| 232 | + println!( | |
| 233 | + "\n{} WeMod installed successfully!", | |
| 234 | + style("SUCCESS").green().bold() | |
| 235 | + ); | |
| 236 | + | |
| 237 | + Ok(()) | |
| 238 | +} | |
| 239 | + | |
| 240 | +async fn uninstall(config: &WandaConfig) -> Result<()> { | |
| 241 | + let mut prefix_manager = PrefixManager::new(config); | |
| 242 | + prefix_manager.load()?; | |
| 243 | + | |
| 244 | + let prefix = prefix_manager.get("default").ok_or_else(|| WandaError::LaunchFailed { | |
| 245 | + reason: "WANDA not initialized.".to_string(), | |
| 246 | + })?; | |
| 247 | + | |
| 248 | + if !prefix.wemod_installed { | |
| 249 | + println!("WeMod is not installed."); | |
| 250 | + return Ok(()); | |
| 251 | + } | |
| 252 | + | |
| 253 | + let steam = SteamInstallation::discover(config)?; | |
| 254 | + let proton_manager = ProtonManager::discover(&steam, config)?; | |
| 255 | + let proton = proton_manager.get_preferred(config)?; | |
| 256 | + | |
| 257 | + println!("Uninstalling WeMod..."); | |
| 258 | + | |
| 259 | + let installer = WemodInstaller::new(prefix, proton); | |
| 260 | + installer.uninstall().await?; | |
| 261 | + | |
| 262 | + prefix_manager.update_metadata("default", |p| { | |
| 263 | + p.wemod_installed = false; | |
| 264 | + p.wemod_version = None; | |
| 265 | + })?; | |
| 266 | + | |
| 267 | + println!("WeMod uninstalled."); | |
| 268 | + | |
| 269 | + Ok(()) | |
| 270 | +} | |
| 271 | + | |
| 272 | +async fn run_wemod(config: &WandaConfig) -> Result<()> { | |
| 273 | + let mut prefix_manager = PrefixManager::new(config); | |
| 274 | + prefix_manager.load()?; | |
| 275 | + | |
| 276 | + let prefix = prefix_manager.get("default").ok_or(WandaError::WemodNotInstalled)?; | |
| 277 | + | |
| 278 | + if !prefix.wemod_installed { | |
| 279 | + return Err(WandaError::WemodNotInstalled); | |
| 280 | + } | |
| 281 | + | |
| 282 | + let steam = SteamInstallation::discover(config)?; | |
| 283 | + let proton_manager = ProtonManager::discover(&steam, config)?; | |
| 284 | + let proton = proton_manager.get_preferred(config)?; | |
| 285 | + | |
| 286 | + println!("Starting WeMod..."); | |
| 287 | + | |
| 288 | + let installer = WemodInstaller::new(prefix, proton); | |
| 289 | + let mut child = installer.run().await?; | |
| 290 | + | |
| 291 | + println!("WeMod is running. Press Ctrl+C to stop."); | |
| 292 | + | |
| 293 | + // Wait for WeMod to exit | |
| 294 | + let _ = child.wait().await; | |
| 295 | + | |
| 296 | + println!("WeMod closed."); | |
| 297 | + | |
| 298 | + Ok(()) | |
| 299 | +} | |
crates/wanda-cli/src/main.rsadded@@ -0,0 +1,101 @@ | ||
| 1 | +//! WANDA CLI - WeMod launcher for Linux | |
| 2 | + | |
| 3 | +mod commands; | |
| 4 | + | |
| 5 | +use clap::{Parser, Subcommand}; | |
| 6 | +use std::path::PathBuf; | |
| 7 | +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; | |
| 8 | + | |
| 9 | +#[derive(Parser)] | |
| 10 | +#[command(name = "wanda")] | |
| 11 | +#[command(author = "WANDA Contributors")] | |
| 12 | +#[command(version)] | |
| 13 | +#[command(about = "WeMod launcher for Linux", long_about = None)] | |
| 14 | +struct Cli { | |
| 15 | + /// Increase verbosity (-v, -vv, -vvv) | |
| 16 | + #[arg(short, long, action = clap::ArgAction::Count, global = true)] | |
| 17 | + verbose: u8, | |
| 18 | + | |
| 19 | + /// Suppress non-essential output | |
| 20 | + #[arg(short, long, global = true)] | |
| 21 | + quiet: bool, | |
| 22 | + | |
| 23 | + /// Output in JSON format | |
| 24 | + #[arg(long, global = true)] | |
| 25 | + json: bool, | |
| 26 | + | |
| 27 | + /// Use a custom config file | |
| 28 | + #[arg(long, global = true)] | |
| 29 | + config: Option<PathBuf>, | |
| 30 | + | |
| 31 | + #[command(subcommand)] | |
| 32 | + command: Commands, | |
| 33 | +} | |
| 34 | + | |
| 35 | +#[derive(Subcommand)] | |
| 36 | +enum Commands { | |
| 37 | + /// Initialize WANDA and set up WeMod prefix | |
| 38 | + Init(commands::init::InitArgs), | |
| 39 | + | |
| 40 | + /// Scan for Steam games | |
| 41 | + Scan(commands::scan::ScanArgs), | |
| 42 | + | |
| 43 | + /// Launch a game with WeMod | |
| 44 | + Launch(commands::launch::LaunchArgs), | |
| 45 | + | |
| 46 | + /// Manage Wine/Proton prefixes | |
| 47 | + #[command(subcommand)] | |
| 48 | + Prefix(commands::prefix::PrefixCommands), | |
| 49 | + | |
| 50 | + /// Manage WeMod installation | |
| 51 | + #[command(subcommand)] | |
| 52 | + Wemod(commands::wemod::WemodCommands), | |
| 53 | + | |
| 54 | + /// Manage configuration | |
| 55 | + #[command(subcommand)] | |
| 56 | + Config(commands::config::ConfigCommands), | |
| 57 | + | |
| 58 | + /// Diagnose issues and generate reports | |
| 59 | + Doctor(commands::doctor::DoctorArgs), | |
| 60 | +} | |
| 61 | + | |
| 62 | +fn setup_logging(verbose: u8, quiet: bool) { | |
| 63 | + let filter = if quiet { | |
| 64 | + "warn" | |
| 65 | + } else { | |
| 66 | + match verbose { | |
| 67 | + 0 => "info", | |
| 68 | + 1 => "debug", | |
| 69 | + _ => "trace", | |
| 70 | + } | |
| 71 | + }; | |
| 72 | + | |
| 73 | + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)); | |
| 74 | + | |
| 75 | + tracing_subscriber::registry() | |
| 76 | + .with(fmt::layer().with_target(false).without_time()) | |
| 77 | + .with(filter) | |
| 78 | + .init(); | |
| 79 | +} | |
| 80 | + | |
| 81 | +#[tokio::main] | |
| 82 | +async fn main() { | |
| 83 | + let cli = Cli::parse(); | |
| 84 | + | |
| 85 | + setup_logging(cli.verbose, cli.quiet); | |
| 86 | + | |
| 87 | + let result = match cli.command { | |
| 88 | + Commands::Init(args) => commands::init::run(args, cli.config).await, | |
| 89 | + Commands::Scan(args) => commands::scan::run(args, cli.config).await, | |
| 90 | + Commands::Launch(args) => commands::launch::run(args, cli.config).await, | |
| 91 | + Commands::Prefix(cmd) => commands::prefix::run(cmd, cli.config).await, | |
| 92 | + Commands::Wemod(cmd) => commands::wemod::run(cmd, cli.config).await, | |
| 93 | + Commands::Config(cmd) => commands::config::run(cmd, cli.config).await, | |
| 94 | + Commands::Doctor(args) => commands::doctor::run(args, cli.config).await, | |
| 95 | + }; | |
| 96 | + | |
| 97 | + if let Err(e) = result { | |
| 98 | + eprintln!("Error: {}", e.user_message()); | |
| 99 | + std::process::exit(1); | |
| 100 | + } | |
| 101 | +} | |
crates/wanda-core/Cargo.tomladded@@ -0,0 +1,23 @@ | ||
| 1 | +[package] | |
| 2 | +name = "wanda-core" | |
| 3 | +version.workspace = true | |
| 4 | +edition.workspace = true | |
| 5 | +license.workspace = true | |
| 6 | +description = "Core library for WANDA - WeMod launcher for Linux" | |
| 7 | + | |
| 8 | +[dependencies] | |
| 9 | +tokio.workspace = true | |
| 10 | +serde.workspace = true | |
| 11 | +serde_json.workspace = true | |
| 12 | +toml.workspace = true | |
| 13 | +thiserror.workspace = true | |
| 14 | +anyhow.workspace = true | |
| 15 | +tracing.workspace = true | |
| 16 | +reqwest.workspace = true | |
| 17 | +dirs.workspace = true | |
| 18 | +walkdir.workspace = true | |
| 19 | +futures.workspace = true | |
| 20 | + | |
| 21 | +# VDF parsing (Steam config files) | |
| 22 | +keyvalues-parser = "0.2" | |
| 23 | +keyvalues-serde = "0.2" | |
crates/wanda-core/src/config.rsadded@@ -0,0 +1,182 @@ | ||
| 1 | +//! Configuration management for WANDA | |
| 2 | + | |
| 3 | +use crate::error::Result; | |
| 4 | +use serde::{Deserialize, Serialize}; | |
| 5 | +use std::path::PathBuf; | |
| 6 | +use tracing::{debug, info}; | |
| 7 | + | |
| 8 | +/// Main WANDA configuration | |
| 9 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 10 | +#[serde(default)] | |
| 11 | +pub struct WandaConfig { | |
| 12 | + /// Config schema version for future migrations | |
| 13 | + pub version: u32, | |
| 14 | + /// Steam-related configuration | |
| 15 | + pub steam: SteamConfig, | |
| 16 | + /// Proton-related configuration | |
| 17 | + pub proton: ProtonConfig, | |
| 18 | + /// WeMod-related configuration | |
| 19 | + pub wemod: WemodConfig, | |
| 20 | + /// Prefix-related configuration | |
| 21 | + pub prefix: PrefixConfig, | |
| 22 | +} | |
| 23 | + | |
| 24 | +impl Default for WandaConfig { | |
| 25 | + fn default() -> Self { | |
| 26 | + Self { | |
| 27 | + version: 1, | |
| 28 | + steam: SteamConfig::default(), | |
| 29 | + proton: ProtonConfig::default(), | |
| 30 | + wemod: WemodConfig::default(), | |
| 31 | + prefix: PrefixConfig::default(), | |
| 32 | + } | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +/// Steam configuration | |
| 37 | +#[derive(Debug, Clone, Serialize, Deserialize, Default)] | |
| 38 | +#[serde(default)] | |
| 39 | +pub struct SteamConfig { | |
| 40 | + /// Override auto-detected Steam installation path | |
| 41 | + pub install_path: Option<PathBuf>, | |
| 42 | + /// Additional Steam library folders to scan | |
| 43 | + pub additional_libraries: Vec<PathBuf>, | |
| 44 | + /// Whether to scan Flatpak Steam installation | |
| 45 | + pub scan_flatpak: bool, | |
| 46 | +} | |
| 47 | + | |
| 48 | +/// Proton configuration | |
| 49 | +#[derive(Debug, Clone, Serialize, Deserialize, Default)] | |
| 50 | +#[serde(default)] | |
| 51 | +pub struct ProtonConfig { | |
| 52 | + /// Preferred Proton version (e.g., "GE-Proton9-5") | |
| 53 | + pub preferred_version: Option<String>, | |
| 54 | + /// Additional paths to search for Proton installations | |
| 55 | + pub search_paths: Vec<PathBuf>, | |
| 56 | +} | |
| 57 | + | |
| 58 | +/// WeMod configuration | |
| 59 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 60 | +#[serde(default)] | |
| 61 | +pub struct WemodConfig { | |
| 62 | + /// Whether to automatically update WeMod | |
| 63 | + pub auto_update: bool, | |
| 64 | + /// Pin to a specific WeMod version | |
| 65 | + pub version: Option<String>, | |
| 66 | +} | |
| 67 | + | |
| 68 | +impl Default for WemodConfig { | |
| 69 | + fn default() -> Self { | |
| 70 | + Self { | |
| 71 | + auto_update: true, | |
| 72 | + version: None, | |
| 73 | + } | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +/// Prefix configuration | |
| 78 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 79 | +#[serde(default)] | |
| 80 | +pub struct PrefixConfig { | |
| 81 | + /// Custom base path for WANDA prefixes | |
| 82 | + pub base_path: Option<PathBuf>, | |
| 83 | + /// Whether to attempt auto-repair of corrupted prefixes | |
| 84 | + pub auto_repair: bool, | |
| 85 | +} | |
| 86 | + | |
| 87 | +impl Default for PrefixConfig { | |
| 88 | + fn default() -> Self { | |
| 89 | + Self { | |
| 90 | + base_path: None, | |
| 91 | + auto_repair: true, | |
| 92 | + } | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +impl WandaConfig { | |
| 97 | + /// Get the default config file path | |
| 98 | + pub fn default_path() -> PathBuf { | |
| 99 | + dirs::config_dir() | |
| 100 | + .unwrap_or_else(|| PathBuf::from(".")) | |
| 101 | + .join("wanda") | |
| 102 | + .join("config.toml") | |
| 103 | + } | |
| 104 | + | |
| 105 | + /// Get the default data directory | |
| 106 | + pub fn default_data_dir() -> PathBuf { | |
| 107 | + dirs::data_local_dir() | |
| 108 | + .unwrap_or_else(|| PathBuf::from(".")) | |
| 109 | + .join("wanda") | |
| 110 | + } | |
| 111 | + | |
| 112 | + /// Get the default cache directory | |
| 113 | + pub fn default_cache_dir() -> PathBuf { | |
| 114 | + dirs::cache_dir() | |
| 115 | + .unwrap_or_else(|| PathBuf::from(".")) | |
| 116 | + .join("wanda") | |
| 117 | + } | |
| 118 | + | |
| 119 | + /// Load configuration from the default path | |
| 120 | + pub fn load() -> Result<Self> { | |
| 121 | + Self::load_from(&Self::default_path()) | |
| 122 | + } | |
| 123 | + | |
| 124 | + /// Load configuration from a specific path | |
| 125 | + pub fn load_from(path: &PathBuf) -> Result<Self> { | |
| 126 | + if !path.exists() { | |
| 127 | + debug!("Config file not found at {}, using defaults", path.display()); | |
| 128 | + return Ok(Self::default()); | |
| 129 | + } | |
| 130 | + | |
| 131 | + let content = std::fs::read_to_string(path)?; | |
| 132 | + let config: WandaConfig = toml::from_str(&content)?; | |
| 133 | + debug!("Loaded config from {}", path.display()); | |
| 134 | + Ok(config) | |
| 135 | + } | |
| 136 | + | |
| 137 | + /// Save configuration to the default path | |
| 138 | + pub fn save(&self) -> Result<()> { | |
| 139 | + self.save_to(&Self::default_path()) | |
| 140 | + } | |
| 141 | + | |
| 142 | + /// Save configuration to a specific path | |
| 143 | + pub fn save_to(&self, path: &PathBuf) -> Result<()> { | |
| 144 | + // Ensure parent directory exists | |
| 145 | + if let Some(parent) = path.parent() { | |
| 146 | + std::fs::create_dir_all(parent)?; | |
| 147 | + } | |
| 148 | + | |
| 149 | + let content = toml::to_string_pretty(self)?; | |
| 150 | + std::fs::write(path, content)?; | |
| 151 | + info!("Saved config to {}", path.display()); | |
| 152 | + Ok(()) | |
| 153 | + } | |
| 154 | + | |
| 155 | + /// Get the prefix base path (custom or default) | |
| 156 | + pub fn prefix_base_path(&self) -> PathBuf { | |
| 157 | + self.prefix | |
| 158 | + .base_path | |
| 159 | + .clone() | |
| 160 | + .unwrap_or_else(|| Self::default_data_dir().join("prefix")) | |
| 161 | + } | |
| 162 | +} | |
| 163 | + | |
| 164 | +#[cfg(test)] | |
| 165 | +mod tests { | |
| 166 | + use super::*; | |
| 167 | + | |
| 168 | + #[test] | |
| 169 | + fn test_default_config() { | |
| 170 | + let config = WandaConfig::default(); | |
| 171 | + assert_eq!(config.version, 1); | |
| 172 | + assert!(config.wemod.auto_update); | |
| 173 | + } | |
| 174 | + | |
| 175 | + #[test] | |
| 176 | + fn test_serialize_deserialize() { | |
| 177 | + let config = WandaConfig::default(); | |
| 178 | + let toml_str = toml::to_string_pretty(&config).unwrap(); | |
| 179 | + let parsed: WandaConfig = toml::from_str(&toml_str).unwrap(); | |
| 180 | + assert_eq!(parsed.version, config.version); | |
| 181 | + } | |
| 182 | +} | |
crates/wanda-core/src/error.rsadded@@ -0,0 +1,114 @@ | ||
| 1 | +//! Error types for WANDA | |
| 2 | + | |
| 3 | +use std::path::PathBuf; | |
| 4 | +use thiserror::Error; | |
| 5 | + | |
| 6 | +/// Result type alias using WandaError | |
| 7 | +pub type Result<T> = std::result::Result<T, WandaError>; | |
| 8 | + | |
| 9 | +/// Main error type for WANDA operations | |
| 10 | +#[derive(Error, Debug)] | |
| 11 | +pub enum WandaError { | |
| 12 | + // Steam errors | |
| 13 | + #[error("Steam installation not found")] | |
| 14 | + SteamNotFound, | |
| 15 | + | |
| 16 | + #[error("Steam library at {path} is inaccessible: {reason}")] | |
| 17 | + SteamLibraryInaccessible { path: PathBuf, reason: String }, | |
| 18 | + | |
| 19 | + #[error("Failed to parse VDF file {path}: {reason}")] | |
| 20 | + VdfParseError { path: PathBuf, reason: String }, | |
| 21 | + | |
| 22 | + #[error("Game with app ID {app_id} not found")] | |
| 23 | + GameNotFound { app_id: u32 }, | |
| 24 | + | |
| 25 | + // Proton errors | |
| 26 | + #[error("No compatible Proton version found")] | |
| 27 | + ProtonNotFound, | |
| 28 | + | |
| 29 | + #[error("Proton version {version} is incompatible: {reason}")] | |
| 30 | + ProtonIncompatible { version: String, reason: String }, | |
| 31 | + | |
| 32 | + // Prefix errors | |
| 33 | + #[error("Prefix at {path} is corrupted: {reason}")] | |
| 34 | + PrefixCorrupted { path: PathBuf, reason: String }, | |
| 35 | + | |
| 36 | + #[error("Failed to create prefix at {path}: {reason}")] | |
| 37 | + PrefixCreationFailed { path: PathBuf, reason: String }, | |
| 38 | + | |
| 39 | + #[error("Prefix at {path} not found")] | |
| 40 | + PrefixNotFound { path: PathBuf }, | |
| 41 | + | |
| 42 | + #[error("Winetricks failed: {reason}")] | |
| 43 | + WinetricksFailed { reason: String }, | |
| 44 | + | |
| 45 | + // WeMod errors | |
| 46 | + #[error("Failed to download WeMod: {reason}")] | |
| 47 | + WemodDownloadFailed { reason: String }, | |
| 48 | + | |
| 49 | + #[error("WeMod installation failed: {reason}")] | |
| 50 | + WemodInstallFailed { reason: String }, | |
| 51 | + | |
| 52 | + #[error("WeMod not installed")] | |
| 53 | + WemodNotInstalled, | |
| 54 | + | |
| 55 | + // Launch errors | |
| 56 | + #[error("Failed to launch game: {reason}")] | |
| 57 | + LaunchFailed { reason: String }, | |
| 58 | + | |
| 59 | + #[error("Process terminated unexpectedly: {reason}")] | |
| 60 | + ProcessCrashed { reason: String }, | |
| 61 | + | |
| 62 | + // Configuration errors | |
| 63 | + #[error("Configuration error: {reason}")] | |
| 64 | + ConfigError { reason: String }, | |
| 65 | + | |
| 66 | + #[error("Configuration file not found at {path}")] | |
| 67 | + ConfigNotFound { path: PathBuf }, | |
| 68 | + | |
| 69 | + // Generic errors | |
| 70 | + #[error("IO error: {0}")] | |
| 71 | + Io(#[from] std::io::Error), | |
| 72 | + | |
| 73 | + #[error("HTTP error: {0}")] | |
| 74 | + Http(#[from] reqwest::Error), | |
| 75 | + | |
| 76 | + #[error("JSON error: {0}")] | |
| 77 | + Json(#[from] serde_json::Error), | |
| 78 | + | |
| 79 | + #[error("TOML parse error: {0}")] | |
| 80 | + TomlParse(#[from] toml::de::Error), | |
| 81 | + | |
| 82 | + #[error("TOML serialize error: {0}")] | |
| 83 | + TomlSerialize(#[from] toml::ser::Error), | |
| 84 | +} | |
| 85 | + | |
| 86 | +impl WandaError { | |
| 87 | + /// Get a user-friendly message with suggestions | |
| 88 | + pub fn user_message(&self) -> String { | |
| 89 | + match self { | |
| 90 | + Self::SteamNotFound => { | |
| 91 | + "Steam installation not found. Please ensure Steam is installed, \ | |
| 92 | + or specify the path manually in the config." | |
| 93 | + .into() | |
| 94 | + } | |
| 95 | + Self::ProtonNotFound => { | |
| 96 | + "No compatible Proton version found. Please install GE-Proton \ | |
| 97 | + or Proton Experimental from Steam." | |
| 98 | + .into() | |
| 99 | + } | |
| 100 | + Self::PrefixCorrupted { path, reason } => { | |
| 101 | + format!( | |
| 102 | + "The Wine prefix at {} appears to be corrupted: {}\n\ | |
| 103 | + Try running 'wanda prefix repair' to fix this issue.", | |
| 104 | + path.display(), | |
| 105 | + reason | |
| 106 | + ) | |
| 107 | + } | |
| 108 | + Self::WemodNotInstalled => { | |
| 109 | + "WeMod is not installed. Run 'wanda init' to set up WANDA.".into() | |
| 110 | + } | |
| 111 | + _ => self.to_string(), | |
| 112 | + } | |
| 113 | + } | |
| 114 | +} | |
crates/wanda-core/src/launcher.rsadded@@ -0,0 +1,352 @@ | ||
| 1 | +//! Game launching with WeMod | |
| 2 | +//! | |
| 3 | +//! Handles launching Steam games alongside WeMod through Proton. | |
| 4 | + | |
| 5 | +use crate::error::{Result, WandaError}; | |
| 6 | +use crate::prefix::WandaPrefix; | |
| 7 | +use crate::steam::{ProtonVersion, SteamApp, SteamInstallation}; | |
| 8 | +use std::collections::HashMap; | |
| 9 | +use std::path::PathBuf; | |
| 10 | +use std::process::Stdio; | |
| 11 | +use std::time::Duration; | |
| 12 | +use tokio::process::{Child, Command}; | |
| 13 | +use tracing::{debug, info, warn}; | |
| 14 | + | |
| 15 | +/// Configuration for launching a game | |
| 16 | +#[derive(Debug, Clone)] | |
| 17 | +pub struct LaunchConfig { | |
| 18 | + /// The game to launch | |
| 19 | + pub app_id: u32, | |
| 20 | + /// Whether to launch with WeMod | |
| 21 | + pub with_wemod: bool, | |
| 22 | + /// Additional command-line arguments for the game | |
| 23 | + pub extra_args: Vec<String>, | |
| 24 | + /// Additional environment variables | |
| 25 | + pub extra_env: HashMap<String, String>, | |
| 26 | + /// Delay between starting WeMod and the game (in seconds) | |
| 27 | + pub wemod_delay: u64, | |
| 28 | +} | |
| 29 | + | |
| 30 | +impl Default for LaunchConfig { | |
| 31 | + fn default() -> Self { | |
| 32 | + Self { | |
| 33 | + app_id: 0, | |
| 34 | + with_wemod: true, | |
| 35 | + extra_args: Vec::new(), | |
| 36 | + extra_env: HashMap::new(), | |
| 37 | + wemod_delay: 3, | |
| 38 | + } | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +/// Handle to a launched game session | |
| 43 | +pub struct LaunchHandle { | |
| 44 | + /// WeMod process (if launched with WeMod) | |
| 45 | + wemod_process: Option<Child>, | |
| 46 | + /// Game App ID | |
| 47 | + pub app_id: u32, | |
| 48 | + /// Start time | |
| 49 | + start_time: std::time::Instant, | |
| 50 | +} | |
| 51 | + | |
| 52 | +impl LaunchHandle { | |
| 53 | + /// Check if the session is still running | |
| 54 | + pub fn is_running(&mut self) -> bool { | |
| 55 | + if let Some(ref mut wemod) = self.wemod_process { | |
| 56 | + match wemod.try_wait() { | |
| 57 | + Ok(Some(_)) => return false, // WeMod exited | |
| 58 | + Ok(None) => return true, // Still running | |
| 59 | + Err(_) => return false, | |
| 60 | + } | |
| 61 | + } | |
| 62 | + false | |
| 63 | + } | |
| 64 | + | |
| 65 | + /// Get elapsed time since launch | |
| 66 | + pub fn elapsed(&self) -> Duration { | |
| 67 | + self.start_time.elapsed() | |
| 68 | + } | |
| 69 | + | |
| 70 | + /// Terminate the session | |
| 71 | + pub async fn terminate(&mut self) -> Result<()> { | |
| 72 | + if let Some(ref mut wemod) = self.wemod_process { | |
| 73 | + let _ = wemod.kill().await; | |
| 74 | + } | |
| 75 | + Ok(()) | |
| 76 | + } | |
| 77 | + | |
| 78 | + /// Wait for WeMod to exit | |
| 79 | + pub async fn wait(&mut self) -> Result<()> { | |
| 80 | + if let Some(ref mut wemod) = self.wemod_process { | |
| 81 | + let _ = wemod.wait().await; | |
| 82 | + } | |
| 83 | + Ok(()) | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +/// Game launcher that coordinates WeMod and game startup | |
| 88 | +pub struct GameLauncher<'a> { | |
| 89 | + /// Steam installation | |
| 90 | + steam: &'a SteamInstallation, | |
| 91 | + /// WANDA prefix with WeMod | |
| 92 | + prefix: &'a WandaPrefix, | |
| 93 | + /// Proton version to use | |
| 94 | + proton: &'a ProtonVersion, | |
| 95 | +} | |
| 96 | + | |
| 97 | +impl<'a> GameLauncher<'a> { | |
| 98 | + /// Create a new game launcher | |
| 99 | + pub fn new( | |
| 100 | + steam: &'a SteamInstallation, | |
| 101 | + prefix: &'a WandaPrefix, | |
| 102 | + proton: &'a ProtonVersion, | |
| 103 | + ) -> Self { | |
| 104 | + Self { | |
| 105 | + steam, | |
| 106 | + prefix, | |
| 107 | + proton, | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + /// Build environment variables for launching | |
| 112 | + fn build_env(&self, game: &SteamApp) -> HashMap<String, String> { | |
| 113 | + let mut env = HashMap::new(); | |
| 114 | + | |
| 115 | + // Wine prefix (WANDA's prefix for WeMod) | |
| 116 | + env.insert( | |
| 117 | + "WINEPREFIX".to_string(), | |
| 118 | + self.prefix.path.to_string_lossy().to_string(), | |
| 119 | + ); | |
| 120 | + | |
| 121 | + // Steam compatibility data path (for the game's own prefix if needed) | |
| 122 | + if let Some(ref compat_path) = game.compat_data_path { | |
| 123 | + env.insert( | |
| 124 | + "STEAM_COMPAT_DATA_PATH".to_string(), | |
| 125 | + compat_path.to_string_lossy().to_string(), | |
| 126 | + ); | |
| 127 | + } | |
| 128 | + | |
| 129 | + // Steam client install path | |
| 130 | + env.insert( | |
| 131 | + "STEAM_COMPAT_CLIENT_INSTALL_PATH".to_string(), | |
| 132 | + self.steam.root_path.to_string_lossy().to_string(), | |
| 133 | + ); | |
| 134 | + | |
| 135 | + // Proton flags that may help stability | |
| 136 | + env.insert("PROTON_NO_ESYNC".to_string(), "1".to_string()); | |
| 137 | + env.insert("PROTON_NO_FSYNC".to_string(), "1".to_string()); | |
| 138 | + | |
| 139 | + // Reduce Wine debug noise | |
| 140 | + env.insert("WINEDEBUG".to_string(), "-all".to_string()); | |
| 141 | + | |
| 142 | + env | |
| 143 | + } | |
| 144 | + | |
| 145 | + /// Get the Wine executable path | |
| 146 | + fn wine_exe(&self) -> PathBuf { | |
| 147 | + let proton_wine = self.proton.wine_exe(); | |
| 148 | + if proton_wine.exists() { | |
| 149 | + proton_wine | |
| 150 | + } else { | |
| 151 | + PathBuf::from("wine") | |
| 152 | + } | |
| 153 | + } | |
| 154 | + | |
| 155 | + /// Launch a game with WeMod | |
| 156 | + pub async fn launch(&self, config: LaunchConfig) -> Result<LaunchHandle> { | |
| 157 | + let game = self | |
| 158 | + .steam | |
| 159 | + .find_game(config.app_id) | |
| 160 | + .ok_or(WandaError::GameNotFound { | |
| 161 | + app_id: config.app_id, | |
| 162 | + })?; | |
| 163 | + | |
| 164 | + info!( | |
| 165 | + "Launching {} (AppID: {}) {}", | |
| 166 | + game.name, | |
| 167 | + game.app_id, | |
| 168 | + if config.with_wemod { | |
| 169 | + "with WeMod" | |
| 170 | + } else { | |
| 171 | + "without WeMod" | |
| 172 | + } | |
| 173 | + ); | |
| 174 | + | |
| 175 | + let mut env = self.build_env(game); | |
| 176 | + env.extend(config.extra_env); | |
| 177 | + | |
| 178 | + let wine = self.wine_exe(); | |
| 179 | + let mut wemod_process = None; | |
| 180 | + | |
| 181 | + // Start WeMod first if requested | |
| 182 | + if config.with_wemod { | |
| 183 | + let wemod_exe = self.prefix.wemod_exe(); | |
| 184 | + if !wemod_exe.exists() { | |
| 185 | + return Err(WandaError::WemodNotInstalled); | |
| 186 | + } | |
| 187 | + | |
| 188 | + info!("Starting WeMod..."); | |
| 189 | + let child = Command::new(&wine) | |
| 190 | + .arg(&wemod_exe) | |
| 191 | + .envs(&env) | |
| 192 | + .stdout(Stdio::null()) | |
| 193 | + .stderr(Stdio::null()) | |
| 194 | + .spawn() | |
| 195 | + .map_err(|e| WandaError::LaunchFailed { | |
| 196 | + reason: format!("Failed to start WeMod: {}", e), | |
| 197 | + })?; | |
| 198 | + | |
| 199 | + wemod_process = Some(child); | |
| 200 | + | |
| 201 | + // Wait for WeMod to initialize | |
| 202 | + info!( | |
| 203 | + "Waiting {} seconds for WeMod to initialize...", | |
| 204 | + config.wemod_delay | |
| 205 | + ); | |
| 206 | + tokio::time::sleep(Duration::from_secs(config.wemod_delay)).await; | |
| 207 | + } | |
| 208 | + | |
| 209 | + // Launch the game via Steam | |
| 210 | + // Using steam:// URL protocol ensures Steam handles Proton setup correctly | |
| 211 | + info!("Launching game via Steam..."); | |
| 212 | + self.launch_via_steam(config.app_id).await?; | |
| 213 | + | |
| 214 | + Ok(LaunchHandle { | |
| 215 | + wemod_process, | |
| 216 | + app_id: config.app_id, | |
| 217 | + start_time: std::time::Instant::now(), | |
| 218 | + }) | |
| 219 | + } | |
| 220 | + | |
| 221 | + /// Launch a game via Steam's URL protocol | |
| 222 | + async fn launch_via_steam(&self, app_id: u32) -> Result<()> { | |
| 223 | + // Use xdg-open to launch via steam:// protocol | |
| 224 | + // This ensures Steam handles all the Proton setup correctly | |
| 225 | + let steam_url = format!("steam://rungameid/{}", app_id); | |
| 226 | + | |
| 227 | + let status = Command::new("xdg-open") | |
| 228 | + .arg(&steam_url) | |
| 229 | + .stdout(Stdio::null()) | |
| 230 | + .stderr(Stdio::null()) | |
| 231 | + .status() | |
| 232 | + .await | |
| 233 | + .map_err(|e| WandaError::LaunchFailed { | |
| 234 | + reason: format!("Failed to launch Steam URL: {}", e), | |
| 235 | + })?; | |
| 236 | + | |
| 237 | + if !status.success() { | |
| 238 | + // Try alternative: steam command directly | |
| 239 | + let status = Command::new("steam") | |
| 240 | + .arg(&steam_url) | |
| 241 | + .stdout(Stdio::null()) | |
| 242 | + .stderr(Stdio::null()) | |
| 243 | + .status() | |
| 244 | + .await | |
| 245 | + .map_err(|e| WandaError::LaunchFailed { | |
| 246 | + reason: format!("Failed to launch via steam command: {}", e), | |
| 247 | + })?; | |
| 248 | + | |
| 249 | + if !status.success() { | |
| 250 | + warn!("Steam launch returned non-zero, game may still start"); | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + debug!("Game launch initiated via Steam"); | |
| 255 | + Ok(()) | |
| 256 | + } | |
| 257 | + | |
| 258 | + /// Launch a game directly via Proton (without Steam) | |
| 259 | + /// This is an alternative method that gives more control | |
| 260 | + #[allow(dead_code)] | |
| 261 | + async fn launch_directly(&self, game: &SteamApp, args: &[String]) -> Result<Child> { | |
| 262 | + let proton_exe = self.proton.proton_exe(); | |
| 263 | + | |
| 264 | + if !proton_exe.exists() { | |
| 265 | + return Err(WandaError::ProtonNotFound); | |
| 266 | + } | |
| 267 | + | |
| 268 | + // Find the game executable | |
| 269 | + let game_exe = self.find_game_executable(game)?; | |
| 270 | + | |
| 271 | + let mut env = self.build_env(game); | |
| 272 | + | |
| 273 | + // Set up Proton environment | |
| 274 | + env.insert("STEAM_COMPAT_DATA_PATH".to_string(), | |
| 275 | + game.compat_data_path | |
| 276 | + .as_ref() | |
| 277 | + .map(|p| p.to_string_lossy().to_string()) | |
| 278 | + .unwrap_or_else(|| { | |
| 279 | + self.steam.root_path | |
| 280 | + .join("steamapps/compatdata") | |
| 281 | + .join(game.app_id.to_string()) | |
| 282 | + .to_string_lossy() | |
| 283 | + .to_string() | |
| 284 | + }) | |
| 285 | + ); | |
| 286 | + | |
| 287 | + let mut cmd = Command::new(&proton_exe); | |
| 288 | + cmd.arg("run").arg(&game_exe); | |
| 289 | + cmd.args(args); | |
| 290 | + cmd.envs(&env); | |
| 291 | + cmd.stdout(Stdio::null()); | |
| 292 | + cmd.stderr(Stdio::null()); | |
| 293 | + | |
| 294 | + let child = cmd.spawn().map_err(|e| WandaError::LaunchFailed { | |
| 295 | + reason: format!("Failed to start game: {}", e), | |
| 296 | + })?; | |
| 297 | + | |
| 298 | + Ok(child) | |
| 299 | + } | |
| 300 | + | |
| 301 | + /// Try to find the main executable for a game | |
| 302 | + fn find_game_executable(&self, game: &SteamApp) -> Result<PathBuf> { | |
| 303 | + let install_path = &game.install_path; | |
| 304 | + | |
| 305 | + if !install_path.exists() { | |
| 306 | + return Err(WandaError::GameNotFound { | |
| 307 | + app_id: game.app_id, | |
| 308 | + }); | |
| 309 | + } | |
| 310 | + | |
| 311 | + // Common executable patterns | |
| 312 | + let patterns = [ | |
| 313 | + format!("{}.exe", game.install_dir), | |
| 314 | + "game.exe".to_string(), | |
| 315 | + "start.exe".to_string(), | |
| 316 | + "launcher.exe".to_string(), | |
| 317 | + ]; | |
| 318 | + | |
| 319 | + // Try to find a matching executable | |
| 320 | + for pattern in &patterns { | |
| 321 | + let exe_path = install_path.join(pattern); | |
| 322 | + if exe_path.exists() { | |
| 323 | + return Ok(exe_path); | |
| 324 | + } | |
| 325 | + } | |
| 326 | + | |
| 327 | + // Walk the directory looking for .exe files | |
| 328 | + for entry in walkdir::WalkDir::new(install_path) | |
| 329 | + .max_depth(2) | |
| 330 | + .into_iter() | |
| 331 | + .flatten() | |
| 332 | + { | |
| 333 | + let path = entry.path(); | |
| 334 | + if let Some(ext) = path.extension() { | |
| 335 | + if ext == "exe" { | |
| 336 | + let name = path.file_name().unwrap_or_default().to_string_lossy(); | |
| 337 | + // Skip common non-game executables | |
| 338 | + if !name.to_lowercase().contains("unins") | |
| 339 | + && !name.to_lowercase().contains("redist") | |
| 340 | + && !name.to_lowercase().contains("setup") | |
| 341 | + { | |
| 342 | + return Ok(path.to_path_buf()); | |
| 343 | + } | |
| 344 | + } | |
| 345 | + } | |
| 346 | + } | |
| 347 | + | |
| 348 | + Err(WandaError::LaunchFailed { | |
| 349 | + reason: format!("Could not find executable for {}", game.name), | |
| 350 | + }) | |
| 351 | + } | |
| 352 | +} | |
crates/wanda-core/src/lib.rsadded@@ -0,0 +1,13 @@ | ||
| 1 | +//! WANDA Core Library | |
| 2 | +//! | |
| 3 | +//! Core functionality for running WeMod on Linux via Wine/Proton. | |
| 4 | + | |
| 5 | +pub mod config; | |
| 6 | +pub mod error; | |
| 7 | +pub mod launcher; | |
| 8 | +pub mod prefix; | |
| 9 | +pub mod steam; | |
| 10 | +pub mod wemod; | |
| 11 | + | |
| 12 | +pub use config::WandaConfig; | |
| 13 | +pub use error::{Result, WandaError}; | |
crates/wanda-core/src/prefix/builder.rsadded@@ -0,0 +1,342 @@ | ||
| 1 | +//! Prefix building and initialization | |
| 2 | + | |
| 3 | +use crate::error::{Result, WandaError}; | |
| 4 | +use crate::steam::ProtonVersion; | |
| 5 | +use std::collections::HashMap; | |
| 6 | +use std::path::{Path, PathBuf}; | |
| 7 | +use tokio::process::Command; | |
| 8 | +use tracing::{debug, error, info, warn}; | |
| 9 | + | |
| 10 | +/// Builds and initializes Wine prefixes for WeMod | |
| 11 | +pub struct PrefixBuilder<'a> { | |
| 12 | + /// Path where the prefix will be created | |
| 13 | + prefix_path: PathBuf, | |
| 14 | + /// Proton version to use | |
| 15 | + proton: &'a ProtonVersion, | |
| 16 | +} | |
| 17 | + | |
| 18 | +impl<'a> PrefixBuilder<'a> { | |
| 19 | + /// Create a new prefix builder | |
| 20 | + pub fn new(prefix_path: &Path, proton: &'a ProtonVersion) -> Self { | |
| 21 | + Self { | |
| 22 | + prefix_path: prefix_path.to_path_buf(), | |
| 23 | + proton, | |
| 24 | + } | |
| 25 | + } | |
| 26 | + | |
| 27 | + /// Get path to Wine executable (prefer Proton's bundled wine) | |
| 28 | + fn get_wine_path(&self) -> PathBuf { | |
| 29 | + // Try Proton's wine64 first | |
| 30 | + let proton_wine = self.proton.path.join("files/bin/wine64"); | |
| 31 | + debug!("Checking for Proton wine64 at: {}", proton_wine.display()); | |
| 32 | + if proton_wine.exists() { | |
| 33 | + debug!("Found Proton wine64"); | |
| 34 | + return proton_wine; | |
| 35 | + } | |
| 36 | + | |
| 37 | + // Try Proton's wine | |
| 38 | + let proton_wine = self.proton.path.join("files/bin/wine"); | |
| 39 | + debug!("Checking for Proton wine at: {}", proton_wine.display()); | |
| 40 | + if proton_wine.exists() { | |
| 41 | + debug!("Found Proton wine"); | |
| 42 | + return proton_wine; | |
| 43 | + } | |
| 44 | + | |
| 45 | + // Fall back to system wine | |
| 46 | + warn!("No Proton wine found, falling back to system wine"); | |
| 47 | + PathBuf::from("wine") | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Build environment variables for Wine/Proton commands | |
| 51 | + fn build_env(&self) -> HashMap<String, String> { | |
| 52 | + let mut env = HashMap::new(); | |
| 53 | + | |
| 54 | + let wine_path = self.get_wine_path(); | |
| 55 | + debug!("Building environment for Wine at: {}", wine_path.display()); | |
| 56 | + | |
| 57 | + // Wine prefix location | |
| 58 | + env.insert( | |
| 59 | + "WINEPREFIX".to_string(), | |
| 60 | + self.prefix_path.to_string_lossy().to_string(), | |
| 61 | + ); | |
| 62 | + | |
| 63 | + // Use 64-bit Windows | |
| 64 | + env.insert("WINEARCH".to_string(), "win64".to_string()); | |
| 65 | + | |
| 66 | + // Tell winetricks which wine to use | |
| 67 | + env.insert("WINE".to_string(), wine_path.to_string_lossy().to_string()); | |
| 68 | + | |
| 69 | + // Proton lib paths for finding dependencies | |
| 70 | + let proton_lib64 = self.proton.path.join("files/lib64"); | |
| 71 | + let proton_lib = self.proton.path.join("files/lib"); | |
| 72 | + debug!("Checking Proton lib64: {} (exists: {})", proton_lib64.display(), proton_lib64.exists()); | |
| 73 | + debug!("Checking Proton lib: {} (exists: {})", proton_lib.display(), proton_lib.exists()); | |
| 74 | + | |
| 75 | + if proton_lib64.exists() || proton_lib.exists() { | |
| 76 | + let mut ld_path = String::new(); | |
| 77 | + if proton_lib64.exists() { | |
| 78 | + ld_path.push_str(&proton_lib64.to_string_lossy()); | |
| 79 | + } | |
| 80 | + if proton_lib.exists() { | |
| 81 | + if !ld_path.is_empty() { | |
| 82 | + ld_path.push(':'); | |
| 83 | + } | |
| 84 | + ld_path.push_str(&proton_lib.to_string_lossy()); | |
| 85 | + } | |
| 86 | + // Append existing LD_LIBRARY_PATH | |
| 87 | + if let Ok(existing) = std::env::var("LD_LIBRARY_PATH") { | |
| 88 | + ld_path.push(':'); | |
| 89 | + ld_path.push_str(&existing); | |
| 90 | + } | |
| 91 | + debug!("Setting LD_LIBRARY_PATH: {}", ld_path); | |
| 92 | + env.insert("LD_LIBRARY_PATH".to_string(), ld_path); | |
| 93 | + } else { | |
| 94 | + warn!("No Proton lib directories found - Wine may have trouble finding libraries"); | |
| 95 | + } | |
| 96 | + | |
| 97 | + // Proton-specific variables | |
| 98 | + env.insert("PROTON_NO_ESYNC".to_string(), "1".to_string()); | |
| 99 | + env.insert("PROTON_NO_FSYNC".to_string(), "1".to_string()); | |
| 100 | + | |
| 101 | + // Enable debug output for troubleshooting | |
| 102 | + env.insert("WINEDEBUG".to_string(), "warn+all".to_string()); | |
| 103 | + | |
| 104 | + // Log all environment variables at trace level | |
| 105 | + for (key, value) in &env { | |
| 106 | + debug!("ENV {}={}", key, value); | |
| 107 | + } | |
| 108 | + | |
| 109 | + env | |
| 110 | + } | |
| 111 | + | |
| 112 | + /// Build the prefix from scratch | |
| 113 | + pub async fn build(&self) -> Result<()> { | |
| 114 | + info!("Building prefix at {}", self.prefix_path.display()); | |
| 115 | + info!("Using Proton: {}", self.proton.name); | |
| 116 | + info!("Wine path: {}", self.get_wine_path().display()); | |
| 117 | + | |
| 118 | + // Create directory structure | |
| 119 | + std::fs::create_dir_all(&self.prefix_path)?; | |
| 120 | + | |
| 121 | + // Initialize with wineboot | |
| 122 | + self.init_wineboot().await?; | |
| 123 | + | |
| 124 | + // Try to install .NET Framework (but don't fail if it doesn't work) | |
| 125 | + match self.install_dotnet().await { | |
| 126 | + Ok(_) => info!(".NET Framework installed successfully"), | |
| 127 | + Err(e) => { | |
| 128 | + warn!(".NET Framework installation failed: {}", e); | |
| 129 | + warn!("WeMod may still work - continuing with installation"); | |
| 130 | + } | |
| 131 | + } | |
| 132 | + | |
| 133 | + // Install additional dependencies (optional) | |
| 134 | + self.install_dependencies().await?; | |
| 135 | + | |
| 136 | + info!("Prefix built successfully"); | |
| 137 | + Ok(()) | |
| 138 | + } | |
| 139 | + | |
| 140 | + /// Initialize the prefix with wineboot | |
| 141 | + async fn init_wineboot(&self) -> Result<()> { | |
| 142 | + info!("Initializing Wine prefix with wineboot..."); | |
| 143 | + | |
| 144 | + let wine_path = self.get_wine_path(); | |
| 145 | + let env = self.build_env(); | |
| 146 | + | |
| 147 | + info!("Running: {} wineboot --init", wine_path.display()); | |
| 148 | + | |
| 149 | + let output = Command::new(&wine_path) | |
| 150 | + .arg("wineboot") | |
| 151 | + .arg("--init") | |
| 152 | + .envs(&env) | |
| 153 | + .output() | |
| 154 | + .await | |
| 155 | + .map_err(|e| WandaError::PrefixCreationFailed { | |
| 156 | + path: self.prefix_path.clone(), | |
| 157 | + reason: format!("wineboot failed to start: {}", e), | |
| 158 | + })?; | |
| 159 | + | |
| 160 | + if !output.status.success() { | |
| 161 | + let stderr = String::from_utf8_lossy(&output.stderr); | |
| 162 | + let stdout = String::from_utf8_lossy(&output.stdout); | |
| 163 | + error!("wineboot stdout: {}", stdout); | |
| 164 | + error!("wineboot stderr: {}", stderr); | |
| 165 | + return Err(WandaError::PrefixCreationFailed { | |
| 166 | + path: self.prefix_path.clone(), | |
| 167 | + reason: format!("wineboot failed with exit code: {:?}", output.status.code()), | |
| 168 | + }); | |
| 169 | + } | |
| 170 | + | |
| 171 | + debug!("wineboot completed successfully"); | |
| 172 | + | |
| 173 | + // Wait for wineserver to finish | |
| 174 | + self.wait_wineserver().await; | |
| 175 | + | |
| 176 | + Ok(()) | |
| 177 | + } | |
| 178 | + | |
| 179 | + /// Wait for wineserver to finish | |
| 180 | + async fn wait_wineserver(&self) { | |
| 181 | + let env = self.build_env(); | |
| 182 | + | |
| 183 | + // Try Proton's wineserver first | |
| 184 | + let proton_wineserver = self.proton.path.join("files/bin/wineserver"); | |
| 185 | + let wineserver = if proton_wineserver.exists() { | |
| 186 | + proton_wineserver.to_string_lossy().to_string() | |
| 187 | + } else { | |
| 188 | + "wineserver".to_string() | |
| 189 | + }; | |
| 190 | + | |
| 191 | + let _ = Command::new(&wineserver) | |
| 192 | + .arg("-w") | |
| 193 | + .envs(&env) | |
| 194 | + .status() | |
| 195 | + .await; | |
| 196 | + } | |
| 197 | + | |
| 198 | + /// Install .NET Framework 4.8 (required for WeMod) | |
| 199 | + pub async fn install_dotnet(&self) -> Result<()> { | |
| 200 | + info!("Installing .NET Framework 4.8 via winetricks..."); | |
| 201 | + info!("This may take 10-15 minutes and requires internet connection"); | |
| 202 | + | |
| 203 | + // Try dotnet48 first, fall back to dotnet40 if it fails | |
| 204 | + match self.install_winetricks_verbose(&["dotnet48"]).await { | |
| 205 | + Ok(_) => return Ok(()), | |
| 206 | + Err(e) => { | |
| 207 | + warn!("dotnet48 failed: {}", e); | |
| 208 | + warn!("Trying dotnet40 as fallback..."); | |
| 209 | + } | |
| 210 | + } | |
| 211 | + | |
| 212 | + // Try dotnet40 as fallback | |
| 213 | + self.install_winetricks_verbose(&["dotnet40"]).await | |
| 214 | + } | |
| 215 | + | |
| 216 | + /// Install additional dependencies | |
| 217 | + async fn install_dependencies(&self) -> Result<()> { | |
| 218 | + info!("Installing additional dependencies..."); | |
| 219 | + | |
| 220 | + // Install common dependencies WeMod might need | |
| 221 | + let deps = ["vcrun2019", "corefonts"]; | |
| 222 | + | |
| 223 | + for dep in deps { | |
| 224 | + info!("Installing {}...", dep); | |
| 225 | + match self.install_winetricks_verbose(&[dep]).await { | |
| 226 | + Ok(_) => info!("{} installed successfully", dep), | |
| 227 | + Err(e) => warn!("Failed to install {}: {} (may not be critical)", dep, e), | |
| 228 | + } | |
| 229 | + } | |
| 230 | + | |
| 231 | + Ok(()) | |
| 232 | + } | |
| 233 | + | |
| 234 | + /// Install components via winetricks with verbose output | |
| 235 | + pub async fn install_winetricks_verbose(&self, components: &[&str]) -> Result<()> { | |
| 236 | + let env = self.build_env(); | |
| 237 | + | |
| 238 | + // Check if winetricks is available | |
| 239 | + let winetricks_check = Command::new("which").arg("winetricks").output().await; | |
| 240 | + | |
| 241 | + if winetricks_check.is_err() || !winetricks_check.unwrap().status.success() { | |
| 242 | + return Err(WandaError::WinetricksFailed { | |
| 243 | + reason: "winetricks not found. Please install winetricks.".to_string(), | |
| 244 | + }); | |
| 245 | + } | |
| 246 | + | |
| 247 | + for component in components { | |
| 248 | + info!("Installing {} via winetricks...", component); | |
| 249 | + debug!("Environment: WINE={}", env.get("WINE").unwrap_or(&"".to_string())); | |
| 250 | + debug!("Environment: WINEPREFIX={}", env.get("WINEPREFIX").unwrap_or(&"".to_string())); | |
| 251 | + | |
| 252 | + // Run winetricks without -q so we can see progress | |
| 253 | + let output = Command::new("winetricks") | |
| 254 | + .arg("--force") // Force installation even if already installed | |
| 255 | + .arg(component) | |
| 256 | + .envs(&env) | |
| 257 | + .output() | |
| 258 | + .await | |
| 259 | + .map_err(|e| WandaError::WinetricksFailed { | |
| 260 | + reason: format!("Failed to run winetricks: {}", e), | |
| 261 | + })?; | |
| 262 | + | |
| 263 | + let stdout = String::from_utf8_lossy(&output.stdout); | |
| 264 | + let stderr = String::from_utf8_lossy(&output.stderr); | |
| 265 | + | |
| 266 | + // Log output for debugging | |
| 267 | + if !stdout.is_empty() { | |
| 268 | + for line in stdout.lines().take(20) { | |
| 269 | + debug!("winetricks: {}", line); | |
| 270 | + } | |
| 271 | + if stdout.lines().count() > 20 { | |
| 272 | + debug!("winetricks: ... ({} more lines)", stdout.lines().count() - 20); | |
| 273 | + } | |
| 274 | + } | |
| 275 | + | |
| 276 | + if !output.status.success() { | |
| 277 | + error!("winetricks {} failed!", component); | |
| 278 | + error!("stderr: {}", stderr); | |
| 279 | + return Err(WandaError::WinetricksFailed { | |
| 280 | + reason: format!( | |
| 281 | + "winetricks {} failed with exit code {:?}", | |
| 282 | + component, | |
| 283 | + output.status.code() | |
| 284 | + ), | |
| 285 | + }); | |
| 286 | + } | |
| 287 | + } | |
| 288 | + | |
| 289 | + // Wait for wineserver to finish | |
| 290 | + self.wait_wineserver().await; | |
| 291 | + | |
| 292 | + Ok(()) | |
| 293 | + } | |
| 294 | + | |
| 295 | + /// Install components via winetricks (quiet mode) | |
| 296 | + pub async fn install_winetricks(&self, components: &[&str]) -> Result<()> { | |
| 297 | + self.install_winetricks_verbose(components).await | |
| 298 | + } | |
| 299 | + | |
| 300 | + /// Run a command in the prefix using Wine | |
| 301 | + pub async fn run_wine_command(&self, args: &[&str]) -> Result<std::process::Output> { | |
| 302 | + let wine_path = self.get_wine_path(); | |
| 303 | + let env = self.build_env(); | |
| 304 | + | |
| 305 | + info!("Running: {} {:?}", wine_path.display(), args); | |
| 306 | + | |
| 307 | + let output = Command::new(&wine_path) | |
| 308 | + .args(args) | |
| 309 | + .envs(&env) | |
| 310 | + .output() | |
| 311 | + .await | |
| 312 | + .map_err(|e| WandaError::PrefixCreationFailed { | |
| 313 | + path: self.prefix_path.clone(), | |
| 314 | + reason: format!("Wine command failed: {}", e), | |
| 315 | + })?; | |
| 316 | + | |
| 317 | + Ok(output) | |
| 318 | + } | |
| 319 | +} | |
| 320 | + | |
| 321 | +#[cfg(test)] | |
| 322 | +mod tests { | |
| 323 | + use super::*; | |
| 324 | + | |
| 325 | + #[test] | |
| 326 | + fn test_build_env() { | |
| 327 | + let proton = ProtonVersion { | |
| 328 | + name: "test".to_string(), | |
| 329 | + path: PathBuf::from("/test"), | |
| 330 | + version: (9, 0, 0), | |
| 331 | + is_ge: true, | |
| 332 | + is_experimental: false, | |
| 333 | + compatibility: crate::steam::ProtonCompatibility::Recommended, | |
| 334 | + }; | |
| 335 | + | |
| 336 | + let builder = PrefixBuilder::new(Path::new("/tmp/test"), &proton); | |
| 337 | + let env = builder.build_env(); | |
| 338 | + | |
| 339 | + assert_eq!(env.get("WINEPREFIX"), Some(&"/tmp/test".to_string())); | |
| 340 | + assert_eq!(env.get("WINEARCH"), Some(&"win64".to_string())); | |
| 341 | + } | |
| 342 | +} | |
crates/wanda-core/src/prefix/manager.rsadded@@ -0,0 +1,361 @@ | ||
| 1 | +//! Prefix lifecycle management | |
| 2 | + | |
| 3 | +use crate::config::WandaConfig; | |
| 4 | +use crate::error::{Result, WandaError}; | |
| 5 | +use crate::prefix::PrefixBuilder; | |
| 6 | +use crate::steam::ProtonVersion; | |
| 7 | +use serde::{Deserialize, Serialize}; | |
| 8 | +use std::path::{Path, PathBuf}; | |
| 9 | +use tracing::{debug, info, warn}; | |
| 10 | + | |
| 11 | +/// Health status of a Wine prefix | |
| 12 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 13 | +pub enum PrefixHealth { | |
| 14 | + /// Prefix is healthy and ready for use | |
| 15 | + Healthy, | |
| 16 | + /// Prefix has issues that may be repairable | |
| 17 | + NeedsRepair(Vec<PrefixIssue>), | |
| 18 | + /// Prefix is corrupted beyond repair | |
| 19 | + Corrupted(String), | |
| 20 | + /// Prefix doesn't exist yet | |
| 21 | + NotCreated, | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// Specific issues that can affect a prefix | |
| 25 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 26 | +pub enum PrefixIssue { | |
| 27 | + /// .NET Framework is missing | |
| 28 | + MissingDotNet, | |
| 29 | + /// Required dependency is missing | |
| 30 | + MissingDependency(String), | |
| 31 | + /// Registry appears corrupted | |
| 32 | + CorruptedRegistry, | |
| 33 | + /// WeMod is not installed | |
| 34 | + WemodNotInstalled, | |
| 35 | + /// WeMod version is outdated | |
| 36 | + WemodOutdated, | |
| 37 | + /// Wine prefix structure is incomplete | |
| 38 | + IncompletePrefix, | |
| 39 | +} | |
| 40 | + | |
| 41 | +impl std::fmt::Display for PrefixIssue { | |
| 42 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 43 | + match self { | |
| 44 | + Self::MissingDotNet => write!(f, ".NET Framework 4.8 not installed"), | |
| 45 | + Self::MissingDependency(dep) => write!(f, "Missing dependency: {}", dep), | |
| 46 | + Self::CorruptedRegistry => write!(f, "Windows registry is corrupted"), | |
| 47 | + Self::WemodNotInstalled => write!(f, "WeMod is not installed"), | |
| 48 | + Self::WemodOutdated => write!(f, "WeMod is outdated"), | |
| 49 | + Self::IncompletePrefix => write!(f, "Wine prefix structure is incomplete"), | |
| 50 | + } | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +/// Metadata about a WANDA prefix | |
| 55 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 56 | +pub struct WandaPrefix { | |
| 57 | + /// Name of this prefix | |
| 58 | + pub name: String, | |
| 59 | + /// Path to the prefix | |
| 60 | + pub path: PathBuf, | |
| 61 | + /// Whether WeMod is installed in this prefix | |
| 62 | + pub wemod_installed: bool, | |
| 63 | + /// Installed WeMod version | |
| 64 | + pub wemod_version: Option<String>, | |
| 65 | + /// Proton version used to create this prefix | |
| 66 | + pub proton_version: Option<String>, | |
| 67 | + /// When the prefix was created | |
| 68 | + pub created_at: Option<String>, | |
| 69 | + /// When the prefix was last used | |
| 70 | + pub last_used: Option<String>, | |
| 71 | +} | |
| 72 | + | |
| 73 | +impl WandaPrefix { | |
| 74 | + /// Get the path to drive_c | |
| 75 | + pub fn drive_c(&self) -> PathBuf { | |
| 76 | + self.path.join("drive_c") | |
| 77 | + } | |
| 78 | + | |
| 79 | + /// Get the path to the Windows user folder | |
| 80 | + pub fn user_folder(&self) -> PathBuf { | |
| 81 | + self.drive_c().join("users/steamuser") | |
| 82 | + } | |
| 83 | + | |
| 84 | + /// Get the expected WeMod installation path | |
| 85 | + pub fn wemod_path(&self) -> PathBuf { | |
| 86 | + self.user_folder().join("AppData/Local/WeMod") | |
| 87 | + } | |
| 88 | + | |
| 89 | + /// Get the WeMod executable path | |
| 90 | + pub fn wemod_exe(&self) -> PathBuf { | |
| 91 | + self.wemod_path().join("WeMod.exe") | |
| 92 | + } | |
| 93 | + | |
| 94 | + /// Get the system32 directory | |
| 95 | + pub fn system32(&self) -> PathBuf { | |
| 96 | + self.drive_c().join("windows/system32") | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +/// Manages WANDA Wine prefixes | |
| 101 | +pub struct PrefixManager { | |
| 102 | + /// Base directory for prefixes | |
| 103 | + pub base_path: PathBuf, | |
| 104 | + /// Currently loaded prefixes | |
| 105 | + prefixes: Vec<WandaPrefix>, | |
| 106 | +} | |
| 107 | + | |
| 108 | +impl PrefixManager { | |
| 109 | + /// Create a new prefix manager | |
| 110 | + pub fn new(config: &WandaConfig) -> Self { | |
| 111 | + Self { | |
| 112 | + base_path: config.prefix_base_path(), | |
| 113 | + prefixes: Vec::new(), | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + /// Load all existing prefixes | |
| 118 | + pub fn load(&mut self) -> Result<()> { | |
| 119 | + self.prefixes.clear(); | |
| 120 | + | |
| 121 | + if !self.base_path.exists() { | |
| 122 | + debug!("Prefix base path doesn't exist yet: {}", self.base_path.display()); | |
| 123 | + return Ok(()); | |
| 124 | + } | |
| 125 | + | |
| 126 | + // Look for prefix metadata files | |
| 127 | + let entries = std::fs::read_dir(&self.base_path)?; | |
| 128 | + | |
| 129 | + for entry in entries.flatten() { | |
| 130 | + let path = entry.path(); | |
| 131 | + if path.is_dir() { | |
| 132 | + let meta_path = path.join("wanda.json"); | |
| 133 | + if meta_path.exists() { | |
| 134 | + match self.load_prefix_metadata(&meta_path) { | |
| 135 | + Ok(prefix) => { | |
| 136 | + debug!("Loaded prefix: {}", prefix.name); | |
| 137 | + self.prefixes.push(prefix); | |
| 138 | + } | |
| 139 | + Err(e) => { | |
| 140 | + warn!("Failed to load prefix at {}: {}", path.display(), e); | |
| 141 | + } | |
| 142 | + } | |
| 143 | + } | |
| 144 | + } | |
| 145 | + } | |
| 146 | + | |
| 147 | + info!("Loaded {} prefixes", self.prefixes.len()); | |
| 148 | + Ok(()) | |
| 149 | + } | |
| 150 | + | |
| 151 | + /// Load prefix metadata from file | |
| 152 | + fn load_prefix_metadata(&self, path: &Path) -> Result<WandaPrefix> { | |
| 153 | + let content = std::fs::read_to_string(path)?; | |
| 154 | + let prefix: WandaPrefix = serde_json::from_str(&content)?; | |
| 155 | + Ok(prefix) | |
| 156 | + } | |
| 157 | + | |
| 158 | + /// Save prefix metadata to file | |
| 159 | + fn save_prefix_metadata(prefix: &WandaPrefix) -> Result<()> { | |
| 160 | + let meta_path = prefix.path.join("wanda.json"); | |
| 161 | + let content = serde_json::to_string_pretty(prefix)?; | |
| 162 | + std::fs::write(meta_path, content)?; | |
| 163 | + Ok(()) | |
| 164 | + } | |
| 165 | + | |
| 166 | + /// Get all prefixes | |
| 167 | + pub fn list(&self) -> &[WandaPrefix] { | |
| 168 | + &self.prefixes | |
| 169 | + } | |
| 170 | + | |
| 171 | + /// Get a prefix by name | |
| 172 | + pub fn get(&self, name: &str) -> Option<&WandaPrefix> { | |
| 173 | + self.prefixes.iter().find(|p| p.name == name) | |
| 174 | + } | |
| 175 | + | |
| 176 | + /// Get the default prefix (creates one if it doesn't exist) | |
| 177 | + pub async fn get_or_create_default( | |
| 178 | + &mut self, | |
| 179 | + proton: &ProtonVersion, | |
| 180 | + ) -> Result<&WandaPrefix> { | |
| 181 | + const DEFAULT_NAME: &str = "default"; | |
| 182 | + | |
| 183 | + // Check if default exists | |
| 184 | + if self.get(DEFAULT_NAME).is_some() { | |
| 185 | + // Validate health | |
| 186 | + let health = self.validate(DEFAULT_NAME)?; | |
| 187 | + if health != PrefixHealth::Healthy { | |
| 188 | + warn!("Default prefix needs attention: {:?}", health); | |
| 189 | + } | |
| 190 | + return Ok(self.get(DEFAULT_NAME).unwrap()); | |
| 191 | + } | |
| 192 | + | |
| 193 | + // Create the default prefix | |
| 194 | + self.create(DEFAULT_NAME, proton).await?; | |
| 195 | + Ok(self.get(DEFAULT_NAME).unwrap()) | |
| 196 | + } | |
| 197 | + | |
| 198 | + /// Create a new prefix | |
| 199 | + pub async fn create(&mut self, name: &str, proton: &ProtonVersion) -> Result<WandaPrefix> { | |
| 200 | + let prefix_path = self.base_path.join(name); | |
| 201 | + | |
| 202 | + if prefix_path.exists() { | |
| 203 | + return Err(WandaError::PrefixCreationFailed { | |
| 204 | + path: prefix_path, | |
| 205 | + reason: "Prefix already exists".to_string(), | |
| 206 | + }); | |
| 207 | + } | |
| 208 | + | |
| 209 | + info!("Creating prefix '{}' at {}", name, prefix_path.display()); | |
| 210 | + | |
| 211 | + // Use PrefixBuilder to set up the prefix | |
| 212 | + let builder = PrefixBuilder::new(&prefix_path, proton); | |
| 213 | + builder.build().await?; | |
| 214 | + | |
| 215 | + // Create metadata | |
| 216 | + let prefix = WandaPrefix { | |
| 217 | + name: name.to_string(), | |
| 218 | + path: prefix_path, | |
| 219 | + wemod_installed: false, | |
| 220 | + wemod_version: None, | |
| 221 | + proton_version: Some(proton.name.clone()), | |
| 222 | + created_at: Some(chrono_lite_now()), | |
| 223 | + last_used: None, | |
| 224 | + }; | |
| 225 | + | |
| 226 | + Self::save_prefix_metadata(&prefix)?; | |
| 227 | + self.prefixes.push(prefix.clone()); | |
| 228 | + | |
| 229 | + info!("Prefix '{}' created successfully", name); | |
| 230 | + Ok(prefix) | |
| 231 | + } | |
| 232 | + | |
| 233 | + /// Validate a prefix's health | |
| 234 | + pub fn validate(&self, name: &str) -> Result<PrefixHealth> { | |
| 235 | + let prefix = self.get(name).ok_or_else(|| WandaError::PrefixNotFound { | |
| 236 | + path: self.base_path.join(name), | |
| 237 | + })?; | |
| 238 | + | |
| 239 | + let mut issues = Vec::new(); | |
| 240 | + | |
| 241 | + // Check basic structure | |
| 242 | + if !prefix.drive_c().exists() { | |
| 243 | + return Ok(PrefixHealth::NotCreated); | |
| 244 | + } | |
| 245 | + | |
| 246 | + if !prefix.system32().exists() { | |
| 247 | + issues.push(PrefixIssue::IncompletePrefix); | |
| 248 | + } | |
| 249 | + | |
| 250 | + // Check for registry files | |
| 251 | + let system_reg = prefix.path.join("system.reg"); | |
| 252 | + let user_reg = prefix.path.join("user.reg"); | |
| 253 | + if !system_reg.exists() || !user_reg.exists() { | |
| 254 | + issues.push(PrefixIssue::CorruptedRegistry); | |
| 255 | + } | |
| 256 | + | |
| 257 | + // Check .NET (look for mscorlib.dll as indicator) | |
| 258 | + let dotnet_indicator = prefix.drive_c().join("windows/Microsoft.NET"); | |
| 259 | + if !dotnet_indicator.exists() { | |
| 260 | + issues.push(PrefixIssue::MissingDotNet); | |
| 261 | + } | |
| 262 | + | |
| 263 | + // Check WeMod | |
| 264 | + if !prefix.wemod_exe().exists() { | |
| 265 | + issues.push(PrefixIssue::WemodNotInstalled); | |
| 266 | + } | |
| 267 | + | |
| 268 | + if issues.is_empty() { | |
| 269 | + Ok(PrefixHealth::Healthy) | |
| 270 | + } else if issues.contains(&PrefixIssue::CorruptedRegistry) { | |
| 271 | + Ok(PrefixHealth::Corrupted("Registry files are missing or corrupted".to_string())) | |
| 272 | + } else { | |
| 273 | + Ok(PrefixHealth::NeedsRepair(issues)) | |
| 274 | + } | |
| 275 | + } | |
| 276 | + | |
| 277 | + /// Attempt to repair a prefix | |
| 278 | + pub async fn repair(&mut self, name: &str, proton: &ProtonVersion) -> Result<()> { | |
| 279 | + let health = self.validate(name)?; | |
| 280 | + | |
| 281 | + match health { | |
| 282 | + PrefixHealth::Healthy => { | |
| 283 | + info!("Prefix '{}' is already healthy", name); | |
| 284 | + return Ok(()); | |
| 285 | + } | |
| 286 | + PrefixHealth::Corrupted(reason) => { | |
| 287 | + return Err(WandaError::PrefixCorrupted { | |
| 288 | + path: self.base_path.join(name), | |
| 289 | + reason, | |
| 290 | + }); | |
| 291 | + } | |
| 292 | + PrefixHealth::NotCreated => { | |
| 293 | + info!("Prefix doesn't exist, creating it"); | |
| 294 | + self.create(name, proton).await?; | |
| 295 | + return Ok(()); | |
| 296 | + } | |
| 297 | + PrefixHealth::NeedsRepair(issues) => { | |
| 298 | + info!("Repairing prefix '{}': {:?}", name, issues); | |
| 299 | + // Get prefix path before mutable borrow | |
| 300 | + let prefix_path = self.base_path.join(name); | |
| 301 | + let builder = PrefixBuilder::new(&prefix_path, proton); | |
| 302 | + | |
| 303 | + for issue in issues { | |
| 304 | + match issue { | |
| 305 | + PrefixIssue::MissingDotNet => { | |
| 306 | + info!("Installing .NET Framework..."); | |
| 307 | + builder.install_dotnet().await?; | |
| 308 | + } | |
| 309 | + PrefixIssue::MissingDependency(dep) => { | |
| 310 | + info!("Installing dependency: {}", dep); | |
| 311 | + builder.install_winetricks(&[&dep]).await?; | |
| 312 | + } | |
| 313 | + PrefixIssue::WemodNotInstalled | PrefixIssue::WemodOutdated => { | |
| 314 | + // WeMod installation is handled separately | |
| 315 | + info!("WeMod needs to be installed/updated"); | |
| 316 | + } | |
| 317 | + _ => { | |
| 318 | + warn!("Cannot automatically repair: {:?}", issue); | |
| 319 | + } | |
| 320 | + } | |
| 321 | + } | |
| 322 | + } | |
| 323 | + } | |
| 324 | + | |
| 325 | + Ok(()) | |
| 326 | + } | |
| 327 | + | |
| 328 | + /// Delete a prefix | |
| 329 | + pub fn delete(&mut self, name: &str) -> Result<()> { | |
| 330 | + let prefix_path = self.base_path.join(name); | |
| 331 | + | |
| 332 | + if !prefix_path.exists() { | |
| 333 | + return Err(WandaError::PrefixNotFound { path: prefix_path }); | |
| 334 | + } | |
| 335 | + | |
| 336 | + info!("Deleting prefix '{}' at {}", name, prefix_path.display()); | |
| 337 | + std::fs::remove_dir_all(&prefix_path)?; | |
| 338 | + | |
| 339 | + self.prefixes.retain(|p| p.name != name); | |
| 340 | + | |
| 341 | + Ok(()) | |
| 342 | + } | |
| 343 | + | |
| 344 | + /// Update prefix metadata (e.g., after WeMod installation) | |
| 345 | + pub fn update_metadata(&mut self, name: &str, updater: impl FnOnce(&mut WandaPrefix)) -> Result<()> { | |
| 346 | + if let Some(prefix) = self.prefixes.iter_mut().find(|p| p.name == name) { | |
| 347 | + updater(prefix); | |
| 348 | + Self::save_prefix_metadata(prefix)?; | |
| 349 | + } | |
| 350 | + Ok(()) | |
| 351 | + } | |
| 352 | +} | |
| 353 | + | |
| 354 | +/// Get current timestamp as ISO string (simple implementation) | |
| 355 | +fn chrono_lite_now() -> String { | |
| 356 | + use std::time::{SystemTime, UNIX_EPOCH}; | |
| 357 | + let duration = SystemTime::now() | |
| 358 | + .duration_since(UNIX_EPOCH) | |
| 359 | + .unwrap_or_default(); | |
| 360 | + format!("{}", duration.as_secs()) | |
| 361 | +} | |
crates/wanda-core/src/prefix/mod.rsadded@@ -0,0 +1,9 @@ | ||
| 1 | +//! Wine/Proton prefix management | |
| 2 | +//! | |
| 3 | +//! Handles creation, validation, and repair of Wine prefixes for WeMod. | |
| 4 | + | |
| 5 | +mod builder; | |
| 6 | +mod manager; | |
| 7 | + | |
| 8 | +pub use builder::PrefixBuilder; | |
| 9 | +pub use manager::{PrefixHealth, PrefixIssue, PrefixManager, WandaPrefix}; | |
crates/wanda-core/src/steam/library.rsadded@@ -0,0 +1,348 @@ | ||
| 1 | +//! Steam library and game discovery | |
| 2 | + | |
| 3 | +use crate::config::WandaConfig; | |
| 4 | +use crate::error::{Result, WandaError}; | |
| 5 | +use crate::steam::vdf::parse_vdf_file; | |
| 6 | +use std::collections::HashMap; | |
| 7 | +use std::path::{Path, PathBuf}; | |
| 8 | +use tracing::{debug, info, warn}; | |
| 9 | + | |
| 10 | +/// A Steam installation with all its libraries | |
| 11 | +#[derive(Debug, Clone)] | |
| 12 | +pub struct SteamInstallation { | |
| 13 | + /// Root Steam installation path | |
| 14 | + pub root_path: PathBuf, | |
| 15 | + /// All discovered library folders | |
| 16 | + pub libraries: Vec<SteamLibrary>, | |
| 17 | + /// Whether this is a Flatpak installation | |
| 18 | + pub is_flatpak: bool, | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// A Steam library folder containing games | |
| 22 | +#[derive(Debug, Clone)] | |
| 23 | +pub struct SteamLibrary { | |
| 24 | + /// Path to the library folder | |
| 25 | + pub path: PathBuf, | |
| 26 | + /// Optional label for this library | |
| 27 | + pub label: Option<String>, | |
| 28 | + /// Installed apps in this library (app_id -> SteamApp) | |
| 29 | + pub apps: HashMap<u32, SteamApp>, | |
| 30 | +} | |
| 31 | + | |
| 32 | +/// A Steam game/application | |
| 33 | +#[derive(Debug, Clone)] | |
| 34 | +pub struct SteamApp { | |
| 35 | + /// Steam App ID | |
| 36 | + pub app_id: u32, | |
| 37 | + /// Game name | |
| 38 | + pub name: String, | |
| 39 | + /// Installation directory name (relative to steamapps/common/) | |
| 40 | + pub install_dir: String, | |
| 41 | + /// Full path to the game installation | |
| 42 | + pub install_path: PathBuf, | |
| 43 | + /// Library this game belongs to | |
| 44 | + pub library_path: PathBuf, | |
| 45 | + /// Size on disk in bytes | |
| 46 | + pub size_on_disk: u64, | |
| 47 | + /// Path to the Proton compatdata directory (if using Proton) | |
| 48 | + pub compat_data_path: Option<PathBuf>, | |
| 49 | +} | |
| 50 | + | |
| 51 | +impl SteamInstallation { | |
| 52 | + /// Discover Steam installation on the system | |
| 53 | + pub fn discover(config: &WandaConfig) -> Result<Self> { | |
| 54 | + // Check for user-specified path first | |
| 55 | + if let Some(ref path) = config.steam.install_path { | |
| 56 | + if Self::is_valid_steam_path(path) { | |
| 57 | + info!("Using configured Steam path: {}", path.display()); | |
| 58 | + return Self::from_path(path.clone(), false); | |
| 59 | + } | |
| 60 | + warn!( | |
| 61 | + "Configured Steam path {} is invalid, falling back to auto-detection", | |
| 62 | + path.display() | |
| 63 | + ); | |
| 64 | + } | |
| 65 | + | |
| 66 | + // Try standard locations | |
| 67 | + let candidates = Self::get_candidate_paths(config.steam.scan_flatpak); | |
| 68 | + | |
| 69 | + for (path, is_flatpak) in candidates { | |
| 70 | + if Self::is_valid_steam_path(&path) { | |
| 71 | + info!("Found Steam installation at: {}", path.display()); | |
| 72 | + return Self::from_path(path, is_flatpak); | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + Err(WandaError::SteamNotFound) | |
| 77 | + } | |
| 78 | + | |
| 79 | + /// Get candidate Steam installation paths | |
| 80 | + fn get_candidate_paths(scan_flatpak: bool) -> Vec<(PathBuf, bool)> { | |
| 81 | + let mut candidates = Vec::new(); | |
| 82 | + | |
| 83 | + // Standard native Steam locations | |
| 84 | + if let Some(data_dir) = dirs::data_local_dir() { | |
| 85 | + candidates.push((data_dir.join("Steam"), false)); | |
| 86 | + } | |
| 87 | + if let Some(home) = dirs::home_dir() { | |
| 88 | + candidates.push((home.join(".steam/steam"), false)); | |
| 89 | + candidates.push((home.join(".steam/root"), false)); | |
| 90 | + } | |
| 91 | + | |
| 92 | + // Flatpak Steam | |
| 93 | + if scan_flatpak { | |
| 94 | + if let Some(home) = dirs::home_dir() { | |
| 95 | + candidates.push(( | |
| 96 | + home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), | |
| 97 | + true, | |
| 98 | + )); | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + candidates | |
| 103 | + } | |
| 104 | + | |
| 105 | + /// Check if a path is a valid Steam installation | |
| 106 | + fn is_valid_steam_path(path: &Path) -> bool { | |
| 107 | + // Steam should have a config directory with libraryfolders.vdf | |
| 108 | + let config_vdf = path.join("config/libraryfolders.vdf"); | |
| 109 | + let steamapps_vdf = path.join("steamapps/libraryfolders.vdf"); | |
| 110 | + | |
| 111 | + config_vdf.exists() || steamapps_vdf.exists() | |
| 112 | + } | |
| 113 | + | |
| 114 | + /// Create SteamInstallation from a known valid path | |
| 115 | + fn from_path(root_path: PathBuf, is_flatpak: bool) -> Result<Self> { | |
| 116 | + let mut installation = Self { | |
| 117 | + root_path: root_path.clone(), | |
| 118 | + libraries: Vec::new(), | |
| 119 | + is_flatpak, | |
| 120 | + }; | |
| 121 | + | |
| 122 | + // Parse library folders | |
| 123 | + let library_folders_path = if root_path.join("config/libraryfolders.vdf").exists() { | |
| 124 | + root_path.join("config/libraryfolders.vdf") | |
| 125 | + } else { | |
| 126 | + root_path.join("steamapps/libraryfolders.vdf") | |
| 127 | + }; | |
| 128 | + | |
| 129 | + installation.parse_library_folders(&library_folders_path)?; | |
| 130 | + | |
| 131 | + Ok(installation) | |
| 132 | + } | |
| 133 | + | |
| 134 | + /// Parse libraryfolders.vdf to discover all library locations | |
| 135 | + fn parse_library_folders(&mut self, vdf_path: &Path) -> Result<()> { | |
| 136 | + let vdf = parse_vdf_file(vdf_path)?; | |
| 137 | + | |
| 138 | + let folders = vdf | |
| 139 | + .get("libraryfolders") | |
| 140 | + .ok_or_else(|| WandaError::VdfParseError { | |
| 141 | + path: vdf_path.to_path_buf(), | |
| 142 | + reason: "Missing 'libraryfolders' key".to_string(), | |
| 143 | + })?; | |
| 144 | + | |
| 145 | + let folders_obj = folders.as_object().ok_or_else(|| WandaError::VdfParseError { | |
| 146 | + path: vdf_path.to_path_buf(), | |
| 147 | + reason: "'libraryfolders' is not an object".to_string(), | |
| 148 | + })?; | |
| 149 | + | |
| 150 | + for (key, value) in folders_obj { | |
| 151 | + // Library entries are numbered: "0", "1", "2", etc. | |
| 152 | + if key.parse::<u32>().is_err() { | |
| 153 | + continue; | |
| 154 | + } | |
| 155 | + | |
| 156 | + if let Some(path_str) = value.get_str("path") { | |
| 157 | + let library_path = PathBuf::from(path_str); | |
| 158 | + let label = value.get_str("label").map(String::from); | |
| 159 | + | |
| 160 | + debug!("Found Steam library: {}", library_path.display()); | |
| 161 | + | |
| 162 | + let mut library = SteamLibrary { | |
| 163 | + path: library_path.clone(), | |
| 164 | + label, | |
| 165 | + apps: HashMap::new(), | |
| 166 | + }; | |
| 167 | + | |
| 168 | + // Scan for installed apps | |
| 169 | + library.scan_apps()?; | |
| 170 | + self.libraries.push(library); | |
| 171 | + } | |
| 172 | + } | |
| 173 | + | |
| 174 | + info!("Discovered {} Steam libraries", self.libraries.len()); | |
| 175 | + Ok(()) | |
| 176 | + } | |
| 177 | + | |
| 178 | + /// Get all installed games across all libraries | |
| 179 | + pub fn get_all_games(&self) -> Vec<&SteamApp> { | |
| 180 | + self.libraries | |
| 181 | + .iter() | |
| 182 | + .flat_map(|lib| lib.apps.values()) | |
| 183 | + .collect() | |
| 184 | + } | |
| 185 | + | |
| 186 | + /// Find a game by app ID | |
| 187 | + pub fn find_game(&self, app_id: u32) -> Option<&SteamApp> { | |
| 188 | + for library in &self.libraries { | |
| 189 | + if let Some(app) = library.apps.get(&app_id) { | |
| 190 | + return Some(app); | |
| 191 | + } | |
| 192 | + } | |
| 193 | + None | |
| 194 | + } | |
| 195 | + | |
| 196 | + /// Find a game by name (case-insensitive partial match) | |
| 197 | + pub fn find_game_by_name(&self, name: &str) -> Vec<&SteamApp> { | |
| 198 | + let name_lower = name.to_lowercase(); | |
| 199 | + self.get_all_games() | |
| 200 | + .into_iter() | |
| 201 | + .filter(|app| app.name.to_lowercase().contains(&name_lower)) | |
| 202 | + .collect() | |
| 203 | + } | |
| 204 | +} | |
| 205 | + | |
| 206 | +impl SteamLibrary { | |
| 207 | + /// Scan for installed apps in this library | |
| 208 | + fn scan_apps(&mut self) -> Result<()> { | |
| 209 | + let steamapps_dir = self.path.join("steamapps"); | |
| 210 | + if !steamapps_dir.exists() { | |
| 211 | + return Ok(()); | |
| 212 | + } | |
| 213 | + | |
| 214 | + // Look for appmanifest_*.acf files | |
| 215 | + let entries = std::fs::read_dir(&steamapps_dir).map_err(|e| { | |
| 216 | + WandaError::SteamLibraryInaccessible { | |
| 217 | + path: self.path.clone(), | |
| 218 | + reason: e.to_string(), | |
| 219 | + } | |
| 220 | + })?; | |
| 221 | + | |
| 222 | + for entry in entries.flatten() { | |
| 223 | + let path = entry.path(); | |
| 224 | + let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); | |
| 225 | + | |
| 226 | + if filename.starts_with("appmanifest_") && filename.ends_with(".acf") { | |
| 227 | + match self.parse_app_manifest(&path) { | |
| 228 | + Ok(app) => { | |
| 229 | + debug!("Found game: {} ({})", app.name, app.app_id); | |
| 230 | + self.apps.insert(app.app_id, app); | |
| 231 | + } | |
| 232 | + Err(e) => { | |
| 233 | + warn!("Failed to parse {}: {}", path.display(), e); | |
| 234 | + } | |
| 235 | + } | |
| 236 | + } | |
| 237 | + } | |
| 238 | + | |
| 239 | + info!( | |
| 240 | + "Found {} games in library {}", | |
| 241 | + self.apps.len(), | |
| 242 | + self.path.display() | |
| 243 | + ); | |
| 244 | + Ok(()) | |
| 245 | + } | |
| 246 | + | |
| 247 | + /// Parse an appmanifest_*.acf file | |
| 248 | + fn parse_app_manifest(&self, acf_path: &Path) -> Result<SteamApp> { | |
| 249 | + let vdf = parse_vdf_file(acf_path)?; | |
| 250 | + | |
| 251 | + let app_state = vdf.get("AppState").ok_or_else(|| WandaError::VdfParseError { | |
| 252 | + path: acf_path.to_path_buf(), | |
| 253 | + reason: "Missing 'AppState' key".to_string(), | |
| 254 | + })?; | |
| 255 | + | |
| 256 | + let app_id: u32 = app_state | |
| 257 | + .get_str("appid") | |
| 258 | + .ok_or_else(|| WandaError::VdfParseError { | |
| 259 | + path: acf_path.to_path_buf(), | |
| 260 | + reason: "Missing 'appid'".to_string(), | |
| 261 | + })? | |
| 262 | + .parse() | |
| 263 | + .map_err(|_| WandaError::VdfParseError { | |
| 264 | + path: acf_path.to_path_buf(), | |
| 265 | + reason: "Invalid 'appid'".to_string(), | |
| 266 | + })?; | |
| 267 | + | |
| 268 | + let name = app_state | |
| 269 | + .get_str("name") | |
| 270 | + .unwrap_or("Unknown") | |
| 271 | + .to_string(); | |
| 272 | + | |
| 273 | + let install_dir = app_state | |
| 274 | + .get_str("installdir") | |
| 275 | + .unwrap_or("") | |
| 276 | + .to_string(); | |
| 277 | + | |
| 278 | + let size_on_disk: u64 = app_state | |
| 279 | + .get_str("SizeOnDisk") | |
| 280 | + .and_then(|s| s.parse().ok()) | |
| 281 | + .unwrap_or(0); | |
| 282 | + | |
| 283 | + let install_path = self.path.join("steamapps/common").join(&install_dir); | |
| 284 | + let compat_data_path = self.path.join("steamapps/compatdata").join(app_id.to_string()); | |
| 285 | + | |
| 286 | + Ok(SteamApp { | |
| 287 | + app_id, | |
| 288 | + name, | |
| 289 | + install_dir, | |
| 290 | + install_path, | |
| 291 | + library_path: self.path.clone(), | |
| 292 | + size_on_disk, | |
| 293 | + compat_data_path: if compat_data_path.exists() { | |
| 294 | + Some(compat_data_path) | |
| 295 | + } else { | |
| 296 | + None | |
| 297 | + }, | |
| 298 | + }) | |
| 299 | + } | |
| 300 | +} | |
| 301 | + | |
| 302 | +impl SteamApp { | |
| 303 | + /// Check if this game uses Proton (has compatdata) | |
| 304 | + pub fn uses_proton(&self) -> bool { | |
| 305 | + self.compat_data_path.is_some() | |
| 306 | + } | |
| 307 | + | |
| 308 | + /// Get the Wine prefix path for this game (inside compatdata) | |
| 309 | + pub fn get_prefix_path(&self) -> Option<PathBuf> { | |
| 310 | + self.compat_data_path.as_ref().map(|p| p.join("pfx")) | |
| 311 | + } | |
| 312 | + | |
| 313 | + /// Format size as human-readable string | |
| 314 | + pub fn size_human(&self) -> String { | |
| 315 | + const KB: u64 = 1024; | |
| 316 | + const MB: u64 = KB * 1024; | |
| 317 | + const GB: u64 = MB * 1024; | |
| 318 | + | |
| 319 | + if self.size_on_disk >= GB { | |
| 320 | + format!("{:.1} GB", self.size_on_disk as f64 / GB as f64) | |
| 321 | + } else if self.size_on_disk >= MB { | |
| 322 | + format!("{:.1} MB", self.size_on_disk as f64 / MB as f64) | |
| 323 | + } else if self.size_on_disk >= KB { | |
| 324 | + format!("{:.1} KB", self.size_on_disk as f64 / KB as f64) | |
| 325 | + } else { | |
| 326 | + format!("{} B", self.size_on_disk) | |
| 327 | + } | |
| 328 | + } | |
| 329 | +} | |
| 330 | + | |
| 331 | +#[cfg(test)] | |
| 332 | +mod tests { | |
| 333 | + use super::*; | |
| 334 | + | |
| 335 | + #[test] | |
| 336 | + fn test_size_human() { | |
| 337 | + let app = SteamApp { | |
| 338 | + app_id: 1, | |
| 339 | + name: "Test".to_string(), | |
| 340 | + install_dir: "test".to_string(), | |
| 341 | + install_path: PathBuf::new(), | |
| 342 | + library_path: PathBuf::new(), | |
| 343 | + size_on_disk: 50 * 1024 * 1024 * 1024, // 50 GB | |
| 344 | + compat_data_path: None, | |
| 345 | + }; | |
| 346 | + assert_eq!(app.size_human(), "50.0 GB"); | |
| 347 | + } | |
| 348 | +} | |
crates/wanda-core/src/steam/mod.rsadded@@ -0,0 +1,11 @@ | ||
| 1 | +//! Steam integration module | |
| 2 | +//! | |
| 3 | +//! Handles Steam library discovery, game detection, and Proton version management. | |
| 4 | + | |
| 5 | +mod library; | |
| 6 | +mod proton; | |
| 7 | +mod vdf; | |
| 8 | + | |
| 9 | +pub use library::{SteamApp, SteamInstallation, SteamLibrary}; | |
| 10 | +pub use proton::{ProtonCompatibility, ProtonManager, ProtonVersion}; | |
| 11 | +pub use vdf::parse_vdf_file; | |
crates/wanda-core/src/steam/proton.rsadded@@ -0,0 +1,326 @@ | ||
| 1 | +//! Proton version detection and management | |
| 2 | + | |
| 3 | +use crate::config::WandaConfig; | |
| 4 | +use crate::error::{Result, WandaError}; | |
| 5 | +use crate::steam::SteamInstallation; | |
| 6 | +use std::cmp::Ordering; | |
| 7 | +use std::path::{Path, PathBuf}; | |
| 8 | +use tracing::{debug, info, warn}; | |
| 9 | + | |
| 10 | +/// Proton compatibility level with WeMod | |
| 11 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 12 | +pub enum ProtonCompatibility { | |
| 13 | + /// Known to work well with WeMod (GE-Proton 8+) | |
| 14 | + Recommended, | |
| 15 | + /// Should work with WeMod | |
| 16 | + Supported, | |
| 17 | + /// May work but not tested | |
| 18 | + Experimental, | |
| 19 | + /// Known to have issues | |
| 20 | + Unsupported, | |
| 21 | +} | |
| 22 | + | |
| 23 | +/// A detected Proton installation | |
| 24 | +#[derive(Debug, Clone)] | |
| 25 | +pub struct ProtonVersion { | |
| 26 | + /// Display name (e.g., "GE-Proton9-5", "Proton 8.0") | |
| 27 | + pub name: String, | |
| 28 | + /// Path to the Proton installation | |
| 29 | + pub path: PathBuf, | |
| 30 | + /// Parsed version numbers (major, minor, patch) | |
| 31 | + pub version: (u32, u32, u32), | |
| 32 | + /// Whether this is a GE-Proton build | |
| 33 | + pub is_ge: bool, | |
| 34 | + /// Whether this is Proton Experimental | |
| 35 | + pub is_experimental: bool, | |
| 36 | + /// Compatibility level with WeMod | |
| 37 | + pub compatibility: ProtonCompatibility, | |
| 38 | +} | |
| 39 | + | |
| 40 | +impl ProtonVersion { | |
| 41 | + /// Get the path to the proton executable | |
| 42 | + pub fn proton_exe(&self) -> PathBuf { | |
| 43 | + self.path.join("proton") | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// Get the path to the Wine binary | |
| 47 | + pub fn wine_exe(&self) -> PathBuf { | |
| 48 | + self.path.join("files/bin/wine64") | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +impl PartialOrd for ProtonVersion { | |
| 53 | + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { | |
| 54 | + Some(self.cmp(other)) | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +impl Ord for ProtonVersion { | |
| 59 | + fn cmp(&self, other: &Self) -> Ordering { | |
| 60 | + // Prefer GE-Proton over standard | |
| 61 | + match (self.is_ge, other.is_ge) { | |
| 62 | + (true, false) => return Ordering::Greater, | |
| 63 | + (false, true) => return Ordering::Less, | |
| 64 | + _ => {} | |
| 65 | + } | |
| 66 | + // Then compare by version | |
| 67 | + self.version.cmp(&other.version) | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +impl PartialEq for ProtonVersion { | |
| 72 | + fn eq(&self, other: &Self) -> bool { | |
| 73 | + self.name == other.name && self.path == other.path | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +impl Eq for ProtonVersion {} | |
| 78 | + | |
| 79 | +/// Manages Proton version discovery and selection | |
| 80 | +pub struct ProtonManager { | |
| 81 | + /// All discovered Proton versions | |
| 82 | + pub versions: Vec<ProtonVersion>, | |
| 83 | +} | |
| 84 | + | |
| 85 | +impl ProtonManager { | |
| 86 | + /// Discover all Proton versions on the system | |
| 87 | + pub fn discover(steam: &SteamInstallation, config: &WandaConfig) -> Result<Self> { | |
| 88 | + let mut versions = Vec::new(); | |
| 89 | + | |
| 90 | + // Search in Steam's common folder (official Proton) | |
| 91 | + let common_dir = steam.root_path.join("steamapps/common"); | |
| 92 | + Self::scan_directory(&common_dir, &mut versions); | |
| 93 | + | |
| 94 | + // Search in compatibility tools directory (GE-Proton, custom builds) | |
| 95 | + let compat_tools_dir = steam.root_path.join("compatibilitytools.d"); | |
| 96 | + Self::scan_directory(&compat_tools_dir, &mut versions); | |
| 97 | + | |
| 98 | + // System-wide compatibility tools | |
| 99 | + let system_compat = PathBuf::from("/usr/share/steam/compatibilitytools.d"); | |
| 100 | + if system_compat.exists() { | |
| 101 | + Self::scan_directory(&system_compat, &mut versions); | |
| 102 | + } | |
| 103 | + | |
| 104 | + // User-configured additional paths | |
| 105 | + for path in &config.proton.search_paths { | |
| 106 | + if path.exists() { | |
| 107 | + Self::scan_directory(path, &mut versions); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + // Sort by preference (GE-Proton first, then by version descending) | |
| 112 | + versions.sort_by(|a, b| b.cmp(a)); | |
| 113 | + | |
| 114 | + info!("Discovered {} Proton versions", versions.len()); | |
| 115 | + for v in &versions { | |
| 116 | + debug!( | |
| 117 | + " {} ({:?}) at {}", | |
| 118 | + v.name, | |
| 119 | + v.compatibility, | |
| 120 | + v.path.display() | |
| 121 | + ); | |
| 122 | + } | |
| 123 | + | |
| 124 | + Ok(Self { versions }) | |
| 125 | + } | |
| 126 | + | |
| 127 | + /// Scan a directory for Proton installations | |
| 128 | + fn scan_directory(dir: &Path, versions: &mut Vec<ProtonVersion>) { | |
| 129 | + let entries = match std::fs::read_dir(dir) { | |
| 130 | + Ok(e) => e, | |
| 131 | + Err(_) => return, | |
| 132 | + }; | |
| 133 | + | |
| 134 | + for entry in entries.flatten() { | |
| 135 | + let path = entry.path(); | |
| 136 | + if path.is_dir() { | |
| 137 | + if let Some(version) = Self::detect_proton(&path) { | |
| 138 | + versions.push(version); | |
| 139 | + } | |
| 140 | + } | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + /// Detect if a directory contains a Proton installation | |
| 145 | + fn detect_proton(path: &Path) -> Option<ProtonVersion> { | |
| 146 | + // Proton directories should have a 'proton' script | |
| 147 | + let proton_script = path.join("proton"); | |
| 148 | + if !proton_script.exists() { | |
| 149 | + return None; | |
| 150 | + } | |
| 151 | + | |
| 152 | + let name = path.file_name()?.to_str()?.to_string(); | |
| 153 | + | |
| 154 | + // Parse version from name | |
| 155 | + let (version, is_ge, is_experimental) = Self::parse_version_from_name(&name); | |
| 156 | + let compatibility = Self::determine_compatibility(&name, version, is_ge, is_experimental); | |
| 157 | + | |
| 158 | + Some(ProtonVersion { | |
| 159 | + name, | |
| 160 | + path: path.to_path_buf(), | |
| 161 | + version, | |
| 162 | + is_ge, | |
| 163 | + is_experimental, | |
| 164 | + compatibility, | |
| 165 | + }) | |
| 166 | + } | |
| 167 | + | |
| 168 | + /// Parse version numbers from Proton directory name | |
| 169 | + fn parse_version_from_name(name: &str) -> ((u32, u32, u32), bool, bool) { | |
| 170 | + let is_ge = name.contains("GE-Proton") || name.contains("Proton-GE"); | |
| 171 | + let is_experimental = name.contains("Experimental"); | |
| 172 | + | |
| 173 | + // Try to extract version numbers | |
| 174 | + // GE-Proton format: "GE-Proton9-5" -> (9, 5, 0) | |
| 175 | + // Standard format: "Proton 8.0-5" -> (8, 0, 5) | |
| 176 | + // Experimental: "Proton - Experimental" -> (999, 0, 0) to sort high | |
| 177 | + | |
| 178 | + if is_experimental { | |
| 179 | + return ((999, 0, 0), is_ge, is_experimental); | |
| 180 | + } | |
| 181 | + | |
| 182 | + let version = Self::extract_version_numbers(name); | |
| 183 | + (version, is_ge, is_experimental) | |
| 184 | + } | |
| 185 | + | |
| 186 | + /// Extract version numbers from a string | |
| 187 | + fn extract_version_numbers(s: &str) -> (u32, u32, u32) { | |
| 188 | + let numbers: Vec<u32> = s | |
| 189 | + .chars() | |
| 190 | + .filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-') | |
| 191 | + .collect::<String>() | |
| 192 | + .split(|c| c == '.' || c == '-') | |
| 193 | + .filter_map(|n| n.parse().ok()) | |
| 194 | + .collect(); | |
| 195 | + | |
| 196 | + match numbers.as_slice() { | |
| 197 | + [major, minor, patch, ..] => (*major, *minor, *patch), | |
| 198 | + [major, minor] => (*major, *minor, 0), | |
| 199 | + [major] => (*major, 0, 0), | |
| 200 | + [] => (0, 0, 0), | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + /// Determine compatibility level based on version info | |
| 205 | + fn determine_compatibility( | |
| 206 | + name: &str, | |
| 207 | + version: (u32, u32, u32), | |
| 208 | + is_ge: bool, | |
| 209 | + is_experimental: bool, | |
| 210 | + ) -> ProtonCompatibility { | |
| 211 | + // GE-Proton 8+ is recommended for WeMod | |
| 212 | + if is_ge && version.0 >= 8 { | |
| 213 | + return ProtonCompatibility::Recommended; | |
| 214 | + } | |
| 215 | + | |
| 216 | + // GE-Proton 7.x should work | |
| 217 | + if is_ge && version.0 >= 7 { | |
| 218 | + return ProtonCompatibility::Supported; | |
| 219 | + } | |
| 220 | + | |
| 221 | + // Proton Experimental might work | |
| 222 | + if is_experimental { | |
| 223 | + return ProtonCompatibility::Experimental; | |
| 224 | + } | |
| 225 | + | |
| 226 | + // Standard Proton 8+ should work | |
| 227 | + if version.0 >= 8 { | |
| 228 | + return ProtonCompatibility::Supported; | |
| 229 | + } | |
| 230 | + | |
| 231 | + // Wine-GE has known issues | |
| 232 | + if name.contains("Wine-GE") { | |
| 233 | + return ProtonCompatibility::Unsupported; | |
| 234 | + } | |
| 235 | + | |
| 236 | + // Older versions might have issues | |
| 237 | + if version.0 < 7 { | |
| 238 | + return ProtonCompatibility::Unsupported; | |
| 239 | + } | |
| 240 | + | |
| 241 | + ProtonCompatibility::Experimental | |
| 242 | + } | |
| 243 | + | |
| 244 | + /// Get the recommended Proton version for WeMod | |
| 245 | + pub fn get_recommended(&self) -> Option<&ProtonVersion> { | |
| 246 | + // First try to find a Recommended version | |
| 247 | + if let Some(v) = self | |
| 248 | + .versions | |
| 249 | + .iter() | |
| 250 | + .find(|v| v.compatibility == ProtonCompatibility::Recommended) | |
| 251 | + { | |
| 252 | + return Some(v); | |
| 253 | + } | |
| 254 | + | |
| 255 | + // Fall back to Supported | |
| 256 | + if let Some(v) = self | |
| 257 | + .versions | |
| 258 | + .iter() | |
| 259 | + .find(|v| v.compatibility == ProtonCompatibility::Supported) | |
| 260 | + { | |
| 261 | + return Some(v); | |
| 262 | + } | |
| 263 | + | |
| 264 | + // Fall back to Experimental | |
| 265 | + self.versions | |
| 266 | + .iter() | |
| 267 | + .find(|v| v.compatibility == ProtonCompatibility::Experimental) | |
| 268 | + } | |
| 269 | + | |
| 270 | + /// Find a Proton version by name | |
| 271 | + pub fn find_by_name(&self, name: &str) -> Option<&ProtonVersion> { | |
| 272 | + let name_lower = name.to_lowercase(); | |
| 273 | + self.versions | |
| 274 | + .iter() | |
| 275 | + .find(|v| v.name.to_lowercase().contains(&name_lower)) | |
| 276 | + } | |
| 277 | + | |
| 278 | + /// Get a version by preference: user-specified, then recommended | |
| 279 | + pub fn get_preferred(&self, config: &WandaConfig) -> Result<&ProtonVersion> { | |
| 280 | + // Check user preference first | |
| 281 | + if let Some(ref preferred) = config.proton.preferred_version { | |
| 282 | + if let Some(v) = self.find_by_name(preferred) { | |
| 283 | + if v.compatibility == ProtonCompatibility::Unsupported { | |
| 284 | + warn!( | |
| 285 | + "Preferred Proton version {} has known compatibility issues", | |
| 286 | + v.name | |
| 287 | + ); | |
| 288 | + } | |
| 289 | + return Ok(v); | |
| 290 | + } | |
| 291 | + warn!( | |
| 292 | + "Preferred Proton version '{}' not found, using recommended", | |
| 293 | + preferred | |
| 294 | + ); | |
| 295 | + } | |
| 296 | + | |
| 297 | + self.get_recommended().ok_or(WandaError::ProtonNotFound) | |
| 298 | + } | |
| 299 | +} | |
| 300 | + | |
| 301 | +#[cfg(test)] | |
| 302 | +mod tests { | |
| 303 | + use super::*; | |
| 304 | + | |
| 305 | + #[test] | |
| 306 | + fn test_version_parsing() { | |
| 307 | + let (v, is_ge, _) = ProtonManager::parse_version_from_name("GE-Proton9-5"); | |
| 308 | + assert_eq!(v, (9, 5, 0)); | |
| 309 | + assert!(is_ge); | |
| 310 | + | |
| 311 | + let (v, is_ge, _) = ProtonManager::parse_version_from_name("Proton 8.0-5"); | |
| 312 | + assert_eq!(v, (8, 0, 5)); | |
| 313 | + assert!(!is_ge); | |
| 314 | + } | |
| 315 | + | |
| 316 | + #[test] | |
| 317 | + fn test_compatibility() { | |
| 318 | + let compat = | |
| 319 | + ProtonManager::determine_compatibility("GE-Proton9-5", (9, 5, 0), true, false); | |
| 320 | + assert_eq!(compat, ProtonCompatibility::Recommended); | |
| 321 | + | |
| 322 | + let compat = | |
| 323 | + ProtonManager::determine_compatibility("Proton 6.0", (6, 0, 0), false, false); | |
| 324 | + assert_eq!(compat, ProtonCompatibility::Unsupported); | |
| 325 | + } | |
| 326 | +} | |
crates/wanda-core/src/steam/vdf.rsadded@@ -0,0 +1,238 @@ | ||
| 1 | +//! VDF (Valve Data Format) file parsing | |
| 2 | +//! | |
| 3 | +//! Steam uses VDF files for configuration (libraryfolders.vdf, appmanifest_*.acf, etc.) | |
| 4 | + | |
| 5 | +use crate::error::{Result, WandaError}; | |
| 6 | +use std::collections::HashMap; | |
| 7 | +use std::path::Path; | |
| 8 | + | |
| 9 | +/// Parsed VDF value - can be a string or a nested object | |
| 10 | +#[derive(Debug, Clone)] | |
| 11 | +pub enum VdfValue { | |
| 12 | + String(String), | |
| 13 | + Object(HashMap<String, VdfValue>), | |
| 14 | +} | |
| 15 | + | |
| 16 | +impl VdfValue { | |
| 17 | + /// Get as string if this is a string value | |
| 18 | + pub fn as_str(&self) -> Option<&str> { | |
| 19 | + match self { | |
| 20 | + VdfValue::String(s) => Some(s), | |
| 21 | + VdfValue::Object(_) => None, | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + /// Get as object if this is an object value | |
| 26 | + pub fn as_object(&self) -> Option<&HashMap<String, VdfValue>> { | |
| 27 | + match self { | |
| 28 | + VdfValue::String(_) => None, | |
| 29 | + VdfValue::Object(obj) => Some(obj), | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + /// Get a nested value by key | |
| 34 | + pub fn get(&self, key: &str) -> Option<&VdfValue> { | |
| 35 | + self.as_object()?.get(key) | |
| 36 | + } | |
| 37 | + | |
| 38 | + /// Get a string value by key | |
| 39 | + pub fn get_str(&self, key: &str) -> Option<&str> { | |
| 40 | + self.get(key)?.as_str() | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// Parse a VDF file from disk | |
| 45 | +pub fn parse_vdf_file(path: &Path) -> Result<VdfValue> { | |
| 46 | + let content = std::fs::read_to_string(path).map_err(|e| WandaError::VdfParseError { | |
| 47 | + path: path.to_path_buf(), | |
| 48 | + reason: e.to_string(), | |
| 49 | + })?; | |
| 50 | + | |
| 51 | + parse_vdf(&content).map_err(|e| WandaError::VdfParseError { | |
| 52 | + path: path.to_path_buf(), | |
| 53 | + reason: e, | |
| 54 | + }) | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// Parse VDF content from a string | |
| 58 | +pub fn parse_vdf(content: &str) -> std::result::Result<VdfValue, String> { | |
| 59 | + let mut parser = VdfParser::new(content); | |
| 60 | + parser.parse_root() | |
| 61 | +} | |
| 62 | + | |
| 63 | +struct VdfParser<'a> { | |
| 64 | + chars: std::iter::Peekable<std::str::Chars<'a>>, | |
| 65 | +} | |
| 66 | + | |
| 67 | +impl<'a> VdfParser<'a> { | |
| 68 | + fn new(content: &'a str) -> Self { | |
| 69 | + Self { | |
| 70 | + chars: content.chars().peekable(), | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + fn parse_root(&mut self) -> std::result::Result<VdfValue, String> { | |
| 75 | + self.skip_whitespace(); | |
| 76 | + | |
| 77 | + // Root is typically a single key-value pair where value is an object | |
| 78 | + let mut root = HashMap::new(); | |
| 79 | + | |
| 80 | + while self.chars.peek().is_some() { | |
| 81 | + self.skip_whitespace(); | |
| 82 | + if self.chars.peek().is_none() { | |
| 83 | + break; | |
| 84 | + } | |
| 85 | + | |
| 86 | + let key = self.parse_string()?; | |
| 87 | + self.skip_whitespace(); | |
| 88 | + let value = self.parse_value()?; | |
| 89 | + root.insert(key, value); | |
| 90 | + self.skip_whitespace(); | |
| 91 | + } | |
| 92 | + | |
| 93 | + Ok(VdfValue::Object(root)) | |
| 94 | + } | |
| 95 | + | |
| 96 | + fn parse_value(&mut self) -> std::result::Result<VdfValue, String> { | |
| 97 | + self.skip_whitespace(); | |
| 98 | + | |
| 99 | + match self.chars.peek() { | |
| 100 | + Some('{') => self.parse_object(), | |
| 101 | + Some('"') => Ok(VdfValue::String(self.parse_string()?)), | |
| 102 | + Some(c) => Err(format!("Unexpected character: {}", c)), | |
| 103 | + None => Err("Unexpected end of input".to_string()), | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + fn parse_object(&mut self) -> std::result::Result<VdfValue, String> { | |
| 108 | + self.expect_char('{')?; | |
| 109 | + let mut obj = HashMap::new(); | |
| 110 | + | |
| 111 | + loop { | |
| 112 | + self.skip_whitespace(); | |
| 113 | + match self.chars.peek() { | |
| 114 | + Some('}') => { | |
| 115 | + self.chars.next(); | |
| 116 | + break; | |
| 117 | + } | |
| 118 | + Some('"') => { | |
| 119 | + let key = self.parse_string()?; | |
| 120 | + self.skip_whitespace(); | |
| 121 | + let value = self.parse_value()?; | |
| 122 | + obj.insert(key, value); | |
| 123 | + } | |
| 124 | + Some(c) => return Err(format!("Expected '\"' or '}}', got '{}'", c)), | |
| 125 | + None => return Err("Unexpected end of input in object".to_string()), | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + Ok(VdfValue::Object(obj)) | |
| 130 | + } | |
| 131 | + | |
| 132 | + fn parse_string(&mut self) -> std::result::Result<String, String> { | |
| 133 | + self.expect_char('"')?; | |
| 134 | + let mut result = String::new(); | |
| 135 | + | |
| 136 | + loop { | |
| 137 | + match self.chars.next() { | |
| 138 | + Some('"') => break, | |
| 139 | + Some('\\') => { | |
| 140 | + // Handle escape sequences | |
| 141 | + match self.chars.next() { | |
| 142 | + Some('n') => result.push('\n'), | |
| 143 | + Some('t') => result.push('\t'), | |
| 144 | + Some('\\') => result.push('\\'), | |
| 145 | + Some('"') => result.push('"'), | |
| 146 | + Some(c) => { | |
| 147 | + result.push('\\'); | |
| 148 | + result.push(c); | |
| 149 | + } | |
| 150 | + None => return Err("Unexpected end of input in escape sequence".to_string()), | |
| 151 | + } | |
| 152 | + } | |
| 153 | + Some(c) => result.push(c), | |
| 154 | + None => return Err("Unexpected end of input in string".to_string()), | |
| 155 | + } | |
| 156 | + } | |
| 157 | + | |
| 158 | + Ok(result) | |
| 159 | + } | |
| 160 | + | |
| 161 | + fn skip_whitespace(&mut self) { | |
| 162 | + while let Some(&c) = self.chars.peek() { | |
| 163 | + if c.is_whitespace() { | |
| 164 | + self.chars.next(); | |
| 165 | + } else if c == '/' { | |
| 166 | + // Handle // comments | |
| 167 | + self.chars.next(); | |
| 168 | + if self.chars.peek() == Some(&'/') { | |
| 169 | + // Skip until end of line | |
| 170 | + while let Some(&c) = self.chars.peek() { | |
| 171 | + self.chars.next(); | |
| 172 | + if c == '\n' { | |
| 173 | + break; | |
| 174 | + } | |
| 175 | + } | |
| 176 | + } | |
| 177 | + } else { | |
| 178 | + break; | |
| 179 | + } | |
| 180 | + } | |
| 181 | + } | |
| 182 | + | |
| 183 | + fn expect_char(&mut self, expected: char) -> std::result::Result<(), String> { | |
| 184 | + match self.chars.next() { | |
| 185 | + Some(c) if c == expected => Ok(()), | |
| 186 | + Some(c) => Err(format!("Expected '{}', got '{}'", expected, c)), | |
| 187 | + None => Err(format!("Expected '{}', got end of input", expected)), | |
| 188 | + } | |
| 189 | + } | |
| 190 | +} | |
| 191 | + | |
| 192 | +#[cfg(test)] | |
| 193 | +mod tests { | |
| 194 | + use super::*; | |
| 195 | + | |
| 196 | + #[test] | |
| 197 | + fn test_parse_simple_vdf() { | |
| 198 | + let vdf = r#" | |
| 199 | + "libraryfolders" | |
| 200 | + { | |
| 201 | + "0" | |
| 202 | + { | |
| 203 | + "path" "/home/user/.local/share/Steam" | |
| 204 | + "label" "" | |
| 205 | + } | |
| 206 | + } | |
| 207 | + "#; | |
| 208 | + | |
| 209 | + let result = parse_vdf(vdf).unwrap(); | |
| 210 | + let folders = result.get("libraryfolders").unwrap(); | |
| 211 | + let folder0 = folders.get("0").unwrap(); | |
| 212 | + assert_eq!( | |
| 213 | + folder0.get_str("path"), | |
| 214 | + Some("/home/user/.local/share/Steam") | |
| 215 | + ); | |
| 216 | + } | |
| 217 | + | |
| 218 | + #[test] | |
| 219 | + fn test_parse_app_manifest() { | |
| 220 | + let acf = r#" | |
| 221 | + "AppState" | |
| 222 | + { | |
| 223 | + "appid" "292030" | |
| 224 | + "name" "The Witcher 3: Wild Hunt" | |
| 225 | + "installdir" "The Witcher 3 Wild Hunt" | |
| 226 | + "SizeOnDisk" "50000000000" | |
| 227 | + } | |
| 228 | + "#; | |
| 229 | + | |
| 230 | + let result = parse_vdf(acf).unwrap(); | |
| 231 | + let app_state = result.get("AppState").unwrap(); | |
| 232 | + assert_eq!(app_state.get_str("appid"), Some("292030")); | |
| 233 | + assert_eq!( | |
| 234 | + app_state.get_str("name"), | |
| 235 | + Some("The Witcher 3: Wild Hunt") | |
| 236 | + ); | |
| 237 | + } | |
| 238 | +} | |
crates/wanda-core/src/wemod/downloader.rsadded@@ -0,0 +1,193 @@ | ||
| 1 | +//! WeMod download functionality | |
| 2 | + | |
| 3 | +use crate::config::WandaConfig; | |
| 4 | +use crate::error::{Result, WandaError}; | |
| 5 | +use futures::StreamExt; | |
| 6 | +use std::path::PathBuf; | |
| 7 | +use tokio::fs::File; | |
| 8 | +use tokio::io::AsyncWriteExt; | |
| 9 | +use tracing::{debug, info}; | |
| 10 | + | |
| 11 | +/// WeMod download API endpoint | |
| 12 | +const WEMOD_DOWNLOAD_URL: &str = "https://api.wemod.com/client/download"; | |
| 13 | + | |
| 14 | +/// Information about a WeMod release | |
| 15 | +#[derive(Debug, Clone)] | |
| 16 | +pub struct WemodRelease { | |
| 17 | + /// Download URL | |
| 18 | + pub url: String, | |
| 19 | + /// Version string (if known) | |
| 20 | + pub version: Option<String>, | |
| 21 | + /// File size in bytes (if known) | |
| 22 | + pub size: Option<u64>, | |
| 23 | +} | |
| 24 | + | |
| 25 | +/// Handles downloading WeMod | |
| 26 | +pub struct WemodDownloader { | |
| 27 | + /// HTTP client | |
| 28 | + client: reqwest::Client, | |
| 29 | + /// Cache directory for downloads | |
| 30 | + cache_dir: PathBuf, | |
| 31 | +} | |
| 32 | + | |
| 33 | +impl WemodDownloader { | |
| 34 | + /// Create a new downloader | |
| 35 | + pub fn new(_config: &WandaConfig) -> Self { | |
| 36 | + Self { | |
| 37 | + client: reqwest::Client::builder() | |
| 38 | + .user_agent("WANDA/0.1.0") | |
| 39 | + .build() | |
| 40 | + .expect("Failed to create HTTP client"), | |
| 41 | + cache_dir: WandaConfig::default_cache_dir().join("downloads"), | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// Get the latest WeMod release info | |
| 46 | + pub async fn get_latest(&self) -> Result<WemodRelease> { | |
| 47 | + info!("Fetching WeMod download info..."); | |
| 48 | + | |
| 49 | + // The WeMod API redirects to the actual download URL | |
| 50 | + // We need to follow the redirect to get the final URL | |
| 51 | + let response = self | |
| 52 | + .client | |
| 53 | + .head(WEMOD_DOWNLOAD_URL) | |
| 54 | + .send() | |
| 55 | + .await | |
| 56 | + .map_err(|e| WandaError::WemodDownloadFailed { | |
| 57 | + reason: format!("Failed to fetch download info: {}", e), | |
| 58 | + })?; | |
| 59 | + | |
| 60 | + let final_url = response.url().to_string(); | |
| 61 | + let content_length = response | |
| 62 | + .headers() | |
| 63 | + .get(reqwest::header::CONTENT_LENGTH) | |
| 64 | + .and_then(|v| v.to_str().ok()) | |
| 65 | + .and_then(|v| v.parse().ok()); | |
| 66 | + | |
| 67 | + // Try to extract version from URL | |
| 68 | + // URL format is typically: https://storage.wemod.com/app/releases/stable/WeMod-X.Y.Z.exe | |
| 69 | + let version = final_url | |
| 70 | + .split('/') | |
| 71 | + .last() | |
| 72 | + .and_then(|filename| { | |
| 73 | + filename | |
| 74 | + .strip_prefix("WeMod-") | |
| 75 | + .and_then(|v| v.strip_suffix(".exe")) | |
| 76 | + .map(String::from) | |
| 77 | + }); | |
| 78 | + | |
| 79 | + debug!("WeMod download URL: {}", final_url); | |
| 80 | + if let Some(ref v) = version { | |
| 81 | + debug!("Detected version: {}", v); | |
| 82 | + } | |
| 83 | + | |
| 84 | + Ok(WemodRelease { | |
| 85 | + url: final_url, | |
| 86 | + version, | |
| 87 | + size: content_length, | |
| 88 | + }) | |
| 89 | + } | |
| 90 | + | |
| 91 | + /// Download WeMod installer to cache | |
| 92 | + pub async fn download<F>(&self, release: &WemodRelease, progress_callback: F) -> Result<PathBuf> | |
| 93 | + where | |
| 94 | + F: Fn(u64, u64), | |
| 95 | + { | |
| 96 | + // Ensure cache directory exists | |
| 97 | + tokio::fs::create_dir_all(&self.cache_dir).await?; | |
| 98 | + | |
| 99 | + let filename = release | |
| 100 | + .version | |
| 101 | + .as_ref() | |
| 102 | + .map(|v| format!("WeMod-{}.exe", v)) | |
| 103 | + .unwrap_or_else(|| "WeMod-latest.exe".to_string()); | |
| 104 | + | |
| 105 | + let dest_path = self.cache_dir.join(&filename); | |
| 106 | + | |
| 107 | + // Check if we already have this version cached | |
| 108 | + if dest_path.exists() { | |
| 109 | + if let Some(expected_size) = release.size { | |
| 110 | + let actual_size = tokio::fs::metadata(&dest_path).await?.len(); | |
| 111 | + if actual_size == expected_size { | |
| 112 | + info!("Using cached WeMod installer: {}", dest_path.display()); | |
| 113 | + return Ok(dest_path); | |
| 114 | + } | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + info!("Downloading WeMod from {}...", release.url); | |
| 119 | + | |
| 120 | + let response = self | |
| 121 | + .client | |
| 122 | + .get(&release.url) | |
| 123 | + .send() | |
| 124 | + .await | |
| 125 | + .map_err(|e| WandaError::WemodDownloadFailed { | |
| 126 | + reason: format!("Download request failed: {}", e), | |
| 127 | + })?; | |
| 128 | + | |
| 129 | + if !response.status().is_success() { | |
| 130 | + return Err(WandaError::WemodDownloadFailed { | |
| 131 | + reason: format!("HTTP error: {}", response.status()), | |
| 132 | + }); | |
| 133 | + } | |
| 134 | + | |
| 135 | + let total_size = response.content_length().unwrap_or(0); | |
| 136 | + let mut downloaded: u64 = 0; | |
| 137 | + | |
| 138 | + let mut file = File::create(&dest_path).await?; | |
| 139 | + let mut stream = response.bytes_stream(); | |
| 140 | + | |
| 141 | + while let Some(chunk_result) = stream.next().await { | |
| 142 | + let chunk = chunk_result.map_err(|e| WandaError::WemodDownloadFailed { | |
| 143 | + reason: format!("Error reading download stream: {}", e), | |
| 144 | + })?; | |
| 145 | + | |
| 146 | + file.write_all(&chunk).await?; | |
| 147 | + | |
| 148 | + downloaded += chunk.len() as u64; | |
| 149 | + progress_callback(downloaded, total_size); | |
| 150 | + } | |
| 151 | + | |
| 152 | + file.flush().await?; | |
| 153 | + | |
| 154 | + info!("Downloaded WeMod to {}", dest_path.display()); | |
| 155 | + Ok(dest_path) | |
| 156 | + } | |
| 157 | + | |
| 158 | + /// Check if we have a cached version matching the release | |
| 159 | + pub async fn get_cached(&self, release: &WemodRelease) -> Option<PathBuf> { | |
| 160 | + if let Some(ref version) = release.version { | |
| 161 | + let path = self.cache_dir.join(format!("WeMod-{}.exe", version)); | |
| 162 | + if path.exists() { | |
| 163 | + return Some(path); | |
| 164 | + } | |
| 165 | + } | |
| 166 | + None | |
| 167 | + } | |
| 168 | + | |
| 169 | + /// Clear the download cache | |
| 170 | + pub async fn clear_cache(&self) -> Result<()> { | |
| 171 | + if self.cache_dir.exists() { | |
| 172 | + tokio::fs::remove_dir_all(&self.cache_dir).await?; | |
| 173 | + } | |
| 174 | + Ok(()) | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +#[cfg(test)] | |
| 179 | +mod tests { | |
| 180 | + use super::*; | |
| 181 | + | |
| 182 | + #[test] | |
| 183 | + fn test_version_extraction() { | |
| 184 | + let url = "https://storage.wemod.com/app/releases/stable/WeMod-8.12.5.exe"; | |
| 185 | + let version = url | |
| 186 | + .split('/') | |
| 187 | + .last() | |
| 188 | + .and_then(|f| f.strip_prefix("WeMod-")) | |
| 189 | + .and_then(|v| v.strip_suffix(".exe")); | |
| 190 | + | |
| 191 | + assert_eq!(version, Some("8.12.5")); | |
| 192 | + } | |
| 193 | +} | |
crates/wanda-core/src/wemod/installer.rsadded@@ -0,0 +1,297 @@ | ||
| 1 | +//! WeMod installation into Wine prefixes | |
| 2 | + | |
| 3 | +use crate::error::{Result, WandaError}; | |
| 4 | +use crate::prefix::WandaPrefix; | |
| 5 | +use crate::steam::ProtonVersion; | |
| 6 | +use std::collections::HashMap; | |
| 7 | +use std::path::Path; | |
| 8 | +use std::process::Stdio; | |
| 9 | +use tokio::process::Command; | |
| 10 | +use tracing::{debug, error, info, warn}; | |
| 11 | + | |
| 12 | +/// Handles WeMod installation into Wine prefixes | |
| 13 | +pub struct WemodInstaller<'a> { | |
| 14 | + /// The prefix to install into | |
| 15 | + prefix: &'a WandaPrefix, | |
| 16 | + /// Proton version for running installer | |
| 17 | + proton: &'a ProtonVersion, | |
| 18 | +} | |
| 19 | + | |
| 20 | +impl<'a> WemodInstaller<'a> { | |
| 21 | + /// Create a new installer | |
| 22 | + pub fn new(prefix: &'a WandaPrefix, proton: &'a ProtonVersion) -> Self { | |
| 23 | + Self { prefix, proton } | |
| 24 | + } | |
| 25 | + | |
| 26 | + /// Build environment variables for Wine | |
| 27 | + fn build_env(&self) -> HashMap<String, String> { | |
| 28 | + let mut env = HashMap::new(); | |
| 29 | + | |
| 30 | + env.insert( | |
| 31 | + "WINEPREFIX".to_string(), | |
| 32 | + self.prefix.path.to_string_lossy().to_string(), | |
| 33 | + ); | |
| 34 | + env.insert("WINEARCH".to_string(), "win64".to_string()); | |
| 35 | + env.insert("WINEDEBUG".to_string(), "warn+all".to_string()); | |
| 36 | + | |
| 37 | + // Proton compatibility flags | |
| 38 | + env.insert("PROTON_NO_ESYNC".to_string(), "1".to_string()); | |
| 39 | + env.insert("PROTON_NO_FSYNC".to_string(), "1".to_string()); | |
| 40 | + | |
| 41 | + // Set Proton lib paths for finding dependencies | |
| 42 | + let proton_lib64 = self.proton.path.join("files/lib64"); | |
| 43 | + let proton_lib = self.proton.path.join("files/lib"); | |
| 44 | + if proton_lib64.exists() || proton_lib.exists() { | |
| 45 | + let mut ld_path = String::new(); | |
| 46 | + if proton_lib64.exists() { | |
| 47 | + ld_path.push_str(&proton_lib64.to_string_lossy()); | |
| 48 | + } | |
| 49 | + if proton_lib.exists() { | |
| 50 | + if !ld_path.is_empty() { | |
| 51 | + ld_path.push(':'); | |
| 52 | + } | |
| 53 | + ld_path.push_str(&proton_lib.to_string_lossy()); | |
| 54 | + } | |
| 55 | + if let Ok(existing) = std::env::var("LD_LIBRARY_PATH") { | |
| 56 | + ld_path.push(':'); | |
| 57 | + ld_path.push_str(&existing); | |
| 58 | + } | |
| 59 | + env.insert("LD_LIBRARY_PATH".to_string(), ld_path); | |
| 60 | + } | |
| 61 | + | |
| 62 | + debug!("Installer environment:"); | |
| 63 | + for (key, value) in &env { | |
| 64 | + debug!(" {}={}", key, value); | |
| 65 | + } | |
| 66 | + | |
| 67 | + env | |
| 68 | + } | |
| 69 | + | |
| 70 | + /// Get the path to wine executable | |
| 71 | + fn wine_exe(&self) -> String { | |
| 72 | + let proton_wine = self.proton.wine_exe(); | |
| 73 | + debug!("Checking Proton wine at: {}", proton_wine.display()); | |
| 74 | + if proton_wine.exists() { | |
| 75 | + debug!("Using Proton wine"); | |
| 76 | + proton_wine.to_string_lossy().to_string() | |
| 77 | + } else { | |
| 78 | + warn!("Proton wine not found, falling back to system wine"); | |
| 79 | + "wine".to_string() | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + /// Get the path to wineserver executable | |
| 84 | + fn wineserver_exe(&self) -> String { | |
| 85 | + let proton_wineserver = self.proton.path.join("files/bin/wineserver"); | |
| 86 | + if proton_wineserver.exists() { | |
| 87 | + proton_wineserver.to_string_lossy().to_string() | |
| 88 | + } else { | |
| 89 | + "wineserver".to_string() | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + /// Install WeMod from the downloaded installer | |
| 94 | + pub async fn install(&self, installer_path: &Path) -> Result<()> { | |
| 95 | + if !installer_path.exists() { | |
| 96 | + return Err(WandaError::WemodInstallFailed { | |
| 97 | + reason: format!("Installer not found at {}", installer_path.display()), | |
| 98 | + }); | |
| 99 | + } | |
| 100 | + | |
| 101 | + info!("Installing WeMod from {}...", installer_path.display()); | |
| 102 | + | |
| 103 | + let env = self.build_env(); | |
| 104 | + let wine = self.wine_exe(); | |
| 105 | + | |
| 106 | + info!("Using wine: {}", wine); | |
| 107 | + info!("Running: {} {} /S", wine, installer_path.display()); | |
| 108 | + | |
| 109 | + // WeMod installer supports silent installation with /S flag | |
| 110 | + let output = Command::new(&wine) | |
| 111 | + .arg(installer_path) | |
| 112 | + .arg("/S") // Silent install | |
| 113 | + .envs(&env) | |
| 114 | + .stdout(Stdio::piped()) | |
| 115 | + .stderr(Stdio::piped()) | |
| 116 | + .output() | |
| 117 | + .await | |
| 118 | + .map_err(|e| WandaError::WemodInstallFailed { | |
| 119 | + reason: format!("Failed to run installer: {}", e), | |
| 120 | + })?; | |
| 121 | + | |
| 122 | + let stdout = String::from_utf8_lossy(&output.stdout); | |
| 123 | + let stderr = String::from_utf8_lossy(&output.stderr); | |
| 124 | + | |
| 125 | + if !stdout.is_empty() { | |
| 126 | + debug!("Installer stdout: {}", stdout); | |
| 127 | + } | |
| 128 | + | |
| 129 | + if !output.status.success() { | |
| 130 | + error!("WeMod installer exited with status: {:?}", output.status.code()); | |
| 131 | + if !stderr.is_empty() { | |
| 132 | + error!("Installer stderr: {}", stderr); | |
| 133 | + } | |
| 134 | + // Don't fail immediately - installer might still have worked | |
| 135 | + } else { | |
| 136 | + debug!("Installer completed with success status"); | |
| 137 | + } | |
| 138 | + | |
| 139 | + // Wait for wineserver using Proton's wineserver | |
| 140 | + let wineserver = self.wineserver_exe(); | |
| 141 | + debug!("Waiting for wineserver: {}", wineserver); | |
| 142 | + let _ = Command::new(&wineserver) | |
| 143 | + .arg("-w") | |
| 144 | + .envs(&env) | |
| 145 | + .status() | |
| 146 | + .await; | |
| 147 | + | |
| 148 | + // Verify installation | |
| 149 | + info!("Verifying WeMod installation..."); | |
| 150 | + if self.verify()? { | |
| 151 | + info!("WeMod installed successfully"); | |
| 152 | + Ok(()) | |
| 153 | + } else { | |
| 154 | + error!("WeMod executable not found after installation"); | |
| 155 | + error!("Checked locations:"); | |
| 156 | + error!(" - {}", self.prefix.wemod_exe().display()); | |
| 157 | + error!(" - {}", self.prefix.user_folder().join("AppData/Local/WeMod/WeMod.exe").display()); | |
| 158 | + error!(" - {}", self.prefix.drive_c().join("Program Files/WeMod/WeMod.exe").display()); | |
| 159 | + Err(WandaError::WemodInstallFailed { | |
| 160 | + reason: "WeMod executable not found after installation".to_string(), | |
| 161 | + }) | |
| 162 | + } | |
| 163 | + } | |
| 164 | + | |
| 165 | + /// Verify WeMod installation | |
| 166 | + pub fn verify(&self) -> Result<bool> { | |
| 167 | + // Check if WeMod.exe exists | |
| 168 | + let wemod_exe = self.prefix.wemod_exe(); | |
| 169 | + debug!("Checking for WeMod at: {}", wemod_exe.display()); | |
| 170 | + | |
| 171 | + if wemod_exe.exists() { | |
| 172 | + info!("WeMod found at {}", wemod_exe.display()); | |
| 173 | + return Ok(true); | |
| 174 | + } | |
| 175 | + | |
| 176 | + // Also check alternative locations | |
| 177 | + let alt_locations = [ | |
| 178 | + self.prefix | |
| 179 | + .user_folder() | |
| 180 | + .join("AppData/Local/WeMod/WeMod.exe"), | |
| 181 | + self.prefix | |
| 182 | + .drive_c() | |
| 183 | + .join("Program Files/WeMod/WeMod.exe"), | |
| 184 | + self.prefix | |
| 185 | + .drive_c() | |
| 186 | + .join("Program Files (x86)/WeMod/WeMod.exe"), | |
| 187 | + ]; | |
| 188 | + | |
| 189 | + for loc in &alt_locations { | |
| 190 | + debug!("Checking alternative location: {}", loc.display()); | |
| 191 | + if loc.exists() { | |
| 192 | + info!("WeMod found at alternative location: {}", loc.display()); | |
| 193 | + return Ok(true); | |
| 194 | + } | |
| 195 | + } | |
| 196 | + | |
| 197 | + // List what's in the WeMod directory if it exists | |
| 198 | + let wemod_dir = self.prefix.wemod_path(); | |
| 199 | + debug!("WeMod directory: {}", wemod_dir.display()); | |
| 200 | + if wemod_dir.exists() { | |
| 201 | + debug!("WeMod directory exists, contents:"); | |
| 202 | + if let Ok(entries) = std::fs::read_dir(&wemod_dir) { | |
| 203 | + for entry in entries.flatten() { | |
| 204 | + debug!(" - {}", entry.path().display()); | |
| 205 | + } | |
| 206 | + } | |
| 207 | + } else { | |
| 208 | + debug!("WeMod directory does not exist"); | |
| 209 | + } | |
| 210 | + | |
| 211 | + // Also check what's in AppData/Local | |
| 212 | + let local_appdata = self.prefix.user_folder().join("AppData/Local"); | |
| 213 | + if local_appdata.exists() { | |
| 214 | + debug!("Listing AppData/Local contents:"); | |
| 215 | + if let Ok(entries) = std::fs::read_dir(&local_appdata) { | |
| 216 | + for entry in entries.flatten() { | |
| 217 | + debug!(" - {}", entry.file_name().to_string_lossy()); | |
| 218 | + } | |
| 219 | + } | |
| 220 | + } | |
| 221 | + | |
| 222 | + debug!("WeMod not found in prefix"); | |
| 223 | + Ok(false) | |
| 224 | + } | |
| 225 | + | |
| 226 | + /// Get the installed WeMod version (if available) | |
| 227 | + pub fn get_version(&self) -> Option<String> { | |
| 228 | + // Try to read version from WeMod's app data | |
| 229 | + let version_file = self.prefix.wemod_path().join("current"); | |
| 230 | + if version_file.exists() { | |
| 231 | + if let Ok(content) = std::fs::read_to_string(&version_file) { | |
| 232 | + // The 'current' file contains the version directory name | |
| 233 | + let version = content.trim().to_string(); | |
| 234 | + if !version.is_empty() { | |
| 235 | + return Some(version); | |
| 236 | + } | |
| 237 | + } | |
| 238 | + } | |
| 239 | + | |
| 240 | + // Try to detect from installed app directories | |
| 241 | + let wemod_dir = self.prefix.wemod_path(); | |
| 242 | + if wemod_dir.exists() { | |
| 243 | + if let Ok(entries) = std::fs::read_dir(&wemod_dir) { | |
| 244 | + for entry in entries.flatten() { | |
| 245 | + let name = entry.file_name().to_string_lossy().to_string(); | |
| 246 | + // Look for version directories like "app-8.12.5" | |
| 247 | + if name.starts_with("app-") { | |
| 248 | + return Some(name.strip_prefix("app-").unwrap_or(&name).to_string()); | |
| 249 | + } | |
| 250 | + } | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + None | |
| 255 | + } | |
| 256 | + | |
| 257 | + /// Uninstall WeMod from the prefix | |
| 258 | + pub async fn uninstall(&self) -> Result<()> { | |
| 259 | + info!("Uninstalling WeMod..."); | |
| 260 | + | |
| 261 | + let wemod_dir = self.prefix.wemod_path(); | |
| 262 | + if wemod_dir.exists() { | |
| 263 | + tokio::fs::remove_dir_all(&wemod_dir).await?; | |
| 264 | + info!("WeMod uninstalled"); | |
| 265 | + } else { | |
| 266 | + info!("WeMod was not installed"); | |
| 267 | + } | |
| 268 | + | |
| 269 | + Ok(()) | |
| 270 | + } | |
| 271 | + | |
| 272 | + /// Run WeMod standalone (for testing or manual use) | |
| 273 | + pub async fn run(&self) -> Result<tokio::process::Child> { | |
| 274 | + let wemod_exe = self.prefix.wemod_exe(); | |
| 275 | + | |
| 276 | + if !wemod_exe.exists() { | |
| 277 | + return Err(WandaError::WemodNotInstalled); | |
| 278 | + } | |
| 279 | + | |
| 280 | + let env = self.build_env(); | |
| 281 | + let wine = self.wine_exe(); | |
| 282 | + | |
| 283 | + info!("Starting WeMod..."); | |
| 284 | + | |
| 285 | + let child = Command::new(&wine) | |
| 286 | + .arg(&wemod_exe) | |
| 287 | + .envs(&env) | |
| 288 | + .stdout(Stdio::null()) | |
| 289 | + .stderr(Stdio::null()) | |
| 290 | + .spawn() | |
| 291 | + .map_err(|e| WandaError::LaunchFailed { | |
| 292 | + reason: format!("Failed to start WeMod: {}", e), | |
| 293 | + })?; | |
| 294 | + | |
| 295 | + Ok(child) | |
| 296 | + } | |
| 297 | +} | |
crates/wanda-core/src/wemod/mod.rsadded@@ -0,0 +1,7 @@ | ||
| 1 | +//! WeMod download and installation management | |
| 2 | + | |
| 3 | +mod downloader; | |
| 4 | +mod installer; | |
| 5 | + | |
| 6 | +pub use downloader::{WemodDownloader, WemodRelease}; | |
| 7 | +pub use installer::WemodInstaller; | |
crates/wanda-gui/Cargo.tomladded@@ -0,0 +1,23 @@ | ||
| 1 | +[package] | |
| 2 | +name = "wanda-gui" | |
| 3 | +version.workspace = true | |
| 4 | +edition.workspace = true | |
| 5 | +license.workspace = true | |
| 6 | +description = "GUI for WANDA - WeMod launcher for Linux" | |
| 7 | + | |
| 8 | +[build-dependencies] | |
| 9 | +tauri-build.workspace = true | |
| 10 | + | |
| 11 | +[dependencies] | |
| 12 | +wanda-core = { path = "../wanda-core" } | |
| 13 | + | |
| 14 | +tauri.workspace = true | |
| 15 | +tauri-plugin-shell.workspace = true | |
| 16 | +tokio.workspace = true | |
| 17 | +serde.workspace = true | |
| 18 | +serde_json.workspace = true | |
| 19 | +tracing.workspace = true | |
| 20 | + | |
| 21 | +[features] | |
| 22 | +default = ["custom-protocol"] | |
| 23 | +custom-protocol = ["tauri/custom-protocol"] | |
crates/wanda-gui/build.rsadded@@ -0,0 +1,3 @@ | ||
| 1 | +fn main() { | |
| 2 | + tauri_build::build() | |
| 3 | +} | |
crates/wanda-gui/capabilities/default.jsonadded@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "$schema": "https://schema.tauri.app/config/2", | |
| 3 | + "identifier": "default", | |
| 4 | + "description": "Default capabilities for WANDA", | |
| 5 | + "windows": ["main"], | |
| 6 | + "permissions": [ | |
| 7 | + "core:default", | |
| 8 | + "shell:allow-open" | |
| 9 | + ] | |
| 10 | +} | |
crates/wanda-gui/frontend/index.htmladded@@ -0,0 +1,30 @@ | ||
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | + <head> | |
| 4 | + <meta charset="UTF-8" /> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 6 | + <title>WANDA</title> | |
| 7 | + <style> | |
| 8 | + * { | |
| 9 | + margin: 0; | |
| 10 | + padding: 0; | |
| 11 | + box-sizing: border-box; | |
| 12 | + } | |
| 13 | + | |
| 14 | + body { | |
| 15 | + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | |
| 16 | + background-color: #1a1a2e; | |
| 17 | + color: #eaeaea; | |
| 18 | + min-height: 100vh; | |
| 19 | + } | |
| 20 | + | |
| 21 | + #root { | |
| 22 | + min-height: 100vh; | |
| 23 | + } | |
| 24 | + </style> | |
| 25 | + </head> | |
| 26 | + <body> | |
| 27 | + <div id="root"></div> | |
| 28 | + <script type="module" src="/src/main.tsx"></script> | |
| 29 | + </body> | |
| 30 | +</html> | |
crates/wanda-gui/frontend/package.jsonadded@@ -0,0 +1,24 @@ | ||
| 1 | +{ | |
| 2 | + "name": "wanda-frontend", | |
| 3 | + "private": true, | |
| 4 | + "version": "0.1.0", | |
| 5 | + "type": "module", | |
| 6 | + "scripts": { | |
| 7 | + "dev": "vite", | |
| 8 | + "build": "tsc && vite build", | |
| 9 | + "preview": "vite preview" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "react": "^18.3.1", | |
| 13 | + "react-dom": "^18.3.1", | |
| 14 | + "react-router-dom": "^6.28.0" | |
| 15 | + }, | |
| 16 | + "devDependencies": { | |
| 17 | + "@tauri-apps/api": "^2.0.0", | |
| 18 | + "@types/react": "^18.3.12", | |
| 19 | + "@types/react-dom": "^18.3.1", | |
| 20 | + "@vitejs/plugin-react": "^4.3.3", | |
| 21 | + "typescript": "^5.6.3", | |
| 22 | + "vite": "^5.4.11" | |
| 23 | + } | |
| 24 | +} | |
crates/wanda-gui/frontend/src/App.tsxadded@@ -0,0 +1,99 @@ | ||
| 1 | +import { useEffect, useState } from "react"; | |
| 2 | +import { Routes, Route, NavLink, useNavigate } from "react-router-dom"; | |
| 3 | +import { getInitStatus } from "./hooks/useApi"; | |
| 4 | +import type { InitStatus } from "./types"; | |
| 5 | +import GamesView from "./views/GamesView"; | |
| 6 | +import SettingsView from "./views/SettingsView"; | |
| 7 | +import PrefixesView from "./views/PrefixesView"; | |
| 8 | +import SetupView from "./views/SetupView"; | |
| 9 | + | |
| 10 | +function App() { | |
| 11 | + const [initStatus, setInitStatus] = useState<InitStatus | null>(null); | |
| 12 | + const [loading, setLoading] = useState(true); | |
| 13 | + const navigate = useNavigate(); | |
| 14 | + | |
| 15 | + useEffect(() => { | |
| 16 | + checkInit(); | |
| 17 | + }, []); | |
| 18 | + | |
| 19 | + async function checkInit() { | |
| 20 | + try { | |
| 21 | + const status = await getInitStatus(); | |
| 22 | + setInitStatus(status); | |
| 23 | + if (!status.initialized) { | |
| 24 | + navigate("/setup"); | |
| 25 | + } | |
| 26 | + } catch (err) { | |
| 27 | + console.error("Failed to check init status:", err); | |
| 28 | + } finally { | |
| 29 | + setLoading(false); | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + if (loading) { | |
| 34 | + return ( | |
| 35 | + <div className="setup-container"> | |
| 36 | + <div className="spinner" /> | |
| 37 | + </div> | |
| 38 | + ); | |
| 39 | + } | |
| 40 | + | |
| 41 | + // Show setup if not initialized | |
| 42 | + if (!initStatus?.initialized) { | |
| 43 | + return ( | |
| 44 | + <Routes> | |
| 45 | + <Route path="*" element={<SetupView onComplete={() => checkInit()} />} /> | |
| 46 | + </Routes> | |
| 47 | + ); | |
| 48 | + } | |
| 49 | + | |
| 50 | + return ( | |
| 51 | + <div className="app"> | |
| 52 | + <aside className="sidebar"> | |
| 53 | + <div className="sidebar-header"> | |
| 54 | + <h1>WANDA</h1> | |
| 55 | + <p>WeMod on Linux</p> | |
| 56 | + </div> | |
| 57 | + <nav> | |
| 58 | + <ul className="nav-list"> | |
| 59 | + <li className="nav-item"> | |
| 60 | + <NavLink | |
| 61 | + to="/" | |
| 62 | + className={({ isActive }) => `nav-link ${isActive ? "active" : ""}`} | |
| 63 | + end | |
| 64 | + > | |
| 65 | + Games | |
| 66 | + </NavLink> | |
| 67 | + </li> | |
| 68 | + <li className="nav-item"> | |
| 69 | + <NavLink | |
| 70 | + to="/prefixes" | |
| 71 | + className={({ isActive }) => `nav-link ${isActive ? "active" : ""}`} | |
| 72 | + > | |
| 73 | + Prefixes | |
| 74 | + </NavLink> | |
| 75 | + </li> | |
| 76 | + <li className="nav-item"> | |
| 77 | + <NavLink | |
| 78 | + to="/settings" | |
| 79 | + className={({ isActive }) => `nav-link ${isActive ? "active" : ""}`} | |
| 80 | + > | |
| 81 | + Settings | |
| 82 | + </NavLink> | |
| 83 | + </li> | |
| 84 | + </ul> | |
| 85 | + </nav> | |
| 86 | + </aside> | |
| 87 | + <main className="main-content"> | |
| 88 | + <Routes> | |
| 89 | + <Route path="/" element={<GamesView />} /> | |
| 90 | + <Route path="/prefixes" element={<PrefixesView />} /> | |
| 91 | + <Route path="/settings" element={<SettingsView />} /> | |
| 92 | + <Route path="/setup" element={<SetupView onComplete={() => checkInit()} />} /> | |
| 93 | + </Routes> | |
| 94 | + </main> | |
| 95 | + </div> | |
| 96 | + ); | |
| 97 | +} | |
| 98 | + | |
| 99 | +export default App; | |
crates/wanda-gui/frontend/src/components/GameCard.tsxadded@@ -0,0 +1,37 @@ | ||
| 1 | +import type { GameInfo } from "../types"; | |
| 2 | + | |
| 3 | +interface Props { | |
| 4 | + game: GameInfo; | |
| 5 | + onLaunch: (appId: number, withWemod: boolean) => void; | |
| 6 | + launching: boolean; | |
| 7 | +} | |
| 8 | + | |
| 9 | +export default function GameCard({ game, onLaunch, launching }: Props) { | |
| 10 | + return ( | |
| 11 | + <div className="game-card"> | |
| 12 | + <h3 className="game-name" title={game.name}> | |
| 13 | + {game.name} | |
| 14 | + </h3> | |
| 15 | + <div className="game-meta"> | |
| 16 | + <span>App ID: {game.app_id}</span> | |
| 17 | + <span>{game.size}</span> | |
| 18 | + </div> | |
| 19 | + <div className="game-actions"> | |
| 20 | + <button | |
| 21 | + className="btn btn-primary btn-small" | |
| 22 | + onClick={() => onLaunch(game.app_id, true)} | |
| 23 | + disabled={launching} | |
| 24 | + > | |
| 25 | + {launching ? "Launching..." : "Launch with WeMod"} | |
| 26 | + </button> | |
| 27 | + <button | |
| 28 | + className="btn btn-secondary btn-small" | |
| 29 | + onClick={() => onLaunch(game.app_id, false)} | |
| 30 | + disabled={launching} | |
| 31 | + > | |
| 32 | + Launch | |
| 33 | + </button> | |
| 34 | + </div> | |
| 35 | + </div> | |
| 36 | + ); | |
| 37 | +} | |
crates/wanda-gui/frontend/src/hooks/useApi.tsadded@@ -0,0 +1,73 @@ | ||
| 1 | +import { invoke } from "@tauri-apps/api/core"; | |
| 2 | +import type { | |
| 3 | + GameInfo, | |
| 4 | + PrefixInfo, | |
| 5 | + ProtonInfo, | |
| 6 | + WemodStatus, | |
| 7 | + InitStatus, | |
| 8 | + DoctorReport, | |
| 9 | + ConfigDto, | |
| 10 | +} from "../types"; | |
| 11 | + | |
| 12 | +// Game API | |
| 13 | +export async function getGames(): Promise<GameInfo[]> { | |
| 14 | + return invoke("get_games"); | |
| 15 | +} | |
| 16 | + | |
| 17 | +export async function getGame(appId: number): Promise<GameInfo> { | |
| 18 | + return invoke("get_game", { appId }); | |
| 19 | +} | |
| 20 | + | |
| 21 | +export async function launchGame(appId: number, withWemod: boolean): Promise<void> { | |
| 22 | + return invoke("launch_game", { appId, withWemod }); | |
| 23 | +} | |
| 24 | + | |
| 25 | +// Prefix API | |
| 26 | +export async function getPrefixes(): Promise<PrefixInfo[]> { | |
| 27 | + return invoke("get_prefixes"); | |
| 28 | +} | |
| 29 | + | |
| 30 | +export async function getPrefixHealth(name: string): Promise<PrefixInfo> { | |
| 31 | + return invoke("get_prefix_health", { name }); | |
| 32 | +} | |
| 33 | + | |
| 34 | +export async function repairPrefix(name: string): Promise<void> { | |
| 35 | + return invoke("repair_prefix", { name }); | |
| 36 | +} | |
| 37 | + | |
| 38 | +// Init API | |
| 39 | +export async function getInitStatus(): Promise<InitStatus> { | |
| 40 | + return invoke("get_init_status"); | |
| 41 | +} | |
| 42 | + | |
| 43 | +export async function initWanda(): Promise<void> { | |
| 44 | + return invoke("init_wanda"); | |
| 45 | +} | |
| 46 | + | |
| 47 | +// Config API | |
| 48 | +export async function getConfig(): Promise<ConfigDto> { | |
| 49 | + return invoke("get_config"); | |
| 50 | +} | |
| 51 | + | |
| 52 | +export async function updateConfig(config: ConfigDto): Promise<void> { | |
| 53 | + return invoke("update_config", { configDto: config }); | |
| 54 | +} | |
| 55 | + | |
| 56 | +// WeMod API | |
| 57 | +export async function getWemodStatus(): Promise<WemodStatus> { | |
| 58 | + return invoke("get_wemod_status"); | |
| 59 | +} | |
| 60 | + | |
| 61 | +export async function updateWemod(): Promise<void> { | |
| 62 | + return invoke("update_wemod"); | |
| 63 | +} | |
| 64 | + | |
| 65 | +// Proton API | |
| 66 | +export async function getProtonVersions(): Promise<ProtonInfo[]> { | |
| 67 | + return invoke("get_proton_versions"); | |
| 68 | +} | |
| 69 | + | |
| 70 | +// Doctor API | |
| 71 | +export async function runDoctor(): Promise<DoctorReport> { | |
| 72 | + return invoke("run_doctor"); | |
| 73 | +} | |
crates/wanda-gui/frontend/src/main.tsxadded@@ -0,0 +1,13 @@ | ||
| 1 | +import React from "react"; | |
| 2 | +import ReactDOM from "react-dom/client"; | |
| 3 | +import { BrowserRouter } from "react-router-dom"; | |
| 4 | +import App from "./App"; | |
| 5 | +import "./styles.css"; | |
| 6 | + | |
| 7 | +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | |
| 8 | + <React.StrictMode> | |
| 9 | + <BrowserRouter> | |
| 10 | + <App /> | |
| 11 | + </BrowserRouter> | |
| 12 | + </React.StrictMode> | |
| 13 | +); | |
crates/wanda-gui/frontend/src/styles.cssadded@@ -0,0 +1,441 @@ | ||
| 1 | +:root { | |
| 2 | + --bg-primary: #1a1a2e; | |
| 3 | + --bg-secondary: #16213e; | |
| 4 | + --bg-card: #0f3460; | |
| 5 | + --accent: #e94560; | |
| 6 | + --accent-hover: #ff6b6b; | |
| 7 | + --text-primary: #eaeaea; | |
| 8 | + --text-secondary: #a0a0a0; | |
| 9 | + --success: #4ade80; | |
| 10 | + --warning: #fbbf24; | |
| 11 | + --error: #f87171; | |
| 12 | + --border: #2a2a4e; | |
| 13 | +} | |
| 14 | + | |
| 15 | +* { | |
| 16 | + margin: 0; | |
| 17 | + padding: 0; | |
| 18 | + box-sizing: border-box; | |
| 19 | +} | |
| 20 | + | |
| 21 | +body { | |
| 22 | + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | |
| 23 | + background-color: var(--bg-primary); | |
| 24 | + color: var(--text-primary); | |
| 25 | + min-height: 100vh; | |
| 26 | + line-height: 1.6; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/* Layout */ | |
| 30 | +.app { | |
| 31 | + display: flex; | |
| 32 | + min-height: 100vh; | |
| 33 | +} | |
| 34 | + | |
| 35 | +.sidebar { | |
| 36 | + width: 220px; | |
| 37 | + background-color: var(--bg-secondary); | |
| 38 | + padding: 20px; | |
| 39 | + border-right: 1px solid var(--border); | |
| 40 | + display: flex; | |
| 41 | + flex-direction: column; | |
| 42 | +} | |
| 43 | + | |
| 44 | +.sidebar-header { | |
| 45 | + margin-bottom: 30px; | |
| 46 | +} | |
| 47 | + | |
| 48 | +.sidebar-header h1 { | |
| 49 | + font-size: 1.5rem; | |
| 50 | + color: var(--accent); | |
| 51 | + font-weight: 700; | |
| 52 | +} | |
| 53 | + | |
| 54 | +.sidebar-header p { | |
| 55 | + font-size: 0.75rem; | |
| 56 | + color: var(--text-secondary); | |
| 57 | +} | |
| 58 | + | |
| 59 | +.nav-list { | |
| 60 | + list-style: none; | |
| 61 | + flex: 1; | |
| 62 | +} | |
| 63 | + | |
| 64 | +.nav-item { | |
| 65 | + margin-bottom: 5px; | |
| 66 | +} | |
| 67 | + | |
| 68 | +.nav-link { | |
| 69 | + display: block; | |
| 70 | + padding: 12px 15px; | |
| 71 | + color: var(--text-secondary); | |
| 72 | + text-decoration: none; | |
| 73 | + border-radius: 8px; | |
| 74 | + transition: all 0.2s; | |
| 75 | +} | |
| 76 | + | |
| 77 | +.nav-link:hover { | |
| 78 | + background-color: var(--bg-card); | |
| 79 | + color: var(--text-primary); | |
| 80 | +} | |
| 81 | + | |
| 82 | +.nav-link.active { | |
| 83 | + background-color: var(--accent); | |
| 84 | + color: white; | |
| 85 | +} | |
| 86 | + | |
| 87 | +.main-content { | |
| 88 | + flex: 1; | |
| 89 | + padding: 30px; | |
| 90 | + overflow-y: auto; | |
| 91 | +} | |
| 92 | + | |
| 93 | +/* Cards */ | |
| 94 | +.card { | |
| 95 | + background-color: var(--bg-card); | |
| 96 | + border-radius: 12px; | |
| 97 | + padding: 20px; | |
| 98 | + margin-bottom: 20px; | |
| 99 | +} | |
| 100 | + | |
| 101 | +.card-header { | |
| 102 | + display: flex; | |
| 103 | + justify-content: space-between; | |
| 104 | + align-items: center; | |
| 105 | + margin-bottom: 15px; | |
| 106 | +} | |
| 107 | + | |
| 108 | +.card-title { | |
| 109 | + font-size: 1.1rem; | |
| 110 | + font-weight: 600; | |
| 111 | +} | |
| 112 | + | |
| 113 | +/* Buttons */ | |
| 114 | +.btn { | |
| 115 | + display: inline-flex; | |
| 116 | + align-items: center; | |
| 117 | + gap: 8px; | |
| 118 | + padding: 10px 20px; | |
| 119 | + border: none; | |
| 120 | + border-radius: 8px; | |
| 121 | + font-size: 0.9rem; | |
| 122 | + font-weight: 500; | |
| 123 | + cursor: pointer; | |
| 124 | + transition: all 0.2s; | |
| 125 | +} | |
| 126 | + | |
| 127 | +.btn-primary { | |
| 128 | + background-color: var(--accent); | |
| 129 | + color: white; | |
| 130 | +} | |
| 131 | + | |
| 132 | +.btn-primary:hover { | |
| 133 | + background-color: var(--accent-hover); | |
| 134 | +} | |
| 135 | + | |
| 136 | +.btn-secondary { | |
| 137 | + background-color: var(--bg-secondary); | |
| 138 | + color: var(--text-primary); | |
| 139 | + border: 1px solid var(--border); | |
| 140 | +} | |
| 141 | + | |
| 142 | +.btn-secondary:hover { | |
| 143 | + background-color: var(--bg-card); | |
| 144 | +} | |
| 145 | + | |
| 146 | +.btn-small { | |
| 147 | + padding: 6px 12px; | |
| 148 | + font-size: 0.8rem; | |
| 149 | +} | |
| 150 | + | |
| 151 | +.btn:disabled { | |
| 152 | + opacity: 0.5; | |
| 153 | + cursor: not-allowed; | |
| 154 | +} | |
| 155 | + | |
| 156 | +/* Game Grid */ | |
| 157 | +.game-grid { | |
| 158 | + display: grid; | |
| 159 | + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
| 160 | + gap: 20px; | |
| 161 | +} | |
| 162 | + | |
| 163 | +.game-card { | |
| 164 | + background-color: var(--bg-card); | |
| 165 | + border-radius: 12px; | |
| 166 | + padding: 20px; | |
| 167 | + transition: transform 0.2s, box-shadow 0.2s; | |
| 168 | + cursor: pointer; | |
| 169 | +} | |
| 170 | + | |
| 171 | +.game-card:hover { | |
| 172 | + transform: translateY(-2px); | |
| 173 | + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); | |
| 174 | +} | |
| 175 | + | |
| 176 | +.game-name { | |
| 177 | + font-size: 1rem; | |
| 178 | + font-weight: 600; | |
| 179 | + margin-bottom: 8px; | |
| 180 | + white-space: nowrap; | |
| 181 | + overflow: hidden; | |
| 182 | + text-overflow: ellipsis; | |
| 183 | +} | |
| 184 | + | |
| 185 | +.game-meta { | |
| 186 | + display: flex; | |
| 187 | + justify-content: space-between; | |
| 188 | + align-items: center; | |
| 189 | + font-size: 0.8rem; | |
| 190 | + color: var(--text-secondary); | |
| 191 | + margin-bottom: 15px; | |
| 192 | +} | |
| 193 | + | |
| 194 | +.game-actions { | |
| 195 | + display: flex; | |
| 196 | + gap: 10px; | |
| 197 | +} | |
| 198 | + | |
| 199 | +/* Status badges */ | |
| 200 | +.badge { | |
| 201 | + display: inline-block; | |
| 202 | + padding: 4px 10px; | |
| 203 | + border-radius: 20px; | |
| 204 | + font-size: 0.75rem; | |
| 205 | + font-weight: 500; | |
| 206 | +} | |
| 207 | + | |
| 208 | +.badge-success { | |
| 209 | + background-color: rgba(74, 222, 128, 0.2); | |
| 210 | + color: var(--success); | |
| 211 | +} | |
| 212 | + | |
| 213 | +.badge-warning { | |
| 214 | + background-color: rgba(251, 191, 36, 0.2); | |
| 215 | + color: var(--warning); | |
| 216 | +} | |
| 217 | + | |
| 218 | +.badge-error { | |
| 219 | + background-color: rgba(248, 113, 113, 0.2); | |
| 220 | + color: var(--error); | |
| 221 | +} | |
| 222 | + | |
| 223 | +/* Forms */ | |
| 224 | +.form-group { | |
| 225 | + margin-bottom: 20px; | |
| 226 | +} | |
| 227 | + | |
| 228 | +.form-label { | |
| 229 | + display: block; | |
| 230 | + margin-bottom: 8px; | |
| 231 | + font-size: 0.9rem; | |
| 232 | + color: var(--text-secondary); | |
| 233 | +} | |
| 234 | + | |
| 235 | +.form-input { | |
| 236 | + width: 100%; | |
| 237 | + padding: 12px; | |
| 238 | + background-color: var(--bg-secondary); | |
| 239 | + border: 1px solid var(--border); | |
| 240 | + border-radius: 8px; | |
| 241 | + color: var(--text-primary); | |
| 242 | + font-size: 0.9rem; | |
| 243 | +} | |
| 244 | + | |
| 245 | +.form-input:focus { | |
| 246 | + outline: none; | |
| 247 | + border-color: var(--accent); | |
| 248 | +} | |
| 249 | + | |
| 250 | +.form-checkbox { | |
| 251 | + display: flex; | |
| 252 | + align-items: center; | |
| 253 | + gap: 10px; | |
| 254 | +} | |
| 255 | + | |
| 256 | +.form-checkbox input { | |
| 257 | + width: 18px; | |
| 258 | + height: 18px; | |
| 259 | + accent-color: var(--accent); | |
| 260 | +} | |
| 261 | + | |
| 262 | +/* Setup screen */ | |
| 263 | +.setup-container { | |
| 264 | + display: flex; | |
| 265 | + justify-content: center; | |
| 266 | + align-items: center; | |
| 267 | + min-height: 100vh; | |
| 268 | + padding: 20px; | |
| 269 | +} | |
| 270 | + | |
| 271 | +.setup-card { | |
| 272 | + max-width: 500px; | |
| 273 | + width: 100%; | |
| 274 | + background-color: var(--bg-card); | |
| 275 | + border-radius: 16px; | |
| 276 | + padding: 40px; | |
| 277 | + text-align: center; | |
| 278 | +} | |
| 279 | + | |
| 280 | +.setup-icon { | |
| 281 | + font-size: 4rem; | |
| 282 | + margin-bottom: 20px; | |
| 283 | +} | |
| 284 | + | |
| 285 | +.setup-title { | |
| 286 | + font-size: 1.5rem; | |
| 287 | + margin-bottom: 10px; | |
| 288 | +} | |
| 289 | + | |
| 290 | +.setup-description { | |
| 291 | + color: var(--text-secondary); | |
| 292 | + margin-bottom: 30px; | |
| 293 | +} | |
| 294 | + | |
| 295 | +.setup-steps { | |
| 296 | + text-align: left; | |
| 297 | + margin-bottom: 30px; | |
| 298 | +} | |
| 299 | + | |
| 300 | +.setup-step { | |
| 301 | + display: flex; | |
| 302 | + align-items: center; | |
| 303 | + gap: 15px; | |
| 304 | + padding: 15px; | |
| 305 | + background-color: var(--bg-secondary); | |
| 306 | + border-radius: 8px; | |
| 307 | + margin-bottom: 10px; | |
| 308 | +} | |
| 309 | + | |
| 310 | +.step-icon { | |
| 311 | + width: 30px; | |
| 312 | + height: 30px; | |
| 313 | + border-radius: 50%; | |
| 314 | + display: flex; | |
| 315 | + align-items: center; | |
| 316 | + justify-content: center; | |
| 317 | + font-size: 0.8rem; | |
| 318 | + font-weight: 600; | |
| 319 | +} | |
| 320 | + | |
| 321 | +.step-icon.pending { | |
| 322 | + background-color: var(--border); | |
| 323 | + color: var(--text-secondary); | |
| 324 | +} | |
| 325 | + | |
| 326 | +.step-icon.done { | |
| 327 | + background-color: var(--success); | |
| 328 | + color: var(--bg-primary); | |
| 329 | +} | |
| 330 | + | |
| 331 | +.step-icon.error { | |
| 332 | + background-color: var(--error); | |
| 333 | + color: white; | |
| 334 | +} | |
| 335 | + | |
| 336 | +/* Loading spinner */ | |
| 337 | +.spinner { | |
| 338 | + width: 40px; | |
| 339 | + height: 40px; | |
| 340 | + border: 3px solid var(--border); | |
| 341 | + border-top-color: var(--accent); | |
| 342 | + border-radius: 50%; | |
| 343 | + animation: spin 1s linear infinite; | |
| 344 | +} | |
| 345 | + | |
| 346 | +@keyframes spin { | |
| 347 | + to { | |
| 348 | + transform: rotate(360deg); | |
| 349 | + } | |
| 350 | +} | |
| 351 | + | |
| 352 | +/* Page header */ | |
| 353 | +.page-header { | |
| 354 | + display: flex; | |
| 355 | + justify-content: space-between; | |
| 356 | + align-items: center; | |
| 357 | + margin-bottom: 30px; | |
| 358 | +} | |
| 359 | + | |
| 360 | +.page-title { | |
| 361 | + font-size: 1.5rem; | |
| 362 | + font-weight: 600; | |
| 363 | +} | |
| 364 | + | |
| 365 | +/* Prefix list */ | |
| 366 | +.prefix-list { | |
| 367 | + display: flex; | |
| 368 | + flex-direction: column; | |
| 369 | + gap: 15px; | |
| 370 | +} | |
| 371 | + | |
| 372 | +.prefix-item { | |
| 373 | + display: flex; | |
| 374 | + justify-content: space-between; | |
| 375 | + align-items: center; | |
| 376 | + padding: 20px; | |
| 377 | + background-color: var(--bg-card); | |
| 378 | + border-radius: 12px; | |
| 379 | +} | |
| 380 | + | |
| 381 | +.prefix-info h3 { | |
| 382 | + font-size: 1rem; | |
| 383 | + margin-bottom: 5px; | |
| 384 | +} | |
| 385 | + | |
| 386 | +.prefix-info p { | |
| 387 | + font-size: 0.8rem; | |
| 388 | + color: var(--text-secondary); | |
| 389 | +} | |
| 390 | + | |
| 391 | +/* Doctor report */ | |
| 392 | +.doctor-section { | |
| 393 | + margin-bottom: 20px; | |
| 394 | +} | |
| 395 | + | |
| 396 | +.doctor-item { | |
| 397 | + display: flex; | |
| 398 | + align-items: center; | |
| 399 | + gap: 15px; | |
| 400 | + padding: 15px; | |
| 401 | + background-color: var(--bg-secondary); | |
| 402 | + border-radius: 8px; | |
| 403 | + margin-bottom: 10px; | |
| 404 | +} | |
| 405 | + | |
| 406 | +.doctor-status { | |
| 407 | + width: 12px; | |
| 408 | + height: 12px; | |
| 409 | + border-radius: 50%; | |
| 410 | +} | |
| 411 | + | |
| 412 | +.doctor-status.ok { | |
| 413 | + background-color: var(--success); | |
| 414 | +} | |
| 415 | + | |
| 416 | +.doctor-status.error { | |
| 417 | + background-color: var(--error); | |
| 418 | +} | |
| 419 | + | |
| 420 | +/* Responsive */ | |
| 421 | +@media (max-width: 768px) { | |
| 422 | + .app { | |
| 423 | + flex-direction: column; | |
| 424 | + } | |
| 425 | + | |
| 426 | + .sidebar { | |
| 427 | + width: 100%; | |
| 428 | + border-right: none; | |
| 429 | + border-bottom: 1px solid var(--border); | |
| 430 | + } | |
| 431 | + | |
| 432 | + .nav-list { | |
| 433 | + display: flex; | |
| 434 | + gap: 10px; | |
| 435 | + overflow-x: auto; | |
| 436 | + } | |
| 437 | + | |
| 438 | + .game-grid { | |
| 439 | + grid-template-columns: 1fr; | |
| 440 | + } | |
| 441 | +} | |
crates/wanda-gui/frontend/src/types/index.tsadded@@ -0,0 +1,59 @@ | ||
| 1 | +// Types matching Rust DTOs | |
| 2 | + | |
| 3 | +export interface GameInfo { | |
| 4 | + app_id: number; | |
| 5 | + name: string; | |
| 6 | + size: string; | |
| 7 | + uses_proton: boolean; | |
| 8 | + install_path: string; | |
| 9 | +} | |
| 10 | + | |
| 11 | +export interface PrefixInfo { | |
| 12 | + name: string; | |
| 13 | + path: string; | |
| 14 | + wemod_installed: boolean; | |
| 15 | + wemod_version: string | null; | |
| 16 | + proton_version: string | null; | |
| 17 | + health: "healthy" | "needs_repair" | "corrupted" | "not_created"; | |
| 18 | + issues: string[]; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export interface ProtonInfo { | |
| 22 | + name: string; | |
| 23 | + path: string; | |
| 24 | + compatibility: "recommended" | "supported" | "experimental" | "unsupported"; | |
| 25 | + is_ge: boolean; | |
| 26 | + is_recommended: boolean; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export interface WemodStatus { | |
| 30 | + installed: boolean; | |
| 31 | + version: string | null; | |
| 32 | + update_available: boolean; | |
| 33 | + latest_version: string | null; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export interface InitStatus { | |
| 37 | + initialized: boolean; | |
| 38 | + steam_found: boolean; | |
| 39 | + proton_found: boolean; | |
| 40 | + prefix_exists: boolean; | |
| 41 | + wemod_installed: boolean; | |
| 42 | +} | |
| 43 | + | |
| 44 | +export interface DoctorReport { | |
| 45 | + steam_ok: boolean; | |
| 46 | + steam_path: string | null; | |
| 47 | + proton_ok: boolean; | |
| 48 | + proton_count: number; | |
| 49 | + prefix_ok: boolean; | |
| 50 | + wemod_ok: boolean; | |
| 51 | + issues: string[]; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export interface ConfigDto { | |
| 55 | + steam_path: string | null; | |
| 56 | + scan_flatpak: boolean; | |
| 57 | + preferred_proton: string | null; | |
| 58 | + auto_update_wemod: boolean; | |
| 59 | +} | |
crates/wanda-gui/frontend/src/views/GamesView.tsxadded@@ -0,0 +1,119 @@ | ||
| 1 | +import { useState, useEffect } from "react"; | |
| 2 | +import { getGames, launchGame } from "../hooks/useApi"; | |
| 3 | +import type { GameInfo } from "../types"; | |
| 4 | +import GameCard from "../components/GameCard"; | |
| 5 | + | |
| 6 | +export default function GamesView() { | |
| 7 | + const [games, setGames] = useState<GameInfo[]>([]); | |
| 8 | + const [loading, setLoading] = useState(true); | |
| 9 | + const [error, setError] = useState<string | null>(null); | |
| 10 | + const [search, setSearch] = useState(""); | |
| 11 | + const [launching, setLaunching] = useState<number | null>(null); | |
| 12 | + | |
| 13 | + useEffect(() => { | |
| 14 | + loadGames(); | |
| 15 | + }, []); | |
| 16 | + | |
| 17 | + async function loadGames() { | |
| 18 | + try { | |
| 19 | + setLoading(true); | |
| 20 | + const gamesList = await getGames(); | |
| 21 | + // Sort by name | |
| 22 | + gamesList.sort((a, b) => a.name.localeCompare(b.name)); | |
| 23 | + setGames(gamesList); | |
| 24 | + } catch (err) { | |
| 25 | + setError(String(err)); | |
| 26 | + } finally { | |
| 27 | + setLoading(false); | |
| 28 | + } | |
| 29 | + } | |
| 30 | + | |
| 31 | + async function handleLaunch(appId: number, withWemod: boolean) { | |
| 32 | + setLaunching(appId); | |
| 33 | + try { | |
| 34 | + await launchGame(appId, withWemod); | |
| 35 | + } catch (err) { | |
| 36 | + setError(String(err)); | |
| 37 | + } finally { | |
| 38 | + setLaunching(null); | |
| 39 | + } | |
| 40 | + } | |
| 41 | + | |
| 42 | + const filteredGames = games.filter((game) => | |
| 43 | + game.name.toLowerCase().includes(search.toLowerCase()) | |
| 44 | + ); | |
| 45 | + | |
| 46 | + if (loading) { | |
| 47 | + return ( | |
| 48 | + <div> | |
| 49 | + <div className="page-header"> | |
| 50 | + <h1 className="page-title">Games</h1> | |
| 51 | + </div> | |
| 52 | + <div style={{ textAlign: "center", padding: "50px" }}> | |
| 53 | + <div className="spinner" style={{ margin: "0 auto" }} /> | |
| 54 | + <p style={{ marginTop: "20px", color: "var(--text-secondary)" }}>Loading games...</p> | |
| 55 | + </div> | |
| 56 | + </div> | |
| 57 | + ); | |
| 58 | + } | |
| 59 | + | |
| 60 | + return ( | |
| 61 | + <div> | |
| 62 | + <div className="page-header"> | |
| 63 | + <h1 className="page-title">Games</h1> | |
| 64 | + <button className="btn btn-secondary" onClick={loadGames}> | |
| 65 | + Refresh | |
| 66 | + </button> | |
| 67 | + </div> | |
| 68 | + | |
| 69 | + {error && ( | |
| 70 | + <div | |
| 71 | + className="card" | |
| 72 | + style={{ backgroundColor: "rgba(248, 113, 113, 0.1)", marginBottom: "20px" }} | |
| 73 | + > | |
| 74 | + <p style={{ color: "var(--error)" }}>{error}</p> | |
| 75 | + </div> | |
| 76 | + )} | |
| 77 | + | |
| 78 | + <div className="form-group"> | |
| 79 | + <input | |
| 80 | + type="text" | |
| 81 | + className="form-input" | |
| 82 | + placeholder="Search games..." | |
| 83 | + value={search} | |
| 84 | + onChange={(e) => setSearch(e.target.value)} | |
| 85 | + /> | |
| 86 | + </div> | |
| 87 | + | |
| 88 | + {filteredGames.length === 0 ? ( | |
| 89 | + <div className="card" style={{ textAlign: "center" }}> | |
| 90 | + <p style={{ color: "var(--text-secondary)" }}> | |
| 91 | + {search ? "No games match your search" : "No Proton games found"} | |
| 92 | + </p> | |
| 93 | + </div> | |
| 94 | + ) : ( | |
| 95 | + <div className="game-grid"> | |
| 96 | + {filteredGames.map((game) => ( | |
| 97 | + <GameCard | |
| 98 | + key={game.app_id} | |
| 99 | + game={game} | |
| 100 | + onLaunch={handleLaunch} | |
| 101 | + launching={launching === game.app_id} | |
| 102 | + /> | |
| 103 | + ))} | |
| 104 | + </div> | |
| 105 | + )} | |
| 106 | + | |
| 107 | + <p | |
| 108 | + style={{ | |
| 109 | + marginTop: "20px", | |
| 110 | + fontSize: "0.8rem", | |
| 111 | + color: "var(--text-secondary)", | |
| 112 | + textAlign: "center", | |
| 113 | + }} | |
| 114 | + > | |
| 115 | + Showing {filteredGames.length} of {games.length} Proton games | |
| 116 | + </p> | |
| 117 | + </div> | |
| 118 | + ); | |
| 119 | +} | |
crates/wanda-gui/frontend/src/views/PrefixesView.tsxadded@@ -0,0 +1,188 @@ | ||
| 1 | +import { useState, useEffect } from "react"; | |
| 2 | +import { getPrefixes, repairPrefix, getWemodStatus, updateWemod } from "../hooks/useApi"; | |
| 3 | +import type { PrefixInfo, WemodStatus } from "../types"; | |
| 4 | + | |
| 5 | +export default function PrefixesView() { | |
| 6 | + const [prefixes, setPrefixes] = useState<PrefixInfo[]>([]); | |
| 7 | + const [wemodStatus, setWemodStatus] = useState<WemodStatus | null>(null); | |
| 8 | + const [loading, setLoading] = useState(true); | |
| 9 | + const [repairing, setRepairing] = useState<string | null>(null); | |
| 10 | + const [updating, setUpdating] = useState(false); | |
| 11 | + const [error, setError] = useState<string | null>(null); | |
| 12 | + | |
| 13 | + useEffect(() => { | |
| 14 | + loadData(); | |
| 15 | + }, []); | |
| 16 | + | |
| 17 | + async function loadData() { | |
| 18 | + try { | |
| 19 | + setLoading(true); | |
| 20 | + const [prefixList, wemod] = await Promise.all([getPrefixes(), getWemodStatus()]); | |
| 21 | + setPrefixes(prefixList); | |
| 22 | + setWemodStatus(wemod); | |
| 23 | + } catch (err) { | |
| 24 | + setError(String(err)); | |
| 25 | + } finally { | |
| 26 | + setLoading(false); | |
| 27 | + } | |
| 28 | + } | |
| 29 | + | |
| 30 | + async function handleRepair(name: string) { | |
| 31 | + setRepairing(name); | |
| 32 | + try { | |
| 33 | + await repairPrefix(name); | |
| 34 | + await loadData(); | |
| 35 | + } catch (err) { | |
| 36 | + setError(String(err)); | |
| 37 | + } finally { | |
| 38 | + setRepairing(null); | |
| 39 | + } | |
| 40 | + } | |
| 41 | + | |
| 42 | + async function handleUpdateWemod() { | |
| 43 | + setUpdating(true); | |
| 44 | + try { | |
| 45 | + await updateWemod(); | |
| 46 | + await loadData(); | |
| 47 | + } catch (err) { | |
| 48 | + setError(String(err)); | |
| 49 | + } finally { | |
| 50 | + setUpdating(false); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + function getHealthBadge(health: string) { | |
| 55 | + switch (health) { | |
| 56 | + case "healthy": | |
| 57 | + return <span className="badge badge-success">Healthy</span>; | |
| 58 | + case "needs_repair": | |
| 59 | + return <span className="badge badge-warning">Needs Repair</span>; | |
| 60 | + case "corrupted": | |
| 61 | + return <span className="badge badge-error">Corrupted</span>; | |
| 62 | + default: | |
| 63 | + return <span className="badge">Unknown</span>; | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + if (loading) { | |
| 68 | + return ( | |
| 69 | + <div> | |
| 70 | + <div className="page-header"> | |
| 71 | + <h1 className="page-title">Prefixes</h1> | |
| 72 | + </div> | |
| 73 | + <div style={{ textAlign: "center", padding: "50px" }}> | |
| 74 | + <div className="spinner" style={{ margin: "0 auto" }} /> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + ); | |
| 78 | + } | |
| 79 | + | |
| 80 | + return ( | |
| 81 | + <div> | |
| 82 | + <div className="page-header"> | |
| 83 | + <h1 className="page-title">Prefixes</h1> | |
| 84 | + </div> | |
| 85 | + | |
| 86 | + {error && ( | |
| 87 | + <div | |
| 88 | + className="card" | |
| 89 | + style={{ backgroundColor: "rgba(248, 113, 113, 0.1)", marginBottom: "20px" }} | |
| 90 | + > | |
| 91 | + <p style={{ color: "var(--error)" }}>{error}</p> | |
| 92 | + </div> | |
| 93 | + )} | |
| 94 | + | |
| 95 | + {/* WeMod Status */} | |
| 96 | + <div className="card"> | |
| 97 | + <div className="card-header"> | |
| 98 | + <h2 className="card-title">WeMod Status</h2> | |
| 99 | + {wemodStatus?.update_available && ( | |
| 100 | + <button | |
| 101 | + className="btn btn-primary btn-small" | |
| 102 | + onClick={handleUpdateWemod} | |
| 103 | + disabled={updating} | |
| 104 | + > | |
| 105 | + {updating ? "Updating..." : `Update to ${wemodStatus.latest_version}`} | |
| 106 | + </button> | |
| 107 | + )} | |
| 108 | + </div> | |
| 109 | + {wemodStatus ? ( | |
| 110 | + <div> | |
| 111 | + <p> | |
| 112 | + <strong>Installed:</strong>{" "} | |
| 113 | + {wemodStatus.installed ? ( | |
| 114 | + <span style={{ color: "var(--success)" }}>Yes</span> | |
| 115 | + ) : ( | |
| 116 | + <span style={{ color: "var(--error)" }}>No</span> | |
| 117 | + )} | |
| 118 | + </p> | |
| 119 | + {wemodStatus.version && ( | |
| 120 | + <p> | |
| 121 | + <strong>Version:</strong> {wemodStatus.version} | |
| 122 | + </p> | |
| 123 | + )} | |
| 124 | + {wemodStatus.update_available && ( | |
| 125 | + <p style={{ color: "var(--warning)" }}> | |
| 126 | + Update available: {wemodStatus.latest_version} | |
| 127 | + </p> | |
| 128 | + )} | |
| 129 | + </div> | |
| 130 | + ) : ( | |
| 131 | + <p style={{ color: "var(--text-secondary)" }}>Loading...</p> | |
| 132 | + )} | |
| 133 | + </div> | |
| 134 | + | |
| 135 | + {/* Prefix List */} | |
| 136 | + <h2 className="card-title" style={{ marginBottom: "15px" }}> | |
| 137 | + Wine Prefixes | |
| 138 | + </h2> | |
| 139 | + | |
| 140 | + {prefixes.length === 0 ? ( | |
| 141 | + <div className="card"> | |
| 142 | + <p style={{ color: "var(--text-secondary)" }}>No prefixes found</p> | |
| 143 | + </div> | |
| 144 | + ) : ( | |
| 145 | + <div className="prefix-list"> | |
| 146 | + {prefixes.map((prefix) => ( | |
| 147 | + <div key={prefix.name} className="prefix-item"> | |
| 148 | + <div className="prefix-info"> | |
| 149 | + <h3> | |
| 150 | + {prefix.name} {getHealthBadge(prefix.health)} | |
| 151 | + </h3> | |
| 152 | + <p>{prefix.path}</p> | |
| 153 | + {prefix.proton_version && ( | |
| 154 | + <p> | |
| 155 | + <small>Proton: {prefix.proton_version}</small> | |
| 156 | + </p> | |
| 157 | + )} | |
| 158 | + {prefix.wemod_installed && prefix.wemod_version && ( | |
| 159 | + <p> | |
| 160 | + <small>WeMod: {prefix.wemod_version}</small> | |
| 161 | + </p> | |
| 162 | + )} | |
| 163 | + {prefix.issues.length > 0 && ( | |
| 164 | + <ul style={{ marginTop: "10px", color: "var(--warning)", fontSize: "0.8rem" }}> | |
| 165 | + {prefix.issues.map((issue, i) => ( | |
| 166 | + <li key={i}>{issue}</li> | |
| 167 | + ))} | |
| 168 | + </ul> | |
| 169 | + )} | |
| 170 | + </div> | |
| 171 | + <div> | |
| 172 | + {prefix.health === "needs_repair" && ( | |
| 173 | + <button | |
| 174 | + className="btn btn-secondary btn-small" | |
| 175 | + onClick={() => handleRepair(prefix.name)} | |
| 176 | + disabled={repairing === prefix.name} | |
| 177 | + > | |
| 178 | + {repairing === prefix.name ? "Repairing..." : "Repair"} | |
| 179 | + </button> | |
| 180 | + )} | |
| 181 | + </div> | |
| 182 | + </div> | |
| 183 | + ))} | |
| 184 | + </div> | |
| 185 | + )} | |
| 186 | + </div> | |
| 187 | + ); | |
| 188 | +} | |
crates/wanda-gui/frontend/src/views/SettingsView.tsxadded@@ -0,0 +1,257 @@ | ||
| 1 | +import { useState, useEffect } from "react"; | |
| 2 | +import { getConfig, updateConfig, getProtonVersions, runDoctor } from "../hooks/useApi"; | |
| 3 | +import type { ConfigDto, ProtonInfo, DoctorReport } from "../types"; | |
| 4 | + | |
| 5 | +export default function SettingsView() { | |
| 6 | + const [config, setConfig] = useState<ConfigDto | null>(null); | |
| 7 | + const [protonVersions, setProtonVersions] = useState<ProtonInfo[]>([]); | |
| 8 | + const [doctor, setDoctor] = useState<DoctorReport | null>(null); | |
| 9 | + const [loading, setLoading] = useState(true); | |
| 10 | + const [saving, setSaving] = useState(false); | |
| 11 | + const [runningDoctor, setRunningDoctor] = useState(false); | |
| 12 | + const [error, setError] = useState<string | null>(null); | |
| 13 | + const [success, setSuccess] = useState<string | null>(null); | |
| 14 | + | |
| 15 | + useEffect(() => { | |
| 16 | + loadData(); | |
| 17 | + }, []); | |
| 18 | + | |
| 19 | + async function loadData() { | |
| 20 | + try { | |
| 21 | + setLoading(true); | |
| 22 | + const [cfg, proton] = await Promise.all([getConfig(), getProtonVersions()]); | |
| 23 | + setConfig(cfg); | |
| 24 | + setProtonVersions(proton); | |
| 25 | + } catch (err) { | |
| 26 | + setError(String(err)); | |
| 27 | + } finally { | |
| 28 | + setLoading(false); | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 32 | + async function handleSave() { | |
| 33 | + if (!config) return; | |
| 34 | + | |
| 35 | + setSaving(true); | |
| 36 | + setError(null); | |
| 37 | + setSuccess(null); | |
| 38 | + | |
| 39 | + try { | |
| 40 | + await updateConfig(config); | |
| 41 | + setSuccess("Settings saved successfully!"); | |
| 42 | + setTimeout(() => setSuccess(null), 3000); | |
| 43 | + } catch (err) { | |
| 44 | + setError(String(err)); | |
| 45 | + } finally { | |
| 46 | + setSaving(false); | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + async function handleRunDoctor() { | |
| 51 | + setRunningDoctor(true); | |
| 52 | + try { | |
| 53 | + const report = await runDoctor(); | |
| 54 | + setDoctor(report); | |
| 55 | + } catch (err) { | |
| 56 | + setError(String(err)); | |
| 57 | + } finally { | |
| 58 | + setRunningDoctor(false); | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + if (loading || !config) { | |
| 63 | + return ( | |
| 64 | + <div> | |
| 65 | + <div className="page-header"> | |
| 66 | + <h1 className="page-title">Settings</h1> | |
| 67 | + </div> | |
| 68 | + <div style={{ textAlign: "center", padding: "50px" }}> | |
| 69 | + <div className="spinner" style={{ margin: "0 auto" }} /> | |
| 70 | + </div> | |
| 71 | + </div> | |
| 72 | + ); | |
| 73 | + } | |
| 74 | + | |
| 75 | + return ( | |
| 76 | + <div> | |
| 77 | + <div className="page-header"> | |
| 78 | + <h1 className="page-title">Settings</h1> | |
| 79 | + </div> | |
| 80 | + | |
| 81 | + {error && ( | |
| 82 | + <div | |
| 83 | + className="card" | |
| 84 | + style={{ backgroundColor: "rgba(248, 113, 113, 0.1)", marginBottom: "20px" }} | |
| 85 | + > | |
| 86 | + <p style={{ color: "var(--error)" }}>{error}</p> | |
| 87 | + </div> | |
| 88 | + )} | |
| 89 | + | |
| 90 | + {success && ( | |
| 91 | + <div | |
| 92 | + className="card" | |
| 93 | + style={{ backgroundColor: "rgba(74, 222, 128, 0.1)", marginBottom: "20px" }} | |
| 94 | + > | |
| 95 | + <p style={{ color: "var(--success)" }}>{success}</p> | |
| 96 | + </div> | |
| 97 | + )} | |
| 98 | + | |
| 99 | + {/* Steam Settings */} | |
| 100 | + <div className="card"> | |
| 101 | + <h2 className="card-title" style={{ marginBottom: "20px" }}> | |
| 102 | + Steam | |
| 103 | + </h2> | |
| 104 | + | |
| 105 | + <div className="form-group"> | |
| 106 | + <label className="form-label">Steam Installation Path (leave empty for auto-detect)</label> | |
| 107 | + <input | |
| 108 | + type="text" | |
| 109 | + className="form-input" | |
| 110 | + value={config.steam_path || ""} | |
| 111 | + onChange={(e) => setConfig({ ...config, steam_path: e.target.value || null })} | |
| 112 | + placeholder="Auto-detect" | |
| 113 | + /> | |
| 114 | + </div> | |
| 115 | + | |
| 116 | + <div className="form-group"> | |
| 117 | + <label className="form-checkbox"> | |
| 118 | + <input | |
| 119 | + type="checkbox" | |
| 120 | + checked={config.scan_flatpak} | |
| 121 | + onChange={(e) => setConfig({ ...config, scan_flatpak: e.target.checked })} | |
| 122 | + /> | |
| 123 | + <span>Scan Flatpak Steam installation</span> | |
| 124 | + </label> | |
| 125 | + </div> | |
| 126 | + </div> | |
| 127 | + | |
| 128 | + {/* Proton Settings */} | |
| 129 | + <div className="card"> | |
| 130 | + <h2 className="card-title" style={{ marginBottom: "20px" }}> | |
| 131 | + Proton | |
| 132 | + </h2> | |
| 133 | + | |
| 134 | + <div className="form-group"> | |
| 135 | + <label className="form-label">Preferred Proton Version</label> | |
| 136 | + <select | |
| 137 | + className="form-input" | |
| 138 | + value={config.preferred_proton || ""} | |
| 139 | + onChange={(e) => setConfig({ ...config, preferred_proton: e.target.value || null })} | |
| 140 | + > | |
| 141 | + <option value="">Auto (Recommended)</option> | |
| 142 | + {protonVersions.map((v) => ( | |
| 143 | + <option key={v.name} value={v.name}> | |
| 144 | + {v.name}{" "} | |
| 145 | + {v.compatibility === "recommended" | |
| 146 | + ? "(Recommended)" | |
| 147 | + : v.compatibility === "unsupported" | |
| 148 | + ? "(Unsupported)" | |
| 149 | + : ""} | |
| 150 | + </option> | |
| 151 | + ))} | |
| 152 | + </select> | |
| 153 | + </div> | |
| 154 | + | |
| 155 | + {protonVersions.length > 0 && ( | |
| 156 | + <div style={{ marginTop: "15px" }}> | |
| 157 | + <p style={{ fontSize: "0.8rem", color: "var(--text-secondary)", marginBottom: "10px" }}> | |
| 158 | + Available Proton versions: | |
| 159 | + </p> | |
| 160 | + <div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}> | |
| 161 | + {protonVersions.map((v) => ( | |
| 162 | + <span | |
| 163 | + key={v.name} | |
| 164 | + className={`badge ${ | |
| 165 | + v.compatibility === "recommended" | |
| 166 | + ? "badge-success" | |
| 167 | + : v.compatibility === "supported" | |
| 168 | + ? "badge-success" | |
| 169 | + : v.compatibility === "experimental" | |
| 170 | + ? "badge-warning" | |
| 171 | + : "badge-error" | |
| 172 | + }`} | |
| 173 | + > | |
| 174 | + {v.name} | |
| 175 | + </span> | |
| 176 | + ))} | |
| 177 | + </div> | |
| 178 | + </div> | |
| 179 | + )} | |
| 180 | + </div> | |
| 181 | + | |
| 182 | + {/* WeMod Settings */} | |
| 183 | + <div className="card"> | |
| 184 | + <h2 className="card-title" style={{ marginBottom: "20px" }}> | |
| 185 | + WeMod | |
| 186 | + </h2> | |
| 187 | + | |
| 188 | + <div className="form-group"> | |
| 189 | + <label className="form-checkbox"> | |
| 190 | + <input | |
| 191 | + type="checkbox" | |
| 192 | + checked={config.auto_update_wemod} | |
| 193 | + onChange={(e) => setConfig({ ...config, auto_update_wemod: e.target.checked })} | |
| 194 | + /> | |
| 195 | + <span>Automatically update WeMod</span> | |
| 196 | + </label> | |
| 197 | + </div> | |
| 198 | + </div> | |
| 199 | + | |
| 200 | + <button className="btn btn-primary" onClick={handleSave} disabled={saving}> | |
| 201 | + {saving ? "Saving..." : "Save Settings"} | |
| 202 | + </button> | |
| 203 | + | |
| 204 | + {/* Diagnostics */} | |
| 205 | + <div className="card" style={{ marginTop: "30px" }}> | |
| 206 | + <div className="card-header"> | |
| 207 | + <h2 className="card-title">Diagnostics</h2> | |
| 208 | + <button | |
| 209 | + className="btn btn-secondary btn-small" | |
| 210 | + onClick={handleRunDoctor} | |
| 211 | + disabled={runningDoctor} | |
| 212 | + > | |
| 213 | + {runningDoctor ? "Running..." : "Run Doctor"} | |
| 214 | + </button> | |
| 215 | + </div> | |
| 216 | + | |
| 217 | + {doctor && ( | |
| 218 | + <div className="doctor-section"> | |
| 219 | + <div className="doctor-item"> | |
| 220 | + <span className={`doctor-status ${doctor.steam_ok ? "ok" : "error"}`} /> | |
| 221 | + <span> | |
| 222 | + Steam: {doctor.steam_ok ? `Found at ${doctor.steam_path}` : "Not found"} | |
| 223 | + </span> | |
| 224 | + </div> | |
| 225 | + <div className="doctor-item"> | |
| 226 | + <span className={`doctor-status ${doctor.proton_ok ? "ok" : "error"}`} /> | |
| 227 | + <span> | |
| 228 | + Proton: {doctor.proton_ok ? `${doctor.proton_count} versions found` : "Not found"} | |
| 229 | + </span> | |
| 230 | + </div> | |
| 231 | + <div className="doctor-item"> | |
| 232 | + <span className={`doctor-status ${doctor.prefix_ok ? "ok" : "error"}`} /> | |
| 233 | + <span>Prefix: {doctor.prefix_ok ? "OK" : "Not initialized"}</span> | |
| 234 | + </div> | |
| 235 | + <div className="doctor-item"> | |
| 236 | + <span className={`doctor-status ${doctor.wemod_ok ? "ok" : "error"}`} /> | |
| 237 | + <span>WeMod: {doctor.wemod_ok ? "Installed" : "Not installed"}</span> | |
| 238 | + </div> | |
| 239 | + | |
| 240 | + {doctor.issues.length > 0 && ( | |
| 241 | + <div style={{ marginTop: "15px" }}> | |
| 242 | + <p style={{ color: "var(--warning)", marginBottom: "10px" }}>Issues:</p> | |
| 243 | + <ul style={{ paddingLeft: "20px" }}> | |
| 244 | + {doctor.issues.map((issue, i) => ( | |
| 245 | + <li key={i} style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}> | |
| 246 | + {issue} | |
| 247 | + </li> | |
| 248 | + ))} | |
| 249 | + </ul> | |
| 250 | + </div> | |
| 251 | + )} | |
| 252 | + </div> | |
| 253 | + )} | |
| 254 | + </div> | |
| 255 | + </div> | |
| 256 | + ); | |
| 257 | +} | |
crates/wanda-gui/frontend/src/views/SetupView.tsxadded@@ -0,0 +1,147 @@ | ||
| 1 | +import { useState, useEffect } from "react"; | |
| 2 | +import { initWanda, getInitStatus, runDoctor } from "../hooks/useApi"; | |
| 3 | +import type { InitStatus, DoctorReport } from "../types"; | |
| 4 | + | |
| 5 | +interface Props { | |
| 6 | + onComplete: () => void; | |
| 7 | +} | |
| 8 | + | |
| 9 | +type SetupStep = "checking" | "ready" | "initializing" | "complete" | "error"; | |
| 10 | + | |
| 11 | +export default function SetupView({ onComplete }: Props) { | |
| 12 | + const [step, setStep] = useState<SetupStep>("checking"); | |
| 13 | + const [status, setStatus] = useState<InitStatus | null>(null); | |
| 14 | + const [doctor, setDoctor] = useState<DoctorReport | null>(null); | |
| 15 | + const [error, setError] = useState<string | null>(null); | |
| 16 | + const [progress, setProgress] = useState(""); | |
| 17 | + | |
| 18 | + useEffect(() => { | |
| 19 | + checkSystem(); | |
| 20 | + }, []); | |
| 21 | + | |
| 22 | + async function checkSystem() { | |
| 23 | + try { | |
| 24 | + const [initStatus, doctorReport] = await Promise.all([ | |
| 25 | + getInitStatus(), | |
| 26 | + runDoctor(), | |
| 27 | + ]); | |
| 28 | + setStatus(initStatus); | |
| 29 | + setDoctor(doctorReport); | |
| 30 | + | |
| 31 | + if (initStatus.initialized) { | |
| 32 | + setStep("complete"); | |
| 33 | + onComplete(); | |
| 34 | + } else if (doctorReport.steam_ok && doctorReport.proton_ok) { | |
| 35 | + setStep("ready"); | |
| 36 | + } else { | |
| 37 | + setStep("error"); | |
| 38 | + } | |
| 39 | + } catch (err) { | |
| 40 | + setError(String(err)); | |
| 41 | + setStep("error"); | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + async function startSetup() { | |
| 46 | + setStep("initializing"); | |
| 47 | + setProgress("Creating Wine prefix..."); | |
| 48 | + | |
| 49 | + try { | |
| 50 | + await initWanda(); | |
| 51 | + setStep("complete"); | |
| 52 | + setTimeout(onComplete, 1500); | |
| 53 | + } catch (err) { | |
| 54 | + setError(String(err)); | |
| 55 | + setStep("error"); | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + return ( | |
| 60 | + <div className="setup-container"> | |
| 61 | + <div className="setup-card"> | |
| 62 | + <div className="setup-icon">🪄</div> | |
| 63 | + <h1 className="setup-title">Welcome to WANDA</h1> | |
| 64 | + <p className="setup-description"> | |
| 65 | + Let's set up WeMod for Linux. This will create a Wine prefix and install WeMod. | |
| 66 | + </p> | |
| 67 | + | |
| 68 | + {step === "checking" && ( | |
| 69 | + <> | |
| 70 | + <div className="spinner" style={{ margin: "20px auto" }} /> | |
| 71 | + <p>Checking system requirements...</p> | |
| 72 | + </> | |
| 73 | + )} | |
| 74 | + | |
| 75 | + {step === "ready" && ( | |
| 76 | + <> | |
| 77 | + <div className="setup-steps"> | |
| 78 | + <div className="setup-step"> | |
| 79 | + <span className={`step-icon ${status?.steam_found ? "done" : "error"}`}> | |
| 80 | + {status?.steam_found ? "✓" : "✕"} | |
| 81 | + </span> | |
| 82 | + <span>Steam detected{doctor?.steam_path ? ` at ${doctor.steam_path}` : ""}</span> | |
| 83 | + </div> | |
| 84 | + <div className="setup-step"> | |
| 85 | + <span className={`step-icon ${status?.proton_found ? "done" : "error"}`}> | |
| 86 | + {status?.proton_found ? "✓" : "✕"} | |
| 87 | + </span> | |
| 88 | + <span> | |
| 89 | + Proton found ({doctor?.proton_count || 0} version | |
| 90 | + {doctor?.proton_count !== 1 ? "s" : ""}) | |
| 91 | + </span> | |
| 92 | + </div> | |
| 93 | + <div className="setup-step"> | |
| 94 | + <span className="step-icon pending">3</span> | |
| 95 | + <span>Create Wine prefix & install WeMod</span> | |
| 96 | + </div> | |
| 97 | + </div> | |
| 98 | + <button className="btn btn-primary" onClick={startSetup}> | |
| 99 | + Initialize WANDA | |
| 100 | + </button> | |
| 101 | + </> | |
| 102 | + )} | |
| 103 | + | |
| 104 | + {step === "initializing" && ( | |
| 105 | + <> | |
| 106 | + <div className="spinner" style={{ margin: "20px auto" }} /> | |
| 107 | + <p>{progress || "This may take several minutes..."}</p> | |
| 108 | + <p style={{ fontSize: "0.8rem", color: "var(--text-secondary)", marginTop: "10px" }}> | |
| 109 | + Installing .NET Framework and WeMod | |
| 110 | + </p> | |
| 111 | + </> | |
| 112 | + )} | |
| 113 | + | |
| 114 | + {step === "complete" && ( | |
| 115 | + <> | |
| 116 | + <div style={{ fontSize: "4rem", marginBottom: "20px" }}>✓</div> | |
| 117 | + <p style={{ color: "var(--success)" }}>WANDA is ready!</p> | |
| 118 | + </> | |
| 119 | + )} | |
| 120 | + | |
| 121 | + {step === "error" && ( | |
| 122 | + <> | |
| 123 | + <div style={{ fontSize: "4rem", marginBottom: "20px" }}>⚠️</div> | |
| 124 | + <p style={{ color: "var(--error)", marginBottom: "20px" }}> | |
| 125 | + {error || "Setup failed"} | |
| 126 | + </p> | |
| 127 | + {doctor?.issues && doctor.issues.length > 0 && ( | |
| 128 | + <div style={{ textAlign: "left", marginBottom: "20px" }}> | |
| 129 | + <p style={{ marginBottom: "10px", color: "var(--text-secondary)" }}>Issues:</p> | |
| 130 | + <ul style={{ listStyle: "disc", paddingLeft: "20px" }}> | |
| 131 | + {doctor.issues.map((issue, i) => ( | |
| 132 | + <li key={i} style={{ color: "var(--error)", fontSize: "0.9rem" }}> | |
| 133 | + {issue} | |
| 134 | + </li> | |
| 135 | + ))} | |
| 136 | + </ul> | |
| 137 | + </div> | |
| 138 | + )} | |
| 139 | + <button className="btn btn-secondary" onClick={checkSystem}> | |
| 140 | + Retry | |
| 141 | + </button> | |
| 142 | + </> | |
| 143 | + )} | |
| 144 | + </div> | |
| 145 | + </div> | |
| 146 | + ); | |
| 147 | +} | |
crates/wanda-gui/frontend/tsconfig.jsonadded@@ -0,0 +1,20 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2020", | |
| 4 | + "useDefineForClassFields": true, | |
| 5 | + "lib": ["ES2020", "DOM", "DOM.Iterable"], | |
| 6 | + "module": "ESNext", | |
| 7 | + "skipLibCheck": true, | |
| 8 | + "moduleResolution": "bundler", | |
| 9 | + "allowImportingTsExtensions": true, | |
| 10 | + "isolatedModules": true, | |
| 11 | + "moduleDetection": "force", | |
| 12 | + "noEmit": true, | |
| 13 | + "jsx": "react-jsx", | |
| 14 | + "strict": true, | |
| 15 | + "noUnusedLocals": true, | |
| 16 | + "noUnusedParameters": true, | |
| 17 | + "noFallthroughCasesInSwitch": true | |
| 18 | + }, | |
| 19 | + "include": ["src"] | |
| 20 | +} | |
crates/wanda-gui/frontend/vite.config.tsadded@@ -0,0 +1,17 @@ | ||
| 1 | +import { defineConfig } from "vite"; | |
| 2 | +import react from "@vitejs/plugin-react"; | |
| 3 | + | |
| 4 | +export default defineConfig({ | |
| 5 | + plugins: [react()], | |
| 6 | + clearScreen: false, | |
| 7 | + server: { | |
| 8 | + port: 5173, | |
| 9 | + strictPort: true, | |
| 10 | + }, | |
| 11 | + envPrefix: ["VITE_", "TAURI_"], | |
| 12 | + build: { | |
| 13 | + target: ["es2021", "chrome100", "safari13"], | |
| 14 | + minify: !process.env.TAURI_DEBUG ? "esbuild" : false, | |
| 15 | + sourcemap: !!process.env.TAURI_DEBUG, | |
| 16 | + }, | |
| 17 | +}); | |
crates/wanda-gui/src/commands.rsadded@@ -0,0 +1,584 @@ | ||
| 1 | +//! Tauri IPC commands | |
| 2 | + | |
| 3 | +use crate::state::AppState; | |
| 4 | +use serde::{Deserialize, Serialize}; | |
| 5 | +use std::sync::Arc; | |
| 6 | +use tauri::State; | |
| 7 | +use tokio::sync::Mutex; | |
| 8 | +use wanda_core::{ | |
| 9 | + config::WandaConfig, | |
| 10 | + launcher::{GameLauncher, LaunchConfig}, | |
| 11 | + prefix::{PrefixHealth, PrefixIssue, PrefixManager}, | |
| 12 | + steam::{ProtonCompatibility, ProtonManager, SteamInstallation}, | |
| 13 | + wemod::{WemodDownloader, WemodInstaller}, | |
| 14 | +}; | |
| 15 | + | |
| 16 | +// ============================================================================ | |
| 17 | +// Data Transfer Objects | |
| 18 | +// ============================================================================ | |
| 19 | + | |
| 20 | +#[derive(Debug, Serialize, Deserialize)] | |
| 21 | +pub struct GameInfo { | |
| 22 | + pub app_id: u32, | |
| 23 | + pub name: String, | |
| 24 | + pub size: String, | |
| 25 | + pub uses_proton: bool, | |
| 26 | + pub install_path: String, | |
| 27 | +} | |
| 28 | + | |
| 29 | +#[derive(Debug, Serialize, Deserialize)] | |
| 30 | +pub struct PrefixInfo { | |
| 31 | + pub name: String, | |
| 32 | + pub path: String, | |
| 33 | + pub wemod_installed: bool, | |
| 34 | + pub wemod_version: Option<String>, | |
| 35 | + pub proton_version: Option<String>, | |
| 36 | + pub health: String, | |
| 37 | + pub issues: Vec<String>, | |
| 38 | +} | |
| 39 | + | |
| 40 | +#[derive(Debug, Serialize, Deserialize)] | |
| 41 | +pub struct ProtonInfo { | |
| 42 | + pub name: String, | |
| 43 | + pub path: String, | |
| 44 | + pub compatibility: String, | |
| 45 | + pub is_ge: bool, | |
| 46 | + pub is_recommended: bool, | |
| 47 | +} | |
| 48 | + | |
| 49 | +#[derive(Debug, Serialize, Deserialize)] | |
| 50 | +pub struct WemodStatus { | |
| 51 | + pub installed: bool, | |
| 52 | + pub version: Option<String>, | |
| 53 | + pub update_available: bool, | |
| 54 | + pub latest_version: Option<String>, | |
| 55 | +} | |
| 56 | + | |
| 57 | +#[derive(Debug, Serialize, Deserialize)] | |
| 58 | +pub struct InitStatus { | |
| 59 | + pub initialized: bool, | |
| 60 | + pub steam_found: bool, | |
| 61 | + pub proton_found: bool, | |
| 62 | + pub prefix_exists: bool, | |
| 63 | + pub wemod_installed: bool, | |
| 64 | +} | |
| 65 | + | |
| 66 | +#[derive(Debug, Serialize, Deserialize)] | |
| 67 | +pub struct DoctorReport { | |
| 68 | + pub steam_ok: bool, | |
| 69 | + pub steam_path: Option<String>, | |
| 70 | + pub proton_ok: bool, | |
| 71 | + pub proton_count: usize, | |
| 72 | + pub prefix_ok: bool, | |
| 73 | + pub wemod_ok: bool, | |
| 74 | + pub issues: Vec<String>, | |
| 75 | +} | |
| 76 | + | |
| 77 | +#[derive(Debug, Serialize, Deserialize)] | |
| 78 | +pub struct ConfigDto { | |
| 79 | + pub steam_path: Option<String>, | |
| 80 | + pub scan_flatpak: bool, | |
| 81 | + pub preferred_proton: Option<String>, | |
| 82 | + pub auto_update_wemod: bool, | |
| 83 | +} | |
| 84 | + | |
| 85 | +// ============================================================================ | |
| 86 | +// Game Commands | |
| 87 | +// ============================================================================ | |
| 88 | + | |
| 89 | +#[tauri::command] | |
| 90 | +pub async fn get_games(state: State<'_, Arc<Mutex<AppState>>>) -> Result<Vec<GameInfo>, String> { | |
| 91 | + let mut state = state.lock().await; | |
| 92 | + state.ensure_loaded()?; | |
| 93 | + | |
| 94 | + let steam = state.steam.as_ref().ok_or("Steam not loaded")?; | |
| 95 | + | |
| 96 | + let games: Vec<GameInfo> = steam | |
| 97 | + .get_all_games() | |
| 98 | + .iter() | |
| 99 | + .filter(|g| g.uses_proton()) | |
| 100 | + .map(|g| GameInfo { | |
| 101 | + app_id: g.app_id, | |
| 102 | + name: g.name.clone(), | |
| 103 | + size: g.size_human(), | |
| 104 | + uses_proton: g.uses_proton(), | |
| 105 | + install_path: g.install_path.to_string_lossy().to_string(), | |
| 106 | + }) | |
| 107 | + .collect(); | |
| 108 | + | |
| 109 | + Ok(games) | |
| 110 | +} | |
| 111 | + | |
| 112 | +#[tauri::command] | |
| 113 | +pub async fn get_game( | |
| 114 | + app_id: u32, | |
| 115 | + state: State<'_, Arc<Mutex<AppState>>>, | |
| 116 | +) -> Result<GameInfo, String> { | |
| 117 | + let mut state = state.lock().await; | |
| 118 | + state.ensure_loaded()?; | |
| 119 | + | |
| 120 | + let steam = state.steam.as_ref().ok_or("Steam not loaded")?; | |
| 121 | + let game = steam | |
| 122 | + .find_game(app_id) | |
| 123 | + .ok_or_else(|| format!("Game {} not found", app_id))?; | |
| 124 | + | |
| 125 | + Ok(GameInfo { | |
| 126 | + app_id: game.app_id, | |
| 127 | + name: game.name.clone(), | |
| 128 | + size: game.size_human(), | |
| 129 | + uses_proton: game.uses_proton(), | |
| 130 | + install_path: game.install_path.to_string_lossy().to_string(), | |
| 131 | + }) | |
| 132 | +} | |
| 133 | + | |
| 134 | +#[tauri::command] | |
| 135 | +pub async fn launch_game( | |
| 136 | + app_id: u32, | |
| 137 | + with_wemod: bool, | |
| 138 | + state: State<'_, Arc<Mutex<AppState>>>, | |
| 139 | +) -> Result<(), String> { | |
| 140 | + let mut state = state.lock().await; | |
| 141 | + state.ensure_loaded()?; | |
| 142 | + | |
| 143 | + let steam = state.steam.as_ref().ok_or("Steam not loaded")?; | |
| 144 | + let config = state.config.as_ref().ok_or("Config not loaded")?; | |
| 145 | + let proton_mgr = state.proton.as_ref().ok_or("Proton not loaded")?; | |
| 146 | + let prefix_mgr = state.prefix_manager.as_ref().ok_or("Prefix manager not loaded")?; | |
| 147 | + | |
| 148 | + let proton = proton_mgr | |
| 149 | + .get_preferred(config) | |
| 150 | + .map_err(|e| e.to_string())?; | |
| 151 | + | |
| 152 | + let prefix = prefix_mgr | |
| 153 | + .get("default") | |
| 154 | + .ok_or("WANDA not initialized")?; | |
| 155 | + | |
| 156 | + let launcher = GameLauncher::new(steam, prefix, proton); | |
| 157 | + | |
| 158 | + let launch_config = LaunchConfig { | |
| 159 | + app_id, | |
| 160 | + with_wemod, | |
| 161 | + wemod_delay: 3, | |
| 162 | + ..Default::default() | |
| 163 | + }; | |
| 164 | + | |
| 165 | + launcher.launch(launch_config).await.map_err(|e| e.to_string())?; | |
| 166 | + | |
| 167 | + Ok(()) | |
| 168 | +} | |
| 169 | + | |
| 170 | +// ============================================================================ | |
| 171 | +// Prefix Commands | |
| 172 | +// ============================================================================ | |
| 173 | + | |
| 174 | +#[tauri::command] | |
| 175 | +pub async fn get_prefixes(state: State<'_, Arc<Mutex<AppState>>>) -> Result<Vec<PrefixInfo>, String> { | |
| 176 | + let mut state = state.lock().await; | |
| 177 | + state.ensure_loaded()?; | |
| 178 | + | |
| 179 | + let prefix_mgr = state.prefix_manager.as_ref().ok_or("Prefix manager not loaded")?; | |
| 180 | + | |
| 181 | + let prefixes: Vec<PrefixInfo> = prefix_mgr | |
| 182 | + .list() | |
| 183 | + .iter() | |
| 184 | + .map(|p| { | |
| 185 | + let health = prefix_mgr.validate(&p.name).unwrap_or(PrefixHealth::NotCreated); | |
| 186 | + let (health_str, issues) = match &health { | |
| 187 | + PrefixHealth::Healthy => ("healthy".to_string(), vec![]), | |
| 188 | + PrefixHealth::NeedsRepair(issues) => ( | |
| 189 | + "needs_repair".to_string(), | |
| 190 | + issues.iter().map(|i| i.to_string()).collect(), | |
| 191 | + ), | |
| 192 | + PrefixHealth::Corrupted(reason) => ("corrupted".to_string(), vec![reason.clone()]), | |
| 193 | + PrefixHealth::NotCreated => ("not_created".to_string(), vec![]), | |
| 194 | + }; | |
| 195 | + | |
| 196 | + PrefixInfo { | |
| 197 | + name: p.name.clone(), | |
| 198 | + path: p.path.to_string_lossy().to_string(), | |
| 199 | + wemod_installed: p.wemod_installed, | |
| 200 | + wemod_version: p.wemod_version.clone(), | |
| 201 | + proton_version: p.proton_version.clone(), | |
| 202 | + health: health_str, | |
| 203 | + issues, | |
| 204 | + } | |
| 205 | + }) | |
| 206 | + .collect(); | |
| 207 | + | |
| 208 | + Ok(prefixes) | |
| 209 | +} | |
| 210 | + | |
| 211 | +#[tauri::command] | |
| 212 | +pub async fn get_prefix_health( | |
| 213 | + name: String, | |
| 214 | + state: State<'_, Arc<Mutex<AppState>>>, | |
| 215 | +) -> Result<PrefixInfo, String> { | |
| 216 | + let mut state = state.lock().await; | |
| 217 | + state.ensure_loaded()?; | |
| 218 | + | |
| 219 | + let prefix_mgr = state.prefix_manager.as_ref().ok_or("Prefix manager not loaded")?; | |
| 220 | + | |
| 221 | + let prefix = prefix_mgr.get(&name).ok_or("Prefix not found")?; | |
| 222 | + let health = prefix_mgr.validate(&name).map_err(|e| e.to_string())?; | |
| 223 | + | |
| 224 | + let (health_str, issues) = match &health { | |
| 225 | + PrefixHealth::Healthy => ("healthy".to_string(), vec![]), | |
| 226 | + PrefixHealth::NeedsRepair(issues) => ( | |
| 227 | + "needs_repair".to_string(), | |
| 228 | + issues.iter().map(|i| i.to_string()).collect(), | |
| 229 | + ), | |
| 230 | + PrefixHealth::Corrupted(reason) => ("corrupted".to_string(), vec![reason.clone()]), | |
| 231 | + PrefixHealth::NotCreated => ("not_created".to_string(), vec![]), | |
| 232 | + }; | |
| 233 | + | |
| 234 | + Ok(PrefixInfo { | |
| 235 | + name: prefix.name.clone(), | |
| 236 | + path: prefix.path.to_string_lossy().to_string(), | |
| 237 | + wemod_installed: prefix.wemod_installed, | |
| 238 | + wemod_version: prefix.wemod_version.clone(), | |
| 239 | + proton_version: prefix.proton_version.clone(), | |
| 240 | + health: health_str, | |
| 241 | + issues, | |
| 242 | + }) | |
| 243 | +} | |
| 244 | + | |
| 245 | +#[tauri::command] | |
| 246 | +pub async fn repair_prefix( | |
| 247 | + name: String, | |
| 248 | + state: State<'_, Arc<Mutex<AppState>>>, | |
| 249 | +) -> Result<(), String> { | |
| 250 | + let mut state = state.lock().await; | |
| 251 | + state.ensure_loaded()?; | |
| 252 | + | |
| 253 | + let config = state.config.as_ref().ok_or("Config not loaded")?; | |
| 254 | + let proton_mgr = state.proton.as_ref().ok_or("Proton not loaded")?; | |
| 255 | + let prefix_mgr = state.prefix_manager.as_mut().ok_or("Prefix manager not loaded")?; | |
| 256 | + | |
| 257 | + let proton = proton_mgr | |
| 258 | + .get_preferred(config) | |
| 259 | + .map_err(|e| e.to_string())?; | |
| 260 | + | |
| 261 | + prefix_mgr.repair(&name, proton).await.map_err(|e| e.to_string())?; | |
| 262 | + | |
| 263 | + Ok(()) | |
| 264 | +} | |
| 265 | + | |
| 266 | +// ============================================================================ | |
| 267 | +// Initialization Commands | |
| 268 | +// ============================================================================ | |
| 269 | + | |
| 270 | +#[tauri::command] | |
| 271 | +pub async fn get_init_status(state: State<'_, Arc<Mutex<AppState>>>) -> Result<InitStatus, String> { | |
| 272 | + let mut state = state.lock().await; | |
| 273 | + | |
| 274 | + // Try to load, but don't fail if we can't | |
| 275 | + let _ = state.load(); | |
| 276 | + | |
| 277 | + let steam_found = state.steam.is_some(); | |
| 278 | + let proton_found = state.proton.as_ref().map(|p| !p.versions.is_empty()).unwrap_or(false); | |
| 279 | + let prefix_exists = state | |
| 280 | + .prefix_manager | |
| 281 | + .as_ref() | |
| 282 | + .map(|pm| pm.get("default").is_some()) | |
| 283 | + .unwrap_or(false); | |
| 284 | + let wemod_installed = state | |
| 285 | + .prefix_manager | |
| 286 | + .as_ref() | |
| 287 | + .and_then(|pm| pm.get("default")) | |
| 288 | + .map(|p| p.wemod_installed) | |
| 289 | + .unwrap_or(false); | |
| 290 | + | |
| 291 | + Ok(InitStatus { | |
| 292 | + initialized: wemod_installed, | |
| 293 | + steam_found, | |
| 294 | + proton_found, | |
| 295 | + prefix_exists, | |
| 296 | + wemod_installed, | |
| 297 | + }) | |
| 298 | +} | |
| 299 | + | |
| 300 | +#[tauri::command] | |
| 301 | +pub async fn init_wanda(state: State<'_, Arc<Mutex<AppState>>>) -> Result<(), String> { | |
| 302 | + let mut state = state.lock().await; | |
| 303 | + | |
| 304 | + // Load config | |
| 305 | + let config = WandaConfig::load().map_err(|e| e.to_string())?; | |
| 306 | + | |
| 307 | + // Discover Steam | |
| 308 | + let steam = SteamInstallation::discover(&config).map_err(|e| e.to_string())?; | |
| 309 | + | |
| 310 | + // Discover Proton | |
| 311 | + let proton_mgr = ProtonManager::discover(&steam, &config).map_err(|e| e.to_string())?; | |
| 312 | + let proton = proton_mgr.get_preferred(&config).map_err(|e| e.to_string())?; | |
| 313 | + | |
| 314 | + // Create prefix manager and default prefix | |
| 315 | + let mut prefix_mgr = PrefixManager::new(&config); | |
| 316 | + prefix_mgr.load().map_err(|e| e.to_string())?; | |
| 317 | + | |
| 318 | + if prefix_mgr.get("default").is_none() { | |
| 319 | + prefix_mgr.create("default", proton).await.map_err(|e| e.to_string())?; | |
| 320 | + } | |
| 321 | + | |
| 322 | + // Download and install WeMod | |
| 323 | + let downloader = WemodDownloader::new(&config); | |
| 324 | + let release = downloader.get_latest().await.map_err(|e| e.to_string())?; | |
| 325 | + let installer_path = downloader | |
| 326 | + .download(&release, |_, _| {}) | |
| 327 | + .await | |
| 328 | + .map_err(|e| e.to_string())?; | |
| 329 | + | |
| 330 | + let prefix = prefix_mgr.get("default").ok_or("Prefix not created")?; | |
| 331 | + let installer = WemodInstaller::new(prefix, proton); | |
| 332 | + installer.install(&installer_path).await.map_err(|e| e.to_string())?; | |
| 333 | + | |
| 334 | + // Update prefix metadata | |
| 335 | + let version = release.version.clone(); | |
| 336 | + prefix_mgr.update_metadata("default", |p| { | |
| 337 | + p.wemod_installed = true; | |
| 338 | + p.wemod_version = version; | |
| 339 | + }).map_err(|e| e.to_string())?; | |
| 340 | + | |
| 341 | + // Save config | |
| 342 | + let mut config = config; | |
| 343 | + config.proton.preferred_version = Some(proton.name.clone()); | |
| 344 | + config.save().map_err(|e| e.to_string())?; | |
| 345 | + | |
| 346 | + // Reload state | |
| 347 | + state.load()?; | |
| 348 | + | |
| 349 | + Ok(()) | |
| 350 | +} | |
| 351 | + | |
| 352 | +// ============================================================================ | |
| 353 | +// Config Commands | |
| 354 | +// ============================================================================ | |
| 355 | + | |
| 356 | +#[tauri::command] | |
| 357 | +pub async fn get_config(state: State<'_, Arc<Mutex<AppState>>>) -> Result<ConfigDto, String> { | |
| 358 | + let mut state = state.lock().await; | |
| 359 | + state.ensure_loaded()?; | |
| 360 | + | |
| 361 | + let config = state.config.as_ref().ok_or("Config not loaded")?; | |
| 362 | + | |
| 363 | + Ok(ConfigDto { | |
| 364 | + steam_path: config.steam.install_path.as_ref().map(|p| p.to_string_lossy().to_string()), | |
| 365 | + scan_flatpak: config.steam.scan_flatpak, | |
| 366 | + preferred_proton: config.proton.preferred_version.clone(), | |
| 367 | + auto_update_wemod: config.wemod.auto_update, | |
| 368 | + }) | |
| 369 | +} | |
| 370 | + | |
| 371 | +#[tauri::command] | |
| 372 | +pub async fn update_config( | |
| 373 | + config_dto: ConfigDto, | |
| 374 | + state: State<'_, Arc<Mutex<AppState>>>, | |
| 375 | +) -> Result<(), String> { | |
| 376 | + let mut state = state.lock().await; | |
| 377 | + state.ensure_loaded()?; | |
| 378 | + | |
| 379 | + let config = state.config.as_mut().ok_or("Config not loaded")?; | |
| 380 | + | |
| 381 | + config.steam.install_path = config_dto.steam_path.map(std::path::PathBuf::from); | |
| 382 | + config.steam.scan_flatpak = config_dto.scan_flatpak; | |
| 383 | + config.proton.preferred_version = config_dto.preferred_proton; | |
| 384 | + config.wemod.auto_update = config_dto.auto_update_wemod; | |
| 385 | + | |
| 386 | + config.save().map_err(|e| e.to_string())?; | |
| 387 | + | |
| 388 | + // Reload state to reflect changes | |
| 389 | + state.load()?; | |
| 390 | + | |
| 391 | + Ok(()) | |
| 392 | +} | |
| 393 | + | |
| 394 | +// ============================================================================ | |
| 395 | +// WeMod Commands | |
| 396 | +// ============================================================================ | |
| 397 | + | |
| 398 | +#[tauri::command] | |
| 399 | +pub async fn get_wemod_status(state: State<'_, Arc<Mutex<AppState>>>) -> Result<WemodStatus, String> { | |
| 400 | + let mut state = state.lock().await; | |
| 401 | + state.ensure_loaded()?; | |
| 402 | + | |
| 403 | + let config = state.config.as_ref().ok_or("Config not loaded")?; | |
| 404 | + let prefix_mgr = state.prefix_manager.as_ref().ok_or("Prefix manager not loaded")?; | |
| 405 | + | |
| 406 | + let prefix = prefix_mgr.get("default"); | |
| 407 | + let installed = prefix.map(|p| p.wemod_installed).unwrap_or(false); | |
| 408 | + let version = prefix.and_then(|p| p.wemod_version.clone()); | |
| 409 | + | |
| 410 | + // Check for updates | |
| 411 | + let downloader = WemodDownloader::new(config); | |
| 412 | + let (update_available, latest_version) = match downloader.get_latest().await { | |
| 413 | + Ok(release) => { | |
| 414 | + let latest = release.version.clone(); | |
| 415 | + let update = match (&version, &latest) { | |
| 416 | + (Some(current), Some(new)) => current != new, | |
| 417 | + _ => false, | |
| 418 | + }; | |
| 419 | + (update, latest) | |
| 420 | + } | |
| 421 | + Err(_) => (false, None), | |
| 422 | + }; | |
| 423 | + | |
| 424 | + Ok(WemodStatus { | |
| 425 | + installed, | |
| 426 | + version, | |
| 427 | + update_available, | |
| 428 | + latest_version, | |
| 429 | + }) | |
| 430 | +} | |
| 431 | + | |
| 432 | +#[tauri::command] | |
| 433 | +pub async fn update_wemod(state: State<'_, Arc<Mutex<AppState>>>) -> Result<(), String> { | |
| 434 | + let mut state = state.lock().await; | |
| 435 | + state.ensure_loaded()?; | |
| 436 | + | |
| 437 | + let config = state.config.as_ref().ok_or("Config not loaded")?; | |
| 438 | + let proton_mgr = state.proton.as_ref().ok_or("Proton not loaded")?; | |
| 439 | + let prefix_mgr = state.prefix_manager.as_mut().ok_or("Prefix manager not loaded")?; | |
| 440 | + | |
| 441 | + let proton = proton_mgr.get_preferred(config).map_err(|e| e.to_string())?; | |
| 442 | + let prefix = prefix_mgr.get("default").ok_or("WANDA not initialized")?; | |
| 443 | + | |
| 444 | + let downloader = WemodDownloader::new(config); | |
| 445 | + let release = downloader.get_latest().await.map_err(|e| e.to_string())?; | |
| 446 | + let installer_path = downloader | |
| 447 | + .download(&release, |_, _| {}) | |
| 448 | + .await | |
| 449 | + .map_err(|e| e.to_string())?; | |
| 450 | + | |
| 451 | + let installer = WemodInstaller::new(prefix, proton); | |
| 452 | + installer.install(&installer_path).await.map_err(|e| e.to_string())?; | |
| 453 | + | |
| 454 | + let version = release.version.clone(); | |
| 455 | + prefix_mgr.update_metadata("default", |p| { | |
| 456 | + p.wemod_version = version; | |
| 457 | + }).map_err(|e| e.to_string())?; | |
| 458 | + | |
| 459 | + Ok(()) | |
| 460 | +} | |
| 461 | + | |
| 462 | +// ============================================================================ | |
| 463 | +// Proton Commands | |
| 464 | +// ============================================================================ | |
| 465 | + | |
| 466 | +#[tauri::command] | |
| 467 | +pub async fn get_proton_versions( | |
| 468 | + state: State<'_, Arc<Mutex<AppState>>>, | |
| 469 | +) -> Result<Vec<ProtonInfo>, String> { | |
| 470 | + let mut state = state.lock().await; | |
| 471 | + state.ensure_loaded()?; | |
| 472 | + | |
| 473 | + let config = state.config.as_ref().ok_or("Config not loaded")?; | |
| 474 | + let proton_mgr = state.proton.as_ref().ok_or("Proton not loaded")?; | |
| 475 | + | |
| 476 | + let recommended = proton_mgr.get_preferred(config).ok(); | |
| 477 | + | |
| 478 | + let versions: Vec<ProtonInfo> = proton_mgr | |
| 479 | + .versions | |
| 480 | + .iter() | |
| 481 | + .map(|v| ProtonInfo { | |
| 482 | + name: v.name.clone(), | |
| 483 | + path: v.path.to_string_lossy().to_string(), | |
| 484 | + compatibility: match v.compatibility { | |
| 485 | + ProtonCompatibility::Recommended => "recommended".to_string(), | |
| 486 | + ProtonCompatibility::Supported => "supported".to_string(), | |
| 487 | + ProtonCompatibility::Experimental => "experimental".to_string(), | |
| 488 | + ProtonCompatibility::Unsupported => "unsupported".to_string(), | |
| 489 | + }, | |
| 490 | + is_ge: v.is_ge, | |
| 491 | + is_recommended: recommended.map(|r| r.name == v.name).unwrap_or(false), | |
| 492 | + }) | |
| 493 | + .collect(); | |
| 494 | + | |
| 495 | + Ok(versions) | |
| 496 | +} | |
| 497 | + | |
| 498 | +// ============================================================================ | |
| 499 | +// Doctor Commands | |
| 500 | +// ============================================================================ | |
| 501 | + | |
| 502 | +#[tauri::command] | |
| 503 | +pub async fn run_doctor(state: State<'_, Arc<Mutex<AppState>>>) -> Result<DoctorReport, String> { | |
| 504 | + let mut state = state.lock().await; | |
| 505 | + | |
| 506 | + let mut issues = Vec::new(); | |
| 507 | + | |
| 508 | + // Try to load config | |
| 509 | + let config = match WandaConfig::load() { | |
| 510 | + Ok(c) => Some(c), | |
| 511 | + Err(e) => { | |
| 512 | + issues.push(format!("Config error: {}", e)); | |
| 513 | + None | |
| 514 | + } | |
| 515 | + }; | |
| 516 | + | |
| 517 | + // Check Steam | |
| 518 | + let (steam_ok, steam_path) = if let Some(ref cfg) = config { | |
| 519 | + match SteamInstallation::discover(cfg) { | |
| 520 | + Ok(steam) => (true, Some(steam.root_path.to_string_lossy().to_string())), | |
| 521 | + Err(e) => { | |
| 522 | + issues.push(format!("Steam not found: {}", e)); | |
| 523 | + (false, None) | |
| 524 | + } | |
| 525 | + } | |
| 526 | + } else { | |
| 527 | + (false, None) | |
| 528 | + }; | |
| 529 | + | |
| 530 | + // Check Proton | |
| 531 | + let (proton_ok, proton_count) = if let (Some(ref cfg), true) = (&config, steam_ok) { | |
| 532 | + let steam = SteamInstallation::discover(cfg).unwrap(); | |
| 533 | + match ProtonManager::discover(&steam, cfg) { | |
| 534 | + Ok(pm) if !pm.versions.is_empty() => (true, pm.versions.len()), | |
| 535 | + Ok(_) => { | |
| 536 | + issues.push("No Proton versions found".to_string()); | |
| 537 | + (false, 0) | |
| 538 | + } | |
| 539 | + Err(e) => { | |
| 540 | + issues.push(format!("Proton error: {}", e)); | |
| 541 | + (false, 0) | |
| 542 | + } | |
| 543 | + } | |
| 544 | + } else { | |
| 545 | + (false, 0) | |
| 546 | + }; | |
| 547 | + | |
| 548 | + // Check prefix | |
| 549 | + let (prefix_ok, wemod_ok) = if let Some(ref cfg) = config { | |
| 550 | + let mut pm = PrefixManager::new(cfg); | |
| 551 | + let _ = pm.load(); | |
| 552 | + if let Some(prefix) = pm.get("default") { | |
| 553 | + let health_ok = matches!(pm.validate("default"), Ok(PrefixHealth::Healthy)); | |
| 554 | + if !health_ok { | |
| 555 | + issues.push("Prefix needs repair".to_string()); | |
| 556 | + } | |
| 557 | + (true, prefix.wemod_installed) | |
| 558 | + } else { | |
| 559 | + issues.push("WANDA not initialized".to_string()); | |
| 560 | + (false, false) | |
| 561 | + } | |
| 562 | + } else { | |
| 563 | + (false, false) | |
| 564 | + }; | |
| 565 | + | |
| 566 | + if !wemod_ok && prefix_ok { | |
| 567 | + issues.push("WeMod not installed".to_string()); | |
| 568 | + } | |
| 569 | + | |
| 570 | + // Update state if load was successful | |
| 571 | + if config.is_some() { | |
| 572 | + let _ = state.load(); | |
| 573 | + } | |
| 574 | + | |
| 575 | + Ok(DoctorReport { | |
| 576 | + steam_ok, | |
| 577 | + steam_path, | |
| 578 | + proton_ok, | |
| 579 | + proton_count, | |
| 580 | + prefix_ok, | |
| 581 | + wemod_ok, | |
| 582 | + issues, | |
| 583 | + }) | |
| 584 | +} | |
crates/wanda-gui/src/main.rsadded@@ -0,0 +1,34 @@ | ||
| 1 | +//! WANDA GUI - Tauri application | |
| 2 | + | |
| 3 | +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] | |
| 4 | + | |
| 5 | +mod commands; | |
| 6 | +mod state; | |
| 7 | + | |
| 8 | +use state::AppState; | |
| 9 | +use std::sync::Arc; | |
| 10 | +use tokio::sync::Mutex; | |
| 11 | + | |
| 12 | +fn main() { | |
| 13 | + tauri::Builder::default() | |
| 14 | + .plugin(tauri_plugin_shell::init()) | |
| 15 | + .manage(Arc::new(Mutex::new(AppState::new()))) | |
| 16 | + .invoke_handler(tauri::generate_handler![ | |
| 17 | + commands::get_games, | |
| 18 | + commands::get_game, | |
| 19 | + commands::launch_game, | |
| 20 | + commands::get_prefixes, | |
| 21 | + commands::get_prefix_health, | |
| 22 | + commands::repair_prefix, | |
| 23 | + commands::init_wanda, | |
| 24 | + commands::get_init_status, | |
| 25 | + commands::get_config, | |
| 26 | + commands::update_config, | |
| 27 | + commands::get_wemod_status, | |
| 28 | + commands::update_wemod, | |
| 29 | + commands::get_proton_versions, | |
| 30 | + commands::run_doctor, | |
| 31 | + ]) | |
| 32 | + .run(tauri::generate_context!()) | |
| 33 | + .expect("error while running tauri application"); | |
| 34 | +} | |
crates/wanda-gui/src/state.rsadded@@ -0,0 +1,77 @@ | ||
| 1 | +//! Application state management | |
| 2 | + | |
| 3 | +use wanda_core::{ | |
| 4 | + config::WandaConfig, | |
| 5 | + prefix::PrefixManager, | |
| 6 | + steam::{ProtonManager, SteamInstallation}, | |
| 7 | +}; | |
| 8 | + | |
| 9 | +/// Shared application state | |
| 10 | +pub struct AppState { | |
| 11 | + /// Loaded configuration | |
| 12 | + pub config: Option<WandaConfig>, | |
| 13 | + /// Steam installation (cached) | |
| 14 | + pub steam: Option<SteamInstallation>, | |
| 15 | + /// Proton manager (cached) | |
| 16 | + pub proton: Option<ProtonManager>, | |
| 17 | + /// Prefix manager | |
| 18 | + pub prefix_manager: Option<PrefixManager>, | |
| 19 | + /// Whether WANDA is initialized | |
| 20 | + pub initialized: bool, | |
| 21 | +} | |
| 22 | + | |
| 23 | +impl AppState { | |
| 24 | + pub fn new() -> Self { | |
| 25 | + Self { | |
| 26 | + config: None, | |
| 27 | + steam: None, | |
| 28 | + proton: None, | |
| 29 | + prefix_manager: None, | |
| 30 | + initialized: false, | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + /// Load or reload state from disk | |
| 35 | + pub fn load(&mut self) -> Result<(), String> { | |
| 36 | + // Load config | |
| 37 | + let config = WandaConfig::load().map_err(|e| e.to_string())?; | |
| 38 | + | |
| 39 | + // Discover Steam | |
| 40 | + let steam = SteamInstallation::discover(&config).map_err(|e| e.to_string())?; | |
| 41 | + | |
| 42 | + // Discover Proton | |
| 43 | + let proton = ProtonManager::discover(&steam, &config).map_err(|e| e.to_string())?; | |
| 44 | + | |
| 45 | + // Load prefix manager | |
| 46 | + let mut prefix_manager = PrefixManager::new(&config); | |
| 47 | + prefix_manager.load().map_err(|e| e.to_string())?; | |
| 48 | + | |
| 49 | + // Check if initialized (has default prefix with WeMod) | |
| 50 | + let initialized = prefix_manager | |
| 51 | + .get("default") | |
| 52 | + .map(|p| p.wemod_installed) | |
| 53 | + .unwrap_or(false); | |
| 54 | + | |
| 55 | + self.config = Some(config); | |
| 56 | + self.steam = Some(steam); | |
| 57 | + self.proton = Some(proton); | |
| 58 | + self.prefix_manager = Some(prefix_manager); | |
| 59 | + self.initialized = initialized; | |
| 60 | + | |
| 61 | + Ok(()) | |
| 62 | + } | |
| 63 | + | |
| 64 | + /// Ensure state is loaded | |
| 65 | + pub fn ensure_loaded(&mut self) -> Result<(), String> { | |
| 66 | + if self.config.is_none() { | |
| 67 | + self.load()?; | |
| 68 | + } | |
| 69 | + Ok(()) | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +impl Default for AppState { | |
| 74 | + fn default() -> Self { | |
| 75 | + Self::new() | |
| 76 | + } | |
| 77 | +} | |
crates/wanda-gui/tauri.conf.jsonadded@@ -0,0 +1,45 @@ | ||
| 1 | +{ | |
| 2 | + "$schema": "https://schema.tauri.app/config/2", | |
| 3 | + "productName": "WANDA", | |
| 4 | + "version": "0.1.0", | |
| 5 | + "identifier": "com.wanda.app", | |
| 6 | + "build": { | |
| 7 | + "frontendDist": "../frontend/dist", | |
| 8 | + "devUrl": "http://localhost:5173", | |
| 9 | + "beforeDevCommand": "cd ../frontend && npm run dev", | |
| 10 | + "beforeBuildCommand": "cd ../frontend && npm run build" | |
| 11 | + }, | |
| 12 | + "app": { | |
| 13 | + "withGlobalTauri": true, | |
| 14 | + "windows": [ | |
| 15 | + { | |
| 16 | + "title": "WANDA", | |
| 17 | + "width": 1200, | |
| 18 | + "height": 800, | |
| 19 | + "minWidth": 800, | |
| 20 | + "minHeight": 600, | |
| 21 | + "resizable": true, | |
| 22 | + "fullscreen": false | |
| 23 | + } | |
| 24 | + ], | |
| 25 | + "security": { | |
| 26 | + "csp": null | |
| 27 | + } | |
| 28 | + }, | |
| 29 | + "bundle": { | |
| 30 | + "active": true, | |
| 31 | + "targets": "all", | |
| 32 | + "icon": [ | |
| 33 | + "icons/32x32.png", | |
| 34 | + "icons/128x128.png", | |
| 35 | + "icons/128x128@2x.png", | |
| 36 | + "icons/icon.icns", | |
| 37 | + "icons/icon.ico" | |
| 38 | + ], | |
| 39 | + "linux": { | |
| 40 | + "appimage": { | |
| 41 | + "bundleMediaFramework": false | |
| 42 | + } | |
| 43 | + } | |
| 44 | + } | |
| 45 | +} | |