//! Steam detection adapter: the primary signal (D1). Reads Steam's live //! `RunningAppID` and resolves it to a display name via the plain-text //! `appmanifest_.acf`, with no dependency on the binary `appinfo.vdf`. //! //! The *parsing* is pure and unit-tested ([`parse_running_app_id`], //! [`parse_library_paths`], [`parse_app_name`], all over file contents). The fs / //! Windows-registry reads are the thin edge, and [`SteamProbe`] caches roots, //! library list, and resolved names — invalidating by mtime — so the 3 s detector //! poll does not rescan every library each tick (Codex hardening). use super::vdf::{self, Value}; use super::{DetectedGame, GameSource}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::SystemTime; /// Max bytes read from any single Steam state file. These are small text files /// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being /// slurped into memory before the parser's own depth guard kicks in. const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024; /// SteamPath is a local filesystem path. Four KiB is deliberately generous and /// prevents a corrupt registry length from driving an enormous allocation. #[cfg(any(windows, test))] const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024; #[cfg(any(windows, test))] fn validate_reg_len(len: u32) -> Option { (len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES) .then_some(len as usize / 2) } #[cfg(any(windows, test))] fn decode_reg_sz(mut buf: Vec, returned_bytes: u32) -> Option { let units = validate_reg_len(returned_bytes)?; if units > buf.len() { return None; } buf.truncate(units); while buf.last() == Some(&0) { buf.pop(); } Some(String::from_utf16_lossy(&buf)) } /// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS /// client's emulated-registry text file). Returns the appid only when present and /// nonzero — `0`/absent is the "no game" state. Pure. pub fn parse_running_app_id(registry_vdf: &str) -> Option { let root = vdf::parse(registry_vdf).ok()?; let raw = root .get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"]) .and_then(Value::as_str)?; let id: u32 = raw.trim().parse().ok()?; (id != 0).then_some(id) } /// Parse the library folder paths out of a `libraryfolders.vdf`, handling **both** /// the current shape (`"0" { "path" "..." }`) and the legacy shape /// (`"1" "/path"`, the path as a direct string value). Non-numeric keys /// (`contentstatsid`, …) are skipped. Pure; paths are returned as-is (escapes /// already decoded by the VDF parser), including ones on offline drives — the /// caller checks existence. pub fn parse_library_paths(libraryfolders_vdf: &str) -> Vec { let Ok(root) = vdf::parse(libraryfolders_vdf) else { return Vec::new(); }; // The root may or may not wrap entries in a "libraryfolders" object. let container = root.get("libraryfolders").unwrap_or(&root); let mut out = Vec::new(); for (key, val) in container.entries() { // Only numeric-keyed entries are library folders. if key.parse::().is_err() { continue; } let path = match val { Value::Str(s) => Some(s.as_str()), Value::Obj(_) => val.get("path").and_then(Value::as_str), }; if let Some(p) = path && !p.is_empty() { out.push(PathBuf::from(p)); } } out } /// Parse the human `name` out of an `appmanifest_.acf`. Pure. pub fn parse_app_name(appmanifest_acf: &str) -> Option { let root = vdf::parse(appmanifest_acf).ok()?; root.get_path(&["AppState", "name"]) .and_then(Value::as_str) .map(|s| s.to_string()) .filter(|s| !s.is_empty()) } /// Read at most [`MAX_STEAM_FILE_BYTES`] of a file as UTF-8 (lossy), or `None` if /// it is missing/unreadable. The thin fs edge under the pure parsers above. fn read_capped(path: &Path) -> Option { use std::io::Read; let file = std::fs::File::open(path).ok()?; let mut buf = Vec::new(); file.take(MAX_STEAM_FILE_BYTES).read_to_end(&mut buf).ok()?; Some(String::from_utf8_lossy(&buf).into_owned()) } fn mtime_of(path: &Path) -> Option { std::fs::metadata(path).ok()?.modified().ok() } /// A library list cached against its source file's mtime. #[derive(Default)] struct CachedLibraries { source: Option, mtime: Option, paths: Vec, } /// A per-appid resolved name cached against the manifest's mtime. `name` is `None` /// when the manifest exists but carries no usable name, or wasn't found. struct CachedManifest { mtime: Option, name: Option, } /// Stateful Steam probe with mtime-invalidated caches. Construct once and call /// [`detect`](Self::detect) each poll; all reads are blocking, so the detector /// service runs it off the async worker. pub struct SteamProbe { roots: Vec, libraries: CachedLibraries, manifests: HashMap, } impl Default for SteamProbe { fn default() -> Self { Self::new() } } impl SteamProbe { pub fn new() -> Self { Self { roots: discover_roots(), libraries: CachedLibraries::default(), manifests: HashMap::new(), } } /// One detection pass: read the live `RunningAppID`, and if a game is running, /// resolve its name from the appmanifest (cached). Returns a `DetectedGame` /// with `name: None` when the appid is known but no manifest name is available /// — the background can still switch by id, but presence must not invent a name. pub fn detect(&mut self) -> Option { let app_id = self.running_app_id()?; let name = self.app_name(app_id); Some(DetectedGame { id: DetectedGame::steam_id(app_id), name, source: GameSource::Steam, }) } /// The live RunningAppID (nonzero), or `None`. /// /// Platform notes: on **Windows** the real registry's `RunningAppID` is updated /// live, so we read it. On **Linux** the client's `registry.vdf` is only /// rewritten on Steam *shutdown* — it's stale while a game runs — so the live /// signal is the running game process's `SteamAppId` environment variable /// (`/proc//environ`, readable for our own processes; the same approach /// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix /// (macOS) only has the `registry.vdf` fallback for now. fn running_app_id(&self) -> Option { #[cfg(windows)] { win::running_app_id() } #[cfg(target_os = "linux")] { running_app_id_from_environ().or_else(registry_running_app_id) } #[cfg(not(any(windows, target_os = "linux")))] { registry_running_app_id() } } /// Resolve (and cache) the display name for an appid by locating its /// `appmanifest_.acf` across the known libraries. fn app_name(&mut self, app_id: u32) -> Option { let manifest = self.find_manifest(app_id)?; let mtime = mtime_of(&manifest); if let Some(cached) = self.manifests.get(&app_id) && cached.mtime == mtime { return cached.name.clone(); } let name = read_capped(&manifest).and_then(|c| parse_app_name(&c)); self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() }); name } /// The path to an appid's manifest, if it exists in any library. fn find_manifest(&mut self, app_id: u32) -> Option { let filename = format!("appmanifest_{app_id}.acf"); for lib in self.library_paths() { let candidate = lib.join("steamapps").join(&filename); if candidate.exists() { return Some(candidate); } } None } /// All Steam library folder paths, cached and refreshed only when the source /// `libraryfolders.vdf` changes (mtime). Discovered from the known roots. fn library_paths(&mut self) -> Vec { // Locate the libraryfolders.vdf to watch (first existing across roots). let source = self .roots .iter() .map(|r| r.join("steamapps").join("libraryfolders.vdf")) .find(|p| p.exists()); let mtime = source.as_deref().and_then(mtime_of); if self.libraries.source == source && self.libraries.mtime == mtime && source.is_some() { return self.libraries.paths.clone(); } let mut paths = Vec::new(); if let Some(ref src) = source && let Some(contents) = read_capped(src) { paths = parse_library_paths(&contents); } // Always include the roots themselves: the install dir is an implicit // library even if libraryfolders.vdf is missing or lists only extras. for root in &self.roots { if !paths.contains(root) { paths.push(root.clone()); } } self.libraries = CachedLibraries { source, mtime, paths: paths.clone() }; paths } } /// Candidate Steam install roots that actually exist on this machine (each is a /// directory containing a `steamapps` folder). Covers native, Flatpak, and Snap /// layouts on Linux; on Windows the install path comes from the registry. fn discover_roots() -> Vec { let mut roots = Vec::new(); #[cfg(windows)] { if let Some(p) = win::install_path() { roots.push(p); } } #[cfg(not(windows))] { if let Some(home) = dirs::home_dir() { for rel in [ ".steam/steam", ".steam/root", ".local/share/Steam", ".var/app/com.valvesoftware.Steam/.local/share/Steam", "snap/steam/common/.local/share/Steam", ] { roots.push(home.join(rel)); } } } // Keep only roots that exist and look like a Steam install. roots.retain(|p| p.join("steamapps").is_dir()); roots.sort(); roots.dedup(); roots } /// Candidate `registry.vdf` locations (Linux/macOS emulated registry). #[cfg(not(windows))] fn registry_vdf_candidates() -> Vec { let mut out = Vec::new(); if let Some(home) = dirs::home_dir() { out.push(home.join(".steam/registry.vdf")); out.push(home.join(".steam/steam/registry.vdf")); out.push(home.join(".var/app/com.valvesoftware.Steam/.steam/registry.vdf")); out.push(home.join("snap/steam/common/.steam/registry.vdf")); } out } /// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a /// game runs (Steam rewrites the file only on shutdown), so this is a *fallback* /// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal. #[cfg(not(windows))] fn registry_running_app_id() -> Option { for path in registry_vdf_candidates() { if let Some(contents) = read_capped(&path) && let Some(id) = parse_running_app_id(&contents) { return Some(id); } } None } /// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated /// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every /// game process. Returns the appid only when present and nonzero. Pure + /// unit-tested; the `/proc` iteration is the thin edge in /// [`running_app_id_from_environ`]. #[cfg(target_os = "linux")] pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option { for kv in environ.split(|&b| b == 0) { if let Some(val) = kv.strip_prefix(b"SteamAppId=") && let Ok(s) = std::str::from_utf8(val) && let Ok(id) = s.trim().parse::() && id != 0 { return Some(id); } } None } /// The live Steam appid of a running game, found by scanning `/proc//environ` /// for the `SteamAppId` Steam exports to the game's process tree. `environ` is /// readable only for our own processes — exactly the ones a Steam game we launched /// runs as — and we skip the rest. The live signal that replaces the stale /// on-disk `registry.vdf` on Linux. #[cfg(target_os = "linux")] fn running_app_id_from_environ() -> Option { let entries = std::fs::read_dir("/proc").ok()?; for entry in entries.flatten() { let name = entry.file_name(); let Some(name) = name.to_str() else { continue }; if !name.bytes().all(|b| b.is_ascii_digit()) { continue; } // Cap the read: an environ is small; this bounds a pathological case. if let Some(environ) = read_capped(&entry.path().join("environ")) && let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes()) { return Some(id); } } None } #[cfg(windows)] mod win { //! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg` //! crate. Steam stores both the live `RunningAppID` and its install path under //! `HKCU\Software\Valve\Steam`. use super::{decode_reg_sz, validate_reg_len}; use std::path::PathBuf; use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::System::Registry::{ RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, }; /// UTF-16, NUL-terminated, for a Win32 wide-string argument. fn wide(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } /// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent. fn open_steam_key() -> Option { let subkey = wide("Software\\Valve\\Steam"); let mut hkey: HKEY = std::ptr::null_mut(); // SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle. let rc = unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) }; (rc == ERROR_SUCCESS).then_some(hkey) } /// The live `RunningAppID` REG_DWORD, nonzero, or `None`. pub fn running_app_id() -> Option { let hkey = open_steam_key()?; let name = wide("RunningAppID"); let mut kind: u32 = 0; let mut data: u32 = 0; let mut len = std::mem::size_of::() as u32; // SAFETY: out-params sized for a DWORD; data buffer is a u32 we own. let rc = unsafe { RegQueryValueExW( hkey, name.as_ptr(), std::ptr::null(), &mut kind, &mut data as *mut u32 as *mut u8, &mut len, ) }; // SAFETY: handle came from RegOpenKeyExW above. unsafe { RegCloseKey(hkey) }; if rc == ERROR_SUCCESS && kind == REG_DWORD && data != 0 { Some(data) } else { None } } /// The Steam install directory from `HKCU\...\Steam\SteamPath`, if it exists. pub fn install_path() -> Option { let hkey = open_steam_key()?; let name = wide("SteamPath"); let mut kind: u32 = 0; let mut len: u32 = 0; // First query the size. // SAFETY: null data ptr with a zeroed len asks for the required size. let rc = unsafe { RegQueryValueExW( hkey, name.as_ptr(), std::ptr::null(), &mut kind, std::ptr::null_mut(), &mut len, ) }; if rc != ERROR_SUCCESS || kind != REG_SZ { // SAFETY: valid handle. unsafe { RegCloseKey(hkey) }; return None; } let Some(units) = validate_reg_len(len) else { // SAFETY: valid handle. unsafe { RegCloseKey(hkey) }; return None; }; let mut buf = vec![0u16; units]; let mut len2 = len; // SAFETY: buffer sized to the queried byte length. let rc = unsafe { RegQueryValueExW( hkey, name.as_ptr(), std::ptr::null(), &mut kind, buf.as_mut_ptr() as *mut u8, &mut len2, ) }; // SAFETY: valid handle. unsafe { RegCloseKey(hkey) }; if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len { return None; } Some(PathBuf::from(decode_reg_sz(buf, len2)?)) } } #[cfg(test)] mod tests { use super::*; #[test] fn registry_string_lengths_are_bounded_and_trimmed() { assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16"); assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None); assert_eq!(validate_reg_len(8), Some(4)); let raw = "C:\\Steam\0ignored".encode_utf16().collect::>(); let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32; assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam")); } #[test] fn running_app_id_reads_nonzero_and_rejects_zero() { let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" { "RunningAppID" "440" } } } } }"#; assert_eq!(parse_running_app_id(running), Some(440)); let idle = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" { "RunningAppID" "0" } } } } }"#; assert_eq!(parse_running_app_id(idle), None); // Missing key / garbage → None, no panic. assert_eq!(parse_running_app_id(r#""Registry" { }"#), None); assert_eq!(parse_running_app_id("not vdf at all {{{"), None); } #[test] fn library_paths_handles_current_and_legacy_shapes() { let current = r#""libraryfolders" { "0" { "path" "/home/eric/.local/share/Steam" "label" "" } "1" { "path" "/mnt/games/SteamLibrary" } "contentstatsid" "12345" }"#; let got = parse_library_paths(current); assert_eq!(got, vec![ PathBuf::from("/home/eric/.local/share/Steam"), PathBuf::from("/mnt/games/SteamLibrary"), ]); // Legacy shape: numeric keys map straight to path strings. let legacy = r#""LibraryFolders" { "TimeNextStatsReport" "9999" "ContentStatsID" "42" "1" "/mnt/old/SteamLibrary" }"#; let got = parse_library_paths(legacy); assert_eq!(got, vec![PathBuf::from("/mnt/old/SteamLibrary")]); } #[test] fn library_paths_empty_on_garbage() { assert!(parse_library_paths("totally broken {{{").is_empty()); } #[cfg(target_os = "linux")] #[test] fn steam_app_id_parsed_from_environ_blob() { // A realistic NUL-separated environ with SteamAppId among other vars. let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0"; assert_eq!(parse_steam_app_id_from_environ(environ), Some(440)); // Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored. assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None); // Absent → None (a non-Steam process). assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None); // Not fooled by a different var that merely contains the substring. assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None); // Garbage value → None, no panic. assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None); } #[test] fn app_name_extracts_and_filters_empty() { let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#; assert_eq!(parse_app_name(acf), Some("Team Fortress 2".to_string())); // Empty name → None (don't broadcast a blank). let blank = r#""AppState" { "appid" "440" "name" "" }"#; assert_eq!(parse_app_name(blank), None); // Missing name → None. assert_eq!(parse_app_name(r#""AppState" { "appid" "440" }"#), None); } }