hydra

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

commit acbe771cab891ab484e45422e7042e32e914eaf6
parent 9ab9fcdd09a58c8ab456297248205a6057c0e064
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Sun, 31 May 2026 17:29:56 -0500

feat: real app names + foreground/background ranking in the app list

Addresses live feedback: the picker showed "client"/"pid 1234" and a wall of
system daemons. Now each audio process is resolved via NSRunningApplication
(localizedName + activationPolicy) in the Obj-C shim, falling back to libproc's
process name, then bundle-id tail, then pid. Adds a `kind` rank (0 foreground
Dock app, 1 menu-bar/accessory, 2 plain process) on AudioApp; the daemon sorts
by kind then name. Verified live: REAPER / Safari / Vesktop now show real names
at the top; daemons sink to the bottom.

TUI: hides kind-2 background processes by default (real apps only); 'a' toggles
"all apps". Foreground apps render in full fg, helpers dimmed. Links AppKit.

19 tests green, 0 warnings.

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

Diffstat:
Mcrates/hydra-core/build.rs | 1+
Mcrates/hydra-core/src/ffi/process.rs | 60++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/hydra-core/src/ffi/tap_shim.m | 39+++++++++++++++++++++++++++++++++++++++
Mcrates/hydra-ipc/src/lib.rs | 4++++
Mcrates/hydra/src/app.rs | 39++++++++++++++++++++++++++++++---------
Mcrates/hydra/src/main.rs | 1+
Mcrates/hydra/src/ui.rs | 15++++++++++-----
7 files changed, 127 insertions(+), 32 deletions(-)

