lib.rs (8971B)
1 //! Shared wire protocol for Hydra: the only crate both the daemon (`hydrad`) and the 2 //! TUI/query binary (`hydra`) depend on. Deliberately has **zero** macOS / CoreAudio 3 //! dependencies so the TUI can never accidentally link the audio engine. 4 //! 5 //! Transport is newline-delimited JSON (NDJSON) over a Unix-domain socket: one serde 6 //! value per line, hand-debuggable with `nc`. 7 8 use serde::{Deserialize, Serialize}; 9 use std::io::{self, BufRead, Write}; 10 use std::path::PathBuf; 11 12 /// Bumped when the wire format changes incompatibly. 13 pub const PROTOCOL_VERSION: u32 = 1; 14 15 /// `~/Library/Application Support/hydra` on macOS. The daemon keeps its socket, config, 16 /// and theme here. Falls back to `/tmp/hydra` if the data dir can't be resolved. 17 pub fn runtime_dir() -> PathBuf { 18 let mut p = dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")); 19 p.push("hydra"); 20 p 21 } 22 23 /// Path to the daemon's control socket. 24 pub fn socket_path() -> PathBuf { 25 let mut p = runtime_dir(); 26 p.push("hydrad.sock"); 27 p 28 } 29 30 // ── Request / response ──────────────────────────────────────────────────────── 31 32 /// A request from a client (TUI or `hydra query`) to the daemon. 33 #[derive(Debug, Clone, Serialize, Deserialize)] 34 pub enum Command { 35 /// Liveness check. 36 Ping, 37 /// Fetch the current routing snapshot. 38 GetState, 39 /// Enumerate the host's audio devices (hardware + virtual). 40 ListDevices, 41 /// Enumerate processes currently producing audio (capture targets). 42 ListApps, 43 /// Start monitoring one process's audio to an output device (P1). 44 /// `output_uid = None` means the system default output. 45 StartMonitor { pid: i32, output_uid: Option<String>, gain: f32 }, 46 /// Combine several processes' audio into a single route to one output (the Loopback 47 /// "combine sources" feature). `output_uid = None` means the system default output. 48 StartCombined { pids: Vec<i32>, output_uid: Option<String>, gain: f32 }, 49 /// Route a hardware INPUT device (mic / line-in, by UID) to an output. No tap/TCC. 50 StartInput { input_uid: String, output_uid: Option<String>, gain: f32 }, 51 /// Tear down a route by id. 52 StopRoute { id: String }, 53 /// Live-adjust a route's gain (linear, 0.0..~2.0). 54 SetGain { id: String, gain: f32 }, 55 /// Live mute/unmute a route. 56 SetMute { id: String, muted: bool }, 57 /// Rename the Hydra virtual device. Writes the driver manifest 58 /// (/Library/Application Support/hydra/devices.json); the new name takes effect on the 59 /// next coreaudiod restart (the driver reads the manifest at load). 60 SetDeviceName { name: String }, 61 /// List saved preset names. 62 ListPresets, 63 /// Save the current routing setup as a named preset (replaces one of the same name). 64 SavePreset { name: String }, 65 /// Replace all live routes with the named preset's routes. Apps not currently running 66 /// are skipped (same as restore-on-startup). 67 ApplyPreset { name: String }, 68 /// Delete a saved preset by name. 69 DeletePreset { name: String }, 70 /// Start recording a route's audio to a WAV file. `path = None` ⇒ daemon picks a 71 /// timestamped file in ~/Music/Hydra. 72 StartRecording { id: String, path: Option<String> }, 73 /// Stop recording a route; reply carries the file path + duration. 74 StopRecording { id: String }, 75 /// Opt this connection into the server-push event stream (state deltas, meters). 76 Subscribe, 77 /// Ask the daemon to exit. 78 Shutdown, 79 } 80 81 /// The daemon's reply to a [`Command`]. 82 #[derive(Debug, Clone, Serialize, Deserialize)] 83 pub enum Response { 84 Pong { version: u32 }, 85 State(StateSnapshot), 86 Devices(Vec<AudioDevice>), 87 Apps(Vec<AudioApp>), 88 /// A route was created; carries its assigned id. 89 RouteStarted { id: String }, 90 /// Saved preset names. 91 Presets(Vec<String>), 92 /// A preset was applied; carries how many of its routes were (re)established. 93 PresetApplied { restored: usize, total: usize }, 94 /// Recording started; carries the file path being written. 95 RecordingStarted { path: String }, 96 /// Recording stopped; carries the file path + sample-frames written. 97 RecordingStopped { path: String, frames: u64 }, 98 Ok, 99 Error(String), 100 } 101 102 /// A host audio device (hardware or virtual), as seen by CoreAudio. 103 #[derive(Debug, Clone, Serialize, Deserialize)] 104 pub struct AudioDevice { 105 pub uid: String, 106 pub name: String, 107 pub input_channels: u32, 108 pub output_channels: u32, 109 pub is_default_output: bool, 110 } 111 112 /// A process currently registered with CoreAudio as an audio producer. 113 #[derive(Debug, Clone, Serialize, Deserialize)] 114 pub struct AudioApp { 115 pub pid: i32, 116 pub name: String, 117 pub bundle_id: Option<String>, 118 /// Sort/priority rank: 0 = foreground Dock app, 1 = menu-bar/background app, 119 /// 2 = plain process (system daemons, helpers). Lower = more likely a real target. 120 #[serde(default)] 121 pub kind: u8, 122 } 123 124 /// Server-initiated push messages (only sent after [`Command::Subscribe`]). 125 #[derive(Debug, Clone, Serialize, Deserialize)] 126 pub enum Event { 127 StateChanged(StateSnapshot), 128 } 129 130 // ── Snapshot types ──────────────────────────────────────────────────────────── 131 132 /// A read-only view of the daemon's routing state, safe to render or print. 133 #[derive(Debug, Clone, Default, Serialize, Deserialize)] 134 pub struct StateSnapshot { 135 pub daemon_version: String, 136 pub devices: Vec<DeviceSummary>, 137 pub routes: Vec<RouteSummary>, 138 } 139 140 #[derive(Debug, Clone, Serialize, Deserialize)] 141 pub struct DeviceSummary { 142 pub uid: String, 143 pub name: String, 144 pub channels: u32, 145 } 146 147 #[derive(Debug, Clone, Serialize, Deserialize)] 148 pub struct RouteSummary { 149 pub id: String, 150 /// Display label, e.g. "Swinsian → Scarlett 18i20". 151 pub target: String, 152 pub source_count: usize, 153 pub active: bool, 154 /// Linear gain (1.0 = unity). 155 pub gain: f32, 156 pub muted: bool, 157 /// Most recent peak level (0.0..~1.0) for metering. 158 pub peak: f32, 159 /// Whether this route is currently being recorded to a file. 160 #[serde(default)] 161 pub recording: bool, 162 } 163 164 // ── NDJSON framing ──────────────────────────────────────────────────────────── 165 166 fn invalid_data<E: std::fmt::Display>(e: E) -> io::Error { 167 io::Error::new(io::ErrorKind::InvalidData, e.to_string()) 168 } 169 170 /// Serialize `msg` as a single JSON line and flush it to `w`. 171 pub fn write_msg<W: Write, T: Serialize>(w: &mut W, msg: &T) -> io::Result<()> { 172 let mut line = serde_json::to_vec(msg).map_err(invalid_data)?; 173 line.push(b'\n'); 174 w.write_all(&line)?; 175 w.flush() 176 } 177 178 /// Read one JSON line from `r`. Returns `Ok(None)` on clean EOF. 179 pub fn read_msg<R: BufRead, T: for<'de> Deserialize<'de>>(r: &mut R) -> io::Result<Option<T>> { 180 let mut line = String::new(); 181 if r.read_line(&mut line)? == 0 { 182 return Ok(None); 183 } 184 let trimmed = line.trim_end(); 185 if trimmed.is_empty() { 186 return Ok(None); 187 } 188 serde_json::from_str(trimmed).map(Some).map_err(invalid_data) 189 } 190 191 #[cfg(test)] 192 mod tests { 193 use super::*; 194 use std::io::BufReader; 195 196 #[test] 197 fn command_round_trips() { 198 let mut buf = Vec::new(); 199 write_msg(&mut buf, &Command::Ping).unwrap(); 200 let mut r = BufReader::new(&buf[..]); 201 let got: Option<Command> = read_msg(&mut r).unwrap(); 202 assert!(matches!(got, Some(Command::Ping))); 203 } 204 205 #[test] 206 fn snapshot_round_trips() { 207 let snap = StateSnapshot { 208 daemon_version: "0.1.0".into(), 209 devices: vec![DeviceSummary { uid: "hydra:main".into(), name: "Main".into(), channels: 16 }], 210 routes: vec![RouteSummary { 211 id: "r1".into(), 212 target: "hydra:main".into(), 213 source_count: 2, 214 active: true, 215 gain: 1.0, 216 muted: false, 217 peak: 0.0, 218 recording: false, 219 }], 220 }; 221 let mut buf = Vec::new(); 222 write_msg(&mut buf, &Response::State(snap)).unwrap(); 223 let mut r = BufReader::new(&buf[..]); 224 let got: Option<Response> = read_msg(&mut r).unwrap(); 225 match got { 226 Some(Response::State(s)) => { 227 assert_eq!(s.devices.len(), 1); 228 assert_eq!(s.routes[0].source_count, 2); 229 } 230 other => panic!("unexpected: {other:?}"), 231 } 232 } 233 234 #[test] 235 fn eof_returns_none() { 236 let mut r = BufReader::new(&b""[..]); 237 let got: Option<Command> = read_msg(&mut r).unwrap(); 238 assert!(got.is_none()); 239 } 240 }