feat(game): pure detection seams — matcher, debouncer, VDF parser, config
Step 1 of the game-detection feature (game-presence-plan.md): all the pure, I/O-free logic, tested first. - src/game/mod.rs: DetectedGame + stable namespaced ids (steam:730 / exe:hl2_linux, never the mutable name); ManualOverride; the priority resolve() matcher (override -> Steam -> mapped process -> none); the Debouncer (2-on/3-off, immediate bypass for manual override) that stops a flapping detector re-announcing the ~48KB-avatar PeerState; match_processes() over explicit user mappings with a launcher denylist (never guesses a game from an arbitrary process). - src/game/vdf.rs: a real recursive-descent KeyValues/VDF parser (not a name-regex) for appmanifest/.acf, libraryfolders.vdf, registry.vdf — depth-capped, escape-aware, never panics on malformed/truncated input. - src/sanitize.rs: sanitize_game_label (64-char/256-byte cap, wider than the 48-char name cap) sharing the bidi/zero-width cleaning. - src/config.rs: additive game_presence_enabled (opt-in, default OFF), game_backgrounds + game_process_map (BTreeMap, deterministic); background_path generalized to hashed per-game files; explicit legacy-config migration test (load() wipes on any deserialize error). - src/background.rs: game_background_filename (FNV-1a hashed, fs-safe). No wire/protocol change yet; no OS reads yet. 386 lib tests (+28). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+88
-6
@@ -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<String, String>,
|
||||
/// 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<String, String>,
|
||||
/// 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<PathBuf> {
|
||||
/// 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String>).
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user