Merge commit '5c5255b4926b0c5539f585950ffe9a8a1181544a'

This commit is contained in:
程序员阿江(Relakkes) 2026-05-05 20:33:33 +08:00
commit 82c454e4dc
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,