hydra

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

commit 17f56429aeb5c07172994fb0196291b626d462d5
parent c53f2f79868d66e9f5c823b4fb38a1acfe4c4414
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Sun, 31 May 2026 17:18:17 -0500

P4 (persistence): routes survive daemon restart

hydra-core::config — Config/SavedRoute persisted as JSON at
~/Library/Application Support/hydra/config.json (house convention: silent
Default on load, best-effort save). Apps keyed by bundle-id (stable across
launches; PIDs aren't). 4 tests: disk round-trip, missing→default,
corrupt→default, path.

Engine: Entry now tracks bundle_id + output_uid; to_config() projects live
routes to a Config (skips bundle-less helper procs); restore() re-establishes
saved routes by resolving bundle-id→live-PID (apps not running are skipped).

Daemon: persist() after every StartMonitor/StopRoute/SetGain/SetMute; on
startup loads config and restores, resolving bundle-ids via the audio-process
list. VERIFIED: seeded a 1-route config, daemon logged "restored 0/1" for a
not-running app (correct skip behaviour).

Also corrects the prior commit's test-count wording: actual suite is 19 tests
(was mislabelled 20), all green; 0 warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Acrates/hydra-core/src/config.rs | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/hydra-core/src/engine.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/hydra-core/src/lib.rs | 1+
Mcrates/hydrad/src/main.rs | 15+++++++++++++++
Mcrates/hydrad/src/server.rs | 40++++++++++++++++++++++++++--------------
5 files changed, 222 insertions(+), 16 deletions(-)

diff --git a/crates/hydra-core/src/config.rs b/crates/hydra-core/src/config.rs @@ -0,0 +1,127 @@ +//! Persisted routing configuration. The daemon saves the current routes here on every +//! change and restores them on startup, so routing survives a daemon restart / reboot +//! (the daemon runs as a KeepAlive LaunchAgent — see scripts/install-agent.sh). +//! +//! Stored as JSON at `~/Library/Application Support/hydra/config.json`, following the +//! house convention: silent fallback to `Default` on load, best-effort (`let _ =`) on save. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Current config schema version (bumped on incompatible changes). +pub const CONFIG_VERSION: u32 = 1; + +/// One saved route: an app (by bundle-id, the stable identifier across restarts) routed to +/// an output device, with its mixer settings. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SavedRoute { + /// Bundle id of the captured app (stable across launches; PIDs are not). + pub bundle_id: String, + /// Target output device UID, or `None` for the system default output. + pub output_uid: Option<String>, + pub gain: f32, + pub muted: bool, +} + +/// The persisted daemon configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Config { + pub version: u32, + #[serde(default)] + pub routes: Vec<SavedRoute>, +} + +impl Default for Config { + fn default() -> Self { + Self { version: CONFIG_VERSION, routes: Vec::new() } + } +} + +impl Config { + /// `~/Library/Application Support/hydra/config.json`. + pub fn path() -> PathBuf { + let mut p = hydra_ipc::runtime_dir(); + p.push("config.json"); + p + } + + /// Load from the standard path, falling back to `Default` on any error (missing file, + /// parse failure) — a corrupt config should never stop the daemon from starting. + pub fn load() -> Self { + Self::load_from(&Self::path()) + } + + pub fn load_from(path: &std::path::Path) -> Self { + std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + /// Best-effort save; logs nothing and never panics (persistence is not critical path). + pub fn save(&self) { + let _ = self.save_to(&Self::path()); + } + + pub fn save_to(&self, path: &std::path::Path) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let json = serde_json::to_string_pretty(self) + .unwrap_or_else(|_| format!("{{\"version\":{CONFIG_VERSION},\"routes\":[]}}")); + std::fs::write(path, json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Config { + Config { + version: CONFIG_VERSION, + routes: vec![ + SavedRoute { + bundle_id: "com.spotify.client".into(), + output_uid: Some("BuiltInSpeakerDevice".into()), + gain: 0.8, + muted: false, + }, + SavedRoute { bundle_id: "com.apple.Music".into(), output_uid: None, gain: 1.0, muted: true }, + ], + } + } + + #[test] + fn round_trips_through_disk() { + let dir = std::env::temp_dir().join(format!("hydra-config-test-{}", std::process::id())); + let path = dir.join("config.json"); + let c = sample(); + c.save_to(&path).unwrap(); + assert_eq!(Config::load_from(&path), c); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn missing_file_yields_default() { + let c = Config::load_from(std::path::Path::new("/nonexistent/hydra/config.json")); + assert_eq!(c, Config::default()); + assert!(c.routes.is_empty()); + } + + #[test] + fn corrupt_file_yields_default() { + let dir = std::env::temp_dir().join(format!("hydra-config-corrupt-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.json"); + std::fs::write(&path, "{ this is not json").unwrap(); + assert_eq!(Config::load_from(&path), Config::default()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn default_path_is_in_app_support() { + assert!(Config::path().ends_with("hydra/config.json")); + } +} diff --git a/crates/hydra-core/src/engine.rs b/crates/hydra-core/src/engine.rs @@ -6,12 +6,18 @@ use std::collections::HashMap; use anyhow::Result; use hydra_ipc::{RouteSummary, StateSnapshot}; +use crate::config::Config; +use crate::config::SavedRoute; use crate::ffi::shim::MonitorRoute; struct Entry { route: MonitorRoute, /// Human-readable "app → output" label for display. label: String, + /// Bundle id of the captured app — the stable key we persist (PIDs don't survive restarts). + bundle_id: Option<String>, + /// Target output device UID, or `None` for the system default. + output_uid: Option<String>, } #[derive(Default)] @@ -25,17 +31,22 @@ impl Engine { } /// Start monitoring `pid` to `output_uid` (default output if `None`). `label` is the - /// display string the TUI/snapshot shows. Returns the new route's id. + /// display string the TUI/snapshot shows; `bundle_id` is persisted so the route can be + /// re-established (by re-resolving the app) after a daemon restart. Returns the route id. pub fn start_monitor( &mut self, pid: i32, output_uid: Option<&str>, gain: f32, label: String, + bundle_id: Option<String>, ) -> Result<String> { let route = MonitorRoute::start(pid, output_uid, gain)?; let id = route.id().to_string(); - self.routes.insert(id.clone(), Entry { route, label }); + self.routes.insert( + id.clone(), + Entry { route, label, bundle_id, output_uid: output_uid.map(str::to_string) }, + ); Ok(id) } @@ -83,4 +94,44 @@ impl Engine { StateSnapshot { daemon_version: crate::VERSION.to_string(), devices: Vec::new(), routes } } + + /// Project the live routes into a persistable [`Config`]. Routes whose source app has no + /// bundle id (anonymous helper processes) are skipped — there's no stable way to restore + /// them after a restart. + pub fn to_config(&self) -> Config { + let mut routes: Vec<SavedRoute> = self + .routes + .values() + .filter_map(|e| { + e.bundle_id.as_ref().map(|b| SavedRoute { + bundle_id: b.clone(), + output_uid: e.output_uid.clone(), + gain: e.route.gain(), + muted: e.route.muted(), + }) + }) + .collect(); + routes.sort_by(|a, b| a.bundle_id.cmp(&b.bundle_id)); + Config { version: crate::config::CONFIG_VERSION, routes } + } + + /// Re-establish saved routes from a [`Config`], resolving each app's bundle id to a live + /// PID via `resolve` (apps not currently running are skipped). Returns how many were + /// restored. Used at daemon startup so routing survives restarts. + pub fn restore(&mut self, config: &Config, resolve: impl Fn(&str) -> Option<i32>) -> usize { + let mut restored = 0; + for saved in &config.routes { + let Some(pid) = resolve(&saved.bundle_id) else { continue }; + let label = format!("{} → {}", saved.bundle_id, saved.output_uid.as_deref().unwrap_or("Default Output")); + if let Ok(id) = + self.start_monitor(pid, saved.output_uid.as_deref(), saved.gain, label, Some(saved.bundle_id.clone())) + { + if saved.muted { + self.set_muted(&id, true); + } + restored += 1; + } + } + restored + } } diff --git a/crates/hydra-core/src/lib.rs b/crates/hydra-core/src/lib.rs @@ -2,6 +2,7 @@ //! engine. Only the daemon (`hydrad`) links this crate; the TUI talks to the daemon //! over [`hydra_ipc`] and never touches CoreAudio. +pub mod config; pub mod manifest; pub mod model; diff --git a/crates/hydrad/src/main.rs b/crates/hydrad/src/main.rs @@ -10,7 +10,9 @@ use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; use std::sync::{Arc, Mutex}; +use hydra_core::config::Config; use hydra_core::engine::Engine; +use hydra_core::ffi::process; fn main() -> Result<(), Box<dyn Error>> { let sock = hydra_ipc::socket_path(); @@ -26,6 +28,19 @@ fn main() -> Result<(), Box<dyn Error>> { let engine = Arc::new(Mutex::new(Engine::new())); + // Restore saved routes (KeepAlive LaunchAgent ⇒ this runs after every restart/reboot). + // Resolve each saved app's bundle id to a currently-running PID; skip apps not running. + let config = Config::load(); + if !config.routes.is_empty() { + let restored = engine.lock().unwrap().restore(&config, |bundle_id| { + process::list_audio_processes() + .into_iter() + .find(|a| a.bundle_id.as_deref() == Some(bundle_id)) + .map(|a| a.pid) + }); + eprintln!("restored {restored}/{} saved route(s)", config.routes.len()); + } + for conn in listener.incoming() { match conn { Ok(stream) => { diff --git a/crates/hydrad/src/server.rs b/crates/hydrad/src/server.rs @@ -12,6 +12,11 @@ use hydra_ipc::{read_msg, write_msg, Command, Response, PROTOCOL_VERSION}; type SharedEngine = Arc<Mutex<Engine>>; +/// Persist the engine's current routes after any mutation, so they survive a restart. +fn persist(engine: &SharedEngine) { + engine.lock().unwrap().to_config().save(); +} + pub fn handle(stream: UnixStream, engine: SharedEngine) -> Result<(), Box<dyn Error>> { let mut reader = BufReader::new(stream.try_clone()?); let mut writer = BufWriter::new(stream); @@ -38,22 +43,30 @@ fn dispatch( }, Command::ListApps => Response::Apps(process::list_audio_processes()), Command::StartMonitor { pid, output_uid, gain } => { - let label = route_label(pid, output_uid.as_deref()); - let resp = match engine.lock().unwrap().start_monitor(pid, output_uid.as_deref(), gain, label) { - Ok(id) => Response::RouteStarted { id }, - Err(e) => Response::Error(e.to_string()), - }; + let (label, bundle_id) = route_label(pid, output_uid.as_deref()); + let resp = + match engine.lock().unwrap().start_monitor(pid, output_uid.as_deref(), gain, label, bundle_id) { + Ok(id) => Response::RouteStarted { id }, + Err(e) => Response::Error(e.to_string()), + }; + persist(engine); notify_route_change(); resp } Command::StopRoute { id } => { let r = ok_or_missing(engine.lock().unwrap().stop(&id), &id); + persist(engine); notify_route_change(); r } - Command::SetGain { id, gain } => ok_or_missing(engine.lock().unwrap().set_gain(&id, gain), &id), + Command::SetGain { id, gain } => { + let r = ok_or_missing(engine.lock().unwrap().set_gain(&id, gain), &id); + persist(engine); + r + } Command::SetMute { id, muted } => { let r = ok_or_missing(engine.lock().unwrap().set_muted(&id, muted), &id); + persist(engine); notify_route_change(); r } @@ -84,13 +97,12 @@ fn notify_route_change() { .spawn(); } -/// Build the "app → output" display label for a new route. -fn route_label(pid: i32, output_uid: Option<&str>) -> String { - let app = process::list_audio_processes() - .into_iter() - .find(|a| a.pid == pid) - .map(|a| a.name) - .unwrap_or_else(|| format!("pid {pid}")); +/// Build the "app → output" display label for a new route, and resolve the app's bundle id +/// (the stable key persisted for restart). Returns `(label, bundle_id)`. +fn route_label(pid: i32, output_uid: Option<&str>) -> (String, Option<String>) { + let app = process::list_audio_processes().into_iter().find(|a| a.pid == pid); + let name = app.as_ref().map(|a| a.name.clone()).unwrap_or_else(|| format!("pid {pid}")); + let bundle_id = app.and_then(|a| a.bundle_id); let output = match output_uid { Some(uid) => hal::list_devices() @@ -100,5 +112,5 @@ fn route_label(pid: i32, output_uid: Option<&str>) -> String { None => "Default Output".to_string(), }; - format!("{app} → {output}") + (format!("{name} → {output}"), bundle_id) }