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:
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 => {