diff --git a/Cargo.lock b/Cargo.lock index 3775ab6b3a..fde8ae565b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b7b2e007e7..e7d5de4f02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 586eed2609..8fd4a7c763 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -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" diff --git a/crates/gpui/examples/README.md b/crates/gpui/examples/README.md index 7fc75e0f5b..dab1c302c9 100644 --- a/crates/gpui/examples/README.md +++ b/crates/gpui/examples/README.md @@ -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. diff --git a/crates/gpui/examples/system_notifications.rs b/crates/gpui/examples/system_notifications.rs new file mode 100644 index 0000000000..70071dc96f --- /dev/null +++ b/crates/gpui/examples/system_notifications.rs @@ -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) -> 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() + .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(); +} diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 1a3e28fff9..857b792dd1 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -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(&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. diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 9e32c5dc2d..582e1ad840 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -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 { + self.test_platform.shown_system_notifications() + } + + /// Returns the system notifications currently delivered by the test platform. + pub fn delivered_system_notifications(&self) -> Vec { + 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 { + 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) { 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) { diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 3caf351fc8..20600c3eb5 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -239,6 +239,46 @@ pub trait Platform: 'static { fn thermal_state(&self) -> ThermalState; fn on_thermal_state_change(&self, callback: Box); + /// 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, + ) { + _ = 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, +} + +/// 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, +} + /// Thermal state of the system #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThermalState { diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index bc1ef4e38b..42edcd6cba 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -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, screen_capture_sources: RefCell>, pub opened_url: RefCell>, + pub(crate) system_notifications: RefCell, pub text_system: Arc, pub expect_restart: RefCell>>>, headless_renderer_factory: Option Option>>>, @@ -82,6 +83,15 @@ struct TestPrompt { tx: oneshot::Sender, } +#[derive(Default)] +pub(crate) struct TestSystemNotifications { + pub(crate) app_identity: Option<(SharedString, SharedString)>, + pub(crate) shown: Vec, + pub(crate) delivered: Vec, + pub(crate) dismissed: Vec, + response_callback: Option>, +} + #[derive(Default)] pub(crate) struct TestPrompts { multiple_choice: VecDeque, @@ -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 { + self.system_notifications.borrow().shown.clone() + } + + pub(crate) fn delivered_system_notifications(&self) -> Vec { + self.system_notifications.borrow().delivered.clone() + } + + pub(crate) fn dismissed_system_notifications(&self) -> Vec { + 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) {} + 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, + ) { + self.system_notifications.borrow_mut().response_callback = Some(callback); + } + fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index 160a9525ce..44884414c0 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -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 diff --git a/crates/gpui_linux/src/linux.rs b/crates/gpui_linux/src/linux.rs index 7202248e37..33393f7e9e 100644 --- a/crates/gpui_linux/src/linux.rs +++ b/crates/gpui_linux/src/linux.rs @@ -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")] diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 8aac55d321..876f931663 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -123,6 +123,8 @@ pub(crate) struct LinuxCommon { pub(crate) callbacks: PlatformHandlers, pub(crate) signal: LoopSignal, pub(crate) menus: Vec, + app_name: Option, + 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 Platform for LinuxPlatform

{ }); } + 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, + ) { + 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) { self.inner.with_common(|common| { common.callbacks.app_menu_action = Some(callback); diff --git a/crates/gpui_linux/src/linux/system_notifications.rs b/crates/gpui_linux/src/linux/system_notifications.rs new file mode 100644 index 0000000000..3530c6f342 --- /dev/null +++ b/crates/gpui_linux/src/linux/system_notifications.rs @@ -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>>>; + +pub(crate) struct SystemNotificationState { + response_sender: mpsc::UnboundedSender, + response_receiver: Option>, + callback: ResponseCallback, + _response_task: Option>, +} + +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(¬ification.title) + .body(¬ification.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, + ) { + *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, +) -> Option> { + 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); + } +} diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 3c359ed53f..610d65a71f 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -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 diff --git a/crates/gpui_macos/src/gpui_macos.rs b/crates/gpui_macos/src/gpui_macos.rs index b9b55430b8..9a9e4d7ae8 100644 --- a/crates/gpui_macos/src/gpui_macos.rs +++ b/crates/gpui_macos/src/gpui_macos.rs @@ -10,6 +10,7 @@ mod display_link; mod events; mod keyboard; mod pasteboard; +mod system_notifications; #[cfg(feature = "screen-capture")] mod screen_capture; diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index eed9e90bcd..b25067935e 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -189,6 +189,7 @@ pub(crate) struct MacPlatformState { keyboard_mapper: Rc, /// Mirrors `[NSCursor setHiddenUntilMouseMoves:]` state, which AppKit doesn't expose. cursor_visible: Arc, + 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, + ) { + let mut state = self.0.lock(); + let executor = state.foreground_executor.clone(); + state.system_notifications.on_response(&executor, callback); + } + fn keyboard_layout(&self) -> Box { Box::new(MacKeyboardLayout::new()) } diff --git a/crates/gpui_macos/src/system_notifications.rs b/crates/gpui_macos/src/system_notifications.rs new file mode 100644 index 0000000000..4bfd9522ca --- /dev/null +++ b/crates/gpui_macos/src/system_notifications.rs @@ -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>>>; + +pub(crate) struct SystemNotificationState { + initialized: bool, + center: Option, + callback: ResponseCallback, + _response_task: Option>, +} + +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, + ) { + 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, + /// The center's `delegate` property is weak; keeping the delegate retained + /// here keeps response handling alive for the app's lifetime. + _delegate: Retained, + /// 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, (SharedString, Retained)>, + >, + authorization_requested: Cell, +} + +impl NotificationCenter { + fn new(sender: mpsc::UnboundedSender) -> Option { + // `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(¬ification.title)); + content.setBody(&NSString::from_str(¬ification.body)); + if !notification.actions.is_empty() { + let category_identifier = self.register_category(¬ification.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(¬ification.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> = 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> = 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, +} + +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, + ) { + 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, + ) { + completion_handler + .call((UNNotificationPresentationOptions::Banner + | UNNotificationPresentationOptions::List,)); + } + } +); + +impl NotificationResponseDelegate { + fn new(sender: mpsc::UnboundedSender) -> Retained { + let this = Self::alloc().set_ivars(DelegateIvars { sender }); + // SAFETY: `NSObject`'s `init` is its designated initializer. + unsafe { msg_send![super(this), init] } + } +} diff --git a/crates/gpui_windows/src/gpui_windows.rs b/crates/gpui_windows/src/gpui_windows.rs index 0af5411d20..692d0dbbfd 100644 --- a/crates/gpui_windows/src/gpui_windows.rs +++ b/crates/gpui_windows/src/gpui_windows.rs @@ -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::*; diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 9ba94563a4..b3ad496200 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -47,6 +47,9 @@ pub struct WindowsPlatform { handle: HWND, suspend_resume_notification: RefCell>, disable_direct_composition: bool, + has_package_identity: bool, + app_identity: RefCell>, + system_notifications: RefCell, } 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, + ) { + self.system_notifications + .borrow_mut() + .on_response(&self.foreground_executor, callback); + } + fn set_menus(&self, menus: Vec

, _keymap: &Keymap) { *self.inner.state.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect(); } @@ -1104,6 +1155,24 @@ struct PlatformWindowCreateContext { dispatcher: Option>, } +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) -> Result<()> { let target = target.as_ref(); let ret = unsafe { diff --git a/crates/gpui_windows/src/system_notifications.rs b/crates/gpui_windows/src/system_notifications.rs new file mode 100644 index 0000000000..b0075f0e39 --- /dev/null +++ b/crates/gpui_windows/src/system_notifications.rs @@ -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>>>; + +pub(crate) struct SystemNotificationState { + notifier: Option, + active_toasts: HashMap, + response_sender: mpsc::UnboundedSender, + response_receiver: Option>, + callback: ResponseCallback, + _response_task: Option>, +} + +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::::new( + move |_sender, arguments| { + let action_id = arguments + .as_ref() + .and_then(|arguments| arguments.cast::().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, + ) { + *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> { + 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 { + 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}"); + } +}