hydra

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

commit 374286c5193145a18b81f79a35b976b602e4ff93
parent 57f248df365636f86328bb8cb584c9e34a4ca735
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 13:22:22 -0500

UX1: saved presets / quick-routes

Name a whole routing setup and recall it in one keystroke — beats Loopback's
build-once-then-it's-stuck model. A preset is a named bundle of SavedRoutes
(reuses the persistence type), stored at ~/Library/Application Support/hydra/
presets.json.

- hydra-core::presets: PresetStore (upsert/get/remove/names), 4 tests.
- engine: refactored restore() → shared apply_routes() + clear_all(), so
  startup-restore and preset-apply share one code path.
- IPC: ListPresets / SavePreset / ApplyPreset / DeletePreset + Presets /
  PresetApplied responses. Daemon resolves bundle-id→live-PID (apps not running
  are skipped, same as restore).
- TUI: generalized the proven rename text-prompt into a typed Prompt
  (RenameDevice | SavePreset); 'P' saves current routing, 'p' opens a centered
  presets overlay (↑↓ select, ⏎ apply, d delete, esc close). Auto-refresh pauses
  while either modal is open.

Verified end-to-end over the live socket: save → list ['Test Setup'] → stop all
routes → apply restored 2/2 → delete → empty list → missing-preset clean error.
23 tests green, 0 warnings.

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

Diffstat:
Mcrates/hydra-core/src/engine.rs | 18++++++++++++++----
Mcrates/hydra-core/src/lib.rs | 1+
Acrates/hydra-core/src/presets.rs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/hydra-ipc/src/lib.rs | 13+++++++++++++
Mcrates/hydra/src/app.rs | 149++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/hydra/src/main.rs | 32+++++++++++++++++++++++---------
Mcrates/hydra/src/ui.rs | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/hydrad/src/server.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 440 insertions(+), 46 deletions(-)

