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>
118 lines
4.5 KiB
Rust
118 lines
4.5 KiB
Rust
//! 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:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|