Compare commits

...
11 Commits
Author SHA1 Message Date
tiennm99 6494740fd1 docs: tighten the README
Merge the overlapping feature, audience, requirements and usage sections, give updates their own section, fold the file tables into one, and drop the v0.5.0 migration notes.
2026-10-27 17:20:26 +07:00
tiennm99 c0bb291754 chore: bump version to 0.9.0 2026-10-27 17:04:32 +07:00
tiennm99 37cf7a3a42 feat: run the usage poll and update check on local clock boundaries
A 5-minute poll now fetches at :00, :05, :10 and so on, and the update check runs at the top of the hour, at midnight or at Monday midnight. At launch a check runs only when such a boundary passed since the last one. Deadlines are wall-clock timestamps, so they also hold across sleep. jiff supplies the local time zone and daylight-saving rules.
2026-10-27 17:03:40 +07:00
tiennm99 15f1120132 chore: bump version to 0.8.1 2026-10-27 15:57:33 +07:00
tiennm99 5462df1e7e feat: raise covered bubbles the moment another app's window appears
A WinEvent hook on window shows and z-order changes re-raises a bubble that another process's always-on-top window covers, instead of waiting for the 1.5 s check, which stays as a backstop. Raises are capped at 20 a second so an app that also fights for the top cannot keep both busy.
2026-10-27 15:56:32 +07:00
tiennm99 dfff4ebe9f style: make the bubble background 80% opaque 2026-10-27 15:56:32 +07:00
tiennm99 4e2ecbf588 fix: keep the bubble visible when click-through is toggled
On Windows, tao's set_ignore_cursor_events re-applies all its window flags. Bubbles are shown through ShowWindow, not tao, so tao believes them hidden: it hid the bubble and rewrote the extended style, dropping its no-activate and tool-window bits. The transparent and layered bits are now set directly on the main thread.
2026-10-27 15:56:32 +07:00
tiennm99 fd5c2ba910 chore: bump version to 0.8.0 2026-10-27 15:16:57 +07:00
tiennm99 cf058a4a38 feat: install found updates without waiting for a click
Where the app can replace itself (the portable exe and the AppImage), a check that finds a release now installs it and relaunches straight away. The .deb still only reports the update.
2026-10-27 15:15:27 +07:00
tiennm99 3aaf963841 fix: keep the see-through bubble readable on light backgrounds
Solid dark tracks looked like black holes and the muted text faded out when the 60% background sat over a light window. Tracks are now tints over the background, a faint edge traces the outline, and text gets a soft halo.
2026-10-27 15:15:27 +07:00
tiennm99 c7af9ed0ac fix: keep bubbles above other apps' always-on-top windows
Telegram toasts and similar topmost windows could sit over a bubble until the next minute's bring-back. On Windows, every fullscreen check now re-raises a shown bubble that another process's visible topmost window overlaps. The app's own panel and menus are ignored, and bubbles hidden for a fullscreen app stay hidden.
2026-10-27 15:15:27 +07:00
12 changed files with 749 additions and 481 deletions

No files matched your search

