mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-10 22:38:44 +02:00
feat(notification): report actions on desktop (#3671)
* 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>
This commit is contained in:
10 files changed
+868
-108
No files matched your search
@@ -37,3 +37,59 @@ pub(crate) async fn notify<R: Runtime>(
|
||||
builder.data = options;
|
||||
builder.show()
|
||||
}
|
||||
|
||||
// The commands below are implemented by the Kotlin and Swift plugins on mobile.
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[command]
|
||||
pub(crate) async fn register_action_types<R: Runtime>(
|
||||
_app: AppHandle<R>,
|
||||
notification: State<'_, Notification<R>>,
|
||||
types: Vec<crate::ActionType>,
|
||||
) -> Result<()> {
|
||||
notification.register_action_types(types)
|
||||
}
|
||||
|
||||
/// A notification of `remove_active`, as the JavaScript API sends it.
|
||||
#[cfg(desktop)]
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct ActiveId {
|
||||
id: i32,
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[command]
|
||||
pub(crate) async fn remove_active<R: Runtime>(
|
||||
_app: AppHandle<R>,
|
||||
notification: State<'_, Notification<R>>,
|
||||
notifications: Option<Vec<ActiveId>>,
|
||||
) -> Result<()> {
|
||||
match notifications {
|
||||
Some(notifications) => {
|
||||
notification.remove_active(notifications.into_iter().map(|n| n.id).collect())
|
||||
}
|
||||
None => notification.remove_all_active(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[command]
|
||||
pub(crate) fn register_listener<R: Runtime>(
|
||||
webview: tauri::Webview<R>,
|
||||
notification: State<'_, Notification<R>>,
|
||||
event: String,
|
||||
handler: tauri::ipc::Channel<serde_json::Value>,
|
||||
) {
|
||||
notification.register_listener(event, handler, &webview);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[command]
|
||||
pub(crate) fn remove_listener<R: Runtime>(
|
||||
_app: AppHandle<R>,
|
||||
notification: State<'_, Notification<R>>,
|
||||
event: String,
|
||||
channel_id: u32,
|
||||
) {
|
||||
notification.remove_listener(&event, channel_id);
|
||||
}
|
||||
@@ -2,33 +2,115 @@
|
||||
// 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, Runtime,
|
||||
AppHandle, Manager, Runtime,
|
||||
ipc::Channel,
|
||||
plugin::{PermissionState, PluginApi},
|
||||
};
|
||||
|
||||
use crate::NotificationBuilder;
|
||||
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.clone()))
|
||||
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>(AppHandle<R>);
|
||||
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 and sound of the notification are used on desktop;
|
||||
/// the scheduling, grouping and action related options are ignored.
|
||||
/// 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.
|
||||
///
|
||||
@@ -37,25 +119,43 @@ impl<R: Runtime> crate::NotificationBuilder<R> {
|
||||
/// 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 {
|
||||
if let Some(body) = self.data.body.clone() {
|
||||
notification = notification.body(body);
|
||||
}
|
||||
if let Some(icon) = self.data.icon {
|
||||
if let Some(icon) = self.data.icon.clone() {
|
||||
notification = notification.icon(icon);
|
||||
}
|
||||
if let Some(sound) = self.data.sound {
|
||||
if let Some(sound) = self.data.sound.clone() {
|
||||
notification = notification.sound(sound);
|
||||
}
|
||||
notification.show()?;
|
||||
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(())
|
||||
}
|
||||
@@ -79,7 +179,7 @@ impl<R: Runtime> Notification<R> {
|
||||
/// }
|
||||
/// ```
|
||||
pub fn builder(&self) -> NotificationBuilder<R> {
|
||||
NotificationBuilder::new(self.0.clone())
|
||||
NotificationBuilder::new(self.app.clone())
|
||||
}
|
||||
|
||||
/// Requests the permission to send notifications.
|
||||
@@ -97,6 +197,84 @@ impl<R: Runtime> Notification<R> {
|
||||
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 {
|
||||
@@ -104,6 +282,18 @@ mod imp {
|
||||
|
||||
#[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.
|
||||
///
|
||||
@@ -141,6 +331,30 @@ mod imp {
|
||||
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 {
|
||||
@@ -180,6 +394,13 @@ mod imp {
|
||||
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
|
||||
@@ -201,7 +422,54 @@ mod imp {
|
||||
/// .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);
|
||||
}
|
||||
@@ -237,11 +505,7 @@ mod imp {
|
||||
});
|
||||
}
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ = notification.show();
|
||||
});
|
||||
|
||||
Ok(())
|
||||
Ok(notification)
|
||||
}
|
||||
|
||||
/// Shows the notification. Same as [`Self::show`].
|
||||
@@ -253,4 +517,299 @@ mod imp {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -243,12 +243,45 @@ impl<R: Runtime, T: Manager<R>> crate::NotificationExt<R> for T {
|
||||
|
||||
/// Initializes the plugin.
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("notification")
|
||||
let builder = Builder::new("notification");
|
||||
// On mobile the other commands are implemented by the Kotlin and Swift plugins.
|
||||
#[cfg(mobile)]
|
||||
let builder = builder.invoke_handler(tauri::generate_handler![
|
||||
commands::notify,
|
||||
commands::request_permission,
|
||||
commands::is_permission_granted
|
||||
]);
|
||||
#[cfg(desktop)]
|
||||
let builder = builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::notify,
|
||||
commands::request_permission,
|
||||
commands::is_permission_granted
|
||||
commands::is_permission_granted,
|
||||
commands::register_action_types,
|
||||
commands::remove_active,
|
||||
commands::register_listener,
|
||||
commands::remove_listener
|
||||
])
|
||||
// the listeners of a page are gone once it navigates away or its window is destroyed
|
||||
.on_page_load(|webview, payload| {
|
||||
if payload.event() == tauri::webview::PageLoadEvent::Started
|
||||
&& let Some(notification) = webview.try_state::<Notification<R>>()
|
||||
{
|
||||
notification.remove_webview_listeners(webview.label());
|
||||
}
|
||||
})
|
||||
.on_event(|app, event| {
|
||||
if let tauri::RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
..
|
||||
} = event
|
||||
&& let Some(notification) = app.try_state::<Notification<R>>()
|
||||
{
|
||||
notification.remove_window_listeners(label);
|
||||
}
|
||||
});
|
||||
builder
|
||||
.js_init_script(include_str!("init-iife.js").replace(
|
||||
"__TEMPLATE_windows__",
|
||||
if cfg!(windows) { "true" } else { "false" },
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use tauri::{
|
||||
AppHandle, Runtime,
|
||||
ipc::{Channel as IpcChannel, InvokeResponseBody},
|
||||
plugin::{PermissionState, PluginApi, PluginHandle},
|
||||
};
|
||||
|
||||
@@ -105,6 +106,40 @@ impl<R: Runtime> Notification<R> {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Calls `handler` for every action the user performs on a notification of this app:
|
||||
/// a tap on the notification itself (`tap`) or on one of its actions.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the listener could not be registered with the mobile plugin.
|
||||
pub fn on_action<F: Fn(&ActionPerformed) + Send + Sync + 'static>(
|
||||
&self,
|
||||
handler: F,
|
||||
) -> crate::Result<()> {
|
||||
#[derive(serde::Serialize)]
|
||||
struct RegisterListener {
|
||||
event: &'static str,
|
||||
handler: IpcChannel,
|
||||
}
|
||||
let channel = IpcChannel::new(move |body| {
|
||||
if let InvokeResponseBody::Json(payload) = body
|
||||
&& let Ok(performed) = serde_json::from_str::<ActionPerformed>(&payload)
|
||||
{
|
||||
handler(&performed);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
self.0
|
||||
.run_mobile_plugin::<()>(
|
||||
"registerListener",
|
||||
RegisterListener {
|
||||
event: "actionPerformed",
|
||||
handler: channel,
|
||||
},
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Removes the delivered notifications with the given identifiers from the notification center.
|
||||
///
|
||||
/// Use [`Self::remove_all_active`] to remove every delivered notification.
|
||||
|
||||
@@ -11,7 +11,7 @@ use url::Url;
|
||||
/// A media file attached to a notification.
|
||||
///
|
||||
/// Attachments are only used on mobile; desktop notifications ignore them.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Attachment {
|
||||
id: String,
|
||||
@@ -31,7 +31,7 @@ impl Attachment {
|
||||
///
|
||||
/// Fields left as [`None`] match any value, so the notification fires on every date
|
||||
/// whose remaining components match. Used by [`Schedule::Interval`].
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScheduleInterval {
|
||||
/// The year the notification fires on.
|
||||
@@ -55,7 +55,7 @@ pub struct ScheduleInterval {
|
||||
/// The unit of the repeating interval used by [`Schedule::Every`].
|
||||
///
|
||||
/// It is serialized as its lowercase camelCase name, e.g. `twoWeeks`.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ScheduleEvery {
|
||||
/// Repeats every year.
|
||||
///
|
||||
@@ -133,7 +133,7 @@ impl<'de> Deserialize<'de> for ScheduleEvery {
|
||||
///
|
||||
/// Scheduling is only implemented on mobile; the desktop implementation delivers the
|
||||
/// notification immediately and ignores the schedule.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Schedule {
|
||||
/// Fires at a specific date and time, which must be in the future.
|
||||
@@ -315,7 +315,7 @@ impl PendingNotification {
|
||||
/// Returned by `Notification::active`, which is only available on mobile.
|
||||
/// Which fields are populated depends on the platform, since Android and iOS expose
|
||||
/// different information about delivered notifications.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActiveNotification {
|
||||
id: i32,
|
||||
@@ -416,30 +416,31 @@ impl ActiveNotification {
|
||||
/// [`NotificationBuilder::action_type_id`](crate::NotificationBuilder::action_type_id).
|
||||
///
|
||||
/// Register it with `Notification::register_action_types` before sending a notification that uses it.
|
||||
/// It maps to a `UNNotificationCategory` on iOS and to an action group on Android.
|
||||
/// It maps to a `UNNotificationCategory` on iOS, to an action group on Android and to the
|
||||
/// buttons of the notification on desktop.
|
||||
///
|
||||
/// Only available on mobile. Use [`ActionType::builder`] to construct one.
|
||||
#[cfg(mobile)]
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Use [`ActionType::builder`] to construct one.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionType {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
actions: Vec<Action>,
|
||||
hidden_previews_body_placeholder: Option<String>,
|
||||
#[serde(default)]
|
||||
custom_dismiss_action: bool,
|
||||
#[serde(default)]
|
||||
allow_in_car_play: bool,
|
||||
#[serde(default)]
|
||||
hidden_previews_show_title: bool,
|
||||
#[serde(default)]
|
||||
hidden_previews_show_subtitle: bool,
|
||||
}
|
||||
|
||||
/// Builder for an [`ActionType`], created with [`ActionType::builder`].
|
||||
///
|
||||
/// Only available on mobile.
|
||||
#[cfg(mobile)]
|
||||
#[derive(Debug)]
|
||||
pub struct ActionTypeBuilder(ActionType);
|
||||
|
||||
#[cfg(mobile)]
|
||||
impl ActionType {
|
||||
/// Creates a builder for an action type with the given identifier.
|
||||
///
|
||||
@@ -503,7 +504,6 @@ impl ActionType {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
impl ActionTypeBuilder {
|
||||
/// Sets the actions associated with this action type.
|
||||
pub fn actions(mut self, actions: Vec<Action>) -> Self {
|
||||
@@ -565,31 +565,30 @@ impl ActionTypeBuilder {
|
||||
/// A button the user can tap on a notification, belonging to an [`ActionType`].
|
||||
///
|
||||
/// It maps to a `UNNotificationAction` on iOS. On Android only the identifier, the title
|
||||
/// and the input flag are used.
|
||||
/// and the input flag are used, on desktop only the identifier and the title.
|
||||
///
|
||||
/// Only available on mobile. Use [`Action::builder`] to construct one.
|
||||
#[cfg(mobile)]
|
||||
#[derive(Debug, Serialize)]
|
||||
/// Use [`Action::builder`] to construct one.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Action {
|
||||
id: String,
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
requires_authentication: bool,
|
||||
#[serde(default)]
|
||||
foreground: bool,
|
||||
#[serde(default)]
|
||||
destructive: bool,
|
||||
#[serde(default)]
|
||||
input: bool,
|
||||
input_button_title: Option<String>,
|
||||
input_placeholder: Option<String>,
|
||||
}
|
||||
|
||||
/// Builder for an [`Action`], created with [`Action::builder`].
|
||||
///
|
||||
/// Only available on mobile.
|
||||
#[cfg(mobile)]
|
||||
#[derive(Debug)]
|
||||
pub struct ActionBuilder(Action);
|
||||
|
||||
#[cfg(mobile)]
|
||||
impl Action {
|
||||
/// Creates a builder for an action with the given identifier and button title.
|
||||
///
|
||||
@@ -659,7 +658,6 @@ impl Action {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
impl ActionBuilder {
|
||||
/// Sets whether the device must be unlocked for the action to run.
|
||||
///
|
||||
@@ -713,6 +711,47 @@ impl ActionBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// An action the user performed on a notification, delivered to the handlers of
|
||||
/// `Notification::on_action` and to the `onAction` listeners of the JavaScript API.
|
||||
///
|
||||
/// On desktop it is emitted when the user clicks the notification body (`actionId` = `tap`) or
|
||||
/// one of the actions of its [`ActionType`], as far as the notification server reports it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionPerformed {
|
||||
action_id: String,
|
||||
input_value: Option<String>,
|
||||
/// Read leniently: the platforms describe the notification differently, and an action is
|
||||
/// still worth reporting when its notification cannot be read.
|
||||
#[serde(default, deserialize_with = "lenient_notification")]
|
||||
notification: Option<ActiveNotification>,
|
||||
}
|
||||
|
||||
fn lenient_notification<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<ActiveNotification>, D::Error> {
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
Ok(serde_json::from_value(value).ok())
|
||||
}
|
||||
|
||||
impl ActionPerformed {
|
||||
/// The identifier of the performed action: `tap` for a click on the notification itself,
|
||||
/// `dismiss` for a dismissal reported on iOS, otherwise the identifier of the [`Action`].
|
||||
pub fn action_id(&self) -> &str {
|
||||
&self.action_id
|
||||
}
|
||||
|
||||
/// The text the user typed, for an [`Action`] with an input field (mobile only).
|
||||
pub fn input_value(&self) -> Option<&str> {
|
||||
self.input_value.as_deref()
|
||||
}
|
||||
|
||||
/// The notification the action was performed on, as far as the platform reports it.
|
||||
pub fn notification(&self) -> Option<&ActiveNotification> {
|
||||
self.notification.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::*;
|
||||
|
||||
|
||||
Reference in new issue
Block a user