| 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 | } |
| 353 |