mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-23 16:16:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef54cc401c | ||
|
|
0be69275b1 | ||
|
|
17ffc8098d | ||
|
|
1f753361c9 | ||
|
|
30446570c9 | ||
|
|
70e121c3c3 | ||
|
|
8766e656cb | ||
|
|
4b11a31756 | ||
|
|
4f14efe953 | ||
|
|
995173c48f | ||
|
|
e518a96e46 | ||
|
|
d583e173d2 | ||
|
|
194609732e | ||
|
|
400dc4a550 | ||
|
|
d6eb773248 | ||
|
|
a4f8011b26 | ||
|
|
c4ad23236e | ||
|
|
8328b147d7 | ||
|
|
ab81dfd7e2 | ||
|
|
9d479c822b | ||
|
|
37ae0030cb | ||
|
|
0ce64eaa70 | ||
|
|
7939fa89ff | ||
|
|
d1f48dfb2f | ||
|
|
13810a6004 | ||
|
|
100cd59194 | ||
|
|
9a7cb5afc2 | ||
|
|
f7c00893b0 | ||
|
|
e7031011f3 |
No files matched your search
@@ -4,13 +4,23 @@ on:
|
||||
push:
|
||||
branches: [main, feat/tauri-port]
|
||||
pull_request:
|
||||
# The release workflow runs this on the tag before it builds anything.
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref to check out and test (defaults to the triggering commit)'
|
||||
type: string
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A called run takes the caller's workflow name and the ref it was given, so
|
||||
# it never shares a group with a push or PR run. It is never cancelled
|
||||
# either: a newer push must not abort the checks a release waits on.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ci-${{ github.workflow }}-${{ inputs.ref || github.ref }}
|
||||
cancel-in-progress: ${{ !inputs.ref }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -22,6 +32,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -88,6 +100,8 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
|
||||
@@ -32,8 +32,37 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# A version at or below the live release is no update for any client, and
|
||||
# publishing it as latest would hide that release from legacy clients.
|
||||
check-version:
|
||||
name: Check the version is above the live release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
steps:
|
||||
- name: Compare with /releases/latest
|
||||
run: |
|
||||
err=$(mktemp)
|
||||
if latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" -q .tag_name 2>"$err"); then
|
||||
live="${latest#v}"
|
||||
highest=$(printf '%s\n%s\n' "$live" "$VERSION" | sort -V | tail -n 1)
|
||||
if [ "$VERSION" = "$live" ] || [ "$highest" != "$VERSION" ]; then
|
||||
echo "::error::version '$VERSION' is not higher than the live release $latest."
|
||||
exit 1
|
||||
fi
|
||||
echo "$VERSION is higher than the live release $latest."
|
||||
elif grep -q 'HTTP 404' "$err"; then
|
||||
echo "No published release yet, so any version is higher."
|
||||
else
|
||||
cat "$err" >&2
|
||||
echo "::error::Could not read /releases/latest."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
win-x64:
|
||||
name: Windows x64 (legacy)
|
||||
needs: check-version
|
||||
runs-on: windows-latest
|
||||
outputs:
|
||||
sha: ${{ steps.sha.outputs.sha }}
|
||||
|
||||
@@ -73,9 +73,19 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Nothing is built for release from a tag whose sources fail CI.
|
||||
verify:
|
||||
name: CI on the tag
|
||||
needs: prepare
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
win-x64:
|
||||
name: Windows x64
|
||||
needs: prepare
|
||||
needs: [prepare, verify]
|
||||
runs-on: windows-latest
|
||||
# The updater signing key decides what every installed client will
|
||||
# install, so only the steps that check or use it see it: never
|
||||
@@ -135,7 +145,7 @@ jobs:
|
||||
|
||||
linux-x64:
|
||||
name: Linux x64
|
||||
needs: prepare
|
||||
needs: [prepare, verify]
|
||||
runs-on: ubuntu-22.04
|
||||
# As on Windows, only the steps that check or use the signing key see it.
|
||||
steps:
|
||||
@@ -170,22 +180,18 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# Compile without the key, then bundle and sign with it: `tauri bundle`
|
||||
# packages the binary already built and runs no cargo build scripts.
|
||||
# Compile and bundle without the key: the AppImage bundler downloads
|
||||
# and runs linuxdeploy tools at whatever version is current, so the key
|
||||
# must never be in its environment. The AppImage is signed on its own
|
||||
# after the rename.
|
||||
- run: npm run tauri build -- --no-bundle -- --locked
|
||||
|
||||
- name: Bundle the AppImage and deb with updater signatures
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: >-
|
||||
npm run tauri bundle --
|
||||
--bundles appimage,deb
|
||||
--config '{"bundle":{"createUpdaterArtifacts":true}}'
|
||||
- name: Bundle the AppImage and deb
|
||||
run: npm run tauri bundle -- --bundles appimage,deb
|
||||
|
||||
# The bundler names outputs after productName, which has spaces, and
|
||||
# GitHub rewrites spaces in asset names, so the URLs in latest.json
|
||||
# would no longer match. A signature covers the content, not the name.
|
||||
# would no longer match.
|
||||
- name: Rename the bundles to space-free names
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
@@ -204,9 +210,19 @@ jobs:
|
||||
name="usage-monitor_${VERSION}_amd64"
|
||||
mkdir -p dist
|
||||
cp "$appimage" "dist/$name.AppImage"
|
||||
cp "$appimage.sig" "dist/$name.AppImage.sig"
|
||||
cp "$deb" "dist/$name.deb"
|
||||
test -s "dist/$name.AppImage.sig"
|
||||
|
||||
# As on Windows, `signer sign` writes a separate .sig next to the file
|
||||
# and leaves the AppImage bytes as bundled.
|
||||
- name: Sign the AppImage
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
appimage="dist/usage-monitor_${VERSION}_amd64.AppImage"
|
||||
npx tauri signer sign --app-version "$VERSION" "$appimage"
|
||||
test -s "$appimage.sig"
|
||||
ls -l dist
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
|
||||
Generated
+1
-1
@@ -4693,7 +4693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "usage-monitor"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"auto-launch",
|
||||
"dbus",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "usage-monitor"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage"
|
||||
@@ -36,7 +36,8 @@ tauri-plugin-updater = "2"
|
||||
# Keep the major in step with the plugin's (`cargo tree -i reqwest`).
|
||||
reqwest = { version = "0.13", default-features = false, features = ["socks"] }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
# Windows and Linux write their start-at-login entries themselves.
|
||||
[target.'cfg(not(any(windows, target_os = "linux")))'.dependencies]
|
||||
tauri-plugin-autostart = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
|
||||
@@ -37,7 +37,7 @@ 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,
|
||||
updates, exit
|
||||
**Reset position**, updates, **Show widget**, **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
|
||||
@@ -55,7 +55,11 @@ right-click **Providers** menu.
|
||||
|
||||
- Windows 10 or Windows 11, or Linux x86_64 (see below)
|
||||
- Claude Code (CLI or App) installed and authenticated
|
||||
- Optional: Codex CLI installed and authenticated, if you want Codex usage
|
||||
- The `claude` CLI, for automatic token refresh. The app looks for it on
|
||||
`PATH`, and on Linux also in `~/.local/bin` and `~/.claude/local`. Without
|
||||
it an expired sign-in is not refreshed and you have to sign in again.
|
||||
- Optional: Codex CLI installed and authenticated, if you want Codex usage.
|
||||
Its token is refreshed through the `codex` CLI, found the same way.
|
||||
|
||||
### Windows
|
||||
|
||||
@@ -107,7 +111,10 @@ 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.
|
||||
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
|
||||
@@ -158,9 +165,11 @@ 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)
|
||||
- **Right-click** for refresh, providers, refresh frequency, "Start with
|
||||
Windows" ("Start at login" on Linux), controls, auto-update check
|
||||
(Disabled / Hourly / Daily / Weekly), manual "Check for updates", exit
|
||||
- **Right-click** for **Refresh**, **Update frequency**, **Providers**,
|
||||
**Settings**, **Controls**, **Show widget**, **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
|
||||
@@ -207,7 +216,7 @@ This writes a log file, truncated on each start, to:
|
||||
| OS | Log file |
|
||||
|----|----------|
|
||||
| Windows | `%TEMP%\usage-monitor.log` |
|
||||
| Linux | `$TMPDIR/usage-monitor.log`, usually `/tmp/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:
|
||||
|
||||
@@ -241,13 +250,29 @@ 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 to GitHub only if you use the app's update-check feature
|
||||
- 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**)
|
||||
- Polling frequency
|
||||
- Auto-update check interval
|
||||
- Last update check time
|
||||
- Displayed provider preferences
|
||||
|
||||
|
||||
+19
-10
@@ -48,15 +48,19 @@ update again.
|
||||
1. Bump `Cargo.toml` `version` (`X.Y.Z`).
|
||||
2. Run `cargo build` locally to refresh `Cargo.lock`.
|
||||
3. Commit: `chore: bump version to X.Y.Z`.
|
||||
4. Tag and push:
|
||||
4. Push `main`, wait for its "CI" run to pass, then tag and push the
|
||||
tag:
|
||||
|
||||
```powershell
|
||||
git tag -a vX.Y.Z -m "vX.Y.Z"
|
||||
git push origin main
|
||||
# wait for the CI run on main to go green
|
||||
git tag -a vX.Y.Z -m "vX.Y.Z"
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
5. Watch the "Release" workflow run. It creates a non-draft,
|
||||
5. Watch the "Release" workflow run. It first runs the full CI
|
||||
(`ci.yml`) on the tag and builds nothing unless that passes. It then
|
||||
creates a non-draft,
|
||||
non-prerelease GitHub Release marked latest, with auto-generated
|
||||
notes and the assets listed below, then fails if
|
||||
`/releases/latest` does not return the new tag.
|
||||
@@ -70,7 +74,7 @@ A release carries exactly these files and nothing else:
|
||||
| `usage-monitor.exe` | Windows job, `npm run tauri build -- --no-bundle` | Legacy Win32 clients (GitHub API) and Tauri Windows clients (`latest.json`) |
|
||||
| `usage-monitor.exe.sig` | `tauri signer sign` on the exe | Tauri Windows clients |
|
||||
| `usage-monitor_X.Y.Z_amd64.AppImage` | Linux job, `tauri bundle` | Tauri AppImage clients |
|
||||
| `usage-monitor_X.Y.Z_amd64.AppImage.sig` | the bundler, with the signing key | Tauri AppImage clients |
|
||||
| `usage-monitor_X.Y.Z_amd64.AppImage.sig` | `tauri signer sign` on the renamed AppImage | Tauri AppImage clients |
|
||||
| `usage-monitor_X.Y.Z_amd64.deb` | Linux job, `tauri bundle` | Users installing by hand (`.deb` installs check for updates but do not apply them) |
|
||||
| `latest.json` | `scripts/make-latest-json.js` in the publish job | Tauri clients, through the updater endpoint in `tauri.conf.json` |
|
||||
|
||||
@@ -85,11 +89,13 @@ The bundler names the Linux outputs after `productName`
|
||||
("Usage Monitor"), and GitHub rewrites spaces in asset
|
||||
names, which would break the `latest.json` URLs. The Linux job
|
||||
therefore copies the bundles to space-free kebab names before upload:
|
||||
`usage-monitor_<version>_amd64.AppImage`, its `.sig`, and
|
||||
`usage-monitor_<version>_amd64.deb`. The signature covers
|
||||
the file content, not its name, so renaming keeps it valid. The
|
||||
bundler also signs the deb, but that signature is not published,
|
||||
because no updater entry uses it.
|
||||
`usage-monitor_<version>_amd64.AppImage` and
|
||||
`usage-monitor_<version>_amd64.deb`. The bundler runs without the
|
||||
signing key, because the AppImage bundler downloads and runs
|
||||
unpinned linuxdeploy tools. A separate step that alone sees the key
|
||||
then signs the renamed AppImage with `tauri signer sign`, which
|
||||
writes `usage-monitor_<version>_amd64.AppImage.sig`. The deb is not
|
||||
signed, because no updater entry uses it.
|
||||
|
||||
### Legacy-client asset contract
|
||||
|
||||
@@ -175,7 +181,10 @@ file is on the default branch, so this workflow, and a dry run of it,
|
||||
is available only after the Tauri port merges into `main`:
|
||||
|
||||
1. Pick a version `X.Y.Z` higher than every release in the field, so
|
||||
every client sees it as an update.
|
||||
every client sees it as an update. The workflow fails before it
|
||||
builds anything unless `X.Y.Z` sorts strictly after the tag that
|
||||
`/releases/latest` returns (`sort -V`); with no published release
|
||||
yet, any version passes.
|
||||
2. Start "Release legacy" from the Actions tab with `ref` set to a ref
|
||||
holding the Win32 sources (for example `v0.5.0`), `version` set to
|
||||
`X.Y.Z`, `tag` set to `vX.Y.Z`, and `draft` left at `true`.
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# Full-project review
|
||||
|
||||
Eleven review dimensions were checked by separate reviewers. Each finding was then checked by three independent verifiers, and kept only when two agreed. Of 40 distinct findings, 37 were confirmed and merged into the 28 items below. Items marked *needs decision* change user-visible behaviour or a product choice, so they were not implemented without the owner's answer.
|
||||
|
||||
## Summary
|
||||
|
||||
I merged 35 confirmed findings into 28 items. The two panel-placement findings (geom.rs:366 and panel.rs:286) share one cause, the virtual-screen union, so they are now one item. Eight dead-code findings all trace back to lib.rs making every module `pub`, so they are also one item; the Status/Utf8 variants are part of it. Verifier votes changed several severities. The licence item drops from high to medium. These drop from medium to low: Ctrl+wheel, the 429 fallback, poll-in-flight, refresh-path tests and the update-check retry.
|
||||
|
||||
The most important items:
|
||||
- **Licence breach.** Upstream MIT icons ship byte-identical, with NOTICE deleted, while the README claims a clean-room implementation.
|
||||
- **Signing-key exposure.** The updater signing key is visible to unpinned linuxdeploy downloads during the AppImage bundle step.
|
||||
- **Untested releases.** A release tag publishes to every client without running tests.
|
||||
- **Panel off-screen.** In the common laptop plus taller external monitor layout, the panel lands off-screen at the default bubble position.
|
||||
- **Codex refresh broken.** `codex exec .` fails the git-repo check, reproduced with codex-cli 0.163.0, so Codex token refresh never works when the app is launched normally.
|
||||
- **Refresh spends quota.** Credential refresh runs a real CLI prompt on every failed poll, with no backoff, and also does so on 403 responses.
|
||||
|
||||
Five items need a user decision:
|
||||
- The licence fix: replace the icons, or restore NOTICE.
|
||||
- The rounding fix, which changes the displayed number.
|
||||
- Sending 403 responses to the Messages fallback instead of refreshing.
|
||||
- The 429 cool-down, because it also changes how often the paid fallback probe runs.
|
||||
- Removing unused but possibly planned code.
|
||||
|
||||
I modified no files.
|
||||
|
||||
## Items
|
||||
|
||||
### 1. Upstream MIT icons ship without the upstream notice, and the README claims a clean-room implementation *needs decision*
|
||||
|
||||
`README.md:23` · severity medium · docs · effort small
|
||||
|
||||
**Why.** src/icons/* are byte-identical to CodeZeno upstream (checked by git blob SHA). They have not changed since c0f3e3f. icons/icon.ico is identical to src/icons/icon.ico and is bundled through tauri.conf.json. runtime.rs:1010 embeds icons/128x128.png. Commit aa6217d deleted NOTICE, and the project's derivation audit says NOTICE must stay while COPY files remain. As a result, every exe, AppImage and deb breaches MIT attribution, and the README misdescribes where the artwork comes from.
|
||||
|
||||
**Fix.** Choose one. (a) Replace src/icons/* with original artwork, regenerate icons/ with `npx tauri icon`, and check that no blob SHA matches upstream. (b) Restore a root NOTICE that reproduces upstream's MIT LICENSE verbatim, including "Copyright (c) 2025 Craig Constable" (not the earlier wrong holder name). It should state that it covers src/icons/* and the icons/* derived from them, and it should be shipped with releases. Then reword README Acknowledgements so the code is described as clean-room and the icons as coming from upstream under MIT.
|
||||
|
||||
### 2. Updater signing key is visible to unpinned linuxdeploy tooling during the AppImage bundle step
|
||||
|
||||
`.github/workflows/release.yml:176` · severity medium · security · effort small
|
||||
|
||||
**Why.** The Linux bundle step passes TAURI_SIGNING_PRIVATE_KEY and its password into the environment of `tauri bundle --bundles appimage,deb`. To build the AppImage, that step downloads and runs linuxdeploy, the appimage plugin from the `continuous` release and the gtk plugin from a `master` raw URL, none of them hash-pinned. The workflow's own rule (release.yml lines 80-82 and 140, docs/release-process.md) says only signing steps may see the key, and the Windows job already follows it. A compromised upstream artifact could steal the key and sign updates that every client would install, and the key cannot be rotated without stranding every existing install.
|
||||
|
||||
**Fix.** 1. In the bundle step, remove both signing env vars and the `--config createUpdaterArtifacts:true` override, so it runs `npm run tauri bundle -- --bundles appimage,deb`.
|
||||
2. In the rename step, drop the `cp`/`test -s` lines for `$appimage.sig`.
|
||||
3. After the rename step, add a step that alone receives the key and password and runs `npx tauri signer sign --app-version "$VERSION" "dist/usage-monitor_${VERSION}_amd64.AppImage"` followed by `test -s` on the .sig, the same way release.yml lines 116-125 do it for Windows.
|
||||
4. Update the AppImage .sig row in docs/release-process.md (line 73) and the workflow comment at lines 172-173.
|
||||
|
||||
### 3. Release workflow publishes a tag as latest without running tests, clippy or lint
|
||||
|
||||
`.github/workflows/release.yml:24` · severity medium · test-gap · effort small
|
||||
|
||||
**Why.** The prepare, win-x64, linux-x64 and publish jobs only check the tag, version and key, then build, sign and run `gh release create --latest`. ci.yml never runs on tags, and step 4 of docs/release-process.md pushes main and the tag back to back. A commit that compiles but fails a test or lint gets published and auto-updates to every client before CI reports red. A tag on a commit outside main gets no CI run at all.
|
||||
|
||||
**Fix.** 1. Add `workflow_call:` (with an optional `ref` input) to ci.yml, and check out `${{ inputs.ref || github.sha }}`.
|
||||
2. In release.yml, add a `verify` job: `needs: prepare`, `uses: ./.github/workflows/ci.yml`, `with: { ref: ${{ needs.prepare.outputs.tag }} }`.
|
||||
3. Set `needs: [prepare, verify]` on win-x64 and linux-x64.
|
||||
4. Confirm that ci.yml's cancel-in-progress concurrency group cannot cancel the called run.
|
||||
5. Change docs/release-process.md step 4 to: push main, wait for green CI, then push the tag.
|
||||
|
||||
### 4. Panel is placed within the union of all monitors' full rects, so it lands off-screen or over the taskbar on mixed-height layouts
|
||||
|
||||
`src/panel.rs:286` · severity medium · bug · effort small
|
||||
|
||||
**Why.** place_near_bubble passes virtual_screen(monitor_rects) as the bounds. monitor_rects uses each monitor's full rect, not its work area, and virtual_screen takes their bounding box. geom::place_near (geom.rs:366) only flips the panel above the bubble at bounds.b. Take a 1920x1080 primary (work-area bottom 1040) next to a top-aligned 2560x1440 secondary, with the bubble at its default spot. The panel then goes to y=1024..1144: it covers the taskbar, and rows 1080-1144 at x<1920 are on no monitor. This is the default position in a very common laptop plus external monitor layout. Even on a single monitor, the flip compares against the monitor bottom rather than the work-area bottom.
|
||||
|
||||
**Fix.** In place_near_bubble, use the work area of the bubble's own monitor as the bounds. Reuse bubble.rs's `screen_of(&bubble).work` (make it pub(crate) or move it into runtime.rs); its fallback chain is current_monitor then primary_monitor. Fall back to virtual_screen, and then to the anchor, only when no monitor is available. Leave place_near unchanged. Add geom tests for: a bubble near the bottom of the shorter monitor in a mixed-height layout (the panel must flip above), and a single monitor whose work area is shorter than its rect.
|
||||
|
||||
### 5. `codex exec .` refresh fails the Codex git-repo or trusted-directory check, so Codex token refresh never works
|
||||
|
||||
`src/usage/refresh.rs:85` · severity medium · bug · effort small
|
||||
|
||||
**Why.** This was reproduced with codex-cli 0.163.0. spawn_first never sets current_dir, so the child inherits the GUI app's working directory: $HOME, `/` or System32. In those directories `codex exec .` prints "Not inside a trusted directory and --skip-git-repo-check was not specified" and exits before it makes any request. auth.json is never rotated, so every Codex token expiry ends in Timeout and a session-expired alert. If the app was instead launched from inside a project, `codex exec .` runs a real agent turn in that project, and `claude -p .` loads that project's CLAUDE.md and hooks.
|
||||
|
||||
**Fix.** 1. Change the Codex args in both cli_for variants (Unix and Windows) to `["exec", "--skip-git-repo-check", "--sandbox", "read-only", "."]`.
|
||||
2. In spawn_first, set `cmd.current_dir(std::env::temp_dir())` (or CODEX_HOME/~/.codex) for both CLIs.
|
||||
3. Add a unit test asserting that the Codex args include `--skip-git-repo-check`.
|
||||
|
||||
### 6. Credential refresh runs a real CLI prompt on every failed poll, with no backoff, and treats every 403 as refreshable *needs decision*
|
||||
|
||||
`src/usage/refresh.rs:47` · severity medium · robustness · effort medium
|
||||
|
||||
**Why.** state.rs:143 records every AuthFailed result, and runtime.rs:320 then always calls refresh_credentials. Orchestrator::refresh always spawns `claude -p .` or `codex exec .`, each of which sends a real model request. Nothing records an earlier failed refresh. anthropic.rs:75-82 maps every 403 to AuthRequired and returns early with `?`, so the Messages fallback is never tried. With a persistent 403 that a refresh cannot fix (scope or org policy), or a CLI that cannot refresh, the app spawns another prompt every poll, as often as every 60 s, and uses the very quota it monitors. A timed-out child is only reaped, never killed.
|
||||
|
||||
**Fix.** 1. Keep per-provider refresh state in the runtime: the time of the last failed attempt and the credential signature at that attempt. Skip the CLI spawn while a doubling backoff runs (for example 5 min, capped at 1-6 h), and also skip while the signature is unchanged since the last failed refresh. Reset the state when a poll succeeds or the signature changes.
|
||||
2. Trigger the CLI refresh only for a 401 or a token past expiresAt. For a 403 from /api/oauth/usage, return Ok(None) so the Messages fallback runs, or return a failure that does not trigger a refresh.
|
||||
3. Killing the child after the timeout is optional. A slow Windows start can finish its refresh after 8 s, so use a longer grace period if you kill it.
|
||||
|
||||
### 7. Privacy section does not say the app runs `claude -p .` / `codex exec .` on auth failure
|
||||
|
||||
`README.md:232` · severity medium · docs · effort small
|
||||
|
||||
**Why.** The Privacy and security section claims to list what the app reads, sends and does. It leaves out the hidden, detached CLI runs (runtime.rs:320 calling refresh.rs:71-86). Those runs send a small prompt under the user's account, which counts against usage, and Codex runs an agent turn in the app's working directory. README:57 also says Claude Code '(CLI or App)' is enough, but the refresh needs the `claude` CLI on PATH, in ~/.local/bin or in ~/.claude/local.
|
||||
|
||||
**Fix.** 1. Add a 'What the app runs' bullet: when a provider rejects the stored credentials, the app runs `claude -p .` and/or `codex exec .` hidden in the background so the CLI can refresh its token. Say that each run sends one tiny prompt counted toward usage, and state the working directory the run uses once the rank-5 fix lands.
|
||||
2. Do not claim this happens on every poll if the rank-6 backoff lands.
|
||||
3. Change README:57 to say automatic refresh needs the `claude` CLI.
|
||||
|
||||
### 8. Bubble resize around its centre is never clamped back into the work area
|
||||
|
||||
`src/bubble.rs:667` · severity low · bug · effort small
|
||||
|
||||
**Why.** resize_all is reached from Ctrl+wheel and the Size menu. It calls set_position(cx - w/2, cy - h/2) with no clamp_into_work_area, and nothing re-clamps afterwards: reclamp is Windows-only and runs only on display-change messages. A bubble at its default bottom-right spot grown to 360 ends 56 px past the right edge and 12 px under the taskbar, and it stays there until the user drags it. The saved top-left also goes stale, so the bubble jumps to a different spot on the next launch.
|
||||
|
||||
**Fix.** 1. In resize_all, keep the provider from `windows(app)` and compute `pos = geom::clamp_into_work_area(Rect::from_xywh(cx-w/2, cy-h/2, w, h), screen_of(&window).work, provider)`. Use the pre-resize screen, then call set_position(pos).
|
||||
2. Return the (provider, pos) pairs, and have Runtime::set_bubble_size call save_bubble_position for each provider that already has a saved entry. This keeps restarts consistent without pinning bubbles that are still at the default.
|
||||
|
||||
### 9. Linux live theme switching never fires: tao emits ThemeChanged with a dummy window id, which Tauri drops
|
||||
|
||||
`src/bubble.rs:532` · severity low · bug · effort medium
|
||||
|
||||
**Why.** In tao 0.37.1, the portal listener sends SetTheme to WindowId::dummy() (portal.rs:54), and the event loop emits ThemeChanged on that dummy id (event_loop.rs:959-973). tauri-runtime-wry 2.12.2 (lib.rs:4211) forwards a window event only when window_id_map knows the id, so this event is dropped. The Linux ThemeChanged arm in bubble.rs never runs, and the theme is read only at create time. When the GNOME or KDE colour scheme changes, existing bubbles keep the old theme until the app restarts, even though live re-theme is an advertised feature.
|
||||
|
||||
**Fix.** 1. Start one Linux-only background thread from setup. It opens `dbus::blocking::Connection::new_session()` and adds a match on `org.freedesktop.portal.Settings.SettingChanged`.
|
||||
2. In the handler, filter for namespace `org.freedesktop.appearance` and key `color-scheme`, unwrap the nested Variant to a u32, and map it with the existing `color_scheme_is_dark`.
|
||||
3. On Some, hop to the main thread with `app.run_on_main_thread` or the existing `defer`, and call `runtime.set_dark`.
|
||||
4. If the session bus is unavailable, log once and exit the thread.
|
||||
5. Delete the dead arm, or replace it with a comment explaining why tao's event never arrives.
|
||||
|
||||
### 10. A failed update check is recorded as completed and pushes the next attempt out by a full interval
|
||||
|
||||
`src/runtime.rs:622` · severity low · robustness · effort small
|
||||
|
||||
**Why.** run_update_check sets last_update_check_unix=now, saves it, and sends UpdateCheckDone{interval} for every outcome, Err included. The scheduler (scheduler.rs:110) re-arms the full interval, and the next startup treats the failed check as recent. With autostart before the network is up, one transient failure delays the next update check by a day or a week, so a release can sit unnoticed for that long.
|
||||
|
||||
**Fix.** 1. In run_update_check, stamp and save last_update_check_unix only on Ok(UpToDate | Available).
|
||||
2. On Err, send UpdateCheckDone with a `succeeded: false` flag (or a new UpdateCheckFailed message). The scheduler then re-arms at `min(interval, 60s * 2^attempt)` and resets `attempt` after a success.
|
||||
3. Apply the same handling to the thread-spawn failure branch (runtime.rs:584-588).
|
||||
4. Add a Deadlines unit test.
|
||||
|
||||
### 11. Weekly "100%" truncates to "10…" at larger bubble sizes because the percent column is measured at weight 400 but drawn at 600
|
||||
|
||||
`ui/layout.js:189` · severity low · bug · effort small
|
||||
|
||||
**Why.** pctReserveW = ceil(measure('100%', fonts.small)) + 2. bubble.js:215 measures with MEASURE_WEIGHT 400, but `.tail-pct` is drawn at font-weight 600 with ellipsis overflow. Measured in Chromium with DejaVu Sans, once fonts.small reaches about 13 px the semibold '100%' is wider than the reserved width (for example 46.3 px against 45 px at 15 px). The number is hidden in exactly the state the pulse is meant to highlight.
|
||||
|
||||
**Fix.** 1. Pass the weight through the callback: `tailLayout(canvas, (text, px, weight) => measure(text, px, weight))`.
|
||||
2. In layout.js:189, use `measure(PCT_TEMPLATE, fonts.small, TAIL_PCT_WEIGHT)`, with an exported `TAIL_PCT_WEIGHT = 600` that matches the CSS.
|
||||
3. Update the MeasureText JSDoc.
|
||||
4. Add a layout.test.js case whose stub returns a wider width at weight 600, and assert that usagePctRect covers it.
|
||||
|
||||
### 12. Enabling a provider while a poll is in flight leaves its bubble empty until the next scheduled poll
|
||||
|
||||
`src/runtime.rs:457` · severity low · bug · effort small
|
||||
|
||||
**Why.** toggle_provider calls spawn_poll, and spawn_poll drops the request when poll_in_flight is set (lines 280-288). The running poll copied the settings at its start, so it never polls the newly enabled provider. That bubble shows the placeholder for up to a full interval. The window in which this can happen widens by up to 8 s per rejected provider while refresh_credentials runs. Manual Refresh is a workaround. The reset_positions call site is harmless.
|
||||
|
||||
**Fix.** 1. Add `poll_requested: AtomicBool`. When spawn_poll's compare-exchange fails, set it to true.
|
||||
2. In the worker, loop: `loop { poll_once(); propagate(); if !poll_requested.swap(false) { break } }`.
|
||||
3. Clear poll_in_flight, then re-check poll_requested and call spawn_poll again if it is set. This closes the gap between the last swap and the clear.
|
||||
|
||||
### 13. 429/5xx from the usage endpoint ignores Retry-After and re-hits the endpoint plus a Messages probe on every poll *needs decision*
|
||||
|
||||
`src/usage/anthropic.rs:83` · severity low · robustness · effort medium
|
||||
|
||||
**Why.** Every non-2xx status other than 401/403, and every transport error, becomes Ok(None), which leads to try_messages_endpoint, a real max_tokens=1 inference POST. Nothing reads Retry-After, and the scheduler has no error backoff. While the token is rate-limited, every poll sends the GET to an endpoint that is already refusing it, plus a small quota-consuming probe. On a 429 the fallback is still useful, because it keeps the numbers showing; 5xx and transport failures turn into extra inference calls instead of a short-lived error.
|
||||
|
||||
**Fix.** 1. Keep the Messages fallback for 429.
|
||||
2. On a 429, read Retry-After (or use a capped exponential delay) and store a per-provider 'usage endpoint blocked until' instant. While it is active, skip the GET and go straight to Messages. Optionally rate-limit the probe and show the last good values marked stale.
|
||||
3. Map 5xx and transport errors to Err(Network/BadResponse) without probing Messages.
|
||||
4. Keep the fallback for shape failures: an unparsable body, or missing reset times.
|
||||
|
||||
### 14. Threshold-crossing check ignores window resets, so a new session that passes 80/95 within one poll never alerts
|
||||
|
||||
`src/core/state.rs:131` · severity low · bug · effort small
|
||||
|
||||
**Why.** old_pct is the previous snapshot's utilization, and resets_at is never consulted. Suppose poll N shows 97%, the 5 h window resets, and poll N+1 shows 85% or 96% of the new window. Nothing crosses, so no alert fires for the new session. This can happen with the 1-hour poll interval, or after suspend or failed polls, because a Failed poll keeps the old snapshot.
|
||||
|
||||
**Fix.** 1. Treat the stored window as a different window when its resets_at has already passed (pass a SystemTime wall_now into apply_results next to the Instant), or when the new resets_at exceeds the old one by more than 60 s.
|
||||
2. In that case, use old_pct = 0.0 before checking crossings.
|
||||
3. Add tests: 97 then a reset then 85 alerts at 80; 96 alerts at both 80 and 95; a few seconds of resets_at jitter inside the same window does not re-alert.
|
||||
|
||||
### 15. Scheduler deadlines use the monotonic clock, which does not advance during suspend
|
||||
|
||||
`src/scheduler.rs:166` · severity low · robustness · effort medium
|
||||
|
||||
**Why.** poll_at, countdown_at and update_at are all Instants, and recv_timeout waits on CLOCK_MONOTONIC, which does not count suspended time on Linux. Nothing hooks resume. After resume, the bubbles keep showing pre-suspend data until the rest of the poll interval passes in awake time. A daily update check on a laptop awake 8 h/day runs about every 3 calendar days.
|
||||
|
||||
**Fix.** 1. Keep update_at as a unix-timestamp target, and recompute the remaining time from SystemTime on every loop pass.
|
||||
2. Cap recv_timeout at about 60 s.
|
||||
3. After each wake, compare the SystemTime elapsed with the Instant elapsed. If wall time jumped ahead by more than about 30 s, treat it as a resume: run Action::Poll, re-arm poll_at, and re-check the update target.
|
||||
4. Pass now_unix into take_due/next_wakeup so the logic stays unit-testable.
|
||||
|
||||
### 16. One wrongly typed field in settings.json resets all settings, and the next save overwrites the file
|
||||
|
||||
`src/core/settings.rs:250` · severity low · robustness · effort small
|
||||
|
||||
**Why.** parse_and_normalise deserialises the whole struct at once. A float in an i32/u32 field (for example a hand edit of 250.5) fails the entire parse, so every field falls back to its default. Any later save (a drag, a toggle, or the automatic update-check timestamp at runtime.rs:622) then overwrites the user's file. One verifier judged this out of scope because the defaults are documented for corrupt files. The loss is silent and needs no user action, so it is kept as cheap hardening.
|
||||
|
||||
**Fix.** 1. Keep the strict parse as the fast path.
|
||||
2. On failure, parse into serde_json::Value. If it is an object, start from Settings::default() and fill each known key with serde_json::from_value, skipping and logging keys that fail, then apply the existing normalisation.
|
||||
3. If the file is not valid JSON at all, copy it to settings.json.bak before the first save.
|
||||
4. Add a test: `{"bubble_size_logical":250.5,"show_codex":true,"poll_interval_ms":60000}` keeps show_codex and poll_interval_ms.
|
||||
|
||||
### 17. Taskbar snap uses the primary monitor's taskbar for bubbles on every monitor
|
||||
|
||||
`src/platform/windows.rs:395` · severity low · bug · effort small
|
||||
|
||||
**Why.** ABM_GETTASKBARPOS reports only the primary Shell_TrayWnd. bubble::move_end passes that rect to geom::snap together with the current monitor's work area. snap_alongside_taskbar (geom.rs:318) compares only one coordinate and never checks that the taskbar touches the bubble's work area. A bubble dropped on a secondary monitor within 12 px of the primary taskbar's line is pulled to it and saved there, for example resting 44 px above the bottom of a monitor that has no taskbar.
|
||||
|
||||
**Fix.** 1. Make the pure snap_alongside_taskbar guard itself: snap only when the taskbar's inner face equals the work-area edge on that side and the taskbar overlaps the work area on the other axis. Bottom: `tb.t==wa.b && tb.l<wa.r && tb.r>wa.l`. Top: `tb.b==wa.t` plus x-overlap. Left: `tb.r==wa.l` plus y-overlap. Right: `tb.l==wa.r` plus y-overlap. Otherwise return early.
|
||||
2. Add a geom test with a secondary-monitor work area and a primary-monitor taskbar, asserting no snap.
|
||||
|
||||
### 18. Linux autostart desktop entry writes an unquoted Exec path, which breaks for AppImage paths containing spaces
|
||||
|
||||
`src/runtime.rs:873` · severity low · bug · effort small
|
||||
|
||||
**Why.** For an AppImage, tauri-plugin-autostart 2.7.0 passes the raw $APPIMAGE path, and auto-launch 0.6.0 writes `Exec={path} {args}` without quoting. A path such as `~/My Apps/usage-monitor.AppImage` makes the session run `/home/u/My`, which fails silently. Meanwhile is_enabled only checks that the file exists, so the menu shows start-at-login as checked. The README tells users to put the AppImage in a writable folder of their choice, so such paths are plausible. Windows already works around the same problem.
|
||||
|
||||
**Fix.** 1. On Linux, bypass the plugin the same way the Windows path does. Build the XDG entry with auto_launch directly, or write ~/.config/autostart/UsageMonitor.desktop yourself, keeping the same name.
|
||||
2. Write Exec as a double-quoted path with `"`, `` ` ``, `$` and `\` backslash-escaped, and `%` written as `%%`. Then apply the desktop-file string escaping of `\`.
|
||||
3. Put the escaping in a pure function, and unit-test it with paths containing a space and a `$`.
|
||||
|
||||
### 19. --diagnose log is written to a fixed /tmp path with default 0644 permissions
|
||||
|
||||
`src/diag/mod.rs:27` · severity low · security · effort small
|
||||
|
||||
**Why.** File::create(temp_dir().join("usage-monitor.log")) on Linux opens /tmp/usage-monitor.log with O_TRUNC and no O_EXCL, and creates it 0644. Other local users can read the request URIs, usage figures and refresh outcomes. Tokens are redacted. This only happens with the opt-in flag, and protected_regular blocks the pre-created-file attack on mainstream distros. The README and module comment describe a %TEMP% location, so the Linux path is probably unintended.
|
||||
|
||||
**Fix.** 1. On Unix, write to dirs::state_dir() or dirs::cache_dir() joined with `usage-monitor/` (created 0700), falling back to temp_dir.
|
||||
2. Open with OpenOptions write+create+truncate and `.mode(0o600)`, plus set_permissions(0o600) for an existing file.
|
||||
3. Keep %TEMP% on Windows.
|
||||
4. Update the README and module comment with the Linux path.
|
||||
|
||||
### 20. Displayed percent rounds up past the colour band, alert and pulse thresholds *needs decision*
|
||||
|
||||
`src/core/i18n.rs:184` · severity low · bug · effort small
|
||||
|
||||
**Why.** format_pct and format_window_at use `{:.0}`, which rounds. bar_fill_color, the alert crossings and the JS pulseActive all use the raw float. So 79.6 reads "80%" with an amber fill and no 80% alert, and 94.6 reads "95%" with no deep red, no pulse and no alert. This only happens with fractional upstream values, which the parsers accept.
|
||||
|
||||
**Fix.** 1. Floor the displayed value in one shared helper used by format_pct and format_window_at (i18n.rs:184 and :197), for example `format!("{}%", p.floor() as i64)`, with a guard for non-finite and negative values.
|
||||
2. Leave the band, alert and pulse checks on the raw value.
|
||||
3. Replace the half-to-even test with floor cases (79.6 -> "79%", 94.99 -> "94%").
|
||||
|
||||
### 21. Ctrl+wheel sends one 20 px resize per wheel event, so a touchpad scroll or pinch jumps the bubble to the min or max
|
||||
|
||||
`ui/bubble.js:458` · severity low · robustness · effort small
|
||||
|
||||
**Why.** Every ctrl+wheel event invokes bubble_resize_step with Math.sign(-deltaY), and each step saves settings, resizes every window and runs propagate. Precision touchpads and pinch (delivered as ctrl+wheel by WebView2 and WebKitGTK) send dozens of small-delta events per gesture, and only 11 events cover the whole range from 140 to 360.
|
||||
|
||||
**Fix.** 1. Normalise deltaY by deltaMode to pixels (line ×16, page ×~800) and add it to an accumulator.
|
||||
2. Reset the accumulator after about 200 ms idle or when the direction flips.
|
||||
3. Each time |acc| reaches 100, send one step with that sign and subtract 100. A mouse notch still gives exactly one step. Do not use a plain per-frame rate limit instead.
|
||||
|
||||
### 22. Rollback workflow accepts a version that is not higher than the live release
|
||||
|
||||
`.github/workflows/release-legacy.yml:51` · severity low · robustness · effort small
|
||||
|
||||
**Why.** The input check (lines 51-58) only checks the X.Y.Z format and that the tag matches. The publish step (lines 176-186) runs `gh release create --latest` with no version comparison. An operator in an incident who enters an unused lower version (for example 0.6.9 while 0.7.1 is live) gets a successful run, but every client sees nothing newer and stays on the broken build. A post-publish check of /releases/latest would not catch this, because `--latest` forces the new release to be latest.
|
||||
|
||||
**Fix.** 1. Before building, in the input step (a read token works for a public repo) or before `gh release create`, run `cur=$(gh api repos/$GITHUB_REPOSITORY/releases/latest -q .tag_name)`.
|
||||
2. Fail unless $VERSION sorts strictly after ${cur#v} under `sort -V`, and also fail when the two are equal.
|
||||
|
||||
### 23. Provider HTTP paths (401/403 mapping, fallback order, account header) have no tests
|
||||
|
||||
`src/usage/anthropic.rs:52` · severity low · test-gap · effort medium
|
||||
|
||||
**Why.** fetch_with_fallback, try_usage_endpoint, try_messages_endpoint and both poll() implementations are untested because the URLs are hard-coded consts, and the only live test is #[ignore]. These branches decide when a credential refresh and the session-expired alert fire. The code is correct today, but a reordered match arm or a dropped ChatGPT-Account-Id header would pass all current tests.
|
||||
|
||||
**Fix.** 1. Pass the endpoint URLs as arguments: fetch_with_fallback(http, token, usage_url, messages_url), with poll supplying the consts. Move the ChatGPT request out into fetch(http, url, token, account_id).
|
||||
2. Add net::test_server::serve_once tests: usage 401/403 gives AuthRequired with no Messages call; usage 5xx or an unparsable body falls back to Messages; a partial body gets its reset times merged; Messages 401 gives AuthRequired; Messages with no headers gives BadResponse; ChatGPT 401 gives AuthRequired and 500 gives BadResponse; the account-id header is sent only when set.
|
||||
3. Update these tests to match the 403 and 5xx changes from ranks 6 and 13.
|
||||
|
||||
### 24. Credential refresh decision (Orchestrator::refresh, Locator) is untested
|
||||
|
||||
`src/usage/refresh.rs:44` · severity low · test-gap · effort small
|
||||
|
||||
**Why.** The Refreshed/Timeout/CliMissing decision is untested. So are Locator::first_available and the signature() implementations. spawn_cli is hard-wired, so tests cannot drive it. Inverting the signature comparison would raise a false 'session expired' alert on every auth failure, and no test would fail. This becomes more important once the backoff from rank 6 lands.
|
||||
|
||||
**Fix.** 1. Add a private `refresh_with(source, spawn: impl FnOnce(&RefreshHint) -> bool)` that refresh calls with spawn_cli.
|
||||
2. Test it with a fake CredentialSource (a Cell counter) and a timeout of a few ms: signature changes gives Refreshed; signature never changes gives Timeout; spawn returns false gives CliMissing.
|
||||
3. Add first_available tests (skips None, keeps order).
|
||||
4. Add a tempfile test that LocalClaudeCreds::signature changes after a rewrite; assert on the size change, because mtime has 1 s resolution.
|
||||
|
||||
### 25. README privacy claim that GitHub is contacted 'only if you use the update-check feature' is false by default
|
||||
|
||||
`README.md:244` · severity low · docs · effort small
|
||||
|
||||
**Why.** update_check_interval_secs defaults to hourly (settings.rs:55-57), and update_check_due returns Now when there is no stored timestamp. A fresh install therefore fetches the GitHub latest.json at first launch and then hourly, with no user action. The request carries no credentials, but the section exists to state network traffic accurately.
|
||||
|
||||
**Fix.** Replace the line with: "Requests to GitHub Releases (`latest.json`) to check for updates: at first launch and then hourly by default. Set the auto-update check to Disabled in the right-click menu to stop them; 'Check for updates' still works manually."
|
||||
|
||||
### 26. Linux shows the in-app hint 'Tray click: show/hide', but a tray click there only opens the menu
|
||||
|
||||
`src/core/menu_model.rs:270` · severity low · docs · effort small
|
||||
|
||||
**Why.** The Controls submenu adds the hint control_tray_click on every platform. On Linux, AppIndicator never delivers tray click events, and README:82-84 and :167-168 say any click opens the menu. The in-app help therefore contradicts both the README and the actual behaviour.
|
||||
|
||||
**Fix.** Add the hint only `if !cfg!(target_os = "linux")`. Alternatively, add an i18n string `control_tray_click_linux` = "Tray click: menu", register it in the key list at i18n.rs:288, and choose between the two by platform. Update any menu_model test that counts Controls entries.
|
||||
|
||||
### 27. README menu lists omit Show widget, Restart and Reset position, and the stored-data list omits widget visibility
|
||||
|
||||
`README.md:161` · severity low · docs · effort small
|
||||
|
||||
**Why.** The real menu (menu_model.rs:233-290) contains a 'Show widget' toggle (persisted as widget_visible), Restart, and Settings > Reset position. None of these appear in README:39-40 or :161-163. The Privacy 'stores locally' list claims completeness but leaves out widget visibility and the auto-update interval. A user can still recover hidden bubbles from the tray menu, so this is a completeness gap only.
|
||||
|
||||
**Fix.** 1. Add 'Show widget' (hides every bubble; re-check it from the tray menu), Settings > 'Reset position' and 'Restart' to both menu lists.
|
||||
2. Add 'Widget visibility' and 'Auto-update check interval' to the stored-locally list (README:246-252).
|
||||
|
||||
### 28. `pub` modules hide about 30 dead items left from the port *needs decision*
|
||||
|
||||
`src/lib.rs:3` · severity low · quality · effort medium
|
||||
|
||||
**Why.** lib.rs makes every module `pub` to silence dead_code, but only main.rs uses the crate, through `run`. Changing the modules to private `mod` produces 34 warnings on Linux. The following items are dead on all targets:
|
||||
- net::Error::Status/Utf8 and Response::text. The three `Err(Status(401|403))` arms in anthropic.rs:75/114 and chatgpt.rs:47 can never match, because the client sets http_status_as_error(false).
|
||||
- color::pulse_triangle, brighten and Rgb::lerp. These duplicate ui/layout.js's live pulse, and the doc comment claims they 'mirror' it.
|
||||
- geom::corner_radius_px and point_in_rounded_rect.
|
||||
- Registry::credential_signatures, Locator::signatures/new, CredentialSource::id and LocalClaudeCreds::path. Their docs falsely claim re-login is detected between polls.
|
||||
- usage::Error::TokenExpired (still matched at runtime.rs:758), refresh::Outcome::StillExpired and creds::Error::Unavailable.
|
||||
- Five unread fields of ProviderMetadata, plus ProviderDisplayMode, ProviderSnapshot and UsageProvider::id. default_enabled duplicates settings::default_show_*.
|
||||
- i18n::format_countdown and time_until_display_change (the non-`_at` versions), Version::current, UiBridge::app and Runtime::ui.
|
||||
Separately, chatgpt.rs:99 uses Option<Option<Box<T>>> plus a FlattenBoxed trait that behaves exactly like Option<T>, and unix_to_systemtime is duplicated in chatgpt.rs:92 and headers.rs:49.
|
||||
|
||||
**Fix.** 1. Make the modules private (`mod x;`) and keep `pub fn run`.
|
||||
2. Delete the items above with their unit tests.
|
||||
3. Drop the TokenExpired arm at runtime.rs:758 and its test assertion; the AuthRequired assertion already covers that path.
|
||||
4. Delete the Status arms and keep the resp.status() checks. Change the net test at mod.rs:367 to assert on body().
|
||||
5. Replace ProviderMetadata with `const fn live_usage(self) -> bool`, and keep settings::default_show_* as the only source of defaults.
|
||||
6. Repoint the intra-doc links at the `_at` functions, and keep the `id` struct fields that signature() uses.
|
||||
7. Change the ChatGPT fields to Option<RateLimit>/Option<ApiWindow>, delete FlattenBoxed, and share one pub(crate) unix_to_systemtime.
|
||||
8. Put `#[cfg(windows)]` on helpers only windows.rs uses: the proxy registry parsers, the WS_* constants, rect_covers_monitor, window_style_bits_allow_fullscreen and FULLSCREEN_EDGE_TOLERANCE_PX.
|
||||
9. Before deleting anything, verify with `cargo check --lib` and `cargo check --lib --target x86_64-pc-windows-msvc` (or Windows CI), and run `cargo doc` to catch broken intra-doc links.
|
||||
|
||||
## Rejected by verification
|
||||
|
||||
- `src/core/state.rs:155` A 95% crossing blocked by the cooldown is lost for good; the user never gets the 'limit is close' alert. The mechanics are as described. In src/core/state.rs:133-136 a crossing is recorded only when old_pct < threshold <= new_pct. At lines 155-161 a crossing that admit_alert (lines 168-176) refuses is dropped and nothing remembers it. last_alert_at is shared by every provider and by session-expired alerts. So the sequence 70->85 at t0, then 85->96 at t0+10min, then parked in 96..100 does produce only
|
||||
- `tauri.conf.json:35` requireSignedVersion is off, so a crafted latest.json can force a downgrade to an older signed build. The finding misreads the plugin. In tauri-plugin-updater 2.14.0, verify_signed_version (updater.rs ~1927-1965) always compares the signed version with the announced one when the signature's trusted comment has a `version:` field, and returns SignedVersionMismatch if they differ, whatever `require_signed_version` is set to. The flag matters only when the signature has no version at all: it decides
|
||||
- `ui/bubble.js:360` Bubble/panel DOM glue (bubble.js, panel.js) has no tests; only the pure helpers are covered. It is true that bubble.js and panel.js have no direct tests. tests/js only covers layout.js, panel-view.js and make-latest-json.js. But the failure scenario the finding gives does not hold. It claims that renaming a payload field would pass the Rust tests and only show up at runtime. That is wrong. The test `wire_format_is_camel_case_with_nulls` in src/core/payload.rs (around lines 281-338) serial
|
||||
|
||||
## Outcome
|
||||
|
||||
All items not marked *needs decision* are implemented on the `review-fixes` branch, each with tests where the behaviour is testable. A follow-up review of the merged branch found three more problems, now fixed: a queued-poll race, Windows refresh treating a missing `claude.cmd` as started, and an imprecise update-check line in the README.
|
||||
|
||||
Still open:
|
||||
|
||||
- Items 1, 6, 13, 20 and 28 wait for the owner's decision.
|
||||
- On GNOME, the portal reports the light style as "no preference", which the app maps to its dark default. So a live switch from dark to light is not followed there. This keeps an earlier deliberate choice and is the owner's call.
|
||||
- A start-at-login entry written by v0.6.0 or earlier keeps its unquoted path until the user toggles the menu item. The README says so. Rewriting it at startup is the alternative.
|
||||
- Windows-only code and the release workflows were checked by reading and linting only. They need a Windows CI run and a real tagged release to confirm.
|
||||
+43
-32
@@ -183,30 +183,42 @@ fn screens(app: &AppHandle) -> Vec<Screen> {
|
||||
|
||||
/// The primary screen, else the first one, else a 1920x1080 stand-in.
|
||||
fn primary_screen(app: &AppHandle, screens: &[Screen]) -> Screen {
|
||||
primary_monitor_screen(app, screens).unwrap_or(FALLBACK)
|
||||
}
|
||||
|
||||
/// The primary screen, else the first one; `None` without any monitor.
|
||||
fn primary_monitor_screen(app: &AppHandle, screens: &[Screen]) -> Option<Screen> {
|
||||
match app.primary_monitor() {
|
||||
Ok(Some(monitor)) => Screen::of(&monitor),
|
||||
Ok(None) => screens.first().copied().unwrap_or(FALLBACK),
|
||||
Ok(Some(monitor)) => Some(Screen::of(&monitor)),
|
||||
Ok(None) => screens.first().copied(),
|
||||
Err(err) => {
|
||||
log::warn!("primary monitor unavailable: {err}");
|
||||
screens.first().copied().unwrap_or(FALLBACK)
|
||||
screens.first().copied()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen a window is on (Win32 picks the nearest), else the primary.
|
||||
/// The screen a window is on (Win32 picks the nearest), else the primary,
|
||||
/// else a 1920x1080 stand-in.
|
||||
fn screen_of(window: &WebviewWindow) -> Screen {
|
||||
monitor_screen_of(window).unwrap_or(FALLBACK)
|
||||
}
|
||||
|
||||
/// The screen a window is on, else the primary; `None` without any monitor.
|
||||
fn monitor_screen_of(window: &WebviewWindow) -> Option<Screen> {
|
||||
match window.current_monitor() {
|
||||
Ok(Some(monitor)) => Screen::of(&monitor),
|
||||
Ok(None) => {
|
||||
let app = window.app_handle();
|
||||
primary_screen(app, &screens(app))
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("monitor of {} unavailable: {err}", window.label());
|
||||
let app = window.app_handle();
|
||||
primary_screen(app, &screens(app))
|
||||
}
|
||||
Ok(Some(monitor)) => return Some(Screen::of(&monitor)),
|
||||
Ok(None) => {}
|
||||
Err(err) => log::warn!("monitor of {} unavailable: {err}", window.label()),
|
||||
}
|
||||
let app = window.app_handle();
|
||||
primary_monitor_screen(app, &screens(app))
|
||||
}
|
||||
|
||||
/// Work area of the monitor `window` is on, else of the primary; `None`
|
||||
/// when no monitor can be found.
|
||||
pub(crate) fn work_area_of(window: &WebviewWindow) -> Option<Rect> {
|
||||
monitor_screen_of(window).map(|s| s.work)
|
||||
}
|
||||
|
||||
fn outer_rect(window: &WebviewWindow) -> Option<Rect> {
|
||||
@@ -527,20 +539,9 @@ pub fn on_window_event(window: &Window, event: &WindowEvent) {
|
||||
}
|
||||
});
|
||||
}
|
||||
// tao reports "no preference" and an unreachable portal as light, so
|
||||
// the portal is read again and only a stated preference applies.
|
||||
#[cfg(target_os = "linux")]
|
||||
WindowEvent::ThemeChanged(_) => {
|
||||
let app = window.app_handle().clone();
|
||||
defer("bubble-theme", move || {
|
||||
let Some(is_dark) = platform::linux::portal_prefers_dark() else {
|
||||
return;
|
||||
};
|
||||
if let Some(runtime) = runtime_of(&app) {
|
||||
runtime.set_dark(is_dark);
|
||||
}
|
||||
});
|
||||
}
|
||||
// On Linux tao sends ThemeChanged with a placeholder window id that
|
||||
// tauri drops, so it never arrives here; `platform::linux_theme`
|
||||
// follows the portal's SettingChanged signal instead.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -663,20 +664,30 @@ fn reclamp(app: &AppHandle, provider: ProviderId) {
|
||||
}
|
||||
|
||||
/// Resize every bubble to `size_logical` around its own centre, at its own
|
||||
/// DPI. Nothing snaps and no position is saved.
|
||||
pub fn resize_all(app: &AppHandle, size_logical: i32) {
|
||||
/// DPI, kept inside the work area of the monitor it was on. Nothing snaps
|
||||
/// and nothing is saved here; returns where each moved bubble now is, empty
|
||||
/// where windows cannot be positioned.
|
||||
pub fn resize_all(app: &AppHandle, size_logical: i32) -> Vec<(ProviderId, Point)> {
|
||||
let placeable = platform::positioning_supported();
|
||||
for (_, window) in windows(app) {
|
||||
let mut moved = Vec::new();
|
||||
for (provider, window) in windows(app) {
|
||||
let Some(rect) = outer_rect(&window) else {
|
||||
continue;
|
||||
};
|
||||
// The monitor before the resize: a bubble grown past an edge may
|
||||
// overlap a neighbour more than its own monitor afterwards.
|
||||
let work = screen_of(&window).work;
|
||||
let (w, h) = geom::bubble_size_px(size_logical, dpi_of(&window));
|
||||
let (cx, cy) = ((rect.l + rect.r) / 2, (rect.t + rect.b) / 2);
|
||||
set_size(&window, (w, h));
|
||||
if placeable {
|
||||
set_position(&window, Point::new(cx - w / 2, cy - h / 2));
|
||||
let centred = Rect::from_xywh(cx - w / 2, cy - h / 2, w, h);
|
||||
let pos = geom::clamp_into_work_area(centred, work, provider);
|
||||
set_position(&window, pos);
|
||||
moved.push((provider, pos));
|
||||
}
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
/// Move every bubble to its default spot on the primary screen. The
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Freedesktop desktop entries: the start-at-login file written to
|
||||
//! `~/.config/autostart` on Linux.
|
||||
//!
|
||||
//! An `Exec` argument goes through two escaping layers. The Exec layer
|
||||
//! quotes the path, backslash-escapes `"`, `` ` ``, `$` and `\` inside the
|
||||
//! quotes and doubles `%` so it is not read as a field code. The value is
|
||||
//! then a desktop-entry string, whose own escaping turns each `\` into `\\`
|
||||
//! (and newline, tab and carriage return into `\n`, `\t`, `\r`, so a path
|
||||
//! cannot end the line and add keys).
|
||||
|
||||
/// The `Exec` value that runs `path` with no arguments, escaped for both
|
||||
/// layers, ready to follow `Exec=`.
|
||||
pub fn exec_value(path: &str) -> String {
|
||||
let mut quoted = String::with_capacity(path.len() + 2);
|
||||
quoted.push('"');
|
||||
for c in path.chars() {
|
||||
match c {
|
||||
'"' | '`' | '$' | '\\' => {
|
||||
quoted.push('\\');
|
||||
quoted.push(c);
|
||||
}
|
||||
'%' => quoted.push_str("%%"),
|
||||
_ => quoted.push(c),
|
||||
}
|
||||
}
|
||||
quoted.push('"');
|
||||
string_value("ed)
|
||||
}
|
||||
|
||||
/// `value` escaped as a desktop-entry string.
|
||||
fn string_value(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
for c in value.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The autostart entry that starts `path` at login, named `name`. The keys
|
||||
/// other than `Exec` match what earlier releases wrote through the
|
||||
/// autostart plugin.
|
||||
pub fn autostart_entry(name: &str, path: &str) -> String {
|
||||
let name = string_value(name);
|
||||
format!(
|
||||
"[Desktop Entry]\n\
|
||||
Type=Application\n\
|
||||
Version=1.0\n\
|
||||
Name={name}\n\
|
||||
Comment={name} startup script\n\
|
||||
Exec={}\n\
|
||||
StartupNotify=false\n\
|
||||
Terminal=false\n",
|
||||
exec_value(path)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plain_path_is_quoted() {
|
||||
assert_eq!(
|
||||
exec_value("/usr/bin/usage-monitor"),
|
||||
"\"/usr/bin/usage-monitor\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_with_a_space_stays_one_argument() {
|
||||
assert_eq!(
|
||||
exec_value("/home/me/My Apps/usage-monitor.AppImage"),
|
||||
"\"/home/me/My Apps/usage-monitor.AppImage\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dollar_is_escaped_for_exec_then_for_the_string() {
|
||||
// Exec layer: `\$`; string layer doubles the backslash.
|
||||
assert_eq!(exec_value("/opt/a$b/app"), "\"/opt/a\\\\$b/app\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_backtick_and_backslash_are_escaped() {
|
||||
assert_eq!(exec_value("/a\"b"), "\"/a\\\\\"b\"");
|
||||
assert_eq!(exec_value("/a`b"), "\"/a\\\\`b\"");
|
||||
// `\` becomes `\\` in the Exec layer, then `\\\\` as a string.
|
||||
assert_eq!(exec_value("/a\\b"), "\"/a\\\\\\\\b\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_is_not_a_field_code() {
|
||||
assert_eq!(exec_value("/opt/100%/app"), "\"/opt/100%%/app\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_cannot_start_a_new_key() {
|
||||
let entry = autostart_entry("UsageMonitor", "/tmp/x\nHidden=true/app");
|
||||
assert!(!entry.lines().any(|line| line == "Hidden=true/app\""));
|
||||
assert!(entry.contains("Exec=\"/tmp/x\\nHidden=true/app\"\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_keeps_the_keys_earlier_releases_wrote() {
|
||||
let entry = autostart_entry("UsageMonitor", "/home/me/My Apps/u.AppImage");
|
||||
assert_eq!(
|
||||
entry,
|
||||
"[Desktop Entry]\n\
|
||||
Type=Application\n\
|
||||
Version=1.0\n\
|
||||
Name=UsageMonitor\n\
|
||||
Comment=UsageMonitor startup script\n\
|
||||
Exec=\"/home/me/My Apps/u.AppImage\"\n\
|
||||
StartupNotify=false\n\
|
||||
Terminal=false\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
+78
-1
@@ -324,6 +324,9 @@ fn snap_alongside_taskbar(
|
||||
gap: i32,
|
||||
) {
|
||||
let Fit { wa, w, h, zone } = *fit;
|
||||
if !taskbar_borders_work_area(edge, tb, wa) {
|
||||
return;
|
||||
}
|
||||
// Only snap on the taskbar's docked edge. The bubble docks against the
|
||||
// inner face of the taskbar with a 4-px gap so it visually leans on it.
|
||||
match edge {
|
||||
@@ -354,6 +357,21 @@ fn snap_alongside_taskbar(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the taskbar sits on this work area's `edge`: its inner face is
|
||||
/// that work-area edge and it overlaps the work area along that edge. A
|
||||
/// taskbar on another monitor (the platform reports only the primary's)
|
||||
/// fails this, so it never pulls a bubble dropped on a secondary monitor.
|
||||
fn taskbar_borders_work_area(edge: Edge, tb: &Rect, wa: &Rect) -> bool {
|
||||
let overlaps_x = tb.l < wa.r && tb.r > wa.l;
|
||||
let overlaps_y = tb.t < wa.b && tb.b > wa.t;
|
||||
match edge {
|
||||
Edge::Bottom => tb.t == wa.b && overlaps_x,
|
||||
Edge::Top => tb.b == wa.t && overlaps_x,
|
||||
Edge::Left => tb.r == wa.l && overlaps_y,
|
||||
Edge::Right => tb.l == wa.r && overlaps_y,
|
||||
}
|
||||
}
|
||||
|
||||
fn align_with_peer(peers: &[Rect], ny: &mut i32, tolerance: i32) {
|
||||
if let Some(peer) = peers.iter().find(|p| (*ny - p.t).abs() <= tolerance) {
|
||||
*ny = peer.t;
|
||||
@@ -362,7 +380,7 @@ fn align_with_peer(peers: &[Rect], ny: &mut i32, tolerance: i32) {
|
||||
|
||||
/// Place a `panel_w`×`panel_h` panel below `anchor` (the bubble's outer
|
||||
/// rect), flipping above when it would clip, and keep it inside `bounds`
|
||||
/// (the virtual-screen union of all monitors, whose origin can be negative).
|
||||
/// (the work area of the bubble's monitor, whose origin can be negative).
|
||||
pub fn place_near(anchor: Rect, panel_size: (i32, i32), bounds: Rect) -> Point {
|
||||
let (panel_w, panel_h) = panel_size;
|
||||
let mut x = anchor.l;
|
||||
@@ -662,6 +680,29 @@ mod tests {
|
||||
assert_eq!(snap(&i), Point::new(800, 900));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taskbar_of_another_monitor_never_snaps() {
|
||||
// The platform reports the primary's bottom taskbar; the bubble is
|
||||
// dropped on a secondary monitor right of the primary, 10 px above
|
||||
// where that taskbar's snap would put it and far from its own edges.
|
||||
let primary_taskbar = Rect::new(0, 1040, 1920, 1080);
|
||||
let rect = Rect::from_xywh(2800, 1040 - 4 - 66 - 10, 200, 66);
|
||||
for secondary_wa in [
|
||||
Rect::new(1920, 0, 3840, 1080),
|
||||
// Same bottom as the primary's work area, still no overlap.
|
||||
Rect::new(1920, 0, 3840, 1040),
|
||||
] {
|
||||
let i = SnapInput {
|
||||
rect,
|
||||
work_area: secondary_wa,
|
||||
dpi: 96,
|
||||
taskbar: Some((Edge::Bottom, primary_taskbar)),
|
||||
peers: Vec::new(),
|
||||
};
|
||||
assert_eq!(snap(&i), Point::new(rect.l, rect.t), "{secondary_wa:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_alignment_takes_first_peer_within_tolerance() {
|
||||
let mut i = input(Rect::from_xywh(800, 500, 200, 66));
|
||||
@@ -741,6 +782,42 @@ mod tests {
|
||||
assert_eq!(place_near(b, (280, 400), tall_bounds), Point::new(0, -190));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_near_flips_above_on_the_shorter_monitor_of_a_mixed_layout() {
|
||||
// A 1920x1080 laptop (40 px taskbar) left of a top-aligned
|
||||
// 2560x1440 monitor. Near the laptop's bottom the panel flips above
|
||||
// within the laptop's work area, though the union of both monitors
|
||||
// would leave room below.
|
||||
let laptop_work = Rect::new(0, 0, 1920, 1040);
|
||||
let bubble = Rect::from_xywh(1500, 940, 200, 66);
|
||||
assert_eq!(
|
||||
place_near(bubble, (280, 120), laptop_work),
|
||||
Point::new(1500, 940 - 120 - 8)
|
||||
);
|
||||
let union = Rect::new(0, 0, 4480, 1440);
|
||||
assert_eq!(
|
||||
place_near(bubble, (280, 120), union),
|
||||
Point::new(1500, 940 + 66 + 8)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_near_flips_against_the_work_area_not_the_monitor() {
|
||||
// The panel fits below inside the 1080-tall monitor but not inside
|
||||
// its work area, which ends at the taskbar's top at 1040.
|
||||
let monitor = Rect::new(0, 0, 1920, 1080);
|
||||
let work = Rect::new(0, 0, 1920, 1040);
|
||||
let bubble = Rect::from_xywh(100, 880, 200, 66);
|
||||
assert_eq!(
|
||||
place_near(bubble, (280, 120), monitor),
|
||||
Point::new(100, 954)
|
||||
);
|
||||
assert_eq!(
|
||||
place_near(bubble, (280, 120), work),
|
||||
Point::new(100, 880 - 120 - 8)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_probe_against_monitors() {
|
||||
let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)];
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct LocaleStrings {
|
||||
pub control_drag: String,
|
||||
pub control_ctrl_wheel: String,
|
||||
pub control_tray_click: String,
|
||||
/// Linux variant of `control_tray_click`: AppIndicator delivers no tray
|
||||
/// clicks, so every click opens the menu.
|
||||
pub control_tray_click_linux: String,
|
||||
pub tray_left_click: String,
|
||||
pub check_for_updates: String,
|
||||
pub checking_for_updates: String,
|
||||
@@ -105,6 +108,17 @@ impl LocaleStrings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls hint for the tray icon on the platform this build targets.
|
||||
/// Linux tray icons (AppIndicator) only open the menu, so the
|
||||
/// show/hide click is not offered there.
|
||||
pub fn tray_click_hint(&self) -> &str {
|
||||
if cfg!(target_os = "linux") {
|
||||
&self.control_tray_click_linux
|
||||
} else {
|
||||
&self.control_tray_click
|
||||
}
|
||||
}
|
||||
|
||||
/// Display name of a provider.
|
||||
pub fn provider_label(&self, provider: ProviderId) -> &str {
|
||||
match provider {
|
||||
@@ -141,6 +155,7 @@ fn english_strings() -> LocaleStrings {
|
||||
control_drag: "Drag: move/snap".into(),
|
||||
control_ctrl_wheel: "Ctrl+Wheel: resize".into(),
|
||||
control_tray_click: "Tray click: show/hide".into(),
|
||||
control_tray_click_linux: "Tray click: menu".into(),
|
||||
tray_left_click: "Left-click: show/hide".into(),
|
||||
check_for_updates: "Check for updates".into(),
|
||||
checking_for_updates: "Checking for updates...".into(),
|
||||
@@ -286,6 +301,10 @@ mod tests {
|
||||
("control_drag", strings.control_drag.as_str()),
|
||||
("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
|
||||
("control_tray_click", strings.control_tray_click.as_str()),
|
||||
(
|
||||
"control_tray_click_linux",
|
||||
strings.control_tray_click_linux.as_str(),
|
||||
),
|
||||
("tray_left_click", strings.tray_left_click.as_str()),
|
||||
] {
|
||||
assert!(!value.trim().is_empty(), "empty string: {name}");
|
||||
@@ -372,4 +391,15 @@ mod tests {
|
||||
assert_eq!(s.autostart_label(), expected);
|
||||
assert_eq!(s.provider_label(ProviderId::ChatGpt), "Codex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_click_hint_matches_the_platform() {
|
||||
let s = english_strings();
|
||||
let expected = if cfg!(target_os = "linux") {
|
||||
"Tray click: menu"
|
||||
} else {
|
||||
"Tray click: show/hide"
|
||||
};
|
||||
assert_eq!(s.tray_click_hint(), expected);
|
||||
}
|
||||
}
|
||||
@@ -267,7 +267,7 @@ pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
|
||||
hint(&s.control_right_click),
|
||||
hint(&s.control_drag),
|
||||
hint(&s.control_ctrl_wheel),
|
||||
hint(&s.control_tray_click),
|
||||
hint(s.tray_click_hint()),
|
||||
];
|
||||
|
||||
vec![
|
||||
@@ -453,6 +453,13 @@ mod tests {
|
||||
assert!(entries[4..]
|
||||
.iter()
|
||||
.all(|e| matches!(e, MenuEntry::Hint { .. })));
|
||||
let strings = I18n::load().strings().clone();
|
||||
assert_eq!(
|
||||
entries[8],
|
||||
MenuEntry::Hint {
|
||||
label: strings.tray_click_hint().to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// One line per node, indented by depth: `label [id] kind`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Portable logic with no OS or Tauri dependency, unit-tested on every host.
|
||||
|
||||
pub mod color;
|
||||
pub mod desktop_entry;
|
||||
pub mod geom;
|
||||
pub mod i18n;
|
||||
pub mod menu_model;
|
||||
|
||||
@@ -196,6 +196,7 @@ mod tests {
|
||||
vec![(ProviderId::Claude, PollOutcome::Ok(windows))],
|
||||
&strings(),
|
||||
Instant::now(),
|
||||
SystemTime::now(),
|
||||
);
|
||||
state
|
||||
}
|
||||
@@ -251,6 +252,7 @@ mod tests {
|
||||
vec![(ProviderId::Claude, PollOutcome::AuthFailed)],
|
||||
&strings(),
|
||||
Instant::now(),
|
||||
SystemTime::now(),
|
||||
);
|
||||
let snap = state.snapshots.get(&ProviderId::Claude);
|
||||
let p = bubble_payload(ProviderId::Claude, snap, &strings(), false, CANVAS, now());
|
||||
@@ -269,6 +271,7 @@ mod tests {
|
||||
vec![(ProviderId::Claude, PollOutcome::Failed)],
|
||||
&strings(),
|
||||
Instant::now(),
|
||||
SystemTime::now(),
|
||||
);
|
||||
let snap = state.snapshots.get(&ProviderId::Claude);
|
||||
let panel = panel_payload(ProviderId::Claude, snap, &strings(), false);
|
||||
|
||||
+126
-7
@@ -231,10 +231,17 @@ pub fn load() -> Settings {
|
||||
load_from(&settings_path())
|
||||
}
|
||||
|
||||
/// [`load`] from an explicit path.
|
||||
/// [`load`] from an explicit path. A file that is not a JSON object at all
|
||||
/// is copied to `settings.json.bak` first, since the next save replaces it.
|
||||
pub fn load_from(path: &Path) -> Settings {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => parse_and_normalise(&content),
|
||||
Ok(content) => {
|
||||
let parsed = parse(&content);
|
||||
if parsed.is_none() {
|
||||
back_up_unreadable(path);
|
||||
}
|
||||
normalise(parsed.unwrap_or_default())
|
||||
}
|
||||
Err(err) => {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
log::warn!("settings read failed ({}): {err}", path.display());
|
||||
@@ -244,13 +251,65 @@ pub fn load_from(path: &Path) -> Settings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse settings JSON, falling back to the defaults when it is corrupt,
|
||||
/// then enforce the invariants a hand-edited file may break.
|
||||
/// Keep a copy of a settings file that could not be read at all, so the
|
||||
/// user can recover it by hand.
|
||||
fn back_up_unreadable(path: &Path) {
|
||||
let backup = path.with_extension("json.bak");
|
||||
match std::fs::copy(path, &backup) {
|
||||
Ok(_) => log::warn!(
|
||||
"settings file is not valid JSON; kept a copy at {}",
|
||||
backup.display()
|
||||
),
|
||||
Err(err) => log::warn!(
|
||||
"settings file is not valid JSON and could not be copied to {}: {err}",
|
||||
backup.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse settings JSON, keeping every readable key, then enforce the
|
||||
/// invariants a hand-edited file may break. A file that is not a JSON
|
||||
/// object yields the defaults.
|
||||
pub fn parse_and_normalise(content: &str) -> Settings {
|
||||
let mut settings: Settings = serde_json::from_str(content).unwrap_or_else(|err| {
|
||||
normalise(parse(content).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Parse settings JSON. A value of the wrong type for one key costs only
|
||||
/// that key, which falls back to its default; `None` when the content is
|
||||
/// not a JSON object.
|
||||
fn parse(content: &str) -> Option<Settings> {
|
||||
let err = match serde_json::from_str::<Settings>(content) {
|
||||
Ok(settings) => return Some(settings),
|
||||
Err(err) => err,
|
||||
};
|
||||
let Ok(serde_json::Value::Object(file)) = serde_json::from_str(content) else {
|
||||
log::warn!("settings file is not valid; using defaults: {err}");
|
||||
Settings::default()
|
||||
});
|
||||
return None;
|
||||
};
|
||||
log::warn!("settings file has invalid values; keeping the valid keys: {err}");
|
||||
let serde_json::Value::Object(mut merged) = serde_json::to_value(Settings::default()).ok()?
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
for (key, value) in file {
|
||||
// Unknown keys are ignored, as the strict parse does.
|
||||
if !merged.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let mut trial = merged.clone();
|
||||
trial.insert(key.clone(), value.clone());
|
||||
match serde_json::from_value::<Settings>(serde_json::Value::Object(trial)) {
|
||||
Ok(_) => {
|
||||
merged.insert(key, value);
|
||||
}
|
||||
Err(err) => log::warn!("settings key {key:?} ignored: {err}"),
|
||||
}
|
||||
}
|
||||
serde_json::from_value(serde_json::Value::Object(merged)).ok()
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
settings.show_claude_code = true;
|
||||
@@ -398,6 +457,66 @@ mod tests {
|
||||
assert!(settings.widget_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_wrongly_typed_key_keeps_the_others() {
|
||||
let settings = parse_and_normalise(
|
||||
r#"{"bubble_size_logical":250.5,"show_codex":true,"poll_interval_ms":60000}"#,
|
||||
);
|
||||
assert_eq!(settings.bubble_size_logical, DEFAULT_BUBBLE_SIZE);
|
||||
assert!(settings.show_codex);
|
||||
assert_eq!(settings.poll_interval_ms, POLL_1_MIN);
|
||||
// Keys missing from the file keep their defaults.
|
||||
assert!(settings.show_claude_code);
|
||||
assert_eq!(
|
||||
settings.update_check_interval_secs,
|
||||
Some(UPDATE_CHECK_HOURLY_SECS)
|
||||
);
|
||||
|
||||
let settings = parse_and_normalise(
|
||||
r#"{"show_codex":"yes","widget_visible":false,"last_update_check_unix":5,
|
||||
"bubble_positions":{"claude":[1,2]},"unknown":1}"#,
|
||||
);
|
||||
assert!(!settings.show_codex);
|
||||
assert!(!settings.widget_visible);
|
||||
assert_eq!(settings.last_update_check_unix, Some(5));
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::Claude),
|
||||
Some((1, 2))
|
||||
);
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"ccub-settings-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_file_is_backed_up_before_it_can_be_replaced() {
|
||||
let dir = temp_dir("backup");
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
let path = dir.join(SETTINGS_FILE);
|
||||
let backup = path.with_extension("json.bak");
|
||||
std::fs::write(&path, "{ not json").expect("write");
|
||||
assert!(load_from(&path).show_claude_code);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&backup).expect("backup"),
|
||||
"{ not json"
|
||||
);
|
||||
|
||||
// A file with only a bad value is salvaged, not backed up.
|
||||
std::fs::remove_file(&backup).expect("remove backup");
|
||||
std::fs::write(&path, r#"{"show_codex":true,"poll_interval_ms":"x"}"#).expect("write");
|
||||
assert!(load_from(&path).show_codex);
|
||||
assert!(!backup.exists());
|
||||
std::fs::remove_dir_all(&dir).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalise_restores_a_live_provider_and_clamps_size() {
|
||||
let settings = parse_and_normalise(
|
||||
|
||||
+156
-5
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::core::i18n::{self, LocaleStrings};
|
||||
use crate::usage::{ProviderId, UsageWindows};
|
||||
use crate::usage::{ProviderId, UsageWindows, Window};
|
||||
|
||||
/// Minimum gap between two cooldown-gated alerts (threshold and session
|
||||
/// expired), so notifications stay calm.
|
||||
@@ -18,6 +18,10 @@ pub const ALERT_COOLDOWN: Duration = Duration::from_secs(30 * 60);
|
||||
/// Usage thresholds that raise an alert the poll they are crossed.
|
||||
pub const ALERT_THRESHOLDS: [u8; 2] = [80, 95];
|
||||
|
||||
/// How far a provider's reported reset time may wander between polls of
|
||||
/// the same window.
|
||||
const RESET_JITTER: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Shortest countdown re-render delay.
|
||||
const MIN_COUNTDOWN_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
@@ -111,12 +115,15 @@ pub struct UsageState {
|
||||
impl UsageState {
|
||||
/// Apply one poll's results. A provider fires an alert only the poll its
|
||||
/// session usage crosses 80 or 95, and each alert must pass the
|
||||
/// cooldown. An empty batch changes nothing.
|
||||
/// cooldown. Usage in a session window that replaced the stored one
|
||||
/// counts as climbing from zero. An empty batch changes nothing.
|
||||
/// `now` drives the cooldown, `wall_now` the window-reset check.
|
||||
pub fn apply_results(
|
||||
&mut self,
|
||||
results: Vec<(ProviderId, PollOutcome)>,
|
||||
strings: &LocaleStrings,
|
||||
now: Instant,
|
||||
wall_now: SystemTime,
|
||||
) -> Applied {
|
||||
let mut applied = Applied::default();
|
||||
if results.is_empty() {
|
||||
@@ -128,7 +135,12 @@ impl UsageState {
|
||||
let entry = self.snapshots.entry(id).or_default();
|
||||
match outcome {
|
||||
PollOutcome::Ok(windows) => {
|
||||
let old_pct = entry.windows.primary.utilization;
|
||||
let old_pct =
|
||||
if is_new_window(&entry.windows.primary, &windows.primary, wall_now) {
|
||||
0.0
|
||||
} else {
|
||||
entry.windows.primary.utilization
|
||||
};
|
||||
let new_pct = windows.primary.utilization;
|
||||
for threshold in ALERT_THRESHOLDS {
|
||||
if old_pct < threshold as f64 && new_pct >= threshold as f64 {
|
||||
@@ -202,6 +214,26 @@ impl UsageState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `new` is a later window than the stored `old` one: the stored
|
||||
/// window's reset time has passed, or the reset time moved forward by more
|
||||
/// than [`RESET_JITTER`]. A reset time within the jitter of the stored one
|
||||
/// is the same window, even when that time has passed, since the provider
|
||||
/// still reports it.
|
||||
fn is_new_window(old: &Window, new: &Window, wall_now: SystemTime) -> bool {
|
||||
let Some(old_reset) = old.resets_at else {
|
||||
return false;
|
||||
};
|
||||
let near = |t: SystemTime| {
|
||||
let gap = t.duration_since(old_reset).unwrap_or_else(|e| e.duration());
|
||||
gap <= RESET_JITTER
|
||||
};
|
||||
match new.resets_at {
|
||||
Some(new_reset) if near(new_reset) => false,
|
||||
Some(new_reset) => old_reset <= wall_now || new_reset > old_reset,
|
||||
None => old_reset <= wall_now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Four-line tray tooltip: label, session, weekly and the click hint.
|
||||
pub fn tray_tooltip(
|
||||
label: &str,
|
||||
@@ -304,14 +336,131 @@ mod tests {
|
||||
}
|
||||
|
||||
fn poll(state: &mut UsageState, pct: f64, now: Instant) -> Applied {
|
||||
poll_window(state, windows(pct), now, SystemTime::now())
|
||||
}
|
||||
|
||||
fn poll_window(
|
||||
state: &mut UsageState,
|
||||
w: UsageWindows,
|
||||
now: Instant,
|
||||
wall_now: SystemTime,
|
||||
) -> Applied {
|
||||
let strings = I18n::load().strings().clone();
|
||||
state.apply_results(
|
||||
vec![(ProviderId::Claude, PollOutcome::Ok(windows(pct)))],
|
||||
vec![(ProviderId::Claude, PollOutcome::Ok(w))],
|
||||
&strings,
|
||||
now,
|
||||
wall_now,
|
||||
)
|
||||
}
|
||||
|
||||
/// A session window at `pct` that resets at `resets_at`.
|
||||
fn session(pct: f64, resets_at: SystemTime) -> UsageWindows {
|
||||
let mut w = windows(pct);
|
||||
w.primary.resets_at = Some(resets_at);
|
||||
w
|
||||
}
|
||||
|
||||
const FIVE_HOURS: Duration = Duration::from_secs(5 * 3_600);
|
||||
|
||||
#[test]
|
||||
fn a_reset_window_alerts_again_on_its_own_crossing() {
|
||||
let t0 = Instant::now();
|
||||
let wall0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let reset1 = wall0 + Duration::from_secs(3_600);
|
||||
let mut state = UsageState::default();
|
||||
assert_eq!(
|
||||
poll_window(&mut state, session(97.0, reset1), t0, wall0).alerts,
|
||||
[threshold(80)]
|
||||
);
|
||||
// After the reset the new window climbs to 85 and crosses 80 again.
|
||||
let t1 = t0 + 2 * ALERT_COOLDOWN;
|
||||
let wall1 = reset1 + Duration::from_secs(600);
|
||||
assert_eq!(
|
||||
poll_window(&mut state, session(85.0, reset1 + FIVE_HOURS), t1, wall1).alerts,
|
||||
[threshold(80)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reset_detected_by_a_later_reset_time_counts_too() {
|
||||
// The poll clock lags, but the provider already reports the next
|
||||
// window: its reset time moved forward by a whole window.
|
||||
let t0 = Instant::now();
|
||||
let wall0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let reset1 = wall0 + Duration::from_secs(60);
|
||||
let mut state = UsageState::default();
|
||||
poll_window(&mut state, session(97.0, reset1), t0, wall0);
|
||||
let t1 = t0 + 2 * ALERT_COOLDOWN;
|
||||
assert_eq!(
|
||||
poll_window(&mut state, session(85.0, reset1 + FIVE_HOURS), t1, wall0).alerts,
|
||||
[threshold(80)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_window_crosses_both_thresholds_in_order() {
|
||||
let t0 = Instant::now();
|
||||
let wall0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let reset1 = wall0 + Duration::from_secs(3_600);
|
||||
let reset2 = reset1 + FIVE_HOURS;
|
||||
let mut state = UsageState::default();
|
||||
poll_window(&mut state, session(97.0, reset1), t0, wall0);
|
||||
// Jumping to 96 crosses 80 and 95; the cooldown keeps the first.
|
||||
let t1 = t0 + 2 * ALERT_COOLDOWN;
|
||||
let wall1 = reset1 + Duration::from_secs(60);
|
||||
assert_eq!(
|
||||
poll_window(&mut state, session(96.0, reset2), t1, wall1).alerts,
|
||||
[threshold(80)]
|
||||
);
|
||||
// Climbing in two steps alerts at 80, then at 95 after the cooldown.
|
||||
let mut state = UsageState::default();
|
||||
poll_window(&mut state, session(97.0, reset1), t0, wall0);
|
||||
assert_eq!(
|
||||
poll_window(&mut state, session(85.0, reset2), t1, wall1).alerts,
|
||||
[threshold(80)]
|
||||
);
|
||||
let t2 = t1 + ALERT_COOLDOWN;
|
||||
assert_eq!(
|
||||
poll_window(&mut state, session(96.0, reset2), t2, wall1).alerts,
|
||||
[threshold(95)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_time_jitter_within_a_window_does_not_realert() {
|
||||
let t0 = Instant::now();
|
||||
let wall0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let reset = wall0 + Duration::from_secs(3_600);
|
||||
let mut state = UsageState::default();
|
||||
assert_eq!(
|
||||
poll_window(&mut state, session(85.0, reset), t0, wall0).alerts,
|
||||
[threshold(80)]
|
||||
);
|
||||
let later = t0 + 2 * ALERT_COOLDOWN;
|
||||
let jitter = Duration::from_secs(3);
|
||||
for (i, reported) in [reset + jitter, reset - jitter, reset]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let t = later + ALERT_COOLDOWN * u32::try_from(i).expect("small");
|
||||
assert!(poll_window(&mut state, session(88.0, reported), t, wall0)
|
||||
.alerts
|
||||
.is_empty());
|
||||
}
|
||||
// A poll just past the reset that still reports the same window is
|
||||
// the same window, not a fresh one at 88%.
|
||||
let past = reset + Duration::from_secs(5);
|
||||
assert!(poll_window(
|
||||
&mut state,
|
||||
session(88.0, reset),
|
||||
later + 4 * ALERT_COOLDOWN,
|
||||
past
|
||||
)
|
||||
.alerts
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
fn threshold(t: u8) -> Alert {
|
||||
Alert::Threshold {
|
||||
provider: ProviderId::Claude,
|
||||
@@ -374,6 +523,7 @@ mod tests {
|
||||
],
|
||||
&strings,
|
||||
Instant::now(),
|
||||
SystemTime::now(),
|
||||
);
|
||||
assert_eq!(applied.auth_failures, [ProviderId::Claude]);
|
||||
assert!(!state.last_poll_ok);
|
||||
@@ -398,12 +548,13 @@ mod tests {
|
||||
],
|
||||
&strings,
|
||||
Instant::now(),
|
||||
SystemTime::now(),
|
||||
);
|
||||
assert!(state.last_poll_ok);
|
||||
assert!(applied.auth_failures.is_empty());
|
||||
assert_eq!(state.snapshots[&ProviderId::ChatGpt].primary_text, "5%");
|
||||
assert_eq!(
|
||||
state.apply_results(Vec::new(), &strings, Instant::now()),
|
||||
state.apply_results(Vec::new(), &strings, Instant::now(), SystemTime::now()),
|
||||
Applied::default()
|
||||
);
|
||||
assert!(state.last_poll_ok);
|
||||
|
||||
@@ -106,6 +106,38 @@ mod tests {
|
||||
assert_eq!(token.expires_at_unix_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_changes_when_a_rewrite_changes_the_file_size() {
|
||||
use crate::creds::CredentialSource;
|
||||
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ccub-local-creds-{}-{}.json",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or_default()
|
||||
));
|
||||
let creds = LocalClaudeCreds {
|
||||
id: format!("local:{}", path.display()),
|
||||
path: path.clone(),
|
||||
};
|
||||
assert_eq!(creds.signature(), None, "missing file has no signature");
|
||||
|
||||
std::fs::write(&path, r#"{"claudeAiOauth":{"accessToken":"a"}}"#).expect("write");
|
||||
let before = creds.signature().expect("file exists");
|
||||
assert_eq!(
|
||||
Some(before.clone()),
|
||||
creds.signature(),
|
||||
"stable while unchanged"
|
||||
);
|
||||
|
||||
std::fs::write(&path, r#"{"claudeAiOauth":{"accessToken":"rotated"}}"#).expect("rewrite");
|
||||
let after = creds.signature().expect("file exists");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_ne!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_are_reported() {
|
||||
let err = parse_claude_json(&fixture("claude-credentials-missing-token.json")).unwrap_err();
|
||||
|
||||
+139
-5
@@ -1,8 +1,11 @@
|
||||
// Diagnostic logging facade backed by `log` + `simplelog`.
|
||||
//
|
||||
// `init(true)` redirects every `log::info!`/`log::warn!`/`log::error!` call
|
||||
// across the crate to a file in `%TEMP%`. With `init(false)` (the default,
|
||||
// i.e. no `--diagnose` flag) logging is a no-op.
|
||||
// across the crate to the file `log_path()` names: `%TEMP%` on Windows, and
|
||||
// on Unix a private `usage-monitor` folder (mode 0700) in the user's state
|
||||
// directory (`$XDG_STATE_HOME`, by default `~/.local/state`), with the file
|
||||
// itself readable by the owner only (mode 0600). With `init(false)` (the
|
||||
// default, i.e. no `--diagnose` flag) logging is a no-op.
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
@@ -11,21 +14,84 @@ use simplelog::{CombinedLogger, ConfigBuilder, LevelFilter, WriteLogger};
|
||||
|
||||
const LOG_FILE_NAME: &str = "usage-monitor.log";
|
||||
|
||||
/// The folder that holds the log on Unix, inside the state directory.
|
||||
#[cfg(unix)]
|
||||
const LOG_DIR_NAME: &str = "usage-monitor";
|
||||
|
||||
/// The updater plugin logs the proxy URL, credentials included, at debug
|
||||
/// level, so its messages are kept to info and above.
|
||||
const UPDATER_TARGET: &str = "tauri_plugin_updater";
|
||||
|
||||
/// Where `--diagnose` writes its log. On Linux this is
|
||||
/// `$XDG_STATE_HOME/usage-monitor/usage-monitor.log`, by default
|
||||
/// `~/.local/state/usage-monitor/usage-monitor.log`; on Windows
|
||||
/// `%TEMP%\usage-monitor.log`.
|
||||
pub fn log_path() -> PathBuf {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unix_log_dir(dirs::state_dir(), dirs::cache_dir(), std::env::temp_dir()).join(LOG_FILE_NAME)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::env::temp_dir().join(LOG_FILE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Unix log folder: under the state directory, else the cache
|
||||
/// directory, else the temp directory.
|
||||
#[cfg(unix)]
|
||||
fn unix_log_dir(state: Option<PathBuf>, cache: Option<PathBuf>, temp: PathBuf) -> PathBuf {
|
||||
state.or(cache).unwrap_or(temp).join(LOG_DIR_NAME)
|
||||
}
|
||||
|
||||
/// Create (or truncate) the log at `path`. On Unix its folder is created
|
||||
/// with mode 0700 and must be a directory owned by this user, so a folder
|
||||
/// someone else made in a shared temp directory is refused; the file is
|
||||
/// created with mode 0600, and an existing file is reset to 0600.
|
||||
fn open_log(path: &std::path::Path) -> std::io::Result<File> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::fs::{DirBuilder, OpenOptions, Permissions};
|
||||
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt};
|
||||
|
||||
if let Some(dir) = path.parent() {
|
||||
DirBuilder::new().recursive(true).mode(0o700).create(dir)?;
|
||||
let meta = std::fs::symlink_metadata(dir)?;
|
||||
// SAFETY: geteuid has no preconditions and cannot fail.
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
if !meta.is_dir() || meta.uid() != uid {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
format!("{} is not a folder owned by this user", dir.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
file.set_permissions(Permissions::from_mode(0o600))?;
|
||||
Ok(file)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
File::create(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialise file-based logging. Idempotent — second call is a no-op.
|
||||
///
|
||||
/// Returns the resolved log-file path on success, or `Ok(None)` when
|
||||
/// `enabled` is false. `Err` is only returned if the file could not be
|
||||
/// opened (e.g. read-only `%TEMP%`); callers may ignore the error.
|
||||
/// opened (e.g. a read-only log folder); callers may ignore the error.
|
||||
pub fn init(enabled: bool) -> std::io::Result<Option<PathBuf>> {
|
||||
if !enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = std::env::temp_dir().join(LOG_FILE_NAME);
|
||||
let file = File::create(&path)?;
|
||||
let path = log_path();
|
||||
let file = open_log(&path)?;
|
||||
let updater_file = file.try_clone()?;
|
||||
let app = ConfigBuilder::new()
|
||||
.add_filter_ignore_str(UPDATER_TARGET)
|
||||
@@ -41,3 +107,71 @@ pub fn init(enabled: bool) -> std::io::Result<Option<PathBuf>> {
|
||||
log::info!("diagnostic logging enabled at {}", path.display());
|
||||
Ok(Some(path))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn mode(path: &Path) -> u32 {
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_folder_prefers_state_then_cache_then_temp() {
|
||||
let state = Some(PathBuf::from("/home/me/.local/state"));
|
||||
let cache = Some(PathBuf::from("/home/me/.cache"));
|
||||
let temp = PathBuf::from("/tmp");
|
||||
assert_eq!(
|
||||
unix_log_dir(state, cache.clone(), temp.clone()),
|
||||
PathBuf::from("/home/me/.local/state/usage-monitor")
|
||||
);
|
||||
assert_eq!(
|
||||
unix_log_dir(None, cache, temp.clone()),
|
||||
PathBuf::from("/home/me/.cache/usage-monitor")
|
||||
);
|
||||
assert_eq!(
|
||||
unix_log_dir(None, None, temp),
|
||||
PathBuf::from("/tmp/usage-monitor")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_log_lives_in_the_state_directory() {
|
||||
let expected = dirs::state_dir()
|
||||
.unwrap()
|
||||
.join("usage-monitor")
|
||||
.join("usage-monitor.log");
|
||||
assert_eq!(log_path(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_is_private_and_truncated() {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("usage-monitor-diag-{}-{nanos}", std::process::id()));
|
||||
let dir = root.join(LOG_DIR_NAME);
|
||||
let path = dir.join(LOG_FILE_NAME);
|
||||
|
||||
let mut file = open_log(&path).unwrap();
|
||||
file.write_all(b"first run").unwrap();
|
||||
drop(file);
|
||||
assert_eq!(mode(&dir), 0o700);
|
||||
assert_eq!(mode(&path), 0o600);
|
||||
|
||||
// A file left readable by an older release is narrowed and emptied.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
drop(open_log(&path).unwrap());
|
||||
assert_eq!(mode(&path), 0o600);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"");
|
||||
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -13,7 +13,7 @@ use tauri::menu::{
|
||||
CheckMenuItem, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
|
||||
};
|
||||
use tauri::{AppHandle, Manager, State, Wry};
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
use tauri_plugin_autostart::{AutoLaunchManager, ManagerExt};
|
||||
|
||||
use crate::core::i18n::LocaleStrings;
|
||||
@@ -114,8 +114,27 @@ fn set_autostart(_app: &AppHandle, enable: bool) -> Result<(), String> {
|
||||
result.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Linux bypasses the autostart plugin too: it writes the AppImage path
|
||||
/// into `Exec` unquoted, so a path with spaces never starts (see
|
||||
/// `platform::linux::set_autostart`).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn autostart_state(_app: &AppHandle) -> Result<bool, String> {
|
||||
crate::platform::linux::autostart_enabled(crate::runtime::AUTOSTART_NAME)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn set_autostart(app: &AppHandle, enable: bool) -> Result<(), String> {
|
||||
crate::platform::linux::set_autostart(
|
||||
crate::runtime::AUTOSTART_NAME,
|
||||
app.env().appimage,
|
||||
enable,
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Without the plugin (it failed to set up) start at login reads as off.
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
fn autostart_state(app: &AppHandle) -> Result<bool, String> {
|
||||
if app.try_state::<AutoLaunchManager>().is_none() {
|
||||
return Ok(false);
|
||||
@@ -123,7 +142,7 @@ fn autostart_state(app: &AppHandle) -> Result<bool, String> {
|
||||
app.autolaunch().is_enabled().map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
fn set_autostart(app: &AppHandle, enable: bool) -> Result<(), String> {
|
||||
if app.try_state::<AutoLaunchManager>().is_none() {
|
||||
return Err("autostart plugin is not available".into());
|
||||
|
||||
+9
-2
@@ -19,6 +19,7 @@ use tauri::{
|
||||
WebviewWindowBuilder, Window, WindowEvent,
|
||||
};
|
||||
|
||||
use crate::bubble;
|
||||
use crate::core::geom::{self, Point, Rect};
|
||||
use crate::core::payload::PanelPayload;
|
||||
use crate::platform;
|
||||
@@ -245,7 +246,8 @@ fn show_for(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) {
|
||||
}
|
||||
|
||||
/// Size the panel for the bubble's DPI and place it next to the bubble,
|
||||
/// inside the union of all monitors. Without a bubble it stays where it is.
|
||||
/// inside the work area of the bubble's monitor. Without a bubble it stays
|
||||
/// where it is.
|
||||
fn place_near_bubble(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) {
|
||||
let Some(bubble) = app.get_webview_window(&runtime::bubble_label(provider)) else {
|
||||
log::warn!("no bubble for {}; panel not moved", provider.slug());
|
||||
@@ -283,7 +285,12 @@ fn place_near_bubble(app: &AppHandle, window: &WebviewWindow, provider: Provider
|
||||
i32::try_from(size.width).unwrap_or(i32::MAX),
|
||||
i32::try_from(size.height).unwrap_or(i32::MAX),
|
||||
);
|
||||
let bounds = virtual_screen(&runtime::monitor_rects(app)).unwrap_or(anchor);
|
||||
// The bubble's own work area keeps the panel off the taskbar and off
|
||||
// the gaps of a mixed-size layout; the union of all monitors and then
|
||||
// the bubble itself stand in only when no monitor is found.
|
||||
let bounds = bubble::work_area_of(&bubble)
|
||||
.or_else(|| virtual_screen(&runtime::monitor_rects(app)))
|
||||
.unwrap_or(anchor);
|
||||
let target = geom::place_near(anchor, panel_size, bounds);
|
||||
// Move first so a DPI change from crossing monitors resizes the window
|
||||
// before the explicit size, then place it again at the final size.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Linux-only helpers.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use x11rb::connection::Connection;
|
||||
@@ -8,6 +10,7 @@ use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt, KeyButMask, Window}
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
use super::FullscreenCheck;
|
||||
use crate::core::desktop_entry;
|
||||
|
||||
/// The AppIndicator libraries the tray backend loads, in its own order. The
|
||||
/// unversioned names come from the backend's `backcompat` feature (on by
|
||||
@@ -262,6 +265,52 @@ fn read_color_scheme() -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The start-at-login entry, `~/.config/autostart/<name>.desktop`: the file
|
||||
/// the autostart plugin wrote in earlier releases, so an entry made by one
|
||||
/// of them still reads as enabled and is removed by a disable.
|
||||
fn autostart_file(name: &str) -> io::Result<PathBuf> {
|
||||
let home = dirs::home_dir()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no home directory"))?;
|
||||
Ok(home
|
||||
.join(".config")
|
||||
.join("autostart")
|
||||
.join(format!("{name}.desktop")))
|
||||
}
|
||||
|
||||
/// Whether the start-at-login entry `name` exists.
|
||||
pub fn autostart_enabled(name: &str) -> io::Result<bool> {
|
||||
Ok(autostart_file(name)?.exists())
|
||||
}
|
||||
|
||||
/// Write or remove the start-at-login entry `name`. Enabling starts
|
||||
/// `appimage` when the process runs from an AppImage (its mounted
|
||||
/// executable moves on every run), otherwise the current executable. The
|
||||
/// path is quoted in `Exec`, so a path with spaces or shell characters
|
||||
/// starts the right file.
|
||||
pub fn set_autostart(name: &str, appimage: Option<OsString>, enable: bool) -> io::Result<()> {
|
||||
let file = autostart_file(name)?;
|
||||
if !enable {
|
||||
return match std::fs::remove_file(&file) {
|
||||
Err(err) if err.kind() != io::ErrorKind::NotFound => Err(err),
|
||||
_ => Ok(()),
|
||||
};
|
||||
}
|
||||
let exe = match appimage {
|
||||
Some(path) => PathBuf::from(path),
|
||||
None => std::env::current_exe()?,
|
||||
};
|
||||
let exe = exe.to_str().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("executable path is not UTF-8: {}", exe.display()),
|
||||
)
|
||||
})?;
|
||||
if let Some(dir) = file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&file, desktop_entry::autostart_entry(name, exe))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Live theme switching on Linux.
|
||||
//!
|
||||
//! tao reports a desktop theme change with a placeholder window id that
|
||||
//! tauri drops, so no window ever sees `ThemeChanged`. Instead one
|
||||
//! background thread listens on the session bus for the desktop portal's
|
||||
//! `SettingChanged` signal and applies a stated `color-scheme` preference.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use dbus::arg::{ArgType, RefArg, Variant};
|
||||
use dbus::blocking::Connection;
|
||||
use dbus::message::MatchRule;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::platform::linux::color_scheme_is_dark;
|
||||
use crate::runtime::Runtime;
|
||||
|
||||
const SETTINGS_INTERFACE: &str = "org.freedesktop.portal.Settings";
|
||||
const APPEARANCE_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
const COLOR_SCHEME_KEY: &str = "color-scheme";
|
||||
|
||||
/// How long one wait for bus traffic blocks before it is started again.
|
||||
const PROCESS_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Start the listener thread. It runs for the life of the process; without
|
||||
/// a session bus it logs once and ends, leaving the startup theme in place.
|
||||
pub fn spawn(app: &AppHandle) {
|
||||
let app = app.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("theme-listener".into())
|
||||
.spawn(move || listen(&app));
|
||||
if let Err(err) = spawned {
|
||||
log::error!("theme listener thread could not start: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn listen(app: &AppHandle) {
|
||||
let conn = match Connection::new_session() {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
log::warn!("no session bus; the theme will not follow the desktop: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let rule = MatchRule::new_signal(SETTINGS_INTERFACE, "SettingChanged");
|
||||
let handle = app.clone();
|
||||
let added = conn.add_match(
|
||||
rule,
|
||||
move |(namespace, key, value): (String, String, Variant<Box<dyn RefArg>>),
|
||||
_: &Connection,
|
||||
_: &dbus::Message| {
|
||||
let scheme = color_scheme_of(&namespace, &key, &value);
|
||||
if let Some(is_dark) = color_scheme_is_dark(scheme) {
|
||||
apply(&handle, is_dark);
|
||||
}
|
||||
true
|
||||
},
|
||||
);
|
||||
if let Err(err) = added {
|
||||
log::warn!(
|
||||
"portal settings signal not watched; the theme will not follow the desktop: {err}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
loop {
|
||||
if let Err(err) = conn.process(PROCESS_TIMEOUT) {
|
||||
log::warn!("theme listener stopped: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the new theme on the main thread, like every other UI change.
|
||||
fn apply(app: &AppHandle, is_dark: bool) {
|
||||
let handle = app.clone();
|
||||
let queued = app.run_on_main_thread(move || {
|
||||
if let Some(runtime) = handle.try_state::<Arc<Runtime>>() {
|
||||
runtime.set_dark(is_dark);
|
||||
}
|
||||
});
|
||||
if let Err(err) = queued {
|
||||
log::warn!("theme change could not reach the main thread: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The `color-scheme` value carried by one `SettingChanged` signal, or
|
||||
/// `None` for any other setting or a value that is not a `u32`. Variants
|
||||
/// are unwrapped however deeply the portal nests them.
|
||||
fn color_scheme_of(namespace: &str, key: &str, value: &dyn RefArg) -> Option<u32> {
|
||||
if namespace != APPEARANCE_NAMESPACE || key != COLOR_SCHEME_KEY {
|
||||
return None;
|
||||
}
|
||||
unwrap_u32(value)
|
||||
}
|
||||
|
||||
fn unwrap_u32(value: &dyn RefArg) -> Option<u32> {
|
||||
match value.arg_type() {
|
||||
ArgType::UInt32 => value.as_u64().and_then(|v| u32::try_from(v).ok()),
|
||||
ArgType::Variant => value.as_iter()?.next().and_then(unwrap_u32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn boxed(value: impl RefArg + 'static) -> Variant<Box<dyn RefArg>> {
|
||||
Variant(Box::new(value))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_scheme_is_read_from_its_own_setting_only() {
|
||||
let dark = boxed(1u32);
|
||||
assert_eq!(
|
||||
color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &dark),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
color_scheme_of(APPEARANCE_NAMESPACE, "accent-color", &dark),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
color_scheme_of("org.gnome.desktop.interface", COLOR_SCHEME_KEY, &dark),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_variants_unwrap_to_the_scheme() {
|
||||
let nested = boxed(Variant(2u32));
|
||||
assert_eq!(
|
||||
color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &nested),
|
||||
Some(2)
|
||||
);
|
||||
let bare: Box<dyn RefArg> = Box::new(0u32);
|
||||
assert_eq!(
|
||||
color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &bare),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_of_another_type_is_ignored() {
|
||||
let text = boxed("prefer-dark".to_string());
|
||||
assert_eq!(
|
||||
color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &text),
|
||||
None
|
||||
);
|
||||
let signed = boxed(1i32);
|
||||
assert_eq!(
|
||||
color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &signed),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux_theme;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
#[cfg(windows)]
|
||||
|
||||
+89
-30
@@ -166,8 +166,11 @@ pub struct Runtime {
|
||||
http: net::Client,
|
||||
registry: Mutex<Registry>,
|
||||
/// At most one poll runs at a time; a Refresh spam or a timer firing
|
||||
/// during a slow poll is dropped instead of stacking requests.
|
||||
/// during a slow poll is folded into one follow-up poll through
|
||||
/// `poll_requested` instead of stacking requests.
|
||||
poll_in_flight: AtomicBool,
|
||||
/// A poll was asked for while one was in flight.
|
||||
poll_requested: AtomicBool,
|
||||
/// The release the last check found, applied by the version menu item.
|
||||
/// Never locked together with `state`.
|
||||
pending_update: Mutex<Option<tauri_plugin_updater::Update>>,
|
||||
@@ -276,27 +279,54 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a poll on a worker thread unless one is already running.
|
||||
/// Start a poll on a worker thread. While one is running the request is
|
||||
/// queued instead, and the worker polls once more when it is done, so a
|
||||
/// provider enabled mid-poll still gets its data.
|
||||
///
|
||||
/// The flags use `SeqCst`: the requester stores `poll_requested` then
|
||||
/// loads `poll_in_flight`, while the worker stores `poll_in_flight` then
|
||||
/// swaps `poll_requested`, so at least one side always sees the other.
|
||||
pub fn spawn_poll(self: &Arc<Self>) {
|
||||
if self
|
||||
while self
|
||||
.poll_in_flight
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
log::debug!("poll already in flight; skipped");
|
||||
return;
|
||||
self.poll_requested.store(true, Ordering::SeqCst);
|
||||
if self.poll_in_flight.load(Ordering::SeqCst) {
|
||||
log::debug!("poll already in flight; queued another");
|
||||
return;
|
||||
}
|
||||
// The worker finished between the failed claim and the queued
|
||||
// request, so nobody will pick the request up: claim it again.
|
||||
}
|
||||
// The poll started here covers any request still queued.
|
||||
self.poll_requested.store(false, Ordering::SeqCst);
|
||||
let runtime = Arc::clone(self);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("poll".into())
|
||||
.spawn(move || {
|
||||
runtime.poll_once();
|
||||
runtime.poll_in_flight.store(false, Ordering::Release);
|
||||
runtime.propagate();
|
||||
});
|
||||
.spawn(move || runtime.run_polls());
|
||||
if let Err(err) = spawned {
|
||||
log::error!("poll thread could not start: {err}");
|
||||
self.poll_in_flight.store(false, Ordering::Release);
|
||||
self.poll_in_flight.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Body of the poll worker: poll until no request is queued, then hand
|
||||
/// the in-flight flag back.
|
||||
fn run_polls(self: &Arc<Self>) {
|
||||
loop {
|
||||
self.poll_once();
|
||||
self.propagate();
|
||||
if !self.poll_requested.swap(false, Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.poll_in_flight.store(false, Ordering::SeqCst);
|
||||
// A request that came after the last check but before the flag was
|
||||
// cleared was only queued; start it now.
|
||||
if self.poll_requested.swap(false, Ordering::SeqCst) {
|
||||
self.spawn_poll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,8 +344,10 @@ impl Runtime {
|
||||
.into_iter()
|
||||
.map(|(provider, result)| (provider, poll_outcome(provider, result)))
|
||||
.collect();
|
||||
let applied =
|
||||
self.with_state(|s| s.usage.apply_results(outcomes, &s.strings, Instant::now()));
|
||||
let applied = self.with_state(|s| {
|
||||
s.usage
|
||||
.apply_results(outcomes, &s.strings, Instant::now(), SystemTime::now())
|
||||
});
|
||||
self.deliver_alerts(applied.alerts);
|
||||
if !applied.auth_failures.is_empty() {
|
||||
self.refresh_credentials(&applied.auth_failures);
|
||||
@@ -515,7 +547,9 @@ impl Runtime {
|
||||
}
|
||||
|
||||
/// Clamp and store the bubble size, then resize every bubble around its
|
||||
/// centre (no snap, no position save); a no-op when it does not change.
|
||||
/// centre, kept inside its work area (no snap); a no-op when it does not
|
||||
/// change. A bubble with a saved position has it updated to where the
|
||||
/// resize put it; one still at its default spot stays unsaved.
|
||||
pub fn set_bubble_size(self: &Arc<Self>, size_logical: i32) {
|
||||
let size = size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||||
let changed = self.with_state(|s| s.settings.bubble_size_logical != size);
|
||||
@@ -523,7 +557,12 @@ impl Runtime {
|
||||
return;
|
||||
}
|
||||
self.update_settings(|s| s.bubble_size_logical = size);
|
||||
bubble::resize_all(&self.ui.app, size);
|
||||
for (provider, pos) in bubble::resize_all(&self.ui.app, size) {
|
||||
let saved = self.with_state(|s| s.settings.bubble_positions.get(provider).is_some());
|
||||
if saved {
|
||||
self.save_bubble_position(provider, (pos.x, pos.y));
|
||||
}
|
||||
}
|
||||
self.propagate();
|
||||
}
|
||||
|
||||
@@ -571,7 +610,12 @@ impl Runtime {
|
||||
// The apply may fail and keep this process running, so the
|
||||
// automatic check must stay armed.
|
||||
log::debug!("update check skipped while an update is applied");
|
||||
self.send(Message::UpdateCheckDone { interval_secs });
|
||||
// Not a failure: re-arm the full interval, as the apply
|
||||
// either relaunches or reports its own error.
|
||||
self.send(Message::UpdateCheckDone {
|
||||
interval_secs,
|
||||
succeeded: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
@@ -584,19 +628,21 @@ impl Runtime {
|
||||
if let Err(err) = spawned {
|
||||
log::error!("update check thread could not start: {err}");
|
||||
self.with_state(|s| s.update_status = UpdateStatus::Failed);
|
||||
self.send(Message::UpdateCheckDone { interval_secs });
|
||||
self.send(Message::UpdateCheckDone {
|
||||
interval_secs,
|
||||
succeeded: false,
|
||||
});
|
||||
self.refresh_shell(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking body of [`Self::check_for_updates`].
|
||||
/// 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) {
|
||||
let proxy = update::proxy_url(net::env_proxy().as_ref(), self.http.system_proxy());
|
||||
let outcome = update::check(&self.ui.app, proxy);
|
||||
let now_unix = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or_default();
|
||||
let succeeded = outcome.is_ok();
|
||||
let (status, found) = match outcome {
|
||||
Ok(CheckOutcome::UpToDate) => {
|
||||
log::info!("update check: up to date");
|
||||
@@ -619,13 +665,21 @@ impl Runtime {
|
||||
.unwrap_or_else(PoisonError::into_inner) = found;
|
||||
let (snapshot, interval_secs) = self.with_state(|s| {
|
||||
s.update_status = status;
|
||||
s.settings.last_update_check_unix = Some(now_unix);
|
||||
(s.settings.clone(), s.settings.update_check_interval_secs)
|
||||
let snapshot = succeeded.then(|| {
|
||||
s.settings.last_update_check_unix = Some(scheduler::unix_now());
|
||||
s.settings.clone()
|
||||
});
|
||||
(snapshot, s.settings.update_check_interval_secs)
|
||||
});
|
||||
if let Err(err) = settings::save(&snapshot) {
|
||||
log::warn!("settings save failed: {err}");
|
||||
if let Some(snapshot) = snapshot {
|
||||
if let Err(err) = settings::save(&snapshot) {
|
||||
log::warn!("settings save failed: {err}");
|
||||
}
|
||||
}
|
||||
self.send(Message::UpdateCheckDone { interval_secs });
|
||||
self.send(Message::UpdateCheckDone {
|
||||
interval_secs,
|
||||
succeeded,
|
||||
});
|
||||
self.refresh_shell(false);
|
||||
}
|
||||
|
||||
@@ -867,8 +921,8 @@ pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
// First, so a second instance exits before anything else starts.
|
||||
.plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {}));
|
||||
// Windows writes its own current-user entry (see `menu::autostart_enabled`).
|
||||
#[cfg(not(windows))]
|
||||
// Windows and Linux write their own entries (see `menu::autostart_enabled`).
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
let builder = {
|
||||
let autostart = tauri_plugin_autostart::Builder::new().app_name(AUTOSTART_NAME);
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -965,6 +1019,7 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
|
||||
http,
|
||||
registry: Mutex::new(Registry::with_defaults()),
|
||||
poll_in_flight: AtomicBool::new(false),
|
||||
poll_requested: AtomicBool::new(false),
|
||||
pending_update: Mutex::new(None),
|
||||
scheduler: tx,
|
||||
trays: Trays::default(),
|
||||
@@ -986,6 +1041,10 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
|
||||
scheduler::spawn(config, rx, Arc::clone(&runtime))?;
|
||||
runtime.send(Message::PollNow);
|
||||
|
||||
// tao's theme change never reaches a window on Linux; follow the portal.
|
||||
#[cfg(target_os = "linux")]
|
||||
platform::linux_theme::spawn(app.handle());
|
||||
|
||||
// Without the thread the bubbles just stay up over fullscreen apps.
|
||||
if let Err(err) = fullscreen::spawn(Arc::clone(&runtime)) {
|
||||
log::error!("fullscreen check thread could not start: {err}");
|
||||
|
||||
+229
-83
@@ -6,6 +6,11 @@
|
||||
//! thread only waits for the nearest deadline or the next message and hands
|
||||
//! 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`]).
|
||||
|
||||
use std::io;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
@@ -21,6 +26,18 @@ const MIN_POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
/// Shortest automatic update-check interval, for the same reason.
|
||||
const MIN_UPDATE_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// First retry delay after a failed update check; it doubles per failure
|
||||
/// in a row and never exceeds the check interval.
|
||||
const UPDATE_RETRY_BASE: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Longest the thread sleeps between passes, so a resume from sleep or a
|
||||
/// wall-clock deadline is noticed within about this long.
|
||||
const MAX_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How far wall time may run ahead of `Instant` between two passes before
|
||||
/// the gap counts as a sleep.
|
||||
const RESUME_THRESHOLD: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
/// Poll at once. The periodic poll deadline is left as it is.
|
||||
@@ -36,8 +53,13 @@ pub enum Message {
|
||||
interval_secs: Option<u64>,
|
||||
last_check_unix: Option<u64>,
|
||||
},
|
||||
/// An update check finished. Only this re-arms the full interval.
|
||||
UpdateCheckDone { interval_secs: 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.
|
||||
UpdateCheckDone {
|
||||
interval_secs: Option<u64>,
|
||||
succeeded: bool,
|
||||
},
|
||||
/// Stop the thread.
|
||||
Shutdown,
|
||||
}
|
||||
@@ -67,7 +89,10 @@ pub struct Deadlines {
|
||||
poll_interval: Duration,
|
||||
poll_at: Instant,
|
||||
countdown_at: Option<Instant>,
|
||||
update_at: Option<Instant>,
|
||||
/// Unix seconds the next update check is due at.
|
||||
update_at_unix: Option<u64>,
|
||||
/// Failed update checks in a row, which set the retry delay.
|
||||
update_failures: u32,
|
||||
}
|
||||
|
||||
impl Deadlines {
|
||||
@@ -79,12 +104,12 @@ impl Deadlines {
|
||||
poll_interval,
|
||||
poll_at: now + poll_interval,
|
||||
countdown_at: None,
|
||||
update_at: None,
|
||||
update_at_unix: None,
|
||||
update_failures: 0,
|
||||
};
|
||||
let actions = deadlines.arm_update(
|
||||
config.update_interval_secs,
|
||||
config.last_update_check_unix,
|
||||
now,
|
||||
now_unix,
|
||||
);
|
||||
(deadlines, actions)
|
||||
@@ -106,9 +131,20 @@ impl Deadlines {
|
||||
Message::SetUpdateInterval {
|
||||
interval_secs,
|
||||
last_check_unix,
|
||||
} => self.arm_update(interval_secs, last_check_unix, now, now_unix),
|
||||
Message::UpdateCheckDone { interval_secs } => {
|
||||
self.update_at = interval_secs.map(|secs| now + update_interval(secs));
|
||||
} => self.arm_update(interval_secs, last_check_unix, now_unix),
|
||||
Message::UpdateCheckDone {
|
||||
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()));
|
||||
Vec::new()
|
||||
}
|
||||
Message::Shutdown => Vec::new(),
|
||||
@@ -118,7 +154,7 @@ impl Deadlines {
|
||||
/// Fire every deadline that has passed. The poll re-arms for a full
|
||||
/// interval; 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) -> Vec<Action> {
|
||||
pub fn take_due(&mut self, now: Instant, now_unix: u64) -> Vec<Action> {
|
||||
let mut due = Vec::new();
|
||||
if self.poll_at <= now {
|
||||
due.push(Action::Poll);
|
||||
@@ -128,52 +164,84 @@ impl Deadlines {
|
||||
due.push(Action::Countdown);
|
||||
self.countdown_at = None;
|
||||
}
|
||||
if self.update_at.is_some_and(|at| at <= now) {
|
||||
if self.update_at_unix.is_some_and(|at| at <= now_unix) {
|
||||
due.push(Action::CheckForUpdates);
|
||||
self.update_at = None;
|
||||
self.update_at_unix = None;
|
||||
}
|
||||
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
|
||||
/// [`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;
|
||||
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) -> Duration {
|
||||
[Some(self.poll_at), self.countdown_at, self.update_at]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min()
|
||||
.map_or(self.poll_interval, |at| at.saturating_duration_since(now))
|
||||
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)));
|
||||
[
|
||||
Some(self.poll_at.saturating_duration_since(now)),
|
||||
self.countdown_at
|
||||
.map(|at| at.saturating_duration_since(now)),
|
||||
update,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min()
|
||||
.unwrap_or(self.poll_interval)
|
||||
}
|
||||
|
||||
fn arm_update(
|
||||
&mut self,
|
||||
interval_secs: Option<u64>,
|
||||
last_check_unix: Option<u64>,
|
||||
now: Instant,
|
||||
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) {
|
||||
None => {
|
||||
self.update_at = None;
|
||||
self.update_at_unix = None;
|
||||
Vec::new()
|
||||
}
|
||||
Some(UpdateCheckDue::Now) => {
|
||||
self.update_at = None;
|
||||
self.update_at_unix = None;
|
||||
vec![Action::CheckForUpdates]
|
||||
}
|
||||
Some(UpdateCheckDue::After(remaining)) => {
|
||||
self.update_at = Some(now + remaining);
|
||||
self.update_at_unix = Some(now_unix.saturating_add(remaining.as_secs()));
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
|
||||
fn update_interval(secs: u64) -> Duration {
|
||||
Duration::from_secs(secs).max(MIN_UPDATE_INTERVAL)
|
||||
}
|
||||
|
||||
/// Whether the wall clock ran ahead of `Instant` by more than
|
||||
/// [`RESUME_THRESHOLD`] between two passes, which means the machine slept
|
||||
/// (or the clock was set forward). `wall_elapsed` is `None` when the wall
|
||||
/// clock went backwards.
|
||||
pub fn resumed_from_sleep(instant_elapsed: Duration, wall_elapsed: Option<Duration>) -> bool {
|
||||
wall_elapsed.is_some_and(|wall| wall.saturating_sub(instant_elapsed) > RESUME_THRESHOLD)
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch; 0 for a clock set before 1970.
|
||||
pub fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
@@ -202,11 +270,29 @@ where
|
||||
}
|
||||
|
||||
fn run_loop<H: SchedulerHost>(mut deadlines: Deadlines, rx: &Receiver<Message>, host: &H) {
|
||||
let mut last_instant = Instant::now();
|
||||
let mut last_wall = SystemTime::now();
|
||||
loop {
|
||||
for action in deadlines.take_due(Instant::now()) {
|
||||
let (now, wall) = (Instant::now(), SystemTime::now());
|
||||
let instant_elapsed = now.saturating_duration_since(last_instant);
|
||||
let wall_elapsed = wall.duration_since(last_wall).ok();
|
||||
(last_instant, last_wall) = (now, wall);
|
||||
if resumed_from_sleep(instant_elapsed, wall_elapsed) {
|
||||
log::info!(
|
||||
"wall clock moved {wall_elapsed:?} while Instant moved {instant_elapsed:?}; \
|
||||
treating it as a resume from sleep"
|
||||
);
|
||||
for action in deadlines.resume(now) {
|
||||
host.run(action);
|
||||
}
|
||||
}
|
||||
for action in deadlines.take_due(now, unix_now()) {
|
||||
host.run(action);
|
||||
}
|
||||
let message = match rx.recv_timeout(deadlines.next_wakeup(Instant::now())) {
|
||||
let wait = deadlines
|
||||
.next_wakeup(Instant::now(), unix_now())
|
||||
.min(MAX_WAIT);
|
||||
let message = match rx.recv_timeout(wait) {
|
||||
Ok(Message::Shutdown) | Err(RecvTimeoutError::Disconnected) => return,
|
||||
Ok(message) => message,
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
@@ -243,11 +329,11 @@ mod tests {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, initial) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
assert!(initial.is_empty());
|
||||
assert_eq!(d.next_wakeup(t0), 5 * MIN);
|
||||
assert!(d.take_due(t0 + 4 * MIN).is_empty());
|
||||
assert_eq!(d.take_due(t0 + 5 * MIN), [Action::Poll]);
|
||||
assert_eq!(d.next_wakeup(t0 + 5 * MIN), 5 * MIN);
|
||||
assert_eq!(d.take_due(t0 + 10 * MIN), [Action::Poll]);
|
||||
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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -258,7 +344,7 @@ mod tests {
|
||||
d.handle(Message::PollNow, t0 + 2 * MIN, NOW_UNIX),
|
||||
[Action::Poll]
|
||||
);
|
||||
assert_eq!(d.take_due(t0 + 5 * MIN), [Action::Poll]);
|
||||
assert_eq!(d.take_due(t0 + 5 * MIN, NOW_UNIX), [Action::Poll]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -269,9 +355,9 @@ mod tests {
|
||||
assert!(d
|
||||
.handle(Message::SetPollInterval(MIN * 15), t1, NOW_UNIX)
|
||||
.is_empty());
|
||||
assert!(d.take_due(t0 + 5 * MIN).is_empty());
|
||||
assert_eq!(d.next_wakeup(t1), 15 * MIN);
|
||||
assert_eq!(d.take_due(t1 + 15 * MIN), [Action::Poll]);
|
||||
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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -280,7 +366,7 @@ mod tests {
|
||||
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), MIN_POLL_INTERVAL);
|
||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX), MIN_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -289,13 +375,13 @@ mod tests {
|
||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
let delay = Duration::from_secs(31);
|
||||
d.handle(Message::RecomputeCountdown(Some(delay)), t0, NOW_UNIX);
|
||||
assert_eq!(d.next_wakeup(t0), delay);
|
||||
assert_eq!(d.take_due(t0 + delay), [Action::Countdown]);
|
||||
assert!(d.take_due(t0 + 2 * delay).is_empty());
|
||||
assert_eq!(d.next_wakeup(t0, NOW_UNIX), delay);
|
||||
assert_eq!(d.take_due(t0 + delay, NOW_UNIX), [Action::Countdown]);
|
||||
assert!(d.take_due(t0 + 2 * delay, NOW_UNIX).is_empty());
|
||||
|
||||
d.handle(Message::RecomputeCountdown(Some(delay)), t0, NOW_UNIX);
|
||||
d.handle(Message::RecomputeCountdown(None), t0, NOW_UNIX);
|
||||
assert!(d.take_due(t0 + 2 * delay).is_empty());
|
||||
assert!(d.take_due(t0 + 2 * delay, NOW_UNIX).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -319,44 +405,118 @@ mod tests {
|
||||
NOW_UNIX,
|
||||
);
|
||||
assert!(initial.is_empty());
|
||||
let remaining = Duration::from_secs(3_000);
|
||||
let remaining = 3_000;
|
||||
assert!(!d
|
||||
.take_due(t0 + remaining - MIN)
|
||||
.take_due(t0, NOW_UNIX + remaining - 60)
|
||||
.contains(&Action::CheckForUpdates));
|
||||
assert_eq!(d.next_wakeup(t0 + remaining - MIN), MIN);
|
||||
assert_eq!(d.take_due(t0 + remaining), [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 {
|
||||
Message::UpdateCheckDone {
|
||||
interval_secs,
|
||||
succeeded,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_finished_check_rearms_the_full_interval() {
|
||||
let t0 = Instant::now();
|
||||
let hour = Duration::from_secs(UPDATE_CHECK_HOURLY_SECS);
|
||||
let (mut d, initial) =
|
||||
Deadlines::new(config(Some(UPDATE_CHECK_HOURLY_SECS), None), t0, NOW_UNIX);
|
||||
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!(!d.take_due(t0 + 3 * hour).contains(&Action::CheckForUpdates));
|
||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 3 * hour));
|
||||
|
||||
let t1 = t0 + 3 * hour;
|
||||
d.handle(
|
||||
Message::UpdateCheckDone {
|
||||
interval_secs: Some(UPDATE_CHECK_HOURLY_SECS),
|
||||
},
|
||||
t1,
|
||||
NOW_UNIX,
|
||||
);
|
||||
assert!(!d.take_due(t1 + hour / 2).contains(&Action::CheckForUpdates));
|
||||
assert!(d.take_due(t1 + hour).contains(&Action::CheckForUpdates));
|
||||
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(
|
||||
Message::UpdateCheckDone {
|
||||
interval_secs: Some(UPDATE_CHECK_HOURLY_SECS),
|
||||
},
|
||||
t2,
|
||||
NOW_UNIX,
|
||||
);
|
||||
assert!(d.take_due(t2 + hour).contains(&Action::CheckForUpdates));
|
||||
d.handle(done(Some(hour), true), t0, t2);
|
||||
assert!(check_due(&mut d, t0, t2 + hour));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_check_retries_with_a_doubling_delay_capped_at_the_interval() {
|
||||
let t0 = Instant::now();
|
||||
let hour = UPDATE_CHECK_HOURLY_SECS;
|
||||
let (mut d, _) = Deadlines::new(config(Some(hour), None), t0, 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));
|
||||
now += expected;
|
||||
}
|
||||
// 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));
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_update_deadline_follows_the_wall_clock_across_a_sleep() {
|
||||
// `Instant` does not advance while the machine sleeps, but the
|
||||
// check is still due once the wall clock passes its time.
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_polls_at_once_and_rearms_the_poll() {
|
||||
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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wall_clock_jump_past_instant_counts_as_a_resume() {
|
||||
let secs = Duration::from_secs;
|
||||
assert!(!resumed_from_sleep(MIN, Some(MIN)));
|
||||
// Ordinary scheduling slack is not a resume.
|
||||
assert!(!resumed_from_sleep(MIN, Some(MIN + secs(5))));
|
||||
assert!(!resumed_from_sleep(MIN, Some(MIN + RESUME_THRESHOLD)));
|
||||
assert!(resumed_from_sleep(
|
||||
MIN,
|
||||
Some(MIN + RESUME_THRESHOLD + secs(1))
|
||||
));
|
||||
assert!(resumed_from_sleep(secs(1), Some(secs(8 * 3_600))));
|
||||
// A clock set back is not a resume.
|
||||
assert!(!resumed_from_sleep(MIN, None));
|
||||
assert!(!resumed_from_sleep(MIN, Some(Duration::ZERO)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -393,13 +553,9 @@ mod tests {
|
||||
NOW_UNIX,
|
||||
);
|
||||
assert!(actions.is_empty());
|
||||
let remaining = Duration::from_secs(5 * DAY);
|
||||
assert!(!d
|
||||
.take_due(t0 + remaining - MIN)
|
||||
.contains(&Action::CheckForUpdates));
|
||||
assert!(d
|
||||
.take_due(t0 + remaining)
|
||||
.contains(&Action::CheckForUpdates));
|
||||
let remaining = 5 * DAY;
|
||||
assert!(!check_due(&mut d, t0, NOW_UNIX + remaining - 60));
|
||||
assert!(check_due(&mut d, t0, NOW_UNIX + remaining));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -418,20 +574,10 @@ mod tests {
|
||||
t0,
|
||||
NOW_UNIX,
|
||||
);
|
||||
assert!(!d
|
||||
.take_due(t0 + Duration::from_secs(7 * DAY))
|
||||
.contains(&Action::CheckForUpdates));
|
||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 7 * DAY));
|
||||
// A check that finishes after auto-check was turned off stays off.
|
||||
d.handle(
|
||||
Message::UpdateCheckDone {
|
||||
interval_secs: None,
|
||||
},
|
||||
t0,
|
||||
NOW_UNIX,
|
||||
);
|
||||
assert!(!d
|
||||
.take_due(t0 + Duration::from_secs(14 * DAY))
|
||||
.contains(&Action::CheckForUpdates));
|
||||
d.handle(done(None, true), t0, NOW_UNIX);
|
||||
assert!(!check_due(&mut d, t0, NOW_UNIX + 14 * DAY));
|
||||
}
|
||||
|
||||
/// Records actions on a channel, standing in for the runtime.
|
||||
|
||||
+151
-9
@@ -44,29 +44,38 @@ impl UsageProvider for ClaudeProvider {
|
||||
if token_is_expired(token.expires_at_unix_ms) {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
fetch_with_fallback(http, &token.access_token)
|
||||
fetch_with_fallback(http, &token.access_token, USAGE_URL, MESSAGES_URL)
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_with_fallback(http: &Client, token: &str) -> Result<UsageWindows, Error> {
|
||||
fn fetch_with_fallback(
|
||||
http: &Client,
|
||||
token: &str,
|
||||
usage_url: &str,
|
||||
messages_url: &str,
|
||||
) -> Result<UsageWindows, Error> {
|
||||
// First try the dedicated endpoint.
|
||||
match try_usage_endpoint(http, token)? {
|
||||
match try_usage_endpoint(http, token, usage_url)? {
|
||||
Some(windows) if has_reset_times(&windows) => return Ok(windows),
|
||||
Some(partial) => {
|
||||
// Got percentages but no reset times — fill them in from messages.
|
||||
if let Ok(fallback) = try_messages_endpoint(http, token) {
|
||||
if let Ok(fallback) = try_messages_endpoint(http, token, messages_url) {
|
||||
return Ok(merge_resets(partial, fallback));
|
||||
}
|
||||
return Ok(partial);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
try_messages_endpoint(http, token)
|
||||
try_messages_endpoint(http, token, messages_url)
|
||||
}
|
||||
|
||||
fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>, Error> {
|
||||
fn try_usage_endpoint(
|
||||
http: &Client,
|
||||
token: &str,
|
||||
url: &str,
|
||||
) -> Result<Option<UsageWindows>, Error> {
|
||||
let resp = match http
|
||||
.get(USAGE_URL)
|
||||
.get(url)
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("anthropic-beta", BETA_HEADER)
|
||||
.send()
|
||||
@@ -95,7 +104,7 @@ fn parse_usage_body(body: &[u8]) -> Option<UsageWindows> {
|
||||
Some(UsageWindows { primary, secondary })
|
||||
}
|
||||
|
||||
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> {
|
||||
fn try_messages_endpoint(http: &Client, token: &str, url: &str) -> Result<UsageWindows, Error> {
|
||||
for model in PROBE_MODELS {
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
@@ -103,7 +112,7 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
|
||||
"messages": [{"role": "user", "content": "."}],
|
||||
});
|
||||
let resp = match http
|
||||
.post(MESSAGES_URL)
|
||||
.post(url)
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("anthropic-version", API_VERSION)
|
||||
.header("anthropic-beta", BETA_HEADER)
|
||||
@@ -264,6 +273,8 @@ struct Bucket {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::test_server::{serve_once, Served};
|
||||
use crate::net::USER_AGENT;
|
||||
|
||||
fn at(secs: u64) -> Option<SystemTime> {
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
@@ -381,4 +392,135 @@ mod tests {
|
||||
assert!(token_is_expired(Some(0)));
|
||||
assert!(!token_is_expired(Some(i64::MAX)));
|
||||
}
|
||||
|
||||
// --- HTTP paths against a loopback server ---
|
||||
|
||||
const RATE_LIMIT_HEADERS: &[(&str, &str)] = &[
|
||||
("anthropic-ratelimit-unified-5h-utilization", "0.25"),
|
||||
("anthropic-ratelimit-unified-5h-reset", "1000"),
|
||||
("anthropic-ratelimit-unified-7d-utilization", "0.5"),
|
||||
("anthropic-ratelimit-unified-7d-reset", "2000"),
|
||||
];
|
||||
|
||||
fn client() -> Client {
|
||||
Client::without_proxy(USER_AGENT)
|
||||
}
|
||||
|
||||
fn fetch(usage: &Served, messages: &Served) -> Result<UsageWindows, Error> {
|
||||
fetch_with_fallback(&client(), "tok", &usage.base, &messages.base)
|
||||
}
|
||||
|
||||
fn received(served: &Served) -> String {
|
||||
served
|
||||
.request
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("server saw the request")
|
||||
}
|
||||
|
||||
fn assert_not_called(served: &Served) {
|
||||
assert!(
|
||||
served
|
||||
.request
|
||||
.recv_timeout(Duration::from_millis(300))
|
||||
.is_err(),
|
||||
"endpoint must not be called"
|
||||
);
|
||||
}
|
||||
|
||||
fn messages_with_headers() -> Served {
|
||||
serve_once("200 OK", RATE_LIMIT_HEADERS, b"{}".to_vec())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_auth_failures_stop_before_the_messages_fallback() {
|
||||
for status in ["401 Unauthorized", "403 Forbidden"] {
|
||||
let usage = serve_once(status, &[], b"{}".to_vec());
|
||||
let messages = messages_with_headers();
|
||||
let result = fetch(&usage, &messages);
|
||||
assert!(
|
||||
matches!(result, Err(Error::AuthRequired)),
|
||||
"{status}: {result:?}"
|
||||
);
|
||||
let request = received(&usage);
|
||||
assert!(request.starts_with("GET / HTTP/1.1\r\n"), "{request}");
|
||||
let lower = request.to_ascii_lowercase();
|
||||
assert!(lower.contains("authorization: bearer tok\r\n"), "{request}");
|
||||
assert!(
|
||||
lower.contains("anthropic-beta: oauth-2025-04-20\r\n"),
|
||||
"{request}"
|
||||
);
|
||||
assert_not_called(&messages);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_server_error_falls_back_to_messages_headers() {
|
||||
let usage = serve_once("500 Internal Server Error", &[], b"oops".to_vec());
|
||||
let messages = messages_with_headers();
|
||||
let w = fetch(&usage, &messages).expect("fallback windows");
|
||||
assert_eq!(w.primary.utilization, 25.0);
|
||||
assert_eq!(w.primary.resets_at, at(1000));
|
||||
assert_eq!(w.secondary.utilization, 50.0);
|
||||
assert_eq!(w.secondary.resets_at, at(2000));
|
||||
let request = received(&messages);
|
||||
assert!(request.starts_with("POST / HTTP/1.1\r\n"), "{request}");
|
||||
assert!(request
|
||||
.to_ascii_lowercase()
|
||||
.contains("anthropic-version: 2023-06-01\r\n"));
|
||||
assert!(request.contains("\"max_tokens\":1"), "{request}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unparsable_usage_body_falls_back_to_messages_headers() {
|
||||
let usage = serve_once("200 OK", &[], b"not json".to_vec());
|
||||
let messages = messages_with_headers();
|
||||
let w = fetch(&usage, &messages).expect("fallback windows");
|
||||
assert_eq!(w.primary.utilization, 25.0);
|
||||
assert_eq!(w.secondary.resets_at, at(2000));
|
||||
received(&messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_usage_body_skips_the_messages_call() {
|
||||
let usage = serve_once("200 OK", &[], fixture("anthropic-usage-full.json"));
|
||||
let messages = messages_with_headers();
|
||||
let w = fetch(&usage, &messages).expect("usage windows");
|
||||
assert_eq!(w.primary.utilization, 42.5);
|
||||
assert_eq!(w.primary.resets_at, at(1_761_040_800));
|
||||
assert_not_called(&messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_usage_body_merges_reset_times_from_messages() {
|
||||
let usage = serve_once("200 OK", &[], fixture("anthropic-usage-partial.json"));
|
||||
let messages = messages_with_headers();
|
||||
let w = fetch(&usage, &messages).expect("merged windows");
|
||||
assert_eq!(w.primary.utilization, 7.0, "usage percent is kept");
|
||||
assert_eq!(w.primary.resets_at, at(1000), "missing reset is filled in");
|
||||
assert_eq!(w.secondary.utilization, 61.0);
|
||||
assert_eq!(
|
||||
w.secondary.resets_at,
|
||||
at(1_709_251_199),
|
||||
"known reset is kept"
|
||||
);
|
||||
received(&messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_auth_failure_is_auth_required() {
|
||||
let usage = serve_once("503 Service Unavailable", &[], Vec::new());
|
||||
let messages = serve_once("401 Unauthorized", RATE_LIMIT_HEADERS, b"{}".to_vec());
|
||||
let result = fetch(&usage, &messages);
|
||||
assert!(matches!(result, Err(Error::AuthRequired)), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_without_rate_limit_headers_is_a_bad_response() {
|
||||
// The second probe model finds the one-shot server gone, which
|
||||
// counts as a failed probe too.
|
||||
let usage = serve_once("500 Internal Server Error", &[], Vec::new());
|
||||
let messages = serve_once("200 OK", &[], b"{}".to_vec());
|
||||
let result = fetch(&usage, &messages);
|
||||
assert!(matches!(result, Err(Error::BadResponse(_))), "{result:?}");
|
||||
}
|
||||
}
|
||||
+117
-26
@@ -35,35 +35,50 @@ impl UsageProvider for ChatGptProvider {
|
||||
fn poll(&mut self, http: &Client) -> Result<UsageWindows, Error> {
|
||||
let source = self.locator.first_available().ok_or(Error::NoCredentials)?;
|
||||
let token = source.read()?;
|
||||
let mut req = http
|
||||
.get(USAGE_URL)
|
||||
.header("Authorization", &format!("Bearer {}", token.access_token))
|
||||
.header("User-Agent", "codex-cli");
|
||||
if let Some(account_id) = token.account_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
req = req.header("ChatGPT-Account-Id", account_id);
|
||||
}
|
||||
let resp = match req.send() {
|
||||
Ok(r) => r,
|
||||
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
Err(e) => return Err(Error::Network(e)),
|
||||
};
|
||||
if resp.status() == 401 || resp.status() == 403 {
|
||||
fetch(
|
||||
http,
|
||||
USAGE_URL,
|
||||
&token.access_token,
|
||||
token.account_id.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage windows from the Codex usage endpoint at `url`. The account header
|
||||
/// is sent only when `account_id` is set and not empty.
|
||||
fn fetch(
|
||||
http: &Client,
|
||||
url: &str,
|
||||
token: &str,
|
||||
account_id: Option<&str>,
|
||||
) -> Result<UsageWindows, Error> {
|
||||
let mut req = http
|
||||
.get(url)
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("User-Agent", "codex-cli");
|
||||
if let Some(account_id) = account_id.filter(|s| !s.is_empty()) {
|
||||
req = req.header("ChatGPT-Account-Id", account_id);
|
||||
}
|
||||
let resp = match req.send() {
|
||||
Ok(r) => r,
|
||||
Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(Error::BadResponse(format!(
|
||||
"Codex usage endpoint returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
let body: Envelope = resp
|
||||
.json()
|
||||
.map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?;
|
||||
envelope_to_windows(body)
|
||||
.ok_or_else(|| Error::BadResponse("missing rate_limit section".into()))
|
||||
Err(e) => return Err(Error::Network(e)),
|
||||
};
|
||||
if resp.status() == 401 || resp.status() == 403 {
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(Error::BadResponse(format!(
|
||||
"Codex usage endpoint returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
let body: Envelope = resp
|
||||
.json()
|
||||
.map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?;
|
||||
envelope_to_windows(body).ok_or_else(|| Error::BadResponse("missing rate_limit section".into()))
|
||||
}
|
||||
|
||||
fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
|
||||
@@ -129,6 +144,8 @@ impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::test_server::{serve_once, Served};
|
||||
use crate::net::USER_AGENT;
|
||||
|
||||
fn at(secs: u64) -> Option<SystemTime> {
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
@@ -173,4 +190,78 @@ mod tests {
|
||||
assert_eq!(w.primary.resets_at, None);
|
||||
assert_eq!(w.secondary.resets_at, at(0));
|
||||
}
|
||||
|
||||
// --- HTTP path against a loopback server ---
|
||||
|
||||
fn fixture(name: &str) -> Vec<u8> {
|
||||
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
|
||||
}
|
||||
|
||||
fn fetch_from(served: &Served, account_id: Option<&str>) -> Result<UsageWindows, Error> {
|
||||
fetch(
|
||||
&Client::without_proxy(USER_AGENT),
|
||||
&served.base,
|
||||
"tok",
|
||||
account_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn received(served: &Served) -> String {
|
||||
served
|
||||
.request
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("server saw the request")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_maps_windows_and_sends_codex_headers() {
|
||||
let served = serve_once("200 OK", &[], fixture("chatgpt-usage-full.json"));
|
||||
let w = fetch_from(&served, None).expect("windows");
|
||||
assert_eq!(w.primary.utilization, 12.0);
|
||||
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
|
||||
let request = received(&served);
|
||||
assert!(
|
||||
request.contains("authorization: bearer tok\r\n"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(request.contains("user-agent: codex-cli\r\n"), "{request}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthorized_is_auth_required() {
|
||||
let served = serve_once("401 Unauthorized", &[], b"{}".to_vec());
|
||||
let result = fetch_from(&served, None);
|
||||
assert!(matches!(result, Err(Error::AuthRequired)), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_error_is_a_bad_response() {
|
||||
let served = serve_once("500 Internal Server Error", &[], b"{}".to_vec());
|
||||
let result = fetch_from(&served, None);
|
||||
assert!(matches!(result, Err(Error::BadResponse(_))), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_header_is_sent_only_with_an_account_id() {
|
||||
let body = || fixture("chatgpt-usage-full.json");
|
||||
let with_id = serve_once("200 OK", &[], body());
|
||||
fetch_from(&with_id, Some("acct-123")).expect("windows");
|
||||
let request = received(&with_id);
|
||||
assert!(
|
||||
request.contains("chatgpt-account-id: acct-123\r\n"),
|
||||
"{request}"
|
||||
);
|
||||
|
||||
for account_id in [None, Some("")] {
|
||||
let served = serve_once("200 OK", &[], body());
|
||||
fetch_from(&served, account_id).expect("windows");
|
||||
let request = received(&served);
|
||||
assert!(
|
||||
!request.contains("chatgpt-account-id"),
|
||||
"{account_id:?}: {request}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
-5
@@ -20,6 +20,17 @@ const EXTRA_HOME_DIRS: &[&str] = &[".local/bin", ".claude/local"];
|
||||
#[cfg(unix)]
|
||||
const EXTRA_SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
|
||||
|
||||
/// `codex exec` refuses to run outside a trusted directory or repository,
|
||||
/// and the refresh runs from the temp directory, so that check is skipped.
|
||||
/// The read-only sandbox keeps the one-shot prompt from changing any file.
|
||||
const CODEX_ARGS: &[&str] = &[
|
||||
"exec",
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
".",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Refreshed,
|
||||
@@ -42,9 +53,19 @@ impl Orchestrator {
|
||||
}
|
||||
|
||||
pub fn refresh(&self, source: &dyn CredentialSource) -> Outcome {
|
||||
self.refresh_with(source, spawn_cli)
|
||||
}
|
||||
|
||||
/// Spawn the refresh CLI through `spawn`, then wait for the credential
|
||||
/// signature to change. `spawn` returns `false` when no CLI could start.
|
||||
fn refresh_with(
|
||||
&self,
|
||||
source: &dyn CredentialSource,
|
||||
spawn: impl FnOnce(&RefreshHint) -> bool,
|
||||
) -> Outcome {
|
||||
let initial_sig = source.signature();
|
||||
let hint = source.refresh_hint();
|
||||
if !spawn_cli(&hint) {
|
||||
if !spawn(&hint) {
|
||||
return Outcome::CliMissing;
|
||||
}
|
||||
let start = Instant::now();
|
||||
@@ -73,7 +94,7 @@ fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static s
|
||||
RefreshHint::LocalClaudeCli => (&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
|
||||
RefreshHint::LocalCodexCli => (
|
||||
&["codex.cmd", "codex.ps1", "codex.exe", "codex"],
|
||||
&["exec", "."],
|
||||
CODEX_ARGS,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -82,18 +103,22 @@ fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static s
|
||||
fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) {
|
||||
match hint {
|
||||
RefreshHint::LocalClaudeCli => (&["claude"], &["-p", "."]),
|
||||
RefreshHint::LocalCodexCli => (&["codex"], &["exec", "."]),
|
||||
RefreshHint::LocalCodexCli => (&["codex"], CODEX_ARGS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the first candidate that starts. The child is detached: a reaper
|
||||
/// thread waits on it so no zombie is left behind.
|
||||
/// thread waits on it so no zombie is left behind. The child runs from the
|
||||
/// temp directory rather than the app's own working directory (the home
|
||||
/// folder, `/` or System32), so the CLI never runs inside a project or
|
||||
/// loads its instructions and hooks.
|
||||
fn spawn_first(candidates: &[&str], args: &[&str]) -> bool {
|
||||
for name in candidates {
|
||||
let Some(mut cmd) = command_for(name, args) else {
|
||||
continue;
|
||||
};
|
||||
cmd.env_remove("CLAUDECODE")
|
||||
cmd.current_dir(std::env::temp_dir())
|
||||
.env_remove("CLAUDECODE")
|
||||
.env_remove("CLAUDE_CODE_ENTRYPOINT")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
@@ -119,8 +144,14 @@ fn reap_detached(mut child: Child) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `cmd.exe` and `powershell.exe` always start, so a wrapped `.cmd` or
|
||||
/// `.ps1` would count as spawned even when the script is missing. Skip a
|
||||
/// candidate that is not on `PATH` so the next one gets its turn.
|
||||
#[cfg(windows)]
|
||||
fn command_for(name: &str, args: &[&str]) -> Option<Command> {
|
||||
if !on_path(name, std::env::var_os("PATH").as_deref()) {
|
||||
return None;
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let mut cmd = if lower.ends_with(".ps1") {
|
||||
let mut c = Command::new("powershell.exe");
|
||||
@@ -141,6 +172,19 @@ fn command_for(name: &str, args: &[&str]) -> Option<Command> {
|
||||
Some(cmd)
|
||||
}
|
||||
|
||||
/// Whether `name` is a file in an absolute `PATH` entry. A bare name with
|
||||
/// no extension also matches `name.exe`, as `Command` would find it.
|
||||
#[cfg(any(windows, test))]
|
||||
fn on_path(name: &str, path_var: Option<&std::ffi::OsStr>) -> bool {
|
||||
let Some(path_var) = path_var else {
|
||||
return false;
|
||||
};
|
||||
let bare = std::path::Path::new(name).extension().is_none();
|
||||
std::env::split_paths(path_var)
|
||||
.filter(|dir| dir.is_absolute())
|
||||
.any(|dir| dir.join(name).is_file() || (bare && dir.join(format!("{name}.exe")).is_file()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn command_for(name: &str, args: &[&str]) -> Option<Command> {
|
||||
let path_var = std::env::var_os("PATH");
|
||||
@@ -209,6 +253,129 @@ fn find_executable(name: &str, dirs: &[std::path::PathBuf]) -> Option<std::path:
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod orchestrator_tests {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::creds::{Error as CredsError, Locator, Token};
|
||||
|
||||
/// A source whose signature the test sets through a shared version
|
||||
/// number; version zero reports no signature, like a missing file.
|
||||
struct FakeSource {
|
||||
id: String,
|
||||
version: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
fn fake(id: &str, version: u32) -> (FakeSource, Arc<AtomicU32>) {
|
||||
let version = Arc::new(AtomicU32::new(version));
|
||||
let source = FakeSource {
|
||||
id: id.to_string(),
|
||||
version: Arc::clone(&version),
|
||||
};
|
||||
(source, version)
|
||||
}
|
||||
|
||||
impl CredentialSource for FakeSource {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn read(&self) -> Result<Token, CredsError> {
|
||||
Err(CredsError::Unavailable)
|
||||
}
|
||||
|
||||
fn signature(&self) -> Option<String> {
|
||||
match self.version.load(Ordering::SeqCst) {
|
||||
0 => None,
|
||||
v => Some(format!("{}|{v}", self.id)),
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_hint(&self) -> RefreshHint {
|
||||
RefreshHint::LocalCodexCli
|
||||
}
|
||||
}
|
||||
|
||||
fn fast_orchestrator() -> Orchestrator {
|
||||
Orchestrator {
|
||||
timeout: Duration::from_millis(30),
|
||||
poll_interval: Duration::from_millis(2),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_refresh_skips_the_repository_check_in_a_read_only_sandbox() {
|
||||
let (_, args) = cli_for(&RefreshHint::LocalCodexCli);
|
||||
assert_eq!(args.first(), Some(&"exec"));
|
||||
assert!(args.contains(&"--skip-git-repo-check"), "{args:?}");
|
||||
let sandbox = args
|
||||
.iter()
|
||||
.position(|a| *a == "--sandbox")
|
||||
.expect("sandbox flag");
|
||||
assert_eq!(args.get(sandbox + 1), Some(&"read-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_change_after_spawn_is_refreshed() {
|
||||
let (source, version) = fake("fake", 1);
|
||||
let outcome = fast_orchestrator().refresh_with(&source, |hint| {
|
||||
assert!(matches!(hint, RefreshHint::LocalCodexCli));
|
||||
version.store(2, Ordering::SeqCst);
|
||||
true
|
||||
});
|
||||
assert_eq!(outcome, Outcome::Refreshed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_signature_times_out() {
|
||||
let (source, _) = fake("fake", 1);
|
||||
let outcome = fast_orchestrator().refresh_with(&source, |_| true);
|
||||
assert_eq!(outcome, Outcome::Timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_spawn_is_cli_missing_without_waiting() {
|
||||
let (source, _) = fake("fake", 1);
|
||||
let orchestrator = Orchestrator {
|
||||
timeout: Duration::from_secs(60),
|
||||
poll_interval: Duration::from_secs(60),
|
||||
};
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
orchestrator.refresh_with(&source, |_| false),
|
||||
Outcome::CliMissing
|
||||
);
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_available_skips_missing_sources_and_keeps_order() {
|
||||
let (missing, _) = fake("missing", 0);
|
||||
let (second, _) = fake("second", 1);
|
||||
let (third, _) = fake("third", 1);
|
||||
let locator = Locator::new(vec![Box::new(missing), Box::new(second), Box::new(third)]);
|
||||
assert_eq!(locator.first_available().map(|s| s.id()), Some("second"));
|
||||
|
||||
let (only_missing, _) = fake("missing", 0);
|
||||
assert!(Locator::new(vec![Box::new(only_missing)])
|
||||
.first_available()
|
||||
.is_none());
|
||||
assert!(Locator::new(Vec::new()).first_available().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_available_prefers_an_earlier_source_once_it_appears() {
|
||||
let (first, first_version) = fake("first", 0);
|
||||
let (second, _) = fake("second", 1);
|
||||
let locator = Locator::new(vec![Box::new(first), Box::new(second)]);
|
||||
assert_eq!(locator.first_available().map(|s| s.id()), Some("second"));
|
||||
first_version.store(1, Ordering::SeqCst);
|
||||
assert_eq!(locator.first_available().map(|s| s.id()), Some("first"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::ffi::OsString;
|
||||
@@ -230,6 +397,27 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_path_finds_only_existing_files_in_absolute_entries() {
|
||||
let dir = temp_dir("on-path");
|
||||
write_file(&dir.join("claude.exe"), 0o644);
|
||||
write_file(&dir.join("codex.cmd"), 0o644);
|
||||
let path_var =
|
||||
std::env::join_paths([dir.clone(), PathBuf::from("relative")]).expect("join paths");
|
||||
let path_var = Some(path_var.as_os_str());
|
||||
assert!(on_path("codex.cmd", path_var));
|
||||
assert!(
|
||||
!on_path("claude.cmd", path_var),
|
||||
"a missing script is skipped"
|
||||
);
|
||||
assert!(on_path("claude", path_var), "a bare name matches its .exe");
|
||||
assert!(!on_path("codex", path_var), "a bare name ignores .cmd");
|
||||
assert!(!on_path("codex.cmd", None));
|
||||
let relative = std::env::join_paths([PathBuf::from("relative")]).expect("join");
|
||||
assert!(!on_path("codex.cmd", Some(relative.as_os_str())));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, mode: u32) {
|
||||
std::fs::write(path, "#!/bin/sh\n").expect("write");
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).expect("chmod");
|
||||
@@ -308,6 +496,32 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawned_child_runs_in_the_temp_directory() {
|
||||
let dir = temp_dir("cwd");
|
||||
let script = dir.join("fake-cli");
|
||||
let marker = dir.join("cwd");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("#!/bin/sh\npwd -P > '{}'\n", marker.display()),
|
||||
)
|
||||
.expect("write script");
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
assert!(spawn_first(&[script.to_str().expect("utf8 path")], &[]));
|
||||
// The file exists before `pwd` writes, so wait for its full line.
|
||||
let read = || std::fs::read_to_string(&marker).unwrap_or_default();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !read().ends_with('\n') && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let written = read();
|
||||
let expected = std::env::temp_dir()
|
||||
.canonicalize()
|
||||
.expect("temp dir resolves");
|
||||
assert_eq!(PathBuf::from(written.trim()), expected);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_path_resolves_env_interpreter_from_extra_dirs() {
|
||||
let tools = temp_dir("tools");
|
||||
|
||||
@@ -15,9 +15,13 @@ import {
|
||||
remainingFraction,
|
||||
ringDash,
|
||||
scale,
|
||||
TAIL_PCT_WEIGHT,
|
||||
tailLayout,
|
||||
timeFillWidth,
|
||||
usageFillWidth,
|
||||
WHEEL_IDLE,
|
||||
WHEEL_IDLE_MS,
|
||||
wheelSteps,
|
||||
} from '../../ui/layout.js';
|
||||
|
||||
/**
|
||||
@@ -130,6 +134,21 @@ describe('tailLayout', () => {
|
||||
assert.equal(tail.usageBarRect.r - tail.usageBarRect.l, 8);
|
||||
});
|
||||
|
||||
it('reserves the percent column at the weight it is drawn in', () => {
|
||||
const regular = 40;
|
||||
const semibold = 61.3;
|
||||
/** @type {Array<number | undefined>} */
|
||||
const pctWeights = [];
|
||||
const tail = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, (text, _px, weight) => {
|
||||
if (text === '100%') {
|
||||
pctWeights.push(weight);
|
||||
}
|
||||
return weight === TAIL_PCT_WEIGHT ? semibold : regular;
|
||||
});
|
||||
assert.deepEqual(pctWeights, [600]);
|
||||
assert.ok(tail.usagePctRect.r - tail.usagePctRect.l >= Math.ceil(semibold));
|
||||
});
|
||||
|
||||
it('rounds fractional measurements up', () => {
|
||||
const exact = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, () => 40);
|
||||
const fractional = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, () => 39.2);
|
||||
@@ -265,3 +284,79 @@ describe('headLabelText', () => {
|
||||
assert.equal(headLabelText('23h 59m', 59, 58), '5H');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wheelSteps', () => {
|
||||
const PIXEL = 0;
|
||||
const LINE = 1;
|
||||
const PAGE = 2;
|
||||
|
||||
/**
|
||||
* Feed wheel events through `wheelSteps` and collect the steps.
|
||||
* @param {Array<[number, number, number]>} events `[deltaY, deltaMode, ms]` triples.
|
||||
* @returns {number[]} Steps per event.
|
||||
*/
|
||||
const run = (events) => {
|
||||
let state = WHEEL_IDLE;
|
||||
return events.map(([deltaY, mode, ms]) => {
|
||||
const result = wheelSteps(state, deltaY, mode, ms);
|
||||
state = result.state;
|
||||
return result.steps;
|
||||
});
|
||||
};
|
||||
|
||||
it('gives exactly one step per mouse notch', () => {
|
||||
assert.deepEqual(run([[-100, PIXEL, 0]]), [1]);
|
||||
assert.deepEqual(run([[100, PIXEL, 0]]), [-1]);
|
||||
assert.deepEqual(run([[-100, PIXEL, 0], [-100, PIXEL, 50], [-100, PIXEL, 100]]), [1, 1, 1]);
|
||||
});
|
||||
|
||||
it('waits for 100 px of travel before a step', () => {
|
||||
assert.deepEqual(run([[-40, PIXEL, 0]]), [0]);
|
||||
assert.deepEqual(run([[-40, PIXEL, 0], [-40, PIXEL, 10], [-40, PIXEL, 20]]), [0, 0, 1]);
|
||||
assert.deepEqual(run([[-40, PIXEL, 0], [-40, PIXEL, 1000]]), [0, 0]);
|
||||
});
|
||||
|
||||
it('turns a stream of small touchpad deltas into one step per 100 px', () => {
|
||||
const events = /** @type {Array<[number, number, number]>} */ (
|
||||
Array.from({ length: 30 }, (_, i) => [-10, PIXEL, i * 10])
|
||||
);
|
||||
const steps = run(events);
|
||||
assert.equal(steps.reduce((a, b) => a + b, 0), 3);
|
||||
assert.ok(steps.every((s) => s === 0 || s === 1));
|
||||
});
|
||||
|
||||
it('starts a new gesture after an idle pause', () => {
|
||||
assert.deepEqual(run([[-60, PIXEL, 0], [-60, PIXEL, WHEEL_IDLE_MS + 1]]), [0, 0]);
|
||||
assert.deepEqual(run([[-60, PIXEL, 0], [-60, PIXEL, WHEEL_IDLE_MS]]), [0, 1]);
|
||||
});
|
||||
|
||||
it('starts a new gesture when the direction flips', () => {
|
||||
assert.deepEqual(run([[-150, PIXEL, 0], [10, PIXEL, 10]]), [1, 0]);
|
||||
assert.deepEqual(run([[-150, PIXEL, 0], [60, PIXEL, 10], [60, PIXEL, 20]]), [1, 0, -1]);
|
||||
});
|
||||
|
||||
it('ignores a few px of reversed jitter inside a gesture', () => {
|
||||
assert.deepEqual(
|
||||
run([[-10, PIXEL, 0], [-10, PIXEL, 10], [2, PIXEL, 20], [-10, PIXEL, 30], [1, PIXEL, 40], [-10, PIXEL, 50]]),
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
);
|
||||
// After a whole notch leaves no travel, a reverse twitch and the next
|
||||
// forward delta still wait for 100 px.
|
||||
assert.deepEqual(run([[-100, PIXEL, 0], [2, PIXEL, 10], [-10, PIXEL, 20]]), [1, 0, 0]);
|
||||
});
|
||||
|
||||
it('sends several steps for one large delta', () => {
|
||||
assert.deepEqual(run([[-250, PIXEL, 0], [-50, PIXEL, 10]]), [2, 1]);
|
||||
});
|
||||
|
||||
it('converts line and page deltas to px', () => {
|
||||
// Three lines are 48 px, so the third event crosses 100 px.
|
||||
const lines = run([[-3, LINE, 0], [-3, LINE, 10], [-3, LINE, 20], [-3, LINE, 30]]);
|
||||
assert.deepEqual(lines, [0, 0, 1, 0]);
|
||||
assert.deepEqual(run([[-1, PAGE, 0]]), [8]);
|
||||
});
|
||||
|
||||
it('ignores zero and non-finite deltas', () => {
|
||||
assert.deepEqual(run([[0, PIXEL, 0], [Number.NaN, PIXEL, 10]]), [0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,7 @@ body {
|
||||
}
|
||||
|
||||
.tail-pct {
|
||||
/* The layout reserves this column measured at TAIL_PCT_WEIGHT in layout.js; change both together. */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
+14
-5
@@ -15,6 +15,8 @@ import {
|
||||
tailLayout,
|
||||
timeFillWidth,
|
||||
usageFillWidth,
|
||||
WHEEL_IDLE,
|
||||
wheelSteps,
|
||||
} from './layout.js';
|
||||
import {
|
||||
forwardErrors,
|
||||
@@ -53,7 +55,7 @@ const DRAG_THRESHOLD_PX = 3;
|
||||
const PULSE_INTERVAL_MS = 80;
|
||||
const TIME_PROGRESS_INTERVAL_MS = 60_000;
|
||||
const FONT_STACK = '"Segoe UI", system-ui, sans-serif';
|
||||
/** Weight the layout's reserved text widths are measured at. */
|
||||
/** Weight text is measured at when the caller names none (regular). */
|
||||
const MEASURE_WEIGHT = 400;
|
||||
const HEAD_LABEL_WEIGHT = 600;
|
||||
|
||||
@@ -212,7 +214,7 @@ function relayout(p) {
|
||||
}
|
||||
const canvas = { canvasW: p.canvasW, canvasH: p.canvasH, dpi: p.dpi };
|
||||
head = headMetrics(canvas);
|
||||
tail = tailLayout(canvas, (text, px) => measure(text, px));
|
||||
tail = tailLayout(canvas, (text, px, weight) => measure(text, px, weight));
|
||||
layoutKey = key;
|
||||
|
||||
rings.setAttribute('viewBox', `0 0 ${p.canvasW} ${p.canvasH}`);
|
||||
@@ -447,6 +449,9 @@ window.addEventListener('contextmenu', (event) => {
|
||||
invoke('show_context_menu', { label: windowLabel }).catch(logFailure('show_context_menu'));
|
||||
});
|
||||
|
||||
/** Ctrl+wheel gesture carried between wheel events. */
|
||||
let wheelState = WHEEL_IDLE;
|
||||
|
||||
window.addEventListener(
|
||||
'wheel',
|
||||
(event) => {
|
||||
@@ -455,11 +460,15 @@ window.addEventListener(
|
||||
}
|
||||
// Ctrl+wheel would otherwise zoom the page.
|
||||
event.preventDefault();
|
||||
const dir = Math.sign(-event.deltaY);
|
||||
if (event.target !== pill || dir === 0) {
|
||||
if (event.target !== pill) {
|
||||
return;
|
||||
}
|
||||
invoke('bubble_resize_step', { dir }).catch(logFailure('bubble_resize_step'));
|
||||
const result = wheelSteps(wheelState, event.deltaY, event.deltaMode, event.timeStamp);
|
||||
wheelState = result.state;
|
||||
const dir = Math.sign(result.steps);
|
||||
for (let i = Math.abs(result.steps); i > 0; i -= 1) {
|
||||
invoke('bubble_resize_step', { dir }).catch(logFailure('bubble_resize_step'));
|
||||
}
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
+58
-4
@@ -1,4 +1,4 @@
|
||||
// Pure bubble geometry and colour helpers. Every length is in physical
|
||||
// Pure bubble geometry, colour and input helpers. Every length is in physical
|
||||
// pixels, driven by the canvas size and DPI that Rust sends in the payload,
|
||||
// never by `innerWidth * devicePixelRatio`. Integer steps truncate toward
|
||||
// zero, like the Rust `i32` arithmetic they mirror.
|
||||
@@ -15,6 +15,18 @@ const COUNTDOWN_TEMPLATE = '999d';
|
||||
/** Widest percent text the tail reserves room for. */
|
||||
const PCT_TEMPLATE = '100%';
|
||||
|
||||
/** Font weight of the tail percent; keep in step with `.tail-pct` in bubble.css. */
|
||||
export const TAIL_PCT_WEIGHT = 600;
|
||||
|
||||
/** Wheel travel, in px, that makes one Ctrl+wheel resize step. */
|
||||
export const WHEEL_STEP_PX = 100;
|
||||
|
||||
/** A pause this long, in ms, ends a Ctrl+wheel gesture. */
|
||||
export const WHEEL_IDLE_MS = 200;
|
||||
|
||||
/** `WheelEvent.deltaMode` values to px: pixel, line, page. */
|
||||
const WHEEL_MODE_PX = [1, 16, 800];
|
||||
|
||||
/** Usage at or above this percent pulses. */
|
||||
export const PULSE_THRESHOLD_PCT = 95;
|
||||
|
||||
@@ -67,13 +79,27 @@ const PULSE_STRENGTH = 0.3;
|
||||
*/
|
||||
|
||||
/**
|
||||
* Measures `text` set at `fontPx` (regular weight) in physical px.
|
||||
* Measures `text` set at `fontPx` and `weight` in physical px.
|
||||
* @callback MeasureText
|
||||
* @param {string} text Text to measure.
|
||||
* @param {number} fontPx Font size in physical px.
|
||||
* @param {number} [weight] CSS font weight; regular (400) when omitted.
|
||||
* @returns {number} Width in physical px.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ctrl+wheel gesture state between wheel events.
|
||||
* @typedef {object} WheelState
|
||||
* @property {number} acc Travel not yet turned into steps, in px; positive grows.
|
||||
* @property {number} lastMs Time of the previous event, in ms; -Infinity before any.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} WheelResult
|
||||
* @property {WheelState} state State to pass with the next event.
|
||||
* @property {number} steps Resize steps to send: positive grows, negative shrinks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} RingDash
|
||||
* @property {string} dashArray Value for `stroke-dasharray`.
|
||||
@@ -174,7 +200,7 @@ export function headMetrics(canvas) {
|
||||
* their right-aligned texts. When the tail is too narrow, the text column
|
||||
* shrinks (possibly to zero) before the bars drop below their minimum.
|
||||
* @param {Canvas} canvas Window canvas.
|
||||
* @param {MeasureText} measure Regular-weight text measurement.
|
||||
* @param {MeasureText} measure Text measurement.
|
||||
* @returns {TailLayout} Tail geometry in physical px.
|
||||
*/
|
||||
export function tailLayout(canvas, measure) {
|
||||
@@ -186,7 +212,7 @@ export function tailLayout(canvas, measure) {
|
||||
const barTextGap = scale(8, dpi);
|
||||
|
||||
const countdownW = Math.ceil(measure(COUNTDOWN_TEMPLATE, fonts.main));
|
||||
const pctReserveW = Math.ceil(measure(PCT_TEMPLATE, fonts.small)) + scale(2, dpi);
|
||||
const pctReserveW = Math.ceil(measure(PCT_TEMPLATE, fonts.small, TAIL_PCT_WEIGHT)) + scale(2, dpi);
|
||||
|
||||
const usageBarH = clamp(idiv(h * 10, 100), scale(6, dpi), scale(12, dpi));
|
||||
const timeBarH = clamp(idiv(h * 4, 100), scale(3, dpi), scale(6, dpi));
|
||||
@@ -341,3 +367,31 @@ export function timeFillWidth(barW, fraction) {
|
||||
export function headLabelText(label, labelW, rectW) {
|
||||
return label === '' || labelW > rectW ? HEAD_LABEL_FALLBACK : label;
|
||||
}
|
||||
|
||||
/** Wheel state before any Ctrl+wheel event. */
|
||||
export const WHEEL_IDLE = Object.freeze({ acc: 0, lastMs: -Infinity });
|
||||
|
||||
/**
|
||||
* Turn one Ctrl+wheel event into resize steps. Travel accumulates in px, so
|
||||
* a touchpad's stream of small deltas resizes gradually instead of one step
|
||||
* per event: a step is sent each time the gesture's travel reaches
|
||||
* `WHEEL_STEP_PX`. A pause of `WHEEL_IDLE_MS` or a change of direction starts
|
||||
* a new gesture from zero, so a few px of reversed jitter inside a touchpad
|
||||
* stream never resizes the other way.
|
||||
* @param {WheelState} state State from the previous event, or `WHEEL_IDLE`.
|
||||
* @param {number} deltaY `WheelEvent.deltaY`; negative scrolls up and grows.
|
||||
* @param {number} deltaMode `WheelEvent.deltaMode`.
|
||||
* @param {number} nowMs Event time in ms.
|
||||
* @returns {WheelResult} The next state and the steps to send.
|
||||
*/
|
||||
export function wheelSteps(state, deltaY, deltaMode, nowMs) {
|
||||
const px = -deltaY * (WHEEL_MODE_PX[deltaMode] ?? 1);
|
||||
if (!Number.isFinite(px) || px === 0) {
|
||||
return { state, steps: 0 };
|
||||
}
|
||||
const fresh = nowMs - state.lastMs > WHEEL_IDLE_MS || Math.sign(state.acc) === -Math.sign(px);
|
||||
const acc = fresh ? px : state.acc + px;
|
||||
// `|| 0` turns the -0 of a small reverse delta into a plain 0.
|
||||
const steps = Math.trunc(acc / WHEEL_STEP_PX) || 0;
|
||||
return { state: { acc: acc - steps * WHEEL_STEP_PX, lastMs: nowMs }, steps };
|
||||
}
|
||||
Reference in new issue
Block a user