hydra

Terminal replacement for Loopback — virtual audio devices and routing on macOS, from a ratatui TUI.
Log | Files | Refs | README | LICENSE

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 }