diff options
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 +} |
