diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index 4f2b059..6dd226c 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -24,7 +24,7 @@ pub(crate) async fn get_core_status(app: tauri::AppHandle) -> Result Result { let config = gui_config_state.set_locale(locale)?; - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "linux", target_os = "windows"))] if let Ok(status) = current_core_status(Some(process_state.inner()), Some(config.port)) { update_windows_tray_locale(&app, &config.locale, &status); } - #[cfg(not(target_os = "windows"))] + #[cfg(not(any(target_os = "linux", target_os = "windows")))] let _ = (app, process_state); Ok(config.locale) } @@ -567,6 +567,13 @@ pub(crate) fn resolve_windows_close_request( action: WindowsCloseAction, remember: Option, ) -> Result<(), String> { + #[cfg(target_os = "linux")] + if action == WindowsCloseAction::MinimizeToTray && !linux_tray_available(&app) { + return Err( + "System tray is unavailable; keep the main window open or exit the app".to_string(), + ); + } + if remember.unwrap_or(false) { let close_behavior = match action { WindowsCloseAction::Exit => WindowsCloseBehavior::Exit, diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index a9ef035..91d4f1f 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -2748,7 +2748,12 @@ pub(crate) fn core_binary_name() -> &'static str { } pub(crate) fn should_start_hidden(config: &GuiConfigFile) -> bool { - config.silent_start && cfg!(any(target_os = "windows", target_os = "macos")) + config.silent_start + && cfg!(any( + target_os = "linux", + target_os = "macos", + target_os = "windows" + )) } pub(crate) fn should_start_core_on_launch(config: &GuiConfigFile) -> bool { @@ -2766,6 +2771,16 @@ pub(crate) fn configure_initial_main_window( #[cfg(target_os = "macos")] set_macos_dock_visible(app_handle, !start_hidden); + #[cfg(target_os = "linux")] + let start_hidden = if start_hidden && !linux_tray_available(app_handle) { + eprintln!( + "System tray is unavailable; showing the main window instead of starting hidden" + ); + false + } else { + start_hidden + }; + if start_hidden { return window .hide() diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8701410..328aa1c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -13,12 +13,20 @@ mod desktop_theme; mod instance_lock; mod management_api; mod network_proxy; -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "windows" +))] mod native_i18n; mod oauth_browser; mod progress; mod provider_health; -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "windows" +))] mod tray; mod usage; @@ -65,11 +73,15 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tar::Archive; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] use tauri::menu::PredefinedMenuItem; #[cfg(target_os = "macos")] use tauri::tray::{MouseButtonState, TrayIcon}; -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "windows" +))] use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, TrayIconBuilder, TrayIconEvent}, @@ -78,7 +90,11 @@ use tauri::{Emitter, LogicalSize, Manager}; use tauri_plugin_autostart::ManagerExt as AutostartManagerExt; use tauri_plugin_opener::OpenerExt; use tokio_util::sync::CancellationToken; -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "windows" +))] use tray::*; use zip::ZipArchive; @@ -115,7 +131,7 @@ const CORE_STATUS_EVENT: &str = "core-status-changed"; const CONFIG_FILES_CHANGED_EVENT: &str = "config-files-changed"; const VERSION_DOWNLOAD_SOURCE_CHANGED_EVENT: &str = "version-download-source-changed"; static VERSION_SOURCE_DETECTION_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] const WINDOWS_CLOSE_REQUEST_EVENT: &str = "windows-close-requested"; const CORE_METADATA_FILE: &str = "cpa-gui-meta.json"; const CORE_CONFIG_FILE: &str = "config.yaml"; @@ -2439,13 +2455,13 @@ fn main() { } }); - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "linux", target_os = "windows"))] let app = app.on_window_event(|window, event| { if window.label() == "main" { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); if let Err(error) = window.emit(WINDOWS_CLOSE_REQUEST_EVENT, ()) { - eprintln!("Failed to show Windows close confirmation: {error}"); + eprintln!("Failed to show desktop close confirmation: {error}"); } } } @@ -2477,6 +2493,12 @@ fn main() { #[cfg(target_os = "macos")] setup_macos_tray(app)?; + #[cfg(target_os = "linux")] + if let Err(error) = setup_windows_tray(app) { + eprintln!( + "Failed to set up Linux tray; keeping the main window available: {error}" + ); + } #[cfg(target_os = "windows")] setup_windows_tray(app)?; diff --git a/src-tauri/src/tests/app_settings.rs b/src-tauri/src/tests/app_settings.rs index cff376f..868c4a2 100644 --- a/src-tauri/src/tests/app_settings.rs +++ b/src-tauri/src/tests/app_settings.rs @@ -140,7 +140,11 @@ fn silent_start_defaults_off_and_requires_tray_support() { }; assert_eq!( should_start_hidden(&enabled), - cfg!(any(target_os = "windows", target_os = "macos")) + cfg!(any( + target_os = "linux", + target_os = "macos", + target_os = "windows" + )) ); } diff --git a/src-tauri/src/tests/platform.rs b/src-tauri/src/tests/platform.rs index d9e4741..a54afb4 100644 --- a/src-tauri/src/tests/platform.rs +++ b/src-tauri/src/tests/platform.rs @@ -2,7 +2,7 @@ use super::support::agent_test_home; use super::*; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] #[test] fn windows_tray_presentation_tracks_core_state_and_busy_actions() { let mut status = CoreStatus { diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index f261950..63cacc8 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -185,27 +185,54 @@ pub(crate) fn setup_macos_tray(app: &mut tauri::App) -> tauri::Resul Ok(()) } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] const WINDOWS_TRAY_ID: &str = "windows-tray"; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] const WINDOWS_TRAY_OPEN_MENU_ID: &str = "windows-tray-open-main-window"; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] const WINDOWS_TRAY_STATUS_MENU_ID: &str = "windows-tray-core-status"; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] const WINDOWS_TRAY_TOGGLE_CORE_MENU_ID: &str = "windows-tray-toggle-core"; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] const WINDOWS_TRAY_RESTART_CORE_MENU_ID: &str = "windows-tray-restart-core"; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] const WINDOWS_TRAY_QUIT_MENU_ID: &str = "windows-tray-quit"; -#[cfg(target_os = "windows")] +#[cfg(target_os = "linux")] +pub(crate) fn linux_tray_available(app_handle: &tauri::AppHandle) -> bool { + use dbus::{arg::Variant, blocking::Connection}; + + if app_handle.tray_by_id(WINDOWS_TRAY_ID).is_none() { + return false; + } + + let Ok(connection) = Connection::new_session() else { + return false; + }; + let proxy = connection.with_proxy( + "org.kde.StatusNotifierWatcher", + "/StatusNotifierWatcher", + Duration::from_millis(500), + ); + let host_registered: Result<(Variant,), _> = proxy.method_call( + "org.freedesktop.DBus.Properties", + "Get", + ( + "org.kde.StatusNotifierWatcher", + "IsStatusNotifierHostRegistered", + ), + ); + matches!(host_registered, Ok((Variant(true),))) +} + +#[cfg(any(target_os = "linux", target_os = "windows"))] #[derive(Clone, Copy)] pub(crate) enum WindowsTrayCoreAction { Toggle, Restart, } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) struct WindowsTrayPresentation { pub(crate) status_text: String, pub(crate) toggle_text: String, @@ -214,7 +241,7 @@ pub(crate) struct WindowsTrayPresentation { pub(crate) tooltip: String, } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) fn windows_tray_presentation( status: &CoreStatus, busy: bool, @@ -255,7 +282,7 @@ pub(crate) fn windows_tray_presentation( } } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) struct WindowsTrayState { open_main_window: MenuItem, status_item: MenuItem, @@ -266,7 +293,7 @@ pub(crate) struct WindowsTrayState { busy: AtomicBool, } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] impl WindowsTrayState { fn locale(&self) -> String { self.locale @@ -293,7 +320,7 @@ impl WindowsTrayState { ]; for (item, label) in labels { if let Err(error) = item.set_text(label) { - eprintln!("Failed to update Windows tray language: {error}"); + eprintln!("Failed to update desktop tray language: {error}"); } } } @@ -311,19 +338,19 @@ impl WindowsTrayState { if let Err(error) = self .status_item .set_text(native_text(&locale, NativeText::CoreStatusWorking)) { - eprintln!("Failed to update Windows tray core status: {error}"); + eprintln!("Failed to update desktop tray core status: {error}"); } if let Err(error) = self.toggle_core_item .set_text(native_text(&locale, NativeText::Working)) { - eprintln!("Failed to update Windows tray action text: {error}"); + eprintln!("Failed to update desktop tray action text: {error}"); } if let Err(error) = self.toggle_core_item.set_enabled(false) { - eprintln!("Failed to update Windows tray action state: {error}"); + eprintln!("Failed to update desktop tray action state: {error}"); } if let Err(error) = self.restart_core_item.set_enabled(false) { - eprintln!("Failed to update Windows tray restart state: {error}"); + eprintln!("Failed to update desktop tray restart state: {error}"); } true } @@ -336,25 +363,25 @@ impl WindowsTrayState { let presentation = windows_tray_presentation(status, self.busy.load(Ordering::Acquire), &self.locale()); if let Err(error) = self.status_item.set_text(presentation.status_text.clone()) { - eprintln!("Failed to update Windows tray core status: {error}"); + eprintln!("Failed to update desktop tray core status: {error}"); } if let Err(error) = self .toggle_core_item .set_text(presentation.toggle_text.clone()) { - eprintln!("Failed to update Windows tray action text: {error}"); + eprintln!("Failed to update desktop tray action text: {error}"); } if let Err(error) = self .toggle_core_item .set_enabled(presentation.toggle_enabled) { - eprintln!("Failed to update Windows tray action state: {error}"); + eprintln!("Failed to update desktop tray action state: {error}"); } if let Err(error) = self .restart_core_item .set_enabled(presentation.restart_enabled) { - eprintln!("Failed to update Windows tray restart state: {error}"); + eprintln!("Failed to update desktop tray restart state: {error}"); } presentation.tooltip } @@ -366,12 +393,12 @@ impl WindowsTrayState { } let text = native_operation_failed(&self.locale(), &summary); if let Err(update_error) = self.status_item.set_text(text) { - eprintln!("Failed to update Windows tray error state: {update_error}"); + eprintln!("Failed to update desktop tray error state: {update_error}"); } } } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) fn show_windows_main_window(app_handle: &tauri::AppHandle) { let Some(window) = app_handle.get_webview_window("main") else { return; @@ -390,7 +417,7 @@ pub(crate) fn show_windows_main_window(app_handle: &tauri::AppHandle) { } } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) fn update_windows_tray_status(app_handle: &tauri::AppHandle, status: &CoreStatus) { let tooltip = app_handle .try_state::() @@ -399,12 +426,12 @@ pub(crate) fn update_windows_tray_status(app_handle: &tauri::AppHandle, status: if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) { if let Err(error) = tray.set_tooltip(Some(&tooltip)) { - eprintln!("Failed to update Windows tray tooltip: {error}"); + eprintln!("Failed to update desktop tray tooltip: {error}"); } } } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) fn update_windows_tray_locale( app_handle: &tauri::AppHandle, locale: &str, @@ -416,9 +443,9 @@ pub(crate) fn update_windows_tray_locale( update_windows_tray_status(app_handle, status); } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) fn show_windows_tray_action_error(app_handle: &tauri::AppHandle, error: &str) { - eprintln!("Windows tray core operation failed: {error}"); + eprintln!("Desktop tray core operation failed: {error}"); let locale = app_handle .try_state::() .map(|state| state.locale()) @@ -431,12 +458,12 @@ pub(crate) fn show_windows_tray_action_error(app_handle: &tauri::AppHandle, erro &locale, NativeText::TooltipCoreOperationFailed, ))) { - eprintln!("Failed to update Windows tray error tooltip: {update_error}"); + eprintln!("Failed to update desktop tray error tooltip: {update_error}"); } } } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) fn run_windows_tray_core_action( app_handle: &tauri::AppHandle, action: WindowsTrayCoreAction, @@ -452,7 +479,7 @@ pub(crate) fn run_windows_tray_core_action( &tray_state.locale(), NativeText::TooltipCoreWorking, ))) { - eprintln!("Failed to update Windows tray busy tooltip: {error}"); + eprintln!("Failed to update desktop tray busy tooltip: {error}"); } } @@ -505,7 +532,7 @@ pub(crate) fn run_windows_tray_core_action( }); } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) fn setup_windows_tray(app: &mut tauri::App) -> tauri::Result<()> { let locale = app .state::() @@ -562,6 +589,10 @@ pub(crate) fn setup_windows_tray(app: &mut tauri::App) -> tauri::Res ], )?; + // Linux AppIndicator implementations always open the menu when the icon is + // activated and do not emit tray click events. Keeping "Open Main Window" + // as the first item provides the reliable restore path on Linux; Windows + // additionally supports the double-click shortcut below. TrayIconBuilder::with_id(WINDOWS_TRAY_ID) .icon( app.default_window_icon()