Skip to main content

rio_notifier/
lib.rs

1/// Request notification authorization from the OS.
2/// On macOS this triggers the permission prompt on first call.
3/// No-op on other platforms.
4pub fn request_authorization() {
5    #[cfg(target_os = "macos")]
6    platform::request_authorization();
7}
8
9/// Send a desktop notification using the platform's native API.
10///
11/// - **macOS**: `UNUserNotificationCenter` (requires app bundle with identifier).
12/// - **Linux**: D-Bus `org.freedesktop.Notifications`.
13/// - **Windows**: Toast notifications via `windows` crate.
14///
15/// Spawns a background thread so the caller is never blocked.
16pub fn send_notification(title: &str, body: &str) {
17    let title = if title.is_empty() {
18        "Rio".to_string()
19    } else {
20        title.to_string()
21    };
22    let body = body.to_string();
23
24    std::thread::spawn(move || {
25        platform::notify(&title, &body);
26    });
27}
28
29#[cfg(target_os = "macos")]
30mod platform {
31    use block2::RcBlock;
32    use objc::runtime::Object;
33    use objc::{class, msg_send, sel, sel_impl};
34    use objc2::runtime::Bool;
35    use objc2_foundation::{NSError, NSString};
36    use objc2_user_notifications::{
37        UNAuthorizationOptions, UNMutableNotificationContent, UNNotificationRequest,
38        UNUserNotificationCenter,
39    };
40    use std::sync::Once;
41
42    pub(crate) fn request_authorization() {
43        static INIT: Once = Once::new();
44        INIT.call_once(|| unsafe {
45            let bundle: *mut Object = msg_send![class!(NSBundle), mainBundle];
46            if bundle.is_null() {
47                return;
48            }
49            let bundle_id: *mut Object = msg_send![bundle, bundleIdentifier];
50            if bundle_id.is_null() {
51                return;
52            }
53
54            let center = UNUserNotificationCenter::currentNotificationCenter();
55            center.requestAuthorizationWithOptions_completionHandler(
56                UNAuthorizationOptions::UNAuthorizationOptionAlert
57                    | UNAuthorizationOptions::UNAuthorizationOptionSound,
58                &RcBlock::new(|_ok: Bool, _err: *mut NSError| {}),
59            );
60        });
61    }
62
63    pub fn notify(title: &str, body: &str) {
64        unsafe {
65            // UNUserNotificationCenter crashes if the app has no bundle
66            // identifier (e.g. cargo run). Guard like Kitty does.
67            let bundle: *mut Object = msg_send![class!(NSBundle), mainBundle];
68            if bundle.is_null() {
69                return;
70            }
71            let bundle_id: *mut Object = msg_send![bundle, bundleIdentifier];
72            if bundle_id.is_null() {
73                return;
74            }
75
76            let center = UNUserNotificationCenter::currentNotificationCenter();
77
78            let content = UNMutableNotificationContent::new();
79            content.setTitle(&NSString::from_str(title));
80            content.setBody(&NSString::from_str(body));
81
82            let identifier = NSString::from_str("rio-notification");
83            let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
84                &identifier,
85                &content,
86                None,
87            );
88
89            center.addNotificationRequest_withCompletionHandler(&request, None);
90        }
91    }
92}
93
94#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
95mod platform {
96    use std::collections::HashMap;
97
98    pub fn notify(title: &str, body: &str) {
99        let Ok(connection) = zbus::blocking::Connection::session() else {
100            return;
101        };
102        let Ok(proxy) = zbus::blocking::Proxy::new(
103            &connection,
104            "org.freedesktop.Notifications",
105            "/org/freedesktop/Notifications",
106            "org.freedesktop.Notifications",
107        ) else {
108            return;
109        };
110        let hints: HashMap<&str, zbus::zvariant::Value<'_>> = HashMap::new();
111        let _: Result<u32, _> = proxy.call(
112            "Notify",
113            &(
114                "Rio",          // app_name
115                0u32,           // replaces_id
116                "rio",          // app_icon
117                title,          // summary
118                body,           // body
119                &[] as &[&str], // actions
120                &hints,         // hints
121                -1i32,          // expire_timeout
122            ),
123        );
124    }
125}
126
127#[cfg(target_os = "windows")]
128mod platform {
129    pub fn notify(title: &str, body: &str) {
130        use windows::core::HSTRING;
131        use windows::Data::Xml::Dom::XmlDocument;
132        use windows::UI::Notifications::{ToastNotification, ToastNotificationManager};
133
134        let Ok(xml) = XmlDocument::new() else {
135            return;
136        };
137        let toast_xml = format!(
138            r#"<toast><visual><binding template="ToastGeneric"><text>{}</text><text>{}</text></binding></visual></toast>"#,
139            title, body,
140        );
141        if xml.LoadXml(&HSTRING::from(&toast_xml)).is_err() {
142            return;
143        }
144        let Ok(toast) = ToastNotification::CreateToastNotification(&xml) else {
145            return;
146        };
147        let Ok(notifier) =
148            ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Rio"))
149        else {
150            return;
151        };
152        let _ = notifier.Show(&toast);
153    }
154}