mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge commit '5c5255b4926b0c5539f585950ffe9a8a1181544a'
This commit is contained in:
commit
82c454e4dc
8
desktop/src-tauri/Info.plist
Normal file
8
desktop/src-tauri/Info.plist
Normal 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>
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
193
desktop/src-tauri/src/macos_notifications.m
Normal file
193
desktop/src-tauri/src/macos_notifications.m
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 域名预检',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user