use serde::{Deserialize, Serialize}; 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]; } 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_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 } #[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, /// 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, #[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, } 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(), 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(), 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, } } } impl AppConfig { fn config_path() -> Option { dirs::config_dir().map(|mut p| { p.push("peerspeak"); p.push("config.json"); p }) } 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); 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()); } #[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 = 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); } }