Generated
+2 -1
View File
@@ -4693,12 +4693,13 @@ dependencies = [
[[package]] [[package]]
name = "usage-monitor" name = "usage-monitor"
version = "0.7.0" version = "0.9.0"
dependencies = [ dependencies = [
"auto-launch", "auto-launch",
"dbus", "dbus",
"dirs 6.0.0", "dirs 6.0.0",
"flate2", "flate2",
"jiff",
"libc", "libc",
"libloading 0.9.0", "libloading 0.9.0",
"log", "log",
+4 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "usage-monitor" name = "usage-monitor"
version = "0.7.0" version = "0.9.0"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage" 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"] } ureq = { version = "3", features = ["platform-verifier", "socks-proxy"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# Local time zone for the clock-aligned poll and update check.
jiff = "0.2"
dirs = "6" dirs = "6"
log = "0.4" log = "0.4"
simplelog = "0.12" simplelog = "0.12"
@@ -56,6 +58,7 @@ windows = { version = "0.62", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_System_Registry", "Win32_System_Registry",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_UI_Accessibility",
"Win32_UI_WindowsAndMessaging", "Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell", "Win32_UI_Shell",
"Win32_Graphics_Dwm", "Win32_Graphics_Dwm",
+122 -250
View File
@@ -4,288 +4,160 @@
# Usage Monitor # Usage Monitor
A floating, draggable circular bubble that shows your Claude Code and/or A floating, see-through bubble that shows how much of your Claude Code and
Codex usage on Windows and Linux — inspired by the floating "memory boost Codex limits you have used, on Windows and Linux x86_64. Linux ARM64 and macOS
ball" UX of 360 Security and IObit Advanced SystemCare. are not supported yet; see [ROADMAP.md](ROADMAP.md).
Drop it anywhere on screen, drag it around, snap it to a monitor edge, ## Features
left-click for a panel with both your 5-hour and 7-day windows, right-click
for the menu.
Linux ARM64 and macOS are not supported yet; see [ROADMAP.md](ROADMAP.md). - **The bubble:** a round head with your 5-hour usage and a progress ring,
and a tail with the 7-day usage and time to reset. Left-click it for a
## Acknowledgements panel with both windows and their countdowns.
- **Place it anywhere:** drag it, and it snaps to a screen edge when you let
Inspired by [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor), go near one. Resize it with `Ctrl` + mouse wheel.
which solves the same "how close am I to the Claude Code limit?" problem - **Always on top:** it stays above other windows, chat notifications
with a horizontal taskbar widget. This project takes the UX in a different included, and hides while a fullscreen app (a game, a video, slides) is
direction — a floating, draggable circular bubble that the user can place in front.
anywhere on screen — and is a clean-room implementation: the HTTP client, - **Click-through:** an option to let clicks pass through the bubble to the
provider polling, credential discovery, tray rendering, and window below.
self-updater are all written from scratch against the same public APIs - **One bubble per provider:** Claude Code is on by default; Codex is
(Anthropic, ChatGPT, GitHub Releases). optional.
- **Tray and alerts:** a tray icon with a usage badge per provider, and
## What you get desktop notifications when usage crosses a threshold or a sign-in expires.
- **Updates itself:** new releases install automatically.
- 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** →
**Make smaller / Make larger / Reset size** from the right-click menu
(140–360 logical pixels)
- 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**, **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
- Every minute, shown bubbles are put back on top, so another
always-on-top window can't keep them covered or hidden
## Who this is for
Windows 10/11 and Linux x86_64 users who already have **Claude Code (CLI or
App) installed and signed in** on the same machine. Codex support is
optional — install and sign in to the Codex CLI, then enable Codex from the
right-click **Providers** menu.
## Requirements ## Requirements
- Windows 10 or Windows 11, or Linux x86_64 (see below) - Windows 10/11, or Linux x86_64.
- Claude Code (CLI or App) installed and authenticated - Claude Code (CLI or app), installed and signed in. The `claude` CLI must
- The `claude` CLI, for automatic token refresh. The app looks for it on be on `PATH` (on Linux, `~/.local/bin` and `~/.claude/local` work too) so
`PATH`, and on Linux also in `~/.local/bin` and `~/.claude/local`. Without the app can refresh an expired sign-in.
it an expired sign-in is not refreshed and you have to sign in again. - For Codex: the Codex CLI, installed and signed in.
- Optional: Codex CLI installed and authenticated, if you want Codex usage. - **Windows:** the
Its token is refreshed through the `codex` CLI, found the same way. [WebView2 runtime](https://developer.microsoft.com/microsoft-edge/webview2/).
Windows 11 and most up-to-date Windows 10 machines have it; if not, the
### Windows app tells you and exits.
- **Linux:**
The app draws its windows with the Microsoft Edge **WebView2 runtime**. - WebKitGTK 4.1 and `libayatana-appindicator3-1` for the tray icons. The
Windows 11 ships it, and most up-to-date Windows 10 machines already have it. `.deb` installs both.
If it is missing, the app shows a message with the download link and exits; - A compositing window manager, or the bubble's corners are drawn
install the opaque.
[Evergreen WebView2 runtime](https://developer.microsoft.com/microsoft-edge/webview2/) - An X11 session. Under Wayland the app runs through Xwayland. Without
and start the app again. Xwayland the bubbles show, but cannot be positioned, snapped or kept on
top.
### Linux
- **Runtime libraries:** WebKitGTK 4.1 (`libwebkit2gtk-4.1-0` on
Debian/Ubuntu) and, for the tray icons, `libayatana-appindicator3-1`.
The `.deb` pulls these in. Without an AppIndicator library the app runs
without tray icons.
- **A compositing window manager.** The bubble is a transparent window;
without a compositor its corners are drawn opaque.
- **X11 is the supported session.** Under Wayland the app runs through
Xwayland, where positioning, snapping and always-on-top work. Without
Xwayland (or with `GDK_BACKEND=wayland` set) the bubbles still show, but
they cannot be positioned, snapped, kept on top or saved where you drop
them.
- **Tray limits:** Linux tray icons (AppIndicator) only open the menu. They
show no tooltip, and a left click does not toggle the bubble; use the
menu instead.
## Install ## Install
### Windows Download from the
[latest release](https://github.com/tiennm99/usage-monitor/releases/latest).
Grab `usage-monitor.exe` from the - **Windows:** `usage-monitor.exe`. Put it in a folder you can write to,
[Releases page](https://github.com/tiennm99/usage-monitor/releases/latest). such as `%LOCALAPPDATA%\UsageMonitor\`, and run it. The exe is unsigned:
Put it in a folder of its own that you can write to (e.g. when SmartScreen warns, click **More info** → **Run anyway**.
`%LOCALAPPDATA%\UsageMonitor\`) and run it. The app is portable: it keeps - **Linux AppImage:** `usage-monitor_X.Y.Z_amd64.AppImage`. Put it in a
`settings.json` next to the exe, so moving the folder keeps your settings. It folder you can write to, run `chmod +x` on it, and run it.
self-updates from the same Releases feed. - **Linux .deb:** `sudo apt install ./usage-monitor_X.Y.Z_amd64.deb`.
**First-run note:** the binary is unsigned, so SmartScreen will show The exe and the AppImage are portable: they keep `settings.json` next to
"Windows protected your PC". Click **More info** → **Run anyway**. Code themselves.
signing is on the roadmap.
### Linux x86_64 ## Use
From the same - **Left-click** the bubble for the details panel.
[Releases page](https://github.com/tiennm99/usage-monitor/releases/latest), - **Right-click** the bubble or the tray icon for the menu:
pick one: - **Refresh**
- **Update frequency:** how often usage is fetched.
- **Providers:** which bubbles to show.
- **Settings:** start at login, **Reset position**, **Check for updates**
and the **Auto-update check** interval.
- **Controls:** resize.
- **Show widget**
- **Click-through**
- **Restart** and **Exit**
- **Tray icon:** a left click shows or hides the bubbles. On Linux any click
opens the menu, and there is no tooltip.
- **With click-through on**, the bubbles ignore the mouse. Turn it off from
the tray menu.
- `usage-monitor_X.Y.Z_amd64.AppImage`: make it executable Timers follow your local clock. Every 5 minutes means :00, :05, :10 and so
(`chmod +x`) and run it. Like the Windows exe it is portable and keeps on; hourly means the top of the hour, daily means midnight, and weekly means
`settings.json` next to the AppImage file, so give it a folder you can Monday at midnight.
write to. The AppImage self-updates in place. 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.
- `usage-monitor_X.Y.Z_amd64.deb`: install it with
`sudo apt install ./usage-monitor_X.Y.Z_amd64.deb`. The app
still checks for updates, but installing one is left to you: download the
new `.deb` and install it the same way.
### Build from source ### Updates
Prerequisites: the [Rust toolchain](https://www.rust-lang.org/tools/install) The app checks GitHub Releases on the **Auto-update check** schedule
and [Node.js](https://nodejs.org/) with npm. The Tauri CLI is a local dev (hourly by default). At launch it checks only if a scheduled time has passed
dependency, so `npm ci` installs it. since the last check. A failed check is retried after 1, 2, 4… minutes, but
never later than the next scheduled time.
On Windows: When a check finds a new release, the exe and the AppImage install it and
restart on their own. The `.deb` only tells you; install the new `.deb` the
same way you installed the first.
```powershell ## Files and diagnostics
git clone https://github.com/tiennm99/usage-monitor
cd usage-monitor
npm ci
npm run tauri build -- --no-bundle
```
The binary lands at `target\release\usage-monitor.exe`. Run with `--diagnose` to write a log, which is cleared on each start.
On Linux x86_64, install the build dependencies first (Debian/Ubuntu): | What | Windows | Linux |
|------|---------|-------|
| Log (`--diagnose`) | `%TEMP%\usage-monitor.log` | `~/.local/state/usage-monitor/usage-monitor.log` |
| Settings | `settings.json` next to the exe | next to the AppImage; for the `.deb`, `~/.config/UsageMonitor/settings.json` |
| Update downloads, `update-error.log` | `%LOCALAPPDATA%\UsageMonitor\updates\` | `~/.local/share/UsageMonitor/updates/` |
| Web view cache | `%LOCALAPPDATA%\io.github.tiennm99.usagemonitor\` | `~/.local/share/io.github.tiennm99.usagemonitor/` |
On Linux the `XDG_*` variables move these folders when they are set. On
Windows the app also writes `%LOCALAPPDATA%\UsageMonitor\icon.png` and an
`HKCU\Software\Classes\AppUserModelId` entry, which notifications from a
portable exe need.
## Privacy
- **Reads:** your Claude Code credentials (`~/.claude/.credentials.json`)
and, with Codex on, your Codex credentials (`$CODEX_HOME/auth.json` or
`~/.codex/auth.json`).
- **Sends:** usage requests to Anthropic and, with Codex on, to ChatGPT,
plus update checks to GitHub Releases. Nothing else: no analytics, no
backend, and no credentials sent to any third party.
- **Runs:** when a sign-in has expired, `claude -p .` or `codex exec .`
runs hidden in the temp folder, so the CLI refreshes its token. Codex runs
with `--skip-git-repo-check` and a read-only sandbox. Each run sends one
tiny prompt, which counts toward your usage.
- **Stores:** bubble positions and size, the menu choices, and the time of
the last update check.
## Build from source
You need [Rust](https://www.rust-lang.org/tools/install) and
[Node.js](https://nodejs.org/). On Debian/Ubuntu, install the build
dependencies first:
```bash ```bash
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \ sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
```
Then:
```bash
git clone https://github.com/tiennm99/usage-monitor git clone https://github.com/tiennm99/usage-monitor
cd usage-monitor cd usage-monitor
npm ci npm ci
npm run tauri build -- --no-bundle npm run tauri build -- --no-bundle
``` ```
The binary lands at `target/release/usage-monitor`. To build the The binary lands in `target/release/`. For the `.deb` and AppImage, install
`.deb` and AppImage instead, also install `patchelf` (the AppImage needs it) `patchelf` and run `npm run tauri build -- --bundles deb,appimage` instead.
and run `npm run tauri build -- --bundles deb,appimage`; they land under Local builds are unsigned, so they can't be published as updates; see
`target/release/bundle/`.
Local builds carry no updater signature (`createUpdaterArtifacts` is off in
`tauri.conf.json`), so they cannot be published as an update. Only the
release workflow signs builds; see
[docs/release-process.md](docs/release-process.md). [docs/release-process.md](docs/release-process.md).
## Use ## Acknowledgements
Run the app. The bubble appears near the bottom-right corner of your Inspired by
primary monitor on first launch. Drag it where you want it, release to snap [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor),
to the nearest edge if you let go close to one. a taskbar widget for the same problem, and by the floating "boost ball" of
360 Security and IObit Advanced SystemCare. This is a clean-room
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns) implementation against the same public APIs.
- **Right-click** for **Refresh**, **Update frequency**, **Providers**,
**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)
- **Drag** anywhere — it floats on top of all other windows
- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
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
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
When multiple providers are shown, each gets its own bubble that you can position
independently.
### Changes from the v0.5.0 Windows build
The app moved from a hand-written Win32 UI to Tauri. Most behaviour is the
same; these differences are visible:
- The transparent corners around the round bubble no longer pass clicks
through to the window below; they just ignore clicks.
- Alerts are Windows toast notifications instead of tray balloons, so they
no longer use a warning or info icon.
- Proxy auto-configuration (PAC scripts and WPAD) is no longer used. A
static system proxy, its bypass list and the `HTTPS_PROXY`-style
environment variables still are, including a SOCKS `ALL_PROXY` such as
`socks5h://127.0.0.1:7890`, for usage requests and updates alike.
- Only one copy runs per signed-in user session, instead of one per machine.
## Diagnostics
```text
usage-monitor.exe --diagnose # Windows
usage-monitor --diagnose # Linux .deb (pass the same flag to the AppImage)
```
This writes a log file, truncated on each start, to:
| OS | Log file |
|----|----------|
| Windows | `%TEMP%\usage-monitor.log` |
| Linux | `$XDG_STATE_HOME/usage-monitor/usage-monitor.log`, usually `~/.local/state/usage-monitor/usage-monitor.log` (readable by your user only) |
Settings are saved to:
| OS | Settings file |
|----|---------------|
| Windows | `settings.json` next to `usage-monitor.exe` |
| Linux AppImage | `settings.json` next to the `.AppImage` file |
| Linux `.deb` | `$XDG_CONFIG_HOME/UsageMonitor/settings.json`, usually `~/.config/UsageMonitor/settings.json` |
Other files the app writes:
| What | Windows | Linux |
|------|---------|-------|
| Update downloads and `update-error.log` | `%LOCALAPPDATA%\UsageMonitor\updates\` | `~/.local/share/UsageMonitor/updates/` |
| Web view profile (cache, storage) | `%LOCALAPPDATA%\io.github.tiennm99.usagemonitor\` (WebView2) | `~/.local/share/io.github.tiennm99.usagemonitor/` (WebKitGTK) |
On Windows the app also writes its notification icon to
`%LOCALAPPDATA%\UsageMonitor\icon.png` and registers it under
`HKCU\Software\Classes\AppUserModelId\io.github.tiennm99.usagemonitor`,
which Windows needs to show toasts from a portable exe.
## Privacy and security
What the app reads:
- Your local Claude Code OAuth credentials from `~/.claude/.credentials.json`
- If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json`
or `~/.codex/auth.json`
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
**Disabled** in the right-click menu to stop them; **Check for updates**
still works when you click it.
What the app runs:
- When a usage request is rejected because the sign-in has expired, the app
runs `claude -p .` for Claude Code and `codex exec .` for Codex, for each
provider that was rejected. They run hidden in the background, so the CLI
refreshes its token. Each run sends one tiny prompt, which counts toward
your usage. The runs use the system temporary folder as their working
folder, and Codex runs with `--skip-git-repo-check` and a read-only
sandbox.
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
- Displayed provider preferences
What it does **not** do: send credentials to any third-party server, run a
backend service, collect analytics, upload your project files, or write to
your Codex `auth.json` directly.
## License ## License
Apache License 2.0 — see [LICENSE](LICENSE). Apache License 2.0. See [LICENSE](LICENSE).
+101 -7
View File
@@ -15,9 +15,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError}; use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration; use std::time::{Duration, Instant};
#[cfg(not(windows))]
use std::time::Instant;
use tauri::{ use tauri::{
AppHandle, Manager, Monitor, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, AppHandle, Manager, Monitor, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
@@ -351,6 +349,8 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
set_position(&window, placement.pos); set_position(&window, placement.pos);
} }
set_size(&window, placement.size); set_size(&window, placement.size);
// Windows applies it on the main thread, in `prepare_native`.
#[cfg(not(windows))]
if click_through { if click_through {
set_ignore_cursor(&window, true); set_ignore_cursor(&window, true);
} }
@@ -363,7 +363,7 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
placement.dpi placement.dpi
); );
prepare_native(app, &window, provider); prepare_native(app, &window, provider, click_through);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if let Some(is_dark) = platform::linux::portal_prefers_dark() { if let Some(is_dark) = platform::linux::portal_prefers_dark() {
runtime.set_dark(is_dark); runtime.set_dark(is_dark);
@@ -399,13 +399,22 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
/// move-end, setting-change and display-change messages. Both must run on /// move-end, setting-change and display-change messages. Both must run on
/// the window's own (main) thread before the first show. /// the window's own (main) thread before the first show.
#[cfg(windows)] #[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 window = window.clone();
let hook_app = app.clone(); let hook_app = app.clone();
let queued = app.run_on_main_thread(move || { let queued = app.run_on_main_thread(move || {
if let Err(err) = platform::windows::apply_no_activate(&window) { if let Err(err) = platform::windows::apply_no_activate(&window) {
log::warn!("{} may take focus: {err}", window.label()); 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| { let hooked = platform::windows::install_bubble_hook(&window, move |event| {
on_hook_event(&hook_app, provider, event); on_hook_event(&hook_app, provider, event);
}); });
@@ -419,7 +428,13 @@ fn prepare_native(app: &AppHandle, window: &WebviewWindow, provider: ProviderId)
} }
#[cfg(not(windows))] #[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)] #[cfg(windows)]
fn on_hook_event(app: &AppHandle, provider: ProviderId, event: platform::windows::BubbleHookEvent) { fn on_hook_event(app: &AppHandle, provider: ProviderId, event: platform::windows::BubbleHookEvent) {
@@ -514,6 +529,75 @@ pub fn bring_back(app: &AppHandle) {
} }
} }
/// Put back on top every shown bubble that another app's always-on-top
/// window, such as a chat toast, now covers. Cheap enough to run on every
/// fullscreen check; bubbles hidden for a fullscreen app are left alone.
#[cfg(windows)]
pub fn keep_on_top(app: &AppHandle) {
let handle = app.clone();
let queued = app.run_on_main_thread(move || 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) { fn raise(window: &WebviewWindow) {
#[cfg(windows)] #[cfg(windows)]
let result = platform::windows::raise_topmost(window).map_err(|e| e.to_string()); let result = platform::windows::raise_topmost(window).map_err(|e| e.to_string());
@@ -541,8 +625,18 @@ pub fn set_click_through(app: &AppHandle, ignore: bool) {
} }
} }
/// On Windows the style bits are set directly: tao's
/// `set_ignore_cursor_events` re-applies all its window flags, and since
/// bubbles are shown through [`show`], not tao, it believes them hidden and
/// hides them, also dropping their no-activate and tool-window styles.
fn set_ignore_cursor(window: &WebviewWindow, ignore: bool) { fn set_ignore_cursor(window: &WebviewWindow, ignore: bool) {
if let Err(err) = window.set_ignore_cursor_events(ignore) { #[cfg(windows)]
let result = platform::windows::set_click_through(window, ignore).map_err(|e| e.to_string());
#[cfg(not(windows))]
let result = window
.set_ignore_cursor_events(ignore)
.map_err(|e| e.to_string());
if let Err(err) = result {
log::warn!("{} click-through not set: {err}", window.label()); log::warn!("{} click-through not set: {err}", window.label());
} }
} }
+110 -22
View File
@@ -8,7 +8,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{Duration, Instant, SystemTime}; use std::time::{Duration, Instant, SystemTime};
use jiff::tz::TimeZone;
use jiff::{Timestamp, ToSpan, Zoned};
use crate::core::i18n::{self, LocaleStrings}; use crate::core::i18n::{self, LocaleStrings};
use crate::core::settings;
use crate::usage::{ProviderId, UsageWindows, Window}; use crate::usage::{ProviderId, UsageWindows, Window};
/// Minimum gap between two cooldown-gated alerts (threshold and session /// Minimum gap between two cooldown-gated alerts (threshold and session
@@ -291,27 +295,78 @@ pub enum UpdateCheckDue {
} }
/// Schedule the automatic update check from the last check time (unix /// Schedule the automatic update check from the last check time (unix
/// seconds). `None` when auto-check is disabled. Due when never checked or /// seconds). `None` when auto-check is disabled. Checks fall on local
/// when `now - last >= interval`; otherwise after the remainder. /// 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( pub fn update_check_due(
last_check_unix: Option<u64>, last_check_unix: Option<u64>,
interval_secs: Option<u64>, interval_secs: Option<u64>,
now_unix: u64, now_unix: u64,
tz: &TimeZone,
) -> Option<UpdateCheckDue> { ) -> Option<UpdateCheckDue> {
let interval = interval_secs?; let interval = interval_secs?;
let Some(last) = last_check_unix else { let (start, next) = clock_slot(interval, now_unix, tz);
return Some(UpdateCheckDue::Now); // A last check after `now` (a clock set back) counts as this slot's.
}; match last_check_unix {
let elapsed = now_unix.saturating_sub(last); Some(last) if last >= start => Some(UpdateCheckDue::After(Duration::from_secs(
if elapsed >= interval { next.saturating_sub(now_unix),
Some(UpdateCheckDue::Now) ))),
} else { _ => Some(UpdateCheckDue::Now),
Some(UpdateCheckDue::After(Duration::from_secs(
interval - elapsed,
)))
} }
} }
/// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -620,28 +675,61 @@ mod tests {
#[test] #[test]
fn update_schedule_due_and_remaining() { fn update_schedule_due_and_remaining() {
let hour = 3_600; let utc = TimeZone::UTC;
assert_eq!(update_check_due(None, None, 10_000), None); let hour = settings::UPDATE_CHECK_HOURLY_SECS;
assert_eq!(update_check_due(Some(9_000), None, 10_000), None); // 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!( assert_eq!(
update_check_due(None, Some(hour), 10_000), update_check_due(None, Some(hour), 10_000, &utc),
Some(UpdateCheckDue::Now) Some(UpdateCheckDue::Now)
); );
assert_eq!( 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) Some(UpdateCheckDue::Now)
); );
assert_eq!( assert_eq!(
update_check_due(Some(10_000 - 600), Some(hour), 10_000), update_check_due(Some(7_200), Some(hour), 10_000, &utc),
Some(UpdateCheckDue::After(Duration::from_secs(3_000))) 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!( assert_eq!(
update_check_due(Some(20_000), Some(hour), 10_000), update_check_due(Some(20_000), Some(hour), 10_000, &utc),
Some(UpdateCheckDue::After(Duration::from_secs(hour))) 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] #[test]
fn countdown_delay_takes_the_nearest_change_with_a_floor() { fn countdown_delay_takes_the_nearest_change_with_a_floor() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
+6 -1
View File
@@ -4,7 +4,10 @@
//! unless the user hid them in the meantime. //! unless the user hid them in the meantime.
//! //!
//! The same thread brings the bubbles back every minute while they should //! The same thread brings the bubbles back every minute while they should
//! show, in case another window covered or hid them. //! show, in case another window covered or hid them. On Windows it also
//! re-raises, on every check, a bubble that another app's always-on-top
//! window (a chat toast, say) covers, as a backstop for the z-order watch
//! in `bubble.rs`.
use std::io; use std::io;
use std::sync::Arc; use std::sync::Arc;
@@ -28,6 +31,8 @@ pub fn spawn(runtime: Arc<Runtime>) -> io::Result<()> {
let mut last_bring_back = Instant::now(); let mut last_bring_back = Instant::now();
while !runtime.is_exiting() { while !runtime.is_exiting() {
tick(&runtime); tick(&runtime);
#[cfg(windows)]
runtime.keep_bubbles_on_top();
if last_bring_back.elapsed() >= BRING_BACK_INTERVAL { if last_bring_back.elapsed() >= BRING_BACK_INTERVAL {
last_bring_back = Instant::now(); last_bring_back = Instant::now();
runtime.bring_back_bubbles(); runtime.bring_back_bubbles();
+133 -9
View File
@@ -1,5 +1,6 @@
//! Windows-only helpers. This is the only module that talks to Win32 APIs. //! Windows-only helpers. This is the only module that talks to Win32 APIs.
use std::cell::RefCell;
use std::io; use std::io;
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
@@ -20,18 +21,22 @@ use windows::Win32::Graphics::Gdi::{
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST, GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
}; };
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}; use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
use windows::Win32::UI::Accessibility::{SetWinEventHook, HWINEVENTHOOK};
use windows::Win32::UI::Shell::{ use windows::Win32::UI::Shell::{
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage, DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage,
SetCurrentProcessExplicitAppUserModelID, SetWindowSubclass, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, SetCurrentProcessExplicitAppUserModelID, SetWindowSubclass, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT,
ABE_TOP, ABM_GETTASKBARPOS, APPBARDATA, ABE_TOP, ABM_GETTASKBARPOS, APPBARDATA,
}; };
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{
GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId, GetClassNameW, GetForegroundWindow, GetWindow, GetWindowLongPtrW, GetWindowRect,
IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos, GetWindowThreadProcessId, IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow,
ShowWindow, GWL_EXSTYLE, GWL_STYLE, HWND_TOPMOST, MA_NOACTIVATE, MB_ICONERROR, MB_OK, SetWindowLongPtrW, SetWindowPos, ShowWindow, CHILDID_SELF, EVENT_OBJECT_REORDER,
MB_SETFOREGROUND, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, EVENT_OBJECT_SHOW, GWL_EXSTYLE, GWL_STYLE, GW_HWNDPREV, HWND_TOPMOST, MA_NOACTIVATE,
SW_HIDE, SW_SHOWNOACTIVATE, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, OBJID_WINDOW, SWP_FRAMECHANGED, SWP_NOACTIVATE,
WM_SETTINGCHANGE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, 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}; 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) 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. /// OR `bits` into a window's extended style and apply the frame change.
fn add_ex_style(window: &tauri::WebviewWindow, bits: u32) -> io::Result<()> { 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 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. // SAFETY: `hwnd` is a live top-level window of this process.
let current = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) }; 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(()); return Ok(());
} }
// SAFETY: as above. A zero return is only an error when the last error // SAFETY: as above. A zero return is only an error when the last error
// is set, so it is cleared first. // is set, so it is cleared first.
unsafe { SetLastError(WIN32_ERROR(0)) }; 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 { if previous == 0 {
let err = io::Error::last_os_error(); let err = io::Error::last_os_error();
if err.raw_os_error().unwrap_or(0) != 0 { if err.raw_os_error().unwrap_or(0) != 0 {
@@ -313,6 +333,110 @@ pub fn raise_topmost(window: &tauri::WebviewWindow) -> io::Result<()> {
.map_err(io::Error::from) .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`]). /// Hide a bubble (see [`show_no_activate`]).
pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> { pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> {
let hwnd = hwnd_of(window)?; let hwnd = hwnd_of(window)?;
+17 -2
View File
@@ -512,6 +512,12 @@ impl Runtime {
bubble::bring_back(&self.ui.app); 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 /// Apply a visibility event to every bubble. A user show or hide is
/// also saved as `widget_visible`. /// also saved as `widget_visible`.
pub fn apply_visibility(&self, event: visibility::Event) { pub fn apply_visibility(&self, event: visibility::Event) {
@@ -651,8 +657,9 @@ impl Runtime {
/// Blocking body of [`Self::check_for_updates`]. Only a check that got /// 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 /// an answer is stamped as the last check; a failed one leaves the
/// stamp alone and has the scheduler retry sooner. /// stamp alone and has the scheduler retry sooner. A release found where
fn run_update_check(&self) { /// 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 proxy = update::proxy_url(net::env_proxy().as_ref(), self.http.system_proxy());
let outcome = update::check(&self.ui.app, proxy); let outcome = update::check(&self.ui.app, proxy);
let succeeded = outcome.is_ok(); let succeeded = outcome.is_ok();
@@ -694,6 +701,13 @@ impl Runtime {
succeeded, succeeded,
}); });
self.refresh_shell(false); 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 /// Download and install the release the last check found on a worker
@@ -1020,6 +1034,7 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
poll_interval: Duration::from_millis(u64::from(state.settings.poll_interval_ms)), poll_interval: Duration::from_millis(u64::from(state.settings.poll_interval_ms)),
update_interval_secs: state.settings.update_check_interval_secs, update_interval_secs: state.settings.update_check_interval_secs,
last_update_check_unix: state.settings.last_update_check_unix, last_update_check_unix: state.settings.last_update_check_unix,
tz: jiff::tz::TimeZone::system(),
}; };
let providers = enabled_providers(&state.settings); let providers = enabled_providers(&state.settings);
+224 -178
View File
@@ -7,17 +7,20 @@
//! each due [`Action`] to its [`SchedulerHost`]. A host must return quickly: //! each due [`Action`] to its [`SchedulerHost`]. A host must return quickly:
//! slow work (HTTP, the CLI refresh) runs on worker threads it spawns. //! slow work (HTTP, the CLI refresh) runs on worker threads it spawns.
//! //!
//! `Instant` stops while the machine sleeps, so the update check, which is //! The poll and the update check run on local clock boundaries (see
//! due at a wall-clock time, is kept as a unix timestamp, and the thread //! [`clock_slot`]): a 5-minute poll at :00, :05, :10 and so on, a daily
//! wakes at least every [`MAX_WAIT`] to notice a resume (see //! update check at midnight. `Instant` stops while the machine sleeps, so
//! [`resumed_from_sleep`]). //! 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::io;
use std::sync::mpsc::{Receiver, RecvTimeoutError}; use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::thread::JoinHandle; use std::thread::JoinHandle;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; 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 /// Shortest poll interval, guarding against a hand-edited `0` in the
/// settings file turning into a busy loop. /// settings file turning into a busy loop.
@@ -42,7 +45,8 @@ const RESUME_THRESHOLD: Duration = Duration::from_secs(30);
pub enum Message { pub enum Message {
/// Poll at once. The periodic poll deadline is left as it is. /// Poll at once. The periodic poll deadline is left as it is.
PollNow, 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), SetPollInterval(Duration),
/// Re-render the countdowns after this delay; `None` clears the deadline. /// Re-render the countdowns after this delay; `None` clears the deadline.
RecomputeCountdown(Option<Duration>), RecomputeCountdown(Option<Duration>),
@@ -53,9 +57,9 @@ pub enum Message {
interval_secs: Option<u64>, interval_secs: Option<u64>,
last_check_unix: Option<u64>, last_check_unix: Option<u64>,
}, },
/// An update check finished. Only this re-arms the check: after the /// An update check finished. Only this re-arms the check: at the next
/// full interval when it succeeded, after a growing retry delay capped /// boundary when it succeeded, after a growing retry delay capped at
/// at the interval when it failed. /// that boundary when it failed.
UpdateCheckDone { UpdateCheckDone {
interval_secs: Option<u64>, interval_secs: Option<u64>,
succeeded: bool, succeeded: bool,
@@ -77,17 +81,21 @@ pub trait SchedulerHost {
} }
/// Settings the deadlines start from. /// Settings the deadlines start from.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Debug)]
pub struct Config { pub struct Config {
pub poll_interval: Duration, pub poll_interval: Duration,
pub update_interval_secs: Option<u64>, pub update_interval_secs: Option<u64>,
pub last_update_check_unix: 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)] #[derive(Debug)]
pub struct Deadlines { pub struct Deadlines {
tz: TimeZone,
poll_interval: Duration, poll_interval: Duration,
poll_at: Instant, /// Unix seconds the next poll is due at.
poll_at_unix: u64,
countdown_at: Option<Instant>, countdown_at: Option<Instant>,
/// Unix seconds the next update check is due at. /// Unix seconds the next update check is due at.
update_at_unix: Option<u64>, update_at_unix: Option<u64>,
@@ -98,15 +106,16 @@ pub struct Deadlines {
impl Deadlines { impl Deadlines {
/// Arm the deadlines at startup. Returns the actions due at once: the /// Arm the deadlines at startup. Returns the actions due at once: the
/// update check when the last one is older than its interval. /// update check when the last one is older than its interval.
pub fn new(config: Config, now: Instant, now_unix: u64) -> (Self, Vec<Action>) { pub fn new(config: Config, now_unix: u64) -> (Self, Vec<Action>) {
let poll_interval = config.poll_interval.max(MIN_POLL_INTERVAL);
let mut deadlines = Self { let mut deadlines = Self {
poll_interval, tz: config.tz,
poll_at: now + poll_interval, poll_interval: config.poll_interval.max(MIN_POLL_INTERVAL),
poll_at_unix: 0,
countdown_at: None, countdown_at: None,
update_at_unix: None, update_at_unix: None,
update_failures: 0, update_failures: 0,
}; };
deadlines.poll_at_unix = deadlines.next_poll(now_unix);
let actions = deadlines.arm_update( let actions = deadlines.arm_update(
config.update_interval_secs, config.update_interval_secs,
config.last_update_check_unix, config.last_update_check_unix,
@@ -121,7 +130,7 @@ impl Deadlines {
Message::PollNow => vec![Action::Poll], Message::PollNow => vec![Action::Poll],
Message::SetPollInterval(interval) => { Message::SetPollInterval(interval) => {
self.poll_interval = interval.max(MIN_POLL_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() Vec::new()
} }
Message::RecomputeCountdown(delay) => { Message::RecomputeCountdown(delay) => {
@@ -136,29 +145,31 @@ impl Deadlines {
interval_secs, interval_secs,
succeeded, succeeded,
} => { } => {
let delay = if succeeded { self.update_at_unix = interval_secs.map(|secs| {
self.update_failures = 0; let next = self.next_update(secs, now_unix);
interval_secs.map(update_interval) if succeeded {
} else { self.update_failures = 0;
let delay = interval_secs.map(|secs| self.retry_delay(secs)); next
self.update_failures = self.update_failures.saturating_add(1); } else {
delay let retry = now_unix.saturating_add(self.retry_delay().as_secs());
}; self.update_failures = self.update_failures.saturating_add(1);
self.update_at_unix = delay.map(|d| now_unix.saturating_add(d.as_secs())); retry.min(next)
}
});
Vec::new() Vec::new()
} }
Message::Shutdown => Vec::new(), Message::Shutdown => Vec::new(),
} }
} }
/// Fire every deadline that has passed. The poll re-arms for a full /// Fire every deadline that has passed. The poll re-arms at its next
/// interval; the countdown and update check stay disarmed until the /// boundary; the countdown and update check stay disarmed until the
/// host re-arms them (after the re-render, after the check finished). /// 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> { pub fn take_due(&mut self, now: Instant, now_unix: u64) -> Vec<Action> {
let mut due = Vec::new(); let mut due = Vec::new();
if self.poll_at <= now { if self.poll_at_unix <= now_unix {
due.push(Action::Poll); 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) { if self.countdown_at.is_some_and(|at| at <= now) {
due.push(Action::Countdown); due.push(Action::Countdown);
@@ -171,25 +182,23 @@ impl Deadlines {
due due
} }
/// The machine woke from sleep: poll at once and re-arm the poll for a /// The machine woke from sleep: poll at once and re-arm the poll at its
/// full interval from now. The update check needs nothing here, since /// next boundary. The update check needs nothing here, since
/// [`Self::take_due`] compares its wall-clock deadline with `now_unix`. /// [`Self::take_due`] compares its wall-clock deadline with `now_unix`.
pub fn resume(&mut self, now: Instant) -> Vec<Action> { pub fn resume(&mut self, now_unix: u64) -> Vec<Action> {
self.poll_at = now + self.poll_interval; self.poll_at_unix = self.next_poll(now_unix);
vec![Action::Poll] vec![Action::Poll]
} }
/// Time until the nearest deadline. The poll deadline is always armed, /// Time until the nearest deadline. The poll deadline is always armed,
/// so there always is one. /// so there always is one.
pub fn next_wakeup(&self, now: Instant, now_unix: u64) -> Duration { pub fn next_wakeup(&self, now: Instant, now_unix: u64) -> Duration {
let update = self let until = |at: u64| Duration::from_secs(at.saturating_sub(now_unix));
.update_at_unix
.map(|at| 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 self.countdown_at
.map(|at| at.saturating_duration_since(now)), .map(|at| at.saturating_duration_since(now)),
update, self.update_at_unix.map(until),
] ]
.into_iter() .into_iter()
.flatten() .flatten()
@@ -204,7 +213,7 @@ impl Deadlines {
now_unix: u64, now_unix: u64,
) -> Vec<Action> { ) -> Vec<Action> {
let interval_secs = interval_secs.map(|secs| secs.max(MIN_UPDATE_INTERVAL.as_secs())); 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 => { None => {
self.update_at_unix = None; self.update_at_unix = None;
Vec::new() Vec::new()
@@ -220,18 +229,24 @@ impl Deadlines {
} }
} }
/// Delay before the next try after the current run of failures: /// The poll boundary after `now_unix`.
/// `UPDATE_RETRY_BASE * 2^failures`, capped at the check interval. fn next_poll(&self, now_unix: u64) -> u64 {
fn retry_delay(&self, interval_secs: u64) -> Duration { clock_slot(self.poll_interval.as_secs(), now_unix, &self.tz).1
let factor = 1u32.checked_shl(self.update_failures).unwrap_or(u32::MAX);
UPDATE_RETRY_BASE
.saturating_mul(factor)
.min(update_interval(interval_secs))
} }
}
fn update_interval(secs: u64) -> Duration { /// The update-check boundary after `now_unix`.
Duration::from_secs(secs).max(MIN_UPDATE_INTERVAL) 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 /// Whether the wall clock ran ahead of `Instant` by more than
@@ -260,7 +275,7 @@ where
std::thread::Builder::new() std::thread::Builder::new()
.name("scheduler".into()) .name("scheduler".into())
.spawn(move || { .spawn(move || {
let (deadlines, initial) = Deadlines::new(config, Instant::now(), unix_now()); let (deadlines, initial) = Deadlines::new(config, unix_now());
for action in initial { for action in initial {
host.run(action); 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:?}; \ "wall clock moved {wall_elapsed:?} while Instant moved {instant_elapsed:?}; \
treating it as a resume from sleep" treating it as a resume from sleep"
); );
for action in deadlines.resume(now) { for action in deadlines.resume(unix_now()) {
host.run(action); host.run(action);
} }
} }
@@ -308,71 +323,97 @@ mod tests {
use super::*; use super::*;
use std::sync::mpsc::{self, Sender}; use std::sync::mpsc::{self, Sender};
use jiff::tz;
use crate::core::settings::{ use crate::core::settings::{
UPDATE_CHECK_DAILY_SECS, UPDATE_CHECK_HOURLY_SECS, UPDATE_CHECK_WEEKLY_SECS, UPDATE_CHECK_DAILY_SECS, UPDATE_CHECK_HOURLY_SECS, UPDATE_CHECK_WEEKLY_SECS,
}; };
const MIN: Duration = Duration::from_secs(60); 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 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 { fn config(update: Option<u64>, last: Option<u64>) -> Config {
Config { Config {
poll_interval: 5 * MIN, poll_interval: 5 * MIN,
update_interval_secs: update, update_interval_secs: update,
last_update_check_unix: last, last_update_check_unix: last,
tz: TimeZone::UTC,
} }
} }
#[test] #[test]
fn poll_fires_every_interval() { fn poll_fires_on_interval_boundaries() {
let t0 = Instant::now(); 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!(initial.is_empty());
assert_eq!(d.next_wakeup(t0, NOW_UNIX), 5 * MIN); assert_eq!(d.next_wakeup(t0, start), Duration::from_secs(2 * M + 50));
assert!(d.take_due(t0 + 4 * MIN, NOW_UNIX).is_empty()); assert!(d.take_due(t0, NOW_UNIX + 5 * M - 1).is_empty());
assert_eq!(d.take_due(t0 + 5 * MIN, NOW_UNIX), [Action::Poll]); assert_eq!(d.take_due(t0, NOW_UNIX + 5 * M), [Action::Poll]);
assert_eq!(d.next_wakeup(t0 + 5 * MIN, NOW_UNIX), 5 * MIN); assert_eq!(d.next_wakeup(t0, NOW_UNIX + 5 * M), 5 * MIN);
assert_eq!(d.take_due(t0 + 10 * MIN, NOW_UNIX), [Action::Poll]); // 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] #[test]
fn poll_now_polls_without_moving_the_periodic_deadline() { fn poll_now_polls_without_moving_the_periodic_deadline() {
let t0 = Instant::now(); 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!( assert_eq!(
d.handle(Message::PollNow, t0 + 2 * MIN, NOW_UNIX), d.handle(Message::PollNow, t0, NOW_UNIX + 2 * M),
[Action::Poll] [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] #[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 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 t1 = t0 + 4 * MIN; let t1 = NOW_UNIX + 4 * M;
assert!(d assert!(d
.handle(Message::SetPollInterval(MIN * 15), t1, NOW_UNIX) .handle(Message::SetPollInterval(MIN * 15), t0, t1)
.is_empty()); .is_empty());
assert!(d.take_due(t0 + 5 * MIN, NOW_UNIX).is_empty()); assert!(d.take_due(t0, NOW_UNIX + 5 * M).is_empty());
assert_eq!(d.next_wakeup(t1, NOW_UNIX), 15 * MIN); assert_eq!(d.next_wakeup(t0, t1), 11 * MIN);
assert_eq!(d.take_due(t1 + 15 * MIN, NOW_UNIX), [Action::Poll]); assert_eq!(d.take_due(t0, NOW_UNIX + 15 * M), [Action::Poll]);
} }
#[test] #[test]
fn a_zero_poll_interval_is_floored() { fn a_zero_poll_interval_is_floored() {
let t0 = Instant::now();
let mut cfg = config(None, None); let mut cfg = config(None, None);
cfg.poll_interval = Duration::ZERO; cfg.poll_interval = Duration::ZERO;
let (d, _) = Deadlines::new(cfg, t0, NOW_UNIX); let (d, _) = Deadlines::new(cfg, NOW_UNIX);
assert_eq!(d.next_wakeup(t0, NOW_UNIX), MIN_POLL_INTERVAL); assert_eq!(d.next_wakeup(Instant::now(), NOW_UNIX), MIN_POLL_INTERVAL);
} }
#[test] #[test]
fn countdown_fires_once_until_rearmed() { fn countdown_fires_once_until_rearmed() {
let t0 = Instant::now(); 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); let delay = Duration::from_secs(31);
d.handle(Message::RecomputeCountdown(Some(delay)), t0, NOW_UNIX); d.handle(Message::RecomputeCountdown(Some(delay)), t0, NOW_UNIX);
assert_eq!(d.next_wakeup(t0, NOW_UNIX), delay); 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()); assert!(d.take_due(t0 + 2 * delay, NOW_UNIX).is_empty());
} }
#[test] /// Whether a check is due at `now_unix`.
fn startup_checks_for_updates_when_due() { fn check_due(d: &mut Deadlines, now_unix: u64) -> bool {
let t0 = Instant::now(); d.take_due(Instant::now(), now_unix)
let hourly = Some(UPDATE_CHECK_HOURLY_SECS); .contains(&Action::CheckForUpdates)
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)
} }
fn done(interval_secs: Option<u64>, succeeded: bool) -> Message { fn done(interval_secs: Option<u64>, succeeded: bool) -> Message {
@@ -430,76 +439,118 @@ mod tests {
} }
#[test] #[test]
fn only_a_finished_check_rearms_the_full_interval() { fn startup_checks_only_when_a_boundary_passed_since_the_last_check() {
let t0 = Instant::now(); let hourly = Some(UPDATE_CHECK_HOURLY_SECS);
let hour = UPDATE_CHECK_HOURLY_SECS; let (_, never_checked) = Deadlines::new(config(hourly, None), NOW_UNIX + M);
let (mut d, initial) = Deadlines::new(config(Some(hour), None), t0, NOW_UNIX); assert_eq!(never_checked, [Action::CheckForUpdates]);
assert_eq!(initial, [Action::CheckForUpdates]); // Checked at 07:59:59, started at 08:01: 08:00 passed in between.
// While the check runs nothing else is scheduled for it. let (_, passed) = Deadlines::new(config(hourly, Some(NOW_UNIX - 1)), NOW_UNIX + M);
assert!(!check_due(&mut d, t0, NOW_UNIX + 3 * hour)); assert_eq!(passed, [Action::CheckForUpdates]);
// Checked at 08:00:30, started at 08:59: no boundary yet, so it
let t1 = NOW_UNIX + 3 * hour; // waits for 09:00.
d.handle(done(Some(hour), true), t0, t1); let (mut d, waiting) =
assert!(!check_due(&mut d, t0, t1 + hour / 2)); Deadlines::new(config(hourly, Some(NOW_UNIX + 30)), NOW_UNIX + 59 * M);
assert!(check_due(&mut d, t0, t1 + hour)); assert!(waiting.is_empty());
// It repeats every interval, not just once. assert!(!check_due(&mut d, NOW_UNIX + HOUR - 1));
let t2 = t1 + hour; assert!(check_due(&mut d, NOW_UNIX + HOUR));
d.handle(done(Some(hour), true), t0, t2); let (_, disabled) = Deadlines::new(config(None, None), NOW_UNIX);
assert!(check_due(&mut d, t0, t2 + hour)); assert!(disabled.is_empty());
} }
#[test] #[test]
fn a_failed_check_retries_with_a_doubling_delay_capped_at_the_interval() { fn daily_and_weekly_checks_fall_on_local_midnight() {
let t0 = Instant::now(); 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 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; let mut now = NOW_UNIX;
for expected in [60, 120, 240, 480, 960, 1_920, hour, hour] { for expected in [60, 120, 240, 480, 960, 1_920, 3_840] {
d.handle(done(Some(hour), false), t0, now); d.handle(done(Some(day), false), Instant::now(), now);
assert!(!check_due(&mut d, t0, now + expected - 1)); assert!(!check_due(&mut d, now + expected - 1));
assert!(check_due(&mut d, t0, now + expected)); assert!(check_due(&mut d, now + expected));
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. // A success resets the delay: the next failure retries after a minute.
d.handle(done(Some(hour), true), t0, now); d.handle(done(Some(day), true), Instant::now(), midnight);
assert!(check_due(&mut d, t0, now + hour)); d.handle(done(Some(day), false), Instant::now(), midnight + HOUR);
now += hour; assert!(check_due(&mut d, midnight + HOUR + 60));
d.handle(done(Some(hour), false), t0, now);
assert!(check_due(&mut d, t0, now + 60));
} }
#[test] #[test]
fn a_failed_check_with_auto_check_off_stays_off() { fn a_failed_check_with_auto_check_off_stays_off() {
let t0 = Instant::now(); let (mut d, _) = Deadlines::new(config(None, None), NOW_UNIX);
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX); d.handle(done(None, false), Instant::now(), NOW_UNIX);
d.handle(done(None, false), t0, NOW_UNIX); assert!(!check_due(&mut d, NOW_UNIX + 7 * DAY));
assert!(!check_due(&mut d, t0, NOW_UNIX + 7 * DAY));
} }
#[test] #[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 // `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 t0 = Instant::now();
let day = UPDATE_CHECK_DAILY_SECS; let day = UPDATE_CHECK_DAILY_SECS;
let (mut d, _) = Deadlines::new(config(Some(day), Some(NOW_UNIX)), t0, NOW_UNIX); let (mut d, _) = Deadlines::new(config(Some(day), Some(NOW_UNIX)), NOW_UNIX);
assert!(!check_due(&mut d, t0, NOW_UNIX + day - 1)); let midnight = NOW_UNIX - 8 * HOUR + DAY;
assert!(check_due( assert!(!d
&mut d, .take_due(t0, midnight - 1)
t0 + Duration::from_secs(1), .contains(&Action::CheckForUpdates));
NOW_UNIX + day assert!(d.take_due(t0, midnight).contains(&Action::CheckForUpdates));
));
} }
#[test] #[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 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 t1 = t0 + 2 * MIN; let woke = NOW_UNIX + 7 * HOUR + 2 * M;
assert_eq!(d.resume(t1), [Action::Poll]); assert_eq!(d.resume(woke), [Action::Poll]);
assert!(d.take_due(t0 + 5 * MIN, NOW_UNIX).is_empty()); assert!(d.take_due(t0, woke).is_empty());
assert_eq!(d.next_wakeup(t1, NOW_UNIX), 5 * MIN); assert_eq!(d.next_wakeup(t0, woke), 3 * MIN);
assert_eq!(d.take_due(t1 + 5 * MIN, NOW_UNIX), [Action::Poll]); assert_eq!(d.take_due(t0, woke + 3 * M), [Action::Poll]);
} }
#[test] #[test]
@@ -520,11 +571,10 @@ mod tests {
} }
#[test] #[test]
fn weekly_to_daily_with_the_last_check_two_days_ago_checks_at_once() { fn weekly_to_daily_checks_at_once_when_midnight_passed_since_the_last_check() {
let t0 = Instant::now();
let last = Some(NOW_UNIX - 2 * DAY); let last = Some(NOW_UNIX - 2 * DAY);
let (mut d, initial) = 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!(initial.is_empty());
assert_eq!( assert_eq!(
d.handle( d.handle(
@@ -532,7 +582,7 @@ mod tests {
interval_secs: Some(UPDATE_CHECK_DAILY_SECS), interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
last_check_unix: last, last_check_unix: last,
}, },
t0, Instant::now(),
NOW_UNIX, NOW_UNIX,
), ),
[Action::CheckForUpdates] [Action::CheckForUpdates]
@@ -540,44 +590,40 @@ mod tests {
} }
#[test] #[test]
fn daily_to_weekly_waits_for_the_new_remainder() { fn daily_to_weekly_waits_for_monday() {
let t0 = Instant::now();
let last = Some(NOW_UNIX - 2 * DAY); 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( let actions = d.handle(
Message::SetUpdateInterval { Message::SetUpdateInterval {
interval_secs: Some(UPDATE_CHECK_WEEKLY_SECS), interval_secs: Some(UPDATE_CHECK_WEEKLY_SECS),
last_check_unix: last, last_check_unix: last,
}, },
t0, Instant::now(),
NOW_UNIX, NOW_UNIX,
); );
assert!(actions.is_empty()); assert!(actions.is_empty());
let remaining = 5 * DAY; assert!(!check_due(&mut d, MONDAY + 7 * DAY - 1));
assert!(!check_due(&mut d, t0, NOW_UNIX + remaining - 60)); assert!(check_due(&mut d, MONDAY + 7 * DAY));
assert!(check_due(&mut d, t0, NOW_UNIX + remaining));
} }
#[test] #[test]
fn disabling_auto_check_cancels_the_deadline() { fn disabling_auto_check_cancels_the_deadline() {
let t0 = Instant::now();
let (mut d, _) = Deadlines::new( let (mut d, _) = Deadlines::new(
config(Some(UPDATE_CHECK_HOURLY_SECS), Some(NOW_UNIX - 600)), config(Some(UPDATE_CHECK_HOURLY_SECS), Some(NOW_UNIX + 60)),
t0, NOW_UNIX + 600,
NOW_UNIX,
); );
d.handle( d.handle(
Message::SetUpdateInterval { Message::SetUpdateInterval {
interval_secs: None, interval_secs: None,
last_check_unix: Some(NOW_UNIX - 600), last_check_unix: Some(NOW_UNIX + 60),
}, },
t0, Instant::now(),
NOW_UNIX, 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. // A check that finishes after auto-check was turned off stays off.
d.handle(done(None, true), t0, NOW_UNIX); d.handle(done(None, true), Instant::now(), NOW_UNIX);
assert!(!check_due(&mut d, t0, NOW_UNIX + 14 * DAY)); assert!(!check_due(&mut d, NOW_UNIX + 14 * DAY));
} }
/// Records actions on a channel, standing in for the runtime. /// Records actions on a channel, standing in for the runtime.
+22 -10
View File
@@ -1,21 +1,27 @@
/* Bubble palette. Accent and fill colours come from the payload. */ /* Bubble palette. Accent and fill colours come from the payload. */
:root[data-theme='dark'] { :root[data-theme='dark'] {
/* 60% opaque, so the desktop shows through; text and bars stay solid. */ /* 80% opaque, so the desktop shows through. Tracks and the edge are
--bg: rgb(31 31 31 / 60%); tints over it, so they keep the same contrast step on a light or dark
--track: #2c2c2c; desktop; the text halo keeps text readable on either. */
--time-track: #404040; --bg: rgb(31 31 31 / 80%);
--track: rgb(0 0 0 / 28%);
--time-track: rgb(255 255 255 / 14%);
--time-fill: #b0b0b0; --time-fill: #b0b0b0;
--edge: rgb(255 255 255 / 22%);
--text: #eaeaea; --text: #eaeaea;
--muted: #a8a8a8; --muted: #c4c4c4;
--halo: 0 0 2px rgb(0 0 0 / 75%);
} }
:root[data-theme='light'] { :root[data-theme='light'] {
--bg: rgb(243 243 243 / 60%); --bg: rgb(243 243 243 / 80%);
--track: #e2e2e2; --track: rgb(0 0 0 / 10%);
--time-track: #e0e0e0; --time-track: rgb(0 0 0 / 8%);
--time-fill: #666666; --time-fill: #666666;
--edge: rgb(0 0 0 / 18%);
--text: #1f1f1f; --text: #1f1f1f;
--muted: #5e5e5e; --muted: #4a4a4a;
--halo: 0 0 2px rgb(255 255 255 / 85%);
} }
html, html,
@@ -56,10 +62,15 @@ body {
height: 100%; height: 100%;
} }
.rings circle { .rings circle,
.edge {
fill: none; fill: none;
} }
.edge {
stroke: var(--edge);
}
.ring-track { .ring-track {
stroke: var(--track); stroke: var(--track);
} }
@@ -82,6 +93,7 @@ body {
margin: 0; margin: 0;
white-space: nowrap; white-space: nowrap;
color: var(--text); color: var(--text);
text-shadow: var(--halo);
} }
.head-label { .head-label {
+1
View File
@@ -9,6 +9,7 @@
<body> <body>
<div class="pill" id="pill"> <div class="pill" id="pill">
<svg class="rings" id="rings" aria-hidden="true"> <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-track" id="ring-track"></circle>
<circle class="ring-arc" id="ring-arc"></circle> <circle class="ring-arc" id="ring-arc"></circle>
<circle class="time-ring-track" id="time-ring-track"></circle> <circle class="time-ring-track" id="time-ring-track"></circle>
+7
View File
@@ -59,6 +59,8 @@ const FONT_STACK = '"Segoe UI", system-ui, sans-serif';
/** Weight text is measured at when the caller names none (regular). */ /** Weight text is measured at when the caller names none (regular). */
const MEASURE_WEIGHT = 400; const MEASURE_WEIGHT = 400;
const HEAD_LABEL_WEIGHT = 600; 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-/, ''); const slug = windowLabel.replace(/^bubble-/, '');
@@ -80,6 +82,8 @@ function byId(id) {
const pill = byId('pill'); const pill = byId('pill');
/** @type {SVGSVGElement} */ /** @type {SVGSVGElement} */
const rings = byId('rings'); const rings = byId('rings');
/** @type {SVGPathElement} */
const edge = byId('edge');
/** @type {SVGCircleElement} */ /** @type {SVGCircleElement} */
const ringTrack = byId('ring-track'); const ringTrack = byId('ring-track');
/** @type {SVGCircleElement} */ /** @type {SVGCircleElement} */
@@ -221,6 +225,9 @@ function relayout(p) {
const outline = outlinePath(cssPx(p.canvasW, p.dpi), cssPx(p.canvasH, p.dpi)); const outline = outlinePath(cssPx(p.canvasW, p.dpi), cssPx(p.canvasH, p.dpi));
pill.style.clipPath = `path('${outline}')`; pill.style.clipPath = `path('${outline}')`;
rings.setAttribute('viewBox', `0 0 ${p.canvasW} ${p.canvasH}`); 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(ringTrack, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
placeCircle(ringArc, 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); placeCircle(timeRingTrack, head.ringCx, head.ringCy, head.timeRingRadius, head.timeRingStroke);