mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-28 14:14:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0bb291754 | ||
|
|
37cf7a3a42 | ||
|
|
15f1120132 | ||
|
|
5462df1e7e | ||
|
|
dfff4ebe9f | ||
|
|
4e2ecbf588 | ||
|
|
fd5c2ba910 | ||
|
|
cf058a4a38 | ||
|
|
3aaf963841 | ||
|
|
c7af9ed0ac | ||
|
|
677feb2e42 | ||
|
|
5362e46be5 | ||
|
|
97100cfcc0 | ||
|
|
4299c8e602 |
No files matched your search
Generated
+2
-1
@@ -4693,12 +4693,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "usage-monitor"
|
||||
version = "0.6.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"auto-launch",
|
||||
"dbus",
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"jiff",
|
||||
"libc",
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "usage-monitor"
|
||||
version = "0.6.1"
|
||||
version = "0.9.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage"
|
||||
@@ -23,6 +23,8 @@ tauri-plugin-single-instance = "2"
|
||||
ureq = { version = "3", features = ["platform-verifier", "socks-proxy"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Local time zone for the clock-aligned poll and update check.
|
||||
jiff = "0.2"
|
||||
dirs = "6"
|
||||
log = "0.4"
|
||||
simplelog = "0.12"
|
||||
@@ -56,6 +58,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",
|
||||
|
||||
@@ -27,8 +27,9 @@ self-updater are all written from scratch against the same public APIs
|
||||
|
||||
## What you get
|
||||
|
||||
- A circular floating bubble showing your current 5-hour Claude Code or
|
||||
Codex usage as a percentage and a colored progress ring
|
||||
- A see-through floating bubble: a round head showing your current 5-hour
|
||||
Claude Code or Codex usage as a percentage and a colored progress ring,
|
||||
with a slimmer tail holding the 7-day usage and reset bars
|
||||
- Drag anywhere — the bubble snaps to monitor work-area edges when
|
||||
released
|
||||
- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls** →
|
||||
@@ -37,12 +38,17 @@ self-updater are all written from scratch against the same public APIs
|
||||
- Left-click the bubble for an expanded panel with both **5h** and **7d**
|
||||
bars plus reset countdowns
|
||||
- Right-click for refresh, displayed providers, update frequency, startup,
|
||||
**Reset position**, updates, **Show widget**, **Restart** and **Exit**
|
||||
**Reset position**, updates, **Show widget**, **Click-through**,
|
||||
**Restart** and **Exit**
|
||||
- A system tray icon per enabled provider, with a usage badge
|
||||
- Desktop notifications when usage crosses a threshold, when a sign-in
|
||||
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
|
||||
- Bubbles stay above other always-on-top windows, such as chat
|
||||
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
|
||||
|
||||
@@ -96,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
|
||||
@@ -111,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.
|
||||
@@ -165,8 +172,12 @@ primary monitor on first launch. Drag it where you want it, release to snap
|
||||
to the nearest edge if you let go close to one.
|
||||
|
||||
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
|
||||
- **Update frequency** sets how often usage is fetched. Fetches follow
|
||||
your local clock: every 5 minutes means :00, :05, :10 and so on, and
|
||||
every hour means the top of the hour
|
||||
- **Right-click** for **Refresh**, **Update frequency**, **Providers**,
|
||||
**Settings**, **Controls**, **Show widget**, **Restart** and **Exit**.
|
||||
**Settings**, **Controls**, **Show widget**, **Click-through**,
|
||||
**Restart** and **Exit**.
|
||||
**Settings** holds "Start with Windows" ("Start at login" on Linux),
|
||||
**Reset position**, a manual **Check for updates** and the
|
||||
**Auto-update check** interval (Disabled / Hourly / Daily / Weekly)
|
||||
@@ -175,6 +186,9 @@ to the nearest edge if you let go close to one.
|
||||
menu, to resize it
|
||||
- **Tray icon:** left-click toggles the bubble visibility, right-click opens
|
||||
the same menu (on Linux, any click opens the menu)
|
||||
- **Click-through:** when checked, the bubbles ignore the mouse, so clicks
|
||||
reach the windows underneath. You can't drag, resize or right-click a
|
||||
bubble then; uncheck it from the tray icon's menu
|
||||
|
||||
### Providers
|
||||
|
||||
@@ -182,9 +196,6 @@ Use the right-click **Providers** menu to choose what is shown:
|
||||
|
||||
- **Claude Code** is enabled by default
|
||||
- **Codex** can be enabled alongside Claude Code or shown by itself
|
||||
- **OpenCode Go** is listed for future support; usage bars remain disabled
|
||||
until OpenCode exposes a stable quota source or console integration is
|
||||
explicitly enabled
|
||||
|
||||
When multiple providers are shown, each gets its own bubble that you can position
|
||||
independently.
|
||||
@@ -250,9 +261,12 @@ What the app sends over the network:
|
||||
|
||||
- Requests to Anthropic's Claude endpoints to read your usage
|
||||
- Requests to ChatGPT's Codex usage endpoint, if Codex is enabled
|
||||
- Requests for `latest.json` from GitHub Releases, hourly by default and at
|
||||
launch when the last check is older than that. A failed check is retried
|
||||
sooner, starting after a minute and backing off up to the interval. Set **Settings** → **Auto-update check** to
|
||||
- Requests for `latest.json` from GitHub Releases, hourly by default. Checks
|
||||
run on your local clock: hourly at the top of each hour, daily at
|
||||
midnight, weekly at midnight on Monday. At launch the app checks only if
|
||||
such a time passed since its last check. A failed check is retried
|
||||
sooner, starting after a minute and backing off, but never past the next
|
||||
scheduled time. Set **Settings** → **Auto-update check** to
|
||||
**Disabled** in the right-click menu to stop them; **Check for updates**
|
||||
still works when you click it.
|
||||
|
||||
@@ -271,6 +285,7 @@ What the app stores locally:
|
||||
- Bubble position(s) per model
|
||||
- Bubble size
|
||||
- Whether the bubbles are shown (**Show widget**)
|
||||
- Whether the bubbles let clicks through (**Click-through**)
|
||||
- Polling frequency
|
||||
- Auto-update check interval
|
||||
- Last update check time
|
||||
|
||||
+1
-3
@@ -126,8 +126,6 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
|
||||
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
|
||||
// Cool dark slate for Codex.
|
||||
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
|
||||
// Deep green for OpenCode Go.
|
||||
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +176,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn arc_starts_at_twelve_o_clock_for_any_percent() {
|
||||
for kind in ProviderId::LIVE_USAGE {
|
||||
for kind in ProviderId::ALL {
|
||||
for pct in [0.5, 1.0, 25.0, 59.9, 60.0, 80.0, 95.0, 100.0, 150.0] {
|
||||
let pixmap = render_pixmap(kind, Some(pct));
|
||||
assert_eq!(
|
||||
|
||||
+164
-9
@@ -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,
|
||||
@@ -263,7 +261,7 @@ fn set_size(window: &WebviewWindow, (w, h): (i32, i32)) {
|
||||
|
||||
/// Bubble windows that exist, with their provider, in display order.
|
||||
fn windows(app: &AppHandle) -> Vec<(ProviderId, WebviewWindow)> {
|
||||
ProviderId::LIVE_USAGE
|
||||
ProviderId::ALL
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
app.get_webview_window(&runtime::bubble_label(p))
|
||||
@@ -316,10 +314,11 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
|
||||
log::warn!("{label} not created: the runtime is not ready");
|
||||
return Ok(());
|
||||
};
|
||||
let (size_logical, saved) = runtime.with_state(|s| {
|
||||
let (size_logical, saved, click_through) = runtime.with_state(|s| {
|
||||
(
|
||||
s.settings.bubble_size_logical,
|
||||
s.settings.bubble_positions.get(provider),
|
||||
s.settings.click_through,
|
||||
)
|
||||
});
|
||||
let all = screens(app);
|
||||
@@ -350,6 +349,11 @@ 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);
|
||||
}
|
||||
log::info!(
|
||||
"{label} created at ({}, {}) size {}x{} dpi {}",
|
||||
placement.pos.x,
|
||||
@@ -359,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);
|
||||
@@ -395,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);
|
||||
});
|
||||
@@ -415,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) {
|
||||
@@ -438,7 +457,6 @@ pub fn mark_ready(app: &AppHandle, label: &str) -> Result<(), String> {
|
||||
let provider = label
|
||||
.strip_prefix(BUBBLE_LABEL_PREFIX)
|
||||
.and_then(ProviderId::from_slug)
|
||||
.filter(|p| p.metadata().live_usage)
|
||||
.ok_or_else(|| "not a bubble window".to_string())?;
|
||||
if app.get_webview_window(label).is_none() {
|
||||
return Err("no such bubble window".into());
|
||||
@@ -486,6 +504,143 @@ pub fn apply(app: &AppHandle, action: Action) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show every ready bubble again and put it back on top, unless the
|
||||
/// bubbles are meant to be hidden. Another always-on-top window, or a shell
|
||||
/// action that hid ours, can otherwise leave a bubble covered or hidden for
|
||||
/// good. A bubble still waiting for its page is left to the ready handshake.
|
||||
/// Visibility is read on the main thread, after any hide queued before.
|
||||
pub fn bring_back(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) {
|
||||
if with_pending(|p| p.contains_key(window.label())) {
|
||||
continue;
|
||||
}
|
||||
show(&window);
|
||||
raise(&window);
|
||||
}
|
||||
});
|
||||
if let Err(err) = queued {
|
||||
log::warn!("bubble bring-back could not reach the main thread: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 || 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());
|
||||
// Re-setting the same level is a no-op, so drop it and set it again.
|
||||
#[cfg(not(windows))]
|
||||
let result = window
|
||||
.set_always_on_top(false)
|
||||
.and_then(|()| window.set_always_on_top(true))
|
||||
.map_err(|e| e.to_string());
|
||||
if let Err(err) = result {
|
||||
log::warn!("{} not raised: {err}", window.label());
|
||||
}
|
||||
}
|
||||
|
||||
/// Make every bubble ignore the mouse, or take it again.
|
||||
pub fn set_click_through(app: &AppHandle, ignore: bool) {
|
||||
let handle = app.clone();
|
||||
let queued = app.run_on_main_thread(move || {
|
||||
for (_, window) in windows(&handle) {
|
||||
set_ignore_cursor(&window, ignore);
|
||||
}
|
||||
});
|
||||
if let Err(err) = queued {
|
||||
log::warn!("bubble click-through could not reach the main thread: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
fn show(window: &WebviewWindow) {
|
||||
#[cfg(windows)]
|
||||
let result = platform::windows::show_no_activate(window).map_err(|e| e.to_string());
|
||||
|
||||
+11
-13
@@ -24,7 +24,7 @@ pub fn get_bubble_state(
|
||||
runtime: State<'_, Arc<Runtime>>,
|
||||
provider: String,
|
||||
) -> Result<BubblePayload, String> {
|
||||
let provider = live_provider(&provider)?;
|
||||
let provider = parse_provider(&provider)?;
|
||||
let scale = window.scale_factor().map_err(|err| err.to_string())?;
|
||||
Ok(runtime.bubble_payload(provider, scale))
|
||||
}
|
||||
@@ -66,7 +66,7 @@ pub fn bubble_ready(app: AppHandle, label: String) -> Result<(), String> {
|
||||
/// running inside the webview's IPC callback.
|
||||
#[tauri::command]
|
||||
pub async fn bubble_click(runtime: State<'_, Arc<Runtime>>, slug: String) -> Result<(), String> {
|
||||
let provider = live_provider(&slug)?;
|
||||
let provider = parse_provider(&slug)?;
|
||||
runtime.bubble_clicked(provider);
|
||||
Ok(())
|
||||
}
|
||||
@@ -74,7 +74,7 @@ pub async fn bubble_click(runtime: State<'_, Arc<Runtime>>, slug: String) -> Res
|
||||
/// The page is about to start an OS drag of a bubble.
|
||||
#[tauri::command]
|
||||
pub fn bubble_drag_start(app: AppHandle, slug: String) -> Result<(), String> {
|
||||
let provider = live_provider(&slug)?;
|
||||
let provider = parse_provider(&slug)?;
|
||||
bubble::drag_start(&app, provider);
|
||||
Ok(())
|
||||
}
|
||||
@@ -96,10 +96,8 @@ fn resize_delta(dir: i32) -> Result<i32, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn live_provider(slug: &str) -> Result<ProviderId, String> {
|
||||
ProviderId::from_slug(slug)
|
||||
.filter(|p| p.metadata().live_usage)
|
||||
.ok_or_else(|| "unknown provider".to_string())
|
||||
fn parse_provider(slug: &str) -> Result<ProviderId, String> {
|
||||
ProviderId::from_slug(slug).ok_or_else(|| "unknown provider".to_string())
|
||||
}
|
||||
|
||||
/// One log line: control characters become spaces, so a message cannot
|
||||
@@ -117,12 +115,12 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_live_providers_are_accepted() {
|
||||
assert_eq!(live_provider("claude"), Ok(ProviderId::Claude));
|
||||
assert_eq!(live_provider("chatgpt"), Ok(ProviderId::ChatGpt));
|
||||
assert!(live_provider("opencode-go").is_err());
|
||||
assert!(live_provider("").is_err());
|
||||
assert!(live_provider("CLAUDE").is_err());
|
||||
fn only_known_providers_are_accepted() {
|
||||
assert_eq!(parse_provider("claude"), Ok(ProviderId::Claude));
|
||||
assert_eq!(parse_provider("chatgpt"), Ok(ProviderId::ChatGpt));
|
||||
assert!(parse_provider("codex").is_err());
|
||||
assert!(parse_provider("").is_err());
|
||||
assert!(parse_provider("CLAUDE").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-6
@@ -163,7 +163,6 @@ pub fn default_position(
|
||||
let stagger = match model {
|
||||
ProviderId::Claude => 0,
|
||||
ProviderId::ChatGpt => height_px + STAGGER_GAP,
|
||||
ProviderId::OpenCodeGo => 2 * (height_px + STAGGER_GAP),
|
||||
};
|
||||
let x = work_area.r - width_px - STAGGER_GAP;
|
||||
let y = work_area.b - height_px - STAGGER_GAP - stagger;
|
||||
@@ -210,7 +209,7 @@ pub struct SnapInput {
|
||||
pub dpi: u32,
|
||||
/// Docked taskbar edge and rect, when the platform reports one.
|
||||
pub taskbar: Option<(Edge, Rect)>,
|
||||
/// Outer rects of the other bubbles, in `ProviderId::LIVE_USAGE` order.
|
||||
/// Outer rects of the other bubbles, in `ProviderId::ALL` order.
|
||||
pub peers: Vec<Rect>,
|
||||
}
|
||||
|
||||
@@ -499,10 +498,6 @@ mod tests {
|
||||
default_position(WA, w, h, ProviderId::ChatGpt),
|
||||
Point::new(1696, 1040 - 66 - 24 - 90)
|
||||
);
|
||||
assert_eq!(
|
||||
default_position(WA, w, h, ProviderId::OpenCodeGo),
|
||||
Point::new(1696, 1040 - 66 - 24 - 180)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+2
-3
@@ -24,7 +24,6 @@ pub struct LocaleStrings {
|
||||
pub providers: String,
|
||||
pub claude_label: String,
|
||||
pub chatgpt_label: String,
|
||||
pub opencode_go_label: String,
|
||||
pub settings: String,
|
||||
pub start_with_windows: String,
|
||||
pub start_at_login: String,
|
||||
@@ -57,6 +56,7 @@ pub struct LocaleStrings {
|
||||
pub exit: String,
|
||||
pub restart: String,
|
||||
pub show_widget: String,
|
||||
pub click_through: String,
|
||||
pub session_window: String,
|
||||
pub weekly_window: String,
|
||||
pub now: String,
|
||||
@@ -124,7 +124,6 @@ impl LocaleStrings {
|
||||
match provider {
|
||||
ProviderId::Claude => &self.claude_label,
|
||||
ProviderId::ChatGpt => &self.chatgpt_label,
|
||||
ProviderId::OpenCodeGo => &self.opencode_go_label,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +140,6 @@ fn english_strings() -> LocaleStrings {
|
||||
providers: "Providers".into(),
|
||||
claude_label: "Claude Code".into(),
|
||||
chatgpt_label: "Codex".into(),
|
||||
opencode_go_label: "OpenCode Go".into(),
|
||||
settings: "Settings".into(),
|
||||
start_with_windows: "Start with Windows".into(),
|
||||
start_at_login: "Start at login".into(),
|
||||
@@ -172,6 +170,7 @@ fn english_strings() -> LocaleStrings {
|
||||
exit: "Exit".into(),
|
||||
restart: "Restart".into(),
|
||||
show_widget: "Show widget".into(),
|
||||
click_through: "Click-through".into(),
|
||||
session_window: "5h".into(),
|
||||
weekly_window: "7d".into(),
|
||||
now: "now".into(),
|
||||
|
||||
+16
-8
@@ -23,6 +23,7 @@ pub enum MenuCommand {
|
||||
SizeLarger,
|
||||
SizeReset,
|
||||
ToggleWidget,
|
||||
ToggleClickThrough,
|
||||
Restart,
|
||||
Exit,
|
||||
}
|
||||
@@ -31,7 +32,7 @@ impl MenuCommand {
|
||||
/// The item id: `refresh`, `freq:<ms>`, `provider:<slug>`, `autostart`,
|
||||
/// `reset-position`, `version`, `update-interval:<secs|off>`,
|
||||
/// `size-smaller`, `size-larger`, `size-reset`, `toggle-widget`,
|
||||
/// `restart` or `exit`.
|
||||
/// `click-through`, `restart` or `exit`.
|
||||
pub fn id(self) -> String {
|
||||
match self {
|
||||
MenuCommand::Refresh => "refresh".into(),
|
||||
@@ -46,6 +47,7 @@ impl MenuCommand {
|
||||
MenuCommand::SizeLarger => "size-larger".into(),
|
||||
MenuCommand::SizeReset => "size-reset".into(),
|
||||
MenuCommand::ToggleWidget => "toggle-widget".into(),
|
||||
MenuCommand::ToggleClickThrough => "click-through".into(),
|
||||
MenuCommand::Restart => "restart".into(),
|
||||
MenuCommand::Exit => "exit".into(),
|
||||
}
|
||||
@@ -64,6 +66,7 @@ impl MenuCommand {
|
||||
"size-larger" => Some(MenuCommand::SizeLarger),
|
||||
"size-reset" => Some(MenuCommand::SizeReset),
|
||||
"toggle-widget" => Some(MenuCommand::ToggleWidget),
|
||||
"click-through" => Some(MenuCommand::ToggleClickThrough),
|
||||
"restart" => Some(MenuCommand::Restart),
|
||||
"exit" => Some(MenuCommand::Exit),
|
||||
_ => None,
|
||||
@@ -205,7 +208,7 @@ pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
|
||||
MenuCommand::ToggleProvider(p),
|
||||
s.provider_label(p),
|
||||
settings.is_provider_enabled(p),
|
||||
p.metadata().live_usage,
|
||||
true,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -282,6 +285,12 @@ pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
|
||||
settings.widget_visible,
|
||||
true,
|
||||
),
|
||||
check(
|
||||
MenuCommand::ToggleClickThrough,
|
||||
&s.click_through,
|
||||
settings.click_through,
|
||||
true,
|
||||
),
|
||||
MenuEntry::Separator,
|
||||
action(MenuCommand::Restart, &s.restart, true),
|
||||
action(MenuCommand::Exit, &s.exit, true),
|
||||
@@ -360,8 +369,8 @@ mod tests {
|
||||
poll_interval_ms: POLL_15_MIN,
|
||||
update_check_interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
|
||||
show_codex: true,
|
||||
show_opencode_go: true,
|
||||
widget_visible: false,
|
||||
click_through: true,
|
||||
..Settings::default()
|
||||
};
|
||||
let m = menu(&settings, UpdateStatus::Idle);
|
||||
@@ -371,11 +380,9 @@ mod tests {
|
||||
assert!(!checked(&m, "update-interval:off"));
|
||||
assert!(checked(&m, "provider:claude"));
|
||||
assert!(checked(&m, "provider:chatgpt"));
|
||||
// OpenCode Go mirrors its flag but stays greyed.
|
||||
assert!(checked(&m, "provider:opencode-go"));
|
||||
assert!(!enabled(&m, "provider:opencode-go"));
|
||||
assert!(checked(&m, "autostart"));
|
||||
assert!(!checked(&m, "toggle-widget"));
|
||||
assert!(checked(&m, "click-through"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -440,6 +447,7 @@ mod tests {
|
||||
"Settings",
|
||||
"Controls",
|
||||
"Show widget",
|
||||
"Click-through",
|
||||
"-",
|
||||
"Restart",
|
||||
"Exit"
|
||||
@@ -524,7 +532,6 @@ mod tests {
|
||||
"Providers >",
|
||||
" Claude Code [provider:claude] on",
|
||||
" Codex [provider:chatgpt] off",
|
||||
" OpenCode Go [provider:opencode-go] off grey",
|
||||
"Settings >",
|
||||
&autostart,
|
||||
" Reset position [reset-position]",
|
||||
@@ -543,12 +550,13 @@ mod tests {
|
||||
let got: Vec<&str> = lines.iter().map(String::as_str).collect();
|
||||
assert_eq!(got[..expected.len()], expected);
|
||||
let tail: Vec<&str> = got[expected.len()..].to_vec();
|
||||
assert_eq!(tail.len(), 9);
|
||||
assert_eq!(tail.len(), 10);
|
||||
assert!(tail[..5].iter().all(|l| l.ends_with(" hint")), "{tail:?}");
|
||||
assert_eq!(
|
||||
tail[5..],
|
||||
[
|
||||
"Show widget [toggle-widget] on",
|
||||
"Click-through [click-through] off",
|
||||
"-",
|
||||
"Restart [restart]",
|
||||
"Exit [exit]"
|
||||
|
||||
@@ -14,7 +14,6 @@ use crate::usage::ProviderId;
|
||||
|
||||
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
|
||||
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
const THIRTY_DAYS_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
|
||||
/// Placeholder for the head label and weekly countdown before the first poll.
|
||||
const PENDING: &str = "…";
|
||||
@@ -25,7 +24,6 @@ const PENDING: &str = "…";
|
||||
pub fn window_durations_secs(provider: ProviderId) -> (u64, u64) {
|
||||
match provider {
|
||||
ProviderId::Claude | ProviderId::ChatGpt => (FIVE_HOURS_SECS, SEVEN_DAYS_SECS),
|
||||
ProviderId::OpenCodeGo => (SEVEN_DAYS_SECS, THIRTY_DAYS_SECS),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,12 +337,4 @@ mod tests {
|
||||
assert!(panel.get(key).is_some(), "missing {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_go_uses_weekly_and_monthly_windows() {
|
||||
assert_eq!(
|
||||
window_durations_secs(ProviderId::OpenCodeGo),
|
||||
(604_800, 2_592_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
+39
-68
@@ -40,9 +40,6 @@ fn default_show_claude() -> bool {
|
||||
fn default_show_codex() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_show_opencode_go() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_widget_visible() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -60,7 +57,6 @@ fn default_update_check_interval_secs() -> Option<u64> {
|
||||
pub struct BubblePositions {
|
||||
pub claude: Option<(i32, i32)>,
|
||||
pub codex: Option<(i32, i32)>,
|
||||
pub opencode_go: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
impl BubblePositions {
|
||||
@@ -68,27 +64,23 @@ impl BubblePositions {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude,
|
||||
ProviderId::ChatGpt => self.codex,
|
||||
ProviderId::OpenCodeGo => self.opencode_go,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude = Some(pos),
|
||||
ProviderId::ChatGpt => self.codex = Some(pos),
|
||||
ProviderId::OpenCodeGo => self.opencode_go = Some(pos),
|
||||
}
|
||||
}
|
||||
pub fn reset(&mut self, provider: ProviderId) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude = None,
|
||||
ProviderId::ChatGpt => self.codex = None,
|
||||
ProviderId::OpenCodeGo => self.opencode_go = None,
|
||||
}
|
||||
}
|
||||
pub fn reset_all(&mut self) {
|
||||
self.claude = None;
|
||||
self.codex = None;
|
||||
self.opencode_go = None;
|
||||
}
|
||||
|
||||
/// Drop any saved position whose top-left no longer falls on one of
|
||||
@@ -115,8 +107,6 @@ pub struct Settings {
|
||||
pub show_claude_code: bool,
|
||||
#[serde(default = "default_show_codex")]
|
||||
pub show_codex: bool,
|
||||
#[serde(default = "default_show_opencode_go")]
|
||||
pub show_opencode_go: bool,
|
||||
#[serde(default)]
|
||||
pub bubble_positions: BubblePositions,
|
||||
#[serde(default = "default_bubble_size")]
|
||||
@@ -129,6 +119,9 @@ pub struct Settings {
|
||||
pub update_check_interval_secs: Option<u64>,
|
||||
#[serde(default = "default_widget_visible")]
|
||||
pub widget_visible: bool,
|
||||
/// The bubbles ignore the mouse, so clicks reach the windows below.
|
||||
#[serde(default)]
|
||||
pub click_through: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -136,13 +129,13 @@ impl Default for Settings {
|
||||
Self {
|
||||
show_claude_code: default_show_claude(),
|
||||
show_codex: default_show_codex(),
|
||||
show_opencode_go: default_show_opencode_go(),
|
||||
bubble_positions: BubblePositions::default(),
|
||||
bubble_size_logical: default_bubble_size(),
|
||||
poll_interval_ms: default_poll_interval_ms(),
|
||||
last_update_check_unix: None,
|
||||
update_check_interval_secs: default_update_check_interval_secs(),
|
||||
widget_visible: default_widget_visible(),
|
||||
click_through: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,7 +145,6 @@ impl Settings {
|
||||
match provider {
|
||||
ProviderId::Claude => self.show_claude_code,
|
||||
ProviderId::ChatGpt => self.show_codex,
|
||||
ProviderId::OpenCodeGo => self.show_opencode_go,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,30 +152,23 @@ impl Settings {
|
||||
match provider {
|
||||
ProviderId::Claude => self.show_claude_code = enabled,
|
||||
ProviderId::ChatGpt => self.show_codex = enabled,
|
||||
ProviderId::OpenCodeGo => self.show_opencode_go = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_provider(&mut self, provider: ProviderId) {
|
||||
/// Menu toggle for a provider. Unchecking the last enabled provider is
|
||||
/// undone so the app always shows something. Returns the provider's new
|
||||
/// state.
|
||||
pub fn toggle_provider(&mut self, provider: ProviderId) -> bool {
|
||||
let enabled = !self.is_provider_enabled(provider);
|
||||
self.set_provider_enabled(provider, enabled);
|
||||
}
|
||||
|
||||
/// Menu toggle for a provider. Providers without live usage cannot be
|
||||
/// toggled, and unchecking the last enabled live provider is undone so
|
||||
/// the app always shows something. Returns the provider's new state.
|
||||
pub fn toggle_live_provider(&mut self, provider: ProviderId) -> bool {
|
||||
if provider.metadata().live_usage {
|
||||
self.toggle_provider(provider);
|
||||
if !self.has_enabled_live_provider() {
|
||||
self.set_provider_enabled(provider, true);
|
||||
}
|
||||
if !self.has_enabled_provider() {
|
||||
self.set_provider_enabled(provider, true);
|
||||
}
|
||||
self.is_provider_enabled(provider)
|
||||
}
|
||||
|
||||
pub fn has_enabled_live_provider(&self) -> bool {
|
||||
ProviderId::LIVE_USAGE
|
||||
pub fn has_enabled_provider(&self) -> bool {
|
||||
ProviderId::ALL
|
||||
.iter()
|
||||
.any(|provider| self.is_provider_enabled(*provider))
|
||||
}
|
||||
@@ -310,8 +295,8 @@ fn parse(content: &str) -> Option<Settings> {
|
||||
|
||||
/// Enforce the invariants a hand-edited file may break.
|
||||
fn normalise(mut settings: Settings) -> Settings {
|
||||
// At least one live provider must be visible. Otherwise the app has nothing to show.
|
||||
if !settings.has_enabled_live_provider() {
|
||||
// At least one provider must be visible. Otherwise the app has nothing to show.
|
||||
if !settings.has_enabled_provider() {
|
||||
settings.show_claude_code = true;
|
||||
}
|
||||
// Clamp bubble size to safe range in case settings.json was hand-edited.
|
||||
@@ -408,7 +393,6 @@ mod tests {
|
||||
|
||||
assert!(!settings.show_claude_code);
|
||||
assert!(settings.show_codex);
|
||||
assert!(!settings.show_opencode_go);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::Claude),
|
||||
Some((10, 20))
|
||||
@@ -417,30 +401,32 @@ mod tests {
|
||||
settings.bubble_positions.get(ProviderId::ChatGpt),
|
||||
Some((30, 40))
|
||||
);
|
||||
assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_provider_settings_json_loads() {
|
||||
fn retired_opencode_go_keys_are_ignored() {
|
||||
let json = r#"{
|
||||
"show_claude_code": true,
|
||||
"show_codex": false,
|
||||
"show_claude_code": false,
|
||||
"show_codex": true,
|
||||
"show_opencode_go": true,
|
||||
"bubble_positions": {
|
||||
"codex": [30, 40],
|
||||
"opencode_go": [50, 60]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let settings: Settings =
|
||||
serde_json::from_str(json).expect("new settings json should parse");
|
||||
serde_json::from_str(json).expect("settings with retired keys should parse");
|
||||
|
||||
assert!(settings.show_claude_code);
|
||||
assert!(!settings.show_codex);
|
||||
assert!(settings.show_opencode_go);
|
||||
assert!(!settings.show_claude_code);
|
||||
assert!(settings.show_codex);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::OpenCodeGo),
|
||||
Some((50, 60))
|
||||
settings.bubble_positions.get(ProviderId::ChatGpt),
|
||||
Some((30, 40))
|
||||
);
|
||||
let keys = serde_json::to_value(&settings).expect("serialise");
|
||||
assert!(keys.get("show_opencode_go").is_none());
|
||||
assert!(keys["bubble_positions"].get("opencode_go").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -518,10 +504,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalise_restores_a_live_provider_and_clamps_size() {
|
||||
fn normalise_restores_a_provider_and_clamps_size() {
|
||||
let settings = parse_and_normalise(
|
||||
r#"{"show_claude_code": false, "show_codex": false, "show_opencode_go": true,
|
||||
"bubble_size_logical": 9000}"#,
|
||||
r#"{"show_claude_code": false, "show_codex": false, "bubble_size_logical": 9000}"#,
|
||||
);
|
||||
assert!(settings.show_claude_code);
|
||||
assert_eq!(settings.bubble_size_logical, MAX_BUBBLE_SIZE);
|
||||
@@ -539,11 +524,11 @@ mod tests {
|
||||
[
|
||||
"bubble_positions",
|
||||
"bubble_size_logical",
|
||||
"click_through",
|
||||
"last_update_check_unix",
|
||||
"poll_interval_ms",
|
||||
"show_claude_code",
|
||||
"show_codex",
|
||||
"show_opencode_go",
|
||||
"update_check_interval_secs",
|
||||
"widget_visible",
|
||||
]
|
||||
@@ -557,15 +542,13 @@ mod tests {
|
||||
#[test]
|
||||
fn offscreen_positions_are_dropped_against_monitors() {
|
||||
let mut settings = parse_and_normalise(
|
||||
r#"{"bubble_positions": {"claude": [100, 100], "codex": [-3000, 100],
|
||||
"opencode_go": [2000, 500]}}"#,
|
||||
r#"{"bubble_positions": {"claude": [100, 100], "codex": [-3000, 100]}}"#,
|
||||
);
|
||||
let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)];
|
||||
drop_offscreen_positions(&mut settings, &monitors);
|
||||
let p = &settings.bubble_positions;
|
||||
assert_eq!(p.get(ProviderId::Claude), Some((100, 100)));
|
||||
assert_eq!(p.get(ProviderId::ChatGpt), None);
|
||||
assert_eq!(p.get(ProviderId::OpenCodeGo), Some((2000, 500)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -617,27 +600,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_keeps_provider_flags_independent() {
|
||||
fn toggle_never_disables_the_last_provider() {
|
||||
let mut settings = Settings::default();
|
||||
settings.toggle_provider(ProviderId::ChatGpt);
|
||||
assert!(settings.is_provider_enabled(ProviderId::ChatGpt));
|
||||
settings.toggle_provider(ProviderId::Claude);
|
||||
assert!(!settings.is_provider_enabled(ProviderId::Claude));
|
||||
assert!(settings.has_enabled_live_provider());
|
||||
settings.toggle_provider(ProviderId::ChatGpt);
|
||||
assert!(!settings.has_enabled_live_provider());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_toggle_never_disables_the_last_live_provider() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(settings.toggle_live_provider(ProviderId::Claude));
|
||||
assert!(settings.show_claude_code);
|
||||
assert!(settings.toggle_live_provider(ProviderId::ChatGpt));
|
||||
assert!(!settings.toggle_live_provider(ProviderId::Claude));
|
||||
assert!(settings.toggle_live_provider(ProviderId::ChatGpt));
|
||||
// OpenCode Go has no live usage, so its flag never changes.
|
||||
assert!(!settings.toggle_live_provider(ProviderId::OpenCodeGo));
|
||||
assert!(!settings.show_opencode_go);
|
||||
assert!(settings.toggle_provider(ProviderId::ChatGpt));
|
||||
assert!(!settings.toggle_provider(ProviderId::Claude));
|
||||
assert!(settings.has_enabled_provider());
|
||||
// ChatGPT is now the only enabled provider, so it stays on.
|
||||
assert!(settings.toggle_provider(ProviderId::ChatGpt));
|
||||
assert!(settings.show_codex);
|
||||
assert!(settings.toggle_provider(ProviderId::Claude));
|
||||
assert!(!settings.toggle_provider(ProviderId::ChatGpt));
|
||||
}
|
||||
}
|
||||
+110
-26
@@ -8,7 +8,11 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use jiff::tz::TimeZone;
|
||||
use jiff::{Timestamp, ToSpan, Zoned};
|
||||
|
||||
use crate::core::i18n::{self, LocaleStrings};
|
||||
use crate::core::settings;
|
||||
use crate::usage::{ProviderId, UsageWindows, Window};
|
||||
|
||||
/// Minimum gap between two cooldown-gated alerts (threshold and session
|
||||
@@ -79,10 +83,6 @@ impl Alert {
|
||||
strings.chatgpt_token_expired_title.clone(),
|
||||
strings.chatgpt_token_expired_body.clone(),
|
||||
),
|
||||
ProviderId::OpenCodeGo => (
|
||||
strings.opencode_go_label.clone(),
|
||||
strings.update_failed.clone(),
|
||||
),
|
||||
},
|
||||
Alert::UpdateApplied { version } => (
|
||||
strings.update_applied_title.clone(),
|
||||
@@ -295,27 +295,78 @@ pub enum UpdateCheckDue {
|
||||
}
|
||||
|
||||
/// Schedule the automatic update check from the last check time (unix
|
||||
/// seconds). `None` when auto-check is disabled. Due when never checked or
|
||||
/// when `now - last >= interval`; otherwise after the remainder.
|
||||
/// seconds). `None` when auto-check is disabled. Checks fall on local
|
||||
/// clock boundaries: hourly at the top of each hour, daily at midnight,
|
||||
/// weekly at midnight on Monday. Due when never checked or when the last
|
||||
/// check came before the boundary that most recently passed; otherwise
|
||||
/// after the time left until the next boundary.
|
||||
pub fn update_check_due(
|
||||
last_check_unix: Option<u64>,
|
||||
interval_secs: Option<u64>,
|
||||
now_unix: u64,
|
||||
tz: &TimeZone,
|
||||
) -> Option<UpdateCheckDue> {
|
||||
let interval = interval_secs?;
|
||||
let Some(last) = last_check_unix else {
|
||||
return Some(UpdateCheckDue::Now);
|
||||
};
|
||||
let elapsed = now_unix.saturating_sub(last);
|
||||
if elapsed >= interval {
|
||||
Some(UpdateCheckDue::Now)
|
||||
} else {
|
||||
Some(UpdateCheckDue::After(Duration::from_secs(
|
||||
interval - elapsed,
|
||||
)))
|
||||
let (start, next) = clock_slot(interval, now_unix, tz);
|
||||
// A last check after `now` (a clock set back) counts as this slot's.
|
||||
match last_check_unix {
|
||||
Some(last) if last >= start => Some(UpdateCheckDue::After(Duration::from_secs(
|
||||
next.saturating_sub(now_unix),
|
||||
))),
|
||||
_ => Some(UpdateCheckDue::Now),
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot of a clock-aligned timer that holds `now_unix`: the boundary
|
||||
/// that most recently passed and the next one, in unix seconds. An hour,
|
||||
/// a day and a week follow the local calendar (top of the hour, midnight,
|
||||
/// Monday midnight), across daylight-saving changes too. Any other interval
|
||||
/// splits the local day evenly, so 5 minutes falls on :00, :05, :10 and
|
||||
/// so on.
|
||||
pub fn clock_slot(interval_secs: u64, now_unix: u64, tz: &TimeZone) -> (u64, u64) {
|
||||
let calendar = || -> Result<(Zoned, Zoned), jiff::Error> {
|
||||
let now = Timestamp::from_second(i64::try_from(now_unix).unwrap_or(i64::MAX))?
|
||||
.to_zoned(tz.clone());
|
||||
let (start, step) = match interval_secs {
|
||||
settings::UPDATE_CHECK_HOURLY_SECS => (
|
||||
now.with()
|
||||
.minute(0)
|
||||
.second(0)
|
||||
.subsec_nanosecond(0)
|
||||
.build()?,
|
||||
1.hour(),
|
||||
),
|
||||
settings::UPDATE_CHECK_DAILY_SECS => (now.start_of_day()?, 1.day()),
|
||||
settings::UPDATE_CHECK_WEEKLY_SECS => {
|
||||
let days = now.weekday().to_monday_zero_offset();
|
||||
let monday = now.start_of_day()?.checked_sub(i64::from(days).days())?;
|
||||
(monday.start_of_day()?, 1.week())
|
||||
}
|
||||
_ => return Err(jiff::Error::from_args(format_args!("no calendar slot"))),
|
||||
};
|
||||
let next = start.checked_add(step)?;
|
||||
Ok((start, next))
|
||||
};
|
||||
match calendar() {
|
||||
Ok((start, next)) => (unix_secs(&start), unix_secs(&next)),
|
||||
Err(_) => {
|
||||
let interval = i64::try_from(interval_secs.max(1)).unwrap_or(i64::MAX);
|
||||
let now = i64::try_from(now_unix).unwrap_or(i64::MAX);
|
||||
let offset = Timestamp::from_second(now)
|
||||
.map(|t| i64::from(tz.to_offset(t).seconds()))
|
||||
.unwrap_or(0);
|
||||
let local = now.saturating_add(offset);
|
||||
let start = local - local.rem_euclid(interval) - offset;
|
||||
let start = u64::try_from(start).unwrap_or(0);
|
||||
(start, start.saturating_add(interval.unsigned_abs()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_secs(zoned: &Zoned) -> u64 {
|
||||
u64::try_from(zoned.timestamp().as_second()).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -624,28 +675,61 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn update_schedule_due_and_remaining() {
|
||||
let hour = 3_600;
|
||||
assert_eq!(update_check_due(None, None, 10_000), None);
|
||||
assert_eq!(update_check_due(Some(9_000), None, 10_000), None);
|
||||
let utc = TimeZone::UTC;
|
||||
let hour = settings::UPDATE_CHECK_HOURLY_SECS;
|
||||
// 10_000 s is 02:46:40 UTC; the hour started at 7_200.
|
||||
assert_eq!(update_check_due(None, None, 10_000, &utc), None);
|
||||
assert_eq!(update_check_due(Some(9_000), None, 10_000, &utc), None);
|
||||
assert_eq!(
|
||||
update_check_due(None, Some(hour), 10_000),
|
||||
update_check_due(None, Some(hour), 10_000, &utc),
|
||||
Some(UpdateCheckDue::Now)
|
||||
);
|
||||
assert_eq!(
|
||||
update_check_due(Some(10_000 - hour), Some(hour), 10_000),
|
||||
update_check_due(Some(7_199), Some(hour), 10_000, &utc),
|
||||
Some(UpdateCheckDue::Now)
|
||||
);
|
||||
assert_eq!(
|
||||
update_check_due(Some(10_000 - 600), Some(hour), 10_000),
|
||||
Some(UpdateCheckDue::After(Duration::from_secs(3_000)))
|
||||
update_check_due(Some(7_200), Some(hour), 10_000, &utc),
|
||||
Some(UpdateCheckDue::After(Duration::from_secs(800)))
|
||||
);
|
||||
// A clock that moved backwards counts as zero elapsed.
|
||||
// A clock that moved backwards counts as checked this slot.
|
||||
assert_eq!(
|
||||
update_check_due(Some(20_000), Some(hour), 10_000),
|
||||
Some(UpdateCheckDue::After(Duration::from_secs(hour)))
|
||||
update_check_due(Some(20_000), Some(hour), 10_000, &utc),
|
||||
Some(UpdateCheckDue::After(Duration::from_secs(800)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_slots_follow_the_local_calendar() {
|
||||
let utc = TimeZone::UTC;
|
||||
// 5 minutes: 02:46:40 sits in 02:45..02:50.
|
||||
assert_eq!(clock_slot(300, 10_000, &utc), (9_900, 10_200));
|
||||
// At +05:30 local hours start at UTC :30.
|
||||
let india = TimeZone::fixed(jiff::tz::Offset::from_seconds(5 * 3_600 + 30 * 60).unwrap());
|
||||
assert_eq!(
|
||||
clock_slot(settings::UPDATE_CHECK_HOURLY_SECS, 10_000, &india),
|
||||
(9_000, 12_600)
|
||||
);
|
||||
// 1970-01-01 was a Thursday: its week started Monday 1969-12-29,
|
||||
// before the epoch, so the slot start clamps to 0.
|
||||
assert_eq!(
|
||||
clock_slot(settings::UPDATE_CHECK_WEEKLY_SECS, 10_000, &utc),
|
||||
(0, 4 * 86_400)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daily_slot_spans_a_short_daylight_saving_day() {
|
||||
let Ok(berlin) = TimeZone::get("Europe/Berlin") else {
|
||||
return; // no tz database on this machine
|
||||
};
|
||||
// 2026-03-29 is 23 hours long in Berlin; it starts at 23:00 UTC.
|
||||
let noon = 1_774_785_600; // 2026-03-29 12:00 UTC
|
||||
let (start, next) = clock_slot(settings::UPDATE_CHECK_DAILY_SECS, noon, &berlin);
|
||||
assert_eq!(start, 1_774_738_800);
|
||||
assert_eq!(next - start, 23 * 3_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn countdown_delay_takes_the_nearest_change_with_a_floor() {
|
||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
|
||||
@@ -19,7 +19,6 @@ pub fn accent_color_for(model: ProviderId, is_dark: bool) -> Color {
|
||||
Color::from_hex("#1A1A1A")
|
||||
}
|
||||
}
|
||||
ProviderId::OpenCodeGo => Color::from_hex("#3BAE75"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +73,6 @@ mod tests {
|
||||
fn safe_band_uses_theme_aware_accent() {
|
||||
assert_eq!(hex(ProviderId::ChatGpt, true, 10.0), "#E5E5E5");
|
||||
assert_eq!(hex(ProviderId::ChatGpt, false, 10.0), "#1A1A1A");
|
||||
assert_eq!(hex(ProviderId::OpenCodeGo, false, 0.0), "#3BAE75");
|
||||
assert_eq!(
|
||||
accent_color_for(ProviderId::Claude, false),
|
||||
accent_color_for(ProviderId::Claude, true)
|
||||
|
||||
+17
-1
@@ -2,10 +2,16 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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) covers, as a backstop for the z-order watch
|
||||
//! in `bubble.rs`.
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::core::visibility::Event;
|
||||
use crate::platform::{self, FullscreenCheck};
|
||||
@@ -14,13 +20,23 @@ use crate::runtime::Runtime;
|
||||
/// How often the foreground window is checked.
|
||||
pub const CHECK_INTERVAL: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// How often shown bubbles are brought back on top.
|
||||
pub const BRING_BACK_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// 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 || {
|
||||
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();
|
||||
}
|
||||
std::thread::sleep(CHECK_INTERVAL);
|
||||
}
|
||||
})
|
||||
|
||||
+153
-9
@@ -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,18 +21,22 @@ 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,
|
||||
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, 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, 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};
|
||||
@@ -202,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 {
|
||||
@@ -293,6 +313,130 @@ pub fn show_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a bubble back at the top of the always-on-top band, above any
|
||||
/// topmost window that came after it, without activating it.
|
||||
pub fn raise_topmost(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
let hwnd = hwnd_of(window)?;
|
||||
// SAFETY: `hwnd` is a live window of this process; nothing moves,
|
||||
// resizes or activates.
|
||||
unsafe {
|
||||
SetWindowPos(
|
||||
hwnd,
|
||||
Some(HWND_TOPMOST),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
|
||||
)
|
||||
}
|
||||
.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))
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
+44
-17
@@ -139,7 +139,7 @@ struct BubbleSnapshot {
|
||||
impl BubbleSnapshot {
|
||||
fn of(state: &AppState) -> Self {
|
||||
Self {
|
||||
providers: enabled_live_providers(&state.settings),
|
||||
providers: enabled_providers(&state.settings),
|
||||
usage: state.usage.clone(),
|
||||
strings: state.strings.clone(),
|
||||
is_dark: state.is_dark,
|
||||
@@ -264,7 +264,7 @@ impl Runtime {
|
||||
};
|
||||
let (views, menu_snapshot) = runtime.with_state(|s| {
|
||||
(
|
||||
tray::views(&enabled_live_providers(&s.settings), &s.usage, &s.strings),
|
||||
tray::views(&enabled_providers(&s.settings), &s.usage, &s.strings),
|
||||
MenuSnapshot::of(s),
|
||||
)
|
||||
});
|
||||
@@ -433,6 +433,7 @@ impl Runtime {
|
||||
MenuCommand::SizeLarger => self.resize_bubbles(geom::RESIZE_STEP_LOGICAL),
|
||||
MenuCommand::SizeReset => self.set_bubble_size(DEFAULT_BUBBLE_SIZE),
|
||||
MenuCommand::ToggleWidget => self.toggle_widget(),
|
||||
MenuCommand::ToggleClickThrough => self.toggle_click_through(),
|
||||
MenuCommand::Restart => self.restart(),
|
||||
MenuCommand::Exit => self.exit(),
|
||||
}
|
||||
@@ -468,13 +469,10 @@ impl Runtime {
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle a live provider, keeping at least one enabled, then create or
|
||||
/// Toggle a provider, keeping at least one enabled, then create or
|
||||
/// destroy its bubble, update the tray and poll.
|
||||
fn toggle_provider(self: &Arc<Self>, provider: ProviderId) {
|
||||
if !provider.metadata().live_usage {
|
||||
return;
|
||||
}
|
||||
let enabled = self.update_settings(|s| s.toggle_live_provider(provider));
|
||||
let enabled = self.update_settings(|s| s.toggle_provider(provider));
|
||||
if enabled {
|
||||
bubble::spawn_create(&self.ui.app, provider);
|
||||
} else if let Some(window) = self.ui.app.get_webview_window(&bubble_label(provider)) {
|
||||
@@ -499,6 +497,27 @@ impl Runtime {
|
||||
self.apply_visibility(event);
|
||||
}
|
||||
|
||||
/// Flip whether the bubbles let the mouse through, and remember it.
|
||||
fn toggle_click_through(&self) {
|
||||
let ignore = self.update_settings(|s| {
|
||||
s.click_through = !s.click_through;
|
||||
s.click_through
|
||||
});
|
||||
bubble::set_click_through(&self.ui.app, ignore);
|
||||
}
|
||||
|
||||
/// Show the bubbles again and put them back on top, unless they are
|
||||
/// meant to be hidden.
|
||||
pub fn bring_back_bubbles(&self) {
|
||||
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) {
|
||||
@@ -638,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();
|
||||
@@ -681,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
|
||||
@@ -835,9 +862,9 @@ pub fn bubble_label(provider: ProviderId) -> String {
|
||||
format!("{BUBBLE_LABEL_PREFIX}{}", provider.slug())
|
||||
}
|
||||
|
||||
/// Live providers the user has enabled, in display order.
|
||||
pub fn enabled_live_providers(settings: &Settings) -> Vec<ProviderId> {
|
||||
ProviderId::LIVE_USAGE
|
||||
/// Providers the user has enabled, in display order.
|
||||
pub fn enabled_providers(settings: &Settings) -> Vec<ProviderId> {
|
||||
ProviderId::ALL
|
||||
.into_iter()
|
||||
.filter(|p| settings.is_provider_enabled(*p))
|
||||
.collect()
|
||||
@@ -1007,8 +1034,9 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
|
||||
poll_interval: Duration::from_millis(u64::from(state.settings.poll_interval_ms)),
|
||||
update_interval_secs: state.settings.update_check_interval_secs,
|
||||
last_update_check_unix: state.settings.last_update_check_unix,
|
||||
tz: jiff::tz::TimeZone::system(),
|
||||
};
|
||||
let providers = enabled_live_providers(&state.settings);
|
||||
let providers = enabled_providers(&state.settings);
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let runtime = Arc::new(Runtime {
|
||||
@@ -1150,18 +1178,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_providers_keep_display_order_and_skip_non_live_ones() {
|
||||
fn enabled_providers_keep_display_order() {
|
||||
let mut settings = Settings {
|
||||
show_codex: true,
|
||||
show_opencode_go: true,
|
||||
..Settings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
enabled_live_providers(&settings),
|
||||
enabled_providers(&settings),
|
||||
[ProviderId::Claude, ProviderId::ChatGpt]
|
||||
);
|
||||
settings.show_claude_code = false;
|
||||
assert_eq!(enabled_live_providers(&settings), [ProviderId::ChatGpt]);
|
||||
assert_eq!(enabled_providers(&settings), [ProviderId::ChatGpt]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+224
-178
@@ -7,17 +7,20 @@
|
||||
//! each due [`Action`] to its [`SchedulerHost`]. A host must return quickly:
|
||||
//! slow work (HTTP, the CLI refresh) runs on worker threads it spawns.
|
||||
//!
|
||||
//! `Instant` stops while the machine sleeps, so the update check, which is
|
||||
//! due at a wall-clock time, is kept as a unix timestamp, and the thread
|
||||
//! wakes at least every [`MAX_WAIT`] to notice a resume (see
|
||||
//! [`resumed_from_sleep`]).
|
||||
//! The poll and the update check run on local clock boundaries (see
|
||||
//! [`clock_slot`]): a 5-minute poll at :00, :05, :10 and so on, a daily
|
||||
//! update check at midnight. `Instant` stops while the machine sleeps, so
|
||||
//! those deadlines are kept as unix timestamps, and the thread wakes at
|
||||
//! least every [`MAX_WAIT`] to notice a resume (see [`resumed_from_sleep`]).
|
||||
|
||||
use std::io;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::core::state::{update_check_due, UpdateCheckDue};
|
||||
use jiff::tz::TimeZone;
|
||||
|
||||
use crate::core::state::{clock_slot, update_check_due, UpdateCheckDue};
|
||||
|
||||
/// Shortest poll interval, guarding against a hand-edited `0` in the
|
||||
/// settings file turning into a busy loop.
|
||||
@@ -42,7 +45,8 @@ const RESUME_THRESHOLD: Duration = Duration::from_secs(30);
|
||||
pub enum Message {
|
||||
/// Poll at once. The periodic poll deadline is left as it is.
|
||||
PollNow,
|
||||
/// Change the poll interval and re-arm it from now, without polling.
|
||||
/// Change the poll interval and re-arm it at its next boundary, without
|
||||
/// polling.
|
||||
SetPollInterval(Duration),
|
||||
/// Re-render the countdowns after this delay; `None` clears the deadline.
|
||||
RecomputeCountdown(Option<Duration>),
|
||||
@@ -53,9 +57,9 @@ pub enum Message {
|
||||
interval_secs: Option<u64>,
|
||||
last_check_unix: Option<u64>,
|
||||
},
|
||||
/// An update check finished. Only this re-arms the check: after the
|
||||
/// full interval when it succeeded, after a growing retry delay capped
|
||||
/// at the interval when it failed.
|
||||
/// An update check finished. Only this re-arms the check: at the next
|
||||
/// boundary when it succeeded, after a growing retry delay capped at
|
||||
/// that boundary when it failed.
|
||||
UpdateCheckDone {
|
||||
interval_secs: Option<u64>,
|
||||
succeeded: bool,
|
||||
@@ -77,17 +81,21 @@ pub trait SchedulerHost {
|
||||
}
|
||||
|
||||
/// Settings the deadlines start from.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub poll_interval: Duration,
|
||||
pub update_interval_secs: Option<u64>,
|
||||
pub last_update_check_unix: Option<u64>,
|
||||
/// The zone whose clock the poll and the update check follow.
|
||||
pub tz: TimeZone,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Deadlines {
|
||||
tz: TimeZone,
|
||||
poll_interval: Duration,
|
||||
poll_at: Instant,
|
||||
/// Unix seconds the next poll is due at.
|
||||
poll_at_unix: u64,
|
||||
countdown_at: Option<Instant>,
|
||||
/// Unix seconds the next update check is due at.
|
||||
update_at_unix: Option<u64>,
|
||||
@@ -98,15 +106,16 @@ pub struct Deadlines {
|
||||
impl Deadlines {
|
||||
/// Arm the deadlines at startup. Returns the actions due at once: the
|
||||
/// update check when the last one is older than its interval.
|
||||
pub fn new(config: Config, now: Instant, now_unix: u64) -> (Self, Vec<Action>) {
|
||||
let poll_interval = config.poll_interval.max(MIN_POLL_INTERVAL);
|
||||
pub fn new(config: Config, now_unix: u64) -> (Self, Vec<Action>) {
|
||||
let mut deadlines = Self {
|
||||
poll_interval,
|
||||
poll_at: now + poll_interval,
|
||||
tz: config.tz,
|
||||
poll_interval: config.poll_interval.max(MIN_POLL_INTERVAL),
|
||||
poll_at_unix: 0,
|
||||
countdown_at: None,
|
||||
update_at_unix: None,
|
||||
update_failures: 0,
|
||||
};
|
||||
deadlines.poll_at_unix = deadlines.next_poll(now_unix);
|
||||
let actions = deadlines.arm_update(
|
||||
config.update_interval_secs,
|
||||
config.last_update_check_unix,
|
||||
@@ -121,7 +130,7 @@ impl Deadlines {
|
||||
Message::PollNow => vec![Action::Poll],
|
||||
Message::SetPollInterval(interval) => {
|
||||
self.poll_interval = interval.max(MIN_POLL_INTERVAL);
|
||||
self.poll_at = now + self.poll_interval;
|
||||
self.poll_at_unix = self.next_poll(now_unix);
|
||||
Vec::new()
|
||||
}
|
||||
Message::RecomputeCountdown(delay) => {
|
||||
@@ -136,29 +145,31 @@ impl Deadlines {
|
||||
interval_secs,
|
||||
succeeded,
|
||||
} => {
|
||||
let delay = if succeeded {
|
||||
self.update_failures = 0;
|
||||
interval_secs.map(update_interval)
|
||||
} else {
|
||||
let delay = interval_secs.map(|secs| self.retry_delay(secs));
|
||||
self.update_failures = self.update_failures.saturating_add(1);
|
||||
delay
|
||||
};
|
||||
self.update_at_unix = delay.map(|d| now_unix.saturating_add(d.as_secs()));
|
||||
self.update_at_unix = interval_secs.map(|secs| {
|
||||
let next = self.next_update(secs, now_unix);
|
||||
if succeeded {
|
||||
self.update_failures = 0;
|
||||
next
|
||||
} else {
|
||||
let retry = now_unix.saturating_add(self.retry_delay().as_secs());
|
||||
self.update_failures = self.update_failures.saturating_add(1);
|
||||
retry.min(next)
|
||||
}
|
||||
});
|
||||
Vec::new()
|
||||
}
|
||||
Message::Shutdown => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire every deadline that has passed. The poll re-arms for a full
|
||||
/// interval; the countdown and update check stay disarmed until the
|
||||
/// Fire every deadline that has passed. The poll re-arms at its next
|
||||
/// boundary; the countdown and update check stay disarmed until the
|
||||
/// host re-arms them (after the re-render, after the check finished).
|
||||
pub fn take_due(&mut self, now: Instant, now_unix: u64) -> Vec<Action> {
|
||||
let mut due = Vec::new();
|
||||
if self.poll_at <= now {
|
||||
if self.poll_at_unix <= now_unix {
|
||||
due.push(Action::Poll);
|
||||
self.poll_at = now + self.poll_interval;
|
||||
self.poll_at_unix = self.next_poll(now_unix);
|
||||
}
|
||||
if self.countdown_at.is_some_and(|at| at <= now) {
|
||||
due.push(Action::Countdown);
|
||||
@@ -171,25 +182,23 @@ impl Deadlines {
|
||||
due
|
||||
}
|
||||
|
||||
/// The machine woke from sleep: poll at once and re-arm the poll for a
|
||||
/// full interval from now. The update check needs nothing here, since
|
||||
/// The machine woke from sleep: poll at once and re-arm the poll at its
|
||||
/// next boundary. The update check needs nothing here, since
|
||||
/// [`Self::take_due`] compares its wall-clock deadline with `now_unix`.
|
||||
pub fn resume(&mut self, now: Instant) -> Vec<Action> {
|
||||
self.poll_at = now + self.poll_interval;
|
||||
pub fn resume(&mut self, now_unix: u64) -> Vec<Action> {
|
||||
self.poll_at_unix = self.next_poll(now_unix);
|
||||
vec![Action::Poll]
|
||||
}
|
||||
|
||||
/// Time until the nearest deadline. The poll deadline is always armed,
|
||||
/// so there always is one.
|
||||
pub fn next_wakeup(&self, now: Instant, now_unix: u64) -> Duration {
|
||||
let update = self
|
||||
.update_at_unix
|
||||
.map(|at| Duration::from_secs(at.saturating_sub(now_unix)));
|
||||
let until = |at: u64| Duration::from_secs(at.saturating_sub(now_unix));
|
||||
[
|
||||
Some(self.poll_at.saturating_duration_since(now)),
|
||||
Some(until(self.poll_at_unix)),
|
||||
self.countdown_at
|
||||
.map(|at| at.saturating_duration_since(now)),
|
||||
update,
|
||||
self.update_at_unix.map(until),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -204,7 +213,7 @@ impl Deadlines {
|
||||
now_unix: u64,
|
||||
) -> Vec<Action> {
|
||||
let interval_secs = interval_secs.map(|secs| secs.max(MIN_UPDATE_INTERVAL.as_secs()));
|
||||
match update_check_due(last_check_unix, interval_secs, now_unix) {
|
||||
match update_check_due(last_check_unix, interval_secs, now_unix, &self.tz) {
|
||||
None => {
|
||||
self.update_at_unix = None;
|
||||
Vec::new()
|
||||
@@ -220,18 +229,24 @@ impl Deadlines {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay before the next try after the current run of failures:
|
||||
/// `UPDATE_RETRY_BASE * 2^failures`, capped at the check interval.
|
||||
fn retry_delay(&self, interval_secs: u64) -> Duration {
|
||||
let factor = 1u32.checked_shl(self.update_failures).unwrap_or(u32::MAX);
|
||||
UPDATE_RETRY_BASE
|
||||
.saturating_mul(factor)
|
||||
.min(update_interval(interval_secs))
|
||||
/// The poll boundary after `now_unix`.
|
||||
fn next_poll(&self, now_unix: u64) -> u64 {
|
||||
clock_slot(self.poll_interval.as_secs(), now_unix, &self.tz).1
|
||||
}
|
||||
}
|
||||
|
||||
fn update_interval(secs: u64) -> Duration {
|
||||
Duration::from_secs(secs).max(MIN_UPDATE_INTERVAL)
|
||||
/// The update-check boundary after `now_unix`.
|
||||
fn next_update(&self, interval_secs: u64, now_unix: u64) -> u64 {
|
||||
let interval_secs = interval_secs.max(MIN_UPDATE_INTERVAL.as_secs());
|
||||
clock_slot(interval_secs, now_unix, &self.tz).1
|
||||
}
|
||||
|
||||
/// Delay before the next try after the current run of failures:
|
||||
/// `UPDATE_RETRY_BASE * 2^failures`; the caller caps it at the next
|
||||
/// boundary.
|
||||
fn retry_delay(&self) -> Duration {
|
||||
let factor = 1u32.checked_shl(self.update_failures).unwrap_or(u32::MAX);
|
||||
UPDATE_RETRY_BASE.saturating_mul(factor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the wall clock ran ahead of `Instant` by more than
|
||||
@@ -260,7 +275,7 @@ where
|
||||
std::thread::Builder::new()
|
||||
.name("scheduler".into())
|
||||
.spawn(move || {
|
||||
let (deadlines, initial) = Deadlines::new(config, Instant::now(), unix_now());
|
||||
let (deadlines, initial) = Deadlines::new(config, unix_now());
|
||||
for action in initial {
|
||||
host.run(action);
|
||||
}
|
||||
@@ -282,7 +297,7 @@ fn run_loop<H: SchedulerHost>(mut deadlines: Deadlines, rx: &Receiver<Message>,
|
||||
"wall clock moved {wall_elapsed:?} while Instant moved {instant_elapsed:?}; \
|
||||
treating it as a resume from sleep"
|
||||
);
|
||||
for action in deadlines.resume(now) {
|
||||
for action in deadlines.resume(unix_now()) {
|
||||
host.run(action);
|
||||
}
|
||||
}
|
||||
@@ -308,71 +323,97 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc::{self, Sender};
|
||||
|
||||
use jiff::tz;
|
||||
|
||||
use crate::core::settings::{
|
||||
UPDATE_CHECK_DAILY_SECS, UPDATE_CHECK_HOURLY_SECS, UPDATE_CHECK_WEEKLY_SECS,
|
||||
};
|
||||
|
||||
const MIN: Duration = Duration::from_secs(60);
|
||||
const M: u64 = 60;
|
||||
const HOUR: u64 = 60 * M;
|
||||
const DAY: u64 = 24 * HOUR;
|
||||
/// Friday 2027-01-15 08:00:00 UTC, a whole hour.
|
||||
const NOW_UNIX: u64 = 1_800_000_000;
|
||||
const DAY: u64 = 24 * 60 * 60;
|
||||
/// The Monday 00:00 UTC that starts `NOW_UNIX`'s week.
|
||||
const MONDAY: u64 = NOW_UNIX - 4 * DAY - 8 * HOUR;
|
||||
|
||||
fn config(update: Option<u64>, last: Option<u64>) -> Config {
|
||||
Config {
|
||||
poll_interval: 5 * MIN,
|
||||
update_interval_secs: update,
|
||||
last_update_check_unix: last,
|
||||
tz: TimeZone::UTC,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_fires_every_interval() {
|
||||
fn poll_fires_on_interval_boundaries() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, initial) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
// Started at 08:02:10, a 5-minute poll is next due at 08:05:00.
|
||||
let start = NOW_UNIX + 2 * M + 10;
|
||||
let (mut d, initial) = Deadlines::new(config(None, None), start);
|
||||
assert!(initial.is_empty());
|
||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX), 5 * MIN);
|
||||
assert!(d.take_due(t0 + 4 * MIN, NOW_UNIX).is_empty());
|
||||
assert_eq!(d.take_due(t0 + 5 * MIN, NOW_UNIX), [Action::Poll]);
|
||||
assert_eq!(d.next_wakeup(t0 + 5 * MIN, NOW_UNIX), 5 * MIN);
|
||||
assert_eq!(d.take_due(t0 + 10 * MIN, NOW_UNIX), [Action::Poll]);
|
||||
assert_eq!(d.next_wakeup(t0, start), Duration::from_secs(2 * M + 50));
|
||||
assert!(d.take_due(t0, NOW_UNIX + 5 * M - 1).is_empty());
|
||||
assert_eq!(d.take_due(t0, NOW_UNIX + 5 * M), [Action::Poll]);
|
||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX + 5 * M), 5 * MIN);
|
||||
// A late wake-up keeps the boundaries: 08:10:20 re-arms 08:15:00.
|
||||
assert_eq!(d.take_due(t0, NOW_UNIX + 10 * M + 20), [Action::Poll]);
|
||||
assert!(d.take_due(t0, NOW_UNIX + 15 * M - 1).is_empty());
|
||||
assert_eq!(d.take_due(t0, NOW_UNIX + 15 * M), [Action::Poll]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_boundaries_follow_the_local_clock() {
|
||||
// At +05:45, 5-minute boundaries fall on the local :00, :05 and so
|
||||
// on, which is UTC :15, :20 and so on.
|
||||
let mut cfg = config(None, None);
|
||||
cfg.tz = TimeZone::fixed(tz::Offset::from_seconds(5 * 3_600 + 45 * 60).unwrap());
|
||||
cfg.poll_interval = 60 * MIN;
|
||||
let (d, _) = Deadlines::new(cfg, NOW_UNIX);
|
||||
assert_eq!(
|
||||
d.next_wakeup(Instant::now(), NOW_UNIX),
|
||||
Duration::from_secs(15 * M)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_now_polls_without_moving_the_periodic_deadline() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||
assert_eq!(
|
||||
d.handle(Message::PollNow, t0 + 2 * MIN, NOW_UNIX),
|
||||
d.handle(Message::PollNow, t0, NOW_UNIX + 2 * M),
|
||||
[Action::Poll]
|
||||
);
|
||||
assert_eq!(d.take_due(t0 + 5 * MIN, NOW_UNIX), [Action::Poll]);
|
||||
assert_eq!(d.take_due(t0, NOW_UNIX + 5 * M), [Action::Poll]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_poll_interval_rearms_from_now_without_polling() {
|
||||
fn new_poll_interval_rearms_at_its_next_boundary_without_polling() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
let t1 = t0 + 4 * MIN;
|
||||
let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||
let t1 = NOW_UNIX + 4 * M;
|
||||
assert!(d
|
||||
.handle(Message::SetPollInterval(MIN * 15), t1, NOW_UNIX)
|
||||
.handle(Message::SetPollInterval(MIN * 15), t0, t1)
|
||||
.is_empty());
|
||||
assert!(d.take_due(t0 + 5 * MIN, NOW_UNIX).is_empty());
|
||||
assert_eq!(d.next_wakeup(t1, NOW_UNIX), 15 * MIN);
|
||||
assert_eq!(d.take_due(t1 + 15 * MIN, NOW_UNIX), [Action::Poll]);
|
||||
assert!(d.take_due(t0, NOW_UNIX + 5 * M).is_empty());
|
||||
assert_eq!(d.next_wakeup(t0, t1), 11 * MIN);
|
||||
assert_eq!(d.take_due(t0, NOW_UNIX + 15 * M), [Action::Poll]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_poll_interval_is_floored() {
|
||||
let t0 = Instant::now();
|
||||
let mut cfg = config(None, None);
|
||||
cfg.poll_interval = Duration::ZERO;
|
||||
let (d, _) = Deadlines::new(cfg, t0, NOW_UNIX);
|
||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX), MIN_POLL_INTERVAL);
|
||||
let (d, _) = Deadlines::new(cfg, NOW_UNIX);
|
||||
assert_eq!(d.next_wakeup(Instant::now(), NOW_UNIX), MIN_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn countdown_fires_once_until_rearmed() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||
let delay = Duration::from_secs(31);
|
||||
d.handle(Message::RecomputeCountdown(Some(delay)), t0, NOW_UNIX);
|
||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX), delay);
|
||||
@@ -384,42 +425,10 @@ mod tests {
|
||||
assert!(d.take_due(t0 + 2 * delay, NOW_UNIX).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_checks_for_updates_when_due() {
|
||||
let t0 = Instant::now();
|
||||
let hourly = Some(UPDATE_CHECK_HOURLY_SECS);
|
||||
let (_, never_checked) = Deadlines::new(config(hourly, None), t0, NOW_UNIX);
|
||||
assert_eq!(never_checked, [Action::CheckForUpdates]);
|
||||
let (_, stale) = Deadlines::new(config(hourly, Some(NOW_UNIX - 2 * 3_600)), t0, NOW_UNIX);
|
||||
assert_eq!(stale, [Action::CheckForUpdates]);
|
||||
let (_, disabled) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
assert!(disabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_waits_for_the_remainder_when_not_due() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, initial) = Deadlines::new(
|
||||
config(Some(UPDATE_CHECK_HOURLY_SECS), Some(NOW_UNIX - 600)),
|
||||
t0,
|
||||
NOW_UNIX,
|
||||
);
|
||||
assert!(initial.is_empty());
|
||||
let remaining = 3_000;
|
||||
assert!(!d
|
||||
.take_due(t0, NOW_UNIX + remaining - 60)
|
||||
.contains(&Action::CheckForUpdates));
|
||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX + remaining - 60), MIN);
|
||||
assert_eq!(
|
||||
d.take_due(t0, NOW_UNIX + remaining),
|
||||
[Action::CheckForUpdates]
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether a check is due at `now_unix`, with `Instant` left at `t0`
|
||||
/// to show the update deadline follows the wall clock alone.
|
||||
fn check_due(d: &mut Deadlines, t0: Instant, now_unix: u64) -> bool {
|
||||
d.take_due(t0, now_unix).contains(&Action::CheckForUpdates)
|
||||
/// Whether a check is due at `now_unix`.
|
||||
fn check_due(d: &mut Deadlines, now_unix: u64) -> bool {
|
||||
d.take_due(Instant::now(), now_unix)
|
||||
.contains(&Action::CheckForUpdates)
|
||||
}
|
||||
|
||||
fn done(interval_secs: Option<u64>, succeeded: bool) -> Message {
|
||||
@@ -430,76 +439,118 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_finished_check_rearms_the_full_interval() {
|
||||
let t0 = Instant::now();
|
||||
let hour = UPDATE_CHECK_HOURLY_SECS;
|
||||
let (mut d, initial) = Deadlines::new(config(Some(hour), None), t0, NOW_UNIX);
|
||||
assert_eq!(initial, [Action::CheckForUpdates]);
|
||||
// While the check runs nothing else is scheduled for it.
|
||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 3 * hour));
|
||||
|
||||
let t1 = NOW_UNIX + 3 * hour;
|
||||
d.handle(done(Some(hour), true), t0, t1);
|
||||
assert!(!check_due(&mut d, t0, t1 + hour / 2));
|
||||
assert!(check_due(&mut d, t0, t1 + hour));
|
||||
// It repeats every interval, not just once.
|
||||
let t2 = t1 + hour;
|
||||
d.handle(done(Some(hour), true), t0, t2);
|
||||
assert!(check_due(&mut d, t0, t2 + hour));
|
||||
fn startup_checks_only_when_a_boundary_passed_since_the_last_check() {
|
||||
let hourly = Some(UPDATE_CHECK_HOURLY_SECS);
|
||||
let (_, never_checked) = Deadlines::new(config(hourly, None), NOW_UNIX + M);
|
||||
assert_eq!(never_checked, [Action::CheckForUpdates]);
|
||||
// Checked at 07:59:59, started at 08:01: 08:00 passed in between.
|
||||
let (_, passed) = Deadlines::new(config(hourly, Some(NOW_UNIX - 1)), NOW_UNIX + M);
|
||||
assert_eq!(passed, [Action::CheckForUpdates]);
|
||||
// Checked at 08:00:30, started at 08:59: no boundary yet, so it
|
||||
// waits for 09:00.
|
||||
let (mut d, waiting) =
|
||||
Deadlines::new(config(hourly, Some(NOW_UNIX + 30)), NOW_UNIX + 59 * M);
|
||||
assert!(waiting.is_empty());
|
||||
assert!(!check_due(&mut d, NOW_UNIX + HOUR - 1));
|
||||
assert!(check_due(&mut d, NOW_UNIX + HOUR));
|
||||
let (_, disabled) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||
assert!(disabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_check_retries_with_a_doubling_delay_capped_at_the_interval() {
|
||||
let t0 = Instant::now();
|
||||
fn daily_and_weekly_checks_fall_on_local_midnight() {
|
||||
let daily = Some(UPDATE_CHECK_DAILY_SECS);
|
||||
let midnight = NOW_UNIX - 8 * HOUR;
|
||||
let (_, before) = Deadlines::new(config(daily, Some(midnight - 1)), NOW_UNIX);
|
||||
assert_eq!(before, [Action::CheckForUpdates]);
|
||||
let (mut d, after) = Deadlines::new(config(daily, Some(midnight)), NOW_UNIX);
|
||||
assert!(after.is_empty());
|
||||
assert!(!check_due(&mut d, midnight + DAY - 1));
|
||||
assert!(check_due(&mut d, midnight + DAY));
|
||||
|
||||
let weekly = Some(UPDATE_CHECK_WEEKLY_SECS);
|
||||
let (mut d, initial) = Deadlines::new(config(weekly, Some(MONDAY + 1)), NOW_UNIX);
|
||||
assert!(initial.is_empty());
|
||||
assert!(!check_due(&mut d, MONDAY + 7 * DAY - 1));
|
||||
assert!(check_due(&mut d, MONDAY + 7 * DAY));
|
||||
|
||||
// At UTC+07:00 local midnight is 17:00 UTC the day before.
|
||||
let mut cfg = config(daily, Some(NOW_UNIX));
|
||||
cfg.tz = TimeZone::fixed(tz::offset(7));
|
||||
let (mut d, _) = Deadlines::new(cfg, NOW_UNIX);
|
||||
assert!(!check_due(&mut d, NOW_UNIX + 9 * HOUR - 1));
|
||||
assert!(check_due(&mut d, NOW_UNIX + 9 * HOUR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_check_rearms_at_the_next_boundary() {
|
||||
let hour = UPDATE_CHECK_HOURLY_SECS;
|
||||
let (mut d, _) = Deadlines::new(config(Some(hour), None), t0, NOW_UNIX);
|
||||
let (mut d, initial) = Deadlines::new(config(Some(hour), None), NOW_UNIX);
|
||||
assert_eq!(initial, [Action::CheckForUpdates]);
|
||||
// While the check runs nothing else is scheduled for it.
|
||||
assert!(!check_due(&mut d, NOW_UNIX + 3 * hour));
|
||||
|
||||
let t1 = NOW_UNIX + 3 * hour + 20 * M;
|
||||
d.handle(done(Some(hour), true), Instant::now(), t1);
|
||||
assert!(!check_due(&mut d, NOW_UNIX + 4 * hour - 1));
|
||||
assert!(check_due(&mut d, NOW_UNIX + 4 * hour));
|
||||
// It repeats every boundary, not just once.
|
||||
d.handle(done(Some(hour), true), Instant::now(), NOW_UNIX + 4 * hour);
|
||||
assert!(check_due(&mut d, NOW_UNIX + 5 * hour));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_check_retries_with_a_doubling_delay_capped_at_the_next_boundary() {
|
||||
let day = UPDATE_CHECK_DAILY_SECS;
|
||||
let (mut d, _) = Deadlines::new(config(Some(day), None), NOW_UNIX);
|
||||
let mut now = NOW_UNIX;
|
||||
for expected in [60, 120, 240, 480, 960, 1_920, hour, hour] {
|
||||
d.handle(done(Some(hour), false), t0, now);
|
||||
assert!(!check_due(&mut d, t0, now + expected - 1));
|
||||
assert!(check_due(&mut d, t0, now + expected));
|
||||
for expected in [60, 120, 240, 480, 960, 1_920, 3_840] {
|
||||
d.handle(done(Some(day), false), Instant::now(), now);
|
||||
assert!(!check_due(&mut d, now + expected - 1));
|
||||
assert!(check_due(&mut d, now + expected));
|
||||
now += expected;
|
||||
}
|
||||
// The next delay would pass midnight, so it retries at midnight.
|
||||
let midnight = NOW_UNIX - 8 * HOUR + DAY;
|
||||
now = midnight - 10 * M;
|
||||
d.handle(done(Some(day), false), Instant::now(), now);
|
||||
assert!(check_due(&mut d, midnight));
|
||||
// A success resets the delay: the next failure retries after a minute.
|
||||
d.handle(done(Some(hour), true), t0, now);
|
||||
assert!(check_due(&mut d, t0, now + hour));
|
||||
now += hour;
|
||||
d.handle(done(Some(hour), false), t0, now);
|
||||
assert!(check_due(&mut d, t0, now + 60));
|
||||
d.handle(done(Some(day), true), Instant::now(), midnight);
|
||||
d.handle(done(Some(day), false), Instant::now(), midnight + HOUR);
|
||||
assert!(check_due(&mut d, midnight + HOUR + 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_check_with_auto_check_off_stays_off() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
d.handle(done(None, false), t0, NOW_UNIX);
|
||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 7 * DAY));
|
||||
let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||
d.handle(done(None, false), Instant::now(), NOW_UNIX);
|
||||
assert!(!check_due(&mut d, NOW_UNIX + 7 * DAY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_update_deadline_follows_the_wall_clock_across_a_sleep() {
|
||||
fn the_wall_clock_deadlines_survive_a_sleep() {
|
||||
// `Instant` does not advance while the machine sleeps, but the
|
||||
// check is still due once the wall clock passes its time.
|
||||
// deadlines are due once the wall clock passes them.
|
||||
let t0 = Instant::now();
|
||||
let day = UPDATE_CHECK_DAILY_SECS;
|
||||
let (mut d, _) = Deadlines::new(config(Some(day), Some(NOW_UNIX)), t0, NOW_UNIX);
|
||||
assert!(!check_due(&mut d, t0, NOW_UNIX + day - 1));
|
||||
assert!(check_due(
|
||||
&mut d,
|
||||
t0 + Duration::from_secs(1),
|
||||
NOW_UNIX + day
|
||||
));
|
||||
let (mut d, _) = Deadlines::new(config(Some(day), Some(NOW_UNIX)), NOW_UNIX);
|
||||
let midnight = NOW_UNIX - 8 * HOUR + DAY;
|
||||
assert!(!d
|
||||
.take_due(t0, midnight - 1)
|
||||
.contains(&Action::CheckForUpdates));
|
||||
assert!(d.take_due(t0, midnight).contains(&Action::CheckForUpdates));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_polls_at_once_and_rearms_the_poll() {
|
||||
fn resume_polls_at_once_and_rearms_the_poll_at_its_boundary() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
let t1 = t0 + 2 * MIN;
|
||||
assert_eq!(d.resume(t1), [Action::Poll]);
|
||||
assert!(d.take_due(t0 + 5 * MIN, NOW_UNIX).is_empty());
|
||||
assert_eq!(d.next_wakeup(t1, NOW_UNIX), 5 * MIN);
|
||||
assert_eq!(d.take_due(t1 + 5 * MIN, NOW_UNIX), [Action::Poll]);
|
||||
let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
|
||||
let woke = NOW_UNIX + 7 * HOUR + 2 * M;
|
||||
assert_eq!(d.resume(woke), [Action::Poll]);
|
||||
assert!(d.take_due(t0, woke).is_empty());
|
||||
assert_eq!(d.next_wakeup(t0, woke), 3 * MIN);
|
||||
assert_eq!(d.take_due(t0, woke + 3 * M), [Action::Poll]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -520,11 +571,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_to_daily_with_the_last_check_two_days_ago_checks_at_once() {
|
||||
let t0 = Instant::now();
|
||||
fn weekly_to_daily_checks_at_once_when_midnight_passed_since_the_last_check() {
|
||||
let last = Some(NOW_UNIX - 2 * DAY);
|
||||
let (mut d, initial) =
|
||||
Deadlines::new(config(Some(UPDATE_CHECK_WEEKLY_SECS), last), t0, NOW_UNIX);
|
||||
Deadlines::new(config(Some(UPDATE_CHECK_WEEKLY_SECS), last), NOW_UNIX);
|
||||
assert!(initial.is_empty());
|
||||
assert_eq!(
|
||||
d.handle(
|
||||
@@ -532,7 +582,7 @@ mod tests {
|
||||
interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
|
||||
last_check_unix: last,
|
||||
},
|
||||
t0,
|
||||
Instant::now(),
|
||||
NOW_UNIX,
|
||||
),
|
||||
[Action::CheckForUpdates]
|
||||
@@ -540,44 +590,40 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_to_weekly_waits_for_the_new_remainder() {
|
||||
let t0 = Instant::now();
|
||||
fn daily_to_weekly_waits_for_monday() {
|
||||
let last = Some(NOW_UNIX - 2 * DAY);
|
||||
let (mut d, _) = Deadlines::new(config(Some(UPDATE_CHECK_HOURLY_SECS), last), t0, NOW_UNIX);
|
||||
let (mut d, _) = Deadlines::new(config(Some(UPDATE_CHECK_HOURLY_SECS), last), NOW_UNIX);
|
||||
let actions = d.handle(
|
||||
Message::SetUpdateInterval {
|
||||
interval_secs: Some(UPDATE_CHECK_WEEKLY_SECS),
|
||||
last_check_unix: last,
|
||||
},
|
||||
t0,
|
||||
Instant::now(),
|
||||
NOW_UNIX,
|
||||
);
|
||||
assert!(actions.is_empty());
|
||||
let remaining = 5 * DAY;
|
||||
assert!(!check_due(&mut d, t0, NOW_UNIX + remaining - 60));
|
||||
assert!(check_due(&mut d, t0, NOW_UNIX + remaining));
|
||||
assert!(!check_due(&mut d, MONDAY + 7 * DAY - 1));
|
||||
assert!(check_due(&mut d, MONDAY + 7 * DAY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_auto_check_cancels_the_deadline() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _) = Deadlines::new(
|
||||
config(Some(UPDATE_CHECK_HOURLY_SECS), Some(NOW_UNIX - 600)),
|
||||
t0,
|
||||
NOW_UNIX,
|
||||
config(Some(UPDATE_CHECK_HOURLY_SECS), Some(NOW_UNIX + 60)),
|
||||
NOW_UNIX + 600,
|
||||
);
|
||||
d.handle(
|
||||
Message::SetUpdateInterval {
|
||||
interval_secs: None,
|
||||
last_check_unix: Some(NOW_UNIX - 600),
|
||||
last_check_unix: Some(NOW_UNIX + 60),
|
||||
},
|
||||
t0,
|
||||
NOW_UNIX,
|
||||
Instant::now(),
|
||||
NOW_UNIX + 600,
|
||||
);
|
||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 7 * DAY));
|
||||
assert!(!check_due(&mut d, NOW_UNIX + 7 * DAY));
|
||||
// A check that finishes after auto-check was turned off stays off.
|
||||
d.handle(done(None, true), t0, NOW_UNIX);
|
||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 14 * DAY));
|
||||
d.handle(done(None, true), Instant::now(), NOW_UNIX);
|
||||
assert!(!check_due(&mut d, NOW_UNIX + 14 * DAY));
|
||||
}
|
||||
|
||||
/// Records actions on a channel, standing in for the runtime.
|
||||
|
||||
+5
-11
@@ -37,7 +37,6 @@ pub struct TrayView {
|
||||
pub fn views(enabled: &[ProviderId], usage: &UsageState, strings: &LocaleStrings) -> Vec<TrayView> {
|
||||
enabled
|
||||
.iter()
|
||||
.filter(|p| p.metadata().live_usage)
|
||||
.map(|&provider| {
|
||||
let entry = usage.snapshots.get(&provider);
|
||||
TrayView {
|
||||
@@ -106,7 +105,7 @@ struct Plan {
|
||||
|
||||
fn plan(shown: &HashMap<ProviderId, Shown>, views: &[TrayView]) -> Plan {
|
||||
let mut plan = Plan {
|
||||
remove: ProviderId::LIVE_USAGE
|
||||
remove: ProviderId::ALL
|
||||
.into_iter()
|
||||
.filter(|p| shown.contains_key(p) && !views.iter().any(|v| v.provider == *p))
|
||||
.collect(),
|
||||
@@ -153,7 +152,7 @@ impl Trays {
|
||||
/// The providers that currently have an icon.
|
||||
pub fn providers(&self) -> Vec<ProviderId> {
|
||||
let shown = self.shown();
|
||||
ProviderId::LIVE_USAGE
|
||||
ProviderId::ALL
|
||||
.into_iter()
|
||||
.filter(|p| shown.contains_key(p))
|
||||
.collect()
|
||||
@@ -319,13 +318,8 @@ mod tests {
|
||||
#[test]
|
||||
fn views_follow_enabled_providers_and_last_poll() {
|
||||
let s = strings();
|
||||
let all = [
|
||||
ProviderId::Claude,
|
||||
ProviderId::ChatGpt,
|
||||
ProviderId::OpenCodeGo,
|
||||
];
|
||||
let v = views(&all, &usage(42.0, true), &s);
|
||||
assert_eq!(v.len(), 2, "OpenCode Go never gets an icon");
|
||||
let v = views(&ProviderId::ALL, &usage(42.0, true), &s);
|
||||
assert_eq!(v.len(), 2);
|
||||
assert_eq!(v[0].provider, ProviderId::Claude);
|
||||
assert_eq!(v[0].percent, Some(42.0));
|
||||
assert_eq!(
|
||||
@@ -335,7 +329,7 @@ mod tests {
|
||||
assert_eq!(v[1].provider, ProviderId::ChatGpt);
|
||||
assert_eq!(v[1].percent, None);
|
||||
// A failed last poll greys every badge to the track.
|
||||
let v = views(&all, &usage(42.0, false), &s);
|
||||
let v = views(&ProviderId::ALL, &usage(42.0, false), &s);
|
||||
assert_eq!(v[0].percent, None);
|
||||
assert!(views(&[ProviderId::ChatGpt], &usage(1.0, true), &s)
|
||||
.iter()
|
||||
|
||||
+2
-18
@@ -31,16 +31,13 @@ impl Registry {
|
||||
settings: &Settings,
|
||||
) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
|
||||
let mut out = Vec::new();
|
||||
for provider in ProviderId::LIVE_USAGE {
|
||||
for provider in ProviderId::ALL {
|
||||
if !settings.is_provider_enabled(provider) {
|
||||
continue;
|
||||
}
|
||||
let result = match provider {
|
||||
ProviderId::Claude => self.claude.poll(http),
|
||||
ProviderId::ChatGpt => self.chatgpt.poll(http),
|
||||
ProviderId::OpenCodeGo => {
|
||||
unreachable!("OpenCode Go has no live usage provider yet")
|
||||
}
|
||||
};
|
||||
out.push((provider, result));
|
||||
}
|
||||
@@ -56,7 +53,6 @@ impl Registry {
|
||||
let locator = match id {
|
||||
ProviderId::Claude => self.claude.locator(),
|
||||
ProviderId::ChatGpt => self.chatgpt.locator(),
|
||||
ProviderId::OpenCodeGo => return refresh::Outcome::CliMissing,
|
||||
};
|
||||
match locator.first_available() {
|
||||
Some(src) => orchestrator.refresh(src),
|
||||
@@ -80,18 +76,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::net::USER_AGENT;
|
||||
|
||||
#[test]
|
||||
fn live_usage_excludes_opencode_go() {
|
||||
// `poll_enabled` relies on this for its `unreachable!` arm.
|
||||
assert!(!ProviderId::LIVE_USAGE.contains(&ProviderId::OpenCodeGo));
|
||||
for provider in ProviderId::ALL {
|
||||
assert_eq!(
|
||||
ProviderId::LIVE_USAGE.contains(&provider),
|
||||
provider.metadata().live_usage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the real providers with the credentials on this machine. Run by
|
||||
/// hand: `cargo test -- --ignored live_poll --nocapture`.
|
||||
#[test]
|
||||
@@ -104,7 +88,7 @@ mod tests {
|
||||
}
|
||||
let mut registry = Registry::with_defaults();
|
||||
let results = registry.poll_enabled(&http, &settings);
|
||||
assert_eq!(results.len(), ProviderId::LIVE_USAGE.len());
|
||||
assert_eq!(results.len(), ProviderId::ALL.len());
|
||||
for (provider, result) in results {
|
||||
match result {
|
||||
Ok(w) => println!(
|
||||
|
||||
+1
-51
@@ -12,19 +12,15 @@ pub enum ProviderId {
|
||||
Claude,
|
||||
#[serde(rename = "chatgpt")]
|
||||
ChatGpt,
|
||||
#[serde(rename = "opencode-go")]
|
||||
OpenCodeGo,
|
||||
}
|
||||
|
||||
impl ProviderId {
|
||||
pub const ALL: [Self; 3] = [Self::Claude, Self::ChatGpt, Self::OpenCodeGo];
|
||||
pub const LIVE_USAGE: [Self; 2] = [Self::Claude, Self::ChatGpt];
|
||||
pub const ALL: [Self; 2] = [Self::Claude, Self::ChatGpt];
|
||||
|
||||
pub fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude",
|
||||
Self::ChatGpt => "chatgpt",
|
||||
Self::OpenCodeGo => "opencode-go",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,51 +28,6 @@ impl ProviderId {
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| p.slug() == slug)
|
||||
}
|
||||
|
||||
pub fn metadata(self) -> ProviderMetadata {
|
||||
match self {
|
||||
Self::Claude => ProviderMetadata {
|
||||
id: self,
|
||||
slug: "claude",
|
||||
default_enabled: true,
|
||||
tray_icon_id: 1,
|
||||
display_mode: ProviderDisplayMode::TwoWindow,
|
||||
live_usage: true,
|
||||
},
|
||||
Self::ChatGpt => ProviderMetadata {
|
||||
id: self,
|
||||
slug: "chatgpt",
|
||||
default_enabled: false,
|
||||
tray_icon_id: 2,
|
||||
display_mode: ProviderDisplayMode::TwoWindow,
|
||||
live_usage: true,
|
||||
},
|
||||
Self::OpenCodeGo => ProviderMetadata {
|
||||
id: self,
|
||||
slug: "opencode-go",
|
||||
default_enabled: false,
|
||||
tray_icon_id: 3,
|
||||
display_mode: ProviderDisplayMode::WeeklyMonthlyFourBar,
|
||||
live_usage: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProviderDisplayMode {
|
||||
TwoWindow,
|
||||
WeeklyMonthlyFourBar,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ProviderMetadata {
|
||||
pub id: ProviderId,
|
||||
pub slug: &'static str,
|
||||
pub default_enabled: bool,
|
||||
pub tray_icon_id: u32,
|
||||
pub display_mode: ProviderDisplayMode,
|
||||
pub live_usage: bool,
|
||||
}
|
||||
|
||||
/// One usage window: how much you've consumed (0–100), and when it resets.
|
||||
@@ -110,7 +61,6 @@ mod tests {
|
||||
let json = serde_json::to_string(&provider).expect("serialise");
|
||||
assert_eq!(json, format!("\"{}\"", provider.slug()));
|
||||
assert_eq!(ProviderId::from_slug(provider.slug()), Some(provider));
|
||||
assert_eq!(provider.metadata().slug, provider.slug());
|
||||
}
|
||||
assert_eq!(ProviderId::from_slug("codex"), None);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
cssPx,
|
||||
headLabelText,
|
||||
headMetrics,
|
||||
outlinePath,
|
||||
pulseActive,
|
||||
pulseT,
|
||||
remainingFraction,
|
||||
@@ -156,6 +157,18 @@ describe('tailLayout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('outlinePath', () => {
|
||||
it('joins a full-height head to a centred tail with rounded end corners', () => {
|
||||
// h 100: tail 60 tall from y 20 to 80, end corners of radius 12, and the
|
||||
// tail edges meet the head circle at x = 50 + sqrt(50² - 30²) = 90.
|
||||
assert.equal(
|
||||
outlinePath(300, 100),
|
||||
'M 90 20 L 288 20 A 12 12 0 0 1 300 32 L 300 68 A 12 12 0 0 1 288 80 ' +
|
||||
'L 90 80 A 50 50 0 1 1 90 20 Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scale and cssPx', () => {
|
||||
it('truncates like the Rust scale', () => {
|
||||
assert.equal(scale(200, 96), 200);
|
||||
|
||||
+26
-12
@@ -1,20 +1,27 @@
|
||||
/* Bubble palette. Accent and fill colours come from the payload. */
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #1f1f1f;
|
||||
--track: #2c2c2c;
|
||||
--time-track: #404040;
|
||||
/* 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 / 80%);
|
||||
--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: #f3f3f3;
|
||||
--track: #e2e2e2;
|
||||
--time-track: #e0e0e0;
|
||||
--bg: rgb(243 243 243 / 80%);
|
||||
--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,
|
||||
@@ -26,7 +33,7 @@ body {
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* Only the pill takes input: the transparent stadium corners are dead. */
|
||||
/* Only the pill takes input: the area outside its outline is dead. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -34,7 +41,8 @@ body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 9999px;
|
||||
/* bubble.js clips this to the head-and-tail outline, which also bounds
|
||||
the hit area. */
|
||||
background: var(--bg);
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
cursor: move;
|
||||
@@ -42,7 +50,7 @@ body {
|
||||
}
|
||||
|
||||
/* Every pointer event targets the pill itself, whose hit area follows its
|
||||
rounded outline. */
|
||||
clipped outline. */
|
||||
.pill * {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -54,10 +62,15 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rings circle {
|
||||
.rings circle,
|
||||
.edge {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.edge {
|
||||
stroke: var(--edge);
|
||||
}
|
||||
|
||||
.ring-track {
|
||||
stroke: var(--track);
|
||||
}
|
||||
@@ -80,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>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
cssPx,
|
||||
headLabelText,
|
||||
headMetrics,
|
||||
outlinePath,
|
||||
pulseActive,
|
||||
pulseT,
|
||||
remainingFraction,
|
||||
@@ -58,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-/, '');
|
||||
|
||||
@@ -79,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} */
|
||||
@@ -217,7 +222,12 @@ function relayout(p) {
|
||||
tail = tailLayout(canvas, (text, px, weight) => measure(text, px, weight));
|
||||
layoutKey = key;
|
||||
|
||||
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);
|
||||
|
||||
@@ -30,6 +30,12 @@ const WHEEL_MODE_PX = [1, 16, 800];
|
||||
/** Usage at or above this percent pulses. */
|
||||
export const PULSE_THRESHOLD_PCT = 95;
|
||||
|
||||
/** Tail height as a share of the head diameter. */
|
||||
export const TAIL_HEIGHT_RATIO = 0.6;
|
||||
|
||||
/** Tail end corner radius as a share of the tail height. */
|
||||
const TAIL_CORNER_RATIO = 0.2;
|
||||
|
||||
/** Pulse period in ticks and the share of the way to white at the peak. */
|
||||
const PULSE_PERIOD = 24;
|
||||
const PULSE_STRENGTH = 0.3;
|
||||
@@ -195,6 +201,35 @@ export function headMetrics(canvas) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline of the bubble: a round head the full height of the window, with a
|
||||
* narrower, vertically centred tail running to the right edge. The tail's
|
||||
* end has small rounded corners. Units are whatever `w` and `h` are in.
|
||||
* @param {number} w Window width.
|
||||
* @param {number} h Window height, which is also the head diameter.
|
||||
* @returns {string} SVG path data for CSS `clip-path: path(...)`.
|
||||
*/
|
||||
export function outlinePath(w, h) {
|
||||
const r = h / 2;
|
||||
const tailH = h * TAIL_HEIGHT_RATIO;
|
||||
const top = (h - tailH) / 2;
|
||||
const bottom = top + tailH;
|
||||
const corner = tailH * TAIL_CORNER_RATIO;
|
||||
// Where the tail's top and bottom edges meet the head circle.
|
||||
const joinX = r + Math.sqrt(r * r - (r - top) ** 2);
|
||||
const n = (/** @type {number} */ v) => Number(v.toFixed(2));
|
||||
return [
|
||||
`M ${n(joinX)} ${n(top)}`,
|
||||
`L ${n(w - corner)} ${n(top)}`,
|
||||
`A ${n(corner)} ${n(corner)} 0 0 1 ${n(w)} ${n(top + corner)}`,
|
||||
`L ${n(w)} ${n(bottom - corner)}`,
|
||||
`A ${n(corner)} ${n(corner)} 0 0 1 ${n(w - corner)} ${n(bottom)}`,
|
||||
`L ${n(joinX)} ${n(bottom)}`,
|
||||
`A ${n(r)} ${n(r)} 0 1 1 ${n(joinX)} ${n(top)}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometry of the tail: the weekly usage bar and remaining-time bar with
|
||||
* their right-aligned texts. When the tail is too narrow, the text column
|
||||
|
||||
Reference in new issue
Block a user