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 <noreply@anthropic.com>
This commit is contained in:
michael.borak
2026-02-05 22:47:13 +01:00
parent 63b99ef88e
commit 4803323087
2 changed files with 178 additions and 15 deletions

View File

@@ -184,7 +184,6 @@ async fn start_recording(
let writer = hound::WavWriter::create(&file_path, spec).map_err(|e| e.to_string())?; let writer = hound::WavWriter::create(&file_path, spec).map_err(|e| e.to_string())?;
let writer = Arc::new(Mutex::new(writer)); let writer = Arc::new(Mutex::new(writer));
let writer_clone = writer.clone();
// Initialize AudioProcessor (VAD) // Initialize AudioProcessor (VAD)
// We pass the writer to it. // 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."); emit_log(&app, "INFO", "Aggregate device detected. Disabling internal System Audio Capture to prevent doubling.");
} else { } else {
let excluded = excluded_apps.unwrap_or_default(); 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 // Get the queue to share with the capture callback
let queue_clone = { let queue_clone = {
@@ -267,13 +271,32 @@ async fn start_recording(
let app_handle = app.clone(); let app_handle = app.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
loop { 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::<AppState>(); let state = app_handle.state::<AppState>();
let mut guard = state.system_capture.lock().await; let mut guard = state.system_capture.lock().await;
if let Some(sys) = guard.as_mut() { if let Some(sys) = guard.as_mut() {
// Try to refresh filter // Update filter with pre-fetched content (fast)
if let Err(e) = sys.refresh_filter().await { if let Err(e) = sys.refresh_with_content(content).await {
// Just log as debug/warn, not critical if it fails once
eprintln!("Failed to refresh audio filter: {}", e); eprintln!("Failed to refresh audio filter: {}", e);
} }
} else { } else {

View File

@@ -11,10 +11,14 @@ use screencapturekit_sys::{
stream_output_handler::UnsafeSCStreamOutput, 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 { pub struct SystemAudioCapture {
stream: Option<Id<UnsafeSCStream>>, stream: Option<Id<UnsafeSCStream>>,
sample_rate: u32, sample_rate: u32,
excluded_apps: Vec<String>, excluded_apps: Vec<String>,
app_handle: Option<AppHandle>,
} }
struct AudioOutputWrapper { struct AudioOutputWrapper {
@@ -50,21 +54,51 @@ pub async fn check_permissions() -> bool {
} }
impl SystemAudioCapture { impl SystemAudioCapture {
pub fn new(sample_rate: u32, excluded_apps: Vec<String>) -> Self { pub fn new(
sample_rate: u32,
excluded_apps: Vec<String>,
app_handle: Option<AppHandle>,
) -> Self {
Self { Self {
stream: None, stream: None,
sample_rate, sample_rate,
excluded_apps, excluded_apps,
app_handle,
} }
} }
async fn build_filter(&self) -> Result<Id<UnsafeContentFilter>, String> { pub fn get_shareable_content() -> Result<Id<UnsafeSCShareableContent>, String> {
let content = UnsafeSCShareableContent::get().map_err(|_| format!("Failed to get content"))
UnsafeSCShareableContent::get().map_err(|_| format!("Failed to get content"))?; }
// Internal helper that takes content
fn build_filter_with_content(
&self,
content: Id<UnsafeSCShareableContent>,
) -> Result<Id<UnsafeContentFilter>, String> {
if let Some(app) = &self.app_handle {
emit_log(app, "TRACE", "[SCK] build_filter_with_content started.");
}
let displays = content.displays(); let displays = content.displays();
let display = displays.first().ok_or("No display found")?; 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 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( return Ok(UnsafeContentFilter::init(UnsafeInitParams::Display(
display.clone(), display.clone(),
))); )));
@@ -73,6 +107,14 @@ impl SystemAudioCapture {
let mut apps_to_exclude = Vec::new(); let mut apps_to_exclude = Vec::new();
let all_apps = content.applications(); 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 // Prepare lowercase excluded list for case-insensitive matching
let excluded_lower: Vec<String> = self let excluded_lower: Vec<String> = self
.excluded_apps .excluded_apps
@@ -80,16 +122,100 @@ impl SystemAudioCapture {
.map(|s| s.to_lowercase()) .map(|s| s.to_lowercase())
.collect(); .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 { for app in all_apps {
if let Some(bid) = app.get_bundle_identifier() { if let Some(bid) = app.get_bundle_identifier() {
let bid_lower = bid.to_lowercase(); 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" // --- SAFETY CHECKS (ALLOWLIST) ---
if excluded_lower
// 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() .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); apps_to_exclude.push(app);
} else {
// Optional: Log what is KEPT (verbose)
} }
} }
} }
@@ -105,6 +231,11 @@ impl SystemAudioCapture {
Ok(UnsafeContentFilter::init(filter_init)) Ok(UnsafeContentFilter::init(filter_init))
} }
async fn build_filter(&self) -> Result<Id<UnsafeContentFilter>, String> {
let content = Self::get_shareable_content()?;
self.build_filter_with_content(content)
}
pub async fn start<F>(&mut self, callback: F) -> Result<(), String> pub async fn start<F>(&mut self, callback: F) -> Result<(), String>
where where
F: Fn(&[f32]) + Send + Sync + 'static, F: Fn(&[f32]) + Send + Sync + 'static,
@@ -142,12 +273,21 @@ impl SystemAudioCapture {
} }
pub async fn refresh_filter(&mut self) -> Result<(), String> { 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<UnsafeSCShareableContent>,
) -> Result<(), String> {
let stream = match &self.stream { let stream = match &self.stream {
Some(s) => s, Some(s) => s,
None => return Ok(()), None => return Ok(()),
}; };
let filter = self.build_filter().await?; let filter = self.build_filter_with_content(content)?;
// Call updateContentFilter:completionHandler: // Call updateContentFilter:completionHandler:
// screencapturekit-sys 0.2.8 does not have this method exposed yet in UnsafeSCStream. // screencapturekit-sys 0.2.8 does not have this method exposed yet in UnsafeSCStream.