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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-05 20:33:12 +08:00
parent 432a58d725
commit 5c5255b492
12 changed files with 614 additions and 17 deletions

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSUserNotificationAlertStyle</key>
<string>banner</string>
</dict>
</plist>

View File

@ -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()
}

View File

@ -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<String> {
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<String, String> {
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<String, String> {
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<String>) -> Result<bool, String> {
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<String, String> {
Ok("unsupported".to_string())
}
pub fn request_permission() -> Result<String, String> {
Ok("unsupported".to_string())
}
pub fn send_notification(_title: String, _body: Option<String>) -> Result<bool, String> {
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<String, String> {
macos_notifications::permission_state()
}
#[tauri::command]
fn macos_request_notification_permission() -> Result<String, String> {
macos_notifications::request_permission()
}
#[tauri::command]
fn macos_send_notification(title: String, body: Option<String>) -> Result<bool, String> {
macos_notifications::send_notification(title, body)
}
fn decode_terminal_output(pending: &mut Vec<u8>, 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)

View File

@ -0,0 +1,193 @@
#import <Cocoa/Cocoa.h>
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
@interface CCHHNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
@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 <NSUserNotificationCenterDelegate>
@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;
}
}

View File

@ -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 () => {

View File

@ -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',

View File

@ -642,6 +642,8 @@ export const zh: Record<TranslationKey, string> = {
'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 域名预检',

View File

@ -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)

View File

@ -48,7 +48,55 @@ function getNotificationSettingsUrl(): string | null {
}
}
async function invokeMacNotificationPermissionState(): Promise<DesktopNotificationPermission | null> {
if (detectPlatform() !== 'darwin') return null
try {
const { invoke } = await import('@tauri-apps/api/core')
const permission = await invoke<DesktopNotificationPermission>('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<DesktopNotificationPermission | null> {
if (detectPlatform() !== 'darwin') return null
try {
const { invoke } = await import('@tauri-apps/api/core')
const permission = await invoke<DesktopNotificationPermission>('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<boolean | null> {
if (detectPlatform() !== 'darwin') return null
try {
const { invoke } = await import('@tauri-apps/api/core')
const sent = await invoke<boolean>('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<DesktopNotificationPermission> {
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<DesktopNotific
}
export async function requestDesktopNotificationPermission(): Promise<DesktopNotificationPermission> {
const macPermission = await invokeMacNotificationPermissionRequest()
if (macPermission) return macPermission
try {
const {
isPermissionGranted,
@ -91,19 +142,15 @@ export async function openDesktopNotificationSettings(): Promise<boolean> {
}
async function sendNativeNotification(options: { title: string; body?: string }): Promise<boolean> {
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
}

View File

@ -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()
}

View File

@ -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(

View File

@ -53,7 +53,7 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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,