From f380d561f8fab6edcd41ecc25595780cc19198ab Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:41:45 +0000 Subject: [PATCH 01/10] Refine 'Ghost Steam' strategy: cache installer and discover Proton runner - Create src/utils.rs with download_windows_steam_client and get_proton_runner. - Implement robust Proton version extraction and discovery. - Expose utils module in src/lib.rs. - Add unit tests for version extraction. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/lib.rs | 1 + src/utils.rs | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/utils.rs diff --git a/src/lib.rs b/src/lib.rs index e127044..ccb8d27 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,4 @@ pub mod library; pub mod models; pub mod steam_client; pub mod ui; +pub mod utils; diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..bd3db2f --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,115 @@ +use std::path::PathBuf; +use crate::config::config_dir; +use anyhow::Result; + +/// Ensures the Windows Steam installer exists in the runtimes directory. +/// If missing, it downloads it from Steam's official CDN. +pub async fn download_windows_steam_client() -> Result { + let runtimes_dir = config_dir()?.join("runtimes"); + tokio::fs::create_dir_all(&runtimes_dir).await?; + let target_path = runtimes_dir.join("SteamSetup.exe"); + + if target_path.exists() { + if let Ok(metadata) = tokio::fs::metadata(&target_path).await { + if metadata.len() > 0 { + tracing::info!("Using cached SteamSetup.exe at {}", target_path.display()); + return Ok(target_path); + } + } + } + + tracing::info!("Downloading SteamSetup.exe..."); + let url = "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe"; + let response = reqwest::get(url).await?; + let bytes = response.bytes().await?; + + if bytes.is_empty() { + return Err(anyhow::anyhow!("Downloaded SteamSetup.exe is empty")); + } + + tokio::fs::write(&target_path, &bytes).await?; + tracing::info!("Saved SteamSetup.exe to {}", target_path.display()); + + Ok(target_path) +} + +/// Searches for the highest version of Proton installed on the system. +/// Checks official Steam paths and SteamFlow's custom compatibility tools directory. +pub fn get_proton_runner() -> Option { + let home = std::env::var("HOME").ok()?; + let mut search_paths = vec![ + PathBuf::from(&home).join(".steam/steam/steamapps/common"), + PathBuf::from(&home).join(".local/share/Steam/steamapps/common"), + ]; + + if let Ok(cfg_dir) = config_dir() { + search_paths.push(cfg_dir.join("compatibilitytools.d")); + } + + let mut candidates = Vec::new(); + + for path in search_paths { + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + let name = entry.file_name().to_string_lossy().to_string(); + // Look for directories that likely contain Proton + if name.contains("Proton") { + let proton_exe = entry.path().join("proton"); + if proton_exe.exists() { + candidates.push((name, proton_exe)); + } + } + } + } + } + } + } + + // Sort by version number (descending) + candidates.sort_by(|a, b| { + let v1 = extract_version(&a.0); + let v2 = extract_version(&b.0); + v2.partial_cmp(&v1).unwrap_or(std::cmp::Ordering::Equal) + }); + + if let Some((name, path)) = candidates.first() { + tracing::info!("Selected Proton runner: {} at {}", name, path.display()); + return Some(path.clone()); + } + + None +} + +fn extract_version(name: &str) -> f32 { + let mut v_str = String::new(); + let mut started = false; + let mut dot_count = 0; + for c in name.chars() { + if c.is_ascii_digit() { + v_str.push(c); + started = true; + } else if (c == '.' || c == '-') && started && dot_count == 0 { + v_str.push('.'); + dot_count += 1; + } else if started { + break; + } + } + v_str.parse::().unwrap_or(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_version() { + assert_eq!(extract_version("Proton 9.0"), 9.0); + assert_eq!(extract_version("Proton 8.0-5"), 8.0); + assert_eq!(extract_version("GE-Proton9-1"), 9.1); + assert_eq!(extract_version("Proton Experimental"), 0.0); + assert_eq!(extract_version("Proton-8.4"), 8.4); + } +} From c5178073f112e91f23b5d1182eb71741796880de Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:54:23 +0000 Subject: [PATCH 02/10] Implement install_ghost_steam_in_prefix and enable tokio process feature - Added src/launch.rs with install_ghost_steam_in_prefix function. - Enabled 'process' feature for tokio in Cargo.toml. - Exposed launch module in src/lib.rs. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- Cargo.lock | 1 + Cargo.toml | 2 +- src/launch.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/launch.rs diff --git a/Cargo.lock b/Cargo.lock index 130d349..930c393 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4602,6 +4602,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index bd08b31..06deb25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ serde_json = "1" steam-vdf-parser = "0.1.1" # Needed for raw binary PICS VDF parsing (not supported by keyvalues-serde or steam-vent yet) steam-vent = "0.4.2" steam-vent-proto = "=0.5.2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "time"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "time", "process"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } walkdir = "2" diff --git a/src/launch.rs b/src/launch.rs new file mode 100644 index 0000000..f8cc1ee --- /dev/null +++ b/src/launch.rs @@ -0,0 +1,63 @@ +use std::path::PathBuf; +use crate::config::config_dir; +use crate::utils::download_windows_steam_client; +use anyhow::{anyhow, Result, Context}; +use tokio::process::Command; + +/// Installs the "Ghost Steam" client into a specific Proton prefix for a game. +/// This avoids dependency conflicts by giving each game its own minimal Steam installation. +pub async fn install_ghost_steam_in_prefix(game_id: u32, proton_path: PathBuf) -> Result { + let base_dir = config_dir()?; + let compat_data_path = base_dir.join("steamapps/compatdata").join(game_id.to_string()); + let prefix_path = compat_data_path.join("pfx"); + + // Expected path to steam.exe inside the Proton prefix + let steam_exe_path = prefix_path + .join("drive_c") + .join("Program Files (x86)") + .join("Steam") + .join("steam.exe"); + + if steam_exe_path.exists() { + tracing::info!(appid = game_id, "Ghost Steam already installed at {}", steam_exe_path.display()); + return Ok(steam_exe_path); + } + + tracing::info!(appid = game_id, "Installing Ghost Steam into prefix..."); + + // 1. Ensure the installer is cached + let installer_path = download_windows_steam_client().await + .context("Failed to ensure Steam installer is cached")?; + + // 2. Ensure compatdata directory exists + tokio::fs::create_dir_all(&compat_data_path).await + .context("Failed to create compatdata directory")?; + + // 3. Run the installer via Proton + // We need to set STEAM_COMPAT_DATA_PATH so Proton knows where the prefix is. + // We also set STEAM_COMPAT_CLIENT_INSTALL_PATH to our config dir. + let mut cmd = Command::new(&proton_path); + cmd.arg("run") + .arg(&installer_path) + .arg("/S") // Silent install flag + .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", &base_dir); + + let status = cmd.status().await + .context(format!("Failed to run Proton installer for app {}", game_id))?; + + if !status.success() { + return Err(anyhow!("Proton installer exited with non-zero status for app {}", game_id)); + } + + // 4. Verify installation + if !steam_exe_path.exists() { + return Err(anyhow!( + "Steam installation seemed to succeed, but steam.exe was not found at {}", + steam_exe_path.display() + )); + } + + tracing::info!(appid = game_id, "Ghost Steam successfully installed at {}", steam_exe_path.display()); + Ok(steam_exe_path) +} diff --git a/src/lib.rs b/src/lib.rs index ccb8d27..847d498 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,3 +7,4 @@ pub mod models; pub mod steam_client; pub mod ui; pub mod utils; +pub mod launch; From 8bb7e17bd03b4bd733b993ef71dc2373aa86ddd9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:08:05 +0000 Subject: [PATCH 03/10] Finalize Ghost Steam integration and prefix management - Update spawn_game_process to handle Ghost Steam lifecycle (interactive first run, silent standard run). - Add 5-second wait for Steam Pipe initialization. - Inject 'Steam Runtime (Windows)' virtual app (ID 0) into library. - Implement prefix selection modal for ID 0 management. - Update UI to use async game process spawning. - Set SteamAppId environment variable. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/library.rs | 13 ++++++ src/steam_client.rs | 106 ++++++++++++++++++++++++++++++++++++-------- src/ui.rs | 89 +++++++++++++++++++++++++++++++++++-- 3 files changed, 186 insertions(+), 22 deletions(-) diff --git a/src/library.rs b/src/library.rs index 4758184..30ea719 100644 --- a/src/library.rs +++ b/src/library.rs @@ -286,6 +286,19 @@ pub fn build_game_library( ) -> GameLibrary { let mut games = Vec::new(); + // Inject Virtual App ID 0 for Steam Runtime management + games.push(LibraryGame { + app_id: 0, + name: "Steam Runtime (Windows)".to_string(), + playtime_forever_minutes: None, + is_installed: true, + install_path: Some("virtual".to_string()), + local_manifest_ids: HashMap::new(), + update_available: false, + update_queued: false, + active_branch: "public".to_string(), + }); + for owned_game in owned { let info = installed_info.get(&owned_game.app_id); let install_path = info.map(|i| i.install_path.to_string_lossy().to_string()); diff --git a/src/steam_client.rs b/src/steam_client.rs index 84a56eb..ae303c6 100644 --- a/src/steam_client.rs +++ b/src/steam_client.rs @@ -1,10 +1,12 @@ use crate::cloud_sync::{default_cloud_root, CloudClient}; use crate::cm_list::get_cm_endpoints; use crate::config::{ - delete_session, library_cache_path, load_launcher_config, load_library_cache, load_session, - save_library_cache, save_session, + config_dir, delete_session, library_cache_path, load_launcher_config, load_library_cache, + load_session, save_library_cache, save_session, }; use crate::depot_browser::{self, DepotInfo as BrowserDepotInfo, ManifestFileEntry}; +use crate::launch::install_ghost_steam_in_prefix; +use crate::utils::get_proton_runner; use crate::models::{ AppInfoRoot, DepotPlatform, DownloadProgress, DownloadProgressState, LibraryGame, ManifestSelection, OwnedGame, SessionState, SteamGuardReq, UserProfile, @@ -15,7 +17,6 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use std::path::{Path, PathBuf}; -use std::process::Command; use std::str::FromStr; use std::time::Instant; @@ -45,6 +46,7 @@ use steam_vent::proto::steammessages_player_steamclient::{ }; use steam_vent::{ConnectionError, ConnectionTrait, ServerList}; use tokio::io::{duplex, sink, AsyncWriteExt}; +use tokio::process::Command; use tokio::sync::mpsc::Receiver; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1592,10 +1594,8 @@ impl SteamClient { } let mut child = - self.spawn_game_process(app, &launch_info, chosen_proton_path, &launcher_config, user_config)?; - child - .wait() - .context("failed waiting for game process exit")?; + self.spawn_game_process(app, &launch_info, chosen_proton_path, &launcher_config, user_config).await?; + child.wait().await.context("failed waiting for game process exit")?; if let (Some(client), Some(root)) = (cloud_client.as_ref(), local_root.as_ref()) { client.sync_up(app.app_id, root).await?; @@ -1613,7 +1613,36 @@ impl SteamClient { user_config: Option<&crate::models::UserAppConfig>, ) -> Result<()> { let launcher_config = load_launcher_config().await.unwrap_or_default(); - self.spawn_game_process(app, launch_info, proton_path, &launcher_config, user_config)?; + self.spawn_game_process(app, launch_info, proton_path, &launcher_config, user_config).await?; + Ok(()) + } + + pub async fn launch_ghost_steam_only( + &self, + app_id: u32, + proton_path: Option<&str>, + ) -> Result<()> { + let launcher_config = load_launcher_config().await.unwrap_or_default(); + let proton_name = proton_path.unwrap_or(launcher_config.proton_version.as_str()); + let library_root = PathBuf::from(&launcher_config.steam_library_path); + let resolved_proton = self.resolve_proton_path(proton_name, &library_root); + let proton_runner = get_proton_runner().unwrap_or_else(|| resolved_proton.clone()); + + let steam_exe = install_ghost_steam_in_prefix(app_id, proton_runner.clone()).await?; + let compat_data_path = config_dir()? + .join("steamapps/compatdata") + .join(app_id.to_string()); + + tracing::info!(appid = app_id, "Launching Ghost Steam for Management..."); + let mut steam_cmd = Command::new(&proton_runner); + steam_cmd + .arg("run") + .arg(&steam_exe) + .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", config_dir()?); + + let mut child = steam_cmd.spawn().context("failed to start Ghost Steam")?; + child.wait().await?; Ok(()) } @@ -2104,14 +2133,14 @@ impl SteamClient { PathBuf::from(proton_name) } - pub(crate) fn spawn_game_process( + pub(crate) async fn spawn_game_process( &self, app: &LibraryGame, launch_info: &LaunchInfo, proton_path: Option<&str>, launcher_config: &crate::config::LauncherConfig, user_config: Option<&crate::models::UserAppConfig>, - ) -> Result { + ) -> Result { let install_dir = PathBuf::from( app.install_path .clone() @@ -2165,7 +2194,7 @@ impl SteamClient { cmd.spawn().context("failed to spawn native linux game") } LaunchTarget::WindowsProton => { - let proton = if let Some(forced) = launcher_config + let proton_name = if let Some(forced) = launcher_config .game_configs .get(&app.app_id) .and_then(|c| c.forced_proton_version.as_ref()) @@ -2178,22 +2207,61 @@ impl SteamClient { }; let library_root = PathBuf::from(&launcher_config.steam_library_path); - let resolved_proton = self.resolve_proton_path(proton, &library_root); + let resolved_proton = self.resolve_proton_path(proton_name, &library_root); + + // --- Ghost Steam Flow --- + let proton_runner = get_proton_runner().unwrap_or_else(|| resolved_proton.clone()); - let compat_data_path = library_root - .join("steamapps") - .join("compatdata") + let compat_data_path = config_dir()? + .join("steamapps/compatdata") .join(app.app_id.to_string()); - std::fs::create_dir_all(&compat_data_path) - .with_context(|| format!("failed creating {}", compat_data_path.display()))?; + let steam_exe_path = compat_data_path + .join("pfx/drive_c/Program Files (x86)/Steam/steam.exe"); + + let first_run = !steam_exe_path.exists(); + + let steam_exe = install_ghost_steam_in_prefix(app.app_id, proton_runner.clone()).await?; + + if first_run { + tracing::info!(appid = app.app_id, "Ghost Steam First Run: Interactive Login required."); + // In a real app, we'd show a notification here. + let mut steam_cmd = Command::new(&proton_runner); + steam_cmd + .arg("run") + .arg(&steam_exe) + .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", config_dir()?); + + let mut steam_child = steam_cmd.spawn().context("failed to start Ghost Steam for interactive login")?; + let _ = steam_child.wait().await; + tracing::info!(appid = app.app_id, "Interactive Ghost Steam exited. Proceeding with game launch."); + } + + // 1. Launch Steam Silently to provide Steam Pipe + tracing::info!(appid = app.app_id, "Starting Ghost Steam for Pipe..."); + let mut steam_cmd = Command::new(&proton_runner); + steam_cmd + .arg("run") + .arg(&steam_exe) + .arg("-silent") + .arg("-no-browser") + .arg("-noverifyfiles") + .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", config_dir()?); + + let _steam_child = steam_cmd.spawn().context("failed to start Ghost Steam")?; + + // Wait for Steam Pipe to initialize + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + // 2. Launch actual game binary let mut cmd = Command::new(&resolved_proton); cmd.arg("run").arg(&executable).args(&args); cmd.current_dir(&install_dir); cmd.env("SteamAppId", app.app_id.to_string()); cmd.env("STEAM_COMPAT_DATA_PATH", &compat_data_path); - cmd.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", &library_root); + cmd.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", config_dir()?); if let Some(config) = user_config { for (key, val) in &config.env_variables { @@ -2201,7 +2269,7 @@ impl SteamClient { } } - tracing::info!("Launching game (Proton): {:?} with args {:?}", resolved_proton, cmd.get_args()); + tracing::info!("Launching game (Proton): {:?} with args {:?}", resolved_proton, cmd.as_std().get_args()); cmd.spawn().context("failed to spawn proton game") } } diff --git a/src/ui.rs b/src/ui.rs index bbea5ed..6b8be18 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -76,6 +76,11 @@ struct LaunchSelectorState { always_use: bool, } +#[derive(Debug, Clone)] +struct PrefixManagerModalState { + selected_app_id: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GameTab { Options, @@ -151,6 +156,7 @@ pub struct SteamLauncher { depot_browser: Option, platform_selection: Option, launch_selector: Option, + prefix_manager_modal: Option, current_tab: GameTab, main_tab: MainTab, account_data: Option, @@ -215,6 +221,7 @@ impl SteamLauncher { depot_browser: None, platform_selection: None, launch_selector: None, + prefix_manager_modal: None, current_tab: GameTab::Options, main_tab: MainTab::Library, account_data: None, @@ -767,6 +774,13 @@ impl SteamLauncher { } fn handle_play_click(&mut self, game: &LibraryGame) { + if game.app_id == 0 { + self.prefix_manager_modal = Some(PrefixManagerModalState { + selected_app_id: None, + }); + return; + } + let proton_path = if self.proton_path_for_windows.trim().is_empty() { None } else { @@ -831,8 +845,8 @@ impl SteamLauncher { local_root = Some(root); } - let mut child: std::process::Child = - match client.spawn_game_process(&game, &launch_info, chosen_proton_path, &launcher_config, user_config.as_ref()) { + let mut child: tokio::process::Child = + match client.spawn_game_process(&game, &launch_info, chosen_proton_path, &launcher_config, user_config.as_ref()).await { Ok(child) => child, Err(e) => { let _ = tx.send(format!("Launch failed for {}: {e}", game.name)); @@ -840,7 +854,7 @@ impl SteamLauncher { } }; - let _ = child.wait(); + let _ = child.wait().await; if let (Some(c), Some(root)) = (cloud_client.as_ref(), local_root.as_ref()) { let _ = c.sync_up(game.app_id, root).await; @@ -906,6 +920,74 @@ impl SteamLauncher { }); } + fn draw_prefix_manager_modal(&mut self, ctx: &egui::Context) { + let mut launch_appid = None; + let mut close = false; + + if let Some(state) = &mut self.prefix_manager_modal { + egui::Window::new("Manage Steam Runtime") + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label("Which game prefix do you want to manage?"); + ui.add_space(8.0); + + let windows_games: Vec<&LibraryGame> = self.library + .iter() + .filter(|g| g.app_id != 0 && g.is_installed) + .collect(); + + if windows_games.is_empty() { + ui.label("No installed Windows games found."); + } else { + egui::ComboBox::from_id_salt("prefix_selector") + .selected_text( + state.selected_app_id + .and_then(|id| windows_games.iter().find(|g| g.app_id == id)) + .map(|g| g.name.as_str()) + .unwrap_or("Select a game...") + ) + .show_ui(ui, |ui| { + for game in windows_games { + ui.selectable_value(&mut state.selected_app_id, Some(game.app_id), &game.name); + } + }); + } + + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.add_enabled(state.selected_app_id.is_some(), egui::Button::new("Launch Steam")).clicked() { + launch_appid = state.selected_app_id; + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + } + + if let Some(appid) = launch_appid { + let client = self.client.clone(); + let (tx, rx) = mpsc::channel(); + self.play_result_rx = Some(rx); + let proton_path = if self.proton_path_for_windows.trim().is_empty() { + None + } else { + Some(self.proton_path_for_windows.trim().to_string()) + }; + + self.runtime.spawn(async move { + match client.launch_ghost_steam_only(appid, proton_path.as_deref()).await { + Ok(_) => { let _ = tx.send(format!("Finished managing prefix for app {appid}")); } + Err(e) => { let _ = tx.send(format!("Failed to launch Steam: {e}")); } + } + }); + self.prefix_manager_modal = None; + } else if close { + self.prefix_manager_modal = None; + } + } + fn draw_launch_selector_modal(&mut self, ctx: &egui::Context) { let mut selection = None; let mut close = false; @@ -2207,6 +2289,7 @@ impl eframe::App for SteamLauncher { self.draw_depot_browser_window(ctx); self.draw_platform_selection_modal(ctx); self.draw_launch_selector_modal(ctx); + self.draw_prefix_manager_modal(ctx); ctx.request_repaint(); } } From 7423c20453b604c0320be3ac6c48e257f040eacf Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:14:56 +0000 Subject: [PATCH 04/10] Implement Credential Sync for Ghost Steam prefixes - Added CredentialManager in src/utils.rs (harvest_credentials, inject_credentials). - Integrated credential syncing into game launch flow and prefix management. - Enabled silent login for Ghost Steam when credentials are cached. - Automated credential harvesting after game exit and management sessions. - Added secrets directory to configuration management. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/config.rs | 6 +++ src/steam_client.rs | 30 +++++++++----- src/ui.rs | 7 ++++ src/utils.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 127 insertions(+), 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index 78868a5..424abc8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -86,6 +86,8 @@ pub async fn ensure_config_dirs() -> Result<()> { fs::create_dir_all(&config).await?; let images = opensteam_image_cache_dir()?; fs::create_dir_all(&images).await?; + let secrets = secrets_dir()?; + fs::create_dir_all(&secrets).await?; Ok(()) } @@ -93,6 +95,10 @@ pub fn opensteam_image_cache_dir() -> Result { Ok(PathBuf::from("./config/SteamFlow/images")) } +pub fn secrets_dir() -> Result { + Ok(PathBuf::from("./config/SteamFlow/secrets")) +} + pub async fn load_session() -> Result { let session_path = config_dir()?.join("session.json"); if !session_path.exists() { diff --git a/src/steam_client.rs b/src/steam_client.rs index ae303c6..fdf3660 100644 --- a/src/steam_client.rs +++ b/src/steam_client.rs @@ -6,7 +6,7 @@ use crate::config::{ }; use crate::depot_browser::{self, DepotInfo as BrowserDepotInfo, ManifestFileEntry}; use crate::launch::install_ghost_steam_in_prefix; -use crate::utils::get_proton_runner; +use crate::utils::{get_proton_runner, harvest_credentials, inject_credentials}; use crate::models::{ AppInfoRoot, DepotPlatform, DownloadProgress, DownloadProgressState, LibraryGame, ManifestSelection, OwnedGame, SessionState, SteamGuardReq, UserProfile, @@ -1628,11 +1628,13 @@ impl SteamClient { let resolved_proton = self.resolve_proton_path(proton_name, &library_root); let proton_runner = get_proton_runner().unwrap_or_else(|| resolved_proton.clone()); - let steam_exe = install_ghost_steam_in_prefix(app_id, proton_runner.clone()).await?; let compat_data_path = config_dir()? .join("steamapps/compatdata") .join(app_id.to_string()); + let _ = inject_credentials(&compat_data_path).await; + let steam_exe = install_ghost_steam_in_prefix(app_id, proton_runner.clone()).await?; + tracing::info!(appid = app_id, "Launching Ghost Steam for Management..."); let mut steam_cmd = Command::new(&proton_runner); steam_cmd @@ -1643,6 +1645,8 @@ impl SteamClient { let mut child = steam_cmd.spawn().context("failed to start Ghost Steam")?; child.wait().await?; + + let _ = harvest_credentials(&compat_data_path).await; Ok(()) } @@ -2223,9 +2227,11 @@ impl SteamClient { let steam_exe = install_ghost_steam_in_prefix(app.app_id, proton_runner.clone()).await?; - if first_run { + // Step 2: inject_credentials(prefix) + let has_credentials = inject_credentials(&compat_data_path).await.unwrap_or(false); + + if first_run && !has_credentials { tracing::info!(appid = app.app_id, "Ghost Steam First Run: Interactive Login required."); - // In a real app, we'd show a notification here. let mut steam_cmd = Command::new(&proton_runner); steam_cmd .arg("run") @@ -2236,17 +2242,21 @@ impl SteamClient { let mut steam_child = steam_cmd.spawn().context("failed to start Ghost Steam for interactive login")?; let _ = steam_child.wait().await; tracing::info!(appid = app.app_id, "Interactive Ghost Steam exited. Proceeding with game launch."); + + // Harvest after first manual login + let _ = harvest_credentials(&compat_data_path).await; } - // 1. Launch Steam Silently to provide Steam Pipe + // Step 3: Launch Steam (-silent if credentials existed, Normal UI if not) tracing::info!(appid = app.app_id, "Starting Ghost Steam for Pipe..."); let mut steam_cmd = Command::new(&proton_runner); + steam_cmd.arg("run").arg(&steam_exe); + + if has_credentials || !first_run { + steam_cmd.arg("-silent").arg("-no-browser").arg("-noverifyfiles"); + } + steam_cmd - .arg("run") - .arg(&steam_exe) - .arg("-silent") - .arg("-no-browser") - .arg("-noverifyfiles") .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", config_dir()?); diff --git a/src/ui.rs b/src/ui.rs index 6b8be18..c8d7b30 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3,6 +3,7 @@ use crate::config::{ }; use crate::depot_browser::{DepotInfo as BrowserDepotInfo, ManifestFileEntry}; use crate::library::{build_game_library, scan_installed_app_paths}; +use crate::utils::harvest_credentials; use crate::models::{ DepotPlatform, DownloadProgress, DownloadProgressState, DownloadState, LibraryGame, SteamGuardReq, UserProfile, @@ -856,6 +857,12 @@ impl SteamLauncher { let _ = child.wait().await; + // Step 5 (After Exit): harvest_credentials(prefix) to keep the cache fresh. + let compat_data_path = crate::config::config_dir().unwrap() + .join("steamapps/compatdata") + .join(game.app_id.to_string()); + let _ = harvest_credentials(&compat_data_path).await; + if let (Some(c), Some(root)) = (cloud_client.as_ref(), local_root.as_ref()) { let _ = c.sync_up(game.app_id, root).await; tracing::info!(appid = game.app_id, "Upload Complete"); diff --git a/src/utils.rs b/src/utils.rs index bd3db2f..f3aac82 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use crate::config::config_dir; +use crate::config::{config_dir, secrets_dir}; use anyhow::Result; /// Ensures the Windows Steam installer exists in the runtimes directory. @@ -100,6 +100,99 @@ fn extract_version(name: &str) -> f32 { v_str.parse::().unwrap_or(0.0) } +/// Harvests Steam credentials from a specific Proton prefix and stores them in a central cache. +pub async fn harvest_credentials(prefix_path: &PathBuf) -> Result<()> { + let steam_root = prefix_path.join("drive_c/Program Files (x86)/Steam"); + let loginusers_vdf = steam_root.join("config/loginusers.vdf"); + + if !loginusers_vdf.exists() { + return Ok(()); + } + + let content = tokio::fs::read_to_string(&loginusers_vdf).await?; + if !content.contains("\"RememberPassword\"\t\t\"1\"") && !content.contains("\"RememberPassword\" \"1\"") { + tracing::info!("RememberPassword not set in loginusers.vdf, skipping credential harvest."); + return Ok(()); + } + + let secrets = secrets_dir()?; + tokio::fs::create_dir_all(&secrets).await?; + + // Copy config files + let config_src = steam_root.join("config"); + let config_dst = secrets.join("config"); + tokio::fs::create_dir_all(&config_dst).await?; + + let files_to_copy = ["config.vdf", "loginusers.vdf"]; + for file in files_to_copy { + let src = config_src.join(file); + if src.exists() { + tokio::fs::copy(&src, config_dst.join(file)).await?; + } + } + + // Copy ssfn* files + if let Ok(mut entries) = tokio::fs::read_dir(&steam_root).await { + while let Some(entry) = entries.next_entry().await? { + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name.starts_with("ssfn") { + tokio::fs::copy(entry.path(), secrets.join(file_name)).await?; + } + } + } + + tracing::info!("Steam credentials harvested successfully to {}", secrets.display()); + Ok(()) +} + +/// Injects cached Steam credentials into a specific Proton prefix. +/// Returns true if credentials were injected. +pub async fn inject_credentials(prefix_path: &PathBuf) -> Result { + let secrets = secrets_dir()?; + if !secrets.exists() { + return Ok(false); + } + + let steam_root = prefix_path.join("drive_c/Program Files (x86)/Steam"); + if !steam_root.exists() { + return Ok(false); + } + + let mut injected = false; + + // Inject config files + let config_src = secrets.join("config"); + let config_dst = steam_root.join("config"); + if config_src.exists() { + tokio::fs::create_dir_all(&config_dst).await?; + let files_to_copy = ["config.vdf", "loginusers.vdf"]; + for file in files_to_copy { + let src = config_src.join(file); + if src.exists() { + tokio::fs::copy(&src, config_dst.join(file)).await?; + injected = true; + } + } + } + + // Inject ssfn* files + if let Ok(mut entries) = tokio::fs::read_dir(&secrets).await { + while let Some(entry) = entries.next_entry().await? { + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name.starts_with("ssfn") { + tokio::fs::copy(entry.path(), steam_root.join(file_name)).await?; + injected = true; + } + } + } + + if injected { + tracing::info!("Steam credentials injected into {}", steam_root.display()); + } + + Ok(injected) +} + #[cfg(test)] mod tests { use super::*; From 56d561549157176cdcc093d89ad64c904f2fc819 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:49:17 +0000 Subject: [PATCH 05/10] Fix launch failure by using absolute paths for Proton/Wine - Added absolute_path helper in src/config.rs. - Updated src/launch.rs and src/steam_client.rs to ensure all paths (WINEPREFIX, STEAM_COMPAT_DATA_PATH, etc.) are absolute. - Fixed command construction for Ghost Steam and game launching. - Improved Proton runner discovery in src/utils.rs to use absolute paths. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/config.rs | 9 +++++++++ src/launch.rs | 9 +++++---- src/steam_client.rs | 32 ++++++++++++++++++-------------- src/utils.rs | 4 +++- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/config.rs b/src/config.rs index 424abc8..f2927fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -99,6 +99,15 @@ pub fn secrets_dir() -> Result { Ok(PathBuf::from("./config/SteamFlow/secrets")) } +pub fn absolute_path(path: PathBuf) -> Result { + if path.is_absolute() { + Ok(path) + } else { + let cwd = std::env::current_dir().with_context(|| "failed to get current directory")?; + Ok(cwd.join(path)) + } +} + pub async fn load_session() -> Result { let session_path = config_dir()?.join("session.json"); if !session_path.exists() { diff --git a/src/launch.rs b/src/launch.rs index f8cc1ee..605dacd 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use crate::config::config_dir; +use crate::config::{config_dir, absolute_path}; use crate::utils::download_windows_steam_client; use anyhow::{anyhow, Result, Context}; use tokio::process::Command; @@ -7,7 +7,7 @@ use tokio::process::Command; /// Installs the "Ghost Steam" client into a specific Proton prefix for a game. /// This avoids dependency conflicts by giving each game its own minimal Steam installation. pub async fn install_ghost_steam_in_prefix(game_id: u32, proton_path: PathBuf) -> Result { - let base_dir = config_dir()?; + let base_dir = absolute_path(config_dir()?)?; let compat_data_path = base_dir.join("steamapps/compatdata").join(game_id.to_string()); let prefix_path = compat_data_path.join("pfx"); @@ -26,8 +26,8 @@ pub async fn install_ghost_steam_in_prefix(game_id: u32, proton_path: PathBuf) - tracing::info!(appid = game_id, "Installing Ghost Steam into prefix..."); // 1. Ensure the installer is cached - let installer_path = download_windows_steam_client().await - .context("Failed to ensure Steam installer is cached")?; + let installer_path = absolute_path(download_windows_steam_client().await + .context("Failed to ensure Steam installer is cached")?)?; // 2. Ensure compatdata directory exists tokio::fs::create_dir_all(&compat_data_path).await @@ -40,6 +40,7 @@ pub async fn install_ghost_steam_in_prefix(game_id: u32, proton_path: PathBuf) - cmd.arg("run") .arg(&installer_path) .arg("/S") // Silent install flag + .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", &base_dir); diff --git a/src/steam_client.rs b/src/steam_client.rs index fdf3660..0d61885 100644 --- a/src/steam_client.rs +++ b/src/steam_client.rs @@ -1,8 +1,8 @@ use crate::cloud_sync::{default_cloud_root, CloudClient}; use crate::cm_list::get_cm_endpoints; use crate::config::{ - config_dir, delete_session, library_cache_path, load_launcher_config, load_library_cache, - load_session, save_library_cache, save_session, + absolute_path, config_dir, delete_session, library_cache_path, load_launcher_config, + load_library_cache, load_session, save_library_cache, save_session, }; use crate::depot_browser::{self, DepotInfo as BrowserDepotInfo, ManifestFileEntry}; use crate::launch::install_ghost_steam_in_prefix; @@ -1624,13 +1624,13 @@ impl SteamClient { ) -> Result<()> { let launcher_config = load_launcher_config().await.unwrap_or_default(); let proton_name = proton_path.unwrap_or(launcher_config.proton_version.as_str()); - let library_root = PathBuf::from(&launcher_config.steam_library_path); + let library_root = absolute_path(PathBuf::from(&launcher_config.steam_library_path))?; let resolved_proton = self.resolve_proton_path(proton_name, &library_root); let proton_runner = get_proton_runner().unwrap_or_else(|| resolved_proton.clone()); - let compat_data_path = config_dir()? + let compat_data_path = absolute_path(config_dir()? .join("steamapps/compatdata") - .join(app_id.to_string()); + .join(app_id.to_string()))?; let _ = inject_credentials(&compat_data_path).await; let steam_exe = install_ghost_steam_in_prefix(app_id, proton_runner.clone()).await?; @@ -1640,8 +1640,9 @@ impl SteamClient { steam_cmd .arg("run") .arg(&steam_exe) + .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) - .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", config_dir()?); + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); let mut child = steam_cmd.spawn().context("failed to start Ghost Steam")?; child.wait().await?; @@ -2145,11 +2146,11 @@ impl SteamClient { launcher_config: &crate::config::LauncherConfig, user_config: Option<&crate::models::UserAppConfig>, ) -> Result { - let install_dir = PathBuf::from( + let install_dir = absolute_path(PathBuf::from( app.install_path .clone() .ok_or_else(|| anyhow!("game {} is not installed", app.app_id))?, - ); + ))?; let executable = install_dir.join(&launch_info.executable); let mut args = split_args(&launch_info.arguments); @@ -2210,15 +2211,15 @@ impl SteamClient { .ok_or_else(|| anyhow!("proton path is required for Windows launch"))? }; - let library_root = PathBuf::from(&launcher_config.steam_library_path); + let library_root = absolute_path(PathBuf::from(&launcher_config.steam_library_path))?; let resolved_proton = self.resolve_proton_path(proton_name, &library_root); // --- Ghost Steam Flow --- let proton_runner = get_proton_runner().unwrap_or_else(|| resolved_proton.clone()); - let compat_data_path = config_dir()? + let compat_data_path = absolute_path(config_dir()? .join("steamapps/compatdata") - .join(app.app_id.to_string()); + .join(app.app_id.to_string()))?; let steam_exe_path = compat_data_path .join("pfx/drive_c/Program Files (x86)/Steam/steam.exe"); @@ -2236,8 +2237,9 @@ impl SteamClient { steam_cmd .arg("run") .arg(&steam_exe) + .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) - .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", config_dir()?); + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); let mut steam_child = steam_cmd.spawn().context("failed to start Ghost Steam for interactive login")?; let _ = steam_child.wait().await; @@ -2257,8 +2259,9 @@ impl SteamClient { } steam_cmd + .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) - .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", config_dir()?); + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); let _steam_child = steam_cmd.spawn().context("failed to start Ghost Steam")?; @@ -2270,8 +2273,9 @@ impl SteamClient { cmd.arg("run").arg(&executable).args(&args); cmd.current_dir(&install_dir); cmd.env("SteamAppId", app.app_id.to_string()); + cmd.env("WINEPREFIX", compat_data_path.join("pfx")); cmd.env("STEAM_COMPAT_DATA_PATH", &compat_data_path); - cmd.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", config_dir()?); + cmd.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); if let Some(config) = user_config { for (key, val) in &config.env_variables { diff --git a/src/utils.rs b/src/utils.rs index f3aac82..14a5570 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -43,7 +43,9 @@ pub fn get_proton_runner() -> Option { ]; if let Ok(cfg_dir) = config_dir() { - search_paths.push(cfg_dir.join("compatibilitytools.d")); + if let Ok(abs_cfg) = crate::config::absolute_path(cfg_dir) { + search_paths.push(abs_cfg.join("compatibilitytools.d")); + } } let mut candidates = Vec::new(); From d84423110d297bbf28ef50008643771e2f894cc8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:17:08 +0000 Subject: [PATCH 06/10] Fix path normalization for Proton/Wine commands - Improved absolute_path in src/config.rs to strip '.' components using path components. - Added unit tests for path normalization. - Ensured all Proton/Wine environment variables and arguments use clean absolute paths. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/config.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index f2927fa..2ee64a5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -100,11 +100,36 @@ pub fn secrets_dir() -> Result { } pub fn absolute_path(path: PathBuf) -> Result { - if path.is_absolute() { - Ok(path) + let absolute = if path.is_absolute() { + path } else { let cwd = std::env::current_dir().with_context(|| "failed to get current directory")?; - Ok(cwd.join(path)) + cwd.join(path) + }; + + // Normalize path by stripping '.' components + let mut normalized = PathBuf::new(); + for component in absolute.components() { + match component { + std::path::Component::CurDir => {} // Skip '.' + _ => normalized.push(component), + } + } + Ok(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_absolute_path_normalization() { + let cwd = std::env::current_dir().unwrap(); + let path = PathBuf::from("./foo/bar"); + let abs = absolute_path(path).unwrap(); + assert!(abs.is_absolute()); + assert_eq!(abs, cwd.join("foo/bar")); + assert!(!abs.to_string_lossy().contains("/./")); } } From 5e216505d3f9ff42f1b6dad6c79cc682c4793542 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:38:39 +0000 Subject: [PATCH 07/10] Make Ghost Steam runtime optional and enable installation GUI for debugging - Added use_steam_runtime field to UserAppConfig in models.rs. - Updated Game Properties UI in ui.rs with a toggle for Steam Runtime. - Updated spawn_game_process in steam_client.rs to honor the toggle. - Temporarily disabled silent install flag and added debug logging in launch.rs to troubleshoot installation issues. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/launch.rs | 3 +- src/models.rs | 6 ++++ src/steam_client.rs | 79 ++++++++++++++++++++++++--------------------- src/ui.rs | 11 +++++-- 4 files changed, 59 insertions(+), 40 deletions(-) diff --git a/src/launch.rs b/src/launch.rs index 605dacd..920b738 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -39,11 +39,12 @@ pub async fn install_ghost_steam_in_prefix(game_id: u32, proton_path: PathBuf) - let mut cmd = Command::new(&proton_path); cmd.arg("run") .arg(&installer_path) - .arg("/S") // Silent install flag + // .arg("/S") // Silent install flag - DISABLED FOR DEBUGGING .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", &base_dir); + tracing::info!("DEBUG: Launching Installer for App {}: {:?}", game_id, cmd); let status = cmd.status().await .context(format!("Failed to run Proton installer for app {}", game_id))?; diff --git a/src/models.rs b/src/models.rs index 40729f9..139ad85 100644 --- a/src/models.rs +++ b/src/models.rs @@ -10,6 +10,12 @@ pub struct UserAppConfig { pub env_variables: HashMap, // e.g. {"MANGOHUD": "1"} pub hidden: bool, // Future use pub favorite: bool, // Future use + #[serde(default = "default_true")] + pub use_steam_runtime: bool, +} + +fn default_true() -> bool { + true } pub type UserConfigStore = HashMap; diff --git a/src/steam_client.rs b/src/steam_client.rs index 0d61885..033a4a4 100644 --- a/src/steam_client.rs +++ b/src/steam_client.rs @@ -2214,60 +2214,65 @@ impl SteamClient { let library_root = absolute_path(PathBuf::from(&launcher_config.steam_library_path))?; let resolved_proton = self.resolve_proton_path(proton_name, &library_root); - // --- Ghost Steam Flow --- - let proton_runner = get_proton_runner().unwrap_or_else(|| resolved_proton.clone()); - let compat_data_path = absolute_path(config_dir()? .join("steamapps/compatdata") .join(app.app_id.to_string()))?; - let steam_exe_path = compat_data_path - .join("pfx/drive_c/Program Files (x86)/Steam/steam.exe"); + // --- Ghost Steam Flow --- + let use_steam_runtime = user_config.map(|c| c.use_steam_runtime).unwrap_or(true); + if use_steam_runtime { + let proton_runner = get_proton_runner().unwrap_or_else(|| resolved_proton.clone()); + + let steam_exe_path = compat_data_path + .join("pfx/drive_c/Program Files (x86)/Steam/steam.exe"); + + let first_run = !steam_exe_path.exists(); - let first_run = !steam_exe_path.exists(); + let steam_exe = install_ghost_steam_in_prefix(app.app_id, proton_runner.clone()).await?; - let steam_exe = install_ghost_steam_in_prefix(app.app_id, proton_runner.clone()).await?; + // Step 2: inject_credentials(prefix) + let has_credentials = inject_credentials(&compat_data_path).await.unwrap_or(false); - // Step 2: inject_credentials(prefix) - let has_credentials = inject_credentials(&compat_data_path).await.unwrap_or(false); + if first_run && !has_credentials { + tracing::info!(appid = app.app_id, "Ghost Steam First Run: Interactive Login required."); + let mut steam_cmd = Command::new(&proton_runner); + steam_cmd + .arg("run") + .arg(&steam_exe) + .env("WINEPREFIX", compat_data_path.join("pfx")) + .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); - if first_run && !has_credentials { - tracing::info!(appid = app.app_id, "Ghost Steam First Run: Interactive Login required."); + let mut steam_child = steam_cmd.spawn().context("failed to start Ghost Steam for interactive login")?; + let _ = steam_child.wait().await; + tracing::info!(appid = app.app_id, "Interactive Ghost Steam exited. Proceeding with game launch."); + + // Harvest after first manual login + let _ = harvest_credentials(&compat_data_path).await; + } + + // Step 3: Launch Steam (-silent if credentials existed, Normal UI if not) + tracing::info!(appid = app.app_id, "Starting Ghost Steam for Pipe..."); let mut steam_cmd = Command::new(&proton_runner); + steam_cmd.arg("run").arg(&steam_exe); + + if has_credentials || !first_run { + steam_cmd.arg("-silent").arg("-no-browser").arg("-noverifyfiles"); + } + steam_cmd - .arg("run") - .arg(&steam_exe) .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); - let mut steam_child = steam_cmd.spawn().context("failed to start Ghost Steam for interactive login")?; - let _ = steam_child.wait().await; - tracing::info!(appid = app.app_id, "Interactive Ghost Steam exited. Proceeding with game launch."); - - // Harvest after first manual login - let _ = harvest_credentials(&compat_data_path).await; - } - - // Step 3: Launch Steam (-silent if credentials existed, Normal UI if not) - tracing::info!(appid = app.app_id, "Starting Ghost Steam for Pipe..."); - let mut steam_cmd = Command::new(&proton_runner); - steam_cmd.arg("run").arg(&steam_exe); + let _steam_child = steam_cmd.spawn().context("failed to start Ghost Steam")?; - if has_credentials || !first_run { - steam_cmd.arg("-silent").arg("-no-browser").arg("-noverifyfiles"); + // Wait for Steam Pipe to initialize + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } else { + tracing::info!(appid = app.app_id, "Ghost Steam is disabled in user config. Launching raw executable."); } - steam_cmd - .env("WINEPREFIX", compat_data_path.join("pfx")) - .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) - .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); - - let _steam_child = steam_cmd.spawn().context("failed to start Ghost Steam")?; - - // Wait for Steam Pipe to initialize - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - // 2. Launch actual game binary let mut cmd = Command::new(&resolved_proton); cmd.arg("run").arg(&executable).args(&args); diff --git a/src/ui.rs b/src/ui.rs index c8d7b30..4e59da5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -33,6 +33,7 @@ struct GamePropertiesModalState { game_name: String, launch_options: String, env_vars: String, // Key=Value per line + use_steam_runtime: bool, } #[derive(Debug, Clone)] @@ -900,6 +901,7 @@ impl SteamLauncher { game_name: game.name.clone(), launch_options: config.launch_options, env_vars, + use_steam_runtime: config.use_steam_runtime, }); } @@ -1167,6 +1169,10 @@ impl SteamLauncher { .resizable(true) .default_size([400.0, 300.0]) .show(ctx, |ui| { + ui.checkbox(&mut state.use_steam_runtime, "Use Steam Runtime (Windows)") + .on_hover_text("Enables the official Steam client in the background. Required for most games. Disable for DRM-free games to launch faster."); + ui.add_space(8.0); + ui.label("Launch Options"); ui.text_edit_singleline(&mut state.launch_options); ui.add_space(8.0); @@ -1186,7 +1192,7 @@ impl SteamLauncher { } } // Note: we'll update the config in the outer scope to avoid borrowing issues - save_config = Some((state.app_id, state.launch_options.clone(), env_map)); + save_config = Some((state.app_id, state.launch_options.clone(), env_map, state.use_steam_runtime)); } if ui.button("Cancel").clicked() { close = true; @@ -1195,10 +1201,11 @@ impl SteamLauncher { }); } - if let Some((app_id, launch_opts, env_map)) = save_config { + if let Some((app_id, launch_opts, env_map, use_steam_runtime)) = save_config { let mut config = self.user_configs.get(&app_id).cloned().unwrap_or_default(); config.launch_options = launch_opts; config.env_variables = env_map; + config.use_steam_runtime = use_steam_runtime; self.user_configs.insert(app_id, config); let store = self.user_configs.clone(); From 386db791a899ce02ab26ed4f6154a92474c1449d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:05:54 +0000 Subject: [PATCH 08/10] Refine Ghost Steam: Manual stub removal and DLL overrides - Updated install_ghost_steam_in_prefix to manually delete Proton's built-in Steam stubs (steam.exe, lsteamclient.dll, etc.). - Implemented manual extraction of real Steam client from SteamSetup.exe using the zip crate. - Added WINEDLLOVERRIDES environment variable to all Proton/Wine commands to ensure native Steam libraries are loaded. - Cleaned up unused imports and variables in launch.rs. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/launch.rs | 66 ++++++++++++++++++++++++++++++++------------- src/steam_client.rs | 10 ++++--- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/launch.rs b/src/launch.rs index 920b738..71c5686 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -2,11 +2,10 @@ use std::path::PathBuf; use crate::config::{config_dir, absolute_path}; use crate::utils::download_windows_steam_client; use anyhow::{anyhow, Result, Context}; -use tokio::process::Command; /// Installs the "Ghost Steam" client into a specific Proton prefix for a game. /// This avoids dependency conflicts by giving each game its own minimal Steam installation. -pub async fn install_ghost_steam_in_prefix(game_id: u32, proton_path: PathBuf) -> Result { +pub async fn install_ghost_steam_in_prefix(game_id: u32, _proton_path: PathBuf) -> Result { let base_dir = absolute_path(config_dir()?)?; let compat_data_path = base_dir.join("steamapps/compatdata").join(game_id.to_string()); let prefix_path = compat_data_path.join("pfx"); @@ -33,25 +32,56 @@ pub async fn install_ghost_steam_in_prefix(game_id: u32, proton_path: PathBuf) - tokio::fs::create_dir_all(&compat_data_path).await .context("Failed to create compatdata directory")?; - // 3. Run the installer via Proton - // We need to set STEAM_COMPAT_DATA_PATH so Proton knows where the prefix is. - // We also set STEAM_COMPAT_CLIENT_INSTALL_PATH to our config dir. - let mut cmd = Command::new(&proton_path); - cmd.arg("run") - .arg(&installer_path) - // .arg("/S") // Silent install flag - DISABLED FOR DEBUGGING - .env("WINEPREFIX", compat_data_path.join("pfx")) - .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) - .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", &base_dir); + // 3. Manual Installation (Extract from SteamSetup.exe and sanitize stubs) + let steam_dir = prefix_path.join("drive_c/Program Files (x86)/Steam"); + tokio::fs::create_dir_all(&steam_dir).await + .context("Failed to create Steam directory in prefix")?; - tracing::info!("DEBUG: Launching Installer for App {}: {:?}", game_id, cmd); - let status = cmd.status().await - .context(format!("Failed to run Proton installer for app {}", game_id))?; - - if !status.success() { - return Err(anyhow!("Proton installer exited with non-zero status for app {}", game_id)); + // Step A: Sanitize the Prefix (Remove Proton's stubs) + let stubs = ["steam.exe", "lsteamclient.dll", "tier0_s.dll", "vstdlib_s.dll"]; + for stub in stubs { + let path = steam_dir.join(stub); + if path.exists() { + tracing::info!(appid = game_id, stub = stub, "Removing built-in stub"); + let _ = tokio::fs::remove_file(&path).await; + } } + // Step B: Extract the Real Client from SteamSetup.exe + tracing::info!(appid = game_id, "Extracting real Steam client from installer..."); + + // We use a blocking task for ZIP extraction to avoid blocking the async executor + let installer_path_clone = installer_path.clone(); + let steam_dir_clone = steam_dir.clone(); + + tokio::task::spawn_blocking(move || -> Result<()> { + let file = std::fs::File::open(&installer_path_clone) + .context("Failed to open SteamSetup.exe")?; + let mut archive = zip::ZipArchive::new(file) + .context("Failed to open SteamSetup.exe as ZIP archive (NSIS might not be ZIP-compatible or file is corrupt)")?; + + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let outpath = match file.enclosed_name() { + Some(path) => steam_dir_clone.join(path), + None => continue, + }; + + if file.name().ends_with('/') { + std::fs::create_dir_all(&outpath)?; + } else { + if let Some(p) = outpath.parent() { + if !p.exists() { + std::fs::create_dir_all(p)?; + } + } + let mut outfile = std::fs::File::create(&outpath)?; + std::io::copy(&mut file, &mut outfile)?; + } + } + Ok(()) + }).await??; + // 4. Verify installation if !steam_exe_path.exists() { return Err(anyhow!( diff --git a/src/steam_client.rs b/src/steam_client.rs index 033a4a4..a2818e9 100644 --- a/src/steam_client.rs +++ b/src/steam_client.rs @@ -1642,7 +1642,8 @@ impl SteamClient { .arg(&steam_exe) .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) - .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?) + .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n"); let mut child = steam_cmd.spawn().context("failed to start Ghost Steam")?; child.wait().await?; @@ -2241,7 +2242,8 @@ impl SteamClient { .arg(&steam_exe) .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) - .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?) + .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n"); let mut steam_child = steam_cmd.spawn().context("failed to start Ghost Steam for interactive login")?; let _ = steam_child.wait().await; @@ -2263,7 +2265,8 @@ impl SteamClient { steam_cmd .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) - .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?) + .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n"); let _steam_child = steam_cmd.spawn().context("failed to start Ghost Steam")?; @@ -2281,6 +2284,7 @@ impl SteamClient { cmd.env("WINEPREFIX", compat_data_path.join("pfx")); cmd.env("STEAM_COMPAT_DATA_PATH", &compat_data_path); cmd.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); + cmd.env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n"); if let Some(config) = user_config { for (key, val) in &config.env_variables { From a17fe1f8e63e874836db501027dd7c674a8932cd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:32:39 +0000 Subject: [PATCH 09/10] Fix Ghost Steam: Revert to execution with stub sanitization and DLL overrides - Reverted install_ghost_steam_in_prefix to execute SteamSetup.exe via Proton. - Implemented sanitize_prefix to delete Proton's built-in Steam stubs before installation. - Expanded WINEDLLOVERRIDES to include steamclient=n across all Proton/Wine commands. - Ensured absolute paths are used for installer execution and environment variables. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/launch.rs | 55 +++++++++++++++++---------------------------- src/steam_client.rs | 8 +++---- 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/src/launch.rs b/src/launch.rs index 71c5686..4ce986b 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -2,10 +2,11 @@ use std::path::PathBuf; use crate::config::{config_dir, absolute_path}; use crate::utils::download_windows_steam_client; use anyhow::{anyhow, Result, Context}; +use tokio::process::Command; /// Installs the "Ghost Steam" client into a specific Proton prefix for a game. /// This avoids dependency conflicts by giving each game its own minimal Steam installation. -pub async fn install_ghost_steam_in_prefix(game_id: u32, _proton_path: PathBuf) -> Result { +pub async fn install_ghost_steam_in_prefix(game_id: u32, proton_path: PathBuf) -> Result { let base_dir = absolute_path(config_dir()?)?; let compat_data_path = base_dir.join("steamapps/compatdata").join(game_id.to_string()); let prefix_path = compat_data_path.join("pfx"); @@ -32,55 +33,39 @@ pub async fn install_ghost_steam_in_prefix(game_id: u32, _proton_path: PathBuf) tokio::fs::create_dir_all(&compat_data_path).await .context("Failed to create compatdata directory")?; - // 3. Manual Installation (Extract from SteamSetup.exe and sanitize stubs) + // 3. Manual Sanitization & Installer Execution let steam_dir = prefix_path.join("drive_c/Program Files (x86)/Steam"); tokio::fs::create_dir_all(&steam_dir).await .context("Failed to create Steam directory in prefix")?; // Step A: Sanitize the Prefix (Remove Proton's stubs) + // If these exist, the installer thinks Steam is already installed/running. let stubs = ["steam.exe", "lsteamclient.dll", "tier0_s.dll", "vstdlib_s.dll"]; for stub in stubs { let path = steam_dir.join(stub); if path.exists() { - tracing::info!(appid = game_id, stub = stub, "Removing built-in stub"); + tracing::info!(appid = game_id, stub = stub, "Removing built-in stub before install"); let _ = tokio::fs::remove_file(&path).await; } } - // Step B: Extract the Real Client from SteamSetup.exe - tracing::info!(appid = game_id, "Extracting real Steam client from installer..."); + // Step B: Run the installer via Proton with Overrides + tracing::info!(appid = game_id, "Executing Steam installer via Proton..."); + let mut cmd = Command::new(&proton_path); + cmd.arg("run") + .arg(&installer_path) + .arg("/S") // Silent install flag + .env("WINEPREFIX", &prefix_path) + .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) + .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", &base_dir) + .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n;steamclient=n"); - // We use a blocking task for ZIP extraction to avoid blocking the async executor - let installer_path_clone = installer_path.clone(); - let steam_dir_clone = steam_dir.clone(); + let status = cmd.status().await + .context(format!("Failed to run Proton installer for app {}", game_id))?; - tokio::task::spawn_blocking(move || -> Result<()> { - let file = std::fs::File::open(&installer_path_clone) - .context("Failed to open SteamSetup.exe")?; - let mut archive = zip::ZipArchive::new(file) - .context("Failed to open SteamSetup.exe as ZIP archive (NSIS might not be ZIP-compatible or file is corrupt)")?; - - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let outpath = match file.enclosed_name() { - Some(path) => steam_dir_clone.join(path), - None => continue, - }; - - if file.name().ends_with('/') { - std::fs::create_dir_all(&outpath)?; - } else { - if let Some(p) = outpath.parent() { - if !p.exists() { - std::fs::create_dir_all(p)?; - } - } - let mut outfile = std::fs::File::create(&outpath)?; - std::io::copy(&mut file, &mut outfile)?; - } - } - Ok(()) - }).await??; + if !status.success() { + return Err(anyhow!("Proton installer exited with non-zero status for app {}", game_id)); + } // 4. Verify installation if !steam_exe_path.exists() { diff --git a/src/steam_client.rs b/src/steam_client.rs index a2818e9..bb40b5a 100644 --- a/src/steam_client.rs +++ b/src/steam_client.rs @@ -1643,7 +1643,7 @@ impl SteamClient { .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?) - .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n"); + .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n;steamclient=n"); let mut child = steam_cmd.spawn().context("failed to start Ghost Steam")?; child.wait().await?; @@ -2243,7 +2243,7 @@ impl SteamClient { .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?) - .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n"); + .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n;steamclient=n"); let mut steam_child = steam_cmd.spawn().context("failed to start Ghost Steam for interactive login")?; let _ = steam_child.wait().await; @@ -2266,7 +2266,7 @@ impl SteamClient { .env("WINEPREFIX", compat_data_path.join("pfx")) .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?) - .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n"); + .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n;steamclient=n"); let _steam_child = steam_cmd.spawn().context("failed to start Ghost Steam")?; @@ -2284,7 +2284,7 @@ impl SteamClient { cmd.env("WINEPREFIX", compat_data_path.join("pfx")); cmd.env("STEAM_COMPAT_DATA_PATH", &compat_data_path); cmd.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", absolute_path(config_dir()?)?); - cmd.env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n"); + cmd.env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n;steamclient=n"); if let Some(config) = user_config { for (key, val) in &config.env_variables { From b30ee37524042fcd4683ee48d85b4ee73b24e796 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:59:59 +0000 Subject: [PATCH 10/10] Refine Ghost Steam: Use Raw Wine for installation - Updated install_ghost_steam_in_prefix to use the actual wine binary inside the Proton directory. - Cleared Proton-specific environment variables (SteamAppId, etc.) during installation to ensure a "Vanilla" prefix. - Retained stub sanitization and WINEDLLOVERRIDES for native library prioritization. - Ensured absolute paths are used for prefix and installer. Co-authored-by: weter11 <14630689+weter11@users.noreply.github.com> --- src/launch.rs | 60 +++++++++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/launch.rs b/src/launch.rs index 4ce986b..15778da 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -29,45 +29,53 @@ pub async fn install_ghost_steam_in_prefix(game_id: u32, proton_path: PathBuf) - let installer_path = absolute_path(download_windows_steam_client().await .context("Failed to ensure Steam installer is cached")?)?; - // 2. Ensure compatdata directory exists - tokio::fs::create_dir_all(&compat_data_path).await - .context("Failed to create compatdata directory")?; + // 2. Locate the Raw Wine binary inside the Proton directory + // We must bypass the 'proton' script to avoid its Steam environment checks. + let wine_candidates = [ + proton_path.parent().and_then(|p| p.parent()).map(|p| p.join("dist/bin/wine")), + proton_path.parent().and_then(|p| p.parent()).map(|p| p.join("files/bin/wine")), + ]; - // 3. Manual Sanitization & Installer Execution - let steam_dir = prefix_path.join("drive_c/Program Files (x86)/Steam"); - tokio::fs::create_dir_all(&steam_dir).await - .context("Failed to create Steam directory in prefix")?; + let wine_exe = wine_candidates.into_iter() + .flatten() + .find(|p| p.exists()) + .ok_or_else(|| anyhow!("Failed to locate wine binary within Proton path: {:?}", proton_path))?; - // Step A: Sanitize the Prefix (Remove Proton's stubs) - // If these exist, the installer thinks Steam is already installed/running. - let stubs = ["steam.exe", "lsteamclient.dll", "tier0_s.dll", "vstdlib_s.dll"]; - for stub in stubs { - let path = steam_dir.join(stub); - if path.exists() { - tracing::info!(appid = game_id, stub = stub, "Removing built-in stub before install"); - let _ = tokio::fs::remove_file(&path).await; + // 3. Manual Sanitization (Remove Proton's built-in Steam stubs) + let steam_dir = prefix_path.join("drive_c/Program Files (x86)/Steam"); + if steam_dir.exists() { + let stubs = ["steam.exe", "lsteamclient.dll", "tier0_s.dll", "vstdlib_s.dll"]; + for stub in stubs { + let path = steam_dir.join(stub); + if path.exists() { + tracing::info!(appid = game_id, stub = stub, "Removing built-in stub before install"); + let _ = tokio::fs::remove_file(&path).await; + } } + } else { + tokio::fs::create_dir_all(&steam_dir).await + .context("Failed to create Steam directory in prefix")?; } - // Step B: Run the installer via Proton with Overrides - tracing::info!(appid = game_id, "Executing Steam installer via Proton..."); - let mut cmd = Command::new(&proton_path); - cmd.arg("run") - .arg(&installer_path) + // 4. Run the installer via Raw Wine + tracing::info!(appid = game_id, wine = ?wine_exe, "Executing Steam installer via Raw Wine..."); + let mut cmd = Command::new(&wine_exe); + cmd.arg(&installer_path) .arg("/S") // Silent install flag .env("WINEPREFIX", &prefix_path) - .env("STEAM_COMPAT_DATA_PATH", &compat_data_path) - .env("STEAM_COMPAT_CLIENT_INSTALL_PATH", &base_dir) - .env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n;steamclient=n"); + .env("WINEDLLOVERRIDES", "mscoree,mshtml=;steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n;steamclient=n") + .env_remove("SteamAppId") + .env_remove("STEAM_COMPAT_DATA_PATH") + .env_remove("STEAM_COMPAT_CLIENT_INSTALL_PATH"); let status = cmd.status().await - .context(format!("Failed to run Proton installer for app {}", game_id))?; + .context(format!("Failed to run Wine installer for app {}", game_id))?; if !status.success() { - return Err(anyhow!("Proton installer exited with non-zero status for app {}", game_id)); + return Err(anyhow!("Wine installer exited with non-zero status for app {}", game_id)); } - // 4. Verify installation + // 5. Verify installation if !steam_exe_path.exists() { return Err(anyhow!( "Steam installation seemed to succeed, but steam.exe was not found at {}",