diff --git a/Cargo.toml b/Cargo.toml index be6f9fb..ae316db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_System_Registry", "Win32_System_Threading", + "Win32_UI_Accessibility", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_Graphics_Dwm", diff --git a/README.md b/README.md index e704993..dbf6a94 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ self-updater are all written from scratch against the same public APIs - Auto-hide when a fullscreen app is in the foreground (games, video, presentations) — reappears when you leave fullscreen - Bubbles stay above other always-on-top windows, such as chat - notifications: on Windows a covered bubble is raised again within about - 1.5 s, and on every platform shown bubbles are put back on top every - minute, so nothing can keep them covered or hidden + notifications: on Windows a bubble goes back on top the moment another + app shows such a window over it, and on every platform shown bubbles are + put back on top every minute, so nothing can keep them covered or hidden ## Who this is for diff --git a/src/bubble.rs b/src/bubble.rs index 68e280f..bbb320d 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -15,9 +15,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; -use std::time::Duration; -#[cfg(not(windows))] -use std::time::Instant; +use std::time::{Duration, Instant}; use tauri::{ AppHandle, Manager, Monitor, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, @@ -416,6 +414,7 @@ fn prepare_native( if click_through { set_ignore_cursor(&window, true); } + watch_z_order(&hook_app); let hooked = platform::windows::install_bubble_hook(&window, move |event| { on_hook_event(&hook_app, provider, event); }); @@ -536,24 +535,69 @@ pub fn bring_back(app: &AppHandle) { #[cfg(windows)] pub fn keep_on_top(app: &AppHandle) { let handle = app.clone(); - let queued = app.run_on_main_thread(move || { - let shows = runtime_of(&handle).is_some_and(|r| r.with_state(|s| s.visibility.shows())); - if !shows { - return; - } - for (_, window) in windows(&handle) { - match platform::windows::covered_by_other_topmost(&window) { - Ok(true) => raise(&window), - Ok(false) => {} - Err(err) => log::warn!("{} on-top check failed: {err}", window.label()), - } - } - }); + let queued = app.run_on_main_thread(move || raise_covered(&handle)); if let Err(err) = queued { log::warn!("bubble on-top check could not reach the main thread: {err}"); } } +/// Most raises in [`RAISE_WINDOW`] before raising pauses until it ends, so +/// another app that also fights for the top cannot keep both busy; the +/// fullscreen check carries on at its own pace. +#[cfg(windows)] +const MAX_RAISES: u32 = 20; +#[cfg(windows)] +const RAISE_WINDOW: Duration = Duration::from_secs(1); + +/// Body of [`keep_on_top`]; runs on the main thread. +#[cfg(windows)] +fn raise_covered(app: &AppHandle) { + static RAISES: Mutex> = Mutex::new(None); + let shows = runtime_of(app).is_some_and(|r| r.with_state(|s| s.visibility.shows())); + if !shows { + return; + } + for (_, window) in windows(app) { + match platform::windows::covered_by_other_topmost(&window) { + Ok(true) => {} + Ok(false) => continue, + Err(err) => { + log::warn!("{} on-top check failed: {err}", window.label()); + continue; + } + } + let allowed = { + let mut raises = RAISES.lock().unwrap_or_else(PoisonError::into_inner); + let now = Instant::now(); + let (start, count) = raises.get_or_insert((now, 0)); + if now.duration_since(*start) >= RAISE_WINDOW { + (*start, *count) = (now, 0); + } + *count += 1; + if *count == MAX_RAISES + 1 { + log::warn!("bubble raises paused: another window keeps taking the top"); + } + *count <= MAX_RAISES + }; + if allowed { + raise(&window); + } + } +} + +/// Raise covered bubbles the moment another app shows a window or changes +/// the z-order. Installed once, on the main thread. +#[cfg(windows)] +fn watch_z_order(app: &AppHandle) { + static WATCHING: std::sync::Once = std::sync::Once::new(); + WATCHING.call_once(|| { + let handle = app.clone(); + if let Err(err) = platform::windows::watch_z_order(move || raise_covered(&handle)) { + log::warn!("z-order watch unavailable, relying on the periodic check: {err}"); + } + }); +} + fn raise(window: &WebviewWindow) { #[cfg(windows)] let result = platform::windows::raise_topmost(window).map_err(|e| e.to_string()); diff --git a/src/fullscreen.rs b/src/fullscreen.rs index 555e1e1..dd4a4ff 100644 --- a/src/fullscreen.rs +++ b/src/fullscreen.rs @@ -6,7 +6,8 @@ //! The same thread brings the bubbles back every minute while they should //! show, in case another window covered or hid them. On Windows it also //! re-raises, on every check, a bubble that another app's always-on-top -//! window (a chat toast, say) has just covered. +//! window (a chat toast, say) covers, as a backstop for the z-order watch +//! in `bubble.rs`. use std::io; use std::sync::Arc; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 064b399..074ac85 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1,5 +1,6 @@ //! Windows-only helpers. This is the only module that talks to Win32 APIs. +use std::cell::RefCell; use std::io; use std::path::Path; use std::time::Duration; @@ -20,6 +21,7 @@ use windows::Win32::Graphics::Gdi::{ GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST, }; use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}; +use windows::Win32::UI::Accessibility::{SetWinEventHook, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{ DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage, SetCurrentProcessExplicitAppUserModelID, SetWindowSubclass, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, @@ -28,11 +30,13 @@ use windows::Win32::UI::Shell::{ use windows::Win32::UI::WindowsAndMessaging::{ GetClassNameW, GetForegroundWindow, GetWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId, IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow, - SetWindowLongPtrW, SetWindowPos, ShowWindow, GWL_EXSTYLE, GWL_STYLE, GW_HWNDPREV, HWND_TOPMOST, - MA_NOACTIVATE, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, SWP_FRAMECHANGED, SWP_NOACTIVATE, - SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE, SW_SHOWNOACTIVATE, WM_DISPLAYCHANGE, - WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY, WM_SETTINGCHANGE, WS_EX_LAYERED, - WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, + SetWindowLongPtrW, SetWindowPos, ShowWindow, CHILDID_SELF, EVENT_OBJECT_REORDER, + EVENT_OBJECT_SHOW, GWL_EXSTYLE, GWL_STYLE, GW_HWNDPREV, HWND_TOPMOST, MA_NOACTIVATE, + MB_ICONERROR, MB_OK, MB_SETFOREGROUND, OBJID_WINDOW, SWP_FRAMECHANGED, SWP_NOACTIVATE, + SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE, SW_SHOWNOACTIVATE, WINEVENT_OUTOFCONTEXT, + WINEVENT_SKIPOWNPROCESS, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY, + WM_SETTINGCHANGE, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, + WS_EX_TRANSPARENT, }; use crate::core::geom::{self, Edge, Rect}; @@ -372,6 +376,67 @@ fn covers(other: HWND, ours: &Rect, own_pid: u32) -> bool { visible_window_bounds(other).is_some_and(|(theirs, _)| theirs.intersects(ours)) } +thread_local! { + /// The callback of [`watch_z_order`], on the thread that installed it. + static Z_ORDER_CALLBACK: RefCell>> = const { RefCell::new(None) }; +} + +/// Call `callback` as soon as a window of another process is shown or the +/// z-order changes, so a bubble can go back on top of a new always-on-top +/// window (a chat toast, say) at once instead of at the next poll. The +/// events arrive through this thread's message loop, so install it on the +/// main thread; the hook lives as long as the process. +pub fn watch_z_order(callback: impl Fn() + 'static) -> io::Result<()> { + Z_ORDER_CALLBACK.with(|slot| *slot.borrow_mut() = Some(Box::new(callback))); + // SAFETY: an out-of-context hook needs no module handle; the procedure + // is a plain function that lives for the whole process. + let hook = unsafe { + SetWinEventHook( + EVENT_OBJECT_SHOW, + EVENT_OBJECT_REORDER, + None, + Some(z_order_event_proc), + 0, + 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS, + ) + }; + if hook.is_invalid() { + Z_ORDER_CALLBACK.with(|slot| slot.borrow_mut().take()); + return Err(io::Error::other("SetWinEventHook failed")); + } + Ok(()) +} + +unsafe extern "system" fn z_order_event_proc( + _hook: HWINEVENTHOOK, + event: u32, + _hwnd: HWND, + id_object: i32, + id_child: i32, + _thread: u32, + _time: u32, +) { + // Only whole windows count: a shown caret or cursor is an object too, + // and a hidden window cannot cover anything. + let relevant = match event { + EVENT_OBJECT_SHOW => id_object == OBJID_WINDOW.0 && id_child == CHILDID_SELF as i32, + EVENT_OBJECT_REORDER => true, + _ => false, + }; + if relevant { + // `try_borrow` skips an event raised from inside the callback. + Z_ORDER_CALLBACK.with(|slot| { + let Ok(callback) = slot.try_borrow() else { + return; + }; + if let Some(callback) = callback.as_ref() { + callback(); + } + }); + } +} + /// Hide a bubble (see [`show_no_activate`]). pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> { let hwnd = hwnd_of(window)?;