Compare commits

...
4 Commits
Author SHA1 Message Date
tiennm99 15f1120132 chore: bump version to 0.8.1 2026-10-27 15:57:33 +07:00
tiennm99 5462df1e7e feat: raise covered bubbles the moment another app's window appears
A WinEvent hook on window shows and z-order changes re-raises a bubble that another process's always-on-top window covers, instead of waiting for the 1.5 s check, which stays as a backstop. Raises are capped at 20 a second so an app that also fights for the top cannot keep both busy.
2026-10-27 15:56:32 +07:00
tiennm99 dfff4ebe9f style: make the bubble background 80% opaque 2026-10-27 15:56:32 +07:00
tiennm99 4e2ecbf588 fix: keep the bubble visible when click-through is toggled
On Windows, tao's set_ignore_cursor_events re-applies all its window flags. Bubbles are shown through ShowWindow, not tao, so tao believes them hidden: it hid the bubble and rewrote the extended style, dropping its no-activate and tool-window bits. The transparent and layered bits are now set directly on the main thread.
2026-10-27 15:56:32 +07:00
7 changed files with 189 additions and 37 deletions

No files matched your search

Generated
+1 -1
View File
@@ -4693,7 +4693,7 @@ dependencies = [
[[package]]
name = "usage-monitor"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"auto-launch",
"dbus",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "usage-monitor"
version = "0.8.0"
version = "0.8.1"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage"
@@ -56,6 +56,7 @@ windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_UI_Accessibility",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
"Win32_Graphics_Dwm",
+3 -3
View File
@@ -46,9 +46,9 @@ self-updater are all written from scratch against the same public APIs
- Auto-hide when a fullscreen app is in the foreground (games, video,
presentations) — reappears when you leave fullscreen
- Bubbles stay above other always-on-top windows, such as chat
notifications: on Windows a covered bubble is raised again within about
1.5 s, and on every platform shown bubbles are put back on top every
minute, so nothing can keep them covered or hidden
notifications: on Windows a bubble goes back on top the moment another
app shows such a window over it, and on every platform shown bubbles are
put back on top every minute, so nothing can keep them covered or hidden
## Who this is for
+90 -20
View File
@@ -15,9 +15,7 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
#[cfg(not(windows))]
use std::time::Instant;
use std::time::{Duration, Instant};
use tauri::{
AppHandle, Manager, Monitor, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
@@ -351,6 +349,8 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
set_position(&window, placement.pos);
}
set_size(&window, placement.size);
// Windows applies it on the main thread, in `prepare_native`.
#[cfg(not(windows))]
if click_through {
set_ignore_cursor(&window, true);
}
@@ -363,7 +363,7 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
placement.dpi
);
prepare_native(app, &window, provider);
prepare_native(app, &window, provider, click_through);
#[cfg(target_os = "linux")]
if let Some(is_dark) = platform::linux::portal_prefers_dark() {
runtime.set_dark(is_dark);
@@ -399,13 +399,22 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
/// move-end, setting-change and display-change messages. Both must run on
/// the window's own (main) thread before the first show.
#[cfg(windows)]
fn prepare_native(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) {
fn prepare_native(
app: &AppHandle,
window: &WebviewWindow,
provider: ProviderId,
click_through: bool,
) {
let window = window.clone();
let hook_app = app.clone();
let queued = app.run_on_main_thread(move || {
if let Err(err) = platform::windows::apply_no_activate(&window) {
log::warn!("{} may take focus: {err}", window.label());
}
if click_through {
set_ignore_cursor(&window, true);
}
watch_z_order(&hook_app);
let hooked = platform::windows::install_bubble_hook(&window, move |event| {
on_hook_event(&hook_app, provider, event);
});
@@ -419,7 +428,13 @@ fn prepare_native(app: &AppHandle, window: &WebviewWindow, provider: ProviderId)
}
#[cfg(not(windows))]
fn prepare_native(_app: &AppHandle, _window: &WebviewWindow, _provider: ProviderId) {}
fn prepare_native(
_app: &AppHandle,
_window: &WebviewWindow,
_provider: ProviderId,
_click_through: bool,
) {
}
#[cfg(windows)]
fn on_hook_event(app: &AppHandle, provider: ProviderId, event: platform::windows::BubbleHookEvent) {
@@ -520,24 +535,69 @@ pub fn bring_back(app: &AppHandle) {
#[cfg(windows)]
pub fn keep_on_top(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) {
match platform::windows::covered_by_other_topmost(&window) {
Ok(true) => raise(&window),
Ok(false) => {}
Err(err) => log::warn!("{} on-top check failed: {err}", window.label()),
}
}
});
let queued = app.run_on_main_thread(move || raise_covered(&handle));
if let Err(err) = queued {
log::warn!("bubble on-top check could not reach the main thread: {err}");
}
}
/// Most raises in [`RAISE_WINDOW`] before raising pauses until it ends, so
/// another app that also fights for the top cannot keep both busy; the
/// fullscreen check carries on at its own pace.
#[cfg(windows)]
const MAX_RAISES: u32 = 20;
#[cfg(windows)]
const RAISE_WINDOW: Duration = Duration::from_secs(1);
/// Body of [`keep_on_top`]; runs on the main thread.
#[cfg(windows)]
fn raise_covered(app: &AppHandle) {
static RAISES: Mutex<Option<(Instant, u32)>> = Mutex::new(None);
let shows = runtime_of(app).is_some_and(|r| r.with_state(|s| s.visibility.shows()));
if !shows {
return;
}
for (_, window) in windows(app) {
match platform::windows::covered_by_other_topmost(&window) {
Ok(true) => {}
Ok(false) => continue,
Err(err) => {
log::warn!("{} on-top check failed: {err}", window.label());
continue;
}
}
let allowed = {
let mut raises = RAISES.lock().unwrap_or_else(PoisonError::into_inner);
let now = Instant::now();
let (start, count) = raises.get_or_insert((now, 0));
if now.duration_since(*start) >= RAISE_WINDOW {
(*start, *count) = (now, 0);
}
*count += 1;
if *count == MAX_RAISES + 1 {
log::warn!("bubble raises paused: another window keeps taking the top");
}
*count <= MAX_RAISES
};
if allowed {
raise(&window);
}
}
}
/// Raise covered bubbles the moment another app shows a window or changes
/// the z-order. Installed once, on the main thread.
#[cfg(windows)]
fn watch_z_order(app: &AppHandle) {
static WATCHING: std::sync::Once = std::sync::Once::new();
WATCHING.call_once(|| {
let handle = app.clone();
if let Err(err) = platform::windows::watch_z_order(move || raise_covered(&handle)) {
log::warn!("z-order watch unavailable, relying on the periodic check: {err}");
}
});
}
fn raise(window: &WebviewWindow) {
#[cfg(windows)]
let result = platform::windows::raise_topmost(window).map_err(|e| e.to_string());
@@ -565,8 +625,18 @@ pub fn set_click_through(app: &AppHandle, ignore: bool) {
}
}
/// On Windows the style bits are set directly: tao's
/// `set_ignore_cursor_events` re-applies all its window flags, and since
/// bubbles are shown through [`show`], not tao, it believes them hidden and
/// hides them, also dropping their no-activate and tool-window styles.
fn set_ignore_cursor(window: &WebviewWindow, ignore: bool) {
if let Err(err) = window.set_ignore_cursor_events(ignore) {
#[cfg(windows)]
let result = platform::windows::set_click_through(window, ignore).map_err(|e| e.to_string());
#[cfg(not(windows))]
let result = window
.set_ignore_cursor_events(ignore)
.map_err(|e| e.to_string());
if let Err(err) = result {
log::warn!("{} click-through not set: {err}", window.label());
}
}
+2 -1
View File
@@ -6,7 +6,8 @@
//! The same thread brings the bubbles back every minute while they should
//! show, in case another window covered or hid them. On Windows it also
//! re-raises, on every check, a bubble that another app's always-on-top
//! window (a chat toast, say) has just covered.
//! window (a chat toast, say) covers, as a backstop for the z-order watch
//! in `bubble.rs`.
use std::io;
use std::sync::Arc;
+88 -8
View File
@@ -1,5 +1,6 @@
//! Windows-only helpers. This is the only module that talks to Win32 APIs.
use std::cell::RefCell;
use std::io;
use std::path::Path;
use std::time::Duration;
@@ -20,6 +21,7 @@ use windows::Win32::Graphics::Gdi::{
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
};
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
use windows::Win32::UI::Accessibility::{SetWinEventHook, HWINEVENTHOOK};
use windows::Win32::UI::Shell::{
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage,
SetCurrentProcessExplicitAppUserModelID, SetWindowSubclass, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT,
@@ -28,11 +30,13 @@ use windows::Win32::UI::Shell::{
use windows::Win32::UI::WindowsAndMessaging::{
GetClassNameW, GetForegroundWindow, GetWindow, GetWindowLongPtrW, GetWindowRect,
GetWindowThreadProcessId, IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow,
SetWindowLongPtrW, SetWindowPos, ShowWindow, GWL_EXSTYLE, GWL_STYLE, GW_HWNDPREV, 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, WS_EX_TOPMOST,
SetWindowLongPtrW, SetWindowPos, ShowWindow, CHILDID_SELF, EVENT_OBJECT_REORDER,
EVENT_OBJECT_SHOW, GWL_EXSTYLE, GWL_STYLE, GW_HWNDPREV, HWND_TOPMOST, MA_NOACTIVATE,
MB_ICONERROR, MB_OK, MB_SETFOREGROUND, OBJID_WINDOW, SWP_FRAMECHANGED, SWP_NOACTIVATE,
SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE, SW_SHOWNOACTIVATE, WINEVENT_OUTOFCONTEXT,
WINEVENT_SKIPOWNPROCESS, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY,
WM_SETTINGCHANGE, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST,
WS_EX_TRANSPARENT,
};
use crate::core::geom::{self, Edge, Rect};
@@ -203,19 +207,34 @@ pub fn apply_toolwindow(window: &tauri::WebviewWindow) -> io::Result<()> {
add_ex_style(window, WS_EX_TOOLWINDOW.0)
}
/// Let mouse input pass through a bubble to the windows below, or take it
/// again. These are the bits tao's `set_ignore_cursor_events` sets, without
/// its re-applying every window flag, which hides a bubble shown through
/// [`show_no_activate`]. Run it on the window's own (main) thread.
pub fn set_click_through(window: &tauri::WebviewWindow, ignore: bool) -> io::Result<()> {
set_ex_style(window, (WS_EX_TRANSPARENT | WS_EX_LAYERED).0, ignore)
}
/// OR `bits` into a window's extended style and apply the frame change.
fn add_ex_style(window: &tauri::WebviewWindow, bits: u32) -> io::Result<()> {
set_ex_style(window, bits, true)
}
/// Set or clear `bits` in a window's extended style and apply the frame
/// change.
fn set_ex_style(window: &tauri::WebviewWindow, bits: u32, on: bool) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
let wanted = bits as isize;
let bits = bits as isize;
// SAFETY: `hwnd` is a live top-level window of this process.
let current = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) };
if current & wanted == wanted {
let wanted = if on { current | bits } else { current & !bits };
if wanted == current {
return Ok(());
}
// SAFETY: as above. A zero return is only an error when the last error
// is set, so it is cleared first.
unsafe { SetLastError(WIN32_ERROR(0)) };
let previous = unsafe { SetWindowLongPtrW(hwnd, GWL_EXSTYLE, current | wanted) };
let previous = unsafe { SetWindowLongPtrW(hwnd, GWL_EXSTYLE, wanted) };
if previous == 0 {
let err = io::Error::last_os_error();
if err.raw_os_error().unwrap_or(0) != 0 {
@@ -357,6 +376,67 @@ fn covers(other: HWND, ours: &Rect, own_pid: u32) -> bool {
visible_window_bounds(other).is_some_and(|(theirs, _)| theirs.intersects(ours))
}
thread_local! {
/// The callback of [`watch_z_order`], on the thread that installed it.
static Z_ORDER_CALLBACK: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
}
/// Call `callback` as soon as a window of another process is shown or the
/// z-order changes, so a bubble can go back on top of a new always-on-top
/// window (a chat toast, say) at once instead of at the next poll. The
/// events arrive through this thread's message loop, so install it on the
/// main thread; the hook lives as long as the process.
pub fn watch_z_order(callback: impl Fn() + 'static) -> io::Result<()> {
Z_ORDER_CALLBACK.with(|slot| *slot.borrow_mut() = Some(Box::new(callback)));
// SAFETY: an out-of-context hook needs no module handle; the procedure
// is a plain function that lives for the whole process.
let hook = unsafe {
SetWinEventHook(
EVENT_OBJECT_SHOW,
EVENT_OBJECT_REORDER,
None,
Some(z_order_event_proc),
0,
0,
WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS,
)
};
if hook.is_invalid() {
Z_ORDER_CALLBACK.with(|slot| slot.borrow_mut().take());
return Err(io::Error::other("SetWinEventHook failed"));
}
Ok(())
}
unsafe extern "system" fn z_order_event_proc(
_hook: HWINEVENTHOOK,
event: u32,
_hwnd: HWND,
id_object: i32,
id_child: i32,
_thread: u32,
_time: u32,
) {
// Only whole windows count: a shown caret or cursor is an object too,
// and a hidden window cannot cover anything.
let relevant = match event {
EVENT_OBJECT_SHOW => id_object == OBJID_WINDOW.0 && id_child == CHILDID_SELF as i32,
EVENT_OBJECT_REORDER => true,
_ => false,
};
if relevant {
// `try_borrow` skips an event raised from inside the callback.
Z_ORDER_CALLBACK.with(|slot| {
let Ok(callback) = slot.try_borrow() else {
return;
};
if let Some(callback) = callback.as_ref() {
callback();
}
});
}
}
/// Hide a bubble (see [`show_no_activate`]).
pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
+3 -3
View File
@@ -1,9 +1,9 @@
/* Bubble palette. Accent and fill colours come from the payload. */
:root[data-theme='dark'] {
/* 60% opaque, so the desktop shows through. Tracks and the edge are
/* 80% opaque, so the desktop shows through. Tracks and the edge are
tints over it, so they keep the same contrast step on a light or dark
desktop; the text halo keeps text readable on either. */
--bg: rgb(31 31 31 / 60%);
--bg: rgb(31 31 31 / 80%);
--track: rgb(0 0 0 / 28%);
--time-track: rgb(255 255 255 / 14%);
--time-fill: #b0b0b0;
@@ -14,7 +14,7 @@
}
:root[data-theme='light'] {
--bg: rgb(243 243 243 / 60%);
--bg: rgb(243 243 243 / 80%);
--track: rgb(0 0 0 / 10%);
--time-track: rgb(0 0 0 / 8%);
--time-fill: #666666;