Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:44:41.114Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

system_notifications.rs

310 lines · 12.4 KB · rust
1//! System notifications through the `UNUserNotificationCenter` API.
2//!
3//! Everything here runs lazily: nothing touches the notification center (and
4//! so nothing can trigger the authorization prompt or the framework's
5//! not-in-a-bundle abort) until the application posts a notification or
6//! registers a response callback.
7
8use std::cell::{Cell, RefCell};
9use std::collections::HashMap;
10use std::rc::Rc;
11
12use block2::RcBlock;
13use futures::StreamExt as _;
14use futures::channel::mpsc;
15use gpui::{
16    ForegroundExecutor, SharedString, SystemNotification, SystemNotificationAction,
17    SystemNotificationResponse, Task,
18};
19use objc2::rc::Retained;
20use objc2::runtime::{Bool, ProtocolObject};
21use objc2::{AnyThread, DefinedClass, define_class, msg_send};
22use objc2_foundation::{NSArray, NSBundle, NSError, NSObject, NSObjectProtocol, NSSet, NSString};
23use objc2_user_notifications::{
24    UNAuthorizationOptions, UNMutableNotificationContent, UNNotification, UNNotificationAction,
25    UNNotificationActionOptions, UNNotificationCategory, UNNotificationCategoryOptions,
26    UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions,
27    UNNotificationRequest, UNNotificationResponse, UNUserNotificationCenter,
28    UNUserNotificationCenterDelegate,
29};
30
31type ResponseCallback = Rc<RefCell<Option<Box<dyn FnMut(SystemNotificationResponse)>>>>;
32
33pub(crate) struct SystemNotificationState {
34    initialized: bool,
35    center: Option<NotificationCenter>,
36    callback: ResponseCallback,
37    _response_task: Option<Task<()>>,
38}
39
40impl SystemNotificationState {
41    pub(crate) fn new() -> Self {
42        Self {
43            initialized: false,
44            center: None,
45            callback: Rc::new(RefCell::new(None)),
46            _response_task: None,
47        }
48    }
49
50    pub(crate) fn show(&mut self, executor: &ForegroundExecutor, notification: SystemNotification) {
51        self.initialize(executor);
52        if let Some(center) = &self.center {
53            center.show(notification);
54        }
55    }
56
57    pub(crate) fn dismiss(&mut self, executor: &ForegroundExecutor, tag: &str) {
58        self.initialize(executor);
59        if let Some(center) = &self.center {
60            center.dismiss(tag);
61        }
62    }
63
64    pub(crate) fn on_response(
65        &mut self,
66        executor: &ForegroundExecutor,
67        callback: Box<dyn FnMut(SystemNotificationResponse)>,
68    ) {
69        self.initialize(executor);
70        *self.callback.borrow_mut() = Some(callback);
71    }
72
73    fn initialize(&mut self, executor: &ForegroundExecutor) {
74        if self.initialized {
75            return;
76        }
77        self.initialized = true;
78
79        let (sender, mut receiver) = mpsc::unbounded();
80        self.center = NotificationCenter::new(sender);
81        if self.center.is_none() {
82            return;
83        }
84
85        // Responses arrive from the delegate on an arbitrary thread; this task
86        // hands them to the registered callback on the main thread.
87        let callback = self.callback.clone();
88        self._response_task = Some(executor.spawn(async move {
89            while let Some(response) = receiver.next().await {
90                // Take the callback out for the call: it may re-enter the
91                // platform (e.g. to dismiss the notification it was told
92                // about) or replace itself.
93                let taken = callback.borrow_mut().take();
94                if let Some(mut taken) = taken {
95                    taken(response);
96                    callback.borrow_mut().get_or_insert(taken);
97                }
98            }
99        }));
100    }
101}
102
103struct NotificationCenter {
104    center: Retained<UNUserNotificationCenter>,
105    /// The center's `delegate` property is weak; keeping the delegate retained
106    /// here keeps response handling alive for the app's lifetime.
107    _delegate: Retained<NotificationResponseDelegate>,
108    /// The union of every action set registered so far. Categories are shared
109    /// by notifications with identical actions because macOS replaces the
110    /// entire registered set whenever a category is added.
111    categories: RefCell<
112        HashMap<Vec<SystemNotificationAction>, (SharedString, Retained<UNNotificationCategory>)>,
113    >,
114    authorization_requested: Cell<bool>,
115}
116
117impl NotificationCenter {
118    fn new(sender: mpsc::UnboundedSender<SystemNotificationResponse>) -> Option<Self> {
119        // `UNUserNotificationCenter` raises `NSInternalInconsistencyException`
120        // ("bundleProxyForCurrentProcess is nil"), aborting the process, when
121        // the binary isn't part of an app bundle — which is how dev builds run
122        // via `cargo run`. A bundle identifier is only present when launched
123        // from a real `.app`, so use it as the guard.
124        if NSBundle::mainBundle().bundleIdentifier().is_none() {
125            log::info!("system notifications disabled: not running from an app bundle");
126            return None;
127        }
128
129        let center = UNUserNotificationCenter::currentNotificationCenter();
130        let delegate = NotificationResponseDelegate::new(sender);
131        center.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
132
133        Some(Self {
134            center,
135            _delegate: delegate,
136            categories: RefCell::new(HashMap::new()),
137            authorization_requested: Cell::new(false),
138        })
139    }
140
141    fn request_authorization(&self) {
142        if self.authorization_requested.replace(true) {
143            return;
144        }
145        let completion = RcBlock::new(|granted: Bool, error: *mut NSError| {
146            // SAFETY: when non-null, `error` is a valid `NSError` for the
147            // duration of the callback.
148            if let Some(error) = unsafe { error.as_ref() } {
149                log::warn!(
150                    "system notification authorization failed: {}",
151                    error.localizedDescription()
152                );
153            } else if !granted.as_bool() {
154                log::info!("system notification authorization denied");
155            }
156        });
157        self.center
158            .requestAuthorizationWithOptions_completionHandler(
159                UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound,
160                &completion,
161            );
162    }
163
164    fn show(&self, notification: SystemNotification) {
165        self.request_authorization();
166
167        let content = UNMutableNotificationContent::new();
168        content.setTitle(&NSString::from_str(&notification.title));
169        content.setBody(&NSString::from_str(&notification.body));
170        if !notification.actions.is_empty() {
171            let category_identifier = self.register_category(&notification.actions);
172            content.setCategoryIdentifier(&NSString::from_str(&category_identifier));
173        }
174
175        // A nil trigger delivers immediately. Reusing the tag as the request
176        // identifier makes a newer notification for the same tag replace the
177        // older one.
178        let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
179            &NSString::from_str(&notification.tag),
180            &content,
181            None,
182        );
183        let completion = RcBlock::new(|error: *mut NSError| {
184            // SAFETY: when non-null, `error` is a valid `NSError` for the
185            // duration of the callback.
186            if let Some(error) = unsafe { error.as_ref() } {
187                log::warn!(
188                    "failed to deliver system notification: {}",
189                    error.localizedDescription()
190                );
191            }
192        });
193        self.center
194            .addNotificationRequest_withCompletionHandler(&request, Some(&completion));
195    }
196
197    fn register_category(&self, actions: &[SystemNotificationAction]) -> SharedString {
198        let mut categories = self.categories.borrow_mut();
199        if let Some((identifier, _category)) = categories.get(actions) {
200            return identifier.clone();
201        }
202
203        let identifier =
204            SharedString::from(format!("gpui-system-notification-{}", categories.len()));
205        let platform_actions: Vec<Retained<UNNotificationAction>> = actions
206            .iter()
207            .map(|action| {
208                UNNotificationAction::actionWithIdentifier_title_options(
209                    &NSString::from_str(&action.id),
210                    &NSString::from_str(&action.label),
211                    UNNotificationActionOptions::empty(),
212                )
213            })
214            .collect();
215        let category =
216            UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options(
217                &NSString::from_str(&identifier),
218                &NSArray::from_retained_slice(&platform_actions),
219                &NSArray::new(),
220                UNNotificationCategoryOptions::empty(),
221            );
222
223        categories.insert(actions.to_vec(), (identifier.clone(), category));
224        let all: Vec<Retained<UNNotificationCategory>> = categories
225            .values()
226            .map(|(_identifier, category)| category.clone())
227            .collect();
228        self.center
229            .setNotificationCategories(&NSSet::from_retained_slice(&all));
230        identifier
231    }
232
233    fn dismiss(&self, tag: &str) {
234        let identifiers = NSArray::from_retained_slice(&[NSString::from_str(tag)]);
235        self.center
236            .removePendingNotificationRequestsWithIdentifiers(&identifiers);
237        self.center
238            .removeDeliveredNotificationsWithIdentifiers(&identifiers);
239    }
240}
241
242struct DelegateIvars {
243    sender: mpsc::UnboundedSender<SystemNotificationResponse>,
244}
245
246define_class!(
247    // SAFETY: `NSObject` has no subclassing requirements and
248    // `NotificationResponseDelegate` does not implement `Drop`.
249    #[unsafe(super(NSObject))]
250    #[ivars = DelegateIvars]
251    struct NotificationResponseDelegate;
252
253    unsafe impl NSObjectProtocol for NotificationResponseDelegate {}
254
255    unsafe impl UNUserNotificationCenterDelegate for NotificationResponseDelegate {
256        // Called when the user activates a delivered notification, possibly on
257        // a non-main thread; the channel hop hands the response to the
258        // foreground task holding the receiver.
259        #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))]
260        fn did_receive_notification_response(
261            &self,
262            _center: &UNUserNotificationCenter,
263            response: &UNNotificationResponse,
264            completion_handler: &block2::DynBlock<dyn Fn()>,
265        ) {
266            let tag = response.notification().request().identifier().to_string();
267            let action = response.actionIdentifier();
268            // The other well-known identifier, `UNNotificationDismissActionIdentifier`,
269            // is only delivered for categories opting into dismiss callbacks,
270            // which we never request.
271            let action_id = if &*action == unsafe { UNNotificationDefaultActionIdentifier } {
272                None
273            } else {
274                Some(SharedString::from(action.to_string()))
275            };
276            self.ivars()
277                .sender
278                .unbounded_send(SystemNotificationResponse {
279                    tag: SharedString::from(tag),
280                    action_id,
281                })
282                .ok();
283            completion_handler.call(());
284        }
285
286        // Without this, macOS suppresses banners while the app is frontmost.
287        // Posting is the application's decision; whether the app is focused is
288        // its criterion to apply, not the platform's.
289        #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))]
290        fn will_present_notification(
291            &self,
292            _center: &UNUserNotificationCenter,
293            _notification: &UNNotification,
294            completion_handler: &block2::DynBlock<dyn Fn(UNNotificationPresentationOptions)>,
295        ) {
296            completion_handler
297                .call((UNNotificationPresentationOptions::Banner
298                    | UNNotificationPresentationOptions::List,));
299        }
300    }
301);
302
303impl NotificationResponseDelegate {
304    fn new(sender: mpsc::UnboundedSender<SystemNotificationResponse>) -> Retained<Self> {
305        let this = Self::alloc().set_ivars(DelegateIvars { sender });
306        // SAFETY: `NSObject`'s `init` is its designated initializer.
307        unsafe { msg_send![super(this), init] }
308    }
309}
310
Served at tenant.openagents/omega Member data and write actions are omitted.