diff --git a/CHANGELOG.md b/CHANGELOG.md index 78cb9093..83576c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.6.29 + +### Bug Fixes +- fix: stabilize Linux tray panel close by @symonbaikov + +--- + +### Changelog + +**Full Changelog**: [v0.6.28...v0.6.29](https://github.com/openusage-community/openusage/compare/v0.6.28...v0.6.29) + +- [72804ea](https://github.com/openusage-community/openusage/commit/72804ea7ca0dd84a223970709d243b7f9ef640b2) fix: stabilize Linux tray panel close by @symonbaikov + ## v0.6.28 ### Chores diff --git a/package.json b/package.json index c26c3828..002733e5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openusage", "private": true, - "version": "0.6.28", + "version": "0.6.29", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b96de001..d290c11f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3227,11 +3227,12 @@ dependencies = [ [[package]] name = "openusage" -version = "0.6.28" +version = "0.6.29" dependencies = [ "aes-gcm", "base64 0.22.1", "dirs 6.0.0", + "gtk", "libc", "log", "objc2", @@ -3259,7 +3260,6 @@ dependencies = [ "time", "tokio", "uuid", - "x11rb", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d398946d..2d8d72d7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openusage" -version = "0.6.28" +version = "0.6.29" description = "OpenUsage is an open source AI subscription limit tracker" authors = ["Robin Ebers"] edition = "2024" @@ -43,7 +43,7 @@ aes-gcm = "0.10.3" sha2 = "0.11" [target.'cfg(target_os = "linux")'.dependencies] -x11rb = "0.13.2" +gtk = "0.18" [target.'cfg(target_os = "macos")'.dependencies] # nspanel powers the floating menubar panel; macOS-only. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 75a50780..904b4186 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -556,21 +556,8 @@ pub fn run() { use tauri::Manager; - #[cfg(not(target_os = "macos"))] - if let Some(window) = app.get_webview_window("main") { - let win = window.clone(); - window.on_window_event(move |event| { - if let tauri::WindowEvent::Focused(false) = event { - let is_visible = match win.is_visible() { - Ok(true) => true, - Ok(false) | Err(_) => return, - }; - if crate::panel::should_hide_for_window_focus_loss_now(is_visible) { - let _ = win.hide(); - } - } - }); - } + #[cfg(target_os = "linux")] + panel::init(app.handle())?; let version = app.package_info().version.to_string(); log::info!("OpenUsage v{} starting", version); diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index 2133f33f..aaab9c4f 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -650,10 +650,6 @@ mod platform { panel.hide(); } } - - pub fn should_hide_for_window_focus_loss_now(_is_visible: bool) -> bool { - true - } } #[cfg(not(target_os = "macos"))] @@ -661,6 +657,6 @@ use crate::panel_non_macos as platform; use platform::apply_panel_position; pub use platform::{ - hide_panel, init, should_hide_for_window_focus_loss_now, show_panel, - show_panel_at_logical_anchor, toggle_panel, toggle_panel_at_tray_icon, + hide_panel, init, show_panel, show_panel_at_logical_anchor, toggle_panel, + toggle_panel_at_tray_icon, }; diff --git a/src-tauri/src/panel_non_macos.rs b/src-tauri/src/panel_non_macos.rs index f819ff41..5d6425b1 100644 --- a/src-tauri/src/panel_non_macos.rs +++ b/src-tauri/src/panel_non_macos.rs @@ -1,5 +1,4 @@ -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{ AppHandle, LogicalPosition, LogicalSize, Manager, PhysicalPosition, Position, Size, WebviewUrl, @@ -7,32 +6,17 @@ use tauri::{ }; #[cfg(target_os = "linux")] -use x11rb::{ - connection::Connection, - protocol::xproto::{ConnectionExt, KeyButMask, Window as X11Window}, - rust_connection::RustConnection, -}; +use gtk::prelude::*; use crate::panel::{ position_panel_at_logical_anchor, position_panel_at_tray_click, position_panel_from_tray, }; -const FOCUS_LOSS_GRACE_MS: u64 = 250; -const FOCUS_POLL_INTERVAL_MS: u64 = 35; const CLICK_CATCHER_LABEL: &str = "panel-click-catcher"; const CLICK_CATCHER_URL: &str = "index.html?overlay=panel-click-catcher"; -static PANEL_OPEN_SESSION: AtomicU64 = AtomicU64::new(0); -static PANEL_FOCUS_WATCH_ID: AtomicU64 = AtomicU64::new(0); -static LAST_PANEL_OPEN_TIME_MS: AtomicU64 = AtomicU64::new(0); -static PANEL_HAD_FOCUS: AtomicBool = AtomicBool::new(false); - -#[derive(Clone, Copy)] -struct PanelRect { - x: i32, - y: i32, - width: u32, - height: u32, -} +#[cfg(target_os = "linux")] +static LINUX_FOCUS_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false); +static PANEL_IS_OPEN: AtomicBool = AtomicBool::new(false); #[derive(Clone, Copy)] struct LogicalOverlayBounds { @@ -42,132 +26,31 @@ struct LogicalOverlayBounds { height: f64, } -impl PanelRect { - fn contains(self, x: i32, y: i32) -> bool { - let right = self.x.saturating_add(self.width as i32); - let bottom = self.y.saturating_add(self.height as i32); - x >= self.x && x < right && y >= self.y && y < bottom - } +fn register_panel_opened() { + PANEL_IS_OPEN.store(true, Ordering::SeqCst); } -#[cfg(target_os = "linux")] -struct PointerSnapshot { - x: i32, - y: i32, - is_button_down: bool, +fn register_panel_closed() { + PANEL_IS_OPEN.store(false, Ordering::SeqCst); } -#[cfg(target_os = "linux")] -struct LinuxPointerWatcher { - connection: RustConnection, - root_window: X11Window, -} - -#[cfg(target_os = "linux")] -impl LinuxPointerWatcher { - fn new() -> Result { - let (connection, screen_num) = - x11rb::connect(None).map_err(|error| format!("connect failed: {error}"))?; - let root_window = connection - .setup() - .roots - .get(screen_num) - .ok_or_else(|| format!("screen {screen_num} not found"))? - .root; - - Ok(Self { - connection, - root_window, - }) - } - - fn read(&self) -> Result { - let reply = self - .connection - .query_pointer(self.root_window) - .map_err(|error| format!("query_pointer failed: {error}"))? - .reply() - .map_err(|error| format!("query_pointer reply failed: {error}"))?; - - let button_mask = u16::from(KeyButMask::BUTTON1) - | u16::from(KeyButMask::BUTTON2) - | u16::from(KeyButMask::BUTTON3); - let active_mask = u16::from(reply.mask); - - Ok(PointerSnapshot { - x: i32::from(reply.root_x), - y: i32::from(reply.root_y), - is_button_down: active_mask & button_mask != 0, - }) - } +fn should_hide_for_focus_loss(is_visible: bool, is_open: bool) -> bool { + is_visible && is_open } -fn run_after_panel_map(session_id: u64, task: impl FnOnce() + Send + 'static) { - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(120)); - if current_panel_open_session() != session_id { - return; - } - task(); - }); +fn register_panel_focus_loss(is_visible: bool) -> bool { + should_hide_for_focus_loss(is_visible, PANEL_IS_OPEN.load(Ordering::Acquire)) } -fn now_millis() -> u64 { - match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(now) => now.as_millis().min(u128::from(u64::MAX)) as u64, - Err(_) => 0, +#[cfg(target_os = "linux")] +fn present_gtk_window(window: &tauri::WebviewWindow) { + if let Ok(gtk_window) = window.gtk_window() { + gtk_window.present(); } } -pub fn register_panel_opened() -> u64 { - let session_id = PANEL_OPEN_SESSION.fetch_add(1, Ordering::SeqCst) + 1; - LAST_PANEL_OPEN_TIME_MS.store(now_millis(), Ordering::SeqCst); - // Keep close-on-blur behavior stable: only close after we have observed - // at least one focus event for this open session. - PANEL_HAD_FOCUS.store(false, Ordering::SeqCst); - session_id -} - -pub(crate) fn current_panel_open_session() -> u64 { - PANEL_OPEN_SESSION.load(Ordering::Acquire) -} - -fn should_hide_unfocused_panel( - is_visible: bool, - panel_had_focus: bool, - is_focused: bool, - elapsed_ms: u64, -) -> bool { - is_visible && panel_had_focus && !is_focused && elapsed_ms >= FOCUS_LOSS_GRACE_MS -} - -fn should_hide_for_pointer_down_outside( - is_visible: bool, - is_button_down: bool, - pointer_inside_panel: bool, - elapsed_ms: u64, -) -> bool { - is_visible && is_button_down && !pointer_inside_panel && elapsed_ms >= FOCUS_LOSS_GRACE_MS -} - -fn should_hide_for_window_focus_loss( - is_visible: bool, - has_open_session: bool, - elapsed_ms: u64, -) -> bool { - is_visible && has_open_session && elapsed_ms >= FOCUS_LOSS_GRACE_MS -} - -pub fn should_hide_for_window_focus_loss_now(is_visible: bool) -> bool { - let opened_at = LAST_PANEL_OPEN_TIME_MS.load(Ordering::Acquire); - let elapsed_ms = if opened_at == 0 { - FOCUS_LOSS_GRACE_MS - } else { - now_millis().saturating_sub(opened_at) - }; - - should_hide_for_window_focus_loss(is_visible, current_panel_open_session() > 0, elapsed_ms) -} +#[cfg(not(target_os = "linux"))] +fn present_gtk_window(_window: &tauri::WebviewWindow) {} fn monitor_logical_bounds(monitor: &tauri::Monitor) -> LogicalOverlayBounds { let scale = monitor.scale_factor(); @@ -230,6 +113,7 @@ fn get_or_create_click_catcher(app_handle: &AppHandle) -> Option Option bool { + true +} + fn show_click_catcher(app_handle: &AppHandle) { + if !should_show_click_catcher() { + return; + } + let Some(main_window) = app_handle.get_webview_window("main") else { return; }; @@ -256,6 +148,7 @@ fn show_click_catcher(app_handle: &AppHandle) { } let _ = click_catcher.set_always_on_top(true); + let _ = click_catcher.set_focusable(false); let _ = click_catcher.show(); } @@ -265,118 +158,6 @@ fn hide_click_catcher(app_handle: &AppHandle) { } } -fn current_panel_rect(window: &tauri::WebviewWindow) -> Result { - let position = window - .outer_position() - .map_err(|error| format!("outer_position failed: {error}"))?; - let size = window - .outer_size() - .map_err(|error| format!("outer_size failed: {error}"))?; - - Ok(PanelRect { - x: position.x, - y: position.y, - width: size.width, - height: size.height, - }) -} - -fn start_focus_loss_watcher(app_handle: AppHandle, session_id: u64) { - let watch_id = PANEL_FOCUS_WATCH_ID.fetch_add(1, Ordering::SeqCst) + 1; - - std::thread::spawn(move || { - let session_marker = session_id; - let started_at = Instant::now(); - #[cfg(target_os = "linux")] - let mut pointer_watcher = match LinuxPointerWatcher::new() { - Ok(watcher) => Some(watcher), - Err(error) => { - log::warn!("panel focus watcher: Linux pointer watcher unavailable: {error}"); - None - } - }; - loop { - std::thread::sleep(Duration::from_millis(FOCUS_POLL_INTERVAL_MS)); - - if PANEL_FOCUS_WATCH_ID.load(Ordering::SeqCst) != watch_id { - return; - } - - if current_panel_open_session() != session_marker { - return; - } - - let Some(window) = app_handle.get_webview_window("main") else { - return; - }; - - let is_visible = match window.is_visible() { - Ok(value) => value, - Err(error) => { - log::warn!("panel focus watcher: failed to read visibility: {error}"); - return; - } - }; - if !is_visible { - return; - } - - let is_focused = match window.is_focused() { - Ok(value) => value, - Err(error) => { - log::warn!("panel focus watcher: failed to read focus: {error}"); - return; - } - }; - if is_focused { - PANEL_HAD_FOCUS.store(true, Ordering::SeqCst); - } - - let elapsed_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; - if should_hide_unfocused_panel( - is_visible, - PANEL_HAD_FOCUS.load(Ordering::Acquire), - is_focused, - elapsed_ms, - ) { - let _ = window.hide(); - return; - } - - #[cfg(target_os = "linux")] - if let Some(watcher) = pointer_watcher.as_ref() { - match watcher.read() { - Ok(pointer) => { - let pointer_inside_panel = match current_panel_rect(&window) { - Ok(rect) => rect.contains(pointer.x, pointer.y), - Err(error) => { - log::warn!( - "panel focus watcher: failed to read panel rect: {error}" - ); - false - } - }; - - if should_hide_for_pointer_down_outside( - is_visible, - pointer.is_button_down, - pointer_inside_panel, - elapsed_ms, - ) { - let _ = window.hide(); - return; - } - } - Err(error) => { - log::warn!("panel focus watcher: disabling Linux pointer watcher: {error}"); - pointer_watcher = LinuxPointerWatcher::new().ok(); - } - } - } - } - }); -} - pub(crate) fn apply_panel_position( app_handle: &AppHandle, panel_x: f64, @@ -417,7 +198,39 @@ pub(crate) fn apply_panel_position( } /// No NSPanel on non-macOS; the regular window is configured via tauri.conf.json. -pub fn init(_app_handle: &AppHandle) -> tauri::Result<()> { +pub fn init(app_handle: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "linux")] + init_linux_focus_loss_handler(app_handle)?; + + Ok(()) +} + +#[cfg(target_os = "linux")] +fn init_linux_focus_loss_handler(app_handle: &AppHandle) -> tauri::Result<()> { + if LINUX_FOCUS_HANDLER_INSTALLED.load(Ordering::Acquire) { + return Ok(()); + } + + let Some(window) = app_handle.get_webview_window("main") else { + return Ok(()); + }; + let app_handle = app_handle.clone(); + + window.on_window_event(move |event| { + let tauri::WindowEvent::Focused(false) = event else { + return; + }; + let is_visible = app_handle + .get_webview_window("main") + .and_then(|window| window.is_visible().ok()) + .unwrap_or(false); + + if register_panel_focus_loss(is_visible) { + hide_panel(&app_handle); + } + }); + + LINUX_FOCUS_HANDLER_INSTALLED.store(true, Ordering::SeqCst); Ok(()) } @@ -427,32 +240,22 @@ pub fn show_panel(app_handle: &AppHandle) { return; }; if window.is_visible().unwrap_or(false) { - let session_id = register_panel_opened(); show_click_catcher(app_handle); + let _ = window.set_always_on_top(true); let _ = window.set_focus(); - start_focus_loss_watcher(app_handle.clone(), session_id); + present_gtk_window(&window); + register_panel_opened(); return; } - let session_id = register_panel_opened(); show_click_catcher(app_handle); let _ = window.set_always_on_top(true); position_panel_from_tray(app_handle); let _ = window.show(); position_panel_from_tray(app_handle); let _ = window.set_focus(); - start_focus_loss_watcher(app_handle.clone(), session_id); - - let app_handle = app_handle.clone(); - run_after_panel_map(session_id, move || { - if current_panel_open_session() != session_id { - return; - } - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.set_focus(); - position_panel_from_tray(&app_handle); - } - }); + present_gtk_window(&window); + register_panel_opened(); } fn show_panel_at_tray_icon( @@ -461,7 +264,6 @@ fn show_panel_at_tray_icon( icon_position: Position, icon_size: Size, ) { - let session_id = register_panel_opened(); let Some(window) = app_handle.get_webview_window("main") else { return; }; @@ -471,22 +273,11 @@ fn show_panel_at_tray_icon( let _ = window.show(); position_panel_at_tray_click(app_handle, click_position, icon_position, icon_size); let _ = window.set_focus(); - start_focus_loss_watcher(app_handle.clone(), session_id); - - let app_handle = app_handle.clone(); - run_after_panel_map(session_id, move || { - if current_panel_open_session() != session_id { - return; - } - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.set_focus(); - position_panel_at_tray_click(&app_handle, click_position, icon_position, icon_size); - } - }); + present_gtk_window(&window); + register_panel_opened(); } pub fn show_panel_at_logical_anchor(app_handle: &AppHandle, center_x: f64, bottom_y: f64) { - let session_id = register_panel_opened(); let Some(window) = app_handle.get_webview_window("main") else { return; }; @@ -496,18 +287,8 @@ pub fn show_panel_at_logical_anchor(app_handle: &AppHandle, center_x: f64, botto let _ = window.show(); position_panel_at_logical_anchor(app_handle, center_x, bottom_y); let _ = window.set_focus(); - start_focus_loss_watcher(app_handle.clone(), session_id); - - let app_handle = app_handle.clone(); - run_after_panel_map(session_id, move || { - if current_panel_open_session() != session_id { - return; - } - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.set_focus(); - position_panel_at_logical_anchor(&app_handle, center_x, bottom_y); - } - }); + present_gtk_window(&window); + register_panel_opened(); } /// Toggle window visibility. @@ -543,6 +324,7 @@ pub fn toggle_panel_at_tray_icon( } pub fn hide_panel(app_handle: &AppHandle) { + register_panel_closed(); hide_click_catcher(app_handle); if let Some(window) = app_handle.get_webview_window("main") { let _ = window.hide(); @@ -552,56 +334,80 @@ pub fn hide_panel(app_handle: &AppHandle) { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; + + fn reset_panel_state_for_test() { + PANEL_IS_OPEN.store(false, Ordering::SeqCst); + } + #[cfg(target_os = "linux")] #[test] - fn unfocused_visible_panel_does_not_hide_before_focus_observed() { - assert!(!should_hide_unfocused_panel(true, false, false, 260)); + fn linux_uses_click_catcher_overlay() { + assert!(should_show_click_catcher()); } #[test] - fn visible_focused_panel_stays_open() { - assert!(!should_hide_unfocused_panel(true, false, true, 0)); + fn visible_open_panel_hides_on_focus_loss() { + assert!(should_hide_for_focus_loss(true, true)); } #[test] - fn unfocused_visible_panel_stays_open_during_grace_period() { - assert!(!should_hide_unfocused_panel(true, true, false, 249)); + fn hidden_or_closed_panel_ignores_focus_loss() { + assert!(!should_hide_for_focus_loss(false, true)); + assert!(!should_hide_for_focus_loss(true, false)); } #[test] - fn focused_or_hidden_panel_stays_open() { - assert!(!should_hide_unfocused_panel(true, true, true, 260)); - assert!(!should_hide_unfocused_panel(false, true, false, 260)); + #[serial] + fn open_panel_hides_on_focus_loss() { + reset_panel_state_for_test(); + register_panel_opened(); + + assert!(register_panel_focus_loss(true)); } #[test] - fn outside_pointer_down_hides_visible_panel_after_grace_period() { - assert!(should_hide_for_pointer_down_outside(true, true, false, 260)); + #[serial] + fn repeated_internal_activity_does_not_break_later_focus_loss_close() { + reset_panel_state_for_test(); + register_panel_opened(); + register_panel_opened(); + register_panel_opened(); + + assert!(register_panel_focus_loss(true)); } #[test] - fn pointer_down_does_not_hide_inside_panel_or_before_grace_period() { - assert!(!should_hide_for_pointer_down_outside(true, true, true, 260)); - assert!(!should_hide_for_pointer_down_outside( - true, true, false, 249 - )); - assert!(!should_hide_for_pointer_down_outside( - false, true, false, 260 - )); - assert!(!should_hide_for_pointer_down_outside( - true, false, false, 260 - )); + #[serial] + fn closed_panel_does_not_hide_on_later_focus_loss() { + reset_panel_state_for_test(); + register_panel_opened(); + + register_panel_closed(); + + assert!(!register_panel_focus_loss(true)); } #[test] - fn window_focus_loss_hides_after_grace_even_before_focus_observed() { - assert!(should_hide_for_window_focus_loss(true, true, 260)); + #[serial] + fn reopened_panel_hides_on_focus_loss() { + reset_panel_state_for_test(); + register_panel_opened(); + register_panel_closed(); + + register_panel_opened(); + + assert!(register_panel_focus_loss(true)); } #[test] - fn window_focus_loss_does_not_hide_before_grace_or_without_session() { - assert!(!should_hide_for_window_focus_loss(true, true, 249)); - assert!(!should_hide_for_window_focus_loss(true, false, 260)); - assert!(!should_hide_for_window_focus_loss(false, true, 260)); + #[serial] + fn closing_panel_resets_active_state() { + reset_panel_state_for_test(); + register_panel_opened(); + + register_panel_closed(); + + assert!(!PANEL_IS_OPEN.load(Ordering::Acquire)); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 16a11492..89db315f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenUsage", - "version": "0.6.28", + "version": "0.6.29", "identifier": "com.sunstory.openusage", "build": { "beforeDevCommand": "bun run bundle:plugins && bun run dev",