fix(desktop): quit on close on Linux desktops without a tray host (#14691)

* fix(desktop): quit on close on Linux desktops without a tray host

Closing the main window only hid it, and the tray Quit item was the only
way to quit. On Linux the tray is a StatusNotifierItem that needs a
StatusNotifier host (KDE, XFCE, GNOME with the AppIndicator extension);
stock GNOME (Fedora Workstation, Debian) has none, so closing left the app
running with no icon and no way to quit except kill. Tray creation cannot
detect this: tray-icon's GTK backend registers the indicator and returns Ok
whether or not anything is listening.

Probe the session bus instead: org.kde.StatusNotifierWatcher's
IsStatusNotifierHostRegistered property, read with zbus (already in the
graph via notify-rust) off the main thread with a 250 ms timeout. No
session bus, no watcher, a false property, or a timeout all mean "no
host", and then close quits through the same app.exit(0) path as the tray
Quit item so the Exit handler stops the sidecar. The probe runs at close
time so a host that appears or disappears mid-session is honored. macOS,
Windows, and Linux with a host keep hiding to the tray.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(desktop): keep hiding to the tray when the host probe times out

A missing session bus or missing watcher is rejected immediately by the
bus daemon, so the 250 ms timeout is only ever reached when a watcher
exists but is slow to answer (a busy panel or shell). Treating that as
"no host" would quit and end running sessions on a desktop that does have
a tray; fall back to the existing hide behavior instead.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-10-23 18:50:20 -07:00
committed by GitHub
co-authored by Saoud Rizwan
parent 1e39ae2f2d
commit 4069b85c0a
5 changed files with 133 additions and 1 deletions
+2 -1
View File
@@ -242,7 +242,8 @@ The app installs as `/usr/bin/cline-app` with a `Cline` launcher entry; the
bundled Cline CLI is `/usr/bin/cline-cli`, and the desktop backend bundle and
SSH remote CLIs live in `/usr/lib/Cline/`. The tray icon needs a StatusNotifier host (KDE, XFCE, and
GNOME with the AppIndicator extension); without one the app still runs but the
tray menu is unavailable. There is no AppImage: linuxdeploy cannot process the
tray menu is unavailable, and closing the window quits the app instead of
hiding it to the tray. There is no AppImage: linuxdeploy cannot process the
Bun-compiled Cline CLI (`ldd` fails on it and `patchelf` corrupts it), so the
AppImage target is excluded from `tauri build` on Linux. A Linux desktop
cannot use a Mac as an SSH remote host (see the changelog).
+1
View File
@@ -506,6 +506,7 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"zbus",
]
[[package]]
@@ -22,6 +22,11 @@ tokio = { version = "1", features = ["sync", "time"] }
# feature is a no-op on macOS and Windows.
rfd = { version = "0.15", default-features = false, features = ["gtk3"] }
[target."cfg(target_os = \"linux\")".dependencies]
# Already in the graph through notify-rust; used to ask the session bus
# whether a StatusNotifier host exists (see linux_tray.rs).
zbus = "5"
[target."cfg(target_os = \"macos\")".dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent", "NSImage", "NSMenu", "NSMenuItem", "NSResponder"] }
@@ -0,0 +1,114 @@
//! Closing the main window hides it to the tray, and the tray's Quit item is
//! the only way to quit. On Linux the tray is a StatusNotifierItem, which is
//! only shown by a StatusNotifier host (KDE, XFCE, GNOME with the AppIndicator
//! extension). Stock GNOME (Fedora Workstation, Debian) has no host, so
//! closing the window left the app running with no icon and no way to quit
//! but `kill`. Creating the tray icon cannot detect this: tray-icon's GTK
//! backend only registers the indicator and reports success whether or not
//! anything is listening. What can is the session bus: a host announces
//! itself to `org.kde.StatusNotifierWatcher`, which exposes it as the
//! `IsStatusNotifierHostRegistered` property.
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
const WATCHER_BUS_NAME: &str = "org.kde.StatusNotifierWatcher";
const WATCHER_OBJECT_PATH: &str = "/StatusNotifierWatcher";
const WATCHER_INTERFACE: &str = "org.kde.StatusNotifierWatcher";
const HOST_REGISTERED_PROPERTY: &str = "IsStatusNotifierHostRegistered";
/// The probe runs on the GTK main thread from the close handler, so it must
/// not stall it: a healthy bus answers one property read in a few
/// milliseconds.
const PROBE_TIMEOUT: Duration = Duration::from_millis(250);
/// Whether a StatusNotifier host is registered on the session bus right now.
///
/// Probed at close time rather than once at startup so that a host that
/// appears mid-session (the user enables the GNOME extension) is honored, and
/// one that disappears (the panel crashes) is too. No session bus, no
/// watcher on it, or a `false` property all mean "no host".
pub fn status_notifier_host_registered() -> bool {
host_registered_within(read_host_registered_property, PROBE_TIMEOUT)
}
fn read_host_registered_property() -> Result<bool, String> {
let connection = zbus::blocking::Connection::session().map_err(|error| error.to_string())?;
// A single read: skip the property cache, which would issue a GetAll and
// subscribe to PropertiesChanged for a proxy that is dropped right away.
let proxy = zbus::blocking::proxy::Builder::<zbus::blocking::Proxy<'_>>::new(&connection)
.destination(WATCHER_BUS_NAME)
.and_then(|builder| builder.path(WATCHER_OBJECT_PATH))
.and_then(|builder| builder.interface(WATCHER_INTERFACE))
.map_err(|error| error.to_string())?
.cache_properties(zbus::proxy::CacheProperties::No)
.build()
.map_err(|error| error.to_string())?;
proxy
.get_property::<bool>(HOST_REGISTERED_PROPERTY)
.map_err(|error| error.to_string())
}
/// Runs `probe` off the calling thread and decides from whatever it reports
/// within `timeout`. A probe that overruns is left to finish on its own and
/// counts as a host being present: a missing bus or missing watcher is
/// rejected immediately by the bus daemon itself, so only a watcher that is
/// slow to answer (a busy panel or shell) ever reaches the timeout, and
/// quitting there would end running sessions on a desktop that has a tray.
fn host_registered_within(
probe: impl FnOnce() -> Result<bool, String> + Send + 'static,
timeout: Duration,
) -> bool {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let _ = tx.send(probe());
});
match rx.recv_timeout(timeout) {
Ok(Ok(registered)) => registered,
Ok(Err(reason)) => {
eprintln!("[tray] no StatusNotifier host: {reason}");
false
}
Err(_) => {
eprintln!("[tray] StatusNotifier host probe timed out; keeping the window in the tray");
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registered_host_is_reported() {
assert!(host_registered_within(|| Ok(true), Duration::from_secs(1)));
}
#[test]
fn watcher_without_a_host_means_no_host() {
assert!(!host_registered_within(
|| Ok(false),
Duration::from_secs(1)
));
}
#[test]
fn missing_bus_or_watcher_means_no_host() {
assert!(!host_registered_within(
|| Err("Connection refused".to_string()),
Duration::from_secs(1)
));
}
#[test]
fn slow_probe_keeps_the_tray_behavior() {
assert!(host_registered_within(
|| {
thread::sleep(Duration::from_secs(5));
Ok(true)
},
Duration::from_millis(50)
));
}
}
@@ -1,5 +1,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[cfg(target_os = "linux")]
mod linux_tray;
#[cfg(target_os = "linux")]
mod linux_webview;
#[cfg(target_os = "macos")]
@@ -1685,6 +1687,15 @@ fn main() {
if window.label() == MAIN_WINDOW_LABEL {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
// Without a tray (Linux with no StatusNotifier host, e.g.
// stock GNOME) nothing could bring a hidden window back or
// quit the app, so close quits instead, through the same
// exit path as the tray's Quit item.
#[cfg(target_os = "linux")]
if !linux_tray::status_notifier_host_registered() {
window.app_handle().exit(0);
return;
}
hide_main_window(window);
}
}