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:
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),
],