From 60e2874b3dee2bd9b68ba468a36f8157152358d0 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Wed, 20 May 2026 08:58:46 +0000 Subject: [PATCH] Fix camera permission dialog not appearing in notarized macOS builds Agent-Logs-Url: https://github.com/infinilabs/coco-app/sessions/ddfb92c3-e7e3-4f81-8546-21c7bd9ee652 Co-authored-by: ayangweb <75017711+ayangweb@users.noreply.github.com> --- src-tauri/src/lib.rs | 5 + src-tauri/src/permissions.rs | 290 +++++++++++++++++++++++++++++++ src/components/Search/Camera.tsx | 41 ++--- src/utils/tauriAdapter.ts | 20 +-- 4 files changed, 318 insertions(+), 38 deletions(-) create mode 100644 src-tauri/src/permissions.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dfb695b0..20f99fcb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod assistant; mod autostart; mod common; mod extension; +mod permissions; mod search; mod selection_monitor; mod server; @@ -176,6 +177,10 @@ pub fn run() { show_check, hide_check, save_camera_photo, + permissions::check_camera_permission, + permissions::request_camera_permission, + permissions::check_microphone_permission, + permissions::request_microphone_permission, server::servers::add_coco_server, server::servers::remove_coco_server, server::servers::list_coco_servers, diff --git a/src-tauri/src/permissions.rs b/src-tauri/src/permissions.rs new file mode 100644 index 00000000..be735999 --- /dev/null +++ b/src-tauri/src/permissions.rs @@ -0,0 +1,290 @@ +//! macOS permissions handling module +//! +//! This module provides proper async handling for camera and microphone permissions +//! on macOS, fixing the issues with tauri-plugin-macos-permissions. + +use tauri::command; + +#[cfg(target_os = "macos")] +use { + objc2::{class, msg_send, runtime::Bool, ClassType}, + objc2_foundation::NSString, + std::sync::mpsc, + std::time::Duration, +}; + +/// Authorization status for AVCaptureDevice +/// Reference: https://developer.apple.com/documentation/avfoundation/avauthorizationstatus +#[cfg(target_os = "macos")] +#[repr(i32)] +enum AVAuthorizationStatus { + NotDetermined = 0, + Restricted = 1, + Denied = 2, + Authorized = 3, +} + +/// Check camera permission status. +/// +/// Returns `true` if camera permission is granted, `false` otherwise. +#[command] +pub async fn check_camera_permission() -> bool { + #[cfg(target_os = "macos")] + unsafe { + let av_media_type = NSString::from_str("vide"); + let status: i32 = msg_send![ + class!(AVCaptureDevice), + authorizationStatusForMediaType: &*av_media_type + ]; + + status == AVAuthorizationStatus::Authorized as i32 + } + + #[cfg(not(target_os = "macos"))] + true +} + +/// Request camera permission with proper async handling. +/// +/// This function properly handles the asynchronous nature of macOS permission requests. +/// It waits for the user to respond to the system permission dialog. +/// +/// Returns: +/// - `Ok(true)` if permission was granted +/// - `Ok(false)` if permission was denied +/// - `Err(String)` if an error occurred +#[command] +pub async fn request_camera_permission() -> Result { + #[cfg(target_os = "macos")] + unsafe { + let av_media_type = NSString::from_str("vide"); + + // First check current status + let status: i32 = msg_send![ + class!(AVCaptureDevice), + authorizationStatusForMediaType: &*av_media_type + ]; + + // If already authorized, return immediately + if status == AVAuthorizationStatus::Authorized as i32 { + return Ok(true); + } + + // If restricted or denied, we can't request again + if status == AVAuthorizationStatus::Restricted as i32 + || status == AVAuthorizationStatus::Denied as i32 + { + return Ok(false); + } + + // Create a channel to receive the callback result + let (tx, rx) = mpsc::channel(); + + // Create a completion handler that sends the result through the channel + let completion_block = Box::new(move |granted: Bool| { + let _ = tx.send(granted.as_bool()); + }); + + // Convert the closure to a raw pointer + let completion_ptr = Box::into_raw(completion_block); + + // Define the block structure for Objective-C runtime + type CompletionHandler = extern "C" fn(*mut std::ffi::c_void, Bool); + + extern "C" fn trampoline(block_ptr: *mut std::ffi::c_void, granted: Bool) { + unsafe { + let closure: Box> = + Box::from_raw(block_ptr as *mut Box); + closure(granted); + } + } + + #[repr(C)] + struct Block { + isa: *const std::ffi::c_void, + flags: i32, + reserved: i32, + invoke: CompletionHandler, + descriptor: *const BlockDescriptor, + closure: *mut std::ffi::c_void, + } + + #[repr(C)] + struct BlockDescriptor { + reserved: usize, + size: usize, + copy_helper: Option, + dispose_helper: Option, + } + + static DESCRIPTOR: BlockDescriptor = BlockDescriptor { + reserved: 0, + size: std::mem::size_of::(), + copy_helper: None, + dispose_helper: Some(dispose_helper), + }; + + extern "C" fn dispose_helper(block: *mut std::ffi::c_void) { + unsafe { + let block = block as *mut Block; + let _ = Box::from_raw((*block).closure as *mut Box); + } + } + + // Get the _NSConcreteStackBlock class + extern "C" { + static _NSConcreteStackBlock: *const std::ffi::c_void; + } + + let block = Block { + isa: &_NSConcreteStackBlock, + flags: 1 << 25, // BLOCK_HAS_COPY_DISPOSE + reserved: 0, + invoke: trampoline, + descriptor: &DESCRIPTOR, + closure: completion_ptr as *mut std::ffi::c_void, + }; + + // Call the requestAccessForMediaType with our completion handler + let _: () = msg_send![ + class!(AVCaptureDevice), + requestAccessForMediaType: &*av_media_type, + completionHandler: &block + ]; + + // Wait for the callback with a timeout + match rx.recv_timeout(Duration::from_secs(60)) { + Ok(granted) => Ok(granted), + Err(_) => Err("Permission request timed out".to_string()), + } + } + + #[cfg(not(target_os = "macos"))] + Ok(true) +} + +/// Check microphone permission status. +/// +/// Returns `true` if microphone permission is granted, `false` otherwise. +#[command] +pub async fn check_microphone_permission() -> bool { + #[cfg(target_os = "macos")] + unsafe { + let av_media_type = NSString::from_str("soun"); + let status: i32 = msg_send![ + class!(AVCaptureDevice), + authorizationStatusForMediaType: &*av_media_type + ]; + + status == AVAuthorizationStatus::Authorized as i32 + } + + #[cfg(not(target_os = "macos"))] + true +} + +/// Request microphone permission with proper async handling. +/// +/// Returns: +/// - `Ok(true)` if permission was granted +/// - `Ok(false)` if permission was denied +/// - `Err(String)` if an error occurred +#[command] +pub async fn request_microphone_permission() -> Result { + #[cfg(target_os = "macos")] + unsafe { + let av_media_type = NSString::from_str("soun"); + + let status: i32 = msg_send![ + class!(AVCaptureDevice), + authorizationStatusForMediaType: &*av_media_type + ]; + + if status == AVAuthorizationStatus::Authorized as i32 { + return Ok(true); + } + + if status == AVAuthorizationStatus::Restricted as i32 + || status == AVAuthorizationStatus::Denied as i32 + { + return Ok(false); + } + + let (tx, rx) = mpsc::channel(); + + let completion_block = Box::new(move |granted: Bool| { + let _ = tx.send(granted.as_bool()); + }); + + let completion_ptr = Box::into_raw(completion_block); + + type CompletionHandler = extern "C" fn(*mut std::ffi::c_void, Bool); + + extern "C" fn trampoline(block_ptr: *mut std::ffi::c_void, granted: Bool) { + unsafe { + let closure: Box> = + Box::from_raw(block_ptr as *mut Box); + closure(granted); + } + } + + #[repr(C)] + struct Block { + isa: *const std::ffi::c_void, + flags: i32, + reserved: i32, + invoke: CompletionHandler, + descriptor: *const BlockDescriptor, + closure: *mut std::ffi::c_void, + } + + #[repr(C)] + struct BlockDescriptor { + reserved: usize, + size: usize, + copy_helper: Option, + dispose_helper: Option, + } + + static DESCRIPTOR: BlockDescriptor = BlockDescriptor { + reserved: 0, + size: std::mem::size_of::(), + copy_helper: None, + dispose_helper: Some(dispose_helper), + }; + + extern "C" fn dispose_helper(block: *mut std::ffi::c_void) { + unsafe { + let block = block as *mut Block; + let _ = Box::from_raw((*block).closure as *mut Box); + } + } + + extern "C" { + static _NSConcreteStackBlock: *const std::ffi::c_void; + } + + let block = Block { + isa: &_NSConcreteStackBlock, + flags: 1 << 25, + reserved: 0, + invoke: trampoline, + descriptor: &DESCRIPTOR, + closure: completion_ptr as *mut std::ffi::c_void, + }; + + let _: () = msg_send![ + class!(AVCaptureDevice), + requestAccessForMediaType: &*av_media_type, + completionHandler: &block + ]; + + match rx.recv_timeout(Duration::from_secs(60)) { + Ok(granted) => Ok(granted), + Err(_) => Err("Permission request timed out".to_string()), + } + } + + #[cfg(not(target_os = "macos"))] + Ok(true) +} diff --git a/src/components/Search/Camera.tsx b/src/components/Search/Camera.tsx index 0c838d6b..ec06e12e 100644 --- a/src/components/Search/Camera.tsx +++ b/src/components/Search/Camera.tsx @@ -43,33 +43,22 @@ const Camera = () => { // Step 1: Check/request macOS native camera permission if (isMac) { const authorized = await platformAdapter.checkCameraPermission(); + console.log("[Camera] Initial permission check:", authorized); + if (!authorized) { - platformAdapter.requestCameraPermission(); - // Poll until permission is granted (timeout after 60 seconds) - const POLL_TIMEOUT_MS = 60000; - const POLL_INTERVAL_MS = 500; - await new Promise((resolve, reject) => { - let elapsed = 0; - const timer = setInterval(async () => { - if (cancelled) { - clearInterval(timer); - reject(new Error("cancelled")); - return; - } - elapsed += POLL_INTERVAL_MS; - if (elapsed >= POLL_TIMEOUT_MS) { - clearInterval(timer); - reject(new Error("Camera permission timeout")); - return; - } - const granted = - await platformAdapter.checkCameraPermission(); - if (granted) { - clearInterval(timer); - resolve(); - } - }, POLL_INTERVAL_MS); - }); + console.log("[Camera] Requesting camera permission..."); + // The new implementation waits for user response + const granted = await platformAdapter.requestCameraPermission(); + console.log("[Camera] Permission request result:", granted); + + if (!granted) { + // User denied permission + console.error("[Camera] Permission denied by user"); + if (!cancelled) { + setError(t("camera.errorAccess")); + } + return; + } } } if (cancelled) return; diff --git a/src/utils/tauriAdapter.ts b/src/utils/tauriAdapter.ts index 46adee69..6a544ae9 100644 --- a/src/utils/tauriAdapter.ts +++ b/src/utils/tauriAdapter.ts @@ -163,27 +163,23 @@ export const createTauriAdapter = (): TauriPlatformAdapter => { }, async checkMicrophonePermission() { - const { checkMicrophonePermission } = - await import("tauri-plugin-macos-permissions-api"); - return checkMicrophonePermission(); + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("check_microphone_permission"); }, async requestMicrophonePermission() { - const { requestMicrophonePermission } = - await import("tauri-plugin-macos-permissions-api"); - return requestMicrophonePermission(); + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("request_microphone_permission"); }, async checkCameraPermission() { - const { checkCameraPermission } = - await import("tauri-plugin-macos-permissions-api"); - return checkCameraPermission(); + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("check_camera_permission"); }, async requestCameraPermission() { - const { requestCameraPermission } = - await import("tauri-plugin-macos-permissions-api"); - return requestCameraPermission(); + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("request_camera_permission"); }, async getScreenshotableMonitors() {