mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-21 18:14:40 +00:00
Compare commits
63
Commits
main
...
feat/tauri-port
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d39feeb1 | ||
|
|
909013789b | ||
|
|
65d667b5e1 | ||
|
|
747de03eb2 | ||
|
|
2e13618837 | ||
|
|
c7ddf71797 | ||
|
|
1afb0f6981 | ||
|
|
2c1d10c47f | ||
|
|
6407e49499 | ||
|
|
fa889c176a | ||
|
|
08e61e6755 | ||
|
|
13fae90fd8 | ||
|
|
2671d5d89e | ||
|
|
37aa3cd2be | ||
|
|
fdf30ac005 | ||
|
|
9a9b01ff07 | ||
|
|
81f29f2e4f | ||
|
|
e712978ff6 | ||
|
|
6c7771e04c | ||
|
|
94019a13a3 | ||
|
|
435797ad91 | ||
|
|
14bdf95bc0 | ||
|
|
51d20b16b1 | ||
|
|
94722f2741 | ||
|
|
4918a5e3bd | ||
|
|
2397d48682 | ||
|
|
31cca4cdda | ||
|
|
d5fe3d8f27 | ||
|
|
43e0d825a7 | ||
|
|
8d7a07b237 | ||
|
|
9c7ade8ea3 | ||
|
|
388305de04 | ||
|
|
2180efaf85 | ||
|
|
768fb1e4ab | ||
|
|
e805f8de8f | ||
|
|
1370c15048 | ||
|
|
0d35c87d1e | ||
|
|
094b6bdcb0 | ||
|
|
340464de58 | ||
|
|
4da57949ee | ||
|
|
bee9565c35 | ||
|
|
974d9c1afd | ||
|
|
17d77bde82 | ||
|
|
f2063bda4f | ||
|
|
539f26019b | ||
|
|
a395c66248 | ||
|
|
83f60d1c1c | ||
|
|
6d84f33e59 | ||
|
|
607a3103d3 | ||
|
|
4ca4f075f3 | ||
|
|
b3afcdc04e | ||
|
|
b8e672f750 | ||
|
|
91d2741028 | ||
|
|
9e2666ff0e | ||
|
|
10c43d76af | ||
|
|
76ee0203cb | ||
|
|
bc1a098875 | ||
|
|
d8f339208d | ||
|
|
ffaa6c17a9 | ||
|
|
4e70aa1d22 | ||
|
|
2adbeeb613 | ||
|
|
f64d81e6a1 | ||
|
|
bb09765be5 |
No files matched your search
@@ -0,0 +1,96 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/tauri-port]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-22.04]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: npm
|
||||
|
||||
- name: Install Linux build prerequisites
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- run: cargo fmt --check
|
||||
|
||||
- run: cargo clippy --all-targets --locked -- -D warnings
|
||||
|
||||
- run: cargo test --locked
|
||||
|
||||
- run: npm run lint
|
||||
|
||||
- run: npm test
|
||||
|
||||
- run: npm run tauri build -- --no-bundle
|
||||
|
||||
# The bundler has no icon fallback, so a bad bundle.icon list only
|
||||
# breaks packaging; the deb needs no patchelf and catches it early.
|
||||
- name: Bundle the deb
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run tauri build -- --bundles deb
|
||||
|
||||
- name: Check the exe version resource
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$match = Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1
|
||||
$cargoVersion = $match.Matches.Groups[1].Value
|
||||
$info = (Get-Item target/release/claude-code-usage-bubble.exe).VersionInfo
|
||||
$info | Format-List ProductName, CompanyName, FileDescription, FileVersion, ProductVersion, LegalCopyright
|
||||
if ($info.ProductVersion -ne $cargoVersion) {
|
||||
Write-Error "ProductVersion '$($info.ProductVersion)' does not match Cargo.toml version '$cargoVersion'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Upload the Windows exe
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bubble-exe-${{ github.sha }}
|
||||
path: target/release/claude-code-usage-bubble.exe
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
macos-check:
|
||||
name: macos-latest (cargo check)
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- run: cargo check --all-targets --locked
|
||||
@@ -0,0 +1,173 @@
|
||||
name: Release legacy
|
||||
|
||||
# Rollback for a broken Tauri release: rebuild the Win32 sources at `ref`
|
||||
# under a higher version and publish them as a Windows-only release. Legacy
|
||||
# clients take the exe through the GitHub API, Tauri Windows clients through
|
||||
# the signed `latest.json`, and Tauri Linux clients find no entry for their
|
||||
# platform, which they report as up to date. The Win32 app accepts the
|
||||
# `--wait-pid <pid> --updated-to X` relaunch the Tauri updater uses.
|
||||
#
|
||||
# The build job holds a read-only token that is never written to disk, and
|
||||
# only the steps that check or use the updater signing key see it. The write
|
||||
# token lives in the publish job, which builds nothing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref holding the Win32 sources (e.g. v0.5.0)'
|
||||
required: true
|
||||
version:
|
||||
description: 'Version to publish as X.Y.Z, higher than every release in the field'
|
||||
required: true
|
||||
tag:
|
||||
description: 'Release tag, v<version>'
|
||||
required: true
|
||||
draft:
|
||||
description: 'Publish as a draft'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
win-x64:
|
||||
name: Windows x64 (legacy)
|
||||
runs-on: windows-latest
|
||||
outputs:
|
||||
sha: ${{ steps.sha.outputs.sha }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check the inputs and the signing key
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: |
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::version '$VERSION' is not X.Y.Z."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TAG" != "v$VERSION" ]; then
|
||||
echo "::error::tag '$TAG' must be v$VERSION."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY//[[:space:]]/}" ]; then
|
||||
echo "::error::The TAURI_SIGNING_PRIVATE_KEY secret is empty; an unsigned build cannot be released."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
# The legacy ref predates package.json and the release script, so take
|
||||
# both from the default branch.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
path: current
|
||||
persist-credentials: false
|
||||
|
||||
- name: Record the commit the release points at
|
||||
id: sha
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: npm
|
||||
cache-dependency-path: current/package-lock.json
|
||||
|
||||
# The Tauri CLI that signs the exe comes from the reviewed lockfile,
|
||||
# not from a version resolved at run time.
|
||||
- run: npm ci --prefix current
|
||||
|
||||
# Only the package's own version changes, in Cargo.toml and its
|
||||
# Cargo.lock entry, so the `--locked` build below resolves nothing and
|
||||
# fails if the lockfile differs in anything else. Git for Windows checks
|
||||
# the files out with CRLF endings, so the patterns accept a trailing CR
|
||||
# and keep it.
|
||||
- name: Set the Cargo.toml and Cargo.lock version
|
||||
run: |
|
||||
sed -i -E '0,/^version[[:space:]]*=/s/^version[[:space:]]*=[[:space:]]*"[^"]*"/version = "'"$VERSION"'"/' Cargo.toml
|
||||
actual=$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' Cargo.toml | head -n 1)
|
||||
if [ "$actual" != "$VERSION" ]; then
|
||||
echo "::error::Cargo.toml version is '$actual' after patching, expected '$VERSION'."
|
||||
exit 1
|
||||
fi
|
||||
sed -i -E '/^name = "claude-code-usage-bubble"\r?$/{n;s/^version = "[^"]*"(\r?)$/version = "'"$VERSION"'"\1/}' Cargo.lock
|
||||
if ! tr -d '\r' < Cargo.lock | grep -A1 -x 'name = "claude-code-usage-bubble"' | grep -qx "version = \"$VERSION\""; then
|
||||
echo "::error::Cargo.lock has no claude-code-usage-bubble entry at version '$VERSION' after patching."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- run: cargo build --release --locked
|
||||
|
||||
# `--no` refuses to download a CLI that the lockfile did not install.
|
||||
- name: Sign the exe
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
exe=target/release/claude-code-usage-bubble.exe
|
||||
npm exec --prefix current --no -- tauri signer sign --app-version "$VERSION" "$exe"
|
||||
test -s "$exe.sig"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-legacy-win-x64
|
||||
path: |
|
||||
target/release/claude-code-usage-bubble.exe
|
||||
target/release/claude-code-usage-bubble.exe.sig
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish
|
||||
needs: win-x64
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
TARGET: ${{ needs.win-x64.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-legacy-win-x64
|
||||
path: dist
|
||||
|
||||
- name: Write the Windows-only latest.json and check the asset set
|
||||
run: node scripts/make-latest-json.js --windows-only "$TAG" dist
|
||||
|
||||
- name: Create the release
|
||||
env:
|
||||
DRAFT: ${{ inputs.draft }}
|
||||
run: |
|
||||
if [ "$DRAFT" = true ]; then
|
||||
mode=(--draft)
|
||||
else
|
||||
mode=(--latest)
|
||||
fi
|
||||
gh release create "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --target "$TARGET" \
|
||||
--title "$TAG" --generate-notes "${mode[@]}"
|
||||
+242
-39
@@ -1,62 +1,265 @@
|
||||
name: Release
|
||||
|
||||
# A pushed plain vX.Y.Z tag publishes the release that every client updates
|
||||
# from: legacy Win32 clients (v0.5.0 and earlier) through the GitHub API's
|
||||
# `/releases/latest`, Tauri clients through its `latest.json` asset. A
|
||||
# dispatch run builds the same assets into a draft, which neither reaches.
|
||||
# Suffixed tags (vX.Y.Z-suffix) are dispatch-only: both client comparators
|
||||
# ignore the suffix, so publishing one would ship an rc as a stable update
|
||||
# and hide the final vX.Y.Z behind it.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to release (must already exist, e.g. v0.1.1)'
|
||||
description: 'Existing tag to build into a draft release (e.g. v0.0.0-test)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
prepare:
|
||||
name: Check the tag and the updater key
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
version: ${{ steps.tag.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
id: tag
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
else
|
||||
tag="$INPUT_TAG"
|
||||
fi
|
||||
if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Tag '$tag' is not of the form vX.Y.Z or vX.Y.Z-suffix."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ] && [[ "$tag" == *-* ]]; then
|
||||
echo "::error::Tag '$tag' has a suffix; suffixed tags are for draft dispatch runs only and are never published."
|
||||
exit 1
|
||||
fi
|
||||
expected="${tag#v}"
|
||||
cargo_version=$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' Cargo.toml | head -n 1)
|
||||
if [ "$cargo_version" != "$expected" ]; then
|
||||
echo "::error::Tag ($tag -> $expected) does not match Cargo.toml version ($cargo_version). Bump Cargo.toml before tagging."
|
||||
exit 1
|
||||
fi
|
||||
echo "Cargo version $cargo_version matches tag $tag"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$expected" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# An exe without the updater public key can never verify, and so never
|
||||
# install, a later update; every install of it would be stranded.
|
||||
# build.rs checks this only for a build that has the signing key in its
|
||||
# environment, so check it here, before any build runs.
|
||||
- name: Refuse an empty updater public key
|
||||
run: |
|
||||
pubkey=$(jq -r '.plugins.updater.pubkey // ""' tauri.conf.json)
|
||||
if [ -z "${pubkey//[[:space:]]/}" ]; then
|
||||
echo "::error::plugins.updater.pubkey in tauri.conf.json is empty: put the updater public key there before releasing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
win-x64:
|
||||
name: Windows x64
|
||||
needs: prepare
|
||||
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
|
||||
# `npm ci` install scripts, the setup actions or crate build scripts.
|
||||
steps:
|
||||
- name: Refuse a missing signing key
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY//[[:space:]]/}" ]; then
|
||||
echo "::error::The TAURI_SIGNING_PRIVATE_KEY secret is empty; an unsigned build cannot be released."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = if ($env:GITHUB_REF -like 'refs/tags/*') {
|
||||
$env:GITHUB_REF -replace '^refs/tags/',''
|
||||
} else {
|
||||
'${{ github.event.inputs.tag }}'
|
||||
}
|
||||
"tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: npm
|
||||
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = '${{ steps.tag.outputs.tag }}'
|
||||
$expected = $tag -replace '^v',''
|
||||
$cargoVersion = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1).Matches.Groups[1].Value
|
||||
if ($cargoVersion -ne $expected) {
|
||||
Write-Error "Tag ($tag -> $expected) does not match Cargo.toml version ($cargoVersion). Bump Cargo.toml before tagging."
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Cargo version $cargoVersion matches tag $tag"
|
||||
- run: npm ci
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --release --locked
|
||||
# The Windows release is the portable exe; no installer is built.
|
||||
- run: npm run tauri build -- --no-bundle -- --locked
|
||||
|
||||
- name: Create release
|
||||
# `signer sign` writes a separate .sig and leaves the exe bytes as
|
||||
# built, so the digest GitHub reports for the uploaded exe, which
|
||||
# legacy clients verify, is the digest of the file they run.
|
||||
- name: Sign the exe
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: pwsh
|
||||
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: |
|
||||
$tag = '${{ steps.tag.outputs.tag }}'
|
||||
$asset = 'target/release/claude-code-usage-bubble.exe'
|
||||
if ('${{ github.event_name }}' -eq 'workflow_dispatch') {
|
||||
gh release create $tag $asset --title $tag --generate-notes --draft
|
||||
} else {
|
||||
gh release create $tag $asset --title $tag --generate-notes
|
||||
exe=target/release/claude-code-usage-bubble.exe
|
||||
npx tauri signer sign --app-version "$VERSION" "$exe"
|
||||
test -s "$exe.sig"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-win-x64
|
||||
path: |
|
||||
target/release/claude-code-usage-bubble.exe
|
||||
target/release/claude-code-usage-bubble.exe.sig
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
linux-x64:
|
||||
name: Linux x64
|
||||
needs: prepare
|
||||
runs-on: ubuntu-22.04
|
||||
# As on Windows, only the steps that check or use the signing key see it.
|
||||
steps:
|
||||
- name: Refuse a missing signing key
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY//[[:space:]]/}" ]; then
|
||||
echo "::error::The TAURI_SIGNING_PRIVATE_KEY secret is empty; an unsigned build cannot be released."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: npm
|
||||
|
||||
- name: Install Linux build prerequisites
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- 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.
|
||||
- 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}}'
|
||||
|
||||
# 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.
|
||||
- name: Rename the bundles to space-free names
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
one() {
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "::error::Expected exactly one bundle, found $#: $*" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$1"
|
||||
}
|
||||
appimage=$(one target/release/bundle/appimage/*_amd64.AppImage)
|
||||
deb=$(one target/release/bundle/deb/*_amd64.deb)
|
||||
name="claude-code-usage-bubble_${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"
|
||||
ls -l dist
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-linux-x64
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish
|
||||
needs: [prepare, win-x64, linux-x64]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: release-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Write latest.json and check the asset set
|
||||
run: node scripts/make-latest-json.js "$TAG" dist
|
||||
|
||||
# Legacy clients read only `/releases/latest`, so a tag release must be
|
||||
# a non-draft, non-prerelease release marked latest.
|
||||
- name: Create the release
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then
|
||||
mode=(--draft)
|
||||
else
|
||||
mode=(--latest)
|
||||
fi
|
||||
gh release create "$TAG" dist/* --verify-tag --title "$TAG" --generate-notes "${mode[@]}"
|
||||
|
||||
- name: Check that /releases/latest is this release
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
run: |
|
||||
latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" -q .tag_name)
|
||||
if [ "$latest" != "$TAG" ]; then
|
||||
echo "::error::/releases/latest returns $latest, not $TAG; legacy clients would not see this release."
|
||||
exit 1
|
||||
fi
|
||||
@@ -1 +1,3 @@
|
||||
/target
|
||||
/node_modules
|
||||
/gen
|
||||
@@ -0,0 +1,6 @@
|
||||
ui/
|
||||
tests/
|
||||
scripts/
|
||||
plans/
|
||||
docs/
|
||||
node_modules/
|
||||
@@ -0,0 +1,10 @@
|
||||
# GitHub Actions workflows: `on:` is a key, not a boolean, the files carry
|
||||
# no document marker, and long shell lines read better unwrapped.
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
document-start: disable
|
||||
truthy:
|
||||
check-keys: false
|
||||
line-length:
|
||||
max: 140
|
||||
Generated
+4883
-288
File diff suppressed because it is too large.
Load diff
+56
-18
@@ -3,11 +3,24 @@ name = "claude-code-usage-bubble"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||
description = "Floating bubble showing Claude Code and Codex usage"
|
||||
homepage = "https://github.com/tiennm99/claude-code-usage-bubble"
|
||||
repository = "https://github.com/tiennm99/claude-code-usage-bubble"
|
||||
|
||||
[lib]
|
||||
name = "claude_code_usage_bubble_lib"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "claude-code-usage-bubble"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "macos-private-api"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
# rustls with the OS verifier keeps corporate roots trusted; gzip is a default.
|
||||
# socks-proxy serves SOCKS environment proxies such as ALL_PROXY=socks5h://.
|
||||
ureq = { version = "3", features = ["platform-verifier", "socks-proxy"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "6"
|
||||
@@ -15,30 +28,55 @@ log = "0.4"
|
||||
simplelog = "0.12"
|
||||
thiserror = "2"
|
||||
tiny-skia = "0.11"
|
||||
sha2 = "0.10"
|
||||
self-replace = "1.5"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
# Not used directly: Cargo's feature unification turns `socks` on for the
|
||||
# updater plugin's reqwest, so update requests can use a SOCKS env proxy.
|
||||
# Keep the major in step with the plugin's (`cargo tree -i reqwest`).
|
||||
reqwest = { version = "0.13", default-features = false, features = ["socks"] }
|
||||
|
||||
[dependencies.windows]
|
||||
version = "0.58"
|
||||
features = [
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
tauri-plugin-autostart = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
x11rb = "0.14"
|
||||
libloading = "0.9"
|
||||
# Same major as the copy tao pulls; reads the portal colour scheme.
|
||||
dbus = "0.9"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Keep the major in step with the `windows` crate tauri pulls
|
||||
# (`cargo tree -i windows`), so only one copy is compiled.
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Globalization",
|
||||
"Win32_Graphics_Dwm",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_System_Registry",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_Security",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_Networking_WinHttp",
|
||||
]
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_Graphics_Dwm",
|
||||
"Win32_Graphics_Gdi",
|
||||
] }
|
||||
# Same major as tauri's; used for the WebView2 runtime check.
|
||||
webview2-com = "0.39"
|
||||
# Same major as the copy auto-launch pulls; reads the system proxy.
|
||||
windows-registry = "0.6"
|
||||
# The start-at-login entry, pinned to the current user (the autostart
|
||||
# plugin's builder cannot set that mode).
|
||||
auto-launch = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
flate2 = "1"
|
||||
# The crypto provider the updater plugin installs before its reqwest client
|
||||
# is built; the SOCKS update test builds such a client itself.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||

|
||||

|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
# Claude Code Usage Bubble
|
||||
|
||||
A floating, draggable circular bubble that shows your Claude Code and/or
|
||||
Codex usage on Windows — inspired by the floating "memory boost ball" UX
|
||||
of 360 Security and IObit Advanced SystemCare.
|
||||
Codex usage on Windows and Linux — inspired by the floating "memory boost
|
||||
ball" UX of 360 Security and IObit Advanced SystemCare.
|
||||
|
||||
Drop it anywhere on screen, drag it around, snap it to a monitor edge,
|
||||
left-click for a panel with both your 5-hour and 7-day windows, right-click
|
||||
for the menu.
|
||||
|
||||
Linux ARM64 and macOS are not supported yet; see [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Inspired by [CodeZeno/Claude-Code-Usage-Monitor](https://github.com/CodeZeno/Claude-Code-Usage-Monitor),
|
||||
@@ -35,25 +38,54 @@ self-updater are all written from scratch against the same public APIs
|
||||
bars plus reset countdowns
|
||||
- Right-click for refresh, displayed providers, update frequency, startup,
|
||||
updates, exit
|
||||
- Optional system tray icons (one per enabled provider)
|
||||
- A system tray icon per enabled provider, with a usage badge
|
||||
- Desktop notifications when usage crosses a threshold, when a sign-in
|
||||
expires and after an update was applied
|
||||
- Auto-hide when a fullscreen app is in the foreground (games, video,
|
||||
presentations) — reappears when you leave fullscreen
|
||||
|
||||
## Who this is for
|
||||
|
||||
Windows 10/11 users who already have **Claude Code (CLI or App) installed
|
||||
and signed in on Windows**. Codex support is optional — install and sign in
|
||||
to the Codex CLI, then enable Codex from the right-click **Providers** menu.
|
||||
Windows 10/11 and Linux x86_64 users who already have **Claude Code (CLI or
|
||||
App) installed and signed in** on the same machine. Codex support is
|
||||
optional — install and sign in to the Codex CLI, then enable Codex from the
|
||||
right-click **Providers** menu.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10 or Windows 11
|
||||
- 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
|
||||
|
||||
### Windows
|
||||
|
||||
The app draws its windows with the Microsoft Edge **WebView2 runtime**.
|
||||
Windows 11 ships it, and most up-to-date Windows 10 machines already have it.
|
||||
If it is missing, the app shows a message with the download link and exits;
|
||||
install the
|
||||
[Evergreen WebView2 runtime](https://developer.microsoft.com/microsoft-edge/webview2/)
|
||||
and start the app again.
|
||||
|
||||
### Linux
|
||||
|
||||
- **Runtime libraries:** WebKitGTK 4.1 (`libwebkit2gtk-4.1-0` on
|
||||
Debian/Ubuntu) and, for the tray icons, `libayatana-appindicator3-1`.
|
||||
The `.deb` pulls these in. Without an AppIndicator library the app runs
|
||||
without tray icons.
|
||||
- **A compositing window manager.** The bubble is a transparent window;
|
||||
without a compositor its corners are drawn opaque.
|
||||
- **X11 is the supported session.** Under Wayland the app runs through
|
||||
Xwayland, where positioning, snapping and always-on-top work. Without
|
||||
Xwayland (or with `GDK_BACKEND=wayland` set) the bubbles still show, but
|
||||
they cannot be positioned, snapped, kept on top or saved where you drop
|
||||
them.
|
||||
- **Tray limits:** Linux tray icons (AppIndicator) only open the menu. They
|
||||
show no tooltip, and a left click does not toggle the bubble; use the
|
||||
menu instead.
|
||||
|
||||
## Install
|
||||
|
||||
### Download the latest release
|
||||
### Windows
|
||||
|
||||
Grab `claude-code-usage-bubble.exe` from the
|
||||
[Releases page](https://github.com/tiennm99/claude-code-usage-bubble/releases/latest).
|
||||
@@ -64,31 +96,72 @@ run it. The app self-updates from the same Releases feed.
|
||||
"Windows protected your PC". Click **More info** → **Run anyway**. Code
|
||||
signing is on the roadmap.
|
||||
|
||||
### Linux x86_64
|
||||
|
||||
From the same
|
||||
[Releases page](https://github.com/tiennm99/claude-code-usage-bubble/releases/latest),
|
||||
pick one:
|
||||
|
||||
- `claude-code-usage-bubble_X.Y.Z_amd64.AppImage`: make it executable
|
||||
(`chmod +x`) and run it. The AppImage self-updates in place.
|
||||
- `claude-code-usage-bubble_X.Y.Z_amd64.deb`: install it with
|
||||
`sudo apt install ./claude-code-usage-bubble_X.Y.Z_amd64.deb`. The app
|
||||
still checks for updates, but installing one is left to you: download the
|
||||
new `.deb` and install it the same way.
|
||||
|
||||
### Build from source
|
||||
|
||||
Prerequisites: the [Rust toolchain](https://www.rust-lang.org/tools/install)
|
||||
and [Node.js](https://nodejs.org/) with npm. The Tauri CLI is a local dev
|
||||
dependency, so `npm ci` installs it.
|
||||
|
||||
On Windows:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/tiennm99/claude-code-usage-bubble
|
||||
cd claude-code-usage-bubble
|
||||
cargo build --release
|
||||
npm ci
|
||||
npm run tauri build -- --no-bundle
|
||||
```
|
||||
|
||||
The binary lands at `target/release/claude-code-usage-bubble.exe`.
|
||||
The binary lands at `target\release\claude-code-usage-bubble.exe`.
|
||||
|
||||
On Linux x86_64, install the build dependencies first (Debian/Ubuntu):
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
git clone https://github.com/tiennm99/claude-code-usage-bubble
|
||||
cd claude-code-usage-bubble
|
||||
npm ci
|
||||
npm run tauri build -- --no-bundle
|
||||
```
|
||||
|
||||
The binary lands at `target/release/claude-code-usage-bubble`. To build the
|
||||
`.deb` and AppImage instead, also install `patchelf` (the AppImage needs it)
|
||||
and run `npm run tauri build -- --bundles deb,appimage`; they land under
|
||||
`target/release/bundle/`.
|
||||
|
||||
Local builds carry no updater signature (`createUpdaterArtifacts` is off in
|
||||
`tauri.conf.json`), so they cannot be published as an update. Only the
|
||||
release workflow signs builds; see
|
||||
[docs/release-process.md](docs/release-process.md).
|
||||
|
||||
## Use
|
||||
|
||||
Run `claude-code-usage-bubble.exe`. The bubble appears near the bottom-right
|
||||
corner of your 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.
|
||||
Run the app. The bubble appears near the bottom-right corner of your
|
||||
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", controls, auto-update check (Disabled / Hourly / Daily / Weekly),
|
||||
manual "Check for updates", exit
|
||||
Windows" ("Start at login" on Linux), controls, auto-update check
|
||||
(Disabled / Hourly / Daily / Weekly), manual "Check for updates", exit
|
||||
- **Drag** anywhere — it floats on top of all other windows
|
||||
- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
|
||||
menu, to resize it
|
||||
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
|
||||
right-click opens the same menu
|
||||
- **Tray icon:** left-click toggles the bubble visibility, right-click opens
|
||||
the same menu (on Linux, any click opens the menu)
|
||||
|
||||
### Providers
|
||||
|
||||
@@ -103,23 +176,53 @@ Use the right-click **Providers** menu to choose what is shown:
|
||||
When multiple providers are shown, each gets its own bubble that you can position
|
||||
independently.
|
||||
|
||||
### Changes from the v0.5.0 Windows build
|
||||
|
||||
The app moved from a hand-written Win32 UI to Tauri. Most behaviour is the
|
||||
same; these differences are visible:
|
||||
|
||||
- The transparent corners around the round bubble no longer pass clicks
|
||||
through to the window below; they just ignore clicks.
|
||||
- Alerts are Windows toast notifications instead of tray balloons, so they
|
||||
no longer use a warning or info icon.
|
||||
- Proxy auto-configuration (PAC scripts and WPAD) is no longer used. A
|
||||
static system proxy, its bypass list and the `HTTPS_PROXY`-style
|
||||
environment variables still are, including a SOCKS `ALL_PROXY` such as
|
||||
`socks5h://127.0.0.1:7890`, for usage requests and updates alike.
|
||||
- Only one copy runs per signed-in user session, instead of one per machine.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
```powershell
|
||||
claude-code-usage-bubble.exe --diagnose
|
||||
```
|
||||
|
||||
This writes a log file to:
|
||||
|
||||
```text
|
||||
%TEMP%\claude-code-usage-bubble.log
|
||||
claude-code-usage-bubble.exe --diagnose # Windows
|
||||
claude-code-usage-bubble --diagnose # Linux .deb (pass the same flag to the AppImage)
|
||||
```
|
||||
|
||||
This writes a log file, truncated on each start, to:
|
||||
|
||||
| OS | Log file |
|
||||
|----|----------|
|
||||
| Windows | `%TEMP%\claude-code-usage-bubble.log` |
|
||||
| Linux | `$TMPDIR/claude-code-usage-bubble.log`, usually `/tmp/claude-code-usage-bubble.log` |
|
||||
|
||||
Settings are saved to:
|
||||
|
||||
```text
|
||||
%APPDATA%\ClaudeCodeUsageBubble\settings.json
|
||||
```
|
||||
| OS | Settings file |
|
||||
|----|---------------|
|
||||
| Windows | `%APPDATA%\ClaudeCodeUsageBubble\settings.json` |
|
||||
| Linux | `$XDG_CONFIG_HOME/ClaudeCodeUsageBubble/settings.json`, usually `~/.config/ClaudeCodeUsageBubble/settings.json` |
|
||||
|
||||
Other files the app writes:
|
||||
|
||||
| What | Windows | Linux |
|
||||
|------|---------|-------|
|
||||
| Update downloads and `update-error.log` | `%LOCALAPPDATA%\ClaudeCodeUsageBubble\updates\` | `~/.local/share/ClaudeCodeUsageBubble/updates/` |
|
||||
| Web view profile (cache, storage) | `%LOCALAPPDATA%\io.github.tiennm99.claudecodeusagebubble\` (WebView2) | `~/.local/share/io.github.tiennm99.claudecodeusagebubble/` (WebKitGTK) |
|
||||
|
||||
On Windows the app also writes its notification icon to
|
||||
`%LOCALAPPDATA%\ClaudeCodeUsageBubble\icon.png` and registers it under
|
||||
`HKCU\Software\Classes\AppUserModelId\io.github.tiennm99.claudecodeusagebubble`,
|
||||
which Windows needs to show toasts from a portable exe.
|
||||
|
||||
## Privacy and security
|
||||
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Roadmap
|
||||
|
||||
These items are undecided or planned for later. None of them is part of the
|
||||
current Tauri port.
|
||||
|
||||
- **Linux ARM64 builds.** Publish aarch64 Linux builds, including an AppImage,
|
||||
and add the `linux-aarch64` entry to the updater's `latest.json`. Linux ships
|
||||
x86_64 only for now.
|
||||
- **macOS build.** Ship an unsigned macOS build. The install notes must explain
|
||||
how to get past Gatekeeper with "Open Anyway" in System Settings, since the
|
||||
app is not notarized.
|
||||
- **Wayland-native positioning.** Position, snap and keep the bubble on top
|
||||
under native Wayland. Today these work only through Xwayland.
|
||||
@@ -0,0 +1,22 @@
|
||||
# TODO
|
||||
|
||||
Steps left before the Tauri v2 port on `feat/tauri-port` can ship. Do them in
|
||||
order.
|
||||
|
||||
1. **Create the updater signing key.** Run `npx tauri signer generate`, put the
|
||||
public key in `plugins.updater.pubkey` in `tauri.conf.json`, add the
|
||||
`TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
repository secrets, and keep an offline backup of the key pair. Until then
|
||||
the release workflow refuses to build and the app rejects every downloaded
|
||||
update.
|
||||
2. **Test on Windows 10 and 11.** Download the exe artifact from the latest CI
|
||||
run on `feat/tauri-port` and work through the checklist in
|
||||
[the final port review](plans/reports/code-reviewer-261021-1028-tauri-port-final.md).
|
||||
Check toast notifications first: a freshly downloaded exe may not show them.
|
||||
3. **Run the release dry run.** Follow the dry-run steps in
|
||||
[docs/release-process.md](docs/release-process.md). It creates a draft
|
||||
release and deletes it afterwards.
|
||||
4. **Merge into `main`.** This reaches production: the next tag publishes the
|
||||
Tauri build to existing users through self-update.
|
||||
5. **Rename to `usage-monitor`.** Follow
|
||||
[the rename plan](plans/261021-1226-rename-to-usage-monitor/plan.md).
|
||||
@@ -1,16 +1,69 @@
|
||||
// Compiles `res/icon.rc` into the binary as Windows PE resources.
|
||||
// `tauri-build` validates `tauri.conf.json` against the enabled crate
|
||||
// features and generates the capability schemas under `gen/`. On Windows it
|
||||
// also embeds the icon, a VERSIONINFO and the application manifest below.
|
||||
//
|
||||
// `embed-resource` shells out to `rc.exe` (when a Windows SDK is on PATH) or
|
||||
// `windres` (MinGW) to produce a .res object that the linker bakes into the
|
||||
// .exe alongside our Rust code. The .rc file references `src/icons/icon.ico`
|
||||
// via a relative path; keep that path stable.
|
||||
// `tauri.conf.json` has no `version`, so `Cargo.toml` stays the only version
|
||||
// source. `tauri-build` writes VERSIONINFO only from the config version, so
|
||||
// the Cargo version is merged in through `TAURI_CONFIG`, which `try_build`
|
||||
// reads after the config file. Any `TAURI_CONFIG` passed by the CLI
|
||||
// (`--config`) is kept.
|
||||
//
|
||||
// A build that has the updater signing key in its environment is a release
|
||||
// build, so it must also carry the matching public key: without one, the
|
||||
// installed app could never verify, and so never install, a later update.
|
||||
//
|
||||
// The manifest keeps tauri's default Common-Controls v6 dependency and adds
|
||||
// PerMonitorV2 DPI awareness, so the process is DPI aware before any window
|
||||
// exists instead of relying on a runtime call.
|
||||
|
||||
fn main() {
|
||||
// `compile` returns a `CompilationResult` annotated `#[must_use]` on
|
||||
// embed-resource 3.x. `manifest_optional()` collapses Linux/Mac no-op
|
||||
// cases (where there's no `rc.exe`) into Ok while still surfacing real
|
||||
// failures on Windows.
|
||||
embed_resource::compile("res/icon.rc", embed_resource::NONE)
|
||||
.manifest_optional()
|
||||
.expect("Failed to compile Windows resources");
|
||||
inject_cargo_version();
|
||||
require_updater_pubkey_when_signing();
|
||||
|
||||
let windows = tauri_build::WindowsAttributes::new()
|
||||
.app_manifest(include_str!("res/windows-app-manifest.xml"));
|
||||
tauri_build::try_build(tauri_build::Attributes::new().windows_attributes(windows))
|
||||
.expect("failed to run the tauri build script");
|
||||
}
|
||||
|
||||
fn inject_cargo_version() {
|
||||
let mut config = match std::env::var("TAURI_CONFIG") {
|
||||
Ok(raw) => serde_json::from_str::<serde_json::Value>(&raw)
|
||||
.expect("TAURI_CONFIG must be a JSON object"),
|
||||
Err(_) => serde_json::json!({}),
|
||||
};
|
||||
let object = config
|
||||
.as_object_mut()
|
||||
.expect("TAURI_CONFIG must be a JSON object");
|
||||
object
|
||||
.entry("version")
|
||||
.or_insert_with(|| env!("CARGO_PKG_VERSION").into());
|
||||
std::env::set_var("TAURI_CONFIG", config.to_string());
|
||||
}
|
||||
|
||||
fn require_updater_pubkey_when_signing() {
|
||||
println!("cargo:rerun-if-env-changed=TAURI_SIGNING_PRIVATE_KEY");
|
||||
let signing = std::env::var("TAURI_SIGNING_PRIVATE_KEY").is_ok_and(|v| !v.trim().is_empty());
|
||||
if !signing {
|
||||
return;
|
||||
}
|
||||
let raw = std::fs::read_to_string("tauri.conf.json").expect("tauri.conf.json must be readable");
|
||||
let file: serde_json::Value =
|
||||
serde_json::from_str(&raw).expect("tauri.conf.json must be valid JSON");
|
||||
// `--config` overrides arrive through TAURI_CONFIG and win over the file.
|
||||
let overrides: serde_json::Value = std::env::var("TAURI_CONFIG")
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str(&raw).ok())
|
||||
.unwrap_or_default();
|
||||
const PUBKEY: &str = "/plugins/updater/pubkey";
|
||||
let pubkey = overrides
|
||||
.pointer(PUBKEY)
|
||||
.or_else(|| file.pointer(PUBKEY))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!pubkey.trim().is_empty(),
|
||||
"plugins.updater.pubkey in tauri.conf.json is empty, but TAURI_SIGNING_PRIVATE_KEY is set: \
|
||||
put the public key of that signing key there before building a release"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Core IPC and window dragging for the bubble and panel windows. Plugins are driven from Rust only.",
|
||||
"windows": ["bubble-*", "panel"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
+180
-17
@@ -1,14 +1,52 @@
|
||||
# Cutting a release
|
||||
|
||||
The `release.yml` workflow builds and publishes on every pushed tag
|
||||
matching `v*.*.*`. The workflow asserts that the pushed tag matches
|
||||
The `release.yml` workflow builds and publishes on every pushed plain
|
||||
`vX.Y.Z` tag. Pushing a tag with a suffix (`vX.Y.Z-rc1`) publishes
|
||||
nothing: the updaters ignore suffixes, so an rc would reach every user
|
||||
as a stable release. The workflow asserts that the pushed tag matches
|
||||
`Cargo.toml` `version` and **fails fast on mismatch**, so the order
|
||||
below matters: bump Cargo *before* you tag.
|
||||
|
||||
## Version source
|
||||
|
||||
The version lives in `Cargo.toml` only. `tauri.conf.json` has no
|
||||
`version` field, so the app, the Windows file properties (written by
|
||||
`build.rs`) and the bundles all inherit the Cargo version. Never add a
|
||||
`version` to `tauri.conf.json`.
|
||||
|
||||
## Signing key and secrets
|
||||
|
||||
Every release is signed with one minisign key pair, the updater key.
|
||||
Installed copies verify each update against the public key they were
|
||||
built with, so losing the private key means no installed copy can ever
|
||||
update again.
|
||||
|
||||
- The public key is `plugins.updater.pubkey` in `tauri.conf.json`.
|
||||
`release.yml` refuses to build while it is empty. `release-legacy.yml`
|
||||
does not read it, because the Win32 sources it builds have no
|
||||
`tauri.conf.json`; it checks only the private key.
|
||||
- The private key and its password are the repository secrets
|
||||
`TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`.
|
||||
Only the workflow steps that check or sign see them; dependency
|
||||
installs and compilation run without them. A signing step fails hard
|
||||
when the key is empty, so no unsigned build is published.
|
||||
- No job keeps the checkout token on disk, and only the publish jobs,
|
||||
which build nothing, hold the `contents: write` token.
|
||||
- Keep an **offline backup** of the private key file and its password
|
||||
(for example an encrypted copy on removable media and the password in
|
||||
a password manager). GitHub secrets cannot be read back, so the
|
||||
backup is the only copy that survives a lost or rotated secret.
|
||||
- Generate the pair once with `npx tauri signer generate`. Rotating it
|
||||
strands every installed copy on the old key, so do it only if the
|
||||
private key leaks.
|
||||
- `build.rs` refuses to build when `TAURI_SIGNING_PRIVATE_KEY` is set
|
||||
and the effective `pubkey` is empty, so a local build with the key in
|
||||
the environment cannot produce a release that never verifies updates.
|
||||
|
||||
## Steps for a new version
|
||||
|
||||
1. Bump `Cargo.toml` `version` (`X.Y.Z`).
|
||||
2. `cargo build --release` locally to refresh `Cargo.lock`.
|
||||
2. Run `cargo build` locally to refresh `Cargo.lock`.
|
||||
3. Commit: `chore: bump version to X.Y.Z`.
|
||||
4. Tag and push:
|
||||
|
||||
@@ -18,16 +56,135 @@ below matters: bump Cargo *before* you tag.
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
5. Watch the "Release" workflow run; it creates the GitHub Release
|
||||
with `claude-code-usage-bubble.exe` attached and auto-generated
|
||||
notes.
|
||||
5. Watch the "Release" workflow run. It 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.
|
||||
|
||||
## Testing without a real tag
|
||||
## Release assets
|
||||
|
||||
Use the workflow's `workflow_dispatch` input with a throwaway tag
|
||||
like `v0.0.0-test`. The release is created as a **draft**, so it
|
||||
does not show up on the public Releases feed or trigger self-updates
|
||||
for users.
|
||||
A release carries exactly these files and nothing else:
|
||||
|
||||
| Asset | Built by | Read by |
|
||||
|-------|----------|---------|
|
||||
| `claude-code-usage-bubble.exe` | Windows job, `npm run tauri build -- --no-bundle` | Legacy Win32 clients (GitHub API) and Tauri Windows clients (`latest.json`) |
|
||||
| `claude-code-usage-bubble.exe.sig` | `tauri signer sign` on the exe | Tauri Windows clients |
|
||||
| `claude-code-usage-bubble_X.Y.Z_amd64.AppImage` | Linux job, `tauri bundle` | Tauri AppImage clients |
|
||||
| `claude-code-usage-bubble_X.Y.Z_amd64.AppImage.sig` | the bundler, with the signing key | Tauri AppImage clients |
|
||||
| `claude-code-usage-bubble_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` |
|
||||
|
||||
`latest.json` holds `version`, `notes`, `pub_date` and exactly two
|
||||
platforms, `windows-x86_64` and `linux-x86_64`, each with the asset's
|
||||
release-download URL and the content of its `.sig`. There is no
|
||||
Linux ARM64 or macOS entry.
|
||||
|
||||
### Rename rule
|
||||
|
||||
The bundler names the Linux outputs after `productName`
|
||||
("Claude Code Usage Bubble"), 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:
|
||||
`claude-code-usage-bubble_<version>_amd64.AppImage`, its `.sig`, and
|
||||
`claude-code-usage-bubble_<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.
|
||||
|
||||
### Legacy-client asset contract
|
||||
|
||||
Clients from v0.5.0 and earlier read `/releases/latest` through the
|
||||
GitHub API, which skips drafts and prereleases. Checked against their
|
||||
updater code at v0.4.0, v0.4.1 and v0.5.0, they need:
|
||||
|
||||
- A non-draft, non-prerelease release marked latest in this repo.
|
||||
- A `tag_name` of plain `vX.Y.Z`, higher than the running version
|
||||
(suffixes are ignored when comparing). The first Tauri release must
|
||||
therefore be above v0.5.0.
|
||||
- An asset named `claude-code-usage-bubble.exe` (matched
|
||||
case-insensitively), else the first asset ending in `.exe`. The
|
||||
release must carry exactly one `.exe` asset, under that name. The
|
||||
`.exe.sig`, AppImage, deb and `latest.json` do not end in `.exe`.
|
||||
- The exe uploaded unmodified, so the `digest` (`sha256:<hex>`) that
|
||||
GitHub computes matches the downloaded body they hash. `tauri signer
|
||||
sign` writes a separate `.sig` and leaves the exe bytes untouched.
|
||||
- A new exe that starts from any of their hand-over modes: v0.4.0 runs
|
||||
it with `--updated-to X` only, v0.4.1 and v0.5.0 with
|
||||
`--wait-pid <pid> --updated-to X`, and v0.1.9 and earlier with no
|
||||
arguments. The Tauri exe accepts all three, and its startup cleanup
|
||||
deletes the `<exe>.old.<pid>`, `updates/update.exe` and
|
||||
`updates/updater-helper-*.exe` files they leave behind.
|
||||
|
||||
`scripts/make-latest-json.js` enforces the asset rules before
|
||||
`gh release create` runs: it fails on a missing platform entry or
|
||||
signature, on any `.exe` other than `claude-code-usage-bubble.exe`
|
||||
(in any case), on any asset name with a space, and on any file outside
|
||||
the exact set above. Tauri clients read only `latest.json`, whose URLs
|
||||
must equal the asset names exactly.
|
||||
|
||||
## Testing without a real tag (dry run)
|
||||
|
||||
A dry run creates a **draft** release, which does not show up on the
|
||||
public Releases feed and does not reach any client, legacy or Tauri.
|
||||
|
||||
1. Pick a throwaway tag with a suffix, like `v0.0.0-test`. The Tauri
|
||||
`release.yml` triggers tag pushes only on plain `vX.Y.Z` tags and
|
||||
refuses a suffixed tag outside a dispatch run, so the suffix keeps
|
||||
the tag push from starting a publishing run. That guard exists only
|
||||
in commits that carry the Tauri `release.yml`: the Win32
|
||||
`release.yml` it replaced triggers on `v*.*.*`, suffix included, and
|
||||
publishes a non-draft release that GitHub marks latest. Never use a
|
||||
plain `vX.Y.Z` tag for a dry run, because pushing it publishes a
|
||||
release marked latest.
|
||||
2. Cut a scratch branch from a commit that contains the Tauri
|
||||
`release.yml`: the Tauri port branch before it merges, `main` after.
|
||||
On it, set `Cargo.toml` `version` to `0.0.0-test`, run `cargo build`
|
||||
locally to refresh `Cargo.lock`, and commit both files. The release
|
||||
jobs build with `--locked`, so a stale `Cargo.lock` fails them
|
||||
before any draft exists. The tag check also runs for drafts, so the
|
||||
tag must point at a commit whose `Cargo.toml` version matches it.
|
||||
3. Tag that commit and push the tag, then start the "Release" workflow
|
||||
(`workflow_dispatch`) from the scratch branch with that tag as input:
|
||||
in the Actions tab, select the scratch branch under "Use workflow
|
||||
from", or run
|
||||
`gh workflow run release.yml --ref <scratch-branch> -f tag=v0.0.0-test`.
|
||||
The default ref is `main`, which runs whatever `release.yml` `main`
|
||||
holds; before the merge that is the Win32 pipeline, which builds one
|
||||
exe and no `latest.json`, so the check in step 4 cannot pass.
|
||||
4. Check the draft: it must hold exactly the six assets in the table
|
||||
above (with `0.0.0-test` as the version), and `latest.json` must list
|
||||
only `windows-x86_64` and `linux-x86_64`, with URLs equal to the
|
||||
asset names.
|
||||
5. Delete the draft release, the tag and the scratch branch.
|
||||
|
||||
## Rollback to the Win32 build
|
||||
|
||||
If a Tauri release is broken in the field, publish the Win32 sources
|
||||
again under a higher version with the "Release legacy" workflow
|
||||
(`release-legacy.yml`). GitHub dispatches a workflow only once its
|
||||
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.
|
||||
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`.
|
||||
3. The workflow patches the version at that ref in `Cargo.toml` and in
|
||||
the package's own `Cargo.lock` entry, so the build resolves no
|
||||
dependency. It builds with `cargo build --release --locked`, signs
|
||||
the exe with the same updater key and writes a Windows-only
|
||||
`latest.json`. Check the draft:
|
||||
it holds `claude-code-usage-bubble.exe`, its `.sig` and `latest.json`.
|
||||
4. Publish it: either edit the draft into a release marked latest, or
|
||||
delete the draft and re-run the workflow with `draft` set to `false`,
|
||||
which publishes it as latest.
|
||||
|
||||
Legacy clients take the exe through the GitHub API, Tauri Windows
|
||||
clients through the signed `latest.json`, and Tauri Linux clients find
|
||||
no entry for their platform, which they report as up to date. The
|
||||
Win32 app accepts the `--wait-pid <pid> --updated-to X` relaunch the
|
||||
Tauri updater uses.
|
||||
|
||||
## Versioning
|
||||
|
||||
@@ -36,10 +193,16 @@ breaking changes (e.g. `settings.json` schema change). The in-app
|
||||
updater compares `Version { major, minor, patch }` lexicographically,
|
||||
so a higher tuple wins.
|
||||
|
||||
## Asset name contract
|
||||
## Residual risk
|
||||
|
||||
The updater matches the release asset by exact filename
|
||||
`claude-code-usage-bubble.exe` (case-insensitive). Cargo's
|
||||
`name = "claude-code-usage-bubble"` already produces that name in
|
||||
`target/release/`, so the workflow uploads the file as-is — do not
|
||||
rename it.
|
||||
The hand-over from a legacy Win32 release to the Tauri exe has been
|
||||
checked only by reading the updater code at v0.4.0, v0.4.1 and v0.5.0;
|
||||
it has never run end to end before a release. Static reading cannot
|
||||
prove runtime behaviour such as antivirus or SmartScreen reactions to
|
||||
a self-replaced, larger, unsigned exe, WebView2 start-up from a
|
||||
detached relaunch, or the v0.4.0 helper timing. The first Tauri
|
||||
release is the first real run. Mitigations: the WebView2 guard, the
|
||||
`--diagnose` log, `update-error.log`, and the `release-legacy.yml`
|
||||
rollback, which rebuilds the Win32 sources at a given ref under a
|
||||
higher version as a Windows-only release. That rollback is itself
|
||||
unproven end to end.
|
||||
@@ -0,0 +1,26 @@
|
||||
import js from '@eslint/js';
|
||||
import jsdoc from 'eslint-plugin-jsdoc';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['target/', 'gen/', 'node_modules/'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
jsdoc.configs['flat/recommended-error'],
|
||||
{
|
||||
files: ['ui/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
__TAURI__: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['tests/**/*.js', 'scripts/**/*.js', 'eslint.config.js'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
];
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 515 B |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Generated
+1785
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "claude-code-usage-bubble",
|
||||
"private": true,
|
||||
"description": "Floating bubble showing Claude Code and Codex usage",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test \"tests/js/**/*.test.js\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "^2.12.3",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-jsdoc": "^65.4.0",
|
||||
"globals": "^17.13.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Scaffold Tauri crate, JS toolchain and CI"
|
||||
status: in-progress
|
||||
priority: P1
|
||||
effort: "5h"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 01: Scaffold Tauri Crate, JS Toolchain And CI
|
||||
|
||||
## Context
|
||||
|
||||
The crate today is Win32-only: `windows` 0.58, embed-resource and `res/icon.rc`
|
||||
(`Cargo.toml`, `build.rs:1-16`). It does not compile on Linux. This phase gives
|
||||
the package a Tauri skeleton (lib plus thin bin, D1) that compiles and runs on
|
||||
Linux and Windows, plus the CI that produces the Windows exe every later phase
|
||||
tests by hand. It detaches the Win32 modules from the module tree; their files
|
||||
stay on disk until the phase that replaces each one deletes it.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Single root package (D1). `[lib] name = "claude_code_usage_bubble_lib"`,
|
||||
`path = "src/lib.rs"`; `[[bin]] name = "claude-code-usage-bubble"`.
|
||||
`src/main.rs` only holds
|
||||
`#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]` and a call
|
||||
to `claude_code_usage_bubble_lib::run()`.
|
||||
- `tauri.conf.json` at the root: `build.frontendDist: "ui"`,
|
||||
`app.withGlobalTauri: true`, no `devUrl`, `app.windows: []`.
|
||||
- `productName`, `mainBinaryName` and `identifier` as in D16. No `version` key.
|
||||
- `app.security.csp`:
|
||||
`default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost`.
|
||||
- `app.macOSPrivateApi: true` **and** the `macos-private-api` feature on the
|
||||
`tauri` dependency. `tauri-build` checks that the config and the crate features
|
||||
match and fails `cargo check` otherwise; the feature is a no-op off macOS and
|
||||
keeps transparency for the later macOS port.
|
||||
- `bundle`: `active: true`, `targets: ["appimage", "deb"]` (used only by the
|
||||
`release.yml` Linux x64 job; Windows and every other build use `--no-bundle`,
|
||||
D8), **`createUpdaterArtifacts: false`** (release turns it on
|
||||
through `--config`, D8), `copyright` "Copyright (C) 2026 tiennm99",
|
||||
`publisher` "tiennm99", `icon` set to the generated list. No `plugins.updater`
|
||||
section yet (Phase 09).
|
||||
- `capabilities/default.json`: `windows: ["bubble-*", "panel"]`, permissions
|
||||
`core:default` and `core:window:allow-start-dragging`. Plugins are called from
|
||||
Rust only, so no plugin JS permissions.
|
||||
- Cargo deps on major tags (per `version-pinning.md`):
|
||||
- `tauri = { version = "2", features = ["tray-icon", "macos-private-api"] }`, `tauri-build = "2"`.
|
||||
- `tauri-plugin-single-instance = "2"` and `tauri-plugin-autostart = "2"` now.
|
||||
`tauri-plugin-notification` is added in Phase 05 and `tauri-plugin-updater`
|
||||
in Phase 09.
|
||||
- `ureq = "3"`, plus the existing serde, serde_json, dirs, log, simplelog,
|
||||
thiserror, tiny-skia, sha2 and self-replace.
|
||||
- `[target.'cfg(unix)'.dependencies] libc = "0.2"` (the `kill(pid, 0)` wait).
|
||||
- `[target.'cfg(target_os = "linux")'.dependencies] x11rb = "0.13"` and
|
||||
`libloading` (major tag; the tray library probe in Phase 05).
|
||||
- `[target.'cfg(windows)'.dependencies] windows`: the major must match the one
|
||||
tauri already pulls (`cargo tree -i windows`). Features, the full set later
|
||||
phases need: `Win32_Foundation`, `Win32_System_Registry`,
|
||||
`Win32_System_Threading`, `Win32_UI_WindowsAndMessaging`, `Win32_UI_Shell`
|
||||
(SHAppBarMessage, SetWindowSubclass, AUMID), `Win32_Graphics_Dwm`,
|
||||
`Win32_Graphics_Gdi` (MonitorFromWindow/GetMonitorInfoW). A later phase that
|
||||
needs one more feature appends it and re-runs `cargo tree -i windows`.
|
||||
- `webview2-com` (the major tauri pulls) for the WebView2 runtime check in
|
||||
Phase 04, only if tauri does not already re-export it.
|
||||
- Keep the release profile (`Cargo.toml:43-48`).
|
||||
- `package.json`: `"private": true`, `"type": "module"`, scripts `tauri`,
|
||||
`lint` (`eslint .`) and `test` (`node --test "tests/js/**/*.test.js"`).
|
||||
A glob that matches nothing exits 0 on Node 24 (checked on this host); a bare
|
||||
directory argument (`node --test ui/`) fails with "Cannot find module".
|
||||
devDependencies, installed with `@latest` so they take the current caret
|
||||
major: `@tauri-apps/cli`, `eslint`, `@eslint/js`, `eslint-plugin-jsdoc`,
|
||||
`globals`.
|
||||
- `eslint.config.js`: `js.configs.recommended` and
|
||||
`jsdoc.configs['flat/recommended-error']` everywhere; `ui/**/*.js` gets
|
||||
`globals.browser` plus a readonly `__TAURI__`; `tests/**`, `scripts/**` and
|
||||
`eslint.config.js` get `globals.node`; `target/`, `gen/` and `node_modules/`
|
||||
are ignored.
|
||||
- `.taurignore` (D1): `ui/`, `tests/`, `scripts/`, `plans/`, `docs/`,
|
||||
`node_modules/`.
|
||||
- `.github/workflows/ci.yml` (D17):
|
||||
- Triggers: pushes to `feat/tauri-port` and `main`, and `pull_request`.
|
||||
- Rows `windows-latest` and `ubuntu-22.04`: checkout,
|
||||
`dtolnay/rust-toolchain@stable` with clippy and rustfmt,
|
||||
`Swatinem/rust-cache@v2`, `actions/setup-node@v4` (node `lts/*`, npm cache).
|
||||
Linux apt list, the official Tauri v2 prerequisites:
|
||||
`libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev`.
|
||||
Then `npm ci`, `cargo fmt --check`,
|
||||
`cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked`,
|
||||
`npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`.
|
||||
- The Linux row also runs `npm run tauri build -- --bundles deb` (no
|
||||
patchelf needed), so a broken `bundle.icon` list fails before release.
|
||||
- The Windows row uploads `target/release/claude-code-usage-bubble.exe` with
|
||||
`actions/upload-artifact` (name `bubble-exe-<sha>`, `retention-days: 7`).
|
||||
- Row `macos-latest`: toolchain, cache, `cargo check --all-targets --locked`
|
||||
only (the macOS compile gate; `ui/` must exist, nothing else is needed).
|
||||
|
||||
## Files
|
||||
|
||||
- Modify: `Cargo.toml`, `build.rs` (becomes `tauri_build::build()`), `src/main.rs`, `.gitignore` (add `/node_modules`, `/gen`)
|
||||
- Create: `src/lib.rs`, `tauri.conf.json`, `.taurignore`, `capabilities/default.json`, `package.json`, `package-lock.json`, `eslint.config.js`, `ui/bubble.html` (placeholder), `tests/js/.gitkeep`, `.github/workflows/ci.yml`, `icons/` (output of `npx tauri icon src/icons/256.svg`, with `icons/icon.ico` then overwritten by `src/icons/icon.ico` to keep the hand-tuned small sizes)
|
||||
- Delete: `res/icon.rc`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `npm init`, then `npm i -D @tauri-apps/cli@latest eslint@latest @eslint/js@latest eslint-plugin-jsdoc@latest globals@latest`.
|
||||
2. Write `tauri.conf.json`, the capabilities file and `.taurignore`.
|
||||
3. Create `src/lib.rs` with `pub mod diag;` and `run()`: diag init plus a `tauri::Builder` that opens one transparent test window loading `bubble.html` and handles `RunEvent::ExitRequested` as in D18. Reduce `src/main.rs` to the shim. Remove every `mod` line for Win32 modules (`app`, `bubble`, `panel`, `tray`, `os`, `net`, `update`, `usage`, `creds`, `settings`, `i18n`, `usage_color`). Later phases re-add modules to `src/lib.rs` as they port them.
|
||||
4. Generate icons. Verify that `tauri-build` embeds the icon, a VERSIONINFO built from the Cargo version, and a PerMonitorV2 manifest.
|
||||
5. Run `npx tauri info` to confirm the CLI resolves a root-level `tauri.conf.json`.
|
||||
6. Add `ci.yml`, run `actionlint` and `yamllint`, push to `feat/tauri-port` and make all three rows green.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] npm toolchain: `package.json`, `package-lock.json`, `eslint.config.js`, `tests/js/.gitkeep`
|
||||
- [x] `tauri.conf.json`, `capabilities/default.json`, `.taurignore`, `.gitignore`
|
||||
- [x] `src/lib.rs` (`diag`, `run()`, test window, D18 exit handling) and the `src/main.rs` shim; Win32 modules out of the module tree
|
||||
- [x] Icons generated, `icons/icon.ico` replaced by `src/icons/icon.ico`; `res/icon.rc` deleted
|
||||
- [x] `tauri-build` embeds the icon, VERSIONINFO from the Cargo version and a PerMonitorV2 manifest
|
||||
- [x] `npx tauri info` resolves the root-level `tauri.conf.json`
|
||||
- [x] `ci.yml` passes `actionlint` and `yamllint`; all three rows green with a `bubble-exe-<sha>` artifact
|
||||
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`
|
||||
- [ ] Windows manual check: the CI exe starts and shows the test window (needs a Windows machine)
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `windows` is `0.62` and `webview2-com` is `0.39`, the majors tauri 2.12 pulls
|
||||
(`cargo tree -i windows` shows a single copy). `webview2-com` is a direct
|
||||
dependency because tauri does not re-export it.
|
||||
- `x11rb` is `0.14` and `libloading` is `0.9`, the current releases.
|
||||
- Actions use their current majors: `actions/checkout@v7`,
|
||||
`actions/setup-node@v7`, `actions/upload-artifact@v7`.
|
||||
- `tauri-build` writes VERSIONINFO only from the config `version`, which D16
|
||||
leaves out. `build.rs` merges the Cargo version into `TAURI_CONFIG` before
|
||||
`try_build`, keeping any CLI `--config` override. The Windows CI row asserts
|
||||
that the exe `ProductVersion` equals the `Cargo.toml` version.
|
||||
- The default tauri manifest has no DPI awareness, so
|
||||
`res/windows-app-manifest.xml` keeps its Common-Controls v6 dependency and
|
||||
adds `PerMonitorV2`.
|
||||
- Only desktop icons are kept: the mobile (`android/`, `ios/`) and Windows
|
||||
Store (`Square*Logo.png`, `StoreLogo.png`) outputs of `tauri icon` are unused.
|
||||
- `bundle.icon` lists every desktop icon. The bundler has no fallback (an
|
||||
empty list panics the AppImage step and ships a deb without hicolor icons).
|
||||
`icons/icon.png` comes first because tauri-codegen takes the first `.png` as
|
||||
the Linux and macOS default window icon.
|
||||
- The test window (`bubble-test`) exits the app on close, since D18 keeps the
|
||||
process alive after its last window is destroyed.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, `npm run lint` and `npm test` (zero tests, exit 0) all pass.
|
||||
- Host: `npm run tauri build -- --no-bundle` builds the binary with no signing key in the environment. `npm run tauri build -- --bundles deb` builds a deb with 32, 128, 256 and 512 px hicolor icons. The AppImage needs patchelf, so it is first exercised by the Phase 10 dry run.
|
||||
- CI: all three rows green; the Windows run has a downloadable `bubble-exe-<sha>` artifact that starts and shows the test window.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| The CLI or `tauri-build` assumes `src-tauri/` | L x M | `tauri info` in step 5. Fallback: `TAURI_CONFIG`/`--config` paths. Last resort is a `src-tauri/` move, recorded as a change to D1. |
|
||||
| Config/feature mismatch fails `build.rs` | M x H | `macOSPrivateApi` and the `macos-private-api` feature are set together; `cargo check` in Validation catches any other allowlist mismatch. |
|
||||
| Two `windows` crate versions get compiled | M x L | Align the major with `cargo tree -i windows`. |
|
||||
| The `macos-latest` check fails on a build script | L x M | It runs on real macOS, so no cross toolchain is needed. A failure there is a real macOS compile bug to fix behind `cfg`. |
|
||||
| The `gen/schemas` dir is regenerated on every build | L x L | Gitignore it. Capabilities point `$schema` at it only for editor help. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the phase commits. The Win32 sources are untouched on disk.
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Extract portable core with tests"
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: "6h"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 02: Extract Portable Core With Tests
|
||||
|
||||
## Context
|
||||
|
||||
Most of the logic is already pure but sits inside Win32 files. Extract it into
|
||||
`src/core/`. The code is plain Rust with `Rect`/`Point` structs instead of
|
||||
`RECT`/`HWND`, so it can be unit-tested on Linux. There are only 6 tests today
|
||||
(`settings.rs` 2, `bubble.rs` 2 (`mod fullscreen_tests` at `src/bubble.rs:1208`),
|
||||
`i18n` 1, `update/install.rs` 1).
|
||||
|
||||
## Requirements
|
||||
|
||||
Move or port each item below, keeping its exact behaviour and constants.
|
||||
|
||||
- `core/geom.rs`: `Rect{l,t,r,b}`, `Point`, and `scale(px, dpi)` (`src/os/dpi.rs:29-31`).
|
||||
- Bubble sizing: `aspect_at_width` (`src/bubble.rs:68`) and `bubble_height_logical`, with `MIN 140 / MAX 360 / DEFAULT 200 / STEP 20` (`src/bubble.rs:50-53`).
|
||||
- `default_position(work_area, w, h, provider)`, with the raw 24 px gap and the `h+24` stagger (`src/bubble.rs:2012-2041`).
|
||||
- `clamp_into_work_area`, including the Codex bottom-right `h+24` lift (`src/bubble.rs:891-948`).
|
||||
- The snap functions over a `SnapInput{rect, work_area, dpi, taskbar: Option<(Edge, Rect)>, peers: Vec<Rect>}`: corner, then edge, then taskbar, then peer, then the final clamp (`src/bubble.rs:709-889`, `950-968`).
|
||||
- `point_in_rounded_rect` (`src/bubble.rs:637`), even though D4 does not use it for hit-testing. Phase 06 uses it to decide whether a drag started inside the pill.
|
||||
- `place_near(bubble, panel_size, bounds)` (`src/panel.rs:515-542`), and `position_on_any_monitor(x, y, monitors)` with a 140 px probe, replacing `src/settings.rs:114`.
|
||||
- `rect_covers_monitor`, and `window_style_bits_allow_fullscreen` as plain u32 bit logic.
|
||||
- `core/settings.rs`: move `src/settings.rs`. Split `load()` into `parse_and_normalise(&str)` and `drop_offscreen_positions(&mut, &[Rect])`; Phase 04 calls the second one after `available_monitors()`. Keep the path (`APP_DIR_NAME` `src/settings.rs:15`), the serde attributes, the atomic save and the toggle invariant.
|
||||
- `core/i18n.rs`: move `src/i18n/mod.rs`. Add `start_at_login`, "Start at login", and select it with `cfg(windows)`. Keep `format_window`, `format_countdown` and `time_until_display_change`.
|
||||
- `core/color.rs` and `core/usage_color.rs`: `Rgb` without `into_colorref`; `accent_color_for`, `bar_fill_color`, `brighten` and `pulse_triangle`; `Rgb::to_hex()`.
|
||||
- `core/state.rs`: `apply_results` with the 80/95 crossings, the `!` and `…` texts, `last_poll_ok` and the auth-failure list (`src/app.rs:513-569`), plus the 30-minute alert cooldown (`src/app.rs:56`, `841-898`). It returns a list of alerts instead of calling the tray. Also `tray_tooltip` (`src/app.rs:777-790`), `UpdateStatus` and `version_action_label` (`src/app.rs:1176-1192`), and the update-check due/remaining math from `last_update_check_unix` (`src/app.rs:1383-1405`): due when never checked or `now - last >= interval`, otherwise the remainder.
|
||||
- `core/payload.rs`: pure builders for `BubblePayload` and `PanelPayload` (plan.md, Data flow), from a provider snapshot plus `is_dark`, `canvas_w`, `canvas_h` and `dpi`. Add `format_pct(Option<f64>) -> String` (`'{:.0}%'` or `'—'`) to `core/i18n.rs`. Before the first poll the head label and weekly countdown are `'…'` (`src/app.rs:329-345`); on an error the bubble keeps the countdowns of the stored windows and never carries `'!'`.
|
||||
- `core/menu_model.rs`: a plain enum tree for the context menu (`src/app.rs:923-1193`) with string ids: `refresh`, `freq:<ms>`, `provider:<slug>`, `autostart`, `reset-position`, `version`, `update-interval:<secs|off>`, `size-smaller`, `size-larger`, `size-reset`, `toggle-widget`, `restart`, `exit`. It also carries the check and enabled flags and the greyed hint rows. Phase 05 maps it onto `tauri::menu`.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/core/mod.rs`, `src/core/geom.rs`, `src/core/settings.rs`, `src/core/i18n.rs`, `src/core/color.rs`, `src/core/usage_color.rs`, `src/core/state.rs`, `src/core/payload.rs`, `src/core/menu_model.rs`
|
||||
- Modify: `src/lib.rs` (`pub mod core;`). Also re-add `pub mod usage;`, but only for `usage/types.rs`, through a temporary `usage/mod.rs` reduced to `pub mod types;`. Phase 03 restores the rest. The `Serialize` derive on `ProviderId` lands here.
|
||||
- Delete: `src/settings.rs`, `src/i18n/mod.rs`, `src/usage_color.rs`, `src/os/color.rs`, `src/os/dpi.rs`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Copy each function verbatim, then swap the Win32 types for `geom` types.
|
||||
2. Port the existing tests, then add the tests named in the subsystem maps:
|
||||
- Aspect ratio and height at 140, 200, 220, 280, 300 and 360.
|
||||
- Corner snap tie order, the edge-zone boundaries, all four taskbar edges, peer alignment and the final clamp.
|
||||
- The Codex stagger lift. `place_near` flipping above, clamping left and right, and with a negative virtual origin.
|
||||
- Fill-colour band edges at 59.9, 60, 79.9, 80, 94.9 and 95.
|
||||
- Countdown buckets, including "now" and 1d23h showing "1d", and `time_until_display_change`.
|
||||
- Half-to-even rounding: 72.5 gives "72" and 73.5 gives "74".
|
||||
- Thresholds: 79→80 fires; 80→85 does not; 79→96 queues both, but the cooldown lets only one through.
|
||||
- The update schedule's due and remaining values. Settings JSON for the legacy two-provider and three-provider files and for a corrupt file. Probe validation against fake monitors.
|
||||
- The menu model's greyed sizes at 140, 360 and 200.
|
||||
- Payloads: `sessionPctText` `'—'` for `None`, `weeklyPctText` `null` for `None`, `'…'` placeholders before the first poll, stale countdowns (not `'!'`) in the bubble after an auth failure, and `'!'`/`'…'` in the panel texts.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `core/geom.rs`: `Rect`, `Point`, `scale`, bubble sizing, `default_position`, `clamp_into_work_area`, `snap` over `SnapInput`, `point_in_rounded_rect`, `place_near`, `position_on_any_monitor`, `rect_covers_monitor`, `window_style_bits_allow_fullscreen`
|
||||
- [x] `core/settings.rs`: `parse_and_normalise`, `drop_offscreen_positions`, same path, serde attributes, atomic save and toggle invariant
|
||||
- [x] `core/i18n.rs`: `start_at_login` selected by `cfg(windows)`, `format_pct`, `format_window`, `format_countdown`, `time_until_display_change`
|
||||
- [x] `core/color.rs` and `core/usage_color.rs`: `Rgb::to_hex`, `accent_color_for`, `bar_fill_color`, `brighten`, `pulse_triangle`
|
||||
- [x] `core/state.rs`: `apply_results`, 30-minute cooldown, `tray_tooltip`, `UpdateStatus`, `version_action_label`, update-check due/remaining
|
||||
- [x] `core/payload.rs`: `BubblePayload` and `PanelPayload` builders
|
||||
- [x] `core/menu_model.rs`: menu tree with string ids, check/enabled flags and hint rows
|
||||
- [x] `src/lib.rs` declares `core` and `usage`; `usage/mod.rs` reduced to `types`; `ProviderId` derives `Serialize`
|
||||
- [x] Deleted `src/settings.rs`, `src/i18n/mod.rs`, `src/usage_color.rs`, `src/os/color.rs`, `src/os/dpi.rs`
|
||||
- [x] Host: `cargo test core::` (65 tests), `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `apply_results` takes `core::state::PollOutcome` (`Ok`, `AuthFailed`,
|
||||
`Failed`) instead of `usage::Error`, because the error type needs `net` and
|
||||
`creds`, which return in Phase 03. The caller maps `AuthRequired` and
|
||||
`TokenExpired` to `AuthFailed`, everything else to `Failed`, and logs the
|
||||
transient error as before.
|
||||
- The cooldown lives in `UsageState::admit_alert`. Threshold alerts pass it
|
||||
inside `apply_results`; `session_expired_alert` uses the same gate; the
|
||||
update-applied alert does not (parity).
|
||||
- Time-dependent helpers have `_at(…, now)` variants so the countdown and
|
||||
payload tests are deterministic. `bubble_payload` takes `now` as well as
|
||||
the `Canvas { w, h, dpi }`.
|
||||
- `version_action_label` drops the old WinGet suffix: no WinGet package
|
||||
exists and Phase 09 replaces the channel enum.
|
||||
- `drop_offscreen_positions` keeps every position when the monitor list is
|
||||
empty, so a failed `available_monitors()` cannot wipe them. The probe
|
||||
needs real overlap with a monitor rect, like `MonitorFromRect` with
|
||||
`MONITOR_DEFAULTTONULL`.
|
||||
- `Settings::save` now returns `io::Result` so the caller can log a failure;
|
||||
the tmp-and-rename write and its direct-write fallback are unchanged.
|
||||
- `Settings::toggle_live_provider` holds the toggle invariant from
|
||||
`toggle_model`: providers without live usage never change, and the last
|
||||
enabled live provider is re-enabled.
|
||||
- `MenuCommand::parse` accepts only the poll and update intervals the menu
|
||||
offers, so a malformed id cannot set an arbitrary interval.
|
||||
- The old `pulse_triangle` doc comment had the peaks wrong; the code is
|
||||
unchanged and the comment now says 0 at phase 0 and 1 at phase 12.
|
||||
- `src/os/mod.rs` still names the deleted `color` and `dpi` files. It is
|
||||
outside the module tree, and the phase that deletes `src/os/` removes it.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test core::`, with all tests green and at least 40 of them.
|
||||
- Host: `cargo clippy --all-targets -- -D warnings`.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| Behaviour drifts while refactoring the snap code off `HWND` | M x H | Port line by line and add tests before deleting the source. The originals stay in git history for diffing. |
|
||||
| Peer order depended on `HashMap` iteration | L x L | Take peers in `LIVE_USAGE` order. The order is deterministic now, and only a tie can differ from today. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. `core` has no runtime consumers yet; because it is `pub` in the lib (D1), unused items do not trip `dead_code`.
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Data layer on ureq"
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: [2]
|
||||
---
|
||||
|
||||
# Phase 03: Data Layer On ureq
|
||||
|
||||
## Context
|
||||
|
||||
The providers (`src/usage/anthropic.rs`, `chatgpt.rs`, `headers.rs`,
|
||||
`registry.rs`, `refresh.rs`) and creds (`src/creds/*`) are portable. They have
|
||||
only two Win32 couplings: `src/net/winhttp.rs`, and the `CommandExt` import in
|
||||
`src/usage/refresh.rs`. None of these modules has tests.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `src/net/mod.rs` keeps the surface the providers use: `Client::new(ua)`,
|
||||
`get`/`post`, `header`, `json_body`, `send`, and
|
||||
`Response::{status, header, body, text, json}`. Swap the `Win` error variant
|
||||
for `Transport(String)`.
|
||||
- The ureq agent:
|
||||
- `http_status_as_error(false)`.
|
||||
- Timeouts: connect 60 s, receive 30 s, global 120 s.
|
||||
- gzip enabled.
|
||||
- TLS is rustls with the platform verifier, so corporate roots stay trusted.
|
||||
- User-Agent `claude-code-usage-bubble/<version>`.
|
||||
- A per-request User-Agent replaces the agent's, as the Codex provider requires.
|
||||
- Proxy:
|
||||
- Env proxies: `net::env_proxy()` walks the variables ureq reads (`ALL_PROXY`, `HTTPS_PROXY`, `HTTP_PROXY`, either case) and keeps the first valid one, in ureq's order. ureq's `socks-proxy` feature is on, so a SOCKS entry such as Clash's `ALL_PROXY=socks5h://…` is used, not skipped. `NO_PROXY` applies to the chosen proxy.
|
||||
- `cfg(windows)`, only when no env proxy is set: `platform::windows::system_proxy()` reads `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings` (`ProxyEnable`, `ProxyServer`, `ProxyOverride`). Nothing when `ProxyEnable` is not 1.
|
||||
- Parsing, a pure function in `core` with tests: a `ProxyServer` of the form `http=h:p;https=h:p;…` uses the `https=` entry, else the `http=` entry; a bare `h:p` applies to all schemes. `ProxyOverride` is split on `;`; each entry is a case-insensitive glob (`*` wildcard) against the host, and `<local>` matches dotless hosts.
|
||||
- `net::Client` holds a proxied agent and a direct agent and picks per request: direct when the request host matches a `ProxyOverride` entry. The parsed proxy URL is also handed to the updater builder in Phase 09.
|
||||
- `refresh.rs`:
|
||||
- `cfg(windows)` keeps today's candidate list and `CREATE_NO_WINDOW`.
|
||||
- Unix tries `claude -p .` and `codex exec .`, searching `PATH` plus `~/.local/bin`, `~/.claude/local`, `/opt/homebrew/bin` and `/usr/local/bin`.
|
||||
- The child is reaped on a detached thread, so no zombies are left.
|
||||
- Keep `OpenCodeGo` non-live. `unreachable!()` must stay unreachable, so add a test asserting that `LIVE_USAGE` excludes it.
|
||||
- Never log tokens or request headers.
|
||||
|
||||
## Files
|
||||
|
||||
- Modify: `src/net/mod.rs`, `src/usage/mod.rs` (restore), `src/usage/refresh.rs`, `src/usage/types.rs` (serde already added), `src/lib.rs` (`pub mod net; pub mod creds; pub mod platform;`), `src/core/mod.rs` (`proxy` parsing)
|
||||
- Create: `src/platform/mod.rs`, `src/platform/windows.rs` (only `system_proxy` for now; later phases append to it), `tests/fixtures/*.json`
|
||||
- Delete: `src/net/winhttp.rs`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Implement the ureq-backed `net`, then compile the providers unchanged.
|
||||
2. Add fixture tests:
|
||||
- OAuth usage JSON: full, partial, null buckets, and null utilization (which goes to the fallback).
|
||||
- Rate-limit headers: fractions, missing values, negative resets.
|
||||
- The ChatGPT envelope: null `rate_limit`, null windows, negative `reset_at`.
|
||||
- ISO 8601 edge cases: `+0000` rejected, lowercase `t` rejected, years before 1970.
|
||||
- Credential JSON: missing fields, a float `expiresAt`, null `tokens`, and an empty `CODEX_HOME`.
|
||||
- Proxy strings: per-scheme with and without `https=`, bare `h:p`, and `ProxyOverride` matches for `*.anthropic.com`, `chatgpt.com` and `<local>`.
|
||||
3. Add a `#[ignore]` live smoke test that polls the real providers when credentials exist. Run it by hand.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `src/net/mod.rs` on ureq: same `Client`/`RequestBuilder`/`Response` surface, `Transport(String)` replaces `Win`, `http_status_as_error(false)`, connect 60 s / receive 30 s / global 120 s, gzip, rustls with the platform verifier, `claude-code-usage-bubble/<version>` User-Agent with per-request override
|
||||
- [x] Proxy: env proxies from ureq; `cfg(windows)` `platform::windows::system_proxy()` reads `ProxyEnable`/`ProxyServer`/`ProxyOverride` only when no env proxy is set; `core::proxy` parses per-scheme and bare servers, `;`-split override globs and `<local>`; `Client` holds a proxied and a direct agent and picks per request
|
||||
- [x] `refresh.rs`: Windows candidates and `CREATE_NO_WINDOW` behind `cfg(windows)`; Unix `claude -p .` / `codex exec .` searched in `PATH`, `~/.local/bin`, `~/.claude/local`, `/opt/homebrew/bin`, `/usr/local/bin`; child reaped on a detached thread
|
||||
- [x] `usage/mod.rs` restored (providers, registry, headers, refresh, `Error`, `UsageProvider`); `src/lib.rs` declares `creds`, `net` and `platform`
|
||||
- [x] Test asserting `LIVE_USAGE` excludes OpenCode Go
|
||||
- [x] Deleted `src/net/winhttp.rs`
|
||||
- [x] Fixture tests in `tests/fixtures/*.json`: OAuth usage (full, partial, null buckets, null utilization), rate-limit headers (fractions, missing, negative, a 429 over loopback), ChatGPT envelope (null `rate_limit`, null windows, negative `reset_at`), ISO 8601 (`+0000`, lowercase `t`, before 1970), credential JSON (missing fields, float `expiresAt`, null `tokens`, empty `CODEX_HOME`), proxy strings
|
||||
- [x] `#[ignore]` `live_poll` smoke test, run by hand on the host: both providers returned data
|
||||
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (112 passed, 1 ignored), `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`
|
||||
- [x] CI Windows, Linux and macOS rows green, including the Windows proxy reader
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- The registry reader uses `windows-registry` 0.6, the crate and major that
|
||||
`auto-launch` already pulls, so no new crate is compiled and no `unsafe`
|
||||
registry code is needed.
|
||||
- ureq always tunnels through an HTTP proxy with `CONNECT`, also for `http://`
|
||||
targets. The loopback routing test asserts the `CONNECT` line instead of a
|
||||
proxied reply.
|
||||
- `net::Error::Status` is kept so the providers compile unchanged; the client
|
||||
never builds it, because every status comes back as a `Response`.
|
||||
- `net::Client::new` consults `net::system_proxy()` only when
|
||||
`net::env_proxy()` finds no usable proxy. SOCKS env proxies go through
|
||||
ureq's `socks-proxy` connector; without that feature ureq fails every
|
||||
request for `socks5h` and silently connects directly for `socks5`. The
|
||||
system proxy stays HTTP/HTTPS only. `Client::system_proxy()`
|
||||
exposes the system proxy in use for the updater
|
||||
builder in Phase 09. Proxy URLs are never logged (they may hold
|
||||
credentials).
|
||||
- An empty `CODEX_HOME` now counts as unset, as in the Codex CLI. Before, it
|
||||
resolved to `auth.json` in the working directory.
|
||||
- A float `expiresAt` is truncated to whole milliseconds instead of being
|
||||
treated as "no expiry".
|
||||
- `anthropic::parse_usage_body` and `codex_auth::{auth_path, parse_codex_json}`
|
||||
were split out of the I/O paths so the fixtures test them directly; the
|
||||
behaviour is unchanged.
|
||||
- The README note that PAC/WPAD is no longer evaluated belongs to the README
|
||||
rewrite in Phase 11; this phase does not own the README.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test usage:: creds:: net::` is green.
|
||||
- Host: `cargo test -- --ignored live_poll` returns data or `NoCredentials`, and never panics.
|
||||
- CI Windows: compiles with the proxy reader.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| ureq returns 4xx/5xx as errors, which breaks the 401/403 handling and the Messages fallback | H x H | `http_status_as_error(false)`, plus a test against a fixture 429 response that carries headers. |
|
||||
| Users behind PAC lose data | L x M | Intentional change, documented in the README. The static proxy, its bypass list and env proxies still work. |
|
||||
| macOS has no credential file | H x L | Out of scope (keyring is deferred). macOS shows `…`. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. The WinHTTP file is recoverable from git.
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "App runtime: state, scheduler, CLI, single instance"
|
||||
status: in-progress
|
||||
priority: P1
|
||||
effort: "5h"
|
||||
dependencies: [3]
|
||||
---
|
||||
|
||||
# Phase 04: App Runtime
|
||||
|
||||
## Context
|
||||
|
||||
`src/app.rs`, about 1500 lines, couples the Win32 message loop, timers and state.
|
||||
Replace it with Tauri setup, managed state, one scheduler thread (D14) and the
|
||||
command and event bridge. All of it lives in the lib (D1); `run()` is the entry.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Pre-Builder order inside `run()`:
|
||||
1. `--diagnose` sets up diag.
|
||||
2. `cfg(windows)`: WebView2 guard. If `GetAvailableCoreWebView2BrowserVersionString` fails or returns empty, `platform::windows::message_box_error` shows "Claude Code Usage Bubble needs the Microsoft Edge WebView2 Runtime" plus the Evergreen download URL, then the process exits 0.
|
||||
3. `--wait-pid <pid>` waits until that PID is gone, polling every 100 ms, capped at 15 s (D13). `OpenProcess`/`WaitForSingleObject` on Windows, `libc::kill(pid, 0)` on Unix. Log the wait and its outcome under `--diagnose`.
|
||||
4. Apply the Linux `GDK_BACKEND=x11` rule (D12).
|
||||
5. Register `tauri-plugin-single-instance` first, with a no-op callback.
|
||||
6. Register autostart and notification in Phase 05; register the updater in Phase 09. No other plugin is registered here, so no plugin needs config that does not exist yet.
|
||||
- `--apply-update` is **not** handled: no released version runs the downloaded binary that way (plan.md, Backwards compatibility). An unknown flag is ignored.
|
||||
- `setup()`:
|
||||
- Load settings, drop off-screen positions against `available_monitors()`, and read the theme.
|
||||
- Build `AppState` in a `Mutex`, plus a `ui_bridge` that holds the `AppHandle`. Provide `with_state(|s| …) -> T` returning owned data; it is the only way to read state before calling Tauri (D14).
|
||||
- Run the startup cleanup: delete `<exe filename>.old.*` siblings and, under `data_local_dir()/ClaudeCodeUsageBubble/updates/`, `update.exe` and `updater-helper-*.exe` (moved from `src/update/handoff.rs:83-108` and `src/update/install.rs:323-343` into `src/handoff.rs`, with a unit test on a temp dir). `update-error.log` is kept.
|
||||
- Start the scheduler thread and trigger the first poll.
|
||||
- `RunEvent::ExitRequested { code: None, api }` calls `api.prevent_exit()` (D18).
|
||||
- The scheduler thread receives messages over an `mpsc` channel with `recv_timeout`:
|
||||
- `PollNow`, and `SetPollInterval`, which re-arms from now without an immediate poll.
|
||||
- `RecomputeCountdown`.
|
||||
- **Update check deadline:** at startup and on `SetUpdateInterval`, use `core::state`'s due/remaining computed from `last_update_check_unix`: check now if due, otherwise sleep the remainder (`src/app.rs:1383-1405`, `1459-1476`). Only `UpdateCheckDone` re-arms for the full interval (intentional fix #6). `SetUpdateInterval(None)` cancels the deadline.
|
||||
- `Shutdown`.
|
||||
- Keep the `AtomicBool` poll gate (`src/app.rs:442-511`).
|
||||
- The poll worker runs registry poll, then `apply_results`, then credential refresh: `try_refresh` every auth-failed provider in order, 8 s each; raise the session-expired alert only for the first provider whose outcome is not `Refreshed`; no immediate re-poll (`src/app.rs:571-590`). Then `propagate()`.
|
||||
- `propagate()` snapshots under the lock, drops it, then builds the payloads (`core::payload`) and sends `bubble:update` with `emit_to` to each bubble label. It calls `tray::sync` and `menu::refresh` (Phase 05 hooks) and `panel::refresh_if_visible` (Phase 07), all outside the lock. Then it re-arms the countdown.
|
||||
- Commands:
|
||||
- `get_bubble_state(provider)` and `get_strings()`.
|
||||
- `log_js(level, msg)`.
|
||||
- `bubble_ready(label)`, which calls the window module's "show without activation" (Phase 06).
|
||||
- Handoff and exit:
|
||||
- `handoff::relaunch(extra_args) -> io::Result<()>` spawns `current_exe` (or `$APPIMAGE`) detached with `--wait-pid <pid>` plus the extra args. On Windows it uses `creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)`.
|
||||
- Restart: save settings, `relaunch(&[])`; on `Ok` call `app.exit(0)`, on `Err` log it and keep running (`src/app.rs:1478-1512`).
|
||||
- Exit is `app.exit(0)`.
|
||||
- Bubbles: `CloseRequested` calls `prevent_close`, so Alt+F4 does nothing (D18).
|
||||
- `--updated-to <v>` stores a pending "update applied" alert, which Phase 05 shows once the tray exists. It works with or without `--wait-pid` (v0.4.0 sends it alone).
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/runtime.rs` (setup, `AppState`, `with_state`, propagate), `src/scheduler.rs`, `src/commands.rs`, `src/handoff.rs` (relaunch, startup cleanup), `src/cli.rs`
|
||||
- Modify: `src/lib.rs`, `src/platform/windows.rs` (`wait_pid`, `webview2_available`, `message_box_error`), `src/platform/mod.rs` (Unix `wait_pid`)
|
||||
- Delete: `src/app.rs`, `src/update/handoff.rs` (its logic moves to `src/handoff.rs`)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Write `cli.rs` as a pure argv parser, with tests for `--diagnose`, `--wait-pid`, `--updated-to` alone and combined, and an ignored unknown flag.
|
||||
2. Build `AppState`, the scheduler and propagate. Use a fake emitter trait so the scheduler's deadline logic is unit-testable without a window, including "Weekly to Daily with the last check 2 days ago checks at once".
|
||||
3. Wire the commands. Leave bubble creation to Phase 06; the test window from Phase 01 renders the payload as raw JSON for now.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `src/cli.rs`: pure parser for `--diagnose`, `--wait-pid <pid>` and `--updated-to <v>` (alone and combined); unknown flags, `--apply-update`, pid 0 and non-version values are ignored; arguments read with `args_os`
|
||||
- [x] Pre-Builder order in `runtime::run()`: diag, `cfg(windows)` WebView2 guard with `message_box_error` and exit 0, `--wait-pid` wait capped at 15 s and logged, Linux `GDK_BACKEND=x11` rule, single-instance plugin first with a no-op callback, no other plugin
|
||||
- [x] `platform::wait_pid`: `kill(pid, 0)` every 100 ms on Unix, `OpenProcess`/`WaitForSingleObject` on Windows; `platform::windows::{webview2_available, message_box_error, is_dark}`
|
||||
- [x] `setup()`: settings load, `drop_offscreen_positions` against `available_monitors()`, theme read, `AppState` behind one mutex with `with_state`, `UiBridge` holding the `AppHandle`, startup cleanup, scheduler start, first poll
|
||||
- [x] `src/handoff.rs`: `relaunch(extra_args) -> io::Result<()>` (`$APPIMAGE` or `current_exe`, `--wait-pid <pid>`, Windows creation flags, own process group on Unix) and `cleanup_stale_files` with a temp-dir test
|
||||
- [x] `src/scheduler.rs`: `PollNow`, `SetPollInterval`, `RecomputeCountdown`, `SetUpdateInterval`, `UpdateCheckDone`, `Shutdown`; pure `Deadlines` tested with a fake host, including "Weekly to Daily with the last check 2 days ago checks at once"
|
||||
- [x] Poll worker: `AtomicBool` gate, registry poll, `apply_results`, CLI refresh (8 s each, session-expired alert for the first provider not `Refreshed`, no re-poll), then `propagate()`
|
||||
- [x] `propagate()`: snapshot under the lock, payloads built and emitted to `bubble-<slug>` from the main thread, countdown re-armed
|
||||
- [x] Commands `get_bubble_state`, `get_strings`, `log_js`, `bubble_ready`, with argument validation
|
||||
- [x] `RunEvent::ExitRequested { code: None }` prevents exit; `RunEvent::Exit` stops the scheduler; bubbles `prevent_close`
|
||||
- [x] Restart (save, `relaunch(&[])`, exit only on `Ok`) and Exit on `Runtime`
|
||||
- [x] `--updated-to` stored as a pending `UpdateApplied` alert
|
||||
- [x] Deleted `src/app.rs` and `src/update/handoff.rs`
|
||||
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (155 passed, 1 ignored), `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`
|
||||
- [x] CI Windows, Linux and macOS rows green
|
||||
- [ ] Linux desktop (X11) and Windows manual checks; they need a display and the Restart/Exit menu, which arrives with the menu work
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `GDK_BACKEND=x11` is set only when `DISPLAY` is also set. With
|
||||
`WAYLAND_DISPLAY` but no X server, forcing x11 would stop GTK from starting
|
||||
at all, so the native Wayland backend stays and positioning features become
|
||||
no-ops later (D12).
|
||||
- On Windows the parent wait is one `WaitForSingleObject` with the 15 s
|
||||
timeout instead of 100 ms polls: it returns as soon as the process exits and
|
||||
holds the handle, so a reused pid cannot be mistaken for the parent.
|
||||
- `--wait-pid 0` is rejected: on Unix `kill(0, 0)` addresses the process group.
|
||||
`--updated-to` accepts only version-like text (digits first, then
|
||||
`[0-9A-Za-z.+-]`, at most 64 chars), because it ends up in a notification.
|
||||
- The scheduler floors the poll interval at 10 s and the update interval at
|
||||
60 s, so a hand-edited `0` cannot become a busy loop. The update deadline is
|
||||
cleared when a check fires and only `UpdateCheckDone` re-arms it.
|
||||
- The scheduler channel is created before the thread starts, so the runtime
|
||||
holds its sender before the first action runs.
|
||||
- The update check action and the alert delivery are hooks: no updater or
|
||||
notification plugin is registered yet (D8, D9), so a due check only logs and
|
||||
alerts reach the diagnostic log. The pending update-applied alert waits in
|
||||
`AppState::pending_update_alert`. `tray::sync`, `menu::refresh` and
|
||||
`panel::refresh_if_visible` join `propagate()` with their modules.
|
||||
- `bubble_ready` shows the window with `show()` until the no-activation show
|
||||
path exists (D5).
|
||||
- The scaffold window is now labelled `bubble-<first enabled provider>`, is a
|
||||
decorated 460x420 window, renders the payload JSON from `get_bubble_state`
|
||||
and `bubble:update`, and forwards errors through `log_js`. Like every bubble
|
||||
it ignores close requests.
|
||||
- `LocaleStrings` derives `Serialize` (camelCase) for `get_strings`.
|
||||
- `src/update/mod.rs` and `install.rs` still name the deleted `handoff`
|
||||
module. They are outside the module tree; the updater rewrite replaces them.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test cli:: scheduler:: handoff::` is green.
|
||||
- Linux desktop (X11, manual; this host has no display): `npm run tauri dev -- -- --diagnose` logs a successful poll (or `NoCredentials`), and the test window shows the payload JSON. A second launch exits at once.
|
||||
- Linux desktop: the Restart command relaunches the app, and the log shows the child waited for the old PID.
|
||||
- Linux desktop: closing the test window does not end the process; Exit does.
|
||||
- Windows (CI artifact): starts, polls, and Restart relaunches.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| Single instance kills the relaunched child while the parent is still exiting | M x M | The wait-until-gone loop (15 s cap) runs before the plugin registers. |
|
||||
| Deadlock between a worker holding the state lock and a main-thread handler | M x H | D14 rule, enforced by `with_state`; code review checks no Tauri call sits inside a lock scope. |
|
||||
| Window operations called off the main thread | M x M | Every window call from a worker goes through `run_on_main_thread`. |
|
||||
| The Linux tray or D-Bus is missing on a headless host | M x L | The app must not exit when tray creation fails (handled in Phase 05). |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. Nothing outside `src/` changes.
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
phase: 5
|
||||
title: "Menu, tray, alerts, autostart"
|
||||
status: in-progress
|
||||
priority: P1
|
||||
effort: "6h"
|
||||
dependencies: [4]
|
||||
---
|
||||
|
||||
# Phase 05: Menu, Tray, Alerts, Autostart
|
||||
|
||||
## Context
|
||||
|
||||
These replace the following Win32 pieces:
|
||||
|
||||
- `src/tray/mod.rs`: `Shell_NotifyIconW` add/modify/delete and balloons.
|
||||
- `src/tray/callback.rs`.
|
||||
- The `pixmap_to_hicon` block in `src/tray/badge.rs`.
|
||||
- The `TrackPopupMenu` menu (`src/app.rs:923-1193`), which calls `SetForegroundWindow(owner)` first (`src/app.rs:1151-1152`).
|
||||
- The Run-key autostart (`src/app.rs:1514-1527`, value name `ClaudeCodeUsageBubble` at `src/app.rs:51`).
|
||||
|
||||
The user approved `tauri-plugin-notification` with Windows AUMID registration
|
||||
for the alerts (D9).
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Menu** (`src/menu.rs`):
|
||||
- Map `core::menu_model` onto `tauri::menu` (`Submenu`, `CheckMenuItem`, `MenuItem` with `enabled(false)`, `PredefinedMenuItem::separator`), keeping the exact order and labels.
|
||||
- The checked state of "Start with Windows" / "Start at login" comes from `autolaunch().is_enabled()`.
|
||||
- OpenCode Go is a disabled `CheckMenuItem`.
|
||||
- The version item reads `"<status> · v<version>"` and is disabled while Checking or Applying. In the package-manager channel it is also disabled while Available (Phase 09).
|
||||
- `menu::refresh()` rebuilds the menu from a state snapshot (D14) and calls `set_menu` on every tray. It runs whenever any input changes (settings, update status, size, autostart) **and after every handled check-item event, including no-op ones** (re-selecting the current frequency or interval, unchecking the last live provider). Native check items toggle themselves on click, so only a rebuild from state keeps the marks right.
|
||||
- `show_context_menu(label)`: on Windows, call `SetForegroundWindow(bubble hwnd)` first (parity with `src/app.rs:1151`; the own-PID fullscreen check already ignores it), then `window.popup_menu(&menu)` on that bubble.
|
||||
- A single `on_menu_event` dispatches the string ids to the same actions as `on_menu_command`.
|
||||
- **Tray** (`src/tray.rs`):
|
||||
- One `TrayIconBuilder::with_id(slug)` per enabled live provider, in `LIVE_USAGE` order, with `show_menu_on_left_click(false)`.
|
||||
- A left-button `Click` with `button_state: Up` toggles the widget.
|
||||
- The badge is `badge::render_pixmap` (tiny-skia, kept), demultiplied into `Image::new_owned(rgba, 32, 32)`, with `is_dark=true` always (parity).
|
||||
- The tooltip comes from `core::state::tray_tooltip`, truncated to 127 UTF-16 units.
|
||||
- `sync()` diffs the set of icons. `set_icon` and `set_tooltip` run only when the cached `(pct, colour)` or the text changed.
|
||||
- Linux: before building any tray, probe with `libloading` for the names the tray backend loads, in its order: `libayatana-appindicator3.so.1`, `libappindicator3.so.1`, then the unversioned `libayatana-appindicator3.so` and `libappindicator3.so` (its default `backcompat` feature; sometimes the only names in an AppImage). The loaded library stays loaded. If none loads, log it and skip tray creation, because the tray library panics on a missing library and the release profile aborts on panic. Any other tray creation error is logged and the app continues.
|
||||
- **Alerts** (D9):
|
||||
- Windows spike first: a fresh portable exe (CI artifact, no Start-menu shortcut, outside `target/`) on Windows 10 and 11 calls `platform::windows::register_aumid()` and shows one toast. If no toast appears, stop and report to the user before writing more alert code.
|
||||
- `register_aumid()` runs at startup before any toast: write `HKCU\Software\Classes\AppUserModelId\io.github.tiennm99.claudecodeusagebubble` with `DisplayName` "Claude Code Usage Bubble" and `IconUri` pointing at a PNG written to `data_local_dir()/ClaudeCodeUsageBubble/icon.png`, then `SetCurrentProcessExplicitAppUserModelID` with the same id.
|
||||
- `tauri-plugin-notification` shows the alerts returned by `apply_results` and the pending "Update applied" alert from `--updated-to`. The update-applied alert ignores the cooldown.
|
||||
- **Autostart** (D10):
|
||||
- `tauri_plugin_autostart::Builder::new().app_name("ClaudeCodeUsageBubble")`, `MacosLauncher::LaunchAgent`, no args. Pin the Windows current-user mode if the Builder exposes it.
|
||||
- On the pinned plugin version, read its Windows source and record in this file: what data `enable()` writes, whether `is_enabled()` compares the data, and whether `enable()` writes StartupApproved. If `is_enabled()` ignores the data, no migration code is written. Otherwise add the one-time data rewrite from D10 (legacy data exactly `"<current exe>"` and different from the plugin format), done through `platform::windows`, never touching StartupApproved.
|
||||
- Confirm that the Linux `.desktop` `Exec` uses `$APPIMAGE` when that variable is set. Otherwise, write the entry ourselves.
|
||||
- **Provider toggle:** keeps the at-least-one invariant, creates or destroys the bubble (Phase 06's `bubble::create`/`destroy`, stubbed until then; D18), syncs the tray, refreshes the menu and polls.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/menu.rs`, `src/tray.rs`
|
||||
- Modify: `src/tray/badge.rs`, which moves to `src/badge.rs` and loses the HICON code. Also `src/runtime.rs` (hooks, plugin registration), `src/platform/windows.rs` (`register_aumid`, `set_foreground`, and the autostart data rewrite only if needed), `src/platform/linux.rs` (`appindicator_available`) and `src/lib.rs`. `Cargo.toml` gains `tauri-plugin-notification = "2"`.
|
||||
- Delete: `src/tray/mod.rs`, `src/tray/callback.rs`, `src/os/registry.rs` (only the theme, proxy and Run reads stay, inside `platform::windows`), `src/os/string.rs` (if no other user remains)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Port `badge.rs`. Add pixel-sampling tests: the disk colour at the centre, the track colour at radius 12.15 with no percent, and the arc colour at 12 o'clock for any percent above 0. Add a demultiply test.
|
||||
2. Build the menu mapping. Add a test that walks the `tauri::menu`-independent model, so it can run headless.
|
||||
3. Add the tray and autostart.
|
||||
4. Run the toast spike, then register the notification plugin and wire the alerts.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `src/badge.rs`: moved from `src/tray/badge.rs` without the HICON code; `demultiplied_rgba` for `Image::new_owned`; pixel tests for the disk centre, the track at radius 12.15, the arc at 12 o'clock for every percent above 0, a quarter sweep, transparent corners, and demultiply
|
||||
- [x] `src/menu.rs`: `core::menu_model` mapped onto `Menu`, `Submenu`, `CheckMenuItem`, `MenuItem` (hints disabled) and separators; autostart check from `autolaunch().is_enabled()`; one `on_menu_event` that runs the command and then rebuilds every tray menu (also for no-op clicks); `show_context_menu(label)` with `SetForegroundWindow` first on Windows
|
||||
- [x] Headless menu test: the full tree (order, labels, ids, check and grey states) walked in `core::menu_model`
|
||||
- [x] `src/tray.rs`: one `TrayIconBuilder::with_id(slug)` per enabled live provider in `LIVE_USAGE` order, `show_menu_on_left_click(false)`, left `Click`/`Up` toggles the widget, badge always dark, tooltip from `tray_tooltip` cut to 127 UTF-16 units, `set_icon`/`set_tooltip` only on a changed `(pct, colour)` or text
|
||||
- [x] Linux: `platform::linux::appindicator_available()` probes `libayatana-appindicator3.so.1`, `libappindicator3.so.1`, `libayatana-appindicator3.so`, then `libappindicator3.so` (the backend's order); without any the tray is skipped and logged; other tray errors are logged
|
||||
- [x] Autostart plugin with `app_name("ClaudeCodeUsageBubble")`, no args, `MacosLauncher::LaunchAgent` on macOS (Linux and macOS); Windows pinned to a current-user entry with the quoted exe path through `auto-launch` directly (notes below); no migration code needed
|
||||
- [x] `platform::windows::register_aumid()` (HKCU `AppUserModelId` key with `DisplayName` and `IconUri`, PNG under `data_local_dir()/ClaudeCodeUsageBubble/icon.png`, `SetCurrentProcessExplicitAppUserModelID`) run in `setup()` before any toast
|
||||
- [x] `tauri-plugin-notification` shows the `apply_results` and session-expired alerts and the pending "Update applied" alert (no cooldown), after the first tray sync
|
||||
- [x] Provider toggle: at-least-one invariant, bubble window created or destroyed (stand-in window until the real bubble), tray sync, menu rebuild, poll
|
||||
- [x] `propagate()` now syncs the trays and rebuilds the menus when the tree changed
|
||||
- [x] Deleted `src/tray/mod.rs`, `src/tray/callback.rs`, `src/os/registry.rs` (and `src/os/theme.rs`, its only user)
|
||||
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (168 passed, 1 ignored), `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle`
|
||||
- [ ] Windows toast spike on Windows 10 and 11 from the CI artifact (manual; no Windows machine here). This is the D9 gate and the phase stays in progress until it passes; the alert wiring already exists, so a failed spike goes to the user before Phase 06 builds on it
|
||||
- [ ] Linux desktop (X11) and Windows manual checks below
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- **Autostart plugin findings** (`tauri-plugin-autostart` 2.7.0 on `auto-launch` 0.6.0):
|
||||
- Windows `enable()` writes the `Run` value data as `"<app_path> <args joined>"`, so with no args it is the **unquoted** exe path plus a trailing space. The legacy data was the quoted path.
|
||||
- Windows `is_enabled()` only checks that the `Run` value exists (in HKLM or HKCU) and is not disabled in `StartupApproved\Run`; it never compares the data. A legacy entry therefore shows checked and no data rewrite is written.
|
||||
- Windows `enable()` also writes the "enabled" bytes to `StartupApproved\Run` when that key exists. That only happens when the user ticks the item, which is the user re-enabling; our own code never touches `StartupApproved`, and `is_enabled()` honours a Task Manager disable.
|
||||
- The plugin Builder does not expose the Windows enable mode, so the plugin would use `auto-launch`'s default `Dynamic`: `enable()` tries HKLM first, so an elevated tick writes an all-users entry that a normal untick cannot remove (`disable()` ignores the HKLM access-denied error and `is_enabled()` still finds it), and that entry holds the unquoted path. Windows therefore does not register the plugin: `platform::windows::autostart_entry` builds an `auto_launch::AutoLaunch` with `WindowsEnableMode::CurrentUser` and the quoted exe path. Its data is `"<exe>" ` (the legacy quoted path plus the trailing space `auto-launch` always appends); `is_enabled()` is unchanged (value present in HKLM or HKCU, not disabled in `StartupApproved\Run`), so a legacy entry still shows checked. Linux and macOS keep the plugin (D10).
|
||||
- Linux `enable()` writes `~/.config/autostart/ClaudeCodeUsageBubble.desktop`, and the plugin uses `$APPIMAGE` (Tauri's `env().appimage`) as the path when it is set, so no entry of our own is needed. The `Exec` path is not quoted.
|
||||
- `menu::refresh` is `Runtime::refresh_shell(force)`: on the main thread it snapshots state (lock released before any Tauri call), then syncs the trays and, when the menu tree differs from the last one applied (or after any menu click), gives every tray a freshly built menu. Countdown ticks and polls therefore rebuild no menu unless something in it changed. The snapshot is taken inside the main-thread closure because a closure queued from a worker runs after one a menu click runs inline; a snapshot taken on the calling thread could otherwise re-apply a stale tree and recreate a removed tray icon.
|
||||
- The left-click handler is registered once on the Tauri builder (`Builder::on_tray_icon_event`), not per icon: Tauri calls a per-icon handler while holding the per-icon listener table, and the inline main-thread refresh may build a missing icon, whose registration locks that same table (a same-thread deadlock).
|
||||
- Each tray gets its own `Menu` instance; the bubble right-click builds a fresh one and pops it from the main thread after the async command returns.
|
||||
- The bubble windows are still the decorated JSON stand-ins; one is created per enabled provider. A window destroyed by a provider toggle is recreated from its `Destroyed` event if the provider is enabled again, except while the process is exiting. Outside `setup()`, bubbles are built on a short-lived thread (Tauri documents that building a webview window inside a menu or window event handler deadlocks on Windows); a process-wide lock, never taken by the main thread, serialises the toggle and the recreate path so only one builds the window. Widget show/hide uses Tauri `show`/`hide` until the no-activation path exists. Reset position and size changes update and save the settings and push new payloads; moving and resizing windows arrives with the bubble window.
|
||||
- The version item calls the update-check hook, which still only logs until the updater exists.
|
||||
- The stand-in page forwards `contextmenu` to `show_context_menu` so the menu can be checked before the real bubble UI.
|
||||
- The Windows toast spike could not run here. The AUMID registration and the notification wiring are implemented; the toast check stays a manual Windows check on the CI artifact, and if no toast appears the alerts need the user's decision before more work.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test badge:: menu_model::` is green.
|
||||
- Linux desktop (X11 session with AppIndicator, manual; this host has no display): one indicator per enabled provider, and the menu is complete with correct check states. Toggling a provider adds or removes an indicator. In a container without the appindicator library, the app runs without a tray instead of aborting.
|
||||
- Windows (CI artifact, manual):
|
||||
- Left-click toggles; right-click shows the native menu; the tooltip has 4 lines.
|
||||
- Re-click the checked frequency, re-click the checked auto-update interval, and uncheck the only enabled provider: the check marks still match state afterwards.
|
||||
- Toggle a provider off and on from the tray menu, once slowly and once quickly: the app does not hang, and exactly one bubble and one tray icon come back.
|
||||
- Tick "Start with Windows" from an elevated run: only `HKCU\…\Run` gets the value, with the quoted path; a normal run can untick it.
|
||||
- The bubble right-click menu dismisses on a click elsewhere.
|
||||
- With a legacy `ClaudeCodeUsageBubble` Run value preset, the item shows checked, and there is exactly one entry after two launches. After a Task Manager disable, the item stays unchecked across two launches.
|
||||
- Threshold, session-expired and update-applied toasts appear from a fresh portable exe with no shortcut.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| Multiple tray icons on Windows are unverified | M x H | Manual check in Validation. Fallback: one tray icon whose badge shows the provider with the higher usage. That would be a recorded parity change, so ask first. |
|
||||
| Toasts do not appear for the portable exe | M x H | AUMID registration, checked by the spike before the alert wiring. On failure, stop and ask the user. |
|
||||
| `app_name` is missing on the pinned autostart Builder | L x M | The value name would differ; then add the D10 migration (delete legacy value, `enable()` once, guarded by the legacy data format so it never runs twice). |
|
||||
| Stale menu state, because the tray menu has no on-open hook | M x M | Rebuild on every state mutation and after every check-item event. |
|
||||
| Deadlock from building menus while holding the state lock | M x H | D14 snapshot rule. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. The app keeps running without a tray or menu, as at the end of Phase 04.
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
phase: 6
|
||||
title: "Bubble window and UI"
|
||||
status: in-progress
|
||||
priority: P1
|
||||
effort: "9h"
|
||||
dependencies: [5]
|
||||
---
|
||||
|
||||
# Phase 06: Bubble Window And UI
|
||||
|
||||
## Context
|
||||
|
||||
Today the bubble is a layered `WS_POPUP` drawn by tiny-skia and GDI
|
||||
(`src/bubble.rs`). It handles hit-testing, drag, snap, clamp, DPI, pulse and
|
||||
time-progress in `wnd_proc`. This phase rebuilds it as one transparent webview
|
||||
window per enabled live provider (D2 to D5, D18). The user approved dead
|
||||
corners for the hit area (D4).
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Window** (`src/bubble.rs`, rewritten):
|
||||
- Created with `WebviewWindowBuilder::new(app, "bubble-<slug>", WebviewUrl::App("bubble.html"))`.
|
||||
- Builder options: `transparent(true)`, `decorations(false)`, `shadow(false)`, `always_on_top(true)`, `skip_taskbar(true)`, `resizable(false)`, `maximizable(false)`, `minimizable(false)`, `focused(false)`, `focusable(false)`, `visible(false)`, `title("Claude Code Usage Bubble")`.
|
||||
- Size is `PhysicalSize(scale(w,dpi), scale(h_logical,dpi))`, recomputed on `ScaleFactorChanged`. Each size change re-sends the payload, whose `canvasW`, `canvasH` and `dpi` drive `layout.js` (D2).
|
||||
- Position is the saved position or `default_position` from the primary monitor's `work_area()`, then clamped.
|
||||
- Honour `widget_visible` when creating the window (intentional fix #7).
|
||||
- Removal is `destroy()` only. `create` waits for the previous window's `Destroyed` event when the same label still exists (fast off/on toggle) (D18).
|
||||
- Show: on Windows through `platform::windows::show_no_activate`, elsewhere `show()`. Triggered by `bubble_ready`, or by a 1.5 s fallback timer after creation if `bubble_ready` has not arrived (D5).
|
||||
- **Windows platform** (`src/platform/windows.rs`):
|
||||
- `apply_no_activate(hwnd)` ORs `WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW` into the extended style before the first show. `show_no_activate(hwnd)` (`SW_SHOWNOACTIVATE`) and `hide(hwnd)` (`SW_HIDE`) are the only show/hide paths for bubbles on Windows.
|
||||
- A `SetWindowSubclass` hook delivers `WM_EXITSIZEMOVE` as move end, `WM_SETTINGCHANGE` as a theme recheck plus reclamp, and `WM_DISPLAYCHANGE` as a reclamp.
|
||||
- `is_dark()` reads `SystemUsesLightTheme`; a missing value means dark.
|
||||
- `taskbar_rect()` uses `SHAppBarMessage(ABM_GETTASKBARPOS)`.
|
||||
- **Linux and macOS** (D3):
|
||||
- Move end is a 250 ms debounce on `Moved`, armed only by `bubble_drag_start`. On X11, when the debounce fires, `platform::linux::button1_down()` (x11rb `QueryPointer`) re-arms it while Button1 is still held.
|
||||
- Theme defaults to dark and is rechecked on bubble creation and `ThemeChanged`. macOS uses `window.theme()`. Linux reads the portal `org.freedesktop.appearance color-scheme` itself (1 dark, 2 light) and keeps the current theme on "no preference" or an unreachable portal, because tao maps both to light.
|
||||
- There is no taskbar rect. On Wayland without Xwayland, skip snap, clamp and saving positions.
|
||||
- **Move end** (Rust): disarm, run `core::geom::snap` with the peers' `outer_position`/`outer_size` in `LIVE_USAGE` order, apply `set_position(Physical)` only if the position changed, then persist `bubble_positions[slug]`.
|
||||
- **Commands:**
|
||||
- `bubble_click(slug)` calls `panel::toggle` (Phase 07; stubbed until then).
|
||||
- `bubble_drag_start(slug)` arms the move-end debounce (no-op on Windows).
|
||||
- `bubble_resize_step(dir)` calls `set_bubble_size(±20)`. That resizes every bubble around its own centre, saves the size and refreshes the menu, with no snap and no position save (parity). Programmatic `set_position` never arms the debounce.
|
||||
- `bubble_ready(label)` shows the window without activation.
|
||||
- **Reset position** (D18): clear the saved positions and save, then `set_position` each existing bubble to its `default_position` (no destroy and recreate). Push the cached payload and poll.
|
||||
- **Visibility transitions** (pure function `core::visibility::next(state, event)`, tested in Phase 08), matching `src/bubble.rs:407-427` and the fullscreen path:
|
||||
|
||||
| Event | Result |
|
||||
|-------|--------|
|
||||
| User shows | `user_hidden = false`; show immediately, whatever `hidden_by_fullscreen` is |
|
||||
| User hides | `user_hidden = true`; hide |
|
||||
| Fullscreen on | `hidden_by_fullscreen = true`; hide |
|
||||
| Fullscreen off | `hidden_by_fullscreen = false`; show only if `!user_hidden` |
|
||||
|
||||
- **Hit area** (D4): `pointer-events: none` outside the pill, so the transparent corners are dead zones. No window region is set.
|
||||
- **Bubble palette** (`src/bubble.rs:1406-1427`, `1857-1866`), as CSS custom properties on `[data-theme=dark]` / `[data-theme=light]`:
|
||||
|
||||
| Token | Dark | Light |
|
||||
|-------|------|-------|
|
||||
| `--bg` (stadium) | `#1F1F1F` | `#F3F3F3` |
|
||||
| `--track` (ring and usage bar) | `#2C2C2C` | `#E2E2E2` |
|
||||
| `--time-track` (inner ring and time bar) | `#404040` | `#E0E0E0` |
|
||||
| `--time-fill` | `#B0B0B0` | `#666666` |
|
||||
| `--text` | `#EAEAEA` | `#1F1F1F` |
|
||||
| `--muted` | `#A8A8A8` | `#5E5E5E` |
|
||||
|
||||
Accent and fill colours come from the payload.
|
||||
- **Pulse** (`src/bubble.rs:340-368`, `1465-1473`, `1536-1541`, `1836-1852`, `1916-1923`):
|
||||
- The ring arc pulses only when `sessionPct >= 95`. The weekly bar fill and the weekly percent text pulse only when `weeklyPct >= 95`. The head big percent never pulses.
|
||||
- An 80 ms `setInterval` runs while either value is 95 or above. It increments `phase`; `t = 1 - |(phase % 24) - 12| / 12` (exactly the Rust code, so `t` is 0 at phase 0 and 1 at phase 12). The colour is `round(c + (255 - c) * t * 0.30)` per channel.
|
||||
- When neither value is 95 or above, the interval stops and `phase` resets to 0. Every `bubble:update` re-syncs the interval.
|
||||
- **UI** (`ui/bubble.html`, `ui/bubble.css`, `ui/bubble.js`, `ui/layout.js`):
|
||||
- `html, body { background: transparent; margin: 0; overflow: hidden; user-select: none }`.
|
||||
- The `.pill` has `cursor: move`.
|
||||
- `layout.js` exports pure functions with JSDoc types, all driven by `canvasW`, `canvasH` and `dpi` from the payload:
|
||||
- `headMetrics` and `tailLayout`, using the formulas from `src/bubble.rs:1249-1396` and `1450-1592` in physical px.
|
||||
- `ringDash`, where the session arc runs clockwise from 12 o'clock and the remaining arc ends at 12 o'clock.
|
||||
- `remainingFraction`.
|
||||
- `pulseT(phase)` and `brighten(hex, t)`.
|
||||
- Text comes only from the payload: `sessionPctText` in the head, `headLabel` as the head label (replaced by `'5H'` when empty or overflowing), `weeklyPctText` (skipped when `null`), `weeklyCountdown` (skipped when empty). JS never formats a number for display.
|
||||
- The font stack is `"Segoe UI", system-ui, sans-serif`, with weights 700, 600 and 400. Sizes come from `big`, `small` and `main` divided by `dpr`.
|
||||
- Timers: the pulse above, and a 60 s timer that advances the time progress while any `resets_at` is set. Recompute immediately on `visibilitychange`.
|
||||
- Events (D3):
|
||||
- `pointerdown` with button 0: `setPointerCapture`, record the point. `pointermove` requires `(e.buttons & 1) === 1`; at 3 or more physical px it invokes `bubble_drag_start`, clears the pressed state and calls `startDragging()`. `pointerup` before the threshold calls `bubble_click`. `pointercancel`, `lostpointercapture` and `blur` clear the pressed state.
|
||||
- `contextmenu` calls `preventDefault()` and `show_context_menu`.
|
||||
- A non-passive `wheel` handler with `ctrlKey` calls `preventDefault()` and invokes `bubble_resize_step` with `sign(-deltaY)`.
|
||||
- Listen to `bubble:update` and `theme`. Call `get_bubble_state` on load, apply it to the DOM, then invoke `bubble_ready` in the same promise callback (no `requestAnimationFrame`).
|
||||
- The JS test file is `tests/js/layout.test.js`.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `ui/bubble.html`, `ui/bubble.css`, `ui/bubble.js`, `ui/layout.js`, `ui/tauri-bridge.js` (thin JSDoc-typed wrappers over `window.__TAURI__`, shared with the panel), `tests/js/layout.test.js`, `src/core/visibility.rs`
|
||||
- Modify: `src/bubble.rs` (full rewrite), `src/platform/windows.rs`, `src/platform/linux.rs`, `src/platform/macos.rs`, `src/platform/mod.rs`, `src/commands.rs`, `src/runtime.rs`, `src/lib.rs`
|
||||
- Delete: `src/os/theme.rs`, `src/os/mod.rs`, `ui/` placeholder content from Phase 01
|
||||
|
||||
## Steps
|
||||
|
||||
1. Write `layout.js` and its tests first. Compare the outputs against hand-computed values from the Rust formulas at widths 200 and 360, at dpi 96 and 144 (including 300 px at 144 dpi, where `innerWidth * dpr` would drift).
|
||||
2. Build the window module and the platform hooks.
|
||||
3. Wire the HTML. Check visually against a v0.5.0 screenshot at 100% and 150% scale.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `ui/layout.js`: `headMetrics`, `tailLayout`, `ringDash`, `remainingFraction`, `pulseT`, `brighten`, plus `usageFillWidth`, `timeFillWidth`, `headLabelText` and `cssPx`; `tests/js/layout.test.js` checks them against values from the Rust formulas at 200 and 360 logical, dpi 96 and 144 (300 px at 144 dpi included) and 140 at 120 dpi
|
||||
- [x] `ui/bubble.html`, `ui/bubble.css` (palette per `[data-theme]`, dead corners), `ui/bubble.js` (layout, texts from the payload only, pulse, 60 s time progress, `visibilitychange`, pointer drag/click, `contextmenu`, Ctrl+wheel), `ui/tauri-bridge.js`
|
||||
- [x] `src/bubble.rs` rewritten: builder options as listed, exact physical size and saved/default position clamped into the work area, refit on `ScaleFactorChanged`, `destroy()` only with recreate after `Destroyed`, show on `bubble_ready` or the 1.5 s fallback, `widget_visible` honoured at creation
|
||||
- [x] `src/platform/windows.rs`: `apply_no_activate`, `show_no_activate`, `hide`, `install_bubble_hook` (`WM_EXITSIZEMOVE`, `WM_SETTINGCHANGE`, `WM_DISPLAYCHANGE`, plus `MA_NOACTIVATE`), `taskbar`
|
||||
- [x] Linux/macOS move end: 250 ms debounce armed by `bubble_drag_start`, re-armed while X11 Button1 is held (`platform::linux::button1_down`); theme from `window.theme()` (macOS) or the portal color-scheme (Linux, only a stated preference applies) on creation and `ThemeChanged`; native Wayland skips snap, clamp and saving
|
||||
- [x] Move end snaps with peers in `LIVE_USAGE` order, moves only on a change, saves the position
|
||||
- [x] Commands `bubble_click` (logs until the panel exists), `bubble_drag_start`, `bubble_resize_step`, `bubble_ready`; resize around each centre without snap or save
|
||||
- [x] Reset position moves the existing bubbles to their defaults
|
||||
- [x] `src/core/visibility.rs` with tests for every row of the transition table; user show/hide goes through it
|
||||
- [x] Deleted `src/os/mod.rs` and `src/os/string.rs` (`src/os/theme.rs` was already gone)
|
||||
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (183 passed, 1 ignored), `npm run lint`, `npm test` (32 passed), `npm run tauri build -- --no-bundle`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest`
|
||||
- [x] Headless Chromium render of the page with a stubbed `__TAURI__` at 100%, 150% and 200% (layout, both themes, dead corners) and synthetic pointer checks (click, drag past 3 px, no drag on a later hover, Ctrl+wheel, right-click on the pill only)
|
||||
- [ ] Visual comparison against a v0.5.0 screenshot at 100% and 150% (none in the repo; Windows manual check)
|
||||
- [ ] Linux desktop (X11) and Windows manual checks below
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Rust sends a `theme` event (`{ isDark }`) to every webview when the system theme changes, then fresh payloads, because the fill colours also depend on the theme. Windows reads `SystemUsesLightTheme` on `WM_SETTINGCHANGE` and ignores tao's `ThemeChanged`, which follows the apps setting.
|
||||
- The visibility state (`user_hidden`, `hidden_by_fullscreen`) lives in `AppState`; `Runtime::apply_visibility(event)` applies `core::visibility::next` and is the entry point for the fullscreen thread. A user show or hide also saves `widget_visible`.
|
||||
- Window event handlers and the Windows hook hand their Tauri work to a short-lived thread, so a refit runs after tao's own `WM_DPICHANGED` resize and nothing runs inside a window procedure.
|
||||
- A new bubble picks the monitor its saved position overlaps most (else the nearest, else the primary) for its DPI and work area.
|
||||
- The head label is measured at its drawn weight (600) for the "5H" fallback; the reserved tail widths ("999d", "100%") are measured at regular weight, as the GDI bubble did.
|
||||
- The details panel does not exist yet, so a bubble click is only logged.
|
||||
- On Linux there is no display-change hook, so clamping after a resolution change happens only on Windows. The README note on transparency without a compositor belongs to the docs phase.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `npm test`, `npm run lint` and `cargo clippy --all-targets -- -D warnings` are green.
|
||||
- Linux desktop (X11, manual; this host has no display): bubbles appear at the bottom right, drag and snap work and persist, pausing mid-drag does not snap, Ctrl+wheel resizes without a snap, right-click opens the menu, the corners are transparent, and the theme switches.
|
||||
- Windows (CI artifact, manual): no focus is stolen when clicking or showing, and there is no white flash. Move end snaps exactly once, and the Codex bubble stacks above Claude. Mixed-DPI drags keep the size. A hover after a drag does not start another drag. Hide by fullscreen then show renders the bubble (not blank). Reset position moves both bubbles and does not quit the app with the panel never opened. Fast provider off/on recreates the bubble.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| WebView2 still activates the window on click | M x H | The `WS_EX_NOACTIVATE` patch. If focus is still taken, also return `MA_NOACTIVATE` for `WM_MOUSEACTIVATE` in the subclass hook. |
|
||||
| `startDragging` swallows `pointerup` | H x L | By design: the end comes from Rust; JS clears its pressed state when it starts the drag. |
|
||||
| A raw show leaves WebView2 blank | M x M | Show and hide both go through the raw path on Windows; the Validation row checks it. |
|
||||
| Ctrl+wheel zooms the page | M x M | `preventDefault` in a non-passive listener. Zoom hotkeys are off by default. |
|
||||
| Transparency fails (no Linux compositor; NVIDIA DMABUF) | M x M | Documented in the README. Do not apply the DMABUF workaround unconditionally. |
|
||||
| Text metrics differ on Linux | H x L | Accepted (intentional change #10). The "5H" fallback adapts. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. Phase 04's test window comes back.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
phase: 7
|
||||
title: "Details panel window"
|
||||
status: in-progress
|
||||
priority: P2
|
||||
effort: "3h"
|
||||
dependencies: [6]
|
||||
---
|
||||
|
||||
# Phase 07: Details Panel Window
|
||||
|
||||
## Context
|
||||
|
||||
`src/panel.rs` is a single opaque `WS_POPUP` toolwindow, fixed at 280x120
|
||||
logical px and GDI-painted. A bubble click toggles it, and losing focus
|
||||
dismisses it.
|
||||
|
||||
## Requirements
|
||||
|
||||
- One `panel` webview window, created hidden at startup with these builder
|
||||
options: `decorations(false)`, `always_on_top(true)`, `skip_taskbar(true)`,
|
||||
`resizable(false)`, `transparent(false)`, `shadow(false)`, `visible(false)`,
|
||||
`title("Usage panel")`, and `LogicalSize(280, 120)`. It is hidden and shown,
|
||||
never destroyed: `CloseRequested` (Alt+F4) calls `prevent_close` and `hide()`
|
||||
(D18).
|
||||
- Windows: `platform::windows::apply_toolwindow(hwnd)` ORs `WS_EX_TOOLWINDOW`
|
||||
(no `NOACTIVATE`, the panel must take focus) before the first show, so the
|
||||
panel has no Alt-Tab entry; `skip_taskbar` alone only removes the taskbar
|
||||
button. Keep `shadow(false)` unless the v0.5.0 screenshot comparison shows the
|
||||
original had a DWM shadow; record the result here.
|
||||
- `panel::toggle(slug)`:
|
||||
- If the panel is visible with the same provider, hide it.
|
||||
- Otherwise set the provider, compute the position with
|
||||
`core::geom::place_near`, using the bubble's outer rect, the panel size
|
||||
scaled by the bubble monitor's DPI, and the union of all monitor rects
|
||||
(physical 8 px gap, parity).
|
||||
- Emit `panel:update` with the `PanelPayload` (plan.md, Data flow), `show()`,
|
||||
then `set_focus()`.
|
||||
- Windows: re-apply `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND)`
|
||||
through `platform::windows::round_corners(hwnd)` on each show.
|
||||
- Dismissal: `WindowEvent::Focused(false)` hides the panel. Ignore a toggle-show
|
||||
that arrives within 150 ms of a blur-hide for the same provider, so a second
|
||||
click on the bubble closes the panel instead of reopening it. Test this on
|
||||
Windows first. If v0.5.0 behaves differently, match v0.5.0.
|
||||
- `refresh_if_visible(payload)` updates the panel in place, without moving it,
|
||||
only for the matching provider. Called from `propagate` outside the state lock
|
||||
(D14).
|
||||
- UI (`ui/panel.html`, `ui/panel.css`, `ui/panel.js`):
|
||||
- Absolute layout with the exact coordinates in `src/panel.rs:299-499`: 4 px
|
||||
accent stripe, a 13 px/600 header at (14,14), rows at y=38 and y=68, bars at
|
||||
x=46 that are 120 wide and 14 high, and right text at x=174, 96 wide,
|
||||
clipped with no ellipsis.
|
||||
- Square bars whose fill width is `round(clamp(pct)/100*120)`.
|
||||
- No pulse and no transitions.
|
||||
- Panel palette: dark `#1F1F1F`/`#EAEAEA`/`#3A3A3A`, light `#FAFAFA`/`#1F1F1F`/`#D6D6D6`.
|
||||
- Fill colours come from the payload.
|
||||
- `get_panel_state` is called on load so the first paint already has data.
|
||||
- Texts come from the payload (`sessionText`, `weeklyText`: `format_window`,
|
||||
`'!'`, `'…'` or `''`) and the header from `label`; JS formats nothing.
|
||||
- WebView defaults are neutralised, because today the panel ignores all
|
||||
input: `preventDefault` on `contextmenu`, on `wheel` with `ctrlKey`, and on
|
||||
`keydown` for browser accelerators (F5, F7, F12, Ctrl/Cmd+R, P, F, G, S, U,
|
||||
O, +, -, 0). If the pinned Tauri/wry exposes a WebView2 "browser accelerator
|
||||
keys" setting, also turn it off for the panel on Windows.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/panel.rs` (rewrite), `ui/panel.html`, `ui/panel.css`, `ui/panel.js`
|
||||
- Modify: `src/commands.rs` (`get_panel_state`), `src/runtime.rs` (create the panel), `src/platform/windows.rs` (`round_corners`, `apply_toolwindow`), `src/lib.rs`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Port `place_near` (already done in Phase 02) and wire the window.
|
||||
2. Write the HTML and CSS, then compare against a v0.5.0 screenshot.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `src/panel.rs` rewritten: the `panel` webview with the listed builder options, created hidden at startup, hidden and shown but never destroyed; Alt+F4 hides it
|
||||
- [x] `panel::toggle`: same provider hides, otherwise `place_near` with the bubble's outer rect, the panel sized for the bubble's DPI and the union of all monitors, then `panel:update`, `show()` and `set_focus()`
|
||||
- [x] Focus loss hides the panel; a toggle within 150 ms of that hide for the same provider is ignored (pure `toggle_state`/`blur_state`, unit-tested)
|
||||
- [x] `refresh_if_visible` from `propagate`, outside the state lock, checked on the main thread against the provider on screen
|
||||
- [x] `src/platform/windows.rs`: `apply_toolwindow`, `round_corners` on each show, `disable_browser_accelerator_keys`; `apply_no_activate` shares the style helper
|
||||
- [x] `get_panel_state` command; `bubble_click` toggles the panel
|
||||
- [x] `ui/panel.html`, `ui/panel.css`, `ui/panel.js`: GDI coordinates, square bars, palette per `[data-theme]`, texts and colours from the payload only, row labels from `get_strings`, `contextmenu`, Ctrl+wheel and browser accelerator keys suppressed
|
||||
- [x] `ui/panel-view.js` (`panelFillWidth`, `isBrowserAccelerator`) with `tests/js/panel-view.test.js`
|
||||
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (191 passed, 1 ignored), `npm run lint`, `npm test` (37 passed), `npm run tauri build -- --no-bundle`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest`
|
||||
- [x] Headless Chromium render of the page with a stubbed `__TAURI__` in both themes (coordinates, fill widths, clipping)
|
||||
- [ ] Visual comparison against a v0.5.0 screenshot and the DWM shadow check (none in the repo; Windows manual check)
|
||||
- [ ] Linux desktop (X11) and Windows manual checks below
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- The row labels ("5h", "7d") are not in `PanelPayload`; the page reads them once from the existing `get_strings` command, so the payload contract stays as planned.
|
||||
- The pure page helpers live in `ui/panel-view.js` instead of `ui/panel.js`, because `panel.js` reads `window.__TAURI__` on import and cannot run under `node --test`.
|
||||
- Tauri does not expose wry's `with_browser_accelerator_keys`, so `platform::windows::disable_browser_accelerator_keys` sets `AreBrowserAcceleratorKeysEnabled` through `with_webview` and reloads the page once, because the setting applies from the next navigation. A page-load hook on the panel runs this after the first `panel.html` load finishes, so the reload cannot replace the initial navigation; still to check on Windows hardware that the panel draws its payload after startup. The `keydown` handler covers the same keys on every platform.
|
||||
- `bubble_click` is an async command, so the toggle is queued to the main thread instead of running inside the webview's IPC callback. Toggles and refreshes both run on the main thread, which keeps a refresh from drawing a provider the panel no longer shows.
|
||||
- The panel background colour is set from the theme before every show, and the payload is emitted before `show()`, against a white or stale flash.
|
||||
- `shadow(false)` is kept until the Windows screenshot comparison says otherwise.
|
||||
- Under native Wayland the panel is only resized, not moved (D12).
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `npm run lint` and `cargo clippy -D warnings` are green.
|
||||
- Linux desktop (X11, manual): clicking a bubble opens the panel below it, or above it near the bottom edge. Clicking elsewhere closes it, clicking the same bubble closes it, and clicking the other bubble swaps its content.
|
||||
- Windows (CI artifact, manual): layout and palette match a v0.5.0 screenshot; placement below the bubble and flipped above near the bottom edge; dismissal on blur and on a second click of the same bubble; rounded corners on Windows 11; no Alt-Tab or taskbar entry; live refresh while open; Alt+F4 hides and a later click reopens; right-click and F5 do nothing.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| Focus is refused (Windows foreground lock, GNOME), so the panel never auto-hides | M x M | Parity: clicking the bubble toggles it closed, as today. |
|
||||
| White flash on the first show in dark theme | M x L | Pre-create the panel, call `set_background_color` from the theme, and populate it before `show()`. |
|
||||
| Wayland ignores `set_position` | H x L | Best effort under D12. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. `bubble_click` goes back to being a stub.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
phase: 8
|
||||
title: "Fullscreen auto-hide"
|
||||
status: in-progress
|
||||
priority: P2
|
||||
effort: "2h"
|
||||
dependencies: [6]
|
||||
---
|
||||
|
||||
# Phase 08: Fullscreen Auto-Hide
|
||||
|
||||
## Context
|
||||
|
||||
Each bubble runs a 1500 ms timer (`src/bubble.rs:970-1205`;
|
||||
`evaluate_foreground_fullscreen` at `src/bubble.rs:1008`). When a foreground
|
||||
window covers its monitor's full rect within 2 px, all bubbles hide, unless
|
||||
that window's class is in the ignore list, it is cloaked, or it is minimized.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `platform::foreground_is_fullscreen() -> (bool, &'static str reason)`:
|
||||
- **Windows:** the existing evaluation, copied verbatim, with one change: the
|
||||
`ClaudeCodeUsageBubble` class check becomes "the foreground window belongs
|
||||
to our own process" (`GetWindowThreadProcessId` compared with
|
||||
`std::process::id()`). The `Progman`, `WorkerW` and `Shell_TrayWnd` class
|
||||
checks stay. Uses `rect_covers_monitor` and the style bits from `core::geom`.
|
||||
- **Linux:** with an X11 connection (`x11rb`, connected once and cached),
|
||||
read root `_NET_ACTIVE_WINDOW`, then that window's `_NET_WM_STATE`. The
|
||||
result is true if the state contains `_NET_WM_STATE_FULLSCREEN` and the
|
||||
window is not ours (compare `_NET_WM_PID`). Without X11, return false.
|
||||
- **macOS:** stub that returns false, with a doc comment pointing to
|
||||
`CGWindowListCopyWindowInfo` for later.
|
||||
- One 1500 ms thread checks the state and applies `core::visibility::next`
|
||||
(Phase 06 table) to every bubble. Fullscreen sets `hidden_by_fullscreen` and
|
||||
hides the bubbles. When it clears, the bubbles show without activation unless
|
||||
`user_hidden` is set. A user show while fullscreen is active shows at once
|
||||
(parity with `src/bubble.rs:407-427`). The thread snapshots state and drops the
|
||||
lock before any window call (D14).
|
||||
- Log each decision with its reason under `--diagnose`.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/fullscreen.rs` (thread and state transitions)
|
||||
- Modify: `src/platform/windows.rs`, `src/platform/linux.rs`, `src/platform/macos.rs`, `src/lib.rs`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Move the Windows code and keep the two existing tests (in `core::geom`).
|
||||
2. Add the x11rb reader.
|
||||
3. Add unit tests for `core::visibility::next` covering every row of the Phase 06 table, including a user show during fullscreen and fullscreen off while user-hidden.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `platform::foreground_is_fullscreen()` dispatcher in `src/platform/mod.rs`; native Wayland (no X11 positioning) reports false
|
||||
- [x] Windows: the v0.5.0 evaluation moved into `src/platform/windows.rs`, with the bubble-class check replaced by the own-PID check (`GetWindowThreadProcessId`); `Progman`, `WorkerW` and `Shell_TrayWnd` still ignored; `rect_covers_monitor` and the style bits come from `core::geom`, whose two tests stay there
|
||||
- [x] Linux: x11rb reader on the shared cached connection (`_NET_ACTIVE_WINDOW`, `_NET_WM_STATE`, `_NET_WM_PID`), pure `evaluate_active_window` unit-tested
|
||||
- [x] macOS stub returning false, pointing at `CGWindowListCopyWindowInfo`
|
||||
- [x] `src/fullscreen.rs`: 1500 ms thread, snapshots the visibility state, then `Runtime::apply_visibility` outside the lock; each hide/show decision logged with reason and detail under `--diagnose`
|
||||
- [x] `core::visibility::next` tests cover every row of the Phase 06 table (user show during fullscreen, fullscreen off while user-hidden); `fullscreen::` tests cover check sequences through it
|
||||
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (197 passed, 1 ignored), `cargo test -- fullscreen:: visibility::` (9 passed), `npm run lint`, `npm test` (37 passed), `npm run tauri build -- --no-bundle`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest`
|
||||
- [ ] Linux desktop (X11) and Windows manual checks below
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `foreground_is_fullscreen()` returns a `FullscreenCheck { fullscreen, reason, detail }` instead of a `(bool, &'static str)` pair, so the diagnostic log keeps the window handle, class, bounds and monitor that v0.5.0 logged.
|
||||
- Linux also treats a `_NET_WM_STATE_HIDDEN` (minimized) active window as not fullscreen, the X11 counterpart of the Windows minimized check. A window without `_NET_WM_PID` is not ours. A failed query (the window vanished between reads) counts as not fullscreen.
|
||||
- Decisions are logged only when they change the bubbles, as v0.5.0 did; logging every 1.5 s tick would flood the log.
|
||||
- A user show during fullscreen keeps `hidden_by_fullscreen`, so the next tick does not hide the bubbles again; leaving fullscreen then clears the flag.
|
||||
- The thread starts before the bubbles are created; a bubble created while fullscreen is active stays hidden through `Visibility::shows`.
|
||||
- The listed `cargo test fullscreen:: visibility::` is not valid cargo syntax (one filter before `--`); the equivalent is `cargo test -- fullscreen:: visibility::`.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test -- fullscreen:: visibility::` is green.
|
||||
- Linux desktop (X11, manual): a fullscreen browser (F11) hides the bubbles within 1.5 s, and they come back when it exits.
|
||||
- Windows (CI artifact, manual): a fullscreen game or video hides them, a maximized window does not, the desktop does not, and opening the bubble menu or the panel does not.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| The panel being foreground is misread as fullscreen | L x M | The own-PID check. |
|
||||
| The X11 connection fails under Xwayland | L x L | Treat it as not fullscreen. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. Bubbles then stay visible over fullscreen apps.
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
phase: 9
|
||||
title: "Updater"
|
||||
status: in-progress
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: [5]
|
||||
---
|
||||
|
||||
# Phase 09: Updater
|
||||
|
||||
## Context
|
||||
|
||||
How v0.5.0 updates today:
|
||||
|
||||
1. It queries the GitHub API (`src/update/release.rs:48-80`) and takes the
|
||||
asset named exactly `claude-code-usage-bubble.exe` (`release.rs:7`, `:68`),
|
||||
or else the first asset ending in `*.exe`.
|
||||
2. It checks the asset's sha256 digest.
|
||||
3. It `self_replace`s its own exe, relaunches with `--wait-pid <pid>` and
|
||||
`--updated-to <v>`, and exits.
|
||||
|
||||
Older versions hand over differently (plan.md, Backwards compatibility table).
|
||||
None of them runs the downloaded binary with `--apply-update`, so the Tauri exe
|
||||
has no such handler; the startup cleanup from Phase 04 removes the v0.3.13 to
|
||||
v0.4.0 helper leftovers.
|
||||
|
||||
The decision is D8: the plugin does the signed check and download, then the app
|
||||
self-replaces on Windows and runs the plugin's install on the AppImage.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Register `tauri_plugin_updater::Builder::new().build()` in this phase, in the
|
||||
same commit that adds `plugins.updater` to `tauri.conf.json`, because the
|
||||
plugin's config requires `pubkey` and plugin init fails without it:
|
||||
- `pubkey`: the content of the public key generated with `npx tauri signer generate`.
|
||||
- `endpoints`: `["https://github.com/tiennm99/claude-code-usage-bubble/releases/latest/download/latest.json"]`.
|
||||
- `tauri.conf.json` keeps `bundle.createUpdaterArtifacts: false`; `release.yml`
|
||||
enables it through `--config` (Phase 10).
|
||||
- `src/update/mod.rs` keeps the status machine (`core::state::UpdateStatus`).
|
||||
- **Check:** `app.updater_builder()` with
|
||||
`.version_comparator(|cur, rel| core::version::newer(rel, cur))`, which
|
||||
keeps the suffix-ignoring tuple compare (`release.rs:21-46`, moved to
|
||||
`core::version`), `.timeout(Duration::from_secs(30))`, and on Windows
|
||||
`.proxy(url)` from `platform::windows::system_proxy()` when no env proxy is
|
||||
set. Then `.build()?.check()`. `Some(update)` maps to Available, `None` to
|
||||
Up to date, `Err(TargetNotFound)` to Up to date, any other error to Failed.
|
||||
Record `last_update_check_unix` on every check, whatever the result, then
|
||||
send `UpdateCheckDone` to the scheduler.
|
||||
- **Apply:** build a second updater the same way but with
|
||||
`.timeout(Duration::from_secs(600))`, call `check()` again, and call
|
||||
`download(..)` on that result. The builder timeout is a total-request
|
||||
timeout that also covers the download, so the 30 s check timeout must not
|
||||
reach it. If the second check returns `None`, set Up to date. Then dispatch
|
||||
on the channel:
|
||||
- `Portable` (Windows): `ensure_writable` and `reject_unsafe_path` (kept),
|
||||
write the bytes to `data_local_dir()/ClaudeCodeUsageBubble/updates/update.exe`,
|
||||
call `self_replace`, delete the staged file, then
|
||||
`handoff::relaunch(&["--updated-to", v])`.
|
||||
- `AppImage` (Linux with `$APPIMAGE` set): `update.install(bytes)`, then
|
||||
`handoff::relaunch` with the same args.
|
||||
- `PackageManager` (Linux without `$APPIMAGE`): apply is disabled.
|
||||
- `Unsupported` (macOS for now): apply is disabled.
|
||||
- On relaunch `Ok`, `app.exit(0)`. On `Err`, the new binary is already in
|
||||
place but not running: set Failed, write `update-error.log`, and keep the
|
||||
current process running (the next Restart or login starts the new build).
|
||||
- **Failure:** Failed status, plus `write_update_error` to `update-error.log`
|
||||
in the existing format, with the plugin errors mapped onto the existing
|
||||
`Error` enum.
|
||||
- Remove the GitHub-API release lookup, the WinHTTP download and the legacy
|
||||
`--apply-update` helper (`src/update/install.rs:58-86`, `175-267`). Keep
|
||||
`parse_sha256_digest` only if something still uses it; otherwise delete it.
|
||||
- Capabilities: none. Everything runs in Rust.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `src/core/version.rs`
|
||||
- Modify: `src/update/mod.rs`, `src/update/install.rs` (portable apply and paths only), `src/update/channel.rs` (Portable, AppImage, PackageManager, Unsupported), `tauri.conf.json`, `Cargo.toml` (`tauri-plugin-updater = "2"`), `src/runtime.rs` (plugin registration), `src/lib.rs`
|
||||
- Delete: `src/update/release.rs`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Generate the key pair locally. Store the private key, and its password if
|
||||
one is used, as the GitHub secrets `TAURI_SIGNING_PRIVATE_KEY` and
|
||||
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. Keep an offline backup. **This is a
|
||||
user action:** the agent must not create the secret.
|
||||
2. Port the version tests: `v1.2.3-rc1` equals 1.2.3, `v1` is 1.0.0, `abc` is
|
||||
0.0.0, and there is no downgrade. Add tests for channel detection, the
|
||||
staging path and the error mapping (`TargetNotFound` to Up to date).
|
||||
3. Verify that the plugin's `Update::download` checks the signature, both in
|
||||
the docs.rs source and with a tampered `.sig`. If it does not, verify with
|
||||
`minisign-verify` against the same public key before calling `self_replace`.
|
||||
4. Verify in the pinned plugin source that the builder timeout is applied to the
|
||||
download request; if it is not, a single builder with the 30 s timeout is
|
||||
enough and step "Apply" drops the second check. Record the finding here.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Step 1, user action: generate the key pair (`npx tauri signer generate`), put the public key in `tauri.conf.json` `plugins.updater.pubkey` (empty until then), store `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` as GitHub secrets and keep an offline backup
|
||||
- [x] `tauri-plugin-updater = "2"` registered in `src/runtime.rs` in the same commit as `plugins.updater` (`pubkey`, `endpoints`) in `tauri.conf.json`; `createUpdaterArtifacts` stays `false`; no capability added
|
||||
- [x] `src/core/version.rs`: suffix-ignoring `Version` and `newer`, used as the plugin's `version_comparator`
|
||||
- [x] Check: 30 s timeout, Windows system proxy unless an env proxy is set (skipped when an update host is in the bypass list), `TargetNotFound`/`TargetsNotFound` map to Up to date, `last_update_check_unix` saved on every check, then `UpdateCheckDone`
|
||||
- [x] Apply: `Portable` stages `updates/update.exe`, runs `ensure_writable`, `reject_unsafe_path` and `self_replace`, deletes the stage; `AppImage` runs the plugin `install`; both relaunch with `--updated-to X.Y.Z` and exit only on `Ok`; a relaunch failure sets Failed and writes `update-error.log`
|
||||
- [x] `PackageManager` and `Unsupported` channels: the check runs, "Update available" is greyed (`MenuInput::update_can_apply`)
|
||||
- [x] Plugin errors mapped onto `update::Error`; apply failures write `update-error.log` in the existing format
|
||||
- [x] Removed `src/update/release.rs`, the WinHTTP download, the `--apply-update` helper, `parse_sha256_digest` and the `sha2` dependency
|
||||
- [x] Tests: version (`v1.2.3-rc1` = 1.2.3, `v1` = 1.0.0, `abc` = 0.0.0, no downgrade), channel detection, staging and log paths, stage-and-swap, error mapping and `TargetNotFound` to Up to date, update proxy bypass, plugin config parse
|
||||
- [x] Step 3: signature check verified in the pinned plugin source and by running its tamper tests (see notes)
|
||||
- [x] Step 4: builder timeout vs download verified in the pinned plugin source (see notes)
|
||||
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (216 passed, 1 ignored), `cargo test -- version:: update::` (18 passed), `npm run lint`, `npm test` (37 passed), `npm run tauri build -- --no-bundle`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest`
|
||||
- [ ] Linux desktop (X11) manual check below
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- **Step 3 (signature):** in `tauri-plugin-updater` 2.14.0, `Update::download` ends with `self.verify(&buffer)?`, which runs `minisign_verify::PublicKey::verify` against the configured `pubkey` before any bytes are returned, and `Update::install` verifies again. The plugin's own offline tests (`verifies_cached_bytes_and_rejects_tampering`, `install_rejects_bytes_that_do_not_match_the_signature`, plus the signed-version ones) were run on this host against the pinned source and pass: tampered bytes, a tampered signature, an invalid signature and a different key are all rejected. No separate `minisign-verify` call is needed.
|
||||
- **Step 4 (timeout):** the builder timeout does **not** reach the download. `Updater::update_from_release` builds every `Update` with `timeout: None`, so `download()` would run without any limit. Following the phase, apply drops the second builder and the second `check()`: it downloads the `Update` the last check found, after setting its public `timeout` field to 10 min (`update::DOWNLOAD_TIMEOUT`). The found update is kept in `Runtime::pending_update`, outside the state lock, as v0.5.0 kept its release.
|
||||
- **Public key:** the agent could not generate the signing key (key generation is the user action in step 1, and it was refused here as credential handling). `plugins.updater.pubkey` is therefore an empty string, which the plugin accepts at registration: checks work, and a download fails signature verification, so nothing unverified is ever installed. `build.rs` refuses to build when `TAURI_SIGNING_PRIVATE_KEY` is set and the effective `pubkey` (file or `--config`) is empty, so a signed release cannot ship without it. `release.yml` also refuses to build, before any build step, while `pubkey` is empty, so an unsigned tag build cannot publish an exe that could never verify an update.
|
||||
- **Proxy:** the plugin's reqwest reads env proxies itself, so the parsed system proxy is passed only when no env proxy is set. `Proxy::all` cannot bypass per host, so it is skipped when `github.com` or a GitHub release-asset host is in `ProxyOverride`; reqwest's own system-proxy support then routes those requests.
|
||||
- **Error log:** only apply failures write `update-error.log`, as in v0.5.0; check failures are logged under `--diagnose` only, so a transient network error never overwrites the record of a failed swap.
|
||||
- **Concurrency:** check and apply claim their status under the state lock (`Idle/UpToDate/Available/Failed` to `Checking`, `Available` to `Applying`), so a scheduled check cannot run alongside an apply. A scheduled check that fires during an apply re-arms the schedule instead of being lost.
|
||||
- Not enabled: `requireSignedVersion` would also bind the announced version to the signature. It needs every published signature to carry `version:` in its trusted comment, which Phase 10's signing step would have to guarantee.
|
||||
|
||||
## Validation
|
||||
|
||||
- Host: `cargo test version:: update::` is green.
|
||||
- Linux desktop (X11, manual): `npm run tauri dev` starts with the updater registered, and a manual
|
||||
"Check for updates" against the real endpoint reports Up to date or Failed
|
||||
without crashing.
|
||||
- The handover from v0.4.0, v0.4.1 and v0.5.0 into the Tauri exe is checked
|
||||
statically in Phase 10 (legacy-client compatibility table). There is no
|
||||
end-to-end rehearsal; the residual risk is recorded there.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| The signing key is lost, which strands every Tauri install | L x H | Offline backup, documented in `docs/release-process.md`. |
|
||||
| An old client picks the wrong asset | L x H | No other `*.exe` asset is published. The exact name is required, and the Phase 10 table and `make-latest-json.js` guard check it. |
|
||||
| The plugin rejects a `latest.json` that lacks a platform | M x M | Phase 10's script emits every entry it builds and fails if one is missing. A deliberately Windows-only legacy release maps `TargetNotFound` to Up to date. |
|
||||
| A slow link times out the download | M x M | 10 min apply timeout, separate from the 30 s check. |
|
||||
| GitHub API rate limits still hit old clients | L x L | Unchanged. New clients use the CDN `latest.json`. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. If this ships broken, use the plan-level rollback
|
||||
(`release-legacy.yml`).
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
phase: 10
|
||||
title: "Release pipeline and legacy-client compatibility"
|
||||
status: in-progress
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: [9]
|
||||
---
|
||||
|
||||
# Phase 10: Release Pipeline And Legacy-Client Compatibility
|
||||
|
||||
## Context
|
||||
|
||||
`.github/workflows/release.yml` builds one Windows exe on tag pushes, with a
|
||||
draft release on `workflow_dispatch`. `ci.yml` already exists from Phase 01.
|
||||
|
||||
v0.4.0 to v0.5.0 read `api.github.com/repos/tiennm99/claude-code-usage-bubble/releases/latest`,
|
||||
which skips drafts and prereleases, and draft assets are not downloadable
|
||||
without auth. No draft can exercise the migration, and the user decided against
|
||||
an end-to-end rehearsal and a throwaway repo. This phase instead checks
|
||||
statically, against the updater code at each released tag, that the new release
|
||||
assets satisfy every legacy client, and records the residual risk.
|
||||
|
||||
Linux ships x86_64 only (user decision). Bundles are built only in CI; this
|
||||
host has no patchelf and no display.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `release.yml`:
|
||||
- Triggers: tag `v*.*.*`, or `workflow_dispatch` with a `tag` input (a draft release). Keep the tag-equals-`Cargo.toml`-version check.
|
||||
- Every build job runs `npm ci` and passes
|
||||
`--config '{"bundle":{"createUpdaterArtifacts":true}}'` where bundles are
|
||||
built. `TAURI_SIGNING_PRIVATE_KEY` and `_PASSWORD` are step-level env on
|
||||
the steps that check or use them only, never job-level, so `npm ci`
|
||||
install scripts, setup actions and crate build scripts never see the key.
|
||||
A signing step fails hard when the key variable is empty.
|
||||
- Keep the step that fails before any build when `plugins.updater.pubkey` in
|
||||
`tauri.conf.json` is empty. `build.rs` only checks the key when
|
||||
`TAURI_SIGNING_PRIVATE_KEY` is in the build environment, so it does not
|
||||
cover a build step that runs without it.
|
||||
- **win-x64** job (`windows-latest`):
|
||||
- `npm run tauri build -- --no-bundle`.
|
||||
- `npx tauri signer sign target/release/claude-code-usage-bubble.exe`. This
|
||||
writes a separate `.sig` and leaves the exe bytes untouched, so the GitHub
|
||||
asset digest that old clients verify is the digest of the exe they run.
|
||||
- Upload the exe and its `.sig`.
|
||||
- **linux-x64** job (`ubuntu-22.04`):
|
||||
- Install the Phase 01 apt list plus `patchelf`.
|
||||
- `npm run tauri build -- --no-bundle -- --locked` without the key, then
|
||||
`npm run tauri bundle -- --bundles appimage,deb --config …` with it.
|
||||
- Rename every output to space-free kebab names before upload, because the
|
||||
bundler names them after `productName` and GitHub rewrites spaces in asset
|
||||
names: `claude-code-usage-bubble_<ver>_amd64.AppImage`, the matching
|
||||
`.AppImage.sig`, and `claude-code-usage-bubble_<ver>_amd64.deb`. The
|
||||
signature covers the content, not the name.
|
||||
- **publish** job:
|
||||
- Download the artifacts.
|
||||
- Run `node scripts/make-latest-json.js <tag> <dir>`. It builds the URLs
|
||||
from the renamed files and writes
|
||||
`{version, notes, pub_date, platforms:{"windows-x86_64", "linux-x86_64"}}`,
|
||||
where each entry is a release-download URL plus the `.sig` content. It
|
||||
fails on any missing entry, on any `*.exe` asset other than
|
||||
`claude-code-usage-bubble.exe`, and on any asset name with a space (a
|
||||
guard that runs after the rename).
|
||||
- Then `gh release create <tag> <assets…> latest.json --generate-notes`,
|
||||
adding `--draft` for a dispatch run. A tag run is non-draft,
|
||||
non-prerelease and `--latest`, because legacy clients only read
|
||||
`/releases/latest`.
|
||||
- `release-legacy.yml` (kept because it is one small file and the only
|
||||
rollback for Tauri clients; `workflow_dispatch` with `ref`, `version`, `tag`
|
||||
and a `draft` input that defaults to `true`): checks out `ref`, patches the
|
||||
`Cargo.toml` version, runs `cargo build --release --locked`, signs the exe
|
||||
with `tauri signer sign` (CLI from `npm ci --prefix current` on the default
|
||||
branch's lockfile, since the legacy ref has no `package.json`), runs `make-latest-json.js --windows-only`
|
||||
(from the current default branch) and publishes `claude-code-usage-bubble.exe`,
|
||||
its `.sig` and the Windows-only `latest.json`. The build job runs with
|
||||
`contents: read` and `persist-credentials: false` and uploads an artifact; a
|
||||
separate publish job holds `contents: write`, as in `release.yml`. Tauri Linux clients get
|
||||
`TargetNotFound`, which Phase 09 maps to Up to date. The v0.5.0 sources accept
|
||||
the Tauri relaunch args `--wait-pid <pid> --updated-to X`
|
||||
(`git show v0.5.0:src/main.rs`, lines 42 and 54).
|
||||
- `scripts/make-latest-json.js`: plain JS with JSDoc, linted, with
|
||||
`tests/js/make-latest-json.test.js` covering the shape (exactly the two
|
||||
platforms), a space-containing input that fails, a second `*.exe` that fails,
|
||||
renamed names that match their URLs, and `--windows-only`.
|
||||
- **Legacy-client compatibility table**, filled in by reading the updater code
|
||||
at each released tag (`git show <tag>:src/update/release.rs`,
|
||||
`git show <tag>:src/update/install.rs`, `git show <tag>:src/main.rs`) and
|
||||
checking every row against the dry-run draft. The rows below were verified at
|
||||
planning time; the implementer re-runs the `git show` reads and marks each row
|
||||
pass or fail. `src/update/release.rs` is byte-identical from v0.4.0 to v0.5.0
|
||||
(`git diff --stat v0.4.0 v0.5.0 -- src/update/release.rs` is empty).
|
||||
|
||||
| What the old client reads or needs | v0.4.0 | v0.4.1 | v0.5.0 | New release provides | Result |
|
||||
|------------------------------------|--------|--------|--------|----------------------|--------|
|
||||
| Endpoint: `GET /repos/tiennm99/claude-code-usage-bubble/releases/latest` (`release.rs:8-9`, `:49`) | same | same | same | The Tauri tag release is non-draft, non-prerelease and marked latest in the same repo | pass (code): re-read at all three tags (`release.rs:8-9`, `:49`); a tag run of `release.yml` publishes without `--draft`/`--prerelease`, with `--latest`, then fails if `gh api …/releases/latest` returns another tag. Draft check: pending |
|
||||
| Release JSON fields: `tag_name`, `assets[].name`, `assets[].browser_download_url`, optional `assets[].digest` (`release.rs:104-119`) | same | same | same | Standard GitHub release object; nothing from `latest.json` is read | pass (code): `release.rs:104-119` at all three tags; `gh release create` makes a standard release object |
|
||||
| Version gate: `tag_name` parsed with the suffix-ignoring compare, must be greater than the running version (`release.rs:60-63`) | same | same | same | Tag `vX.Y.Z` above `v0.5.0`, equal to `Cargo.toml` (release.yml tag check) | pass (code): `release.rs:60-63` at all three tags; the `prepare` job requires `vX.Y.Z[-suffix]` equal to `Cargo.toml`. The first Tauri tag must be above v0.5.0 (a version bump, not a workflow check) |
|
||||
| Asset pick: name equal, ASCII case-insensitive, to `claude-code-usage-bubble.exe`, else the first asset ending in `.exe` (`release.rs:7`, `:65-74`) | same | same | same | Exactly one `*.exe` asset, named `claude-code-usage-bubble.exe`; `.exe.sig`, `.AppImage`, `.AppImage.sig`, `.deb` and `latest.json` do not end in `.exe` (`make-latest-json.js` guard) | pass (code): `release.rs:7`, `:65-74` at all three tags; `make-latest-json.js` rejects any other `.exe` (any case) and any asset outside the exact set, and runs before `gh release create`. Draft check: pending |
|
||||
| Checksum: SHA-256 of the downloaded body must equal `digest` `sha256:<hex>` when present (`release.rs:78`, `:86-100`; `install.rs` `download`) | `install.rs:79` | `install.rs:88` | `install.rs:88` | GitHub computes `digest` for every uploaded asset (v0.5.0 shows one); the exe is uploaded unmodified after signing, so the digest matches | pass (code): `release.rs:78`, `:86-98`; `install.rs:95` (v0.4.0) and `:104` (v0.4.1, v0.5.0) compare the body hash. `tauri signer sign` was run on this host with a throwaway key: it writes `<file>.sig` beside the binary and leaves the binary as built |
|
||||
| Download: 2xx after redirects from `browser_download_url`, whole body in memory | same | same | same | Same GitHub download URL form as today; the exe grows from about 0.75 MB to several MB, which the in-memory read handles | pass (code): `install.rs:79-108` (v0.4.0) and `:88-117` (v0.4.1, v0.5.0) read the whole body after a 2xx; the URL is GitHub's `browser_download_url` as today |
|
||||
| Swap and relaunch | Helper copy of the old exe runs `--apply-update` (`install.rs:44-45`, `:128`), waits for the parent, then starts the new exe with `--updated-to X` only (`install.rs:173`) | `self_replace` in-process, then `--wait-pid <pid> --updated-to X` (`install.rs:155-172`) | same as v0.4.1 (`install.rs:155-172`) | The Tauri exe accepts a bare start, `--updated-to X` alone, and `--wait-pid <pid> --updated-to X` (Phase 04 CLI). It never needs `--apply-update`. The v0.4.0 helper runs `run_cli` before any mutex (`main.rs:33`), and the Tauri lock is a different, per-session lock, so the helper cannot block the new instance | pass (code): v0.4.0 `install.rs:44-45`, `:128-143` (helper args), `:173` (`--updated-to` only), `main.rs:33` (`run_cli` before `app::run`; `main.rs` is identical at all three tags); v0.4.1 and v0.5.0 `install.rs:155-172`. The Tauri `src/cli.rs` `parse` accepts a bare start, `--updated-to X` and `--wait-pid <pid> --updated-to X` and ignores `--apply-update` (`unknown_flags_are_ignored`) |
|
||||
| Leftovers | `<exe>.old.<pid>`, `updates/updater-helper-*.exe`, `updates/update.exe` | `updates/update.exe` | same as v0.4.1 | Phase 04 startup cleanup deletes all three patterns | pass (code): v0.4.0 `install.rs:214` (`.old.<pid>`), `:247` (`update.exe`), `:258` (`updater-helper-<pid>.exe`); v0.4.1 and v0.5.0 `install.rs:276` (`update.exe`). `src/handoff.rs` `cleanup_stale_files` (called at startup from `src/runtime.rs`) deletes all three patterns |
|
||||
|
||||
Versions before v0.4.0 use the same `ASSET_NAME` constant (checked at
|
||||
v0.1.0, v0.1.9, v0.1.10, v0.2.0, v0.3.0, v0.3.12 and v0.3.13) and the
|
||||
hand-over modes in the plan.md backwards-compatibility table; they are not
|
||||
re-checked row by row.
|
||||
|
||||
- **Residual risk** (record it in this file and in `docs/release-process.md`):
|
||||
the legacy-to-Tauri swap is never run end to end before release. Static
|
||||
reading cannot prove runtime behaviour such as antivirus or SmartScreen
|
||||
reactions to a self-replaced, larger, unsigned exe, WebView2 start-up from a
|
||||
detached relaunch, or the v0.4.0 helper timing. The first Tauri release is the
|
||||
first real run. Mitigations: the WebView2 guard (Phase 04), `--diagnose`
|
||||
logging, `update-error.log`, and the `release-legacy.yml` rollback, which is
|
||||
itself unproven end to end.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `.github/workflows/release-legacy.yml`, `scripts/make-latest-json.js`, `tests/js/make-latest-json.test.js`
|
||||
- Modify: `.github/workflows/release.yml`, `docs/release-process.md` (assets, name contract, residual risk), this phase file (the filled-in table and the residual risk)
|
||||
- Added during implementation: `.yamllint.yaml` (see notes)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Write the `latest.json` script and its test.
|
||||
2. Rewrite `release.yml` and add `release-legacy.yml`. Run `actionlint` and `yamllint`.
|
||||
3. With the user's OK (it creates a draft release in this repo), run a
|
||||
`workflow_dispatch` dry run of `release.yml` with a throwaway tag
|
||||
(`v0.0.0-test`) and inspect the draft's asset list. Run `release-legacy.yml`
|
||||
once with `draft: true` and the v0.5.0 sources to check that it builds,
|
||||
signs and writes a Windows-only `latest.json`. Delete both drafts afterwards.
|
||||
4. Re-run the `git show` reads, fill in the Result column of the compatibility
|
||||
table against the dry-run asset list, and write the residual-risk note.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `scripts/make-latest-json.js` with `tests/js/make-latest-json.test.js` (two-platform shape, signature content, URLs equal to the renamed names, `--windows-only`, a name with a space, a second `.exe` in any case, a missing `.sig`, an AppImage of another version, an empty signature, bad arguments)
|
||||
- [x] `release.yml` rewritten: `prepare` (tag form, tag equals `Cargo.toml`, non-empty `pubkey`) gates `win-x64` and `linux-x64`; both refuse an empty `TAURI_SIGNING_PRIVATE_KEY`; `publish` runs the script and `gh release create` (draft on dispatch, `--latest` on tags, then checks `/releases/latest`)
|
||||
- [x] `release-legacy.yml` added (`ref`, `version`, `tag`, `draft` default `true`)
|
||||
- [x] `actionlint` and `yamllint` clean on all three workflows (with the new `.yamllint.yaml`)
|
||||
- [x] Compatibility table re-read from `git show <tag>:src/update/{release,install}.rs` and `src/main.rs` at v0.4.0, v0.4.1 and v0.5.0; every row passes against the code
|
||||
- [x] Residual risk recorded here and in `docs/release-process.md`
|
||||
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked` (216 passed, 1 ignored), `npm run lint`, `npm test` (52 passed), `npm run tauri build -- --no-bundle -- --locked`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest` for the workflow commits
|
||||
- [ ] Step 3, needs the user: the `release.yml` dispatch dry run and the `release-legacy.yml` draft run, then the draft asset-list check of every table row. Blocked until the signing key exists (Phase 09 step 1), and it creates draft releases, which this run was not allowed to do
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- **Dry runs not run.** Both workflows refuse to build without the signing secret, and `release.yml` also without `plugins.updater.pubkey`, which is still empty (Phase 09 step 1 is a user action). Creating draft releases was also outside what this run may touch. The table therefore says "pass (code)"; the "Draft check" items stay open until the user runs step 3.
|
||||
- **Signing flow checked locally.** With a throwaway key (deleted afterwards) and the matching `pubkey` passed through `--config`, `npm run tauri build -- --bundles deb --config '{"bundle":{"createUpdaterArtifacts":true},…}' -- --locked` produced `Claude Code Usage Bubble_<ver>_<arch>.deb` plus a `.deb.sig`, and `tauri signer sign --app-version` wrote `<binary>.sig` beside an untouched binary. The bundler also signs the deb, so the rename step copies only the AppImage, its `.sig` and the deb; the deb signature is not published (no updater entry uses it). Bundle names carry spaces, as the phase expected.
|
||||
- **Runner args.** `npm run tauri build -- <opts> -- --locked` hands `--locked` to cargo (checked with an invalid flag, which cargo rejected), so release builds stay locked like the old `cargo build --release --locked`.
|
||||
- **`--app-version`.** Both signing steps bind the version into the signature's trusted comment, as `tauri build` does for the AppImage, so `requireSignedVersion` can be turned on later without re-signing anything.
|
||||
- **Legacy version patch.** `release-legacy.yml` patches the first `version =` line of `Cargo.toml`, then sets the same version on the package's own `Cargo.lock` entry with `sed`, so no dependency is re-resolved. Checked on this host against an archive of v0.5.0: `cargo metadata --locked` accepts the patched lockfile without rewriting it, and rejects it when the two versions differ. The release is created with `--target` set to the built commit, so the tag lands on the legacy sources, not the default branch.
|
||||
- **`make-latest-json.js` scope.** Besides the listed guards it requires the exact asset set (any unexpected file fails), so a stray upload cannot slip into a release. It writes `<dir>/latest.json` and ignores an existing one, so a re-run is safe.
|
||||
- **yamllint.** The repo had no yamllint config, and the default rules reject every GitHub workflow (`on:` as a truthy key, no `---`, 80-column lines), `ci.yml` included. `.yamllint.yaml` relaxes exactly those three rules (line length 140), so the check is reproducible.
|
||||
- **Draft dry-run tag.** The tag check also runs for dispatch, so `v0.0.0-test` must point at a commit whose `Cargo.toml` says `0.0.0-test`; `docs/release-process.md` says so.
|
||||
- **Suffixed tags never publish.** The push trigger matches only plain `vX.Y.Z`, and `prepare` also fails a non-dispatch run on a suffixed tag. Before this, pushing `v0.0.0-test` (or an rc tag) started a run that published it with `--latest`, and both client comparators ignore the suffix.
|
||||
|
||||
## Residual risk
|
||||
|
||||
The legacy-to-Tauri swap is never run end to end before release. Static
|
||||
reading cannot prove runtime behaviour such as antivirus or SmartScreen
|
||||
reactions to a self-replaced, larger, unsigned exe, WebView2 start-up from a
|
||||
detached relaunch, or the v0.4.0 helper timing. The first Tauri release is the
|
||||
first real run. Mitigations: the WebView2 guard (Phase 04), `--diagnose`
|
||||
logging, `update-error.log`, and the `release-legacy.yml` rollback, which is
|
||||
itself unproven end to end. The draft dry runs (step 3) are also still
|
||||
outstanding, so the asset list has been checked against the workflow and the
|
||||
script, not against a real draft.
|
||||
|
||||
## Validation
|
||||
|
||||
- `actionlint` and `yamllint` are clean on all workflows.
|
||||
- `npm test` covers `make-latest-json.js`, including the two-platform shape and the second-`*.exe` failure.
|
||||
- The dry-run draft contains exactly `claude-code-usage-bubble.exe`, its `.exe.sig`, one renamed `_amd64.AppImage` with its `.sig`, one renamed `_amd64.deb` and a `latest.json` with only `windows-x86_64` and `linux-x86_64`, whose URLs match the asset names exactly.
|
||||
- The `release-legacy.yml` draft contains the exe, its `.sig` and a Windows-only `latest.json`.
|
||||
- Every row of the compatibility table reads pass, with the tag and `file:line` it was checked against, and the residual risk is written down.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| The legacy-to-Tauri swap fails in the field (unproven end to end) | L x H | The static table above; WebView2 guard; `update-error.log`; `release-legacy.yml` rollback. Accepted by the user in place of a rehearsal. |
|
||||
| A missing secret silently produces an unsigned build | L x H | The signing step fails hard when the env var is empty. |
|
||||
| An old client picks the wrong asset | L x H | Exactly one `*.exe` asset, enforced by `make-latest-json.js`. |
|
||||
| The Tauri release is not the one `/releases/latest` returns | L x H | Tag runs publish non-draft, non-prerelease, `--latest`; check with `gh api repos/tiennm99/claude-code-usage-bubble/releases/latest -q .tag_name` after publishing. |
|
||||
| A draft dry run reaches users | L x H | `/releases/latest` skips drafts; the dry-run tag carries a suffix, which the push trigger and `prepare` refuse to publish; both drafts are deleted after inspection. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the workflow commits. Already published releases are unaffected.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
phase: 11
|
||||
title: "Cleanup, docs, parity sign-off"
|
||||
status: in-progress
|
||||
priority: P1
|
||||
effort: "3h"
|
||||
dependencies: [7, 8, 10]
|
||||
---
|
||||
|
||||
# Phase 11: Cleanup, Docs, Parity Sign-Off
|
||||
|
||||
## Context
|
||||
|
||||
Earlier phases deleted each Win32 file when they replaced it. This phase
|
||||
confirms that nothing is left, updates the user-facing docs and runs the Windows
|
||||
parity checklist before the first Tauri release.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `grep -rn "windows::" src` must find matches only in `src/platform/windows.rs`.
|
||||
No untracked leftovers may remain under `src/os`, `src/tray` or `res/`.
|
||||
- `README.md`:
|
||||
- Platform badges for Windows and Linux x86_64, plus a note that Linux ARM64 and macOS are not yet supported (see `ROADMAP.md`).
|
||||
- The WebView2 requirement on Windows 10.
|
||||
- Linux runtime deps (webkit2gtk-4.1 and libayatana-appindicator3), the need for a compositor, Wayland limits (positioning works only via Xwayland) and the tray limits (menu only, no tooltip).
|
||||
- Settings and log paths per OS, plus the WebView2/WebKit profile dir under the identifier.
|
||||
- The privacy list, unchanged apart from removing WinHTTP.
|
||||
- The intentional changes from plan.md that users can see (dead corners, toasts, PAC).
|
||||
- Fix the drift: tray icons are not optional.
|
||||
- Rewrite "Build from source" (`README.md:67-72`, today `cargo build --release`): Node/npm prerequisite, `npm ci`, `npm run tauri build -- --no-bundle` on Windows, `npm run tauri build -- --no-bundle` or `--bundles deb,appimage` on Linux x86_64, the Linux apt list from Phase 01 plus `patchelf` for AppImage, and a note that local bundles are unsigned for the updater (`createUpdaterArtifacts` is off).
|
||||
- `docs/release-process.md`:
|
||||
- The version lives in `Cargo.toml` only (`tauri.conf.json` inherits it). Bump it, then run `cargo build` to refresh `Cargo.lock`.
|
||||
- Signing-key custody and secrets, with an offline backup.
|
||||
- The asset contract: the exact `claude-code-usage-bubble.exe`, with no other `*.exe`, plus `.sig` files and `latest.json`.
|
||||
- The dry-run steps, the asset rename rule, the legacy-client asset contract from the Phase 10 compatibility table, its residual risk (no end-to-end rehearsal), and the `release-legacy.yml` rollback procedure.
|
||||
- No plan IDs or phase numbers in code comments or commit messages.
|
||||
|
||||
## Windows parity checklist (manual, Windows 10 and 11, at 100% and 150% scale)
|
||||
|
||||
- [ ] The bubble shows bottom right, with Codex above Claude. It has no taskbar or Alt-Tab entry, stays topmost, and never steals focus.
|
||||
- [ ] A saved position on an unplugged monitor resets to the default position at startup.
|
||||
- [ ] Rings, bars, text, colours and the 95% pulse match a v0.5.0 screenshot. The light and dark switch applies live.
|
||||
- [ ] A click toggles the panel and a drag moves the bubble. Corner, edge, taskbar and peer snaps match, and the position persists across restarts. A hover right after a drag does not start a new drag.
|
||||
- [ ] Panel: layout and palette match v0.5.0; it opens below the bubble and flips above near the bottom edge; it hides on blur and on a second click of the same bubble; clicking the other bubble swaps content; no Alt-Tab or taskbar entry; Windows 11 corners round; values refresh live while open; right-click, F5 and Ctrl+wheel do nothing.
|
||||
- [ ] Ctrl+wheel resizes within 140 to 360 around the centre and resizes every bubble. The menu size items grey out at the limits.
|
||||
- [ ] Right-click menu: the full structure, check marks and every action work. Re-clicking a checked frequency or interval and unchecking the last provider leave the marks correct. The menu dismisses on a click elsewhere.
|
||||
- [ ] Tray: one icon per provider, the badge ring, the 4-line tooltip, left-click toggle and right-click menu.
|
||||
- [ ] Alerts: threshold, session-expired and update-applied notifications appear from a fresh portable exe with no Start-menu shortcut, with the cooldown respected.
|
||||
- [ ] Fullscreen auto-hide works, as do the DPI change and monitor unplug cases (the bubble is re-clamped).
|
||||
- [ ] Autostart: a legacy v0.5.0 entry shows checked, there is one entry after two launches, the toggle works, and a Task Manager disable survives two launches.
|
||||
- [ ] The Phase 10 legacy-client compatibility table has every row marked pass for v0.4.0, v0.4.1 and v0.5.0, and the residual risk is recorded.
|
||||
- [ ] A second launch exits silently. Restart relaunches (and the old process is gone). Exit ends the process.
|
||||
- [ ] Without the WebView2 runtime (Windows Sandbox or an LTSC VM), the exe shows the message box with the download link and exits.
|
||||
- [ ] `--diagnose` writes `%TEMP%\claude-code-usage-bubble.log`.
|
||||
|
||||
## Files
|
||||
|
||||
- Modify: `README.md`, `docs/release-process.md`
|
||||
- Delete: any leftover detached Win32 files (expected: none)
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] Leftover sweep: the `windows` crate is used only in `src/platform/windows.rs`; the other `windows::` matches are calls into the `platform::windows` module (`src/bubble.rs`, `src/menu.rs`, `src/panel.rs`, `src/platform/mod.rs`). `src/os`, `src/tray` and `res/icon.rc` are gone; the empty untracked `src/i18n/` directory was removed. `res/windows-app-manifest.xml` stays (embedded by `build.rs`), and so do `src/icons/*` (the icon sources `icons/` was generated from)
|
||||
- [x] Stale Windows-only header comment in `src/creds/local_fs.rs` corrected (the credentials path is per OS)
|
||||
- [x] `README.md`: Windows and Linux x86_64 badges, Linux ARM64 and macOS unsupported (links `ROADMAP.md`), WebView2 requirement, Linux runtime deps, compositor, Wayland and tray limits, log, settings, update and webview profile paths per OS, the AUMID registration, privacy list, visible intentional changes (dead corners, toasts, PAC/WPAD, per-session lock), tray icons not optional, Linux install (AppImage self-updates, `.deb` check only), rewritten "Build from source"
|
||||
- [x] `docs/release-process.md`: `Cargo.toml` as the only version source and `cargo build` for `Cargo.lock`, signing-key custody and secrets with an offline backup, the asset table and `latest.json` shape, the rename rule, the legacy-client asset contract, dry-run steps, the `release-legacy.yml` rollback procedure, residual risk
|
||||
- [x] Links in both docs resolve; claims checked against the code and workflows
|
||||
- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked` (216 passed, 1 ignored), `npm run lint`, `npm test`, `npm run tauri build -- --no-bundle -- --locked`
|
||||
- [ ] Windows parity checklist above, on Windows 10 and 11 at 100% and 150% (manual, needs a Windows machine with the CI artifact; this host has no Windows or display)
|
||||
- [ ] Linux x86_64 X11 smoke run (manual, needs a desktop)
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- **`.deb` dependencies checked.** A host-built `.deb` declares `Depends: libayatana-appindicator3-1, libwebkit2gtk-4.1-0, libgtk-3-0`, so the README's "the `.deb` pulls these in" holds.
|
||||
- **Webview profile paths.** Tauri 2.12 sets the webview data directory to `app_local_data_dir()` on Windows and Linux (`tauri` `src/manager/webview.rs`), which is `<LocalAppData>/<identifier>`; the README lists it per OS.
|
||||
- **Linux tray.** AppIndicator icons deliver no click events and no tooltip, so the README tells Linux users to use the menu; the badge still renders.
|
||||
- **Privacy list.** The README never named WinHTTP, so the list is unchanged. The AUMID registry key and icon PNG are listed with the other written files instead.
|
||||
- **Manual checklist open.** Every row needs a Windows 10/11 machine; the earlier phases' Windows manual checks are also still open. Nothing in this phase changes runtime behaviour, so no parity row can have regressed here.
|
||||
|
||||
## Validation
|
||||
|
||||
- The checklist is complete. Every failure is either fixed or recorded as an intentional change with user sign-off.
|
||||
- Every link in the README and release doc resolves, and each claim matches the code.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | L x I | Mitigation |
|
||||
|------|-------|------------|
|
||||
| A parity failure is found late | M x M | Each earlier phase already runs its Windows manual check. This phase only confirms them. |
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the doc commits. No code changes in this phase.
|
||||
@@ -0,0 +1,263 @@
|
||||
---
|
||||
title: Port the usage bubble to Tauri v2
|
||||
description: >-
|
||||
Rebuild the Win32 bubble, panel, tray and updater on Tauri v2 with a plain
|
||||
HTML/CSS/SVG/JS UI, keeping Windows parity, running on Linux x86_64 and cfg-gating macOS.
|
||||
status: in-progress
|
||||
priority: P1
|
||||
effort: 51h
|
||||
branch: feat/tauri-port
|
||||
tags:
|
||||
- tauri
|
||||
- cross-platform
|
||||
- port
|
||||
- updater
|
||||
created: '2026-10-21'
|
||||
---
|
||||
|
||||
# Port The Usage Bubble To Tauri v2
|
||||
|
||||
## Outcome
|
||||
|
||||
The app runs on Tauri v2. Windows behaves the same as v0.5.0, and existing
|
||||
v0.4.0+ users self-update into the Tauri build without losing settings or
|
||||
autostart. Linux x86_64 builds and runs (X11 full, Wayland best-effort). macOS
|
||||
compiles behind cfg-gated stubs so it can be finished later. Linux ARM64, a macOS
|
||||
build and Wayland-native positioning are later work in the root `ROADMAP.md`.
|
||||
The rename to `usage-monitor` follows in its own plan,
|
||||
[261021-1226-rename-to-usage-monitor](../261021-1226-rename-to-usage-monitor/plan.md).
|
||||
None of these is part of this plan.
|
||||
|
||||
## Stack (approved)
|
||||
|
||||
- Rust core plus Tauri v2. Approved plugins: tray (core `tray-icon` feature),
|
||||
updater, autostart, single-instance and notification (approved for the alerts,
|
||||
with Windows AUMID registration, D9).
|
||||
- UI: plain HTML/CSS/SVG and plain JavaScript ES modules in a static `ui/`
|
||||
folder, with `withGlobalTauri`. No framework, bundler or TypeScript.
|
||||
- ESLint flat config with `eslint-plugin-jsdoc`. Pure JS modules are tested with
|
||||
`node --test`, which adds no dependency.
|
||||
- npm only. `@tauri-apps/cli` is a local devDependency.
|
||||
- `ureq` 3 (rustls plus platform verifier) replaces WinHTTP.
|
||||
- macOS Keychain credentials (keyring) are deferred, as the user requested.
|
||||
|
||||
## User decisions (2026-10-21)
|
||||
|
||||
- Alerts use `tauri-plugin-notification` with Windows AUMID registration (D9).
|
||||
- The transparent stadium corners become dead corners (D4).
|
||||
- There is no end-to-end update rehearsal and no throwaway repo. Phase 10
|
||||
instead checks, statically and against the code at each released tag, that the
|
||||
new release assets satisfy every legacy client. The end-to-end path stays
|
||||
unproven until the first real release (residual risk, Phase 10).
|
||||
- Linux ships x86_64 only. There are no arm64 CI rows, release jobs or
|
||||
`linux-aarch64` `latest.json` entry.
|
||||
- This host (headless Linux ARM64, no display, no patchelf, no xvfb) verifies with
|
||||
`cargo fmt --check`, `cargo clippy`, `cargo test`, `npm run lint`, `npm test`
|
||||
and `npm run tauri build -- --no-bundle` (or `cargo build`). Bundles (AppImage,
|
||||
`.deb`) are built only in CI, where the runner installs patchelf. Checks that
|
||||
need a display are marked "Linux desktop" and run by hand on an X11 desktop.
|
||||
|
||||
## Decisions
|
||||
|
||||
| ID | Decision | Reason |
|
||||
|----|----------|--------|
|
||||
| D1 | **Layout: one package at the repo root, lib plus thin bin.** `Cargo.toml`, `build.rs`, `tauri.conf.json`, `.taurignore`, `capabilities/`, `icons/`, `src/`, `ui/`, `tests/js/` and `package.json` live at the root. There is no `src-tauri/`. `src/lib.rs` (`[lib] name = "claude_code_usage_bubble_lib"`) declares every module `pub` and exposes `run()`. `src/main.rs` only calls `run()` and carries `windows_subsystem`. Portable logic goes in `src/core/`, OS code in `src/platform/{windows,linux,macos}.rs`. JS tests live in `tests/js/`, so they are not embedded in the binary. `.taurignore` lists `ui/`, `tests/`, `scripts/`, `plans/`, `docs/` and `node_modules/`, so UI edits do not trigger a cargo rebuild. | Git history stays on `src/`. Unused `pub` items in a lib do not trip `dead_code`, so `clippy -D warnings` holds while later phases are still wiring consumers. The `_lib` suffix avoids the Windows pdb name clash between a lib and a bin of the same name (the Tauri template does the same). |
|
||||
| D2 | **Rendering:** the bubble is an HTML stadium pill (`border-radius: 9999px`). Head rings are SVG circles drawn with `stroke-dasharray`, tail bars are pill divs. Rust sets the window to the exact integer physical size and sends `canvasW`, `canvasH` (physical) and `dpi` in the payload; `ui/layout.js` works from those, never from `innerWidth * devicePixelRatio`. Stroke clamps (ring 2..4, time ring 2..3 physical px) carry over. Text widths come from canvas `measureText`. The bubble palette (Phase 06 table, `src/bubble.rs:1406-1427`, `1857-1866`) is CSS custom properties keyed by `[data-theme]`. Fill colours come from Rust `bar_fill_color` as hex. **Every displayed string is formatted in Rust** (half-to-even percent, countdowns, placeholders); JS never formats a number for display. The pulse is a JS 80 ms step with the exact per-element rules in Phase 06. | One owner per rule. Rust owns colours and strings; JS owns pixels. |
|
||||
| D3 | **Interaction:** pointerdown (button 0) calls `setPointerCapture` and records the screen point. Pointermove requires `(e.buttons & 1) === 1`; once the pointer has moved 3 or more physical px, JS invokes `bubble_drag_start`, clears its pressed state and calls `startDragging()`. Pressed state also clears on `pointercancel`, `lostpointercapture` and `blur`. A release before the threshold invokes `bubble_click`. On Windows, a `SetWindowSubclass` hook on the bubble HWND catches `WM_EXITSIZEMOVE` (move end), `WM_SETTINGCHANGE` (theme recheck and reclamp) and `WM_DISPLAYCHANGE` (reclamp). On Linux and macOS, move end is a 250 ms debounce on `WindowEvent::Moved` that is **armed only by `bubble_drag_start`**; on X11 the debounce also checks Button1 with x11rb `QueryPointer` and re-arms while it is still held. Snap disarms before its own `set_position`, so programmatic moves (snap, clamp, resize recentre, Reset position) never snap or persist. `contextmenu` invokes `show_context_menu`. Ctrl+wheel uses a non-passive `wheel` listener that invokes `bubble_resize_step`. | Matches the HTCAPTION move loop on Windows, avoids mid-drag snaps on X11 and keeps "no snap or save after resize or clamp". |
|
||||
| D4 | **Hit area (user-approved):** the transparent stadium corners become dead zones (CSS `pointer-events: none` outside the pill) instead of click-through. | WebView windows take input across their whole rectangle. `SetWindowRgn` loses the anti-aliased edge. The area involved is about 0.21·h² (roughly 920 px² at 66 px). |
|
||||
| D5 | **No activation (Windows):** bubbles are built with `focused(false)` and `focusable(false)`. `platform::windows::apply_no_activate` ORs `WS_EX_NOACTIVATE \| WS_EX_TOOLWINDOW` into bubble HWNDs; `apply_toolwindow` ORs only `WS_EX_TOOLWINDOW` into the panel HWND (it must take focus). Both run before the first show. On Windows, bubble show and hide both go through `platform::windows` (`SW_SHOWNOACTIVATE` / `SW_HIDE`), never mixed with Tauri `show()`/`hide()`. Bubbles are created hidden; JS invokes `bubble_ready` right after it applies the first `get_bubble_state` result to the DOM (no `requestAnimationFrame`), and Rust shows the bubble anyway 1.5 s after creation if `bubble_ready` has not arrived. | Keeps today's never-steals-focus behaviour, keeps the panel out of Alt-Tab, avoids a white flash, and cannot leave a bubble invisible because a hidden webview throttles rendering. |
|
||||
| D6 | **Data layer:** the `usage/` and `creds/` modules stay. `net/` keeps the same `Client`/`Response` surface, backed by ureq with `http_status_as_error(false)`, gzip, a 60 s connect timeout, a 30 s receive timeout, the same User-Agent and the platform verifier. A Windows system proxy is read from `HKCU\…\Internet Settings` with per-scheme parsing and `ProxyOverride` bypass (Phase 03); PAC/WPAD is dropped (intentional change). `refresh.rs` gates `CREATE_NO_WINDOW` and the `cmd.exe`/`powershell` candidates behind `cfg(windows)`; Unix uses the plain `claude` and `codex` commands. | Providers compile unchanged and 401/403 handling and header fallbacks keep working. |
|
||||
| D7 | **Settings: no migration.** The path stays `dirs::config_dir()/ClaudeCodeUsageBubble/settings.json`, and the schema, key names and physical-px positions are byte-compatible. No new keys. Tauri `app_config_dir` and the store plugin are not used. Monitor validation moves to a pure function fed by `available_monitors()`. | Existing users keep their position, size, providers and intervals without any conversion step. |
|
||||
| D8 | **Updater: hybrid on one key.** All platforms use `tauri-plugin-updater` for `check()` and `download()`, which verifies the minisign signature against `latest.json`. A custom `version_comparator` keeps the suffix-ignoring comparison. **Check** uses a builder with a 30 s timeout. **Apply** builds a fresh updater with a 10 min timeout, re-runs `check()` and calls `download()` on that result, because the builder timeout is a total-request timeout that also covers the download. On Windows both builders get the parsed system proxy. A `TargetNotFound` error from `check()` maps to Up to date. Windows stays a **portable exe**: the downloaded bytes go through `self_replace`. The Linux AppImage uses the plugin's `install()`. In both cases the app relaunches itself with `--wait-pid <pid> --updated-to X.Y.Z` and exits only if the spawn succeeded. A `.deb` install is a package-manager channel: the check runs, apply is greyed out. `tauri.conf.json` keeps `createUpdaterArtifacts: false`; only `release.yml` turns it on through `--config`, so local bundle builds need no key. The updater plugin is registered only from Phase 09, together with its `pubkey` and endpoint. No NSIS installer is published. | The portable model is part of Windows parity. Old clients pick the exact asset name through the GitHub API, so they swap straight into the Tauri exe. No released version ever ran the downloaded binary as `--apply-update` (see Backwards compatibility), so that handler is dropped. |
|
||||
| D9 | **Notifications (user-approved):** `tauri-plugin-notification` carries the threshold, session-expired and update-applied alerts. On Windows, before the first toast, `platform::windows::register_aumid()` writes `HKCU\Software\Classes\AppUserModelId\io.github.tiennm99.claudecodeusagebubble` with `DisplayName` "Claude Code Usage Bubble" and `IconUri` (a PNG written to `data_local_dir()/ClaudeCodeUsageBubble/icon.png`), then sets that AUMID for the process. A Windows spike at the start of the Phase 05 alerts work checks that a fresh portable exe with no Start-menu shortcut shows a toast on Windows 10 and 11; if it does not, stop and report to the user before writing more alert code. The 30-minute cooldown logic stays in Rust. | Tauri tray icons have no balloon API, and these alerts are part of Windows parity. Windows drops toasts for an unregistered AUMID. |
|
||||
| D10 | **Autostart:** `tauri-plugin-autostart` with `app_name("ClaudeCodeUsageBubble")`, the same Run value name as v0.5.0, so a legacy entry is already the plugin's entry. No migration code runs by default. Phase 05 verifies on the pinned plugin that `is_enabled()` does not compare the value data, that `enable()` writes HKCU (pin the current-user mode if the Builder exposes it) and what data `enable()` writes. Only if `is_enabled()` rejects the legacy `"<exe>"` data does a one-time rewrite happen: when the Run value data equals the legacy quoted path exactly and differs from the plugin format, overwrite the data directly in `platform::windows`. StartupApproved is never written by our code. The label is "Start with Windows" on Windows and "Start at login" elsewhere. | Avoids a double launch, a wrongly unchecked item, and silently undoing a Task Manager disable on every start. |
|
||||
| D11 | **Fullscreen auto-hide:** Windows reuses `evaluate_foreground_fullscreen` verbatim, with the bubble-class check replaced by "foreground window owned by our PID". Linux X11/Xwayland uses `x11rb` with `_NET_ACTIVE_WINDOW` and `_NET_WM_STATE_FULLSCREEN`. Native Wayland and macOS return `false` (stubs). A 1500 ms thread drives all of them. | The proven logic is kept as-is. The PID check also covers the panel, the popup menu and WebView child windows. |
|
||||
| D12 | **Wayland:** when `WAYLAND_DISPLAY` is set and `GDK_BACKEND` is unset, set `GDK_BACKEND=x11` before Tauri starts. If no X server is present, positioning features become no-ops and a `(0,0)` position is never saved. | Positioning, snapping and topmost only work under Xwayland. |
|
||||
| D13 | **Single instance:** `tauri-plugin-single-instance` is registered first, and its callback does nothing. With `--wait-pid <pid>`, the pre-Builder wait loops until that PID is gone, capped at 15 s (today: 5 s wait plus about 3 s of mutex retries), and logs under `--diagnose`. `handoff::relaunch` returns `Result`; callers call `app.exit(0)` only on `Ok`. **Intentional change:** the lock is per user session instead of the `Global\` machine-wide mutex. | The plugin cannot retry, so the wait must outlast a slow WebView2 teardown. A failed spawn must not leave the user with no app. |
|
||||
| D14 | **Timers and threading:** one `std::thread` scheduler owns the poll deadline, the countdown deadline and the update-check deadline, fed by an `mpsc` channel with `recv_timeout`. The `AtomicBool` in-flight poll gate is kept. Polls run on worker threads. **Hard rule:** no Tauri window, tray, menu or emit API is called while the `AppState` mutex is held. Code takes an owned snapshot through `with_state(\|s\| …)`, drops the guard, then calls Tauri. `propagate`, `menu::refresh`, `tray::sync`, the fullscreen thread and every command follow it. | Plain std, no tokio dependency. Tauri tray, menu and window calls from a worker block on the main thread, so holding the lock across them deadlocks. |
|
||||
| D15 | **Logging:** keep `diag` (simplelog) behind the `--diagnose` gate, writing to `temp_dir()/claude-code-usage-bubble.log` and truncating on each start. It is initialised before the Builder. A `log_js` command forwards `window.onerror`. | The existing behaviour is kept with no new plugin. |
|
||||
| D16 | **Identity:** `productName` "Claude Code Usage Bubble", `mainBinaryName` "claude-code-usage-bubble", `identifier` "io.github.tiennm99.claudecodeusagebubble". `tauri.conf.json` has no `version`, so it inherits it from `Cargo.toml`. Release assets are renamed to space-free kebab names (Phase 10). | One version source for the CI tag check. The identifier is permanent, because it names the WebView data dir, the D-Bus single-instance name and the AUMID. |
|
||||
| D17 | **CI:** `ci.yml` lands in **Phase 01** and runs on pushes to `feat/tauri-port` and `main` and on PRs. Rows: `windows-latest` and `ubuntu-22.04` run fmt, clippy, test, lint, `npm test` and `npm run tauri build -- --no-bundle`; the Windows row uploads the unsigned exe as an artifact (7-day retention), which is the input for every phase's Windows manual check. A `macos-latest` row runs only `cargo check --all-targets` and is the macOS compile gate. `release.yml` (Phase 10) is a matrix covering Windows x64 and Linux x64, plus a publish job. Only CI builds bundles (AppImage needs patchelf, which the runner installs). | Every phase can be verified on Windows without a Windows build host. |
|
||||
| D18 | **Window lifecycle:** bubbles are removed only with `destroy()`. A new window with the same label is created only after the old one's `Destroyed` event. Reset position moves the existing bubbles with `set_position` instead of recreating them. Bubbles get `prevent_close` on `CloseRequested`; the panel gets `prevent_close` plus `hide()`. `run()` handles `RunEvent::ExitRequested { code: None, api }` with `api.prevent_exit()`, so only an explicit `app.exit(0)` ends the process. | `close()` would be blocked by our own handler, re-using a label before destruction completes fails, and destroying the last window would otherwise quit the app. |
|
||||
|
||||
### Intentionally changed behaviour
|
||||
|
||||
1. Corner click-through is replaced by dead corners (D4).
|
||||
2. The single-instance lock is per session, not machine-wide (D13).
|
||||
3. Windows PAC/WPAD proxy discovery is dropped. The static system proxy, its bypass list and env proxies are still honoured (D6).
|
||||
4. Balloon notifications become toasts, and the warning/info icon distinction is lost (D9).
|
||||
5. **Bug fix:** a theme change now reaches already-open bubbles. Today `update_dark_mode` is never called (`src/bubble.rs:396`).
|
||||
6. **Bug fix:** the auto-update check now repeats every interval. Today the timer is re-armed from a worker thread (`src/app.rs:1450-1455`).
|
||||
7. **Bug fix:** a saved `widget_visible=false` is applied when bubbles are created at startup and on provider toggle (`src/app.rs:331-353`).
|
||||
8. The autostart checked state now honours a Task Manager disable, because the plugin reads StartupApproved.
|
||||
9. Windows file properties now show the real version instead of the stale `0.1.0` (`res/icon.rc`).
|
||||
10. Fonts: Segoe UI stays first in the stack. Linux and macOS fall back to `system-ui`, so text metrics differ there.
|
||||
11. The app refuses to start without the WebView2 runtime and shows a message box with the download link instead (Phase 04).
|
||||
12. Windows autostart does not register `tauri-plugin-autostart`. It uses `auto-launch` directly in current-user mode with the quoted exe path, because the plugin's default mode writes an all-users entry when elevated that a normal untick cannot remove (Phase 05, refines D10).
|
||||
13. Applying an update downloads the `Update` the last check found, with its timeout set to 600 s, instead of building a second updater and checking again: the builder timeout never reaches `download()` (Phase 09, refines D8).
|
||||
|
||||
Everything else in the subsystem maps is kept as-is. The quirks are kept too:
|
||||
the global `last_poll_ok`, the first-poll threshold alert, the unscaled 24 px
|
||||
Codex stagger, panel placement against the virtual-screen union with a physical
|
||||
8 px gap, the tray badge always rendering dark, and OpenCode Go staying a greyed,
|
||||
non-live entry.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Effort | Depends on | Status |
|
||||
|---|-------|--------|------------|--------|
|
||||
| 01 | [Scaffold Tauri crate, JS toolchain and CI](phase-01-scaffold-tauri-and-toolchain.md) | 5h | none | in-progress: code done; awaits the Windows manual check |
|
||||
| 02 | [Extract portable core with tests](phase-02-portable-core.md) | 6h | 01 | completed |
|
||||
| 03 | [Data layer on ureq](phase-03-data-layer-ureq.md) | 4h | 02 | completed |
|
||||
| 04 | [App runtime: state, scheduler, CLI, single instance](phase-04-app-runtime.md) | 5h | 03 | in-progress: code done; awaits the Linux desktop and Windows manual checks |
|
||||
| 05 | [Menu, tray, alerts, autostart](phase-05-menu-tray-notify-autostart.md) | 6h | 04 | in-progress: code done; awaits the Windows toast spike (D9 gate) and the Linux desktop and Windows manual checks |
|
||||
| 06 | [Bubble window and UI](phase-06-bubble-window-ui.md) | 9h | 05 | in-progress: code done; awaits the v0.5.0 visual comparison and the Linux desktop and Windows manual checks |
|
||||
| 07 | [Details panel window](phase-07-panel-window.md) | 3h | 06 | in-progress: code done; awaits the v0.5.0 visual and DWM shadow comparison and the Linux desktop and Windows manual checks |
|
||||
| 08 | [Fullscreen auto-hide](phase-08-fullscreen-auto-hide.md) | 2h | 06 | in-progress: code done; awaits the Linux desktop and Windows manual checks |
|
||||
| 09 | [Updater](phase-09-updater-migration.md) | 4h | 05 | in-progress: code done; awaits the user generating the signing key and setting `pubkey`, and the Linux desktop manual check |
|
||||
| 10 | [Release pipeline and legacy-client compatibility](phase-10-ci-release.md) | 4h | 09 | in-progress: code done; awaits the user-approved `release.yml` and `release-legacy.yml` draft dry runs, which need the signing key |
|
||||
| 11 | [Cleanup, docs, parity sign-off](phase-11-cleanup-docs-parity.md) | 3h | 07, 08, 10 | in-progress: docs done; awaits the Windows parity checklist on Windows 10 and 11 and the Linux x86_64 X11 smoke run |
|
||||
|
||||
Run the phases strictly in order. Phases 07, 08 and 09 all edit
|
||||
`src/platform/windows.rs` and `src/lib.rs`, so they must not run in parallel.
|
||||
Every phase's Windows manual check runs the exe artifact from the `ci.yml` run
|
||||
of that phase's last commit.
|
||||
|
||||
## Feature-to-phase map
|
||||
|
||||
| Feature (subsystem maps) | Phase |
|
||||
|--------------------------|-------|
|
||||
| Bubble window styles, topmost, no taskbar, no activation, move cursor | 06 (D5) |
|
||||
| Stadium shape, size 140..360 step 20, aspect ratios | 02 (math), 06 (UI) |
|
||||
| Head rings, inner time ring, tail bars, text and fonts, '5H' fallback, placeholders | 02 (strings), 04 (payload), 06 |
|
||||
| Colours, theme (SystemUsesLightTheme), re-theming of open bubbles | 02, 06 |
|
||||
| Critical pulse at 95% and above, 60 s time-progress refresh | 06 |
|
||||
| Drag, click vs drag, snap (corner, edge, taskbar, peer), clamp | 02 (math), 06 |
|
||||
| Offscreen validation on load, clamp on settings/display change, Codex stagger | 02, 06 |
|
||||
| Default position, DPI changes, Ctrl+wheel resize around the centre | 02, 06 |
|
||||
| Hit area | 06 (D4) |
|
||||
| Fullscreen auto-hide | 08 |
|
||||
| User show/hide, `user_hidden` and `hidden_by_fullscreen` flags | 04, 06, 08 |
|
||||
| Panel: toggle, single window, 280x120 layout, bars, colours, theme, placement, focus dismissal, no Alt-Tab, Win11 corners, live refresh | 07 |
|
||||
| Tray icon per live provider, badge, tooltip, left-click toggle, menu | 05 |
|
||||
| Context menu (full structure, check and grey states, ids) | 05 |
|
||||
| Threshold, session-expired and update-applied alerts, 30 min cooldown | 02 (logic), 05 (D9) |
|
||||
| Poll scheduling, in-flight gate, apply results, credential refresh | 02, 03, 04 |
|
||||
| Countdown re-render scheduling | 02, 04 |
|
||||
| Provider toggle and its invariant, OpenCode Go greyed | 02, 05 |
|
||||
| Reset position, Restart, Exit, CLI flags, `--diagnose`, WebView2 guard | 04, 06 |
|
||||
| Single instance | 04 (D13) |
|
||||
| Settings schema, path, atomic save, load normalisation | 02 (D7) |
|
||||
| Start with Windows (same value name, D10) | 05 |
|
||||
| Updater: check, status machine, schedule, signature, self-replace, relaunch, startup cleanup, error log | 02, 04 (cleanup), 09 (D8) |
|
||||
| Usage providers, creds, HTTP, proxy, TLS | 03 (D6) |
|
||||
| Icons, version info, manifest | 01 |
|
||||
| CI, release, legacy-client compatibility table, release-process doc, README | 01, 10, 11 |
|
||||
|
||||
## Data flow
|
||||
|
||||
1. Scheduler thread sends a poll tick to a worker. The worker calls
|
||||
`Registry::poll_enabled`, which runs ureq requests against the provider
|
||||
endpoints and returns `Vec<(ProviderId, Result)>`.
|
||||
2. `core::state::apply_results` updates the snapshots, the alerts to fire and the
|
||||
auth failures. Auth failures go to the refresh orchestrator, which spawns the
|
||||
CLI for every failed provider.
|
||||
3. `propagate` snapshots state under the lock, drops it (D14), and builds two
|
||||
payloads in `core::payload` (pure, unit-tested):
|
||||
- `BubblePayload` → `emit_to("bubble-<slug>", "bubble:update")`:
|
||||
`{provider, sessionPct|null, weeklyPct|null, sessionPctText, weeklyPctText|null, headLabel, weeklyCountdown, sessionResetsAtMs|null, weeklyResetsAtMs|null, primaryDurSecs, secondaryDurSecs, sessionFill, weeklyFill, accent, isDark, canvasW, canvasH, dpi}`.
|
||||
`sessionPctText` is `'{:.0}%'` or `'—'` when there is no value.
|
||||
`weeklyPctText` is `null` when there is no weekly value (the text is not
|
||||
drawn). `headLabel` is the session countdown only (`format_countdown`), `'…'`
|
||||
before the first poll; JS replaces an empty or overflowing label with `'5H'`.
|
||||
`weeklyCountdown` is countdown-only, `'…'` before the first poll, `''` hides
|
||||
it. On a poll error the bubble keeps its last values and countdowns; **the
|
||||
bubble never shows `'!'`**.
|
||||
- `PanelPayload` → `emit_to("panel", "panel:update")` only when the panel is
|
||||
visible and showing that provider, and returned by `get_panel_state`:
|
||||
`{provider, label, isDark, accent, sessionPct, weeklyPct, sessionText, weeklyText, sessionFill, weeklyFill}`.
|
||||
The texts are `format_window` output (`'73% · 2h'`), `'!'` after an auth
|
||||
failure, `'…'` after a transient error, `''` before the first poll.
|
||||
- The tray gets `set_icon`/`set_tooltip`, but only when a cached key changed.
|
||||
The tooltip uses the same panel texts, with `'...'` for an empty one.
|
||||
4. In the other direction, JS calls `invoke` with `bubble_ready`, `bubble_click`,
|
||||
`bubble_drag_start`, `bubble_resize_step`, `show_context_menu`,
|
||||
`get_bubble_state`, `get_panel_state` and `log_js`. On Windows, the subclass
|
||||
hook delivers move end, setting changes and display changes to Rust. On other
|
||||
platforms the armed Moved debounce does the same.
|
||||
5. Persistence: every change runs `Settings::save` (atomic write through tmp and
|
||||
rename). Other writes: the update staging file and `update-error.log` under
|
||||
`data_local_dir()/ClaudeCodeUsageBubble/updates/`, the AUMID icon PNG next to
|
||||
them and the HKCU AppUserModelId key.
|
||||
|
||||
## Backwards compatibility and migration
|
||||
|
||||
- **Settings:** same file and keys (D7). An old build reading a file written by
|
||||
the new one still loads, because there are no new keys.
|
||||
- **Autostart:** same Run value name, so no migration by default (D10).
|
||||
- **Updates:** how each released version hands over to the Tauri exe (verified
|
||||
with `git show <tag>:src/update/install.rs`):
|
||||
|
||||
| Old version | Who swaps the file | How the Tauri exe is started |
|
||||
|-------------|--------------------|------------------------------|
|
||||
| v0.1.9 and earlier | `cmd.exe` one-liner (2 s wait, `move`, `start`) | no args |
|
||||
| v0.1.10 – v0.3.12 | the old app, in-process rename | `--wait-pid <pid> --updated-to X` |
|
||||
| v0.3.13 – v0.4.0 | `updater-helper-<pid>.exe`, a copy of the **old** exe, run as `--apply-update`; leaves `<exe>.old.<pid>` | `--updated-to X` only |
|
||||
| v0.4.1 – v0.5.0 | `self_replace` in the old app | `--wait-pid <pid> --updated-to X` |
|
||||
|
||||
No version runs the downloaded binary with `--apply-update`, so the Tauri exe
|
||||
drops that handler. It still accepts `--wait-pid`, `--updated-to` and a bare
|
||||
start, and its startup cleanup deletes `<exe>.old.*` and
|
||||
`updates/updater-helper-*.exe`. Phase 10 checks statically, against the
|
||||
updater code at the v0.4.0, v0.4.1 and v0.5.0 tags, that the new release
|
||||
assets satisfy each of them. The end-to-end swap is not rehearsed.
|
||||
- **WebView2:** before the Builder, `cfg(windows)` checks
|
||||
`GetAvailableCoreWebView2BrowserVersionString`. If the runtime is missing, a
|
||||
`MessageBoxW` shows the Evergreen download link and the process exits 0. An
|
||||
auto-updated user on such a machine therefore sees why and how to fix it,
|
||||
instead of a silent dead app. The README documents the same requirement.
|
||||
|
||||
## Test matrix
|
||||
|
||||
| Level | What | Where |
|
||||
|-------|------|-------|
|
||||
| Unit (Rust) | Geometry (aspect, height, snap, clamp, default position, `place_near`, `rect_covers_monitor`, style bits), settings load/normalise/validate and legacy JSON, i18n `format_window`/`format_countdown`/`time_until_display_change`/half-to-even, `usage_color` band edges, `apply_results` thresholds and cooldown, payload builders, update schedule, version parsing, badge pixel sampling, provider JSON/header/ISO fixtures, proxy string parsing, menu model, visibility transitions, CLI parsing | `cargo test` on Linux host and CI Windows/Linux |
|
||||
| Unit (JS) | `ui/layout.js` geometry, pulse triangle and brighten, remaining fraction, `scripts/make-latest-json.js` | `npm test` (`node --test "tests/js/**/*.test.js"`) |
|
||||
| Static | `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `npm run lint` | host plus CI |
|
||||
| Build | `npm run tauri build -- --no-bundle` (or `cargo build`) on the host, on `windows-latest` and on `ubuntu-22.04`; `cargo check --all-targets` on `macos-latest`; AppImage and `.deb` bundles only in the `release.yml` Linux x64 job (patchelf on the runner) | host plus CI |
|
||||
| Static compatibility | The legacy-client table in Phase 10: the asset name, digest and release JSON fields that v0.4.0, v0.4.1 and v0.5.0 read, checked against their tagged code, plus `make-latest-json.js` guards | Phase 10 (written review plus `npm test`) |
|
||||
| E2E (manual) | Windows parity checklist (Phase 11), Linux X11 smoke run | Windows machine with the CI artifact, Linux x86_64 X11 desktop |
|
||||
|
||||
No end-to-end update rehearsal runs. The legacy-to-Tauri swap is first
|
||||
exercised by real users of the first Tauri release (residual risk, Phase 10).
|
||||
|
||||
## Rollback
|
||||
|
||||
- Each phase is one or more focused commits on `feat/tauri-port`. Rolling a
|
||||
phase back means reverting its commits; `main` is untouched until merge.
|
||||
- After release, if the Tauri build is broken in the field, run
|
||||
`release-legacy.yml` (Phase 10) with a ref holding the v0.5.0 sources and a
|
||||
higher version. It builds with plain `cargo build --release`, signs the exe
|
||||
with the same key and publishes a Windows-only `latest.json`. Old clients take
|
||||
it through the GitHub API, Tauri Windows clients through the plugin, and Tauri
|
||||
Linux clients get `TargetNotFound`, which maps to Up to date. The workflow is
|
||||
kept because it is one small file. Phase 10 only lints it and checks its build
|
||||
and signing in a draft dry run; the rollback swap itself is unproven.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- [ ] Every row of the Windows parity checklist (Phase 11) passes on Windows 10 and 11.
|
||||
- [ ] The Phase 10 legacy-client compatibility table is filled in from `git show <tag>:src/update/…` for v0.4.0, v0.4.1 and v0.5.0, every row passes against the dry-run asset list, and the residual risk is recorded.
|
||||
- [ ] `cargo fmt --check`, `cargo test`, `clippy -D warnings`, `npm run lint`, `npm test` and `npm run tauri build -- --no-bundle` are green on `windows-latest`, `ubuntu-22.04` and this host.
|
||||
- [ ] The `release.yml` dry-run draft holds exactly `claude-code-usage-bubble.exe`, its `.sig`, one x86_64 AppImage with `.sig`, one x86_64 `.deb` and a `latest.json` with only `windows-x86_64` and `linux-x86_64`.
|
||||
- [ ] The `macos-latest` `cargo check --all-targets` CI row is green.
|
||||
- [ ] The Linux x86_64 build runs on an X11 desktop, showing the bubble, data, menu, drag and snap.
|
||||
- [ ] No `windows` crate use exists outside `src/platform/windows.rs`.
|
||||
|
||||
## Open questions
|
||||
|
||||
None. Later and undecided work lives in the root `ROADMAP.md`.
|
||||
|
||||
User actions (not questions): generate and store the signing key (Phase 09), and
|
||||
approve the Phase 10 `workflow_dispatch` dry run, which creates (and afterwards
|
||||
deletes) a draft release in this repo.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Rename to usage-monitor
|
||||
description: >-
|
||||
Rename the repository and product from claude-code-usage-bubble to
|
||||
usage-monitor once the Tauri port lands.
|
||||
status: pending
|
||||
priority: P3
|
||||
branch: main
|
||||
tags:
|
||||
- rename
|
||||
blockedBy:
|
||||
- 261021-1028-tauri-v2-port
|
||||
blocks: []
|
||||
created: '2026-10-21T05:26:00.000Z'
|
||||
---
|
||||
|
||||
# Rename To usage-monitor
|
||||
|
||||
## Decision
|
||||
|
||||
The user chose `usage-monitor` (display name "Usage Monitor") on 2026-10-21.
|
||||
Both Claude Code and Codex call their quota "usage", and the name does not tie
|
||||
the app to the bubble shape or to one provider. This is a personal tool, so the
|
||||
user's preference decides the name.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Installed legacy copies update only from an asset named
|
||||
`claude-code-usage-bubble.exe` in `tiennm99/claude-code-usage-bubble`
|
||||
releases. The first releases after the rename must keep publishing that
|
||||
asset name, and the old repository URL must keep redirecting.
|
||||
- Whether the legacy WinHTTP updater follows GitHub's API redirect after a
|
||||
repository rename is unverified. Verify it before relying on the redirect.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Rename the GitHub repository to `tiennm99/usage-monitor` with
|
||||
`gh repo rename`, then update `origin` and move the local checkout to
|
||||
`tiennm99/usage-monitor`.
|
||||
2. Update the updater's repository constant, `productName`, README, release
|
||||
workflow and docs to the new name. Keep publishing the legacy `.exe` asset
|
||||
name alongside the new one.
|
||||
3. Confirm a legacy copy still finds the update through the redirect.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Final Audit: Tauri v2 Port (`feat/tauri-port` vs `main`)
|
||||
|
||||
Date: 2026-10-21. Scope: 55 commits `main..HEAD`, 124 files, about +22.9k / -6.7k
|
||||
lines. Plan: `plans/261021-1028-tauri-v2-port/plan.md`.
|
||||
|
||||
## Verdict
|
||||
|
||||
The automatable side is done. Every row of the feature-to-phase map has code
|
||||
behind it, the latest CI run is green on Windows, Linux and macOS, nothing
|
||||
Win32-only is left over, and the commits are clean. What remains is user-only
|
||||
work: the signing key, the release dry runs, and the manual Windows parity and
|
||||
Linux X11 checks.
|
||||
|
||||
## 1. Feature parity (plan.md feature-to-phase map)
|
||||
|
||||
| Feature | Evidence |
|
||||
|---------|----------|
|
||||
| Bubble styles: topmost, no taskbar, no activation, move cursor | `src/bubble.rs:323-329`, `src/platform/windows.rs:193` (`apply_no_activate`), `:292` (`SW_SHOWNOACTIVATE`), `ui/bubble.css:40` |
|
||||
| Stadium, size 140..360 step 20 | `src/core/geom.rs:77-80` |
|
||||
| Rings, time ring, bars, text, '5H' fallback, placeholders | `ui/bubble.js:198` (dasharray), `ui/layout.js:10`, `:334`; strings in `src/core/payload.rs`, `src/core/i18n.rs` |
|
||||
| Colours, theme, live re-theme (intentional change 5) | `src/platform/windows.rs:57-62`, `WM_SETTINGCHANGE` hook `:309` and `src/bubble.rs:414`; Linux portal `src/platform/linux.rs:217-220` |
|
||||
| Pulse at 95%+, 60 s time-progress refresh | `ui/layout.js:19`, `:275`; `ui/bubble.js:53-54` |
|
||||
| Drag, click vs drag, snap, clamp | `ui/bubble.js:52`, `:389-414`; `src/core/geom.rs:179` (clamp), `:220` (snap); move-end hook `src/platform/windows.rs:307`; X11 debounce `src/bubble.rs:40`, `:517` |
|
||||
| Offscreen check on load, reclamp on setting/display change, Codex stagger | `src/runtime.rs:939` to `src/core/settings.rs:242`; `src/bubble.rs:420`; `src/core/geom.rs:89` |
|
||||
| Default position, DPI change, Ctrl+wheel resize | `src/core/geom.rs:157`; `src/bubble.rs:512`; `ui/bubble.js:453-462` to `src/commands.rs:84` |
|
||||
| Hit area: dead corners (intentional change 1) | `ui/bubble.css:30-47` |
|
||||
| Fullscreen auto-hide | `src/fullscreen.rs:15`; `src/platform/windows.rs:449-471`; `src/platform/linux.rs:163` |
|
||||
| `user_hidden` / `hidden_by_fullscreen` | `src/core/visibility.rs:10-25`; `src/runtime.rs:471-481` |
|
||||
| Panel: 280x120, blur dismissal, no Alt-Tab, Win11 corners, live refresh | `src/panel.rs:35-36`, `:352`, `:187`, `:318`; `src/platform/windows.rs:246-252`; `src/core/geom.rs:366` (`place_near`) |
|
||||
| Tray per provider, badge, 4-line tooltip, left-click toggle | `src/tray.rs:2`, `:202`, `:249-265`; `src/badge.rs:20` |
|
||||
| Context menu, check and grey states, OpenCode Go greyed | `src/core/menu_model.rs:173`, `:374`; `src/menu.rs:190`, `:208` |
|
||||
| Threshold, session-expired and update-applied alerts, 30 min cooldown, AUMID | `src/core/state.rs:16`, `:48-50`; `src/runtime.rs:351`, `:1026`; `src/platform/windows.rs:124` |
|
||||
| Poll scheduling, in-flight gate, credential refresh | `src/scheduler.rs:1-31`; `src/runtime.rs:170`, `:282-294`, `:305-345`; `src/usage/refresh.rs:65-104` |
|
||||
| Provider toggle invariant | `src/core/settings.rs:165-173`; `src/runtime.rs:441-446` |
|
||||
| Reset position, Restart, Exit, CLI flags, `--diagnose`, WebView2 guard | `src/runtime.rs:499`, `:404-405`, `:708`; `src/cli.rs:44`; `src/diag/mod.rs:19`; `src/runtime.rs:844-848`, `src/platform/windows.rs:90-110` |
|
||||
| Single instance (per session, intentional change 2) | `src/runtime.rs:873` |
|
||||
| Settings path, atomic save, normalise | `src/core/settings.rs:191`, `:250-257` |
|
||||
| Start with Windows (same Run value name) | `src/runtime.rs:56`; `src/platform/windows.rs:169`; `src/menu.rs:78-108` |
|
||||
| Updater: check, comparator, `TargetNotFound` mapped to up to date, self-replace, relaunch, cleanup | `src/update/mod.rs:109-141`, `:145-163`; `src/update/install.rs:43`; `src/handoff.rs:17`, `:79`; `src/runtime.rs:981` |
|
||||
| Providers, creds, HTTP, proxy, TLS | `src/net/mod.rs:19`, `:55`, `:118`; `src/core/proxy.rs`; `Cargo.toml` (`ureq` with `platform-verifier`) |
|
||||
| Icons, version info, manifest | `build.rs` (version merged in, manifest embedded), `res/windows-app-manifest.xml`, `icons/`; CI checks `ProductVersion` against `Cargo.toml` |
|
||||
| CI, release, legacy compatibility, docs | `.github/workflows/ci.yml`, `release.yml`, `release-legacy.yml`, `scripts/make-latest-json.js`, `docs/release-process.md`, `README.md` |
|
||||
|
||||
Deviations from the plan text are documented in the phase notes, not in the
|
||||
plan.md "Intentionally changed behaviour" list:
|
||||
|
||||
- **Windows autostart** goes through `auto-launch` directly (`WindowsEnableMode::CurrentUser`) instead of `tauri-plugin-autostart`. The plugin's builder cannot pin the current-user mode. The Run value name and `is_enabled` behaviour stay the same, and this is recorded in the phase 05 notes.
|
||||
- **Update apply** sets `Update.timeout = 600 s` instead of building a second updater and checking again. The pinned plugin builds each `Update` with no timeout, so a second check was not needed. This is recorded in the phase 09 notes. Neither change alters behaviour users can see.
|
||||
|
||||
## 2. CI
|
||||
|
||||
Latest run on `feat/tauri-port` (HEAD `6407e49`, after this audit's doc fix):
|
||||
<https://github.com/tiennm99/claude-code-usage-bubble/actions/runs/39590034376>
|
||||
is **success**. `windows-latest` ran fmt, clippy, tests, lint, `npm test`, the
|
||||
no-bundle build, the version-resource check and the exe artifact.
|
||||
`ubuntu-22.04` ran the same plus the deb bundle. `macos-latest` ran
|
||||
`cargo check`. The previous HEAD `fa889c1` was also green (run 39588741335).
|
||||
|
||||
Host re-run: `cargo fmt --check` OK. `cargo clippy --all-targets --locked -D warnings`
|
||||
OK. `cargo test --locked` passed 216, with 1 ignored. `npm run lint` OK.
|
||||
`npm test` passed 52. `actionlint` and `yamllint` OK on every workflow.
|
||||
|
||||
## 3. Hygiene
|
||||
|
||||
- **Leftover Win32 code:** none. `src/os/`, `src/tray/`, `src/app.rs`, `src/settings.rs`, `src/net/winhttp.rs`, `src/update/{release,handoff}.rs` and `res/icon.rc` are deleted. The `windows` crate is used only in `src/platform/windows.rs`; `std::os::windows` in `src/handoff.rs:52` and `src/usage/refresh.rs:103` is std, behind `cfg(windows)`. `res/windows-app-manifest.xml` is embedded by `build.rs`. `src/icons/*` are the icon sources and stay.
|
||||
- **Commits:** all 55 are authored by `tiennm99 <tiennm99@outlook.com>`, carry no trailers and contain no AI attribution. Messages follow conventional commits and hold no phase numbers, decision IDs or finding codes.
|
||||
- **Code comments:** no phase, decision or finding IDs. The only hits are `F5`/`F12` key names, `Alt+F4` and the pulse wave "phase", all of which are legitimate.
|
||||
- **Docs:**
|
||||
- `README.md` matches the code: badges, WebView2, Linux dependencies, Wayland and tray limits, the `--diagnose` and file paths, the autostart label, the visible intentional changes, and build from source.
|
||||
- `ROADMAP.md` matches the plan's out-of-scope list.
|
||||
- `docs/release-process.md` said "Both release workflows refuse to build while [pubkey] is empty". `release-legacy.yml` never reads the pubkey; it checks only the private key secret. **Fixed** in `6407e49` (`docs: say only the Tauri release workflow checks the updater public key`), and CI is green on it.
|
||||
|
||||
## Larger gaps (not fixed here; user action or plan bookkeeping)
|
||||
|
||||
1. **Release blocker:** `tauri.conf.json` `plugins.updater.pubkey` is `""`. `release.yml` refuses to run until it is set, and `build.rs` refuses a signed build without it. The user must generate the key pair, set the pubkey, store both secrets and keep an offline backup.
|
||||
2. **`release.yml` dispatch dry run not run.** This blocks the success criterion on the six-asset draft. Its dry run needs the key (item 1). `release-legacy.yml` has a separate, later prerequisite: GitHub dispatches it only from the default branch, so it can only be dry-run after the merge (`docs/release-process.md`).
|
||||
3. **Manual checks open:** the Windows parity checklist (below), the D9 toast spike (phase 05 gate), and the Linux X11 smoke run.
|
||||
4. **Plan bookkeeping is stale:** the plan.md frontmatter says `status: pending`, and the phase table shows 05 in progress and 06-11 pending. The phase files say 01-04 are completed and 05-11 are in progress, waiting only on manual checks. Leave the fix to the plan owner.
|
||||
5. **Low:** `Registry::credential_signatures` (`src/usage/registry.rs:69`) has no callers. It is portable code and was already dead on `main`, so it is not Win32 debris. Remove it opportunistically.
|
||||
|
||||
## Manual Windows parity checklist (CI exe artifact; Windows 10 and 11; 100% and 150%)
|
||||
|
||||
1. **First launch:** the bubble appears bottom right, with Codex stacked above Claude. It has no taskbar or Alt-Tab entry, stays topmost and never steals focus.
|
||||
2. **Saved position on an unplugged monitor:** at startup the bubble resets to the default position.
|
||||
3. **Visuals** match a v0.5.0 screenshot: rings, bars, text, colours, the 95% pulse. A light/dark switch applies live.
|
||||
4. **Click and drag:** a click toggles the panel and a drag moves the bubble. Corner, edge, taskbar and peer snaps work, and the position survives a restart. Hovering right after a drag does not start a new drag. The transparent corners ignore clicks.
|
||||
5. **Panel:**
|
||||
- Its layout and palette match v0.5.0.
|
||||
- It opens below the bubble and flips above it near the bottom edge.
|
||||
- It hides on blur and on a second click of the same bubble. Clicking the other bubble swaps its content.
|
||||
- It has no Alt-Tab or taskbar entry, and its corners are round on Windows 11.
|
||||
- Values refresh live while it is open.
|
||||
- Right-click, F5 and Ctrl+wheel do nothing.
|
||||
6. **Resize:** Ctrl+wheel resizes from 140 to 360 around the centre, and every bubble resizes. The menu size items grey out at the limits.
|
||||
7. **Right-click menu:** the full structure, check marks and every action work. Re-checking a checked frequency or interval, and unchecking the last provider, leave the marks correct. A click elsewhere dismisses the menu.
|
||||
8. **Tray:** one icon per provider, the badge, the 4-line tooltip, left-click toggle and right-click menu.
|
||||
9. **Alerts:** the threshold, session-expired and update-applied toasts appear from a fresh portable exe with no Start-menu shortcut. The 30-minute cooldown holds.
|
||||
10. **Fullscreen and displays:** the bubbles hide while an app is fullscreen and come back after it. A DPI change or monitor unplug reclamps them.
|
||||
11. **Autostart:**
|
||||
- A legacy v0.5.0 Run entry shows checked.
|
||||
- Two launches leave one entry.
|
||||
- The toggle works.
|
||||
- A Task Manager disable survives two launches.
|
||||
12. **Instances:** a second launch exits silently. Restart relaunches and the old process is gone. Exit ends the process.
|
||||
13. **No WebView2:** in Windows Sandbox or an LTSC VM, the exe shows the download-link message box and exits.
|
||||
14. **Diagnostics:** `--diagnose` writes `%TEMP%\claude-code-usage-bubble.log`.
|
||||
15. **Legacy update (after the first real release):** v0.4.0, v0.4.1 and v0.5.0 each self-update into the Tauri exe. Settings and autostart are kept, and the `.old.*` and `updater-helper-*` leftovers are cleaned up.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
None beyond the user actions above.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Cross-Platform Framework Options
|
||||
|
||||
Researched 2026-10-21. Scope: run the bubble on Windows, macOS, and Linux from one codebase.
|
||||
|
||||
## Answer
|
||||
|
||||
Use **Tauri v2**. It keeps the project in Rust, so the provider, credential, settings, and usage-model code (`src/usage/`, `src/creds/`, `src/settings.rs`, about 1,500 lines) moves over with small changes. Tauri covers the parts that are Win32-only today: a transparent, frameless, always-on-top window, the tray, and an updater plugin. The bubble and panel drawing in `src/bubble.rs` and `src/panel.rs`, about 2,600 lines of tiny-skia and GDI code, would be rewritten as HTML, CSS, and SVG. A circle with a progress ring is simple to draw that way.
|
||||
|
||||
No framework removes all per-OS work. The OS limits below apply to every option.
|
||||
|
||||
## Current state
|
||||
|
||||
The app is 7,500 lines of Rust written directly against Win32 through the `windows` crate. The Win32-bound modules are `bubble.rs`, `app.rs`, `panel.rs`, `tray/`, `net/winhttp.rs`, `os/`, and `update/install.rs`. The portable modules are the provider clients, the credential parsing, the settings, and the usage types.
|
||||
|
||||
## Options compared
|
||||
|
||||
| Option | Language | What carries over | Fit for a small floating widget | Maturity |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Tauri v2** | Rust + web UI | All non-UI Rust | Good. It has transparent, `alwaysOnTop`, `skipTaskbar`, tray, and updater support. Uses the system webview, so the binary stays small. | Stable, with releases through mid-2026 |
|
||||
| winit + tiny-skia + `tray-icon` + `muda` | Rust | Non-UI Rust plus the tiny-skia renderer | Good in theory, but you build drag, DPI, menus, and updates yourself. Transparent presentation through `softbuffer` is uneven across platforms (unverified). | These are the crates Tauri uses internally |
|
||||
| egui / eframe | Rust | Non-UI Rust | Fair. There are open reports of transparent windows rendering black, and tray support is not built in. | Stable, but the API changes often |
|
||||
| Wails v3 | Go | Nothing. This is a full rewrite. | Good. It has frameless and transparent windows plus a tray with an attached window. | **Beta** (v3.0.0-beta.x), and the docs still say alpha |
|
||||
| Electron | JS | Nothing. This is a full rewrite. | Works, but it ships about 100 MB of Chromium for a 100 px circle. | Stable |
|
||||
|
||||
Wails v3 is the best match for your language ranking (Go first). It is still beta, though, and it means throwing away working Rust. Tauri gives you cross-platform support without a rewrite.
|
||||
|
||||
## Limits that apply to every framework
|
||||
|
||||
- **Linux Wayland:** Wayland does not let an app place its own window or keep it always on top. There is no core protocol for either, and wlr-layer-shell exists only on some compositors. On GNOME and KDE Wayland, the bubble's drag-to-position, edge snapping, and "always on top" will fail or do nothing. The workarounds are to run the app under XWayland (for example `GDK_BACKEND=x11`) or to treat Wayland as best-effort.
|
||||
- **macOS credentials:** Claude Code stores its token in the macOS Keychain, not in a file. Third-party code reports the Keychain service name as `Claude Code-credentials`; check that on a real Mac. Linux uses `~/.claude/.credentials.json`, the same as Windows. `src/creds/` needs a Keychain backend, for example the `keyring` or `security-framework` crate.
|
||||
- **macOS transparency:** Tauri needs `macOSPrivateApi` plus the `macos-private-api` Cargo feature. That rules out the Mac App Store, which a GitHub-release app doesn't need. One open issue reports transparency working in `tauri dev` but rendering white in a bundled build.
|
||||
- **Fullscreen auto-hide:** Detecting a fullscreen foreground app is OS-specific under any framework. Expect a small `#[cfg(target_os)]` module for each OS.
|
||||
- **Self-update:** Each OS needs its own approach. Windows replaces the .exe, macOS replaces the .app bundle, and Linux replaces an AppImage (Tauri's updater supports all three). The updater requires signed update artifacts. Unsigned macOS builds also hit Gatekeeper, which is stricter than SmartScreen, so you need an Apple Developer ID ($99 a year) to ship macOS properly.
|
||||
- **Startup entry:** The Windows registry Run key becomes a LaunchAgent on macOS and an XDG autostart file on Linux. The Tauri `autostart` plugin handles all three.
|
||||
|
||||
## Suggested migration path
|
||||
|
||||
```text
|
||||
1. Split crate: core (usage, creds, settings; swap WinHTTP -> ureq/reqwest) + platform shell
|
||||
2. Add Tauri v2 shell: transparent always-on-top window, tray, autostart, updater plugins
|
||||
3. Port bubble/panel rendering to HTML/CSS/SVG; drag via data-tauri-drag-region or startDragging()
|
||||
4. Per-OS shims: macOS Keychain creds, fullscreen detection
|
||||
5. CI matrix: windows / macos / ubuntu builds + signed updater manifests
|
||||
```
|
||||
|
||||
Step 1 is worth doing whichever framework you choose, because it isolates the provider logic from the UI.
|
||||
|
||||
## Next steps
|
||||
|
||||
1. Spike Tauri v2 for a day. Build only a transparent, round, draggable, always-on-top window with a tray icon, and test it on Windows, macOS, and Linux (both X11 and Wayland). Decide based on that result, not on the docs.
|
||||
2. If the spike passes, plan the core/shell split before porting any UI.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Is macOS in scope, and will you pay for an Apple Developer ID? Without one, macOS distribution is painful.
|
||||
- Which Linux targets matter? Is Wayland-first GNOME/KDE in scope, or is X11/XWayland acceptable?
|
||||
- Would you accept a Go rewrite (Wails v3, still beta) to follow your language preference, or is keeping Rust the priority?
|
||||
|
||||
## Follow-up decisions (2026-10-21)
|
||||
|
||||
The user set the platform priority to Windows first, then Linux, then macOS. macOS and Wayland support are optional, but the stack must allow porting to them later. The user will not buy an Apple Developer ID and is open to either Rust or Go.
|
||||
|
||||
- **Unsigned macOS can still ship.** On macOS 15 Sequoia and later, Control-click → Open no longer bypasses Gatekeeper. Users open the app once, then click **Privacy & Security → Open Anyway**, or run `xattr -d com.apple.quarantine <app>`. Tauri's updater signs updates with its own free key pair, so it does not need an Apple certificate.
|
||||
- **Wails v3 is still in beta.** The latest release found is v3.0.0-beta.12, whose notes say the API is stable. On macOS and Linux it needs cgo (Cocoa and WebKitGTK bindings), which breaks the pure-Go, `CGO_ENABLED=0` rule. On Windows it does not need cgo.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Tauri release history](https://tauri.app/release/tauri/all-versions/)
|
||||
- [Tauri v2 window configuration notes](https://dev.to/rain9/tauri-part-3-get-the-window-configuration-right-first-3gf7)
|
||||
- [Tauri issue: transparency white in bundled macOS build](https://github.com/tauri-apps/tauri/issues/13415)
|
||||
- [Wails v3 what's new](https://v3.wails.io/ru/whats-new), [Wails v3 frameless windows](https://v3.wails.io/features/windows/frameless/), [Wails v3.0.0-beta.2](https://newreleases.io/project/github/wailsapp/wails/release/v3.0.0-beta.2)
|
||||
- [egui transparency discussion](https://de.github.com/emilk/egui/discussions/4446)
|
||||
- [Wayland: always-on-top protocol request](https://gitlab.freedesktop.org/wayland/wayland/-/issues/305), [Wayland: can't set top-level position](https://gitlab.freedesktop.org/wayland/wayland/-/issues/183)
|
||||
- [Claude Code authentication docs](https://code.claude.com/docs/en/authentication.md), [ai-usagebar keychain source](https://docs.rs/crate/ai-usagebar/0.21.0/source/src/anthropic/keychain.rs)
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
#include <winver.h>
|
||||
|
||||
1 ICON "..\\src\\icons\\icon.ico"
|
||||
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,1,0,0
|
||||
PRODUCTVERSION 0,1,0,0
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS 0x40004L
|
||||
FILETYPE 0x1L
|
||||
FILESUBTYPE 0x0L
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904E4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "tiennm99\0"
|
||||
VALUE "FileDescription", "Claude Code Usage Bubble\0"
|
||||
VALUE "FileVersion", "0.1.0\0"
|
||||
VALUE "InternalName", "ClaudeCodeUsageBubble\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 tiennm99\0"
|
||||
VALUE "OriginalFilename", "claude-code-usage-bubble.exe\0"
|
||||
VALUE "ProductName", "Claude Code Usage Bubble\0"
|
||||
VALUE "ProductVersion", "0.1.0\0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1252
|
||||
END
|
||||
END
|
||||
@@ -0,0 +1,20 @@
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,192 @@
|
||||
// Write the Tauri updater manifest (`latest.json`) for a release.
|
||||
//
|
||||
// Usage: node scripts/make-latest-json.js [--windows-only] <tag> <dir>
|
||||
//
|
||||
// `<dir>` holds exactly the release assets, already renamed to space-free
|
||||
// names. The script checks that set before it writes `<dir>/latest.json`,
|
||||
// because the same files are uploaded next to the manifest:
|
||||
//
|
||||
// - Legacy clients (v0.5.0 and earlier) read `/releases/latest` through the
|
||||
// GitHub API and download the asset named `claude-code-usage-bubble.exe`,
|
||||
// else the first asset ending in `.exe`. So exactly one `.exe` asset may
|
||||
// exist, under that exact name.
|
||||
// - GitHub rewrites spaces in asset names, so a name with whitespace would no
|
||||
// longer match the URL written into the manifest.
|
||||
// - Every platform entry the app looks up must be present; a missing one
|
||||
// would leave those installs without updates.
|
||||
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const REPO = 'tiennm99/claude-code-usage-bubble';
|
||||
export const WINDOWS_EXE = 'claude-code-usage-bubble.exe';
|
||||
export const MANIFEST_NAME = 'latest.json';
|
||||
|
||||
const TAG_PATTERN = /^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/;
|
||||
|
||||
/**
|
||||
* @typedef {object} PlatformEntry
|
||||
* @property {string} url Release-download URL of the update asset.
|
||||
* @property {string} signature Content of the asset's `.sig` file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} Manifest
|
||||
* @property {string} version Version without the leading `v`.
|
||||
* @property {string} notes Short release note.
|
||||
* @property {string} pub_date RFC 3339 publication time.
|
||||
* @property {Record<string, PlatformEntry>} platforms Entries by updater target.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The version a release tag announces.
|
||||
* @param {string} tag Tag such as `v1.2.3` or `v0.0.0-test`.
|
||||
* @returns {string} The version without the leading `v`.
|
||||
* @throws {Error} When the tag is not `v<major>.<minor>.<patch>[-suffix]`.
|
||||
*/
|
||||
export function versionFromTag(tag) {
|
||||
const match = TAG_PATTERN.exec(tag);
|
||||
if (!match) {
|
||||
throw new Error(`tag "${tag}" is not of the form vX.Y.Z or vX.Y.Z-suffix`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* The updater platforms a release carries, with the asset each one installs.
|
||||
* @param {string} version Release version.
|
||||
* @param {boolean} windowsOnly True for a Windows-only (legacy rollback) release.
|
||||
* @returns {Record<string, string>} Asset name by updater target.
|
||||
*/
|
||||
export function platformAssets(version, windowsOnly) {
|
||||
/** @type {Record<string, string>} */
|
||||
const platforms = { 'windows-x86_64': WINDOWS_EXE };
|
||||
if (!windowsOnly) {
|
||||
platforms['linux-x86_64'] = `claude-code-usage-bubble_${version}_amd64.AppImage`;
|
||||
}
|
||||
return platforms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every asset a release must upload besides `latest.json`.
|
||||
* @param {string} version Release version.
|
||||
* @param {boolean} windowsOnly True for a Windows-only release.
|
||||
* @returns {string[]} Asset names, sorted.
|
||||
*/
|
||||
export function expectedAssets(version, windowsOnly) {
|
||||
const names = Object.values(platformAssets(version, windowsOnly)).flatMap((n) => [n, `${n}.sig`]);
|
||||
if (!windowsOnly) {
|
||||
names.push(`claude-code-usage-bubble_${version}_amd64.deb`);
|
||||
}
|
||||
return names.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a release's asset names against what every client needs.
|
||||
* @param {string[]} names Asset names, `latest.json` excluded.
|
||||
* @param {string} version Release version.
|
||||
* @param {boolean} windowsOnly True for a Windows-only release.
|
||||
* @throws {Error} On a name with whitespace, a second `.exe`, a missing asset or an unexpected one.
|
||||
*/
|
||||
export function checkAssetNames(names, version, windowsOnly) {
|
||||
const spaced = names.filter((n) => /\s/.test(n));
|
||||
if (spaced.length > 0) {
|
||||
throw new Error(`asset names must not contain whitespace: ${spaced.join(', ')}`);
|
||||
}
|
||||
const strayExe = names.filter((n) => n.toLowerCase().endsWith('.exe') && n !== WINDOWS_EXE);
|
||||
if (strayExe.length > 0) {
|
||||
throw new Error(
|
||||
`the only .exe asset allowed is ${WINDOWS_EXE}; legacy clients could pick: ${strayExe.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const expected = expectedAssets(version, windowsOnly);
|
||||
const missing = expected.filter((n) => !names.includes(n));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`missing release assets: ${missing.join(', ')}`);
|
||||
}
|
||||
const unexpected = names.filter((n) => !expected.includes(n));
|
||||
if (unexpected.length > 0) {
|
||||
throw new Error(`unexpected release assets: ${unexpected.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The download URL GitHub serves a release asset from.
|
||||
* @param {string} tag Release tag.
|
||||
* @param {string} name Asset name.
|
||||
* @returns {string} The URL.
|
||||
*/
|
||||
export function assetUrl(tag, name) {
|
||||
return `https://github.com/${REPO}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the manifest for a release whose asset names were already checked.
|
||||
* @param {object} input Release facts.
|
||||
* @param {string} input.tag Release tag.
|
||||
* @param {boolean} input.windowsOnly True for a Windows-only release.
|
||||
* @param {(name: string) => string} input.readSignature Content of the named `.sig` asset.
|
||||
* @param {Date} input.now Publication time.
|
||||
* @returns {Manifest} The manifest.
|
||||
* @throws {Error} When a signature is empty.
|
||||
*/
|
||||
export function buildManifest({ tag, windowsOnly, readSignature, now }) {
|
||||
const version = versionFromTag(tag);
|
||||
/** @type {Record<string, PlatformEntry>} */
|
||||
const platforms = {};
|
||||
for (const [target, name] of Object.entries(platformAssets(version, windowsOnly))) {
|
||||
const signature = readSignature(`${name}.sig`).trim();
|
||||
if (signature === '') {
|
||||
throw new Error(`${name}.sig is empty`);
|
||||
}
|
||||
platforms[target] = { url: assetUrl(tag, name), signature };
|
||||
}
|
||||
return {
|
||||
version,
|
||||
notes: `See https://github.com/${REPO}/releases/tag/${tag}`,
|
||||
pub_date: now.toISOString(),
|
||||
platforms,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the assets in `dir` and write `dir/latest.json`.
|
||||
* @param {string[]} argv Arguments after the script path.
|
||||
* @param {Date} [now] Publication time.
|
||||
* @returns {Manifest} The manifest written.
|
||||
* @throws {Error} On bad arguments or any failed check.
|
||||
*/
|
||||
export function run(argv, now = new Date()) {
|
||||
const windowsOnly = argv.includes('--windows-only');
|
||||
const positional = argv.filter((a) => a !== '--windows-only');
|
||||
if (positional.length !== 2 || positional.some((a) => a.startsWith('--'))) {
|
||||
throw new Error('usage: make-latest-json.js [--windows-only] <tag> <dir>');
|
||||
}
|
||||
const [tag, dir] = positional;
|
||||
const version = versionFromTag(tag);
|
||||
const names = readdirSync(dir).filter((n) => n !== MANIFEST_NAME);
|
||||
const notFiles = names.filter((n) => !statSync(join(dir, n)).isFile());
|
||||
if (notFiles.length > 0) {
|
||||
throw new Error(`not a file: ${notFiles.join(', ')}`);
|
||||
}
|
||||
checkAssetNames(names, version, windowsOnly);
|
||||
const manifest = buildManifest({
|
||||
tag,
|
||||
windowsOnly,
|
||||
readSignature: (name) => readFileSync(join(dir, name), 'utf8'),
|
||||
now,
|
||||
});
|
||||
writeFileSync(join(dir, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
const manifest = run(process.argv.slice(2));
|
||||
console.log(`latest.json ${manifest.version}: ${Object.keys(manifest.platforms).join(', ')}`);
|
||||
} catch (err) {
|
||||
console.error(`make-latest-json: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
-1527
File diff suppressed because it is too large.
Load diff
+228
@@ -0,0 +1,228 @@
|
||||
//! Anti-aliased tray badge rendering.
|
||||
//!
|
||||
//! A filled disk plus a percentage-sweep ring, drawn with tiny-skia. The
|
||||
//! badge is intentionally text-free: the floating bubble already shows the
|
||||
//! exact percentage, the tray badge is a coarse colour-and-fill indicator.
|
||||
|
||||
use tiny_skia::{FillRule, LineCap, Paint, Path, PathBuilder, Pixmap, Stroke, Transform};
|
||||
|
||||
use crate::core::usage_color;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
/// Width and height of the badge in pixels.
|
||||
pub const BADGE_PX: u32 = 32;
|
||||
|
||||
/// Colour of the unused part of the ring.
|
||||
const TRACK_RGB: [u8; 3] = [0x3a, 0x3a, 0x3a];
|
||||
|
||||
/// Render the badge for `kind` with `percent` of the ring filled, or only
|
||||
/// the track when `percent` is `None`. Premultiplied RGBA.
|
||||
pub fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
|
||||
let mut pixmap = Pixmap::new(BADGE_PX, BADGE_PX).expect("32x32 pixmap");
|
||||
pixmap.fill(tiny_skia::Color::TRANSPARENT);
|
||||
|
||||
let c = BADGE_PX as f32 / 2.0;
|
||||
let outer = c - 1.0;
|
||||
let inner = outer * 0.62;
|
||||
let ring_radius = (inner + outer) / 2.0;
|
||||
let ring_width = outer - inner - 1.0;
|
||||
|
||||
// Filled inner disk in the provider's base tint.
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_circle(c, c, inner);
|
||||
if let Some(path) = pb.finish() {
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint(base_color(kind)),
|
||||
FillRule::Winding,
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// Track ring (the unused portion of the quota).
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_circle(c, c, ring_radius);
|
||||
if let Some(path) = pb.finish() {
|
||||
let stroke = Stroke {
|
||||
width: ring_width,
|
||||
..Stroke::default()
|
||||
};
|
||||
pixmap.stroke_path(
|
||||
&path,
|
||||
&paint(TRACK_RGB),
|
||||
&stroke,
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// Active sweep: the used share of the ring in the usage colour.
|
||||
if let Some(p) = percent {
|
||||
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
||||
if sweep > 0.0 {
|
||||
if let Some(path) = build_arc(c, c, ring_radius, sweep) {
|
||||
let stroke = Stroke {
|
||||
width: ring_width,
|
||||
line_cap: LineCap::Round,
|
||||
..Stroke::default()
|
||||
};
|
||||
pixmap.stroke_path(
|
||||
&path,
|
||||
&paint(usage_rgb(kind, p)),
|
||||
&stroke,
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pixmap
|
||||
}
|
||||
|
||||
/// The pixmap as straight (non-premultiplied) RGBA bytes, the format
|
||||
/// `tauri::image::Image::new_owned` expects.
|
||||
pub fn demultiplied_rgba(pixmap: &Pixmap) -> Vec<u8> {
|
||||
pixmap
|
||||
.pixels()
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let c = p.demultiply();
|
||||
[c.red(), c.green(), c.blue(), c.alpha()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn paint(rgb: [u8; 3]) -> Paint<'static> {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color_rgba8(rgb[0], rgb[1], rgb[2], 255);
|
||||
paint.anti_alias = true;
|
||||
paint
|
||||
}
|
||||
|
||||
/// An open polyline arc from 12 o'clock, clockwise. tiny-skia 0.11 has no
|
||||
/// arc primitive, so the circumference is sampled.
|
||||
fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option<Path> {
|
||||
let segments = ((sweep_fraction * 64.0).ceil() as usize).max(1);
|
||||
let start = -std::f32::consts::FRAC_PI_2;
|
||||
let total = sweep_fraction * std::f32::consts::TAU;
|
||||
let mut pb = PathBuilder::new();
|
||||
for i in 0..=segments {
|
||||
let a = start + (i as f32 / segments as f32) * total;
|
||||
let (x, y) = (cx + a.cos() * radius, cy + a.sin() * radius);
|
||||
if i == 0 {
|
||||
pb.move_to(x, y);
|
||||
} else {
|
||||
pb.line_to(x, y);
|
||||
}
|
||||
}
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
fn base_color(kind: ProviderId) -> [u8; 3] {
|
||||
match kind {
|
||||
// A dark warm tint reads as Claude without copying its exact accent.
|
||||
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
|
||||
// Cool dark slate for Codex.
|
||||
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
|
||||
// Deep green for OpenCode Go.
|
||||
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep colour. The badge disk is always dark whatever the system theme,
|
||||
/// so the dark palette keeps the ring readable (Codex stays white).
|
||||
fn usage_rgb(kind: ProviderId, percent: f64) -> [u8; 3] {
|
||||
let c = usage_color::bar_fill_color(kind, true, percent);
|
||||
[c.r, c.g, c.b]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Straight RGB at a pixel, asserting it is fully opaque.
|
||||
fn opaque_rgb(pixmap: &Pixmap, x: u32, y: u32) -> [u8; 3] {
|
||||
let p = pixmap.pixel(x, y).expect("pixel in range").demultiply();
|
||||
assert_eq!(p.alpha(), 255, "pixel ({x},{y}) is not opaque");
|
||||
[p.red(), p.green(), p.blue()]
|
||||
}
|
||||
|
||||
/// The pixel whose centre lies on the ring radius (12.15) to the right
|
||||
/// of the centre: x = 16 + 12.15 rounds into pixel 28.
|
||||
const TRACK_SAMPLE: (u32, u32) = (28, 16);
|
||||
/// The pixel at 12 o'clock on the ring radius: y = 16 - 12.15 = 3.85.
|
||||
const TOP_SAMPLE: (u32, u32) = (16, 3);
|
||||
|
||||
#[test]
|
||||
fn centre_is_the_provider_disk() {
|
||||
for kind in ProviderId::ALL {
|
||||
let pixmap = render_pixmap(kind, Some(50.0));
|
||||
assert_eq!(opaque_rgb(&pixmap, 16, 16), base_color(kind), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_is_track_coloured_without_a_percent() {
|
||||
let pixmap = render_pixmap(ProviderId::Claude, None);
|
||||
assert_eq!(
|
||||
opaque_rgb(&pixmap, TRACK_SAMPLE.0, TRACK_SAMPLE.1),
|
||||
TRACK_RGB
|
||||
);
|
||||
assert_eq!(opaque_rgb(&pixmap, TOP_SAMPLE.0, TOP_SAMPLE.1), TRACK_RGB);
|
||||
// Zero percent draws no arc either.
|
||||
let zero = render_pixmap(ProviderId::Claude, Some(0.0));
|
||||
assert_eq!(opaque_rgb(&zero, TOP_SAMPLE.0, TOP_SAMPLE.1), TRACK_RGB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_starts_at_twelve_o_clock_for_any_percent() {
|
||||
for kind in ProviderId::LIVE_USAGE {
|
||||
for pct in [0.5, 1.0, 25.0, 59.9, 60.0, 80.0, 95.0, 100.0, 150.0] {
|
||||
let pixmap = render_pixmap(kind, Some(pct));
|
||||
assert_eq!(
|
||||
opaque_rgb(&pixmap, TOP_SAMPLE.0, TOP_SAMPLE.1),
|
||||
usage_rgb(kind, pct),
|
||||
"{kind:?} at {pct}%"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_sweep_leaves_the_left_side_as_track() {
|
||||
let pixmap = render_pixmap(ProviderId::Claude, Some(25.0));
|
||||
// 3 o'clock is the end of the sweep (round cap), 9 o'clock is track.
|
||||
assert_eq!(
|
||||
opaque_rgb(&pixmap, TRACK_SAMPLE.0, TRACK_SAMPLE.1),
|
||||
usage_rgb(ProviderId::Claude, 25.0)
|
||||
);
|
||||
assert_eq!(opaque_rgb(&pixmap, 3, 16), TRACK_RGB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_stay_transparent() {
|
||||
let pixmap = render_pixmap(ProviderId::ChatGpt, Some(100.0));
|
||||
assert_eq!(pixmap.pixel(0, 0).expect("pixel").alpha(), 0);
|
||||
assert_eq!(pixmap.pixel(31, 31).expect("pixel").alpha(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demultiply_restores_straight_alpha() {
|
||||
let mut pixmap = Pixmap::new(2, 1).expect("pixmap");
|
||||
// 50% alpha red: premultiplied (128, 0, 0, 128).
|
||||
pixmap.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 128));
|
||||
assert_eq!(pixmap.data()[..4], [128, 0, 0, 128]);
|
||||
assert_eq!(demultiplied_rgba(&pixmap), [255, 0, 0, 128, 255, 0, 0, 128]);
|
||||
|
||||
let badge = render_pixmap(ProviderId::Claude, Some(42.0));
|
||||
let rgba = demultiplied_rgba(&badge);
|
||||
assert_eq!(rgba.len(), (BADGE_PX * BADGE_PX * 4) as usize);
|
||||
// Opaque pixels are unchanged; transparent ones stay zero.
|
||||
assert_eq!(rgba[..4], [0, 0, 0, 0]);
|
||||
let centre = ((16 * BADGE_PX + 16) * 4) as usize;
|
||||
assert_eq!(rgba[centre..centre + 3], base_color(ProviderId::Claude));
|
||||
assert_eq!(rgba[centre + 3], 255);
|
||||
}
|
||||
}
|
||||
+866
-1967
File diff suppressed because it is too large.
Load diff
+164
@@ -0,0 +1,164 @@
|
||||
//! Command-line flags. Parsing is pure so every accepted form is unit-tested.
|
||||
//!
|
||||
//! Accepted flags:
|
||||
//! - `--diagnose` turns on the diagnostic log.
|
||||
//! - `--wait-pid <pid>` waits for an exiting instance before starting.
|
||||
//! - `--updated-to <version>` announces a finished update.
|
||||
//!
|
||||
//! Anything else, including a flag whose value is missing or malformed, is
|
||||
//! ignored so an old or newer caller can never stop the app from starting.
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
/// Longest version string accepted for `--updated-to`.
|
||||
const MAX_VERSION_LEN: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CliArgs {
|
||||
pub diagnose: bool,
|
||||
pub wait_pid: Option<u32>,
|
||||
pub updated_to: Option<String>,
|
||||
}
|
||||
|
||||
/// The process arguments, parsed. Non-UTF-8 arguments are read lossily
|
||||
/// instead of panicking, as `std::env::args` would.
|
||||
pub fn from_env() -> CliArgs {
|
||||
parse(std::env::args_os().skip(1))
|
||||
}
|
||||
|
||||
/// Parse the arguments after the program name.
|
||||
pub fn parse<I, S>(args: I) -> CliArgs
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
let args: Vec<String> = args
|
||||
.into_iter()
|
||||
.map(|a| a.into().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let mut out = CliArgs::default();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--diagnose" => out.diagnose = true,
|
||||
"--wait-pid" => {
|
||||
if let Some(pid) = args.get(i + 1).and_then(|v| parse_pid(v)) {
|
||||
out.wait_pid = Some(pid);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--updated-to" => {
|
||||
if let Some(version) = args.get(i + 1).filter(|v| is_version_like(v)) {
|
||||
out.updated_to = Some(version.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A positive process id. Zero is rejected: on Unix it names the whole
|
||||
/// process group, never a single parent.
|
||||
fn parse_pid(value: &str) -> Option<u32> {
|
||||
value.parse::<u32>().ok().filter(|pid| *pid != 0)
|
||||
}
|
||||
|
||||
/// A version as the updater passes it (`0.6.0`, `1.2.3-rc.1`). The text ends
|
||||
/// up in a notification, so anything else is dropped.
|
||||
fn is_version_like(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_VERSION_LEN
|
||||
&& value.starts_with(|c: char| c.is_ascii_digit())
|
||||
&& value
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(args: &[&str]) -> CliArgs {
|
||||
parse(args.iter().copied())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_flags_is_the_default() {
|
||||
assert_eq!(p(&[]), CliArgs::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnose_flag() {
|
||||
assert!(p(&["--diagnose"]).diagnose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_pid_takes_the_next_value() {
|
||||
assert_eq!(p(&["--wait-pid", "4242"]).wait_pid, Some(4242));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_pid_rejects_zero_garbage_and_a_missing_value() {
|
||||
assert_eq!(p(&["--wait-pid", "0"]).wait_pid, None);
|
||||
assert_eq!(p(&["--wait-pid", "-5"]).wait_pid, None);
|
||||
assert_eq!(p(&["--wait-pid"]).wait_pid, None);
|
||||
// A following flag is not swallowed as the value.
|
||||
let args = p(&["--wait-pid", "--diagnose"]);
|
||||
assert_eq!(args.wait_pid, None);
|
||||
assert!(args.diagnose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updated_to_alone() {
|
||||
// v0.3.13 to v0.4.0 relaunch with only this flag.
|
||||
assert_eq!(
|
||||
p(&["--updated-to", "0.6.0"]),
|
||||
CliArgs {
|
||||
updated_to: Some("0.6.0".into()),
|
||||
..CliArgs::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updated_to_combined_with_wait_pid() {
|
||||
assert_eq!(
|
||||
p(&[
|
||||
"--wait-pid",
|
||||
"17",
|
||||
"--updated-to",
|
||||
"1.2.3-rc.1",
|
||||
"--diagnose"
|
||||
]),
|
||||
CliArgs {
|
||||
diagnose: true,
|
||||
wait_pid: Some(17),
|
||||
updated_to: Some("1.2.3-rc.1".into()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updated_to_rejects_text_that_is_not_a_version() {
|
||||
assert_eq!(p(&["--updated-to", "--diagnose"]).updated_to, None);
|
||||
assert_eq!(p(&["--updated-to", "v0.6.0"]).updated_to, None);
|
||||
assert_eq!(p(&["--updated-to", "0.6.0 <b>"]).updated_to, None);
|
||||
let long = "1".repeat(MAX_VERSION_LEN + 1);
|
||||
assert_eq!(p(&["--updated-to", &long]).updated_to, None);
|
||||
assert_eq!(p(&["--updated-to"]).updated_to, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_are_ignored() {
|
||||
assert_eq!(
|
||||
p(&["--apply-update", "x", "--wait-pid", "9", "--frobnicate"]),
|
||||
CliArgs {
|
||||
wait_pid: Some(9),
|
||||
..CliArgs::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
//! Commands the webviews invoke. Every argument comes from a webview, so
|
||||
//! each one is validated here before it reaches the runtime.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, State, WebviewWindow};
|
||||
|
||||
use crate::bubble;
|
||||
use crate::core::geom::RESIZE_STEP_LOGICAL;
|
||||
use crate::core::i18n::LocaleStrings;
|
||||
use crate::core::payload::{BubblePayload, PanelPayload};
|
||||
use crate::panel;
|
||||
use crate::runtime::Runtime;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
/// Longest JS log message kept, in characters.
|
||||
const MAX_JS_LOG_CHARS: usize = 2_000;
|
||||
|
||||
/// The current payload for `provider`'s bubble, sized for the calling
|
||||
/// window's scale factor.
|
||||
#[tauri::command]
|
||||
pub fn get_bubble_state(
|
||||
window: WebviewWindow,
|
||||
runtime: State<'_, Arc<Runtime>>,
|
||||
provider: String,
|
||||
) -> Result<BubblePayload, String> {
|
||||
let provider = live_provider(&provider)?;
|
||||
let scale = window.scale_factor().map_err(|err| err.to_string())?;
|
||||
Ok(runtime.bubble_payload(provider, scale))
|
||||
}
|
||||
|
||||
/// The details-panel payload for the provider the panel last showed;
|
||||
/// `None` before the first bubble click.
|
||||
#[tauri::command]
|
||||
pub fn get_panel_state(runtime: State<'_, Arc<Runtime>>) -> Option<PanelPayload> {
|
||||
panel::current_provider().map(|provider| runtime.panel_payload(provider))
|
||||
}
|
||||
|
||||
/// Every UI string, camelCase keys.
|
||||
#[tauri::command]
|
||||
pub fn get_strings(runtime: State<'_, Arc<Runtime>>) -> LocaleStrings {
|
||||
runtime.with_state(|s| s.strings.clone())
|
||||
}
|
||||
|
||||
/// Forward a webview error or message to the diagnostic log.
|
||||
#[tauri::command]
|
||||
pub fn log_js(window: WebviewWindow, level: String, message: String) {
|
||||
let message = sanitize_log_message(&message);
|
||||
let label = window.label();
|
||||
match level.as_str() {
|
||||
"error" => log::error!(target: "js", "[{label}] {message}"),
|
||||
"warn" => log::warn!(target: "js", "[{label}] {message}"),
|
||||
"debug" => log::debug!(target: "js", "[{label}] {message}"),
|
||||
_ => log::info!(target: "js", "[{label}] {message}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bubble rendered its first state and can be shown (unless hidden).
|
||||
#[tauri::command]
|
||||
pub fn bubble_ready(app: AppHandle, label: String) -> Result<(), String> {
|
||||
bubble::mark_ready(&app, &label)
|
||||
}
|
||||
|
||||
/// A left click (a press released before the drag threshold) on a bubble.
|
||||
/// Async, so the panel toggle is queued to the main thread instead of
|
||||
/// running inside the webview's IPC callback.
|
||||
#[tauri::command]
|
||||
pub async fn bubble_click(runtime: State<'_, Arc<Runtime>>, slug: String) -> Result<(), String> {
|
||||
let provider = live_provider(&slug)?;
|
||||
runtime.bubble_clicked(provider);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The page is about to start an OS drag of a bubble.
|
||||
#[tauri::command]
|
||||
pub fn bubble_drag_start(app: AppHandle, slug: String) -> Result<(), String> {
|
||||
let provider = live_provider(&slug)?;
|
||||
bubble::drag_start(&app, provider);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ctrl+wheel on a bubble: `dir` 1 grows every bubble one step, -1 shrinks.
|
||||
#[tauri::command]
|
||||
pub fn bubble_resize_step(runtime: State<'_, Arc<Runtime>>, dir: i32) -> Result<(), String> {
|
||||
let delta = resize_delta(dir)?;
|
||||
runtime.resize_bubbles(delta);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The size change for a resize direction; only 1 and -1 are accepted.
|
||||
fn resize_delta(dir: i32) -> Result<i32, String> {
|
||||
match dir {
|
||||
1 => Ok(RESIZE_STEP_LOGICAL),
|
||||
-1 => Ok(-RESIZE_STEP_LOGICAL),
|
||||
_ => Err("resize direction must be 1 or -1".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn live_provider(slug: &str) -> Result<ProviderId, String> {
|
||||
ProviderId::from_slug(slug)
|
||||
.filter(|p| p.metadata().live_usage)
|
||||
.ok_or_else(|| "unknown provider".to_string())
|
||||
}
|
||||
|
||||
/// One log line: control characters become spaces, so a message cannot
|
||||
/// forge extra log lines, and the length is capped.
|
||||
fn sanitize_log_message(message: &str) -> String {
|
||||
message
|
||||
.chars()
|
||||
.take(MAX_JS_LOG_CHARS)
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_live_providers_are_accepted() {
|
||||
assert_eq!(live_provider("claude"), Ok(ProviderId::Claude));
|
||||
assert_eq!(live_provider("chatgpt"), Ok(ProviderId::ChatGpt));
|
||||
assert!(live_provider("opencode-go").is_err());
|
||||
assert!(live_provider("").is_err());
|
||||
assert!(live_provider("CLAUDE").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_steps_are_one_step_either_way() {
|
||||
assert_eq!(resize_delta(1), Ok(20));
|
||||
assert_eq!(resize_delta(-1), Ok(-20));
|
||||
assert!(resize_delta(0).is_err());
|
||||
assert!(resize_delta(2).is_err());
|
||||
assert!(resize_delta(i32::MIN).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_messages_stay_on_one_capped_line() {
|
||||
assert_eq!(sanitize_log_message("a\nb\r\tc"), "a b c");
|
||||
let long = "x".repeat(MAX_JS_LOG_CHARS + 50);
|
||||
assert_eq!(
|
||||
sanitize_log_message(&long).chars().count(),
|
||||
MAX_JS_LOG_CHARS
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Plain RGB colour helpers shared by the bubble, the panel and the tray badge.
|
||||
//!
|
||||
//! The webview takes colours as `#RRGGBB` strings, so [`Rgb::to_hex`] is the
|
||||
//! boundary conversion.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Rgb {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
|
||||
/// Parse `#RRGGBB` or `RRGGBB`. Panics on malformed input — meant for
|
||||
/// hardcoded design-system literals at compile-known call sites.
|
||||
pub fn from_hex(hex: &str) -> Self {
|
||||
Self::parse_hex(hex).unwrap_or_else(|| panic!("invalid hex color literal: {hex:?}"))
|
||||
}
|
||||
|
||||
/// Parse `#RRGGBB` or `RRGGBB`. Returns `None` on malformed input.
|
||||
pub fn parse_hex(hex: &str) -> Option<Self> {
|
||||
let s = hex.trim_start_matches('#');
|
||||
if s.len() != 6 || !s.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
|
||||
Some(Self { r, g, b })
|
||||
}
|
||||
|
||||
/// Format as an uppercase `#RRGGBB` CSS colour.
|
||||
pub fn to_hex(self) -> String {
|
||||
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
|
||||
}
|
||||
|
||||
/// Linear interpolation between two colours. `t` is clamped to `[0, 1]`.
|
||||
pub fn lerp(self, other: Rgb, t: f64) -> Rgb {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
|
||||
Rgb {
|
||||
r: mix(self.r, other.r),
|
||||
g: mix(self.g, other.g),
|
||||
b: mix(self.b, other.b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Triangle wave in [0, 1] with a period of 24 ticks: 0 at phase 0, 0.5 at
|
||||
/// phase 6 and 18, and 1 at phase 12. The bubble UI mirrors this exactly.
|
||||
pub fn pulse_triangle(phase: u32) -> f64 {
|
||||
let p = (phase % 24) as i32;
|
||||
let dist = (p - 12).abs(); // 0..12
|
||||
1.0 - (dist as f64 / 12.0)
|
||||
}
|
||||
|
||||
/// Linearly brighten `c` toward white by `t` in [0, 1].
|
||||
pub fn brighten(c: Rgb, t: f64) -> Rgb {
|
||||
// Map t to a smaller brightness delta — the pulse should be a subtle nudge.
|
||||
let t = t.clamp(0.0, 1.0) * 0.30;
|
||||
Rgb::new(
|
||||
((c.r as f64) + (255.0 - c.r as f64) * t).round() as u8,
|
||||
((c.g as f64) + (255.0 - c.g as f64) * t).round() as u8,
|
||||
((c.b as f64) + (255.0 - c.b as f64) * t).round() as u8,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_round_trips_and_rejects_malformed_input() {
|
||||
let c = Rgb::parse_hex("#d97757").expect("valid hex");
|
||||
assert_eq!(c, Rgb::new(0xD9, 0x77, 0x57));
|
||||
assert_eq!(c.to_hex(), "#D97757");
|
||||
assert_eq!(Rgb::parse_hex("1A1A1A"), Some(Rgb::new(0x1A, 0x1A, 0x1A)));
|
||||
assert_eq!(Rgb::parse_hex("#12345"), None);
|
||||
assert_eq!(Rgb::parse_hex("#GG0000"), None);
|
||||
assert_eq!(Rgb::parse_hex("#ééé"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulse_triangle_peaks_mid_period() {
|
||||
assert_eq!(pulse_triangle(0), 0.0);
|
||||
assert_eq!(pulse_triangle(6), 0.5);
|
||||
assert_eq!(pulse_triangle(12), 1.0);
|
||||
assert_eq!(pulse_triangle(18), 0.5);
|
||||
assert_eq!(pulse_triangle(24), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brighten_caps_at_thirty_percent_toward_white() {
|
||||
let c = Rgb::new(0xA0, 0x18, 0x18);
|
||||
assert_eq!(brighten(c, 0.0), c);
|
||||
// 160 + 95 * 0.3 = 188.5 -> 189; 24 + 231 * 0.3 = 93.3 -> 93
|
||||
assert_eq!(brighten(c, 1.0), Rgb::new(189, 93, 93));
|
||||
assert_eq!(brighten(c, 5.0), brighten(c, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lerp_clamps_t() {
|
||||
let a = Rgb::new(0, 0, 0);
|
||||
let b = Rgb::new(255, 255, 255);
|
||||
assert_eq!(a.lerp(b, -1.0), a);
|
||||
assert_eq!(a.lerp(b, 2.0), b);
|
||||
assert_eq!(a.lerp(b, 0.5), Rgb::new(128, 128, 128));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
//! Window geometry in physical pixels: bubble sizing, default placement,
|
||||
//! snapping, clamping, panel placement and fullscreen checks.
|
||||
//!
|
||||
//! Everything here is pure. The window layer collects monitors, work areas,
|
||||
//! the taskbar and peer bubbles, and passes them in as plain [`Rect`]s.
|
||||
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
/// Screen rectangle with exclusive right/bottom edges, like Win32 `RECT`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Rect {
|
||||
pub l: i32,
|
||||
pub t: i32,
|
||||
pub r: i32,
|
||||
pub b: i32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub const fn new(l: i32, t: i32, r: i32, b: i32) -> Self {
|
||||
Self { l, t, r, b }
|
||||
}
|
||||
|
||||
/// Rect from a top-left corner and a size.
|
||||
pub const fn from_xywh(x: i32, y: i32, w: i32, h: i32) -> Self {
|
||||
Self {
|
||||
l: x,
|
||||
t: y,
|
||||
r: x + w,
|
||||
b: y + h,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn w(&self) -> i32 {
|
||||
self.r - self.l
|
||||
}
|
||||
|
||||
pub const fn h(&self) -> i32 {
|
||||
self.b - self.t
|
||||
}
|
||||
|
||||
pub const fn is_empty(&self) -> bool {
|
||||
self.r <= self.l || self.b <= self.t
|
||||
}
|
||||
|
||||
/// True when the two rects share a non-empty area.
|
||||
pub const fn intersects(&self, other: &Rect) -> bool {
|
||||
self.l < other.r && other.l < self.r && self.t < other.b && other.t < self.b
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Point {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
pub const fn new(x: i32, y: i32) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
/// The DPI the OS reports for 100% scaling.
|
||||
pub const BASE_DPI: u32 = 96;
|
||||
|
||||
/// Scale a logical (96-DPI) pixel measurement to the given DPI.
|
||||
pub fn scale(logical_px: i32, dpi: u32) -> i32 {
|
||||
((logical_px as i64) * (dpi as i64) / (BASE_DPI as i64)) as i32
|
||||
}
|
||||
|
||||
// ---------- Bubble size ----------
|
||||
|
||||
// Width clamps in logical pixels. Height is derived per width (see
|
||||
// `bubble_height_logical`) — aspect tapers from 3:1 at the small end toward
|
||||
// 2.6:1 at the large end so the bars look proportionally chunkier as the
|
||||
// bubble grows.
|
||||
pub const MIN_BUBBLE_SIZE: i32 = 140;
|
||||
pub const MAX_BUBBLE_SIZE: i32 = 360;
|
||||
pub const DEFAULT_BUBBLE_SIZE: i32 = 200;
|
||||
pub const RESIZE_STEP_LOGICAL: i32 = 20;
|
||||
|
||||
const SNAP_ZONE_LOGICAL: i32 = 12;
|
||||
const CORNER_SNAP_ZONE_LOGICAL: i32 = 32;
|
||||
const CORNER_INSET_LOGICAL: i32 = 12;
|
||||
const TASKBAR_GAP_LOGICAL: i32 = 4;
|
||||
const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
|
||||
|
||||
/// Raw (unscaled) gap used by the default position and the Codex stagger.
|
||||
const STAGGER_GAP: i32 = 24;
|
||||
|
||||
/// Side of the square probed when validating a saved position. 140 px
|
||||
/// matches `MIN_BUBBLE_SIZE`: a saved top-left a few px past the work-area
|
||||
/// edge still passes the validator, but a position fully on a disconnected
|
||||
/// monitor fails.
|
||||
const POSITION_PROBE_PX: i32 = 140;
|
||||
|
||||
/// Gap between a bubble and its details panel, in physical px.
|
||||
const PANEL_GAP_PX: i32 = 8;
|
||||
|
||||
const FULLSCREEN_EDGE_TOLERANCE_PX: i32 = 2;
|
||||
|
||||
/// (num, den) such that bubble_height = (width * den) / num. 3:1 below 200,
|
||||
/// 2.8:1 below 280, 2.6:1 above — the bubble gets a touch taller as it
|
||||
/// grows so the wider bars don't look anaemic.
|
||||
pub fn aspect_at_width(w_logical: i32) -> (i32, i32) {
|
||||
if w_logical <= 200 {
|
||||
(3, 1)
|
||||
} else if w_logical <= 280 {
|
||||
(14, 5) // 2.8 : 1
|
||||
} else {
|
||||
(13, 5) // 2.6 : 1
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bubble_height_logical(width_logical: i32) -> i32 {
|
||||
let (num, den) = aspect_at_width(width_logical);
|
||||
((width_logical * den) / num).max(20)
|
||||
}
|
||||
|
||||
/// Physical `(width, height)` of a bubble at `size_logical` and `dpi`.
|
||||
pub fn bubble_size_px(size_logical: i32, dpi: u32) -> (i32, i32) {
|
||||
(
|
||||
scale(size_logical, dpi),
|
||||
scale(bubble_height_logical(size_logical), dpi),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn corner_radius_px(height_px: i32) -> i32 {
|
||||
height_px / 2
|
||||
}
|
||||
|
||||
/// Whether the local point `(x, y)` lies inside a `w`×`h` rounded rect with
|
||||
/// corner radius `r`.
|
||||
pub fn point_in_rounded_rect(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool {
|
||||
if x < 0 || y < 0 || x >= w || y >= h {
|
||||
return false;
|
||||
}
|
||||
// The straight horizontal and vertical strips are always inside; only the
|
||||
// four corner squares need the circular falloff check.
|
||||
let in_x_strip = x >= r && x < w - r;
|
||||
let in_y_strip = y >= r && y < h - r;
|
||||
if in_x_strip || in_y_strip {
|
||||
return true;
|
||||
}
|
||||
let cx = if x < r { r } else { w - 1 - r };
|
||||
let cy = if y < r { r } else { h - 1 - r };
|
||||
let dx = x - cx;
|
||||
let dy = y - cy;
|
||||
dx * dx + dy * dy <= r * r
|
||||
}
|
||||
|
||||
// ---------- Placement ----------
|
||||
|
||||
/// Bottom-right of the primary work area, with a 24-pixel gap from the
|
||||
/// edges. The Codex bubble is staggered above the Claude one so both fit
|
||||
/// when enabled.
|
||||
pub fn default_position(
|
||||
work_area: Rect,
|
||||
width_px: i32,
|
||||
height_px: i32,
|
||||
model: ProviderId,
|
||||
) -> Point {
|
||||
let stagger = match model {
|
||||
ProviderId::Claude => 0,
|
||||
ProviderId::ChatGpt => height_px + STAGGER_GAP,
|
||||
ProviderId::OpenCodeGo => 2 * (height_px + STAGGER_GAP),
|
||||
};
|
||||
let x = work_area.r - width_px - STAGGER_GAP;
|
||||
let y = work_area.b - height_px - STAGGER_GAP - stagger;
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
/// Keep `rect` inside `work_area` and return the clamped top-left.
|
||||
///
|
||||
/// When both bubbles get clamped to the same bottom-right corner (e.g.
|
||||
/// saved positions were on a disconnected monitor and the validator missed
|
||||
/// them), the Codex bubble keeps the stagger `default_position` uses so they
|
||||
/// don't visually stack.
|
||||
pub fn clamp_into_work_area(rect: Rect, work_area: Rect, model: ProviderId) -> Point {
|
||||
let (w, h) = (rect.w(), rect.h());
|
||||
let nx = rect
|
||||
.l
|
||||
.clamp(work_area.l, (work_area.r - w).max(work_area.l));
|
||||
let mut ny = rect
|
||||
.t
|
||||
.clamp(work_area.t, (work_area.b - h).max(work_area.t));
|
||||
if model == ProviderId::ChatGpt && nx == work_area.r - w && ny == work_area.b - h {
|
||||
ny = (ny - h - STAGGER_GAP).max(work_area.t);
|
||||
}
|
||||
Point::new(nx, ny)
|
||||
}
|
||||
|
||||
/// Monitor edge the taskbar is docked to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Edge {
|
||||
Left,
|
||||
Top,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// Everything the drag-end snap needs, in physical px.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SnapInput {
|
||||
/// The dragged bubble's current outer rect.
|
||||
pub rect: Rect,
|
||||
/// Work area of the monitor nearest the bubble.
|
||||
pub work_area: Rect,
|
||||
/// DPI of the bubble's monitor.
|
||||
pub dpi: u32,
|
||||
/// Docked taskbar edge and rect, when the platform reports one.
|
||||
pub taskbar: Option<(Edge, Rect)>,
|
||||
/// Outer rects of the other bubbles, in `ProviderId::LIVE_USAGE` order.
|
||||
pub peers: Vec<Rect>,
|
||||
}
|
||||
|
||||
/// Snap a dropped bubble: corner first; otherwise edge, taskbar and peer
|
||||
/// alignment; then always clamp into the work area. Returns the new
|
||||
/// top-left, which equals `input.rect`'s when nothing moves.
|
||||
pub fn snap(input: &SnapInput) -> Point {
|
||||
let dpi = input.dpi;
|
||||
let edge_zone = scale(SNAP_ZONE_LOGICAL, dpi);
|
||||
let corner_zone = scale(CORNER_SNAP_ZONE_LOGICAL, dpi);
|
||||
let corner_inset = scale(CORNER_INSET_LOGICAL, dpi);
|
||||
let taskbar_gap = scale(TASKBAR_GAP_LOGICAL, dpi);
|
||||
let peer_tolerance = scale(PEER_ALIGN_TOLERANCE_LOGICAL, dpi);
|
||||
|
||||
let wa = &input.work_area;
|
||||
let w = input.rect.w();
|
||||
let h = input.rect.h();
|
||||
let mut nx = input.rect.l;
|
||||
let mut ny = input.rect.t;
|
||||
|
||||
// 1. Corner snap — if the bubble's nearest-corner distance is under the
|
||||
// 32-px corner zone, slam it into the corner with the 12-px inset.
|
||||
let snapped_to_corner = try_corner_snap(&mut nx, &mut ny, wa, w, h, corner_zone, corner_inset);
|
||||
|
||||
if !snapped_to_corner {
|
||||
// 2. Edge snap — also handles taskbar adjacency when the taskbar
|
||||
// steals from the work area on the same edge.
|
||||
snap_to_work_area_edges(&mut nx, &mut ny, wa, w, h, edge_zone);
|
||||
if let Some((edge, tb)) = &input.taskbar {
|
||||
let fit = Fit {
|
||||
wa,
|
||||
w,
|
||||
h,
|
||||
zone: edge_zone,
|
||||
};
|
||||
snap_alongside_taskbar(&mut nx, &mut ny, *edge, tb, &fit, taskbar_gap);
|
||||
}
|
||||
|
||||
// 3. Peer vertical alignment — when another bubble is within ±8 px
|
||||
// on Y, snap the dragged bubble to share its baseline.
|
||||
align_with_peer(&input.peers, &mut ny, peer_tolerance);
|
||||
}
|
||||
|
||||
// Clamp into the work area in any case (so the bubble can't be lost off-screen).
|
||||
nx = nx.clamp(wa.l, (wa.r - w).max(wa.l));
|
||||
ny = ny.clamp(wa.t, (wa.b - h).max(wa.t));
|
||||
Point::new(nx, ny)
|
||||
}
|
||||
|
||||
fn try_corner_snap(
|
||||
nx: &mut i32,
|
||||
ny: &mut i32,
|
||||
wa: &Rect,
|
||||
w: i32,
|
||||
h: i32,
|
||||
zone: i32,
|
||||
inset: i32,
|
||||
) -> bool {
|
||||
// Distance from each work-area corner to the bubble's nearest corner.
|
||||
let tl = (*nx - wa.l).abs() + (*ny - wa.t).abs();
|
||||
let tr = (wa.r - (*nx + w)).abs() + (*ny - wa.t).abs();
|
||||
let bl = (*nx - wa.l).abs() + (wa.b - (*ny + h)).abs();
|
||||
let br = (wa.r - (*nx + w)).abs() + (wa.b - (*ny + h)).abs();
|
||||
let min = tl.min(tr).min(bl).min(br);
|
||||
if min > zone * 2 {
|
||||
return false;
|
||||
}
|
||||
if min == tl {
|
||||
*nx = wa.l + inset;
|
||||
*ny = wa.t + inset;
|
||||
} else if min == tr {
|
||||
*nx = wa.r - inset - w;
|
||||
*ny = wa.t + inset;
|
||||
} else if min == bl {
|
||||
*nx = wa.l + inset;
|
||||
*ny = wa.b - inset - h;
|
||||
} else {
|
||||
*nx = wa.r - inset - w;
|
||||
*ny = wa.b - inset - h;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn snap_to_work_area_edges(nx: &mut i32, ny: &mut i32, wa: &Rect, w: i32, h: i32, zone: i32) {
|
||||
if (*nx - wa.l).abs() < zone {
|
||||
*nx = wa.l;
|
||||
} else if (wa.r - (*nx + w)).abs() < zone {
|
||||
*nx = wa.r - w;
|
||||
}
|
||||
if (*ny - wa.t).abs() < zone {
|
||||
*ny = wa.t;
|
||||
} else if (wa.b - (*ny + h)).abs() < zone {
|
||||
*ny = wa.b - h;
|
||||
}
|
||||
}
|
||||
|
||||
/// Work area, bubble size and snap zone shared by the taskbar snap.
|
||||
struct Fit<'a> {
|
||||
wa: &'a Rect,
|
||||
w: i32,
|
||||
h: i32,
|
||||
zone: i32,
|
||||
}
|
||||
|
||||
fn snap_alongside_taskbar(
|
||||
nx: &mut i32,
|
||||
ny: &mut i32,
|
||||
edge: Edge,
|
||||
tb: &Rect,
|
||||
fit: &Fit<'_>,
|
||||
gap: i32,
|
||||
) {
|
||||
let Fit { wa, w, h, zone } = *fit;
|
||||
// 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 {
|
||||
Edge::Bottom => {
|
||||
let target = tb.t - gap - h;
|
||||
if (*ny - target).abs() < zone {
|
||||
*ny = target.max(wa.t);
|
||||
}
|
||||
}
|
||||
Edge::Top => {
|
||||
let target = tb.b + gap;
|
||||
if (*ny - target).abs() < zone {
|
||||
*ny = target.min(wa.b - h);
|
||||
}
|
||||
}
|
||||
Edge::Left => {
|
||||
let target = tb.r + gap;
|
||||
if (*nx - target).abs() < zone {
|
||||
*nx = target.min(wa.r - w);
|
||||
}
|
||||
}
|
||||
Edge::Right => {
|
||||
let target = tb.l - gap - w;
|
||||
if (*nx - target).abs() < zone {
|
||||
*nx = target.max(wa.l);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
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;
|
||||
let mut y = anchor.b + PANEL_GAP_PX;
|
||||
if y + panel_h > bounds.b {
|
||||
y = anchor.t - panel_h - PANEL_GAP_PX;
|
||||
}
|
||||
if y < bounds.t {
|
||||
y = anchor.t.max(bounds.t);
|
||||
}
|
||||
if x + panel_w > bounds.r {
|
||||
x = bounds.r - panel_w - PANEL_GAP_PX;
|
||||
}
|
||||
if x < bounds.l {
|
||||
x = bounds.l + PANEL_GAP_PX;
|
||||
}
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
/// Whether a saved top-left `(x, y)` still lands on a connected monitor:
|
||||
/// a `140×140` probe there must overlap at least one of `monitors`.
|
||||
pub fn position_on_any_monitor(x: i32, y: i32, monitors: &[Rect]) -> bool {
|
||||
let probe = Rect::from_xywh(x, y, POSITION_PROBE_PX, POSITION_PROBE_PX);
|
||||
monitors.iter().any(|m| probe.intersects(m))
|
||||
}
|
||||
|
||||
// ---------- Fullscreen ----------
|
||||
|
||||
/// Whether `rect` covers the monitor `bounds`, allowing 2 px of slack per edge.
|
||||
pub fn rect_covers_monitor(rect: &Rect, bounds: &Rect) -> bool {
|
||||
rect.l <= bounds.l + FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
&& rect.t <= bounds.t + FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
&& rect.r >= bounds.r - FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
&& rect.b >= bounds.b - FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
}
|
||||
|
||||
// Win32 window style bits (`WS_*`), as the plain values `GWL_STYLE` returns.
|
||||
pub const WS_CHILD: u32 = 0x4000_0000;
|
||||
pub const WS_POPUP: u32 = 0x8000_0000;
|
||||
pub const WS_CAPTION: u32 = 0x00C0_0000;
|
||||
pub const WS_THICKFRAME: u32 = 0x0004_0000;
|
||||
|
||||
/// A fullscreen candidate is a top-level popup, or a top-level window with
|
||||
/// neither a caption nor a resize frame.
|
||||
pub fn window_style_bits_allow_fullscreen(style: u32) -> bool {
|
||||
let has_child = style & WS_CHILD != 0;
|
||||
let has_popup = style & WS_POPUP != 0;
|
||||
let has_caption = style & WS_CAPTION != 0;
|
||||
let has_resize_frame = style & WS_THICKFRAME != 0;
|
||||
|
||||
!has_child && (has_popup || (!has_caption && !has_resize_frame))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const WA: Rect = Rect::new(0, 0, 1920, 1040);
|
||||
|
||||
fn input(rect: Rect) -> SnapInput {
|
||||
SnapInput {
|
||||
rect,
|
||||
work_area: WA,
|
||||
dpi: 96,
|
||||
taskbar: None,
|
||||
peers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_follows_dpi() {
|
||||
assert_eq!(scale(200, 96), 200);
|
||||
assert_eq!(scale(200, 144), 300);
|
||||
assert_eq!(scale(12, 120), 15);
|
||||
assert_eq!(scale(66, 168), 115);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_and_height_per_width() {
|
||||
for (w, aspect, h) in [
|
||||
(140, (3, 1), 46),
|
||||
(200, (3, 1), 66),
|
||||
(220, (14, 5), 78),
|
||||
(280, (14, 5), 100),
|
||||
(300, (13, 5), 115),
|
||||
(360, (13, 5), 138),
|
||||
] {
|
||||
assert_eq!(aspect_at_width(w), aspect, "aspect at {w}");
|
||||
assert_eq!(bubble_height_logical(w), h, "height at {w}");
|
||||
}
|
||||
assert_eq!(bubble_size_px(200, 144), (300, 99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_rect_excludes_corners_only() {
|
||||
let (w, h) = (200, 66);
|
||||
let r = corner_radius_px(h);
|
||||
assert!(point_in_rounded_rect(100, 0, w, h, r));
|
||||
assert!(point_in_rounded_rect(33, 33, w, h, r));
|
||||
assert!(!point_in_rounded_rect(0, 0, w, h, r));
|
||||
assert!(!point_in_rounded_rect(w - 1, h - 1, w, h, r));
|
||||
assert!(point_in_rounded_rect(1, 33, w, h, r));
|
||||
assert!(!point_in_rounded_rect(0, 33, w, h, r));
|
||||
assert!(!point_in_rounded_rect(-1, 33, w, h, r));
|
||||
assert!(!point_in_rounded_rect(w, 33, w, h, r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_position_staggers_by_provider() {
|
||||
let (w, h) = (200, 66);
|
||||
assert_eq!(
|
||||
default_position(WA, w, h, ProviderId::Claude),
|
||||
Point::new(1920 - 200 - 24, 1040 - 66 - 24)
|
||||
);
|
||||
assert_eq!(
|
||||
default_position(WA, w, h, ProviderId::ChatGpt),
|
||||
Point::new(1696, 1040 - 66 - 24 - 90)
|
||||
);
|
||||
assert_eq!(
|
||||
default_position(WA, w, h, ProviderId::OpenCodeGo),
|
||||
Point::new(1696, 1040 - 66 - 24 - 180)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_pulls_offscreen_rect_into_work_area() {
|
||||
let r = Rect::from_xywh(-500, -30, 200, 66);
|
||||
assert_eq!(
|
||||
clamp_into_work_area(r, WA, ProviderId::Claude),
|
||||
Point::new(0, 0)
|
||||
);
|
||||
let inside = Rect::from_xywh(100, 100, 200, 66);
|
||||
assert_eq!(
|
||||
clamp_into_work_area(inside, WA, ProviderId::ChatGpt),
|
||||
Point::new(100, 100)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_lifts_codex_out_of_bottom_right_corner() {
|
||||
let r = Rect::from_xywh(5000, 5000, 200, 66);
|
||||
assert_eq!(
|
||||
clamp_into_work_area(r, WA, ProviderId::Claude),
|
||||
Point::new(1720, 974)
|
||||
);
|
||||
assert_eq!(
|
||||
clamp_into_work_area(r, WA, ProviderId::ChatGpt),
|
||||
Point::new(1720, 974 - 66 - 24)
|
||||
);
|
||||
// The lift never goes above the work area.
|
||||
let tiny = Rect::new(0, 0, 400, 100);
|
||||
assert_eq!(
|
||||
clamp_into_work_area(
|
||||
Rect::from_xywh(900, 900, 200, 66),
|
||||
tiny,
|
||||
ProviderId::ChatGpt
|
||||
),
|
||||
Point::new(200, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_snap_insets_into_each_corner() {
|
||||
// Within 64 px (2 x 32) Manhattan distance of a corner.
|
||||
let tl = snap(&input(Rect::from_xywh(20, 30, 200, 66)));
|
||||
assert_eq!(tl, Point::new(12, 12));
|
||||
let br = snap(&input(Rect::from_xywh(1690, 940, 200, 66)));
|
||||
assert_eq!(br, Point::new(1920 - 12 - 200, 1040 - 12 - 66));
|
||||
let tr = snap(&input(Rect::from_xywh(1700, 10, 200, 66)));
|
||||
assert_eq!(tr, Point::new(1708, 12));
|
||||
let bl = snap(&input(Rect::from_xywh(10, 950, 200, 66)));
|
||||
assert_eq!(bl, Point::new(12, 962));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_snap_tie_order_is_tl_tr_bl_br() {
|
||||
// The work area leaves 40 px of slack on both axes, so the four
|
||||
// corner distances can be made to tie.
|
||||
let wa = Rect::new(0, 0, 240, 106);
|
||||
let at = |x, y| SnapInput {
|
||||
work_area: wa,
|
||||
..input(Rect::from_xywh(x, y, 200, 66))
|
||||
};
|
||||
// tl = tr = 20 (bl = br = 60): top-left wins.
|
||||
assert_eq!(snap(&at(20, 0)), Point::new(12, 12));
|
||||
// tl = 50, tr = br = 30: top-right wins over bottom-right.
|
||||
assert_eq!(snap(&at(30, 20)), Point::new(28, 12));
|
||||
// tl = tr = 50, bl = br = 30: bottom-left wins over bottom-right.
|
||||
assert_eq!(snap(&at(20, 30)), Point::new(12, 28));
|
||||
// Only bottom-right is nearest.
|
||||
assert_eq!(snap(&at(30, 30)), Point::new(28, 28));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_zone_boundary_is_inclusive_at_twice_the_zone() {
|
||||
// tl distance exactly 64 snaps; 65 does not.
|
||||
assert_eq!(
|
||||
snap(&input(Rect::from_xywh(32, 32, 200, 66))),
|
||||
Point::new(12, 12)
|
||||
);
|
||||
let out = snap(&input(Rect::from_xywh(33, 32, 200, 66)));
|
||||
assert_ne!(out, Point::new(12, 12));
|
||||
// Not a corner: falls through to edge snap on the top edge (32 >= 12
|
||||
// so x stays; y=32 is outside the 12 px edge zone too).
|
||||
assert_eq!(out, Point::new(33, 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_zone_is_exclusive_at_twelve_px() {
|
||||
// 11 px from the left edge snaps flush; 12 px does not.
|
||||
assert_eq!(
|
||||
snap(&input(Rect::from_xywh(11, 500, 200, 66))),
|
||||
Point::new(0, 500)
|
||||
);
|
||||
assert_eq!(
|
||||
snap(&input(Rect::from_xywh(12, 500, 200, 66))),
|
||||
Point::new(12, 500)
|
||||
);
|
||||
// Right and bottom edges.
|
||||
assert_eq!(
|
||||
snap(&input(Rect::from_xywh(1709, 500, 200, 66))),
|
||||
Point::new(1720, 500)
|
||||
);
|
||||
assert_eq!(
|
||||
snap(&input(Rect::from_xywh(800, 965, 200, 66))),
|
||||
Point::new(800, 974)
|
||||
);
|
||||
assert_eq!(
|
||||
snap(&input(Rect::from_xywh(800, 5, 200, 66))),
|
||||
Point::new(800, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_zone_scales_with_dpi() {
|
||||
let mut i = input(Rect::from_xywh(17, 500, 300, 99));
|
||||
i.dpi = 144; // zone 18 px
|
||||
assert_eq!(snap(&i), Point::new(0, 500));
|
||||
i.rect = Rect::from_xywh(18, 500, 300, 99);
|
||||
assert_eq!(snap(&i), Point::new(18, 500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taskbar_snap_on_each_edge() {
|
||||
// Monitor 1920x1080; the work area excludes a 40 px taskbar.
|
||||
let cases = [
|
||||
(
|
||||
Edge::Bottom,
|
||||
Rect::new(0, 1040, 1920, 1080),
|
||||
Rect::new(0, 0, 1920, 1040),
|
||||
Rect::from_xywh(800, 960, 200, 66),
|
||||
Point::new(800, 1040 - 4 - 66),
|
||||
),
|
||||
(
|
||||
Edge::Top,
|
||||
Rect::new(0, 0, 1920, 40),
|
||||
Rect::new(0, 40, 1920, 1080),
|
||||
Rect::from_xywh(800, 52, 200, 66),
|
||||
Point::new(800, 44),
|
||||
),
|
||||
(
|
||||
Edge::Left,
|
||||
Rect::new(0, 0, 40, 1080),
|
||||
Rect::new(40, 0, 1920, 1080),
|
||||
Rect::from_xywh(52, 500, 200, 66),
|
||||
Point::new(44, 500),
|
||||
),
|
||||
(
|
||||
Edge::Right,
|
||||
Rect::new(1880, 0, 1920, 1080),
|
||||
Rect::new(0, 0, 1880, 1080),
|
||||
Rect::from_xywh(1666, 500, 200, 66),
|
||||
Point::new(1880 - 4 - 200, 500),
|
||||
),
|
||||
];
|
||||
for (edge, tb, wa, rect, expected) in cases {
|
||||
let i = SnapInput {
|
||||
rect,
|
||||
work_area: wa,
|
||||
dpi: 96,
|
||||
taskbar: Some((edge, tb)),
|
||||
peers: Vec::new(),
|
||||
};
|
||||
assert_eq!(snap(&i), expected, "taskbar {edge:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taskbar_snap_ignores_far_positions() {
|
||||
let i = SnapInput {
|
||||
rect: Rect::from_xywh(800, 900, 200, 66),
|
||||
work_area: WA,
|
||||
dpi: 96,
|
||||
taskbar: Some((Edge::Bottom, Rect::new(0, 1040, 1920, 1080))),
|
||||
peers: Vec::new(),
|
||||
};
|
||||
assert_eq!(snap(&i), Point::new(800, 900));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_alignment_takes_first_peer_within_tolerance() {
|
||||
let mut i = input(Rect::from_xywh(800, 500, 200, 66));
|
||||
i.peers = vec![
|
||||
Rect::from_xywh(100, 509, 200, 66),
|
||||
Rect::from_xywh(400, 506, 200, 66),
|
||||
];
|
||||
// 9 px is outside the 8 px tolerance; 6 px is inside.
|
||||
assert_eq!(snap(&i), Point::new(800, 506));
|
||||
i.peers = vec![
|
||||
Rect::from_xywh(100, 508, 200, 66),
|
||||
Rect::from_xywh(400, 506, 200, 66),
|
||||
];
|
||||
assert_eq!(snap(&i), Point::new(800, 508));
|
||||
i.peers.clear();
|
||||
assert_eq!(snap(&i), Point::new(800, 500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_alignment_is_skipped_after_a_corner_snap() {
|
||||
let mut i = input(Rect::from_xywh(20, 20, 200, 66));
|
||||
i.peers = vec![Rect::from_xywh(900, 18, 200, 66)];
|
||||
assert_eq!(snap(&i), Point::new(12, 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_clamp_keeps_peer_aligned_bubble_on_screen() {
|
||||
// The bottom edge snap puts y at 974; aligning to the peer at 980
|
||||
// would overflow the 1040 bottom, so the clamp wins.
|
||||
let mut i = input(Rect::from_xywh(800, 970, 200, 66));
|
||||
i.peers = vec![Rect::from_xywh(100, 980, 200, 30)];
|
||||
assert_eq!(snap(&i), Point::new(800, 974));
|
||||
// A rect larger than the work area pins to its top-left.
|
||||
let mut big = input(Rect::from_xywh(500, 500, 3000, 2000));
|
||||
big.work_area = Rect::new(100, 50, 1920, 1040);
|
||||
assert_eq!(snap(&big), Point::new(100, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_near_below_then_above() {
|
||||
let bounds = Rect::new(0, 0, 1920, 1080);
|
||||
let bubble = Rect::from_xywh(100, 100, 200, 66);
|
||||
assert_eq!(place_near(bubble, (280, 120), bounds), Point::new(100, 174));
|
||||
let low = Rect::from_xywh(100, 950, 200, 66);
|
||||
assert_eq!(place_near(low, (280, 120), bounds), Point::new(100, 822));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_near_clamps_left_and_right() {
|
||||
let bounds = Rect::new(0, 0, 1920, 1080);
|
||||
let right = Rect::from_xywh(1800, 100, 200, 66);
|
||||
assert_eq!(
|
||||
place_near(right, (280, 120), bounds),
|
||||
Point::new(1920 - 280 - 8, 174)
|
||||
);
|
||||
let left = Rect::from_xywh(-50, 100, 200, 66);
|
||||
assert_eq!(place_near(left, (280, 120), bounds), Point::new(8, 174));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_near_handles_negative_virtual_origin() {
|
||||
// A secondary monitor left of and above the primary.
|
||||
let bounds = Rect::new(-1920, -200, 1920, 1080);
|
||||
let bubble = Rect::from_xywh(-1900, -190, 200, 66);
|
||||
assert_eq!(
|
||||
place_near(bubble, (280, 120), bounds),
|
||||
Point::new(-1900, -116)
|
||||
);
|
||||
let edge = Rect::from_xywh(-2000, 1000, 200, 66);
|
||||
assert_eq!(
|
||||
place_near(edge, (280, 120), bounds),
|
||||
Point::new(-1912, 1000 - 120 - 8)
|
||||
);
|
||||
// Flipping above would leave the top too; it pins to the anchor top.
|
||||
let tall_bounds = Rect::new(-1920, -200, 1920, 100);
|
||||
let b = Rect::from_xywh(0, -190, 200, 66);
|
||||
assert_eq!(place_near(b, (280, 400), tall_bounds), Point::new(0, -190));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_probe_against_monitors() {
|
||||
let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)];
|
||||
assert!(position_on_any_monitor(100, 100, &monitors));
|
||||
assert!(position_on_any_monitor(2000, 500, &monitors));
|
||||
// Partially off the left edge still overlaps.
|
||||
assert!(position_on_any_monitor(-100, 100, &monitors));
|
||||
// Fully past the left edge does not.
|
||||
assert!(!position_on_any_monitor(-140, 100, &monitors));
|
||||
assert!(!position_on_any_monitor(4000, 100, &monitors));
|
||||
assert!(!position_on_any_monitor(100, 100, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_bits_allow_popup_or_borderless_windows_only() {
|
||||
assert!(window_style_bits_allow_fullscreen(WS_POPUP));
|
||||
assert!(window_style_bits_allow_fullscreen(0));
|
||||
assert!(!window_style_bits_allow_fullscreen(
|
||||
WS_CAPTION | WS_THICKFRAME
|
||||
));
|
||||
assert!(!window_style_bits_allow_fullscreen(WS_CHILD | WS_POPUP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_cover_check_allows_small_edge_differences() {
|
||||
let monitor = Rect::new(0, 0, 1920, 1080);
|
||||
let almost_exact = Rect::new(1, 2, 1919, 1078);
|
||||
let inset = Rect::new(8, 0, 1920, 1080);
|
||||
|
||||
assert!(rect_covers_monitor(&almost_exact, &monitor));
|
||||
assert!(!rect_covers_monitor(&inset, &monitor));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
// English UI strings and formatting helpers.
|
||||
//
|
||||
// Every string the UI displays is formatted here; the webview never formats
|
||||
// a number for display.
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// The strings every UI module needs.
|
||||
#[derive(Clone, Debug)]
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::usage::{ProviderId, Window};
|
||||
|
||||
/// The strings every UI module needs. Serialised camelCase for the
|
||||
/// webviews.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocaleStrings {
|
||||
pub window_title: String,
|
||||
pub refresh: String,
|
||||
@@ -18,6 +27,7 @@ pub struct LocaleStrings {
|
||||
pub opencode_go_label: String,
|
||||
pub settings: String,
|
||||
pub start_with_windows: String,
|
||||
pub start_at_login: String,
|
||||
pub reset_position: String,
|
||||
pub size_smaller: String,
|
||||
pub size_larger: String,
|
||||
@@ -85,6 +95,26 @@ impl I18n {
|
||||
}
|
||||
}
|
||||
|
||||
impl LocaleStrings {
|
||||
/// Label of the autostart menu item for the platform this build targets.
|
||||
pub fn autostart_label(&self) -> &str {
|
||||
if cfg!(windows) {
|
||||
&self.start_with_windows
|
||||
} else {
|
||||
&self.start_at_login
|
||||
}
|
||||
}
|
||||
|
||||
/// Display name of a provider.
|
||||
pub fn provider_label(&self, provider: ProviderId) -> &str {
|
||||
match provider {
|
||||
ProviderId::Claude => &self.claude_label,
|
||||
ProviderId::ChatGpt => &self.chatgpt_label,
|
||||
ProviderId::OpenCodeGo => &self.opencode_go_label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn english_strings() -> LocaleStrings {
|
||||
LocaleStrings {
|
||||
window_title: "Claude Code Usage Bubble".into(),
|
||||
@@ -100,6 +130,7 @@ fn english_strings() -> LocaleStrings {
|
||||
opencode_go_label: "OpenCode Go".into(),
|
||||
settings: "Settings".into(),
|
||||
start_with_windows: "Start with Windows".into(),
|
||||
start_at_login: "Start at login".into(),
|
||||
reset_position: "Reset position".into(),
|
||||
size_smaller: "Make smaller".into(),
|
||||
size_larger: "Make larger".into(),
|
||||
@@ -147,11 +178,24 @@ fn english_strings() -> LocaleStrings {
|
||||
|
||||
// ---------- Free-function helpers ----------
|
||||
|
||||
/// A percentage rounded half-to-even (`"72%"`), or `"—"` when there is none.
|
||||
pub fn format_pct(pct: Option<f64>) -> String {
|
||||
match pct {
|
||||
Some(p) => format!("{p:.0}%"),
|
||||
None => String::from("\u{2014}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a `usage::Window` percentage + countdown as `"73% · 2h"`-style text.
|
||||
/// Returns just the percentage when no reset time is available.
|
||||
pub fn format_window(window: &crate::usage::Window, strings: &LocaleStrings) -> String {
|
||||
pub fn format_window(window: &Window, strings: &LocaleStrings) -> String {
|
||||
format_window_at(window, strings, SystemTime::now())
|
||||
}
|
||||
|
||||
/// [`format_window`] evaluated at `now`.
|
||||
pub fn format_window_at(window: &Window, strings: &LocaleStrings, now: SystemTime) -> String {
|
||||
let pct = format!("{:.0}%", window.utilization);
|
||||
let cd = format_countdown(window.resets_at, strings);
|
||||
let cd = format_countdown_at(window.resets_at, strings, now);
|
||||
if cd.is_empty() {
|
||||
pct
|
||||
} else {
|
||||
@@ -162,10 +206,19 @@ pub fn format_window(window: &crate::usage::Window, strings: &LocaleStrings) ->
|
||||
/// Countdown only — used by the bubble, which renders the percent inside the
|
||||
/// bar fill and only needs the time-to-reset on the right.
|
||||
pub fn format_countdown(resets_at: Option<SystemTime>, strings: &LocaleStrings) -> String {
|
||||
format_countdown_at(resets_at, strings, SystemTime::now())
|
||||
}
|
||||
|
||||
/// [`format_countdown`] evaluated at `now`.
|
||||
pub fn format_countdown_at(
|
||||
resets_at: Option<SystemTime>,
|
||||
strings: &LocaleStrings,
|
||||
now: SystemTime,
|
||||
) -> String {
|
||||
let Some(reset) = resets_at else {
|
||||
return String::new();
|
||||
};
|
||||
let remaining = match reset.duration_since(SystemTime::now()) {
|
||||
let remaining = match reset.duration_since(now) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return strings.now.clone(),
|
||||
};
|
||||
@@ -190,8 +243,16 @@ fn format_countdown_secs(total_secs: u64, strings: &LocaleStrings) -> String {
|
||||
/// How long before `format_window`'s string would change.
|
||||
/// Used by the countdown timer to refresh exactly when needed.
|
||||
pub fn time_until_display_change(resets_at: Option<SystemTime>) -> Option<Duration> {
|
||||
time_until_display_change_at(resets_at, SystemTime::now())
|
||||
}
|
||||
|
||||
/// [`time_until_display_change`] evaluated at `now`.
|
||||
pub fn time_until_display_change_at(
|
||||
resets_at: Option<SystemTime>,
|
||||
now: SystemTime,
|
||||
) -> Option<Duration> {
|
||||
let reset = resets_at?;
|
||||
let remaining = reset.duration_since(SystemTime::now()).ok()?;
|
||||
let remaining = reset.duration_since(now).ok()?;
|
||||
let secs = remaining.as_secs();
|
||||
let bucket_start = if secs / 86_400 >= 1 {
|
||||
(secs / 86_400) * 86_400
|
||||
@@ -230,4 +291,85 @@ mod tests {
|
||||
assert!(!value.trim().is_empty(), "empty string: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
fn at(now: SystemTime, secs: u64) -> Option<SystemTime> {
|
||||
Some(now + Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn countdown_buckets() {
|
||||
let s = english_strings();
|
||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let cd = |secs| format_countdown_at(at(now, secs), &s, now);
|
||||
assert_eq!(format_countdown_at(None, &s, now), "");
|
||||
assert_eq!(
|
||||
format_countdown_at(Some(now - Duration::from_secs(1)), &s, now),
|
||||
"now"
|
||||
);
|
||||
assert_eq!(cd(0), "0s");
|
||||
assert_eq!(cd(59), "59s");
|
||||
assert_eq!(cd(60), "1m");
|
||||
assert_eq!(cd(3_599), "59m");
|
||||
assert_eq!(cd(3_600), "1h");
|
||||
assert_eq!(cd(86_399), "23h");
|
||||
assert_eq!(cd(86_400), "1d");
|
||||
assert_eq!(cd(86_400 + 23 * 3_600), "1d");
|
||||
assert_eq!(cd(7 * 86_400), "7d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_text_joins_percent_and_countdown() {
|
||||
let s = english_strings();
|
||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let w = Window {
|
||||
utilization: 73.2,
|
||||
resets_at: at(now, 2 * 3_600 + 5),
|
||||
};
|
||||
assert_eq!(format_window_at(&w, &s, now), "73% \u{00b7} 2h");
|
||||
let bare = Window {
|
||||
utilization: 73.2,
|
||||
resets_at: None,
|
||||
};
|
||||
assert_eq!(format_window_at(&bare, &s, now), "73%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_change_fires_just_past_the_bucket_edge() {
|
||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let ttl = |secs| time_until_display_change_at(at(now, secs), now);
|
||||
assert_eq!(time_until_display_change_at(None, now), None);
|
||||
assert_eq!(
|
||||
time_until_display_change_at(Some(now - Duration::from_secs(5)), now),
|
||||
None
|
||||
);
|
||||
// 1d 23h: "1d" stays until 23h + 1s from now.
|
||||
assert_eq!(
|
||||
ttl(86_400 + 23 * 3_600),
|
||||
Some(Duration::from_secs(23 * 3_600 + 1))
|
||||
);
|
||||
assert_eq!(ttl(2 * 3_600 + 30), Some(Duration::from_secs(31)));
|
||||
assert_eq!(ttl(5 * 60 + 7), Some(Duration::from_secs(8)));
|
||||
// Under a minute the text changes every second.
|
||||
assert_eq!(ttl(42), Some(Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_rounds_half_to_even() {
|
||||
assert_eq!(format_pct(Some(72.5)), "72%");
|
||||
assert_eq!(format_pct(Some(73.5)), "74%");
|
||||
assert_eq!(format_pct(Some(0.0)), "0%");
|
||||
assert_eq!(format_pct(None), "\u{2014}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autostart_label_matches_the_platform() {
|
||||
let s = english_strings();
|
||||
let expected = if cfg!(windows) {
|
||||
"Start with Windows"
|
||||
} else {
|
||||
"Start at login"
|
||||
};
|
||||
assert_eq!(s.autostart_label(), expected);
|
||||
assert_eq!(s.provider_label(ProviderId::ChatGpt), "Codex");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
//! The context menu as a plain tree with string ids, independent of any
|
||||
//! menu toolkit. The tray and bubble menus are built from it, and menu
|
||||
//! events come back as [`MenuCommand::parse`] of the clicked id.
|
||||
|
||||
use crate::core::geom::{DEFAULT_BUBBLE_SIZE, MAX_BUBBLE_SIZE, MIN_BUBBLE_SIZE};
|
||||
use crate::core::i18n::LocaleStrings;
|
||||
use crate::core::settings::{Settings, POLL_INTERVALS, UPDATE_CHECK_INTERVALS};
|
||||
use crate::core::state::{version_action_label, UpdateStatus};
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
/// Every action a menu item can trigger.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MenuCommand {
|
||||
Refresh,
|
||||
PollInterval(u32),
|
||||
ToggleProvider(ProviderId),
|
||||
Autostart,
|
||||
ResetPosition,
|
||||
VersionAction,
|
||||
/// `None` disables the automatic check.
|
||||
UpdateInterval(Option<u64>),
|
||||
SizeSmaller,
|
||||
SizeLarger,
|
||||
SizeReset,
|
||||
ToggleWidget,
|
||||
Restart,
|
||||
Exit,
|
||||
}
|
||||
|
||||
impl MenuCommand {
|
||||
/// The item id: `refresh`, `freq:<ms>`, `provider:<slug>`, `autostart`,
|
||||
/// `reset-position`, `version`, `update-interval:<secs|off>`,
|
||||
/// `size-smaller`, `size-larger`, `size-reset`, `toggle-widget`,
|
||||
/// `restart` or `exit`.
|
||||
pub fn id(self) -> String {
|
||||
match self {
|
||||
MenuCommand::Refresh => "refresh".into(),
|
||||
MenuCommand::PollInterval(ms) => format!("freq:{ms}"),
|
||||
MenuCommand::ToggleProvider(p) => format!("provider:{}", p.slug()),
|
||||
MenuCommand::Autostart => "autostart".into(),
|
||||
MenuCommand::ResetPosition => "reset-position".into(),
|
||||
MenuCommand::VersionAction => "version".into(),
|
||||
MenuCommand::UpdateInterval(None) => "update-interval:off".into(),
|
||||
MenuCommand::UpdateInterval(Some(secs)) => format!("update-interval:{secs}"),
|
||||
MenuCommand::SizeSmaller => "size-smaller".into(),
|
||||
MenuCommand::SizeLarger => "size-larger".into(),
|
||||
MenuCommand::SizeReset => "size-reset".into(),
|
||||
MenuCommand::ToggleWidget => "toggle-widget".into(),
|
||||
MenuCommand::Restart => "restart".into(),
|
||||
MenuCommand::Exit => "exit".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an item id. Only values the menu offers are accepted, so an
|
||||
/// unknown or hand-made id maps to `None` instead of an arbitrary
|
||||
/// interval.
|
||||
pub fn parse(id: &str) -> Option<Self> {
|
||||
let simple = match id {
|
||||
"refresh" => Some(MenuCommand::Refresh),
|
||||
"autostart" => Some(MenuCommand::Autostart),
|
||||
"reset-position" => Some(MenuCommand::ResetPosition),
|
||||
"version" => Some(MenuCommand::VersionAction),
|
||||
"size-smaller" => Some(MenuCommand::SizeSmaller),
|
||||
"size-larger" => Some(MenuCommand::SizeLarger),
|
||||
"size-reset" => Some(MenuCommand::SizeReset),
|
||||
"toggle-widget" => Some(MenuCommand::ToggleWidget),
|
||||
"restart" => Some(MenuCommand::Restart),
|
||||
"exit" => Some(MenuCommand::Exit),
|
||||
_ => None,
|
||||
};
|
||||
if simple.is_some() {
|
||||
return simple;
|
||||
}
|
||||
let (kind, value) = id.split_once(':')?;
|
||||
match kind {
|
||||
"freq" => {
|
||||
let ms = value.parse::<u32>().ok()?;
|
||||
POLL_INTERVALS
|
||||
.contains(&ms)
|
||||
.then_some(MenuCommand::PollInterval(ms))
|
||||
}
|
||||
"provider" => ProviderId::from_slug(value).map(MenuCommand::ToggleProvider),
|
||||
"update-interval" => {
|
||||
let secs = if value == "off" {
|
||||
None
|
||||
} else {
|
||||
Some(value.parse::<u64>().ok()?)
|
||||
};
|
||||
UPDATE_CHECK_INTERVALS
|
||||
.contains(&secs)
|
||||
.then_some(MenuCommand::UpdateInterval(secs))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One node of the menu tree.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MenuEntry {
|
||||
Action {
|
||||
command: MenuCommand,
|
||||
label: String,
|
||||
enabled: bool,
|
||||
},
|
||||
Check {
|
||||
command: MenuCommand,
|
||||
label: String,
|
||||
checked: bool,
|
||||
enabled: bool,
|
||||
},
|
||||
/// A greyed, id-less hint row.
|
||||
Hint {
|
||||
label: String,
|
||||
},
|
||||
Separator,
|
||||
Submenu {
|
||||
label: String,
|
||||
entries: Vec<MenuEntry>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Everything the menu reflects, snapshotted outside the state lock.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MenuInput<'a> {
|
||||
pub settings: &'a Settings,
|
||||
pub strings: &'a LocaleStrings,
|
||||
pub autostart_enabled: bool,
|
||||
pub update_status: UpdateStatus,
|
||||
/// Whether this install can apply an update in place. When it cannot
|
||||
/// (a package-manager install), "Update available" stays greyed.
|
||||
pub update_can_apply: bool,
|
||||
}
|
||||
|
||||
fn action(command: MenuCommand, label: &str, enabled: bool) -> MenuEntry {
|
||||
MenuEntry::Action {
|
||||
command,
|
||||
label: label.to_string(),
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn check(command: MenuCommand, label: &str, checked: bool, enabled: bool) -> MenuEntry {
|
||||
MenuEntry::Check {
|
||||
command,
|
||||
label: label.to_string(),
|
||||
checked,
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn hint(label: &str) -> MenuEntry {
|
||||
MenuEntry::Hint {
|
||||
label: label.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn submenu(label: &str, entries: Vec<MenuEntry>) -> MenuEntry {
|
||||
MenuEntry::Submenu {
|
||||
label: label.to_string(),
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version item checks for updates, or applies one once it is
|
||||
/// available. It is greyed while either runs, and an available update stays
|
||||
/// greyed when this install cannot apply it.
|
||||
fn version_action_enabled(status: UpdateStatus, can_apply: bool) -> bool {
|
||||
!status.is_busy() && (can_apply || status != UpdateStatus::Available)
|
||||
}
|
||||
|
||||
/// Build the context menu in display order.
|
||||
pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
|
||||
let MenuInput {
|
||||
settings,
|
||||
strings: s,
|
||||
autostart_enabled,
|
||||
update_status,
|
||||
update_can_apply,
|
||||
} = input;
|
||||
|
||||
let freq_labels = [
|
||||
&s.one_minute,
|
||||
&s.five_minutes,
|
||||
&s.fifteen_minutes,
|
||||
&s.one_hour,
|
||||
];
|
||||
let frequency = POLL_INTERVALS
|
||||
.iter()
|
||||
.zip(freq_labels)
|
||||
.map(|(&ms, label)| {
|
||||
check(
|
||||
MenuCommand::PollInterval(ms),
|
||||
label,
|
||||
settings.poll_interval_ms == ms,
|
||||
true,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let providers = ProviderId::ALL
|
||||
.iter()
|
||||
.map(|&p| {
|
||||
check(
|
||||
MenuCommand::ToggleProvider(p),
|
||||
s.provider_label(p),
|
||||
settings.is_provider_enabled(p),
|
||||
p.metadata().live_usage,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let auto_labels = [
|
||||
&s.auto_check_disabled,
|
||||
&s.auto_check_hourly,
|
||||
&s.auto_check_daily,
|
||||
&s.auto_check_weekly,
|
||||
];
|
||||
let auto_update = UPDATE_CHECK_INTERVALS
|
||||
.iter()
|
||||
.zip(auto_labels)
|
||||
.map(|(&secs, label)| {
|
||||
check(
|
||||
MenuCommand::UpdateInterval(secs),
|
||||
label,
|
||||
settings.update_check_interval_secs == secs,
|
||||
true,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let size = settings.bubble_size_logical;
|
||||
let settings_menu = vec![
|
||||
check(
|
||||
MenuCommand::Autostart,
|
||||
s.autostart_label(),
|
||||
autostart_enabled,
|
||||
true,
|
||||
),
|
||||
action(MenuCommand::ResetPosition, &s.reset_position, true),
|
||||
action(
|
||||
MenuCommand::VersionAction,
|
||||
&version_action_label(update_status, s),
|
||||
version_action_enabled(update_status, update_can_apply),
|
||||
),
|
||||
submenu(&s.auto_update_check, auto_update),
|
||||
];
|
||||
|
||||
let controls = vec![
|
||||
action(
|
||||
MenuCommand::SizeSmaller,
|
||||
&s.size_smaller,
|
||||
size > MIN_BUBBLE_SIZE,
|
||||
),
|
||||
action(
|
||||
MenuCommand::SizeLarger,
|
||||
&s.size_larger,
|
||||
size < MAX_BUBBLE_SIZE,
|
||||
),
|
||||
action(
|
||||
MenuCommand::SizeReset,
|
||||
&s.reset_size,
|
||||
size != DEFAULT_BUBBLE_SIZE,
|
||||
),
|
||||
MenuEntry::Separator,
|
||||
hint(&s.control_left_click),
|
||||
hint(&s.control_right_click),
|
||||
hint(&s.control_drag),
|
||||
hint(&s.control_ctrl_wheel),
|
||||
hint(&s.control_tray_click),
|
||||
];
|
||||
|
||||
vec![
|
||||
action(MenuCommand::Refresh, &s.refresh, true),
|
||||
submenu(&s.update_frequency, frequency),
|
||||
submenu(&s.providers, providers),
|
||||
submenu(&s.settings, settings_menu),
|
||||
submenu(&s.controls, controls),
|
||||
check(
|
||||
MenuCommand::ToggleWidget,
|
||||
&s.show_widget,
|
||||
settings.widget_visible,
|
||||
true,
|
||||
),
|
||||
MenuEntry::Separator,
|
||||
action(MenuCommand::Restart, &s.restart, true),
|
||||
action(MenuCommand::Exit, &s.exit, true),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::i18n::I18n;
|
||||
use crate::core::settings::{POLL_15_MIN, UPDATE_CHECK_DAILY_SECS};
|
||||
|
||||
fn menu(settings: &Settings, status: UpdateStatus) -> Vec<MenuEntry> {
|
||||
let strings = I18n::load().strings().clone();
|
||||
build(MenuInput {
|
||||
settings,
|
||||
strings: &strings,
|
||||
autostart_enabled: true,
|
||||
update_status: status,
|
||||
update_can_apply: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn find<'a>(entries: &'a [MenuEntry], id: &str) -> Option<&'a MenuEntry> {
|
||||
entries.iter().find_map(|e| match e {
|
||||
MenuEntry::Action { command, .. } | MenuEntry::Check { command, .. }
|
||||
if command.id() == id =>
|
||||
{
|
||||
Some(e)
|
||||
}
|
||||
MenuEntry::Submenu { entries, .. } => find(entries, id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn enabled(entries: &[MenuEntry], id: &str) -> bool {
|
||||
match find(entries, id) {
|
||||
Some(MenuEntry::Action { enabled, .. } | MenuEntry::Check { enabled, .. }) => *enabled,
|
||||
other => panic!("no item {id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn checked(entries: &[MenuEntry], id: &str) -> bool {
|
||||
match find(entries, id) {
|
||||
Some(MenuEntry::Check { checked, .. }) => *checked,
|
||||
other => panic!("no check item {id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_size(size: i32) -> Settings {
|
||||
Settings {
|
||||
bubble_size_logical: size,
|
||||
..Settings::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_items_grey_out_at_the_limits() {
|
||||
let m = menu(&with_size(140), UpdateStatus::Idle);
|
||||
assert!(!enabled(&m, "size-smaller"));
|
||||
assert!(enabled(&m, "size-larger"));
|
||||
assert!(enabled(&m, "size-reset"));
|
||||
let m = menu(&with_size(360), UpdateStatus::Idle);
|
||||
assert!(enabled(&m, "size-smaller"));
|
||||
assert!(!enabled(&m, "size-larger"));
|
||||
assert!(enabled(&m, "size-reset"));
|
||||
let m = menu(&with_size(200), UpdateStatus::Idle);
|
||||
assert!(enabled(&m, "size-smaller"));
|
||||
assert!(enabled(&m, "size-larger"));
|
||||
assert!(!enabled(&m, "size-reset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_follow_settings() {
|
||||
let settings = Settings {
|
||||
poll_interval_ms: POLL_15_MIN,
|
||||
update_check_interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
|
||||
show_codex: true,
|
||||
show_opencode_go: true,
|
||||
widget_visible: false,
|
||||
..Settings::default()
|
||||
};
|
||||
let m = menu(&settings, UpdateStatus::Idle);
|
||||
assert!(checked(&m, "freq:900000"));
|
||||
assert!(!checked(&m, "freq:300000"));
|
||||
assert!(checked(&m, "update-interval:86400"));
|
||||
assert!(!checked(&m, "update-interval:off"));
|
||||
assert!(checked(&m, "provider:claude"));
|
||||
assert!(checked(&m, "provider:chatgpt"));
|
||||
// OpenCode Go mirrors its flag but stays greyed.
|
||||
assert!(checked(&m, "provider:opencode-go"));
|
||||
assert!(!enabled(&m, "provider:opencode-go"));
|
||||
assert!(checked(&m, "autostart"));
|
||||
assert!(!checked(&m, "toggle-widget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_item_is_disabled_while_busy() {
|
||||
assert!(!enabled(
|
||||
&menu(&Settings::default(), UpdateStatus::Checking),
|
||||
"version"
|
||||
));
|
||||
assert!(!enabled(
|
||||
&menu(&Settings::default(), UpdateStatus::Applying),
|
||||
"version"
|
||||
));
|
||||
assert!(enabled(
|
||||
&menu(&Settings::default(), UpdateStatus::Available),
|
||||
"version"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_update_is_greyed_when_it_cannot_be_applied() {
|
||||
let strings = I18n::load().strings().clone();
|
||||
let settings = Settings::default();
|
||||
let menu_for = |status| {
|
||||
build(MenuInput {
|
||||
settings: &settings,
|
||||
strings: &strings,
|
||||
autostart_enabled: true,
|
||||
update_status: status,
|
||||
update_can_apply: false,
|
||||
})
|
||||
};
|
||||
assert!(!enabled(&menu_for(UpdateStatus::Available), "version"));
|
||||
// Checking still works on such an install.
|
||||
for status in [
|
||||
UpdateStatus::Idle,
|
||||
UpdateStatus::UpToDate,
|
||||
UpdateStatus::Failed,
|
||||
] {
|
||||
assert!(enabled(&menu_for(status), "version"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_order_and_hint_rows() {
|
||||
let m = menu(&Settings::default(), UpdateStatus::Idle);
|
||||
let labels: Vec<String> = m
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
MenuEntry::Action { label, .. }
|
||||
| MenuEntry::Check { label, .. }
|
||||
| MenuEntry::Hint { label }
|
||||
| MenuEntry::Submenu { label, .. } => label.clone(),
|
||||
MenuEntry::Separator => "-".into(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
[
|
||||
"Refresh",
|
||||
"Update frequency",
|
||||
"Providers",
|
||||
"Settings",
|
||||
"Controls",
|
||||
"Show widget",
|
||||
"-",
|
||||
"Restart",
|
||||
"Exit"
|
||||
]
|
||||
);
|
||||
let Some(MenuEntry::Submenu { entries, .. }) = m.get(4) else {
|
||||
panic!("controls submenu missing");
|
||||
};
|
||||
assert_eq!(entries.len(), 9);
|
||||
assert_eq!(entries[3], MenuEntry::Separator);
|
||||
assert!(entries[4..]
|
||||
.iter()
|
||||
.all(|e| matches!(e, MenuEntry::Hint { .. })));
|
||||
}
|
||||
|
||||
/// One line per node, indented by depth: `label [id] kind`.
|
||||
fn outline(entries: &[MenuEntry], depth: usize, out: &mut Vec<String>) {
|
||||
let pad = " ".repeat(depth);
|
||||
for e in entries {
|
||||
match e {
|
||||
MenuEntry::Action {
|
||||
command,
|
||||
label,
|
||||
enabled,
|
||||
} => out.push(format!(
|
||||
"{pad}{label} [{}]{}",
|
||||
command.id(),
|
||||
if *enabled { "" } else { " grey" }
|
||||
)),
|
||||
MenuEntry::Check {
|
||||
command,
|
||||
label,
|
||||
checked,
|
||||
enabled,
|
||||
} => out.push(format!(
|
||||
"{pad}{label} [{}] {}{}",
|
||||
command.id(),
|
||||
if *checked { "on" } else { "off" },
|
||||
if *enabled { "" } else { " grey" }
|
||||
)),
|
||||
MenuEntry::Hint { label } => out.push(format!("{pad}{label} hint")),
|
||||
MenuEntry::Separator => out.push(format!("{pad}-")),
|
||||
MenuEntry::Submenu { label, entries } => {
|
||||
out.push(format!("{pad}{label} >"));
|
||||
outline(entries, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_tree_keeps_order_labels_ids_and_states() {
|
||||
let strings = I18n::load().strings().clone();
|
||||
let m = build(MenuInput {
|
||||
settings: &Settings::default(),
|
||||
strings: &strings,
|
||||
autostart_enabled: false,
|
||||
update_status: UpdateStatus::Idle,
|
||||
update_can_apply: true,
|
||||
});
|
||||
let mut lines = Vec::new();
|
||||
outline(&m, 0, &mut lines);
|
||||
let autostart = format!(" {} [autostart] off", strings.autostart_label());
|
||||
let version = format!(
|
||||
" Check for updates · v{} [version]",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
let expected = [
|
||||
"Refresh [refresh]",
|
||||
"Update frequency >",
|
||||
" 1 minute [freq:60000] off",
|
||||
" 5 minutes [freq:300000] on",
|
||||
" 15 minutes [freq:900000] off",
|
||||
" 1 hour [freq:3600000] off",
|
||||
"Providers >",
|
||||
" Claude Code [provider:claude] on",
|
||||
" Codex [provider:chatgpt] off",
|
||||
" OpenCode Go [provider:opencode-go] off grey",
|
||||
"Settings >",
|
||||
&autostart,
|
||||
" Reset position [reset-position]",
|
||||
&version,
|
||||
" Auto-update check >",
|
||||
" Disabled [update-interval:off] off",
|
||||
" Hourly [update-interval:3600] on",
|
||||
" Daily [update-interval:86400] off",
|
||||
" Weekly [update-interval:604800] off",
|
||||
"Controls >",
|
||||
" Make smaller [size-smaller]",
|
||||
" Make larger [size-larger]",
|
||||
" Reset size [size-reset] grey",
|
||||
" -",
|
||||
];
|
||||
let got: Vec<&str> = lines.iter().map(String::as_str).collect();
|
||||
assert_eq!(got[..expected.len()], expected);
|
||||
let tail: Vec<&str> = got[expected.len()..].to_vec();
|
||||
assert_eq!(tail.len(), 9);
|
||||
assert!(tail[..5].iter().all(|l| l.ends_with(" hint")), "{tail:?}");
|
||||
assert_eq!(
|
||||
tail[5..],
|
||||
[
|
||||
"Show widget [toggle-widget] on",
|
||||
"-",
|
||||
"Restart [restart]",
|
||||
"Exit [exit]"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_round_trip_and_unknown_ids_are_rejected() {
|
||||
let m = menu(&Settings::default(), UpdateStatus::Idle);
|
||||
fn walk(entries: &[MenuEntry], out: &mut Vec<MenuCommand>) {
|
||||
for e in entries {
|
||||
match e {
|
||||
MenuEntry::Action { command, .. } | MenuEntry::Check { command, .. } => {
|
||||
out.push(*command)
|
||||
}
|
||||
MenuEntry::Submenu { entries, .. } => walk(entries, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
walk(&m, &mut commands);
|
||||
assert_eq!(commands.len(), 21);
|
||||
for c in commands {
|
||||
assert_eq!(MenuCommand::parse(&c.id()), Some(c), "{}", c.id());
|
||||
}
|
||||
for bad in [
|
||||
"",
|
||||
"freq:1",
|
||||
"freq:abc",
|
||||
"provider:codex",
|
||||
"update-interval:5",
|
||||
"nope:1",
|
||||
] {
|
||||
assert_eq!(MenuCommand::parse(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Portable logic with no OS or Tauri dependency, unit-tested on every host.
|
||||
|
||||
pub mod color;
|
||||
pub mod geom;
|
||||
pub mod i18n;
|
||||
pub mod menu_model;
|
||||
pub mod payload;
|
||||
pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod state;
|
||||
pub mod usage_color;
|
||||
pub mod version;
|
||||
pub mod visibility;
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Pure builders for the JSON payloads the bubble and panel webviews render.
|
||||
//!
|
||||
//! Rust owns every displayed string and colour; the webview only lays out
|
||||
//! pixels. Field names are camelCase on the wire.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::core::i18n::{self, LocaleStrings};
|
||||
use crate::core::state::ProviderUiState;
|
||||
use crate::core::usage_color::{accent_color_for, bar_fill_color};
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
|
||||
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
const THIRTY_DAYS_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
|
||||
/// Placeholder for the head label and weekly countdown before the first poll.
|
||||
const PENDING: &str = "…";
|
||||
|
||||
/// Length of a provider's `(primary, secondary)` usage windows in seconds.
|
||||
/// Claude exposes 5h/7d directly; Codex's primary/secondary map to the same
|
||||
/// short/long windows in the compact UI.
|
||||
pub fn window_durations_secs(provider: ProviderId) -> (u64, u64) {
|
||||
match provider {
|
||||
ProviderId::Claude | ProviderId::ChatGpt => (FIVE_HOURS_SECS, SEVEN_DAYS_SECS),
|
||||
ProviderId::OpenCodeGo => (SEVEN_DAYS_SECS, THIRTY_DAYS_SECS),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bubble window's canvas, in physical px.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Canvas {
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
pub dpi: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BubblePayload {
|
||||
pub provider: ProviderId,
|
||||
pub session_pct: Option<f64>,
|
||||
pub weekly_pct: Option<f64>,
|
||||
/// `"73%"`, or `"—"` with no value.
|
||||
pub session_pct_text: String,
|
||||
/// `None` hides the weekly percent.
|
||||
pub weekly_pct_text: Option<String>,
|
||||
/// Session countdown; `"…"` before the first poll. The UI swaps an empty
|
||||
/// or overflowing label for `"5H"`.
|
||||
pub head_label: String,
|
||||
/// Weekly countdown; `"…"` before the first poll, `""` hides it.
|
||||
pub weekly_countdown: String,
|
||||
pub session_resets_at_ms: Option<u64>,
|
||||
pub weekly_resets_at_ms: Option<u64>,
|
||||
pub primary_dur_secs: u64,
|
||||
pub secondary_dur_secs: u64,
|
||||
pub session_fill: String,
|
||||
pub weekly_fill: String,
|
||||
pub accent: String,
|
||||
pub is_dark: bool,
|
||||
pub canvas_w: u32,
|
||||
pub canvas_h: u32,
|
||||
pub dpi: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PanelPayload {
|
||||
pub provider: ProviderId,
|
||||
pub label: String,
|
||||
pub is_dark: bool,
|
||||
pub accent: String,
|
||||
pub session_pct: f64,
|
||||
pub weekly_pct: f64,
|
||||
/// `"73% · 2h"`, `"!"` after an auth failure, `"…"` after a transient
|
||||
/// error, `""` before the first poll.
|
||||
pub session_text: String,
|
||||
pub weekly_text: String,
|
||||
pub session_fill: String,
|
||||
pub weekly_fill: String,
|
||||
}
|
||||
|
||||
fn unix_ms(t: Option<SystemTime>) -> Option<u64> {
|
||||
let ms = t?.duration_since(UNIX_EPOCH).ok()?.as_millis();
|
||||
u64::try_from(ms).ok()
|
||||
}
|
||||
|
||||
/// Build the bubble payload. `snapshot` is `None` before the provider's
|
||||
/// first poll. After an error the snapshot still holds the last good
|
||||
/// windows, so the bubble keeps those values and countdowns; it never shows
|
||||
/// the panel's `"!"` or `"…"` markers.
|
||||
pub fn bubble_payload(
|
||||
provider: ProviderId,
|
||||
snapshot: Option<&ProviderUiState>,
|
||||
strings: &LocaleStrings,
|
||||
is_dark: bool,
|
||||
canvas: Canvas,
|
||||
now: SystemTime,
|
||||
) -> BubblePayload {
|
||||
let windows = snapshot.map(|s| s.windows);
|
||||
let session_pct = windows.map(|w| w.primary.utilization);
|
||||
let weekly_pct = windows.map(|w| w.secondary.utilization);
|
||||
let (head_label, weekly_countdown) = match windows {
|
||||
Some(w) => (
|
||||
i18n::format_countdown_at(w.primary.resets_at, strings, now),
|
||||
i18n::format_countdown_at(w.secondary.resets_at, strings, now),
|
||||
),
|
||||
None => (PENDING.to_string(), PENDING.to_string()),
|
||||
};
|
||||
let (primary_dur_secs, secondary_dur_secs) = window_durations_secs(provider);
|
||||
BubblePayload {
|
||||
provider,
|
||||
session_pct,
|
||||
weekly_pct,
|
||||
session_pct_text: i18n::format_pct(session_pct),
|
||||
weekly_pct_text: weekly_pct.map(|p| i18n::format_pct(Some(p))),
|
||||
head_label,
|
||||
weekly_countdown,
|
||||
session_resets_at_ms: unix_ms(windows.and_then(|w| w.primary.resets_at)),
|
||||
weekly_resets_at_ms: unix_ms(windows.and_then(|w| w.secondary.resets_at)),
|
||||
primary_dur_secs,
|
||||
secondary_dur_secs,
|
||||
session_fill: bar_fill_color(provider, is_dark, session_pct.unwrap_or(0.0)).to_hex(),
|
||||
weekly_fill: bar_fill_color(provider, is_dark, weekly_pct.unwrap_or(0.0)).to_hex(),
|
||||
accent: accent_color_for(provider, is_dark).to_hex(),
|
||||
is_dark,
|
||||
canvas_w: canvas.w,
|
||||
canvas_h: canvas.h,
|
||||
dpi: canvas.dpi,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the details-panel payload. Before the first poll the bars are
|
||||
/// empty and the texts blank.
|
||||
pub fn panel_payload(
|
||||
provider: ProviderId,
|
||||
snapshot: Option<&ProviderUiState>,
|
||||
strings: &LocaleStrings,
|
||||
is_dark: bool,
|
||||
) -> PanelPayload {
|
||||
let state = snapshot.cloned().unwrap_or_default();
|
||||
let session_pct = state.windows.primary.utilization;
|
||||
let weekly_pct = state.windows.secondary.utilization;
|
||||
PanelPayload {
|
||||
provider,
|
||||
label: strings.provider_label(provider).to_string(),
|
||||
is_dark,
|
||||
accent: accent_color_for(provider, is_dark).to_hex(),
|
||||
session_pct,
|
||||
weekly_pct,
|
||||
session_text: state.primary_text,
|
||||
weekly_text: state.secondary_text,
|
||||
session_fill: bar_fill_color(provider, is_dark, session_pct).to_hex(),
|
||||
weekly_fill: bar_fill_color(provider, is_dark, weekly_pct).to_hex(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
use crate::core::i18n::I18n;
|
||||
use crate::core::state::{PollOutcome, UsageState};
|
||||
use crate::usage::{UsageWindows, Window};
|
||||
|
||||
const CANVAS: Canvas = Canvas {
|
||||
w: 300,
|
||||
h: 99,
|
||||
dpi: 144,
|
||||
};
|
||||
|
||||
fn now() -> SystemTime {
|
||||
UNIX_EPOCH + Duration::from_secs(1_800_000_000)
|
||||
}
|
||||
|
||||
fn strings() -> LocaleStrings {
|
||||
I18n::load().strings().clone()
|
||||
}
|
||||
|
||||
fn polled(session: f64, weekly: f64) -> UsageState {
|
||||
let mut state = UsageState::default();
|
||||
let windows = UsageWindows {
|
||||
primary: Window {
|
||||
utilization: session,
|
||||
resets_at: Some(now() + Duration::from_secs(2 * 3_600 + 10)),
|
||||
},
|
||||
secondary: Window {
|
||||
utilization: weekly,
|
||||
resets_at: Some(now() + Duration::from_secs(3 * 86_400)),
|
||||
},
|
||||
};
|
||||
state.apply_results(
|
||||
vec![(ProviderId::Claude, PollOutcome::Ok(windows))],
|
||||
&strings(),
|
||||
Instant::now(),
|
||||
);
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_first_poll_shows_placeholders() {
|
||||
let p = bubble_payload(ProviderId::ChatGpt, None, &strings(), true, CANVAS, now());
|
||||
assert_eq!(p.session_pct, None);
|
||||
assert_eq!(p.session_pct_text, "—");
|
||||
assert_eq!(p.weekly_pct_text, None);
|
||||
assert_eq!(p.head_label, "…");
|
||||
assert_eq!(p.weekly_countdown, "…");
|
||||
assert_eq!(p.session_resets_at_ms, None);
|
||||
assert_eq!(p.session_fill, "#E5E5E5");
|
||||
assert_eq!(p.accent, "#E5E5E5");
|
||||
assert_eq!((p.canvas_w, p.canvas_h, p.dpi), (300, 99, 144));
|
||||
|
||||
let panel = panel_payload(ProviderId::ChatGpt, None, &strings(), false);
|
||||
assert_eq!(panel.session_text, "");
|
||||
assert_eq!(panel.weekly_text, "");
|
||||
assert_eq!(panel.session_pct, 0.0);
|
||||
assert_eq!(panel.label, "Codex");
|
||||
assert_eq!(panel.accent, "#1A1A1A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polled_values_format_in_rust() {
|
||||
let state = polled(72.5, 96.0);
|
||||
let snap = state.snapshots.get(&ProviderId::Claude);
|
||||
let p = bubble_payload(ProviderId::Claude, snap, &strings(), true, CANVAS, now());
|
||||
assert_eq!(p.session_pct, Some(72.5));
|
||||
assert_eq!(p.session_pct_text, "72%");
|
||||
assert_eq!(p.weekly_pct_text.as_deref(), Some("96%"));
|
||||
assert_eq!(p.head_label, "2h");
|
||||
assert_eq!(p.weekly_countdown, "3d");
|
||||
assert_eq!(
|
||||
p.session_resets_at_ms,
|
||||
Some((1_800_000_000 + 2 * 3_600 + 10) * 1_000)
|
||||
);
|
||||
assert_eq!(
|
||||
(p.primary_dur_secs, p.secondary_dur_secs),
|
||||
(18_000, 604_800)
|
||||
);
|
||||
assert_eq!(p.session_fill, "#E0A040");
|
||||
assert_eq!(p.weekly_fill, "#A01818");
|
||||
assert_eq!(p.accent, "#D97757");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_keep_stale_bubble_values_but_mark_the_panel() {
|
||||
let mut state = polled(40.0, 20.0);
|
||||
state.apply_results(
|
||||
vec![(ProviderId::Claude, PollOutcome::AuthFailed)],
|
||||
&strings(),
|
||||
Instant::now(),
|
||||
);
|
||||
let snap = state.snapshots.get(&ProviderId::Claude);
|
||||
let p = bubble_payload(ProviderId::Claude, snap, &strings(), false, CANVAS, now());
|
||||
assert_eq!(p.session_pct_text, "40%");
|
||||
assert_eq!(p.head_label, "2h");
|
||||
assert_eq!(p.weekly_countdown, "3d");
|
||||
let json = serde_json::to_string(&p).expect("serialise");
|
||||
assert!(!json.contains('!'), "{json}");
|
||||
|
||||
let panel = panel_payload(ProviderId::Claude, snap, &strings(), false);
|
||||
assert_eq!(panel.session_text, "!");
|
||||
assert_eq!(panel.weekly_text, "!");
|
||||
assert_eq!(panel.session_pct, 40.0);
|
||||
|
||||
state.apply_results(
|
||||
vec![(ProviderId::Claude, PollOutcome::Failed)],
|
||||
&strings(),
|
||||
Instant::now(),
|
||||
);
|
||||
let snap = state.snapshots.get(&ProviderId::Claude);
|
||||
let panel = panel_payload(ProviderId::Claude, snap, &strings(), false);
|
||||
assert_eq!(panel.session_text, "…");
|
||||
assert_eq!(panel.weekly_text, "…");
|
||||
let p = bubble_payload(ProviderId::Claude, snap, &strings(), false, CANVAS, now());
|
||||
assert_eq!(p.head_label, "2h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_format_is_camel_case_with_nulls() {
|
||||
let p = bubble_payload(ProviderId::Claude, None, &strings(), true, CANVAS, now());
|
||||
let v = serde_json::to_value(&p).expect("serialise");
|
||||
assert_eq!(v["provider"], "claude");
|
||||
assert!(v["sessionPct"].is_null());
|
||||
assert!(v["weeklyPctText"].is_null());
|
||||
assert_eq!(v["sessionPctText"], "—");
|
||||
assert_eq!(v["canvasW"], 300);
|
||||
assert_eq!(v["isDark"], true);
|
||||
let mut keys: Vec<&str> = v
|
||||
.as_object()
|
||||
.expect("object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"accent",
|
||||
"canvasH",
|
||||
"canvasW",
|
||||
"dpi",
|
||||
"headLabel",
|
||||
"isDark",
|
||||
"primaryDurSecs",
|
||||
"provider",
|
||||
"secondaryDurSecs",
|
||||
"sessionFill",
|
||||
"sessionPct",
|
||||
"sessionPctText",
|
||||
"sessionResetsAtMs",
|
||||
"weeklyCountdown",
|
||||
"weeklyFill",
|
||||
"weeklyPct",
|
||||
"weeklyPctText",
|
||||
"weeklyResetsAtMs",
|
||||
]
|
||||
);
|
||||
|
||||
let panel = serde_json::to_value(panel_payload(ProviderId::Claude, None, &strings(), true))
|
||||
.expect("serialise");
|
||||
for key in [
|
||||
"provider",
|
||||
"label",
|
||||
"isDark",
|
||||
"accent",
|
||||
"sessionPct",
|
||||
"weeklyPct",
|
||||
"sessionText",
|
||||
"weeklyText",
|
||||
"sessionFill",
|
||||
"weeklyFill",
|
||||
] {
|
||||
assert!(panel.get(key).is_some(), "missing {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_go_uses_weekly_and_monthly_windows() {
|
||||
assert_eq!(
|
||||
window_durations_secs(ProviderId::OpenCodeGo),
|
||||
(604_800, 2_592_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Parsing of the Windows "Internet Settings" static proxy.
|
||||
//!
|
||||
//! The registry reader lives in `platform::windows`; everything here is pure
|
||||
//! so it can be tested on any host. PAC/WPAD scripts are not evaluated.
|
||||
|
||||
/// A static system proxy and the hosts that bypass it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SystemProxy {
|
||||
/// Proxy URI such as `http://proxy.corp:8080`, ready for `ureq::Proxy::new`.
|
||||
pub url: String,
|
||||
/// `ProxyOverride` entries, trimmed and non-empty.
|
||||
pub bypass: Vec<String>,
|
||||
}
|
||||
|
||||
impl SystemProxy {
|
||||
/// Whether a request to `host` must skip the proxy.
|
||||
pub fn bypasses(&self, host: &str) -> bool {
|
||||
bypass_matches(&self.bypass, host)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the proxy from the raw `ProxyEnable`, `ProxyServer` and
|
||||
/// `ProxyOverride` registry values. Returns `None` unless the proxy is
|
||||
/// enabled and the server string names a usable entry.
|
||||
pub fn from_registry_values(
|
||||
enable: Option<u32>,
|
||||
server: Option<&str>,
|
||||
proxy_override: Option<&str>,
|
||||
) -> Option<SystemProxy> {
|
||||
if enable != Some(1) {
|
||||
return None;
|
||||
}
|
||||
let url = parse_proxy_server(server?)?;
|
||||
Some(SystemProxy {
|
||||
url,
|
||||
bypass: parse_override(proxy_override.unwrap_or_default()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick the proxy for HTTPS traffic out of a `ProxyServer` value.
|
||||
///
|
||||
/// A per-scheme value (`http=h:p;https=h:p;…`) uses the `https=` entry, else
|
||||
/// the `http=` entry. A bare `h:p` applies to every scheme. The result always
|
||||
/// carries a scheme, defaulting to `http://` (a CONNECT proxy). Only `http`
|
||||
/// and `https` proxies are accepted; any other scheme yields `None`.
|
||||
pub fn parse_proxy_server(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let target = if value.contains('=') {
|
||||
let entries: Vec<(String, &str)> = value
|
||||
.split(';')
|
||||
.filter_map(|entry| entry.split_once('='))
|
||||
.map(|(scheme, addr)| (scheme.trim().to_ascii_lowercase(), addr.trim()))
|
||||
.filter(|(_, addr)| !addr.is_empty())
|
||||
.collect();
|
||||
let pick = |scheme: &str| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|(s, _)| s == scheme)
|
||||
.map(|(_, addr)| *addr)
|
||||
};
|
||||
pick("https").or_else(|| pick("http"))?
|
||||
} else {
|
||||
value
|
||||
};
|
||||
match target.split_once("://") {
|
||||
None => Some(format!("http://{target}")),
|
||||
Some((scheme, _))
|
||||
if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") =>
|
||||
{
|
||||
Some(target.to_string())
|
||||
}
|
||||
// SOCKS and any other scheme are not supported as a system proxy.
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a `ProxyOverride` value on `;` into trimmed, non-empty entries.
|
||||
pub fn parse_override(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `host` matches any bypass entry. Entries are case-insensitive
|
||||
/// globs where `*` matches any run of characters; `<local>` matches every
|
||||
/// host without a dot.
|
||||
pub fn bypass_matches(entries: &[String], host: &str) -> bool {
|
||||
let host = host.to_ascii_lowercase();
|
||||
entries.iter().any(|entry| {
|
||||
if entry.eq_ignore_ascii_case("<local>") {
|
||||
!host.contains('.')
|
||||
} else {
|
||||
glob_match(&entry.to_ascii_lowercase(), &host)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Iterative `*`-only glob match with single-star backtracking.
|
||||
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
let p = pattern.as_bytes();
|
||||
let t = text.as_bytes();
|
||||
let (mut pi, mut ti) = (0, 0);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && p[pi] == b'*' {
|
||||
star = Some((pi, ti));
|
||||
pi += 1;
|
||||
} else if pi < p.len() && p[pi] == t[ti] {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
} else if let Some((sp, st)) = star {
|
||||
pi = sp + 1;
|
||||
ti = st + 1;
|
||||
star = Some((sp, st + 1));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
p[pi..].iter().all(|&c| c == b'*')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entries(value: &str) -> Vec<String> {
|
||||
parse_override(value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_scheme_server_prefers_https_entry() {
|
||||
assert_eq!(
|
||||
parse_proxy_server("http=web:80;https=secure:8443;ftp=f:21").as_deref(),
|
||||
Some("http://secure:8443")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_scheme_server_without_https_uses_http_entry() {
|
||||
assert_eq!(
|
||||
parse_proxy_server("ftp=f:21; HTTP=web:3128").as_deref(),
|
||||
Some("http://web:3128")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_scheme_server_without_http_entries_is_none() {
|
||||
assert_eq!(parse_proxy_server("socks=s:1080"), None);
|
||||
assert_eq!(parse_proxy_server("https=;http="), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_server_applies_to_all_schemes() {
|
||||
assert_eq!(
|
||||
parse_proxy_server(" proxy.corp:8080 ").as_deref(),
|
||||
Some("http://proxy.corp:8080")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proxy_server("http://proxy.corp:8080").as_deref(),
|
||||
Some("http://proxy.corp:8080")
|
||||
);
|
||||
assert_eq!(parse_proxy_server(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_http_proxy_schemes_are_rejected() {
|
||||
assert_eq!(parse_proxy_server("socks5://h:1"), None);
|
||||
assert_eq!(parse_proxy_server("https=socks5://h:1"), None);
|
||||
assert_eq!(parse_proxy_server("SOCKS4://h:1"), None);
|
||||
assert_eq!(
|
||||
parse_proxy_server("HTTPS://h:1").as_deref(),
|
||||
Some("HTTPS://h:1")
|
||||
);
|
||||
assert_eq!(
|
||||
from_registry_values(Some(1), Some("socks5://h:1"), None),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_values_need_enable_flag_and_server() {
|
||||
assert_eq!(
|
||||
from_registry_values(Some(0), Some("p:1"), Some("<local>")),
|
||||
None
|
||||
);
|
||||
assert_eq!(from_registry_values(None, Some("p:1"), None), None);
|
||||
assert_eq!(from_registry_values(Some(1), None, None), None);
|
||||
assert_eq!(
|
||||
from_registry_values(Some(1), Some("p:1"), Some("*.corp; <local> ;")),
|
||||
Some(SystemProxy {
|
||||
url: "http://p:1".into(),
|
||||
bypass: vec!["*.corp".into(), "<local>".into()],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
from_registry_values(Some(1), Some("p:1"), None).map(|p| p.bypass),
|
||||
Some(Vec::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_wildcard_matches_subdomains_case_insensitively() {
|
||||
let list = entries("*.anthropic.com");
|
||||
assert!(bypass_matches(&list, "api.anthropic.com"));
|
||||
assert!(bypass_matches(&list, "API.Anthropic.COM"));
|
||||
assert!(!bypass_matches(&list, "anthropic.com"));
|
||||
assert!(!bypass_matches(&list, "chatgpt.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_exact_host_matches_only_that_host() {
|
||||
let list = entries("chatgpt.com");
|
||||
assert!(bypass_matches(&list, "chatgpt.com"));
|
||||
assert!(!bypass_matches(&list, "api.chatgpt.com"));
|
||||
assert!(!bypass_matches(&list, "chatgpt.com.evil"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_local_matches_dotless_hosts() {
|
||||
let list = entries("<LOCAL>");
|
||||
assert!(bypass_matches(&list, "intranet"));
|
||||
assert!(bypass_matches(&list, "localhost"));
|
||||
assert!(!bypass_matches(&list, "api.anthropic.com"));
|
||||
assert!(!bypass_matches(&list, "127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_globs_cover_prefixes_and_inner_stars() {
|
||||
let list = entries("10.*;api.*.com");
|
||||
assert!(bypass_matches(&list, "10.1.2.3"));
|
||||
assert!(bypass_matches(&list, "api.anthropic.com"));
|
||||
assert!(!bypass_matches(&list, "chatgpt.com"));
|
||||
assert!(bypass_matches(&entries("*"), "anything.example"));
|
||||
assert!(!bypass_matches(&[], "chatgpt.com"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
// Persisted settings. The path, schema, key names and physical-px positions
|
||||
// stay byte-compatible with the Win32 builds, so no migration step exists.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::geom::{
|
||||
position_on_any_monitor, Rect, DEFAULT_BUBBLE_SIZE, MAX_BUBBLE_SIZE, MIN_BUBBLE_SIZE,
|
||||
};
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
pub const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble";
|
||||
const SETTINGS_FILE: &str = "settings.json";
|
||||
|
||||
pub const POLL_1_MIN: u32 = 60_000;
|
||||
pub const POLL_5_MIN: u32 = 5 * 60_000;
|
||||
pub const POLL_15_MIN: u32 = 15 * 60_000;
|
||||
pub const POLL_1_HOUR: u32 = 60 * 60_000;
|
||||
/// The poll intervals the menu offers, in menu order.
|
||||
pub const POLL_INTERVALS: [u32; 4] = [POLL_1_MIN, POLL_5_MIN, POLL_15_MIN, POLL_1_HOUR];
|
||||
|
||||
// Update-check intervals (seconds). `None` means auto-check is disabled.
|
||||
pub const UPDATE_CHECK_HOURLY_SECS: u64 = 60 * 60;
|
||||
pub const UPDATE_CHECK_DAILY_SECS: u64 = 24 * 60 * 60;
|
||||
pub const UPDATE_CHECK_WEEKLY_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
/// The auto-update check choices the menu offers, in menu order.
|
||||
pub const UPDATE_CHECK_INTERVALS: [Option<u64>; 4] = [
|
||||
None,
|
||||
Some(UPDATE_CHECK_HOURLY_SECS),
|
||||
Some(UPDATE_CHECK_DAILY_SECS),
|
||||
Some(UPDATE_CHECK_WEEKLY_SECS),
|
||||
];
|
||||
|
||||
fn default_show_claude() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_show_codex() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_show_opencode_go() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_widget_visible() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_bubble_size() -> i32 {
|
||||
DEFAULT_BUBBLE_SIZE
|
||||
}
|
||||
fn default_poll_interval_ms() -> u32 {
|
||||
POLL_5_MIN
|
||||
}
|
||||
fn default_update_check_interval_secs() -> Option<u64> {
|
||||
Some(UPDATE_CHECK_HOURLY_SECS)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BubblePositions {
|
||||
pub claude: Option<(i32, i32)>,
|
||||
pub codex: Option<(i32, i32)>,
|
||||
pub opencode_go: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
impl BubblePositions {
|
||||
pub fn get(&self, provider: ProviderId) -> Option<(i32, i32)> {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude,
|
||||
ProviderId::ChatGpt => self.codex,
|
||||
ProviderId::OpenCodeGo => self.opencode_go,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude = Some(pos),
|
||||
ProviderId::ChatGpt => self.codex = Some(pos),
|
||||
ProviderId::OpenCodeGo => self.opencode_go = Some(pos),
|
||||
}
|
||||
}
|
||||
pub fn reset(&mut self, provider: ProviderId) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude = None,
|
||||
ProviderId::ChatGpt => self.codex = None,
|
||||
ProviderId::OpenCodeGo => self.opencode_go = None,
|
||||
}
|
||||
}
|
||||
pub fn reset_all(&mut self) {
|
||||
self.claude = None;
|
||||
self.codex = None;
|
||||
self.opencode_go = None;
|
||||
}
|
||||
|
||||
/// Drop any saved position whose top-left no longer falls on one of
|
||||
/// `monitors`. Guards against creating a bubble on a disconnected
|
||||
/// secondary monitor, where the user can't see or recover it.
|
||||
pub fn validate(&mut self, monitors: &[Rect]) {
|
||||
for provider in ProviderId::ALL {
|
||||
if let Some((x, y)) = self.get(provider) {
|
||||
if !position_on_any_monitor(x, y, monitors) {
|
||||
log::warn!(
|
||||
"bubble position {} ({x},{y}) outside all monitors; resetting to default",
|
||||
provider.slug()
|
||||
);
|
||||
self.reset(provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default = "default_show_claude")]
|
||||
pub show_claude_code: bool,
|
||||
#[serde(default = "default_show_codex")]
|
||||
pub show_codex: bool,
|
||||
#[serde(default = "default_show_opencode_go")]
|
||||
pub show_opencode_go: bool,
|
||||
#[serde(default)]
|
||||
pub bubble_positions: BubblePositions,
|
||||
#[serde(default = "default_bubble_size")]
|
||||
pub bubble_size_logical: i32,
|
||||
#[serde(default = "default_poll_interval_ms")]
|
||||
pub poll_interval_ms: u32,
|
||||
#[serde(default)]
|
||||
pub last_update_check_unix: Option<u64>,
|
||||
#[serde(default = "default_update_check_interval_secs")]
|
||||
pub update_check_interval_secs: Option<u64>,
|
||||
#[serde(default = "default_widget_visible")]
|
||||
pub widget_visible: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_claude_code: default_show_claude(),
|
||||
show_codex: default_show_codex(),
|
||||
show_opencode_go: default_show_opencode_go(),
|
||||
bubble_positions: BubblePositions::default(),
|
||||
bubble_size_logical: default_bubble_size(),
|
||||
poll_interval_ms: default_poll_interval_ms(),
|
||||
last_update_check_unix: None,
|
||||
update_check_interval_secs: default_update_check_interval_secs(),
|
||||
widget_visible: default_widget_visible(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn is_provider_enabled(&self, provider: ProviderId) -> bool {
|
||||
match provider {
|
||||
ProviderId::Claude => self.show_claude_code,
|
||||
ProviderId::ChatGpt => self.show_codex,
|
||||
ProviderId::OpenCodeGo => self.show_opencode_go,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.show_claude_code = enabled,
|
||||
ProviderId::ChatGpt => self.show_codex = enabled,
|
||||
ProviderId::OpenCodeGo => self.show_opencode_go = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_provider(&mut self, provider: ProviderId) {
|
||||
let enabled = !self.is_provider_enabled(provider);
|
||||
self.set_provider_enabled(provider, enabled);
|
||||
}
|
||||
|
||||
/// Menu toggle for a provider. Providers without live usage cannot be
|
||||
/// toggled, and unchecking the last enabled live provider is undone so
|
||||
/// the app always shows something. Returns the provider's new state.
|
||||
pub fn toggle_live_provider(&mut self, provider: ProviderId) -> bool {
|
||||
if provider.metadata().live_usage {
|
||||
self.toggle_provider(provider);
|
||||
if !self.has_enabled_live_provider() {
|
||||
self.set_provider_enabled(provider, true);
|
||||
}
|
||||
}
|
||||
self.is_provider_enabled(provider)
|
||||
}
|
||||
|
||||
pub fn has_enabled_live_provider(&self) -> bool {
|
||||
ProviderId::LIVE_USAGE
|
||||
.iter()
|
||||
.any(|provider| self.is_provider_enabled(*provider))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join(APP_DIR_NAME))
|
||||
}
|
||||
|
||||
pub fn settings_path() -> PathBuf {
|
||||
settings_dir()
|
||||
.unwrap_or_else(|| std::env::temp_dir().join(APP_DIR_NAME))
|
||||
.join(SETTINGS_FILE)
|
||||
}
|
||||
|
||||
/// Read and normalise the settings file. A missing, unreadable or corrupt
|
||||
/// file yields the defaults. Saved positions are not checked against the
|
||||
/// monitors here; call [`drop_offscreen_positions`] once they are known.
|
||||
pub fn load() -> Settings {
|
||||
load_from(&settings_path())
|
||||
}
|
||||
|
||||
/// [`load`] from an explicit path.
|
||||
pub fn load_from(path: &Path) -> Settings {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => parse_and_normalise(&content),
|
||||
Err(err) => {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
log::warn!("settings read failed ({}): {err}", path.display());
|
||||
}
|
||||
Settings::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse settings JSON, falling back to the defaults when it is corrupt,
|
||||
/// then enforce the invariants a hand-edited file may break.
|
||||
pub fn parse_and_normalise(content: &str) -> Settings {
|
||||
let mut settings: Settings = serde_json::from_str(content).unwrap_or_else(|err| {
|
||||
log::warn!("settings file is not valid; using defaults: {err}");
|
||||
Settings::default()
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
// Clamp bubble size to safe range in case settings.json was hand-edited.
|
||||
settings.bubble_size_logical = settings
|
||||
.bubble_size_logical
|
||||
.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
|
||||
settings
|
||||
}
|
||||
|
||||
/// Drop positions on monitors that have since been disconnected.
|
||||
/// `monitors` are the full monitor rects in physical px. An empty list means
|
||||
/// the monitors could not be enumerated, so every position is kept rather
|
||||
/// than all of them being thrown away.
|
||||
pub fn drop_offscreen_positions(settings: &mut Settings, monitors: &[Rect]) {
|
||||
if monitors.is_empty() {
|
||||
log::warn!("no monitors reported; keeping saved bubble positions");
|
||||
return;
|
||||
}
|
||||
settings.bubble_positions.validate(monitors);
|
||||
}
|
||||
|
||||
/// Persist `settings` with an atomic write (tmp file, then rename). Falls
|
||||
/// back to a direct write when the rename fails.
|
||||
pub fn save(settings: &Settings) -> std::io::Result<()> {
|
||||
save_to(&settings_path(), settings)
|
||||
}
|
||||
|
||||
/// [`save`] to an explicit path.
|
||||
pub fn save_to(path: &Path, settings: &Settings) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(settings).map_err(std::io::Error::other)?;
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
let atomic = std::fs::write(&tmp_path, &json).and_then(|()| std::fs::rename(&tmp_path, path));
|
||||
match atomic {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
log::warn!("atomic settings save failed, writing directly: {err}");
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
std::fs::write(path, json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn old_two_provider_settings_json_still_loads() {
|
||||
let json = r#"{
|
||||
"show_claude_code": false,
|
||||
"show_codex": true,
|
||||
"bubble_positions": {
|
||||
"claude": [10, 20],
|
||||
"codex": [30, 40]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let settings: Settings =
|
||||
serde_json::from_str(json).expect("old settings json should parse");
|
||||
|
||||
assert!(!settings.show_claude_code);
|
||||
assert!(settings.show_codex);
|
||||
assert!(!settings.show_opencode_go);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::Claude),
|
||||
Some((10, 20))
|
||||
);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::ChatGpt),
|
||||
Some((30, 40))
|
||||
);
|
||||
assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_provider_settings_json_loads() {
|
||||
let json = r#"{
|
||||
"show_claude_code": true,
|
||||
"show_codex": false,
|
||||
"show_opencode_go": true,
|
||||
"bubble_positions": {
|
||||
"opencode_go": [50, 60]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let settings: Settings =
|
||||
serde_json::from_str(json).expect("new settings json should parse");
|
||||
|
||||
assert!(settings.show_claude_code);
|
||||
assert!(!settings.show_codex);
|
||||
assert!(settings.show_opencode_go);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::OpenCodeGo),
|
||||
Some((50, 60))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_falls_back_to_defaults() {
|
||||
let settings = parse_and_normalise("{ not json");
|
||||
assert!(settings.show_claude_code);
|
||||
assert!(!settings.show_codex);
|
||||
assert_eq!(settings.bubble_size_logical, DEFAULT_BUBBLE_SIZE);
|
||||
assert_eq!(settings.poll_interval_ms, POLL_5_MIN);
|
||||
assert_eq!(
|
||||
settings.update_check_interval_secs,
|
||||
Some(UPDATE_CHECK_HOURLY_SECS)
|
||||
);
|
||||
assert!(settings.widget_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalise_restores_a_live_provider_and_clamps_size() {
|
||||
let settings = parse_and_normalise(
|
||||
r#"{"show_claude_code": false, "show_codex": false, "show_opencode_go": true,
|
||||
"bubble_size_logical": 9000}"#,
|
||||
);
|
||||
assert!(settings.show_claude_code);
|
||||
assert_eq!(settings.bubble_size_logical, MAX_BUBBLE_SIZE);
|
||||
let small = parse_and_normalise(r#"{"bubble_size_logical": 10}"#);
|
||||
assert_eq!(small.bubble_size_logical, MIN_BUBBLE_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialised_keys_stay_compatible() {
|
||||
let value = serde_json::to_value(Settings::default()).expect("serialise");
|
||||
let mut keys: Vec<_> = value.as_object().expect("object").keys().cloned().collect();
|
||||
keys.sort();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"bubble_positions",
|
||||
"bubble_size_logical",
|
||||
"last_update_check_unix",
|
||||
"poll_interval_ms",
|
||||
"show_claude_code",
|
||||
"show_codex",
|
||||
"show_opencode_go",
|
||||
"update_check_interval_secs",
|
||||
"widget_visible",
|
||||
]
|
||||
);
|
||||
let mut s = Settings::default();
|
||||
s.bubble_positions.set(ProviderId::ChatGpt, (-1900, 40));
|
||||
let json = serde_json::to_string(&s).expect("serialise");
|
||||
assert!(json.contains(r#""codex":[-1900,40]"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_positions_are_dropped_against_monitors() {
|
||||
let mut settings = parse_and_normalise(
|
||||
r#"{"bubble_positions": {"claude": [100, 100], "codex": [-3000, 100],
|
||||
"opencode_go": [2000, 500]}}"#,
|
||||
);
|
||||
let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)];
|
||||
drop_offscreen_positions(&mut settings, &monitors);
|
||||
let p = &settings.bubble_positions;
|
||||
assert_eq!(p.get(ProviderId::Claude), Some((100, 100)));
|
||||
assert_eq!(p.get(ProviderId::ChatGpt), None);
|
||||
assert_eq!(p.get(ProviderId::OpenCodeGo), Some((2000, 500)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_monitors_keeps_saved_positions() {
|
||||
let mut settings = Settings::default();
|
||||
settings
|
||||
.bubble_positions
|
||||
.set(ProviderId::Claude, (-3000, 100));
|
||||
drop_offscreen_positions(&mut settings, &[]);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::Claude),
|
||||
Some((-3000, 100))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ccub-settings-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or_default()
|
||||
));
|
||||
let path = dir.join(SETTINGS_FILE);
|
||||
let mut settings = Settings {
|
||||
show_codex: true,
|
||||
poll_interval_ms: POLL_15_MIN,
|
||||
update_check_interval_secs: None,
|
||||
last_update_check_unix: Some(1_700_000_000),
|
||||
..Settings::default()
|
||||
};
|
||||
settings.bubble_positions.set(ProviderId::Claude, (12, 34));
|
||||
save_to(&path, &settings).expect("save");
|
||||
assert!(!path.with_extension("json.tmp").exists());
|
||||
let loaded = load_from(&path);
|
||||
assert!(loaded.show_codex);
|
||||
assert_eq!(loaded.poll_interval_ms, POLL_15_MIN);
|
||||
assert_eq!(loaded.update_check_interval_secs, None);
|
||||
assert_eq!(loaded.last_update_check_unix, Some(1_700_000_000));
|
||||
assert_eq!(
|
||||
loaded.bubble_positions.get(ProviderId::Claude),
|
||||
Some((12, 34))
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).expect("cleanup");
|
||||
// A missing file loads the defaults.
|
||||
assert!(load_from(&path).show_claude_code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_keeps_provider_flags_independent() {
|
||||
let mut settings = Settings::default();
|
||||
settings.toggle_provider(ProviderId::ChatGpt);
|
||||
assert!(settings.is_provider_enabled(ProviderId::ChatGpt));
|
||||
settings.toggle_provider(ProviderId::Claude);
|
||||
assert!(!settings.is_provider_enabled(ProviderId::Claude));
|
||||
assert!(settings.has_enabled_live_provider());
|
||||
settings.toggle_provider(ProviderId::ChatGpt);
|
||||
assert!(!settings.has_enabled_live_provider());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_toggle_never_disables_the_last_live_provider() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(settings.toggle_live_provider(ProviderId::Claude));
|
||||
assert!(settings.show_claude_code);
|
||||
assert!(settings.toggle_live_provider(ProviderId::ChatGpt));
|
||||
assert!(!settings.toggle_live_provider(ProviderId::Claude));
|
||||
assert!(settings.toggle_live_provider(ProviderId::ChatGpt));
|
||||
// OpenCode Go has no live usage, so its flag never changes.
|
||||
assert!(!settings.toggle_live_provider(ProviderId::OpenCodeGo));
|
||||
assert!(!settings.show_opencode_go);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
//! Portable app state rules: applying poll results, alert crossings and
|
||||
//! cooldown, the tray tooltip, the update status label and the update-check
|
||||
//! schedule.
|
||||
//!
|
||||
//! None of this touches a window, tray or menu. Callers apply the returned
|
||||
//! values after dropping their state lock.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::core::i18n::{self, LocaleStrings};
|
||||
use crate::usage::{ProviderId, UsageWindows};
|
||||
|
||||
/// Minimum gap between two cooldown-gated alerts (threshold and session
|
||||
/// expired), so notifications stay calm.
|
||||
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];
|
||||
|
||||
/// Shortest countdown re-render delay.
|
||||
const MIN_COUNTDOWN_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
/// One provider's last known usage plus its panel/tooltip texts.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ProviderUiState {
|
||||
pub windows: UsageWindows,
|
||||
/// `format_window` text for the session window, `"!"` after an auth
|
||||
/// failure, `"…"` after a transient error.
|
||||
pub primary_text: String,
|
||||
/// Same as `primary_text` for the weekly window.
|
||||
pub secondary_text: String,
|
||||
}
|
||||
|
||||
/// How one provider's poll ended, independent of the HTTP error type.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PollOutcome {
|
||||
Ok(UsageWindows),
|
||||
/// The credentials were rejected (auth required or token expired).
|
||||
AuthFailed,
|
||||
/// Any other failure: network, bad response, missing credentials.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A notification to show.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Alert {
|
||||
Threshold { provider: ProviderId, threshold: u8 },
|
||||
SessionExpired { provider: ProviderId },
|
||||
UpdateApplied { version: String },
|
||||
}
|
||||
|
||||
impl Alert {
|
||||
/// `(title, body)` for the notification.
|
||||
pub fn text(&self, strings: &LocaleStrings) -> (String, String) {
|
||||
match self {
|
||||
Alert::Threshold {
|
||||
provider,
|
||||
threshold,
|
||||
} => {
|
||||
let title = format!("{} · {threshold}%", strings.provider_label(*provider));
|
||||
let body = if *threshold >= 95 {
|
||||
strings.threshold_95_body.clone()
|
||||
} else {
|
||||
strings.threshold_80_body.clone()
|
||||
};
|
||||
(title, body)
|
||||
}
|
||||
Alert::SessionExpired { provider } => match provider {
|
||||
ProviderId::Claude => (
|
||||
strings.token_expired_title.clone(),
|
||||
strings.token_expired_body.clone(),
|
||||
),
|
||||
ProviderId::ChatGpt => (
|
||||
strings.chatgpt_token_expired_title.clone(),
|
||||
strings.chatgpt_token_expired_body.clone(),
|
||||
),
|
||||
ProviderId::OpenCodeGo => (
|
||||
strings.opencode_go_label.clone(),
|
||||
strings.update_failed.clone(),
|
||||
),
|
||||
},
|
||||
Alert::UpdateApplied { version } => (
|
||||
strings.update_applied_title.clone(),
|
||||
format!("{}{version}", strings.update_applied_body),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a batch of poll results changed beyond the state itself.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Applied {
|
||||
/// Threshold alerts that passed the cooldown, in firing order.
|
||||
pub alerts: Vec<Alert>,
|
||||
/// Providers whose credentials were rejected; the caller runs the CLI
|
||||
/// refresh for them.
|
||||
pub auth_failures: Vec<ProviderId>,
|
||||
}
|
||||
|
||||
/// Usage snapshots and the alert cooldown.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct UsageState {
|
||||
pub snapshots: HashMap<ProviderId, ProviderUiState>,
|
||||
/// True when at least one provider succeeded on the last non-empty poll.
|
||||
/// Shared by all providers, as before.
|
||||
pub last_poll_ok: bool,
|
||||
pub last_alert_at: Option<Instant>,
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn apply_results(
|
||||
&mut self,
|
||||
results: Vec<(ProviderId, PollOutcome)>,
|
||||
strings: &LocaleStrings,
|
||||
now: Instant,
|
||||
) -> Applied {
|
||||
let mut applied = Applied::default();
|
||||
if results.is_empty() {
|
||||
return applied;
|
||||
}
|
||||
let mut crossings: Vec<(ProviderId, u8)> = Vec::new();
|
||||
let mut any_ok = false;
|
||||
for (id, outcome) in results {
|
||||
let entry = self.snapshots.entry(id).or_default();
|
||||
match outcome {
|
||||
PollOutcome::Ok(windows) => {
|
||||
let old_pct = 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 {
|
||||
crossings.push((id, threshold));
|
||||
}
|
||||
}
|
||||
entry.windows = windows;
|
||||
entry.primary_text = i18n::format_window(&windows.primary, strings);
|
||||
entry.secondary_text = i18n::format_window(&windows.secondary, strings);
|
||||
any_ok = true;
|
||||
}
|
||||
PollOutcome::AuthFailed => {
|
||||
applied.auth_failures.push(id);
|
||||
entry.primary_text = "!".into();
|
||||
entry.secondary_text = "!".into();
|
||||
}
|
||||
PollOutcome::Failed => {
|
||||
entry.primary_text = "…".into();
|
||||
entry.secondary_text = "…".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_poll_ok = any_ok;
|
||||
for (provider, threshold) in crossings {
|
||||
if self.admit_alert(now) {
|
||||
applied.alerts.push(Alert::Threshold {
|
||||
provider,
|
||||
threshold,
|
||||
});
|
||||
}
|
||||
}
|
||||
applied
|
||||
}
|
||||
|
||||
/// Claim the alert cooldown. Returns false while the previous gated
|
||||
/// alert is younger than [`ALERT_COOLDOWN`].
|
||||
pub fn admit_alert(&mut self, now: Instant) -> bool {
|
||||
if let Some(last) = self.last_alert_at {
|
||||
if now.saturating_duration_since(last) < ALERT_COOLDOWN {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.last_alert_at = Some(now);
|
||||
true
|
||||
}
|
||||
|
||||
/// The session-expired alert for `provider`, if the cooldown allows one.
|
||||
pub fn session_expired_alert(&mut self, provider: ProviderId, now: Instant) -> Option<Alert> {
|
||||
self.admit_alert(now)
|
||||
.then_some(Alert::SessionExpired { provider })
|
||||
}
|
||||
|
||||
/// Re-format every countdown text after time passed. Error markers are
|
||||
/// replaced too, matching the countdown timer of the Win32 build.
|
||||
pub fn refresh_texts(&mut self, strings: &LocaleStrings) {
|
||||
for entry in self.snapshots.values_mut() {
|
||||
entry.primary_text = i18n::format_window(&entry.windows.primary, strings);
|
||||
entry.secondary_text = i18n::format_window(&entry.windows.secondary, strings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay until any displayed countdown changes, at least one second.
|
||||
/// `None` when no window has a reset time ahead.
|
||||
pub fn countdown_delay(&self, now: SystemTime) -> Option<Duration> {
|
||||
self.snapshots
|
||||
.values()
|
||||
.flat_map(|e| [e.windows.primary.resets_at, e.windows.secondary.resets_at])
|
||||
.filter_map(|reset| i18n::time_until_display_change_at(reset, now))
|
||||
.min()
|
||||
.map(|d| d.max(MIN_COUNTDOWN_DELAY))
|
||||
}
|
||||
}
|
||||
|
||||
/// Four-line tray tooltip: label, session, weekly and the click hint.
|
||||
pub fn tray_tooltip(
|
||||
label: &str,
|
||||
entry: Option<&ProviderUiState>,
|
||||
strings: &LocaleStrings,
|
||||
) -> String {
|
||||
let session = entry
|
||||
.map(|e| e.primary_text.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("...");
|
||||
let weekly = entry
|
||||
.map(|e| e.secondary_text.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("...");
|
||||
format!(
|
||||
"{label}\n{}: {session}\n{}: {weekly}\n{}",
|
||||
strings.session_window, strings.weekly_window, strings.tray_left_click
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum UpdateStatus {
|
||||
#[default]
|
||||
Idle,
|
||||
Checking,
|
||||
UpToDate,
|
||||
Available,
|
||||
Applying,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl UpdateStatus {
|
||||
/// Whether a check or apply is running, which disables the menu item.
|
||||
pub fn is_busy(self) -> bool {
|
||||
matches!(self, UpdateStatus::Checking | UpdateStatus::Applying)
|
||||
}
|
||||
}
|
||||
|
||||
/// The version menu item: `"<status> · v<version>"`.
|
||||
pub fn version_action_label(status: UpdateStatus, strings: &LocaleStrings) -> String {
|
||||
let base = match status {
|
||||
UpdateStatus::Idle => &strings.check_for_updates,
|
||||
UpdateStatus::Checking => &strings.checking_for_updates,
|
||||
UpdateStatus::UpToDate => &strings.up_to_date,
|
||||
UpdateStatus::Available => &strings.update_available,
|
||||
UpdateStatus::Applying => &strings.applying_update,
|
||||
UpdateStatus::Failed => &strings.update_failed,
|
||||
};
|
||||
// The running binary's version, so the user can see what they are on
|
||||
// without an About dialog. The middle dot matches the countdown text.
|
||||
format!("{base} \u{00b7} v{}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// When the next automatic update check runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UpdateCheckDue {
|
||||
Now,
|
||||
After(Duration),
|
||||
}
|
||||
|
||||
/// Schedule the automatic update check from the last check time (unix
|
||||
/// seconds). `None` when auto-check is disabled. Due when never checked or
|
||||
/// when `now - last >= interval`; otherwise after the remainder.
|
||||
pub fn update_check_due(
|
||||
last_check_unix: Option<u64>,
|
||||
interval_secs: Option<u64>,
|
||||
now_unix: u64,
|
||||
) -> Option<UpdateCheckDue> {
|
||||
let interval = interval_secs?;
|
||||
let Some(last) = last_check_unix else {
|
||||
return Some(UpdateCheckDue::Now);
|
||||
};
|
||||
let elapsed = now_unix.saturating_sub(last);
|
||||
if elapsed >= interval {
|
||||
Some(UpdateCheckDue::Now)
|
||||
} else {
|
||||
Some(UpdateCheckDue::After(Duration::from_secs(
|
||||
interval - elapsed,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::i18n::I18n;
|
||||
use crate::usage::Window;
|
||||
|
||||
fn windows(primary: f64) -> UsageWindows {
|
||||
UsageWindows {
|
||||
primary: Window {
|
||||
utilization: primary,
|
||||
resets_at: None,
|
||||
},
|
||||
secondary: Window {
|
||||
utilization: 10.0,
|
||||
resets_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn poll(state: &mut UsageState, pct: f64, now: Instant) -> Applied {
|
||||
let strings = I18n::load().strings().clone();
|
||||
state.apply_results(
|
||||
vec![(ProviderId::Claude, PollOutcome::Ok(windows(pct)))],
|
||||
&strings,
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
fn threshold(t: u8) -> Alert {
|
||||
Alert::Threshold {
|
||||
provider: ProviderId::Claude,
|
||||
threshold: t,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_eighty_fires_once() {
|
||||
let now = Instant::now();
|
||||
let mut state = UsageState::default();
|
||||
assert!(poll(&mut state, 79.0, now).alerts.is_empty());
|
||||
assert_eq!(poll(&mut state, 80.0, now).alerts, [threshold(80)]);
|
||||
// Parked above the threshold: no new crossing, even after the cooldown.
|
||||
let later = now + ALERT_COOLDOWN;
|
||||
assert!(poll(&mut state, 85.0, later).alerts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_crossing_is_cut_to_one_by_the_cooldown() {
|
||||
let now = Instant::now();
|
||||
let mut state = UsageState::default();
|
||||
poll(&mut state, 79.0, now);
|
||||
assert_eq!(poll(&mut state, 96.0, now).alerts, [threshold(80)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_poll_above_threshold_alerts() {
|
||||
let mut state = UsageState::default();
|
||||
assert_eq!(
|
||||
poll(&mut state, 90.0, Instant::now()).alerts,
|
||||
[threshold(80)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_expires_after_thirty_minutes() {
|
||||
let now = Instant::now();
|
||||
let mut state = UsageState::default();
|
||||
assert!(state.admit_alert(now));
|
||||
assert!(!state.admit_alert(now + ALERT_COOLDOWN - Duration::from_secs(1)));
|
||||
assert_eq!(state.session_expired_alert(ProviderId::ChatGpt, now), None);
|
||||
assert_eq!(
|
||||
state.session_expired_alert(ProviderId::ChatGpt, now + ALERT_COOLDOWN),
|
||||
Some(Alert::SessionExpired {
|
||||
provider: ProviderId::ChatGpt
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_set_marker_texts_and_auth_failures() {
|
||||
let strings = I18n::load().strings().clone();
|
||||
let mut state = UsageState::default();
|
||||
poll(&mut state, 40.0, Instant::now());
|
||||
let applied = state.apply_results(
|
||||
vec![
|
||||
(ProviderId::Claude, PollOutcome::AuthFailed),
|
||||
(ProviderId::ChatGpt, PollOutcome::Failed),
|
||||
],
|
||||
&strings,
|
||||
Instant::now(),
|
||||
);
|
||||
assert_eq!(applied.auth_failures, [ProviderId::Claude]);
|
||||
assert!(!state.last_poll_ok);
|
||||
let claude = &state.snapshots[&ProviderId::Claude];
|
||||
assert_eq!(claude.primary_text, "!");
|
||||
assert_eq!(claude.secondary_text, "!");
|
||||
// The stored windows survive the error.
|
||||
assert_eq!(claude.windows.primary.utilization, 40.0);
|
||||
let codex = &state.snapshots[&ProviderId::ChatGpt];
|
||||
assert_eq!(codex.primary_text, "…");
|
||||
assert_eq!(codex.secondary_text, "…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_poll_ok_is_global_and_empty_batches_change_nothing() {
|
||||
let strings = I18n::load().strings().clone();
|
||||
let mut state = UsageState::default();
|
||||
let applied = state.apply_results(
|
||||
vec![
|
||||
(ProviderId::Claude, PollOutcome::Failed),
|
||||
(ProviderId::ChatGpt, PollOutcome::Ok(windows(5.0))),
|
||||
],
|
||||
&strings,
|
||||
Instant::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()),
|
||||
Applied::default()
|
||||
);
|
||||
assert!(state.last_poll_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alert_texts() {
|
||||
let strings = I18n::load().strings().clone();
|
||||
assert_eq!(
|
||||
threshold(95).text(&strings),
|
||||
(
|
||||
"Claude Code · 95%".to_string(),
|
||||
"Limit is close - consider easing up.".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Alert::SessionExpired {
|
||||
provider: ProviderId::ChatGpt
|
||||
}
|
||||
.text(&strings)
|
||||
.0,
|
||||
"Codex session expired"
|
||||
);
|
||||
assert_eq!(
|
||||
Alert::UpdateApplied {
|
||||
version: "0.6.0".into()
|
||||
}
|
||||
.text(&strings)
|
||||
.1,
|
||||
"Updated to v0.6.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_uses_dots_for_empty_texts() {
|
||||
let strings = I18n::load().strings().clone();
|
||||
assert_eq!(
|
||||
tray_tooltip("Claude Code", None, &strings),
|
||||
"Claude Code\n5h: ...\n7d: ...\nLeft-click: show/hide"
|
||||
);
|
||||
let entry = ProviderUiState {
|
||||
primary_text: "73% · 2h".into(),
|
||||
secondary_text: String::new(),
|
||||
..ProviderUiState::default()
|
||||
};
|
||||
assert_eq!(
|
||||
tray_tooltip("Codex", Some(&entry), &strings),
|
||||
"Codex\n5h: 73% · 2h\n7d: ...\nLeft-click: show/hide"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_label_carries_status_and_version() {
|
||||
let strings = I18n::load().strings().clone();
|
||||
let v = env!("CARGO_PKG_VERSION");
|
||||
assert_eq!(
|
||||
version_action_label(UpdateStatus::Idle, &strings),
|
||||
format!("Check for updates · v{v}")
|
||||
);
|
||||
assert_eq!(
|
||||
version_action_label(UpdateStatus::Applying, &strings),
|
||||
format!("Applying update... · v{v}")
|
||||
);
|
||||
assert!(UpdateStatus::Checking.is_busy());
|
||||
assert!(!UpdateStatus::Available.is_busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_schedule_due_and_remaining() {
|
||||
let hour = 3_600;
|
||||
assert_eq!(update_check_due(None, None, 10_000), None);
|
||||
assert_eq!(update_check_due(Some(9_000), None, 10_000), None);
|
||||
assert_eq!(
|
||||
update_check_due(None, Some(hour), 10_000),
|
||||
Some(UpdateCheckDue::Now)
|
||||
);
|
||||
assert_eq!(
|
||||
update_check_due(Some(10_000 - hour), Some(hour), 10_000),
|
||||
Some(UpdateCheckDue::Now)
|
||||
);
|
||||
assert_eq!(
|
||||
update_check_due(Some(10_000 - 600), Some(hour), 10_000),
|
||||
Some(UpdateCheckDue::After(Duration::from_secs(3_000)))
|
||||
);
|
||||
// A clock that moved backwards counts as zero elapsed.
|
||||
assert_eq!(
|
||||
update_check_due(Some(20_000), Some(hour), 10_000),
|
||||
Some(UpdateCheckDue::After(Duration::from_secs(hour)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn countdown_delay_takes_the_nearest_change_with_a_floor() {
|
||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let mut state = UsageState::default();
|
||||
assert_eq!(state.countdown_delay(now), None);
|
||||
let mut w = windows(10.0);
|
||||
w.primary.resets_at = Some(now + Duration::from_secs(2 * 3_600 + 30));
|
||||
w.secondary.resets_at = Some(now + Duration::from_secs(3 * 86_400 + 90));
|
||||
state.snapshots.insert(
|
||||
ProviderId::Claude,
|
||||
ProviderUiState {
|
||||
windows: w,
|
||||
..ProviderUiState::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(state.countdown_delay(now), Some(Duration::from_secs(31)));
|
||||
w.primary.resets_at = Some(now + Duration::from_secs(20));
|
||||
state
|
||||
.snapshots
|
||||
.get_mut(&ProviderId::Claude)
|
||||
.expect("entry")
|
||||
.windows = w;
|
||||
assert_eq!(state.countdown_delay(now), Some(Duration::from_secs(1)));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// and the tray badge. Keeping the function in one place ensures the three
|
||||
// surfaces never disagree about what "78% used" looks like.
|
||||
|
||||
use crate::os::Rgb as Color;
|
||||
use crate::core::color::Rgb as Color;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex tracks
|
||||
@@ -47,3 +47,37 @@ pub fn bar_fill_color(model: ProviderId, is_dark: bool, percent: f64) -> Color {
|
||||
Color::from_hex("#A01818")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hex(model: ProviderId, is_dark: bool, pct: f64) -> String {
|
||||
bar_fill_color(model, is_dark, pct).to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_band_edges() {
|
||||
let c = ProviderId::Claude;
|
||||
assert_eq!(hex(c, true, 59.9), "#D97757");
|
||||
assert_eq!(hex(c, true, 60.0), "#E0A040");
|
||||
assert_eq!(hex(c, false, 60.0), "#B47A20");
|
||||
assert_eq!(hex(c, true, 79.9), "#E0A040");
|
||||
assert_eq!(hex(c, false, 79.9), "#B47A20");
|
||||
assert_eq!(hex(c, true, 80.0), "#C45020");
|
||||
assert_eq!(hex(c, false, 94.9), "#C45020");
|
||||
assert_eq!(hex(c, true, 95.0), "#A01818");
|
||||
assert_eq!(hex(c, false, 100.0), "#A01818");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_band_uses_theme_aware_accent() {
|
||||
assert_eq!(hex(ProviderId::ChatGpt, true, 10.0), "#E5E5E5");
|
||||
assert_eq!(hex(ProviderId::ChatGpt, false, 10.0), "#1A1A1A");
|
||||
assert_eq!(hex(ProviderId::OpenCodeGo, false, 0.0), "#3BAE75");
|
||||
assert_eq!(
|
||||
accent_color_for(ProviderId::Claude, false),
|
||||
accent_color_for(ProviderId::Claude, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Release version parsing and comparison for the update check.
|
||||
//!
|
||||
//! Only `major.minor.patch` counts: a leading `v` and anything after the
|
||||
//! first `-` are ignored, and a missing or non-numeric part reads as 0. So
|
||||
//! `v1.2.3-rc1` equals `1.2.3`, and a pre-release of the running version is
|
||||
//! never offered as an update.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// A `major.minor.patch` triple, ordered numerically part by part.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Version {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
pub patch: u32,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
/// The running build's version, from `Cargo.toml`.
|
||||
pub fn current() -> Self {
|
||||
Self::parse(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// Parse a release tag or version string. Never fails: anything that
|
||||
/// is not a number becomes 0, so `abc` is `0.0.0`.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
let core = s
|
||||
.trim()
|
||||
.trim_start_matches(['v', 'V'])
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
let mut parts = core.split('.').map(|p| p.trim().parse::<u32>().ok());
|
||||
let mut next = || parts.next().flatten().unwrap_or(0);
|
||||
Version {
|
||||
major: next(),
|
||||
minor: next(),
|
||||
patch: next(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Version {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `candidate` is strictly newer than `current`. Equal versions and
|
||||
/// downgrades are not updates.
|
||||
pub fn newer(candidate: &str, current: &str) -> bool {
|
||||
Version::parse(candidate) > Version::parse(current)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn v(major: u32, minor: u32, patch: u32) -> Version {
|
||||
Version {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffixes_and_prefix_are_ignored() {
|
||||
assert_eq!(Version::parse("v1.2.3-rc1"), v(1, 2, 3));
|
||||
assert_eq!(Version::parse("1.2.3"), v(1, 2, 3));
|
||||
assert_eq!(Version::parse(" V0.5.0 "), v(0, 5, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_malformed_parts_read_as_zero() {
|
||||
assert_eq!(Version::parse("v1"), v(1, 0, 0));
|
||||
assert_eq!(Version::parse("1.2"), v(1, 2, 0));
|
||||
assert_eq!(Version::parse("abc"), v(0, 0, 0));
|
||||
assert_eq!(Version::parse(""), v(0, 0, 0));
|
||||
assert_eq!(Version::parse("1.x.3"), v(1, 0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_strictly_newer_version_is_an_update() {
|
||||
assert!(newer("0.6.0", "0.5.0"));
|
||||
assert!(newer("v0.5.1", "0.5.0"));
|
||||
assert!(newer("1.0.0", "0.99.99"));
|
||||
assert!(newer("0.10.0", "0.9.0"));
|
||||
// Same version, with or without a suffix: no update.
|
||||
assert!(!newer("0.5.0", "0.5.0"));
|
||||
assert!(!newer("v0.5.0-rc1", "0.5.0"));
|
||||
assert!(!newer("0.5.0", "0.5.0-beta"));
|
||||
// No downgrade.
|
||||
assert!(!newer("0.4.9", "0.5.0"));
|
||||
assert!(!newer("abc", "0.5.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn displays_the_bare_triple() {
|
||||
assert_eq!(Version::parse("v1.2.3-rc1").to_string(), "1.2.3");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Bubble visibility: the user's show/hide choice and the fullscreen
|
||||
//! auto-hide, combined into show and hide actions.
|
||||
//!
|
||||
//! A user show wins over fullscreen and shows at once; leaving fullscreen
|
||||
//! shows the bubbles again only when the user has not hidden them.
|
||||
|
||||
/// The two reasons a bubble can be hidden.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Visibility {
|
||||
pub user_hidden: bool,
|
||||
pub hidden_by_fullscreen: bool,
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
/// The state at start-up, from the saved `widget_visible` setting.
|
||||
pub fn from_widget_visible(widget_visible: bool) -> Self {
|
||||
Self {
|
||||
user_hidden: !widget_visible,
|
||||
hidden_by_fullscreen: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a bubble created now should be shown.
|
||||
pub fn shows(&self) -> bool {
|
||||
!self.user_hidden && !self.hidden_by_fullscreen
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Event {
|
||||
UserShow,
|
||||
UserHide,
|
||||
FullscreenOn,
|
||||
FullscreenOff,
|
||||
}
|
||||
|
||||
/// What to do with every bubble after an event.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Show,
|
||||
Hide,
|
||||
}
|
||||
|
||||
/// Apply `event` to `state`. Returns the new state and the action for the
|
||||
/// bubbles, `None` when they stay as they are.
|
||||
pub fn next(state: Visibility, event: Event) -> (Visibility, Option<Action>) {
|
||||
match event {
|
||||
Event::UserShow => (
|
||||
Visibility {
|
||||
user_hidden: false,
|
||||
..state
|
||||
},
|
||||
Some(Action::Show),
|
||||
),
|
||||
Event::UserHide => (
|
||||
Visibility {
|
||||
user_hidden: true,
|
||||
..state
|
||||
},
|
||||
Some(Action::Hide),
|
||||
),
|
||||
Event::FullscreenOn if state.hidden_by_fullscreen => (state, None),
|
||||
Event::FullscreenOn => (
|
||||
Visibility {
|
||||
hidden_by_fullscreen: true,
|
||||
..state
|
||||
},
|
||||
Some(Action::Hide),
|
||||
),
|
||||
Event::FullscreenOff if !state.hidden_by_fullscreen => (state, None),
|
||||
Event::FullscreenOff => (
|
||||
Visibility {
|
||||
hidden_by_fullscreen: false,
|
||||
..state
|
||||
},
|
||||
(!state.user_hidden).then_some(Action::Show),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SHOWN: Visibility = Visibility {
|
||||
user_hidden: false,
|
||||
hidden_by_fullscreen: false,
|
||||
};
|
||||
|
||||
fn state(user_hidden: bool, hidden_by_fullscreen: bool) -> Visibility {
|
||||
Visibility {
|
||||
user_hidden,
|
||||
hidden_by_fullscreen,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_show_shows_at_once_even_during_fullscreen() {
|
||||
assert_eq!(
|
||||
next(state(true, false), Event::UserShow),
|
||||
(SHOWN, Some(Action::Show))
|
||||
);
|
||||
assert_eq!(
|
||||
next(state(true, true), Event::UserShow),
|
||||
(state(false, true), Some(Action::Show))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_hide_hides() {
|
||||
assert_eq!(
|
||||
next(SHOWN, Event::UserHide),
|
||||
(state(true, false), Some(Action::Hide))
|
||||
);
|
||||
assert_eq!(
|
||||
next(state(false, true), Event::UserHide),
|
||||
(state(true, true), Some(Action::Hide))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_on_hides_once() {
|
||||
assert_eq!(
|
||||
next(SHOWN, Event::FullscreenOn),
|
||||
(state(false, true), Some(Action::Hide))
|
||||
);
|
||||
assert_eq!(
|
||||
next(state(true, false), Event::FullscreenOn),
|
||||
(state(true, true), Some(Action::Hide))
|
||||
);
|
||||
assert_eq!(
|
||||
next(state(false, true), Event::FullscreenOn),
|
||||
(state(false, true), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_off_shows_only_when_the_user_has_not_hidden() {
|
||||
assert_eq!(
|
||||
next(state(false, true), Event::FullscreenOff),
|
||||
(SHOWN, Some(Action::Show))
|
||||
);
|
||||
assert_eq!(
|
||||
next(state(true, true), Event::FullscreenOff),
|
||||
(state(true, false), None)
|
||||
);
|
||||
assert_eq!(next(SHOWN, Event::FullscreenOff), (SHOWN, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_up_state_follows_the_saved_setting() {
|
||||
assert!(Visibility::from_widget_visible(true).shows());
|
||||
assert!(!Visibility::from_widget_visible(false).shows());
|
||||
assert!(!state(false, true).shows());
|
||||
}
|
||||
}
|
||||
+80
-17
@@ -5,6 +5,7 @@
|
||||
// timestamp in the file; we discover expiry only when the server returns
|
||||
// 401 or 403 to a poll.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
@@ -17,16 +18,36 @@ pub struct LocalCodexCreds {
|
||||
|
||||
impl LocalCodexCreds {
|
||||
pub fn detect() -> Option<Self> {
|
||||
let path = if let Some(home) = std::env::var_os("CODEX_HOME") {
|
||||
PathBuf::from(home).join("auth.json")
|
||||
} else {
|
||||
dirs::home_dir()?.join(".codex").join("auth.json")
|
||||
};
|
||||
let path = auth_path(std::env::var_os("CODEX_HOME"), dirs::home_dir())?;
|
||||
let id = format!("codex:{}", path.display());
|
||||
Some(Self { path, id })
|
||||
}
|
||||
}
|
||||
|
||||
/// `$CODEX_HOME/auth.json`, else `~/.codex/auth.json`. An empty `CODEX_HOME`
|
||||
/// counts as unset (as in the Codex CLI), so it never resolves to a path
|
||||
/// relative to the working directory.
|
||||
fn auth_path(codex_home: Option<OsString>, home: Option<PathBuf>) -> Option<PathBuf> {
|
||||
match codex_home.filter(|h| !h.is_empty()) {
|
||||
Some(dir) => Some(PathBuf::from(dir).join("auth.json")),
|
||||
None => Some(home?.join(".codex").join("auth.json")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `auth.json` shape written by the Codex CLI.
|
||||
fn parse_codex_json(content: &str) -> Result<super::Token, super::Error> {
|
||||
let parsed: Envelope = serde_json::from_str(content)?;
|
||||
let tokens = parsed.tokens.ok_or(super::Error::MissingField("tokens"))?;
|
||||
if tokens.access_token.is_empty() {
|
||||
return Err(super::Error::MissingField("access_token"));
|
||||
}
|
||||
Ok(super::Token {
|
||||
access_token: tokens.access_token,
|
||||
expires_at_unix_ms: None,
|
||||
account_id: tokens.account_id,
|
||||
})
|
||||
}
|
||||
|
||||
impl super::CredentialSource for LocalCodexCreds {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
@@ -34,18 +55,7 @@ impl super::CredentialSource for LocalCodexCreds {
|
||||
|
||||
fn read(&self) -> Result<super::Token, super::Error> {
|
||||
let content = std::fs::read_to_string(&self.path)?;
|
||||
let parsed: Envelope = serde_json::from_str(&content)?;
|
||||
let tokens = parsed
|
||||
.tokens
|
||||
.ok_or(super::Error::MissingField("tokens"))?;
|
||||
if tokens.access_token.is_empty() {
|
||||
return Err(super::Error::MissingField("access_token"));
|
||||
}
|
||||
Ok(super::Token {
|
||||
access_token: tokens.access_token,
|
||||
expires_at_unix_ms: None,
|
||||
account_id: tokens.account_id,
|
||||
})
|
||||
parse_codex_json(&content)
|
||||
}
|
||||
|
||||
fn signature(&self) -> Option<String> {
|
||||
@@ -74,3 +84,56 @@ struct Tokens {
|
||||
access_token: String,
|
||||
account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::Error;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_auth_reads_token_and_account() {
|
||||
let token = parse_codex_json(&fixture("codex-auth-full.json")).expect("token");
|
||||
assert_eq!(token.access_token, "fixture-access-token");
|
||||
assert_eq!(token.account_id.as_deref(), Some("fixture-account"));
|
||||
assert_eq!(token.expires_at_unix_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_tokens_is_a_missing_field() {
|
||||
let err = parse_codex_json(&fixture("codex-auth-null-tokens.json")).unwrap_err();
|
||||
assert!(matches!(err, Error::MissingField("tokens")), "{err}");
|
||||
let err = parse_codex_json("{}").unwrap_err();
|
||||
assert!(matches!(err, Error::MissingField("tokens")), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_missing_access_token_is_rejected() {
|
||||
let err = parse_codex_json(&fixture("codex-auth-empty-access-token.json")).unwrap_err();
|
||||
assert!(matches!(err, Error::MissingField("access_token")), "{err}");
|
||||
let err = parse_codex_json(&fixture("codex-auth-missing-access-token.json")).unwrap_err();
|
||||
assert!(matches!(err, Error::Json(_)), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_home_wins_unless_empty() {
|
||||
let home = Some(PathBuf::from("/home/u"));
|
||||
assert_eq!(
|
||||
auth_path(Some(OsString::from("/opt/codex")), home.clone()),
|
||||
Some(PathBuf::from("/opt/codex/auth.json"))
|
||||
);
|
||||
assert_eq!(
|
||||
auth_path(Some(OsString::new()), home.clone()),
|
||||
Some(PathBuf::from("/home/u/.codex/auth.json"))
|
||||
);
|
||||
assert_eq!(
|
||||
auth_path(None, home),
|
||||
Some(PathBuf::from("/home/u/.codex/auth.json"))
|
||||
);
|
||||
assert_eq!(auth_path(Some(OsString::new()), None), None);
|
||||
}
|
||||
}
|
||||
+52
-5
@@ -1,8 +1,8 @@
|
||||
// Read Claude credentials from the Windows user profile.
|
||||
// Read Claude credentials from the user's home directory.
|
||||
//
|
||||
// Path: `%USERPROFILE%\.claude\.credentials.json` (matches what the
|
||||
// official Claude CLI writes). Schema: `{ "claudeAiOauth": { "accessToken",
|
||||
// "expiresAt": <ms-since-epoch> } }`.
|
||||
// Path: `~/.claude/.credentials.json`, `%USERPROFILE%\.claude\.credentials.json`
|
||||
// on Windows (matches what the official Claude CLI writes). Schema:
|
||||
// `{ "claudeAiOauth": { "accessToken", "expiresAt": <ms-since-epoch> } }`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::UNIX_EPOCH;
|
||||
@@ -62,10 +62,57 @@ pub(crate) fn parse_claude_json(content: &str) -> Result<super::Token, super::Er
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(super::Error::MissingField("accessToken"))?
|
||||
.to_string();
|
||||
let expires_at_unix_ms = oauth.get("expiresAt").and_then(|v| v.as_i64());
|
||||
// The CLI writes integer milliseconds; a float is truncated rather than
|
||||
// dropped, so a client-side expiry check still runs.
|
||||
let expires_at_unix_ms = oauth
|
||||
.get("expiresAt")
|
||||
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)));
|
||||
Ok(super::Token {
|
||||
access_token,
|
||||
expires_at_unix_ms,
|
||||
account_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::creds::Error;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_credentials_read_token_and_expiry() {
|
||||
let token = parse_claude_json(&fixture("claude-credentials-full.json")).expect("token");
|
||||
assert_eq!(token.access_token, "sk-ant-oat01-fixture");
|
||||
assert_eq!(token.expires_at_unix_ms, Some(1_761_040_800_000));
|
||||
assert_eq!(token.account_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_expiry_is_truncated_to_milliseconds() {
|
||||
let token =
|
||||
parse_claude_json(&fixture("claude-credentials-float-expiry.json")).expect("token");
|
||||
assert_eq!(token.expires_at_unix_ms, Some(1_761_040_800_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_expiry_is_none() {
|
||||
let token = parse_claude_json(r#"{"claudeAiOauth":{"accessToken":"t","expiresAt":null}}"#)
|
||||
.expect("token");
|
||||
assert_eq!(token.expires_at_unix_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_are_reported() {
|
||||
let err = parse_claude_json(&fixture("claude-credentials-missing-token.json")).unwrap_err();
|
||||
assert!(matches!(err, Error::MissingField("accessToken")), "{err}");
|
||||
let err = parse_claude_json(&fixture("claude-credentials-missing-oauth.json")).unwrap_err();
|
||||
assert!(matches!(err, Error::MissingField("claudeAiOauth")), "{err}");
|
||||
let err = parse_claude_json("not json").unwrap_err();
|
||||
assert!(matches!(err, Error::Json(_)), "{err}");
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -7,10 +7,14 @@
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use simplelog::{Config, LevelFilter, WriteLogger};
|
||||
use simplelog::{CombinedLogger, ConfigBuilder, LevelFilter, WriteLogger};
|
||||
|
||||
const LOG_FILE_NAME: &str = "claude-code-usage-bubble.log";
|
||||
|
||||
/// 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";
|
||||
|
||||
/// Initialise file-based logging. Idempotent — second call is a no-op.
|
||||
///
|
||||
/// Returns the resolved log-file path on success, or `Ok(None)` when
|
||||
@@ -22,8 +26,18 @@ pub fn init(enabled: bool) -> std::io::Result<Option<PathBuf>> {
|
||||
}
|
||||
let path = std::env::temp_dir().join(LOG_FILE_NAME);
|
||||
let file = File::create(&path)?;
|
||||
let updater_file = file.try_clone()?;
|
||||
let app = ConfigBuilder::new()
|
||||
.add_filter_ignore_str(UPDATER_TARGET)
|
||||
.build();
|
||||
let updater = ConfigBuilder::new()
|
||||
.add_filter_allow_str(UPDATER_TARGET)
|
||||
.build();
|
||||
// simplelog will refuse a second init; convert that into a soft no-op.
|
||||
let _ = WriteLogger::init(LevelFilter::Debug, Config::default(), file);
|
||||
let _ = CombinedLogger::init(vec![
|
||||
WriteLogger::new(LevelFilter::Debug, app, file),
|
||||
WriteLogger::new(LevelFilter::Info, updater, updater_file),
|
||||
]);
|
||||
log::info!("diagnostic logging enabled at {}", path.display());
|
||||
Ok(Some(path))
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Fullscreen auto-hide: a background thread checks every 1.5 s whether a
|
||||
//! fullscreen window of another process is in the foreground, and hides the
|
||||
//! bubbles while one is. They come back without activation when it leaves,
|
||||
//! unless the user hid them in the meantime.
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::core::visibility::Event;
|
||||
use crate::platform::{self, FullscreenCheck};
|
||||
use crate::runtime::Runtime;
|
||||
|
||||
/// How often the foreground window is checked.
|
||||
pub const CHECK_INTERVAL: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// Start the check thread. It runs until the process starts exiting.
|
||||
pub fn spawn(runtime: Arc<Runtime>) -> io::Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("fullscreen".into())
|
||||
.spawn(move || {
|
||||
while !runtime.is_exiting() {
|
||||
tick(&runtime);
|
||||
std::thread::sleep(CHECK_INTERVAL);
|
||||
}
|
||||
})
|
||||
.map(drop)
|
||||
}
|
||||
|
||||
/// One check: read the foreground state and, when it differs from what the
|
||||
/// bubbles reflect, apply the matching visibility event. The state is read
|
||||
/// and released before any window call (`apply_visibility` does the same).
|
||||
fn tick(runtime: &Runtime) {
|
||||
let check = platform::foreground_is_fullscreen();
|
||||
let visibility = runtime.with_state(|s| s.visibility);
|
||||
let Some(event) = event_for(visibility.hidden_by_fullscreen, check.fullscreen) else {
|
||||
return;
|
||||
};
|
||||
log_decision(event, &check, visibility.user_hidden);
|
||||
runtime.apply_visibility(event);
|
||||
}
|
||||
|
||||
/// The visibility event for a check result, or `None` when the bubbles
|
||||
/// already reflect it.
|
||||
pub fn event_for(hidden_by_fullscreen: bool, fullscreen: bool) -> Option<Event> {
|
||||
match (hidden_by_fullscreen, fullscreen) {
|
||||
(false, true) => Some(Event::FullscreenOn),
|
||||
(true, false) => Some(Event::FullscreenOff),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn log_decision(event: Event, check: &FullscreenCheck, user_hidden: bool) {
|
||||
let action = if event == Event::FullscreenOn {
|
||||
"hide"
|
||||
} else {
|
||||
"show"
|
||||
};
|
||||
log::info!(
|
||||
"bubble fullscreen {action} reason={} {} user_hidden={user_hidden}",
|
||||
check.reason,
|
||||
check.detail
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::visibility::{self, Action, Visibility};
|
||||
|
||||
#[test]
|
||||
fn only_a_change_produces_an_event() {
|
||||
assert_eq!(event_for(false, true), Some(Event::FullscreenOn));
|
||||
assert_eq!(event_for(true, false), Some(Event::FullscreenOff));
|
||||
assert_eq!(event_for(false, false), None);
|
||||
assert_eq!(event_for(true, true), None);
|
||||
}
|
||||
|
||||
/// Feed a sequence of check results through the state machine and
|
||||
/// collect the actions the bubbles receive.
|
||||
fn run(start: Visibility, checks: &[bool]) -> (Visibility, Vec<Action>) {
|
||||
let mut state = start;
|
||||
let mut actions = Vec::new();
|
||||
for &fullscreen in checks {
|
||||
if let Some(event) = event_for(state.hidden_by_fullscreen, fullscreen) {
|
||||
let (next, action) = visibility::next(state, event);
|
||||
state = next;
|
||||
actions.extend(action);
|
||||
}
|
||||
}
|
||||
(state, actions)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fullscreen_spell_hides_once_and_shows_once() {
|
||||
let (state, actions) = run(Visibility::default(), &[false, true, true, true, false]);
|
||||
assert_eq!(actions, [Action::Hide, Action::Show]);
|
||||
assert_eq!(state, Visibility::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_hidden_bubbles_stay_hidden_after_fullscreen() {
|
||||
let start = Visibility::from_widget_visible(false);
|
||||
let (state, actions) = run(start, &[true, false]);
|
||||
assert_eq!(actions, [Action::Hide]);
|
||||
assert_eq!(state, start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_show_during_fullscreen_is_not_undone_by_the_next_check() {
|
||||
let (hidden, _) = run(Visibility::default(), &[true]);
|
||||
let (shown, action) = visibility::next(hidden, Event::UserShow);
|
||||
assert_eq!(action, Some(Action::Show));
|
||||
// Fullscreen is still on: no event, so the bubbles stay shown.
|
||||
let (state, actions) = run(shown, &[true, true]);
|
||||
assert!(actions.is_empty());
|
||||
// Leaving fullscreen clears the flag; the bubbles are already shown.
|
||||
let (state, actions) = run(state, &[false]);
|
||||
assert_eq!(actions, [Action::Show]);
|
||||
assert!(state.shows());
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
//! Process handoff: relaunching the app and cleaning up after earlier
|
||||
//! updates.
|
||||
//!
|
||||
//! A relaunched instance gets `--wait-pid <our pid>` and waits for this
|
||||
//! process to exit before it takes the single-instance lock.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::core::settings::APP_DIR_NAME;
|
||||
|
||||
/// Spawn a detached copy of the app with `--wait-pid <pid>` followed by
|
||||
/// `extra_args`. The caller exits only when this returns `Ok`, so a failed
|
||||
/// spawn never leaves the user without a running app.
|
||||
pub fn relaunch(extra_args: &[OsString]) -> io::Result<()> {
|
||||
let exe = relaunch_target()?;
|
||||
let mut command = Command::new(&exe);
|
||||
command
|
||||
.arg("--wait-pid")
|
||||
.arg(std::process::id().to_string())
|
||||
.args(extra_args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
detach(&mut command);
|
||||
let child = command.spawn()?;
|
||||
log::info!(
|
||||
"relaunched {} as pid {} (parent pid {})",
|
||||
exe.display(),
|
||||
child.id(),
|
||||
std::process::id()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The file to start: the AppImage itself when running from one (the
|
||||
/// executable path points into its temporary mount), else this executable.
|
||||
fn relaunch_target() -> io::Result<PathBuf> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(appimage) = std::env::var_os("APPIMAGE").filter(|v| !v.is_empty()) {
|
||||
return Ok(PathBuf::from(appimage));
|
||||
}
|
||||
}
|
||||
std::env::current_exe()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn detach(command: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn detach(command: &mut Command) {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
// A new process group, so a Ctrl+C aimed at the old instance's
|
||||
// terminal does not also stop the new one.
|
||||
command.process_group(0);
|
||||
}
|
||||
|
||||
/// `data_local_dir()/ClaudeCodeUsageBubble/updates`, where updates are
|
||||
/// staged and `update-error.log` lives.
|
||||
pub fn updates_dir() -> Option<PathBuf> {
|
||||
dirs::data_local_dir().map(|d| d.join(APP_DIR_NAME).join("updates"))
|
||||
}
|
||||
|
||||
/// Delete what earlier updates left behind: `<exe file name>.old.*` next to
|
||||
/// the executable and, in `updates_dir`, the staged `update.exe` and
|
||||
/// `updater-helper-*.exe` copies. `update-error.log` is kept. Every failure
|
||||
/// is logged and skipped.
|
||||
pub fn cleanup_stale_files(exe: &Path, updates_dir: Option<&Path>) {
|
||||
if let (Some(dir), Some(name)) = (exe.parent(), exe.file_name()) {
|
||||
let prefix = format!("{}.old.", name.to_string_lossy());
|
||||
remove_matching(dir, |file| file.starts_with(&prefix));
|
||||
}
|
||||
if let Some(dir) = updates_dir {
|
||||
remove_matching(dir, |file| {
|
||||
file == "update.exe" || (file.starts_with("updater-helper-") && file.ends_with(".exe"))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_matching(dir: &Path, matches: impl Fn(&str) -> bool) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
if err.kind() != io::ErrorKind::NotFound {
|
||||
log::debug!("cleanup: read_dir {} failed: {err}", dir.display());
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if !matches(&name.to_string_lossy()) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => log::info!("cleanup: removed {}", path.display()),
|
||||
Err(err) => log::debug!("cleanup: remove {} failed: {err}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// A fresh, empty directory under the system temp dir.
|
||||
fn scratch_dir(tag: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ccub-handoff-{tag}-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn touch(path: &Path) {
|
||||
std::fs::write(path, b"x").expect("write file");
|
||||
}
|
||||
|
||||
fn names(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir)
|
||||
.expect("read dir")
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_only_leftovers_from_updates() {
|
||||
let root = scratch_dir("cleanup");
|
||||
let install = root.join("install");
|
||||
let updates = root.join("updates");
|
||||
std::fs::create_dir_all(&install).expect("install dir");
|
||||
std::fs::create_dir_all(&updates).expect("updates dir");
|
||||
|
||||
let exe = install.join("claude-code-usage-bubble.exe");
|
||||
touch(&exe);
|
||||
touch(&install.join("claude-code-usage-bubble.exe.old.1234"));
|
||||
touch(&install.join("claude-code-usage-bubble.exe.old.99"));
|
||||
touch(&install.join("other.exe.old.1"));
|
||||
touch(&install.join("settings.json"));
|
||||
// A directory with a matching name is left alone.
|
||||
std::fs::create_dir_all(install.join("claude-code-usage-bubble.exe.old.dir")).expect("dir");
|
||||
|
||||
touch(&updates.join("update.exe"));
|
||||
touch(&updates.join("updater-helper-4242.exe"));
|
||||
touch(&updates.join("updater-helper-notes.txt"));
|
||||
touch(&updates.join("update-error.log"));
|
||||
|
||||
cleanup_stale_files(&exe, Some(&updates));
|
||||
|
||||
assert_eq!(
|
||||
names(&install),
|
||||
[
|
||||
"claude-code-usage-bubble.exe",
|
||||
"claude-code-usage-bubble.exe.old.dir",
|
||||
"other.exe.old.1",
|
||||
"settings.json",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
names(&updates),
|
||||
["update-error.log", "updater-helper-notes.txt"]
|
||||
);
|
||||
std::fs::remove_dir_all(&root).expect("remove scratch dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_tolerates_missing_directories() {
|
||||
let root = scratch_dir("missing");
|
||||
let exe = root.join("absent").join("app");
|
||||
cleanup_stale_files(&exe, Some(&root.join("no-updates")));
|
||||
cleanup_stale_files(Path::new("app"), None);
|
||||
std::fs::remove_dir_all(&root).expect("remove scratch dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_dir_sits_under_the_app_data_dir() {
|
||||
if let Some(dir) = updates_dir() {
|
||||
assert!(dir.ends_with(Path::new(APP_DIR_NAME).join("updates")));
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
//! Claude Code Usage Bubble on Tauri v2.
|
||||
//!
|
||||
//! The binary in `src/main.rs` only calls [`run`]. Every module is `pub` so
|
||||
//! that items wired up incrementally do not trip `dead_code`.
|
||||
|
||||
pub mod badge;
|
||||
pub mod bubble;
|
||||
pub mod cli;
|
||||
pub mod commands;
|
||||
pub mod core;
|
||||
pub mod creds;
|
||||
pub mod diag;
|
||||
pub mod fullscreen;
|
||||
pub mod handoff;
|
||||
pub mod menu;
|
||||
pub mod net;
|
||||
pub mod panel;
|
||||
pub mod platform;
|
||||
pub mod runtime;
|
||||
pub mod scheduler;
|
||||
pub mod tray;
|
||||
pub mod update;
|
||||
pub mod usage;
|
||||
|
||||
/// Starts the application and blocks until it exits.
|
||||
pub fn run() {
|
||||
runtime::run();
|
||||
}
|
||||
+4
-67
@@ -1,70 +1,7 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
// Several modules (creds, usage, update, os::dpi, …) expose API that the
|
||||
// app surface doesn't fully call yet — they're scaffolding for in-progress
|
||||
// port phases. Allow at the crate root rather than scattering attributes.
|
||||
#![allow(dead_code)]
|
||||
|
||||
// Original infrastructure.
|
||||
mod creds;
|
||||
mod diag;
|
||||
mod i18n;
|
||||
mod net;
|
||||
mod os;
|
||||
mod tray;
|
||||
mod update;
|
||||
mod usage;
|
||||
mod usage_color;
|
||||
|
||||
// Application surface.
|
||||
mod app;
|
||||
mod bubble;
|
||||
mod panel;
|
||||
mod settings;
|
||||
// Release builds use the GUI subsystem so no console window opens with the
|
||||
// app. Debug builds keep the console for log output.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let diagnose_enabled = args.iter().any(|a| a == "--diagnose");
|
||||
if diagnose_enabled {
|
||||
if let Ok(Some(path)) = diag::init(true) {
|
||||
log::info!("startup args={args:?} log_path={}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(exit_code) = update::run_cli(&args) {
|
||||
if diagnose_enabled {
|
||||
log::info!("cli mode exited with code {exit_code}");
|
||||
}
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
let wait_pid = args
|
||||
.iter()
|
||||
.position(|a| a == "--wait-pid")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
if let Some(pid) = wait_pid {
|
||||
if diagnose_enabled {
|
||||
log::info!("waiting up to 5s for parent pid {pid} to exit");
|
||||
}
|
||||
update::handoff::wait_for_parent_exit(pid, 5_000);
|
||||
}
|
||||
|
||||
let updated_to = args
|
||||
.iter()
|
||||
.position(|a| a == "--updated-to")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.cloned();
|
||||
|
||||
if diagnose_enabled {
|
||||
log::info!("entering app::run (wait_pid={wait_pid:?} updated_to={updated_to:?})");
|
||||
}
|
||||
app::run(AppArgs {
|
||||
wait_pid_present: wait_pid.is_some(),
|
||||
updated_to,
|
||||
});
|
||||
}
|
||||
|
||||
pub struct AppArgs {
|
||||
pub wait_pid_present: bool,
|
||||
pub updated_to: Option<String>,
|
||||
claude_code_usage_bubble_lib::run();
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
//! The native context menu: [`core::menu_model`](crate::core::menu_model)
|
||||
//! mapped onto `tauri::menu`, shown on every tray icon and on a bubble
|
||||
//! right-click.
|
||||
//!
|
||||
//! Native check items toggle their own mark when clicked, so the menus are
|
||||
//! rebuilt from state after every handled click, including clicks that
|
||||
//! change nothing. Every function here that touches Tauri runs without the
|
||||
//! state lock held.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use tauri::menu::{
|
||||
CheckMenuItem, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
|
||||
};
|
||||
use tauri::{AppHandle, Manager, State, Wry};
|
||||
#[cfg(not(windows))]
|
||||
use tauri_plugin_autostart::{AutoLaunchManager, ManagerExt};
|
||||
|
||||
use crate::core::i18n::LocaleStrings;
|
||||
use crate::core::menu_model::{self, MenuCommand, MenuEntry, MenuInput};
|
||||
use crate::core::settings::Settings;
|
||||
use crate::core::state::UpdateStatus;
|
||||
use crate::runtime::{AppState, Runtime, BUBBLE_LABEL_PREFIX};
|
||||
|
||||
/// An owned copy of the state the menu reflects, taken under the lock.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MenuSnapshot {
|
||||
settings: Settings,
|
||||
strings: LocaleStrings,
|
||||
update_status: UpdateStatus,
|
||||
update_can_apply: bool,
|
||||
}
|
||||
|
||||
impl MenuSnapshot {
|
||||
pub fn of(state: &AppState) -> Self {
|
||||
Self {
|
||||
settings: state.settings.clone(),
|
||||
strings: state.strings.clone(),
|
||||
update_status: state.update_status,
|
||||
update_can_apply: state.update_channel.can_apply(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu tree for this snapshot and the autostart state.
|
||||
pub fn entries(&self, autostart_enabled: bool) -> Vec<MenuEntry> {
|
||||
menu_model::build(MenuInput {
|
||||
settings: &self.settings,
|
||||
strings: &self.strings,
|
||||
autostart_enabled,
|
||||
update_status: self.update_status,
|
||||
update_can_apply: self.update_can_apply,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu tree last applied to the trays, so an unchanged tree is not
|
||||
/// rebuilt on every poll or countdown tick.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MenuCache {
|
||||
last: Mutex<Option<Vec<MenuEntry>>>,
|
||||
}
|
||||
|
||||
impl MenuCache {
|
||||
/// Record `entries` and report whether the trays need a new menu: when
|
||||
/// forced or when the tree differs from the last one recorded.
|
||||
pub fn update(&self, entries: &[MenuEntry], force: bool) -> bool {
|
||||
let mut last = self.last.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if !force && last.as_deref() == Some(entries) {
|
||||
return false;
|
||||
}
|
||||
*last = Some(entries.to_vec());
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the app starts at login. Reads the live entry, so a Task
|
||||
/// Manager disable shows as unchecked; any read error counts as off.
|
||||
pub fn autostart_enabled(app: &AppHandle) -> bool {
|
||||
autostart_state(app).unwrap_or_else(|err| {
|
||||
log::warn!("autostart state unreadable: {err}");
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// Flip the start-at-login entry. Errors are logged; the next menu rebuild
|
||||
/// shows the real state either way.
|
||||
pub fn toggle_autostart(app: &AppHandle) {
|
||||
let result = autostart_state(app).and_then(|enabled| set_autostart(app, !enabled));
|
||||
if let Err(err) = result {
|
||||
log::error!("autostart toggle failed: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows bypasses the autostart plugin: its builder cannot pin the
|
||||
/// current-user mode, so an elevated run would write an all-users entry
|
||||
/// with an unquoted path (see `platform::windows::autostart_entry`).
|
||||
#[cfg(windows)]
|
||||
fn autostart_state(_app: &AppHandle) -> Result<bool, String> {
|
||||
crate::platform::windows::autostart_entry(crate::runtime::AUTOSTART_NAME)
|
||||
.map_err(|err| err.to_string())?
|
||||
.is_enabled()
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn set_autostart(_app: &AppHandle, enable: bool) -> Result<(), String> {
|
||||
let entry = crate::platform::windows::autostart_entry(crate::runtime::AUTOSTART_NAME)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let result = if enable {
|
||||
entry.enable()
|
||||
} else {
|
||||
entry.disable()
|
||||
};
|
||||
result.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Without the plugin (it failed to set up) start at login reads as off.
|
||||
#[cfg(not(windows))]
|
||||
fn autostart_state(app: &AppHandle) -> Result<bool, String> {
|
||||
if app.try_state::<AutoLaunchManager>().is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
app.autolaunch().is_enabled().map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn set_autostart(app: &AppHandle, enable: bool) -> Result<(), String> {
|
||||
if app.try_state::<AutoLaunchManager>().is_none() {
|
||||
return Err("autostart plugin is not available".into());
|
||||
}
|
||||
let manager = app.autolaunch();
|
||||
let result = if enable {
|
||||
manager.enable()
|
||||
} else {
|
||||
manager.disable()
|
||||
};
|
||||
result.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Build a native menu from the tree, keeping order, labels, check marks
|
||||
/// and greyed states. Hint rows are disabled items with generated ids.
|
||||
pub fn build(app: &AppHandle, entries: &[MenuEntry]) -> tauri::Result<Menu<Wry>> {
|
||||
let menu = Menu::new(app)?;
|
||||
for entry in entries {
|
||||
menu.append(item(app, entry)?.as_ref())?;
|
||||
}
|
||||
Ok(menu)
|
||||
}
|
||||
|
||||
fn item(app: &AppHandle, entry: &MenuEntry) -> tauri::Result<Box<dyn IsMenuItem<Wry>>> {
|
||||
Ok(match entry {
|
||||
MenuEntry::Action {
|
||||
command,
|
||||
label,
|
||||
enabled,
|
||||
} => Box::new(MenuItem::with_id(
|
||||
app,
|
||||
command.id(),
|
||||
label,
|
||||
*enabled,
|
||||
None::<&str>,
|
||||
)?),
|
||||
MenuEntry::Check {
|
||||
command,
|
||||
label,
|
||||
checked,
|
||||
enabled,
|
||||
} => Box::new(CheckMenuItem::with_id(
|
||||
app,
|
||||
command.id(),
|
||||
label,
|
||||
*enabled,
|
||||
*checked,
|
||||
None::<&str>,
|
||||
)?),
|
||||
MenuEntry::Hint { label } => Box::new(MenuItem::new(app, label, false, None::<&str>)?),
|
||||
MenuEntry::Separator => Box::new(PredefinedMenuItem::separator(app)?),
|
||||
MenuEntry::Submenu { label, entries } => {
|
||||
let submenu = Submenu::new(app, label, true)?;
|
||||
for child in entries {
|
||||
submenu.append(item(app, child)?.as_ref())?;
|
||||
}
|
||||
Box::new(submenu)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The one handler for every menu click, from a tray or a bubble menu.
|
||||
/// Runs on the main thread.
|
||||
pub fn on_menu_event(app: &AppHandle, event: MenuEvent) {
|
||||
let id = event.id().as_ref();
|
||||
let Some(command) = MenuCommand::parse(id) else {
|
||||
log::debug!("menu: ignoring id {id:?}");
|
||||
return;
|
||||
};
|
||||
let Some(runtime) = app.try_state::<Arc<Runtime>>() else {
|
||||
return;
|
||||
};
|
||||
let runtime = Arc::clone(&runtime);
|
||||
runtime.on_menu_command(command);
|
||||
if !matches!(command, MenuCommand::Restart | MenuCommand::Exit) {
|
||||
runtime.refresh_shell(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the context menu on a bubble at the cursor.
|
||||
#[tauri::command]
|
||||
pub async fn show_context_menu(
|
||||
app: AppHandle,
|
||||
runtime: State<'_, Arc<Runtime>>,
|
||||
label: String,
|
||||
) -> Result<(), String> {
|
||||
if !label.starts_with(BUBBLE_LABEL_PREFIX) {
|
||||
return Err("not a bubble window".into());
|
||||
}
|
||||
let window = app
|
||||
.get_webview_window(&label)
|
||||
.ok_or_else(|| "no such bubble window".to_string())?;
|
||||
let snapshot = runtime.with_state(|s| MenuSnapshot::of(s));
|
||||
let handle = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
let entries = snapshot.entries(autostart_enabled(&handle));
|
||||
let menu = match build(&handle, &entries) {
|
||||
Ok(menu) => menu,
|
||||
Err(err) => {
|
||||
log::error!("context menu could not be built: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The popup only dismisses on an outside click when its owner is
|
||||
// the foreground window.
|
||||
#[cfg(windows)]
|
||||
crate::platform::windows::set_foreground(&window);
|
||||
if let Err(err) = window.popup_menu(&menu) {
|
||||
log::error!(
|
||||
"context menu could not be shown on {}: {err}",
|
||||
window.label()
|
||||
);
|
||||
}
|
||||
})
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::i18n::I18n;
|
||||
|
||||
fn snapshot(settings: Settings) -> MenuSnapshot {
|
||||
MenuSnapshot {
|
||||
settings,
|
||||
strings: I18n::load().strings().clone(),
|
||||
update_status: UpdateStatus::Idle,
|
||||
update_can_apply: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_skips_an_unchanged_tree_unless_forced() {
|
||||
let cache = MenuCache::default();
|
||||
let entries = snapshot(Settings::default()).entries(false);
|
||||
assert!(
|
||||
cache.update(&entries, false),
|
||||
"first tree is always applied"
|
||||
);
|
||||
assert!(!cache.update(&entries, false));
|
||||
assert!(
|
||||
cache.update(&entries, true),
|
||||
"a check-item click rebuilds even when nothing changed"
|
||||
);
|
||||
let toggled = snapshot(Settings::default()).entries(true);
|
||||
assert!(cache.update(&toggled, false));
|
||||
assert!(!cache.update(&toggled, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_entries_follow_the_autostart_state() {
|
||||
let snap = snapshot(Settings::default());
|
||||
let autostart = |entries: &[MenuEntry]| -> Option<bool> {
|
||||
fn find(entries: &[MenuEntry]) -> Option<bool> {
|
||||
entries.iter().find_map(|e| match e {
|
||||
MenuEntry::Check {
|
||||
command: MenuCommand::Autostart,
|
||||
checked,
|
||||
..
|
||||
} => Some(*checked),
|
||||
MenuEntry::Submenu { entries, .. } => find(entries),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
find(entries)
|
||||
};
|
||||
assert_eq!(autostart(&snap.entries(true)), Some(true));
|
||||
assert_eq!(autostart(&snap.entries(false)), Some(false));
|
||||
}
|
||||
}
|
||||
+708
-7
@@ -1,9 +1,710 @@
|
||||
// `net` namespace: HTTP client implementations.
|
||||
//
|
||||
// `winhttp` is the only backend right now (Windows-only app). It produces
|
||||
// `Response` values that are cheap to inspect via `status`, `header`,
|
||||
// `text`, and `json`.
|
||||
//! Minimal blocking HTTP client on `ureq`.
|
||||
//!
|
||||
//! One [`Client`] is shared across poll workers. 4xx and 5xx responses come
|
||||
//! back as ordinary [`Response`] values so providers can read the status and
|
||||
//! the rate-limit headers of an error reply. Tokens and request headers are
|
||||
//! never logged here.
|
||||
|
||||
pub mod winhttp;
|
||||
use std::time::Duration;
|
||||
|
||||
pub use winhttp::{Client, Error, Response};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use ureq::http::{self, Uri};
|
||||
use ureq::tls::{RootCerts, TlsConfig};
|
||||
use ureq::{Agent, Proxy, ProxyProtocol};
|
||||
|
||||
use crate::core::proxy::SystemProxy;
|
||||
|
||||
/// Default User-Agent; a per-request `User-Agent` header replaces it.
|
||||
pub const USER_AGENT: &str = concat!("claude-code-usage-bubble/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const GLOBAL_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("HTTP transport: {0}")]
|
||||
Transport(String),
|
||||
#[error("response was not valid UTF-8")]
|
||||
Utf8,
|
||||
#[error("JSON parse: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
/// Kept for the providers' 401/403 match arms. The client reports every
|
||||
/// status as a `Response`, so it never builds this variant itself.
|
||||
#[error("response status {0}")]
|
||||
Status(u32),
|
||||
}
|
||||
|
||||
impl From<ureq::Error> for Error {
|
||||
fn from(err: ureq::Error) -> Self {
|
||||
Error::Transport(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<http::Error> for Error {
|
||||
fn from(err: http::Error) -> Self {
|
||||
Error::Transport(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy variables in the order `ureq` reads them; the first usable one wins.
|
||||
const ENV_PROXY_VARS: &[&str] = &[
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
];
|
||||
|
||||
/// The environment proxy, if one is set.
|
||||
///
|
||||
/// Walks the variables `ureq` reads and keeps the first valid one, whatever its
|
||||
/// scheme: HTTP and HTTPS proxies are reached with CONNECT, SOCKS4/4a/5/5h
|
||||
/// proxies (`ALL_PROXY=socks5h://…`, as Clash and v2ray set) through ureq's
|
||||
/// `socks-proxy` support. `NO_PROXY` applies to the chosen proxy.
|
||||
pub fn env_proxy() -> Option<Proxy> {
|
||||
env_proxy_from(|name| std::env::var(name).ok())
|
||||
}
|
||||
|
||||
fn env_proxy_from(var: impl Fn(&str) -> Option<String>) -> Option<Proxy> {
|
||||
let no_proxy = var("NO_PROXY").or_else(|| var("no_proxy"));
|
||||
for name in ENV_PROXY_VARS {
|
||||
let Some(value) = var(name) else { continue };
|
||||
// Invalid values are skipped, as `ureq` does. Values are not logged:
|
||||
// they may carry credentials.
|
||||
let Ok(parsed) = Proxy::new(&value) else {
|
||||
continue;
|
||||
};
|
||||
// Rebuild so the proxy carries the `NO_PROXY` bypass list, which only
|
||||
// the builder accepts.
|
||||
let mut builder = Proxy::builder(parsed.protocol())
|
||||
.host(parsed.host())
|
||||
.port(parsed.port());
|
||||
if let Some(user) = parsed.username() {
|
||||
builder = builder.username(user);
|
||||
}
|
||||
if let Some(password) = parsed.password() {
|
||||
builder = builder.password(password);
|
||||
}
|
||||
for expr in no_proxy.iter().flat_map(|v| v.split(',')) {
|
||||
let expr = expr.trim();
|
||||
if !expr.is_empty() {
|
||||
builder = builder.no_proxy(expr);
|
||||
}
|
||||
}
|
||||
match builder.build() {
|
||||
Ok(proxy) => return Some(proxy),
|
||||
Err(err) => log::warn!("{name} ignored, it is not a valid proxy URI: {err}"),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `proxy` is an HTTP CONNECT proxy, the only kind the system proxy
|
||||
/// may be.
|
||||
fn is_connect_proxy(proxy: &Proxy) -> bool {
|
||||
matches!(proxy.protocol(), ProxyProtocol::Http | ProxyProtocol::Https)
|
||||
}
|
||||
|
||||
/// The static system proxy: the `Internet Settings` proxy on Windows, none
|
||||
/// elsewhere. It applies only when [`env_proxy`] finds nothing.
|
||||
pub fn system_proxy() -> Option<SystemProxy> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::platform::windows::system_proxy()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A system-proxied agent plus the bypass rules that route around it.
|
||||
struct Proxied {
|
||||
agent: Agent,
|
||||
proxy: SystemProxy,
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
/// Direct agent, or the environment-proxied one when an env proxy is set.
|
||||
direct: Agent,
|
||||
proxied: Option<Proxied>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a client whose default User-Agent is `user_agent`.
|
||||
pub fn new(user_agent: &str) -> Result<Self, Error> {
|
||||
let env = env_proxy();
|
||||
let system = if env.is_some() { None } else { system_proxy() };
|
||||
Self::build(user_agent, env, system)
|
||||
}
|
||||
|
||||
/// A client that ignores every proxy, for loopback tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn without_proxy(user_agent: &str) -> Self {
|
||||
Self::build(user_agent, None, None).expect("client without proxy")
|
||||
}
|
||||
|
||||
fn build(
|
||||
user_agent: &str,
|
||||
env_proxy: Option<Proxy>,
|
||||
system: Option<SystemProxy>,
|
||||
) -> Result<Self, Error> {
|
||||
let direct = make_agent(user_agent, env_proxy);
|
||||
let proxied = match system {
|
||||
Some(proxy) => match Proxy::new(&proxy.url) {
|
||||
// The system proxy keeps the old WinHTTP client's scope:
|
||||
// CONNECT proxies only, as `core::proxy` already parses it.
|
||||
Ok(p) if !is_connect_proxy(&p) => {
|
||||
log::warn!("system proxy ignored, only http and https proxies are supported");
|
||||
None
|
||||
}
|
||||
Ok(p) => Some(Proxied {
|
||||
agent: make_agent(user_agent, Some(p)),
|
||||
proxy,
|
||||
}),
|
||||
Err(err) => {
|
||||
// The proxy URL is not logged: it may carry credentials.
|
||||
log::warn!("system proxy ignored, it is not a valid proxy URI: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Ok(Self { direct, proxied })
|
||||
}
|
||||
|
||||
/// The system proxy this client routes through, if any. The updater
|
||||
/// reuses it so both paths reach the network the same way.
|
||||
pub fn system_proxy(&self) -> Option<&SystemProxy> {
|
||||
self.proxied.as_ref().map(|p| &p.proxy)
|
||||
}
|
||||
|
||||
pub fn get<'a>(&'a self, url: &str) -> RequestBuilder<'a> {
|
||||
RequestBuilder::new(self, http::Method::GET, url)
|
||||
}
|
||||
|
||||
pub fn post<'a>(&'a self, url: &str) -> RequestBuilder<'a> {
|
||||
RequestBuilder::new(self, http::Method::POST, url)
|
||||
}
|
||||
|
||||
/// The agent for `uri`: direct when the host matches a bypass entry or no
|
||||
/// system proxy is configured, proxied otherwise.
|
||||
fn agent_for(&self, uri: &Uri) -> &Agent {
|
||||
match &self.proxied {
|
||||
Some(p) if !uri.host().is_some_and(|h| p.proxy.bypasses(h)) => &p.agent,
|
||||
_ => &self.direct,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_agent(user_agent: &str, proxy: Option<Proxy>) -> Agent {
|
||||
let config = Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_connect(Some(CONNECT_TIMEOUT))
|
||||
.timeout_recv_response(Some(RECEIVE_TIMEOUT))
|
||||
.timeout_recv_body(Some(RECEIVE_TIMEOUT))
|
||||
.timeout_global(Some(GLOBAL_TIMEOUT))
|
||||
.user_agent(user_agent)
|
||||
.proxy(proxy)
|
||||
.tls_config(
|
||||
TlsConfig::builder()
|
||||
.root_certs(RootCerts::PlatformVerifier)
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
Agent::new_with_config(config)
|
||||
}
|
||||
|
||||
pub struct RequestBuilder<'a> {
|
||||
client: &'a Client,
|
||||
method: http::Method,
|
||||
url: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<'a> RequestBuilder<'a> {
|
||||
fn new(client: &'a Client, method: http::Method, url: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
method,
|
||||
url: url.to_string(),
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a header. A `User-Agent` header replaces the client default.
|
||||
pub fn header(mut self, name: &str, value: &str) -> Self {
|
||||
self.headers.push((name.to_string(), value.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn json_body<T: Serialize>(mut self, body: &T) -> Result<Self, Error> {
|
||||
self.body = Some(serde_json::to_vec(body)?);
|
||||
self.headers
|
||||
.push(("Content-Type".into(), "application/json".into()));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn send(self) -> Result<Response, Error> {
|
||||
let uri: Uri = self
|
||||
.url
|
||||
.parse()
|
||||
.map_err(|err| Error::Transport(format!("invalid URL: {err}")))?;
|
||||
let agent = self.client.agent_for(&uri);
|
||||
let mut builder = http::Request::builder().method(self.method).uri(uri);
|
||||
for (name, value) in &self.headers {
|
||||
builder = builder.header(name.as_str(), value.as_str());
|
||||
}
|
||||
let response = match self.body {
|
||||
Some(body) => agent.run(builder.body(body)?)?,
|
||||
None => agent.run(builder.body(())?)?,
|
||||
};
|
||||
let status = u32::from(response.status().as_u16());
|
||||
let headers = response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
(
|
||||
name.as_str().to_string(),
|
||||
String::from_utf8_lossy(value.as_bytes()).into_owned(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let body = response.into_body().read_to_vec()?;
|
||||
Ok(Response {
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Response {
|
||||
status: u32,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Build a response by hand, for tests of code that inspects responses.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_parts(status: u32, headers: &[(&str, &str)], body: &[u8]) -> Self {
|
||||
Self {
|
||||
status,
|
||||
headers: headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect(),
|
||||
body: body.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> u32 {
|
||||
self.status
|
||||
}
|
||||
|
||||
/// First header named `name`, compared case-insensitively.
|
||||
pub fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
pub fn body(&self) -> &[u8] {
|
||||
&self.body
|
||||
}
|
||||
|
||||
pub fn text(&self) -> Result<&str, Error> {
|
||||
std::str::from_utf8(&self.body).map_err(|_| Error::Utf8)
|
||||
}
|
||||
|
||||
pub fn json<T: DeserializeOwned>(&self) -> Result<T, Error> {
|
||||
Ok(serde_json::from_slice(&self.body)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::test_server::{serve_once, serve_socks5_once};
|
||||
use super::*;
|
||||
|
||||
fn direct_client() -> Client {
|
||||
Client::without_proxy(USER_AGENT)
|
||||
}
|
||||
|
||||
fn received(served: &test_server::Served) -> String {
|
||||
served
|
||||
.request
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("server saw the request")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_status_is_a_response_with_headers() {
|
||||
let served = serve_once(
|
||||
"429 Too Many Requests",
|
||||
&[("anthropic-ratelimit-unified-5h-utilization", "0.5")],
|
||||
b"{\"error\":\"rate_limited\"}".to_vec(),
|
||||
);
|
||||
let resp = direct_client()
|
||||
.get(&format!("{}/v1/messages", served.base))
|
||||
.send()
|
||||
.expect("429 is not a transport error");
|
||||
assert_eq!(resp.status(), 429);
|
||||
assert_eq!(
|
||||
resp.header("Anthropic-Ratelimit-Unified-5h-Utilization"),
|
||||
Some("0.5")
|
||||
);
|
||||
assert_eq!(resp.text().expect("utf8"), "{\"error\":\"rate_limited\"}");
|
||||
received(&served);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_default_user_agent() {
|
||||
let served = serve_once("200 OK", &[], Vec::new());
|
||||
direct_client().get(&served.base).send().expect("send");
|
||||
let request = received(&served).to_ascii_lowercase();
|
||||
let expected = format!("user-agent: {}\r\n", USER_AGENT.to_ascii_lowercase());
|
||||
assert!(request.contains(&expected), "{request}");
|
||||
assert!(USER_AGENT.starts_with("claude-code-usage-bubble/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_request_user_agent_replaces_default() {
|
||||
let served = serve_once("200 OK", &[], Vec::new());
|
||||
direct_client()
|
||||
.get(&served.base)
|
||||
.header("User-Agent", "codex-cli")
|
||||
.send()
|
||||
.expect("send");
|
||||
let request = received(&served).to_ascii_lowercase();
|
||||
assert!(request.contains("user-agent: codex-cli\r\n"), "{request}");
|
||||
assert_eq!(request.matches("user-agent:").count(), 1, "{request}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_body_is_posted_with_content_type() {
|
||||
let served = serve_once("200 OK", &[], b"{\"ok\":true}".to_vec());
|
||||
let resp = direct_client()
|
||||
.post(&served.base)
|
||||
.json_body(&serde_json::json!({"max_tokens": 1}))
|
||||
.and_then(RequestBuilder::send)
|
||||
.expect("send");
|
||||
let value: serde_json::Value = resp.json().expect("json");
|
||||
assert_eq!(value["ok"], true);
|
||||
let request = received(&served);
|
||||
assert!(request.starts_with("POST / HTTP/1.1\r\n"), "{request}");
|
||||
assert!(request
|
||||
.to_ascii_lowercase()
|
||||
.contains("content-type: application/json\r\n"));
|
||||
assert!(request.ends_with("{\"max_tokens\":1}"), "{request}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gzip_body_is_decompressed() {
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(b"plain text").expect("compress");
|
||||
let gz = encoder.finish().expect("finish");
|
||||
let served = serve_once("200 OK", &[("Content-Encoding", "gzip")], gz);
|
||||
let resp = direct_client().get(&served.base).send().expect("send");
|
||||
assert_eq!(resp.body(), b"plain text");
|
||||
let request = received(&served).to_ascii_lowercase();
|
||||
assert!(request.contains("accept-encoding: gzip"), "{request}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_failure_is_an_error() {
|
||||
let served = serve_once("200 OK", &[], Vec::new());
|
||||
let base = served.base.clone();
|
||||
// Free the port by completing the one request the server accepts.
|
||||
direct_client().get(&base).send().expect("send");
|
||||
received(&served);
|
||||
let err = direct_client()
|
||||
.get(&base)
|
||||
.send()
|
||||
.err()
|
||||
.expect("closed port fails");
|
||||
assert!(matches!(err, Error::Transport(_)), "{err}");
|
||||
assert!(matches!(
|
||||
direct_client().get("not a url").send(),
|
||||
Err(Error::Transport(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_proxy_is_used_unless_the_host_is_bypassed() {
|
||||
let proxy = serve_once("200 OK", &[], Vec::new());
|
||||
let origin = serve_once("200 OK", &[], b"direct".to_vec());
|
||||
let system = SystemProxy {
|
||||
url: proxy.base.clone(),
|
||||
bypass: Vec::new(),
|
||||
};
|
||||
let client = Client::build(USER_AGENT, None, Some(system.clone())).expect("client");
|
||||
assert_eq!(client.system_proxy(), Some(&system));
|
||||
// The fake proxy does not tunnel, so the request itself fails; what
|
||||
// matters is that it went to the proxy as a CONNECT for the origin.
|
||||
let _ = client.get(&format!("{}/x", origin.base)).send();
|
||||
let request = received(&proxy);
|
||||
let authority = origin.base.trim_start_matches("http://");
|
||||
assert!(
|
||||
request.starts_with(&format!("CONNECT {authority} HTTP/1.1")),
|
||||
"{request}"
|
||||
);
|
||||
|
||||
let bypassing = Client::build(
|
||||
USER_AGENT,
|
||||
None,
|
||||
Some(SystemProxy {
|
||||
url: proxy.base.clone(),
|
||||
bypass: vec!["127.0.0.*".into()],
|
||||
}),
|
||||
)
|
||||
.expect("client");
|
||||
let resp = bypassing.get(&origin.base).send().expect("send");
|
||||
assert_eq!(resp.body(), b"direct");
|
||||
received(&origin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_system_proxy_falls_back_to_direct() {
|
||||
let client = Client::build(
|
||||
USER_AGENT,
|
||||
None,
|
||||
Some(SystemProxy {
|
||||
url: "http://bad host:notaport".into(),
|
||||
bypass: Vec::new(),
|
||||
}),
|
||||
)
|
||||
.expect("client");
|
||||
assert_eq!(client.system_proxy(), None);
|
||||
}
|
||||
|
||||
fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let vars: Vec<(String, String)> = vars
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socks5h_env_proxy_tunnels_with_remote_resolution() {
|
||||
let (socks_port, seen) = serve_socks5_once(b"via socks");
|
||||
let http_proxy = serve_once("200 OK", &[], Vec::new());
|
||||
// Clash and v2ray set both; ureq's order puts ALL_PROXY first.
|
||||
let chosen = env_proxy_from(env(&[
|
||||
("all_proxy", &format!("socks5h://127.0.0.1:{socks_port}")),
|
||||
("https_proxy", &http_proxy.base),
|
||||
]))
|
||||
.expect("socks env proxy");
|
||||
assert_eq!(chosen.protocol(), ProxyProtocol::Socks5h);
|
||||
|
||||
let client = Client::build(USER_AGENT, Some(chosen), None).expect("client");
|
||||
// `socks5h` hands the host name to the proxy instead of resolving it,
|
||||
// so the name need not resolve to the fake proxy's own address.
|
||||
let resp = client
|
||||
.get("http://example.invalid:8080/x")
|
||||
.send()
|
||||
.expect("send through socks");
|
||||
assert_eq!(resp.body(), b"via socks");
|
||||
let request = seen
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("socks proxy saw the request");
|
||||
assert!(
|
||||
request.starts_with("5 example.invalid:8080\nGET /x HTTP/1.1\r\n"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(http_proxy
|
||||
.request
|
||||
.recv_timeout(Duration::from_millis(200))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_proxy_follows_ureq_order_and_skips_invalid_values() {
|
||||
let socks = env_proxy_from(env(&[("ALL_PROXY", "socks5://127.0.0.1:1080")]))
|
||||
.expect("socks env proxy");
|
||||
assert_eq!(socks.protocol(), ProxyProtocol::Socks5);
|
||||
let http = env_proxy_from(env(&[
|
||||
("ALL_PROXY", "bad host:x"),
|
||||
("http_proxy", "http://proxy.corp:3128"),
|
||||
]))
|
||||
.expect("http env proxy");
|
||||
assert_eq!(http.protocol(), ProxyProtocol::Http);
|
||||
assert_eq!(http.host(), "proxy.corp");
|
||||
assert!(env_proxy_from(env(&[("HTTPS_PROXY", "bad host:x")])).is_none());
|
||||
assert!(env_proxy_from(env(&[])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_proxy_keeps_credentials_and_no_proxy() {
|
||||
let chosen = env_proxy_from(env(&[
|
||||
("HTTPS_PROXY", "http://user:pa%40ss@proxy.corp:3128"),
|
||||
("NO_PROXY", "localhost, .internal"),
|
||||
]))
|
||||
.expect("env proxy");
|
||||
assert_eq!(chosen.host(), "proxy.corp");
|
||||
assert_eq!(chosen.port(), 3128);
|
||||
assert_eq!(chosen.username(), Some("user"));
|
||||
assert_eq!(chosen.password(), Some("pa%40ss"));
|
||||
let uri = |s: &str| s.parse::<Uri>().expect("uri");
|
||||
assert!(chosen.is_no_proxy(&uri("http://localhost/")));
|
||||
assert!(chosen.is_no_proxy(&uri("https://api.internal/")));
|
||||
assert!(!chosen.is_no_proxy(&uri("https://api.anthropic.com/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socks_system_proxy_falls_back_to_direct() {
|
||||
let client = Client::build(
|
||||
USER_AGENT,
|
||||
None,
|
||||
Some(SystemProxy {
|
||||
url: "socks5://127.0.0.1:1080".into(),
|
||||
bypass: Vec::new(),
|
||||
}),
|
||||
)
|
||||
.expect("client");
|
||||
assert_eq!(client.system_proxy(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_server {
|
||||
//! A one-shot HTTP/1.1 server on loopback that returns a canned reply and
|
||||
//! hands the raw request back to the test.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
pub struct Served {
|
||||
/// `http://127.0.0.1:<port>`.
|
||||
pub base: String,
|
||||
/// Raw request head (request line plus headers), then the body.
|
||||
pub request: mpsc::Receiver<String>,
|
||||
}
|
||||
|
||||
/// Serve one request with `status`, `headers` and `body`.
|
||||
pub fn serve_once(status: &str, headers: &[(&str, &str)], body: Vec<u8>) -> Served {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
|
||||
let port = listener.local_addr().expect("local addr").port();
|
||||
let mut head = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n", body.len());
|
||||
for (k, v) in headers {
|
||||
head.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let Ok((stream, _)) = listener.accept() else {
|
||||
return;
|
||||
};
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut request = String::new();
|
||||
let mut content_length = 0usize;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
||||
break;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
if k.eq_ignore_ascii_case("content-length") {
|
||||
content_length = v.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
request.push_str(&line);
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut request_body = vec![0u8; content_length];
|
||||
let _ = reader.read_exact(&mut request_body);
|
||||
request.push_str(&String::from_utf8_lossy(&request_body));
|
||||
let mut stream = reader.into_inner();
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
let _ = stream.write_all(&body);
|
||||
let _ = tx.send(request);
|
||||
});
|
||||
Served {
|
||||
base: format!("http://127.0.0.1:{port}"),
|
||||
request: rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-shot SOCKS5 proxy that accepts the handshake, reports the CONNECT
|
||||
/// target it was asked for, then answers the tunnelled request itself with
|
||||
/// `200 OK` and `body`.
|
||||
pub fn serve_socks5_once(body: &'static [u8]) -> (u16, mpsc::Receiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
|
||||
let port = listener.local_addr().expect("local addr").port();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let Ok((mut stream, _)) = listener.accept() else {
|
||||
return;
|
||||
};
|
||||
// A broken handshake must fail the test, not hang it.
|
||||
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10)));
|
||||
let byte = |stream: &mut std::net::TcpStream, n: usize| {
|
||||
let mut buf = vec![0u8; n];
|
||||
stream.read_exact(&mut buf).map(|_| buf)
|
||||
};
|
||||
// Greeting: version 5, then the offered auth methods.
|
||||
let Ok(head) = byte(&mut stream, 2) else {
|
||||
return;
|
||||
};
|
||||
let _ = byte(&mut stream, usize::from(head[1]));
|
||||
if stream.write_all(&[5, 0]).is_err() {
|
||||
return;
|
||||
}
|
||||
// Request: version, CONNECT, reserved, address type.
|
||||
let Ok(req) = byte(&mut stream, 4) else {
|
||||
return;
|
||||
};
|
||||
let target = match req[3] {
|
||||
3 => {
|
||||
let len = byte(&mut stream, 1).map(|b| usize::from(b[0])).unwrap_or(0);
|
||||
String::from_utf8_lossy(&byte(&mut stream, len).unwrap_or_default())
|
||||
.into_owned()
|
||||
}
|
||||
1 => byte(&mut stream, 4)
|
||||
.map(|a| std::net::Ipv4Addr::new(a[0], a[1], a[2], a[3]).to_string())
|
||||
.unwrap_or_default(),
|
||||
other => format!("address type {other}"),
|
||||
};
|
||||
let port = byte(&mut stream, 2)
|
||||
.map(|p| u16::from_be_bytes([p[0], p[1]]))
|
||||
.unwrap_or(0);
|
||||
let _ = stream.write_all(&[5, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
|
||||
// Read the tunnelled request head, then answer it.
|
||||
let mut seen = Vec::new();
|
||||
let mut buf = [0u8; 1024];
|
||||
while !seen.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
match stream.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => seen.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
}
|
||||
let reply = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(reply.as_bytes());
|
||||
let _ = stream.write_all(body);
|
||||
let _ = tx.send(format!(
|
||||
"{} {target}:{port}\n{}",
|
||||
req[0],
|
||||
String::from_utf8_lossy(&seen)
|
||||
));
|
||||
});
|
||||
(port, rx)
|
||||
}
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
// Minimal blocking HTTP client built on Win32 WinHTTP.
|
||||
//
|
||||
// One `Client` owns a session handle and is thread-safe (WinHTTP sessions
|
||||
// can be used from multiple threads per MSDN). Each `send()` call manages
|
||||
// its own connection + request handle lifetime via RAII guards so failures
|
||||
// at any point clean up correctly.
|
||||
//
|
||||
// We deliberately do NOT use `WinHttpCrackUrl` — the small `parse_url`
|
||||
// helper below is enough for the HTTPS URLs this app actually talks to and
|
||||
// keeps the call sites simpler.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::ptr::null_mut;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Networking::WinHttp::*;
|
||||
|
||||
use crate::os::string::to_utf16_nul;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("WinHTTP call failed: {context}")]
|
||||
Win { context: String },
|
||||
#[error("invalid URL: {0}")]
|
||||
Url(String),
|
||||
#[error("response was not valid UTF-8")]
|
||||
Utf8,
|
||||
#[error("JSON parse: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("response status {0}")]
|
||||
Status(u32),
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
session: SessionHandle,
|
||||
}
|
||||
|
||||
// WinHTTP session handles are safe to use concurrently per the Microsoft docs
|
||||
// (https://learn.microsoft.com/en-us/windows/win32/winhttp/winhttp-functions).
|
||||
unsafe impl Send for Client {}
|
||||
unsafe impl Sync for Client {}
|
||||
|
||||
impl Client {
|
||||
/// Create a new HTTP client. `user_agent` is sent on every request.
|
||||
pub fn new(user_agent: &str) -> Result<Self, Error> {
|
||||
let ua = to_utf16_nul(user_agent);
|
||||
let session = unsafe {
|
||||
WinHttpOpen(
|
||||
PCWSTR::from_raw(ua.as_ptr()),
|
||||
WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
|
||||
PCWSTR::null(),
|
||||
PCWSTR::null(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if session.is_null() {
|
||||
return Err(Error::Win {
|
||||
context: "WinHttpOpen".into(),
|
||||
});
|
||||
}
|
||||
// Ask WinHTTP to decompress gzip/deflate transparently so callers
|
||||
// get plain bytes back from `Response::body()`. Best-effort; if it
|
||||
// fails the request still works, callers just see raw compressed
|
||||
// bytes on responses that opt-in to compression.
|
||||
unsafe {
|
||||
let flags: u32 = WINHTTP_DECOMPRESSION_FLAG_GZIP | WINHTTP_DECOMPRESSION_FLAG_DEFLATE;
|
||||
let flag_bytes = flags.to_ne_bytes();
|
||||
if let Err(e) = WinHttpSetOption(
|
||||
Some(session as *const c_void),
|
||||
WINHTTP_OPTION_DECOMPRESSION,
|
||||
Some(&flag_bytes),
|
||||
) {
|
||||
log::warn!("WinHttpSetOption(DECOMPRESSION) failed: {e}");
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
session: SessionHandle(session),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get<'a>(&'a self, url: &str) -> RequestBuilder<'a> {
|
||||
RequestBuilder::new(self, Method::Get, url)
|
||||
}
|
||||
|
||||
pub fn post<'a>(&'a self, url: &str) -> RequestBuilder<'a> {
|
||||
RequestBuilder::new(self, Method::Post, url)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Method {
|
||||
Get,
|
||||
Post,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
fn verb(self) -> &'static str {
|
||||
match self {
|
||||
Method::Get => "GET",
|
||||
Method::Post => "POST",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RequestBuilder<'a> {
|
||||
client: &'a Client,
|
||||
method: Method,
|
||||
url: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<'a> RequestBuilder<'a> {
|
||||
fn new(client: &'a Client, method: Method, url: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
method,
|
||||
url: url.to_string(),
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header(mut self, name: &str, value: &str) -> Self {
|
||||
self.headers.push((name.to_string(), value.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn json_body<T: Serialize>(mut self, body: &T) -> Result<Self, Error> {
|
||||
self.body = Some(serde_json::to_vec(body)?);
|
||||
self.headers
|
||||
.push(("Content-Type".into(), "application/json".into()));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn send(self) -> Result<Response, Error> {
|
||||
let parsed = parse_url(&self.url)?;
|
||||
let host_w = to_utf16_nul(&parsed.host);
|
||||
let path_w = to_utf16_nul(&parsed.path);
|
||||
let verb_w = to_utf16_nul(self.method.verb());
|
||||
|
||||
let connect = unsafe {
|
||||
WinHttpConnect(
|
||||
self.client.session.0,
|
||||
PCWSTR::from_raw(host_w.as_ptr()),
|
||||
parsed.port,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if connect.is_null() {
|
||||
return Err(Error::Win {
|
||||
context: "WinHttpConnect".into(),
|
||||
});
|
||||
}
|
||||
let _connect_guard = HandleGuard(connect);
|
||||
|
||||
let flags = if parsed.secure {
|
||||
WINHTTP_FLAG_SECURE
|
||||
} else {
|
||||
WINHTTP_OPEN_REQUEST_FLAGS(0)
|
||||
};
|
||||
let request = unsafe {
|
||||
WinHttpOpenRequest(
|
||||
connect,
|
||||
PCWSTR::from_raw(verb_w.as_ptr()),
|
||||
PCWSTR::from_raw(path_w.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
PCWSTR::null(),
|
||||
std::ptr::null::<PCWSTR>(),
|
||||
flags,
|
||||
)
|
||||
};
|
||||
if request.is_null() {
|
||||
return Err(Error::Win {
|
||||
context: "WinHttpOpenRequest".into(),
|
||||
});
|
||||
}
|
||||
let _request_guard = HandleGuard(request);
|
||||
|
||||
// Combine headers into a single CRLF-separated string. The binding
|
||||
// takes a UTF-16 slice; length is derived from the slice and no
|
||||
// trailing NUL is required.
|
||||
if !self.headers.is_empty() {
|
||||
let header_str = self
|
||||
.headers
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}: {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n");
|
||||
let header_w: Vec<u16> = header_str.encode_utf16().collect();
|
||||
unsafe {
|
||||
WinHttpAddRequestHeaders(
|
||||
request,
|
||||
&header_w[..],
|
||||
WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE,
|
||||
)
|
||||
}
|
||||
.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpAddRequestHeaders: {e}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
// Send body if present. dwTotalLength = body length; dwOptionalLength
|
||||
// mirrors it for synchronous sends with the buffer included up front.
|
||||
let body_bytes: &[u8] = self.body.as_deref().unwrap_or(&[]);
|
||||
let body_ptr = if body_bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(body_bytes.as_ptr() as *const c_void)
|
||||
};
|
||||
let body_len = body_bytes.len() as u32;
|
||||
unsafe {
|
||||
WinHttpSendRequest(request, None, body_ptr, body_len, body_len, 0)
|
||||
}
|
||||
.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpSendRequest: {e}"),
|
||||
})?;
|
||||
|
||||
unsafe { WinHttpReceiveResponse(request, null_mut()) }.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpReceiveResponse: {e}"),
|
||||
})?;
|
||||
|
||||
let status = query_status_code(request)?;
|
||||
let headers = query_raw_headers(request)?;
|
||||
let body = read_body(request)?;
|
||||
|
||||
Ok(Response {
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Response {
|
||||
status: u32,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn status(&self) -> u32 {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
pub fn body(&self) -> &[u8] {
|
||||
&self.body
|
||||
}
|
||||
|
||||
pub fn text(&self) -> Result<&str, Error> {
|
||||
std::str::from_utf8(&self.body).map_err(|_| Error::Utf8)
|
||||
}
|
||||
|
||||
pub fn json<T: DeserializeOwned>(&self) -> Result<T, Error> {
|
||||
Ok(serde_json::from_slice(&self.body)?)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Low-level helpers ----------
|
||||
|
||||
struct SessionHandle(*mut c_void);
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe {
|
||||
let _ = WinHttpCloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HandleGuard(*mut c_void);
|
||||
|
||||
impl Drop for HandleGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe {
|
||||
let _ = WinHttpCloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query_status_code(request: *mut c_void) -> Result<u32, Error> {
|
||||
let mut status: u32 = 0;
|
||||
let mut size: u32 = std::mem::size_of::<u32>() as u32;
|
||||
unsafe {
|
||||
WinHttpQueryHeaders(
|
||||
request,
|
||||
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||
PCWSTR::null(),
|
||||
Some((&mut status as *mut u32) as *mut c_void),
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
}
|
||||
.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpQueryHeaders(STATUS_CODE): {e}"),
|
||||
})?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn query_raw_headers(request: *mut c_void) -> Result<Vec<(String, String)>, Error> {
|
||||
// First call sizes the buffer (returns Err with ERROR_INSUFFICIENT_BUFFER
|
||||
// and writes the required byte count to `needed`).
|
||||
let mut needed: u32 = 0;
|
||||
let _ = unsafe {
|
||||
WinHttpQueryHeaders(
|
||||
request,
|
||||
WINHTTP_QUERY_RAW_HEADERS_CRLF,
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
&mut needed,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if needed == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let chars = (needed as usize) / std::mem::size_of::<u16>();
|
||||
let mut buf: Vec<u16> = vec![0u16; chars];
|
||||
unsafe {
|
||||
WinHttpQueryHeaders(
|
||||
request,
|
||||
WINHTTP_QUERY_RAW_HEADERS_CRLF,
|
||||
PCWSTR::null(),
|
||||
Some(buf.as_mut_ptr() as *mut c_void),
|
||||
&mut needed,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
}
|
||||
.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpQueryHeaders(RAW_HEADERS_CRLF): {e}"),
|
||||
})?;
|
||||
let text = String::from_utf16_lossy(&buf[..chars.saturating_sub(1)]);
|
||||
Ok(parse_header_block(&text))
|
||||
}
|
||||
|
||||
fn parse_header_block(block: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
let mut lines = block.split("\r\n");
|
||||
let _ = lines.next(); // status line, e.g. "HTTP/1.1 200 OK"
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
out.push((k.trim().to_string(), v.trim().to_string()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_body(request: *mut c_void) -> Result<Vec<u8>, Error> {
|
||||
let mut body = Vec::new();
|
||||
loop {
|
||||
let mut available: u32 = 0;
|
||||
unsafe { WinHttpQueryDataAvailable(request, &mut available) }.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpQueryDataAvailable: {e}"),
|
||||
})?;
|
||||
if available == 0 {
|
||||
break;
|
||||
}
|
||||
let mut chunk = vec![0u8; available as usize];
|
||||
let mut read: u32 = 0;
|
||||
unsafe {
|
||||
WinHttpReadData(
|
||||
request,
|
||||
chunk.as_mut_ptr() as *mut c_void,
|
||||
available,
|
||||
&mut read,
|
||||
)
|
||||
}
|
||||
.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpReadData: {e}"),
|
||||
})?;
|
||||
chunk.truncate(read as usize);
|
||||
body.append(&mut chunk);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
// ---------- URL parsing ----------
|
||||
|
||||
struct ParsedUrl {
|
||||
host: String,
|
||||
port: u16,
|
||||
path: String,
|
||||
secure: bool,
|
||||
}
|
||||
|
||||
fn parse_url(url: &str) -> Result<ParsedUrl, Error> {
|
||||
let (scheme, rest) = url
|
||||
.split_once("://")
|
||||
.ok_or_else(|| Error::Url(url.to_string()))?;
|
||||
let secure = match scheme.to_ascii_lowercase().as_str() {
|
||||
"https" => true,
|
||||
"http" => false,
|
||||
other => return Err(Error::Url(format!("unsupported scheme: {other}"))),
|
||||
};
|
||||
let (host_port, path) = match rest.find('/') {
|
||||
Some(i) => (&rest[..i], &rest[i..]),
|
||||
None => (rest, "/"),
|
||||
};
|
||||
let (host, port) = match host_port.rsplit_once(':') {
|
||||
Some((h, p)) => (
|
||||
h.to_string(),
|
||||
p.parse::<u16>().map_err(|_| Error::Url(url.to_string()))?,
|
||||
),
|
||||
None => (host_port.to_string(), if secure { 443 } else { 80 }),
|
||||
};
|
||||
if host.is_empty() {
|
||||
return Err(Error::Url(url.to_string()));
|
||||
}
|
||||
Ok(ParsedUrl {
|
||||
host,
|
||||
port,
|
||||
path: path.to_string(),
|
||||
secure,
|
||||
})
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Color helpers for GDI.
|
||||
//
|
||||
// Win32 GDI stores colours as a 32-bit COLORREF in 0x00BBGGRR byte order
|
||||
// (B in the low byte). We keep a normal `r,g,b` struct in code and convert
|
||||
// at the FFI boundary.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Rgb {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
|
||||
/// Parse `#RRGGBB` or `RRGGBB`. Panics on malformed input — meant for
|
||||
/// hardcoded design-system literals at compile-known call sites.
|
||||
pub fn from_hex(hex: &str) -> Self {
|
||||
Self::parse_hex(hex).unwrap_or_else(|| panic!("invalid hex color literal: {hex:?}"))
|
||||
}
|
||||
|
||||
/// Parse `#RRGGBB` or `RRGGBB`. Returns `None` on malformed input.
|
||||
pub fn parse_hex(hex: &str) -> Option<Self> {
|
||||
let s = hex.trim_start_matches('#');
|
||||
if s.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
|
||||
Some(Self { r, g, b })
|
||||
}
|
||||
|
||||
/// Pack into a Win32 COLORREF (`0x00BBGGRR`).
|
||||
pub fn into_colorref(self) -> u32 {
|
||||
(self.r as u32) | ((self.g as u32) << 8) | ((self.b as u32) << 16)
|
||||
}
|
||||
|
||||
/// Linear interpolation between two colours. `t` is clamped to `[0, 1]`.
|
||||
pub fn lerp(self, other: Rgb, t: f64) -> Rgb {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
|
||||
Rgb {
|
||||
r: mix(self.r, other.r),
|
||||
g: mix(self.g, other.g),
|
||||
b: mix(self.b, other.b),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Per-window DPI helpers.
|
||||
//
|
||||
// The DPI of a window can differ from `GetDpiForSystem` on multi-monitor
|
||||
// setups where the app is per-monitor DPI aware. Always prefer
|
||||
// `GetDpiForWindow` for HWNDs that participate in the message loop.
|
||||
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::HiDpi::{GetDpiForSystem, GetDpiForWindow};
|
||||
|
||||
/// The default DPI Win32 reports for 100% scaling.
|
||||
pub const BASE_DPI: u32 = 96;
|
||||
|
||||
/// DPI for the supplied window. Falls back to system DPI if the call fails.
|
||||
pub fn for_window(hwnd: HWND) -> u32 {
|
||||
let raw = unsafe { GetDpiForWindow(hwnd) };
|
||||
if raw == 0 {
|
||||
for_system()
|
||||
} else {
|
||||
raw.max(BASE_DPI)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global system DPI. Cheap; safe to call from any thread.
|
||||
pub fn for_system() -> u32 {
|
||||
unsafe { GetDpiForSystem() }.max(BASE_DPI)
|
||||
}
|
||||
|
||||
/// Scale a logical (96-DPI) pixel measurement to the given DPI.
|
||||
pub fn scale(logical_px: i32, dpi: u32) -> i32 {
|
||||
((logical_px as i64) * (dpi as i64) / (BASE_DPI as i64)) as i32
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// `os` namespace: thin, typed wrappers over the slice of Win32 we use.
|
||||
//
|
||||
// Each submodule covers one concern (color conversion, UTF-16 strings,
|
||||
// DPI math, registry I/O, theme detection). Nothing in here knows about
|
||||
// the bubble UI or the polling loop — it's pure platform glue.
|
||||
|
||||
pub mod color;
|
||||
pub mod dpi;
|
||||
pub mod registry;
|
||||
pub mod string;
|
||||
pub mod theme;
|
||||
|
||||
pub use color::Rgb;
|
||||
pub use string::to_utf16_nul;
|
||||
@@ -1,159 +0,0 @@
|
||||
// Typed wrapper over a tiny subset of the Win32 registry API.
|
||||
//
|
||||
// All operations target `HKEY_CURRENT_USER` keys by default (the app only
|
||||
// reads/writes user-scoped state — startup entry, theme detection, etc.).
|
||||
// Each call opens and closes the key internally; there is no caching so
|
||||
// state changes by other processes are visible immediately.
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows::Win32::System::Registry::{
|
||||
RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, HKEY,
|
||||
HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_SZ,
|
||||
};
|
||||
|
||||
use super::string::to_utf16_nul;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RegistryError {
|
||||
#[error("registry open failed for {key}: code {code}")]
|
||||
Open { key: String, code: u32 },
|
||||
#[error("registry write failed for {key}\\{value}: code {code}")]
|
||||
Write { key: String, value: String, code: u32 },
|
||||
}
|
||||
|
||||
/// Read a `REG_DWORD` value under `HKEY_CURRENT_USER\<subkey>`.
|
||||
/// Returns `None` if the key or value does not exist.
|
||||
pub fn read_u32(subkey: &str, value_name: &str) -> Option<u32> {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let mut data: u32 = 0;
|
||||
let mut size: u32 = std::mem::size_of::<u32>() as u32;
|
||||
let query = RegQueryValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(value_w.as_ptr()),
|
||||
None,
|
||||
None,
|
||||
Some((&mut data as *mut u32) as *mut u8),
|
||||
Some(&mut size),
|
||||
);
|
||||
let _ = RegCloseKey(hkey);
|
||||
if query == ERROR_SUCCESS {
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test whether a value (any type) exists under `HKEY_CURRENT_USER\<subkey>`.
|
||||
pub fn value_exists(subkey: &str, value_name: &str) -> bool {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return false;
|
||||
}
|
||||
let mut size: u32 = 0;
|
||||
let query = RegQueryValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(value_w.as_ptr()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&mut size),
|
||||
);
|
||||
let _ = RegCloseKey(hkey);
|
||||
query == ERROR_SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a string value as `REG_SZ` under `HKEY_CURRENT_USER\<subkey>`.
|
||||
pub fn write_string(subkey: &str, value_name: &str, value: &str) -> Result<(), RegistryError> {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
let data_w = to_utf16_nul(value);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return Err(RegistryError::Open {
|
||||
key: subkey.to_string(),
|
||||
code: open.0,
|
||||
});
|
||||
}
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
data_w.as_ptr() as *const u8,
|
||||
data_w.len() * std::mem::size_of::<u16>(),
|
||||
);
|
||||
let res = RegSetValueExW(
|
||||
hkey,
|
||||
PCWSTR::from_raw(value_w.as_ptr()),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(bytes),
|
||||
);
|
||||
let _ = RegCloseKey(hkey);
|
||||
if res == ERROR_SUCCESS {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RegistryError::Write {
|
||||
key: subkey.to_string(),
|
||||
value: value_name.to_string(),
|
||||
code: res.0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a value under `HKEY_CURRENT_USER\<subkey>`. Returns `Ok(())` even
|
||||
/// if the value never existed.
|
||||
pub fn delete_value(subkey: &str, value_name: &str) -> Result<(), RegistryError> {
|
||||
let subkey_w = to_utf16_nul(subkey);
|
||||
let value_w = to_utf16_nul(value_name);
|
||||
unsafe {
|
||||
let mut hkey = HKEY::default();
|
||||
let open = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR::from_raw(subkey_w.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey,
|
||||
);
|
||||
if open != ERROR_SUCCESS {
|
||||
return Err(RegistryError::Open {
|
||||
key: subkey.to_string(),
|
||||
code: open.0,
|
||||
});
|
||||
}
|
||||
let _ = RegDeleteValueW(hkey, PCWSTR::from_raw(value_w.as_ptr()));
|
||||
let _ = RegCloseKey(hkey);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// UTF-16 conversion helpers.
|
||||
|
||||
/// Encode a Rust `&str` as a NUL-terminated UTF-16 vector suitable for
|
||||
/// passing to Win32 `PCWSTR`-typed parameters.
|
||||
///
|
||||
/// The result lives as long as the returned `Vec<u16>`; callers must keep
|
||||
/// the vector alive across the FFI call.
|
||||
pub fn to_utf16_nul(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// Windows light/dark theme detection.
|
||||
//
|
||||
// Windows stores the current theme under
|
||||
// `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`
|
||||
// with a `SystemUsesLightTheme` DWORD: 1 means light, 0 means dark.
|
||||
|
||||
use super::registry;
|
||||
|
||||
const THEME_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
const LIGHT_VALUE: &str = "SystemUsesLightTheme";
|
||||
|
||||
/// `true` if the system is in dark mode. Defaults to dark when the registry
|
||||
/// value is missing (matches Windows 11 first-boot behaviour).
|
||||
pub fn is_dark() -> bool {
|
||||
!matches!(registry::read_u32(THEME_KEY, LIGHT_VALUE), Some(1))
|
||||
}
|
||||
+447
-497
@@ -1,542 +1,492 @@
|
||||
// Expanded panel shown when a bubble is left-clicked.
|
||||
//
|
||||
// Plain opaque top-most popup with two horizontal usage bars (session/5h and
|
||||
// weekly/7d) plus countdown text. Closes on focus loss.
|
||||
//! The details panel: one opaque, undecorated, always-on-top webview
|
||||
//! labelled `panel`, created hidden at startup and then only shown and
|
||||
//! hidden, never destroyed.
|
||||
//!
|
||||
//! A bubble click toggles it for that bubble's provider, placed below the
|
||||
//! bubble (or above it near the bottom edge). Losing focus hides it. While
|
||||
//! it is open, every state push refreshes it in place.
|
||||
//!
|
||||
//! The page (`ui/panel.js`) only draws the [`PanelPayload`]. Every Tauri
|
||||
//! call here runs on the main thread, which also serialises a toggle
|
||||
//! against a refresh, and no function holds a lock across a Tauri call.
|
||||
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Dwm::{
|
||||
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
|
||||
use tauri::window::Color;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
|
||||
WebviewWindowBuilder, Window, WindowEvent,
|
||||
};
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
use crate::i18n::LocaleStrings;
|
||||
use crate::os::dpi::scale as scale_to_dpi;
|
||||
use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
|
||||
use crate::core::geom::{self, Point, Rect};
|
||||
use crate::core::payload::PanelPayload;
|
||||
use crate::platform;
|
||||
use crate::runtime::{self, Runtime};
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
|
||||
/// Window label of the panel.
|
||||
pub const PANEL_LABEL: &str = "panel";
|
||||
|
||||
/// Event carrying a [`PanelPayload`] to the panel webview.
|
||||
pub const PANEL_UPDATE_EVENT: &str = "panel:update";
|
||||
|
||||
const TITLE: &str = "Usage panel";
|
||||
const PANEL_W_LOGICAL: i32 = 280;
|
||||
const PANEL_H_LOGICAL: i32 = 120;
|
||||
const PADDING_LOGICAL: i32 = 14;
|
||||
const ROW_GAP_LOGICAL: i32 = 8;
|
||||
const LABEL_W_LOGICAL: i32 = 28;
|
||||
const RIGHT_TEXT_W_LOGICAL: i32 = 96;
|
||||
const BAR_HEIGHT_LOGICAL: i32 = 14;
|
||||
|
||||
pub struct PanelData {
|
||||
pub model: ProviderId,
|
||||
pub session_pct: f64,
|
||||
pub session_text: String,
|
||||
pub weekly_pct: f64,
|
||||
pub weekly_text: String,
|
||||
pub is_dark: bool,
|
||||
pub strings: LocaleStrings,
|
||||
}
|
||||
/// A click that reopens the panel this soon after focus loss hid it, for
|
||||
/// the same provider, is the click that caused the focus loss: it closes
|
||||
/// the panel instead of reopening it.
|
||||
const BLUR_REOPEN_GUARD: Duration = Duration::from_millis(150);
|
||||
|
||||
/// Panel backgrounds, matching `ui/panel.css`, so a show never flashes white.
|
||||
const DARK_BG: Color = Color(0x1F, 0x1F, 0x1F, 0xFF);
|
||||
const LIGHT_BG: Color = Color(0xFA, 0xFA, 0xFA, 0xFF);
|
||||
|
||||
/// What the panel shows. Only the main thread changes it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct PanelState {
|
||||
hwnd: HWND,
|
||||
data: PanelData,
|
||||
/// The provider on screen, `None` while the panel is hidden.
|
||||
shown: Option<ProviderId>,
|
||||
/// The provider last shown, so a reloaded page can ask for its data.
|
||||
provider: Option<ProviderId>,
|
||||
/// The provider and time of the last hide caused by focus loss.
|
||||
blur_hide: Option<(ProviderId, Instant)>,
|
||||
}
|
||||
|
||||
// HWND wraps `*mut c_void` which is `!Send`, but the panel state is only ever
|
||||
// accessed from the UI thread — the Mutex exists to satisfy the `OnceLock`
|
||||
// static contract, not because of real cross-thread sharing.
|
||||
unsafe impl Send for PanelState {}
|
||||
static STATE: Mutex<PanelState> = Mutex::new(PanelState {
|
||||
shown: None,
|
||||
provider: None,
|
||||
blur_hide: None,
|
||||
});
|
||||
|
||||
fn state() -> &'static Mutex<Option<PanelState>> {
|
||||
static S: OnceLock<Mutex<Option<PanelState>>> = OnceLock::new();
|
||||
S.get_or_init(|| Mutex::new(None))
|
||||
fn with_state<T>(f: impl FnOnce(&mut PanelState) -> T) -> T {
|
||||
let mut guard = STATE.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
f(&mut guard)
|
||||
}
|
||||
|
||||
fn lock_state() -> MutexGuard<'static, Option<PanelState>> {
|
||||
state().lock().expect("panel state mutex poisoned")
|
||||
/// What a bubble click does to the panel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Toggle {
|
||||
Show,
|
||||
Hide,
|
||||
/// The click already closed the panel through focus loss.
|
||||
Ignore,
|
||||
}
|
||||
|
||||
pub fn register_class() {
|
||||
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||||
REGISTERED.get_or_init(|| unsafe {
|
||||
let class_w = wide_str(CLASS_NAME);
|
||||
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
|
||||
let wc = WNDCLASSEXW {
|
||||
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(wnd_proc),
|
||||
hInstance: HINSTANCE(hinstance.0),
|
||||
hCursor: LoadCursorW(HINSTANCE::default(), IDC_ARROW).unwrap_or_default(),
|
||||
hbrBackground: HBRUSH(std::ptr::null_mut()),
|
||||
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
|
||||
..Default::default()
|
||||
};
|
||||
if RegisterClassExW(&wc) == 0 {
|
||||
log::error!("panel RegisterClassExW returned 0");
|
||||
/// Decide a click on `provider`'s bubble and record the result in `state`.
|
||||
fn toggle_state(state: &mut PanelState, provider: ProviderId, now: Instant) -> Toggle {
|
||||
if state.shown == Some(provider) {
|
||||
state.shown = None;
|
||||
return Toggle::Hide;
|
||||
}
|
||||
let just_blurred = state.shown.is_none()
|
||||
&& state.blur_hide.is_some_and(|(p, at)| {
|
||||
p == provider && now.saturating_duration_since(at) < BLUR_REOPEN_GUARD
|
||||
});
|
||||
// Either way the guard is spent, so a later click opens the panel.
|
||||
state.blur_hide = None;
|
||||
if just_blurred {
|
||||
return Toggle::Ignore;
|
||||
}
|
||||
state.shown = Some(provider);
|
||||
state.provider = Some(provider);
|
||||
Toggle::Show
|
||||
}
|
||||
|
||||
/// Record a hide caused by focus loss. Returns whether the panel was shown.
|
||||
fn blur_state(state: &mut PanelState, now: Instant) -> bool {
|
||||
match state.shown.take() {
|
||||
Some(provider) => {
|
||||
state.blur_hide = Some((provider, now));
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider on screen, `None` while the panel is hidden.
|
||||
pub fn shown_provider() -> Option<ProviderId> {
|
||||
with_state(|s| s.shown)
|
||||
}
|
||||
|
||||
/// The provider the panel last showed, hidden or not.
|
||||
pub fn current_provider() -> Option<ProviderId> {
|
||||
with_state(|s| s.provider)
|
||||
}
|
||||
|
||||
fn runtime_of(app: &AppHandle) -> Option<Arc<Runtime>> {
|
||||
app.try_state::<Arc<Runtime>>().map(|r| Arc::clone(&r))
|
||||
}
|
||||
|
||||
fn background(is_dark: bool) -> Color {
|
||||
if is_dark {
|
||||
DARK_BG
|
||||
} else {
|
||||
LIGHT_BG
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the hidden panel window. Called once at startup.
|
||||
pub fn create(app: &AppHandle, is_dark: bool) -> tauri::Result<()> {
|
||||
let builder = WebviewWindowBuilder::new(app, PANEL_LABEL, WebviewUrl::App("panel.html".into()))
|
||||
.title(TITLE)
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.transparent(false)
|
||||
.shadow(false)
|
||||
.visible(false)
|
||||
.background_color(background(is_dark))
|
||||
.inner_size(f64::from(PANEL_W_LOGICAL), f64::from(PANEL_H_LOGICAL));
|
||||
#[cfg(windows)]
|
||||
let builder = builder.on_page_load(disable_browser_keys_after_first_load());
|
||||
let window = builder.build()?;
|
||||
prepare_native(app, &window);
|
||||
log::info!("{PANEL_LABEL} created");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: a page-load hook that turns off the webview's browser keys
|
||||
/// once `panel.html` has finished its first load. The setting only applies
|
||||
/// from the next navigation, so it comes with a reload; waiting for the
|
||||
/// first load keeps that reload from racing the initial navigation, and the
|
||||
/// flag keeps the reload's own load from repeating it. The work leaves the
|
||||
/// navigation callback through another thread, which queues it on the main
|
||||
/// thread for after the callback returns.
|
||||
#[cfg(windows)]
|
||||
fn disable_browser_keys_after_first_load(
|
||||
) -> impl Fn(WebviewWindow, tauri::webview::PageLoadPayload<'_>) + Send + Sync + 'static {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::webview::PageLoadEvent;
|
||||
|
||||
let done = AtomicBool::new(false);
|
||||
move |window, payload| {
|
||||
if payload.event() != PageLoadEvent::Finished
|
||||
|| payload.url().scheme() == "about"
|
||||
|| done.swap(true, Ordering::SeqCst)
|
||||
{
|
||||
return;
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = platform::windows::disable_browser_accelerator_keys(&window) {
|
||||
log::warn!("{PANEL_LABEL} keeps the browser accelerator keys: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: drop the panel from Alt-Tab, on the window's own (main)
|
||||
/// thread, before the first show.
|
||||
#[cfg(windows)]
|
||||
fn prepare_native(app: &AppHandle, window: &WebviewWindow) {
|
||||
let window = window.clone();
|
||||
let queued = app.run_on_main_thread(move || {
|
||||
if let Err(err) = platform::windows::apply_toolwindow(&window) {
|
||||
log::warn!("{PANEL_LABEL} may show in Alt-Tab: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn is_visible() -> bool {
|
||||
let s = lock_state();
|
||||
s.as_ref()
|
||||
.map(|p| unsafe { IsWindowVisible(p.hwnd).as_bool() })
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn current_model() -> Option<ProviderId> {
|
||||
lock_state().as_ref().map(|p| p.data.model)
|
||||
}
|
||||
|
||||
/// Show the panel for the given model, anchored near the supplied bubble HWND.
|
||||
/// If a panel is already visible, hide it instead (toggle).
|
||||
pub fn toggle(data: PanelData, anchor_hwnd: HWND) {
|
||||
if is_visible() && current_model() == Some(data.model) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
show(data, anchor_hwnd);
|
||||
}
|
||||
|
||||
pub fn show(data: PanelData, anchor_hwnd: HWND) {
|
||||
register_class();
|
||||
|
||||
let mut anchor_rect = RECT::default();
|
||||
unsafe {
|
||||
let _ = GetWindowRect(anchor_hwnd, &mut anchor_rect);
|
||||
}
|
||||
let dpi = unsafe { GetDpiForWindow(anchor_hwnd).max(96) };
|
||||
let panel_w = scale_to_dpi(PANEL_W_LOGICAL, dpi);
|
||||
let panel_h = scale_to_dpi(PANEL_H_LOGICAL, dpi);
|
||||
let (x, y) = place_near(anchor_rect, panel_w, panel_h);
|
||||
|
||||
let existing_hwnd = lock_state().as_ref().map(|p| p.hwnd);
|
||||
let hwnd = match existing_hwnd {
|
||||
Some(h) => h,
|
||||
None => match create_panel_window(x, y, panel_w, panel_h) {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
|
||||
apply_win11_window_chrome(hwnd);
|
||||
|
||||
{
|
||||
let mut guard = lock_state();
|
||||
if let Some(p) = guard.as_mut() {
|
||||
p.data = data;
|
||||
} else {
|
||||
*guard = Some(PanelState { hwnd, data });
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
HWND::default(),
|
||||
x,
|
||||
y,
|
||||
panel_w,
|
||||
panel_h,
|
||||
SWP_NOZORDER | SWP_NOACTIVATE,
|
||||
);
|
||||
let _ = InvalidateRect(hwnd, None, true);
|
||||
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||
let _ = SetForegroundWindow(hwnd);
|
||||
if let Err(err) = queued {
|
||||
log::warn!("panel set-up could not reach the main thread: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
||||
let hwnd = unsafe {
|
||||
let class_w = wide_str(CLASS_NAME);
|
||||
let title_w = wide_str("Usage panel");
|
||||
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
|
||||
CreateWindowExW(
|
||||
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
||||
PCWSTR::from_raw(class_w.as_ptr()),
|
||||
PCWSTR::from_raw(title_w.as_ptr()),
|
||||
WS_POPUP,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
HWND::default(),
|
||||
HMENU::default(),
|
||||
hinstance,
|
||||
None,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if hwnd == HWND::default() {
|
||||
log::error!("panel CreateWindowExW failed");
|
||||
None
|
||||
} else {
|
||||
Some(hwnd)
|
||||
#[cfg(not(windows))]
|
||||
fn prepare_native(_app: &AppHandle, _window: &WebviewWindow) {}
|
||||
|
||||
/// A click on `provider`'s bubble: hide the panel when it shows that
|
||||
/// provider, otherwise move it next to the bubble and show that provider.
|
||||
pub fn toggle(app: &AppHandle, provider: ProviderId) {
|
||||
let handle = app.clone();
|
||||
let queued = app.run_on_main_thread(move || toggle_on_main(&handle, provider));
|
||||
if let Err(err) = queued {
|
||||
log::warn!("panel toggle could not reach the main thread: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply Windows 11 rounded corners. Win11-only — `DwmSetWindowAttribute`
|
||||
/// returns an error on Win10 and earlier, which we deliberately swallow so
|
||||
/// the panel falls back to square corners without complaint. Idempotent:
|
||||
/// DWM ignores redundant identical-value sets, so calling this on every
|
||||
/// `show()` is safe.
|
||||
fn apply_win11_window_chrome(hwnd: HWND) {
|
||||
unsafe {
|
||||
let pref = DWMWCP_ROUND;
|
||||
let _ = DwmSetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_WINDOW_CORNER_PREFERENCE,
|
||||
&pref as *const _ as *const _,
|
||||
std::mem::size_of_val(&pref) as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hide() {
|
||||
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
||||
if let Some(hwnd) = hwnd_opt {
|
||||
unsafe {
|
||||
let _ = ShowWindow(hwnd, SW_HIDE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroy() {
|
||||
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
||||
if let Some(hwnd) = hwnd_opt {
|
||||
unsafe {
|
||||
let _ = DestroyWindow(hwnd);
|
||||
}
|
||||
}
|
||||
*lock_state() = None;
|
||||
}
|
||||
|
||||
/// Refresh the panel's data (called from app when poll cycle completes).
|
||||
pub fn refresh_data(data: PanelData) {
|
||||
let hwnd_opt = {
|
||||
let mut guard = lock_state();
|
||||
let Some(p) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if p.data.model != data.model {
|
||||
// Showing a different model right now — caller can decide whether to swap.
|
||||
return;
|
||||
}
|
||||
p.data = data;
|
||||
Some(p.hwnd)
|
||||
};
|
||||
if let Some(hwnd) = hwnd_opt {
|
||||
unsafe {
|
||||
let _ = InvalidateRect(hwnd, None, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Window proc & painting ----------
|
||||
|
||||
unsafe extern "system" fn wnd_proc(
|
||||
hwnd: HWND,
|
||||
msg: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
match msg {
|
||||
WM_PAINT => {
|
||||
let mut ps = PAINTSTRUCT::default();
|
||||
let hdc = BeginPaint(hwnd, &mut ps);
|
||||
paint(hwnd, hdc);
|
||||
let _ = EndPaint(hwnd, &ps);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_ERASEBKGND => LRESULT(1),
|
||||
WM_KILLFOCUS => {
|
||||
// Close the panel when it loses focus. Use ShowWindow rather than
|
||||
// DestroyWindow so we can re-show it next click without re-creating.
|
||||
let _ = ShowWindow(hwnd, SW_HIDE);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_DESTROY => {
|
||||
*lock_state() = None;
|
||||
LRESULT(0)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(hwnd: HWND, hdc: HDC) {
|
||||
let Some(data) = clone_data() else {
|
||||
fn toggle_on_main(app: &AppHandle, provider: ProviderId) {
|
||||
let Some(window) = app.get_webview_window(PANEL_LABEL) else {
|
||||
log::warn!("bubble click on {} but no panel exists", provider.slug());
|
||||
return;
|
||||
};
|
||||
let mut rc = RECT::default();
|
||||
unsafe {
|
||||
let _ = GetClientRect(hwnd, &mut rc);
|
||||
}
|
||||
let dpi = unsafe { GetDpiForWindow(hwnd).max(96) };
|
||||
let scaled = |v: i32| scale_to_dpi(v, dpi);
|
||||
|
||||
let bg = if data.is_dark {
|
||||
Color::from_hex("#1F1F1F")
|
||||
} else {
|
||||
Color::from_hex("#FAFAFA")
|
||||
};
|
||||
let text_color = if data.is_dark {
|
||||
Color::from_hex("#EAEAEA")
|
||||
} else {
|
||||
Color::from_hex("#1F1F1F")
|
||||
};
|
||||
let track = if data.is_dark {
|
||||
Color::from_hex("#3A3A3A")
|
||||
} else {
|
||||
Color::from_hex("#D6D6D6")
|
||||
};
|
||||
let session_accent =
|
||||
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.session_pct);
|
||||
let weekly_accent =
|
||||
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.weekly_pct);
|
||||
|
||||
unsafe {
|
||||
let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
|
||||
FillRect(hdc, &rc, bg_brush);
|
||||
let _ = DeleteObject(bg_brush);
|
||||
|
||||
let stripe_color = crate::usage_color::accent_color_for(data.model, data.is_dark);
|
||||
let stripe_w = scaled(4);
|
||||
let stripe_rect = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: stripe_w,
|
||||
bottom: rc.bottom,
|
||||
};
|
||||
let stripe_brush = CreateSolidBrush(COLORREF(stripe_color.into_colorref()));
|
||||
FillRect(hdc, &stripe_rect, stripe_brush);
|
||||
let _ = DeleteObject(stripe_brush);
|
||||
|
||||
// Header row: model label
|
||||
let header = match data.model {
|
||||
ProviderId::Claude => data.strings.claude_label.clone(),
|
||||
ProviderId::ChatGpt => data.strings.chatgpt_label.clone(),
|
||||
ProviderId::OpenCodeGo => data.strings.opencode_go_label.clone(),
|
||||
};
|
||||
draw_text(
|
||||
hdc,
|
||||
&header,
|
||||
text_color,
|
||||
scaled(PADDING_LOGICAL),
|
||||
scaled(PADDING_LOGICAL),
|
||||
rc.right - 2 * scaled(PADDING_LOGICAL),
|
||||
scaled(18),
|
||||
true,
|
||||
dpi,
|
||||
);
|
||||
|
||||
let bar_x = scaled(PADDING_LOGICAL) + scaled(LABEL_W_LOGICAL) + scaled(4);
|
||||
let bar_w =
|
||||
rc.right - bar_x - scaled(PADDING_LOGICAL) - scaled(RIGHT_TEXT_W_LOGICAL) - scaled(4);
|
||||
let row1_y = scaled(PADDING_LOGICAL) + scaled(24);
|
||||
let row2_y = row1_y + scaled(BAR_HEIGHT_LOGICAL) + scaled(ROW_GAP_LOGICAL) + scaled(8);
|
||||
|
||||
draw_row(
|
||||
hdc,
|
||||
&data.strings.session_window,
|
||||
scaled(PADDING_LOGICAL),
|
||||
row1_y,
|
||||
bar_x,
|
||||
bar_w,
|
||||
scaled(BAR_HEIGHT_LOGICAL),
|
||||
data.session_pct,
|
||||
&data.session_text,
|
||||
text_color,
|
||||
track,
|
||||
session_accent,
|
||||
dpi,
|
||||
);
|
||||
|
||||
draw_row(
|
||||
hdc,
|
||||
&data.strings.weekly_window,
|
||||
scaled(PADDING_LOGICAL),
|
||||
row2_y,
|
||||
bar_x,
|
||||
bar_w,
|
||||
scaled(BAR_HEIGHT_LOGICAL),
|
||||
data.weekly_pct,
|
||||
&data.weekly_text,
|
||||
text_color,
|
||||
track,
|
||||
weekly_accent,
|
||||
dpi,
|
||||
);
|
||||
match with_state(|s| toggle_state(s, provider, Instant::now())) {
|
||||
Toggle::Hide => hide(&window),
|
||||
Toggle::Ignore => log::debug!("panel just closed by this click; not reopened"),
|
||||
Toggle::Show => show_for(app, &window, provider),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_row(
|
||||
hdc: HDC,
|
||||
label: &str,
|
||||
label_x: i32,
|
||||
row_y: i32,
|
||||
bar_x: i32,
|
||||
bar_w: i32,
|
||||
bar_h: i32,
|
||||
pct: f64,
|
||||
right_text: &str,
|
||||
text_color: Color,
|
||||
track: Color,
|
||||
accent: Color,
|
||||
dpi: u32,
|
||||
) {
|
||||
let scaled = |v: i32| scale_to_dpi(v, dpi);
|
||||
let label_w = scaled(LABEL_W_LOGICAL);
|
||||
draw_text(
|
||||
hdc,
|
||||
label,
|
||||
text_color,
|
||||
label_x,
|
||||
row_y - scaled(2),
|
||||
label_w,
|
||||
bar_h + scaled(4),
|
||||
false,
|
||||
dpi,
|
||||
/// Fill the panel with `provider`'s data, place it next to that bubble and
|
||||
/// show it with focus, so that losing focus can dismiss it.
|
||||
fn show_for(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) {
|
||||
let Some(runtime) = runtime_of(app) else {
|
||||
log::warn!("{PANEL_LABEL} not shown: the runtime is not ready");
|
||||
return;
|
||||
};
|
||||
let payload = runtime.panel_payload(provider);
|
||||
place_near_bubble(app, window, provider);
|
||||
if let Err(err) = window.set_background_color(Some(background(payload.is_dark))) {
|
||||
log::debug!("{PANEL_LABEL} background not set: {err}");
|
||||
}
|
||||
emit(app, &payload);
|
||||
#[cfg(windows)]
|
||||
if let Err(err) = platform::windows::round_corners(window) {
|
||||
// Windows 10 has no rounded corners.
|
||||
log::debug!("{PANEL_LABEL} keeps square corners: {err}");
|
||||
}
|
||||
if let Err(err) = window.show() {
|
||||
log::warn!("{PANEL_LABEL} not shown: {err}");
|
||||
}
|
||||
if let Err(err) = window.set_focus() {
|
||||
log::debug!("{PANEL_LABEL} not focused: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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());
|
||||
return;
|
||||
};
|
||||
let (Ok(pos), Ok(size), Ok(scale)) = (
|
||||
bubble.outer_position(),
|
||||
bubble.outer_size(),
|
||||
bubble.scale_factor(),
|
||||
) else {
|
||||
log::warn!(
|
||||
"bounds of the {} bubble unavailable; panel not moved",
|
||||
provider.slug()
|
||||
);
|
||||
return;
|
||||
};
|
||||
let dpi = runtime::dpi_for_scale(scale);
|
||||
let panel_size = (
|
||||
geom::scale(PANEL_W_LOGICAL, dpi),
|
||||
geom::scale(PANEL_H_LOGICAL, dpi),
|
||||
);
|
||||
|
||||
unsafe {
|
||||
let track_brush = CreateSolidBrush(COLORREF(track.into_colorref()));
|
||||
let bar_rect = RECT {
|
||||
left: bar_x,
|
||||
top: row_y,
|
||||
right: bar_x + bar_w,
|
||||
bottom: row_y + bar_h,
|
||||
};
|
||||
FillRect(hdc, &bar_rect, track_brush);
|
||||
let _ = DeleteObject(track_brush);
|
||||
|
||||
let fill_w = ((pct.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32;
|
||||
if fill_w > 0 {
|
||||
let accent_brush = CreateSolidBrush(COLORREF(accent.into_colorref()));
|
||||
let fill_rect = RECT {
|
||||
left: bar_x,
|
||||
top: row_y,
|
||||
right: bar_x + fill_w,
|
||||
bottom: row_y + bar_h,
|
||||
};
|
||||
FillRect(hdc, &fill_rect, accent_brush);
|
||||
let _ = DeleteObject(accent_brush);
|
||||
let physical = PhysicalSize::new(
|
||||
panel_size.0.max(1).unsigned_abs(),
|
||||
panel_size.1.max(1).unsigned_abs(),
|
||||
);
|
||||
if !platform::positioning_supported() {
|
||||
if let Err(err) = window.set_size(physical) {
|
||||
log::warn!("{PANEL_LABEL} not resized: {err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let right_text_x = bar_x + bar_w + scaled(8);
|
||||
let right_text_w = scaled(RIGHT_TEXT_W_LOGICAL);
|
||||
draw_text(
|
||||
hdc,
|
||||
right_text,
|
||||
text_color,
|
||||
right_text_x,
|
||||
row_y - scaled(2),
|
||||
right_text_w,
|
||||
bar_h + scaled(4),
|
||||
false,
|
||||
dpi,
|
||||
let anchor = Rect::from_xywh(
|
||||
pos.x,
|
||||
pos.y,
|
||||
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);
|
||||
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.
|
||||
set_position(window, target);
|
||||
if let Err(err) = window.set_size(physical) {
|
||||
log::warn!("{PANEL_LABEL} not resized: {err}");
|
||||
}
|
||||
set_position(window, target);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_text(
|
||||
hdc: HDC,
|
||||
text: &str,
|
||||
color: Color,
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
bold: bool,
|
||||
dpi: u32,
|
||||
) {
|
||||
let mut text_w: Vec<u16> = text.encode_utf16().collect();
|
||||
let font_size = if bold {
|
||||
scale_to_dpi(13, dpi)
|
||||
} else {
|
||||
scale_to_dpi(11, dpi)
|
||||
fn set_position(window: &WebviewWindow, pos: Point) {
|
||||
if let Err(err) = window.set_position(PhysicalPosition::new(pos.x, pos.y)) {
|
||||
log::warn!("{PANEL_LABEL} not moved: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounding rect of all monitors, `None` without any.
|
||||
fn virtual_screen(monitors: &[Rect]) -> Option<Rect> {
|
||||
monitors
|
||||
.iter()
|
||||
.copied()
|
||||
.reduce(|a, b| Rect::new(a.l.min(b.l), a.t.min(b.t), a.r.max(b.r), a.b.max(b.b)))
|
||||
}
|
||||
|
||||
fn hide(window: &WebviewWindow) {
|
||||
if let Err(err) = window.hide() {
|
||||
log::warn!("{PANEL_LABEL} not hidden: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(app: &AppHandle, payload: &PanelPayload) {
|
||||
if let Err(err) = app.emit_to(PANEL_LABEL, PANEL_UPDATE_EVENT, payload) {
|
||||
log::warn!("emit {PANEL_UPDATE_EVENT} failed: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the open panel in place, without moving it, when it shows
|
||||
/// `payload`'s provider. Called after every state push, outside the state
|
||||
/// lock; the check runs on the main thread, after any queued toggle.
|
||||
pub fn refresh_if_visible(app: &AppHandle, payload: PanelPayload) {
|
||||
let handle = app.clone();
|
||||
let queued = app.run_on_main_thread(move || {
|
||||
if shown_provider() == Some(payload.provider) {
|
||||
emit(&handle, &payload);
|
||||
}
|
||||
});
|
||||
if let Err(err) = queued {
|
||||
log::warn!("panel refresh could not reach the main thread: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the panel's window events; other windows are ignored.
|
||||
pub fn on_window_event(window: &Window, event: &WindowEvent) {
|
||||
if window.label() != PANEL_LABEL {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
// Alt+F4 hides the panel; it is never destroyed.
|
||||
WindowEvent::CloseRequested { api, .. } => {
|
||||
api.prevent_close();
|
||||
with_state(|s| s.shown = None);
|
||||
if let Err(err) = window.hide() {
|
||||
log::warn!("{PANEL_LABEL} not hidden: {err}");
|
||||
}
|
||||
}
|
||||
WindowEvent::Focused(false) => on_blur(window),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus left the panel: hide it if it was open, and remember when, so
|
||||
/// the bubble click that took the focus does not reopen it.
|
||||
fn on_blur(window: &Window) {
|
||||
if !with_state(|s| blur_state(s, Instant::now())) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = window.hide() {
|
||||
log::warn!("{PANEL_LABEL} not hidden: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HIDDEN: PanelState = PanelState {
|
||||
shown: None,
|
||||
provider: None,
|
||||
blur_hide: None,
|
||||
};
|
||||
let font_name = wide_str("Segoe UI");
|
||||
unsafe {
|
||||
let weight = if bold { FW_SEMIBOLD.0 } else { FW_NORMAL.0 } as i32;
|
||||
let font = CreateFontW(
|
||||
-font_size,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
weight,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
DEFAULT_CHARSET.0 as u32,
|
||||
OUT_DEFAULT_PRECIS.0 as u32,
|
||||
CLIP_DEFAULT_PRECIS.0 as u32,
|
||||
CLEARTYPE_QUALITY.0 as u32,
|
||||
(FF_SWISS.0 | DEFAULT_PITCH.0) as u32,
|
||||
PCWSTR::from_raw(font_name.as_ptr()),
|
||||
);
|
||||
let old_font = SelectObject(hdc, font);
|
||||
SetTextColor(hdc, COLORREF(color.into_colorref()));
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
let mut rect = RECT {
|
||||
left: x,
|
||||
top: y,
|
||||
right: x + w,
|
||||
bottom: y + h,
|
||||
};
|
||||
let _ = DrawTextW(
|
||||
hdc,
|
||||
&mut text_w,
|
||||
&mut rect,
|
||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE,
|
||||
);
|
||||
SelectObject(hdc, old_font);
|
||||
let _ = DeleteObject(font);
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_data() -> Option<PanelData> {
|
||||
let guard = lock_state();
|
||||
let p = guard.as_ref()?;
|
||||
Some(PanelData {
|
||||
model: p.data.model,
|
||||
session_pct: p.data.session_pct,
|
||||
session_text: p.data.session_text.clone(),
|
||||
weekly_pct: p.data.weekly_pct,
|
||||
weekly_text: p.data.weekly_text.clone(),
|
||||
is_dark: p.data.is_dark,
|
||||
strings: p.data.strings.clone(),
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn a_click_opens_the_hidden_panel() {
|
||||
let mut state = HIDDEN;
|
||||
let now = Instant::now();
|
||||
assert_eq!(
|
||||
toggle_state(&mut state, ProviderId::Claude, now),
|
||||
Toggle::Show
|
||||
);
|
||||
assert_eq!(state.shown, Some(ProviderId::Claude));
|
||||
assert_eq!(state.provider, Some(ProviderId::Claude));
|
||||
}
|
||||
|
||||
fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
|
||||
// Anchor below the bubble by default; flip above if it would clip.
|
||||
let mut x = anchor.left;
|
||||
let mut y = anchor.bottom + 8;
|
||||
// The virtual screen spans all monitors. Its origin is offset from
|
||||
// the primary monitor when a secondary monitor sits left of / above
|
||||
// the primary, so clamps must include SM_XVIRTUALSCREEN /
|
||||
// SM_YVIRTUALSCREEN — not just the width/height of the union.
|
||||
let vx = unsafe { GetSystemMetrics(SM_XVIRTUALSCREEN) };
|
||||
let vy = unsafe { GetSystemMetrics(SM_YVIRTUALSCREEN) };
|
||||
let vw = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
|
||||
let vh = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
|
||||
let right = vx + vw;
|
||||
let bottom = vy + vh;
|
||||
if y + panel_h > bottom {
|
||||
y = anchor.top - panel_h - 8;
|
||||
#[test]
|
||||
fn a_second_click_on_the_same_bubble_closes_it() {
|
||||
let mut state = HIDDEN;
|
||||
let now = Instant::now();
|
||||
toggle_state(&mut state, ProviderId::Claude, now);
|
||||
assert_eq!(
|
||||
toggle_state(&mut state, ProviderId::Claude, now),
|
||||
Toggle::Hide
|
||||
);
|
||||
assert_eq!(state.shown, None);
|
||||
// The last provider is kept for a reloaded page.
|
||||
assert_eq!(state.provider, Some(ProviderId::Claude));
|
||||
}
|
||||
if y < vy {
|
||||
y = anchor.top.max(vy);
|
||||
|
||||
#[test]
|
||||
fn a_click_on_the_other_bubble_swaps_the_content() {
|
||||
let mut state = HIDDEN;
|
||||
let now = Instant::now();
|
||||
toggle_state(&mut state, ProviderId::Claude, now);
|
||||
assert_eq!(
|
||||
toggle_state(&mut state, ProviderId::ChatGpt, now),
|
||||
Toggle::Show
|
||||
);
|
||||
assert_eq!(state.shown, Some(ProviderId::ChatGpt));
|
||||
}
|
||||
if x + panel_w > right {
|
||||
x = right - panel_w - 8;
|
||||
|
||||
#[test]
|
||||
fn a_click_right_after_a_blur_hide_does_not_reopen() {
|
||||
let mut state = HIDDEN;
|
||||
let t0 = Instant::now();
|
||||
toggle_state(&mut state, ProviderId::Claude, t0);
|
||||
assert!(blur_state(&mut state, t0));
|
||||
assert_eq!(state.shown, None);
|
||||
assert_eq!(
|
||||
toggle_state(
|
||||
&mut state,
|
||||
ProviderId::Claude,
|
||||
t0 + Duration::from_millis(149)
|
||||
),
|
||||
Toggle::Ignore
|
||||
);
|
||||
assert_eq!(state.shown, None);
|
||||
// The guard is spent: the next click opens the panel again.
|
||||
assert_eq!(
|
||||
toggle_state(
|
||||
&mut state,
|
||||
ProviderId::Claude,
|
||||
t0 + Duration::from_millis(160)
|
||||
),
|
||||
Toggle::Show
|
||||
);
|
||||
}
|
||||
if x < vx {
|
||||
x = vx + 8;
|
||||
|
||||
#[test]
|
||||
fn the_blur_guard_expires_and_ignores_other_providers() {
|
||||
let t0 = Instant::now();
|
||||
let mut state = HIDDEN;
|
||||
toggle_state(&mut state, ProviderId::Claude, t0);
|
||||
blur_state(&mut state, t0);
|
||||
assert_eq!(
|
||||
toggle_state(&mut state, ProviderId::Claude, t0 + BLUR_REOPEN_GUARD),
|
||||
Toggle::Show
|
||||
);
|
||||
|
||||
let mut state = HIDDEN;
|
||||
toggle_state(&mut state, ProviderId::Claude, t0);
|
||||
blur_state(&mut state, t0);
|
||||
assert_eq!(
|
||||
toggle_state(
|
||||
&mut state,
|
||||
ProviderId::ChatGpt,
|
||||
t0 + Duration::from_millis(10)
|
||||
),
|
||||
Toggle::Show
|
||||
);
|
||||
assert_eq!(state.shown, Some(ProviderId::ChatGpt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blur_while_hidden_records_nothing() {
|
||||
let mut state = HIDDEN;
|
||||
assert!(!blur_state(&mut state, Instant::now()));
|
||||
assert_eq!(state.blur_hide, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_screen_is_the_union_of_the_monitors() {
|
||||
assert_eq!(virtual_screen(&[]), None);
|
||||
let primary = Rect::new(0, 0, 1920, 1080);
|
||||
assert_eq!(virtual_screen(&[primary]), Some(primary));
|
||||
let left_above = Rect::new(-1920, -200, 0, 880);
|
||||
assert_eq!(
|
||||
virtual_screen(&[primary, left_above]),
|
||||
Some(Rect::new(-1920, -200, 1920, 1080))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_backgrounds_match_the_palette() {
|
||||
assert_eq!(background(true), Color(0x1F, 0x1F, 0x1F, 0xFF));
|
||||
assert_eq!(background(false), Color(0xFA, 0xFA, 0xFA, 0xFF));
|
||||
}
|
||||
(x, y)
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Linux-only helpers.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt, KeyButMask, Window};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
use super::FullscreenCheck;
|
||||
|
||||
/// The AppIndicator libraries the tray backend loads, in its own order. The
|
||||
/// unversioned names come from the backend's `backcompat` feature (on by
|
||||
/// default) and are sometimes the only ones present in an AppImage.
|
||||
const APPINDICATOR_LIBRARIES: [&str; 4] = [
|
||||
"libayatana-appindicator3.so.1",
|
||||
"libappindicator3.so.1",
|
||||
"libayatana-appindicator3.so",
|
||||
"libappindicator3.so",
|
||||
];
|
||||
|
||||
/// Whether an AppIndicator library can be loaded. The tray backend panics
|
||||
/// when neither loads, and the release profile aborts on panic, so the tray
|
||||
/// is built only when this is true. Probed once per process.
|
||||
pub fn appindicator_available() -> bool {
|
||||
// The loaded library is kept for the life of the process rather than
|
||||
// unloaded right before the tray backend loads it again.
|
||||
static LIBRARY: OnceLock<Option<libloading::Library>> = OnceLock::new();
|
||||
LIBRARY
|
||||
.get_or_init(|| {
|
||||
for name in APPINDICATOR_LIBRARIES {
|
||||
// SAFETY: these are the libraries the tray backend itself
|
||||
// loads; their initialisers run either way once a tray is built.
|
||||
match unsafe { libloading::Library::new(name) } {
|
||||
Ok(lib) => {
|
||||
log::info!("tray: {name} found");
|
||||
return Some(lib);
|
||||
}
|
||||
Err(err) => log::debug!("tray: {name} not loadable: {err}"),
|
||||
}
|
||||
}
|
||||
log::warn!("tray: no AppIndicator library found; running without a tray");
|
||||
None
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Whether GTK runs on X11, where windows can be placed: `GDK_BACKEND`
|
||||
/// names x11 first, or it is unset outside a Wayland session. The runtime
|
||||
/// sets `GDK_BACKEND=x11` under Wayland when Xwayland is available.
|
||||
pub fn positioning_supported(
|
||||
wayland_display: Option<OsString>,
|
||||
gdk_backend: Option<OsString>,
|
||||
) -> bool {
|
||||
let set = |v: &Option<OsString>| v.as_ref().is_some_and(|v| !v.is_empty());
|
||||
if set(&gdk_backend) {
|
||||
let backend = gdk_backend.unwrap_or_default();
|
||||
return backend
|
||||
.to_string_lossy()
|
||||
.split(',')
|
||||
.next()
|
||||
.is_some_and(|first| first.trim() == "x11");
|
||||
}
|
||||
!set(&wayland_display)
|
||||
}
|
||||
|
||||
/// One X connection for the process and its default root window.
|
||||
struct X11 {
|
||||
conn: RustConnection,
|
||||
root: Window,
|
||||
}
|
||||
|
||||
/// The shared X connection, opened on first use; `None` without an X
|
||||
/// server (then it is not retried).
|
||||
fn x11() -> Option<&'static X11> {
|
||||
static X11_CONNECTION: OnceLock<Option<X11>> = OnceLock::new();
|
||||
X11_CONNECTION
|
||||
.get_or_init(|| match x11rb::connect(None) {
|
||||
Ok((conn, screen)) => {
|
||||
let root = conn.setup().roots.get(screen)?.root;
|
||||
Some(X11 { conn, root })
|
||||
}
|
||||
Err(err) => {
|
||||
log::info!("no X11 connection: {err}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Whether mouse Button1 is held, from an X `QueryPointer`. False without
|
||||
/// X11 or when the query fails.
|
||||
pub fn button1_down() -> bool {
|
||||
let Some(x11) = x11() else {
|
||||
return false;
|
||||
};
|
||||
let reply = x11
|
||||
.conn
|
||||
.query_pointer(x11.root)
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|cookie| cookie.reply().map_err(|err| err.to_string()));
|
||||
match reply {
|
||||
Ok(reply) => reply.mask.contains(KeyButMask::BUTTON1),
|
||||
Err(err) => {
|
||||
log::debug!("X QueryPointer failed: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
x11rb::atom_manager! {
|
||||
/// The EWMH atoms the fullscreen check reads.
|
||||
FullscreenAtoms: FullscreenAtomsCookie {
|
||||
_NET_ACTIVE_WINDOW,
|
||||
_NET_WM_STATE,
|
||||
_NET_WM_STATE_FULLSCREEN,
|
||||
_NET_WM_STATE_HIDDEN,
|
||||
_NET_WM_PID,
|
||||
}
|
||||
}
|
||||
|
||||
/// The fullscreen atoms on the shared connection, interned on first use;
|
||||
/// `None` without X11 or when interning failed (then it is not retried).
|
||||
fn fullscreen_atoms() -> Option<(&'static X11, FullscreenAtoms)> {
|
||||
static ATOMS: OnceLock<Option<FullscreenAtoms>> = OnceLock::new();
|
||||
let x11 = x11()?;
|
||||
let atoms = ATOMS.get_or_init(|| {
|
||||
let atoms = FullscreenAtoms::new(&x11.conn)
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|cookie| cookie.reply().map_err(|err| err.to_string()));
|
||||
match atoms {
|
||||
Ok(atoms) => Some(atoms),
|
||||
Err(err) => {
|
||||
log::warn!("EWMH atoms could not be interned: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
atoms.map(|atoms| (x11, atoms))
|
||||
}
|
||||
|
||||
/// The 32-bit values of `property` on `window`, at most `max` of them.
|
||||
/// Empty when the property is missing or has another type or format.
|
||||
fn property32(
|
||||
x11: &X11,
|
||||
window: Window,
|
||||
property: Atom,
|
||||
kind: AtomEnum,
|
||||
max: u32,
|
||||
) -> Result<Vec<u32>, String> {
|
||||
let reply = x11
|
||||
.conn
|
||||
.get_property(false, window, property, kind, 0, max)
|
||||
.map_err(|err| err.to_string())?
|
||||
.reply()
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(reply.value32().map(Iterator::collect).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Whether the EWMH active window is fullscreen and belongs to another
|
||||
/// process. False without X11, without an active window or when a query
|
||||
/// fails (a window can vanish between the reads).
|
||||
pub fn foreground_is_fullscreen() -> FullscreenCheck {
|
||||
let Some((x11, atoms)) = fullscreen_atoms() else {
|
||||
return FullscreenCheck::new(false, "no X11 connection");
|
||||
};
|
||||
let active = match property32(x11, x11.root, atoms._NET_ACTIVE_WINDOW, AtomEnum::WINDOW, 1) {
|
||||
Ok(values) => values.first().copied().unwrap_or(0),
|
||||
Err(err) => {
|
||||
log::debug!("_NET_ACTIVE_WINDOW unreadable: {err}");
|
||||
return FullscreenCheck::new(false, "active window unreadable");
|
||||
}
|
||||
};
|
||||
if active == 0 {
|
||||
return FullscreenCheck::new(false, "no active window");
|
||||
}
|
||||
let state = property32(x11, active, atoms._NET_WM_STATE, AtomEnum::ATOM, 64);
|
||||
let pid = property32(x11, active, atoms._NET_WM_PID, AtomEnum::CARDINAL, 1);
|
||||
let (state, pid) = match (state, pid) {
|
||||
(Ok(state), Ok(pid)) => (state, pid.first().copied()),
|
||||
(Err(err), _) | (_, Err(err)) => {
|
||||
log::debug!("state of window 0x{active:X} unreadable: {err}");
|
||||
return FullscreenCheck::new(false, "active window unreadable");
|
||||
}
|
||||
};
|
||||
let (fullscreen, reason) = evaluate_active_window(&state, &atoms, pid, std::process::id());
|
||||
FullscreenCheck {
|
||||
fullscreen,
|
||||
reason,
|
||||
detail: format!(
|
||||
"window=0x{active:X} pid={}",
|
||||
pid.map_or_else(|| "<none>".to_string(), |p| p.to_string())
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision for the active window from its `_NET_WM_STATE` atoms and
|
||||
/// `_NET_WM_PID`: fullscreen, not minimized, and not one of our windows.
|
||||
fn evaluate_active_window(
|
||||
state: &[Atom],
|
||||
atoms: &FullscreenAtoms,
|
||||
window_pid: Option<u32>,
|
||||
own_pid: u32,
|
||||
) -> (bool, &'static str) {
|
||||
if window_pid == Some(own_pid) {
|
||||
return (false, "foreground window is ours");
|
||||
}
|
||||
if state.contains(&atoms._NET_WM_STATE_HIDDEN) {
|
||||
return (false, "foreground minimized");
|
||||
}
|
||||
if !state.contains(&atoms._NET_WM_STATE_FULLSCREEN) {
|
||||
return (false, "foreground not fullscreen");
|
||||
}
|
||||
(true, "foreground fullscreen")
|
||||
}
|
||||
|
||||
/// How long the portal settings read may block.
|
||||
const PORTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Maps the portal's `org.freedesktop.appearance color-scheme` value to a
|
||||
/// dark flag: 1 prefers dark, 2 prefers light. "No preference" (0), any
|
||||
/// other value and an unreachable portal give `None`, so the caller keeps
|
||||
/// its current theme. tao's `Window::theme()` maps all of those to light.
|
||||
pub fn color_scheme_is_dark(scheme: Option<u32>) -> Option<bool> {
|
||||
match scheme {
|
||||
Some(1) => Some(true),
|
||||
Some(2) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the desktop portal states a dark or light preference; `None`
|
||||
/// when it states none or cannot be reached. Blocks for up to
|
||||
/// `PORTAL_TIMEOUT`.
|
||||
pub fn portal_prefers_dark() -> Option<bool> {
|
||||
color_scheme_is_dark(read_color_scheme())
|
||||
}
|
||||
|
||||
fn read_color_scheme() -> Option<u32> {
|
||||
use dbus::arg::Variant;
|
||||
use dbus::blocking::Connection;
|
||||
|
||||
let result = Connection::new_session().and_then(|conn| {
|
||||
let proxy = conn.with_proxy(
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
PORTAL_TIMEOUT,
|
||||
);
|
||||
let (value,): (Variant<Variant<u32>>,) = proxy.method_call(
|
||||
"org.freedesktop.portal.Settings",
|
||||
"Read",
|
||||
("org.freedesktop.appearance", "color-scheme"),
|
||||
)?;
|
||||
Ok(value.0 .0)
|
||||
});
|
||||
match result {
|
||||
Ok(scheme) => Some(scheme),
|
||||
Err(err) => {
|
||||
log::debug!("portal color-scheme unavailable: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn os(v: &str) -> Option<OsString> {
|
||||
Some(OsString::from(v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positioning_follows_the_gdk_backend() {
|
||||
// Plain X11 session.
|
||||
assert!(positioning_supported(None, None));
|
||||
assert!(positioning_supported(os(""), None));
|
||||
// Wayland with the x11 override (Xwayland).
|
||||
assert!(positioning_supported(os("wayland-0"), os("x11")));
|
||||
assert!(positioning_supported(os("wayland-0"), os("x11,wayland")));
|
||||
// Native Wayland.
|
||||
assert!(!positioning_supported(os("wayland-0"), None));
|
||||
assert!(!positioning_supported(os("wayland-0"), os("wayland")));
|
||||
assert!(!positioning_supported(os("wayland-0"), os("wayland,x11")));
|
||||
assert!(!positioning_supported(None, os("broadway")));
|
||||
}
|
||||
|
||||
const ATOMS: FullscreenAtoms = FullscreenAtoms {
|
||||
_NET_ACTIVE_WINDOW: 1,
|
||||
_NET_WM_STATE: 2,
|
||||
_NET_WM_STATE_FULLSCREEN: 3,
|
||||
_NET_WM_STATE_HIDDEN: 4,
|
||||
_NET_WM_PID: 5,
|
||||
};
|
||||
const ABOVE: Atom = 99;
|
||||
|
||||
#[test]
|
||||
fn fullscreen_needs_the_state_atom_on_a_window_of_another_process() {
|
||||
let fs = ATOMS._NET_WM_STATE_FULLSCREEN;
|
||||
assert_eq!(
|
||||
evaluate_active_window(&[ABOVE, fs], &ATOMS, Some(42), 7),
|
||||
(true, "foreground fullscreen")
|
||||
);
|
||||
// A window without `_NET_WM_PID` cannot be ours.
|
||||
assert!(evaluate_active_window(&[fs], &ATOMS, None, 7).0);
|
||||
assert_eq!(
|
||||
evaluate_active_window(&[ABOVE], &ATOMS, Some(42), 7),
|
||||
(false, "foreground not fullscreen")
|
||||
);
|
||||
assert!(!evaluate_active_window(&[], &ATOMS, Some(42), 7).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn our_own_and_minimized_windows_never_count_as_fullscreen() {
|
||||
let fs = ATOMS._NET_WM_STATE_FULLSCREEN;
|
||||
assert_eq!(
|
||||
evaluate_active_window(&[fs], &ATOMS, Some(7), 7),
|
||||
(false, "foreground window is ours")
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_active_window(&[fs, ATOMS._NET_WM_STATE_HIDDEN], &ATOMS, Some(42), 7),
|
||||
(false, "foreground minimized")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_stated_portal_preference_sets_the_theme() {
|
||||
assert_eq!(color_scheme_is_dark(Some(1)), Some(true));
|
||||
assert_eq!(color_scheme_is_dark(Some(2)), Some(false));
|
||||
// No preference, unknown values and an unreachable portal keep the
|
||||
// current theme (dark by default).
|
||||
assert_eq!(color_scheme_is_dark(Some(0)), None);
|
||||
assert_eq!(color_scheme_is_dark(Some(7)), None);
|
||||
assert_eq!(color_scheme_is_dark(None), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! macOS-only helpers. The macOS build is a compile target for now; these
|
||||
//! stubs keep the portable callers working until the platform is finished.
|
||||
|
||||
/// Whether the primary mouse button is held. Always false for now, so a
|
||||
/// drag ends 250 ms after the last move; `NSEvent.pressedMouseButtons`
|
||||
/// would let a paused drag keep the debounce armed, as on X11.
|
||||
pub fn primary_button_down() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether a fullscreen window of another app is frontmost. Always false
|
||||
/// for now; `CGWindowListCopyWindowInfo` with the on-screen window list
|
||||
/// (layer 0, bounds covering a display, owner PID not ours) would answer it.
|
||||
pub fn foreground_is_fullscreen() -> super::FullscreenCheck {
|
||||
super::FullscreenCheck::new(false, "not implemented on macOS")
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! OS-specific code. Each platform file is compiled only on its own target,
|
||||
//! and portable callers reach it through `cfg`-gated calls.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
/// How often the Unix wait checks whether the process is still there.
|
||||
#[cfg(unix)]
|
||||
const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Wait until process `pid` has exited, at most `timeout`. Returns true when
|
||||
/// it is gone, false when it was still running at the deadline.
|
||||
pub fn wait_pid(pid: u32, timeout: Duration) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows::wait_pid(pid, timeout)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
wait_until_gone(timeout, WAIT_POLL_INTERVAL, || unix_pid_alive(pid))
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll `alive` every `interval` until it returns false or `timeout` passes.
|
||||
/// Returns true when `alive` reported false.
|
||||
pub fn wait_until_gone(
|
||||
timeout: Duration,
|
||||
interval: Duration,
|
||||
mut alive: impl FnMut() -> bool,
|
||||
) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if !alive() {
|
||||
return true;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(interval.min(deadline - now));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `pid` names a live (or zombie) process. A process owned by
|
||||
/// another user still counts as alive.
|
||||
#[cfg(unix)]
|
||||
fn unix_pid_alive(pid: u32) -> bool {
|
||||
// Zero and negative ids address process groups, never one process.
|
||||
let Ok(pid) = libc::pid_t::try_from(pid) else {
|
||||
return false;
|
||||
};
|
||||
if pid <= 0 {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: signal 0 only checks for existence and permission.
|
||||
if unsafe { libc::kill(pid, 0) } == 0 {
|
||||
return true;
|
||||
}
|
||||
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
|
||||
}
|
||||
|
||||
/// Whether the system theme is dark at startup. Platforms without a
|
||||
/// readable system setting default to dark.
|
||||
pub fn system_is_dark() -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows::is_dark()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the app can place its windows. False only under a native
|
||||
/// Wayland GTK backend (no Xwayland), where moves are ignored and positions
|
||||
/// read back as `(0, 0)`, so snapping, clamping and saving are skipped.
|
||||
pub fn positioning_supported() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::positioning_supported(
|
||||
std::env::var_os("WAYLAND_DISPLAY"),
|
||||
std::env::var_os("GDK_BACKEND"),
|
||||
)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the primary mouse button is held right now, where the platform
|
||||
/// can tell; false otherwise.
|
||||
pub fn primary_button_down() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::button1_down()
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::primary_button_down()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of one fullscreen check: whether a fullscreen window of
|
||||
/// another process is in the foreground, and why.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FullscreenCheck {
|
||||
pub fullscreen: bool,
|
||||
pub reason: &'static str,
|
||||
/// The window and geometry behind the decision for the diagnostic log;
|
||||
/// empty when there is nothing to add.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl FullscreenCheck {
|
||||
pub fn new(fullscreen: bool, reason: &'static str) -> Self {
|
||||
Self {
|
||||
fullscreen,
|
||||
reason,
|
||||
detail: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the foreground window is a fullscreen window that is not ours.
|
||||
/// Native Wayland and macOS cannot tell and always report false.
|
||||
pub fn foreground_is_fullscreen() -> FullscreenCheck {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows::foreground_is_fullscreen()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if positioning_supported() {
|
||||
linux::foreground_is_fullscreen()
|
||||
} else {
|
||||
FullscreenCheck::new(false, "native Wayland cannot report the foreground")
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::foreground_is_fullscreen()
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
FullscreenCheck::new(false, "unsupported platform")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wait_until_gone_returns_once_the_process_is_gone() {
|
||||
let mut checks = 0;
|
||||
let gone = wait_until_gone(Duration::from_secs(5), Duration::from_millis(1), || {
|
||||
checks += 1;
|
||||
checks < 3
|
||||
});
|
||||
assert!(gone);
|
||||
assert_eq!(checks, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_until_gone_gives_up_at_the_deadline() {
|
||||
let start = Instant::now();
|
||||
let gone = wait_until_gone(Duration::from_millis(30), Duration::from_millis(5), || true);
|
||||
assert!(!gone);
|
||||
assert!(start.elapsed() >= Duration::from_millis(30));
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_process_is_waited_for_until_the_timeout() {
|
||||
assert!(!wait_pid(std::process::id(), Duration::from_millis(50)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_exited_process_is_gone() {
|
||||
let mut child = std::process::Command::new("true")
|
||||
.spawn()
|
||||
.expect("spawn true");
|
||||
let pid = child.id();
|
||||
// Reap it, so the pid no longer names even a zombie.
|
||||
child.wait().expect("wait");
|
||||
assert!(wait_pid(pid, Duration::from_secs(2)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn group_ids_are_never_alive() {
|
||||
assert!(!unix_pid_alive(0));
|
||||
assert!(!unix_pid_alive(u32::MAX));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Windows-only helpers. This is the only module that talks to Win32 APIs.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::{
|
||||
GetAvailableCoreWebView2BrowserVersionString, ICoreWebView2Settings3,
|
||||
};
|
||||
use windows::core::{Interface, HSTRING, PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::{
|
||||
CloseHandle, GetLastError, SetLastError, HWND, LPARAM, LRESULT, RECT, WAIT_OBJECT_0,
|
||||
WIN32_ERROR, WPARAM,
|
||||
};
|
||||
use windows::Win32::Graphics::Dwm::{
|
||||
DwmGetWindowAttribute, DwmSetWindowAttribute, DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND, DWM_WINDOW_CORNER_PREFERENCE,
|
||||
};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||
};
|
||||
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
|
||||
use windows::Win32::UI::Shell::{
|
||||
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage,
|
||||
SetCurrentProcessExplicitAppUserModelID, SetWindowSubclass, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT,
|
||||
ABE_TOP, ABM_GETTASKBARPOS, APPBARDATA,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId,
|
||||
IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos,
|
||||
ShowWindow, GWL_EXSTYLE, GWL_STYLE, MA_NOACTIVATE, MB_ICONERROR, MB_OK, MB_SETFOREGROUND,
|
||||
SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE,
|
||||
SW_SHOWNOACTIVATE, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY,
|
||||
WM_SETTINGCHANGE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW,
|
||||
};
|
||||
|
||||
use crate::core::geom::{self, Edge, Rect};
|
||||
use crate::core::proxy::{self, SystemProxy};
|
||||
|
||||
const INTERNET_SETTINGS_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
const PERSONALIZE_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
const AUMID_KEY_PREFIX: &str = r"Software\Classes\AppUserModelId\";
|
||||
|
||||
/// The static proxy from `HKCU\…\Internet Settings`, or `None` when
|
||||
/// `ProxyEnable` is not 1 or no usable `ProxyServer` entry exists.
|
||||
/// PAC/WPAD (`AutoConfigURL`, auto-detect) is deliberately not evaluated.
|
||||
pub fn system_proxy() -> Option<SystemProxy> {
|
||||
let key = windows_registry::CURRENT_USER
|
||||
.open(INTERNET_SETTINGS_KEY)
|
||||
.ok()?;
|
||||
let enable = key.get_u32("ProxyEnable").ok();
|
||||
let server = key.get_string("ProxyServer").ok();
|
||||
let proxy_override = key.get_string("ProxyOverride").ok();
|
||||
proxy::from_registry_values(enable, server.as_deref(), proxy_override.as_deref())
|
||||
}
|
||||
|
||||
/// Whether the apps theme is dark. `SystemUsesLightTheme` is 1 for light;
|
||||
/// a missing value means dark, as on a fresh Windows 11 install.
|
||||
pub fn is_dark() -> bool {
|
||||
let light = windows_registry::CURRENT_USER
|
||||
.open(PERSONALIZE_KEY)
|
||||
.and_then(|key| key.get_u32("SystemUsesLightTheme"));
|
||||
!matches!(light, Ok(1))
|
||||
}
|
||||
|
||||
/// Wait until process `pid` has exited, at most `timeout`. Returns true when
|
||||
/// it is gone (or never existed), false on timeout or a failed wait.
|
||||
pub fn wait_pid(pid: u32, timeout: Duration) -> bool {
|
||||
// SAFETY: plain FFI call; the returned handle is closed below.
|
||||
let Ok(handle) = (unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false, pid) }) else {
|
||||
// No such process: it already exited.
|
||||
return true;
|
||||
};
|
||||
let ms = u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX);
|
||||
// SAFETY: `handle` is a valid process handle opened with SYNCHRONIZE.
|
||||
let result = unsafe { WaitForSingleObject(handle, ms) };
|
||||
// SAFETY: `handle` came from OpenProcess and is closed exactly once.
|
||||
if let Err(err) = unsafe { CloseHandle(handle) } {
|
||||
log::debug!("CloseHandle for pid {pid} failed: {err}");
|
||||
}
|
||||
result == WAIT_OBJECT_0
|
||||
}
|
||||
|
||||
/// Whether the Microsoft Edge WebView2 runtime is installed.
|
||||
pub fn webview2_available() -> bool {
|
||||
let mut version = PWSTR::null();
|
||||
// SAFETY: a null folder asks for the installed Evergreen runtime; the
|
||||
// out pointer is valid for the call.
|
||||
let result =
|
||||
unsafe { GetAvailableCoreWebView2BrowserVersionString(PCWSTR::null(), &mut version) };
|
||||
// Frees the CoTaskMem string; a null pointer yields "".
|
||||
let version = webview2_com::take_pwstr(version);
|
||||
match result {
|
||||
Ok(()) if !version.is_empty() => {
|
||||
log::info!("WebView2 runtime {version}");
|
||||
true
|
||||
}
|
||||
Ok(()) => false,
|
||||
Err(err) => {
|
||||
log::warn!("WebView2 runtime lookup failed: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a blocking error message box with no owner window.
|
||||
pub fn message_box_error(title: &str, text: &str) {
|
||||
// SAFETY: both strings outlive the call; no owner window is passed.
|
||||
let _ = unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
&HSTRING::from(text),
|
||||
&HSTRING::from(title),
|
||||
MB_OK | MB_ICONERROR | MB_SETFOREGROUND,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Register `aumid` so toasts from this portable exe are shown although no
|
||||
/// Start-menu shortcut carries the id: write
|
||||
/// `HKCU\Software\Classes\AppUserModelId\<aumid>` with `DisplayName` and
|
||||
/// `IconUri` (`icon_png` written to `icon_path`), then make it the process
|
||||
/// id. Each step is attempted; the first error is returned after all ran.
|
||||
pub fn register_aumid(
|
||||
aumid: &str,
|
||||
display_name: &str,
|
||||
icon_png: &[u8],
|
||||
icon_path: &Path,
|
||||
) -> io::Result<()> {
|
||||
let icon = write_if_changed(icon_path, icon_png);
|
||||
if let Err(err) = &icon {
|
||||
log::warn!("AUMID icon {} not written: {err}", icon_path.display());
|
||||
}
|
||||
|
||||
let key = windows_registry::CURRENT_USER
|
||||
.create(format!("{AUMID_KEY_PREFIX}{aumid}"))
|
||||
.and_then(|key| {
|
||||
key.set_string("DisplayName", display_name)?;
|
||||
if icon.is_ok() {
|
||||
key.set_string("IconUri", icon_path.to_string_lossy().as_ref())?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(io::Error::from);
|
||||
|
||||
// SAFETY: the HSTRING outlives the call.
|
||||
let process = unsafe { SetCurrentProcessExplicitAppUserModelID(&HSTRING::from(aumid)) }
|
||||
.map_err(io::Error::from);
|
||||
|
||||
key.and(process).and(icon)
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` unless it already holds exactly them.
|
||||
fn write_if_changed(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if std::fs::read(path).is_ok_and(|current| current == bytes) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, bytes)
|
||||
}
|
||||
|
||||
/// The current user's start-at-login entry, `HKCU\…\Run\<name>`. Its data
|
||||
/// is the quoted exe path, as every earlier release wrote it, so a path with
|
||||
/// spaces cannot resolve to a planted prefix. HKLM is never written, so an
|
||||
/// elevated run cannot leave an entry that a normal run cannot remove.
|
||||
/// `is_enabled` honours a Task Manager disable in `StartupApproved\Run`.
|
||||
pub fn autostart_entry(name: &str) -> io::Result<auto_launch::AutoLaunch> {
|
||||
let exe = std::env::current_exe()?;
|
||||
Ok(auto_launch::AutoLaunch::new(
|
||||
name,
|
||||
"ed_path(&exe),
|
||||
auto_launch::WindowsEnableMode::CurrentUser,
|
||||
&[] as &[&str],
|
||||
))
|
||||
}
|
||||
|
||||
/// `path` wrapped in double quotes, the form a `Run` value needs.
|
||||
fn quoted_path(path: &Path) -> String {
|
||||
format!("\"{}\"", path.display())
|
||||
}
|
||||
|
||||
/// The window handle of `window`.
|
||||
fn hwnd_of(window: &tauri::WebviewWindow) -> io::Result<HWND> {
|
||||
window
|
||||
.hwnd()
|
||||
.map_err(|err| io::Error::other(format!("no HWND for {}: {err}", window.label())))
|
||||
}
|
||||
|
||||
/// Make a bubble a tool window that never activates: no Alt-Tab entry and
|
||||
/// no focus taken on a click or a show. Run it before the first show.
|
||||
pub fn apply_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
add_ex_style(window, (WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW).0)
|
||||
}
|
||||
|
||||
/// Make the details panel a tool window, so it has no Alt-Tab entry
|
||||
/// (`skip_taskbar` only removes the taskbar button). It still activates:
|
||||
/// the panel takes focus so that losing it can dismiss the panel. Run it
|
||||
/// before the first show.
|
||||
pub fn apply_toolwindow(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
add_ex_style(window, WS_EX_TOOLWINDOW.0)
|
||||
}
|
||||
|
||||
/// OR `bits` into a window's extended style and apply the frame change.
|
||||
fn add_ex_style(window: &tauri::WebviewWindow, bits: u32) -> io::Result<()> {
|
||||
let hwnd = hwnd_of(window)?;
|
||||
let wanted = bits as isize;
|
||||
// SAFETY: `hwnd` is a live top-level window of this process.
|
||||
let current = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) };
|
||||
if current & wanted == wanted {
|
||||
return Ok(());
|
||||
}
|
||||
// SAFETY: as above. A zero return is only an error when the last error
|
||||
// is set, so it is cleared first.
|
||||
unsafe { SetLastError(WIN32_ERROR(0)) };
|
||||
let previous = unsafe { SetWindowLongPtrW(hwnd, GWL_EXSTYLE, current | wanted) };
|
||||
if previous == 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error().unwrap_or(0) != 0 {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
// Extended style changes take effect after a frame change.
|
||||
// SAFETY: as above; nothing moves, resizes or activates.
|
||||
unsafe {
|
||||
SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED,
|
||||
)
|
||||
}
|
||||
.map_err(io::Error::from)
|
||||
}
|
||||
|
||||
/// Ask DWM for rounded window corners. Only Windows 11 knows the
|
||||
/// attribute; Windows 10 returns an error and keeps square corners. DWM
|
||||
/// ignores a repeated identical value, so calling this on every show is
|
||||
/// cheap.
|
||||
pub fn round_corners(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
let hwnd = hwnd_of(window)?;
|
||||
let preference = DWMWCP_ROUND;
|
||||
// SAFETY: `hwnd` is a live window of this process; the pointer and size
|
||||
// describe `preference`, which outlives the call.
|
||||
unsafe {
|
||||
DwmSetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_WINDOW_CORNER_PREFERENCE,
|
||||
std::ptr::from_ref(&preference).cast(),
|
||||
std::mem::size_of::<DWM_WINDOW_CORNER_PREFERENCE>() as u32,
|
||||
)
|
||||
}
|
||||
.map_err(io::Error::from)
|
||||
}
|
||||
|
||||
/// Turn off WebView2's browser accelerator keys (F5, Ctrl+R, Ctrl+P,
|
||||
/// Ctrl+F, zoom, F12 and the like) for `window`'s webview, then reload it,
|
||||
/// because the setting applies from the next navigation. Call it only after
|
||||
/// the first navigation has finished, or the reload can replace that
|
||||
/// navigation. The work runs on the main thread; a failure inside the
|
||||
/// webview is logged there.
|
||||
pub fn disable_browser_accelerator_keys(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
let label = window.label().to_string();
|
||||
window
|
||||
.with_webview(move |webview| {
|
||||
// SAFETY: plain COM calls on the live controller Tauri handed
|
||||
// over, on the main thread that owns it.
|
||||
let result = unsafe {
|
||||
webview.controller().CoreWebView2().and_then(|core| {
|
||||
let settings: ICoreWebView2Settings3 = core.Settings()?.cast()?;
|
||||
settings.SetAreBrowserAcceleratorKeysEnabled(false)?;
|
||||
core.Reload()
|
||||
})
|
||||
};
|
||||
if let Err(err) = result {
|
||||
log::warn!("{label} keeps the browser accelerator keys: {err}");
|
||||
}
|
||||
})
|
||||
.map_err(|err| io::Error::other(format!("no webview for {}: {err}", window.label())))
|
||||
}
|
||||
|
||||
/// Show a bubble without activating it. Bubbles are shown and hidden only
|
||||
/// through this and [`hide`], never through Tauri's `show`/`hide`.
|
||||
pub fn show_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
let hwnd = hwnd_of(window)?;
|
||||
// SAFETY: `hwnd` is a live window of this process. The return value is
|
||||
// the previous visibility, not an error.
|
||||
let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hide a bubble (see [`show_no_activate`]).
|
||||
pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
let hwnd = hwnd_of(window)?;
|
||||
// SAFETY: as in `show_no_activate`.
|
||||
let _ = unsafe { ShowWindow(hwnd, SW_HIDE) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A bubble window message the app reacts to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BubbleHookEvent {
|
||||
/// An OS move loop ended (`WM_EXITSIZEMOVE`): the user dropped the bubble.
|
||||
MoveEnd,
|
||||
/// A system setting changed (`WM_SETTINGCHANGE`): theme, taskbar, work area.
|
||||
SettingChange,
|
||||
/// The display layout or resolution changed (`WM_DISPLAYCHANGE`).
|
||||
DisplayChange,
|
||||
}
|
||||
|
||||
type BubbleHookCallback = Box<dyn Fn(BubbleHookEvent) + Send + 'static>;
|
||||
|
||||
/// Subclass id of the bubble hook ("BUBB").
|
||||
const BUBBLE_SUBCLASS_ID: usize = 0x4255_4242;
|
||||
|
||||
/// Subclass a bubble window so `callback` hears about move ends, setting
|
||||
/// changes and display changes. The callback runs inside the window
|
||||
/// procedure on the main thread, after the default handling; it must not
|
||||
/// block, and should hand any Tauri work to another thread. The hook also
|
||||
/// answers `WM_MOUSEACTIVATE` with `MA_NOACTIVATE`, so a click never
|
||||
/// activates the bubble. Must be called on the main thread (the window's
|
||||
/// thread); the hook removes itself when the window is destroyed.
|
||||
pub fn install_bubble_hook(
|
||||
window: &tauri::WebviewWindow,
|
||||
callback: impl Fn(BubbleHookEvent) + Send + 'static,
|
||||
) -> io::Result<()> {
|
||||
let hwnd = hwnd_of(window)?;
|
||||
let boxed: Box<BubbleHookCallback> = Box::new(Box::new(callback));
|
||||
let data = Box::into_raw(boxed);
|
||||
// SAFETY: `hwnd` belongs to this thread; `data` stays valid until the
|
||||
// hook frees it on WM_NCDESTROY.
|
||||
let installed = unsafe {
|
||||
SetWindowSubclass(
|
||||
hwnd,
|
||||
Some(bubble_subclass_proc),
|
||||
BUBBLE_SUBCLASS_ID,
|
||||
data as usize,
|
||||
)
|
||||
};
|
||||
if installed.as_bool() {
|
||||
Ok(())
|
||||
} else {
|
||||
// SAFETY: the hook was not installed, so nothing else owns `data`.
|
||||
drop(unsafe { Box::from_raw(data) });
|
||||
Err(io::Error::other(format!(
|
||||
"SetWindowSubclass failed for {}",
|
||||
window.label()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn bubble_subclass_proc(
|
||||
hwnd: HWND,
|
||||
msg: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
_id: usize,
|
||||
data: usize,
|
||||
) -> LRESULT {
|
||||
match msg {
|
||||
WM_MOUSEACTIVATE => return LRESULT(MA_NOACTIVATE as isize),
|
||||
WM_NCDESTROY => {
|
||||
// SAFETY: the hook was installed on this window with this proc
|
||||
// and id; `data` is the box `install_bubble_hook` leaked, freed
|
||||
// exactly once here after the hook is gone.
|
||||
unsafe {
|
||||
let _ = RemoveWindowSubclass(hwnd, Some(bubble_subclass_proc), BUBBLE_SUBCLASS_ID);
|
||||
drop(Box::from_raw(data as *mut BubbleHookCallback));
|
||||
return DefSubclassProc(hwnd, msg, wparam, lparam);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// SAFETY: forwarding the message down the subclass chain.
|
||||
let result = unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) };
|
||||
let event = match msg {
|
||||
WM_EXITSIZEMOVE => Some(BubbleHookEvent::MoveEnd),
|
||||
WM_SETTINGCHANGE => Some(BubbleHookEvent::SettingChange),
|
||||
WM_DISPLAYCHANGE => Some(BubbleHookEvent::DisplayChange),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(event) = event {
|
||||
// SAFETY: `data` stays valid until WM_NCDESTROY, handled above.
|
||||
let callback = unsafe { &*(data as *const BubbleHookCallback) };
|
||||
callback(event);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The taskbar's docked edge and rect, when the shell reports one.
|
||||
pub fn taskbar() -> Option<(Edge, Rect)> {
|
||||
let mut data = APPBARDATA {
|
||||
cbSize: std::mem::size_of::<APPBARDATA>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `data` is a correctly sized APPBARDATA.
|
||||
if unsafe { SHAppBarMessage(ABM_GETTASKBARPOS, &mut data) } == 0 {
|
||||
return None;
|
||||
}
|
||||
let edge = match data.uEdge {
|
||||
ABE_LEFT => Edge::Left,
|
||||
ABE_TOP => Edge::Top,
|
||||
ABE_RIGHT => Edge::Right,
|
||||
ABE_BOTTOM => Edge::Bottom,
|
||||
_ => return None,
|
||||
};
|
||||
let rc = data.rc;
|
||||
Some((edge, Rect::new(rc.left, rc.top, rc.right, rc.bottom)))
|
||||
}
|
||||
|
||||
/// Bring `window` to the foreground. A popup menu needs its owner in the
|
||||
/// foreground, or it does not dismiss on a click elsewhere.
|
||||
pub fn set_foreground(window: &tauri::WebviewWindow) {
|
||||
match window.hwnd() {
|
||||
// SAFETY: the handle belongs to a live window of this process.
|
||||
Ok(hwnd) => {
|
||||
if !unsafe { SetForegroundWindow(hwnd) }.as_bool() {
|
||||
log::debug!("SetForegroundWindow({}) refused", window.label());
|
||||
}
|
||||
}
|
||||
Err(err) => log::warn!("no HWND for {}: {err}", window.label()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Fullscreen detection ----------
|
||||
|
||||
/// Shell windows that cover a monitor without being a fullscreen app.
|
||||
const IGNORED_FULLSCREEN_CLASSES: [&str; 3] = ["Progman", "WorkerW", "Shell_TrayWnd"];
|
||||
|
||||
struct FullscreenEvaluation {
|
||||
is_fullscreen: bool,
|
||||
hwnd: HWND,
|
||||
class_name: String,
|
||||
bounds: Option<Rect>,
|
||||
bounds_source: &'static str,
|
||||
monitor: Option<Rect>,
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
/// Whether the foreground window covers its whole monitor as a fullscreen
|
||||
/// app: not a window of this process (bubbles, panel, popup menu, WebView
|
||||
/// children), not a shell window, visible, not minimized or cloaked, its
|
||||
/// visible bounds within 2 px of the monitor rect and its style borderless
|
||||
/// or popup.
|
||||
pub fn foreground_is_fullscreen() -> super::FullscreenCheck {
|
||||
// SAFETY: plain FFI call with no arguments.
|
||||
let fg = unsafe { GetForegroundWindow() };
|
||||
let evaluation = evaluate_foreground_fullscreen(fg);
|
||||
super::FullscreenCheck {
|
||||
fullscreen: evaluation.is_fullscreen,
|
||||
reason: evaluation.reason,
|
||||
detail: format!(
|
||||
"fg=0x{:X} class={} bounds_source={} bounds={} monitor={}",
|
||||
evaluation.hwnd.0 as usize,
|
||||
if evaluation.class_name.is_empty() {
|
||||
"<unknown>"
|
||||
} else {
|
||||
evaluation.class_name.as_str()
|
||||
},
|
||||
evaluation.bounds_source,
|
||||
format_rect(evaluation.bounds.as_ref()),
|
||||
format_rect(evaluation.monitor.as_ref()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_foreground_fullscreen(fg: HWND) -> FullscreenEvaluation {
|
||||
let mut evaluation = FullscreenEvaluation {
|
||||
is_fullscreen: false,
|
||||
hwnd: fg,
|
||||
class_name: String::new(),
|
||||
bounds: None,
|
||||
bounds_source: "none",
|
||||
monitor: None,
|
||||
reason: "not evaluated",
|
||||
};
|
||||
|
||||
if fg.is_invalid() {
|
||||
evaluation.reason = "no foreground window";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
evaluation.class_name = window_class_name(fg);
|
||||
if window_process_id(fg) == Some(std::process::id()) {
|
||||
evaluation.reason = "foreground window is ours";
|
||||
return evaluation;
|
||||
}
|
||||
if is_ignored_fullscreen_class(&evaluation.class_name) {
|
||||
evaluation.reason = "ignored foreground class";
|
||||
return evaluation;
|
||||
}
|
||||
// SAFETY: plain queries on a window handle; a stale one returns false.
|
||||
if unsafe { !IsWindowVisible(fg).as_bool() || IsIconic(fg).as_bool() } {
|
||||
evaluation.reason = "foreground invisible or minimized";
|
||||
return evaluation;
|
||||
}
|
||||
if is_dwm_cloaked(fg) {
|
||||
evaluation.reason = "foreground cloaked";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
let Some((bounds, source)) = visible_window_bounds(fg) else {
|
||||
evaluation.reason = "foreground bounds unavailable";
|
||||
return evaluation;
|
||||
};
|
||||
evaluation.bounds = Some(bounds);
|
||||
evaluation.bounds_source = source;
|
||||
|
||||
// SAFETY: as above; NEAREST always yields a monitor for a live window.
|
||||
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
|
||||
if monitor.is_invalid() {
|
||||
evaluation.reason = "foreground monitor unavailable";
|
||||
return evaluation;
|
||||
}
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `info` is a correctly sized MONITORINFO.
|
||||
if unsafe { !GetMonitorInfoW(monitor, &mut info).as_bool() } {
|
||||
evaluation.reason = "foreground monitor info unavailable";
|
||||
return evaluation;
|
||||
}
|
||||
let monitor = rect_from(&info.rcMonitor);
|
||||
evaluation.monitor = Some(monitor);
|
||||
|
||||
if !geom::rect_covers_monitor(&bounds, &monitor) {
|
||||
evaluation.reason = "visible bounds do not cover monitor";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
if !window_style_allows_fullscreen(fg) {
|
||||
evaluation.reason = "standard framed window";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
evaluation.is_fullscreen = true;
|
||||
evaluation.reason = "visible bounds cover monitor";
|
||||
evaluation
|
||||
}
|
||||
|
||||
/// The id of the process that created `hwnd`; `None` for a stale handle.
|
||||
fn window_process_id(hwnd: HWND) -> Option<u32> {
|
||||
let mut pid = 0u32;
|
||||
// SAFETY: `pid` outlives the call; a stale handle returns thread id 0.
|
||||
let thread = unsafe { GetWindowThreadProcessId(hwnd, Some(std::ptr::from_mut(&mut pid))) };
|
||||
(thread != 0).then_some(pid)
|
||||
}
|
||||
|
||||
/// The visible bounds of `hwnd`: the DWM extended frame (without the
|
||||
/// invisible resize borders), else the window rect.
|
||||
fn visible_window_bounds(hwnd: HWND) -> Option<(Rect, &'static str)> {
|
||||
let mut rect = RECT::default();
|
||||
// SAFETY: the pointer and size describe `rect`, which outlives the call.
|
||||
let dwm_ok = unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
std::ptr::from_mut(&mut rect).cast(),
|
||||
std::mem::size_of::<RECT>() as u32,
|
||||
)
|
||||
.is_ok()
|
||||
};
|
||||
if dwm_ok && !rect_is_empty(&rect) {
|
||||
return Some((rect_from(&rect), "dwm-extended-frame"));
|
||||
}
|
||||
|
||||
// SAFETY: as above.
|
||||
if unsafe { GetWindowRect(hwnd, &mut rect) }.is_err() {
|
||||
return None;
|
||||
}
|
||||
if rect_is_empty(&rect) {
|
||||
None
|
||||
} else {
|
||||
Some((rect_from(&rect), "window-rect"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether DWM cloaks `hwnd` (a suspended UWP app or a window on another
|
||||
/// virtual desktop).
|
||||
fn is_dwm_cloaked(hwnd: HWND) -> bool {
|
||||
let mut cloaked = 0u32;
|
||||
// SAFETY: the pointer and size describe `cloaked`, which outlives the call.
|
||||
unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_CLOAKED,
|
||||
std::ptr::from_mut(&mut cloaked).cast(),
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
)
|
||||
.is_ok()
|
||||
&& cloaked != 0
|
||||
}
|
||||
}
|
||||
|
||||
fn window_class_name(hwnd: HWND) -> String {
|
||||
let mut buf = [0u16; 256];
|
||||
// SAFETY: the slice bounds the write.
|
||||
let len = unsafe { GetClassNameW(hwnd, &mut buf) };
|
||||
match usize::try_from(len) {
|
||||
Ok(len) if len > 0 => String::from_utf16_lossy(&buf[..len]),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ignored_fullscreen_class(class_name: &str) -> bool {
|
||||
IGNORED_FULLSCREEN_CLASSES
|
||||
.iter()
|
||||
.any(|ignored| class_name.eq_ignore_ascii_case(ignored))
|
||||
}
|
||||
|
||||
fn window_style_allows_fullscreen(hwnd: HWND) -> bool {
|
||||
// SAFETY: a zero style is only an error when the last error is set,
|
||||
// so it is cleared first.
|
||||
unsafe { SetLastError(WIN32_ERROR(0)) };
|
||||
let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) };
|
||||
if style == 0 && unsafe { GetLastError() } != WIN32_ERROR(0) {
|
||||
return false;
|
||||
}
|
||||
// The style is a 32-bit value in a pointer-sized return.
|
||||
geom::window_style_bits_allow_fullscreen(style as u32)
|
||||
}
|
||||
|
||||
fn rect_from(rect: &RECT) -> Rect {
|
||||
Rect::new(rect.left, rect.top, rect.right, rect.bottom)
|
||||
}
|
||||
|
||||
fn rect_is_empty(rect: &RECT) -> bool {
|
||||
rect.right <= rect.left || rect.bottom <= rect.top
|
||||
}
|
||||
|
||||
fn format_rect(rect: Option<&Rect>) -> String {
|
||||
match rect {
|
||||
Some(r) => format!("({},{} {}x{})", r.l, r.t, r.r - r.l, r.b - r.t),
|
||||
None => String::from("<none>"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shell_classes_are_ignored_case_insensitively() {
|
||||
assert!(is_ignored_fullscreen_class("Progman"));
|
||||
assert!(is_ignored_fullscreen_class("workerw"));
|
||||
assert!(is_ignored_fullscreen_class("SHELL_TRAYWND"));
|
||||
assert!(!is_ignored_fullscreen_class("Chrome_WidgetWin_1"));
|
||||
assert!(!is_ignored_fullscreen_class(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_window_has_no_process() {
|
||||
assert_eq!(window_process_id(HWND::default()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_value_path_is_quoted() {
|
||||
assert_eq!(
|
||||
quoted_path(Path::new(r"D:\My Tools\claude-code-usage-bubble.exe")),
|
||||
r#""D:\My Tools\claude-code-usage-bubble.exe""#
|
||||
);
|
||||
}
|
||||
}
|
||||
+1150
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,475 @@
|
||||
//! The single timer thread. It owns the poll, countdown and update-check
|
||||
//! deadlines and is driven by messages over an `mpsc` channel.
|
||||
//!
|
||||
//! The deadline rules live in [`Deadlines`], which takes the current time as
|
||||
//! an argument and so is tested without a thread, a window or a clock. The
|
||||
//! 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.
|
||||
|
||||
use std::io;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::core::state::{update_check_due, UpdateCheckDue};
|
||||
|
||||
/// Shortest poll interval, guarding against a hand-edited `0` in the
|
||||
/// settings file turning into a busy loop.
|
||||
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);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
/// Poll at once. The periodic poll deadline is left as it is.
|
||||
PollNow,
|
||||
/// Change the poll interval and re-arm it from now, without polling.
|
||||
SetPollInterval(Duration),
|
||||
/// Re-render the countdowns after this delay; `None` clears the deadline.
|
||||
RecomputeCountdown(Option<Duration>),
|
||||
/// The auto-check interval or its last check changed: check now if one
|
||||
/// is due, otherwise wait for the remainder. `interval_secs: None`
|
||||
/// cancels the deadline.
|
||||
SetUpdateInterval {
|
||||
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> },
|
||||
/// Stop the thread.
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Poll,
|
||||
Countdown,
|
||||
CheckForUpdates,
|
||||
}
|
||||
|
||||
/// Receives the due actions on the scheduler thread.
|
||||
pub trait SchedulerHost {
|
||||
fn run(&self, action: Action);
|
||||
}
|
||||
|
||||
/// Settings the deadlines start from.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Config {
|
||||
pub poll_interval: Duration,
|
||||
pub update_interval_secs: Option<u64>,
|
||||
pub last_update_check_unix: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Deadlines {
|
||||
poll_interval: Duration,
|
||||
poll_at: Instant,
|
||||
countdown_at: Option<Instant>,
|
||||
update_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Deadlines {
|
||||
/// Arm the deadlines at startup. Returns the actions due at once: the
|
||||
/// update check when the last one is older than its interval.
|
||||
pub fn new(config: Config, now: Instant, now_unix: u64) -> (Self, Vec<Action>) {
|
||||
let poll_interval = config.poll_interval.max(MIN_POLL_INTERVAL);
|
||||
let mut deadlines = Self {
|
||||
poll_interval,
|
||||
poll_at: now + poll_interval,
|
||||
countdown_at: None,
|
||||
update_at: None,
|
||||
};
|
||||
let actions = deadlines.arm_update(
|
||||
config.update_interval_secs,
|
||||
config.last_update_check_unix,
|
||||
now,
|
||||
now_unix,
|
||||
);
|
||||
(deadlines, actions)
|
||||
}
|
||||
|
||||
/// Apply one message. Returns the actions to run at once.
|
||||
pub fn handle(&mut self, message: Message, now: Instant, now_unix: u64) -> Vec<Action> {
|
||||
match message {
|
||||
Message::PollNow => vec![Action::Poll],
|
||||
Message::SetPollInterval(interval) => {
|
||||
self.poll_interval = interval.max(MIN_POLL_INTERVAL);
|
||||
self.poll_at = now + self.poll_interval;
|
||||
Vec::new()
|
||||
}
|
||||
Message::RecomputeCountdown(delay) => {
|
||||
self.countdown_at = delay.map(|d| now + d);
|
||||
Vec::new()
|
||||
}
|
||||
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));
|
||||
Vec::new()
|
||||
}
|
||||
Message::Shutdown => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire every deadline that has passed. The poll re-arms for a full
|
||||
/// interval; the countdown and update check stay disarmed until the
|
||||
/// host re-arms them (after the re-render, after the check finished).
|
||||
pub fn take_due(&mut self, now: Instant) -> Vec<Action> {
|
||||
let mut due = Vec::new();
|
||||
if self.poll_at <= now {
|
||||
due.push(Action::Poll);
|
||||
self.poll_at = now + self.poll_interval;
|
||||
}
|
||||
if self.countdown_at.is_some_and(|at| at <= now) {
|
||||
due.push(Action::Countdown);
|
||||
self.countdown_at = None;
|
||||
}
|
||||
if self.update_at.is_some_and(|at| at <= now) {
|
||||
due.push(Action::CheckForUpdates);
|
||||
self.update_at = None;
|
||||
}
|
||||
due
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
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;
|
||||
Vec::new()
|
||||
}
|
||||
Some(UpdateCheckDue::Now) => {
|
||||
self.update_at = None;
|
||||
vec![Action::CheckForUpdates]
|
||||
}
|
||||
Some(UpdateCheckDue::After(remaining)) => {
|
||||
self.update_at = Some(now + remaining);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_interval(secs: u64) -> Duration {
|
||||
Duration::from_secs(secs).max(MIN_UPDATE_INTERVAL)
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch; 0 for a clock set before 1970.
|
||||
pub fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Start the scheduler thread reading `rx`. Create the channel first, so
|
||||
/// the sender exists before the host runs its first action.
|
||||
/// [`Message::Shutdown`] or dropping every sender stops the thread.
|
||||
pub fn spawn<H>(config: Config, rx: Receiver<Message>, host: H) -> io::Result<JoinHandle<()>>
|
||||
where
|
||||
H: SchedulerHost + Send + 'static,
|
||||
{
|
||||
std::thread::Builder::new()
|
||||
.name("scheduler".into())
|
||||
.spawn(move || {
|
||||
let (deadlines, initial) = Deadlines::new(config, Instant::now(), unix_now());
|
||||
for action in initial {
|
||||
host.run(action);
|
||||
}
|
||||
run_loop(deadlines, &rx, &host);
|
||||
log::info!("scheduler stopped");
|
||||
})
|
||||
}
|
||||
|
||||
fn run_loop<H: SchedulerHost>(mut deadlines: Deadlines, rx: &Receiver<Message>, host: &H) {
|
||||
loop {
|
||||
for action in deadlines.take_due(Instant::now()) {
|
||||
host.run(action);
|
||||
}
|
||||
let message = match rx.recv_timeout(deadlines.next_wakeup(Instant::now())) {
|
||||
Ok(Message::Shutdown) | Err(RecvTimeoutError::Disconnected) => return,
|
||||
Ok(message) => message,
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
};
|
||||
for action in deadlines.handle(message, Instant::now(), unix_now()) {
|
||||
host.run(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc::{self, Sender};
|
||||
|
||||
use crate::core::settings::{
|
||||
UPDATE_CHECK_DAILY_SECS, UPDATE_CHECK_HOURLY_SECS, UPDATE_CHECK_WEEKLY_SECS,
|
||||
};
|
||||
|
||||
const MIN: Duration = Duration::from_secs(60);
|
||||
const NOW_UNIX: u64 = 1_800_000_000;
|
||||
const DAY: u64 = 24 * 60 * 60;
|
||||
|
||||
fn config(update: Option<u64>, last: Option<u64>) -> Config {
|
||||
Config {
|
||||
poll_interval: 5 * MIN,
|
||||
update_interval_secs: update,
|
||||
last_update_check_unix: last,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_fires_every_interval() {
|
||||
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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_now_polls_without_moving_the_periodic_deadline() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
assert_eq!(
|
||||
d.handle(Message::PollNow, t0 + 2 * MIN, NOW_UNIX),
|
||||
[Action::Poll]
|
||||
);
|
||||
assert_eq!(d.take_due(t0 + 5 * MIN), [Action::Poll]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_poll_interval_rearms_from_now_without_polling() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
let t1 = t0 + 4 * MIN;
|
||||
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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_poll_interval_is_floored() {
|
||||
let t0 = Instant::now();
|
||||
let mut cfg = config(None, None);
|
||||
cfg.poll_interval = Duration::ZERO;
|
||||
let (d, _) = Deadlines::new(cfg, t0, NOW_UNIX);
|
||||
assert_eq!(d.next_wakeup(t0), MIN_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn countdown_fires_once_until_rearmed() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
let 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());
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_checks_for_updates_when_due() {
|
||||
let t0 = Instant::now();
|
||||
let hourly = Some(UPDATE_CHECK_HOURLY_SECS);
|
||||
let (_, never_checked) = Deadlines::new(config(hourly, None), t0, NOW_UNIX);
|
||||
assert_eq!(never_checked, [Action::CheckForUpdates]);
|
||||
let (_, stale) = Deadlines::new(config(hourly, Some(NOW_UNIX - 2 * 3_600)), t0, NOW_UNIX);
|
||||
assert_eq!(stale, [Action::CheckForUpdates]);
|
||||
let (_, disabled) = Deadlines::new(config(None, None), t0, NOW_UNIX);
|
||||
assert!(disabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_waits_for_the_remainder_when_not_due() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, initial) = Deadlines::new(
|
||||
config(Some(UPDATE_CHECK_HOURLY_SECS), Some(NOW_UNIX - 600)),
|
||||
t0,
|
||||
NOW_UNIX,
|
||||
);
|
||||
assert!(initial.is_empty());
|
||||
let remaining = Duration::from_secs(3_000);
|
||||
assert!(!d
|
||||
.take_due(t0 + remaining - MIN)
|
||||
.contains(&Action::CheckForUpdates));
|
||||
assert_eq!(d.next_wakeup(t0 + remaining - MIN), MIN);
|
||||
assert_eq!(d.take_due(t0 + remaining), [Action::CheckForUpdates]);
|
||||
}
|
||||
|
||||
#[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);
|
||||
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));
|
||||
|
||||
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));
|
||||
// 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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_to_daily_with_the_last_check_two_days_ago_checks_at_once() {
|
||||
let t0 = Instant::now();
|
||||
let last = Some(NOW_UNIX - 2 * DAY);
|
||||
let (mut d, initial) =
|
||||
Deadlines::new(config(Some(UPDATE_CHECK_WEEKLY_SECS), last), t0, NOW_UNIX);
|
||||
assert!(initial.is_empty());
|
||||
assert_eq!(
|
||||
d.handle(
|
||||
Message::SetUpdateInterval {
|
||||
interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
|
||||
last_check_unix: last,
|
||||
},
|
||||
t0,
|
||||
NOW_UNIX,
|
||||
),
|
||||
[Action::CheckForUpdates]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_to_weekly_waits_for_the_new_remainder() {
|
||||
let t0 = Instant::now();
|
||||
let last = Some(NOW_UNIX - 2 * DAY);
|
||||
let (mut d, _) = Deadlines::new(config(Some(UPDATE_CHECK_HOURLY_SECS), last), t0, NOW_UNIX);
|
||||
let actions = d.handle(
|
||||
Message::SetUpdateInterval {
|
||||
interval_secs: Some(UPDATE_CHECK_WEEKLY_SECS),
|
||||
last_check_unix: last,
|
||||
},
|
||||
t0,
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_auto_check_cancels_the_deadline() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _) = Deadlines::new(
|
||||
config(Some(UPDATE_CHECK_HOURLY_SECS), Some(NOW_UNIX - 600)),
|
||||
t0,
|
||||
NOW_UNIX,
|
||||
);
|
||||
d.handle(
|
||||
Message::SetUpdateInterval {
|
||||
interval_secs: None,
|
||||
last_check_unix: Some(NOW_UNIX - 600),
|
||||
},
|
||||
t0,
|
||||
NOW_UNIX,
|
||||
);
|
||||
assert!(!d
|
||||
.take_due(t0 + Duration::from_secs(7 * DAY))
|
||||
.contains(&Action::CheckForUpdates));
|
||||
// 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));
|
||||
}
|
||||
|
||||
/// Records actions on a channel, standing in for the runtime.
|
||||
struct FakeHost(Sender<Action>);
|
||||
|
||||
impl SchedulerHost for FakeHost {
|
||||
fn run(&self, action: Action) {
|
||||
let _ = self.0.send(action);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_runs_actions_and_stops_on_shutdown() {
|
||||
let (action_tx, action_rx) = mpsc::channel();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let handle = spawn(
|
||||
config(Some(UPDATE_CHECK_HOURLY_SECS), None),
|
||||
rx,
|
||||
FakeHost(action_tx),
|
||||
)
|
||||
.expect("spawn scheduler");
|
||||
let wait = Duration::from_secs(5);
|
||||
assert_eq!(action_rx.recv_timeout(wait), Ok(Action::CheckForUpdates));
|
||||
tx.send(Message::PollNow).expect("send");
|
||||
assert_eq!(action_rx.recv_timeout(wait), Ok(Action::Poll));
|
||||
tx.send(Message::RecomputeCountdown(Some(Duration::from_millis(20))))
|
||||
.expect("send");
|
||||
assert_eq!(action_rx.recv_timeout(wait), Ok(Action::Countdown));
|
||||
tx.send(Message::Shutdown).expect("send");
|
||||
handle.join().expect("join scheduler");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_stops_when_every_sender_is_dropped() {
|
||||
let (action_tx, _action_rx) = mpsc::channel();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let handle = spawn(config(None, None), rx, FakeHost(action_tx)).expect("spawn");
|
||||
drop(tx);
|
||||
handle.join().expect("join scheduler");
|
||||
}
|
||||
}
|
||||
-295
@@ -1,295 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windows::Win32::Foundation::RECT;
|
||||
use windows::Win32::Graphics::Gdi::{MonitorFromRect, MONITOR_DEFAULTTONULL};
|
||||
|
||||
use crate::bubble::DEFAULT_BUBBLE_SIZE;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
// 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area
|
||||
// edge still passes the validator, but a position fully on a disconnected
|
||||
// monitor (the bug we're guarding against) fails.
|
||||
const POSITION_PROBE_PX: i32 = 140;
|
||||
|
||||
const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble";
|
||||
const SETTINGS_FILE: &str = "settings.json";
|
||||
|
||||
pub const POLL_1_MIN: u32 = 60_000;
|
||||
pub const POLL_5_MIN: u32 = 5 * 60_000;
|
||||
pub const POLL_15_MIN: u32 = 15 * 60_000;
|
||||
pub const POLL_1_HOUR: u32 = 60 * 60_000;
|
||||
|
||||
// Update-check intervals (seconds). `None` means auto-check is disabled.
|
||||
pub const UPDATE_CHECK_HOURLY_SECS: u64 = 60 * 60;
|
||||
pub const UPDATE_CHECK_DAILY_SECS: u64 = 24 * 60 * 60;
|
||||
pub const UPDATE_CHECK_WEEKLY_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
|
||||
fn default_show_claude() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_show_codex() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_show_opencode_go() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_widget_visible() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_bubble_size() -> i32 {
|
||||
DEFAULT_BUBBLE_SIZE
|
||||
}
|
||||
fn default_poll_interval_ms() -> u32 {
|
||||
POLL_5_MIN
|
||||
}
|
||||
fn default_update_check_interval_secs() -> Option<u64> {
|
||||
Some(UPDATE_CHECK_HOURLY_SECS)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BubblePositions {
|
||||
pub claude: Option<(i32, i32)>,
|
||||
pub codex: Option<(i32, i32)>,
|
||||
pub opencode_go: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
impl BubblePositions {
|
||||
pub fn get(&self, provider: ProviderId) -> Option<(i32, i32)> {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude,
|
||||
ProviderId::ChatGpt => self.codex,
|
||||
ProviderId::OpenCodeGo => self.opencode_go,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude = Some(pos),
|
||||
ProviderId::ChatGpt => self.codex = Some(pos),
|
||||
ProviderId::OpenCodeGo => self.opencode_go = Some(pos),
|
||||
}
|
||||
}
|
||||
pub fn reset(&mut self, provider: ProviderId) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude = None,
|
||||
ProviderId::ChatGpt => self.codex = None,
|
||||
ProviderId::OpenCodeGo => self.opencode_go = None,
|
||||
}
|
||||
}
|
||||
pub fn reset_all(&mut self) {
|
||||
self.claude = None;
|
||||
self.codex = None;
|
||||
self.opencode_go = None;
|
||||
}
|
||||
|
||||
/// Drop any saved position whose top-left no longer falls on a connected
|
||||
/// monitor. Guards against `bubble::create` placing the window on a
|
||||
/// disconnected secondary monitor (where the user can't see or recover it).
|
||||
pub fn validate(&mut self) {
|
||||
if let Some((x, y)) = self.claude {
|
||||
if !position_on_any_monitor(x, y) {
|
||||
log::warn!(
|
||||
"bubble position claude ({x},{y}) outside all monitors; resetting to default"
|
||||
);
|
||||
self.claude = None;
|
||||
}
|
||||
}
|
||||
if let Some((x, y)) = self.codex {
|
||||
if !position_on_any_monitor(x, y) {
|
||||
log::warn!(
|
||||
"bubble position codex ({x},{y}) outside all monitors; resetting to default"
|
||||
);
|
||||
self.codex = None;
|
||||
}
|
||||
}
|
||||
if let Some((x, y)) = self.opencode_go {
|
||||
if !position_on_any_monitor(x, y) {
|
||||
log::warn!("bubble position opencode-go ({x},{y}) outside all monitors; resetting to default");
|
||||
self.opencode_go = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn position_on_any_monitor(x: i32, y: i32) -> bool {
|
||||
// MONITOR_DEFAULTTONULL returns a null HMONITOR when the rect intersects
|
||||
// no connected monitor — exactly the signal we want.
|
||||
let probe = RECT {
|
||||
left: x,
|
||||
top: y,
|
||||
right: x + POSITION_PROBE_PX,
|
||||
bottom: y + POSITION_PROBE_PX,
|
||||
};
|
||||
let monitor = unsafe { MonitorFromRect(&probe, MONITOR_DEFAULTTONULL) };
|
||||
!monitor.is_invalid()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default = "default_show_claude")]
|
||||
pub show_claude_code: bool,
|
||||
#[serde(default = "default_show_codex")]
|
||||
pub show_codex: bool,
|
||||
#[serde(default = "default_show_opencode_go")]
|
||||
pub show_opencode_go: bool,
|
||||
#[serde(default)]
|
||||
pub bubble_positions: BubblePositions,
|
||||
#[serde(default = "default_bubble_size")]
|
||||
pub bubble_size_logical: i32,
|
||||
#[serde(default = "default_poll_interval_ms")]
|
||||
pub poll_interval_ms: u32,
|
||||
#[serde(default)]
|
||||
pub last_update_check_unix: Option<u64>,
|
||||
#[serde(default = "default_update_check_interval_secs")]
|
||||
pub update_check_interval_secs: Option<u64>,
|
||||
#[serde(default = "default_widget_visible")]
|
||||
pub widget_visible: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_claude_code: default_show_claude(),
|
||||
show_codex: default_show_codex(),
|
||||
show_opencode_go: default_show_opencode_go(),
|
||||
bubble_positions: BubblePositions::default(),
|
||||
bubble_size_logical: default_bubble_size(),
|
||||
poll_interval_ms: default_poll_interval_ms(),
|
||||
last_update_check_unix: None,
|
||||
update_check_interval_secs: default_update_check_interval_secs(),
|
||||
widget_visible: default_widget_visible(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn is_provider_enabled(&self, provider: ProviderId) -> bool {
|
||||
match provider {
|
||||
ProviderId::Claude => self.show_claude_code,
|
||||
ProviderId::ChatGpt => self.show_codex,
|
||||
ProviderId::OpenCodeGo => self.show_opencode_go,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.show_claude_code = enabled,
|
||||
ProviderId::ChatGpt => self.show_codex = enabled,
|
||||
ProviderId::OpenCodeGo => self.show_opencode_go = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_provider(&mut self, provider: ProviderId) {
|
||||
let enabled = !self.is_provider_enabled(provider);
|
||||
self.set_provider_enabled(provider, enabled);
|
||||
}
|
||||
|
||||
pub fn has_enabled_live_provider(&self) -> bool {
|
||||
ProviderId::LIVE_USAGE
|
||||
.iter()
|
||||
.any(|provider| self.is_provider_enabled(*provider))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join(APP_DIR_NAME))
|
||||
}
|
||||
|
||||
pub fn settings_path() -> PathBuf {
|
||||
settings_dir()
|
||||
.unwrap_or_else(|| std::env::temp_dir().join(APP_DIR_NAME))
|
||||
.join(SETTINGS_FILE)
|
||||
}
|
||||
|
||||
pub fn load() -> Settings {
|
||||
let path = settings_path();
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Settings::default(),
|
||||
};
|
||||
let mut settings: Settings = serde_json::from_str(&content).unwrap_or_default();
|
||||
// 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;
|
||||
}
|
||||
// Clamp bubble size to safe range in case settings.json was hand-edited.
|
||||
settings.bubble_size_logical = settings.bubble_size_logical.clamp(
|
||||
crate::bubble::MIN_BUBBLE_SIZE,
|
||||
crate::bubble::MAX_BUBBLE_SIZE,
|
||||
);
|
||||
// Drop positions on monitors that have since been disconnected.
|
||||
settings.bubble_positions.validate();
|
||||
settings
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn old_two_provider_settings_json_still_loads() {
|
||||
let json = r#"{
|
||||
"show_claude_code": false,
|
||||
"show_codex": true,
|
||||
"bubble_positions": {
|
||||
"claude": [10, 20],
|
||||
"codex": [30, 40]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let settings: Settings =
|
||||
serde_json::from_str(json).expect("old settings json should parse");
|
||||
|
||||
assert!(!settings.show_claude_code);
|
||||
assert!(settings.show_codex);
|
||||
assert!(!settings.show_opencode_go);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::Claude),
|
||||
Some((10, 20))
|
||||
);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::ChatGpt),
|
||||
Some((30, 40))
|
||||
);
|
||||
assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_provider_settings_json_loads() {
|
||||
let json = r#"{
|
||||
"show_claude_code": true,
|
||||
"show_codex": false,
|
||||
"show_opencode_go": true,
|
||||
"bubble_positions": {
|
||||
"opencode_go": [50, 60]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let settings: Settings =
|
||||
serde_json::from_str(json).expect("new settings json should parse");
|
||||
|
||||
assert!(settings.show_claude_code);
|
||||
assert!(!settings.show_codex);
|
||||
assert!(settings.show_opencode_go);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::OpenCodeGo),
|
||||
Some((50, 60))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(settings: &Settings) {
|
||||
let path = settings_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let Ok(json) = serde_json::to_string_pretty(settings) else {
|
||||
return;
|
||||
};
|
||||
// Atomic write: tmp then rename. Falls back to direct write on rename failure.
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp_path, &json).is_ok() && std::fs::rename(&tmp_path, &path).is_ok() {
|
||||
return;
|
||||
}
|
||||
let _ = std::fs::write(&path, json);
|
||||
}
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
//! One tray icon per enabled live provider, with a usage badge, a
|
||||
//! four-line tooltip and the context menu. A left click toggles the widget.
|
||||
//!
|
||||
//! [`Trays::sync`] runs on the main thread with owned [`TrayView`]s; the
|
||||
//! state lock is never held while a tray is touched.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use tauri::image::Image;
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Manager, Wry};
|
||||
|
||||
use crate::badge;
|
||||
use crate::core::i18n::LocaleStrings;
|
||||
use crate::core::menu_model::MenuEntry;
|
||||
use crate::core::state::{tray_tooltip, UsageState};
|
||||
use crate::menu;
|
||||
use crate::runtime::Runtime;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
/// The tooltip limit of the Windows notification area (`szTip` holds 128
|
||||
/// UTF-16 units including the terminator).
|
||||
pub const TOOLTIP_MAX_UTF16: usize = 127;
|
||||
|
||||
/// What one provider's tray icon shows.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrayView {
|
||||
pub provider: ProviderId,
|
||||
/// Session usage, or `None` (track only) until a poll succeeded.
|
||||
pub percent: Option<f64>,
|
||||
pub tooltip: String,
|
||||
}
|
||||
|
||||
/// The tray icons for the enabled live providers, in display order. The
|
||||
/// badge shows usage only while the last poll succeeded, as before.
|
||||
pub fn views(enabled: &[ProviderId], usage: &UsageState, strings: &LocaleStrings) -> Vec<TrayView> {
|
||||
enabled
|
||||
.iter()
|
||||
.filter(|p| p.metadata().live_usage)
|
||||
.map(|&provider| {
|
||||
let entry = usage.snapshots.get(&provider);
|
||||
TrayView {
|
||||
provider,
|
||||
percent: if usage.last_poll_ok {
|
||||
entry.map(|e| e.windows.primary.utilization)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tooltip: truncate_utf16(
|
||||
&tray_tooltip(strings.provider_label(provider), entry, strings),
|
||||
TOOLTIP_MAX_UTF16,
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Cut `text` to at most `max` UTF-16 units without splitting a character.
|
||||
pub fn truncate_utf16(text: &str, max: usize) -> String {
|
||||
let mut units = 0;
|
||||
let mut end = 0;
|
||||
for (i, c) in text.char_indices() {
|
||||
units += c.len_utf16();
|
||||
if units > max {
|
||||
break;
|
||||
}
|
||||
end = i + c.len_utf8();
|
||||
}
|
||||
text[..end].to_string()
|
||||
}
|
||||
|
||||
/// What an icon currently shows, so unchanged icons and tooltips are not
|
||||
/// pushed again.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Shown {
|
||||
/// Percent bits and fill colour of the drawn badge.
|
||||
badge: (Option<u64>, [u8; 3]),
|
||||
tooltip: String,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
fn of(view: &TrayView) -> Self {
|
||||
let colour = view
|
||||
.percent
|
||||
.map(|p| {
|
||||
let c = crate::core::usage_color::bar_fill_color(view.provider, true, p);
|
||||
[c.r, c.g, c.b]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
badge: (view.percent.map(f64::to_bits), colour),
|
||||
tooltip: view.tooltip.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The diff between the icons that exist and the ones wanted.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Plan {
|
||||
remove: Vec<ProviderId>,
|
||||
create: Vec<ProviderId>,
|
||||
/// Existing icons with `(new badge?, new tooltip?)`.
|
||||
update: Vec<(ProviderId, bool, bool)>,
|
||||
}
|
||||
|
||||
fn plan(shown: &HashMap<ProviderId, Shown>, views: &[TrayView]) -> Plan {
|
||||
let mut plan = Plan {
|
||||
remove: ProviderId::LIVE_USAGE
|
||||
.into_iter()
|
||||
.filter(|p| shown.contains_key(p) && !views.iter().any(|v| v.provider == *p))
|
||||
.collect(),
|
||||
..Plan::default()
|
||||
};
|
||||
for view in views {
|
||||
let next = Shown::of(view);
|
||||
match shown.get(&view.provider) {
|
||||
None => plan.create.push(view.provider),
|
||||
Some(current) => {
|
||||
let badge = current.badge != next.badge;
|
||||
let tooltip = current.tooltip != next.tooltip;
|
||||
if badge || tooltip {
|
||||
plan.update.push((view.provider, badge, tooltip));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
/// The tray icons this process created and what they show.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Trays {
|
||||
shown: Mutex<HashMap<ProviderId, Shown>>,
|
||||
}
|
||||
|
||||
impl Trays {
|
||||
fn shown(&self) -> HashMap<ProviderId, Shown> {
|
||||
self.shown
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn record(&self, provider: ProviderId, shown: Option<Shown>) {
|
||||
let mut map = self.shown.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match shown {
|
||||
Some(s) => map.insert(provider, s),
|
||||
None => map.remove(&provider),
|
||||
};
|
||||
}
|
||||
|
||||
/// The providers that currently have an icon.
|
||||
pub fn providers(&self) -> Vec<ProviderId> {
|
||||
let shown = self.shown();
|
||||
ProviderId::LIVE_USAGE
|
||||
.into_iter()
|
||||
.filter(|p| shown.contains_key(p))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bring the icons in line with `views`: remove the ones no longer
|
||||
/// wanted, create new ones with a menu from `entries`, and push a badge
|
||||
/// or tooltip only when it changed. Main thread only. On Linux nothing
|
||||
/// happens without an AppIndicator library.
|
||||
pub fn sync(&self, app: &AppHandle, views: &[TrayView], entries: &[MenuEntry]) {
|
||||
if !tray_supported() {
|
||||
return;
|
||||
}
|
||||
let plan = plan(&self.shown(), views);
|
||||
for provider in plan.remove {
|
||||
app.remove_tray_by_id(provider.slug());
|
||||
self.record(provider, None);
|
||||
}
|
||||
for view in views {
|
||||
if plan.create.contains(&view.provider) {
|
||||
match create(app, view, entries) {
|
||||
Ok(_) => self.record(view.provider, Some(Shown::of(view))),
|
||||
Err(err) => log::error!(
|
||||
"tray icon for {} could not be created: {err}",
|
||||
view.provider.slug()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
for (provider, badge_changed, tooltip_changed) in plan.update {
|
||||
let Some(view) = views.iter().find(|v| v.provider == provider) else {
|
||||
continue;
|
||||
};
|
||||
let Some(tray) = app.tray_by_id(provider.slug()) else {
|
||||
self.record(provider, None);
|
||||
continue;
|
||||
};
|
||||
let mut ok = true;
|
||||
if badge_changed {
|
||||
if let Err(err) = tray.set_icon(Some(badge_image(view))) {
|
||||
log::warn!("tray icon of {} not updated: {err}", provider.slug());
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if tooltip_changed {
|
||||
if let Err(err) = tray.set_tooltip(Some(&view.tooltip)) {
|
||||
log::warn!("tray tooltip of {} not updated: {err}", provider.slug());
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
self.record(provider, Some(Shown::of(view)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Give every icon a fresh menu built from `entries`. Main thread only.
|
||||
pub fn set_menus(&self, app: &AppHandle, entries: &[MenuEntry]) {
|
||||
for provider in self.providers() {
|
||||
let Some(tray) = app.tray_by_id(provider.slug()) else {
|
||||
continue;
|
||||
};
|
||||
let result = menu::build(app, entries).and_then(|m| tray.set_menu(Some(m)));
|
||||
if let Err(err) = result {
|
||||
log::warn!("tray menu of {} not updated: {err}", provider.slug());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a tray can be created on this system without aborting.
|
||||
fn tray_supported() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
crate::platform::linux::appindicator_available()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn badge_image(view: &TrayView) -> Image<'static> {
|
||||
let pixmap = badge::render_pixmap(view.provider, view.percent);
|
||||
Image::new_owned(
|
||||
badge::demultiplied_rgba(&pixmap),
|
||||
badge::BADGE_PX,
|
||||
badge::BADGE_PX,
|
||||
)
|
||||
}
|
||||
|
||||
fn create(app: &AppHandle, view: &TrayView, entries: &[MenuEntry]) -> tauri::Result<TrayIcon<Wry>> {
|
||||
TrayIconBuilder::with_id(view.provider.slug())
|
||||
.icon(badge_image(view))
|
||||
.tooltip(&view.tooltip)
|
||||
.menu(&menu::build(app, entries)?)
|
||||
.show_menu_on_left_click(false)
|
||||
.build(app)
|
||||
}
|
||||
|
||||
/// App-level tray click handler, registered on the Tauri builder rather than
|
||||
/// per icon. Tauri calls a per-icon handler while holding the per-icon
|
||||
/// listener table, and the refresh below runs inline on the main thread and
|
||||
/// may build a missing tray icon, which locks that same table to register
|
||||
/// itself: a same-thread deadlock. The app-level table is never touched by
|
||||
/// icon creation.
|
||||
pub fn on_tray_icon_event(app: &AppHandle, event: TrayIconEvent) {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if let Some(runtime) = app.try_state::<Arc<Runtime>>() {
|
||||
let runtime = Arc::clone(&runtime);
|
||||
runtime.toggle_widget();
|
||||
runtime.refresh_shell(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::i18n::I18n;
|
||||
use crate::core::state::ProviderUiState;
|
||||
use crate::usage::{UsageWindows, Window};
|
||||
|
||||
fn strings() -> LocaleStrings {
|
||||
I18n::load().strings().clone()
|
||||
}
|
||||
|
||||
fn usage(pct: f64, ok: bool) -> UsageState {
|
||||
let mut usage = UsageState {
|
||||
last_poll_ok: ok,
|
||||
..UsageState::default()
|
||||
};
|
||||
usage.snapshots.insert(
|
||||
ProviderId::Claude,
|
||||
ProviderUiState {
|
||||
windows: UsageWindows {
|
||||
primary: Window {
|
||||
utilization: pct,
|
||||
resets_at: None,
|
||||
},
|
||||
secondary: Window::default(),
|
||||
},
|
||||
primary_text: format!("{pct}%"),
|
||||
secondary_text: String::new(),
|
||||
},
|
||||
);
|
||||
usage
|
||||
}
|
||||
|
||||
fn view(provider: ProviderId, percent: Option<f64>, tooltip: &str) -> TrayView {
|
||||
TrayView {
|
||||
provider,
|
||||
percent,
|
||||
tooltip: tooltip.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_follow_enabled_providers_and_last_poll() {
|
||||
let s = strings();
|
||||
let all = [
|
||||
ProviderId::Claude,
|
||||
ProviderId::ChatGpt,
|
||||
ProviderId::OpenCodeGo,
|
||||
];
|
||||
let v = views(&all, &usage(42.0, true), &s);
|
||||
assert_eq!(v.len(), 2, "OpenCode Go never gets an icon");
|
||||
assert_eq!(v[0].provider, ProviderId::Claude);
|
||||
assert_eq!(v[0].percent, Some(42.0));
|
||||
assert_eq!(
|
||||
v[0].tooltip,
|
||||
"Claude Code\n5h: 42%\n7d: ...\nLeft-click: show/hide"
|
||||
);
|
||||
assert_eq!(v[1].provider, ProviderId::ChatGpt);
|
||||
assert_eq!(v[1].percent, None);
|
||||
// A failed last poll greys every badge to the track.
|
||||
let v = views(&all, &usage(42.0, false), &s);
|
||||
assert_eq!(v[0].percent, None);
|
||||
assert!(views(&[ProviderId::ChatGpt], &usage(1.0, true), &s)
|
||||
.iter()
|
||||
.all(|v| v.provider == ProviderId::ChatGpt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltips_are_cut_to_the_utf16_limit() {
|
||||
assert_eq!(truncate_utf16("abc", 127), "abc");
|
||||
assert_eq!(truncate_utf16("abcdef", 3), "abc");
|
||||
// '·' is one unit, '😀' two: never split a surrogate pair.
|
||||
assert_eq!(truncate_utf16("a·😀b", 3), "a·");
|
||||
assert_eq!(truncate_utf16("a·😀b", 4), "a·😀");
|
||||
let long = "x".repeat(200);
|
||||
assert_eq!(truncate_utf16(&long, TOOLTIP_MAX_UTF16).len(), 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_creates_removes_and_updates_only_changes() {
|
||||
let mut shown = HashMap::new();
|
||||
let first = [view(ProviderId::Claude, None, "a")];
|
||||
let p = plan(&shown, &first);
|
||||
assert_eq!(p.create, [ProviderId::Claude]);
|
||||
assert!(p.remove.is_empty() && p.update.is_empty());
|
||||
|
||||
shown.insert(ProviderId::Claude, Shown::of(&first[0]));
|
||||
assert_eq!(plan(&shown, &first), Plan::default(), "nothing changed");
|
||||
|
||||
let p = plan(&shown, &[view(ProviderId::Claude, None, "b")]);
|
||||
assert_eq!(p.update, [(ProviderId::Claude, false, true)]);
|
||||
let p = plan(&shown, &[view(ProviderId::Claude, Some(10.0), "a")]);
|
||||
assert_eq!(p.update, [(ProviderId::Claude, true, false)]);
|
||||
|
||||
let p = plan(&shown, &[view(ProviderId::ChatGpt, None, "c")]);
|
||||
assert_eq!(p.remove, [ProviderId::Claude]);
|
||||
assert_eq!(p.create, [ProviderId::ChatGpt]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn badge_key_tracks_percent_and_colour() {
|
||||
let a = Shown::of(&view(ProviderId::Claude, Some(59.0), "t"));
|
||||
let b = Shown::of(&view(ProviderId::Claude, Some(61.0), "t"));
|
||||
assert_ne!(a.badge, b.badge);
|
||||
assert_ne!(a.badge.1, b.badge.1, "crossing 60% changes the colour");
|
||||
let none = Shown::of(&view(ProviderId::Claude, None, "t"));
|
||||
assert_eq!(none.badge, (None, [0, 0, 0]));
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
// Anti-aliased tray badge rendering.
|
||||
//
|
||||
// We draw a filled circle + percentage-sweep ring with tiny-skia, then
|
||||
// hand the resulting BGRA pixmap to Win32 as an HICON via
|
||||
// `CreateIconIndirect`. The badge is intentionally text-free — the
|
||||
// floating bubble already shows the exact percentage; the tray badge
|
||||
// is just a coarse colour-and-fill indicator.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
||||
use windows::Win32::Foundation::{BOOL, HWND};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER,
|
||||
DIB_RGB_COLORS, HBITMAP,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, HICON, ICONINFO};
|
||||
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
const BADGE_PX: u32 = 32;
|
||||
|
||||
pub fn render_hicon(kind: ProviderId, percent: Option<f64>) -> HICON {
|
||||
let pixmap = render_pixmap(kind, percent);
|
||||
pixmap_to_hicon(&pixmap).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
|
||||
let mut pixmap = Pixmap::new(BADGE_PX, BADGE_PX).expect("32×32 pixmap");
|
||||
pixmap.fill(tiny_skia::Color::TRANSPARENT);
|
||||
|
||||
let cx = BADGE_PX as f32 / 2.0;
|
||||
let cy = BADGE_PX as f32 / 2.0;
|
||||
let outer = (BADGE_PX as f32 / 2.0) - 1.0;
|
||||
let inner = outer * 0.62;
|
||||
|
||||
// Filled inner disk in the model's base tint.
|
||||
let base = base_color(kind);
|
||||
{
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color_rgba8(base[0], base[1], base[2], 255);
|
||||
paint.anti_alias = true;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_circle(cx, cy, inner);
|
||||
if let Some(path) = pb.finish() {
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
FillRule::Winding,
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Track ring (the unused portion of the quota).
|
||||
{
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color_rgba8(0x3a, 0x3a, 0x3a, 255);
|
||||
paint.anti_alias = true;
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = outer - inner - 1.0;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_circle(cx, cy, (inner + outer) / 2.0);
|
||||
if let Some(path) = pb.finish() {
|
||||
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
// Active sweep — percentage of ring filled in usage colour.
|
||||
if let Some(p) = percent {
|
||||
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
||||
if sweep > 0.0 {
|
||||
let fill = usage_color(kind, p);
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
|
||||
paint.anti_alias = true;
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = outer - inner - 1.0;
|
||||
stroke.line_cap = tiny_skia::LineCap::Round;
|
||||
|
||||
let pb_path = build_arc(cx, cy, (inner + outer) / 2.0, sweep);
|
||||
if let Some(path) = pb_path {
|
||||
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pixmap
|
||||
}
|
||||
|
||||
fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option<tiny_skia::Path> {
|
||||
// tiny-skia 0.11 lacks a direct arc primitive. Approximate by sampling
|
||||
// points along the circumference from the 12 o'clock position clockwise.
|
||||
let segments = (sweep_fraction * 64.0).ceil() as usize;
|
||||
let segments = segments.max(1);
|
||||
let mut pb = PathBuilder::new();
|
||||
let start_angle: f32 = -std::f32::consts::FRAC_PI_2;
|
||||
let total = sweep_fraction * std::f32::consts::TAU;
|
||||
for i in 0..=segments {
|
||||
let t = i as f32 / segments as f32;
|
||||
let a = start_angle + t * total;
|
||||
let x = cx + a.cos() * radius;
|
||||
let y = cy + a.sin() * radius;
|
||||
if i == 0 {
|
||||
pb.move_to(x, y);
|
||||
} else {
|
||||
pb.line_to(x, y);
|
||||
}
|
||||
}
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
fn base_color(kind: ProviderId) -> [u8; 3] {
|
||||
match kind {
|
||||
// Warm orange-ish tint reads as "Claude" without copying the
|
||||
// upstream's exact #D97757; close enough to be familiar.
|
||||
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
|
||||
// Cool dark slate for ChatGPT/Codex.
|
||||
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
|
||||
// Deep green for OpenCode Go.
|
||||
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep-ring fill color for the tray badge. The badge inner disk is always
|
||||
/// dark regardless of system theme, so we pass `is_dark = true` to keep the
|
||||
/// ring readable (Codex sweep stays white instead of charcoal).
|
||||
fn usage_color(kind: ProviderId, percent: f64) -> [u8; 3] {
|
||||
let c = crate::usage_color::bar_fill_color(kind, true, percent);
|
||||
[c.r, c.g, c.b]
|
||||
}
|
||||
|
||||
// ---------- Pixmap → HICON ----------
|
||||
|
||||
fn pixmap_to_hicon(pixmap: &Pixmap) -> Option<HICON> {
|
||||
let width = pixmap.width() as i32;
|
||||
let height = pixmap.height() as i32;
|
||||
let pixels = pixmap.data(); // tiny-skia premultiplied RGBA bytes
|
||||
|
||||
// Build a 32bpp top-down DIB section for the colour bitmap.
|
||||
let bmi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: width,
|
||||
biHeight: -height,
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: 0, // BI_RGB
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let hdc = GetDC(HWND::default());
|
||||
let mut bits: *mut c_void = std::ptr::null_mut();
|
||||
let color_bmp = CreateDIBSection(hdc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
ReleaseDC(HWND::default(), hdc);
|
||||
if color_bmp.is_invalid() || bits.is_null() {
|
||||
return None;
|
||||
}
|
||||
// tiny-skia produces RGBA (premultiplied); GDI's DIB is BGRA. Swap.
|
||||
let pixel_count = (width * height) as usize;
|
||||
let dst = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
|
||||
for i in 0..pixel_count {
|
||||
let r = pixels[i * 4];
|
||||
let g = pixels[i * 4 + 1];
|
||||
let b = pixels[i * 4 + 2];
|
||||
let a = pixels[i * 4 + 3];
|
||||
dst[i] = (a as u32) << 24 | (r as u32) << 16 | (g as u32) << 8 | (b as u32);
|
||||
}
|
||||
|
||||
// Monochrome AND mask — opaque pixels marked 0, transparent 1.
|
||||
let mask_row_stride = ((width + 15) / 16) * 2; // 16-bit aligned per scanline
|
||||
let mut mask = vec![0u8; (mask_row_stride * height) as usize];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let idx = (y * width + x) as usize;
|
||||
let alpha = pixels[idx * 4 + 3];
|
||||
if alpha == 0 {
|
||||
let byte = (y * mask_row_stride + (x / 8)) as usize;
|
||||
mask[byte] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mask_bmp: HBITMAP = CreateBitmap(width, height, 1, 1, Some(mask.as_ptr() as *const _));
|
||||
if mask_bmp.is_invalid() {
|
||||
let _ = DeleteObject(color_bmp);
|
||||
return None;
|
||||
}
|
||||
|
||||
let info = ICONINFO {
|
||||
fIcon: BOOL(1),
|
||||
xHotspot: 0,
|
||||
yHotspot: 0,
|
||||
hbmMask: mask_bmp,
|
||||
hbmColor: color_bmp,
|
||||
};
|
||||
let hicon = CreateIconIndirect(&info).ok();
|
||||
|
||||
let _ = DeleteObject(color_bmp);
|
||||
let _ = DeleteObject(mask_bmp);
|
||||
hicon
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Dispatch the `WM_APP_TRAY` notification message.
|
||||
//
|
||||
// Shell_NotifyIconW packs the event in the LOWORD of lparam (the
|
||||
// underlying mouse-event code) and the icon ID in HIWORD. We translate
|
||||
// to a `TrayAction` and let the app handle it.
|
||||
|
||||
use windows::Win32::Foundation::LPARAM;
|
||||
|
||||
use super::TrayAction;
|
||||
|
||||
const WM_LBUTTONUP: u32 = 0x0202;
|
||||
const WM_RBUTTONUP: u32 = 0x0205;
|
||||
|
||||
pub fn handle(lparam: LPARAM) -> TrayAction {
|
||||
let raw = lparam.0 as u32;
|
||||
let event = raw & 0xFFFF;
|
||||
match event {
|
||||
WM_LBUTTONUP => TrayAction::ToggleWidget,
|
||||
WM_RBUTTONUP => TrayAction::ShowContextMenu,
|
||||
_ => TrayAction::None,
|
||||
}
|
||||
}
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
// Tray-area icon management — stateless module-level API.
|
||||
//
|
||||
// Each enabled provider gets one notification-area icon. `sync` reconciles
|
||||
// the set of registered icons with the supplied desired list, issuing
|
||||
// `NIM_ADD`, `NIM_MODIFY`, or `NIM_DELETE` per icon. We track registration
|
||||
// state in a private mutex so callers (the app orchestrator) don't have
|
||||
// to thread a `Manager` through their snapshot/clone pipelines.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::Shell::{
|
||||
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING, NIM_ADD,
|
||||
NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::DestroyIcon;
|
||||
|
||||
pub mod badge;
|
||||
pub mod callback;
|
||||
|
||||
pub use crate::usage::ProviderId as IconKind;
|
||||
|
||||
/// Menu-command ID for the "Show widget" toggle that appears in the
|
||||
/// right-click menu and is also fired by left-clicking a tray icon.
|
||||
pub const IDM_TOGGLE_WIDGET: u16 = 50;
|
||||
|
||||
/// Notification message routed back to the owner HWND when the user
|
||||
/// interacts with a tray icon (left/right click).
|
||||
pub const WM_APP_TRAY: u32 = 0x8003;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrayIcon {
|
||||
pub kind: IconKind,
|
||||
pub percent: Option<f64>,
|
||||
pub tooltip: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TrayAction {
|
||||
None,
|
||||
ToggleWidget,
|
||||
ShowContextMenu,
|
||||
}
|
||||
|
||||
fn registered() -> &'static Mutex<HashSet<IconKind>> {
|
||||
static R: OnceLock<Mutex<HashSet<IconKind>>> = OnceLock::new();
|
||||
R.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
/// Reconcile registered icons with the desired list.
|
||||
pub fn sync(owner: HWND, desired: &[TrayIcon]) {
|
||||
let mut current = registered().lock().expect("tray registry mutex poisoned");
|
||||
let target: HashSet<IconKind> = desired.iter().map(|i| i.kind).collect();
|
||||
|
||||
let to_remove: Vec<IconKind> = current.difference(&target).copied().collect();
|
||||
for kind in to_remove {
|
||||
unsafe {
|
||||
let _ = Shell_NotifyIconW(NIM_DELETE, &build_data(owner, kind));
|
||||
}
|
||||
current.remove(&kind);
|
||||
}
|
||||
|
||||
for icon in desired {
|
||||
let hicon = badge::render_hicon(icon.kind, icon.percent);
|
||||
let mut data = build_data(owner, icon.kind);
|
||||
data.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||
data.hIcon = hicon;
|
||||
write_utf16(&mut data.szTip, &icon.tooltip);
|
||||
let msg = if current.contains(&icon.kind) {
|
||||
NIM_MODIFY
|
||||
} else {
|
||||
NIM_ADD
|
||||
};
|
||||
unsafe {
|
||||
if Shell_NotifyIconW(msg, &data).as_bool() && msg == NIM_ADD {
|
||||
current.insert(icon.kind);
|
||||
}
|
||||
let _ = DestroyIcon(hicon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a yellow-warning balloon on an already-registered icon.
|
||||
pub fn notify_warning(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
notify_inner(owner, kind, title, body, NIIF_WARNING);
|
||||
}
|
||||
|
||||
/// Show a blue-info balloon on an already-registered icon.
|
||||
pub fn notify_info(owner: HWND, kind: IconKind, title: &str, body: &str) {
|
||||
notify_inner(owner, kind, title, body, NIIF_INFO);
|
||||
}
|
||||
|
||||
fn notify_inner(
|
||||
owner: HWND,
|
||||
kind: IconKind,
|
||||
title: &str,
|
||||
body: &str,
|
||||
flags: NOTIFY_ICON_INFOTIP_FLAGS,
|
||||
) {
|
||||
let mut data = build_data(owner, kind);
|
||||
data.uFlags = NIF_INFO;
|
||||
write_utf16(&mut data.szInfoTitle, title);
|
||||
write_utf16(&mut data.szInfo, body);
|
||||
data.dwInfoFlags = flags;
|
||||
unsafe {
|
||||
let _ = Shell_NotifyIconW(NIM_MODIFY, &data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down every registered icon. Call from app shutdown if you want to.
|
||||
#[allow(dead_code)]
|
||||
pub fn remove_all(owner: HWND) {
|
||||
let mut current = registered().lock().expect("tray registry mutex poisoned");
|
||||
for kind in current.drain().collect::<Vec<_>>() {
|
||||
unsafe {
|
||||
let _ = Shell_NotifyIconW(NIM_DELETE, &build_data(owner, kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_data(owner: HWND, kind: IconKind) -> NOTIFYICONDATAW {
|
||||
NOTIFYICONDATAW {
|
||||
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
|
||||
hWnd: owner,
|
||||
uID: icon_id(kind),
|
||||
uCallbackMessage: WM_APP_TRAY,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_id(kind: IconKind) -> u32 {
|
||||
kind.metadata().tray_icon_id
|
||||
}
|
||||
|
||||
fn write_utf16(dst: &mut [u16], src: &str) {
|
||||
let units: Vec<u16> = src.encode_utf16().collect();
|
||||
let n = units.len().min(dst.len().saturating_sub(1));
|
||||
dst[..n].copy_from_slice(&units[..n]);
|
||||
if n < dst.len() {
|
||||
dst[n] = 0;
|
||||
}
|
||||
}
|
||||
+64
-8
@@ -1,16 +1,72 @@
|
||||
// Install-channel detection.
|
||||
//
|
||||
// Until this app has a winget package, every install is treated as
|
||||
// "portable" — meaning self-update applies via the inline-cmd handoff.
|
||||
// When a winget package exists, restore the path-probe code from git
|
||||
// history (see commit before Phase 6).
|
||||
//! How this copy of the app was installed, which decides whether an update
|
||||
//! can be applied in place.
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Channel {
|
||||
/// The Windows portable exe: the download replaces it via `self_replace`.
|
||||
Portable,
|
||||
Winget,
|
||||
/// A Linux AppImage (`$APPIMAGE` set): the updater plugin swaps the file.
|
||||
AppImage,
|
||||
/// A Linux install owned by a package manager (`.deb`): the check runs,
|
||||
/// applying is left to the package manager.
|
||||
PackageManager,
|
||||
/// No in-place update path yet (macOS).
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
/// Whether "Update available" may download and swap the binary.
|
||||
pub fn can_apply(self) -> bool {
|
||||
matches!(self, Channel::Portable | Channel::AppImage)
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel of the running process.
|
||||
pub fn current() -> Channel {
|
||||
Channel::Portable
|
||||
detect(std::env::consts::OS, std::env::var_os("APPIMAGE"))
|
||||
}
|
||||
|
||||
/// The channel for an OS name (`std::env::consts::OS`) and the `APPIMAGE`
|
||||
/// variable the AppImage runtime sets. An empty `APPIMAGE` counts as unset.
|
||||
pub fn detect(os: &str, appimage: Option<OsString>) -> Channel {
|
||||
match os {
|
||||
"windows" => Channel::Portable,
|
||||
"linux" if appimage.is_some_and(|v| !v.is_empty()) => Channel::AppImage,
|
||||
"linux" => Channel::PackageManager,
|
||||
_ => Channel::Unsupported,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn os(v: &str) -> Option<OsString> {
|
||||
Some(OsString::from(v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_follows_the_os_and_appimage_variable() {
|
||||
assert_eq!(detect("windows", None), Channel::Portable);
|
||||
// A stray APPIMAGE on Windows does not change the portable path.
|
||||
assert_eq!(detect("windows", os("/x.AppImage")), Channel::Portable);
|
||||
assert_eq!(
|
||||
detect("linux", os("/home/u/App.AppImage")),
|
||||
Channel::AppImage
|
||||
);
|
||||
assert_eq!(detect("linux", None), Channel::PackageManager);
|
||||
assert_eq!(detect("linux", os("")), Channel::PackageManager);
|
||||
assert_eq!(detect("macos", None), Channel::Unsupported);
|
||||
assert_eq!(detect("macos", os("/x.AppImage")), Channel::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_portable_and_appimage_apply_in_place() {
|
||||
assert!(Channel::Portable.can_apply());
|
||||
assert!(Channel::AppImage.can_apply());
|
||||
assert!(!Channel::PackageManager.can_apply());
|
||||
assert!(!Channel::Unsupported.can_apply());
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// Native Win32 process + file handoff primitives used by the in-app
|
||||
// restart path and the auto-update install path. The main binary uses
|
||||
// `windows_subsystem = "windows"`, so spawning the child directly via
|
||||
// `CreateProcessW` allocates no console — nothing can flash.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::path::Path;
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::{CloseHandle, FALSE, HANDLE, WAIT_OBJECT_0};
|
||||
use windows::Win32::System::Threading::{
|
||||
CreateProcessW, OpenProcess, WaitForSingleObject, CREATE_NEW_PROCESS_GROUP,
|
||||
CREATE_NO_WINDOW, DETACHED_PROCESS, PROCESS_INFORMATION, PROCESS_SYNCHRONIZE,
|
||||
STARTUPINFOW,
|
||||
};
|
||||
|
||||
/// Spawn `exe` with the supplied args as a detached, console-less child.
|
||||
///
|
||||
/// Caller is fire-and-forget: the child's handles are closed immediately
|
||||
/// so no zombie wait is required.
|
||||
pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
|
||||
let mut cmdline = build_command_line(exe, args);
|
||||
let exe_w: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
|
||||
let si = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
|
||||
let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
|
||||
let ok = unsafe {
|
||||
CreateProcessW(
|
||||
PCWSTR::from_raw(exe_w.as_ptr()),
|
||||
windows::core::PWSTR(cmdline.as_mut_ptr()),
|
||||
None,
|
||||
None,
|
||||
FALSE,
|
||||
flags,
|
||||
None,
|
||||
PCWSTR::null(),
|
||||
&si,
|
||||
&mut pi,
|
||||
)
|
||||
};
|
||||
|
||||
if ok.is_err() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
if !pi.hThread.is_invalid() {
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
}
|
||||
if !pi.hProcess.is_invalid() {
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
}
|
||||
}
|
||||
// Suppress the unused-variable warning until si.lpReserved fields ever matter.
|
||||
let _ = &si;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait up to `timeout_ms` for `pid` to exit. Silent on any failure —
|
||||
/// caller treats this as a best-effort barrier before acquiring the
|
||||
/// singleton mutex.
|
||||
pub fn wait_for_parent_exit(pid: u32, timeout_ms: u32) {
|
||||
let handle: HANDLE = match unsafe { OpenProcess(PROCESS_SYNCHRONIZE, FALSE, pid) } {
|
||||
Ok(h) if !h.is_invalid() => h,
|
||||
_ => return,
|
||||
};
|
||||
unsafe {
|
||||
let res = WaitForSingleObject(handle, timeout_ms);
|
||||
if res != WAIT_OBJECT_0 {
|
||||
log::debug!("wait_for_parent_exit pid={pid} timeout/err res={:?}", res.0);
|
||||
}
|
||||
let _ = CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove leftover `<exe>.old.<pid>` siblings from previous in-place updates.
|
||||
/// Filled in by phase 4; stubbed here so phase 1 can wire the call sites.
|
||||
pub fn cleanup_stale_old_exes(current_exe: &Path) {
|
||||
let Some(dir) = current_exe.parent() else {
|
||||
return;
|
||||
};
|
||||
let Some(stem) = current_exe.file_name() else {
|
||||
return;
|
||||
};
|
||||
let prefix = format!("{}.old.", stem.to_string_lossy());
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().starts_with(&prefix) {
|
||||
if let Err(e) = std::fs::remove_file(entry.path()) {
|
||||
log::debug!(
|
||||
"cleanup_stale_old_exes: remove {:?} failed: {e}",
|
||||
entry.path()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command_line(exe: &Path, args: &[OsString]) -> Vec<u16> {
|
||||
// CreateProcessW parses argv[0] from a quoted exe path. We wrap the
|
||||
// exe in `"…"` and join args separated by spaces. Args are quoted
|
||||
// only when they contain whitespace; our callers pass simple tokens
|
||||
// (--wait-pid <number>, --updated-to <version>) so naive quoting is
|
||||
// sufficient.
|
||||
let mut line = String::new();
|
||||
line.push('"');
|
||||
line.push_str(&exe.to_string_lossy());
|
||||
line.push('"');
|
||||
for a in args {
|
||||
line.push(' ');
|
||||
let s = a.to_string_lossy();
|
||||
if s.chars().any(|c| c.is_whitespace()) {
|
||||
line.push('"');
|
||||
line.push_str(&s);
|
||||
line.push('"');
|
||||
} else {
|
||||
line.push_str(&s);
|
||||
}
|
||||
}
|
||||
let mut wide: Vec<u16> = std::ffi::OsString::from(line).encode_wide().collect();
|
||||
wide.push(0);
|
||||
wide
|
||||
}
|
||||
+177
-332
@@ -1,372 +1,217 @@
|
||||
// Download a release asset and swap it in through the proven `self_replace`
|
||||
// handoff path.
|
||||
//
|
||||
// The running process cannot reliably replace its own mapped image on
|
||||
// Windows. `self_replace` handles the platform-specific rename/copy sequence
|
||||
// for that case. After the replacement succeeds, we start the installed path
|
||||
// with `--wait-pid` and let the current process exit.
|
||||
//! Applying a downloaded, signature-checked update to the portable exe, and
|
||||
//! the paths under `updates_dir` that updates use.
|
||||
//!
|
||||
//! The running process cannot reliably replace its own mapped image on
|
||||
//! Windows; `self_replace` handles the rename and copy sequence for that.
|
||||
//! The caller then relaunches the installed path with `--wait-pid` and exits.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::Display;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use super::Error;
|
||||
use crate::handoff;
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
|
||||
};
|
||||
use windows::Win32::System::Threading::GetCurrentProcessId;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
|
||||
/// File name the download is staged under before the swap. Startup cleanup
|
||||
/// deletes a leftover one.
|
||||
const STAGED_EXE: &str = "update.exe";
|
||||
|
||||
use crate::net::Client;
|
||||
use crate::os::to_utf16_nul;
|
||||
/// File name of the last apply failure's log.
|
||||
const ERROR_LOG: &str = "update-error.log";
|
||||
|
||||
pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> {
|
||||
match begin_inner(http, release) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
write_update_error(&e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
/// `updates_dir()/update.exe`.
|
||||
pub fn stage_path() -> Result<PathBuf, Error> {
|
||||
updates_file(STAGED_EXE)
|
||||
}
|
||||
|
||||
fn begin_inner(http: &Client, release: &super::Release) -> Result<(), super::Error> {
|
||||
/// `updates_dir()/update-error.log`.
|
||||
pub fn error_log_path() -> Result<PathBuf, Error> {
|
||||
updates_file(ERROR_LOG)
|
||||
}
|
||||
|
||||
fn updates_file(name: &str) -> Result<PathBuf, Error> {
|
||||
handoff::updates_dir()
|
||||
.map(|dir| dir.join(name))
|
||||
.ok_or_else(|| Error::NotWritable("no local data directory available".to_string()))
|
||||
}
|
||||
|
||||
/// Replace the running executable with `bytes`.
|
||||
pub fn replace_portable(bytes: &[u8]) -> Result<(), Error> {
|
||||
let current = std::env::current_exe()?;
|
||||
ensure_writable(¤t)?;
|
||||
let staging = stage_path()?;
|
||||
// Defense in depth: `MoveFileExW` itself is immune to `%`-expansion
|
||||
// (no shell parses our paths), but the existing rejection guards
|
||||
// future code paths that might invoke external tools, so keep it.
|
||||
reject_unsafe_path(¤t)?;
|
||||
reject_unsafe_path(&staging)?;
|
||||
if let Some(parent) = staging.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
download(
|
||||
http,
|
||||
&release.asset_url,
|
||||
&staging,
|
||||
release.asset_sha256.as_ref(),
|
||||
)?;
|
||||
replace_current_exe(&staging)?;
|
||||
spawn_replaced_exe(¤t, &release.version)?;
|
||||
let _ = std::fs::remove_file(&staging);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CLI entry point for `--apply-update <target> <source> <parent-pid> <version>`.
|
||||
/// Runs from the staged new binary, waits for the old UI process to exit,
|
||||
/// replaces the installed exe, and starts the installed copy.
|
||||
pub fn run_cli(args: &[String]) -> Option<i32> {
|
||||
if args.get(1).map(String::as_str) != Some("--apply-update") {
|
||||
return None;
|
||||
}
|
||||
let Some(target) = args.get(2).map(PathBuf::from) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(source) = args.get(3).map(PathBuf::from) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(parent_pid) = args.get(4).and_then(|s| s.parse::<u32>().ok()) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(version) = args.get(5).cloned() else {
|
||||
return Some(2);
|
||||
};
|
||||
|
||||
super::handoff::wait_for_parent_exit(parent_pid, 15_000);
|
||||
match replace_from_helper(&source, &target, &version) {
|
||||
Ok(()) => Some(0),
|
||||
Err(e) => {
|
||||
log::error!("apply-update failed: {e}");
|
||||
Some(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn download(
|
||||
http: &Client,
|
||||
url: &str,
|
||||
to: &Path,
|
||||
expected_sha256: Option<&[u8; 32]>,
|
||||
) -> Result<(), super::Error> {
|
||||
let resp = http
|
||||
.get(url)
|
||||
.header("User-Agent", super::release::user_agent())
|
||||
.send()?;
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(super::Error::Network(crate::net::Error::Status(
|
||||
resp.status(),
|
||||
)));
|
||||
}
|
||||
let body = resp.body();
|
||||
if let Some(expected) = expected_sha256 {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(body);
|
||||
let actual = hasher.finalize();
|
||||
if actual.as_slice() != expected {
|
||||
return Err(super::Error::ChecksumMismatch {
|
||||
expected: hex_encode(expected),
|
||||
actual: hex_encode(&actual),
|
||||
});
|
||||
}
|
||||
}
|
||||
std::fs::write(to, body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
out.push(HEX[(b >> 4) as usize] as char);
|
||||
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> {
|
||||
let s = p.to_string_lossy();
|
||||
if s.contains('%') {
|
||||
return Err(super::Error::UnsafePath(format!("path contains '%': {s}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_update_helper(
|
||||
helper: &Path,
|
||||
staging: &Path,
|
||||
target: &Path,
|
||||
version: &super::release::Version,
|
||||
) -> Result<(), super::Error> {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
|
||||
let args = vec![
|
||||
OsString::from("--apply-update"),
|
||||
target.as_os_str().to_os_string(),
|
||||
staging.as_os_str().to_os_string(),
|
||||
OsString::from(pid.to_string()),
|
||||
OsString::from(version_str),
|
||||
];
|
||||
super::handoff::spawn_detached(helper, &args).map_err(super::Error::Io)
|
||||
}
|
||||
|
||||
fn replace_current_exe(staging: &Path) -> Result<(), super::Error> {
|
||||
self_replace::self_replace(staging)
|
||||
.map_err(|e| super::Error::SwapFailed(format!("self_replace({}): {e}", staging.display())))
|
||||
}
|
||||
|
||||
fn spawn_replaced_exe(
|
||||
target: &Path,
|
||||
version: &super::release::Version,
|
||||
) -> Result<(), super::Error> {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
|
||||
let args = vec![
|
||||
OsString::from("--wait-pid"),
|
||||
OsString::from(pid.to_string()),
|
||||
OsString::from("--updated-to"),
|
||||
OsString::from(version_str),
|
||||
];
|
||||
super::handoff::spawn_detached(target, &args).map_err(super::Error::Io)
|
||||
}
|
||||
|
||||
fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> {
|
||||
let backup = backup_path(target);
|
||||
// Parent has exited, so the install target is no longer mapped.
|
||||
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
|
||||
|
||||
let swap_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
|
||||
if let Err(swap_err) = move_file(source, target, swap_flags) {
|
||||
// Compatibility for users updating from a release that invoked
|
||||
// the downloaded binary itself as the helper. A mapped source exe
|
||||
// may not be movable, but it can usually still be copied.
|
||||
let copy_result = std::fs::copy(source, target);
|
||||
if copy_result.is_err() {
|
||||
log::error!("source move failed before copy fallback: {swap_err}");
|
||||
}
|
||||
if let Err(copy_err) = copy_result {
|
||||
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
log::error!("rollback also failed: {revert_err}; surfacing modal");
|
||||
let target_name = target
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
|
||||
surface_rollback_failure(&backup, &target_name);
|
||||
}
|
||||
return Err(super::Error::Io(copy_err));
|
||||
}
|
||||
}
|
||||
|
||||
let args = vec![OsString::from("--updated-to"), OsString::from(version)];
|
||||
if let Err(spawn_err) = super::handoff::spawn_detached(target, &args) {
|
||||
log::error!("spawn_detached failed after swap: {spawn_err}; attempting revert");
|
||||
let _ = std::fs::remove_file(target);
|
||||
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
log::error!("post-spawn revert failed: {revert_err}");
|
||||
}
|
||||
return Err(super::Error::Io(spawn_err));
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(source);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_file(src: &Path, dst: &Path, flags: MOVE_FILE_FLAGS) -> Result<(), super::Error> {
|
||||
let src_w = to_utf16_nul(&src.to_string_lossy());
|
||||
let dst_w = to_utf16_nul(&dst.to_string_lossy());
|
||||
let result = unsafe {
|
||||
MoveFileExW(
|
||||
PCWSTR::from_raw(src_w.as_ptr()),
|
||||
PCWSTR::from_raw(dst_w.as_ptr()),
|
||||
flags,
|
||||
)
|
||||
};
|
||||
result.map_err(|e| {
|
||||
super::Error::SwapFailed(format!(
|
||||
"MoveFileExW({} -> {}): {e}",
|
||||
src.display(),
|
||||
dst.display()
|
||||
))
|
||||
stage_and_swap(¤t, &staging, bytes, |staged| {
|
||||
self_replace::self_replace(staged)
|
||||
})
|
||||
}
|
||||
|
||||
fn backup_path(target: &Path) -> PathBuf {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let fname = target
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "exe".to_string());
|
||||
let mut p = target.to_owned();
|
||||
p.set_file_name(format!("{fname}.old.{pid}"));
|
||||
p
|
||||
}
|
||||
|
||||
fn surface_rollback_failure(backup: &Path, target_name: &str) {
|
||||
// Pull the UI body text from i18n; the caller passes the
|
||||
// user-meaningful filename so we can format it in-place.
|
||||
let strings = crate::i18n::I18n::load().strings().clone();
|
||||
let body = format!(
|
||||
"{}{}\n\n{}",
|
||||
strings.update_rollback_failed_body,
|
||||
backup.display(),
|
||||
target_name
|
||||
);
|
||||
let title_w = to_utf16_nul(&strings.update_failed);
|
||||
let body_w = to_utf16_nul(&body);
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
PCWSTR::from_raw(body_w.as_ptr()),
|
||||
PCWSTR::from_raw(title_w.as_ptr()),
|
||||
MB_OK | MB_ICONERROR,
|
||||
);
|
||||
/// Write `bytes` to `staging`, then `swap` it in for `target`. The staged
|
||||
/// file is removed afterwards, whether the swap worked or not.
|
||||
fn stage_and_swap(
|
||||
target: &Path,
|
||||
staging: &Path,
|
||||
bytes: &[u8],
|
||||
swap: impl FnOnce(&Path) -> io::Result<()>,
|
||||
) -> Result<(), Error> {
|
||||
ensure_writable(target)?;
|
||||
// No shell parses these paths today; the rejection keeps any future
|
||||
// hand-off through an external tool safe from `%`-expansion.
|
||||
reject_unsafe_path(target)?;
|
||||
reject_unsafe_path(staging)?;
|
||||
if let Some(parent) = staging.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(staging, bytes)?;
|
||||
let swapped = swap(staging).map_err(|e| {
|
||||
Error::SwapFailed(format!(
|
||||
"self_replace({} -> {}): {e}",
|
||||
staging.display(),
|
||||
target.display()
|
||||
))
|
||||
});
|
||||
if let Err(err) = std::fs::remove_file(staging) {
|
||||
log::debug!("staged update {} not removed: {err}", staging.display());
|
||||
}
|
||||
swapped
|
||||
}
|
||||
|
||||
fn stage_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir().ok_or_else(|| {
|
||||
super::Error::NotWritable("no local data directory available".to_string())
|
||||
})?;
|
||||
Ok(base
|
||||
.join("ClaudeCodeUsageBubble")
|
||||
.join("updates")
|
||||
.join("update.exe"))
|
||||
/// Fail early when the install directory cannot take a new file, which
|
||||
/// `self_replace` needs.
|
||||
fn ensure_writable(target: &Path) -> Result<(), Error> {
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| Error::NotWritable("could not resolve install directory".to_string()))?;
|
||||
let probe = parent.join(".__bubble_update_probe");
|
||||
std::fs::write(&probe, b"").map_err(|e| Error::NotWritable(e.to_string()))?;
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_error_log_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir().ok_or_else(|| {
|
||||
super::Error::NotWritable("no local data directory available".to_string())
|
||||
})?;
|
||||
Ok(base
|
||||
.join("ClaudeCodeUsageBubble")
|
||||
.join("updates")
|
||||
.join("update-error.log"))
|
||||
fn reject_unsafe_path(p: &Path) -> Result<(), Error> {
|
||||
let s = p.to_string_lossy();
|
||||
if s.contains('%') {
|
||||
return Err(Error::UnsafePath(format!("path contains '%': {s}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_update_error(error: &super::Error) {
|
||||
let Ok(path) = update_error_log_path() else {
|
||||
return;
|
||||
/// Record an apply failure in `update-error.log`, replacing the previous
|
||||
/// entry. Failures to write it are only logged.
|
||||
pub fn write_update_error(error: &impl Display) {
|
||||
let path = match error_log_path() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
log::warn!("update error log skipped: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let ts = std::time::SystemTime::now()
|
||||
let unix_time = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or_default();
|
||||
let body = format!("unix_time={ts}\nerror={error}\n");
|
||||
let _ = std::fs::write(path, body);
|
||||
}
|
||||
|
||||
fn helper_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir().ok_or_else(|| {
|
||||
super::Error::NotWritable("no local data directory available".to_string())
|
||||
})?;
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
Ok(base
|
||||
.join("ClaudeCodeUsageBubble")
|
||||
.join("updates")
|
||||
.join(format!("updater-helper-{pid}.exe")))
|
||||
}
|
||||
|
||||
fn prepare_update_helper(current: &Path, helper: &Path) -> Result<(), super::Error> {
|
||||
if let Some(parent) = helper.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(current, helper)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cleanup_staged_update_files() {
|
||||
let Ok(stage) = stage_path() else {
|
||||
return;
|
||||
};
|
||||
let Some(dir) = stage.parent() else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name == "update.exe" || (name.starts_with("updater-helper-") && name.ends_with(".exe")) {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
log::debug!("cleanup_staged_update_files: remove {:?} failed: {e}", path);
|
||||
}
|
||||
}
|
||||
if let Err(err) = std::fs::write(&path, error_log_body(unix_time, error)) {
|
||||
log::warn!("update error log {} not written: {err}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_writable(target: &Path) -> Result<(), super::Error> {
|
||||
let parent = target.parent().ok_or_else(|| {
|
||||
super::Error::NotWritable("could not resolve install directory".to_string())
|
||||
})?;
|
||||
let probe = parent.join(".__bubble_update_probe");
|
||||
std::fs::write(&probe, b"").map_err(|e| super::Error::NotWritable(e.to_string()))?;
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
/// The log format every release has written.
|
||||
fn error_log_body(unix_time: u64, error: &impl Display) -> String {
|
||||
format!("unix_time={unix_time}\nerror={error}\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn scratch_dir(tag: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ccub-install-{tag}-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_and_helper_paths_are_distinct_exes() {
|
||||
let stage = stage_path().expect("stage path");
|
||||
let helper = helper_path().expect("helper path");
|
||||
fn staging_and_log_paths_sit_in_the_updates_dir() {
|
||||
let (Ok(stage), Ok(log)) = (stage_path(), error_log_path()) else {
|
||||
// No local data dir on this host; the error path is covered by
|
||||
// the `NotWritable` mapping.
|
||||
return;
|
||||
};
|
||||
let dir = handoff::updates_dir().expect("updates dir");
|
||||
assert_eq!(stage, dir.join("update.exe"));
|
||||
assert_eq!(log, dir.join("update-error.log"));
|
||||
}
|
||||
|
||||
assert_eq!(stage.file_name().unwrap(), "update.exe");
|
||||
assert!(helper
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("updater-helper-"));
|
||||
assert_ne!(stage, helper);
|
||||
#[test]
|
||||
fn swap_gets_the_staged_bytes_and_the_stage_is_removed() {
|
||||
let root = scratch_dir("swap");
|
||||
let target = root.join("app.exe");
|
||||
std::fs::write(&target, b"old").expect("target");
|
||||
let staging = root.join("updates").join("update.exe");
|
||||
|
||||
stage_and_swap(&target, &staging, b"new", |staged| {
|
||||
std::fs::copy(staged, &target).map(|_| ())
|
||||
})
|
||||
.expect("swap");
|
||||
|
||||
assert_eq!(std::fs::read(&target).expect("read"), b"new");
|
||||
assert!(!staging.exists());
|
||||
std::fs::remove_dir_all(&root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_swap_keeps_the_target_and_removes_the_stage() {
|
||||
let root = scratch_dir("fail");
|
||||
let target = root.join("app.exe");
|
||||
std::fs::write(&target, b"old").expect("target");
|
||||
let staging = root.join("update.exe");
|
||||
|
||||
let err = stage_and_swap(&target, &staging, b"new", |_| {
|
||||
Err(io::Error::new(io::ErrorKind::PermissionDenied, "locked"))
|
||||
})
|
||||
.expect_err("swap fails");
|
||||
|
||||
assert!(matches!(err, Error::SwapFailed(ref m) if m.contains("locked")));
|
||||
assert_eq!(std::fs::read(&target).expect("read"), b"old");
|
||||
assert!(!staging.exists());
|
||||
std::fs::remove_dir_all(&root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_paths_are_rejected_before_anything_is_written() {
|
||||
let root = scratch_dir("unsafe");
|
||||
let target = root.join("app.exe");
|
||||
let staging = root.join("%TEMP%").join("update.exe");
|
||||
|
||||
let err = stage_and_swap(&target, &staging, b"new", |_| Ok(())).expect_err("rejected");
|
||||
|
||||
assert!(matches!(err, Error::UnsafePath(_)));
|
||||
assert!(!staging.exists());
|
||||
std::fs::remove_dir_all(&root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_install_dir_is_not_writable() {
|
||||
let root = scratch_dir("missing");
|
||||
let target = root.join("absent").join("app.exe");
|
||||
let err = stage_and_swap(&target, &root.join("update.exe"), b"new", |_| Ok(()))
|
||||
.expect_err("not writable");
|
||||
assert!(matches!(err, Error::NotWritable(_)));
|
||||
std::fs::remove_dir_all(&root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_log_keeps_its_format() {
|
||||
assert_eq!(
|
||||
error_log_body(1_700_000_000, &"network: timed out"),
|
||||
"unix_time=1700000000\nerror=network: timed out\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
+433
-17
@@ -1,19 +1,48 @@
|
||||
// Self-update subsystem.
|
||||
//
|
||||
// Two stages: `release::fetch_latest` checks GitHub releases for a newer
|
||||
// build; `install::begin` downloads the .exe, swaps it in via `self_replace`,
|
||||
// then spawns the new binary detached via `CreateProcessW`. No shell handoff —
|
||||
// nothing can flash a console.
|
||||
//! Self-update through `tauri-plugin-updater`.
|
||||
//!
|
||||
//! The plugin fetches `latest.json`, picks this platform's entry and
|
||||
//! downloads it, checking the minisign signature against the configured
|
||||
//! public key before handing the bytes back. Applying depends on the
|
||||
//! install [`Channel`]: the Windows portable exe swaps itself through
|
||||
//! `self_replace`, an AppImage goes through the plugin's install, and other
|
||||
//! channels only report that an update exists. The caller relaunches with
|
||||
//! `--updated-to` and exits.
|
||||
//!
|
||||
//! Every call here blocks; run it on a worker thread.
|
||||
|
||||
pub mod channel;
|
||||
pub mod handoff;
|
||||
pub mod install;
|
||||
pub mod release;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Runtime, Url};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
|
||||
use crate::core::version::{self, Version};
|
||||
|
||||
pub use channel::{current as current_channel, Channel};
|
||||
|
||||
/// Budget for the whole check request.
|
||||
pub const CHECK_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Budget for the whole download request. The plugin does not carry the
|
||||
/// check builder's timeout over to the download (an `Update` starts with
|
||||
/// none), so apply sets this one explicitly.
|
||||
pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Hosts the update requests go to: the manifest endpoint and the release
|
||||
/// asset downloads it redirects to. A system proxy bypassing either is not
|
||||
/// applied, so the system's own routing stays in charge.
|
||||
const UPDATE_HOSTS: [&str; 3] = [
|
||||
"github.com",
|
||||
"release-assets.githubusercontent.com",
|
||||
"objects.githubusercontent.com",
|
||||
];
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] crate::net::Error),
|
||||
Network(String),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("no matching release asset")]
|
||||
@@ -22,21 +51,408 @@ pub enum Error {
|
||||
NotWritable(String),
|
||||
#[error("malformed version: {0}")]
|
||||
BadVersion(String),
|
||||
#[error("asset checksum mismatch: expected {expected}, got {actual}")]
|
||||
ChecksumMismatch { expected: String, actual: String },
|
||||
#[error("signature check failed: {0}")]
|
||||
Signature(String),
|
||||
#[error("path rejected for safety: {0}")]
|
||||
UnsafePath(String),
|
||||
#[error("file swap failed: {0}")]
|
||||
SwapFailed(String),
|
||||
#[error("updates cannot be applied in place for this install")]
|
||||
Unsupported,
|
||||
#[error("relaunch after the update failed: {0}")]
|
||||
Relaunch(std::io::Error),
|
||||
#[error("updater: {0}")]
|
||||
Updater(String),
|
||||
}
|
||||
|
||||
pub use channel::{current as current_channel, Channel};
|
||||
pub use install::run_cli;
|
||||
pub use release::Release;
|
||||
impl From<tauri_plugin_updater::Error> for Error {
|
||||
fn from(err: tauri_plugin_updater::Error) -> Self {
|
||||
use tauri_plugin_updater::Error as E;
|
||||
match err {
|
||||
E::Io(e) => Error::Io(e),
|
||||
E::Reqwest(_)
|
||||
| E::Network(_)
|
||||
| E::Http(_)
|
||||
| E::ReleaseNotFound
|
||||
| E::Serialization(_)
|
||||
| E::UrlParse(_)
|
||||
| E::EmptyEndpoints
|
||||
| E::InsecureTransportProtocol => Error::Network(err.to_string()),
|
||||
E::Semver(_) => Error::BadVersion(err.to_string()),
|
||||
E::TargetNotFound(_) | E::TargetsNotFound(_) => Error::NoAsset,
|
||||
E::Minisign(_)
|
||||
| E::Base64(_)
|
||||
| E::SignatureUtf8(_)
|
||||
| E::SignedVersionMismatch { .. }
|
||||
| E::MissingSignedVersion => Error::Signature(err.to_string()),
|
||||
E::InvalidUpdaterFormat
|
||||
| E::BinaryNotFoundInArchive
|
||||
| E::TempDirNotFound
|
||||
| E::TempDirNotOnSameMountPoint
|
||||
| E::PreviousAppNotRestored(_)
|
||||
| E::FailedToDetermineExtractPath => Error::SwapFailed(err.to_string()),
|
||||
E::UnsupportedOs | E::UnsupportedArch => Error::Unsupported,
|
||||
other => Error::Updater(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a release-check call.
|
||||
/// Result of an update check.
|
||||
#[derive(Debug)]
|
||||
pub enum CheckOutcome {
|
||||
pub enum CheckOutcome<T = Update> {
|
||||
UpToDate,
|
||||
Available(Release),
|
||||
Available(T),
|
||||
}
|
||||
|
||||
/// Ask the update endpoint whether a newer release exists for this
|
||||
/// platform. `proxy` overrides the plugin's own proxy discovery.
|
||||
pub fn check<R: Runtime>(app: &AppHandle<R>, proxy: Option<Url>) -> Result<CheckOutcome, Error> {
|
||||
let mut builder = app
|
||||
.updater_builder()
|
||||
.version_comparator(|current, release| {
|
||||
version::newer(&release.version.to_string(), ¤t.to_string())
|
||||
})
|
||||
.timeout(CHECK_TIMEOUT);
|
||||
if let Some(proxy) = proxy {
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
// The file to replace is the AppImage itself, not the executable
|
||||
// inside its read-only mount.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(appimage) = std::env::var_os("APPIMAGE").filter(|v| !v.is_empty()) {
|
||||
builder = builder.executable_path(appimage);
|
||||
}
|
||||
let updater = builder.build()?;
|
||||
outcome_of(tauri::async_runtime::block_on(updater.check()))
|
||||
}
|
||||
|
||||
/// Map the plugin's check result. A manifest without an entry for this
|
||||
/// platform (a deliberately Windows-only release, say) is not a failure:
|
||||
/// there is simply nothing to install here.
|
||||
pub fn outcome_of<T>(
|
||||
result: Result<Option<T>, tauri_plugin_updater::Error>,
|
||||
) -> Result<CheckOutcome<T>, Error> {
|
||||
use tauri_plugin_updater::Error as E;
|
||||
match result {
|
||||
Ok(Some(update)) => Ok(CheckOutcome::Available(update)),
|
||||
Ok(None) | Err(E::TargetNotFound(_) | E::TargetsNotFound(_)) => Ok(CheckOutcome::UpToDate),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Download `update`, which the plugin checks against the signing key, and
|
||||
/// put it in place of the running binary. Returns the installed version for
|
||||
/// `--updated-to`. The caller relaunches; nothing here exits.
|
||||
pub fn apply(mut update: Update, channel: Channel) -> Result<Version, Error> {
|
||||
if !channel.can_apply() {
|
||||
return Err(Error::Unsupported);
|
||||
}
|
||||
update.timeout = Some(DOWNLOAD_TIMEOUT);
|
||||
log::info!(
|
||||
"downloading update {} from {}",
|
||||
update.version,
|
||||
update.download_url
|
||||
);
|
||||
let bytes = tauri::async_runtime::block_on(update.download(|_, _| {}, || {}))?;
|
||||
log::info!("update {} downloaded and verified", update.version);
|
||||
match channel {
|
||||
Channel::Portable => install::replace_portable(&bytes)?,
|
||||
// `install` checks the signature again before swapping the file.
|
||||
Channel::AppImage => update.install(&bytes)?,
|
||||
Channel::PackageManager | Channel::Unsupported => return Err(Error::Unsupported),
|
||||
}
|
||||
Ok(Version::parse(&update.version))
|
||||
}
|
||||
|
||||
/// The proxy for the update requests, passed to the plugin explicitly so it
|
||||
/// matches the one the usage requests use.
|
||||
///
|
||||
/// An environment proxy (`net::env_proxy`, SOCKS included) wins over the
|
||||
/// system proxy. `None`, when an update host is on the `NO_PROXY` or system
|
||||
/// bypass list or no proxy is set, leaves the plugin to its own discovery,
|
||||
/// which honours those lists per host.
|
||||
pub fn proxy_url(
|
||||
env: Option<&ureq::Proxy>,
|
||||
system: Option<&crate::core::proxy::SystemProxy>,
|
||||
) -> Option<Url> {
|
||||
match env {
|
||||
Some(env) => env_proxy_url(env),
|
||||
None => system_proxy_url(system?),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_proxy_url(proxy: &ureq::Proxy) -> Option<Url> {
|
||||
use ureq::ProxyProtocol as P;
|
||||
let bypassed = UPDATE_HOSTS.iter().any(|host| {
|
||||
format!("https://{host}/")
|
||||
.parse::<ureq::http::Uri>()
|
||||
.is_ok_and(|uri| proxy.is_no_proxy(&uri))
|
||||
});
|
||||
if bypassed {
|
||||
return None;
|
||||
}
|
||||
let scheme = match proxy.protocol() {
|
||||
P::Http => "http",
|
||||
P::Https => "https",
|
||||
P::Socks4 => "socks4",
|
||||
P::Socks4A => "socks4a",
|
||||
P::Socks5 => "socks5",
|
||||
P::Socks5h => "socks5h",
|
||||
_ => {
|
||||
log::warn!("environment proxy ignored for updates, unsupported protocol");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The credentials stay percent-encoded, as they were in the variable.
|
||||
let userinfo = match (proxy.username(), proxy.password()) {
|
||||
(Some(user), Some(password)) => format!("{user}:{password}@"),
|
||||
(Some(user), None) => format!("{user}@"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let built = Url::parse(&format!(
|
||||
"{scheme}://{userinfo}{}:{}",
|
||||
proxy.host(),
|
||||
proxy.port()
|
||||
));
|
||||
match built {
|
||||
Ok(url) => Some(url),
|
||||
Err(err) => {
|
||||
// The proxy URL is not logged: it may carry credentials.
|
||||
log::warn!("environment proxy ignored for updates, not a valid URL: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn system_proxy_url(system: &crate::core::proxy::SystemProxy) -> Option<Url> {
|
||||
if UPDATE_HOSTS.iter().any(|host| system.bypasses(host)) {
|
||||
return None;
|
||||
}
|
||||
match Url::parse(&system.url) {
|
||||
Ok(url) => Some(url),
|
||||
Err(err) => {
|
||||
// The proxy URL is not logged: it may carry credentials.
|
||||
log::warn!("system proxy ignored for updates, not a valid URL: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::proxy::SystemProxy;
|
||||
use tauri_plugin_updater::Error as E;
|
||||
|
||||
#[test]
|
||||
fn missing_platform_entry_is_up_to_date() {
|
||||
assert!(matches!(
|
||||
outcome_of::<()>(Err(E::TargetNotFound("windows-x86_64".into()))),
|
||||
Ok(CheckOutcome::UpToDate)
|
||||
));
|
||||
assert!(matches!(
|
||||
outcome_of::<()>(Err(E::TargetsNotFound(vec!["linux-x86_64".into()]))),
|
||||
Ok(CheckOutcome::UpToDate)
|
||||
));
|
||||
assert!(matches!(
|
||||
outcome_of::<()>(Ok(None)),
|
||||
Ok(CheckOutcome::UpToDate)
|
||||
));
|
||||
assert!(matches!(
|
||||
outcome_of(Ok(Some(7))),
|
||||
Ok(CheckOutcome::Available(7))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_check_errors_fail() {
|
||||
assert!(matches!(
|
||||
outcome_of::<()>(Err(E::ReleaseNotFound)),
|
||||
Err(Error::Network(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
outcome_of::<()>(Err(E::Network("503".into()))),
|
||||
Err(Error::Network(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_errors_map_onto_the_update_errors() {
|
||||
let io = std::io::Error::other("disk");
|
||||
assert!(matches!(Error::from(E::Io(io)), Error::Io(_)));
|
||||
assert!(matches!(
|
||||
Error::from(E::SignatureUtf8("x".into())),
|
||||
Error::Signature(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
Error::from(E::SignedVersionMismatch {
|
||||
signed: "0.6.0".into(),
|
||||
announced: "0.7.0".into()
|
||||
}),
|
||||
Error::Signature(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
Error::from(E::InvalidUpdaterFormat),
|
||||
Error::SwapFailed(_)
|
||||
));
|
||||
assert!(matches!(Error::from(E::UnsupportedOs), Error::Unsupported));
|
||||
assert!(matches!(
|
||||
Error::from(E::TargetNotFound("t".into())),
|
||||
Error::NoAsset
|
||||
));
|
||||
assert!(matches!(
|
||||
Error::from(E::AuthenticationFailed),
|
||||
Error::Updater(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_text_keeps_the_log_prefixes() {
|
||||
assert_eq!(
|
||||
Error::from(E::Network("timed out".into())).to_string(),
|
||||
"network: `timed out`"
|
||||
);
|
||||
assert_eq!(
|
||||
Error::NotWritable("denied".into()).to_string(),
|
||||
"install location not writable: denied"
|
||||
);
|
||||
}
|
||||
|
||||
fn proxy(bypass: &[&str]) -> SystemProxy {
|
||||
SystemProxy {
|
||||
url: "http://proxy.corp:8080".into(),
|
||||
bypass: bypass.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_proxy_honours_the_bypass_list() {
|
||||
assert_eq!(proxy_url(None, None), None);
|
||||
assert_eq!(
|
||||
proxy_url(None, Some(&proxy(&[]))).map(|u| u.to_string()),
|
||||
Some("http://proxy.corp:8080/".into())
|
||||
);
|
||||
assert!(proxy_url(None, Some(&proxy(&["*.example.com"]))).is_some());
|
||||
assert_eq!(proxy_url(None, Some(&proxy(&["github.com"]))), None);
|
||||
assert_eq!(
|
||||
proxy_url(None, Some(&proxy(&["*.githubusercontent.com"]))),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn env(uri: &str) -> ureq::Proxy {
|
||||
ureq::Proxy::new(uri).expect("valid proxy")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_proxy_passes_the_env_proxy_whatever_its_scheme() {
|
||||
let url = |env_uri: &str| proxy_url(Some(&env(env_uri)), None).map(|u| u.to_string());
|
||||
assert_eq!(
|
||||
url("http://proxy.corp:3128"),
|
||||
Some("http://proxy.corp:3128/".into())
|
||||
);
|
||||
assert_eq!(
|
||||
url("socks5h://127.0.0.1:7890"),
|
||||
Some("socks5h://127.0.0.1:7890".into())
|
||||
);
|
||||
assert_eq!(url("socks5://[::1]"), Some("socks5://[::1]:1080".into()));
|
||||
assert_eq!(
|
||||
url("socks4a://10.0.0.1:1081"),
|
||||
Some("socks4a://10.0.0.1:1081".into())
|
||||
);
|
||||
assert_eq!(
|
||||
url("socks5h://us%40r:p%3Ass@127.0.0.1:7890"),
|
||||
Some("socks5h://us%40r:p%3Ass@127.0.0.1:7890".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_proxy_prefers_the_env_proxy_over_the_system_proxy() {
|
||||
assert_eq!(
|
||||
proxy_url(Some(&env("socks5h://127.0.0.1:7890")), Some(&proxy(&[])))
|
||||
.map(|u| u.to_string()),
|
||||
Some("socks5h://127.0.0.1:7890".into())
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_url(Some(&env("http://proxy.env:3128")), Some(&proxy(&[])))
|
||||
.map(|u| u.to_string()),
|
||||
Some("http://proxy.env:3128/".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_proxy_leaves_no_proxy_hosts_to_the_plugin() {
|
||||
let with_no_proxy = |expr: &str| {
|
||||
ureq::Proxy::builder(ureq::ProxyProtocol::Socks5h)
|
||||
.host("127.0.0.1")
|
||||
.port(7890)
|
||||
.no_proxy(expr)
|
||||
.build()
|
||||
.expect("valid proxy")
|
||||
};
|
||||
assert_eq!(proxy_url(Some(&with_no_proxy("github.com")), None), None);
|
||||
assert_eq!(
|
||||
proxy_url(Some(&with_no_proxy(".githubusercontent.com")), None),
|
||||
None
|
||||
);
|
||||
assert!(proxy_url(Some(&with_no_proxy("example.com")), None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updater_client_reaches_the_network_through_a_socks_env_proxy() {
|
||||
// Guards the `socks` feature the direct reqwest dependency turns on
|
||||
// for the plugin: without it reqwest rejects the SOCKS proxy.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let (socks_port, seen) = crate::net::test_server::serve_socks5_once(b"via socks");
|
||||
let url = proxy_url(
|
||||
Some(&env(&format!("socks5h://127.0.0.1:{socks_port}"))),
|
||||
None,
|
||||
)
|
||||
.expect("socks proxy url");
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(reqwest::Proxy::all(url.as_str()).expect("reqwest proxy"))
|
||||
.build()
|
||||
.expect("reqwest client");
|
||||
let body = tauri::async_runtime::block_on(async {
|
||||
client
|
||||
.get("http://example.invalid:8080/latest.json")
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await
|
||||
})
|
||||
.expect("request through socks");
|
||||
assert_eq!(body, "via socks");
|
||||
let request = seen
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("socks proxy saw the request");
|
||||
assert!(
|
||||
request.starts_with("5 example.invalid:8080\nGET /latest.json HTTP/1.1\r\n"),
|
||||
"{request}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_points_the_plugin_at_the_release_manifest() {
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../tauri.conf.json")).expect("tauri.conf.json");
|
||||
assert_eq!(
|
||||
config.pointer("/plugins/updater/endpoints"),
|
||||
Some(&serde_json::json!([
|
||||
"https://github.com/tiennm99/claude-code-usage-bubble/releases/latest/download/latest.json"
|
||||
]))
|
||||
);
|
||||
// The plugin parses this when it is registered; a parse error
|
||||
// would stop the app from starting.
|
||||
let plugin: tauri_plugin_updater::Config =
|
||||
serde_json::from_value(config["plugins"]["updater"].clone())
|
||||
.expect("updater plugin config");
|
||||
assert_eq!(plugin.endpoints.len(), 1);
|
||||
// Only the release workflow turns updater artifacts on, so local
|
||||
// bundle builds need no signing key.
|
||||
assert_eq!(
|
||||
config.pointer("/bundle/createUpdaterArtifacts"),
|
||||
Some(&serde_json::Value::Bool(false))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// Query the GitHub Releases API and pick the relevant asset.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::net::Client;
|
||||
|
||||
const ASSET_NAME: &str = "claude-code-usage-bubble.exe";
|
||||
const REPO_OWNER: &str = "tiennm99";
|
||||
const REPO_NAME: &str = "claude-code-usage-bubble";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Release {
|
||||
pub version: Version,
|
||||
pub asset_url: String,
|
||||
/// SHA-256 of the asset bytes, parsed from the GitHub Releases
|
||||
/// API `digest` field. `None` if GitHub omitted it (older
|
||||
/// releases predate the digest field).
|
||||
pub asset_sha256: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Version {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
pub patch: u32,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
pub fn current() -> Self {
|
||||
Self::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version {
|
||||
major: 0,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let core = s.trim().trim_start_matches('v').split('-').next()?;
|
||||
let mut parts = core.split('.').map(|p| p.parse::<u32>().ok());
|
||||
Some(Version {
|
||||
major: parts.next().flatten().unwrap_or(0),
|
||||
minor: parts.next().flatten().unwrap_or(0),
|
||||
patch: parts.next().flatten().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch_latest(http: &Client) -> Result<super::CheckOutcome, super::Error> {
|
||||
let url = format!("https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest");
|
||||
let resp = http
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.header("User-Agent", user_agent())
|
||||
.send()?;
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
|
||||
}
|
||||
let body: GhRelease = resp.json()?;
|
||||
let candidate = Version::parse(&body.tag_name)
|
||||
.ok_or_else(|| super::Error::BadVersion(body.tag_name.clone()))?;
|
||||
if candidate <= Version::current() {
|
||||
return Ok(super::CheckOutcome::UpToDate);
|
||||
}
|
||||
let asset = body
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(ASSET_NAME))
|
||||
.or_else(|| {
|
||||
body.assets
|
||||
.iter()
|
||||
.find(|a| a.name.to_ascii_lowercase().ends_with(".exe"))
|
||||
})
|
||||
.ok_or(super::Error::NoAsset)?;
|
||||
Ok(super::CheckOutcome::Available(Release {
|
||||
version: candidate,
|
||||
asset_url: asset.browser_download_url.clone(),
|
||||
asset_sha256: asset.digest.as_deref().and_then(parse_sha256_digest),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parse a GitHub `digest` field of the form `"sha256:<64 hex chars>"`
|
||||
/// into a 32-byte array. Returns `None` for any other algorithm or
|
||||
/// malformed input — callers should treat a missing digest as "no
|
||||
/// integrity check available" rather than as a parse failure.
|
||||
fn parse_sha256_digest(raw: &str) -> Option<[u8; 32]> {
|
||||
let hex = raw.strip_prefix("sha256:")?;
|
||||
if hex.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
for (i, byte_chars) in hex.as_bytes().chunks(2).enumerate() {
|
||||
let high = (byte_chars[0] as char).to_digit(16)?;
|
||||
let low = (byte_chars[1] as char).to_digit(16)?;
|
||||
out[i] = ((high << 4) | low) as u8;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn user_agent() -> &'static str {
|
||||
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GhRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GhAsset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GhAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
/// GitHub started returning `digest: "sha256:..."` on the asset
|
||||
/// object in 2024. Older releases omit it; we treat that as
|
||||
/// "verification unavailable" rather than a hard error.
|
||||
#[serde(default)]
|
||||
digest: Option<String>,
|
||||
}
|
||||
+138
-12
@@ -17,10 +17,7 @@ const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
const BETA_HEADER: &str = "oauth-2025-04-20";
|
||||
const API_VERSION: &str = "2023-06-01";
|
||||
|
||||
const PROBE_MODELS: &[&str] = &[
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-haiku-4-5-20251001",
|
||||
];
|
||||
const PROBE_MODELS: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"];
|
||||
|
||||
pub struct ClaudeProvider {
|
||||
locator: Locator,
|
||||
@@ -86,13 +83,16 @@ fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let body: OauthUsage = match resp.json() {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
Ok(parse_usage_body(resp.body()))
|
||||
}
|
||||
|
||||
/// Windows from an `/api/oauth/usage` body, or `None` when the body does not
|
||||
/// match the expected shape (which sends the caller to the Messages fallback).
|
||||
fn parse_usage_body(body: &[u8]) -> Option<UsageWindows> {
|
||||
let body: OauthUsage = serde_json::from_slice(body).ok()?;
|
||||
let primary = body.five_hour.map(bucket_to_window).unwrap_or_default();
|
||||
let secondary = body.seven_day.map(bucket_to_window).unwrap_or_default();
|
||||
Ok(Some(UsageWindows { primary, secondary }))
|
||||
Some(UsageWindows { primary, secondary })
|
||||
}
|
||||
|
||||
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> {
|
||||
@@ -120,8 +120,12 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
|
||||
return Err(Error::AuthRequired);
|
||||
}
|
||||
// Even an error response from Messages can carry rate-limit headers.
|
||||
if resp.header("anthropic-ratelimit-unified-5h-utilization").is_some()
|
||||
|| resp.header("anthropic-ratelimit-unified-7d-utilization").is_some()
|
||||
if resp
|
||||
.header("anthropic-ratelimit-unified-5h-utilization")
|
||||
.is_some()
|
||||
|| resp
|
||||
.header("anthropic-ratelimit-unified-7d-utilization")
|
||||
.is_some()
|
||||
{
|
||||
return Ok(headers::parse_anthropic(&resp));
|
||||
}
|
||||
@@ -240,7 +244,7 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||
}
|
||||
|
||||
fn is_leap(year: u64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
|
||||
}
|
||||
|
||||
// --- JSON shape ---
|
||||
@@ -256,3 +260,125 @@ struct Bucket {
|
||||
utilization: f64,
|
||||
resets_at: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(secs: u64) -> Option<SystemTime> {
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
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}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_usage_body_maps_both_buckets() {
|
||||
let w = parse_usage_body(&fixture("anthropic-usage-full.json")).expect("parses");
|
||||
assert_eq!(w.primary.utilization, 42.5);
|
||||
assert_eq!(w.primary.resets_at, at(1_761_040_800));
|
||||
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
|
||||
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
|
||||
assert!(has_reset_times(&w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_usage_body_keeps_percentages_and_merges_fallback_resets() {
|
||||
let w = parse_usage_body(&fixture("anthropic-usage-partial.json")).expect("parses");
|
||||
assert_eq!(w.primary.utilization, 7.0);
|
||||
assert_eq!(w.primary.resets_at, None);
|
||||
assert_eq!(w.secondary.resets_at, at(1_709_251_199));
|
||||
assert!(!has_reset_times(&w));
|
||||
|
||||
let fallback = UsageWindows {
|
||||
primary: Window {
|
||||
utilization: 99.0,
|
||||
resets_at: at(10),
|
||||
},
|
||||
secondary: Window {
|
||||
utilization: 99.0,
|
||||
resets_at: at(20),
|
||||
},
|
||||
};
|
||||
let merged = merge_resets(w, fallback);
|
||||
assert_eq!(merged.primary.utilization, 7.0, "percent is not replaced");
|
||||
assert_eq!(merged.primary.resets_at, at(10));
|
||||
assert_eq!(merged.secondary.resets_at, at(1_709_251_199));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bucket_defaults_to_empty_window() {
|
||||
let w = parse_usage_body(&fixture("anthropic-usage-five-hour-only.json")).expect("parses");
|
||||
assert_eq!(w.primary.utilization, 3.0);
|
||||
assert_eq!(w.secondary.utilization, 0.0);
|
||||
assert_eq!(w.secondary.resets_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_buckets_parse_as_empty_windows() {
|
||||
let w = parse_usage_body(&fixture("anthropic-usage-null-buckets.json")).expect("parses");
|
||||
assert_eq!(w.primary.utilization, 0.0);
|
||||
assert_eq!(w.secondary.utilization, 0.0);
|
||||
assert!(!has_reset_times(&w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_utilization_rejects_body_so_fallback_runs() {
|
||||
assert!(parse_usage_body(&fixture("anthropic-usage-null-utilization.json")).is_none());
|
||||
assert!(parse_usage_body(b"not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_accepts_utc_offsets_and_fractions() {
|
||||
assert_eq!(parse_iso8601("2025-10-21T10:00:00Z"), at(1_761_040_800));
|
||||
assert_eq!(
|
||||
parse_iso8601("2025-10-21T10:00:00.999999+00:00"),
|
||||
at(1_761_040_800)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_iso8601("2025-10-27T03:00:00+07:00"),
|
||||
at(1_761_508_800)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_iso8601("2025-10-21T05:30:00-04:30"),
|
||||
at(1_761_040_800)
|
||||
);
|
||||
assert_eq!(parse_iso8601("2025-10-21T10:00:00"), at(1_761_040_800));
|
||||
assert_eq!(parse_iso8601("2024-02-29T23:59:59Z"), at(1_709_251_199));
|
||||
assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), at(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_rejects_malformed_values() {
|
||||
assert_eq!(
|
||||
parse_iso8601("2025-10-21T10:00:00+0000"),
|
||||
None,
|
||||
"colonless offset"
|
||||
);
|
||||
assert_eq!(parse_iso8601("2025-10-21t10:00:00Z"), None, "lowercase t");
|
||||
assert_eq!(parse_iso8601("1969-12-31T23:59:59Z"), None, "before 1970");
|
||||
assert_eq!(
|
||||
parse_iso8601("1970-01-01T00:30:00+01:00"),
|
||||
None,
|
||||
"negative epoch"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_iso8601("2025-02-29T00:00:00Z"),
|
||||
None,
|
||||
"not a leap year"
|
||||
);
|
||||
assert_eq!(parse_iso8601("2025-13-01T00:00:00Z"), None);
|
||||
assert_eq!(parse_iso8601("2025-10-21 10:00:00Z"), None);
|
||||
assert_eq!(parse_iso8601("2025-10-21T10:00Z"), None);
|
||||
assert_eq!(parse_iso8601(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_expiry_compares_milliseconds() {
|
||||
assert!(!token_is_expired(None));
|
||||
assert!(token_is_expired(Some(0)));
|
||||
assert!(!token_is_expired(Some(i64::MAX)));
|
||||
}
|
||||
}
|
||||
@@ -125,3 +125,52 @@ impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
|
||||
self.and_then(|inner| inner.map(|b| *b))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(secs: u64) -> Option<SystemTime> {
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
fn envelope(name: &str) -> Envelope {
|
||||
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
|
||||
serde_json::from_str(&text).expect("fixture parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_envelope_maps_both_windows() {
|
||||
let w = envelope_to_windows(envelope("chatgpt-usage-full.json")).expect("rate_limit");
|
||||
assert_eq!(w.primary.utilization, 12.0);
|
||||
assert_eq!(w.primary.resets_at, at(1_761_040_800));
|
||||
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
|
||||
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_rate_limit_is_a_bad_response() {
|
||||
assert!(envelope_to_windows(envelope("chatgpt-usage-null-rate-limit.json")).is_none());
|
||||
let missing: Envelope = serde_json::from_str("{}").expect("parses");
|
||||
assert!(envelope_to_windows(missing).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_window_defaults_to_empty() {
|
||||
let w =
|
||||
envelope_to_windows(envelope("chatgpt-usage-null-windows.json")).expect("rate_limit");
|
||||
assert_eq!(w.primary.utilization, 55.0);
|
||||
assert_eq!(w.secondary.utilization, 0.0);
|
||||
assert_eq!(w.secondary.resets_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_reset_has_no_reset_time_and_percent_is_clamped() {
|
||||
let w =
|
||||
envelope_to_windows(envelope("chatgpt-usage-negative-reset.json")).expect("rate_limit");
|
||||
assert_eq!(w.primary.utilization, 0.0);
|
||||
assert_eq!(w.primary.resets_at, None);
|
||||
assert_eq!(w.secondary.resets_at, at(0));
|
||||
}
|
||||
}
|
||||
@@ -53,3 +53,95 @@ fn unix_to_systemtime(secs: Option<i64>) -> Option<SystemTime> {
|
||||
}
|
||||
Some(UNIX_EPOCH + Duration::from_secs(s as u64))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::test_server::serve_once;
|
||||
use crate::net::{Client, USER_AGENT};
|
||||
|
||||
fn at(secs: u64) -> Option<SystemTime> {
|
||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractions_scale_to_percent_and_resets_parse() {
|
||||
let resp = Response::from_parts(
|
||||
200,
|
||||
&[
|
||||
("anthropic-ratelimit-unified-5h-utilization", "0.425"),
|
||||
("anthropic-ratelimit-unified-5h-reset", "1761040800"),
|
||||
("Anthropic-Ratelimit-Unified-7d-Utilization", "1.2"),
|
||||
("anthropic-ratelimit-unified-7d-reset", "1761508800"),
|
||||
],
|
||||
b"",
|
||||
);
|
||||
let w = parse_anthropic(&resp);
|
||||
assert!((w.primary.utilization - 42.5).abs() < 1e-9);
|
||||
assert_eq!(w.primary.resets_at, at(1_761_040_800));
|
||||
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
|
||||
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_garbage_values_fall_back_to_empty() {
|
||||
let resp = Response::from_parts(
|
||||
200,
|
||||
&[
|
||||
("anthropic-ratelimit-unified-5h-utilization", "abc"),
|
||||
("anthropic-ratelimit-unified-5h-reset", "soon"),
|
||||
],
|
||||
b"",
|
||||
);
|
||||
let w = parse_anthropic(&resp);
|
||||
assert_eq!(w.primary.utilization, 0.0);
|
||||
assert_eq!(w.primary.resets_at, None);
|
||||
assert_eq!(w.secondary.utilization, 0.0);
|
||||
assert_eq!(w.secondary.resets_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_values_clamp_and_drop_reset() {
|
||||
let resp = Response::from_parts(
|
||||
200,
|
||||
&[
|
||||
("anthropic-ratelimit-unified-5h-utilization", "-0.3"),
|
||||
("anthropic-ratelimit-unified-5h-reset", "-1"),
|
||||
("anthropic-ratelimit-unified-7d-utilization", "0.5"),
|
||||
("anthropic-ratelimit-unified-7d-reset", "0"),
|
||||
],
|
||||
b"",
|
||||
);
|
||||
let w = parse_anthropic(&resp);
|
||||
assert_eq!(w.primary.utilization, 0.0);
|
||||
assert_eq!(w.primary.resets_at, None);
|
||||
assert_eq!(w.secondary.utilization, 50.0);
|
||||
assert_eq!(w.secondary.resets_at, at(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_reply_still_yields_headers_over_http() {
|
||||
let served = serve_once(
|
||||
"429 Too Many Requests",
|
||||
&[
|
||||
("anthropic-ratelimit-unified-5h-utilization", "1.0"),
|
||||
("anthropic-ratelimit-unified-5h-reset", "1761040800"),
|
||||
("anthropic-ratelimit-unified-7d-utilization", "0.25"),
|
||||
("anthropic-ratelimit-unified-7d-reset", "1761508800"),
|
||||
],
|
||||
br#"{"type":"error","error":{"type":"rate_limit_error"}}"#.to_vec(),
|
||||
);
|
||||
let client = Client::without_proxy(USER_AGENT);
|
||||
let resp = client
|
||||
.post(&served.base)
|
||||
.json_body(&serde_json::json!({"max_tokens": 1}))
|
||||
.and_then(crate::net::RequestBuilder::send)
|
||||
.expect("429 is a response, not an error");
|
||||
assert_eq!(resp.status(), 429);
|
||||
let w = parse_anthropic(&resp);
|
||||
assert_eq!(w.primary.utilization, 100.0);
|
||||
assert_eq!(w.primary.resets_at, at(1_761_040_800));
|
||||
assert_eq!(w.secondary.utilization, 25.0);
|
||||
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -1,8 +1,5 @@
|
||||
// Usage subsystem: trait + per-provider implementations + registry.
|
||||
//
|
||||
// Phase 2 stubs out only `types`. Phase 4 fills in `anthropic`, `chatgpt`,
|
||||
// `refresh`, `registry`, and `headers`. The trait below is the contract
|
||||
// every provider must satisfy.
|
||||
// Usage subsystem: the provider trait, the per-provider implementations,
|
||||
// the registry that polls them and the credential refresh orchestrator.
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod chatgpt;
|
||||
|
||||
+276
-34
@@ -4,14 +4,22 @@
|
||||
// which CLI to spawn. We invoke that CLI and watch the credential file's
|
||||
// signature; if it changes within the timeout we declare success.
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::creds::{CredentialSource, RefreshHint};
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// Directories searched after `PATH` on Unix. A desktop session often starts
|
||||
/// GUI apps with a shorter `PATH` than the login shell, which misses the
|
||||
/// usual per-user install locations of the Claude and Codex CLIs.
|
||||
#[cfg(unix)]
|
||||
const EXTRA_HOME_DIRS: &[&str] = &[".local/bin", ".claude/local"];
|
||||
#[cfg(unix)]
|
||||
const EXTRA_SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Refreshed,
|
||||
@@ -55,54 +63,288 @@ impl Orchestrator {
|
||||
}
|
||||
|
||||
fn spawn_cli(hint: &RefreshHint) -> bool {
|
||||
let (candidates, args) = cli_for(hint);
|
||||
spawn_first(candidates, args)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) {
|
||||
match hint {
|
||||
RefreshHint::LocalClaudeCli => {
|
||||
spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."])
|
||||
}
|
||||
RefreshHint::LocalCodexCli => spawn_local(
|
||||
RefreshHint::LocalClaudeCli => (&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
|
||||
RefreshHint::LocalCodexCli => (
|
||||
&["codex.cmd", "codex.ps1", "codex.exe", "codex"],
|
||||
&["exec", "."],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
|
||||
#[cfg(unix)]
|
||||
fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) {
|
||||
match hint {
|
||||
RefreshHint::LocalClaudeCli => (&["claude"], &["-p", "."]),
|
||||
RefreshHint::LocalCodexCli => (&["codex"], &["exec", "."]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the first candidate that starts. The child is detached: a reaper
|
||||
/// thread waits on it so no zombie is left behind.
|
||||
fn spawn_first(candidates: &[&str], args: &[&str]) -> bool {
|
||||
for name in candidates {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let mut cmd = if lower.ends_with(".ps1") {
|
||||
let mut c = Command::new("powershell.exe");
|
||||
c.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
.arg("-File")
|
||||
.arg(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
} else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.arg("/c").arg(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new(name);
|
||||
for a in args {
|
||||
c.arg(a);
|
||||
}
|
||||
c
|
||||
let Some(mut cmd) = command_for(name, args) else {
|
||||
continue;
|
||||
};
|
||||
cmd.env_remove("CLAUDECODE")
|
||||
.env_remove("CLAUDE_CODE_ENTRYPOINT")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
if cmd.spawn().is_ok() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
if let Ok(child) = cmd.spawn() {
|
||||
reap_detached(child);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn reap_detached(mut child: Child) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = child.wait() {
|
||||
log::warn!("refresh CLI could not be reaped: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn command_for(name: &str, args: &[&str]) -> Option<Command> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let mut cmd = if lower.ends_with(".ps1") {
|
||||
let mut c = Command::new("powershell.exe");
|
||||
c.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
.arg("-File")
|
||||
.arg(name);
|
||||
c
|
||||
} else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.arg("/c").arg(name);
|
||||
c
|
||||
} else {
|
||||
Command::new(name)
|
||||
};
|
||||
cmd.args(args);
|
||||
Some(cmd)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn command_for(name: &str, args: &[&str]) -> Option<Command> {
|
||||
let path_var = std::env::var_os("PATH");
|
||||
let home = dirs::home_dir();
|
||||
let dirs = search_dirs(path_var.as_deref(), home.as_deref());
|
||||
command_in(name, args, &dirs)
|
||||
}
|
||||
|
||||
/// Resolve `name` in `dirs` and run it with `PATH` set to the same list.
|
||||
/// npm-installed CLIs are `#!/usr/bin/env node` scripts, so the child has to
|
||||
/// see the extra install directories too: a GUI session's short `PATH`
|
||||
/// would let the script start and then exit at once with "node not found".
|
||||
#[cfg(unix)]
|
||||
fn command_in(name: &str, args: &[&str], dirs: &[std::path::PathBuf]) -> Option<Command> {
|
||||
let program = find_executable(name, dirs)?;
|
||||
let path = match std::env::join_paths(dirs) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
log::warn!("refresh CLI search path cannot be joined: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args).env("PATH", path);
|
||||
Some(cmd)
|
||||
}
|
||||
|
||||
/// `PATH` entries in order, then the extra install locations, without
|
||||
/// duplicates. Empty and relative entries are skipped so the working
|
||||
/// directory is never searched.
|
||||
#[cfg(unix)]
|
||||
fn search_dirs(
|
||||
path_var: Option<&std::ffi::OsStr>,
|
||||
home: Option<&std::path::Path>,
|
||||
) -> Vec<std::path::PathBuf> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let from_path = path_var.map(|p| std::env::split_paths(p).collect::<Vec<_>>());
|
||||
let extras_home = home
|
||||
.into_iter()
|
||||
.flat_map(|h| EXTRA_HOME_DIRS.iter().map(move |d| h.join(d)));
|
||||
let extras_system = EXTRA_SYSTEM_DIRS.iter().map(PathBuf::from);
|
||||
for dir in from_path
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(extras_home)
|
||||
.chain(extras_system)
|
||||
{
|
||||
if dir.is_absolute() && !out.contains(&dir) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// First `dir/name` that is a regular file with an execute bit set.
|
||||
#[cfg(unix)]
|
||||
fn find_executable(name: &str, dirs: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
dirs.iter().map(|d| d.join(name)).find(|candidate| {
|
||||
std::fs::metadata(candidate)
|
||||
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ccub-refresh-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or_default()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_dirs_puts_path_first_then_extras_without_duplicates() {
|
||||
let path = OsString::from("/usr/bin::relative/bin:/usr/local/bin:/usr/bin");
|
||||
let dirs = search_dirs(Some(&path), Some(Path::new("/home/u")));
|
||||
let expected: Vec<PathBuf> = [
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/home/u/.local/bin",
|
||||
"/home/u/.claude/local",
|
||||
"/opt/homebrew/bin",
|
||||
]
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
assert_eq!(dirs, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_dirs_without_path_or_home_keeps_system_extras() {
|
||||
let dirs = search_dirs(None, None);
|
||||
assert_eq!(
|
||||
dirs,
|
||||
vec![
|
||||
PathBuf::from("/opt/homebrew/bin"),
|
||||
PathBuf::from("/usr/local/bin")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_executable_skips_missing_and_non_executable_files() {
|
||||
let first = temp_dir("first");
|
||||
let second = temp_dir("second");
|
||||
write_file(&first.join("claude"), 0o644);
|
||||
std::fs::create_dir_all(first.join("codex")).expect("dir named like the CLI");
|
||||
write_file(&second.join("claude"), 0o755);
|
||||
let dirs = vec![first.clone(), second.clone()];
|
||||
assert_eq!(
|
||||
find_executable("claude", &dirs),
|
||||
Some(second.join("claude"))
|
||||
);
|
||||
assert_eq!(find_executable("codex", &dirs), None);
|
||||
assert_eq!(find_executable("absent", &dirs), None);
|
||||
let _ = std::fs::remove_dir_all(first);
|
||||
let _ = std::fs::remove_dir_all(second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawned_child_is_reaped() {
|
||||
let dir = temp_dir("spawn");
|
||||
let script = dir.join("fake-cli");
|
||||
let marker = dir.join("ran");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("#!/bin/sh\necho \"$@\" > '{}'\n", marker.display()),
|
||||
)
|
||||
.expect("write script");
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
let name = script.to_str().expect("utf8 path");
|
||||
assert!(spawn_first(
|
||||
&["definitely-not-a-cli-name", name],
|
||||
&["-p", "."]
|
||||
));
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !marker.exists() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let written = std::fs::read_to_string(&marker).unwrap_or_default();
|
||||
assert_eq!(written.trim(), "-p .");
|
||||
assert!(!spawn_first(&["definitely-not-a-cli-name"], &[]));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_path_resolves_env_interpreter_from_extra_dirs() {
|
||||
let tools = temp_dir("tools");
|
||||
let bin = temp_dir("bin");
|
||||
let marker = bin.join("ran");
|
||||
let helper = "ccub-fake-interpreter";
|
||||
std::fs::write(
|
||||
tools.join(helper),
|
||||
format!("#!/bin/sh\necho interpreted > '{}'\n", marker.display()),
|
||||
)
|
||||
.expect("write helper");
|
||||
std::fs::set_permissions(tools.join(helper), std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod helper");
|
||||
std::fs::write(bin.join("fake-cli"), format!("#!/usr/bin/env {helper}\n"))
|
||||
.expect("write script");
|
||||
std::fs::set_permissions(bin.join("fake-cli"), std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod script");
|
||||
let dirs = vec![
|
||||
PathBuf::from("/usr/bin"),
|
||||
PathBuf::from("/bin"),
|
||||
bin.clone(),
|
||||
tools.clone(),
|
||||
];
|
||||
let mut cmd = command_in("fake-cli", &[], &dirs).expect("script is found");
|
||||
let status = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.expect("script runs");
|
||||
assert!(status.success(), "child exited with {status}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&marker).unwrap_or_default().trim(),
|
||||
"interpreted"
|
||||
);
|
||||
assert!(command_in("absent-cli", &[], &dirs).is_none());
|
||||
let _ = std::fs::remove_dir_all(tools);
|
||||
let _ = std::fs::remove_dir_all(bin);
|
||||
}
|
||||
}
|
||||
+47
-1
@@ -4,9 +4,9 @@
|
||||
// every cycle. The result is a flat list of `(id, Result<UsageWindows>)`
|
||||
// pairs the app can apply to its state.
|
||||
|
||||
use crate::core::settings::Settings;
|
||||
use crate::creds::Locator;
|
||||
use crate::net::Client;
|
||||
use crate::settings::Settings;
|
||||
use crate::usage::{
|
||||
anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider,
|
||||
UsageWindows,
|
||||
@@ -74,3 +74,49 @@ impl Registry {
|
||||
sigs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::USER_AGENT;
|
||||
|
||||
#[test]
|
||||
fn live_usage_excludes_opencode_go() {
|
||||
// `poll_enabled` relies on this for its `unreachable!` arm.
|
||||
assert!(!ProviderId::LIVE_USAGE.contains(&ProviderId::OpenCodeGo));
|
||||
for provider in ProviderId::ALL {
|
||||
assert_eq!(
|
||||
ProviderId::LIVE_USAGE.contains(&provider),
|
||||
provider.metadata().live_usage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the real providers with the credentials on this machine. Run by
|
||||
/// hand: `cargo test -- --ignored live_poll --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "talks to the real provider endpoints"]
|
||||
fn live_poll() {
|
||||
let http = Client::new(USER_AGENT).expect("client");
|
||||
let mut settings = Settings::default();
|
||||
for provider in ProviderId::ALL {
|
||||
settings.set_provider_enabled(provider, true);
|
||||
}
|
||||
let mut registry = Registry::with_defaults();
|
||||
let results = registry.poll_enabled(&http, &settings);
|
||||
assert_eq!(results.len(), ProviderId::LIVE_USAGE.len());
|
||||
for (provider, result) in results {
|
||||
match result {
|
||||
Ok(w) => println!(
|
||||
"{}: 5h {:.1}% resets {:?}, weekly {:.1}% resets {:?}",
|
||||
provider.slug(),
|
||||
w.primary.utilization,
|
||||
w.primary.resets_at,
|
||||
w.secondary.utilization,
|
||||
w.secondary.resets_at
|
||||
),
|
||||
Err(err) => println!("{}: {err}", provider.slug()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -2,10 +2,17 @@
|
||||
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||
use serde::Serialize;
|
||||
|
||||
/// Serialises as its [`slug`](ProviderId::slug), which also names the
|
||||
/// provider's bubble window and tray icon.
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize)]
|
||||
pub enum ProviderId {
|
||||
#[serde(rename = "claude")]
|
||||
Claude,
|
||||
#[serde(rename = "chatgpt")]
|
||||
ChatGpt,
|
||||
#[serde(rename = "opencode-go")]
|
||||
OpenCodeGo,
|
||||
}
|
||||
|
||||
@@ -21,6 +28,11 @@ impl ProviderId {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`slug`](ProviderId::slug).
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| p.slug() == slug)
|
||||
}
|
||||
|
||||
pub fn metadata(self) -> ProviderMetadata {
|
||||
match self {
|
||||
Self::Claude => ProviderMetadata {
|
||||
@@ -87,3 +99,19 @@ pub struct ProviderSnapshot {
|
||||
pub id: ProviderId,
|
||||
pub windows: UsageWindows,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serialises_as_slug_and_parses_back() {
|
||||
for provider in ProviderId::ALL {
|
||||
let json = serde_json::to_string(&provider).expect("serialise");
|
||||
assert_eq!(json, format!("\"{}\"", provider.slug()));
|
||||
assert_eq!(ProviderId::from_slug(provider.slug()), Some(provider));
|
||||
assert_eq!(provider.metadata().slug, provider.slug());
|
||||
}
|
||||
assert_eq!(ProviderId::from_slug("codex"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Claude Code Usage Bubble",
|
||||
"mainBinaryName": "claude-code-usage-bubble",
|
||||
"identifier": "io.github.tiennm99.claudecodeusagebubble",
|
||||
"build": {
|
||||
"frontendDist": "ui"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"macOSPrivateApi": true,
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["appimage",
|
||||
"deb"
|
||||
],
|
||||
"createUpdaterArtifacts": false,
|
||||
"copyright": "Copyright (C) 2026 tiennm99",
|
||||
"publisher": "tiennm99",
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "",
|
||||
"endpoints": [
|
||||
"https://github.com/tiennm99/claude-code-usage-bubble/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"five_hour": { "utilization": 3.0, "resets_at": "2025-10-21T10:00:00Z" }
|
||||
}
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user