diff --git a/README.md b/README.md index 400dc00..7ff5386 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/bubble.rs b/src/bubble.rs index 191662b..5bd9e61 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -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(); diff --git a/src/fullscreen.rs b/src/fullscreen.rs index f22f6b0..6ca8365 100644 --- a/src/fullscreen.rs +++ b/src/fullscreen.rs @@ -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) -> 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); } }) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index b9cc7d1..6acb7b2 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -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)?; diff --git a/src/runtime.rs b/src/runtime.rs index 918fd33..b752a6a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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) {