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:
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)
}