Skip to content

feat(notification): report actions on desktop - #3671

Merged
lucasfernog merged 6 commits into
tauri-apps:v2from
haexmas:feat/notification-desktop-actions-upstream
Oct 8, 2026
Merged

lucasfernog merged 6 commits into
tauri-apps:v2from
haexmas:feat/notification-desktop-actions-upstream

Conversation

@haexmas

@haexmas haexmas commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Closes #2150
Closes #1903

Problem

On desktop, a click on a notification never reaches the app: onAction is documented as mobile only, and registerActionTypes, removeActive and the listener commands fail with Command ... not found (#1903) because only notify, request_permission and is_permission_granted are registered there.

Change

  • onAction on desktop. A click on the notification arrives as actionId: "tap". A click on one of the buttons of its action type arrives as that action's id. The payload is the same as on mobile ({ actionId, inputValue: null, notification }).
    • Linux and the BSDs: from the server's ActionInvoked signal (notify_rust::handle_action).
    • Windows and macOS: from NotificationHandle::wait_for_response. This is why notify-rust goes from 4.11 to 4.18.
  • registerActionTypes on desktop. The actions of a type become the buttons of the notifications that reference it via actionTypeId. Only id and title are used.
  • removeActive on desktop. It closes the notification on Linux and the BSDs. It does nothing on Windows and macOS, where notify-rust cannot remove a shown notification. This is documented.
  • New Rust API Notification::on_action(handler) on every platform (desktop and mobile), so the Rust side can react to clicks without a webview listener.
  • Only notifications shown while a handler or listener exists report actions. This keeps notifications from apps that don't listen exactly as they are today, with no extra thread per notification.
  • The api example sends a notification with two actions and shows the action it receives.

How to test

examples/api, Notifications tab, "Send notification with actions":

  • Click the notification body: the app shows Action "tap" on notification <id>.
  • Click "Open" or "Later": the app shows that action's id.

CI of the fork (all 157 checks, Linux, Windows, macOS, Android and iOS) is green: https://github.com/haexmas/plugins-workspace/pull/2

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.
@haexmas
haexmas requested a review from a team as a code owner October 7, 2026 06:57
@socket-security

socket-security Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedcargo/​notify-rust@​4.11.7 ⏵ 4.18.19810093100100

View full report

@haexmas haexmas changed the title Feat/notification desktop actions upstream feat(notification): report actions on desktop Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Package Changes Through e0a1bec

There are 2 changes which include notification with minor, notification-js with minor

Planned Package Versions

The following package releases are the planned based on the context of changes in this pull request.

package current next
api-example 2.0.54 2.0.55
api-example-js 2.0.50 2.0.51
notification 2.5.1 2.6.0
notification-js 2.5.1 2.6.0

Add another change file through the GitHub UI by following this link.


Read about change files or the docs at github.com/jbolda/covector

*/
async function onAction(
cb: (notification: Options) => void
cb: (event: ActionPerformed) => void

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For anyone wondering like me, no this is not a breaking change but a bugfix. This is the payload we actually sent on mobile all the time, not Options...

@haexmas

haexmas commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

The windows e2e job is crashed. This seems like a flake. Could you please restart the job?

@lucasfernog

Copy link
Copy Markdown
Member

there's a flake indeed in the windows plugins build..

@lucasfernog
lucasfernog merged commit 4638151 into tauri-apps:v2 Oct 8, 2026
159 checks passed
Comment on lines +523 to +671
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;
};
let server_id = handle.id();
lock(&shared.active).insert(id, Shown(handle));
let _ = notify_rust::handle_action(server_id, |response| {
// A later notification with the same id may have taken the entry meanwhile.
let mut active = lock(&shared.active);
if active
.get(&id)
.is_some_and(|shown| shown.0.id() == server_id)
{
active.remove(&id);
// 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;
}
drop(active);
let action = match response {
notify_rust::ActionResponse::Custom("default") => "tap",
notify_rust::ActionResponse::Custom(action) => action,
notify_rust::ActionResponse::Closed(_) => 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,),
);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's going on here? do i understand it correctly that notify-rust isn't working on linux here? shouldn't this be an upstream change then?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] Notification onclick event [notification] registerActionTypes fails with Command register_action_types not found

4 participants