hydra

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

process.rs (3791B)


      1 //! Process enumeration — the candidate capture targets for per-app routing.
      2 //!
      3 //! CoreAudio exposes a "process object" per app that has touched audio. Reading the
      4 //! list and each process's PID/bundle-id needs no capture permission; only *tapping*
      5 //! one (the engine) requires TCC consent. App display names + a foreground/background
      6 //! rank come from `NSRunningApplication` (via the Obj-C shim), so the list reads as
      7 //! "Spotify", "Google Chrome" — not "client" or "pid 1234".
      8 
      9 use std::ffi::c_char;
     10 
     11 use anyhow::Result;
     12 use hydra_ipc::AudioApp;
     13 
     14 use super::{addr, get_array, get_cfstring, get_scalar, scope, sel, AudioObjectID, ELEMENT_MAIN, SYSTEM_OBJECT};
     15 
     16 extern "C" {
     17     fn hydra_app_info(pid: i32, out_name: *mut c_char, name_cap: i32) -> i32;
     18 }
     19 
     20 /// Resolve a PID to (display name, kind rank) via NSRunningApplication / libproc.
     21 fn app_info(pid: i32) -> (Option<String>, u8) {
     22     let mut buf = [0i8; 256];
     23     let kind = unsafe { hydra_app_info(pid, buf.as_mut_ptr(), buf.len() as i32) };
     24     let name = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }
     25         .to_string_lossy()
     26         .into_owned();
     27     (if name.is_empty() { None } else { Some(name) }, kind.clamp(0, 2) as u8)
     28 }
     29 
     30 /// Enumerate processes registered with CoreAudio as audio producers, best-named first.
     31 ///
     32 /// Returns an empty list (not an error) on macOS older than 14.4, where the process
     33 /// object list selector doesn't exist.
     34 pub fn list_audio_processes() -> Vec<AudioApp> {
     35     let ids: Vec<AudioObjectID> =
     36         match unsafe { get_array(SYSTEM_OBJECT, &addr(sel::PROCESS_LIST, scope::GLOBAL, ELEMENT_MAIN)) } {
     37             Ok(ids) => ids,
     38             Err(_) => return Vec::new(),
     39         };
     40 
     41     let mut apps: Vec<AudioApp> = ids
     42         .into_iter()
     43         .map(|id| {
     44             let pid: i32 =
     45                 unsafe { get_scalar(id, &addr(sel::PROCESS_PID, scope::GLOBAL, ELEMENT_MAIN)).unwrap_or(-1) };
     46             let bundle_id =
     47                 unsafe { get_cfstring(id, &addr(sel::PROCESS_BUNDLE_ID, scope::GLOBAL, ELEMENT_MAIN)) }
     48                     .ok()
     49                     .filter(|s| !s.is_empty());
     50             let (app_name, kind) = app_info(pid);
     51             let name = app_name
     52                 .or_else(|| bundle_id.as_deref().map(friendly_from_bundle))
     53                 .unwrap_or_else(|| format!("pid {pid}"));
     54             AudioApp { pid, name, bundle_id, kind }
     55         })
     56         .collect();
     57 
     58     // Foreground apps first, then background, then daemons; alphabetical within a rank.
     59     apps.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
     60     apps
     61 }
     62 
     63 /// Resolve a PID to its CoreAudio process object (needed before creating a tap).
     64 ///
     65 /// Uses `kAudioHardwarePropertyTranslatePIDToProcessObject`, which takes the PID as
     66 /// qualifier data and returns the matching object ID.
     67 pub fn process_object_for_pid(pid: i32) -> Result<AudioObjectID> {
     68     let a = addr(sel::TRANSLATE_PID_TO_PROCESS, scope::GLOBAL, ELEMENT_MAIN);
     69     let mut obj: AudioObjectID = 0;
     70     let mut io = std::mem::size_of::<AudioObjectID>() as u32;
     71     let mut pid_q = pid;
     72     let st = unsafe {
     73         coreaudio_sys::AudioObjectGetPropertyData(
     74             SYSTEM_OBJECT,
     75             &a,
     76             std::mem::size_of::<i32>() as u32,
     77             &mut pid_q as *mut _ as *const _,
     78             &mut io,
     79             &mut obj as *mut _ as *mut _,
     80         )
     81     };
     82     if st != 0 || obj == 0 {
     83         anyhow::bail!("translate pid {pid} -> process object failed (OSStatus {st})");
     84     }
     85     Ok(obj)
     86 }
     87 
     88 /// Last-resort name from a bundle id: the tail component (e.g. com.spotify.client → client).
     89 fn friendly_from_bundle(bundle_id: &str) -> String {
     90     bundle_id.rsplit('.').next().filter(|s| !s.is_empty()).unwrap_or(bundle_id).to_string()
     91 }