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:
authored and GitHub committed 2026-10-08 14:14:19 -03:00
1 parent 116f7f8239
commit 46381516b7
10 files changed
+868 -108

No files matched your search

+56
View File
@@ -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);
}
+575 -16
View File
@@ -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(&notifications));
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, &notification) {
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: &notify_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",
&(
&notification.appname,
0u32,
&notification.icon,
&notification.summary,
&notification.body,
&notification.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: &notify_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);
}
}
+35 -2
View File
@@ -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" },
+35
View File
@@ -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.
+62 -23
View File
@@ -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::*;