Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
7.4 KiB
Rust
199 lines
7.4 KiB
Rust
//! System notifications as Windows toast notifications.
|
|
|
|
use std::cell::RefCell;
|
|
use std::collections::{HashMap, hash_map::DefaultHasher};
|
|
use std::hash::{Hash as _, Hasher as _};
|
|
use std::rc::Rc;
|
|
|
|
use futures::StreamExt as _;
|
|
use futures::channel::mpsc;
|
|
use gpui::{
|
|
ForegroundExecutor, SharedString, SystemNotification, SystemNotificationResponse, Task,
|
|
};
|
|
use windows::Data::Xml::Dom::XmlDocument;
|
|
use windows::Foundation::TypedEventHandler;
|
|
use windows::UI::Notifications::{
|
|
ToastActivatedEventArgs, ToastNotification, ToastNotificationManager, ToastNotifier,
|
|
};
|
|
use windows::core::{IInspectable, Interface as _, h};
|
|
|
|
type ResponseCallback = Rc<RefCell<Option<Box<dyn FnMut(SystemNotificationResponse)>>>>;
|
|
|
|
pub(crate) struct SystemNotificationState {
|
|
notifier: Option<ToastNotifier>,
|
|
active_toasts: HashMap<SharedString, ToastNotification>,
|
|
response_sender: mpsc::UnboundedSender<SystemNotificationResponse>,
|
|
response_receiver: Option<mpsc::UnboundedReceiver<SystemNotificationResponse>>,
|
|
callback: ResponseCallback,
|
|
_response_task: Option<Task<()>>,
|
|
}
|
|
|
|
impl SystemNotificationState {
|
|
pub(crate) fn new() -> Self {
|
|
let (response_sender, response_receiver) = mpsc::unbounded();
|
|
Self {
|
|
notifier: None,
|
|
active_toasts: HashMap::new(),
|
|
response_sender,
|
|
response_receiver: Some(response_receiver),
|
|
callback: Rc::new(RefCell::new(None)),
|
|
_response_task: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn show(
|
|
&mut self,
|
|
has_package_identity: bool,
|
|
app_identity: Option<(&str, &str)>,
|
|
notification: SystemNotification,
|
|
) -> windows::core::Result<()> {
|
|
let Some(notifier) = self.notifier(has_package_identity, app_identity)? else {
|
|
return Ok(());
|
|
};
|
|
|
|
let document = toast_document(¬ification)?;
|
|
let toast = ToastNotification::CreateToastNotification(&document)?;
|
|
// Windows caps toast tags at 64 characters (post-Creators Update),
|
|
// so hash the arbitrary GPUI tag down to a fixed-width value.
|
|
let tag = {
|
|
let mut hasher = DefaultHasher::new();
|
|
notification.tag.hash(&mut hasher);
|
|
format!("{:016x}", hasher.finish())
|
|
};
|
|
toast.SetTag(&tag.into())?;
|
|
|
|
let sender = self.response_sender.clone();
|
|
let response_tag = notification.tag.clone();
|
|
toast.Activated(&TypedEventHandler::<ToastNotification, IInspectable>::new(
|
|
move |_sender, arguments| {
|
|
let action_id = arguments
|
|
.as_ref()
|
|
.and_then(|arguments| arguments.cast::<ToastActivatedEventArgs>().ok())
|
|
.and_then(|arguments| arguments.Arguments().ok())
|
|
.filter(|arguments| !arguments.is_empty())
|
|
.map(|arguments| SharedString::from(arguments.to_string()));
|
|
sender
|
|
.unbounded_send(SystemNotificationResponse {
|
|
tag: response_tag.clone(),
|
|
action_id,
|
|
})
|
|
.ok();
|
|
Ok(())
|
|
},
|
|
))?;
|
|
|
|
if let Some(previous) = self.active_toasts.remove(¬ification.tag) {
|
|
notifier.Hide(&previous)?;
|
|
}
|
|
notifier.Show(&toast)?;
|
|
self.active_toasts.insert(notification.tag, toast);
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn dismiss(&mut self, tag: &str) {
|
|
let Some(toast) = self.active_toasts.remove(tag) else {
|
|
return;
|
|
};
|
|
let Some(notifier) = &self.notifier else {
|
|
return;
|
|
};
|
|
if let Err(error) = notifier.Hide(&toast) {
|
|
log::warn!("failed to dismiss system notification: {error}");
|
|
}
|
|
}
|
|
|
|
pub(crate) fn on_response(
|
|
&mut self,
|
|
executor: &ForegroundExecutor,
|
|
callback: Box<dyn FnMut(SystemNotificationResponse)>,
|
|
) {
|
|
*self.callback.borrow_mut() = Some(callback);
|
|
|
|
if let Some(mut receiver) = self.response_receiver.take() {
|
|
let callback = self.callback.clone();
|
|
self._response_task = Some(executor.spawn(async move {
|
|
while let Some(response) = receiver.next().await {
|
|
// Take the callback out for the call: it may re-enter the
|
|
// platform (e.g. to dismiss the notification it was told
|
|
// about) or replace itself.
|
|
let taken = callback.borrow_mut().take();
|
|
if let Some(mut taken) = taken {
|
|
taken(response);
|
|
callback.borrow_mut().get_or_insert(taken);
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
fn notifier(
|
|
&mut self,
|
|
has_package_identity: bool,
|
|
app_identity: Option<(&str, &str)>,
|
|
) -> windows::core::Result<Option<ToastNotifier>> {
|
|
if let Some(notifier) = &self.notifier {
|
|
return Ok(Some(notifier.clone()));
|
|
}
|
|
|
|
let notifier = if has_package_identity {
|
|
ToastNotificationManager::CreateToastNotifier()?
|
|
} else {
|
|
let Some((app_identifier, app_name)) = app_identity else {
|
|
log::warn!(
|
|
"cannot show a system notification without an app identity; \
|
|
call `App::set_app_identity` during startup"
|
|
);
|
|
return Ok(None);
|
|
};
|
|
register_app_user_model_id(app_identifier, app_name);
|
|
ToastNotificationManager::CreateToastNotifierWithId(&app_identifier.into())?
|
|
};
|
|
|
|
self.notifier = Some(notifier.clone());
|
|
Ok(Some(notifier))
|
|
}
|
|
}
|
|
|
|
fn toast_document(notification: &SystemNotification) -> windows::core::Result<XmlDocument> {
|
|
let document = XmlDocument::new()?;
|
|
let toast = document.CreateElement(h!("toast"))?;
|
|
document.AppendChild(&toast)?;
|
|
|
|
let visual = document.CreateElement(h!("visual"))?;
|
|
toast.AppendChild(&visual)?;
|
|
let binding = document.CreateElement(h!("binding"))?;
|
|
binding.SetAttribute(h!("template"), h!("ToastGeneric"))?;
|
|
visual.AppendChild(&binding)?;
|
|
for text in [¬ification.title, ¬ification.body] {
|
|
let element = document.CreateElement(h!("text"))?;
|
|
element.SetInnerText(&text.as_ref().into())?;
|
|
binding.AppendChild(&element)?;
|
|
}
|
|
|
|
if !notification.actions.is_empty() {
|
|
let actions = document.CreateElement(h!("actions"))?;
|
|
toast.AppendChild(&actions)?;
|
|
for action in ¬ification.actions {
|
|
let action_element = document.CreateElement(h!("action"))?;
|
|
action_element.SetAttribute(h!("content"), &action.label.as_ref().into())?;
|
|
action_element.SetAttribute(h!("arguments"), &action.id.as_ref().into())?;
|
|
actions.AppendChild(&action_element)?;
|
|
}
|
|
}
|
|
|
|
let audio = document.CreateElement(h!("audio"))?;
|
|
audio.SetAttribute(h!("silent"), h!("true"))?;
|
|
toast.AppendChild(&audio)?;
|
|
Ok(document)
|
|
}
|
|
|
|
/// Registers the app's AUMID so toasts display correctly for an unpackaged app without a Start Menu shortcut.
|
|
fn register_app_user_model_id(app_identifier: &str, app_name: &str) {
|
|
let result = windows_registry::CURRENT_USER
|
|
.create(format!(r"Software\Classes\AppUserModelId\{app_identifier}"))
|
|
.and_then(|key| key.set_string("DisplayName", app_name));
|
|
if let Err(error) = result {
|
|
log::warn!("failed to register AppUserModelID; notifications may not display: {error}");
|
|
}
|
|
}
|