feat(linux): add system tray with safe window fallback

This commit is contained in:
lzt404 committed 2026-09-27 20:03:33 +08:00
1 parent ecf0ad58fd
commit 02d5a2d469
6 files changed
+124 -45

No files matched your search

+10 -3
View File
@@ -24,7 +24,7 @@ pub(crate) async fn get_core_status(app: tauri::AppHandle) -> Result<CoreStatus,
}
pub(crate) fn emit_core_status(app: &tauri::AppHandle, status: &CoreStatus) {
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
update_windows_tray_status(app, status);
let _ = app.emit(CORE_STATUS_EVENT, status);
}
@@ -551,11 +551,11 @@ pub(crate) fn set_app_locale(
locale: String,
) -> Result<String, String> {
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<bool>,
) -> 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,
+16 -1
View File
@@ -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()
+30 -8
View File
@@ -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)?;
+5 -1
View File
@@ -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"
))
);
}
+1 -1
View File
@@ -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 {
+62 -31
View File
@@ -185,27 +185,54 @@ pub(crate) fn setup_macos_tray(app: &mut tauri::App<tauri::Wry>) -> 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<bool>,), _> = 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<tauri::Wry>,
status_item: MenuItem<tauri::Wry>,
@@ -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::<WindowsTrayState>()
@@ -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::<WindowsTrayState>()
.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::Wry>) -> tauri::Result<()> {
let locale = app
.state::<GuiConfigState>()
@@ -562,6 +589,10 @@ pub(crate) fn setup_windows_tray(app: &mut tauri::App<tauri::Wry>) -> 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()