Skip to repository content310 lines · 12.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:44:41.114Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
system_notifications.rs
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(¬ification.title));
169 content.setBody(&NSString::from_str(¬ification.body));
170 if !notification.actions.is_empty() {
171 let category_identifier = self.register_category(¬ification.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(¬ification.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