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 }