fix: bring hidden or covered bubbles back every minute

Another always-on-top window, or a shell action that hid ours, could
leave a bubble covered or hidden until restart. The fullscreen check
thread now shows every ready bubble again and puts it back at the top of
the always-on-top band once a minute, unless the user or a fullscreen
app has them hidden. Windows re-asserts HWND_TOPMOST without activating;
other platforms drop and re-set always-on-top.
This commit is contained in:
tiennm99 committed 2026-10-27 12:11:24 +07:00
1 parent 97100cfcc0
commit 5362e46be5
5 files changed
+82 -4

No files matched your search

+2
View File
@@ -45,6 +45,8 @@ 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
## Who this is for
+39
View File
@@ -489,6 +489,45 @@ pub fn apply(app: &AppHandle, action: Action) {
}
}
/// Show every ready bubble again and put it back on top, unless the
/// bubbles are meant to be hidden. Another always-on-top window, or a shell
/// action that hid ours, can otherwise leave a bubble covered or hidden for
/// good. A bubble still waiting for its page is left to the ready handshake.
/// Visibility is read on the main thread, after any hide queued before.
pub fn bring_back(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) {
if with_pending(|p| p.contains_key(window.label())) {
continue;
}
show(&window);
raise(&window);
}
});
if let Err(err) = queued {
log::warn!("bubble bring-back 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());
// Re-setting the same level is a no-op, so drop it and set it again.
#[cfg(not(windows))]
let result = window
.set_always_on_top(false)
.and_then(|()| window.set_always_on_top(true))
.map_err(|e| e.to_string());
if let Err(err) = result {
log::warn!("{} not raised: {err}", window.label());
}
}
/// Make every bubble ignore the mouse, or take it again.
pub fn set_click_through(app: &AppHandle, ignore: bool) {
let handle = app.clone();
+12 -1
View File
@@ -2,10 +2,13 @@
//! fullscreen window of another process is in the foreground, and hides the
//! bubbles while one is. They come back without activation when it leaves,
//! 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.
use std::io;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use crate::core::visibility::Event;
use crate::platform::{self, FullscreenCheck};
@@ -14,13 +17,21 @@ use crate::runtime::Runtime;
/// How often the foreground window is checked.
pub const CHECK_INTERVAL: Duration = Duration::from_millis(1500);
/// How often shown bubbles are brought back on top.
pub const BRING_BACK_INTERVAL: Duration = Duration::from_secs(60);
/// Start the check thread. It runs until the process starts exiting.
pub fn spawn(runtime: Arc<Runtime>) -> io::Result<()> {
std::thread::Builder::new()
.name("fullscreen".into())
.spawn(move || {
let mut last_bring_back = Instant::now();
while !runtime.is_exiting() {
tick(&runtime);
if last_bring_back.elapsed() >= BRING_BACK_INTERVAL {
last_bring_back = Instant::now();
runtime.bring_back_bubbles();
}
std::thread::sleep(CHECK_INTERVAL);
}
})
+23 -3
View File
@@ -28,9 +28,9 @@ use windows::Win32::UI::Shell::{
use windows::Win32::UI::WindowsAndMessaging::{
GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId,
IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos,
ShowWindow, GWL_EXSTYLE, GWL_STYLE, 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,
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,
};
@@ -293,6 +293,26 @@ pub fn show_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> {
Ok(())
}
/// Put a bubble back at the top of the always-on-top band, above any
/// topmost window that came after it, without activating it.
pub fn raise_topmost(window: &tauri::WebviewWindow) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
// SAFETY: `hwnd` is a live window of this process; nothing moves,
// resizes or activates.
unsafe {
SetWindowPos(
hwnd,
Some(HWND_TOPMOST),
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
)
}
.map_err(io::Error::from)
}
/// Hide a bubble (see [`show_no_activate`]).
pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
+6
View File
@@ -506,6 +506,12 @@ impl Runtime {
bubble::set_click_through(&self.ui.app, ignore);
}
/// Show the bubbles again and put them back on top, unless they are
/// meant to be hidden.
pub fn bring_back_bubbles(&self) {
bubble::bring_back(&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) {