diff --git a/Cargo.lock b/Cargo.lock index ebcf214..f473116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4894,6 +4894,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f655a10..fe13518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,3 +65,12 @@ rfd = { version = "0.17", default-features = false } # Windows audio backend: cpal drives WASAPI for capture/playback behind the # AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire. cpal = "0.15" +# Win32 FFI for game detection (no new crate: windows-sys is already pulled in +# transitively by cpal/rfd). Registry reads the Steam RunningAppID + install path; +# Toolhelp enumerates running processes for the non-Steam process-scan fallback. +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_Registry", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", +] } diff --git a/src/game/mod.rs b/src/game/mod.rs index 8c1511a..f583e93 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -12,6 +12,8 @@ //! ([`scan`]) — feed already-parsed values into these pure functions, and the //! cancellable poll service ([`detector`]) wires them together. +pub mod scan; +pub mod steam; pub mod vdf; use std::collections::{BTreeMap, BTreeSet}; diff --git a/src/game/scan.rs b/src/game/scan.rs new file mode 100644 index 0000000..84c6fc6 --- /dev/null +++ b/src/game/scan.rs @@ -0,0 +1,117 @@ +//! Running-process enumeration for the non-Steam detection fallback (D6/D7): +//! native adapters only — `/proc` on Linux, Toolhelp on Windows — so there is no +//! `sysinfo` dependency and the audit surface stays small. +//! +//! This module is *just the OS edge*: it returns the list of running executable +//! paths/names. The trustworthy part — turning that list into a game via the +//! user's explicit mappings and the launcher denylist — is the pure +//! [`match_processes`](super::match_processes), unit-tested in the parent module. + +/// Enumerate the executables of currently-running processes as paths/basenames. +/// Best-effort: processes we can't introspect (other users') are skipped rather +/// than erroring. The result is fed to [`match_processes`](super::match_processes), +/// which normalizes each entry to a basename before matching. +pub fn running_executables() -> Vec { + #[cfg(target_os = "linux")] + { + linux_proc_executables() + } + #[cfg(windows)] + { + windows_toolhelp_executables() + } + #[cfg(not(any(target_os = "linux", windows)))] + { + Vec::new() + } +} + +#[cfg(target_os = "linux")] +fn linux_proc_executables() -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return out; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + // Only numeric entries are processes. + if !name.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let proc_dir = entry.path(); + // Prefer the real exe path (full, untruncated); fall back to `comm`, which + // is readable for all processes but truncated to 15 bytes. + if let Ok(exe) = std::fs::read_link(proc_dir.join("exe")) + && let Some(s) = exe.to_str() + { + out.push(s.to_string()); + continue; + } + if let Ok(comm) = std::fs::read_to_string(proc_dir.join("comm")) { + let trimmed = comm.trim(); + if !trimmed.is_empty() { + out.push(trimmed.to_string()); + } + } + } + out +} + +#[cfg(windows)] +fn windows_toolhelp_executables() -> Vec { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + let mut out = Vec::new(); + // SAFETY: standard Toolhelp snapshot of all processes; handle checked below. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return out; + } + let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + entry.dwSize = std::mem::size_of::() as u32; + // SAFETY: entry is zeroed with dwSize set, as Process32FirstW requires. + let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) }; + while ok != 0 { + // szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe). + let end = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len()); + let name = String::from_utf16_lossy(&entry.szExeFile[..end]); + if !name.is_empty() { + out.push(name); + } + // SAFETY: same valid snapshot + entry struct. + ok = unsafe { Process32NextW(snapshot, &mut entry) }; + } + // SAFETY: snapshot handle came from CreateToolhelp32Snapshot above. + unsafe { CloseHandle(snapshot) }; + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "linux")] + #[test] + fn enumerates_at_least_this_process() { + // The test runner itself is a process, so /proc enumeration must be + // non-empty and include something that normalizes to our own exe basename. + let exes = running_executables(); + assert!(!exes.is_empty(), "expected to see running processes via /proc"); + // Our own /proc/self/exe basename should appear among them. + let me = std::fs::read_link("/proc/self/exe") + .ok() + .and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned())); + if let Some(me) = me { + let me_norm = super::super::normalize_exe(&me); + assert!( + exes.iter().any(|e| super::super::normalize_exe(e) == me_norm), + "running list should include our own executable {me_norm:?}" + ); + } + } +} diff --git a/src/game/steam.rs b/src/game/steam.rs new file mode 100644 index 0000000..b8d2985 --- /dev/null +++ b/src/game/steam.rs @@ -0,0 +1,430 @@ +//! 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; + +/// 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`. Linux/macOS read the client's + /// `registry.vdf`; Windows reads the real registry. + fn running_app_id(&self) -> Option { + #[cfg(windows)] + { + win::running_app_id() + } + #[cfg(not(windows))] + { + 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 + } + } + + /// 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 +} + +#[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 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 || len == 0 { + // SAFETY: valid handle. + unsafe { RegCloseKey(hkey) }; + return None; + } + let mut buf = vec![0u16; (len as usize).div_ceil(2)]; + 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 { + return None; + } + // Trim the trailing NUL(s). + while buf.last() == Some(&0) { + buf.pop(); + } + Some(PathBuf::from(String::from_utf16_lossy(&buf))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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()); + } + + #[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); + } +}