diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..0f429e4
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,365 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: macos-26
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
+ - name: Select Xcode
+ run: Scripts/select-xcode.sh
+ - name: Run every hook
+ run: just lint
+
+ package-compatibility:
+ name: Package (${{ matrix.name }})
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: macOS 14 / Xcode 16
+ runner: ${{ vars.MACOS_14_RUNNER || 'macos-14' }}
+ minimum-xcode: 16
+ - name: macOS 15 / Xcode 26
+ runner: macos-15
+ minimum-xcode: 26
+ - name: Xcode 27 SDK
+ runner: ${{ vars.XCODE_27_RUNNER || 'xcode-27' }}
+ minimum-xcode: 27
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Select Xcode
+ id: xcode
+ env:
+ MIN_XCODE_MAJOR: ${{ matrix.minimum-xcode }}
+ EXACT_XCODE_MAJOR: ${{ matrix.minimum-xcode }}
+ run: |
+ Scripts/select-xcode.sh
+ version="$(xcodebuild -version | tr '\n' '-')"
+ echo "cache=$(printf '%s' "$version" | shasum -a 256 | cut -c 1-12)" >> "$GITHUB_OUTPUT"
+ - name: Cache SwiftPM
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: .build
+ key: spm-${{ runner.os }}-${{ runner.arch }}-${{ matrix.runner }}-${{ steps.xcode.outputs.cache }}-${{ hashFiles('Package.swift')
+ }}
+ - name: Build
+ run: swift build
+ - name: Test
+ run: Scripts/check-test-isolation.sh && swift test --no-parallel
+
+ coverage:
+ name: Tests and coverage (macOS 26)
+ runs-on: macos-26
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
+ - name: Select Xcode
+ id: xcode
+ run: |
+ Scripts/select-xcode.sh
+ version="$(xcodebuild -version | tr '\n' '-')"
+ echo "cache=$(printf '%s' "$version" | shasum -a 256 | cut -c 1-12)" >> "$GITHUB_OUTPUT"
+ - name: Cache SwiftPM
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: .build
+ key: spm-${{ runner.os }}-${{ runner.arch }}-macos-26-${{ steps.xcode.outputs.cache }}-${{ hashFiles('Package.swift')
+ }}
+ - name: Build
+ run: swift build
+ - name: Tests with coverage gate
+ run: just coverage
+
+ application-ui:
+ name: Application UI (${{ matrix.name }})
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: macOS 14
+ runner: ${{ vars.MACOS_14_RUNNER || 'macos-14' }}
+ minimum-xcode: 16
+ - name: macOS 15
+ runner: macos-15
+ minimum-xcode: 26
+ - name: macOS 26
+ runner: macos-26
+ minimum-xcode: 26
+ - name: Xcode 27 SDK
+ runner: ${{ vars.XCODE_27_RUNNER || 'xcode-27' }}
+ minimum-xcode: 27
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
+ - name: Select Xcode
+ id: xcode
+ env:
+ MIN_XCODE_MAJOR: ${{ matrix.minimum-xcode }}
+ EXACT_XCODE_MAJOR: ${{ matrix.minimum-xcode }}
+ run: |
+ Scripts/select-xcode.sh
+ version="$(xcodebuild -version | tr '\n' '-')"
+ echo "cache=$(printf '%s' "$version" | shasum -a 256 | cut -c 1-12)" >> "$GITHUB_OUTPUT"
+ - name: Cache Xcode packages
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: .build/xcode-packages
+ key: xcode-spm-${{ runner.os }}-${{ runner.arch }}-${{ matrix.runner }}-${{ steps.xcode.outputs.cache }}-${{ hashFiles('Package.swift',
+ 'App/project.yml') }}
+ - name: Generate project
+ run: just xcode
+ - name: Test the running application
+ run: |
+ set -o pipefail
+ xcodebuild -project App/TokenMenuBar.xcodeproj -scheme TokenMenuBar-Direct -configuration Debug \
+ -destination 'platform=macOS' -derivedDataPath .build/ui-derived \
+ -clonedSourcePackagesDirPath .build/xcode-packages \
+ -only-testing:TokenMenuBarApplicationUITests test | tail -50
+
+ application:
+ name: Build ${{ matrix.scheme }} (${{ matrix.name }})
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: macOS 26
+ runner: macos-26
+ minimum-xcode: 26
+ scheme: TokenMenuBar-Direct
+ target: TokenMenuBarDirect
+ configuration: Release
+ distribution: Direct
+ condition: DIRECT
+ sandbox: NO
+ entitlements: Direct.entitlements
+ updater: required
+ - name: macOS 26
+ runner: macos-26
+ minimum-xcode: 26
+ scheme: TokenMenuBar-AppStore
+ target: TokenMenuBarAppStore
+ configuration: AppStore
+ distribution: App Store
+ condition: APPSTORE
+ sandbox: YES
+ entitlements: AppStore.entitlements
+ updater: forbidden
+ - name: macOS 26
+ runner: macos-26
+ minimum-xcode: 26
+ scheme: TokenMenuBar-Homebrew
+ target: TokenMenuBarHomebrew
+ configuration: Homebrew
+ distribution: Homebrew
+ condition: HOMEBREW
+ sandbox: NO
+ entitlements: Direct.entitlements
+ updater: forbidden
+ - name: Xcode 27 SDK
+ runner: ${{ vars.XCODE_27_RUNNER || 'xcode-27' }}
+ minimum-xcode: 27
+ scheme: TokenMenuBar-Direct
+ target: TokenMenuBarDirect
+ configuration: Release
+ distribution: Direct
+ condition: DIRECT
+ sandbox: NO
+ entitlements: Direct.entitlements
+ updater: required
+ - name: Xcode 27 SDK
+ runner: ${{ vars.XCODE_27_RUNNER || 'xcode-27' }}
+ minimum-xcode: 27
+ scheme: TokenMenuBar-AppStore
+ target: TokenMenuBarAppStore
+ configuration: AppStore
+ distribution: App Store
+ condition: APPSTORE
+ sandbox: YES
+ entitlements: AppStore.entitlements
+ updater: forbidden
+ - name: Xcode 27 SDK
+ runner: ${{ vars.XCODE_27_RUNNER || 'xcode-27' }}
+ minimum-xcode: 27
+ scheme: TokenMenuBar-Homebrew
+ target: TokenMenuBarHomebrew
+ configuration: Homebrew
+ distribution: Homebrew
+ condition: HOMEBREW
+ sandbox: NO
+ entitlements: Direct.entitlements
+ updater: forbidden
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
+ - name: Select Xcode
+ id: xcode
+ env:
+ MIN_XCODE_MAJOR: ${{ matrix.minimum-xcode }}
+ EXACT_XCODE_MAJOR: ${{ matrix.minimum-xcode }}
+ run: |
+ Scripts/select-xcode.sh
+ version="$(xcodebuild -version | tr '\n' '-')"
+ echo "cache=$(printf '%s' "$version" | shasum -a 256 | cut -c 1-12)" >> "$GITHUB_OUTPUT"
+ - name: Cache Xcode packages
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: .build/xcode-packages
+ key: xcode-spm-${{ runner.os }}-${{ runner.arch }}-${{ matrix.runner }}-${{ steps.xcode.outputs.cache }}-${{ hashFiles('Package.swift',
+ 'App/project.yml') }}
+ - name: Generate project
+ run: just xcode
+ - name: Build
+ env:
+ SCHEME: ${{ matrix.scheme }}
+ CONFIGURATION: ${{ matrix.configuration }}
+ run: |
+ set -o pipefail
+ xcodebuild -project App/TokenMenuBar.xcodeproj -scheme "$SCHEME" -configuration "$CONFIGURATION" \
+ -destination 'platform=macOS' -derivedDataPath .build/app-derived \
+ -clonedSourcePackagesDirPath .build/xcode-packages CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY="" \
+ SPARKLE_PUBLIC_ED_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= build \
+ | tail -30
+ - name: Verify distribution settings
+ env:
+ CONFIGURATION: ${{ matrix.configuration }}
+ DISTRIBUTION: ${{ matrix.distribution }}
+ CONDITION: ${{ matrix.condition }}
+ SANDBOX: ${{ matrix.sandbox }}
+ ENTITLEMENTS: ${{ matrix.entitlements }}
+ TARGET: ${{ matrix.target }}
+ run: Scripts/verify-build-settings.sh "$TARGET" "$CONFIGURATION" "$DISTRIBUTION" "$CONDITION" "$SANDBOX" "$ENTITLEMENTS"
+ - name: Verify every shipped deployment target
+ run: |
+ app="$(find .build/app-derived/Build/Products -path '*/Token Menu Bar.app' -type d | head -1)"
+ test -n "$app"
+ Scripts/verify-deployment-targets.sh "$app" 14.0
+ Scripts/verify-app-bundle.sh "$app" "${{ matrix.distribution }}" "${{ matrix.updater }}"
+ - name: Package the macOS 14 smoke artifact
+ if: matrix.runner == 'macos-26' && matrix.scheme == 'TokenMenuBar-Direct'
+ run: |
+ app="$(find .build/app-derived/Build/Products -path '*/Token Menu Bar.app' -type d | head -1)"
+ codesign --force --deep --sign - "$app"
+ ditto -c -k --keepParent "$app" .build/token-menu-bar-macos-14-smoke.zip
+ - name: Upload the macOS 14 smoke artifact
+ if: matrix.runner == 'macos-26' && matrix.scheme == 'TokenMenuBar-Direct'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: token-menu-bar-macos-14-smoke
+ path: .build/token-menu-bar-macos-14-smoke.zip
+ if-no-files-found: error
+ retention-days: 1
+
+ macos-14-runtime:
+ name: Release smoke (macOS 14 runtime)
+ needs: application
+ runs-on: ${{ vars.MACOS_14_RUNTIME_RUNNER || 'macos-14' }}
+ steps:
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: token-menu-bar-macos-14-smoke
+ path: smoke
+ - name: Launch with isolated data
+ run: |
+ ditto -x -k smoke/token-menu-bar-macos-14-smoke.zip smoke/app
+ executable="smoke/app/Token Menu Bar.app/Contents/MacOS/Token Menu Bar"
+ runtime="$PWD/smoke/runtime"
+ session="macos-14-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ support="$runtime/token-menu-bar-verify-$session"
+ ready="$support/snapshots-demo.json"
+ mkdir -p "$support"
+ TOKEN_MENU_BAR_VERIFY_SESSION="$session" TOKEN_MENU_BAR_VERIFY_SUPPORT_DIRECTORY="$support" \
+ "$executable" --verify-ui >smoke/launch.log 2>&1 &
+ pid=$!
+ for _ in {1..80}; do
+ if grep -Fq '"claude"' "$ready" 2>/dev/null && grep -Fq '"codex"' "$ready" 2>/dev/null; then
+ break
+ fi
+ if ! kill -0 "$pid" 2>/dev/null; then
+ cat smoke/launch.log
+ exit 1
+ fi
+ sleep 0.25
+ done
+ if ! grep -Fq '"claude"' "$ready" 2>/dev/null || ! grep -Fq '"codex"' "$ready" 2>/dev/null; then
+ cat smoke/launch.log
+ test ! -f "$support/log.txt" || cat "$support/log.txt"
+ kill "$pid" 2>/dev/null || true
+ wait "$pid" || true
+ exit 1
+ fi
+ kill "$pid" 2>/dev/null || true
+ wait "$pid" || true
+
+ macos-27-runtime:
+ name: Application UI (macOS 27 runtime)
+ if: vars.MACOS_27_RUNTIME_RUNNER != ''
+ runs-on: ${{ vars.MACOS_27_RUNTIME_RUNNER }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
+ - name: Select Xcode
+ env:
+ MIN_XCODE_MAJOR: 27
+ EXACT_XCODE_MAJOR: 27
+ run: Scripts/select-xcode.sh
+ - name: Generate project
+ run: just xcode
+ - name: Test the running application
+ run: |
+ set -o pipefail
+ xcodebuild -project App/TokenMenuBar.xcodeproj -scheme TokenMenuBar-Direct -configuration Debug \
+ -destination 'platform=macOS' -derivedDataPath .build/ui-derived \
+ -only-testing:TokenMenuBarApplicationUITests test | tail -50
+
+ macos-27-runtime-policy:
+ name: Required macOS 27 runtime
+ if: always()
+ needs: macos-27-runtime
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Require real runtime coverage
+ env:
+ RUNNER: ${{ vars.MACOS_27_RUNTIME_RUNNER }}
+ RESULT: ${{ needs.macos-27-runtime.result }}
+ run: |
+ if [ -z "$RUNNER" ]; then
+ echo "::error::Set MACOS_27_RUNTIME_RUNNER to a self-hosted runner running macOS 27"
+ echo "Xcode 27 jobs verify the SDK, not the macOS 27 runtime. This required status stays red until a real runtime runner is configured." \
+ >> "$GITHUB_STEP_SUMMARY"
+ exit 1
+ elif [ "$RESULT" != "success" ]; then
+ echo "::error::The configured macOS 27 runtime job did not pass"
+ exit 1
+ else
+ echo "macOS 27 runtime UI tests passed on $RUNNER." >> "$GITHUB_STEP_SUMMARY"
+ fi
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..fd3e773
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,37 @@
+name: Website
+
+on:
+ push:
+ branches: [main]
+ paths: ["website/**", "mise.toml", "mise.lock", ".readthedocs.yaml", ".github/workflows/docs.yml"]
+ pull_request:
+ paths: ["website/**", "mise.toml", "mise.lock", ".readthedocs.yaml", ".github/workflows/docs.yml"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: Build the site
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
+ # Read the Docs publishes the site; this only has to fail when a template or a shortcode breaks.
+ - name: Build
+ run: just site
+ - name: Check the generated pages
+ run: |
+ cd website
+ test -s public/index.html
+ test -s public/llms.txt
+ test -s public/sitemap.xml
+ test -s public/robots.txt
+ test -s public/start/index.md
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
new file mode 100644
index 0000000..4968e68
--- /dev/null
+++ b/.github/workflows/prepare-release.yml
@@ -0,0 +1,97 @@
+name: Prepare Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ bump:
+ description: Which part of the version to raise
+ type: choice
+ options: [patch, minor, major]
+ default: patch
+ version:
+ description: Exact version to release (1.2.3), overriding the bump
+ type: string
+ default: ""
+
+permissions:
+ contents: read
+
+jobs:
+ tag:
+ name: Tag the release
+ if: github.repository == 'tox-dev/token-menu-bar-macos'
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ outputs:
+ version: ${{ steps.version.outputs.next }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: true
+ - name: Work out the version
+ id: version
+ env:
+ BUMP: ${{ inputs.bump }}
+ EXPLICIT: ${{ inputs.version }}
+ run: |
+ if [ -n "$EXPLICIT" ]; then
+ next="${EXPLICIT#v}"
+ else
+ current=$(git tag --list 'v*' --sort=-v:refname | head -1)
+ current="${current#v}"
+ current="${current:-0.0.0}"
+ IFS='.' read -r major minor patch <<< "$current"
+ case "$BUMP" in
+ major) next="$((major + 1)).0.0" ;;
+ minor) next="$major.$((minor + 1)).0" ;;
+ patch) next="$major.$minor.$((patch + 1))" ;;
+ esac
+ fi
+ echo "next=$next" >> "$GITHUB_OUTPUT"
+ echo "Releasing $next"
+ - name: Push the tag
+ env:
+ NEXT: ${{ steps.version.outputs.next }}
+ run: |
+ if git rev-parse "v$NEXT" >/dev/null 2>&1; then
+ echo "::error::v$NEXT already exists"
+ exit 1
+ fi
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git tag -a "v$NEXT" -m "Release $NEXT"
+ git push origin "v$NEXT"
+ - name: Say what happens next
+ env:
+ NEXT: ${{ steps.version.outputs.next }}
+ run: |
+ echo "Tagged v$NEXT. Release builds the direct download, the cask and the App Store upload." \
+ >> "$GITHUB_STEP_SUMMARY"
+
+ # A tag pushed with GITHUB_TOKEN starts no workflow of its own, so the release runs here rather than waiting for
+ # the tag event. One run, one place to watch.
+ release:
+ name: Release
+ needs: tag
+ uses: ./.github/workflows/release.yml
+ with:
+ tag: v${{ needs.tag.outputs.version }}
+ permissions:
+ contents: write
+ secrets:
+ DEVELOPER_ID_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}
+ DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }}
+ APPLE_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.APPLE_DISTRIBUTION_CERTIFICATE_BASE64 }}
+ APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD }}
+ MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_BASE64 }}
+ MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_PASSWORD }}
+ APP_STORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APP_STORE_PROVISIONING_PROFILE_BASE64 }}
+ APP_STORE_WIDGET_PROVISIONING_PROFILE_BASE64: ${{ secrets.APP_STORE_WIDGET_PROVISIONING_PROFILE_BASE64 }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
+ APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
+ APP_STORE_CONNECT_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_BASE64 }}
+ SPARKLE_PUBLIC_ED_KEY: ${{ secrets.SPARKLE_PUBLIC_ED_KEY }}
+ SPARKLE_PRIVATE_ED_KEY: ${{ secrets.SPARKLE_PRIVATE_ED_KEY }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..299e516
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,303 @@
+name: Release
+
+on:
+ push:
+ tags: ["v*"]
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Existing tag to build (v1.2.3)
+ required: true
+ # Prepare Release calls this after tagging, so the whole release is one run and needs no token of its own.
+ workflow_call:
+ inputs:
+ tag:
+ description: The tag to build
+ required: true
+ type: string
+ secrets:
+ DEVELOPER_ID_CERTIFICATE_BASE64: {required: true}
+ DEVELOPER_ID_CERTIFICATE_PASSWORD: {required: true}
+ APPLE_DISTRIBUTION_CERTIFICATE_BASE64: {required: false}
+ APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD: {required: false}
+ MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_BASE64: {required: false}
+ MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_PASSWORD: {required: false}
+ APP_STORE_PROVISIONING_PROFILE_BASE64: {required: false}
+ APP_STORE_WIDGET_PROVISIONING_PROFILE_BASE64: {required: false}
+ APPLE_TEAM_ID: {required: true}
+ APP_STORE_CONNECT_KEY_ID: {required: true}
+ APP_STORE_CONNECT_ISSUER_ID: {required: true}
+ APP_STORE_CONNECT_KEY_BASE64: {required: true}
+ SPARKLE_PUBLIC_ED_KEY: {required: true}
+ SPARKLE_PRIVATE_ED_KEY: {required: true}
+
+permissions:
+ contents: read
+
+env:
+ TAG: ${{ inputs.tag || github.ref_name }}
+
+jobs:
+ preflight:
+ name: Which legs can run
+ # Only this repository holds the signing identities; a fork that runs the workflow gets nothing to leak.
+ if: github.repository == 'tox-dev/token-menu-bar-macos'
+ runs-on: ubuntu-24.04
+ # The signing secrets live in this environment, so every job that touches them names it and inherits its
+ # protection rules.
+ environment: release
+ outputs:
+ app_store: ${{ steps.check.outputs.app_store }}
+ steps:
+ # A job cannot read secrets in its own `if`, so the release works out here which legs it can publish.
+ - name: Check the App Store credentials
+ id: check
+ env:
+ APPLE_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.APPLE_DISTRIBUTION_CERTIFICATE_BASE64 }}
+ APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD }}
+ MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_BASE64 }}
+ MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_PASSWORD }}
+ APP_STORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APP_STORE_PROVISIONING_PROFILE_BASE64 }}
+ APP_STORE_WIDGET_PROVISIONING_PROFILE_BASE64: ${{ secrets.APP_STORE_WIDGET_PROVISIONING_PROFILE_BASE64 }}
+ run: |
+ missing=""
+ for name in APPLE_DISTRIBUTION_CERTIFICATE_BASE64 APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD \
+ MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_BASE64 MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_PASSWORD \
+ APP_STORE_PROVISIONING_PROFILE_BASE64 APP_STORE_WIDGET_PROVISIONING_PROFILE_BASE64; do
+ [ -n "${!name}" ] || missing="$missing $name"
+ done
+ if [ -n "$missing" ]; then
+ echo "app_store=false" >> "$GITHUB_OUTPUT"
+ echo "Skipping the App Store upload, no:$missing" >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo "app_store=true" >> "$GITHUB_OUTPUT"
+ fi
+ - name: Require a macOS 27 runtime runner
+ env:
+ MACOS_27_RUNTIME_RUNNER: ${{ vars.MACOS_27_RUNTIME_RUNNER }}
+ run: |
+ if [ -z "$MACOS_27_RUNTIME_RUNNER" ]; then
+ echo "::error::Set MACOS_27_RUNTIME_RUNNER to a self-hosted runner running macOS 27"
+ exit 1
+ fi
+
+ macos-27-runtime:
+ name: Release UI tests (macOS 27 runtime)
+ needs: preflight
+ runs-on: ${{ vars.MACOS_27_RUNTIME_RUNNER }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ env.TAG }}
+ persist-credentials: false
+ - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
+ with:
+ cache: false
+ - name: Select Xcode
+ env:
+ MIN_XCODE_MAJOR: 27
+ EXACT_XCODE_MAJOR: 27
+ run: Scripts/select-xcode.sh
+ - name: Generate project
+ run: just xcode
+ - name: Test the running application
+ run: |
+ set -o pipefail
+ xcodebuild -project App/TokenMenuBar.xcodeproj -scheme TokenMenuBar-Direct -configuration Debug \
+ -destination 'platform=macOS' -derivedDataPath .build/release-ui-derived \
+ -only-testing:TokenMenuBarApplicationUITests test | tail -50
+
+ direct:
+ name: Direct and Homebrew downloads
+ needs: [preflight, macos-27-runtime]
+ runs-on: macos-26
+ environment: release
+ # This job pushes the cask back to main and publishes the release.
+ permissions:
+ contents: write
+ outputs:
+ signed: ${{ steps.identity.outputs.signed }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ env.TAG }}
+ persist-credentials: false
+ - name: Select Xcode
+ run: Scripts/select-xcode.sh
+ - name: Install tools
+ run: brew install xcodegen
+ - name: Stamp version
+ run: Scripts/stamp-version.sh "${TAG#v}"
+ - name: Require release credentials
+ # A tag must never publish an app Gatekeeper rejects or a feed Sparkle cannot verify, so the job stops here
+ # rather than falling back to an ad-hoc build. Use the ad-hoc workflow_dispatch build for unsigned testing.
+ env:
+ CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}
+ CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }}
+ TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
+ APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
+ APP_STORE_CONNECT_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_BASE64 }}
+ SPARKLE_PUBLIC_ED_KEY: ${{ secrets.SPARKLE_PUBLIC_ED_KEY }}
+ SPARKLE_PRIVATE_ED_KEY: ${{ secrets.SPARKLE_PRIVATE_ED_KEY }}
+ run: |
+ missing=""
+ for name in CERTIFICATE_BASE64 CERTIFICATE_PASSWORD TEAM_ID APP_STORE_CONNECT_KEY_ID \
+ APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_KEY_BASE64 SPARKLE_PUBLIC_ED_KEY SPARKLE_PRIVATE_ED_KEY; do
+ [ -n "${!name}" ] || missing="$missing $name"
+ done
+ if [ -n "$missing" ]; then
+ echo "::error::Cannot publish a release without:$missing"
+ exit 1
+ fi
+ - name: Import Developer ID certificate
+ id: identity
+ env:
+ CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}
+ CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }}
+ run: |
+ Scripts/import-certificate.sh
+ echo "signed=true" >> "$GITHUB_OUTPUT"
+ - name: Generate project
+ run: cd App && xcodegen generate
+ - name: Archive and export
+ env:
+ SIGNED: ${{ steps.identity.outputs.signed }}
+ TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ SPARKLE_PUBLIC_ED_KEY: ${{ secrets.SPARKLE_PUBLIC_ED_KEY }}
+ run: Scripts/build-direct.sh
+ - name: Archive and export Homebrew
+ env:
+ SIGNED: ${{ steps.identity.outputs.signed }}
+ TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: Scripts/build-homebrew.sh
+ - name: Notarize and staple applications
+ if: steps.identity.outputs.signed == 'true'
+ env:
+ APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
+ APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
+ APP_STORE_CONNECT_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_BASE64 }}
+ run: |
+ Scripts/notarize.sh dist/direct
+ Scripts/notarize.sh dist/homebrew
+ - name: Package downloads
+ run: |
+ Scripts/package-direct.sh "${TAG#v}"
+ Scripts/package-homebrew.sh "${TAG#v}"
+ - name: Notarize and staple disk images
+ if: steps.identity.outputs.signed == 'true'
+ env:
+ APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
+ APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
+ APP_STORE_CONNECT_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_BASE64 }}
+ run: |
+ Scripts/notarize.sh dist/direct
+ Scripts/notarize.sh dist/homebrew
+ - name: Refresh checksums
+ run: |
+ for file in dist/direct/TokenMenuBar.zip dist/direct/TokenMenuBar.dmg \
+ dist/homebrew/TokenMenuBar-Homebrew.zip dist/homebrew/TokenMenuBar-Homebrew.dmg; do
+ shasum -a 256 "$file" | awk '{print $1}' > "$file.sha256"
+ done
+ - name: Generate Sparkle appcast
+ env:
+ SPARKLE_PRIVATE_ED_KEY: ${{ secrets.SPARKLE_PRIVATE_ED_KEY }}
+ run: Scripts/appcast.sh "${TAG#v}"
+ - name: Update Homebrew cask file
+ run: Scripts/update-cask.sh "${TAG#v}" dist/homebrew/TokenMenuBar-Homebrew.dmg
+ - name: Upload artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: downloads
+ path: |
+ dist/direct/*.zip
+ dist/direct/*.dmg
+ dist/direct/*.sha256
+ dist/direct/appcast.xml
+ dist/homebrew/*.zip
+ dist/homebrew/*.dmg
+ dist/homebrew/*.sha256
+ Casks/token-menu-bar.rb
+ - name: Publish GitHub release
+ # the action uploads the assets and writes the notes in one step
+ uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 # zizmor: ignore[superfluous-actions]
+ with:
+ tag_name: ${{ env.TAG }}
+ generate_release_notes: true
+ files: |
+ dist/direct/TokenMenuBar.zip
+ dist/direct/TokenMenuBar.dmg
+ dist/direct/TokenMenuBar.zip.sha256
+ dist/direct/TokenMenuBar.dmg.sha256
+ dist/direct/appcast.xml
+ dist/homebrew/TokenMenuBar-Homebrew.zip
+ dist/homebrew/TokenMenuBar-Homebrew.dmg
+ dist/homebrew/TokenMenuBar-Homebrew.zip.sha256
+ dist/homebrew/TokenMenuBar-Homebrew.dmg.sha256
+ Casks/token-menu-bar.rb
+ - name: Verify the Homebrew asset
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fx 'TokenMenuBar-Homebrew.dmg'
+ - name: Commit the cask back to the default branch
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git fetch "$remote" main
+ git stash push -- Casks/token-menu-bar.rb
+ git checkout FETCH_HEAD
+ git stash pop
+ if ! git diff --quiet -- Casks/token-menu-bar.rb; then
+ git add Casks/token-menu-bar.rb
+ git commit -m "Update Homebrew cask for ${TAG}"
+ git push "$remote" HEAD:main
+ fi
+
+ app-store:
+ name: Mac App Store upload
+ needs: [preflight, direct]
+ runs-on: macos-26
+ environment: release
+ permissions:
+ contents: read
+ if: needs.preflight.outputs.app_store == 'true'
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ env.TAG }}
+ persist-credentials: false
+ - name: Select Xcode
+ run: Scripts/select-xcode.sh
+ - name: Install tools
+ run: brew install xcodegen
+ - name: Stamp version
+ run: Scripts/stamp-version.sh "${TAG#v}"
+ - name: Import App Store certificates
+ env:
+ CERTIFICATE_BASE64: ${{ secrets.APPLE_DISTRIBUTION_CERTIFICATE_BASE64 }}
+ CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD }}
+ INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_BASE64 }}
+ INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_PASSWORD }}
+ run: Scripts/import-certificate.sh
+ - name: Install provisioning profile
+ env:
+ PROVISIONING_PROFILE_BASE64: ${{ secrets.APP_STORE_PROVISIONING_PROFILE_BASE64 }}
+ WIDGET_PROVISIONING_PROFILE_BASE64: ${{ secrets.APP_STORE_WIDGET_PROVISIONING_PROFILE_BASE64 }}
+ run: |
+ profiles=~/Library/MobileDevice/Provisioning\ Profiles
+ mkdir -p "$profiles"
+ echo "$PROVISIONING_PROFILE_BASE64" | base64 --decode > "$profiles/token-menu-bar.provisionprofile"
+ echo "$WIDGET_PROVISIONING_PROFILE_BASE64" | base64 --decode \
+ > "$profiles/token-menu-bar-widget.provisionprofile"
+ - name: Generate project
+ run: cd App && xcodegen generate
+ - name: Archive, export and upload
+ env:
+ TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
+ APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
+ APP_STORE_CONNECT_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_BASE64 }}
+ run: Scripts/build-app-store.sh
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..58e5d0c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+.build/
+build/
+App/build/
+.swiftpm/
+*.xcodeproj/
+DerivedData/
+dist/
+.DS_Store
+site/
+App/Widget/Info.plist
+default.profraw
+review.md
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..31b91b4
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,98 @@
+exclude: ^(website/public|website/resources|dist)/
+ci:
+ # swift-format needs a Swift toolchain, which the Linux runner does not have. The Lint job on macOS is where
+ # every hook, this one included, has to pass.
+ skip: [swift-format]
+ autofix_prs: false
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ - id: check-yaml
+ - id: check-toml
+ - id: check-json
+ - id: check-merge-conflict
+ - id: check-case-conflict
+ - id: check-added-large-files
+ args: ["--maxkb=4096"]
+ - id: mixed-line-ending
+ args: ["--fix=lf"]
+ - id: detect-private-key
+ - repo: https://github.com/hukkin/mdformat
+ rev: 1.0.0
+ hooks:
+ - id: mdformat
+ args: ["--wrap", "120", "--number"]
+ # website/layouts holds Hugo templates rather than prose, and reflowing a shortcode breaks the build
+ exclude: ^(website/layouts|review\.md)
+ additional_dependencies:
+ - mdformat-gfm>=0.4
+ - mdformat-hugo>=0.1
+ - mdformat-beautysh>=1
+ - mdformat-simple-breaks>=0.1
+ # shellcheck-py rather than the upstream hook: pre-commit.ci has no Docker
+ - repo: https://github.com/shellcheck-py/shellcheck-py
+ rev: v0.11.0.1
+ hooks:
+ - id: shellcheck
+ - repo: https://github.com/scop/pre-commit-shfmt
+ rev: v3.13.1-1
+ hooks:
+ - id: shfmt
+ args: ["--indent", "2", "--case-indent", "--space-redirects", "--write"]
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.16.5
+ hooks:
+ - id: ruff-format
+ - id: ruff-check
+ args: ["--fix"]
+ - repo: https://github.com/ComPWA/taplo-pre-commit
+ rev: v0.9.3
+ hooks:
+ - id: taplo-format
+ - repo: https://github.com/rbubley/mirrors-prettier
+ rev: v3.9.6
+ hooks:
+ - id: prettier
+ # The Hugo templates are Go templates in .html files, which prettier cannot parse
+ files: ^website/assets/.*\.(css|js)$
+ - repo: https://github.com/rhysd/actionlint
+ rev: v1.7.12
+ hooks:
+ - id: actionlint
+ - repo: https://github.com/zizmorcore/zizmor-pre-commit
+ rev: v1.28.0
+ hooks:
+ - id: zizmor
+ - repo: https://github.com/google/yamlfmt
+ rev: v0.21.0
+ hooks:
+ - id: yamlfmt
+ args: ["--formatter", "max_line_length=120,retain_line_breaks_single=true"]
+ # yamlfmt rewrites folded scalars in the site's data files into marker comments
+ exclude: ^website/data/
+ - repo: local
+ hooks:
+ - id: swift-format
+ name: swift-format
+ # The subcommand rather than the binary: Xcode ships swift-format inside the toolchain, off PATH.
+ entry: swift format lint --strict
+ language: system
+ types: [swift]
+ require_serial: false
+ # plistlib rather than plutil, and just from PyPI, so these run on Linux as well as a Mac
+ - id: plists
+ name: property lists parse
+ entry: python -c "import plistlib,sys;[plistlib.loads(open(f,'rb').read()) for f in sys.argv[1:]]"
+ language: python
+ files: \.(plist|entitlements|xcprivacy)$
+ - id: just-format
+ name: justfile formatting
+ entry: just --unstable --fmt --check --justfile
+ language: python
+ additional_dependencies: [rust-just]
+ files: ^justfile$
+ pass_filenames: false
+ args: [justfile]
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..573a1af
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,12 @@
+# Read the Docs builds the site the same way a laptop does: mise provides the pinned tools, just runs the recipe.
+# https://docs.readthedocs.com/platform/stable/build-customization.html
+version: 2
+
+build:
+ os: ubuntu-24.04
+ tools:
+ python: "3.13"
+ commands:
+ - curl --fail --location https://mise.run | sh
+ - MISE_SAFE=1 ~/.local/bin/mise install --locked hugo just
+ - MISE_EXEC_AUTO_INSTALL=0 MISE_SAFE=1 ~/.local/bin/mise exec -- just site-readthedocs
diff --git a/.swift-format b/.swift-format
new file mode 100644
index 0000000..9c8f2bb
--- /dev/null
+++ b/.swift-format
@@ -0,0 +1,23 @@
+{
+ "version": 1,
+ "lineLength": 120,
+ "indentation": { "spaces": 2 },
+ "maximumBlankLines": 1,
+ "respectsExistingLineBreaks": true,
+ "lineBreakBeforeControlFlowKeywords": false,
+ "lineBreakBeforeEachArgument": false,
+ "prioritizeKeepingFunctionOutputTogether": true,
+ "rules": {
+ "AlwaysUseLowerCamelCase": true,
+ "AmbiguousTrailingClosureOverload": true,
+ "DoNotUseSemicolons": true,
+ "FullyIndirectEnum": true,
+ "NeverForceUnwrap": false,
+ "NeverUseImplicitlyUnwrappedOptionals": true,
+ "NoBlockComments": true,
+ "OrderedImports": true,
+ "ReturnVoidInsteadOfEmptyTuple": true,
+ "UseEarlyExits": true,
+ "UseShorthandTypeNames": true
+ }
+}
diff --git a/App/.gitignore b/App/.gitignore
new file mode 100644
index 0000000..b8c71fe
--- /dev/null
+++ b/App/.gitignore
@@ -0,0 +1,3 @@
+Info.plist
+TokenMenuBar.xcodeproj/
+Widget/Info.plist
diff --git a/App/AppStore.entitlements b/App/AppStore.entitlements
new file mode 100644
index 0000000..225f807
--- /dev/null
+++ b/App/AppStore.entitlements
@@ -0,0 +1,18 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+ com.apple.security.files.user-selected.read-write
+
+ com.apple.security.files.bookmarks.app-scope
+
+ com.apple.security.application-groups
+
+ $(APP_GROUP_ID)
+
+
+
diff --git a/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/App/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..8815e3e
--- /dev/null
+++ b/App/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images": [
+ {
+ "filename": "icon_16x16.png",
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "16x16"
+ },
+ {
+ "filename": "icon_16x16@2x.png",
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "16x16"
+ },
+ {
+ "filename": "icon_32x32.png",
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "32x32"
+ },
+ {
+ "filename": "icon_32x32@2x.png",
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "32x32"
+ },
+ {
+ "filename": "icon_128x128.png",
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "128x128"
+ },
+ {
+ "filename": "icon_128x128@2x.png",
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "128x128"
+ },
+ {
+ "filename": "icon_256x256.png",
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "256x256"
+ },
+ {
+ "filename": "icon_256x256@2x.png",
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "256x256"
+ },
+ {
+ "filename": "icon_512x512.png",
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "512x512"
+ },
+ {
+ "filename": "icon_512x512@2x.png",
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "512x512"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
new file mode 100644
index 0000000..7de23d4
Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ
diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
new file mode 100644
index 0000000..20e73f0
Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ
diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
new file mode 100644
index 0000000..4eafd86
Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ
diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
new file mode 100644
index 0000000..a66c39b
Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ
diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
new file mode 100644
index 0000000..20e73f0
Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ
diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
new file mode 100644
index 0000000..a27fb80
Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ
diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
new file mode 100644
index 0000000..a66c39b
Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ
diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
new file mode 100644
index 0000000..0c2e283
Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ
diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
new file mode 100644
index 0000000..a27fb80
Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ
diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
new file mode 100644
index 0000000..9d66963
Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ
diff --git a/App/Assets.xcassets/Contents.json b/App/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..74d6a72
--- /dev/null
+++ b/App/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/App/Direct.entitlements b/App/Direct.entitlements
new file mode 100644
index 0000000..ad60f69
--- /dev/null
+++ b/App/Direct.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.application-groups
+
+ $(APP_GROUP_ID)
+
+
+
diff --git a/App/Info-AppStore.plist b/App/Info-AppStore.plist
new file mode 100644
index 0000000..45f2f1e
--- /dev/null
+++ b/App/Info-AppStore.plist
@@ -0,0 +1,44 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Token Menu Bar
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Token Menu Bar
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ ITSAppUsesNonExemptEncryption
+
+ LSApplicationCategoryType
+ public.app-category.developer-tools
+ LSMinimumSystemVersion
+ 14.0
+ LSUIElement
+
+ NSHumanReadableCopyright
+ Copyright © 2026 Bernát Gábor. MIT licensed.
+ NSSupportsAutomaticTermination
+
+ TMBDistribution
+ App Store
+ TMBSelfUpdateEnabled
+ $(SELF_UPDATE_ENABLED)
+ TMBSourceVersion
+ $(SOURCE_VERSION)
+ TokenMenuBarAppGroup
+ $(APP_GROUP_ID)
+
+
diff --git a/App/Info-Homebrew.plist b/App/Info-Homebrew.plist
new file mode 100644
index 0000000..49292a2
--- /dev/null
+++ b/App/Info-Homebrew.plist
@@ -0,0 +1,44 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Token Menu Bar
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Token Menu Bar
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ ITSAppUsesNonExemptEncryption
+
+ LSApplicationCategoryType
+ public.app-category.developer-tools
+ LSMinimumSystemVersion
+ 14.0
+ LSUIElement
+
+ NSHumanReadableCopyright
+ Copyright © 2026 Bernát Gábor. MIT licensed.
+ NSSupportsAutomaticTermination
+
+ TMBDistribution
+ Homebrew
+ TMBSelfUpdateEnabled
+ $(SELF_UPDATE_ENABLED)
+ TMBSourceVersion
+ $(SOURCE_VERSION)
+ TokenMenuBarAppGroup
+ $(APP_GROUP_ID)
+
+
diff --git a/App/PrivacyInfo.xcprivacy b/App/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..215a9bb
--- /dev/null
+++ b/App/PrivacyInfo.xcprivacy
@@ -0,0 +1,31 @@
+
+
+
+
+ NSPrivacyTracking
+
+ NSPrivacyTrackingDomains
+
+ NSPrivacyCollectedDataTypes
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+
+
diff --git a/App/Sources/SparkleUpdater.swift b/App/Sources/SparkleUpdater.swift
new file mode 100644
index 0000000..85747f8
--- /dev/null
+++ b/App/Sources/SparkleUpdater.swift
@@ -0,0 +1,22 @@
+import AppKit
+import Sparkle
+import TokenMenuBarUI
+
+@MainActor
+final class SparkleUpdater: UpdaterHook {
+ private let controller = SPUStandardUpdaterController(
+ startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
+
+ var canCheck: Bool {
+ controller.updater.canCheckForUpdates
+ }
+
+ var automaticallyChecks: Bool {
+ get { controller.updater.automaticallyChecksForUpdates }
+ set { controller.updater.automaticallyChecksForUpdates = newValue }
+ }
+
+ func checkForUpdates() {
+ controller.checkForUpdates(nil)
+ }
+}
diff --git a/App/TokenMenuBar.xctestplan b/App/TokenMenuBar.xctestplan
new file mode 100644
index 0000000..3176daf
--- /dev/null
+++ b/App/TokenMenuBar.xctestplan
@@ -0,0 +1,29 @@
+{
+ "configurations" : [
+ {
+ "id" : "3844B410-3635-4A9B-80D0-A65177767161",
+ "name" : "Test Scheme Action",
+ "options" : {
+
+ }
+ }
+ ],
+ "defaultOptions" : {
+ "targetForVariableExpansion" : {
+ "containerPath" : "container:TokenMenuBar.xcodeproj",
+ "identifier" : "EDE813082ECDC0E560455657",
+ "name" : "TokenMenuBarDirect"
+ }
+ },
+ "testTargets" : [
+ {
+ "parallelizable" : false,
+ "target" : {
+ "containerPath" : "container:TokenMenuBar.xcodeproj",
+ "identifier" : "0C919F3E9C6CF2D0B315AEC5",
+ "name" : "TokenMenuBarApplicationUITests"
+ }
+ }
+ ],
+ "version" : 1
+}
diff --git a/App/UITests/LiveControlAuditUITests.swift b/App/UITests/LiveControlAuditUITests.swift
new file mode 100644
index 0000000..713e384
--- /dev/null
+++ b/App/UITests/LiveControlAuditUITests.swift
@@ -0,0 +1,1340 @@
+import CoreGraphics
+import Foundation
+import TokenMenuBarCore
+import XCTest
+
+final class LiveControlAuditUITests: XCTestCase {
+ private let responsivenessBudget = 0.5
+
+ @MainActor
+ func testHistoryDatesAndEveryVisibleControlRespond() throws {
+ executionTimeAllowance = 300
+ let verification = VerificationApplication(
+ testName: name, profile: VerificationProfile(fixture: .controlAudit, nativePanels: true))
+ defer { verification.terminate() }
+ verification.launch()
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+
+ let output = try outputDirectory()
+ var records: [ControlAuditRecord] = []
+ defer { try? write(records, to: output.appendingPathComponent("control-matrix.json")) }
+ for tab in ["Usage", "Settings", "History"] {
+ if tab != "Usage" { verification.tab(tab).click() }
+ XCTAssertTrue(
+ verification.application.descendants(matching: .any)["tab-content-\(tab)"].waitForExistence(timeout: 2))
+ if tab == "Settings" {
+ XCTAssertTrue(verification.application.textFields["model-filter"].waitForExistence(timeout: 2))
+ }
+ if tab == "History" {
+ records += try exerciseHistoryControls(
+ verification.application, supportDirectory: verification.supportDirectory)
+ }
+ if tab == "Settings" {
+ records += try exerciseSettingsControls(
+ verification.application, statusItem: verification.statusItem,
+ supportDirectory: verification.supportDirectory,
+ processIdentifier: try verification.processIdentifier(),
+ reopen: verification.openPopover)
+ }
+ if tab == "Usage" { records += exerciseUsageControls(verification.application) }
+ try verification.application.screenshot().pngRepresentation.write(
+ to: output.appendingPathComponent("controls-\(tab.lowercased()).png"))
+ }
+
+ let failures = records.filter { $0.result.hasPrefix("failed") }
+ assertRequiredInventory(records)
+ XCTAssertTrue(failures.isEmpty, failures.map { "\($0.tab): \($0.label) \($0.result)" }.joined(separator: "\n"))
+ XCTAssertTrue(records.contains { $0.tab == "Usage" && $0.interacted })
+ XCTAssertTrue(records.contains { $0.tab == "History" && $0.interacted })
+ XCTAssertTrue(records.contains { $0.tab == "Settings" && $0.interacted })
+ print("CONTROL_AUDIT=\(output.path) controls=\(records.count) failures=\(failures.count)")
+ }
+
+ @MainActor
+ func testLongTextAtWideAndNarrowWidthsInLightAndDark() throws {
+ executionTimeAllowance = 120
+ let output = try outputDirectory()
+ for appearance in [VerificationApplication.Appearance.light, .dark] {
+ for (widthName, width) in [("wide", nil), ("narrow", 752.0)] {
+ let verification = VerificationApplication(
+ testName: "\(name)-\(appearance.rawValue)-\(widthName)",
+ profile: VerificationProfile(fixture: .longText, visibleFrameWidth: width),
+ appearance: appearance, doubleLocalizedStrings: true)
+ defer { verification.terminate() }
+ verification.launch()
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+ let surface = verification.application.descendants(matching: .any)["popover-surface"]
+ XCTAssertTrue(surface.waitForExistence(timeout: 2))
+ XCTAssertEqual(surface.frame.width, width == nil ? 880 : 728, accuracy: 3)
+
+ for tab in ["Usage", "History", "Settings"] {
+ verification.tab(tab).click()
+ XCTAssertTrue(
+ verification.application.descendants(matching: .any)["tab-content-\(tab)"].waitForExistence(timeout: 2))
+ if tab == "Settings" {
+ XCTAssertTrue(verification.application.textFields["model-filter"].waitForExistence(timeout: 2))
+ }
+ if tab == "Usage" {
+ XCTAssertTrue(
+ verification.application.staticTexts.matching(
+ NSPredicate(
+ format: "label CONTAINS %@ OR value CONTAINS %@",
+ "Verification warning text is intentionally long", "Verification warning text is intentionally long")
+ ).firstMatch.waitForExistence(timeout: 5))
+ }
+ let exposedText = try captureAndAuditPages(
+ tab: tab, surface: surface, application: verification.application, output: output,
+ prefix: "\(appearance.rawValue.lowercased())-\(widthName)")
+ if tab == "Usage" {
+ XCTAssertTrue(exposedText.contains { $0.contains("Verification warning text is intentionally long") })
+ XCTAssertTrue(exposedText.contains { $0.contains("usage window with a deliberately long model name") })
+ }
+ if tab == "Settings" {
+ XCTAssertTrue(exposedText.contains { $0.contains("account-profile-with-a-deliberately-long-file-name") })
+ }
+ }
+ }
+ }
+ print("LONG_TEXT_AUDIT=\(output.path)")
+ }
+
+ @MainActor
+ func testRichTooltipsStayAdjacentAndTabsHaveNone() throws {
+ executionTimeAllowance = 90
+ let verification = VerificationApplication(testName: name)
+ defer { verification.terminate() }
+ verification.launch()
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+ let output = try outputDirectory()
+ let processIdentifier = try verification.processIdentifier()
+
+ for tab in ["Usage", "History", "Settings"] {
+ verification.tab(tab).click()
+ XCTAssertTrue(
+ verification.application.descendants(matching: .any)["tab-content-\(tab)"].waitForExistence(timeout: 2))
+ if tab == "Settings" {
+ XCTAssertTrue(verification.application.textFields["model-filter"].waitForExistence(timeout: 2))
+ }
+ let surface = verification.application.descendants(matching: .any)["popover-surface"]
+ let candidates = tooltipCandidates(in: surface)
+ XCTAssertGreaterThanOrEqual(candidates.count, 5, "\(tab) did not expose tooltip controls across the panel")
+ for (position, control) in candidates {
+ movePointerOffPanel()
+ Thread.sleep(forTimeInterval: 0.15)
+ let baseline = applicationWindows(processIdentifier: processIdentifier)
+ let hoverStarted = ProcessInfo.processInfo.systemUptime
+ control.hover()
+ let hoverReturned = ProcessInfo.processInfo.systemUptime
+ if hoverReturned - hoverStarted < 0.1 {
+ Thread.sleep(forTimeInterval: max(0, 0.149 - (ProcessInfo.processInfo.systemUptime - hoverStarted)))
+ XCTAssertTrue(
+ applicationWindows(processIdentifier: processIdentifier).allSatisfy { baseline[$0.key] != nil },
+ "Tooltip appeared before the 150 ms threshold")
+ }
+ let tooltip = try waitForTooltip(processIdentifier: processIdentifier, excluding: baseline, timeout: 0.75)
+ let showLatency = ProcessInfo.processInfo.systemUptime - hoverStarted
+ XCTAssertGreaterThanOrEqual(showLatency, 0.14, "Tooltip appeared before its 150 ms delay")
+ let delta = distance(between: control.frame, and: tooltip.frame)
+ XCTAssertLessThanOrEqual(delta, 20, "Tooltip was not adjacent to \(control.label)")
+ XCTAssertTrue(CGDisplayBounds(CGMainDisplayID()).contains(tooltip.frame), "Tooltip left the active screen")
+ control.coordinate(withNormalizedOffset: CGVector(dx: 0.75, dy: 0.5)).hover()
+ Thread.sleep(forTimeInterval: 0.05)
+ XCTAssertNotNil(
+ applicationWindows(processIdentifier: processIdentifier)[tooltip.identifier],
+ "Tooltip flickered while the pointer stayed inside \(control.label)")
+ try verification.application.screenshot().pngRepresentation.write(
+ to: output.appendingPathComponent("tooltip-\(tab.lowercased())-\(position).png"))
+ print(
+ "TOOLTIP tab=\(tab) control=\(control.label) position=\(position) show_ms=\(Int(showLatency * 1_000)) "
+ + "delta=\(delta) frame=\(tooltip.frame)")
+ let dismissStarted = ProcessInfo.processInfo.systemUptime
+ movePointerOffPanel()
+ wait(until: dismissStarted + 0.149)
+ XCTAssertNotNil(
+ applicationWindows(processIdentifier: processIdentifier)[tooltip.identifier],
+ "Tooltip disappeared before its 150 ms exit delay")
+ let remaining = max(0, dismissStarted + 0.25 - ProcessInfo.processInfo.systemUptime)
+ XCTAssertTrue(
+ waitUntil(timeout: remaining) {
+ applicationWindows(processIdentifier: processIdentifier)[tooltip.identifier] == nil
+ }, "Tooltip did not dismiss by the 250 ms scheduler-safe ceiling")
+ print(
+ "TOOLTIP_DISMISS tab=\(tab) control=\(control.label) ms=\(Int((ProcessInfo.processInfo.systemUptime - dismissStarted) * 1_000))"
+ )
+ }
+
+ if let scrollView = surface.scrollViews.allElementsBoundByIndex.first(where: { $0.isHittable }),
+ let control = candidates.first?.1
+ {
+ let baseline = applicationWindows(processIdentifier: processIdentifier)
+ control.hover()
+ let tooltip = try waitForTooltip(processIdentifier: processIdentifier, excluding: baseline, timeout: 0.75)
+ scrollView.scroll(byDeltaX: 0, deltaY: -180)
+ XCTAssertTrue(
+ waitUntil(timeout: 0.15) {
+ applicationWindows(processIdentifier: processIdentifier)[tooltip.identifier] == nil
+ }, "Scrolling did not dismiss the tooltip immediately")
+ movePointerOffPanel()
+ if let scrolledControl = tooltipCandidates(in: surface).first?.1 {
+ let scrolledBaseline = applicationWindows(processIdentifier: processIdentifier)
+ scrolledControl.hover()
+ let scrolledTooltip = try waitForTooltip(
+ processIdentifier: processIdentifier, excluding: scrolledBaseline, timeout: 0.75)
+ XCTAssertLessThanOrEqual(distance(between: scrolledControl.frame, and: scrolledTooltip.frame), 20)
+ XCTAssertTrue(CGDisplayBounds(CGMainDisplayID()).contains(scrolledTooltip.frame))
+ try verification.application.screenshot().pngRepresentation.write(
+ to: output.appendingPathComponent("tooltip-\(tab.lowercased())-after-scroll.png"))
+ movePointerOffPanel()
+ XCTAssertTrue(
+ waitUntil(timeout: 0.25) {
+ applicationWindows(processIdentifier: processIdentifier)[scrolledTooltip.identifier] == nil
+ })
+ }
+ }
+ }
+
+ for tab in ["Usage", "History", "Settings"] {
+ movePointerOffPanel()
+ Thread.sleep(forTimeInterval: 0.2)
+ let baseline = applicationWindows(processIdentifier: processIdentifier)
+ verification.tab(tab).hover()
+ Thread.sleep(forTimeInterval: 0.25)
+ let newWindows = applicationWindows(processIdentifier: processIdentifier).filter { baseline[$0.key] == nil }
+ XCTAssertTrue(newWindows.isEmpty, "Tab \(tab) presented a tooltip")
+ }
+
+ verification.tab("Settings").click()
+ let surface = verification.application.descendants(matching: .any)["popover-surface"]
+ if let control = tooltipCandidates(in: surface).first?.1 {
+ movePointerOffPanel()
+ let tabBaseline = applicationWindows(processIdentifier: processIdentifier)
+ control.hover()
+ let tabTooltip = try waitForTooltip(
+ processIdentifier: processIdentifier, excluding: tabBaseline, timeout: 0.75)
+ verification.tab("Usage").click()
+ XCTAssertTrue(
+ waitUntil(timeout: 0.15) {
+ applicationWindows(processIdentifier: processIdentifier)[tabTooltip.identifier] == nil
+ }, "Tab switching did not dismiss the tooltip immediately")
+
+ let usageSurface = verification.application.descendants(matching: .any)["popover-surface"]
+ let escapeBaseline = applicationWindows(processIdentifier: processIdentifier)
+ tooltipCandidates(in: usageSurface).first?.1.hover()
+ let escapeTooltip = try waitForTooltip(
+ processIdentifier: processIdentifier, excluding: escapeBaseline, timeout: 0.75)
+ verification.application.typeKey(.escape, modifierFlags: [])
+ XCTAssertTrue(
+ waitUntil(timeout: 0.15) {
+ applicationWindows(processIdentifier: processIdentifier)[escapeTooltip.identifier] == nil
+ }, "Escape did not dismiss the tooltip immediately")
+ verification.openPopover()
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 2))
+ }
+ print("TOOLTIP_AUDIT=\(output.path)")
+ }
+
+ @MainActor
+ private func exerciseHistoryControls(
+ _ application: XCUIApplication, supportDirectory: URL
+ ) throws -> [ControlAuditRecord] {
+ var records: [ControlAuditRecord] = []
+ let period = application.descendants(matching: .any)["history-period"]
+ let rollup = application.descendants(matching: .any)["history-rollup"]
+ let metric = application.descendants(matching: .any)["history-metric"]
+ let start = application.datePickers["history-from"]
+ let end = application.datePickers["history-to"]
+ XCTAssertTrue(period.waitForExistence(timeout: 2))
+ XCTAssertEqual(segments(in: period).count, 6, "History must expose Now, four fixed periods, and Custom")
+ XCTAssertTrue(metric.waitForExistence(timeout: 2))
+ try selectMenuItem("Usage %", from: metric, application: application)
+ let utc = application.checkBoxes["history-utc"]
+ XCTAssertTrue(utc.waitForExistence(timeout: 2))
+ XCTAssertTrue(utc.isEnabled)
+ toggleAndRestore(utc)
+ records.append(scenarioRecord(tab: "History", label: "UTC boundaries", element: utc, action: "toggle twice"))
+ XCTAssertTrue(rollup.waitForExistence(timeout: 2))
+ XCTAssertTrue(rollup.isEnabled, "Window Usage must enable Rollup")
+ XCTAssertEqual(segments(in: rollup).count, 3, "Rollup must expose Minute, Hour, and Day")
+ for label in ["Minute", "Hour", "Day"] {
+ let option = segment(label, in: rollup)
+ XCTAssertTrue(option.isHittable)
+ option.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.isSelected(option) })
+ }
+ records.append(scenarioRecord(tab: "History", label: "Window Usage rollups", element: rollup))
+
+ try selectMenuItem("Input tokens", from: metric, application: application)
+ let stacked = application.checkBoxes["history-stacked"]
+ XCTAssertTrue(stacked.waitForExistence(timeout: 2))
+ XCTAssertTrue(stacked.isEnabled, "An additive metric with parallel series must enable Stacked")
+ toggleAndRestore(stacked)
+ records.append(scenarioRecord(tab: "History", label: "Additive metric stacking", element: stacked))
+
+ XCTAssertTrue(application.descendants(matching: .any)["history-chart"].waitForExistence(timeout: 2))
+ let series = application.descendants(matching: .any).matching(
+ NSPredicate(format: "identifier BEGINSWITH %@", "history-series-")
+ ).allElementsBoundByIndex
+ XCTAssertGreaterThan(series.count, 1, "Input tokens must expose more than one data series")
+ for toggle in series {
+ XCTAssertTrue(toggle.isEnabled)
+ XCTAssertTrue(toggle.isHittable)
+ toggle.click()
+ toggle.click()
+ }
+ let firstSeries = try XCTUnwrap(series.first)
+ records.append(
+ scenarioRecord(tab: "History", label: "\(series.count) legend series off and on", element: firstSeries))
+
+ try selectMenuItem("Usage %", from: metric, application: application)
+ segment("Today", in: period).click()
+ let previous = application.buttons["history-previous-period"]
+ let next = application.buttons["history-next-period"]
+ XCTAssertTrue(previous.waitForExistence(timeout: 2))
+ XCTAssertTrue(previous.isEnabled)
+ previous.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { next.isEnabled })
+ next.click()
+ previous.click()
+ let now = segment("Now", in: period)
+ XCTAssertTrue(now.isHittable)
+ now.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.isSelected(now) })
+ records.append(scenarioRecord(tab: "History", label: "Previous, next, and Now", element: period))
+
+ XCTAssertTrue(start.waitForExistence(timeout: 2))
+ XCTAssertTrue(end.waitForExistence(timeout: 2))
+ XCTAssertTrue(start.isEnabled)
+ XCTAssertTrue(end.isEnabled)
+
+ let custom = segment("Custom", in: period)
+ custom.click()
+ try increment(start, application: application)
+ try increment(end, application: application)
+ segment("Today", in: period).click()
+ try increment(start, application: application)
+ XCTAssertTrue(isSelected(custom))
+ records.append(scenarioRecord(tab: "History", label: "Custom From and To", element: start))
+
+ let export = application.buttons["history-export"]
+ XCTAssertTrue(export.waitForExistence(timeout: 2))
+ let exportRecord = scenarioRecord(tab: "History", label: "Export CSV save panel Cancel", element: export)
+ export.click()
+ assertAndCancelNativePanel(application, rootedAt: supportDirectory)
+ records.append(exportRecord)
+ return records
+ }
+
+ @MainActor
+ private func exerciseUsageControls(_ application: XCUIApplication) -> [ControlAuditRecord] {
+ let surface = application.descendants(matching: .any)["popover-surface"]
+ scrollToTop(surface)
+ var records: [ControlAuditRecord] = []
+ let refresh = application.buttons["Refresh usage"]
+ XCTAssertTrue(refresh.waitForExistence(timeout: 2))
+ XCTAssertTrue(refresh.isHittable)
+ refresh.click()
+ records.append(scenarioRecord(tab: "Usage", label: "Refresh all providers", element: refresh, action: "click"))
+
+ var copiedValues = 0
+ for provider in ProviderID.allCases {
+ let providerRefresh = application.buttons["Refresh \(provider.displayName)"]
+ XCTAssertTrue(reveal(providerRefresh, in: surface), "Missing \(provider.displayName) Usage refresh")
+ XCTAssertTrue(waitUntil(timeout: 2) { providerRefresh.isEnabled })
+ providerRefresh.click()
+ records.append(
+ scenarioRecord(
+ tab: "Usage", label: "Refresh \(provider.displayName)", element: providerRefresh, action: "click"))
+
+ let card = application.descendants(matching: .any)["usage-provider-\(provider.rawValue)"]
+ XCTAssertTrue(card.exists)
+ let copyButtons = card.buttons.allElementsBoundByIndex.filter {
+ $0.isHittable && $0.label.hasPrefix("Copy ") && $0.label != "Copy Diagnostics"
+ }
+ for copy in copyButtons {
+ let value = String(copy.label.dropFirst("Copy ".count))
+ let primary = surface.buttons[value].firstMatch
+ if primary.exists && primary.isHittable {
+ primary.click()
+ records.append(
+ scenarioRecord(
+ tab: "Usage", label: "\(provider.displayName) identity \(value)", element: primary,
+ action: "click"))
+ }
+ copy.click()
+ records.append(
+ scenarioRecord(
+ tab: "Usage", label: "\(provider.displayName) copy \(value)", element: copy,
+ action: "click"))
+ copiedValues += 1
+ }
+ }
+ XCTAssertGreaterThanOrEqual(copiedValues, ProviderID.allCases.count)
+ XCTAssertFalse(application.links.firstMatch.exists, "Usage must not expose usage-site links")
+ XCTAssertFalse(
+ application.staticTexts.matching(NSPredicate(format: "label BEGINSWITH 'Sign in to '")).firstMatch.exists,
+ "Sign-in guidance belongs in Providers, not Usage")
+ records.append(absenceRecord(tab: "Usage", label: "Usage-site links", action: "assert absent"))
+ records.append(absenceRecord(tab: "Usage", label: "Sign-in prompts", action: "assert absent"))
+ return records
+ }
+
+ @MainActor
+ private func exerciseSettingsControls(
+ _ application: XCUIApplication, statusItem: XCUIElement, supportDirectory: URL,
+ processIdentifier: pid_t, reopen: () -> Void
+ ) throws -> [ControlAuditRecord] {
+ let surface = application.descendants(matching: .any)["popover-surface"]
+ var records: [ControlAuditRecord] = []
+ scrollToTop(surface)
+ records += exerciseAboutControls(application, surface: surface)
+
+ let order = try segmentedControl(containing: "Stable", application: application)
+ let stable = segment("Stable", in: order)
+ stable.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.isSelected(stable) })
+ let moveLater = application.buttons.matching(
+ NSPredicate(format: "label BEGINSWITH 'Move ' AND label ENDSWITH ' later'")
+ ).firstMatch
+ XCTAssertTrue(reveal(moveLater, in: surface), "Stable order did not expose a Move Later button")
+ moveLater.click()
+ let moveEarlier = application.buttons.matching(
+ NSPredicate(format: "label BEGINSWITH 'Move ' AND label ENDSWITH ' earlier'")
+ ).firstMatch
+ XCTAssertTrue(reveal(moveEarlier, in: surface), "Stable order did not expose a Move Earlier button")
+ moveEarlier.click()
+ records.append(scenarioRecord(tab: "Settings", label: "Stable order move later and earlier", element: order))
+
+ scrollToTop(surface)
+ let format = try segmentedControl(containing: "Custom", application: application)
+ let frameBeforeStatusEdits = statusItem.frame
+ let panelBeforeStatusEdits = surface.frame
+ let formatSignature = contentSignature(statusItem)
+ let customFormat = segment("Custom", in: format)
+ customFormat.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.isSelected(customFormat) })
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.contentSignature(statusItem) != formatSignature })
+ assertAnchorsHeld(
+ statusItem: statusItem, statusFrame: frameBeforeStatusEdits,
+ surface: surface, panelFrame: panelBeforeStatusEdits, action: "Format")
+ let template = application.textFields["Template"]
+ XCTAssertTrue(template.waitForExistence(timeout: 2))
+ XCTAssertTrue(application.staticTexts["{cell} {pct} {label} {provider} {window} {reset}"].exists)
+
+ let originalTemplate = template.value as? String ?? ""
+ let templateSignature = contentSignature(statusItem)
+ replaceText(in: template, with: "{label}:{pct}", application: application)
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.contentSignature(statusItem) != templateSignature })
+ assertAnchorsHeld(
+ statusItem: statusItem, statusFrame: frameBeforeStatusEdits,
+ surface: surface, panelFrame: panelBeforeStatusEdits, action: "Template")
+
+ let decimals = application.steppers.matching(NSPredicate(format: "label BEGINSWITH 'Decimals:'")).firstMatch
+ XCTAssertTrue(decimals.waitForExistence(timeout: 2))
+ let decimalsSignature = contentSignature(statusItem)
+ incrementStepper(decimals)
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.contentSignature(statusItem) != decimalsSignature })
+ assertAnchorsHeld(
+ statusItem: statusItem, statusFrame: frameBeforeStatusEdits,
+ surface: surface, panelFrame: panelBeforeStatusEdits, action: "Decimals")
+
+ let label = application.textFields["Label"].firstMatch
+ XCTAssertTrue(reveal(label, in: surface), "The model list did not expose a short-label field")
+ let originalLabel = label.value as? String ?? ""
+ XCTAssertFalse(originalLabel.isEmpty, "Short labels must be prefilled")
+ label.click()
+ application.typeKey("a", modifierFlags: .command)
+ label.typeText("TOOLONG")
+ XCTAssertTrue(
+ waitUntil(timeout: responsivenessBudget) { ((label.value as? String) ?? "").count == ShortLabelPolicy.limit })
+ let labelSignature = contentSignature(statusItem)
+ replaceText(in: label, with: "VX", application: application)
+ application.typeKey(.enter, modifierFlags: [])
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.contentSignature(statusItem) != labelSignature })
+ assertAnchorsHeld(
+ statusItem: statusItem, statusFrame: frameBeforeStatusEdits,
+ surface: surface, panelFrame: panelBeforeStatusEdits, action: "Short label")
+ let revert = application.buttons["Revert label"].firstMatch
+ XCTAssertTrue(revert.waitForExistence(timeout: 2))
+ revert.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { (label.value as? String) == originalLabel })
+ records.append(
+ scenarioRecord(tab: "Settings", label: "Prefilled six-character short label", element: label, action: "edit"))
+ records.append(scenarioRecord(tab: "Settings", label: "Revert short label", element: revert, action: "click"))
+
+ let modelSelection = application.checkBoxes.matching(
+ NSPredicate(format: "label BEGINSWITH 'Show ' AND label ENDSWITH ' in the menu bar'")
+ ).firstMatch
+ XCTAssertTrue(modelSelection.exists && modelSelection.isHittable)
+ toggleAndRestore(modelSelection)
+ records.append(
+ scenarioRecord(tab: "Settings", label: "Model selection", element: modelSelection, action: "toggle twice"))
+
+ for provider in ProviderID.allCases {
+ let providerSelection = application.checkBoxes["Show all \(provider.displayName) models"]
+ XCTAssertTrue(reveal(providerSelection, in: surface), "Missing \(provider.displayName) model select-all")
+ toggleAndRestore(providerSelection)
+ records.append(
+ scenarioRecord(
+ tab: "Settings", label: "\(provider.displayName) model select-all", element: providerSelection,
+ action: "toggle twice"))
+ }
+
+ scrollToTop(surface)
+ let modelFilter = application.textFields["model-filter"]
+ XCTAssertTrue(reveal(modelFilter, in: surface))
+ replaceText(in: modelFilter, with: "codex", application: application)
+ replaceText(in: modelFilter, with: "", application: application)
+ records.append(
+ scenarioRecord(tab: "Settings", label: "Model filter", element: modelFilter, action: "type and clear"))
+ application.typeKey("f", modifierFlags: .command)
+ modelFilter.typeText("route")
+ XCTAssertEqual(modelFilter.value as? String, "route", "Command-F did not focus Filter models")
+ XCTAssertNotEqual(application.textFields["Search log"].value as? String, "route")
+ replaceText(in: modelFilter, with: "", application: application)
+ records.append(
+ scenarioRecord(tab: "Settings", label: "Command-F model filter", element: modelFilter, action: "⌘F then type"))
+
+ let hideUnused = application.checkBoxes["Hide unused in range"]
+ XCTAssertTrue(hideUnused.exists && hideUnused.isHittable)
+ toggleAndRestore(hideUnused)
+ records.append(
+ scenarioRecord(tab: "Settings", label: "Hide unused models", element: hideUnused, action: "toggle twice"))
+ scrollToTop(surface)
+ replaceText(in: template, with: originalTemplate, application: application)
+ for label in ["Hide 0%", "Fit to space"] {
+ let toggle = application.checkBoxes[label]
+ XCTAssertTrue(toggle.exists && toggle.isHittable)
+ toggleAndRestore(toggle)
+ records.append(scenarioRecord(tab: "Settings", label: label, element: toggle, action: "toggle twice"))
+ }
+ let preview = application.descendants(matching: .any)["Menu bar preview"]
+ XCTAssertTrue(preview.exists)
+ records.append(scenarioRecord(tab: "Settings", label: "Live menu bar preview", element: preview, action: "observe"))
+ records.append(scenarioRecord(tab: "Settings", label: "Model order", element: order, action: "select Stable"))
+ records.append(scenarioRecord(tab: "Settings", label: "Status format", element: format, action: "select Custom"))
+ records.append(scenarioRecord(tab: "Settings", label: "Decimals", element: decimals, action: "increment"))
+ records.append(scenarioRecord(tab: "Settings", label: "Template and tokens", element: template, action: "edit"))
+ records.append(
+ scenarioRecord(tab: "Settings", label: "Live status content and fixed anchor", element: template))
+
+ records += try exerciseProviderControls(application, surface: surface, supportDirectory: supportDirectory)
+ records += exerciseDataControls(application, surface: surface, supportDirectory: supportDirectory)
+ records += exerciseNotificationControls(application, surface: surface)
+ records += exerciseLogControls(application, surface: surface)
+
+ scrollToTop(surface)
+ XCTAssertEqual(
+ collectButtonLabels(prefix: "Reset", in: surface), ["Reset All Settings"],
+ "Settings must expose only the guarded global reset")
+ scrollToTop(surface)
+ let reset = application.buttons["Reset All Settings"]
+ XCTAssertTrue(reset.waitForExistence(timeout: 2))
+ reset.click()
+ let alert = application.alerts["Reset all settings?"]
+ XCTAssertTrue(alert.waitForExistence(timeout: 2))
+ XCTAssertTrue(alert.buttons["Cancel"].isHittable)
+ alert.buttons["Cancel"].click()
+ XCTAssertTrue(alert.waitForNonExistence(timeout: 2))
+ records.append(scenarioRecord(tab: "Settings", label: "Reset All Settings Cancel", element: reset))
+
+ application.typeKey(.escape, modifierFlags: [])
+ XCTAssertTrue(popoverTabs(in: application).waitForNonExistence(timeout: 2))
+ let reopenSampler = WindowFrameSampler(processIdentifier: processIdentifier)
+ reopenSampler.start()
+ reopen()
+ XCTAssertTrue(popoverTabs(in: application).waitForExistence(timeout: 2))
+ XCTAssertTrue(application.textFields["model-filter"].waitForExistence(timeout: 2))
+ let reopenTimeline = reopenSampler.stop()
+ XCTAssertFalse(reopenTimeline.isEmpty)
+ if let settled = reopenTimeline.last?.frame {
+ for sample in reopenTimeline {
+ XCTAssertLessThan(abs(sample.frame.minX - settled.minX), 2, "Reopen moved horizontally")
+ XCTAssertLessThan(abs(sample.frame.minY - settled.minY), 2, "Reopen moved the top edge")
+ }
+ }
+ try write(reopenTimeline, to: outputDirectory().appendingPathComponent("status-reopen-frames.json"))
+ records.append(scenarioRecord(tab: "Settings", label: "Deferred fit after reopen", element: statusItem))
+ return records
+ }
+
+ @MainActor
+ private func exerciseAboutControls(
+ _ application: XCUIApplication, surface: XCUIElement
+ ) -> [ControlAuditRecord] {
+ var records: [ControlAuditRecord] = []
+ let version = surface.staticTexts.matching(
+ NSPredicate(
+ format: "(label CONTAINS '(' AND label CONTAINS ')') OR (value CONTAINS '(' AND value CONTAINS ')')")
+ ).firstMatch
+ XCTAssertTrue(reveal(version, in: surface), "Missing About version and build")
+ records.append(scenarioRecord(tab: "Settings", label: "Version and build", element: version, action: "observe"))
+ let channel = surface.staticTexts["Direct"]
+ XCTAssertTrue(channel.exists, "Missing About distribution channel")
+ records.append(scenarioRecord(tab: "Settings", label: "Distribution channel", element: channel, action: "observe"))
+ let launchAtLogin = application.checkBoxes["Launch at login"]
+ XCTAssertTrue(reveal(launchAtLogin, in: surface))
+ toggleAndRestore(launchAtLogin)
+ records.append(
+ scenarioRecord(tab: "Settings", label: "Launch at login", element: launchAtLogin, action: "toggle twice"))
+ let settingsContent = surface.scrollViews["tab-content-Settings"]
+ for label in ["Open Login Items", "Copy Diagnostics", "Report Issue", "Source"] {
+ let button = settingsContent.buttons[label].firstMatch
+ XCTAssertTrue(button.exists && button.isHittable, "Missing About action \(label)")
+ button.click()
+ records.append(scenarioRecord(tab: "Settings", label: label, element: button, action: "click"))
+ }
+ XCTAssertFalse(application.checkBoxes["Check for updates automatically"].exists)
+ XCTAssertFalse(application.buttons["Check Now"].exists)
+ records.append(
+ absenceRecord(
+ tab: "Settings", label: "Direct update controls", action: "assert absent outside a live Direct updater"))
+ return records
+ }
+
+ @MainActor
+ private func exerciseProviderControls(
+ _ application: XCUIApplication, surface: XCUIElement, supportDirectory: URL
+ ) throws -> [ControlAuditRecord] {
+ let showAll = application.checkBoxes["Show all providers"]
+ XCTAssertTrue(reveal(showAll, in: surface))
+ set(showAll, enabled: true)
+ var records = [scenarioRecord(tab: "Settings", label: "Show all providers", element: showAll)]
+ var refreshSteppers = 0
+ var recoveryActions = 0
+ var resourceActions = 0
+ for provider in ProviderID.allCases {
+ let row = application.descendants(matching: .any)["\(provider.displayName) setup"]
+ XCTAssertTrue(reveal(row, in: surface), "Missing \(provider.displayName) provider row")
+ XCTAssertTrue(
+ String(describing: row.value).contains("Demo data"),
+ "\(provider.displayName) did not expose its isolated authentication source")
+ records.append(
+ scenarioRecord(
+ tab: "Settings", label: "\(provider.displayName) authentication source", element: row,
+ action: "observe"))
+ let toggle = row.checkBoxes[provider.displayName]
+ XCTAssertTrue(toggle.exists)
+ toggleAndRestore(toggle)
+ records.append(
+ scenarioRecord(
+ tab: "Settings", label: "\(provider.displayName) enabled", element: toggle, action: "toggle twice"))
+ let stepper = row.steppers.firstMatch
+ XCTAssertTrue(stepper.exists, "Missing \(provider.displayName) refresh interval")
+ XCTAssertTrue(stepper.isHittable)
+ incrementStepper(stepper)
+ refreshSteppers += 1
+ records.append(
+ scenarioRecord(
+ tab: "Settings", label: "\(provider.displayName) refresh interval", element: stepper,
+ action: "increment"))
+ for action in row.buttons.allElementsBoundByIndex
+ where ["Copy command", "Check again", "Grant access", "Contact administrator"].contains(action.label) {
+ XCTAssertTrue(action.isHittable)
+ action.click()
+ if action.label == "Grant access" {
+ assertAndCancelNativePanel(application, rootedAt: supportDirectory)
+ }
+ recoveryActions += 1
+ records.append(
+ scenarioRecord(
+ tab: "Settings", label: "\(provider.displayName) recovery \(action.label)", element: action,
+ action: action.label == "Grant access" ? "open panel and Cancel" : "click"))
+ }
+ for action in row.buttons.allElementsBoundByIndex where ["Grant", "Grant Again"].contains(action.label) {
+ XCTAssertTrue(action.isHittable)
+ action.click()
+ assertAndCancelNativePanel(application, rootedAt: supportDirectory)
+ resourceActions += 1
+ records.append(
+ scenarioRecord(
+ tab: "Settings", label: "\(provider.displayName) resource \(action.label)", element: action,
+ action: "open panel and Cancel"))
+ }
+ }
+ XCTAssertEqual(refreshSteppers, ProviderID.allCases.count)
+ XCTAssertEqual(recoveryActions, ProviderID.allCases.count, "Each provider must expose its recovery action")
+ XCTAssertEqual(
+ resourceActions, ProviderID.allCases.flatMap(\.sandboxResources).count,
+ "Each required sandbox resource must expose its grant action")
+ let tokenRefresh = application.checkBoxes["Refresh expired Claude, Codex, and Gemini tokens on my behalf"]
+ XCTAssertTrue(reveal(tokenRefresh, in: surface))
+ toggleAndRestore(tokenRefresh)
+ records.append(scenarioRecord(tab: "Settings", label: "Provider token refresh", element: tokenRefresh))
+ return records
+ }
+
+ @MainActor
+ private func exerciseDataControls(
+ _ application: XCUIApplication, surface: XCUIElement, supportDirectory: URL
+ ) -> [ControlAuditRecord] {
+ var records: [ControlAuditRecord] = []
+ let retention = application.steppers.matching(NSPredicate(format: "label MATCHES '\\d+ days'")).firstMatch
+ XCTAssertTrue(reveal(retention, in: surface), "Missing history retention")
+ adjustStepperAndRestore(retention)
+ records.append(
+ scenarioRecord(tab: "Settings", label: "History retention", element: retention, action: "increment/decrement"))
+
+ let analytics = application.steppers.matching(NSPredicate(format: "label BEGINSWITH 'Every '"))
+ .allElementsBoundByIndex
+ .first { $0.isHittable }
+ if let analytics {
+ adjustStepperAndRestore(analytics)
+ records.append(
+ scenarioRecord(tab: "Settings", label: "Analytics refresh", element: analytics, action: "increment/decrement"))
+ } else {
+ XCTFail("Missing analytics refresh interval")
+ }
+
+ let historyPath = application.descendants(matching: .any)["History file"]
+ XCTAssertTrue(historyPath.exists)
+ let path = String(describing: historyPath.value)
+ XCTAssertTrue(path.contains("token-menu-bar-verify"), "History must stay in the verification directory")
+ records.append(scenarioRecord(tab: "Settings", label: "Full history path", element: historyPath, action: "observe"))
+
+ let open = application.buttons["Open"]
+ XCTAssertTrue(open.exists && open.isHittable)
+ open.click()
+ records.append(scenarioRecord(tab: "Settings", label: "History Open", element: open, action: "click"))
+ let export = application.buttons["Export…"]
+ XCTAssertTrue(export.exists && export.isHittable)
+ export.click()
+ assertAndCancelNativePanel(application, rootedAt: supportDirectory)
+ records.append(
+ scenarioRecord(tab: "Settings", label: "History Export", element: export, action: "open panel and Cancel"))
+ let clear = application.buttons["Clear…"]
+ XCTAssertTrue(clear.exists && clear.isHittable)
+ clear.click()
+ let destructive = application.buttons["Clear History"]
+ XCTAssertTrue(destructive.waitForExistence(timeout: 2), "Clear History must require confirmation")
+ application.typeKey(.escape, modifierFlags: [])
+ XCTAssertTrue(destructive.waitForNonExistence(timeout: 2))
+ records.append(
+ scenarioRecord(tab: "Settings", label: "Clear History confirmation", element: clear, action: "Cancel"))
+ return records
+ }
+
+ @MainActor
+ private func exerciseNotificationControls(
+ _ application: XCUIApplication, surface: XCUIElement
+ ) -> [ControlAuditRecord] {
+ let enabled = application.checkBoxes["Enable threshold notifications"]
+ XCTAssertTrue(reveal(enabled, in: surface))
+ set(enabled, enabled: true)
+ var records = [
+ scenarioRecord(tab: "Settings", label: "Notifications enabled", element: enabled, action: "enable")
+ ]
+ let labels = ["50%", "75%", "90%", "100%"]
+ for label in labels {
+ let threshold = application.checkBoxes[label]
+ XCTAssertTrue(threshold.exists && threshold.isEnabled && threshold.isHittable, "Missing notification \(label)")
+ toggleAndRestore(threshold)
+ records.append(
+ scenarioRecord(
+ tab: "Settings", label: "Notification threshold \(label)", element: threshold, action: "toggle twice"))
+ }
+ for label in ["Window resets", "Sign-in needed"] {
+ let toggle = application.checkBoxes[label]
+ XCTAssertTrue(toggle.exists && toggle.isEnabled && toggle.isHittable, "Missing \(label)")
+ toggleAndRestore(toggle)
+ records.append(
+ scenarioRecord(tab: "Settings", label: label, element: toggle, action: "toggle twice"))
+ }
+ set(enabled, enabled: false)
+ return records
+ }
+
+ @MainActor
+ private func exerciseLogControls(
+ _ application: XCUIApplication, surface: XCUIElement
+ ) -> [ControlAuditRecord] {
+ let detailed = application.checkBoxes["Detailed logging"]
+ XCTAssertTrue(reveal(detailed, in: surface))
+ set(detailed, enabled: true)
+ var records = [
+ scenarioRecord(tab: "Settings", label: "Detailed logging", element: detailed, action: "enable")
+ ]
+ let log = application.textViews["Log"]
+ XCTAssertTrue(log.waitForExistence(timeout: 2))
+ application.typeKey("r", modifierFlags: .command)
+ XCTAssertTrue(
+ waitUntil(timeout: 2) { String(describing: log.value).contains("refresh.provider") },
+ "Command-R did not trigger a logged provider refresh")
+ records.append(scenarioRecord(tab: "Settings", label: "Command-R refresh", element: log, action: "⌘R"))
+ records.append(scenarioRecord(tab: "Settings", label: "Log view", element: log, action: "observe"))
+ let level = application.descendants(matching: .any)["Log level"]
+ XCTAssertTrue(level.exists)
+ XCTAssertEqual(level.buttons.count, 5, "Log level must expose All plus four severities")
+ for segment in level.buttons.allElementsBoundByIndex where segment.isEnabled && segment.isHittable {
+ segment.click()
+ }
+ records.append(scenarioRecord(tab: "Settings", label: "Log level", element: level, action: "select every level"))
+ let search = application.textFields["Search log"]
+ XCTAssertTrue(search.exists && search.isHittable)
+ replaceText(in: search, with: "verification", application: application)
+ replaceText(in: search, with: "", application: application)
+ records.append(scenarioRecord(tab: "Settings", label: "Log search", element: search, action: "type and clear"))
+ for label in ["Copy", "Clear"] {
+ let button = application.buttons[label]
+ XCTAssertTrue(button.exists && button.isHittable)
+ button.click()
+ records.append(scenarioRecord(tab: "Settings", label: "Log \(label)", element: button, action: "click"))
+ }
+ let fullLog = application.buttons["Show Full Log"]
+ XCTAssertTrue(fullLog.exists && fullLog.isHittable)
+ fullLog.click()
+ XCTAssertTrue(application.windows.count > 1)
+ application.typeKey("f", modifierFlags: .command)
+ let fullLogSearch = application.textFields.matching(NSPredicate(format: "label == 'Search log'"))
+ .allElementsBoundByIndex.first { $0.isHittable }
+ guard let fullLogSearch else {
+ XCTFail("Full Log did not expose a searchable field")
+ return records
+ }
+ fullLogSearch.typeText("route")
+ XCTAssertEqual(fullLogSearch.value as? String, "route", "Full Log Command-F did not focus its search")
+ records.append(
+ scenarioRecord(
+ tab: "Settings", label: "Full log Command-F search", element: fullLogSearch, action: "⌘F then type"))
+ application.typeKey("w", modifierFlags: .command)
+ records.append(scenarioRecord(tab: "Settings", label: "Show Full Log", element: fullLog, action: "open and close"))
+ set(detailed, enabled: false)
+ return records
+ }
+
+ @MainActor
+ private func increment(_ picker: XCUIElement, application: XCUIApplication) throws {
+ let before = String(describing: picker.value)
+ picker.click()
+ application.typeKey(.upArrow, modifierFlags: [])
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { String(describing: picker.value) != before })
+ }
+
+ @MainActor
+ private func segmentedControl(
+ containing label: String, application: XCUIApplication
+ ) throws -> XCUIElement {
+ let control = application.descendants(matching: .any).allElementsBoundByIndex.first {
+ ($0.elementType == .segmentedControl || $0.elementType == .radioGroup)
+ && ($0.buttons[label].exists || $0.radioButtons[label].exists)
+ }
+ return try XCTUnwrap(control, "No segmented control contains \(label)")
+ }
+
+ @MainActor
+ private func segment(_ label: String, in control: XCUIElement) -> XCUIElement {
+ control.elementType == .radioGroup ? control.radioButtons[label] : control.buttons[label]
+ }
+
+ @MainActor
+ private func segments(in control: XCUIElement) -> [XCUIElement] {
+ (control.elementType == .radioGroup ? control.radioButtons : control.buttons).allElementsBoundByIndex
+ }
+
+ @MainActor
+ private func popoverTabs(in application: XCUIApplication) -> XCUIElement {
+ application.descendants(matching: .radioGroup)
+ .matching(NSPredicate(format: "label == %@", "Popover tabs")).firstMatch
+ }
+
+ @MainActor
+ private func selectMenuItem(
+ _ label: String, from picker: XCUIElement, application: XCUIApplication
+ ) throws {
+ picker.click()
+ let item = application.menuItems[label]
+ XCTAssertTrue(item.waitForExistence(timeout: 2), "The picker did not expose \(label)")
+ item.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { String(describing: picker.value).contains(label) })
+ }
+
+ @MainActor
+ private func set(_ toggle: XCUIElement, enabled: Bool) {
+ if isSelected(toggle) != enabled { toggle.click() }
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.isSelected(toggle) == enabled })
+ }
+
+ @MainActor
+ private func toggleAndRestore(_ toggle: XCUIElement) {
+ let before = isSelected(toggle)
+ toggle.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.isSelected(toggle) != before })
+ toggle.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { self.isSelected(toggle) == before })
+ }
+
+ @MainActor
+ private func incrementStepper(_ stepper: XCUIElement) {
+ let before = String(describing: stepper.value)
+ let increment =
+ stepper.buttons["Increment"].exists
+ ? stepper.buttons["Increment"] : stepper.buttons.allElementsBoundByIndex.last
+ guard let increment else {
+ XCTFail("Stepper did not expose an increment button")
+ return
+ }
+ XCTAssertTrue(increment.isHittable)
+ increment.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { String(describing: stepper.value) != before })
+ }
+
+ @MainActor
+ private func adjustStepperAndRestore(_ stepper: XCUIElement) {
+ let before = String(describing: stepper.value)
+ let buttons = stepper.buttons.allElementsBoundByIndex
+ guard buttons.count >= 2 else {
+ XCTFail("Stepper did not expose increment and decrement buttons")
+ return
+ }
+ let increment = stepper.buttons["Increment"].exists ? stepper.buttons["Increment"] : buttons.last!
+ let decrement = stepper.buttons["Decrement"].exists ? stepper.buttons["Decrement"] : buttons.first!
+ increment.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { String(describing: stepper.value) != before })
+ decrement.click()
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { String(describing: stepper.value) == before })
+ }
+
+ @MainActor
+ private func isSelected(_ element: XCUIElement) -> Bool {
+ if let number = element.value as? NSNumber { return number.boolValue }
+ let value = String(describing: element.value).lowercased()
+ return value == "1" || value == "on" || value.contains("selected")
+ }
+
+ @MainActor
+ private func replaceText(in field: XCUIElement, with value: String, application: XCUIApplication) {
+ field.click()
+ application.typeKey("a", modifierFlags: .command)
+ if value.isEmpty {
+ application.typeKey(.delete, modifierFlags: [])
+ } else {
+ field.typeText(value)
+ }
+ XCTAssertTrue(waitUntil(timeout: responsivenessBudget) { (field.value as? String) == value })
+ }
+
+ @MainActor
+ private func reveal(_ element: XCUIElement, in surface: XCUIElement) -> Bool {
+ if element.exists && element.isHittable { return true }
+ let scrollView = surface.scrollViews.firstMatch
+ guard scrollView.exists else { return false }
+ for _ in 0..<20 {
+ scrollView.scroll(byDeltaX: 0, deltaY: -420)
+ if waitUntil(timeout: 0.08, condition: { element.exists && element.isHittable }) { return true }
+ }
+ return element.exists && element.isHittable
+ }
+
+ @MainActor
+ private func scrollToTop(_ surface: XCUIElement) {
+ let scrollView = surface.scrollViews.firstMatch
+ guard scrollView.exists else { return }
+ for _ in 0..<20 { scrollView.scroll(byDeltaX: 0, deltaY: 600) }
+ }
+
+ @MainActor
+ private func collectButtonLabels(prefix: String, in surface: XCUIElement) -> Set {
+ let scrollView = surface.scrollViews.firstMatch
+ scrollToTop(surface)
+ var labels: Set = []
+ for _ in 0..<20 {
+ labels.formUnion(
+ surface.buttons.allElementsBoundByIndex.lazy.map(\.label).filter { $0.hasPrefix(prefix) })
+ guard scrollView.exists else { break }
+ scrollView.scroll(byDeltaX: 0, deltaY: -420)
+ }
+ return labels
+ }
+
+ @MainActor
+ private func contentSignature(_ element: XCUIElement) -> String {
+ let value = String(describing: element.value)
+ let pixels = element.screenshot().pngRepresentation
+ return "\(element.label)|\(value)|\(pixels.count)|\(pixels.hashValue)"
+ }
+
+ @MainActor
+ private func assertAnchorsHeld(
+ statusItem: XCUIElement, statusFrame: CGRect, surface: XCUIElement, panelFrame: CGRect, action: String
+ ) {
+ XCTAssertFalse(statusItem.label.isEmpty, "\(action) removed the status-item accessibility label")
+ XCTAssertLessThan(abs(statusItem.frame.minX - statusFrame.minX), 2, "\(action) moved the status item")
+ XCTAssertLessThan(abs(statusItem.frame.width - statusFrame.width), 2, "\(action) resized the open status item")
+ XCTAssertLessThan(abs(surface.frame.minX - panelFrame.minX), 2, "\(action) moved the panel horizontally")
+ XCTAssertLessThan(abs(surface.frame.minY - panelFrame.minY), 2, "\(action) moved the panel top edge")
+ }
+
+ @MainActor
+ private func assertAndCancelNativePanel(_ application: XCUIApplication, rootedAt directory: URL) {
+ let panel = application.dialogs["save-panel"]
+ XCTAssertTrue(panel.waitForExistence(timeout: 2), "The action did not present an on-screen native panel")
+ guard panel.exists else { return }
+ let location = panel.popUpButtons["where popup"]
+ let locationValue = location.value as? String ?? ""
+ XCTAssertTrue(
+ location.exists && directory.lastPathComponent.hasPrefix(locationValue.replacingOccurrences(of: "...", with: "")),
+ "The native panel is not rooted in \(directory.path): \(locationValue)")
+ let cancel = panel.buttons["CancelButton"]
+ XCTAssertTrue(cancel.isHittable, "The native panel did not expose an on-screen Cancel button")
+ guard cancel.isHittable else { return }
+ cancel.click()
+ XCTAssertTrue(cancel.waitForNonExistence(timeout: 2))
+ }
+
+ @MainActor
+ private func scenarioRecord(
+ tab: String, label: String, element: XCUIElement, action: String = "interact"
+ ) -> ControlAuditRecord {
+ ControlAuditRecord(
+ tab: tab, type: "scenario", identifier: element.identifier, label: label,
+ value: String(describing: element.value), enabled: element.isEnabled, hittable: element.isHittable,
+ interacted: true, action: action, result: "passed", frame: FrameRecord(element.frame))
+ }
+
+ private func absenceRecord(tab: String, label: String, action: String) -> ControlAuditRecord {
+ ControlAuditRecord(
+ tab: tab, type: "absence", identifier: label, label: label, value: "absent", enabled: false,
+ hittable: false, interacted: false, action: action, result: "passed", frame: FrameRecord(.zero))
+ }
+
+ private func assertRequiredInventory(_ records: [ControlAuditRecord]) {
+ let recorded = Set(records.map { "\($0.tab)|\($0.label)" })
+ var required: Set = [
+ "Usage|Refresh all providers", "Usage|Usage-site links", "Usage|Sign-in prompts",
+ "History|UTC boundaries", "History|Window Usage rollups", "History|Additive metric stacking",
+ "History|Previous, next, and Now", "History|Custom From and To",
+ "History|Export CSV save panel Cancel", "Settings|Version and build", "Settings|Distribution channel",
+ "Settings|Reset All Settings Cancel", "Settings|Launch at login", "Settings|Open Login Items",
+ "Settings|Copy Diagnostics", "Settings|Report Issue", "Settings|Source",
+ "Settings|Direct update controls", "Settings|Model order", "Settings|Status format", "Settings|Decimals",
+ "Settings|Template and tokens", "Settings|Live menu bar preview", "Settings|Model filter",
+ "Settings|Command-F model filter",
+ "Settings|Hide unused models", "Settings|Model selection", "Settings|Revert short label",
+ "Settings|Stable order move later and earlier", "Settings|Hide 0%", "Settings|Fit to space",
+ "Settings|Show all providers", "Settings|Provider token refresh", "Settings|History retention",
+ "Settings|Analytics refresh", "Settings|Full history path", "Settings|History Open",
+ "Settings|History Export", "Settings|Clear History confirmation", "Settings|Notifications enabled",
+ "Settings|Window resets", "Settings|Sign-in needed", "Settings|Detailed logging", "Settings|Log level",
+ "Settings|Log search", "Settings|Log Copy", "Settings|Log Clear", "Settings|Show Full Log",
+ "Settings|Log view", "Settings|Command-R refresh", "Settings|Full log Command-F search",
+ ]
+ for threshold in ["50%", "75%", "90%", "100%"] {
+ required.insert("Settings|Notification threshold \(threshold)")
+ }
+ for provider in ProviderID.allCases {
+ required.formUnion([
+ "Usage|Refresh \(provider.displayName)", "Settings|\(provider.displayName) model select-all",
+ "Settings|\(provider.displayName) enabled", "Settings|\(provider.displayName) refresh interval",
+ "Settings|\(provider.displayName) authentication source",
+ ])
+ }
+ XCTAssertTrue(
+ required.isSubset(of: recorded),
+ "Control matrix omitted: \(required.subtracting(recorded).sorted().joined(separator: ", "))")
+ }
+
+ @MainActor
+ private func auditControls(in tab: String, application: XCUIApplication) -> [ControlAuditRecord] {
+ let surface = application.descendants(matching: .any)["popover-surface"]
+ let scrollView = surface.scrollViews.firstMatch
+ var records: [String: ControlAuditRecord] = [:]
+ var unchangedPages = 0
+ var previousKeys: Set = []
+ for _ in 0..<18 {
+ let elements = surface.descendants(matching: .any).allElementsBoundByIndex.filter {
+ Self.auditedTypes.contains($0.elementType) && $0.frame.intersects(surface.frame)
+ }
+ let keys = Set(elements.map(controlKey))
+ unchangedPages = keys == previousKeys ? unchangedPages + 1 : 0
+ previousKeys = keys
+ for element in elements where records[controlKey(element)] == nil {
+ records[controlKey(element)] = exercise(element, tab: tab, application: application)
+ }
+ if tab != "Settings" || application.buttons["Show Full Log"].isHittable || unchangedPages >= 2 { break }
+ guard scrollView.exists else { break }
+ scrollView.scroll(byDeltaX: 0, deltaY: -520)
+ }
+ return records.values.sorted { ($0.type, $0.label, $0.frame.minY) < ($1.type, $1.label, $1.frame.minY) }
+ }
+
+ @MainActor
+ private func exercise(
+ _ element: XCUIElement, tab: String, application: XCUIApplication
+ ) -> ControlAuditRecord {
+ let type = element.elementType
+ let identifier = element.identifier
+ let label = element.label
+ let value = String(describing: element.value)
+ let frame = FrameRecord(element.frame)
+ guard element.isEnabled else {
+ return ControlAuditRecord(
+ tab: tab, type: String(describing: type), identifier: identifier,
+ label: label, value: value, enabled: false, hittable: element.isHittable,
+ interacted: false, action: "observe-disabled", result: "disabled-by-state", frame: frame)
+ }
+ guard element.isHittable else {
+ return ControlAuditRecord(
+ tab: tab, type: String(describing: type), identifier: identifier,
+ label: label, value: value, enabled: true, hittable: false,
+ interacted: false, action: "interact", result: "failed:not-hittable", frame: frame)
+ }
+
+ let started = ProcessInfo.processInfo.systemUptime
+ var result = "passed"
+ var interacted = true
+ switch type {
+ case .button:
+ if ["Usage", "History", "Settings"].contains(label) {
+ interacted = false
+ result = "tab-covered-separately"
+ } else {
+ element.click()
+ if label.contains("Reset") || label.contains("Clear") { dismissConfirmationIfNeeded(application) }
+ if label == "Show Full Log", application.windows.count > 1 {
+ application.typeKey("w", modifierFlags: .command)
+ }
+ }
+ case .checkBox, .switch:
+ element.click()
+ element.click()
+ case .segmentedControl:
+ for segment in element.buttons.allElementsBoundByIndex where segment.isEnabled && segment.isHittable {
+ segment.click()
+ }
+ case .popUpButton, .comboBox:
+ element.click()
+ application.typeKey(.downArrow, modifierFlags: [])
+ application.typeKey(.enter, modifierFlags: [])
+ case .searchField, .textField:
+ let original = element.value as? String ?? ""
+ element.click()
+ application.typeKey("a", modifierFlags: .command)
+ element.typeText(identifier == "model-filter" ? "codex" : "VX")
+ application.typeKey("a", modifierFlags: .command)
+ if !original.isEmpty { element.typeText(original) }
+ case .slider:
+ element.click()
+ application.typeKey(.rightArrow, modifierFlags: [])
+ application.typeKey(.leftArrow, modifierFlags: [])
+ case .datePicker:
+ interacted = tab == "History"
+ result = interacted ? "passed:date-covered-separately" : "failed:unexpected-date-picker"
+ case .link, .menuButton, .radioButton, .stepper:
+ element.click()
+ default:
+ interacted = false
+ result = "failed:unsupported-control"
+ }
+ let latency = ProcessInfo.processInfo.systemUptime - started
+ if latency >= responsivenessBudget { result = "failed:latency-\(Int((latency * 1_000).rounded()))ms" }
+ return ControlAuditRecord(
+ tab: tab, type: String(describing: type), identifier: identifier,
+ label: label, value: value, enabled: true, hittable: true,
+ interacted: interacted, action: "generic-interaction", result: result, frame: frame)
+ }
+
+ @MainActor
+ private func dismissConfirmationIfNeeded(_ application: XCUIApplication) {
+ let alert = application.alerts.firstMatch
+ guard alert.waitForExistence(timeout: 0.1) else { return }
+ let cancel = alert.buttons["Cancel"]
+ if cancel.exists { cancel.click() }
+ }
+
+ @MainActor
+ private func assertVisibleStringsAndControls(in surface: XCUIElement, application: XCUIApplication) {
+ for text in surface.staticTexts.allElementsBoundByIndex where text.frame.intersects(surface.frame) {
+ let label = text.label
+ XCTAssertFalse(label.contains("..."), "Visible text contains a truncation marker: \(label)")
+ XCTAssertFalse(label.hasSuffix("…"), "Visible text ends with a truncation marker: \(label)")
+ }
+ for control in surface.descendants(matching: .any).allElementsBoundByIndex
+ where Self.auditedTypes.contains(control.elementType) && control.frame.intersects(surface.frame)
+ && control.isEnabled
+ {
+ XCTAssertTrue(control.isHittable, "Visible enabled control is not hittable: \(control.label)")
+ }
+ XCTAssertEqual(application.state, .runningForeground)
+ }
+
+ @MainActor
+ private func captureAndAuditPages(
+ tab: String, surface: XCUIElement, application: XCUIApplication, output: URL, prefix: String
+ ) throws -> [String] {
+ let scrollView = surface.scrollViews.firstMatch
+ var exposedText: [String] = []
+ var previousPage: Set = []
+ for page in 0..<18 {
+ assertVisibleStringsAndControls(in: surface, application: application)
+ let text = surface.staticTexts.allElementsBoundByIndex.filter { $0.frame.intersects(surface.frame) }.map(\.label)
+ let values: [String] = surface.descendants(matching: .any).allElementsBoundByIndex.compactMap {
+ element -> String? in
+ guard element.frame.intersects(surface.frame), let value = element.value as? String else { return nil }
+ return value
+ }
+ exposedText += text + values
+ try application.screenshot().pngRepresentation.write(
+ to: output.appendingPathComponent("\(prefix)-\(tab.lowercased())-page-\(page).png"))
+ let fingerprint = Set(text + values)
+ if !scrollView.exists || fingerprint == previousPage
+ || tab == "Settings" && application.buttons["Show Full Log"].isHittable
+ {
+ break
+ }
+ previousPage = fingerprint
+ scrollView.scroll(byDeltaX: 0, deltaY: -520)
+ }
+ return exposedText
+ }
+
+ @MainActor
+ private func tooltipCandidates(in surface: XCUIElement) -> [(String, XCUIElement)] {
+ var controls = surface.descendants(matching: .any).allElementsBoundByIndex.filter {
+ Self.auditedTypes.contains($0.elementType) && $0.isEnabled && $0.isHittable
+ && !["Usage", "History", "Settings"].contains($0.label)
+ }
+ guard controls.count >= 5 else { return [] }
+ let center = CGPoint(x: surface.frame.midX, y: surface.frame.midY)
+ func take(_ best: ([XCUIElement]) -> XCUIElement?) -> XCUIElement {
+ let selected = best(controls)!
+ controls.removeAll { $0 == selected }
+ return selected
+ }
+ return [
+ ("top", take { $0.min { $0.frame.midY < $1.frame.midY } }),
+ ("bottom", take { $0.max { $0.frame.midY < $1.frame.midY } }),
+ ("left", take { $0.min { $0.frame.midX < $1.frame.midX } }),
+ ("right", take { $0.max { $0.frame.midX < $1.frame.midX } }),
+ ("center", take { $0.min { distance($0.frame.center, center) < distance($1.frame.center, center) } }),
+ ]
+ }
+
+ private func waitForTooltip(
+ processIdentifier: pid_t, excluding baseline: [CGWindowID: WindowRecord], timeout: TimeInterval
+ ) throws -> WindowRecord {
+ let deadline = ProcessInfo.processInfo.systemUptime + timeout
+ while ProcessInfo.processInfo.systemUptime < deadline {
+ if let tooltip = applicationWindows(processIdentifier: processIdentifier).first(where: {
+ baseline[$0.key] == nil && $0.value.frame.width <= 420 && $0.value.frame.height <= 240
+ })?.value {
+ return tooltip
+ }
+ Thread.sleep(forTimeInterval: 0.016)
+ }
+ throw ControlAuditError.tooltipMissing
+ }
+
+ private func applicationWindows(processIdentifier: pid_t) -> [CGWindowID: WindowRecord] {
+ guard
+ let values = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID)
+ as? [[CFString: Any]]
+ else { return [:] }
+ return Dictionary(
+ uniqueKeysWithValues: values.compactMap { value in
+ guard
+ (value[kCGWindowOwnerPID] as? NSNumber)?.int32Value == processIdentifier,
+ let identifier = (value[kCGWindowNumber] as? NSNumber)?.uint32Value,
+ let bounds = value[kCGWindowBounds] as? NSDictionary,
+ let frame = CGRect(dictionaryRepresentation: bounds as CFDictionary)
+ else { return nil }
+ return (identifier, WindowRecord(identifier: identifier, frame: frame))
+ })
+ }
+
+ private func movePointerOffPanel() {
+ CGEvent(
+ mouseEventSource: nil, mouseType: .mouseMoved,
+ mouseCursorPosition: CGPoint(x: 5, y: CGDisplayBounds(CGMainDisplayID()).midY), mouseButton: .left
+ )?.post(tap: .cghidEventTap)
+ }
+
+ private func distance(between control: CGRect, and tooltip: CGRect) -> CGFloat {
+ let horizontal = max(max(control.minX - tooltip.maxX, tooltip.minX - control.maxX), 0)
+ let vertical = max(max(control.minY - tooltip.maxY, tooltip.minY - control.maxY), 0)
+ return hypot(horizontal, vertical)
+ }
+
+ private func distance(_ lhs: CGPoint, _ rhs: CGPoint) -> CGFloat {
+ hypot(lhs.x - rhs.x, lhs.y - rhs.y)
+ }
+
+ private func waitUntil(timeout: TimeInterval, condition: () -> Bool) -> Bool {
+ let deadline = ProcessInfo.processInfo.systemUptime + timeout
+ while ProcessInfo.processInfo.systemUptime < deadline {
+ if condition() { return true }
+ Thread.sleep(forTimeInterval: 0.016)
+ }
+ return condition()
+ }
+
+ private func wait(until deadline: TimeInterval) {
+ while ProcessInfo.processInfo.systemUptime < deadline {
+ Thread.sleep(forTimeInterval: min(0.005, deadline - ProcessInfo.processInfo.systemUptime))
+ }
+ }
+
+ private func outputDirectory() throws -> URL {
+ let root =
+ ProcessInfo.processInfo.environment["TMB_BENCHMARK_OUTPUT_DIR"].map {
+ URL(fileURLWithPath: $0, isDirectory: true)
+ } ?? FileManager.default.temporaryDirectory.appendingPathComponent("token-menu-bar-live-audit", isDirectory: true)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ return root
+ }
+
+ private func write(_ value: T, to url: URL) throws {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ try encoder.encode(value).write(to: url)
+ }
+
+ @MainActor
+ private func controlKey(_ element: XCUIElement) -> String {
+ "\(element.elementType)|\(element.identifier)|\(element.label)|\(Int(element.frame.minX))|\(Int(element.frame.minY))"
+ }
+
+ private static let auditedTypes: Set = [
+ .button, .checkBox, .comboBox, .datePicker, .link, .menuButton, .popUpButton, .radioButton,
+ .searchField, .segmentedControl, .slider, .stepper, .switch, .textField,
+ ]
+
+ private enum ControlAuditError: Error {
+ case tooltipMissing
+ }
+}
+
+private struct ControlAuditRecord: Codable {
+ let tab: String
+ let type: String
+ let identifier: String
+ let label: String
+ let value: String
+ let enabled: Bool
+ let hittable: Bool
+ let interacted: Bool
+ let action: String
+ let result: String
+ let frame: FrameRecord
+}
+
+private struct FrameRecord: Codable {
+ let minX: CGFloat
+ let minY: CGFloat
+ let width: CGFloat
+ let height: CGFloat
+
+ init(_ frame: CGRect) {
+ minX = frame.minX
+ minY = frame.minY
+ width = frame.width
+ height = frame.height
+ }
+}
+
+private struct WindowRecord {
+ let identifier: CGWindowID
+ let frame: CGRect
+}
+
+private extension CGRect {
+ var center: CGPoint { CGPoint(x: midX, y: midY) }
+}
diff --git a/App/UITests/TabSwitchBenchmarkUITests.swift b/App/UITests/TabSwitchBenchmarkUITests.swift
new file mode 100644
index 0000000..918f0ad
--- /dev/null
+++ b/App/UITests/TabSwitchBenchmarkUITests.swift
@@ -0,0 +1,386 @@
+import CoreGraphics
+import Foundation
+import XCTest
+
+final class TabSwitchBenchmarkUITests: XCTestCase {
+ private let latencyP95Budget = 0.02
+ private let latencyMaximumBudget = 0.05
+ private let settleTimeout = 2.0
+ private let iterations = 5
+ private let instrumentedPhysicalFootprintBudget = 256 * 1024 * 1024
+ private let instrumentedPhysicalFootprintGrowthBudget = 20 * 1024 * 1024
+
+ @MainActor
+ func testTabSwitchLatencyAndFrameStability() throws {
+ executionTimeAllowance = 120
+ let verification = VerificationApplication(testName: name, detailedLogging: true)
+ defer { verification.terminate() }
+ let launchStarted = ProcessInfo.processInfo.systemUptime
+ verification.launch()
+
+ XCTAssertTrue(verification.statusItem.waitForExistence(timeout: 3))
+ let launchToStatusItem = ProcessInfo.processInfo.systemUptime - launchStarted
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 3))
+ let launchToPanel = ProcessInfo.processInfo.systemUptime - launchStarted
+ verification.application.typeKey(.escape, modifierFlags: [])
+ XCTAssertTrue(verification.tabs.waitForNonExistence(timeout: 2))
+ let processIdentifier = try verification.processIdentifier()
+ let frameSampler = WindowFrameSampler(processIdentifier: processIdentifier)
+ frameSampler.start()
+ verification.openPopover()
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 2))
+
+ let output = try outputDirectory()
+ let surface = verification.application.descendants(matching: .any)["popover-surface"]
+ let initialFrame = surface.frame
+ let openFrame = try settledFrame(
+ of: surface, after: verification.application.descendants(matching: .any)["tab-content-Usage"],
+ timeout: settleTimeout)
+ let openFrameTimeline = frameSampler.stop()
+ XCTAssertFalse(openFrameTimeline.isEmpty, "CGWindow did not expose the opening panel frame")
+ if let finalWindowFrame = openFrameTimeline.last?.frame {
+ for sample in openFrameTimeline {
+ XCTAssertLessThan(abs(sample.frame.minX - finalWindowFrame.minX), 2, "Opening panel moved horizontally")
+ XCTAssertLessThan(abs(sample.frame.minY - finalWindowFrame.minY), 2, "Opening panel top edge moved")
+ }
+ }
+ XCTAssertLessThan(abs(initialFrame.minX - openFrame.minX), 2, "Popover moved horizontally after opening")
+ XCTAssertLessThan(abs(initialFrame.minY - openFrame.minY), 2, "Popover top edge moved after opening")
+
+ verification.tab("Settings").click()
+ let coldSettingsFrame = try settledFrame(
+ of: surface, after: readyContent("Settings", application: verification.application),
+ timeout: settleTimeout)
+ XCTAssertLessThan(abs(coldSettingsFrame.minX - openFrame.minX), 2, "Cold Settings moved horizontally")
+ XCTAssertLessThan(abs(coldSettingsFrame.minY - openFrame.minY), 2, "Cold Settings moved the panel top edge")
+
+ for tab in ["History", "Usage"] {
+ verification.tab(tab).click()
+ _ = try settledFrame(
+ of: surface, after: readyContent(tab, application: verification.application),
+ timeout: settleTimeout)
+ }
+
+ let settledProcessSnapshot = try verification.processSnapshot()
+ let idleCPUStart = try verification.cpuTime()
+ Thread.sleep(forTimeInterval: 10)
+ let idleCPUTime = try verification.cpuTime() - idleCPUStart
+ let idleProcessSnapshot = try verification.processSnapshot()
+ let physicalFootprintBefore = try verification.physicalFootprintBytes()
+ XCTAssertLessThan(
+ physicalFootprintBefore,
+ instrumentedPhysicalFootprintBudget,
+ "Instrumented physical footprint exceeded 256 MB")
+ XCTAssertLessThan(idleCPUTime, 0.1, "Idle CPU exceeded 1% of one core over ten seconds")
+ let interactionCPUStart = try verification.cpuTime()
+ let tabs = ["History", "Settings", "Usage"]
+ var samples: [TabSwitchSample] = []
+ for iteration in 1...iterations {
+ for tab in tabs {
+ verification.tab(tab).click()
+ let firstFrame = surface.frame
+ let frame = try settledFrame(
+ of: surface, after: readyContent(tab, application: verification.application),
+ timeout: settleTimeout)
+ samples.append(
+ TabSwitchSample(iteration: iteration, tab: tab, latency: 0, firstFrame: firstFrame, frame: frame))
+ XCTAssertLessThan(abs(firstFrame.minX - frame.minX), 2, "\(tab) moved horizontally while settling")
+ XCTAssertLessThan(abs(firstFrame.minY - frame.minY), 2, "\(tab) moved the panel's top edge while settling")
+ }
+ }
+
+ let presentationDurations = try verification.tabPresentationDurations()
+ XCTAssertGreaterThanOrEqual(presentationDurations.count, samples.count + 3)
+ let coldSettingsPresentation = presentationDurations[presentationDurations.count - samples.count - 3]
+ samples = zip(samples, presentationDurations.suffix(samples.count)).map { sample, latency in
+ TabSwitchSample(
+ iteration: sample.iteration,
+ tab: sample.tab,
+ latency: latency,
+ firstFrame: sample.firstFrame.cgRect,
+ frame: sample.frame.cgRect)
+ }
+ for sample in samples {
+ XCTAssertLessThan(
+ sample.latency, latencyMaximumBudget,
+ "\(sample.tab) took \(Self.milliseconds(sample.latency)) ms to present; maximum is \(Self.milliseconds(latencyMaximumBudget)) ms"
+ )
+ }
+ XCTAssertLessThan(
+ coldSettingsPresentation, latencyMaximumBudget,
+ "First Settings presentation took \(Self.milliseconds(coldSettingsPresentation)) ms")
+ let p95 = Self.percentile(samples.map(\.latency), percentile: 0.95)
+ let interactionCPUTime = try verification.cpuTime() - interactionCPUStart
+ XCTAssertLessThan(
+ p95, latencyP95Budget,
+ "Warm tab p95 was \(Self.milliseconds(p95)) ms; budget is \(Self.milliseconds(latencyP95Budget)) ms")
+ Thread.sleep(forTimeInterval: 2)
+ let physicalFootprintAfter = try verification.physicalFootprintBytes()
+ let interactionProcessSnapshot = try verification.processSnapshot()
+ XCTAssertLessThan(
+ physicalFootprintAfter,
+ instrumentedPhysicalFootprintBudget,
+ "Instrumented physical footprint exceeded 256 MB")
+ XCTAssertLessThan(
+ physicalFootprintAfter - physicalFootprintBefore,
+ instrumentedPhysicalFootprintGrowthBudget,
+ "(iterations) tab cycles grew instrumented physical footprint by more than 20 MB")
+
+ for tab in tabs {
+ verification.tab(tab).click()
+ _ = try settledFrame(
+ of: surface, after: readyContent(tab, application: verification.application),
+ timeout: settleTimeout)
+ retain(
+ verification.application.screenshot().pngRepresentation,
+ named: "\(tab.lowercased()).png", writingTo: output)
+ }
+
+ let widths = samples.map(\.width)
+ let horizontalOrigins = samples.map(\.minX)
+ let topEdges = samples.map(\.minY)
+ XCTAssertLessThan((widths.max() ?? 0) - (widths.min() ?? 0), 2, "Popover width moved")
+ XCTAssertLessThan((horizontalOrigins.max() ?? 0) - (horizontalOrigins.min() ?? 0), 2, "Popover moved horizontally")
+ XCTAssertLessThan((topEdges.max() ?? 0) - (topEdges.min() ?? 0), 2, "Popover top edge moved")
+ let report = TabSwitchReport(
+ p95BudgetMilliseconds: Self.milliseconds(latencyP95Budget),
+ maximumBudgetMilliseconds: Self.milliseconds(latencyMaximumBudget),
+ coldSettingsPresentation: coldSettingsPresentation,
+ launchToStatusItem: launchToStatusItem, launchToPanel: launchToPanel,
+ displayBounds: CGDisplayBounds(CGMainDisplayID()),
+ physicalFootprintBefore: physicalFootprintBefore, physicalFootprintAfter: physicalFootprintAfter,
+ idleCPUTime: idleCPUTime, interactionCPUTime: interactionCPUTime,
+ openFirstFrame: initialFrame, openSettledFrame: openFrame,
+ openFrameTimeline: openFrameTimeline,
+ processSnapshots: [settledProcessSnapshot, idleProcessSnapshot, interactionProcessSnapshot], samples: samples)
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ retain(try encoder.encode(report), named: "timings.json", writingTo: output)
+ print("TAB_SWITCH_BENCHMARK=\(output.path)")
+ print(
+ "TAB_SWITCH_SUMMARY median_ms=\(Self.milliseconds(Self.percentile(samples.map(\.latency), percentile: 0.5))) "
+ + "p95_ms=\(Self.milliseconds(p95)) max_ms=\(Self.milliseconds(samples.map(\.latency).max() ?? 0)) "
+ + "cold_settings_ms=\(Self.milliseconds(coldSettingsPresentation)) "
+ + "launch_status_ms=\(Self.milliseconds(launchToStatusItem)) launch_panel_ms=\(Self.milliseconds(launchToPanel)) "
+ + "footprint_before=\(physicalFootprintBefore) footprint_after=\(physicalFootprintAfter) "
+ + "idle_cpu_s=\(idleCPUTime) "
+ + "interaction_cpu_s=\(interactionCPUTime)")
+ for tab in tabs {
+ let latencies = samples.filter { $0.tab == tab }.map(\.latency)
+ print(
+ "TAB_SWITCH_SUMMARY tab=\(tab) median_ms=\(Self.milliseconds(Self.percentile(latencies, percentile: 0.5))) "
+ + "p95_ms=\(Self.milliseconds(Self.percentile(latencies, percentile: 0.95))) "
+ + "max_ms=\(Self.milliseconds(latencies.max() ?? 0))")
+ }
+ for sample in samples {
+ print(
+ "TAB_SWITCH tab=\(sample.tab) iteration=\(sample.iteration) latency_ms=\(Self.milliseconds(sample.latency)) "
+ + "frame=\(sample.frameDescription)")
+ }
+ }
+
+ @MainActor
+ private func settledFrame(of surface: XCUIElement, after content: XCUIElement, timeout: TimeInterval) throws -> CGRect
+ {
+ let deadline = ProcessInfo.processInfo.systemUptime + timeout
+ var previous: CGRect?
+ var stableObservations = 0
+ while ProcessInfo.processInfo.systemUptime < deadline {
+ if content.exists, surface.exists {
+ let frame = surface.frame
+ if frame.width > 0, frame.height > 0 {
+ stableObservations = previous.map { Self.matches($0, frame) } == true ? stableObservations + 1 : 0
+ previous = frame
+ if stableObservations >= 2 { return frame }
+ }
+ }
+ Thread.sleep(forTimeInterval: 0.016)
+ }
+ throw BenchmarkError.didNotSettle
+ }
+
+ @MainActor
+ private func readyContent(_ tab: String, application: XCUIApplication) -> XCUIElement {
+ if tab == "Settings" { return application.textFields["model-filter"] }
+ return application.descendants(matching: .any)["tab-content-\(tab)"]
+ }
+
+ private func outputDirectory() throws -> URL {
+ let environment = ProcessInfo.processInfo.environment
+ let root =
+ environment["TMB_BENCHMARK_OUTPUT_DIR"].map { URL(fileURLWithPath: $0, isDirectory: true) }
+ ?? FileManager.default.temporaryDirectory.appendingPathComponent("token-menu-bar-benchmark", isDirectory: true)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ return root
+ }
+
+ private func retain(_ data: Data, named name: String, writingTo directory: URL) {
+ let attachment = XCTAttachment(
+ data: data, uniformTypeIdentifier: name.hasSuffix(".json") ? "public.json" : "public.png")
+ attachment.name = name
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ try? data.write(to: directory.appendingPathComponent(name))
+ }
+
+ private static func matches(_ lhs: CGRect, _ rhs: CGRect) -> Bool {
+ abs(lhs.minX - rhs.minX) < 0.5 && abs(lhs.minY - rhs.minY) < 0.5 && abs(lhs.width - rhs.width) < 0.5
+ && abs(lhs.height - rhs.height) < 0.5
+ }
+
+ private static func milliseconds(_ duration: TimeInterval) -> Int {
+ Int((duration * 1_000).rounded())
+ }
+
+ private static func percentile(_ values: [TimeInterval], percentile: Double) -> TimeInterval {
+ let sorted = values.sorted()
+ guard !sorted.isEmpty else { return 0 }
+ let index = min(Int(ceil(Double(sorted.count) * percentile)) - 1, sorted.count - 1)
+ return sorted[max(index, 0)]
+ }
+
+ private enum BenchmarkError: Error {
+ case didNotSettle
+ }
+}
+
+private struct TabSwitchReport: Codable {
+ let p95BudgetMilliseconds: Int
+ let maximumBudgetMilliseconds: Int
+ let coldSettingsPresentation: TimeInterval
+ let launchToStatusItem: TimeInterval
+ let launchToPanel: TimeInterval
+ let displayWidth: CGFloat
+ let displayHeight: CGFloat
+ let physicalFootprintBefore: Int
+ let physicalFootprintAfter: Int
+ let idleCPUTime: TimeInterval
+ let interactionCPUTime: TimeInterval
+ let openFirstFrame: FrameSample
+ let openSettledFrame: FrameSample
+ let openFrameTimeline: [WindowFrameTimelineSample]
+ let processSnapshots: [String]
+ let samples: [TabSwitchSample]
+
+ init(
+ p95BudgetMilliseconds: Int, maximumBudgetMilliseconds: Int,
+ coldSettingsPresentation: TimeInterval,
+ launchToStatusItem: TimeInterval, launchToPanel: TimeInterval,
+ displayBounds: CGRect, physicalFootprintBefore: Int, physicalFootprintAfter: Int,
+ idleCPUTime: TimeInterval, interactionCPUTime: TimeInterval, openFirstFrame: CGRect, openSettledFrame: CGRect,
+ openFrameTimeline: [WindowFrameTimelineSample], processSnapshots: [String], samples: [TabSwitchSample]
+ ) {
+ self.p95BudgetMilliseconds = p95BudgetMilliseconds
+ self.maximumBudgetMilliseconds = maximumBudgetMilliseconds
+ self.coldSettingsPresentation = coldSettingsPresentation
+ self.launchToStatusItem = launchToStatusItem
+ self.launchToPanel = launchToPanel
+ displayWidth = displayBounds.width
+ displayHeight = displayBounds.height
+ self.physicalFootprintBefore = physicalFootprintBefore
+ self.physicalFootprintAfter = physicalFootprintAfter
+ self.idleCPUTime = idleCPUTime
+ self.interactionCPUTime = interactionCPUTime
+ self.openFirstFrame = FrameSample(openFirstFrame)
+ self.openSettledFrame = FrameSample(openSettledFrame)
+ self.openFrameTimeline = openFrameTimeline
+ self.processSnapshots = processSnapshots
+ self.samples = samples
+ }
+}
+
+private struct TabSwitchSample: Codable {
+ let iteration: Int
+ let tab: String
+ let latency: TimeInterval
+ let firstFrame: FrameSample
+ let frame: FrameSample
+
+ init(iteration: Int, tab: String, latency: TimeInterval, firstFrame: CGRect, frame: CGRect) {
+ self.iteration = iteration
+ self.tab = tab
+ self.latency = latency
+ self.firstFrame = FrameSample(firstFrame)
+ self.frame = FrameSample(frame)
+ }
+
+ var minX: CGFloat { frame.minX }
+ var minY: CGFloat { frame.minY }
+ var width: CGFloat { frame.width }
+ var frameDescription: String {
+ "\(Int(frame.minX.rounded())),\(Int(frame.minY.rounded())),\(Int(frame.width.rounded())),\(Int(frame.height.rounded()))"
+ }
+}
+
+struct FrameSample: Codable {
+ let minX: CGFloat
+ let minY: CGFloat
+ let width: CGFloat
+ let height: CGFloat
+
+ var cgRect: CGRect { CGRect(x: minX, y: minY, width: width, height: height) }
+
+ init(_ frame: CGRect) {
+ minX = frame.minX
+ minY = frame.minY
+ width = frame.width
+ height = frame.height
+ }
+}
+
+struct WindowFrameTimelineSample: Codable {
+ let elapsed: TimeInterval
+ let frame: FrameSample
+}
+
+final class WindowFrameSampler: @unchecked Sendable {
+ private let processIdentifier: pid_t
+ private let started = ProcessInfo.processInfo.systemUptime
+ private let queue = DispatchQueue(label: "dev.tox.token-menu-bar.frame-sampler")
+ private let lock = NSLock()
+ private var samples: [WindowFrameTimelineSample] = []
+ private var timer: DispatchSourceTimer?
+
+ init(processIdentifier: pid_t) {
+ self.processIdentifier = processIdentifier
+ }
+
+ func start() {
+ let timer = DispatchSource.makeTimerSource(queue: queue)
+ timer.schedule(deadline: .now(), repeating: .milliseconds(6), leeway: .milliseconds(1))
+ timer.setEventHandler { [weak self] in self?.capture() }
+ lock.withLock { self.timer = timer }
+ timer.resume()
+ }
+
+ func stop() -> [WindowFrameTimelineSample] {
+ let timer = lock.withLock { () -> DispatchSourceTimer? in
+ let timer = self.timer
+ self.timer = nil
+ return timer
+ }
+ timer?.cancel()
+ queue.sync {}
+ return lock.withLock { samples }
+ }
+
+ private func capture() {
+ guard
+ let values = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID)
+ as? [[CFString: Any]],
+ let frame = values.compactMap({ value -> CGRect? in
+ guard
+ (value[kCGWindowOwnerPID] as? NSNumber)?.int32Value == processIdentifier,
+ (value[kCGWindowLayer] as? NSNumber)?.intValue == 0,
+ let bounds = value[kCGWindowBounds] as? NSDictionary,
+ let frame = CGRect(dictionaryRepresentation: bounds as CFDictionary),
+ frame.width >= 500, frame.height >= 100
+ else { return nil }
+ return frame
+ }).max(by: { $0.width * $0.height < $1.width * $1.height })
+ else { return }
+ let sample = WindowFrameTimelineSample(
+ elapsed: ProcessInfo.processInfo.systemUptime - started, frame: FrameSample(frame))
+ lock.withLock { samples.append(sample) }
+ }
+}
diff --git a/App/UITests/TokenMenuBarApplicationUITests.swift b/App/UITests/TokenMenuBarApplicationUITests.swift
new file mode 100644
index 0000000..0ca6046
--- /dev/null
+++ b/App/UITests/TokenMenuBarApplicationUITests.swift
@@ -0,0 +1,134 @@
+import XCTest
+
+final class TokenMenuBarApplicationUITests: XCTestCase {
+ @MainActor
+ func testStatusItemReopensThePopoverAfterEscape() {
+ let verification = VerificationApplication(testName: name)
+ defer { verification.terminate() }
+ verification.launch()
+
+ XCTAssertTrue(verification.statusItem.waitForExistence(timeout: 5))
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+ verification.application.typeKey(.escape, modifierFlags: [])
+ XCTAssertTrue(verification.tabs.waitForNonExistence(timeout: 2))
+
+ verification.openPopover()
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 2))
+ }
+
+ @MainActor
+ func testEveryTabExposesNamedControls() {
+ let verification = VerificationApplication(testName: name)
+ defer { verification.terminate() }
+ verification.launch()
+
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+ for tab in ["History", "Settings", "Usage"] {
+ verification.selectTab(tab)
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 2))
+ let surface = verification.application.descendants(matching: .any)["popover-surface"]
+ XCTAssertTrue(surface.waitForExistence(timeout: 2))
+ let content = verification.application.descendants(matching: .any)["tab-content-\(tab)"]
+ XCTAssertTrue(content.waitForExistence(timeout: 2))
+ XCTAssertFalse(verification.tab(tab).label.isEmpty)
+ for identifier in ["footer-refresh", "footer-report-issue", "footer-quit"] {
+ let control = verification.application.descendants(matching: .any)[identifier]
+ XCTAssertTrue(control.waitForExistence(timeout: 2))
+ XCTAssertFalse(control.label.isEmpty)
+ }
+ switch tab {
+ case "Usage":
+ XCTAssertTrue(
+ verification.application.descendants(matching: .any)["usage-refresh"].waitForExistence(timeout: 2))
+ case "History":
+ XCTAssertTrue(
+ verification.application.descendants(matching: .any)["history-period"].waitForExistence(timeout: 2))
+ XCTAssertTrue(
+ verification.application.descendants(matching: .any)["history-from"].waitForExistence(timeout: 2))
+ default:
+ XCTAssertTrue(
+ verification.application.descendants(matching: .any)["model-filter"].waitForExistence(timeout: 2))
+ }
+ }
+ }
+
+ @MainActor
+ func testTabSwitchReturnsToIdle() throws {
+ let verification = VerificationApplication(testName: name)
+ defer { verification.terminate() }
+ verification.launch()
+
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+ let before = try verification.cpuTime()
+ verification.selectTab("History")
+ Thread.sleep(forTimeInterval: 2)
+ XCTAssertLessThan(try verification.cpuTime() - before, 0.5)
+ }
+
+ @MainActor
+ func testCommandFFocusesTheModelFilter() {
+ let verification = VerificationApplication(testName: name)
+ defer { verification.terminate() }
+ verification.launch()
+
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+ verification.tab("Settings").click()
+ verification.application.typeKey("f", modifierFlags: .command)
+ let filter = verification.application.textFields["model-filter"]
+ XCTAssertTrue(filter.waitForExistence(timeout: 2))
+ filter.typeText("codex")
+ XCTAssertEqual(filter.value as? String, "codex")
+ XCTAssertNotEqual(verification.application.textFields["Search log"].value as? String, "codex")
+ }
+
+ @MainActor
+ func testEscapeClosesEveryTab() {
+ let verification = VerificationApplication(testName: name)
+ defer { verification.terminate() }
+ verification.launch()
+
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+ for tab in ["History", "Settings"] {
+ verification.tab(tab).click()
+ verification.application.typeKey(.escape, modifierFlags: [])
+ XCTAssertTrue(verification.tabs.waitForNonExistence(timeout: 2))
+ verification.openPopover()
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 2))
+ }
+ }
+
+ @MainActor
+ func testLaunchStaysWithinBudget() {
+ let verification = VerificationApplication(testName: name)
+ defer { verification.terminate() }
+
+ let duration = verification.launch()
+
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+ XCTAssertLessThan(duration, 5)
+ }
+
+ @MainActor
+ func testResidentMemoryStaysWithinBudget() throws {
+ let verification = VerificationApplication(testName: name)
+ defer { verification.terminate() }
+ verification.launch()
+
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+ XCTAssertLessThan(try verification.residentMemoryBytes(), 256 * 1024 * 1024)
+ }
+
+ @MainActor
+ func testIdleCPUStaysWithinBudget() throws {
+ let verification = VerificationApplication(testName: name)
+ defer { verification.terminate() }
+ verification.launch()
+
+ XCTAssertTrue(verification.tabs.waitForExistence(timeout: 5))
+ Thread.sleep(forTimeInterval: 1)
+ let before = try verification.cpuTime()
+ Thread.sleep(forTimeInterval: 2)
+ let consumed = try verification.cpuTime() - before
+ XCTAssertLessThan(consumed, 0.5)
+ }
+}
diff --git a/App/UITests/VerificationApplication.swift b/App/UITests/VerificationApplication.swift
new file mode 100644
index 0000000..daa8779
--- /dev/null
+++ b/App/UITests/VerificationApplication.swift
@@ -0,0 +1,160 @@
+import AppKit
+import Foundation
+import TokenMenuBarCore
+import XCTest
+
+@MainActor
+struct VerificationApplication {
+ enum Appearance: String {
+ case light = "Light"
+ case dark = "Dark"
+ }
+
+ let application: XCUIApplication
+ private let launchPolicy: LaunchPolicy
+ private let session: String
+
+ init(
+ testName: String, profile: VerificationProfile = VerificationProfile(),
+ appearance: Appearance? = nil, doubleLocalizedStrings: Bool = false, detailedLogging: Bool = false
+ ) {
+ let session = "\(testName)-\(UUID().uuidString)"
+ self.session = session
+ let supportDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "token-menu-bar-verify-\(session)", isDirectory: true)
+ application = XCUIApplication()
+ application.launchArguments = [LaunchPolicy.verificationArgument]
+ if let appearance {
+ application.launchArguments += ["-AppleInterfaceStyle", appearance.rawValue]
+ }
+ if doubleLocalizedStrings {
+ application.launchArguments += ["-NSDoubleLocalizedStrings", "YES"]
+ }
+ if detailedLogging {
+ application.launchArguments += ["-detailedLogging", "YES"]
+ }
+ application.launchEnvironment = [
+ LaunchPolicy.verificationSessionKey: session,
+ LaunchPolicy.verificationSupportDirectoryKey: supportDirectory.path,
+ VerificationProfile.fixtureEnvironmentKey: profile.fixture.rawValue,
+ ]
+ if let visibleFrameWidth = profile.visibleFrameWidth {
+ application.launchEnvironment[VerificationProfile.visibleFrameWidthEnvironmentKey] = String(visibleFrameWidth)
+ }
+ if profile.nativePanels {
+ application.launchEnvironment[VerificationProfile.nativePanelsEnvironmentKey] = "1"
+ }
+ launchPolicy = LaunchPolicy(
+ arguments: ["TokenMenuBar", LaunchPolicy.verificationArgument],
+ environment: [
+ LaunchPolicy.verificationSessionKey: session,
+ LaunchPolicy.verificationSupportDirectoryKey: supportDirectory.path,
+ ])
+ }
+
+ var statusItem: XCUIElement { application.statusItems.firstMatch }
+ var tabs: XCUIElement {
+ application.descendants(matching: .radioGroup)
+ .matching(NSPredicate(format: "label == %@", "Popover tabs")).firstMatch
+ }
+
+ func tab(_ title: String) -> XCUIElement {
+ tabs.radioButtons[title]
+ }
+
+ func selectTab(_ title: String) {
+ tab(title).click()
+ }
+
+ var supportDirectory: URL { launchPolicy.supportDirectory! }
+
+ func openPopover() {
+ if NSScreen.screens.contains(where: { $0.frame.intersects(statusItem.frame) }) {
+ statusItem.click()
+ return
+ }
+ for _ in 0..<5 {
+ DistributedNotificationCenter.default().post(
+ name: LaunchPolicy.verificationOpenPopoverNotification,
+ object: session,
+ userInfo: nil)
+ Thread.sleep(forTimeInterval: 0.05)
+ }
+ }
+
+ @discardableResult func launch() -> TimeInterval {
+ let started = Date()
+ application.launch()
+ return Date().timeIntervalSince(started)
+ }
+
+ func terminate() {
+ application.terminate()
+ _ = application.wait(for: .notRunning, timeout: 2)
+ do {
+ try launchPolicy.cleanup()
+ } catch {
+ XCTFail("Could not remove verification state: \(error)")
+ }
+ }
+
+ func residentMemoryBytes() throws -> Int {
+ Int(try performanceSnapshot().residentMemoryBytes)
+ }
+
+ func physicalFootprintBytes() throws -> Int {
+ Int(try performanceSnapshot().physicalFootprintBytes)
+ }
+
+ func cpuTime() throws -> TimeInterval {
+ TimeInterval(try performanceSnapshot().cpuNanoseconds) / 1_000_000_000
+ }
+
+ func processSnapshot() throws -> String {
+ let identifier = try processIdentifier()
+ let snapshot = try performanceSnapshot()
+ return "pid=\(identifier) rss=\(snapshot.residentMemoryBytes) footprint=\(snapshot.physicalFootprintBytes) "
+ + "cpu_ns=\(snapshot.cpuNanoseconds)"
+ }
+
+ func processIdentifier() throws -> pid_t {
+ let processIdentifier = try performanceSnapshot().processIdentifier
+ guard processIdentifier > 0 else { throw ProcessMeasurementError.processNotFound }
+ return processIdentifier
+ }
+
+ func tabPresentationDurations() throws -> [TimeInterval] {
+ _ = try performanceSnapshot()
+ let text = try String(contentsOf: supportDirectory.appendingPathComponent("log.txt"), encoding: .utf8)
+ let expression = try NSRegularExpression(pattern: #"tab\.presented[^\n]*durationMs=([0-9.]+)"#)
+ let range = NSRange(text.startIndex..., in: text)
+ return expression.matches(in: text, range: range).compactMap { match in
+ guard let range = Range(match.range(at: 1), in: text), let milliseconds = Double(text[range]) else {
+ return nil
+ }
+ return milliseconds / 1_000
+ }
+ }
+
+ private func performanceSnapshot() throws -> ProcessPerformanceSnapshot {
+ let url = supportDirectory.appendingPathComponent("process-snapshot.json")
+ if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) }
+ DistributedNotificationCenter.default().post(
+ name: LaunchPolicy.verificationSnapshotNotification,
+ object: session,
+ userInfo: nil)
+ let deadline = Date().addingTimeInterval(2)
+ repeat {
+ if FileManager.default.fileExists(atPath: url.path) {
+ return try JSONDecoder().decode(ProcessPerformanceSnapshot.self, from: Data(contentsOf: url))
+ }
+ Thread.sleep(forTimeInterval: 0.01)
+ } while Date() < deadline
+ throw ProcessMeasurementError.measurementFailed
+ }
+
+ private enum ProcessMeasurementError: Error {
+ case measurementFailed
+ case processNotFound
+ }
+}
diff --git a/App/Widget/Sources/WidgetBundle.swift b/App/Widget/Sources/WidgetBundle.swift
new file mode 100644
index 0000000..daf49f2
--- /dev/null
+++ b/App/Widget/Sources/WidgetBundle.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+import TokenMenuBarWidgets
+import WidgetKit
+
+@main
+struct TokenMenuBarWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ UsageWidget()
+ }
+}
diff --git a/App/Widget/Widget.entitlements b/App/Widget/Widget.entitlements
new file mode 100644
index 0000000..01f6b13
--- /dev/null
+++ b/App/Widget/Widget.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ $(APP_GROUP_ID)
+
+
+
diff --git a/App/project.yml b/App/project.yml
new file mode 100644
index 0000000..73f11f7
--- /dev/null
+++ b/App/project.yml
@@ -0,0 +1,248 @@
+name: TokenMenuBar
+options:
+ bundleIdPrefix: dev.tox
+ deploymentTarget:
+ macOS: "14.0"
+ xcodeVersion: "26.0"
+ createIntermediateGroups: true
+ generateEmptyDirectories: false
+configs:
+ Debug: debug
+ Release: release
+ Homebrew: release
+ AppStore: release
+settings:
+ base:
+ SWIFT_VERSION: "6.0"
+ SWIFT_STRICT_CONCURRENCY: complete
+ SWIFT_TREAT_WARNINGS_AS_ERRORS: YES
+ MARKETING_VERSION: "0.1.0"
+ CURRENT_PROJECT_VERSION: "1"
+ # The git-derived version, which names the commit a dev build came from; see Scripts/version.sh
+ SOURCE_VERSION: "0.1.0"
+ MACOSX_DEPLOYMENT_TARGET: "14.0"
+ ARCHS: arm64 x86_64
+ ONLY_ACTIVE_ARCH: NO
+ # Archives strip by default, but a plain `xcodebuild build` does not, and the difference is over half the
+ # binary. Naming them means the shipped size does not depend on which invocation produced it.
+ STRIP_INSTALLED_PRODUCT: YES
+ STRIP_STYLE: all
+ DEAD_CODE_STRIPPING: YES
+ APP_GROUP_ID: group.dev.tox.token-menu-bar
+ # The names of the App Store profiles, matched by the export options plist the release writes
+ APP_PROFILE: Token Menu Bar App Store
+ WIDGET_PROFILE: Token Menu Bar Widget App Store
+ configs:
+ Debug:
+ ONLY_ACTIVE_ARCH: YES
+packages:
+ TokenMenuBarPackage:
+ path: ..
+ Sparkle:
+ url: https://github.com/sparkle-project/Sparkle
+ from: "2.9.0"
+targetTemplates:
+ Application:
+ type: application
+ platform: macOS
+ sources:
+ - path: ../Sources/TokenMenuBar
+ name: Entry
+ - path: Sources
+ name: App
+ excludes:
+ - SparkleUpdater.swift
+ - path: Assets.xcassets
+ - path: PrivacyInfo.xcprivacy
+ dependencies:
+ - package: TokenMenuBarPackage
+ product: TokenMenuBarUI
+ - package: TokenMenuBarPackage
+ product: TokenMenuBarCore
+ - target: TokenMenuBarWidget
+ embed: true
+ info:
+ path: ${info_path}
+ properties:
+ CFBundleName: Token Menu Bar
+ CFBundleDisplayName: Token Menu Bar
+ CFBundleIdentifier: $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
+ TMBSourceVersion: $(SOURCE_VERSION)
+ TMBDistribution: ${distribution}
+ TMBSelfUpdateEnabled: $(SELF_UPDATE_ENABLED)
+ LSUIElement: true
+ LSMinimumSystemVersion: "14.0"
+ LSApplicationCategoryType: public.app-category.developer-tools
+ NSHumanReadableCopyright: "Copyright © 2026 Bernát Gábor. MIT licensed."
+ ITSAppUsesNonExemptEncryption: false
+ NSSupportsAutomaticTermination: false
+ TokenMenuBarAppGroup: $(APP_GROUP_ID)
+ settings:
+ base:
+ PRODUCT_NAME: Token Menu Bar
+ PRODUCT_BUNDLE_IDENTIFIER: dev.tox.token-menu-bar
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ ENABLE_HARDENED_RUNTIME: YES
+ ENABLE_APP_SANDBOX: NO
+ CODE_SIGN_ENTITLEMENTS: ${entitlements}
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS: ${condition}
+ TMB_DISTRIBUTION: ${distribution}
+ LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/../Frameworks"
+targets:
+ TokenMenuBarDirect:
+ templates:
+ - Application
+ templateAttributes:
+ condition: DIRECT
+ distribution: Direct
+ entitlements: Direct.entitlements
+ info_path: Info.plist
+ sources:
+ - path: Sources/SparkleUpdater.swift
+ name: SparkleUpdater
+ dependencies:
+ - package: Sparkle
+ product: Sparkle
+ info:
+ properties:
+ SUFeedURL: https://github.com/tox-dev/token-menu-bar-macos/releases/latest/download/appcast.xml
+ SUPublicEDKey: $(SPARKLE_PUBLIC_ED_KEY)
+ SUEnableInstallerLauncherService: true
+ settings:
+ base:
+ SPARKLE_PUBLIC_ED_KEY: ""
+ SELF_UPDATE_ENABLED: NO
+ configs:
+ Debug:
+ CODE_SIGN_ENTITLEMENTS: ""
+ CODE_SIGN_IDENTITY: "-"
+ CODE_SIGN_STYLE: Manual
+ PRODUCT_NAME: TokenMenuBarDirect
+ PRODUCT_BUNDLE_IDENTIFIER: dev.tox.token-menu-bar.verification
+ Release:
+ CODE_SIGN_IDENTITY: "Developer ID Application"
+ CODE_SIGN_STYLE: Manual
+ TokenMenuBarHomebrew:
+ templates:
+ - Application
+ templateAttributes:
+ condition: HOMEBREW
+ distribution: Homebrew
+ entitlements: Direct.entitlements
+ info_path: Info-Homebrew.plist
+ settings:
+ configs:
+ Homebrew:
+ CODE_SIGN_IDENTITY: "Developer ID Application"
+ CODE_SIGN_STYLE: Manual
+ TokenMenuBarAppStore:
+ templates:
+ - Application
+ templateAttributes:
+ condition: APPSTORE
+ distribution: App Store
+ entitlements: AppStore.entitlements
+ info_path: Info-AppStore.plist
+ settings:
+ base:
+ ENABLE_HARDENED_RUNTIME: NO
+ ENABLE_APP_SANDBOX: YES
+ configs:
+ AppStore:
+ CODE_SIGN_IDENTITY: "Apple Distribution"
+ CODE_SIGN_STYLE: Manual
+ PROVISIONING_PROFILE_SPECIFIER: $(APP_PROFILE)
+ TokenMenuBarWidget:
+ type: app-extension
+ platform: macOS
+ sources:
+ - path: Widget/Sources
+ name: Widget
+ dependencies:
+ - package: TokenMenuBarPackage
+ product: TokenMenuBarWidgets
+ - package: TokenMenuBarPackage
+ product: TokenMenuBarCore
+ - sdk: WidgetKit.framework
+ - sdk: SwiftUI.framework
+ info:
+ path: Widget/Info.plist
+ properties:
+ CFBundleName: Token Menu Bar Widget
+ CFBundleDisplayName: Token Menu Bar
+ CFBundleIdentifier: $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
+ TMBSourceVersion: $(SOURCE_VERSION)
+ TokenMenuBarAppGroup: $(APP_GROUP_ID)
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.widgetkit-extension
+ settings:
+ base:
+ PRODUCT_NAME: TokenMenuBarWidget
+ PRODUCT_BUNDLE_IDENTIFIER: dev.tox.token-menu-bar.widget
+ CODE_SIGN_ENTITLEMENTS: Widget/Widget.entitlements
+ ENABLE_APP_SANDBOX: YES
+ SKIP_INSTALL: YES
+ configs:
+ Debug:
+ CODE_SIGN_ENTITLEMENTS: ""
+ CODE_SIGN_IDENTITY: "-"
+ CODE_SIGN_STYLE: Manual
+ PRODUCT_BUNDLE_IDENTIFIER: dev.tox.token-menu-bar.verification.widget
+ Release:
+ CODE_SIGN_IDENTITY: "Developer ID Application"
+ CODE_SIGN_STYLE: Manual
+ Homebrew:
+ CODE_SIGN_IDENTITY: "Developer ID Application"
+ CODE_SIGN_STYLE: Manual
+ AppStore:
+ CODE_SIGN_IDENTITY: "Apple Distribution"
+ CODE_SIGN_STYLE: Manual
+ PROVISIONING_PROFILE_SPECIFIER: $(WIDGET_PROFILE)
+ TokenMenuBarApplicationUITests:
+ type: bundle.ui-testing
+ platform: macOS
+ sources:
+ - path: UITests
+ dependencies:
+ - target: TokenMenuBarDirect
+ - package: TokenMenuBarPackage
+ product: TokenMenuBarCore
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: dev.tox.token-menu-bar.application-ui-tests
+ GENERATE_INFOPLIST_FILE: YES
+ TEST_TARGET_NAME: TokenMenuBarDirect
+schemes:
+ TokenMenuBar-Direct:
+ build:
+ targets:
+ TokenMenuBarDirect: all
+ run:
+ config: Debug
+ test:
+ config: Debug
+ testPlans:
+ - path: TokenMenuBar.xctestplan
+ defaultPlan: true
+ archive:
+ config: Release
+ TokenMenuBar-AppStore:
+ build:
+ targets:
+ TokenMenuBarAppStore: all
+ run:
+ config: AppStore
+ archive:
+ config: AppStore
+ TokenMenuBar-Homebrew:
+ build:
+ targets:
+ TokenMenuBarHomebrew: all
+ run:
+ config: Homebrew
+ archive:
+ config: Homebrew
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..2e4126d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,16 @@
+# Contributing
+
+The architecture, the house style, the release pipeline and every workflow live at
+.
+
+The short version, from a fresh checkout:
+
+```sh
+mise install # hugo, just, pre-commit, xcodegen
+just # the list of workflows
+just check # build, tests with the coverage gate, every lint hook
+```
+
+`just check` covers what CI runs as `just lint` and `just coverage`, so a green one on your Mac means a green pull
+request. To report a bug, use Settings > About > **Report Issue**, which fills the issue in for you. For a
+vulnerability, read [SECURITY.md](SECURITY.md) first.
diff --git a/Casks/token-menu-bar.rb b/Casks/token-menu-bar.rb
new file mode 100644
index 0000000..ffc528c
--- /dev/null
+++ b/Casks/token-menu-bar.rb
@@ -0,0 +1,26 @@
+cask "token-menu-bar" do
+ version "0.1.0"
+ sha256 "0000000000000000000000000000000000000000000000000000000000000000"
+
+ url "https://github.com/tox-dev/token-menu-bar-macos/releases/download/v#{version}/TokenMenuBar-Homebrew.dmg"
+ name "Token Menu Bar"
+ desc "Menu bar monitor for Claude Code and Codex plan usage limits"
+ homepage "https://github.com/tox-dev/token-menu-bar-macos"
+
+ livecheck do
+ url :url
+ strategy :github_latest
+ end
+
+ depends_on macos: :sonoma
+
+ app "Token Menu Bar.app"
+
+ zap trash: [
+ "~/Library/Application Support/Token Menu Bar",
+ "~/Library/Caches/dev.tox.token-menu-bar",
+ "~/Library/HTTPStorages/dev.tox.token-menu-bar",
+ "~/Library/Preferences/dev.tox.token-menu-bar.plist",
+ "~/Library/Saved Application State/dev.tox.token-menu-bar.savedState",
+ ]
+end
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..466f021
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Bernát Gábor
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..584ad5d
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,38 @@
+// swift-tools-version: 6.0
+import PackageDescription
+
+let strict: [SwiftSetting] = [
+ .swiftLanguageMode(.v6),
+ .enableUpcomingFeature("ExistentialAny"),
+ .unsafeFlags(["-warnings-as-errors"]),
+]
+
+let package = Package(
+ name: "TokenMenuBar",
+ platforms: [.macOS(.v14)],
+ products: [
+ .library(name: "TokenMenuBarCore", targets: ["TokenMenuBarCore"]),
+ .library(name: "TokenMenuBarUI", targets: ["TokenMenuBarUI"]),
+ .library(name: "TokenMenuBarWidgets", targets: ["TokenMenuBarWidgets"]),
+ .executable(name: "TokenMenuBar", targets: ["TokenMenuBar"]),
+ ],
+ targets: [
+ .target(name: "TokenMenuBarCore", swiftSettings: strict),
+ .target(
+ name: "TokenMenuBarUI",
+ dependencies: ["TokenMenuBarCore"],
+ resources: [.process("Resources")],
+ swiftSettings: strict
+ ),
+ .target(name: "TokenMenuBarWidgets", dependencies: ["TokenMenuBarCore"], swiftSettings: strict),
+ .executableTarget(name: "TokenMenuBar", dependencies: ["TokenMenuBarUI"], swiftSettings: strict),
+ .testTarget(
+ name: "TokenMenuBarCoreTests",
+ dependencies: ["TokenMenuBarCore"],
+ resources: [.copy("Fixtures")],
+ swiftSettings: strict
+ ),
+ .testTarget(name: "TokenMenuBarUITests", dependencies: ["TokenMenuBarUI"], swiftSettings: strict),
+ .testTarget(name: "TokenMenuBarWidgetsTests", dependencies: ["TokenMenuBarWidgets"], swiftSettings: strict),
+ ]
+)
diff --git a/README.md b/README.md
index 65dcedd..adb45ab 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,46 @@
-# token-menu-bar-macos
\ No newline at end of file
+# Token Menu Bar
+
+A macOS menu bar app that shows how much of your Claude (Pro/Max), OpenAI Codex (Plus/Pro), Gemini CLI, Cursor and
+GitHub Copilot plan limits you have used, in the detail the vendor usage pages show: session, weekly and monthly windows
+per model, usage credits and spend limits, reset countdowns, pace projections, notifications, 60 days of local history,
+desktop widgets, and the Codex and Claude analytics charts.
+
+It reads the tokens the `claude`, `codex`, `gemini`, Cursor and Copilot clients keep on your Mac, so you sign in to the
+clients rather than to this app, and it calls only the vendors' own endpoints.
+
+**Documentation: **
+
+| Provider | Reads | Windows |
+| ---------------------------------------------------------- | -------------------------- | --------------- |
+|
Claude | Keychain, `~/.claude` | session, weekly |
+|
Codex | `~/.codex` | 5-hour, weekly |
+|
Gemini | `~/.gemini` | daily per model |
+|
Cursor | Cursor app, `~/.cursor` | plan, spend |
+|
Copilot | `~/.config/github-copilot` | premium |
+
+Requires macOS 14 or later on Apple Silicon, and one signed-in client.
+
+- [Get started](https://token-menu-bar-macos.readthedocs.io/en/latest/start/): install it and read your first numbers
+- [Interface reference](https://token-menu-bar-macos.readthedocs.io/en/latest/reference/interface/): the menu bar, the
+ three tabs and the widgets
+- [Settings reference](https://token-menu-bar-macos.readthedocs.io/en/latest/reference/settings/): what each option does
+- [Privacy and rate limits](https://token-menu-bar-macos.readthedocs.io/en/latest/explanation/): what it reads, where it
+ sends it, why the poll interval stays long
+- [Troubleshooting](https://token-menu-bar-macos.readthedocs.io/en/latest/troubleshooting/): the log to capture, and
+ what each symptom means
+- [Contributing](https://token-menu-bar-macos.readthedocs.io/en/latest/contributing/): the architecture, the house
+ style, and every workflow
+
+## Working on it
+
+[mise](https://mise.jdx.dev) pins the tools and [just](https://just.systems) runs the workflows.
+
+```sh
+mise install # hugo, just, pre-commit, xcodegen
+just # the list of workflows
+just check # build, tests with the coverage gate, every lint hook
+just run # ad-hoc .app for machines without Xcode, launched
+just install # the same build, into /Applications
+```
+
+MIT licensed, by [Bernát Gábor](https://bernat.tech).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..a7a83a3
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,65 @@
+# Security policy
+
+## Reporting a vulnerability
+
+Report it privately through
+[GitHub security advisories](https://github.com/tox-dev/token-menu-bar-macos/security/advisories/new). Please do not
+open a public issue, pull request or discussion for anything that could expose someone's credentials.
+
+Say what you did and what happened, and give the version from Settings > About. A proof of concept helps. A diagnostics
+report (Settings > About > **Copy Diagnostics**) names your plan and how much of it you have used, so send one only when
+it bears on the report, and read it first.
+
+This is a one-person project with no bounty. You get an answer, a fix where the problem holds up, and credit in the
+advisory unless you would rather not have it.
+
+## In scope
+
+- The app, wherever it could leak a credential, read a file it has no business reading, write one it should not, or
+ reach a host other than the ones below.
+- How it finds credentials, which is the list further down. Path traversal, a symlink trick, or a way to make it read
+ another account's tokens all belong here.
+- The release pipeline: signing, notarization, the Sparkle appcast, the Homebrew cask, and the GitHub Actions workflows
+ with their secrets.
+
+## Out of scope
+
+- The vendors' own APIs, CLIs and accounts. A bug in `claude`, `codex`, `gemini`, Cursor or Copilot goes to that vendor.
+- The quota numbers themselves. The app reports what the endpoint returns, so a wrong number is a bug rather than a
+ vulnerability.
+- Anything that needs an attacker who already has your unlocked Mac and your login keychain.
+
+## What the app touches
+
+It reads the credentials the clients already stored. It never asks you for one, and never sends one anywhere but the
+vendor it came from.
+
+| Provider | Reads | Writes |
+| -------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| Claude | Keychain item `Claude Code-credentials`, or `~/.claude/.credentials.json`; `~/.claude.json`; `~/.claude/projects/**/*.jsonl` | Whichever of those two the token came from, only with token refresh on |
+| Codex | `~/.codex/auth.json` (`CODEX_HOME` honoured); `~/.codex/sessions/**/rollout-*.jsonl` | `~/.codex/auth.json`, only with token refresh on |
+| Gemini | `~/.gemini/oauth_creds.json` (`GEMINI_CLI_HOME` honoured) | `~/.gemini/oauth_creds.json`, only with token refresh on |
+| Cursor | Cursor's `state.vscdb`, opened read-only and immutable; or `~/.cursor/auth.json` | Nothing |
+| Copilot | `~/.config/github-copilot/hosts.json` and `apps.json` (`XDG_CONFIG_HOME` honoured) | Nothing |
+
+**Refresh expired tokens on my behalf** (Settings > Providers) is off by default. With it off the app never writes a
+credential file or Keychain item; it shows a sign-in hint instead. With it on, an expired Claude, Codex or Gemini token
+goes to that vendor's token endpoint, and the app writes the rotated token back where the CLI keeps it.
+
+The sandboxed App Store build cannot reach those paths on its own and asks you to grant each one, keeping a
+security-scoped bookmark for it.
+
+## What the app stores and sends
+
+Everything it keeps lives under `~/Library/Application Support/Token Menu Bar/`: the SQLite history, a snapshot cache of
+the last values (which includes the plan name, and the account email when the vendor returns one), and `log.txt`. The
+log holds request URLs stripped of their query strings and with UUIDs masked, the status, size and duration of each
+response, and the first 200 bytes of an error body. It holds no request header and no token.
+
+The widget snapshot in the `group.dev.tox.token-menu-bar` app group holds window labels, percentages and reset times,
+with no token and no email in it.
+
+Outbound traffic goes to the five vendor hosts documented in
+[Privacy and rate limits](https://token-menu-bar-macos.readthedocs.io/en/latest/explanation/), plus GitHub releases for
+the Sparkle update feed in the direct build. The app runs no telemetry, reports no crashes, and has no server of its
+own.
diff --git a/Scripts/appcast.sh b/Scripts/appcast.sh
new file mode 100755
index 0000000..62d9c2f
--- /dev/null
+++ b/Scripts/appcast.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Generates dist/direct/appcast.xml for Sparkle from the release zip. Without SPARKLE_PRIVATE_ED_KEY the appcast is
+# written unsigned so the release still ships; Sparkle refuses unsigned feeds, so set the key before shipping updates.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+version="${1:?usage: appcast.sh }"
+out="dist/direct"
+download_prefix="https://github.com/tox-dev/token-menu-bar-macos/releases/download/v${version}/"
+
+sparkle_bin="$(
+ find ~/Library/Developer/Xcode/DerivedData "$PWD/App" -path '*artifacts/sparkle/Sparkle/bin' -type d 2> /dev/null |
+ head -1 || true
+)"
+if [[ -n "$sparkle_bin" && -n "${SPARKLE_PRIVATE_ED_KEY:-}" ]]; then
+ echo "$SPARKLE_PRIVATE_ED_KEY" |
+ "$sparkle_bin/generate_appcast" --ed-key-file - --download-url-prefix "$download_prefix" "$out"
+ exit 0
+fi
+
+length="$(stat -f %z "$out/TokenMenuBar.zip")"
+build="$(sed -n 's/.*CURRENT_PROJECT_VERSION: "\(.*\)"/\1/p' App/project.yml | head -1)"
+cat > "$out/appcast.xml" << XML
+
+
+
+ Token Menu Bar
+ -
+ ${version}
+ $(date -u +"%a, %d %b %Y %H:%M:%S +0000")
+ ${build}
+ ${version}
+ 14.0
+
+
+
+
+XML
+echo "wrote unsigned appcast (set SPARKLE_PRIVATE_ED_KEY to sign)"
diff --git a/Scripts/build-app-store.sh b/Scripts/build-app-store.sh
new file mode 100755
index 0000000..43c5e4a
--- /dev/null
+++ b/Scripts/build-app-store.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+: "${TEAM_ID:?}"
+: "${APP_STORE_CONNECT_KEY_ID:?}"
+: "${APP_STORE_CONNECT_ISSUER_ID:?}"
+: "${APP_STORE_CONNECT_KEY_BASE64:?}"
+# The profile names have to match the ones installed on the runner and the ones the project archives with.
+APP_PROFILE="${APP_PROFILE:-Token Menu Bar App Store}"
+WIDGET_PROFILE="${WIDGET_PROFILE:-Token Menu Bar Widget App Store}"
+out="dist/app-store"
+archive="$out/TokenMenuBar.xcarchive"
+key="$RUNNER_TEMP/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8"
+echo "$APP_STORE_CONNECT_KEY_BASE64" | base64 --decode > "$key"
+rm -rf "$out"
+mkdir -p "$out"
+
+xcodebuild -project App/TokenMenuBar.xcodeproj -scheme TokenMenuBar-AppStore -configuration AppStore \
+ -destination 'platform=macOS' -archivePath "$archive" DEVELOPMENT_TEAM="$TEAM_ID" archive | tail -20
+
+app="$archive/Products/Applications/Token Menu Bar.app"
+Scripts/verify-deployment-targets.sh "$app" 14.0
+Scripts/verify-app-bundle.sh "$app" "App Store" forbidden
+
+cat > "$out/export.plist" << PLIST
+
+
+
+
+ methodapp-store-connect
+ destinationupload
+ teamID${TEAM_ID}
+ signingStylemanual
+ signingCertificateApple Distribution
+ installerSigningCertificateMac Installer Distribution
+ provisioningProfiles
+
+ dev.tox.token-menu-bar${APP_PROFILE}
+ dev.tox.token-menu-bar.widget${WIDGET_PROFILE}
+
+
+
+PLIST
+
+xcodebuild -exportArchive -archivePath "$archive" -exportOptionsPlist "$out/export.plist" -exportPath "$out" \
+ -allowProvisioningUpdates -authenticationKeyPath "$key" -authenticationKeyID "$APP_STORE_CONNECT_KEY_ID" \
+ -authenticationKeyIssuerID "$APP_STORE_CONNECT_ISSUER_ID" | tail -30
+rm -f "$key"
diff --git a/Scripts/build-direct.sh b/Scripts/build-direct.sh
new file mode 100755
index 0000000..245756d
--- /dev/null
+++ b/Scripts/build-direct.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+signed="${SIGNED:-false}"
+scheme="${SCHEME:-TokenMenuBar-Direct}"
+configuration="${CONFIGURATION:-Release}"
+out="${OUT_DIR:-dist/direct}"
+expected_distribution="${EXPECTED_DISTRIBUTION:-Direct}"
+expected_updater="${EXPECTED_UPDATER:-required}"
+archive="$out/TokenMenuBar.xcarchive"
+verification_key="${SPARKLE_PUBLIC_ED_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}"
+rm -rf "$out"
+mkdir -p "$out"
+
+if [[ "$signed" == "true" ]]; then
+ xcodebuild -project App/TokenMenuBar.xcodeproj -scheme "$scheme" -configuration "$configuration" \
+ -destination 'platform=macOS' -archivePath "$archive" \
+ DEVELOPMENT_TEAM="${TEAM_ID:?}" SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:?}" \
+ APP_GROUP_ID="${TEAM_ID}.dev.tox.token-menu-bar" SELF_UPDATE_ENABLED=YES archive | tail -20
+ cat > "$out/export.plist" << PLIST
+
+
+
+
+ methoddeveloper-id
+ teamID${TEAM_ID}
+ signingStylemanual
+ signingCertificateDeveloper ID Application
+
+
+PLIST
+ xcodebuild -exportArchive -archivePath "$archive" -exportOptionsPlist "$out/export.plist" \
+ -exportPath "$out" | tail -20
+else
+ # The app group entitlement needs a provisioning profile, which an unsigned build has none of, so signing is off
+ # for the archive and the bundle is ad-hoc signed below instead.
+ xcodebuild -project App/TokenMenuBar.xcodeproj -scheme "$scheme" -configuration "$configuration" \
+ -destination 'platform=macOS' -archivePath "$archive" \
+ CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" CODE_SIGN_STYLE=Manual \
+ DEVELOPMENT_TEAM="" SPARKLE_PUBLIC_ED_KEY="$verification_key" archive | tail -20
+ cp -R "$archive/Products/Applications/Token Menu Bar.app" "$out/"
+ codesign --force --deep --sign - "$out/Token Menu Bar.app"
+fi
+
+Scripts/verify-deployment-targets.sh "$out/Token Menu Bar.app" 14.0
+Scripts/verify-app-bundle.sh "$out/Token Menu Bar.app" "$expected_distribution" "$expected_updater"
+codesign --verify --deep --strict --verbose=2 "$out/Token Menu Bar.app"
+echo "exported $out/Token Menu Bar.app (scheme=$scheme, signed=$signed)"
diff --git a/Scripts/build-homebrew.sh b/Scripts/build-homebrew.sh
new file mode 100755
index 0000000..7ab6265
--- /dev/null
+++ b/Scripts/build-homebrew.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+SCHEME=TokenMenuBar-Homebrew CONFIGURATION=Homebrew OUT_DIR=dist/homebrew \
+ EXPECTED_DISTRIBUTION=Homebrew EXPECTED_UPDATER=forbidden \
+ Scripts/build-direct.sh
diff --git a/Scripts/bundle-dev.sh b/Scripts/bundle-dev.sh
new file mode 100755
index 0000000..a9f8bc6
--- /dev/null
+++ b/Scripts/bundle-dev.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# Builds the SwiftPM executable and wraps it in a minimal, ad-hoc signed .app for local development on machines
+# without Xcode. Pass --run for real provider data or --run-demo for an isolated verification launch.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+configuration="${CONFIGURATION:-debug}"
+out="${OUT_DIR:-dist}"
+app="$out/Token Menu Bar.app"
+version="$(Scripts/version.sh --marketing)"
+source_version="$(Scripts/version.sh --full)"
+build="$(Scripts/version.sh --build)"
+
+swift build -c "$configuration" --product TokenMenuBar
+bin_dir="$(swift build -c "$configuration" --show-bin-path)"
+binary="$bin_dir/TokenMenuBar"
+
+rm -rf "$app"
+mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
+cp "$binary" "$app/Contents/MacOS/TokenMenuBar"
+resource_bundle="$bin_dir/TokenMenuBar_TokenMenuBarUI.bundle"
+if [[ ! -d "$resource_bundle" ]]; then
+ echo "missing SwiftPM resource bundle: $resource_bundle" >&2
+ exit 1
+fi
+cp -R "$resource_bundle" "$app/Contents/Resources/"
+cat > "$app/Contents/Info.plist" << PLIST
+
+
+
+
+ CFBundleDevelopmentRegionen
+ CFBundleExecutableTokenMenuBar
+ CFBundleIdentifierdev.tox.token-menu-bar
+ CFBundleInfoDictionaryVersion6.0
+ CFBundleIconFileAppIcon
+ CFBundleNameToken Menu Bar
+ CFBundleDisplayNameToken Menu Bar
+ CFBundlePackageTypeAPPL
+ CFBundleShortVersionString${version}
+ CFBundleVersion${build}
+ TMBSourceVersion${source_version}
+ LSMinimumSystemVersion14.0
+ LSUIElement
+ NSHighResolutionCapable
+ NSSupportsAutomaticTermination
+
+
+PLIST
+iconset="$(mktemp -d)/TokenMenuBar.iconset"
+"$binary" --export-icon "$iconset" > /dev/null
+iconutil --convert icns --output "$app/Contents/Resources/AppIcon.icns" "$iconset"
+rm -rf "$iconset"
+
+codesign --force --sign - "$app"
+echo "built $app"
+
+case "${1:-}" in
+ --run)
+ pkill -x TokenMenuBar || true
+ open "$app"
+ ;;
+ --run-demo)
+ pkill -x TokenMenuBar || true
+ open "$app" --args --verify-ui
+ ;;
+ "") ;;
+ *)
+ echo "usage: $0 [--run|--run-demo]" >&2
+ exit 2
+ ;;
+esac
diff --git a/Scripts/check-test-isolation.sh b/Scripts/check-test-isolation.sh
new file mode 100755
index 0000000..0e8b7a1
--- /dev/null
+++ b/Scripts/check-test-isolation.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+forbidden='SecItem(Add|CopyMatching|Delete|Update)|URLSession(\.shared|[[:space:]]*\()|SystemHTTPTransport\.make|KeychainCredentialClient\.system|keychain:[[:space:]]*\.system|LaunchAtLoginService\.backend|UNUserNotificationCenter\.current'
+if matches="$(rg -n "$forbidden" Tests App/UITests --glob '*.swift')"; then
+ echo "tests and benchmarks must use isolated credential, HTTP, notification, and login-item clients" >&2
+ echo "$matches" >&2
+ exit 1
+fi
diff --git a/Scripts/check-toolchain.sh b/Scripts/check-toolchain.sh
new file mode 100755
index 0000000..07cbc92
--- /dev/null
+++ b/Scripts/check-toolchain.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Diagnoses a toolchain that cannot build this app, and says how to fix it. Changes nothing and never asks for root:
+# `just build` on the wrong toolchain otherwise fails with a raw compiler error that names no cause.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+required="$(grep -oE '\.macOS\(\.v[0-9]+\)' Package.swift | grep -oE '[0-9]+' | head -1)"
+problems=0
+
+note() {
+ echo "$1" >&2
+ problems=$((problems + 1))
+}
+
+developer_dir="$(xcode-select -p 2> /dev/null || echo "")"
+case "$developer_dir" in
+ *CommandLineTools*)
+ note "xcode-select points at $developer_dir, which cannot build an app bundle.
+ Fix: sudo xcode-select --switch /Applications/Xcode.app"
+ ;;
+ "")
+ note "No developer directory is selected.
+ Fix: install Xcode, then sudo xcode-select --switch /Applications/Xcode.app"
+ ;;
+esac
+
+host="$(sw_vers -productVersion)"
+if [ "${host%%.*}" -lt "$required" ]; then
+ note "This Mac runs macOS $host and the app targets macOS $required or newer, so it cannot run what it builds."
+fi
+
+if [ -x "$developer_dir/usr/bin/xcodebuild" ] || command -v xcodebuild > /dev/null 2>&1; then
+ # `xcodebuild -version | head -1` closes the pipe and kills xcodebuild, so read it whole and cut afterwards.
+ if versions="$(xcodebuild -version 2> /dev/null)"; then
+ xcode="${versions%%$'\n'*}"
+ xcode="${xcode#Xcode }"
+ if [ "${xcode%%.*}" -lt "$required" ]; then
+ note "Xcode ${xcode%%.*} is too old for the macOS $required SDK. Install a newer Xcode."
+ fi
+ fi
+fi
+
+if swift_version="$(swift --version 2>&1)"; then
+ sdk="$(echo "$swift_version" | grep -oE 'macosx[0-9]+' | grep -oE '[0-9]+' | head -1)"
+ if [ -n "$sdk" ] && [ "$sdk" -lt "$required" ]; then
+ note "The active Swift toolchain targets macosx$sdk, older than the macOS $required this app needs.
+ Fix: point swiftly or xcode-select at a newer toolchain."
+ fi
+fi
+
+if [ "$problems" -gt 0 ]; then
+ echo "" >&2
+ echo "$problems toolchain problem(s); see above." >&2
+ exit 1
+fi
+echo "toolchain ok: $developer_dir, macOS $host host, targeting macOS $required"
diff --git a/Scripts/coverage.sh b/Scripts/coverage.sh
new file mode 100755
index 0000000..7e33dd6
--- /dev/null
+++ b/Scripts/coverage.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+Scripts/check-test-isolation.sh
+
+# A profile left by an earlier filtered run merges into this one and hides lines the full suite covers.
+rm -rf "$(swift build --show-bin-path)/codecov"
+# Serially: running the suite in parallel drops counters, and the gate then reports a line as unexecuted that a
+# test plainly runs. It cost several false failures before the cause was pinned down. `just test` stays parallel.
+swift test --enable-code-coverage --no-parallel "$@"
+
+bin_dir="$(swift build --show-bin-path)"
+# swift-testing and XCTest each write their own raw profile, and taking whatever SwiftPM happened to merge has
+# reported a line as unexecuted when only one of them was in it. Merge every raw profile that exists.
+profdata="$bin_dir/codecov/merged.profdata"
+raw=("$bin_dir"/codecov/*.profraw)
+if [ ! -e "${raw[0]}" ]; then
+ echo "no coverage profiles under $bin_dir/codecov" >&2
+ exit 1
+fi
+xcrun llvm-profdata merge -sparse "${raw[@]}" -o "$profdata"
+bundle="$(find "$bin_dir" -name '*.xctest' -type d | head -1)"
+binary="$bundle/Contents/MacOS/$(basename "$bundle" .xctest)"
+
+# These files need a running host, a version-bound framework, or xcodebuild. The gate caps their size so logic cannot
+# accumulate where the package tests cannot reach it. This array also supplies the SwiftPM coverage exclusions below.
+glue=(
+ Sources/TokenMenuBar/main.swift Sources/TokenMenuBarUI/WorkspaceGlue.swift
+ Sources/TokenMenuBarUI/Adapters/LaunchAtLoginService.swift
+ Sources/TokenMenuBarUI/Adapters/PanelMaterialAdapter.swift
+ Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift
+ Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift
+ Sources/TokenMenuBarWidgets/WidgetKitGlue.swift App/Sources/SparkleUpdater.swift
+ App/Widget/Sources/WidgetBundle.swift
+)
+budget=40
+ignore='(\.build|Tests'
+for file in "${glue[@]}"; do
+ lines="$(grep -cE '^[[:space:]]*[a-zA-Z@#}]' "$file")"
+ if ((lines > budget)); then
+ echo "$file has $lines lines of code; unmeasured glue must stay under $budget. Move the logic into Core or UI."
+ exit 1
+ fi
+ echo "unmeasured glue: $file ($lines lines, capped at $budget)"
+ if [[ "$file" == Sources/* ]]; then
+ ignore+="|${file//./\.}"
+ fi
+done
+ignore+=')'
+
+report="$(
+ xcrun llvm-cov report "$binary" -instr-profile "$profdata" -ignore-filename-regex="$ignore" -use-color=false
+)"
+echo "$report"
+summary="$(
+ xcrun llvm-cov export "$binary" -instr-profile "$profdata" -ignore-filename-regex="$ignore" -summary-only
+)"
+missed="$(jq '[.data[].files[].summary.lines | .count - .covered] | add // 0' <<< "$summary")"
+
+if ((missed > 0)); then
+ echo "lines never executed: $missed"
+ jq -r '
+ .data[].files[]
+ | select(.summary.lines.covered < .summary.lines.count)
+ | "\(.filename): \(.summary.lines.count - .summary.lines.covered)"
+ ' <<< "$summary"
+ exit 1
+fi
+echo "coverage gate passed: every line in Core and UI executed"
diff --git a/Scripts/import-certificate.sh b/Scripts/import-certificate.sh
new file mode 100755
index 0000000..6b37487
--- /dev/null
+++ b/Scripts/import-certificate.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+: "${CERTIFICATE_BASE64:?}"
+: "${CERTIFICATE_PASSWORD:?}"
+keychain="$RUNNER_TEMP/signing.keychain-db"
+keychain_password="$(openssl rand -hex 16)"
+certificate="$RUNNER_TEMP/certificate.p12"
+installer_certificate="$RUNNER_TEMP/installer-certificate.p12"
+
+echo "$CERTIFICATE_BASE64" | base64 --decode > "$certificate"
+security create-keychain -p "$keychain_password" "$keychain"
+security set-keychain-settings -lut 21600 "$keychain"
+security unlock-keychain -p "$keychain_password" "$keychain"
+security import "$certificate" -P "$CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain"
+if [ -n "${INSTALLER_CERTIFICATE_BASE64:-}" ]; then
+ : "${INSTALLER_CERTIFICATE_PASSWORD:?}"
+ echo "$INSTALLER_CERTIFICATE_BASE64" | base64 --decode > "$installer_certificate"
+ security import "$installer_certificate" -P "$INSTALLER_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain"
+fi
+security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain"
+security list-keychains -d user -s "$keychain" login.keychain-db
+rm -f "$certificate" "$installer_certificate"
+security find-identity -v -p codesigning "$keychain"
diff --git a/Scripts/notarize.sh b/Scripts/notarize.sh
new file mode 100755
index 0000000..c085558
--- /dev/null
+++ b/Scripts/notarize.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+directory="${1:?usage: notarize.sh }"
+: "${APP_STORE_CONNECT_KEY_ID:?}"
+: "${APP_STORE_CONNECT_ISSUER_ID:?}"
+: "${APP_STORE_CONNECT_KEY_BASE64:?}"
+key="$RUNNER_TEMP/AuthKey.p8"
+echo "$APP_STORE_CONNECT_KEY_BASE64" | base64 --decode > "$key"
+
+notarize() {
+ xcrun notarytool submit "$1" --key "$key" --key-id "$APP_STORE_CONNECT_KEY_ID" \
+ --issuer "$APP_STORE_CONNECT_ISSUER_ID" --wait
+}
+
+for app in "$directory"/*.app; do
+ [[ -d "$app" ]] || continue
+ archive="$RUNNER_TEMP/$(basename "$app" .app).zip"
+ ditto -c -k --keepParent "$app" "$archive"
+ notarize "$archive"
+ xcrun stapler staple "$app"
+ spctl --assess --type exec --verbose=2 "$app"
+done
+
+for dmg in "$directory"/*.dmg; do
+ [[ -f "$dmg" ]] || continue
+ notarize "$dmg"
+ xcrun stapler staple "$dmg"
+done
+rm -f "$key"
diff --git a/Scripts/optimize-png.py b/Scripts/optimize-png.py
new file mode 100755
index 0000000..e346688
--- /dev/null
+++ b/Scripts/optimize-png.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""Re-encode PNG files with a filter the image actually suits.
+
+macOS wants every icon size in the asset catalog, and the encoder AppKit uses leaves roughly half the bytes on the
+table for a smooth gradient. This picks the filter that compresses best and re-deflates at maximum effort.
+"""
+
+from __future__ import annotations
+
+import pathlib
+import struct
+import sys
+import zlib
+from typing import Final
+
+_HEADER: Final = b"\x89PNG\r\n\x1a\n"
+_SUB: Final = 1
+_UP: Final = 2
+_PAETH: Final = 4
+_FILTERS: Final = (_SUB, _UP, _PAETH)
+_CHANNELS: Final = 4
+
+
+def _chunks(data: bytes) -> list[tuple[bytes, bytes]]:
+ out: list[tuple[bytes, bytes]] = []
+ index = len(_HEADER)
+ while index < len(data):
+ length = struct.unpack(">I", data[index : index + 4])[0]
+ out.append((data[index + 4 : index + 8], data[index + 8 : index + 8 + length]))
+ index += 12 + length
+ return out
+
+
+def _base(kind: int, left: int, up: int, upleft: int) -> int:
+ if kind == _SUB:
+ return left
+ if kind == _UP:
+ return up
+ estimate = left + up - upleft
+ distances = (abs(estimate - left), abs(estimate - up), abs(estimate - upleft))
+ return (left, up, upleft)[distances.index(min(distances))]
+
+
+def _neighbours(line: bytes, previous: bytes, index: int) -> tuple[int, int, int]:
+ behind = index >= _CHANNELS
+ return (
+ line[index - _CHANNELS] if behind else 0,
+ previous[index],
+ previous[index - _CHANNELS] if behind else 0,
+ )
+
+
+def _decode(raw: bytes, stride: int, height: int) -> list[bytes]:
+ rows: list[bytes] = []
+ previous = bytes(stride)
+ offset = 0
+ for _ in range(height):
+ kind = raw[offset]
+ line = bytearray(raw[offset + 1 : offset + 1 + stride])
+ offset += 1 + stride
+ for index in range(stride * bool(kind)):
+ line[index] = (line[index] + _base(kind, *_neighbours(line, previous, index))) & 0xFF
+ rows.append(bytes(line))
+ previous = bytes(line)
+ return rows
+
+
+def _encode(rows: list[bytes], stride: int, kind: int) -> bytes:
+ packed = bytearray()
+ previous = bytes(stride)
+ for row in rows:
+ packed.append(kind)
+ for index in range(stride):
+ packed.append((row[index] - _base(kind, *_neighbours(row, previous, index))) & 0xFF)
+ previous = row
+ return zlib.compress(bytes(packed), 9)
+
+
+def _rebuild(header: bytes, data: bytes) -> bytes:
+ out = bytearray(_HEADER)
+ for kind, payload in ((b"IHDR", header), (b"IDAT", data), (b"IEND", b"")):
+ out += struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload))
+ return bytes(out)
+
+
+def repack(path: pathlib.Path) -> int:
+ """Rewrite one file when a different filter compresses it better.
+
+ Args:
+ path: The PNG to rewrite in place.
+
+ Returns:
+ The number of bytes saved, or zero when the file is already as small as this can make it.
+
+ """
+ data = path.read_bytes()
+ if data[: len(_HEADER)] != _HEADER:
+ return 0
+ parts = _chunks(data)
+ header = next(payload for kind, payload in parts if kind == b"IHDR")
+ width, height, depth, colour, _, _, interlace = struct.unpack(">IIBBBBB", header)
+ if (depth, colour, interlace) != (8, 6, 0):
+ return 0
+ stride = width * _CHANNELS
+ raw = zlib.decompress(b"".join(payload for kind, payload in parts if kind == b"IDAT"))
+ rows = _decode(raw, stride, height)
+ best = min((_encode(rows, stride, kind) for kind in _FILTERS), key=len)
+ out = _rebuild(header, best)
+ if len(out) >= len(data):
+ return 0
+ path.write_bytes(out)
+ return len(data) - len(out)
+
+
+def main(paths: list[str]) -> int:
+ """Repack every PNG under each path, reporting the total saved.
+
+ Args:
+ paths: Directories to walk.
+
+ Returns:
+ A process exit code.
+
+ """
+ saved = sum(repack(file) for root in paths for file in sorted(pathlib.Path(root).rglob("*.png")))
+ sys.stderr.write(f"saved {saved / 1e6:.2f} MB\n")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/Scripts/package-direct.sh b/Scripts/package-direct.sh
new file mode 100755
index 0000000..a49e2f3
--- /dev/null
+++ b/Scripts/package-direct.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+version="${1:?usage: package-direct.sh }"
+out="${OUT_DIR:-dist/direct}"
+archive_basename="${ARCHIVE_BASENAME:-TokenMenuBar}"
+expected_distribution="${EXPECTED_DISTRIBUTION:-Direct}"
+expected_updater="${EXPECTED_UPDATER:-required}"
+app="$out/Token Menu Bar.app"
+[[ -d "$app" ]] || {
+ echo "missing $app"
+ exit 1
+}
+Scripts/verify-app-bundle.sh "$app" "$expected_distribution" "$expected_updater"
+
+rm -f "$out/$archive_basename.zip" "$out/$archive_basename.dmg"
+ditto -c -k --keepParent --sequesterRsrc "$app" "$out/$archive_basename.zip"
+
+staging="$(mktemp -d)"
+extracted="$(mktemp -d)"
+mount_point="$(mktemp -d)"
+device=""
+cleanup() {
+ if [[ -n "$device" ]]; then hdiutil detach "$device" > /dev/null || true; fi
+ rm -rf "$staging" "$extracted" "$mount_point"
+}
+trap cleanup EXIT
+cp -R "$app" "$staging/"
+ln -s /Applications "$staging/Applications"
+# ULMO mounts on macOS 10.15 and later, below the app's macOS 14 deployment floor.
+hdiutil create -volname "Token Menu Bar $version" -srcfolder "$staging" -ov -format ULMO -fs HFS+ \
+ "$out/$archive_basename.dmg" > /dev/null
+
+if codesign -dv "$app" 2>&1 | grep -q "Developer ID Application"; then
+ codesign --force --sign "Developer ID Application" --timestamp "$out/$archive_basename.dmg"
+fi
+
+ditto -x -k "$out/$archive_basename.zip" "$extracted"
+Scripts/verify-app-bundle.sh "$extracted/Token Menu Bar.app" "$expected_distribution" "$expected_updater"
+attach_output="$(hdiutil attach -nobrowse -readonly -mountpoint "$mount_point" "$out/$archive_basename.dmg")"
+device="$(awk '$1 ~ /^\/dev\// { value=$1 } END { print value }' <<< "$attach_output")"
+[[ -n "$device" ]] || {
+ echo "could not identify the mounted DMG device" >&2
+ exit 1
+}
+Scripts/verify-app-bundle.sh "$mount_point/Token Menu Bar.app" "$expected_distribution" "$expected_updater"
+hdiutil detach "$device" > /dev/null
+device=""
+
+for file in "$out/$archive_basename.zip" "$out/$archive_basename.dmg"; do
+ shasum -a 256 "$file" | awk '{print $1}' > "$file.sha256"
+done
+cleanup
+trap - EXIT
+ls -la "$out"
diff --git a/Scripts/package-homebrew.sh b/Scripts/package-homebrew.sh
new file mode 100755
index 0000000..4d8ba3b
--- /dev/null
+++ b/Scripts/package-homebrew.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+OUT_DIR=dist/homebrew ARCHIVE_BASENAME=TokenMenuBar-Homebrew EXPECTED_DISTRIBUTION=Homebrew \
+ EXPECTED_UPDATER=forbidden Scripts/package-direct.sh "${1:?usage: package-homebrew.sh }"
diff --git a/Scripts/screenshots.sh b/Scripts/screenshots.sh
new file mode 100755
index 0000000..9807644
--- /dev/null
+++ b/Scripts/screenshots.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Refreshes the website screenshots. The app renders them itself on demo data, so a shot never depends on how
+# crowded this machine's menu bar is, what sits behind the popover, or which display the popover opened on.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+out="${OUT_DIR:-website/assets/images}"
+binary="${APP_BINARY:-dist/Token Menu Bar.app/Contents/MacOS/TokenMenuBar}"
+
+# Always rebuild unless a binary was named explicitly: reusing whatever is in dist/ meant every screenshot after a
+# UI change silently showed the previous build.
+if [[ -z "${APP_BINARY:-}" ]]; then
+ echo "building the app bundle first" >&2
+ CONFIGURATION=release Scripts/bundle-dev.sh > /dev/null
+fi
+if [[ ! -x "$binary" ]]; then
+ echo "no app binary at $binary" >&2
+ exit 1
+fi
+
+if ! command -v cwebp > /dev/null; then
+ echo "cwebp is missing: brew install webp" >&2
+ exit 1
+fi
+
+mkdir -p "$out"
+"$binary" --export-menubar "$out"
+"$binary" --export-popover "$out"
+
+# The app draws PNG, the site serves WebP, and the repository keeps only what the site serves.
+for shot in "$out"/*.png; do
+ cwebp -quiet -q 92 -alpha_q 100 "$shot" -o "${shot%.png}.webp"
+ rm "$shot"
+done
+
+echo "screenshots written to $out"
+ls -la "$out"
diff --git a/Scripts/select-xcode.sh b/Scripts/select-xcode.sh
new file mode 100755
index 0000000..68581f9
--- /dev/null
+++ b/Scripts/select-xcode.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+newest=""
+newest_major=0
+newest_minor=0
+newest_patch=0
+exact="${EXACT_XCODE_MAJOR:-}"
+for app in /Applications/Xcode*.app; do
+ [ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] || continue
+ version="$("$app/Contents/Developer/usr/bin/xcodebuild" -version)"
+ number="${version%%$'\n'*}"
+ number="${number#Xcode }"
+ IFS=. read -r candidate_major candidate_minor candidate_patch <<< "$number"
+ candidate_minor="${candidate_minor:-0}"
+ candidate_patch="${candidate_patch:-0}"
+ if [ -n "$exact" ] && [ "$candidate_major" -ne "$exact" ]; then
+ continue
+ fi
+ if [ "$candidate_major" -gt "$newest_major" ] ||
+ { [ "$candidate_major" -eq "$newest_major" ] && [ "$candidate_minor" -gt "$newest_minor" ]; } ||
+ { [ "$candidate_major" -eq "$newest_major" ] && [ "$candidate_minor" -eq "$newest_minor" ] &&
+ [ "$candidate_patch" -gt "$newest_patch" ]; }; then
+ newest="$app"
+ newest_major="$candidate_major"
+ newest_minor="$candidate_minor"
+ newest_patch="$candidate_patch"
+ fi
+done
+if [ -z "$newest" ]; then
+ echo "No matching Xcode in /Applications${exact:+ for major $exact}" >&2
+ exit 1
+fi
+# Switching needs root, which a laptop has no terminal for here, so only do it when it would change something.
+if [ "$(xcode-select -p 2> /dev/null)" != "$newest/Contents/Developer" ]; then
+ sudo xcode-select -s "$newest/Contents/Developer"
+fi
+
+# `xcodebuild -version | head -1` kills xcodebuild with a broken pipe, so read it whole and cut afterwards.
+versions="$(xcodebuild -version)"
+major="${versions%%$'\n'*}"
+major="${major#Xcode }"
+major="${major%%.*}"
+minimum="${MIN_XCODE_MAJOR:-26}"
+if [ "$major" -lt "$minimum" ]; then
+ echo "Xcode ${major} is older than the required ${minimum}" >&2
+ exit 1
+fi
+if [ -n "$exact" ] && [ "$major" -ne "$exact" ]; then
+ echo "Xcode ${major} does not match the required major ${exact}" >&2
+ exit 1
+fi
+echo "$versions"
+swift --version
diff --git a/Scripts/stamp-version.sh b/Scripts/stamp-version.sh
new file mode 100755
index 0000000..ffc0022
--- /dev/null
+++ b/Scripts/stamp-version.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Writes the version into App/project.yml before xcodegen reads it. With no argument it derives one from git, so a
+# build off any commit is traceable; the release passes the tag explicitly.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+version="${1:-$(Scripts/version.sh --marketing)}"
+source_version="${1:-$(Scripts/version.sh --full)}"
+build="$(Scripts/version.sh --build)"
+sed -i '' "s/MARKETING_VERSION: \".*\"/MARKETING_VERSION: \"$version\"/" App/project.yml
+sed -i '' "s/CURRENT_PROJECT_VERSION: \".*\"/CURRENT_PROJECT_VERSION: \"$build\"/" App/project.yml
+sed -i '' "s/SOURCE_VERSION: \".*\"/SOURCE_VERSION: \"$source_version\"/" App/project.yml
+echo "stamped $source_version ($build)"
diff --git a/Scripts/update-cask.sh b/Scripts/update-cask.sh
new file mode 100755
index 0000000..3b65b7a
--- /dev/null
+++ b/Scripts/update-cask.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+version="${1:?usage: update-cask.sh }"
+dmg="${2:?usage: update-cask.sh }"
+sha="$(shasum -a 256 "$dmg" | awk '{print $1}')"
+sed -i '' "s/^ version \".*\"/ version \"$version\"/" Casks/token-menu-bar.rb
+sed -i '' "s/^ sha256 \".*\"/ sha256 \"$sha\"/" Casks/token-menu-bar.rb
+cat Casks/token-menu-bar.rb
diff --git a/Scripts/verify-app-bundle.sh b/Scripts/verify-app-bundle.sh
new file mode 100755
index 0000000..674cbb0
--- /dev/null
+++ b/Scripts/verify-app-bundle.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+app="${1:?usage: verify-app-bundle.sh }"
+expected_distribution="${2:?usage: verify-app-bundle.sh }"
+expected_updater="${3:?usage: verify-app-bundle.sh }"
+[[ "$expected_updater" == "required" || "$expected_updater" == "forbidden" ]] || {
+ echo "updater policy must be required or forbidden" >&2
+ exit 1
+}
+
+plist="$app/Contents/Info.plist"
+executable_name="$(/usr/libexec/PlistBuddy -c 'Print:CFBundleExecutable' "$plist")"
+executable="$app/Contents/MacOS/$executable_name"
+actual_distribution="$(/usr/libexec/PlistBuddy -c 'Print:TMBDistribution' "$plist")"
+[[ "$actual_distribution" == "$expected_distribution" ]] || {
+ echo "expected $expected_distribution distribution, got $actual_distribution" >&2
+ exit 1
+}
+
+mach_o_count=0
+while IFS= read -r -d '' binary; do
+ file -b "$binary" | grep -q 'Mach-O' || continue
+ mach_o_count=$((mach_o_count + 1))
+ architectures="$(lipo -archs "$binary")"
+ [[ " $architectures " == *" arm64 "* && " $architectures " == *" x86_64 "* ]] || {
+ echo "$binary is not universal arm64/x86_64: $architectures" >&2
+ exit 1
+ }
+ xcrun dyld_info -validate_only "$binary"
+done < <(find "$app" -type f -print0)
+[[ "$mach_o_count" -gt 0 ]] || {
+ echo "no Mach-O files found in $app" >&2
+ exit 1
+}
+
+otool_dependencies="$(xcrun otool -L "$executable")"
+dyld_dependencies="$(xcrun dyld_info -linked_dylibs "$executable")"
+sparkle_framework="$app/Contents/Frameworks/Sparkle.framework"
+if [[ "$expected_updater" == "required" ]]; then
+ [[ -d "$sparkle_framework" ]] || {
+ echo "Direct build does not embed Sparkle.framework" >&2
+ exit 1
+ }
+ grep -q 'Sparkle.framework' <<< "$otool_dependencies" || {
+ echo "Direct executable has no Sparkle load command" >&2
+ exit 1
+ }
+ grep -q 'Sparkle.framework' <<< "$dyld_dependencies" || {
+ echo "dyld does not report Sparkle as a Direct dependency" >&2
+ exit 1
+ }
+ /usr/libexec/PlistBuddy -c 'Print:SUFeedURL' "$plist" > /dev/null
+ public_key="$(/usr/libexec/PlistBuddy -c 'Print:SUPublicEDKey' "$plist")"
+ [[ -n "$public_key" && "$public_key" != *"\$("* ]] || {
+ echo "Direct build has an empty or unexpanded SUPublicEDKey" >&2
+ exit 1
+ }
+ /usr/libexec/PlistBuddy -c 'Print:SUEnableInstallerLauncherService' "$plist" > /dev/null
+else
+ [[ ! -e "$sparkle_framework" ]] || {
+ echo "Sparkle.framework is embedded in $expected_distribution" >&2
+ exit 1
+ }
+ ! grep -q 'Sparkle.framework' <<< "$otool_dependencies" || {
+ echo "$expected_distribution executable retains a Sparkle load command" >&2
+ exit 1
+ }
+ ! grep -q 'Sparkle.framework' <<< "$dyld_dependencies" || {
+ echo "dyld reports Sparkle as a $expected_distribution dependency" >&2
+ exit 1
+ }
+ for key in SUFeedURL SUPublicEDKey SUEnableInstallerLauncherService; do
+ if /usr/libexec/PlistBuddy -c "Print:$key" "$plist" > /dev/null 2>&1; then
+ echo "$key remains in $expected_distribution metadata" >&2
+ exit 1
+ fi
+ done
+fi
+
+echo "verified $expected_distribution app: universal, dyld-valid, updater=$expected_updater"
diff --git a/Scripts/verify-build-settings.sh b/Scripts/verify-build-settings.sh
new file mode 100755
index 0000000..9e8abfe
--- /dev/null
+++ b/Scripts/verify-build-settings.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+target="${1:?usage: verify-build-settings.sh }"
+configuration="${2:?usage: verify-build-settings.sh }"
+distribution="${3:?usage: verify-build-settings.sh }"
+condition="${4:?usage: verify-build-settings.sh }"
+sandbox="${5:?usage: verify-build-settings.sh }"
+entitlements="${6:?usage: verify-build-settings.sh }"
+
+settings="$(xcodebuild -project App/TokenMenuBar.xcodeproj -target "$target" -configuration "$configuration" \
+ -showBuildSettings)"
+
+assert_setting() {
+ local key="$1"
+ local expected="$2"
+ local actual
+ actual="$(awk -F ' = ' -v key="$key" '$1 ~ "^[[:space:]]*" key "$" { print $2; exit }' <<< "$settings")"
+ [[ "$actual" == *"$expected"* ]] || {
+ echo "$configuration: expected $key to contain '$expected', got '$actual'" >&2
+ exit 1
+ }
+}
+
+assert_setting TMB_DISTRIBUTION "$distribution"
+assert_setting SWIFT_ACTIVE_COMPILATION_CONDITIONS "$condition"
+assert_setting ENABLE_APP_SANDBOX "$sandbox"
+assert_setting CODE_SIGN_ENTITLEMENTS "$entitlements"
diff --git a/Scripts/verify-deployment-targets.sh b/Scripts/verify-deployment-targets.sh
new file mode 100755
index 0000000..3e7d172
--- /dev/null
+++ b/Scripts/verify-deployment-targets.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+app="${1:?usage: verify-deployment-targets.sh [maximum-version]}"
+maximum="${2:-14.0}"
+[[ -d "$app" ]] || {
+ echo "missing application bundle: $app" >&2
+ exit 1
+}
+
+checked=0
+while IFS= read -r -d '' candidate; do
+ file "$candidate" | grep -q 'Mach-O' || continue
+ checked=$((checked + 1))
+ min_versions="$(xcrun vtool -show-build "$candidate" | awk '$1 == "minos" { print $2 }')"
+ [[ -n "$min_versions" ]] || {
+ echo "missing LC_BUILD_VERSION in $candidate" >&2
+ exit 1
+ }
+ while IFS= read -r minimum; do
+ if ! awk -v minimum="$minimum" -v maximum="$maximum" 'BEGIN {
+ split(minimum, a, "."); split(maximum, b, ".")
+ exit (a[1] < b[1] || (a[1] == b[1] && a[2] <= b[2])) ? 0 : 1
+ }'; then
+ echo "$candidate requires macOS $minimum, above $maximum" >&2
+ exit 1
+ fi
+ done <<< "$min_versions"
+done < <(find "$app" -type f -print0)
+
+[[ "$checked" -gt 0 ]] || {
+ echo "no Mach-O files found in $app" >&2
+ exit 1
+}
+echo "verified $checked Mach-O files at macOS $maximum or earlier"
diff --git a/Scripts/version.sh b/Scripts/version.sh
new file mode 100755
index 0000000..95f5a1b
--- /dev/null
+++ b/Scripts/version.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# Derives the version from git the way setuptools-scm does, so a build off any commit names the commit it came from.
+#
+# on a tag, clean tree v1.2.3 -> 1.2.3
+# five commits past it v1.2.3-5-gabc123 -> 1.2.4.dev5+gabc123
+# with local edits -> 1.2.4.dev5+gabc123.d20260830
+#
+# --marketing prints the numeric part alone, which is all CFBundleShortVersionString accepts, and --build prints the
+# commit count for CFBundleVersion, which has to increase with every submission.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+described="$(git describe --tags --long --dirty --match 'v[0-9]*' 2> /dev/null || true)"
+if [ -n "$described" ]; then
+ dirty=""
+ case "$described" in
+ *-dirty)
+ dirty="yes"
+ described="${described%-dirty}"
+ ;;
+ esac
+ node="${described##*-}"
+ rest="${described%-*}"
+ distance="${rest##*-}"
+ tag="${rest%-*}"
+ base="${tag#v}"
+else
+ # No release yet, so every commit is a pre-release of the first one.
+ dirty=""
+ [ -z "$(git status --porcelain 2> /dev/null)" ] || dirty="yes"
+ base="0.0.0"
+ distance="$(git rev-list --count HEAD 2> /dev/null || echo 0)"
+ node="g$(git rev-parse --short HEAD 2> /dev/null || echo unknown)"
+fi
+
+if [ "$distance" = "0" ] && [ -z "$dirty" ]; then
+ marketing="$base"
+ full="$base"
+else
+ major="${base%%.*}"
+ patch="${base##*.}"
+ minor="${base#*.}"
+ minor="${minor%.*}"
+ marketing="${major}.${minor}.$((patch + 1))"
+ full="${marketing}.dev${distance}+${node}"
+ [ -z "$dirty" ] || full="${full}.d$(date -u +%Y%m%d)"
+fi
+
+case "${1:---full}" in
+ --marketing) echo "$marketing" ;;
+ --build) git rev-list --count HEAD 2> /dev/null || echo 1 ;;
+ --full) echo "$full" ;;
+ *)
+ echo "usage: version.sh [--full|--marketing|--build]" >&2
+ exit 2
+ ;;
+esac
diff --git a/Sources/TokenMenuBar/Updater.swift b/Sources/TokenMenuBar/Updater.swift
new file mode 100644
index 0000000..dcfbb51
--- /dev/null
+++ b/Sources/TokenMenuBar/Updater.swift
@@ -0,0 +1,14 @@
+import TokenMenuBarCore
+import TokenMenuBarUI
+
+@MainActor
+enum Updater {
+ static func make(appInfo: AppInfo) -> (any UpdaterHook)? {
+ guard appInfo.canSelfUpdate else { return nil }
+ #if DIRECT
+ return SparkleUpdater()
+ #else
+ return nil
+ #endif
+ }
+}
diff --git a/Sources/TokenMenuBar/main.swift b/Sources/TokenMenuBar/main.swift
new file mode 100644
index 0000000..6472ee4
--- /dev/null
+++ b/Sources/TokenMenuBar/main.swift
@@ -0,0 +1,44 @@
+import AppKit
+import TokenMenuBarCore
+import TokenMenuBarUI
+import UserNotifications
+
+// Exporting never starts the UI, so the screenshot script can run it while the app is open.
+if let (command, directory) = ExportCommand.parse(CommandLine.arguments) {
+ do {
+ let written = try await ExportRunner.run(command, directory: directory)
+ print("wrote \(written.count) files to \(directory.path)")
+ exit(0)
+ } catch {
+ FileHandle.standardError.write(Data("\(command.failureMessage): \(error)\n".utf8))
+ exit(1)
+ }
+}
+
+let app = NSApplication.shared
+let launchPolicy = LaunchPolicy()
+let appInfo = AppInfo.from(bundle: .main, distribution: .direct)
+let transport: any HTTPTransport =
+ launchPolicy.mode == .verification ? DisabledHTTPTransport() : SystemHTTPTransport.make()
+let keychain: KeychainCredentialClient = launchPolicy.mode == .verification ? .empty : .system
+let launchAtLogin: LaunchAtLoginBackend =
+ launchPolicy.mode == .verification ? .inMemory() : LaunchAtLoginService.backend()
+let paths =
+ if let supportDirectory = launchPolicy.supportDirectory {
+ LiveDependencies.Paths(
+ home: supportDirectory, supportDirectory: supportDirectory,
+ environment: launchPolicy.environment, userName: "verification", arguments: CommandLine.arguments,
+ verificationProfile: launchPolicy.verificationProfile)
+ } else {
+ LiveDependencies.Paths(environment: launchPolicy.environment)
+ }
+let delegate = AppRunner.bootstrapDeferred(
+ distribution: appInfo.distribution,
+ notificationCenter: launchPolicy.mode == .verification || Bundle.main.bundleIdentifier == nil
+ ? nil : UNUserNotificationCenter.current(),
+ updater: launchPolicy.mode == .verification ? nil : Updater.make(appInfo: appInfo),
+ isSandboxed: ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil, paths: paths,
+ defaults: launchPolicy.defaults(), transport: transport, keychain: keychain, launchAtLogin: launchAtLogin
+)
+app.delegate = delegate
+app.run()
diff --git a/Sources/TokenMenuBarCore/Brand.swift b/Sources/TokenMenuBarCore/Brand.swift
new file mode 100644
index 0000000..d4e2771
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Brand.swift
@@ -0,0 +1,80 @@
+import Foundation
+
+public struct BrandColor: Hashable, Sendable {
+ public let red: Double
+ public let green: Double
+ public let blue: Double
+
+ public init(red: Double, green: Double, blue: Double) {
+ self.red = red
+ self.green = green
+ self.blue = blue
+ }
+
+ public init(_ hex: UInt32) {
+ self.init(
+ red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255)
+ }
+
+ public var hex: String {
+ let channels = [red, green, blue].map { Int((min(max($0, 0), 1) * 255).rounded()) }
+ return "#" + channels.map { String(format: "%02X", $0) }.joined()
+ }
+
+ public func mixed(with other: BrandColor, fraction: Double) -> BrandColor {
+ let amount = min(max(fraction, 0), 1)
+ return BrandColor(
+ red: red + (other.red - red) * amount, green: green + (other.green - green) * amount,
+ blue: blue + (other.blue - blue) * amount)
+ }
+}
+
+/// Palette shared by the app icon, the menu bar rendering and the website, so both stay in step.
+public enum Brand {
+ public static let name = "Token Menu Bar"
+ public static let tagline = "Your AI coding plan limits, one glance away"
+
+ public static let gradientStart = BrandColor(0x4C_3BE0)
+ public static let gradientEnd = BrandColor(0x9A_6BFF)
+ public static let iris = BrandColor(0x5A_46E8)
+ public static let irisDark = BrandColor(0xA7_8BFA)
+ public static let pageLight = BrandColor(0xFA_FAFC)
+ public static let pageDark = BrandColor(0x0F_1117)
+ public static let cardLight = BrandColor(0xFF_FFFF)
+ public static let cardDark = BrandColor(0x17_1A22)
+
+ /// The card the website draws a screenshot on, so an exported shot sits flush with the page around it.
+ public static func card(dark: Bool) -> BrandColor {
+ dark ? cardDark : cardLight
+ }
+
+ public static func gradient(at fraction: Double) -> BrandColor {
+ gradientStart.mixed(with: gradientEnd, fraction: fraction)
+ }
+
+ /// The usage scale owns the semantic colors rather than the brand, so a full gauge reads as red.
+ public static var usageStops: [(name: String, color: BrandColor)] {
+ [("green", usage(0)), ("orange", usage(UsageColor.orangeAt * 100)), ("red", usage(100))]
+ }
+
+ static func usage(_ percent: Double) -> BrandColor {
+ rgb(UsageColor.color(percent: percent))
+ }
+
+ static func rgb(_ hsb: HSBColor) -> BrandColor {
+ let chroma = hsb.brightness * hsb.saturation
+ let sector = hsb.hue * 6
+ let secondary = chroma * (1 - abs(sector.truncatingRemainder(dividingBy: 2) - 1))
+ let base = hsb.brightness - chroma
+ let rgb: (Double, Double, Double) =
+ switch sector {
+ case ..<1: (chroma, secondary, 0)
+ case ..<2: (secondary, chroma, 0)
+ case ..<3: (0, chroma, secondary)
+ case ..<4: (0, secondary, chroma)
+ case ..<5: (secondary, 0, chroma)
+ default: (chroma, 0, secondary)
+ }
+ return BrandColor(red: rgb.0 + base, green: rgb.1 + base, blue: rgb.2 + base)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Clock.swift b/Sources/TokenMenuBarCore/Clock.swift
new file mode 100644
index 0000000..cadfe1a
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Clock.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+public struct Clock: Sendable {
+ public let now: @Sendable () -> Date
+ public let sleep: @Sendable (TimeInterval) async throws -> Void
+
+ public init(
+ now: @escaping @Sendable () -> Date,
+ sleep: @escaping @Sendable (TimeInterval) async throws -> Void
+ ) {
+ self.now = now
+ self.sleep = sleep
+ }
+
+ public static let system = Clock(now: { Date() }, sleep: { try await Task.sleep(for: .seconds($0)) })
+
+ public static func fixed(_ date: Date) -> Clock {
+ Clock(now: { date }, sleep: { _ in })
+ }
+}
+
+public enum ShutdownPolicy {
+ public static let persistenceTimeout = Duration.seconds(1)
+
+ public static func waitForCompletion(
+ timeout: Duration = persistenceTimeout,
+ pollInterval: Duration = .milliseconds(10),
+ operation: @escaping @Sendable () async -> Void
+ ) async -> Bool {
+ let completion = Completion()
+ let task = Task {
+ await operation()
+ await completion.finish()
+ }
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: timeout)
+ while !(await completion.finished), clock.now < deadline {
+ try? await clock.sleep(for: pollInterval)
+ }
+ let finished = await completion.finished
+ if !finished { task.cancel() }
+ return finished
+ }
+
+ private actor Completion {
+ private(set) var finished = false
+
+ func finish() {
+ finished = true
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift b/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift
new file mode 100644
index 0000000..64dd4c7
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift
@@ -0,0 +1,251 @@
+import CryptoKit
+import Foundation
+import Security
+
+public struct ClaudeOAuthCredentials: Sendable, Equatable {
+ public static let keychainService = "Claude Code-credentials"
+
+ public let accessToken: String
+ public let refreshToken: String?
+ public let expiresAt: Date?
+ public let scopes: [String]
+ public let subscriptionType: String?
+ public let rateLimitTier: String?
+ public let document: JSONValue
+
+ public init?(document: JSONValue) {
+ guard let oauth = document["claudeAiOauth"], let accessToken = oauth["accessToken"]?.stringValue else { return nil }
+ self.document = document
+ self.accessToken = accessToken
+ refreshToken = oauth["refreshToken"]?.stringValue
+ expiresAt = oauth["expiresAt"]?.doubleValue.map { Date(timeIntervalSince1970: $0 / 1000) }
+ scopes = oauth["scopes"]?.arrayValue?.compactMap(\.stringValue) ?? []
+ subscriptionType = oauth["subscriptionType"]?.stringValue
+ rateLimitTier = oauth["rateLimitTier"]?.stringValue
+ }
+
+ public init(
+ accessToken: String, refreshToken: String?, expiresAt: Date?, scopes: [String] = ["user:profile"],
+ subscriptionType: String? = nil, rateLimitTier: String? = nil
+ ) {
+ var oauth: [String: JSONValue] = [
+ "accessToken": .string(accessToken), "scopes": .array(scopes.map(JSONValue.string)),
+ ]
+ oauth["refreshToken"] = refreshToken.map(JSONValue.string)
+ oauth["expiresAt"] = expiresAt.map { .number(($0.timeIntervalSince1970 * 1000).rounded()) }
+ oauth["subscriptionType"] = subscriptionType.map(JSONValue.string)
+ oauth["rateLimitTier"] = rateLimitTier.map(JSONValue.string)
+ self.init(document: .object(["claudeAiOauth": .object(oauth)]))!
+ }
+
+ public var hasProfileScope: Bool {
+ scopes.contains("user:profile")
+ }
+
+ var cacheFingerprint: String {
+ let claims = JWT.payload(accessToken)
+ let identity = claims?["sub"]?.stringValue ?? claims?["email"]?.stringValue ?? refreshToken ?? accessToken
+ return SHA256.hash(data: Data("claude-cache:\(identity)".utf8))
+ .map { String(format: "%02x", $0) }
+ .joined()
+ }
+
+ public func state(now: Date) -> CredentialState {
+ CredentialState.from(expiresAt: expiresAt, now: now)
+ }
+
+ public func refreshed(
+ accessToken: String, refreshToken: String?, expiresIn: TimeInterval, now: Date
+ ) -> ClaudeOAuthCredentials {
+ var oauth = document["claudeAiOauth"]!.objectValue!
+ oauth["accessToken"] = .string(accessToken)
+ if let refreshToken { oauth["refreshToken"] = .string(refreshToken) }
+ oauth["expiresAt"] = .number((now.addingTimeInterval(expiresIn).timeIntervalSince1970 * 1000).rounded())
+ return ClaudeOAuthCredentials(document: document.merging("claudeAiOauth", .object(oauth)))!
+ }
+
+ public static func keychainService(configDir: String?) -> String {
+ guard let configDir, !configDir.isEmpty else { return keychainService }
+ let digest = SHA256.hash(data: Data(configDir.utf8)).map { String(format: "%02x", $0) }.joined()
+ return "\(keychainService)-\(digest.prefix(8))"
+ }
+}
+
+public protocol ClaudeCredentialStore: Sendable {
+ func load() throws -> ClaudeOAuthCredentials?
+ func loadWithSource() throws -> (credentials: ClaudeOAuthCredentials, source: CredentialSource)?
+ func save(_ credentials: ClaudeOAuthCredentials) throws
+ var description: String { get }
+ var source: CredentialSource { get }
+}
+
+extension ClaudeCredentialStore {
+ public var source: CredentialSource {
+ CredentialSource(id: "claude.custom", provider: .claude, title: "Claude credentials", detail: description)
+ }
+
+ public func save(
+ _ credentials: ClaudeOAuthCredentials,
+ replacing expected: ClaudeOAuthCredentials
+ ) throws -> CredentialSaveResult {
+ let current = try loadWithSource()
+ guard current?.credentials == expected else {
+ return .changed(current?.credentials, source: current?.source)
+ }
+ try save(credentials)
+ return .saved
+ }
+
+ public func loadWithSource() throws -> (credentials: ClaudeOAuthCredentials, source: CredentialSource)? {
+ try load().map { ($0, source) }
+ }
+
+ public func credentialHealth(now: Date) -> ProviderCredentialHealth {
+ do {
+ guard let found = try loadWithSource() else {
+ return .missing(expected: ProviderID.claude.setup.credentialSources)
+ }
+ return .from(
+ found.credentials.state(now: now), source: found.source, expected: ProviderID.claude.setup.credentialSources)
+ } catch {
+ return .from(readError: error, fallbackSource: source)
+ }
+ }
+}
+
+public enum CredentialStoreError: Error, Equatable {
+ case keychain(OSStatus)
+ case malformed(String)
+}
+
+public struct KeychainClaudeCredentialStore: ClaudeCredentialStore {
+ public let service: String
+ public let account: String
+ private let keychain: KeychainCredentialClient
+
+ public init(
+ service: String = ClaudeOAuthCredentials.keychainService,
+ account: String,
+ keychain: KeychainCredentialClient
+ ) {
+ self.service = service
+ self.account = account
+ self.keychain = keychain
+ }
+
+ public var description: String {
+ "Keychain item \(service)"
+ }
+
+ public var source: CredentialSource { ProviderID.claude.credentialSource("claude.keychain") }
+
+ public func load() throws -> ClaudeOAuthCredentials? {
+ guard let item = try keychain.load(service: service, account: account) else { return nil }
+ return try Self.parse(item.data)
+ }
+
+ public func save(_ credentials: ClaudeOAuthCredentials) throws {
+ try keychain.save(try JSONEncoder().encode(credentials.document), service: service, account: account)
+ }
+
+ static func parse(_ data: Data) throws -> ClaudeOAuthCredentials? {
+ guard let document = try? JSONDecoder().decode(JSONValue.self, from: data) else {
+ throw CredentialStoreError.malformed("Keychain item is not JSON")
+ }
+ return ClaudeOAuthCredentials(document: document)
+ }
+}
+
+public struct FileClaudeCredentialStore: ClaudeCredentialStore {
+ public let url: URL
+
+ public init(url: URL) {
+ self.url = url
+ }
+
+ public var description: String {
+ url.path
+ }
+
+ public var source: CredentialSource { ProviderID.claude.credentialSource("claude.file") }
+
+ public func load() throws -> ClaudeOAuthCredentials? {
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+ let data = try Data(contentsOf: url)
+ guard let document = try? JSONDecoder().decode(JSONValue.self, from: data) else {
+ throw CredentialStoreError.malformed("\(url.lastPathComponent) is not JSON")
+ }
+ return ClaudeOAuthCredentials(document: document)
+ }
+
+ public func save(_ credentials: ClaudeOAuthCredentials) throws {
+ try JSONEncoder().encode(credentials.document).write(to: url, options: .atomic)
+ }
+}
+
+public struct ChainedClaudeCredentialStore: ClaudeCredentialStore {
+ public let stores: [any ClaudeCredentialStore]
+
+ public init(_ stores: [any ClaudeCredentialStore]) {
+ self.stores = stores
+ }
+
+ public var description: String {
+ stores.map(\.description).joined(separator: ", ")
+ }
+
+ public var source: CredentialSource {
+ CredentialSource(
+ id: "claude.automatic", provider: .claude, title: "Claude Code credentials",
+ detail: stores.map(\.source.title).joined(separator: ", "))
+ }
+
+ public func load() throws -> ClaudeOAuthCredentials? {
+ try loadWithSource()?.credentials
+ }
+
+ public func loadWithSource() throws -> (credentials: ClaudeOAuthCredentials, source: CredentialSource)? {
+ var lastError: CredentialReadFailure?
+ for store in stores {
+ do {
+ if let found = try store.loadWithSource() { return found }
+ } catch {
+ lastError = CredentialReadFailure(source: store.source, error: error)
+ }
+ }
+ if let lastError { throw lastError }
+ return nil
+ }
+
+ public func save(_ credentials: ClaudeOAuthCredentials) throws {
+ let target = stores.first { (try? $0.load()) != nil } ?? stores.first
+ guard let target else { return }
+ try target.save(credentials)
+ }
+}
+
+public struct ClaudeLocalAccount: Sendable, Equatable {
+ public let email: String?
+ public let organizationName: String?
+ public let rateLimitTier: String?
+ public let hasExtraUsageEnabled: Bool?
+
+ public init(email: String?, organizationName: String?, rateLimitTier: String?, hasExtraUsageEnabled: Bool?) {
+ self.email = email
+ self.organizationName = organizationName
+ self.rateLimitTier = rateLimitTier
+ self.hasExtraUsageEnabled = hasExtraUsageEnabled
+ }
+
+ public static func load(from url: URL) -> ClaudeLocalAccount? {
+ guard let data = try? Data(contentsOf: url), let json = try? JSONDecoder().decode(JSONValue.self, from: data),
+ let account = json["oauthAccount"]
+ else { return nil }
+ return ClaudeLocalAccount(
+ email: account["emailAddress"]?.stringValue,
+ organizationName: account["organizationName"]?.stringValue,
+ rateLimitTier: account["organizationRateLimitTier"]?.stringValue,
+ hasExtraUsageEnabled: account["hasExtraUsageEnabled"]?.boolValue
+ )
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift b/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift
new file mode 100644
index 0000000..8890586
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift
@@ -0,0 +1,292 @@
+import CryptoKit
+import Foundation
+
+public struct CodexAuth: Sendable, Equatable {
+ public let accessToken: String
+ public let refreshToken: String?
+ public let idToken: String?
+ public let accountID: String?
+ public let apiKey: String?
+ public let lastRefresh: Date?
+ public let document: JSONValue
+
+ public init?(document: JSONValue) {
+ let tokens = document["tokens"]
+ apiKey = document["OPENAI_API_KEY"]?.stringValue
+ guard let accessToken = tokens?["access_token"]?.stringValue ?? apiKey else { return nil }
+ self.document = document
+ self.accessToken = accessToken
+ refreshToken = tokens?["refresh_token"]?.stringValue
+ idToken = tokens?["id_token"]?.stringValue
+ accountID =
+ tokens?["account_id"]?.stringValue
+ ?? JWT.payload(idToken ?? "")?["https://api.openai.com/auth"]?["chatgpt_account_id"]?.stringValue
+ lastRefresh = ISODate.parse(document["last_refresh"]?.stringValue)
+ }
+
+ public init(
+ accessToken: String, refreshToken: String? = nil, idToken: String? = nil, accountID: String? = nil,
+ lastRefresh: Date? = nil
+ ) {
+ var tokens: [String: JSONValue] = ["access_token": .string(accessToken)]
+ tokens["refresh_token"] = refreshToken.map(JSONValue.string)
+ tokens["id_token"] = idToken.map(JSONValue.string)
+ tokens["account_id"] = accountID.map(JSONValue.string)
+ var document: [String: JSONValue] = [
+ "tokens": .object(tokens), "auth_mode": .string("chatgpt"), "OPENAI_API_KEY": .null,
+ ]
+ document["last_refresh"] = lastRefresh.map { .string(ISODate.string($0)) }
+ self.init(document: .object(document))!
+ }
+
+ public var claims: JSONValue? {
+ idToken.flatMap(JWT.payload)
+ }
+
+ public var email: String? {
+ claims?["email"]?.stringValue
+ }
+
+ public var planType: String? {
+ claims?["https://api.openai.com/auth"]?["chatgpt_plan_type"]?.stringValue
+ }
+
+ public var subscriptionActiveUntil: Date? {
+ ISODate.parse(claims?["https://api.openai.com/auth"]?["chatgpt_subscription_active_until"]?.stringValue)
+ }
+
+ var accountFingerprint: String {
+ let value: String
+ if let accountID = accountID?.trimmingCharacters(in: .whitespacesAndNewlines), !accountID.isEmpty {
+ value = "account:\(accountID)"
+ } else if let email = email?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !email.isEmpty {
+ value = "email:\(email)"
+ } else {
+ value = "token:\(accessToken)"
+ }
+ return SHA256.hash(data: Data("codex-analytics:\(value)".utf8))
+ .map { String(format: "%02x", $0) }
+ .joined()
+ }
+
+ public func state(now: Date) -> CredentialState {
+ CredentialState.from(expiresAt: JWT.expiry(accessToken), now: now)
+ }
+
+ public func refreshed(accessToken: String, refreshToken: String?, idToken: String?, now: Date) -> CodexAuth {
+ var tokens = document["tokens"]?.objectValue ?? [:]
+ tokens["access_token"] = .string(accessToken)
+ if let refreshToken { tokens["refresh_token"] = .string(refreshToken) }
+ if let idToken { tokens["id_token"] = .string(idToken) }
+ let updated = document.merging("tokens", .object(tokens)).merging("last_refresh", .string(ISODate.string(now)))
+ return CodexAuth(document: updated)!
+ }
+}
+
+public protocol CodexAuthStore: Sendable {
+ func load() throws -> CodexAuth?
+ func loadWithSource() throws -> (auth: CodexAuth, source: CredentialSource)?
+ func save(_ auth: CodexAuth) throws
+ var description: String { get }
+ var source: CredentialSource { get }
+}
+
+extension CodexAuthStore {
+ public var source: CredentialSource {
+ CredentialSource(id: "codex.custom", provider: .codex, title: "Codex credentials", detail: description)
+ }
+
+ public func save(_ auth: CodexAuth, replacing expected: CodexAuth) throws -> CredentialSaveResult {
+ let current = try loadWithSource()
+ guard current?.auth == expected else { return .changed(current?.auth, source: current?.source) }
+ try save(auth)
+ return .saved
+ }
+
+ public func loadWithSource() throws -> (auth: CodexAuth, source: CredentialSource)? {
+ try load().map { ($0, source) }
+ }
+
+ public func credentialHealth(now: Date) -> ProviderCredentialHealth {
+ do {
+ guard let found = try loadWithSource() else {
+ return .missing(expected: ProviderID.codex.setup.credentialSources)
+ }
+ return .from(found.auth.state(now: now), source: found.source, expected: ProviderID.codex.setup.credentialSources)
+ } catch {
+ return .from(readError: error, fallbackSource: source)
+ }
+ }
+}
+
+public struct FileCodexAuthStore: CodexAuthStore {
+ public let url: URL
+
+ public init(url: URL) {
+ self.url = url
+ }
+
+ public static func defaultURL(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ home: URL = FileManager.default.homeDirectoryForCurrentUser
+ ) -> URL {
+ let root = environment["CODEX_HOME"].map { URL(fileURLWithPath: $0) } ?? home.appendingPathComponent(".codex")
+ return root.appendingPathComponent("auth.json")
+ }
+
+ public var description: String {
+ url.path
+ }
+
+ public var source: CredentialSource { ProviderID.codex.credentialSource("codex.file") }
+
+ public func load() throws -> CodexAuth? {
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+ let data = try Data(contentsOf: url)
+ guard let document = try? JSONDecoder().decode(JSONValue.self, from: data) else {
+ throw CredentialStoreError.malformed("\(url.lastPathComponent) is not JSON")
+ }
+ return CodexAuth(document: document)
+ }
+
+ public func save(_ auth: CodexAuth) throws {
+ try JSONEncoder().encode(auth.document).write(to: url, options: .atomic)
+ }
+}
+
+public struct KeychainCodexAuthStore: CodexAuthStore {
+ public static let service = "Codex Auth"
+
+ public let account: String
+ private let keychain: KeychainCredentialClient
+
+ public init(codexHome: URL, keychain: KeychainCredentialClient) {
+ account = Self.account(codexHome: codexHome)
+ self.keychain = keychain
+ }
+
+ public init(account: String, keychain: KeychainCredentialClient) {
+ self.account = account
+ self.keychain = keychain
+ }
+
+ public var description: String { "Keychain item \(Self.service)" }
+ public var source: CredentialSource { ProviderID.codex.credentialSource("codex.keyring") }
+
+ public func load() throws -> CodexAuth? {
+ guard let item = try keychain.load(service: Self.service, account: account) else { return nil }
+ return try Self.parse(item.data)
+ }
+
+ public func save(_ auth: CodexAuth) throws {
+ try keychain.save(try JSONEncoder().encode(auth.document), service: Self.service, account: account)
+ }
+
+ public static func account(codexHome: URL) -> String {
+ let canonical = codexHome.standardizedFileURL.resolvingSymlinksInPath().path
+ let digest = SHA256.hash(data: Data(canonical.utf8)).map { String(format: "%02x", $0) }.joined()
+ return "cli|\(digest.prefix(16))"
+ }
+
+ static func parse(_ data: Data) throws -> CodexAuth? {
+ guard let document = try? JSONDecoder().decode(JSONValue.self, from: data) else {
+ throw CredentialStoreError.malformed("Codex Keychain item is not JSON")
+ }
+ return CodexAuth(document: document)
+ }
+}
+
+public struct ChainedCodexAuthStore: CodexAuthStore {
+ public let stores: [any CodexAuthStore]
+
+ public init(_ stores: [any CodexAuthStore]) {
+ self.stores = stores
+ }
+
+ public var description: String { stores.map(\.description).joined(separator: ", ") }
+
+ public var source: CredentialSource {
+ CredentialSource(
+ id: "codex.automatic", provider: .codex, title: "Codex credentials",
+ detail: stores.map(\.source.title).joined(separator: ", "))
+ }
+
+ public func load() throws -> CodexAuth? {
+ try loadWithSource()?.auth
+ }
+
+ public func loadWithSource() throws -> (auth: CodexAuth, source: CredentialSource)? {
+ var firstError: CredentialReadFailure?
+ for store in stores {
+ do {
+ if let found = try store.loadWithSource() { return found }
+ } catch {
+ firstError = firstError ?? CredentialReadFailure(source: store.source, error: error)
+ }
+ }
+ if let firstError { throw firstError }
+ return nil
+ }
+
+ public func save(_ auth: CodexAuth) throws {
+ let target = stores.first { (try? $0.load()) != nil } ?? stores.first
+ guard let target else { return }
+ try target.save(auth)
+ }
+}
+
+public enum CodexCredentialStorage: Sendable, Equatable {
+ case automatic
+ case file
+ case keyring
+ case unknown(String)
+}
+
+public enum CodexCredentialStorageReader {
+ public static func load(
+ from url: URL,
+ read: (URL) throws -> String = { try String(contentsOf: $0, encoding: .utf8) }
+ ) -> CodexCredentialStorage {
+ guard let text = try? read(url) else { return .automatic }
+ return parse(text)
+ }
+
+ public static func parse(_ text: String) -> CodexCredentialStorage {
+ for line in text.split(whereSeparator: \.isNewline) {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.hasPrefix("[") { break }
+ guard !trimmed.hasPrefix("#"), let separator = trimmed.firstIndex(of: "=") else { continue }
+ let key = trimmed[.. Date? {
+ guard let string else { return nil }
+ return fractional.date(from: string) ?? plain.date(from: string)
+ }
+
+ public static func string(_ date: Date) -> String {
+ fractional.string(from: date)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Credentials/CopilotCredentials.swift b/Sources/TokenMenuBarCore/Credentials/CopilotCredentials.swift
new file mode 100644
index 0000000..1a9e521
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Credentials/CopilotCredentials.swift
@@ -0,0 +1,320 @@
+import Foundation
+
+public struct CopilotAuth: Sendable, Equatable {
+ public let token: String
+ public let user: String?
+ public let host: String
+
+ public init(token: String, user: String? = nil, host: String = "github.com") {
+ self.token = token
+ self.user = user
+ self.host = Self.normalizedHost(host) ?? ""
+ }
+
+ static func normalizedHost(_ value: String) -> String? {
+ var host = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ for prefix in ["https://", "http://"] where host.lowercased().hasPrefix(prefix) {
+ host.removeFirst(prefix.count)
+ break
+ }
+ host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ guard !host.isEmpty, !host.contains(where: { $0 == "/" || $0 == "?" || $0 == "#" || $0.isWhitespace })
+ else { return nil }
+ let punctuation = CharacterSet(charactersIn: ".-")
+ guard host.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) || punctuation.contains($0) })
+ else { return nil }
+ return host.lowercased()
+ }
+
+ public func state(now: Date) -> CredentialState {
+ .valid(expiresAt: nil)
+ }
+}
+
+public protocol CopilotAuthStore: Sendable {
+ func load() throws -> CopilotAuth?
+ func loadWithSource() throws -> (auth: CopilotAuth, source: CredentialSource)?
+ var description: String { get }
+ var source: CredentialSource { get }
+}
+
+extension CopilotAuthStore {
+ public var source: CredentialSource {
+ CredentialSource(id: "copilot.custom", provider: .copilot, title: "GitHub Copilot credentials", detail: description)
+ }
+
+ public func loadWithSource() throws -> (auth: CopilotAuth, source: CredentialSource)? {
+ try load().map { ($0, source) }
+ }
+
+ public func credentialHealth(now: Date) -> ProviderCredentialHealth {
+ do {
+ guard let found = try loadWithSource() else {
+ return .missing(expected: ProviderID.copilot.setup.credentialSources)
+ }
+ return .from(
+ found.auth.state(now: now), source: found.source, expected: ProviderID.copilot.setup.credentialSources)
+ } catch {
+ return .from(readError: error, fallbackSource: source)
+ }
+ }
+}
+
+public struct FileCopilotAuthStore: CopilotAuthStore {
+ public let urls: [URL]
+
+ public init(urls: [URL]) {
+ self.urls = urls
+ }
+
+ public static func defaultURLs(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ home: URL = FileManager.default.homeDirectoryForCurrentUser
+ ) -> [URL] {
+ let config =
+ environment["XDG_CONFIG_HOME"].map { URL(fileURLWithPath: $0) } ?? home.appendingPathComponent(".config")
+ let root = config.appendingPathComponent("github-copilot")
+ return [root.appendingPathComponent("hosts.json"), root.appendingPathComponent("apps.json")]
+ }
+
+ public var description: String {
+ urls.map(\.path).joined(separator: ", ")
+ }
+
+ public var source: CredentialSource { ProviderID.copilot.credentialSource("copilot.legacy-file") }
+
+ public func load() throws -> CopilotAuth? {
+ for url in urls where FileManager.default.fileExists(atPath: url.path) {
+ let data = try Data(contentsOf: url)
+ guard let document = try? JSONDecoder().decode(JSONValue.self, from: data), let entries = document.objectValue
+ else { throw CredentialStoreError.malformed("\(url.lastPathComponent) is not a JSON object") }
+ let candidates = entries.keys.sorted().filter { $0.contains("github.com") } + entries.keys.sorted()
+ for key in candidates {
+ guard let token = entries[key]?["oauth_token"]?.stringValue, !token.isEmpty else { continue }
+ return CopilotAuth(
+ token: token, user: entries[key]?["user"]?.stringValue,
+ host: String(key.split(separator: ":").first ?? "github.com"))
+ }
+ }
+ return nil
+ }
+}
+
+public struct EnvironmentCopilotAuthStore: CopilotAuthStore {
+ public let environment: [String: String]
+
+ public init(environment: [String: String]) {
+ self.environment = environment
+ }
+
+ public var description: String { "COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN" }
+ public var source: CredentialSource { ProviderID.copilot.credentialSource("copilot.environment") }
+
+ public func load() throws -> CopilotAuth? {
+ for key in ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] {
+ if let token = environment[key], !token.isEmpty {
+ return CopilotAuth(token: token, host: environment["GH_HOST"] ?? "github.com")
+ }
+ }
+ return nil
+ }
+}
+
+public struct FileCopilotCLIAuthStore: CopilotAuthStore {
+ public let url: URL
+
+ public init(url: URL) {
+ self.url = url
+ }
+
+ public var description: String { url.path }
+ public var source: CredentialSource { ProviderID.copilot.credentialSource("copilot.file") }
+
+ public func load() throws -> CopilotAuth? {
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+ let data = try Data(contentsOf: url)
+ guard let document = try? JSONDecoder().decode(JSONValue.self, from: data) else {
+ throw CredentialStoreError.malformed("\(url.lastPathComponent) is not JSON")
+ }
+ return Self.accounts(document).compactMap { account in
+ account.token.map { CopilotAuth(token: $0, user: account.user, host: account.host) }
+ }.first
+ }
+
+ public func keychainAccounts() -> [String] {
+ guard let data = try? Data(contentsOf: url), let document = try? JSONDecoder().decode(JSONValue.self, from: data)
+ else { return [] }
+ return Self.accounts(document).map(\.account).uniqued()
+ }
+
+ static func accounts(_ document: JSONValue) -> [CopilotCLIAccount] {
+ guard let users = document["loggedInUsers"] else { return [] }
+ if let entries = users.objectValue {
+ return entries.keys.sorted().compactMap { account(key: $0, value: entries[$0]!) }
+ }
+ return users.arrayValue?.compactMap { account(key: nil, value: $0) } ?? []
+ }
+
+ private static func account(key: String?, value: JSONValue) -> CopilotCLIAccount? {
+ let values = value.objectValue
+ let user =
+ values?["user"]?.stringValue ?? values?["login"]?.stringValue ?? values?["username"]?.stringValue
+ ?? value.stringValue
+ let hostValue = values?["host"]?.stringValue ?? values?["url"]?.stringValue ?? key ?? "github.com"
+ let host = hostValue.replacingOccurrences(of: "https://", with: "").trimmingCharacters(
+ in: CharacterSet(charactersIn: "/"))
+ let account =
+ values?["keychainAccount"]?.stringValue
+ ?? user.map { "https://\(host):\($0)" }
+ ?? key
+ guard let account else { return nil }
+ let token =
+ values?["token"]?.stringValue ?? values?["oauthToken"]?.stringValue ?? values?["oauth_token"]?.stringValue
+ return CopilotCLIAccount(account: account, token: token, user: user, host: host)
+ }
+}
+
+struct CopilotCLIAccount {
+ let account: String
+ let token: String?
+ let user: String?
+ let host: String
+}
+
+public struct KeychainCopilotAuthStore: CopilotAuthStore {
+ public static let service = "copilot-cli"
+ public let accounts: [String]
+ private let service: String
+ private let keychain: KeychainCredentialClient
+
+ public init(
+ service: String = Self.service,
+ accounts: [String] = [],
+ keychain: KeychainCredentialClient
+ ) {
+ self.service = service
+ self.accounts = accounts
+ self.keychain = keychain
+ }
+
+ public var description: String { "Keychain item \(service)" }
+ public var source: CredentialSource { ProviderID.copilot.credentialSource("copilot.keychain") }
+
+ public func load() throws -> CopilotAuth? {
+ var firstError: CredentialReadFailure?
+ for account in accounts.map(Optional.some) + [nil] {
+ do {
+ guard let item = try keychain.load(service: service, account: account) else { continue }
+ if let auth = try Self.parse(item.data, account: item.account) { return auth }
+ } catch {
+ firstError = firstError ?? CredentialReadFailure(source: source, error: error)
+ }
+ }
+ if let firstError { throw firstError }
+ return nil
+ }
+
+ static func parse(_ data: Data, account: String?) throws -> CopilotAuth? {
+ let accountIdentity = identity(account)
+ if let document = try? JSONDecoder().decode(JSONValue.self, from: data) {
+ let token =
+ document["token"]?.stringValue ?? document["access_token"]?.stringValue
+ ?? document["oauth_token"]?.stringValue
+ guard let token, !token.isEmpty else { return nil }
+ return CopilotAuth(
+ token: token,
+ user: document["user"]?.stringValue ?? document["login"]?.stringValue ?? accountIdentity.user,
+ host: document["host"]?.stringValue ?? accountIdentity.host ?? "github.com")
+ }
+ guard let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !token.isEmpty
+ else { throw CredentialStoreError.malformed("GitHub Copilot Keychain item is not a token") }
+ return CopilotAuth(token: token, user: accountIdentity.user, host: accountIdentity.host ?? "github.com")
+ }
+
+ private static func identity(_ account: String?) -> (host: String?, user: String?) {
+ guard var value = account?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return (nil, nil)
+ }
+ let lowercase = value.lowercased()
+ let hadScheme = lowercase.hasPrefix("https://") || lowercase.hasPrefix("http://")
+ if lowercase.hasPrefix("https://") {
+ value.removeFirst(8)
+ } else if lowercase.hasPrefix("http://") {
+ value.removeFirst(7)
+ }
+ value = value.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ guard let separator = value.lastIndex(of: ":") else {
+ return (nil, hadScheme ? nil : value)
+ }
+ let host = String(value[.. CopilotAuth? {
+ try loadWithSource()?.auth
+ }
+
+ public func loadWithSource() throws -> (auth: CopilotAuth, source: CredentialSource)? {
+ var firstError: CredentialReadFailure?
+ for store in stores {
+ do {
+ if let found = try store.loadWithSource() { return found }
+ } catch {
+ firstError = firstError ?? CredentialReadFailure(source: store.source, error: error)
+ }
+ }
+ if let firstError { throw firstError }
+ return nil
+ }
+}
+
+public enum CopilotCredentialStorage: Sendable, Equatable {
+ case environment
+ case cliKeychain
+ case cliFile
+ case legacyFile
+ case missing
+}
+
+public enum CopilotCredentialStorageReader {
+ public static func detect(
+ environmentTokenExists: Bool,
+ keychainItemExists: Bool,
+ cliFileExists: Bool,
+ legacyFileExists: Bool
+ ) -> CopilotCredentialStorage {
+ if environmentTokenExists { return .environment }
+ if keychainItemExists { return .cliKeychain }
+ if cliFileExists { return .cliFile }
+ return legacyFileExists ? .legacyFile : .missing
+ }
+
+ public static func detect(keychainItemExists: Bool, legacyFileExists: Bool) -> CopilotCredentialStorage {
+ detect(
+ environmentTokenExists: false,
+ keychainItemExists: keychainItemExists,
+ cliFileExists: false,
+ legacyFileExists: legacyFileExists)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Credentials/CredentialState.swift b/Sources/TokenMenuBarCore/Credentials/CredentialState.swift
new file mode 100644
index 0000000..5530bc0
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Credentials/CredentialState.swift
@@ -0,0 +1,117 @@
+import Foundation
+
+public enum CredentialSaveResult: Sendable {
+ case saved
+ case changed(Value?, source: CredentialSource?)
+}
+
+struct PendingCredentialSave: Sendable {
+ let credential: Value
+ let replacing: Value
+ let source: CredentialSource
+ let detail: String
+}
+
+struct ResolvedCredential: Sendable {
+ let credential: Value?
+ let source: CredentialSource?
+ let issue: ProviderRecoveryIssue?
+}
+
+func resolveCredential(
+ pending: inout PendingCredentialSave?,
+ provider: ProviderID,
+ load: () throws -> (credential: Value, source: CredentialSource)?,
+ save: (Value, Value) throws -> CredentialSaveResult
+) throws -> ResolvedCredential {
+ let loaded: (credential: Value, source: CredentialSource)?
+ do {
+ loaded = try load()
+ } catch {
+ guard let pending else { throw error }
+ return ResolvedCredential(
+ credential: pending.credential,
+ source: pending.source,
+ issue: .credentialPersistence(provider: provider, detail: pending.detail))
+ }
+ guard let cached = pending else {
+ return ResolvedCredential(credential: loaded?.credential, source: loaded?.source, issue: nil)
+ }
+ if loaded?.credential == cached.credential {
+ pending = nil
+ return ResolvedCredential(credential: loaded?.credential, source: loaded?.source, issue: nil)
+ }
+ guard loaded?.credential == cached.replacing else {
+ pending = nil
+ return ResolvedCredential(credential: loaded?.credential, source: loaded?.source, issue: nil)
+ }
+ do {
+ switch try save(cached.credential, cached.replacing) {
+ case .saved:
+ pending = nil
+ return ResolvedCredential(credential: cached.credential, source: cached.source, issue: nil)
+ case .changed(let current, let source):
+ pending = nil
+ return ResolvedCredential(credential: current, source: current == nil ? nil : source, issue: nil)
+ }
+ } catch {
+ let detail = credentialPersistenceDetail(error)
+ pending = PendingCredentialSave(
+ credential: cached.credential, replacing: cached.replacing, source: cached.source, detail: detail)
+ return ResolvedCredential(
+ credential: cached.credential,
+ source: cached.source,
+ issue: .credentialPersistence(provider: provider, detail: detail))
+ }
+}
+
+func credentialPersistenceDetail(_ error: any Error) -> String {
+ "The refreshed session remains active in memory. Token Menu Bar will retry saving it on the next refresh. \(error)"
+}
+
+public enum CredentialState: Sendable, Equatable {
+ case missing(String)
+ case expired(Date)
+ case valid(expiresAt: Date?)
+
+ public static let expiryBuffer: TimeInterval = 120
+
+ public static func from(expiresAt: Date?, now: Date) -> CredentialState {
+ if let expiresAt, expiresAt.timeIntervalSince(now) < expiryBuffer { return .expired(expiresAt) }
+ return .valid(expiresAt: expiresAt)
+ }
+
+ public var isMissing: Bool {
+ if case .missing = self { return true }
+ return false
+ }
+
+ public var isUsable: Bool {
+ if case .valid = self { return true }
+ return false
+ }
+
+ public var description: String {
+ switch self {
+ case .missing(let reason): "No credentials: \(reason)"
+ case .expired(let date): "Token expired \(date.formatted(.relative(presentation: .named)))"
+ case .valid(let date):
+ date.map { "Token valid until \($0.formatted(date: .abbreviated, time: .shortened))" } ?? "Token present"
+ }
+ }
+}
+
+public enum JWT {
+ public static func payload(_ token: String) -> JSONValue? {
+ let segments = token.split(separator: ".", omittingEmptySubsequences: false)
+ guard segments.count == 3 else { return nil }
+ var base64 = String(segments[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
+ base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
+ guard let data = Data(base64Encoded: base64) else { return nil }
+ return try? JSONDecoder().decode(JSONValue.self, from: data)
+ }
+
+ public static func expiry(_ token: String) -> Date? {
+ payload(token)?["exp"]?.doubleValue.map { Date(timeIntervalSince1970: $0) }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Credentials/CursorCredentials.swift b/Sources/TokenMenuBarCore/Credentials/CursorCredentials.swift
new file mode 100644
index 0000000..f1df0bd
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Credentials/CursorCredentials.swift
@@ -0,0 +1,154 @@
+import Foundation
+
+public struct CursorAuth: Sendable, Equatable {
+ public let accessToken: String
+ public let refreshToken: String?
+ public let email: String?
+ public let membershipType: String?
+
+ public init(accessToken: String, refreshToken: String? = nil, email: String? = nil, membershipType: String? = nil) {
+ self.accessToken = accessToken
+ self.refreshToken = refreshToken
+ self.email = email
+ self.membershipType = membershipType
+ }
+
+ public var userID: String? {
+ guard let subject = JWT.payload(accessToken)?["sub"]?.stringValue else { return nil }
+ return subject.split(separator: "|", maxSplits: 1).last.map(String.init)
+ }
+
+ public var sessionCookie: String {
+ "WorkosCursorSessionToken=\(userID ?? "")%3A%3A\(accessToken)"
+ }
+
+ public func state(now: Date) -> CredentialState {
+ CredentialState.from(expiresAt: JWT.expiry(accessToken), now: now)
+ }
+}
+
+public protocol CursorAuthStore: Sendable {
+ func load() throws -> CursorAuth?
+ func loadWithSource() throws -> (auth: CursorAuth, source: CredentialSource)?
+ var description: String { get }
+ var source: CredentialSource { get }
+}
+
+extension CursorAuthStore {
+ public var source: CredentialSource {
+ CredentialSource(id: "cursor.custom", provider: .cursor, title: "Cursor credentials", detail: description)
+ }
+
+ public func loadWithSource() throws -> (auth: CursorAuth, source: CredentialSource)? {
+ try load().map { ($0, source) }
+ }
+
+ public func credentialHealth(now: Date) -> ProviderCredentialHealth {
+ do {
+ guard let found = try loadWithSource() else {
+ return .missing(expected: ProviderID.cursor.setup.credentialSources)
+ }
+ return .from(
+ found.auth.state(now: now), source: found.source, expected: ProviderID.cursor.setup.credentialSources)
+ } catch {
+ return .from(readError: error, fallbackSource: source)
+ }
+ }
+}
+
+public struct CursorStateStore: CursorAuthStore {
+ public let url: URL
+
+ public init(url: URL) {
+ self.url = url
+ }
+
+ public static func defaultURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
+ home.appendingPathComponent("Library/Application Support/Cursor/User/globalStorage/state.vscdb")
+ }
+
+ public var description: String {
+ url.path
+ }
+
+ public var source: CredentialSource { ProviderID.cursor.credentialSource("cursor.app") }
+
+ public func load() throws -> CursorAuth? {
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+ let database = try SQLiteDatabase(path: url.path, readOnly: true)
+ let rows = try database.query("SELECT key, value FROM ItemTable WHERE key LIKE 'cursorAuth/%'") { row in
+ (row.text(0), row.text(1))
+ }
+ let values = Dictionary(rows, uniquingKeysWith: { $1 })
+ guard let token = values["cursorAuth/accessToken"], !token.isEmpty else { return nil }
+ return CursorAuth(
+ accessToken: token, refreshToken: values["cursorAuth/refreshToken"], email: values["cursorAuth/cachedEmail"],
+ membershipType: values["cursorAuth/stripeMembershipType"])
+ }
+}
+
+public struct FileCursorAuthStore: CursorAuthStore {
+ public let url: URL
+
+ public init(url: URL) {
+ self.url = url
+ }
+
+ public static func defaultURL(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ home: URL = FileManager.default.homeDirectoryForCurrentUser
+ ) -> URL {
+ home.appendingPathComponent(".cursor").appendingPathComponent("auth.json")
+ }
+
+ public var description: String {
+ url.path
+ }
+
+ public var source: CredentialSource { ProviderID.cursor.credentialSource("cursor.agent") }
+
+ public func load() throws -> CursorAuth? {
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+ let data = try Data(contentsOf: url)
+ guard let document = try? JSONDecoder().decode(JSONValue.self, from: data) else {
+ throw CredentialStoreError.malformed("\(url.lastPathComponent) is not JSON")
+ }
+ guard let token = document["accessToken"]?.stringValue else { return nil }
+ return CursorAuth(accessToken: token, refreshToken: document["refreshToken"]?.stringValue)
+ }
+}
+
+public struct ChainedCursorAuthStore: CursorAuthStore {
+ public let stores: [any CursorAuthStore]
+
+ public init(_ stores: [any CursorAuthStore]) {
+ self.stores = stores
+ }
+
+ public var description: String {
+ stores.map(\.description).joined(separator: ", ")
+ }
+
+ public var source: CredentialSource {
+ CredentialSource(
+ id: "cursor.automatic", provider: .cursor, title: "Cursor credentials",
+ detail: stores.map(\.source.title).joined(separator: ", "))
+ }
+
+ public func load() throws -> CursorAuth? {
+ try loadWithSource()?.auth
+ }
+
+ public func loadWithSource() throws -> (auth: CursorAuth, source: CredentialSource)? {
+ var firstError: CredentialReadFailure?
+ for store in stores {
+ do {
+ if let found = try store.loadWithSource() { return found }
+ } catch {
+ firstError = firstError ?? CredentialReadFailure(source: store.source, error: error)
+ }
+ }
+ if let firstError { throw firstError }
+ return nil
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Credentials/GeminiCredentials.swift b/Sources/TokenMenuBarCore/Credentials/GeminiCredentials.swift
new file mode 100644
index 0000000..caaba66
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Credentials/GeminiCredentials.swift
@@ -0,0 +1,238 @@
+import CryptoKit
+import Foundation
+
+public struct GeminiAuth: Sendable, Equatable {
+ public let accessToken: String
+ public let refreshToken: String?
+ public let idToken: String?
+ public let expiresAt: Date?
+ public let document: JSONValue
+
+ public init?(document: JSONValue) {
+ let wrapped = document["token"]
+ guard let accessToken = document["access_token"]?.stringValue ?? wrapped?["accessToken"]?.stringValue else {
+ return nil
+ }
+ self.document = document
+ self.accessToken = accessToken
+ refreshToken = document["refresh_token"]?.stringValue ?? wrapped?["refreshToken"]?.stringValue
+ idToken = document["id_token"]?.stringValue ?? wrapped?["idToken"]?.stringValue
+ expiresAt = (document["expiry_date"]?.doubleValue ?? wrapped?["expiresAt"]?.doubleValue).map {
+ Date(timeIntervalSince1970: $0 / 1000)
+ }
+ }
+
+ public init(accessToken: String, refreshToken: String? = nil, idToken: String? = nil, expiresAt: Date? = nil) {
+ var document: [String: JSONValue] = ["access_token": .string(accessToken), "token_type": .string("Bearer")]
+ document["refresh_token"] = refreshToken.map(JSONValue.string)
+ document["id_token"] = idToken.map(JSONValue.string)
+ document["expiry_date"] = expiresAt.map { .number($0.timeIntervalSince1970 * 1000) }
+ self.init(document: .object(document))!
+ }
+
+ public var email: String? {
+ idToken.flatMap(JWT.payload)?["email"]?.stringValue
+ }
+
+ public var hostedDomain: String? {
+ idToken.flatMap(JWT.payload)?["hd"]?.stringValue
+ }
+
+ var cacheFingerprint: String {
+ let claims = idToken.flatMap(JWT.payload)
+ let identity = claims?["sub"]?.stringValue ?? claims?["email"]?.stringValue ?? refreshToken ?? accessToken
+ return SHA256.hash(data: Data("gemini-cache:\(identity)".utf8))
+ .map { String(format: "%02x", $0) }
+ .joined()
+ }
+
+ public func state(now: Date) -> CredentialState {
+ CredentialState.from(expiresAt: expiresAt, now: now)
+ }
+
+ public func refreshed(accessToken: String, expiresIn: TimeInterval, idToken: String?, now: Date) -> GeminiAuth {
+ if var token = document["token"]?.objectValue {
+ token["accessToken"] = .string(accessToken)
+ token["expiresAt"] = .number(now.addingTimeInterval(expiresIn).timeIntervalSince1970 * 1000)
+ if let idToken { token["idToken"] = .string(idToken) }
+ return GeminiAuth(document: document.merging("token", .object(token)))!
+ }
+ var updated = document.merging("access_token", .string(accessToken))
+ .merging("expiry_date", .number(now.addingTimeInterval(expiresIn).timeIntervalSince1970 * 1000))
+ if let idToken { updated = updated.merging("id_token", .string(idToken)) }
+ return GeminiAuth(document: updated)!
+ }
+}
+
+public protocol GeminiAuthStore: Sendable {
+ func load() throws -> GeminiAuth?
+ func loadWithSource() throws -> (auth: GeminiAuth, source: CredentialSource)?
+ func save(_ auth: GeminiAuth) throws
+ var description: String { get }
+ var source: CredentialSource { get }
+}
+
+extension GeminiAuthStore {
+ public var source: CredentialSource {
+ CredentialSource(id: "gemini.custom", provider: .gemini, title: "Gemini credentials", detail: description)
+ }
+
+ public func save(_ auth: GeminiAuth, replacing expected: GeminiAuth) throws -> CredentialSaveResult {
+ let current = try loadWithSource()
+ guard current?.auth == expected else { return .changed(current?.auth, source: current?.source) }
+ try save(auth)
+ return .saved
+ }
+
+ public func loadWithSource() throws -> (auth: GeminiAuth, source: CredentialSource)? {
+ try load().map { ($0, source) }
+ }
+
+ public func credentialHealth(now: Date) -> ProviderCredentialHealth {
+ do {
+ guard let found = try loadWithSource() else {
+ return .missing(expected: ProviderID.gemini.setup.credentialSources)
+ }
+ return .from(
+ found.auth.state(now: now), source: found.source, expected: ProviderID.gemini.setup.credentialSources)
+ } catch {
+ return .from(readError: error, fallbackSource: source)
+ }
+ }
+}
+
+public struct FileGeminiAuthStore: GeminiAuthStore {
+ public let url: URL
+
+ public init(url: URL) {
+ self.url = url
+ }
+
+ public static func defaultURL(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ home: URL = FileManager.default.homeDirectoryForCurrentUser
+ ) -> URL {
+ let root = environment["GEMINI_CLI_HOME"].map { URL(fileURLWithPath: $0) } ?? home
+ return root.appendingPathComponent(".gemini").appendingPathComponent("oauth_creds.json")
+ }
+
+ public var description: String {
+ url.path
+ }
+
+ public var source: CredentialSource { ProviderID.gemini.credentialSource("gemini.file") }
+
+ public func load() throws -> GeminiAuth? {
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+ let data = try Data(contentsOf: url)
+ guard let document = try? JSONDecoder().decode(JSONValue.self, from: data) else {
+ throw CredentialStoreError.malformed("\(url.lastPathComponent) is not JSON")
+ }
+ return GeminiAuth(document: document)
+ }
+
+ public func save(_ auth: GeminiAuth) throws {
+ try JSONEncoder().encode(auth.document).write(to: url, options: .atomic)
+ }
+}
+
+public struct KeychainGeminiAuthStore: GeminiAuthStore {
+ public static let service = "gemini-cli-oauth"
+ public static let account = "main-account"
+ private let service: String
+ private let keychain: KeychainCredentialClient
+
+ public init(service: String = Self.service, keychain: KeychainCredentialClient) {
+ self.service = service
+ self.keychain = keychain
+ }
+
+ public var description: String { "Keychain item \(service)" }
+ public var source: CredentialSource { ProviderID.gemini.credentialSource("gemini.keychain") }
+
+ public func load() throws -> GeminiAuth? {
+ guard let item = try keychain.load(service: service, account: Self.account) else { return nil }
+ return try Self.parse(item.data)
+ }
+
+ public func save(_ auth: GeminiAuth) throws {
+ try keychain.save(
+ try JSONEncoder().encode(Self.document(for: auth, updatedAt: Date())), service: service,
+ account: Self.account)
+ }
+
+ static func parse(_ data: Data) throws -> GeminiAuth? {
+ guard let document = try? JSONDecoder().decode(JSONValue.self, from: data) else {
+ throw CredentialStoreError.malformed("Gemini Keychain item is not JSON")
+ }
+ return GeminiAuth(document: document)
+ }
+
+ static func document(for auth: GeminiAuth, updatedAt: Date) -> JSONValue {
+ if auth.document["token"]?.objectValue != nil {
+ var document = auth.document.merging("updatedAt", .number(updatedAt.timeIntervalSince1970 * 1000))
+ if document["serverName"] == nil { document = document.merging("serverName", .string(Self.account)) }
+ return document
+ }
+ var token: [String: JSONValue] = [
+ "accessToken": .string(auth.accessToken),
+ "tokenType": .string("Bearer"),
+ ]
+ token["refreshToken"] = auth.refreshToken.map(JSONValue.string)
+ token["idToken"] = auth.idToken.map(JSONValue.string)
+ token["expiresAt"] = auth.expiresAt.map { .number($0.timeIntervalSince1970 * 1000) }
+ return .object([
+ "serverName": .string(Self.account),
+ "token": .object(token),
+ "updatedAt": .number(updatedAt.timeIntervalSince1970 * 1000),
+ ])
+ }
+}
+
+public struct ChainedGeminiAuthStore: GeminiAuthStore {
+ public let stores: [any GeminiAuthStore]
+
+ public init(_ stores: [any GeminiAuthStore]) {
+ self.stores = stores
+ }
+
+ public var description: String { stores.map(\.description).joined(separator: ", ") }
+
+ public var source: CredentialSource {
+ CredentialSource(
+ id: "gemini.automatic", provider: .gemini, title: "Gemini credentials",
+ detail: stores.map(\.source.title).joined(separator: ", "))
+ }
+
+ public func load() throws -> GeminiAuth? {
+ try loadWithSource()?.auth
+ }
+
+ public func loadWithSource() throws -> (auth: GeminiAuth, source: CredentialSource)? {
+ var firstError: CredentialReadFailure?
+ for store in stores {
+ do {
+ if let found = try store.loadWithSource() { return found }
+ } catch {
+ firstError = firstError ?? CredentialReadFailure(source: store.source, error: error)
+ }
+ }
+ if let firstError { throw firstError }
+ return nil
+ }
+
+ public func save(_ auth: GeminiAuth) throws {
+ let target = stores.first { (try? $0.load()) != nil } ?? stores.first
+ guard let target else { return }
+ try target.save(auth)
+ }
+}
+
+public enum GeminiCredentialStorage: Sendable, Equatable {
+ case file
+ case keychain
+
+ public static func resolve(environment: [String: String]) -> GeminiCredentialStorage {
+ environment["GEMINI_FORCE_ENCRYPTED_FILE_STORAGE"] == "true" ? .keychain : .file
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift b/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift
new file mode 100644
index 0000000..8daa185
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift
@@ -0,0 +1,35 @@
+import Foundation
+
+public struct KeychainCredentialItem: Sendable, Equatable {
+ public let data: Data
+ public let account: String?
+
+ public init(data: Data, account: String?) {
+ self.data = data
+ self.account = account
+ }
+}
+
+public struct KeychainCredentialClient: Sendable {
+ private let loadValue: @Sendable (String, String?) throws -> KeychainCredentialItem?
+ private let saveValue: @Sendable (Data, String, String) throws -> Void
+
+ public init(
+ load: @escaping @Sendable (String, String?) throws -> KeychainCredentialItem?,
+ save: @escaping @Sendable (Data, String, String) throws -> Void
+ ) {
+ loadValue = load
+ saveValue = save
+ }
+
+ public func load(service: String, account: String? = nil) throws -> KeychainCredentialItem? {
+ try loadValue(service, account)
+ }
+
+ public func save(_ data: Data, service: String, account: String) throws {
+ try saveValue(data, service, account)
+ }
+
+ public static let empty = KeychainCredentialClient(load: { _, _ in nil }, save: { _, _, _ in })
+ public static let system = KeychainCredentialClient(load: systemKeychainLoad, save: systemKeychainSave)
+}
diff --git a/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift b/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift
new file mode 100644
index 0000000..c0a4b78
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift
@@ -0,0 +1,37 @@
+import Foundation
+import Security
+
+func systemKeychainLoad(service: String, account: String?) throws -> KeychainCredentialItem? {
+ var query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecReturnAttributes as String: true,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ query[kSecAttrAccount as String] = account
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
+ guard status != errSecItemNotFound else { return nil }
+ guard status == errSecSuccess, let values = item as? [String: Any],
+ let data = values[kSecValueData as String] as? Data
+ else { throw CredentialStoreError.keychain(status) }
+ return KeychainCredentialItem(data: data, account: values[kSecAttrAccount as String] as? String)
+}
+
+func systemKeychainSave(data: Data, service: String, account: String) throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ ]
+ let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
+ if status == errSecItemNotFound {
+ var addQuery = query
+ addQuery[kSecValueData as String] = data
+ let added = SecItemAdd(addQuery as CFDictionary, nil)
+ guard added == errSecSuccess else { throw CredentialStoreError.keychain(added) }
+ return
+ }
+ guard status == errSecSuccess else { throw CredentialStoreError.keychain(status) }
+}
diff --git a/Sources/TokenMenuBarCore/Demo/DemoData.swift b/Sources/TokenMenuBarCore/Demo/DemoData.swift
new file mode 100644
index 0000000..863cd7b
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Demo/DemoData.swift
@@ -0,0 +1,329 @@
+import Foundation
+
+public enum DemoData {
+ public static let email = "you@example.com"
+ public static let historyDays = 30
+ static let seedInterval: TimeInterval = 1800
+
+ public static func snapshot(
+ _ provider: ProviderID, now: Date, fixture: VerificationProfile.Fixture = .standard
+ ) -> ProviderSnapshot {
+ let snapshot =
+ switch provider {
+ case .claude: claude(now: now)
+ case .codex: codex(now: now)
+ case .gemini: gemini(now: now)
+ case .cursor: cursor(now: now)
+ case .copilot: copilot(now: now)
+ }
+ return fixture == .longText ? longText(snapshot) : snapshot
+ }
+
+ public static func analytics(
+ _ provider: ProviderID, now: Date, days: Int, fixture: VerificationProfile.Fixture = .standard
+ ) -> ProviderAnalytics? {
+ guard provider == .claude || provider == .codex else { return nil }
+ let stamps = (0.. Double {
+ 0.55 + 0.45 * sin(Double(dayIndex) * 1.7 + 0.4) * sin(Double(dayIndex) * 0.6)
+ }
+
+ static func claude(now: Date) -> ProviderSnapshot {
+ let session = window(
+ id: "session", label: "Current session", group: .session, duration: 5 * 3600, offset: 0, pace: 1.15, now: now)
+ let weekly = window(
+ id: "weekly", label: "All models", group: .weekly, duration: 7 * 86400, offset: 3 * 86400 + 8 * 3600, pace: 0.9,
+ now: now)
+ let fable = window(
+ id: "weekly:fable", label: "Fable", group: .weekly, duration: 7 * 86400, offset: 3 * 86400 + 8 * 3600, pace: 1.05,
+ now: now, scope: "Fable")
+ let sonnet = window(
+ id: "weekly:sonnet", label: "Sonnet", group: .weekly, duration: 7 * 86400, offset: 3 * 86400 + 8 * 3600,
+ pace: 0.4, now: now, scope: "Sonnet")
+ let monthReset = boundary(now: now, duration: 30 * 86400, offset: 12 * 86400)
+ return ProviderSnapshot(
+ provider: .claude,
+ identity: ProviderIdentity(planName: "Max 20x", tier: "default_claude_max_20x", email: email),
+ windows: [session, weekly, fable, sonnet],
+ spend: SpendControl(
+ enabled: true, canToggle: true, used: Money(amountMinor: 1240, currency: "USD"),
+ limit: Money(amountMinor: 5000, currency: "USD"), percent: 24.8, resetsAt: monthReset,
+ balance: Money(amountMinor: 3760, currency: "USD"), autoReload: false, canPurchaseCredits: true),
+ notices: [Notice(kind: .promotion, text: "Weekly limits are boosted 2x until the next reset.")],
+ localUsage: LocalUsage(
+ windowTokens: Int(1_840_000 * session.usedPercent / 60), windowCost: 22.4 * session.usedPercent / 60,
+ costPerHour: 8.1, todayTokens: 6_200_000, todayCost: 71.3, todayMessages: 214),
+ fetchedAt: now
+ )
+ }
+
+ static func codex(now: Date) -> ProviderSnapshot {
+ let session = window(
+ id: "session", label: "5-hour", group: .session, duration: 5 * 3600, offset: 0, pace: 0.7, now: now)
+ let weekly = window(
+ id: "weekly", label: "Weekly", group: .weekly, duration: 7 * 86400, offset: 5 * 86400 + 14 * 3600, pace: 0.95,
+ now: now)
+ let spark = window(
+ id: "additional:gpt-5.3-codex-spark:weekly", label: "GPT-5.3-Codex-Spark Weekly", group: .weekly,
+ duration: 7 * 86400, offset: 5 * 86400 + 10 * 3600, pace: 0.2, now: now)
+ let review = window(
+ id: "code_review", label: "Code review", group: .other, duration: 7 * 86400, offset: 2 * 86400, pace: 0.35,
+ now: now)
+ return ProviderSnapshot(
+ provider: .codex,
+ identity: ProviderIdentity(
+ planName: "Pro", email: email, subscriptionActiveUntil: now.addingTimeInterval(40 * 86400)),
+ windows: [session, weekly, spark, review],
+ credits: CreditBalance(
+ balance: 42.5, hasCredits: true, approxLocalMessages: 120...260, approxCloudMessages: 60...130),
+ spend: SpendControl(enabled: true, limit: Money(amountMinor: 20000, currency: "USD")),
+ resetCredits: ResetCredits(available: 1, applicable: 1, totalEarned: 3),
+ notices: [Notice(kind: .promotion, text: "Usage limits are doubled through the end of the month.")],
+ fetchedAt: now
+ )
+ }
+
+ static func gemini(now: Date) -> ProviderSnapshot {
+ let pro = window(
+ id: "model:gemini-2.5-pro", label: "Gemini 2.5 Pro", group: .other, duration: 86400, offset: 7 * 3600, pace: 0.9,
+ now: now)
+ let flash = window(
+ id: "model:gemini-2.5-flash", label: "Gemini 2.5 Flash", group: .other, duration: 86400, offset: 7 * 3600,
+ pace: 0.3, now: now)
+ return ProviderSnapshot(
+ provider: .gemini, identity: ProviderIdentity(planName: "Google AI Pro", email: email), windows: [pro, flash],
+ credits: CreditBalance(balance: 1500, hasCredits: true), fetchedAt: now)
+ }
+
+ static func cursor(now: Date) -> ProviderSnapshot {
+ let plan = window(
+ id: "plan", label: "Plan usage", group: .monthly, duration: 30 * 86400, offset: 0, pace: 0.8, now: now)
+ let onDemand = window(
+ id: "on_demand", label: "On-demand", group: .monthly, duration: 30 * 86400, offset: 0, pace: 0.25, now: now)
+ return ProviderSnapshot(
+ provider: .cursor, identity: ProviderIdentity(planName: "Pro", email: email), windows: [plan, onDemand],
+ spend: SpendControl(
+ enabled: true, used: Money(amountMinor: Int(onDemand.usedPercent * 50), currency: "USD"),
+ limit: Money(amountMinor: 5000, currency: "USD"), percent: onDemand.usedPercent, resetsAt: onDemand.resetsAt),
+ fetchedAt: now)
+ }
+
+ static func copilot(now: Date) -> ProviderSnapshot {
+ let premium = window(
+ id: "premium_interactions", label: "Premium requests", group: .monthly, duration: 30 * 86400, offset: 0,
+ pace: 1.1, now: now)
+ return ProviderSnapshot(
+ provider: .copilot, identity: ProviderIdentity(planName: "Pro", email: "octocat"), windows: [premium],
+ notices: [Notice(kind: .info, text: "Chat and completions are unlimited on this plan.")], fetchedAt: now)
+ }
+
+ static func window(
+ id: String, label: String, group: WindowGroup, duration: TimeInterval, offset: TimeInterval, pace: Double,
+ now: Date, scope: String? = nil
+ ) -> QuotaWindow {
+ let resetsAt = boundary(now: now, duration: duration, offset: offset)
+ let start = resetsAt.addingTimeInterval(-duration)
+ return QuotaWindow(
+ id: id, label: label, group: group,
+ usedPercent: min(100, max(0, burned(from: start, to: now, of: duration) * pace * 100)), resetsAt: resetsAt,
+ duration: duration, scope: scope)
+ }
+
+ /// Quota burns while someone works, so the demo curve climbs through office hours and flattens overnight and at
+ /// weekends. A flat ramp reads as synthetic the moment it lands on a chart. Sampling a fixed number of steps keeps
+ /// this cheap enough to run for every point the seeded history holds.
+ static func burned(from start: Date, to now: Date, of duration: TimeInterval) -> Double {
+ let step = max(duration / 48, 900)
+ var spent = 0.0
+ var total = 0.0
+ for offset in stride(from: 0.0, to: duration, by: step) {
+ let moment = start.addingTimeInterval(offset)
+ let weight = intensity(at: moment)
+ total += weight
+ if moment <= now { spent += weight }
+ }
+ return total > 0 ? spent / total : 0
+ }
+
+ /// Hour and weekday from the epoch rather than Calendar: the seed asks for this hundreds of thousands of times.
+ static func intensity(at date: Date) -> Double {
+ let seconds = date.timeIntervalSince1970
+ let day = (seconds / 86400).rounded(.down)
+ let hour = (seconds - day * 86400) / 3600
+ let workday =
+ switch hour {
+ case 9..<12, 14..<18: 1.0
+ case 12..<14: 0.55
+ case 18..<22: 0.35
+ case 8..<9: 0.5
+ default: 0.05
+ }
+ // 1 January 1970 was a Thursday, so index 0 lands on Sunday
+ let weekday = Int(day.truncatingRemainder(dividingBy: 7) + 4).quotientAndRemainder(dividingBy: 7).remainder
+ let weekend = weekday == 0 || weekday == 6
+ return workday * (weekend ? 0.3 : 1)
+ }
+
+ static func boundary(now: Date, duration: TimeInterval, offset: TimeInterval) -> Date {
+ let elapsed = now.timeIntervalSince1970 - offset
+ return Date(timeIntervalSince1970: (floor(elapsed / duration) + 1) * duration + offset)
+ }
+
+ public static func seed(
+ _ history: UsageHistoryStore, providers: [ProviderID], now: Date,
+ fixture: VerificationProfile.Fixture = .standard
+ ) async throws {
+ let start = now.addingTimeInterval(-Double(historyDays) * 86400)
+ var snapshots: [(ProviderSnapshot, Date)] = []
+ for provider in providers {
+ for stamp in stride(from: start, to: now, by: seedInterval) {
+ snapshots.append((snapshot(provider, now: stamp, fixture: fixture), stamp))
+ }
+ if let analytics = analytics(provider, now: now, days: historyDays, fixture: fixture) {
+ try await history.record(analytics)
+ }
+ }
+ try await history.seed(snapshots)
+ }
+
+ private static func longText(_ snapshot: ProviderSnapshot) -> ProviderSnapshot {
+ let suffix = "verification-fixture-with-a-deliberately-long-identifier"
+ let identity = snapshot.identity.map {
+ ProviderIdentity(
+ planName: "\($0.planName) plan for a large multi-team organization",
+ tier: $0.tier.map { "\($0)-\(suffix)" },
+ email: "automation-account-with-a-long-address@engineering.example.com",
+ organization: "Example Engineering Platform and Developer Experience Organization",
+ subscriptionActiveUntil: $0.subscriptionActiveUntil)
+ }
+ let windows = snapshot.windows.map {
+ QuotaWindow(
+ id: "\($0.id)-\(suffix)", label: "\($0.label) usage window with a deliberately long model name",
+ group: $0.group, usedPercent: $0.usedPercent, resetsAt: $0.resetsAt, duration: $0.duration,
+ severity: $0.severity, isActive: $0.isActive,
+ scope: $0.scope.map { "\($0) model scope with a deliberately long identifier" })
+ }
+ let notices =
+ snapshot.notices + [
+ Notice(
+ kind: .info,
+ text:
+ "Verification warning text is intentionally long so wrapping, accessibility values, and panel sizing "
+ + "can be checked without exposing account data."
+ )
+ ]
+ return ProviderSnapshot(
+ provider: snapshot.provider, identity: identity, windows: windows, credits: snapshot.credits,
+ spend: snapshot.spend, resetCredits: snapshot.resetCredits, notices: notices,
+ localUsage: snapshot.localUsage, source: snapshot.source, fetchedAt: snapshot.fetchedAt)
+ }
+}
+
+public struct DemoProvider: UsageProvider {
+ public let id: ProviderID
+ public let fixture: VerificationProfile.Fixture
+ public let pollingPolicy = PollingPolicy(minimumInterval: 60, activeInterval: 60, defaultInterval: 60)
+
+ public init(id: ProviderID, fixture: VerificationProfile.Fixture = .standard) {
+ self.id = id
+ self.fixture = fixture
+ }
+
+ public var credentialDescription: String {
+ fixture == .longText
+ ? "/private/tmp/token-menu-bar-verification/credentials/\(id.rawValue)"
+ + "/account-profile-with-a-deliberately-long-file-name.json"
+ : "Demo data"
+ }
+
+ public func credentialState(now: Date) -> CredentialState {
+ .valid(expiresAt: nil)
+ }
+
+ public func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult {
+ ProviderFetchResult(
+ outcome: .success(DemoData.snapshot(id, now: now, fixture: fixture)),
+ analytics: options.includeAnalytics
+ ? DemoData.analytics(id, now: now, days: options.analyticsDays, fixture: fixture) : nil,
+ recoveryIssue: fixture == .controlAudit ? controlAuditIssue : nil
+ )
+ }
+
+ private var controlAuditIssue: ProviderRecoveryIssue {
+ switch id {
+ case .claude:
+ ProviderRecoveryIssue(
+ kind: .resourceAccess, title: "File access needed",
+ detail: "Grant access to the isolated Claude verification directory.",
+ action: .grantAccess(id.sandboxResources[0]))
+ case .codex:
+ ProviderRecoveryIssue(
+ kind: .credentialExpired, title: "Codex sign-in expired",
+ detail: "Run the deterministic verification command.", action: .copyCommand("codex login"))
+ case .gemini:
+ ProviderRecoveryIssue(
+ kind: .network, title: "Gemini check required",
+ detail: "Check the isolated verification provider again.", action: .checkAgain)
+ case .cursor:
+ ProviderRecoveryIssue(
+ kind: .service, title: "Cursor check required",
+ detail: "Refresh only the isolated verification provider.", action: .refreshProvider(.cursor))
+ case .copilot:
+ ProviderRecoveryIssue(
+ kind: .accountUnsupported, title: "Copilot administrator check required",
+ detail: "Copy the deterministic administrator guidance.", action: .contactAdministrator)
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Diagnostics.swift b/Sources/TokenMenuBarCore/Diagnostics.swift
new file mode 100644
index 0000000..2a58ac7
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Diagnostics.swift
@@ -0,0 +1,166 @@
+import Foundation
+
+public enum DistributionChannel: String, CaseIterable, Codable, Sendable {
+ case direct
+ case appStore
+ case homebrew
+
+ public init?(configurationValue: String) {
+ switch configurationValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
+ case "direct": self = .direct
+ case "app store", "appstore": self = .appStore
+ case "homebrew": self = .homebrew
+ default: return nil
+ }
+ }
+
+ public var displayName: String {
+ switch self {
+ case .direct: "Direct"
+ case .appStore: "App Store"
+ case .homebrew: "Homebrew"
+ }
+ }
+
+ public var isAppStore: Bool { self == .appStore }
+ public var allowsSelfUpdate: Bool { self == .direct }
+}
+
+public struct AppInfo: Sendable, Equatable {
+ public let name: String
+ public let version: String
+ /// The git-derived version, `1.2.4.dev5+gabc123` off a tag, so a bug report from a dev build names its commit.
+ public let sourceVersion: String
+ public let build: String
+ public let bundleIdentifier: String
+ public let distribution: DistributionChannel
+ public let selfUpdateEnabled: Bool
+ public let repository: URL
+
+ public init(
+ name: String, version: String, sourceVersion: String? = nil, build: String, bundleIdentifier: String,
+ distribution: DistributionChannel, selfUpdateEnabled: Bool = false, repository: URL
+ ) {
+ self.name = name
+ self.version = version
+ self.sourceVersion = sourceVersion ?? version
+ self.build = build
+ self.bundleIdentifier = bundleIdentifier
+ self.distribution = distribution
+ self.selfUpdateEnabled = selfUpdateEnabled
+ self.repository = repository
+ }
+
+ public init(
+ name: String, version: String, sourceVersion: String? = nil, build: String, bundleIdentifier: String,
+ isAppStore: Bool, repository: URL
+ ) {
+ self.init(
+ name: name, version: version, sourceVersion: sourceVersion, build: build, bundleIdentifier: bundleIdentifier,
+ distribution: isAppStore ? .appStore : .direct, repository: repository)
+ }
+
+ /// True when this build came from somewhere other than a release tag, which is worth saying out loud in About.
+ public var isPrerelease: Bool {
+ sourceVersion.contains(".dev")
+ }
+
+ public var isAppStore: Bool { distribution.isAppStore }
+ public var canSelfUpdate: Bool { distribution.allowsSelfUpdate && selfUpdateEnabled }
+
+ public static let repositoryURL = URL(string: "https://github.com/tox-dev/token-menu-bar-macos")!
+
+ public static func from(bundle: Bundle, isAppStore: Bool) -> AppInfo {
+ from(bundle: bundle, distribution: isAppStore ? .appStore : .direct)
+ }
+
+ public static func from(bundle: Bundle, distribution fallback: DistributionChannel) -> AppInfo {
+ let info = bundle.infoDictionary ?? [:]
+ return AppInfo(
+ name: info["CFBundleName"] as? String ?? "Token Menu Bar",
+ version: info["CFBundleShortVersionString"] as? String ?? "0.0.0",
+ sourceVersion: info["TMBSourceVersion"] as? String,
+ build: info["CFBundleVersion"] as? String ?? "0",
+ bundleIdentifier: bundle.bundleIdentifier ?? "dev.tox.token-menu-bar",
+ distribution: (info["TMBDistribution"] as? String).flatMap(DistributionChannel.init(configurationValue:))
+ ?? fallback,
+ selfUpdateEnabled: (info["TMBSelfUpdateEnabled"] as? String)?.localizedCaseInsensitiveCompare("YES")
+ == .orderedSame,
+ repository: repositoryURL
+ )
+ }
+
+ public var releasesURL: URL {
+ repository.appendingPathComponent("releases")
+ }
+}
+
+public enum Diagnostics {
+ public static let maxIssueURLLength = 8000
+ public static let logLines = 80
+
+ @MainActor
+ public static func report(
+ app: AppInfo, osVersion: String, settings: Settings, state: AppState, historyLocation: URL?, log: LogBuffer,
+ now: Date, lines: Int = logLines
+ ) -> String {
+ var out: [String] = []
+ out.append("\(app.name) \(app.sourceVersion) (\(app.build)) \(app.distribution.displayName)")
+ out.append("macOS \(osVersion)")
+ out.append(
+ "Refresh "
+ + ProviderID.allCases.map { "\($0.rawValue) \(settings.refreshInterval(for: $0))s" }
+ .joined(separator: ", ")
+ + ", analytics every \(settings.analyticsRefreshMinutes)m, format \(settings.statusFormat.rawValue)"
+ )
+ let activeProviders = settings.activeProviders(states: state.providers)
+ out.append("Providers: \(activeProviders.map(\.rawValue).sorted().joined(separator: ", "))")
+ out.append("History: \(historyLocation?.path ?? "in memory")")
+ out.append("Last refresh: \(state.lastRefresh.map { Format.relativeAge($0, now: now) } ?? "never")")
+ for provider in state.orderedProviders where activeProviders.contains(provider) {
+ let item = state.state(for: provider)
+ out.append(
+ "- \(provider.displayName): \(item.availability.rawValue), "
+ + "plan \(item.snapshot?.identity?.planName ?? "-"), windows \(windowSummary(item))"
+ )
+ if let error = item.lastError { out.append(" error: \(error)") }
+ if let credential = item.credentialState { out.append(" credentials: \(credential.description)") }
+ }
+ out.append("")
+ out.append("Log (last \(lines) lines):")
+ out += log.tail(lines).map(\.line)
+ return LogSanitizer.redact(out.joined(separator: "\n"))
+ }
+
+ static func windowSummary(_ state: ProviderState) -> String {
+ guard let windows = state.snapshot?.windows, !windows.isEmpty else { return "-" }
+ return windows.map { "\($0.id)=\(Format.percent($0.usedPercent))" }.joined(separator: " ")
+ }
+
+ public static func issueURL(repository: URL, title: String, report: String) -> URL {
+ let lines = LogSanitizer.redact(report).split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ func candidate(_ count: Int) -> URL {
+ build(repository: repository, title: title, body: lines.prefix(count).joined(separator: "\n"))
+ }
+ var low = 0
+ var high = lines.count
+ while low < high {
+ let mid = (low + high + 1) / 2
+ if candidate(mid).absoluteString.count <= maxIssueURLLength {
+ low = mid
+ } else {
+ high = mid - 1
+ }
+ }
+ return candidate(low)
+ }
+
+ static func build(repository: URL, title: String, body: String) -> URL {
+ var components = URLComponents(
+ url: repository.appendingPathComponent("issues/new"), resolvingAgainstBaseURL: false)!
+ components.queryItems = [
+ URLQueryItem(name: "title", value: title), URLQueryItem(name: "body", value: "```\n\(body)\n```"),
+ ]
+ return components.url!
+ }
+}
diff --git a/Sources/TokenMenuBarCore/ExportCommand.swift b/Sources/TokenMenuBarCore/ExportCommand.swift
new file mode 100644
index 0000000..6f80a5f
--- /dev/null
+++ b/Sources/TokenMenuBarCore/ExportCommand.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+public enum ExportCommand: String, CaseIterable, Sendable {
+ case icons = "--export-icon"
+ case menuBar = "--export-menubar"
+ case popover = "--export-popover"
+
+ public static func parse(_ arguments: [String]) -> (command: ExportCommand, directory: URL)? {
+ for command in allCases {
+ guard let index = arguments.firstIndex(of: command.rawValue), index + 1 < arguments.count else { continue }
+ return (command, URL(fileURLWithPath: arguments[index + 1]))
+ }
+ return nil
+ }
+
+ public var failureMessage: String {
+ switch self {
+ case .icons: "icon export failed"
+ case .menuBar: "menu bar export failed"
+ case .popover: "popover export failed"
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Formatting.swift b/Sources/TokenMenuBarCore/Formatting.swift
new file mode 100644
index 0000000..f6e9b62
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Formatting.swift
@@ -0,0 +1,106 @@
+import Foundation
+
+public enum Format {
+ // `Calendar.current` rebuilds the calendar on every read, and every quota window on screen asks for a reset time
+ // once a second while the popover is open. The autoupdating one is as cheap to hold but follows the user across a
+ // time zone change, which an app that runs for weeks will see.
+ public static let calendar = Calendar.autoupdatingCurrent
+
+ public static func percent(_ value: Double, decimals: Int = 0) -> String {
+ min(max(value, 0), 100).formatted(.number.precision(.fractionLength(decimals))) + "%"
+ }
+
+ public static func countdown(to date: Date?, now: Date) -> String {
+ guard let date else { return "—" }
+ let seconds = date.timeIntervalSince(now)
+ guard seconds > 0 else { return "reset due" }
+ let minutes = Int(seconds / 60)
+ let days = minutes / 1440
+ let hours = (minutes % 1440) / 60
+ let mins = minutes % 60
+ if days > 0 { return "\(days)d \(hours)h" }
+ if hours > 0 { return "\(hours) hr \(mins) min" }
+ if minutes > 0 { return "\(mins) min" }
+ return "< 1 min"
+ }
+
+ public static func compactCountdown(to date: Date?, now: Date) -> String {
+ guard let date else { return "--" }
+ let seconds = date.timeIntervalSince(now)
+ guard seconds > 0 else { return "0m" }
+ let minutes = Int(seconds / 60)
+ let days = minutes / 1440
+ let hours = (minutes % 1440) / 60
+ let mins = minutes % 60
+ if days > 0 { return "\(days)d\(hours)h" }
+ if hours > 0 { return "\(hours)h\(String(format: "%02d", mins))m" }
+ return "\(mins)m"
+ }
+
+ public static func resetClock(_ date: Date?, now: Date, calendar: Calendar = Format.calendar) -> String {
+ guard let date else { return "—" }
+ guard date > now else { return "now" }
+ if calendar.isDate(date, inSameDayAs: now) { return date.formatted(date: .omitted, time: .shortened) }
+ if date.timeIntervalSince(now) < 6 * 86400 {
+ return date.formatted(.dateTime.weekday(.abbreviated).hour().minute())
+ }
+ return date.formatted(.dateTime.month(.abbreviated).day().hour().minute())
+ }
+
+ public static func compactNumber(_ value: Double) -> String {
+ let (scaled, suffix): (Double, String) =
+ switch abs(value) {
+ case ..<1000: (value, "")
+ case ..<1_000_000: (value / 1000, "K")
+ case ..<1_000_000_000: (value / 1_000_000, "M")
+ default: (value / 1_000_000_000, "B")
+ }
+ return scaled.formatted(.number.precision(.fractionLength(abs(scaled) < 10 && !suffix.isEmpty ? 1 : 0)))
+ + suffix
+ }
+
+ public static func duration(_ seconds: TimeInterval) -> String {
+ let hours = Int(seconds / 3600)
+ if hours >= 24, hours % 24 == 0 { return hours == 24 ? "24h" : "\(hours / 24)d" }
+ if hours > 0 { return "\(hours)h" }
+ return "\(Int(seconds / 60))m"
+ }
+
+ public static func windowLabel(seconds: TimeInterval) -> String {
+ switch seconds {
+ case 18000: "5-hour"
+ case 604_800: "Weekly"
+ case 86400: "Daily"
+ case 2_592_000, 2_678_400: "Monthly"
+ default: duration(seconds)
+ }
+ }
+
+ public static func humanize(_ key: String) -> String {
+ key.split(whereSeparator: { $0 == "_" || $0 == "-" }).map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined(
+ separator: " ")
+ }
+
+ public static func slug(_ text: String) -> String {
+ var slug = ""
+ for character in text.lowercased() {
+ if character.isASCII, character.isLetter || character.isNumber {
+ slug.append(character)
+ } else if slug.last != "-" {
+ slug.append("-")
+ }
+ }
+ if slug.last == "-" { slug.removeLast() }
+ return slug.hasPrefix("-") ? String(slug.dropFirst()) : slug
+ }
+
+ public static func relativeAge(_ date: Date?, now: Date) -> String {
+ guard let date else { return "never" }
+ let seconds = max(now.timeIntervalSince(date), 0)
+ if seconds < 5 { return "just now" }
+ if seconds < 60 { return "\(Int(seconds))s ago" }
+ if seconds < 3600 { return "\(Int(seconds / 60)) min ago" }
+ if seconds < 86400 { return "\(Int(seconds / 3600)) hr ago" }
+ return "\(Int(seconds / 86400)) d ago"
+ }
+}
diff --git a/Sources/TokenMenuBarCore/HTTP/APIClient.swift b/Sources/TokenMenuBarCore/HTTP/APIClient.swift
new file mode 100644
index 0000000..b4b1947
--- /dev/null
+++ b/Sources/TokenMenuBarCore/HTTP/APIClient.swift
@@ -0,0 +1,207 @@
+import Foundation
+
+public protocol HTTPTransport: Sendable {
+ func data(for request: URLRequest) async throws -> (Data, URLResponse)
+}
+
+extension URLSession: HTTPTransport {}
+
+public struct DisabledHTTPTransport: HTTPTransport {
+ public init() {}
+
+ public func data(for _: URLRequest) async throws -> (Data, URLResponse) {
+ throw URLError(.unsupportedURL)
+ }
+}
+
+public enum APIError: Error, Equatable, Sendable {
+ case http(status: Int, body: String, retryAfter: TimeInterval?)
+ case network(String)
+ case decoding(String)
+
+ public var isAuthenticationFailure: Bool {
+ if case .http(let status, _, _) = self { return status == 401 || status == 403 }
+ return false
+ }
+
+ public var isRateLimited: Bool {
+ if case .http(let status, _, _) = self { return status == 429 }
+ return false
+ }
+
+ public var retryAfter: TimeInterval? {
+ if case .http(_, _, let retryAfter) = self { return retryAfter }
+ return nil
+ }
+
+ public var message: String {
+ switch self {
+ case .http(let status, _, _): "HTTP \(status)"
+ case .network(let text): "Network error: \(LogSanitizer.message(text))"
+ case .decoding(let text): "Unexpected response: \(LogSanitizer.message(text))"
+ }
+ }
+}
+
+public struct APIClient: Sendable {
+ static let bodySnippetLength = 200
+ static let liveCacheCapacity = 4 * 1024 * 1024
+ public static let timeout: TimeInterval = 20
+
+ private let transport: any HTTPTransport
+ private let log: LogBuffer
+ private let clock: Clock
+ private let decoder: JSONDecoder
+
+ public init(transport: any HTTPTransport, log: LogBuffer, clock: Clock = .system) {
+ self.transport = transport
+ self.log = log
+ self.clock = clock
+ decoder = JSONDecoder()
+ }
+
+ static func liveConfiguration() -> URLSessionConfiguration {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.httpCookieStorage = nil
+ configuration.urlCredentialStorage = nil
+ configuration.urlCache = URLCache(
+ memoryCapacity: liveCacheCapacity,
+ diskCapacity: 0,
+ diskPath: nil)
+ configuration.requestCachePolicy = .useProtocolCachePolicy
+ return configuration
+ }
+
+ public func get(_ url: URL, headers: [String: String], operation: String) async throws(APIError) -> Data {
+ let request = URLRequest(url: url, timeoutInterval: Self.timeout)
+ return try await send(request, method: "GET", headers: headers, operation: operation)
+ }
+
+ public func post(
+ _ url: URL, json body: Data, headers: [String: String], operation: String
+ ) async throws(APIError) -> Data {
+ var request = URLRequest(url: url, timeoutInterval: Self.timeout)
+ request.httpBody = body
+ return try await send(
+ request, method: "POST", headers: headers.merging(["Content-Type": "application/json"]) { $1 },
+ operation: operation)
+ }
+
+ public func post(
+ _ url: URL, form fields: [String: String], headers: [String: String], operation: String
+ ) async throws(APIError) -> Data {
+ var request = URLRequest(url: url, timeoutInterval: Self.timeout)
+ var components = URLComponents()
+ components.queryItems = fields.keys.sorted().map { URLQueryItem(name: $0, value: fields[$0]) }
+ request.httpBody = Data(components.percentEncodedQuery!.utf8)
+ return try await send(
+ request, method: "POST",
+ headers: headers.merging(["Content-Type": "application/x-www-form-urlencoded"]) { $1 },
+ operation: operation)
+ }
+
+ public func getJSON(
+ _ type: Payload.Type, _ url: URL, headers: [String: String], operation: String
+ ) async throws(APIError) -> Payload {
+ let data = try await get(url, headers: headers, operation: operation)
+ return try decode(type, data, operation: operation)
+ }
+
+ public func decode(
+ _ type: Payload.Type, _ data: Data, operation: String
+ ) throws(APIError) -> Payload {
+ do {
+ return try decoder.decode(type, from: data)
+ } catch {
+ let value = error as NSError
+ log.logDebug(
+ "decode failed operation=\(operation) errorDomain=\(value.domain) errorCode=\(value.code)",
+ category: .network)
+ throw APIError.decoding("\(operation): invalid payload")
+ }
+ }
+
+ private func send(
+ _ base: URLRequest, method: String, headers: [String: String], operation: String
+ ) async throws(APIError) -> Data {
+ var request = base
+ request.httpMethod = method
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
+ let id = String(UUID().uuidString.prefix(8)).lowercased()
+ let endpoint = Self.redact(request.url)
+ let started = clock.now()
+ log.logDebug(
+ "request started operation=\(operation) id=\(id) method=\(method) endpoint=\(endpoint)",
+ category: .network)
+ let data: Data
+ let response: URLResponse
+ do {
+ (data, response) = try await transport.data(for: request)
+ } catch {
+ let duration = Int(clock.now().timeIntervalSince(started) * 1000)
+ let value = error as NSError
+ log.detailed(
+ .request(
+ RequestDiagnostic(
+ requestID: id,
+ operation: operation,
+ method: method,
+ byteCount: 0,
+ durationMilliseconds: duration,
+ error: error)))
+ log.logDebug(
+ "request failed operation=\(operation) id=\(id) endpoint=\(endpoint) "
+ + "errorDomain=\(value.domain) errorCode=\(value.code)",
+ category: .network)
+ throw APIError.network(error.localizedDescription)
+ }
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
+ let duration = Int(clock.now().timeIntervalSince(started) * 1000)
+ log.detailed(
+ .request(
+ RequestDiagnostic(
+ requestID: id,
+ operation: operation,
+ method: method,
+ status: status,
+ byteCount: data.count,
+ durationMilliseconds: duration)))
+ guard (200..<300).contains(status) else {
+ let snippet = String(decoding: data.prefix(Self.bodySnippetLength), as: UTF8.self)
+ log.logDebug(
+ "request rejected operation=\(operation) id=\(id) status=\(status) bytes=\(data.count) "
+ + "duration=\(duration)ms",
+ category: .network)
+ throw APIError.http(
+ status: status, body: snippet, retryAfter: Self.retryAfter(response as? HTTPURLResponse, data))
+ }
+ return data
+ }
+
+ static func retryAfter(_ response: HTTPURLResponse?, _ body: Data) -> TimeInterval? {
+ if let header = response?.value(forHTTPHeaderField: "Retry-After"), let seconds = TimeInterval(header) {
+ return seconds
+ }
+ if let json = try? JSONDecoder().decode(JSONValue.self, from: body), let seconds = json["retry_after"]?.doubleValue
+ {
+ return seconds
+ }
+ return nil
+ }
+
+ static func redact(_ url: URL?) -> String {
+ guard let url, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return "-" }
+ components.query = nil
+ // Assigning back through `components.path` would percent-encode the braces, so replace on the rendered string.
+ return components.string!.split(separator: "/", omittingEmptySubsequences: false).map {
+ isIdentifier($0) ? "{id}" : $0
+ }.joined(separator: "/")
+ }
+
+ static func isIdentifier(_ component: Substring) -> Bool {
+ let groups = component.split(separator: "-", omittingEmptySubsequences: false)
+ guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
+ return groups.allSatisfy { $0.allSatisfy(\.isHexDigit) }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift b/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift
new file mode 100644
index 0000000..5020e1a
--- /dev/null
+++ b/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+public enum SystemHTTPTransport {
+ public static func make() -> any HTTPTransport {
+ URLSession(configuration: APIClient.liveConfiguration())
+ }
+}
diff --git a/Sources/TokenMenuBarCore/History/ChartPipeline.swift b/Sources/TokenMenuBarCore/History/ChartPipeline.swift
new file mode 100644
index 0000000..6df3249
--- /dev/null
+++ b/Sources/TokenMenuBarCore/History/ChartPipeline.swift
@@ -0,0 +1,605 @@
+import Foundation
+
+public enum HistoryRange: String, CaseIterable, Codable, Sendable {
+ case today = "Today"
+ case week = "7d"
+ case month = "30d"
+ case twoMonths = "60d"
+ case custom = "Custom"
+
+ public var days: Int? {
+ switch self {
+ case .today: 1
+ case .week: 7
+ case .month: 30
+ case .twoMonths: 60
+ case .custom: nil
+ }
+ }
+}
+
+public enum Rollup: String, CaseIterable, Codable, Sendable {
+ case minute = "Minute"
+ case hour = "Hour"
+ case day = "Day"
+
+ public var seconds: TimeInterval {
+ switch self {
+ case .minute: 60
+ case .hour: 3600
+ case .day: 86400
+ }
+ }
+}
+
+public struct HistoryRequest: Hashable, Sendable {
+ public let keys: [WindowKey]
+ public let allKeys: [WindowKey]
+ public let start: Date
+ public let end: Date
+ public let rollup: Rollup
+ public let stacked: Bool
+ public let timeZone: TimeZone
+ public let includesEnd: Bool
+
+ public init(
+ keys: [WindowKey], allKeys: [WindowKey]? = nil, start: Date, end: Date, rollup: Rollup, stacked: Bool = false,
+ timeZone: TimeZone = .current, includesEnd: Bool = true
+ ) {
+ self.keys = keys
+ self.allKeys = allKeys ?? keys
+ self.start = start
+ self.end = end
+ self.rollup = rollup
+ self.stacked = stacked
+ self.timeZone = timeZone
+ self.includesEnd = includesEnd
+ }
+}
+
+public struct SeriesPoint: Hashable, Sendable {
+ public let date: Date
+ public let value: Double
+ public let stackBase: Double
+ public let resetsAt: Date?
+ public let segment: Int
+ public let isReset: Bool
+
+ public init(
+ date: Date, value: Double, stackBase: Double = 0, resetsAt: Date? = nil, segment: Int = 0,
+ isReset: Bool = false
+ ) {
+ self.date = date
+ self.value = value
+ self.stackBase = stackBase
+ self.resetsAt = resetsAt
+ self.segment = segment
+ self.isReset = isReset
+ }
+
+ public var stackTop: Double { stackBase + value }
+}
+
+public struct HistorySeries: Hashable, Sendable, Identifiable {
+ public let id: HistorySeriesID
+ public let label: String
+ public let points: [SeriesPoint]
+ public let style: HistoryStyleSlot
+ public let isVisible: Bool
+ public let summaryValue: Double?
+
+ public init(
+ id: HistorySeriesID, label: String, points: [SeriesPoint], style: HistoryStyleSlot = .init(index: 0),
+ isVisible: Bool = true, summaryValue: Double? = nil
+ ) {
+ self.id = id
+ self.label = label
+ self.points = points
+ self.style = style
+ self.isVisible = isVisible
+ self.summaryValue = summaryValue
+ }
+
+ public init(key: WindowKey, label: String, points: [SeriesPoint]) {
+ self.init(id: .window(key), label: label, points: points, summaryValue: points.last?.value)
+ }
+
+ public var key: WindowKey {
+ switch id {
+ case .window(let key): key
+ case .analytics(let provider, let series): WindowKey(provider: provider, windowID: series)
+ }
+ }
+
+ public func value(at date: Date, metric: HistoryMetric = .windowUsagePercent) -> SeriesPoint? {
+ guard !points.isEmpty else { return nil }
+ var lower = 0
+ var upper = points.count
+ while lower < upper {
+ let middle = (lower + upper) / 2
+ if points[middle].date < date { lower = middle + 1 } else { upper = middle }
+ }
+ if lower < points.count, points[lower].date == date { return points[lower] }
+ guard lower > 0, lower < points.count else { return nil }
+ let before = points[lower - 1]
+ let after = points[lower]
+ switch metric.markKind {
+ case .bars:
+ return nil
+ case .stepLine:
+ return SeriesPoint(
+ date: date, value: before.value, resetsAt: before.resetsAt, segment: before.segment)
+ case .line:
+ let duration = after.date.timeIntervalSince(before.date)
+ guard duration > 0 else { return before }
+ let progress = date.timeIntervalSince(before.date) / duration
+ return SeriesPoint(
+ date: date, value: before.value + (after.value - before.value) * progress, segment: before.segment)
+ }
+ }
+}
+
+public struct HistoryChartModel: Hashable, Sendable {
+ public let metric: HistoryMetric
+ public let series: [HistorySeries]
+ public let domain: ClosedRange
+ public let yMax: Double
+ public let timeline: [Date]
+ public let resetEvents: [HistoryResetEvent]
+ public let summaryText: String
+ public let dataPointCount: Int
+
+ public init(
+ metric: HistoryMetric = .windowUsagePercent, series: [HistorySeries], domain: ClosedRange, yMax: Double,
+ timeline: [Date]? = nil, resetEvents: [HistoryResetEvent] = [], summaryText: String = "",
+ dataPointCount: Int? = nil
+ ) {
+ self.metric = metric
+ self.series = series
+ self.domain = domain
+ self.yMax = yMax
+ self.timeline = timeline ?? Array(Set(series.flatMap { $0.points.map(\.date) })).sorted()
+ self.resetEvents = resetEvents
+ self.summaryText = summaryText
+ self.dataPointCount = dataPointCount ?? series.reduce(0) { $0 + $1.points.count }
+ }
+
+ public var visibleSeries: [HistorySeries] { series.filter(\.isVisible) }
+ public var isEmpty: Bool { series.allSatisfy(\.points.isEmpty) }
+
+ public func replacingSeries(_ series: [HistorySeries]) -> HistoryChartModel {
+ HistoryChartModel(
+ metric: metric, series: series, domain: domain, yMax: yMax, timeline: timeline, resetEvents: resetEvents,
+ summaryText: summaryText, dataPointCount: dataPointCount)
+ }
+}
+
+public typealias HistoryRenderData = HistoryChartModel
+
+public enum ChartPipeline {
+ public static let maxPoints = 400
+ public static let maxTotalPoints = 1_200
+
+ public static func render(
+ samples: [UsageSample], request: HistoryRequest, labels: [WindowKey: String], now: Date
+ ) -> HistoryChartModel {
+ let clampedEnd = min(request.end, now)
+ let wanted = Set(request.allKeys)
+ let visible = Set(request.keys)
+ let grouped = Dictionary(grouping: samples.filter { wanted.contains($0.key) }, by: \.key)
+ let minimumCadence = max(request.rollup.seconds, UsageHistoryStore.sampleInterval)
+ var resets: [HistoryResetEvent] = []
+ let lines = request.allKeys.compactMap { key -> HistorySeries? in
+ guard !Task.isCancelled else { return nil }
+ var raw = bucket(grouped[key] ?? [], rollup: request.rollup, timeZone: request.timeZone)
+ let cadence = inferredCadence(in: raw, minimum: minimumCadence)
+ raw = insertResetZeros(raw)
+ raw = clip(raw, start: request.start, end: clampedEnd, cadence: cadence, includesEnd: request.includesEnd)
+ let gapStarts = gapStarts(in: raw, cadence: cadence)
+ raw = changePoints(raw, cadence: cadence)
+ raw = extendFresh(raw, end: clampedEnd, cadence: cadence)
+ raw = downsample(raw, limit: maxPoints, cadence: cadence)
+ guard !raw.isEmpty else { return nil }
+ let segmented = segments(raw, gapStarts: gapStarts)
+ let id = HistorySeriesID.window(key)
+ resets += segmented.compactMap { point in
+ guard point.raw.isReset, let resetsAt = point.raw.resetsAt else { return nil }
+ return HistoryResetEvent(seriesID: id, date: point.raw.date, resetsAt: resetsAt)
+ }
+ let points = segmented.map {
+ SeriesPoint(
+ date: $0.raw.date, value: $0.raw.value, resetsAt: $0.raw.resetsAt, segment: $0.segment,
+ isReset: $0.raw.isReset)
+ }
+ return HistorySeries(
+ id: id, label: labels[key] ?? key.windowID, points: points, isVisible: visible.contains(key),
+ summaryValue: points.last?.value)
+ }
+ let rendered = budgeted(request.stacked ? stack(lines) : lines)
+ let top = rendered.lazy.filter(\.isVisible).flatMap(\.points).map(\.stackTop).max() ?? 0
+ let timeline = Array(Set(rendered.filter(\.isVisible).flatMap { $0.points.map(\.date) })).sorted()
+ return HistoryChartModel(
+ series: rendered, domain: request.start...max(clampedEnd, request.start), yMax: max(100, top), timeline: timeline,
+ resetEvents: resets.sorted { $0.date < $1.date }, summaryText: "\(rendered.count) models",
+ dataPointCount: samples.count {
+ wanted.contains($0.key) && $0.timestamp >= request.start
+ && (request.includesEnd ? $0.timestamp <= clampedEnd : $0.timestamp < clampedEnd)
+ })
+ }
+
+ public static func renderAnalytics(
+ rows: [HistoryAnalyticsRow], metric: HistoryMetric, start: Date, end: Date,
+ hidden: Set = [], stacked: Bool = false
+ ) -> HistoryChartModel {
+ guard case .analytics(let analyticsMetric) = metric else {
+ return HistoryChartModel(metric: metric, series: [], domain: start...max(start, end), yMax: 100)
+ }
+ let filtered = rows.filter { $0.point.metric == analyticsMetric }
+ let detailedDays = Set(
+ filtered.filter { $0.point.series != "total" }.map { "\($0.provider.rawValue):\($0.point.day)" })
+ let chartRows =
+ metric.hasParallelBreakdowns
+ ? filtered.filter {
+ $0.point.series != "total" || !detailedDays.contains("\($0.provider.rawValue):\($0.point.day)")
+ } : filtered
+ let grouped = Dictionary(grouping: chartRows) {
+ HistorySeriesID.analytics(provider: $0.provider, series: $0.point.series)
+ }
+ let series = budgeted(
+ grouped.keys.sorted().compactMap { id -> HistorySeries? in
+ guard !Task.isCancelled else { return nil }
+ guard let values = grouped[id] else { return nil }
+ var byDay: [String: Double] = [:]
+ for row in values { byDay[row.point.day, default: 0] += row.point.value }
+ let dated = byDay.compactMap { day, value in DayStamp.date(day).map { ($0, value) } }.sorted { $0.0 < $1.0 }
+ guard !dated.isEmpty else { return nil }
+ var segment = 0
+ var previous: Date?
+ let points = dated.map { date, value -> SeriesPoint in
+ if metric.markKind != .bars, let previous, date.timeIntervalSince(previous) > 1.5 * Rollup.day.seconds {
+ segment += 1
+ }
+ defer { previous = date }
+ return SeriesPoint(date: date, value: value, segment: segment)
+ }
+ let summary = metric.summaryKind == .sum ? points.reduce(0) { $0 + $1.value } : points.last?.value
+ return HistorySeries(
+ id: id, label: analyticsLabel(id, includeProvider: metric.suppliers.count > 1), points: points,
+ isVisible: !hidden.contains(id), summaryValue: summary)
+ })
+ let visible = series.filter(\.isVisible)
+ let values: [Double]
+ if stacked, metric.supportsStacking {
+ var totals: [Date: Double] = [:]
+ for point in visible.flatMap(\.points) { totals[point.date, default: 0] += point.value }
+ values = Array(totals.values)
+ } else {
+ values = visible.flatMap(\.points).map(\.value)
+ }
+ let yMax = metric.unit == .percentage ? 100 : paddedMaximum(values)
+ let timeline = Array(Set(visible.flatMap { $0.points.map(\.date) })).sorted()
+ return HistoryChartModel(
+ metric: metric, series: series, domain: start...max(start, end), yMax: yMax, timeline: timeline,
+ summaryText: summary(metric: metric, series: visible, rows: filtered, timeline: timeline),
+ dataPointCount: chartRows.count)
+ }
+
+ struct Raw: Hashable {
+ let date: Date
+ let value: Double
+ let resetsAt: Date?
+ let isReset: Bool
+
+ init(date: Date, value: Double, resetsAt: Date?, isReset: Bool = false) {
+ self.date = date
+ self.value = value
+ self.resetsAt = resetsAt
+ self.isReset = isReset
+ }
+ }
+
+ static func bucket(_ samples: [UsageSample], rollup: Rollup, timeZone: TimeZone) -> [Raw] {
+ var latest: [Date: Raw] = [:]
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = timeZone
+ for sample in samples {
+ if Task.isCancelled { break }
+ let bucketStart: Date
+ switch rollup {
+ case .minute: bucketStart = calendar.dateInterval(of: .minute, for: sample.timestamp)!.start
+ case .hour: bucketStart = calendar.dateInterval(of: .hour, for: sample.timestamp)!.start
+ case .day: bucketStart = calendar.startOfDay(for: sample.timestamp)
+ }
+ let candidate = Raw(date: sample.timestamp, value: sample.usedPercent, resetsAt: sample.resetsAt)
+ if let existing = latest[bucketStart], existing.date > candidate.date { continue }
+ latest[bucketStart] = candidate
+ }
+ return latest.values.sorted { $0.date < $1.date }
+ }
+
+ static func insertResetZeros(_ points: [Raw]) -> [Raw] {
+ guard points.count > 1 else { return points }
+ var result = [points[0]]
+ for (previous, current) in zip(points, points.dropFirst()) {
+ if let previousReset = previous.resetsAt, let currentReset = current.resetsAt, currentReset > previousReset,
+ previousReset > previous.date, previousReset < current.date
+ {
+ result.append(Raw(date: previousReset, value: 0, resetsAt: previousReset, isReset: true))
+ } else if let previousReset = previous.resetsAt, let currentReset = current.resetsAt,
+ currentReset > previousReset, current.value < previous.value
+ {
+ result.append(
+ Raw(date: current.date.addingTimeInterval(-1), value: 0, resetsAt: current.date, isReset: true))
+ }
+ result.append(current)
+ }
+ return result
+ }
+
+ static func clip(
+ _ points: [Raw], start: Date, end: Date, cadence: TimeInterval, includesEnd: Bool = true
+ ) -> [Raw] {
+ var inside = points.filter { $0.date >= start && (includesEnd ? $0.date <= end : $0.date < end) }
+ if let last = points.last(where: { $0.date < start }), start.timeIntervalSince(last.date) <= cadence * 1.5,
+ inside.first.map({ $0.date > start }) ?? true
+ {
+ inside.insert(Raw(date: start, value: last.value, resetsAt: last.resetsAt), at: 0)
+ }
+ return inside
+ }
+
+ static func changePoints(_ points: [Raw], cadence: TimeInterval? = nil) -> [Raw] {
+ guard points.count > 2 else { return points }
+ return points.enumerated().compactMap { index, point in
+ let isFirst = index == 0
+ let isLast = index == points.count - 1
+ let next = isLast ? nil : points[index + 1]
+ let previous = isFirst ? nil : points[index - 1]
+ let valueChanges =
+ (previous.map { $0.value != point.value } ?? true) || (next.map { $0.value != point.value } ?? true)
+ let resetBoundary = point.isReset || (point.value == 0 && previous.map { $0.value > 0 } ?? false)
+ let gapBefore =
+ cadence.map { interval in
+ previous.map { point.date.timeIntervalSince($0.date) > interval * 1.5 } ?? false
+ } ?? false
+ let gapAfter =
+ cadence.map { interval in
+ next.map { $0.date.timeIntervalSince(point.date) > interval * 1.5 } ?? false
+ } ?? false
+ return isFirst || isLast || valueChanges || resetBoundary || gapBefore || gapAfter ? point : nil
+ }
+ }
+
+ static func extendToNow(_ points: [Raw], end: Date) -> [Raw] {
+ guard let last = points.last, last.date < end else { return points }
+ return points + [Raw(date: end, value: last.value, resetsAt: last.resetsAt)]
+ }
+
+ static func extendFresh(_ points: [Raw], end: Date, cadence: TimeInterval) -> [Raw] {
+ guard let last = points.last, last.date < end, end.timeIntervalSince(last.date) <= cadence * 1.5 else {
+ return points
+ }
+ return points + [Raw(date: end, value: last.value, resetsAt: last.resetsAt)]
+ }
+
+ static func downsample(_ points: [Raw], limit: Int, cadence: TimeInterval? = nil) -> [Raw] {
+ guard limit > 0 else { return [] }
+ guard points.count > limit else { return points }
+ var protected = Set([points.startIndex, points.index(before: points.endIndex)])
+ protected.formUnion(points.indices.filter { points[$0].isReset })
+ if let cadence {
+ for index in points.indices.dropFirst()
+ where points[index].date.timeIntervalSince(points[index - 1].date) > cadence * 1.5 {
+ protected.insert(index - 1)
+ protected.insert(index)
+ }
+ }
+ let selectedProtected = evenlySpaced(Array(protected).sorted(), limit: min(protected.count, limit))
+ if selectedProtected.count == limit { return selectedProtected.map { points[$0] } }
+ let budget = limit - selectedProtected.count
+ if budget == 1 {
+ let candidate = points.indices.filter { !protected.contains($0) }.max {
+ abs(points[$0].value) < abs(points[$1].value)
+ }
+ let selected = selectedProtected + [candidate!]
+ return selected.sorted().map { points[$0] }
+ }
+ let interior = points.dropFirst().dropLast()
+ let bucketCount = max(budget / 2, 1)
+ let bucketSize = Double(interior.count) / Double(bucketCount)
+ var result: [Raw] = []
+ for bucketIndex in 0.. [HistorySeries] {
+ let limits = pointLimits(series.map { $0.points.count })
+ return zip(series, limits).map { series, limit in
+ guard series.points.count > limit else { return series }
+ let points = downsample(series.points, limit: limit)
+ return HistorySeries(
+ id: series.id, label: series.label, points: points, style: series.style,
+ isVisible: series.isVisible, summaryValue: series.summaryValue)
+ }
+ }
+
+ static func pointLimits(_ counts: [Int]) -> [Int] {
+ let capacities = counts.map { min(max($0, 0), maxPoints) }
+ let populated = capacities.indices.filter { capacities[$0] > 0 }
+ var remaining = max(maxTotalPoints, populated.count)
+ var limits = [Int](repeating: 0, count: counts.count)
+ var active = populated
+ while remaining > 0, !active.isEmpty {
+ let share = max(remaining / active.count, 1)
+ var next: [Int] = []
+ for index in active {
+ guard remaining > 0 else {
+ next.append(index)
+ continue
+ }
+ let grant = min(capacities[index] - limits[index], min(share, remaining))
+ limits[index] += grant
+ remaining -= grant
+ if limits[index] < capacities[index] { next.append(index) }
+ }
+ active = next
+ }
+ return limits
+ }
+
+ private static func downsample(_ points: [SeriesPoint], limit: Int) -> [SeriesPoint] {
+ let raw = points.map { Raw(date: $0.date, value: $0.value, resetsAt: $0.resetsAt, isReset: $0.isReset) }
+ var selected = Dictionary(grouping: downsample(raw, limit: limit), by: { $0 }).mapValues(\.count)
+ return zip(points, raw).compactMap { point, rawPoint in
+ guard let count = selected[rawPoint], count > 0 else { return nil }
+ selected[rawPoint] = count - 1
+ return point
+ }
+ }
+
+ static func stack(_ series: [HistorySeries]) -> [HistorySeries] {
+ let dates = Array(Set(series.filter(\.isVisible).flatMap { $0.points.map(\.date) })).sorted()
+ var bases = [Double](repeating: 0, count: dates.count)
+ return series.map { line in
+ guard line.isVisible else { return line }
+ var cursor = line.points.startIndex
+ var carried = 0.0
+ let points = dates.enumerated().map { index, date -> SeriesPoint in
+ while cursor < line.points.endIndex, line.points[cursor].date <= date {
+ carried = line.points[cursor].value
+ cursor += 1
+ }
+ let point = SeriesPoint(date: date, value: carried, stackBase: bases[index])
+ bases[index] += carried
+ return point
+ }
+ return HistorySeries(
+ id: line.id, label: line.label, points: points, style: line.style, isVisible: true,
+ summaryValue: line.summaryValue)
+ }
+ }
+
+ public static func nearestDate(in data: HistoryChartModel, to date: Date) -> Date? {
+ nearestDate(in: data.timeline, to: date)
+ }
+
+ public static func nearestDate(in dates: [Date], to date: Date) -> Date? {
+ guard !dates.isEmpty else { return nil }
+ var lower = 0
+ var upper = dates.count
+ while lower < upper {
+ let middle = (lower + upper) / 2
+ if dates[middle] < date { lower = middle + 1 } else { upper = middle }
+ }
+ if lower == 0 { return dates[0] }
+ if lower == dates.count { return dates[dates.count - 1] }
+ let before = dates[lower - 1]
+ let after = dates[lower]
+ return date.timeIntervalSince(before) <= after.timeIntervalSince(date) ? before : after
+ }
+
+ public static func dailyBuckets(
+ _ points: [AnalyticsPoint], metric: AnalyticsMetric, topSeries _: Int? = nil
+ ) -> [(day: String, series: String, value: Double)] {
+ var buckets: [String: [String: Double]] = [:]
+ for point in points where point.metric == metric {
+ buckets[point.day, default: [:]][point.series, default: 0] += point.value
+ }
+ return buckets.keys.sorted().flatMap { day in
+ buckets[day]!.keys.sorted().map { (day: day, series: $0, value: buckets[day]![$0]!) }
+ }
+ }
+
+ static func canonicalTotal(_ rows: [HistoryAnalyticsRow], metric _: HistoryMetric) -> Double {
+ let grouped = Dictionary(grouping: rows) { "\($0.provider.rawValue):\($0.point.day)" }
+ return grouped.values.reduce(0) { result, dayRows in
+ let total = dayRows.filter { $0.point.series == "total" }
+ if !total.isEmpty { return result + total.reduce(0) { $0 + $1.point.value } }
+ let surfaces = dayRows.filter { $0.point.series.hasPrefix("surface:") }
+ return result + (surfaces.isEmpty ? dayRows : surfaces).reduce(0) { $0 + $1.point.value }
+ }
+ }
+
+ static func canonicalTotal(_ points: [AnalyticsPoint], metric: HistoryMetric) -> Double {
+ canonicalTotal(points.map { HistoryAnalyticsRow(provider: .codex, point: $0) }, metric: metric)
+ }
+
+ private static func gapStarts(in points: [Raw], cadence: TimeInterval) -> Set {
+ Set(
+ zip(points, points.dropFirst()).compactMap { previous, point in
+ point.date.timeIntervalSince(previous.date) > cadence * 1.5 ? point.date : nil
+ })
+ }
+
+ private static func inferredCadence(in points: [Raw], minimum: TimeInterval) -> TimeInterval {
+ let intervals = zip(points, points.dropFirst()).map { $1.date.timeIntervalSince($0.date) }.filter { $0 > 0 }
+ .sorted()
+ guard !intervals.isEmpty else { return minimum }
+ return max(minimum, intervals[(intervals.count - 1) / 4])
+ }
+
+ private static func segments(_ points: [Raw], gapStarts: Set) -> [(raw: Raw, segment: Int)] {
+ var segment = 0
+ return points.map { point in
+ if gapStarts.contains(point.date) { segment += 1 }
+ return (point, segment)
+ }
+ }
+
+ private static func paddedMaximum(_ values: [Double]) -> Double {
+ guard let maximum = values.max(), maximum > 0 else { return 1 }
+ return maximum * 1.08
+ }
+
+ private static func evenlySpaced(_ values: [Element], limit: Int) -> [Element] {
+ guard values.count > limit, limit > 1 else { return Array(values.prefix(max(limit, 0))) }
+ let last = Double(values.count - 1)
+ return (0.. String {
+ guard case .analytics(let provider, let raw) = id else { return id.storageKey }
+ let label: String
+ if raw.hasPrefix("model:") {
+ label = "Model · \(raw.dropFirst("model:".count))"
+ } else if raw.hasPrefix("surface:") {
+ label = "Surface · \(raw.dropFirst("surface:".count))"
+ } else {
+ label = raw
+ }
+ return includeProvider ? "\(provider.displayName) · \(label)" : label
+ }
+
+ private static func summary(
+ metric: HistoryMetric, series: [HistorySeries], rows: [HistoryAnalyticsRow], timeline: [Date]
+ ) -> String {
+ if metric.summaryKind == .latest {
+ guard let latest = timeline.last else { return "No data in this period" }
+ var style = Date.FormatStyle().month(.abbreviated).day()
+ style.timeZone = TimeZone(secondsFromGMT: 0)!
+ return "\(series.count) series · latest \(latest.formatted(style))"
+ }
+ let total =
+ metric.hasParallelBreakdowns
+ ? canonicalTotal(rows, metric: metric) : series.compactMap(\.summaryValue).reduce(0, +)
+ return switch metric.unit {
+ case .usd: "$\(total.formatted(.number.precision(.fractionLength(2)))) total"
+ case .percentage: Format.percent(total)
+ case .tokens: "\(Format.compactNumber(total)) tokens"
+ case .credits: "\(Format.compactNumber(total)) credits"
+ case .count: "\(Format.compactNumber(total)) total"
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/History/HistoryModels.swift b/Sources/TokenMenuBarCore/History/HistoryModels.swift
new file mode 100644
index 0000000..54f9ea2
--- /dev/null
+++ b/Sources/TokenMenuBarCore/History/HistoryModels.swift
@@ -0,0 +1,279 @@
+import Foundation
+
+public enum HistoryMetricGroup: String, CaseIterable, Sendable {
+ case windows = "Windows"
+ case bothProviders = "Claude and Codex"
+ case claude = "Claude"
+ case codex = "Codex"
+}
+
+public enum HistoryMarkKind: Sendable, Hashable {
+ case stepLine
+ case line
+ case bars
+}
+
+public enum HistoryUnit: Sendable, Hashable {
+ case percentage
+ case tokens
+ case credits
+ case usd
+ case count
+}
+
+public enum HistorySummaryKind: Sendable, Hashable {
+ case latest
+ case sum
+}
+
+public enum HistoryMetric: Sendable, Hashable, Identifiable {
+ case windowUsagePercent
+ case analytics(AnalyticsMetric)
+
+ public static let allCases: [HistoryMetric] =
+ [.windowUsagePercent] + AnalyticsMetric.allCases.map(HistoryMetric.analytics)
+
+ public var id: String {
+ storageID
+ }
+
+ public var storageID: String {
+ switch self {
+ case .windowUsagePercent: "windowUsagePercent"
+ case .analytics(let metric): "analytics:\(metric.rawValue)"
+ }
+ }
+
+ public init?(storageID: String) {
+ if storageID == "windowUsagePercent" {
+ self = .windowUsagePercent
+ return
+ }
+ let prefix = "analytics:"
+ guard storageID.hasPrefix(prefix), let metric = AnalyticsMetric(rawValue: String(storageID.dropFirst(prefix.count)))
+ else { return nil }
+ self = .analytics(metric)
+ }
+
+ public var title: String {
+ switch self {
+ case .windowUsagePercent: "Usage %"
+ case .analytics(let metric): metric.title
+ }
+ }
+
+ public var group: HistoryMetricGroup {
+ switch self {
+ case .windowUsagePercent: .windows
+ case .analytics(.inputTokens), .analytics(.cachedInputTokens), .analytics(.outputTokens): .bothProviders
+ case .analytics(.cacheWriteTokens), .analytics(.costUSD), .analytics(.messages), .analytics(.sessions),
+ .analytics(.toolCalls):
+ .claude
+ case .analytics: .codex
+ }
+ }
+
+ public var suppliers: [ProviderID] {
+ switch group {
+ case .windows: ProviderID.allCases.sorted()
+ case .bothProviders: [.claude, .codex]
+ case .claude: [.claude]
+ case .codex: [.codex]
+ }
+ }
+
+ public var markKind: HistoryMarkKind {
+ switch self {
+ case .windowUsagePercent: .stepLine
+ case .analytics(.surfaceUsagePercent): .line
+ case .analytics: .bars
+ }
+ }
+
+ public var unit: HistoryUnit {
+ switch self {
+ case .windowUsagePercent, .analytics(.surfaceUsagePercent): .percentage
+ case .analytics(.inputTokens), .analytics(.cachedInputTokens), .analytics(.outputTokens),
+ .analytics(.cacheWriteTokens):
+ .tokens
+ case .analytics(.modelCredits), .analytics(.credits): .credits
+ case .analytics(.costUSD): .usd
+ case .analytics: .count
+ }
+ }
+
+ public var summaryKind: HistorySummaryKind {
+ unit == .percentage ? .latest : .sum
+ }
+
+ public var supportsStacking: Bool {
+ switch self {
+ case .analytics(.turns), .analytics(.threads), .analytics(.credits): false
+ default: markKind == .bars
+ }
+ }
+
+ public var hasParallelBreakdowns: Bool {
+ switch self {
+ case .analytics(.turns), .analytics(.threads), .analytics(.credits): true
+ default: false
+ }
+ }
+
+ public var usesDailyUTC: Bool {
+ if case .analytics = self { return true }
+ return false
+ }
+
+ public var attribution: String {
+ switch self {
+ case .windowUsagePercent:
+ "Every model · step line · selected time zone"
+ case .analytics(.inputTokens), .analytics(.cachedInputTokens), .analytics(.outputTokens):
+ "Claude + Codex · Claude by model, Codex total · daily UTC"
+ case .analytics(.surfaceUsagePercent):
+ "Codex · by surface · daily UTC"
+ case .analytics(.modelCredits):
+ "Codex · by model · daily UTC"
+ case .analytics(.turns), .analytics(.threads), .analytics(.credits):
+ "Codex · by model and surface · daily UTC"
+ case .analytics(.skillInvocations):
+ "Codex · by skill · daily UTC"
+ case .analytics(.pluginInvocations):
+ "Codex · by plugin · daily UTC"
+ case .analytics(.codeReviews):
+ "Codex · by review type · daily UTC"
+ case .analytics(.cacheWriteTokens), .analytics(.costUSD):
+ "Claude · by model · daily UTC"
+ case .analytics(.messages), .analytics(.sessions), .analytics(.toolCalls):
+ "Claude · one series · daily UTC"
+ }
+ }
+
+ public func attribution(providers: [ProviderID]) -> String {
+ let active = suppliers.filter(Set(providers).contains)
+ guard !active.isEmpty else { return "No enabled provider data in this period" }
+ let names = active.map(\.displayName).joined(separator: " + ")
+ switch self {
+ case .windowUsagePercent:
+ return "\(names) · enabled models · step line · selected time zone"
+ case .analytics(.inputTokens), .analytics(.cachedInputTokens), .analytics(.outputTokens):
+ let breakdown =
+ active == [.claude, .codex] ? "Claude by model, Codex total" : active == [.claude] ? "by model" : "total"
+ return "\(names) · \(breakdown) · daily UTC"
+ case .analytics:
+ return "\(names) · " + attribution.split(separator: " · ").dropFirst().joined(separator: " · ")
+ }
+ }
+}
+
+public enum HistoryPeriod: Sendable, Hashable, Identifiable {
+ case now
+ case range(HistoryRange)
+
+ public static let allCases: [HistoryPeriod] = [.now] + HistoryRange.allCases.map(HistoryPeriod.range)
+
+ public var id: String {
+ switch self {
+ case .now: "Now"
+ case .range(let range): range.rawValue
+ }
+ }
+
+ public var title: String { id }
+}
+
+public struct HistoryDataScope: Hashable, Sendable {
+ public let activeProviders: Set
+ public let selectedWindows: Set?
+
+ public init(activeProviders: Set, selectedWindows: Set? = nil) {
+ self.activeProviders = activeProviders
+ self.selectedWindows = selectedWindows
+ }
+
+ public static let all = HistoryDataScope(activeProviders: Set(ProviderID.allCases))
+
+ public func includes(_ key: WindowKey) -> Bool {
+ activeProviders.contains(key.provider) && (selectedWindows?.contains(key) ?? true)
+ }
+}
+
+public enum HistorySeriesID: Sendable, Hashable, Comparable {
+ case window(WindowKey)
+ case analytics(provider: ProviderID, series: String)
+
+ public var provider: ProviderID {
+ switch self {
+ case .window(let key): key.provider
+ case .analytics(let provider, _): provider
+ }
+ }
+
+ public var storageKey: String {
+ switch self {
+ case .window(let key): "window:\(key.storageKey)"
+ case .analytics(let provider, let series): "analytics:\(provider.rawValue):\(series)"
+ }
+ }
+
+ public static func < (lhs: HistorySeriesID, rhs: HistorySeriesID) -> Bool {
+ lhs.storageKey < rhs.storageKey
+ }
+}
+
+public struct HistoryStyleSlot: Sendable, Hashable {
+ public let seed: UInt64
+
+ public init(index: Int) {
+ seed = UInt64(max(index, 0))
+ }
+
+ public init(storageKey: String) {
+ var hash: UInt64 = 14_695_981_039_346_656_037
+ for byte in storageKey.utf8 {
+ hash ^= UInt64(byte)
+ hash &*= 1_099_511_628_211
+ }
+ hash ^= hash >> 33
+ hash &*= 0xff51afd7ed558ccd
+ hash ^= hash >> 33
+ seed = hash
+ }
+
+ public var hueIndex: Int { Int(seed % 8) }
+ public var variant: Int { Int((seed / 8) % UInt64(Int.max)) }
+ public var visualIdentity: String { "\(hueIndex):\(variant)" }
+
+ public static func allocate(_ ids: S) -> [HistorySeriesID: HistoryStyleSlot]
+ where S.Element == HistorySeriesID {
+ Dictionary(
+ uniqueKeysWithValues: Set(ids).sorted().enumerated().map { index, id in
+ (id, HistoryStyleSlot(index: index))
+ })
+ }
+}
+
+public struct HistoryResetEvent: Sendable, Hashable, Identifiable {
+ public let seriesID: HistorySeriesID
+ public let date: Date
+ public let resetsAt: Date
+
+ public init(seriesID: HistorySeriesID, date: Date, resetsAt: Date) {
+ self.seriesID = seriesID
+ self.date = date
+ self.resetsAt = resetsAt
+ }
+
+ public var id: String { "\(seriesID.storageKey):\(date.timeIntervalSinceReferenceDate)" }
+}
+
+public struct HistoryAnalyticsRow: Sendable, Hashable {
+ public let provider: ProviderID
+ public let point: AnalyticsPoint
+
+ public init(provider: ProviderID, point: AnalyticsPoint) {
+ self.provider = provider
+ self.point = point
+ }
+}
diff --git a/Sources/TokenMenuBarCore/History/SQLite.swift b/Sources/TokenMenuBarCore/History/SQLite.swift
new file mode 100644
index 0000000..bd6fcf5
--- /dev/null
+++ b/Sources/TokenMenuBarCore/History/SQLite.swift
@@ -0,0 +1,160 @@
+import Foundation
+import SQLite3
+
+public enum SQLiteError: Error, Equatable {
+ case open(String)
+ case prepare(String, String)
+ case step(String, String)
+}
+
+public enum SQLiteValue: Sendable, Equatable {
+ case null
+ case integer(Int64)
+ case real(Double)
+ case text(String)
+
+ init(_ double: Double?) {
+ self = double.map(SQLiteValue.real) ?? .null
+ }
+
+ init(_ date: Date?) {
+ self = date.map { .real($0.timeIntervalSince1970) } ?? .null
+ }
+}
+
+public struct SQLiteRow {
+ private let statement: OpaquePointer
+
+ init(_ statement: OpaquePointer) {
+ self.statement = statement
+ }
+
+ public func double(_ index: Int32) -> Double? {
+ sqlite3_column_type(statement, index) == SQLITE_NULL ? nil : sqlite3_column_double(statement, index)
+ }
+
+ public func int(_ index: Int32) -> Int {
+ Int(sqlite3_column_int64(statement, index))
+ }
+
+ public func text(_ index: Int32) -> String {
+ sqlite3_column_text(statement, index).map { String(cString: $0) } ?? ""
+ }
+
+ public func date(_ index: Int32) -> Date? {
+ double(index).map { Date(timeIntervalSince1970: $0) }
+ }
+}
+
+public final class SQLiteDatabase: @unchecked Sendable {
+ private var handle: OpaquePointer?
+
+ public init(path: String, readOnly: Bool = false) throws {
+ var handle: OpaquePointer?
+ let flags =
+ readOnly
+ ? SQLITE_OPEN_READONLY | SQLITE_OPEN_URI | SQLITE_OPEN_FULLMUTEX
+ : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
+ guard sqlite3_open_v2(path, &handle, flags, nil) == SQLITE_OK, let handle else {
+ let message = String(cString: sqlite3_errmsg(handle))
+ sqlite3_close(handle)
+ throw SQLiteError.open(message)
+ }
+ self.handle = handle
+ }
+
+ deinit {
+ sqlite3_close(handle)
+ }
+
+ private var errorMessage: String {
+ String(cString: sqlite3_errmsg(handle))
+ }
+
+ public func execute(_ sql: String, _ parameters: [SQLiteValue] = []) throws {
+ _ = try query(sql, parameters) { _ in () }
+ }
+
+ public func interrupt() {
+ sqlite3_interrupt(handle)
+ }
+
+ public func withTransaction(_ body: () throws -> Result) throws -> Result {
+ try execute("BEGIN")
+ do {
+ let result = try body()
+ try execute("COMMIT")
+ return result
+ } catch {
+ try? rollback()
+ throw error
+ }
+ }
+
+ private func rollback() throws {
+ guard sqlite3_exec(handle, "ROLLBACK", nil, nil, nil) == SQLITE_OK else {
+ throw SQLiteError.step("ROLLBACK", errorMessage)
+ }
+ }
+
+ /// Runs the same statement once per row, preparing it once. A day of analytics is a few thousand rows, and
+ /// preparing each one separately costs more than the insert.
+ public func executeMany(_ sql: String, _ rows: [[SQLiteValue]]) throws {
+ guard !rows.isEmpty else { return }
+ var statement: OpaquePointer?
+ guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else {
+ throw SQLiteError.prepare(sql, errorMessage)
+ }
+ defer { sqlite3_finalize(statement) }
+ for parameters in rows {
+ bind(parameters, to: statement)
+ guard sqlite3_step(statement) == SQLITE_DONE else { throw SQLiteError.step(sql, errorMessage) }
+ sqlite3_reset(statement)
+ sqlite3_clear_bindings(statement)
+ }
+ }
+
+ private func bind(_ parameters: [SQLiteValue], to statement: OpaquePointer) {
+ let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
+ for (offset, parameter) in parameters.enumerated() {
+ let index = Int32(offset + 1)
+ switch parameter {
+ case .null: sqlite3_bind_null(statement, index)
+ case .integer(let value): sqlite3_bind_int64(statement, index, value)
+ case .real(let value): sqlite3_bind_double(statement, index, value)
+ case .text(let value): sqlite3_bind_text(statement, index, value, -1, transient)
+ }
+ }
+ }
+
+ public func query(
+ _ sql: String, _ parameters: [SQLiteValue] = [], _ row: (SQLiteRow) throws -> Row
+ ) throws -> [Row] {
+ var rows: [Row] = []
+ try forEachRow(sql, parameters) { rows.append(try row($0)) }
+ return rows
+ }
+
+ public func forEachRow(
+ _ sql: String, _ parameters: [SQLiteValue] = [], _ row: (SQLiteRow) throws -> Void
+ ) throws {
+ var statement: OpaquePointer?
+ guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else {
+ throw SQLiteError.prepare(sql, errorMessage)
+ }
+ defer { sqlite3_finalize(statement) }
+ bind(parameters, to: statement)
+ while true {
+ try Task.checkCancellation()
+ let status = sqlite3_step(statement)
+ if status == SQLITE_DONE { break }
+ try Task.checkCancellation()
+ guard status == SQLITE_ROW else { throw SQLiteError.step(sql, errorMessage) }
+ try row(SQLiteRow(statement))
+ }
+ }
+
+ public var changes: Int {
+ Int(sqlite3_changes(handle))
+ }
+}
diff --git a/Sources/TokenMenuBarCore/History/UsageHistoryStore.swift b/Sources/TokenMenuBarCore/History/UsageHistoryStore.swift
new file mode 100644
index 0000000..c4e06d5
--- /dev/null
+++ b/Sources/TokenMenuBarCore/History/UsageHistoryStore.swift
@@ -0,0 +1,607 @@
+import Foundation
+
+public struct UsageSample: Sendable, Hashable, Codable {
+ public let timestamp: Date
+ public let key: WindowKey
+ public let usedPercent: Double
+ public let resetsAt: Date?
+
+ public init(timestamp: Date, key: WindowKey, usedPercent: Double, resetsAt: Date?) {
+ self.timestamp = timestamp
+ self.key = key
+ self.usedPercent = usedPercent
+ self.resetsAt = resetsAt
+ }
+}
+
+public struct WindowSummary: Sendable, Hashable, Identifiable {
+ public let key: WindowKey
+ public let label: String
+ public let lastSeen: Date
+ public let lastPercent: Double
+
+ public var id: WindowKey { key }
+}
+
+public struct HistoryStats: Sendable, Equatable {
+ public let sampleCount: Int
+ public let analyticsCount: Int
+ public let oldest: Date?
+ public let newest: Date?
+}
+
+public struct HistoryPruneResult: Sendable, Equatable {
+ public let samples: Int
+ public let analytics: Int
+
+ public init(samples: Int, analytics: Int) {
+ self.samples = samples
+ self.analytics = analytics
+ }
+
+ public var total: Int { samples + analytics }
+}
+
+private struct AnalyticsStorageKey: Hashable {
+ let day: String
+ let metric: AnalyticsMetric
+ let series: String
+}
+
+private struct SampleBucket: Hashable {
+ let key: WindowKey
+ let start: Date
+}
+
+private struct OffsetSegment {
+ let start: Date
+ let end: Date
+ let includesEnd: Bool
+ let offset: TimeInterval
+}
+
+public actor UsageHistoryStore {
+ public static let defaultRetentionDays = 60
+ public static let retention: TimeInterval = TimeInterval(defaultRetentionDays) * 86400
+ public static let sampleInterval: TimeInterval = 300
+ static let changeThreshold: Double = 5
+
+ let database: SQLiteDatabase
+ private var lastRecorded: [WindowKey: UsageSample] = [:]
+ private var lastPrune: Date?
+ public private(set) var retentionDays: Int
+ public nonisolated let location: URL?
+
+ public init(url: URL?, retentionDays: Int = defaultRetentionDays) throws {
+ location = url
+ self.retentionDays = min(max(retentionDays, 7), 365)
+ if let url {
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ }
+ database = try SQLiteDatabase(path: url?.path ?? ":memory:")
+ try database.execute("PRAGMA journal_mode = WAL")
+ try database.execute(
+ """
+ CREATE TABLE IF NOT EXISTS samples (
+ ts REAL NOT NULL, key TEXT NOT NULL, label TEXT NOT NULL, used REAL NOT NULL, resets_at REAL,
+ PRIMARY KEY (key, ts)
+ )
+ """
+ )
+ try database.execute(
+ """
+ CREATE TABLE IF NOT EXISTS analytics (
+ provider TEXT NOT NULL, day TEXT NOT NULL, metric TEXT NOT NULL, series TEXT NOT NULL, value REAL NOT NULL,
+ PRIMARY KEY (provider, day, metric, series)
+ )
+ """
+ )
+ try database.execute(
+ """
+ CREATE TABLE IF NOT EXISTS analytics_accounts (
+ provider TEXT PRIMARY KEY, fingerprint TEXT NOT NULL
+ )
+ """
+ )
+ try database.execute("CREATE INDEX IF NOT EXISTS samples_ts ON samples (ts)")
+ try database.execute(
+ "CREATE INDEX IF NOT EXISTS analytics_metric_day_provider ON analytics (metric, day, provider)")
+ let latest = try database.query(
+ "SELECT key, MAX(ts), used, resets_at FROM samples GROUP BY key"
+ ) { row -> UsageSample? in
+ WindowKey(storageKey: row.text(0)).map {
+ UsageSample(timestamp: row.date(1)!, key: $0, usedPercent: row.double(2)!, resetsAt: row.date(3))
+ }
+ }
+ for sample in latest.compactMap({ $0 }) { lastRecorded[sample.key] = sample }
+ }
+
+ @discardableResult
+ public func record(_ snapshot: ProviderSnapshot, now: Date) throws -> Int {
+ var pending: [(key: WindowKey, sample: UsageSample, row: [SQLiteValue])] = []
+ for window in snapshot.windows {
+ let key = WindowKey(snapshot.provider, window)
+ let sample = UsageSample(timestamp: now, key: key, usedPercent: window.usedPercent, resetsAt: window.resetsAt)
+ guard Self.shouldRecord(sample, after: lastRecorded[key]) else { continue }
+ pending.append(
+ (
+ key, sample,
+ [
+ .real(now.timeIntervalSince1970), .text(key.storageKey), .text(window.label), .real(window.usedPercent),
+ SQLiteValue(window.resetsAt),
+ ]
+ ))
+ }
+ try database.withTransaction {
+ try database.executeMany(
+ "INSERT OR REPLACE INTO samples (ts, key, label, used, resets_at) VALUES (?, ?, ?, ?, ?)",
+ pending.map(\.row))
+ }
+ for item in pending { lastRecorded[item.key] = item.sample }
+ try pruneIfNeeded(now: now)
+ return pending.count
+ }
+
+ public func seed(_ snapshots: [(ProviderSnapshot, Date)]) throws {
+ try database.withTransaction {
+ try database.executeMany(
+ "INSERT OR REPLACE INTO samples (ts, key, label, used, resets_at) VALUES (?, ?, ?, ?, ?)",
+ snapshots.flatMap { snapshot, stamp in
+ snapshot.windows.map { window in
+ [
+ SQLiteValue.real(stamp.timeIntervalSince1970), .text(WindowKey(snapshot.provider, window).storageKey),
+ .text(window.label), .real(window.usedPercent), SQLiteValue(window.resetsAt),
+ ]
+ }
+ })
+ }
+ }
+
+ static func shouldRecord(_ sample: UsageSample, after previous: UsageSample?) -> Bool {
+ guard let previous else { return true }
+ if sample.timestamp.timeIntervalSince(previous.timestamp) >= sampleInterval { return true }
+ if sample.resetsAt != previous.resetsAt { return true }
+ return abs(sample.usedPercent - previous.usedPercent) >= changeThreshold
+ }
+
+ @discardableResult
+ public func record(_ analytics: ProviderAnalytics) throws -> Int {
+ let cutoff = analyticsCutoffDay(now: analytics.fetchedAt)
+ var totals: [AnalyticsStorageKey: Double] = [:]
+ for point in analytics.points where point.day >= cutoff {
+ totals[AnalyticsStorageKey(day: point.day, metric: point.metric, series: point.series), default: 0] += point.value
+ }
+ let points = totals.map { key, value in
+ AnalyticsPoint(day: key.day, metric: key.metric, series: key.series, value: value)
+ }
+ try database.withTransaction {
+ if let fingerprint = analytics.accountFingerprint {
+ let stored = try database.query(
+ "SELECT fingerprint FROM analytics_accounts WHERE provider = ?", [.text(analytics.provider.rawValue)]
+ ) { $0.text(0) }.first
+ if let stored, stored != fingerprint {
+ try database.execute("DELETE FROM analytics WHERE provider = ?", [.text(analytics.provider.rawValue)])
+ }
+ try database.execute(
+ "INSERT OR REPLACE INTO analytics_accounts (provider, fingerprint) VALUES (?, ?)",
+ [.text(analytics.provider.rawValue), .text(fingerprint)])
+ }
+ for scope in analytics.coveredScopes where !scope.metrics.isEmpty {
+ let start = max(scope.startDay, cutoff)
+ guard start <= scope.endDay else { continue }
+ let metrics = scope.metrics.sorted { $0.rawValue < $1.rawValue }
+ let placeholders = Array(repeating: "?", count: metrics.count).joined(separator: ",")
+ try database.execute(
+ "DELETE FROM analytics WHERE provider = ? AND metric IN (\(placeholders)) AND day >= ? AND day <= ?",
+ [.text(analytics.provider.rawValue)] + metrics.map { .text($0.rawValue) }
+ + [.text(start), .text(scope.endDay)])
+ }
+ try database.executeMany(
+ "INSERT OR REPLACE INTO analytics (provider, day, metric, series, value) VALUES (?, ?, ?, ?, ?)",
+ points.map { point in
+ [
+ SQLiteValue.text(analytics.provider.rawValue), .text(point.day), .text(point.metric.rawValue),
+ .text(point.series), .real(point.value),
+ ]
+ })
+ try database.execute("DELETE FROM analytics WHERE day < ?", [.text(cutoff)])
+ }
+ return points.count
+ }
+
+ /// - Parameter rollup: when given, the database returns the newest row of each `(key, bucket)` rather than every
+ /// row, which is what the chart keeps anyway. A sixty-day range is around 150 000 rows and a few thousand
+ /// buckets, so this is the difference between holding the table in memory and holding what is drawn.
+ /// - Parameter timeZone: the calendar used for bucket boundaries. Queries split at offset changes so database
+ /// reduction stays small without assigning rows to the wrong local bucket.
+ public func samples(
+ keys: [WindowKey]? = nil, from start: Date, to end: Date, rollup: TimeInterval? = nil,
+ timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!, includesEnd: Bool = true
+ ) async throws -> [UsageSample] {
+ try await withTaskCancellationHandler {
+ if keys?.isEmpty == true { return [] }
+ guard let rollup, rollup > 0 else {
+ return try querySamples(keys: keys, from: start, to: end, includesEnd: includesEnd)
+ }
+ let samples = try Self.offsetSegments(
+ from: start, to: end, timeZone: timeZone, includesEnd: includesEnd
+ ).flatMap { segment in
+ try querySamples(
+ keys: keys, from: segment.start, to: segment.end, includesEnd: segment.includesEnd, rollup: rollup,
+ timeZoneOffset: segment.offset)
+ }
+ return Self.collapse(samples, rollup: rollup, timeZone: timeZone)
+ } onCancel: {
+ database.interrupt()
+ }
+ }
+
+ private func querySamples(
+ keys: [WindowKey]?, from start: Date, to end: Date, includesEnd: Bool, rollup: TimeInterval? = nil,
+ timeZoneOffset: TimeInterval = 0
+ ) throws -> [UsageSample] {
+ var sql = "SELECT ts, key, used, resets_at FROM samples WHERE ts >= ? AND ts \(includesEnd ? "<=" : "<") ?"
+ var parameters: [SQLiteValue] = [.real(start.timeIntervalSince1970), .real(end.timeIntervalSince1970)]
+ if let keys {
+ sql += " AND key IN (\(Array(repeating: "?", count: keys.count).joined(separator: ",")))"
+ parameters += keys.map { .text($0.storageKey) }
+ }
+ if let rollup, rollup > 0 {
+ sql =
+ "SELECT ts, key, used, resets_at FROM (SELECT ts, key, used, resets_at, ROW_NUMBER() OVER "
+ + "(PARTITION BY key, CAST((ts + ?) / ? AS INTEGER) ORDER BY ts DESC) AS rank FROM (\(sql))) WHERE rank = 1"
+ parameters = [.real(timeZoneOffset), .real(rollup)] + parameters
+ }
+ sql += " ORDER BY ts ASC"
+ return try database.query(sql, parameters) { row -> UsageSample? in
+ WindowKey(storageKey: row.text(1)).map {
+ UsageSample(timestamp: row.date(0)!, key: $0, usedPercent: row.double(2)!, resetsAt: row.date(3))
+ }
+ }.compactMap { $0 }
+ }
+
+ public func lastUsageDates(
+ keys: [WindowKey], from start: Date, to end: Date
+ ) async throws -> [WindowKey: Date] {
+ try await withTaskCancellationHandler {
+ let keys = keys.uniqued()
+ guard !keys.isEmpty else { return [:] }
+ let placeholders = Array(repeating: "?", count: keys.count).joined(separator: ",")
+ let parameters: [SQLiteValue] =
+ [.real(start.timeIntervalSince1970), .real(end.timeIntervalSince1970)]
+ + keys.map { .text($0.storageKey) }
+ let rows = try database.query(
+ """
+ WITH chronological AS (
+ SELECT ts, key, used, resets_at,
+ LAG(ts) OVER (PARTITION BY key ORDER BY ts) AS previous_ts,
+ LAG(used) OVER (PARTITION BY key ORDER BY ts) AS previous_used,
+ LAG(resets_at) OVER (PARTITION BY key ORDER BY ts) AS previous_resets_at
+ FROM samples
+ WHERE ts >= ? AND ts <= ? AND key IN (\(placeholders))
+ )
+ SELECT key, MAX(ts)
+ FROM chronological
+ WHERE used > 0 AND (
+ previous_ts IS NULL OR resets_at IS NOT previous_resets_at OR used > previous_used
+ )
+ GROUP BY key
+ """,
+ parameters
+ ) { row -> (WindowKey, Date)? in
+ guard let key = WindowKey(storageKey: row.text(0)), let date = row.date(1) else { return nil }
+ return (key, date)
+ }.compactMap { $0 }
+ return Dictionary(uniqueKeysWithValues: rows)
+ } onCancel: {
+ database.interrupt()
+ }
+ }
+
+ public func recentSamples(key: WindowKey, since: Date) async throws -> [UsageSample] {
+ try await samples(keys: [key], from: since, to: .distantFuture)
+ }
+
+ public func analytics(provider: ProviderID, from start: String, to end: String) throws -> [AnalyticsPoint] {
+ try database.query(
+ "SELECT day, metric, series, value FROM analytics WHERE provider = ? AND day >= ? AND day <= ? ORDER BY day ASC",
+ [.text(provider.rawValue), .text(start), .text(end)]
+ ) { row -> AnalyticsPoint? in
+ AnalyticsMetric(rawValue: row.text(1)).map {
+ AnalyticsPoint(day: row.text(0), metric: $0, series: row.text(2), value: row.double(3)!)
+ }
+ }.compactMap { $0 }
+ }
+
+ public func analytics(
+ metric: AnalyticsMetric, providers: [ProviderID], from start: String, to end: String
+ ) throws -> [HistoryAnalyticsRow] {
+ try analytics(metric: metric, providers: providers, from: start, end: end, includesEnd: true)
+ }
+
+ public func analytics(
+ metric: AnalyticsMetric, providers: [ProviderID], from start: String, before end: String
+ ) throws -> [HistoryAnalyticsRow] {
+ try analytics(metric: metric, providers: providers, from: start, end: end, includesEnd: false)
+ }
+
+ private func analytics(
+ metric: AnalyticsMetric, providers: [ProviderID], from start: String, end: String, includesEnd: Bool
+ ) throws -> [HistoryAnalyticsRow] {
+ guard !providers.isEmpty else { return [] }
+ let placeholders = Array(repeating: "?", count: providers.count).joined(separator: ",")
+ let parameters: [SQLiteValue] =
+ [.text(metric.rawValue), .text(start), .text(end)] + providers.map { .text($0.rawValue) }
+ return try database.query(
+ "SELECT provider, day, series, value FROM analytics WHERE metric = ? AND day >= ? "
+ + "AND day \(includesEnd ? "<=" : "<") ? "
+ + "AND provider IN (\(placeholders)) ORDER BY day ASC, provider ASC, series ASC",
+ parameters
+ ) { row -> HistoryAnalyticsRow? in
+ guard let provider = ProviderID(rawValue: row.text(0)) else { return nil }
+ return HistoryAnalyticsRow(
+ provider: provider,
+ point: AnalyticsPoint(day: row.text(1), metric: metric, series: row.text(2), value: row.double(3)!))
+ }.compactMap { $0 }
+ }
+
+ public func summaries() throws -> [WindowSummary] {
+ // Grouping the whole table walks every row. The primary key is (key, ts), so the inner query seeks the last row
+ // of each key group and the join reads only those.
+ let sql =
+ "SELECT s.key, s.label, s.ts, s.used FROM samples s "
+ + "JOIN (SELECT key, MAX(ts) AS last_ts FROM samples GROUP BY key) latest "
+ + "ON s.key = latest.key AND s.ts = latest.last_ts ORDER BY s.key"
+ return try database.query(sql) {
+ row -> WindowSummary? in
+ WindowKey(storageKey: row.text(0)).map {
+ WindowSummary(key: $0, label: row.text(1), lastSeen: row.date(2)!, lastPercent: row.double(3)!)
+ }
+ }.compactMap { $0 }
+ }
+
+ public func earliestSample(keys: [WindowKey]? = nil) throws -> Date? {
+ guard let keys else { return try database.query("SELECT MIN(ts) FROM samples") { $0.date(0) }.first! }
+ guard !keys.isEmpty else { return nil }
+ let placeholders = Array(repeating: "?", count: keys.count).joined(separator: ",")
+ return try database.query(
+ "SELECT MIN(ts) FROM samples WHERE key IN (\(placeholders))", keys.map { .text($0.storageKey) }
+ ) { $0.date(0) }.first!
+ }
+
+ public func earliestAnalytics(metric: AnalyticsMetric, providers: [ProviderID]) throws -> Date? {
+ guard !providers.isEmpty else { return nil }
+ let placeholders = Array(repeating: "?", count: providers.count).joined(separator: ",")
+ let parameters = [SQLiteValue.text(metric.rawValue)] + providers.map { .text($0.rawValue) }
+ let day = try database.query(
+ "SELECT MIN(day) FROM analytics WHERE metric = ? AND provider IN (\(placeholders))", parameters
+ ) { row in row.text(0) }.first
+ return day.flatMap(DayStamp.date)
+ }
+
+ public func stats() throws -> HistoryStats {
+ let counts = try database.query("SELECT COUNT(*), MIN(ts), MAX(ts) FROM samples") {
+ ($0.int(0), $0.date(1), $0.date(2))
+ }.first!
+ let analyticsCount = try database.query("SELECT COUNT(*) FROM analytics") { $0.int(0) }.first!
+ return HistoryStats(sampleCount: counts.0, analyticsCount: analyticsCount, oldest: counts.1, newest: counts.2)
+ }
+
+ /// Writes the whole table to `url` a chunk at a time. Building it in memory first meant one string per row plus a
+ /// joined copy of the lot, which over a full retention window is tens of megabytes.
+ public func exportCSV(to url: URL) async throws {
+ try await withTaskCancellationHandler {
+ try writeCSV(
+ to: url,
+ header: "kind,timestamp,key,label,used_percent,resets_at,provider,day,metric,series,value\n"
+ ) { write in
+ try database.forEachRow("SELECT ts, key, label, used, resets_at FROM samples ORDER BY ts ASC, key ASC") { row in
+ try write(
+ [
+ "sample", ISODate.string(row.date(0)!), row.text(1), row.text(2), String(format: "%.2f", row.double(3)!),
+ row.date(4).map(ISODate.string) ?? "", "", "", "", "", "",
+ ])
+ }
+ try database.forEachRow(
+ "SELECT provider, day, metric, series, value FROM analytics "
+ + "ORDER BY day ASC, provider ASC, metric ASC, series ASC"
+ ) { row in
+ try write(
+ [
+ "analytics", "", "", "", "", "", row.text(0), row.text(1), row.text(2), row.text(3),
+ String(format: "%.4f", row.double(4)!),
+ ])
+ }
+ }
+ } onCancel: {
+ database.interrupt()
+ }
+ }
+
+ public func exportCSV(
+ to url: URL, metric: HistoryMetric, from start: Date, to end: Date, keys: [WindowKey]? = nil,
+ providers: [ProviderID]? = nil, includesEnd: Bool = true
+ ) async throws {
+ try await withTaskCancellationHandler {
+ switch metric {
+ case .windowUsagePercent:
+ try exportSamplesCSV(to: url, from: start, to: end, keys: keys, includesEnd: includesEnd)
+ case .analytics(let analyticsMetric):
+ let exclusiveEnd = DayStamp.string(end.addingTimeInterval(Rollup.day.seconds))
+ try exportAnalyticsCSV(
+ to: url, metric: analyticsMetric, providers: providers ?? metric.suppliers, from: DayStamp.string(start),
+ before: exclusiveEnd)
+ }
+ } onCancel: {
+ database.interrupt()
+ }
+ }
+
+ @discardableResult
+ public func clear() throws -> Int {
+ let removed = try database.withTransaction {
+ try database.execute("DELETE FROM samples")
+ let samples = database.changes
+ try database.execute("DELETE FROM analytics")
+ try database.execute("DELETE FROM analytics_accounts")
+ return samples
+ }
+ lastRecorded.removeAll()
+ return removed
+ }
+
+ public func setRetentionDays(_ days: Int) {
+ let clamped = min(max(days, 7), 365)
+ guard clamped != retentionDays else { return }
+ retentionDays = clamped
+ lastPrune = nil
+ }
+
+ @discardableResult
+ public func setRetentionDays(_ days: Int, now: Date) async throws -> HistoryPruneResult {
+ try await withTaskCancellationHandler {
+ try applyRetentionDays(days, now: now)
+ } onCancel: {
+ database.interrupt()
+ }
+ }
+
+ private func applyRetentionDays(_ days: Int, now: Date) throws -> HistoryPruneResult {
+ let clamped = min(max(days, 7), 365)
+ let cutoff = now.addingTimeInterval(-TimeInterval(clamped) * 86400)
+ let analyticsCutoff = Self.analyticsCutoffDay(now: now, retentionDays: clamped)
+ let result = try database.withTransaction {
+ try database.execute("DELETE FROM samples WHERE ts < ?", [.real(cutoff.timeIntervalSince1970)])
+ let samples = database.changes
+ try database.execute("DELETE FROM analytics WHERE day < ?", [.text(analyticsCutoff)])
+ return HistoryPruneResult(samples: samples, analytics: database.changes)
+ }
+ retentionDays = clamped
+ lastRecorded = lastRecorded.filter { $0.value.timestamp >= cutoff }
+ lastPrune = now
+ return result
+ }
+
+ func pruneIfNeeded(now: Date) throws {
+ if let lastPrune, now.timeIntervalSince(lastPrune) < 3600 { return }
+ _ = try applyRetentionDays(retentionDays, now: now)
+ }
+
+ private func analyticsCutoffDay(now: Date) -> String {
+ Self.analyticsCutoffDay(now: now, retentionDays: retentionDays)
+ }
+
+ private static func analyticsCutoffDay(now: Date, retentionDays: Int) -> String {
+ DayStamp.string(now.addingTimeInterval(-TimeInterval(max(retentionDays - 1, 0)) * 86400))
+ }
+
+ private static func offsetSegments(
+ from start: Date, to end: Date, timeZone: TimeZone, includesEnd: Bool
+ ) -> [OffsetSegment] {
+ var segments: [OffsetSegment] = []
+ var cursor = start
+ while let transition = timeZone.nextDaylightSavingTimeTransition(after: cursor), transition < end {
+ segments.append(
+ OffsetSegment(
+ start: cursor, end: transition, includesEnd: false,
+ offset: TimeInterval(timeZone.secondsFromGMT(for: cursor))))
+ cursor = transition
+ }
+ segments.append(
+ OffsetSegment(
+ start: cursor, end: end, includesEnd: includesEnd,
+ offset: TimeInterval(timeZone.secondsFromGMT(for: cursor))))
+ return segments
+ }
+
+ private static func collapse(_ samples: [UsageSample], rollup: TimeInterval, timeZone: TimeZone) -> [UsageSample] {
+ var latest: [SampleBucket: UsageSample] = [:]
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = timeZone
+ for sample in samples {
+ let start: Date
+ if rollup == Rollup.minute.seconds {
+ start = calendar.dateInterval(of: .minute, for: sample.timestamp)!.start
+ } else if rollup == Rollup.hour.seconds {
+ start = calendar.dateInterval(of: .hour, for: sample.timestamp)!.start
+ } else if rollup == Rollup.day.seconds {
+ start = calendar.startOfDay(for: sample.timestamp)
+ } else {
+ let offset = TimeInterval(timeZone.secondsFromGMT(for: sample.timestamp))
+ let local = sample.timestamp.timeIntervalSince1970 + offset
+ start = Date(timeIntervalSince1970: (local / rollup).rounded(.down) * rollup - offset)
+ }
+ let bucket = SampleBucket(key: sample.key, start: start)
+ if let existing = latest[bucket], existing.timestamp > sample.timestamp { continue }
+ latest[bucket] = sample
+ }
+ return latest.values.sorted { ($0.timestamp, $0.key.storageKey) < ($1.timestamp, $1.key.storageKey) }
+ }
+
+ private func exportSamplesCSV(
+ to url: URL, from start: Date, to end: Date, keys: [WindowKey]?, includesEnd: Bool
+ ) throws {
+ var sql = "SELECT ts, key, label, used, resets_at FROM samples WHERE ts >= ? AND ts \(includesEnd ? "<=" : "<") ?"
+ var parameters: [SQLiteValue] = [.real(start.timeIntervalSince1970), .real(end.timeIntervalSince1970)]
+ if let keys {
+ if keys.isEmpty {
+ try writeCSV(to: url, header: "timestamp,key,label,used_percent,resets_at\n") { _ in }
+ return
+ }
+ sql += " AND key IN (\(Array(repeating: "?", count: keys.count).joined(separator: ",")))"
+ parameters += keys.map { .text($0.storageKey) }
+ }
+ sql += " ORDER BY ts ASC"
+ try writeCSV(to: url, header: "timestamp,key,label,used_percent,resets_at\n") { write in
+ try database.forEachRow(sql, parameters) { row in
+ try write(
+ [
+ ISODate.string(row.date(0)!), row.text(1), row.text(2), String(format: "%.2f", row.double(3)!),
+ row.date(4).map(ISODate.string) ?? "",
+ ])
+ }
+ }
+ }
+
+ private func exportAnalyticsCSV(
+ to url: URL, metric: AnalyticsMetric, providers: [ProviderID], from start: String, before end: String
+ ) throws {
+ let placeholders = Array(repeating: "?", count: providers.count).joined(separator: ",")
+ let parameters: [SQLiteValue] =
+ [.text(metric.rawValue), .text(start), .text(end)] + providers.map { .text($0.rawValue) }
+ try writeCSV(to: url, header: "provider,day,metric,series,value\n") { write in
+ try database.forEachRow(
+ "SELECT provider, day, metric, series, value FROM analytics WHERE metric = ? AND day >= ? AND day < ? "
+ + "AND provider IN (\(placeholders)) ORDER BY day ASC, provider ASC, series ASC",
+ parameters
+ ) { row in
+ try write(
+ [
+ row.text(0), row.text(1), row.text(2), row.text(3), String(format: "%.4f", row.double(4)!),
+ ])
+ }
+ }
+ }
+
+ private func writeCSV(
+ to url: URL, header: String, rows: (_ write: ([String]) throws -> Void) throws -> Void
+ ) throws {
+ FileManager.default.createFile(atPath: url.path, contents: nil)
+ let handle = try FileHandle(forWritingTo: url)
+ defer { try? handle.close() }
+ var buffer = header
+ try rows { fields in
+ buffer += fields.map(Self.csvField).joined(separator: ",") + "\n"
+ if buffer.utf8.count >= 256 * 1024 {
+ try handle.write(contentsOf: Data(buffer.utf8))
+ buffer = ""
+ }
+ }
+ try handle.write(contentsOf: Data(buffer.utf8))
+ }
+
+ private static func csvField(_ field: String) -> String {
+ guard field.contains(where: { $0 == "," || $0 == "\"" || $0 == "\n" }) else { return field }
+ return "\"\(field.replacingOccurrences(of: "\"", with: "\"\""))\""
+ }
+}
diff --git a/Sources/TokenMenuBarCore/InterfaceTokens.swift b/Sources/TokenMenuBarCore/InterfaceTokens.swift
new file mode 100644
index 0000000..57924c4
--- /dev/null
+++ b/Sources/TokenMenuBarCore/InterfaceTokens.swift
@@ -0,0 +1,90 @@
+public enum SemanticColorRole: String, CaseIterable, Sendable {
+ case primary
+ case secondary
+ case tertiary
+ case accent
+ case warning
+ case destructive
+}
+
+public enum ControlIntent: String, CaseIterable, Sendable {
+ case action
+ case selection
+ case destructive
+ case warning
+ case data
+}
+
+public struct ControlAppearance: Equatable, Sendable {
+ public let foreground: SemanticColorRole
+ public let tint: SemanticColorRole?
+
+ public init(foreground: SemanticColorRole, tint: SemanticColorRole? = nil) {
+ self.foreground = foreground
+ self.tint = tint
+ }
+}
+
+public struct ControlPolicy: Equatable, Sendable {
+ public let action: ControlAppearance
+ public let selected: ControlAppearance
+ public let destructive: ControlAppearance
+ public let warning: ControlAppearance
+ public let data: ControlAppearance
+
+ public init(
+ action: ControlAppearance,
+ selected: ControlAppearance,
+ destructive: ControlAppearance,
+ warning: ControlAppearance,
+ data: ControlAppearance
+ ) {
+ self.action = action
+ self.selected = selected
+ self.destructive = destructive
+ self.warning = warning
+ self.data = data
+ }
+
+ public func appearance(for intent: ControlIntent, selected isSelected: Bool = false) -> ControlAppearance {
+ switch intent {
+ case .action: action
+ case .selection: isSelected ? selected : action
+ case .destructive: destructive
+ case .warning: warning
+ case .data: data
+ }
+ }
+}
+
+public struct InterfaceTokens: Equatable, Sendable {
+ public let bodyForeground: SemanticColorRole
+ public let detailForeground: SemanticColorRole
+ public let quietForeground: SemanticColorRole
+ public let controls: ControlPolicy
+
+ public init(
+ bodyForeground: SemanticColorRole,
+ detailForeground: SemanticColorRole,
+ quietForeground: SemanticColorRole,
+ controls: ControlPolicy
+ ) {
+ self.bodyForeground = bodyForeground
+ self.detailForeground = detailForeground
+ self.quietForeground = quietForeground
+ self.controls = controls
+ }
+
+ public static let standard = InterfaceTokens(
+ bodyForeground: .primary,
+ detailForeground: .secondary,
+ quietForeground: .tertiary,
+ controls: ControlPolicy(
+ action: ControlAppearance(foreground: .primary),
+ selected: ControlAppearance(foreground: .primary, tint: .accent),
+ destructive: ControlAppearance(foreground: .destructive),
+ warning: ControlAppearance(foreground: .warning),
+ data: ControlAppearance(foreground: .primary, tint: .accent)
+ )
+ )
+}
diff --git a/Sources/TokenMenuBarCore/JSONValue.swift b/Sources/TokenMenuBarCore/JSONValue.swift
new file mode 100644
index 0000000..f2f7c4c
--- /dev/null
+++ b/Sources/TokenMenuBarCore/JSONValue.swift
@@ -0,0 +1,99 @@
+import Foundation
+
+public enum JSONValue: Codable, Sendable, Hashable {
+ case null
+ case bool(Bool)
+ case number(Double)
+ case string(String)
+ case array([JSONValue])
+ case object([String: JSONValue])
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if container.decodeNil() {
+ self = .null
+ } else if let value = try? container.decode(Bool.self) {
+ self = .bool(value)
+ } else if let value = try? container.decode(Double.self) {
+ self = .number(value)
+ } else if let value = try? container.decode(String.self) {
+ self = .string(value)
+ } else if let value = try? container.decode([JSONValue].self) {
+ self = .array(value)
+ } else {
+ self = .object(try container.decode([String: JSONValue].self))
+ }
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .null: try container.encodeNil()
+ case .bool(let value): try container.encode(value)
+ case .number(let value):
+ if value == value.rounded(), abs(value) < 1e15 {
+ try container.encode(Int64(value))
+ } else {
+ try container.encode(value)
+ }
+ case .string(let value): try container.encode(value)
+ case .array(let value): try container.encode(value)
+ case .object(let value): try container.encode(value)
+ }
+ }
+
+ public subscript(key: String) -> JSONValue? {
+ guard case .object(let dict) = self else { return nil }
+ return dict[key]
+ }
+
+ public var stringValue: String? {
+ if case .string(let value) = self { return value }
+ return nil
+ }
+
+ public var doubleValue: Double? {
+ switch self {
+ case .number(let value): value
+ case .string(let value): Double(value)
+ default: nil
+ }
+ }
+
+ public var boolValue: Bool? {
+ if case .bool(let value) = self { return value }
+ return nil
+ }
+
+ public var arrayValue: [JSONValue]? {
+ if case .array(let value) = self { return value }
+ return nil
+ }
+
+ public var objectValue: [String: JSONValue]? {
+ if case .object(let value) = self { return value }
+ return nil
+ }
+
+ public var isNull: Bool {
+ if case .null = self { return true }
+ return false
+ }
+
+ public func merging(_ key: String, _ value: JSONValue) -> JSONValue {
+ var dict = objectValue ?? [:]
+ dict[key] = value
+ return .object(dict)
+ }
+
+ public var summary: String {
+ switch self {
+ case .null: "null"
+ case .bool(let value): String(value)
+ case .number(let value): value.formatted(.number.precision(.fractionLength(0...2)))
+ case .string(let value): value
+ case .array(let values): values.map(\.summary).joined(separator: ", ")
+ case .object(let dict): dict.keys.sorted().map { "\($0): \(dict[$0]!.summary)" }.joined(separator: ", ")
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/LaunchAtLogin.swift b/Sources/TokenMenuBarCore/LaunchAtLogin.swift
new file mode 100644
index 0000000..eefad38
--- /dev/null
+++ b/Sources/TokenMenuBarCore/LaunchAtLogin.swift
@@ -0,0 +1,76 @@
+import Foundation
+
+public struct LaunchAtLoginBackend: Sendable {
+ public enum Status: String, Sendable, Equatable {
+ case enabled
+ case notRegistered
+ case notFound
+ case requiresApproval
+ case unknown
+
+ public var isEnabled: Bool {
+ self == .enabled
+ }
+
+ public var explanation: String? {
+ switch self {
+ case .requiresApproval: "Approve Token Menu Bar under System Settings > General > Login Items."
+ case .notFound: "Launch at login needs the app to run from /Applications."
+ default: nil
+ }
+ }
+ }
+
+ public let status: @Sendable () -> Status
+ public let register: @Sendable () throws -> Void
+ public let unregister: @Sendable () throws -> Void
+ public let openSettings: @Sendable () -> Void
+
+ public init(
+ status: @escaping @Sendable () -> Status,
+ register: @escaping @Sendable () throws -> Void,
+ unregister: @escaping @Sendable () throws -> Void,
+ openSettings: @escaping @Sendable () -> Void = {}
+ ) {
+ self.status = status
+ self.register = register
+ self.unregister = unregister
+ self.openSettings = openSettings
+ }
+
+ public static let unsupported = LaunchAtLoginBackend(status: { .unknown }, register: {}, unregister: {})
+
+ public static func inMemory(initiallyEnabled: Bool = false) -> LaunchAtLoginBackend {
+ let state = InMemoryLaunchAtLoginState(enabled: initiallyEnabled)
+ return LaunchAtLoginBackend(
+ status: state.status,
+ register: { state.setEnabled(true) },
+ unregister: { state.setEnabled(false) })
+ }
+
+ public func setEnabled(_ enabled: Bool) -> Status {
+ do {
+ if enabled { try register() } else { try unregister() }
+ } catch {
+ return status()
+ }
+ return status()
+ }
+}
+
+private final class InMemoryLaunchAtLoginState: @unchecked Sendable {
+ private let lock = NSLock()
+ private var enabled: Bool
+
+ init(enabled: Bool) {
+ self.enabled = enabled
+ }
+
+ func status() -> LaunchAtLoginBackend.Status {
+ lock.withLock { enabled ? .enabled : .notRegistered }
+ }
+
+ func setEnabled(_ enabled: Bool) {
+ lock.withLock { self.enabled = enabled }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/LaunchPolicy.swift b/Sources/TokenMenuBarCore/LaunchPolicy.swift
new file mode 100644
index 0000000..507b95b
--- /dev/null
+++ b/Sources/TokenMenuBarCore/LaunchPolicy.swift
@@ -0,0 +1,104 @@
+import Foundation
+
+public struct VerificationProfile: Equatable, Sendable {
+ public enum Fixture: String, Equatable, Sendable {
+ case standard
+ case longText = "long-text"
+ case controlAudit = "control-audit"
+ }
+
+ public static let fixtureEnvironmentKey = "TOKEN_MENU_BAR_VERIFY_FIXTURE"
+ public static let visibleFrameWidthEnvironmentKey = "TOKEN_MENU_BAR_VERIFY_VISIBLE_WIDTH"
+ public static let nativePanelsEnvironmentKey = "TOKEN_MENU_BAR_VERIFY_NATIVE_PANELS"
+
+ public let fixture: Fixture
+ public let visibleFrameWidth: Double?
+ public let nativePanels: Bool
+
+ public init(fixture: Fixture = .standard, visibleFrameWidth: Double? = nil, nativePanels: Bool = false) {
+ self.fixture = fixture
+ self.visibleFrameWidth = visibleFrameWidth
+ self.nativePanels = nativePanels
+ }
+
+ init(environment: [String: String]) {
+ fixture = environment[Self.fixtureEnvironmentKey].flatMap(Fixture.init(rawValue:)) ?? .standard
+ visibleFrameWidth = environment[Self.visibleFrameWidthEnvironmentKey].flatMap(Double.init).flatMap {
+ $0.isFinite && $0 > 0 ? $0 : nil
+ }
+ nativePanels = environment[Self.nativePanelsEnvironmentKey] == "1"
+ }
+}
+
+public struct LaunchPolicy: Equatable, Sendable {
+ public enum Mode: Equatable, Sendable {
+ case standard
+ case verification
+ }
+
+ public static let verificationArgument = "--verify-ui"
+ public static let verificationEnvironmentKey = "TOKEN_MENU_BAR_VERIFY_UI"
+ public static let verificationSessionKey = "TOKEN_MENU_BAR_VERIFY_SESSION"
+ public static let verificationSupportDirectoryKey = "TOKEN_MENU_BAR_VERIFY_SUPPORT_DIRECTORY"
+ public static let verificationSuitePrefix = "dev.tox.token-menu-bar.verify"
+ public static let verificationOpenPopoverNotification = Notification.Name(
+ "dev.tox.token-menu-bar.verification.open-popover")
+ public static let verificationSnapshotNotification = Notification.Name(
+ "dev.tox.token-menu-bar.verification.snapshot")
+
+ public let mode: Mode
+ public let environment: [String: String]
+ public let defaultsSuiteName: String?
+ public let supportDirectory: URL?
+ public let verificationProfile: VerificationProfile?
+
+ public init(
+ arguments: [String] = CommandLine.arguments, environment: [String: String] = ProcessInfo.processInfo.environment,
+ temporaryDirectory: URL = FileManager.default.temporaryDirectory,
+ verificationIdentifier: String? = nil
+ ) {
+ guard arguments.contains(Self.verificationArgument) || environment[Self.verificationEnvironmentKey] != nil else {
+ mode = .standard
+ self.environment = environment
+ defaultsSuiteName = nil
+ supportDirectory = nil
+ verificationProfile = nil
+ return
+ }
+
+ mode = .verification
+ let session = Self.safeSession(environment[Self.verificationSessionKey] ?? verificationIdentifier ?? "manual")
+ var resolvedEnvironment = environment
+ resolvedEnvironment["TOKEN_MENU_BAR_DEMO"] = "1"
+ resolvedEnvironment["TOKEN_MENU_BAR_OPEN_POPOVER"] = "1"
+ self.environment = resolvedEnvironment
+ defaultsSuiteName = "\(Self.verificationSuitePrefix).\(session)"
+ supportDirectory =
+ environment[Self.verificationSupportDirectoryKey].map {
+ URL(fileURLWithPath: $0, isDirectory: true)
+ } ?? temporaryDirectory.appendingPathComponent("token-menu-bar-verify-\(session)", isDirectory: true)
+ verificationProfile = VerificationProfile(environment: environment)
+ }
+
+ public func defaults(standard: UserDefaults = .standard) -> UserDefaults {
+ guard let defaultsSuiteName else { return standard }
+ let defaults = UserDefaults(suiteName: defaultsSuiteName)!
+ defaults.removePersistentDomain(forName: defaultsSuiteName)
+ return defaults
+ }
+
+ public func cleanup(fileManager: FileManager = .default) throws {
+ guard mode == .verification else { return }
+ if let defaultsSuiteName {
+ UserDefaults(suiteName: defaultsSuiteName)?.removePersistentDomain(forName: defaultsSuiteName)
+ }
+ if let supportDirectory, fileManager.fileExists(atPath: supportDirectory.path) {
+ try fileManager.removeItem(at: supportDirectory)
+ }
+ }
+
+ private static func safeSession(_ value: String) -> String {
+ let safe = String(value.map { $0.isLetter || $0.isNumber || $0 == "-" ? $0 : "-" })
+ return safe.isEmpty ? "session" : safe
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Logging/DiagnosticEvent.swift b/Sources/TokenMenuBarCore/Logging/DiagnosticEvent.swift
new file mode 100644
index 0000000..6bda4ff
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Logging/DiagnosticEvent.swift
@@ -0,0 +1,521 @@
+import CoreGraphics
+import Foundation
+import OSLog
+
+public enum LogCategory: String, CaseIterable, Codable, Sendable {
+ case app
+ case geometry
+ case network
+ case persistence
+ case refresh
+ case status
+ case tabs
+
+ public var title: String {
+ switch self {
+ case .app: "App"
+ case .geometry: "Geometry"
+ case .network: "Network"
+ case .persistence: "Persistence"
+ case .refresh: "Refresh"
+ case .status: "Status item"
+ case .tabs: "Tabs"
+ }
+ }
+}
+
+public struct DiagnosticRect: Sendable, Equatable, CustomStringConvertible {
+ public let x: Double
+ public let y: Double
+ public let width: Double
+ public let height: Double
+
+ public init(x: Double, y: Double, width: Double, height: Double) {
+ self.x = x
+ self.y = y
+ self.width = width
+ self.height = height
+ }
+
+ public init(_ rect: CGRect) {
+ self.init(x: rect.minX, y: rect.minY, width: rect.width, height: rect.height)
+ }
+
+ public var description: String {
+ "(\(DiagnosticNumber.text(x)),\(DiagnosticNumber.text(y)) "
+ + "\(DiagnosticNumber.text(width))x\(DiagnosticNumber.text(height)))"
+ }
+}
+
+public struct DiagnosticSize: Sendable, Equatable, CustomStringConvertible {
+ public let width: Double
+ public let height: Double
+
+ public init(width: Double, height: Double) {
+ self.width = width
+ self.height = height
+ }
+
+ public init(_ size: CGSize) {
+ self.init(width: size.width, height: size.height)
+ }
+
+ public var description: String {
+ "\(DiagnosticNumber.text(width))x\(DiagnosticNumber.text(height))"
+ }
+}
+
+public struct PanelDiagnostic: Sendable, Equatable {
+ public enum Action: String, Sendable {
+ case open
+ case resize
+ case screenChanged = "screen-changed"
+ }
+
+ public let action: Action
+ public let trigger: String
+ public let tab: String
+ public let anchor: DiagnosticRect?
+ public let screenID: String?
+ public let screenFrame: DiagnosticRect?
+ public let maximum: DiagnosticSize
+ public let proposed: DiagnosticSize
+ public let clamped: DiagnosticSize
+ public let resultFrame: DiagnosticRect?
+ public let appActive: Bool
+ public let windowKey: Bool?
+ public let windowMain: Bool?
+ public let frontmostBundleID: String?
+
+ public init(
+ action: Action,
+ trigger: String,
+ tab: String,
+ anchor: DiagnosticRect?,
+ screenID: String?,
+ screenFrame: DiagnosticRect?,
+ maximum: DiagnosticSize,
+ proposed: DiagnosticSize,
+ clamped: DiagnosticSize,
+ resultFrame: DiagnosticRect?,
+ appActive: Bool,
+ windowKey: Bool?,
+ windowMain: Bool?,
+ frontmostBundleID: String?
+ ) {
+ self.action = action
+ self.trigger = trigger
+ self.tab = tab
+ self.anchor = anchor
+ self.screenID = screenID
+ self.screenFrame = screenFrame
+ self.maximum = maximum
+ self.proposed = proposed
+ self.clamped = clamped
+ self.resultFrame = resultFrame
+ self.appActive = appActive
+ self.windowKey = windowKey
+ self.windowMain = windowMain
+ self.frontmostBundleID = frontmostBundleID
+ }
+
+ public static func postResize(
+ trigger: String,
+ tab: String,
+ anchor: DiagnosticRect?,
+ screenID: String?,
+ screenFrame: DiagnosticRect?,
+ maximum: DiagnosticSize,
+ proposed: DiagnosticSize,
+ clamped: DiagnosticSize,
+ resultFrame: DiagnosticRect?,
+ appActive: Bool,
+ windowKey: Bool?,
+ windowMain: Bool?,
+ frontmostBundleID: String?
+ ) -> PanelDiagnostic {
+ PanelDiagnostic(
+ action: .resize,
+ trigger: trigger,
+ tab: tab,
+ anchor: anchor,
+ screenID: screenID,
+ screenFrame: screenFrame,
+ maximum: maximum,
+ proposed: proposed,
+ clamped: clamped,
+ resultFrame: resultFrame,
+ appActive: appActive,
+ windowKey: windowKey,
+ windowMain: windowMain,
+ frontmostBundleID: frontmostBundleID)
+ }
+}
+
+public struct TabDiagnostic: Sendable, Equatable {
+ public enum Action: String, Sendable {
+ case measurement
+ case presented
+ case transition
+ }
+
+ public let action: Action
+ public let from: String?
+ public let to: String?
+ public let sourceTab: String?
+ public let activeTab: String
+ public let filedUnderTab: String?
+ public let size: DiagnosticSize?
+ public let chromeHeight: Double?
+ public let durationMilliseconds: Double?
+
+ public init(
+ action: Action,
+ from: String? = nil,
+ to: String? = nil,
+ sourceTab: String? = nil,
+ activeTab: String,
+ filedUnderTab: String? = nil,
+ size: DiagnosticSize? = nil,
+ chromeHeight: Double? = nil,
+ durationMilliseconds: Double? = nil
+ ) {
+ self.action = action
+ self.from = from
+ self.to = to
+ self.sourceTab = sourceTab
+ self.activeTab = activeTab
+ self.filedUnderTab = filedUnderTab
+ self.size = size
+ self.chromeHeight = chromeHeight
+ self.durationMilliseconds = durationMilliseconds
+ }
+}
+
+public struct StatusDiagnostic: Sendable, Equatable {
+ public enum Action: String, Sendable {
+ case deferred
+ case probe
+ case retier
+ }
+
+ public let action: Action
+ public let trigger: String
+ public let buttonFrame: DiagnosticRect?
+ public let oldTier: Int?
+ public let newTier: Int?
+ public let visible: Bool
+ public let popoverVisible: Bool
+ public let fits: Bool?
+ public let layoutContext: String?
+
+ public init(
+ action: Action,
+ trigger: String,
+ buttonFrame: DiagnosticRect?,
+ oldTier: Int?,
+ newTier: Int?,
+ visible: Bool,
+ popoverVisible: Bool,
+ fits: Bool?,
+ layoutContext: String?
+ ) {
+ self.action = action
+ self.trigger = trigger
+ self.buttonFrame = buttonFrame
+ self.oldTier = oldTier
+ self.newTier = newTier
+ self.visible = visible
+ self.popoverVisible = popoverVisible
+ self.fits = fits
+ self.layoutContext = layoutContext
+ }
+
+ public static func retierIfChanged(
+ trigger: String,
+ buttonFrame: DiagnosticRect?,
+ oldTier: Int,
+ newTier: Int,
+ visible: Bool,
+ popoverVisible: Bool,
+ fits: Bool?,
+ layoutContext: String?
+ ) -> StatusDiagnostic? {
+ guard oldTier != newTier else { return nil }
+ return StatusDiagnostic(
+ action: .retier,
+ trigger: trigger,
+ buttonFrame: buttonFrame,
+ oldTier: oldTier,
+ newTier: newTier,
+ visible: visible,
+ popoverVisible: popoverVisible,
+ fits: fits,
+ layoutContext: layoutContext)
+ }
+}
+
+public enum DiagnosticRefreshOutcome: String, Sendable {
+ case authenticationRequired = "authentication-required"
+ case failed
+ case networkUnavailable = "network-unavailable"
+ case partial
+ case rateLimited = "rate-limited"
+ case skipped
+ case success
+}
+
+public enum DiagnosticRefreshSkipReason: String, Sendable {
+ case analyticsNotDue = "analytics-not-due"
+ case cancelled
+ case disabled
+ case notDiscovered = "not-discovered"
+ case noWork = "no-work"
+ case retryBackoff = "retry-backoff"
+}
+
+public struct RefreshDiagnostic: Sendable, Equatable {
+ public let cycleID: String
+ public let trigger: String
+ public let provider: ProviderID
+ public let usagePolicy: String
+ public let analyticsPolicy: String
+ public let outcome: DiagnosticRefreshOutcome
+ public let durationMilliseconds: Int
+ public let includeAnalytics: Bool
+ public let analyticsReturned: Bool
+ public let analyticsPointCount: Int
+ public let warnings: [String]
+ public let skipReason: DiagnosticRefreshSkipReason?
+
+ public init(
+ cycleID: String,
+ trigger: String,
+ provider: ProviderID,
+ usagePolicy: String,
+ analyticsPolicy: String,
+ outcome: DiagnosticRefreshOutcome,
+ durationMilliseconds: Int,
+ includeAnalytics: Bool,
+ analyticsReturned: Bool,
+ analyticsPointCount: Int,
+ warnings: [String],
+ skipReason: DiagnosticRefreshSkipReason? = nil
+ ) {
+ self.cycleID = cycleID
+ self.trigger = trigger
+ self.provider = provider
+ self.usagePolicy = usagePolicy
+ self.analyticsPolicy = analyticsPolicy
+ self.outcome = outcome
+ self.durationMilliseconds = durationMilliseconds
+ self.includeAnalytics = includeAnalytics
+ self.analyticsReturned = analyticsReturned
+ self.analyticsPointCount = analyticsPointCount
+ self.warnings = warnings
+ self.skipReason = skipReason
+ }
+
+ public static func skipped(
+ cycleID: String,
+ trigger: String,
+ provider: ProviderID,
+ usagePolicy: String,
+ analyticsPolicy: String,
+ reason: DiagnosticRefreshSkipReason
+ ) -> RefreshDiagnostic {
+ RefreshDiagnostic(
+ cycleID: cycleID,
+ trigger: trigger,
+ provider: provider,
+ usagePolicy: usagePolicy,
+ analyticsPolicy: analyticsPolicy,
+ outcome: .skipped,
+ durationMilliseconds: 0,
+ includeAnalytics: false,
+ analyticsReturned: false,
+ analyticsPointCount: 0,
+ warnings: [],
+ skipReason: reason)
+ }
+}
+
+public struct RequestDiagnostic: Sendable, Equatable {
+ public let requestID: String
+ public let operation: String
+ public let method: String
+ public let status: Int?
+ public let byteCount: Int
+ public let durationMilliseconds: Int
+ public let errorDomain: String?
+ public let errorCode: Int?
+
+ public init(
+ requestID: String,
+ operation: String,
+ method: String,
+ status: Int?,
+ byteCount: Int,
+ durationMilliseconds: Int,
+ errorDomain: String? = nil,
+ errorCode: Int? = nil
+ ) {
+ self.requestID = requestID
+ self.operation = operation
+ self.method = method
+ self.status = status
+ self.byteCount = byteCount
+ self.durationMilliseconds = durationMilliseconds
+ self.errorDomain = errorDomain
+ self.errorCode = errorCode
+ }
+
+ public init(
+ requestID: String,
+ operation: String,
+ method: String,
+ byteCount: Int,
+ durationMilliseconds: Int,
+ error: any Error
+ ) {
+ let value = error as NSError
+ self.init(
+ requestID: requestID,
+ operation: operation,
+ method: method,
+ status: nil,
+ byteCount: byteCount,
+ durationMilliseconds: durationMilliseconds,
+ errorDomain: value.domain,
+ errorCode: value.code)
+ }
+}
+
+public enum DiagnosticEvent: Sendable, Equatable {
+ case panel(PanelDiagnostic)
+ case refresh(RefreshDiagnostic)
+ case request(RequestDiagnostic)
+ case status(StatusDiagnostic)
+ case tab(TabDiagnostic)
+
+ public var category: LogCategory {
+ switch self {
+ case .panel: .geometry
+ case .refresh: .refresh
+ case .request: .network
+ case .status: .status
+ case .tab: .tabs
+ }
+ }
+
+ public var message: String {
+ switch self {
+ case .panel(let event):
+ fields(
+ "panel.\(event.action.rawValue)",
+ [
+ ("trigger", event.trigger), ("tab", event.tab), ("anchor", event.anchor?.description),
+ ("screen", event.screenID), ("screenFrame", event.screenFrame?.description),
+ ("max", event.maximum.description), ("proposed", event.proposed.description),
+ ("clamped", event.clamped.description), ("result", event.resultFrame?.description),
+ ("appActive", event.appActive.description), ("windowKey", event.windowKey?.description),
+ ("windowMain", event.windowMain?.description), ("frontmost", event.frontmostBundleID),
+ ])
+ case .tab(let event):
+ fields(
+ "tab.\(event.action.rawValue)",
+ [
+ ("from", event.from), ("to", event.to), ("source", event.sourceTab), ("active", event.activeTab),
+ ("filedUnder", event.filedUnderTab), ("size", event.size?.description),
+ ("chromeHeight", event.chromeHeight.map(DiagnosticNumber.text)),
+ ("durationMs", event.durationMilliseconds.map(DiagnosticNumber.text)),
+ ])
+ case .status(let event):
+ fields(
+ "status.\(event.action.rawValue)",
+ [
+ ("trigger", event.trigger), ("buttonFrame", event.buttonFrame?.description),
+ ("oldTier", event.oldTier.map(String.init)), ("newTier", event.newTier.map(String.init)),
+ ("visible", event.visible.description), ("popoverVisible", event.popoverVisible.description),
+ ("fits", event.fits?.description), ("context", event.layoutContext),
+ ])
+ case .refresh(let event):
+ fields(
+ "refresh.provider",
+ [
+ ("cycle", event.cycleID), ("trigger", event.trigger), ("provider", event.provider.rawValue),
+ ("usagePolicy", event.usagePolicy), ("analyticsPolicy", event.analyticsPolicy),
+ ("outcome", event.outcome.rawValue), ("durationMs", String(event.durationMilliseconds)),
+ ("includeAnalytics", event.includeAnalytics.description),
+ ("analyticsReturned", event.analyticsReturned.description),
+ ("analyticsPoints", String(event.analyticsPointCount)), ("warnings", String(event.warnings.count)),
+ ("skipReason", event.skipReason?.rawValue),
+ ] + event.warnings.enumerated().map { ("warning\($0.offset + 1)", $0.element) }
+ )
+ case .request(let event):
+ fields(
+ "request.finished",
+ [
+ ("id", event.requestID), ("operation", event.operation), ("method", event.method),
+ ("status", event.status.map(String.init)), ("bytes", String(event.byteCount)),
+ ("durationMs", String(event.durationMilliseconds)), ("errorDomain", event.errorDomain),
+ ("errorCode", event.errorCode.map(String.init)),
+ ])
+ }
+ }
+
+ private func fields(_ name: String, _ values: [(String, String?)]) -> String {
+ ([name] + values.compactMap { key, value in value.map { "\(key)=\(DiagnosticValue.text($0))" } })
+ .joined(separator: " ")
+ }
+}
+
+public struct DiagnosticSignposter: Sendable {
+ private let base: OSSignposter
+
+ public init(category: LogCategory) {
+ base = OSSignposter(subsystem: SystemLog.subsystem, category: category.rawValue)
+ }
+
+ public func withInterval(
+ _ name: StaticString, operation: () throws -> Result
+ ) rethrows -> Result {
+ guard base.isEnabled else { return try operation() }
+ let state = base.beginInterval(name)
+ defer { base.endInterval(name, state) }
+ return try operation()
+ }
+
+ public func withInterval(
+ _ name: StaticString, operation: () async throws -> Result
+ ) async rethrows -> Result {
+ guard base.isEnabled else { return try await operation() }
+ let state = base.beginInterval(name)
+ defer { base.endInterval(name, state) }
+ return try await operation()
+ }
+}
+
+public enum DiagnosticSignposts {
+ public static let geometry = DiagnosticSignposter(category: .geometry)
+ public static let refresh = DiagnosticSignposter(category: .refresh)
+ public static let tabs = DiagnosticSignposter(category: .tabs)
+}
+
+enum DiagnosticNumber {
+ static func text(_ value: Double) -> String {
+ value.rounded() == value ? String(Int(value)) : String(format: "%.1f", value)
+ }
+}
+
+enum DiagnosticValue {
+ static func text(_ value: String) -> String {
+ let safe = LogSanitizer.redact(value)
+ guard safe.contains(where: { $0.isWhitespace || $0 == #"""# || $0 == "=" }) else { return safe }
+ return #""\#(safe.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\""))""#
+ }
+}
+
+enum SystemLog {
+ static let subsystem = "dev.tox.token-menu-bar"
+}
diff --git a/Sources/TokenMenuBarCore/Logging/LogBuffer.swift b/Sources/TokenMenuBarCore/Logging/LogBuffer.swift
new file mode 100644
index 0000000..413134f
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Logging/LogBuffer.swift
@@ -0,0 +1,767 @@
+import Foundation
+import OSLog
+
+public enum LogLevel: String, CaseIterable, Codable, Sendable, Comparable {
+ case debug
+ case info
+ case warning
+ case error
+
+ public var title: String {
+ switch self {
+ case .debug: "Debug"
+ case .info: "Info"
+ case .warning: "Warn"
+ case .error: "Error"
+ }
+ }
+
+ public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
+ lhs.order < rhs.order
+ }
+
+ private var order: Int {
+ switch self {
+ case .debug: 0
+ case .info: 1
+ case .warning: 2
+ case .error: 3
+ }
+ }
+}
+
+public struct LogEntry: Sendable, Hashable, Identifiable {
+ public static let maximumLineBytes = 2_048
+
+ public let timestamp: Date
+ public let level: LogLevel
+ public let category: LogCategory
+ public let message: String
+ public let line: String
+ public let sequenceID: UInt64
+
+ public init(timestamp: Date, level: LogLevel, category: LogCategory = .app, message: String) {
+ let safe = LogSanitizer.message(message)
+ self.init(
+ timestamp: timestamp,
+ level: level,
+ category: category,
+ message: safe,
+ line: Self.render(timestamp: timestamp, level: level, category: category, message: safe),
+ sequenceID: LogSequence.next())
+ }
+
+ public var id: UInt64 { sequenceID }
+
+ static func parse(_ text: String, after previous: Date) -> LogEntry {
+ guard let timestamp = storedTimestamp(text) else {
+ return LogEntry(timestamp: previous, level: .info, message: text)
+ }
+ let close = text.index(text.startIndex, offsetBy: 24)
+ let afterTimestamp = text[text.index(after: close)...].drop { $0 == " " }
+ guard afterTimestamp.hasPrefix("["), let levelClose = afterTimestamp.firstIndex(of: "]"),
+ let level = LogLevel(
+ rawValue: String(afterTimestamp[afterTimestamp.index(after: afterTimestamp.startIndex).. Date? {
+ var text = text
+ return text.withUTF8 { storedTimestamp($0) }
+ }
+
+ private static func storedTimestamp(_ bytes: UnsafeBufferPointer) -> Date? {
+ guard bytes.count >= 25,
+ bytes[0] == 0x5B, bytes[5] == 0x2D, bytes[8] == 0x2D, bytes[11] == 0x20,
+ bytes[14] == 0x3A, bytes[17] == 0x3A, bytes[20] == 0x2E, bytes[24] == 0x5D
+ else { return nil }
+
+ func decimal(_ offset: Int, _ count: Int) -> Int? {
+ var value = 0
+ for index in offset..<(offset + count) {
+ let byte = bytes[index]
+ guard byte >= 0x30, byte <= 0x39 else { return nil }
+ value = value * 10 + Int(byte - 0x30)
+ }
+ return value
+ }
+
+ guard let year = decimal(1, 4), let month = decimal(6, 2), let day = decimal(9, 2),
+ let hour = decimal(12, 2), let minute = decimal(15, 2), let second = decimal(18, 2),
+ let millisecond = decimal(21, 3),
+ (1...9999).contains(year), (1...12).contains(month), (0...23).contains(hour), (0...59).contains(minute),
+ (0...59).contains(second), day >= 1, day <= daysInMonth(month, year: year)
+ else { return nil }
+
+ let adjustedYear = year - (month <= 2 ? 1 : 0)
+ let era = adjustedYear / 400
+ let yearOfEra = adjustedYear - era * 400
+ let adjustedMonth = month + (month > 2 ? -3 : 9)
+ let dayOfYear = (153 * adjustedMonth + 2) / 5 + day - 1
+ let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
+ let daysSinceEpoch = era * 146_097 + dayOfEra - 719_468
+ let seconds = daysSinceEpoch * 86_400 + hour * 3_600 + minute * 60 + second
+ return Date(timeIntervalSince1970: Double(seconds) + Double(millisecond) / 1_000)
+ }
+
+ private static func daysInMonth(_ month: Int, year: Int) -> Int {
+ switch month {
+ case 2: year.isMultiple(of: 400) || (year.isMultiple(of: 4) && !year.isMultiple(of: 100)) ? 29 : 28
+ case 4, 6, 9, 11: 30
+ default: 31
+ }
+ }
+
+ private static func stored(
+ timestamp: Date, level: LogLevel, category: LogCategory, message: String, line: String
+ ) -> LogEntry {
+ let safe = LogSanitizer.message(message)
+ return LogEntry(
+ timestamp: timestamp,
+ level: level,
+ category: category,
+ message: safe,
+ line: safe == message ? line : render(timestamp: timestamp, level: level, category: category, message: safe),
+ sequenceID: LogSequence.next())
+ }
+
+ private static func render(timestamp: Date, level: LogLevel, category: LogCategory, message: String) -> String {
+ let prefix = "[\(LogBuffer.timestampFormat.string(from: timestamp))] [\(level.rawValue)]"
+ return category == .app ? "\(prefix) \(message)" : "\(prefix) [\(category.rawValue)] \(message)"
+ }
+
+ func assigningSequenceID(_ sequenceID: UInt64) -> LogEntry {
+ LogEntry(
+ timestamp: timestamp,
+ level: level,
+ category: category,
+ message: message,
+ line: line,
+ sequenceID: sequenceID)
+ }
+
+ private init(
+ timestamp: Date,
+ level: LogLevel,
+ category: LogCategory,
+ message: String,
+ line: String,
+ sequenceID: UInt64
+ ) {
+ self.timestamp = timestamp
+ self.level = level
+ self.category = category
+ self.message = message
+ self.line = line
+ self.sequenceID = sequenceID
+ }
+}
+
+private enum LogSequence {
+ static let lock = NSLock()
+ nonisolated(unsafe) static var value: UInt64 = 0
+
+ static func next() -> UInt64 {
+ lock.withLock {
+ value += 1
+ return value
+ }
+ }
+}
+
+public final class LogSubscription: @unchecked Sendable {
+ private let lock = NSLock()
+ private var cancellation: (@Sendable () -> Void)?
+
+ init(cancellation: @escaping @Sendable () -> Void) {
+ self.cancellation = cancellation
+ }
+
+ public func cancel() {
+ let action = lock.withLock {
+ defer { cancellation = nil }
+ return cancellation
+ }
+ action?()
+ }
+
+ deinit {
+ cancel()
+ }
+}
+
+public final class LogBuffer: @unchecked Sendable {
+ public static let capacity = 500
+ public static let retention: TimeInterval = 7 * 86_400
+ public static let flushInterval: TimeInterval = 5
+ public static let fileByteLimit = 1_048_576
+ public static let retainedFileCount = 3
+ public static let pendingCapacity = capacity * 2
+ public static let retainedEntryLimit = 100_000
+
+ private static let notificationInterval: TimeInterval = 0.05
+
+ static let timestampFormat: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(identifier: "UTC")
+ formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
+ return formatter
+ }()
+
+ private let lock = NSLock()
+ private let clock: Clock
+ private let fileURL: URL?
+ private let maximumFileBytes: Int
+ private let maximumFileCount: Int
+ private let ioQueue = DispatchQueue(label: "dev.tox.token-menu-bar.log-file", qos: .utility)
+ private let notificationQueue = DispatchQueue(label: "dev.tox.token-menu-bar.log-observers", qos: .utility)
+ private var entries: [LogEntry] = []
+ private var unwritten: [LogEntry] = []
+ private var observers: [UUID: @Sendable ([LogEntry]) -> Void] = [:]
+ private var lastPrune: Date?
+ private var flushScheduled = false
+ private var notificationScheduled = false
+ private var rewriteOnFlush = false
+ private var _debugEnabled = false
+
+ public init(
+ fileURL: URL?,
+ clock: Clock = .system,
+ maximumFileBytes: Int = LogBuffer.fileByteLimit,
+ maximumFileCount: Int = LogBuffer.retainedFileCount
+ ) {
+ self.fileURL = fileURL
+ self.clock = clock
+ self.maximumFileBytes = max(maximumFileBytes, 1)
+ self.maximumFileCount = max(maximumFileCount, 1)
+ guard let fileURL else { return }
+ let now = clock.now()
+ let stored = Self.load(
+ fileURL: fileURL,
+ count: self.maximumFileCount,
+ after: now.addingTimeInterval(-Self.retention),
+ maximumFileBytes: self.maximumFileBytes,
+ maximumEntries: Self.retainedEntryLimit)
+ entries = Array(stored.entries.suffix(Self.capacity))
+ lastPrune = now
+ guard stored.droppedEntries else { return }
+ let loaded = stored.entries
+ ioQueue.sync {
+ do {
+ try rewriteStorage(loaded)
+ } catch {
+ SystemLogSink.persistenceError(error)
+ lock.withLock { rewriteOnFlush = true }
+ }
+ }
+ }
+
+ public var debugEnabled: Bool {
+ get { lock.withLock { _debugEnabled } }
+ set { lock.withLock { _debugEnabled = newValue } }
+ }
+
+ public var snapshot: [LogEntry] {
+ lock.withLock { entries }
+ }
+
+ public var text: String {
+ LogExport.text(entries: snapshot)
+ }
+
+ public func log(_ message: @autoclosure () -> String) {
+ append(.info, category: .app, message())
+ }
+
+ public func logInfo(_ message: @autoclosure () -> String, category: LogCategory = .app) {
+ append(.info, category: category, message())
+ }
+
+ public func logWarning(_ message: @autoclosure () -> String, category: LogCategory = .app) {
+ append(.warning, category: category, message())
+ }
+
+ public func logError(_ message: @autoclosure () -> String, category: LogCategory = .app) {
+ append(.error, category: category, message())
+ }
+
+ public func logDebug(_ message: @autoclosure () -> String, category: LogCategory = .app) {
+ guard debugEnabled else { return }
+ append(.debug, category: category, message())
+ }
+
+ public func detailed(_ event: @autoclosure () -> DiagnosticEvent) {
+ guard debugEnabled else { return }
+ record(event(), level: .debug)
+ }
+
+ public func record(_ event: DiagnosticEvent, level: LogLevel) {
+ SystemLogSink.record(event, level: level)
+ append(level, category: event.category, event.message, writeSystemLog: false)
+ }
+
+ public func tail(_ count: Int) -> [LogEntry] {
+ lock.withLock { Array(entries.suffix(max(count, 0))) }
+ }
+
+ public func filtered(_ filter: LogFilter) -> [LogEntry] {
+ filter.entries(from: snapshot)
+ }
+
+ public func export(filter: LogFilter = LogFilter(), header: LogExportHeader? = nil) -> String {
+ LogExport.text(entries: filtered(filter), header: header)
+ }
+
+ public func subscribe(_ observer: @escaping @Sendable () -> Void) -> LogSubscription {
+ let id = UUID()
+ lock.withLock { observers[id] = { _ in observer() } }
+ return LogSubscription { [weak self] in self?.removeObserver(id) }
+ }
+
+ public func snapshots() -> AsyncStream<[LogEntry]> {
+ AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
+ let id = UUID()
+ let subscription = lock.withLock {
+ observers[id] = { continuation.yield($0) }
+ continuation.yield(entries)
+ return LogSubscription { [weak self] in self?.removeObserver(id) }
+ }
+ continuation.onTermination = { _ in subscription.cancel() }
+ }
+ }
+
+ public func retainedSnapshot() async -> [LogEntry] {
+ guard let fileURL else { return snapshot }
+ return await withCheckedContinuation { continuation in
+ ioQueue.async { [self] in
+ flushPending()
+ let result = Self.load(
+ fileURL: fileURL,
+ count: maximumFileCount,
+ after: clock.now().addingTimeInterval(-Self.retention),
+ maximumFileBytes: maximumFileBytes,
+ maximumEntries: Self.retainedEntryLimit)
+ continuation.resume(returning: Self.align(result.entries, with: snapshot))
+ }
+ }
+ }
+
+ public func flush() {
+ ioQueue.sync { flushPending() }
+ }
+
+ public func clear() {
+ lock.withLock {
+ entries.removeAll(keepingCapacity: true)
+ unwritten.removeAll(keepingCapacity: true)
+ flushScheduled = false
+ rewriteOnFlush = false
+ }
+ let failure = ioQueue.sync {
+ do {
+ try clearStorage()
+ return false
+ } catch {
+ SystemLogSink.persistenceError(error)
+ return true
+ }
+ }
+ if failure { requeue(.rewrite([])) }
+ scheduleObserverNotification()
+ }
+
+ private func append(
+ _ level: LogLevel, category: LogCategory, _ message: String, writeSystemLog: Bool = true
+ ) {
+ let result = lock.withLock {
+ let now = clock.now()
+ let entry = LogEntry(timestamp: now, level: level, category: category, message: message)
+ entries.append(entry)
+ if fileURL != nil {
+ unwritten.append(entry)
+ if unwritten.count > Self.pendingCapacity {
+ unwritten.removeFirst(unwritten.count - Self.pendingCapacity)
+ rewriteOnFlush = true
+ }
+ }
+ if entries.count > Self.capacity { entries.removeFirst(entries.count - Self.capacity) }
+ rewriteOnFlush = prune(now: now) || rewriteOnFlush
+ guard fileURL != nil, !flushScheduled else { return (false, entry.message) }
+ flushScheduled = true
+ return (true, entry.message)
+ }
+ if writeSystemLog { SystemLogSink.record(result.1, level: level, category: category) }
+ if result.0 {
+ ioQueue.asyncAfter(deadline: .now() + Self.flushInterval) { [weak self] in self?.flushPending() }
+ }
+ scheduleObserverNotification()
+ }
+
+ private func prune(now: Date) -> Bool {
+ guard lastPrune.map({ now.timeIntervalSince($0) >= 3_600 }) ?? true else { return false }
+ let count = entries.count
+ entries.removeAll { $0.timestamp < now.addingTimeInterval(-Self.retention) }
+ lastPrune = now
+ return entries.count != count
+ }
+
+ private func flushPending() {
+ let work: FlushWork? = lock.withLock {
+ flushScheduled = false
+ if rewriteOnFlush {
+ rewriteOnFlush = false
+ unwritten.removeAll(keepingCapacity: true)
+ return FlushWork.rewrite(entries)
+ }
+ guard !unwritten.isEmpty else { return nil }
+ defer { unwritten.removeAll(keepingCapacity: true) }
+ return FlushWork.append(unwritten)
+ }
+ guard let work else { return }
+ do {
+ switch work {
+ case .append(let pending): try appendToStorage(pending)
+ case .rewrite(let replacement): try rewriteStorage(replacement)
+ }
+ } catch {
+ SystemLogSink.persistenceError(error)
+ requeue(work)
+ }
+ }
+
+ private func requeue(_ work: FlushWork) {
+ let shouldRetry = lock.withLock {
+ switch work {
+ case .append(let pending):
+ unwritten.insert(contentsOf: pending, at: 0)
+ if unwritten.count > Self.pendingCapacity {
+ unwritten.removeFirst(unwritten.count - Self.pendingCapacity)
+ rewriteOnFlush = true
+ }
+ case .rewrite:
+ rewriteOnFlush = true
+ }
+ guard fileURL != nil, !flushScheduled else { return false }
+ flushScheduled = true
+ return true
+ }
+ guard shouldRetry else { return }
+ ioQueue.asyncAfter(deadline: .now() + Self.flushInterval) { [weak self] in self?.flushPending() }
+ }
+
+ private func scheduleObserverNotification() {
+ let shouldNotify = lock.withLock {
+ guard !observers.isEmpty, !notificationScheduled else { return false }
+ notificationScheduled = true
+ return true
+ }
+ guard shouldNotify else { return }
+ notificationQueue.asyncAfter(deadline: .now() + Self.notificationInterval) { [weak self] in
+ self?.notifyObservers()
+ }
+ }
+
+ private func notifyObservers() {
+ let (callbacks, snapshot) = lock.withLock {
+ notificationScheduled = false
+ return (Array(observers.values), entries)
+ }
+ for callback in callbacks { callback(snapshot) }
+ }
+
+ private func removeObserver(_ id: UUID) {
+ _ = lock.withLock { observers.removeValue(forKey: id) }
+ }
+
+ private func appendToStorage(_ pending: [LogEntry]) throws {
+ guard let fileURL, !pending.isEmpty else { return }
+ try FileManager.default.createDirectory(
+ at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
+ var size = Self.fileSize(fileURL)
+ var handle = try Self.open(fileURL)
+ defer { try? handle.close() }
+ for entry in pending {
+ let data = Self.fileData(entry, maximumBytes: maximumFileBytes)
+ if size > 0, size + data.count > maximumFileBytes {
+ try handle.close()
+ try rotateStorage(fileURL)
+ handle = try Self.open(fileURL)
+ size = 0
+ }
+ try handle.write(contentsOf: data)
+ size += data.count
+ }
+ }
+
+ private func rewriteStorage(_ replacement: [LogEntry]) throws {
+ try clearStorage()
+ try appendToStorage(replacement)
+ }
+
+ private func rotateStorage(_ fileURL: URL) throws {
+ if maximumFileCount == 1 {
+ if FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) }
+ return
+ }
+ for index in stride(from: maximumFileCount - 1, through: 1, by: -1) {
+ let source = index == 1 ? fileURL : Self.archive(fileURL, index: index - 1)
+ let destination = Self.archive(fileURL, index: index)
+ if FileManager.default.fileExists(atPath: destination.path) {
+ try FileManager.default.removeItem(at: destination)
+ }
+ if FileManager.default.fileExists(atPath: source.path) {
+ try FileManager.default.moveItem(at: source, to: destination)
+ }
+ }
+ }
+
+ private func clearStorage() throws {
+ guard let fileURL else { return }
+ for index in 0.. LoadResult {
+ var previous = cutoff
+ var loaded: [LogEntry] = []
+ var droppedEntries = false
+ var text = ""
+ let urls = stride(from: count - 1, through: 0, by: -1).map {
+ $0 == 0 ? fileURL : archive(fileURL, index: $0)
+ }
+ for url in urls {
+ guard let contents = try? boundedContents(of: url, maximumBytes: maximumFileBytes) else { continue }
+ droppedEntries = contents.truncated || droppedEntries
+ text += contents.text + "\n"
+ }
+ let lines = text.split(separator: "\n", omittingEmptySubsequences: true)
+ droppedEntries = lines.count > maximumEntries || droppedEntries
+ for line in lines.suffix(maximumEntries) {
+ let entry = LogEntry.parse(String(line), after: previous)
+ previous = entry.timestamp
+ if entry.timestamp >= cutoff {
+ loaded.append(entry)
+ } else {
+ droppedEntries = true
+ }
+ }
+ return LoadResult(entries: loaded, droppedEntries: droppedEntries)
+ }
+
+ private static func align(_ retained: [LogEntry], with live: [LogEntry]) -> [LogEntry] {
+ guard !retained.isEmpty, !live.isEmpty else { return retained }
+ var aligned = retained
+ var retainedIndex = retained.index(before: retained.endIndex)
+ var liveIndex = live.index(before: live.endIndex)
+ while retained[retainedIndex].line == live[liveIndex].line {
+ aligned[retainedIndex] = retained[retainedIndex].assigningSequenceID(live[liveIndex].sequenceID)
+ guard retainedIndex != retained.startIndex, liveIndex != live.startIndex else { break }
+ retained.formIndex(before: &retainedIndex)
+ live.formIndex(before: &liveIndex)
+ }
+ return aligned
+ }
+
+ private static func boundedContents(of url: URL, maximumBytes: Int) throws -> BoundedContents {
+ let handle = try FileHandle(forReadingFrom: url)
+ defer { try? handle.close() }
+ let size = try handle.seekToEnd()
+ let limit = UInt64(max(maximumBytes, 1))
+ let offset = size > limit ? size - limit : 0
+ try handle.seek(toOffset: offset)
+ let data = handle.readData(ofLength: Int(size - offset))
+ var text = String(decoding: data, as: UTF8.self)
+ if offset > 0 {
+ guard let newline = text.firstIndex(of: "\n") else { return BoundedContents(text: "", truncated: true) }
+ text.removeSubrange(...newline)
+ }
+ return BoundedContents(text: text, truncated: offset > 0)
+ }
+
+ private static func fileData(_ entry: LogEntry, maximumBytes: Int) -> Data {
+ let data = Data((entry.line + "\n").utf8)
+ guard data.count > maximumBytes else { return data }
+ guard maximumBytes > 1 else { return Data("\n".utf8.prefix(maximumBytes)) }
+ var bytes = Array(entry.line.utf8.prefix(maximumBytes - 1))
+ while String(bytes: bytes, encoding: .utf8) == nil { bytes.removeLast() }
+ bytes.append(0x0A)
+ return Data(bytes)
+ }
+
+ private static func open(_ url: URL) throws -> FileHandle {
+ if !FileManager.default.fileExists(atPath: url.path) { try Data().write(to: url, options: .atomic) }
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
+ let handle = try FileHandle(forWritingTo: url)
+ _ = try handle.seekToEnd()
+ return handle
+ }
+
+ private static func fileSize(_ url: URL) -> Int {
+ let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
+ return (attributes?[.size] as? NSNumber)?.intValue ?? 0
+ }
+
+ private static func archive(_ url: URL, index: Int) -> URL {
+ URL(fileURLWithPath: "\(url.path).\(index)")
+ }
+
+ private enum FlushWork {
+ case append([LogEntry])
+ case rewrite([LogEntry])
+ }
+
+ private struct LoadResult {
+ let entries: [LogEntry]
+ let droppedEntries: Bool
+ }
+
+ private struct BoundedContents {
+ let text: String
+ let truncated: Bool
+ }
+}
+
+private enum SystemLogSink {
+ static func record(_ message: String, level: LogLevel, category: LogCategory) {
+ logger(category).log(level: level.osLogType, "\(message, privacy: .private(mask: .hash))")
+ }
+
+ static func record(_ event: DiagnosticEvent, level: LogLevel) {
+ let logger = logger(event.category)
+ switch event {
+ case .panel(let value):
+ let anchor = value.anchor?.description ?? "-"
+ let screen = value.screenID ?? "-"
+ let screenFrame = value.screenFrame?.description ?? "-"
+ let result = value.resultFrame?.description ?? "-"
+ let frontmost = value.frontmostBundleID ?? "-"
+ logger.log(
+ level: level.osLogType,
+ """
+ panel action=\(value.action.rawValue, privacy: .public) \
+ trigger=\(value.trigger, privacy: .public) tab=\(value.tab, privacy: .public) \
+ anchor=\(anchor, privacy: .public) screen=\(screen, privacy: .public) \
+ screenFrame=\(screenFrame, privacy: .public) max=\(value.maximum.description, privacy: .public) \
+ proposed=\(value.proposed.description, privacy: .public) \
+ clamped=\(value.clamped.description, privacy: .public) result=\(result, privacy: .public) \
+ appActive=\(value.appActive, privacy: .public) \
+ frontmost=\(frontmost, privacy: .private(mask: .hash))
+ """
+ )
+ case .tab(let value):
+ let from = value.from ?? "-"
+ let to = value.to ?? "-"
+ let source = value.sourceTab ?? "-"
+ let filedUnder = value.filedUnderTab ?? "-"
+ let size = value.size?.description ?? "-"
+ logger.log(
+ level: level.osLogType,
+ """
+ tab action=\(value.action.rawValue, privacy: .public) from=\(from, privacy: .public) \
+ to=\(to, privacy: .public) source=\(source, privacy: .public) \
+ active=\(value.activeTab, privacy: .public) filedUnder=\(filedUnder, privacy: .public) \
+ size=\(size, privacy: .public)
+ """
+ )
+ case .status(let value):
+ let frame = value.buttonFrame?.description ?? "-"
+ let oldTier = value.oldTier ?? -1
+ let newTier = value.newTier ?? -1
+ let context = value.layoutContext ?? "-"
+ logger.log(
+ level: level.osLogType,
+ """
+ status action=\(value.action.rawValue, privacy: .public) \
+ trigger=\(value.trigger, privacy: .public) frame=\(frame, privacy: .public) \
+ oldTier=\(oldTier, privacy: .public) newTier=\(newTier, privacy: .public) \
+ visible=\(value.visible, privacy: .public) popoverVisible=\(value.popoverVisible, privacy: .public) \
+ context=\(context, privacy: .private(mask: .hash))
+ """
+ )
+ case .refresh(let value):
+ let skipReason = value.skipReason?.rawValue ?? "-"
+ logger.log(
+ level: level.osLogType,
+ """
+ refresh cycle=\(value.cycleID, privacy: .public) trigger=\(value.trigger, privacy: .public) \
+ provider=\(value.provider.rawValue, privacy: .public) \
+ usagePolicy=\(value.usagePolicy, privacy: .public) \
+ analyticsPolicy=\(value.analyticsPolicy, privacy: .public) \
+ outcome=\(value.outcome.rawValue, privacy: .public) skipReason=\(skipReason, privacy: .public) \
+ durationMs=\(value.durationMilliseconds, privacy: .public) \
+ includeAnalytics=\(value.includeAnalytics, privacy: .public) \
+ analyticsReturned=\(value.analyticsReturned, privacy: .public) \
+ analyticsPoints=\(value.analyticsPointCount, privacy: .public) \
+ warnings=\(value.warnings.count, privacy: .public)
+ """
+ )
+ case .request(let value):
+ let status = value.status ?? 0
+ let domain = value.errorDomain ?? "-"
+ let code = value.errorCode ?? 0
+ logger.log(
+ level: level.osLogType,
+ """
+ request id=\(value.requestID, privacy: .public) operation=\(value.operation, privacy: .public) \
+ method=\(value.method, privacy: .public) status=\(status, privacy: .public) \
+ bytes=\(value.byteCount, privacy: .public) \
+ durationMs=\(value.durationMilliseconds, privacy: .public) \
+ errorDomain=\(domain, privacy: .public) errorCode=\(code, privacy: .public)
+ """
+ )
+ }
+ }
+
+ static func persistenceError(_ error: any Error) {
+ let value = error as NSError
+ persistence.error(
+ "log file operation failed domain=\(value.domain, privacy: .public) code=\(value.code, privacy: .public)")
+ }
+
+ private static func logger(_ category: LogCategory) -> Logger {
+ switch category {
+ case .app: app
+ case .geometry: geometry
+ case .network: network
+ case .persistence: persistence
+ case .refresh: refresh
+ case .status: status
+ case .tabs: tabs
+ }
+ }
+
+ private static let app = Logger(subsystem: SystemLog.subsystem, category: LogCategory.app.rawValue)
+ private static let geometry = Logger(subsystem: SystemLog.subsystem, category: LogCategory.geometry.rawValue)
+ private static let network = Logger(subsystem: SystemLog.subsystem, category: LogCategory.network.rawValue)
+ private static let persistence = Logger(subsystem: SystemLog.subsystem, category: LogCategory.persistence.rawValue)
+ private static let refresh = Logger(subsystem: SystemLog.subsystem, category: LogCategory.refresh.rawValue)
+ private static let status = Logger(subsystem: SystemLog.subsystem, category: LogCategory.status.rawValue)
+ private static let tabs = Logger(subsystem: SystemLog.subsystem, category: LogCategory.tabs.rawValue)
+}
+
+extension LogLevel {
+ fileprivate var osLogType: OSLogType {
+ switch self {
+ case .debug: .debug
+ case .info: .info
+ case .warning: .default
+ case .error: .error
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Logging/LogQuery.swift b/Sources/TokenMenuBarCore/Logging/LogQuery.swift
new file mode 100644
index 0000000..56870f1
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Logging/LogQuery.swift
@@ -0,0 +1,127 @@
+import Foundation
+
+public struct LogFilter: Sendable, Equatable {
+ public var search: String
+ public var levels: Set
+ public var categories: Set
+
+ public init(
+ search: String = "", levels: Set = Set(LogLevel.allCases),
+ categories: Set = Set(LogCategory.allCases)
+ ) {
+ self.search = search
+ self.levels = levels
+ self.categories = categories
+ }
+
+ public func entries(from source: some Sequence) -> [LogEntry] {
+ let term = search.trimmingCharacters(in: .whitespacesAndNewlines)
+ return source.filter {
+ levels.contains($0.level) && categories.contains($0.category)
+ && (term.isEmpty || $0.line.localizedCaseInsensitiveContains(term))
+ }
+ }
+}
+
+public struct LogExportHeader: Sendable, Equatable {
+ public let appName: String
+ public let sourceVersion: String
+ public let build: String
+ public let distribution: String
+ public let osVersion: String
+
+ public init(app: AppInfo, osVersion: String) {
+ appName = app.name
+ sourceVersion = app.sourceVersion
+ build = app.build
+ distribution = app.distribution.displayName
+ self.osVersion = osVersion
+ }
+
+ public init(appName: String, sourceVersion: String, build: String, distribution: String, osVersion: String) {
+ self.appName = appName
+ self.sourceVersion = sourceVersion
+ self.build = build
+ self.distribution = distribution
+ self.osVersion = osVersion
+ }
+}
+
+public enum LogExport {
+ public static func text(entries: some Sequence, header: LogExportHeader? = nil) -> String {
+ var lines: [String] = []
+ if let header {
+ lines = [
+ "\(header.appName) \(header.sourceVersion) (\(header.build)) \(header.distribution)",
+ "macOS \(header.osVersion)",
+ "",
+ ].map(LogSanitizer.redact)
+ }
+ lines += entries.map(\.line)
+ return lines.joined(separator: "\n")
+ }
+}
+
+public enum LogSanitizer {
+ public static let maximumMessageBytes = 1_900
+
+ public static func message(_ value: String) -> String {
+ truncate(
+ redact(
+ value.replacingOccurrences(of: "\r", with: "\\r").replacingOccurrences(of: "\n", with: "\\n")),
+ maximumBytes: maximumMessageBytes)
+ }
+
+ public static func redact(_ value: String) -> String {
+ var text = value.replacingOccurrences(
+ of: FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path, with: "~")
+ for rule in rules.expressions {
+ let range = NSRange(text.startIndex.. String {
+ guard value.utf8.count > maximumBytes else { return value }
+ let suffix = "…"
+ var bytes = Array(value.utf8.prefix(max(maximumBytes - suffix.utf8.count, 0)))
+ while String(bytes: bytes, encoding: .utf8) == nil { bytes.removeLast() }
+ return String(decoding: bytes, as: UTF8.self) + suffix
+ }
+
+ private static let rules = Rules()
+
+ private final class Rules: @unchecked Sendable {
+ let expressions: [(expression: NSRegularExpression, replacement: String)]
+
+ init() {
+ expressions = [
+ Self.rule(#"\b(?:request|response)?[_-]?body\s*=[^\r\n]*"#, "body="),
+ Self.rule(
+ #"\b(authorization|proxy[_-]?authorization)\s*[:=]\s*(?:Bearer|Basic)\s+[^\s,;}\]\r\n]+"#,
+ "$1="),
+ Self.rule(#"\b(Bearer|Basic)\s+[A-Za-z0-9+/_=.:-]+"#, "$1 "),
+ Self.rule(
+ #"(?:\\?[\"'])?\b(authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|secret|"#
+ + #"client[_-]?secret|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token)"#
+ + #"(?:\\?[\"'])?\s*[:=]\s*(?:\\?[\"'][^\"'\r\n]*\\?[\"']|[^\s\\,;}\]\r\n]+)"#,
+ "$1="),
+ Self.rule(#"([a-z][a-z0-9+.-]*://[^ \t\r\n?]+)\?[^ \t\r\n\\]*"#, "$1?"),
+ Self.rule(#"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b"#, ""),
+ ]
+ }
+
+ private static func rule(
+ _ pattern: String, _ replacement: String
+ ) -> (
+ expression: NSRegularExpression, replacement: String
+ ) {
+ (
+ try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive]),
+ replacement
+ )
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Models/Analytics.swift b/Sources/TokenMenuBarCore/Models/Analytics.swift
new file mode 100644
index 0000000..400a7d5
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Models/Analytics.swift
@@ -0,0 +1,218 @@
+import Foundation
+
+public enum AnalyticsMetric: String, Codable, CaseIterable, Sendable, Hashable {
+ case surfaceUsagePercent
+ case modelCredits
+ case turns
+ case threads
+ case credits
+ case inputTokens
+ case cachedInputTokens
+ case outputTokens
+ case skillInvocations
+ case pluginInvocations
+ case codeReviews
+ case cacheWriteTokens
+ case costUSD
+ case messages
+ case sessions
+ case toolCalls
+
+ public var title: String {
+ switch self {
+ case .surfaceUsagePercent: "Usage by surface"
+ case .modelCredits: "Credits by model"
+ case .turns: "Turns"
+ case .threads: "Threads"
+ case .credits: "Credits"
+ case .inputTokens: "Input tokens"
+ case .cachedInputTokens: "Cached input tokens"
+ case .outputTokens: "Output tokens"
+ case .skillInvocations: "Skills used"
+ case .pluginInvocations: "Plugin calls"
+ case .codeReviews: "Code reviews"
+ case .cacheWriteTokens: "Cache write tokens"
+ case .costUSD: "API-equivalent cost"
+ case .messages: "Messages"
+ case .sessions: "Sessions"
+ case .toolCalls: "Tool calls"
+ }
+ }
+
+ public var unit: String {
+ switch self {
+ case .surfaceUsagePercent: "%"
+ case .modelCredits, .credits: "credits"
+ case .inputTokens, .cachedInputTokens, .outputTokens, .cacheWriteTokens: "tokens"
+ case .costUSD: "USD"
+ default: "count"
+ }
+ }
+}
+
+public struct AnalyticsPoint: Codable, Sendable, Hashable {
+ public let day: String
+ public let metric: AnalyticsMetric
+ public let series: String
+ public let value: Double
+
+ public init(day: String, metric: AnalyticsMetric, series: String, value: Double) {
+ self.day = day
+ self.metric = metric
+ self.series = series
+ self.value = value
+ }
+}
+
+public struct CreditEvent: Codable, Sendable, Hashable, Identifiable {
+ public let id: String
+ public let date: Date
+ public let service: String
+ public let creditsUsed: Double
+
+ public init(id: String, date: Date, service: String, creditsUsed: Double) {
+ self.id = id
+ self.date = date
+ self.service = service
+ self.creditsUsed = creditsUsed
+ }
+}
+
+public struct AnalyticsCoverageScope: Codable, Sendable, Hashable {
+ public let metrics: Set
+ public let startDay: String
+ public let endDay: String
+
+ public init(metrics: Set, startDay: String, endDay: String) {
+ self.metrics = metrics
+ self.startDay = startDay
+ self.endDay = endDay
+ }
+
+ public func contains(_ point: AnalyticsPoint) -> Bool {
+ metrics.contains(point.metric) && point.day >= startDay && point.day <= endDay
+ }
+}
+
+public struct ProviderAnalytics: Codable, Sendable, Hashable {
+ public let provider: ProviderID
+ public let points: [AnalyticsPoint]
+ public let creditEvents: [CreditEvent]
+ public let fetchedAt: Date
+ public let accountFingerprint: String?
+ public let coveredScopes: [AnalyticsCoverageScope]
+
+ public init(
+ provider: ProviderID,
+ points: [AnalyticsPoint],
+ creditEvents: [CreditEvent] = [],
+ fetchedAt: Date,
+ accountFingerprint: String? = nil,
+ coveredScopes: [AnalyticsCoverageScope] = []
+ ) {
+ self.provider = provider
+ self.points = points
+ self.creditEvents = creditEvents
+ self.fetchedAt = fetchedAt
+ self.accountFingerprint = accountFingerprint
+ self.coveredScopes = coveredScopes
+ }
+
+ private enum CodingKeys: CodingKey {
+ case provider, points, creditEvents, fetchedAt, accountFingerprint, coveredScopes
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ provider = try container.decode(ProviderID.self, forKey: .provider)
+ points = try container.decode([AnalyticsPoint].self, forKey: .points)
+ creditEvents = try container.decode([CreditEvent].self, forKey: .creditEvents)
+ fetchedAt = try container.decode(Date.self, forKey: .fetchedAt)
+ accountFingerprint = try container.decodeIfPresent(String.self, forKey: .accountFingerprint)
+ coveredScopes = try container.decodeIfPresent([AnalyticsCoverageScope].self, forKey: .coveredScopes) ?? []
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(provider, forKey: .provider)
+ try container.encode(points, forKey: .points)
+ try container.encode(creditEvents, forKey: .creditEvents)
+ try container.encode(fetchedAt, forKey: .fetchedAt)
+ try container.encodeIfPresent(accountFingerprint, forKey: .accountFingerprint)
+ try container.encode(coveredScopes, forKey: .coveredScopes)
+ }
+
+ public func total(_ metric: AnalyticsMetric) -> Double {
+ points.filter { $0.metric == metric }.reduce(0) { $0 + $1.value }
+ }
+
+ public func series(for metric: AnalyticsMetric) -> [String] {
+ Array(Set(points.filter { $0.metric == metric }.map(\.series))).sorted()
+ }
+
+ public func merging(_ newer: ProviderAnalytics, retentionDays: Int) -> ProviderAnalytics {
+ let cutoff = DayStamp.string(
+ newer.fetchedAt.addingTimeInterval(-Double(max(retentionDays - 1, 0)) * 86400))
+ let end = DayStamp.string(newer.fetchedAt)
+ let cutoffDate = DayStamp.date(cutoff)!
+ let endDate = DayStamp.date(end)!.addingTimeInterval(86400)
+ let canMerge = provider == newer.provider && accountFingerprint == newer.accountFingerprint
+ var mergedPoints: [AnalyticsPointKey: AnalyticsPoint] = [:]
+ for point in canMerge ? points : []
+ where point.day >= cutoff && point.day <= end && !newer.coveredScopes.contains(where: { $0.contains(point) }) {
+ mergedPoints[AnalyticsPointKey(point)] = point
+ }
+ for point in newer.points where point.day >= cutoff && point.day <= end {
+ mergedPoints[AnalyticsPointKey(point)] = point
+ }
+ var mergedEvents: [String: CreditEvent] = [:]
+ for event in canMerge ? creditEvents : [] where event.date >= cutoffDate && event.date < endDate {
+ mergedEvents[event.id] = event
+ }
+ for event in newer.creditEvents where event.date >= cutoffDate && event.date < endDate {
+ mergedEvents[event.id] = event
+ }
+ return ProviderAnalytics(
+ provider: newer.provider,
+ points: mergedPoints.values.sorted {
+ ($0.day, $0.metric.rawValue, $0.series) < ($1.day, $1.metric.rawValue, $1.series)
+ },
+ creditEvents: mergedEvents.values.sorted {
+ $0.date == $1.date ? $0.id < $1.id : $0.date < $1.date
+ },
+ fetchedAt: newer.fetchedAt,
+ accountFingerprint: newer.accountFingerprint,
+ coveredScopes: newer.coveredScopes)
+ }
+}
+
+private struct AnalyticsPointKey: Hashable {
+ let day: String
+ let metric: AnalyticsMetric
+ let series: String
+
+ init(_ point: AnalyticsPoint) {
+ day = point.day
+ metric = point.metric
+ series = point.series
+ }
+}
+
+public enum DayStamp {
+ private static let formatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.calendar = Calendar(identifier: .iso8601)
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(identifier: "UTC")
+ formatter.dateFormat = "yyyy-MM-dd"
+ return formatter
+ }()
+
+ public static func string(_ date: Date) -> String {
+ formatter.string(from: date)
+ }
+
+ public static func date(_ stamp: String) -> Date? {
+ formatter.date(from: stamp)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Models/FetchOutcome.swift b/Sources/TokenMenuBarCore/Models/FetchOutcome.swift
new file mode 100644
index 0000000..96fdae7
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Models/FetchOutcome.swift
@@ -0,0 +1,119 @@
+import Foundation
+
+public enum ProviderFetchOutcome: Sendable, Equatable {
+ case success(ProviderSnapshot)
+ case partial(ProviderSnapshot, String)
+ case notAuthenticated(String)
+ case networkUnavailable(String)
+ case rateLimited(String, retryAfter: TimeInterval?)
+ case failed(String)
+
+ public var snapshot: ProviderSnapshot? {
+ switch self {
+ case .success(let snapshot), .partial(let snapshot, _): snapshot
+ default: nil
+ }
+ }
+
+ public var errorDescription: String? {
+ switch self {
+ case .success: nil
+ case .partial(_, let message), .notAuthenticated(let message), .networkUnavailable(let message),
+ .failed(let message), .rateLimited(let message, _):
+ message
+ }
+ }
+}
+
+public enum QuotaAvailability: String, Sendable, Equatable {
+ case loading
+ case current
+ case stale
+ case authenticationRequired
+ case networkUnavailable
+ case rateLimited
+ case unavailable
+ case disabled
+
+ public var title: String {
+ switch self {
+ case .loading: "Loading"
+ case .current: "Up to date"
+ case .stale: "Showing last known values"
+ case .authenticationRequired: "Sign-in required"
+ case .networkUnavailable: "Offline"
+ case .rateLimited: "Rate limited"
+ case .unavailable: "Unavailable"
+ case .disabled: "Disabled"
+ }
+ }
+}
+
+public struct ProviderCredentialStatus: Sendable, Equatable {
+ public let state: CredentialState
+ public let health: ProviderCredentialHealth
+
+ public init(state: CredentialState, health: ProviderCredentialHealth) {
+ self.state = state
+ self.health = health
+ }
+}
+
+public struct ProviderFetchResult: Sendable, Equatable {
+ public let outcome: ProviderFetchOutcome
+ public let warnings: [String]
+ public let analytics: ProviderAnalytics?
+ public let recoveryIssue: ProviderRecoveryIssue?
+ public let credentialStatus: ProviderCredentialStatus?
+
+ public init(
+ outcome: ProviderFetchOutcome,
+ warnings: [String] = [],
+ analytics: ProviderAnalytics? = nil,
+ recoveryIssue: ProviderRecoveryIssue? = nil,
+ credentialStatus: ProviderCredentialStatus? = nil
+ ) {
+ self.outcome = outcome
+ self.warnings = warnings
+ self.analytics = analytics
+ self.recoveryIssue = recoveryIssue
+ self.credentialStatus = credentialStatus
+ }
+
+ func withCredentialStatus(_ credentialStatus: ProviderCredentialStatus) -> ProviderFetchResult {
+ ProviderFetchResult(
+ outcome: outcome,
+ warnings: warnings,
+ analytics: analytics,
+ recoveryIssue: recoveryIssue,
+ credentialStatus: credentialStatus)
+ }
+}
+
+extension ProviderCredentialStatus {
+ static func resolved(
+ _ state: CredentialState,
+ provider: ProviderID,
+ source: CredentialSource
+ ) -> ProviderCredentialStatus {
+ ProviderCredentialStatus(
+ state: state,
+ health: .from(state, source: source, expected: provider.setup.credentialSources))
+ }
+
+ static func missing(_ reason: String, provider: ProviderID) -> ProviderCredentialStatus {
+ ProviderCredentialStatus(
+ state: .missing(reason),
+ health: .missing(expected: provider.setup.credentialSources))
+ }
+
+ static func unreadable(
+ _ error: any Error,
+ provider: ProviderID,
+ fallbackSource: CredentialSource
+ ) -> ProviderCredentialStatus {
+ ProviderCredentialStatus(
+ state: .missing(String(describing: error)),
+ health: .from(readError: error, fallbackSource: fallbackSource))
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Models/ProviderSnapshot.swift b/Sources/TokenMenuBarCore/Models/ProviderSnapshot.swift
new file mode 100644
index 0000000..6d7ab82
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Models/ProviderSnapshot.swift
@@ -0,0 +1,203 @@
+import Foundation
+
+public struct Money: Codable, Sendable, Hashable {
+ public let amountMinor: Int
+ public let currency: String
+ public let exponent: Int
+
+ public init(amountMinor: Int, currency: String, exponent: Int = 2) {
+ self.amountMinor = amountMinor
+ self.currency = currency
+ self.exponent = exponent
+ }
+
+ public var amount: Decimal {
+ Decimal(amountMinor) / pow(10, exponent)
+ }
+
+ public var formatted: String {
+ amount.formatted(.currency(code: currency).precision(.fractionLength(exponent)))
+ }
+}
+
+public struct ProviderIdentity: Codable, Sendable, Hashable {
+ public let planName: String
+ public let tier: String?
+ public let email: String?
+ public let organization: String?
+ public let subscriptionActiveUntil: Date?
+
+ public init(
+ planName: String,
+ tier: String? = nil,
+ email: String? = nil,
+ organization: String? = nil,
+ subscriptionActiveUntil: Date? = nil
+ ) {
+ self.planName = planName
+ self.tier = tier
+ self.email = email
+ self.organization = organization
+ self.subscriptionActiveUntil = subscriptionActiveUntil
+ }
+}
+
+public struct CreditBalance: Codable, Sendable, Hashable {
+ public let balance: Decimal?
+ public let currency: String?
+ public let unlimited: Bool
+ public let hasCredits: Bool
+ public let overageLimitReached: Bool
+ public let approxLocalMessages: ClosedRange?
+ public let approxCloudMessages: ClosedRange?
+
+ public init(
+ balance: Decimal?,
+ currency: String? = nil,
+ unlimited: Bool = false,
+ hasCredits: Bool = false,
+ overageLimitReached: Bool = false,
+ approxLocalMessages: ClosedRange? = nil,
+ approxCloudMessages: ClosedRange? = nil
+ ) {
+ self.balance = balance
+ self.currency = currency
+ self.unlimited = unlimited
+ self.hasCredits = hasCredits
+ self.overageLimitReached = overageLimitReached
+ self.approxLocalMessages = approxLocalMessages
+ self.approxCloudMessages = approxCloudMessages
+ }
+
+ public var formattedBalance: String {
+ guard let balance else { return "—" }
+ if let currency { return balance.formatted(.currency(code: currency)) }
+ return balance.formatted(.number.precision(.fractionLength(0...2)))
+ }
+}
+
+public struct SpendControl: Codable, Sendable, Hashable {
+ public let enabled: Bool
+ public let canToggle: Bool
+ public let used: Money?
+ public let limit: Money?
+ public let percent: Double?
+ public let resetsAt: Date?
+ public let limitReached: Bool
+ public let disabledReason: String?
+ public let balance: Money?
+ public let autoReload: Bool?
+ public let canPurchaseCredits: Bool
+
+ public init(
+ enabled: Bool,
+ canToggle: Bool = false,
+ used: Money? = nil,
+ limit: Money? = nil,
+ percent: Double? = nil,
+ resetsAt: Date? = nil,
+ limitReached: Bool = false,
+ disabledReason: String? = nil,
+ balance: Money? = nil,
+ autoReload: Bool? = nil,
+ canPurchaseCredits: Bool = false
+ ) {
+ self.enabled = enabled
+ self.canToggle = canToggle
+ self.used = used
+ self.limit = limit
+ self.percent = percent
+ self.resetsAt = resetsAt
+ self.limitReached = limitReached
+ self.disabledReason = disabledReason
+ self.balance = balance
+ self.autoReload = autoReload
+ self.canPurchaseCredits = canPurchaseCredits
+ }
+}
+
+public struct ResetCredits: Codable, Sendable, Hashable {
+ public let available: Int
+ public let applicable: Int
+ public let totalEarned: Int?
+ public let immediatePurchaseEligible: Bool
+
+ public init(available: Int, applicable: Int, totalEarned: Int? = nil, immediatePurchaseEligible: Bool = false) {
+ self.available = available
+ self.applicable = applicable
+ self.totalEarned = totalEarned
+ self.immediatePurchaseEligible = immediatePurchaseEligible
+ }
+}
+
+public struct Notice: Codable, Sendable, Hashable, Identifiable {
+ public enum Kind: String, Codable, Sendable {
+ case promotion
+ case limitReached
+ case spendControl
+ case info
+ }
+
+ public let kind: Kind
+ public let text: String
+
+ public init(kind: Kind, text: String) {
+ self.kind = kind
+ self.text = text
+ }
+
+ public var id: String {
+ "\(kind.rawValue):\(text)"
+ }
+}
+
+public enum DataSource: String, Codable, Sendable, Hashable {
+ case network
+ case localLog
+ case cache
+}
+
+public struct ProviderSnapshot: Codable, Sendable, Hashable {
+ public let provider: ProviderID
+ public let identity: ProviderIdentity?
+ public let windows: [QuotaWindow]
+ public let credits: CreditBalance?
+ public let spend: SpendControl?
+ public let resetCredits: ResetCredits?
+ public let notices: [Notice]
+ public let localUsage: LocalUsage?
+ public let source: DataSource
+ public let fetchedAt: Date
+
+ public init(
+ provider: ProviderID,
+ identity: ProviderIdentity? = nil,
+ windows: [QuotaWindow],
+ credits: CreditBalance? = nil,
+ spend: SpendControl? = nil,
+ resetCredits: ResetCredits? = nil,
+ notices: [Notice] = [],
+ localUsage: LocalUsage? = nil,
+ source: DataSource = .network,
+ fetchedAt: Date
+ ) {
+ self.provider = provider
+ self.identity = identity
+ self.windows = windows.sorted { ($0.group, $0.id) < ($1.group, $1.id) }
+ self.credits = credits
+ self.spend = spend
+ self.resetCredits = resetCredits
+ self.notices = notices
+ self.localUsage = localUsage
+ self.source = source
+ self.fetchedAt = fetchedAt
+ }
+
+ public func window(_ id: String) -> QuotaWindow? {
+ windows.first { $0.id == id }
+ }
+
+ public var worstWindow: QuotaWindow? {
+ windows.filter(\.isActive).max { $0.usedPercent < $1.usedPercent }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Models/QuotaWindow.swift b/Sources/TokenMenuBarCore/Models/QuotaWindow.swift
new file mode 100644
index 0000000..c53aefd
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Models/QuotaWindow.swift
@@ -0,0 +1,115 @@
+import Foundation
+
+public enum WindowGroup: String, Codable, Sendable, Hashable, Comparable {
+ case session
+ case weekly
+ case monthly
+ case other
+
+ private var order: Int {
+ switch self {
+ case .session: 0
+ case .weekly: 1
+ case .monthly: 2
+ case .other: 3
+ }
+ }
+
+ public static func < (lhs: WindowGroup, rhs: WindowGroup) -> Bool {
+ lhs.order < rhs.order
+ }
+}
+
+public enum Severity: String, Codable, Sendable, Hashable {
+ case normal
+ case warning
+ case critical
+
+ public init(raw: String?) {
+ switch raw?.lowercased() {
+ case "warning", "elevated", "high": self = .warning
+ case "critical", "exhausted", "limit_reached": self = .critical
+ default: self = .normal
+ }
+ }
+
+ public init(percent: Double) {
+ self = percent >= 90 ? .critical : percent >= 75 ? .warning : .normal
+ }
+}
+
+public struct QuotaWindow: Codable, Sendable, Hashable, Identifiable {
+ public let id: String
+ public let label: String
+ public let group: WindowGroup
+ public let usedPercent: Double
+ public let resetsAt: Date?
+ public let duration: TimeInterval?
+ public let severity: Severity
+ public let isActive: Bool
+ public let scope: String?
+
+ public init(
+ id: String,
+ label: String,
+ group: WindowGroup,
+ usedPercent: Double,
+ resetsAt: Date?,
+ duration: TimeInterval? = nil,
+ severity: Severity? = nil,
+ isActive: Bool = true,
+ scope: String? = nil
+ ) {
+ self.id = id
+ self.label = label
+ self.group = group
+ self.usedPercent = min(max(usedPercent, 0), 100)
+ self.resetsAt = resetsAt
+ self.duration = duration
+ self.severity = severity ?? Severity(percent: usedPercent)
+ self.isActive = isActive
+ self.scope = scope
+ }
+
+ public var remainingPercent: Double {
+ 100 - usedPercent
+ }
+
+ public func windowStart(now: Date) -> Date? {
+ guard let resetsAt, let duration else { return nil }
+ return resetsAt.addingTimeInterval(-duration)
+ }
+
+ public func hasReset(since previous: QuotaWindow) -> Bool {
+ guard let old = previous.resetsAt, let new = resetsAt else { return usedPercent < previous.usedPercent - 1 }
+ return new > old
+ }
+}
+
+public struct WindowKey: Codable, Sendable, Hashable, Comparable {
+ public let provider: ProviderID
+ public let windowID: String
+
+ public init(provider: ProviderID, windowID: String) {
+ self.provider = provider
+ self.windowID = windowID
+ }
+
+ public init(_ provider: ProviderID, _ window: QuotaWindow) {
+ self.init(provider: provider, windowID: window.id)
+ }
+
+ public var storageKey: String {
+ "\(provider.rawValue):\(windowID)"
+ }
+
+ public init?(storageKey: String) {
+ guard let colon = storageKey.firstIndex(of: ":"), let provider = ProviderID(rawValue: String(storageKey[.. Bool {
+ (lhs.provider, lhs.windowID) < (rhs.provider, rhs.windowID)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift b/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift
new file mode 100644
index 0000000..03ee8b4
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift
@@ -0,0 +1,125 @@
+import Foundation
+
+public struct NotificationSettings: Sendable, Equatable, Codable {
+ public static let defaultThresholds = [75, 90, 100]
+
+ public var enabled: Bool
+ public var thresholds: [Int]
+ public var notifyOnReset: Bool
+ public var notifyOnAuthProblems: Bool
+
+ public init(
+ enabled: Bool = true, thresholds: [Int] = defaultThresholds, notifyOnReset: Bool = true,
+ notifyOnAuthProblems: Bool = true
+ ) {
+ self.enabled = enabled
+ self.thresholds = thresholds.filter { (1...100).contains($0) }.sorted()
+ self.notifyOnReset = notifyOnReset
+ self.notifyOnAuthProblems = notifyOnAuthProblems
+ }
+}
+
+public struct NotificationEvent: Sendable, Hashable, Identifiable {
+ public enum Kind: String, Sendable {
+ case threshold
+ case reset
+ case authentication
+ case credits
+ }
+
+ public let id: String
+ public let kind: Kind
+ public let provider: ProviderID
+ public let window: WindowKey?
+ public let title: String
+ public let body: String
+
+ public init(
+ id: String, kind: Kind, provider: ProviderID, window: WindowKey? = nil, title: String, body: String
+ ) {
+ self.id = id
+ self.kind = kind
+ self.provider = provider
+ self.window = window
+ self.title = title
+ self.body = body
+ }
+}
+
+public enum NotificationPlanner {
+ public static func events(
+ previous: ProviderSnapshot?,
+ current: ProviderSnapshot?,
+ previousAvailability: QuotaAvailability,
+ currentAvailability: QuotaAvailability,
+ provider: ProviderID,
+ settings: NotificationSettings,
+ credentialMissing: Bool = false,
+ now: Date
+ ) -> [NotificationEvent] {
+ guard settings.enabled else { return [] }
+ var events: [NotificationEvent] = []
+ if settings.notifyOnAuthProblems, !credentialMissing, previousAvailability != currentAvailability {
+ if currentAvailability == .authenticationRequired {
+ events.append(
+ NotificationEvent(
+ id: "\(provider.rawValue):auth:\(Int(now.timeIntervalSince1970))", kind: .authentication,
+ provider: provider, title: "\(provider.displayName) sign-in needed", body: provider.loginHint))
+ }
+ }
+ guard let current else { return events }
+ if let previous {
+ events += thresholdEvents(previous: previous, current: current, settings: settings)
+ if settings.notifyOnReset { events += resetEvents(previous: previous, current: current, settings: settings) }
+ if previous.credits?.hasCredits == true, current.credits?.hasCredits == false {
+ events.append(
+ NotificationEvent(
+ id: "\(provider.rawValue):credits:\(Int(now.timeIntervalSince1970))", kind: .credits, provider: provider,
+ title: "\(provider.displayName) credits depleted", body: "Usage credits ran out; plan limits now apply."))
+ }
+ }
+ return events
+ }
+
+ static func thresholdEvents(
+ previous: ProviderSnapshot, current: ProviderSnapshot, settings: NotificationSettings
+ ) -> [NotificationEvent] {
+ current.windows.flatMap { window -> [NotificationEvent] in
+ guard let before = previous.window(window.id), !window.hasReset(since: before) else { return [] }
+ let crossed = settings.thresholds.filter { before.usedPercent < Double($0) && window.usedPercent >= Double($0) }
+ guard let highest = crossed.max() else { return [] }
+ let resets = window.resetsAt.map { " Resets \(Format.resetClock($0, now: current.fetchedAt))." } ?? ""
+ return [
+ NotificationEvent(
+ id:
+ "\(current.provider.rawValue):\(window.id):\(highest):\(Int(window.resetsAt?.timeIntervalSince1970 ?? 0))",
+ kind: .threshold,
+ provider: current.provider,
+ window: WindowKey(current.provider, window),
+ title: "\(current.provider.displayName) \(window.label) at \(Format.percent(window.usedPercent))",
+ body: highest >= 100
+ ? "Limit reached.\(resets)" : "Crossed \(highest)% of the \(window.label.lowercased()) limit.\(resets)"
+ )
+ ]
+ }
+ }
+
+ static func resetEvents(
+ previous: ProviderSnapshot, current: ProviderSnapshot, settings: NotificationSettings
+ ) -> [NotificationEvent] {
+ let floor = Double(settings.thresholds.first ?? 75)
+ return current.windows.compactMap { window in
+ guard let before = previous.window(window.id), window.hasReset(since: before), before.usedPercent >= floor else {
+ return nil
+ }
+ return NotificationEvent(
+ id: "\(current.provider.rawValue):\(window.id):reset:\(Int(window.resetsAt?.timeIntervalSince1970 ?? 0))",
+ kind: .reset,
+ provider: current.provider,
+ window: WindowKey(current.provider, window),
+ title: "\(current.provider.displayName) \(window.label) reset",
+ body: "Usage is back to \(Format.percent(window.usedPercent))."
+ )
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Pace.swift b/Sources/TokenMenuBarCore/Pace.swift
new file mode 100644
index 0000000..33d35c4
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Pace.swift
@@ -0,0 +1,105 @@
+import Foundation
+
+public enum PaceStatus: String, Sendable, Equatable {
+ case unknown
+ case onTrack
+ case ahead
+ case behind
+ case exhausted
+
+ public var title: String {
+ switch self {
+ case .unknown: "Learning pace"
+ case .onTrack: "On pace"
+ case .ahead: "Ahead of pace"
+ case .behind: "Under pace"
+ case .exhausted: "Limit reached"
+ }
+ }
+}
+
+public struct PaceEstimate: Sendable, Hashable {
+ public static let aheadRatio = 1.25
+ public static let behindRatio = 0.75
+ public static let minimumElapsedPercent = 5.0
+ public static let slopeWindow: TimeInterval = 3600
+
+ public let status: PaceStatus
+ public let expectedPercent: Double?
+ public let ratio: Double?
+ public let projectedExhaustion: Date?
+
+ public init(status: PaceStatus, expectedPercent: Double?, ratio: Double?, projectedExhaustion: Date?) {
+ self.status = status
+ self.expectedPercent = expectedPercent
+ self.ratio = ratio
+ self.projectedExhaustion = projectedExhaustion
+ }
+
+ public static func estimate(window: QuotaWindow, samples: [UsageSample] = [], now: Date) -> PaceEstimate {
+ if window.usedPercent >= 100 {
+ return PaceEstimate(status: .exhausted, expectedPercent: nil, ratio: nil, projectedExhaustion: now)
+ }
+ guard let resetsAt = window.resetsAt, let duration = window.duration, duration > 0, resetsAt > now else {
+ return PaceEstimate(status: .unknown, expectedPercent: nil, ratio: nil, projectedExhaustion: nil)
+ }
+ let start = resetsAt.addingTimeInterval(-duration)
+ let expected = min(max(now.timeIntervalSince(start) / duration, 0), 1) * 100
+ let projection = projectedExhaustion(window: window, start: start, resetsAt: resetsAt, samples: samples, now: now)
+ guard expected >= minimumElapsedPercent else {
+ return PaceEstimate(status: .unknown, expectedPercent: expected, ratio: nil, projectedExhaustion: projection)
+ }
+ let ratio = window.usedPercent / expected
+ let status: PaceStatus = ratio > aheadRatio ? .ahead : ratio < behindRatio ? .behind : .onTrack
+ return PaceEstimate(status: status, expectedPercent: expected, ratio: ratio, projectedExhaustion: projection)
+ }
+
+ static func projectedExhaustion(
+ window: QuotaWindow, start: Date, resetsAt: Date, samples: [UsageSample], now: Date
+ ) -> Date? {
+ let recent = samples.filter { $0.timestamp >= now.addingTimeInterval(-slopeWindow) && $0.timestamp <= now }
+ var rate: Double?
+ if let first = recent.first, let last = recent.last, last.timestamp > first.timestamp,
+ last.usedPercent > first.usedPercent
+ {
+ rate = (last.usedPercent - first.usedPercent) / last.timestamp.timeIntervalSince(first.timestamp)
+ } else if window.usedPercent > 0, now > start {
+ rate = window.usedPercent / now.timeIntervalSince(start)
+ }
+ guard let rate, rate > 0 else { return nil }
+ let projected = now.addingTimeInterval((100 - window.usedPercent) / rate)
+ return projected < resetsAt ? projected : nil
+ }
+
+ public func summary(now: Date) -> String {
+ switch status {
+ case .exhausted: "Limit reached"
+ case .unknown:
+ projectedExhaustion.map { "Early in window; at this rate hits 100% \(Format.resetClock($0, now: now))" }
+ ?? "Early in window"
+ default:
+ projectedExhaustion.map {
+ "\(status.title) (expected \(Format.percent(expectedPercent ?? 0))); "
+ + "hits 100% \(Format.resetClock($0, now: now))"
+ }
+ ?? "\(status.title) (expected \(Format.percent(expectedPercent ?? 0))); lasts until reset"
+ }
+ }
+
+ public func comparison(now: Date) -> String {
+ if status == .exhausted { return "Limit reached" }
+ var parts = [status.title]
+ if let expectedPercent {
+ parts.append("expected \(Format.percent(expectedPercent))")
+ }
+ if let ratio {
+ parts.append("\(ratio.formatted(.number.precision(.fractionLength(1))))×")
+ }
+ if let projectedExhaustion {
+ parts.append("hits 100% in \(Format.countdown(to: projectedExhaustion, now: now))")
+ } else if expectedPercent != nil {
+ parts.append("lasts to reset")
+ }
+ return parts.joined(separator: " · ")
+ }
+}
diff --git a/Sources/TokenMenuBarCore/PanelMaterial.swift b/Sources/TokenMenuBarCore/PanelMaterial.swift
new file mode 100644
index 0000000..0376d0e
--- /dev/null
+++ b/Sources/TokenMenuBarCore/PanelMaterial.swift
@@ -0,0 +1,21 @@
+public enum PanelSurfaceRole: CaseIterable, Sendable {
+ case popoverChrome
+ case content
+}
+
+public enum PanelMaterial: CaseIterable, Sendable {
+ case system
+ case standardContent
+}
+
+public enum PanelMaterialPolicy {
+ public static func material(
+ for surface: PanelSurfaceRole,
+ generation: PlatformDesignGeneration
+ ) -> PanelMaterial {
+ switch (generation, surface) {
+ case (.macOS14, .popoverChrome), (.macOS15, .popoverChrome), (.macOS26, .popoverChrome): .system
+ case (.macOS14, .content), (.macOS15, .content), (.macOS26, .content): .standardContent
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/PlatformDesign.swift b/Sources/TokenMenuBarCore/PlatformDesign.swift
new file mode 100644
index 0000000..925e6d5
--- /dev/null
+++ b/Sources/TokenMenuBarCore/PlatformDesign.swift
@@ -0,0 +1,5 @@
+public enum PlatformDesignGeneration: CaseIterable, Sendable {
+ case macOS14
+ case macOS15
+ case macOS26
+}
diff --git a/Sources/TokenMenuBarCore/PopoverGeometry.swift b/Sources/TokenMenuBarCore/PopoverGeometry.swift
new file mode 100644
index 0000000..1dc0253
--- /dev/null
+++ b/Sources/TokenMenuBarCore/PopoverGeometry.swift
@@ -0,0 +1,172 @@
+import CoreGraphics
+import Foundation
+
+public enum PopoverDismissalTrigger: Sendable, Equatable {
+ case mouseDown
+ case mouseMoved
+ case keyEscape
+}
+
+public struct PopoverDismissalGate: Sendable, Equatable {
+ public init() {}
+
+ public func shouldClose(
+ mouseLocation: CGPoint,
+ popoverFrame: CGRect?,
+ excludedFrame: CGRect? = nil,
+ trigger: PopoverDismissalTrigger
+ ) -> Bool {
+ if trigger == .keyEscape { return true }
+ if popoverFrame?.contains(mouseLocation) ?? false { return false }
+ if excludedFrame?.contains(mouseLocation) == true { return false }
+ switch trigger {
+ case .mouseDown: return true
+ case .mouseMoved: return false
+ case .keyEscape: return true
+ }
+ }
+}
+
+public struct PopoverMeasurement: Sendable, Equatable {
+ public let tab: PopoverTab
+ public let size: CGSize
+
+ public init(tab: PopoverTab, size: CGSize) {
+ self.tab = tab
+ self.size = size
+ }
+}
+
+public struct SettingsHeightInput: Sendable, Equatable {
+ public let mountedSections: Set
+ public let showsModelFilter: Bool
+ public let providerCount: Int
+ public let modelCount: Int
+ public let logLineCount: Int
+ public let showsCustomTemplate: Bool
+ public let showsUpdates: Bool
+
+ public init(
+ mountedSections: Set, showsModelFilter: Bool, providerCount: Int, modelCount: Int,
+ logLineCount: Int, showsCustomTemplate: Bool, showsUpdates: Bool
+ ) {
+ self.mountedSections = mountedSections
+ self.showsModelFilter = showsModelFilter
+ self.providerCount = providerCount
+ self.modelCount = modelCount
+ self.logLineCount = logLineCount
+ self.showsCustomTemplate = showsCustomTemplate
+ self.showsUpdates = showsUpdates
+ }
+}
+
+public enum PopoverGeometry {
+ public static let minimumWidth: CGFloat = 728
+ public static let contentPadding: CGFloat = 14
+ public static let stableTabWidth: CGFloat = 880
+ public static let stableContentWidth: CGFloat = stableTabWidth - 2 * contentPadding
+ public static let minimumHeight: CGFloat = 200
+ public static let tabBarHeight: CGFloat = 30
+ public static let footerHeight: CGFloat = 39
+ public static let settingsInitialHeight: CGFloat = 480
+ public static let historyInitialHeight: CGFloat = 620
+ public static let historyChartHeight: CGFloat = 500
+ public static let usageInitialHeight: CGFloat = 760
+ public static let margin: CGFloat = 12
+
+ public static func contentWidth(for tab: PopoverTab) -> CGFloat {
+ stableContentWidth
+ }
+
+ public static func tabWidth(for tab: PopoverTab) -> CGFloat {
+ stableTabWidth
+ }
+
+ public static func stableWidth(maximum: CGFloat = stableTabWidth) -> CGFloat {
+ min(stableTabWidth, max(maximum, 0))
+ }
+
+ public static func visibleFrame(_ visibleFrame: CGRect?, cappedTo width: CGFloat?) -> CGRect? {
+ guard let visibleFrame else { return nil }
+ guard let width else { return visibleFrame }
+ let cappedWidth = min(width, visibleFrame.width)
+ return CGRect(
+ x: visibleFrame.maxX - cappedWidth, y: visibleFrame.minY,
+ width: cappedWidth, height: visibleFrame.height)
+ }
+
+ public static func preferredHeight(for tab: PopoverTab, measured: CGFloat? = nil) -> CGFloat {
+ switch tab {
+ case .usage: measured ?? usageInitialHeight
+ case .history: measured ?? historyInitialHeight
+ case .settings: measured ?? settingsInitialHeight
+ }
+ }
+
+ public static func settingsHeight(_ input: SettingsHeightInput) -> CGFloat {
+ let sectionSpacing = CGFloat(max(input.mountedSections.count - 1, 0)) * 12
+ var height = contentPadding * 2 + sectionSpacing + 34
+ for section in input.mountedSections {
+ height += 30
+ switch section {
+ case .about:
+ height += 104 + (input.showsUpdates ? 30 : 0)
+ case .menuBar:
+ height += 112 + (input.showsCustomTemplate ? 42 : 0)
+ if input.showsModelFilter {
+ height += 52 + CGFloat(max(input.providerCount, 0)) * 36 + CGFloat(max(input.modelCount, 0)) * 48
+ }
+ case .providers:
+ height += 62 + CGFloat(max(input.providerCount, 0)) * 68
+ case .data:
+ height += 82
+ case .notifications:
+ height += 58
+ case .log:
+ height += 96 + CGFloat(min(max(input.logLineCount, 0), 8)) * 14
+ }
+ }
+ return max(ceil(height), minimumHeight)
+ }
+
+ public static func maxSize(
+ anchor: CGRect, visibleFrame: CGRect, popoverChromeSize: CGSize = .zero
+ ) -> CGSize {
+ let chromeHeight = max(popoverChromeSize.height, 0)
+ let drawableHeight = anchor.minY - visibleFrame.minY - margin - chromeHeight
+ // A zero or off-screen anchor is transient while the status item gains a window. Keep that first frame visible,
+ // but never make the fallback taller than the screen can draw.
+ let fallbackHeight = min(minimumHeight, max(visibleFrame.height - margin - chromeHeight, 0))
+ return CGSize(
+ width: stableWidth(maximum: visibleFrame.width - margin * 2 - max(popoverChromeSize.width, 0)),
+ height: drawableHeight > 0 ? drawableHeight : fallbackHeight)
+ }
+
+ public static func maximumBodyHeight(
+ anchor: CGRect, visibleFrame: CGRect, chromeHeight: CGFloat
+ ) -> CGFloat {
+ max(maxSize(anchor: anchor, visibleFrame: visibleFrame).height - chromeHeight, 0)
+ }
+
+ public static func clamp(_ size: CGSize, maximum: CGSize) -> CGSize {
+ CGSize(
+ width: min(max(size.width, minimumWidth), max(maximum.width, 0)),
+ height: min(max(size.height, minimumHeight), max(maximum.height, 0)))
+ }
+
+ public static func recoveredFrame(
+ windowFrame: CGRect, visibleFrame: CGRect, margin: CGFloat = margin
+ ) -> CGRect {
+ CGRect(
+ origin: CGPoint(
+ x: max(visibleFrame.maxX - windowFrame.width - margin, visibleFrame.minX),
+ y: max(visibleFrame.maxY - windowFrame.height, visibleFrame.minY)),
+ size: windowFrame.size)
+ }
+
+ public static func pinnedOrigin(lastTopCenter: CGPoint, size: CGSize, visibleFrame: CGRect) -> CGPoint {
+ return CGPoint(
+ x: min(max(lastTopCenter.x - size.width / 2, visibleFrame.minX), visibleFrame.maxX - size.width),
+ y: min(max(lastTopCenter.y - size.height, visibleFrame.minY), visibleFrame.maxY - size.height))
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Presenters/HistoryPresenter.swift b/Sources/TokenMenuBarCore/Presenters/HistoryPresenter.swift
new file mode 100644
index 0000000..8ecb51b
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Presenters/HistoryPresenter.swift
@@ -0,0 +1,649 @@
+import Foundation
+import Observation
+
+public enum HistoryLoadState: Equatable, Sendable {
+ case loading
+ case loaded(HistoryChartModel, isRefreshing: Bool, error: String?)
+ case failed(String)
+
+ public var data: HistoryChartModel? {
+ if case .loaded(let data, _, _) = self { return data }
+ return nil
+ }
+}
+
+private struct HistoryCacheKey: Hashable, Sendable {
+ let metric: HistoryMetric
+ let keys: [WindowKey]
+ let start: Date
+ let end: Date
+ let rollup: Rollup
+ let timeZoneID: String
+}
+
+private enum HistoryCachedRows: Sendable {
+ case windows(samples: [UsageSample], labels: [WindowKey: String])
+ case analytics([HistoryAnalyticsRow])
+
+ var rowCount: Int {
+ switch self {
+ case .windows(let samples, _): samples.count
+ case .analytics(let rows): rows.count
+ }
+ }
+}
+
+private struct HistoryIsolation {
+ let id: HistorySeriesID
+ let hidden: Set
+}
+
+@MainActor
+@Observable
+public final class HistoryPresenter {
+ public private(set) var state: HistoryLoadState = .loading
+ public private(set) var availableWindows: [WindowSummary] = []
+ public private(set) var earliest: Date?
+ public private(set) var selectedMetric: HistoryMetric
+ public private(set) var exportError: String?
+ public var customStart: Date
+ public var customEnd: Date
+ public var followNow = true
+ public var selectedDate: Date?
+ public var hoveredKey: WindowKey?
+ public var hoveredSeriesID: HistorySeriesID?
+
+ private let history: UsageHistoryStore
+ private let settings: Settings
+ private let clock: Clock
+ private let persistMetric: @MainActor (HistoryMetric) -> Void
+ private var loadTask: Task?
+ private var hasLoaded = false
+ private var anchorEnd: Date?
+ private var cache: [HistoryCacheKey: HistoryCachedRows] = [:]
+ private var cacheOrder: [HistoryCacheKey] = []
+ private var hiddenAnalytics: [HistoryMetric: Set] = [:]
+ private var isolation: [HistoryMetric: HistoryIsolation] = [:]
+ private var dataScope = HistoryDataScope.all
+ private var lastRequest: HistoryRequest?
+ private var earliestMetric: HistoryMetric?
+ static let maxQueryPointsPerSeries = 800
+ static let maxCachedRows = 24_000
+
+ public init(
+ history: UsageHistoryStore, settings: Settings, clock: Clock = .system,
+ initialMetric: HistoryMetric = .windowUsagePercent,
+ persistMetric: @escaping @MainActor (HistoryMetric) -> Void = { _ in }
+ ) {
+ self.history = history
+ self.settings = settings
+ self.clock = clock
+ self.selectedMetric = initialMetric
+ self.persistMetric = persistMetric
+ let now = clock.now()
+ customStart = now.addingTimeInterval(-86400)
+ customEnd = now
+ }
+
+ public var timeZone: TimeZone {
+ settings.historyUseUTC ? Self.utc : .current
+ }
+
+ public var chartTimeZone: TimeZone {
+ selectedMetric.usesDailyUTC ? Self.utc : timeZone
+ }
+
+ public var period: HistoryPeriod {
+ followNow ? .now : .range(settings.historyRange)
+ }
+
+ public var effectiveRollup: Rollup {
+ selectedMetric.usesDailyUTC ? .day : settings.historyRollup
+ }
+
+ public var canStack: Bool {
+ selectedMetric.supportsStacking && (state.data?.visibleSeries.count ?? 0) > 1
+ }
+
+ public var currentViewport: ClosedRange {
+ let viewport = viewport(now: clock.now())
+ return viewport.start...viewport.end
+ }
+
+ public func request(now: Date) -> HistoryRequest {
+ let viewport = viewport(now: now)
+ let allKeys = availableWindows.map(\.key).filter(dataScope.includes)
+ let keys = allKeys.filter { !settings.historyHiddenKeys.contains($0) }
+ return HistoryRequest(
+ keys: keys, allKeys: allKeys, start: viewport.start, end: viewport.end, rollup: effectiveRollup,
+ stacked: settings.historyStacked && selectedMetric.supportsStacking, timeZone: chartTimeZone,
+ includesEnd: followNow && !selectedMetric.usesDailyUTC)
+ }
+
+ public func ensureLoaded() {
+ guard !hasLoaded, loadTask == nil else { return }
+ startLoad(useCache: true, refreshMetadata: true)
+ }
+
+ public func reload() {
+ cache.removeAll(keepingCapacity: true)
+ cacheOrder.removeAll(keepingCapacity: true)
+ startLoad(useCache: false, refreshMetadata: true)
+ }
+
+ public func reset() {
+ selectedMetric = HistoryMetric(storageID: settings.historyMetricID) ?? .windowUsagePercent
+ followNow = true
+ anchorEnd = nil
+ let now = clock.now()
+ customStart = now.addingTimeInterval(-86400)
+ customEnd = now
+ hiddenAnalytics.removeAll(keepingCapacity: true)
+ isolation.removeAll(keepingCapacity: true)
+ invalidateData()
+ }
+
+ public func invalidateData() {
+ loadTask?.cancel()
+ loadTask = nil
+ cache.removeAll(keepingCapacity: true)
+ cacheOrder.removeAll(keepingCapacity: true)
+ availableWindows = []
+ earliest = nil
+ earliestMetric = nil
+ lastRequest = nil
+ selectedDate = nil
+ hoveredKey = nil
+ hoveredSeriesID = nil
+ hasLoaded = false
+ state = .loading
+ startLoad(useCache: false, refreshMetadata: true)
+ }
+
+ public func setDataScope(_ scope: HistoryDataScope) {
+ guard scope != dataScope else { return }
+ dataScope = scope
+ invalidateData()
+ }
+
+ public func redraw() {
+ guard let previous = lastRequest, let cached = cache[cacheKey(request: previous)] else {
+ startLoad(useCache: true, now: lastRequest?.end)
+ return
+ }
+ render(cached, request: updatingVisibility(in: previous))
+ }
+
+ public func waitForLoad() async {
+ var waiting = loadTask
+ while let task = waiting {
+ await task.value
+ waiting = loadTask == task ? nil : loadTask
+ }
+ }
+
+ public func setMetric(_ metric: HistoryMetric) {
+ guard metric != selectedMetric else { return }
+ selectedMetric = metric
+ persistMetric(metric)
+ if case .analytics(let analyticsMetric) = metric { settings.historyAnalyticsMetric = analyticsMetric }
+ selectedDate = nil
+ hoveredSeriesID = nil
+ hoveredKey = nil
+ startLoad(useCache: true, now: followNow ? clock.now() : anchorEnd ?? lastRequest?.end)
+ }
+
+ public func setPeriod(_ period: HistoryPeriod) {
+ switch period {
+ case .now:
+ let customDuration = max(customEnd.timeIntervalSince(customStart), 60)
+ followNow = true
+ anchorEnd = nil
+ if settings.historyRange == .custom {
+ customEnd = clock.now()
+ customStart = customEnd.addingTimeInterval(-customDuration)
+ }
+ case .range(let range):
+ settings.historyRange = range
+ if range == .today, settings.historyRollup == .day { settings.historyRollup = .hour }
+ followNow = false
+ anchorEnd = clock.now()
+ if range == .custom {
+ customEnd = clock.now()
+ customStart = min(customStart, customEnd.addingTimeInterval(-60))
+ }
+ }
+ selectedDate = nil
+ startLoad(useCache: true)
+ }
+
+ public func setRange(_ range: HistoryRange) {
+ setPeriod(.range(range))
+ }
+
+ public func setRollup(_ rollup: Rollup) {
+ guard !selectedMetric.usesDailyUTC else { return }
+ settings.historyRollup = rollup
+ if rollup == .day, settings.historyRange == .today { settings.historyRange = .week }
+ startLoad(useCache: true, now: lastRequest?.end)
+ }
+
+ public func setStacked(_ stacked: Bool) {
+ settings.historyStacked = stacked
+ redraw()
+ }
+
+ public func setCustomStart(_ date: Date) {
+ let currentEnd = viewport(now: clock.now()).end
+ settings.historyRange = .custom
+ customEnd = currentEnd
+ customStart = min(date, currentEnd.addingTimeInterval(-60))
+ followNow = false
+ anchorEnd = currentEnd
+ selectedDate = nil
+ startLoad(useCache: true)
+ }
+
+ public func setCustomEnd(_ date: Date) {
+ let currentStart = viewport(now: clock.now()).start
+ settings.historyRange = .custom
+ customStart = currentStart
+ customEnd = max(date, currentStart.addingTimeInterval(60))
+ followNow = false
+ anchorEnd = customEnd
+ selectedDate = nil
+ startLoad(useCache: true)
+ }
+
+ public func setFollowNow(_ follow: Bool) {
+ if follow {
+ setPeriod(.now)
+ } else {
+ followNow = false
+ anchorEnd = clock.now()
+ startLoad(useCache: true)
+ }
+ }
+
+ public func setUseUTC(_ utc: Bool) {
+ settings.historyUseUTC = utc
+ guard !selectedMetric.usesDailyUTC else { return }
+ startLoad(useCache: true, now: lastRequest?.end)
+ }
+
+ public func page(forward: Bool, now: Date) {
+ let view = viewport(now: now)
+ let calendar = calendar(for: selectedMetric.usesDailyUTC ? Self.utc : timeZone)
+ if settings.historyRange == .custom {
+ if selectedMetric.usesDailyUTC {
+ let today = calendar.startOfDay(for: now)
+ let dayCount = max(calendar.dateComponents([.day], from: view.start, to: view.end).day! + 1, 1)
+ let proposedEnd =
+ forward
+ ? calendar.date(byAdding: .day, value: dayCount, to: view.end)!
+ : calendar.date(byAdding: .day, value: -1, to: view.start)!
+ let nextEnd = min(proposedEnd, today)
+ let nextStart = calendar.date(byAdding: .day, value: -(dayCount - 1), to: nextEnd)!
+ customStart = nextStart
+ customEnd = nextEnd
+ followNow = nextEnd >= today
+ anchorEnd = followNow ? nil : nextEnd
+ } else {
+ let duration = max(view.end.timeIntervalSince(view.start), 60)
+ let proposedEnd = forward ? view.end.addingTimeInterval(duration) : view.start
+ let nextEnd = min(proposedEnd, now)
+ customEnd = nextEnd
+ customStart = nextEnd.addingTimeInterval(-duration)
+ followNow = nextEnd >= now
+ anchorEnd = followNow ? nil : nextEnd
+ }
+ } else {
+ let days = settings.historyRange.days!
+ let nextEnd: Date
+ if selectedMetric.usesDailyUTC {
+ let today = calendar.startOfDay(for: now)
+ nextEnd = min(
+ forward
+ ? calendar.date(byAdding: .day, value: days, to: view.end)!
+ : calendar.date(byAdding: .day, value: -1, to: view.start)!,
+ today)
+ } else {
+ if settings.historyRange == .today, !forward, view.end > calendar.startOfDay(for: view.end) {
+ nextEnd = calendar.startOfDay(for: view.end)
+ } else {
+ nextEnd = min(
+ forward
+ ? calendar.date(byAdding: .day, value: days, to: view.end)!
+ : view.start,
+ now)
+ }
+ }
+ let currentEnd = selectedMetric.usesDailyUTC ? calendar.startOfDay(for: now) : now
+ followNow = nextEnd >= currentEnd
+ anchorEnd = followNow ? nil : nextEnd
+ }
+ selectedDate = nil
+ startLoad(useCache: true)
+ }
+
+ public var canPageBack: Bool {
+ earliest.map { $0 < viewport(now: clock.now()).start } ?? false
+ }
+
+ public var canPageForward: Bool {
+ !followNow && viewport(now: clock.now()).end < clock.now()
+ }
+
+ public func toggleVisibility(_ id: HistorySeriesID) {
+ guard let data = state.data else { return }
+ if data.visibleSeries.count == 1, data.visibleSeries[0].id == id { return }
+ isolation[selectedMetric] = nil
+ switch id {
+ case .window(let key):
+ var hidden = settings.historyHiddenKeys
+ if hidden.contains(key) { hidden.remove(key) } else { hidden.insert(key) }
+ settings.historyHiddenKeys = hidden
+ case .analytics:
+ var hidden = hiddenAnalytics[selectedMetric] ?? []
+ if hidden.contains(id) { hidden.remove(id) } else { hidden.insert(id) }
+ hiddenAnalytics[selectedMetric] = hidden
+ }
+ redraw()
+ }
+
+ public func toggleVisibility(_ key: WindowKey) {
+ toggleVisibility(.window(key))
+ }
+
+ public func isolate(_ id: HistorySeriesID) {
+ guard let data = state.data else { return }
+ let metric = selectedMetric
+ if let active = isolation[metric], active.id == id {
+ applyHidden(active.hidden, for: metric)
+ isolation[metric] = nil
+ redraw()
+ return
+ }
+ let original = isolation[metric]?.hidden ?? hiddenIDs(for: metric)
+ let others = Set(data.series.map(\.id)).subtracting([id])
+ isolation[metric] = HistoryIsolation(id: id, hidden: original)
+ applyHidden(original.union(others).subtracting([id]), for: metric)
+ redraw()
+ }
+
+ public func isolate(_ key: WindowKey) {
+ isolate(.window(key))
+ }
+
+ public func isVisible(_ id: HistorySeriesID) -> Bool {
+ switch id {
+ case .window(let key): !settings.historyHiddenKeys.contains(key)
+ case .analytics: !(hiddenAnalytics[selectedMetric] ?? []).contains(id)
+ }
+ }
+
+ public func isVisible(_ key: WindowKey) -> Bool {
+ isVisible(.window(key))
+ }
+
+ public func select(x date: Date?) {
+ guard let date, let data = state.data else {
+ if selectedDate != nil { selectedDate = nil }
+ return
+ }
+ let nearest = ChartPipeline.nearestDate(in: data, to: date)
+ if nearest != selectedDate { selectedDate = nearest }
+ }
+
+ public func setHovered(_ id: HistorySeriesID?) {
+ hoveredSeriesID = id
+ if case .window(let key) = id { hoveredKey = key } else { hoveredKey = nil }
+ }
+
+ public func moveSelection(_ offset: Int) {
+ guard let data = state.data, !data.timeline.isEmpty else { return }
+ let current = selectedDate.flatMap { data.timeline.firstIndex(of: $0) } ?? (offset > 0 ? -1 : data.timeline.count)
+ selectedDate = data.timeline[min(max(current + offset, 0), data.timeline.count - 1)]
+ }
+
+ public func value(for series: HistorySeries) -> String {
+ let value = selectedDate.map { series.value(at: $0, metric: selectedMetric)?.value } ?? series.summaryValue
+ return value.map { format($0, unit: selectedMetric.unit) } ?? "—"
+ }
+
+ public func resetDescription(for series: HistorySeries) -> String? {
+ guard let selectedDate, let point = series.value(at: selectedDate, metric: selectedMetric), point.isReset,
+ let resetsAt = point.resetsAt
+ else {
+ return nil
+ }
+ return "Reset \(resetsAt.formatted(date: .abbreviated, time: .shortened))"
+ }
+
+ @discardableResult
+ public func exportCSV(to url: URL) -> Task {
+ exportError = nil
+ let metric = selectedMetric
+ let viewport = viewport(now: clock.now())
+ let keys = availableWindows.map(\.key).filter(dataScope.includes)
+ let providers = selectedMetric.suppliers.filter(dataScope.activeProviders.contains)
+ return Task { [weak self] in
+ guard let self else { return }
+ do {
+ try await history.exportCSV(
+ to: url, metric: metric, from: viewport.start, to: viewport.end, keys: keys,
+ providers: providers, includesEnd: followNow && !metric.usesDailyUTC)
+ } catch {
+ exportError = "Export failed: \(error)"
+ }
+ }
+ }
+
+ private func startLoad(useCache: Bool, now requestedNow: Date? = nil, refreshMetadata: Bool = false) {
+ loadTask?.cancel()
+ let now = requestedNow ?? clock.now()
+ if case .loaded(let data, _, _) = state { state = .loaded(data, isRefreshing: true, error: nil) }
+ loadTask = Task { [weak self] in
+ guard let self else { return }
+ do {
+ if refreshMetadata || !hasLoaded {
+ let summaries = try await history.summaries()
+ guard !Task.isCancelled else { return }
+ availableWindows = summaries
+ }
+ let metric = selectedMetric
+ if refreshMetadata || earliestMetric != metric {
+ let loadedEarliest = try await earliestDate(for: metric)
+ guard !Task.isCancelled else { return }
+ earliest = loadedEarliest
+ earliestMetric = metric
+ }
+ let request = request(now: now)
+ lastRequest = request
+ let key = cacheKey(request: request)
+ let rows: HistoryCachedRows
+ if useCache, let cached = cache[key] {
+ rows = cached
+ } else {
+ rows = try await loadRows(request: request)
+ store(rows, for: key)
+ }
+ guard !Task.isCancelled else { return }
+ await renderRows(rows, request: request)
+ guard !Task.isCancelled else { return }
+ hasLoaded = true
+ } catch {
+ guard !Task.isCancelled else { return }
+ if let data = state.data {
+ state = .loaded(data, isRefreshing: false, error: "\(error)")
+ } else {
+ state = .failed("\(error)")
+ }
+ }
+ }
+ }
+
+ private func loadRows(request: HistoryRequest) async throws -> HistoryCachedRows {
+ switch selectedMetric {
+ case .windowUsagePercent:
+ let queryRollup = Self.queryRollup(for: request)
+ let lookback = max(queryRollup, UsageHistoryStore.sampleInterval) * 1.5
+ let samples = try await history.samples(
+ keys: request.allKeys, from: request.start.addingTimeInterval(-lookback), to: request.end,
+ rollup: queryRollup, timeZone: request.timeZone, includesEnd: request.includesEnd)
+ let labels = Dictionary(
+ uniqueKeysWithValues: availableWindows.map { ($0.key, "\($0.key.provider.displayName) \($0.label)") })
+ return .windows(samples: samples, labels: labels)
+ case .analytics(let metric):
+ let exclusiveEnd = calendar(for: Self.utc).date(byAdding: .day, value: 1, to: request.end)!
+ return .analytics(
+ try await history.analytics(
+ metric: metric, providers: selectedMetric.suppliers.filter(dataScope.activeProviders.contains),
+ from: DayStamp.string(request.start),
+ before: DayStamp.string(exclusiveEnd)))
+ }
+ }
+
+ private func render(_ cached: HistoryCachedRows, request: HistoryRequest) {
+ loadTask?.cancel()
+ loadTask = Task { [weak self] in
+ await self?.renderRows(cached, request: request)
+ }
+ }
+
+ private func renderRows(_ cached: HistoryCachedRows, request: HistoryRequest) async {
+ let metric = selectedMetric
+ let hidden = hiddenAnalytics[metric] ?? []
+ let renderTask = Task.detached(priority: .userInitiated) {
+ switch cached {
+ case .windows(let samples, let labels):
+ return ChartPipeline.render(samples: samples, request: request, labels: labels, now: request.end)
+ case .analytics(let rows):
+ let start = DayStamp.date(DayStamp.string(request.start))!
+ let lastDay = DayStamp.date(DayStamp.string(request.end))!
+ return ChartPipeline.renderAnalytics(
+ rows: rows, metric: metric, start: start, end: lastDay.addingTimeInterval(Rollup.day.seconds), hidden: hidden,
+ stacked: request.stacked)
+ }
+ }
+ let model = await withTaskCancellationHandler {
+ await renderTask.value
+ } onCancel: {
+ renderTask.cancel()
+ }
+ guard !Task.isCancelled else { return }
+ let styled = applyingStyles(to: model)
+ state = .loaded(styled, isRefreshing: false, error: nil)
+ }
+
+ private func applyingStyles(to model: HistoryChartModel) -> HistoryChartModel {
+ let slots = HistoryStyleSlot.allocate(model.series.map(\.id))
+ let styled = model.series.map { series -> HistorySeries in
+ return HistorySeries(
+ id: series.id, label: series.label, points: series.points,
+ style: slots[series.id]!, isVisible: series.isVisible,
+ summaryValue: series.summaryValue)
+ }
+ return model.replacingSeries(styled)
+ }
+
+ private func viewport(now: Date) -> (start: Date, end: Date) {
+ let calendar = calendar(for: selectedMetric.usesDailyUTC ? Self.utc : timeZone)
+ if settings.historyRange == .custom {
+ let end = followNow ? now : min(customEnd, now)
+ if selectedMetric.usesDailyUTC {
+ let endDay = calendar.startOfDay(for: end)
+ return (min(calendar.startOfDay(for: customStart), endDay), endDay)
+ }
+ return (min(customStart, end.addingTimeInterval(-60)), end)
+ }
+ let end = followNow ? now : min(anchorEnd ?? now, now)
+ let days = settings.historyRange.days!
+ if selectedMetric.usesDailyUTC {
+ let endDay = calendar.startOfDay(for: end)
+ let start = calendar.date(byAdding: .day, value: -(days - 1), to: endDay)!
+ return (start, endDay)
+ }
+ if settings.historyRange == .today {
+ let start =
+ followNow
+ ? calendar.startOfDay(for: end)
+ : calendar.date(byAdding: .day, value: -1, to: end)!
+ return (start, end)
+ }
+ return (calendar.date(byAdding: .day, value: -days, to: end)!, end)
+ }
+
+ private func cacheKey(request: HistoryRequest) -> HistoryCacheKey {
+ HistoryCacheKey(
+ metric: selectedMetric, keys: request.allKeys, start: request.start, end: request.end, rollup: request.rollup,
+ timeZoneID: request.timeZone.identifier)
+ }
+
+ private func updatingVisibility(in request: HistoryRequest) -> HistoryRequest {
+ HistoryRequest(
+ keys: request.allKeys.filter { !settings.historyHiddenKeys.contains($0) }, allKeys: request.allKeys,
+ start: request.start, end: request.end, rollup: request.rollup,
+ stacked: settings.historyStacked && selectedMetric.supportsStacking,
+ timeZone: request.timeZone, includesEnd: request.includesEnd)
+ }
+
+ private func store(_ rows: HistoryCachedRows, for key: HistoryCacheKey) {
+ cache[key] = rows
+ cacheOrder.removeAll { $0 == key }
+ cacheOrder.append(key)
+ while cacheOrder.count > 2 || cache.values.reduce(0, { $0 + $1.rowCount }) > Self.maxCachedRows {
+ cache[cacheOrder.removeFirst()] = nil
+ }
+ }
+
+ static func queryRollup(for request: HistoryRequest) -> TimeInterval {
+ let duration = max(request.end.timeIntervalSince(request.start), 0)
+ let budgetInterval =
+ ceil(duration / Double(maxQueryPointsPerSeries) / UsageHistoryStore.sampleInterval)
+ * UsageHistoryStore.sampleInterval
+ return max(request.rollup.seconds, max(UsageHistoryStore.sampleInterval, budgetInterval))
+ }
+
+ private func earliestDate(for metric: HistoryMetric) async throws -> Date? {
+ switch metric {
+ case .windowUsagePercent:
+ try await history.earliestSample(keys: availableWindows.map(\.key).filter(dataScope.includes))
+ case .analytics(let analyticsMetric):
+ try await history.earliestAnalytics(
+ metric: analyticsMetric, providers: metric.suppliers.filter(dataScope.activeProviders.contains))
+ }
+ }
+
+ private func hiddenIDs(for metric: HistoryMetric) -> Set {
+ switch metric {
+ case .windowUsagePercent: Set(settings.historyHiddenKeys.map(HistorySeriesID.window))
+ case .analytics: hiddenAnalytics[metric] ?? []
+ }
+ }
+
+ private func applyHidden(_ ids: Set, for metric: HistoryMetric) {
+ switch metric {
+ case .windowUsagePercent:
+ settings.historyHiddenKeys = Set(ids.compactMap { if case .window(let key) = $0 { key } else { nil } })
+ case .analytics:
+ hiddenAnalytics[metric] = ids
+ }
+ }
+
+ private func format(_ value: Double, unit: HistoryUnit) -> String {
+ switch unit {
+ case .percentage: Format.percent(value)
+ case .tokens, .credits, .count: Format.compactNumber(value)
+ case .usd: "$\(value.formatted(.number.precision(.fractionLength(2))))"
+ }
+ }
+
+ private func calendar(for timeZone: TimeZone) -> Calendar {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = timeZone
+ return calendar
+ }
+
+ private static let utc = TimeZone(identifier: "UTC")!
+}
diff --git a/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift b/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift
new file mode 100644
index 0000000..ae2a12f
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift
@@ -0,0 +1,464 @@
+import Foundation
+
+/// Consecutive windows that reset together. Claude's weekly models share one reset, so the card printed the same
+/// "Resets in 6d 14h · Sep 6 at 1:00 AM" under three rows running; the group carries it once.
+public struct WindowRowGroup: Sendable, Hashable, Identifiable {
+ public let rows: [WindowRow]
+ public let resetText: String?
+
+ public init(rows: [WindowRow], resetText: String?) {
+ self.rows = rows
+ self.resetText = resetText
+ }
+
+ public var id: WindowKey { rows[0].key }
+
+ /// True when the group is a single window, which reads better with its reset on its own row than under a header.
+ public var isSingle: Bool { rows.count == 1 }
+
+ public var resetDeadline: UsageDeadline? {
+ rows[0].window.resetsAt.map { .reset($0) }
+ }
+
+ public func resetText(at now: Date) -> String? {
+ resetDeadline?.text(at: now)
+ }
+}
+
+public struct WindowRow: Sendable, Hashable, Identifiable {
+ public let key: WindowKey
+ public let window: QuotaWindow
+ public let pace: PaceEstimate
+ public let countdown: String
+ public let resetClock: String
+ public let detail: String
+ public let paceLabel: String
+ public let paceText: String
+ public let helpText: String
+ public let isSelected: Bool
+
+ public init(
+ key: WindowKey, window: QuotaWindow, pace: PaceEstimate, countdown: String, resetClock: String,
+ detail: String? = nil, paceLabel: String? = nil, paceText: String? = nil, helpText: String? = nil,
+ isSelected: Bool = true
+ ) {
+ self.key = key
+ self.window = window
+ self.pace = pace
+ self.countdown = countdown
+ self.resetClock = resetClock
+ self.detail = detail ?? window.id
+ self.paceLabel = paceLabel ?? pace.status.title
+ self.paceText = paceText ?? pace.status.title
+ self.helpText = helpText ?? paceText ?? pace.status.title
+ self.isSelected = isSelected
+ }
+
+ public var id: WindowKey { key }
+
+ public var percentText: String {
+ Format.percent(window.usedPercent)
+ }
+
+ public var color: HSBColor {
+ UsageColor.color(pace: pace.status, percent: window.usedPercent)
+ }
+
+ public var resetDeadline: UsageDeadline {
+ .reset(window.resetsAt)
+ }
+
+ public func resetText(at now: Date) -> String {
+ resetDeadline.text(at: now)
+ }
+
+ public func accessibilityValue(at now: Date) -> String {
+ let inactive = window.isActive ? "" : ", inactive"
+ let selection = isSelected ? "" : ", not shown in the menu bar"
+ return
+ "\(percentText) used\(inactive)\(selection), \(resetText(at: now).lowercased()), \(pace.comparison(now: now))"
+ }
+}
+
+public struct Chip: Sendable, Hashable, Identifiable {
+ public let text: String
+
+ public init(text: String) {
+ self.text = text
+ }
+
+ public var id: String { text }
+}
+
+public struct ProviderCard: Sendable, Hashable, Identifiable {
+ public let provider: ProviderID
+ public let availability: QuotaAvailability
+ public let identity: ProviderIdentity?
+ public let chips: [Chip]
+ public let rows: [WindowRow]
+ public let credits: CreditBalance?
+ public let spend: SpendControl?
+ public let resetCredits: ResetCredits?
+ public let notices: [Notice]
+ public let warnings: [String]
+ public let lastError: String?
+ public let presentedAt: Date
+ public let fetchedAt: Date?
+ public let fetchedAge: String
+ public let source: DataSource?
+ public let emptyTitle: String
+ public let emptyDescription: String
+ public let isRefreshing: Bool
+ public let localUsage: LocalUsage?
+ public let codeReviews: String?
+ public let groups: [WindowRowGroup]
+ public let spendPresentation: UsageSpendPresentation?
+ public let creditsPresentation: UsageCreditsPresentation?
+ public let localPresentation: UsageLocalPresentation?
+
+ public var id: ProviderID { provider }
+
+ public var isStale: Bool {
+ availability != .current && !rows.isEmpty
+ }
+
+ /// What sits where the fetch time normally goes. A refresh keeps the numbers it already has on screen, so this
+ /// says the values below are the previous ones and when they were read.
+ public var statusText: String {
+ statusText(at: presentedAt)
+ }
+
+ public func statusText(at now: Date) -> String {
+ if rows.isEmpty { return isRefreshing ? "Refreshing…" : availability.title }
+ let fetchedAge = Format.relativeAge(fetchedAt, now: now)
+ // A refresh keeps the numbers it is replacing on screen, so this says they are the previous ones and how old
+ // they are. Being offline or rate limited still leads, because it explains why they are not moving.
+ guard isRefreshing else {
+ return availability == .current ? "fetched \(fetchedAge)" : "\(availability.title) · \(fetchedAge)"
+ }
+ // "showing " already says the values are old, so only a reason the refresh may not fix leads.
+ let explains: Set = [.networkUnavailable, .rateLimited, .authenticationRequired, .unavailable]
+ let prefix = explains.contains(availability) ? "\(availability.title) · " : ""
+ return "\(prefix)refreshing… · showing \(fetchedAge)"
+ }
+
+ public var statusHelp: String {
+ if source == .cache { return "Values stored when the app last ran; refreshing now." }
+ if isRefreshing {
+ return rows.isEmpty
+ ? "Fetching the first values." : "Fetching new values; the ones below are from the last successful fetch."
+ }
+ return "Values as of the last successful fetch."
+ }
+}
+
+public enum UsagePresenter {
+ public static func iconTone(_ state: [ProviderID: ProviderState]) -> StatusIconTone {
+ let availability = state.values.map(\.availability)
+ return availability.contains(.authenticationRequired)
+ ? .attention : availability.contains(.networkUnavailable) ? .offline : .normal
+ }
+
+ /// Groups neighbouring rows that share a reset instant, preserving the order they were selected in.
+ public static func groups(_ rows: [WindowRow]) -> [WindowRowGroup] {
+ var result: [WindowRowGroup] = []
+ for row in rows {
+ let reset = row.window.resetsAt
+ if let last = result.last, last.rows[0].window.resetsAt == reset, reset != nil {
+ result[result.count - 1] = WindowRowGroup(rows: last.rows + [row], resetText: last.resetText)
+ } else {
+ let text = reset == nil ? nil : "Resets in \(row.countdown) · \(row.resetClock)"
+ result.append(WindowRowGroup(rows: [row], resetText: text))
+ }
+ }
+ return result
+ }
+
+ public static func cards(
+ state: [ProviderID: ProviderState], enabled: Set, samples: [WindowKey: [UsageSample]], now: Date
+ ) -> [ProviderCard] {
+ state.keys.sorted().filter { isVisible(state[$0]!, enabled: enabled.contains($0)) }.map { provider in
+ card(provider: provider, state: state[provider]!, samples: samples, now: now)
+ }
+ }
+
+ public static func presentation(
+ state: [ProviderID: ProviderState], enabled: Set, selected: Set,
+ samples: [WindowKey: [UsageSample]], analytics: [ProviderID: UsageAnalyticsPresentation], lastRefresh: Date?,
+ iconTone: StatusIconTone, isRefreshing: Bool, now: Date
+ ) -> UsagePresentation {
+ let cards = state.keys.sorted().filter { isVisible(state[$0]!, enabled: enabled.contains($0)) }.map { provider in
+ card(
+ provider: provider, state: state[provider]!, samples: samples, selected: selected,
+ analytics: analytics[provider] ?? UsageAnalyticsPresentation(), now: now)
+ }
+ return UsagePresentation(
+ builtAt: now, lastRefresh: lastRefresh, iconTone: iconTone, isRefreshing: isRefreshing, cards: cards,
+ emptyTitle: enabled.isEmpty ? "No providers enabled" : "No usage available",
+ emptyDescription: "Set up providers under Settings > Providers.")
+ }
+
+ public static func isVisible(_ state: ProviderState, enabled: Bool) -> Bool {
+ guard enabled else { return false }
+ if state.snapshot != nil || state.analytics != nil || state.credentialHealth.isUsable { return true }
+ if case .valid = state.credentialState { return true }
+ return false
+ }
+
+ public static func card(
+ provider: ProviderID, state: ProviderState, samples: [WindowKey: [UsageSample]], now: Date
+ ) -> ProviderCard {
+ card(
+ provider: provider, state: state, samples: samples, selected: nil,
+ analytics: analyticsPresentation(state.analytics, now: now), now: now)
+ }
+
+ static func card(
+ provider: ProviderID, state: ProviderState, samples: [WindowKey: [UsageSample]], selected: Set?,
+ analytics: UsageAnalyticsPresentation, now: Date
+ ) -> ProviderCard {
+ let snapshot = state.snapshot
+ let rows = (snapshot?.windows ?? []).filter { window in
+ selected?.contains(WindowKey(provider, window)) ?? true
+ }.map { window -> WindowRow in
+ let key = WindowKey(provider, window)
+ let pace = PaceEstimate.estimate(window: window, samples: samples[key] ?? [], now: now)
+ return WindowRow(
+ key: key,
+ window: window,
+ pace: pace,
+ countdown: Format.countdown(to: window.resetsAt, now: now),
+ resetClock: Format.resetClock(window.resetsAt, now: now),
+ detail: detail(window),
+ paceLabel: pace.status.title,
+ paceText: pace.summary(now: now),
+ helpText: helpText(window: window, pace: pace, now: now),
+ isSelected: selected?.contains(key) ?? true
+ )
+ }
+ let (title, description) = emptyState(provider: provider, state: state)
+ let spend = snapshot?.spend.map { spendPresentation($0, provider: provider, now: now) }
+ let credits = creditsPresentation(snapshot?.credits, resetCredits: snapshot?.resetCredits)
+ let local = snapshot?.localUsage.map(localPresentation)
+ return ProviderCard(
+ provider: provider,
+ availability: state.availability,
+ identity: snapshot?.identity,
+ chips: chips(provider: provider, snapshot: snapshot),
+ rows: rows,
+ credits: snapshot?.credits,
+ spend: snapshot?.spend,
+ resetCredits: snapshot?.resetCredits,
+ notices: snapshot?.notices ?? [],
+ warnings: state.warnings,
+ lastError: state.lastError,
+ presentedAt: now,
+ fetchedAt: snapshot?.fetchedAt,
+ fetchedAge: Format.relativeAge(snapshot?.fetchedAt, now: now),
+ source: snapshot?.source,
+ emptyTitle: title,
+ emptyDescription: description,
+ isRefreshing: state.isRefreshing,
+ localUsage: snapshot?.localUsage,
+ codeReviews: analytics.codeReviews,
+ groups: groups(rows),
+ spendPresentation: spend,
+ creditsPresentation: credits,
+ localPresentation: local
+ )
+ }
+
+ public static func analyticsPresentation(_ analytics: ProviderAnalytics?, now: Date) -> UsageAnalyticsPresentation {
+ UsageAnalyticsPresentation(codeReviews: codeReviewSummary(analytics, now: now))
+ }
+
+ static func codeReviewSummary(_ analytics: ProviderAnalytics?, now: Date) -> String? {
+ guard let analytics else { return nil }
+ // UIEnvironment caches this by analytics revision and UTC day, avoiding another 60-day scan on quota updates.
+ let today = DayStamp.string(now)
+ let weekStart = DayStamp.string(now.addingTimeInterval(-6 * 86400))
+ var todayCount = 0.0
+ var weekCount = 0.0
+ var any = false
+ for point in analytics.points where point.metric == .codeReviews {
+ any = true
+ if point.day == today { todayCount += point.value }
+ if point.day >= weekStart { weekCount += point.value }
+ }
+ guard any else { return nil }
+ return "\(Int(todayCount)) today · \(Int(weekCount)) this week"
+ }
+
+ public static func spendPresentation(
+ _ spend: SpendControl, provider: ProviderID, now: Date
+ ) -> UsageSpendPresentation {
+ var metrics: [UsageMetricPresentation] = []
+ if let limit = spend.limit {
+ metrics.append(
+ UsageMetricPresentation(
+ title: "Monthly limit", value: limit.formatted, help: "Spend cap for credits beyond plan limits."))
+ }
+ if let used = spend.used {
+ metrics.append(
+ UsageMetricPresentation(title: "Spent", value: used.formatted, help: "Credits consumed this month."))
+ }
+ if let balance = spend.balance {
+ metrics.append(
+ UsageMetricPresentation(title: "Balance", value: balance.formatted, help: "Prepaid credit balance."))
+ }
+ if let resets = spend.resetsAt {
+ metrics.append(
+ UsageMetricPresentation(
+ title: "Resets", value: Format.resetClock(resets, now: now),
+ help: "When the monthly spend counter resets."))
+ }
+ if let autoReload = spend.autoReload {
+ metrics.append(
+ UsageMetricPresentation(
+ title: "Auto-reload", value: autoReload ? "On" : "Off", help: "Whether credits top up automatically."))
+ }
+ metrics.append(
+ UsageMetricPresentation(
+ title: "Purchase", value: spend.canPurchaseCredits ? "Available" : "Website only",
+ help: "Credits are bought on the provider website; the app displays their state."))
+ return UsageSpendPresentation(
+ spend: spend, provider: provider, title: provider == .claude ? "Usage credits" : "Spend control",
+ summary: spendSummary(spend), metrics: metrics)
+ }
+
+ public static func creditsPresentation(
+ _ credits: CreditBalance?, resetCredits: ResetCredits?
+ ) -> UsageCreditsPresentation? {
+ var metrics: [UsageMetricPresentation] = []
+ if let credits {
+ metrics.append(
+ UsageMetricPresentation(
+ title: "Credits", value: creditsSummary(credits), help: "Credits extend usage beyond plan limits."))
+ if credits.overageLimitReached {
+ metrics.append(
+ UsageMetricPresentation(
+ title: "Overage", value: "Limit reached", help: "The credit overage cap has been hit."))
+ }
+ if let cloud = credits.approxCloudMessages, cloud.upperBound > 0 {
+ metrics.append(
+ UsageMetricPresentation(
+ title: "Cloud messages", value: "~\(cloud.lowerBound)–\(cloud.upperBound)",
+ help: "Estimated cloud task messages the balance covers."))
+ }
+ }
+ if let resetCredits {
+ metrics.append(
+ UsageMetricPresentation(
+ title: "Limit resets", value: "\(resetCredits.available) available",
+ help: "Usage-limit resets available to redeem; \(resetCredits.applicable) apply now."))
+ if let earned = resetCredits.totalEarned {
+ metrics.append(
+ UsageMetricPresentation(title: "Resets earned", value: "\(earned)", help: "Reset credits earned so far."))
+ }
+ }
+ return metrics.isEmpty
+ ? nil : UsageCreditsPresentation(credits: credits, resetCredits: resetCredits, metrics: metrics)
+ }
+
+ public static func localPresentation(_ usage: LocalUsage) -> UsageLocalPresentation {
+ UsageLocalPresentation(
+ usage: usage,
+ metrics: [
+ UsageMetricPresentation(
+ title: "5-hour block", value: Format.compactNumber(Double(usage.windowTokens)) + " tokens",
+ help: "Tokens the CLI logged since the session window started, including cache reads."),
+ UsageMetricPresentation(
+ title: "Block cost", value: localMoney(usage.windowCost),
+ help: "API list-price equivalent for the same traffic; subscriptions are not billed per token."),
+ UsageMetricPresentation(
+ title: "Burn rate", value: localMoney(usage.costPerHour) + "/hr",
+ help: "API-equivalent spend per hour over the current block."),
+ UsageMetricPresentation(
+ title: "Today", value: Format.compactNumber(Double(usage.todayTokens)) + " tokens",
+ help: "Tokens logged today, including cache reads."),
+ UsageMetricPresentation(
+ title: "Messages today", value: "\(usage.todayMessages)", help: "Assistant messages logged today."),
+ UsageMetricPresentation(
+ title: "Today cost", value: localMoney(usage.todayCost), help: "API-equivalent cost of today's traffic."),
+ ])
+ }
+
+ public static func localMoney(_ value: Double) -> String {
+ value.formatted(.currency(code: "USD").precision(.fractionLength(value < 10 ? 2 : 0)))
+ }
+
+ static func detail(_ window: QuotaWindow) -> String {
+ switch window.id {
+ case "session", "weekly", "monthly":
+ window.duration.map { "window · \(Format.duration($0))" } ?? "window"
+ default: window.id
+ }
+ }
+
+ static func helpText(window: QuotaWindow, pace: PaceEstimate, now: Date) -> String {
+ var parts = [
+ "Used \(Format.percent(window.usedPercent, decimals: 1)); "
+ + "\(Format.percent(window.remainingPercent, decimals: 1)) remains."
+ ]
+ if let duration = window.duration { parts.append("Window duration: \(Format.duration(duration)).") }
+ if let expected = pace.expectedPercent {
+ parts.append("Even pace would be \(Format.percent(expected)) by now.")
+ }
+ if let ratio = pace.ratio {
+ parts.append("Pace ratio: \(ratio.formatted(.number.precision(.fractionLength(2))))×.")
+ }
+ parts.append(pace.summary(now: now) + ".")
+ parts.append("Severity: \(window.severity.rawValue).")
+ return parts.joined(separator: " ")
+ }
+
+ static func chips(provider: ProviderID, snapshot: ProviderSnapshot?) -> [Chip] {
+ guard let snapshot else { return [] }
+ var chips: [Chip] = []
+ if let plan = snapshot.identity?.planName { chips.append(Chip(text: plan)) }
+ if let email = snapshot.identity?.email { chips.append(Chip(text: email)) }
+ if let organization = snapshot.identity?.organization,
+ organization != snapshot.identity?.email.map({ "\($0)'s Organization" })
+ {
+ chips.append(Chip(text: organization))
+ }
+ if let until = snapshot.identity?.subscriptionActiveUntil {
+ chips.append(Chip(text: "Renews \(until.formatted(date: .abbreviated, time: .omitted))"))
+ }
+ if snapshot.source == .localLog { chips.append(Chip(text: "From local logs")) }
+ return chips
+ }
+
+ static func emptyState(provider: ProviderID, state: ProviderState) -> (String, String) {
+ switch state.availability {
+ case .loading: ("Loading \(provider.displayName)", "Fetching usage from \(provider.displayName)…")
+ case .authenticationRequired:
+ ("No usage available", "Review \(provider.displayName) under Settings > Providers.")
+ case .networkUnavailable: ("\(provider.displayName) is offline", state.lastError ?? "No network connection.")
+ case .disabled: ("\(provider.displayName) disabled", "Enable it under Settings > Providers.")
+ case .unavailable:
+ ("\(provider.displayName) unavailable", state.lastError ?? "The usage endpoint returned an error.")
+ case .rateLimited:
+ ("\(provider.displayName) rate limited", state.lastError ?? "The usage endpoint asked us to slow down.")
+ case .current, .stale: ("No usage yet", "\(provider.displayName) reports no active limits.")
+ }
+ }
+
+ public static func spendSummary(_ spend: SpendControl) -> String {
+ guard spend.enabled else { return spend.disabledReason.map { "Off (\(Format.humanize($0)))" } ?? "Off" }
+ let used = spend.used?.formatted ?? "—"
+ let limit = spend.limit?.formatted ?? "no limit"
+ let percent = spend.percent.map { " (\(Format.percent($0)))" } ?? ""
+ return "\(used) of \(limit)\(percent)"
+ }
+
+ public static func creditsSummary(_ credits: CreditBalance) -> String {
+ if credits.unlimited { return "Unlimited" }
+ guard credits.hasCredits, let balance = credits.balance, balance > 0 else { return "No credits" }
+ var text = credits.formattedBalance
+ if let local = credits.approxLocalMessages, local.upperBound > 0 {
+ text += " · ~\(local.lowerBound)–\(local.upperBound) local messages"
+ }
+ return text
+ }
+}
diff --git a/Sources/TokenMenuBarCore/ProcessPerformanceSnapshot.swift b/Sources/TokenMenuBarCore/ProcessPerformanceSnapshot.swift
new file mode 100644
index 0000000..a2406bf
--- /dev/null
+++ b/Sources/TokenMenuBarCore/ProcessPerformanceSnapshot.swift
@@ -0,0 +1,34 @@
+import Darwin
+import Foundation
+
+public struct ProcessPerformanceSnapshot: Codable, Equatable, Sendable {
+ public let processIdentifier: pid_t
+ public let residentMemoryBytes: UInt64
+ public let physicalFootprintBytes: UInt64
+ public let cpuNanoseconds: UInt64
+
+ public init(
+ processIdentifier: pid_t = getpid(), residentMemoryBytes: UInt64, physicalFootprintBytes: UInt64,
+ cpuNanoseconds: UInt64
+ ) {
+ self.processIdentifier = processIdentifier
+ self.residentMemoryBytes = residentMemoryBytes
+ self.physicalFootprintBytes = physicalFootprintBytes
+ self.cpuNanoseconds = cpuNanoseconds
+ }
+
+ public static func current() -> ProcessPerformanceSnapshot? {
+ var usage = rusage_info_v4()
+ let result = withUnsafeMutablePointer(to: &usage) { pointer in
+ pointer.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) {
+ proc_pid_rusage(getpid(), RUSAGE_INFO_V4, $0)
+ }
+ }
+ guard result == 0 else { return nil }
+ return ProcessPerformanceSnapshot(
+ processIdentifier: getpid(),
+ residentMemoryBytes: usage.ri_resident_size,
+ physicalFootprintBytes: usage.ri_phys_footprint,
+ cpuNanoseconds: usage.ri_user_time + usage.ri_system_time)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/ProviderDiscovery.swift b/Sources/TokenMenuBarCore/ProviderDiscovery.swift
new file mode 100644
index 0000000..19b7dbf
--- /dev/null
+++ b/Sources/TokenMenuBarCore/ProviderDiscovery.swift
@@ -0,0 +1,80 @@
+import Foundation
+
+public enum ProviderRediscoveryTrigger: Sendable, Equatable {
+ case applicationActivated
+ case userInitiated
+}
+
+public struct ProviderRediscoveryPolicy: Sendable {
+ public static let activationInterval: TimeInterval = 60
+
+ private let activationInterval: TimeInterval
+ private var lastDiscoveryAt: Date?
+
+ public init(
+ activationInterval: TimeInterval = activationInterval,
+ lastDiscoveryAt: Date? = nil
+ ) {
+ self.activationInterval = activationInterval
+ self.lastDiscoveryAt = lastDiscoveryAt
+ }
+
+ public mutating func begin(_ trigger: ProviderRediscoveryTrigger, at now: Date) -> Bool {
+ if trigger == .applicationActivated, let lastDiscoveryAt,
+ now >= lastDiscoveryAt, now.timeIntervalSince(lastDiscoveryAt) < activationInterval
+ {
+ return false
+ }
+ lastDiscoveryAt = now
+ return true
+ }
+
+ public mutating func recordDiscovery(at now: Date) {
+ lastDiscoveryAt = now
+ }
+}
+
+public struct ProviderDiscoverySnapshot: Sendable, Equatable {
+ public let providerIDs: [ProviderID]
+ public let credentials: [ProviderID: ProviderCredentialHealth]
+ public let resources: [ProviderID: [ResourceAccessState]]
+
+ public init(
+ providerIDs: [ProviderID],
+ credentials: [ProviderID: ProviderCredentialHealth],
+ resources: [ProviderID: [ResourceAccessState]]
+ ) {
+ self.providerIDs = providerIDs
+ self.credentials = credentials
+ self.resources = resources
+ }
+
+ public static func inspect(_ registry: ProviderRegistry, now: Date) async -> ProviderDiscoverySnapshot {
+ let credentials = await withTaskGroup(
+ of: (ProviderID, ProviderCredentialHealth).self,
+ returning: [ProviderID: ProviderCredentialHealth].self
+ ) { group in
+ for provider in registry.providers {
+ group.addTask { (provider.id, await provider.credentialHealth(now: now)) }
+ }
+ var values: [ProviderID: ProviderCredentialHealth] = [:]
+ for await (provider, health) in group { values[provider] = health }
+ return values
+ }
+ return ProviderDiscoverySnapshot(
+ providerIDs: registry.ids,
+ credentials: credentials,
+ resources: registry.setupStates.mapValues(\.resources))
+ }
+
+ public func differs(
+ from states: [ProviderID: ProviderState],
+ providerIDs currentProviderIDs: [ProviderID]
+ ) -> Bool {
+ guard providerIDs == currentProviderIDs else { return true }
+ return providerIDs.contains { provider in
+ credentials[provider] != states[provider]?.credentialHealth
+ || (resources[provider] ?? []) != (states[provider]?.resourceAccess ?? [])
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/ProviderID.swift b/Sources/TokenMenuBarCore/ProviderID.swift
new file mode 100644
index 0000000..2507611
--- /dev/null
+++ b/Sources/TokenMenuBarCore/ProviderID.swift
@@ -0,0 +1,44 @@
+import Foundation
+
+public enum ProviderID: String, Codable, CaseIterable, Sendable, Hashable, Comparable {
+ case claude
+ case codex
+ case gemini
+ case cursor
+ case copilot
+
+ public var displayName: String {
+ switch self {
+ case .claude: "Claude"
+ case .codex: "Codex"
+ case .gemini: "Gemini"
+ case .cursor: "Cursor"
+ case .copilot: "GitHub Copilot"
+ }
+ }
+
+ public var shortLabel: String {
+ switch self {
+ case .claude: "CC"
+ case .codex: "CX"
+ case .gemini: "GM"
+ case .cursor: "CU"
+ case .copilot: "CP"
+ }
+ }
+
+ public var loginHint: String {
+ switch self {
+ case .claude: "Run `claude` once to sign in; the app reads its Keychain or credential-file session."
+ case .codex: "Run `codex login` once; the app follows cli_auth_credentials_store in config.toml."
+ case .gemini: "Run `gemini` and sign in with Google once; the app reads its selected credential store."
+ case .cursor: "Sign in to the Cursor app or run `cursor-agent login`; the app reads Cursor's local session."
+ case .copilot:
+ "Run `copilot login`; the app also reads supported token environment variables and existing editor sessions."
+ }
+ }
+
+ public static func < (lhs: ProviderID, rhs: ProviderID) -> Bool {
+ lhs.rawValue < rhs.rawValue
+ }
+}
diff --git a/Sources/TokenMenuBarCore/ProviderSetup.swift b/Sources/TokenMenuBarCore/ProviderSetup.swift
new file mode 100644
index 0000000..682d2be
--- /dev/null
+++ b/Sources/TokenMenuBarCore/ProviderSetup.swift
@@ -0,0 +1,386 @@
+import Foundation
+
+public struct CredentialSource: Sendable, Equatable, Hashable, Identifiable {
+ public let id: String
+ public let provider: ProviderID
+ public let title: String
+ public let detail: String
+
+ public init(id: String, provider: ProviderID, title: String, detail: String) {
+ self.id = id
+ self.provider = provider
+ self.title = title
+ self.detail = detail
+ }
+}
+
+public struct CredentialReadFailure: Error, Sendable, Equatable, CustomStringConvertible {
+ public let source: CredentialSource
+ public let detail: String
+
+ public init(source: CredentialSource, detail: String) {
+ self.source = source
+ self.detail = detail
+ }
+
+ public init(source: CredentialSource, error: any Error) {
+ if let failure = error as? CredentialReadFailure {
+ self = failure
+ } else {
+ self.init(source: source, detail: String(describing: error))
+ }
+ }
+
+ public var description: String { detail }
+}
+
+public enum ProviderCredentialHealth: Sendable, Equatable {
+ case unchecked
+ case missing(expected: [CredentialSource])
+ case valid(source: CredentialSource, expiresAt: Date?)
+ case expired(source: CredentialSource, at: Date)
+ case unreadable(source: CredentialSource?, detail: String)
+
+ public var source: CredentialSource? {
+ switch self {
+ case .valid(let source, _), .expired(let source, _): source
+ case .unreadable(let source, _): source
+ case .unchecked, .missing: nil
+ }
+ }
+
+ public var isUsable: Bool {
+ if case .valid = self { return true }
+ return false
+ }
+
+ public static func from(
+ _ state: CredentialState,
+ source: CredentialSource,
+ expected: [CredentialSource]
+ ) -> ProviderCredentialHealth {
+ switch state {
+ case .missing: .missing(expected: expected)
+ case .valid(let expiresAt): .valid(source: source, expiresAt: expiresAt)
+ case .expired(let date): .expired(source: source, at: date)
+ }
+ }
+
+ static func from(readError: any Error, fallbackSource: CredentialSource) -> ProviderCredentialHealth {
+ let failure = CredentialReadFailure(source: fallbackSource, error: readError)
+ return .unreadable(source: failure.source, detail: failure.detail)
+ }
+}
+
+public enum ProviderServiceHealth: Sendable, Equatable {
+ case unchecked
+ case checking
+ case available
+ case offline(detail: String)
+ case rateLimited(retryAt: Date?, detail: String)
+ case unavailable(detail: String)
+
+ public static func from(
+ availability: QuotaAvailability,
+ detail: String?,
+ retryAt: Date? = nil
+ ) -> ProviderServiceHealth {
+ switch availability {
+ case .loading: .checking
+ case .current, .stale: .available
+ case .networkUnavailable: .offline(detail: detail ?? "The provider could not be reached.")
+ case .rateLimited: .rateLimited(retryAt: retryAt, detail: detail ?? "The provider is rate limiting requests.")
+ case .authenticationRequired, .unavailable: .unavailable(detail: detail ?? availability.title)
+ case .disabled: .unchecked
+ }
+ }
+}
+
+public enum ResourceAccessHealth: Sendable, Equatable {
+ case notRequired
+ case needed
+ case granted
+ case stale
+ case error(String)
+}
+
+public struct ResourceAccessState: Sendable, Equatable, Identifiable {
+ public let resource: SandboxResource
+ public let health: ResourceAccessHealth
+ public let isRequired: Bool
+
+ public var id: String { resource.id }
+
+ public init(resource: SandboxResource, health: ResourceAccessHealth, isRequired: Bool = true) {
+ self.resource = resource
+ self.health = health
+ self.isRequired = isRequired
+ }
+
+ public static func notRequired(_ resource: SandboxResource) -> ResourceAccessState {
+ ResourceAccessState(resource: resource, health: .notRequired, isRequired: false)
+ }
+}
+
+public enum ProviderRecoveryAction: Sendable, Equatable {
+ case copyCommand(String)
+ case checkAgain
+ case refreshProvider(ProviderID)
+ case grantAccess(SandboxResource)
+ case openLoginItems
+ case contactAdministrator
+
+ public var title: String {
+ switch self {
+ case .copyCommand: "Copy command"
+ case .checkAgain: "Check again"
+ case .refreshProvider: "Check again"
+ case .grantAccess: "Grant access"
+ case .openLoginItems: "Open Login Items"
+ case .contactAdministrator: "Contact administrator"
+ }
+ }
+}
+
+public struct ProviderRecoveryIssue: Sendable, Equatable {
+ public enum Kind: Sendable, Equatable {
+ case credentialMissing
+ case credentialExpired
+ case credentialUnreadable
+ case resourceAccess
+ case network
+ case rateLimited
+ case service
+ case accountUnsupported
+ case credentialPersistence
+ }
+
+ public let kind: Kind
+ public let title: String
+ public let detail: String
+ public let action: ProviderRecoveryAction
+
+ public init(kind: Kind, title: String, detail: String, action: ProviderRecoveryAction) {
+ self.kind = kind
+ self.title = title
+ self.detail = detail
+ self.action = action
+ }
+
+ public static func unsupportedAccount(provider: ProviderID, detail: String) -> ProviderRecoveryIssue {
+ ProviderRecoveryIssue(
+ kind: .accountUnsupported,
+ title: "\(provider.displayName) account is not supported",
+ detail: detail,
+ action: provider.setup.missingCredentialIssue.action)
+ }
+}
+
+public struct ProviderSetupState: Sendable, Equatable {
+ public var enabled: Bool
+ public var credential: ProviderCredentialHealth
+ public var service: ProviderServiceHealth
+ public var resources: [ResourceAccessState]
+ public var issue: ProviderRecoveryIssue?
+
+ public init(
+ enabled: Bool,
+ credential: ProviderCredentialHealth = .unchecked,
+ service: ProviderServiceHealth = .unchecked,
+ resources: [ResourceAccessState] = [],
+ issue: ProviderRecoveryIssue? = nil
+ ) {
+ self.enabled = enabled
+ self.credential = credential
+ self.service = service
+ self.resources = resources
+ self.issue = issue
+ }
+
+ public static func from(
+ provider: ProviderID,
+ enabled: Bool,
+ credentialState: CredentialState,
+ source: CredentialSource,
+ resources: [ResourceAccessState]
+ ) -> ProviderSetupState {
+ let credential = ProviderCredentialHealth.from(
+ credentialState, source: source, expected: provider.setup.credentialSources)
+ var state = from(provider: provider, enabled: enabled, credential: credential, resources: resources)
+ if case .missing(let detail) = credentialState, state.issue?.kind != .resourceAccess {
+ state.issue = ProviderRecoveryIssue(
+ kind: .credentialMissing,
+ title: provider.setup.signInTitle,
+ detail: detail,
+ action: provider.setup.missingCredentialIssue.action)
+ }
+ return state
+ }
+
+ public static func from(
+ provider: ProviderID,
+ enabled: Bool,
+ credential: ProviderCredentialHealth,
+ resources: [ResourceAccessState]
+ ) -> ProviderSetupState {
+ var issue: ProviderRecoveryIssue?
+ switch credential {
+ case .unchecked, .valid:
+ break
+ case .missing:
+ issue = provider.setup.missingCredentialIssue
+ case .expired:
+ issue = ProviderRecoveryIssue(
+ kind: .credentialExpired,
+ title: "\(provider.displayName) sign-in expired",
+ detail: provider.setup.signInDetail,
+ action: provider.setup.missingCredentialIssue.action)
+ case .unreadable(_, let detail):
+ issue = ProviderRecoveryIssue(
+ kind: .credentialUnreadable,
+ title: "\(provider.displayName) credentials could not be read",
+ detail: detail,
+ action: .refreshProvider(provider))
+ }
+ if !credential.isUsable,
+ let resource = resources.first(where: { $0.isRequired && $0.health != .granted })
+ {
+ issue = ProviderRecoveryIssue(
+ kind: .resourceAccess,
+ title: resource.health == .stale ? "Access grant needs renewal" : "File access needed",
+ detail: "Grant access to \(resource.resource.label) so \(provider.displayName) data can be read.",
+ action: .grantAccess(resource.resource))
+ }
+ return ProviderSetupState(
+ enabled: enabled, credential: credential, resources: resources, issue: issue)
+ }
+}
+
+public struct ProviderSetupMetadata: Sendable, Equatable {
+ public let provider: ProviderID
+ public let signInTitle: String
+ public let signInDetail: String
+ public let signInCommand: String?
+ public let credentialSources: [CredentialSource]
+
+ public init(
+ provider: ProviderID,
+ signInTitle: String,
+ signInDetail: String,
+ signInCommand: String?,
+ credentialSources: [CredentialSource]
+ ) {
+ self.provider = provider
+ self.signInTitle = signInTitle
+ self.signInDetail = signInDetail
+ self.signInCommand = signInCommand
+ self.credentialSources = credentialSources
+ }
+
+ public var missingCredentialIssue: ProviderRecoveryIssue {
+ ProviderRecoveryIssue(
+ kind: .credentialMissing,
+ title: signInTitle,
+ detail: signInDetail,
+ action: signInCommand.map(ProviderRecoveryAction.copyCommand) ?? .refreshProvider(provider))
+ }
+}
+
+extension ProviderRecoveryIssue {
+ public static func credentialPersistence(provider: ProviderID, detail: String) -> ProviderRecoveryIssue {
+ ProviderRecoveryIssue(
+ kind: .credentialPersistence,
+ title: "\(provider.displayName) sign-in could not be saved",
+ detail: detail,
+ action: .refreshProvider(provider))
+ }
+}
+
+extension ProviderID {
+ public func credentialSource(_ id: String) -> CredentialSource {
+ setup.credentialSources.first { $0.id == id }
+ ?? CredentialSource(id: id, provider: self, title: "Local credentials", detail: "A local credential source.")
+ }
+
+ public func needsSandboxResources(for credentialSource: CredentialSource?) -> Bool {
+ self != .copilot || credentialSource?.id != "copilot.environment"
+ }
+
+ public var setup: ProviderSetupMetadata {
+ let sources: [CredentialSource]
+ let title: String
+ let detail: String
+ let command: String?
+ switch self {
+ case .claude:
+ sources = [
+ CredentialSource(
+ id: "claude.keychain", provider: self, title: "Claude Code Keychain",
+ detail: "The sign-in maintained by Claude Code."),
+ CredentialSource(
+ id: "claude.file", provider: self, title: "Claude credential file",
+ detail: "The file fallback under the Claude configuration directory."),
+ ]
+ title = "Sign in to Claude Code"
+ detail = "Run Claude Code once and complete its sign-in. Token Menu Bar reads that local session."
+ command = "claude"
+ case .codex:
+ sources = [
+ CredentialSource(
+ id: "codex.keyring", provider: self, title: "Codex keychain",
+ detail: "Used when cli_auth_credentials_store is keyring or auto."),
+ CredentialSource(
+ id: "codex.file", provider: self, title: "Codex auth.json",
+ detail: "Used when cli_auth_credentials_store is file or its file fallback is present."),
+ ]
+ title = "Sign in to Codex"
+ detail = "Sign in with the Codex CLI. Token Menu Bar follows the credential store selected in config.toml."
+ command = "codex login"
+ case .gemini:
+ sources = [
+ CredentialSource(
+ id: "gemini.keychain", provider: self, title: "Gemini CLI Keychain",
+ detail: "Used when encrypted credential storage is enabled for Gemini CLI."),
+ CredentialSource(
+ id: "gemini.file", provider: self, title: "Gemini oauth_creds.json",
+ detail: "The standard Gemini CLI credential file."),
+ ]
+ title = "Sign in to Gemini CLI"
+ detail = "Run Gemini CLI and choose Sign in with Google. Token Menu Bar reads that local session."
+ command = "gemini"
+ case .cursor:
+ sources = [
+ CredentialSource(
+ id: "cursor.app", provider: self, title: "Cursor app session",
+ detail: "The session in Cursor's local application database."),
+ CredentialSource(
+ id: "cursor.agent", provider: self, title: "Cursor Agent auth.json",
+ detail: "The session created by cursor-agent login."),
+ ]
+ title = "Sign in to Cursor"
+ detail = "Sign in in Cursor or use Cursor Agent. Token Menu Bar checks both local sessions."
+ command = "cursor-agent login"
+ case .copilot:
+ sources = [
+ CredentialSource(
+ id: "copilot.environment", provider: self, title: "GitHub token environment",
+ detail: "COPILOT_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN, in that order."),
+ CredentialSource(
+ id: "copilot.keychain", provider: self, title: "GitHub Copilot CLI Keychain",
+ detail: "The current Copilot CLI session in macOS Keychain."),
+ CredentialSource(
+ id: "copilot.file", provider: self, title: "GitHub Copilot CLI config.json",
+ detail: "The plaintext fallback under COPILOT_HOME when Keychain is unavailable."),
+ CredentialSource(
+ id: "copilot.legacy-file", provider: self, title: "GitHub Copilot extension files",
+ detail: "Existing hosts.json and apps.json sessions remain supported."),
+ ]
+ title = "Sign in to GitHub Copilot"
+ detail = "Sign in from GitHub Copilot CLI or a supported editor, then check again."
+ command = "copilot login"
+ }
+ return ProviderSetupMetadata(
+ provider: self, signInTitle: title, signInDetail: detail, signInCommand: command,
+ credentialSources: sources)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeAPI.swift b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeAPI.swift
new file mode 100644
index 0000000..7d90076
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeAPI.swift
@@ -0,0 +1,359 @@
+import Foundation
+
+enum ClaudeAPI {
+ static let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!
+ static let profileURL = URL(string: "https://api.anthropic.com/api/oauth/profile")!
+ static let tokenURL = URL(string: "https://platform.claude.com/v1/oauth/token")!
+ static let clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
+ static let betaHeader = "oauth-2025-04-20"
+ static let userAgent = "claude-code/2.1.251"
+
+ static func headers(token: String) -> [String: String] {
+ ["Authorization": "Bearer \(token)", "anthropic-beta": betaHeader, "User-Agent": userAgent]
+ }
+
+ struct Window: Decodable, Sendable, Equatable {
+ let utilization: Double
+ let resetsAt: String?
+
+ enum CodingKeys: String, CodingKey {
+ case utilization
+ case resetsAt = "resets_at"
+ }
+ }
+
+ struct Limit: Decodable, Sendable, Equatable {
+ struct Scope: Decodable, Sendable, Equatable {
+ struct Model: Decodable, Sendable, Equatable {
+ let id: String?
+ let displayName: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case displayName = "display_name"
+ }
+ }
+
+ let model: Model?
+ let surface: String?
+ }
+
+ let kind: String
+ let group: String?
+ let percent: Double
+ let severity: String?
+ let resetsAt: String?
+ let scope: Scope?
+ let isActive: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case kind, group, percent, severity, scope
+ case resetsAt = "resets_at"
+ case isActive = "is_active"
+ }
+ }
+
+ struct MoneyDTO: Decodable, Sendable, Equatable {
+ let amountMinor: Int
+ let currency: String?
+ let exponent: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case amountMinor = "amount_minor"
+ case currency, exponent
+ }
+
+ func money(defaultCurrency: String) -> Money {
+ Money(amountMinor: amountMinor, currency: currency ?? defaultCurrency, exponent: exponent ?? 2)
+ }
+ }
+
+ struct Spend: Decodable, Sendable, Equatable {
+ let used: MoneyDTO?
+ let limit: MoneyDTO?
+ let percent: Double?
+ let severity: String?
+ let enabled: Bool?
+ let disabledReason: String?
+ let balance: MoneyDTO?
+ let autoReload: JSONValue?
+ let canPurchaseCredits: Bool?
+ let canToggle: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case used, limit, percent, severity, enabled, balance
+ case disabledReason = "disabled_reason"
+ case autoReload = "auto_reload"
+ case canPurchaseCredits = "can_purchase_credits"
+ case canToggle = "can_toggle"
+ }
+ }
+
+ struct ExtraUsage: Decodable, Sendable, Equatable {
+ let isEnabled: Bool?
+ let monthlyLimit: Double?
+ let usedCredits: Double?
+ let utilization: Double?
+ let currency: String?
+ let decimalPlaces: Int?
+ let disabledReason: String?
+ let spendLimitReached: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case utilization, currency
+ case isEnabled = "is_enabled"
+ case monthlyLimit = "monthly_limit"
+ case usedCredits = "used_credits"
+ case decimalPlaces = "decimal_places"
+ case disabledReason = "disabled_reason"
+ case spendLimitReached = "spend_limit_reached"
+ }
+ }
+
+ struct UsageResponse: Decodable, Sendable, Equatable {
+ static let knownWindowKeys: [String: (id: String, label: String, group: WindowGroup)] = [
+ "five_hour": ("session", "Current session", .session),
+ "seven_day": ("weekly", "All models", .weekly),
+ "seven_day_opus": ("weekly:opus", "Opus", .weekly),
+ "seven_day_sonnet": ("weekly:sonnet", "Sonnet", .weekly),
+ "seven_day_oauth_apps": ("weekly:oauth-apps", "OAuth apps", .weekly),
+ "seven_day_cowork": ("weekly:cowork", "Cowork", .weekly),
+ ]
+
+ let limits: [Limit]
+ let windows: [String: Window]
+ let spend: Spend?
+ let extraUsage: ExtraUsage?
+
+ // The response mixes known blocks with a window per vendor-chosen key, so it decodes as a JSON object and each
+ // unrecognised key becomes a window.
+ init(from decoder: any Decoder) throws {
+ let document = try JSONValue(from: decoder)
+ let fields = document.objectValue ?? [:]
+ let decoder = JSONDecoder()
+ func decode(_ type: Value.Type, _ key: String) -> Value? {
+ guard let field = fields[key], !field.isNull, let data = try? JSONEncoder().encode(field) else { return nil }
+ return try? decoder.decode(type, from: data)
+ }
+ limits = decode([Limit].self, "limits") ?? []
+ spend = decode(Spend.self, "spend")
+ extraUsage = decode(ExtraUsage.self, "extra_usage")
+ var windows: [String: Window] = [:]
+ for key in fields.keys where !["limits", "spend", "extra_usage", "member_dashboard_available"].contains(key) {
+ if let window = decode(Window.self, key) { windows[key] = window }
+ }
+ self.windows = windows
+ }
+ }
+
+ struct ProfileResponse: Decodable, Sendable, Equatable {
+ struct Account: Decodable, Sendable, Equatable {
+ let email: String?
+ let displayName: String?
+ let hasClaudeMax: Bool?
+ let hasClaudePro: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case email
+ case displayName = "display_name"
+ case hasClaudeMax = "has_claude_max"
+ case hasClaudePro = "has_claude_pro"
+ }
+ }
+
+ struct Organization: Decodable, Sendable, Equatable {
+ let name: String?
+ let organizationType: String?
+ let rateLimitTier: String?
+ let hasExtraUsageEnabled: Bool?
+ let subscriptionStatus: String?
+
+ enum CodingKeys: String, CodingKey {
+ case name
+ case organizationType = "organization_type"
+ case rateLimitTier = "rate_limit_tier"
+ case hasExtraUsageEnabled = "has_extra_usage_enabled"
+ case subscriptionStatus = "subscription_status"
+ }
+ }
+
+ let account: Account?
+ let organization: Organization?
+ }
+
+ struct TokenResponse: Decodable, Sendable, Equatable {
+ let accessToken: String
+ let refreshToken: String?
+ let expiresIn: Double?
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case refreshToken = "refresh_token"
+ case expiresIn = "expires_in"
+ }
+ }
+}
+
+enum ClaudeMapper {
+ static let sessionDuration: TimeInterval = 5 * 3600
+
+ /// The digits in a tier like `default_claude_max_20x`, which name how many times the base plan the account gets.
+ static func trailingMultiplier(in tier: String) -> String? {
+ guard tier.hasSuffix("x") else { return nil }
+ let digits = tier.dropLast().reversed().prefix { $0.isNumber }.reversed()
+ return digits.isEmpty ? nil : String(digits)
+ }
+ static let weeklyDuration: TimeInterval = 7 * 86400
+
+ static func windows(_ response: ClaudeAPI.UsageResponse) -> [QuotaWindow] {
+ if !response.limits.isEmpty { return response.limits.map(window) }
+ return response.windows.map { key, window in
+ let known = ClaudeAPI.UsageResponse.knownWindowKeys[key]
+ let group: WindowGroup = known?.group ?? (key.hasPrefix("seven_day") ? .weekly : .other)
+ return QuotaWindow(
+ id: known?.id ?? key,
+ label: known?.label ?? Format.humanize(key),
+ group: group,
+ usedPercent: window.utilization,
+ resetsAt: ISODate.parse(window.resetsAt),
+ duration: group == .session ? sessionDuration : group == .weekly ? weeklyDuration : nil
+ )
+ }
+ }
+
+ static func window(_ limit: ClaudeAPI.Limit) -> QuotaWindow {
+ let scopeName = limit.scope?.model?.displayName ?? limit.scope?.surface
+ let id: String
+ let label: String
+ let group: WindowGroup
+ let duration: TimeInterval?
+ switch limit.kind {
+ case "session":
+ id = "session"
+ label = "Current session"
+ group = .session
+ duration = sessionDuration
+ case "weekly_all":
+ id = "weekly"
+ label = "All models"
+ group = .weekly
+ duration = weeklyDuration
+ case "weekly_scoped":
+ id = "weekly:\(Format.slug(scopeName ?? "scoped"))"
+ label = scopeName ?? "Scoped weekly"
+ group = .weekly
+ duration = weeklyDuration
+ default:
+ if let scopeName {
+ id = "\(limit.kind):\(Format.slug(scopeName))"
+ label = "\(Format.humanize(limit.kind)) \(scopeName)"
+ } else {
+ id = limit.kind
+ label = Format.humanize(limit.kind)
+ }
+ group = WindowGroup(rawValue: limit.group ?? "") ?? .other
+ duration = nil
+ }
+ return QuotaWindow(
+ id: id,
+ label: label,
+ group: group,
+ usedPercent: limit.percent,
+ resetsAt: ISODate.parse(limit.resetsAt),
+ duration: duration,
+ severity: limit.severity.map { Severity(raw: $0) },
+ isActive: limit.isActive ?? true,
+ scope: scopeName
+ )
+ }
+
+ static func spend(
+ _ response: ClaudeAPI.UsageResponse, now: Date, calendar: Calendar = .current
+ ) -> SpendControl? {
+ let extra = response.extraUsage
+ let spend = response.spend
+ guard extra != nil || spend != nil else { return nil }
+ let currency = extra?.currency ?? spend?.used?.currency ?? "USD"
+ let exponent = extra?.decimalPlaces ?? 2
+ let scale = pow(10, Double(exponent))
+ let used =
+ spend?.used?.money(defaultCurrency: currency)
+ ?? extra?.usedCredits.map {
+ Money(amountMinor: Int(($0 * scale).rounded()), currency: currency, exponent: exponent)
+ }
+ let limit =
+ spend?.limit?.money(defaultCurrency: currency)
+ ?? extra?.monthlyLimit.map {
+ Money(amountMinor: Int(($0 * scale).rounded()), currency: currency, exponent: exponent)
+ }
+ let enabled = spend?.enabled ?? extra?.isEnabled ?? false
+ let derivedPercent: Double? = limit.flatMap { limit in
+ used.map { used in
+ limit.amountMinor > 0 ? Double(used.amountMinor) / Double(limit.amountMinor) * 100 : 0
+ }
+ }
+ let reportedPercent: Double? = spend?.percent
+ let extraPercent: Double? = extra?.utilization
+ let percent = reportedPercent ?? extraPercent ?? derivedPercent
+ let autoReload: Bool? = spend?.autoReload.map { !$0.isNull }
+ return SpendControl(
+ enabled: enabled,
+ canToggle: spend?.canToggle ?? false,
+ used: used,
+ limit: limit,
+ percent: percent,
+ resetsAt: nextMonthStart(after: now, calendar: calendar),
+ limitReached: extra?.spendLimitReached ?? false,
+ disabledReason: enabled ? nil : (spend?.disabledReason ?? extra?.disabledReason),
+ balance: spend?.balance?.money(defaultCurrency: currency),
+ autoReload: autoReload,
+ canPurchaseCredits: spend?.canPurchaseCredits ?? false
+ )
+ }
+
+ static func nextMonthStart(after date: Date, calendar: Calendar) -> Date? {
+ let start = calendar.dateInterval(of: .month, for: date)?.start
+ return start.flatMap { calendar.date(byAdding: .month, value: 1, to: $0) }
+ }
+
+ static func identity(
+ profile: ClaudeAPI.ProfileResponse?, credentials: ClaudeOAuthCredentials?, local: ClaudeLocalAccount?
+ ) -> ProviderIdentity {
+ let tier = profile?.organization?.rateLimitTier ?? credentials?.rateLimitTier ?? local?.rateLimitTier
+ let base: String
+ switch profile?.organization?.organizationType ?? credentials?.subscriptionType {
+ case "claude_max", "max": base = "Max"
+ case "claude_pro", "pro": base = "Pro"
+ case "claude_team", "team": base = "Team"
+ case "claude_enterprise", "enterprise": base = "Enterprise"
+ case .some(let other): base = Format.humanize(other.replacingOccurrences(of: "claude_", with: ""))
+ case nil:
+ if profile?.account?.hasClaudeMax == true {
+ base = "Max"
+ } else if profile?.account?.hasClaudePro == true {
+ base = "Pro"
+ } else {
+ base = "Claude"
+ }
+ }
+ let multiplier = tier.flatMap(trailingMultiplier)
+ return ProviderIdentity(
+ planName: multiplier.map { "\(base) \($0)x" } ?? base,
+ tier: tier,
+ email: profile?.account?.email ?? local?.email,
+ organization: profile?.organization?.name ?? local?.organizationName
+ )
+ }
+
+ static func notices(_ response: ClaudeAPI.UsageResponse) -> [Notice] {
+ var notices: [Notice] = []
+ if response.extraUsage?.spendLimitReached == true {
+ notices.append(Notice(kind: .spendControl, text: "Monthly usage-credit spend limit reached."))
+ }
+ for limit in response.limits.filter({ Severity(raw: $0.severity) == .critical }) {
+ notices.append(
+ Notice(kind: .limitReached, text: "\(window(limit).label) limit reached; resets \(limit.resetsAt ?? "later")."))
+ }
+ return notices
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift
new file mode 100644
index 0000000..62691d1
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift
@@ -0,0 +1,210 @@
+import Foundation
+
+public actor ClaudeProvider: UsageProvider {
+ static let profileCacheInterval: TimeInterval = 6 * 3600
+
+ public nonisolated let id: ProviderID = .claude
+ public nonisolated let pollingPolicy = PollingPolicy.defaults(for: .claude)
+ private let credentials: any ClaudeCredentialStore
+ private let localAccountURL: URL?
+ private let transcripts: ClaudeTranscriptReader?
+ private let client: APIClient
+ private let log: LogBuffer
+ private let allowRefresh: @MainActor @Sendable () -> Bool
+ private var cachedProfile: (fingerprint: String, profile: ClaudeAPI.ProfileResponse, at: Date)?
+ private var pendingCredentialSave: PendingCredentialSave?
+
+ public init(
+ credentials: any ClaudeCredentialStore,
+ localAccountURL: URL?,
+ transcripts: ClaudeTranscriptReader? = nil,
+ client: APIClient,
+ log: LogBuffer,
+ allowRefresh: @escaping @MainActor @Sendable () -> Bool
+ ) {
+ self.credentials = credentials
+ self.localAccountURL = localAccountURL
+ self.transcripts = transcripts
+ self.client = client
+ self.log = log
+ self.allowRefresh = allowRefresh
+ }
+
+ public nonisolated var credentialDescription: String {
+ credentials.description
+ }
+
+ public nonisolated func credentialState(now: Date) -> CredentialState {
+ do {
+ guard let stored = try credentials.load() else { return .missing("no Claude Code sign-in found") }
+ return stored.state(now: now)
+ } catch {
+ return .missing("\(error)")
+ }
+ }
+
+ public func credentialHealth(now: Date) async -> ProviderCredentialHealth {
+ if let pendingCredentialSave {
+ return .from(
+ pendingCredentialSave.credential.state(now: now), source: pendingCredentialSave.source,
+ expected: id.setup.credentialSources)
+ }
+ return credentials.credentialHealth(now: now)
+ }
+
+ public func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult {
+ let resolved: ResolvedCredential
+ do {
+ resolved = try resolveCredential(
+ pending: &pendingCredentialSave,
+ provider: id,
+ load: {
+ try credentials.loadWithSource().map { (credential: $0.credentials, source: $0.source) }
+ },
+ save: { try credentials.save($0, replacing: $1) })
+ guard resolved.credential != nil else {
+ return ProviderFetchResult(outcome: .notAuthenticated("No Claude Code credentials. \(id.loginHint)"))
+ .withCredentialStatus(.missing("no Claude Code sign-in found", provider: id))
+ }
+ } catch {
+ return ProviderFetchResult(outcome: .notAuthenticated("Cannot read Claude credentials: \(error)"))
+ .withCredentialStatus(.unreadable(error, provider: id, fallbackSource: credentials.source))
+ }
+ let stored = resolved.credential!
+ var recoveryIssue = resolved.issue
+ var warnings: [String] = []
+ var active = stored
+ var activeSource = resolved.source!
+ if case .expired = stored.state(now: now) {
+ guard await allowRefresh() else {
+ return ProviderFetchResult(outcome: .notAuthenticated("Claude token expired. \(id.loginHint)"))
+ .withCredentialStatus(
+ .resolved(stored.state(now: now), provider: id, source: activeSource))
+ }
+ do {
+ let refreshed = try await refresh(stored, source: activeSource, now: now)
+ active = refreshed.credential
+ activeSource = refreshed.source
+ recoveryIssue = refreshed.issue
+ } catch {
+ return ProviderFetchResult(outcome: .notAuthenticated("Claude token refresh failed: \(error.message)"))
+ .withCredentialStatus(
+ .resolved(stored.state(now: now), provider: id, source: activeSource))
+ }
+ }
+ let credentialStatus = ProviderCredentialStatus.resolved(
+ active.state(now: now), provider: id, source: activeSource)
+ let fingerprint = active.cacheFingerprint
+ if !active.hasProfileScope {
+ warnings.append("Token lacks the user:profile scope; sign in with `claude` rather than `claude setup-token`.")
+ }
+ let headers = ClaudeAPI.headers(token: active.accessToken)
+ async let usageTask = usage(headers: headers)
+ async let profileTask = profile(headers: headers, fingerprint: fingerprint, now: now)
+ let (usageResult, profileResult) = await (usageTask, profileTask)
+ let local = localAccountURL.flatMap(ClaudeLocalAccount.load(from:))
+ var profile: ClaudeAPI.ProfileResponse?
+ switch profileResult {
+ case .success(let value): profile = value
+ case .failure(let error): warnings.append("Profile unavailable: \(error.message)")
+ }
+ let identity = ClaudeMapper.identity(profile: profile, credentials: active, local: local)
+ let transcript = await transcripts?.refresh(now: now, retentionDays: options.analyticsDays)
+ switch usageResult {
+ case .success(let response):
+ let windows = ClaudeMapper.windows(response)
+ let session = windows.first { $0.id == "session" }
+ let snapshot = ProviderSnapshot(
+ provider: .claude,
+ identity: identity,
+ windows: windows,
+ spend: ClaudeMapper.spend(response, now: now),
+ notices: ClaudeMapper.notices(response),
+ localUsage: transcript?.localUsage(
+ windowResetsAt: session?.resetsAt, windowDuration: ClaudeMapper.sessionDuration, now: now),
+ fetchedAt: now
+ )
+ let analytics = options.includeAnalytics ? transcript?.analytics(now: now) : nil
+ return ProviderFetchResult(
+ outcome: .success(snapshot), warnings: warnings, analytics: analytics, recoveryIssue: recoveryIssue
+ ).withCredentialStatus(credentialStatus)
+ case .failure(let error):
+ return ProviderFetchResult(
+ outcome: ProviderOutcomeBuilder.outcome(for: error, hint: id.loginHint), warnings: warnings,
+ recoveryIssue: recoveryIssue
+ ).withCredentialStatus(credentialStatus)
+ }
+ }
+
+ private func usage(headers: [String: String]) async -> Result {
+ do {
+ return .success(
+ try await client.getJSON(
+ ClaudeAPI.UsageResponse.self, ClaudeAPI.usageURL, headers: headers, operation: "claude.usage"))
+ } catch {
+ return .failure(error)
+ }
+ }
+
+ private func profile(
+ headers: [String: String], fingerprint: String, now: Date
+ ) async -> Result {
+ if let cachedProfile, cachedProfile.fingerprint == fingerprint,
+ now.timeIntervalSince(cachedProfile.at) < Self.profileCacheInterval
+ {
+ return .success(cachedProfile.profile)
+ }
+ do {
+ let profile = try await client.getJSON(
+ ClaudeAPI.ProfileResponse.self, ClaudeAPI.profileURL, headers: headers, operation: "claude.profile")
+ cachedProfile = (fingerprint, profile, now)
+ return .success(profile)
+ } catch {
+ if let cachedProfile, cachedProfile.fingerprint == fingerprint { return .success(cachedProfile.profile) }
+ return .failure(error)
+ }
+ }
+
+ private func refresh(
+ _ stored: ClaudeOAuthCredentials,
+ source: CredentialSource,
+ now: Date
+ ) async throws(APIError) -> (
+ credential: ClaudeOAuthCredentials,
+ source: CredentialSource,
+ issue: ProviderRecoveryIssue?
+ ) {
+ guard let refreshToken = stored.refreshToken else {
+ throw APIError.http(status: 401, body: "no refresh token", retryAfter: nil)
+ }
+ let body = try! JSONEncoder().encode(
+ ["grant_type": "refresh_token", "refresh_token": refreshToken, "client_id": ClaudeAPI.clientID]
+ )
+ let data = try await client.post(
+ ClaudeAPI.tokenURL, json: body, headers: ["User-Agent": ClaudeAPI.userAgent], operation: "claude.refresh")
+ let token = try client.decode(ClaudeAPI.TokenResponse.self, data, operation: "claude.refresh")
+ let refreshed = stored.refreshed(
+ accessToken: token.accessToken, refreshToken: token.refreshToken, expiresIn: token.expiresIn ?? 3600, now: now)
+ let saveResult: CredentialSaveResult
+ do {
+ saveResult = try credentials.save(refreshed, replacing: stored)
+ } catch {
+ log.logError("claude token refreshed but could not be stored: \(error)")
+ let detail = credentialPersistenceDetail(error)
+ pendingCredentialSave = PendingCredentialSave(
+ credential: refreshed, replacing: stored, source: source, detail: detail)
+ return (refreshed, source, .credentialPersistence(provider: id, detail: detail))
+ }
+ switch saveResult {
+ case .saved:
+ log.log("claude token refreshed and stored")
+ case .changed(let current, let currentSource):
+ log.log("claude token refresh not stored because the credential source changed")
+ guard let current else {
+ throw APIError.http(status: 401, body: "credentials were removed during refresh", retryAfter: nil)
+ }
+ return (current, currentSource!, nil)
+ }
+ return (refreshed, source, nil)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift
new file mode 100644
index 0000000..8c8d616
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift
@@ -0,0 +1,977 @@
+import Foundation
+
+public struct ModelPrice: Sendable, Equatable {
+ public let input: Double
+ public let output: Double
+ public let cacheWrite: Double
+ public let cacheRead: Double
+
+ public init(input: Double, output: Double, cacheWrite: Double, cacheRead: Double) {
+ self.input = input
+ self.output = output
+ self.cacheWrite = cacheWrite
+ self.cacheRead = cacheRead
+ }
+}
+
+public enum ClaudePricing {
+ public static let perMillion: [String: ModelPrice] = [
+ "opus": ModelPrice(input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5),
+ "sonnet": ModelPrice(input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3),
+ "haiku": ModelPrice(input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1),
+ "fable": ModelPrice(input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5),
+ "mythos": ModelPrice(input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5),
+ ]
+
+ public static func price(for model: String) -> ModelPrice? {
+ let lowered = model.lowercased()
+ return perMillion.first { lowered.contains($0.key) }?.value
+ }
+
+ public static func cost(_ usage: TokenUsage, model: String) -> Double {
+ guard let price = price(for: model) else { return 0 }
+ return
+ (Double(usage.input) * price.input + Double(usage.output) * price.output + Double(usage.cacheWrite)
+ * price.cacheWrite
+ + Double(usage.cacheRead) * price.cacheRead) / 1_000_000
+ }
+}
+
+public struct TokenUsage: Sendable, Equatable, Hashable, Codable {
+ public var input: Int
+ public var output: Int
+ public var cacheWrite: Int
+ public var cacheRead: Int
+
+ public init(input: Int = 0, output: Int = 0, cacheWrite: Int = 0, cacheRead: Int = 0) {
+ self.input = input
+ self.output = output
+ self.cacheWrite = cacheWrite
+ self.cacheRead = cacheRead
+ }
+
+ public var total: Int {
+ input + output + cacheWrite + cacheRead
+ }
+
+ public static func += (lhs: inout TokenUsage, rhs: TokenUsage) {
+ lhs.input += rhs.input
+ lhs.output += rhs.output
+ lhs.cacheWrite += rhs.cacheWrite
+ lhs.cacheRead += rhs.cacheRead
+ }
+}
+
+public struct TranscriptMessage: Sendable, Equatable, Hashable {
+ public let id: String
+ public let timestamp: Date
+ public let session: String
+ public let model: String
+ public let usage: TokenUsage
+ public let toolCalls: Int
+
+ public init(id: String, timestamp: Date, session: String, model: String, usage: TokenUsage, toolCalls: Int) {
+ self.id = id
+ self.timestamp = timestamp
+ self.session = session
+ self.model = model
+ self.usage = usage
+ self.toolCalls = toolCalls
+ }
+
+ public var cost: Double {
+ ClaudePricing.cost(usage, model: model)
+ }
+}
+
+public struct LocalUsage: Sendable, Equatable, Hashable, Codable {
+ public let windowTokens: Int
+ public let windowCost: Double
+ public let costPerHour: Double
+ public let todayTokens: Int
+ public let todayCost: Double
+ public let todayMessages: Int
+
+ public init(
+ windowTokens: Int, windowCost: Double, costPerHour: Double, todayTokens: Int, todayCost: Double, todayMessages: Int
+ ) {
+ self.windowTokens = windowTokens
+ self.windowCost = windowCost
+ self.costPerHour = costPerHour
+ self.todayTokens = todayTokens
+ self.todayCost = todayCost
+ self.todayMessages = todayMessages
+ }
+}
+
+public struct ClaudeTranscriptSnapshot: Sendable {
+ fileprivate let days: [String: DayAggregate]
+ fileprivate let recent: [String: RecentAggregate]
+
+ fileprivate init(state: ClaudeTranscriptState) {
+ days = state.days
+ recent = state.recent
+ }
+
+ public var messageCount: Int {
+ days.values.reduce(0) { $0 + $1.messages }
+ }
+
+ public func analytics(now: Date) -> ProviderAnalytics? {
+ guard messageCount > 0 else { return nil }
+ var points: [AnalyticsPoint] = []
+ for (day, aggregate) in days {
+ for (model, usage) in aggregate.models {
+ points.append(AnalyticsPoint(day: day, metric: .inputTokens, series: model, value: Double(usage.input)))
+ points.append(AnalyticsPoint(day: day, metric: .outputTokens, series: model, value: Double(usage.output)))
+ points.append(
+ AnalyticsPoint(day: day, metric: .cachedInputTokens, series: model, value: Double(usage.cacheRead)))
+ points.append(
+ AnalyticsPoint(day: day, metric: .cacheWriteTokens, series: model, value: Double(usage.cacheWrite)))
+ points.append(
+ AnalyticsPoint(day: day, metric: .costUSD, series: model, value: ClaudePricing.cost(usage, model: model)))
+ }
+ points.append(AnalyticsPoint(day: day, metric: .messages, series: "messages", value: Double(aggregate.messages)))
+ points.append(
+ AnalyticsPoint(day: day, metric: .sessions, series: "sessions", value: Double(aggregate.sessions.count)))
+ points.append(
+ AnalyticsPoint(day: day, metric: .toolCalls, series: "tool calls", value: Double(aggregate.toolCalls)))
+ }
+ return ProviderAnalytics(
+ provider: .claude,
+ points: points.sorted { ($0.day, $0.metric.rawValue, $0.series) < ($1.day, $1.metric.rawValue, $1.series) },
+ fetchedAt: now)
+ }
+
+ public func localUsage(
+ windowResetsAt: Date?, windowDuration: TimeInterval, now: Date, calendar: Calendar = .current
+ ) -> LocalUsage? {
+ guard messageCount > 0 else { return nil }
+ let windowStart = (windowResetsAt ?? now).addingTimeInterval(-windowDuration)
+ let todayStart = calendar.startOfDay(for: now)
+ var windowTokens = 0
+ var windowCost = 0.0
+ var windowFirst: Date?
+ var todayTokens = 0
+ var todayCost = 0.0
+ var todayMessages = 0
+ for aggregate in recent.values {
+ let first = aggregate.firstTimestamp!
+ let last = aggregate.lastTimestamp!
+ if first >= windowStart, last <= now {
+ windowTokens += aggregate.tokens
+ windowCost += aggregate.cost
+ windowFirst = min(windowFirst ?? first, first)
+ } else if last >= windowStart, first <= now {
+ for event in aggregate.events!
+ where event.timestamp >= windowStart && event.timestamp <= now {
+ windowTokens += event.tokens
+ windowCost += event.cost
+ windowFirst = min(windowFirst ?? event.timestamp, event.timestamp)
+ }
+ }
+ if first >= todayStart, last <= now {
+ todayTokens += aggregate.tokens
+ todayCost += aggregate.cost
+ todayMessages += aggregate.messages
+ } else if last >= todayStart, first <= now {
+ for event in aggregate.events!
+ where event.timestamp >= todayStart && event.timestamp <= now {
+ todayTokens += event.tokens
+ todayCost += event.cost
+ todayMessages += event.messages
+ }
+ }
+ }
+ let elapsedHours = max(now.timeIntervalSince(windowFirst ?? now) / 3600, 1.0 / 60)
+ return LocalUsage(
+ windowTokens: windowTokens,
+ windowCost: windowCost,
+ costPerHour: windowFirst == nil ? 0 : windowCost / elapsedHours,
+ todayTokens: todayTokens,
+ todayCost: todayCost,
+ todayMessages: todayMessages)
+ }
+}
+
+fileprivate struct ClaudeTranscriptState: Codable, Sendable {
+ var offsets: [String: TranscriptOffset] = [:]
+ var seenByDay: [String: Set] = [:]
+ var days: [String: DayAggregate] = [:]
+ var recent: [String: RecentAggregate] = [:]
+
+ @discardableResult
+ mutating func ingest(_ message: TranscriptMessage) -> Bool {
+ let day = DayStamp.string(message.timestamp)
+ guard seenByDay[day, default: []].insert(message.id).inserted else { return false }
+ var aggregate = days[day] ?? DayAggregate()
+ aggregate.models[message.model, default: TokenUsage()] += message.usage
+ aggregate.messages += 1
+ aggregate.sessions.insert(message.session)
+ aggregate.toolCalls += message.toolCalls
+ days[day] = aggregate
+ let minute = floor(message.timestamp.timeIntervalSince1970 / 60) * 60
+ let key = String(Int64(minute))
+ recent[key, default: RecentAggregate(timestamp: Date(timeIntervalSince1970: minute))].append(
+ RecentEvent(timestamp: message.timestamp, tokens: message.usage.total, cost: message.cost, messages: 1))
+ return true
+ }
+
+ mutating func prune(now: Date, retentionDays: Int) -> Bool {
+ let retainedDay = DayStamp.string(now.addingTimeInterval(-Double(retentionDays) * 86400))
+ let recentCutoff = now.addingTimeInterval(-26 * 3600)
+ let oldDays = days.count
+ let oldRecent = recent.count
+ days = days.filter { $0.key >= retainedDay }
+ seenByDay = seenByDay.filter { $0.key >= retainedDay }
+ var compacted: [String: RecentAggregate] = [:]
+ for aggregate in recent.values where (aggregate.lastTimestamp ?? aggregate.timestamp) >= recentCutoff {
+ let minute = floor(aggregate.timestamp.timeIntervalSince1970 / 60) * 60
+ let key = String(Int64(minute))
+ compacted[key, default: RecentAggregate(timestamp: Date(timeIntervalSince1970: minute))].merge(aggregate)
+ }
+ let recentChanged = compacted != recent
+ recent = compacted
+ return days.count != oldDays || recent.count != oldRecent || recentChanged
+ }
+}
+
+fileprivate struct TranscriptOffset: Codable, Sendable {
+ var bytes: Int
+ var scanGeneration: UInt64
+
+ init(bytes: Int, scanGeneration: UInt64) {
+ self.bytes = bytes
+ self.scanGeneration = scanGeneration
+ }
+
+ init(from decoder: any Decoder) throws {
+ if let legacy = try? decoder.singleValueContainer().decode(Int.self) {
+ self.init(bytes: legacy, scanGeneration: 0)
+ return
+ }
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.init(
+ bytes: try container.decode(Int.self, forKey: .bytes),
+ scanGeneration: try container.decode(UInt64.self, forKey: .scanGeneration))
+ }
+}
+
+fileprivate struct DayAggregate: Codable, Sendable {
+ var models: [String: TokenUsage] = [:]
+ var messages = 0
+ var sessions: Set = []
+ var toolCalls = 0
+}
+
+fileprivate struct RecentAggregate: Codable, Sendable, Equatable {
+ let timestamp: Date
+ var tokens = 0
+ var cost = 0.0
+ var messages = 0
+ var firstTimestamp: Date?
+ var lastTimestamp: Date?
+ var events: [RecentEvent]?
+
+ mutating func append(_ event: RecentEvent) {
+ tokens += event.tokens
+ cost += event.cost
+ messages += event.messages
+ firstTimestamp = min(firstTimestamp ?? event.timestamp, event.timestamp)
+ lastTimestamp = max(lastTimestamp ?? event.timestamp, event.timestamp)
+ if events == nil { events = [] }
+ events?.append(event)
+ }
+
+ mutating func merge(_ aggregate: RecentAggregate) {
+ tokens += aggregate.tokens
+ cost += aggregate.cost
+ messages += aggregate.messages
+ let first = aggregate.firstTimestamp ?? aggregate.timestamp
+ let last = aggregate.lastTimestamp ?? aggregate.timestamp
+ firstTimestamp = min(firstTimestamp ?? first, first)
+ lastTimestamp = max(lastTimestamp ?? last, last)
+ if events == nil { events = [] }
+ events?.append(contentsOf: aggregate.events ?? [aggregate.legacyEvent])
+ }
+
+ var legacyEvent: RecentEvent {
+ RecentEvent(timestamp: timestamp, tokens: tokens, cost: cost, messages: messages)
+ }
+}
+
+fileprivate struct RecentEvent: Codable, Sendable, Equatable {
+ let timestamp: Date
+ let tokens: Int
+ let cost: Double
+ let messages: Int
+}
+
+private struct TranscriptFile: Sendable, Equatable {
+ let url: URL
+ let size: Int
+ let modified: Date
+}
+
+private struct TranscriptIngestResult {
+ let changed: Bool
+ let bytesRead: Int
+ let succeeded: Bool
+ let complete: Bool
+
+ static let unchanged = TranscriptIngestResult(changed: false, bytesRead: 0, succeeded: true, complete: true)
+ static let failed = TranscriptIngestResult(changed: false, bytesRead: 0, succeeded: false, complete: true)
+}
+
+private struct PartialTranscriptRead {
+ var committedOffset: Int
+ var cursor: Int
+ var pending = Data()
+ var oversized = false
+}
+
+private struct TranscriptScan {
+ let enumerator: FileManager.DirectoryEnumerator
+ let cutoff: Date
+ let referenceNow: Date
+ let generation: UInt64
+ var hotFiles: [TranscriptFile] = []
+ var currentFile: TranscriptFile?
+}
+
+public struct ClaudeTranscriptWorkload: Sendable, Equatable {
+ public fileprivate(set) var scansStarted = 0
+ public fileprivate(set) var scansCompleted = 0
+ public fileprivate(set) var treeEntriesExamined = 0
+ public fileprivate(set) var statChecks = 0
+ public fileprivate(set) var filesOpened = 0
+ public fileprivate(set) var bytesRead = 0
+ public fileprivate(set) var largestSliceBytesRead = 0
+ public fileprivate(set) var largestSliceEntriesExamined = 0
+ public fileprivate(set) var checkpointAttempts = 0
+ public fileprivate(set) var checkpoints = 0
+ public fileprivate(set) var checkpointBytesWritten = 0
+ public fileprivate(set) var lastCheckpointBytes = 0
+ public fileprivate(set) var retainedPartialFiles = 0
+ public fileprivate(set) var retainedPartialBytes = 0
+ public fileprivate(set) var largestRetainedPartialBytes = 0
+
+ public init() {}
+}
+
+public actor ClaudeTranscriptReader {
+ public static let defaultFileScanInterval: TimeInterval = 5 * 60
+ public static let defaultCheckpointInterval: TimeInterval = 5 * 60
+ public static let defaultMaximumCheckpointBytes = 1024 * 1024
+ public static let defaultWorkByteBudget = 8 * 1024 * 1024
+ public static let defaultWorkEntryBudget = 256
+ public static let defaultWorkTimeBudget: TimeInterval = 0.05
+ public static let defaultBackgroundWorkDelay: TimeInterval = 0.01
+ public static let defaultMaximumLineBytes = 16 * 1024 * 1024
+ public static let defaultMaximumRetainedPartialBytes = 16 * 1024 * 1024
+ static let maxIndexedFiles = 64
+ static let readChunkSize = 256 * 1024
+
+ private let root: URL
+ private let stateURL: URL?
+ private let fileScanInterval: TimeInterval
+ private let checkpointInterval: TimeInterval
+ private let maximumCheckpointBytes: Int
+ private let workByteBudget: Int
+ private let workEntryBudget: Int
+ private let workTimeBudget: TimeInterval
+ private let backgroundWorkDelay: TimeInterval
+ private let maximumLineBytes: Int
+ private let maximumRetainedPartialBytes: Int
+ private let readChunk: @Sendable (FileHandle, Int) throws -> Data?
+ private var state = ClaudeTranscriptState()
+ private var indexedFiles: [String: TranscriptFile] = [:]
+ private var partialReads: [String: PartialTranscriptRead] = [:]
+ private var pendingTails: [TranscriptFile] = []
+ private var pendingTailIndex = 0
+ private var scan: TranscriptScan?
+ private var backgroundTask: Task?
+ private var stateLoadTask: Task?
+ private var nextFileScanAt = Date.distantPast
+ private var nextCheckpointAt = Date.distantFuture
+ private var nextPruneAt = Date.distantPast
+ private var workReferenceNow = Date.distantPast
+ private var workCutoff = Date.distantPast
+ private var uncheckpointedBytes = 0
+ private var stateRevision = 0
+ private var checkpointInFlightRevision: Int?
+ private var checkpointRetryAt: Date?
+ private var stateDirty = false
+ private var stateLoaded = false
+ private var hasScanned = false
+ private var scanGeneration: UInt64 = 0
+ private var activeRetentionDays = UsageHistoryStore.defaultRetentionDays
+ public private(set) var workload = ClaudeTranscriptWorkload()
+
+ /// - Parameter stateURL: where to keep how far each session file has been read. Without it the first refresh
+ /// after every launch re-reads every session Claude Code wrote in the retention window, which for a heavy user
+ /// is hundreds of megabytes.
+ public init(
+ root: URL,
+ stateURL: URL? = nil,
+ fileScanInterval: TimeInterval = defaultFileScanInterval,
+ checkpointInterval: TimeInterval = defaultCheckpointInterval,
+ maximumCheckpointBytes: Int = defaultMaximumCheckpointBytes,
+ workByteBudget: Int = defaultWorkByteBudget,
+ workEntryBudget: Int = defaultWorkEntryBudget,
+ workTimeBudget: TimeInterval = defaultWorkTimeBudget,
+ backgroundWorkDelay: TimeInterval = defaultBackgroundWorkDelay,
+ maximumLineBytes: Int = defaultMaximumLineBytes,
+ maximumRetainedPartialBytes: Int = defaultMaximumRetainedPartialBytes,
+ readChunk: @escaping @Sendable (FileHandle, Int) throws -> Data? = { handle, count in
+ try handle.read(upToCount: count)
+ }
+ ) {
+ self.root = root
+ self.stateURL = stateURL
+ self.fileScanInterval = fileScanInterval
+ self.checkpointInterval = checkpointInterval
+ self.maximumCheckpointBytes = max(maximumCheckpointBytes, 1)
+ self.workByteBudget = max(workByteBudget, 1)
+ self.workEntryBudget = max(workEntryBudget, 1)
+ self.workTimeBudget = max(workTimeBudget, 0.001)
+ self.backgroundWorkDelay = max(backgroundWorkDelay, 0.001)
+ self.maximumLineBytes = max(maximumLineBytes, 1)
+ self.maximumRetainedPartialBytes = max(maximumRetainedPartialBytes, self.maximumLineBytes)
+ self.readChunk = readChunk
+ }
+
+ deinit { backgroundTask?.cancel() }
+
+ public func cancelBackgroundWork() {
+ backgroundTask?.cancel()
+ backgroundTask = nil
+ scan = nil
+ hasScanned = false
+ pendingTails.removeAll(keepingCapacity: true)
+ pendingTailIndex = 0
+ partialReads.removeAll(keepingCapacity: true)
+ recordPartialWorkload()
+ }
+
+ private static let usageMarker = Array("\"usage\"".utf8)
+
+ public func refresh(
+ now: Date, retentionDays: Int = UsageHistoryStore.defaultRetentionDays
+ ) async -> ClaudeTranscriptSnapshot {
+ await loadState()
+ let retentionDays = min(max(retentionDays, 7), 365)
+ if retentionDays != activeRetentionDays {
+ if retentionDays > activeRetentionDays {
+ state.offsets = state.offsets.mapValues {
+ TranscriptOffset(bytes: 0, scanGeneration: $0.scanGeneration)
+ }
+ markDirty()
+ }
+ activeRetentionDays = retentionDays
+ scan = nil
+ hasScanned = false
+ pendingTails.removeAll(keepingCapacity: true)
+ pendingTailIndex = 0
+ nextFileScanAt = .distantPast
+ nextPruneAt = .distantPast
+ }
+ let cutoff = now.addingTimeInterval(-Double(retentionDays) * 86400)
+ workReferenceNow = now
+ workCutoff = cutoff
+ if now >= nextPruneAt {
+ if state.prune(now: now, retentionDays: retentionDays) { markDirty() }
+ nextPruneAt = nextPruneDate(after: now)
+ }
+ prepareIndexedTails()
+ if scan == nil, !hasScanned || now >= nextFileScanAt { beginScan(cutoff: cutoff, now: now) }
+ runWorkSlice()
+ await checkpointIfNeeded(now: now)
+ scheduleBackgroundWork()
+ return ClaudeTranscriptSnapshot(state: state)
+ }
+
+ private func loadState() async {
+ guard !stateLoaded else { return }
+ if stateLoadTask == nil {
+ let stateURL = stateURL
+ stateLoadTask = Task.detached(priority: .utility) {
+ guard let stateURL, let data = try? Data(contentsOf: stateURL) else { return nil }
+ return try? JSONDecoder().decode(ClaudeTranscriptState.self, from: data)
+ }
+ }
+ let stored = await stateLoadTask?.value
+ guard !stateLoaded else { return }
+ stateLoaded = true
+ stateLoadTask = nil
+ nextCheckpointAt = .distantPast
+ if let stored {
+ state = stored
+ scanGeneration = stored.offsets.values.map(\.scanGeneration).max() ?? 0
+ }
+ }
+
+ private func transcriptFile(_ url: URL) -> TranscriptFile? {
+ workload.statChecks += 1
+ guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
+ attributes[.type] as? FileAttributeType == .typeRegular
+ else { return nil }
+ return TranscriptFile(
+ url: url,
+ size: (attributes[.size] as! NSNumber).intValue,
+ modified: attributes[.modificationDate] as! Date)
+ }
+
+ private static func isNewer(_ lhs: TranscriptFile, _ rhs: TranscriptFile) -> Bool {
+ lhs.modified == rhs.modified ? lhs.url.path < rhs.url.path : lhs.modified > rhs.modified
+ }
+
+ private func beginScan(cutoff: Date, now: Date) {
+ partialReads.removeAll(keepingCapacity: true)
+ recordPartialWorkload()
+ let enumerator = FileManager.default.enumerator(
+ at: root,
+ includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey],
+ options: [.skipsHiddenFiles])!
+ scanGeneration &+= 1
+ scan = TranscriptScan(
+ enumerator: enumerator, cutoff: cutoff, referenceNow: now, generation: scanGeneration)
+ workload.scansStarted += 1
+ }
+
+ private func prepareIndexedTails() {
+ guard pendingTailIndex >= pendingTails.count else { return }
+ pendingTails.removeAll(keepingCapacity: true)
+ pendingTailIndex = 0
+ for key in indexedFiles.keys.sorted() {
+ guard let indexed = indexedFiles[key], let current = transcriptFile(indexed.url) else { continue }
+ let partial = partialReads[key]
+ guard current != indexed || partial.map({ $0.cursor < current.size }) == true else { continue }
+ if current.size < (partial?.cursor ?? state.offsets[key]?.bytes ?? 0)
+ || current.size == indexed.size && current.modified != indexed.modified
+ {
+ state.offsets[key] = TranscriptOffset(bytes: 0, scanGeneration: state.offsets[key]!.scanGeneration)
+ partialReads.removeValue(forKey: key)
+ markDirty()
+ }
+ indexedFiles[key] = current
+ pendingTails.append(current)
+ }
+ }
+
+ private var hasPendingWork: Bool {
+ pendingTailIndex < pendingTails.count || scan != nil
+ }
+
+ private func runWorkSlice() {
+ let initialBytes = workload.bytesRead
+ let initialEntries = workload.treeEntriesExamined
+ defer {
+ workload.largestSliceBytesRead = max(workload.largestSliceBytesRead, workload.bytesRead - initialBytes)
+ workload.largestSliceEntriesExamined = max(
+ workload.largestSliceEntriesExamined, workload.treeEntriesExamined - initialEntries)
+ }
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: .seconds(workTimeBudget))
+ var bytesRemaining = workByteBudget
+ while pendingTailIndex < pendingTails.count, bytesRemaining > 0, clock.now < deadline {
+ let file = pendingTails[pendingTailIndex]
+ let result = ingest(
+ file, cutoff: workCutoff, byteLimit: bytesRemaining, deadline: deadline, retainsIncompleteTail: true)
+ apply(result)
+ bytesRemaining -= result.bytesRead
+ guard result.complete else { break }
+ pendingTailIndex += 1
+ }
+ if pendingTailIndex >= pendingTails.count {
+ pendingTails.removeAll(keepingCapacity: true)
+ pendingTailIndex = 0
+ }
+ guard bytesRemaining > 0, clock.now < deadline, var activeScan = scan else { return }
+ var entriesRemaining = workEntryBudget
+ while bytesRemaining > 0, entriesRemaining > 0, clock.now < deadline {
+ if let file = activeScan.currentFile {
+ let retainsIncompleteTail = activeScan.hotFiles.contains { $0.url.path == file.url.path }
+ let result = ingest(
+ file, cutoff: activeScan.cutoff, byteLimit: bytesRemaining, deadline: deadline,
+ retainsIncompleteTail: retainsIncompleteTail)
+ apply(result)
+ bytesRemaining -= result.bytesRead
+ guard result.complete else { break }
+ activeScan.currentFile = nil
+ continue
+ }
+ guard let url = activeScan.enumerator.nextObject() as? URL else {
+ finishScan(activeScan)
+ return
+ }
+ workload.treeEntriesExamined += 1
+ entriesRemaining -= 1
+ guard url.pathExtension == "jsonl", let file = transcriptFile(url), file.modified >= activeScan.cutoff else {
+ continue
+ }
+ if state.offsets[file.url.path] != nil {
+ state.offsets[file.url.path]?.scanGeneration = activeScan.generation
+ }
+ let retainsIncompleteTail = insertHotFile(file, into: &activeScan.hotFiles)
+ if state.offsets[file.url.path]?.bytes != file.size || partialReads[file.url.path] != nil {
+ activeScan.currentFile = file
+ let result = ingest(
+ file, cutoff: activeScan.cutoff, byteLimit: bytesRemaining, deadline: deadline,
+ retainsIncompleteTail: retainsIncompleteTail)
+ apply(result)
+ bytesRemaining -= result.bytesRead
+ guard result.complete else { break }
+ activeScan.currentFile = nil
+ }
+ }
+ scan = activeScan
+ }
+
+ private func finishScan(_ completed: TranscriptScan) {
+ let previousOffsetCount = state.offsets.count
+ state.offsets = state.offsets.filter { $0.value.scanGeneration == completed.generation }
+ if state.offsets.count != previousOffsetCount {
+ markDirty()
+ }
+ indexedFiles = Dictionary(uniqueKeysWithValues: completed.hotFiles.map { ($0.url.path, $0) })
+ scan = nil
+ hasScanned = true
+ nextFileScanAt = completed.referenceNow.addingTimeInterval(fileScanInterval)
+ workload.scansCompleted += 1
+ recordPartialWorkload()
+ }
+
+ @discardableResult
+ private func insertHotFile(_ file: TranscriptFile, into files: inout [TranscriptFile]) -> Bool {
+ if files.count < Self.maxIndexedFiles {
+ files.append(file)
+ files.sort(by: Self.isNewer)
+ return true
+ } else if let oldest = files.last, Self.isNewer(file, oldest) {
+ partialReads.removeValue(forKey: oldest.url.path)
+ files[files.count - 1] = file
+ files.sort(by: Self.isNewer)
+ return true
+ }
+ partialReads.removeValue(forKey: file.url.path)
+ return false
+ }
+
+ private func ingest(
+ _ file: TranscriptFile, cutoff: Date, byteLimit: Int, deadline: ContinuousClock.Instant,
+ retainsIncompleteTail: Bool
+ ) -> TranscriptIngestResult {
+ let key = file.url.path
+ let storedOffset = state.offsets[key]?.bytes ?? 0
+ let generation = scan?.generation ?? state.offsets[key]?.scanGeneration ?? scanGeneration
+ var partial = partialReads[key] ?? PartialTranscriptRead(committedOffset: storedOffset, cursor: storedOffset)
+ if file.size < partial.cursor || file.size < partial.committedOffset {
+ partial = PartialTranscriptRead(committedOffset: 0, cursor: 0)
+ state.offsets[key] = TranscriptOffset(bytes: 0, scanGeneration: generation)
+ }
+ guard partial.cursor < file.size else {
+ return TranscriptIngestResult(
+ changed: partial.committedOffset != storedOffset, bytesRead: 0, succeeded: true, complete: true)
+ }
+ guard let handle = try? FileHandle(forReadingFrom: file.url) else { return .failed }
+ workload.filesOpened += 1
+ defer { try? handle.close() }
+ try! handle.seek(toOffset: UInt64(partial.cursor))
+ let clock = ContinuousClock()
+ var bytesRead = 0
+ var aggregateChanged = false
+ var reachedStaleEOF = false
+ while partial.cursor < file.size, bytesRead < byteLimit, clock.now < deadline {
+ let count = min(Self.readChunkSize, byteLimit - bytesRead, file.size - partial.cursor)
+ let result = Result { try readChunk(handle, count) }
+ guard case .success(let value) = result else { return .failed }
+ guard let chunk = value, !chunk.isEmpty else {
+ reachedStaleEOF = true
+ break
+ }
+ let chunkStart = partial.cursor
+ partial.cursor += chunk.count
+ bytesRead += chunk.count
+ aggregateChanged = consume(chunk, startingAt: chunkStart, cutoff: cutoff, partial: &partial) || aggregateChanged
+ }
+ state.offsets[key] = TranscriptOffset(bytes: partial.committedOffset, scanGeneration: generation)
+ if reachedStaleEOF || (partial.pending.isEmpty && !partial.oversized && partial.cursor >= file.size)
+ || (partial.cursor >= file.size && !retainsIncompleteTail)
+ {
+ partialReads.removeValue(forKey: key)
+ } else {
+ retainPartial(partial, for: key)
+ }
+ recordPartialWorkload()
+ workload.bytesRead += bytesRead
+ return TranscriptIngestResult(
+ changed: aggregateChanged || partial.committedOffset != storedOffset,
+ bytesRead: bytesRead,
+ succeeded: !reachedStaleEOF,
+ complete: reachedStaleEOF || partial.cursor >= file.size)
+ }
+
+ private func consume(
+ _ chunk: Data, startingAt chunkStart: Int, cutoff: Date, partial: inout PartialTranscriptRead
+ ) -> Bool {
+ var changed = false
+ var start = chunk.startIndex
+ while start < chunk.endIndex {
+ if partial.oversized {
+ guard let newline = chunk[start...].firstIndex(of: UInt8(ascii: "\n")) else { return changed }
+ partial.oversized = false
+ partial.committedOffset = chunkStart + chunk.distance(from: chunk.startIndex, to: newline) + 1
+ start = chunk.index(after: newline)
+ continue
+ }
+ guard let newline = chunk[start...].firstIndex(of: UInt8(ascii: "\n")) else {
+ let suffix = chunk[start...]
+ if partial.pending.count + suffix.count <= maximumLineBytes {
+ partial.pending.append(contentsOf: suffix)
+ } else {
+ partial.pending.removeAll(keepingCapacity: false)
+ partial.oversized = true
+ }
+ return changed
+ }
+ let segment = chunk[start..= cutoff
+ {
+ changed = state.ingest(message) || changed
+ }
+ }
+ partial.pending.removeAll(keepingCapacity: false)
+ partial.committedOffset = chunkStart + chunk.distance(from: chunk.startIndex, to: newline) + 1
+ start = chunk.index(after: newline)
+ }
+ return changed
+ }
+
+ private func apply(_ result: TranscriptIngestResult) {
+ guard result.changed else { return }
+ markDirty(bytes: result.bytesRead)
+ }
+
+ private func markDirty(bytes: Int = 0) {
+ stateDirty = true
+ stateRevision += 1
+ uncheckpointedBytes += bytes
+ }
+
+ private func recordPartialWorkload() {
+ workload.retainedPartialFiles = partialReads.count
+ workload.retainedPartialBytes = partialReads.values.reduce(0) { $0 + $1.pending.count }
+ workload.largestRetainedPartialBytes = max(
+ workload.largestRetainedPartialBytes, workload.retainedPartialBytes)
+ }
+
+ private func retainPartial(_ partial: PartialTranscriptRead, for key: String) {
+ partialReads[key] = partial
+ var retainedBytes = partialReads.values.reduce(0) { $0 + $1.pending.count }
+ while retainedBytes > maximumRetainedPartialBytes {
+ let victim = partialReads.keys.filter({ $0 != key }).max(by: {
+ let lhs = partialReads[$0]!.pending.count
+ let rhs = partialReads[$1]!.pending.count
+ return lhs == rhs ? $0 > $1 : lhs < rhs
+ })!
+ retainedBytes -= partialReads.removeValue(forKey: victim)!.pending.count
+ }
+ recordPartialWorkload()
+ }
+
+ private func checkpointIfNeeded(now: Date) async {
+ guard stateDirty else { return }
+ if let checkpointRetryAt {
+ guard now >= checkpointRetryAt else { return }
+ } else {
+ guard now >= nextCheckpointAt || uncheckpointedBytes >= maximumCheckpointBytes else { return }
+ }
+ guard checkpointInFlightRevision == nil else { return }
+ guard let stateURL else {
+ stateDirty = false
+ uncheckpointedBytes = 0
+ return
+ }
+ let revision = stateRevision
+ let byteCount = uncheckpointedBytes
+ let snapshot = state
+ checkpointInFlightRevision = revision
+ workload.checkpointAttempts += 1
+ let writtenBytes = await Task.detached(priority: .utility) { () -> Int? in
+ let data = try! JSONEncoder().encode(snapshot)
+ guard
+ (try? FileManager.default.createDirectory(
+ at: stateURL.deletingLastPathComponent(), withIntermediateDirectories: true)) != nil,
+ (try? data.write(to: stateURL, options: .atomic)) != nil
+ else { return nil }
+ return data.count
+ }.value
+ checkpointInFlightRevision = nil
+ guard let writtenBytes else {
+ checkpointRetryAt = now.addingTimeInterval(max(min(checkpointInterval, 60), 1))
+ return
+ }
+ checkpointRetryAt = nil
+ workload.checkpoints += 1
+ workload.checkpointBytesWritten += writtenBytes
+ workload.lastCheckpointBytes = writtenBytes
+ uncheckpointedBytes = max(uncheckpointedBytes - byteCount, 0)
+ stateDirty = stateRevision != revision
+ nextCheckpointAt = now.addingTimeInterval(checkpointInterval)
+ }
+
+ private func scheduleBackgroundWork() {
+ guard hasPendingWork, backgroundTask == nil else { return }
+ let delay = backgroundWorkDelay
+ backgroundTask = Task(priority: .utility) { [weak self] in
+ do {
+ try await ContinuousClock().sleep(for: .seconds(delay))
+ try Task.checkCancellation()
+ try await self?.runBackgroundSlice()
+ } catch {
+ return
+ }
+ }
+ }
+
+ private func runBackgroundSlice() async throws {
+ guard hasPendingWork else {
+ backgroundTask = nil
+ return
+ }
+ runWorkSlice()
+ await checkpointIfNeeded(now: workReferenceNow)
+ try Task.checkCancellation()
+ backgroundTask = nil
+ scheduleBackgroundWork()
+ }
+
+ private func nextPruneDate(after now: Date) -> Date {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: 0)!
+ return calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))!
+ }
+
+ /// The handful of fields a transcript line contributes. Decoding into this rather than a `JSONValue` tree skips
+ /// the assistant's own text, which is nearly all of every line and is thrown away immediately.
+ private struct Line: Decodable {
+ struct Message: Decodable {
+ struct Usage: Decodable {
+ let inputTokens: Int?
+ let outputTokens: Int?
+ let cacheCreationInputTokens: Int?
+ let cacheReadInputTokens: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case inputTokens = "input_tokens"
+ case outputTokens = "output_tokens"
+ case cacheCreationInputTokens = "cache_creation_input_tokens"
+ case cacheReadInputTokens = "cache_read_input_tokens"
+ }
+ }
+
+ struct Block: Decodable {
+ let type: String?
+ }
+
+ let id: String?
+ let model: String?
+ let usage: Usage?
+ let content: [Block]?
+ }
+
+ let type: String?
+ let uuid: String?
+ let requestId: String?
+ let sessionId: String?
+ let timestamp: String?
+ let message: Message?
+ }
+
+ private static let decoder = JSONDecoder()
+
+ static func parse(line: Data) -> TranscriptMessage? {
+ guard let json = try? decoder.decode(Line.self, from: line), json.type == "assistant",
+ let message = json.message, let usage = message.usage, let model = message.model,
+ let timestamp = ISODate.parse(json.timestamp)
+ else { return nil }
+ return TranscriptMessage(
+ id: "\(message.id ?? json.uuid ?? ""):\(json.requestId ?? "")",
+ timestamp: timestamp,
+ session: json.sessionId ?? "",
+ model: model,
+ usage: TokenUsage(
+ input: usage.inputTokens ?? 0,
+ output: usage.outputTokens ?? 0,
+ cacheWrite: usage.cacheCreationInputTokens ?? 0,
+ cacheRead: usage.cacheReadInputTokens ?? 0
+ ),
+ toolCalls: message.content?.count { $0.type == "tool_use" } ?? 0
+ )
+ }
+
+ public static func analytics(_ messages: [TranscriptMessage], now: Date) -> ProviderAnalytics? {
+ guard !messages.isEmpty else { return nil }
+ var byDayModel: [String: [String: TokenUsage]] = [:]
+ var daily: [String: (messages: Int, sessions: Set, toolCalls: Int, cost: Double)] = [:]
+ for message in messages {
+ let day = DayStamp.string(message.timestamp)
+ byDayModel[day, default: [:]][message.model, default: TokenUsage()] += message.usage
+ var entry = daily[day] ?? (0, [], 0, 0)
+ entry.messages += 1
+ entry.sessions.insert(message.session)
+ entry.toolCalls += message.toolCalls
+ entry.cost += message.cost
+ daily[day] = entry
+ }
+ var points: [AnalyticsPoint] = []
+ for (day, models) in byDayModel {
+ for (model, usage) in models {
+ points.append(AnalyticsPoint(day: day, metric: .inputTokens, series: model, value: Double(usage.input)))
+ points.append(AnalyticsPoint(day: day, metric: .outputTokens, series: model, value: Double(usage.output)))
+ points.append(
+ AnalyticsPoint(day: day, metric: .cachedInputTokens, series: model, value: Double(usage.cacheRead)))
+ points.append(
+ AnalyticsPoint(day: day, metric: .cacheWriteTokens, series: model, value: Double(usage.cacheWrite)))
+ points.append(
+ AnalyticsPoint(day: day, metric: .costUSD, series: model, value: ClaudePricing.cost(usage, model: model)))
+ }
+ }
+ for (day, entry) in daily {
+ points.append(AnalyticsPoint(day: day, metric: .messages, series: "messages", value: Double(entry.messages)))
+ points.append(
+ AnalyticsPoint(day: day, metric: .sessions, series: "sessions", value: Double(entry.sessions.count)))
+ points.append(AnalyticsPoint(day: day, metric: .toolCalls, series: "tool calls", value: Double(entry.toolCalls)))
+ }
+ return ProviderAnalytics(
+ provider: .claude,
+ points: points.sorted { ($0.day, $0.metric.rawValue, $0.series) < ($1.day, $1.metric.rawValue, $1.series) },
+ fetchedAt: now)
+ }
+
+ public static func localUsage(
+ _ messages: [TranscriptMessage], windowResetsAt: Date?, windowDuration: TimeInterval, now: Date,
+ calendar: Calendar = .current
+ ) -> LocalUsage? {
+ guard !messages.isEmpty else { return nil }
+ let windowStart = (windowResetsAt ?? now).addingTimeInterval(-windowDuration)
+ let inWindow = messages.filter { $0.timestamp >= windowStart && $0.timestamp <= now }
+ let windowTokens = inWindow.reduce(0) { $0 + $1.usage.total }
+ let windowCost = inWindow.reduce(0) { $0 + $1.cost }
+ let elapsedHours = max(now.timeIntervalSince(inWindow.map(\.timestamp).min() ?? now) / 3600, 1.0 / 60)
+ let todayStart = calendar.startOfDay(for: now)
+ let today = messages.filter { $0.timestamp >= todayStart }
+ return LocalUsage(
+ windowTokens: windowTokens,
+ windowCost: windowCost,
+ costPerHour: inWindow.isEmpty ? 0 : windowCost / elapsedHours,
+ todayTokens: today.reduce(0) { $0 + $1.usage.total },
+ todayCost: today.reduce(0) { $0 + $1.cost },
+ todayMessages: today.count
+ )
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift
new file mode 100644
index 0000000..298418d
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift
@@ -0,0 +1,438 @@
+import Foundation
+
+enum CodexAPI {
+ static let base = URL(string: "https://chatgpt.com/backend-api")!
+ static let tokenURL = URL(string: "https://auth.openai.com/oauth/token")!
+ static let clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
+ static let originator = "codex_cli_rs"
+ static let terminalRefreshErrors: Set = [
+ "refresh_token_expired", "refresh_token_reused", "refresh_token_invalidated", "invalid_grant",
+ ]
+
+ static var usageURL: URL { base.appendingPathComponent("wham/usage") }
+ static var resetCreditsURL: URL { base.appendingPathComponent("wham/rate-limit-reset-credits") }
+ static var creditEventsURL: URL { base.appendingPathComponent("wham/usage/credit-usage-events") }
+
+ enum Analytics: String, CaseIterable, Sendable {
+ case tokenUsage = "wham/usage/daily-token-usage-breakdown"
+ case workspaceCounts = "wham/analytics/daily-workspace-usage-counts"
+ case skills = "wham/analytics/daily-skill-usage-metrics"
+ case plugins = "wham/analytics/daily-plugin-usage-metrics"
+ case codeReview = "wham/analytics/daily-code-review-metrics"
+
+ var metrics: Set {
+ switch self {
+ case .tokenUsage: [.surfaceUsagePercent, .modelCredits]
+ case .workspaceCounts: [.inputTokens, .cachedInputTokens, .outputTokens, .turns, .threads, .credits]
+ case .skills: [.skillInvocations]
+ case .plugins: [.pluginInvocations]
+ case .codeReview: [.codeReviews]
+ }
+ }
+
+ func url(start: String, end: String) -> URL {
+ var components = URLComponents(
+ url: CodexAPI.base.appendingPathComponent(rawValue), resolvingAgainstBaseURL: false)!
+ var items = [
+ URLQueryItem(name: "start_date", value: start), URLQueryItem(name: "end_date", value: end),
+ URLQueryItem(name: "group_by", value: "day"),
+ ]
+ switch self {
+ case .tokenUsage: break
+ case .skills:
+ items += [
+ URLQueryItem(name: "workspace_user", value: "true"), URLQueryItem(name: "top_skill_limit", value: "20"),
+ ]
+ case .plugins:
+ items += [
+ URLQueryItem(name: "workspace_user", value: "true"), URLQueryItem(name: "top_plugin_limit", value: "20"),
+ ]
+ case .workspaceCounts, .codeReview: items.append(URLQueryItem(name: "workspace_user", value: "true"))
+ }
+ components.queryItems = items
+ return components.url!
+ }
+ }
+
+ static func headers(token: String, accountID: String?) -> [String: String] {
+ var headers = [
+ "Authorization": "Bearer \(token)", "originator": originator, "User-Agent": "\(originator)/token-menu-bar",
+ ]
+ if let accountID { headers["ChatGPT-Account-Id"] = accountID }
+ return headers
+ }
+
+ struct Window: Decodable, Sendable, Equatable {
+ let usedPercent: Double
+ let limitWindowSeconds: Double?
+ let resetAfterSeconds: Double?
+ let resetAt: Double?
+
+ enum CodingKeys: String, CodingKey {
+ case usedPercent = "used_percent"
+ case limitWindowSeconds = "limit_window_seconds"
+ case resetAfterSeconds = "reset_after_seconds"
+ case resetAt = "reset_at"
+ }
+
+ init(usedPercent: Double, limitWindowSeconds: Double?, resetAfterSeconds: Double?, resetAt: Double?) {
+ self.usedPercent = usedPercent
+ self.limitWindowSeconds = limitWindowSeconds
+ self.resetAfterSeconds = resetAfterSeconds
+ self.resetAt = resetAt
+ }
+ }
+
+ struct RateLimit: Decodable, Sendable, Equatable {
+ let allowed: Bool?
+ let limitReached: Bool?
+ let primaryWindow: Window?
+ let secondaryWindow: Window?
+
+ enum CodingKeys: String, CodingKey {
+ case allowed
+ case limitReached = "limit_reached"
+ case primaryWindow = "primary_window"
+ case secondaryWindow = "secondary_window"
+ }
+
+ init(allowed: Bool?, limitReached: Bool?, primaryWindow: Window?, secondaryWindow: Window?) {
+ self.allowed = allowed
+ self.limitReached = limitReached
+ self.primaryWindow = primaryWindow
+ self.secondaryWindow = secondaryWindow
+ }
+ }
+
+ struct AdditionalRateLimit: Decodable, Sendable, Equatable {
+ let limitName: String
+ let meteredFeature: String?
+ let rateLimit: RateLimit
+
+ enum CodingKeys: String, CodingKey {
+ case limitName = "limit_name"
+ case meteredFeature = "metered_feature"
+ case rateLimit = "rate_limit"
+ }
+ }
+
+ struct Credits: Decodable, Sendable, Equatable {
+ let hasCredits: Bool?
+ let unlimited: Bool?
+ let overageLimitReached: Bool?
+ let balance: String?
+ let approxLocalMessages: [Int]?
+ let approxCloudMessages: [Int]?
+
+ enum CodingKeys: String, CodingKey {
+ case unlimited, balance
+ case hasCredits = "has_credits"
+ case overageLimitReached = "overage_limit_reached"
+ case approxLocalMessages = "approx_local_messages"
+ case approxCloudMessages = "approx_cloud_messages"
+ }
+ }
+
+ struct IndividualLimit: Decodable, Sendable, Equatable {
+ let limit: String?
+ let used: String?
+ let remaining: String?
+ let usedPercent: Double?
+ let resetAt: Double?
+
+ enum CodingKeys: String, CodingKey {
+ case limit, used, remaining
+ case usedPercent = "used_percent"
+ case resetAt = "reset_at"
+ }
+ }
+
+ struct SpendControl: Decodable, Sendable, Equatable {
+ let reached: Bool?
+ let individualLimit: IndividualLimit?
+
+ enum CodingKeys: String, CodingKey {
+ case reached
+ case individualLimit = "individual_limit"
+ }
+ }
+
+ struct ResetCreditsSummary: Decodable, Sendable, Equatable {
+ let availableCount: Int?
+ let applicableAvailableCount: Int?
+ let totalEarnedCount: Int?
+ let immediateResetPurchaseEligible: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case availableCount = "available_count"
+ case applicableAvailableCount = "applicable_available_count"
+ case totalEarnedCount = "total_earned_count"
+ case immediateResetPurchaseEligible = "immediate_reset_purchase_eligible"
+ }
+ }
+
+ struct UsageResponse: Decodable, Sendable, Equatable {
+ let email: String?
+ let planType: String?
+ let rateLimit: RateLimit?
+ let codeReviewRateLimit: RateLimit?
+ let additionalRateLimits: [AdditionalRateLimit]?
+ let credits: Credits?
+ let spendControl: SpendControl?
+ let rateLimitReachedType: JSONValue?
+ let promo: JSONValue?
+ let rateLimitResetCredits: ResetCreditsSummary?
+
+ enum CodingKeys: String, CodingKey {
+ case email, credits, promo
+ case planType = "plan_type"
+ case rateLimit = "rate_limit"
+ case codeReviewRateLimit = "code_review_rate_limit"
+ case additionalRateLimits = "additional_rate_limits"
+ case spendControl = "spend_control"
+ case rateLimitReachedType = "rate_limit_reached_type"
+ case rateLimitResetCredits = "rate_limit_reset_credits"
+ }
+ }
+
+ struct TokenResponse: Decodable, Sendable, Equatable {
+ let accessToken: String?
+ let refreshToken: String?
+ let idToken: String?
+ let error: String?
+
+ enum CodingKeys: String, CodingKey {
+ case error
+ case accessToken = "access_token"
+ case refreshToken = "refresh_token"
+ case idToken = "id_token"
+ }
+ }
+
+ struct DailyRows: Decodable, Sendable, Equatable {
+ let data: [JSONValue]
+ let dataFreshness: String?
+
+ enum CodingKeys: String, CodingKey {
+ case data
+ case dataFreshness = "data_freshness_ts"
+ }
+ }
+}
+
+enum CodexMapper {
+ static func planName(_ planType: String?) -> String {
+ switch planType?.lowercased() {
+ case nil, "": "ChatGPT"
+ case "pro": "Pro"
+ case "prolite": "Pro Lite"
+ case "plus": "Plus"
+ case "go": "Go"
+ case "free": "Free"
+ case "team", "free_workspace": "Team"
+ case "business", "self_serve_business_prolite": "Business"
+ case "enterprise": "Enterprise"
+ case "edu", "education": "Education"
+ case .some(let other): Format.humanize(other)
+ }
+ }
+
+ static func windows(_ response: CodexAPI.UsageResponse) -> [QuotaWindow] {
+ var windows = rateLimitWindows(response.rateLimit, idPrefix: "", labelPrefix: "")
+ windows += rateLimitWindows(response.codeReviewRateLimit, idPrefix: "code_review:", labelPrefix: "Code review ")
+ for extra in response.additionalRateLimits ?? [] {
+ windows += rateLimitWindows(
+ extra.rateLimit, idPrefix: "additional:\(Format.slug(extra.limitName)):", labelPrefix: "\(extra.limitName) ",
+ scope: extra.limitName)
+ }
+ return windows
+ }
+
+ static func rateLimitWindows(
+ _ limit: CodexAPI.RateLimit?, idPrefix: String, labelPrefix: String, scope: String? = nil
+ ) -> [QuotaWindow] {
+ guard let limit else { return [] }
+ return [limit.primaryWindow, limit.secondaryWindow].compactMap { $0 }.map { window in
+ let seconds = window.limitWindowSeconds ?? 18000
+ let (suffix, group): (String, WindowGroup) =
+ switch seconds {
+ case 18000: ("session", .session)
+ case 604_800: ("weekly", .weekly)
+ case 2_592_000, 2_678_400: ("monthly", .monthly)
+ default: ("window-\(Int(seconds))", .other)
+ }
+ return QuotaWindow(
+ id: idPrefix + suffix,
+ label: labelPrefix + Format.windowLabel(seconds: seconds),
+ group: group,
+ usedPercent: window.usedPercent,
+ resetsAt: window.resetAt.map { Date(timeIntervalSince1970: $0) },
+ duration: seconds,
+ severity: limit.limitReached == true ? .critical : nil,
+ scope: scope
+ )
+ }
+ }
+
+ static func credits(_ credits: CodexAPI.Credits?) -> CreditBalance? {
+ guard let credits else { return nil }
+ return CreditBalance(
+ balance: credits.balance.flatMap { Decimal(string: $0) },
+ unlimited: credits.unlimited ?? false,
+ hasCredits: credits.hasCredits ?? false,
+ overageLimitReached: credits.overageLimitReached ?? false,
+ approxLocalMessages: range(credits.approxLocalMessages),
+ approxCloudMessages: range(credits.approxCloudMessages)
+ )
+ }
+
+ static func range(_ values: [Int]?) -> ClosedRange? {
+ guard let values, let low = values.first, let high = values.last, low <= high else { return nil }
+ return low...high
+ }
+
+ static func spend(_ control: CodexAPI.SpendControl?) -> SpendControl? {
+ guard let control, let limit = control.individualLimit else { return nil }
+ func money(_ text: String?) -> Money? {
+ text.flatMap { Decimal(string: $0) }.map {
+ Money(amountMinor: Int((NSDecimalNumber(decimal: $0 * 100)).doubleValue.rounded()), currency: "USD")
+ }
+ }
+ return SpendControl(
+ enabled: true,
+ used: money(limit.used),
+ limit: money(limit.limit),
+ percent: limit.usedPercent,
+ resetsAt: limit.resetAt.map { Date(timeIntervalSince1970: $0) },
+ limitReached: control.reached ?? false
+ )
+ }
+
+ static func resetCredits(_ summary: CodexAPI.ResetCreditsSummary?) -> ResetCredits? {
+ guard let summary else { return nil }
+ return ResetCredits(
+ available: summary.availableCount ?? 0,
+ applicable: summary.applicableAvailableCount ?? summary.availableCount ?? 0,
+ totalEarned: summary.totalEarnedCount,
+ immediatePurchaseEligible: summary.immediateResetPurchaseEligible ?? false
+ )
+ }
+
+ static func notices(_ response: CodexAPI.UsageResponse) -> [Notice] {
+ var notices: [Notice] = []
+ if let reached = response.rateLimitReachedType, !reached.isNull {
+ let type = reached["type"]?.stringValue ?? reached.summary
+ notices.append(Notice(kind: .limitReached, text: "Limit reached: \(Format.humanize(type))."))
+ } else if response.rateLimit?.limitReached == true {
+ notices.append(Notice(kind: .limitReached, text: "Usage limit reached."))
+ }
+ if response.spendControl?.reached == true {
+ notices.append(Notice(kind: .spendControl, text: "Workspace spend limit reached."))
+ }
+ if let promo = response.promo, !promo.isNull {
+ notices.append(
+ Notice(kind: .promotion, text: promo["text"]?.stringValue ?? promo["title"]?.stringValue ?? promo.summary))
+ }
+ if response.credits?.overageLimitReached == true {
+ notices.append(Notice(kind: .spendControl, text: "Credit overage limit reached."))
+ }
+ return notices
+ }
+
+ static func identity(_ response: CodexAPI.UsageResponse?, auth: CodexAuth?) -> ProviderIdentity {
+ let planType = response?.planType ?? auth?.planType
+ return ProviderIdentity(
+ planName: planName(planType),
+ tier: planType,
+ email: response?.email ?? auth?.email,
+ subscriptionActiveUntil: auth?.subscriptionActiveUntil
+ )
+ }
+
+ static func analytics(_ endpoint: CodexAPI.Analytics, rows: [JSONValue]) -> [AnalyticsPoint] {
+ rows.flatMap { row -> [AnalyticsPoint] in
+ guard let day = row["date"]?.stringValue else { return [] }
+ switch endpoint {
+ case .tokenUsage:
+ let surfaces = (row["product_surface_usage_values"]?.objectValue ?? [:]).compactMap { key, value in
+ value.doubleValue.map { AnalyticsPoint(day: day, metric: .surfaceUsagePercent, series: key, value: $0) }
+ }
+ let models = (row["models"]?.arrayValue ?? []).compactMap { model in
+ model["model"]?.stringValue.flatMap { name in
+ model["credits"]?.doubleValue.map {
+ AnalyticsPoint(day: day, metric: .modelCredits, series: name, value: $0)
+ }
+ }
+ }
+ return surfaces + models
+ case .workspaceCounts:
+ var points: [AnalyticsPoint] = []
+ let totals = row["totals"]
+ let tokenMetrics: [(String, AnalyticsMetric)] = [
+ ("uncached_text_input_tokens", .inputTokens), ("cached_text_input_tokens", .cachedInputTokens),
+ ("text_output_tokens", .outputTokens),
+ ]
+ for (key, metric) in tokenMetrics {
+ if let value = totals?[key]?.doubleValue {
+ points.append(AnalyticsPoint(day: day, metric: metric, series: "total", value: value))
+ }
+ }
+ for (key, metric) in [
+ ("turns", AnalyticsMetric.turns), ("threads", .threads), ("credits", .credits),
+ ] {
+ if let value = totals?[key]?.doubleValue {
+ points.append(AnalyticsPoint(day: day, metric: metric, series: "total", value: value))
+ }
+ }
+ for (collection, prefix, nameKey) in [("models", "model", "model"), ("clients", "surface", "client_id")] {
+ for entry in row[collection]?.arrayValue ?? [] {
+ guard let name = entry[nameKey]?.stringValue else { continue }
+ let series = "\(prefix):\(name)"
+ for (key, metric) in [("turns", AnalyticsMetric.turns), ("threads", .threads), ("credits", .credits)] {
+ if let value = entry[key]?.doubleValue {
+ points.append(AnalyticsPoint(day: day, metric: metric, series: series, value: value))
+ }
+ }
+ }
+ }
+ return points
+ case .skills:
+ return (row["skill_usage_overviews"]?.arrayValue ?? []).compactMap { skill in
+ let name = skill["display_name"]?.stringValue ?? skill["skill_name"]?.stringValue
+ return name.flatMap { series in
+ skill["invocation_counts"]?.doubleValue.map {
+ AnalyticsPoint(day: day, metric: .skillInvocations, series: series, value: $0)
+ }
+ }
+ }
+ case .plugins:
+ var invocations: [String: Double] = [:]
+ for plugin in row["plugin_usage_overviews"]?.arrayValue ?? [] {
+ guard let name = plugin["plugin_name"]?.stringValue ?? plugin["display_name"]?.stringValue,
+ let count = plugin["invocation_counts"]?.doubleValue
+ else { continue }
+ invocations[name, default: 0] += count
+ }
+ return invocations.sorted { $0.key < $1.key }.map { name, count in
+ AnalyticsPoint(day: day, metric: .pluginInvocations, series: name, value: count)
+ }
+ case .codeReview:
+ return row.objectValue!.compactMap { key, value in
+ key == "date"
+ ? nil : value.doubleValue.map { AnalyticsPoint(day: day, metric: .codeReviews, series: key, value: $0) }
+ }
+ }
+ }
+ }
+
+ static func creditEvents(_ rows: [JSONValue]) -> [CreditEvent] {
+ rows.enumerated().compactMap { index, row in
+ let dateText = row["date"]?.stringValue ?? row["created_at"]?.stringValue ?? row["timestamp"]?.stringValue
+ guard let date = dateText.flatMap({ ISODate.parse($0) ?? DayStamp.date($0) }) else { return nil }
+ let used = row["credits_used"]?.doubleValue ?? row["credits"]?.doubleValue ?? row["amount"]?.doubleValue ?? 0
+ let service = row["service"]?.stringValue ?? row["product"]?.stringValue ?? "Codex"
+ return CreditEvent(
+ id: row["id"]?.stringValue ?? "\(dateText!)-\(index)", date: date, service: service, creditsUsed: used)
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexAnalyticsWatermarkStore.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexAnalyticsWatermarkStore.swift
new file mode 100644
index 0000000..5a1727f
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexAnalyticsWatermarkStore.swift
@@ -0,0 +1,116 @@
+import Foundation
+
+public struct CodexAnalyticsWatermarkPersistence: @unchecked Sendable {
+ public static let standard = CodexAnalyticsWatermarkPersistence(defaults: .standard)
+
+ let defaults: UserDefaults
+
+ public init(defaults: UserDefaults) {
+ self.defaults = defaults
+ }
+}
+
+struct CodexAnalyticsCoverage: Codable, Equatable {
+ var start: String
+ var through: String
+}
+
+struct CodexAnalyticsWatermarkStore {
+ static let storageKey = "codexAnalyticsWatermarks"
+ static let maximumAccounts = 4
+
+ let persistence: CodexAnalyticsWatermarkPersistence
+
+ func load(account: String, now: Date, retentionDays: Int) -> [CodexAPI.Analytics: CodexAnalyticsCoverage] {
+ var (state, needsWrite) = decoded(now: now)
+ needsWrite = prune(&state, now: now, retentionDays: retentionDays) || needsWrite
+ if needsWrite { write(state) }
+ return Dictionary(
+ uniqueKeysWithValues: (state.accounts[account]?.coverage ?? [:]).compactMap { key, value in
+ CodexAPI.Analytics(rawValue: key).map { ($0, value) }
+ })
+ }
+
+ func update(
+ account: String,
+ coverage: [CodexAPI.Analytics: CodexAnalyticsCoverage],
+ now: Date,
+ retentionDays: Int
+ ) {
+ var (state, _) = decoded(now: now)
+ state.accounts[account] = StoredAccount(
+ lastAccess: now,
+ coverage: Dictionary(uniqueKeysWithValues: coverage.map { ($0.key.rawValue, $0.value) }))
+ _ = prune(&state, now: now, retentionDays: retentionDays)
+ write(state)
+ }
+
+ private func decoded(now: Date) -> (StoredState, Bool) {
+ guard let data = persistence.defaults.data(forKey: Self.storageKey) else { return (StoredState(), false) }
+ let decoder = JSONDecoder()
+ if let state = try? decoder.decode(StoredState.self, from: data), state.version == StoredState.version {
+ return (state, false)
+ }
+ if let legacy = try? decoder.decode(LegacyState.self, from: data) {
+ return (
+ StoredState(
+ accounts: legacy.accounts.mapValues { watermarks in
+ StoredAccount(
+ lastAccess: now,
+ coverage: watermarks.mapValues { CodexAnalyticsCoverage(start: $0, through: $0) })
+ }),
+ true
+ )
+ }
+ return (StoredState(), true)
+ }
+
+ private func prune(_ state: inout StoredState, now: Date, retentionDays: Int) -> Bool {
+ let cutoff = DayStamp.string(now.addingTimeInterval(-Double(max(retentionDays - 1, 0)) * 86400))
+ let end = DayStamp.string(now)
+ let previous = state
+ state.accounts = state.accounts.compactMapValues { account in
+ var account = account
+ account.coverage = account.coverage.filter { key, value in
+ CodexAPI.Analytics(rawValue: key) != nil
+ && DayStamp.date(value.start) != nil
+ && DayStamp.date(value.through) != nil
+ && value.start <= value.through
+ && value.through >= cutoff
+ && value.through <= end
+ }.mapValues { value in
+ CodexAnalyticsCoverage(start: max(value.start, cutoff), through: value.through)
+ }
+ return account.coverage.isEmpty ? nil : account
+ }
+ if state.accounts.count > Self.maximumAccounts {
+ let retained = state.accounts.sorted {
+ $0.value.lastAccess == $1.value.lastAccess
+ ? $0.key > $1.key
+ : $0.value.lastAccess > $1.value.lastAccess
+ }.prefix(Self.maximumAccounts)
+ state.accounts = Dictionary(uniqueKeysWithValues: retained.map { ($0.key, $0.value) })
+ }
+ return state != previous
+ }
+
+ private func write(_ state: StoredState) {
+ persistence.defaults.set(try! JSONEncoder().encode(state), forKey: Self.storageKey)
+ }
+}
+
+private struct StoredState: Codable, Equatable {
+ static let version = 1
+
+ var version = Self.version
+ var accounts: [String: StoredAccount] = [:]
+}
+
+private struct StoredAccount: Codable, Equatable {
+ var lastAccess: Date
+ var coverage: [String: CodexAnalyticsCoverage]
+}
+
+private struct LegacyState: Codable {
+ let accounts: [String: [String: String]]
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift
new file mode 100644
index 0000000..6272c47
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift
@@ -0,0 +1,391 @@
+import Foundation
+
+public actor CodexProvider: UsageProvider {
+ public static let resetCreditsTTL: TimeInterval = 15 * 60
+ public static let resetCreditsFailureTTL: TimeInterval = 5 * 60
+
+ public nonisolated let id: ProviderID = .codex
+ public nonisolated let pollingPolicy = PollingPolicy.defaults(for: .codex)
+ private let auth: any CodexAuthStore
+ private let rollouts: CodexRolloutReader?
+ private let client: APIClient
+ private let log: LogBuffer
+ private let allowRefresh: @MainActor @Sendable () -> Bool
+ private let analyticsWatermarks: CodexAnalyticsWatermarkStore
+ private var activeAccount: String?
+ private var resetCreditsCache: CachedResetCredits?
+ private var resetCreditsTask: Task, Never>?
+ private var analyticsCoverage: [CodexAPI.Analytics: CodexAnalyticsCoverage] = [:]
+ private var pendingCredentialSave: PendingCredentialSave?
+
+ public init(
+ auth: any CodexAuthStore,
+ rollouts: CodexRolloutReader?,
+ client: APIClient,
+ log: LogBuffer,
+ allowRefresh: @escaping @MainActor @Sendable () -> Bool,
+ analyticsWatermarkPersistence: CodexAnalyticsWatermarkPersistence = .standard
+ ) {
+ self.auth = auth
+ self.rollouts = rollouts
+ self.client = client
+ self.log = log
+ self.allowRefresh = allowRefresh
+ analyticsWatermarks = CodexAnalyticsWatermarkStore(persistence: analyticsWatermarkPersistence)
+ }
+
+ public nonisolated var credentialDescription: String {
+ auth.description
+ }
+
+ public nonisolated func credentialState(now: Date) -> CredentialState {
+ do {
+ guard let stored = try auth.load() else { return .missing("no Codex sign-in found") }
+ return stored.state(now: now)
+ } catch {
+ return .missing("\(error)")
+ }
+ }
+
+ public func credentialHealth(now: Date) async -> ProviderCredentialHealth {
+ if let pendingCredentialSave {
+ return .from(
+ pendingCredentialSave.credential.state(now: now), source: pendingCredentialSave.source,
+ expected: id.setup.credentialSources)
+ }
+ return auth.credentialHealth(now: now)
+ }
+
+ public func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult {
+ let resolved: ResolvedCredential
+ do {
+ resolved = try resolveCredential(
+ pending: &pendingCredentialSave,
+ provider: id,
+ load: { try auth.loadWithSource().map { (credential: $0.auth, source: $0.source) } },
+ save: { try auth.save($0, replacing: $1) })
+ guard resolved.credential != nil else {
+ return await fallback(
+ reason: "No Codex credentials. \(id.loginHint)", auth: nil, now: now, notAuthenticated: true,
+ credentialStatus: .missing("no Codex sign-in found", provider: id))
+ }
+ } catch {
+ return await fallback(
+ reason: "Cannot read Codex credentials: \(error)", auth: nil, now: now, notAuthenticated: true,
+ credentialStatus: .unreadable(error, provider: id, fallbackSource: auth.source))
+ }
+ let stored = resolved.credential!
+ var recoveryIssue = resolved.issue
+ var active = stored
+ var activeSource = resolved.source!
+ if case .expired = stored.state(now: now) {
+ guard await allowRefresh() else {
+ return await fallback(
+ reason: "Codex token expired. \(id.loginHint)", auth: stored, now: now, notAuthenticated: true,
+ credentialStatus: .resolved(stored.state(now: now), provider: id, source: activeSource))
+ }
+ do {
+ let refreshed = try await refresh(stored, source: activeSource, now: now)
+ active = refreshed.credential
+ activeSource = refreshed.source
+ recoveryIssue = refreshed.issue
+ } catch {
+ return await fallback(
+ reason: "Codex token refresh failed: \(error.message)", auth: stored, now: now, notAuthenticated: true,
+ recoveryIssue: recoveryIssue,
+ credentialStatus: .resolved(stored.state(now: now), provider: id, source: activeSource))
+ }
+ }
+ let credentialStatus = ProviderCredentialStatus.resolved(
+ active.state(now: now), provider: id, source: activeSource)
+ let account = active.accountFingerprint
+ activate(account: account, now: now, retentionDays: options.analyticsDays)
+ let headers = CodexAPI.headers(token: active.accessToken, accountID: active.accountID)
+ let response: CodexAPI.UsageResponse
+ do {
+ response = try await client.getJSON(
+ CodexAPI.UsageResponse.self, CodexAPI.usageURL, headers: headers, operation: "codex.usage")
+ } catch {
+ let outcome = ProviderOutcomeBuilder.outcome(for: error, hint: id.loginHint)
+ switch outcome {
+ case .failed, .rateLimited:
+ return ProviderFetchResult(outcome: outcome, recoveryIssue: recoveryIssue)
+ .withCredentialStatus(credentialStatus)
+ default: break
+ }
+ return await fallback(
+ reason: outcome.errorDescription!, auth: active, now: now,
+ notAuthenticated: error.isAuthenticationFailure, recoveryIssue: recoveryIssue,
+ credentialStatus: credentialStatus)
+ }
+ var warnings: [String] = []
+ let (resetCredits, resetCreditsWarning) = await resetCredits(
+ response: response, account: account, headers: headers, now: now)
+ if let resetCreditsWarning { warnings.append(resetCreditsWarning) }
+ let snapshot = ProviderSnapshot(
+ provider: .codex,
+ identity: CodexMapper.identity(response, auth: active),
+ windows: CodexMapper.windows(response),
+ credits: CodexMapper.credits(response.credits),
+ spend: CodexMapper.spend(response.spendControl),
+ resetCredits: resetCredits,
+ notices: CodexMapper.notices(response),
+ fetchedAt: now
+ )
+ var analytics: ProviderAnalytics?
+ if options.includeAnalytics {
+ let (result, analyticsWarnings) = await fetchAnalytics(
+ account: account, headers: headers, now: now, days: options.analyticsDays)
+ analytics = result
+ warnings += analyticsWarnings
+ }
+ return ProviderFetchResult(
+ outcome: .success(snapshot), warnings: warnings, analytics: analytics, recoveryIssue: recoveryIssue
+ ).withCredentialStatus(credentialStatus)
+ }
+
+ private func resetCredits(
+ response: CodexAPI.UsageResponse,
+ account: String,
+ headers: [String: String],
+ now: Date
+ ) async -> (ResetCredits?, String?) {
+ let inline = CodexMapper.resetCredits(response.rateLimitResetCredits)
+ if let summary = response.rateLimitResetCredits,
+ summary.totalEarnedCount != nil,
+ summary.immediateResetPurchaseEligible != nil,
+ let inline
+ {
+ if activeAccount == account {
+ resetCreditsCache = CachedResetCredits(
+ value: inline, warning: nil, expiresAt: now.addingTimeInterval(Self.resetCreditsTTL))
+ }
+ return (inline, nil)
+ }
+ if activeAccount == account, let cached = resetCreditsCache, cached.expiresAt > now {
+ return (cached.value ?? inline, cached.warning)
+ }
+ let stale = resetCreditsCache?.value ?? inline
+ let task: Task, Never>
+ if activeAccount == account, let current = resetCreditsTask {
+ task = current
+ } else {
+ task = Task { [client] in await Self.fetchResetCredits(client, headers: headers) }
+ if activeAccount == account { resetCreditsTask = task }
+ }
+ let result = await task.value
+ if activeAccount == account { resetCreditsTask = nil }
+ switch result {
+ case .success(let value):
+ let resolved = value!
+ if activeAccount == account {
+ resetCreditsCache = CachedResetCredits(
+ value: resolved, warning: nil, expiresAt: now.addingTimeInterval(Self.resetCreditsTTL))
+ }
+ return (resolved, nil)
+ case .failure(let error):
+ let warning = "Reset credits unavailable: \(error.message)"
+ if activeAccount == account {
+ resetCreditsCache = CachedResetCredits(
+ value: stale, warning: warning, expiresAt: now.addingTimeInterval(Self.resetCreditsFailureTTL))
+ }
+ return (stale, warning)
+ }
+ }
+
+ private func fetchAnalytics(
+ account: String, headers: [String: String], now: Date, days: Int
+ ) async -> (ProviderAnalytics?, [String]) {
+ let end = DayStamp.string(now)
+ let defaultStart = DayStamp.string(now.addingTimeInterval(-Double(max(days - 1, 0)) * 86400))
+ let cutoffDate = DayStamp.date(defaultStart)!
+ let endDate = DayStamp.date(end)!.addingTimeInterval(86400)
+ analyticsCoverage = analyticsCoverage.filter { _, coverage in
+ coverage.start <= coverage.through && coverage.through >= defaultStart && coverage.through <= end
+ }.mapValues { coverage in
+ CodexAnalyticsCoverage(start: max(coverage.start, defaultStart), through: coverage.through)
+ }
+ var points: [AnalyticsPoint] = []
+ var warnings: [String] = []
+ var starts: [CodexAPI.Analytics: String] = [:]
+ var successful: Set = []
+ await withTaskGroup(of: (CodexAPI.Analytics, Result).self) { group in
+ for endpoint in CodexAPI.Analytics.allCases {
+ let start = analyticsStart(endpoint: endpoint, fallback: defaultStart)
+ starts[endpoint] = start
+ group.addTask { [client] in
+ (
+ endpoint,
+ await Self.rows(
+ client, endpoint.url(start: start, end: end), headers: headers, operation: "codex.\(endpoint)")
+ )
+ }
+ }
+ for await (endpoint, result) in group {
+ switch result {
+ case .success(let rows):
+ points += CodexMapper.analytics(endpoint, rows: rows.data).filter { $0.day >= defaultStart && $0.day <= end }
+ successful.insert(endpoint)
+ case .failure(let error):
+ warnings.append("\(Format.humanize(String(describing: endpoint))) analytics unavailable: \(error.message)")
+ }
+ }
+ }
+ let coveredScopes: [AnalyticsCoverageScope] = CodexAPI.Analytics.allCases.compactMap { endpoint in
+ guard successful.contains(endpoint), let start = starts[endpoint] else { return nil }
+ return AnalyticsCoverageScope(metrics: endpoint.metrics, startDay: start, endDay: end)
+ }
+ if activeAccount == account, !successful.isEmpty {
+ for endpoint in successful {
+ let start = starts[endpoint]!
+ let coveredStart = min(analyticsCoverage[endpoint]?.start ?? start, start)
+ analyticsCoverage[endpoint] = CodexAnalyticsCoverage(start: coveredStart, through: end)
+ }
+ analyticsWatermarks.update(
+ account: account, coverage: analyticsCoverage, now: now, retentionDays: days)
+ }
+ var events: [CreditEvent] = []
+ switch await Self.rows(client, CodexAPI.creditEventsURL, headers: headers, operation: "codex.credit-events") {
+ case .success(let rows):
+ events = CodexMapper.creditEvents(rows.data).filter { $0.date >= cutoffDate && $0.date < endDate }
+ case .failure(let error): warnings.append("Credit usage history unavailable: \(error.message)")
+ }
+ guard !points.isEmpty || !events.isEmpty || !coveredScopes.isEmpty else { return (nil, warnings) }
+ return (
+ ProviderAnalytics(
+ provider: .codex,
+ points: points,
+ creditEvents: events,
+ fetchedAt: now,
+ accountFingerprint: account,
+ coveredScopes: coveredScopes),
+ warnings
+ )
+ }
+
+ private func analyticsStart(endpoint: CodexAPI.Analytics, fallback: String) -> String {
+ guard let coverage = analyticsCoverage[endpoint], coverage.start <= fallback,
+ let date = DayStamp.date(coverage.through)
+ else { return fallback }
+ let through = DayStamp.string(date.addingTimeInterval(-86400))
+ return max(fallback, through)
+ }
+
+ private func activate(account: String, now: Date, retentionDays: Int) {
+ guard activeAccount != account else { return }
+ resetCreditsTask?.cancel()
+ activeAccount = account
+ resetCreditsCache = nil
+ resetCreditsTask = nil
+ analyticsCoverage = analyticsWatermarks.load(account: account, now: now, retentionDays: retentionDays)
+ }
+
+ private static func rows(
+ _ client: APIClient, _ url: URL, headers: [String: String], operation: String
+ ) async -> Result {
+ do {
+ return .success(try await client.getJSON(CodexAPI.DailyRows.self, url, headers: headers, operation: operation))
+ } catch {
+ return .failure(error)
+ }
+ }
+
+ private static func fetchResetCredits(
+ _ client: APIClient, headers: [String: String]
+ ) async -> Result {
+ do {
+ let summary = try await client.getJSON(
+ CodexAPI.ResetCreditsSummary.self,
+ CodexAPI.resetCreditsURL,
+ headers: headers,
+ operation: "codex.reset-credits")
+ return .success(CodexMapper.resetCredits(summary))
+ } catch {
+ return .failure(error)
+ }
+ }
+
+ private func fallback(
+ reason: String,
+ auth: CodexAuth?,
+ now: Date,
+ notAuthenticated: Bool,
+ recoveryIssue: ProviderRecoveryIssue? = nil,
+ credentialStatus: ProviderCredentialStatus
+ ) async -> ProviderFetchResult {
+ guard let reading = await rollouts?.latest(now: now) else {
+ return ProviderFetchResult(
+ outcome: notAuthenticated ? .notAuthenticated(reason) : .networkUnavailable(reason),
+ recoveryIssue: recoveryIssue
+ ).withCredentialStatus(credentialStatus)
+ }
+ let response = CodexAPI.UsageResponse(
+ email: nil, planType: reading.planType, rateLimit: reading.rateLimit, codeReviewRateLimit: nil,
+ additionalRateLimits: nil,
+ credits: reading.credits, spendControl: nil, rateLimitReachedType: nil, promo: nil, rateLimitResetCredits: nil
+ )
+ let snapshot = ProviderSnapshot(
+ provider: .codex,
+ identity: CodexMapper.identity(response, auth: auth),
+ windows: CodexMapper.windows(response),
+ credits: CodexMapper.credits(reading.credits),
+ source: .localLog,
+ fetchedAt: reading.observedAt ?? now
+ )
+ return ProviderFetchResult(
+ outcome: .partial(snapshot, reason), warnings: ["Showing the last values Codex CLI logged locally."],
+ recoveryIssue: recoveryIssue
+ ).withCredentialStatus(credentialStatus)
+ }
+
+ private func refresh(
+ _ stored: CodexAuth,
+ source: CredentialSource,
+ now: Date
+ ) async throws(APIError) -> (
+ credential: CodexAuth,
+ source: CredentialSource,
+ issue: ProviderRecoveryIssue?
+ ) {
+ guard let refreshToken = stored.refreshToken else {
+ throw APIError.http(status: 401, body: "no refresh token", retryAfter: nil)
+ }
+ let body = try! JSONEncoder().encode([
+ "client_id": CodexAPI.clientID, "grant_type": "refresh_token", "refresh_token": refreshToken,
+ ])
+ let data = try await client.post(CodexAPI.tokenURL, json: body, headers: [:], operation: "codex.refresh")
+ let token = try client.decode(CodexAPI.TokenResponse.self, data, operation: "codex.refresh")
+ guard let accessToken = token.accessToken else {
+ throw APIError.http(status: 401, body: token.error ?? "refresh returned no access token", retryAfter: nil)
+ }
+ let refreshed = stored.refreshed(
+ accessToken: accessToken, refreshToken: token.refreshToken, idToken: token.idToken, now: now)
+ let saveResult: CredentialSaveResult
+ do {
+ saveResult = try auth.save(refreshed, replacing: stored)
+ } catch {
+ log.logError("codex token refreshed but could not be stored: \(error)")
+ let detail = credentialPersistenceDetail(error)
+ pendingCredentialSave = PendingCredentialSave(
+ credential: refreshed, replacing: stored, source: source, detail: detail)
+ return (refreshed, source, .credentialPersistence(provider: id, detail: detail))
+ }
+ switch saveResult {
+ case .saved:
+ log.log("codex token refreshed and stored")
+ case .changed(let current, let currentSource):
+ log.log("codex token refresh not stored because the credential source changed")
+ guard let current else {
+ throw APIError.http(status: 401, body: "credentials were removed during refresh", retryAfter: nil)
+ }
+ return (current, currentSource!, nil)
+ }
+ return (refreshed, source, nil)
+ }
+}
+
+private struct CachedResetCredits {
+ let value: ResetCredits?
+ let warning: String?
+ let expiresAt: Date
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift
new file mode 100644
index 0000000..1c9f1f8
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift
@@ -0,0 +1,393 @@
+import Foundation
+
+public struct CodexRolloutWorkload: Sendable, Equatable {
+ public fileprivate(set) var treesScanned = 0
+ public fileprivate(set) var treeEntriesExamined = 0
+ public fileprivate(set) var statChecks = 0
+ public fileprivate(set) var filesOpened = 0
+ public fileprivate(set) var bytesRead = 0
+ public fileprivate(set) var largestSliceBytesRead = 0
+ public fileprivate(set) var largestTreeSliceEntries = 0
+ public fileprivate(set) var searchesCompleted = 0
+
+ public init() {}
+}
+
+private struct RolloutFile: Sendable, Equatable {
+ let url: URL
+ let size: Int
+ let modified: Date
+}
+
+private struct RolloutCandidateCache {
+ var files: [RolloutFile]
+ let expiresAt: Date
+}
+
+private struct RolloutSearch {
+ let files: [RolloutFile]
+ let referenceNow: Date
+ var fileIndex = 0
+ var current: ReverseRolloutRead?
+}
+
+private struct RolloutTreeScan {
+ let enumerator: FileManager.DirectoryEnumerator
+ let referenceNow: Date
+ var candidates: [RolloutFile] = []
+}
+
+private struct ReverseRolloutRead {
+ let file: RolloutFile
+ var cursor: Int
+ var reversedLine = Data()
+ var oversized = false
+}
+
+public actor CodexRolloutReader {
+ static let maxFiles = 8
+ public static let defaultCacheInterval: TimeInterval = 5 * 60
+ public static let defaultWorkByteBudget = 2 * 1024 * 1024
+ public static let defaultWorkEntryBudget = 256
+ public static let defaultWorkTimeBudget: TimeInterval = 0.05
+ public static let defaultBackgroundWorkDelay: TimeInterval = 0.01
+ static let readChunkSize = 64 * 1024
+ static let maximumLineBytes = 1024 * 1024
+
+ public let sessionsRoot: URL
+ private let cacheInterval: TimeInterval
+ private let workByteBudget: Int
+ private let workEntryBudget: Int
+ private let workTimeBudget: TimeInterval
+ private let backgroundWorkDelay: TimeInterval
+ private var cache: Cache?
+ private var candidateCache: RolloutCandidateCache?
+ private var treeScan: RolloutTreeScan?
+ private var search: RolloutSearch?
+ private var backgroundTask: Task?
+ public private(set) var workload = CodexRolloutWorkload()
+
+ public init(
+ sessionsRoot: URL,
+ cacheInterval: TimeInterval = defaultCacheInterval,
+ workByteBudget: Int = defaultWorkByteBudget,
+ workEntryBudget: Int = defaultWorkEntryBudget,
+ workTimeBudget: TimeInterval = defaultWorkTimeBudget,
+ backgroundWorkDelay: TimeInterval = defaultBackgroundWorkDelay
+ ) {
+ self.sessionsRoot = sessionsRoot
+ self.cacheInterval = cacheInterval
+ self.workByteBudget = max(workByteBudget, 1)
+ self.workEntryBudget = max(workEntryBudget, 1)
+ self.workTimeBudget = max(workTimeBudget, 0.001)
+ self.backgroundWorkDelay = max(backgroundWorkDelay, 0.001)
+ }
+
+ deinit { backgroundTask?.cancel() }
+
+ public func cancelBackgroundWork() {
+ backgroundTask?.cancel()
+ backgroundTask = nil
+ treeScan = nil
+ search = nil
+ }
+
+ struct Reading: Sendable, Equatable {
+ let rateLimit: CodexAPI.RateLimit
+ let planType: String?
+ let credits: CodexAPI.Credits?
+ let observedAt: Date?
+ }
+
+ func latest(now: Date = Date()) -> Reading? {
+ if let cache, cache.expiresAt > now {
+ if let refreshed = refreshedFiles(cache.files) {
+ if refreshed == cache.files { return cache.reading }
+ candidateCache = RolloutCandidateCache(
+ files: refreshed, expiresAt: candidateCache!.expiresAt)
+ self.cache = nil
+ search = RolloutSearch(files: refreshed, referenceNow: now)
+ } else {
+ self.cache = nil
+ candidateCache = nil
+ treeScan = nil
+ search = nil
+ }
+ }
+ if treeScan == nil, search == nil {
+ if let candidateCache, candidateCache.expiresAt > now {
+ search = RolloutSearch(files: candidateCache.files, referenceNow: now)
+ } else {
+ beginTreeScan(now: now)
+ }
+ }
+ runWorkSlice()
+ scheduleBackgroundWork()
+ return cache?.reading
+ }
+
+ private func beginTreeScan(now: Date) {
+ let enumerator = FileManager.default.enumerator(
+ at: sessionsRoot,
+ includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey],
+ options: [.skipsHiddenFiles])!
+ workload.treesScanned += 1
+ treeScan = RolloutTreeScan(enumerator: enumerator, referenceNow: now)
+ }
+
+ private func refreshedFiles(_ files: [RolloutFile]) -> [RolloutFile]? {
+ var refreshed: [RolloutFile] = []
+ refreshed.reserveCapacity(files.count)
+ for file in files {
+ guard let current = rolloutFile(file.url) else { return nil }
+ refreshed.append(current)
+ }
+ return refreshed.sorted(by: Self.isNewer)
+ }
+
+ private func runWorkSlice() {
+ let initialBytes = workload.bytesRead
+ let initialEntries = workload.treeEntriesExamined
+ defer {
+ workload.largestSliceBytesRead = max(workload.largestSliceBytesRead, workload.bytesRead - initialBytes)
+ workload.largestTreeSliceEntries = max(
+ workload.largestTreeSliceEntries, workload.treeEntriesExamined - initialEntries)
+ }
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: .seconds(workTimeBudget))
+ advanceTreeScan(deadline: deadline)
+ guard clock.now < deadline else { return }
+ runSearchSlice(deadline: deadline)
+ }
+
+ private func advanceTreeScan(deadline: ContinuousClock.Instant) {
+ guard var active = treeScan else { return }
+ let clock = ContinuousClock()
+ var entriesRemaining = workEntryBudget
+ while entriesRemaining > 0, clock.now < deadline {
+ guard let url = active.enumerator.nextObject() as? URL else {
+ let files = active.candidates
+ candidateCache = RolloutCandidateCache(
+ files: files, expiresAt: active.referenceNow.addingTimeInterval(cacheInterval))
+ treeScan = nil
+ search = RolloutSearch(files: files, referenceNow: active.referenceNow)
+ return
+ }
+ workload.treeEntriesExamined += 1
+ entriesRemaining -= 1
+ guard url.lastPathComponent.hasPrefix("rollout-"), url.pathExtension == "jsonl", let file = rolloutFile(url)
+ else { continue }
+ insertCandidate(file, into: &active.candidates)
+ }
+ treeScan = active
+ }
+
+ private func runSearchSlice(deadline: ContinuousClock.Instant) {
+ guard var active = search else { return }
+ let clock = ContinuousClock()
+ var bytesRemaining = workByteBudget
+ while active.fileIndex < active.files.count, bytesRemaining > 0, clock.now < deadline {
+ var current =
+ active.current
+ ?? ReverseRolloutRead(
+ file: active.files[active.fileIndex], cursor: active.files[active.fileIndex].size)
+ guard let handle = try? FileHandle(forReadingFrom: current.file.url) else {
+ active.current = nil
+ active.fileIndex += 1
+ continue
+ }
+ workload.filesOpened += 1
+ defer { try? handle.close() }
+ var found: Reading?
+ var reachedStaleEOF = false
+ while current.cursor > 0, bytesRemaining > 0, clock.now < deadline {
+ let count = min(Self.readChunkSize, bytesRemaining, current.cursor)
+ let start = current.cursor - count
+ let result = Result {
+ try handle.seek(toOffset: UInt64(start))
+ return try handle.read(upToCount: count)
+ }
+ guard case .success(let value) = result, let chunk = value, chunk.count == count else {
+ reachedStaleEOF = true
+ break
+ }
+ workload.bytesRead += chunk.count
+ bytesRemaining -= chunk.count
+ current.cursor = start
+ for byte in chunk.reversed() {
+ if byte == UInt8(ascii: "\n") {
+ if !current.oversized, let reading = Self.parse(reversedLine: current.reversedLine) {
+ found = reading
+ break
+ }
+ current.reversedLine.removeAll(keepingCapacity: true)
+ current.oversized = false
+ } else if current.reversedLine.count < Self.maximumLineBytes {
+ current.reversedLine.append(byte)
+ } else {
+ current.oversized = true
+ }
+ }
+ if found != nil { break }
+ }
+ if let found {
+ cache = Cache(
+ reading: found, files: active.files, expiresAt: active.referenceNow.addingTimeInterval(cacheInterval))
+ workload.searchesCompleted += 1
+ search = nil
+ return
+ }
+ if reachedStaleEOF {
+ active.current = nil
+ active.fileIndex += 1
+ } else if current.cursor == 0 {
+ if !current.oversized, let reading = Self.parse(reversedLine: current.reversedLine) {
+ cache = Cache(
+ reading: reading, files: active.files, expiresAt: active.referenceNow.addingTimeInterval(cacheInterval))
+ workload.searchesCompleted += 1
+ search = nil
+ return
+ }
+ active.current = nil
+ active.fileIndex += 1
+ } else {
+ active.current = current
+ }
+ }
+ if active.fileIndex >= active.files.count {
+ cache = Cache(
+ reading: nil, files: active.files, expiresAt: active.referenceNow.addingTimeInterval(cacheInterval))
+ workload.searchesCompleted += 1
+ search = nil
+ } else {
+ search = active
+ }
+ }
+
+ private static func parse(reversedLine: Data) -> Reading? {
+ guard !reversedLine.isEmpty else { return nil }
+ let line = Data(reversedLine.reversed())
+ guard line.range(of: Data("\"rate_limits\"".utf8)) != nil else { return nil }
+ return parse(line: String(decoding: line, as: UTF8.self))
+ }
+
+ private var hasPendingWork: Bool { treeScan != nil || search != nil }
+
+ private func scheduleBackgroundWork() {
+ guard hasPendingWork, backgroundTask == nil else { return }
+ let delay = backgroundWorkDelay
+ backgroundTask = Task(priority: .utility) { [weak self] in
+ do {
+ try await ContinuousClock().sleep(for: .seconds(delay))
+ } catch {
+ return
+ }
+ guard !Task.isCancelled else { return }
+ await self?.runBackgroundSlice()
+ }
+ }
+
+ private func runBackgroundSlice() {
+ guard hasPendingWork else {
+ backgroundTask = nil
+ return
+ }
+ runWorkSlice()
+ guard !Task.isCancelled else { return }
+ backgroundTask = nil
+ scheduleBackgroundWork()
+ }
+
+ func newestRollouts() async -> [URL] {
+ backgroundTask?.cancel()
+ backgroundTask = nil
+ cache = nil
+ candidateCache = nil
+ treeScan = nil
+ search = nil
+ beginTreeScan(now: Date())
+ while !Task.isCancelled, treeScan != nil {
+ runWorkSlice()
+ if treeScan != nil {
+ try? await ContinuousClock().sleep(for: .seconds(backgroundWorkDelay))
+ }
+ }
+ return candidateCache?.files.map(\.url) ?? []
+ }
+
+ private func insertCandidate(_ file: RolloutFile, into candidates: inout [RolloutFile]) {
+ if candidates.count < Self.maxFiles {
+ candidates.append(file)
+ candidates.sort(by: Self.isNewer)
+ } else if let oldest = candidates.last, Self.isNewer(file, oldest) {
+ candidates[candidates.count - 1] = file
+ candidates.sort(by: Self.isNewer)
+ }
+ }
+
+ private func rolloutFile(_ url: URL) -> RolloutFile? {
+ workload.statChecks += 1
+ guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
+ attributes[.type] as? FileAttributeType == .typeRegular
+ else { return nil }
+ return RolloutFile(
+ url: url,
+ size: (attributes[.size] as! NSNumber).intValue,
+ modified: attributes[.modificationDate] as! Date)
+ }
+
+ private static func isNewer(_ lhs: RolloutFile, _ rhs: RolloutFile) -> Bool {
+ lhs.modified == rhs.modified ? lhs.url.path < rhs.url.path : lhs.modified > rhs.modified
+ }
+
+ static func parse(line: String) -> Reading? {
+ guard let json = try? JSONDecoder().decode(JSONValue.self, from: Data(line.utf8)), let limits = findRateLimits(json)
+ else { return nil }
+ func window(_ value: JSONValue?) -> CodexAPI.Window? {
+ guard let value, let used = value["used_percent"]?.doubleValue else { return nil }
+ let minutes = value["window_minutes"]?.doubleValue
+ return CodexAPI.Window(
+ usedPercent: used,
+ limitWindowSeconds: minutes.map { $0 * 60 } ?? value["limit_window_seconds"]?.doubleValue,
+ resetAfterSeconds: value["resets_in_seconds"]?.doubleValue,
+ resetAt: value["resets_at"]?.doubleValue ?? value["reset_at"]?.doubleValue
+ )
+ }
+ let credits = limits["credits"].flatMap { value -> CodexAPI.Credits? in
+ let data = try! JSONEncoder().encode(value)
+ return try? JSONDecoder().decode(CodexAPI.Credits.self, from: data)
+ }
+ let observed = json["timestamp"]?.stringValue.flatMap { ISODate.parse($0) }
+ return Reading(
+ rateLimit: CodexAPI.RateLimit(
+ allowed: nil,
+ limitReached: limits["rate_limit_reached_type"].map { !$0.isNull },
+ primaryWindow: window(limits["primary"]),
+ secondaryWindow: window(limits["secondary"])
+ ),
+ planType: limits["plan_type"]?.stringValue,
+ credits: credits,
+ observedAt: observed
+ )
+ }
+
+ static func findRateLimits(_ value: JSONValue) -> JSONValue? {
+ switch value {
+ case .object(let dict):
+ if let limits = dict["rate_limits"], limits.objectValue != nil { return limits }
+ for child in dict.values { if let found = findRateLimits(child) { return found } }
+ return nil
+ case .array(let items):
+ for item in items { if let found = findRateLimits(item) { return found } }
+ return nil
+ default:
+ return nil
+ }
+ }
+}
+
+private struct Cache {
+ let reading: CodexRolloutReader.Reading?
+ let files: [RolloutFile]
+ let expiresAt: Date
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Copilot/CopilotAPI.swift b/Sources/TokenMenuBarCore/Providers/Copilot/CopilotAPI.swift
new file mode 100644
index 0000000..504e2d6
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Copilot/CopilotAPI.swift
@@ -0,0 +1,100 @@
+import Foundation
+
+enum CopilotAPI {
+ static let editorVersion = "vscode/1.96.2"
+ static let pluginVersion = "copilot-chat/0.26.7"
+
+ static func userURL(host: String) -> URL {
+ let apiHost = host == "github.com" ? "api.github.com" : "api.\(host)"
+ return URL(string: "https://\(apiHost)/copilot_internal/user")!
+ }
+
+ static func headers(token: String) -> [String: String] {
+ [
+ "Authorization": "token \(token)", "Editor-Version": editorVersion, "Editor-Plugin-Version": pluginVersion,
+ "User-Agent": "GitHubCopilotChat/0.26.7", "X-Github-Api-Version": "2025-04-01",
+ ]
+ }
+
+ static let snapshotOrder = ["premium_interactions", "chat", "completions"]
+}
+
+enum CopilotMapper {
+ static func windows(_ user: JSONValue) -> [QuotaWindow] {
+ let resetsAt = date(user["quota_reset_date"]?.stringValue)
+ var windows: [QuotaWindow] = []
+ if let snapshots = user["quota_snapshots"]?.objectValue {
+ let ordered =
+ CopilotAPI.snapshotOrder.filter { snapshots[$0] != nil }
+ + snapshots.keys.sorted().filter { !CopilotAPI.snapshotOrder.contains($0) }
+ for key in ordered {
+ guard let snapshot = snapshots[key], let percent = percentUsed(snapshot) else { continue }
+ windows.append(
+ QuotaWindow(id: key, label: label(key), group: .monthly, usedPercent: percent, resetsAt: resetsAt))
+ }
+ }
+ if let limited = user["limited_user_quotas"]?.objectValue, let monthly = user["monthly_quotas"]?.objectValue {
+ let freeReset = date(user["limited_user_reset_date"]?.stringValue) ?? resetsAt
+ for key in limited.keys.sorted() {
+ guard let remaining = limited[key]?.doubleValue, let limit = monthly[key]?.doubleValue, limit > 0 else {
+ continue
+ }
+ windows.append(
+ QuotaWindow(
+ id: "free:\(key)", label: label(key), group: .monthly, usedPercent: (1 - remaining / limit) * 100,
+ resetsAt: freeReset))
+ }
+ }
+ return windows
+ }
+
+ static func percentUsed(_ snapshot: JSONValue) -> Double? {
+ if snapshot["unlimited"]?.boolValue == true { return nil }
+ let entitlement = number(snapshot["entitlement"])
+ let remaining = number(snapshot["remaining"])
+ if let percent = number(snapshot["percent_remaining"]) { return 100 - percent }
+ guard let entitlement, entitlement > 0, let remaining else { return nil }
+ return (1 - remaining / entitlement) * 100
+ }
+
+ static func number(_ value: JSONValue?) -> Double? {
+ value?.doubleValue ?? value?.stringValue.flatMap(Double.init)
+ }
+
+ static func label(_ key: String) -> String {
+ switch key {
+ case "premium_interactions": "Premium requests"
+ default: Format.humanize(key)
+ }
+ }
+
+ static func date(_ text: String?) -> Date? {
+ guard let text else { return nil }
+ return ISODate.parse(text) ?? DayStamp.date(text)
+ }
+
+ static func identity(_ user: JSONValue, auth: CopilotAuth) -> ProviderIdentity {
+ let plan = user["copilot_plan"]?.stringValue ?? user["access_type_sku"]?.stringValue ?? "Copilot"
+ return ProviderIdentity(
+ planName: Format.humanize(plan), tier: user["access_type_sku"]?.stringValue, email: auth.user,
+ subscriptionActiveUntil: nil)
+ }
+
+ static func notices(_ user: JSONValue) -> [Notice] {
+ var notices: [Notice] = []
+ let snapshots = user["quota_snapshots"]?.objectValue ?? [:]
+ let credits = snapshots.values.compactMap { number($0["credits_used"]) }.reduce(0, +)
+ if user["token_based_billing"]?.boolValue == true {
+ notices.append(Notice(kind: .info, text: "Token-based billing: \(Int(credits)) credits used this cycle."))
+ }
+ for key in CopilotAPI.snapshotOrder {
+ guard let snapshot = snapshots[key], let percent = percentUsed(snapshot), percent > 100 else { continue }
+ let overage = number(snapshot["overage_count"]) ?? 0
+ notices.append(
+ Notice(
+ kind: snapshot["overage_permitted"]?.boolValue == true ? .info : .limitReached,
+ text: "\(label(key)): quota exceeded, \(Int(overage)) overage requests."))
+ }
+ return notices
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Copilot/CopilotProvider.swift b/Sources/TokenMenuBarCore/Providers/Copilot/CopilotProvider.swift
new file mode 100644
index 0000000..117429d
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Copilot/CopilotProvider.swift
@@ -0,0 +1,78 @@
+import Foundation
+
+public actor CopilotProvider: UsageProvider {
+ public nonisolated let id: ProviderID = .copilot
+ public nonisolated let pollingPolicy = PollingPolicy.defaults(for: .copilot)
+ private let auth: any CopilotAuthStore
+ private let client: APIClient
+ private let log: LogBuffer
+
+ public init(auth: any CopilotAuthStore, client: APIClient, log: LogBuffer) {
+ self.auth = auth
+ self.client = client
+ self.log = log
+ }
+
+ public nonisolated var credentialDescription: String {
+ auth.description
+ }
+
+ public nonisolated func credentialState(now: Date) -> CredentialState {
+ do {
+ guard let stored = try auth.load() else { return .missing("no Copilot sign-in found") }
+ return stored.state(now: now)
+ } catch {
+ return .missing("\(error)")
+ }
+ }
+
+ public func credentialHealth(now: Date) async -> ProviderCredentialHealth {
+ auth.credentialHealth(now: now)
+ }
+
+ public func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult {
+ let resolved: (auth: CopilotAuth, source: CredentialSource)
+ do {
+ guard let loaded = try auth.loadWithSource() else {
+ return ProviderFetchResult(outcome: .notAuthenticated("No Copilot credentials. \(id.loginHint)"))
+ .withCredentialStatus(.missing("no Copilot sign-in found", provider: id))
+ }
+ resolved = loaded
+ } catch {
+ return ProviderFetchResult(outcome: .notAuthenticated("Cannot read Copilot credentials: \(error)"))
+ .withCredentialStatus(.unreadable(error, provider: id, fallbackSource: auth.source))
+ }
+ let stored = resolved.auth
+ let credentialStatus = ProviderCredentialStatus.resolved(
+ stored.state(now: now), provider: id, source: resolved.source)
+ guard !stored.host.isEmpty else {
+ return ProviderFetchResult(
+ outcome: .notAuthenticated("The GitHub host in Copilot credentials is invalid."),
+ recoveryIssue: ProviderRecoveryIssue(
+ kind: .credentialUnreadable,
+ title: "GitHub Copilot credentials could not be read",
+ detail: "Sign in again with a valid GitHub or GitHub Enterprise host.",
+ action: id.setup.missingCredentialIssue.action)
+ )
+ .withCredentialStatus(credentialStatus)
+ }
+ let user: JSONValue
+ do {
+ user = try await client.getJSON(
+ JSONValue.self, CopilotAPI.userURL(host: stored.host), headers: CopilotAPI.headers(token: stored.token),
+ operation: "copilot.user")
+ } catch {
+ return ProviderFetchResult(outcome: ProviderOutcomeBuilder.outcome(for: error, hint: id.loginHint))
+ .withCredentialStatus(credentialStatus)
+ }
+ let snapshot = ProviderSnapshot(
+ provider: .copilot,
+ identity: CopilotMapper.identity(user, auth: stored),
+ windows: CopilotMapper.windows(user),
+ notices: CopilotMapper.notices(user),
+ fetchedAt: now
+ )
+ return ProviderFetchResult(outcome: .success(snapshot))
+ .withCredentialStatus(credentialStatus)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift b/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift
new file mode 100644
index 0000000..f8d431e
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift
@@ -0,0 +1,126 @@
+import Foundation
+
+enum CursorAPI {
+ static let usageSummaryURL = URL(string: "https://cursor.com/api/usage-summary")!
+ static let meURL = URL(string: "https://cursor.com/api/auth/me")!
+ static let periodUsageURL = URL(
+ string: "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage")!
+
+ static func cookieHeaders(_ auth: CursorAuth) -> [String: String] {
+ ["Cookie": auth.sessionCookie, "Origin": "https://cursor.com", "User-Agent": "token-menu-bar"]
+ }
+
+ static func bearerHeaders(_ auth: CursorAuth) -> [String: String] {
+ ["Authorization": "Bearer \(auth.accessToken)", "Connect-Protocol-Version": "1", "User-Agent": "token-menu-bar"]
+ }
+
+ struct Bucket: Decodable, Sendable, Equatable {
+ let enabled: Bool?
+ let used: Double?
+ let limit: Double?
+ let remaining: Double?
+ let autoPercentUsed: Double?
+ let apiPercentUsed: Double?
+ let totalPercentUsed: Double?
+
+ var percentUsed: Double? {
+ if let totalPercentUsed { return totalPercentUsed }
+ switch (autoPercentUsed, apiPercentUsed) {
+ case (let auto?, let api?): return (auto + api) / 2
+ case (let auto?, nil): return auto
+ case (nil, let api?): return api
+ default: break
+ }
+ guard let used, let limit, limit > 0 else { return nil }
+ return used / limit * 100
+ }
+ }
+
+ struct IndividualUsage: Decodable, Sendable, Equatable {
+ let plan: Bucket?
+ let onDemand: Bucket?
+ let overall: Bucket?
+ }
+
+ struct TeamUsage: Decodable, Sendable, Equatable {
+ let onDemand: Bucket?
+ let pooled: Bucket?
+ }
+
+ struct UsageSummary: Decodable, Sendable, Equatable {
+ let billingCycleStart: String?
+ let billingCycleEnd: String?
+ let membershipType: String?
+ let isUnlimited: Bool?
+ let individualUsage: IndividualUsage?
+ let teamUsage: TeamUsage?
+ }
+
+ struct PeriodUsage: Decodable, Sendable, Equatable {
+ let billingCycleStart: String?
+ let billingCycleEnd: String?
+ let planUsage: Bucket?
+ let displayMessage: String?
+
+ var summary: UsageSummary {
+ UsageSummary(
+ billingCycleStart: billingCycleStart, billingCycleEnd: billingCycleEnd, membershipType: nil, isUnlimited: nil,
+ individualUsage: IndividualUsage(plan: planUsage, onDemand: nil, overall: nil), teamUsage: nil)
+ }
+ }
+
+ struct Me: Decodable, Sendable, Equatable {
+ let email: String?
+ let name: String?
+ let sub: String?
+ }
+}
+
+enum CursorMapper {
+ static func windows(_ summary: CursorAPI.UsageSummary) -> [QuotaWindow] {
+ let resetsAt = ISODate.parse(summary.billingCycleEnd)
+ let start = ISODate.parse(summary.billingCycleStart)
+ let duration = start.flatMap { start in resetsAt.map { $0.timeIntervalSince(start) } }
+ var windows: [QuotaWindow] = []
+ func add(_ id: String, _ label: String, _ bucket: CursorAPI.Bucket?) {
+ guard let bucket, bucket.enabled != false, let percent = bucket.percentUsed else { return }
+ windows.append(
+ QuotaWindow(id: id, label: label, group: .monthly, usedPercent: percent, resetsAt: resetsAt, duration: duration)
+ )
+ }
+ add("plan", "Plan usage", summary.individualUsage?.plan)
+ if summary.individualUsage?.onDemand?.limit ?? 0 > 0 {
+ add("on_demand", "On-demand", summary.individualUsage?.onDemand)
+ }
+ add("overall", "Overall", summary.individualUsage?.overall)
+ add("team_pool", "Team pool", summary.teamUsage?.pooled)
+ return windows
+ }
+
+ static func spend(_ summary: CursorAPI.UsageSummary) -> SpendControl? {
+ guard let onDemand = summary.individualUsage?.onDemand, onDemand.enabled != false else { return nil }
+ return SpendControl(
+ enabled: true,
+ used: onDemand.used.map { Money(amountMinor: Int($0.rounded()), currency: "USD") },
+ limit: onDemand.limit.map { Money(amountMinor: Int($0.rounded()), currency: "USD") },
+ percent: onDemand.limit.flatMap { limit in onDemand.used.map { limit > 0 ? $0 / limit * 100 : 0 } },
+ resetsAt: ISODate.parse(summary.billingCycleEnd),
+ limitReached: (onDemand.remaining ?? 1) <= 0 && (onDemand.limit ?? 0) > 0)
+ }
+
+ static func identity(
+ _ summary: CursorAPI.UsageSummary, auth: CursorAuth, me: CursorAPI.Me?
+ )
+ -> ProviderIdentity
+ {
+ let plan = summary.membershipType ?? auth.membershipType ?? "Cursor"
+ return ProviderIdentity(planName: Format.humanize(plan), email: me?.email ?? auth.email)
+ }
+
+ static func notices(_ summary: CursorAPI.UsageSummary, period: CursorAPI.PeriodUsage?) -> [Notice] {
+ var notices: [Notice] = []
+ if summary.isUnlimited == true { notices.append(Notice(kind: .info, text: "This plan has unlimited usage.")) }
+ if let message = period?.displayMessage, !message.isEmpty { notices.append(Notice(kind: .info, text: message)) }
+ return notices
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Cursor/CursorProvider.swift b/Sources/TokenMenuBarCore/Providers/Cursor/CursorProvider.swift
new file mode 100644
index 0000000..7680ebb
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Cursor/CursorProvider.swift
@@ -0,0 +1,129 @@
+import Foundation
+
+public actor CursorProvider: UsageProvider {
+ public static let identityTTL: TimeInterval = 60 * 60
+ public static let identityFailureTTL: TimeInterval = 5 * 60
+
+ public nonisolated let id: ProviderID = .cursor
+ public nonisolated let pollingPolicy = PollingPolicy.defaults(for: .cursor)
+ private let auth: any CursorAuthStore
+ private let client: APIClient
+ private let log: LogBuffer
+ private var identityCache: CachedCursorIdentity?
+ private var identityTask: CursorIdentityTask?
+
+ public init(auth: any CursorAuthStore, client: APIClient, log: LogBuffer) {
+ self.auth = auth
+ self.client = client
+ self.log = log
+ }
+
+ public nonisolated var credentialDescription: String {
+ auth.description
+ }
+
+ public nonisolated func credentialState(now: Date) -> CredentialState {
+ do {
+ guard let stored = try auth.load() else { return .missing("no Cursor sign-in found") }
+ return stored.state(now: now)
+ } catch {
+ return .missing("\(error)")
+ }
+ }
+
+ public func credentialHealth(now: Date) async -> ProviderCredentialHealth {
+ auth.credentialHealth(now: now)
+ }
+
+ public func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult {
+ let resolved: (auth: CursorAuth, source: CredentialSource)
+ do {
+ guard let loaded = try auth.loadWithSource() else {
+ return ProviderFetchResult(outcome: .notAuthenticated("No Cursor credentials. \(id.loginHint)"))
+ .withCredentialStatus(.missing("no Cursor sign-in found", provider: id))
+ }
+ resolved = loaded
+ } catch {
+ return ProviderFetchResult(outcome: .notAuthenticated("Cannot read Cursor credentials: \(error)"))
+ .withCredentialStatus(.unreadable(error, provider: id, fallbackSource: auth.source))
+ }
+ let stored = resolved.auth
+ let credentialStatus = ProviderCredentialStatus.resolved(
+ stored.state(now: now), provider: id, source: resolved.source)
+ if case .expired = stored.state(now: now) {
+ return ProviderFetchResult(
+ outcome: .notAuthenticated("Cursor session expired; open Cursor to refresh it. \(id.loginHint)")
+ )
+ .withCredentialStatus(credentialStatus)
+ }
+ var warnings: [String] = []
+ var summary: CursorAPI.UsageSummary
+ var period: CursorAPI.PeriodUsage?
+ do {
+ summary = try await client.getJSON(
+ CursorAPI.UsageSummary.self, CursorAPI.usageSummaryURL, headers: CursorAPI.cookieHeaders(stored),
+ operation: "cursor.usage-summary")
+ } catch {
+ do {
+ let data = try await client.post(
+ CursorAPI.periodUsageURL, json: Data("{}".utf8), headers: CursorAPI.bearerHeaders(stored),
+ operation: "cursor.period-usage")
+ let usage = try client.decode(CursorAPI.PeriodUsage.self, data, operation: "cursor.period-usage")
+ period = usage
+ summary = usage.summary
+ warnings.append("Dashboard summary unavailable: \(error.message)")
+ } catch {
+ return ProviderFetchResult(outcome: ProviderOutcomeBuilder.outcome(for: error, hint: id.loginHint))
+ .withCredentialStatus(credentialStatus)
+ }
+ }
+ let me = await identity(stored, now: now)
+ let snapshot = ProviderSnapshot(
+ provider: .cursor,
+ identity: CursorMapper.identity(summary, auth: stored, me: me),
+ windows: CursorMapper.windows(summary),
+ spend: CursorMapper.spend(summary),
+ notices: CursorMapper.notices(summary, period: period),
+ fetchedAt: now
+ )
+ return ProviderFetchResult(outcome: .success(snapshot), warnings: warnings)
+ .withCredentialStatus(credentialStatus)
+ }
+
+ private func identity(_ auth: CursorAuth, now: Date) async -> CursorAPI.Me? {
+ if let identityCache, identityCache.accessToken == auth.accessToken, identityCache.expiresAt > now {
+ return identityCache.value
+ }
+ let task: Task
+ if let identityTask, identityTask.accessToken == auth.accessToken {
+ task = identityTask.task
+ } else {
+ identityTask?.task.cancel()
+ let headers = CursorAPI.cookieHeaders(auth)
+ task = Task { [client] in
+ try? await client.getJSON(CursorAPI.Me.self, CursorAPI.meURL, headers: headers, operation: "cursor.me")
+ }
+ identityTask = CursorIdentityTask(accessToken: auth.accessToken, task: task)
+ }
+ let value = await task.value
+ if identityTask?.accessToken == auth.accessToken {
+ identityTask = nil
+ identityCache = CachedCursorIdentity(
+ accessToken: auth.accessToken,
+ value: value,
+ expiresAt: now.addingTimeInterval(value == nil ? Self.identityFailureTTL : Self.identityTTL))
+ }
+ return value
+ }
+}
+
+private struct CachedCursorIdentity {
+ let accessToken: String
+ let value: CursorAPI.Me?
+ let expiresAt: Date
+}
+
+private struct CursorIdentityTask {
+ let accessToken: String
+ let task: Task
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Gemini/GeminiAPI.swift b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiAPI.swift
new file mode 100644
index 0000000..7ccb5a4
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiAPI.swift
@@ -0,0 +1,138 @@
+import Foundation
+
+enum GeminiAPI {
+ static let base = "https://cloudcode-pa.googleapis.com/v1internal"
+ static let tokenURL = URL(string: "https://oauth2.googleapis.com/token")!
+ static let unsupportedClientMessage =
+ """
+ Google ended Login with Google for personal Gemini accounts on June 18, 2026. Workspace and Gemini Code Assist \
+ Standard or Enterprise accounts still report quota.
+ """
+
+ static var loadCodeAssistURL: URL { URL(string: "\(base):loadCodeAssist")! }
+ static var quotaURL: URL { URL(string: "\(base):retrieveUserQuota")! }
+
+ static let loadCodeAssistBody = Data(#"{"metadata":{"ideType":"GEMINI_CLI","pluginType":"GEMINI"}}"#.utf8)
+
+ static func headers(token: String) -> [String: String] {
+ ["Authorization": "Bearer \(token)", "User-Agent": "GeminiCLI/token-menu-bar (darwin; arm64; cli)"]
+ }
+
+ static func quotaBody(project: String?) -> Data {
+ try! JSONEncoder().encode(project.map { ["project": $0] } ?? [:])
+ }
+
+ struct Credit: Decodable, Sendable, Equatable {
+ let creditType: String?
+ let creditAmount: String?
+ }
+
+ struct Tier: Decodable, Sendable, Equatable {
+ let id: String?
+ let name: String?
+ let availableCredits: [Credit]?
+ }
+
+ struct IneligibleTier: Decodable, Sendable, Equatable {
+ let reasonCode: String?
+ let reasonMessage: String?
+ }
+
+ struct LoadCodeAssistResponse: Decodable, Sendable, Equatable {
+ let currentTier: Tier?
+ let paidTier: Tier?
+ let cloudaicompanionProject: JSONValue?
+ let ineligibleTiers: [IneligibleTier]?
+
+ var projectID: String? {
+ cloudaicompanionProject?.stringValue ?? cloudaicompanionProject?["id"]?.stringValue
+ ?? cloudaicompanionProject?["projectId"]?.stringValue
+ }
+
+ var unsupportedReason: String? {
+ guard currentTier == nil else { return nil }
+ let ineligible = ineligibleTiers ?? []
+ guard
+ let reason = ineligible.first(where: { $0.reasonCode?.uppercased() == "UNSUPPORTED_CLIENT" })
+ ?? ineligible.first
+ else { return nil }
+ return reason.reasonMessage ?? GeminiAPI.unsupportedClientMessage
+ }
+ }
+
+ struct Bucket: Decodable, Sendable, Equatable {
+ let modelId: String?
+ let tokenType: String?
+ let remainingFraction: Double?
+ let remainingAmount: String?
+ let resetTime: String?
+ }
+
+ struct QuotaResponse: Decodable, Sendable, Equatable {
+ let buckets: [Bucket]?
+ }
+
+ struct TokenResponse: Decodable, Sendable, Equatable {
+ let accessToken: String?
+ let expiresIn: Double?
+ let idToken: String?
+ let error: String?
+ let errorDescription: String?
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case expiresIn = "expires_in"
+ case idToken = "id_token"
+ case error
+ case errorDescription = "error_description"
+ }
+ }
+}
+
+enum GeminiMapper {
+ static func identity(_ assist: GeminiAPI.LoadCodeAssistResponse?, auth: GeminiAuth) -> ProviderIdentity {
+ ProviderIdentity(
+ planName: planName(assist, hostedDomain: auth.hostedDomain), tier: assist?.currentTier?.id, email: auth.email,
+ organization: auth.hostedDomain)
+ }
+
+ static func planName(_ assist: GeminiAPI.LoadCodeAssistResponse?, hostedDomain: String?) -> String {
+ if let paid = assist?.paidTier?.name, !paid.isEmpty { return paid }
+ switch assist?.currentTier?.id {
+ case "standard-tier": return "Standard"
+ case "legacy-tier": return "Legacy"
+ case "free-tier": return hostedDomain == nil ? "Free" : "Workspace"
+ default: return assist?.currentTier?.name ?? "Gemini"
+ }
+ }
+
+ static func credits(_ assist: GeminiAPI.LoadCodeAssistResponse?) -> CreditBalance? {
+ let credits = (assist?.paidTier?.availableCredits ?? []) + (assist?.currentTier?.availableCredits ?? [])
+ let total = credits.compactMap { $0.creditAmount.flatMap(Double.init) }.reduce(0, +)
+ guard !credits.isEmpty else { return nil }
+ return CreditBalance(balance: Decimal(total), hasCredits: total > 0)
+ }
+
+ static func windows(_ quota: GeminiAPI.QuotaResponse) -> [QuotaWindow] {
+ var lowest: [String: GeminiAPI.Bucket] = [:]
+ var order: [String] = []
+ for bucket in quota.buckets ?? [] {
+ guard let model = bucket.modelId, let fraction = bucket.remainingFraction else { continue }
+ if lowest[model] == nil { order.append(model) }
+ if (lowest[model]?.remainingFraction ?? 2) > fraction { lowest[model] = bucket }
+ }
+ return order.map { model in
+ let bucket = lowest[model]!
+ return QuotaWindow(
+ id: "model:\(model)", label: modelLabel(model), group: .other,
+ usedPercent: (1 - bucket.remainingFraction!) * 100, resetsAt: ISODate.parse(bucket.resetTime), duration: 86400,
+ scope: model)
+ }
+ }
+
+ static func modelLabel(_ model: String) -> String {
+ model.split(separator: "-").map { part in
+ part.first?.isNumber == true ? String(part) : part.prefix(1).uppercased() + part.dropFirst()
+ }.joined(separator: " ")
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Gemini/GeminiOAuthConfig.swift b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiOAuthConfig.swift
new file mode 100644
index 0000000..1211559
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiOAuthConfig.swift
@@ -0,0 +1,60 @@
+import Foundation
+
+public struct GeminiOAuthClient: Sendable, Equatable {
+ public let id: String
+ public let secret: String
+
+ public init(id: String, secret: String) {
+ self.id = id
+ self.secret = secret
+ }
+}
+
+/// Refreshing a Gemini token needs the installed CLI's own installed-app OAuth client. Those values belong to the
+/// Gemini CLI, not to this app, so they are read from the installed copy at runtime rather than vendored here.
+public enum GeminiOAuthConfig {
+ public static let relativePaths = [
+ "node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js",
+ "lib/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js",
+ "lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js",
+ "lib/node_modules/@google/gemini-cli/dist/gemini.js",
+ ]
+
+ public static func searchRoots(environment: [String: String], home: URL) -> [URL] {
+ var roots = [
+ home.appendingPathComponent(".npm-global"), URL(fileURLWithPath: "/opt/homebrew"),
+ URL(fileURLWithPath: "/usr/local"),
+ ]
+ if let prefix = environment["NPM_CONFIG_PREFIX"] { roots.insert(URL(fileURLWithPath: prefix), at: 0) }
+ return roots
+ }
+
+ public static func extract(from source: String) -> GeminiOAuthClient? {
+ guard let id = value(of: "OAUTH_CLIENT_ID", in: source), let secret = value(of: "OAUTH_CLIENT_SECRET", in: source)
+ else { return nil }
+ return GeminiOAuthClient(id: id, secret: secret)
+ }
+
+ static func value(of name: String, in source: String) -> String? {
+ let pattern = try! Regex("\(name)\\s*=\\s*['\"]([^'\"]+)['\"]")
+ guard let match = try? pattern.firstMatch(in: source), let range = match[1].range else { return nil }
+ return String(source[range])
+ }
+
+ public static func resolve(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ home: URL = FileManager.default.homeDirectoryForCurrentUser,
+ read: (URL) -> String? = { try? String(contentsOf: $0, encoding: .utf8) }
+ ) -> GeminiOAuthClient? {
+ if let id = environment["GEMINI_OAUTH_CLIENT_ID"], let secret = environment["GEMINI_OAUTH_CLIENT_SECRET"] {
+ return GeminiOAuthClient(id: id, secret: secret)
+ }
+ for root in searchRoots(environment: environment, home: home) {
+ for path in relativePaths {
+ guard let source = read(root.appendingPathComponent(path)), let client = extract(from: source) else { continue }
+ return client
+ }
+ }
+ return nil
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/Gemini/GeminiProvider.swift b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiProvider.swift
new file mode 100644
index 0000000..ff9f390
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiProvider.swift
@@ -0,0 +1,229 @@
+import Foundation
+
+public actor GeminiProvider: UsageProvider {
+ public nonisolated let id: ProviderID = .gemini
+ public nonisolated let pollingPolicy = PollingPolicy.defaults(for: .gemini)
+ private let auth: any GeminiAuthStore
+ private let client: APIClient
+ private let log: LogBuffer
+ private let allowRefresh: @MainActor @Sendable () -> Bool
+ private let oauthClient: @Sendable () -> GeminiOAuthClient?
+ private var cachedAssist: (fingerprint: String, assist: GeminiAPI.LoadCodeAssistResponse, at: Date)?
+ private var pendingCredentialSave: PendingCredentialSave?
+
+ public init(
+ auth: any GeminiAuthStore,
+ client: APIClient,
+ log: LogBuffer,
+ allowRefresh: @escaping @MainActor @Sendable () -> Bool,
+ oauthClient: @escaping @Sendable () -> GeminiOAuthClient? = { GeminiOAuthConfig.resolve() }
+ ) {
+ self.auth = auth
+ self.client = client
+ self.log = log
+ self.allowRefresh = allowRefresh
+ self.oauthClient = oauthClient
+ }
+
+ public nonisolated var credentialDescription: String {
+ auth.description
+ }
+
+ public nonisolated func credentialState(now: Date) -> CredentialState {
+ do {
+ guard let stored = try auth.load() else { return .missing("no Gemini CLI sign-in found") }
+ return stored.state(now: now)
+ } catch {
+ return .missing("\(error)")
+ }
+ }
+
+ public func credentialHealth(now: Date) async -> ProviderCredentialHealth {
+ if let pendingCredentialSave {
+ return .from(
+ pendingCredentialSave.credential.state(now: now), source: pendingCredentialSave.source,
+ expected: id.setup.credentialSources)
+ }
+ return auth.credentialHealth(now: now)
+ }
+
+ public func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult {
+ let resolved: ResolvedCredential
+ do {
+ resolved = try resolveCredential(
+ pending: &pendingCredentialSave,
+ provider: id,
+ load: { try auth.loadWithSource().map { (credential: $0.auth, source: $0.source) } },
+ save: { try auth.save($0, replacing: $1) })
+ guard resolved.credential != nil else {
+ return ProviderFetchResult(outcome: .notAuthenticated("No Gemini credentials. \(id.loginHint)"))
+ .withCredentialStatus(.missing("no Gemini CLI sign-in found", provider: id))
+ }
+ } catch {
+ return ProviderFetchResult(outcome: .notAuthenticated("Cannot read Gemini credentials: \(error)"))
+ .withCredentialStatus(.unreadable(error, provider: id, fallbackSource: auth.source))
+ }
+ let stored = resolved.credential!
+ var recoveryIssue = resolved.issue
+ var active = stored
+ var activeSource = resolved.source!
+ if case .expired = stored.state(now: now) {
+ guard await allowRefresh() else {
+ return ProviderFetchResult(outcome: .notAuthenticated("Gemini token expired. \(id.loginHint)"))
+ .withCredentialStatus(
+ .resolved(stored.state(now: now), provider: id, source: activeSource))
+ }
+ do {
+ let refreshed = try await refresh(stored, source: activeSource, now: now)
+ active = refreshed.credential
+ activeSource = refreshed.source
+ recoveryIssue = refreshed.issue
+ } catch {
+ return ProviderFetchResult(
+ outcome: .notAuthenticated("Gemini token refresh failed: \(error.message)")
+ )
+ .withCredentialStatus(
+ .resolved(stored.state(now: now), provider: id, source: activeSource))
+ }
+ }
+ let credentialStatus = ProviderCredentialStatus.resolved(
+ active.state(now: now), provider: id, source: activeSource)
+ let fingerprint = active.cacheFingerprint
+ let headers = GeminiAPI.headers(token: active.accessToken)
+ var warnings: [String] = []
+ let assist: GeminiAPI.LoadCodeAssistResponse?
+ do {
+ assist = try await loadCodeAssist(headers: headers, fingerprint: fingerprint, now: now)
+ } catch {
+ if error.isAuthenticationFailure || error.isRateLimited {
+ return ProviderFetchResult(
+ outcome: ProviderOutcomeBuilder.outcome(for: error, hint: id.loginHint), recoveryIssue: recoveryIssue
+ ).withCredentialStatus(credentialStatus)
+ }
+ assist = nil
+ warnings.append("Plan details unavailable: \(error.message)")
+ }
+ if let reason = assist?.unsupportedReason {
+ return ProviderFetchResult(
+ outcome: .notAuthenticated(reason),
+ recoveryIssue: .unsupportedAccount(provider: id, detail: reason)
+ ).withCredentialStatus(credentialStatus)
+ }
+ let quota: GeminiAPI.QuotaResponse
+ do {
+ let data = try await client.post(
+ GeminiAPI.quotaURL, json: GeminiAPI.quotaBody(project: assist?.projectID), headers: headers,
+ operation: "gemini.quota")
+ quota = try client.decode(GeminiAPI.QuotaResponse.self, data, operation: "gemini.quota")
+ } catch {
+ if case .http(let status, let body, _) = error, status == 403, body.uppercased().contains("SUBSCRIPTION_REQUIRED")
+ {
+ return ProviderFetchResult(
+ outcome: .notAuthenticated(GeminiAPI.unsupportedClientMessage),
+ recoveryIssue: .unsupportedAccount(provider: id, detail: GeminiAPI.unsupportedClientMessage)
+ ).withCredentialStatus(credentialStatus)
+ }
+ return ProviderFetchResult(
+ outcome: ProviderOutcomeBuilder.outcome(for: error, hint: id.loginHint), recoveryIssue: recoveryIssue
+ ).withCredentialStatus(credentialStatus)
+ }
+ let snapshot = ProviderSnapshot(
+ provider: .gemini,
+ identity: GeminiMapper.identity(assist, auth: active),
+ windows: GeminiMapper.windows(quota),
+ credits: GeminiMapper.credits(assist),
+ fetchedAt: now
+ )
+ return ProviderFetchResult(outcome: .success(snapshot), warnings: warnings, recoveryIssue: recoveryIssue)
+ .withCredentialStatus(credentialStatus)
+ }
+
+ private func loadCodeAssist(
+ headers: [String: String], fingerprint: String, now: Date
+ ) async throws(APIError)
+ -> GeminiAPI.LoadCodeAssistResponse
+ {
+ if let cachedAssist, cachedAssist.fingerprint == fingerprint, now.timeIntervalSince(cachedAssist.at) < 3600 {
+ return cachedAssist.assist
+ }
+ let data = try await client.post(
+ GeminiAPI.loadCodeAssistURL, json: GeminiAPI.loadCodeAssistBody, headers: headers,
+ operation: "gemini.load-code-assist")
+ let response = try client.decode(GeminiAPI.LoadCodeAssistResponse.self, data, operation: "gemini.load-code-assist")
+ cachedAssist = (fingerprint, response, now)
+ return response
+ }
+
+ private func refresh(
+ _ stored: GeminiAuth,
+ source: CredentialSource,
+ now: Date
+ ) async throws(GeminiRefreshError) -> (
+ credential: GeminiAuth,
+ source: CredentialSource,
+ issue: ProviderRecoveryIssue?
+ ) {
+ guard let refreshToken = stored.refreshToken else {
+ throw GeminiRefreshError.noRefreshToken
+ }
+ guard let oauth = oauthClient() else {
+ throw GeminiRefreshError.oauthClientUnavailable
+ }
+ let token: GeminiAPI.TokenResponse
+ do {
+ let data = try await client.post(
+ GeminiAPI.tokenURL,
+ form: [
+ "client_id": oauth.id, "client_secret": oauth.secret, "grant_type": "refresh_token",
+ "refresh_token": refreshToken,
+ ], headers: [:], operation: "gemini.refresh")
+ token = try client.decode(GeminiAPI.TokenResponse.self, data, operation: "gemini.refresh")
+ } catch {
+ throw GeminiRefreshError.api(error)
+ }
+ guard let accessToken = token.accessToken else {
+ throw GeminiRefreshError.api(
+ APIError.http(
+ status: 401, body: token.errorDescription ?? token.error ?? "refresh returned no access token",
+ retryAfter: nil))
+ }
+ let refreshed = stored.refreshed(
+ accessToken: accessToken, expiresIn: token.expiresIn ?? 3600, idToken: token.idToken, now: now)
+ let saveResult: CredentialSaveResult
+ do {
+ saveResult = try auth.save(refreshed, replacing: stored)
+ } catch {
+ log.logError("gemini token refreshed but could not be stored: \(error)")
+ let detail = credentialPersistenceDetail(error)
+ pendingCredentialSave = PendingCredentialSave(
+ credential: refreshed, replacing: stored, source: source, detail: detail)
+ return (refreshed, source, .credentialPersistence(provider: id, detail: detail))
+ }
+ switch saveResult {
+ case .saved:
+ log.log("gemini token refreshed and stored")
+ case .changed(let current, let currentSource):
+ log.log("gemini token refresh not stored because the credential source changed")
+ guard let current else {
+ throw GeminiRefreshError.api(
+ APIError.http(status: 401, body: "credentials were removed during refresh", retryAfter: nil))
+ }
+ return (current, currentSource!, nil)
+ }
+ return (refreshed, source, nil)
+ }
+}
+
+private enum GeminiRefreshError: Error {
+ case api(APIError)
+ case noRefreshToken
+ case oauthClientUnavailable
+
+ var message: String {
+ switch self {
+ case .api(let error): error.message
+ case .noRefreshToken: "no refresh token"
+ case .oauthClientUnavailable: "the Gemini CLI OAuth client could not be read from the installed CLI"
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift b/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift
new file mode 100644
index 0000000..ddffaaa
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift
@@ -0,0 +1,160 @@
+import Foundation
+
+public enum ProviderRegistryFactory {
+ public struct Configuration: Sendable {
+ public let home: URL
+ public let supportDirectory: URL
+ public let environment: [String: String]
+ public let userName: String
+ public let resourceURLs: [String: URL]
+ public let resourceAccess: [ProviderID: [ResourceAccessState]]
+ public let resourceLeases: [SecurityScopedResourceLease]
+ public let enabledProviders: Set
+ public let keychain: KeychainCredentialClient
+ public let allowTokenRefresh: @MainActor @Sendable () -> Bool
+
+ public init(
+ home: URL,
+ supportDirectory: URL,
+ environment: [String: String],
+ userName: String,
+ resourceURLs: [String: URL],
+ resourceAccess: [ProviderID: [ResourceAccessState]] = [:],
+ resourceLeases: [SecurityScopedResourceLease] = [],
+ enabledProviders: Set,
+ keychain: KeychainCredentialClient,
+ allowTokenRefresh: @escaping @MainActor @Sendable () -> Bool
+ ) {
+ self.home = home
+ self.supportDirectory = supportDirectory
+ self.environment = environment
+ self.userName = userName
+ self.resourceURLs = resourceURLs
+ self.resourceAccess = resourceAccess
+ self.resourceLeases = resourceLeases
+ self.enabledProviders = enabledProviders
+ self.keychain = keychain
+ self.allowTokenRefresh = allowTokenRefresh
+ }
+
+ fileprivate func url(for resource: SandboxResource) -> URL {
+ resourceURLs[resource.id] ?? resource.configuredURL(environment: environment, home: home)
+ }
+ }
+
+ public static func resourcesRequiringSandboxAccess(environment: [String: String]) -> Set {
+ let copilotEnvironment = EnvironmentCopilotAuthStore(environment: environment)
+ let copilotSource = (try? copilotEnvironment.load()).map { _ in copilotEnvironment.source }
+ return Set(
+ ProviderID.allCases.flatMap { provider in
+ provider.needsSandboxResources(for: provider == .copilot ? copilotSource : nil)
+ ? provider.sandboxResources
+ : []
+ })
+ }
+
+ public static func make(
+ configuration: Configuration,
+ client: APIClient,
+ log: LogBuffer
+ ) -> ProviderRegistry {
+ let claudeHome = configuration.url(for: ProviderID.claude.sandboxResources[0])
+ let claudeCredentials = ChainedClaudeCredentialStore([
+ KeychainClaudeCredentialStore(
+ service: ClaudeOAuthCredentials.keychainService(
+ configDir: configuration.environment["CLAUDE_CONFIG_DIR"]),
+ account: configuration.userName,
+ keychain: configuration.keychain),
+ FileClaudeCredentialStore(url: claudeHome.appendingPathComponent(".credentials.json")),
+ ])
+ let claude = ClaudeProvider(
+ credentials: claudeCredentials,
+ localAccountURL: configuration.url(for: ProviderID.claude.sandboxResources[1]),
+ transcripts: ClaudeTranscriptReader(
+ root: claudeHome.appendingPathComponent("projects"),
+ stateURL: configuration.supportDirectory.appendingPathComponent("claude-transcript-offsets.json")),
+ client: client,
+ log: log,
+ allowRefresh: configuration.allowTokenRefresh
+ )
+
+ let codexHome = configuration.url(for: ProviderID.codex.sandboxResources[0])
+ let codexFile = FileCodexAuthStore(url: codexHome.appendingPathComponent("auth.json"))
+ let codexAuth: any CodexAuthStore =
+ switch CodexCredentialStorageReader.load(from: codexHome.appendingPathComponent("config.toml")) {
+ case .file: codexFile
+ case .automatic, .keyring, .unknown:
+ ChainedCodexAuthStore([
+ KeychainCodexAuthStore(
+ account: KeychainCodexAuthStore.account(codexHome: codexHome), keychain: configuration.keychain),
+ codexFile,
+ ])
+ }
+ let codex = CodexProvider(
+ auth: codexAuth,
+ rollouts: CodexRolloutReader(sessionsRoot: codexHome.appendingPathComponent("sessions")),
+ client: client,
+ log: log,
+ allowRefresh: configuration.allowTokenRefresh
+ )
+
+ let geminiHome = configuration.url(for: ProviderID.gemini.sandboxResources[0])
+ let geminiFile = FileGeminiAuthStore(url: geminiHome.appendingPathComponent("oauth_creds.json"))
+ let geminiAuth: any GeminiAuthStore =
+ switch GeminiCredentialStorage.resolve(environment: configuration.environment) {
+ case .file: geminiFile
+ case .keychain:
+ ChainedGeminiAuthStore([
+ KeychainGeminiAuthStore(service: KeychainGeminiAuthStore.service, keychain: configuration.keychain),
+ geminiFile,
+ ])
+ }
+ let gemini = GeminiProvider(
+ auth: geminiAuth,
+ client: client,
+ log: log,
+ allowRefresh: configuration.allowTokenRefresh,
+ oauthClient: { GeminiOAuthConfig.resolve(environment: configuration.environment, home: configuration.home) }
+ )
+
+ let cursorAuth = ChainedCursorAuthStore([
+ CursorStateStore(
+ url: configuration.url(for: ProviderID.cursor.sandboxResources[0])
+ .appendingPathComponent("User/globalStorage/state.vscdb")),
+ FileCursorAuthStore(
+ url: configuration.url(for: ProviderID.cursor.sandboxResources[1]).appendingPathComponent("auth.json")),
+ ])
+ let cursor = CursorProvider(auth: cursorAuth, client: client, log: log)
+
+ let copilotHome = configuration.url(for: ProviderID.copilot.sandboxResources[0])
+ let copilotLegacy = configuration.url(for: ProviderID.copilot.sandboxResources[1])
+ let copilotConfig = FileCopilotCLIAuthStore(url: copilotHome.appendingPathComponent("config.json"))
+ let copilotAuth = ChainedCopilotAuthStore([
+ EnvironmentCopilotAuthStore(environment: configuration.environment),
+ KeychainCopilotAuthStore(
+ service: KeychainCopilotAuthStore.service,
+ accounts: copilotConfig.keychainAccounts(),
+ keychain: configuration.keychain),
+ copilotConfig,
+ FileCopilotAuthStore(
+ urls: ["hosts.json", "apps.json"].map { copilotLegacy.appendingPathComponent($0) }),
+ ])
+ let copilot = CopilotProvider(auth: copilotAuth, client: client, log: log)
+
+ let setupStates = Dictionary(
+ uniqueKeysWithValues: ProviderID.allCases.map { provider in
+ (
+ provider,
+ ProviderSetupState.from(
+ provider: provider,
+ enabled: configuration.enabledProviders.contains(provider),
+ credential: .unchecked,
+ resources: configuration.resourceAccess[provider] ?? [])
+ )
+ })
+ return ProviderRegistry(
+ [claude, codex, gemini, cursor, copilot],
+ setupStates: setupStates,
+ resourceLeases: configuration.resourceLeases)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Providers/UsageProvider.swift b/Sources/TokenMenuBarCore/Providers/UsageProvider.swift
new file mode 100644
index 0000000..e6efbca
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Providers/UsageProvider.swift
@@ -0,0 +1,86 @@
+import Foundation
+
+public struct FetchOptions: Sendable, Equatable {
+ public let includeAnalytics: Bool
+ public let analyticsDays: Int
+
+ public init(includeAnalytics: Bool = false, analyticsDays: Int = 30) {
+ self.includeAnalytics = includeAnalytics
+ self.analyticsDays = analyticsDays
+ }
+}
+
+public struct PollingPolicy: Sendable, Equatable {
+ public let minimumInterval: TimeInterval
+ public let activeInterval: TimeInterval
+ public let defaultInterval: TimeInterval
+
+ public init(minimumInterval: TimeInterval, activeInterval: TimeInterval, defaultInterval: TimeInterval) {
+ self.minimumInterval = minimumInterval
+ self.activeInterval = activeInterval
+ self.defaultInterval = defaultInterval
+ }
+
+ public func interval(active: Bool, requested: TimeInterval) -> TimeInterval {
+ max(active ? min(requested, activeInterval) : requested, minimumInterval)
+ }
+
+ public static func defaults(for provider: ProviderID) -> PollingPolicy {
+ switch provider {
+ case .claude: PollingPolicy(minimumInterval: 120, activeInterval: 120, defaultInterval: 300)
+ case .codex: PollingPolicy(minimumInterval: 60, activeInterval: 60, defaultInterval: 120)
+ case .gemini: PollingPolicy(minimumInterval: 60, activeInterval: 60, defaultInterval: 120)
+ case .cursor, .copilot: PollingPolicy(minimumInterval: 60, activeInterval: 60, defaultInterval: 300)
+ }
+ }
+}
+
+public protocol UsageProvider: Sendable {
+ var id: ProviderID { get }
+ var credentialDescription: String { get }
+ var pollingPolicy: PollingPolicy { get }
+ var supportsAnalytics: Bool { get }
+ func credentialState(now: Date) -> CredentialState
+ func credentialHealth(now: Date) async -> ProviderCredentialHealth
+ func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult
+}
+
+public extension UsageProvider {
+ var supportsAnalytics: Bool { id == .claude || id == .codex }
+ func credentialHealth(now: Date) async -> ProviderCredentialHealth { .unchecked }
+}
+
+public struct ProviderRegistry: Sendable {
+ public let providers: [any UsageProvider]
+ public let setupStates: [ProviderID: ProviderSetupState]
+ private let resourceLeases: [SecurityScopedResourceLease]
+
+ public init(
+ _ providers: [any UsageProvider],
+ setupStates: [ProviderID: ProviderSetupState] = [:],
+ resourceLeases: [SecurityScopedResourceLease] = []
+ ) {
+ self.providers = providers.sorted { $0.id < $1.id }
+ self.setupStates = setupStates
+ self.resourceLeases = resourceLeases
+ }
+
+ public subscript(id: ProviderID) -> (any UsageProvider)? {
+ providers.first { $0.id == id }
+ }
+
+ public var ids: [ProviderID] {
+ providers.map(\.id)
+ }
+}
+
+public enum ProviderOutcomeBuilder {
+ public static func outcome(for error: APIError, hint: String) -> ProviderFetchOutcome {
+ switch error {
+ case .network(let text): .networkUnavailable(text)
+ case .http where error.isAuthenticationFailure: .notAuthenticated("\(error.message). \(hint)")
+ case .http where error.isRateLimited: .rateLimited(error.message, retryAfter: error.retryAfter)
+ default: .failed(error.message)
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Refresh/AppState.swift b/Sources/TokenMenuBarCore/Refresh/AppState.swift
new file mode 100644
index 0000000..178f088
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Refresh/AppState.swift
@@ -0,0 +1,311 @@
+import Foundation
+import Observation
+
+public struct ProviderState: Sendable, Equatable {
+ public var snapshot: ProviderSnapshot?
+ public var analytics: ProviderAnalytics?
+ public var availability: QuotaAvailability
+ public var lastError: String?
+ public var warnings: [String]
+ public var lastAttempt: Date?
+ public var lastAnalyticsAttempt: Date?
+ public var lastSuccess: Date?
+ public var retryNotBefore: Date?
+ public var credentialState: CredentialState?
+ public var credentialHealth: ProviderCredentialHealth
+ public var serviceHealth: ProviderServiceHealth
+ public var resourceAccess: [ResourceAccessState]
+ public var recoveryIssue: ProviderRecoveryIssue?
+ public var isRefreshing: Bool
+
+ public init(
+ snapshot: ProviderSnapshot? = nil,
+ analytics: ProviderAnalytics? = nil,
+ availability: QuotaAvailability = .loading,
+ lastError: String? = nil,
+ warnings: [String] = [],
+ lastAttempt: Date? = nil,
+ lastAnalyticsAttempt: Date? = nil,
+ lastSuccess: Date? = nil,
+ retryNotBefore: Date? = nil,
+ credentialState: CredentialState? = nil,
+ credentialHealth: ProviderCredentialHealth = .unchecked,
+ serviceHealth: ProviderServiceHealth = .unchecked,
+ resourceAccess: [ResourceAccessState] = [],
+ recoveryIssue: ProviderRecoveryIssue? = nil,
+ isRefreshing: Bool = false
+ ) {
+ self.snapshot = snapshot
+ self.analytics = analytics
+ self.availability = availability
+ self.lastError = lastError
+ self.warnings = warnings
+ self.lastAttempt = lastAttempt
+ self.lastAnalyticsAttempt = lastAnalyticsAttempt
+ self.lastSuccess = lastSuccess
+ self.retryNotBefore = retryNotBefore
+ self.credentialState = credentialState
+ self.credentialHealth = credentialHealth
+ self.serviceHealth = serviceHealth
+ self.resourceAccess = resourceAccess
+ self.recoveryIssue = recoveryIssue
+ self.isRefreshing = isRefreshing
+ }
+
+ public var isStale: Bool {
+ snapshot != nil && availability != .current
+ }
+}
+
+@MainActor
+@Observable
+public final class AppState {
+ public private(set) var providers: [ProviderID: ProviderState] = [:]
+ private var providerSetups: [ProviderID: ProviderSetupState] = [:]
+ public private(set) var statusModel: StatusItemModel = .empty
+ public private(set) var statusLadder: [StatusItemModel] = [.empty]
+ public private(set) var sampleRevision: UInt64 = 0
+ public private(set) var historyRevision: UInt64 = 0
+ public private(set) var lastRefresh: Date?
+ public private(set) var nextRefreshAt: Date?
+ public private(set) var isRefreshing = false
+ public var popoverVisible = false
+
+ public init() {}
+
+ public func state(for provider: ProviderID) -> ProviderState {
+ providers[provider] ?? ProviderState()
+ }
+
+ public var snapshots: [ProviderID: ProviderSnapshot] {
+ providers.compactMapValues(\.snapshot)
+ }
+
+ public var availability: [ProviderID: QuotaAvailability] {
+ providers.mapValues(\.availability)
+ }
+
+ public var orderedProviders: [ProviderID] {
+ providers.keys.sorted()
+ }
+
+ public func update(_ provider: ProviderID, _ mutate: (inout ProviderState) -> Void) {
+ let state = updatedState(provider, mutate)
+ // Observation fires on assignment rather than on change, so writing an identical state redraws every view that
+ // reads it. The refresh tick rewrites disabled providers every minute.
+ guard providers[provider] != state else { return }
+ providers[provider] = state
+ }
+
+ func applyProviderStates(_ states: [ProviderID: ProviderState]) {
+ var next = providers
+ for (provider, state) in states {
+ next[provider] = updatedState(provider, in: next) { $0 = state }
+ }
+ if providers != next { providers = next }
+ }
+
+ func beginRefreshing(_ refreshing: [ProviderID], disabling: [ProviderID]) {
+ var next = providers
+ for provider in disabling {
+ next[provider] = updatedState(provider, in: next) { $0 = ProviderState(availability: .disabled) }
+ }
+ for provider in refreshing {
+ next[provider] = updatedState(provider, in: next) { $0.isRefreshing = true }
+ }
+ if providers != next { providers = next }
+ setRefreshing(true, at: nil)
+ }
+
+ func disable(_ disabled: [ProviderID]) {
+ var next = providers
+ for provider in disabled {
+ next[provider] = updatedState(provider, in: next) { $0 = ProviderState(availability: .disabled) }
+ }
+ if providers != next { providers = next }
+ }
+
+ func finishRefreshing(_ refreshed: [ProviderID], at date: Date?) {
+ var next = providers
+ for provider in refreshed {
+ next[provider] = updatedState(provider, in: next) { $0.isRefreshing = false }
+ }
+ if providers != next { providers = next }
+ setRefreshing(false, at: date)
+ }
+
+ public func applySetupStates(_ setups: [ProviderID: ProviderSetupState]) {
+ providerSetups = setups
+ var next = providers
+ for (provider, setup) in setups {
+ next[provider] = updatedState(provider, in: next) { state in
+ let resourcesChanged = state.resourceAccess != setup.resources
+ guard case .unchecked = setup.credential else {
+ let credentialChanged = state.credentialHealth != setup.credential
+ state.credentialHealth = setup.credential
+ state.credentialState = Self.credentialState(for: setup.credential, provider: provider)
+ if credentialChanged {
+ state.recoveryIssue = nil
+ if setup.credential.isUsable, state.availability == .authenticationRequired {
+ state.availability = state.snapshot == nil ? .loading : .stale
+ state.lastError = nil
+ }
+ } else if resourcesChanged, state.recoveryIssue?.kind == .resourceAccess {
+ state.recoveryIssue = nil
+ }
+ return
+ }
+ if resourcesChanged, state.recoveryIssue?.kind == .resourceAccess {
+ state.recoveryIssue = nil
+ }
+ }
+ }
+ if providers != next { providers = next }
+ }
+
+ public func setStatusLadder(_ ladder: [StatusItemModel]) {
+ let models = ladder.isEmpty ? [.empty] : ladder
+ if models != statusLadder { statusLadder = models }
+ if models[0] != statusModel { statusModel = models[0] }
+ }
+
+ public func markSamplesChanged() {
+ sampleRevision &+= 1
+ markHistoryChanged()
+ }
+
+ public func markHistoryChanged() {
+ historyRevision &+= 1
+ }
+
+ public func setRefreshing(_ refreshing: Bool, at date: Date?) {
+ if isRefreshing != refreshing { isRefreshing = refreshing }
+ if let date { lastRefresh = date }
+ }
+
+ public func setNextRefresh(_ date: Date?) {
+ if nextRefreshAt != date { nextRefreshAt = date }
+ }
+
+ public func cancelRefreshing() {
+ finishRefreshing(Array(providers.keys), at: nil)
+ }
+
+ public func remove(_ provider: ProviderID) {
+ removeProviders([provider])
+ }
+
+ func removeProviders(_ removed: Set) {
+ var next = providers
+ for provider in removed {
+ next[provider] = nil
+ providerSetups[provider] = nil
+ }
+ if providers != next { providers = next }
+ }
+
+ private func updatedState(
+ _ provider: ProviderID,
+ in states: [ProviderID: ProviderState]? = nil,
+ _ mutate: (inout ProviderState) -> Void
+ ) -> ProviderState {
+ var state = states?[provider] ?? providers[provider] ?? ProviderState()
+ let previous = state
+ mutate(&state)
+ if state.availability == .disabled {
+ state.snapshot = state.snapshot ?? previous.snapshot
+ state.analytics = state.analytics ?? previous.analytics
+ state.lastSuccess = state.lastSuccess ?? previous.lastSuccess
+ state.lastError = state.lastError ?? previous.lastError
+ if state.warnings.isEmpty { state.warnings = previous.warnings }
+ state.credentialState = state.credentialState ?? previous.credentialState
+ if case .unchecked = state.credentialHealth { state.credentialHealth = previous.credentialHealth }
+ }
+ if let setup = providerSetups[provider] { merge(setup, provider: provider, into: &state) }
+ return state
+ }
+
+ private static func credentialState(
+ for health: ProviderCredentialHealth,
+ provider: ProviderID
+ ) -> CredentialState? {
+ switch health {
+ case .unchecked: nil
+ case .missing: .missing(provider.setup.signInDetail)
+ case .valid(_, let expiresAt): .valid(expiresAt: expiresAt)
+ case .expired(_, let date): .expired(date)
+ case .unreadable(_, let detail): .missing(detail)
+ }
+ }
+
+ private func merge(_ setup: ProviderSetupState, provider: ProviderID, into state: inout ProviderState) {
+ state.resourceAccess = setup.resources
+ state.serviceHealth = .from(
+ availability: state.availability, detail: state.lastError, retryAt: state.retryNotBefore)
+ if case .unchecked = state.credentialHealth, let credentialState = state.credentialState {
+ let source = setup.credential.source ?? provider.setup.credentialSources[0]
+ switch credentialState {
+ case .missing:
+ if case .unreadable = setup.credential {
+ state.credentialHealth = setup.credential
+ } else {
+ state.credentialHealth = .missing(expected: provider.setup.credentialSources)
+ }
+ case .expired(let date):
+ state.credentialHealth = .expired(source: source, at: date)
+ case .valid(let expiresAt):
+ state.credentialHealth = .valid(source: source, expiresAt: expiresAt)
+ }
+ } else if case .unchecked = state.credentialHealth {
+ state.credentialHealth = setup.credential
+ }
+ state.recoveryIssue = recoveryIssue(
+ setup: setup, provider: provider, state: state, providerIssue: state.recoveryIssue)
+ }
+
+ private func recoveryIssue(
+ setup: ProviderSetupState,
+ provider: ProviderID,
+ state: ProviderState,
+ providerIssue: ProviderRecoveryIssue?
+ ) -> ProviderRecoveryIssue? {
+ guard state.availability != .disabled else { return nil }
+ if let providerIssue { return providerIssue }
+ let credentialIssue = ProviderSetupState.from(
+ provider: provider, enabled: true, credential: state.credentialHealth, resources: setup.resources
+ ).issue
+ switch state.availability {
+ case .authenticationRequired:
+ if let resource = setup.resources.first(where: { $0.isRequired && $0.health != .granted }) {
+ return ProviderRecoveryIssue(
+ kind: .resourceAccess,
+ title: resource.health == .stale ? "Access grant needs renewal" : "File access needed",
+ detail: "Grant access to \(resource.resource.label) so \(provider.displayName) data can be read.",
+ action: .grantAccess(resource.resource))
+ }
+ return credentialIssue ?? setup.issue ?? provider.setup.missingCredentialIssue
+ case .networkUnavailable:
+ return ProviderRecoveryIssue(
+ kind: .network,
+ title: "\(provider.displayName) is offline",
+ detail: state.lastError ?? "The provider could not be reached.",
+ action: .refreshProvider(provider))
+ case .rateLimited:
+ return ProviderRecoveryIssue(
+ kind: .rateLimited,
+ title: "\(provider.displayName) is rate limited",
+ detail: state.lastError ?? "Token Menu Bar will retry after the provider allows another request.",
+ action: .refreshProvider(provider))
+ case .unavailable:
+ return ProviderRecoveryIssue(
+ kind: .service,
+ title: "\(provider.displayName) is unavailable",
+ detail: state.lastError ?? "The provider did not return usable data.",
+ action: .refreshProvider(provider))
+ case .loading:
+ return state.credentialHealth.isUsable ? nil : credentialIssue ?? setup.issue
+ case .current, .stale, .disabled:
+ return nil
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift b/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift
new file mode 100644
index 0000000..536d65b
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift
@@ -0,0 +1,708 @@
+import Foundation
+import Observation
+
+public enum RefreshReason: Sendable, Equatable {
+ case scheduled
+ case popoverOpened
+ case userInitiated
+ case export
+}
+
+public enum RefreshPolicy: Int, Sendable, Equatable {
+ case skip
+ case ifDue
+ case force
+}
+
+public struct RefreshRequest: Sendable, Equatable {
+ public var reason: RefreshReason
+ public var usage: RefreshPolicy
+ public var analytics: RefreshPolicy
+ public var providers: Set?
+
+ public init(
+ reason: RefreshReason = .scheduled,
+ usage: RefreshPolicy = .ifDue,
+ analytics: RefreshPolicy = .ifDue,
+ providers: Set? = nil
+ ) {
+ self.reason = reason
+ self.usage = usage
+ self.analytics = analytics
+ self.providers = providers
+ }
+
+ func merged(with other: RefreshRequest) -> RefreshRequest {
+ RefreshRequest(
+ reason: reason.priority >= other.reason.priority ? reason : other.reason,
+ usage: max(usage, other.usage),
+ analytics: max(analytics, other.analytics),
+ providers: Self.union(providers, other.providers))
+ }
+
+ func covers(_ other: RefreshRequest) -> Bool {
+ usage.rawValue >= other.usage.rawValue && analytics.rawValue >= other.analytics.rawValue
+ && Self.covers(providers, other.providers)
+ }
+
+ private static func union(_ lhs: Set?, _ rhs: Set?) -> Set? {
+ guard let lhs, let rhs else { return nil }
+ return lhs.union(rhs)
+ }
+
+ private static func covers(_ lhs: Set?, _ rhs: Set?) -> Bool {
+ guard let lhs else { return true }
+ guard let rhs else { return false }
+ return lhs.isSuperset(of: rhs)
+ }
+}
+
+private extension RefreshReason {
+ var priority: Int {
+ switch self {
+ case .scheduled: 0
+ case .popoverOpened: 1
+ case .userInitiated: 2
+ case .export: 3
+ }
+ }
+}
+
+private func max(_ lhs: RefreshPolicy, _ rhs: RefreshPolicy) -> RefreshPolicy {
+ lhs.rawValue >= rhs.rawValue ? lhs : rhs
+}
+
+private struct RefreshRun {
+ let generation: Int
+ let registry: ProviderRegistry
+ let task: Task
+}
+
+private struct ProviderRefreshPlan {
+ let provider: any UsageProvider
+ let includeAnalytics: Bool
+ let retryInterval: TimeInterval
+}
+
+private struct ProviderApplyResult {
+ let providerState: ProviderState?
+ let events: [NotificationEvent]
+ let samplesChanged: Bool
+ let analyticsChanged: Bool
+
+ static let empty = ProviderApplyResult(
+ providerState: nil, events: [], samplesChanged: false, analyticsChanged: false)
+}
+
+@MainActor
+public final class RefreshCoordinator {
+ public static let rateLimitBackoff: TimeInterval = 300
+ public static let minimumBackoff: TimeInterval = 60
+ public static let maximumBackoff: TimeInterval = 1800
+ public static let networkBackoff: TimeInterval = 60
+
+ public private(set) var registry: ProviderRegistry
+ private let settings: Settings
+ private let state: AppState
+ private let history: UsageHistoryStore
+ private let log: LogBuffer
+ private let clock: Clock
+ private let notify: @MainActor ([NotificationEvent]) -> Void
+ private var loop: Task?
+ private var refreshRun: RefreshRun?
+ private var activeRequest: RefreshRequest?
+ private var activeGeneration: Int?
+ private var pending: RefreshRequest?
+ private var rateLimitStrikes: [ProviderID: Int] = [:]
+ private var refreshGeneration = 0
+ private var registryGeneration = 0
+ private var lastWidget: WidgetSnapshot?
+ private let persistence: SnapshotPersistence
+ private var cacheSubmissionTask: Task?
+ public var widgetSink: ((WidgetSnapshot) -> Void)? {
+ didSet {
+ // The status model is built during init, before AppController attaches the sink, so publish what is already
+ // known rather than suppressing it as unchanged.
+ guard let widget = lastWidget else { return }
+ widgetSink?(widget)
+ }
+ }
+
+ public init(
+ registry: ProviderRegistry,
+ settings: Settings,
+ state: AppState,
+ history: UsageHistoryStore,
+ log: LogBuffer,
+ clock: Clock = .system,
+ cache: SnapshotCache = SnapshotCache(url: nil),
+ persistence: SnapshotPersistence? = nil,
+ notify: @escaping @MainActor ([NotificationEvent]) -> Void
+ ) {
+ self.registry = registry
+ self.settings = settings
+ self.state = state
+ self.history = history
+ self.log = log
+ self.clock = clock
+ self.persistence =
+ persistence
+ ?? SnapshotPersistence(
+ cache: cache,
+ failureHandler: { failure in log.logError(failure.message) })
+ self.notify = notify
+ rebuildStatus()
+ }
+
+ public func restoreCachedSnapshots() async {
+ let snapshots = await persistence.loadSnapshots()
+ var restored: [ProviderID: ProviderState] = [:]
+ for (provider, snapshot) in snapshots where registry[provider] != nil {
+ guard state.state(for: provider).snapshot == nil else { continue }
+ var providerState = state.state(for: provider)
+ providerState.snapshot = snapshot
+ providerState.availability = .stale
+ restored[provider] = providerState
+ }
+ if !restored.isEmpty {
+ state.applyProviderStates(restored)
+ rebuildStatus()
+ }
+ }
+
+ public var isRunning: Bool {
+ loop != nil
+ }
+
+ public func start() {
+ guard loop == nil else { return }
+ observeScheduleInputs()
+ loop = makeLoop()
+ }
+
+ public func stop() {
+ loop?.cancel()
+ loop = nil
+ refreshRun?.task.cancel()
+ refreshRun = nil
+ activeRequest = nil
+ activeGeneration = nil
+ pending = nil
+ state.setNextRefresh(nil)
+ state.cancelRefreshing()
+ }
+
+ public func refresh(_ request: RefreshRequest) async {
+ enqueue(request)
+ while pending != nil || refreshRun != nil {
+ if let run = refreshRun {
+ await run.task.value
+ if refreshRun?.generation == run.generation { refreshRun = nil }
+ continue
+ }
+ refreshGeneration += 1
+ let generation = refreshGeneration
+ let registry = registry
+ let registryGeneration = registryGeneration
+ let task = Task {
+ await drainRefreshes(generation: generation, registry: registry, registryGeneration: registryGeneration)
+ }
+ refreshRun = RefreshRun(generation: generation, registry: registry, task: task)
+ }
+ }
+
+ public func replaceRegistry(_ registry: ProviderRegistry) {
+ let previous = self.registry
+ self.registry = registry
+ registryChanged(from: previous)
+ }
+
+ private func registryChanged(from previous: ProviderRegistry) {
+ let removed = Set(previous.ids).subtracting(registry.ids)
+ registryGeneration += 1
+ refreshRun?.task.cancel()
+ state.cancelRefreshing()
+ state.applySetupStates(registry.setupStates)
+ state.removeProviders(removed)
+ rateLimitStrikes = rateLimitStrikes.filter { registry[$0.key] != nil }
+ reschedule()
+ }
+
+ public func reschedule() {
+ guard loop != nil else { return }
+ loop?.cancel()
+ loop = makeLoop()
+ }
+
+ public func nextRefreshDate(now: Date? = nil) -> Date? {
+ let now = now ?? clock.now()
+ return registry.providers.compactMap { provider -> Date? in
+ let providerState = state.state(for: provider.id)
+ guard settings.isProviderActive(provider.id, state: providerState) else { return nil }
+ if let retry = providerState.retryNotBefore { return retry > now ? retry : now }
+ let usage =
+ providerState.lastAttempt.map {
+ $0.addingTimeInterval(
+ provider.pollingPolicy.interval(
+ active: state.popoverVisible, requested: TimeInterval(settings.refreshInterval(for: provider.id))))
+ } ?? now
+ guard provider.supportsAnalytics else { return usage }
+ let analytics =
+ providerState.lastAnalyticsAttempt.map {
+ $0.addingTimeInterval(TimeInterval(settings.analyticsRefreshMinutes * 60))
+ } ?? now
+ return min(usage, analytics)
+ }.min()
+ }
+
+ private func enqueue(_ request: RefreshRequest) {
+ guard activeRequest?.covers(request) != true, pending?.covers(request) != true else { return }
+ pending = pending.map { $0.merged(with: request) } ?? request
+ }
+
+ private func drainRefreshes(
+ generation: Int,
+ registry: ProviderRegistry,
+ registryGeneration: Int
+ ) async {
+ while !Task.isCancelled, let request = pending {
+ pending = nil
+ activeRequest = request
+ activeGeneration = generation
+ await perform(request, registry: registry, registryGeneration: registryGeneration)
+ if activeGeneration == generation {
+ activeRequest = nil
+ activeGeneration = nil
+ }
+ }
+ }
+
+ private func makeLoop() -> Task {
+ Task { [weak self] in
+ guard let self else { return }
+ while !Task.isCancelled {
+ await refresh(RefreshRequest())
+ guard !Task.isCancelled, let deadline = nextRefreshDate() else { break }
+ state.setNextRefresh(deadline)
+ do {
+ try await clock.sleep(max(0, deadline.timeIntervalSince(clock.now())))
+ } catch {
+ break
+ }
+ }
+ }
+ }
+
+ private func observeScheduleInputs() {
+ withObservationTracking {
+ _ = state.popoverVisible
+ _ = settings.enabledProviders
+ _ = settings.configuredProviders
+ _ = settings.refreshSeconds
+ _ = settings.analyticsRefreshMinutes
+ } onChange: { [weak self] in
+ Task { @MainActor [weak self] in
+ guard let self, self.loop != nil else { return }
+ self.observeScheduleInputs()
+ self.reschedule()
+ }
+ }
+ }
+
+ private func perform(
+ _ request: RefreshRequest,
+ registry: ProviderRegistry,
+ registryGeneration: Int
+ ) async {
+ let now = clock.now()
+ let cycleID = log.debugEnabled ? UUID().uuidString : ""
+ let active = settings.activeProviders(states: state.providers)
+ let inactive = registry.ids.filter { !active.contains($0) }
+ let plans: [ProviderRefreshPlan] = registry.providers.compactMap { provider in
+ guard request.providers?.contains(provider.id) ?? true else { return nil }
+ guard let plan = plan(for: provider, request: request, now: now) else {
+ log.detailed(
+ .refresh(
+ RefreshDiagnostic.skipped(
+ cycleID: cycleID,
+ trigger: request.reason.diagnosticName,
+ provider: provider.id,
+ usagePolicy: request.usage.diagnosticName,
+ analyticsPolicy: request.analytics.diagnosticName,
+ reason: skipReason(for: provider, request: request, now: now))))
+ return nil
+ }
+ return plan
+ }
+ guard !plans.isEmpty, !Task.isCancelled else {
+ state.disable(inactive)
+ return
+ }
+ let planIDs = plans.map(\.provider.id)
+ state.beginRefreshing(planIDs, disabling: inactive)
+ var completed = false
+ defer {
+ if registryGeneration == self.registryGeneration {
+ state.finishRefreshing(planIDs, at: completed ? clock.now() : nil)
+ }
+ }
+ var events: [NotificationEvent] = []
+ var providerStates: [ProviderID: ProviderState] = [:]
+ var samplesChanged = false
+ var analyticsChanged = false
+ await withTaskGroup(
+ of: (ProviderID, ProviderFetchResult, ProviderCredentialStatus, Bool, TimeInterval, Int).self
+ ) { group in
+ for plan in plans {
+ let provider = plan.provider
+ let options = FetchOptions(
+ includeAnalytics: plan.includeAnalytics, analyticsDays: settings.historyRetentionDays)
+ group.addTask {
+ let clock = ContinuousClock()
+ let started = clock.now
+ let result = await DiagnosticSignposts.refresh.withInterval("Provider refresh") {
+ await provider.fetch(now: now, options: options)
+ }
+ let credentialStatus: ProviderCredentialStatus
+ if let fetched = result.credentialStatus {
+ credentialStatus = fetched
+ } else {
+ credentialStatus = await ProviderCredentialStatus(
+ state: provider.credentialState(now: now),
+ health: provider.credentialHealth(now: now))
+ }
+ return (
+ provider.id,
+ result,
+ credentialStatus,
+ options.includeAnalytics,
+ plan.retryInterval,
+ Self.milliseconds(started.duration(to: clock.now))
+ )
+ }
+ }
+ for await (id, result, credentialStatus, includedAnalytics, retryInterval, duration) in group {
+ guard !Task.isCancelled else {
+ group.cancelAll()
+ break
+ }
+ let applied = await apply(
+ id,
+ result: result,
+ credentialStatus: credentialStatus,
+ includedAnalytics: includedAnalytics,
+ retryInterval: retryInterval,
+ registryGeneration: registryGeneration,
+ now: now)
+ events += applied.events
+ if let providerState = applied.providerState { providerStates[id] = providerState }
+ samplesChanged = applied.samplesChanged || samplesChanged
+ analyticsChanged = applied.analyticsChanged || analyticsChanged
+ log.detailed(
+ .refresh(
+ RefreshDiagnostic(
+ cycleID: cycleID,
+ trigger: request.reason.diagnosticName,
+ provider: id,
+ usagePolicy: request.usage.diagnosticName,
+ analyticsPolicy: request.analytics.diagnosticName,
+ outcome: result.outcome.diagnosticOutcome,
+ durationMilliseconds: duration,
+ includeAnalytics: includedAnalytics,
+ analyticsReturned: result.analytics != nil,
+ analyticsPointCount: result.analytics?.points.count ?? 0,
+ warnings: result.warnings)))
+ }
+ }
+ guard !Task.isCancelled, registryGeneration == self.registryGeneration else { return }
+ state.applyProviderStates(providerStates)
+ if samplesChanged {
+ state.markSamplesChanged()
+ } else if analyticsChanged {
+ state.markHistoryChanged()
+ }
+ rebuildStatus(now: now)
+ storeCache()
+ if !events.isEmpty { notify(events) }
+ completed = true
+ }
+
+ nonisolated private static func milliseconds(_ duration: Duration) -> Int {
+ let components = duration.components
+ return Int(components.seconds * 1_000 + components.attoseconds / 1_000_000_000_000_000)
+ }
+
+ func storeCache() {
+ let snapshots = state.snapshots
+ let previous = cacheSubmissionTask
+ cacheSubmissionTask = Task { [persistence] in
+ await previous?.value
+ await persistence.submitSnapshots(snapshots)
+ }
+ }
+
+ public func flushPersistence() async {
+ await cacheSubmissionTask?.value
+ await persistence.flush()
+ }
+
+ private func plan(
+ for provider: any UsageProvider, request: RefreshRequest, now: Date
+ ) -> ProviderRefreshPlan? {
+ let providerState = state.state(for: provider.id)
+ let targetedProbe = request.reason == .userInitiated && request.providers?.contains(provider.id) == true
+ guard settings.isProviderActive(provider.id, state: providerState) || targetedProbe else { return nil }
+ if let blocked = providerState.retryNotBefore, blocked > now,
+ request.reason != .userInitiated || providerState.availability == .rateLimited
+ {
+ return nil
+ }
+ let retryDue = providerState.retryNotBefore != nil
+ let interval = provider.pollingPolicy.interval(
+ active: state.popoverVisible, requested: TimeInterval(settings.refreshInterval(for: provider.id)))
+ let usageDue =
+ request.usage != .skip
+ && (retryDue || due(request.usage, lastAttempt: providerState.lastAttempt, interval: interval, now: now))
+ let analyticsDue =
+ provider.supportsAnalytics
+ && due(
+ request.analytics,
+ lastAttempt: providerState.lastAnalyticsAttempt,
+ interval: TimeInterval(settings.analyticsRefreshMinutes * 60),
+ now: now)
+ guard usageDue || analyticsDue else { return nil }
+ return ProviderRefreshPlan(provider: provider, includeAnalytics: analyticsDue, retryInterval: interval)
+ }
+
+ private func skipReason(
+ for provider: any UsageProvider, request: RefreshRequest, now: Date
+ ) -> DiagnosticRefreshSkipReason {
+ let providerState = state.state(for: provider.id)
+ guard settings.isProviderActive(provider.id, state: providerState) else {
+ return settings.providerOverride(for: provider.id) == false ? .disabled : .notDiscovered
+ }
+ if let blocked = providerState.retryNotBefore, blocked > now,
+ request.reason != .userInitiated || providerState.availability == .rateLimited
+ {
+ return .retryBackoff
+ }
+ if request.usage == .skip, request.analytics != .skip, provider.supportsAnalytics { return .analyticsNotDue }
+ return .noWork
+ }
+
+ private func due(_ policy: RefreshPolicy, lastAttempt: Date?, interval: TimeInterval, now: Date) -> Bool {
+ switch policy {
+ case .skip: false
+ case .force: true
+ case .ifDue: lastAttempt.map { now.timeIntervalSince($0) >= interval - 1 } ?? true
+ }
+ }
+
+ public func nextAttempt(for id: ProviderID) -> Date? {
+ state.state(for: id).retryNotBefore
+ }
+
+ func rateLimitBlock(
+ for id: ProviderID,
+ retryAfter: TimeInterval?,
+ retryInterval: TimeInterval = RefreshCoordinator.minimumBackoff
+ ) -> TimeInterval {
+ let strikes = (rateLimitStrikes[id] ?? 0) + 1
+ rateLimitStrikes[id] = strikes
+ let base = max(max(retryAfter ?? Self.rateLimitBackoff, Self.minimumBackoff), retryInterval)
+ return min(base * pow(2, Double(strikes - 1)), Self.maximumBackoff)
+ }
+
+ private func apply(
+ _ id: ProviderID,
+ result: ProviderFetchResult,
+ credentialStatus: ProviderCredentialStatus,
+ includedAnalytics: Bool,
+ retryInterval: TimeInterval,
+ registryGeneration: Int,
+ now: Date
+ ) async -> ProviderApplyResult {
+ guard registryGeneration == self.registryGeneration, registry[id] != nil else { return .empty }
+ guard settings.providerOverride(for: id) != false else {
+ return ProviderApplyResult(
+ providerState: ProviderState(availability: .disabled), events: [], samplesChanged: false,
+ analyticsChanged: false)
+ }
+ let previous = state.state(for: id)
+ var next = previous
+ next.isRefreshing = false
+ next.lastAttempt = now
+ if includedAnalytics { next.lastAnalyticsAttempt = now }
+ next.warnings = result.warnings
+ next.credentialState = credentialStatus.state
+ if credentialStatus.health != .unchecked { next.credentialHealth = credentialStatus.health }
+ next.recoveryIssue = result.recoveryIssue
+ var samplesChanged = false
+ var analyticsChanged = false
+ switch result.outcome {
+ case .success(let snapshot):
+ next.snapshot = snapshot
+ next.availability = .current
+ next.lastError = nil
+ next.lastSuccess = now
+ next.retryNotBefore = nil
+ rateLimitStrikes[id] = nil
+ samplesChanged = await record(snapshot, now: now)
+ case .partial(let snapshot, let reason):
+ if previous.snapshot.map({ snapshot.fetchedAt >= $0.fetchedAt }) ?? true {
+ next.snapshot = snapshot
+ }
+ next.availability = .stale
+ next.lastError = reason
+ next.retryNotBefore = now.addingTimeInterval(max(Self.networkBackoff, retryInterval))
+ case .notAuthenticated(let reason):
+ next.availability = .authenticationRequired
+ next.lastError = reason
+ next.retryNotBefore = now.addingTimeInterval(max(Self.networkBackoff, retryInterval))
+ case .networkUnavailable(let reason):
+ next.availability = .networkUnavailable
+ next.lastError = reason
+ next.retryNotBefore = now.addingTimeInterval(max(Self.networkBackoff, retryInterval))
+ case .rateLimited(let reason, let retryAfter):
+ let block = rateLimitBlock(for: id, retryAfter: retryAfter, retryInterval: retryInterval)
+ let until = now.addingTimeInterval(block)
+ next.availability = .rateLimited
+ next.lastError = "\(reason). Next attempt \(Format.resetClock(until, now: now))."
+ next.retryNotBefore = until
+ case .failed(let reason):
+ next.availability = .unavailable
+ next.lastError = reason
+ next.retryNotBefore = now.addingTimeInterval(max(Self.networkBackoff, retryInterval))
+ }
+ if let analytics = result.analytics {
+ next.analytics = previous.analytics?.merging(analytics, retentionDays: settings.historyRetentionDays) ?? analytics
+ do {
+ analyticsChanged = try await history.record(analytics) > 0
+ } catch {
+ log.logError("history analytics write failed provider=\(id.rawValue) error=\(error)")
+ }
+ } else if result.outcome.snapshot != nil, next.analytics == nil {
+ next.analytics = await storedAnalytics(id, now: now)
+ }
+ let isActive = settings.isProviderActive(id, state: next)
+ if !isActive {
+ next.availability = .disabled
+ next.lastError = nil
+ next.warnings = []
+ next.recoveryIssue = nil
+ }
+ if isActive, let error = next.lastError, error != previous.lastError {
+ log.logError(
+ "refresh provider=\(id.rawValue) outcome=\(next.availability.rawValue) error=\(error)", category: .refresh)
+ }
+ log.logDebug(
+ "refresh provider=\(id.rawValue) outcome=\(next.availability.rawValue) "
+ + "windows=\(next.snapshot?.windows.count ?? 0)"
+ )
+ return ProviderApplyResult(
+ providerState: next,
+ events: isActive
+ ? NotificationPlanner.events(
+ previous: previous.snapshot,
+ current: next.snapshot,
+ previousAvailability: previous.availability,
+ currentAvailability: next.availability,
+ provider: id,
+ settings: settings.notifications,
+ credentialMissing: credentialStatus.state.isMissing,
+ now: now)
+ : [],
+ samplesChanged: samplesChanged,
+ analyticsChanged: analyticsChanged
+ )
+ }
+
+ private func record(_ snapshot: ProviderSnapshot, now: Date) async -> Bool {
+ do {
+ return try await history.record(snapshot, now: now) > 0
+ } catch {
+ log.logError("history write failed provider=\(snapshot.provider.rawValue) error=\(error)")
+ return false
+ }
+ }
+
+ private func storedAnalytics(_ id: ProviderID, now: Date) async -> ProviderAnalytics? {
+ let start = DayStamp.string(now.addingTimeInterval(-60 * 86400))
+ guard let points = try? await history.analytics(provider: id, from: start, to: DayStamp.string(now)),
+ !points.isEmpty
+ else { return nil }
+ return ProviderAnalytics(provider: id, points: points, fetchedAt: now)
+ }
+
+ public func rebuildStatus(now: Date? = nil, publishWidget: Bool = true) {
+ let active = settings.activeProviders(states: state.providers)
+ let snapshots = state.snapshots.filter { active.contains($0.key) }
+ let availability = state.availability.filter { active.contains($0.key) }
+ let available = snapshots.keys.sorted().flatMap { provider in
+ snapshots[provider]!.windows.map { WindowKey(provider, $0) }
+ }
+ let selected =
+ settings.hasCustomSelection ? settings.selectedWindows : StatusItemBuilder.defaultSelection(snapshots)
+ let selection = SettingsOrderDraft(
+ providers: settings.providerOrder, models: settings.modelOrder, available: available
+ ).orderedSelection(selected)
+ let input = StatusItemInput(
+ snapshots: snapshots,
+ availability: availability,
+ selectedKeys: selection,
+ format: settings.statusFormat,
+ customTemplate: settings.customTemplate,
+ decimals: settings.percentDecimals,
+ hideZeroCells: settings.hideZeroCells,
+ order: settings.windowOrder,
+ labels: settings.shortLabels,
+ now: now ?? clock.now()
+ )
+ state.setStatusLadder(
+ settings.adaptiveWidth ? StatusItemBuilder.candidates(input) : [StatusItemBuilder.build(input)])
+ // Reloading widget timelines is an XPC round trip, so it waits for the cycle to finish rather than firing once
+ // per provider as each one reports.
+ guard publishWidget else { return }
+ let widget = WidgetSnapshot.build(
+ snapshots: snapshots, availability: availability, selectedKeys: selection,
+ now: snapshots.values.map(\.fetchedAt).max() ?? input.now)
+ if widget != lastWidget {
+ lastWidget = widget
+ widgetSink?(widget)
+ }
+ }
+}
+
+private extension RefreshReason {
+ var diagnosticName: String {
+ switch self {
+ case .scheduled: "scheduled"
+ case .popoverOpened: "popover-opened"
+ case .userInitiated: "user-initiated"
+ case .export: "export"
+ }
+ }
+}
+
+private extension RefreshPolicy {
+ var diagnosticName: String {
+ switch self {
+ case .skip: "skip"
+ case .ifDue: "if-due"
+ case .force: "force"
+ }
+ }
+}
+
+private extension ProviderFetchOutcome {
+ var diagnosticOutcome: DiagnosticRefreshOutcome {
+ switch self {
+ case .success: .success
+ case .partial: .partial
+ case .notAuthenticated: .authenticationRequired
+ case .networkUnavailable: .networkUnavailable
+ case .rateLimited: .rateLimited
+ case .failed: .failed
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Refresh/SnapshotCache.swift b/Sources/TokenMenuBarCore/Refresh/SnapshotCache.swift
new file mode 100644
index 0000000..ab087b5
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Refresh/SnapshotCache.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+public struct SnapshotCache: Sendable {
+ public let url: URL?
+
+ public init(url: URL?) {
+ self.url = url
+ }
+
+ public func load() -> [ProviderID: ProviderSnapshot] {
+ (try? read()) ?? [:]
+ }
+
+ public func read() throws -> [ProviderID: ProviderSnapshot] {
+ guard let url else { return [:] }
+ let data: Data
+ do {
+ data = try Data(contentsOf: url)
+ } catch let error as CocoaError where error.code == .fileReadNoSuchFile {
+ return [:]
+ }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .secondsSince1970
+ let stored = try decoder.decode([ProviderID: ProviderSnapshot].self, from: data)
+ return stored.mapValues { snapshot in
+ ProviderSnapshot(
+ provider: snapshot.provider, identity: snapshot.identity, windows: snapshot.windows,
+ credits: snapshot.credits, spend: snapshot.spend, resetCredits: snapshot.resetCredits,
+ notices: snapshot.notices, localUsage: snapshot.localUsage, source: .cache, fetchedAt: snapshot.fetchedAt)
+ }
+ }
+
+ public func store(_ snapshots: [ProviderID: ProviderSnapshot]) throws {
+ guard let url else { return }
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .secondsSince1970
+ try encoder.encode(snapshots).write(to: url, options: .atomic)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift b/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift
new file mode 100644
index 0000000..c35ffc2
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift
@@ -0,0 +1,169 @@
+import Foundation
+
+public enum SnapshotPersistenceFailure: Sendable, Equatable {
+ case cacheLoad(String)
+ case cacheWrite(String)
+ case widgetWrite(String)
+
+ public var message: String {
+ switch self {
+ case .cacheLoad(let detail): "snapshot cache load failed: \(detail)"
+ case .cacheWrite(let detail): "snapshot cache write failed: \(detail)"
+ case .widgetWrite(let detail): "widget snapshot write failed: \(detail)"
+ }
+ }
+}
+
+public struct SnapshotPersistenceWorkload: Sendable, Equatable {
+ public fileprivate(set) var cacheLoads = 0
+ public fileprivate(set) var cacheSubmissions = 0
+ public fileprivate(set) var cacheWrites = 0
+ public fileprivate(set) var coalescedCacheSubmissions = 0
+ public fileprivate(set) var widgetSubmissions = 0
+ public fileprivate(set) var widgetWrites = 0
+ public fileprivate(set) var coalescedWidgetSubmissions = 0
+ public fileprivate(set) var widgetReloads = 0
+
+ public init() {}
+}
+
+private enum SnapshotCacheLoadResult: Sendable {
+ case success([ProviderID: ProviderSnapshot])
+ case failure(String)
+}
+
+public actor SnapshotPersistence {
+ public typealias FailureHandler = @Sendable (SnapshotPersistenceFailure) async -> Void
+ public typealias WidgetReload = @MainActor @Sendable () -> Void
+
+ private let cache: SnapshotCache
+ private let widgetStore: WidgetSnapshotStore?
+ private let failureHandler: FailureHandler
+ private let reloadWidgets: WidgetReload
+ private var cachedLoad: [ProviderID: ProviderSnapshot]?
+ private var cacheLoadTask: Task?
+ private var pendingSnapshots: [ProviderID: ProviderSnapshot]?
+ private var pendingWidget: WidgetSnapshot?
+ private var lastWrittenSnapshots: [ProviderID: ProviderSnapshot]?
+ private var lastWrittenWidget: WidgetSnapshot?
+ private var cacheDrainTask: Task?
+ private var widgetDrainTask: Task?
+ public private(set) var workload = SnapshotPersistenceWorkload()
+
+ public init(
+ cache: SnapshotCache,
+ widgetStore: WidgetSnapshotStore? = nil,
+ failureHandler: @escaping FailureHandler = { _ in },
+ reloadWidgets: @escaping WidgetReload = {}
+ ) {
+ self.cache = cache
+ self.widgetStore = widgetStore
+ self.failureHandler = failureHandler
+ self.reloadWidgets = reloadWidgets
+ }
+
+ public func loadSnapshots() async -> [ProviderID: ProviderSnapshot] {
+ if let cachedLoad { return cachedLoad }
+ let task: Task
+ if let cacheLoadTask {
+ task = cacheLoadTask
+ } else {
+ let cache = cache
+ let created: Task = Task.detached(priority: .utility) {
+ do {
+ return .success(try cache.read())
+ } catch {
+ return .failure(String(describing: error))
+ }
+ }
+ cacheLoadTask = created
+ task = created
+ }
+ let result = await task.value
+ if let cachedLoad { return cachedLoad }
+ workload.cacheLoads += 1
+ cacheLoadTask = nil
+ let snapshots: [ProviderID: ProviderSnapshot]
+ switch result {
+ case .success(let loaded): snapshots = loaded
+ case .failure(let detail):
+ snapshots = [:]
+ await failureHandler(.cacheLoad(detail))
+ }
+ cachedLoad = snapshots
+ return snapshots
+ }
+
+ public func submitSnapshots(_ snapshots: [ProviderID: ProviderSnapshot]) {
+ workload.cacheSubmissions += 1
+ if pendingSnapshots != nil { workload.coalescedCacheSubmissions += 1 }
+ pendingSnapshots = snapshots
+ guard cacheDrainTask == nil else { return }
+ cacheDrainTask = Task { [weak self] in await self?.drainSnapshots() }
+ }
+
+ public func submitWidget(_ snapshot: WidgetSnapshot) {
+ guard let widgetStore else { return }
+ workload.widgetSubmissions += 1
+ if pendingWidget != nil { workload.coalescedWidgetSubmissions += 1 }
+ pendingWidget = snapshot
+ guard widgetDrainTask == nil else { return }
+ widgetDrainTask = Task { [weak self] in await self?.drainWidgets(widgetStore) }
+ }
+
+ public func flush() async {
+ while cacheDrainTask != nil || widgetDrainTask != nil {
+ let cacheTask = cacheDrainTask
+ let widgetTask = widgetDrainTask
+ await cacheTask?.value
+ await widgetTask?.value
+ }
+ }
+
+ private func drainSnapshots() async {
+ while let snapshots = pendingSnapshots {
+ pendingSnapshots = nil
+ guard snapshots != lastWrittenSnapshots else { continue }
+ let cache = cache
+ let failure = await Task.detached(priority: .utility) { () -> String? in
+ do {
+ try cache.store(snapshots)
+ return nil
+ } catch {
+ return String(describing: error)
+ }
+ }.value
+ if let failure {
+ await failureHandler(.cacheWrite(failure))
+ } else {
+ lastWrittenSnapshots = snapshots
+ workload.cacheWrites += 1
+ }
+ }
+ cacheDrainTask = nil
+ }
+
+ private func drainWidgets(_ widgetStore: WidgetSnapshotStore) async {
+ while let snapshot = pendingWidget {
+ pendingWidget = nil
+ guard snapshot != lastWrittenWidget else { continue }
+ let failure = await Task.detached(priority: .utility) { () -> String? in
+ do {
+ try widgetStore.write(snapshot)
+ return nil
+ } catch {
+ return String(describing: error)
+ }
+ }.value
+ if let failure {
+ await failureHandler(.widgetWrite(failure))
+ } else {
+ lastWrittenWidget = snapshot
+ workload.widgetWrites += 1
+ await reloadWidgets()
+ workload.widgetReloads += 1
+ }
+ }
+ widgetDrainTask = nil
+ }
+}
diff --git a/Sources/TokenMenuBarCore/SandboxAccess.swift b/Sources/TokenMenuBarCore/SandboxAccess.swift
new file mode 100644
index 0000000..793df7e
--- /dev/null
+++ b/Sources/TokenMenuBarCore/SandboxAccess.swift
@@ -0,0 +1,92 @@
+import Foundation
+
+/// A path the sandboxed build cannot read until the user grants a security-scoped bookmark for it.
+public struct SandboxResource: Sendable, Hashable, Identifiable {
+ /// How an environment variable, when set, moves the path away from the default under the home directory.
+ public enum Override: Sendable, Hashable {
+ /// The variable holds the resource path itself, as `CODEX_HOME` and `CLAUDE_CONFIG_DIR` do.
+ case path(String)
+ /// The variable replaces the home directory, as `GEMINI_CLI_HOME` does.
+ case home(String)
+ /// The variable replaces the leading directories, as `XDG_CONFIG_HOME` does for `.config`.
+ case prefix(String)
+ }
+
+ public enum Kind: Sendable, Hashable {
+ case directory
+ case file
+ }
+
+ public let id: String
+ public let relativePath: String
+ public let provider: ProviderID
+ public let kind: Kind
+ public let override: Override?
+
+ public init(
+ id: String, relativePath: String, provider: ProviderID, kind: Kind = .directory, override: Override? = nil
+ ) {
+ self.id = id
+ self.relativePath = relativePath
+ self.provider = provider
+ self.kind = kind
+ self.override = override
+ }
+
+ public var label: String {
+ "~/\(relativePath)"
+ }
+
+ /// The path this build reads, so the grant panel and the provider agree on one location.
+ public func configuredURL(environment: [String: String], home: URL) -> URL {
+ switch override {
+ case .path(let key):
+ if let value = environment[key] { return URL(fileURLWithPath: value) }
+ case .home(let key):
+ if let value = environment[key] { return URL(fileURLWithPath: value).appending(path: relativePath) }
+ case .prefix(let key):
+ if let value = environment[key] {
+ return URL(fileURLWithPath: value).appending(path: (relativePath as NSString).lastPathComponent)
+ }
+ case nil:
+ break
+ }
+ return home.appending(path: relativePath)
+ }
+}
+
+extension ProviderID {
+ /// The paths this provider reads under the user's home. The unsandboxed build reads them directly; the App Store
+ /// build needs one bookmark per entry before the provider reports anything.
+ public var sandboxResources: [SandboxResource] {
+ switch self {
+ case .claude:
+ [
+ SandboxResource(
+ id: "claude.home", relativePath: ".claude", provider: self, override: .path("CLAUDE_CONFIG_DIR")),
+ SandboxResource(id: "claude.account", relativePath: ".claude.json", provider: self, kind: .file),
+ ]
+ case .codex:
+ [SandboxResource(id: "codex.home", relativePath: ".codex", provider: self, override: .path("CODEX_HOME"))]
+ case .gemini:
+ [SandboxResource(id: "gemini.home", relativePath: ".gemini", provider: self, override: .home("GEMINI_CLI_HOME"))]
+ case .cursor:
+ [
+ SandboxResource(id: "cursor.support", relativePath: "Library/Application Support/Cursor", provider: self),
+ SandboxResource(id: "cursor.home", relativePath: ".cursor", provider: self),
+ ]
+ case .copilot:
+ [
+ SandboxResource(
+ id: "copilot.home", relativePath: ".copilot", provider: self, override: .path("COPILOT_HOME")),
+ SandboxResource(
+ id: "copilot.config", relativePath: ".config/github-copilot", provider: self,
+ override: .prefix("XDG_CONFIG_HOME")),
+ ]
+ }
+ }
+
+ public static var allSandboxResources: [SandboxResource] {
+ allCases.flatMap(\.sandboxResources)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/SecurityScopedAccess.swift b/Sources/TokenMenuBarCore/SecurityScopedAccess.swift
new file mode 100644
index 0000000..13d93ce
--- /dev/null
+++ b/Sources/TokenMenuBarCore/SecurityScopedAccess.swift
@@ -0,0 +1,136 @@
+import Foundation
+
+public struct SecurityScopedBookmarkResolution: Sendable, Equatable {
+ public let url: URL
+ public let isStale: Bool
+
+ public init(url: URL, isStale: Bool) {
+ self.url = url
+ self.isStale = isStale
+ }
+}
+
+public struct SecurityScopedBookmarkClient: Sendable {
+ public var resolve: @Sendable (Data) throws -> SecurityScopedBookmarkResolution
+ public var create: @Sendable (URL) throws -> Data
+ public var start: @Sendable (URL) -> Bool
+ public var stop: @Sendable (URL) -> Void
+
+ public init(
+ resolve: @escaping @Sendable (Data) throws -> SecurityScopedBookmarkResolution,
+ create: @escaping @Sendable (URL) throws -> Data,
+ start: @escaping @Sendable (URL) -> Bool,
+ stop: @escaping @Sendable (URL) -> Void
+ ) {
+ self.resolve = resolve
+ self.create = create
+ self.start = start
+ self.stop = stop
+ }
+
+ public static let live = SecurityScopedBookmarkClient(
+ resolve: { data in
+ var stale = false
+ let url = try URL(
+ resolvingBookmarkData: data, options: .withSecurityScope, relativeTo: nil,
+ bookmarkDataIsStale: &stale)
+ return SecurityScopedBookmarkResolution(url: url, isStale: stale)
+ },
+ create: { url in
+ try (url as NSURL).bookmarkData(
+ options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
+ },
+ start: { $0.startAccessingSecurityScopedResource() },
+ stop: { $0.stopAccessingSecurityScopedResource() }
+ )
+}
+
+public final class SecurityScopedResourceLease: @unchecked Sendable {
+ public let url: URL
+ private let lock = NSLock()
+ private let stop: @Sendable (URL) -> Void
+ private var active = true
+
+ init(url: URL, stop: @escaping @Sendable (URL) -> Void) {
+ self.url = url
+ self.stop = stop
+ }
+
+ public func release() {
+ let shouldStop = lock.withLock { () -> Bool in
+ guard active else { return false }
+ active = false
+ return true
+ }
+ if shouldStop { stop(url) }
+ }
+
+ deinit {
+ release()
+ }
+}
+
+public struct SecurityScopedResourceResolution: Sendable {
+ public let url: URL
+ public let access: ResourceAccessState
+ public let lease: SecurityScopedResourceLease?
+ public let replacementBookmark: Data?
+
+ public init(
+ url: URL,
+ access: ResourceAccessState,
+ lease: SecurityScopedResourceLease?,
+ replacementBookmark: Data?
+ ) {
+ self.url = url
+ self.access = access
+ self.lease = lease
+ self.replacementBookmark = replacementBookmark
+ }
+}
+
+public struct SecurityScopedResourceResolver: Sendable {
+ public let client: SecurityScopedBookmarkClient
+
+ public init(client: SecurityScopedBookmarkClient = .live) {
+ self.client = client
+ }
+
+ public func resolve(resource: SandboxResource, bookmark: Data?, fallback: URL) -> SecurityScopedResourceResolution {
+ guard let bookmark else {
+ return SecurityScopedResourceResolution(
+ url: fallback, access: ResourceAccessState(resource: resource, health: .needed), lease: nil,
+ replacementBookmark: nil)
+ }
+ do {
+ let resolved = try client.resolve(bookmark)
+ guard client.start(resolved.url) else {
+ return SecurityScopedResourceResolution(
+ url: fallback,
+ access: ResourceAccessState(
+ resource: resource, health: .error("macOS denied access to the selected location.")),
+ lease: nil, replacementBookmark: nil)
+ }
+ let lease = SecurityScopedResourceLease(url: resolved.url, stop: client.stop)
+ guard resolved.isStale else {
+ return SecurityScopedResourceResolution(
+ url: resolved.url, access: ResourceAccessState(resource: resource, health: .granted), lease: lease,
+ replacementBookmark: nil)
+ }
+ do {
+ return SecurityScopedResourceResolution(
+ url: resolved.url, access: ResourceAccessState(resource: resource, health: .granted), lease: lease,
+ replacementBookmark: try client.create(resolved.url))
+ } catch {
+ return SecurityScopedResourceResolution(
+ url: resolved.url, access: ResourceAccessState(resource: resource, health: .stale), lease: lease,
+ replacementBookmark: nil)
+ }
+ } catch {
+ return SecurityScopedResourceResolution(
+ url: fallback,
+ access: ResourceAccessState(resource: resource, health: .error("The saved access grant is no longer valid.")),
+ lease: nil, replacementBookmark: nil)
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Settings.swift b/Sources/TokenMenuBarCore/Settings.swift
new file mode 100644
index 0000000..5478b95
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Settings.swift
@@ -0,0 +1,309 @@
+import Foundation
+import Observation
+
+public enum PopoverTab: String, CaseIterable, Codable, Sendable {
+ case usage = "Usage"
+ case history = "History"
+ case settings = "Settings"
+}
+
+@MainActor
+@Observable
+public final class Settings {
+ public static let maximumRefreshSeconds = 1800
+ public static let defaultAnalyticsMinutes = 15
+ public static let defaultHistoryRetentionDays = 60
+ public static let defaultCustomTemplate = "{provider} {window}\n{pct}"
+
+ private let defaults: UserDefaults
+ private let encoder = JSONEncoder()
+ private let decoder = JSONDecoder()
+ private var loading = true
+ @ObservationIgnored private var defersShortLabelStore = false
+ @ObservationIgnored private var shortLabelStoreTask: Task?
+
+ public var refreshSeconds: [ProviderID: Int] {
+ didSet { storeCodable(refreshSeconds, key: .refreshSeconds) }
+ }
+
+ public func refreshInterval(for provider: ProviderID) -> Int {
+ refreshSeconds[provider] ?? Int(PollingPolicy.defaults(for: provider).defaultInterval)
+ }
+
+ public func setRefreshInterval(_ seconds: Int, for provider: ProviderID) {
+ let floor = Int(PollingPolicy.defaults(for: provider).minimumInterval)
+ refreshSeconds[provider] = min(max(seconds, floor), Self.maximumRefreshSeconds)
+ }
+
+ public var analyticsRefreshMinutes: Int {
+ didSet {
+ let clamped = max(analyticsRefreshMinutes, 5)
+ if clamped != analyticsRefreshMinutes {
+ analyticsRefreshMinutes = clamped
+ return
+ }
+ store(analyticsRefreshMinutes, key: .analyticsMinutes)
+ }
+ }
+
+ public var historyRetentionDays: Int {
+ didSet {
+ let clamped = min(max(historyRetentionDays, 7), 365)
+ if clamped != historyRetentionDays {
+ historyRetentionDays = clamped
+ return
+ }
+ store(historyRetentionDays, key: .historyRetentionDays)
+ }
+ }
+
+ public var enabledProviders: Set { didSet { storeCodable(enabledProviders, key: .enabledProviders) } }
+ public var showAllProviders: Bool { didSet { store(showAllProviders, key: .showAllProviders) } }
+ public var configuredProviders: Set {
+ didSet { storeCodable(configuredProviders, key: .configuredProviders) }
+ }
+
+ public func providerOverride(for provider: ProviderID) -> Bool? {
+ configuredProviders.contains(provider) ? enabledProviders.contains(provider) : nil
+ }
+
+ public func setProvider(_ provider: ProviderID, enabled: Bool) {
+ configuredProviders.insert(provider)
+ if enabled {
+ enabledProviders.insert(provider)
+ } else {
+ enabledProviders.remove(provider)
+ }
+ }
+
+ public func isProviderActive(_ provider: ProviderID, state: ProviderState?) -> Bool {
+ ProviderSettingsVisibility.isActive(
+ provider, state: state, enabled: enabledProviders, overridden: configuredProviders)
+ }
+
+ public func activeProviders(states: [ProviderID: ProviderState]) -> Set {
+ ProviderSettingsVisibility.activeProviders(
+ states: states, enabled: enabledProviders, overridden: configuredProviders)
+ }
+
+ public var configuredProviderSettings: Set {
+ var providers = configuredProviders.union(refreshSeconds.keys)
+ for provider in ProviderID.allCases
+ where provider.sandboxResources.contains(where: { accessBookmarks[$0.id] != nil }) {
+ providers.insert(provider)
+ }
+ return providers
+ }
+ public var selectedWindows: [WindowKey] { didSet { storeCodable(selectedWindows, key: .selectedWindows) } }
+ public var hasCustomSelection: Bool { didSet { store(hasCustomSelection, key: .hasCustomSelection) } }
+ public var statusFormat: StatusFormat { didSet { store(statusFormat.rawValue, key: .statusFormat) } }
+ public var customTemplate: String { didSet { store(customTemplate, key: .customTemplate) } }
+ public var percentDecimals: Int {
+ didSet {
+ let clamped = min(max(percentDecimals, 0), 2)
+ if clamped != percentDecimals {
+ percentDecimals = clamped
+ return
+ }
+ store(percentDecimals, key: .percentDecimals)
+ }
+ }
+ public var hideZeroCells: Bool { didSet { store(hideZeroCells, key: .hideZeroCells) } }
+ public var adaptiveWidth: Bool { didSet { store(adaptiveWidth, key: .adaptiveWidth) } }
+ public var windowOrder: WindowOrder { didSet { store(windowOrder.rawValue, key: .windowOrder) } }
+ public var shortLabels: [WindowKey: String] {
+ didSet {
+ if !defersShortLabelStore { storeCodable(shortLabels, key: .shortLabels) }
+ }
+ }
+
+ public func setShortLabel(_ label: String?, for key: WindowKey) {
+ defersShortLabelStore = true
+ shortLabels[key] = label
+ defersShortLabelStore = false
+ shortLabelStoreTask?.cancel()
+ let labels = shortLabels
+ shortLabelStoreTask = Task { [weak self] in
+ do {
+ try await Task.sleep(for: .milliseconds(150))
+ } catch {
+ return
+ }
+ guard let self else { return }
+ storeCodable(labels, key: .shortLabels)
+ shortLabelStoreTask = nil
+ }
+ }
+ public var providerOrder: [ProviderID] { didSet { storeCodable(providerOrder, key: .providerOrder) } }
+ public var modelOrder: [WindowKey] { didSet { storeCodable(modelOrder, key: .modelOrder) } }
+ public var hideUnusedModels: Bool { didSet { store(hideUnusedModels, key: .hideUnusedModels) } }
+ public var allowTokenRefresh: Bool { didSet { store(allowTokenRefresh, key: .allowTokenRefresh) } }
+ public var notifications: NotificationSettings { didSet { storeCodable(notifications, key: .notifications) } }
+ public var lastTab: PopoverTab { didSet { store(lastTab.rawValue, key: .lastTab) } }
+ public var historyRange: HistoryRange { didSet { store(historyRange.rawValue, key: .historyRange) } }
+ public var historyRollup: Rollup { didSet { store(historyRollup.rawValue, key: .historyRollup) } }
+ public var historyStacked: Bool { didSet { store(historyStacked, key: .historyStacked) } }
+ public var historyUseUTC: Bool { didSet { store(historyUseUTC, key: .historyUseUTC) } }
+ public var historyHiddenKeys: Set { didSet { storeCodable(historyHiddenKeys, key: .historyHiddenKeys) } }
+ public var historyAnalyticsMetric: AnalyticsMetric {
+ didSet { store(historyAnalyticsMetric.rawValue, key: .historyAnalyticsMetric) }
+ }
+ public var historyMetricID: String { didSet { store(historyMetricID, key: .historyMetricID) } }
+ public var detailedLogging: Bool { didSet { store(detailedLogging, key: .detailedLogging) } }
+ public var automaticUpdates: Bool { didSet { store(automaticUpdates, key: .automaticUpdates) } }
+ public var lastLaunchedVersion: String? { didSet { store(lastLaunchedVersion, key: .lastLaunchedVersion) } }
+ public var accessBookmarks: [String: Data] { didSet { storeCodable(accessBookmarks, key: .accessBookmarks) } }
+ /// nil follows TOKEN_MENU_BAR_DEMO or --demo; once the user ticks the box their choice wins, so turning demo
+ /// off in an instance the environment started leaves demo mode.
+ public var demoMode: Bool? { didSet { store(demoMode, key: .demoMode) } }
+
+ public init(defaults: UserDefaults) {
+ self.defaults = defaults
+ refreshSeconds = Self.loadCodable([ProviderID: Int].self, defaults, .refreshSeconds) ?? [:]
+ analyticsRefreshMinutes =
+ defaults.object(forKey: Key.analyticsMinutes.rawValue) as? Int ?? Self.defaultAnalyticsMinutes
+ historyRetentionDays =
+ defaults.object(forKey: Key.historyRetentionDays.rawValue) as? Int ?? Self.defaultHistoryRetentionDays
+ let storedProviders = Self.loadCodable(Set.self, defaults, .enabledProviders)
+ enabledProviders = storedProviders ?? Set(ProviderID.allCases)
+ showAllProviders = defaults.bool(forKey: Key.showAllProviders.rawValue)
+ configuredProviders =
+ Self.loadCodable(Set.self, defaults, .configuredProviders)
+ ?? (storedProviders == nil ? [] : Set(ProviderID.allCases))
+ selectedWindows = Self.loadCodable([WindowKey].self, defaults, .selectedWindows) ?? []
+ hasCustomSelection = defaults.bool(forKey: Key.hasCustomSelection.rawValue)
+ statusFormat =
+ (defaults.string(forKey: Key.statusFormat.rawValue)).flatMap(StatusFormat.init(rawValue:)) ?? .stacked
+ customTemplate = defaults.string(forKey: Key.customTemplate.rawValue) ?? Self.defaultCustomTemplate
+ percentDecimals = defaults.object(forKey: Key.percentDecimals.rawValue) as? Int ?? 0
+ hideZeroCells = defaults.object(forKey: Key.hideZeroCells.rawValue) as? Bool ?? true
+ adaptiveWidth = defaults.object(forKey: Key.adaptiveWidth.rawValue) as? Bool ?? true
+ windowOrder = defaults.string(forKey: Key.windowOrder.rawValue).flatMap(WindowOrder.init(rawValue:)) ?? .provider
+ shortLabels = Self.loadCodable([WindowKey: String].self, defaults, .shortLabels) ?? [:]
+ providerOrder = Self.loadCodable([ProviderID].self, defaults, .providerOrder) ?? ProviderID.allCases
+ modelOrder = Self.loadCodable([WindowKey].self, defaults, .modelOrder) ?? []
+ hideUnusedModels = defaults.bool(forKey: Key.hideUnusedModels.rawValue)
+ allowTokenRefresh = defaults.bool(forKey: Key.allowTokenRefresh.rawValue)
+ notifications = Self.loadCodable(NotificationSettings.self, defaults, .notifications) ?? NotificationSettings()
+ lastTab = defaults.string(forKey: Key.lastTab.rawValue).flatMap(PopoverTab.init(rawValue:)) ?? .usage
+ historyRange = defaults.string(forKey: Key.historyRange.rawValue).flatMap(HistoryRange.init(rawValue:)) ?? .today
+ historyRollup = defaults.string(forKey: Key.historyRollup.rawValue).flatMap(Rollup.init(rawValue:)) ?? .minute
+ historyStacked = defaults.bool(forKey: Key.historyStacked.rawValue)
+ historyUseUTC = defaults.bool(forKey: Key.historyUseUTC.rawValue)
+ historyHiddenKeys = Self.loadCodable(Set.self, defaults, .historyHiddenKeys) ?? []
+ historyAnalyticsMetric =
+ defaults.string(forKey: Key.historyAnalyticsMetric.rawValue).flatMap(AnalyticsMetric.init(rawValue:))
+ ?? .surfaceUsagePercent
+ historyMetricID =
+ defaults.string(forKey: Key.historyMetricID.rawValue).flatMap(HistoryMetric.init(storageID:))?.storageID
+ ?? defaults.string(forKey: Key.historyAnalyticsMetric.rawValue).flatMap(AnalyticsMetric.init(rawValue:))
+ .map { HistoryMetric.analytics($0).storageID }
+ ?? HistoryMetric.windowUsagePercent.storageID
+ detailedLogging = defaults.bool(forKey: Key.detailedLogging.rawValue)
+ automaticUpdates = defaults.object(forKey: Key.automaticUpdates.rawValue) as? Bool ?? true
+ lastLaunchedVersion = defaults.string(forKey: Key.lastLaunchedVersion.rawValue)
+ accessBookmarks = Self.loadCodable([String: Data].self, defaults, .accessBookmarks) ?? [:]
+ demoMode = defaults.object(forKey: Key.demoMode.rawValue) as? Bool
+ loading = false
+ }
+
+ public func bookmark(for resource: SandboxResource) -> Data? {
+ accessBookmarks[resource.id]
+ }
+
+ public func setBookmark(_ data: Data, for resource: SandboxResource) {
+ accessBookmarks[resource.id] = data
+ }
+
+ /// The paths this provider still cannot read in a sandboxed build, so Settings can offer one button each.
+ public func missingAccess(for provider: ProviderID) -> [SandboxResource] {
+ provider.sandboxResources.filter { bookmark(for: $0) == nil }
+ }
+
+ public func flush() {
+ shortLabelStoreTask?.cancel()
+ shortLabelStoreTask = nil
+ storeCodable(shortLabels, key: .shortLabels)
+ defaults.synchronize()
+ }
+
+ public func resetToDefaults() {
+ shortLabelStoreTask?.cancel()
+ shortLabelStoreTask = nil
+ let activeTab = lastTab
+ for key in Key.allCases { defaults.removeObject(forKey: key.rawValue) }
+ let fresh = Settings(defaults: defaults)
+ loading = true
+ defer {
+ loading = false
+ for key in Key.allCases { defaults.removeObject(forKey: key.rawValue) }
+ }
+ refreshSeconds = fresh.refreshSeconds
+ analyticsRefreshMinutes = fresh.analyticsRefreshMinutes
+ historyRetentionDays = fresh.historyRetentionDays
+ enabledProviders = fresh.enabledProviders
+ showAllProviders = fresh.showAllProviders
+ configuredProviders = fresh.configuredProviders
+ selectedWindows = fresh.selectedWindows
+ hasCustomSelection = fresh.hasCustomSelection
+ statusFormat = fresh.statusFormat
+ customTemplate = fresh.customTemplate
+ percentDecimals = fresh.percentDecimals
+ hideZeroCells = fresh.hideZeroCells
+ adaptiveWidth = fresh.adaptiveWidth
+ windowOrder = fresh.windowOrder
+ shortLabels = fresh.shortLabels
+ providerOrder = fresh.providerOrder
+ modelOrder = fresh.modelOrder
+ hideUnusedModels = fresh.hideUnusedModels
+ allowTokenRefresh = fresh.allowTokenRefresh
+ notifications = fresh.notifications
+ lastTab = activeTab
+ historyRange = fresh.historyRange
+ historyRollup = fresh.historyRollup
+ historyStacked = fresh.historyStacked
+ historyUseUTC = fresh.historyUseUTC
+ historyHiddenKeys = fresh.historyHiddenKeys
+ historyAnalyticsMetric = fresh.historyAnalyticsMetric
+ historyMetricID = fresh.historyMetricID
+ detailedLogging = fresh.detailedLogging
+ automaticUpdates = fresh.automaticUpdates
+ lastLaunchedVersion = fresh.lastLaunchedVersion
+ accessBookmarks = fresh.accessBookmarks
+ demoMode = fresh.demoMode
+ }
+
+ public var activeTemplate: String {
+ statusFormat.template ?? customTemplate
+ }
+
+ enum Key: String, CaseIterable {
+ case refreshSeconds, analyticsMinutes, historyRetentionDays, enabledProviders, selectedWindows, hasCustomSelection,
+ statusFormat, customTemplate
+ case percentDecimals, hideZeroCells, adaptiveWidth, windowOrder, shortLabels, providerOrder, modelOrder,
+ hideUnusedModels, allowTokenRefresh, notifications, lastTab, showAllProviders, configuredProviders
+ case historyRange, historyRollup, historyStacked, historyUseUTC, historyHiddenKeys, historyAnalyticsMetric,
+ historyMetricID
+ case detailedLogging, automaticUpdates, lastLaunchedVersion, accessBookmarks, demoMode
+
+ }
+
+ private func store(_ value: (any Sendable)?, key: Key) {
+ guard !loading else { return }
+ if let value {
+ defaults.set(value, forKey: key.rawValue)
+ } else {
+ defaults.removeObject(forKey: key.rawValue)
+ }
+ }
+
+ private func storeCodable(_ value: Value, key: Key) {
+ guard !loading, let data = try? encoder.encode(value) else { return }
+ defaults.set(data, forKey: key.rawValue)
+ }
+
+ private static func loadCodable(_ type: Value.Type, _ defaults: UserDefaults, _ key: Key) -> Value?
+ {
+ defaults.data(forKey: key.rawValue).flatMap { try? JSONDecoder().decode(type, from: $0) }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/SettingsPresentation.swift b/Sources/TokenMenuBarCore/SettingsPresentation.swift
new file mode 100644
index 0000000..826ad55
--- /dev/null
+++ b/Sources/TokenMenuBarCore/SettingsPresentation.swift
@@ -0,0 +1,391 @@
+import Foundation
+
+public enum SettingsSection: String, CaseIterable, Sendable {
+ case about
+ case menuBar
+ case providers
+ case data
+ case notifications
+ case log
+
+ public var title: String {
+ switch self {
+ case .about: "About"
+ case .menuBar: "Menu bar"
+ case .providers: "Providers"
+ case .data: "Data"
+ case .notifications: "Notifications"
+ case .log: "Log"
+ }
+ }
+}
+
+public struct SettingsModelRow: Sendable, Equatable, Identifiable {
+ public let key: WindowKey
+ public let window: QuotaWindow
+ public let detail: String
+ public let recency: String
+ public let isSelected: Bool
+ public let defaultLabel: String
+ public let label: String
+ public let isLabelOverridden: Bool
+
+ public var id: WindowKey { key }
+
+ public init(
+ key: WindowKey, window: QuotaWindow, detail: String, recency: String, isSelected: Bool, defaultLabel: String,
+ label: String, isLabelOverridden: Bool
+ ) {
+ self.key = key
+ self.window = window
+ self.detail = detail
+ self.recency = recency
+ self.isSelected = isSelected
+ self.defaultLabel = defaultLabel
+ self.label = label
+ self.isLabelOverridden = isLabelOverridden
+ }
+}
+
+public struct SettingsProviderGroup: Sendable, Equatable, Identifiable {
+ public let provider: ProviderID
+ public let rows: [SettingsModelRow]
+ public let selectedCount: Int
+ public let totalCount: Int
+
+ public var id: ProviderID { provider }
+ public var selection: SettingsGroupSelection {
+ selectedCount == 0 ? .none : selectedCount == totalCount ? .all : .some
+ }
+
+ public init(provider: ProviderID, rows: [SettingsModelRow], selectedCount: Int, totalCount: Int) {
+ self.provider = provider
+ self.rows = rows
+ self.selectedCount = selectedCount
+ self.totalCount = totalCount
+ }
+}
+
+public enum SettingsGroupSelection: Sendable, Equatable {
+ case none
+ case some
+ case all
+}
+
+public enum ProviderSettingsVisibility {
+ public static func providers(
+ states: [ProviderID: ProviderState], configured _: Set, showAll: Bool,
+ revealed: ProviderID? = nil
+ ) -> [ProviderID] {
+ ProviderID.allCases.filter { provider in
+ showAll || provider == revealed || discovered(states[provider])
+ }
+ }
+
+ public static func discovered(_ state: ProviderState?) -> Bool {
+ guard let state else { return false }
+ if state.snapshot != nil || state.analytics != nil { return true }
+ switch state.credentialHealth {
+ case .valid: return true
+ case .unchecked:
+ guard let credential = state.credentialState else { return false }
+ if case .valid = credential { return true }
+ return false
+ case .missing, .expired, .unreadable: return false
+ }
+ }
+
+ public static func isActive(
+ _ provider: ProviderID,
+ state: ProviderState?,
+ enabled: Set,
+ overridden: Set
+ ) -> Bool {
+ guard discovered(state) else { return false }
+ return !overridden.contains(provider) || enabled.contains(provider)
+ }
+
+ public static func activeProviders(
+ states: [ProviderID: ProviderState],
+ enabled: Set,
+ overridden: Set
+ ) -> Set {
+ Set(ProviderID.allCases.filter { isActive($0, state: states[$0], enabled: enabled, overridden: overridden) })
+ }
+}
+
+public enum ShortLabelPolicy {
+ public static let limit = 6
+
+ public static func draft(_ label: String) -> String {
+ String(label.prefix(limit))
+ }
+
+ public static func override(_ label: String, default defaultLabel: String) -> String? {
+ let value = draft(label).trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty || normalized(value) == normalized(defaultLabel) ? nil : value
+ }
+
+ public static func derivedLabels(windows: [WindowKey: QuotaWindow]) -> [WindowKey: String] {
+ var labels: [WindowKey: String] = [:]
+ var used: Set = []
+ for key in windows.keys.sorted() {
+ guard let window = windows[key] else { continue }
+ let base = StatusItemBuilder.defaultShortLabel(provider: key.provider, window: window)
+ labels[key] = unique(base, used: &used)
+ }
+ return labels
+ }
+
+ public static func resolvedLabels(
+ windows: [WindowKey: QuotaWindow], overrides: [WindowKey: String]
+ ) -> [WindowKey: String] {
+ let defaults = derivedLabels(windows: windows)
+ var candidates: [WindowKey: String] = [:]
+ var candidateValues: Set = []
+ for key in windows.keys.sorted() {
+ guard let defaultLabel = defaults[key],
+ let value = overrides[key].flatMap({ override($0, default: defaultLabel) })
+ else { continue }
+ let normalizedValue = normalized(value)
+ guard candidateValues.insert(normalizedValue).inserted else { continue }
+ candidates[key] = value
+ }
+ while true {
+ let fallbackLabels = defaults.filter { candidates[$0.key] == nil }
+ let rejected = candidates.keys.filter { key in
+ guard let value = candidates[key] else { return false }
+ return fallbackLabels.contains { $0.key != key && normalized($0.value) == normalized(value) }
+ }
+ guard !rejected.isEmpty else { break }
+ for key in rejected { candidates[key] = nil }
+ }
+ return defaults.merging(candidates) { _, candidate in candidate }
+ }
+
+ public static func conflictingKey(
+ _ label: String, for key: WindowKey, windows: [WindowKey: QuotaWindow], overrides: [WindowKey: String]
+ ) -> WindowKey? {
+ let defaults = derivedLabels(windows: windows)
+ guard let defaultLabel = defaults[key], let value = override(label, default: defaultLabel) else { return nil }
+ let normalizedValue = normalized(value)
+ var otherOverrides = overrides
+ otherOverrides[key] = nil
+ let resolved = resolvedLabels(windows: windows, overrides: otherOverrides)
+ return windows.keys.sorted().first { other in
+ guard other != key else { return false }
+ if resolved[other].map({ normalized($0) == normalizedValue }) == true { return true }
+ guard let otherDefault = defaults[other],
+ let otherValue = otherOverrides[other].flatMap({ override($0, default: otherDefault) })
+ else { return false }
+ return normalized(otherValue) == normalizedValue
+ }
+ }
+
+ public static func validOverrides(
+ windows: [WindowKey: QuotaWindow], persisted: [WindowKey: String], drafts: [WindowKey: String]
+ ) -> [WindowKey: String] {
+ let defaults = derivedLabels(windows: windows)
+ var result = persisted
+ for key in drafts.keys.sorted() {
+ guard let draft = drafts[key], let defaultLabel = defaults[key] else { continue }
+ guard conflictingKey(draft, for: key, windows: windows, overrides: result) == nil else { continue }
+ result[key] = override(draft, default: defaultLabel)
+ }
+ return result
+ }
+
+ static func normalized(_ label: String) -> String {
+ label.components(separatedBy: .whitespacesAndNewlines)
+ .filter { !$0.isEmpty }
+ .joined(separator: " ")
+ .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX"))
+ .lowercased()
+ }
+
+ static func unique(_ base: String, used: inout Set) -> String {
+ let base = draft(base)
+ if used.insert(normalized(base)).inserted { return base }
+ var ordinal = 2
+ var candidate: String
+ repeat {
+ let suffix = String(ordinal, radix: 36).uppercased()
+ candidate = String(base.prefix(max(limit - suffix.count, 0))) + suffix
+ ordinal += 1
+ } while !used.insert(normalized(candidate)).inserted
+ return candidate
+ }
+}
+
+public struct SettingsOrderDraft: Sendable, Equatable {
+ public private(set) var providers: [ProviderID]
+ public private(set) var models: [WindowKey]
+
+ public init(providers: [ProviderID], models: [WindowKey], available: [WindowKey]) {
+ let availableProviders = available.map(\.provider).uniqued()
+ self.providers = Self.normalized(providers, available: availableProviders)
+ let providerMajor = self.providers.flatMap { provider in available.filter { $0.provider == provider } }
+ let normalizedModels = Self.normalized(models, available: providerMajor)
+ self.models = self.providers.flatMap { provider in normalizedModels.filter { $0.provider == provider } }
+ }
+
+ public mutating func moveProvider(_ provider: ProviderID, before target: ProviderID) {
+ Self.move(provider, before: target, in: &providers)
+ }
+
+ public mutating func moveModel(_ key: WindowKey, before target: WindowKey) {
+ guard key.provider == target.provider else { return }
+ Self.move(key, before: target, in: &models)
+ }
+
+ public mutating func moveProvider(_ provider: ProviderID, by offset: Int) {
+ Self.move(provider, by: offset, in: &providers)
+ }
+
+ public mutating func moveModel(_ key: WindowKey, by offset: Int) {
+ var providerModels = models.filter { $0.provider == key.provider }
+ Self.move(key, by: offset, in: &providerModels)
+ var iterator = providerModels.makeIterator()
+ models = models.map { $0.provider == key.provider ? iterator.next()! : $0 }
+ }
+
+ public func orderedSelection(_ selected: [WindowKey]) -> [WindowKey] {
+ let selected = Set(selected)
+ return providers.flatMap { provider in models.filter { $0.provider == provider && selected.contains($0) } }
+ }
+
+ static func normalized(_ preferred: [Value], available: [Value]) -> [Value] {
+ let availableSet = Set(available)
+ let preferredSet = Set(preferred)
+ return preferred.filter { availableSet.contains($0) }.uniqued()
+ + available.filter { !preferredSet.contains($0) }.uniqued()
+ }
+
+ static func move(_ value: Value, before target: Value, in values: inout [Value]) {
+ guard value != target, values.contains(value), values.contains(target) else { return }
+ values.removeAll { $0 == value }
+ guard let targetIndex = values.firstIndex(of: target) else { return }
+ values.insert(value, at: min(targetIndex, values.count))
+ }
+
+ static func move(_ value: Value, by offset: Int, in values: inout [Value]) {
+ guard let index = values.firstIndex(of: value) else { return }
+ let target = min(max(index + offset, 0), values.count - 1)
+ guard target != index else { return }
+ values.remove(at: index)
+ values.insert(value, at: target)
+ }
+}
+
+public enum SettingsModelPresentation {
+ public static func groups(
+ snapshots: [ProviderID: ProviderSnapshot], selected: [WindowKey], labels: [WindowKey: String],
+ providerOrder: [ProviderID], modelOrder: [WindowKey], query: String, hideUnused: Bool,
+ lastUsedAt: [WindowKey: Date] = [:], revealedKey: WindowKey? = nil, now: Date
+ ) -> [SettingsProviderGroup] {
+ let availablePairs = snapshots.flatMap { provider, snapshot in
+ snapshot.windows.map { (WindowKey(provider, $0), $0) }
+ }
+ let availableWindows = Dictionary(uniqueKeysWithValues: availablePairs)
+ let available = availablePairs.map(\.0)
+ let order = SettingsOrderDraft(providers: providerOrder, models: modelOrder, available: available)
+ let selectedSet = Set(selected)
+ let normalizedQuery = normalized(query)
+ let defaultLabels = ShortLabelPolicy.derivedLabels(windows: availableWindows)
+ let resolvedLabels = ShortLabelPolicy.resolvedLabels(windows: availableWindows, overrides: labels)
+ return order.providers.compactMap { provider in
+ guard let snapshot = snapshots[provider] else { return nil }
+ let windows = Dictionary(uniqueKeysWithValues: snapshot.windows.map { (WindowKey(provider, $0), $0) })
+ let providerKeys = order.models.filter { $0.provider == provider && windows[$0] != nil }
+ let rows = order.models.compactMap { key -> SettingsModelRow? in
+ guard key.provider == provider, let window = windows[key] else { return nil }
+ guard let defaultLabel = defaultLabels[key], let label = resolvedLabels[key] else { return nil }
+ let labelOverride = labels[key].flatMap { ShortLabelPolicy.override($0, default: defaultLabel) }
+ let isLabelOverridden = labelOverride == label
+ guard
+ key == revealedKey
+ || ((!hideUnused || window.usedPercent > 0 || lastUsedAt[key] != nil)
+ && (normalizedQuery.isEmpty
+ || [provider.displayName, window.label, window.id, label].contains(where: {
+ normalized($0).contains(normalizedQuery)
+ })))
+ else { return nil }
+ let lastUse = lastUsedAt[key] ?? (window.usedPercent > 0 ? snapshot.fetchedAt : nil)
+ return SettingsModelRow(
+ key: key, window: window, detail: detail(window), recency: recency(lastUse, now: now),
+ isSelected: selectedSet.contains(key), defaultLabel: defaultLabel, label: label,
+ isLabelOverridden: isLabelOverridden)
+ }
+ guard !rows.isEmpty else { return nil }
+ return SettingsProviderGroup(
+ provider: provider, rows: rows, selectedCount: providerKeys.count(where: { selectedSet.contains($0) }),
+ totalCount: providerKeys.count)
+ }
+ }
+
+ public static func lastUsageDates(_ chronologicalSamples: [UsageSample]) -> [WindowKey: Date] {
+ var previous: [WindowKey: UsageSample] = [:]
+ var result: [WindowKey: Date] = [:]
+ for sample in chronologicalSamples {
+ let prior = previous[sample.key]
+ if sample.usedPercent > 0,
+ prior.map({ sample.resetsAt != $0.resetsAt || sample.usedPercent > $0.usedPercent }) ?? true
+ {
+ result[sample.key] = sample.timestamp
+ }
+ previous[sample.key] = sample
+ }
+ return result
+ }
+
+ static func detail(_ window: QuotaWindow) -> String {
+ switch window.id {
+ case "session", "weekly", "monthly": "window · \(StatusTemplate.windowTag(window))"
+ default: window.id
+ }
+ }
+
+ static func recency(_ date: Date?, now: Date) -> String {
+ guard let date else { return "no usage recorded" }
+ if Format.calendar.isDate(date, inSameDayAs: now) { return "today" }
+ return "last \(date.formatted(.dateTime.month(.abbreviated).day()))"
+ }
+
+ static func normalized(_ text: String) -> String {
+ text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
+
+public struct SettingsProviderPresentation: Sendable, Equatable {
+ public let identity: String?
+ public let lastSuccess: String
+ public let service: String
+
+ public init(state: ProviderState, now: Date) {
+ if let identity = state.snapshot?.identity {
+ self.identity = [identity.email, identity.organization, identity.planName, identity.tier]
+ .compactMap { $0 }
+ .uniqued()
+ .joined(separator: " · ")
+ } else {
+ identity = nil
+ }
+ lastSuccess =
+ state.lastSuccess.map { "Last success \(Format.relativeAge($0, now: now))" } ?? "No successful refresh"
+ service = Self.service(state.serviceHealth)
+ }
+
+ static func service(_ health: ProviderServiceHealth) -> String {
+ switch health {
+ case .unchecked: "Service not checked"
+ case .checking: "Checking service"
+ case .available: "Service available"
+ case .offline(let detail): "Offline · \(detail)"
+ case .rateLimited(let retryAt, let detail):
+ retryAt.map { "Rate limited · retry \($0.formatted(date: .abbreviated, time: .shortened)) · \(detail)" }
+ ?? "Rate limited · \(detail)"
+ case .unavailable(let detail): "Unavailable · \(detail)"
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/StatusBar/AdaptiveWidth.swift b/Sources/TokenMenuBarCore/StatusBar/AdaptiveWidth.swift
new file mode 100644
index 0000000..8df35d7
--- /dev/null
+++ b/Sources/TokenMenuBarCore/StatusBar/AdaptiveWidth.swift
@@ -0,0 +1,64 @@
+import CoreGraphics
+import Foundation
+
+public enum StatusTier: String, CaseIterable, Codable, Sendable {
+ case configured
+ case stacked
+ case worstPerProvider
+ case miniBars
+ case iconOnly
+}
+
+public struct AdaptiveWidthPlanner: Sendable, Equatable {
+ public private(set) var index = 0
+ private var remembered: [String: Int] = [:]
+
+ public init() {}
+
+ /// Starts one tier wider than the tier that last fit for this context: menu bar space comes back when the user
+ /// quits an app or drops a window, and a planner that only narrowed would stay collapsed until a screen change.
+ public mutating func begin(context: String, ladderCount: Int) -> Int {
+ let remembered = min(remembered[context] ?? 0, max(ladderCount - 1, 0))
+ index = max(remembered - 1, 0)
+ return index
+ }
+
+ public mutating func didNotFit(ladderCount: Int) -> Int? {
+ guard index + 1 < ladderCount else { return nil }
+ index += 1
+ return index
+ }
+
+ public mutating func didFit(context: String) {
+ remembered[context] = index
+ }
+
+ public mutating func selectNarrowest(ladderCount: Int) -> Int {
+ index = max(ladderCount - 1, 0)
+ return index
+ }
+
+ public mutating func forget() {
+ remembered.removeAll()
+ index = 0
+ }
+
+ public static func ladder(_ models: [StatusItemModel], widths: [Double]) -> [StatusItemModel] {
+ guard let first = models.first, let firstWidth = widths.first else { return [] }
+ let narrower = zip(models.dropFirst(), widths.dropFirst()).filter { $0.1 < firstWidth }.sorted { $0.1 > $1.1 }
+ var ladder = [first]
+ for (model, _) in narrower where !ladder.contains(model) { ladder.append(model) }
+ return ladder
+ }
+
+ /// macOS parks an item that no longer fits past the screen edge, so an item whose frame stops overlapping every
+ /// screen counts as hidden. Overlap decides it rather than containment, since an edge item can poke a point past.
+ public static func isOnScreen(itemFrame: CGRect, screenFrames: [CGRect]) -> Bool {
+ itemFrame.width > 0 && screenFrames.contains { $0.intersects(itemFrame) }
+ }
+
+ public static func hiddenByNotch(itemFrame: CGRect, leftArea: CGRect?, rightArea: CGRect?) -> Bool {
+ guard let leftArea, let rightArea else { return false }
+ return itemFrame.minX < rightArea.minX && itemFrame.maxX > leftArea.maxX
+ }
+}
diff --git a/Sources/TokenMenuBarCore/StatusBar/MenuCommand.swift b/Sources/TokenMenuBarCore/StatusBar/MenuCommand.swift
new file mode 100644
index 0000000..d3a6203
--- /dev/null
+++ b/Sources/TokenMenuBarCore/StatusBar/MenuCommand.swift
@@ -0,0 +1,41 @@
+import Foundation
+
+/// The right-click menu as data, so what it contains is decided and tested without AppKit.
+public enum MenuCommand: Sendable, Equatable, Identifiable {
+ case refresh
+ case separator
+ case checkForUpdates
+ case quit(appName: String)
+
+ public var id: String {
+ switch self {
+ case .refresh: "refresh"
+ case .separator: "separator"
+ case .checkForUpdates: "updates"
+ case .quit: "quit"
+ }
+ }
+
+ public var title: String {
+ switch self {
+ case .refresh: "Refresh Now"
+ case .separator: ""
+ case .checkForUpdates: "Check for Updates…"
+ case .quit(let appName): "Quit \(appName)"
+ }
+ }
+
+ public var keyEquivalent: String {
+ switch self {
+ case .refresh: "r"
+ case .quit: "q"
+ default: ""
+ }
+ }
+
+ public static func menu(canCheckForUpdates: Bool, appName: String) -> [MenuCommand] {
+ var commands: [MenuCommand] = [.refresh]
+ if canCheckForUpdates { commands += [.separator, .checkForUpdates] }
+ return commands + [.separator, .quit(appName: appName)]
+ }
+}
diff --git a/Sources/TokenMenuBarCore/StatusBar/StatusItemModel.swift b/Sources/TokenMenuBarCore/StatusBar/StatusItemModel.swift
new file mode 100644
index 0000000..0bc9327
--- /dev/null
+++ b/Sources/TokenMenuBarCore/StatusBar/StatusItemModel.swift
@@ -0,0 +1,277 @@
+import Foundation
+
+public enum StatusIconTone: String, Sendable, Equatable {
+ case normal
+ case offline
+ case attention
+}
+
+public struct StatusBar: Hashable, Sendable {
+ public let label: String
+ public let percent: Double
+
+ public init(label: String, percent: Double) {
+ self.label = label
+ self.percent = percent
+ }
+}
+
+public struct StatusCell: Hashable, Sendable, Identifiable {
+ public let id: String
+ public let provider: ProviderID
+ public let lines: [[StatusRun]]
+ public let bars: [StatusBar]
+ public let percent: Double
+ public let tooltip: String
+
+ public init(
+ id: String, provider: ProviderID, lines: [[StatusRun]], bars: [StatusBar] = [], percent: Double, tooltip: String
+ ) {
+ self.id = id
+ self.provider = provider
+ self.lines = lines
+ self.bars = bars
+ self.percent = percent
+ self.tooltip = tooltip
+ }
+
+ public var isMiniBar: Bool {
+ !bars.isEmpty
+ }
+}
+
+public struct StatusItemModel: Hashable, Sendable {
+ public let cells: [StatusCell]
+ public let iconTone: StatusIconTone
+ public let showsIcon: Bool
+ public let countdownActive: Bool
+
+ public init(cells: [StatusCell], iconTone: StatusIconTone, showsIcon: Bool, countdownActive: Bool) {
+ self.cells = cells
+ self.iconTone = iconTone
+ self.showsIcon = showsIcon
+ self.countdownActive = countdownActive
+ }
+
+ public static let empty = StatusItemModel(cells: [], iconTone: .normal, showsIcon: true, countdownActive: false)
+}
+
+public enum WindowOrder: String, CaseIterable, Codable, Sendable {
+ case provider = "By provider"
+ case percent = "By percent used"
+}
+
+public struct StatusItemInput: Sendable {
+ public let snapshots: [ProviderID: ProviderSnapshot]
+ public let availability: [ProviderID: QuotaAvailability]
+ public let selectedKeys: [WindowKey]
+ public let format: StatusFormat
+ public let customTemplate: String
+ public let decimals: Int
+ public let hideZeroCells: Bool
+ public let order: WindowOrder
+ public let labels: [WindowKey: String]
+ public let now: Date
+ public let tier: StatusTier
+
+ public init(
+ snapshots: [ProviderID: ProviderSnapshot],
+ availability: [ProviderID: QuotaAvailability],
+ selectedKeys: [WindowKey],
+ format: StatusFormat,
+ customTemplate: String,
+ decimals: Int,
+ hideZeroCells: Bool,
+ order: WindowOrder,
+ labels: [WindowKey: String],
+ now: Date,
+ tier: StatusTier = .configured
+ ) {
+ self.snapshots = snapshots
+ self.availability = availability
+ self.selectedKeys = selectedKeys
+ self.format = format
+ self.customTemplate = customTemplate
+ self.decimals = decimals
+ self.hideZeroCells = hideZeroCells
+ self.order = order
+ self.labels = labels
+ self.now = now
+ self.tier = tier
+ }
+
+ public func with(tier: StatusTier) -> StatusItemInput {
+ StatusItemInput(
+ snapshots: snapshots, availability: availability, selectedKeys: selectedKeys, format: format,
+ customTemplate: customTemplate, decimals: decimals, hideZeroCells: hideZeroCells, order: order, labels: labels,
+ now: now, tier: tier)
+ }
+
+ var effectiveFormat: StatusFormat {
+ switch tier {
+ case .configured, .iconOnly: format
+ case .stacked, .worstPerProvider: .stacked
+ case .miniBars: .miniBars
+ }
+ }
+}
+
+public enum StatusItemBuilder {
+ public static func defaultSelection(_ snapshots: [ProviderID: ProviderSnapshot]) -> [WindowKey] {
+ snapshots.keys.sorted().flatMap { provider -> [WindowKey] in
+ let windows = snapshots[provider]!.windows.filter(\.isActive)
+ let preferred = windows.filter { $0.id == "session" || $0.id == "weekly" || $0.id.hasPrefix("weekly:") }
+ return (preferred.isEmpty ? Array(windows.prefix(2)) : preferred).map { WindowKey(provider, $0) }
+ }
+ }
+
+ public static func defaultShortLabel(provider: ProviderID, window: QuotaWindow) -> String {
+ if window.scope == nil && ["session", "weekly", "monthly"].contains(window.id) {
+ return ShortLabelPolicy.draft("\(provider.shortLabel) \(StatusTemplate.windowTag(window))")
+ }
+ return ShortLabelPolicy.draft(semanticShortLabel(provider: provider, window: window))
+ }
+
+ static func semanticShortLabel(provider: ProviderID, window: QuotaWindow) -> String {
+ let source = window.scope ?? window.label
+ let words =
+ source
+ .components(separatedBy: CharacterSet.alphanumerics.inverted)
+ .filter { !$0.isEmpty }
+ let normalized = words.joined(separator: " ").lowercased()
+ let digits = words.flatMap { $0.filter(\.isNumber) }
+ if normalized.contains("opus") { return "OP" + (digits.first.map(String.init) ?? "") }
+ if normalized.contains("sonnet") { return "SO" }
+ if normalized.contains("haiku") { return "HA" }
+ if normalized.contains("fable") { return "FAB" }
+ if normalized.contains("spark") { return "SPK" }
+ if normalized.contains("flash") { return provider == .gemini ? "GFL" : "FLA" }
+ if provider == .gemini, normalized.contains("pro"), !digits.isEmpty { return "G" + String(digits.prefix(2)) }
+ if normalized.contains("code review") { return "CR" }
+ if normalized.contains("on demand") { return "OND" }
+ if normalized.contains("premium") { return "PRE" }
+ if provider == .copilot, normalized.contains("completion") { return "GHX" }
+ if provider == .copilot, normalized.contains("chat") { return "GHC" }
+ let ignored = Set(["claude", "codex", "gemini", "github", "copilot", "model", "models", "window"])
+ let meaningful = words.filter { !ignored.contains($0.lowercased()) }
+ guard meaningful.count != 1 else { return String(meaningful[0].prefix(3)).uppercased() }
+ let initials = meaningful.compactMap(\.first).map(String.init).joined().uppercased()
+ if initials.count >= 2 { return String(initials.prefix(3)) }
+ return String(source.prefix(3)).uppercased()
+ }
+
+ public static func candidates(_ input: StatusItemInput) -> [StatusItemModel] {
+ var models: [StatusItemModel] = []
+ for tier in StatusTier.allCases {
+ let model = build(input.with(tier: tier))
+ if !models.contains(model) { models.append(model) }
+ }
+ return models
+ }
+
+ public static func build(_ input: StatusItemInput) -> StatusItemModel {
+ let tone: StatusIconTone =
+ input.availability.values.contains(.authenticationRequired)
+ ? .attention : input.availability.values.contains(.networkUnavailable) ? .offline : .normal
+ if input.tier == .iconOnly {
+ return StatusItemModel(cells: [], iconTone: tone, showsIcon: true, countdownActive: false)
+ }
+ let format = input.effectiveFormat
+ let template = StatusTemplate.compile(format.template ?? input.customTemplate)
+ let countdown = format != .miniBars && template.referencesCountdown
+ let selectedEntries = input.selectedKeys.compactMap { key -> (WindowKey, ProviderSnapshot, QuotaWindow)? in
+ guard let snapshot = input.snapshots[key.provider], let window = snapshot.window(key.windowID) else { return nil }
+ return (key, snapshot, window)
+ }
+ var availableWindows: [WindowKey: QuotaWindow] = [:]
+ for (provider, snapshot) in input.snapshots {
+ for window in snapshot.windows { availableWindows[WindowKey(provider, window)] = window }
+ }
+ let labels = ShortLabelPolicy.resolvedLabels(windows: availableWindows, overrides: input.labels)
+ var entries = selectedEntries
+ if input.hideZeroCells { entries = entries.filter { $0.2.usedPercent > 0 } }
+ if input.tier == .worstPerProvider { entries = worstPerProvider(entries) }
+ if input.order == .percent { entries.sort { $0.2.usedPercent > $1.2.usedPercent } }
+ let cells: [StatusCell]
+ if format == .miniBars {
+ let providers = input.order == .percent ? orderedProviders(entries) : entries.map(\.0.provider).uniqued()
+ cells = providers.map { provider in
+ let own = entries.filter { $0.0.provider == provider }
+ let bars = own.map {
+ StatusBar(
+ label: labels[$0.0]!,
+ percent: $0.2.usedPercent)
+ }
+ let tooltip = own.map { "\($0.2.label): \(Format.percent($0.2.usedPercent))" }.joined(separator: "\n")
+ return StatusCell(
+ id: provider.rawValue, provider: provider, lines: [], bars: bars,
+ percent: own.map(\.2.usedPercent).max()!, tooltip: tooltip)
+ }
+ } else {
+ let perProvider = Dictionary(grouping: entries, by: \.0.provider).mapValues(\.count)
+ cells = entries.map { key, snapshot, window in
+ let tag = StatusTemplate.windowTag(window)
+ let context = StatusCellContext(
+ provider: key.provider,
+ window: window,
+ cellLabel: perProvider[key.provider]! > 1
+ ? "\(key.provider.shortLabel) \(tag)" : key.provider.shortLabel,
+ shortLabel: labels[key]!,
+ decimals: input.decimals,
+ planName: snapshot.identity?.planName,
+ credits: snapshot.credits?.formattedBalance,
+ now: input.now
+ )
+ let lines = StatusTemplate.render(template, context: context)
+ let tooltip =
+ "\(key.provider.displayName) \(window.label): \(Format.percent(window.usedPercent)), "
+ + "resets \(Format.countdown(to: window.resetsAt, now: input.now))"
+ return StatusCell(
+ id: key.storageKey, provider: key.provider, lines: lines, percent: window.usedPercent, tooltip: tooltip)
+ }
+ }
+ return StatusItemModel(
+ cells: cells, iconTone: tone, showsIcon: cells.isEmpty,
+ countdownActive: countdown && !cells.isEmpty)
+ }
+
+ static func worstPerProvider(
+ _ entries: [(WindowKey, ProviderSnapshot, QuotaWindow)]
+ ) -> [(
+ WindowKey, ProviderSnapshot, QuotaWindow
+ )] {
+ var worst: [ProviderID: (WindowKey, ProviderSnapshot, QuotaWindow)] = [:]
+ for entry in entries where (worst[entry.0.provider]?.2.usedPercent ?? -1) < entry.2.usedPercent {
+ worst[entry.0.provider] = entry
+ }
+ return entries.map(\.0.provider).uniqued().compactMap { worst[$0] }
+ }
+
+ static func orderedProviders(_ entries: [(WindowKey, ProviderSnapshot, QuotaWindow)]) -> [ProviderID] {
+ var best: [ProviderID: Double] = [:]
+ for entry in entries { best[entry.0.provider] = max(best[entry.0.provider] ?? 0, entry.2.usedPercent) }
+ return best.keys.sorted { (best[$0]!, $1) > (best[$1]!, $0) }
+ }
+}
+
+extension Array where Element: Hashable {
+ func uniqued() -> [Element] {
+ var seen = Set()
+ return filter { seen.insert($0).inserted }
+ }
+}
+
+/// Text metrics for the status item. They live here rather than in the renderer because they are arithmetic on the
+/// menu bar height, and the two-line case has to match the proportions of the system's own widgets.
+public enum StatusMetrics {
+ public static let maxFontSize: Double = 13
+ public static let minFontSize: Double = 8
+
+ public static func fontSizes(height: Double, lineCount: Int) -> [Double] {
+ switch lineCount {
+ case ...1: [maxFontSize]
+ case 2: [9, 11.5]
+ default: Array(repeating: max(minFontSize, min(9, height / Double(lineCount) * 0.8)), count: lineCount)
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift b/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift
new file mode 100644
index 0000000..1ab8f95
--- /dev/null
+++ b/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift
@@ -0,0 +1,221 @@
+import Foundation
+
+public enum StatusFormat: String, CaseIterable, Codable, Sendable {
+ case stacked = "Stacked"
+ case inline = "Inline"
+ case miniBars = "Mini bars"
+ case custom = "Custom"
+
+ public var template: String? {
+ switch self {
+ case .stacked: "{label}\n{pct}"
+ case .inline: "{label}:{pct}"
+ case .miniBars, .custom: nil
+ }
+ }
+}
+
+public struct StatusRun: Hashable, Sendable {
+ public enum Kind: Hashable, Sendable {
+ case label
+ case number
+ case usage(Double)
+ }
+
+ public let text: String
+ public let kind: Kind
+
+ public init(text: String, kind: Kind) {
+ self.text = text
+ self.kind = kind
+ }
+}
+
+public struct StatusCellContext: Sendable {
+ public let provider: ProviderID
+ public let window: QuotaWindow
+ public let cellLabel: String
+ public let shortLabel: String
+ public let decimals: Int
+ public let planName: String?
+ public let credits: String?
+ public let now: Date
+
+ public init(
+ provider: ProviderID, window: QuotaWindow, cellLabel: String, shortLabel: String, decimals: Int, planName: String?,
+ credits: String?, now: Date
+ ) {
+ self.provider = provider
+ self.window = window
+ self.cellLabel = cellLabel
+ self.shortLabel = shortLabel
+ self.decimals = decimals
+ self.planName = planName
+ self.credits = credits
+ self.now = now
+ }
+}
+
+public enum StatusTemplate {
+ public static let tokens: [(token: String, help: String)] = [
+ ("{cell}", "provider tag, plus the window tag when a provider shows several windows"),
+ ("{provider}", "provider tag (CC, CX)"),
+ ("{providerName}", "provider name"),
+ ("{window}", "window tag (5h, 7d, model)"),
+ ("{label}", "editable short label"),
+ ("{pct}", "used percent at the configured decimals"),
+ ("{pct0}", "used percent, no decimals"),
+ ("{pct1}", "used percent, one decimal"),
+ ("{remaining}", "remaining percent"),
+ ("{reset}", "countdown to reset"),
+ ("{resetClock}", "reset time"),
+ ("{plan}", "plan name"),
+ ("{credits}", "credit balance"),
+ ]
+
+ enum Token: Equatable {
+ case text(String)
+ case placeholder(String)
+ case newline
+ }
+
+ /// A template parsed once. The status bar renders the same template for every cell, and again for every tier the
+ /// adaptive ladder tries, so parsing per render walked the string two dozen times per rebuild.
+ public struct Compiled: Sendable {
+ let tokens: [Token]
+ public let referencesCountdown: Bool
+
+ init(_ template: String) {
+ tokens = StatusTemplate.parse(template)
+ referencesCountdown = tokens.contains { $0 == .placeholder("reset") }
+ }
+ }
+
+ public static func compile(_ template: String) -> Compiled {
+ Compiled(template)
+ }
+
+ public static func referencesCountdown(_ template: String) -> Bool {
+ Compiled(template).referencesCountdown
+ }
+
+ public static func render(_ template: String, context: StatusCellContext) -> [[StatusRun]] {
+ render(Compiled(template), context: context)
+ }
+
+ public static func render(_ compiled: Compiled, context: StatusCellContext) -> [[StatusRun]] {
+ var lines: [[StatusRun]] = [[]]
+ for token in compiled.tokens {
+ switch token {
+ case .newline: lines.append([])
+ case .text(let text): lines[lines.count - 1].append(StatusRun(text: text, kind: .label))
+ case .placeholder(let name):
+ if let run = run(for: name, context: context) { lines[lines.count - 1].append(run) }
+ }
+ }
+ return lines.filter { !$0.isEmpty }
+ }
+
+ static func parse(_ template: String) -> [Token] {
+ var tokens: [Token] = []
+ var text = ""
+ var iterator = template.makeIterator()
+ var pending: Character?
+ func flush() {
+ if !text.isEmpty { tokens.append(.text(text)) }
+ text = ""
+ }
+ while let character = pending ?? iterator.next() {
+ pending = nil
+ switch character {
+ case "\\":
+ if let next = iterator.next() {
+ if next == "n" {
+ flush()
+ tokens.append(.newline)
+ } else {
+ text.append(next)
+ }
+ }
+ case "\n":
+ flush()
+ tokens.append(.newline)
+ case "{":
+ if let next = iterator.next() {
+ if next == "{" {
+ text.append("{")
+ } else {
+ var name = String(next)
+ var closed = false
+ while let inner = iterator.next() {
+ if inner == "}" {
+ closed = true
+ break
+ }
+ name.append(inner)
+ }
+ if closed {
+ flush()
+ tokens.append(.placeholder(name))
+ } else {
+ text += "{" + name
+ }
+ }
+ } else {
+ text.append("{")
+ }
+ case "}":
+ if let next = iterator.next() {
+ if next != "}" { pending = next }
+ }
+ text.append("}")
+ default:
+ text.append(character)
+ }
+ }
+ flush()
+ return tokens
+ }
+
+ static func run(for name: String, context: StatusCellContext) -> StatusRun? {
+ let percent = context.window.usedPercent
+ switch name {
+ case "cell": return StatusRun(text: context.cellLabel, kind: .label)
+ case "provider": return StatusRun(text: context.provider.shortLabel, kind: .label)
+ case "providerName": return StatusRun(text: context.provider.displayName, kind: .label)
+ case "window": return StatusRun(text: windowTag(context.window), kind: .label)
+ case "label": return StatusRun(text: context.shortLabel, kind: .label)
+ case "pct": return StatusRun(text: Format.percent(percent, decimals: context.decimals), kind: .usage(percent))
+ case "pct0": return StatusRun(text: Format.percent(percent, decimals: 0), kind: .usage(percent))
+ case "pct1": return StatusRun(text: Format.percent(percent, decimals: 1), kind: .usage(percent))
+ case "pct2": return StatusRun(text: Format.percent(percent, decimals: 2), kind: .usage(percent))
+ case "remaining":
+ return StatusRun(
+ text: Format.percent(context.window.remainingPercent, decimals: context.decimals), kind: .usage(percent))
+ case "reset":
+ return StatusRun(text: Format.compactCountdown(to: context.window.resetsAt, now: context.now), kind: .number)
+ case "resetClock":
+ return StatusRun(text: Format.resetClock(context.window.resetsAt, now: context.now), kind: .number)
+ case "plan": return context.planName.map { StatusRun(text: $0, kind: .label) }
+ case "credits": return context.credits.map { StatusRun(text: $0, kind: .number) }
+ default: return nil
+ }
+ }
+
+ public static func plainText(_ lines: [[StatusRun]]) -> String {
+ lines.map { $0.map(\.text).joined() }.joined(separator: "\n")
+ }
+
+ public static func windowTag(_ window: QuotaWindow) -> String {
+ switch window.id {
+ case "session": return "5h"
+ case "weekly": return "7d"
+ case "monthly": return "1mo"
+ default:
+ if let scope = window.scope {
+ return String((scope.split(separator: " ").first.map(String.init) ?? scope).prefix(3)).uppercased()
+ }
+ return String((window.id.split(separator: ":").last.map(String.init) ?? window.id).prefix(3)).uppercased()
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/StatusBar/UsageColor.swift b/Sources/TokenMenuBarCore/StatusBar/UsageColor.swift
new file mode 100644
index 0000000..1aacb5e
--- /dev/null
+++ b/Sources/TokenMenuBarCore/StatusBar/UsageColor.swift
@@ -0,0 +1,44 @@
+import Foundation
+
+public struct HSBColor: Hashable, Sendable {
+ public let hue: Double
+ public let saturation: Double
+ public let brightness: Double
+
+ public init(hue: Double, saturation: Double, brightness: Double) {
+ self.hue = hue
+ self.saturation = saturation
+ self.brightness = brightness
+ }
+
+ func mixed(with other: HSBColor, fraction: Double) -> HSBColor {
+ if fraction <= 0 { return self }
+ if fraction >= 1 { return other }
+ return HSBColor(
+ hue: hue + (other.hue - hue) * fraction,
+ saturation: saturation + (other.saturation - saturation) * fraction,
+ brightness: brightness + (other.brightness - brightness) * fraction)
+ }
+}
+
+public enum UsageColor {
+ public static let green = HSBColor(hue: 0.38, saturation: 0.72, brightness: 0.52)
+ public static let orange = HSBColor(hue: 0.08, saturation: 0.9, brightness: 0.86)
+ public static let red = HSBColor(hue: 0.0, saturation: 0.85, brightness: 0.8)
+ public static let orangeAt = 0.6
+
+ public static func color(percent: Double) -> HSBColor {
+ let fraction = min(max(percent, 0), 100) / 100
+ return fraction < orangeAt
+ ? green.mixed(with: orange, fraction: pow(fraction / orangeAt, 2))
+ : orange.mixed(with: red, fraction: (fraction - orangeAt) / (1 - orangeAt))
+ }
+
+ public static func color(pace: PaceStatus, percent: Double) -> HSBColor {
+ switch pace {
+ case .ahead: orange
+ case .exhausted: red
+ default: color(percent: percent)
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarCore/TooltipPolicy.swift b/Sources/TokenMenuBarCore/TooltipPolicy.swift
new file mode 100644
index 0000000..c97a647
--- /dev/null
+++ b/Sources/TokenMenuBarCore/TooltipPolicy.swift
@@ -0,0 +1,116 @@
+import CoreGraphics
+import Foundation
+
+public enum TooltipTiming {
+ public static let presentationDelay: Duration = .milliseconds(150)
+ public static let dismissalDelay: Duration = .milliseconds(150)
+ public static let fadeDuration: TimeInterval = 0.09
+}
+
+public struct TooltipOwner: Hashable, Sendable {
+ public let rawValue: UInt64
+
+ public init(rawValue: UInt64) {
+ self.rawValue = rawValue
+ }
+}
+
+public struct TooltipRequest: Equatable, Sendable {
+ public let owner: TooltipOwner
+ public let generation: UInt64
+
+ public init(owner: TooltipOwner, generation: UInt64) {
+ self.owner = owner
+ self.generation = generation
+ }
+}
+
+public struct TooltipArbiter: Equatable, Sendable {
+ public private(set) var generation: UInt64 = 0
+ public private(set) var pending: TooltipRequest?
+ public private(set) var visible: TooltipRequest?
+
+ public init() {}
+
+ public mutating func arm(owner: TooltipOwner) -> TooltipRequest? {
+ if pending?.owner == owner || visible?.owner == owner { return nil }
+ generation &+= 1
+ let request = TooltipRequest(owner: owner, generation: generation)
+ pending = request
+ return request
+ }
+
+ public mutating func present(_ request: TooltipRequest) -> Bool {
+ guard pending == request else { return false }
+ pending = nil
+ visible = request
+ return true
+ }
+
+ public mutating func dismiss(owner: TooltipOwner) -> Bool {
+ if pending?.owner == owner {
+ generation &+= 1
+ pending = nil
+ return true
+ }
+ if visible?.owner == owner {
+ generation &+= 1
+ visible = nil
+ return true
+ }
+ return false
+ }
+
+ public mutating func dismissAll() {
+ guard pending != nil || visible != nil else { return }
+ clear()
+ }
+
+ private mutating func clear() {
+ generation &+= 1
+ pending = nil
+ visible = nil
+ }
+}
+
+public enum TooltipSide: Sendable, Equatable {
+ case above
+ case below
+}
+
+public struct TooltipPlacement: Sendable, Equatable {
+ public let origin: CGPoint
+ public let side: TooltipSide
+
+ public init(origin: CGPoint, side: TooltipSide) {
+ self.origin = origin
+ self.side = side
+ }
+}
+
+public enum TooltipGeometry {
+ public static let screenInset: CGFloat = 8
+ public static let targetGap: CGFloat = 7
+ public static let maximumWidth: CGFloat = 320
+
+ public static func placement(
+ anchor: CGRect,
+ tooltipSize: CGSize,
+ visibleFrame: CGRect,
+ screenInset: CGFloat = screenInset,
+ targetGap: CGFloat = targetGap
+ ) -> TooltipPlacement {
+ let minimumX = visibleFrame.minX + screenInset
+ let maximumX = max(minimumX, visibleFrame.maxX - screenInset - tooltipSize.width)
+ let preferredX = anchor.midX - tooltipSize.width / 2
+ let x = min(max(preferredX, minimumX), maximumX)
+
+ let minimumY = visibleFrame.minY + screenInset
+ let maximumY = max(minimumY, visibleFrame.maxY - screenInset - tooltipSize.height)
+ let below = anchor.minY - targetGap - tooltipSize.height
+ let side: TooltipSide = below >= minimumY ? .below : .above
+ let preferredY = side == .below ? below : anchor.maxY + targetGap
+ let y = min(max(preferredY, minimumY), maximumY)
+ return TooltipPlacement(origin: CGPoint(x: x, y: y), side: side)
+ }
+}
diff --git a/Sources/TokenMenuBarCore/UsagePresentation.swift b/Sources/TokenMenuBarCore/UsagePresentation.swift
new file mode 100644
index 0000000..b238de3
--- /dev/null
+++ b/Sources/TokenMenuBarCore/UsagePresentation.swift
@@ -0,0 +1,140 @@
+import Foundation
+
+public enum UsageDeadline: Sendable, Hashable {
+ case age(Date?)
+ case reset(Date?)
+
+ public func text(at now: Date) -> String {
+ lines(at: now).joined(separator: " · ")
+ }
+
+ public func lines(at now: Date) -> [String] {
+ switch self {
+ case .age(let date): [Format.relativeAge(date, now: now)]
+ case .reset(let date):
+ date.map { ["Resets in \(Format.countdown(to: $0, now: now))", Format.resetClock($0, now: now)] }
+ ?? ["No reset scheduled"]
+ }
+ }
+
+ public func nextUpdate(after now: Date) -> Date? {
+ switch self {
+ case .age(let date):
+ guard let date else { return nil }
+ let seconds = max(now.timeIntervalSince(date), 0)
+ let interval: TimeInterval
+ switch seconds {
+ case ..<3600: interval = 60
+ case ..<86400: interval = 3600
+ default: interval = 86400
+ }
+ let boundary = date.addingTimeInterval((floor(seconds / interval) + 1) * interval)
+ return boundary
+ case .reset(let date):
+ guard let date, date > now else { return nil }
+ let seconds = date.timeIntervalSince(now)
+ guard seconds >= 60 else { return date }
+ let remainder = seconds.truncatingRemainder(dividingBy: 60)
+ return now.addingTimeInterval(max(remainder, 1))
+ }
+ }
+}
+
+public struct UsageMetricPresentation: Sendable, Hashable, Identifiable {
+ public let title: String
+ public let value: String
+ public let help: String
+
+ public var id: String { title }
+
+ public init(title: String, value: String, help: String) {
+ self.title = title
+ self.value = value
+ self.help = help
+ }
+}
+
+public struct UsageSpendPresentation: Sendable, Hashable {
+ public let spend: SpendControl
+ public let provider: ProviderID
+ public let title: String
+ public let summary: String
+ public let metrics: [UsageMetricPresentation]
+
+ public init(
+ spend: SpendControl, provider: ProviderID, title: String, summary: String, metrics: [UsageMetricPresentation]
+ ) {
+ self.spend = spend
+ self.provider = provider
+ self.title = title
+ self.summary = summary
+ self.metrics = metrics
+ }
+}
+
+public struct UsageCreditsPresentation: Sendable, Hashable {
+ public let credits: CreditBalance?
+ public let resetCredits: ResetCredits?
+ public let metrics: [UsageMetricPresentation]
+
+ public init(
+ credits: CreditBalance?, resetCredits: ResetCredits?, metrics: [UsageMetricPresentation]
+ ) {
+ self.credits = credits
+ self.resetCredits = resetCredits
+ self.metrics = metrics
+ }
+}
+
+public struct UsageLocalPresentation: Sendable, Hashable {
+ public let usage: LocalUsage
+ public let metrics: [UsageMetricPresentation]
+
+ public init(usage: LocalUsage, metrics: [UsageMetricPresentation]) {
+ self.usage = usage
+ self.metrics = metrics
+ }
+}
+
+public struct UsageAnalyticsPresentation: Sendable, Hashable {
+ public let codeReviews: String?
+
+ public init(codeReviews: String? = nil) {
+ self.codeReviews = codeReviews
+ }
+}
+
+public struct UsagePresentation: Sendable, Equatable {
+ public let builtAt: Date
+ public let lastRefresh: Date?
+ public let iconTone: StatusIconTone
+ public let isRefreshing: Bool
+ public let cards: [ProviderCard]
+ public let emptyTitle: String
+ public let emptyDescription: String
+
+ public init(
+ builtAt: Date, lastRefresh: Date?, iconTone: StatusIconTone, isRefreshing: Bool, cards: [ProviderCard],
+ emptyTitle: String, emptyDescription: String
+ ) {
+ self.builtAt = builtAt
+ self.lastRefresh = lastRefresh
+ self.iconTone = iconTone
+ self.isRefreshing = isRefreshing
+ self.cards = cards
+ self.emptyTitle = emptyTitle
+ self.emptyDescription = emptyDescription
+ }
+
+ public func updatedText(at now: Date) -> String {
+ "Updated \(UsageDeadline.age(lastRefresh).text(at: now))"
+ }
+
+ public func nextDeadline(after now: Date) -> Date? {
+ var deadlines = cards.flatMap { card in
+ card.rows.map(\.resetDeadline) + (card.rows.isEmpty ? [] : [.age(card.fetchedAt)])
+ }
+ deadlines.append(.age(lastRefresh))
+ return deadlines.compactMap { $0.nextUpdate(after: now) }.min()
+ }
+}
diff --git a/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift b/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift
new file mode 100644
index 0000000..3eb8367
--- /dev/null
+++ b/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift
@@ -0,0 +1,117 @@
+import Foundation
+
+public struct WidgetRow: Codable, Sendable, Hashable, Identifiable {
+ public let key: WindowKey
+ public let providerName: String
+ public let label: String
+ public let usedPercent: Double
+ public let resetsAt: Date?
+
+ public init(key: WindowKey, providerName: String, label: String, usedPercent: Double, resetsAt: Date?) {
+ self.key = key
+ self.providerName = providerName
+ self.label = label
+ self.usedPercent = usedPercent
+ self.resetsAt = resetsAt
+ }
+
+ public var id: WindowKey { key }
+
+ public var percentText: String {
+ Format.percent(usedPercent)
+ }
+
+ public func resetText(now: Date) -> String {
+ Format.countdown(to: resetsAt, now: now)
+ }
+}
+
+public struct WidgetSnapshot: Codable, Sendable, Hashable {
+ public static let appGroup = "group.dev.tox.token-menu-bar"
+ public static let appGroupInfoKey = "TokenMenuBarAppGroup"
+ public static let fileName = "widget.json"
+
+ public static func appGroup(info: [String: Any]?) -> String {
+ guard let configured = info?[appGroupInfoKey] as? String, !configured.isEmpty, !configured.hasPrefix("$(") else {
+ return appGroup
+ }
+ return configured
+ }
+
+ public let rows: [WidgetRow]
+ public let attention: Bool
+ public let updatedAt: Date
+
+ public init(rows: [WidgetRow], attention: Bool, updatedAt: Date) {
+ self.rows = rows
+ self.attention = attention
+ self.updatedAt = updatedAt
+ }
+
+ /// Shown until the app writes its first snapshot. Empty on purpose: `placeholder` carries sample percentages
+ /// and would otherwise read as the viewer's own quota.
+ public static let unavailable = WidgetSnapshot(rows: [], attention: false, updatedAt: .distantPast)
+
+ public static let placeholder = WidgetSnapshot(
+ rows: [
+ WidgetRow(
+ key: WindowKey(provider: .claude, windowID: "session"), providerName: "Claude", label: "Current session",
+ usedPercent: 36, resetsAt: Date().addingTimeInterval(4 * 3600)),
+ WidgetRow(
+ key: WindowKey(provider: .claude, windowID: "weekly"), providerName: "Claude", label: "All models",
+ usedPercent: 61, resetsAt: Date().addingTimeInterval(3 * 86400)),
+ WidgetRow(
+ key: WindowKey(provider: .codex, windowID: "weekly"), providerName: "Codex", label: "Weekly", usedPercent: 76,
+ resetsAt: Date().addingTimeInterval(6 * 86400)),
+ ], attention: false, updatedAt: Date())
+
+ public static func build(
+ snapshots: [ProviderID: ProviderSnapshot], availability: [ProviderID: QuotaAvailability],
+ selectedKeys: [WindowKey], now: Date
+ ) -> WidgetSnapshot {
+ let rows = selectedKeys.compactMap { key -> WidgetRow? in
+ guard let snapshot = snapshots[key.provider], let window = snapshot.window(key.windowID) else { return nil }
+ return WidgetRow(
+ key: key, providerName: key.provider.displayName, label: window.label, usedPercent: window.usedPercent,
+ resetsAt: window.resetsAt)
+ }
+ return WidgetSnapshot(
+ rows: rows, attention: availability.values.contains(.authenticationRequired), updatedAt: now)
+ }
+
+ public var hasData: Bool {
+ updatedAt != .distantPast
+ }
+
+ public var isStale: Bool {
+ Date().timeIntervalSince(updatedAt) > 3600
+ }
+}
+
+public struct WidgetSnapshotStore: Sendable {
+ public let url: URL
+
+ public init(url: URL) {
+ self.url = url
+ }
+
+ public static func sharedURL(
+ containerURL: (String) -> URL?, fallbackDirectory: URL, appGroup: String = WidgetSnapshot.appGroup
+ ) -> URL {
+ (containerURL(appGroup) ?? fallbackDirectory).appendingPathComponent(WidgetSnapshot.fileName)
+ }
+
+ public func write(_ snapshot: WidgetSnapshot) throws {
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .secondsSince1970
+ try encoder.encode(snapshot).write(to: url, options: .atomic)
+ }
+
+ public func read() -> WidgetSnapshot? {
+ guard let data = try? Data(contentsOf: url) else { return nil }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .secondsSince1970
+ return try? decoder.decode(WidgetSnapshot.self, from: data)
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Adapters/LaunchAtLoginService.swift b/Sources/TokenMenuBarUI/Adapters/LaunchAtLoginService.swift
new file mode 100644
index 0000000..c787ecc
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Adapters/LaunchAtLoginService.swift
@@ -0,0 +1,27 @@
+import AppKit
+import ServiceManagement
+import TokenMenuBarCore
+
+public enum LaunchAtLoginService {
+ public static func backend(
+ service: @escaping @Sendable () -> SMAppService = { .mainApp },
+ openSettings: @escaping @Sendable () -> Void = SMAppService.openSystemSettingsLoginItems
+ ) -> LaunchAtLoginBackend {
+ LaunchAtLoginBackend(
+ status: { status(service().status) },
+ register: { try service().register() },
+ unregister: { try service().unregister() },
+ openSettings: openSettings
+ )
+ }
+
+ public static func status(_ status: SMAppService.Status) -> LaunchAtLoginBackend.Status {
+ switch status {
+ case .enabled: .enabled
+ case .notRegistered: .notRegistered
+ case .notFound: .notFound
+ case .requiresApproval: .requiresApproval
+ @unknown default: .unknown
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Adapters/Notifier.swift b/Sources/TokenMenuBarUI/Adapters/Notifier.swift
new file mode 100644
index 0000000..47ca7d8
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Adapters/Notifier.swift
@@ -0,0 +1,74 @@
+import Foundation
+import TokenMenuBarCore
+import UserNotifications
+
+public protocol NotificationCenterProtocol: AnyObject, Sendable {
+ func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
+ func add(_ request: UNNotificationRequest) async throws
+ func removeDeliveredNotifications(withIdentifiers identifiers: [String])
+}
+
+extension UNUserNotificationCenter: @retroactive @unchecked Sendable {}
+extension UNUserNotificationCenter: NotificationCenterProtocol {}
+
+@MainActor
+public final class Notifier {
+ private let center: (any NotificationCenterProtocol)?
+ private let log: LogBuffer
+ private(set) var authorized = false
+ // Threshold events are dropped once their window resets, but authentication and credit ones have no such trigger,
+ // and a denied permission prompt means nothing ever drains `pending`. Both are capped so neither grows for the
+ // lifetime of the process.
+ static let historyLimit = 50
+ private(set) var delivered: [NotificationEvent] = []
+ private(set) var pending: [NotificationEvent] = []
+
+ public init(center: (any NotificationCenterProtocol)?, log: LogBuffer) {
+ self.center = center
+ self.log = log
+ }
+
+ public func requestAuthorization() async {
+ guard let center else { return }
+ do {
+ authorized = try await center.requestAuthorization(options: [.alert, .sound, .badge])
+ log.logDebug("notifications authorized=\(authorized)")
+ } catch {
+ log.logError("notification authorization failed: \(error.localizedDescription)")
+ }
+ let queued = pending
+ pending = []
+ guard authorized, !queued.isEmpty else { return }
+ log.logDebug("flushing \(queued.count) notifications held during authorization")
+ await deliver(queued)
+ }
+
+ public func deliver(_ events: [NotificationEvent]) async {
+ guard let center else { return }
+ guard authorized else {
+ // The refresh loop starts before the permission prompt is answered; holding the events means the first
+ // threshold crossing still arrives once the user allows notifications.
+ pending = (pending + events).suffix(Self.historyLimit)
+ return
+ }
+ delivered = (delivered + events).suffix(Self.historyLimit)
+ for event in events {
+ let content = UNMutableNotificationContent()
+ content.title = event.title
+ content.body = event.body
+ content.threadIdentifier = event.provider.rawValue
+ content.sound = .default
+ do {
+ try await center.add(UNNotificationRequest(identifier: event.id, content: content, trigger: nil))
+ } catch {
+ log.logError("notification delivery failed: \(error.localizedDescription)")
+ }
+ }
+ let reset = Set(events.filter { $0.kind == .reset }.compactMap(\.window))
+ if !reset.isEmpty {
+ let stale = Set(delivered.filter { $0.kind == .threshold && $0.window.map(reset.contains) == true }.map(\.id))
+ center.removeDeliveredNotifications(withIdentifiers: Array(stale))
+ delivered.removeAll { stale.contains($0.id) }
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Adapters/PanelMaterialAdapter.swift b/Sources/TokenMenuBarUI/Adapters/PanelMaterialAdapter.swift
new file mode 100644
index 0000000..34f50d9
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Adapters/PanelMaterialAdapter.swift
@@ -0,0 +1,46 @@
+import SwiftUI
+import TokenMenuBarCore
+
+enum PanelSurfaceFill: Equatable {
+ case inherited
+ case windowBackground
+}
+
+@MainActor
+enum PanelMaterialAdapter {
+ static let generation: PlatformDesignGeneration = {
+ if #available(macOS 26, *) { return .macOS26 }
+ if #available(macOS 15, *) { return .macOS15 }
+ return .macOS14
+ }()
+
+ static func material(for surface: PanelSurfaceRole) -> PanelMaterial {
+ PanelMaterialPolicy.material(for: surface, generation: generation)
+ }
+
+ static func fill(for surface: PanelSurfaceRole) -> PanelSurfaceFill {
+ switch material(for: surface) {
+ case .system: .inherited
+ case .standardContent: .windowBackground
+ }
+ }
+}
+
+extension View {
+ func panelSurface(_ surface: PanelSurfaceRole) -> some View {
+ modifier(PanelSurfaceModifier(fill: PanelMaterialAdapter.fill(for: surface)))
+ }
+}
+
+private struct PanelSurfaceModifier: ViewModifier {
+ let fill: PanelSurfaceFill
+
+ @ViewBuilder func body(content: Content) -> some View {
+ switch fill {
+ case .inherited:
+ content
+ case .windowBackground:
+ content.background(Color(nsColor: .windowBackgroundColor))
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarUI/AppController.swift b/Sources/TokenMenuBarUI/AppController.swift
new file mode 100644
index 0000000..4d6608e
--- /dev/null
+++ b/Sources/TokenMenuBarUI/AppController.swift
@@ -0,0 +1,874 @@
+import AppKit
+import SwiftUI
+import TokenMenuBarCore
+
+@MainActor
+final class AppControllerMenuSource {
+ private weak var controller: AppController?
+
+ init(_ controller: AppController?) {
+ self.controller = controller
+ }
+
+ func menu() -> NSMenu {
+ guard let controller else { return NSMenu() }
+ return controller.contextMenu()
+ }
+}
+
+@MainActor
+public protocol UpdaterHook: AnyObject {
+ var canCheck: Bool { get }
+ var automaticallyChecks: Bool { get set }
+ func checkForUpdates()
+}
+
+@MainActor
+public struct AppDependencies {
+ public var appInfo: AppInfo
+ public var settings: TokenMenuBarCore.Settings
+ public var state: AppState
+ public var history: UsageHistoryStore
+ public var log: LogBuffer
+ public var registry: ProviderRegistry
+ public var notifier: Notifier
+ public var launchAtLogin: LaunchAtLoginBackend
+ public var clock: Clock
+ public var updater: (any UpdaterHook)?
+ public var statusBar: NSStatusBar
+ public var isSandboxed: Bool
+ public var isDemo: Bool
+ public var openURL: @MainActor (URL) -> Void
+ public var copyToPasteboard: @MainActor (String) -> Void
+ public var revealInFinder: @MainActor (URL) -> Void
+ public var chooseExportURL: () -> URL?
+ public var chooseDirectory: (SandboxResource) -> URL?
+ public var terminate: @MainActor () -> Void
+ public var relaunch: @MainActor @Sendable () -> Void
+ public var widgetStore: WidgetSnapshotStore?
+ public var snapshotCache: SnapshotCache
+ public var persistence: SnapshotPersistence
+ public var reloadWidgets: @MainActor @Sendable () -> Void
+ public var rebuildProviders: @MainActor @Sendable (TokenMenuBarCore.Settings) async -> ProviderRegistry
+ public var screenVisibleFrame: () -> CGRect?
+ public var openPopoverOnLaunch: Bool
+ public var presentsWindows: Bool
+ public var persistsStatusItemPosition: Bool
+ public var recoversOffscreenPopover: Bool
+ public var verificationSession: String?
+ public var verificationSnapshotURL: URL?
+ public var captureProcessSnapshot: @Sendable () -> ProcessPerformanceSnapshot?
+
+ public init(
+ appInfo: AppInfo,
+ settings: TokenMenuBarCore.Settings,
+ state: AppState,
+ history: UsageHistoryStore,
+ log: LogBuffer,
+ registry: ProviderRegistry,
+ notifier: Notifier,
+ launchAtLogin: LaunchAtLoginBackend,
+ clock: Clock = .system,
+ updater: (any UpdaterHook)? = nil,
+ statusBar: NSStatusBar = .system,
+ isSandboxed: Bool = false,
+ isDemo: Bool = false,
+ openURL: @escaping @MainActor (URL) -> Void,
+ copyToPasteboard: @escaping @MainActor (String) -> Void,
+ revealInFinder: @escaping @MainActor (URL) -> Void,
+ chooseExportURL: @escaping () -> URL?,
+ chooseDirectory: @escaping (SandboxResource) -> URL?,
+ terminate: @escaping @MainActor () -> Void,
+ relaunch: @escaping @MainActor @Sendable () -> Void = {},
+ widgetStore: WidgetSnapshotStore? = nil,
+ snapshotCache: SnapshotCache = SnapshotCache(url: nil),
+ persistence: SnapshotPersistence? = nil,
+ reloadWidgets: @escaping @MainActor @Sendable () -> Void = {},
+ rebuildProviders: @escaping @MainActor @Sendable (TokenMenuBarCore.Settings) async -> ProviderRegistry,
+ screenVisibleFrame: @escaping () -> CGRect?,
+ openPopoverOnLaunch: Bool = false,
+ presentsWindows: Bool = true,
+ persistsStatusItemPosition: Bool = true,
+ recoversOffscreenPopover: Bool = false,
+ verificationSession: String? = nil,
+ verificationSnapshotURL: URL? = nil,
+ captureProcessSnapshot: @escaping @Sendable () -> ProcessPerformanceSnapshot? = ProcessPerformanceSnapshot.current
+ ) {
+ self.appInfo = appInfo
+ self.settings = settings
+ self.state = state
+ self.history = history
+ self.log = log
+ self.registry = registry
+ self.notifier = notifier
+ self.launchAtLogin = launchAtLogin
+ self.clock = clock
+ self.updater = updater
+ self.statusBar = statusBar
+ self.isSandboxed = isSandboxed
+ self.isDemo = isDemo
+ self.openURL = openURL
+ self.copyToPasteboard = copyToPasteboard
+ self.revealInFinder = revealInFinder
+ self.chooseExportURL = chooseExportURL
+ self.chooseDirectory = chooseDirectory
+ self.terminate = terminate
+ self.relaunch = relaunch
+ self.widgetStore = widgetStore
+ self.snapshotCache = snapshotCache
+ self.persistence =
+ persistence
+ ?? SnapshotPersistence(
+ cache: snapshotCache,
+ widgetStore: widgetStore,
+ failureHandler: { failure in log.logError(failure.message) },
+ reloadWidgets: reloadWidgets)
+ self.reloadWidgets = reloadWidgets
+ self.rebuildProviders = rebuildProviders
+ self.screenVisibleFrame = screenVisibleFrame
+ self.openPopoverOnLaunch = openPopoverOnLaunch
+ self.presentsWindows = presentsWindows
+ self.persistsStatusItemPosition = persistsStatusItemPosition
+ self.recoversOffscreenPopover = recoversOffscreenPopover
+ self.verificationSession = verificationSession
+ self.verificationSnapshotURL = verificationSnapshotURL
+ self.captureProcessSnapshot = captureProcessSnapshot
+ }
+}
+
+@MainActor
+public final class AppController {
+ public private(set) var dependencies: AppDependencies
+ public let environment: UIEnvironment
+ public let coordinator: RefreshCoordinator
+ public private(set) var statusItem: StatusItemController?
+ private var stopped = false
+ public private(set) var popover: PopoverController?
+ private var logWindow: LogWindowController?
+ private var workspaceObservers: [Any] = []
+ private var applicationObservers: [Any] = []
+ private var distributedObservers: [Any] = []
+ private var lastPopoverVisibleFrame: CGRect?
+ private var registry: ProviderRegistry
+ private var appliedRetentionDays: Int
+ private var retentionTask: Task?
+ private var retentionGeneration = 0
+ private var widgetSubmissionTask: Task?
+ private var providerHealthTask: Task?
+ private var providerRediscoveryTask: Task?
+ private var lifecycleFlushTask: Task?
+ private var providerGeneration = 0
+ private var providerRediscoveryGeneration = 0
+ private var providerRediscoveryPolicy = ProviderRediscoveryPolicy()
+ private let initialStatusItem: NSStatusItem?
+
+ public init(dependencies: AppDependencies, initialStatusItem: NSStatusItem? = nil) {
+ self.dependencies = dependencies
+ self.initialStatusItem = initialStatusItem
+ registry = dependencies.registry
+ appliedRetentionDays = dependencies.settings.historyRetentionDays
+ let notifier = dependencies.notifier
+ let state = dependencies.state
+ coordinator = RefreshCoordinator(
+ registry: dependencies.registry,
+ settings: dependencies.settings,
+ state: state,
+ history: dependencies.history,
+ log: dependencies.log,
+ clock: dependencies.clock,
+ cache: dependencies.snapshotCache,
+ persistence: dependencies.persistence
+ ) { events in Task { await notifier.deliver(events) } }
+ environment = UIEnvironment(
+ state: state,
+ settings: dependencies.settings,
+ history: dependencies.history,
+ log: dependencies.log,
+ appInfo: dependencies.appInfo,
+ clock: dependencies.clock,
+ launchAtLoginStatus: dependencies.launchAtLogin.status(),
+ credentialDescriptions: Dictionary(
+ uniqueKeysWithValues: dependencies.registry.providers.map { ($0.id, $0.credentialDescription) }),
+ canCheckForUpdates: dependencies.updater?.canCheck ?? false,
+ isSandboxed: dependencies.isSandboxed,
+ isDemo: dependencies.isDemo
+ )
+ environment.actions = actions()
+ dependencies.log.debugEnabled = dependencies.settings.detailedLogging
+ coordinator.widgetSink = { [weak self] in self?.publishWidget($0) }
+ }
+
+ public func publishWidget(_ snapshot: WidgetSnapshot) {
+ let previous = widgetSubmissionTask
+ widgetSubmissionTask = Task { [persistence = dependencies.persistence] in
+ await previous?.value
+ await persistence.submitWidget(snapshot)
+ }
+ }
+
+ public func flushPersistence() async {
+ await widgetSubmissionTask?.value
+ await coordinator.flushPersistence()
+ }
+
+ public func prepareToTerminate() async {
+ cancelBackgroundTasks()
+ coordinator.stop()
+ _ = await ShutdownPolicy.waitForCompletion { [weak self] in await self?.flushPersistence() }
+ }
+
+ func actions() -> UIActions {
+ UIActions(
+ refresh: { [weak self] in self?.refreshNow() },
+ refreshProvider: { [weak self] in self?.refreshNow(provider: $0) },
+ showProviders: { [weak self] in self?.showProviders($0) },
+ openURL: { [weak self] in self?.dependencies.openURL($0) },
+ copy: { [weak self] in self?.dependencies.copyToPasteboard($0) },
+ exportHistory: { [weak self] in self?.exportHistory() },
+ clearHistory: { [weak self] in self?.clearHistory() },
+ revealHistory: { [weak self] in self?.revealHistory() },
+ copyDiagnostics: { [weak self] in self?.copyDiagnostics() },
+ reportIssue: { [weak self] in self?.reportIssue() },
+ showFullLog: { [weak self] in self?.showFullLog() },
+ setLaunchAtLogin: { [weak self] in self?.setLaunchAtLogin($0) },
+ openLoginItems: { [weak self] in self?.dependencies.launchAtLogin.openSettings() },
+ grantAccess: { [weak self] resource in Task { await self?.grantAccess(to: resource) } },
+ checkForUpdates: { [weak self] in self?.dependencies.updater?.checkForUpdates() },
+ quit: { [weak self] in self?.dependencies.terminate() },
+ setDemoMode: { [weak self] in self?.setDemoMode($0) },
+ settingsChanged: { [weak self] in self?.settingsChanged() },
+ settingsReset: { [weak self] in Task { await self?.settingsReset() } }
+ )
+ }
+
+ public func start() {
+ let log = dependencies.log
+ if let previous = dependencies.settings.lastLaunchedVersion, previous != dependencies.appInfo.version {
+ log.log("updated from \(previous) to \(dependencies.appInfo.version)")
+ }
+ dependencies.settings.lastLaunchedVersion = dependencies.appInfo.version
+ log.log("launch \(dependencies.appInfo.name) \(dependencies.appInfo.version) (\(dependencies.appInfo.build))")
+ let item = StatusItemController(
+ statusBar: dependencies.statusBar, item: initialStatusItem, log: log,
+ autosaveName: dependencies.persistsStatusItemPosition
+ ? StatusItemController.autosaveName(bundleIdentifier: Bundle.main.bundleIdentifier) : nil
+ ) {
+ $0.performClick(nil)
+ }
+ item.onClick = { [weak self] in self?.togglePopover() }
+ item.onCountdownTick = { [weak self] in self?.coordinator.rebuildStatus() }
+ item.menuProvider = AppControllerMenuSource(self).menu
+ item.adaptive = dependencies.settings.adaptiveWidth
+ item.detailedLoggingEnabled = dependencies.settings.detailedLogging
+ item.update(ladder: dependencies.state.statusLadder)
+ statusItem = item
+ let popover = PopoverController(
+ content: AnyView(EmptyView()), log: log, presentsWindow: dependencies.presentsWindows,
+ recoversOffscreenAnchor: dependencies.recoversOffscreenPopover)
+ popover.setContent(rootView(popover))
+ popover.select(tab: dependencies.settings.lastTab)
+ popover.excludedFrame = { [weak self] in self?.statusItem?.buttonFrameOnScreen }
+ popover.onRefresh = { [weak self] in self?.refreshNow() }
+ popover.onVisibilityChange = { [weak self] visible in
+ self?.statusItem?.popoverVisible = visible
+ self?.dependencies.state.popoverVisible = visible
+ if visible {
+ self?.refreshIfStale()
+ }
+ }
+ self.popover = popover
+ installObservers()
+ dependencies.updater?.automaticallyChecks = dependencies.settings.automaticUpdates
+ probeProviderHealth()
+ providerRediscoveryPolicy.recordDiscovery(at: dependencies.clock.now())
+ Task { [coordinator] in await coordinator.restoreCachedSnapshots() }
+ coordinator.start()
+ Task { await dependencies.notifier.requestAuthorization() }
+ observeStatusModel()
+ if dependencies.openPopoverOnLaunch {
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in
+ self?.openPopoverAfterStatusItemAttachment()
+ }
+ }
+ }
+
+ private func openPopoverAfterStatusItemAttachment(
+ remainingAttempts: Int = 20, previousButtonFrame: CGRect? = nil, forcedNarrowest: Bool = false
+ ) {
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
+ guard let self, !stopped else { return }
+ guard let buttonFrame = statusItem?.buttonFrameOnScreen, statusItem?.fits() == true, !buttonFrame.isEmpty,
+ buttonFrame.minX.isFinite, buttonFrame.minY.isFinite,
+ buttonFrame.width.isFinite, buttonFrame.height.isFinite
+ else {
+ retryOpening(
+ remainingAttempts: remainingAttempts, previousButtonFrame: nil, forcedNarrowest: forcedNarrowest)
+ return
+ }
+ guard buttonFrame == previousButtonFrame else {
+ retryOpening(
+ remainingAttempts: remainingAttempts, previousButtonFrame: buttonFrame, forcedNarrowest: forcedNarrowest)
+ return
+ }
+ togglePopover()
+ }
+ }
+
+ @discardableResult
+ func retryOpening(remainingAttempts: Int, previousButtonFrame: CGRect?, forcedNarrowest: Bool) -> Bool {
+ if remainingAttempts == 12, !forcedNarrowest, statusItem?.collapseToNarrowest() == true {
+ statusItem?.reattach()
+ openPopoverAfterStatusItemAttachment(remainingAttempts: 8, forcedNarrowest: true)
+ return false
+ }
+ guard remainingAttempts > 1 else {
+ guard forcedNarrowest, dependencies.recoversOffscreenPopover else { return false }
+ togglePopover()
+ return true
+ }
+ openPopoverAfterStatusItemAttachment(
+ remainingAttempts: remainingAttempts - 1, previousButtonFrame: previousButtonFrame,
+ forcedNarrowest: forcedNarrowest)
+ return false
+ }
+
+ func rootView(_ popover: PopoverController) -> AnyView {
+ AnyView(
+ RootView(
+ environment: environment, onMeasure: { [weak popover] in popover?.measure($0) },
+ onTabChange: { [weak popover] tab in popover?.select(tab: tab) },
+ chooseHistoryExportURL: { [weak self] in self?.dependencies.chooseExportURL() }))
+ }
+
+ func observeStatusModel() {
+ // The tracking closure re-registers itself after every change, so capturing self strongly here would leave the
+ // registrar holding the controller, its state and its history store for good.
+ withObservationTracking { [weak self] in
+ _ = self?.dependencies.state.statusLadder
+ } onChange: { [weak self] in
+ Task { @MainActor [weak self] in
+ guard let self, !self.stopped else { return }
+ self.statusItem?.update(ladder: self.dependencies.state.statusLadder)
+ self.observeStatusModel()
+ }
+ }
+ }
+
+ public func stop() {
+ cancelBackgroundTasks()
+ coordinator.stop()
+ popover?.close()
+ statusItem?.remove(from: dependencies.statusBar)
+ statusItem = nil
+ for observer in workspaceObservers { NSWorkspace.shared.notificationCenter.removeObserver(observer) }
+ workspaceObservers.removeAll()
+ for observer in applicationObservers { NotificationCenter.default.removeObserver(observer) }
+ applicationObservers.removeAll()
+ for observer in distributedObservers { DistributedNotificationCenter.default().removeObserver(observer) }
+ distributedObservers.removeAll()
+ stopped = true
+ dependencies.log.log("stopped")
+ dependencies.log.flush()
+ }
+
+ private func cancelBackgroundTasks() {
+ retentionTask?.cancel()
+ retentionTask = nil
+ providerHealthTask?.cancel()
+ providerHealthTask = nil
+ providerRediscoveryTask?.cancel()
+ providerRediscoveryTask = nil
+ }
+
+ func installObservers() {
+ let center = NSWorkspace.shared.notificationCenter
+ workspaceObservers.append(
+ center.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in
+ Task { @MainActor [weak self] in self?.handleSleep() }
+ })
+ workspaceObservers.append(
+ center.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in
+ Task { @MainActor [weak self] in self?.handleWake() }
+ })
+ applicationObservers.append(
+ NotificationCenter.default.addObserver(
+ forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in self?.updatePopoverGeometry() }
+ })
+ applicationObservers.append(
+ NotificationCenter.default.addObserver(
+ forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in self?.handleApplicationActivation() }
+ })
+ if let session = dependencies.verificationSession {
+ distributedObservers.append(
+ DistributedNotificationCenter.default().addObserver(
+ forName: LaunchPolicy.verificationOpenPopoverNotification,
+ object: session,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in self?.openPopoverForVerification() }
+ })
+ distributedObservers.append(
+ DistributedNotificationCenter.default().addObserver(
+ forName: LaunchPolicy.verificationSnapshotNotification,
+ object: session,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in self?.writeVerificationSnapshot() }
+ })
+ }
+ }
+
+ private func writeVerificationSnapshot() {
+ dependencies.log.flush()
+ guard let url = dependencies.verificationSnapshotURL, let snapshot = dependencies.captureProcessSnapshot()
+ else { return }
+ do {
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try JSONEncoder().encode(snapshot).write(to: url, options: .atomic)
+ } catch {
+ dependencies.log.logWarning("Could not write verification process snapshot: \(error.localizedDescription)")
+ }
+ }
+
+ public func handleSleep() {
+ dependencies.log.logDebug("sleep: pausing refresh loop")
+ coordinator.stop()
+ let previous = lifecycleFlushTask
+ lifecycleFlushTask = Task { [weak self] in
+ await previous?.value
+ await self?.flushPersistence()
+ }
+ }
+
+ public func handleWake() {
+ dependencies.log.logDebug("wake: resuming refresh loop")
+ coordinator.start()
+ }
+
+ public func togglePopover() {
+ let geometry = popoverGeometry()
+ popover?.toggle(
+ relativeTo: statusItem?.item.button,
+ anchorFrame: geometry.anchorFrame,
+ visibleFrame: geometry.visibleFrame,
+ screenID: geometry.screenID,
+ screenFrame: geometry.screenFrame)
+ }
+
+ private func openPopoverForVerification() {
+ guard popover?.isShown != true else { return }
+ let geometry = popoverGeometry()
+ popover?.show(
+ relativeTo: statusItem?.item.button,
+ anchorFrame: geometry.anchorFrame,
+ visibleFrame: geometry.visibleFrame,
+ screenID: geometry.screenID,
+ screenFrame: geometry.screenFrame)
+ }
+
+ func updatePopoverGeometry() {
+ let geometry = popoverGeometry()
+ popover?.updateGeometry(
+ anchorFrame: geometry.anchorFrame,
+ visibleFrame: geometry.visibleFrame,
+ screenID: geometry.screenID,
+ screenFrame: geometry.screenFrame)
+ }
+
+ func popoverGeometry() -> (
+ anchorFrame: CGRect?, visibleFrame: CGRect?, screenID: String?, screenFrame: CGRect?
+ ) {
+ let anchorFrame = statusItem?.buttonFrameOnScreen
+ let screens = NSScreen.screens
+ let anchorScreen =
+ statusItem?.item.button?.window?.screen
+ ?? anchorFrame.flatMap { anchor in
+ screens.first { $0.frame.intersects(anchor) }
+ ?? screens.first { $0.frame.contains(CGPoint(x: anchor.midX, y: anchor.midY - 100)) }
+ }
+ let previousScreen = lastPopoverVisibleFrame.flatMap { previous in
+ screens.first { $0.frame.intersects(previous) }
+ }
+ let visibleFrame = Self.resolveVisibleFrame(
+ dependencies.screenVisibleFrame(), anchorScreen: anchorScreen?.visibleFrame,
+ previousScreen: previousScreen?.visibleFrame)
+ if anchorScreen != nil || visibleFrame != nil { lastPopoverVisibleFrame = visibleFrame }
+ let screen = anchorScreen ?? previousScreen
+ let screenID = (screen?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.stringValue
+ return (anchorFrame, visibleFrame, screenID, screen?.frame)
+ }
+
+ static func resolveVisibleFrame(
+ _ explicit: CGRect?, anchorScreen: CGRect?, previousScreen: CGRect?
+ ) -> CGRect? {
+ if let explicit { return explicit }
+ if let anchorScreen { return anchorScreen }
+ return previousScreen
+ }
+
+ public func refreshNow() {
+ Task { [weak self] in
+ guard let self else { return }
+ await rediscoverProviders(.userInitiated)
+ await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force, analytics: .ifDue))
+ }
+ }
+
+ public func refreshNow(provider: ProviderID) {
+ Task {
+ await coordinator.refresh(
+ RefreshRequest(reason: .userInitiated, usage: .force, analytics: .ifDue, providers: [provider]))
+ }
+ }
+
+ public func refreshIfStale() {
+ Task { await coordinator.refresh(RefreshRequest(reason: .popoverOpened)) }
+ }
+
+ public func handleApplicationActivation() {
+ Task { [weak self] in await self?.rediscoverProviders(.applicationActivated) }
+ }
+
+ public func showProviders(_ provider: ProviderID?) {
+ environment.providerFocusRequest = ProviderSettingsFocusRequest(provider: provider)
+ dependencies.settings.lastTab = .settings
+ popover?.select(tab: .settings)
+ }
+
+ public func settingsChanged() {
+ dependencies.log.debugEnabled = dependencies.settings.detailedLogging
+ statusItem?.detailedLoggingEnabled = dependencies.settings.detailedLogging
+ dependencies.updater?.automaticallyChecks = dependencies.settings.automaticUpdates
+ statusItem?.adaptive = dependencies.settings.adaptiveWidth
+ coordinator.rebuildStatus()
+ let retentionDays = dependencies.settings.historyRetentionDays
+ retentionTask?.cancel()
+ retentionGeneration += 1
+ let generation = retentionGeneration
+ guard retentionDays != appliedRetentionDays else {
+ retentionTask = nil
+ return
+ }
+ let now = dependencies.clock.now()
+ retentionTask = Task { @MainActor [weak self, history = dependencies.history, log = dependencies.log] in
+ do {
+ try await Task.sleep(for: .milliseconds(150))
+ } catch {
+ return
+ }
+ guard !Task.isCancelled, let self, retentionGeneration == generation,
+ dependencies.settings.historyRetentionDays == retentionDays
+ else { return }
+ do {
+ let removed = try await history.setRetentionDays(retentionDays, now: now)
+ guard !Task.isCancelled, retentionGeneration == generation,
+ dependencies.settings.historyRetentionDays == retentionDays
+ else { return }
+ appliedRetentionDays = retentionDays
+ if removed.samples > 0 { dependencies.state.markSamplesChanged() }
+ environment.historyPresenter.invalidateData()
+ log.log("history retention updated days=\(retentionDays) removed=\(removed.total)")
+ } catch {
+ log.logError("history retention update failed: \(error)")
+ }
+ }
+ }
+
+ public func settingsReset() async {
+ setLaunchAtLogin(false)
+ replaceProviders(await dependencies.rebuildProviders(dependencies.settings))
+ environment.historyPresenter.reset()
+ if environment.isDemo {
+ dependencies.log.log("demo mode reset; relaunching")
+ dependencies.relaunch()
+ }
+ }
+
+ public func contextMenu() -> NSMenu {
+ let menu = NSMenu()
+ let commands = MenuCommand.menu(
+ canCheckForUpdates: dependencies.updater?.canCheck == true, appName: dependencies.appInfo.name)
+ for command in commands {
+ guard command != .separator else {
+ menu.addItem(.separator())
+ continue
+ }
+ let item = NSMenuItem(
+ title: command.title, action: #selector(MenuTarget.run(_:)), keyEquivalent: command.keyEquivalent)
+ item.representedObject = command.id
+ item.target = menuTarget
+ menu.addItem(item)
+ }
+ return menu
+ }
+
+ public func run(_ commandID: String) {
+ switch commandID {
+ case MenuCommand.refresh.id: refreshNow()
+ case MenuCommand.checkForUpdates.id: dependencies.updater?.checkForUpdates()
+ case MenuCommand.quit(appName: "").id: dependencies.terminate()
+ default: return
+ }
+ }
+
+ lazy var menuTarget = MenuTarget(controller: self)
+
+ @discardableResult
+ public func exportHistory() -> Task {
+ guard let url = dependencies.chooseExportURL() else { return Task {} }
+ return Task {
+ do {
+ try await dependencies.history.exportCSV(to: url)
+ dependencies.log.log("history exported to \(url.lastPathComponent)")
+ } catch {
+ dependencies.log.logError("history export failed: \(error)")
+ }
+ }
+ }
+
+ @discardableResult
+ public func clearHistory() -> Task {
+ Task {
+ do {
+ let removed = try await dependencies.history.clear()
+ dependencies.log.log("history cleared rows=\(removed)")
+ if removed > 0 { dependencies.state.markSamplesChanged() }
+ environment.historyPresenter.invalidateData()
+ } catch {
+ dependencies.log.logError("history clear failed: \(error)")
+ }
+ }
+ }
+
+ public func revealHistory() {
+ guard let location = dependencies.history.location else { return }
+ dependencies.revealInFinder(location)
+ }
+
+ public func diagnosticsReport() -> String {
+ Diagnostics.report(
+ app: dependencies.appInfo,
+ osVersion: ProcessInfo.processInfo.operatingSystemVersionString,
+ settings: dependencies.settings,
+ state: dependencies.state,
+ historyLocation: dependencies.history.location,
+ log: dependencies.log,
+ now: dependencies.clock.now()
+ )
+ }
+
+ public func copyDiagnostics() {
+ dependencies.copyToPasteboard(diagnosticsReport())
+ }
+
+ public func reportIssue() {
+ dependencies.openURL(
+ Diagnostics.issueURL(
+ repository: dependencies.appInfo.repository, title: "Issue report", report: diagnosticsReport()))
+ }
+
+ public func showFullLog() {
+ if logWindow == nil {
+ logWindow = LogWindowController(log: dependencies.log, presentsWindow: dependencies.presentsWindows)
+ }
+ logWindow?.showWindow(nil)
+ }
+
+ public func setLaunchAtLogin(_ enabled: Bool) {
+ environment.launchAtLoginStatus = dependencies.launchAtLogin.setEnabled(enabled)
+ dependencies.log.log("launch at login \(enabled ? "on" : "off") -> \(environment.launchAtLoginStatus.rawValue)")
+ }
+
+ public func grantAccess(to resource: SandboxResource) async {
+ guard let url = dependencies.chooseDirectory(resource) else { return }
+ do {
+ let bookmark = try await Task.detached(priority: .userInitiated) {
+ try SecurityScopedBookmarkClient.live.create(url)
+ }.value
+ dependencies.settings.setBookmark(bookmark, for: resource)
+ dependencies.settings.flush()
+ dependencies.log.log("\(resource.label) access granted: \(url.lastPathComponent)")
+ replaceProviders(await dependencies.rebuildProviders(dependencies.settings))
+ refreshNow()
+ } catch {
+ dependencies.log.logError("bookmark for \(resource.label) failed: \(error)")
+ }
+ }
+
+ public func setDemoMode(_ enabled: Bool) {
+ dependencies.settings.demoMode = enabled
+ dependencies.settings.flush()
+ dependencies.isDemo = enabled
+ environment.isDemo = enabled
+ dependencies.log.log("demo mode \(enabled ? "on" : "off"); relaunching")
+ dependencies.relaunch()
+ }
+
+ public func replaceProviders(_ registry: ProviderRegistry) {
+ replaceProviderRegistry(registry)
+ probeProviderHealth()
+ }
+
+ private func replaceProviderRegistry(_ registry: ProviderRegistry) {
+ providerGeneration &+= 1
+ self.registry = registry
+ dependencies.registry = registry
+ coordinator.replaceRegistry(registry)
+ dependencies.state.applySetupStates(registry.setupStates)
+ environment.credentialDescriptions = Dictionary(
+ uniqueKeysWithValues: registry.providers.map { ($0.id, $0.credentialDescription) })
+ }
+
+ private func probeProviderHealth() {
+ providerHealthTask?.cancel()
+ let registry = registry
+ let generation = providerGeneration
+ let now = dependencies.clock.now()
+ providerHealthTask = Task { @MainActor [weak self] in
+ let discovery = await ProviderDiscoverySnapshot.inspect(registry, now: now)
+ guard let self, !Task.isCancelled, generation == providerGeneration else { return }
+ apply(discovery, to: registry)
+ }
+ }
+
+ private func rediscoverProviders(_ trigger: ProviderRediscoveryTrigger) async {
+ let now = dependencies.clock.now()
+ if let task = providerRediscoveryTask {
+ await task.value
+ if trigger == .userInitiated { await rediscoverProviders(trigger) }
+ return
+ }
+ guard providerRediscoveryPolicy.begin(trigger, at: now) else { return }
+ providerRediscoveryGeneration &+= 1
+ let generation = providerRediscoveryGeneration
+ let task = Task { @MainActor [weak self] in
+ guard let self else { return }
+ defer {
+ if generation == providerRediscoveryGeneration { providerRediscoveryTask = nil }
+ }
+ let candidate = await dependencies.rebuildProviders(dependencies.settings)
+ let discovery = await ProviderDiscoverySnapshot.inspect(candidate, now: now)
+ guard !Task.isCancelled, generation == providerRediscoveryGeneration else { return }
+ if trigger == .userInitiated
+ || discovery.differs(from: dependencies.state.providers, providerIDs: registry.ids)
+ {
+ providerHealthTask?.cancel()
+ replaceProviderRegistry(candidate)
+ apply(discovery, to: candidate)
+ }
+ }
+ providerRediscoveryTask = task
+ await task.value
+ }
+
+ private func apply(_ discovery: ProviderDiscoverySnapshot, to registry: ProviderRegistry) {
+ let setups = Dictionary(
+ uniqueKeysWithValues: registry.providers.map { provider in
+ let health = Self.credentialHealth(discovery.credentials, provider: provider.id)
+ var state = dependencies.state.state(for: provider.id)
+ state.credentialHealth = health
+ return (
+ provider.id,
+ ProviderSetupState.from(
+ provider: provider.id,
+ enabled: dependencies.settings.isProviderActive(provider.id, state: state),
+ credential: health,
+ resources: discovery.resources[provider.id] ?? [])
+ )
+ })
+ dependencies.state.applySetupStates(setups)
+ coordinator.reschedule()
+ }
+
+ static func credentialHealth(
+ _ credentials: [ProviderID: ProviderCredentialHealth], provider: ProviderID
+ ) -> ProviderCredentialHealth {
+ if let health = credentials[provider] { return health }
+ return .unchecked
+ }
+}
+
+/// Renders the popover tabs straight to PNG for the website. A screen capture of the live popover picks up its shadow
+/// and whatever sits behind it, so the export hosts the same view offscreen and keeps the content alone.
+@MainActor
+public enum PopoverExporter {
+ /// Renders a view offscreen at the size the popover would give it, so the shot shows what a viewer would see
+ /// rather than the whole scrolled content unrolled.
+ public static func image(_ view: some View, dark: Bool, size: CGSize? = nil) -> NSImage? {
+ let hosting = NSHostingView(rootView: view)
+ let appearance = NSAppearance(named: dark ? .darkAqua : .aqua)!
+ hosting.appearance = appearance
+ // The popover paints on a translucent material with no window behind it here, so back it with the colour the
+ // website draws behind the shot. windowBackgroundColor would resolve against whatever appearance the exporting
+ // process happens to run under, which is how light shots ended up grey.
+ hosting.wantsLayer = true
+ hosting.layer?.backgroundColor = Brand.card(dark: dark).cgColor
+ hosting.frame = CGRect(origin: .zero, size: size ?? hosting.fittingSize)
+ hosting.layoutSubtreeIfNeeded()
+ guard hosting.bounds.width > 0, hosting.bounds.height > 0,
+ let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)
+ else { return nil }
+ appearance.performAsCurrentDrawingAppearance { hosting.cacheDisplay(in: hosting.bounds, to: rep) }
+ let image = NSImage(size: hosting.bounds.size)
+ image.addRepresentation(rep)
+ return image
+ }
+
+ public static func png(_ view: some View, dark: Bool, size: CGSize? = nil) -> Data? {
+ guard let image = image(view, dark: dark, size: size), let rep = image.representations.first as? NSBitmapImageRep
+ else {
+ return nil
+ }
+ return rep.representation(using: .png, properties: [:])
+ }
+}
+
+@MainActor
+final class MenuTarget: NSObject {
+ weak var controller: AppController?
+
+ init(controller: AppController) {
+ self.controller = controller
+ }
+
+ @objc func run(_ sender: NSMenuItem) {
+ guard let id = sender.representedObject as? String else { return }
+ controller?.run(id)
+ }
+}
+
+@MainActor
+public final class LogWindowController: NSWindowController {
+ public let log: LogBuffer
+ private let present: @MainActor (NSWindow, Any?) -> Void
+
+ public convenience init(log: LogBuffer, presentsWindow: Bool = true) {
+ self.init(log: log, present: LiveDependencies.windowPresentation(enabled: presentsWindow))
+ }
+
+ init(log: LogBuffer, present: @escaping @MainActor (NSWindow, Any?) -> Void) {
+ self.log = log
+ self.present = present
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 720, height: 480), styleMask: [.titled, .closable, .resizable],
+ backing: .buffered, defer: false)
+ window.title = "Token Menu Bar Log"
+ window.contentView = NSHostingView(rootView: FullLogView(log: log))
+ window.center()
+ super.init(window: window)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ nil
+ }
+
+ public override func showWindow(_ sender: Any?) {
+ guard let window else { return }
+ present(window, sender)
+ }
+}
diff --git a/Sources/TokenMenuBarUI/AppDelegate.swift b/Sources/TokenMenuBarUI/AppDelegate.swift
new file mode 100644
index 0000000..e6f8767
--- /dev/null
+++ b/Sources/TokenMenuBarUI/AppDelegate.swift
@@ -0,0 +1,161 @@
+import AppKit
+import TokenMenuBarCore
+
+@MainActor
+public final class AppDelegate: NSObject, NSApplicationDelegate {
+ public let controller: AppController
+ private var terminationPending = false
+
+ public init(controller: AppController) {
+ self.controller = controller
+ }
+
+ public func applicationDidFinishLaunching(_ notification: Notification) {
+ NSApplication.shared.setActivationPolicy(.accessory)
+ controller.start()
+ }
+
+ public func applicationWillTerminate(_ notification: Notification) {
+ controller.stop()
+ }
+
+ public func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ guard !terminationPending else { return .terminateLater }
+ terminationPending = true
+ Task { [controller] in
+ await controller.prepareToTerminate()
+ sender.reply(toApplicationShouldTerminate: true)
+ }
+ return .terminateLater
+ }
+
+ public func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+ controller.togglePopover()
+ return false
+ }
+}
+
+@MainActor
+public final class DeferredAppDelegate: NSObject, NSApplicationDelegate {
+ public typealias Loader = @MainActor @Sendable () async throws -> AppDependencies
+ public typealias FailureHandler = @MainActor @Sendable (String) -> Void
+
+ public private(set) var controller: AppController?
+ public private(set) var statusShellVisible = false
+ private let statusBar: NSStatusBar
+ private let loader: Loader
+ private let failureHandler: FailureHandler
+ private var statusShell: NSStatusItem?
+ private var loadingTask: Task?
+ private var terminationPending = false
+
+ public init(
+ statusBar: NSStatusBar = .system,
+ loader: @escaping Loader,
+ failureHandler: @escaping FailureHandler
+ ) {
+ self.statusBar = statusBar
+ self.loader = loader
+ self.failureHandler = failureHandler
+ }
+
+ public func applicationDidFinishLaunching(_ notification: Notification) {
+ NSApplication.shared.setActivationPolicy(.accessory)
+ showStatusShell()
+ loadingTask = Task { [weak self] in
+ await Task.yield()
+ guard let self, !Task.isCancelled else { return }
+ do {
+ let dependencies = try await loader()
+ guard !Task.isCancelled else { return }
+ let controller = AppController(dependencies: dependencies, initialStatusItem: statusShell)
+ self.controller = controller
+ controller.start()
+ statusShell = nil
+ statusShellVisible = false
+ } catch {
+ removeStatusShell()
+ failureHandler(String(describing: error))
+ }
+ }
+ }
+
+ public func applicationWillTerminate(_ notification: Notification) {
+ loadingTask?.cancel()
+ loadingTask = nil
+ controller?.stop()
+ removeStatusShell()
+ }
+
+ public func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ guard let controller else {
+ loadingTask?.cancel()
+ return .terminateNow
+ }
+ guard !terminationPending else { return .terminateLater }
+ terminationPending = true
+ Task {
+ await controller.prepareToTerminate()
+ sender.reply(toApplicationShouldTerminate: true)
+ }
+ return .terminateLater
+ }
+
+ public func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+ controller?.togglePopover()
+ return false
+ }
+
+ private func showStatusShell() {
+ let item = statusBar.statusItem(withLength: NSStatusItem.variableLength)
+ item.button?.title = "…"
+ item.button?.toolTip = "Token Menu Bar is starting"
+ item.button?.setAccessibilityLabel("Token Menu Bar is starting")
+ statusShell = item
+ statusShellVisible = true
+ }
+
+ private func removeStatusShell() {
+ guard let statusShell else { return }
+ statusBar.removeStatusItem(statusShell)
+ self.statusShell = nil
+ statusShellVisible = false
+ }
+}
+
+public enum AppRunner {
+ @MainActor
+ public static func bootstrap(
+ distribution: DistributionChannel, notificationCenter: (any NotificationCenterProtocol)?,
+ updater: (any UpdaterHook)?,
+ isSandboxed: Bool, paths: LiveDependencies.Paths = LiveDependencies.Paths(), defaults: UserDefaults = .standard,
+ transport: any HTTPTransport, keychain: KeychainCredentialClient, launchAtLogin: LaunchAtLoginBackend
+ ) async throws -> AppDelegate {
+ let appInfo = AppInfo.from(bundle: .main, distribution: distribution)
+ let dependencies = try await LiveDependencies.make(
+ appInfo: appInfo, paths: paths, defaults: defaults, notificationCenter: notificationCenter, updater: updater,
+ isSandboxed: isSandboxed, transport: transport, keychain: keychain, launchAtLogin: launchAtLogin)
+ return AppDelegate(controller: AppController(dependencies: dependencies))
+ }
+
+ @MainActor
+ public static func bootstrapDeferred(
+ distribution: DistributionChannel, notificationCenter: (any NotificationCenterProtocol)?,
+ updater: (any UpdaterHook)?,
+ isSandboxed: Bool, paths: LiveDependencies.Paths = LiveDependencies.Paths(), defaults: UserDefaults = .standard,
+ transport: any HTTPTransport, keychain: KeychainCredentialClient, launchAtLogin: LaunchAtLoginBackend
+ ) -> DeferredAppDelegate {
+ let appInfo = AppInfo.from(bundle: .main, distribution: distribution)
+ return DeferredAppDelegate {
+ try await LiveDependencies.makeDeferred(
+ appInfo: appInfo, paths: paths, defaults: defaults, notificationCenter: notificationCenter, updater: updater,
+ isSandboxed: isSandboxed, transport: transport, keychain: keychain, launchAtLogin: launchAtLogin)
+ } failureHandler: { detail in
+ let alert = NSAlert()
+ alert.messageText = "Token Menu Bar cannot start"
+ alert.informativeText = detail
+ alert.runModal()
+ NSApplication.shared.terminate(nil)
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarUI/AppIcon.swift b/Sources/TokenMenuBarUI/AppIcon.swift
new file mode 100644
index 0000000..4456fc6
--- /dev/null
+++ b/Sources/TokenMenuBarUI/AppIcon.swift
@@ -0,0 +1,197 @@
+import AppKit
+import SwiftUI
+import TokenMenuBarCore
+
+public enum AppIcon {
+ public static let designSize: CGFloat = 24
+
+ public static func draw(in context: CGContext, rect: CGRect, tone: StatusIconTone, dark: Bool) {
+ let scale = min(rect.width, rect.height) / designSize
+ context.saveGState()
+ context.translateBy(
+ x: rect.minX + (rect.width - designSize * scale) / 2, y: rect.minY + (rect.height - designSize * scale) / 2)
+ context.scaleBy(x: scale, y: scale)
+ let ink = inkColor(tone: tone, dark: dark)
+ let frame = CGPath(
+ roundedRect: CGRect(x: 2, y: 2, width: 20, height: 20), cornerWidth: 5, cornerHeight: 5, transform: nil)
+ context.setStrokeColor(ink.cgColor)
+ context.setLineWidth(1.6)
+ context.addPath(frame)
+ context.strokePath()
+ let bars: [(y: CGFloat, width: CGFloat)] = [(15, 12), (10.5, 8), (6, 5)]
+ for bar in bars {
+ let track = CGPath(
+ roundedRect: CGRect(x: 6, y: bar.y, width: 12, height: 2.4), cornerWidth: 1.2, cornerHeight: 1.2, transform: nil
+ )
+ context.setFillColor(ink.withAlphaComponent(0.25).cgColor)
+ context.addPath(track)
+ context.fillPath()
+ let fill = CGPath(
+ roundedRect: CGRect(x: 6, y: bar.y, width: bar.width, height: 2.4), cornerWidth: 1.2, cornerHeight: 1.2,
+ transform: nil)
+ context.setFillColor(ink.cgColor)
+ context.addPath(fill)
+ context.fillPath()
+ }
+ if tone == .attention {
+ context.setFillColor(NSColor.systemOrange.cgColor)
+ context.fillEllipse(in: CGRect(x: 16, y: 16, width: 7, height: 7))
+ }
+ context.restoreGState()
+ }
+
+ public static func inkColor(tone: StatusIconTone, dark: Bool) -> NSColor {
+ switch tone {
+ case .offline: NSColor.systemGray
+ case .attention: dark ? NSColor.white : NSColor.black
+ case .normal: dark ? NSColor.white : NSColor.black
+ }
+ }
+
+ public static func image(height: CGFloat, tone: StatusIconTone, dark: Bool) -> NSImage {
+ let size = CGSize(width: height, height: height)
+ let image = NSImage(size: size, flipped: false) { rect in
+ guard let context = NSGraphicsContext.current?.cgContext else { return false }
+ draw(in: context, rect: rect, tone: tone, dark: dark)
+ return true
+ }
+ image.isTemplate = false
+ return image
+ }
+}
+
+extension AppIcon {
+ public static let squircleRatio: CGFloat = 0.2237
+ public static let productInset: CGFloat = 0.092
+ public static let appIconSizes: [Int] = [16, 32, 64, 128, 256, 512, 1024]
+
+ public static func drawProduct(in context: CGContext, size: CGFloat) {
+ let inset = size * productInset
+ let rect = CGRect(x: inset, y: inset, width: size - 2 * inset, height: size - 2 * inset)
+ let radius = rect.width * squircleRatio
+ let path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
+ context.saveGState()
+ context.addPath(path)
+ context.clip()
+ let space = CGColorSpaceCreateDeviceRGB()
+ let stops = [Brand.gradientStart, Brand.gradientEnd].map(\.cgColor)
+ if let gradient = CGGradient(colorsSpace: space, colors: stops as CFArray, locations: [0, 1]) {
+ context.drawLinearGradient(
+ gradient, start: CGPoint(x: rect.minX, y: rect.maxY), end: CGPoint(x: rect.maxX, y: rect.minY), options: [])
+ }
+ let unit = rect.width / 100
+ let bars: [(y: CGFloat, fill: CGFloat)] = [(62, 56), (43, 38), (24, 22)]
+ for bar in bars {
+ let height = 14 * unit
+ let bottom = rect.minY + bar.y * unit
+ for (width, alpha) in [(CGFloat(56), 0.32), (bar.fill, 1.0)] {
+ let barRect = CGRect(x: rect.minX + 22 * unit, y: bottom, width: width * unit, height: height)
+ context.setFillColor(CGColor(gray: 1, alpha: alpha))
+ context.addPath(
+ CGPath(roundedRect: barRect, cornerWidth: height / 2, cornerHeight: height / 2, transform: nil))
+ context.fillPath()
+ }
+ }
+ context.restoreGState()
+ }
+
+ /// Draws into a bitmap of exactly `size` pixels. Going through `NSImage.cgImage` hands back the backing store,
+ /// which on a Retina context is twice the requested size, and actool rejects every slot whose image is the wrong
+ /// size — silently, so the app ships with no icon at all.
+ public static func pngData(size: Int) -> Data? {
+ guard
+ let rep = NSBitmapImageRep(
+ bitmapDataPlanes: nil, pixelsWide: size, pixelsHigh: size, bitsPerSample: 8, samplesPerPixel: 4,
+ hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0),
+ let context = NSGraphicsContext(bitmapImageRep: rep)
+ else { return nil }
+ rep.size = CGSize(width: size, height: size)
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = context
+ drawProduct(in: context.cgContext, size: CGFloat(size))
+ NSGraphicsContext.restoreGraphicsState()
+ return rep.representation(using: .png, properties: [:])
+ }
+
+ /// `iconutil` turns the directory this writes into the `.icns` the bundle ships.
+ public static func exportIconSet(to directory: URL) throws {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ for size in appIconSizes {
+ guard let data = pngData(size: size) else { continue }
+ try data.write(to: directory.appendingPathComponent("icon_\(size).png"))
+ if size >= 32 {
+ try data.write(to: directory.appendingPathComponent("icon_\(size / 2)x\(size / 2)@2x.png"))
+ }
+ if size <= 512 {
+ try data.write(to: directory.appendingPathComponent("icon_\(size)x\(size).png"))
+ }
+ }
+ }
+}
+
+public struct AppIconView: View {
+ public let size: CGFloat
+ public let tone: StatusIconTone
+
+ public init(size: CGFloat, tone: StatusIconTone = .normal) {
+ self.size = size
+ self.tone = tone
+ }
+
+ @Environment(\.colorScheme) private var colorScheme
+
+ public var body: some View {
+ Canvas { context, canvasSize in
+ context.withCGContext { cg in
+ AppIcon.draw(in: cg, rect: CGRect(origin: .zero, size: canvasSize), tone: tone, dark: colorScheme == .dark)
+ }
+ }
+ .frame(width: size, height: size)
+ .accessibilityLabel("Token Menu Bar")
+ }
+}
+
+extension Color {
+ /// The brand iris, resolved per appearance so it holds contrast on both popover materials.
+ public static var brandAccent: Color {
+ Color(
+ nsColor: NSColor(name: nil) { appearance in
+ let brand = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? Brand.irisDark : Brand.iris
+ return NSColor(cgColor: brand.cgColor)!
+ })
+ }
+
+}
+
+public enum ProviderGlyph {
+ public static func image(_ provider: ProviderID, pointSize: CGFloat) -> NSImage {
+ return NSImage(systemSymbolName: symbolName(provider), accessibilityDescription: provider.displayName)!
+ .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold))!
+ }
+
+ public static func symbolName(_ provider: ProviderID) -> String {
+ switch provider {
+ case .claude: "sparkle"
+ case .codex: "chevron.left.forwardslash.chevron.right"
+ case .gemini: "sparkles"
+ case .cursor: "cursorarrow.rays"
+ case .copilot: "circle.hexagongrid"
+ }
+ }
+
+ public static func color(_ provider: ProviderID) -> Color {
+ switch provider {
+ case .claude: Color(red: 0.85, green: 0.47, blue: 0.34)
+ case .codex: Color(red: 0.06, green: 0.64, blue: 0.55)
+ case .gemini: Color(red: 0.26, green: 0.52, blue: 0.96)
+ case .cursor: Color(red: 0.45, green: 0.45, blue: 0.5)
+ case .copilot: Color(red: 0.42, green: 0.35, blue: 0.8)
+ }
+ }
+}
+
+extension BrandColor {
+ var cgColor: CGColor {
+ CGColor(srgbRed: red, green: green, blue: blue, alpha: 1)
+ }
+}
diff --git a/Sources/TokenMenuBarUI/ExportRunner.swift b/Sources/TokenMenuBarUI/ExportRunner.swift
new file mode 100644
index 0000000..907ab2c
--- /dev/null
+++ b/Sources/TokenMenuBarUI/ExportRunner.swift
@@ -0,0 +1,110 @@
+import AppKit
+import TokenMenuBarCore
+
+@MainActor
+public enum ExportRunner {
+ public static func run(
+ _ command: ExportCommand, directory: URL, now: Date = Date(), settle: Duration = .seconds(2)
+ ) async throws -> [URL] {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ switch command {
+ case .icons: return try exportIcons(to: directory)
+ case .menuBar: return try exportMenuBar(to: directory, now: now)
+ case .popover: return try await exportPopover(to: directory, settle: settle)
+ }
+ }
+
+ private static func exportIcons(to directory: URL) throws -> [URL] {
+ try AppIcon.exportIconSet(to: directory)
+ return try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).sorted {
+ $0.path < $1.path
+ }
+ }
+
+ private static func exportMenuBar(to directory: URL, now: Date) throws -> [URL] {
+ let snapshots = Dictionary(
+ uniqueKeysWithValues: [ProviderID.claude, .codex].map { ($0, DemoData.snapshot($0, now: now)) })
+ let model = StatusItemBuilder.build(
+ StatusItemInput(
+ snapshots: snapshots, availability: snapshots.mapValues { _ in .current },
+ selectedKeys: StatusItemBuilder.defaultSelection(snapshots), format: .stacked, customTemplate: "", decimals: 0,
+ hideZeroCells: true, order: .provider, labels: [:], now: now))
+ var written: [URL] = []
+ for (name, dark) in [("menubar-light", false), ("menubar-dark", true)] {
+ let data = StatusItemRenderer.stripData(for: model, dark: dark)!
+ let url = directory.appendingPathComponent("\(name).png")
+ try data.write(to: url)
+ written.append(url)
+ }
+ return written
+ }
+
+ // demo data, so no account details reach the website
+ private static func exportPopover(to directory: URL, settle: Duration) async throws -> [URL] {
+ let suite = "dev.tox.token-menu-bar.export"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ // A leftover history file would freeze the shots at whatever the demo generated on an earlier run
+ let support = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-export")
+ try? FileManager.default.removeItem(at: support)
+ let delegate = try await AppRunner.bootstrap(
+ distribution: .direct, notificationCenter: nil, updater: nil, isSandboxed: false,
+ paths: LiveDependencies.Paths(supportDirectory: support, environment: ["TOKEN_MENU_BAR_DEMO": "1"]),
+ defaults: defaults, transport: DisabledHTTPTransport(), keychain: .empty, launchAtLogin: .inMemory())
+ let controller = delegate.controller
+ // A month of daily buckets shows the weekly rhythm; the default "today" view has nothing to draw yet
+ controller.environment.settings.historyRange = .month
+ controller.environment.settings.historyRollup = .day
+ // Demo history seeds on a background task, and an empty chart makes for a poor screenshot
+ for _ in 0..<200 where try await controller.environment.history.stats().sampleCount == 0 {
+ try? await Task.sleep(for: .milliseconds(100))
+ }
+ await controller.coordinator.refresh(RefreshRequest(reason: .export, usage: .force, analytics: .force))
+ controller.environment.historyPresenter.reload()
+ try? await Task.sleep(for: settle)
+ await controller.environment.loadRecentSamples(force: true)
+ var written: [URL] = []
+ for tab in PopoverTab.allCases {
+ controller.environment.settings.lastTab = tab
+ for (suffix, dark) in [("light", false), ("dark", true)] {
+ let measured = MeasuredSize()
+ let view = RootView(
+ environment: controller.environment,
+ onMeasure: { measurement in
+ if measurement.tab == tab { measured.value = measurement.size }
+ })
+ // Render once to let the tab report its natural size, then again at that size, so the shot carries the whole
+ // tab rather than the slice the popover would clamp it to. SwiftUI reports the size a run loop turn later.
+ _ = PopoverExporter.image(view, dark: dark, size: shotSize)
+ try? await Task.sleep(for: .milliseconds(50))
+ let size = exportSize(measured: measured.value, fallback: shotSize)
+ let data = PopoverExporter.png(view, dark: dark, size: size)!
+ let url = directory.appendingPathComponent("popover-\(tab.rawValue.lowercased())-\(suffix).png")
+ try data.write(to: url)
+ written.append(url)
+ }
+ }
+ return written
+ }
+
+ /// The popover as it opens on a 14-inch display. A tab taller than this grows to fit, so the website can show the
+ /// whole tab inside a scrolling frame.
+ static var shotSize: CGSize {
+ let screen = CGRect(x: 0, y: 0, width: 1512, height: 944)
+ let anchor = CGRect(x: screen.midX, y: screen.maxY - 24, width: 40, height: 24)
+ return CGSize(
+ width: PopoverGeometry.stableWidth(),
+ height: min(PopoverGeometry.maxSize(anchor: anchor, visibleFrame: screen).height, 760))
+ }
+
+ static func exportSize(measured: CGSize, fallback: CGSize) -> CGSize {
+ CGSize(
+ width: measured.width > 0 ? measured.width : fallback.width,
+ height: measured.height > 0 ? measured.height : fallback.height)
+ }
+}
+
+@MainActor
+final class MeasuredSize {
+ var value: CGSize = .zero
+}
diff --git a/Sources/TokenMenuBarUI/LiveDependencies.swift b/Sources/TokenMenuBarUI/LiveDependencies.swift
new file mode 100644
index 0000000..e8b2b3c
--- /dev/null
+++ b/Sources/TokenMenuBarUI/LiveDependencies.swift
@@ -0,0 +1,447 @@
+import AppKit
+import TokenMenuBarCore
+import UniformTypeIdentifiers
+import WidgetKit
+
+public enum LiveDependencies {
+ public typealias WorkspaceOpen =
+ @MainActor @Sendable (
+ URL, NSWorkspace.OpenConfiguration, @escaping @Sendable () -> Void
+ ) -> Void
+
+ public struct Paths: Sendable {
+ public var home: URL
+ public var supportDirectory: URL
+ public var environment: [String: String]
+ public var userName: String
+ public var arguments: [String]
+ public var verificationProfile: VerificationProfile?
+
+ public init(
+ home: URL = FileManager.default.homeDirectoryForCurrentUser,
+ supportDirectory: URL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("Token Menu Bar"),
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ userName: String = NSUserName(),
+ arguments: [String] = CommandLine.arguments,
+ verificationProfile: VerificationProfile? = nil
+ ) {
+ self.home = home
+ self.supportDirectory = supportDirectory
+ self.environment = environment
+ self.userName = userName
+ self.arguments = arguments
+ self.verificationProfile = verificationProfile
+ }
+
+ public var demoRequested: Bool {
+ environment["TOKEN_MENU_BAR_DEMO"] != nil || arguments.contains("--demo")
+ }
+ }
+
+ @MainActor
+ public static func make(
+ appInfo: AppInfo,
+ paths: Paths = Paths(),
+ defaults: UserDefaults = .standard,
+ notificationCenter: (any NotificationCenterProtocol)?,
+ updater: (any UpdaterHook)? = nil,
+ isSandboxed: Bool = false,
+ transport: any HTTPTransport,
+ keychain: KeychainCredentialClient,
+ launchAtLogin: LaunchAtLoginBackend,
+ workspaceOpen: WorkspaceOpen? = nil
+ ) async throws -> AppDependencies {
+ let log = LogBuffer(fileURL: paths.supportDirectory.appendingPathComponent("log.txt"))
+ let settings = TokenMenuBarCore.Settings(defaults: defaults)
+ let isDemo = settings.demoMode ?? paths.demoRequested
+ let history = try UsageHistoryStore(
+ url: paths.supportDirectory.appendingPathComponent(isDemo ? "usage-demo.sqlite" : "usage.sqlite"),
+ retentionDays: settings.historyRetentionDays)
+ let resolvedWorkspaceOpen = resolvedWorkspaceOpen(workspaceOpen)
+ return await assemble(
+ appInfo: appInfo, paths: paths, notificationCenter: notificationCenter, updater: updater,
+ isSandboxed: isSandboxed, transport: transport, keychain: keychain, log: log, settings: settings,
+ history: history, isDemo: isDemo, launchAtLogin: launchAtLogin, workspaceOpen: resolvedWorkspaceOpen)
+ }
+
+ @MainActor
+ public static func makeDeferred(
+ appInfo: AppInfo,
+ paths: Paths = Paths(),
+ defaults: UserDefaults = .standard,
+ notificationCenter: (any NotificationCenterProtocol)?,
+ updater: (any UpdaterHook)? = nil,
+ isSandboxed: Bool = false,
+ transport: any HTTPTransport,
+ keychain: KeychainCredentialClient,
+ launchAtLogin: LaunchAtLoginBackend,
+ workspaceOpen: WorkspaceOpen? = nil
+ ) async throws -> AppDependencies {
+ let settings = TokenMenuBarCore.Settings(defaults: defaults)
+ let isDemo = settings.demoMode ?? paths.demoRequested
+ let supportDirectory = paths.supportDirectory
+ let retentionDays = settings.historyRetentionDays
+ let logTask = Task.detached(priority: .utility) {
+ LogBuffer(fileURL: supportDirectory.appendingPathComponent("log.txt"))
+ }
+ let historyTask = Task.detached(priority: .utility) {
+ try UsageHistoryStore(
+ url: supportDirectory.appendingPathComponent(isDemo ? "usage-demo.sqlite" : "usage.sqlite"),
+ retentionDays: retentionDays)
+ }
+ let log = await logTask.value
+ let history = try await historyTask.value
+ let resolvedWorkspaceOpen = resolvedWorkspaceOpen(workspaceOpen)
+ return await assemble(
+ appInfo: appInfo, paths: paths, notificationCenter: notificationCenter, updater: updater,
+ isSandboxed: isSandboxed, transport: transport, keychain: keychain, log: log, settings: settings,
+ history: history, isDemo: isDemo, launchAtLogin: launchAtLogin, workspaceOpen: resolvedWorkspaceOpen)
+ }
+
+ @MainActor
+ private static func assemble(
+ appInfo: AppInfo,
+ paths: Paths,
+ notificationCenter: (any NotificationCenterProtocol)?,
+ updater: (any UpdaterHook)?,
+ isSandboxed: Bool,
+ transport: any HTTPTransport,
+ keychain: KeychainCredentialClient,
+ log: LogBuffer,
+ settings: TokenMenuBarCore.Settings,
+ history: UsageHistoryStore,
+ isDemo: Bool,
+ launchAtLogin: LaunchAtLoginBackend,
+ workspaceOpen: @escaping WorkspaceOpen
+ ) async -> AppDependencies {
+ let verificationProfile = paths.verificationProfile
+ if isDemo { seedDemo(history, log: log, fixture: verificationProfile?.fixture ?? .standard) }
+ let client = APIClient(transport: transport, log: log)
+ let build: @MainActor @Sendable (TokenMenuBarCore.Settings) async -> ProviderRegistry = { settings in
+ if isDemo { return demoRegistry(fixture: verificationProfile?.fixture ?? .standard) }
+ return await providers(
+ paths: paths, client: client, log: log, settings: settings, isSandboxed: isSandboxed, keychain: keychain)
+ }
+ let registry = await build(settings)
+ let state = AppState()
+ state.applySetupStates(registry.setupStates)
+ let effectiveSandboxed = isSandboxed || verificationProfile?.fixture == .controlAudit
+ let runtimeActions = runtimeActions(verification: verificationProfile != nil)
+ let relaunchAction: @MainActor @Sendable () -> Void = {
+ Self.relaunch(bundle: .main, open: workspaceOpen) { NSApplication.shared.terminate(nil) }
+ }
+ return AppDependencies(
+ appInfo: appInfo,
+ settings: settings,
+ state: state,
+ history: history,
+ log: log,
+ registry: registry,
+ notifier: Notifier(center: notificationCenter, log: log),
+ launchAtLogin: launchAtLogin,
+ updater: updater,
+ isSandboxed: effectiveSandboxed,
+ isDemo: isDemo,
+ openURL: runtimeActions.openURL,
+ copyToPasteboard: runtimeActions.copy,
+ revealInFinder: runtimeActions.reveal,
+ chooseExportURL: exportChooser(profile: verificationProfile, supportDirectory: paths.supportDirectory),
+ chooseDirectory: directoryChooser(
+ profile: verificationProfile, paths: paths, supportDirectory: paths.supportDirectory),
+ terminate: runtimeActions.terminate,
+ relaunch: relaunchAction,
+ widgetStore: isDemo ? nil : widgetStore(supportDirectory: paths.supportDirectory),
+ snapshotCache: SnapshotCache(
+ url: paths.supportDirectory.appendingPathComponent(isDemo ? "snapshots-demo.json" : "snapshots.json")),
+ reloadWidgets: { WidgetCenter.shared.reloadAllTimelines() },
+ rebuildProviders: build,
+ screenVisibleFrame: {
+ PopoverGeometry.visibleFrame(
+ NSScreen.main?.visibleFrame, cappedTo: verificationProfile?.visibleFrameWidth.map { CGFloat($0) })
+ },
+ openPopoverOnLaunch: paths.environment["TOKEN_MENU_BAR_OPEN_POPOVER"] != nil,
+ persistsStatusItemPosition: verificationProfile == nil,
+ recoversOffscreenPopover: verificationProfile != nil,
+ verificationSession: paths.environment[LaunchPolicy.verificationSessionKey],
+ verificationSnapshotURL: verificationProfile.map { _ in
+ paths.supportDirectory.appendingPathComponent("process-snapshot.json")
+ }
+ )
+ }
+
+ private static func demoRegistry(fixture: VerificationProfile.Fixture) -> ProviderRegistry {
+ let providers = ProviderID.allCases.map { DemoProvider(id: $0, fixture: fixture) }
+ let setup = Dictionary(
+ uniqueKeysWithValues: ProviderID.allCases.map { provider in
+ let source = CredentialSource(
+ id: "demo-\(provider.rawValue)", provider: provider,
+ title: fixture == .longText
+ ? "Deterministic verification credential source for \(provider.displayName)" : "Demo data",
+ detail: fixture == .longText
+ ? "/private/tmp/token-menu-bar-verification/credentials/\(provider.rawValue)"
+ + "/account-profile-with-a-deliberately-long-file-name.json"
+ : "Generated locally for previewing the interface.")
+ return (
+ provider,
+ ProviderSetupState(
+ enabled: true, credential: .valid(source: source, expiresAt: nil),
+ resources: fixture == .controlAudit
+ ? provider.sandboxResources.map { ResourceAccessState(resource: $0, health: .needed) } : [])
+ )
+ })
+ return ProviderRegistry(providers, setupStates: setup)
+ }
+
+ @MainActor
+ static func exportChooser(
+ profile: VerificationProfile?, supportDirectory: URL,
+ run: @escaping (NSSavePanel) -> NSApplication.ModalResponse = { $0.runModal() }
+ ) -> () -> URL? {
+ guard let profile else { return { chosen(exportPanel(), run: run) } }
+ guard profile.nativePanels else {
+ let url = supportDirectory.appendingPathComponent("verification-history.csv")
+ return { url }
+ }
+ return { chosen(exportPanel(default: supportDirectory), run: run) }
+ }
+
+ @MainActor
+ static func directoryChooser(
+ profile: VerificationProfile?, paths: Paths, supportDirectory: URL,
+ run: @escaping (NSOpenPanel) -> NSApplication.ModalResponse = { $0.runModal() }
+ ) -> (SandboxResource) -> URL? {
+ guard let profile else {
+ return { chosen(directoryPanel($0, paths: paths), run: run) }
+ }
+ guard profile.nativePanels else { return { _ in nil } }
+ return { resource in
+ let initialURL =
+ resource.kind == .file ? supportDirectory.appendingPathComponent("verification-selection") : supportDirectory
+ return chosen(directoryPanel(resource: resource, default: initialURL), run: run)
+ }
+ }
+
+ @MainActor
+ public static func providers(
+ paths: Paths,
+ client: APIClient,
+ log: LogBuffer,
+ settings: TokenMenuBarCore.Settings,
+ isSandboxed: Bool,
+ keychain: KeychainCredentialClient,
+ resolver: SecurityScopedResourceResolver = SecurityScopedResourceResolver(),
+ buildRegistry:
+ @escaping @Sendable (
+ ProviderRegistryFactory.Configuration, APIClient, LogBuffer
+ ) -> ProviderRegistry = { ProviderRegistryFactory.make(configuration: $0, client: $1, log: $2) }
+ ) async -> ProviderRegistry {
+ let bookmarks = Dictionary(
+ uniqueKeysWithValues: ProviderID.allSandboxResources.compactMap { resource in
+ settings.bookmark(for: resource).map { (resource.id, $0) }
+ })
+ let enabledProviders = Set(
+ ProviderID.allCases.filter {
+ settings.isProviderActive($0, state: ProviderState(credentialHealth: .unchecked))
+ })
+ let allowTokenRefresh: @MainActor @Sendable () -> Bool = { settings.allowTokenRefresh }
+ let result = await Task.detached(priority: .userInitiated) {
+ buildProviders(
+ paths: paths,
+ client: client,
+ log: log,
+ bookmarks: bookmarks,
+ enabledProviders: enabledProviders,
+ keychain: keychain,
+ allowTokenRefresh: allowTokenRefresh,
+ isSandboxed: isSandboxed,
+ resolver: resolver,
+ buildRegistry: buildRegistry)
+ }.value
+ for event in result.logEvents {
+ switch event {
+ case .info(let message): log.log(message)
+ case .error(let message): log.logError(message)
+ }
+ }
+ for resource in ProviderID.allSandboxResources {
+ guard let bookmark = result.replacementBookmarks[resource.id] else { continue }
+ settings.setBookmark(bookmark, for: resource)
+ }
+ if !result.replacementBookmarks.isEmpty { settings.flush() }
+ return result.registry
+ }
+
+ private static func buildProviders(
+ paths: Paths,
+ client: APIClient,
+ log: LogBuffer,
+ bookmarks: [String: Data],
+ enabledProviders: Set,
+ keychain: KeychainCredentialClient,
+ allowTokenRefresh: @escaping @MainActor @Sendable () -> Bool,
+ isSandboxed: Bool,
+ resolver: SecurityScopedResourceResolver,
+ buildRegistry:
+ @escaping @Sendable (
+ ProviderRegistryFactory.Configuration, APIClient, LogBuffer
+ ) -> ProviderRegistry
+ ) -> ProviderBuildResult {
+ var access: [ProviderID: [ResourceAccessState]] = [:]
+ var leases: [SecurityScopedResourceLease] = []
+ var resourceURLs: [String: URL] = [:]
+ var replacementBookmarks: [String: Data] = [:]
+ var logEvents: [ProviderBuildLogEvent] = []
+ let required = ProviderRegistryFactory.resourcesRequiringSandboxAccess(environment: paths.environment)
+ for resource in ProviderID.allSandboxResources {
+ let configured = resource.configuredURL(environment: paths.environment, home: paths.home)
+ guard isSandboxed else {
+ resourceURLs[resource.id] = configured
+ continue
+ }
+ guard required.contains(resource) else {
+ resourceURLs[resource.id] = configured
+ access[resource.provider, default: []].append(.notRequired(resource))
+ continue
+ }
+ let result = resolver.resolve(
+ resource: resource, bookmark: bookmarks[resource.id], fallback: configured)
+ resourceURLs[resource.id] = result.url
+ access[resource.provider, default: []].append(result.access)
+ if let lease = result.lease { leases.append(lease) }
+ if let bookmark = result.replacementBookmark {
+ replacementBookmarks[resource.id] = bookmark
+ logEvents.append(.info("replaced stale bookmark for \(resource.label)"))
+ }
+ switch result.access.health {
+ case .error(let detail): logEvents.append(.error("\(resource.label) access failed: \(detail)"))
+ case .stale: logEvents.append(.error("\(resource.label) access grant is stale"))
+ case .notRequired, .needed, .granted: break
+ }
+ }
+ let configuration = ProviderRegistryFactory.Configuration(
+ home: paths.home,
+ supportDirectory: paths.supportDirectory,
+ environment: paths.environment,
+ userName: paths.userName,
+ resourceURLs: resourceURLs,
+ resourceAccess: access,
+ resourceLeases: leases,
+ enabledProviders: enabledProviders,
+ keychain: keychain,
+ allowTokenRefresh: allowTokenRefresh)
+ let registry = buildRegistry(configuration, client, log)
+ return ProviderBuildResult(
+ registry: registry, replacementBookmarks: replacementBookmarks, logEvents: logEvents)
+ }
+
+ private struct ProviderBuildResult: Sendable {
+ let registry: ProviderRegistry
+ let replacementBookmarks: [String: Data]
+ let logEvents: [ProviderBuildLogEvent]
+ }
+
+ private enum ProviderBuildLogEvent: Sendable {
+ case info(String)
+ case error(String)
+ }
+
+ public static func widgetStore(
+ supportDirectory: URL,
+ containerURL: (String) -> URL? = { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0) },
+ appGroup: String = WidgetSnapshot.appGroup(info: Bundle.main.infoDictionary)
+ ) -> WidgetSnapshotStore {
+ WidgetSnapshotStore(
+ url: WidgetSnapshotStore.sharedURL(
+ containerURL: containerURL, fallbackDirectory: supportDirectory, appGroup: appGroup))
+ }
+
+ @discardableResult
+ public static func seedDemo(
+ _ history: UsageHistoryStore, log: LogBuffer, now: Date = Date(),
+ fixture: VerificationProfile.Fixture = .standard
+ ) -> Task {
+ Task {
+ do {
+ guard try await history.stats().sampleCount == 0 else { return }
+ try await DemoData.seed(history, providers: ProviderID.allCases, now: now, fixture: fixture)
+ log.log("demo history seeded")
+ } catch {
+ log.logError("demo history seeding failed: \(error)")
+ }
+ }
+ }
+
+ @MainActor
+ public static func relaunch(
+ bundle: Bundle,
+ open: @MainActor (URL, NSWorkspace.OpenConfiguration, @escaping @Sendable () -> Void) -> Void,
+ then terminate: @escaping @MainActor @Sendable () -> Void
+ ) {
+ let configuration = NSWorkspace.OpenConfiguration()
+ configuration.createsNewApplicationInstance = true
+ // Start the replacement without the demo flag this instance may have been launched with.
+ configuration.environment = ProcessInfo.processInfo.environment.filter { $0.key != "TOKEN_MENU_BAR_DEMO" }
+ open(bundle.bundleURL, configuration) { Task { @MainActor in terminate() } }
+ }
+
+ /// Resolves a home directory the sandbox would otherwise block, using the bookmark the user granted for it.
+ @MainActor
+ public static func directory(
+ _ resource: SandboxResource, paths: Paths, settings: TokenMenuBarCore.Settings, isSandboxed: Bool, log: LogBuffer
+ ) -> URL {
+ let configured = resource.configuredURL(environment: paths.environment, home: paths.home)
+ guard isSandboxed else { return configured }
+ return resolve(bookmark: settings.bookmark(for: resource), fallback: configured, log: log)
+ }
+
+ public static func resolve(bookmark: Data?, fallback: URL, log: LogBuffer) -> URL {
+ guard let bookmark else { return fallback }
+ var stale = false
+ guard
+ let url = try? URL(
+ resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &stale)
+ else {
+ log.logError("bookmark could not be resolved; falling back to \(fallback.path)")
+ return fallback
+ }
+ if stale { log.log("bookmark for \(url.lastPathComponent) is stale; grant access again if reads fail") }
+ return url
+ }
+
+ public static func copy(_ text: String, to pasteboard: NSPasteboard) {
+ pasteboard.clearContents()
+ pasteboard.setString(text, forType: .string)
+ }
+
+ @MainActor
+ public static func exportPanel(default directory: URL? = nil) -> NSSavePanel {
+ let panel = NSSavePanel()
+ panel.allowedContentTypes = [.commaSeparatedText]
+ panel.nameFieldStringValue = "token-menu-bar-history.csv"
+ panel.directoryURL = directory
+ return panel
+ }
+
+ @MainActor
+ public static func directoryPanel(_ resource: SandboxResource, paths: Paths) -> NSOpenPanel {
+ directoryPanel(
+ resource: resource, default: resource.configuredURL(environment: paths.environment, home: paths.home))
+ }
+
+ @MainActor
+ public static func directoryPanel(resource: SandboxResource, default directory: URL) -> NSOpenPanel {
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = resource.kind == .directory
+ panel.canChooseFiles = resource.kind == .file
+ panel.showsHiddenFiles = true
+ panel.directoryURL = resource.kind == .file ? directory.deletingLastPathComponent() : directory
+ panel.message = "Select \(resource.label) so \(resource.provider.displayName) usage can be read."
+ return panel
+ }
+
+ @MainActor
+ public static func chosen(_ panel: Panel, run: (Panel) -> NSApplication.ModalResponse) -> URL? {
+ run(panel) == .OK ? panel.url : nil
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Popover/PopoverController.swift b/Sources/TokenMenuBarUI/Popover/PopoverController.swift
new file mode 100644
index 0000000..d32fad8
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Popover/PopoverController.swift
@@ -0,0 +1,487 @@
+import AppKit
+import SwiftUI
+import TokenMenuBarCore
+
+typealias GlobalEventMonitorInstaller = (
+ NSEvent.EventTypeMask, @escaping (NSEvent) -> Void
+) -> Any?
+
+@MainActor
+public final class PopoverController: NSObject, NSPopoverDelegate {
+ public let popover: NSPopover
+ private let hosting: NSHostingController
+ private let isKeyWindow: (NSWindow) -> Bool
+ private let log: LogBuffer?
+ private var gate = PopoverDismissalGate()
+ private var menuObservers: [any NSObjectProtocol] = []
+ private var menuTrackingDepth = 0
+ private var monitors: [Any] = []
+ private var resizeObserver: (any NSObjectProtocol)?
+ private var requestedContentSize: CGSize?
+ private var recoveryAnchorWindow: NSWindow?
+ private var anchorFrame: CGRect?
+ private var pinnedTopY: CGFloat?
+ private var screenFrame: CGRect?
+ private var screenID: String?
+ private var visibleFrame: CGRect?
+ private let presentsWindow: Bool
+ private let recoversOffscreenAnchor: Bool
+ private let addGlobalEventMonitor: GlobalEventMonitorInstaller
+ public var onVisibilityChange: ((Bool) -> Void)?
+ public var onRefresh: (() -> Void)?
+ public var excludedFrame: (() -> CGRect?)?
+ private(set) var measured: [PopoverTab: CGSize] = [:]
+ private(set) var activeTab = PopoverTab.usage
+ private(set) var sessionWidth = PopoverGeometry.stableWidth()
+ private(set) var popoverChromeSize = CGSize.zero
+ public var maximum = CGSize(width: PopoverGeometry.stableWidth(), height: CGFloat.greatestFiniteMagnitude)
+
+ public convenience init(
+ content: AnyView, log: LogBuffer? = nil, animates: Bool = false, presentsWindow: Bool = true,
+ recoversOffscreenAnchor: Bool = false
+ ) {
+ self.init(
+ content: content, log: log, animates: animates, isKeyWindow: { $0.isKeyWindow },
+ presentsWindow: presentsWindow, recoversOffscreenAnchor: recoversOffscreenAnchor)
+ }
+
+ init(
+ content: AnyView, log: LogBuffer?, animates: Bool, isKeyWindow: @escaping (NSWindow) -> Bool,
+ presentsWindow: Bool = true, recoversOffscreenAnchor: Bool = false,
+ addGlobalEventMonitor: @escaping GlobalEventMonitorInstaller = NSEvent.addGlobalMonitorForEvents
+ ) {
+ hosting = NSHostingController(rootView: content)
+ self.isKeyWindow = isKeyWindow
+ self.log = log
+ self.presentsWindow = presentsWindow
+ self.recoversOffscreenAnchor = recoversOffscreenAnchor
+ self.addGlobalEventMonitor = addGlobalEventMonitor
+ popover = NSPopover()
+ super.init()
+ popover.behavior = .applicationDefined
+ popover.animates = animates
+ popover.contentViewController = hosting
+ popover.delegate = self
+ }
+
+ public var isShown: Bool {
+ popover.isShown
+ }
+
+ public func setContent(_ content: AnyView) {
+ hosting.rootView = content
+ }
+
+ public func toggle(
+ relativeTo view: NSView?,
+ anchorFrame: CGRect?,
+ visibleFrame: CGRect?,
+ screenID: String? = nil,
+ screenFrame: CGRect? = nil
+ ) {
+ if popover.isShown {
+ close()
+ } else {
+ show(
+ relativeTo: view,
+ anchorFrame: anchorFrame,
+ visibleFrame: visibleFrame,
+ screenID: screenID,
+ screenFrame: screenFrame)
+ }
+ }
+
+ public func show(
+ relativeTo view: NSView?,
+ anchorFrame: CGRect?,
+ visibleFrame: CGRect?,
+ screenID: String? = nil,
+ screenFrame: CGRect? = nil
+ ) {
+ guard let view, view.window != nil, !popover.isShown else { return }
+ removeResizeObserver()
+ pinnedTopY = nil
+ requestedContentSize = nil
+ popoverChromeSize = .zero
+ sessionWidth = PopoverGeometry.stableWidth()
+ maximum = CGSize(width: sessionWidth, height: .greatestFiniteMagnitude)
+ let resolvedVisibleFrame = Self.resolveVisibleFrame(
+ visibleFrame, windowScreen: view.window?.screen?.visibleFrame, mainScreen: NSScreen.main?.visibleFrame)
+ let reportedAnchorFrame =
+ anchorFrame ?? Self.frameOnScreen(of: view)
+ ?? resolvedVisibleFrame.map {
+ CGRect(x: $0.midX, y: $0.maxY, width: 1, height: 1)
+ }
+ let presentation = presentationAnchor(
+ view: view, anchorFrame: reportedAnchorFrame, visibleFrame: resolvedVisibleFrame)
+ updateGeometry(
+ anchorFrame: presentation.frame,
+ visibleFrame: resolvedVisibleFrame,
+ screenID: screenID,
+ screenFrame: screenFrame,
+ trigger: "open")
+ gate = PopoverDismissalGate()
+ onVisibilityChange?(true)
+ // An accessory app is never frontmost on its own, and a popover in a background app takes no key events, so
+ // neither Tab nor VoiceOver reaches the controls until the app activates.
+ NSApplication.shared.activate()
+ popover.show(relativeTo: presentation.view.bounds, of: presentation.view, preferredEdge: .minY)
+ guard popover.isShown else {
+ removeRecoveryAnchor()
+ onVisibilityChange?(false)
+ return
+ }
+ configureShownWindow()
+ if presentation.recovered {
+ recordPanel(action: .screenChanged, trigger: "offscreen-anchor-recovery")
+ }
+ installMonitors()
+ installMenuTrackingObservers()
+ recordPanel(action: .open, trigger: "status-item")
+ }
+
+ public func close() {
+ guard popover.isShown else { return }
+ popover.close()
+ }
+
+ public func popoverDidClose(_ notification: Notification) {
+ TooltipPresenter.shared.tearDown()
+ removeResizeObserver()
+ pinnedTopY = nil
+ removeMonitors()
+ removeMenuTrackingObservers()
+ removeRecoveryAnchor()
+ onVisibilityChange?(false)
+ }
+
+ public func popoverShouldClose(_ popover: NSPopover) -> Bool {
+ true
+ }
+
+ public func measure(_ measurement: PopoverMeasurement) {
+ measured[measurement.tab] = measurement.size
+ guard measurement.tab == activeTab else { return }
+ applySize()
+ }
+
+ public func select(tab: PopoverTab) {
+ activeTab = tab
+ guard measured[tab] != nil else { return }
+ Task { @MainActor [weak self] in self?.applySize() }
+ }
+
+ public func applySize(trigger: String = "measurement") {
+ let content = idealContentSize()
+ let size = PopoverGeometry.clamp(content, maximum: maximum)
+ guard requestedContentSize.map({ Self.differs($0, size) }) ?? true else { return }
+ requestedContentSize = size
+ guard Self.differs(popover.contentSize, size) else { return }
+ DiagnosticSignposts.geometry.withInterval("Panel resize") { popover.contentSize = size }
+ pinWindowFrame()
+ recordPanel(action: .resize, trigger: trigger, proposed: content, clamped: size)
+ }
+
+ public func updateGeometry(
+ anchorFrame: CGRect?,
+ visibleFrame: CGRect?,
+ screenID: String? = nil,
+ screenFrame: CGRect? = nil,
+ trigger: String = "display-change"
+ ) {
+ guard let anchorFrame, let visibleFrame else { return }
+ var shiftedOriginX: CGFloat?
+ if popover.isShown {
+ let anchorDeltaX = Self.anchorDeltaX(current: anchorFrame, previous: self.anchorFrame)
+ let anchorOffset = Self.anchorOffset(pinnedTopY: pinnedTopY, previous: self.anchorFrame)
+ pinnedTopY = anchorFrame.minY + anchorOffset
+ if abs(anchorDeltaX) > 0.5, let window = popover.contentViewController?.view.window {
+ shiftedOriginX = window.frame.minX + anchorDeltaX
+ }
+ }
+ self.anchorFrame = anchorFrame
+ self.screenID = screenID
+ self.screenFrame = screenFrame
+ maximum = PopoverGeometry.maxSize(
+ anchor: anchorFrame, visibleFrame: visibleFrame, popoverChromeSize: popoverChromeSize)
+ sessionWidth = maximum.width
+ self.visibleFrame = visibleFrame
+ applySize(trigger: trigger)
+ if popover.isShown {
+ if let shiftedOriginX, let window = popover.contentViewController?.view.window {
+ let maximumX = max(visibleFrame.maxX - window.frame.width, visibleFrame.minX)
+ let x = min(max(shiftedOriginX, visibleFrame.minX), maximumX)
+ window.setFrameOrigin(CGPoint(x: x, y: window.frame.minY))
+ }
+ pinWindowFrame()
+ recordPanel(action: .screenChanged, trigger: trigger)
+ }
+ }
+
+ static func resolveVisibleFrame(
+ _ explicit: CGRect?, windowScreen: CGRect?, mainScreen: CGRect?
+ ) -> CGRect? {
+ if let explicit { return explicit }
+ if let windowScreen { return windowScreen }
+ return mainScreen
+ }
+
+ static func anchorDeltaX(current: CGRect, previous: CGRect?) -> CGFloat {
+ guard let previous else { return 0 }
+ return current.midX - previous.midX
+ }
+
+ static func anchorOffset(pinnedTopY: CGFloat?, previous: CGRect?) -> CGFloat {
+ guard let pinnedTopY, let previous else { return 0 }
+ return pinnedTopY - previous.minY
+ }
+
+ private func recordPanel(
+ action: PanelDiagnostic.Action,
+ trigger: String,
+ proposed: CGSize? = nil,
+ clamped: CGSize? = nil
+ ) {
+ guard let log, log.debugEnabled else { return }
+ let proposed = proposed ?? idealContentSize()
+ let clamped = clamped ?? PopoverGeometry.clamp(proposed, maximum: maximum)
+ let window = popover.contentViewController?.view.window
+ log.detailed(
+ .panel(
+ PanelDiagnostic(
+ action: action,
+ trigger: trigger,
+ tab: activeTab.rawValue,
+ anchor: anchorFrame.map(DiagnosticRect.init),
+ screenID: screenID,
+ screenFrame: (screenFrame ?? visibleFrame).map(DiagnosticRect.init),
+ maximum: DiagnosticSize(maximum),
+ proposed: DiagnosticSize(proposed),
+ clamped: DiagnosticSize(clamped),
+ resultFrame: window.map { DiagnosticRect($0.frame) },
+ appActive: NSApp.isActive,
+ windowKey: window?.isKeyWindow,
+ windowMain: window?.isMainWindow,
+ frontmostBundleID: NSWorkspace.shared.frontmostApplication?.bundleIdentifier)))
+ }
+
+ func installMonitors() {
+ removeMonitors()
+ monitors.append(
+ addGlobalEventMonitor(Self.globalEventMask) { [weak self] in self?.handle($0) } as Any)
+ monitors.append(
+ NSEvent.addLocalMonitorForEvents(matching: Self.localEventMask) { [weak self] event in
+ guard let self else { return event }
+ return self.routeLocal(event)
+ } as Any)
+ }
+
+ static let globalEventMask: NSEvent.EventTypeMask = [.leftMouseDown, .rightMouseDown]
+ static let localEventMask = globalEventMask.union(.keyDown)
+
+ func removeMonitors() {
+ for monitor in monitors { NSEvent.removeMonitor(monitor) }
+ monitors.removeAll()
+ }
+
+ @discardableResult
+ public func forward(_ event: NSEvent) -> NSEvent {
+ handle(event)
+ return event
+ }
+
+ func routeLocal(_ event: NSEvent) -> NSEvent? {
+ guard event.type == .keyDown else {
+ handle(event)
+ return event
+ }
+ guard owns(event), let window = popover.contentViewController?.view.window, acceptsKeyRouting(in: window) else {
+ return event
+ }
+ handle(event)
+ return nil
+ }
+
+ @discardableResult
+ public func handle(_ event: NSEvent) -> Bool {
+ if ownsRefresh(event) {
+ onRefresh?()
+ return true
+ }
+ let trigger: PopoverDismissalTrigger
+ switch event.type {
+ case .leftMouseDown, .rightMouseDown: trigger = .mouseDown
+ case .mouseMoved: trigger = .mouseMoved
+ case .keyDown: trigger = .keyEscape
+ default: return false
+ }
+ if trigger == .keyEscape, event.keyCode != 53 { return false }
+ let mouseLocation =
+ if let window = event.window {
+ window.convertPoint(toScreen: event.locationInWindow)
+ } else {
+ event.locationInWindow
+ }
+ return evaluate(trigger: trigger, mouseLocation: mouseLocation)
+ }
+
+ private func owns(_ event: NSEvent) -> Bool {
+ guard event.type == .keyDown, let window = popover.contentViewController?.view.window,
+ isKeyWindow(window), event.windowNumber == window.windowNumber
+ else { return false }
+ return ownsRefresh(event) || ownsEscape(event)
+ }
+
+ private func ownsRefresh(_ event: NSEvent) -> Bool {
+ guard event.type == .keyDown, event.charactersIgnoringModifiers?.lowercased() == "r" else { return false }
+ let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask).subtracting(.capsLock)
+ return modifiers == .command
+ }
+
+ private func ownsEscape(_ event: NSEvent) -> Bool {
+ guard event.type == .keyDown, event.keyCode == 53 else { return false }
+ return event.modifierFlags.intersection([.command, .control, .option]).isEmpty
+ }
+
+ private func acceptsKeyRouting(in window: NSWindow) -> Bool {
+ // AppKit does not send events from nested control and menu tracking loops through a local monitor. Keep the
+ // explicit state for tracking-area exits and for any key event already queued when a menu starts tracking.
+ guard menuTrackingDepth == 0 else { return false }
+ let responder = window.firstResponder
+ if let input = responder as? any NSTextInputClient, input.hasMarkedText() { return false }
+ if let textView = responder as? NSTextView, textView.isFieldEditor { return false }
+ return true
+ }
+
+ @discardableResult
+ public func evaluate(trigger: PopoverDismissalTrigger, mouseLocation: CGPoint) -> Bool {
+ let frame = popover.contentViewController?.view.window?.frame
+ guard
+ gate.shouldClose(
+ mouseLocation: mouseLocation, popoverFrame: frame, excludedFrame: excludedFrame?(), trigger: trigger)
+ else { return false }
+ close()
+ return true
+ }
+
+ private func configureShownWindow() {
+ let window = hosting.view.window!
+ if !presentsWindow { window.alphaValue = 0 }
+ if recoversOffscreenAnchor {
+ window.setAccessibilityElement(true)
+ window.setAccessibilityRole(.window)
+ window.setAccessibilityLabel("Token Menu Bar")
+ }
+ pinnedTopY = window.frame.maxY
+ let contentSize = hosting.view.frame.size
+ if contentSize.width > 0, contentSize.height > 0 {
+ popoverChromeSize = CGSize(
+ width: max(window.frame.width - contentSize.width, 0),
+ height: max(window.frame.height - contentSize.height, 0))
+ }
+ if let anchorFrame, let visibleFrame {
+ maximum = PopoverGeometry.maxSize(
+ anchor: anchorFrame, visibleFrame: visibleFrame, popoverChromeSize: popoverChromeSize)
+ sessionWidth = maximum.width
+ }
+ resizeObserver = NotificationCenter.default.addObserver(
+ forName: NSWindow.didResizeNotification, object: window, queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ guard let self, self.popover.isShown else { return }
+ self.pinWindowFrame()
+ self.recordPanel(action: .resize, trigger: "backing-window")
+ }
+ }
+ if measured[activeTab] == nil || popover.contentSize.width > maximum.width + 1
+ || popover.contentSize.height > maximum.height + 1
+ {
+ applySize(trigger: "window-chrome")
+ }
+ }
+
+ private func idealContentSize() -> CGSize {
+ CGSize(
+ width: sessionWidth,
+ height: PopoverGeometry.preferredHeight(for: activeTab, measured: measured[activeTab]?.height))
+ }
+
+ private func presentationAnchor(
+ view: NSView, anchorFrame: CGRect?, visibleFrame: CGRect?
+ ) -> (view: NSView, frame: CGRect?, recovered: Bool) {
+ removeRecoveryAnchor()
+ guard recoversOffscreenAnchor, let anchorFrame, let visibleFrame, !anchorFrame.intersects(visibleFrame) else {
+ return (view, anchorFrame, false)
+ }
+ let size = CGSize(width: max(anchorFrame.width, 1), height: max(anchorFrame.height, 1))
+ let frame = PopoverGeometry.recoveredFrame(
+ windowFrame: CGRect(origin: .zero, size: size), visibleFrame: visibleFrame)
+ let window = NSPanel(
+ contentRect: frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
+ window.backgroundColor = .clear
+ window.hasShadow = false
+ window.ignoresMouseEvents = true
+ window.isOpaque = false
+ window.isReleasedWhenClosed = false
+ let anchor = NSView(frame: CGRect(origin: .zero, size: size))
+ window.contentView = anchor
+ window.orderFrontRegardless()
+ recoveryAnchorWindow = window
+ return (anchor, frame, true)
+ }
+
+ private func removeRecoveryAnchor() {
+ recoveryAnchorWindow?.orderOut(nil)
+ recoveryAnchorWindow = nil
+ }
+
+ private static func differs(_ lhs: CGSize, _ rhs: CGSize) -> Bool {
+ abs(lhs.width - rhs.width) > 1 || abs(lhs.height - rhs.height) > 1
+ }
+
+ private static func frameOnScreen(of view: NSView) -> CGRect? {
+ let window = view.window!
+ let frame = window.convertToScreen(view.convert(view.bounds, to: nil))
+ return frame.isEmpty ? nil : frame
+ }
+
+ private func pinWindowFrame() {
+ // NSPopover does not define which edge survives contentSize changes. Keep the backing-window dependency here so
+ // an NSPanel migration can delete one compatibility seam instead of unwinding window manipulation across the UI.
+ guard let pinnedTopY, let window = popover.contentViewController?.view.window else { return }
+ let y = pinnedTopY - window.frame.height
+ guard abs(window.frame.minY - y) > 0.5 else { return }
+ window.setFrameOrigin(CGPoint(x: window.frame.minX, y: y))
+ }
+
+ private func installMenuTrackingObservers() {
+ removeMenuTrackingObservers()
+ menuObservers = [
+ NotificationCenter.default.addObserver(
+ forName: NSMenu.didBeginTrackingNotification, object: nil, queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated { self?.menuTrackingDepth += 1 }
+ },
+ NotificationCenter.default.addObserver(
+ forName: NSMenu.didEndTrackingNotification, object: nil, queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ self.menuTrackingDepth = max(self.menuTrackingDepth - 1, 0)
+ }
+ },
+ ]
+ }
+
+ private func removeMenuTrackingObservers() {
+ for observer in menuObservers { NotificationCenter.default.removeObserver(observer) }
+ menuObservers.removeAll()
+ menuTrackingDepth = 0
+ }
+
+ private func removeResizeObserver() {
+ guard let resizeObserver else { return }
+ NotificationCenter.default.removeObserver(resizeObserver)
+ self.resizeObserver = nil
+ }
+}
diff --git a/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift b/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift
new file mode 100644
index 0000000..cf829f8
--- /dev/null
+++ b/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift
@@ -0,0 +1,99 @@
+import Foundation
+import TokenMenuBarCore
+
+public enum ProviderMarkAppearance: String, CaseIterable, Sendable {
+ case light
+ case dark
+}
+
+public struct ProviderMarkDescriptor: Equatable, Sendable {
+ public let provider: ProviderID
+ public let appearance: ProviderMarkAppearance
+ public let resourceName: String?
+ public let fallbackText: String
+ public let backgroundColor: BrandColor
+ public let foregroundColor: BrandColor
+
+ public var accessibilityLabel: String { provider.displayName }
+}
+
+public enum ProviderMarkCatalog {
+ public static func descriptor(
+ for provider: ProviderID, appearance: ProviderMarkAppearance
+ ) -> ProviderMarkDescriptor {
+ ProviderMarkDescriptor(
+ provider: provider,
+ appearance: appearance,
+ resourceName: resourceName(for: provider, appearance: appearance),
+ fallbackText: provider.shortLabel,
+ backgroundColor: backgroundColor(for: provider, appearance: appearance),
+ foregroundColor: foregroundColor(for: provider, appearance: appearance))
+ }
+
+ public static var metadataURL: URL? {
+ resourceURL(named: "provider-marks.json")
+ }
+
+ static func resourceURL(named resourceName: String) -> URL? {
+ let resource = resourceName as NSString
+ let name = resource.deletingPathExtension
+ let fileExtension = resource.pathExtension
+ return resourceBundle.url(forResource: name, withExtension: fileExtension, subdirectory: "ProviderMarks")
+ ?? resourceBundle.url(forResource: name, withExtension: fileExtension)
+ }
+
+ private static let resourceBundle: Bundle = {
+ if let url = Bundle.main.url(forResource: "TokenMenuBar_TokenMenuBarUI", withExtension: "bundle"),
+ let bundle = Bundle(url: url)
+ {
+ return bundle
+ }
+ return Bundle.module
+ }()
+
+ private static func resourceName(
+ for provider: ProviderID, appearance: ProviderMarkAppearance
+ ) -> String? {
+ switch (provider, appearance) {
+ case (.codex, _): "OpenAI-white-monoblossom.svg"
+ case (.cursor, _): "CUBE_2D_DARK.svg"
+ case (.claude, _): "Claude.svg"
+ case (.gemini, _): "GoogleGemini.svg"
+ case (.copilot, _): "GitHubCopilot.svg"
+ }
+ }
+
+ private static func backgroundColor(
+ for provider: ProviderID, appearance: ProviderMarkAppearance
+ ) -> BrandColor {
+ switch (provider, appearance) {
+ case (.claude, .light): BrandColor(0xD9_7757)
+ case (.claude, .dark): BrandColor(0xB8_5F43)
+ case (.codex, .light): BrandColor(0x10_A37F)
+ case (.codex, .dark): BrandColor(0x0C_8064)
+ case (.gemini, .light): BrandColor(0x76_51B5)
+ case (.gemini, .dark): BrandColor(0x5D_3D91)
+ case (.cursor, .light): BrandColor(0x67_78C4)
+ case (.cursor, .dark): BrandColor(0x43_4D8E)
+ case (.copilot, .light): BrandColor(0x6E_40C9)
+ case (.copilot, .dark): BrandColor(0x54_30A0)
+ }
+ }
+
+ private static func foregroundColor(
+ for provider: ProviderID, appearance: ProviderMarkAppearance
+ ) -> BrandColor {
+ switch (provider, appearance) {
+ case (.claude, .light): BrandColor(0x7A_2F1E)
+ case (.claude, .dark): BrandColor(0xFF_E7DF)
+ case (.codex, .light): BrandColor(0x00_0000)
+ case (.codex, .dark): BrandColor(0xFF_FFFF)
+ case (.gemini, .light): BrandColor(0x17_4EA6)
+ case (.gemini, .dark): BrandColor(0xDC_E8FF)
+ case (.cursor, .light): BrandColor(0x26_251E)
+ case (.cursor, .dark): BrandColor(0xED_ECEC)
+ case (.copilot, .light): BrandColor(0x3C_2D91)
+ case (.copilot, .dark): BrandColor(0xEC_E9FF)
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkImageLoader.swift b/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkImageLoader.swift
new file mode 100644
index 0000000..5d1c1ba
--- /dev/null
+++ b/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkImageLoader.swift
@@ -0,0 +1,36 @@
+import AppKit
+import TokenMenuBarCore
+
+@MainActor
+public final class ProviderMarkImageLoader {
+ public static let shared = ProviderMarkImageLoader()
+
+ private struct Key: Hashable {
+ let provider: ProviderID
+ let appearance: ProviderMarkAppearance
+ }
+
+ private var images: [Key: NSImage] = [:]
+ private var unavailable: Set = []
+
+ private init() {}
+
+ public func image(for provider: ProviderID, appearance: ProviderMarkAppearance) -> NSImage? {
+ let key = Key(provider: provider, appearance: appearance)
+ if let image = images[key] { return image }
+ if unavailable.contains(key) { return nil }
+ let descriptor = ProviderMarkCatalog.descriptor(for: provider, appearance: appearance)
+ guard
+ let resourceName = descriptor.resourceName,
+ let url = ProviderMarkCatalog.resourceURL(named: resourceName),
+ let image = NSImage(contentsOf: url)
+ else {
+ unavailable.insert(key)
+ return nil
+ }
+ image.isTemplate = false
+ image.cacheMode = .always
+ images[key] = image
+ return image
+ }
+}
diff --git a/Sources/TokenMenuBarUI/ProviderSettingsFocusRequest.swift b/Sources/TokenMenuBarUI/ProviderSettingsFocusRequest.swift
new file mode 100644
index 0000000..75f4c47
--- /dev/null
+++ b/Sources/TokenMenuBarUI/ProviderSettingsFocusRequest.swift
@@ -0,0 +1,12 @@
+import Foundation
+import TokenMenuBarCore
+
+public struct ProviderSettingsFocusRequest: Identifiable, Equatable, Sendable {
+ public let id: UUID
+ public let provider: ProviderID?
+
+ public init(id: UUID = UUID(), provider: ProviderID?) {
+ self.id = id
+ self.provider = provider
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Resources/ProviderMarks/CUBE_2D_DARK.svg b/Sources/TokenMenuBarUI/Resources/ProviderMarks/CUBE_2D_DARK.svg
new file mode 100644
index 0000000..6849fbc
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Resources/ProviderMarks/CUBE_2D_DARK.svg
@@ -0,0 +1,12 @@
+
+
diff --git a/Sources/TokenMenuBarUI/Resources/ProviderMarks/CUBE_2D_LIGHT.svg b/Sources/TokenMenuBarUI/Resources/ProviderMarks/CUBE_2D_LIGHT.svg
new file mode 100644
index 0000000..b054b18
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Resources/ProviderMarks/CUBE_2D_LIGHT.svg
@@ -0,0 +1,12 @@
+
+
diff --git a/Sources/TokenMenuBarUI/Resources/ProviderMarks/Claude.svg b/Sources/TokenMenuBarUI/Resources/ProviderMarks/Claude.svg
new file mode 100644
index 0000000..48f37a4
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Resources/ProviderMarks/Claude.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/TokenMenuBarUI/Resources/ProviderMarks/GitHubCopilot.svg b/Sources/TokenMenuBarUI/Resources/ProviderMarks/GitHubCopilot.svg
new file mode 100644
index 0000000..980bf1f
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Resources/ProviderMarks/GitHubCopilot.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/TokenMenuBarUI/Resources/ProviderMarks/GoogleGemini.svg b/Sources/TokenMenuBarUI/Resources/ProviderMarks/GoogleGemini.svg
new file mode 100644
index 0000000..5faaf7d
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Resources/ProviderMarks/GoogleGemini.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/TokenMenuBarUI/Resources/ProviderMarks/OpenAI-black-monoblossom.svg b/Sources/TokenMenuBarUI/Resources/ProviderMarks/OpenAI-black-monoblossom.svg
new file mode 100644
index 0000000..832fa6a
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Resources/ProviderMarks/OpenAI-black-monoblossom.svg
@@ -0,0 +1,15 @@
+
diff --git a/Sources/TokenMenuBarUI/Resources/ProviderMarks/OpenAI-white-monoblossom.svg b/Sources/TokenMenuBarUI/Resources/ProviderMarks/OpenAI-white-monoblossom.svg
new file mode 100644
index 0000000..ba36fc2
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Resources/ProviderMarks/OpenAI-white-monoblossom.svg
@@ -0,0 +1,15 @@
+
diff --git a/Sources/TokenMenuBarUI/Resources/ProviderMarks/provider-marks.json b/Sources/TokenMenuBarUI/Resources/ProviderMarks/provider-marks.json
new file mode 100644
index 0000000..8670e2a
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Resources/ProviderMarks/provider-marks.json
@@ -0,0 +1,122 @@
+{
+ "schemaVersion": 1,
+ "retrieved": "2026-09-01",
+ "assets": [
+ {
+ "provider": "codex",
+ "brand": "OpenAI",
+ "approvalState": "allowed-under-published-guidelines",
+ "sourcePage": "https://openai.com/brand/",
+ "sourceArchive": "https://cdn.openai.com/brand/OpenAI-Logos-2025.zip",
+ "sourceArchiveSHA256": "b2c4cd1e86bbe76bdc4946a72d014efa455240c177f4878fd68cc9b88c71d2ec",
+ "terms": "https://openai.com/brand/",
+ "variants": [
+ {
+ "appearance": "light",
+ "resource": "OpenAI-white-monoblossom.svg",
+ "archiveMember": "OpenAI-logos(new)/SVGs/OpenAI-white-monoblossom.svg",
+ "sha256": "b94ea61d860fae6f82f43571f36f17111fcf5d348e8e9cc22ae4b441c7560011"
+ },
+ {
+ "appearance": "dark",
+ "resource": "OpenAI-white-monoblossom.svg",
+ "archiveMember": "OpenAI-logos(new)/SVGs/OpenAI-white-monoblossom.svg",
+ "sha256": "b94ea61d860fae6f82f43571f36f17111fcf5d348e8e9cc22ae4b441c7560011"
+ }
+ ]
+ },
+ {
+ "provider": "cursor",
+ "brand": "Cursor",
+ "approvalState": "published-for-consistent-representation",
+ "sourcePage": "https://cursor.com/brand",
+ "sourceArchive": "https://ptht05hbb1ssoooe.public.blob.vercel-storage.com/assets/brand/cursor-brand-assets.zip",
+ "sourceArchiveSHA256": "97488a7751914e60f9ff532bc33810cdeaebdddc017548abe6ca2bc29bbc3928",
+ "terms": "https://cursor.com/brand",
+ "variants": [
+ {
+ "appearance": "light",
+ "resource": "CUBE_2D_DARK.svg",
+ "archiveMember": "General Logos/Cube/SVG/CUBE_2D_DARK.svg",
+ "sha256": "0c1940179c8fe0a877c331a1e19a2cb9437a8f9633594e1d81f9ffba106615ff"
+ },
+ {
+ "appearance": "dark",
+ "resource": "CUBE_2D_DARK.svg",
+ "archiveMember": "General Logos/Cube/SVG/CUBE_2D_DARK.svg",
+ "sha256": "0c1940179c8fe0a877c331a1e19a2cb9437a8f9633594e1d81f9ffba106615ff"
+ }
+ ]
+ },
+ {
+ "provider": "claude",
+ "brand": "Claude",
+ "approvalState": "cc0-source-artwork",
+ "sourcePage": "https://claude.ai",
+ "sourceArchive": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.21.0.tgz",
+ "sourceArchiveSHA256": "9921025b53ce4c29529846e1dd29532e6c991a356c5d7685a2ab487e87affe87",
+ "terms": "https://github.com/simple-icons/simple-icons/blob/16.21.0/LICENSE.md",
+ "variants": [
+ {
+ "appearance": "light",
+ "resource": "Claude.svg",
+ "archiveMember": "package/icons/claude.svg",
+ "sha256": "e141dd986358b96437c4789c0c2fd194047e4e80ac2dc4c5472158828543e2f7"
+ },
+ {
+ "appearance": "dark",
+ "resource": "Claude.svg",
+ "archiveMember": "package/icons/claude.svg",
+ "sha256": "e141dd986358b96437c4789c0c2fd194047e4e80ac2dc4c5472158828543e2f7"
+ }
+ ]
+ },
+ {
+ "provider": "gemini",
+ "brand": "Google Gemini",
+ "approvalState": "cc0-source-artwork",
+ "sourcePage": "https://gemini.google.com",
+ "sourceArchive": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.21.0.tgz",
+ "sourceArchiveSHA256": "9921025b53ce4c29529846e1dd29532e6c991a356c5d7685a2ab487e87affe87",
+ "terms": "https://github.com/simple-icons/simple-icons/blob/16.21.0/LICENSE.md",
+ "variants": [
+ {
+ "appearance": "light",
+ "resource": "GoogleGemini.svg",
+ "archiveMember": "package/icons/googlegemini.svg",
+ "sha256": "c083a589b9db54b6d912cbdd8a2bb998c8a29ac9acc1d739a5c75b522f3af5c0"
+ },
+ {
+ "appearance": "dark",
+ "resource": "GoogleGemini.svg",
+ "archiveMember": "package/icons/googlegemini.svg",
+ "sha256": "c083a589b9db54b6d912cbdd8a2bb998c8a29ac9acc1d739a5c75b522f3af5c0"
+ }
+ ]
+ },
+ {
+ "provider": "copilot",
+ "brand": "GitHub Copilot",
+ "approvalState": "mit-source-artwork",
+ "sourcePage": "https://primer.style/foundations/icons/copilot-24",
+ "sourceArchive": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.21.0.tgz",
+ "sourceArchiveSHA256": "9921025b53ce4c29529846e1dd29532e6c991a356c5d7685a2ab487e87affe87",
+ "terms": "https://github.com/primer/octicons/blob/main/LICENSE",
+ "variants": [
+ {
+ "appearance": "light",
+ "resource": "GitHubCopilot.svg",
+ "archiveMember": "package/icons/githubcopilot.svg",
+ "sha256": "e552ea0d793ff47ee97a52cc69ed5e2c7479d649b738a85243806f68b4db0c27"
+ },
+ {
+ "appearance": "dark",
+ "resource": "GitHubCopilot.svg",
+ "archiveMember": "package/icons/githubcopilot.svg",
+ "sha256": "e552ea0d793ff47ee97a52cc69ed5e2c7479d649b738a85243806f68b4db0c27"
+ }
+ ]
+ }
+ ],
+ "fallbacks": []
+}
diff --git a/Sources/TokenMenuBarUI/StatusItem/StatusItemController.swift b/Sources/TokenMenuBarUI/StatusItem/StatusItemController.swift
new file mode 100644
index 0000000..fc1c138
--- /dev/null
+++ b/Sources/TokenMenuBarUI/StatusItem/StatusItemController.swift
@@ -0,0 +1,460 @@
+import AppKit
+import TokenMenuBarCore
+
+public struct StatusItemProbe: Equatable, Sendable {
+ public let isVisible: Bool
+ public let buttonHidden: Bool
+ public let windowVisible: Bool?
+ public let occlusionVisible: Bool?
+ public let length: Double
+ public let buttonWidth: Double
+ public let frontmostApp: String?
+
+ public init(
+ isVisible: Bool, buttonHidden: Bool, windowVisible: Bool?, occlusionVisible: Bool?, length: Double,
+ buttonWidth: Double, frontmostApp: String?
+ ) {
+ self.isVisible = isVisible
+ self.buttonHidden = buttonHidden
+ self.windowVisible = windowVisible
+ self.occlusionVisible = occlusionVisible
+ self.length = length
+ self.buttonWidth = buttonWidth
+ self.frontmostApp = frontmostApp
+ }
+
+ public var summary: String {
+ """
+ visible=\(isVisible) buttonHidden=\(buttonHidden) window=\(windowVisible.map(String.init) ?? "-") \
+ occlusion=\(occlusionVisible.map(String.init) ?? "-") length=\(Int(length)) width=\(Int(buttonWidth)) \
+ front=\(frontmostApp ?? "-")
+ """
+ }
+}
+
+@MainActor
+final class MenuCloseObserver: NSObject, NSMenuDelegate {
+ private let onClose: () -> Void
+
+ init(onClose: @escaping () -> Void) {
+ self.onClose = onClose
+ }
+
+ func menuDidClose(_ menu: NSMenu) {
+ onClose()
+ }
+}
+
+@MainActor
+public final class StatusItemController {
+ public let item: NSStatusItem
+ private let log: LogBuffer
+ private var lastSignature: StatusRenderSignature?
+ private var countdownTask: Task?
+ private var probeTask: Task?
+ private var lastProbe: StatusItemProbe?
+ private var planner = AdaptiveWidthPlanner()
+ private var fitTask: Task?
+ private var deferredForget = false
+ private var frozenLength: CGFloat?
+ private var countdownGeneration = 0
+ private(set) var ladder: [StatusItemModel] = [.empty]
+ public var adaptive = true
+ public var notchAreas: (() -> (CGRect?, CGRect?))?
+ public var frontmostContext: () -> String = {
+ normalizedContext(NSWorkspace.shared.frontmostApplication?.bundleIdentifier)
+ }
+ public var visibleItemFrame: (NSStatusItem) -> CGRect? = { StatusItemController.onScreenFrame(of: $0.button?.window) }
+ private var lastForeignContext = ""
+ public var fitCheckDelay: Duration = .milliseconds(30)
+ private var observers: [(center: NotificationCenter, token: any NSObjectProtocol)] = []
+ private var appearanceObservation: NSKeyValueObservation?
+ private(set) var model: StatusItemModel = .empty
+ public var onClick: (() -> Void)?
+ public var onCountdownTick: (() -> Void)?
+ public var onProbeChange: ((StatusItemProbe) -> Void)?
+ public var menuProvider: (() -> NSMenu)?
+ public var popoverVisible = false {
+ didSet {
+ guard popoverVisible != oldValue else { return }
+ if popoverVisible {
+ if let width = item.button?.frame.width, width > 0 {
+ frozenLength = width
+ item.length = width
+ }
+ fitTask?.cancel()
+ fitTask = nil
+ } else {
+ frozenLength = nil
+ item.length = NSStatusItem.variableLength
+ if deferredForget { planner.forget() }
+ deferredForget = false
+ restart(trigger: "popover-close")
+ }
+ }
+ }
+ public var detailedLoggingEnabled = false {
+ didSet { updateProbeTimer() }
+ }
+ private let presentMenu: (NSStatusBarButton) -> Void
+ private let clock: Clock
+ private let diagnosticProbeInterval: TimeInterval
+
+ public init(
+ statusBar: NSStatusBar = .system,
+ item existingItem: NSStatusItem? = nil,
+ log: LogBuffer,
+ clock: Clock = .system,
+ diagnosticProbeInterval: TimeInterval = 60,
+ autosaveName: String? = StatusItemController.autosaveName(bundleIdentifier: Bundle.main.bundleIdentifier),
+ presentMenu: @escaping (NSStatusBarButton) -> Void
+ ) {
+ self.log = log
+ self.clock = clock
+ self.diagnosticProbeInterval = diagnosticProbeInterval
+ self.presentMenu = presentMenu
+ item = existingItem ?? statusBar.statusItem(withLength: NSStatusItem.variableLength)
+ item.length = NSStatusItem.variableLength
+ item.autosaveName = autosaveName
+ item.button?.target = self
+ item.button?.action = #selector(buttonClicked(_:))
+ item.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
+ appearanceObservation = item.button?.observe(\.effectiveAppearance) { [weak self] _, _ in self?.appearanceChanged()
+ }
+ let center = NotificationCenter.default
+ for name in [
+ NSApplication.didChangeScreenParametersNotification, NSWorkspace.didWakeNotification,
+ NSWorkspace.didActivateApplicationNotification,
+ ] {
+ let sender: NotificationCenter =
+ name == NSApplication.didChangeScreenParametersNotification ? center : NSWorkspace.shared.notificationCenter
+ let token = sender.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.layoutChanged(
+ forgetting: name != NSWorkspace.didActivateApplicationNotification,
+ trigger: name.rawValue)
+ }
+ }
+ // Two of these live on the workspace's own centre, so each token has to go back to the centre it came from.
+ observers.append((sender, token))
+ }
+ }
+
+ public static func autosaveName(bundleIdentifier: String?) -> String {
+ "\(bundleIdentifier ?? "dev.tox.token-menu-bar").status"
+ }
+
+ nonisolated static func normalizedContext(_ bundleIdentifier: String?) -> String {
+ bundleIdentifier ?? ""
+ }
+
+ /// Which app's menu bar the item is competing with. Opening the popover activates this app, and remembering a
+ /// tier against ourselves would re-tier the item every time it opens, so the app underneath keeps the context.
+ func layoutContext() -> String {
+ let frontmost = frontmostContext()
+ if !frontmost.isEmpty, frontmost != Bundle.main.bundleIdentifier { lastForeignContext = frontmost }
+ return lastForeignContext
+ }
+
+ func layoutChanged(forgetting: Bool, trigger: String = "layout-change") {
+ if detailedLoggingEnabled { probe() }
+ guard !popoverVisible else {
+ deferredForget = deferredForget || forgetting
+ recordStatus(action: .deferred, trigger: trigger)
+ return
+ }
+ if forgetting { planner.forget() }
+ restart(trigger: trigger)
+ }
+
+ nonisolated func appearanceChanged() {
+ Task { @MainActor in render(force: true) }
+ }
+
+ public var isDark: Bool {
+ let appearance = item.button?.effectiveAppearance ?? NSApp.effectiveAppearance
+ return appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
+ }
+
+ public var barHeight: CGFloat {
+ max(NSStatusBar.system.thickness, 22)
+ }
+
+ public func update(_ model: StatusItemModel) {
+ update(ladder: [model])
+ }
+
+ public func update(ladder: [StatusItemModel]) {
+ let height = barHeight
+ let dark = isDark
+ let candidates = ladder.isEmpty ? [.empty] : ladder
+ let widths = candidates.map {
+ Double(StatusItemRenderer.attributedTitle(for: $0, height: height, dark: dark).size().width)
+ }
+ self.ladder = AdaptiveWidthPlanner.ladder(candidates, widths: widths)
+ if popoverVisible {
+ let index = adaptive ? planner.index : 0
+ apply(self.ladder[min(index, self.ladder.count - 1)])
+ recordStatus(action: .deferred, trigger: "model-update")
+ return
+ }
+ restart(trigger: "model-update")
+ }
+
+ func restart(trigger: String = "restart") {
+ guard !popoverVisible else {
+ recordStatus(action: .deferred, trigger: trigger)
+ return
+ }
+ let count = adaptive ? self.ladder.count : 1
+ let context = layoutContext()
+ let oldIndex = planner.index
+ let index = planner.begin(context: context, ladderCount: count)
+ apply(self.ladder[min(index, self.ladder.count - 1)])
+ recordStatus(action: .retier, trigger: trigger, oldTier: oldIndex, newTier: index, context: context)
+ scheduleFitCheck()
+ }
+
+ func apply(_ model: StatusItemModel) {
+ self.model = model
+ render(force: false)
+ updateCountdownTimer()
+ }
+
+ func scheduleFitCheck() {
+ fitTask?.cancel()
+ guard !popoverVisible, adaptive, ladder.count > 1 else { return }
+ fitTask = Task { @MainActor [weak self, fitCheckDelay] in
+ guard (try? await Task.sleep(for: fitCheckDelay)) != nil else { return }
+ self?.checkFit()
+ }
+ }
+
+ public static func onScreenFrame(of window: NSWindow?, screens: [NSScreen] = NSScreen.screens) -> CGRect? {
+ guard let window, window.isVisible,
+ AdaptiveWidthPlanner.isOnScreen(itemFrame: window.frame, screenFrames: screens.map(\.frame))
+ else { return nil }
+ return window.frame
+ }
+
+ public func settleFitCheck() async {
+ await fitTask?.value
+ }
+
+ public func fits() -> Bool {
+ guard let frame = visibleItemFrame(item) else { return false }
+ let screen = item.button?.window?.screen
+ let areas = notchAreas?() ?? (screen?.auxiliaryTopLeftArea, screen?.auxiliaryTopRightArea)
+ return !AdaptiveWidthPlanner.hiddenByNotch(itemFrame: frame, leftArea: areas.0, rightArea: areas.1)
+ }
+
+ @discardableResult
+ public func checkFit() -> Bool {
+ guard !popoverVisible else { return true }
+ let fits = fits()
+ let oldIndex = planner.index
+ let context = layoutContext()
+ if fits {
+ planner.didFit(context: context)
+ } else if let next = planner.didNotFit(ladderCount: ladder.count) {
+ log.logDebug("status item does not fit; stepping down to tier \(next)")
+ apply(ladder[next])
+ recordStatus(
+ action: .retier,
+ trigger: "fit-check",
+ oldTier: oldIndex,
+ newTier: next,
+ fits: false,
+ context: context)
+ scheduleFitCheck()
+ }
+ recordStatus(action: .probe, trigger: "fit-check", fits: fits, context: context)
+ return fits
+ }
+
+ @discardableResult
+ public func collapseToNarrowest() -> Bool {
+ let index = planner.selectNarrowest(ladderCount: ladder.count)
+ apply(ladder[index])
+ return true
+ }
+
+ public func reattach() {
+ item.isVisible = false
+ item.isVisible = true
+ lastSignature = nil
+ render(force: true)
+ }
+
+ func render(force: Bool) {
+ let signature = StatusRenderSignature(model: model, dark: isDark, height: Double(barHeight))
+ guard force || signature != lastSignature, let button = item.button else { return }
+ lastSignature = signature
+ let height = barHeight
+ if model.showsIcon {
+ button.image = AppIcon.image(height: height, tone: model.iconTone, dark: isDark)
+ button.imagePosition = model.cells.isEmpty ? .imageOnly : .imageLeading
+ } else {
+ button.image = nil
+ button.imagePosition = .noImage
+ }
+ button.attributedTitle = StatusItemRenderer.attributedTitle(for: model, height: height, dark: isDark)
+ button.setAccessibilityLabel(StatusItemRenderer.accessibilityDescription(for: model))
+ button.toolTip = model.cells.map(\.tooltip).joined(separator: "\n")
+ item.length = frozenLength ?? NSStatusItem.variableLength
+ }
+
+ func updateCountdownTimer() {
+ guard model.countdownActive else {
+ countdownGeneration += 1
+ countdownTask?.cancel()
+ countdownTask = nil
+ return
+ }
+ guard countdownTask == nil else { return }
+ countdownGeneration += 1
+ let generation = countdownGeneration
+ countdownTask = Task { @MainActor [weak self, clock] in
+ while !Task.isCancelled {
+ let now = clock.now()
+ let deadline = Self.nextCountdownUpdate(after: now)
+ do {
+ try await clock.sleep(deadline.timeIntervalSince(now))
+ } catch {
+ break
+ }
+ guard !Task.isCancelled else { break }
+ self?.onCountdownTick?()
+ }
+ guard let self, self.countdownGeneration == generation else { return }
+ self.countdownTask = nil
+ }
+ }
+
+ public var countdownRunning: Bool {
+ countdownTask != nil
+ }
+
+ nonisolated static func nextCountdownUpdate(after date: Date) -> Date {
+ Date(timeIntervalSince1970: (floor(date.timeIntervalSince1970 / 60) + 1) * 60)
+ }
+
+ func updateProbeTimer() {
+ probeTask?.cancel()
+ probeTask = nil
+ guard detailedLoggingEnabled else { return }
+ probeTask = Self.ticker(every: diagnosticProbeInterval, clock: clock) { [weak self] in self?.probe() }
+ }
+
+ public var diagnosticProbeRunning: Bool {
+ probeTask != nil
+ }
+
+ nonisolated static func ticker(
+ every interval: TimeInterval,
+ clock: Clock,
+ _ tick: @escaping @MainActor @Sendable () -> Void
+ ) -> Task {
+ Task.detached {
+ while (try? await clock.sleep(interval)) != nil {
+ guard !Task.isCancelled else { break }
+ await tick()
+ }
+ }
+ }
+
+ @discardableResult
+ public func probe() -> StatusItemProbe {
+ let button = item.button
+ let window = button?.window
+ let sample = StatusItemProbe(
+ isVisible: item.isVisible,
+ buttonHidden: button?.isHidden ?? true,
+ windowVisible: window?.isVisible,
+ occlusionVisible: window.map { $0.occlusionState.contains(.visible) },
+ length: Double(item.length),
+ buttonWidth: Double(button?.frame.width ?? 0),
+ frontmostApp: NSWorkspace.shared.frontmostApplication?.localizedName
+ )
+ if sample != lastProbe {
+ lastProbe = sample
+ log.logDebug("status item \(sample.summary)")
+ recordStatus(action: .probe, trigger: "visibility-probe", context: layoutContext())
+ onProbeChange?(sample)
+ }
+ return sample
+ }
+
+ private func recordStatus(
+ action: StatusDiagnostic.Action,
+ trigger: String,
+ oldTier: Int? = nil,
+ newTier: Int? = nil,
+ fits: Bool? = nil,
+ context: String? = nil
+ ) {
+ guard detailedLoggingEnabled else { return }
+ let buttonFrame = buttonFrameOnScreen.map(DiagnosticRect.init)
+ let event: StatusDiagnostic?
+ if action == .retier, let oldTier, let newTier {
+ event = StatusDiagnostic.retierIfChanged(
+ trigger: trigger,
+ buttonFrame: buttonFrame,
+ oldTier: oldTier,
+ newTier: newTier,
+ visible: item.isVisible,
+ popoverVisible: popoverVisible,
+ fits: fits,
+ layoutContext: context)
+ } else {
+ event = StatusDiagnostic(
+ action: action,
+ trigger: trigger,
+ buttonFrame: buttonFrame,
+ oldTier: oldTier,
+ newTier: newTier,
+ visible: item.isVisible,
+ popoverVisible: popoverVisible,
+ fits: fits,
+ layoutContext: context)
+ }
+ guard let event else { return }
+ log.detailed(.status(event))
+ }
+
+ public var buttonFrameOnScreen: CGRect? {
+ guard let button = item.button, let window = button.window else { return nil }
+ return window.convertToScreen(button.convert(button.bounds, to: nil))
+ }
+
+ @objc func buttonClicked(_ sender: Any?) {
+ handleClick(NSApp.currentEvent)
+ }
+
+ public func handleClick(_ event: NSEvent?) {
+ if event?.type == .rightMouseUp, let menu = menuProvider?() { return show(menu) }
+ onClick?()
+ }
+
+ public func show(_ menu: NSMenu) {
+ menu.delegate = menuDelegate
+ item.menu = menu
+ if let button = item.button { presentMenu(button) }
+ }
+
+ private lazy var menuDelegate = MenuCloseObserver { [weak self] in self?.item.menu = nil }
+
+ public func remove(from statusBar: NSStatusBar = .system) {
+ countdownTask?.cancel()
+ countdownTask = nil
+ countdownGeneration += 1
+ fitTask?.cancel()
+ fitTask = nil
+ detailedLoggingEnabled = false
+ for observer in observers { observer.center.removeObserver(observer.token) }
+ observers.removeAll()
+ appearanceObservation?.invalidate()
+ appearanceObservation = nil
+ statusBar.removeStatusItem(item)
+ }
+}
diff --git a/Sources/TokenMenuBarUI/StatusItem/StatusItemRenderer.swift b/Sources/TokenMenuBarUI/StatusItem/StatusItemRenderer.swift
new file mode 100644
index 0000000..ad46060
--- /dev/null
+++ b/Sources/TokenMenuBarUI/StatusItem/StatusItemRenderer.swift
@@ -0,0 +1,227 @@
+import AppKit
+import TokenMenuBarCore
+
+public struct StatusRenderSignature: Hashable, Sendable {
+ public let model: StatusItemModel
+ public let dark: Bool
+ public let height: Double
+
+ public init(model: StatusItemModel, dark: Bool, height: Double) {
+ self.model = model
+ self.dark = dark
+ self.height = height
+ }
+}
+
+@MainActor
+public enum StatusItemRenderer {
+ public static let maxFontSize: CGFloat = 13
+ public static let minFontSize: CGFloat = 8
+ public static let cellPadding: CGFloat = 6
+ public static let separatorWidth: CGFloat = 8
+ public static let barWidth: CGFloat = 30
+ public static let barHeight: CGFloat = 4
+
+ public static func fontSizes(height: CGFloat, lineCount: Int) -> [CGFloat] {
+ StatusMetrics.fontSizes(height: Double(height), lineCount: lineCount).map { CGFloat($0) }
+ }
+
+ public static func color(for kind: StatusRun.Kind, dark: Bool) -> NSColor {
+ switch kind {
+ case .label:
+ return dark ? NSColor.white : NSColor.black
+ case .number:
+ return (dark ? NSColor.white : NSColor.black).withAlphaComponent(0.85)
+ case .usage(let percent):
+ let hsb = UsageColor.color(percent: percent)
+ return NSColor(
+ hue: hsb.hue, saturation: hsb.saturation, brightness: dark ? hsb.brightness + 0.1 : hsb.brightness, alpha: 1)
+ }
+ }
+
+ /// The last title built, so measuring the adaptive ladder and then rendering the entry it picked share one build
+ /// instead of doing it twice for the same model.
+ private static var lastTitle: (signature: StatusRenderSignature, title: NSAttributedString)?
+
+ public static func attributedTitle(for model: StatusItemModel, height: CGFloat, dark: Bool) -> NSAttributedString {
+ let signature = StatusRenderSignature(model: model, dark: dark, height: Double(height))
+ if let lastTitle, lastTitle.signature == signature { return lastTitle.title }
+ let title = build(model, height: height, dark: dark)
+ lastTitle = (signature, title)
+ return title
+ }
+
+ /// The item draws its content into images, so VoiceOver needs both the visible rendering and its full context.
+ public static func accessibilityDescription(for model: StatusItemModel) -> String {
+ let readings = model.cells.flatMap { cell in
+ var descriptions: [String] = []
+ if cell.isMiniBar {
+ let bars = cell.bars.map { "\($0.label) bar at \(Format.percent($0.percent))" }.joined(separator: ", ")
+ if !bars.isEmpty { descriptions.append("Displayed \(bars)") }
+ } else {
+ let rendered = cell.lines.map { $0.map(\.text).joined() }.joined(separator: " / ")
+ if !rendered.isEmpty { descriptions.append("Displayed \(rendered)") }
+ }
+ descriptions += cell.tooltip.split(separator: "\n").map(String.init)
+ return descriptions
+ }
+ return readings.isEmpty ? "Token Menu Bar, no usage yet" : "Token Menu Bar, " + readings.joined(separator: ", ")
+ }
+
+ private static func build(_ model: StatusItemModel, height: CGFloat, dark: Bool) -> NSAttributedString {
+ let title = NSMutableAttributedString()
+ for (index, cell) in model.cells.enumerated() {
+ if index > 0 { title.append(attachment(separatorImage(height: height))) }
+ title.append(attachment(cellImage(cell, height: height, dark: dark)))
+ }
+ return title
+ }
+
+ static func attachment(_ image: NSImage) -> NSAttributedString {
+ let attachment = NSTextAttachment()
+ attachment.image = image
+ let font = NSFont.menuBarFont(ofSize: 0)
+ attachment.bounds = CGRect(
+ x: 0, y: (font.capHeight - image.size.height) / 2, width: image.size.width, height: image.size.height)
+ return NSAttributedString(attachment: attachment)
+ }
+
+ static func separatorImage(height: CGFloat) -> NSImage {
+ NSImage(size: CGSize(width: separatorWidth, height: height), flipped: false) { rect in
+ NSColor.tertiaryLabelColor.withAlphaComponent(0.5).setFill()
+ NSRect(x: rect.midX - 0.5, y: 4, width: 1, height: rect.height - 8).fill()
+ return true
+ }
+ }
+
+ public static func cellImage(_ cell: StatusCell, height: CGFloat, dark: Bool) -> NSImage {
+ cell.isMiniBar ? miniBarImage(cell, height: height, dark: dark) : textImage(cell, height: height, dark: dark)
+ }
+
+ static func textImage(_ cell: StatusCell, height: CGFloat, dark: Bool) -> NSImage {
+ let lines = lineStrings(cell, height: height, dark: dark)
+ // Laying out an attributed string is the expensive part here, and AppKit calls the drawing handler again on
+ // every scale and appearance change, so measure once and carry the sizes in.
+ let sizes = lines.map { $0.size() }
+ let width = ceil(sizes.map(\.width).max() ?? 0) + cellPadding * 2
+ let total = sizes.map(\.height).reduce(0, +)
+ return NSImage(size: CGSize(width: width, height: height), flipped: true) { rect in
+ var lineTop = (rect.height - total) / 2
+ for (line, size) in zip(lines, sizes) {
+ line.draw(at: CGPoint(x: (rect.width - size.width) / 2, y: lineTop))
+ lineTop += size.height
+ }
+ return true
+ }
+ }
+
+ static func lineStrings(_ cell: StatusCell, height: CGFloat, dark: Bool) -> [NSAttributedString] {
+ zip(cell.lines, fontSizes(height: height, lineCount: cell.lines.count)).map { runs, size in
+ let line = NSMutableAttributedString()
+ for run in runs {
+ let font: NSFont =
+ switch run.kind {
+ case .label: NSFont.systemFont(ofSize: size, weight: .regular)
+ default: NSFont.monospacedDigitSystemFont(ofSize: size, weight: .medium)
+ }
+ line.append(
+ NSAttributedString(
+ string: run.text, attributes: [.font: font, .foregroundColor: color(for: run.kind, dark: dark)]))
+ }
+ return line
+ }
+ }
+
+ static func miniBarImage(_ cell: StatusCell, height: CGFloat, dark: Bool) -> NSImage {
+ let glyph = ProviderGlyph.image(cell.provider, pointSize: min(height * 0.55, 12))
+ let labelFont = NSFont.systemFont(ofSize: 8, weight: .bold)
+ let labelWidth =
+ cell.bars.map { NSAttributedString(string: $0.label, attributes: [.font: labelFont]).size().width }.max() ?? 0
+ let width = cellPadding + glyph.size.width + 4 + ceil(labelWidth) + 3 + barWidth + cellPadding
+ let bars = cell.bars
+ return NSImage(size: CGSize(width: width, height: height), flipped: true) { rect in
+ let glyphY = (rect.height - glyph.size.height) / 2
+ glyph.draw(
+ in: CGRect(x: cellPadding, y: glyphY, width: glyph.size.width, height: glyph.size.height), from: .zero,
+ operation: .sourceOver, fraction: 1, respectFlipped: true, hints: nil)
+ let rowHeight = rect.height / CGFloat(max(bars.count, 1))
+ let labelLeft = cellPadding + glyph.size.width + 4
+ for (index, bar) in bars.enumerated() {
+ let centerY = rowHeight * CGFloat(index) + rowHeight / 2
+ let label = NSAttributedString(
+ string: bar.label, attributes: [.font: labelFont, .foregroundColor: color(for: .label, dark: dark)])
+ label.draw(at: CGPoint(x: labelLeft, y: centerY - label.size().height / 2))
+ let trackRect = CGRect(
+ x: labelLeft + ceil(labelWidth) + 3, y: centerY - barHeight / 2, width: barWidth,
+ height: barHeight)
+ color(for: .label, dark: dark).withAlphaComponent(0.18).setFill()
+ NSBezierPath(roundedRect: trackRect, xRadius: barHeight / 2, yRadius: barHeight / 2).fill()
+ let filled = max(barWidth * CGFloat(min(max(bar.percent, 0), 100) / 100), 2.5)
+ color(for: .usage(bar.percent), dark: dark).setFill()
+ NSBezierPath(
+ roundedRect: CGRect(x: trackRect.minX, y: trackRect.minY, width: filled, height: barHeight),
+ xRadius: barHeight / 2, yRadius: barHeight / 2
+ ).fill()
+ }
+ return true
+ }
+ }
+
+ /// Renders the status item on a menu-bar-like strip. The docs use this rather than a screen capture, which would
+ /// carry whatever else crowds the machine's own menu bar.
+ public static func stripImage(
+ for model: StatusItemModel, height: CGFloat = 24, dark: Bool, width: CGFloat = 520
+ )
+ -> NSImage
+ {
+ let cells = previewImage(for: model, height: height, dark: dark)
+ return NSImage(size: CGSize(width: width, height: height + 4), flipped: false) { rect in
+ let background =
+ dark
+ ? NSColor(calibratedWhite: 0.13, alpha: 1)
+ : NSColor(calibratedWhite: 0.93, alpha: 1)
+ background.setFill()
+ NSBezierPath(roundedRect: rect, xRadius: 6, yRadius: 6).fill()
+ cells.draw(
+ at: CGPoint(x: rect.maxX - cells.size.width - 12, y: (rect.height - height) / 2), from: .zero,
+ operation: .sourceOver, fraction: 1)
+ return true
+ }
+ }
+
+ /// Renders at a device scale so the strip stays sharp when the website shows it a few hundred points wide.
+ public static func stripData(for model: StatusItemModel, dark: Bool, scale: Int = 3) -> Data? {
+ let image = stripImage(for: model, dark: dark)
+ let pixels = CGSize(width: image.size.width * CGFloat(scale), height: image.size.height * CGFloat(scale))
+ guard
+ let rep = NSBitmapImageRep(
+ bitmapDataPlanes: nil, pixelsWide: Int(pixels.width), pixelsHigh: Int(pixels.height), bitsPerSample: 8,
+ samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0,
+ bitsPerPixel: 0)
+ else { return nil }
+ rep.size = image.size
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
+ image.draw(in: CGRect(origin: .zero, size: image.size))
+ NSGraphicsContext.restoreGraphicsState()
+ return rep.representation(using: .png, properties: [:])
+ }
+
+ public static func previewImage(for model: StatusItemModel, height: CGFloat, dark: Bool) -> NSImage {
+ let images = model.cells.enumerated().flatMap { index, cell -> [NSImage] in
+ let image = cellImage(cell, height: height, dark: dark)
+ return index == 0 ? [image] : [separatorImage(height: height), image]
+ }
+ let icon = model.showsIcon ? [AppIcon.image(height: height, tone: model.iconTone, dark: dark)] : []
+ let all = icon + images
+ let width = max(all.map(\.size.width).reduce(0, +), 1)
+ return NSImage(size: CGSize(width: width, height: height), flipped: false) { _ in
+ var x: CGFloat = 0
+ for image in all {
+ image.draw(at: CGPoint(x: x, y: 0), from: .zero, operation: .sourceOver, fraction: 1)
+ x += image.size.width
+ }
+ return true
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Tooltip/TooltipContent.swift b/Sources/TokenMenuBarUI/Tooltip/TooltipContent.swift
new file mode 100644
index 0000000..0ede8db
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Tooltip/TooltipContent.swift
@@ -0,0 +1,41 @@
+import Foundation
+
+public enum TooltipSpan: Hashable, Sendable {
+ case code(String)
+ case text(String)
+
+ var text: String {
+ switch self {
+ case .code(let value), .text(let value): value
+ }
+ }
+}
+
+public struct TooltipContent: Hashable, Sendable {
+ public let title: String
+ public let body: [TooltipSpan]
+
+ public init(title: String, body: String) {
+ self.init(title: title, body: [.text(body)])
+ }
+
+ public init(title: String, body: [TooltipSpan]) {
+ self.title = title
+ self.body = body
+ }
+
+ public var accessibilityHint: String {
+ let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
+ let body = body.map(\.text).joined().trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !title.isEmpty else { return body }
+ guard !body.isEmpty else { return title }
+
+ if let titleRange = body.range(of: title, options: [.anchored, .caseInsensitive]),
+ titleRange.upperBound == body.endIndex || body[titleRange.upperBound].isWhitespace
+ || body[titleRange.upperBound].isPunctuation
+ {
+ return body
+ }
+ return "\(title). \(body)"
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Tooltip/TooltipPanel.swift b/Sources/TokenMenuBarUI/Tooltip/TooltipPanel.swift
new file mode 100644
index 0000000..2fdecc9
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Tooltip/TooltipPanel.swift
@@ -0,0 +1,174 @@
+import AppKit
+import TokenMenuBarCore
+
+@MainActor
+protocol TooltipPanelPresenting: AnyObject {
+ var isVisible: Bool { get }
+
+ func show(
+ content: TooltipContent,
+ anchorRect: CGRect,
+ visibleFrame: CGRect,
+ parentWindow: NSWindow,
+ reduceMotion: Bool,
+ reduceTransparency: Bool
+ )
+ func hide()
+ func tearDown()
+}
+
+@MainActor
+final class TooltipPanel: NSPanel, TooltipPanelPresenting {
+ private static let horizontalPadding: CGFloat = 10
+ private static let verticalPadding: CGFloat = 8
+
+ private weak var ownerWindow: NSWindow?
+ private let effectView = NSVisualEffectView()
+ private let label = NSTextField(labelWithString: "")
+
+ init() {
+ super.init(
+ contentRect: .zero,
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: true
+ )
+ animationBehavior = .none
+ backgroundColor = .clear
+ collectionBehavior = [.transient, .ignoresCycle]
+ hasShadow = true
+ hidesOnDeactivate = false
+ ignoresMouseEvents = true
+ isMovable = false
+ isOpaque = false
+ isReleasedWhenClosed = false
+ level = .floating
+
+ effectView.blendingMode = .behindWindow
+ effectView.material = .toolTip
+ effectView.state = .active
+ effectView.wantsLayer = true
+ effectView.layer?.cornerRadius = 8
+ effectView.layer?.masksToBounds = true
+ effectView.layer?.borderWidth = 1
+ effectView.addSubview(label)
+ effectView.setAccessibilityElement(false)
+
+ label.isEditable = false
+ label.isSelectable = false
+ label.isBezeled = false
+ label.drawsBackground = false
+ label.maximumNumberOfLines = 0
+ label.lineBreakMode = .byWordWrapping
+ label.setAccessibilityElement(false)
+ contentView = effectView
+ setAccessibilityElement(false)
+ }
+
+ override var canBecomeKey: Bool { false }
+ override var canBecomeMain: Bool { false }
+
+ func show(
+ content: TooltipContent,
+ anchorRect: CGRect,
+ visibleFrame: CGRect,
+ parentWindow: NSWindow,
+ reduceMotion: Bool,
+ reduceTransparency: Bool
+ ) {
+ appearance = parentWindow.effectiveAppearance
+ effectView.state = reduceTransparency ? .inactive : .active
+ effectView.layer?.backgroundColor =
+ reduceTransparency ? NSColor.windowBackgroundColor.cgColor : NSColor.clear.cgColor
+ effectView.layer?.borderColor = NSColor.separatorColor.cgColor
+
+ let attributed = Self.attributedString(content)
+ label.attributedStringValue = attributed
+ let maximumPanelWidth = min(
+ TooltipGeometry.maximumWidth,
+ max(1, visibleFrame.width - TooltipGeometry.screenInset * 2)
+ )
+ let maximumTextWidth = max(1, maximumPanelWidth - Self.horizontalPadding * 2)
+ let textBounds = attributed.boundingRect(
+ with: CGSize(width: maximumTextWidth, height: .greatestFiniteMagnitude),
+ options: [.usesFontLeading, .usesLineFragmentOrigin]
+ )
+ let textSize = CGSize(
+ width: min(maximumTextWidth, max(1, ceil(textBounds.width))),
+ height: max(1, ceil(textBounds.height))
+ )
+ let panelSize = CGSize(
+ width: textSize.width + Self.horizontalPadding * 2,
+ height: textSize.height + Self.verticalPadding * 2
+ )
+ label.frame = CGRect(origin: CGPoint(x: Self.horizontalPadding, y: Self.verticalPadding), size: textSize)
+ effectView.frame = CGRect(origin: .zero, size: panelSize)
+
+ let placement = TooltipGeometry.placement(
+ anchor: anchorRect,
+ tooltipSize: panelSize,
+ visibleFrame: visibleFrame
+ )
+ if parent !== parentWindow {
+ parent?.removeChildWindow(self)
+ parentWindow.addChildWindow(self, ordered: .above)
+ }
+ ownerWindow = parentWindow
+ setFrame(CGRect(origin: placement.origin, size: panelSize), display: false)
+
+ alphaValue = reduceMotion ? 1 : 0
+ orderFrontRegardless()
+ guard !reduceMotion else { return }
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = TooltipTiming.fadeDuration
+ animator().alphaValue = 1
+ }
+ }
+
+ func hide() {
+ alphaValue = 0
+ ownerWindow?.removeChildWindow(self)
+ ownerWindow = nil
+ orderOut(nil)
+ }
+
+ func tearDown() {
+ hide()
+ contentView = nil
+ }
+
+ private static func attributedString(_ content: TooltipContent) -> NSAttributedString {
+ let paragraph = NSMutableParagraphStyle()
+ paragraph.lineBreakMode = .byWordWrapping
+ paragraph.lineSpacing = 2
+ let value = NSMutableAttributedString(
+ string: content.title,
+ attributes: [
+ .font: NSFont.systemFont(ofSize: 12, weight: .semibold),
+ .foregroundColor: NSColor.labelColor,
+ .paragraphStyle: paragraph,
+ ]
+ )
+ value.append(NSAttributedString(string: "\n"))
+ for span in content.body {
+ let attributes: [NSAttributedString.Key: Any]
+ switch span {
+ case .code:
+ attributes = [
+ .backgroundColor: NSColor.quaternaryLabelColor,
+ .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular),
+ .foregroundColor: NSColor.labelColor,
+ .paragraphStyle: paragraph,
+ ]
+ case .text:
+ attributes = [
+ .font: NSFont.systemFont(ofSize: 12),
+ .foregroundColor: NSColor.labelColor,
+ .paragraphStyle: paragraph,
+ ]
+ }
+ value.append(NSAttributedString(string: span.text, attributes: attributes))
+ }
+ return value
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Tooltip/TooltipPresenter.swift b/Sources/TokenMenuBarUI/Tooltip/TooltipPresenter.swift
new file mode 100644
index 0000000..8522f11
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Tooltip/TooltipPresenter.swift
@@ -0,0 +1,445 @@
+import AppKit
+import TokenMenuBarCore
+
+@MainActor
+struct TooltipPresentationContext {
+ let anchorRect: CGRect
+ let visibleFrame: CGRect
+ let parentWindow: NSWindow
+}
+
+@MainActor
+protocol TooltipPresentationSource: AnyObject {
+ var tooltipOwner: TooltipOwner { get }
+ var tooltipContent: TooltipContent { get }
+ var tooltipPresentationContext: TooltipPresentationContext? { get }
+ var tooltipClipView: NSClipView? { get }
+}
+
+@MainActor
+private final class CursorTooltipSource: TooltipPresentationSource {
+ let tooltipOwner: TooltipOwner
+ var tooltipContent = TooltipContent(title: "", body: "")
+ let tooltipClipView: NSClipView? = nil
+ var hovering = false
+ private let context: @MainActor () -> TooltipPresentationContext?
+
+ init(owner: TooltipOwner, context: @escaping @MainActor () -> TooltipPresentationContext?) {
+ tooltipOwner = owner
+ self.context = context
+ }
+
+ var tooltipPresentationContext: TooltipPresentationContext? {
+ guard hovering else { return nil }
+ return context()
+ }
+}
+
+@MainActor
+private final class ActiveTooltipSource {
+ weak var source: (any TooltipPresentationSource)?
+ var focused = false
+ var focusSequence: UInt64 = 0
+ var hovering = false
+ var hoverSequence: UInt64 = 0
+
+ init(source: any TooltipPresentationSource) {
+ self.source = source
+ }
+}
+
+@MainActor
+public final class TooltipPresenter {
+ public static let shared = TooltipPresenter()
+
+ typealias Sleep = @Sendable (Duration) async throws -> Void
+ typealias PanelFactory = @MainActor () -> any TooltipPanelPresenting
+ typealias CursorContext = @MainActor () -> TooltipPresentationContext?
+
+ private let sleep: Sleep
+ private let panelFactory: PanelFactory
+ private let cursorContext: CursorContext
+ private var arbiter = TooltipArbiter()
+ private var presentationTask: Task?
+ private var dismissalTask: Task?
+ private var dismissalOwner: TooltipOwner?
+ private weak var source: (any TooltipPresentationSource)?
+ private var panel: (any TooltipPanelPresenting)?
+ private var activeSources: [TooltipOwner: ActiveTooltipSource] = [:]
+ private var activitySequence: UInt64 = 0
+ private var nextOwnerValue: UInt64 = 0
+ private var cursorSource: CursorTooltipSource?
+ private var eventMonitor: Any?
+ private var windowObservers: [any NSObjectProtocol] = []
+ private weak var observedWindow: NSWindow?
+ private var accessibilityObserver: (any NSObjectProtocol)?
+ private weak var clipView: NSClipView?
+ private var clipViewObserver: (any NSObjectProtocol)?
+ private var clipViewWasPostingBoundsChanges = false
+
+ init(
+ sleep: @escaping Sleep = { try await Task.sleep(for: $0) },
+ panelFactory: @escaping PanelFactory = { TooltipPanel() },
+ cursorContext: @escaping CursorContext = {
+ TooltipPresenter.currentCursorContext(point: NSEvent.mouseLocation, windows: NSApp.orderedWindows)
+ }
+ ) {
+ self.sleep = sleep
+ self.panelFactory = panelFactory
+ self.cursorContext = cursorContext
+ }
+
+ var hasPanel: Bool { panel != nil }
+ var hasPendingTask: Bool { presentationTask != nil || dismissalTask != nil }
+ var hasEventMonitor: Bool { eventMonitor != nil }
+ var visibleOwner: TooltipOwner? { arbiter.visible?.owner }
+
+ func makeOwner() -> TooltipOwner {
+ nextOwnerValue &+= 1
+ return TooltipOwner(rawValue: nextOwnerValue)
+ }
+
+ func settle() async {
+ let presentationTask = presentationTask
+ let dismissalTask = dismissalTask
+ await presentationTask?.value
+ await dismissalTask?.value
+ }
+
+ func arm(source: any TooltipPresentationSource) {
+ let active = activeSources[source.tooltipOwner]
+ update(source: source, hovering: true, focused: active?.focused ?? false)
+ }
+
+ func updateCursor(content: TooltipContent, hovering: Bool) {
+ let cursorSource = cursorSource ?? CursorTooltipSource(owner: makeOwner(), context: cursorContext)
+ self.cursorSource = cursorSource
+ cursorSource.tooltipContent = content
+ cursorSource.hovering = hovering
+ update(source: cursorSource, hovering: hovering, focused: false)
+ }
+
+ static func currentCursorContext(point: CGPoint, windows: [NSWindow]) -> TooltipPresentationContext? {
+ guard
+ let window = windows.first(where: {
+ $0.isVisible && $0.frame.contains(point) && !($0 is TooltipPanel)
+ }),
+ let screen = window.screen
+ else { return nil }
+ return TooltipPresentationContext(
+ anchorRect: CGRect(origin: point, size: CGSize(width: 1, height: 1)),
+ visibleFrame: screen.visibleFrame,
+ parentWindow: window)
+ }
+
+ func update(source: any TooltipPresentationSource, hovering: Bool, focused: Bool) {
+ let entry = activeSources[source.tooltipOwner] ?? ActiveTooltipSource(source: source)
+ let wasFocused = entry.focused
+ let wasHovering = entry.hovering
+ entry.source = source
+ if hovering, !entry.hovering {
+ activitySequence &+= 1
+ entry.hoverSequence = activitySequence
+ }
+ if focused, !entry.focused {
+ activitySequence &+= 1
+ entry.focusSequence = activitySequence
+ }
+ entry.hovering = hovering
+ entry.focused = focused
+ if hovering || focused {
+ cancelHoverDismissal()
+ activeSources[source.tooltipOwner] = entry
+ reconcile()
+ return
+ }
+ activeSources.removeValue(forKey: source.tooltipOwner)
+ if wasFocused {
+ dismiss(owner: source.tooltipOwner)
+ } else if wasHovering {
+ cancelPresentation(owner: source.tooltipOwner)
+ if selectedSource() == nil {
+ scheduleHoverDismissal(owner: source.tooltipOwner)
+ } else {
+ reconcile()
+ }
+ }
+ }
+
+ private func beginPresentation(source: any TooltipPresentationSource) {
+ guard let context = source.tooltipPresentationContext else {
+ activeSources.removeValue(forKey: source.tooltipOwner)
+ return
+ }
+ let request = arbiter.arm(owner: source.tooltipOwner)!
+ presentationTask?.cancel()
+ self.source = source
+ observe(source: source, window: context.parentWindow)
+ installEventMonitor()
+ let sleep = sleep
+ presentationTask = Task { @MainActor [weak self, weak source] in
+ do {
+ try await sleep(TooltipTiming.presentationDelay)
+ } catch {
+ guard let self, arbiter.pending == request else { return }
+ dismiss(owner: request.owner)
+ return
+ }
+ guard !Task.isCancelled, let self else { return }
+ guard let source, source.tooltipOwner == request.owner else {
+ dismiss(owner: request.owner)
+ return
+ }
+ presentationTask = nil
+ let replacesVisible = arbiter.visible != nil
+ guard arbiter.present(request) else { return }
+ show(source: source, animated: !replacesVisible)
+ }
+ }
+
+ func dismiss(owner: TooltipOwner) {
+ cancelHoverDismissal()
+ activeSources.removeValue(forKey: owner)
+ if arbiter.pending?.owner == owner {
+ presentationTask?.cancel()
+ presentationTask = nil
+ }
+ let hidesPanel = arbiter.visible?.owner == owner
+ _ = arbiter.dismiss(owner: owner)
+ if hidesPanel { panel?.hide() }
+ reconcile()
+ }
+
+ public func dismissAll() {
+ activeSources.removeAll(keepingCapacity: true)
+ arbiter.dismissAll()
+ clearPresentation()
+ }
+
+ public func tearDown() {
+ dismissAll()
+ activeSources.removeAll(keepingCapacity: false)
+ removeAccessibilityObserver()
+ panel?.tearDown()
+ panel = nil
+ cursorSource = nil
+ }
+
+ func refresh(source: any TooltipPresentationSource) {
+ guard activeSources[source.tooltipOwner] != nil else { return }
+ guard source.tooltipPresentationContext != nil else {
+ dismiss(owner: source.tooltipOwner)
+ return
+ }
+ guard arbiter.visible?.owner == source.tooltipOwner else { return }
+ self.source = source
+ show(source: source, animated: false)
+ }
+
+ private func reconcile() {
+ guard let selected = selectedSource() else {
+ arbiter.dismissAll()
+ clearPresentation()
+ return
+ }
+ self.source = selected
+ if arbiter.pending?.owner == selected.tooltipOwner { return }
+ if arbiter.visible?.owner == selected.tooltipOwner { return }
+ beginPresentation(source: selected)
+ }
+
+ private func selectedSource() -> (any TooltipPresentationSource)? {
+ var staleOwners: [TooltipOwner] = []
+ var hovered: ActiveTooltipSource?
+ var focused: ActiveTooltipSource?
+ var newestHover: UInt64 = 0
+ var newestFocus: UInt64 = 0
+ for (owner, entry) in activeSources {
+ guard let source = entry.source, source.tooltipPresentationContext != nil else {
+ staleOwners.append(owner)
+ continue
+ }
+ if entry.hovering {
+ newestHover = max(newestHover, entry.hoverSequence)
+ if hovered == nil || preferred(entry, over: hovered!, sequence: \.hoverSequence) { hovered = entry }
+ }
+ if entry.focused {
+ newestFocus = max(newestFocus, entry.focusSequence)
+ if focused == nil || preferred(entry, over: focused!, sequence: \.focusSequence) { focused = entry }
+ }
+ }
+ for owner in staleOwners { activeSources.removeValue(forKey: owner) }
+ return (newestFocus > newestHover ? focused : hovered)?.source
+ }
+
+ private func preferred(
+ _ candidate: ActiveTooltipSource,
+ over selected: ActiveTooltipSource,
+ sequence: KeyPath
+ ) -> Bool {
+ let candidateSource = candidate.source!
+ let selectedSource = selected.source!
+ if let candidateContext = candidateSource.tooltipPresentationContext,
+ let selectedContext = selectedSource.tooltipPresentationContext,
+ candidateContext.parentWindow === selectedContext.parentWindow
+ {
+ let candidateRect = candidateContext.anchorRect.standardized
+ let selectedRect = selectedContext.anchorRect.standardized
+ if selectedRect != candidateRect {
+ if selectedRect.contains(candidateRect) || candidateRect.contains(selectedRect) {
+ return candidateRect.width * candidateRect.height < selectedRect.width * selectedRect.height
+ }
+ }
+ }
+ return candidate[keyPath: sequence] > selected[keyPath: sequence]
+ }
+
+ private func show(source: any TooltipPresentationSource, animated: Bool) {
+ guard
+ let context = source.tooltipPresentationContext,
+ context.parentWindow === observedWindow
+ else {
+ dismiss(owner: source.tooltipOwner)
+ return
+ }
+ let panel = panel ?? makePanel()
+ panel.show(
+ content: source.tooltipContent,
+ anchorRect: context.anchorRect,
+ visibleFrame: context.visibleFrame,
+ parentWindow: context.parentWindow,
+ reduceMotion: !animated || NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
+ reduceTransparency: NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency
+ )
+ self.panel = panel
+ }
+
+ private func makePanel() -> any TooltipPanelPresenting {
+ let panel = panelFactory()
+ let center = NSWorkspace.shared.notificationCenter
+ accessibilityObserver = center.addObserver(
+ forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ guard let self, let source = self.source else { return }
+ self.refresh(source: source)
+ }
+ }
+ return panel
+ }
+
+ private func clearPresentation() {
+ presentationTask?.cancel()
+ presentationTask = nil
+ cancelHoverDismissal()
+ panel?.hide()
+ source = nil
+ removeEventMonitor()
+ stopObservingSource()
+ }
+
+ private func installEventMonitor() {
+ guard eventMonitor == nil else { return }
+ eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .mouseMoved, .scrollWheel]) {
+ [weak self] event in
+ if event.type == .scrollWheel || (event.type == .keyDown && event.keyCode == 53) {
+ self?.dismissAll()
+ } else if event.type == .mouseMoved {
+ self?.validateCurrentSource()
+ }
+ return event
+ }
+ }
+
+ private func removeEventMonitor() {
+ guard let eventMonitor else { return }
+ NSEvent.removeMonitor(eventMonitor)
+ self.eventMonitor = nil
+ }
+
+ private func observe(source: any TooltipPresentationSource, window: NSWindow) {
+ stopObservingSource()
+ if let clipView = source.tooltipClipView {
+ self.clipView = clipView
+ clipViewWasPostingBoundsChanges = clipView.postsBoundsChangedNotifications
+ clipView.postsBoundsChangedNotifications = true
+ clipViewObserver = NotificationCenter.default.addObserver(
+ forName: NSView.boundsDidChangeNotification,
+ object: clipView,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in self?.dismissAll() }
+ }
+ }
+ observedWindow = window
+ for name in [
+ NSWindow.willCloseNotification, NSWindow.didResignKeyNotification, NSWindow.didMiniaturizeNotification,
+ ] {
+ windowObservers.append(
+ NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
+ Task { @MainActor [weak self] in self?.dismissAll() }
+ })
+ }
+ for name in [NSWindow.didMoveNotification, NSWindow.didResizeNotification, NSWindow.didChangeScreenNotification] {
+ windowObservers.append(
+ NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
+ Task { @MainActor [weak self] in self?.dismissAll() }
+ })
+ }
+ }
+
+ private func validateCurrentSource() {
+ guard let source else { return }
+ if source.tooltipPresentationContext == nil { dismiss(owner: source.tooltipOwner) }
+ }
+
+ private func cancelPresentation(owner: TooltipOwner) {
+ guard arbiter.pending?.owner == owner else { return }
+ presentationTask?.cancel()
+ presentationTask = nil
+ _ = arbiter.dismiss(owner: owner)
+ }
+
+ private func scheduleHoverDismissal(owner: TooltipOwner) {
+ cancelHoverDismissal()
+ dismissalOwner = owner
+ let sleep = sleep
+ dismissalTask = Task { @MainActor [weak self] in
+ do {
+ try await sleep(TooltipTiming.dismissalDelay)
+ } catch {
+ return
+ }
+ guard !Task.isCancelled, let self, dismissalOwner == owner else { return }
+ dismissalTask = nil
+ dismissalOwner = nil
+ arbiter.dismissAll()
+ clearPresentation()
+ }
+ }
+
+ private func cancelHoverDismissal() {
+ dismissalTask?.cancel()
+ dismissalTask = nil
+ dismissalOwner = nil
+ }
+
+ private func stopObservingSource() {
+ if let clipViewObserver { NotificationCenter.default.removeObserver(clipViewObserver) }
+ clipViewObserver = nil
+ if let clipView, !clipViewWasPostingBoundsChanges { clipView.postsBoundsChangedNotifications = false }
+ clipView = nil
+ clipViewWasPostingBoundsChanges = false
+ for observer in windowObservers { NotificationCenter.default.removeObserver(observer) }
+ windowObservers.removeAll()
+ observedWindow = nil
+ }
+
+ private func removeAccessibilityObserver() {
+ guard let accessibilityObserver else { return }
+ NSWorkspace.shared.notificationCenter.removeObserver(accessibilityObserver)
+ self.accessibilityObserver = nil
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Tooltip/TooltipTrackingView.swift b/Sources/TokenMenuBarUI/Tooltip/TooltipTrackingView.swift
new file mode 100644
index 0000000..49f7caa
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Tooltip/TooltipTrackingView.swift
@@ -0,0 +1,155 @@
+import AppKit
+import SwiftUI
+import TokenMenuBarCore
+
+@MainActor
+final class TooltipTrackingView: NSView, TooltipPresentationSource {
+ let tooltipOwner: TooltipOwner
+ private weak var presenter: TooltipPresenter?
+ private var focused = false
+ private var hovering = false
+ private var tracking: NSTrackingArea?
+ var tooltipContent: TooltipContent
+
+ init(content: TooltipContent, presenter: TooltipPresenter, tracksHover: Bool = true) {
+ tooltipOwner = presenter.makeOwner()
+ tooltipContent = content
+ self.presenter = presenter
+ super.init(frame: .zero)
+ if tracksHover {
+ let tracking = NSTrackingArea(
+ rect: .zero,
+ options: [.activeInActiveApp, .inVisibleRect, .mouseEnteredAndExited],
+ owner: self,
+ userInfo: nil
+ )
+ addTrackingArea(tracking)
+ self.tracking = tracking
+ }
+ setAccessibilityElement(false)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is unavailable")
+ }
+
+ override var acceptsFirstResponder: Bool { false }
+
+ var tooltipPresentationContext: TooltipPresentationContext? {
+ guard
+ let window,
+ window.isVisible,
+ !isHiddenOrHasHiddenAncestor,
+ let screen = window.screen
+ else { return nil }
+ let visibleBounds = visibleRect.intersection(bounds)
+ guard !visibleBounds.isNull, !visibleBounds.isEmpty else { return nil }
+
+ let anchorRect: CGRect
+ if hovering {
+ let mouseInWindow = window.mouseLocationOutsideOfEventStream
+ guard visibleBounds.contains(convert(mouseInWindow, from: nil)) else { return nil }
+ let mouseOnScreen = window.convertToScreen(CGRect(origin: mouseInWindow, size: .zero)).origin
+ anchorRect = CGRect(origin: mouseOnScreen, size: CGSize(width: 1, height: 1))
+ } else if focused {
+ anchorRect = window.convertToScreen(convert(visibleBounds, to: nil))
+ } else {
+ return nil
+ }
+ let clippedAnchor = anchorRect.intersection(screen.visibleFrame)
+ guard !clippedAnchor.isNull, !clippedAnchor.isEmpty else { return nil }
+ return TooltipPresentationContext(
+ anchorRect: clippedAnchor,
+ visibleFrame: screen.visibleFrame,
+ parentWindow: window
+ )
+ }
+
+ var tooltipClipView: NSClipView? {
+ enclosingScrollView?.contentView
+ }
+
+ override func mouseEntered(with event: NSEvent) {
+ hovering = true
+ presenter?.update(source: self, hovering: hovering, focused: focused)
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ hovering = false
+ presenter?.update(source: self, hovering: hovering, focused: focused)
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ if window == nil {
+ hovering = false
+ presenter?.dismiss(owner: tooltipOwner)
+ } else {
+ presenter?.refresh(source: self)
+ }
+ }
+
+ override func viewDidMoveToSuperview() {
+ super.viewDidMoveToSuperview()
+ presenter?.refresh(source: self)
+ }
+
+ override func viewDidHide() {
+ super.viewDidHide()
+ hovering = false
+ presenter?.dismiss(owner: tooltipOwner)
+ }
+
+ override func setFrameOrigin(_ newOrigin: NSPoint) {
+ let changed = frame.origin != newOrigin
+ super.setFrameOrigin(newOrigin)
+ if changed { presenter?.refresh(source: self) }
+ }
+
+ override func setFrameSize(_ newSize: NSSize) {
+ let changed = frame.size != newSize
+ super.setFrameSize(newSize)
+ if changed { presenter?.refresh(source: self) }
+ }
+
+ func update(content: TooltipContent, focused: Bool) {
+ let contentChanged = tooltipContent != content
+ let focusChanged = focused != self.focused
+ tooltipContent = content
+ self.focused = focused
+ if focusChanged {
+ presenter?.update(source: self, hovering: hovering, focused: focused)
+ } else if contentChanged, focused || hovering {
+ presenter?.refresh(source: self)
+ }
+ }
+
+ func dismantle() {
+ presenter?.dismiss(owner: tooltipOwner)
+ focused = false
+ hovering = false
+ presenter = nil
+ if let tracking { removeTrackingArea(tracking) }
+ tracking = nil
+ }
+}
+
+struct TooltipAnchor: NSViewRepresentable {
+ let content: TooltipContent
+ let focused: Bool
+ let presenter: TooltipPresenter
+ let tracksHover: Bool
+
+ func makeNSView(context: Context) -> TooltipTrackingView {
+ TooltipTrackingView(content: content, presenter: presenter, tracksHover: tracksHover)
+ }
+
+ func updateNSView(_ view: TooltipTrackingView, context: Context) {
+ view.update(content: content, focused: focused)
+ }
+
+ static func dismantleNSView(_ view: TooltipTrackingView, coordinator: Void) {
+ view.dismantle()
+ }
+}
diff --git a/Sources/TokenMenuBarUI/UIActions.swift b/Sources/TokenMenuBarUI/UIActions.swift
new file mode 100644
index 0000000..f05cf26
--- /dev/null
+++ b/Sources/TokenMenuBarUI/UIActions.swift
@@ -0,0 +1,336 @@
+import Foundation
+import TokenMenuBarCore
+
+@MainActor
+public struct UIActions {
+ public var refresh: () -> Void
+ public var refreshProvider: (ProviderID) -> Void
+ public var showProviders: (ProviderID?) -> Void
+ public var openURL: (URL) -> Void
+ public var copy: (String) -> Void
+ public var exportHistory: () -> Void
+ public var clearHistory: () -> Void
+ public var revealHistory: () -> Void
+ public var copyDiagnostics: () -> Void
+ public var reportIssue: () -> Void
+ public var showFullLog: () -> Void
+ public var setLaunchAtLogin: (Bool) -> Void
+ public var openLoginItems: () -> Void
+ public var grantAccess: (SandboxResource) -> Void
+ public var checkForUpdates: () -> Void
+ public var quit: () -> Void
+ public var setDemoMode: (Bool) -> Void
+ public var settingsChanged: () -> Void
+ public var settingsReset: () -> Void
+
+ public init(
+ refresh: @escaping () -> Void = {},
+ refreshProvider: @escaping (ProviderID) -> Void = { _ in },
+ showProviders: @escaping (ProviderID?) -> Void = { _ in },
+ openURL: @escaping (URL) -> Void = { _ in },
+ copy: @escaping (String) -> Void = { _ in },
+ exportHistory: @escaping () -> Void = {},
+ clearHistory: @escaping () -> Void = {},
+ revealHistory: @escaping () -> Void = {},
+ copyDiagnostics: @escaping () -> Void = {},
+ reportIssue: @escaping () -> Void = {},
+ showFullLog: @escaping () -> Void = {},
+ setLaunchAtLogin: @escaping (Bool) -> Void = { _ in },
+ openLoginItems: @escaping () -> Void = {},
+ grantAccess: @escaping (SandboxResource) -> Void = { _ in },
+ checkForUpdates: @escaping () -> Void = {},
+ quit: @escaping () -> Void = {},
+ setDemoMode: @escaping (Bool) -> Void = { _ in },
+ settingsChanged: @escaping () -> Void = {},
+ settingsReset: @escaping () -> Void = {}
+ ) {
+ self.refresh = refresh
+ self.refreshProvider = refreshProvider
+ self.showProviders = showProviders
+ self.openURL = openURL
+ self.copy = copy
+ self.exportHistory = exportHistory
+ self.clearHistory = clearHistory
+ self.revealHistory = revealHistory
+ self.copyDiagnostics = copyDiagnostics
+ self.reportIssue = reportIssue
+ self.showFullLog = showFullLog
+ self.setLaunchAtLogin = setLaunchAtLogin
+ self.openLoginItems = openLoginItems
+ self.grantAccess = grantAccess
+ self.checkForUpdates = checkForUpdates
+ self.quit = quit
+ self.setDemoMode = setDemoMode
+ self.settingsChanged = settingsChanged
+ self.settingsReset = settingsReset
+ }
+}
+
+@MainActor
+@Observable
+public final class UIEnvironment {
+ public let state: AppState
+ public let settings: Settings
+ public let history: UsageHistoryStore
+ public let historyPresenter: HistoryPresenter
+ public let log: LogBuffer
+ public let appInfo: AppInfo
+ public let clock: Clock
+ public var actions: UIActions
+ public var launchAtLoginStatus: LaunchAtLoginBackend.Status
+ public var credentialDescriptions: [ProviderID: String]
+ public var canCheckForUpdates: Bool
+ public var isSandboxed: Bool
+ public var isDemo: Bool
+ public var providerFocusRequest: ProviderSettingsFocusRequest?
+ public var samples: [WindowKey: [UsageSample]] = [:]
+ public var now: Date
+ public private(set) var usagePresentation: UsagePresentation
+ public private(set) var usageDeadlineNow: Date
+
+ private var usageAnalyticsCache: [ProviderID: UsageAnalyticsCacheEntry]
+ private var usagePresentationDirty = false
+ private var recentSamplesLoad: RecentSamplesLoad?
+ private var settingsActivityCache: SettingsActivityCacheEntry?
+ private var settingsActivityLoad: SettingsActivityLoad?
+ @ObservationIgnored private var tabTransition: (tab: PopoverTab, startedAt: TimeInterval)?
+
+ public init(
+ state: AppState,
+ settings: Settings,
+ history: UsageHistoryStore,
+ log: LogBuffer,
+ appInfo: AppInfo,
+ clock: Clock = .system,
+ actions: UIActions = UIActions(),
+ launchAtLoginStatus: LaunchAtLoginBackend.Status = .unknown,
+ credentialDescriptions: [ProviderID: String] = [:],
+ canCheckForUpdates: Bool = false,
+ isSandboxed: Bool = false,
+ isDemo: Bool = false
+ ) {
+ self.state = state
+ self.settings = settings
+ self.history = history
+ self.log = log
+ self.appInfo = appInfo
+ self.clock = clock
+ self.actions = actions
+ self.launchAtLoginStatus = launchAtLoginStatus
+ self.credentialDescriptions = credentialDescriptions
+ self.canCheckForUpdates = canCheckForUpdates
+ self.isSandboxed = isSandboxed
+ self.isDemo = isDemo
+ providerFocusRequest = nil
+ historyPresenter = HistoryPresenter(
+ history: history,
+ settings: settings,
+ clock: clock,
+ initialMetric: HistoryMetric(storageID: settings.historyMetricID) ?? .windowUsagePercent,
+ persistMetric: { settings.historyMetricID = $0.storageID })
+ let now = clock.now()
+ self.now = now
+ usageDeadlineNow = now
+ settingsActivityCache = nil
+ settingsActivityLoad = nil
+ let analyticsCache = Self.analyticsCache(state.providers, now: now)
+ usageAnalyticsCache = analyticsCache
+ let activeProviders = settings.activeProviders(states: state.providers)
+ usagePresentation = UsagePresenter.presentation(
+ state: state.providers, enabled: activeProviders,
+ selected: Self.selectedWindows(state, settings),
+ samples: [:], analytics: analyticsCache.mapValues(\.presentation), lastRefresh: state.lastRefresh,
+ iconTone: UsagePresenter.iconTone(state.providers.filter { activeProviders.contains($0.key) }),
+ isRefreshing: state.isRefreshing, now: now)
+ observeUsageInputs()
+ }
+
+ public func tick() {
+ let now = clock.now()
+ self.now = now
+ advanceUsageDeadlines(to: now)
+ }
+
+ public func beginTabTransition(to tab: PopoverTab) {
+ tabTransition = (tab, ProcessInfo.processInfo.systemUptime)
+ }
+
+ public func completeTabTransition(to tab: PopoverTab) {
+ guard let transition = tabTransition, transition.tab == tab else { return }
+ tabTransition = nil
+ log.detailed(
+ .tab(
+ TabDiagnostic(
+ action: .presented,
+ to: tab.rawValue,
+ activeTab: tab.rawValue,
+ durationMilliseconds: max(
+ (ProcessInfo.processInfo.systemUptime - transition.startedAt) * 1_000,
+ 0))))
+ }
+
+ public func refreshUsagePresentation(at date: Date? = nil) {
+ let now = date ?? clock.now()
+ self.now = now
+ usageDeadlineNow = now
+ updateAnalyticsCache(now: now)
+ let activeProviders = settings.activeProviders(states: state.providers)
+ usagePresentation = UsagePresenter.presentation(
+ state: state.providers, enabled: activeProviders,
+ selected: Self.selectedWindows(state, settings),
+ samples: samples, analytics: usageAnalyticsCache.mapValues(\.presentation), lastRefresh: state.lastRefresh,
+ iconTone: UsagePresenter.iconTone(state.providers.filter { activeProviders.contains($0.key) }),
+ isRefreshing: state.isRefreshing, now: now)
+ usagePresentationDirty = false
+ }
+
+ public func nextUsageDeadline(after date: Date? = nil) -> Date? {
+ usagePresentation.nextDeadline(after: date ?? usageDeadlineNow)
+ }
+
+ @discardableResult
+ public func advanceUsageDeadlines(to date: Date? = nil) -> Bool {
+ let date = date ?? clock.now()
+ guard let deadline = nextUsageDeadline(), date >= deadline else { return false }
+ usageDeadlineNow = date
+ return true
+ }
+
+ public func prepareUsage() async {
+ if usagePresentationDirty { refreshUsagePresentation() }
+ await loadRecentSamples()
+ }
+
+ public func loadRecentSamples(force: Bool = false) async {
+ // Only the popover's cards read these, and one query covers every window: a loop was an actor hop and a
+ // prepared statement each.
+ guard force || (state.popoverVisible && settings.lastTab == .usage) else { return }
+ if let recentSamplesLoad {
+ _ = await recentSamplesLoad.task.value
+ return
+ }
+ let since = now.addingTimeInterval(-PaceEstimate.slopeWindow)
+ var keys: [WindowKey] = []
+ for (provider, item) in state.providers {
+ guard let windows = item.snapshot?.windows else { continue }
+ for window in windows { keys.append(WindowKey(provider, window)) }
+ }
+ let task = Task { [history] in
+ (try? await history.samples(keys: keys, from: since, to: .distantFuture)) ?? []
+ }
+ recentSamplesLoad = RecentSamplesLoad(task: task)
+ let rows = await task.value
+ recentSamplesLoad = nil
+ var loaded = Dictionary(grouping: rows, by: \.key)
+ for key in keys where loaded[key] == nil { loaded[key] = [] }
+ samples = loaded
+ if force || (state.popoverVisible && settings.lastTab == .usage) {
+ refreshUsagePresentation()
+ } else {
+ usagePresentationDirty = true
+ }
+ }
+
+ func settingsActivity(for request: SettingsActivityRequest) async -> [WindowKey: Date] {
+ if let cache = settingsActivityCache, cache.request == request { return cache.dates }
+ if let load = settingsActivityLoad, load.request == request { return await load.task.value ?? [:] }
+ settingsActivityLoad?.task.cancel()
+ let id = UUID()
+ let end = clock.now()
+ let start = end.addingTimeInterval(-TimeInterval(request.retentionDays) * 86400)
+ let task = Task { [history] in
+ try? await history.lastUsageDates(keys: request.keys, from: start, to: end)
+ }
+ settingsActivityLoad = SettingsActivityLoad(id: id, request: request, task: task)
+ let dates = await task.value
+ let resolvedDates: [WindowKey: Date] = if let dates { dates } else { [:] }
+ guard settingsActivityLoad?.id == id else { return resolvedDates }
+ settingsActivityLoad = nil
+ if let dates { settingsActivityCache = SettingsActivityCacheEntry(request: request, dates: dates) }
+ return resolvedDates
+ }
+
+ public var cards: [ProviderCard] {
+ usagePresentation.cards
+ }
+
+ private func observeUsageInputs() {
+ withObservationTracking {
+ _ = state.providers
+ _ = state.lastRefresh
+ _ = state.isRefreshing
+ _ = settings.enabledProviders
+ _ = settings.configuredProviders
+ _ = settings.selectedWindows
+ _ = settings.hasCustomSelection
+ } onChange: { [weak self] in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ if self.state.popoverVisible, self.settings.lastTab == .usage {
+ self.refreshUsagePresentation()
+ } else {
+ self.usagePresentationDirty = true
+ }
+ self.observeUsageInputs()
+ }
+ }
+ }
+
+ private func updateAnalyticsCache(now: Date) {
+ let day = DayStamp.string(now)
+ let providers = Set(state.providers.keys)
+ usageAnalyticsCache = usageAnalyticsCache.filter { providers.contains($0.key) }
+ for (provider, providerState) in state.providers {
+ guard let analytics = providerState.analytics else {
+ usageAnalyticsCache[provider] = nil
+ continue
+ }
+ let key = UsageAnalyticsCacheKey(fetchedAt: analytics.fetchedAt, day: day)
+ guard usageAnalyticsCache[provider]?.key != key else { continue }
+ usageAnalyticsCache[provider] = UsageAnalyticsCacheEntry(
+ key: key, presentation: UsagePresenter.analyticsPresentation(analytics, now: now))
+ }
+ }
+
+ private static func analyticsCache(
+ _ state: [ProviderID: ProviderState], now: Date
+ ) -> [ProviderID: UsageAnalyticsCacheEntry] {
+ let day = DayStamp.string(now)
+ return state.compactMapValues { providerState in
+ providerState.analytics.map {
+ UsageAnalyticsCacheEntry(
+ key: UsageAnalyticsCacheKey(fetchedAt: $0.fetchedAt, day: day),
+ presentation: UsagePresenter.analyticsPresentation($0, now: now))
+ }
+ }
+ }
+
+ private static func selectedWindows(_ state: AppState, _ settings: Settings) -> Set {
+ Set(settings.hasCustomSelection ? settings.selectedWindows : StatusItemBuilder.defaultSelection(state.snapshots))
+ }
+}
+
+private struct UsageAnalyticsCacheKey: Equatable {
+ let fetchedAt: Date
+ let day: String
+}
+
+private struct UsageAnalyticsCacheEntry {
+ let key: UsageAnalyticsCacheKey
+ let presentation: UsageAnalyticsPresentation
+}
+
+private struct RecentSamplesLoad {
+ let task: Task<[UsageSample], Never>
+}
+
+private struct SettingsActivityCacheEntry {
+ let request: SettingsActivityRequest
+ let dates: [WindowKey: Date]
+}
+
+private struct SettingsActivityLoad {
+ let id: UUID
+ let request: SettingsActivityRequest
+ let task: Task<[WindowKey: Date]?, Never>
+}
diff --git a/Sources/TokenMenuBarUI/Views/ChartOverlay.swift b/Sources/TokenMenuBarUI/Views/ChartOverlay.swift
new file mode 100644
index 0000000..31d242c
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Views/ChartOverlay.swift
@@ -0,0 +1,84 @@
+import Charts
+import SwiftUI
+import TokenMenuBarCore
+
+struct ChartOverlay: View {
+ let chart: UsageChart
+ let proxy: ChartProxy
+
+ var body: some View {
+ GeometryReader { geometry in
+ if let plotFrame = proxy.plotFrame {
+ let plot = geometry[plotFrame]
+ let drag = DragGesture(minimumDistance: 0)
+ ZStack(alignment: .topLeading) {
+ if let selected = chart.presenter.selectedDate,
+ let x = proxy.position(forX: selected)
+ {
+ Path { path in
+ path.move(to: CGPoint(x: plot.minX + x, y: plot.minY))
+ path.addLine(to: CGPoint(x: plot.minX + x, y: plot.maxY))
+ }
+ .stroke(.secondary, style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
+ .accessibilityHidden(true)
+ ForEach(chart.selectionPoints(at: selected)) { selection in
+ if let y = proxy.position(forY: selection.point.value) {
+ HistorySelectionPoint(variant: selection.series.style.variant)
+ .fill(UsageChart.color(index: selection.series.style.hueIndex))
+ .frame(width: 8, height: 8)
+ .position(x: plot.minX + x, y: plot.minY + y)
+ .accessibilityHidden(true)
+ }
+ }
+ }
+ Rectangle().fill(Color.clear).contentShape(Rectangle())
+ .gesture(
+ drag.onChanged(
+ ChartDragAction(chart: chart, plot: plot, location: \DragGesture.Value.location).callAsFunction)
+ )
+ .onContinuousHover(perform: ChartHoverAction(chart: chart, plot: plot).callAsFunction)
+ }
+ }
+ }
+ }
+}
+
+@MainActor struct ChartDragAction {
+ let chart: UsageChart
+ let plot: CGRect
+ let location: KeyPath
+
+ func callAsFunction(_ value: Value) {
+ chart.pick(value[keyPath: location], in: plot)
+ }
+}
+
+@MainActor struct ChartHoverAction {
+ let chart: UsageChart
+ let plot: CGRect
+
+ func callAsFunction(_ phase: HoverPhase) {
+ chart.hover(phase, in: plot)
+ }
+}
+
+private struct HistorySelectionPoint: Shape {
+ let variant: Int
+
+ func path(in rect: CGRect) -> Path {
+ switch variant % 3 {
+ case 1:
+ Path(rect)
+ case 2:
+ Path { path in
+ path.move(to: CGPoint(x: rect.midX, y: rect.minY))
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
+ path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
+ path.addLine(to: CGPoint(x: rect.minX, y: rect.midY))
+ path.closeSubpath()
+ }
+ default:
+ Path(ellipseIn: rect)
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Views/Components.swift b/Sources/TokenMenuBarUI/Views/Components.swift
new file mode 100644
index 0000000..a84d2a2
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Views/Components.swift
@@ -0,0 +1,534 @@
+import AppKit
+import SwiftUI
+import TokenMenuBarCore
+
+extension Color {
+ public init(_ hsb: HSBColor) {
+ self.init(hue: hsb.hue, saturation: hsb.saturation, brightness: hsb.brightness)
+ }
+}
+
+public struct UsageBar: View {
+ public let percent: Double
+ public let expectedPercent: Double?
+ public let color: Color
+ public let height: CGFloat
+ public let label: String
+
+ public init(
+ percent: Double, expectedPercent: Double? = nil, color: Color, height: CGFloat = 5, label: String
+ ) {
+ self.percent = percent
+ self.expectedPercent = expectedPercent
+ self.color = color
+ self.height = height
+ self.label = label
+ }
+
+ public var body: some View {
+ GeometryReader { proxy in
+ ZStack(alignment: .leading) {
+ Capsule().fill(Color.primary.opacity(0.06))
+ let fraction = min(max(percent, 0), 100) / 100
+ if fraction > 0 {
+ Capsule()
+ .fill(color.gradient)
+ .frame(width: max(proxy.size.width * fraction, height))
+ }
+ if let expectedPercent {
+ let expected = min(max(expectedPercent, 0), 100) / 100
+ RoundedRectangle(cornerRadius: 1)
+ .fill(Color.primary)
+ .frame(width: 2, height: height + 4)
+ .offset(x: max(min(proxy.size.width * expected - 1, proxy.size.width - 2), 0))
+ }
+ }
+ }
+ .frame(height: height)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(label)
+ .accessibilityValue(
+ expectedPercent.map { "\(Format.percent(percent)) used, expected \(Format.percent($0))" }
+ ?? "\(Format.percent(percent)) used")
+ }
+}
+
+public struct Banner: View {
+ public enum Tone {
+ case warning
+ case info
+ }
+
+ public let text: String
+ public let tone: Tone
+
+ public init(_ text: String, tone: Tone = .warning) {
+ self.text = text
+ self.tone = tone
+ }
+
+ public var body: some View {
+ HStack(alignment: .top, spacing: 6) {
+ Image(systemName: tone == .warning ? "exclamationmark.triangle.fill" : "info.circle.fill")
+ .semanticForeground(tone == .warning ? .warning : .secondary)
+ .accessibilityHidden(true)
+ LinkifiedText(text)
+ .font(.body)
+ .textSelection(.enabled)
+ Spacer(minLength: 0)
+ }
+ .padding(8)
+ .background(
+ Color(tone == .warning ? .warning : .secondary).opacity(0.12), in: RoundedRectangle(cornerRadius: 8)
+ )
+ .accessibilityElement(children: .combine)
+ // The tint is the only thing that separates a warning from a note, so the label says which one this is.
+ .accessibilityLabel("\(tone == .warning ? "Warning" : "Note"): \(text)")
+ }
+}
+
+public struct LinkifiedText: View {
+ public let text: String
+
+ public init(_ text: String) {
+ self.text = text
+ }
+
+ public var body: some View {
+ Text(Self.attributed(text))
+ }
+
+ public static func attributed(_ text: String) -> AttributedString {
+ var result = AttributedString(text)
+ // Compiling a Regex costs more than scanning the string, and every banner does this on every body evaluation.
+ for span in links(in: text) {
+ if let range = Range(span, in: result), let url = URL(string: String(text[span])) {
+ result[range].link = url
+ result[range].underlineStyle = .single
+ }
+ }
+ return result
+ }
+
+ /// The `http://` and `https://` runs in `text`, each ending at the first space or closing bracket.
+ static func links(in text: String) -> [Range] {
+ var spans: [Range] = []
+ var cursor = text.startIndex
+ while let scheme = text.range(of: "http", range: cursor..: View {
+ public let intent: ControlIntent
+ public let action: () -> Void
+ public let label: Label
+
+ public init(
+ intent: ControlIntent = .action, action: @escaping () -> Void, @ViewBuilder label: () -> Label
+ ) {
+ self.intent = intent
+ self.action = action
+ self.label = label()
+ }
+
+ public var body: some View {
+ Button(role: role, action: action) { label }
+ .buttonStyle(.bordered)
+ .semanticControl(intent)
+ }
+
+ var role: ButtonRole? {
+ switch intent {
+ case .destructive: .destructive
+ default: nil
+ }
+ }
+}
+
+public struct NativeSegmentedControl: NSViewRepresentable {
+ @Binding private var selection: Value
+ private let values: [Value]
+ private let labels: [String]
+ private let accessibilityLabel: String
+ private let accessibilityIdentifier: String?
+
+ public init(
+ _ items: [(value: Value, label: String)],
+ selection: Binding,
+ accessibilityLabel: String,
+ accessibilityIdentifier: String? = nil
+ ) {
+ values = items.map(\.value)
+ labels = items.map(\.label)
+ _selection = selection
+ self.accessibilityLabel = accessibilityLabel
+ self.accessibilityIdentifier = accessibilityIdentifier
+ }
+
+ public func makeNSView(context: Context) -> NSSegmentedControl {
+ let control = NSSegmentedControl(
+ labels: labels,
+ trackingMode: .selectOne,
+ target: context.coordinator,
+ action: #selector(Coordinator.changed(_:)))
+ control.controlSize = .small
+ control.segmentStyle = .automatic
+ control.setAccessibilityLabel(accessibilityLabel)
+ if let accessibilityIdentifier { control.setAccessibilityIdentifier(accessibilityIdentifier) }
+ return control
+ }
+
+ public func updateNSView(_ control: NSSegmentedControl, context: Context) {
+ let selectedSegment = values.firstIndex(of: selection) ?? -1
+ if control.selectedSegment != selectedSegment { control.selectedSegment = selectedSegment }
+ if control.isEnabled != context.environment.isEnabled { control.isEnabled = context.environment.isEnabled }
+ context.coordinator.selection = $selection
+ context.coordinator.values = values
+ }
+
+ public func makeCoordinator() -> Coordinator {
+ Coordinator(selection: $selection, values: values)
+ }
+
+ @MainActor
+ public final class Coordinator: NSObject {
+ var selection: Binding
+ var values: [Value]
+
+ init(selection: Binding, values: [Value]) {
+ self.selection = selection
+ self.values = values
+ }
+
+ @objc func changed(_ sender: NSSegmentedControl) {
+ guard values.indices.contains(sender.selectedSegment) else { return }
+ selection.wrappedValue = values[sender.selectedSegment]
+ }
+ }
+}
+
+extension NativeActionButton where Label == Text {
+ public init(_ title: String, intent: ControlIntent = .action, action: @escaping () -> Void) {
+ self.init(intent: intent, action: action) { Text(title) }
+ }
+}
+
+public struct NativeIconButton: View {
+ public let symbol: String
+ public let accessibilityLabel: String
+ public let explanation: String
+ public let intent: ControlIntent
+ public let action: () -> Void
+
+ public init(
+ symbol: String,
+ accessibilityLabel: String,
+ explanation: String? = nil,
+ intent: ControlIntent = .action,
+ action: @escaping () -> Void
+ ) {
+ self.symbol = symbol
+ self.accessibilityLabel = accessibilityLabel
+ self.explanation = explanation ?? accessibilityLabel
+ self.intent = intent
+ self.action = action
+ }
+
+ public init(symbol: String, help: String, intent: ControlIntent = .action, action: @escaping () -> Void) {
+ self.init(symbol: symbol, accessibilityLabel: help, explanation: help, intent: intent, action: action)
+ }
+
+ public var body: some View {
+ NativeActionButton(intent: intent, action: action) {
+ Label(accessibilityLabel, systemImage: symbol).labelStyle(.iconOnly)
+ }
+ .buttonBorderShape(.circle)
+ .richHelp(TooltipContent(title: accessibilityLabel, body: explanation))
+ .accessibilityLabel(accessibilityLabel)
+ }
+}
+
+public typealias IconButton = NativeIconButton
+
+public struct SectionLabel: View {
+ public let title: String
+
+ public init(_ title: String) {
+ self.title = title
+ }
+
+ public var body: some View {
+ Text(title)
+ .textCase(.uppercase)
+ .font(.caption2.weight(.semibold))
+ .tracking(0.6)
+ .semanticForeground(.secondary)
+ .accessibilityAddTraits(.isHeader)
+ }
+}
+
+public struct PanelSection: View {
+ public let title: String
+ public let content: Content
+
+ public init(_ title: String, @ViewBuilder content: () -> Content) {
+ self.title = title
+ self.content = content()
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ SectionLabel(title)
+ content
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+public struct ResponsivePanelLayout: View {
+ public let wide: Wide
+ public let narrow: Narrow
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
+
+ public init(@ViewBuilder wide: () -> Wide, @ViewBuilder narrow: () -> Narrow) {
+ self.wide = wide()
+ self.narrow = narrow()
+ }
+
+ public var body: some View {
+ if dynamicTypeSize.isAccessibilitySize {
+ narrow
+ } else {
+ ViewThatFits(in: .horizontal) {
+ wide
+ narrow
+ }
+ }
+ }
+}
+
+public struct PanelRow: View {
+ public let title: String
+ public let labelWidth: CGFloat
+ public let content: Content
+
+ public init(_ title: String, labelWidth: CGFloat = 116, @ViewBuilder content: () -> Content) {
+ self.title = title
+ self.labelWidth = labelWidth
+ self.content = content()
+ }
+
+ public var body: some View {
+ ResponsivePanelLayout {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ label.frame(width: labelWidth, alignment: .leading)
+ content.frame(maxWidth: .infinity, alignment: .leading)
+ }
+ } narrow: {
+ VStack(alignment: .leading, spacing: 5) {
+ if !title.isEmpty { label }
+ content.frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+
+ private var label: some View {
+ Text(title).semanticForeground(.secondary)
+ }
+}
+
+public struct ChipView: View {
+ public let chip: Chip
+ public let onCopy: (String) -> Void
+
+ public init(chip: Chip, onCopy: @escaping (String) -> Void) {
+ self.chip = chip
+ self.onCopy = onCopy
+ }
+
+ public var body: some View {
+ ControlGroup {
+ Button(chip.text, action: primaryAction)
+ .accessibilityHint("Copies this value")
+ Button(action: copyAction) { Label("Copy \(chip.text)", systemImage: "doc.on.doc").labelStyle(.iconOnly) }
+ .richHelp(TooltipContent(title: "Copy", body: "Copies this value to the clipboard."))
+ .accessibilityLabel("Copy \(chip.text)")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .semanticControl(.action)
+ .contextMenu {
+ Button("Copy", systemImage: "doc.on.doc", action: copyAction)
+ }
+ }
+
+ public func primaryAction() {
+ onCopy(chip.text)
+ }
+
+ public func copyAction() {
+ onCopy(chip.text)
+ }
+}
+
+public struct WrappingHStack: Layout {
+ public let horizontalSpacing: CGFloat
+ public let verticalSpacing: CGFloat
+
+ public init(horizontalSpacing: CGFloat = 6, verticalSpacing: CGFloat = 6) {
+ self.horizontalSpacing = horizontalSpacing
+ self.verticalSpacing = verticalSpacing
+ }
+
+ public func makeCache(subviews: Subviews) -> Cache {
+ Cache()
+ }
+
+ public func updateCache(_ cache: inout Cache, subviews: Subviews) {
+ cache = Cache()
+ }
+
+ public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
+ let rows = layoutRows(width: proposal.width ?? .infinity, subviews: subviews, cache: &cache)
+ let height = rows.map { $0.height }.reduce(0, +) + CGFloat(max(rows.count - 1, 0)) * verticalSpacing
+ let width = rows.map { $0.width }.max() ?? 0
+ return CGSize(width: proposal.width ?? width, height: height)
+ }
+
+ public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
+ var rowTop = bounds.minY
+ for row in layoutRows(width: bounds.width, subviews: subviews, cache: &cache) {
+ var itemLeft = bounds.minX
+ for item in row.items {
+ subviews[item.index].place(
+ at: CGPoint(x: itemLeft, y: rowTop + (row.height - item.size.height) / 2), proposal: .unspecified)
+ itemLeft += item.size.width + horizontalSpacing
+ }
+ rowTop += row.height + verticalSpacing
+ }
+ }
+
+ public struct Cache {
+ var width: CGFloat?
+ var rows: [Row] = []
+ }
+
+ struct Row {
+ var items: [(index: Int, size: CGSize)] = []
+ var width: CGFloat = 0
+ var height: CGFloat = 0
+ }
+
+ func layoutRows(width: CGFloat, subviews: Subviews, cache: inout Cache) -> [Row] {
+ if cache.width == width { return cache.rows }
+ var rows: [Row] = []
+ var current = Row()
+ for (index, subview) in subviews.enumerated() {
+ let size = subview.sizeThatFits(.unspecified)
+ let spacing = current.items.isEmpty ? 0 : horizontalSpacing
+ if !current.items.isEmpty, current.width + spacing + size.width > width {
+ rows.append(current)
+ current = Row()
+ }
+ current.items.append((index, size))
+ current.width += (current.items.count == 1 ? 0 : horizontalSpacing) + size.width
+ current.height = max(current.height, size.height)
+ }
+ if !current.items.isEmpty { rows.append(current) }
+ cache.width = width
+ cache.rows = rows
+ return rows
+ }
+}
+
+public struct SizeKey: PreferenceKey {
+ public static let defaultValue: CGSize = .zero
+
+ /// Keeping the last non-zero value would make a second publisher anywhere in the subtree win by reduction order,
+ /// silently sizing the popover to some inner view.
+ public static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
+ let next = nextValue()
+ value = CGSize(width: max(value.width, next.width), height: max(value.height, next.height))
+ }
+}
+
+extension View {
+ /// Reports this view's size to the enclosing `onPreferenceChange(SizeKey.self)` without observing it here.
+ public func publishSize() -> some View {
+ background(GeometryReader { proxy in Color.clear.preference(key: SizeKey.self, value: proxy.size) })
+ }
+
+ public func measureSize(_ onChange: @escaping @MainActor @Sendable (CGSize) -> Void) -> some View {
+ publishSize().onPreferenceChange(SizeKey.self) { size in Task { @MainActor in onChange(size) } }
+ }
+}
+
+public struct MetricCell: View {
+ public let title: String
+ public let value: String
+ public let help: String
+
+ public init(title: String, value: String, help: String) {
+ self.title = title
+ self.value = value
+ self.help = help
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title).font(.callout).semanticForeground(.secondary)
+ Text(value).font(.body.monospacedDigit()).fixedSize(horizontal: false, vertical: true)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .richHelp(TooltipContent(title: title, body: help))
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("\(title): \(value)")
+ }
+}
+
+public struct HelpText: View {
+ public let text: String
+
+ public init(_ text: String) {
+ self.text = text
+ }
+
+ public var body: some View {
+ Text(text).font(.callout).frame(idealWidth: 260, maxWidth: 260, alignment: .leading)
+ }
+}
+
+public struct EmptyStateView: View {
+ public let title: String
+ public let systemImage: String
+ public let description: String
+
+ public init(title: String, systemImage: String, description: String) {
+ self.title = title
+ self.systemImage = systemImage
+ self.description = description
+ }
+
+ public var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: systemImage).font(.system(size: 26)).semanticForeground(.secondary).frame(width: 32)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(title).font(.headline)
+ Text(description).font(.callout).semanticForeground(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .padding(.vertical, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityElement(children: .combine)
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Views/CreditsView.swift b/Sources/TokenMenuBarUI/Views/CreditsView.swift
new file mode 100644
index 0000000..91214ce
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Views/CreditsView.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+import TokenMenuBarCore
+
+public struct CreditsView: View {
+ public let presentation: UsageCreditsPresentation?
+
+ public init(credits: CreditBalance?, resetCredits: ResetCredits?) {
+ presentation = UsagePresenter.creditsPresentation(credits, resetCredits: resetCredits)
+ }
+
+ public init(presentation: UsageCreditsPresentation) {
+ self.presentation = presentation
+ }
+
+ public var body: some View {
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 118, maximum: 180), alignment: .leading)], spacing: 6) {
+ if let presentation {
+ ForEach(presentation.metrics) { metric in
+ MetricCell(title: metric.title, value: metric.value, help: metric.help)
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Views/EmptyHistoryView.swift b/Sources/TokenMenuBarUI/Views/EmptyHistoryView.swift
new file mode 100644
index 0000000..4c4c907
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Views/EmptyHistoryView.swift
@@ -0,0 +1,13 @@
+import Charts
+import SwiftUI
+import TokenMenuBarCore
+
+public struct EmptyHistoryView: View {
+ public init() {}
+
+ public var body: some View {
+ ContentUnavailableView(
+ "No samples yet", systemImage: "chart.xyaxis.line",
+ description: Text("The app records usage every few minutes while it runs."))
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Views/HistoryInspector.swift b/Sources/TokenMenuBarUI/Views/HistoryInspector.swift
new file mode 100644
index 0000000..be5f98d
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Views/HistoryInspector.swift
@@ -0,0 +1,174 @@
+import SwiftUI
+import TokenMenuBarCore
+
+@MainActor
+struct HistoryLegendHoverAction {
+ let presenter: HistoryPresenter
+ let seriesID: HistorySeriesID
+
+ func callAsFunction(_ hovering: Bool) {
+ presenter.setHovered(hovering ? seriesID : nil)
+ }
+}
+
+public struct HistoryInspector: View {
+ @Bindable var environment: UIEnvironment
+
+ public init(environment: UIEnvironment) {
+ self.environment = environment
+ }
+
+ private var presenter: HistoryPresenter { environment.historyPresenter }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text(selectionTitle).font(.caption).semanticForeground(InterfaceTokens.standard.detailForeground)
+ Spacer()
+ Toggle(
+ "UTC",
+ isOn: useUTCBinding
+ )
+ .toggleStyle(.checkbox)
+ .font(.caption)
+ .disabled(presenter.selectedMetric.usesDailyUTC)
+ .accessibilityIdentifier("history-utc")
+ .richHelp(
+ TooltipContent(
+ title: "UTC boundaries",
+ body:
+ "Uses UTC boundaries for window buckets. Provider analytics already arrives as UTC days, "
+ + "so the setting does not apply there."
+ )
+ )
+ }
+ if let data = presenter.state.data {
+ VStack(alignment: .leading, spacing: 7) {
+ ForEach(data.series) { series in legendRow(series, metric: data.metric) }
+ }
+ }
+ Text("Toggle a row to show or hide it. Double-click or press I to isolate it.")
+ .font(.caption2)
+ .semanticForeground(InterfaceTokens.standard.detailForeground)
+ }
+ }
+
+ private var selectionTitle: String {
+ guard let date = presenter.selectedDate else { return "Range values" }
+ var style = Date.FormatStyle(date: .abbreviated, time: .shortened)
+ style.timeZone = presenter.chartTimeZone
+ return date.formatted(style)
+ }
+
+ private func legendRow(_ series: HistorySeries, metric: HistoryMetric) -> some View {
+ let isolate = { presenter.isolate(series.id) }
+ return HStack(alignment: .firstTextBaseline, spacing: 7) {
+ HistoryLegendSwatch(style: series.style, markKind: metric.markKind)
+ .frame(width: 18, height: 10)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(series.label).fixedSize(horizontal: false, vertical: true)
+ if let reset = presenter.resetDescription(for: series) {
+ Text(reset).font(.caption2).semanticForeground(InterfaceTokens.standard.detailForeground)
+ }
+ }
+ Spacer(minLength: 4)
+ Text(presenter.value(for: series)).monospacedDigit()
+ Toggle(
+ "Show \(series.label)",
+ isOn: visibilityBinding(for: series.id)
+ )
+ .labelsHidden()
+ .toggleStyle(.checkbox)
+ .accessibilityIdentifier("history-series-\(series.id.storageKey)")
+ .richHelp(
+ TooltipContent(
+ title: "Show \(series.label)",
+ body:
+ metric.hasParallelBreakdowns
+ ? "Includes this breakdown in the chart. The workspace total remains independent of model "
+ + "and surface visibility."
+ : "Includes this series in the chart and total. "
+ + "Turning it off keeps the data available in this list.")
+ )
+ }
+ .font(.caption)
+ .contentShape(Rectangle())
+ .padding(.vertical, 2)
+ .padding(.horizontal, 4)
+ .background {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(presenter.hoveredSeriesID == series.id ? Color.accentColor.opacity(0.08) : .clear)
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(presenter.hoveredSeriesID == series.id ? Color.accentColor.opacity(0.45) : .clear)
+ }
+ .onHover(perform: HistoryLegendHoverAction(presenter: presenter, seriesID: series.id).callAsFunction)
+ .onTapGesture(count: 2, perform: isolate)
+ .focusable()
+ .onKeyPress("i") {
+ isolate()
+ return .handled
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(series.label)
+ .accessibilityValue("\(presenter.value(for: series)), \(series.isVisible ? "shown" : "hidden")")
+ .accessibilityAction(named: "Isolate", isolate)
+ .richHelp(
+ TooltipContent(
+ title: series.label,
+ body:
+ "Shows the value at the selected date. Double-click or press I to isolate this series; "
+ + "repeat to restore the others."
+ )
+ )
+ }
+
+ var useUTCBinding: Binding {
+ Binding(get: { environment.settings.historyUseUTC }, set: { presenter.setUseUTC($0) })
+ }
+
+ func visibilityBinding(for seriesID: HistorySeriesID) -> Binding {
+ Binding(get: { presenter.isVisible(seriesID) }, set: { _ in presenter.toggleVisibility(seriesID) })
+ }
+}
+
+struct HistoryLegendSwatch: View {
+ let style: HistoryStyleSlot
+ let markKind: HistoryMarkKind
+
+ var body: some View {
+ let color = UsageChart.color(index: style.hueIndex)
+ switch markKind {
+ case .stepLine, .line:
+ ZStack {
+ Canvas { context, size in
+ var path = Path()
+ path.move(to: CGPoint(x: 0, y: size.height / 2))
+ path.addLine(to: CGPoint(x: size.width, y: size.height / 2))
+ context.stroke(path, with: .color(color), style: UsageChart.stroke(variant: style.variant))
+ }
+ HistoryLegendPointSymbol(variant: style.variant, color: color).frame(width: 5, height: 5)
+ }
+ case .bars:
+ RoundedRectangle(cornerRadius: 1.5).fill(UsageChart.barStyle(style))
+ }
+ }
+}
+
+private struct HistoryLegendPointSymbol: View {
+ let variant: Int
+ let color: Color
+
+ var body: some View {
+ switch variant % 3 {
+ case 1:
+ Rectangle().fill(color)
+ case 2:
+ Rectangle().fill(color).rotationEffect(.degrees(45))
+ default:
+ Circle().fill(color)
+ }
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Views/HistoryTab.swift b/Sources/TokenMenuBarUI/Views/HistoryTab.swift
new file mode 100644
index 0000000..28dce50
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Views/HistoryTab.swift
@@ -0,0 +1,385 @@
+import AppKit
+import Charts
+import SwiftUI
+import TokenMenuBarCore
+
+public struct HistoryTab: View {
+ @Bindable var environment: UIEnvironment
+ private let chooseExportURL: @MainActor () -> URL?
+
+ public init(
+ environment: UIEnvironment,
+ chooseExportURL: @escaping @MainActor () -> URL? = {
+ LiveDependencies.chosen(LiveDependencies.exportPanel(), run: { $0.runModal() })
+ }
+ ) {
+ self.environment = environment
+ self.chooseExportURL = chooseExportURL
+ }
+
+ private var presenter: HistoryPresenter { environment.historyPresenter }
+ private var settings: TokenMenuBarCore.Settings { environment.settings }
+
+ public var body: some View {
+ ScrollingTab(tab: .history) {
+ VStack(alignment: .leading, spacing: 10) {
+ periodControls
+ viewportControls
+ metricControls
+ if let data = presenter.state.data, !data.summaryText.isEmpty {
+ Text(data.summaryText).font(.title2.monospacedDigit().weight(.semibold))
+ }
+ chartRow
+ footer
+ if let error = presenter.exportError {
+ HStack(spacing: 5) {
+ Image(systemName: "exclamationmark.circle.fill").semanticForeground(.destructive)
+ Text(error).semanticForeground(InterfaceTokens.standard.bodyForeground)
+ }
+ .font(.caption)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(error)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .task(id: dataScope) {
+ presenter.setDataScope(dataScope)
+ presenter.ensureLoaded()
+ }
+ .onChange(of: environment.state.historyRevision) { presenter.reload() }
+ }
+
+ private var periodControls: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 10) {
+ periodPicker
+ Text("Rollup").font(.caption).semanticForeground(InterfaceTokens.standard.detailForeground)
+ rollupPicker
+ stackToggle
+ Spacer(minLength: 4)
+ exportButton
+ }
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 10) {
+ periodPicker
+ Spacer(minLength: 4)
+ exportButton
+ }
+ HStack(spacing: 10) {
+ Text("Rollup").font(.caption).semanticForeground(InterfaceTokens.standard.detailForeground)
+ rollupPicker
+ stackToggle
+ Spacer(minLength: 4)
+ }
+ }
+ }
+ }
+
+ private var periodPicker: some View {
+ NativeSegmentedControl(
+ HistoryPeriod.allCases.map { (value: $0, label: $0.title) },
+ selection: Binding(get: { presenter.period }, set: { presenter.setPeriod($0) }),
+ accessibilityLabel: "Period",
+ accessibilityIdentifier: "history-period"
+ )
+ .frame(minWidth: 289)
+ .richHelp(
+ TooltipContent(
+ title: "History period",
+ body: "Now follows the current period. Paging stops live updates until you choose Now."))
+ }
+
+ private var rollupPicker: some View {
+ NativeSegmentedControl(
+ Rollup.allCases.map { (value: $0, label: $0.rawValue) },
+ selection: Binding(get: { presenter.effectiveRollup }, set: { presenter.setRollup($0) }),
+ accessibilityLabel: "Rollup",
+ accessibilityIdentifier: "history-rollup"
+ )
+ .frame(minWidth: 152)
+ .disabled(presenter.selectedMetric.usesDailyUTC)
+ .richHelp(
+ TooltipContent(
+ title: "Rollup",
+ body: "Combines window samples to limit chart work. Provider analytics keeps its daily UTC buckets."))
+ }
+
+ private var stackToggle: some View {
+ Toggle("Stacked", isOn: stackedBinding)
+ .toggleStyle(.checkbox)
+ .disabled(!presenter.canStack)
+ .accessibilityIdentifier("history-stacked")
+ .richHelp(
+ TooltipContent(
+ title: "Stack series",
+ body: "Adds visible bar series into one daily column. Leave it off to compare series side by side."))
+ }
+
+ private var exportButton: some View {
+ NativeActionButton("Export CSV", action: exportCurrentPeriod)
+ .accessibilityIdentifier("history-export")
+ .richHelp(
+ TooltipContent(
+ title: "Export selected period",
+ body: "Writes the selected metric and period without loading the full history into memory."))
+ }
+
+ private var viewportControls: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 8) {
+ pageBackButton
+ dateRange
+ pageForwardButton
+ Spacer()
+ earliestLabel
+ }
+ VStack(alignment: .leading, spacing: 6) {
+ dateRange
+ HStack(spacing: 8) {
+ pageBackButton
+ pageForwardButton
+ Spacer()
+ earliestLabel
+ }
+ }
+ }
+ .font(.caption)
+ }
+
+ private var pageBackButton: some View {
+ NativeIconButton(
+ symbol: "chevron.left", accessibilityLabel: "Previous period",
+ explanation: "Moves back by the selected calendar period and stops following Now.", action: pageBack
+ )
+ .disabled(!presenter.canPageBack)
+ .accessibilityIdentifier("history-previous-period")
+ }
+
+ private var dateRange: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 6) {
+ startPicker
+ Text("→").semanticForeground(InterfaceTokens.standard.detailForeground)
+ endPicker
+ }
+ VStack(alignment: .leading, spacing: 4) {
+ startPicker
+ endPicker
+ }
+ }
+ }
+
+ private var startPicker: some View {
+ DatePicker(
+ "From", selection: startBinding, displayedComponents: dateComponents
+ )
+ .accessibilityIdentifier("history-from")
+ .environment(\.timeZone, presenter.chartTimeZone)
+ .richHelp(
+ TooltipContent(
+ title: "Start date",
+ body: "Sets the first instant, switches the period to Custom, and stops following Now.")
+ )
+ }
+
+ private var endPicker: some View {
+ DatePicker(
+ "To", selection: endBinding, displayedComponents: dateComponents
+ )
+ .accessibilityIdentifier("history-to")
+ .environment(\.timeZone, presenter.chartTimeZone)
+ .richHelp(
+ TooltipContent(
+ title: "End date",
+ body: "Sets the last instant, switches the period to Custom, and stops following Now.")
+ )
+ }
+
+ private var pageForwardButton: some View {
+ NativeIconButton(
+ symbol: "chevron.right", accessibilityLabel: "Next period",
+ explanation: "Moves toward the current period. The button stops at Now.", action: pageForward
+ )
+ .disabled(!presenter.canPageForward)
+ .accessibilityIdentifier("history-next-period")
+ }
+
+ private var earliestLabel: some View {
+ Text(earliestText).font(.caption2).semanticForeground(InterfaceTokens.standard.detailForeground)
+ }
+
+ private var metricControls: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 8) {
+ metricPicker
+ attribution
+ Spacer()
+ }
+ VStack(alignment: .leading, spacing: 4) {
+ metricPicker
+ attribution
+ }
+ }
+ }
+
+ private var metricPicker: some View {
+ HStack(spacing: 8) {
+ Text("Metric").font(.caption).semanticForeground(InterfaceTokens.standard.detailForeground)
+ Picker(
+ "Metric", selection: Binding(get: { presenter.selectedMetric }, set: { presenter.setMetric($0) })
+ ) {
+ ForEach(HistoryMetricGroup.allCases, id: \.self) { group in
+ Section(group.rawValue) {
+ ForEach(HistoryMetric.allCases.filter { $0.group == group }) { metric in
+ Text(metric.title).tag(metric)
+ }
+ }
+ }
+ }
+ .labelsHidden()
+ .frame(minWidth: 250, idealWidth: 320, alignment: .leading)
+ .accessibilityIdentifier("history-metric")
+ .richHelp(
+ TooltipContent(
+ title: "History metric",
+ body: "Chooses the data to load and draw. The groups name which providers supply each metric.")
+ )
+ }
+ }
+
+ private var attribution: some View {
+ WrappingHStack(horizontalSpacing: 4, verticalSpacing: 3) {
+ ForEach(plottedProviders, id: \.self) { provider in
+ ProviderMarkView(provider, size: CGSize(width: 22, height: 16))
+ .accessibilityHidden(true)
+ }
+ Text(presenter.selectedMetric.attribution(providers: plottedProviders))
+ .font(.caption2)
+ .semanticForeground(InterfaceTokens.standard.detailForeground)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(presenter.selectedMetric.attribution(providers: plottedProviders))
+ }
+
+ private var plottedProviders: [ProviderID] {
+ let providers = Set(presenter.state.data?.series.filter { !$0.points.isEmpty }.map(\.id.provider) ?? [])
+ return presenter.selectedMetric.suppliers.filter(providers.contains)
+ }
+
+ private var dataScope: HistoryDataScope {
+ HistoryDataScope(
+ activeProviders: settings.activeProviders(states: environment.state.providers),
+ selectedWindows: settings.hasCustomSelection ? Set(settings.selectedWindows) : nil)
+ }
+
+ private var chartRow: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(alignment: .top, spacing: 12) {
+ chart.frame(minWidth: 600).frame(height: PopoverGeometry.historyChartHeight)
+ HistoryInspector(environment: environment)
+ .frame(width: 220, alignment: .leading)
+ }
+ VStack(alignment: .leading, spacing: 12) {
+ chart.frame(minWidth: 320).frame(height: PopoverGeometry.historyChartHeight)
+ HistoryInspector(environment: environment)
+ }
+ }
+ }
+
+ @ViewBuilder private var chart: some View {
+ switch presenter.state {
+ case .loading:
+ ProgressView("Loading history…").frame(maxWidth: .infinity, maxHeight: .infinity)
+ case .failed(let error):
+ VStack(spacing: 8) {
+ ContentUnavailableView(
+ "History unavailable", systemImage: "exclamationmark.triangle", description: Text(error))
+ NativeActionButton("Retry", action: presenter.reload)
+ .richHelp(
+ TooltipContent(
+ title: "Retry history",
+ body: "Queries the selected period again. Existing stored samples remain unchanged."))
+ }
+ case .loaded(let data, let refreshing, let error):
+ ZStack(alignment: .topTrailing) {
+ if data.isEmpty {
+ EmptyHistoryView()
+ } else {
+ UsageChart(
+ data: data, presenter: presenter, stacked: settings.historyStacked && presenter.canStack,
+ timeZone: presenter.chartTimeZone)
+ }
+ if refreshing { UpdatingBadge().accessibilityLabel("Updating history") }
+ if let error {
+ VStack {
+ Spacer()
+ HStack(spacing: 8) {
+ Text("Update failed: \(error)")
+ .fixedSize(horizontal: false, vertical: true)
+ NativeActionButton("Retry", action: presenter.reload)
+ .richHelp(
+ TooltipContent(
+ title: "Retry history",
+ body: "Queries the selected period again. Existing stored samples remain unchanged."))
+ }
+ .font(.caption)
+ .padding(6)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6))
+ .accessibilityElement(children: .contain)
+ }
+ }
+ }
+ }
+ }
+
+ private var footer: some View {
+ let data = presenter.state.data
+ let since = earliest.map { " · since \($0.formatted(date: .abbreviated, time: .omitted))" } ?? ""
+ return Text(
+ "\(data?.dataPointCount ?? 0) samples · \(data?.series.count ?? 0) series" + since
+ )
+ .font(.caption2)
+ .semanticForeground(InterfaceTokens.standard.detailForeground)
+ }
+
+ var stackedBinding: Binding {
+ Binding(get: { settings.historyStacked }, set: { presenter.setStacked($0) })
+ }
+
+ var startBinding: Binding {
+ Binding(
+ get: { presenter.currentViewport.lowerBound },
+ set: { presenter.setCustomStart($0) })
+ }
+
+ var endBinding: Binding {
+ Binding(
+ get: { presenter.currentViewport.upperBound },
+ set: { presenter.setCustomEnd($0) })
+ }
+
+ private var dateComponents: DatePickerComponents {
+ presenter.effectiveRollup == .day ? [.date] : [.date, .hourAndMinute]
+ }
+
+ private var earliest: Date? { presenter.earliest }
+
+ private var earliestText: String {
+ earliest.map { "Earliest sample \($0.formatted(date: .abbreviated, time: .omitted))" } ?? "No earlier samples"
+ }
+
+ public func pageBack() {
+ presenter.page(forward: false, now: environment.clock.now())
+ }
+
+ public func pageForward() {
+ presenter.page(forward: true, now: environment.clock.now())
+ }
+
+ private func exportCurrentPeriod() {
+ guard let url = chooseExportURL() else { return }
+ presenter.exportCSV(to: url)
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Views/HoverHelp.swift b/Sources/TokenMenuBarUI/Views/HoverHelp.swift
new file mode 100644
index 0000000..0a8fd93
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Views/HoverHelp.swift
@@ -0,0 +1,136 @@
+import SwiftUI
+
+@MainActor
+private struct RichHelpModifier: ViewModifier {
+ let help: TooltipContent
+ let presenter: TooltipPresenter
+
+ func body(content: Content) -> some View {
+ content
+ .richHelpAccessibility(help)
+ .background(
+ TooltipAnchor(content: help, focused: false, presenter: presenter, tracksHover: true)
+ .allowsHitTesting(false)
+ )
+ }
+}
+
+@MainActor
+private struct FocusValueRichHelpModifier: ViewModifier {
+ let help: TooltipContent
+ let isFocused: Bool
+ let presenter: TooltipPresenter
+
+ func body(content: Content) -> some View {
+ content
+ .richHelpAccessibility(help)
+ .background(
+ TooltipAnchor(content: help, focused: isFocused, presenter: presenter, tracksHover: true)
+ .allowsHitTesting(false)
+ )
+ }
+}
+
+@MainActor
+private struct FocusedRichHelpModifier: ViewModifier {
+ let help: TooltipContent
+ let focus: FocusState.Binding
+ let value: Value
+ let presenter: TooltipPresenter
+
+ func body(content: Content) -> some View {
+ content
+ .focused(focus, equals: value)
+ .modifier(
+ FocusValueRichHelpModifier(
+ help: help,
+ isFocused: focus.wrappedValue == value,
+ presenter: presenter
+ ))
+ }
+}
+
+@MainActor
+private struct BooleanFocusedRichHelpModifier: ViewModifier {
+ let help: TooltipContent
+ let focus: FocusState.Binding
+ let presenter: TooltipPresenter
+
+ func body(content: Content) -> some View {
+ content
+ .focused(focus)
+ .modifier(
+ FocusValueRichHelpModifier(
+ help: help,
+ isFocused: focus.wrappedValue,
+ presenter: presenter
+ ))
+ }
+}
+
+extension View {
+ @MainActor
+ public func richHelpAccessibility(_ help: TooltipContent) -> some View {
+ accessibilityHint(Text(help.accessibilityHint))
+ }
+
+ @MainActor
+ public func richHelp(_ help: TooltipContent) -> some View {
+ richHelp(help, presenter: .shared)
+ }
+
+ @MainActor
+ public func richHelp(_ help: TooltipContent, presenter: TooltipPresenter) -> some View {
+ modifier(RichHelpModifier(help: help, presenter: presenter))
+ }
+
+ @MainActor
+ public func richHelp(_ help: TooltipContent, isFocused: Bool) -> some View {
+ richHelp(help, isFocused: isFocused, presenter: .shared)
+ }
+
+ @MainActor
+ public func richHelp(
+ _ help: TooltipContent,
+ isFocused: Bool,
+ presenter: TooltipPresenter
+ ) -> some View {
+ modifier(FocusValueRichHelpModifier(help: help, isFocused: isFocused, presenter: presenter))
+ }
+
+ @MainActor
+ public func richHelp(
+ _ help: TooltipContent,
+ focus: FocusState.Binding,
+ equals value: Value
+ ) -> some View {
+ richHelp(help, focus: focus, equals: value, presenter: .shared)
+ }
+
+ @MainActor
+ public func richHelp(
+ _ help: TooltipContent,
+ focus: FocusState.Binding,
+ equals value: Value,
+ presenter: TooltipPresenter
+ ) -> some View {
+ modifier(FocusedRichHelpModifier(help: help, focus: focus, value: value, presenter: presenter))
+ }
+
+ @MainActor
+ public func richHelp(
+ _ help: TooltipContent,
+ focus: FocusState.Binding
+ ) -> some View {
+ richHelp(help, focus: focus, presenter: .shared)
+ }
+
+ @MainActor
+ public func richHelp(
+ _ help: TooltipContent,
+ focus: FocusState.Binding,
+ presenter: TooltipPresenter
+ ) -> some View {
+ modifier(BooleanFocusedRichHelpModifier(help: help, focus: focus, presenter: presenter))
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Views/LocalUsageView.swift b/Sources/TokenMenuBarUI/Views/LocalUsageView.swift
new file mode 100644
index 0000000..a8cdcbd
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Views/LocalUsageView.swift
@@ -0,0 +1,29 @@
+import SwiftUI
+import TokenMenuBarCore
+
+public struct LocalUsageView: View {
+ public let presentation: UsageLocalPresentation
+
+ public init(usage: LocalUsage) {
+ presentation = UsagePresenter.localPresentation(usage)
+ }
+
+ public init(presentation: UsageLocalPresentation) {
+ self.presentation = presentation
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 5) {
+ Text("Local session logs").font(.callout.weight(.medium)).accessibilityAddTraits(.isHeader)
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 118, maximum: 180), alignment: .leading)], spacing: 6) {
+ ForEach(presentation.metrics) { metric in
+ MetricCell(title: metric.title, value: metric.value, help: metric.help)
+ }
+ }
+ }
+ }
+
+ static func money(_ value: Double) -> String {
+ UsagePresenter.localMoney(value)
+ }
+}
diff --git a/Sources/TokenMenuBarUI/Views/LogSection.swift b/Sources/TokenMenuBarUI/Views/LogSection.swift
new file mode 100644
index 0000000..b1337e3
--- /dev/null
+++ b/Sources/TokenMenuBarUI/Views/LogSection.swift
@@ -0,0 +1,255 @@
+import AppKit
+import SwiftUI
+import TokenMenuBarCore
+
+public struct LogSection: View {
+ @Bindable var environment: UIEnvironment
+ @State private var entries: [LogEntry]
+ @State private var level: LogLevel?
+ @State private var search = ""
+
+ public init(environment: UIEnvironment) {
+ self.environment = environment
+ _entries = State(initialValue: environment.log.snapshot)
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 8) {
+ actions
+ Spacer()
+ options
+ }
+ VStack(alignment: .leading, spacing: 7) {
+ actions
+ options
+ }
+ }
+ LogViewer(
+ entries: displayedEntries, level: $level, search: $search, height: 150, newestFirst: true,
+ searchShortcut: false)
+ }
+ .task {
+ for await snapshot in environment.log.snapshots() { entries = snapshot }
+ }
+ }
+
+ public func setDetailedLogging(_ enabled: Bool) {
+ environment.settings.detailedLogging = enabled
+ environment.log.debugEnabled = enabled
+ environment.actions.settingsChanged()
+ }
+
+ func setDemoMode(_ enabled: Bool) {
+ environment.actions.setDemoMode(enabled)
+ }
+
+ func copyDisplayedEntries() {
+ environment.actions.copy(exportedEntries)
+ }
+
+ func clear() {
+ environment.log.clear()
+ }
+
+ func showFullLog() {
+ environment.actions.showFullLog()
+ }
+
+ var demoModeBinding: Binding {
+ Binding(get: { environment.isDemo }, set: { setDemoMode($0) })
+ }
+
+ var detailedLoggingBinding: Binding