feat: raise covered bubbles the moment another app's window appears

A WinEvent hook on window shows and z-order changes re-raises a bubble that another process's always-on-top window covers, instead of waiting for the 1.5 s check, which stays as a backstop. Raises are capped at 20 a second so an app that also fights for the top cannot keep both busy.
This commit is contained in:
tiennm99 committed 2026-10-27 15:56:32 +07:00
1 parent dfff4ebe9f
commit 5462df1e7e
5 files changed
+136 -25

No files matched your search

+1
View File
@@ -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",
+3 -3
View File
@@ -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
+60 -16
View File
@@ -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<Option<(Instant, u32)>> = 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());
+2 -1
View File
@@ -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;
+70 -5
View File
@@ -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<Option<Box<dyn Fn()>>> = 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)?;