diff --git a/crates/hydra-core/build.rs b/crates/hydra-core/build.rs @@ -12,6 +12,7 @@ fn main() { println!("cargo:rerun-if-changed=src/ffi/tap_shim.m"); println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=AppKit"); println!("cargo:rustc-link-lib=framework=CoreFoundation"); println!("cargo:rustc-link-lib=framework=CoreAudio"); } diff --git a/crates/hydra-core/src/ffi/process.rs b/crates/hydra-core/src/ffi/process.rs @@ -2,14 +2,32 @@ //! //! CoreAudio exposes a "process object" per app that has touched audio. Reading the //! list and each process's PID/bundle-id needs no capture permission; only *tapping* -//! one (P1's engine) requires TCC consent. +//! one (the engine) requires TCC consent. App display names + a foreground/background +//! rank come from `NSRunningApplication` (via the Obj-C shim), so the list reads as +//! "Spotify", "Google Chrome" — not "client" or "pid 1234". + +use std::ffi::c_char; use anyhow::Result; use hydra_ipc::AudioApp; use super::{addr, get_array, get_cfstring, get_scalar, scope, sel, AudioObjectID, ELEMENT_MAIN, SYSTEM_OBJECT}; -/// Enumerate processes registered with CoreAudio as audio producers. +extern "C" { + fn hydra_app_info(pid: i32, out_name: *mut c_char, name_cap: i32) -> i32; +} + +/// Resolve a PID to (display name, kind rank) via NSRunningApplication / libproc. +fn app_info(pid: i32) -> (Option<String>, u8) { + let mut buf = [0i8; 256]; + let kind = unsafe { hydra_app_info(pid, buf.as_mut_ptr(), buf.len() as i32) }; + let name = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_string_lossy() + .into_owned(); + (if name.is_empty() { None } else { Some(name) }, kind.clamp(0, 2) as u8) +} + +/// Enumerate processes registered with CoreAudio as audio producers, best-named first. /// /// Returns an empty list (not an error) on macOS older than 14.4, where the process /// object list selector doesn't exist. @@ -20,16 +38,25 @@ pub fn list_audio_processes() -> Vec<AudioApp> { Err(_) => return Vec::new(), }; - let mut apps = Vec::with_capacity(ids.len()); - for id in ids { - let pid: i32 = - unsafe { get_scalar(id, &addr(sel::PROCESS_PID, scope::GLOBAL, ELEMENT_MAIN)).unwrap_or(-1) }; - let bundle_id = unsafe { get_cfstring(id, &addr(sel::PROCESS_BUNDLE_ID, scope::GLOBAL, ELEMENT_MAIN)) } - .ok() - .filter(|s| !s.is_empty()); - let name = friendly_name(pid, bundle_id.as_deref()); - apps.push(AudioApp { pid, name, bundle_id }); - } + let mut apps: Vec<AudioApp> = ids + .into_iter() + .map(|id| { + let pid: i32 = + unsafe { get_scalar(id, &addr(sel::PROCESS_PID, scope::GLOBAL, ELEMENT_MAIN)).unwrap_or(-1) }; + let bundle_id = + unsafe { get_cfstring(id, &addr(sel::PROCESS_BUNDLE_ID, scope::GLOBAL, ELEMENT_MAIN)) } + .ok() + .filter(|s| !s.is_empty()); + let (app_name, kind) = app_info(pid); + let name = app_name + .or_else(|| bundle_id.as_deref().map(friendly_from_bundle)) + .unwrap_or_else(|| format!("pid {pid}")); + AudioApp { pid, name, bundle_id, kind } + }) + .collect(); + + // Foreground apps first, then background, then daemons; alphabetical within a rank. + apps.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); apps } @@ -58,10 +85,7 @@ pub fn process_object_for_pid(pid: i32) -> Result<AudioObjectID> { Ok(obj) } -/// Best-effort display name: bundle-id tail if present, else `pid N`. -fn friendly_name(pid: i32, bundle_id: Option<&str>) -> String { - match bundle_id { - Some(b) => b.rsplit('.').next().filter(|s| !s.is_empty()).unwrap_or(b).to_string(), - None => format!("pid {pid}"), - } +/// Last-resort name from a bundle id: the tail component (e.g. com.spotify.client → client). +fn friendly_from_bundle(bundle_id: &str) -> String { + bundle_id.rsplit('.').next().filter(|s| !s.is_empty()).unwrap_or(bundle_id).to_string() } diff --git a/crates/hydra-core/src/ffi/tap_shim.m b/crates/hydra-core/src/ffi/tap_shim.m @@ -15,8 +15,10 @@ // buffer of gain — acceptable for a control parameter, and lock-free by construction. #import <Foundation/Foundation.h> +#import <AppKit/AppKit.h> #import <CoreAudio/CoreAudio.h> #import <CoreAudio/CATapDescription.h> +#import <libproc.h> #import <math.h> // The process-tap entry points (macOS 14.4+) aren't pulled in by the CoreAudio umbrella @@ -25,6 +27,43 @@ extern OSStatus AudioHardwareCreateProcessTap(CATapDescription *inDescription, AudioObjectID *outTapID); extern OSStatus AudioHardwareDestroyProcessTap(AudioObjectID inTapID); +// Resolve a PID to a human-friendly app name + a "kind" rank for sorting/filtering. +// Writes a UTF-8 name into out_name (capacity name_cap) and returns: +// 0 = regular foreground app (has a Dock presence — the apps users think of) +// 1 = accessory/background app with a known name (menu-bar agents etc.) +// 2 = plain process (name from the executable; system daemons, helpers) +// Prefers NSRunningApplication.localizedName; falls back to libproc's process name. +int hydra_app_info(int pid, char *out_name, int name_cap) { + if (!out_name || name_cap <= 0) return 2; + out_name[0] = '\0'; + int kind = 2; + + @autoreleasepool { + NSRunningApplication *app = + [NSRunningApplication runningApplicationWithProcessIdentifier:(pid_t)pid]; + NSString *name = nil; + if (app != nil) { + name = app.localizedName; + switch (app.activationPolicy) { + case NSApplicationActivationPolicyRegular: kind = 0; break; // Dock app + case NSApplicationActivationPolicyAccessory: kind = 1; break; // menu-bar agent + default: kind = 1; break; + } + } + if (name != nil && name.length > 0) { + strlcpy(out_name, name.UTF8String, (size_t)name_cap); + return kind; + } + } + + // Fallback: executable name via libproc (no AppKit identity, e.g. CLI/helpers). + char proc[PROC_PIDPATHINFO_MAXSIZE]; + if (proc_name(pid, proc, sizeof(proc)) > 0 && proc[0] != '\0') { + strlcpy(out_name, proc, (size_t)name_cap); + } + return 2; +} + typedef struct { float gain; // linear, read by IOProc int muted; // 0/1, read by IOProc diff --git a/crates/hydra-ipc/src/lib.rs b/crates/hydra-ipc/src/lib.rs @@ -84,6 +84,10 @@ pub struct AudioApp { pub pid: i32, pub name: String, pub bundle_id: Option<String>, + /// Sort/priority rank: 0 = foreground Dock app, 1 = menu-bar/background app, + /// 2 = plain process (system daemons, helpers). Lower = more likely a real target. + #[serde(default)] + pub kind: u8, } /// Server-initiated push messages (only sent after [`Command::Subscribe`]). diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs @@ -20,7 +20,11 @@ pub enum Focus { pub struct App { pub connection: Connection, + /// All audio processes from the daemon (already kind-sorted). pub apps: Vec<AudioApp>, + /// When false (default), background daemons (kind 2) are hidden from the app list, + /// so you see real apps (Spotify, browsers, DAWs) not the system-helper wall. + pub show_all_apps: bool, pub routes: Vec<RouteSummary>, /// Output devices a route can target (output_channels > 0), e.g. speakers or "Hydra". pub outputs: Vec<AudioDevice>, @@ -38,6 +42,7 @@ impl App { let mut app = App { connection: Connection::Disconnected { reason: "connecting…".into() }, apps: Vec::new(), + show_all_apps: false, routes: Vec::new(), outputs: Vec::new(), output_sel: 0, @@ -67,8 +72,8 @@ impl App { } } - if let Ok(Response::Apps(mut apps)) = client::request(Command::ListApps) { - apps.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + if let Ok(Response::Apps(apps)) = client::request(Command::ListApps) { + // Daemon already sorts by kind then name; keep that order. self.apps = apps; } if let Ok(Response::Devices(devices)) = client::request(Command::ListDevices) { @@ -92,8 +97,19 @@ impl App { .unwrap_or(0); } + /// The app list as shown: real apps only (kind < 2) unless `show_all_apps` is on. + pub fn visible_apps(&self) -> Vec<&AudioApp> { + self.apps.iter().filter(|a| self.show_all_apps || a.kind < 2).collect() + } + + /// Toggle whether background daemons/helpers appear in the app list. + pub fn toggle_show_all(&mut self) { + self.show_all_apps = !self.show_all_apps; + self.clamp_selection(); + } + fn clamp_selection(&mut self) { - self.app_sel = self.app_sel.min(self.apps.len().saturating_sub(1)); + self.app_sel = self.app_sel.min(self.visible_apps().len().saturating_sub(1)); self.route_sel = self.route_sel.min(self.routes.len().saturating_sub(1)); self.output_sel = self.output_sel.min(self.outputs.len().saturating_sub(1)); } @@ -120,8 +136,11 @@ impl App { pub fn move_down(&mut self) { match self.focus { - Focus::Apps if !self.apps.is_empty() => { - self.app_sel = (self.app_sel + 1).min(self.apps.len() - 1) + Focus::Apps => { + let n = self.visible_apps().len(); + if n > 0 { + self.app_sel = (self.app_sel + 1).min(n - 1); + } } Focus::Routes if !self.routes.is_empty() => { self.route_sel = (self.route_sel + 1).min(self.routes.len() - 1) @@ -139,11 +158,13 @@ impl App { /// Start monitoring the selected app to the selected output device. pub fn start_selected(&mut self) { - let Some(app) = self.apps.get(self.app_sel) else { return }; + let visible = self.visible_apps(); + let Some(app) = visible.get(self.app_sel) else { return }; + let (pid, name) = (app.pid, app.name.clone()); let output_uid = self.selected_output().map(|d| d.uid.clone()); - let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output"); - match client::request(Command::StartMonitor { pid: app.pid, output_uid, gain: 1.0 }) { - Ok(Response::RouteStarted { id }) => self.status = format!("{id}: {} → {dest}", app.name), + let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output").to_string(); + match client::request(Command::StartMonitor { pid, output_uid, gain: 1.0 }) { + Ok(Response::RouteStarted { id }) => self.status = format!("{id}: {name} → {dest}"), Ok(Response::Error(e)) => self.status = format!("start failed: {e}"), Ok(other) => self.status = format!("unexpected: {other:?}"), Err(e) => self.status = format!("start failed: {e}"), diff --git a/crates/hydra/src/main.rs b/crates/hydra/src/main.rs @@ -73,6 +73,7 @@ fn handle_key(app: &mut App, code: KeyCode) { KeyCode::Up | KeyCode::Char('k') => app.move_up(), KeyCode::Enter => app.start_selected(), KeyCode::Char('o') => app.cycle_output(), + KeyCode::Char('a') => app.toggle_show_all(), KeyCode::Char('m') => app.toggle_mute_selected(), KeyCode::Char('d') | KeyCode::Char('x') => app.stop_selected(), KeyCode::Char('+') | KeyCode::Char('=') => app.adjust_gain(0.05), diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs @@ -71,24 +71,27 @@ fn draw_header(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { fn draw_apps(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { let focused = app.focus == Focus::Apps; - let items: Vec<ListItem> = app - .apps + let visible = app.visible_apps(); + let items: Vec<ListItem> = visible .iter() .map(|a| { + // Foreground apps in full fg; background helpers dimmed. + let name_color = if a.kind == 0 { theme.fg } else { theme.fg_dim }; ListItem::new(Line::from(vec![ Span::styled(format!("{:>6} ", a.pid), Style::default().fg(theme.fg_dim)), - Span::styled(a.name.clone(), Style::default().fg(theme.fg)), + Span::styled(a.name.clone(), Style::default().fg(name_color)), ])) }) .collect(); + let title = if app.show_all_apps { "audio apps (all)" } else { "audio apps" }; let list = List::new(items) - .block(pane_block("audio apps", focused, theme)) + .block(pane_block(title, focused, theme)) .highlight_symbol("▶ ") .highlight_style(selection_style(focused, theme)); let mut state = ListState::default(); - if !app.apps.is_empty() { + if !visible.is_empty() { state.select(Some(app.app_sel)); } f.render_stateful_widget(list, area, &mut state); @@ -145,6 +148,8 @@ fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { hint(" monitor ", theme), key("o", theme), hint(" output ", theme), + key("a", theme), + hint(" all apps ", theme), key("⇥", theme), hint(" routes ", theme), ],