The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
515 lines
19 KiB
Rust
515 lines
19 KiB
Rust
//! Game detection, game-presence, and game-reactive backgrounds.
|
|
//!
|
|
//! A single local "what game is running" detector feeds two consumers:
|
|
//! 1. **Local** — a per-game UI background that auto-switches (extends W16).
|
|
//! 2. **Broadcast** — a `Playing <name>` status next to our avatar in every peer's
|
|
//! roster, riding the gossip presence plane like nickname + avatar.
|
|
//!
|
|
//! This module is structured testable-seams-first: the *pure* logic lives here
|
|
//! (the stable-id scheme, the priority [`resolve`] matcher, the [`Debouncer`], and
|
|
//! the process-name [`match_processes`] mapping), unit-tested with zero I/O. The OS
|
|
//! edges — Steam state/file reads ([`steam`]) and the running-process scan
|
|
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
|
|
//! cancellable poll service ([`detector`]) wires them together.
|
|
|
|
pub mod detector;
|
|
pub mod scan;
|
|
pub mod steam;
|
|
pub mod vdf;
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
/// Where a detected game came from. Encodes the trust/priority tier directly:
|
|
/// a manual override beats live Steam state, which beats a matched process. Used
|
|
/// only for prioritization and as a presentation hint — never trusted as identity.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum GameSource {
|
|
/// The user forced a specific game (or "none") via the manual override.
|
|
Manual,
|
|
/// Steam's live `RunningAppID` resolved against an `appmanifest`.
|
|
Steam,
|
|
/// A running process matched against the user's process→name mappings.
|
|
Process,
|
|
}
|
|
|
|
/// A game the local detector currently believes is running.
|
|
///
|
|
/// `id` is the stable, namespaced identity used as the config key for backgrounds
|
|
/// (`steam:730`, `exe:hl2_linux`) — **never** the mutable display name. `name` is
|
|
/// the human label shown locally and broadcast as presence; it is `None` only for
|
|
/// the Steam appid-without-manifest case, where the background can still switch by
|
|
/// `id` but nothing is broadcast (per the "don't invent `Steam App 123`" rule).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DetectedGame {
|
|
/// Stable namespaced identity. Config-key safe; survives renames.
|
|
pub id: String,
|
|
/// Trustworthy human name; `None` = id-only (Steam manifest unavailable).
|
|
pub name: Option<String>,
|
|
/// Provenance / priority tier.
|
|
pub source: GameSource,
|
|
}
|
|
|
|
impl DetectedGame {
|
|
/// The Steam namespaced id for an appid: `steam:<appid>`.
|
|
pub fn steam_id(app_id: u32) -> String {
|
|
format!("steam:{app_id}")
|
|
}
|
|
|
|
/// The process namespaced id for an executable identity: `exe:<normalized>`.
|
|
pub fn exe_id(exe: &str) -> String {
|
|
format!("exe:{}", normalize_exe(exe))
|
|
}
|
|
}
|
|
|
|
/// The user's manual override sitting above both detectors (D2). Small by design.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub enum ManualOverride {
|
|
/// Trust the auto-detector (default).
|
|
#[default]
|
|
Auto,
|
|
/// Force "not playing anything" regardless of what is detected.
|
|
ForceNone,
|
|
/// Force a specific game (the user picked it from the known-games list).
|
|
Force(DetectedGame),
|
|
}
|
|
|
|
/// The outcome of [`resolve`]: the chosen game (if any) plus whether the choice is
|
|
/// a manual override and so should **bypass the [`Debouncer`]** (apply immediately).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Resolution {
|
|
pub game: Option<DetectedGame>,
|
|
/// `true` when a manual override (`ForceNone`/`Force`) decided the value.
|
|
pub immediate: bool,
|
|
}
|
|
|
|
/// Apply the detector priority (D2 / §5): **manual override → Steam → mapped
|
|
/// process → none**. Pure; the adapters resolve `steam`/`processes` into
|
|
/// `DetectedGame`s and this only picks the winner. `processes` is in the adapter's
|
|
/// deterministic priority order (see [`match_processes`]); its first entry wins.
|
|
pub fn resolve(
|
|
override_: &ManualOverride,
|
|
steam: Option<DetectedGame>,
|
|
processes: &[DetectedGame],
|
|
) -> Resolution {
|
|
match override_ {
|
|
ManualOverride::ForceNone => Resolution {
|
|
game: None,
|
|
immediate: true,
|
|
},
|
|
ManualOverride::Force(g) => Resolution {
|
|
game: Some(g.clone()),
|
|
immediate: true,
|
|
},
|
|
ManualOverride::Auto => {
|
|
let game = steam.or_else(|| processes.first().cloned());
|
|
Resolution {
|
|
game,
|
|
immediate: false,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Samples required before a *new* game is accepted/switched to.
|
|
pub const ACCEPT_HITS: u32 = 2;
|
|
/// Consecutive "no game" samples before a currently-shown game is cleared. At the
|
|
/// ~3 s poll cadence this is ~9 s, absorbing a brief Steam stale/crash blip.
|
|
pub const CLEAR_MISSES: u32 = 3;
|
|
|
|
/// Debounces a stream of raw per-poll detections into a stable published value, so
|
|
/// a flapping detector can't repeatedly re-announce the entire `PeerState` (which
|
|
/// can carry the ~48 KB avatar). Pure state machine — the service feeds it samples
|
|
/// and re-announces only when [`observe`](Debouncer::observe) reports a change.
|
|
///
|
|
/// A switch to a different game needs [`ACCEPT_HITS`] matching samples; clearing a
|
|
/// game needs [`CLEAR_MISSES`] consecutive misses. A manual override
|
|
/// (`immediate = true`) applies at once, bypassing both counters.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Debouncer {
|
|
current: Option<DetectedGame>,
|
|
pending: Option<DetectedGame>,
|
|
pending_hits: u32,
|
|
misses: u32,
|
|
}
|
|
|
|
impl Debouncer {
|
|
/// The currently published, debounced value.
|
|
pub fn current(&self) -> Option<&DetectedGame> {
|
|
self.current.as_ref()
|
|
}
|
|
|
|
/// Feed one poll result. `immediate` (a manual override is active) bypasses the
|
|
/// debounce. Returns `true` iff the published [`current`](Self::current) value
|
|
/// changed — the signal for the service to re-announce presence / switch the
|
|
/// background.
|
|
pub fn observe(&mut self, sample: Option<DetectedGame>, immediate: bool) -> bool {
|
|
if immediate {
|
|
let changed = self.current != sample;
|
|
self.current = sample;
|
|
self.pending = None;
|
|
self.pending_hits = 0;
|
|
self.misses = 0;
|
|
return changed;
|
|
}
|
|
match sample {
|
|
Some(game) => {
|
|
self.misses = 0;
|
|
if self.current.as_ref() == Some(&game) {
|
|
// Already publishing this game; drop any half-counted switch.
|
|
self.pending = None;
|
|
self.pending_hits = 0;
|
|
false
|
|
} else {
|
|
if self.pending.as_ref() == Some(&game) {
|
|
self.pending_hits += 1;
|
|
} else {
|
|
self.pending = Some(game);
|
|
self.pending_hits = 1;
|
|
}
|
|
if self.pending_hits >= ACCEPT_HITS {
|
|
self.current = self.pending.take();
|
|
self.pending_hits = 0;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
// A miss never counts toward a *switch*; drop any pending candidate.
|
|
self.pending = None;
|
|
self.pending_hits = 0;
|
|
if self.current.is_some() {
|
|
self.misses += 1;
|
|
if self.misses >= CLEAR_MISSES {
|
|
self.current = None;
|
|
self.misses = 0;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Normalize a raw executable name/path to a stable identity for matching and ids:
|
|
/// take the final path component (handling both `/` and `\\` separators) and
|
|
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
|
|
/// hypothetical `minecraft`), trims surrounding whitespace.
|
|
pub fn normalize_exe(raw: &str) -> String {
|
|
raw.rsplit(['/', '\\'])
|
|
.next()
|
|
.unwrap_or(raw)
|
|
.trim()
|
|
.to_lowercase()
|
|
}
|
|
|
|
/// Launcher/helper executables that must NEVER be reported as a game even if a
|
|
/// mapping names them — defense against a mis-entered mapping turning the launcher
|
|
/// itself into "the game". Normalized (lowercase basename) for comparison.
|
|
const BUILTIN_DENYLIST: &[&str] = &[
|
|
"steam",
|
|
"steam.exe",
|
|
"steamwebhelper",
|
|
"steamwebhelper.exe",
|
|
"steamerrorreporter",
|
|
"gameoverlayui",
|
|
"reaper",
|
|
"lutris",
|
|
"heroic",
|
|
"heroic.exe",
|
|
"legendary",
|
|
"gogdl",
|
|
"wine",
|
|
"wine64",
|
|
"wineserver",
|
|
"wine-preloader",
|
|
"proton",
|
|
"pressure-vessel-wrap",
|
|
"explorer.exe",
|
|
"services.exe",
|
|
"svchost.exe",
|
|
];
|
|
|
|
/// The built-in launcher/helper denylist as a set, for membership checks.
|
|
pub fn builtin_denylist() -> BTreeSet<&'static str> {
|
|
BUILTIN_DENYLIST.iter().copied().collect()
|
|
}
|
|
|
|
/// Match the currently-running executables against the user's explicit
|
|
/// process→display-name mappings, returning detected games in **deterministic
|
|
/// priority order** (sorted by stable id) with duplicates removed.
|
|
///
|
|
/// Conservative by construction (§3): only exact normalized-basename matches to a
|
|
/// user mapping count — we never guess that an arbitrary long-running process is a
|
|
/// game. Any executable on `denylist` is rejected even if mapped, so a launcher or
|
|
/// helper can't be promoted to "the game".
|
|
///
|
|
/// `user_map` keys are matched against the normalized basename of each running
|
|
/// entry; the key itself is normalized too, so the caller may store either
|
|
/// `Half-Life 2` style display values keyed by `hl2_linux` or `HL2_Linux`.
|
|
pub fn match_processes(
|
|
running: &[String],
|
|
user_map: &BTreeMap<String, String>,
|
|
denylist: &BTreeSet<&str>,
|
|
) -> Vec<DetectedGame> {
|
|
// Normalize the user map once so lookups are basename/case-insensitive.
|
|
let normalized_map: BTreeMap<String, &String> = user_map
|
|
.iter()
|
|
.map(|(k, v)| (normalize_exe(k), v))
|
|
.collect();
|
|
|
|
let mut seen: BTreeSet<String> = BTreeSet::new();
|
|
let mut out: Vec<DetectedGame> = Vec::new();
|
|
for raw in running {
|
|
let norm = normalize_exe(raw);
|
|
if norm.is_empty() || denylist.contains(norm.as_str()) {
|
|
continue;
|
|
}
|
|
if let Some(name) = normalized_map.get(&norm) {
|
|
let id = format!("exe:{norm}");
|
|
if seen.insert(id.clone()) {
|
|
out.push(DetectedGame {
|
|
id,
|
|
name: Some((*name).clone()),
|
|
source: GameSource::Process,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// Deterministic priority: stable order independent of process-scan order.
|
|
out.sort_by(|a, b| a.id.cmp(&b.id));
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn steam_game(app_id: u32, name: &str) -> DetectedGame {
|
|
DetectedGame {
|
|
id: DetectedGame::steam_id(app_id),
|
|
name: Some(name.to_string()),
|
|
source: GameSource::Steam,
|
|
}
|
|
}
|
|
|
|
// --- ids / normalization ----------------------------------------------
|
|
|
|
#[test]
|
|
fn stable_ids_are_namespaced() {
|
|
assert_eq!(DetectedGame::steam_id(730), "steam:730");
|
|
assert_eq!(
|
|
DetectedGame::exe_id("/usr/games/hl2_linux"),
|
|
"exe:hl2_linux"
|
|
);
|
|
assert_eq!(
|
|
DetectedGame::exe_id("C:\\Games\\Minecraft.exe"),
|
|
"exe:minecraft.exe"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_handles_both_separators_and_case() {
|
|
assert_eq!(normalize_exe("/opt/Foo/Bar.x86_64"), "bar.x86_64");
|
|
assert_eq!(normalize_exe("D:\\a\\b\\GAME.EXE"), "game.exe");
|
|
assert_eq!(normalize_exe(" spaced.bin "), "spaced.bin");
|
|
assert_eq!(normalize_exe("bare"), "bare");
|
|
}
|
|
|
|
// --- resolve priority --------------------------------------------------
|
|
|
|
#[test]
|
|
fn resolve_prefers_steam_over_process_in_auto() {
|
|
let steam = steam_game(730, "CS2");
|
|
let procs = vec![DetectedGame {
|
|
id: "exe:foo".into(),
|
|
name: Some("Foo".into()),
|
|
source: GameSource::Process,
|
|
}];
|
|
let r = resolve(&ManualOverride::Auto, Some(steam.clone()), &procs);
|
|
assert_eq!(r.game, Some(steam));
|
|
assert!(!r.immediate);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_falls_back_to_first_process_then_none() {
|
|
let procs = vec![
|
|
DetectedGame {
|
|
id: "exe:a".into(),
|
|
name: Some("A".into()),
|
|
source: GameSource::Process,
|
|
},
|
|
DetectedGame {
|
|
id: "exe:b".into(),
|
|
name: Some("B".into()),
|
|
source: GameSource::Process,
|
|
},
|
|
];
|
|
let r = resolve(&ManualOverride::Auto, None, &procs);
|
|
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
|
|
let none = resolve(&ManualOverride::Auto, None, &[]);
|
|
assert_eq!(none.game, None);
|
|
assert!(!none.immediate);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_manual_override_wins_and_is_immediate() {
|
|
let steam = steam_game(730, "CS2");
|
|
// ForceNone overrides a live Steam detection, immediately.
|
|
let r = resolve(&ManualOverride::ForceNone, Some(steam.clone()), &[]);
|
|
assert_eq!(r.game, None);
|
|
assert!(r.immediate);
|
|
// Force(x) overrides too.
|
|
let forced = steam_game(220, "HL2");
|
|
let r = resolve(&ManualOverride::Force(forced.clone()), Some(steam), &[]);
|
|
assert_eq!(r.game, Some(forced));
|
|
assert!(r.immediate);
|
|
}
|
|
|
|
// --- debounce ----------------------------------------------------------
|
|
|
|
#[test]
|
|
fn debounce_requires_two_hits_to_switch() {
|
|
let mut d = Debouncer::default();
|
|
let g = steam_game(730, "CS2");
|
|
// First sighting: not yet published.
|
|
assert!(!d.observe(Some(g.clone()), false));
|
|
assert_eq!(d.current(), None);
|
|
// Second consecutive sighting: now published.
|
|
assert!(d.observe(Some(g.clone()), false));
|
|
assert_eq!(d.current(), Some(&g));
|
|
// Steady state: same game, no further change events.
|
|
assert!(!d.observe(Some(g.clone()), false));
|
|
}
|
|
|
|
#[test]
|
|
fn debounce_requires_three_misses_to_clear() {
|
|
let mut d = Debouncer::default();
|
|
let g = steam_game(730, "CS2");
|
|
d.observe(Some(g.clone()), false);
|
|
d.observe(Some(g.clone()), false);
|
|
assert_eq!(d.current(), Some(&g));
|
|
// Two misses: still shown (absorbs a transient blip).
|
|
assert!(!d.observe(None, false));
|
|
assert!(!d.observe(None, false));
|
|
assert_eq!(d.current(), Some(&g));
|
|
// Third miss: cleared.
|
|
assert!(d.observe(None, false));
|
|
assert_eq!(d.current(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn debounce_blip_during_clear_resets_miss_count() {
|
|
let mut d = Debouncer::default();
|
|
let g = steam_game(730, "CS2");
|
|
d.observe(Some(g.clone()), false);
|
|
d.observe(Some(g.clone()), false);
|
|
// Miss, miss, then the game reappears: miss count resets, stays published.
|
|
d.observe(None, false);
|
|
d.observe(None, false);
|
|
assert!(!d.observe(Some(g.clone()), false));
|
|
assert_eq!(d.current(), Some(&g));
|
|
// It now takes a fresh run of three misses to clear.
|
|
d.observe(None, false);
|
|
d.observe(None, false);
|
|
assert!(d.observe(None, false));
|
|
assert_eq!(d.current(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn debounce_immediate_bypasses_counters() {
|
|
let mut d = Debouncer::default();
|
|
let g = steam_game(730, "CS2");
|
|
// A manual override publishes on the first sample.
|
|
assert!(d.observe(Some(g.clone()), true));
|
|
assert_eq!(d.current(), Some(&g));
|
|
// ForceNone clears immediately.
|
|
assert!(d.observe(None, true));
|
|
assert_eq!(d.current(), None);
|
|
// Re-issuing the same immediate value is not a change.
|
|
d.observe(Some(g.clone()), true);
|
|
assert!(!d.observe(Some(g.clone()), true));
|
|
}
|
|
|
|
#[test]
|
|
fn debounce_switching_games_needs_two_hits_of_the_new_one() {
|
|
let mut d = Debouncer::default();
|
|
let a = steam_game(1, "A");
|
|
let b = steam_game(2, "B");
|
|
d.observe(Some(a.clone()), false);
|
|
d.observe(Some(a.clone()), false);
|
|
assert_eq!(d.current(), Some(&a));
|
|
// One sample of B does not switch.
|
|
assert!(!d.observe(Some(b.clone()), false));
|
|
assert_eq!(d.current(), Some(&a));
|
|
// Second consecutive B switches.
|
|
assert!(d.observe(Some(b.clone()), false));
|
|
assert_eq!(d.current(), Some(&b));
|
|
}
|
|
|
|
// --- process matching --------------------------------------------------
|
|
|
|
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
|
pairs
|
|
.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn match_processes_matches_only_explicit_mappings() {
|
|
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
|
let deny = builtin_denylist();
|
|
let running = vec![
|
|
"/usr/bin/firefox".to_string(),
|
|
"/games/Half-Life 2/hl2_linux".to_string(),
|
|
"/usr/bin/htop".to_string(),
|
|
];
|
|
let got = match_processes(&running, &user, &deny);
|
|
assert_eq!(got.len(), 1);
|
|
assert_eq!(got[0].id, "exe:hl2_linux");
|
|
assert_eq!(got[0].name.as_deref(), Some("Half-Life 2"));
|
|
assert_eq!(got[0].source, GameSource::Process);
|
|
}
|
|
|
|
#[test]
|
|
fn match_processes_rejects_denylisted_even_if_mapped() {
|
|
// A mis-entered mapping naming the Steam client must not win.
|
|
let user = map(&[("steam", "Steam (oops)"), ("mygame", "My Game")]);
|
|
let deny = builtin_denylist();
|
|
let running = vec!["/usr/bin/steam".into(), "/opt/mygame".into()];
|
|
let got = match_processes(&running, &user, &deny);
|
|
assert_eq!(got.len(), 1);
|
|
assert_eq!(got[0].id, "exe:mygame");
|
|
}
|
|
|
|
#[test]
|
|
fn match_processes_is_deterministic_and_deduped() {
|
|
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
|
|
let deny = builtin_denylist();
|
|
// Same game twice (two processes) + reverse discovery order.
|
|
let running = vec!["/b/zed".into(), "/a/alpha".into(), "/c/alpha".into()];
|
|
let got = match_processes(&running, &user, &deny);
|
|
// Deduped to two, sorted by id (alpha before zed) regardless of scan order.
|
|
assert_eq!(
|
|
got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(),
|
|
vec!["exe:alpha", "exe:zed"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match_processes_ignores_unmapped_and_case_folds() {
|
|
let user = map(&[("Game.x86_64", "The Game")]);
|
|
let deny = builtin_denylist();
|
|
let running = vec!["/x/GAME.X86_64".into(), "/y/random".into()];
|
|
let got = match_processes(&running, &user, &deny);
|
|
assert_eq!(got.len(), 1);
|
|
assert_eq!(got[0].name.as_deref(), Some("The Game"));
|
|
}
|
|
}
|