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>
This commit is contained in:
anthropic-code-agent[bot]
2026-05-20 08:58:46 +00:00
committed by GitHub
parent b3bb773e65
commit 60e2874b3d
4 changed files with 318 additions and 38 deletions

View File

@@ -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,

View File

@@ -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<bool, String> {
#[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<dyn FnOnce(Bool)>> =
Box::from_raw(block_ptr as *mut Box<dyn FnOnce(Bool)>);
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<extern "C" fn(*mut std::ffi::c_void, *const std::ffi::c_void)>,
dispose_helper: Option<extern "C" fn(*mut std::ffi::c_void)>,
}
static DESCRIPTOR: BlockDescriptor = BlockDescriptor {
reserved: 0,
size: std::mem::size_of::<Block>(),
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<dyn FnOnce(Bool)>);
}
}
// 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<bool, String> {
#[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<dyn FnOnce(Bool)>> =
Box::from_raw(block_ptr as *mut Box<dyn FnOnce(Bool)>);
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<extern "C" fn(*mut std::ffi::c_void, *const std::ffi::c_void)>,
dispose_helper: Option<extern "C" fn(*mut std::ffi::c_void)>,
}
static DESCRIPTOR: BlockDescriptor = BlockDescriptor {
reserved: 0,
size: std::mem::size_of::<Block>(),
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<dyn FnOnce(Bool)>);
}
}
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)
}

View File

@@ -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<void>((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;

View File

@@ -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<boolean>("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<boolean>("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<boolean>("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<boolean>("request_camera_permission");
},
async getScreenshotableMonitors() {