Phase 2. The lease, the settings, the events and the status surface were already platform-neutral; what Windows lacked was a way to see processes and a way to ask one to close. `procscan` splits per-OS behind one contract. The Windows matcher is a Toolhelp snapshot plus each process's full image path (`QueryFullProcessImageNameW`), with creation times from `GetProcessTimes` enforcing the two rules the module exists for: never adopt a process that predates the launch, never trust a bare pid. Both matter more here than on Linux — the host is SYSTEM, so it can see and signal everything, and Windows recycles pids briskly. Path matching is case-insensitive and separator-aware, and normalizes the `\\?\` prefix `canonicalize` adds, without which a store-derived path would never compare equal to a live image path. There is no reaper argv and no readable environment on Windows, so a spec carrying only a Steam appid or an env marker matches nothing rather than falling through to an empty predicate. Steam's appid instead feeds a **veto**: its per-app `Running` flag in the user's hive can't be trusted to say a game IS running (Steam sets it around updates too, and leaves it stale after a crash), but it is exactly right for refusing to declare one gone. If the matcher can't see a game whose exe sits outside its manifest's install dir, ending the session would be a false positive the player feels immediately; honoring the veto only ever leaves a stream up. Termination asks before it insists, which on Windows needs a detail that fails silently if missed: `EnumWindows` only sees the calling thread's desktop, and the host's is session 0, which holds none of the user's windows. So the terminating thread binds to the input desktop first (the pattern `pf-inject`'s `sendinput.rs` uses), posts `WM_CLOSE` to the game's visible top-level windows, waits, and only then calls `TerminateProcess` on freshly re-verified pids. Without the desktop bind the polite pass finds nothing and every game dies unsaved. Job Objects, planned as WP2.3, are deferred with the reasoning in the design doc: every Windows launch either hands off through a launcher or the shell — where a job would wrap a shim that exits immediately — or is a direct exe whose path we already know, and wiring one in would touch the `CreateProcessAsUserW` token path the UAC and secure-desktop work depends on. Also: the watcher's steady-state poll now re-verifies known pids and only re-scans the whole table once they all vanish (which still catches a game that re-execs). On Windows a full scan is an `OpenProcess` per process on the box, so this is the difference between a negligible and a noticeable poll. Gates: Linux .21 check/clippy/fmt/293 tests green; Windows .133 check + clippy --all-targets clean, and the 4 procscan tests pass — including the live one that finds the test process in the real process table and rejects a wrong creation time. On-glass Windows (Steam/Epic/GOG/Xbox titles, the unsaved-progress check) still owed; it needs a box with games on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
93 lines
4.2 KiB
Rust
93 lines
4.2 KiB
Rust
//! Finding a launched game's processes, from the signals its store gave us
|
|
//! ([`crate::library::DetectSpec`]).
|
|
//!
|
|
//! Read-only by construction: the host enumerates processes and reads metadata it already has
|
|
//! permission to see. No ptrace, no injection, no handles held open. On Linux it looks only at
|
|
//! processes owned by its **own uid**; on Windows it runs as SYSTEM and therefore *can* see
|
|
//! everything, which makes the two rules below the load-bearing part rather than an afterthought.
|
|
//!
|
|
//! ### The two rules
|
|
//!
|
|
//! 1. **Never adopt a process that predates the launch.** A player may already have the game open
|
|
//! when a session starts; treating that instance as "this session's game" would let a session end
|
|
//! kill a process it never started. Candidates are filtered by start time against
|
|
//! [`launch_stamp`], taken before anything spawns.
|
|
//! 2. **Never trust a bare pid.** Pids are recycled — aggressively so on Windows — and a lease can
|
|
//! outlive its game by a grace window, so every remembered process carries its start time and is
|
|
//! re-verified against it ([`Scanner::alive`]) before it is counted as running, or signalled.
|
|
//!
|
|
//! ### Per-OS implementations
|
|
//!
|
|
//! The two have nothing in common beyond that contract, so they are separate modules presenting the
|
|
//! same [`Scanner`] surface: `/proc` on Linux, a Toolhelp snapshot on Windows. Everything above the
|
|
//! scanner ([`crate::gamelease`]) is platform-neutral.
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod linux;
|
|
#[cfg(target_os = "linux")]
|
|
pub use linux::Scanner;
|
|
|
|
#[cfg(windows)]
|
|
mod windows;
|
|
#[cfg(windows)]
|
|
pub use windows::Scanner;
|
|
|
|
/// A process the matcher adopted: its pid plus a start stamp that pins that pid to *this* process,
|
|
/// so a recycled pid can never be mistaken for it.
|
|
///
|
|
/// `start` is opaque and only ever compared for equality against a later read of the same pid — its
|
|
/// units differ per platform (clock ticks since boot on Linux, a creation `FILETIME` on Windows).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct ProcRef {
|
|
pub pid: u32,
|
|
pub start: u64,
|
|
}
|
|
|
|
/// Tolerance on the "started after the launch" test, in seconds.
|
|
///
|
|
/// The launch stamp is taken just before the spawn, but start times are quantized (~10 ms on Linux)
|
|
/// and a launcher can race ahead of the host's own bookkeeping, so an exact comparison would
|
|
/// occasionally reject the real game. Two seconds is far below the time any launcher takes to bring a
|
|
/// game up, so it cannot let a *pre-existing* instance through.
|
|
pub const START_SLACK_SECS: f64 = 2.0;
|
|
|
|
/// The reference instant for adopting a launch's processes, in **seconds on the platform's
|
|
/// process-start timeline** — seconds since boot on Linux, seconds since the Windows epoch on
|
|
/// Windows. Only ever compared against a process's own start time on the same platform, never
|
|
/// interpreted as a wall clock or persisted.
|
|
///
|
|
/// Call it **before** anything spawns; see [`crate::gamelease::LeaseRequest::launch_stamp`]. `None`
|
|
/// when the platform has no matcher (macOS) or the clock could not be read, which disables the
|
|
/// start-time filter rather than rejecting everything.
|
|
pub fn launch_stamp() -> Option<f64> {
|
|
#[cfg(any(target_os = "linux", windows))]
|
|
{
|
|
Scanner::system().now_stamp()
|
|
}
|
|
#[cfg(not(any(target_os = "linux", windows)))]
|
|
{
|
|
None
|
|
}
|
|
}
|
|
|
|
/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan.
|
|
///
|
|
/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the
|
|
/// primary signal. `Some(true)` = something else believes it is up, so hold off; `Some(false)` = that
|
|
/// something agrees it is gone; `None` = no opinion available, which is the common case.
|
|
///
|
|
/// Linux has none by design: Steam's launch reaper is both the sharpest signal and already a *process*,
|
|
/// so it is covered by the scan itself. Windows has no reaper, which is exactly where a second opinion
|
|
/// earns its keep.
|
|
pub fn running_hint(spec: &crate::library::DetectSpec) -> Option<bool> {
|
|
#[cfg(windows)]
|
|
{
|
|
spec.steam_appid.and_then(windows::steam_running_hint)
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
let _ = spec;
|
|
None
|
|
}
|
|
}
|