presets.rs (4668B)
1 //! Saved presets — named routing setups you can recall in one keystroke ("Stream", 2 //! "Record", "Calls"). A preset is just a named bundle of [`SavedRoute`]s (the same type 3 //! the daemon already persists), stored alongside `config.json`. 4 //! 5 //! Same house convention as [`crate::config`]: JSON, silent fallback to empty on load, 6 //! best-effort save. 7 8 use std::path::{Path, PathBuf}; 9 10 use serde::{Deserialize, Serialize}; 11 12 use crate::config::SavedRoute; 13 14 /// Current preset-store schema version. 15 pub const PRESETS_VERSION: u32 = 1; 16 17 /// One named routing setup. 18 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 19 pub struct Preset { 20 pub name: String, 21 #[serde(default)] 22 pub routes: Vec<SavedRoute>, 23 } 24 25 /// All saved presets. 26 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 27 pub struct PresetStore { 28 pub version: u32, 29 #[serde(default)] 30 pub presets: Vec<Preset>, 31 } 32 33 impl Default for PresetStore { 34 fn default() -> Self { 35 Self { version: PRESETS_VERSION, presets: Vec::new() } 36 } 37 } 38 39 impl PresetStore { 40 /// `~/Library/Application Support/hydra/presets.json`. 41 pub fn path() -> PathBuf { 42 let mut p = hydra_ipc::runtime_dir(); 43 p.push("presets.json"); 44 p 45 } 46 47 pub fn load() -> Self { 48 Self::load_from(&Self::path()) 49 } 50 51 pub fn load_from(path: &Path) -> Self { 52 std::fs::read_to_string(path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() 53 } 54 55 pub fn save(&self) { 56 let _ = self.save_to(&Self::path()); 57 } 58 59 pub fn save_to(&self, path: &Path) -> std::io::Result<()> { 60 if let Some(dir) = path.parent() { 61 std::fs::create_dir_all(dir)?; 62 } 63 let json = serde_json::to_string_pretty(self) 64 .unwrap_or_else(|_| format!("{{\"version\":{PRESETS_VERSION},\"presets\":[]}}")); 65 std::fs::write(path, json) 66 } 67 68 /// Names of all presets, in stored order. 69 pub fn names(&self) -> Vec<String> { 70 self.presets.iter().map(|p| p.name.clone()).collect() 71 } 72 73 /// Look up a preset by name (case-insensitive). 74 pub fn get(&self, name: &str) -> Option<&Preset> { 75 self.presets.iter().find(|p| p.name.eq_ignore_ascii_case(name)) 76 } 77 78 /// Insert or replace a preset by name (case-insensitive). Returns whether it replaced one. 79 pub fn upsert(&mut self, name: &str, routes: Vec<SavedRoute>) -> bool { 80 let name = name.trim().to_string(); 81 if let Some(existing) = self.presets.iter_mut().find(|p| p.name.eq_ignore_ascii_case(&name)) { 82 existing.routes = routes; 83 true 84 } else { 85 self.presets.push(Preset { name, routes }); 86 false 87 } 88 } 89 90 /// Remove a preset by name (case-insensitive). Returns whether one was removed. 91 pub fn remove(&mut self, name: &str) -> bool { 92 let before = self.presets.len(); 93 self.presets.retain(|p| !p.name.eq_ignore_ascii_case(name)); 94 self.presets.len() != before 95 } 96 } 97 98 #[cfg(test)] 99 mod tests { 100 use super::*; 101 102 fn route(b: &str) -> SavedRoute { 103 SavedRoute { bundle_id: b.into(), output_uid: Some("Hydra_UID".into()), gain: 10.0, muted: false } 104 } 105 106 #[test] 107 fn upsert_adds_then_replaces() { 108 let mut s = PresetStore::default(); 109 assert!(!s.upsert("Stream", vec![route("com.spotify.client")])); 110 assert_eq!(s.presets.len(), 1); 111 // Same name (different case) replaces, doesn't duplicate. 112 assert!(s.upsert("stream", vec![route("com.apple.Music"), route("com.spotify.client")])); 113 assert_eq!(s.presets.len(), 1); 114 assert_eq!(s.get("STREAM").unwrap().routes.len(), 2); 115 } 116 117 #[test] 118 fn remove_and_names() { 119 let mut s = PresetStore::default(); 120 s.upsert("A", vec![route("a")]); 121 s.upsert("B", vec![route("b")]); 122 assert_eq!(s.names(), vec!["A".to_string(), "B".to_string()]); 123 assert!(s.remove("a")); 124 assert!(!s.remove("a")); 125 assert_eq!(s.names(), vec!["B".to_string()]); 126 } 127 128 #[test] 129 fn round_trips_through_disk() { 130 let dir = std::env::temp_dir().join(format!("hydra-presets-{}", std::process::id())); 131 let path = dir.join("presets.json"); 132 let mut s = PresetStore::default(); 133 s.upsert("Calls", vec![route("us.zoom.xos")]); 134 s.save_to(&path).unwrap(); 135 assert_eq!(PresetStore::load_from(&path), s); 136 let _ = std::fs::remove_dir_all(&dir); 137 } 138 139 #[test] 140 fn missing_file_is_empty() { 141 let s = PresetStore::load_from(Path::new("/nonexistent/hydra/presets.json")); 142 assert!(s.presets.is_empty()); 143 } 144 }