diff --git a/apps/examples/desktop-app/README.md b/apps/examples/desktop-app/README.md index 874cdbcf40..c5803142bf 100644 --- a/apps/examples/desktop-app/README.md +++ b/apps/examples/desktop-app/README.md @@ -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). diff --git a/apps/examples/desktop-app/src-tauri/Cargo.lock b/apps/examples/desktop-app/src-tauri/Cargo.lock index 653acb6b11..82d4381769 100644 --- a/apps/examples/desktop-app/src-tauri/Cargo.lock +++ b/apps/examples/desktop-app/src-tauri/Cargo.lock @@ -506,6 +506,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tokio", + "zbus", ] [[package]] diff --git a/apps/examples/desktop-app/src-tauri/Cargo.toml b/apps/examples/desktop-app/src-tauri/Cargo.toml index c2cee8797d..79095cc0eb 100644 --- a/apps/examples/desktop-app/src-tauri/Cargo.toml +++ b/apps/examples/desktop-app/src-tauri/Cargo.toml @@ -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"] } diff --git a/apps/examples/desktop-app/src-tauri/src/linux_tray.rs b/apps/examples/desktop-app/src-tauri/src/linux_tray.rs new file mode 100644 index 0000000000..febcd7ffc2 --- /dev/null +++ b/apps/examples/desktop-app/src-tauri/src/linux_tray.rs @@ -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 { + 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::>::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::(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 + 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) + )); + } +} diff --git a/apps/examples/desktop-app/src-tauri/src/main.rs b/apps/examples/desktop-app/src-tauri/src/main.rs index 96a40913bd..f0b95797be 100644 --- a/apps/examples/desktop-app/src-tauri/src/main.rs +++ b/apps/examples/desktop-app/src-tauri/src/main.rs @@ -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); } }