config.rs (4271B)
1 //! Persisted routing configuration. The daemon saves the current routes here on every 2 //! change and restores them on startup, so routing survives a daemon restart / reboot 3 //! (the daemon runs as a KeepAlive LaunchAgent — see scripts/install-agent.sh). 4 //! 5 //! Stored as JSON at `~/Library/Application Support/hydra/config.json`, following the 6 //! house convention: silent fallback to `Default` on load, best-effort (`let _ =`) on save. 7 8 use std::path::PathBuf; 9 10 use serde::{Deserialize, Serialize}; 11 12 /// Current config schema version (bumped on incompatible changes). 13 pub const CONFIG_VERSION: u32 = 1; 14 15 /// One saved route: an app (by bundle-id, the stable identifier across restarts) routed to 16 /// an output device, with its mixer settings. 17 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 18 pub struct SavedRoute { 19 /// Bundle id of the captured app (stable across launches; PIDs are not). 20 pub bundle_id: String, 21 /// Target output device UID, or `None` for the system default output. 22 pub output_uid: Option<String>, 23 pub gain: f32, 24 pub muted: bool, 25 } 26 27 /// The persisted daemon configuration. 28 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 29 pub struct Config { 30 pub version: u32, 31 #[serde(default)] 32 pub routes: Vec<SavedRoute>, 33 } 34 35 impl Default for Config { 36 fn default() -> Self { 37 Self { version: CONFIG_VERSION, routes: Vec::new() } 38 } 39 } 40 41 impl Config { 42 /// `~/Library/Application Support/hydra/config.json`. 43 pub fn path() -> PathBuf { 44 let mut p = hydra_ipc::runtime_dir(); 45 p.push("config.json"); 46 p 47 } 48 49 /// Load from the standard path, falling back to `Default` on any error (missing file, 50 /// parse failure) — a corrupt config should never stop the daemon from starting. 51 pub fn load() -> Self { 52 Self::load_from(&Self::path()) 53 } 54 55 pub fn load_from(path: &std::path::Path) -> Self { 56 std::fs::read_to_string(path) 57 .ok() 58 .and_then(|s| serde_json::from_str(&s).ok()) 59 .unwrap_or_default() 60 } 61 62 /// Best-effort save; logs nothing and never panics (persistence is not critical path). 63 pub fn save(&self) { 64 let _ = self.save_to(&Self::path()); 65 } 66 67 pub fn save_to(&self, path: &std::path::Path) -> std::io::Result<()> { 68 if let Some(dir) = path.parent() { 69 std::fs::create_dir_all(dir)?; 70 } 71 let json = serde_json::to_string_pretty(self) 72 .unwrap_or_else(|_| format!("{{\"version\":{CONFIG_VERSION},\"routes\":[]}}")); 73 std::fs::write(path, json) 74 } 75 } 76 77 #[cfg(test)] 78 mod tests { 79 use super::*; 80 81 fn sample() -> Config { 82 Config { 83 version: CONFIG_VERSION, 84 routes: vec![ 85 SavedRoute { 86 bundle_id: "com.spotify.client".into(), 87 output_uid: Some("BuiltInSpeakerDevice".into()), 88 gain: 0.8, 89 muted: false, 90 }, 91 SavedRoute { bundle_id: "com.apple.Music".into(), output_uid: None, gain: 1.0, muted: true }, 92 ], 93 } 94 } 95 96 #[test] 97 fn round_trips_through_disk() { 98 let dir = std::env::temp_dir().join(format!("hydra-config-test-{}", std::process::id())); 99 let path = dir.join("config.json"); 100 let c = sample(); 101 c.save_to(&path).unwrap(); 102 assert_eq!(Config::load_from(&path), c); 103 let _ = std::fs::remove_dir_all(&dir); 104 } 105 106 #[test] 107 fn missing_file_yields_default() { 108 let c = Config::load_from(std::path::Path::new("/nonexistent/hydra/config.json")); 109 assert_eq!(c, Config::default()); 110 assert!(c.routes.is_empty()); 111 } 112 113 #[test] 114 fn corrupt_file_yields_default() { 115 let dir = std::env::temp_dir().join(format!("hydra-config-corrupt-{}", std::process::id())); 116 std::fs::create_dir_all(&dir).unwrap(); 117 let path = dir.join("config.json"); 118 std::fs::write(&path, "{ this is not json").unwrap(); 119 assert_eq!(Config::load_from(&path), Config::default()); 120 let _ = std::fs::remove_dir_all(&dir); 121 } 122 123 #[test] 124 fn default_path_is_in_app_support() { 125 assert!(Config::path().ends_with("hydra/config.json")); 126 } 127 }