hydra

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

manifest.rs (4718B)


      1 //! The driver manifest: how the daemon tells the Hydra HAL driver which virtual
      2 //! devices to publish.
      3 //!
      4 //! The driver runs inside `coreaudiod` (as `_coreaudiod`), which cannot read the user's
      5 //! home directory — so the manifest lives at a world-readable system path. The daemon
      6 //! writes it; the driver reads it at load (and, in P6, on a live-reload kick).
      7 //!
      8 //! P2 honours only the **first** device's `name`/`uid` (channels are fixed at the driver's
      9 //! build-time channel count). The full N-device array is P3.
     10 
     11 use std::io;
     12 use std::path::{Path, PathBuf};
     13 
     14 use serde::{Deserialize, Serialize};
     15 
     16 /// World-readable directory both the daemon and the sandboxed driver can reach.
     17 pub const MANIFEST_DIR: &str = "/Library/Application Support/hydra";
     18 /// The manifest file name within [`MANIFEST_DIR`].
     19 pub const MANIFEST_FILE: &str = "devices.json";
     20 
     21 /// Current manifest schema version (bumped on incompatible changes).
     22 pub const MANIFEST_VERSION: u32 = 1;
     23 
     24 /// One virtual device the driver should publish.
     25 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
     26 pub struct ManifestDevice {
     27     /// Stable UID other apps use to select the device (e.g. "hydra:main").
     28     pub uid: String,
     29     /// Human-readable name shown in audio device pickers.
     30     pub name: String,
     31     /// Channel count. Advisory in P2 (driver uses its build-time count); honoured in P3.
     32     pub channels: u32,
     33     /// Supported sample rates; empty ⇒ driver defaults.
     34     #[serde(default)]
     35     pub sample_rates: Vec<u32>,
     36 }
     37 
     38 /// The full manifest document written to disk.
     39 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
     40 pub struct Manifest {
     41     pub version: u32,
     42     pub devices: Vec<ManifestDevice>,
     43 }
     44 
     45 impl Manifest {
     46     pub fn new(devices: Vec<ManifestDevice>) -> Self {
     47         Self { version: MANIFEST_VERSION, devices }
     48     }
     49 
     50     /// Canonical manifest path (`/Library/Application Support/hydra/devices.json`).
     51     pub fn path() -> PathBuf {
     52         Path::new(MANIFEST_DIR).join(MANIFEST_FILE)
     53     }
     54 
     55     /// Serialize to pretty JSON (the driver's tiny parser tolerates whitespace).
     56     pub fn to_json(&self) -> String {
     57         // Infallible for this plain struct; fall back to an empty doc rather than panic.
     58         serde_json::to_string_pretty(self).unwrap_or_else(|_| {
     59             format!("{{\"version\":{MANIFEST_VERSION},\"devices\":[]}}")
     60         })
     61     }
     62 
     63     /// Write the manifest to `path`, creating the parent directory if possible.
     64     ///
     65     /// Writing under `/Library/Application Support` needs privileges the daemon may not
     66     /// have until the installer has created+chowned the directory; callers should treat
     67     /// an `io::Error` here as "driver not installed yet", not fatal.
     68     pub fn write_to(&self, path: &Path) -> io::Result<()> {
     69         if let Some(dir) = path.parent() {
     70             std::fs::create_dir_all(dir)?;
     71         }
     72         std::fs::write(path, self.to_json())
     73     }
     74 
     75     /// Write to the canonical [`Manifest::path`].
     76     pub fn write(&self) -> io::Result<()> {
     77         self.write_to(&Self::path())
     78     }
     79 }
     80 
     81 #[cfg(test)]
     82 mod tests {
     83     use super::*;
     84 
     85     fn sample() -> Manifest {
     86         Manifest::new(vec![ManifestDevice {
     87             uid: "hydra:main".into(),
     88             name: "Hydra Main".into(),
     89             channels: 16,
     90             sample_rates: vec![44100, 48000],
     91         }])
     92     }
     93 
     94     #[test]
     95     fn round_trips_through_json() {
     96         let m = sample();
     97         let parsed: Manifest = serde_json::from_str(&m.to_json()).unwrap();
     98         assert_eq!(m, parsed);
     99     }
    100 
    101     #[test]
    102     fn json_has_expected_shape() {
    103         let json = sample().to_json();
    104         assert!(json.contains("\"version\": 1"));
    105         assert!(json.contains("\"uid\": \"hydra:main\""));
    106         assert!(json.contains("\"name\": \"Hydra Main\""));
    107     }
    108 
    109     #[test]
    110     fn sample_rates_default_when_absent() {
    111         let parsed: Manifest =
    112             serde_json::from_str(r#"{"version":1,"devices":[{"uid":"u","name":"n","channels":2}]}"#).unwrap();
    113         assert!(parsed.devices[0].sample_rates.is_empty());
    114     }
    115 
    116     #[test]
    117     fn writes_and_reads_back_from_tempdir() {
    118         let dir = std::env::temp_dir().join(format!("hydra-manifest-test-{}", std::process::id()));
    119         let path = dir.join("devices.json");
    120         let m = sample();
    121         m.write_to(&path).unwrap();
    122         let back: Manifest = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    123         assert_eq!(m, back);
    124         let _ = std::fs::remove_dir_all(&dir);
    125     }
    126 
    127     #[test]
    128     fn canonical_path_is_system_location() {
    129         assert_eq!(
    130             Manifest::path().to_str().unwrap(),
    131             "/Library/Application Support/hydra/devices.json"
    132         );
    133     }
    134 }