Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 8 additions & 3 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::*;
Expand Down
103 changes: 103 additions & 0 deletions desktop/src-tauri/src/commands/notifications.rs
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 44 additions & 2 deletions desktop/src/features/notifications/lib/desktop.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -172,6 +177,7 @@ export async function listenForDesktopNotificationActions(
);

let pluginListener: { unregister: () => Promise<void> } | null = null;
let nativeUnlisten: (() => void) | null = null;

if (isTauri()) {
try {
Expand All @@ -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 () => {
Expand All @@ -196,6 +220,7 @@ export async function listenForDesktopNotificationActions(
handleNotificationAction,
);
void pluginListener?.unregister();
nativeUnlisten?.();
};
}

Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/shared/lib/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down