hydra

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

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 }