fix desktop notification handling on macos
This commit is contained in:
@@ -10,6 +10,10 @@ export interface DesktopPermissionRowProps {
|
|||||||
isRequesting: boolean;
|
isRequesting: boolean;
|
||||||
showBorder?: boolean;
|
showBorder?: boolean;
|
||||||
onRequest: () => void;
|
onRequest: () => void;
|
||||||
|
extraActionLabel?: string;
|
||||||
|
isExtraActionBusy?: boolean;
|
||||||
|
isExtraActionDisabled?: boolean;
|
||||||
|
onExtraAction?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopPermissionRow({
|
export function DesktopPermissionRow({
|
||||||
@@ -18,6 +22,10 @@ export function DesktopPermissionRow({
|
|||||||
isRequesting,
|
isRequesting,
|
||||||
showBorder,
|
showBorder,
|
||||||
onRequest,
|
onRequest,
|
||||||
|
extraActionLabel,
|
||||||
|
isExtraActionBusy = false,
|
||||||
|
isExtraActionDisabled = false,
|
||||||
|
onExtraAction,
|
||||||
}: DesktopPermissionRowProps) {
|
}: DesktopPermissionRowProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const state = status?.state ?? "unknown";
|
const state = status?.state ?? "unknown";
|
||||||
@@ -36,9 +44,21 @@ export function DesktopPermissionRow({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.permissionRowActions}>
|
<View style={styles.permissionRowActions}>
|
||||||
{isGranted ? (
|
{isGranted ? (
|
||||||
<View style={styles.permissionStatusPill}>
|
<View style={styles.permissionGrantedActions}>
|
||||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<View style={styles.permissionStatusPill}>
|
||||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
<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>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
|
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
|
||||||
@@ -75,6 +95,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
permissionGrantedActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
permissionStatusPill: {
|
permissionStatusPill: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ export function DesktopPermissionsSection() {
|
|||||||
snapshot,
|
snapshot,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
requestingPermission,
|
requestingPermission,
|
||||||
|
isSendingTestNotification,
|
||||||
refreshPermissions,
|
refreshPermissions,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
|
sendTestNotification,
|
||||||
} = useDesktopPermissions();
|
} = useDesktopPermissions();
|
||||||
|
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
@@ -22,6 +24,7 @@ export function DesktopPermissionsSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isBusy = isRefreshing || requestingPermission !== null;
|
const isBusy = isRefreshing || requestingPermission !== null;
|
||||||
|
const notificationsGranted = snapshot?.notifications.state === "granted";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={settingsStyles.section}>
|
<View style={settingsStyles.section}>
|
||||||
@@ -48,6 +51,12 @@ export function DesktopPermissionsSection() {
|
|||||||
onRequest={() => {
|
onRequest={() => {
|
||||||
void requestPermission("notifications");
|
void requestPermission("notifications");
|
||||||
}}
|
}}
|
||||||
|
extraActionLabel="Test"
|
||||||
|
isExtraActionBusy={isSendingTestNotification}
|
||||||
|
isExtraActionDisabled={!notificationsGranted || isBusy}
|
||||||
|
onExtraAction={() => {
|
||||||
|
void sendTestNotification();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<DesktopPermissionRow
|
<DesktopPermissionRow
|
||||||
title="Microphone"
|
title="Microphone"
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import {
|
|||||||
type DesktopPermissionKind,
|
type DesktopPermissionKind,
|
||||||
type DesktopPermissionSnapshot,
|
type DesktopPermissionSnapshot,
|
||||||
} from "@/desktop/permissions/desktop-permissions";
|
} from "@/desktop/permissions/desktop-permissions";
|
||||||
|
import { sendOsNotification } from "@/utils/os-notifications";
|
||||||
|
|
||||||
export interface UseDesktopPermissionsReturn {
|
export interface UseDesktopPermissionsReturn {
|
||||||
isDesktop: boolean;
|
isDesktop: boolean;
|
||||||
snapshot: DesktopPermissionSnapshot | null;
|
snapshot: DesktopPermissionSnapshot | null;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
requestingPermission: DesktopPermissionKind | null;
|
requestingPermission: DesktopPermissionKind | null;
|
||||||
|
isSendingTestNotification: boolean;
|
||||||
refreshPermissions: () => Promise<void>;
|
refreshPermissions: () => Promise<void>;
|
||||||
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
||||||
|
sendTestNotification: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_NOTIFICATION_STATUS = {
|
const EMPTY_NOTIFICATION_STATUS = {
|
||||||
@@ -34,6 +37,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
|||||||
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
|
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -109,6 +113,29 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
|||||||
[isDesktop, refreshPermissions]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
return;
|
return;
|
||||||
@@ -122,7 +149,9 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
|||||||
snapshot,
|
snapshot,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
requestingPermission,
|
requestingPermission,
|
||||||
|
isSendingTestNotification,
|
||||||
refreshPermissions,
|
refreshPermissions,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
|
sendTestNotification,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -2632,12 +2632,12 @@ name = "paseo"
|
|||||||
version = "0.1.30"
|
version = "0.1.30"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"cc",
|
||||||
"dirs",
|
"dirs",
|
||||||
"dunce",
|
"dunce",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"log",
|
"log",
|
||||||
"mac-notification-sys",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ name = "Paseo"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
cc = "1"
|
||||||
tauri-build = { version = "2.5.3", features = [] }
|
tauri-build = { version = "2.5.3", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -41,9 +42,6 @@ tauri-plugin-websocket = "2"
|
|||||||
tokio = { version = "1", features = ["net", "sync"] }
|
tokio = { version = "1", features = ["net", "sync"] }
|
||||||
tokio-tungstenite = "0.24"
|
tokio-tungstenite = "0.24"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
|
||||||
mac-notification-sys = "0.6"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
fn main() {
|
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()
|
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::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::{AppHandle, Wry};
|
||||||
|
|
||||||
pub const DESKTOP_NOTIFICATION_CLICK_EVENT: &str = "desktop-notification-click";
|
pub const DESKTOP_NOTIFICATION_CLICK_EVENT: &str = "desktop-notification-click";
|
||||||
|
|
||||||
@@ -22,70 +22,240 @@ struct DesktopNotificationClickPayload {
|
|||||||
mod platform {
|
mod platform {
|
||||||
use super::{
|
use super::{
|
||||||
AppHandle, DesktopNotificationClickPayload, DesktopNotificationInput,
|
AppHandle, DesktopNotificationClickPayload, DesktopNotificationInput,
|
||||||
DESKTOP_NOTIFICATION_CLICK_EVENT,
|
DESKTOP_NOTIFICATION_CLICK_EVENT, Value, Wry,
|
||||||
};
|
};
|
||||||
use std::sync::OnceLock;
|
use std::ffi::{CStr, CString};
|
||||||
use tauri::{async_runtime, Emitter, Runtime};
|
use std::os::raw::c_char;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
const MACOS_NOTIFICATION_SENDER_BUNDLE_ID: &str = "dev.paseo.desktop";
|
#[link(name = "paseo_notifications", kind = "static")]
|
||||||
static MACOS_NOTIFICATION_SENDER_INIT: OnceLock<Result<(), String>> = OnceLock::new();
|
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> {
|
static MACOS_NOTIFICATION_APP: OnceLock<Mutex<Option<AppHandle<Wry>>>> = OnceLock::new();
|
||||||
MACOS_NOTIFICATION_SENDER_INIT
|
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(|| {
|
.get_or_init(|| {
|
||||||
mac_notification_sys::set_application(MACOS_NOTIFICATION_SENDER_BUNDLE_ID)
|
let mut error_ptr: *mut c_char = std::ptr::null_mut();
|
||||||
.map_err(|error| {
|
let ok = unsafe {
|
||||||
format!(
|
paseo_notifications_initialize(handle_notification_click, &mut error_ptr)
|
||||||
"Failed to initialize macOS notification sender for {}: {error}",
|
};
|
||||||
MACOS_NOTIFICATION_SENDER_BUNDLE_ID
|
if ok {
|
||||||
)
|
Ok(())
|
||||||
})
|
} else {
|
||||||
|
Err(read_owned_error_message(error_ptr))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_notification<R: Runtime>(
|
pub fn send_notification(
|
||||||
app: AppHandle<R>,
|
app: AppHandle<Wry>,
|
||||||
input: DesktopNotificationInput,
|
input: DesktopNotificationInput,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let _task = async_runtime::spawn_blocking(move || {
|
{
|
||||||
if let Err(error) = ensure_notification_sender() {
|
let mut guard = app_slot()
|
||||||
log::warn!("{error}");
|
.lock()
|
||||||
return;
|
.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();
|
#[test]
|
||||||
notification
|
#[ignore = "requires a running Cocoa app main queue; verify through the desktop app"]
|
||||||
.title(&input.title)
|
fn native_notification_send_returns_without_waiting_for_click() {
|
||||||
.message(input.body.as_deref().unwrap_or(""))
|
let mut error_ptr: *mut c_char = std::ptr::null_mut();
|
||||||
.wait_for_click(true);
|
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() {
|
let title = CString::new("Paseo notification smoke test").expect("valid title");
|
||||||
Ok(mac_notification_sys::NotificationResponse::Click) => {
|
let started_at = Instant::now();
|
||||||
let payload = DesktopNotificationClickPayload { data: input.data };
|
let sent = unsafe {
|
||||||
if let Err(error) = app.emit(DESKTOP_NOTIFICATION_CLICK_EVENT, payload) {
|
paseo_notifications_send(
|
||||||
log::warn!("failed to emit desktop notification click event: {error}");
|
title.as_ptr(),
|
||||||
}
|
std::ptr::null(),
|
||||||
}
|
std::ptr::null(),
|
||||||
Ok(_) => {}
|
&mut error_ptr,
|
||||||
Err(error) => {
|
)
|
||||||
log::warn!("failed to send macOS notification: {error}");
|
};
|
||||||
}
|
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"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
mod platform {
|
mod platform {
|
||||||
use super::{AppHandle, DesktopNotificationInput};
|
use super::{AppHandle, DesktopNotificationInput, Wry};
|
||||||
use tauri::Runtime;
|
|
||||||
use tauri_plugin_notification::NotificationExt;
|
use tauri_plugin_notification::NotificationExt;
|
||||||
|
|
||||||
pub fn send_notification<R: Runtime>(
|
pub fn send_notification(
|
||||||
app: AppHandle<R>,
|
app: AppHandle<Wry>,
|
||||||
input: DesktopNotificationInput,
|
input: DesktopNotificationInput,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut notification = app.notification().builder().title(&input.title);
|
let mut notification = app.notification().builder().title(&input.title);
|
||||||
@@ -102,8 +272,8 @@ mod platform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn send_desktop_notification<R: Runtime>(
|
pub async fn send_desktop_notification(
|
||||||
app: AppHandle<R>,
|
app: AppHandle<Wry>,
|
||||||
input: DesktopNotificationInput,
|
input: DesktopNotificationInput,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
platform::send_notification(app, input)
|
platform::send_notification(app, input)
|
||||||
|
|||||||
Reference in New Issue
Block a user