fix desktop notification handling on macos

This commit is contained in:
Mohamed Boudra
2026-03-19 16:43:34 +07:00
parent a952112910
commit efb3df8233
9 changed files with 486 additions and 53 deletions
@@ -10,6 +10,10 @@ export interface DesktopPermissionRowProps {
isRequesting: boolean;
showBorder?: boolean;
onRequest: () => void;
extraActionLabel?: string;
isExtraActionBusy?: boolean;
isExtraActionDisabled?: boolean;
onExtraAction?: () => void;
}
export function DesktopPermissionRow({
@@ -18,6 +22,10 @@ export function DesktopPermissionRow({
isRequesting,
showBorder,
onRequest,
extraActionLabel,
isExtraActionBusy = false,
isExtraActionDisabled = false,
onExtraAction,
}: DesktopPermissionRowProps) {
const { theme } = useUnistyles();
const state = status?.state ?? "unknown";
@@ -36,9 +44,21 @@ export function DesktopPermissionRow({
</View>
<View style={styles.permissionRowActions}>
{isGranted ? (
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
<View style={styles.permissionGrantedActions}>
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
</View>
{extraActionLabel && onExtraAction ? (
<Button
variant="outline"
size="sm"
onPress={onExtraAction}
disabled={isExtraActionDisabled || isExtraActionBusy}
>
{isExtraActionBusy ? `${extraActionLabel}...` : extraActionLabel}
</Button>
) : null}
</View>
) : (
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
@@ -75,6 +95,11 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "flex-end",
gap: theme.spacing[1],
},
permissionGrantedActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
permissionStatusPill: {
flexDirection: "row",
alignItems: "center",
@@ -13,8 +13,10 @@ export function DesktopPermissionsSection() {
snapshot,
isRefreshing,
requestingPermission,
isSendingTestNotification,
refreshPermissions,
requestPermission,
sendTestNotification,
} = useDesktopPermissions();
if (!isDesktop) {
@@ -22,6 +24,7 @@ export function DesktopPermissionsSection() {
}
const isBusy = isRefreshing || requestingPermission !== null;
const notificationsGranted = snapshot?.notifications.state === "granted";
return (
<View style={settingsStyles.section}>
@@ -48,6 +51,12 @@ export function DesktopPermissionsSection() {
onRequest={() => {
void requestPermission("notifications");
}}
extraActionLabel="Test"
isExtraActionBusy={isSendingTestNotification}
isExtraActionDisabled={!notificationsGranted || isBusy}
onExtraAction={() => {
void sendTestNotification();
}}
/>
<DesktopPermissionRow
title="Microphone"
@@ -6,14 +6,17 @@ import {
type DesktopPermissionKind,
type DesktopPermissionSnapshot,
} from "@/desktop/permissions/desktop-permissions";
import { sendOsNotification } from "@/utils/os-notifications";
export interface UseDesktopPermissionsReturn {
isDesktop: boolean;
snapshot: DesktopPermissionSnapshot | null;
isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null;
isSendingTestNotification: boolean;
refreshPermissions: () => Promise<void>;
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
sendTestNotification: () => Promise<void>;
}
const EMPTY_NOTIFICATION_STATUS = {
@@ -34,6 +37,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
null
);
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
useEffect(() => {
return () => {
@@ -109,6 +113,29 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
[isDesktop, refreshPermissions]
);
const sendTestNotification = useCallback(async () => {
if (!isDesktop) {
return;
}
setIsSendingTestNotification(true);
try {
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, delivery works. Click it to verify the open flow.",
});
if (!sent) {
console.warn("[Settings] Desktop test notification was not delivered");
}
} catch (error) {
console.error("[Settings] Failed to send desktop test notification", error);
} finally {
if (isMountedRef.current) {
setIsSendingTestNotification(false);
}
}
}, [isDesktop]);
useEffect(() => {
if (!isDesktop) {
return;
@@ -122,7 +149,9 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
snapshot,
isRefreshing,
requestingPermission,
isSendingTestNotification,
refreshPermissions,
requestPermission,
sendTestNotification,
};
}
+1 -1
View File
@@ -2632,12 +2632,12 @@ name = "paseo"
version = "0.1.30"
dependencies = [
"base64 0.22.1",
"cc",
"dirs",
"dunce",
"futures-util",
"http",
"log",
"mac-notification-sys",
"serde",
"serde_json",
"tauri",
+1 -3
View File
@@ -19,6 +19,7 @@ name = "Paseo"
path = "src/main.rs"
[build-dependencies]
cc = "1"
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
@@ -41,9 +42,6 @@ tauri-plugin-websocket = "2"
tokio = { version = "1", features = ["net", "sync"] }
tokio-tungstenite = "0.24"
[target.'cfg(target_os = "macos")'.dependencies]
mac-notification-sys = "0.6"
+14
View File
@@ -1,3 +1,17 @@
fn main() {
#[cfg(target_os = "macos")]
{
println!("cargo:rerun-if-changed=macos/paseo_notifications.h");
println!("cargo:rerun-if-changed=macos/paseo_notifications.m");
cc::Build::new()
.file("macos/paseo_notifications.m")
.flag("-fobjc-arc")
.compile("paseo_notifications");
println!("cargo:rustc-link-lib=framework=Foundation");
println!("cargo:rustc-link-lib=framework=UserNotifications");
}
tauri_build::build()
}
@@ -0,0 +1,12 @@
#import <stdbool.h>
typedef void (*PaseoNotificationClickCallback)(const char* payload_json);
bool paseo_notifications_initialize(PaseoNotificationClickCallback callback, char** error_out);
bool paseo_notifications_send(
const char* title,
const char* body,
const char* payload_json,
char** error_out
);
void paseo_notifications_free_string(char* value);
@@ -0,0 +1,176 @@
#import "paseo_notifications.h"
#import <dispatch/dispatch.h>
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>
@interface PaseoNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
@property(nonatomic, assign) PaseoNotificationClickCallback clickCallback;
@end
@implementation PaseoNotificationDelegate
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
didReceiveNotificationResponse:(UNNotificationResponse*)response
withCompletionHandler:(void (^)(void))completionHandler {
if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier] &&
self.clickCallback != NULL) {
id payload = response.notification.request.content.userInfo[@"paseoPayloadJson"];
if ([payload isKindOfClass:[NSString class]]) {
self.clickCallback([(NSString*)payload UTF8String]);
} else {
self.clickCallback(NULL);
}
}
[center removeDeliveredNotificationsWithIdentifiers:@[
response.notification.request.identifier,
]];
completionHandler();
}
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
willPresentNotification:(UNNotification*)notification
withCompletionHandler:
(void (^)(UNNotificationPresentationOptions options))completionHandler {
if (@available(macOS 11.0, *)) {
completionHandler(
UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionList
);
return;
}
completionHandler((UNNotificationPresentationOptions)0);
}
@end
static PaseoNotificationDelegate* paseoNotificationDelegate = nil;
static BOOL paseoNotificationsAvailable = NO;
static void paseo_run_on_main_sync(dispatch_block_t block) {
if ([NSThread isMainThread]) {
block();
return;
}
dispatch_sync(dispatch_get_main_queue(), block);
}
static char* paseo_strdup(NSString* value) {
if (value == nil) {
return NULL;
}
const char* utf8 = [value UTF8String];
if (utf8 == NULL) {
return NULL;
}
size_t length = strlen(utf8);
char* buffer = malloc(length + 1);
if (buffer == NULL) {
return NULL;
}
memcpy(buffer, utf8, length + 1);
return buffer;
}
static BOOL paseo_is_bundled_app_host(void) {
NSURL* bundleURL = [[NSBundle mainBundle] bundleURL];
NSString* pathExtension = [[bundleURL pathExtension] lowercaseString];
return [pathExtension isEqualToString:@"app"];
}
bool paseo_notifications_initialize(PaseoNotificationClickCallback callback, char** error_out) {
@autoreleasepool {
paseo_run_on_main_sync(^{
if (paseoNotificationDelegate == nil) {
paseoNotificationDelegate = [[PaseoNotificationDelegate alloc] init];
paseoNotificationsAvailable = paseo_is_bundled_app_host();
if (paseoNotificationsAvailable) {
[[UNUserNotificationCenter currentNotificationCenter]
setDelegate:paseoNotificationDelegate];
}
}
paseoNotificationDelegate.clickCallback = callback;
});
if (error_out != NULL) {
*error_out = NULL;
}
return true;
}
}
bool paseo_notifications_send(
const char* title,
const char* body,
const char* payload_json,
char** error_out
) {
@autoreleasepool {
if (paseoNotificationDelegate == nil) {
if (error_out != NULL) {
*error_out = paseo_strdup(@"Notification bridge is not initialized.");
}
return false;
}
if (!paseoNotificationsAvailable) {
if (error_out != NULL) {
*error_out = NULL;
}
return true;
}
NSString* notificationTitle =
[NSString stringWithUTF8String:(title != NULL ? title : "")];
NSString* notificationBody =
(body != NULL && body[0] != '\0') ? [NSString stringWithUTF8String:body] : nil;
NSString* payloadString =
(payload_json != NULL && payload_json[0] != '\0')
? [NSString stringWithUTF8String:payload_json]
: nil;
NSString* identifier = [[NSUUID UUID] UUIDString];
dispatch_async(dispatch_get_main_queue(), ^{
UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
content.title = notificationTitle;
if (notificationBody != nil) {
content.body = notificationBody;
}
if (payloadString != nil) {
content.userInfo = @{
@"paseoPayloadJson" : payloadString,
};
}
UNNotificationRequest* request =
[UNNotificationRequest requestWithIdentifier:identifier
content:content
trigger:nil];
[[UNUserNotificationCenter currentNotificationCenter]
addNotificationRequest:request
withCompletionHandler:^(NSError* _Nullable error) {
if (error != nil) {
NSLog(@"[PaseoNotifications] Failed to schedule notification: %@",
error.localizedDescription);
}
}];
});
if (error_out != NULL) {
*error_out = NULL;
}
return true;
}
}
void paseo_notifications_free_string(char* value) {
if (value != NULL) {
free(value);
}
}
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{AppHandle, Runtime};
use tauri::{AppHandle, Wry};
pub const DESKTOP_NOTIFICATION_CLICK_EVENT: &str = "desktop-notification-click";
@@ -22,70 +22,240 @@ struct DesktopNotificationClickPayload {
mod platform {
use super::{
AppHandle, DesktopNotificationClickPayload, DesktopNotificationInput,
DESKTOP_NOTIFICATION_CLICK_EVENT,
DESKTOP_NOTIFICATION_CLICK_EVENT, Value, Wry,
};
use std::sync::OnceLock;
use tauri::{async_runtime, Emitter, Runtime};
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::sync::{Mutex, OnceLock};
use tauri::Emitter;
const MACOS_NOTIFICATION_SENDER_BUNDLE_ID: &str = "dev.paseo.desktop";
static MACOS_NOTIFICATION_SENDER_INIT: OnceLock<Result<(), String>> = OnceLock::new();
#[link(name = "paseo_notifications", kind = "static")]
unsafe extern "C" {
fn paseo_notifications_initialize(
callback: extern "C" fn(*const c_char),
error_out: *mut *mut c_char,
) -> bool;
fn paseo_notifications_send(
title: *const c_char,
body: *const c_char,
payload_json: *const c_char,
error_out: *mut *mut c_char,
) -> bool;
fn paseo_notifications_free_string(value: *mut c_char);
}
fn ensure_notification_sender() -> Result<(), String> {
MACOS_NOTIFICATION_SENDER_INIT
static MACOS_NOTIFICATION_APP: OnceLock<Mutex<Option<AppHandle<Wry>>>> = OnceLock::new();
static MACOS_NOTIFICATION_INIT: OnceLock<Result<(), String>> = OnceLock::new();
fn app_slot() -> &'static Mutex<Option<AppHandle<Wry>>> {
MACOS_NOTIFICATION_APP.get_or_init(|| Mutex::new(None))
}
fn read_owned_error_message(error_ptr: *mut c_char) -> String {
if error_ptr.is_null() {
return "Unknown macOS notification error".to_string();
}
let message = unsafe { CStr::from_ptr(error_ptr) }
.to_string_lossy()
.into_owned();
unsafe {
paseo_notifications_free_string(error_ptr);
}
message
}
extern "C" fn handle_notification_click(payload_json: *const c_char) {
let data = if payload_json.is_null() {
None
} else {
let raw = unsafe { CStr::from_ptr(payload_json) }.to_string_lossy();
serde_json::from_str::<Value>(&raw).ok()
};
let payload = DesktopNotificationClickPayload { data };
let Ok(guard) = app_slot().lock() else {
return;
};
let Some(app) = guard.clone() else {
return;
};
if let Err(error) = app.emit(DESKTOP_NOTIFICATION_CLICK_EVENT, payload) {
log::warn!("failed to emit desktop notification click event: {error}");
}
}
fn ensure_notification_bridge() -> Result<(), String> {
MACOS_NOTIFICATION_INIT
.get_or_init(|| {
mac_notification_sys::set_application(MACOS_NOTIFICATION_SENDER_BUNDLE_ID)
.map_err(|error| {
format!(
"Failed to initialize macOS notification sender for {}: {error}",
MACOS_NOTIFICATION_SENDER_BUNDLE_ID
)
})
let mut error_ptr: *mut c_char = std::ptr::null_mut();
let ok = unsafe {
paseo_notifications_initialize(handle_notification_click, &mut error_ptr)
};
if ok {
Ok(())
} else {
Err(read_owned_error_message(error_ptr))
}
})
.clone()
}
pub fn send_notification<R: Runtime>(
app: AppHandle<R>,
pub fn send_notification(
app: AppHandle<Wry>,
input: DesktopNotificationInput,
) -> Result<(), String> {
let _task = async_runtime::spawn_blocking(move || {
if let Err(error) = ensure_notification_sender() {
log::warn!("{error}");
return;
{
let mut guard = app_slot()
.lock()
.map_err(|_| "Failed to lock macOS notification app handle".to_string())?;
*guard = Some(app);
}
ensure_notification_bridge()?;
let title = CString::new(input.title)
.map_err(|_| "Desktop notification title contains interior NUL byte".to_string())?;
let body = CString::new(input.body.unwrap_or_default())
.map_err(|_| "Desktop notification body contains interior NUL byte".to_string())?;
let payload_json = CString::new(
input
.data
.map(|value| value.to_string())
.unwrap_or_default(),
)
.map_err(|_| "Desktop notification payload contains interior NUL byte".to_string())?;
let mut error_ptr: *mut c_char = std::ptr::null_mut();
let ok = unsafe {
paseo_notifications_send(
title.as_ptr(),
body.as_ptr(),
payload_json.as_ptr(),
&mut error_ptr,
)
};
if ok {
Ok(())
} else {
Err(read_owned_error_message(error_ptr))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::time::{Duration, Instant};
extern "C" fn noop_notification_click(_payload_json: *const c_char) {}
static CLICK_TEST_SENDER: std::sync::OnceLock<mpsc::Sender<Option<String>>> =
std::sync::OnceLock::new();
extern "C" fn capture_notification_click(payload_json: *const c_char) {
let payload = if payload_json.is_null() {
None
} else {
Some(
unsafe { CStr::from_ptr(payload_json) }
.to_string_lossy()
.into_owned(),
)
};
if let Some(sender) = CLICK_TEST_SENDER.get() {
let _ = sender.send(payload);
}
}
let mut notification = mac_notification_sys::Notification::default();
notification
.title(&input.title)
.message(input.body.as_deref().unwrap_or(""))
.wait_for_click(true);
#[test]
#[ignore = "requires a running Cocoa app main queue; verify through the desktop app"]
fn native_notification_send_returns_without_waiting_for_click() {
let mut error_ptr: *mut c_char = std::ptr::null_mut();
let initialized = unsafe {
paseo_notifications_initialize(noop_notification_click, &mut error_ptr)
};
assert!(
initialized,
"bridge initialization failed: {}",
read_owned_error_message(error_ptr)
);
match notification.send() {
Ok(mac_notification_sys::NotificationResponse::Click) => {
let payload = DesktopNotificationClickPayload { data: input.data };
if let Err(error) = app.emit(DESKTOP_NOTIFICATION_CLICK_EVENT, payload) {
log::warn!("failed to emit desktop notification click event: {error}");
}
}
Ok(_) => {}
Err(error) => {
log::warn!("failed to send macOS notification: {error}");
}
}
});
let title = CString::new("Paseo notification smoke test").expect("valid title");
let started_at = Instant::now();
let sent = unsafe {
paseo_notifications_send(
title.as_ptr(),
std::ptr::null(),
std::ptr::null(),
&mut error_ptr,
)
};
assert!(
sent,
"native send failed: {}",
read_owned_error_message(error_ptr)
);
assert!(
started_at.elapsed() < Duration::from_secs(2),
"native send unexpectedly blocked for {:?}",
started_at.elapsed()
);
}
Ok(())
#[test]
#[ignore = "manual smoke test; click the macOS notification within 60 seconds"]
fn native_notification_click_callback_roundtrip() {
let (tx, rx) = mpsc::channel();
let _ = CLICK_TEST_SENDER.set(tx);
let mut error_ptr: *mut c_char = std::ptr::null_mut();
let initialized = unsafe {
paseo_notifications_initialize(capture_notification_click, &mut error_ptr)
};
assert!(
initialized,
"bridge initialization failed: {}",
read_owned_error_message(error_ptr)
);
let title = CString::new("Paseo manual click test").expect("valid title");
let body = CString::new("Click this notification to verify the callback path.")
.expect("valid body");
let payload_json =
CString::new(r#"{"kind":"manual-smoke","source":"codex"}"#).expect("valid payload");
let sent = unsafe {
paseo_notifications_send(
title.as_ptr(),
body.as_ptr(),
payload_json.as_ptr(),
&mut error_ptr,
)
};
assert!(
sent,
"native send failed: {}",
read_owned_error_message(error_ptr)
);
eprintln!("Notification sent. Click it within 60 seconds.");
let payload = rx
.recv_timeout(Duration::from_secs(60))
.expect("timed out waiting for notification click");
assert_eq!(payload.as_deref(), Some(r#"{"kind":"manual-smoke","source":"codex"}"#));
}
}
}
#[cfg(not(target_os = "macos"))]
mod platform {
use super::{AppHandle, DesktopNotificationInput};
use tauri::Runtime;
use super::{AppHandle, DesktopNotificationInput, Wry};
use tauri_plugin_notification::NotificationExt;
pub fn send_notification<R: Runtime>(
app: AppHandle<R>,
pub fn send_notification(
app: AppHandle<Wry>,
input: DesktopNotificationInput,
) -> Result<(), String> {
let mut notification = app.notification().builder().title(&input.title);
@@ -102,8 +272,8 @@ mod platform {
}
#[tauri::command]
pub async fn send_desktop_notification<R: Runtime>(
app: AppHandle<R>,
pub async fn send_desktop_notification(
app: AppHandle<Wry>,
input: DesktopNotificationInput,
) -> Result<(), String> {
platform::send_notification(app, input)