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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
40 changes: 40 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,53 @@ 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(())
}

pub fn opensteam_image_cache_dir() -> Result<PathBuf> {
Ok(PathBuf::from("./config/SteamFlow/images"))
}

pub fn secrets_dir() -> Result<PathBuf> {
Ok(PathBuf::from("./config/SteamFlow/secrets"))
}

pub fn absolute_path(path: PathBuf) -> Result<PathBuf> {
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<SessionState> {
let session_path = config_dir()?.join("session.json");
if !session_path.exists() {
Expand Down
88 changes: 88 additions & 0 deletions src/launch.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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)
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ pub mod library;
pub mod models;
pub mod steam_client;
pub mod ui;
pub mod utils;
pub mod launch;
13 changes: 13 additions & 0 deletions src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
6 changes: 6 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ pub struct UserAppConfig {
pub env_variables: HashMap<String, String>, // 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<u32, UserAppConfig>;
Expand Down
Loading