//! 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 ` 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, /// Provenance / priority tier. pub source: GameSource, } impl DetectedGame { /// The Steam namespaced id for an appid: `steam:`. pub fn steam_id(app_id: u32) -> String { format!("steam:{app_id}") } /// The process namespaced id for an executable identity: `exe:`. 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, /// `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, 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, pending: Option, 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, 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, denylist: &BTreeSet<&str>, ) -> Vec { // Normalize the user map once so lookups are basename/case-insensitive. let normalized_map: BTreeMap = user_map .iter() .map(|(k, v)| (normalize_exe(k), v)) .collect(); let mut seen: BTreeSet = BTreeSet::new(); let mut out: Vec = 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 { 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!["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")); } }