Fix desktop notifications on GNOME 46+ Linux (#1246)

Signed-off-by: benthecarman <benthecarman@live.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
benthecarman
2026-06-26 11:56:54 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c6e3e947ab
commit ca50d83289
7 changed files with 170 additions and 5 deletions
+1
View File
@@ -871,6 +871,7 @@ dependencies = [
"mesh-llm-sdk",
"neteq",
"nostr",
"notify-rust",
"objc2-app-kit",
"opus",
"png 0.18.1",
+8 -3
View File
@@ -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 }
@@ -41,9 +49,6 @@ security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
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"
+2
View File
@@ -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::*;
@@ -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<String>,
target: Option<serde_json::Value>,
) -> 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<String>,
target: Option<serde_json::Value>,
) {
// 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);
});
});
}
}
+1
View File
@@ -802,6 +802,7 @@ pub fn run() {
add_reaction,
remove_reaction,
get_event,
show_native_notification,
upload_media,
pick_and_upload_media,
upload_media_bytes,
@@ -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<void> } | 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<unknown>(
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,
+11
View File
@@ -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)