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,