From 96ae141763e5459beb68a847e0082e931af72f4c Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 22:53:25 -0600 Subject: [PATCH] fix(desktop): skip native notifications outside app bundles (#5004) ## Summary - require the macOS process to be running from an actual `.app` bundle before initializing `UNUserNotificationCenter` - keep the existing bundle-identifier requirement - cover packaged, case-insensitive `.app`, raw `target/debug`, and extensionless paths ## Why PR #4799 guarded native notification initialization with `NSBundle.mainBundle.bundleIdentifier != nil`. Tauri embeds a bundle identifier in raw development executables, so `tauri dev` passed that guard and `UNUserNotificationCenter.current()` raised an uncaught `NSInternalInconsistencyException` because LaunchServices had no bundle proxy. ## Validation - focused macOS notification tests: 6 passed - direct raw debug executable no longer raises the notification-center exception - pre-commit formatting hook passed - pre-push package checks passed on pushed commit `f29a6664d2a863e7b8aa527f6149fd00b183e4de` The first push attempt hit an unrelated timing-test failure in `relay_admission::tests::concurrent_429_extends_the_window_for_parked_waiters`; its focused rerun passed, and the complete pre-push package suite passed on the next push. Signed-off-by: Wes Co-authored-by: Carl --- 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 da2312b457..5bcedd8975 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!(