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.
This commit is contained in:
tiennm99 committed 2026-10-21 15:58:02 +07:00
1 parent 9c7ade8ea3
commit 8d7a07b237
7 files changed
+568 -8

No files matched your search

+122
View File
@@ -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<Runtime>) -> 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<Event> {
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<Action>) {
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());
}
}
+1
View File
@@ -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;
+147 -1
View File
@@ -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<Option<FullscreenAtoms>> = 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<Vec<u32>, 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(|| "<none>".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<u32>,
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));
+7
View File
@@ -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")
}
+46
View File
@@ -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::*;
+239 -6
View File
@@ -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<Rect>,
bounds_source: &'static str,
monitor: Option<Rect>,
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() {
"<unknown>"
} 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::<MONITORINFO>() 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<u32> {
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::<RECT>() 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::<u32>() 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("<none>"),
}
}
#[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!(
+6 -1
View File
@@ -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<dyn Error>> {
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)?;
}