From 5c5255b4926b0c5539f585950ffe9a8a1181544a 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: Tue, 5 May 2026 20:33:12 +0800 Subject: [PATCH] fix: make macOS notification delivery verifiable Desktop notifications were enabled by default and depended on the Tauri notification plugin for both permission state and delivery. On macOS the plugin reports desktop permission as granted and can hide foreground delivery failures, so authorization prompts could appear in the app without a real system notification. This moves macOS permission and delivery through a native UserNotifications bridge, keeps notifications opt-in by default, and sends a test notification after successful authorization. Constraint: Tauri notification plugin desktop permission state is always granted. Constraint: macOS foreground delivery needs explicit native presentation handling. Rejected: Keep using the plugin with retries | it still cannot report real macOS permission or delivery failures. Confidence: high Scope-risk: moderate Directive: Do not remove the macOS bridge without verifying foreground banners from an installed app bundle. Tested: cd desktop && bun run test --run src/lib/desktopNotifications.test.ts src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx src/stores/chatStore.test.ts Tested: bun run check:desktop 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.0_aarch64.dmg Tested: Computer Use macOS Settings and Bash authorization notifications, confirmed usernoted displayed banners. Not-tested: Windows toast delivery on a real installed Windows build. --- desktop/src-tauri/Info.plist | 8 + desktop/src-tauri/build.rs | 34 +++ desktop/src-tauri/src/lib.rs | 144 ++++++++++++- desktop/src-tauri/src/macos_notifications.m | 193 ++++++++++++++++++ .../src/__tests__/generalSettings.test.tsx | 7 + desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/lib/desktopNotifications.test.ts | 109 +++++++++- desktop/src/lib/desktopNotifications.ts | 63 +++++- desktop/src/pages/Settings.tsx | 13 ++ desktop/src/stores/settingsStore.test.ts | 52 +++++ desktop/src/stores/settingsStore.ts | 4 +- 12 files changed, 614 insertions(+), 17 deletions(-) create mode 100644 desktop/src-tauri/Info.plist create mode 100644 desktop/src-tauri/src/macos_notifications.m diff --git a/desktop/src-tauri/Info.plist b/desktop/src-tauri/Info.plist new file mode 100644 index 00000000..a9a1ee4b --- /dev/null +++ b/desktop/src-tauri/Info.plist @@ -0,0 +1,8 @@ + + + + + NSUserNotificationAlertStyle + banner + + diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index d860e1e6..f6240955 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,37 @@ fn main() { + #[cfg(target_os = "macos")] + { + use std::{env, path::PathBuf, process::Command}; + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by Cargo")); + let object_path = out_dir.join("macos_notifications.o"); + let source_path = PathBuf::from("src/macos_notifications.m"); + + println!("cargo:rerun-if-changed={}", source_path.display()); + + let status = Command::new("clang") + .args([ + "-fobjc-arc", + "-c", + source_path + .to_str() + .expect("notification bridge path is UTF-8"), + "-o", + object_path + .to_str() + .expect("compiled notification bridge path is UTF-8"), + ]) + .status() + .expect("failed to invoke clang for macOS notification bridge"); + + if !status.success() { + panic!("failed to compile macOS notification bridge"); + } + + println!("cargo:rustc-link-arg={}", object_path.display()); + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=UserNotifications"); + } + tauri_build::build() } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2b9e0fc4..7df4a847 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -25,6 +25,130 @@ use tauri_plugin_shell::{ ShellExt, }; +#[cfg(target_os = "macos")] +mod macos_notifications { + use std::ffi::{CStr, CString}; + use std::os::raw::{c_char, c_int}; + + const ERROR_BUFFER_LEN: usize = 1024; + + unsafe extern "C" { + fn cchh_notification_authorization_status( + error_buffer: *mut c_char, + error_buffer_len: usize, + ) -> c_int; + fn cchh_request_notification_authorization( + error_buffer: *mut c_char, + error_buffer_len: usize, + ) -> bool; + fn cchh_send_user_notification( + title: *const c_char, + body: *const c_char, + error_buffer: *mut c_char, + error_buffer_len: usize, + ) -> bool; + } + + fn new_error_buffer() -> [c_char; ERROR_BUFFER_LEN] { + [0; ERROR_BUFFER_LEN] + } + + fn read_error(buffer: &[c_char; ERROR_BUFFER_LEN]) -> Option { + let message = unsafe { CStr::from_ptr(buffer.as_ptr()) } + .to_string_lossy() + .trim() + .to_string(); + if message.is_empty() { + None + } else { + Some(message) + } + } + + fn permission_from_status(status: c_int) -> &'static str { + match status { + 1 => "denied", + 2 | 3 | 4 => "granted", + _ => "default", + } + } + + pub fn permission_state() -> Result { + let mut error_buffer = new_error_buffer(); + let status = unsafe { + cchh_notification_authorization_status(error_buffer.as_mut_ptr(), ERROR_BUFFER_LEN) + }; + + if status < 0 { + return Err(read_error(&error_buffer) + .unwrap_or_else(|| "failed to read macOS notification permission".to_string())); + } + + Ok(permission_from_status(status).to_string()) + } + + pub fn request_permission() -> Result { + let mut error_buffer = new_error_buffer(); + let granted = unsafe { + cchh_request_notification_authorization(error_buffer.as_mut_ptr(), ERROR_BUFFER_LEN) + }; + + if granted { + return Ok("granted".to_string()); + } + + if let Some(error) = read_error(&error_buffer) { + return Err(error); + } + + permission_state() + } + + pub fn send_notification(title: String, body: Option) -> Result { + let title = CString::new(title) + .map_err(|_| "notification title contains an unsupported NUL byte".to_string())?; + let body = body + .map(CString::new) + .transpose() + .map_err(|_| "notification body contains an unsupported NUL byte".to_string())?; + + let mut error_buffer = new_error_buffer(); + let sent = unsafe { + cchh_send_user_notification( + title.as_ptr(), + body.as_ref() + .map_or(std::ptr::null(), |value| value.as_ptr()), + error_buffer.as_mut_ptr(), + ERROR_BUFFER_LEN, + ) + }; + + if sent { + return Ok(true); + } + + match read_error(&error_buffer).as_deref() { + Some("not_authorized") | None => Ok(false), + Some(error) => Err(error.to_string()), + } + } +} + +#[cfg(not(target_os = "macos"))] +mod macos_notifications { + pub fn permission_state() -> Result { + Ok("unsupported".to_string()) + } + + pub fn request_permission() -> Result { + Ok("unsupported".to_string()) + } + + pub fn send_notification(_title: String, _body: Option) -> Result { + Ok(false) + } +} + const SERVER_STARTUP_LOG_LIMIT: usize = 80; const MAIN_WINDOW_LABEL: &str = "main"; const TRAY_SHOW_ID: &str = "tray_show"; @@ -604,6 +728,21 @@ fn terminal_kill(state: State<'_, TerminalState>, session_id: u32) -> Result<(), Ok(()) } +#[tauri::command] +fn macos_notification_permission_state() -> Result { + macos_notifications::permission_state() +} + +#[tauri::command] +fn macos_request_notification_permission() -> Result { + macos_notifications::request_permission() +} + +#[tauri::command] +fn macos_send_notification(title: String, body: Option) -> Result { + macos_notifications::send_notification(title, body) +} + fn decode_terminal_output(pending: &mut Vec, chunk: &[u8]) -> String { pending.extend_from_slice(chunk); let mut output = String::new(); @@ -1234,7 +1373,10 @@ pub fn run() { terminal_spawn, terminal_write, terminal_resize, - terminal_kill + terminal_kill, + macos_notification_permission_state, + macos_request_notification_permission, + macos_send_notification ]); // macOS: native menu bar (traffic-light overlay style) diff --git a/desktop/src-tauri/src/macos_notifications.m b/desktop/src-tauri/src/macos_notifications.m new file mode 100644 index 00000000..4753ac79 --- /dev/null +++ b/desktop/src-tauri/src/macos_notifications.m @@ -0,0 +1,193 @@ +#import +#import +#import +#include +#include +#include + +@interface CCHHNotificationDelegate : NSObject +@end + +@implementation CCHHNotificationDelegate +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + willPresentNotification:(UNNotification *)notification + withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler { + if (@available(macOS 11.0, *)) { + completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionList | UNNotificationPresentationOptionSound); + } else { + completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound); + } +} +@end + +@interface CCHHUserNotificationDelegate : NSObject +@end + +@implementation CCHHUserNotificationDelegate +- (BOOL)userNotificationCenter:(NSUserNotificationCenter *)center shouldPresentNotification:(NSUserNotification *)notification { + return YES; +} +@end + +static CCHHNotificationDelegate *cchhNotificationDelegate = nil; +static CCHHUserNotificationDelegate *cchhUserNotificationDelegate = nil; + +static void cchh_copy_error(char *buffer, uintptr_t buffer_len, NSString *message) { + if (buffer == NULL || buffer_len == 0) { + return; + } + + buffer[0] = '\0'; + if (message == nil) { + return; + } + + const char *utf8 = [message UTF8String]; + if (utf8 == NULL) { + return; + } + + size_t max_len = (size_t)buffer_len - 1; + strncpy(buffer, utf8, max_len); + buffer[max_len] = '\0'; +} + +static UNUserNotificationCenter *cchh_notification_center(void) { + static dispatch_once_t onceToken; + static UNUserNotificationCenter *center = nil; + + dispatch_once(&onceToken, ^{ + center = [UNUserNotificationCenter currentNotificationCenter]; + cchhNotificationDelegate = [[CCHHNotificationDelegate alloc] init]; + center.delegate = cchhNotificationDelegate; + }); + + return center; +} + +static NSUserNotificationCenter *cchh_user_notification_center(void) { + static dispatch_once_t onceToken; + static NSUserNotificationCenter *center = nil; + + dispatch_once(&onceToken, ^{ + center = [NSUserNotificationCenter defaultUserNotificationCenter]; + cchhUserNotificationDelegate = [[CCHHUserNotificationDelegate alloc] init]; + center.delegate = cchhUserNotificationDelegate; + }); + + return center; +} + +int cchh_notification_authorization_status(char *error_buffer, uintptr_t error_buffer_len) { + @autoreleasepool { + cchh_copy_error(error_buffer, error_buffer_len, nil); + + if (@available(macOS 10.14, *)) { + __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); + }]; + + 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"); + return -1; + } + + return (int)status; + } + + return (int)UNAuthorizationStatusAuthorized; + } +} + +bool cchh_request_notification_authorization(char *error_buffer, uintptr_t error_buffer_len) { + @autoreleasepool { + cchh_copy_error(error_buffer, error_buffer_len, nil); + + if (@available(macOS 10.14, *)) { + __block BOOL granted = NO; + __block NSError *requestError = nil; + 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); + }]; + + 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"); + return false; + } + + if (requestError != nil) { + cchh_copy_error(error_buffer, error_buffer_len, [requestError localizedDescription]); + return false; + } + + return granted; + } + + return true; + } +} + +bool cchh_send_user_notification(const char *title, const char *body, char *error_buffer, uintptr_t error_buffer_len) { + @autoreleasepool { + cchh_copy_error(error_buffer, error_buffer_len, nil); + + int status = cchh_notification_authorization_status(error_buffer, error_buffer_len); + if (status < 0) { + return false; + } + if (status == UNAuthorizationStatusNotDetermined || status == UNAuthorizationStatusDenied) { + cchh_copy_error(error_buffer, error_buffer_len, @"not_authorized"); + return false; + } + + if (@available(macOS 10.14, *)) { + UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; + content.title = title != NULL ? [NSString stringWithUTF8String:title] : @"Claude Code Haha"; + if (body != NULL && strlen(body) > 0) { + content.body = [NSString stringWithUTF8String:body]; + } + content.sound = [UNNotificationSound defaultSound]; + + NSString *identifier = [[NSUUID UUID] UUIDString]; + UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:identifier content:content trigger:nil]; + __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); + }]; + + 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"); + return false; + } + + if (deliveryError != nil) { + cchh_copy_error(error_buffer, error_buffer_len, [deliveryError localizedDescription]); + return false; + } + + return true; + } + + NSUserNotification *notification = [[NSUserNotification alloc] init]; + notification.title = title != NULL ? [NSString stringWithUTF8String:title] : @"Claude Code Haha"; + if (body != NULL && strlen(body) > 0) { + notification.informativeText = [NSString stringWithUTF8String:body]; + } + notification.soundName = NSUserNotificationDefaultSoundName; + + [cchh_user_notification_center() deliverNotification:notification]; + return true; + } +} diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 526de846..141c2894 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -14,6 +14,7 @@ const MOCK_GET_SETTINGS = vi.fn() const MOCK_UPDATE_SETTINGS = vi.fn() const desktopNotificationsMock = vi.hoisted(() => ({ getDesktopNotificationPermission: vi.fn(), + notifyDesktop: vi.fn(), requestDesktopNotificationPermission: vi.fn(), openDesktopNotificationSettings: vi.fn(), })) @@ -95,9 +96,11 @@ describe('Settings > General tab', () => { beforeEach(() => { MOCK_DELETE_PROVIDER.mockReset() desktopNotificationsMock.getDesktopNotificationPermission.mockReset() + desktopNotificationsMock.notifyDesktop.mockReset() desktopNotificationsMock.requestDesktopNotificationPermission.mockReset() desktopNotificationsMock.openDesktopNotificationSettings.mockReset() desktopNotificationsMock.getDesktopNotificationPermission.mockResolvedValue('default') + desktopNotificationsMock.notifyDesktop.mockResolvedValue(true) desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('granted') desktopNotificationsMock.openDesktopNotificationSettings.mockResolvedValue(true) MOCK_GET_SETTINGS.mockResolvedValue({}) @@ -213,6 +216,10 @@ describe('Settings > General tab', () => { await vi.waitFor(() => { expect(desktopNotificationsMock.requestDesktopNotificationPermission).toHaveBeenCalledTimes(1) }) + expect(desktopNotificationsMock.notifyDesktop).toHaveBeenCalledWith({ + title: 'Claude Code Haha notifications are enabled', + body: 'Permission prompts will now use macOS notifications.', + }) }) it('opens system settings when enabling notifications finds system denial', async () => { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index b02ade1b..cf776d92 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -640,6 +640,8 @@ export const en = { 'settings.general.notificationsStatusUnsupported': 'Unavailable in this environment', 'settings.general.notificationsAuthorize': 'Authorize', 'settings.general.notificationsOpenSettings': 'Open Settings', + 'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled', + 'settings.general.notificationsTestBody': 'Permission prompts will now use macOS notifications.', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', 'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.', 'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 1d329c40..587abd1c 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -642,6 +642,8 @@ export const zh: Record = { 'settings.general.notificationsStatusUnsupported': '当前环境不可用', 'settings.general.notificationsAuthorize': '授权通知', 'settings.general.notificationsOpenSettings': '打开系统设置', + 'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用', + 'settings.general.notificationsTestBody': '后续授权确认会通过 macOS 系统通知提醒。', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', 'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检', diff --git a/desktop/src/lib/desktopNotifications.test.ts b/desktop/src/lib/desktopNotifications.test.ts index dfe5a83c..6e0d3766 100644 --- a/desktop/src/lib/desktopNotifications.test.ts +++ b/desktop/src/lib/desktopNotifications.test.ts @@ -5,6 +5,9 @@ const notificationPluginMock = vi.hoisted(() => ({ requestPermission: vi.fn(), sendNotification: vi.fn(), })) +const coreApiMock = vi.hoisted(() => ({ + invoke: vi.fn(), +})) const requestUserAttentionMock = vi.hoisted(() => vi.fn()) const windowApiMock = vi.hoisted(() => ({ requestUserAttention: requestUserAttentionMock, @@ -18,6 +21,7 @@ const windowApiMock = vi.hoisted(() => ({ })) vi.mock('@tauri-apps/plugin-notification', () => notificationPluginMock) +vi.mock('@tauri-apps/api/core', () => coreApiMock) vi.mock('@tauri-apps/api/window', () => windowApiMock) import { @@ -33,17 +37,21 @@ describe('desktopNotifications', () => { beforeEach(() => { vi.useRealTimers() resetDesktopNotificationsForTests() + coreApiMock.invoke.mockReset() notificationPluginMock.isPermissionGranted.mockReset() notificationPluginMock.requestPermission.mockReset() notificationPluginMock.sendNotification.mockReset() windowApiMock.getCurrentWindow.mockClear() windowApiMock.requestUserAttention.mockReset() useSettingsStore.setState({ desktopNotificationsEnabled: true }) + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'Linux x86_64', + }) }) - it('requests native notification permission before sending through the Tauri plugin', async () => { - notificationPluginMock.isPermissionGranted.mockResolvedValue(false) - notificationPluginMock.requestPermission.mockResolvedValue('granted') + it('sends through the Tauri plugin when native notification permission is already granted', async () => { + notificationPluginMock.isPermissionGranted.mockResolvedValue(true) notifyDesktop({ dedupeKey: 'permission:1', @@ -53,17 +61,16 @@ describe('desktopNotifications', () => { await vi.waitFor(() => expect(notificationPluginMock.sendNotification).toHaveBeenCalledTimes(1)) expect(notificationPluginMock.isPermissionGranted).toHaveBeenCalledTimes(1) - expect(notificationPluginMock.requestPermission).toHaveBeenCalledTimes(1) + expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith({ title: 'Permission required', body: 'Approve command execution', }) }) - it('does not fall back to sound when native notification permission is denied', async () => { + it('does not request notification permission from a blocking permission prompt', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) notificationPluginMock.isPermissionGranted.mockResolvedValue(false) - notificationPluginMock.requestPermission.mockResolvedValue('denied') notifyDesktop({ title: 'Permission required' }) @@ -72,6 +79,64 @@ describe('desktopNotifications', () => { expect(warnSpy).toHaveBeenCalledWith( '[desktopNotifications] native notification permission was not granted', ) + expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('uses the macOS native bridge for foreground-visible notifications', async () => { + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'MacIntel', + }) + coreApiMock.invoke.mockResolvedValueOnce(true) + + await expect(notifyDesktop({ + title: 'Permission required', + body: 'Approve command execution', + })).resolves.toBe(true) + + expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_send_notification', { + title: 'Permission required', + body: 'Approve command execution', + }) + expect(notificationPluginMock.sendNotification).not.toHaveBeenCalled() + expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() + }) + + it('does not request macOS permission from a blocking permission prompt', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'MacIntel', + }) + coreApiMock.invoke.mockResolvedValueOnce(false) + + await expect(notifyDesktop({ title: 'Permission required' })).resolves.toBe(false) + + expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_send_notification', { + title: 'Permission required', + body: undefined, + }) + expect(coreApiMock.invoke).not.toHaveBeenCalledWith('macos_request_notification_permission') + expect(warnSpy).toHaveBeenCalledWith( + '[desktopNotifications] native notification permission was not granted', + ) + warnSpy.mockRestore() + }) + + it('does not fall back to the Tauri plugin when the macOS bridge fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'MacIntel', + }) + coreApiMock.invoke.mockRejectedValueOnce(new Error('bridge unavailable')) + notificationPluginMock.isPermissionGranted.mockResolvedValue(true) + + await expect(notifyDesktop({ title: 'Permission required' })).resolves.toBe(false) + + expect(notificationPluginMock.sendNotification).not.toHaveBeenCalled() + expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled() warnSpy.mockRestore() }) @@ -116,6 +181,38 @@ describe('desktopNotifications', () => { expect(notificationPluginMock.requestPermission).toHaveBeenCalledTimes(1) }) + it('reports and requests macOS notification permission through the native bridge', async () => { + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'MacIntel', + }) + coreApiMock.invoke + .mockResolvedValueOnce('default') + .mockResolvedValueOnce('granted') + + await expect(getDesktopNotificationPermission()).resolves.toBe('default') + await expect(requestDesktopNotificationPermission()).resolves.toBe('granted') + + expect(coreApiMock.invoke).toHaveBeenNthCalledWith(1, 'macos_notification_permission_state') + expect(coreApiMock.invoke).toHaveBeenNthCalledWith(2, 'macos_request_notification_permission') + expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() + }) + + it('does not use the Tauri plugin permission fallback on macOS bridge errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'MacIntel', + }) + coreApiMock.invoke.mockRejectedValueOnce(new Error('bridge unavailable')) + notificationPluginMock.isPermissionGranted.mockResolvedValue(true) + + await expect(getDesktopNotificationPermission()).resolves.toBe('unsupported') + + expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + it('sends a native notification once for a dedupe key', async () => { const sender = vi.fn(async () => true) setNativeNotificationSenderForTests(sender) diff --git a/desktop/src/lib/desktopNotifications.ts b/desktop/src/lib/desktopNotifications.ts index ce267edc..c328e7f2 100644 --- a/desktop/src/lib/desktopNotifications.ts +++ b/desktop/src/lib/desktopNotifications.ts @@ -48,7 +48,55 @@ function getNotificationSettingsUrl(): string | null { } } +async function invokeMacNotificationPermissionState(): Promise { + if (detectPlatform() !== 'darwin') return null + + try { + const { invoke } = await import('@tauri-apps/api/core') + const permission = await invoke('macos_notification_permission_state') + return ['default', 'denied', 'granted', 'unsupported'].includes(permission) ? permission : 'unsupported' + } catch (err) { + if (typeof console !== 'undefined') { + console.warn('[desktopNotifications] failed to read macOS notification permission:', err) + } + return 'unsupported' + } +} + +async function invokeMacNotificationPermissionRequest(): Promise { + if (detectPlatform() !== 'darwin') return null + + try { + const { invoke } = await import('@tauri-apps/api/core') + const permission = await invoke('macos_request_notification_permission') + return ['default', 'denied', 'granted', 'unsupported'].includes(permission) ? permission : 'unsupported' + } catch (err) { + if (typeof console !== 'undefined') { + console.warn('[desktopNotifications] failed to request macOS notification permission:', err) + } + return 'unsupported' + } +} + +async function sendMacNotification(options: { title: string; body?: string }): Promise { + if (detectPlatform() !== 'darwin') return null + + try { + const { invoke } = await import('@tauri-apps/api/core') + const sent = await invoke('macos_send_notification', options) + return typeof sent === 'boolean' ? sent : false + } catch (err) { + if (typeof console !== 'undefined') { + console.warn('[desktopNotifications] failed to send macOS native notification:', err) + } + return false + } +} + export async function getDesktopNotificationPermission(): Promise { + const macPermission = await invokeMacNotificationPermissionState() + if (macPermission) return macPermission + try { const { isPermissionGranted } = await import('@tauri-apps/plugin-notification') if (await isPermissionGranted()) return 'granted' @@ -59,6 +107,9 @@ export async function getDesktopNotificationPermission(): Promise { + const macPermission = await invokeMacNotificationPermissionRequest() + if (macPermission) return macPermission + try { const { isPermissionGranted, @@ -91,19 +142,15 @@ export async function openDesktopNotificationSettings(): Promise { } async function sendNativeNotification(options: { title: string; body?: string }): Promise { + const macSent = await sendMacNotification(options) + if (macSent !== null) return macSent + const { isPermissionGranted, - requestPermission, sendNotification, } = await import('@tauri-apps/plugin-notification') - let permissionGranted = await isPermissionGranted() - if (!permissionGranted) { - const permission = await requestPermission() - permissionGranted = permission === 'granted' - } - - if (!permissionGranted) { + if (!(await isPermissionGranted())) { return false } diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index aa570580..14177398 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -33,6 +33,7 @@ import { formatBytes } from '../lib/formatBytes' import { isTauriRuntime } from '../lib/desktopRuntime' import { getDesktopNotificationPermission, + notifyDesktop, openDesktopNotificationSettings, requestDesktopNotificationPermission, type DesktopNotificationPermission, @@ -1415,6 +1416,12 @@ function GeneralSettings() { try { const permission = await requestDesktopNotificationPermission() setNotificationPermission(permission) + if (permission === 'granted') { + void notifyDesktop({ + title: t('settings.general.notificationsTestTitle'), + body: t('settings.general.notificationsTestBody'), + }) + } if (permission === 'denied') { await openDesktopNotificationSettings() } @@ -1431,6 +1438,12 @@ function GeneralSettings() { } else { const permission = await requestDesktopNotificationPermission() setNotificationPermission(permission) + if (permission === 'granted') { + void notifyDesktop({ + title: t('settings.general.notificationsTestTitle'), + body: t('settings.general.notificationsTestBody'), + }) + } if (permission === 'denied') { await openDesktopNotificationSettings() } diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index ab6246a0..15a84217 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -28,6 +28,58 @@ describe('settingsStore desktop notification persistence', () => { window.localStorage.clear() }) + it('defaults desktop notifications to explicit opt-in', async () => { + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn(), + updateUser: vi.fn(), + getPermissionMode: vi.fn(), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn(), + getCurrent: vi.fn(), + setCurrent: vi.fn(), + getEffort: vi.fn(), + setEffort: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + + expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(false) + }) + + it('keeps desktop notifications disabled when user settings do not opt in', async () => { + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn().mockResolvedValue({}), + updateUser: vi.fn(), + getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn().mockResolvedValue({ models: [] }), + getCurrent: vi.fn().mockResolvedValue({ model: null }), + setCurrent: vi.fn(), + getEffort: vi.fn().mockResolvedValue({ level: 'medium' }), + setEffort: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + + await useSettingsStore.getState().fetchAll() + + expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(false) + }) + it('persists the latest desktop notification toggle when saves overlap', async () => { const pendingSaves: Array<() => void> = [] const updateUser = vi.fn( diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 182401a8..df185a70 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -53,7 +53,7 @@ export const useSettingsStore = create((set, get) => ({ locale: getStoredLocale(), theme: useUIStore.getState().theme, skipWebFetchPreflight: true, - desktopNotificationsEnabled: true, + desktopNotificationsEnabled: false, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, isLoading: false, error: null, @@ -79,7 +79,7 @@ export const useSettingsStore = create((set, get) => ({ thinkingEnabled: userSettings.alwaysThinkingEnabled !== false, theme, skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false, - desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled !== false, + desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true, webSearch: normalizeWebSearchSettings(userSettings.webSearch), isLoading: false, error: null,