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/config.rs b/src/config.rs index 78868a5..2ee64a5 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,44 @@ 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 fn absolute_path(path: PathBuf) -> Result { + let absolute = if path.is_absolute() { + path + } else { + let cwd = std::env::current_dir().with_context(|| "failed to get current directory")?; + 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("/./")); + } +} + 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 new file mode 100644 index 0000000..15778da --- /dev/null +++ b/src/launch.rs @@ -0,0 +1,88 @@ +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 { + 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"); + + // 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 = absolute_path(download_windows_steam_client().await + .context("Failed to ensure Steam installer is cached")?)?; + + // 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")), + ]; + + 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))?; + + // 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")?; + } + + // 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("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 Wine installer for app {}", game_id))?; + + if !status.success() { + return Err(anyhow!("Wine installer exited with non-zero status for app {}", game_id)); + } + + // 5. 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 e127044..847d498 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,5 @@ pub mod library; pub mod models; pub mod steam_client; pub mod ui; +pub mod utils; +pub mod launch; 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/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 84a56eb..bb40b5a 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, + 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; +use crate::utils::{get_proton_runner, harvest_credentials, inject_credentials}; 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,42 @@ 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 = 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 = absolute_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 + .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()?)?) + .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?; + + let _ = harvest_credentials(&compat_data_path).await; Ok(()) } @@ -2104,19 +2139,19 @@ 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 { - let install_dir = PathBuf::from( + ) -> Result { + 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); @@ -2165,7 +2200,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()) @@ -2177,23 +2212,79 @@ impl SteamClient { .ok_or_else(|| anyhow!("proton path is required for Windows launch"))? }; - let library_root = PathBuf::from(&launcher_config.steam_library_path); - let resolved_proton = self.resolve_proton_path(proton, &library_root); + let library_root = absolute_path(PathBuf::from(&launcher_config.steam_library_path))?; + let resolved_proton = self.resolve_proton_path(proton_name, &library_root); + + let compat_data_path = absolute_path(config_dir()? + .join("steamapps/compatdata") + .join(app.app_id.to_string()))?; + + // --- 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 compat_data_path = library_root - .join("steamapps") - .join("compatdata") - .join(app.app_id.to_string()); + let steam_exe = install_ghost_steam_in_prefix(app.app_id, proton_runner.clone()).await?; - std::fs::create_dir_all(&compat_data_path) - .with_context(|| format!("failed creating {}", compat_data_path.display()))?; + // 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()?)?) + .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; + 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 + .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;steamclient=n"); + + 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; + } else { + tracing::info!(appid = app.app_id, "Ghost Steam is disabled in user config. Launching raw executable."); + } + // 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("WINEPREFIX", compat_data_path.join("pfx")); 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", absolute_path(config_dir()?)?); + 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 { @@ -2201,7 +2292,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..4e59da5 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, @@ -32,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)] @@ -76,6 +78,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 +158,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 +223,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 +776,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 +847,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 +856,13 @@ impl SteamLauncher { } }; - let _ = child.wait(); + 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; @@ -879,6 +901,7 @@ impl SteamLauncher { game_name: game.name.clone(), launch_options: config.launch_options, env_vars, + use_steam_runtime: config.use_steam_runtime, }); } @@ -906,6 +929,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; @@ -1078,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); @@ -1097,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; @@ -1106,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(); @@ -2207,6 +2303,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(); } } diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..14a5570 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,210 @@ +use std::path::PathBuf; +use crate::config::{config_dir, secrets_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() { + if let Ok(abs_cfg) = crate::config::absolute_path(cfg_dir) { + search_paths.push(abs_cfg.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) +} + +/// 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::*; + + #[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); + } +}