diff --git a/crates/hydra-core/src/engine.rs b/crates/hydra-core/src/engine.rs @@ -154,12 +154,22 @@ impl Engine { 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. + /// Re-establish saved routes from a [`Config`] (daemon startup). See [`Engine::apply_routes`]. pub fn restore(&mut self, config: &Config, resolve: impl Fn(&str) -> Option<i32>) -> usize { + self.apply_routes(&config.routes, resolve) + } + + /// Tear down every live route. + pub fn clear_all(&mut self) { + self.routes.clear(); + } + + /// Establish a set of [`SavedRoute`]s, resolving each app's bundle id to a live PID via + /// `resolve` (apps not currently running are skipped). Returns how many were established. + /// Shared by startup-restore and preset-apply. + pub fn apply_routes(&mut self, routes: &[SavedRoute], resolve: impl Fn(&str) -> Option<i32>) -> usize { let mut restored = 0; - for saved in &config.routes { + for saved in 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) = diff --git a/crates/hydra-core/src/lib.rs b/crates/hydra-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod manifest; pub mod model; +pub mod presets; pub use model::RoutingState; diff --git a/crates/hydra-core/src/presets.rs b/crates/hydra-core/src/presets.rs @@ -0,0 +1,144 @@ +//! Saved presets — named routing setups you can recall in one keystroke ("Stream", +//! "Record", "Calls"). A preset is just a named bundle of [`SavedRoute`]s (the same type +//! the daemon already persists), stored alongside `config.json`. +//! +//! Same house convention as [`crate::config`]: JSON, silent fallback to empty on load, +//! best-effort save. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::config::SavedRoute; + +/// Current preset-store schema version. +pub const PRESETS_VERSION: u32 = 1; + +/// One named routing setup. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Preset { + pub name: String, + #[serde(default)] + pub routes: Vec<SavedRoute>, +} + +/// All saved presets. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PresetStore { + pub version: u32, + #[serde(default)] + pub presets: Vec<Preset>, +} + +impl Default for PresetStore { + fn default() -> Self { + Self { version: PRESETS_VERSION, presets: Vec::new() } + } +} + +impl PresetStore { + /// `~/Library/Application Support/hydra/presets.json`. + pub fn path() -> PathBuf { + let mut p = hydra_ipc::runtime_dir(); + p.push("presets.json"); + p + } + + pub fn load() -> Self { + Self::load_from(&Self::path()) + } + + pub fn load_from(path: &Path) -> Self { + std::fs::read_to_string(path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() + } + + pub fn save(&self) { + let _ = self.save_to(&Self::path()); + } + + pub fn save_to(&self, 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\":{PRESETS_VERSION},\"presets\":[]}}")); + std::fs::write(path, json) + } + + /// Names of all presets, in stored order. + pub fn names(&self) -> Vec<String> { + self.presets.iter().map(|p| p.name.clone()).collect() + } + + /// Look up a preset by name (case-insensitive). + pub fn get(&self, name: &str) -> Option<&Preset> { + self.presets.iter().find(|p| p.name.eq_ignore_ascii_case(name)) + } + + /// Insert or replace a preset by name (case-insensitive). Returns whether it replaced one. + pub fn upsert(&mut self, name: &str, routes: Vec<SavedRoute>) -> bool { + let name = name.trim().to_string(); + if let Some(existing) = self.presets.iter_mut().find(|p| p.name.eq_ignore_ascii_case(&name)) { + existing.routes = routes; + true + } else { + self.presets.push(Preset { name, routes }); + false + } + } + + /// Remove a preset by name (case-insensitive). Returns whether one was removed. + pub fn remove(&mut self, name: &str) -> bool { + let before = self.presets.len(); + self.presets.retain(|p| !p.name.eq_ignore_ascii_case(name)); + self.presets.len() != before + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn route(b: &str) -> SavedRoute { + SavedRoute { bundle_id: b.into(), output_uid: Some("Hydra_UID".into()), gain: 10.0, muted: false } + } + + #[test] + fn upsert_adds_then_replaces() { + let mut s = PresetStore::default(); + assert!(!s.upsert("Stream", vec![route("com.spotify.client")])); + assert_eq!(s.presets.len(), 1); + // Same name (different case) replaces, doesn't duplicate. + assert!(s.upsert("stream", vec![route("com.apple.Music"), route("com.spotify.client")])); + assert_eq!(s.presets.len(), 1); + assert_eq!(s.get("STREAM").unwrap().routes.len(), 2); + } + + #[test] + fn remove_and_names() { + let mut s = PresetStore::default(); + s.upsert("A", vec![route("a")]); + s.upsert("B", vec![route("b")]); + assert_eq!(s.names(), vec!["A".to_string(), "B".to_string()]); + assert!(s.remove("a")); + assert!(!s.remove("a")); + assert_eq!(s.names(), vec!["B".to_string()]); + } + + #[test] + fn round_trips_through_disk() { + let dir = std::env::temp_dir().join(format!("hydra-presets-{}", std::process::id())); + let path = dir.join("presets.json"); + let mut s = PresetStore::default(); + s.upsert("Calls", vec![route("us.zoom.xos")]); + s.save_to(&path).unwrap(); + assert_eq!(PresetStore::load_from(&path), s); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn missing_file_is_empty() { + let s = PresetStore::load_from(Path::new("/nonexistent/hydra/presets.json")); + assert!(s.presets.is_empty()); + } +} diff --git a/crates/hydra-ipc/src/lib.rs b/crates/hydra-ipc/src/lib.rs @@ -56,6 +56,15 @@ pub enum Command { /// (/Library/Application Support/hydra/devices.json); the new name takes effect on the /// next coreaudiod restart (the driver reads the manifest at load). SetDeviceName { name: String }, + /// List saved preset names. + ListPresets, + /// Save the current routing setup as a named preset (replaces one of the same name). + SavePreset { name: String }, + /// Replace all live routes with the named preset's routes. Apps not currently running + /// are skipped (same as restore-on-startup). + ApplyPreset { name: String }, + /// Delete a saved preset by name. + DeletePreset { name: String }, /// Opt this connection into the server-push event stream (state deltas, meters). Subscribe, /// Ask the daemon to exit. @@ -71,6 +80,10 @@ pub enum Response { Apps(Vec<AudioApp>), /// A route was created; carries its assigned id. RouteStarted { id: String }, + /// Saved preset names. + Presets(Vec<String>), + /// A preset was applied; carries how many of its routes were (re)established. + PresetApplied { restored: usize, total: usize }, Ok, Error(String), } diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs @@ -16,6 +16,16 @@ pub enum Connection { Disconnected { reason: String }, } +/// A modal text-entry prompt. Both flows are "type a name, ⏎ to confirm, esc to cancel"; +/// the kind decides what the confirmed text does. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PromptKind { + /// Rename the Hydra virtual device. + RenameDevice, + /// Save the current routing as a named preset. + SavePreset, +} + /// Which pane has keyboard focus. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { @@ -41,8 +51,10 @@ pub struct App { pub app_sel: usize, pub route_sel: usize, pub status: String, - /// When `Some`, the device-rename prompt is open and holds the in-progress text. - pub rename_buf: Option<String>, + /// When `Some`, a modal text prompt is open: (what it's for, in-progress text). + pub prompt: Option<(PromptKind, String)>, + /// When `Some`, the presets overlay is open: (preset names, selected index). + pub presets: Option<(Vec<String>, usize)>, pub should_quit: bool, } @@ -60,60 +72,141 @@ impl App { app_sel: 0, route_sel: 0, status: String::new(), - rename_buf: None, + prompt: None, + presets: None, should_quit: false, }; app.refresh(); app } - /// Open the device-rename prompt, seeded with the current text. + // ── Modal text prompt (rename device / save preset) ────────────────────────────── + pub fn begin_rename(&mut self) { - self.rename_buf = Some(String::new()); - self.status = "rename Hydra device: type a name, ⏎ to apply, esc to cancel".into(); + self.prompt = Some((PromptKind::RenameDevice, String::new())); + } + + pub fn begin_save_preset(&mut self) { + self.prompt = Some((PromptKind::SavePreset, String::new())); } - /// Append a typed char to the rename buffer (ignored if the prompt isn't open). - pub fn rename_push(&mut self, c: char) { - if let Some(buf) = self.rename_buf.as_mut() { + pub fn prompt_push(&mut self, c: char) { + if let Some((_, buf)) = self.prompt.as_mut() { buf.push(c); } } - /// Backspace in the rename buffer. - pub fn rename_backspace(&mut self) { - if let Some(buf) = self.rename_buf.as_mut() { + pub fn prompt_backspace(&mut self) { + if let Some((_, buf)) = self.prompt.as_mut() { buf.pop(); } } - pub fn rename_cancel(&mut self) { - self.rename_buf = None; - self.status = "rename cancelled".into(); + pub fn prompt_cancel(&mut self) { + self.prompt = None; + self.status = "cancelled".into(); } - /// Commit the rename: write the manifest via the daemon. Takes effect on the next - /// coreaudiod restart (the driver reads the device name at load). - pub fn rename_commit(&mut self) { - let Some(name) = self.rename_buf.take() else { return }; - let name = name.trim().to_string(); - if name.is_empty() { - self.status = "rename cancelled (empty)".into(); + /// Confirm the open prompt, dispatching by kind. + pub fn prompt_commit(&mut self) { + let Some((kind, text)) = self.prompt.take() else { return }; + let text = text.trim().to_string(); + if text.is_empty() { + self.status = "cancelled (empty)".into(); return; } - match client::request(Command::SetDeviceName { name: name.clone() }) { + match kind { + PromptKind::RenameDevice => match client::request(Command::SetDeviceName { name: text.clone() }) { + Ok(Response::Ok) => { + self.status = format!("device → \"{text}\" (apply: sudo killall coreaudiod)") + } + Ok(Response::Error(e)) => self.status = e, + Ok(other) => self.status = format!("unexpected: {other:?}"), + Err(e) => self.status = format!("rename failed: {e}"), + }, + PromptKind::SavePreset => match client::request(Command::SavePreset { name: text.clone() }) { + Ok(Response::Ok) => self.status = format!("saved preset \"{text}\""), + Ok(Response::Error(e)) => self.status = e, + Ok(other) => self.status = format!("unexpected: {other:?}"), + Err(e) => self.status = format!("save failed: {e}"), + }, + } + } + + /// Title shown above the open prompt, if any. + pub fn prompt_title(&self) -> Option<&'static str> { + self.prompt.as_ref().map(|(k, _)| match k { + PromptKind::RenameDevice => "rename device", + PromptKind::SavePreset => "save preset as", + }) + } + + pub fn prompt_text(&self) -> Option<&str> { + self.prompt.as_ref().map(|(_, b)| b.as_str()) + } + + pub fn is_prompting(&self) -> bool { + self.prompt.is_some() + } + + // ── Presets overlay (list → apply / delete) ─────────────────────────────────────── + + /// Open the presets overlay, fetching the current list from the daemon. + pub fn open_presets(&mut self) { + match client::request(Command::ListPresets) { + Ok(Response::Presets(names)) if !names.is_empty() => self.presets = Some((names, 0)), + Ok(Response::Presets(_)) => self.status = "no saved presets — press P to save the current routing".into(), + Ok(other) => self.status = format!("unexpected: {other:?}"), + Err(e) => self.status = format!("list presets failed: {e}"), + } + } + + pub fn presets_close(&mut self) { + self.presets = None; + } + + pub fn presets_move(&mut self, down: bool) { + if let Some((names, sel)) = self.presets.as_mut() { + if names.is_empty() { + return; + } + *sel = if down { (*sel + 1).min(names.len() - 1) } else { sel.saturating_sub(1) }; + } + } + + /// Apply the highlighted preset (replaces all live routes). + pub fn presets_apply(&mut self) { + let Some((names, sel)) = self.presets.as_ref() else { return }; + let Some(name) = names.get(*sel).cloned() else { return }; + self.presets = None; + match client::request(Command::ApplyPreset { name: name.clone() }) { + Ok(Response::PresetApplied { restored, total }) => { + self.status = format!("applied \"{name}\": {restored}/{total} route(s) live"); + } + Ok(Response::Error(e)) => self.status = e, + Ok(other) => self.status = format!("unexpected: {other:?}"), + Err(e) => self.status = format!("apply failed: {e}"), + } + self.refresh(); + } + + /// Delete the highlighted preset. + pub fn presets_delete(&mut self) { + let Some((names, sel)) = self.presets.as_ref() else { return }; + let Some(name) = names.get(*sel).cloned() else { return }; + match client::request(Command::DeletePreset { name: name.clone() }) { Ok(Response::Ok) => { - self.status = format!("device → \"{name}\" (restart coreaudiod to apply: sudo killall coreaudiod)") + self.status = format!("deleted preset \"{name}\""); + self.open_presets(); // refresh the list (closes if now empty) } Ok(Response::Error(e)) => self.status = e, Ok(other) => self.status = format!("unexpected: {other:?}"), - Err(e) => self.status = format!("rename failed: {e}"), + Err(e) => self.status = format!("delete failed: {e}"), } } - /// Whether the rename prompt is currently capturing input. - pub fn is_renaming(&self) -> bool { - self.rename_buf.is_some() + pub fn is_presets_open(&self) -> bool { + self.presets.is_some() } /// Pull connection status, app list, output devices, and routes from the daemon. diff --git a/crates/hydra/src/main.rs b/crates/hydra/src/main.rs @@ -56,9 +56,9 @@ fn run(terminal: &mut Tui, theme: Theme) -> Result<(), Box<dyn Error>> { } } - // Don't poll the daemon while the rename prompt is open — it would overwrite the - // status line and fight the text the user is typing. - if !app.is_renaming() && last_refresh.elapsed() >= REFRESH { + // Don't poll the daemon while a prompt or the presets overlay is open — it would + // overwrite the status line and fight the text the user is typing. + if !app.is_prompting() && !app.is_presets_open() && last_refresh.elapsed() >= REFRESH { app.refresh(); last_refresh = Instant::now(); } @@ -67,13 +67,25 @@ fn run(terminal: &mut Tui, theme: Theme) -> Result<(), Box<dyn Error>> { } fn handle_key(app: &mut App, code: KeyCode) { - // The rename prompt captures all keys while open. - if app.is_renaming() { + // A modal text prompt (rename / save-preset) captures all keys while open. + if app.is_prompting() { match code { - KeyCode::Enter => app.rename_commit(), - KeyCode::Esc => app.rename_cancel(), - KeyCode::Backspace => app.rename_backspace(), - KeyCode::Char(c) => app.rename_push(c), + KeyCode::Enter => app.prompt_commit(), + KeyCode::Esc => app.prompt_cancel(), + KeyCode::Backspace => app.prompt_backspace(), + KeyCode::Char(c) => app.prompt_push(c), + _ => {} + } + return; + } + // The presets overlay captures keys while open. + if app.is_presets_open() { + match code { + KeyCode::Esc | KeyCode::Char('q') => app.presets_close(), + KeyCode::Down | KeyCode::Char('j') => app.presets_move(true), + KeyCode::Up | KeyCode::Char('k') => app.presets_move(false), + KeyCode::Enter => app.presets_apply(), + KeyCode::Char('d') | KeyCode::Char('x') => app.presets_delete(), _ => {} } return; @@ -90,6 +102,8 @@ fn handle_key(app: &mut App, code: KeyCode) { KeyCode::Char('o') => app.cycle_output(), KeyCode::Char('a') => app.toggle_show_all(), KeyCode::Char('n') => app.begin_rename(), + KeyCode::Char('p') => app.open_presets(), + KeyCode::Char('P') => app.begin_save_preset(), KeyCode::Char('m') => app.toggle_mute_selected(), KeyCode::Char('d') | KeyCode::Char('x') => app.stop_selected(), KeyCode::Char('+') | KeyCode::Char('=') => app.adjust_gain(true), diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs @@ -4,7 +4,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, Frame, }; @@ -29,6 +29,73 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { draw_routes(f, panes[1], app, theme); draw_footer(f, rows[2], app, theme); + + // Presets overlay floats above everything when open. + if let Some((names, sel)) = &app.presets { + draw_presets_overlay(f, names, *sel, theme); + } +} + +/// A centered modal listing saved presets. +fn draw_presets_overlay(f: &mut Frame, names: &[String], sel: usize, theme: &Theme) { + let area = centered_rect(46, 60, f.area()); + f.render_widget(Clear, area); + + let items: Vec<ListItem> = names + .iter() + .map(|n| ListItem::new(Line::from(Span::styled(format!(" {n}"), Style::default().fg(theme.fg))))) + .collect(); + + let block = Block::default() + .title(Span::styled(" presets ", Style::default().fg(theme.accent).add_modifier(Modifier::BOLD))) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.ghost)) + .style(Style::default().bg(theme.bg_elevated)); + + let list = List::new(items) + .block(block) + .highlight_symbol("▶ ") + .highlight_style(Style::default().bg(theme.bg).fg(theme.ghost).add_modifier(Modifier::BOLD)); + + let mut state = ListState::default(); + if !names.is_empty() { + state.select(Some(sel)); + } + f.render_stateful_widget(list, area, &mut state); + + // Hint line just below the box. + if area.bottom() < f.area().bottom() { + let hint_area = Rect::new(area.x, area.bottom(), area.width, 1); + let hint_line = Line::from(vec![ + key("⏎", theme), + hint(" apply ", theme), + key("d", theme), + hint(" delete ", theme), + key("esc", theme), + hint(" close", theme), + ]); + f.render_widget(Paragraph::new(hint_line).style(Style::default().bg(theme.bg)), hint_area); + } +} + +/// A rect `pct_w` × `pct_h` percent of `r`, centered. +fn centered_rect(pct_w: u16, pct_h: u16, r: Rect) -> Rect { + let v = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - pct_h) / 2), + Constraint::Percentage(pct_h), + Constraint::Percentage((100 - pct_h) / 2), + ]) + .split(r); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - pct_w) / 2), + Constraint::Percentage(pct_w), + Constraint::Percentage((100 - pct_w) / 2), + ]) + .split(v[1])[1] } fn pane_block<'a>(title: &'a str, focused: bool, theme: &Theme) -> Block<'a> { @@ -143,13 +210,13 @@ fn draw_routes(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { } fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { - // While renaming, the footer becomes the text-entry prompt. - if let Some(buf) = &app.rename_buf { + // While a modal prompt is open, the footer becomes the text-entry line. + if let (Some(title), Some(buf)) = (app.prompt_title(), app.prompt_text()) { let line = Line::from(vec![ - Span::styled(" rename device ", Style::default().fg(theme.bg).bg(theme.accent).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {title} "), Style::default().fg(theme.bg).bg(theme.accent).add_modifier(Modifier::BOLD)), Span::styled(format!(" {buf}"), Style::default().fg(theme.fg)), Span::styled("▏", Style::default().fg(theme.ghost)), // cursor - Span::styled(" ⏎ apply · esc cancel", Style::default().fg(theme.fg_dim)), + Span::styled(" ⏎ confirm · esc cancel", Style::default().fg(theme.fg_dim)), ]); f.render_widget(Paragraph::new(line).style(Style::default().bg(theme.bg)), area); return; @@ -160,6 +227,8 @@ fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { hint(" select ", theme), key("⏎", theme), hint(" monitor ", theme), + key("p", theme), + hint(" presets ", theme), key("n", theme), hint(" name ", theme), key("␣", theme), diff --git a/crates/hydrad/src/server.rs b/crates/hydrad/src/server.rs @@ -9,10 +9,20 @@ use std::sync::{Arc, Mutex}; use hydra_core::engine::Engine; use hydra_core::ffi::{hal, process}; use hydra_core::manifest::{Manifest, ManifestDevice}; +use hydra_core::presets::PresetStore; use hydra_ipc::{read_msg, write_msg, Command, Response, PROTOCOL_VERSION}; type SharedEngine = Arc<Mutex<Engine>>; +/// Resolve an app bundle id to a currently-running audio-process PID, or None if it isn't +/// playing. Shared by startup-restore and preset-apply. +fn resolve_bundle_to_pid(bundle_id: &str) -> Option<i32> { + process::list_audio_processes() + .into_iter() + .find(|a| a.bundle_id.as_deref() == Some(bundle_id)) + .map(|a| a.pid) +} + /// Stable UID for Hydra's single virtual device. The forked driver keeps a fixed UID /// (`Hydra_UID`); only the display name is manifest-driven for now. const HYDRA_DEVICE_UID: &str = "Hydra_UID"; @@ -125,6 +135,46 @@ fn dispatch( r } Command::SetDeviceName { name } => set_device_name(&name), + Command::ListPresets => Response::Presets(PresetStore::load().names()), + Command::SavePreset { name } => { + let name = name.trim().to_string(); + if name.is_empty() { + Response::Error("preset name cannot be empty".into()) + } else { + let routes = engine.lock().unwrap().to_config().routes; + let mut store = PresetStore::load(); + store.upsert(&name, routes); + store.save(); + Response::Ok + } + } + Command::ApplyPreset { name } => { + let store = PresetStore::load(); + match store.get(&name) { + Some(preset) => { + let total = preset.routes.len(); + let routes = preset.routes.clone(); + let restored = { + let mut eng = engine.lock().unwrap(); + eng.clear_all(); + eng.apply_routes(&routes, resolve_bundle_to_pid) + }; + persist(engine); + notify_route_change(); + Response::PresetApplied { restored, total } + } + None => Response::Error(format!("no preset named {name:?}")), + } + } + Command::DeletePreset { name } => { + let mut store = PresetStore::load(); + if store.remove(&name) { + store.save(); + Response::Ok + } else { + Response::Error(format!("no preset named {name:?}")) + } + } // Server-push subscription lands in P4; acknowledge for now. Command::Subscribe => Response::Ok, Command::Shutdown => {