diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index f920c574..b36ffd6a 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -1,5 +1,9 @@ name: SteamFlow Rust Build +permissions: + contents: read + actions: write + on: push: paths: diff --git a/Cargo.lock b/Cargo.lock index 130d3498..930c393a 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 bd08b31f..06deb252 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 10d02792..5e19e666 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use crate::models::{OwnedGame, SessionState, UserConfigStore}; +use crate::models::{OwnedGame, SessionState}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -11,6 +11,34 @@ pub struct GameConfig { pub platform_preference: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserAppConfig { + pub launch_options: String, // e.g. "-novid -console" + 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 +} + +impl Default for UserAppConfig { + fn default() -> Self { + Self { + launch_options: String::new(), + env_variables: HashMap::new(), + hidden: false, + favorite: false, + use_steam_runtime: true, + } + } +} + +pub type UserConfigStore = HashMap; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LauncherConfig { pub steam_library_path: String, @@ -163,6 +191,30 @@ pub async fn save_launcher_config(config: &LauncherConfig) -> Result<()> { Ok(()) } +pub fn absolute_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let abs_path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + + // Normalize: remove "." and handle ".." + let mut components = abs_path.components().peekable(); + let mut ret = PathBuf::new(); + + while let Some(c) = components.next() { + match c { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + ret.pop(); + } + _ => ret.push(c), + } + } + Ok(ret) +} + pub fn library_cache_path() -> Result { Ok(data_dir()?.join("library_cache.json")) } diff --git a/src/launch.rs b/src/launch.rs new file mode 100644 index 00000000..b885446c --- /dev/null +++ b/src/launch.rs @@ -0,0 +1,169 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use tokio::process::Command; +use crate::utils::ensure_steam_installer; + +pub fn build_runner_command(runner_path: &Path) -> Result { + // 1. Check if it's Proton (has a 'proton' script in the root) + let proton_script = runner_path.join("proton"); + if proton_script.exists() { + let mut cmd = Command::new(proton_script); + cmd.arg("run"); + return Ok(cmd); + } + + // 2. Check if it's standard Wine / wine-tkg (has 'bin/wine') + let wine_bin = runner_path.join("bin/wine"); + if wine_bin.exists() { + return Ok(Command::new(wine_bin)); + } + + // 3. Fallback for system wine if runner_path is just "/usr/bin/wine" + if runner_path.is_file() && runner_path.file_name() == Some(std::ffi::OsStr::new("wine")) { + return Ok(Command::new(runner_path)); + } + + Err(anyhow::anyhow!("failed to find wine binary in runner directory: {:?}", runner_path)) +} + +pub async fn install_ghost_steam(app_id: u32, proton_path: &Path) -> Result<()> { + println!("Starting Steam Runtime Installation for AppID: {}...", app_id); + let launcher_config = crate::config::load_launcher_config().await?; + let library_root = crate::config::absolute_path(&launcher_config.steam_library_path)?; + let prefix = library_root.join("steamapps/compatdata").join(app_id.to_string()); + + let installer_path = ensure_steam_installer().await?; + println!("Installer Path: {}", installer_path.display()); + println!("Prefix Path: {}", prefix.display()); + + // Step A: Sanitize Target + let target_dir = prefix.join("pfx/drive_c/Program Files (x86)/Steam"); + if target_dir.exists() { + println!("Sanitizing prefix..."); + let mut deleted_count = 0; + let entries = ["steam.exe", "lsteamclient.dll"]; + for entry in entries { + let file_path = target_dir.join(entry); + if file_path.exists() { + std::fs::remove_file(&file_path)?; + deleted_count += 1; + } + } + println!("Sanitizing prefix... deleted {} files.", deleted_count); + } + + // Step B: Construct Command + let mut cmd = build_runner_command(proton_path)?; + cmd.arg(&installer_path).arg("/S"); + + let abs_prefix = crate::config::absolute_path(prefix.join("pfx"))?; + cmd.env("WINEPREFIX", &abs_prefix); + cmd.env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n;steamclient=n"); + + // REMOVE: SteamAppId, SteamGameId, STEAM_COMPAT_CLIENT_INSTALL_PATH + cmd.env_remove("SteamAppId"); + cmd.env_remove("SteamGameId"); + cmd.env_remove("STEAM_COMPAT_CLIENT_INSTALL_PATH"); + + // Fix TLS/Network Error + cmd.env("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); + cmd.env("SSL_CERT_DIR", "/etc/ssl/certs"); + + // Pass through display variables for installer UI (even if silent, sometimes needed) + for var in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR"] { + if let Ok(val) = std::env::var(var) { + cmd.env(var, val); + } + } + + // Step C: Execute & Wait + let status = cmd.status().await.context("failed to run Steam installer")?; + println!("Installer finished with exit code: {}", status); + + Ok(()) +} + +pub async fn launch_ghost_steam( + app_id: u32, + proton_path: &Path, + steam_exe: &Path, + prefix: &Path, + silent: bool, +) -> Result<()> { + if silent { + println!("Launching Steam Runtime in background..."); + } else { + println!("Launching Steam Runtime interactively..."); + } + + let mut cmd = build_runner_command(proton_path)?; + if silent { + cmd.arg(steam_exe).arg("-silent").arg("-no-browser").arg("-noverifyfiles").arg("-tcp").arg("-cef-disable-gpu-compositing"); + } else { + cmd.arg(steam_exe).arg("-tcp").arg("-cef-disable-gpu-compositing"); + } + + let abs_prefix = crate::config::absolute_path(prefix.join("pfx"))?; + cmd.env("WINEPREFIX", &abs_prefix); + cmd.env("STEAM_COMPAT_DATA_PATH", crate::config::absolute_path(prefix)?); + + if app_id != 0 { + cmd.env("SteamAppId", app_id.to_string()); + } + + // Ensure it uses native binaries + cmd.env("WINEDLLOVERRIDES", "steam.exe=n;lsteamclient=n;steam_api=n;steam_api64=n;steamclient=n"); + + // Fix TLS/Network Error + cmd.env("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); + cmd.env("SSL_CERT_DIR", "/etc/ssl/certs"); + + // Pass through display variables + for var in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR"] { + if let Ok(val) = std::env::var(var) { + cmd.env(var, val); + } + } + + if silent { + let _child = cmd.spawn().context("failed to launch Ghost Steam")?; + println!("Waiting for Steam Runtime initialization..."); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } else { + let status = cmd.status().await.context("failed to run interactive Steam")?; + println!("Interactive Steam exited with status: {}", status); + } + + Ok(()) +} + +pub fn get_installed_steam_path(prefix: &Path) -> Option { + let pfx_path = prefix.join("pfx"); + if !pfx_path.exists() { + return None; + } + + let drive_c = pfx_path.join("drive_c"); + let search_paths = [ + drive_c.join("Program Files (x86)/Steam"), + drive_c.join("Program Files/Steam"), + drive_c.join("Steam"), + ]; + + for path in search_paths { + if path.exists() { + if let Ok(entries) = std::fs::read_dir(&path) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if name == "steam.exe" { + println!("Steam Executable Found: Yes ({})", entry.path().display()); + return Some(entry.path()); + } + } + } + } + } + + println!("Steam Executable Found: No"); + None +} diff --git a/src/lib.rs b/src/lib.rs index e127044e..847d498d 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/models.rs b/src/models.rs index 40729f93..dca7214b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -4,15 +4,6 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct UserAppConfig { - pub launch_options: String, // e.g. "-novid -console" - pub env_variables: HashMap, // e.g. {"MANGOHUD": "1"} - pub hidden: bool, // Future use - pub favorite: bool, // Future use -} - -pub type UserConfigStore = HashMap; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SessionState { diff --git a/src/steam_client.rs b/src/steam_client.rs index 84a56eb5..d264711c 100644 --- a/src/steam_client.rs +++ b/src/steam_client.rs @@ -2,7 +2,7 @@ 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, + save_library_cache, save_session, UserAppConfig, }; use crate::depot_browser::{self, DepotInfo as BrowserDepotInfo, ManifestFileEntry}; use crate::models::{ @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use std::path::{Path, PathBuf}; -use std::process::Command; +use tokio::process::Command; use std::str::FromStr; use std::time::Instant; @@ -1556,7 +1556,7 @@ impl SteamClient { &mut self, app: &LibraryGame, proton_path: Option<&str>, - user_config: Option<&crate::models::UserAppConfig>, + user_config: Option<&UserAppConfig>, ) -> Result { let prefer_proton = proton_path.is_some(); let launch_options = self.get_product_info(app.app_id, prefer_proton).await?; @@ -1592,9 +1592,10 @@ impl SteamClient { } let mut child = - self.spawn_game_process(app, &launch_info, chosen_proton_path, &launcher_config, user_config)?; + 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()) { @@ -1610,10 +1611,10 @@ impl SteamClient { app: &LibraryGame, launch_info: &LaunchInfo, proton_path: Option<&str>, - user_config: Option<&crate::models::UserAppConfig>, + user_config: Option<&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(()) } @@ -2082,36 +2083,105 @@ impl SteamClient { return PathBuf::from(proton_name); } - let standard_path = library_root + let standard_dir = library_root .join("steamapps/common") - .join(proton_name) - .join("proton"); + .join(proton_name); - if standard_path.exists() { - return standard_path; + if standard_dir.exists() { + return standard_dir; } let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let custom_path = PathBuf::from(home) + let custom_dir = PathBuf::from(home) .join(".local/share/Steam/compatibilitytools.d") - .join(proton_name) - .join("proton"); + .join(proton_name); - if custom_path.exists() { - return custom_path; + if custom_dir.exists() { + return custom_dir; } 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 { + user_config: Option<&UserAppConfig>, + ) -> Result { + let library_root = crate::config::absolute_path(&launcher_config.steam_library_path)?; + + let use_runtime = user_config.map(|c| c.use_steam_runtime).unwrap_or_else(|| { + matches!(launch_info.target, LaunchTarget::WindowsProton) + }); + + // Determine resolved proton if needed + let resolved_proton = if matches!(launch_info.target, LaunchTarget::WindowsProton) { + let proton = if let Some(forced) = launcher_config + .game_configs + .get(&app.app_id) + .and_then(|c| c.forced_proton_version.as_ref()) + { + forced + } else { + proton_path + .filter(|p| !p.is_empty()) + .ok_or_else(|| anyhow!("proton path is required for Windows launch"))? + }; + Some(self.resolve_proton_path(proton, &library_root)) + } else { + None + }; + + if !use_runtime || matches!(launch_info.target, LaunchTarget::NativeLinux) { + return self.spawn_raw_game_process(app, launch_info, resolved_proton.as_deref(), launcher_config, user_config, use_runtime); + } + + // Ghost Steam Logic (Phase 4) + let prefix = library_root.join("steamapps/compatdata").join(app.app_id.to_string()); + let resolved_proton = resolved_proton.ok_or_else(|| anyhow!("Resolved proton missing for Windows launch"))?; + + // Check Install + let steam_exe = crate::launch::get_installed_steam_path(&prefix); + let steam_exe = match steam_exe { + Some(exe) => exe, + None => { + crate::launch::install_ghost_steam(app.app_id, &resolved_proton).await?; + crate::launch::get_installed_steam_path(&prefix) + .ok_or_else(|| anyhow!("Failed to find steam.exe in game prefix after installation"))? + } + }; + + // Launch Steam (Background) + crate::launch::launch_ghost_steam( + app.app_id, + &resolved_proton, + &steam_exe, + &prefix, + true, // silent + ).await?; + + println!("Launching Game Executable..."); + self.spawn_raw_game_process(app, launch_info, Some(&resolved_proton), launcher_config, user_config, use_runtime) + } + + pub async fn get_compat_data_path(&self, app_id: u32) -> Result { + let cfg = load_launcher_config().await?; + let library_root = crate::config::absolute_path(&cfg.steam_library_path)?; + Ok(library_root.join("steamapps/compatdata").join(app_id.to_string())) + } + + pub(crate) fn spawn_raw_game_process( + &self, + app: &LibraryGame, + launch_info: &LaunchInfo, + resolved_proton_path: Option<&Path>, + launcher_config: &crate::config::LauncherConfig, + user_config: Option<&UserAppConfig>, + use_runtime: bool, + ) -> Result { let install_dir = PathBuf::from( app.install_path .clone() @@ -2165,20 +2235,9 @@ impl SteamClient { cmd.spawn().context("failed to spawn native linux game") } LaunchTarget::WindowsProton => { - let proton = if let Some(forced) = launcher_config - .game_configs - .get(&app.app_id) - .and_then(|c| c.forced_proton_version.as_ref()) - { - forced - } else { - proton_path - .filter(|p| !p.is_empty()) - .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 = crate::config::absolute_path(&launcher_config.steam_library_path)?; + let resolved_proton = resolved_proton_path + .ok_or_else(|| anyhow!("Resolved proton path missing for Windows launch"))?; let compat_data_path = library_root .join("steamapps") @@ -2188,12 +2247,31 @@ impl SteamClient { std::fs::create_dir_all(&compat_data_path) .with_context(|| format!("failed creating {}", compat_data_path.display()))?; - let mut cmd = Command::new(&resolved_proton); - cmd.arg("run").arg(&executable).args(&args); + let mut cmd = crate::launch::build_runner_command(resolved_proton)?; + cmd.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); + + if use_runtime { + cmd.env("SteamAppId", app.app_id.to_string()); + } else { + cmd.env_remove("SteamAppId"); + cmd.env_remove("SteamGameId"); + } + + cmd.env("STEAM_COMPAT_DATA_PATH", crate::config::absolute_path(&compat_data_path)?); + let abs_pfx = crate::config::absolute_path(compat_data_path.join("pfx"))?; + cmd.env("WINEPREFIX", abs_pfx); + + let trap_path = crate::utils::setup_fake_steam_env()?; + cmd.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", &trap_path); + + // Modify PATH to prioritize the fake steam trap + let existing_path = std::env::var("PATH").unwrap_or_default(); + cmd.env("PATH", format!("{}:{}", trap_path.display(), existing_path)); + + if use_runtime { + cmd.env("WINEDLLOVERRIDES", "steam.exe=n;steamclient=n;steamclient64=n;lsteamclient=n;steam_api=n;steam_api64=n"); + } if let Some(config) = user_config { for (key, val) in &config.env_variables { @@ -2201,7 +2279,7 @@ impl SteamClient { } } - tracing::info!("Launching game (Proton): {:?} with args {:?}", resolved_proton, cmd.get_args()); + tracing::info!("Launching game (Proton): {:?} with args {:?}", resolved_proton, args); cmd.spawn().context("failed to spawn proton game") } } diff --git a/src/ui.rs b/src/ui.rs index bbea5edb..8699ff6c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,5 +1,6 @@ use crate::config::{ load_launcher_config, opensteam_image_cache_dir, save_launcher_config, LauncherConfig, + UserConfigStore, }; use crate::depot_browser::{DepotInfo as BrowserDepotInfo, ManifestFileEntry}; use crate::library::{build_game_library, scan_installed_app_paths}; @@ -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)] @@ -110,7 +112,7 @@ pub enum AsyncOp { SettingsSaved(bool), ScanCompleted(u32, HashMap), MetadataFetched(u32, crate::steam_client::AppMetadata), - UserConfigsFetched(crate::models::UserConfigStore), + UserConfigsFetched(UserConfigStore), Error(String), } @@ -158,7 +160,7 @@ pub struct SteamLauncher { depot_list: Vec, depot_selection: HashSet, is_verifying: bool, - user_configs: crate::models::UserConfigStore, + user_configs: UserConfigStore, operation_tx: Sender, operation_rx: Receiver, } @@ -773,6 +775,7 @@ impl SteamLauncher { Some(self.proton_path_for_windows.trim().to_string()) }; + let mut prefer_proton = proton_path.is_some(); if let Some(config) = self.launcher_config.game_configs.get(&game.app_id) { if let Some(pref) = &config.platform_preference { @@ -831,8 +834,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 +843,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; @@ -879,6 +882,7 @@ impl SteamLauncher { game_name: game.name.clone(), launch_options: config.launch_options, env_vars, + use_steam_runtime: config.use_steam_runtime, }); } @@ -1087,6 +1091,10 @@ impl SteamLauncher { ui.text_edit_multiline(&mut state.env_vars); }); + ui.add_space(8.0); + ui.checkbox(&mut state.use_steam_runtime, "Use Steam Runtime (Windows)") + .on_hover_text("Enables the official Steam client in the background. Required for games with DRM. Disable for faster launch on DRM-free games."); + ui.add_space(12.0); ui.horizontal(|ui| { if ui.button("Save").clicked() { @@ -1097,7 +1105,12 @@ 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 +1119,11 @@ impl SteamLauncher { }); } - if let Some((app_id, launch_opts, env_map)) = save_config { + if let Some((app_id, launch_opts, env_map, use_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_runtime; self.user_configs.insert(app_id, config); let store = self.user_configs.clone(); diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 00000000..a827138f --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,47 @@ +use anyhow::{Context, Result}; +use std::path::PathBuf; +use tokio::fs; + +pub async fn ensure_steam_installer() -> Result { + let config_dir = crate::config::config_dir()?; + let runtimes_dir = config_dir.join("runtimes"); + fs::create_dir_all(&runtimes_dir).await?; + + let installer_path = runtimes_dir.join("SteamSetup.exe"); + if installer_path.exists() { + return Ok(installer_path.canonicalize()?); + } + + println!("Downloading Steam Runtime installer..."); + let url = "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe"; + let response = reqwest::get(url).await.context("failed to download Steam installer")?; + let bytes = response.bytes().await.context("failed to read Steam installer bytes")?; + + fs::write(&installer_path, bytes).await.context("failed to write Steam installer to disk")?; + println!("Download Complete"); + + Ok(installer_path.canonicalize()?) +} + + +pub fn setup_fake_steam_env() -> Result { + let config_dir = crate::config::config_dir()?; + let fake_env_dir = config_dir.join("fake_env"); + std::fs::create_dir_all(&fake_env_dir)?; + + let scripts = ["steam", "steam.sh"]; + let content = "#!/bin/sh\nexit 0\n"; + + for script in scripts { + let script_path = fake_env_dir.join(script); + std::fs::write(&script_path, content)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?; + } + } + + Ok(fake_env_dir.canonicalize()?) +}