diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-14 02:45:12 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-14 02:45:25 -0600 |
| commit | 817e8f5cd26739c69d930d21cd9dc4c0b6e4984e (patch) | |
| tree | 52aed11d6751bb7d20b06acf197d56fac113203d /libs/nak/src/steam | |
| parent | 51a9f8f197727f00896e5de44569b098923527dd (diff) | |
Add FUSE external mapping support, BG3/Oblivion Remastered fixes, fomod-plus and NaK integration
FUSE VFS now deploys non-data-dir mod mappings (Paks, OBSE, UE4SS, etc.)
via real symlinks and injects file-level data-dir mappings (plugins.txt,
loadorder.txt) into the VFS tree. Fixes game launches for Oblivion
Remastered (Root Builder path resolution, script extender support) and
BG3 (Wine prefix documents directory, file mapper symlinks on Linux).
Vendors mo2-fomod-plus plugin and NaK crate for FOMOD installer and
game finder/runtime support.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/nak/src/steam')
| -rw-r--r-- | libs/nak/src/steam/mod.rs | 135 | ||||
| -rw-r--r-- | libs/nak/src/steam/paths.rs | 259 | ||||
| -rw-r--r-- | libs/nak/src/steam/proton.rs | 257 |
3 files changed, 651 insertions, 0 deletions
diff --git a/libs/nak/src/steam/mod.rs b/libs/nak/src/steam/mod.rs new file mode 100644 index 0000000..c5964aa --- /dev/null +++ b/libs/nak/src/steam/mod.rs @@ -0,0 +1,135 @@ +//! Steam integration module +//! +//! Handles Proton detection, Steam path detection, and mount point discovery. +//! Shortcuts and config.vdf manipulation removed (handled by C++ side). + +mod paths; +mod proton; + +// Re-export path detection utilities +pub use paths::{ + detect_steam_path_checked, find_steam_path, find_userdata_path, + get_steam_accounts, +}; + +// Re-export Proton detection +pub use proton::{find_steam_protons, SteamProton}; + +use std::fs; + +/// Kill Steam process gracefully, then force if needed +pub fn kill_steam() -> Result<(), Box<dyn std::error::Error>> { + use std::process::Command; + + // Try steam -shutdown first (graceful) + let _ = Command::new("steam") + .arg("-shutdown") + .status(); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + // Then force kill if still running + let _ = Command::new("pkill") + .arg("-9") + .arg("steam") + .status(); + + // Brief wait for Steam to fully exit + std::thread::sleep(std::time::Duration::from_secs(2)); + + Ok(()) +} + +/// Start Steam in background +pub fn start_steam() -> Result<(), Box<dyn std::error::Error>> { + use std::process::{Command, Stdio}; + + Command::new("setsid") + .arg("steam") + .arg("-silent") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + Ok(()) +} + +/// Restart Steam (kill then start) +pub fn restart_steam() -> Result<(), Box<dyn std::error::Error>> { + kill_steam()?; + start_steam()?; + Ok(()) +} + +// ============================================================================ +// STEAM_COMPAT_MOUNTS Detection +// ============================================================================ + +/// Directories that pressure-vessel already exposes by default +const ALREADY_EXPOSED: &[&str] = &[ + "bin", "etc", "home", "lib", "lib32", "lib64", + "overrides", "run", "sbin", "tmp", "usr", "var", +]; + +/// System directories that shouldn't be mounted +const SYSTEM_DIRS: &[&str] = &[ + "proc", "sys", "dev", "boot", "root", "lost+found", "snap", +]; + +/// Detect directories at root that need to be added to STEAM_COMPAT_MOUNTS +pub fn detect_extra_mounts() -> Vec<String> { + let mut mounts = Vec::new(); + + let Ok(entries) = fs::read_dir("/") else { + return mounts; + }; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + + if ALREADY_EXPOSED.contains(&name.as_str()) { + continue; + } + + if SYSTEM_DIRS.contains(&name.as_str()) { + continue; + } + + if name.starts_with('.') { + continue; + } + + if entry.path().is_dir() { + mounts.push(format!("/{}", name)); + } + } + + mounts.sort(); + mounts +} + +/// Generate launch options string with DXVK config file and STEAM_COMPAT_MOUNTS +pub fn generate_launch_options(dxvk_conf_path: Option<&std::path::Path>, is_electron_app: bool) -> String { + let mounts = detect_extra_mounts(); + + let dxvk_part = match dxvk_conf_path { + Some(path) => format!( + "DXVK_CONFIG_FILE=\"{}\"", + crate::config::normalize_path_for_steam(&path.to_string_lossy()) + ), + None => String::new(), + }; + + let electron_flags = if is_electron_app { + " --disable-gpu --no-sandbox" + } else { + "" + }; + + match (dxvk_part.is_empty(), mounts.is_empty()) { + (true, true) => format!("%command%{}", electron_flags), + (true, false) => format!("STEAM_COMPAT_MOUNTS={} %command%{}", mounts.join(":"), electron_flags), + (false, true) => format!("{} %command%{}", dxvk_part, electron_flags), + (false, false) => format!("{} STEAM_COMPAT_MOUNTS={} %command%{}", dxvk_part, mounts.join(":"), electron_flags), + } +} diff --git a/libs/nak/src/steam/paths.rs b/libs/nak/src/steam/paths.rs new file mode 100644 index 0000000..8ef5013 --- /dev/null +++ b/libs/nak/src/steam/paths.rs @@ -0,0 +1,259 @@ +//! Steam path detection utilities + +use std::fs; +use std::path::PathBuf; + +use crate::logging::{log_info, log_warning}; + +// ============================================================================ +// Core Path Detection +// ============================================================================ + +/// Find the Steam installation path. +#[must_use] +pub fn find_steam_path() -> Option<PathBuf> { + let home = std::env::var("HOME").ok()?; + + let steam_paths = [ + format!("{}/.steam/steam", home), + format!("{}/.local/share/Steam", home), + format!("{}/.var/app/com.valvesoftware.Steam/.steam/steam", home), + format!("{}/snap/steam/common/.steam/steam", home), + ]; + + steam_paths + .iter() + .map(PathBuf::from) + .find(|p| p.exists()) +} + +/// Find the Steam userdata directory. +#[must_use] +pub fn find_userdata_path() -> Option<PathBuf> { + let config = crate::config::AppConfig::load(); + if !config.selected_steam_account.is_empty() { + if let Some(path) = find_userdata_path_for_account(&config.selected_steam_account) { + return Some(path); + } + } + + let steam_path = find_steam_path()?; + let userdata = steam_path.join("userdata"); + + if !userdata.exists() { + return None; + } + + let accounts = get_steam_accounts(); + + if let Some(most_recent) = accounts.iter().find(|a| a.most_recent) { + let path = userdata.join(&most_recent.account_id); + if path.exists() { + log_info(&format!( + "Using Steam account from loginusers.vdf (MostRecent): {} ({})", + most_recent.persona_name, most_recent.account_id + )); + return Some(path); + } + } + + if let Some(first_account) = accounts.first() { + let path = userdata.join(&first_account.account_id); + if path.exists() { + log_info(&format!( + "Using Steam account from loginusers.vdf (most recent timestamp): {} ({})", + first_account.persona_name, first_account.account_id + )); + return Some(path); + } + } + + log_warning("Could not determine active Steam account from loginusers.vdf, falling back to directory modification time"); + + let mut user_dirs: Vec<PathBuf> = Vec::new(); + + if let Ok(entries) = fs::read_dir(&userdata) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name() { + let name_str = name.to_string_lossy(); + if name_str != "0" && name_str.chars().all(|c| c.is_ascii_digit()) { + user_dirs.push(path); + } + } + } + } + } + + user_dirs.sort_by(|a, b| { + let a_time = fs::metadata(a).and_then(|m| m.modified()).ok(); + let b_time = fs::metadata(b).and_then(|m| m.modified()).ok(); + b_time.cmp(&a_time) + }); + + user_dirs.into_iter().next() +} + +// ============================================================================ +// Steam Account Detection (loginusers.vdf parsing) +// ============================================================================ + +/// Information about a Steam user account +#[derive(Debug, Clone)] +pub struct SteamAccount { + pub account_id: String, + pub persona_name: String, + pub most_recent: bool, + pub timestamp: u64, +} + +/// Get all Steam accounts from loginusers.vdf with their display names +#[must_use] +pub fn get_steam_accounts() -> Vec<SteamAccount> { + let Some(steam_path) = find_steam_path() else { + return Vec::new(); + }; + + let loginusers_path = steam_path.join("config/loginusers.vdf"); + let userdata_path = steam_path.join("userdata"); + + let Ok(content) = fs::read_to_string(&loginusers_path) else { + return Vec::new(); + }; + + let mut accounts = Vec::new(); + + let mut current_steam_id: Option<String> = None; + let mut current_account: Option<SteamAccountBuilder> = None; + + for line in content.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with('"') && trimmed.ends_with('"') { + let id = trimmed.trim_matches('"'); + if id.len() == 17 && id.starts_with("7656") && id.chars().all(|c| c.is_ascii_digit()) { + if let (Some(steam_id), Some(builder)) = (current_steam_id.take(), current_account.take()) { + if let Some(account) = builder.build(&steam_id, &userdata_path) { + accounts.push(account); + } + } + current_steam_id = Some(id.to_string()); + current_account = Some(SteamAccountBuilder::default()); + } + } + + if let Some(ref mut builder) = current_account { + if let Some((key, value)) = parse_vdf_kv(trimmed) { + match key.to_lowercase().as_str() { + "accountname" => builder.account_name = Some(value), + "personaname" => builder.persona_name = Some(value), + "mostrecent" => builder.most_recent = value == "1", + "timestamp" => builder.timestamp = value.parse().unwrap_or(0), + _ => {} + } + } + } + } + + if let (Some(steam_id), Some(builder)) = (current_steam_id, current_account) { + if let Some(account) = builder.build(&steam_id, &userdata_path) { + accounts.push(account); + } + } + + accounts.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + accounts +} + +#[derive(Default)] +struct SteamAccountBuilder { + account_name: Option<String>, + persona_name: Option<String>, + most_recent: bool, + timestamp: u64, +} + +impl SteamAccountBuilder { + fn build(self, steam_id: &str, userdata_base: &std::path::Path) -> Option<SteamAccount> { + let account_name = self.account_name?; + let persona_name = self.persona_name.unwrap_or_else(|| account_name.clone()); + + let steam64: u64 = steam_id.parse().ok()?; + let account_id = (steam64 - 76561197960265728).to_string(); + + let userdata_path = userdata_base.join(&account_id); + + if !userdata_path.exists() { + return None; + } + + Some(SteamAccount { + account_id, + persona_name, + most_recent: self.most_recent, + timestamp: self.timestamp, + }) + } +} + +/// Parse a VDF key-value pair like: "Key" "Value" +fn parse_vdf_kv(line: &str) -> Option<(String, String)> { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + + for c in line.chars() { + match c { + '"' => { + if in_quotes { + parts.push(current.clone()); + current.clear(); + } + in_quotes = !in_quotes; + } + _ if in_quotes => current.push(c), + _ => {} + } + } + + if parts.len() >= 2 { + Some((parts[0].clone(), parts[1].clone())) + } else { + None + } +} + +/// Find the userdata path for a specific Steam account +#[must_use] +pub fn find_userdata_path_for_account(account_id: &str) -> Option<PathBuf> { + let steam_path = find_steam_path()?; + let userdata = steam_path.join("userdata").join(account_id); + + if userdata.exists() { + Some(userdata) + } else { + None + } +} + +// ============================================================================ +// Convenience Wrappers +// ============================================================================ + +/// Detect the Steam installation path with logging. +#[must_use] +pub fn detect_steam_path_checked() -> Option<String> { + match find_steam_path() { + Some(path) => { + let path_str = path.to_string_lossy().to_string(); + log_info(&format!("Steam detected at: {}", path_str)); + Some(path_str) + } + None => { + log_warning("Steam installation not detected! NaK requires Steam to be installed."); + None + } + } +} diff --git a/libs/nak/src/steam/proton.rs b/libs/nak/src/steam/proton.rs new file mode 100644 index 0000000..064fd96 --- /dev/null +++ b/libs/nak/src/steam/proton.rs @@ -0,0 +1,257 @@ +//! Steam Proton detection and management +//! +//! Finds Protons that Steam can see and use for non-Steam games. +//! This includes Steam's built-in Protons and custom Protons in compatibilitytools.d. + +use std::fs; +use std::path::PathBuf; + +use super::find_steam_path; + +/// Information about an installed Proton version +#[derive(Debug, Clone)] +pub struct SteamProton { + /// Display name (e.g., "GE-Proton9-20", "Proton Experimental") + pub name: String, + /// Internal name used in config.vdf (e.g., "proton_experimental", "GE-Proton9-20") + pub config_name: String, + /// Full path to the Proton installation + pub path: PathBuf, + /// Whether this is a Steam-provided Proton (vs custom) + pub is_steam_proton: bool, + /// Whether this is Proton Experimental + pub is_experimental: bool, +} + +impl SteamProton { + /// Get the path to the wine binary. + pub fn wine_binary(&self) -> Option<PathBuf> { + let paths = [ + self.path.join("files/bin/wine"), + self.path.join("dist/bin/wine"), + ]; + paths.into_iter().find(|p| p.exists()) + } + + /// Get the path to the wineserver binary. + pub fn wineserver_binary(&self) -> Option<PathBuf> { + let paths = [ + self.path.join("files/bin/wineserver"), + self.path.join("dist/bin/wineserver"), + ]; + paths.into_iter().find(|p| p.exists()) + } + + /// Get the bin directory containing wine executables. + pub fn bin_dir(&self) -> Option<PathBuf> { + self.wine_binary().and_then(|p| p.parent().map(|p| p.to_path_buf())) + } +} + +/// Find all Protons that Steam can use (Proton 10+ only) +pub fn find_steam_protons() -> Vec<SteamProton> { + let mut protons = Vec::new(); + + let Some(steam_path) = find_steam_path() else { + return protons; + }; + + let is_flatpak = steam_path.to_string_lossy().contains(".var/app/com.valvesoftware.Steam"); + + // 1. Steam's built-in Protons (steamapps/common/Proton*) + protons.extend(find_builtin_protons(&steam_path)); + + // 2. Custom Protons in user's compatibilitytools.d + protons.extend(find_custom_protons(&steam_path)); + + // 3. System-level Protons in /usr/share/steam/compatibilitytools.d/ + if is_flatpak { + crate::logging::log_info("Flatpak Steam detected - skipping system protons in /usr/share/steam/compatibilitytools.d/"); + } else { + protons.extend(find_system_protons()); + } + + // Filter to only include Proton 10+ (required for Steam-native integration) + protons.retain(is_proton_10_or_newer); + + // Filter to only include Protons with valid wine binaries + protons.retain(|p| { + let has_wine = p.wine_binary().is_some(); + if !has_wine { + crate::logging::log_warning(&format!( + "Skipping Proton '{}': wine binary not found at expected paths (files/bin/wine or dist/bin/wine)", + p.name + )); + } + has_wine + }); + + // Sort: Experimental first, then by name descending (newest first) + protons.sort_by(|a, b| { + if a.is_experimental != b.is_experimental { + return b.is_experimental.cmp(&a.is_experimental); + } + b.name.cmp(&a.name) + }); + + protons +} + +/// Check if a Proton version is 10 or newer +fn is_proton_10_or_newer(proton: &SteamProton) -> bool { + let name = &proton.name; + + if proton.is_experimental || name.contains("Experimental") { + return true; + } + + if name.contains("CachyOS") { + return true; + } + + if name == "LegacyRuntime" || name.contains("Runtime") { + return false; + } + + if name.starts_with("GE-Proton") { + if let Some(version_part) = name.strip_prefix("GE-Proton") { + let major: Option<u32> = version_part + .split('-') + .next() + .and_then(|s| s.parse().ok()); + return major.map(|v| v >= 10).unwrap_or(false); + } + } + + if name.starts_with("Proton ") { + if let Some(version_part) = name.strip_prefix("Proton ") { + let major: Option<u32> = version_part + .split('.') + .next() + .and_then(|s| s.parse().ok()); + return major.map(|v| v >= 10).unwrap_or(false); + } + } + + if name.starts_with("EM-") { + if let Some(version_part) = name.strip_prefix("EM-") { + let major: Option<u32> = version_part + .split('.') + .next() + .and_then(|s| s.parse().ok()); + return major.map(|v| v >= 10).unwrap_or(false); + } + } + + // Unknown format - allow it + true +} + +/// Find Steam's built-in Proton versions +fn find_builtin_protons(steam_path: &std::path::Path) -> Vec<SteamProton> { + let mut found = Vec::new(); + let common_dir = steam_path.join("steamapps/common"); + + let Ok(entries) = fs::read_dir(&common_dir) else { + return found; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + + if name.starts_with("Proton") && path.join("proton").exists() { + let is_experimental = name.contains("Experimental"); + + let config_name = if is_experimental { + "proton_experimental".to_string() + } else { + let version = name.replace("Proton ", ""); + let major = version.split('.').next().unwrap_or(&version); + format!("proton_{}", major) + }; + + found.push(SteamProton { + name: name.clone(), + config_name, + path, + is_steam_proton: true, + is_experimental, + }); + } + } + + found +} + +/// Find custom Protons in compatibilitytools.d +fn find_custom_protons(steam_path: &std::path::Path) -> Vec<SteamProton> { + let mut found = Vec::new(); + let compat_dir = steam_path.join("compatibilitytools.d"); + + let Ok(entries) = fs::read_dir(&compat_dir) else { + return found; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + + let has_proton = path.join("proton").exists(); + let has_vdf = path.join("compatibilitytool.vdf").exists(); + + if has_proton || has_vdf { + found.push(SteamProton { + name: name.clone(), + config_name: name.clone(), + path, + is_steam_proton: false, + is_experimental: false, + }); + } + } + + found +} + +/// Find system-level Protons in /usr/share/steam/compatibilitytools.d/ +fn find_system_protons() -> Vec<SteamProton> { + let mut found = Vec::new(); + let system_compat_dir = PathBuf::from("/usr/share/steam/compatibilitytools.d"); + + let Ok(entries) = fs::read_dir(&system_compat_dir) else { + return found; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + + let has_proton = path.join("proton").exists(); + let has_vdf = path.join("compatibilitytool.vdf").exists(); + + if has_proton || has_vdf { + found.push(SteamProton { + name: name.clone(), + config_name: name.clone(), + path, + is_steam_proton: false, + is_experimental: false, + }); + } + } + + found +} |
