feat(game): Steam + process-scan OS adapters

Step 2-3 of game detection (game-presence-plan.md). The OS edges feeding
the pure seams from the previous commit.

- src/game/steam.rs: SteamProbe — reads the live RunningAppID and resolves
  it to a name via appmanifest_<id>.acf (no binary appinfo.vdf). Pure parse
  fns (parse_running_app_id / parse_library_paths / parse_app_name) over
  file contents are unit-tested incl. current+legacy libraryfolders shapes,
  escaped Windows paths, empty/missing names, and garbage. Roots discovered
  across native/Flatpak/Snap (Linux) and the registry (Windows); libraries
  and resolved names cached + mtime-invalidated so the 3s poll doesn't
  rescan. File reads byte-capped.
- src/game/scan.rs: native running-process enumeration — /proc (exe symlink,
  comm fallback) on Linux, Toolhelp on Windows — feeding the pure
  match_processes. No sysinfo dep (D7).
- Cargo.toml: windows-sys as a direct Windows-only dep for the registry +
  Toolhelp FFI. No NEW crate — it was already in the lockfile transitively
  via cpal/rfd, so the audit surface is unchanged.

391 lib tests (+5). Linux: build + clippy --all-targets clean. Windows FFI
signatures verified against windows-sys 0.61 source (one *const vs *mut
lpReserved fixed) but NOT yet cross-compiled — defer to the post-UI Windows
build cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 15:19:53 -04:00
co-authored by Claude Opus 4.8
parent 87a2209a85
commit e31d3db986
5 changed files with 559 additions and 0 deletions
Generated
+1
View File
@@ -4894,6 +4894,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"windows-sys 0.61.2",
]
[[package]]
+9
View File
@@ -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",
] }
+2
View File
@@ -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};
+117
View File
@@ -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<String> {
#[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<String> {
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<String> {
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::<PROCESSENTRY32W>() 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:?}"
);
}
}
}
+430
View File
@@ -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_<appid>.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<u32> {
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<PathBuf> {
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::<u32>().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_<appid>.acf`. Pure.
pub fn parse_app_name(appmanifest_acf: &str) -> Option<String> {
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<String> {
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<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
/// A library list cached against its source file's mtime.
#[derive(Default)]
struct CachedLibraries {
source: Option<PathBuf>,
mtime: Option<SystemTime>,
paths: Vec<PathBuf>,
}
/// 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<SystemTime>,
name: Option<String>,
}
/// 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<PathBuf>,
libraries: CachedLibraries,
manifests: HashMap<u32, CachedManifest>,
}
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<DetectedGame> {
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<u32> {
#[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_<appid>.acf` across the known libraries.
fn app_name(&mut self, app_id: u32) -> Option<String> {
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<PathBuf> {
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<PathBuf> {
// 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<PathBuf> {
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<PathBuf> {
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<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent.
fn open_steam_key() -> Option<HKEY> {
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<u32> {
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::<u32>() 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<PathBuf> {
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);
}
}