Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 41 additions & 25 deletions src/infra/runners/wine_tkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -304,6 +295,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;

Expand All @@ -323,45 +315,66 @@ 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
let log_count = std::fs::read_dir(&steam_logs_dir)
.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 {
Expand All @@ -370,7 +383,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"
));
}
}
}
Expand Down
113 changes: 110 additions & 3 deletions src/launch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ 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()?;
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() {
Expand All @@ -30,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());
Expand Down Expand Up @@ -84,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)
Expand Down Expand Up @@ -121,3 +121,110 @@ async fn download_steam_setup(path: &Path) -> Result<()> {
std::fs::write(path, response)?;
Ok(())
}

pub async fn backup_master_steam() -> Result<()> {
let steam_cfg = crate::utils::get_master_steam_config();
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
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<PathBuf> {
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 old_path = steam_cfg.root_dir.with_extension(format!("old.{}", timestamp));
std::fs::rename(&steam_cfg.root_dir, &old_path)?;
}

// 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?;
}

// 2. Re-install (handles directory creation)
install_master_steam(config).await
}
118 changes: 118 additions & 0 deletions src/launch/verification_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading