use crate::notify::Sound; use crate::theme::AppTheme; use anyhow::Context; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; /// 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)", }) } } /// Named Opus encoder / network-resilience policy (W12). The user picks a /// profile instead of raw codec knobs; the concrete libopus parameters live in /// `codec::opus_impl::opus_params`. Applies live to the running encoder. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum AudioProfile { /// Lowest mouth-to-ear delay: modest bitrate, no FEC redundancy. Best on a /// clean LAN / low-loss link where added latency matters more than loss. LowLatency, /// Sensible default: voice bitrate with in-band FEC for light packet loss. #[default] Balanced, /// Maximum resilience on a lossy/congested link: in-band FEC tuned for heavy /// loss, at a lower bitrate to leave headroom for the redundancy. BadNetwork, } impl AudioProfile { /// All variants, in picker display order. pub const ALL: [AudioProfile; 3] = [ AudioProfile::LowLatency, AudioProfile::Balanced, AudioProfile::BadNetwork, ]; /// Compact discriminant for handing the profile to the capture thread via an /// atomic. Pairs with [`AudioProfile::from_u8`]. pub fn as_u8(self) -> u8 { match self { AudioProfile::LowLatency => 0, AudioProfile::Balanced => 1, AudioProfile::BadNetwork => 2, } } /// Inverse of [`AudioProfile::as_u8`]; unknown values fall back to the default. pub fn from_u8(v: u8) -> AudioProfile { match v { 0 => AudioProfile::LowLatency, 2 => AudioProfile::BadNetwork, _ => AudioProfile::Balanced, } } } impl std::fmt::Display for AudioProfile { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { AudioProfile::LowLatency => "Low latency", AudioProfile::Balanced => "Balanced", AudioProfile::BadNetwork => "Bad network", }) } } 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) } } /// Pixelpass host quality preset for screen shares. `Auto` leaves pixelpass free /// to choose from its bandwidth pre-flight; fixed presets are passed as CLI flags. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum ShareQuality { #[default] Auto, Low, Medium, High, Source, } impl ShareQuality { pub const ALL: [ShareQuality; 5] = [ ShareQuality::Auto, ShareQuality::Low, ShareQuality::Medium, ShareQuality::High, ShareQuality::Source, ]; } impl std::fmt::Display for ShareQuality { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { ShareQuality::Auto => "Auto", ShareQuality::Low => "Low", ShareQuality::Medium => "Medium", ShareQuality::High => "High", ShareQuality::Source => "Source", }) } } /// Preferred local player for watching a peer's screen share. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum SharePlayer { #[default] Mpv, Vlc, } impl SharePlayer { pub const ALL: [SharePlayer; 2] = [SharePlayer::Mpv, SharePlayer::Vlc]; } impl std::fmt::Display for SharePlayer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { SharePlayer::Mpv => "mpv", SharePlayer::Vlc => "VLC", }) } } /// Local player buffering posture for screen-share playback. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum ShareBuffering { #[default] LowLatency, Smooth, } impl ShareBuffering { pub const ALL: [ShareBuffering; 2] = [ShareBuffering::LowLatency, ShareBuffering::Smooth]; } impl std::fmt::Display for ShareBuffering { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { ShareBuffering::LowLatency => "Low latency", ShareBuffering::Smooth => "Smooth", }) } } fn default_screen_share_cache_mb() -> u32 { 2 } /// Local-only screen-share preferences. Host fields become pixelpass host CLI /// flags; viewer fields shape local mpv/VLC launch. None/empty/default values /// deliberately let pixelpass/player defaults stand. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ScreenShareSettings { #[serde(default)] pub quality: ShareQuality, #[serde(default)] pub bitrate_mbps: Option, #[serde(default)] pub framerate: Option, #[serde(default)] pub max_height: Option, #[serde(default)] pub max_viewers: Option, #[serde(default)] pub force_software_encode: bool, #[serde(default)] pub extra_host_args: String, #[serde(default)] pub player: SharePlayer, #[serde(default)] pub hardware_decode: bool, #[serde(default)] pub buffering: ShareBuffering, #[serde(default = "default_screen_share_cache_mb")] pub cache_mb: u32, #[serde(default)] pub extra_mpv_args: String, } impl Default for ScreenShareSettings { fn default() -> Self { Self { quality: ShareQuality::default(), bitrate_mbps: None, framerate: None, max_height: None, max_viewers: None, force_software_encode: false, extra_host_args: String::new(), player: SharePlayer::default(), hardware_decode: false, buffering: ShareBuffering::default(), cache_mb: default_screen_share_cache_mb(), extra_mpv_args: String::new(), } } } 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_threecol_playlist_height() -> f32 { 220.0 } fn default_controls_width() -> f32 { 280.0 } fn default_chat_drawer_width() -> f32 { 320.0 } fn default_playlist_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, /// Universal playback gain for inline chat audio clips (1.0 = unity). One /// level shared by every uploaded clip so the slider sticks across plays. #[serde(default = "default_volume")] pub clip_volume: f32, /// W22 music: the user's personal playlist as local file PATHS (not bytes). /// Loaded into memory at startup; missing files are skipped/marked on play. #[serde(default)] pub music_playlist: Vec, /// W22 music: local playback gain for the dedicated music player (1.0 = unity). #[serde(default = "default_volume")] pub music_volume: f32, /// W22 music: opt-in shared listening broadcast toggle. Local preference. #[serde(default)] pub music_broadcast: bool, /// Show the slim now-playing player bar in the room screen. #[serde(default = "default_true")] pub show_player_bar: bool, /// When true, `clip_volume` governs every clip. When false, each clip keeps /// its own (in-memory) level and the universal slider is inactive. #[serde(default = "default_true")] pub clip_volume_universal: bool, #[serde(default)] pub network_mode: NetworkMode, /// Opus encoder / network-resilience profile (W12). Applies live to the /// running encoder; default `Balanced`. #[serde(default)] pub audio_profile: AudioProfile, /// 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, /// Height (px) of the standalone Playlist card stacked under Chat in the /// 3-column layout. Resized via its own horizontal divider; re-clamped to the /// window on load/resize. Only used by `RoomLayout::ThreeColumn`. #[serde(default = "default_threecol_playlist_height")] pub threecol_playlist_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, /// Playlist drawer width for the room-screen right-edge music panel (px). #[serde(default = "default_playlist_drawer_width")] pub playlist_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, /// 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, /// 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, #[serde(default)] pub custom_sound_self_join: Option, #[serde(default)] pub custom_sound_peer_join: Option, #[serde(default)] pub custom_sound_peer_leave: Option, #[serde(default)] pub custom_sound_reconnect_attempt: Option, #[serde(default)] pub custom_sound_reconnected: Option, #[serde(default)] pub custom_sound_self_leave: Option, #[serde(default)] pub custom_sound_mic_toggle: Option, #[serde(default)] pub custom_sound_reconnect_failed: Option, #[serde(default)] pub custom_sound_chat_sent: Option, #[serde(default)] pub custom_sound_chat_received: Option, #[serde(default)] pub custom_sound_contact_online: Option, #[serde(default)] pub custom_sound_contact_offline: Option, /// 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, #[serde(default = "default_true")] pub sound_chat_sent_enabled: bool, #[serde(default = "default_true")] pub sound_chat_received_enabled: bool, #[serde(default = "default_true")] pub sound_contact_online_enabled: bool, #[serde(default = "default_true")] pub sound_contact_offline_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, /// Local-only host/player controls for screen sharing. #[serde(default)] pub screen_share: ScreenShareSettings, /// 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, /// 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, /// 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, /// 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, /// Per-source music listen volume/gain (`1.0` = unity), keyed by peer node id /// string. Local preference only; never sent to peers. Absent entry falls /// back to `music_volume`. #[serde(default)] pub music_source_volume: HashMap, /// 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, /// 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, #[serde(default)] pub window_y: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoadOutcome { Missing, Loaded, Recovered, } 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, clip_volume: 1.0, music_playlist: Vec::new(), music_volume: 1.0, music_broadcast: false, show_player_bar: true, clip_volume_universal: true, network_mode: NetworkMode::default(), audio_profile: AudioProfile::default(), presence_mode: crate::presence::PresenceMode::default(), echo_cancellation_enabled: false, notifications_enabled: true, participants_width: default_participants_width(), chat_height: default_chat_height(), threecol_playlist_height: default_threecol_playlist_height(), controls_width: default_controls_width(), chat_drawer_width: default_chat_drawer_width(), playlist_drawer_width: default_playlist_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, custom_sound_chat_sent: None, custom_sound_chat_received: None, custom_sound_contact_online: None, custom_sound_contact_offline: 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, sound_chat_sent_enabled: true, sound_chat_received_enabled: true, sound_contact_online_enabled: true, sound_contact_offline_enabled: true, pixelpass_path: None, screen_share: ScreenShareSettings::default(), recents: Vec::new(), peer_eq: HashMap::new(), peer_pan: HashMap::new(), peer_volume: HashMap::new(), music_source_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, Sound::ChatSent => self.sound_chat_sent_enabled, Sound::ChatReceived => self.sound_chat_received_enabled, Sound::ContactOnline => self.sound_contact_online_enabled, Sound::ContactOffline => self.sound_contact_offline_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, Sound::ChatSent => self.sound_chat_sent_enabled = enabled, Sound::ChatReceived => self.sound_chat_received_enabled = enabled, Sound::ContactOnline => self.sound_contact_online_enabled = enabled, Sound::ContactOffline => self.sound_contact_offline_enabled = enabled, } } fn config_path() -> Option { 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 { 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 { 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 { let Some(path) = Self::config_path() else { return Self::default(); }; let (config, _) = Self::load_from(&path); config } pub fn save(&self) { if let Some(path) = Self::config_path() { if let Err(e) = self.save_to(&path) { crate::log_msg(&format!("config: save failed: {e:#}")); } } else { crate::log_msg("config: save failed: could not determine a config directory"); } } pub fn load_from(path: &Path) -> (Self, LoadOutcome) { match fs::read_to_string(path) { Ok(contents) => match serde_json::from_str(&contents) { Ok(config) => (config, LoadOutcome::Loaded), Err(e) => { let backup = recover_corrupt_config(path, &format!("failed to parse: {e}")); (Self::default(), backup) } }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { (Self::default(), LoadOutcome::Missing) } Err(e) => { let backup = recover_corrupt_config(path, &format!("failed to read: {e}")); (Self::default(), backup) } } } pub fn save_to(&self, path: &Path) -> anyhow::Result<()> { let parent = path .parent() .context("config path has no parent directory")?; fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; let json = serde_json::to_string_pretty(self).context("failed to encode config")?; let tmp = config_tmp_path(path)?; let result = (|| -> anyhow::Result<()> { { let mut f = fs::File::create(&tmp) .with_context(|| format!("failed to create {}", tmp.display()))?; f.write_all(json.as_bytes()) .with_context(|| format!("failed to write {}", tmp.display()))?; f.sync_all().ok(); } fs::rename(&tmp, path).with_context(|| { format!("failed to rename {} -> {}", tmp.display(), path.display()) })?; Ok(()) })(); if result.is_err() { let _ = fs::remove_file(&tmp); } result } } fn config_tmp_path(path: &Path) -> anyhow::Result { let parent = path .parent() .context("config path has no parent directory")?; let mut name = path .file_name() .context("config path has no file name")? .to_os_string(); name.push(format!(".tmp.{}", std::process::id())); Ok(parent.join(name)) } fn corrupt_backup_path(path: &Path) -> PathBuf { let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let parent = path.parent().unwrap_or_else(|| Path::new(".")); let mut name = path .file_name() .map(|n| n.to_os_string()) .unwrap_or_else(|| "config.json".into()); name.push(format!(".corrupt.{secs}")); parent.join(name) } fn recover_corrupt_config(path: &Path, reason: &str) -> LoadOutcome { let backup = corrupt_backup_path(path); match fs::rename(path, &backup) { Ok(()) => { crate::log_msg(&format!( "config: {reason}; moved damaged config to {}", backup.display() )); } Err(e) => { crate::log_msg(&format!( "config: {reason}; failed to move damaged config to {}: {e}", backup.display() )); } } LoadOutcome::Recovered } #[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); } fn temp_config_path(tag: &str) -> PathBuf { let mut p = std::env::temp_dir(); p.push(format!( "peerspeak-configtest-{}-{}", std::process::id(), tag )); p.push("config.json"); p } #[test] fn save_to_then_load_from_round_trips() { let path = temp_config_path("roundtrip"); let _ = fs::remove_dir_all(path.parent().unwrap()); let cfg = AppConfig { username: "Ada".into(), input_device: "mic".into(), output_device: "speaker".into(), noise_gate_threshold: 0.42, ..AppConfig::default() }; cfg.save_to(&path).unwrap(); let (loaded, outcome) = AppConfig::load_from(&path); assert_eq!(outcome, LoadOutcome::Loaded); assert_eq!(loaded, cfg); let _ = fs::remove_dir_all(path.parent().unwrap()); } #[test] fn load_from_missing_returns_default_without_corrupt_backup() { let path = temp_config_path("missing"); let _ = fs::remove_dir_all(path.parent().unwrap()); let (loaded, outcome) = AppConfig::load_from(&path); assert_eq!(outcome, LoadOutcome::Missing); assert_eq!(loaded, AppConfig::default()); assert!(!path.parent().unwrap().exists()); } #[test] fn load_from_corrupt_file_preserves_original_bytes() { let path = temp_config_path("corrupt"); let _ = fs::remove_dir_all(path.parent().unwrap()); fs::create_dir_all(path.parent().unwrap()).unwrap(); let corrupt = b"{ this is not json"; fs::write(&path, corrupt).unwrap(); let (loaded, outcome) = AppConfig::load_from(&path); assert_eq!(outcome, LoadOutcome::Recovered); assert_eq!(loaded, AppConfig::default()); assert_ne!(fs::read(&path).ok().as_deref(), Some(corrupt.as_slice())); let backups: Vec<_> = fs::read_dir(path.parent().unwrap()) .unwrap() .map(|entry| entry.unwrap().path()) .filter(|entry| { entry .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.starts_with("config.json.corrupt.")) }) .collect(); assert_eq!(backups.len(), 1, "expected one corrupt backup"); assert_eq!(fs::read(&backups[0]).unwrap(), corrupt); let _ = fs::remove_dir_all(path.parent().unwrap()); } #[test] fn save_to_leaves_no_tmp_file_after_success() { let path = temp_config_path("atomic"); let _ = fs::remove_dir_all(path.parent().unwrap()); AppConfig::default().save_to(&path).unwrap(); let tmp_files: Vec<_> = fs::read_dir(path.parent().unwrap()) .unwrap() .map(|entry| entry.unwrap().path()) .filter(|entry| { entry .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.contains(".tmp.")) }) .collect(); assert!(tmp_files.is_empty(), "leftover temp files: {tmp_files:?}"); let _ = fs::remove_dir_all(path.parent().unwrap()); } #[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_eq!(deserialized.playlist_drawer_width, 320.0); assert!(deserialized.show_player_bar); 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()); assert!(deserialized.custom_sound_chat_sent.is_none()); assert!(deserialized.custom_sound_chat_received.is_none()); assert!(deserialized.custom_sound_contact_online.is_none()); assert!(deserialized.custom_sound_contact_offline.is_none()); assert_eq!(deserialized.screen_share, ScreenShareSettings::default()); assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto); assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv); assert_eq!( deserialized.screen_share.buffering, ShareBuffering::LowLatency ); assert_eq!(deserialized.screen_share.cache_mb, 2); // 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.music_source_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). 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); assert_eq!(def.clip_volume, 1.0); assert!(def.music_playlist.is_empty()); assert_eq!(def.music_volume, 1.0); assert!(!def.music_broadcast); assert!(def.show_player_bar); assert!(def.clip_volume_universal); // 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); assert_eq!(cfg_missing.clip_volume, 1.0); assert!(cfg_missing.music_playlist.is_empty()); assert_eq!(cfg_missing.music_volume, 1.0); assert!(!cfg_missing.music_broadcast); assert!(cfg_missing.show_player_bar); // Configs predating the toggle default to universal mode. assert!(cfg_missing.clip_volume_universal); // Explicit non-unity values are preserved across a round-trip. let cfg = AppConfig { input_volume: 1.5, output_volume: 0.25, clip_volume: 0.7, music_playlist: vec!["/tmp/song.ogg".to_string()], music_volume: 0.6, music_broadcast: true, show_player_bar: false, clip_volume_universal: false, ..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); assert_eq!(round_tripped.clip_volume, 0.7); assert_eq!( round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()] ); assert_eq!(round_tripped.music_volume, 0.6); assert!(round_tripped.music_broadcast); assert!(!round_tripped.show_player_bar); assert!(!round_tripped.clip_volume_universal); } #[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_audio_profile() { // Default is Balanced. assert_eq!(AudioProfile::default(), AudioProfile::Balanced); // ALL holds the three variants. assert_eq!(AudioProfile::ALL.len(), 3); assert!(AudioProfile::ALL.contains(&AudioProfile::LowLatency)); assert!(AudioProfile::ALL.contains(&AudioProfile::Balanced)); assert!(AudioProfile::ALL.contains(&AudioProfile::BadNetwork)); // as_u8 / from_u8 round-trip every variant, and unknown bytes fall back // to the default rather than panicking. for p in AudioProfile::ALL { assert_eq!(AudioProfile::from_u8(p.as_u8()), p); } assert_eq!(AudioProfile::from_u8(99), AudioProfile::Balanced); // serde round-trips, and Display strings are non-empty + distinct. let mut labels = Vec::new(); for p in AudioProfile::ALL { let s = serde_json::to_string(&p).unwrap(); assert_eq!(serde_json::from_str::(&s).unwrap(), p); let label = p.to_string(); assert!(!label.is_empty()); labels.push(label); } labels.sort(); labels.dedup(); assert_eq!(labels.len(), 3); } #[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 = 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); } }