From 9b30e6e3ef0ab181508328a048947d222cfc1a7c Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 24 Jun 2026 14:07:58 -0500 Subject: [PATCH] Fix desktop notifications on GNOME 46+ Linux The app posts every desktop notification through the Web Notification constructor, which tauri-plugin-notification routes to its `notify` command. That command calls notify-rust's `show()` and then drops the returned handle immediately. The handle owns the D-Bus connection used to post the notification, and on GNOME 46+ (Ubuntu 24.04, Fedora 41+) tearing that connection down dismisses the notification the instant it appears, so nothing is ever seen. macOS uses a different backend and is unaffected. Add a Linux-only `show_native_notification` command that posts from a dedicated thread and holds the connection open via `wait_for_action` until the notification is closed. The same wait surfaces the default click action, which is forwarded to the frontend so clicking focuses the window and routes to the notification target. The web client now sends through this command on Linux and keeps the plugin path on macOS and Windows. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: benthecarman --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 11 +- desktop/src-tauri/src/commands/mod.rs | 2 + .../src-tauri/src/commands/notifications.rs | 103 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + .../src/features/notifications/lib/desktop.ts | 46 +++++++- desktop/src/shared/lib/platform.ts | 11 ++ 7 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 desktop/src-tauri/src/commands/notifications.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 32ce245a3..cf1e8af8e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -871,6 +871,7 @@ dependencies = [ "mesh-llm-sdk", "neteq", "nostr", + "notify-rust", "objc2-app-kit", "opus", "png 0.18.1", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a500b70df..245ce9371 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -32,6 +32,14 @@ tauri-build = { version = "2", features = [] } libc = "0.2" ctrlc = { version = "3", features = ["termination"] } +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "vendored"], optional = true } +# Used directly (alongside tauri-plugin-notification) so we can hold the posting +# D-Bus connection open. GNOME 46+ dismisses a notification the moment that +# connection is dropped, which the plugin does immediately. Default features +# keep the pure-Rust zbus backend, matching the plugin (no libdbus needed). +notify-rust = "4" + [target.'cfg(target_os = "macos")'.dependencies] objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } @@ -40,9 +48,6 @@ keyring = { version = "3.6.3", default-features = false, features = ["apple-nati windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Foundation"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } -[target.'cfg(target_os = "linux")'.dependencies] -keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "vendored"], optional = true } - [dependencies] atomic-write-file = "0.3" anyhow = "1" diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a8bce1081..e8a2756eb 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -17,6 +17,7 @@ mod media_transcode; #[cfg(feature = "mesh-llm")] mod mesh_llm; mod messages; +mod notifications; pub mod pairing; mod personas; mod prevent_sleep; @@ -45,6 +46,7 @@ pub use media_download::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; pub use messages::*; +pub use notifications::*; pub use pairing::*; pub use personas::*; pub use prevent_sleep::*; diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs new file mode 100644 index 000000000..c13d96ff6 --- /dev/null +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -0,0 +1,103 @@ +//! Native (Linux) desktop-notification helper. +//! +//! `tauri-plugin-notification` posts a notification by calling `notify_rust`'s +//! `show()` and then immediately dropping the returned `NotificationHandle`. +//! That handle owns the D-Bus connection used to post the notification, and on +//! GNOME 46+ (Ubuntu 24.04+, Fedora 41+) tearing that connection down dismisses +//! the notification the instant it appears — so notifications never show. +//! See tauri-apps/plugins-workspace#2566 and hoodie/notify-rust#218. +//! +//! We side-step the plugin on Linux by posting the notification from a +//! dedicated thread that holds the connection open (via `wait_for_action`) +//! until the notification is closed. The same wait surfaces the default click +//! action, which we forward to the frontend so it can focus the window and +//! route to the notification target. + +/// Show a desktop notification natively. +/// +/// On Linux this uses the connection-preserving path described above. On other +/// platforms the bundled notification plugin already works correctly, so the +/// frontend never calls this and we simply report that it is unused. +#[tauri::command] +pub fn show_native_notification( + app: tauri::AppHandle, + title: String, + body: Option, + target: Option, +) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + linux::show(app, title, body, target); + Ok(()) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = (&app, &title, &body, &target); + Err("show_native_notification is only supported on Linux".to_string()) + } +} + +#[cfg(target_os = "linux")] +mod linux { + use tauri::Emitter; + + /// Emitted to the frontend when the user clicks a native notification. The + /// payload is the opaque target object the frontend passed in. + const ACTIVATE_EVENT: &str = "native-notification-activated"; + + pub fn show( + app: tauri::AppHandle, + title: String, + body: Option, + target: Option, + ) { + // notify_rust's `show()` blocks on D-Bus and the returned handle must + // outlive the notification, so this runs on its own thread rather than + // the async runtime. + std::thread::spawn(move || { + let mut builder = notify_rust::Notification::new(); + builder.summary(&title); + if let Some(body) = body.as_deref() { + builder.body(body); + } + if let Some(name) = app.config().product_name.clone() { + builder.appname(&name); + } + // Tie the notification to the installed desktop entry so GNOME shows + // the app's name and icon and groups our notifications together. + builder.hint(notify_rust::Hint::DesktopEntry( + app.config().identifier.clone(), + )); + builder.auto_icon(); + // Match the silent posting used on other platforms; the app does its + // own unread cues and a per-message sound would be noisy. + builder.hint(notify_rust::Hint::SuppressSound(true)); + // Declaring a default action makes the whole notification clickable. + builder.action("default", "Open"); + + let handle = match builder.show() { + Ok(handle) => handle, + Err(error) => { + eprintln!("buzz-desktop: failed to post native notification: {error}"); + return; + } + }; + + // Block until the notification is actioned or closed. Holding the + // handle keeps its D-Bus connection alive, which is what stops + // GNOME 46+ from dismissing the notification immediately. The wait + // also returns when the notification expires or is dismissed, so + // the thread does not leak. + handle.wait_for_action(|action| { + if action != "default" { + return; + } + + // The frontend focuses the window on activation (the same path + // every other platform uses), so we only forward the target. + let _ = app.emit(ACTIVATE_EVENT, target); + }); + }); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 55fa38a5a..5120365cd 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -765,6 +765,7 @@ pub fn run() { add_reaction, remove_reaction, get_event, + show_native_notification, upload_media, pick_and_upload_media, upload_media_bytes, diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index e7c73350a..380521e0f 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -1,11 +1,16 @@ -import { isTauri } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { UserAttentionType, getCurrentWindow } from "@tauri-apps/api/window"; import { isPermissionGranted, onAction, requestPermission, } from "@tauri-apps/plugin-notification"; -import { isMacPlatform } from "@/shared/lib/platform"; +import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; + +// Backend event emitted when the user clicks a native (Linux) notification. +// See src-tauri/src/commands/notifications.rs. +const NATIVE_NOTIFICATION_ACTIVATED_EVENT = "native-notification-activated"; export type DesktopNotificationPermissionState = | NotificationPermission @@ -172,6 +177,7 @@ export async function listenForDesktopNotificationActions( ); let pluginListener: { unregister: () => Promise } | null = null; + let nativeUnlisten: (() => void) | null = null; if (isTauri()) { try { @@ -188,6 +194,24 @@ export async function listenForDesktopNotificationActions( } catch { pluginListener = null; } + + // Clicks on Linux notifications come back via a backend event rather than + // the plugin's onAction (whose connection is torn down before it can fire). + try { + nativeUnlisten = await listen( + NATIVE_NOTIFICATION_ACTIVATED_EVENT, + (event) => { + const target = parseNotificationTarget(event.payload); + if (!target) { + return; + } + + dispatchDesktopNotificationTarget(target); + }, + ); + } catch { + nativeUnlisten = null; + } } return () => { @@ -196,6 +220,7 @@ export async function listenForDesktopNotificationActions( handleNotificationAction, ); void pluginListener?.unregister(); + nativeUnlisten?.(); }; } @@ -268,6 +293,23 @@ export async function sendDesktopNotification( return false; } + // On Linux the bundled notification plugin posts via a D-Bus connection that + // it drops immediately; GNOME 46+ then dismisses the notification before it + // is seen. Route through a backend command that keeps the connection alive. + // See src-tauri/src/commands/notifications.rs. + if (isTauri() && isLinuxPlatform()) { + try { + await invoke("show_native_notification", { + title: payload.title, + body: payload.body, + target: payload.target ?? null, + }); + return true; + } catch { + return false; + } + } + const notification = new window.Notification(payload.title, { body: payload.body, silent: true, diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts index 759cd9818..42e7f5b94 100644 --- a/desktop/src/shared/lib/platform.ts +++ b/desktop/src/shared/lib/platform.ts @@ -12,6 +12,17 @@ export function isMacPlatform(): boolean { return /mac|iphone|ipad|ipod/i.test(navigator.platform); } +/** Returns true on Linux desktops (excludes Android). */ +export function isLinuxPlatform(): boolean { + if (typeof navigator === "undefined") { + return false; + } + + return ( + /linux/i.test(navigator.platform) && !/android/i.test(navigator.userAgent) + ); +} + /** * The platform's normal application-shortcut modifier: * - macOS: Command (Meta)