From 6c221b06527b8f398d0997b8b76e64f026e7d502 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 21:42:16 -0600 Subject: [PATCH] fix(desktop): skip native notifications outside app bundles UNUserNotificationCenter aborts raw macOS dev executables even when Tauri embeds a bundle identifier. Require an actual .app/Contents/MacOS layout before initializing the native notification center. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/macos_notifications.rs | 54 ++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs index da2312b457e..5bcedd8975b 100644 --- a/desktop/src-tauri/src/macos_notifications.rs +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -8,6 +8,7 @@ use std::{ collections::VecDeque, + path::Path, ptr::NonNull, sync::{mpsc, Mutex, OnceLock}, time::Duration, @@ -128,7 +129,7 @@ pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { // objc2 cannot turn that exception into a Rust error, so do not call // into the framework at all in this environment. eprintln!( - "buzz-desktop: macOS notifications disabled because the process has no bundle identifier" + "buzz-desktop: macOS notifications disabled because the process is not running from an app bundle" ); return Ok(()); } @@ -293,7 +294,30 @@ pub(crate) fn take_pending_activations() -> Result, Strin } fn is_bundled_application() -> bool { - NSBundle::mainBundle().bundleIdentifier().is_some() + let bundle = NSBundle::mainBundle(); + bundle.bundleIdentifier().is_some() + && bundle.executablePath().is_some_and(|executable_path| { + is_application_bundle_layout( + Path::new(&bundle.bundlePath().to_string()), + Path::new(&executable_path.to_string()), + ) + }) +} + +fn is_application_bundle_layout(bundle_path: &Path, executable_path: &Path) -> bool { + let Some(macos_path) = executable_path.parent() else { + return false; + }; + let Some(contents_path) = macos_path.parent() else { + return false; + }; + + bundle_path + .extension() + .is_some_and(|extension| extension == "app") + && macos_path.file_name() == Some("MacOS".as_ref()) + && contents_path.file_name() == Some("Contents".as_ref()) + && contents_path.parent() == Some(bundle_path) } fn target_from_response(response: &UNNotificationResponse) -> Option { @@ -311,10 +335,12 @@ fn parse_target(serialized: &str) -> Option { #[cfg(test)] mod tests { use super::{ - is_bundled_application, parse_target, permission_state, queue_activation, - take_pending_activations, NotificationPermissionState, MAX_PENDING_ACTIVATIONS, + is_application_bundle_layout, is_bundled_application, parse_target, permission_state, + queue_activation, take_pending_activations, NotificationPermissionState, + MAX_PENDING_ACTIVATIONS, }; use objc2_user_notifications::UNAuthorizationStatus; + use std::path::Path; #[test] fn activation_queue_is_bounded_and_drained() { @@ -336,6 +362,26 @@ mod tests { assert!(!is_bundled_application()); } + #[test] + fn requires_the_executable_to_use_the_app_bundle_layout() { + assert!(is_application_bundle_layout( + Path::new("/Applications/Buzz.app"), + Path::new("/Applications/Buzz.app/Contents/MacOS/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/tmp/Fake.app"), + Path::new("/tmp/Fake.app/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/Users/developer/buzz/desktop/src-tauri/target/debug"), + Path::new("/Users/developer/buzz/desktop/src-tauri/target/debug/buzz-desktop"), + )); + assert!(!is_application_bundle_layout( + Path::new("/Applications/Buzz.app"), + Path::new("/Applications/Other.app/Contents/MacOS/buzz-desktop"), + )); + } + #[test] fn maps_native_authorization_states_to_frontend_contract() { assert_eq!(