From 4749fbbfaf98cad30a99a480a0ef7ea4423717e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 8 May 2026 09:27:09 +0800 Subject: [PATCH 1/2] fix: keep macOS notification bridge off the invoke thread The macOS notification bridge synchronously waits for UserNotifications callbacks. Running that wait on the Tauri command caller can block the callback path and make permission reads time out, which the UI reports as an unsupported environment. Route the bridge calls through a blocking worker so permission reads, permission prompts, and delivery checks do not stall the app thread that needs to service native notification callbacks. Constraint: UserNotifications permission and delivery APIs complete asynchronously through macOS callbacks. Rejected: Change the settings status label only | it would hide the failed native permission read without fixing notification delivery. Confidence: high Scope-risk: narrow Directive: Keep macOS notification bridge calls off the invoke caller thread unless the Objective-C bridge becomes fully async. Tested: cd desktop/src-tauri && cargo test Tested: bun run check:native Tested: cd desktop && bun run test --run src/lib/desktopNotifications.test.ts src/__tests__/generalSettings.test.tsx Tested: SKIP_INSTALL=1 ./scripts/build-macos-arm64.sh Tested: codesign --verify --deep --strict --verbose=2 desktop/build-artifacts/macos-arm64/Claude Code Haha.app Tested: hdiutil verify desktop/build-artifacts/macos-arm64/Claude Code Haha_0.2.1_aarch64.dmg Not-tested: Manual click-through of the newly built .app notification settings screen. --- desktop/src-tauri/src/lib.rs | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 743c19c3..b2bccacf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -811,22 +811,33 @@ fn terminal_kill(state: State<'_, TerminalState>, session_id: u32) -> Result<(), } #[tauri::command] -fn macos_notification_permission_state() -> Result { - macos_notifications::permission_state() +async fn macos_notification_permission_state() -> Result { + run_notification_bridge(macos_notifications::permission_state).await } #[tauri::command] -fn macos_request_notification_permission() -> Result { - macos_notifications::request_permission() +async fn macos_request_notification_permission() -> Result { + run_notification_bridge(macos_notifications::request_permission).await } #[tauri::command] -fn macos_send_notification( +async fn macos_send_notification( title: String, body: Option, target: Option, ) -> Result { - macos_notifications::send_notification(title, body, target) + run_notification_bridge(move || macos_notifications::send_notification(title, body, target)) + .await +} + +async fn run_notification_bridge(operation: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tauri::async_runtime::spawn_blocking(operation) + .await + .map_err(|err| format!("notification bridge worker failed: {err}"))? } fn decode_terminal_output(pending: &mut Vec, chunk: &[u8]) -> String { @@ -1344,7 +1355,7 @@ mod tests { use super::{ decode_terminal_output, default_utf8_locale, ensure_utf8_locale, has_meaningful_intersection, is_persistable_window_state, parse_env_block, - StoredWindowState, + run_notification_bridge, StoredWindowState, }; use std::collections::HashMap; @@ -1482,6 +1493,17 @@ mod tests { assert_eq!(env.get("LC_CTYPE").map(String::as_str), Some("en_US.UTF8")); assert_eq!(env.get("LC_ALL").map(String::as_str), Some("C.UTF-8")); } + + #[test] + fn notification_bridge_runs_off_the_calling_thread() { + let caller_thread = std::thread::current().id(); + let ran_on_worker = tauri::async_runtime::block_on(run_notification_bridge(move || { + Ok(std::thread::current().id() != caller_thread) + })) + .expect("notification bridge operation should complete"); + + assert!(ran_on_worker); + } } #[cfg_attr(mobile, tauri::mobile_entry_point)] From 28955bf74a081c35b524962deb2e27f5b8da56ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 8 May 2026 09:43:25 +0800 Subject: [PATCH 2/2] fix: request macOS notifications from the main queue The Rust command now keeps the synchronous bridge wait off the invoke thread, but the Objective-C bridge still initiated UserNotifications authorization from that worker. In a packaged app that path could fail immediately and the settings UI collapsed the error into an unsupported environment. Start notification settings reads, authorization requests, and delivery submissions on the macOS main queue while keeping the blocking wait on the worker thread. This preserves foreground-safe notification delivery without deadlocking the UI or losing the system permission prompt. Constraint: UserNotifications APIs are UI-adjacent and should be initiated on the app main queue. Rejected: Only change the unsupported status copy | it would keep the authorization path broken. Confidence: high Scope-risk: narrow Directive: Keep UserNotifications calls main-queue initiated while the synchronous wait remains off the main thread. Tested: cd desktop/src-tauri && cargo test Tested: bun run check:native Tested: SKIP_INSTALL=1 ./scripts/build-macos-arm64.sh Tested: codesign --verify --deep --strict --verbose=2 desktop/build-artifacts/macos-arm64/Claude Code Haha.app Tested: hdiutil verify desktop/build-artifacts/macos-arm64/Claude Code Haha_0.2.1_aarch64.dmg Tested: Computer Use opened /Users/nanmi/workspace/myself_code/claude-code-haha/desktop/build-artifacts/macos-arm64/Claude Code Haha.app and verified Settings -> General shows notifications as authorized after disable/enable. Not-tested: Fresh first-time system notification prompt on a machine that has never granted this bundle identifier. --- desktop/src-tauri/src/macos_notifications.m | 41 ++++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/desktop/src-tauri/src/macos_notifications.m b/desktop/src-tauri/src/macos_notifications.m index fe911499..9de6e864 100644 --- a/desktop/src-tauri/src/macos_notifications.m +++ b/desktop/src-tauri/src/macos_notifications.m @@ -78,6 +78,15 @@ static void cchh_copy_error(char *buffer, uintptr_t buffer_len, NSString *messag buffer[max_len] = '\0'; } +static void cchh_run_on_main_sync(dispatch_block_t block) { + if ([NSThread isMainThread]) { + block(); + return; + } + + dispatch_sync(dispatch_get_main_queue(), block); +} + void cchh_set_notification_response_callback(CCHHNotificationResponseCallback callback) { cchhNotificationResponseCallback = callback; } @@ -116,10 +125,12 @@ int cchh_notification_authorization_status(char *error_buffer, uintptr_t error_b __block NSInteger status = UNAuthorizationStatusNotDetermined; dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); - [cchh_notification_center() getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) { - status = settings.authorizationStatus; - dispatch_semaphore_signal(semaphore); - }]; + cchh_run_on_main_sync(^{ + [cchh_notification_center() getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) { + status = settings.authorizationStatus; + dispatch_semaphore_signal(semaphore); + }]; + }); if (dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)) != 0) { cchh_copy_error(error_buffer, error_buffer_len, @"Timed out while reading macOS notification permission"); @@ -143,11 +154,13 @@ bool cchh_request_notification_authorization(char *error_buffer, uintptr_t error dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionSound; - [cchh_notification_center() requestAuthorizationWithOptions:options completionHandler:^(BOOL isGranted, NSError *error) { - granted = isGranted; - requestError = error; - dispatch_semaphore_signal(semaphore); - }]; + cchh_run_on_main_sync(^{ + [cchh_notification_center() requestAuthorizationWithOptions:options completionHandler:^(BOOL isGranted, NSError *error) { + granted = isGranted; + requestError = error; + dispatch_semaphore_signal(semaphore); + }]; + }); if (dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC)) != 0) { cchh_copy_error(error_buffer, error_buffer_len, @"Timed out while requesting macOS notification permission"); @@ -195,10 +208,12 @@ bool cchh_send_user_notification(const char *title, const char *body, const char __block NSError *deliveryError = nil; dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); - [cchh_notification_center() addNotificationRequest:request withCompletionHandler:^(NSError *error) { - deliveryError = error; - dispatch_semaphore_signal(semaphore); - }]; + cchh_run_on_main_sync(^{ + [cchh_notification_center() addNotificationRequest:request withCompletionHandler:^(NSError *error) { + deliveryError = error; + dispatch_semaphore_signal(semaphore); + }]; + }); if (dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)) != 0) { cchh_copy_error(error_buffer, error_buffer_len, @"Timed out while delivering macOS notification");