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>
748 lines
31 KiB
Rust
748 lines
31 KiB
Rust
use crate::notify::Sound;
|
|
use crate::theme::AppTheme;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
/// Relay/discovery posture, trading connectivity against how much the n0
|
|
/// infrastructure learns about you. See the network module for details.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub enum NetworkMode {
|
|
/// n0 relay for NAT traversal, but no DNS presence beacon. Peer addresses
|
|
/// come from the join ticket and gossip, so n0 only sees relayed-call
|
|
/// metadata, never a standing "I'm online" record. Default.
|
|
#[default]
|
|
RelayNoDiscovery,
|
|
/// Full n0 defaults: relay plus DNS publish/resolve (most convenient,
|
|
/// most phone-home).
|
|
N0Full,
|
|
/// No relay, no discovery: direct hole-punching only. Fully serverless,
|
|
/// but fails behind symmetric/CGNAT NATs with no fallback.
|
|
DirectOnly,
|
|
}
|
|
|
|
impl NetworkMode {
|
|
/// All variants, for presentation in a picker.
|
|
pub const ALL: [NetworkMode; 3] =
|
|
[NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly];
|
|
}
|
|
|
|
/// Arrangement of the in-call room screen, chosen via the layout picker.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub enum RoomLayout {
|
|
/// Participants | Chat | Controls, all columns visible at once.
|
|
ThreeColumn,
|
|
/// Participants + Controls on top, full-width Chat docked along the bottom.
|
|
#[default]
|
|
BottomDock,
|
|
/// Participants | Controls, with a collapsible Chat drawer on the right edge.
|
|
Drawer,
|
|
}
|
|
|
|
impl RoomLayout {
|
|
/// All variants, in picker display order.
|
|
pub const ALL: [RoomLayout; 3] =
|
|
[RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer];
|
|
}
|
|
|
|
/// What a call recording captures. `Mixed` is the original single-file behaviour;
|
|
/// `Multitrack`/`Both` write per-peer stems for post-production (see
|
|
/// `docs/multitrack-recording-plan.md`).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub enum RecordingMode {
|
|
/// One mixed WAV (your mic + the incoming mix). Smallest; the default.
|
|
#[default]
|
|
Mixed,
|
|
/// One WAV per peer + your mic, all sample-aligned. Rebuild the mix yourself.
|
|
Multitrack,
|
|
/// Per-peer stems + your mic + a convenience mixed track.
|
|
Both,
|
|
}
|
|
|
|
impl RecordingMode {
|
|
/// All variants, in picker display order.
|
|
pub const ALL: [RecordingMode; 3] =
|
|
[RecordingMode::Mixed, RecordingMode::Multitrack, RecordingMode::Both];
|
|
|
|
/// True when this mode writes per-peer stem tracks (Multitrack or Both).
|
|
pub fn is_multitrack(self) -> bool {
|
|
matches!(self, RecordingMode::Multitrack | RecordingMode::Both)
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for RecordingMode {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self {
|
|
RecordingMode::Mixed => "Mixed (single file)",
|
|
RecordingMode::Multitrack => "Multitrack (per-peer stems)",
|
|
RecordingMode::Both => "Both (stems + mixed)",
|
|
})
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for RoomLayout {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self {
|
|
RoomLayout::ThreeColumn => "3-Column",
|
|
RoomLayout::BottomDock => "Bottom Dock",
|
|
RoomLayout::Drawer => "Drawer",
|
|
})
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for NetworkMode {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let label = match self {
|
|
NetworkMode::RelayNoDiscovery => "Relay, no presence beacon",
|
|
NetworkMode::N0Full => "n0 defaults (relay + DNS)",
|
|
NetworkMode::DirectOnly => "Direct only (no relay)",
|
|
};
|
|
f.write_str(label)
|
|
}
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_background_dim() -> f32 {
|
|
crate::background::DEFAULT_DIM
|
|
}
|
|
|
|
fn default_volume() -> f32 {
|
|
1.0
|
|
}
|
|
|
|
fn default_username() -> String {
|
|
"Peer".to_string()
|
|
}
|
|
|
|
fn default_participants_width() -> f32 {
|
|
540.0
|
|
}
|
|
|
|
fn default_chat_height() -> f32 {
|
|
180.0
|
|
}
|
|
|
|
fn default_controls_width() -> f32 {
|
|
280.0
|
|
}
|
|
|
|
fn default_chat_drawer_width() -> f32 {
|
|
320.0
|
|
}
|
|
|
|
fn default_window_width() -> f32 {
|
|
900.0
|
|
}
|
|
|
|
fn default_window_height() -> f32 {
|
|
760.0
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct AppConfig {
|
|
/// Last nickname used to join/create a room; pre-filled on the launch screen.
|
|
#[serde(default = "default_username")]
|
|
pub username: String,
|
|
pub input_device: String,
|
|
pub output_device: String,
|
|
pub noise_gate_threshold: f32,
|
|
/// App-internal capture gain applied to the mic before encode (1.0 = unity).
|
|
#[serde(default = "default_volume")]
|
|
pub input_volume: f32,
|
|
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
|
#[serde(default = "default_volume")]
|
|
pub output_volume: f32,
|
|
#[serde(default)]
|
|
pub network_mode: NetworkMode,
|
|
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
|
/// discoverable. Default `Normal` = answer friends only, no DNS beacon.
|
|
#[serde(default)]
|
|
pub presence_mode: crate::presence::PresenceMode,
|
|
/// Route audio through PipeWire's echo-cancel module (AEC + noise suppression).
|
|
/// Takes effect on the next room join. Off by default.
|
|
#[serde(default)]
|
|
pub echo_cancellation_enabled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub notifications_enabled: bool,
|
|
/// Persisted room-screen divider positions (px): Participants panel width and
|
|
/// Chat dock height. Re-clamped to the window on load and on resize.
|
|
#[serde(default = "default_participants_width")]
|
|
pub participants_width: f32,
|
|
#[serde(default = "default_chat_height")]
|
|
pub chat_height: f32,
|
|
/// Controls panel width for the 3-column layout (px).
|
|
#[serde(default = "default_controls_width")]
|
|
pub controls_width: f32,
|
|
/// Chat drawer width for the drawer layout (px).
|
|
#[serde(default = "default_chat_drawer_width")]
|
|
pub chat_drawer_width: f32,
|
|
/// Chosen arrangement of the in-call room screen.
|
|
#[serde(default)]
|
|
pub room_layout: RoomLayout,
|
|
/// Chosen UI colour theme.
|
|
#[serde(default)]
|
|
pub theme: AppTheme,
|
|
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
|
|
#[serde(default)]
|
|
pub avatar: crate::avatar::Avatar,
|
|
/// Custom UI background image (W16): path to the downscaled PNG we wrote into
|
|
/// the config dir (see `background_path`). `None` = use the theme background.
|
|
/// Local-only; never sent to peers.
|
|
#[serde(default)]
|
|
pub background: Option<String>,
|
|
/// Scrim strength drawn over the custom background for legibility (0.0 = image
|
|
/// at full strength, 1.0 = fully hidden behind the theme base). See
|
|
/// `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,
|
|
#[serde(default)]
|
|
pub custom_sound_self_join: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_peer_join: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_peer_leave: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_reconnect_attempt: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_reconnected: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_self_leave: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_mic_toggle: Option<String>,
|
|
#[serde(default)]
|
|
pub custom_sound_reconnect_failed: Option<String>,
|
|
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle
|
|
/// gates ALL chimes; these let the user silence individual events while the
|
|
/// master stays on. A chime plays only if the master AND its flag are true.
|
|
/// Default true so behaviour is unchanged until a user opts a sound out.
|
|
#[serde(default = "default_true")]
|
|
pub sound_self_join_enabled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub sound_peer_join_enabled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub sound_peer_leave_enabled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub sound_reconnect_attempt_enabled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub sound_reconnected_enabled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub sound_self_leave_enabled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub sound_mic_toggle_enabled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub sound_reconnect_failed_enabled: bool,
|
|
/// Optional override for the `pixelpass` binary location (screen share).
|
|
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
|
#[serde(default)]
|
|
pub pixelpass_path: Option<String>,
|
|
/// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a
|
|
/// one-click rejoin; never sent over the wire. De-duped by room topic and
|
|
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
|
|
#[serde(default)]
|
|
pub recents: Vec<crate::recents::Recent>,
|
|
/// Per-peer listener-side EQ settings, keyed by peer node id string. Local
|
|
/// preference only; never sent to peers.
|
|
#[serde(default)]
|
|
pub peer_eq: HashMap<String, crate::audio::eq::EqSettings>,
|
|
/// Per-peer listener-side pan (`-1.0` left, `0.0` center, `1.0` right),
|
|
/// keyed by peer node id string. Local preference only.
|
|
#[serde(default)]
|
|
pub peer_pan: HashMap<String, f32>,
|
|
/// Per-peer listener-side volume/gain (`1.0` = unity), keyed by peer node id
|
|
/// string. Local preference only; never sent to peers. Absent entry = unity.
|
|
#[serde(default)]
|
|
pub peer_volume: HashMap<String, f32>,
|
|
/// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off),
|
|
/// keyed by peer node id string. Local preference only; never sent to peers.
|
|
/// Absent entry = gate disabled (pass-through).
|
|
#[serde(default)]
|
|
pub peer_gate: HashMap<String, f32>,
|
|
/// Focused app-local keyboard shortcuts.
|
|
#[serde(default)]
|
|
pub hotkeys: crate::hotkeys::HotkeyMap,
|
|
/// Last window size (px), restored as the initial size on next launch.
|
|
/// Saved on close.
|
|
#[serde(default = "default_window_width")]
|
|
pub window_width: f32,
|
|
#[serde(default = "default_window_height")]
|
|
pub window_height: f32,
|
|
/// Last window position (px). Saved on close, restored on next launch — but
|
|
/// only on **X11**: Wayland's xdg-shell gives clients no way to place their
|
|
/// own window, so we center there and let the compositor decide. `None` =
|
|
/// never saved a position yet (e.g. always run under Wayland) → center.
|
|
#[serde(default)]
|
|
pub window_x: Option<i32>,
|
|
#[serde(default)]
|
|
pub window_y: Option<i32>,
|
|
}
|
|
|
|
impl Default for AppConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
username: default_username(),
|
|
input_device: "".to_string(),
|
|
output_device: "".to_string(),
|
|
noise_gate_threshold: 0.01,
|
|
input_volume: 1.0,
|
|
output_volume: 1.0,
|
|
network_mode: NetworkMode::default(),
|
|
presence_mode: crate::presence::PresenceMode::default(),
|
|
echo_cancellation_enabled: false,
|
|
notifications_enabled: true,
|
|
participants_width: default_participants_width(),
|
|
chat_height: default_chat_height(),
|
|
controls_width: default_controls_width(),
|
|
chat_drawer_width: default_chat_drawer_width(),
|
|
room_layout: RoomLayout::default(),
|
|
theme: AppTheme::default(),
|
|
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,
|
|
custom_sound_peer_leave: None,
|
|
custom_sound_reconnect_attempt: None,
|
|
custom_sound_reconnected: None,
|
|
custom_sound_self_leave: None,
|
|
custom_sound_mic_toggle: None,
|
|
custom_sound_reconnect_failed: None,
|
|
sound_self_join_enabled: true,
|
|
sound_peer_join_enabled: true,
|
|
sound_peer_leave_enabled: true,
|
|
sound_reconnect_attempt_enabled: true,
|
|
sound_reconnected_enabled: true,
|
|
sound_self_leave_enabled: true,
|
|
sound_mic_toggle_enabled: true,
|
|
sound_reconnect_failed_enabled: true,
|
|
pixelpass_path: None,
|
|
recents: Vec::new(),
|
|
peer_eq: HashMap::new(),
|
|
peer_pan: HashMap::new(),
|
|
peer_volume: HashMap::new(),
|
|
peer_gate: HashMap::new(),
|
|
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
|
window_width: default_window_width(),
|
|
window_height: default_window_height(),
|
|
window_x: None,
|
|
window_y: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AppConfig {
|
|
/// Whether the chime for `sound` is enabled (its own per-sound flag; does
|
|
/// NOT factor in the master `notifications_enabled` toggle — see `notify`).
|
|
pub fn sound_enabled(&self, sound: Sound) -> bool {
|
|
match sound {
|
|
Sound::SelfJoin => self.sound_self_join_enabled,
|
|
Sound::PeerJoin => self.sound_peer_join_enabled,
|
|
Sound::PeerLeave => self.sound_peer_leave_enabled,
|
|
Sound::ReconnectAttempt => self.sound_reconnect_attempt_enabled,
|
|
Sound::Reconnected => self.sound_reconnected_enabled,
|
|
Sound::SelfLeave => self.sound_self_leave_enabled,
|
|
Sound::MicToggle => self.sound_mic_toggle_enabled,
|
|
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
|
|
}
|
|
}
|
|
|
|
/// Set the per-sound enable flag for `sound`.
|
|
pub fn set_sound_enabled(&mut self, sound: Sound, enabled: bool) {
|
|
match sound {
|
|
Sound::SelfJoin => self.sound_self_join_enabled = enabled,
|
|
Sound::PeerJoin => self.sound_peer_join_enabled = enabled,
|
|
Sound::PeerLeave => self.sound_peer_leave_enabled = enabled,
|
|
Sound::ReconnectAttempt => self.sound_reconnect_attempt_enabled = enabled,
|
|
Sound::Reconnected => self.sound_reconnected_enabled = enabled,
|
|
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
|
|
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
|
|
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
|
|
}
|
|
}
|
|
|
|
fn config_path() -> Option<PathBuf> {
|
|
dirs::config_dir().map(|mut p| {
|
|
p.push("peerspeak");
|
|
p.push("config.json");
|
|
p
|
|
})
|
|
}
|
|
|
|
/// 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(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)
|
|
&& let Ok(config) = serde_json::from_str(&contents) {
|
|
return config;
|
|
}
|
|
Self::default()
|
|
}
|
|
|
|
pub fn save(&self) {
|
|
if let Some(path) = Self::config_path() {
|
|
if let Some(dir) = path.parent() {
|
|
let _ = fs::create_dir_all(dir);
|
|
}
|
|
if let Ok(json) = serde_json::to_string_pretty(self) {
|
|
let _ = fs::write(path, json);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_round_trip() {
|
|
let original = AppConfig::default();
|
|
let serialized = serde_json::to_string(&original).unwrap();
|
|
let deserialized: AppConfig = serde_json::from_str(&serialized).unwrap();
|
|
assert_eq!(original, deserialized);
|
|
}
|
|
|
|
#[test]
|
|
fn test_backward_compat_default_fill() {
|
|
let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
|
let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap();
|
|
|
|
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
|
|
// Configs predating the presence posture load as friends-only (no beacon).
|
|
assert_eq!(deserialized.presence_mode, crate::presence::PresenceMode::Normal);
|
|
assert!(!deserialized.echo_cancellation_enabled);
|
|
assert!(deserialized.notifications_enabled);
|
|
// Configs predating the volume sliders must load at unity gain.
|
|
assert_eq!(deserialized.input_volume, 1.0);
|
|
assert_eq!(deserialized.output_volume, 1.0);
|
|
// Configs predating the draggable dividers must load the default sizes.
|
|
assert_eq!(deserialized.participants_width, 540.0);
|
|
assert_eq!(deserialized.chat_height, 180.0);
|
|
// Configs predating the remembered username load the default nickname.
|
|
assert_eq!(deserialized.username, "Peer");
|
|
// Configs predating the layout picker load the default layout + sizes.
|
|
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
|
|
assert_eq!(deserialized.controls_width, 280.0);
|
|
assert_eq!(deserialized.chat_drawer_width, 320.0);
|
|
assert!(deserialized.custom_sound_self_join.is_none());
|
|
assert!(deserialized.custom_sound_peer_join.is_none());
|
|
assert!(deserialized.custom_sound_peer_leave.is_none());
|
|
assert!(deserialized.custom_sound_reconnect_attempt.is_none());
|
|
assert!(deserialized.custom_sound_reconnected.is_none());
|
|
assert!(deserialized.custom_sound_self_leave.is_none());
|
|
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
|
assert!(deserialized.custom_sound_reconnect_failed.is_none());
|
|
// Configs predating the per-sound flags (W6) enable every chime, so an
|
|
// upgrade is silent-change-free.
|
|
for sound in Sound::ALL {
|
|
assert!(deserialized.sound_enabled(sound), "{sound:?} should default on");
|
|
}
|
|
// The accessor and mutator agree round-trip.
|
|
let mut cfg = AppConfig::default();
|
|
cfg.set_sound_enabled(Sound::PeerJoin, false);
|
|
assert!(!cfg.sound_enabled(Sound::PeerJoin));
|
|
assert!(cfg.sound_enabled(Sound::SelfJoin));
|
|
// Configs predating the remembered window size load the default size.
|
|
assert_eq!(deserialized.window_width, 900.0);
|
|
assert_eq!(deserialized.window_height, 760.0);
|
|
// Configs predating the recents list load an empty list.
|
|
assert!(deserialized.recents.is_empty());
|
|
// Configs predating per-peer listener shaping load flat/center/default
|
|
// shortcut settings.
|
|
assert!(deserialized.peer_eq.is_empty());
|
|
assert!(deserialized.peer_pan.is_empty());
|
|
assert!(deserialized.peer_volume.is_empty());
|
|
assert!(deserialized.peer_gate.is_empty());
|
|
assert_eq!(
|
|
crate::hotkeys::format_binding(
|
|
deserialized
|
|
.hotkeys
|
|
.binding(crate::hotkeys::HotkeyAction::PushToTalk)
|
|
),
|
|
"Space"
|
|
);
|
|
}
|
|
|
|
#[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.
|
|
let def = AppConfig::default();
|
|
assert_eq!(def.window_width, 900.0);
|
|
assert_eq!(def.window_height, 760.0);
|
|
// A saved size round-trips.
|
|
let cfg = AppConfig {
|
|
window_width: 1280.0,
|
|
window_height: 720.0,
|
|
..AppConfig::default()
|
|
};
|
|
let json = serde_json::to_string(&cfg).unwrap();
|
|
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back.window_width, 1280.0);
|
|
assert_eq!(back.window_height, 720.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_window_position_fields() {
|
|
// Position is unset by default (only ever saved on X11).
|
|
let def = AppConfig::default();
|
|
assert_eq!(def.window_x, None);
|
|
assert_eq!(def.window_y, None);
|
|
// A saved position (incl. negative coords) round-trips.
|
|
let cfg = AppConfig {
|
|
window_x: Some(200),
|
|
window_y: Some(-50),
|
|
..AppConfig::default()
|
|
};
|
|
let json = serde_json::to_string(&cfg).unwrap();
|
|
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back.window_x, Some(200));
|
|
assert_eq!(back.window_y, Some(-50));
|
|
}
|
|
|
|
#[test]
|
|
fn test_recording_mode_field() {
|
|
// Default is Mixed (back-compat with pre-feature configs).
|
|
assert_eq!(AppConfig::default().recording_mode, RecordingMode::Mixed);
|
|
// A chosen mode round-trips through JSON.
|
|
let cfg = AppConfig {
|
|
recording_mode: RecordingMode::Both,
|
|
..AppConfig::default()
|
|
};
|
|
let back: AppConfig =
|
|
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
|
assert_eq!(back.recording_mode, RecordingMode::Both);
|
|
// is_multitrack() classifies correctly.
|
|
assert!(!RecordingMode::Mixed.is_multitrack());
|
|
assert!(RecordingMode::Multitrack.is_multitrack());
|
|
assert!(RecordingMode::Both.is_multitrack());
|
|
}
|
|
|
|
#[test]
|
|
fn test_theme_field() {
|
|
// Default theme is Mocha (the original look).
|
|
assert_eq!(AppConfig::default().theme, AppTheme::Mocha);
|
|
// A chosen theme round-trips.
|
|
let cfg = AppConfig {
|
|
theme: AppTheme::Nord,
|
|
..AppConfig::default()
|
|
};
|
|
let json = serde_json::to_string(&cfg).unwrap();
|
|
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back.theme, AppTheme::Nord);
|
|
}
|
|
|
|
#[test]
|
|
fn old_config_without_theme_loads() {
|
|
// Configs predating the theme picker load with the default (Mocha).
|
|
let json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
|
let cfg: AppConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(cfg.theme, AppTheme::Mocha);
|
|
}
|
|
|
|
#[test]
|
|
fn old_config_without_position_loads() {
|
|
// A config written before window_x/window_y existed still deserializes
|
|
// (serde default → None), so upgrades don't wipe a user's settings.
|
|
// (The three device/gate fields have no serde default, so any valid
|
|
// config must carry them — mirrors test_backward_compat_default_fill.)
|
|
let json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"window_width":1024.0,"window_height":768.0}"#;
|
|
let cfg: AppConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(cfg.window_x, None);
|
|
assert_eq!(cfg.window_y, None);
|
|
assert_eq!(cfg.window_width, 1024.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_input_output_volume_fields() {
|
|
// Default impl is unity gain.
|
|
let def = AppConfig::default();
|
|
assert_eq!(def.input_volume, 1.0);
|
|
assert_eq!(def.output_volume, 1.0);
|
|
|
|
// Missing in JSON → unity (serde default).
|
|
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
|
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
|
assert_eq!(cfg_missing.input_volume, 1.0);
|
|
assert_eq!(cfg_missing.output_volume, 1.0);
|
|
|
|
// Explicit non-unity values are preserved across a round-trip.
|
|
let cfg = AppConfig {
|
|
input_volume: 1.5,
|
|
output_volume: 0.25,
|
|
..AppConfig::default()
|
|
};
|
|
let round_tripped: AppConfig =
|
|
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
|
assert_eq!(round_tripped.input_volume, 1.5);
|
|
assert_eq!(round_tripped.output_volume, 0.25);
|
|
}
|
|
|
|
#[test]
|
|
fn test_notifications_enabled_specifically() {
|
|
let missing_notifications = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
|
let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap();
|
|
assert!(config_missing.notifications_enabled);
|
|
|
|
let explicit_false = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"notifications_enabled":false}"#;
|
|
let config_false: AppConfig = serde_json::from_str(explicit_false).unwrap();
|
|
assert!(!config_false.notifications_enabled);
|
|
|
|
let explicit_true = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"notifications_enabled":true}"#;
|
|
let config_true: AppConfig = serde_json::from_str(explicit_true).unwrap();
|
|
assert!(config_true.notifications_enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn test_network_mode() {
|
|
// NetworkMode::default() == RelayNoDiscovery
|
|
assert_eq!(NetworkMode::default(), NetworkMode::RelayNoDiscovery);
|
|
|
|
// ALL.len() == 3 and contains all three variants
|
|
assert_eq!(NetworkMode::ALL.len(), 3);
|
|
assert!(NetworkMode::ALL.contains(&NetworkMode::RelayNoDiscovery));
|
|
assert!(NetworkMode::ALL.contains(&NetworkMode::N0Full));
|
|
assert!(NetworkMode::ALL.contains(&NetworkMode::DirectOnly));
|
|
|
|
// each variant serde round-trips to itself
|
|
for mode in NetworkMode::ALL {
|
|
let serialized = serde_json::to_string(&mode).unwrap();
|
|
let deserialized: NetworkMode = serde_json::from_str(&serialized).unwrap();
|
|
assert_eq!(mode, deserialized);
|
|
}
|
|
|
|
// the three Display strings are non-empty and distinct
|
|
let display_0 = NetworkMode::RelayNoDiscovery.to_string();
|
|
let display_1 = NetworkMode::N0Full.to_string();
|
|
let display_2 = NetworkMode::DirectOnly.to_string();
|
|
|
|
assert!(!display_0.is_empty());
|
|
assert!(!display_1.is_empty());
|
|
assert!(!display_2.is_empty());
|
|
|
|
assert_ne!(display_0, display_1);
|
|
assert_ne!(display_1, display_2);
|
|
assert_ne!(display_0, display_2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_field_tolerance() {
|
|
// Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
|
|
// Assert whatever actually happens and leave a comment; do not add deny_unknown_fields.
|
|
let json_with_extra = r#"{
|
|
"input_device": "",
|
|
"output_device": "",
|
|
"noise_gate_threshold": 0.01,
|
|
"unrecognized_field_xyz_123": "some_value"
|
|
}"#;
|
|
let deserialized_res: Result<AppConfig, _> = serde_json::from_str(json_with_extra);
|
|
|
|
// Assert that deserialization succeeds even with unrecognized/unknown fields.
|
|
// This confirms that serde does not reject unknown fields (i.e. default behavior).
|
|
assert!(deserialized_res.is_ok(), "Config deserialization failed when an unknown field was present");
|
|
|
|
let config = deserialized_res.unwrap();
|
|
assert_eq!(config.input_device, "");
|
|
assert_eq!(config.output_device, "");
|
|
assert_eq!(config.noise_gate_threshold, 0.01);
|
|
}
|
|
}
|