From 82f7df35524bdf787a4c04ff1d3d3bd96e16f327 Mon Sep 17 00:00:00 2001 From: Vladimir <139348193+thernsdr@users.noreply.github.com> Date: Mon, 26 Jan 2026 09:54:31 +0300 Subject: [PATCH 1/2] fix: weird microphone permission request behavior (macOS 26.2) fixes https://github.com/Dimillian/CodexMonitor/issues/212 tested on macOS 26.2 --- src-tauri/src/dictation.rs | 85 ++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/src-tauri/src/dictation.rs b/src-tauri/src/dictation.rs index 49f00a05c..c2e0dbc9b 100644 --- a/src-tauri/src/dictation.rs +++ b/src-tauri/src/dictation.rs @@ -22,6 +22,9 @@ use objc2_av_foundation::{AVAuthorizationStatus, AVCaptureDevice, AVMediaTypeAud const DEFAULT_MODEL_ID: &str = "base"; const MAX_CAPTURE_SECONDS: u32 = 120; +#[cfg(target_os = "macos")] +static MIC_PERMISSION_REQUESTED: AtomicBool = AtomicBool::new(false); + /// Checks microphone authorization status on macOS. #[cfg(target_os = "macos")] fn check_microphone_authorization() -> Result { @@ -59,38 +62,60 @@ async fn request_microphone_permission(app: &AppHandle) -> Result match status { AVAuthorizationStatus::Authorized => Ok(true), - AVAuthorizationStatus::Denied | AVAuthorizationStatus::Restricted => Ok(false), - AVAuthorizationStatus::NotDetermined | _ => { - // Trigger the permission request (this shows the system dialog) - // Ensure we do this on the main thread so the system dialog appears. - let (tx, rx) = oneshot::channel(); - let app_handle = app.clone(); - app_handle - .run_on_main_thread(move || { - let _ = tx.send(trigger_microphone_permission_request()); - }) - .map_err(|error| error.to_string())?; - - match rx.await { - Ok(Ok(())) => {} - Ok(Err(error)) => return Err(error), - Err(_) => return Err("Failed to request microphone permission.".to_string()), + AVAuthorizationStatus::Denied | AVAuthorizationStatus::Restricted => { + // Some macOS versions report Denied before the first prompt; try once per process. + if MIC_PERMISSION_REQUESTED.swap(true, Ordering::SeqCst) { + return Ok(false); } + trigger_microphone_permission_request_on_main_thread(app).await?; + let updated_status = check_microphone_authorization()?; + Ok(updated_status == AVAuthorizationStatus::Authorized) + } + AVAuthorizationStatus::NotDetermined | _ => { + MIC_PERMISSION_REQUESTED.store(true, Ordering::SeqCst); + trigger_microphone_permission_request_on_main_thread(app).await?; + wait_for_microphone_permission_change(status).await + } + } +} - // Poll the authorization status until it changes from NotDetermined - let mut attempts = 0; - loop { - tokio::time::sleep(Duration::from_millis(100)).await; - let new_status = check_microphone_authorization()?; - if new_status != AVAuthorizationStatus::NotDetermined { - return Ok(new_status == AVAuthorizationStatus::Authorized); - } - attempts += 1; - if attempts > 600 { - // 60 seconds timeout - return Err("Microphone permission request timed out.".to_string()); - } - } +#[cfg(target_os = "macos")] +async fn trigger_microphone_permission_request_on_main_thread( + app: &AppHandle, +) -> Result<(), String> { + // Trigger the permission request (this shows the system dialog) + // Ensure we do this on the main thread so the system dialog appears. + let (tx, rx) = oneshot::channel(); + let app_handle = app.clone(); + app_handle + .run_on_main_thread(move || { + let _ = tx.send(trigger_microphone_permission_request()); + }) + .map_err(|error| error.to_string())?; + + match rx.await { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(error), + Err(_) => Err("Failed to request microphone permission.".to_string()), + } +} + +#[cfg(target_os = "macos")] +async fn wait_for_microphone_permission_change( + pending_status: AVAuthorizationStatus, +) -> Result { + // Poll the authorization status until it changes from the pending status. + let mut attempts = 0; + loop { + tokio::time::sleep(Duration::from_millis(100)).await; + let new_status = check_microphone_authorization()?; + if new_status != pending_status { + return Ok(new_status == AVAuthorizationStatus::Authorized); + } + attempts += 1; + if attempts > 600 { + // 60 seconds timeout + return Err("Microphone permission request timed out.".to_string()); } } } From 5e2fefea6e1fd49463f52be281c026d77e4d497f Mon Sep 17 00:00:00 2001 From: Vladimir <139348193+thernsdr@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:32:41 +0300 Subject: [PATCH 2/2] =?UTF-8?q?Refactor=20macOS=20microphone=20permission?= =?UTF-8?q?=20request=20logic=20(waiting=20for=20completion=E2=80=91handle?= =?UTF-8?q?r)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/dictation.rs | 92 +++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 52 deletions(-) diff --git a/src-tauri/src/dictation.rs b/src-tauri/src/dictation.rs index c2e0dbc9b..4c2872c84 100644 --- a/src-tauri/src/dictation.rs +++ b/src-tauri/src/dictation.rs @@ -33,26 +33,6 @@ fn check_microphone_authorization() -> Result { Ok(status) } -/// Triggers the microphone permission request dialog on macOS. -/// This must be called from a thread (not across await points) due to RcBlock not being Send. -#[cfg(target_os = "macos")] -fn trigger_microphone_permission_request() -> Result<(), String> { - use block2::RcBlock; - use objc2::runtime::Bool; - - let media_type = unsafe { AVMediaTypeAudio.ok_or("Failed to get audio media type")? }; - - let block = RcBlock::new(|_granted: Bool| { - // Completion handler - we poll the status separately - }); - - unsafe { - AVCaptureDevice::requestAccessForMediaType_completionHandler(media_type, &block); - } - - Ok(()) -} - /// Requests microphone permission on macOS. /// Returns Ok(true) if permission was granted, Ok(false) if denied, /// or Err with a message if the request failed. @@ -67,56 +47,64 @@ async fn request_microphone_permission(app: &AppHandle) -> Result if MIC_PERMISSION_REQUESTED.swap(true, Ordering::SeqCst) { return Ok(false); } - trigger_microphone_permission_request_on_main_thread(app).await?; - let updated_status = check_microphone_authorization()?; - Ok(updated_status == AVAuthorizationStatus::Authorized) + request_microphone_permission_with_completion(app).await } AVAuthorizationStatus::NotDetermined | _ => { MIC_PERMISSION_REQUESTED.store(true, Ordering::SeqCst); - trigger_microphone_permission_request_on_main_thread(app).await?; - wait_for_microphone_permission_change(status).await + request_microphone_permission_with_completion(app).await + } + } +} + +#[cfg(target_os = "macos")] +fn trigger_microphone_permission_request( + tx: oneshot::Sender>, +) { + use block2::RcBlock; + use objc2::runtime::Bool; + + let media_type = match unsafe { AVMediaTypeAudio } { + Some(media_type) => media_type, + None => { + let _ = tx.send(Err("Failed to get audio media type".to_string())); + return; + } + }; + + let tx = Arc::new(Mutex::new(Some(tx))); + let tx_clone = Arc::clone(&tx); + let block = RcBlock::new(move |granted: Bool| { + if let Ok(mut guard) = tx_clone.lock() { + if let Some(sender) = guard.take() { + let _ = sender.send(Ok(granted.as_bool())); + } } + }); + + unsafe { + AVCaptureDevice::requestAccessForMediaType_completionHandler(media_type, &block); } } #[cfg(target_os = "macos")] -async fn trigger_microphone_permission_request_on_main_thread( +async fn request_microphone_permission_with_completion( app: &AppHandle, -) -> Result<(), String> { +) -> Result { // Trigger the permission request (this shows the system dialog) // Ensure we do this on the main thread so the system dialog appears. let (tx, rx) = oneshot::channel(); let app_handle = app.clone(); app_handle .run_on_main_thread(move || { - let _ = tx.send(trigger_microphone_permission_request()); + trigger_microphone_permission_request(tx); }) .map_err(|error| error.to_string())?; - match rx.await { - Ok(Ok(())) => Ok(()), - Ok(Err(error)) => Err(error), - Err(_) => Err("Failed to request microphone permission.".to_string()), - } -} - -#[cfg(target_os = "macos")] -async fn wait_for_microphone_permission_change( - pending_status: AVAuthorizationStatus, -) -> Result { - // Poll the authorization status until it changes from the pending status. - let mut attempts = 0; - loop { - tokio::time::sleep(Duration::from_millis(100)).await; - let new_status = check_microphone_authorization()?; - if new_status != pending_status { - return Ok(new_status == AVAuthorizationStatus::Authorized); - } - attempts += 1; - if attempts > 600 { - // 60 seconds timeout - return Err("Microphone permission request timed out.".to_string()); - } + match tokio::time::timeout(Duration::from_secs(60), rx).await { + Ok(Ok(Ok(granted))) => Ok(granted), + Ok(Ok(Err(error))) => Err(error), + Ok(Err(_)) => Err("Failed to request microphone permission.".to_string()), + Err(_) => Err("Microphone permission request timed out.".to_string()), } }