mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-27 12:16:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd5c2ba910 | ||
|
|
cf058a4a38 | ||
|
|
3aaf963841 | ||
|
|
c7af9ed0ac |
No files matched your search
Generated
+1
-1
@@ -4693,7 +4693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "usage-monitor"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"auto-launch",
|
||||
"dbus",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "usage-monitor"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage"
|
||||
|
||||
@@ -45,8 +45,10 @@ self-updater are all written from scratch against the same public APIs
|
||||
expires and after an update was applied
|
||||
- Auto-hide when a fullscreen app is in the foreground (games, video,
|
||||
presentations) — reappears when you leave fullscreen
|
||||
- Every minute, shown bubbles are put back on top, so another
|
||||
always-on-top window can't keep them covered or hidden
|
||||
- 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
|
||||
|
||||
## Who this is for
|
||||
|
||||
@@ -100,7 +102,8 @@ Grab `usage-monitor.exe` from the
|
||||
Put it in a folder of its own that you can write to (e.g.
|
||||
`%LOCALAPPDATA%\UsageMonitor\`) and run it. The app is portable: it keeps
|
||||
`settings.json` next to the exe, so moving the folder keeps your settings. It
|
||||
self-updates from the same Releases feed.
|
||||
updates itself from the same Releases feed: when a check finds a new
|
||||
release, it installs it and restarts on its own, with no click needed.
|
||||
|
||||
**First-run note:** the binary is unsigned, so SmartScreen will show
|
||||
"Windows protected your PC". Click **More info** → **Run anyway**. Code
|
||||
@@ -115,7 +118,7 @@ pick one:
|
||||
- `usage-monitor_X.Y.Z_amd64.AppImage`: make it executable
|
||||
(`chmod +x`) and run it. Like the Windows exe it is portable and keeps
|
||||
`settings.json` next to the AppImage file, so give it a folder you can
|
||||
write to. The AppImage self-updates in place. If you turned on "Start
|
||||
write to. The AppImage updates itself in place, the same way. If you turned on "Start
|
||||
at login" in v0.6.0 or earlier and the AppImage path contains a space,
|
||||
turn it off and on again once: those releases wrote the path unquoted,
|
||||
so the app never started at login.
|
||||
|
||||
@@ -514,6 +514,30 @@ pub fn bring_back(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put back on top every shown bubble that another app's always-on-top
|
||||
/// window, such as a chat toast, now covers. Cheap enough to run on every
|
||||
/// fullscreen check; bubbles hidden for a fullscreen app are left alone.
|
||||
#[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()),
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(err) = queued {
|
||||
log::warn!("bubble on-top check could not reach the main thread: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn raise(window: &WebviewWindow) {
|
||||
#[cfg(windows)]
|
||||
let result = platform::windows::raise_topmost(window).map_err(|e| e.to_string());
|
||||
|
||||
+5
-1
@@ -4,7 +4,9 @@
|
||||
//! unless the user hid them in the meantime.
|
||||
//!
|
||||
//! The same thread brings the bubbles back every minute while they should
|
||||
//! show, in case another window covered or hid them.
|
||||
//! 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.
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
@@ -28,6 +30,8 @@ pub fn spawn(runtime: Arc<Runtime>) -> io::Result<()> {
|
||||
let mut last_bring_back = Instant::now();
|
||||
while !runtime.is_exiting() {
|
||||
tick(&runtime);
|
||||
#[cfg(windows)]
|
||||
runtime.keep_bubbles_on_top();
|
||||
if last_bring_back.elapsed() >= BRING_BACK_INTERVAL {
|
||||
last_bring_back = Instant::now();
|
||||
runtime.bring_back_bubbles();
|
||||
|
||||
+50
-6
@@ -26,12 +26,13 @@ use windows::Win32::UI::Shell::{
|
||||
ABE_TOP, ABM_GETTASKBARPOS, APPBARDATA,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId,
|
||||
IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos,
|
||||
ShowWindow, GWL_EXSTYLE, GWL_STYLE, 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,
|
||||
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,
|
||||
};
|
||||
|
||||
use crate::core::geom::{self, Edge, Rect};
|
||||
@@ -313,6 +314,49 @@ pub fn raise_topmost(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
.map_err(io::Error::from)
|
||||
}
|
||||
|
||||
/// Whether a visible always-on-top window of another process sits above
|
||||
/// `window` in the z-order and overlaps it, such as a chat app's toast.
|
||||
/// Our own windows (the panel, a context menu) never count, so raising a
|
||||
/// bubble over them is never asked for.
|
||||
pub fn covered_by_other_topmost(window: &tauri::WebviewWindow) -> io::Result<bool> {
|
||||
let hwnd = hwnd_of(window)?;
|
||||
let Some((ours, _)) = visible_window_bounds(hwnd) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let own_pid = std::process::id();
|
||||
// SAFETY: `hwnd` is a live window of this process; each step only reads
|
||||
// the z-order, and a window closing mid-walk ends it with an error.
|
||||
let mut above = unsafe { GetWindow(hwnd, GW_HWNDPREV) };
|
||||
// Topmost windows sit at the head of the z-order, so the walk is short;
|
||||
// the cap guards against a z-order that keeps changing under it.
|
||||
for _ in 0..MAX_Z_ORDER_WALK {
|
||||
let Ok(other) = above else {
|
||||
return Ok(false);
|
||||
};
|
||||
if covers(other, &ours, own_pid) {
|
||||
return Ok(true);
|
||||
}
|
||||
// SAFETY: as above.
|
||||
above = unsafe { GetWindow(other, GW_HWNDPREV) };
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
const MAX_Z_ORDER_WALK: usize = 1024;
|
||||
|
||||
fn covers(other: HWND, ours: &Rect, own_pid: u32) -> bool {
|
||||
// SAFETY: read-only queries; a stale handle yields 0 / false.
|
||||
let ex_style = unsafe { GetWindowLongPtrW(other, GWL_EXSTYLE) } as u32;
|
||||
if ex_style & WS_EX_TOPMOST.0 == 0
|
||||
|| unsafe { !IsWindowVisible(other).as_bool() }
|
||||
|| is_dwm_cloaked(other)
|
||||
|| window_process_id(other).is_none_or(|pid| pid == own_pid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
visible_window_bounds(other).is_some_and(|(theirs, _)| theirs.intersects(ours))
|
||||
}
|
||||
|
||||
/// Hide a bubble (see [`show_no_activate`]).
|
||||
pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
let hwnd = hwnd_of(window)?;
|
||||
|
||||
+16
-2
@@ -512,6 +512,12 @@ impl Runtime {
|
||||
bubble::bring_back(&self.ui.app);
|
||||
}
|
||||
|
||||
/// Raise shown bubbles that another app's always-on-top window covers.
|
||||
#[cfg(windows)]
|
||||
pub fn keep_bubbles_on_top(&self) {
|
||||
bubble::keep_on_top(&self.ui.app);
|
||||
}
|
||||
|
||||
/// Apply a visibility event to every bubble. A user show or hide is
|
||||
/// also saved as `widget_visible`.
|
||||
pub fn apply_visibility(&self, event: visibility::Event) {
|
||||
@@ -651,8 +657,9 @@ impl Runtime {
|
||||
|
||||
/// Blocking body of [`Self::check_for_updates`]. Only a check that got
|
||||
/// an answer is stamped as the last check; a failed one leaves the
|
||||
/// stamp alone and has the scheduler retry sooner.
|
||||
fn run_update_check(&self) {
|
||||
/// stamp alone and has the scheduler retry sooner. A release found where
|
||||
/// the app can replace itself is installed straight away.
|
||||
fn run_update_check(self: &Arc<Self>) {
|
||||
let proxy = update::proxy_url(net::env_proxy().as_ref(), self.http.system_proxy());
|
||||
let outcome = update::check(&self.ui.app, proxy);
|
||||
let succeeded = outcome.is_ok();
|
||||
@@ -694,6 +701,13 @@ impl Runtime {
|
||||
succeeded,
|
||||
});
|
||||
self.refresh_shell(false);
|
||||
let auto_apply = self.with_state(|s| {
|
||||
s.update_status == UpdateStatus::Available && s.update_channel.can_apply()
|
||||
});
|
||||
if auto_apply {
|
||||
log::info!("update found; installing it without waiting for a click");
|
||||
self.apply_update();
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and install the release the last check found on a worker
|
||||
|
||||
+20
-8
@@ -1,21 +1,27 @@
|
||||
/* Bubble palette. Accent and fill colours come from the payload. */
|
||||
:root[data-theme='dark'] {
|
||||
/* 60% opaque, so the desktop shows through; text and bars stay solid. */
|
||||
/* 60% 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%);
|
||||
--track: #2c2c2c;
|
||||
--time-track: #404040;
|
||||
--track: rgb(0 0 0 / 28%);
|
||||
--time-track: rgb(255 255 255 / 14%);
|
||||
--time-fill: #b0b0b0;
|
||||
--edge: rgb(255 255 255 / 22%);
|
||||
--text: #eaeaea;
|
||||
--muted: #a8a8a8;
|
||||
--muted: #c4c4c4;
|
||||
--halo: 0 0 2px rgb(0 0 0 / 75%);
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--bg: rgb(243 243 243 / 60%);
|
||||
--track: #e2e2e2;
|
||||
--time-track: #e0e0e0;
|
||||
--track: rgb(0 0 0 / 10%);
|
||||
--time-track: rgb(0 0 0 / 8%);
|
||||
--time-fill: #666666;
|
||||
--edge: rgb(0 0 0 / 18%);
|
||||
--text: #1f1f1f;
|
||||
--muted: #5e5e5e;
|
||||
--muted: #4a4a4a;
|
||||
--halo: 0 0 2px rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -56,10 +62,15 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rings circle {
|
||||
.rings circle,
|
||||
.edge {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.edge {
|
||||
stroke: var(--edge);
|
||||
}
|
||||
|
||||
.ring-track {
|
||||
stroke: var(--track);
|
||||
}
|
||||
@@ -82,6 +93,7 @@ body {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
text-shadow: var(--halo);
|
||||
}
|
||||
|
||||
.head-label {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<body>
|
||||
<div class="pill" id="pill">
|
||||
<svg class="rings" id="rings" aria-hidden="true">
|
||||
<path class="edge" id="edge"></path>
|
||||
<circle class="ring-track" id="ring-track"></circle>
|
||||
<circle class="ring-arc" id="ring-arc"></circle>
|
||||
<circle class="time-ring-track" id="time-ring-track"></circle>
|
||||
|
||||
@@ -59,6 +59,8 @@ const FONT_STACK = '"Segoe UI", system-ui, sans-serif';
|
||||
/** Weight text is measured at when the caller names none (regular). */
|
||||
const MEASURE_WEIGHT = 400;
|
||||
const HEAD_LABEL_WEIGHT = 600;
|
||||
/** Outline stroke in CSS px; the clip keeps the inner half. */
|
||||
const EDGE_STROKE = 2;
|
||||
|
||||
const slug = windowLabel.replace(/^bubble-/, '');
|
||||
|
||||
@@ -80,6 +82,8 @@ function byId(id) {
|
||||
const pill = byId('pill');
|
||||
/** @type {SVGSVGElement} */
|
||||
const rings = byId('rings');
|
||||
/** @type {SVGPathElement} */
|
||||
const edge = byId('edge');
|
||||
/** @type {SVGCircleElement} */
|
||||
const ringTrack = byId('ring-track');
|
||||
/** @type {SVGCircleElement} */
|
||||
@@ -221,6 +225,9 @@ function relayout(p) {
|
||||
const outline = outlinePath(cssPx(p.canvasW, p.dpi), cssPx(p.canvasH, p.dpi));
|
||||
pill.style.clipPath = `path('${outline}')`;
|
||||
rings.setAttribute('viewBox', `0 0 ${p.canvasW} ${p.canvasH}`);
|
||||
// Half the stroke falls outside the clip, leaving a 1 px edge.
|
||||
edge.setAttribute('d', outlinePath(p.canvasW, p.canvasH));
|
||||
edge.setAttribute('stroke-width', String((EDGE_STROKE * p.dpi) / BASE_DPI));
|
||||
placeCircle(ringTrack, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
|
||||
placeCircle(ringArc, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
|
||||
placeCircle(timeRingTrack, head.ringCx, head.ringCy, head.timeRingRadius, head.timeRingStroke);
|
||||
|
||||
Reference in new issue
Block a user