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:
1 parent
31813161f1
commit
cd91c84675
20 files changed
+1416
-10
No files matched your search
Generated
+74
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(¬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<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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ mod display_link;
|
||||
mod events;
|
||||
mod keyboard;
|
||||
mod pasteboard;
|
||||
mod system_notifications;
|
||||
|
||||
#[cfg(feature = "screen-capture")]
|
||||
mod screen_capture;
|
||||
|
||||
@@ -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(¬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<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] }
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(¬ification)?;
|
||||
let toast = ToastNotification::CreateToastNotification(&document)?;
|
||||
// Windows caps toast tags at 64 characters (post-Creators Update),
|
||||
// so hash the arbitrary GPUI tag down to a fixed-width value.
|
||||
let tag = {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
notification.tag.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
};
|
||||
toast.SetTag(&tag.into())?;
|
||||
|
||||
let sender = self.response_sender.clone();
|
||||
let response_tag = notification.tag.clone();
|
||||
toast.Activated(&TypedEventHandler::<ToastNotification, IInspectable>::new(
|
||||
move |_sender, arguments| {
|
||||
let action_id = arguments
|
||||
.as_ref()
|
||||
.and_then(|arguments| arguments.cast::<ToastActivatedEventArgs>().ok())
|
||||
.and_then(|arguments| arguments.Arguments().ok())
|
||||
.filter(|arguments| !arguments.is_empty())
|
||||
.map(|arguments| SharedString::from(arguments.to_string()));
|
||||
sender
|
||||
.unbounded_send(SystemNotificationResponse {
|
||||
tag: response_tag.clone(),
|
||||
action_id,
|
||||
})
|
||||
.ok();
|
||||
Ok(())
|
||||
},
|
||||
))?;
|
||||
|
||||
if let Some(previous) = self.active_toasts.remove(¬ification.tag) {
|
||||
notifier.Hide(&previous)?;
|
||||
}
|
||||
notifier.Show(&toast)?;
|
||||
self.active_toasts.insert(notification.tag, toast);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn dismiss(&mut self, tag: &str) {
|
||||
let Some(toast) = self.active_toasts.remove(tag) else {
|
||||
return;
|
||||
};
|
||||
let Some(notifier) = &self.notifier else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = notifier.Hide(&toast) {
|
||||
log::warn!("failed to dismiss system notification: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_response(
|
||||
&mut self,
|
||||
executor: &ForegroundExecutor,
|
||||
callback: Box<dyn FnMut(SystemNotificationResponse)>,
|
||||
) {
|
||||
*self.callback.borrow_mut() = Some(callback);
|
||||
|
||||
if let Some(mut receiver) = self.response_receiver.take() {
|
||||
let callback = self.callback.clone();
|
||||
self._response_task = Some(executor.spawn(async move {
|
||||
while let Some(response) = receiver.next().await {
|
||||
// Take the callback out for the call: it may re-enter the
|
||||
// platform (e.g. to dismiss the notification it was told
|
||||
// about) or replace itself.
|
||||
let taken = callback.borrow_mut().take();
|
||||
if let Some(mut taken) = taken {
|
||||
taken(response);
|
||||
callback.borrow_mut().get_or_insert(taken);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn notifier(
|
||||
&mut self,
|
||||
has_package_identity: bool,
|
||||
app_identity: Option<(&str, &str)>,
|
||||
) -> windows::core::Result<Option<ToastNotifier>> {
|
||||
if let Some(notifier) = &self.notifier {
|
||||
return Ok(Some(notifier.clone()));
|
||||
}
|
||||
|
||||
let notifier = if has_package_identity {
|
||||
ToastNotificationManager::CreateToastNotifier()?
|
||||
} else {
|
||||
let Some((app_identifier, app_name)) = app_identity else {
|
||||
log::warn!(
|
||||
"cannot show a system notification without an app identity; \
|
||||
call `App::set_app_identity` during startup"
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
register_app_user_model_id(app_identifier, app_name);
|
||||
ToastNotificationManager::CreateToastNotifierWithId(&app_identifier.into())?
|
||||
};
|
||||
|
||||
self.notifier = Some(notifier.clone());
|
||||
Ok(Some(notifier))
|
||||
}
|
||||
}
|
||||
|
||||
fn toast_document(notification: &SystemNotification) -> windows::core::Result<XmlDocument> {
|
||||
let document = XmlDocument::new()?;
|
||||
let toast = document.CreateElement(h!("toast"))?;
|
||||
document.AppendChild(&toast)?;
|
||||
|
||||
let visual = document.CreateElement(h!("visual"))?;
|
||||
toast.AppendChild(&visual)?;
|
||||
let binding = document.CreateElement(h!("binding"))?;
|
||||
binding.SetAttribute(h!("template"), h!("ToastGeneric"))?;
|
||||
visual.AppendChild(&binding)?;
|
||||
for text in [¬ification.title, ¬ification.body] {
|
||||
let element = document.CreateElement(h!("text"))?;
|
||||
element.SetInnerText(&text.as_ref().into())?;
|
||||
binding.AppendChild(&element)?;
|
||||
}
|
||||
|
||||
if !notification.actions.is_empty() {
|
||||
let actions = document.CreateElement(h!("actions"))?;
|
||||
toast.AppendChild(&actions)?;
|
||||
for action in ¬ification.actions {
|
||||
let action_element = document.CreateElement(h!("action"))?;
|
||||
action_element.SetAttribute(h!("content"), &action.label.as_ref().into())?;
|
||||
action_element.SetAttribute(h!("arguments"), &action.id.as_ref().into())?;
|
||||
actions.AppendChild(&action_element)?;
|
||||
}
|
||||
}
|
||||
|
||||
let audio = document.CreateElement(h!("audio"))?;
|
||||
audio.SetAttribute(h!("silent"), h!("true"))?;
|
||||
toast.AppendChild(&audio)?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
/// Registers the app's AUMID so toasts display correctly for an unpackaged app without a Start Menu shortcut.
|
||||
fn register_app_user_model_id(app_identifier: &str, app_name: &str) {
|
||||
let result = windows_registry::CURRENT_USER
|
||||
.create(format!(r"Software\Classes\AppUserModelId\{app_identifier}"))
|
||||
.and_then(|key| key.set_string("DisplayName", app_name));
|
||||
if let Err(error) = result {
|
||||
log::warn!("failed to register AppUserModelID; notifications may not display: {error}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user