mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-21 20:16:50 +00:00
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:
1 parent
9c7ade8ea3
commit
8d7a07b237
7 files changed
+568
-8
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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")
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)?;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user