From 4dcfecc016cd24f4798df60d75e67ddd55b782b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Fri, 15 May 2026 12:05:52 +0200 Subject: [PATCH] Position new UI window near tray icon --- src-tauri/Cargo.lock | 18 +-- src-tauri/src/appstate.rs | 7 +- src-tauri/src/bin/defguard-client.rs | 1 + src-tauri/src/commands.rs | 3 +- src-tauri/src/tray.rs | 62 ++++++++-- src-tauri/src/window.rs | 163 +++++++++++++++++++++------ 6 files changed, 194 insertions(+), 60 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 65b8e495..fc8c6060 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7096,7 +7096,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -7159,7 +7159,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -7168,7 +7168,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -8852,9 +8852,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -9182,7 +9182,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.2", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -9210,7 +9210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 1.0.2", + "winnow 1.0.3", "zvariant", ] @@ -9339,7 +9339,7 @@ dependencies = [ "enumflags2", "serde", "url", - "winnow 1.0.2", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] @@ -9367,5 +9367,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow 1.0.2", + "winnow 1.0.3", ] diff --git a/src-tauri/src/appstate.rs b/src-tauri/src/appstate.rs index 179b9a6b..02e6ae4d 100644 --- a/src-tauri/src/appstate.rs +++ b/src-tauri/src/appstate.rs @@ -1,6 +1,9 @@ use std::{collections::HashMap, sync::Mutex}; -use tauri::async_runtime::{spawn, JoinHandle}; +use tauri::{ + async_runtime::{spawn, JoinHandle}, + PhysicalPosition, +}; use tokio_util::sync::CancellationToken; use crate::{ @@ -15,6 +18,7 @@ use crate::{ pub struct AppState { pub log_watchers: Mutex>, pub app_config: Mutex, + pub tray_click_position: Mutex>>, stat_threads: Mutex>>, // location ID is the key pub provisioning_config: Mutex>, } @@ -25,6 +29,7 @@ impl AppState { Self { log_watchers: Mutex::new(HashMap::new()), app_config: Mutex::new(config), + tray_click_position: Mutex::new(None), stat_threads: Mutex::new(HashMap::new()), provisioning_config: Mutex::new(provisioning_config), } diff --git a/src-tauri/src/bin/defguard-client.rs b/src-tauri/src/bin/defguard-client.rs index 9074a5e4..48415bd4 100644 --- a/src-tauri/src/bin/defguard-client.rs +++ b/src-tauri/src/bin/defguard-client.rs @@ -354,6 +354,7 @@ fn main() { WebviewWindowBuilder::new(app, "new-ui", new_url) .title("New UI") .inner_size(360.0, 675.0) + .visible(false) .build()?; // Open old UI window. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 68b4ab96..111bbf3a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1334,8 +1334,7 @@ fn select_reported_app_version( ) -> String { build_version_override .filter(|version| !version.trim().is_empty()) - .map(str::to_owned) - .unwrap_or_else(|| package_version.to_owned()) + .map_or_else(|| package_version.to_owned(), str::to_owned) } fn reported_app_version(handle: &AppHandle) -> String { diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index db6fcca1..e364c6e8 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -6,6 +6,8 @@ use tauri::{ AppHandle, Emitter, Manager, Runtime, }; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent}; + use crate::{ active_connections::{get_connection_id_by_type, ACTIVE_CONNECTIONS}, appstate::AppState, @@ -13,6 +15,7 @@ use crate::{ database::{models::location::Location, DB_POOL}, error::Error, events::EventKey, + window::show_new_ui_window_near_tray, ConnectionType, }; @@ -20,7 +23,8 @@ const SUBSCRIBE_UPDATES_LINK: &str = "https://defguard.net/newsletter"; const JOIN_COMMUNITY_LINK: &str = "https://github.com/DefGuard/defguard/discussions/new/choose"; const FOLLOW_US_LINK: &str = "https://floss.social/@defguard"; -const MAIN_WINDOW_ID: &str = "main"; +const NEW_UI_WINDOW_ID: &str = "new-ui"; +const OLD_UI_WINDOW_ID: &str = "old-ui"; const TRAY_ICON_ID: &str = "tray"; @@ -31,6 +35,22 @@ const TRAY_EVENT_UPDATES: &str = "updates"; const TRAY_EVENT_COMMUNITY: &str = "community"; const TRAY_EVENT_FOLLOW: &str = "follow"; +fn store_tray_click_position(app: &AppHandle, event: &TrayIconEvent) { + let position = match event { + TrayIconEvent::Click { + button_state: MouseButtonState::Down, + position, + .. + } + | TrayIconEvent::DoubleClick { position, .. } => Some(*position), + _ => None, + }; + + if let Some(position) = position { + *app.state::().tray_click_position.lock().unwrap() = Some(position); + } +} + /// Generate contents of system tray menu. async fn generate_tray_menu(app: &AppHandle) -> Result, Error> { debug!("Generating tray menu."); @@ -129,6 +149,16 @@ pub async fn setup_tray(app: &AppHandle) -> Result<(), Error> { TrayIconBuilder::with_id(TRAY_ICON_ID) .menu(&tray_menu) .show_menu_on_left_click(true) + .on_tray_icon_event(|icon, event| { + store_tray_click_position(icon.app_handle(), &event); + if let TrayIconEvent::DoubleClick { + button: MouseButton::Left, + .. + } = event + { + show_new_ui_window_near_tray(icon.app_handle()); + } + }) .on_menu_event(handle_tray_menu_event) .build(app)?; // On other systems (especially Windows), system tray menu is on right-click, @@ -138,8 +168,13 @@ pub async fn setup_tray(app: &AppHandle) -> Result<(), Error> { .menu(&tray_menu) .show_menu_on_left_click(false) .on_tray_icon_event(|icon, event| { - if let tauri::tray::TrayIconEvent::DoubleClick { .. } = event { - show_main_window(icon.app_handle()); + store_tray_click_position(icon.app_handle(), &event); + if let TrayIconEvent::DoubleClick { + button: MouseButton::Left, + .. + } = event + { + show_new_ui_window_near_tray(icon.app_handle()); } }) .on_menu_event(handle_tray_menu_event) @@ -169,16 +204,21 @@ fn hide_main_window(app: &AppHandle) { warn!("Failed to hide application: {err}"); } #[cfg(not(target_os = "macos"))] - if let Some(main_window) = app.get_webview_window(MAIN_WINDOW_ID) { - if let Err(err) = main_window.hide() { - warn!("Failed to hide main window: {err}"); + for window_id in [NEW_UI_WINDOW_ID, OLD_UI_WINDOW_ID] { + if let Some(window) = app.get_webview_window(window_id) { + if let Err(err) = window.hide() { + warn!("Failed to hide window {window_id}: {err}"); + } } } } pub fn show_main_window(app: &AppHandle) { - if let Some(main_window) = app.get_webview_window(MAIN_WINDOW_ID) { - if let Err(err) = main_window.unminimize() { + if let Some(window) = app + .get_webview_window(NEW_UI_WINDOW_ID) + .or_else(|| app.get_webview_window(OLD_UI_WINDOW_ID)) + { + if let Err(err) = window.unminimize() { warn!("Failed to unminimize main window: {err}"); } #[cfg(target_os = "macos")] @@ -187,11 +227,11 @@ pub fn show_main_window(app: &AppHandle) { } #[cfg(not(target_os = "macos"))] { - if let Err(err) = main_window.show() { + if let Err(err) = window.show() { warn!("Failed to show main window: {err}"); } } - let _ = main_window.set_focus(); + let _ = window.set_focus(); } } @@ -203,7 +243,7 @@ pub fn handle_tray_menu_event(app: &AppHandle, event: MenuEvent) { info!("Received QUIT request. Initiating shutdown..."); handle.exit(0); } - TRAY_EVENT_SHOW => show_main_window(app), + TRAY_EVENT_SHOW => show_new_ui_window_near_tray(app), TRAY_EVENT_HIDE => hide_main_window(app), TRAY_EVENT_UPDATES => { let _ = webbrowser::open(SUBSCRIBE_UPDATES_LINK); diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index c298923a..cf6262ae 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -1,65 +1,154 @@ -use tauri::{webview::WebviewWindowBuilder, AppHandle, Manager, WebviewUrl}; +use tauri::{ + webview::WebviewWindowBuilder, AppHandle, LogicalPosition, Manager, Monitor, Position, + WebviewUrl, WebviewWindow, +}; -#[tauri::command] -pub async fn open_new_ui_window(app: AppHandle) { - let url = if cfg!(debug_assertions) { +use crate::appstate::AppState; + +const NEW_UI_WINDOW_ID: &str = "new-ui"; +const OLD_UI_WINDOW_ID: &str = "old-ui"; +const NEW_UI_WIDTH: f64 = 360.0; +const NEW_UI_HEIGHT: f64 = 675.0; +const OLD_UI_WIDTH: f64 = 720.0; +const OLD_UI_HEIGHT: f64 = 920.0; + +fn new_ui_url() -> WebviewUrl { + if cfg!(debug_assertions) { WebviewUrl::External("http://localhost:5072".parse().unwrap()) } else { WebviewUrl::App("new-ui/index.html".into()) - }; - - let _window = WebviewWindowBuilder::new(&app, "new-ui", url) - .title("New UI") - .inner_size(1000.0, 800.0) - .build() - .unwrap(); + } } -#[tauri::command] -pub async fn open_old_ui_window(app: AppHandle) { - let url = if cfg!(debug_assertions) { +fn old_ui_url() -> WebviewUrl { + if cfg!(debug_assertions) { WebviewUrl::External("http://localhost:5071".parse().unwrap()) } else { WebviewUrl::App("old-ui/index.html".into()) + } +} + +/// Try to get monitor at the given position, with a fall back to primary monitor, and then to the +/// first one on the list of available monitors. +fn get_monitor_for_position(app: &AppHandle, x: f64, y: f64) -> Option { + if let Ok(Some(monitor)) = app.monitor_from_point(x, y) { + return Some(monitor); + } + + if let Ok(Some(monitor)) = app.primary_monitor() { + return Some(monitor); + } + + // On macOS, it seems this is the only working method (as of Tauri 2.11), but fortunately it + // returns the current monitor as the first one. + if let Ok(mut monitors) = app.available_monitors() { + monitors.pop() + } else { + None + } +} + +fn get_tray_window_position( + app: &AppHandle, + width: f64, + height: f64, +) -> Option> { + let app_state = app.state::(); + let tray_position = app_state.tray_click_position.lock().unwrap().to_owned()?; + + let monitor = get_monitor_for_position(app, tray_position.x, tray_position.y)?; + + let scale_factor = monitor.scale_factor(); + let monitor_position = monitor.position().to_logical::(scale_factor); + let monitor_size = monitor.size().to_logical::(scale_factor); + let tray_position = tray_position.to_logical::(scale_factor); + + let mut x = tray_position.x - (width / 2.0); + let center_y = monitor_position.y + (monitor_size.height / 2.0); + let mut y = if tray_position.y < center_y { + tray_position.y + } else { + tray_position.y - height }; - let _window = WebviewWindowBuilder::new(&app, "old-ui", url) + x = x.clamp( + monitor_position.x, + monitor_position.x + monitor_size.width - width, + ); + y = y.clamp( + monitor_position.y, + monitor_position.y + monitor_size.height - height, + ); + + Some(LogicalPosition::new(x, y)) +} + +fn position_window_near_tray(app: &AppHandle, window: &WebviewWindow, width: f64, height: f64) { + if let Some(position) = get_tray_window_position(app, width, height) { + if let Err(err) = window.set_position(Position::Logical(position)) { + warn!("Failed to position window near tray icon: {err}"); + } + } +} + +fn show_new_ui_window_internal(app: &AppHandle, near_tray: bool) { + let window = if let Some(window) = app.get_webview_window(NEW_UI_WINDOW_ID) { + let _ = window.unminimize(); + window + } else { + WebviewWindowBuilder::new(app, NEW_UI_WINDOW_ID, new_ui_url()) + .title("New UI") + .inner_size(NEW_UI_WIDTH, NEW_UI_HEIGHT) + .build() + .unwrap() + }; + if near_tray { + position_window_near_tray(app, &window, NEW_UI_WIDTH, NEW_UI_HEIGHT); + } + #[cfg(target_os = "macos")] + let _ = app.show(); + let _ = window.show(); + let _ = window.set_focus(); +} + +pub(crate) fn show_new_ui_window(app: &AppHandle) { + show_new_ui_window_internal(app, false); +} + +pub(crate) fn show_new_ui_window_near_tray(app: &AppHandle) { + show_new_ui_window_internal(app, true); +} + +#[tauri::command] +pub fn open_new_ui_window(app: AppHandle) { + show_new_ui_window(&app); +} + +#[tauri::command] +pub fn open_old_ui_window(app: AppHandle) { + let _window = WebviewWindowBuilder::new(&app, OLD_UI_WINDOW_ID, old_ui_url()) .title("Old UI") - .inner_size(1000.0, 800.0) + .inner_size(OLD_UI_WIDTH, OLD_UI_HEIGHT) .build() .unwrap(); } #[tauri::command] -pub async fn swap_to_old_ui(app: AppHandle) { - let url = if cfg!(debug_assertions) { - WebviewUrl::External("http://localhost:5071".parse().unwrap()) - } else { - WebviewUrl::App("old-ui/index.html".into()) - }; - WebviewWindowBuilder::new(&app, "old-ui", url) +pub fn swap_to_old_ui(app: AppHandle) { + WebviewWindowBuilder::new(&app, OLD_UI_WINDOW_ID, old_ui_url()) .title("Old UI") - .inner_size(1000.0, 800.0) + .inner_size(OLD_UI_WIDTH, OLD_UI_HEIGHT) .build() .unwrap(); - if let Some(w) = app.get_webview_window("new-ui") { + if let Some(w) = app.get_webview_window(NEW_UI_WINDOW_ID) { w.close().unwrap(); } } #[tauri::command] -pub async fn swap_to_new_ui(app: AppHandle) { - let url = if cfg!(debug_assertions) { - WebviewUrl::External("http://localhost:5072".parse().unwrap()) - } else { - WebviewUrl::App("new-ui/index.html".into()) - }; - WebviewWindowBuilder::new(&app, "new-ui", url) - .title("New UI") - .inner_size(1000.0, 800.0) - .build() - .unwrap(); - if let Some(w) = app.get_webview_window("old-ui") { +pub fn swap_to_new_ui(app: AppHandle) { + show_new_ui_window(&app); + if let Some(w) = app.get_webview_window(OLD_UI_WINDOW_ID) { w.close().unwrap(); } }