diff --git a/src/background.rs b/src/background.rs index d74076a..3fd46eb 100644 --- a/src/background.rs +++ b/src/background.rs @@ -45,6 +45,22 @@ pub fn process_background(raw: &[u8]) -> Result, String> { Ok(png.into_inner()) } +/// A filesystem-safe, app-owned filename for the processed PNG of a per-game +/// background (W18), derived from the game's stable id by hashing rather than +/// embedding the raw id: keeps the name short and safe (ids contain `:` and +/// arbitrary executable basenames) and avoids leaking the id into the filesystem. +/// Deterministic and dependency-free (FNV-1a 64-bit), so the same game id always +/// maps to the same file. +pub fn game_background_filename(game_id: &str) -> String { + // FNV-1a, 64-bit. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for b in game_id.as_bytes() { + hash ^= *b as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("game-bg-{hash:016x}.png") +} + /// The legibility scrim drawn between the background image and the UI: the active /// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim` /// recedes the image so body text and panel chrome stay readable, and it re-tints @@ -90,6 +106,17 @@ mod tests { assert!(process_background(b"definitely not an image").is_err()); } + #[test] + fn game_background_filename_is_stable_safe_and_distinct() { + let a = game_background_filename("steam:730"); + // Stable for the same id. + assert_eq!(a, game_background_filename("steam:730")); + // Distinct ids → distinct files (no `:` or path chars leak through). + assert_ne!(a, game_background_filename("exe:hl2_linux")); + assert!(a.starts_with("game-bg-") && a.ends_with(".png")); + assert!(!a.contains(':') && !a.contains('/') && !a.contains('\\')); + } + #[test] fn scrim_color_sets_alpha_and_keeps_rgb() { let base = Color::from_rgb(0.1, 0.2, 0.3); diff --git a/src/config.rs b/src/config.rs index eb73c19..20ef286 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,7 @@ use crate::notify::Sound; use crate::theme::AppTheme; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::PathBuf; @@ -199,6 +199,26 @@ pub struct AppConfig { /// `crate::background::scrim_color`. #[serde(default = "default_background_dim")] pub background_dim: f32, + /// Broadcast the detected game as presence next to our avatar (game-detection + /// feature). **Opt-in, default OFF.** Enabling immediately publishes the + /// current game; disabling immediately publishes `game: None`. Toggling this + /// is the only thing that puts our game on the wire — detection itself (for the + /// local background) runs regardless. + #[serde(default)] + pub game_presence_enabled: bool, + /// Per-game UI background overrides (W18), keyed by stable game id + /// (`steam:730`, `exe:hl2_linux`) → path to the processed PNG we wrote in the + /// config dir (see `game_background_path`). The running game's entry wins; with + /// no entry we fall back to the single custom `background`. Local-only; never + /// sent to peers. `BTreeMap` for deterministic serialization. + #[serde(default)] + pub game_backgrounds: BTreeMap, + /// User process→display-name mappings for non-Steam game detection, keyed by + /// normalized executable basename (`hl2_linux`) → the name to show/broadcast + /// (`Half-Life 2`). Only exact mappings here are ever matched (we never guess a + /// game from an arbitrary process). Local-only. + #[serde(default)] + pub game_process_map: BTreeMap, /// What a call recording captures (mixed / per-peer stems / both). #[serde(default)] pub recording_mode: RecordingMode, @@ -305,6 +325,9 @@ impl Default for AppConfig { avatar: crate::avatar::Avatar::default(), background: None, background_dim: default_background_dim(), + game_presence_enabled: false, + game_backgrounds: BTreeMap::new(), + game_process_map: BTreeMap::new(), recording_mode: RecordingMode::default(), custom_sound_self_join: None, custom_sound_peer_join: None, @@ -375,17 +398,31 @@ impl AppConfig { }) } - /// Path the processed custom-background PNG (W16) is written to, alongside - /// `config.json` in the app config dir. We store our own downscaled copy here - /// (rather than base64 in the config) so the JSON stays small. - pub fn background_path() -> Option { + /// Path to a processed-background PNG of the given filename, alongside + /// `config.json` in the app config dir. We store our own downscaled copies here + /// (rather than base64 in the config) so the JSON stays small. Used for both + /// the single custom background and the per-game backgrounds. + fn background_dir_path(filename: &str) -> Option { dirs::config_dir().map(|mut p| { p.push("peerspeak"); - p.push("background.png"); + p.push(filename); p }) } + /// Path the single custom-background PNG (W16) is written to. + pub fn background_path() -> Option { + Self::background_dir_path("background.png") + } + + /// Path the processed per-game background PNG (W18) for `game_id` is written + /// to. The filename is an app-owned hash of the id (see + /// `crate::background::game_background_filename`), so raw game ids never appear + /// on disk and the name is always filesystem-safe. + pub fn game_background_path(game_id: &str) -> Option { + Self::background_dir_path(&crate::background::game_background_filename(game_id)) + } + pub fn load() -> Self { if let Some(path) = Self::config_path() && let Ok(contents) = fs::read_to_string(&path) @@ -480,6 +517,51 @@ mod tests { ); } + #[test] + fn test_backward_compat_game_detection_fields() { + // A config that predates the game-detection feature (W18) — and crucially + // still carries the W16 single `background` as a plain string — must + // deserialize without error. `AppConfig::load()` silently replaces ANY + // deserialize failure with full defaults, so a broken migration here would + // wipe everyone's settings; this guards that the additive fields kept the + // old shape loadable and that `background` was NOT retyped. + let legacy_json = r#"{ + "input_device": "", + "output_device": "", + "noise_gate_threshold": 0.01, + "username": "Eric", + "background": "/home/eric/.config/peerspeak/background.png", + "background_dim": 0.4 + }"#; + let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap(); + // The pre-existing single background survives untouched (still Option). + assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png")); + assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON); + // The new game-detection fields default to off/empty → silent, opt-in upgrade. + assert!(!cfg.game_presence_enabled); + assert!(cfg.game_backgrounds.is_empty()); + assert!(cfg.game_process_map.is_empty()); + } + + #[test] + fn test_game_maps_serialize_deterministically() { + // BTreeMap ordering makes the serialized config stable across runs. + let mut cfg = AppConfig::default(); + cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into()); + cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into()); + cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into()); + let json = serde_json::to_string(&cfg).unwrap(); + // Keys appear in sorted order (exe: before steam:). + let bg = json.find("game_backgrounds").unwrap(); + let exe_at = json[bg..].find("exe:hl2_linux").unwrap(); + let steam_at = json[bg..].find("steam:730").unwrap(); + assert!(exe_at < steam_at, "BTreeMap keys must serialize sorted"); + // Full round-trip preserves the maps. + let back: AppConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.game_backgrounds, cfg.game_backgrounds); + assert_eq!(back.game_process_map, cfg.game_process_map); + } + #[test] fn test_window_size_fields() { // Default impl is the standard launch size. diff --git a/src/game/mod.rs b/src/game/mod.rs new file mode 100644 index 0000000..8c1511a --- /dev/null +++ b/src/game/mod.rs @@ -0,0 +1,480 @@ +//! 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 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")); + } +} diff --git a/src/game/vdf.rs b/src/game/vdf.rs new file mode 100644 index 0000000..30f438f --- /dev/null +++ b/src/game/vdf.rs @@ -0,0 +1,340 @@ +//! A small, defensive parser for Valve's KeyValues / VDF text format, used by +//! `appmanifest_.acf`, `libraryfolders.vdf`, and `~/.steam/registry.vdf`. +//! +//! Pure (operates on already-read file *contents*) and unit-tested, per the +//! testable-seams-first workflow — the file I/O and size caps live in the Steam +//! adapter. Deliberately a real recursive-descent KeyValues parser rather than a +//! `"name"`-line regex: escapes, nesting, and truncation will eventually break a +//! regex (Codex's "use a real VDF parser" hardening). Hardened against hostile +//! input with a recursion-depth cap, so a deeply nested file errors instead of +//! overflowing the stack, and never panics on malformed/truncated input. + +/// Max object nesting depth accepted before bailing out. Real Steam files nest a +/// handful of levels (`registry.vdf` is the deepest at ~6); this is generous while +/// still bounding a malicious file. +const MAX_DEPTH: usize = 32; + +/// A parsed KeyValues value: either a leaf string or a nested object. Child order +/// is preserved and duplicate keys are kept (KeyValues permits them); lookups +/// return the first match. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + Str(String), + Obj(Vec<(String, Value)>), +} + +impl Value { + /// The leaf string at this node, if it is a string (not an object). + pub fn as_str(&self) -> Option<&str> { + match self { + Value::Str(s) => Some(s), + Value::Obj(_) => None, + } + } + + /// The first child value under `key`, if this is an object containing it. + /// Case-insensitive on the key (KeyValues keys are conventionally + /// case-insensitive, and Steam is inconsistent, e.g. `AppState`/`appid`). + pub fn get(&self, key: &str) -> Option<&Value> { + match self { + Value::Obj(pairs) => pairs + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, v)| v), + Value::Str(_) => None, + } + } + + /// Follow a chain of object keys, returning the value at the end of the path. + /// `root.get_path(&["AppState", "name"])`. + pub fn get_path<'a>(&'a self, path: &[&str]) -> Option<&'a Value> { + let mut cur = self; + for key in path { + cur = cur.get(key)?; + } + Some(cur) + } + + /// Iterate the (key, value) child pairs if this is an object. + pub fn entries(&self) -> &[(String, Value)] { + match self { + Value::Obj(pairs) => pairs, + Value::Str(_) => &[], + } + } +} + +/// Parse KeyValues/VDF text into a top-level object (the sequence of root +/// key→value pairs). Returns `Err` on unbalanced braces, a key with no value, or +/// nesting past [`MAX_DEPTH`]. Never panics. +pub fn parse(input: &str) -> Result { + let mut lexer = Lexer { rest: input }; + let obj = parse_object(&mut lexer, 0, true)?; + Ok(Value::Obj(obj)) +} + +/// Parse a run of `key value` pairs. `top_level` parses until EOF; otherwise it +/// parses until a closing `}` (which it consumes). +fn parse_object( + lexer: &mut Lexer, + depth: usize, + top_level: bool, +) -> Result, String> { + if depth > MAX_DEPTH { + return Err("VDF nesting too deep".to_string()); + } + let mut pairs = Vec::new(); + loop { + match lexer.next_token()? { + None => { + if top_level { + return Ok(pairs); + } + return Err("unexpected end of input inside object".to_string()); + } + Some(Token::Close) => { + if top_level { + return Err("unexpected '}' at top level".to_string()); + } + return Ok(pairs); + } + Some(Token::Open) => { + return Err("expected key, found '{'".to_string()); + } + Some(Token::Str(key)) => { + // A key must be followed by a value: a string or a nested object. + match lexer.next_token()? { + Some(Token::Str(val)) => pairs.push((key, Value::Str(val))), + Some(Token::Open) => { + let child = parse_object(lexer, depth + 1, false)?; + pairs.push((key, Value::Obj(child))); + } + Some(Token::Close) => { + return Err(format!("key '{key}' has no value (found '}}')")); + } + None => return Err(format!("key '{key}' has no value (end of input)")), + } + } + } + } +} + +enum Token { + Open, + Close, + Str(String), +} + +struct Lexer<'a> { + rest: &'a str, +} + +impl Lexer<'_> { + /// Produce the next token, skipping whitespace and `//` line comments. + fn next_token(&mut self) -> Result, String> { + loop { + self.rest = self.rest.trim_start(); + if self.rest.is_empty() { + return Ok(None); + } + // Line comments: `//` to end of line. + if let Some(after) = self.rest.strip_prefix("//") { + match after.find('\n') { + Some(nl) => self.rest = &after[nl + 1..], + None => { + self.rest = ""; + return Ok(None); + } + } + continue; + } + let mut chars = self.rest.char_indices(); + let (_, first) = chars.next().expect("non-empty checked above"); + return match first { + '{' => { + self.advance_bytes(first.len_utf8()); + Ok(Some(Token::Open)) + } + '}' => { + self.advance_bytes(first.len_utf8()); + Ok(Some(Token::Close)) + } + '"' => self.lex_quoted(), + _ => Ok(Some(self.lex_bareword())), + }; + } + } + + fn advance_bytes(&mut self, n: usize) { + self.rest = &self.rest[n..]; + } + + /// Lex a `"..."` string, decoding `\\ \" \n \t` escapes. Errors if unterminated. + fn lex_quoted(&mut self) -> Result, String> { + // Skip the opening quote. + self.advance_bytes(1); + let mut out = String::new(); + let mut chars = self.rest.char_indices(); + while let Some((i, c)) = chars.next() { + match c { + '"' => { + // Consume through the closing quote. + self.rest = &self.rest[i + 1..]; + return Ok(Some(Token::Str(out))); + } + '\\' => { + // Decode the escape. + match chars.next() { + Some((_, esc)) => out.push(match esc { + 'n' => '\n', + 't' => '\t', + 'r' => '\r', + // `\\`, `\"`, and anything else: take the literal char. + other => other, + }), + None => return Err("unterminated escape in quoted string".to_string()), + } + } + other => out.push(other), + } + } + Err("unterminated quoted string".to_string()) + } + + /// Lex an unquoted token: run of non-whitespace, non-brace, non-quote chars. + fn lex_bareword(&mut self) -> Token { + let end = self + .rest + .find(|c: char| c.is_whitespace() || matches!(c, '{' | '}' | '"')) + .unwrap_or(self.rest.len()); + let word = self.rest[..end].to_string(); + self.rest = &self.rest[end..]; + Token::Str(word) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_appmanifest_name() { + // A trimmed-down real appmanifest_.acf. + let acf = r#" +"AppState" +{ + "appid" "730" + "name" "Counter-Strike 2" + "StateFlags" "4" + "installdir" "Counter-Strike Global Offensive" + "UserConfig" + { + "language" "english" + } +} +"#; + let root = parse(acf).unwrap(); + assert_eq!(root.get_path(&["AppState", "name"]).and_then(Value::as_str), Some("Counter-Strike 2")); + assert_eq!(root.get_path(&["AppState", "appid"]).and_then(Value::as_str), Some("730")); + // Case-insensitive key lookup. + assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2")); + } + + #[test] + fn parses_libraryfolders_paths_with_escaped_backslashes() { + // Windows paths arrive with doubled backslashes (escaped). + let vdf = r#" +"libraryfolders" +{ + "0" + { + "path" "C:\\Program Files (x86)\\Steam" + "apps" + { + "730" "35000000000" + } + } + "1" + { + "path" "/home/eric/.local/share/Steam" + } +} +"#; + let root = parse(vdf).unwrap(); + let lf = root.get("libraryfolders").unwrap(); + assert_eq!(lf.get_path(&["0", "path"]).and_then(Value::as_str), Some(r"C:\Program Files (x86)\Steam")); + assert_eq!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam")); + // The library folder ids are iterable for discovery. + let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(ids, vec!["0", "1"]); + } + + #[test] + fn parses_registry_running_appid_deep_path() { + let reg = r#" +"Registry" +{ + "HKCU" + { + "Software" + { + "Valve" + { + "Steam" + { + "RunningAppID" "570" + "language" "english" + } + } + } + } +} +"#; + let root = parse(reg).unwrap(); + let appid = root + .get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"]) + .and_then(Value::as_str); + assert_eq!(appid, Some("570")); + } + + #[test] + fn handles_comments_and_barewords() { + let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n"; + let root = parse(vdf).unwrap(); + assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue")); + } + + #[test] + fn rejects_malformed_without_panicking() { + // Unbalanced braces. + assert!(parse("\"a\" {").is_err()); + // Stray closing brace. + assert!(parse("}").is_err()); + // Key with no value at EOF. + assert!(parse("\"lonely\"").is_err()); + // Unterminated quoted string. + assert!(parse("\"key\" \"unterminated").is_err()); + } + + #[test] + fn rejects_pathologically_deep_nesting() { + // Build MAX_DEPTH+5 nested objects; must error, not overflow the stack. + let mut s = String::new(); + for i in 0..(MAX_DEPTH + 5) { + s.push_str(&format!("\"k{i}\" {{")); + } + for _ in 0..(MAX_DEPTH + 5) { + s.push('}'); + } + assert!(parse(&s).is_err()); + } + + #[test] + fn missing_keys_return_none_not_error() { + let root = parse("\"AppState\" { \"appid\" \"1\" }").unwrap(); + assert_eq!(root.get_path(&["AppState", "name"]), None); + assert_eq!(root.get_path(&["Nope"]), None); + // Treating a string as an object yields None rather than panicking. + assert_eq!(root.get_path(&["AppState", "appid", "deeper"]), None); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3b9b55d..8629613 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod recents; pub mod discovery; pub mod hotkeys; pub mod files; +pub mod game; use std::fs::File; use std::path::{Path, PathBuf}; diff --git a/src/sanitize.rs b/src/sanitize.rs index da0d7c6..a3dfd5a 100644 --- a/src/sanitize.rs +++ b/src/sanitize.rs @@ -27,19 +27,50 @@ fn is_spoofing_format_char(c: char) -> bool { ) } +/// Max characters kept for a broadcast game-presence label after sanitizing +/// (game titles run longer than nicknames, so a wider cap than [`NAME_MAX_CHARS`]), +/// bounded additionally by [`GAME_LABEL_MAX_BYTES`] so a multibyte-heavy string +/// can't blow the presence frame. +pub const GAME_LABEL_MAX_CHARS: usize = 64; +/// Max UTF-8 bytes kept for a broadcast game-presence label, applied on top of +/// [`GAME_LABEL_MAX_CHARS`]. Caps the on-wire size regardless of scalar width. +pub const GAME_LABEL_MAX_BYTES: usize = 256; + +/// Shared cleaning for untrusted short labels: strip bidi / zero-width spoofing +/// format characters, turn control characters into spaces, collapse any whitespace +/// run to a single space, and trim the ends. Length capping is the caller's job. +fn clean_label(input: &str) -> String { + let cleaned: String = input + .chars() + .filter(|c| !is_spoofing_format_char(*c)) + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + cleaned.split_whitespace().collect::>().join(" ") +} + /// Sanitize an untrusted peer display name for safe rendering. Strips bidi / /// zero-width format characters, turns control characters into spaces, collapses /// any whitespace run to a single space, trims the ends, and caps the length at /// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may /// substitute a placeholder such as a short id). pub fn sanitize_name(input: &str) -> String { - let cleaned: String = input - .chars() - .filter(|c| !is_spoofing_format_char(*c)) - .map(|c| if c.is_control() { ' ' } else { c }) - .collect(); - let collapsed = cleaned.split_whitespace().collect::>().join(" "); - collapsed.chars().take(NAME_MAX_CHARS).collect() + clean_label(input).chars().take(NAME_MAX_CHARS).collect() +} + +/// Sanitize an untrusted game-presence label (the `Playing ` status that +/// rides the gossip presence plane). Same spoof/control cleaning as +/// [`sanitize_name`], but capped at [`GAME_LABEL_MAX_CHARS`] scalars AND +/// [`GAME_LABEL_MAX_BYTES`] bytes. Apply on BOTH the outgoing label we detect and +/// any incoming peer label. Returns `""` if nothing usable remains (no broadcast). +pub fn sanitize_game_label(input: &str) -> String { + let mut out = String::new(); + for c in clean_label(input).chars().take(GAME_LABEL_MAX_CHARS) { + if out.len() + c.len_utf8() > GAME_LABEL_MAX_BYTES { + break; + } + out.push(c); + } + out } /// A piece of a chat message after URL detection: literal text or a link. @@ -135,6 +166,46 @@ mod tests { assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS); } + // --- sanitize_game_label ---------------------------------------------- + + #[test] + fn game_label_keeps_ordinary_titles_and_strips_spoofing() { + assert_eq!(sanitize_game_label("Half-Life 2"), "Half-Life 2"); + // Same spoof/control cleaning as names. + assert_eq!(sanitize_game_label("Doom\u{202E}txt"), "Doomtxt"); + assert_eq!(sanitize_game_label("a\u{0}b\r\nc"), "a b c"); + } + + #[test] + fn game_label_caps_chars_wider_than_names() { + // A game label keeps more than a name's 48 (up to 64), so a title between + // the two caps survives in full. + let mid = "g".repeat(56); + assert_eq!(sanitize_game_label(&mid).chars().count(), 56); + let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100); + assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS); + } + + #[test] + fn game_label_caps_bytes_for_multibyte_titles() { + // Each '世' is 3 bytes; 64 of them = 192 bytes (under 256) → all kept. + let cjk = "世".repeat(GAME_LABEL_MAX_CHARS); + let out = sanitize_game_label(&cjk); + assert_eq!(out.chars().count(), GAME_LABEL_MAX_CHARS); + assert!(out.len() <= GAME_LABEL_MAX_BYTES); + // Emoji are 4 bytes; the byte cap bites before the char cap (256/4 = 64, + // but the leading clean keeps them as a run) — never exceeds the byte cap. + let emoji = "🎮".repeat(GAME_LABEL_MAX_CHARS); + let out = sanitize_game_label(&emoji); + assert!(out.len() <= GAME_LABEL_MAX_BYTES); + assert!(out.chars().all(|c| c == '🎮')); + } + + #[test] + fn game_label_empty_when_nothing_usable() { + assert_eq!(sanitize_game_label("\u{0}\r\n\t "), ""); + } + // --- linkify ----------------------------------------------------------- /// Concatenating every segment's inner text must reproduce the input exactly.