diff --git a/tauri/src-tauri/bin/mic_check b/tauri/src-tauri/bin/mic_check index 8419e31a..c74032e1 100755 Binary files a/tauri/src-tauri/bin/mic_check and b/tauri/src-tauri/bin/mic_check differ diff --git a/tauri/src-tauri/bin/mic_check-aarch64-apple-darwin b/tauri/src-tauri/bin/mic_check-aarch64-apple-darwin index 8419e31a..c74032e1 100755 Binary files a/tauri/src-tauri/bin/mic_check-aarch64-apple-darwin and b/tauri/src-tauri/bin/mic_check-aarch64-apple-darwin differ diff --git a/tauri/src-tauri/build.rs b/tauri/src-tauri/build.rs index f373d7d0..bc6ef4bf 100644 --- a/tauri/src-tauri/build.rs +++ b/tauri/src-tauri/build.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; fn main() { + compile_mic_check_helper(); compile_system_audio_helper(); compile_calendar_helper(); stage_minutes_cli_sidecar(); @@ -108,6 +109,42 @@ fn stage_minutes_cli_sidecar() { } } +fn compile_mic_check_helper() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "macos" { + return; + } + + let manifest_dir = PathBuf::from( + std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR should be set"), + ); + let source = manifest_dir.join("src/mic_check.swift"); + let bin_dir = manifest_dir.join("bin"); + let binary = bin_dir.join("mic_check"); + let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown-target".into()); + let target_binary = bin_dir.join(format!("mic_check-{}", target)); + + println!("cargo:rerun-if-changed={}", source.display()); + std::fs::create_dir_all(&bin_dir).expect("failed to create helper bin dir"); + + let output = Command::new("swiftc") + .arg(&source) + .arg("-o") + .arg(&binary) + .output() + .expect("failed to run swiftc for mic_check"); + + if !output.status.success() { + panic!( + "failed to compile mic_check.swift: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + std::fs::copy(&binary, &target_binary) + .expect("failed to copy target-specific mic_check helper"); +} + fn compile_system_audio_helper() { let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); if target_os != "macos" { diff --git a/tauri/src-tauri/src/call_detect.rs b/tauri/src-tauri/src/call_detect.rs index 1ec3f6ab..856d541b 100644 --- a/tauri/src-tauri/src/call_detect.rs +++ b/tauri/src-tauri/src/call_detect.rs @@ -1,14 +1,14 @@ //! Auto-detect video/voice calls and prompt the user to start recording. //! -//! Detection strategy: poll for known call-app processes that are actively -//! using the microphone. Two signals together (process running + mic active) +//! Detection strategy: poll for known call-app processes while any audio input +//! is actively capturing. Two signals together (process running + mic active) //! give high confidence with minimal false positives. //! //! Currently macOS-only. The detection functions (`running_process_names`, //! `is_mic_in_use`) use CoreAudio and `ps`. Windows/Linux would need //! alternative implementations behind `cfg(target_os)` gates. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -221,17 +221,56 @@ fn is_low_confidence_native_call_app(app: &str) -> bool { matches!(app.to_ascii_lowercase().as_str(), "slack") } -fn native_app_matches_running_process(config_app: &str, running: &[String]) -> bool { +#[derive(Debug, Clone, PartialEq, Eq)] +struct RunningProcess { + pid: u32, + ppid: u32, + name: String, +} + +fn process_name_matches_config_app(config_app: &str, process_name: &str) -> bool { let config_lower = config_app.to_lowercase(); - running.iter().any(|p| { - let p_lower = p.to_lowercase(); - // Exact match (most common) or the config name is a prefix/suffix of - // the binary name (e.g. "zoom.us" matches "zoom.us"), but NOT a mere - // substring of a longer daemon name. - p_lower == config_lower - || p_lower.starts_with(&format!("{}.", config_lower)) - || p_lower.starts_with(&format!("{} ", config_lower)) - }) + let process_lower = process_name.to_lowercase(); + // Exact match (most common) or the config name is a prefix/suffix of + // the binary name (e.g. "zoom.us" matches "zoom.us"), but NOT a mere + // substring of a longer daemon name. + process_lower == config_lower + || process_lower.starts_with(&format!("{}.", config_lower)) + || process_lower.starts_with(&format!("{} ", config_lower)) +} + +fn native_app_candidate_process_pids( + config_app: &str, + processes: &[RunningProcess], +) -> HashSet { + let mut candidates: HashSet = processes + .iter() + .filter(|process| process_name_matches_config_app(config_app, &process.name)) + .map(|process| process.pid) + .collect(); + + let mut changed = true; + while changed { + changed = false; + for process in processes { + if candidates.contains(&process.ppid) && candidates.insert(process.pid) { + changed = true; + } + } + } + + candidates +} + +fn native_app_has_active_input( + config_app: &str, + processes: &[RunningProcess], + active_input_pids: &HashSet, +) -> bool { + let candidate_pids = native_app_candidate_process_pids(config_app, processes); + candidate_pids + .iter() + .any(|pid| active_input_pids.contains(pid)) } enum BrowserMeetProbe { @@ -700,14 +739,19 @@ impl CallDetector { /// Check if any configured call app is active. fn detect_active_call(&self, config: &CallDetectionConfig) -> DetectActiveCallResult { - let mic_live = is_mic_in_use(); + let processes = running_processes(); + let active_input_pids = active_input_process_pids(); + let mic_live = active_input_pids + .as_ref() + .map(|pids| !pids.is_empty()) + .unwrap_or_else(is_mic_in_use); let mic_just_activated = self.note_mic_state(mic_live); - let running = running_process_names(); - self.detect_active_call_from_snapshot( + self.detect_active_call_from_process_snapshot( config, mic_live, - &running, + &processes, + active_input_pids.as_ref(), mic_just_activated, |detector, running, has_google_meet, has_teams_web| { if has_google_meet || has_teams_web { @@ -720,12 +764,62 @@ impl CallDetector { ) } + #[cfg(test)] fn detect_active_call_from_snapshot( &self, config: &CallDetectionConfig, mic_live: bool, running: &[String], force_browser_probe: bool, + browser_probe: F, + ) -> DetectActiveCallResult + where + F: FnMut(&Self, &[String], bool, bool) -> Option, + { + self.detect_active_call_from_snapshot_with_processes( + config, + mic_live, + running, + None, + None, + force_browser_probe, + browser_probe, + ) + } + + fn detect_active_call_from_process_snapshot( + &self, + config: &CallDetectionConfig, + mic_live: bool, + processes: &[RunningProcess], + active_input_pids: Option<&HashSet>, + force_browser_probe: bool, + browser_probe: F, + ) -> DetectActiveCallResult + where + F: FnMut(&Self, &[String], bool, bool) -> Option, + { + let running = process_names_from_snapshot(processes); + self.detect_active_call_from_snapshot_with_processes( + config, + mic_live, + &running, + Some(processes), + active_input_pids, + force_browser_probe, + browser_probe, + ) + } + + #[allow(clippy::too_many_arguments)] + fn detect_active_call_from_snapshot_with_processes( + &self, + config: &CallDetectionConfig, + mic_live: bool, + running: &[String], + processes: Option<&[RunningProcess]>, + active_input_pids: Option<&HashSet>, + force_browser_probe: bool, mut browser_probe: F, ) -> DetectActiveCallResult where @@ -787,7 +881,13 @@ impl CallDetector { // e.g. "FaceTime" should match the "FaceTime" binary, NOT // "com.apple.FaceTime.FTConversationService" (a system daemon // that runs permanently and caused false positives). - if native_app_matches_running_process(config_app, running) { + let native_active = match (processes, active_input_pids) { + (Some(processes), Some(active_input_pids)) => { + native_app_has_active_input(config_app, processes, active_input_pids) + } + _ => false, + }; + if native_active { let display = display_name_for(config_app); return DetectActiveCallResult::Detected { display_name: display, @@ -797,7 +897,13 @@ impl CallDetector { } for config_app in low_confidence_native_apps { - if native_app_matches_running_process(config_app, running) { + let native_active = match (processes, active_input_pids) { + (Some(processes), Some(active_input_pids)) => { + native_app_has_active_input(config_app, processes, active_input_pids) + } + _ => false, + }; + if native_active { let display = display_name_for(config_app); return DetectActiveCallResult::Detected { display_name: display, @@ -1293,8 +1399,14 @@ fn looks_like_teams_meeting_url(url: &str) -> bool { // ── macOS-specific detection ────────────────────────────────── +fn binary_name_from_command(command: &str) -> String { + let trimmed = command.trim(); + trimmed.rsplit('/').next().unwrap_or(trimmed).to_string() +} + /// Get list of running process names via `ps`. Fast (~2ms), no permissions /// needed, no osascript overhead. +#[cfg(test)] fn running_process_names() -> Vec { let output = std::process::Command::new("ps") .args(["-eo", "comm="]) @@ -1309,6 +1421,28 @@ fn running_process_names() -> Vec { } } +fn running_processes() -> Vec { + let output = std::process::Command::new("ps") + .args(["-axo", "pid=,ppid=,comm="]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let text = String::from_utf8_lossy(&out.stdout); + process_snapshots_from_ps_output(&text) + } + _ => Vec::new(), + } +} + +fn process_names_from_snapshot(processes: &[RunningProcess]) -> Vec { + processes + .iter() + .map(|process| process.name.clone()) + .collect() +} + +#[cfg(test)] fn process_names_from_ps_output(text: &str) -> Vec { text.lines() .filter_map(|line| { @@ -1318,16 +1452,42 @@ fn process_names_from_ps_output(text: &str) -> Vec { if trimmed.is_empty() { return None; } - Some(trimmed.rsplit('/').next().unwrap_or(trimmed).to_string()) + Some(binary_name_from_command(trimmed)) + }) + .collect() +} + +fn split_first_field(text: &str) -> Option<(&str, &str)> { + let trimmed = text.trim_start(); + let end = trimmed.find(char::is_whitespace)?; + Some((&trimmed[..end], &trimmed[end..])) +} + +fn process_snapshots_from_ps_output(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let (pid_text, rest) = split_first_field(trimmed)?; + let (ppid_text, command) = split_first_field(rest)?; + let pid = pid_text.parse().ok()?; + let ppid = ppid_text.parse().ok()?; + Some(RunningProcess { + pid, + ppid, + name: binary_name_from_command(command), + }) }) .collect() } -/// Check if the default audio input device is currently being used. +/// Check if any audio input is currently being used. /// -/// Uses a pre-compiled Swift helper that calls CoreAudio -/// `kAudioDevicePropertyDeviceIsRunningSomewhere` on the default input device. -/// Works on both Intel and Apple Silicon Macs. +/// Uses a pre-compiled Swift helper that queries CoreAudio process input +/// activity and falls back to scanning input-capable devices. This catches +/// call apps that capture from a non-default input route. /// /// Falls back to an inline `swift` invocation if the helper binary is missing. fn is_mic_in_use() -> bool { @@ -1343,22 +1503,9 @@ fn is_mic_in_use() -> bool { } // Fallback: inline swift (slower: ~200ms, but always works) - let script = r#" -import CoreAudio -var id = AudioObjectID(kAudioObjectSystemObject) -var pa = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) -var sz = UInt32(MemoryLayout.size) -guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &pa, 0, nil, &sz, &id) == noErr else { print("0"); exit(0) } -var r: UInt32 = 0 -var ra = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) -sz = UInt32(MemoryLayout.size) -guard AudioObjectGetPropertyData(id, &ra, 0, nil, &sz, &r) == noErr else { print("0"); exit(0) } -print(r > 0 ? "1" : "0") -"#; - let output = std::process::Command::new("swift") .arg("-e") - .arg(script) + .arg(include_str!("mic_check.swift")) .output(); match output { @@ -1367,6 +1514,29 @@ print(r > 0 ? "1" : "0") } } +fn active_input_process_pids() -> Option> { + let helper = find_mic_check_binary()?; + let out = std::process::Command::new(helper) + .arg("--active-input-pids") + .output() + .ok()?; + if !out.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&out.stdout); + let mut pids = HashSet::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let pid = trimmed.parse().ok()?; + pids.insert(pid); + } + Some(pids) +} + /// Find the pre-compiled mic_check binary. /// Checks next to the app binary first, then the source tree location. fn find_mic_check_binary() -> Option { @@ -1544,41 +1714,39 @@ mod tests { } #[test] - fn native_app_detection_wins_before_browser_meet_probe() { + fn native_app_does_not_fall_back_to_process_match_after_browser_no_match() { let detector = CallDetector::new(test_call_detection_config(vec![ "zoom.us".into(), "google-meet".into(), ])); - - let running = ["zoom.us".into(), "Safari".into()]; - let mic_live = true; - - // Reproduce the decision ordering from detect_active_call without relying - // on live browser automation in tests. let config = detector.current_config(); - let native_apps: Vec<&String> = config - .apps - .iter() - .filter(|app| app.as_str() != "google-meet") - .collect(); + let processes = vec![ + RunningProcess { + pid: 100, + ppid: 1, + name: "zoom.us".into(), + }, + RunningProcess { + pid: 200, + ppid: 1, + name: "Google Chrome".into(), + }, + ]; - let mut detected = None; - if mic_live { - for config_app in native_apps { - let config_lower = config_app.to_lowercase(); - if running.iter().any(|p: &String| { - let p_lower = p.to_lowercase(); - p_lower == config_lower - || p_lower.starts_with(&format!("{}.", config_lower)) - || p_lower.starts_with(&format!("{} ", config_lower)) - }) { - detected = Some(config_app.clone()); - break; - } - } - } + let result = detector.detect_active_call_from_process_snapshot( + &config, + true, + &processes, + None, + false, + |_detector, _running, want_meet, want_teams| { + assert!(want_meet); + assert!(!want_teams); + Some(BrowserMeetProbe::NoMatch) + }, + ); - assert_eq!(detected.as_deref(), Some("zoom.us")); + assert_eq!(result, DetectActiveCallResult::None); } #[test] @@ -1640,12 +1808,25 @@ mod tests { "google-meet".into(), ])); let config = detector.current_config(); - let running = vec!["zoom.us".into(), "Google Chrome".into()]; + let processes = vec![ + RunningProcess { + pid: 100, + ppid: 1, + name: "zoom.us".into(), + }, + RunningProcess { + pid: 200, + ppid: 1, + name: "Google Chrome".into(), + }, + ]; + let active_input_pids = HashSet::from([100]); - let result = detector.detect_active_call_from_snapshot( + let result = detector.detect_active_call_from_process_snapshot( &config, true, - &running, + &processes, + Some(&active_input_pids), false, |_detector, _running, _want_meet, _want_teams| Some(BrowserMeetProbe::NoMatch), ); @@ -1666,12 +1847,25 @@ mod tests { "google-meet".into(), ])); let config = detector.current_config(); - let running = vec!["Slack".into(), "Google Chrome".into()]; + let processes = vec![ + RunningProcess { + pid: 100, + ppid: 1, + name: "Slack".into(), + }, + RunningProcess { + pid: 200, + ppid: 1, + name: "Google Chrome".into(), + }, + ]; + let active_input_pids = HashSet::from([100]); - let result = detector.detect_active_call_from_snapshot( + let result = detector.detect_active_call_from_process_snapshot( &config, true, - &running, + &processes, + Some(&active_input_pids), false, |_detector, _running, _want_meet, _want_teams| Some(BrowserMeetProbe::NoMatch), ); @@ -1685,6 +1879,161 @@ mod tests { ); } + #[test] + fn idle_zoom_does_not_detect_when_another_app_has_input() { + let detector = CallDetector::new(test_call_detection_config(vec!["zoom.us".into()])); + let config = detector.current_config(); + let processes = vec![ + RunningProcess { + pid: 100, + ppid: 1, + name: "zoom.us".into(), + }, + RunningProcess { + pid: 200, + ppid: 1, + name: "superwhisper".into(), + }, + ]; + let active_input_pids = HashSet::from([200]); + + let result = detector.detect_active_call_from_process_snapshot( + &config, + true, + &processes, + Some(&active_input_pids), + false, + |_detector, _running, _want_meet, _want_teams| None, + ); + + assert_eq!(result, DetectActiveCallResult::None); + } + + #[test] + fn idle_zoom_does_not_detect_when_active_input_attribution_is_unavailable() { + let detector = CallDetector::new(test_call_detection_config(vec!["zoom.us".into()])); + let config = detector.current_config(); + let processes = vec![ + RunningProcess { + pid: 100, + ppid: 1, + name: "zoom.us".into(), + }, + RunningProcess { + pid: 200, + ppid: 1, + name: "superwhisper".into(), + }, + ]; + + let result = detector.detect_active_call_from_process_snapshot( + &config, + true, + &processes, + None, + false, + |_detector, _running, _want_meet, _want_teams| None, + ); + + assert_eq!(result, DetectActiveCallResult::None); + } + + #[test] + fn zoom_helper_input_detects_zoom_call() { + let detector = CallDetector::new(test_call_detection_config(vec!["zoom.us".into()])); + let config = detector.current_config(); + let processes = vec![ + RunningProcess { + pid: 100, + ppid: 1, + name: "zoom.us".into(), + }, + RunningProcess { + pid: 110, + ppid: 100, + name: "ZoomHybridConf".into(), + }, + ]; + let active_input_pids = HashSet::from([110]); + + let result = detector.detect_active_call_from_process_snapshot( + &config, + true, + &processes, + Some(&active_input_pids), + false, + |_detector, _running, _want_meet, _want_teams| None, + ); + + assert_eq!( + result, + DetectActiveCallResult::Detected { + display_name: "Zoom".into(), + process_name: "zoom.us".into(), + } + ); + } + + #[test] + fn unrooted_zoom_helper_input_does_not_detect_zoom_call() { + let detector = CallDetector::new(test_call_detection_config(vec!["zoom.us".into()])); + let config = detector.current_config(); + let processes = vec![RunningProcess { + pid: 110, + ppid: 1, + name: "ZoomHybridConf".into(), + }]; + let active_input_pids = HashSet::from([110]); + + let result = detector.detect_active_call_from_process_snapshot( + &config, + true, + &processes, + Some(&active_input_pids), + false, + |_detector, _running, _want_meet, _want_teams| None, + ); + + assert_eq!(result, DetectActiveCallResult::None); + } + + #[test] + fn child_process_input_detects_native_call_app() { + let detector = + CallDetector::new(test_call_detection_config(vec!["Microsoft Teams".into()])); + let config = detector.current_config(); + let processes = vec![ + RunningProcess { + pid: 300, + ppid: 1, + name: "Microsoft Teams".into(), + }, + RunningProcess { + pid: 301, + ppid: 300, + name: "Microsoft Teams Helper".into(), + }, + ]; + let active_input_pids = HashSet::from([301]); + + let result = detector.detect_active_call_from_process_snapshot( + &config, + true, + &processes, + Some(&active_input_pids), + false, + |_detector, _running, _want_meet, _want_teams| None, + ); + + assert_eq!( + result, + DetectActiveCallResult::Detected { + display_name: "Teams".into(), + process_name: "Microsoft Teams".into(), + } + ); + } + #[test] fn mic_activation_forces_browser_probe_before_slack_even_when_rate_limited() { let detector = CallDetector::new(test_call_detection_config(vec![ @@ -1786,6 +2135,29 @@ mod tests { assert_eq!(procs, vec!["zoom.us", "Safari"]); } + #[test] + fn process_snapshot_parser_preserves_paths_with_spaces() { + let procs = process_snapshots_from_ps_output( + "\n 100 1 /Applications/zoom.us.app/Contents/MacOS/zoom.us\n 200 100 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome\n", + ); + + assert_eq!( + procs, + vec![ + RunningProcess { + pid: 100, + ppid: 1, + name: "zoom.us".into(), + }, + RunningProcess { + pid: 200, + ppid: 100, + name: "Google Chrome".into(), + }, + ] + ); + } + #[test] fn process_list_probe_does_not_panic() { let _procs = running_process_names(); @@ -1798,6 +2170,40 @@ mod tests { let _result = is_mic_in_use(); } + #[test] + fn bundled_mic_check_detects_non_default_input_activity() { + let swift_source = include_str!("mic_check.swift"); + + assert!( + swift_source.contains("kAudioHardwarePropertyProcessObjectList"), + "mic_check should query CoreAudio process objects for input activity" + ); + assert!( + swift_source.contains("kAudioProcessPropertyIsRunningInput"), + "mic_check should prefer process-level input activity over device-global running state" + ); + assert!( + swift_source.contains("kAudioProcessPropertyPID"), + "mic_check should expose active input PIDs for call-app attribution" + ); + assert!( + swift_source.contains("--active-input-pids"), + "mic_check should support attributed active-input PID output" + ); + assert!( + swift_source.contains("kAudioHardwarePropertyDevices"), + "mic_check must still enumerate devices as a fallback for non-default inputs" + ); + assert!( + swift_source.contains("kAudioDevicePropertyScopeInput"), + "mic_check must limit activity checks to input-capable devices" + ); + assert!( + !swift_source.contains("kAudioHardwarePropertyDefaultInputDevice"), + "mic_check must not regress to default-input-only detection" + ); + } + #[test] fn call_end_fires_once_per_session() { let detector = CallDetector::new(test_call_detection_config(vec!["zoom.us".into()])); diff --git a/tauri/src-tauri/src/mic_check.swift b/tauri/src-tauri/src/mic_check.swift index 0849fe5b..deb90996 100644 --- a/tauri/src-tauri/src/mic_check.swift +++ b/tauri/src-tauri/src/mic_check.swift @@ -1,37 +1,140 @@ -// Minimal Swift helper to check if the default audio input device is in use. -// Outputs "1" if mic is active, "0" if idle. -// Uses CoreAudio kAudioDevicePropertyDeviceIsRunningSomewhere which works -// on both Intel and Apple Silicon Macs. +// Minimal Swift helper to check if any audio input is active. +// Outputs "1" if mic/input capture is active, "0" if idle. import CoreAudio import Foundation -var defaultInputID = AudioObjectID(kAudioObjectSystemObject) -var propAddr = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain -) -var size = UInt32(MemoryLayout.size) -let err = AudioObjectGetPropertyData( - AudioObjectID(kAudioObjectSystemObject), &propAddr, 0, nil, &size, &defaultInputID -) -guard err == noErr else { - print("0") - exit(0) +let systemObject = AudioObjectID(kAudioObjectSystemObject) + +func objectIDs( + for selector: AudioObjectPropertySelector, + on objectID: AudioObjectID = systemObject, + scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal +) -> [AudioObjectID]? { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: scope, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(objectID, &address, 0, nil, &size) == noErr else { + return nil + } + let count = Int(size) / MemoryLayout.size + guard count > 0 else { + return [] + } + + var ids = [AudioObjectID](repeating: AudioObjectID(kAudioObjectUnknown), count: count) + let status = ids.withUnsafeMutableBufferPointer { buffer in + guard let baseAddress = buffer.baseAddress else { + return kAudioHardwareBadObjectError + } + return AudioObjectGetPropertyData(objectID, &address, 0, nil, &size, baseAddress) + } + guard status == noErr else { + return nil + } + return ids +} + +func uint32Property( + _ selector: AudioObjectPropertySelector, + on objectID: AudioObjectID, + scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal +) -> UInt32? { + var value: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: scope, + mElement: kAudioObjectPropertyElementMain + ) + guard AudioObjectGetPropertyData(objectID, &address, 0, nil, &size, &value) == noErr else { + return nil + } + return value +} + +func pidProperty(on objectID: AudioObjectID) -> pid_t? { + var value = pid_t(0) + var size = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: kAudioProcessPropertyPID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + guard AudioObjectGetPropertyData(objectID, &address, 0, nil, &size, &value) == noErr else { + return nil + } + return value +} + +func activeInputProcessIDs() -> [pid_t]? { + guard let processes = objectIDs(for: kAudioHardwarePropertyProcessObjectList) else { + return nil + } + var pids: [pid_t] = [] + var sawReadableProcess = false + for processID in processes { + guard let isRunning = uint32Property(kAudioProcessPropertyIsRunningInput, on: processID) else { + continue + } + sawReadableProcess = true + if isRunning > 0 { + if let pid = pidProperty(on: processID), pid > 0 { + pids.append(pid) + } + } + } + return sawReadableProcess ? pids : nil +} + +func inputChannelCount(for deviceID: AudioObjectID) -> UInt32 { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) == noErr, size > 0 else { + return 0 + } + + let bufferList = UnsafeMutableRawPointer.allocate( + byteCount: Int(size), + alignment: MemoryLayout.alignment + ) + defer { bufferList.deallocate() } + + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, bufferList) == noErr else { + return 0 + } + + let audioBufferList = bufferList.assumingMemoryBound(to: AudioBufferList.self) + return UnsafeMutableAudioBufferListPointer(audioBufferList) + .reduce(UInt32(0)) { total, buffer in total + buffer.mNumberChannels } +} + +func anyInputDeviceRunning() -> Bool { + guard let devices = objectIDs(for: kAudioHardwarePropertyDevices) else { + return false + } + return devices.contains { deviceID in + inputChannelCount(for: deviceID) > 0 + && (uint32Property(kAudioDevicePropertyDeviceIsRunningSomewhere, on: deviceID) ?? 0) > 0 + } } -var isRunning: UInt32 = 0 -var runAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain -) -size = UInt32(MemoryLayout.size) -let err2 = AudioObjectGetPropertyData(defaultInputID, &runAddr, 0, nil, &size, &isRunning) -guard err2 == noErr else { - print("0") +if CommandLine.arguments.contains("--active-input-pids") { + guard let pids = activeInputProcessIDs() else { + exit(2) + } + for pid in pids { + print(pid) + } exit(0) } -print(isRunning > 0 ? "1" : "0") +let micActive = activeInputProcessIDs().map { !$0.isEmpty } ?? anyInputDeviceRunning() +print(micActive ? "1" : "0")