mirror of
https://github.com/cline/cline.git
synced 2026-10-25 10:26:36 +08:00
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:
co-authored by
Saoud Rizwan
parent
1e39ae2f2d
commit
4069b85c0a
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user