From 48033230875634980424ca1f70c05e0006bfeb32 Mon Sep 17 00:00:00 2001 From: "michael.borak" Date: Thu, 5 Feb 2026 22:47:13 +0100 Subject: [PATCH] feat: fix 3CX PWA audio capture by recognizing Chrome PWA bundle ID 3CX on macOS is a Chrome PWA (com.google.Chrome.app.dmmmfdbaapefjmjeomigbhpmjbbjgnph), not a native app. The previous allowlist only checked for "3cx" in the bundle ID, which never matched. Now recognizes the known PWA ID and protects the Chrome main process from exclusion when 3CX is running, since ScreenCaptureKit routes PWA audio through Chrome. Co-Authored-By: Claude Opus 4.5 --- src-tauri/src/lib.rs | 35 +++++++-- src-tauri/src/sc_audio.rs | 158 +++++++++++++++++++++++++++++++++++--- 2 files changed, 178 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ed3ccc..4681418 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -184,7 +184,6 @@ async fn start_recording( let writer = hound::WavWriter::create(&file_path, spec).map_err(|e| e.to_string())?; let writer = Arc::new(Mutex::new(writer)); - let writer_clone = writer.clone(); // Initialize AudioProcessor (VAD) // We pass the writer to it. @@ -231,7 +230,12 @@ async fn start_recording( emit_log(&app, "INFO", "Aggregate device detected. Disabling internal System Audio Capture to prevent doubling."); } else { let excluded = excluded_apps.unwrap_or_default(); - let mut sys_capture = sc_audio::SystemAudioCapture::new(config.sample_rate(), excluded); + let app_handle_for_sys_capture = app.clone(); + let mut sys_capture = sc_audio::SystemAudioCapture::new( + config.sample_rate(), + excluded, + Some(app_handle_for_sys_capture), + ); // Get the queue to share with the capture callback let queue_clone = { @@ -267,13 +271,32 @@ async fn start_recording( let app_handle = app.clone(); tauri::async_runtime::spawn(async move { loop { - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + tokio::time::sleep(tokio::time::Duration::from_secs(8)).await; + + // Offload the heavy content fetch to a blocking thread + // This prevents blocking the async runtime or main thread + let content_result = tauri::async_runtime::spawn_blocking(|| { + sc_audio::SystemAudioCapture::get_shareable_content() + }) + .await; + + let content = match content_result { + Ok(Ok(c)) => c, + Ok(Err(e)) => { + eprintln!("Failed to fetch shareable content: {}", e); + continue; + } + Err(e) => { + eprintln!("Join error in content fetch: {}", e); + continue; + } + }; + let state = app_handle.state::(); let mut guard = state.system_capture.lock().await; if let Some(sys) = guard.as_mut() { - // Try to refresh filter - if let Err(e) = sys.refresh_filter().await { - // Just log as debug/warn, not critical if it fails once + // Update filter with pre-fetched content (fast) + if let Err(e) = sys.refresh_with_content(content).await { eprintln!("Failed to refresh audio filter: {}", e); } } else { diff --git a/src-tauri/src/sc_audio.rs b/src-tauri/src/sc_audio.rs index 0cf2939..f719df5 100644 --- a/src-tauri/src/sc_audio.rs +++ b/src-tauri/src/sc_audio.rs @@ -11,10 +11,14 @@ use screencapturekit_sys::{ stream_output_handler::UnsafeSCStreamOutput, }; +use crate::emit_log; +use tauri::{AppHandle, Emitter, Manager}; // Ensure Emitter is verified for v2 (it is trait typically) + pub struct SystemAudioCapture { stream: Option>, sample_rate: u32, excluded_apps: Vec, + app_handle: Option, } struct AudioOutputWrapper { @@ -50,21 +54,51 @@ pub async fn check_permissions() -> bool { } impl SystemAudioCapture { - pub fn new(sample_rate: u32, excluded_apps: Vec) -> Self { + pub fn new( + sample_rate: u32, + excluded_apps: Vec, + app_handle: Option, + ) -> Self { Self { stream: None, sample_rate, excluded_apps, + app_handle, } } - async fn build_filter(&self) -> Result, String> { - let content = - UnsafeSCShareableContent::get().map_err(|_| format!("Failed to get content"))?; + pub fn get_shareable_content() -> Result, String> { + UnsafeSCShareableContent::get().map_err(|_| format!("Failed to get content")) + } + + // Internal helper that takes content + fn build_filter_with_content( + &self, + content: Id, + ) -> Result, String> { + if let Some(app) = &self.app_handle { + emit_log(app, "TRACE", "[SCK] build_filter_with_content started."); + } + let displays = content.displays(); let display = displays.first().ok_or("No display found")?; + if let Some(app) = &self.app_handle { + emit_log( + app, + "DEBUG", + &format!("[SCK] Excluded Apps Config: {:?}", self.excluded_apps), + ); + } + if self.excluded_apps.is_empty() { + if let Some(app) = &self.app_handle { + emit_log( + app, + "DEBUG", + "[SCK] Excluded list empty. Capturing entire display.", + ); + } return Ok(UnsafeContentFilter::init(UnsafeInitParams::Display( display.clone(), ))); @@ -73,6 +107,14 @@ impl SystemAudioCapture { let mut apps_to_exclude = Vec::new(); let all_apps = content.applications(); + if let Some(app) = &self.app_handle { + emit_log( + app, + "DEBUG", + &format!("[SCK] Found {} running applications.", all_apps.len()), + ); + } + // Prepare lowercase excluded list for case-insensitive matching let excluded_lower: Vec = self .excluded_apps @@ -80,16 +122,100 @@ impl SystemAudioCapture { .map(|s| s.to_lowercase()) .collect(); + // Known 3CX PWA Bundle-ID suffix (Chrome PWA). + // The full ID is "com.google.Chrome.app.dmmmfdbaapefjmjeomigbhpmjbbjgnph" + // which does NOT contain "3cx", so we must match by PWA ID. + const KNOWN_3CX_PWA_ID: &str = "dmmmfdbaapefjmjeomigbhpmjbbjgnph"; + + // First pass: Check if 3CX PWA is running. + // If it is, we must also protect the Chrome main process from exclusion, + // because ScreenCaptureKit routes PWA audio through the Chrome host process. + let threecx_pwa_running = all_apps.iter().any(|a| { + a.get_bundle_identifier() + .map(|bid| { + let bl = bid.to_lowercase(); + bl.contains("3cx") || bl.contains(KNOWN_3CX_PWA_ID) + }) + .unwrap_or(false) + }); + + if threecx_pwa_running { + if let Some(h) = &self.app_handle { + emit_log( + h, + "INFO", + "[SCK] 3CX detected as running. Chrome main process will be protected from exclusion.", + ); + } + } + for app in all_apps { if let Some(bid) = app.get_bundle_identifier() { let bid_lower = bid.to_lowercase(); - // Smart match: check if the running app's ID starts with any blocked ID - // e.g., "com.apple.Safari.WebContent" starts with "com.apple.Safari" - if excluded_lower + + // --- SAFETY CHECKS (ALLOWLIST) --- + + // 1. ALWAYS Allow 3CX (native app OR known PWA Bundle-ID) + if bid_lower.contains("3cx") || bid_lower.contains(KNOWN_3CX_PWA_ID) { + if let Some(h) = &self.app_handle { + emit_log(h, "INFO", &format!("[SCK] ALLOWLIST: Found 3CX App ('{}'). Ensuring it is NOT excluded.", bid)); + } + continue; + } + + // 2. PROTECT Chrome main process when 3CX PWA is running. + // ScreenCaptureKit routes Chrome PWA audio through the main Chrome process. + // If we exclude Chrome while 3CX PWA is active, 3CX audio is lost. + if threecx_pwa_running && bid_lower == "com.google.chrome" { + if let Some(h) = &self.app_handle { + emit_log( + h, + "INFO", + "[SCK] ALLOWLIST: Preserving Chrome main process because 3CX PWA is running.", + ); + } + continue; + } + + // 3. PROTECT Chrome PWAs (prevent wildcard matching from "Google Chrome") + if bid_lower.contains("chrome.app") { + // Only exclude if the user has explicitly blocked THIS EXACT app ID. + if excluded_lower.contains(&bid_lower) { + if let Some(h) = &self.app_handle { + emit_log( + h, + "DEBUG", + &format!("[SCK] Excluding PWA App (Exact Match): '{}'", bid), + ); + } + apps_to_exclude.push(app); + continue; + } else { + // It's a PWA, and not exactly blocked. + // But if "Google Chrome" is blocked, `starts_with` would kill this. + // So we SKIP the rest of the loop to protect it. + if let Some(h) = &self.app_handle { + emit_log(h, "INFO", &format!("[SCK] ALLOWLIST: Preserving Chrome PWA ('{}') from wildcard exclusion.", bid)); + } + continue; + } + } + + // --- STANDARD EXCLUSION LOGIC --- + if let Some(matched) = excluded_lower .iter() - .any(|excluded| bid_lower.starts_with(excluded)) + .find(|excluded| bid_lower.starts_with(*excluded)) { + if let Some(h) = &self.app_handle { + emit_log( + h, + "DEBUG", + &format!("[SCK] Excluding App: '{}' (Matches: '{}')", bid, matched), + ); + } apps_to_exclude.push(app); + } else { + // Optional: Log what is KEPT (verbose) } } } @@ -105,6 +231,11 @@ impl SystemAudioCapture { Ok(UnsafeContentFilter::init(filter_init)) } + async fn build_filter(&self) -> Result, String> { + let content = Self::get_shareable_content()?; + self.build_filter_with_content(content) + } + pub async fn start(&mut self, callback: F) -> Result<(), String> where F: Fn(&[f32]) + Send + Sync + 'static, @@ -142,12 +273,21 @@ impl SystemAudioCapture { } pub async fn refresh_filter(&mut self) -> Result<(), String> { + // Fallback to internal fetch + let content = Self::get_shareable_content()?; + self.refresh_with_content(content).await + } + + pub async fn refresh_with_content( + &mut self, + content: Id, + ) -> Result<(), String> { let stream = match &self.stream { Some(s) => s, None => return Ok(()), }; - let filter = self.build_filter().await?; + let filter = self.build_filter_with_content(content)?; // Call updateContentFilter:completionHandler: // screencapturekit-sys 0.2.8 does not have this method exposed yet in UnsafeSCStream.