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.