diff --git a/README.md b/README.md index 7ff5386..1b53ec9 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,10 @@ self-updater are all written from scratch against the same public APIs expires and after an update was applied - Auto-hide when a fullscreen app is in the foreground (games, video, presentations) — reappears when you leave fullscreen -- Every minute, shown bubbles are put back on top, so another - always-on-top window can't keep them covered or hidden +- 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 ## Who this is for diff --git a/src/bubble.rs b/src/bubble.rs index 5bd9e61..b2b7d46 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -514,6 +514,30 @@ pub fn bring_back(app: &AppHandle) { } } +/// Put back on top every shown bubble that another app's always-on-top +/// window, such as a chat toast, now covers. Cheap enough to run on every +/// fullscreen check; bubbles hidden for a fullscreen app are left alone. +#[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()), + } + } + }); + if let Err(err) = queued { + log::warn!("bubble on-top check could not reach the main thread: {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 6ca8365..555e1e1 100644 --- a/src/fullscreen.rs +++ b/src/fullscreen.rs @@ -4,7 +4,9 @@ //! unless the user hid them in the meantime. //! //! The same thread brings the bubbles back every minute while they should -//! show, in case another window covered or hid them. +//! 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. use std::io; use std::sync::Arc; @@ -28,6 +30,8 @@ pub fn spawn(runtime: Arc) -> io::Result<()> { let mut last_bring_back = Instant::now(); while !runtime.is_exiting() { tick(&runtime); + #[cfg(windows)] + runtime.keep_bubbles_on_top(); if last_bring_back.elapsed() >= BRING_BACK_INTERVAL { last_bring_back = Instant::now(); runtime.bring_back_bubbles(); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 6acb7b2..f848248 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -26,12 +26,13 @@ use windows::Win32::UI::Shell::{ ABE_TOP, ABM_GETTASKBARPOS, APPBARDATA, }; use windows::Win32::UI::WindowsAndMessaging::{ - GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId, - IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos, - ShowWindow, GWL_EXSTYLE, GWL_STYLE, 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_NOACTIVATE, WS_EX_TOOLWINDOW, + 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_NOACTIVATE, + WS_EX_TOOLWINDOW, WS_EX_TOPMOST, }; use crate::core::geom::{self, Edge, Rect}; @@ -313,6 +314,49 @@ pub fn raise_topmost(window: &tauri::WebviewWindow) -> io::Result<()> { .map_err(io::Error::from) } +/// Whether a visible always-on-top window of another process sits above +/// `window` in the z-order and overlaps it, such as a chat app's toast. +/// Our own windows (the panel, a context menu) never count, so raising a +/// bubble over them is never asked for. +pub fn covered_by_other_topmost(window: &tauri::WebviewWindow) -> io::Result { + let hwnd = hwnd_of(window)?; + let Some((ours, _)) = visible_window_bounds(hwnd) else { + return Ok(false); + }; + let own_pid = std::process::id(); + // SAFETY: `hwnd` is a live window of this process; each step only reads + // the z-order, and a window closing mid-walk ends it with an error. + let mut above = unsafe { GetWindow(hwnd, GW_HWNDPREV) }; + // Topmost windows sit at the head of the z-order, so the walk is short; + // the cap guards against a z-order that keeps changing under it. + for _ in 0..MAX_Z_ORDER_WALK { + let Ok(other) = above else { + return Ok(false); + }; + if covers(other, &ours, own_pid) { + return Ok(true); + } + // SAFETY: as above. + above = unsafe { GetWindow(other, GW_HWNDPREV) }; + } + Ok(false) +} + +const MAX_Z_ORDER_WALK: usize = 1024; + +fn covers(other: HWND, ours: &Rect, own_pid: u32) -> bool { + // SAFETY: read-only queries; a stale handle yields 0 / false. + let ex_style = unsafe { GetWindowLongPtrW(other, GWL_EXSTYLE) } as u32; + if ex_style & WS_EX_TOPMOST.0 == 0 + || unsafe { !IsWindowVisible(other).as_bool() } + || is_dwm_cloaked(other) + || window_process_id(other).is_none_or(|pid| pid == own_pid) + { + return false; + } + visible_window_bounds(other).is_some_and(|(theirs, _)| theirs.intersects(ours)) +} + /// Hide a bubble (see [`show_no_activate`]). pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> { let hwnd = hwnd_of(window)?; diff --git a/src/runtime.rs b/src/runtime.rs index b752a6a..4637566 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -512,6 +512,12 @@ impl Runtime { bubble::bring_back(&self.ui.app); } + /// Raise shown bubbles that another app's always-on-top window covers. + #[cfg(windows)] + pub fn keep_bubbles_on_top(&self) { + bubble::keep_on_top(&self.ui.app); + } + /// Apply a visibility event to every bubble. A user show or hide is /// also saved as `widget_visible`. pub fn apply_visibility(&self, event: visibility::Event) {