mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-27 18:16:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15f1120132 | ||
|
|
5462df1e7e | ||
|
|
dfff4ebe9f | ||
|
|
4e2ecbf588 |
No files matched your search
Generated
+1
-1
@@ -4693,7 +4693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "usage-monitor"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"auto-launch",
|
||||
"dbus",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user