mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-12 22:47:08 +02:00
* feat(notification): report actions on desktop A click on a notification (tap) or on one of the actions of its action type now reaches the JavaScript onAction listeners on desktop, and the new Rust Notification::on_action on every platform. On Linux and the BSDs the click comes from the notification server's ActionInvoked signal; on Windows and macOS from notify-rust's wait_for_response, which needs notify-rust 4.18. register_action_types, remove_active (Linux and the BSDs) and the listener commands are implemented for desktop, so they no longer fail with "command not found". The api example sends a notification with two actions and shows the action it hears. Closes #2150, closes #1903. * fix(notification): complete desktop action API * cleanup --------- Co-authored-by: Haex <hexxx@ok.de> Co-authored-by: Lucas Nogueira <lucas@tauri.app>
816 lines
30 KiB
Rust
816 lines
30 KiB
Rust
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
use std::{
|
|
collections::HashMap,
|
|
sync::{Arc, Mutex, MutexGuard, PoisonError},
|
|
};
|
|
|
|
use serde::de::DeserializeOwned;
|
|
use serde_json::Value;
|
|
use tauri::{
|
|
AppHandle, Manager, Runtime,
|
|
ipc::Channel,
|
|
plugin::{PermissionState, PluginApi},
|
|
};
|
|
|
|
use crate::{ActionPerformed, ActionType, NotificationBuilder};
|
|
|
|
/// The event of the JavaScript `onAction` listeners.
|
|
const ACTION_PERFORMED: &str = "actionPerformed";
|
|
|
|
/// Initializes the desktop implementation of the notification APIs.
|
|
pub fn init<R: Runtime, C: DeserializeOwned>(
|
|
app: &AppHandle<R>,
|
|
_api: PluginApi<R, C>,
|
|
) -> crate::Result<Notification<R>> {
|
|
Ok(Notification {
|
|
app: app.clone(),
|
|
shared: Arc::default(),
|
|
})
|
|
}
|
|
|
|
type ActionHandler = Arc<dyn Fn(&ActionPerformed) + Send + Sync>;
|
|
|
|
/// A JavaScript listener, with the webview and the window it was registered from.
|
|
struct Listener {
|
|
event: String,
|
|
channel: Channel<Value>,
|
|
webview: String,
|
|
window: String,
|
|
}
|
|
|
|
/// What the notifications of this app share: the registered action types, who listens to
|
|
/// actions, and the shown notifications whose actions are reported.
|
|
#[derive(Default)]
|
|
pub(crate) struct Shared {
|
|
action_types: Mutex<HashMap<String, ActionType>>,
|
|
handlers: Mutex<Vec<ActionHandler>>,
|
|
listeners: Mutex<Vec<Listener>>,
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
tracker: imp::Tracker,
|
|
}
|
|
|
|
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
|
mutex.lock().unwrap_or_else(PoisonError::into_inner)
|
|
}
|
|
|
|
impl Shared {
|
|
fn has_listeners(&self) -> bool {
|
|
!lock(&self.handlers).is_empty()
|
|
|| lock(&self.listeners)
|
|
.iter()
|
|
.any(|listener| listener.event == ACTION_PERFORMED)
|
|
}
|
|
|
|
fn retain_listeners(&self, keep: impl Fn(&Listener) -> bool) {
|
|
lock(&self.listeners).retain(keep);
|
|
}
|
|
|
|
/// Hands an action to the Rust handlers and the JavaScript listeners. The handlers run
|
|
/// without a lock held, so they may register handlers or remove notifications themselves.
|
|
fn dispatch(&self, payload: Value) {
|
|
if let Ok(performed) = serde_json::from_value::<ActionPerformed>(payload.clone()) {
|
|
let handlers = lock(&self.handlers).clone();
|
|
for handler in handlers {
|
|
handler(&performed);
|
|
}
|
|
}
|
|
for listener in lock(&self.listeners).iter() {
|
|
if listener.event == ACTION_PERFORMED {
|
|
let _ = listener.channel.send(payload.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Access to the notification APIs.
|
|
///
|
|
/// You can get an instance of this type via [`NotificationExt`](crate::NotificationExt)
|
|
pub struct Notification<R: Runtime> {
|
|
app: AppHandle<R>,
|
|
shared: Arc<Shared>,
|
|
}
|
|
|
|
impl<R: Runtime> crate::NotificationBuilder<R> {
|
|
/// Shows the notification.
|
|
///
|
|
/// When no title was set with [`Self::title`], the `productName` from the Tauri configuration is used instead.
|
|
/// Only the title, body, icon, sound and the actions of the [action type](Self::action_type_id)
|
|
/// of the notification are used on desktop; the scheduling and grouping options are ignored.
|
|
///
|
|
/// When a handler is registered with [`Notification::on_action`] or a JavaScript `onAction`
|
|
/// listener exists, a click on the notification (`tap`) or on one of its actions is reported
|
|
/// to them, as far as the notification server reports it. Otherwise the actions of the
|
|
/// notification are not shown, since nothing would receive them.
|
|
///
|
|
/// ## Platform-specific
|
|
///
|
|
/// - **Windows**: a click is only reported while the toast is on screen, not once it moved to
|
|
/// the Action Center.
|
|
/// - **macOS**: two or more actions are shown in an "Options" menu, and a click on the menu
|
|
/// button itself reports the first action. Actions are told apart by their title.
|
|
///
|
|
/// The notification is dispatched on a background task, so this returns as soon as the payload is prepared.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error when the notification could not be prepared,
|
|
/// e.g. when the path of the running executable cannot be resolved on Windows.
|
|
pub fn show(self) -> crate::Result<()> {
|
|
let shared = Arc::clone(&self.app.state::<Notification<R>>().shared);
|
|
let mut notification = imp::Notification::new(self.app.config().identifier.clone());
|
|
|
|
if let Some(title) = self
|
|
.data
|
|
.title
|
|
.clone()
|
|
.or_else(|| self.app.config().product_name.clone())
|
|
{
|
|
notification = notification.title(title);
|
|
}
|
|
if let Some(body) = self.data.body.clone() {
|
|
notification = notification.body(body);
|
|
}
|
|
if let Some(icon) = self.data.icon.clone() {
|
|
notification = notification.icon(icon);
|
|
}
|
|
if let Some(sound) = self.data.sound.clone() {
|
|
notification = notification.sound(sound);
|
|
}
|
|
if shared.has_listeners() {
|
|
if let Some(action_type) = self
|
|
.data
|
|
.action_type_id
|
|
.as_ref()
|
|
.and_then(|id| lock(&shared.action_types).get(id).cloned())
|
|
{
|
|
for action in action_type.actions() {
|
|
notification = notification.action(action.id(), action.title());
|
|
}
|
|
}
|
|
let id = self.data.id;
|
|
let data = serde_json::to_value(&self.data).unwrap_or(Value::Null);
|
|
notification.show_tracked(shared, id, data)?;
|
|
} else {
|
|
notification.show()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> Notification<R> {
|
|
/// Creates a new builder for a notification.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use tauri_plugin_notification::NotificationExt;
|
|
///
|
|
/// fn notify<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
|
|
/// app.notification()
|
|
/// .builder()
|
|
/// .title("Tauri")
|
|
/// .body("Tauri is awesome!")
|
|
/// .show()
|
|
/// .unwrap();
|
|
/// }
|
|
/// ```
|
|
pub fn builder(&self) -> NotificationBuilder<R> {
|
|
NotificationBuilder::new(self.app.clone())
|
|
}
|
|
|
|
/// Requests the permission to send notifications.
|
|
///
|
|
/// Desktop applications do not need to ask for this permission,
|
|
/// so this always resolves to [`PermissionState::Granted`] without prompting the user.
|
|
pub fn request_permission(&self) -> crate::Result<PermissionState> {
|
|
Ok(PermissionState::Granted)
|
|
}
|
|
|
|
/// Checks whether the permission to send notifications was granted.
|
|
///
|
|
/// Desktop applications do not need to ask for this permission,
|
|
/// so this always resolves to [`PermissionState::Granted`].
|
|
pub fn permission_state(&self) -> crate::Result<PermissionState> {
|
|
Ok(PermissionState::Granted)
|
|
}
|
|
|
|
/// Registers the action types a notification can reference
|
|
/// through [`NotificationBuilder::action_type_id`](crate::NotificationBuilder::action_type_id).
|
|
///
|
|
/// On desktop the actions of a type become the buttons of the notification; only their
|
|
/// identifier and title are used. A type registered again replaces the previous one.
|
|
pub fn register_action_types(&self, types: Vec<ActionType>) -> crate::Result<()> {
|
|
let mut registered = lock(&self.shared.action_types);
|
|
for action_type in types {
|
|
registered.insert(action_type.id().to_owned(), action_type);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Removes the delivered notifications with the given identifiers.
|
|
///
|
|
/// ## Platform-specific
|
|
///
|
|
/// - **Linux and the BSDs**: closes notifications shown while an action listener existed.
|
|
/// - **macOS and Windows**: does nothing, `notify-rust` cannot remove a shown notification there.
|
|
pub fn remove_active(&self, notifications: Vec<i32>) -> crate::Result<()> {
|
|
imp::close(&self.shared, Some(¬ifications));
|
|
Ok(())
|
|
}
|
|
|
|
/// Removes all delivered notifications that can still be closed.
|
|
pub fn remove_all_active(&self) -> crate::Result<()> {
|
|
imp::close(&self.shared, None);
|
|
Ok(())
|
|
}
|
|
|
|
/// Calls `handler` for every action the user performs on a notification of this app:
|
|
/// a click on the notification itself (`tap`) or on one of its actions.
|
|
///
|
|
/// Notifications shown before the first handler or listener existed report nothing.
|
|
/// The handler runs on a background thread and should return quickly.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Never on desktop; the signature matches the mobile one.
|
|
pub fn on_action<F: Fn(&ActionPerformed) + Send + Sync + 'static>(
|
|
&self,
|
|
handler: F,
|
|
) -> crate::Result<()> {
|
|
lock(&self.shared.handlers).push(Arc::new(handler));
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn register_listener(
|
|
&self,
|
|
event: String,
|
|
channel: Channel<Value>,
|
|
webview: &tauri::Webview<R>,
|
|
) {
|
|
lock(&self.shared.listeners).push(Listener {
|
|
event,
|
|
channel,
|
|
webview: webview.label().to_owned(),
|
|
window: webview.window().label().to_owned(),
|
|
});
|
|
}
|
|
|
|
pub(crate) fn remove_listener(&self, event: &str, channel_id: u32) {
|
|
lock(&self.shared.listeners)
|
|
.retain(|listener| !(listener.event == event && listener.channel.id() == channel_id));
|
|
}
|
|
|
|
/// Drops the listeners of a webview that navigates away, since its page no longer exists.
|
|
pub(crate) fn remove_webview_listeners(&self, webview: &str) {
|
|
self.shared
|
|
.retain_listeners(|listener| listener.webview != webview);
|
|
}
|
|
|
|
/// Drops the listeners of the webviews of a destroyed window.
|
|
pub(crate) fn remove_window_listeners(&self, window: &str) {
|
|
self.shared
|
|
.retain_listeners(|listener| listener.window != window);
|
|
}
|
|
}
|
|
|
|
mod imp {
|
|
//! Types and functions related to desktop notifications.
|
|
|
|
#[cfg(windows)]
|
|
use std::path::MAIN_SEPARATOR as SEP;
|
|
use std::sync::Arc;
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
use std::{
|
|
collections::HashMap,
|
|
sync::{Mutex, OnceLock, Weak},
|
|
};
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use super::Shared;
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
use super::lock;
|
|
|
|
/// The desktop notification definition.
|
|
///
|
|
/// Allows you to construct a Notification data and send it.
|
|
///
|
|
/// # Examples
|
|
/// ```rust,no_run
|
|
/// use tauri_plugin_notification::NotificationExt;
|
|
/// // first we build the application to access the Tauri configuration
|
|
/// let app = tauri::Builder::default()
|
|
/// // on an actual app, remove the string argument
|
|
/// .build(tauri::generate_context!("test/tauri.conf.json"))
|
|
/// .expect("error while building tauri application");
|
|
///
|
|
/// // shows a notification with the given title and body
|
|
/// app.notification()
|
|
/// .builder()
|
|
/// .title("New message")
|
|
/// .body("You've got a new message.")
|
|
/// .show();
|
|
///
|
|
/// // run the app
|
|
/// app.run(|_app_handle, _event| {});
|
|
/// ```
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Default)]
|
|
pub struct Notification {
|
|
/// The notification body.
|
|
body: Option<String>,
|
|
/// The notification title.
|
|
title: Option<String>,
|
|
/// The notification icon.
|
|
icon: Option<String>,
|
|
/// The notification sound.
|
|
sound: Option<String>,
|
|
/// The notification identifier
|
|
identifier: String,
|
|
/// The actions, as identifier and title.
|
|
actions: Vec<(String, String)>,
|
|
}
|
|
|
|
/// Linux and the BSDs: the notifications whose actions are reported. A single session bus
|
|
/// connection, opened with the first of them, receives the signals of all of them on a thread
|
|
/// of its own, so a tracked notification costs no thread or connection of its own.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
#[derive(Default)]
|
|
pub(crate) struct Tracker {
|
|
/// The connection the signals arrive on; `None` when it could not be set up.
|
|
connection: OnceLock<Option<zbus::blocking::Connection>>,
|
|
/// The tracked notifications the server still shows, by server id: their identifier and
|
|
/// the payload their actions carry.
|
|
pub(super) shown: Mutex<HashMap<u32, (i32, Value)>>,
|
|
}
|
|
|
|
/// The payload of an `actionPerformed` event, as the mobile plugins send it.
|
|
pub(super) fn performed(action_id: &str, notification: &Value) -> Value {
|
|
json!({
|
|
"actionId": action_id,
|
|
"inputValue": null,
|
|
"notification": notification,
|
|
})
|
|
}
|
|
|
|
impl Notification {
|
|
/// Initializes a instance of a Notification.
|
|
pub fn new(identifier: impl Into<String>) -> Self {
|
|
Self {
|
|
identifier: identifier.into(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Sets the notification body.
|
|
#[must_use]
|
|
pub fn body(mut self, body: impl Into<String>) -> Self {
|
|
self.body = Some(body.into());
|
|
self
|
|
}
|
|
|
|
/// Sets the notification title.
|
|
#[must_use]
|
|
pub fn title(mut self, title: impl Into<String>) -> Self {
|
|
self.title = Some(title.into());
|
|
self
|
|
}
|
|
|
|
/// Sets the notification icon.
|
|
#[must_use]
|
|
pub fn icon(mut self, icon: impl Into<String>) -> Self {
|
|
self.icon = Some(icon.into());
|
|
self
|
|
}
|
|
|
|
/// Sets the notification sound file.
|
|
#[must_use]
|
|
pub fn sound(mut self, sound: impl Into<String>) -> Self {
|
|
self.sound = Some(sound.into());
|
|
self
|
|
}
|
|
|
|
/// Adds an action button.
|
|
#[must_use]
|
|
pub fn action(mut self, id: impl Into<String>, title: impl Into<String>) -> Self {
|
|
self.actions.push((id.into(), title.into()));
|
|
self
|
|
}
|
|
|
|
/// Shows the notification.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use tauri_plugin_notification::NotificationExt;
|
|
///
|
|
/// tauri::Builder::default()
|
|
/// .setup(|app| {
|
|
/// app.notification()
|
|
/// .builder()
|
|
/// .title("Tauri")
|
|
/// .body("Tauri is awesome!")
|
|
/// .show()
|
|
/// .unwrap();
|
|
/// Ok(())
|
|
/// })
|
|
/// .run(tauri::generate_context!("test/tauri.conf.json"))
|
|
/// .expect("error while running tauri application");
|
|
/// ```
|
|
pub fn show(self) -> crate::Result<()> {
|
|
let notification = self.build()?;
|
|
tauri::async_runtime::spawn(async move {
|
|
let _ = notification.show();
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Shows the notification and reports what the user does with it: a click on the
|
|
/// notification (`tap`) or on an action goes to `shared`'s listeners as an
|
|
/// `actionPerformed` event carrying `data`. Closing it reports nothing.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
pub(crate) fn show_tracked(
|
|
self,
|
|
shared: Arc<Shared>,
|
|
id: i32,
|
|
data: Value,
|
|
) -> crate::Result<()> {
|
|
let mut notification = self.build()?;
|
|
// The Desktop Notifications Specification reports a click on the notification itself
|
|
// as the action `default`, and only for a notification that offers that action.
|
|
notification.action("default", "");
|
|
tauri::async_runtime::spawn_blocking(move || track(notification, &shared, id, data));
|
|
Ok(())
|
|
}
|
|
|
|
/// Shows the notification and waits on a thread of its own for what the user does with it;
|
|
/// a click on the notification (`tap`) or on an action goes to `shared`'s listeners as an
|
|
/// `actionPerformed` event carrying `data`. Closing it reports nothing.
|
|
#[cfg(any(windows, target_os = "macos"))]
|
|
pub(crate) fn show_tracked(
|
|
self,
|
|
shared: Arc<Shared>,
|
|
_id: i32,
|
|
data: Value,
|
|
) -> crate::Result<()> {
|
|
let notification = self.build()?;
|
|
std::thread::Builder::new()
|
|
.name("notification".into())
|
|
.spawn(move || wait(notification, shared, data))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn build(self) -> crate::Result<notify_rust::Notification> {
|
|
let mut notification = notify_rust::Notification::new();
|
|
for (id, title) in &self.actions {
|
|
notification.action(id, title);
|
|
}
|
|
if let Some(body) = self.body {
|
|
notification.body(&body);
|
|
}
|
|
if let Some(title) = self.title {
|
|
notification.summary(&title);
|
|
}
|
|
if let Some(icon) = self.icon {
|
|
notification.icon(&icon);
|
|
} else {
|
|
notification.auto_icon();
|
|
}
|
|
if let Some(sound) = self.sound {
|
|
notification.sound_name(&sound);
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let exe = tauri::utils::platform::current_exe()?;
|
|
let exe_dir = exe.parent().expect("failed to get exe directory");
|
|
let curr_dir = exe_dir.display().to_string();
|
|
// set the notification's System.AppUserModel.ID only when running the installed app
|
|
if !(curr_dir.ends_with(format!("{SEP}target{SEP}debug").as_str())
|
|
|| curr_dir.ends_with(format!("{SEP}target{SEP}release").as_str()))
|
|
{
|
|
notification.app_id(&self.identifier);
|
|
}
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let _ = notify_rust::set_application(if tauri::is_dev() {
|
|
"com.apple.Terminal"
|
|
} else {
|
|
&self.identifier
|
|
});
|
|
}
|
|
|
|
Ok(notification)
|
|
}
|
|
|
|
/// Shows the notification. Same as [`Self::show`].
|
|
#[cfg(feature = "windows7-compat")]
|
|
#[allow(dead_code)]
|
|
#[cfg_attr(docsrs, doc(cfg(feature = "windows7-compat")))]
|
|
#[deprecated = "Tauri no longer supports Windows 7, use `Self::show` instead."]
|
|
pub fn notify<R: tauri::Runtime>(self, _app: &tauri::AppHandle<R>) -> crate::Result<()> {
|
|
self.show()
|
|
}
|
|
}
|
|
|
|
/// The bus name, object path and interface of the Desktop Notifications Specification.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
pub(super) const BUS: (&str, &str, &str) = (
|
|
"org.freedesktop.Notifications",
|
|
"/org/freedesktop/Notifications",
|
|
"org.freedesktop.Notifications",
|
|
);
|
|
|
|
/// Linux and the BSDs: shows the notification and records it, so the listener thread knows
|
|
/// which payload its signals carry.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
fn track(notification: notify_rust::Notification, shared: &Arc<Shared>, id: i32, data: Value) {
|
|
let tracker = &shared.tracker;
|
|
let Some(connection) = tracker
|
|
.connection
|
|
.get_or_init(|| listen(Arc::downgrade(shared)))
|
|
else {
|
|
let _ = notification.show();
|
|
return;
|
|
};
|
|
// Held while the notification is sent, so a signal that arrives before the server id is
|
|
// recorded waits for it instead of being missed.
|
|
let mut shown = lock(&tracker.shown);
|
|
if let Ok(server_id) = notify(connection, ¬ification) {
|
|
shown.insert(server_id, (id, data));
|
|
}
|
|
}
|
|
|
|
/// Linux and the BSDs: sends the notification on the connection the signals arrive on, since
|
|
/// some servers, like dunst, send the signals of a notification only to the connection that
|
|
/// sent it. Returns the server id of the notification.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
fn notify(
|
|
connection: &zbus::blocking::Connection,
|
|
notification: ¬ify_rust::Notification,
|
|
) -> zbus::Result<u32> {
|
|
// the only hint the plugin sets
|
|
let hints = notification
|
|
.hints
|
|
.iter()
|
|
.filter_map(|hint| match hint {
|
|
notify_rust::Hint::SoundName(name) => {
|
|
Some(("sound-name", zbus::zvariant::Value::from(name.as_str())))
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
let (destination, path, interface) = BUS;
|
|
connection
|
|
.call_method(
|
|
Some(destination),
|
|
path,
|
|
Some(interface),
|
|
"Notify",
|
|
&(
|
|
¬ification.appname,
|
|
0u32,
|
|
¬ification.icon,
|
|
¬ification.summary,
|
|
¬ification.body,
|
|
¬ification.actions,
|
|
hints,
|
|
-1i32,
|
|
),
|
|
)?
|
|
.body()
|
|
.deserialize()
|
|
}
|
|
|
|
/// Linux and the BSDs: connects to the session bus and subscribes to the signals of the
|
|
/// notification server, then handles them on a thread of its own until the plugin goes away.
|
|
/// The subscription is in place when this returns, so no signal of a later notification is
|
|
/// missed.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
fn listen(shared: Weak<Shared>) -> Option<zbus::blocking::Connection> {
|
|
let (_, path, interface) = BUS;
|
|
let connection = zbus::blocking::Connection::session().ok()?;
|
|
let rule = zbus::MatchRule::builder()
|
|
.msg_type(zbus::message::Type::Signal)
|
|
.path(path)
|
|
.ok()?
|
|
.interface(interface)
|
|
.ok()?
|
|
.build();
|
|
let signals =
|
|
zbus::blocking::MessageIterator::for_match_rule(rule, &connection, None).ok()?;
|
|
std::thread::Builder::new()
|
|
.name("notification-actions".into())
|
|
.spawn(move || {
|
|
for signal in signals.flatten() {
|
|
let Some(shared) = shared.upgrade() else {
|
|
return;
|
|
};
|
|
on_signal(&shared, &signal);
|
|
}
|
|
})
|
|
.ok()?;
|
|
Some(connection)
|
|
}
|
|
|
|
/// Linux and the BSDs: reports an `ActionInvoked` signal of a tracked notification and forgets
|
|
/// the notification on `NotificationClosed`. Signals of other notifications are ignored.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
pub(super) fn on_signal(shared: &Shared, signal: &zbus::message::Message) {
|
|
let header = signal.header();
|
|
let body = signal.body();
|
|
let (server_id, action) = match header.member().map(|member| member.as_str()) {
|
|
Some("ActionInvoked") => match body.deserialize::<(u32, String)>() {
|
|
Ok((server_id, action)) => (server_id, action),
|
|
Err(_) => return,
|
|
},
|
|
Some("NotificationClosed") => {
|
|
if let Ok((server_id, _reason)) = body.deserialize::<(u32, u32)>() {
|
|
lock(&shared.tracker.shown).remove(&server_id);
|
|
}
|
|
return;
|
|
}
|
|
_ => return,
|
|
};
|
|
// The entry stays until the server closes the notification, which it may keep showing.
|
|
let data = lock(&shared.tracker.shown)
|
|
.get(&server_id)
|
|
.map(|(_, data)| data.clone());
|
|
if let Some(data) = data {
|
|
let action = if action == "default" { "tap" } else { &action };
|
|
shared.dispatch(performed(action, &data));
|
|
}
|
|
}
|
|
|
|
/// Linux and the BSDs: closes the tracked notifications with the given identifiers, or all of
|
|
/// them. They are forgotten when the server reports them closed.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
pub(super) fn close(shared: &Shared, ids: Option<&[i32]>) {
|
|
let Some(Some(connection)) = shared.tracker.connection.get() else {
|
|
return;
|
|
};
|
|
let server_ids = lock(&shared.tracker.shown)
|
|
.iter()
|
|
.filter(|(_, (id, _))| ids.is_none_or(|ids| ids.contains(id)))
|
|
.map(|(server_id, _)| *server_id)
|
|
.collect::<Vec<_>>();
|
|
let (destination, path, interface) = BUS;
|
|
for server_id in server_ids {
|
|
let _ = connection.call_method(
|
|
Some(destination),
|
|
path,
|
|
Some(interface),
|
|
"CloseNotification",
|
|
&(server_id,),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// macOS and Windows: `notify-rust` cannot remove a shown notification.
|
|
#[cfg(any(windows, target_os = "macos"))]
|
|
pub(super) fn close(_shared: &Shared, _ids: Option<&[i32]>) {}
|
|
|
|
/// macOS and Windows: `notify-rust` sends the notification and blocks until the user acts on
|
|
/// it or it goes away.
|
|
#[cfg(any(windows, target_os = "macos"))]
|
|
fn wait(notification: notify_rust::Notification, shared: Arc<Shared>, data: Value) {
|
|
let Ok(handle) = notification.show() else {
|
|
return;
|
|
};
|
|
let _ = handle.wait_for_response(|response: ¬ify_rust::NotificationResponse| {
|
|
let action = match response {
|
|
notify_rust::NotificationResponse::Default => "tap",
|
|
notify_rust::NotificationResponse::Action(action) => action.as_str(),
|
|
_ => return,
|
|
};
|
|
shared.dispatch(performed(action, &data));
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use super::*;
|
|
use crate::NotificationData;
|
|
|
|
#[test]
|
|
fn a_performed_action_reaches_the_rust_handlers_with_its_notification() {
|
|
let shared = Shared::default();
|
|
assert!(!shared.has_listeners());
|
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
|
let record = Arc::clone(&seen);
|
|
lock(&shared.handlers).push(Arc::new(move |performed: &ActionPerformed| {
|
|
record.lock().unwrap().push((
|
|
performed.action_id().to_owned(),
|
|
performed.notification().map(|n| n.id()),
|
|
performed
|
|
.notification()
|
|
.and_then(|n| n.title().map(str::to_owned)),
|
|
));
|
|
}));
|
|
assert!(shared.has_listeners());
|
|
|
|
let data = NotificationData {
|
|
id: 7,
|
|
title: Some("Standup".into()),
|
|
..Default::default()
|
|
};
|
|
let data = serde_json::to_value(&data).unwrap();
|
|
shared.dispatch(imp::performed("tap", &data));
|
|
shared.dispatch(imp::performed("snooze", &data));
|
|
assert_eq!(
|
|
*seen.lock().unwrap(),
|
|
[
|
|
("tap".to_owned(), Some(7), Some("Standup".to_owned())),
|
|
("snooze".to_owned(), Some(7), Some("Standup".to_owned())),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unreadable_notification_still_reports_the_action() {
|
|
let performed: ActionPerformed = serde_json::from_value(serde_json::json!({
|
|
"actionId": "tap",
|
|
"inputValue": null,
|
|
"notification": { "id": "not a number" },
|
|
}))
|
|
.unwrap();
|
|
assert_eq!(performed.action_id(), "tap");
|
|
assert!(performed.notification().is_none());
|
|
}
|
|
|
|
fn listener(webview: &str, window: &str) -> Listener {
|
|
Listener {
|
|
event: ACTION_PERFORMED.into(),
|
|
channel: Channel::new(|_| Ok(())),
|
|
webview: webview.into(),
|
|
window: window.into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_listeners_of_a_gone_page_stop_tracking() {
|
|
let shared = Shared::default();
|
|
lock(&shared.listeners).extend([listener("main", "main"), listener("side", "other")]);
|
|
shared.retain_listeners(|listener| listener.webview != "main");
|
|
assert!(shared.has_listeners());
|
|
shared.retain_listeners(|listener| listener.window != "other");
|
|
assert!(!shared.has_listeners());
|
|
}
|
|
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
fn signal(
|
|
member: &str,
|
|
body: &(impl serde::Serialize + zbus::zvariant::DynamicType),
|
|
) -> zbus::message::Message {
|
|
let (_, path, interface) = imp::BUS;
|
|
zbus::message::Message::signal(path, interface, member)
|
|
.unwrap()
|
|
.build(body)
|
|
.unwrap()
|
|
}
|
|
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
#[test]
|
|
fn the_signals_of_a_tracked_notification_are_reported_until_it_closes() {
|
|
let shared = Shared::default();
|
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
|
let record = Arc::clone(&seen);
|
|
lock(&shared.handlers).push(Arc::new(move |performed: &ActionPerformed| {
|
|
record
|
|
.lock()
|
|
.unwrap()
|
|
.push(performed.action_id().to_owned());
|
|
}));
|
|
lock(&shared.tracker.shown).insert(41, (7, serde_json::json!({ "id": 7 })));
|
|
|
|
imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "default")));
|
|
imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "snooze")));
|
|
// another notification, maybe of another app
|
|
imp::on_signal(&shared, &signal("ActionInvoked", &(42u32, "default")));
|
|
imp::on_signal(&shared, &signal("NotificationClosed", &(41u32, 2u32)));
|
|
imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "default")));
|
|
|
|
assert_eq!(*seen.lock().unwrap(), ["tap", "snooze"]);
|
|
assert!(lock(&shared.tracker.shown).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_handler_may_register_another_handler() {
|
|
let shared = Arc::new(Shared::default());
|
|
let inner = Arc::clone(&shared);
|
|
lock(&shared.handlers).push(Arc::new(move |_: &ActionPerformed| {
|
|
lock(&inner.handlers).push(Arc::new(|_: &ActionPerformed| {}));
|
|
}));
|
|
shared.dispatch(imp::performed("tap", &Value::Null));
|
|
assert_eq!(lock(&shared.handlers).len(), 2);
|
|
}
|
|
}
|