zeroed-some/wanda / 57532bd

Browse files

first pass, cli, gui, installer

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
57532bd23fc50213e2af31c80e4df6a56423f75c
Parents
ea4ad0a
Tree
b570c6d

46 changed files

StatusFile+-
A crates/wanda-cli/Cargo.toml 24 0
A crates/wanda-cli/src/commands/config.rs 202 0
A crates/wanda-cli/src/commands/doctor.rs 243 0
A crates/wanda-cli/src/commands/init.rs 175 0
A crates/wanda-cli/src/commands/launch.rs 149 0
A crates/wanda-cli/src/commands/mod.rs 9 0
A crates/wanda-cli/src/commands/prefix.rs 301 0
A crates/wanda-cli/src/commands/scan.rs 136 0
A crates/wanda-cli/src/commands/wemod.rs 299 0
A crates/wanda-cli/src/main.rs 101 0
A crates/wanda-core/Cargo.toml 23 0
A crates/wanda-core/src/config.rs 182 0
A crates/wanda-core/src/error.rs 114 0
A crates/wanda-core/src/launcher.rs 352 0
A crates/wanda-core/src/lib.rs 13 0
A crates/wanda-core/src/prefix/builder.rs 342 0
A crates/wanda-core/src/prefix/manager.rs 361 0
A crates/wanda-core/src/prefix/mod.rs 9 0
A crates/wanda-core/src/steam/library.rs 348 0
A crates/wanda-core/src/steam/mod.rs 11 0
A crates/wanda-core/src/steam/proton.rs 326 0
A crates/wanda-core/src/steam/vdf.rs 238 0
A crates/wanda-core/src/wemod/downloader.rs 193 0
A crates/wanda-core/src/wemod/installer.rs 297 0
A crates/wanda-core/src/wemod/mod.rs 7 0
A crates/wanda-gui/Cargo.toml 23 0
A crates/wanda-gui/build.rs 3 0
A crates/wanda-gui/capabilities/default.json 10 0
A crates/wanda-gui/frontend/index.html 30 0
A crates/wanda-gui/frontend/package.json 24 0
A crates/wanda-gui/frontend/src/App.tsx 99 0
A crates/wanda-gui/frontend/src/components/GameCard.tsx 37 0
A crates/wanda-gui/frontend/src/hooks/useApi.ts 73 0
A crates/wanda-gui/frontend/src/main.tsx 13 0
A crates/wanda-gui/frontend/src/styles.css 441 0
A crates/wanda-gui/frontend/src/types/index.ts 59 0
A crates/wanda-gui/frontend/src/views/GamesView.tsx 119 0
A crates/wanda-gui/frontend/src/views/PrefixesView.tsx 188 0
A crates/wanda-gui/frontend/src/views/SettingsView.tsx 257 0
A crates/wanda-gui/frontend/src/views/SetupView.tsx 147 0
A crates/wanda-gui/frontend/tsconfig.json 20 0
A crates/wanda-gui/frontend/vite.config.ts 17 0
A crates/wanda-gui/src/commands.rs 584 0
A crates/wanda-gui/src/main.rs 34 0
A crates/wanda-gui/src/state.rs 77 0
A crates/wanda-gui/tauri.conf.json 45 0
crates/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
+}