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)