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,