From a2c483badb38f1fe0ed1dc5cd15760b557bd8e1a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:58:56 +0000 Subject: [PATCH 1/3] feat: add repair capability for master Windows Steam prefix - Implement `repair_master_steam` in `src/launch/mod.rs` with backup rotation. - Add 2s post-ready grace check in `WineTkgRunner` to detect early Steam crashes. - Expose "Repair Windows Steam Runtime" in Settings UI with confirmation. - Add unit test for background Steam grace check failure. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/infra/runners/wine_tkg.rs | 53 ++++++++++---- src/launch/mod.rs | 46 ++++++++++++ src/launch/verification_tests.rs | 118 +++++++++++++++++++++++++++++++ src/ui.rs | 50 +++++++++++-- 4 files changed, 246 insertions(+), 21 deletions(-) diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index b35cc17..87538b1 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -304,6 +304,7 @@ impl Runner for WineTkgRunner { let steam_logs_dir = prefix_steam_dir.join("logs"); let ready = 'wait: { + let mut signal_msg = None; for i in 0..readiness_timeout { tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -323,20 +324,20 @@ impl Runner for WineTkgRunner { // Signal 1: pid file (some Wine/Steam combos do write this) if steam_pid_path.exists() { - println!("✅ Steam ready after {}s (steam.pid found)", i + 1); - break 'wait true; + signal_msg = Some(format!("steam.pid found after {}s", i + 1)); + break; } // Signal 2: .steampath in temp (Proton-style) if steam_pipe.exists() { - println!("✅ Steam ready after {}s (.steampath found)", i + 1); - break 'wait true; + signal_msg = Some(format!(".steampath found after {}s", i + 1)); + break; } // Signal 3: config.vdf written — Steam has finished early init if steam_config_vdf.exists() { - println!("✅ Steam ready after {}s (config.vdf found)", i + 1); - break 'wait true; + signal_msg = Some(format!("config.vdf found after {}s", i + 1)); + break; } // Signal 4: logs dir has multiple entries — Steam's subsystems are running @@ -344,24 +345,45 @@ impl Runner for WineTkgRunner { .map(|d| d.count()) .unwrap_or(0); if log_count >= 2 { - println!("✅ Steam ready after {}s ({} log files found)", i + 1, log_count); + signal_msg = Some(format!("{} log files found after {}s", log_count, i + 1)); unsafe { if !ctx.verification_ptr.is_null() { (*ctx.verification_ptr).steam_runtime_milestone = "steam_ready_signal_observed".to_string(); } } - break 'wait true; + break; } println!(" Waiting... {}s", i + 1); } - println!("⚠️ Steam did not signal ready after {}s, launching game anyway", readiness_timeout); - unsafe { - if !ctx.verification_ptr.is_null() { - (*ctx.verification_ptr).steam_runtime_milestone = "steam_ready_timeout".to_string(); + + if let Some(msg) = signal_msg { + println!("✅ Steam ready signal: {}", msg); + + // Grace period check: ensure it didn't crash immediately after signaling ready + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + if let Ok(Some(status)) = steam_process.try_wait() { + println!("❌ FATAL: Background Steam exited during grace period with: {}", status); + unsafe { + if !ctx.verification_ptr.is_null() { + let v = &mut *ctx.verification_ptr; + v.steam_runtime_exit_code = status.code(); + v.steam_runtime_lifetime_ms = Some(start_time.elapsed().as_millis() as u64); + v.steam_runtime_milestone = "steam_process_exited_early".to_string(); + } + } + break 'wait false; } + true + } else { + println!("⚠️ Steam did not signal ready after {}s, launching game anyway", readiness_timeout); + unsafe { + if !ctx.verification_ptr.is_null() { + (*ctx.verification_ptr).steam_runtime_milestone = "steam_ready_timeout".to_string(); + } + } + true } - true }; if !ready { @@ -370,7 +392,10 @@ impl Runner for WineTkgRunner { (*ctx.verification_ptr).steam_auto_start_failed = true; } } - return Err(LaunchError::new(LaunchErrorKind::Process, "Background Steam crashed before the game could start")); + return Err(LaunchError::new( + LaunchErrorKind::Process, + "Background Steam crashed before the game could start — this often indicates a corrupted Windows Steam install — try Settings → Repair / Reinstall Windows Steam Runtime" + )); } } } diff --git a/src/launch/mod.rs b/src/launch/mod.rs index 8f38ac9..548643f 100644 --- a/src/launch/mod.rs +++ b/src/launch/mod.rs @@ -121,3 +121,49 @@ async fn download_steam_setup(path: &Path) -> Result<()> { std::fs::write(path, response)?; Ok(()) } + +pub async fn repair_master_steam(config: &LauncherConfig) -> Result<()> { + let steam_cfg = crate::utils::get_master_steam_config(); + tracing::info!("Starting repair for Windows Steam Runtime in {}", steam_cfg.wine_prefix.display()); + + // 1. Kill all processes + crate::steam_client::SteamClient::kill_steam_in_prefix(&steam_cfg.wine_prefix); + crate::utils::kill_all_wine_in_prefix(&steam_cfg.wine_prefix); + + // 2. Manage backups + if steam_cfg.root_dir.exists() { + let parent = steam_cfg.root_dir.parent().context("master steam root has no parent")?; + + // Find existing backups + if let Ok(entries) = std::fs::read_dir(parent) { + let mut backups: Vec<_> = entries.flatten() + .filter(|e| { + e.file_name().to_string_lossy().starts_with("master_steam_prefix.bak.") + }) + .map(|e| e.path()) + .collect(); + + // Keep at most 1 previous backup: delete all but the newest before creating a new one + backups.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()); + + // If we have any backups, delete all of them so we only have one after renaming + for old_bak in backups { + tracing::info!("Removing old backup: {}", old_bak.display()); + let _ = std::fs::remove_dir_all(old_bak); + } + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let backup_path = parent.join(format!("master_steam_prefix.bak.{}", timestamp)); + + tracing::info!("Moving {} to {}", steam_cfg.root_dir.display(), backup_path.display()); + std::fs::rename(&steam_cfg.root_dir, &backup_path) + .context("failed to move prefix to backup")?; + } + + // 3. Re-install + install_master_steam(config).await +} diff --git a/src/launch/verification_tests.rs b/src/launch/verification_tests.rs index 2361b7b..4c561a0 100644 --- a/src/launch/verification_tests.rs +++ b/src/launch/verification_tests.rs @@ -102,3 +102,121 @@ async fn test_launch_verification_success() { let _ = child.kill(); } } + +#[tokio::test] +async fn test_background_steam_grace_period_failure() { + use crate::infra::runners::wine_tkg::WineTkgRunner; + use crate::config::LauncherConfig; + use crate::models::{LibraryGame, UserAppConfig, SteamRuntimePolicy}; + use crate::steam_client::{LaunchInfo, LaunchTarget}; + use std::fs; + + let tmp = tempdir().unwrap(); + + // Mock HOME for get_master_steam_config + let old_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", tmp.path()); + + let root_dir = tmp.path().join(".config/SteamFlow/master_steam_prefix"); + let wine_prefix = root_dir.join("pfx"); + let steam_exe_path = wine_prefix.join("drive_c/Program Files (x86)/Steam/steam.exe"); + fs::create_dir_all(steam_exe_path.parent().unwrap()).unwrap(); + fs::write(&steam_exe_path, "").unwrap(); + + let lib = tmp.path().join("library"); + fs::create_dir_all(&lib).unwrap(); + + // Setup a fake runner that exits quickly + let runner_dir = tmp.path().join("fake_runner"); + fs::create_dir_all(runner_dir.join("bin")).unwrap(); + let wine_path = runner_dir.join("bin/wine64"); + + // Create a script that acts as wine64: + // It should create the signal file and then exit. + #[cfg(unix)] + { + // We want it to be alive when checked in the loop (T=1s), + // but dead when checked in grace period (T=1s + 2s = 3s). + fs::write(&wine_path, "#!/bin/sh\ntouch \"$WINEPREFIX/config.vdf\"\nsleep 2\nexit 1\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&wine_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&wine_path, perms).unwrap(); + } + + let mut config = LauncherConfig::default(); + config.steam_library_path = lib.to_string_lossy().to_string(); + config.steam_runtime_runner = runner_dir.clone(); + + let mut user_config = UserAppConfig::default(); + user_config.use_steam_runtime = true; + user_config.steam_runtime_policy = SteamRuntimePolicy::Enabled; + + let app = LibraryGame { + app_id: 123, + name: "Test".to_string(), + install_path: Some(tmp.path().to_string_lossy().to_string()), + is_installed: true, + playtime_forever_minutes: None, + active_branch: "public".to_string(), + update_available: false, + update_queued: false, + local_manifest_ids: HashMap::new(), + }; + + // We need to set up the master prefix env so get_master_steam_config find it + // But get_master_steam_config uses crate::config::config_dir() + // We can't easily mock config_dir() without changing global state or using a mock. + // However, WineTkgRunner::prepare_prefix calls get_master_steam_config(). + + // For this test, we want to verify the logic in prepare_prefix. + // We'll use a WineTkgRunner and call prepare_prefix directly. + + let mut verification = crate::infra::logging::LaunchVerification::default(); + let ctx = LaunchContext { + app, + launch_info: LaunchInfo { + app_id: 123, + id: "0".into(), + description: "Test".into(), + executable: "test.exe".into(), + arguments: "".into(), + workingdir: None, + target: LaunchTarget::WindowsProton, + }, + launcher_config: config, + user_config: Some(user_config), + proton_path: Some(runner_dir.to_string_lossy().to_string()), + target_architecture: crate::models::ExecutableArchitecture::X86_64, + dll_resolutions: Vec::new(), + fixup_result: None, + verification_ptr: &mut verification as *mut _, + }; + + // We need to make sure get_master_steam_config returns something we can control + // or just make sure the directories it expects exist. + // get_master_steam_config uses ~/.config/SteamFlow/master_steam_prefix + // This is hard to override in a unit test without env vars. + + // Let's see if we can use a mock instead of real WineTkgRunner for the pipeline, + // but the task asked to verify the new grace-check specifically. + + // Actually, I can just test the logic by calling prepare_prefix and mocking the environment. + // But prepare_prefix is quite integrated. + + // Alternative: verify that the error message is what we expect when it fails. + + let runner = WineTkgRunner; + let result = runner.prepare_prefix(&ctx).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind, LaunchErrorKind::Process); + assert!(err.message.contains("indicates a corrupted Windows Steam install")); + + assert_eq!(verification.steam_runtime_milestone, "steam_process_exited_early"); + + if let Some(h) = old_home { + std::env::set_var("HOME", h); + } +} diff --git a/src/ui.rs b/src/ui.rs index fba5514..f98743b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -124,6 +124,7 @@ pub enum AsyncOp { SettingsSaved(bool), WineControlPanelLaunched, ScanCompleted(u32, HashMap), + MasterSteamRepaired, MetadataFetched(u32, crate::steam_client::AppMetadata), UserConfigsFetched(crate::models::UserConfigStore), ProtonListFetched(Vec, Vec), @@ -184,6 +185,7 @@ pub struct SteamLauncher { last_scanned_runner: PathBuf, last_scanned_appid: Option, available_gpus: Vec, + show_repair_confirmation: bool, operation_tx: Sender, operation_rx: Receiver, } @@ -269,6 +271,7 @@ impl SteamLauncher { last_scanned_runner: resolved, last_scanned_appid: None, available_gpus: crate::utils::list_available_gpus(), + show_repair_confirmation: false, operation_tx, operation_rx, } @@ -591,6 +594,9 @@ impl SteamLauncher { "Failed to save settings".to_string() }; } + AsyncOp::MasterSteamRepaired => { + self.status = "Windows Steam Runtime repaired successfully".to_string(); + } AsyncOp::WineControlPanelLaunched => { self.status = "Wine Control Panel launched".to_string(); } @@ -2681,13 +2687,43 @@ impl eframe::App for SteamLauncher { } ui.add_space(4.0); - if ui.button("Install / Manage Windows Steam Runtime").clicked() { - let config = self.launcher_config.clone(); - let tx = self.operation_tx.clone(); - self.runtime.spawn(async move { - if let Err(e) = crate::launch::install_master_steam(&config).await { - let _ = tx.send(AsyncOp::Error(format!("Runtime error: {e}"))); - } + ui.horizontal(|ui| { + if ui.button("Install / Manage Windows Steam Runtime").clicked() { + let config = self.launcher_config.clone(); + let tx = self.operation_tx.clone(); + self.runtime.spawn(async move { + if let Err(e) = crate::launch::install_master_steam(&config).await { + let _ = tx.send(AsyncOp::Error(format!("Runtime error: {e}"))); + } + }); + } + + if ui.button("Repair Windows Steam Runtime").clicked() { + self.show_repair_confirmation = !self.show_repair_confirmation; + } + }); + + if self.show_repair_confirmation { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.colored_label(egui::Color32::YELLOW, "⚠ Repair will move your current Steam prefix to a backup."); + ui.label("You will need to log in to Windows Steam again."); + ui.horizontal(|ui| { + if ui.button("Confirm Repair").clicked() { + self.show_repair_confirmation = false; + let config = self.launcher_config.clone(); + let tx = self.operation_tx.clone(); + self.runtime.spawn(async move { + if let Err(e) = crate::launch::repair_master_steam(&config).await { + let _ = tx.send(AsyncOp::Error(format!("Repair failed: {e}"))); + } else { + let _ = tx.send(AsyncOp::MasterSteamRepaired); + } + }); + } + if ui.button("Cancel").clicked() { + self.show_repair_confirmation = false; + } + }); }); } From 3aee8e4fcdd92e4248011f07e06d8a0627bcb8c6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:35:21 +0000 Subject: [PATCH 2/3] feat: implement backup/restore/repair lifecycle for Windows Steam Runtime - Fixed `install_master_steam` to create prefix directories before Wine initialization. - Added `backup_master_steam` and `restore_master_steam` logic with prefix rotation (keeping 1 backup). - Implemented a post-ready grace check in background Steam launcher to detect early crashes and suggest repair. - Revamped Settings UI to show context-aware actions: Install, Manage, Repair, Backup, and Restore. - Added confirmation prompts for destructive operations. - Added unit test for background Steam grace period failure. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/launch/mod.rs | 114 +++++++++++++++++++++++++++++++++++----------- src/ui.rs | 90 ++++++++++++++++++++++++++++++++---- 2 files changed, 168 insertions(+), 36 deletions(-) diff --git a/src/launch/mod.rs b/src/launch/mod.rs index 548643f..2cdd674 100644 --- a/src/launch/mod.rs +++ b/src/launch/mod.rs @@ -17,6 +17,7 @@ pub async fn install_master_steam(config: &LauncherConfig) -> Result<()> { let steam_cfg = crate::utils::get_master_steam_config(); let runtimes_dir = base_dir.join("runtimes"); std::fs::create_dir_all(&runtimes_dir)?; + std::fs::create_dir_all(&steam_cfg.wine_prefix)?; let setup_exe = runtimes_dir.join("SteamSetup.exe"); if !setup_exe.exists() { @@ -122,48 +123,109 @@ async fn download_steam_setup(path: &Path) -> Result<()> { Ok(()) } -pub async fn repair_master_steam(config: &LauncherConfig) -> Result<()> { +pub async fn backup_master_steam() -> Result<()> { let steam_cfg = crate::utils::get_master_steam_config(); - tracing::info!("Starting repair for Windows Steam Runtime in {}", steam_cfg.wine_prefix.display()); + if !steam_cfg.root_dir.exists() { + return Err(anyhow!("Master Steam prefix does not exist")); + } + + tracing::info!("Backing up Windows Steam Runtime in {}", steam_cfg.wine_prefix.display()); // 1. Kill all processes crate::steam_client::SteamClient::kill_steam_in_prefix(&steam_cfg.wine_prefix); crate::utils::kill_all_wine_in_prefix(&steam_cfg.wine_prefix); // 2. Manage backups - if steam_cfg.root_dir.exists() { - let parent = steam_cfg.root_dir.parent().context("master steam root has no parent")?; - - // Find existing backups - if let Ok(entries) = std::fs::read_dir(parent) { - let mut backups: Vec<_> = entries.flatten() - .filter(|e| { - e.file_name().to_string_lossy().starts_with("master_steam_prefix.bak.") - }) - .map(|e| e.path()) - .collect(); - - // Keep at most 1 previous backup: delete all but the newest before creating a new one - backups.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()); - - // If we have any backups, delete all of them so we only have one after renaming - for old_bak in backups { - tracing::info!("Removing old backup: {}", old_bak.display()); - let _ = std::fs::remove_dir_all(old_bak); + let parent = steam_cfg.root_dir.parent().context("master steam root has no parent")?; + + // Find existing backups + if let Ok(entries) = std::fs::read_dir(parent) { + let mut backups: Vec<_> = entries.flatten() + .filter(|e| { + e.file_name().to_string_lossy().starts_with("master_steam_prefix.bak.") + }) + .map(|e| e.path()) + .collect(); + + // Keep at most 1 previous backup: delete all but the newest before creating a new one + backups.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()); + + // If we have more than one, delete the oldest + if backups.len() >= 1 { + for i in 0..backups.len() { + tracing::info!("Removing old backup: {}", backups[i].display()); + let _ = std::fs::remove_dir_all(&backups[i]); } } + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let backup_path = parent.join(format!("master_steam_prefix.bak.{}", timestamp)); + + tracing::info!("Moving {} to {}", steam_cfg.root_dir.display(), backup_path.display()); + std::fs::rename(&steam_cfg.root_dir, &backup_path) + .context("failed to move prefix to backup")?; + + Ok(()) +} + +pub fn get_latest_backup() -> Option { + let steam_cfg = crate::utils::get_master_steam_config(); + let parent = steam_cfg.root_dir.parent()?; + + if let Ok(entries) = std::fs::read_dir(parent) { + let mut backups: Vec<_> = entries.flatten() + .filter(|e| { + e.file_name().to_string_lossy().starts_with("master_steam_prefix.bak.") + }) + .map(|e| e.path()) + .collect(); + + backups.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()); + return backups.pop(); + } + None +} +pub async fn restore_master_steam() -> Result<()> { + let latest_backup = get_latest_backup().ok_or_else(|| anyhow!("No backup found to restore"))?; + let steam_cfg = crate::utils::get_master_steam_config(); + + tracing::info!("Restoring Master Steam from {}", latest_backup.display()); + + // 1. Kill all processes in current prefix if it exists + if steam_cfg.wine_prefix.exists() { + crate::steam_client::SteamClient::kill_steam_in_prefix(&steam_cfg.wine_prefix); + crate::utils::kill_all_wine_in_prefix(&steam_cfg.wine_prefix); + } + + // 2. Move current aside if it exists + if steam_cfg.root_dir.exists() { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let backup_path = parent.join(format!("master_steam_prefix.bak.{}", timestamp)); + let old_path = steam_cfg.root_dir.with_extension(format!("old.{}", timestamp)); + std::fs::rename(&steam_cfg.root_dir, &old_path)?; + } - tracing::info!("Moving {} to {}", steam_cfg.root_dir.display(), backup_path.display()); - std::fs::rename(&steam_cfg.root_dir, &backup_path) - .context("failed to move prefix to backup")?; + // 3. Restore + std::fs::rename(latest_backup, &steam_cfg.root_dir)?; + Ok(()) +} + +pub async fn repair_master_steam(config: &LauncherConfig) -> Result<()> { + let steam_cfg = crate::utils::get_master_steam_config(); + tracing::info!("Starting repair for Windows Steam Runtime in {}", steam_cfg.wine_prefix.display()); + + // 1. Backup existing (handles killing processes and rotation) + if steam_cfg.root_dir.exists() { + backup_master_steam().await?; } - // 3. Re-install + // 2. Re-install (handles directory creation) install_master_steam(config).await } diff --git a/src/ui.rs b/src/ui.rs index f98743b..b5c9aa4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -125,6 +125,8 @@ pub enum AsyncOp { WineControlPanelLaunched, ScanCompleted(u32, HashMap), MasterSteamRepaired, + MasterSteamBackedUp, + MasterSteamRestored, MetadataFetched(u32, crate::steam_client::AppMetadata), UserConfigsFetched(crate::models::UserConfigStore), ProtonListFetched(Vec, Vec), @@ -186,6 +188,7 @@ pub struct SteamLauncher { last_scanned_appid: Option, available_gpus: Vec, show_repair_confirmation: bool, + show_restore_confirmation: bool, operation_tx: Sender, operation_rx: Receiver, } @@ -272,6 +275,7 @@ impl SteamLauncher { last_scanned_appid: None, available_gpus: crate::utils::list_available_gpus(), show_repair_confirmation: false, + show_restore_confirmation: false, operation_tx, operation_rx, } @@ -597,6 +601,12 @@ impl SteamLauncher { AsyncOp::MasterSteamRepaired => { self.status = "Windows Steam Runtime repaired successfully".to_string(); } + AsyncOp::MasterSteamBackedUp => { + self.status = "Windows Steam Runtime backed up successfully".to_string(); + } + AsyncOp::MasterSteamRestored => { + self.status = "Windows Steam Runtime restored successfully".to_string(); + } AsyncOp::WineControlPanelLaunched => { self.status = "Wine Control Panel launched".to_string(); } @@ -2687,22 +2697,82 @@ impl eframe::App for SteamLauncher { } ui.add_space(4.0); + let steam_cfg = crate::utils::get_master_steam_config(); + let prefix_exists = steam_cfg.root_dir.exists(); + let latest_backup = crate::launch::get_latest_backup(); + ui.horizontal(|ui| { - if ui.button("Install / Manage Windows Steam Runtime").clicked() { - let config = self.launcher_config.clone(); - let tx = self.operation_tx.clone(); - self.runtime.spawn(async move { - if let Err(e) = crate::launch::install_master_steam(&config).await { - let _ = tx.send(AsyncOp::Error(format!("Runtime error: {e}"))); - } - }); + if !prefix_exists { + if ui.button("Install Windows Steam Runtime").clicked() { + let config = self.launcher_config.clone(); + let tx = self.operation_tx.clone(); + self.runtime.spawn(async move { + if let Err(e) = crate::launch::install_master_steam(&config).await { + let _ = tx.send(AsyncOp::Error(format!("Runtime error: {e}"))); + } + }); + } + } else { + if ui.button("Manage Windows Steam Runtime").clicked() { + let config = self.launcher_config.clone(); + let tx = self.operation_tx.clone(); + self.runtime.spawn(async move { + if let Err(e) = crate::launch::install_master_steam(&config).await { + let _ = tx.send(AsyncOp::Error(format!("Runtime error: {e}"))); + } + }); + } + + if ui.button("Repair / Reinstall").clicked() { + self.show_repair_confirmation = !self.show_repair_confirmation; + } + + if ui.button("Backup Runtime").clicked() { + let tx = self.operation_tx.clone(); + self.runtime.spawn(async move { + if let Err(e) = crate::launch::backup_master_steam().await { + let _ = tx.send(AsyncOp::Error(format!("Backup failed: {e}"))); + } else { + let _ = tx.send(AsyncOp::MasterSteamBackedUp); + } + }); + } } - if ui.button("Repair Windows Steam Runtime").clicked() { - self.show_repair_confirmation = !self.show_repair_confirmation; + if let Some(backup_path) = latest_backup { + if ui.button("Restore from Backup").clicked() { + self.show_restore_confirmation = !self.show_restore_confirmation; + } + ui.label(format!("Latest: {}", backup_path.file_name().unwrap_or_default().to_string_lossy())) + .on_hover_text(backup_path.to_string_lossy()); } }); + if self.show_restore_confirmation { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.colored_label(egui::Color32::YELLOW, "⚠ Restoring will replace your current Steam prefix with the backup."); + if prefix_exists { + ui.label("The current prefix will be moved to a temporary .old directory."); + } + ui.horizontal(|ui| { + if ui.button("Confirm Restore").clicked() { + self.show_restore_confirmation = false; + let tx = self.operation_tx.clone(); + self.runtime.spawn(async move { + if let Err(e) = crate::launch::restore_master_steam().await { + let _ = tx.send(AsyncOp::Error(format!("Restore failed: {e}"))); + } else { + let _ = tx.send(AsyncOp::MasterSteamRestored); + } + }); + } + if ui.button("Cancel").clicked() { + self.show_restore_confirmation = false; + } + }); + }); + } + if self.show_repair_confirmation { egui::Frame::group(ui.style()).show(ui, |ui| { ui.colored_label(egui::Color32::YELLOW, "⚠ Repair will move your current Steam prefix to a backup."); From 9596641c302dc132d96602dc4f3f3ca9570c212e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:31:56 +0000 Subject: [PATCH 3/3] feat: implement full Steam Runtime lifecycle with bare Wine execution - Added `build_bare_wine_command` to bypass Proton bootstrap for Steam client/installer. - Implemented `backup_master_steam` and `restore_master_steam` with rotation logic. - Revamped Settings UI to dynamically show context-aware actions: Install, Manage, Repair, Backup, and Restore. - Added 2s grace check to catch early background Steam crashes. - Ensured prefix directory existence before Wine initialization. - Added automated test for crash detection logic. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/infra/runners/wine_tkg.rs | 13 ++----------- src/launch/mod.rs | 5 ++--- src/utils.rs | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index 87538b1..800945b 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -240,17 +240,8 @@ impl Runner for WineTkgRunner { tracing::info!("Using runner for background Steam: {}", steam_runner.display()); - let mut steam_cmd = match crate::utils::classify_runner(&steam_runner) { - crate::utils::RunnerKind::PlainWine { .. } => crate::utils::build_runner_command(&steam_runner) - .map_err(|e| LaunchError::new(LaunchErrorKind::Runner, format!("Invalid Steam Runtime runner path: {}", steam_runner.display())).with_source(e))?, - crate::utils::RunnerKind::Proton { bundled_wine64: Some(wine64), .. } => Command::new(wine64), - crate::utils::RunnerKind::Proton { bundled_wine64: None, .. } => { - return Err(LaunchError::new(LaunchErrorKind::Runner, format!("Steam Runtime Runner {} is a Proton tree but no bundled wine64 was found", steam_runner.display()))); - } - crate::utils::RunnerKind::Unknown => { - return Err(LaunchError::new(LaunchErrorKind::Runner, format!("Unknown Steam Runtime Runner: {}", steam_runner.display()))); - } - }; + let mut steam_cmd = crate::utils::build_bare_wine_command(&steam_runner) + .map_err(|e| LaunchError::new(LaunchErrorKind::Runner, format!("Invalid Steam Runtime runner path: {}", steam_runner.display())).with_source(e))?; steam_cmd.current_dir(&prefix_steam_dir); steam_cmd .arg("C:\\Program Files (x86)\\Steam\\steam.exe") diff --git a/src/launch/mod.rs b/src/launch/mod.rs index 2cdd674..e52420d 100644 --- a/src/launch/mod.rs +++ b/src/launch/mod.rs @@ -10,7 +10,6 @@ mod verification_tests; use std::path::{Path, PathBuf}; use anyhow::{Result, Context, anyhow}; use crate::config::{config_dir, LauncherConfig}; -use crate::utils::build_runner_command; pub async fn install_master_steam(config: &LauncherConfig) -> Result<()> { let base_dir = config_dir()?; @@ -31,7 +30,7 @@ pub async fn install_master_steam(config: &LauncherConfig) -> Result<()> { let library_root = PathBuf::from(&config.steam_library_path); let resolved_runner = crate::utils::resolve_runner(&runner_name, &library_root); - let mut cmd = build_runner_command(&resolved_runner)?; + let mut cmd = crate::utils::build_bare_wine_command(&resolved_runner)?; tracing::info!("Unified Master Steam resolution:"); tracing::info!(" - Root Dir: {}", steam_cfg.root_dir.display()); @@ -85,7 +84,7 @@ pub async fn install_master_steam(config: &LauncherConfig) -> Result<()> { pub fn launch_wine_control_panel(config: &LauncherConfig) -> Result<()> { let library_root = PathBuf::from(&config.steam_library_path); let resolved_runner = crate::utils::resolve_runner(&config.proton_version, &library_root); - let mut cmd = build_runner_command(&resolved_runner)?; + let mut cmd = crate::utils::build_bare_wine_command(&resolved_runner)?; let steam_cfg = crate::utils::get_master_steam_config(); std::fs::create_dir_all(&steam_cfg.wine_prefix) diff --git a/src/utils.rs b/src/utils.rs index 5c0043c..387a721 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -180,6 +180,27 @@ pub fn build_runner_command(runner_path: &Path) -> Result { bail!("Failed to resolve a valid runner binary from {}", runner_path.display()) } +/// Builds a command that points directly to a bare Wine binary, bypassing any +/// Proton scripts or bootstrap logic. This is critical for background Steam +/// management where we want minimal interference. +pub fn build_bare_wine_command(runner_path: &Path) -> Result { + match classify_runner(runner_path) { + RunnerKind::PlainWine { wine64, .. } => Ok(Command::new(wine64)), + RunnerKind::Proton { bundled_wine64: Some(wine64), .. } => Ok(Command::new(wine64)), + RunnerKind::Proton { bundled_wine64: None, .. } => { + bail!("Proton tree {} has no bundled wine64", runner_path.display()) + } + RunnerKind::Unknown => { + // Fallback for ad-hoc paths + if runner_path.is_file() { + Ok(Command::new(runner_path)) + } else { + bail!("Could not resolve bare Wine binary from {}", runner_path.display()) + } + } + } +} + pub fn resolve_runner(name: &str, library_root: &Path) -> PathBuf { let name_path = Path::new(name); if name_path.is_absolute() || name_path.exists() {