gpui: Add system notification platform APIs (#61189)

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: John Tur <john-tur@outlook.com>
This commit is contained in:
Lukas WirthandJohn Tur authored and GitHub committed 2026-07-17 18:52:23 +00:00
1 parent 31813161f1
commit cd91c84675
20 files changed
+1416 -10

No files matched your search

Generated
+74
View File
@@ -7888,6 +7888,7 @@ dependencies = [
"itertools 0.14.0",
"libc",
"log",
"notify-rust",
"oo7",
"open",
"parking_lot",
@@ -7923,6 +7924,7 @@ dependencies = [
"anyhow",
"async-task",
"block",
"block2 0.6.2",
"cbindgen",
"cocoa 0.26.0",
"collections",
@@ -7950,6 +7952,7 @@ dependencies = [
"objc2 0.6.3",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-user-notifications",
"parking_lot",
"pathfinder_geometry",
"raw-window-handle",
@@ -10679,6 +10682,20 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
"time",
"uuid",
]
[[package]]
name = "mach2"
version = "0.4.3"
@@ -11648,6 +11665,20 @@ dependencies = [
"notify 6.1.1",
]
[[package]]
name = "notify-rust"
version = "4.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
dependencies = [
"futures-lite 2.6.1",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]]
name = "notify-types"
version = "2.1.0"
@@ -12060,6 +12091,16 @@ dependencies = [
"objc2-metal 0.2.2",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-services"
version = "0.3.2"
@@ -12163,6 +12204,19 @@ dependencies = [
"objc2-metal 0.3.2",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"bitflags 2.10.0",
"block2 0.6.2",
"objc2 0.6.3",
"objc2-core-location",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc_exception"
version = "0.1.2"
@@ -18370,6 +18424,17 @@ dependencies = [
"zed_actions",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"thiserror 2.0.17",
"windows 0.61.3",
"windows-version",
]
[[package]]
name = "telemetry"
version = "0.1.0"
@@ -21986,6 +22051,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
+7 -1
View File
@@ -555,6 +555,7 @@ aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] }
backtrace = "0.3"
base64 = "0.22"
bitflags = "2.6.0"
block2 = "0.6"
brotli = "8.0.2"
bytes = "1.0"
cargo_metadata = "0.19"
@@ -669,6 +670,7 @@ moka = { version = "0.12.10", features = ["sync"] }
nanoid = "0.4"
nbformat = "1.2.0"
nix = "0.29"
notify-rust = "4"
nucleo = "0.5"
num-format = "0.4.4"
objc = "0.2"
@@ -700,6 +702,7 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [
"NSProcessInfo",
"NSRange",
"NSRunLoop",
"NSSet",
"NSString",
"NSURL",
"NSUndoManager",
@@ -707,6 +710,7 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [
"objc2-core-foundation",
"std",
] }
objc2-user-notifications = "0.3"
open = "5.0.0"
ordered-float = "2.1.1"
palette = { version = "0.7.5", default-features = false, features = ["std"] }
@@ -880,15 +884,16 @@ yawc = "0.2.5"
zeroize = "1.8"
zstd = "0.11"
[workspace.dependencies.windows]
version = "0.61"
features = [
"Data_Xml_Dom",
"Foundation_Numerics",
"Globalization_DateTimeFormatting",
"Storage_Search",
"Storage_Streams",
"System_Threading",
"UI_Notifications",
"UI_ViewManagement",
"Wdk_System_SystemServices",
"Win32_Foundation",
@@ -910,6 +915,7 @@ features = [
"Win32_Security_Credentials",
"Win32_Security_Cryptography",
"Win32_Storage_FileSystem",
"Win32_Storage_Packaging_Appx",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Console",
+5
View File
@@ -211,6 +211,11 @@ path = "examples/pattern.rs"
name = "set_menus"
path = "examples/set_menus.rs"
[[example]]
name = "system_notifications"
path = "examples/system_notifications.rs"
[[example]]
name = "shadow"
path = "examples/shadow.rs"
+1
View File
@@ -54,6 +54,7 @@ cargo run -p gpui --example hello_world
- `move_entity_between_windows` shows moving an entity between windows.
- `on_window_close_quit` demonstrates quitting when a window closes.
- `set_menus` shows application menu setup.
- `system_notifications` demonstrates posting, replacing, dismissing, and responding to operating-system notifications.
- `window` demonstrates creating normal, dialog, popup, and floating windows.
- `window_positioning` demonstrates window bounds and placement.
- `window_shadow` demonstrates window shadow styling.
@@ -0,0 +1,149 @@
#![cfg_attr(target_family = "wasm", no_main)]
//! Demonstrates posting, replacing, dismissing, and responding to system notifications.
use gpui::{
App, Bounds, Context, Div, SharedString, Stateful, SystemNotification,
SystemNotificationAction, SystemNotificationResponse, Window, WindowBounds, WindowOptions, div,
prelude::*, px, rgb, size,
};
use gpui_platform::application;
const NOTIFICATION_TAG: &str = "gpui-system-notification-example";
struct SystemNotificationExample {
revision: usize,
status: SharedString,
}
impl Render for SystemNotificationExample {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.size_full()
.gap_4()
.p_8()
.bg(rgb(0x18181b))
.text_color(rgb(0xf4f4f5))
.child(div().text_2xl().child("GPUI system notifications"))
.child(div().text_sm().text_color(rgb(0xa1a1aa)).child(
"Post repeatedly to replace the notification with the same tag. Click the \
notification body or an action button to send a response back to GPUI.",
))
.child(
div()
.flex()
.gap_3()
.child(button("show", "Show or replace").on_click(cx.listener(
|this, _, _, cx| {
this.revision += 1;
let revision = this.revision;
cx.show_system_notification(SystemNotification {
tag: NOTIFICATION_TAG.into(),
title: format!("Example notification {revision}").into(),
body: "This notification was posted by the GPUI example.".into(),
actions: vec![
SystemNotificationAction {
id: "open".into(),
label: "Open".into(),
},
SystemNotificationAction {
id: "snooze".into(),
label: "Snooze".into(),
},
],
});
this.status = format!("Posted notification revision {revision}").into();
cx.notify();
},
)))
.child(
button("dismiss", "Dismiss").on_click(cx.listener(|this, _, _, cx| {
cx.dismiss_system_notification(NOTIFICATION_TAG);
this.status = "Dismissed the notification".into();
cx.notify();
})),
),
)
.child(
div()
.mt_2()
.p_4()
.rounded_md()
.bg(rgb(0x27272a))
.child(self.status.clone()),
)
.when(cfg!(target_os = "macos"), |this| {
this.child(div().mt_2().text_xs().text_color(rgb(0x71717a)).child(
"macOS only delivers notifications when this example runs from an app bundle.",
))
})
}
}
fn button(id: &'static str, label: &'static str) -> Stateful<Div> {
div()
.id(id)
.px_4()
.py_2()
.rounded_md()
.bg(rgb(0x3f3f46))
.hover(|style| style.bg(rgb(0x52525b)))
.active(|style| style.bg(rgb(0x71717a)))
.cursor_pointer()
.child(label)
}
fn run_example() {
application().run(|cx: &mut App| {
cx.set_app_identity("dev.zed.gpui.system-notifications", "GPUI Notifications");
let view = cx.new(|_| SystemNotificationExample {
revision: 0,
status: "No notification posted yet".into(),
});
cx.on_system_notification_response({
let view = view.clone();
move |response, cx| {
let SystemNotificationResponse { tag, action_id } = response;
view.update(cx, |this, cx| {
this.status = match action_id {
Some(action_id) => {
format!("Received action '{action_id}' for tag '{tag}'").into()
}
None => format!("Notification body clicked for tag '{tag}'").into(),
};
cx.notify();
});
}
});
let bounds = Bounds::centered(None, size(px(560.), px(360.)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(gpui::TitlebarOptions {
title: Some("System Notifications Example".into()),
..Default::default()
}),
..Default::default()
},
move |_, _| view,
)
.expect("failed to open system notifications example window");
cx.activate(true);
});
}
#[cfg(not(target_family = "wasm"))]
fn main() {
run_example();
}
#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
gpui_platform::web_init();
run_example();
}
+47 -3
View File
@@ -50,9 +50,9 @@ use crate::{
PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout,
PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, PromptHandle,
PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource,
SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextRenderingMode, TextSystem,
ThermalState, Window, WindowAppearance, WindowButtonLayout, WindowHandle, WindowId,
WindowInvalidator,
SharedString, SubscriberSet, Subscription, SvgRenderer, SystemNotification,
SystemNotificationResponse, Task, TextRenderingMode, TextSystem, ThermalState, Window,
WindowAppearance, WindowButtonLayout, WindowHandle, WindowId, WindowInvalidator,
colors::{Colors, GlobalColors},
hash, init_app_menus,
};
@@ -1419,6 +1419,50 @@ impl App {
self.platform.register_url_scheme(scheme)
}
/// Sets the application's process-wide identity and user-visible name.
///
/// The identifier is used for platform identity mechanisms such as the
/// Windows AppUserModelID. The name is used wherever the operating system
/// presents the application to the user. Call this once, early in startup,
/// before opening windows or posting notifications.
pub fn set_app_identity(&self, identifier: &str, name: &str) {
self.platform.set_app_identity(identifier, name);
}
/// Posts a notification to the operating system's notification center.
///
/// Posting a notification whose [`SystemNotification::tag`] matches an
/// earlier one replaces that notification where the platform supports it.
/// No-op on platforms without notification support, or when delivery is
/// unavailable (e.g. authorization was denied).
pub fn show_system_notification(&self, notification: SystemNotification) {
self.platform.show_system_notification(notification);
}
/// Removes the delivered or pending notification with this tag.
///
/// Best-effort: some platforms cannot retract a notification once shown,
/// in which case it ages out of the notification center on its own.
pub fn dismiss_system_notification(&self, tag: &str) {
self.platform.dismiss_system_notification(tag);
}
/// Registers the handler invoked when the user activates a system
/// notification, either by clicking its body or one of its action
/// buttons. Subsequent registrations replace the handler.
pub fn on_system_notification_response<F>(&self, mut callback: F)
where
F: 'static + FnMut(SystemNotificationResponse, &mut App),
{
let this = self.this.clone();
self.platform
.on_system_notification_response(Box::new(move |response| {
if let Some(app) = this.upgrade() {
callback(response, &mut app.borrow_mut());
}
}));
}
/// Returns the full pathname of the current app bundle.
///
/// Returns an error if the app is not being run from a bundle.
+176 -4
View File
@@ -3,9 +3,10 @@ use crate::{
BackgroundExecutor, BorrowAppContext, Bounds, Capslock, ClipboardItem, DrawPhase, Drawable,
Element, Empty, EntityId, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke,
Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform,
TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds,
WindowHandle, WindowOptions, app::GpuiMode, window::ElementArenaScope,
Pixels, Platform, Point, Render, Result, SharedString, Size, SystemNotification,
SystemNotificationResponse, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource,
TestWindow, TextSystem, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions,
app::GpuiMode, window::ElementArenaScope,
};
use anyhow::{anyhow, bail};
use futures::{Stream, StreamExt, channel::oneshot};
@@ -371,6 +372,32 @@ impl TestAppContext {
self.test_platform.opened_url.borrow().clone()
}
/// Returns the application identity configured during this test.
pub fn app_identity(&self) -> Option<(SharedString, SharedString)> {
self.test_platform.app_identity()
}
/// Returns all system notifications shown during this test, in order.
pub fn shown_system_notifications(&self) -> Vec<SystemNotification> {
self.test_platform.shown_system_notifications()
}
/// Returns the system notifications currently delivered by the test platform.
pub fn delivered_system_notifications(&self) -> Vec<SystemNotification> {
self.test_platform.delivered_system_notifications()
}
/// Returns the tags of all system notifications dismissed during this test, in order.
pub fn dismissed_system_notifications(&self) -> Vec<SharedString> {
self.test_platform.dismissed_system_notifications()
}
/// Simulates the user activating a system notification.
pub fn simulate_system_notification_response(&self, response: SystemNotificationResponse) {
self.test_platform
.simulate_system_notification_response(response);
}
/// Simulates the user resizing the window to the new size.
pub fn simulate_window_resize(&self, window_handle: AnyWindowHandle, size: Size<Pixels>) {
self.test_window(window_handle).simulate_resize(size);
@@ -1115,8 +1142,153 @@ impl AnyWindowHandle {
#[cfg(test)]
mod tests {
use crate::{PathPromptOptions, TestAppContext};
use crate::{
PathPromptOptions, SystemNotification, SystemNotificationAction,
SystemNotificationResponse, TestAppContext,
};
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
#[gpui::test]
async fn test_system_notifications_require_identity_and_replace_matching_tags(
cx: &mut TestAppContext,
) {
cx.update(|cx| {
cx.show_system_notification(SystemNotification {
tag: "thread-1".into(),
title: "Task started".into(),
body: "Running tests".into(),
actions: Vec::new(),
});
});
assert!(cx.shown_system_notifications().is_empty());
assert!(cx.delivered_system_notifications().is_empty());
cx.update(|cx| {
cx.set_app_identity("com.example.tasks", "Tasks");
cx.show_system_notification(SystemNotification {
tag: "thread-1".into(),
title: "Task started".into(),
body: "Running tests".into(),
actions: Vec::new(),
});
cx.show_system_notification(SystemNotification {
tag: "thread-1".into(),
title: "Task finished".into(),
body: "All tests passed".into(),
actions: vec![SystemNotificationAction {
id: "open".into(),
label: "Open".into(),
}],
});
});
assert_eq!(
cx.app_identity(),
Some(("com.example.tasks".into(), "Tasks".into()))
);
assert_eq!(cx.shown_system_notifications().len(), 2);
assert_eq!(
cx.delivered_system_notifications(),
[SystemNotification {
tag: "thread-1".into(),
title: "Task finished".into(),
body: "All tests passed".into(),
actions: vec![SystemNotificationAction {
id: "open".into(),
label: "Open".into(),
}],
}]
);
cx.update(|cx| cx.dismiss_system_notification("thread-1"));
assert!(cx.delivered_system_notifications().is_empty());
assert_eq!(cx.dismissed_system_notifications(), ["thread-1"]);
}
#[gpui::test]
async fn test_system_notification_body_and_action_responses(cx: &mut TestAppContext) {
let responses = Rc::new(RefCell::new(Vec::new()));
cx.update(|cx| {
cx.on_system_notification_response({
let responses = responses.clone();
move |response, _cx| responses.borrow_mut().push(response)
});
});
cx.simulate_system_notification_response(SystemNotificationResponse {
tag: "thread-1".into(),
action_id: None,
});
cx.simulate_system_notification_response(SystemNotificationResponse {
tag: "thread-1".into(),
action_id: Some("default".into()),
});
assert_eq!(
responses.borrow().as_slice(),
&[
SystemNotificationResponse {
tag: "thread-1".into(),
action_id: None,
},
SystemNotificationResponse {
tag: "thread-1".into(),
action_id: Some("default".into()),
},
]
);
}
#[gpui::test]
async fn test_system_notification_response_handler_can_be_replaced(cx: &mut TestAppContext) {
let first_responses = Rc::new(RefCell::new(Vec::new()));
let second_responses = Rc::new(RefCell::new(Vec::new()));
cx.update(|cx| {
cx.on_system_notification_response({
let first_responses = first_responses.clone();
move |response, _cx| first_responses.borrow_mut().push(response)
});
cx.on_system_notification_response({
let second_responses = second_responses.clone();
move |response, _cx| second_responses.borrow_mut().push(response)
});
});
let response = SystemNotificationResponse {
tag: "thread-1".into(),
action_id: None,
};
cx.simulate_system_notification_response(response.clone());
assert!(first_responses.borrow().is_empty());
assert_eq!(second_responses.borrow().as_slice(), &[response]);
}
#[gpui::test]
async fn test_system_notification_response_handler_can_reenter_app(cx: &mut TestAppContext) {
cx.update(|cx| {
cx.set_app_identity("com.example.tasks", "Tasks");
cx.show_system_notification(SystemNotification {
tag: "thread-1".into(),
title: "Task finished".into(),
body: "All tests passed".into(),
actions: Vec::new(),
});
cx.on_system_notification_response(|response, cx| {
cx.dismiss_system_notification(&response.tag);
});
});
cx.simulate_system_notification_response(SystemNotificationResponse {
tag: "thread-1".into(),
action_id: None,
});
assert!(cx.delivered_system_notifications().is_empty());
assert_eq!(cx.dismissed_system_notifications(), ["thread-1"]);
}
#[gpui::test]
async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) {
+77
View File
@@ -239,6 +239,46 @@ pub trait Platform: 'static {
fn thermal_state(&self) -> ThermalState;
fn on_thermal_state_change(&self, callback: Box<dyn FnMut()>);
/// Sets the application's process-wide identity and user-visible name.
///
/// The identifier is used for platform identity mechanisms such as the
/// Windows AppUserModelID. The name is used wherever the operating system
/// presents the application to the user. Call this once, early in startup,
/// before opening windows or posting notifications.
fn set_app_identity(&self, identifier: &str, name: &str) {
_ = (identifier, name);
}
/// Posts a notification to the operating system's notification center.
///
/// Posting a notification whose [`SystemNotification::tag`] matches an
/// earlier one replaces that notification where the platform supports it.
/// No-op on platforms without notification support, or when delivery is
/// unavailable (e.g. authorization was denied).
fn show_system_notification(&self, notification: SystemNotification) {
_ = notification;
}
/// Removes the delivered or pending notification with this tag.
///
/// Best-effort: some platforms cannot retract a notification once shown,
/// in which case it ages out of the notification center on its own.
fn dismiss_system_notification(&self, tag: &str) {
_ = tag;
}
/// Registers the callback invoked when the user activates a system
/// notification, either by clicking its body or one of its action
/// buttons.
///
/// Implementations must invoke the callback on the main thread.
fn on_system_notification_response(
&self,
callback: Box<dyn FnMut(SystemNotificationResponse)>,
) {
_ = callback;
}
fn compositor_name(&self) -> &'static str {
""
}
@@ -309,6 +349,43 @@ pub trait PlatformDisplay: Debug {
}
}
/// A notification posted to the operating system's notification center,
/// rather than rendered as in-app UI.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SystemNotification {
/// Stable identity for the notification. Posting a new notification with
/// the same tag replaces the previous one where the platform supports it,
/// and responses carry the tag back to the application.
pub tag: SharedString,
/// The notification's headline.
pub title: SharedString,
/// Additional text displayed below the title.
pub body: SharedString,
/// Buttons offered on the notification. Platforms that cannot display
/// action buttons show the notification without them.
pub actions: Vec<SystemNotificationAction>,
}
/// A button offered on a [`SystemNotification`].
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SystemNotificationAction {
/// Identifies the action in [`SystemNotificationResponse::action_id`]
/// when the user presses this button.
pub id: SharedString,
/// The button's user-visible label.
pub label: SharedString,
}
/// The user's activation of a [`SystemNotification`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SystemNotificationResponse {
/// The [`SystemNotification::tag`] of the activated notification.
pub tag: SharedString,
/// The pressed action button's [`SystemNotificationAction::id`], or
/// `None` when the user activated the notification body itself.
pub action_id: Option<SharedString>,
}
/// Thermal state of the system
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThermalState {
+87 -2
View File
@@ -3,8 +3,8 @@ use crate::{
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform,
PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper,
PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams,
size,
SharedString, SourceMetadata, SystemNotification, SystemNotificationResponse, Task,
TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
};
use anyhow::Result;
use collections::VecDeque;
@@ -33,6 +33,7 @@ pub(crate) struct TestPlatform {
pub(crate) prompts: RefCell<TestPrompts>,
screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
pub opened_url: RefCell<Option<String>>,
pub(crate) system_notifications: RefCell<TestSystemNotifications>,
pub text_system: Arc<dyn PlatformTextSystem>,
pub expect_restart: RefCell<Option<oneshot::Sender<Option<PathBuf>>>>,
headless_renderer_factory: Option<Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>>>,
@@ -82,6 +83,15 @@ struct TestPrompt {
tx: oneshot::Sender<usize>,
}
#[derive(Default)]
pub(crate) struct TestSystemNotifications {
pub(crate) app_identity: Option<(SharedString, SharedString)>,
pub(crate) shown: Vec<SystemNotification>,
pub(crate) delivered: Vec<SystemNotification>,
pub(crate) dismissed: Vec<SharedString>,
response_callback: Option<Box<dyn FnMut(SystemNotificationResponse)>>,
}
#[derive(Default)]
pub(crate) struct TestPrompts {
multiple_choice: VecDeque<TestPrompt>,
@@ -134,6 +144,7 @@ impl TestPlatform {
current_find_pasteboard_item: Mutex::new(None),
weak: weak.clone(),
opened_url: Default::default(),
system_notifications: Default::default(),
text_system,
headless_renderer_factory,
})
@@ -258,6 +269,40 @@ impl TestPlatform {
pub(crate) fn did_prompt_for_new_path(&self) -> bool {
!self.prompts.borrow().new_path.is_empty()
}
pub(crate) fn app_identity(&self) -> Option<(SharedString, SharedString)> {
self.system_notifications.borrow().app_identity.clone()
}
pub(crate) fn shown_system_notifications(&self) -> Vec<SystemNotification> {
self.system_notifications.borrow().shown.clone()
}
pub(crate) fn delivered_system_notifications(&self) -> Vec<SystemNotification> {
self.system_notifications.borrow().delivered.clone()
}
pub(crate) fn dismissed_system_notifications(&self) -> Vec<SharedString> {
self.system_notifications.borrow().dismissed.clone()
}
pub(crate) fn simulate_system_notification_response(
&self,
response: SystemNotificationResponse,
) {
let callback = self
.system_notifications
.borrow_mut()
.response_callback
.take();
if let Some(mut callback) = callback {
callback(response);
self.system_notifications
.borrow_mut()
.response_callback
.get_or_insert(callback);
}
}
}
impl Platform for TestPlatform {
@@ -416,6 +461,46 @@ impl Platform for TestPlatform {
fn on_system_wake(&self, _callback: Box<dyn FnMut()>) {}
fn set_app_identity(&self, identifier: &str, name: &str) {
self.system_notifications.borrow_mut().app_identity =
Some((identifier.to_string().into(), name.to_string().into()));
}
fn show_system_notification(&self, notification: SystemNotification) {
let mut system_notifications = self.system_notifications.borrow_mut();
if system_notifications.app_identity.is_none() {
return;
}
let delivered = system_notifications
.delivered
.iter_mut()
.find(|delivered| delivered.tag == notification.tag);
if let Some(delivered) = delivered {
*delivered = notification.clone();
} else {
system_notifications.delivered.push(notification.clone());
}
system_notifications.shown.push(notification);
}
fn dismiss_system_notification(&self, tag: &str) {
let mut system_notifications = self.system_notifications.borrow_mut();
system_notifications
.delivered
.retain(|notification| notification.tag != tag);
system_notifications
.dismissed
.push(SharedString::from(tag.to_string()));
}
fn on_system_notification_response(
&self,
callback: Box<dyn FnMut(SystemNotificationResponse)>,
) {
self.system_notifications.borrow_mut().response_callback = Some(callback);
}
fn set_menus(&self, _menus: Vec<crate::Menu>, _keymap: &Keymap) {}
fn set_dock_menu(&self, _menu: Vec<crate::MenuItem>, _keymap: &Keymap) {}
+1
View File
@@ -81,6 +81,7 @@ oo7 = { version = "0.6", default-features = false, features = [
"native_crypto",
] }
calloop = "0.14.3"
notify-rust.workspace = true
raw-window-handle.workspace = true
# Used in both windowing options
+1
View File
@@ -2,6 +2,7 @@ mod dispatcher;
mod headless;
mod keyboard;
mod platform;
mod system_notifications;
#[cfg(any(feature = "wayland", feature = "x11"))]
mod text_system;
#[cfg(feature = "wayland")]
+33
View File
@@ -123,6 +123,8 @@ pub(crate) struct LinuxCommon {
pub(crate) callbacks: PlatformHandlers,
pub(crate) signal: LoopSignal,
pub(crate) menus: Vec<OwnedMenu>,
app_name: Option<String>,
system_notifications: crate::linux::system_notifications::SystemNotificationState,
#[cfg_attr(
not(all(target_os = "linux", any(feature = "wayland", feature = "x11"))),
allow(dead_code)
@@ -163,6 +165,9 @@ impl LinuxCommon {
callbacks,
signal,
menus: Vec::new(),
app_name: None,
system_notifications: crate::linux::system_notifications::SystemNotificationState::new(
),
wake_sender,
wake_listener_started: false,
};
@@ -556,6 +561,34 @@ impl<P: LinuxClient + 'static> Platform for LinuxPlatform<P> {
});
}
fn set_app_identity(&self, _identifier: &str, name: &str) {
self.inner
.with_common(|common| common.app_name = Some(name.to_string()));
}
fn show_system_notification(&self, notification: gpui::SystemNotification) {
self.inner.with_common(|common| {
common
.system_notifications
.show(common.app_name.as_deref(), notification)
});
}
fn dismiss_system_notification(&self, tag: &str) {
self.inner
.with_common(|common| common.system_notifications.dismiss(tag));
}
fn on_system_notification_response(
&self,
callback: Box<dyn FnMut(gpui::SystemNotificationResponse)>,
) {
self.inner.with_common(|common| {
let executor = common.foreground_executor.clone();
common.system_notifications.on_response(&executor, callback)
});
}
fn on_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>) {
self.inner.with_common(|common| {
common.callbacks.app_menu_action = Some(callback);
@@ -0,0 +1,154 @@
//! System notifications over the XDG notifications D-Bus interface, via
//! `notify-rust`.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use futures::StreamExt as _;
use futures::channel::mpsc;
use gpui::{
ForegroundExecutor, SharedString, SystemNotification, SystemNotificationResponse, Task,
};
/// The XDG action key invoked when the user activates the notification body
/// rather than a specific action button.
const DEFAULT_ACTION: &str = "default";
type ResponseCallback = Rc<RefCell<Option<Box<dyn FnMut(SystemNotificationResponse)>>>>;
pub(crate) struct SystemNotificationState {
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 {
response_sender,
response_receiver: Some(response_receiver),
callback: Rc::new(RefCell::new(None)),
_response_task: None,
}
}
pub(crate) fn show(&self, app_name: Option<&str>, notification: SystemNotification) {
let mut builder = notify_rust::Notification::new();
if let Some(app_name) = app_name {
builder.appname(app_name);
}
builder
.summary(&notification.title)
.body(&notification.body)
.action(DEFAULT_ACTION, DEFAULT_ACTION);
let mut action_ids = HashMap::new();
for (index, action) in notification.actions.iter().enumerate() {
let transport_id = format!("gpui-action-{index}");
builder.action(&transport_id, &action.label);
action_ids.insert(transport_id, action.id.clone());
}
let built = builder.finalize();
let sender = self.response_sender.clone();
let tag = notification.tag.clone();
// `show` connects to the session bus and `wait_for_action` blocks
// until the notification closes, so both run off the UI thread.
let spawn_result = std::thread::Builder::new()
.name("system-notification".to_string())
.spawn(move || match built.show() {
Ok(handle) => handle.wait_for_action(|action| {
let action_id = match action {
// Sent by `notify-rust` when the notification closes
// without being activated.
"__closed" => return,
transport_id => {
let Some(action_id) = response_action_id(transport_id, &action_ids)
else {
log::warn!(
"system notification returned unknown action {transport_id:?}"
);
return;
};
action_id
}
};
sender
.unbounded_send(SystemNotificationResponse { tag, action_id })
.ok();
}),
Err(error) => log::warn!("failed to show system notification: {error}"),
});
if let Err(error) = spawn_result {
log::warn!("failed to spawn system notification thread: {error}");
}
}
pub(crate) fn dismiss(&self, _tag: &str) {
// The XDG notifications protocol only allows closing via the
// server-assigned id on a live handle, which `wait_for_action`
// consumes; stale notifications simply age out of the shade.
}
pub(crate) fn on_response(
&mut self,
executor: &ForegroundExecutor,
callback: Box<dyn FnMut(SystemNotificationResponse)>,
) {
*self.callback.borrow_mut() = Some(callback);
// Responses arrive from per-notification threads; this task hands
// them to the registered callback on the main thread.
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 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 response_action_id(
transport_id: &str,
action_ids: &HashMap<String, SharedString>,
) -> Option<Option<SharedString>> {
if transport_id == DEFAULT_ACTION {
Some(None)
} else {
action_ids.get(transport_id).cloned().map(Some)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn caller_action_ids_do_not_collide_with_transport_action_ids() {
let action_ids = HashMap::from([
("gpui-action-0".to_string(), SharedString::from("default")),
("gpui-action-1".to_string(), SharedString::from("__closed")),
]);
assert_eq!(response_action_id(DEFAULT_ACTION, &action_ids), Some(None));
assert_eq!(
response_action_id("gpui-action-0", &action_ids),
Some(Some("default".into()))
);
assert_eq!(
response_action_id("gpui-action-1", &action_ids),
Some(Some("__closed".into()))
);
assert_eq!(response_action_id("unknown", &action_ids), None);
}
}
+2
View File
@@ -27,6 +27,7 @@ accesskit_macos.workspace = true
anyhow.workspace = true
async-task = "4.7"
block = "0.1"
block2.workspace = true
cocoa.workspace = true
collections.workspace = true
core-foundation.workspace = true
@@ -54,6 +55,7 @@ objc.workspace = true
objc2.workspace = true
objc2-app-kit.workspace = true
objc2-foundation.workspace = true
objc2-user-notifications.workspace = true
parking_lot.workspace = true
pathfinder_geometry = "0.5"
raw-window-handle.workspace = true
+1
View File
@@ -10,6 +10,7 @@ mod display_link;
mod events;
mod keyboard;
mod pasteboard;
mod system_notifications;
#[cfg(feature = "screen-capture")]
mod screen_capture;
+23
View File
@@ -189,6 +189,7 @@ pub(crate) struct MacPlatformState {
keyboard_mapper: Rc<MacKeyboardMapper>,
/// Mirrors `[NSCursor setHiddenUntilMouseMoves:]` state, which AppKit doesn't expose.
cursor_visible: Arc<AtomicBool>,
system_notifications: crate::system_notifications::SystemNotificationState,
}
impl MacPlatform {
@@ -235,6 +236,7 @@ impl MacPlatform {
menus: None,
keyboard_mapper,
cursor_visible: Arc::new(AtomicBool::new(true)),
system_notifications: crate::system_notifications::SystemNotificationState::new(),
}))
}
@@ -968,6 +970,27 @@ impl Platform for MacPlatform {
}
}
fn show_system_notification(&self, notification: gpui::SystemNotification) {
let mut state = self.0.lock();
let executor = state.foreground_executor.clone();
state.system_notifications.show(&executor, notification);
}
fn dismiss_system_notification(&self, tag: &str) {
let mut state = self.0.lock();
let executor = state.foreground_executor.clone();
state.system_notifications.dismiss(&executor, tag);
}
fn on_system_notification_response(
&self,
callback: Box<dyn FnMut(gpui::SystemNotificationResponse)>,
) {
let mut state = self.0.lock();
let executor = state.foreground_executor.clone();
state.system_notifications.on_response(&executor, callback);
}
fn keyboard_layout(&self) -> Box<dyn PlatformKeyboardLayout> {
Box::new(MacKeyboardLayout::new())
}
@@ -0,0 +1,309 @@
//! System notifications through the `UNUserNotificationCenter` API.
//!
//! Everything here runs lazily: nothing touches the notification center (and
//! so nothing can trigger the authorization prompt or the framework's
//! not-in-a-bundle abort) until the application posts a notification or
//! registers a response callback.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use block2::RcBlock;
use futures::StreamExt as _;
use futures::channel::mpsc;
use gpui::{
ForegroundExecutor, SharedString, SystemNotification, SystemNotificationAction,
SystemNotificationResponse, Task,
};
use objc2::rc::Retained;
use objc2::runtime::{Bool, ProtocolObject};
use objc2::{AnyThread, DefinedClass, define_class, msg_send};
use objc2_foundation::{NSArray, NSBundle, NSError, NSObject, NSObjectProtocol, NSSet, NSString};
use objc2_user_notifications::{
UNAuthorizationOptions, UNMutableNotificationContent, UNNotification, UNNotificationAction,
UNNotificationActionOptions, UNNotificationCategory, UNNotificationCategoryOptions,
UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions,
UNNotificationRequest, UNNotificationResponse, UNUserNotificationCenter,
UNUserNotificationCenterDelegate,
};
type ResponseCallback = Rc<RefCell<Option<Box<dyn FnMut(SystemNotificationResponse)>>>>;
pub(crate) struct SystemNotificationState {
initialized: bool,
center: Option<NotificationCenter>,
callback: ResponseCallback,
_response_task: Option<Task<()>>,
}
impl SystemNotificationState {
pub(crate) fn new() -> Self {
Self {
initialized: false,
center: None,
callback: Rc::new(RefCell::new(None)),
_response_task: None,
}
}
pub(crate) fn show(&mut self, executor: &ForegroundExecutor, notification: SystemNotification) {
self.initialize(executor);
if let Some(center) = &self.center {
center.show(notification);
}
}
pub(crate) fn dismiss(&mut self, executor: &ForegroundExecutor, tag: &str) {
self.initialize(executor);
if let Some(center) = &self.center {
center.dismiss(tag);
}
}
pub(crate) fn on_response(
&mut self,
executor: &ForegroundExecutor,
callback: Box<dyn FnMut(SystemNotificationResponse)>,
) {
self.initialize(executor);
*self.callback.borrow_mut() = Some(callback);
}
fn initialize(&mut self, executor: &ForegroundExecutor) {
if self.initialized {
return;
}
self.initialized = true;
let (sender, mut receiver) = mpsc::unbounded();
self.center = NotificationCenter::new(sender);
if self.center.is_none() {
return;
}
// Responses arrive from the delegate on an arbitrary thread; this task
// hands them to the registered callback on the main thread.
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);
}
}
}));
}
}
struct NotificationCenter {
center: Retained<UNUserNotificationCenter>,
/// The center's `delegate` property is weak; keeping the delegate retained
/// here keeps response handling alive for the app's lifetime.
_delegate: Retained<NotificationResponseDelegate>,
/// The union of every action set registered so far. Categories are shared
/// by notifications with identical actions because macOS replaces the
/// entire registered set whenever a category is added.
categories: RefCell<
HashMap<Vec<SystemNotificationAction>, (SharedString, Retained<UNNotificationCategory>)>,
>,
authorization_requested: Cell<bool>,
}
impl NotificationCenter {
fn new(sender: mpsc::UnboundedSender<SystemNotificationResponse>) -> Option<Self> {
// `UNUserNotificationCenter` raises `NSInternalInconsistencyException`
// ("bundleProxyForCurrentProcess is nil"), aborting the process, when
// the binary isn't part of an app bundle — which is how dev builds run
// via `cargo run`. A bundle identifier is only present when launched
// from a real `.app`, so use it as the guard.
if NSBundle::mainBundle().bundleIdentifier().is_none() {
log::info!("system notifications disabled: not running from an app bundle");
return None;
}
let center = UNUserNotificationCenter::currentNotificationCenter();
let delegate = NotificationResponseDelegate::new(sender);
center.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
Some(Self {
center,
_delegate: delegate,
categories: RefCell::new(HashMap::new()),
authorization_requested: Cell::new(false),
})
}
fn request_authorization(&self) {
if self.authorization_requested.replace(true) {
return;
}
let completion = RcBlock::new(|granted: Bool, error: *mut NSError| {
// SAFETY: when non-null, `error` is a valid `NSError` for the
// duration of the callback.
if let Some(error) = unsafe { error.as_ref() } {
log::warn!(
"system notification authorization failed: {}",
error.localizedDescription()
);
} else if !granted.as_bool() {
log::info!("system notification authorization denied");
}
});
self.center
.requestAuthorizationWithOptions_completionHandler(
UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound,
&completion,
);
}
fn show(&self, notification: SystemNotification) {
self.request_authorization();
let content = UNMutableNotificationContent::new();
content.setTitle(&NSString::from_str(&notification.title));
content.setBody(&NSString::from_str(&notification.body));
if !notification.actions.is_empty() {
let category_identifier = self.register_category(&notification.actions);
content.setCategoryIdentifier(&NSString::from_str(&category_identifier));
}
// A nil trigger delivers immediately. Reusing the tag as the request
// identifier makes a newer notification for the same tag replace the
// older one.
let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
&NSString::from_str(&notification.tag),
&content,
None,
);
let completion = RcBlock::new(|error: *mut NSError| {
// SAFETY: when non-null, `error` is a valid `NSError` for the
// duration of the callback.
if let Some(error) = unsafe { error.as_ref() } {
log::warn!(
"failed to deliver system notification: {}",
error.localizedDescription()
);
}
});
self.center
.addNotificationRequest_withCompletionHandler(&request, Some(&completion));
}
fn register_category(&self, actions: &[SystemNotificationAction]) -> SharedString {
let mut categories = self.categories.borrow_mut();
if let Some((identifier, _category)) = categories.get(actions) {
return identifier.clone();
}
let identifier =
SharedString::from(format!("gpui-system-notification-{}", categories.len()));
let platform_actions: Vec<Retained<UNNotificationAction>> = actions
.iter()
.map(|action| {
UNNotificationAction::actionWithIdentifier_title_options(
&NSString::from_str(&action.id),
&NSString::from_str(&action.label),
UNNotificationActionOptions::empty(),
)
})
.collect();
let category =
UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options(
&NSString::from_str(&identifier),
&NSArray::from_retained_slice(&platform_actions),
&NSArray::new(),
UNNotificationCategoryOptions::empty(),
);
categories.insert(actions.to_vec(), (identifier.clone(), category));
let all: Vec<Retained<UNNotificationCategory>> = categories
.values()
.map(|(_identifier, category)| category.clone())
.collect();
self.center
.setNotificationCategories(&NSSet::from_retained_slice(&all));
identifier
}
fn dismiss(&self, tag: &str) {
let identifiers = NSArray::from_retained_slice(&[NSString::from_str(tag)]);
self.center
.removePendingNotificationRequestsWithIdentifiers(&identifiers);
self.center
.removeDeliveredNotificationsWithIdentifiers(&identifiers);
}
}
struct DelegateIvars {
sender: mpsc::UnboundedSender<SystemNotificationResponse>,
}
define_class!(
// SAFETY: `NSObject` has no subclassing requirements and
// `NotificationResponseDelegate` does not implement `Drop`.
#[unsafe(super(NSObject))]
#[ivars = DelegateIvars]
struct NotificationResponseDelegate;
unsafe impl NSObjectProtocol for NotificationResponseDelegate {}
unsafe impl UNUserNotificationCenterDelegate for NotificationResponseDelegate {
// Called when the user activates a delivered notification, possibly on
// a non-main thread; the channel hop hands the response to the
// foreground task holding the receiver.
#[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))]
fn did_receive_notification_response(
&self,
_center: &UNUserNotificationCenter,
response: &UNNotificationResponse,
completion_handler: &block2::DynBlock<dyn Fn()>,
) {
let tag = response.notification().request().identifier().to_string();
let action = response.actionIdentifier();
// The other well-known identifier, `UNNotificationDismissActionIdentifier`,
// is only delivered for categories opting into dismiss callbacks,
// which we never request.
let action_id = if &*action == unsafe { UNNotificationDefaultActionIdentifier } {
None
} else {
Some(SharedString::from(action.to_string()))
};
self.ivars()
.sender
.unbounded_send(SystemNotificationResponse {
tag: SharedString::from(tag),
action_id,
})
.ok();
completion_handler.call(());
}
// Without this, macOS suppresses banners while the app is frontmost.
// Posting is the application's decision; whether the app is focused is
// its criterion to apply, not the platform's.
#[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))]
fn will_present_notification(
&self,
_center: &UNUserNotificationCenter,
_notification: &UNNotification,
completion_handler: &block2::DynBlock<dyn Fn(UNNotificationPresentationOptions)>,
) {
completion_handler
.call((UNNotificationPresentationOptions::Banner
| UNNotificationPresentationOptions::List,));
}
}
);
impl NotificationResponseDelegate {
fn new(sender: mpsc::UnboundedSender<SystemNotificationResponse>) -> Retained<Self> {
let this = Self::alloc().set_ivars(DelegateIvars { sender });
// SAFETY: `NSObject`'s `init` is its designated initializer.
unsafe { msg_send![super(this), init] }
}
}
+2
View File
@@ -12,6 +12,7 @@ mod display;
mod events;
mod keyboard;
mod platform;
mod system_notifications;
mod system_settings;
mod util;
mod vsync;
@@ -29,6 +30,7 @@ pub(crate) use display::*;
pub(crate) use events::*;
pub(crate) use keyboard::*;
pub(crate) use platform::*;
pub(crate) use system_notifications::*;
pub(crate) use system_settings::*;
pub(crate) use util::*;
pub(crate) use vsync::*;
+69
View File
@@ -47,6 +47,9 @@ pub struct WindowsPlatform {
handle: HWND,
suspend_resume_notification: RefCell<Option<HPOWERNOTIFY>>,
disable_direct_composition: bool,
has_package_identity: bool,
app_identity: RefCell<Option<(String, String)>>,
system_notifications: RefCell<SystemNotificationState>,
}
struct WindowsPlatformInner {
@@ -197,8 +200,11 @@ impl WindowsPlatform {
direct_write_text_system,
suspend_resume_notification: RefCell::new(None),
disable_direct_composition,
has_package_identity: has_package_identity(),
drop_target_helper,
invalidate_devices: Arc::new(AtomicBool::new(false)),
app_identity: RefCell::new(None),
system_notifications: RefCell::new(SystemNotificationState::new()),
})
}
@@ -646,6 +652,51 @@ impl Platform for WindowsPlatform {
}
}
fn set_app_identity(&self, identifier: &str, name: &str) {
// If the process has package identity, it's automatally granted an AUMID by the system.
if self.has_package_identity {
return;
}
let identifier_utf16 = windows::core::HSTRING::from(identifier);
// SAFETY: `identifier_utf16` outlives the call and is null-terminated.
if let Err(error) = unsafe {
windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID(
windows::core::PCWSTR(identifier_utf16.as_ptr()),
)
} {
log::warn!("failed to set the process AppUserModelID: {error}");
}
*self.app_identity.borrow_mut() = Some((identifier.to_string(), name.to_string()));
}
fn show_system_notification(&self, notification: gpui::SystemNotification) {
let app_identity = self.app_identity.borrow().clone();
self.system_notifications
.borrow_mut()
.show(
self.has_package_identity,
app_identity
.as_ref()
.map(|(identifier, name)| (identifier.as_str(), name.as_str())),
notification,
)
.log_err();
}
fn dismiss_system_notification(&self, tag: &str) {
self.system_notifications.borrow_mut().dismiss(tag);
}
fn on_system_notification_response(
&self,
callback: Box<dyn FnMut(gpui::SystemNotificationResponse)>,
) {
self.system_notifications
.borrow_mut()
.on_response(&self.foreground_executor, callback);
}
fn set_menus(&self, menus: Vec<Menu>, _keymap: &Keymap) {
*self.inner.state.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect();
}
@@ -1104,6 +1155,24 @@ struct PlatformWindowCreateContext {
dispatcher: Option<Arc<WindowsDispatcher>>,
}
fn has_package_identity() -> bool {
let mut package_full_name_length = 0;
let result = unsafe {
windows::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName(
&mut package_full_name_length,
None,
)
};
if result == ERROR_INSUFFICIENT_BUFFER {
true
} else if result == APPMODEL_ERROR_NO_PACKAGE {
false
} else {
log::warn!("failed to determine whether the process has package identity: {result:?}");
false
}
}
fn open_target(target: impl AsRef<OsStr>) -> Result<()> {
let target = target.as_ref();
let ret = unsafe {
@@ -0,0 +1,198 @@
//! 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(&notification)?;
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(&notification.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 [&notification.title, &notification.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 &notification.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}");
}
}