From 8d7a07b237ac066ca508fde74414385d11c79754 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 21 Oct 2026 15:58:02 +0700 Subject: [PATCH] feat: hide the bubbles while another app is fullscreen A 1.5 s thread asks the platform whether the foreground window is a fullscreen window of another process and feeds the result through the visibility state machine. Windows keeps the proven evaluation with an own-process check in place of the bubble class check; Linux reads the EWMH active window state over X11; native Wayland and macOS report false. --- src/fullscreen.rs | 122 ++++++++++++++++++++ src/lib.rs | 1 + src/platform/linux.rs | 148 +++++++++++++++++++++++- src/platform/macos.rs | 7 ++ src/platform/mod.rs | 46 ++++++++ src/platform/windows.rs | 245 +++++++++++++++++++++++++++++++++++++++- src/runtime.rs | 7 +- 7 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 src/fullscreen.rs diff --git a/src/fullscreen.rs b/src/fullscreen.rs new file mode 100644 index 0000000..f22f6b0 --- /dev/null +++ b/src/fullscreen.rs @@ -0,0 +1,122 @@ +//! Fullscreen auto-hide: a background thread checks every 1.5 s whether a +//! 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. + +use std::io; +use std::sync::Arc; +use std::time::Duration; + +use crate::core::visibility::Event; +use crate::platform::{self, FullscreenCheck}; +use crate::runtime::Runtime; + +/// How often the foreground window is checked. +pub const CHECK_INTERVAL: Duration = Duration::from_millis(1500); + +/// 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 || { + while !runtime.is_exiting() { + tick(&runtime); + std::thread::sleep(CHECK_INTERVAL); + } + }) + .map(drop) +} + +/// One check: read the foreground state and, when it differs from what the +/// bubbles reflect, apply the matching visibility event. The state is read +/// and released before any window call (`apply_visibility` does the same). +fn tick(runtime: &Runtime) { + let check = platform::foreground_is_fullscreen(); + let visibility = runtime.with_state(|s| s.visibility); + let Some(event) = event_for(visibility.hidden_by_fullscreen, check.fullscreen) else { + return; + }; + log_decision(event, &check, visibility.user_hidden); + runtime.apply_visibility(event); +} + +/// The visibility event for a check result, or `None` when the bubbles +/// already reflect it. +pub fn event_for(hidden_by_fullscreen: bool, fullscreen: bool) -> Option { + match (hidden_by_fullscreen, fullscreen) { + (false, true) => Some(Event::FullscreenOn), + (true, false) => Some(Event::FullscreenOff), + _ => None, + } +} + +fn log_decision(event: Event, check: &FullscreenCheck, user_hidden: bool) { + let action = if event == Event::FullscreenOn { + "hide" + } else { + "show" + }; + log::info!( + "bubble fullscreen {action} reason={} {} user_hidden={user_hidden}", + check.reason, + check.detail + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::visibility::{self, Action, Visibility}; + + #[test] + fn only_a_change_produces_an_event() { + assert_eq!(event_for(false, true), Some(Event::FullscreenOn)); + assert_eq!(event_for(true, false), Some(Event::FullscreenOff)); + assert_eq!(event_for(false, false), None); + assert_eq!(event_for(true, true), None); + } + + /// Feed a sequence of check results through the state machine and + /// collect the actions the bubbles receive. + fn run(start: Visibility, checks: &[bool]) -> (Visibility, Vec) { + let mut state = start; + let mut actions = Vec::new(); + for &fullscreen in checks { + if let Some(event) = event_for(state.hidden_by_fullscreen, fullscreen) { + let (next, action) = visibility::next(state, event); + state = next; + actions.extend(action); + } + } + (state, actions) + } + + #[test] + fn a_fullscreen_spell_hides_once_and_shows_once() { + let (state, actions) = run(Visibility::default(), &[false, true, true, true, false]); + assert_eq!(actions, [Action::Hide, Action::Show]); + assert_eq!(state, Visibility::default()); + } + + #[test] + fn user_hidden_bubbles_stay_hidden_after_fullscreen() { + let start = Visibility::from_widget_visible(false); + let (state, actions) = run(start, &[true, false]); + assert_eq!(actions, [Action::Hide]); + assert_eq!(state, start); + } + + #[test] + fn a_user_show_during_fullscreen_is_not_undone_by_the_next_check() { + let (hidden, _) = run(Visibility::default(), &[true]); + let (shown, action) = visibility::next(hidden, Event::UserShow); + assert_eq!(action, Some(Action::Show)); + // Fullscreen is still on: no event, so the bubbles stay shown. + let (state, actions) = run(shown, &[true, true]); + assert!(actions.is_empty()); + // Leaving fullscreen clears the flag; the bubbles are already shown. + let (state, actions) = run(state, &[false]); + assert_eq!(actions, [Action::Show]); + assert!(state.shows()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4a8132e..7d72818 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod commands; pub mod core; pub mod creds; pub mod diag; +pub mod fullscreen; pub mod handoff; pub mod menu; pub mod net; diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 5ae06dc..9e2219b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -4,9 +4,11 @@ use std::ffi::OsString; use std::sync::OnceLock; use x11rb::connection::Connection; -use x11rb::protocol::xproto::{ConnectionExt, KeyButMask, Window}; +use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt, KeyButMask, Window}; use x11rb::rust_connection::RustConnection; +use super::FullscreenCheck; + /// The AppIndicator libraries the tray backend loads, in its own order. The /// unversioned names come from the backend's `backcompat` feature (on by /// default) and are sometimes the only ones present in an AppImage. @@ -106,6 +108,112 @@ pub fn button1_down() -> bool { } } +x11rb::atom_manager! { + /// The EWMH atoms the fullscreen check reads. + FullscreenAtoms: FullscreenAtomsCookie { + _NET_ACTIVE_WINDOW, + _NET_WM_STATE, + _NET_WM_STATE_FULLSCREEN, + _NET_WM_STATE_HIDDEN, + _NET_WM_PID, + } +} + +/// The fullscreen atoms on the shared connection, interned on first use; +/// `None` without X11 or when interning failed (then it is not retried). +fn fullscreen_atoms() -> Option<(&'static X11, FullscreenAtoms)> { + static ATOMS: OnceLock> = OnceLock::new(); + let x11 = x11()?; + let atoms = ATOMS.get_or_init(|| { + let atoms = FullscreenAtoms::new(&x11.conn) + .map_err(|err| err.to_string()) + .and_then(|cookie| cookie.reply().map_err(|err| err.to_string())); + match atoms { + Ok(atoms) => Some(atoms), + Err(err) => { + log::warn!("EWMH atoms could not be interned: {err}"); + None + } + } + }); + atoms.map(|atoms| (x11, atoms)) +} + +/// The 32-bit values of `property` on `window`, at most `max` of them. +/// Empty when the property is missing or has another type or format. +fn property32( + x11: &X11, + window: Window, + property: Atom, + kind: AtomEnum, + max: u32, +) -> Result, String> { + let reply = x11 + .conn + .get_property(false, window, property, kind, 0, max) + .map_err(|err| err.to_string())? + .reply() + .map_err(|err| err.to_string())?; + Ok(reply.value32().map(Iterator::collect).unwrap_or_default()) +} + +/// Whether the EWMH active window is fullscreen and belongs to another +/// process. False without X11, without an active window or when a query +/// fails (a window can vanish between the reads). +pub fn foreground_is_fullscreen() -> FullscreenCheck { + let Some((x11, atoms)) = fullscreen_atoms() else { + return FullscreenCheck::new(false, "no X11 connection"); + }; + let active = match property32(x11, x11.root, atoms._NET_ACTIVE_WINDOW, AtomEnum::WINDOW, 1) { + Ok(values) => values.first().copied().unwrap_or(0), + Err(err) => { + log::debug!("_NET_ACTIVE_WINDOW unreadable: {err}"); + return FullscreenCheck::new(false, "active window unreadable"); + } + }; + if active == 0 { + return FullscreenCheck::new(false, "no active window"); + } + let state = property32(x11, active, atoms._NET_WM_STATE, AtomEnum::ATOM, 64); + let pid = property32(x11, active, atoms._NET_WM_PID, AtomEnum::CARDINAL, 1); + let (state, pid) = match (state, pid) { + (Ok(state), Ok(pid)) => (state, pid.first().copied()), + (Err(err), _) | (_, Err(err)) => { + log::debug!("state of window 0x{active:X} unreadable: {err}"); + return FullscreenCheck::new(false, "active window unreadable"); + } + }; + let (fullscreen, reason) = evaluate_active_window(&state, &atoms, pid, std::process::id()); + FullscreenCheck { + fullscreen, + reason, + detail: format!( + "window=0x{active:X} pid={}", + pid.map_or_else(|| "".to_string(), |p| p.to_string()) + ), + } +} + +/// The decision for the active window from its `_NET_WM_STATE` atoms and +/// `_NET_WM_PID`: fullscreen, not minimized, and not one of our windows. +fn evaluate_active_window( + state: &[Atom], + atoms: &FullscreenAtoms, + window_pid: Option, + own_pid: u32, +) -> (bool, &'static str) { + if window_pid == Some(own_pid) { + return (false, "foreground window is ours"); + } + if state.contains(&atoms._NET_WM_STATE_HIDDEN) { + return (false, "foreground minimized"); + } + if !state.contains(&atoms._NET_WM_STATE_FULLSCREEN) { + return (false, "foreground not fullscreen"); + } + (true, "foreground fullscreen") +} + /// How long the portal settings read may block. const PORTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); @@ -177,6 +285,44 @@ mod tests { assert!(!positioning_supported(None, os("broadway"))); } + const ATOMS: FullscreenAtoms = FullscreenAtoms { + _NET_ACTIVE_WINDOW: 1, + _NET_WM_STATE: 2, + _NET_WM_STATE_FULLSCREEN: 3, + _NET_WM_STATE_HIDDEN: 4, + _NET_WM_PID: 5, + }; + const ABOVE: Atom = 99; + + #[test] + fn fullscreen_needs_the_state_atom_on_a_window_of_another_process() { + let fs = ATOMS._NET_WM_STATE_FULLSCREEN; + assert_eq!( + evaluate_active_window(&[ABOVE, fs], &ATOMS, Some(42), 7), + (true, "foreground fullscreen") + ); + // A window without `_NET_WM_PID` cannot be ours. + assert!(evaluate_active_window(&[fs], &ATOMS, None, 7).0); + assert_eq!( + evaluate_active_window(&[ABOVE], &ATOMS, Some(42), 7), + (false, "foreground not fullscreen") + ); + assert!(!evaluate_active_window(&[], &ATOMS, Some(42), 7).0); + } + + #[test] + fn our_own_and_minimized_windows_never_count_as_fullscreen() { + let fs = ATOMS._NET_WM_STATE_FULLSCREEN; + assert_eq!( + evaluate_active_window(&[fs], &ATOMS, Some(7), 7), + (false, "foreground window is ours") + ); + assert_eq!( + evaluate_active_window(&[fs, ATOMS._NET_WM_STATE_HIDDEN], &ATOMS, Some(42), 7), + (false, "foreground minimized") + ); + } + #[test] fn only_a_stated_portal_preference_sets_the_theme() { assert_eq!(color_scheme_is_dark(Some(1)), Some(true)); diff --git a/src/platform/macos.rs b/src/platform/macos.rs index b2fafce..fd55a34 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -7,3 +7,10 @@ pub fn primary_button_down() -> bool { false } + +/// Whether a fullscreen window of another app is frontmost. Always false +/// for now; `CGWindowListCopyWindowInfo` with the on-screen window list +/// (layer 0, bounds covering a display, owner PID not ours) would answer it. +pub fn foreground_is_fullscreen() -> super::FullscreenCheck { + super::FullscreenCheck::new(false, "not implemented on macOS") +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index de5184a..fe0ea1a 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -112,6 +112,52 @@ pub fn primary_button_down() -> bool { } } +/// The result of one fullscreen check: whether a fullscreen window of +/// another process is in the foreground, and why. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FullscreenCheck { + pub fullscreen: bool, + pub reason: &'static str, + /// The window and geometry behind the decision for the diagnostic log; + /// empty when there is nothing to add. + pub detail: String, +} + +impl FullscreenCheck { + pub fn new(fullscreen: bool, reason: &'static str) -> Self { + Self { + fullscreen, + reason, + detail: String::new(), + } + } +} + +/// Whether the foreground window is a fullscreen window that is not ours. +/// Native Wayland and macOS cannot tell and always report false. +pub fn foreground_is_fullscreen() -> FullscreenCheck { + #[cfg(windows)] + { + windows::foreground_is_fullscreen() + } + #[cfg(target_os = "linux")] + { + if positioning_supported() { + linux::foreground_is_fullscreen() + } else { + FullscreenCheck::new(false, "native Wayland cannot report the foreground") + } + } + #[cfg(target_os = "macos")] + { + macos::foreground_is_fullscreen() + } + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] + { + FullscreenCheck::new(false, "unsupported platform") + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 3dfa276..28c7bf3 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -9,11 +9,15 @@ use webview2_com::Microsoft::Web::WebView2::Win32::{ }; use windows::core::{Interface, HSTRING, PCWSTR, PWSTR}; use windows::Win32::Foundation::{ - CloseHandle, SetLastError, HWND, LPARAM, LRESULT, WAIT_OBJECT_0, WIN32_ERROR, WPARAM, + CloseHandle, GetLastError, SetLastError, HWND, LPARAM, LRESULT, RECT, WAIT_OBJECT_0, + WIN32_ERROR, WPARAM, }; use windows::Win32::Graphics::Dwm::{ - DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND, - DWM_WINDOW_CORNER_PREFERENCE, + DwmGetWindowAttribute, DwmSetWindowAttribute, DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS, + DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND, DWM_WINDOW_CORNER_PREFERENCE, +}; +use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST, }; use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}; use windows::Win32::UI::Shell::{ @@ -22,14 +26,15 @@ use windows::Win32::UI::Shell::{ ABE_TOP, ABM_GETTASKBARPOS, APPBARDATA, }; use windows::Win32::UI::WindowsAndMessaging::{ - GetWindowLongPtrW, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos, - ShowWindow, GWL_EXSTYLE, MA_NOACTIVATE, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, + 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, WM_SETTINGCHANGE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, }; -use crate::core::geom::{Edge, Rect}; +use crate::core::geom::{self, Edge, Rect}; use crate::core::proxy::{self, SystemProxy}; const INTERNET_SETTINGS_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"; @@ -421,10 +426,238 @@ pub fn set_foreground(window: &tauri::WebviewWindow) { } } +// ---------- Fullscreen detection ---------- + +/// Shell windows that cover a monitor without being a fullscreen app. +const IGNORED_FULLSCREEN_CLASSES: [&str; 3] = ["Progman", "WorkerW", "Shell_TrayWnd"]; + +struct FullscreenEvaluation { + is_fullscreen: bool, + hwnd: HWND, + class_name: String, + bounds: Option, + bounds_source: &'static str, + monitor: Option, + reason: &'static str, +} + +/// Whether the foreground window covers its whole monitor as a fullscreen +/// app: not a window of this process (bubbles, panel, popup menu, WebView +/// children), not a shell window, visible, not minimized or cloaked, its +/// visible bounds within 2 px of the monitor rect and its style borderless +/// or popup. +pub fn foreground_is_fullscreen() -> super::FullscreenCheck { + // SAFETY: plain FFI call with no arguments. + let fg = unsafe { GetForegroundWindow() }; + let evaluation = evaluate_foreground_fullscreen(fg); + super::FullscreenCheck { + fullscreen: evaluation.is_fullscreen, + reason: evaluation.reason, + detail: format!( + "fg=0x{:X} class={} bounds_source={} bounds={} monitor={}", + evaluation.hwnd.0 as usize, + if evaluation.class_name.is_empty() { + "" + } else { + evaluation.class_name.as_str() + }, + evaluation.bounds_source, + format_rect(evaluation.bounds.as_ref()), + format_rect(evaluation.monitor.as_ref()), + ), + } +} + +fn evaluate_foreground_fullscreen(fg: HWND) -> FullscreenEvaluation { + let mut evaluation = FullscreenEvaluation { + is_fullscreen: false, + hwnd: fg, + class_name: String::new(), + bounds: None, + bounds_source: "none", + monitor: None, + reason: "not evaluated", + }; + + if fg.is_invalid() { + evaluation.reason = "no foreground window"; + return evaluation; + } + + evaluation.class_name = window_class_name(fg); + if window_process_id(fg) == Some(std::process::id()) { + evaluation.reason = "foreground window is ours"; + return evaluation; + } + if is_ignored_fullscreen_class(&evaluation.class_name) { + evaluation.reason = "ignored foreground class"; + return evaluation; + } + // SAFETY: plain queries on a window handle; a stale one returns false. + if unsafe { !IsWindowVisible(fg).as_bool() || IsIconic(fg).as_bool() } { + evaluation.reason = "foreground invisible or minimized"; + return evaluation; + } + if is_dwm_cloaked(fg) { + evaluation.reason = "foreground cloaked"; + return evaluation; + } + + let Some((bounds, source)) = visible_window_bounds(fg) else { + evaluation.reason = "foreground bounds unavailable"; + return evaluation; + }; + evaluation.bounds = Some(bounds); + evaluation.bounds_source = source; + + // SAFETY: as above; NEAREST always yields a monitor for a live window. + let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) }; + if monitor.is_invalid() { + evaluation.reason = "foreground monitor unavailable"; + return evaluation; + } + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + // SAFETY: `info` is a correctly sized MONITORINFO. + if unsafe { !GetMonitorInfoW(monitor, &mut info).as_bool() } { + evaluation.reason = "foreground monitor info unavailable"; + return evaluation; + } + let monitor = rect_from(&info.rcMonitor); + evaluation.monitor = Some(monitor); + + if !geom::rect_covers_monitor(&bounds, &monitor) { + evaluation.reason = "visible bounds do not cover monitor"; + return evaluation; + } + + if !window_style_allows_fullscreen(fg) { + evaluation.reason = "standard framed window"; + return evaluation; + } + + evaluation.is_fullscreen = true; + evaluation.reason = "visible bounds cover monitor"; + evaluation +} + +/// The id of the process that created `hwnd`; `None` for a stale handle. +fn window_process_id(hwnd: HWND) -> Option { + let mut pid = 0u32; + // SAFETY: `pid` outlives the call; a stale handle returns thread id 0. + let thread = unsafe { GetWindowThreadProcessId(hwnd, Some(std::ptr::from_mut(&mut pid))) }; + (thread != 0).then_some(pid) +} + +/// The visible bounds of `hwnd`: the DWM extended frame (without the +/// invisible resize borders), else the window rect. +fn visible_window_bounds(hwnd: HWND) -> Option<(Rect, &'static str)> { + let mut rect = RECT::default(); + // SAFETY: the pointer and size describe `rect`, which outlives the call. + let dwm_ok = unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_EXTENDED_FRAME_BOUNDS, + std::ptr::from_mut(&mut rect).cast(), + std::mem::size_of::() as u32, + ) + .is_ok() + }; + if dwm_ok && !rect_is_empty(&rect) { + return Some((rect_from(&rect), "dwm-extended-frame")); + } + + // SAFETY: as above. + if unsafe { GetWindowRect(hwnd, &mut rect) }.is_err() { + return None; + } + if rect_is_empty(&rect) { + None + } else { + Some((rect_from(&rect), "window-rect")) + } +} + +/// Whether DWM cloaks `hwnd` (a suspended UWP app or a window on another +/// virtual desktop). +fn is_dwm_cloaked(hwnd: HWND) -> bool { + let mut cloaked = 0u32; + // SAFETY: the pointer and size describe `cloaked`, which outlives the call. + unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_CLOAKED, + std::ptr::from_mut(&mut cloaked).cast(), + std::mem::size_of::() as u32, + ) + .is_ok() + && cloaked != 0 + } +} + +fn window_class_name(hwnd: HWND) -> String { + let mut buf = [0u16; 256]; + // SAFETY: the slice bounds the write. + let len = unsafe { GetClassNameW(hwnd, &mut buf) }; + match usize::try_from(len) { + Ok(len) if len > 0 => String::from_utf16_lossy(&buf[..len]), + _ => String::new(), + } +} + +fn is_ignored_fullscreen_class(class_name: &str) -> bool { + IGNORED_FULLSCREEN_CLASSES + .iter() + .any(|ignored| class_name.eq_ignore_ascii_case(ignored)) +} + +fn window_style_allows_fullscreen(hwnd: HWND) -> bool { + // SAFETY: a zero style is only an error when the last error is set, + // so it is cleared first. + unsafe { SetLastError(WIN32_ERROR(0)) }; + let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) }; + if style == 0 && unsafe { GetLastError() } != WIN32_ERROR(0) { + return false; + } + // The style is a 32-bit value in a pointer-sized return. + geom::window_style_bits_allow_fullscreen(style as u32) +} + +fn rect_from(rect: &RECT) -> Rect { + Rect::new(rect.left, rect.top, rect.right, rect.bottom) +} + +fn rect_is_empty(rect: &RECT) -> bool { + rect.right <= rect.left || rect.bottom <= rect.top +} + +fn format_rect(rect: Option<&Rect>) -> String { + match rect { + Some(r) => format!("({},{} {}x{})", r.l, r.t, r.r - r.l, r.b - r.t), + None => String::from(""), + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn shell_classes_are_ignored_case_insensitively() { + assert!(is_ignored_fullscreen_class("Progman")); + assert!(is_ignored_fullscreen_class("workerw")); + assert!(is_ignored_fullscreen_class("SHELL_TRAYWND")); + assert!(!is_ignored_fullscreen_class("Chrome_WidgetWin_1")); + assert!(!is_ignored_fullscreen_class("")); + } + + #[test] + fn a_null_window_has_no_process() { + assert_eq!(window_process_id(HWND::default()), None); + } + #[test] fn run_value_path_is_quoted() { assert_eq!( diff --git a/src/runtime.rs b/src/runtime.rs index db660c6..c95283d 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -31,7 +31,7 @@ use crate::menu::{self, MenuCache, MenuSnapshot}; use crate::scheduler::{self, Action, Message, SchedulerHost}; use crate::tray::{self, Trays}; use crate::usage::{self, refresh, ProviderId, Registry}; -use crate::{bubble, diag, handoff, net, panel, platform}; +use crate::{bubble, diag, fullscreen, handoff, net, panel, platform}; /// Event carrying a [`BubblePayload`] to a bubble webview. pub const BUBBLE_UPDATE_EVENT: &str = "bubble:update"; @@ -822,6 +822,11 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box> { scheduler::spawn(config, rx, Arc::clone(&runtime))?; runtime.send(Message::PollNow); + // Without the thread the bubbles just stay up over fullscreen apps. + if let Err(err) = fullscreen::spawn(Arc::clone(&runtime)) { + log::error!("fullscreen check thread could not start: {err}"); + } + for provider in providers { bubble::create(app.handle(), provider)?; }