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 @@ +Claude 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 @@ +GitHub Copilot 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 @@ +Google Gemini 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 { + Binding(get: { environment.settings.detailedLogging }, set: { setDetailedLogging($0) }) + } + + private var displayedEntries: [LogEntry] { + Array(entries.suffix(200)) + } + + private var actions: some View { + HStack(spacing: 8) { + NativeActionButton("Copy", action: copyDisplayedEntries) + .richHelp( + TooltipContent( + title: "Copy log", + body: "Copies the visible lines after applying the level and search filters. Private values are redacted.")) + NativeActionButton("Clear", intent: .destructive, action: clear) + .richHelp( + TooltipContent( + title: "Clear log", + body: "Removes the in-memory log and its rotated files. New events continue to be recorded.")) + NativeActionButton("Show Full Log", action: showFullLog) + .richHelp( + TooltipContent( + title: "Show full log", body: "Opens every retained line in a larger searchable window.")) + } + } + + private var options: some View { + HStack(spacing: 8) { + Toggle( + "Demo data", + isOn: demoModeBinding + ) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Demo data", + body: + "Replaces providers with generated data in a separate history file. " + + "Turning it off restores real data after relaunch." + )) + Toggle( + "Detailed logging", + isOn: detailedLoggingBinding + ) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Detailed logging", + body: + "Records panel geometry, tab measurements, status-item re-tiers, and refresh outcomes. " + + "Off keeps routine and failure messages only." + )) + Text("off by default") + .font(.caption2) + .semanticForeground(.secondary) + } + } + + private var exportedEntries: String { + let filter = LogFilter(search: search, levels: Self.selectedLevels(level)) + return LogExport.text(entries: filter.entries(from: displayedEntries.reversed())) + } + + static func selectedLevels(_ level: LogLevel?) -> Set { + if let level { [level] } else { Set(LogLevel.allCases) } + } +} + +public struct FullLogView: View { + public let log: LogBuffer + @State private var entries: [LogEntry] + @State private var level: LogLevel? + @State private var search = "" + + public init(log: LogBuffer) { + self.log = log + _entries = State(initialValue: log.snapshot) + } + + public var body: some View { + LogViewer( + entries: entries, level: $level, search: $search, height: nil, newestFirst: false, + searchShortcut: true + ) + .padding(12) + .task { + var retained = await log.retainedSnapshot() + entries = retained + var previousLive: [LogEntry] = [] + for await snapshot in log.snapshots() { + if snapshot.isEmpty { + retained.removeAll(keepingCapacity: true) + entries.removeAll(keepingCapacity: true) + } else if previousLive.isEmpty { + entries = Self.merge(retained: retained, live: snapshot) + } else { + let overlap = Self.overlap(previousLive, snapshot) + if overlap > 0 { + entries.append(contentsOf: snapshot.dropFirst(overlap)) + } else { + retained = await log.retainedSnapshot() + entries = Self.merge(retained: retained, live: snapshot) + } + } + if entries.count > LogBuffer.retainedEntryLimit + LogBuffer.capacity { + entries.removeFirst(entries.count - LogBuffer.retainedEntryLimit) + } + previousLive = snapshot + } + } + } + + static func merge(retained: [LogEntry], live: [LogEntry]) -> [LogEntry] { + guard !retained.isEmpty else { return live } + guard !live.isEmpty else { return [] } + let overlap = overlap(retained, live) + return retained + Array(live.dropFirst(overlap)) + } + + static func overlap(_ previous: [LogEntry], _ current: [LogEntry]) -> Int { + guard let first = current.first, + let start = previous.firstIndex(where: { $0.sequenceID == first.sequenceID }) + else { return 0 } + let count = min(previous.distance(from: start, to: previous.endIndex), current.count) + return previous[start...].prefix(count).elementsEqual( + current.prefix(count), + by: { + $0.sequenceID == $1.sequenceID + }) ? count : 0 + } +} + +private struct LogViewer: View { + let entries: [LogEntry] + @Binding var level: LogLevel? + @Binding var search: String + let height: CGFloat? + let newestFirst: Bool + let searchShortcut: Bool + @FocusState private var searchFocused: Bool + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + NativeSegmentedControl( + [(value: LogLevel?.none, label: "All")] + + LogLevel.allCases.map { (value: LogLevel?.some($0), label: $0.title) }, + selection: $level, + accessibilityLabel: "Log level" + ) + .fixedSize() + .richHelp( + TooltipContent( + title: "Log level", + body: "Shows one severity. All keeps debug, information, warning, and error lines visible.")) + TextField("Search log", text: $search, prompt: Text("Search log…")) + .textFieldStyle(.roundedBorder) + .focused($searchFocused) + .accessibilityLabel("Search log") + .richHelp( + TooltipContent( + title: "Search log", + body: "Filters retained lines as you type without changing or deleting the stored log.")) + if searchShortcut { + Button("Search Log") { searchFocused = true } + .keyboardShortcut("f", modifiers: .command) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + } + .padding(6) + .background(.quaternary.opacity(0.35)) + Divider() + LogTextView(entries: filteredEntries, height: height, bordered: false, followsTail: !newestFirst) + .frame(maxWidth: .infinity, maxHeight: height == nil ? .infinity : nil) + } + .background(.background.opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(.separator, lineWidth: 1) + } + } + + private var filteredEntries: [LogEntry] { + let filter = LogFilter(search: search, levels: LogSection.selectedLevels(level)) + let result = filter.entries(from: entries) + return newestFirst ? result.reversed() : result + } +} diff --git a/Sources/TokenMenuBarUI/Views/LogTextView.swift b/Sources/TokenMenuBarUI/Views/LogTextView.swift new file mode 100644 index 0000000..2bc059b --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/LogTextView.swift @@ -0,0 +1,176 @@ +import AppKit +import SwiftUI +import TokenMenuBarCore + +public struct LogTextView: NSViewRepresentable { + public static var textColor: NSColor { SemanticColorPalette.color(for: .secondary) } + + public let entries: [LogEntry] + public let height: CGFloat? + public let bordered: Bool + public let followsTail: Bool + + public init(entries: [LogEntry], height: CGFloat? = nil, bordered: Bool = true, followsTail: Bool = false) { + self.entries = entries + self.height = height + self.bordered = bordered + self.followsTail = followsTail + } + + public func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + let textView = scrollView.documentView as! NSTextView + textView.isEditable = false + textView.isSelectable = true + textView.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular) + textView.textColor = Self.textColor + textView.drawsBackground = false + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.textContainer?.widthTracksTextView = true + textView.layoutManager?.allowsNonContiguousLayout = true + textView.textContainerInset = CGSize(width: 8, height: 8) + textView.setAccessibilityLabel("Log") + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.borderType = bordered ? .bezelBorder : .noBorder + if let height { scrollView.heightAnchor.constraint(equalToConstant: height).isActive = true } + return scrollView + } + + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + public func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + guard let change = context.coordinator.change(to: entries) else { return } + let origin = scrollView.contentView.bounds.origin + let previousHeight = change.prepended ? textView.frame.height : 0 + let wasAtEnd = textView.bounds.maxY - scrollView.documentVisibleRect.maxY <= 12 + let ranges = textView.selectedRanges + switch change { + case .append(let suffix): + textView.textStorage?.append(NSAttributedString(string: suffix, attributes: textView.typingAttributes)) + case .prepend(let prefix): + textView.textStorage?.insert(NSAttributedString(string: prefix, attributes: textView.typingAttributes), at: 0) + case .replace: + textView.string = Self.text(entries) + } + textView.selectedRanges = Self.adjusted(ranges, for: change, textLength: textView.string.utf16.count) + if followsTail, wasAtEnd { + textView.scrollToEndOfDocument(nil) + } else { + let verticalOffset = change.prepended ? max(textView.frame.height - previousHeight, 0) : 0 + scrollView.contentView.scroll(to: CGPoint(x: origin.x, y: origin.y + verticalOffset)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + } + + @MainActor + public final class Coordinator { + private var snapshot = EntrySnapshot.empty + + func change(to entries: [LogEntry]) -> Change? { + guard !snapshot.matches(entries) else { return nil } + guard snapshot.count > 0, !entries.isEmpty else { + snapshot.replace(with: entries) + return .replace + } + if entries.count > snapshot.count, + snapshot.matchesPrefix(of: entries) + { + let appended = entries.dropFirst(snapshot.count) + snapshot.append(contentsOf: appended) + return .append(LogTextView.text(appended, leadingNewline: true)) + } + let offset = entries.count - snapshot.count + if offset > 0, + snapshot.matchesSuffix(of: entries) + { + let prepended = entries.prefix(offset) + snapshot.prepend(contentsOf: prepended) + return .prepend(LogTextView.text(prepended, trailingNewline: true)) + } + snapshot.replace(with: entries) + return .replace + } + } + + private static func text(_ entries: Entries) -> String where Entries.Element == LogEntry { + text(entries, leadingNewline: false, trailingNewline: false) + } + + private static func text( + _ entries: Entries, leadingNewline: Bool = false, trailingNewline: Bool = false + ) -> String where Entries.Element == LogEntry { + guard !entries.isEmpty else { return "" } + var result = "" + result.reserveCapacity(entries.reduce(0) { $0 + $1.line.utf8.count + 1 }) + if leadingNewline { result.append("\n") } + for entry in entries { + if result.last != nil, result.last != "\n" { result.append("\n") } + result.append(entry.line) + } + if trailingNewline { result.append("\n") } + return result + } + + private static func adjusted(_ values: [NSValue], for change: Change, textLength: Int) -> [NSValue] { + values.map { + let range = $0.rangeValue + let location = min(range.location + change.selectionOffset, textLength) + return NSValue(range: NSRange(location: location, length: min(range.length, textLength - location))) + } + } + + enum Change { + case append(String) + case prepend(String) + case replace + + var prepended: Bool { + if case .prepend = self { return true } + return false + } + + var selectionOffset: Int { + if case .prepend(let text) = self { return (text as NSString).length } + return 0 + } + } + + private struct EntrySnapshot { + static let empty = EntrySnapshot(ids: []) + + private var ids: [UInt64] + var count: Int { ids.count } + + mutating func append(contentsOf entries: Entries) where Entries.Element == LogEntry { + ids.append(contentsOf: entries.lazy.map(\.sequenceID)) + } + + mutating func prepend(contentsOf entries: Entries) where Entries.Element == LogEntry { + ids.insert(contentsOf: entries.lazy.map(\.sequenceID), at: 0) + } + + mutating func replace(with entries: [LogEntry]) { + ids = entries.map(\.sequenceID) + } + + func matches(_ entries: [LogEntry]) -> Bool { + ids.elementsEqual(entries.lazy.map(\.sequenceID)) + } + + func matchesPrefix(of entries: [LogEntry]) -> Bool { + ids.elementsEqual(entries.prefix(count).lazy.map(\.sequenceID)) + } + + func matchesSuffix(of entries: [LogEntry]) -> Bool { + ids.elementsEqual(entries.suffix(count).lazy.map(\.sequenceID)) + } + } +} diff --git a/Sources/TokenMenuBarUI/Views/PersistentTabContent.swift b/Sources/TokenMenuBarUI/Views/PersistentTabContent.swift new file mode 100644 index 0000000..520c626 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/PersistentTabContent.swift @@ -0,0 +1,171 @@ +import AppKit +import SwiftUI +import TokenMenuBarCore + +struct PersistentTabContent: NSViewRepresentable { + @Bindable var environment: UIEnvironment + let selection: PopoverTab + let chooseHistoryExportURL: @MainActor () -> URL? + let onMeasure: @MainActor (PopoverMeasurement) -> Void + let onPresent: @MainActor (PopoverTab) -> Void + + func makeNSView(context: Context) -> PersistentTabContainer { + let container = PersistentTabContainer() + for tab in PopoverTab.allCases { + let host = NSHostingView( + rootView: AnyView( + PersistentTabRoot( + environment: environment, + tab: tab, + mountsSettingsIncrementally: false, + chooseHistoryExportURL: chooseHistoryExportURL, + onMeasure: onMeasure))) + container.install(host, for: tab) + } + container.select(selection) + return container + } + + func updateNSView(_ container: PersistentTabContainer, context: Context) { + container.select(selection) + onPresent(selection) + } +} + +@MainActor +final class PersistentTabContainer: NSView { + private var slots: [PopoverTab: PersistentTabSlot] = [:] + private var selected = PopoverTab.usage + private var prewarmed: Set = [] + private var prewarmQueue: [PopoverTab] = [] + private var prewarmScheduled = false + + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) + } + + override func accessibilityChildren() -> [Any]? { + slots[selected].map { [$0] } ?? [] + } + + func install(_ host: NSHostingView, for tab: PopoverTab) { + let slot = PersistentTabSlot(host: host) + slot.autoresizingMask = [.width, .height] + addSubview(slot) + slots[tab] = slot + } + + func select(_ tab: PopoverTab) { + guard let slot = slots[tab], selected != tab || !slot.isActive else { return } + selected = tab + prewarmed.insert(tab) + for (candidate, slot) in slots { slot.setActive(candidate == tab) } + slot.frame = bounds + slot.layoutSubtreeIfNeeded() + schedulePrewarm() + } + + override func layout() { + super.layout() + if let slot = slots[selected], slot.frame != bounds { slot.frame = bounds } + schedulePrewarm() + } + + private func schedulePrewarm() { + guard !prewarmScheduled, !bounds.isEmpty else { return } + prewarmQueue = [.settings, .history].filter { $0 != selected && !prewarmed.contains($0) } + guard !prewarmQueue.isEmpty else { return } + prewarmScheduled = true + DispatchQueue.main.async { [weak self] in self?.prewarmNext() } + } + + private func prewarmNext() { + guard let tab = prewarmQueue.first, let slot = slots[tab] else { + prewarmScheduled = false + return + } + prewarmQueue.removeFirst() + prewarmed.insert(tab) + slot.prewarm(in: bounds) + DispatchQueue.main.async { [weak self] in self?.prewarmNext() } + } +} + +@MainActor +final class PersistentTabSlot: NSView { + private let host: NSHostingView + private(set) var isActive = false + + init(host: NSHostingView) { + self.host = host + super.init(frame: .zero) + host.autoresizingMask = [.width, .height] + addSubview(host) + isHidden = true + setAccessibilityHidden(true) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is unavailable") + } + + override func accessibilityChildren() -> [Any]? { + isActive ? [host] : [] + } + + override func layout() { + super.layout() + if host.frame != bounds { host.frame = bounds } + } + + func setActive(_ active: Bool) { + guard active != isActive else { return } + isActive = active + setAccessibilityHidden(!active) + isHidden = !active + alphaValue = 1 + } + + func prewarm(in bounds: CGRect) { + guard !isActive else { return } + frame = bounds + alphaValue = 0 + isHidden = false + layoutSubtreeIfNeeded() + isHidden = true + alphaValue = 1 + } +} + +private struct PersistentTabRoot: View { + @Bindable var environment: UIEnvironment + let tab: PopoverTab + let mountsSettingsIncrementally: Bool + let chooseHistoryExportURL: @MainActor () -> URL? + let onMeasure: @MainActor (PopoverMeasurement) -> Void + + var body: some View { + content + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tab-content-\(tab.rawValue)") + .onPreferenceChange(PopoverMeasurementKey.self) { measurement in + guard let measurement, measurement.tab == tab else { return } + MainActor.assumeIsolated { onMeasure(measurement) } + } + } + + @ViewBuilder private var content: some View { + switch tab { + case .usage: + UsageTab(environment: environment) + case .history: + HistoryTab(environment: environment, chooseExportURL: chooseHistoryExportURL) + case .settings: + SettingsTab( + environment: environment, + providerFocusRequest: environment.providerFocusRequest, + mountsIncrementally: mountsSettingsIncrementally) + } + } +} diff --git a/Sources/TokenMenuBarUI/Views/PopoverFooter.swift b/Sources/TokenMenuBarUI/Views/PopoverFooter.swift new file mode 100644 index 0000000..4806114 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/PopoverFooter.swift @@ -0,0 +1,57 @@ +import SwiftUI +import TokenMenuBarCore + +public struct PopoverFooter: View { + @Bindable var environment: UIEnvironment + + public init(environment: UIEnvironment) { + self.environment = environment + } + + public var body: some View { + HStack(spacing: 8) { + NativeActionButton(action: environment.actions.refresh) { + Label("Refresh", systemImage: "arrow.clockwise") + } + .accessibilityIdentifier("footer-refresh") + .keyboardShortcut("r", modifiers: .command) + .richHelp( + TooltipContent( + title: "Refresh", + body: "Fetches current usage from each active provider. History analytics stay on their separate clock.")) + NativeActionButton(action: environment.actions.reportIssue) { + Label("Report Issue", systemImage: "ladybug") + } + .accessibilityIdentifier("footer-report-issue") + .richHelp( + TooltipContent( + title: "Report Issue", + body: "Opens a new issue with a diagnostic summary. Review the text before submitting it.")) + if environment.canCheckForUpdates { + NativeActionButton(action: environment.actions.checkForUpdates) { + Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath") + } + .accessibilityIdentifier("footer-check-updates") + .richHelp( + TooltipContent( + title: "Check for Updates", + body: "Checks the direct-download release feed without changing the automatic-update setting.")) + } + Spacer(minLength: 8) + NativeActionButton(action: environment.actions.quit) { + Label("Quit", systemImage: "power") + } + .accessibilityIdentifier("footer-quit") + .keyboardShortcut("q", modifiers: .command) + .richHelp( + TooltipContent( + title: "Quit", + body: "Stops provider polling and exits Token Menu Bar. Your settings and stored history remain.")) + } + .controlSize(.small) + .padding(.horizontal, PopoverGeometry.contentPadding) + .frame(height: PopoverGeometry.footerHeight) + .overlay(alignment: .top) { Divider() } + .panelSurface(.popoverChrome) + } +} diff --git a/Sources/TokenMenuBarUI/Views/ProviderCardView.swift b/Sources/TokenMenuBarUI/Views/ProviderCardView.swift new file mode 100644 index 0000000..a94e161 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/ProviderCardView.swift @@ -0,0 +1,251 @@ +import SwiftUI +import TokenMenuBarCore + +public struct ProviderCardView: View { + public let card: ProviderCard + @Bindable var environment: UIEnvironment + public let onRefreshProvider: (ProviderID) -> Void + + public init( + card: ProviderCard, environment: UIEnvironment, onRefreshProvider: @escaping (ProviderID) -> Void = { _ in } + ) { + self.card = card + self.environment = environment + self.onRefreshProvider = onRefreshProvider + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 6) { + ResponsivePanelLayout { + HStack(alignment: .center, spacing: 8) { + ProviderHeaderIdentity(provider: card.provider) + Spacer(minLength: 8) + ProviderStatusText(card: card, environment: environment) + headerActions + } + .frame(minWidth: 600) + } narrow: { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 8) { + ProviderHeaderIdentity(provider: card.provider) + Spacer(minLength: 8) + headerActions + } + ProviderStatusText(card: card, environment: environment) + } + } + if !card.chips.isEmpty { + WrappingHStack(horizontalSpacing: 5, verticalSpacing: 4) { + ForEach(card.chips) { chip in + UsageIdentityChip(chip: chip, provider: card.provider, onCopy: environment.actions.copy) + } + } + } + } + .padding(.horizontal, 11) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.035)) + + VStack(alignment: .leading, spacing: 5) { + if card.isStale, !card.isRefreshing, let error = card.lastError { + Banner("Showing older values: \(error)") + } + ForEach(card.warnings, id: \.self) { warning in + Banner(warning) + } + ForEach(card.notices) { notice in + Banner(notice.text, tone: notice.kind == .promotion ? .info : .warning) + } + if card.rows.isEmpty { + EmptyStateView( + title: card.emptyTitle, systemImage: icon(for: card.availability), description: card.emptyDescription) + } + ForEach(card.groups) { group in + VStack(alignment: .leading, spacing: 2) { + ForEach(group.rows) { row in + WindowRowView(row: row, environment: environment, showsReset: group.isSingle) + } + if !group.isSingle, group.resetDeadline != nil { + GroupResetText(group: group, environment: environment) + } + } + } + if let spend = card.spendPresentation { + Divider() + SpendView(presentation: spend) + } + if let credits = card.creditsPresentation { + CreditsView(presentation: credits) + } + if let local = card.localPresentation { + Divider() + LocalUsageView(presentation: local) + } + if let reviews = card.codeReviews { + HStack(spacing: 6) { + Text("Code reviews").semanticForeground(.secondary) + Text(reviews).monospacedDigit() + } + .font(.caption) + .richHelp( + TooltipContent( + title: "Code reviews", body: "Code reviews counted today and over the last seven days.") + ) + .accessibilityElement(children: .combine) + } + } + .padding(.horizontal, 11) + .padding(.top, 4) + .padding(.bottom, 7) + } + .background(Color.primary.opacity(0.02), in: RoundedRectangle(cornerRadius: 9)) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).stroke(Color.primary.opacity(0.09))) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("usage-provider-\(card.provider.rawValue)") + } + + public func refresh() { + onRefreshProvider(card.provider) + } + + public func showProviders() { + environment.actions.showProviders(card.provider) + } + + var shouldShowProviders: Bool { + switch card.availability { + case .authenticationRequired, .disabled, .unavailable: true + default: false + } + } + + @ViewBuilder private var headerActions: some View { + if card.isRefreshing { + ProgressView().controlSize(.small).accessibilityLabel("Refreshing \(card.provider.displayName)") + } + NativeIconButton( + symbol: "arrow.clockwise", accessibilityLabel: "Refresh \(card.provider.displayName)", + explanation: + "Fetches current quota data from \(card.provider.displayName). " + + "Last-known values remain visible if the refresh fails." + ) { refresh() } + .controlSize(.small) + .disabled(card.isRefreshing || card.availability == .disabled) + if shouldShowProviders { + NativeIconButton( + symbol: "slider.horizontal.3", accessibilityLabel: "Set up \(card.provider.displayName)", + explanation: + "Opens setup and recovery for \(card.provider.displayName). " + + "Last-known usage remains visible while access is repaired." + ) { showProviders() } + .controlSize(.small) + } + } + + func icon(for availability: QuotaAvailability) -> String { + switch availability { + case .authenticationRequired: "person.crop.circle.badge.exclamationmark" + case .networkUnavailable: "wifi.slash" + case .disabled: "pause.circle" + case .loading: "hourglass" + default: "chart.bar" + } + } +} + +struct UsageIdentityChip: View { + let chip: Chip + let provider: ProviderID + let onCopy: (String) -> Void + + var primaryHelp: TooltipContent { + TooltipContent( + title: chip.text, + body: + "Shows \(provider.displayName) plan, account, renewal, or data-source information. Copies the displayed value." + ) + } + + var copyHelp: TooltipContent { + TooltipContent(title: "Copy \(chip.text)", body: "Copies this value to the clipboard.") + } + + var body: some View { + HStack(spacing: 4) { + Button(action: primaryAction) { + Text(chip.text) + .multilineTextAlignment(.leading) + .frame(maxWidth: 240, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + .richHelp(primaryHelp) + .accessibilityLabel(chip.text) + .accessibilityValue(chip.text) + Button(action: copyAction) { + Label("Copy \(chip.text)", systemImage: "doc.on.doc").labelStyle(.iconOnly) + } + .richHelp(copyHelp) + .accessibilityLabel("Copy \(chip.text)") + } + .frame(maxWidth: 280, alignment: .leading) + .buttonStyle(.bordered) + .controlSize(.small) + .semanticControl(.action) + .contextMenu { + Button("Copy", systemImage: "doc.on.doc", action: copyAction) + .accessibilityHint(copyHelp.accessibilityHint) + } + } + + func primaryAction() { + onCopy(chip.text) + } + + func copyAction() { + onCopy(chip.text) + } +} + +private struct ProviderHeaderIdentity: View { + let provider: ProviderID + + var body: some View { + HStack(spacing: 7) { + ProviderMarkView(provider, size: CGSize(width: 22, height: 18)).accessibilityHidden(true) + Text(provider.displayName) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + } + } +} + +private struct ProviderStatusText: View { + let card: ProviderCard + @Bindable var environment: UIEnvironment + + var body: some View { + let status = card.statusText(at: environment.usageDeadlineNow) + Text(status) + .font(.caption) + .semanticForeground(.primary) + .fixedSize(horizontal: false, vertical: true) + .richHelp(TooltipContent(title: "\(card.provider.displayName) status", body: card.statusHelp)) + .accessibilityLabel("\(card.provider.displayName) status") + .accessibilityValue(status) + } +} + +private struct GroupResetText: View { + let group: WindowRowGroup + @Bindable var environment: UIEnvironment + + var body: some View { + if let deadline = group.resetDeadline { + ResetDeadlineText(deadline: deadline, now: environment.usageDeadlineNow, alignment: .trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } +} diff --git a/Sources/TokenMenuBarUI/Views/ProviderMarkView.swift b/Sources/TokenMenuBarUI/Views/ProviderMarkView.swift new file mode 100644 index 0000000..ac0ca54 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/ProviderMarkView.swift @@ -0,0 +1,54 @@ +import SwiftUI +import TokenMenuBarCore + +public struct ProviderMarkView: View { + public static let defaultSize = CGSize(width: 48, height: 18) + + public let provider: ProviderID + public let size: CGSize + + @Environment(\.colorScheme) private var colorScheme + + public init(_ provider: ProviderID, size: CGSize = defaultSize) { + self.provider = provider + self.size = size + } + + public var body: some View { + let appearance = ProviderMarkAppearance(colorScheme) + let descriptor = ProviderMarkCatalog.descriptor(for: provider, appearance: appearance) + ZStack { + RoundedRectangle(cornerRadius: min(5, size.height * 0.28), style: .continuous) + .fill(Color(descriptor.backgroundColor)) + if let image = ProviderMarkImageLoader.shared.image(for: provider, appearance: appearance) { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .padding(max(2, size.height * 0.18)) + } else { + Text(descriptor.fallbackText) + .font(.system(size: min(11, size.height * 0.55), weight: .semibold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.7) + .foregroundStyle(Color(descriptor.foregroundColor)) + .padding(.horizontal, max(2, size.width * 0.08)) + } + } + .frame(width: size.width, height: size.height) + .accessibilityElement(children: .ignore) + .accessibilityLabel(descriptor.accessibilityLabel) + } +} + +extension Color { + fileprivate init(_ color: BrandColor) { + self.init(red: color.red, green: color.green, blue: color.blue) + } +} + +extension ProviderMarkAppearance { + fileprivate init(_ colorScheme: ColorScheme) { + self = colorScheme == .dark ? .dark : .light + } +} diff --git a/Sources/TokenMenuBarUI/Views/RootView.swift b/Sources/TokenMenuBarUI/Views/RootView.swift new file mode 100644 index 0000000..9f0e678 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/RootView.swift @@ -0,0 +1,132 @@ +import AppKit +import SwiftUI +import TokenMenuBarCore + +public struct RootView: View { + @Bindable var environment: UIEnvironment + public let onMeasure: (PopoverMeasurement) -> Void + public let onTabChange: ((PopoverTab) -> Void)? + public let chooseHistoryExportURL: @MainActor () -> URL? + + public init( + environment: UIEnvironment, onMeasure: @escaping (PopoverMeasurement) -> Void, + onTabChange: ((PopoverTab) -> Void)? = nil, + chooseHistoryExportURL: @escaping @MainActor () -> URL? = { nil } + ) { + self.environment = environment + self.onMeasure = onMeasure + self.onTabChange = onTabChange + self.chooseHistoryExportURL = chooseHistoryExportURL + } + + public var body: some View { + VStack(spacing: 0) { + tabBar + PersistentTabContent( + environment: environment, + selection: environment.settings.lastTab, + chooseHistoryExportURL: chooseHistoryExportURL, + onMeasure: measured, + onPresent: environment.completeTabTransition + ) + .preference(key: SettingsContentReadyKey.self, value: true) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + PopoverFooter(environment: environment) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("popover-surface") + .panelSurface(.content) + .font(.body) + .task(id: usageClockSchedule) { await advanceUsageClock(usageClockSchedule) } + .task(id: usageSampleSchedule) { await prepareUsage(usageSampleSchedule) } + } + + private var tabBar: some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + TabPicker(selection: Binding(get: { environment.settings.lastTab }, set: { select($0) })) + .fixedSize() + Spacer(minLength: 0) + } + .frame(height: PopoverGeometry.tabBarHeight) + .panelSurface(.content) + } + + private var usageClockSchedule: UsageClockSchedule { + let visible = environment.state.popoverVisible && environment.settings.lastTab == .usage + return UsageClockSchedule( + visible: visible, + deadline: visible ? environment.nextUsageDeadline() : nil) + } + + private var usageSampleSchedule: UsageSampleSchedule { + UsageSampleSchedule( + visible: environment.state.popoverVisible && environment.settings.lastTab == .usage, + revision: environment.state.sampleRevision) + } + + private func prepareUsage(_ schedule: UsageSampleSchedule) async { + guard schedule.visible else { return } + await environment.prepareUsage() + } + + private func advanceUsageClock(_ schedule: UsageClockSchedule) async { + guard schedule.visible, let deadline = schedule.deadline else { return } + do { + try await environment.clock.sleep(max(deadline.timeIntervalSince(environment.clock.now()), 0)) + } catch { + return + } + guard !Task.isCancelled, environment.state.popoverVisible else { return } + environment.advanceUsageDeadlines(to: environment.clock.now()) + } + + func measured(_ measurement: PopoverMeasurement) { + guard measurement.size != .zero else { return } + let measurement = PopoverMeasurement( + tab: measurement.tab, + size: CGSize( + width: measurement.size.width, + height: measurement.size.height + PopoverGeometry.tabBarHeight + PopoverGeometry.footerHeight)) + environment.log.detailed( + .tab( + TabDiagnostic( + action: .measurement, + sourceTab: measurement.tab.rawValue, + activeTab: environment.settings.lastTab.rawValue, + filedUnderTab: measurement.tab.rawValue, + size: DiagnosticSize(measurement.size), + chromeHeight: PopoverGeometry.tabBarHeight + PopoverGeometry.footerHeight))) + DiagnosticSignposts.tabs.withInterval("Tab measurement") { onMeasure(measurement) } + } + + func select(_ tab: PopoverTab) { + let previous = environment.settings.lastTab + guard tab != previous else { return } + environment.beginTabTransition(to: tab) + environment.log.detailed( + .tab( + TabDiagnostic( + action: .transition, + from: previous.rawValue, + to: tab.rawValue, + activeTab: previous.rawValue))) + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + environment.settings.lastTab = tab + onTabChange?(tab) + } + } +} + +private struct UsageClockSchedule: Equatable { + let visible: Bool + let deadline: Date? +} + +private struct UsageSampleSchedule: Equatable { + let visible: Bool + let revision: UInt64 +} diff --git a/Sources/TokenMenuBarUI/Views/ScrollerStyler.swift b/Sources/TokenMenuBarUI/Views/ScrollerStyler.swift new file mode 100644 index 0000000..e73af4a --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/ScrollerStyler.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI +import TokenMenuBarCore + +public struct ScrollerStyler: NSViewRepresentable { + public init() {} + + public func makeNSView(context: Context) -> NSView { + let view = ProbeView(frame: .zero) + DispatchQueue.main.async { Self.apply(from: view) } + return view + } + + public func updateNSView(_ view: NSView, context: Context) { + Self.applyEnclosing(from: view) + } + + static func apply(from view: NSView) { + applyEnclosing(from: view) + } + + static func applyEnclosing(from view: NSView) { + guard let scrollView = view.enclosingScrollView else { return } + apply(to: scrollView) + } + + static func apply(to scrollView: NSScrollView) { + scrollView.scrollerStyle = .overlay + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.horizontalScrollElasticity = .none + } + + @MainActor final class ProbeView: NSView { + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + ScrollerStyler.applyEnclosing(from: self) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + ScrollerStyler.applyEnclosing(from: self) + DispatchQueue.main.async { ScrollerStyler.apply(from: self) } + } + } +} diff --git a/Sources/TokenMenuBarUI/Views/ScrollingTab.swift b/Sources/TokenMenuBarUI/Views/ScrollingTab.swift new file mode 100644 index 0000000..1ca0d10 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/ScrollingTab.swift @@ -0,0 +1,60 @@ +import AppKit +import SwiftUI +import TokenMenuBarCore + +public struct ScrollingTab: View { + let tab: PopoverTab + let content: Content + let measurementHeight: CGFloat? + @State private var measuredSize = CGSize.zero + + public init(tab: PopoverTab, measurementHeight: CGFloat? = nil, @ViewBuilder content: () -> Content) { + self.tab = tab + self.measurementHeight = measurementHeight + self.content = content() + } + + public var body: some View { + ScrollView(.vertical) { + content + .padding(PopoverGeometry.contentPadding) + .background(ScrollerStyler()) + .modifier(ContentMeasurement(tab: tab, fixedHeight: measurementHeight, measuredSize: $measuredSize)) + } + .frame(minHeight: 200) + } +} + +private struct ContentMeasurement: ViewModifier { + let tab: PopoverTab + let fixedHeight: CGFloat? + @Binding var measuredSize: CGSize + + func body(content: Content) -> some View { + if let fixedHeight { + content.preference( + key: PopoverMeasurementKey.self, + value: PopoverMeasurement( + tab: tab, + size: CGSize(width: PopoverGeometry.stableTabWidth, height: fixedHeight))) + } else { + content + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { size in + measuredSize = size + } + .preference( + key: PopoverMeasurementKey.self, + value: measuredSize == .zero ? nil : PopoverMeasurement(tab: tab, size: measuredSize)) + } + } +} + +public struct PopoverMeasurementKey: PreferenceKey { + public static let defaultValue: PopoverMeasurement? = nil + + public static func reduce(value: inout PopoverMeasurement?, nextValue: () -> PopoverMeasurement?) { + value = nextValue() ?? value + } +} diff --git a/Sources/TokenMenuBarUI/Views/SemanticStyles.swift b/Sources/TokenMenuBarUI/Views/SemanticStyles.swift new file mode 100644 index 0000000..1d50eb1 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/SemanticStyles.swift @@ -0,0 +1,64 @@ +import AppKit +import SwiftUI +import TokenMenuBarCore + +extension Color { + public init(_ role: SemanticColorRole) { + self.init(nsColor: SemanticColorPalette.color(for: role)) + } +} + +public enum SemanticColorPalette { + public static func color(for role: SemanticColorRole) -> NSColor { + switch role { + case .primary: .labelColor + case .secondary: + NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + ? NSColor(srgbRed: 0.78, green: 0.78, blue: 0.8, alpha: 1) + : NSColor(srgbRed: 0.3, green: 0.3, blue: 0.32, alpha: 1) + } + case .tertiary: + NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + ? NSColor(srgbRed: 0.68, green: 0.68, blue: 0.71, alpha: 1) + : NSColor(srgbRed: 0.36, green: 0.36, blue: 0.38, alpha: 1) + } + case .accent: .controlAccentColor + case .warning: + NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + ? NSColor(srgbRed: 1, green: 0.71, blue: 0.35, alpha: 1) + : NSColor(srgbRed: 0.45, green: 0.22, blue: 0, alpha: 1) + } + case .destructive: + NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + ? NSColor(srgbRed: 1, green: 0.61, blue: 0.59, alpha: 1) + : NSColor(srgbRed: 0.58, green: 0.04, blue: 0.1, alpha: 1) + } + } + } +} + +extension View { + public func semanticForeground(_ role: SemanticColorRole) -> some View { + foregroundStyle(Color(role)) + } + + public func semanticControl(_ intent: ControlIntent, selected: Bool = false) -> some View { + modifier(SemanticControlModifier(intent: intent, selected: selected)) + } +} + +private struct SemanticControlModifier: ViewModifier { + let intent: ControlIntent + let selected: Bool + + func body(content: Content) -> some View { + let appearance = InterfaceTokens.standard.controls.appearance(for: intent, selected: selected) + content + .foregroundStyle(Color(appearance.foreground)) + .tint(appearance.tint.map(Color.init)) + } +} diff --git a/Sources/TokenMenuBarUI/Views/SettingsTab.swift b/Sources/TokenMenuBarUI/Views/SettingsTab.swift new file mode 100644 index 0000000..2532996 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/SettingsTab.swift @@ -0,0 +1,1089 @@ +import AppKit +import SwiftUI +import TokenMenuBarCore + +public struct SettingsTab: View { + @Bindable var environment: UIEnvironment + public let providerFocusRequest: ProviderSettingsFocusRequest? + @State private var confirmClear = false + @State private var confirmResetAll = false + @State private var highlightedModel: WindowKey? + @State private var focusRequest: SettingsModelFocusRequest? + @State private var labelDrafts: [WindowKey: String] = [:] + @State private var mountedSections: Set + @State private var modelsMounted: Bool + @State private var measurementHeight: CGFloat + @FocusState private var providerFocus: ProviderID? + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + private let mountsIncrementally: Bool + + public init( + environment: UIEnvironment, providerFocusRequest: ProviderSettingsFocusRequest? = nil, + mountsIncrementally: Bool = true + ) { + self.environment = environment + self.providerFocusRequest = providerFocusRequest + self.mountsIncrementally = mountsIncrementally + let mountedSections: Set = mountsIncrementally ? [.about] : Set(SettingsSection.allCases) + let modelsMounted = !mountsIncrementally + _mountedSections = State(initialValue: mountedSections) + _modelsMounted = State(initialValue: modelsMounted) + _measurementHeight = State( + initialValue: PopoverGeometry.settingsHeight( + Self.heightInput(environment: environment, mountedSections: mountedSections, modelsMounted: modelsMounted))) + } + + var settings: TokenMenuBarCore.Settings { environment.settings } + private var contentMounted: Bool { + modelsMounted && mountedSections.count == SettingsSection.allCases.count + } + public var body: some View { + ScrollViewReader { scroll in + ScrollingTab( + tab: .settings, + measurementHeight: measurementHeight + ) { + VStack(alignment: .leading, spacing: 12) { + ForEach(SettingsSection.allCases, id: \.self) { scope in + section(scope) { sectionContent(scope, scroll: scroll) } + .id( + scope == .providers + ? AnyHashable(SettingsFocusTarget.providers) : AnyHashable(scope)) + } + HStack { + Spacer() + resetDefaultsButton + } + .padding(.top, 2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .controlSize(dynamicTypeSize.isAccessibilitySize ? .regular : .small) + } + .preference(key: SettingsContentReadyKey.self, value: contentMounted) + .onAppear { focusProvider(providerFocusRequest, scroll: scroll) } + .onChange(of: providerFocusRequest) { _, request in focusProvider(request, scroll: scroll) } + .onChange(of: mountedSections.contains(.providers)) { _, ready in + if ready { focusProvider(providerFocusRequest, scroll: scroll) } + } + .onChange(of: heightInput) { _, input in measurementHeight = PopoverGeometry.settingsHeight(input) } + .task { + if mountsIncrementally { await mountDeferredContent() } + } + } + .alert("Reset all settings?", isPresented: $confirmResetAll) { + resetAlertActions + } message: { + Text( + "This restores all stored settings, including model selection, short labels, provider setup, and hidden series." + ) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(contentMounted ? "settings-content-ready" : "settings-content-loading") + } + + var resetAlertActions: some View { + Group { + Button("Cancel", role: .cancel, action: cancelResetAction) + .accessibilityHint("Keeps all current settings") + Button("Reset All Settings", role: .destructive, action: resetAllSettingsAction) + .accessibilityHint("Restores defaults in all six sections") + } + } + + var cancelResetAction: () -> Void { + { cancelResetDefaults() } + } + + var resetAllSettingsAction: () -> Void { + { resetDefaults() } + } + + var heightInput: SettingsHeightInput { + Self.heightInput(environment: environment, mountedSections: mountedSections, modelsMounted: modelsMounted) + } + + private static func heightInput( + environment: UIEnvironment, mountedSections: Set, modelsMounted: Bool + ) -> SettingsHeightInput { + let snapshots = environment.state.snapshots + let modelCount = + modelsMounted + ? snapshots.values.reduce(into: 0) { count, snapshot in + count += snapshot.windows.count { !environment.settings.hideUnusedModels || $0.usedPercent > 0 } + } : 0 + let providerCount = + mountedSections.contains(.providers) + ? ProviderSettingsVisibility.providers( + states: environment.state.providers, configured: environment.settings.configuredProviderSettings, + showAll: environment.settings.showAllProviders, revealed: environment.providerFocusRequest?.provider + ).count + : Set(snapshots.keys).count + return SettingsHeightInput( + mountedSections: mountedSections, + showsModelFilter: modelsMounted, + providerCount: providerCount, + modelCount: modelCount, + logLineCount: mountedSections.contains(.log) ? min(environment.log.snapshot.count, 200) : 0, + showsCustomTemplate: environment.settings.statusFormat == .custom, + showsUpdates: environment.canCheckForUpdates) + } + + func focusProvider(_ request: ProviderSettingsFocusRequest?, scroll: ScrollViewProxy) { + guard let request else { return } + guard !mountsIncrementally || mountedSections.contains(.providers) else { return } + let provider = request.provider + let target: SettingsFocusTarget = if let provider { .provider(provider) } else { .providers } + withAnimation(.easeOut(duration: 0.12)) { + scroll.scrollTo(target, anchor: .center) + } + providerFocus = provider + if environment.providerFocusRequest?.id == request.id { environment.providerFocusRequest = nil } + } + + func section(_ scope: SettingsSection, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 10) { + SectionLabel(scope.title) + } + .accessibilityIdentifier("settings-section-\(scope.rawValue)") + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + .padding(.bottom, 4) + .overlay(alignment: .bottom) { Divider() } + } + + @ViewBuilder func sectionContent(_ scope: SettingsSection, scroll: ScrollViewProxy) -> some View { + if scope != .about, !mountedSections.contains(scope) { + sectionPlaceholder(scope) + } else { + switch scope { + case .about: about + case .menuBar: menuBar(scroll) + case .providers: providers + case .data: data + case .notifications: notifications + case .log: LogSection(environment: environment) + } + } + } + + private func sectionPlaceholder(_ scope: SettingsSection) -> some View { + Color.clear + .frame(height: placeholderHeight(scope)) + .accessibilityHidden(true) + } + + private func placeholderHeight(_ scope: SettingsSection) -> CGFloat { + switch scope { + case .about: 180 + case .menuBar: 620 + case .providers: 420 + case .data, .notifications: 120 + case .log: 260 + } + } + + private func mountDeferredContent() async { + await Task.yield() + guard !Task.isCancelled else { return } + mountedSections = Set(SettingsSection.allCases) + modelsMounted = true + } + + public func setting(_ keyPath: ReferenceWritableKeyPath) -> Binding { + Binding(get: { settings[keyPath: keyPath] }, set: { settings[keyPath: keyPath] = $0 }) + } + + public func menuBarSetting( + _ keyPath: ReferenceWritableKeyPath + ) -> Binding { + Binding(get: { settings[keyPath: keyPath] }, set: { update(keyPath, to: $0) }) + } + + func update(_ keyPath: ReferenceWritableKeyPath, to value: Value) { + settings[keyPath: keyPath] = value + environment.actions.settingsChanged() + } + + public func resetDefaults() { + settings.resetToDefaults() + labelDrafts.removeAll() + environment.actions.settingsReset() + environment.actions.settingsChanged() + } + + func requestResetDefaults() { + confirmResetAll = true + } + + func cancelResetDefaults() { + confirmResetAll = false + } + + func requestClearHistory() { + confirmClear = true + } + + func clearHistory() { + environment.actions.clearHistory() + } + + func setLaunchAtLogin(_ enabled: Bool) { + environment.actions.setLaunchAtLogin(enabled) + } + + public func setProvider(_ provider: ProviderID, enabled: Bool) { + settings.setProvider(provider, enabled: enabled) + environment.actions.settingsChanged() + } + + public func setThreshold(_ threshold: Int, on: Bool) { + var thresholds = Set(settings.notifications.thresholds) + if on { thresholds.insert(threshold) } else { thresholds.remove(threshold) } + settings.notifications = NotificationSettings( + enabled: settings.notifications.enabled, thresholds: Array(thresholds), + notifyOnReset: settings.notifications.notifyOnReset, + notifyOnAuthProblems: settings.notifications.notifyOnAuthProblems) + } + + public func threshold(_ threshold: Int) -> Binding { + Binding(get: { settings.notifications.thresholds.contains(threshold) }, set: { setThreshold(threshold, on: $0) }) + } + + public func refreshMinutes(_ provider: ProviderID) -> Binding { + Binding( + get: { settings.refreshInterval(for: provider) / 60 }, + set: { settings.setRefreshInterval($0 * 60, for: provider) }) + } + + public var historyRetentionDays: Binding { + Binding( + get: { settings.historyRetentionDays }, + set: { + settings.historyRetentionDays = $0 + environment.actions.settingsChanged() + }) + } + + public func missingAccess(_ provider: ProviderID) -> [SandboxResource] { + settings.missingAccess(for: provider) + } + + public func openRepository() { + environment.actions.openURL(environment.appInfo.repository) + } + + public func grantAccess(_ resource: SandboxResource) { + environment.actions.grantAccess(resource) + } + + func resourceGrantAction(_ resource: SandboxResource) -> () -> Void { + { grantAccess(resource) } + } + + public func credentialText(_ provider: ProviderID) -> String { + let state = environment.state.state(for: provider) + switch state.credentialHealth { + case .unchecked: + return state.credentialState?.isMissing == true ? "Not found" : credentialLocation(provider) ?? "Not checked yet" + case .missing: + return "Not found" + case .valid(let source, let expiresAt): + return uniqueCredentialParts( + [source.title, source.detail] + + [expiresAt.map { "expires \($0.formatted(date: .abbreviated, time: .shortened))" }]) + case .expired(let source, let date): + return uniqueCredentialParts([ + source.title, source.detail, + "expired \(date.formatted(date: .abbreviated, time: .omitted))", + ]) + case .unreadable(let source, let detail): + return uniqueCredentialParts( + [source?.title, source?.detail, source == nil ? credentialLocation(provider) : nil, "unreadable: \(detail)"]) + } + } + + func credentialLocation(_ provider: ProviderID) -> String? { + guard + let location = environment.credentialDescriptions[provider]?.trimmingCharacters(in: .whitespacesAndNewlines), + !location.isEmpty + else { return nil } + return location + } + + private func uniqueCredentialParts(_ values: [String?]) -> String { + var seen: Set = [] + return values.compactMap { value in + guard let value, seen.insert(value).inserted else { return nil } + return value + } + .joined(separator: " · ") + } + + public func authenticationHint(_ provider: ProviderID) -> String? { + guard settings.isProviderActive(provider, state: environment.state.providers[provider]) else { return nil } + let state = environment.state.state(for: provider) + if let issue = state.recoveryIssue { return issue.detail } + return state.availability == .authenticationRequired ? provider.loginHint : nil + } + + func recoveryIssue(_ provider: ProviderID) -> ProviderRecoveryIssue? { + let state = environment.state.state(for: provider) + if let issue = state.recoveryIssue { return issue } + if let issue = ProviderSetupState.from( + provider: provider, enabled: true, credential: state.credentialHealth, + resources: resourceStates(provider) + ).issue { + return issue + } + return state.availability == .authenticationRequired ? provider.setup.missingCredentialIssue : nil + } + + func resourceStates(_ provider: ProviderID) -> [ResourceAccessState] { + let current = environment.state.state(for: provider).resourceAccess + if !current.isEmpty { return current } + return provider.sandboxResources.map { resource in + ResourceAccessState(resource: resource, health: settings.bookmark(for: resource) == nil ? .needed : .granted) + } + } + + func perform(_ action: ProviderRecoveryAction, provider: ProviderID, detail: String) { + switch action { + case .copyCommand(let command): environment.actions.copy(command) + case .checkAgain: environment.actions.refreshProvider(provider) + case .refreshProvider(let provider): environment.actions.refreshProvider(provider) + case .grantAccess(let resource): grantAccess(resource) + case .openLoginItems: environment.actions.openLoginItems() + case .contactAdministrator: environment.actions.copy(detail) + } + } + + public func provider(_ provider: ProviderID) -> Binding { + Binding( + get: { settings.isProviderActive(provider, state: environment.state.providers[provider]) }, + set: { setProvider(provider, enabled: $0) }) + } + + var selection: [WindowKey] { + settings.hasCustomSelection + ? settings.selectedWindows : StatusItemBuilder.defaultSelection(environment.state.snapshots) + } + + var previewModel: StatusItemModel { + let activeProviders = settings.activeProviders(states: environment.state.providers) + let snapshots = environment.state.snapshots.filter { activeProviders.contains($0.key) } + let available = snapshots.keys.sorted().flatMap { provider in + snapshots[provider]!.windows.map { (WindowKey(provider, $0), $0) } + } + let order = SettingsOrderDraft( + providers: settings.providerOrder, models: settings.modelOrder, available: available.map(\.0)) + let windows = Dictionary(uniqueKeysWithValues: available) + let labels = ShortLabelPolicy.validOverrides( + windows: windows, persisted: settings.shortLabels, drafts: labelDrafts) + return StatusItemBuilder.build( + StatusItemInput( + snapshots: snapshots, + availability: environment.state.availability.filter { activeProviders.contains($0.key) }, + selectedKeys: order.orderedSelection(selection), format: settings.statusFormat, + customTemplate: settings.customTemplate, decimals: settings.percentDecimals, + hideZeroCells: settings.hideZeroCells, order: settings.windowOrder, + labels: labels, now: environment.now)) + } + + private var about: some View { + VStack(alignment: .leading, spacing: 7) { + PanelRow("Version") { versionSummary } + PanelRow("Startup") { + VStack(alignment: .leading, spacing: 4) { + WrappingHStack(horizontalSpacing: 8, verticalSpacing: 6) { + launchAtLoginToggle + openLoginItemsButton + } + launchAtLoginExplanation + } + } + if environment.canCheckForUpdates { + PanelRow("Updates") { + WrappingHStack(horizontalSpacing: 8, verticalSpacing: 6) { + Toggle("Check for updates automatically", isOn: menuBarSetting(\.automaticUpdates)) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Automatic updates", + body: + "Checks the direct-download release feed in the background. " + + "When off, Token Menu Bar checks only when you choose Check Now." + )) + NativeActionButton("Check Now", action: environment.actions.checkForUpdates) + .richHelp( + TooltipContent( + title: "Check Now", + body: + "Checks the direct-download release feed now. " + + "The check does not change your automatic-update setting." + )) + } + } + } + PanelRow("Diagnostics") { + WrappingHStack(horizontalSpacing: 8, verticalSpacing: 6) { + NativeActionButton("Copy Diagnostics", action: environment.actions.copyDiagnostics) + .richHelp( + TooltipContent( + title: "Copy Diagnostics", + body: + "Copies the version, build channel, provider auth and refresh state, and recent log. " + + "Credentials and tokens are excluded." + )) + NativeActionButton("Report Issue", action: environment.actions.reportIssue) + .richHelp( + TooltipContent( + title: "Report Issue", + body: "Opens a new issue with the diagnostic summary prefilled. Review the text before submitting it.")) + NativeActionButton("Source", action: openRepository) + .richHelp( + TooltipContent( + title: "Source", + body: "Opens the Token Menu Bar source repository in your default browser.")) + } + } + } + } + + private var versionSummary: some View { + HStack(spacing: 6) { + Text("\(environment.appInfo.sourceVersion) (\(environment.appInfo.build))") + Text(environment.appInfo.distribution.displayName).semanticForeground(.secondary) + } + .fixedSize(horizontal: false, vertical: true) + } + + private var resetDefaultsButton: some View { + NativeActionButton("Reset All Settings", action: requestResetDefaults) + .richHelp( + TooltipContent( + title: "Reset All Settings", + body: + "Restores the stored values in all six sections, including model selection, short labels, " + + "and provider setup. A confirmation appears before any values change." + )) + } + + private var launchAtLoginToggle: some View { + Toggle( + "Launch at login", + isOn: launchAtLoginBinding + ) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Launch at login", + body: "Registers Token Menu Bar as a macOS login item. When off, open the app yourself after signing in.")) + } + + var launchAtLoginBinding: Binding { + Binding(get: { environment.launchAtLoginStatus.isEnabled }, set: { setLaunchAtLogin($0) }) + } + + private var openLoginItemsButton: some View { + NativeActionButton("Open Login Items", action: environment.actions.openLoginItems) + .richHelp( + TooltipContent( + title: "Open Login Items", + body: "Opens macOS Login Items, where you can allow or block Token Menu Bar at the system level.")) + } + + @ViewBuilder private var launchAtLoginExplanation: some View { + if let explanation = environment.launchAtLoginStatus.explanation { + Text(explanation) + .font(.caption) + .semanticForeground(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func menuBar(_ scroll: ScrollViewProxy) -> some View { + VStack(alignment: .leading, spacing: 7) { + VStack(alignment: .leading, spacing: 7) { + PanelRow("Order") { + ResponsivePanelLayout { + HStack(spacing: 12) { + orderPicker + Text("Format").semanticForeground(.secondary) + formatPicker + Spacer() + decimalsStepper + } + .frame(minWidth: 610) + } narrow: { + VStack(alignment: .leading, spacing: 7) { + orderPicker + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Format").semanticForeground(.secondary) + formatPicker + } + decimalsStepper + } + } + } + PanelRow("Options") { + WrappingHStack(horizontalSpacing: 16, verticalSpacing: 6) { + Toggle("Hide 0%", isOn: menuBarSetting(\.hideZeroCells)) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Hide 0%", + body: + "Removes zero-usage models from the menu bar to save width. " + + "Their data and Usage rows remain available." + )) + Toggle("Fit to space", isOn: menuBarSetting(\.adaptiveWidth)) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Fit to space", + body: + "Uses shorter status renderings when menu bar room runs low. " + + "When off, macOS may truncate a wide status item." + )) + } + } + if settings.statusFormat == .custom { + PanelRow("Template") { + VStack(alignment: .leading, spacing: 3) { + TextField("Template", text: menuBarSetting(\.customTemplate)) + .font(.body.monospaced()) + .richHelp( + TooltipContent( + title: "Custom template", + body: + "Builds each status cell from the listed tokens. " + + "The {label} token uses the short label beside each model; unknown tokens render no text." + )) + Text("{cell} {pct} {label} {provider} {window} {reset}") + .font(.caption.monospaced()) + .semanticForeground(.secondary) + } + } + } + PanelRow("Preview") { + StatusPreview(model: previewModel, highlightedKey: $highlightedModel) { key in + focusRequest = SettingsModelFocusRequest(key: key) + Task { @MainActor in + await Task.yield() + withAnimation(.easeOut(duration: 0.12)) { scroll.scrollTo(key, anchor: .center) } + } + } + } + } + WindowSelectionList( + environment: environment, highlightedKey: $highlightedModel, labelDrafts: $labelDrafts, + focusRequest: focusRequest) + } + } + + private var orderPicker: some View { + NativeSegmentedControl( + [(value: WindowOrder.provider, label: "Stable"), (value: .percent, label: "Usage")], + selection: menuBarSetting(\.windowOrder), + accessibilityLabel: "Order" + ) + .fixedSize() + .richHelp( + TooltipContent( + title: "Model order", + body: + "Stable uses the provider and model order set below. " + + "Usage sorts current percentages from highest to lowest; drag ordering has no effect in that mode." + )) + } + + private var formatPicker: some View { + NativeSegmentedControl( + StatusFormat.allCases.map { (value: $0, label: $0.rawValue) }, + selection: menuBarSetting(\.statusFormat), + accessibilityLabel: "Format" + ) + .fixedSize() + .richHelp( + TooltipContent( + title: "Format", + body: + "Stacked places the percentage under its label. Inline keeps both on one line. " + + "Mini bars use compact gauges. Custom uses the template and per-model short labels." + )) + } + + private var decimalsStepper: some View { + Stepper("Decimals: \(settings.percentDecimals)", value: menuBarSetting(\.percentDecimals), in: 0...2) + .richHelp( + TooltipContent( + title: "Percentage decimals", + body: "Shows zero, one, or two decimal places in the menu bar. More precision uses more menu bar width.")) + } + + private var providers: some View { + VStack(alignment: .leading, spacing: 7) { + Toggle("Show all providers", isOn: settingWithoutRefresh(\.showAllProviders)) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Show all providers", + body: + "Reveals providers that have no discovered credentials or cached data so you can set them up. " + + "Turning it off keeps configured providers visible." + )) + if visibleProviders.isEmpty { + EmptyStateView( + title: "No providers discovered", systemImage: "person.crop.circle.badge.questionmark", + description: "Select Show all providers to set up a provider on this Mac.") + } else { + ForEach(visibleProviders, id: \.self) { provider in providerRow(provider) } + } + ResponsivePanelLayout { + HStack { + tokenRefreshToggle + Spacer() + tokenRefreshFloor + } + .frame(minWidth: 600) + } narrow: { + VStack(alignment: .leading, spacing: 4) { + tokenRefreshToggle + tokenRefreshFloor + } + } + Text("Token refresh supports Claude, Codex, and Gemini and writes rotated tokens to their credential stores.") + .font(.caption) + .semanticForeground(.secondary) + } + } + + var visibleProviders: [ProviderID] { + ProviderSettingsVisibility.providers( + states: environment.state.providers, configured: settings.configuredProviderSettings, + showAll: settings.showAllProviders, revealed: providerFocusRequest?.provider) + } + + func settingWithoutRefresh( + _ keyPath: ReferenceWritableKeyPath + ) -> Binding { + Binding(get: { settings[keyPath: keyPath] }, set: { settings[keyPath: keyPath] = $0 }) + } + + private var tokenRefreshToggle: some View { + Toggle("Refresh expired Claude, Codex, and Gemini tokens on my behalf", isOn: setting(\.allowTokenRefresh)) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Token refresh", + body: + "Rotates supported Claude, Codex, and Gemini refresh tokens and writes them to the credential " + + "file or Keychain. When off, an expired provider stops updating until you sign in again." + )) + } + + private var tokenRefreshFloor: some View { + Text("Floors: Claude 2 min, others 1 min; the panel polls at the floor while open.") + .font(.caption) + .semanticForeground(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + func providerRow(_ providerID: ProviderID) -> some View { + let state = environment.state.state(for: providerID) + let presentation = SettingsProviderPresentation(state: state, now: environment.now) + let issue = recoveryIssue(providerID) + return VStack(alignment: .leading, spacing: 3) { + ResponsivePanelLayout { + HStack(spacing: 8) { + providerControl(providerID) + Text(providerAvailabilityText(providerID)).font(.caption).semanticForeground(.secondary) + Spacer() + providerRefreshStepper(providerID) + } + .frame(minWidth: 560) + } narrow: { + VStack(alignment: .leading, spacing: 5) { + providerControl(providerID) + Text(providerAvailabilityText(providerID)).font(.caption).semanticForeground(.secondary) + providerRefreshStepper(providerID) + } + } + ResponsivePanelLayout { + HStack(alignment: .bottom, spacing: 8) { + providerDetails(providerID, presentation: presentation, issue: issue) + Spacer(minLength: 8) + providerRecoveryButton(actionableRecoveryIssue(providerID), provider: providerID) + } + .frame(minWidth: 520) + } narrow: { + VStack(alignment: .leading, spacing: 5) { + providerDetails(providerID, presentation: presentation, issue: issue) + providerRecoveryButton(actionableRecoveryIssue(providerID), provider: providerID) + } + } + .padding(.leading, 30) + if environment.isSandboxed { + ForEach(visibleResourceStates(providerID)) { access in + providerResourceRow(access, provider: providerID).padding(.leading, 30) + } + } + } + .id(SettingsFocusTarget.provider(providerID)) + .accessibilityElement(children: .contain) + .accessibilityLabel("\(providerID.displayName) setup") + .accessibilityValue( + [ + providerAvailabilityText(providerID), presentation.identity, credentialText(providerID), + presentation.lastSuccess, + presentation.service, recoveryIssue(providerID)?.detail, + ] + .compactMap { $0 } + .joined(separator: ", ") + ) + } + + func providerAvailabilityText(_ provider: ProviderID) -> String { + if settings.providerOverride(for: provider) == false { return "Off" } + guard settings.isProviderActive(provider, state: environment.state.providers[provider]) else { + return "Not configured" + } + return environment.state.state(for: provider).availability.title + } + + private func providerControl(_ providerID: ProviderID) -> some View { + HStack(spacing: 8) { + ProviderMarkView(providerID, size: CGSize(width: 22, height: 18)).accessibilityHidden(true) + Toggle(providerID.displayName, isOn: provider(providerID)) + .toggleStyle(.checkbox) + .fixedSize(horizontal: false, vertical: true) + .richHelp( + TooltipContent( + title: "\(providerID.displayName) provider", + body: + "Discovered providers turn on automatically. Changing this checkbox creates an explicit polling " + + "override until Reset All Settings. Turning it off keeps stored history, account details, " + + "and provider settings." + ), + focus: $providerFocus, equals: providerID + ) + } + } + + private func providerRefreshStepper(_ providerID: ProviderID) -> some View { + Stepper( + "Every \(settings.refreshInterval(for: providerID) / 60) min", value: refreshMinutes(providerID), + in: Int(PollingPolicy.defaults(for: providerID).minimumInterval) / 60...TokenMenuBarCore.Settings + .maximumRefreshSeconds / 60 + ) + .richHelp( + TooltipContent( + title: "\(providerID.displayName) refresh interval", + body: + "Sets background usage polling. Shorter intervals use more network and CPU; while the panel is open, " + + "polling uses this provider's minimum interval." + )) + } + + private func providerDetails( + _ providerID: ProviderID, presentation: SettingsProviderPresentation, issue: ProviderRecoveryIssue? + ) -> some View { + VStack(alignment: .leading, spacing: 1) { + ResponsivePanelLayout { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("Authentication").fontWeight(.medium) + Text(credentialText(providerID)).fixedSize(horizontal: false, vertical: true) + } + } narrow: { + VStack(alignment: .leading, spacing: 1) { + Text("Authentication").fontWeight(.medium) + Text(credentialText(providerID)).fixedSize(horizontal: false, vertical: true) + } + } + .richHelp( + TooltipContent( + title: "Authentication source", + body: + "Shows the credential store or session used for \(providerID.displayName), including its safe local " + + "location when available. Tokens and account secrets never appear." + )) + Text( + [presentation.identity, presentation.lastSuccess, presentation.service] + .compactMap { $0 } + .joined(separator: " · ") + ) + if let issue { + Text(issue.title).fontWeight(.medium) + Text(issue.detail) + } + } + .font(.caption) + .semanticForeground(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder private func providerRecoveryButton(_ issue: ProviderRecoveryIssue?, provider: ProviderID) -> some View { + if let issue { + NativeActionButton(issue.action.title) { + perform(issue.action, provider: provider, detail: issue.detail) + } + .richHelp( + TooltipContent( + title: issue.title, + body: + "\(issue.detail) The action checks only \(provider.displayName); stored usage and the last successful " + + "snapshot remain available." + )) + } + } + + func actionableRecoveryIssue(_ provider: ProviderID) -> ProviderRecoveryIssue? { + guard settings.providerOverride(for: provider) != false else { return nil } + let setupIsVisible = settings.showAllProviders || providerFocusRequest?.provider == provider + if settings.isProviderActive(provider, state: environment.state.providers[provider]) { + return recoveryIssue(provider) + } + if setupIsVisible { return recoveryIssue(provider) } + return nil + } + + func visibleResourceStates(_ provider: ProviderID) -> [ResourceAccessState] { + resourceStates(provider).filter { $0.isRequired && $0.health != .notRequired } + } + + private func providerResourceRow(_ access: ResourceAccessState, provider: ProviderID) -> some View { + ResponsivePanelLayout { + HStack(spacing: 8) { + Text(access.resource.label).font(.caption) + Text(resourceText(access.health)).font(.caption).semanticForeground(.secondary) + Spacer(minLength: 8) + resourceGrantButton(access, provider: provider) + } + .frame(minWidth: 420) + } narrow: { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(access.resource.label).font(.caption) + Text(resourceText(access.health)).font(.caption).semanticForeground(.secondary) + } + resourceGrantButton(access, provider: provider) + } + } + } + + @ViewBuilder private func resourceGrantButton(_ access: ResourceAccessState, provider: ProviderID) -> some View { + if resourceNeedsGrant(access.health) { + NativeActionButton(resourceGrantTitle(access.health), action: resourceGrantAction(access.resource)) + .richHelp( + TooltipContent( + title: "Grant \(access.resource.label) access", + body: + "Opens a macOS file picker for this sandbox resource. " + + "Without access, \(provider.displayName) cannot read the local data it needs." + )) + } + } + + func resourceText(_ health: ResourceAccessHealth) -> String { + switch health { + case .notRequired: "Not required" + case .needed: "Needed" + case .granted: "Granted" + case .stale: "Stale" + case .error(let detail): "Error: \(detail)" + } + } + + func resourceNeedsGrant(_ health: ResourceAccessHealth) -> Bool { + switch health { + case .needed, .stale, .error: true + case .notRequired, .granted: false + } + } + + func resourceGrantTitle(_ health: ResourceAccessHealth) -> String { + switch health { + case .notRequired: "Not required" + case .needed: "Grant" + case .stale, .error: "Grant Again" + case .granted: "Granted" + } + } + + private var data: some View { + VStack(alignment: .leading, spacing: 7) { + PanelRow("Retention") { + Stepper("\(settings.historyRetentionDays) days", value: historyRetentionDays, in: 7...365) + .richHelp( + TooltipContent( + title: "History retention", + body: + "Keeps usage samples for 7 to 365 days. A longer period uses more disk space " + + "and makes older ranges available in History." + )) + } + PanelRow("Analytics") { + Stepper( + "Every \(settings.analyticsRefreshMinutes) min", value: setting(\.analyticsRefreshMinutes), + in: 5...120, step: 5 + ) + .richHelp( + TooltipContent( + title: "Analytics refresh interval", + body: + "Sets the separate clock for transcript and provider analytics. " + + "Shorter intervals use more disk, network, and CPU." + )) + } + PanelRow("History") { + ResponsivePanelLayout { + HStack(spacing: 8) { + historyPath + Spacer(minLength: 8) + historyActions + } + .frame(minWidth: 570) + } narrow: { + VStack(alignment: .leading, spacing: 6) { + historyPath + historyActions + } + } + } + } + } + + private var historyPath: some View { + HorizontallyScrollableText(environment.history.location?.path ?? "History kept in memory") + .frame(minWidth: 180, maxWidth: .infinity, minHeight: 18, maxHeight: 18) + .layoutPriority(-1) + .richHelp( + TooltipContent( + title: "History file", + body: + "Shows the complete path to the local history database. Scroll horizontally or select the text to copy it." + )) + } + + private var historyActions: some View { + WrappingHStack(horizontalSpacing: 8, verticalSpacing: 6) { + NativeActionButton("Open", action: environment.actions.revealHistory) + .richHelp( + TooltipContent( + title: "Open History", + body: "Reveals the history database in Finder. This does not stop collection or change the file.")) + NativeActionButton("Export…", action: environment.actions.exportHistory) + .richHelp( + TooltipContent( + title: "Export History", + body: "Writes stored usage history to a file you choose. Exporting keeps the database unchanged.")) + NativeActionButton("Clear…", intent: .destructive, action: requestClearHistory) + .richHelp( + TooltipContent( + title: "Clear History", + body: "Deletes stored usage samples after confirmation. Provider settings and current snapshots remain.") + ) + .confirmationDialog("Clear all stored history?", isPresented: $confirmClear) { + clearHistoryConfirmationAction + } + } + .fixedSize(horizontal: true, vertical: false) + } + + var clearHistoryConfirmationAction: some View { + Button("Clear History", role: .destructive, action: clearHistoryAction) + .accessibilityHint("Deletes stored usage samples and keeps provider settings") + } + + var clearHistoryAction: () -> Void { + { clearHistory() } + } + + private var notifications: some View { + VStack(alignment: .leading, spacing: 7) { + PanelRow("Notify at") { + WrappingHStack(horizontalSpacing: 12, verticalSpacing: 6) { + Toggle("Notifications", isOn: setting(\.notifications.enabled)) + .labelsHidden() + .toggleStyle(.checkbox) + .accessibilityLabel("Enable threshold notifications") + .richHelp( + TooltipContent( + title: "Usage notifications", + body: + "Allows the selected usage thresholds, reset notices, and sign-in notices. " + + "When off, Token Menu Bar sends no notifications." + )) + ForEach([50, 75, 90, 100], id: \.self) { value in + Toggle("\(value)%", isOn: threshold(value)) + .toggleStyle(.checkbox) + .disabled(!settings.notifications.enabled) + .richHelp( + TooltipContent( + title: "Notify at \(value)%", + body: + "Sends one notice when a usage window reaches \(value) percent. " + + "Disabled while usage notifications are off." + )) + } + } + } + PanelRow("") { + WrappingHStack(horizontalSpacing: 16, verticalSpacing: 6) { + Toggle("Window resets", isOn: setting(\.notifications.notifyOnReset)) + .toggleStyle(.checkbox) + .disabled(!settings.notifications.enabled) + .richHelp( + TooltipContent( + title: "Window resets", + body: "Sends a notice when a tracked usage window resets. Disabled while usage notifications are off.")) + Toggle("Sign-in needed", isOn: setting(\.notifications.notifyOnAuthProblems)) + .toggleStyle(.checkbox) + .disabled(!settings.notifications.enabled) + .richHelp( + TooltipContent( + title: "Sign-in needed", + body: "Sends a notice when a provider needs authentication. Disabled while usage notifications are off." + )) + } + } + } + } +} + +private struct HorizontallyScrollableText: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + ScrollView(.horizontal) { + Text(text) + .font(.system(size: NSFont.smallSystemFontSize, design: .monospaced)) + .semanticForeground(.secondary) + .fixedSize(horizontal: true, vertical: false) + .textSelection(.enabled) + } + .frame(height: 18) + .accessibilityLabel("History file") + .accessibilityValue(text) + } +} + +struct SettingsContentReadyKey: PreferenceKey { + static let defaultValue = false + + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = value || nextValue() + } +} + +private enum SettingsFocusTarget: Hashable { + case providers + case provider(ProviderID) +} diff --git a/Sources/TokenMenuBarUI/Views/SpendView.swift b/Sources/TokenMenuBarUI/Views/SpendView.swift new file mode 100644 index 0000000..c6c663b --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/SpendView.swift @@ -0,0 +1,44 @@ +import SwiftUI +import TokenMenuBarCore + +public struct SpendView: View { + public let presentation: UsageSpendPresentation + + public init(spend: SpendControl, provider: ProviderID, now: Date) { + presentation = UsagePresenter.spendPresentation(spend, provider: provider, now: now) + } + + public init(presentation: UsageSpendPresentation) { + self.presentation = presentation + } + + public var title: String { + presentation.title + } + + public var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(title).font(.callout.weight(.medium)) + Spacer() + // Red alone carried "limit reached", which neither VoiceOver nor a colour-blind reader picks up. + if presentation.spend.limitReached { + Image(systemName: "exclamationmark.octagon.fill").semanticForeground(.destructive).accessibilityHidden(true) + Text("Limit reached").font(.callout.weight(.medium)) + } + Text(presentation.summary) + .font(.callout.monospacedDigit()) + .semanticForeground(.primary) + } + .accessibilityElement(children: .combine) + if presentation.spend.enabled, let percent = presentation.spend.percent { + UsageBar(percent: percent, color: Color(UsageColor.color(percent: percent)), label: title) + } + 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) + } + } + } + } +} diff --git a/Sources/TokenMenuBarUI/Views/StatusPreview.swift b/Sources/TokenMenuBarUI/Views/StatusPreview.swift new file mode 100644 index 0000000..5f88f1d --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/StatusPreview.swift @@ -0,0 +1,131 @@ +import AppKit +import SwiftUI +import TokenMenuBarCore + +public struct StatusPreview: View { + public let model: StatusItemModel + @Binding private var highlightedKey: WindowKey? + private let select: (WindowKey) -> Void + @Environment(\.colorScheme) private var colorScheme + + public init( + model: StatusItemModel, highlightedKey: Binding = .constant(nil), + select: @escaping (WindowKey) -> Void = { _ in } + ) { + self.model = model + _highlightedKey = highlightedKey + self.select = select + } + + public var body: some View { + Group { + if !model.showsIcon { + WrappingHStack(horizontalSpacing: 6, verticalSpacing: 4) { + ForEach(model.cells) { cell in preview(cell) } + } + } else { + HStack(spacing: 0) { + Image(nsImage: StatusItemRenderer.previewImage(for: model, height: 24, dark: colorScheme == .dark)) + .accessibilityLabel("Menu bar preview") + .accessibilityValue(StatusItemRenderer.accessibilityDescription(for: model)) + Spacer(minLength: 0) + } + } + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + colorScheme == .dark ? Color.black.opacity(0.5) : Color.white.opacity(0.7), + in: RoundedRectangle(cornerRadius: 6) + ) + } + + @ViewBuilder + func preview(_ cell: StatusCell) -> some View { + if let key = WindowKey(storageKey: cell.id) { + let help = + "Shows the status cell from the same model used by the menu bar. " + + "Select it to scroll to and focus its model row." + StatusPreviewCellButton( + image: StatusItemRenderer.cellImage(cell, height: 24, dark: colorScheme == .dark), + accessibilityLabel: cell.tooltip, + accessibilityHelp: help, + selected: highlightedKey == key + ) { + highlightedKey = key + select(key) + } + .richHelp( + TooltipContent( + title: "Preview \(cell.tooltip)", + body: help) + ) + .onHover { inside in + if inside { highlightedKey = key } else if highlightedKey == key { highlightedKey = nil } + } + } else { + Image(nsImage: StatusItemRenderer.cellImage(cell, height: 24, dark: colorScheme == .dark)) + .accessibilityLabel(cell.tooltip) + } + } +} + +private struct StatusPreviewCellButton: NSViewRepresentable { + let image: NSImage + let accessibilityLabel: String + let accessibilityHelp: String + let selected: Bool + let action: () -> Void + + func makeNSView(context: Context) -> NSButton { + let button = StatusPreviewButton() + button.target = context.coordinator + button.action = #selector(Coordinator.press(_:)) + button.setButtonType(.toggle) + button.bezelStyle = .rounded + button.controlSize = .small + button.imagePosition = .imageOnly + button.imageScaling = .scaleNone + button.refusesFirstResponder = false + button.setAccessibilityElement(true) + button.setAccessibilityRole(.button) + update(button, coordinator: context.coordinator) + return button + } + + func updateNSView(_ button: NSButton, context: Context) { + update(button, coordinator: context.coordinator) + } + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + private func update(_ button: NSButton, coordinator: Coordinator) { + coordinator.action = action + button.image = image + button.state = selected ? .on : .off + button.setAccessibilityLabel(accessibilityLabel) + button.setAccessibilityHelp(accessibilityHelp) + } + + @MainActor + final class Coordinator: NSObject { + var action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func press(_: NSButton) { + action() + } + } +} + +private final class StatusPreviewButton: NSButton { + override func accessibilityPerformPress() -> Bool { + performClick(nil) + return true + } +} diff --git a/Sources/TokenMenuBarUI/Views/TabPicker.swift b/Sources/TokenMenuBarUI/Views/TabPicker.swift new file mode 100644 index 0000000..19b4bb3 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/TabPicker.swift @@ -0,0 +1,56 @@ +import AppKit +import SwiftUI +import TokenMenuBarCore + +public struct TabPicker: NSViewRepresentable { + @Binding var selection: PopoverTab + + public init(selection: Binding) { + _selection = selection + } + + public func makeNSView(context: Context) -> NSSegmentedControl { + let control = NSSegmentedControl( + labels: PopoverTab.allCases.map(\.rawValue), trackingMode: .selectOne, target: context.coordinator, + action: #selector(Coordinator.changed(_:))) + control.segmentStyle = .automatic + control.setAccessibilityLabel("Popover tabs") + let symbols = ["chart.bar.fill", "clock.arrow.circlepath", "gearshape.fill"] + let symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .medium) + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: control.controlSize)) + let widest = + PopoverTab.allCases.map { + NSAttributedString(string: $0.rawValue, attributes: [.font: font]) + .size().width + }.max()! + for index in PopoverTab.allCases.indices { + let image = NSImage(systemSymbolName: symbols[index], accessibilityDescription: nil) + control.setImage(image?.withSymbolConfiguration(symbolConfiguration), forSegment: index) + control.setImageScaling(.scaleProportionallyDown, forSegment: index) + control.setWidth(ceil(widest) + 38, forSegment: index) + } + return control + } + + public func updateNSView(_ control: NSSegmentedControl, context: Context) { + control.selectedSegment = PopoverTab.allCases.firstIndex(of: selection)! + context.coordinator.selection = $selection + } + + public func makeCoordinator() -> Coordinator { + Coordinator(selection: $selection) + } + + @MainActor + public final class Coordinator: NSObject { + var selection: Binding + + init(selection: Binding) { + self.selection = selection + } + + @objc func changed(_ sender: NSSegmentedControl) { + selection.wrappedValue = PopoverTab.allCases[max(sender.selectedSegment, 0)] + } + } +} diff --git a/Sources/TokenMenuBarUI/Views/UpdatingBadge.swift b/Sources/TokenMenuBarUI/Views/UpdatingBadge.swift new file mode 100644 index 0000000..5b277e4 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/UpdatingBadge.swift @@ -0,0 +1,12 @@ +import Charts +import SwiftUI +import TokenMenuBarCore + +public struct UpdatingBadge: View { + public init() {} + + public var body: some View { + Text("Updating").font(.caption2).padding(.horizontal, 6).padding(.vertical, 2).background( + .thinMaterial, in: Capsule()) + } +} diff --git a/Sources/TokenMenuBarUI/Views/UsageChart.swift b/Sources/TokenMenuBarUI/Views/UsageChart.swift new file mode 100644 index 0000000..53b413d --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/UsageChart.swift @@ -0,0 +1,305 @@ +import Accessibility +import Charts +import SwiftUI +import TokenMenuBarCore + +public struct UsageChart: View { + struct BarPattern: Hashable { + let direction: Int + let bands: Int + let fadedStep: Int + let solid: Bool + } + + public let data: HistoryChartModel + public let presenter: HistoryPresenter + public let stacked: Bool + public let timeZone: TimeZone + + public static let palette: [Color] = [.blue, .orange, .green, .purple, .pink, .teal, .indigo, .brown] + + public init(data: HistoryChartModel, presenter: HistoryPresenter, stacked: Bool, timeZone: TimeZone) { + self.data = data + self.presenter = presenter + self.stacked = stacked + self.timeZone = timeZone + } + + public static func color(index: Int) -> Color { + palette[index % palette.count] + } + + public var body: some View { + HistoryBaseChart(data: data, stacked: stacked) + .equatable() + .chartXScale(domain: data.domain) + .chartYScale(domain: 0...data.yMax) + .chartYAxis { + AxisMarks(values: .automatic(desiredCount: 5)) { value in + AxisGridLine() + AxisValueLabel { Text(axisLabel(value)) } + } + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { _ in + AxisGridLine() + AxisValueLabel(format: Self.axisFormat(for: data.domain)) + } + } + .chartLegend(.hidden) + .chartOverlay { proxy in ChartOverlay(chart: self, proxy: proxy) } + .environment(\.timeZone, timeZone) + .focusable() + .onKeyPress(.leftArrow) { + presenter.moveSelection(-1) + return .handled + } + .onKeyPress(.rightArrow) { + presenter.moveSelection(1) + return .handled + } + .onKeyPress(.escape) { + presenter.select(x: nil) + return .handled + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("history-chart") + .accessibilityLabel("\(data.metric.title) history") + .accessibilityValue(data.summaryText) + .accessibilityChartDescriptor(HistoryChartAccessibility(data: data, timeZone: timeZone)) + .richHelp( + TooltipContent( + title: "History chart", + body: + "Move the pointer to inspect a date. Use Left and Right to move between visible dates, " + + "and Escape to clear the selection." + )) + } + + static func stroke(variant: Int) -> StrokeStyle { + guard variant > 0 else { return StrokeStyle(lineWidth: 2) } + let patterns: [[CGFloat]] = [[7, 3], [2, 3], [8, 2, 2, 2], [4, 2, 1, 2], [10, 3, 2, 3]] + return StrokeStyle( + lineWidth: 1.75 + CGFloat((variant / patterns.count) % 3) * 0.25, + dash: patterns[variant % patterns.count], dashPhase: CGFloat((variant / (patterns.count * 3)) % 7)) + } + + static func barStyle(_ slot: HistoryStyleSlot) -> AnyShapeStyle { + let color = color(index: slot.hueIndex) + let pattern = barPattern(variant: slot.variant) + guard !pattern.solid else { return AnyShapeStyle(color) } + let directions: [(UnitPoint, UnitPoint)] = [ + (.leading, .trailing), (.top, .bottom), (.topLeading, .bottomTrailing), (.bottomLeading, .topTrailing), + ] + let direction = directions[pattern.direction] + let faded = 0.3 + Double(pattern.fadedStep) * 0.1 + let colors = (0.. BarPattern { + BarPattern( + direction: variant % 4, bands: 2 + (variant / 4) % 4, fadedStep: (variant / 16) % 4, + solid: variant == 0) + } + + static func symbol(for variant: Int) -> BasicChartSymbolShape { + switch variant % 3 { + case 1: .square + case 2: .diamond + default: .circle + } + } + + static func symbolPoints(_ points: [SeriesPoint], limit: Int = 16) -> [SeriesPoint] { + guard points.count > limit, limit > 1 else { return points } + let last = Double(points.count - 1) + return (0.. String { + var style = Date.FormatStyle(date: .abbreviated, time: .shortened) + style.timeZone = timeZone + return "\(label), \(date.formatted(style))" + } + + static func axisFormat(for domain: ClosedRange) -> Date.FormatStyle { + domain.upperBound.timeIntervalSince(domain.lowerBound) > 2 * 86400 + ? .dateTime.month(.defaultDigits).day() : .dateTime.hour().minute() + } + + func selectionPoints(at date: Date) -> [HistorySelection] { + data.visibleSeries.compactMap { series in + series.value(at: date, metric: data.metric).map { HistorySelection(series: series, point: $0) } + } + } + + public func hover(_ phase: HoverPhase, in plot: CGRect) { + switch phase { + case .active(let location): pick(location, in: plot) + case .ended: presenter.select(x: nil) + } + } + + public func pick(_ location: CGPoint, in plot: CGRect) { + guard plot.width > 0, plot.contains(location) else { + presenter.select(x: nil) + return + } + let fraction = (location.x - plot.minX) / plot.width + let interval = data.domain.upperBound.timeIntervalSince(data.domain.lowerBound) + presenter.select(x: data.domain.lowerBound.addingTimeInterval(interval * fraction)) + } + + private func axisLabel(_ value: AxisValue) -> String { + guard let number = value.as(Double.self) else { return "" } + return formatted(number) + } + + private func formatted(_ value: Double) -> String { + Self.formatted(value, unit: data.metric.unit) + } + + static func formatted(_ value: Double, unit: HistoryUnit) -> String { + switch unit { + case .percentage: Format.percent(value) + case .usd: "$\(value.formatted(.number.precision(.fractionLength(2))))" + case .tokens, .credits, .count: Format.compactNumber(value) + } + } +} + +struct HistorySelection: Identifiable { + let series: HistorySeries + let point: SeriesPoint + + var id: HistorySeriesID { series.id } +} + +private struct HistoryBaseChart: View, Equatable { + let data: HistoryChartModel + let stacked: Bool + + var body: some View { + Chart { + ForEach(data.visibleSeries) { series in + switch data.metric.markKind { + case .stepLine, .line: + line(series) + case .bars: + bars(series) + } + } + } + } + + @ChartContentBuilder + private func line(_ series: HistorySeries) -> some ChartContent { + let color = UsageChart.color(index: series.style.hueIndex) + let stroke = UsageChart.stroke(variant: series.style.variant) + ForEach(series.points, id: \.self) { point in + LineMark( + x: .value("Time", point.date), y: .value("Value", point.value), + series: .value("Series", series.id.storageKey) + ) + .foregroundStyle(color) + .lineStyle(stroke) + .interpolationMethod(data.metric.markKind == .stepLine ? .stepEnd : .linear) + .accessibilityHidden(true) + if point.isReset { + PointMark(x: .value("Reset", point.date), y: .value("Value", point.value)) + .foregroundStyle(color) + .symbol(.diamond) + .symbolSize(28) + .accessibilityHidden(true) + } + } + ForEach(UsageChart.symbolPoints(series.points), id: \.self) { point in + PointMark(x: .value("Time", point.date), y: .value("Value", point.value)) + .foregroundStyle(color) + .symbol(UsageChart.symbol(for: series.style.variant)) + .symbolSize(18) + .accessibilityHidden(true) + } + } + + @ChartContentBuilder + private func bars(_ series: HistorySeries) -> some ChartContent { + let style = UsageChart.barStyle(series.style) + ForEach(series.points, id: \.self) { point in + if stacked { + BarMark( + x: .value("Day", point.date, unit: .day), y: .value("Value", point.value), stacking: .standard + ) + .foregroundStyle(style) + .accessibilityHidden(true) + } else { + BarMark( + x: .value("Day", point.date, unit: .day), y: .value("Value", point.value), stacking: .unstacked + ) + .foregroundStyle(style) + .position(by: .value("Series", series.id.storageKey)) + .accessibilityHidden(true) + } + } + } +} + +private struct HistoryChartAccessibility: AXChartDescriptorRepresentable { + let data: HistoryChartModel + let timeZone: TimeZone + + func makeChartDescriptor() -> AXChartDescriptor { + let xAxis = AXNumericDataAxisDescriptor( + title: "Time", + range: data.domain.lowerBound + .timeIntervalSinceReferenceDate...data.domain.upperBound.timeIntervalSinceReferenceDate, + gridlinePositions: [] + ) { value in + var style = Date.FormatStyle(date: .abbreviated, time: .shortened) + style.timeZone = timeZone + return Date(timeIntervalSinceReferenceDate: value).formatted(style) + } + let yAxis = AXNumericDataAxisDescriptor(title: data.metric.title, range: 0...data.yMax, gridlinePositions: []) { + value in + switch data.metric.unit { + case .percentage: Format.percent(value) + case .usd: "$\(value.formatted(.number.precision(.fractionLength(2))))" + case .tokens, .credits, .count: Format.compactNumber(value) + } + } + let series = data.visibleSeries.flatMap { series -> [AXDataSeriesDescriptor] in + if data.metric.markKind == .bars { + return [ + AXDataSeriesDescriptor( + name: series.label, isContinuous: false, + dataPoints: series.points.map { point in + AXDataPoint( + x: point.date.timeIntervalSinceReferenceDate, y: point.value, + label: resetLabel(point)) + }) + ] + } + return [ + AXDataSeriesDescriptor( + name: series.label, isContinuous: true, + dataPoints: series.points.map { point in + AXDataPoint( + x: point.date.timeIntervalSinceReferenceDate, y: point.value, + label: resetLabel(point)) + }) + ] + } + return AXChartDescriptor( + title: "\(data.metric.title) history", summary: data.summaryText, xAxis: xAxis, yAxis: yAxis, series: series) + } + + private func resetLabel(_ point: SeriesPoint) -> String? { + guard point.isReset else { return nil } + guard let date = point.resetsAt else { return "Reset" } + var style = Date.FormatStyle(date: .abbreviated, time: .shortened) + style.timeZone = timeZone + return "Reset at \(date.formatted(style))" + } +} diff --git a/Sources/TokenMenuBarUI/Views/UsageTab.swift b/Sources/TokenMenuBarUI/Views/UsageTab.swift new file mode 100644 index 0000000..819bcaf --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/UsageTab.swift @@ -0,0 +1,93 @@ +import SwiftUI +import TokenMenuBarCore + +public struct UsageTab: View { + @Bindable var environment: UIEnvironment + public let onRefreshProvider: (ProviderID) -> Void + + public init( + environment: UIEnvironment, onRefreshProvider: ((ProviderID) -> Void)? = nil + ) { + self.environment = environment + self.onRefreshProvider = onRefreshProvider ?? environment.actions.refreshProvider + } + + public var body: some View { + ScrollingTab(tab: .usage) { + let presentation = environment.usagePresentation + VStack(alignment: .leading, spacing: 8) { + header(presentation) + if presentation.cards.isEmpty { + HStack(alignment: .center, spacing: 12) { + EmptyStateView( + title: presentation.emptyTitle, systemImage: "slider.horizontal.3", + description: presentation.emptyDescription) + NativeActionButton("Open Providers") { environment.actions.showProviders(nil) } + .controlSize(.small) + .richHelp( + TooltipContent( + title: "Open Providers", + body: + "Opens provider setup so usage sources can be enabled or repaired. " + + "Usage remains empty until a provider supplies data." + )) + } + } + ForEach(presentation.cards) { card in + ProviderCardView(card: card, environment: environment, onRefreshProvider: onRefreshProvider) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func header(_ presentation: UsagePresentation) -> some View { + HStack(spacing: 8) { + AppIconView(size: 22, tone: presentation.iconTone) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(environment.appInfo.name).font(.headline) + UsageUpdatedText(lastRefresh: presentation.lastRefresh, environment: environment) + } + .accessibilityElement(children: .combine) + if environment.isDemo { + NativeActionButton("Disable Demo") { environment.actions.setDemoMode(false) } + .controlSize(.small) + .accessibilityIdentifier("disable-demo-data") + .richHelp( + TooltipContent( + title: "Disable demo data", + body: "Closes this generated-data session and relaunches the app with real provider discovery.")) + } + Spacer(minLength: 8) + if presentation.isRefreshing { + ProgressView().controlSize(.small).accessibilityLabel("Refreshing") + } + NativeIconButton( + symbol: "arrow.clockwise", accessibilityLabel: "Refresh usage", + explanation: + "Fetches current quota data from every enabled provider. " + + "Last-known values remain visible if a refresh fails.", + action: environment.actions.refresh + ) + .accessibilityIdentifier("usage-refresh") + } + } +} + +private struct UsageUpdatedText: View { + let lastRefresh: Date? + @Bindable var environment: UIEnvironment + + var body: some View { + Text("Updated \(UsageDeadline.age(lastRefresh).text(at: environment.usageDeadlineNow))") + .font(.callout) + .semanticForeground(.secondary) + .fixedSize(horizontal: false, vertical: true) + .richHelp( + TooltipContent( + title: "Last refresh", + body: "Shows when the latest provider refresh finished. Individual cards identify stale or cached values." + )) + } +} diff --git a/Sources/TokenMenuBarUI/Views/WindowHelpView.swift b/Sources/TokenMenuBarUI/Views/WindowHelpView.swift new file mode 100644 index 0000000..575bcd3 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/WindowHelpView.swift @@ -0,0 +1,25 @@ +import SwiftUI +import TokenMenuBarCore + +public struct WindowHelpView: View { + public let row: WindowRow + + public init(row: WindowRow) { + self.row = row + } + + public var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(row.window.label).font(.headline) + Text( + "Used \(Format.percent(row.window.usedPercent, decimals: 1)), " + + "\(Format.percent(row.window.remainingPercent, decimals: 1)) left" + ) + if let duration = row.window.duration { Text("Window: \(Format.duration(duration))") } + if let expected = row.pace.expectedPercent { Text("Even pace would be \(Format.percent(expected)) by now") } + if let ratio = row.pace.ratio { Text("Pace ratio: \(ratio.formatted(.number.precision(.fractionLength(2))))×") } + Text("Severity: \(row.window.severity.rawValue)") + } + .font(.callout) + } +} diff --git a/Sources/TokenMenuBarUI/Views/WindowRowView.swift b/Sources/TokenMenuBarUI/Views/WindowRowView.swift new file mode 100644 index 0000000..0416380 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/WindowRowView.swift @@ -0,0 +1,149 @@ +import SwiftUI +import TokenMenuBarCore + +public struct WindowRowView: View { + public let row: WindowRow + /// False when neighbouring windows share this reset and the group prints it once underneath them all. + public let showsReset: Bool + private let fixedNow: Date? + private let environment: UIEnvironment? + + public init(row: WindowRow, now: Date, showsReset: Bool = true) { + self.row = row + self.showsReset = showsReset + fixedNow = now + environment = nil + } + + public init(row: WindowRow, environment: UIEnvironment, showsReset: Bool = true) { + self.row = row + self.showsReset = showsReset + fixedNow = nil + self.environment = environment + } + + public var body: some View { + ResponsivePanelLayout { + wideRow.frame(minWidth: 680) + } narrow: { + narrowRow + } + .frame(maxWidth: .infinity, alignment: .leading) + .richHelp(TooltipContent(title: row.window.label, body: row.helpText)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabelText) + .accessibilityValue(accessibilityValue) + } + + private var wideRow: some View { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 1) { + Text(row.window.label).font(.callout.weight(.medium)) + HStack(alignment: .firstTextBaseline, spacing: 7) { + Text(row.detail).font(.caption.monospaced()).semanticForeground(.secondary) + if !row.window.isActive { + Text("inactive").font(.caption2).semanticForeground(.secondary) + } + if !row.isSelected { + Text("Not in menu bar").font(.caption2).semanticForeground(.secondary) + } + } + } + .fixedSize(horizontal: false, vertical: true) + .frame(width: 240, alignment: .leading) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + UsageBar( + percent: row.window.usedPercent, expectedPercent: row.pace.expectedPercent, + color: usageBarColor, label: row.window.label) + Text(row.percentText) + .font(.callout.monospacedDigit().weight(.semibold)) + .semanticForeground(.primary) + .frame(width: 52, alignment: .trailing) + } + Text(row.pace.comparison(now: currentNow)) + .font(.caption) + .semanticForeground(.primary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(minWidth: 100, idealWidth: 250, maxWidth: .infinity, alignment: .leading) + if showsReset { + ResetDeadlineText(deadline: row.resetDeadline, now: currentNow, alignment: .trailing) + .frame(width: 150, alignment: .trailing) + } else { + Color.clear.frame(width: 150, height: 1) + } + } + .frame(minHeight: 34) + } + + private var narrowRow: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 1) { + Text(row.window.label).font(.callout.weight(.medium)) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(row.detail).font(.caption.monospaced()).semanticForeground(.secondary) + if !row.window.isActive { Text("inactive").font(.caption2).semanticForeground(.secondary) } + if !row.isSelected { Text("Not in menu bar").font(.caption2).semanticForeground(.secondary) } + } + } + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + Text(row.percentText) + .font(.callout.monospacedDigit().weight(.semibold)) + .semanticForeground(.primary) + } + UsageBar( + percent: row.window.usedPercent, expectedPercent: row.pace.expectedPercent, + color: usageBarColor, label: row.window.label) + Text(row.pace.comparison(now: currentNow)) + .font(.caption) + .semanticForeground(.primary) + .fixedSize(horizontal: false, vertical: true) + if showsReset { + ResetDeadlineText(deadline: row.resetDeadline, now: currentNow, alignment: .leading) + } + } + } + + private var usageBarColor: Color { + Color(row.color) + } + + var accessibilityValue: String { + row.accessibilityValue(at: currentNow) + } + + var accessibilityLabelText: String { + "\(row.key.provider.displayName) \(row.window.label), \(row.detail)" + } + + /// Pace state belongs to the bar; text stays readable on both panel appearances. + var paceColor: Color { + .primary + } + + private var currentNow: Date { + environment?.usageDeadlineNow ?? fixedNow! + } +} + +struct ResetDeadlineText: View { + let deadline: UsageDeadline + let now: Date + let alignment: HorizontalAlignment + + var body: some View { + let lines = deadline.lines(at: now) + VStack(alignment: alignment, spacing: 1) { + ForEach(lines.indices, id: \.self) { index in + Text(lines[index]).fixedSize(horizontal: false, vertical: true) + } + } + .font(.caption.monospacedDigit()) + .semanticForeground(.secondary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(deadline.text(at: now)) + } +} diff --git a/Sources/TokenMenuBarUI/Views/WindowSelectionList.swift b/Sources/TokenMenuBarUI/Views/WindowSelectionList.swift new file mode 100644 index 0000000..57ef200 --- /dev/null +++ b/Sources/TokenMenuBarUI/Views/WindowSelectionList.swift @@ -0,0 +1,779 @@ +import AppKit +import SwiftUI +import TokenMenuBarCore + +public struct SettingsModelFocusRequest: Equatable, Identifiable { + public let id: UUID + public let key: WindowKey + + public init(id: UUID = UUID(), key: WindowKey) { + self.id = id + self.key = key + } +} + +public struct WindowSelectionList: View { + @Bindable var environment: UIEnvironment + @Binding private var highlightedKey: WindowKey? + @Binding private var labelDrafts: [WindowKey: String] + public let focusRequest: SettingsModelFocusRequest? + @State private var query = "" + @State private var lastUsedAt: [WindowKey: Date] = [:] + @State private var revealedKey: WindowKey? + @State private var hoveredReorderTarget: ReorderTarget? + @FocusState private var focus: Field? + + enum Field: Hashable { + case filter + case hideUnused + case providerSelection(ProviderID) + case modelSelection(WindowKey) + case label(WindowKey) + } + + enum ReorderTarget: Hashable { + case provider(ProviderID) + case model(WindowKey) + } + + public init( + environment: UIEnvironment, highlightedKey: Binding = .constant(nil), + labelDrafts: Binding<[WindowKey: String]> = .constant([:]), focusRequest: SettingsModelFocusRequest? = nil + ) { + self.environment = environment + _highlightedKey = highlightedKey + _labelDrafts = labelDrafts + self.focusRequest = focusRequest + } + + private var settings: TokenMenuBarCore.Settings { environment.settings } + + var rows: [(key: WindowKey, window: QuotaWindow)] { + orderedProviders.flatMap { provider in + orderedWindows(provider).map { (WindowKey(provider, $0), $0) } + } + } + + var selection: [WindowKey] { + settings.hasCustomSelection + ? settings.selectedWindows : StatusItemBuilder.defaultSelection(environment.state.snapshots) + } + + var groups: [SettingsProviderGroup] { + SettingsModelPresentation.groups( + snapshots: environment.state.snapshots, selected: selection, labels: settings.shortLabels, + providerOrder: settings.providerOrder, modelOrder: settings.modelOrder, query: query, + hideUnused: settings.hideUnusedModels, lastUsedAt: lastUsedAt, revealedKey: revealedKey, now: environment.now) + } + + var orderedProviders: [ProviderID] { + let available = environment.state.snapshots.keys.sorted() + return settings.providerOrder.filter { available.contains($0) } + + available.filter { !settings.providerOrder.contains($0) } + } + + public var body: some View { + VStack(alignment: .leading, spacing: 6) { + ResponsivePanelLayout { + HStack(spacing: 8) { + modelFilter + Text("⌘F").font(.caption.monospaced()).semanticForeground(.secondary) + hideUnusedToggle + } + .frame(minWidth: 600) + } narrow: { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + modelFilter + Text("⌘F").font(.caption.monospaced()).semanticForeground(.secondary) + } + hideUnusedToggle + } + } + Text("Checked models appear in the menu bar; the range uses retained activity and the current quota window.") + .font(.caption) + .semanticForeground(.secondary) + if groups.isEmpty { + ContentUnavailableView( + rows.isEmpty ? "No models yet" : "No matching models", + systemImage: rows.isEmpty ? "clock.arrow.circlepath" : "line.3.horizontal.decrease.circle", + description: Text( + rows.isEmpty ? "Models appear after the first successful refresh." : "Clear the filter to show all models.") + ) + } else { + VStack(spacing: 0) { + ForEach(groups) { group in + providerHeader(group) + ForEach(group.rows) { row in modelRow(row) } + } + } + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.1)) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + if selection.count == 1 { + Text("At least one model stays selected.").font(.caption).semanticForeground(.secondary) + } + Button("Filter Models") { focus = .filter } + .keyboardShortcut("f", modifiers: .command) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + .onAppear(perform: prepareDrafts) + .task(id: activityRequest) { await loadActivity(activityRequest) } + .onChange(of: focusRequest) { _, request in + guard let request else { return } + revealedKey = request.key + Task { @MainActor in + await Task.yield() + focus = .label(request.key) + } + } + .onChange(of: query, queryChangeAction) + .onChange(of: settings.hideUnusedModels) { _, _ in revealedKey = nil } + .onChange(of: focus) { old, new in + guard case .label(let key) = old, old != new else { return } + commitLabel(key) + } + .onDisappear { commitDrafts() } + } + + var queryChangeAction: (String, String) -> Void { + { _, _ in revealedKey = nil } + } + + private var modelFilter: some View { + TextField("Filter models…", text: $query) + .textFieldStyle(.roundedBorder) + .richHelp( + TooltipContent( + title: "Filter models", + body: + "Matches provider names, model names, identifiers, and effective short labels. " + + "Filtering does not change selection or stored settings." + ), + focus: $focus, equals: .filter + ) + .accessibilityIdentifier("model-filter") + } + + private var hideUnusedToggle: some View { + Toggle("Hide unused in range", isOn: setting(\.hideUnusedModels)) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Hide unused in range", + body: + "Filters models with no retained activity in the selected retention range and a current quota window " + + "at zero. Selection, labels, order, and history stay unchanged." + ), + focus: $focus, equals: .hideUnused + ) + } + + @ViewBuilder + func providerHeader(_ group: SettingsProviderGroup) -> some View { + let target = ReorderTarget.provider(group.provider) + let moveEarlier = { moveProvider(group.provider, by: -1) } + let moveLater = { moveProvider(group.provider, by: 1) } + let header = ResponsivePanelLayout { + HStack(spacing: 8) { + providerIdentity(group) + Spacer(minLength: 8) + providerSelection(group) + if settings.windowOrder == .provider { providerReorderControls(group.provider, target: target) } + } + .frame(minWidth: 600) + } narrow: { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + providerIdentity(group) + Spacer(minLength: 8) + providerSelection(group) + } + if settings.windowOrder == .provider { providerReorderControls(group.provider, target: target) } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.05)) + .contentShape(Rectangle()) + .onHover { hoveredReorderTarget = $0 ? target : nil } + + if settings.windowOrder == .provider { + header + .dropDestination(for: String.self) { values, _ in + guard let raw = values.first?.dropPrefix("provider:"), let provider = ProviderID(rawValue: raw) else { + return false + } + moveProvider(provider, before: group.provider) + return true + } + .contextMenu { + Button("Move Earlier", action: moveEarlier) + .richHelp( + TooltipContent( + title: "Move provider earlier", + body: "Moves \(group.provider.displayName) one place earlier in Stable order.")) + Button("Move Later", action: moveLater) + .richHelp( + TooltipContent( + title: "Move provider later", + body: "Moves \(group.provider.displayName) one place later in Stable order.")) + } + .accessibilityAction(named: "Move Earlier", moveEarlier) + .accessibilityAction(named: "Move Later", moveLater) + } else { + header + } + } + + private func providerIdentity(_ group: SettingsProviderGroup) -> some View { + HStack(spacing: 8) { + ProviderMarkView(group.provider, size: CGSize(width: 22, height: 18)).accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(group.provider.displayName).fontWeight(.semibold) + Text("\(group.selectedCount) of \(group.totalCount) shown").font(.caption).semanticForeground(.secondary) + } + .fixedSize(horizontal: false, vertical: true) + } + } + + private func providerSelection(_ group: SettingsProviderGroup) -> some View { + let bindings = providerKeys(group.provider).map(selectionBinding) + return Toggle(sources: bindings, isOn: \.self) { Text("All") } + .toggleStyle(.checkbox) + .disabled(selection.count == group.selectedCount && group.selection == .all) + .accessibilityLabel("Show all \(group.provider.displayName) models") + .accessibilityValue("\(group.selectedCount) of \(group.totalCount) selected") + .richHelp( + TooltipContent( + title: "Show all \(group.provider.displayName) models", + body: + "Selects or clears this provider's models as a group. " + + "Token Menu Bar keeps at least one model selected across all providers." + ), + focus: $focus, equals: .providerSelection(group.provider) + ) + } + + private func providerReorderControls(_ provider: ProviderID, target: ReorderTarget) -> some View { + HStack(spacing: 3) { + reorderButton( + symbol: "chevron.up", label: "Move \(provider.displayName) earlier", + explanation: "Moves \(provider.displayName) one place earlier in Stable order.", + disabled: !canMoveProvider(provider, by: -1) + ) { moveProvider(provider, by: -1) } + reorderButton( + symbol: "chevron.down", label: "Move \(provider.displayName) later", + explanation: "Moves \(provider.displayName) one place later in Stable order.", + disabled: !canMoveProvider(provider, by: 1) + ) { moveProvider(provider, by: 1) } + reorderHandle( + payload: "provider:\(provider.rawValue)", target: target, title: "Reorder \(provider.displayName)", + explanation: "Drag this handle to change Stable provider order.") + } + } + + @ViewBuilder + func modelRow(_ row: SettingsModelRow) -> some View { + let target = ReorderTarget.model(row.key) + let moveEarlier = modelMoveAction(row.key, by: -1) + let moveLater = modelMoveAction(row.key, by: 1) + let revertLabel = revertAction(row) + let content = ResponsivePanelLayout { + wideModelRow(row, target: target).frame(minWidth: 680) + } narrow: { + narrowModelRow(row, target: target) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(highlightedKey == row.key ? Color.accentColor.opacity(0.12) : Color.clear) + .contentShape(Rectangle()) + .onHover { hover($0, row: row, target: target) } + .id(row.key) + .accessibilityElement(children: .contain) + .accessibilityLabel(modelAccessibilityLabel(row)) + .accessibilityValue(modelAccessibilityValue(row)) + + if settings.windowOrder == .provider { + content + .dropDestination(for: String.self) { values, _ in + guard let raw = values.first?.dropPrefix("model:"), let key = WindowKey(storageKey: raw) else { return false } + moveModel(key, before: row.key) + return true + } + .contextMenu { + Button("Move Earlier", action: moveEarlier) + .richHelp( + TooltipContent( + title: "Move model earlier", + body: "Moves \(row.window.label) one place earlier in Stable order.")) + Button("Move Later", action: moveLater) + .richHelp( + TooltipContent( + title: "Move model later", + body: "Moves \(row.window.label) one place later in Stable order.")) + if isOverridden(row) { + Button("Revert Label", action: revertLabel) + .richHelp( + TooltipContent( + title: "Revert short label", + body: "Restores the unique label derived for \(row.window.label).")) + } + } + .accessibilityAction(named: "Move Earlier", moveEarlier) + .accessibilityAction(named: "Move Later", moveLater) + } else if isOverridden(row) { + content.contextMenu { + Button("Revert Label", action: revertLabel) + .richHelp( + TooltipContent( + title: "Revert short label", + body: "Restores the unique label derived for \(row.window.label).")) + } + } else { + content + } + } + + func modelMoveAction(_ key: WindowKey, by offset: Int) -> () -> Void { + { moveModel(key, by: offset) } + } + + func revertAction(_ row: SettingsModelRow) -> () -> Void { + { revert(row) } + } + + func hover(_ inside: Bool, row: SettingsModelRow, target: ReorderTarget) { + if inside { + highlightedKey = row.key + hoveredReorderTarget = target + } else { + if highlightedKey == row.key { highlightedKey = nil } + if hoveredReorderTarget == target { hoveredReorderTarget = nil } + } + } + + private func wideModelRow(_ row: SettingsModelRow, target: ReorderTarget) -> some View { + Grid(horizontalSpacing: 10, verticalSpacing: 0) { + GridRow(alignment: .center) { + modelSelection(row) + modelIdentity(row).frame(maxWidth: .infinity, alignment: .leading) + modelUsage(row).frame(width: 104) + labelEditor(row) + labelBudget(row) + if settings.windowOrder == .provider { modelReorderControls(row, target: target) } + } + } + } + + private func narrowModelRow(_ row: SettingsModelRow, target: ReorderTarget) -> some View { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .top, spacing: 8) { + modelSelection(row) + modelIdentity(row) + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 1) { + Text(Format.percent(row.window.usedPercent, decimals: 2)).monospacedDigit() + Text(row.recency).font(.caption).semanticForeground(.secondary) + } + } + modelGauge(row) + HStack(spacing: 6) { + labelEditor(row) + labelBudget(row) + Spacer(minLength: 8) + if settings.windowOrder == .provider { modelReorderControls(row, target: target) } + } + } + } + + private func modelSelection(_ row: SettingsModelRow) -> some View { + Toggle("", isOn: selectionBinding(row.key)) + .labelsHidden() + .toggleStyle(.checkbox) + .disabled(selection == [row.key]) + .accessibilityLabel("Show \(row.window.label) in the menu bar") + .richHelp( + TooltipContent( + title: "Menu bar selection", + body: + "Controls whether \(row.window.label) can appear in the status item. " + + "Its Usage row and history remain available when unchecked." + ), + focus: $focus, equals: .modelSelection(row.key) + ) + } + + private func modelIdentity(_ row: SettingsModelRow) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(row.window.label).fixedSize(horizontal: false, vertical: true) + Text(row.detail).font(.caption.monospaced()).semanticForeground(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func modelUsage(_ row: SettingsModelRow) -> some View { + VStack(alignment: .trailing, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(Format.percent(row.window.usedPercent, decimals: 2)).monospacedDigit() + Text(row.recency).font(.caption).semanticForeground(.secondary) + } + modelGauge(row) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private func modelGauge(_ row: SettingsModelRow) -> some View { + Gauge(value: row.window.usedPercent, in: 0...100) { Text("Usage") } + .gaugeStyle(.accessoryLinearCapacity) + .labelsHidden() + .tint(Color(UsageColor.color(percent: row.window.usedPercent))) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(row.window.label) usage") + .accessibilityValue("\(Format.percent(row.window.usedPercent, decimals: 2)), \(row.recency)") + .richHelp( + TooltipContent( + title: "Usage and recency", + body: + "Shows the latest \(row.window.label) percentage and when Token Menu Bar last observed usage " + + "for this model or window." + )) + } + + private func labelEditor(_ row: SettingsModelRow) -> some View { + let conflict = labelConflict(row) + return HStack(spacing: 4) { + TextField("Label", text: label(row)) + .font(.caption.monospaced()) + .multilineTextAlignment(.center) + .frame(width: 64) + .richHelp( + TooltipContent( + title: "Short label", + body: + "Sets the model's label in every menu-bar format, up to six characters. " + + "Clearing the field restores the derived label." + ), + focus: $focus, equals: .label(row.key) + ) + .onSubmit { commitLabel(row.key) } + .accessibilityLabel("Short label for \(row.key.provider.displayName) \(row.window.label)") + .accessibilityValue(shortLabelAccessibilityValue(row)) + .accessibilityHint(conflict.map { labelConflictDescription(row, conflictingKey: $0) } ?? "") + .overlay { + RoundedRectangle(cornerRadius: 4) + .stroke(conflict == nil ? Color.clear : Color(.destructive), lineWidth: 1) + } + if let conflict { + Image(systemName: "exclamationmark.triangle.fill") + .semanticForeground(.destructive) + .richHelp( + TooltipContent( + title: "Duplicate short label", + body: labelConflictDescription(row, conflictingKey: conflict)) + ) + .accessibilityLabel("Duplicate short label") + .accessibilityValue(labelConflictDescription(row, conflictingKey: conflict)) + } + if isOverridden(row) { + NativeIconButton( + symbol: "arrow.counterclockwise", + accessibilityLabel: "Revert label for \(row.key.provider.displayName) \(row.window.label)", + explanation: "Restores the derived label \(row.defaultLabel) and removes the saved override." + ) { revert(row) } + .controlSize(.mini) + .accessibilityHint("Restores \(row.defaultLabel)") + } else { + Color.clear.frame(width: 20, height: 1) + } + } + } + + private func labelBudget(_ row: SettingsModelRow) -> some View { + Text("\(draft(for: row).count)/\(ShortLabelPolicy.limit)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(Color(labelConflict(row) == nil ? .secondary : .destructive)) + .frame(width: 28, alignment: .trailing) + } + + private func modelReorderControls(_ row: SettingsModelRow, target: ReorderTarget) -> some View { + HStack(spacing: 3) { + reorderButton( + symbol: "chevron.up", label: "Move \(row.window.label) earlier", + explanation: "Moves \(row.window.label) one place earlier within \(row.key.provider.displayName).", + disabled: !canMoveModel(row.key, by: -1) + ) { moveModel(row.key, by: -1) } + reorderButton( + symbol: "chevron.down", label: "Move \(row.window.label) later", + explanation: "Moves \(row.window.label) one place later within \(row.key.provider.displayName).", + disabled: !canMoveModel(row.key, by: 1) + ) { moveModel(row.key, by: 1) } + reorderHandle( + payload: "model:\(row.key.storageKey)", target: target, title: "Reorder \(row.window.label)", + explanation: "Drag this handle within \(row.key.provider.displayName) to change Stable order.") + } + } + + private func reorderButton( + symbol: String, label: String, explanation: String, disabled: Bool, action: @escaping () -> Void + ) -> some View { + NativeIconButton(symbol: symbol, accessibilityLabel: label, explanation: explanation, action: action) + .controlSize(.mini) + .disabled(disabled) + } + + private func reorderHandle( + payload: String, target: ReorderTarget, title: String, explanation: String + ) -> some View { + Image(systemName: "line.3.horizontal") + .semanticForeground(.secondary) + .frame(width: 18) + .contentShape(Rectangle()) + .opacity(hoveredReorderTarget == target ? 1 : 0) + .allowsHitTesting(hoveredReorderTarget == target) + .onDrag(reorderDragAction(payload)) + .richHelp(TooltipContent(title: title, body: explanation)) + .accessibilityHidden(true) + } + + func reorderDragAction(_ payload: String) -> () -> NSItemProvider { + { NSItemProvider(object: payload as NSString) } + } + + func canMoveProvider(_ provider: ProviderID, by offset: Int) -> Bool { + guard let index = orderDraft.providers.firstIndex(of: provider) else { return false } + return orderDraft.providers.indices.contains(index + offset) + } + + func canMoveModel(_ key: WindowKey, by offset: Int) -> Bool { + let keys = orderDraft.models.filter { $0.provider == key.provider } + guard let index = keys.firstIndex(of: key) else { return false } + return keys.indices.contains(index + offset) + } + + func orderedWindows(_ provider: ProviderID) -> [QuotaWindow] { + guard let windows = environment.state.snapshots[provider]?.windows else { return [] } + let byKey = Dictionary(uniqueKeysWithValues: windows.map { (WindowKey(provider, $0), $0) }) + return orderDraft.models.filter { $0.provider == provider }.compactMap { byKey[$0] } + } + + var orderDraft: SettingsOrderDraft { + SettingsOrderDraft(providers: settings.providerOrder, models: settings.modelOrder, available: availableKeys) + } + + var availableKeys: [WindowKey] { + environment.state.snapshots.keys.sorted().flatMap { provider in + environment.state.snapshots[provider]!.windows.map { WindowKey(provider, $0) } + } + } + + var availableWindows: [WindowKey: QuotaWindow] { + Dictionary(uniqueKeysWithValues: rows.map { ($0.key, $0.window) }) + } + + var activityRequest: SettingsActivityRequest { + SettingsActivityRequest( + keys: availableKeys.sorted(), sampleRevision: environment.state.sampleRevision, + retentionDays: settings.historyRetentionDays, + rangeHour: Int64(environment.clock.now().timeIntervalSince1970 / 3600)) + } + + func loadActivity(_ request: SettingsActivityRequest) async { + await Task.yield() + guard !Task.isCancelled else { return } + guard !request.keys.isEmpty else { + lastUsedAt = [:] + return + } + let dates = await environment.settingsActivity(for: request) + guard !Task.isCancelled else { return } + lastUsedAt = dates + } + + func setting(_ keyPath: ReferenceWritableKeyPath) -> Binding { + Binding( + get: { settings[keyPath: keyPath] }, + set: { + settings[keyPath: keyPath] = $0 + environment.actions.settingsChanged() + }) + } + + func selectionBinding(_ key: WindowKey) -> Binding { + Binding(get: { selection.contains(key) }, set: { toggle(key, on: $0) }) + } + + func providerKeys(_ provider: ProviderID) -> [WindowKey] { + availableKeys.filter { $0.provider == provider } + } + + func toggle(_ key: WindowKey, on: Bool) { + var keys = selection + if on { + if !keys.contains(key) { keys.append(key) } + } else if keys.count > 1 { + keys.removeAll { $0 == key } + } + settings.selectedWindows = orderDraft.orderedSelection(keys) + settings.hasCustomSelection = true + environment.actions.settingsChanged() + } + + func label(_ row: SettingsModelRow) -> Binding { + Binding( + get: { draft(for: row) }, + set: { setLabel(row.key, $0) }) + } + + func label(_ key: WindowKey, window: QuotaWindow) -> Binding { + Binding( + get: { + labelDrafts[key] + ?? ShortLabelPolicy.resolvedLabels(windows: availableWindows, overrides: settings.shortLabels)[key] + ?? StatusItemBuilder.defaultShortLabel(provider: key.provider, window: window) + }, + set: { setLabel(key, $0) }) + } + + func draft(for row: SettingsModelRow) -> String { + labelDrafts[row.key] ?? row.label + } + + func shortLabelAccessibilityValue(_ row: SettingsModelRow) -> String { + let value = draft(for: row) + let count = "\(value.count) of \(ShortLabelPolicy.limit) characters" + guard let conflict = labelConflict(row) else { return "\(value.isEmpty ? "Empty" : value), \(count)" } + return "\(value), \(count). \(labelConflictDescription(row, conflictingKey: conflict))" + } + + func modelAccessibilityLabel(_ row: SettingsModelRow) -> String { + "\(row.window.label), \(row.detail)" + } + + func modelAccessibilityValue(_ row: SettingsModelRow) -> String { + "\(row.isSelected ? "shown" : "hidden"), " + + "\(Format.percent(row.window.usedPercent, decimals: 2)), \(row.recency), label \(draft(for: row))" + } + + func labelConflict(_ row: SettingsModelRow) -> WindowKey? { + ShortLabelPolicy.conflictingKey( + draft(for: row), for: row.key, windows: availableWindows, overrides: settings.shortLabels) + } + + func labelConflictDescription(_ row: SettingsModelRow, conflictingKey: WindowKey) -> String { + let window = environment.state.snapshots[conflictingKey.provider]?.window(conflictingKey.windowID) + let name = window?.label ?? conflictingKey.windowID + return "Already used by \(conflictingKey.provider.displayName) \(name); saved label remains \(row.label)." + } + + func setLabel(_ key: WindowKey, _ label: String) { + guard let row = row(key) else { return } + let draft = ShortLabelPolicy.draft(label) + labelDrafts[key] = draft + guard + ShortLabelPolicy.conflictingKey( + draft, for: key, windows: availableWindows, overrides: settings.shortLabels) == nil + else { return } + let value = ShortLabelPolicy.override(draft, default: row.defaultLabel) + guard settings.shortLabels[key] != value else { return } + labelDrafts[key] = value ?? row.defaultLabel + settings.setShortLabel(value, for: key) + environment.actions.settingsChanged() + } + + func commitLabel(_ key: WindowKey) { + guard let row = row(key) else { return } + commitLabel(key, default: row.defaultLabel) + } + + func commitLabel(_ key: WindowKey, default defaultLabel: String) { + let draft = labelDrafts[key] ?? defaultLabel + guard + ShortLabelPolicy.conflictingKey( + draft, for: key, windows: availableWindows, overrides: settings.shortLabels) == nil + else { return } + let value = ShortLabelPolicy.override(draft, default: defaultLabel) + guard settings.shortLabels[key] != value else { return } + settings.setShortLabel(value, for: key) + labelDrafts[key] = value ?? defaultLabel + environment.actions.settingsChanged() + } + + func commitDrafts() { + for key in labelDrafts.keys { commitLabel(key) } + } + + func prepareDrafts() { + for row in groups.flatMap(\.rows) where labelDrafts[row.key] == nil { labelDrafts[row.key] = row.label } + } + + func revert(_ row: SettingsModelRow) { + labelDrafts[row.key] = row.defaultLabel + commitLabel(row.key, default: row.defaultLabel) + } + + func isOverridden(_ row: SettingsModelRow) -> Bool { + ShortLabelPolicy.override(draft(for: row), default: row.defaultLabel) != nil + } + + func row(_ key: WindowKey) -> SettingsModelRow? { + SettingsModelPresentation.groups( + snapshots: environment.state.snapshots, selected: selection, labels: settings.shortLabels, + providerOrder: settings.providerOrder, modelOrder: settings.modelOrder, query: "", hideUnused: false, + now: environment.now + ).flatMap(\.rows).first { $0.key == key } + } + + func moveProvider(_ provider: ProviderID, before target: ProviderID) { + guard settings.windowOrder == .provider else { return } + var draft = orderDraft + draft.moveProvider(provider, before: target) + commit(draft) + } + + func moveProvider(_ provider: ProviderID, by offset: Int) { + guard settings.windowOrder == .provider else { return } + var draft = orderDraft + draft.moveProvider(provider, by: offset) + commit(draft) + } + + func moveModel(_ key: WindowKey, before target: WindowKey) { + guard settings.windowOrder == .provider else { return } + var draft = orderDraft + draft.moveModel(key, before: target) + commit(draft) + } + + func moveModel(_ key: WindowKey, by offset: Int) { + guard settings.windowOrder == .provider else { return } + var draft = orderDraft + draft.moveModel(key, by: offset) + commit(draft) + } + + func commit(_ draft: SettingsOrderDraft) { + guard settings.providerOrder != draft.providers || settings.modelOrder != draft.models else { return } + settings.providerOrder = draft.providers + settings.modelOrder = draft.models + settings.selectedWindows = draft.orderedSelection(selection) + environment.actions.settingsChanged() + } +} + +private extension String { + func dropPrefix(_ prefix: String) -> String? { + hasPrefix(prefix) ? String(dropFirst(prefix.count)) : nil + } +} + +struct SettingsActivityRequest: Hashable, Sendable { + let keys: [WindowKey] + let sampleRevision: UInt64 + let retentionDays: Int + let rangeHour: Int64 +} diff --git a/Sources/TokenMenuBarUI/WorkspaceGlue.swift b/Sources/TokenMenuBarUI/WorkspaceGlue.swift new file mode 100644 index 0000000..9e64949 --- /dev/null +++ b/Sources/TokenMenuBarUI/WorkspaceGlue.swift @@ -0,0 +1,45 @@ +import AppKit + +// Launching a replacement instance asks LaunchServices to open a bundle, which a test cannot do without opening a +// real app, so this one call lives here and the coverage gate skips it. +extension LiveDependencies { + struct RuntimeActions { + let openURL: @MainActor (URL) -> Void + let copy: @MainActor (String) -> Void + let reveal: @MainActor (URL) -> Void + let terminate: @MainActor () -> Void + } + + @MainActor static func resolvedWorkspaceOpen(_ open: WorkspaceOpen?) -> WorkspaceOpen { + guard let open else { + return { url, configuration, done in + NSWorkspace.shared.openApplication(at: url, configuration: configuration) { _, _ in done() } + } + } + return open + } + + @MainActor static func windowPresentation(enabled: Bool) -> @MainActor (NSWindow, Any?) -> Void { + if enabled { return { window, sender in window.makeKeyAndOrderFront(sender) } } + return { _, _ in } + } + + @MainActor static func runtimeActions(verification: Bool) -> RuntimeActions { + if verification { + return RuntimeActions(openURL: { _ in }, copy: { _ in }, reveal: { _ in }, terminate: {}) + } + return RuntimeActions( + openURL: { NSWorkspace.shared.open($0) }, + copy: { copy($0, to: .general) }, + reveal: { NSWorkspace.shared.activateFileViewerSelecting([$0]) }, + terminate: { NSApplication.shared.terminate(nil) }) + } + + @MainActor + public static func workspaceLauncher( + _ url: URL, _ configuration: NSWorkspace.OpenConfiguration, _ done: @escaping @Sendable () -> Void + ) { + NSWorkspace.shared.openApplication(at: url, configuration: configuration) { _, _ in done() } + } + +} diff --git a/Sources/TokenMenuBarWidgets/UsageWidget.swift b/Sources/TokenMenuBarWidgets/UsageWidget.swift new file mode 100644 index 0000000..f6bfa7e --- /dev/null +++ b/Sources/TokenMenuBarWidgets/UsageWidget.swift @@ -0,0 +1,148 @@ +import SwiftUI +import TokenMenuBarCore +import WidgetKit + +public struct UsageEntry: TimelineEntry, Sendable { + public let date: Date + public let snapshot: WidgetSnapshot + + public init(date: Date, snapshot: WidgetSnapshot) { + self.date = date + self.snapshot = snapshot + } +} + +public struct UsageTimelineProvider: Sendable { + public static let refreshInterval: TimeInterval = 900 + + public let store: WidgetSnapshotStore + public let now: @Sendable () -> Date + + public init(store: WidgetSnapshotStore, now: @escaping @Sendable () -> Date = { Date() }) { + self.store = store + self.now = now + } + + public static func defaultStore( + containerURL: (String) -> URL? = { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0) + }, + fallbackDirectory: URL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Token Menu Bar"), + appGroup: String = WidgetSnapshot.appGroup(info: Bundle.main.infoDictionary) + ) -> WidgetSnapshotStore { + WidgetSnapshotStore( + url: WidgetSnapshotStore.sharedURL( + containerURL: containerURL, fallbackDirectory: fallbackDirectory, appGroup: appGroup)) + } + + public func placeholderEntry() -> UsageEntry { + UsageEntry(date: now(), snapshot: .placeholder) + } + + public func timeline() -> Timeline { + let entry = entry() + return Timeline(entries: [entry], policy: .after(entry.date.addingTimeInterval(Self.refreshInterval))) + } + + public func entry() -> UsageEntry { + UsageEntry(date: now(), snapshot: store.read() ?? .unavailable) + } +} + +public struct UsageWidgetView: View { + public let entry: UsageEntry + public let family: WidgetFamily + + public init(entry: UsageEntry, family: WidgetFamily) { + self.entry = entry + self.family = family + } + + public var rows: [WidgetRow] { + Array(entry.snapshot.rows.prefix(rowLimit)) + } + + public var rowLimit: Int { + switch family { + case .systemSmall: 3 + case .systemMedium: 4 + default: 8 + } + } + + public var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text("Token Menu Bar").font(.caption.weight(.semibold)) + Spacer() + if entry.snapshot.attention { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange).font(.caption) + } + if entry.snapshot.hasData { + Text(entry.snapshot.updatedAt, style: .relative).font(.caption2).foregroundStyle(.secondary) + } + } + if rows.isEmpty { + Text(entry.snapshot.hasData ? "Open Token Menu Bar to pick windows." : "Open Token Menu Bar to start tracking.") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(rows) { row in + WidgetRowView(row: row, now: entry.date, compact: family == .systemSmall) + } + Spacer(minLength: 0) + } + .containerBackground(.background, for: .widget) + } +} + +public struct WidgetRowView: View { + public let row: WidgetRow + public let now: Date + public let compact: Bool + + public init(row: WidgetRow, now: Date, compact: Bool) { + self.row = row + self.now = now + self.compact = compact + } + + public var color: Color { + let hsb = UsageColor.color(percent: row.usedPercent) + return Color(hue: hsb.hue, saturation: hsb.saturation, brightness: hsb.brightness) + } + + public var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(compact ? row.label : "\(row.providerName) \(row.label)").font(.caption).lineLimit(1) + Spacer() + Text(row.percentText).font(.caption.monospacedDigit().weight(.semibold)).foregroundStyle(color) + } + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.primary.opacity(0.12)) + Capsule().fill(color).frame(width: max(proxy.size.width * row.usedPercent / 100, 3)) + } + } + .frame(height: 4) + if !compact { + Text("Resets in \(row.resetText(now: now))").font(.caption2).foregroundStyle(.secondary) + } + } + } +} + +public struct UsageWidgetEntryView: View { + public let entry: UsageEntry + @Environment(\.widgetFamily) private var family + + public init(entry: UsageEntry) { + self.entry = entry + } + + public var body: some View { + UsageWidgetView(entry: entry, family: family) + } +} diff --git a/Sources/TokenMenuBarWidgets/WidgetKitGlue.swift b/Sources/TokenMenuBarWidgets/WidgetKitGlue.swift new file mode 100644 index 0000000..3118fde --- /dev/null +++ b/Sources/TokenMenuBarWidgets/WidgetKitGlue.swift @@ -0,0 +1,34 @@ +import SwiftUI +import TokenMenuBarCore +import WidgetKit + +// WidgetKit hands these entry points a context that no test can construct outside an extension host, so this file +// holds the calls alone and the coverage gate caps its size instead. +extension UsageTimelineProvider: TimelineProvider { + public func placeholder(in context: Context) -> UsageEntry { + placeholderEntry() + } + + public func getSnapshot(in context: Context, completion: @escaping (UsageEntry) -> Void) { + completion(entry()) + } + + public func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + completion(timeline()) + } +} + +public struct UsageWidget: Widget { + public static let kind = "dev.tox.token-menu-bar.usage" + + public init() {} + + public var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: UsageTimelineProvider(store: UsageTimelineProvider.defaultStore())) { + UsageWidgetEntryView(entry: $0) + } + .configurationDisplayName("Plan usage") + .description("Claude, Codex and other plan windows with reset countdowns.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/Tests/TokenMenuBarCoreTests/APIClientTests.swift b/Tests/TokenMenuBarCoreTests/APIClientTests.swift new file mode 100644 index 0000000..4878ff7 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/APIClientTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +private let endpoint = URL( + string: "https://example.com/api/organizations/565b9c34-9c85-4cad-a16d-03f1e6e313a0/usage?x=1")! + +@Test func apiClientGetDecodesAndLogs() async throws { + let transport = StubTransport() + transport.on(path: "/usage", .text(#"{"value":1}"#)) + let log = makeLog() + log.debugEnabled = true + let client = APIClient(transport: transport, log: log, clock: testClock) + struct Payload: Decodable { let value: Int } + #expect(try await client.getJSON(Payload.self, endpoint, headers: ["X-Test": "1"], operation: "op").value == 1) + let request = transport.requests[0] + #expect(request.value(forHTTPHeaderField: "X-Test") == "1") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.httpMethod == "GET") + let lines = log.text + #expect(lines.contains("endpoint=https://example.com/api/organizations/{id}/usage")) + #expect(!lines.contains("x=1")) + #expect(lines.contains("status=200")) +} + +@Test func apiClientPostSendsJSONBody() async throws { + let transport = StubTransport() + transport.on(path: "/token", .text("{}")) + let client = APIClient(transport: transport, log: makeLog()) + _ = try await client.post( + URL(string: "https://example.com/token")!, json: Data("{\"a\":1}".utf8), + headers: ["Content-Type": "text/plain", "A": "b"], operation: "post") + let request = transport.requests[0] + #expect(request.httpMethod == "POST") + #expect(request.httpBody == Data("{\"a\":1}".utf8)) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "A") == "b") +} + +@Test func apiClientMapsHTTPErrorsWithRetryAfter() async { + let transport = StubTransport() + transport.on(path: "/header", .text("slow down", status: 429, headers: ["Retry-After": "120"])) + transport.on(path: "/body", .text(#"{"retry_after": 30}"#, status: 429)) + transport.on(path: "/plain", .text(String(repeating: "x", count: 300), status: 500)) + let log = makeLog() + log.debugEnabled = true + let client = APIClient(transport: transport, log: log) + await #expect(throws: APIError.http(status: 429, body: "slow down", retryAfter: 120)) { + try await client.get(URL(string: "https://example.com/header")!, headers: [:], operation: "a") + } + await #expect(throws: APIError.http(status: 429, body: #"{"retry_after": 30}"#, retryAfter: 30)) { + try await client.get(URL(string: "https://example.com/body")!, headers: [:], operation: "b") + } + do { + _ = try await client.get(URL(string: "https://example.com/plain")!, headers: [:], operation: "c") + Issue.record("expected failure") + } catch { + guard case .http(let status, let body, let retryAfter) = error else { + Issue.record("wrong error") + return + } + #expect(status == 500) + #expect(body.count == APIClient.bodySnippetLength) + #expect(retryAfter == nil) + } + #expect(!log.text.contains("slow down")) + #expect(!log.text.contains("retry_after")) + #expect(!log.text.contains(String(repeating: "x", count: 20))) + #expect(log.snapshot.contains { $0.category == .network && $0.message.contains("status=429") }) +} + +@Test func apiClientMapsNetworkAndDecodingErrors() async { + let transport = StubTransport() + transport.on(path: "/down", error: URLError(.notConnectedToInternet)) + transport.on(path: "/bad", .text("not json")) + let log = makeLog() + log.debugEnabled = true + let client = APIClient(transport: transport, log: log) + do { + _ = try await client.get(URL(string: "https://example.com/down")!, headers: [:], operation: "a") + Issue.record("expected failure") + } catch { + guard case .network(let text) = error else { + Issue.record("wrong error") + return + } + #expect(!text.isEmpty) + #expect(error.message.hasPrefix("Network error")) + } + do { + _ = try await client.getJSON( + [String: Int].self, URL(string: "https://example.com/bad")!, headers: [:], operation: "decode") + Issue.record("expected failure") + } catch { + guard case .decoding(let text) = error else { + Issue.record("wrong error") + return + } + #expect(text.hasPrefix("decode:")) + #expect(error.message.hasPrefix("Unexpected response")) + } + do { + _ = try await client.get(URL(string: "https://example.com/unmatched")!, headers: [:], operation: "u") + Issue.record("expected failure") + } catch { + #expect(error.retryAfter == nil) + } + #expect( + log.snapshot.contains { + $0.category == .network && $0.message.contains("request.finished") + && $0.message.contains("errorDomain=NSURLErrorDomain") + }) +} + +@Test func apiClientKeepsRequestFailuresOutOfTheDefaultLog() async { + let transport = StubTransport() + transport.on(path: "/optional", .text("missing", status: 404)) + let log = makeLog() + let client = APIClient(transport: transport, log: log) + + await #expect(throws: APIError.http(status: 404, body: "missing", retryAfter: nil)) { + try await client.get(URL(string: "https://example.com/optional")!, headers: [:], operation: "optional") + } + + #expect(log.snapshot.isEmpty) +} + +@Test func apiErrorClassification() { + #expect(APIError.http(status: 401, body: "", retryAfter: nil).isAuthenticationFailure) + #expect(APIError.http(status: 403, body: "", retryAfter: nil).isAuthenticationFailure) + #expect(!APIError.http(status: 500, body: "", retryAfter: nil).isAuthenticationFailure) + #expect(!APIError.network("x").isAuthenticationFailure) + #expect(APIError.http(status: 429, body: "", retryAfter: nil).isRateLimited) + #expect(!APIError.decoding("x").isRateLimited) + #expect(APIError.http(status: 500, body: "", retryAfter: nil).message == "HTTP 500") + #expect(APIError.http(status: 500, body: "boom", retryAfter: nil).message == "HTTP 500") +} + +@Test func apiClientRedactsURLs() { + #expect(APIClient.redact(nil) == "-") + #expect( + APIClient.redact(URL(string: "https://h/p/ABCDEF12-3456-7890-abcd-ef1234567890/x?q=1")!) == "https://h/p/{id}/x") + #expect(APIClient.retryAfter(nil, Data()) == nil) +} + +@Test func apiClientRejectsMalformedIdentifierShapes() { + #expect(!APIClient.isIdentifier("not-an-identifier")) + #expect(!APIClient.isIdentifier("zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz")) + #expect(APIClient.isIdentifier("abcdef12-3456-7890-abcd-ef1234567890")) +} + +@Test func apiClientLiveSessionUsesBoundedMemoryCache() { + let configuration = APIClient.liveConfiguration() + #expect(configuration.urlCache?.memoryCapacity == APIClient.liveCacheCapacity) + #expect(configuration.urlCache?.diskCapacity == 0) + #expect(configuration.httpCookieStorage == nil) + #expect(configuration.urlCredentialStorage == nil) + #expect(configuration.requestCachePolicy == .useProtocolCachePolicy) +} + +@Test func disabledHTTPTransportRejectsRequests() async { + do { + _ = try await DisabledHTTPTransport().data(for: URLRequest(url: endpoint)) + Issue.record("expected failure") + } catch { + #expect((error as? URLError)?.code == .unsupportedURL) + } +} diff --git a/Tests/TokenMenuBarCoreTests/AdaptiveWidthTests.swift b/Tests/TokenMenuBarCoreTests/AdaptiveWidthTests.swift new file mode 100644 index 0000000..61913dc --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/AdaptiveWidthTests.swift @@ -0,0 +1,134 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func tiersReshapeTheStatusModel() { + let configured = StatusItemBuilder.build(input()) + #expect(configured.cells.count == 3) + #expect(StatusItemBuilder.build(input(tier: .stacked)) == configured) + #expect(StatusItemBuilder.build(input(tier: .worstPerProvider)).cells.map(\.id) == ["claude:weekly", "codex:weekly"]) + #expect(StatusItemBuilder.build(input(tier: .miniBars)).cells.allSatisfy { $0.isMiniBar }) + let icon = StatusItemBuilder.build(input(tier: .iconOnly)) + #expect(icon.cells.isEmpty && icon.showsIcon && !icon.countdownActive) + #expect(StatusItemBuilder.build(input(format: .inline, tier: .stacked)).cells.first?.lines.count == 2) + #expect(input(format: .miniBars, tier: .iconOnly).effectiveFormat == .miniBars) + #expect(input(format: .inline).with(tier: .miniBars).tier == .miniBars) + let percentOrdered = StatusItemInput( + snapshots: input().snapshots, availability: [:], selectedKeys: input().selectedKeys, format: .stacked, + customTemplate: "", decimals: 0, hideZeroCells: true, order: .percent, labels: [:], now: fixedNow, + tier: .worstPerProvider) + #expect(StatusItemBuilder.build(percentOrdered).cells.map(\.id) == ["codex:weekly", "claude:weekly"]) +} + +private func input( + format: StatusFormat = .stacked, tier: StatusTier = .configured, hideZero: Bool = true +) + -> StatusItemInput +{ + let claude = ProviderSnapshot( + provider: .claude, + windows: [ + QuotaWindow(id: "session", label: "Session", group: .session, usedPercent: 36, resetsAt: fixedNow), + QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: 61, resetsAt: fixedNow), + ], fetchedAt: fixedNow) + let codex = ProviderSnapshot( + provider: .codex, + windows: [QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: 80, resetsAt: fixedNow)], + fetchedAt: fixedNow) + let snapshots: [ProviderID: ProviderSnapshot] = [.claude: claude, .codex: codex] + return StatusItemInput( + snapshots: snapshots, availability: [.claude: .current, .codex: .current], + selectedKeys: StatusItemBuilder.defaultSelection(snapshots), format: format, customTemplate: "{cell}", + decimals: 0, hideZeroCells: hideZero, order: .provider, labels: [:], now: fixedNow, tier: tier) +} + +@Test func candidatesDedupeEqualModels() { + let candidates = StatusItemBuilder.candidates(input()) + #expect(candidates.count == 4) + #expect(candidates.first == StatusItemBuilder.build(input())) + #expect(candidates.last?.cells.isEmpty == true) + #expect(Set(candidates).count == candidates.count) + #expect(StatusItemBuilder.candidates(input(format: .miniBars)).count == 4) +} + +@Test func plannerStepsDownRemembersAndForgets() { + var planner = AdaptiveWidthPlanner() + #expect(planner.begin(context: "app", ladderCount: 3) == 0) + #expect(planner.didNotFit(ladderCount: 3) == 1) + #expect(planner.didNotFit(ladderCount: 3) == 2) + #expect(planner.didNotFit(ladderCount: 3) == nil) + #expect(planner.index == 2) + planner.didFit(context: "app") + #expect(planner.begin(context: "other", ladderCount: 3) == 0) + // one tier wider than what last fit, so space freed since then gets retried + #expect(planner.begin(context: "app", ladderCount: 3) == 1) + #expect(planner.begin(context: "app", ladderCount: 2) == 0) + #expect(planner.didNotFit(ladderCount: 3) == 1) + planner.didFit(context: "app") + #expect(planner.begin(context: "app", ladderCount: 3) == 0) + planner.forget() + #expect(planner.begin(context: "app", ladderCount: 3) == 0) + #expect(planner.begin(context: "app", ladderCount: 0) == 0) + #expect(planner == AdaptiveWidthPlanner()) +} + +@Test func ladderKeepsOnlyNarrowerUniqueModels() { + let candidates = StatusItemBuilder.candidates(input()) + let ladder = AdaptiveWidthPlanner.ladder(candidates, widths: [100, 120, 60, 80]) + #expect(ladder == [candidates[0], candidates[3], candidates[2]]) + #expect(AdaptiveWidthPlanner.ladder([candidates[0], candidates[0]], widths: [100, 50]) == [candidates[0]]) + #expect(AdaptiveWidthPlanner.ladder([], widths: []).isEmpty) +} + +@Test func plannerCanSelectItsNarrowestTier() { + var planner = AdaptiveWidthPlanner() + #expect(planner.selectNarrowest(ladderCount: 5) == 4) + #expect(planner.index == 4) + #expect(planner.selectNarrowest(ladderCount: 0) == 0) +} + +@Test func notchDetectionUsesAuxiliaryAreas() { + let left = CGRect(x: 0, y: 0, width: 400, height: 30) + let right = CGRect(x: 600, y: 0, width: 400, height: 30) + #expect( + AdaptiveWidthPlanner.hiddenByNotch( + itemFrame: CGRect(x: 500, y: 0, width: 50, height: 30), leftArea: left, rightArea: right)) + #expect( + AdaptiveWidthPlanner.hiddenByNotch( + itemFrame: CGRect(x: 580, y: 0, width: 50, height: 30), leftArea: left, rightArea: right)) + #expect( + !AdaptiveWidthPlanner.hiddenByNotch( + itemFrame: CGRect(x: 700, y: 0, width: 50, height: 30), leftArea: left, rightArea: right)) + #expect( + !AdaptiveWidthPlanner.hiddenByNotch( + itemFrame: CGRect(x: 100, y: 0, width: 50, height: 30), leftArea: left, rightArea: right)) + #expect( + !AdaptiveWidthPlanner.hiddenByNotch( + itemFrame: CGRect(x: 500, y: 0, width: 50, height: 30), leftArea: nil, rightArea: right)) +} + +@Test func onScreenNeedsOverlapAndWidth() { + let screen = CGRect(x: 0, y: 0, width: 1440, height: 900) + #expect( + AdaptiveWidthPlanner.isOnScreen(itemFrame: CGRect(x: 100, y: 0, width: 60, height: 24), screenFrames: [screen])) + #expect( + AdaptiveWidthPlanner.isOnScreen(itemFrame: CGRect(x: 1430, y: 0, width: 60, height: 24), screenFrames: [screen])) + #expect( + !AdaptiveWidthPlanner.isOnScreen(itemFrame: CGRect(x: 5000, y: 0, width: 60, height: 24), screenFrames: [screen])) + #expect( + !AdaptiveWidthPlanner.isOnScreen(itemFrame: CGRect(x: 10, y: 0, width: 0, height: 24), screenFrames: [screen])) + #expect(!AdaptiveWidthPlanner.isOnScreen(itemFrame: screen, screenFrames: [])) +} + +@Test(arguments: [(1, [13.0]), (2, [9.0, 11.5])]) +func fontSizesMatchTheLineCount(lineCount: Int, expected: [Double]) { + #expect(StatusMetrics.fontSizes(height: 24, lineCount: lineCount) == expected) +} + +@Test func threeLinesShrinkWithTheBarHeight() { + #expect(StatusMetrics.fontSizes(height: 24, lineCount: 3) == [8, 8, 8]) + #expect(StatusMetrics.fontSizes(height: 120, lineCount: 3) == [9, 9, 9]) + #expect(StatusMetrics.fontSizes(height: 1, lineCount: 3).allSatisfy { $0 == StatusMetrics.minFontSize }) +} diff --git a/Tests/TokenMenuBarCoreTests/AnalyticsCorrectionScopeTests.swift b/Tests/TokenMenuBarCoreTests/AnalyticsCorrectionScopeTests.swift new file mode 100644 index 0000000..04b3e3a --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/AnalyticsCorrectionScopeTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func providerAnalyticsMergeReplacesOnlyCoveredMetricDays() { + let previous = ProviderAnalytics( + provider: .codex, + points: correctionSeed, + fetchedAt: fixedNow, + accountFingerprint: "account") + let correction = ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: "2026-08-29", metric: .surfaceUsagePercent, series: "cli", value: 9)], + fetchedAt: fixedNow, + accountFingerprint: "account", + coveredScopes: [tokenUsageScope]) + + #expect(Set(previous.merging(correction, retentionDays: 7).points) == Set(correctedPoints)) +} + +@Test func historyRecordReplacesOnlyCoveredMetricDays() async throws { + let store = try UsageHistoryStore(url: nil) + try await store.record( + ProviderAnalytics( + provider: .codex, points: correctionSeed, fetchedAt: fixedNow, accountFingerprint: "account")) + + try await store.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: "2026-08-29", metric: .surfaceUsagePercent, series: "cli", value: 9)], + fetchedAt: fixedNow, + accountFingerprint: "account", + coveredScopes: [tokenUsageScope])) + + let stored = try await store.analytics(provider: .codex, from: "2026-08-28", to: "2026-08-29") + #expect(Set(stored) == Set(correctedPoints)) +} + +@Test func historyRecordRollsBackScopeDeletionWhenInsertionFails() async throws { + let store = try UsageHistoryStore(url: nil) + let previous = AnalyticsPoint(day: "2026-08-29", metric: .surfaceUsagePercent, series: "web", value: 5) + try await store.record(ProviderAnalytics(provider: .codex, points: [previous], fetchedAt: fixedNow)) + try await store.rejectAnalyticsInsertions() + + await #expect(throws: SQLiteError.self) { + try await store.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: "2026-08-29", metric: .surfaceUsagePercent, series: "cli", value: 9)], + fetchedAt: fixedNow, + coveredScopes: [tokenUsageScope])) + } + + #expect(try await store.analytics(provider: .codex, from: "2026-08-29", to: "2026-08-29") == [previous]) +} + +@Test func codexProviderReportsCoverageOnlyForSuccessfulEndpoints() async throws { + let transport = StubTransport() + transport.on( + path: "/wham/usage", + .text( + #"{"rate_limit_reset_credits":{"available_count":1,"total_earned_count":1,"# + + #""immediate_reset_purchase_eligible":true}}"#)) + for endpoint in [ + CodexAPI.Analytics.tokenUsage, .workspaceCounts, .plugins, .codeReview, + ] { + transport.on(path: endpoint.rawValue, .text(#"{"data":[]}"#)) + } + transport.on(path: CodexAPI.Analytics.skills.rawValue, error: URLError(.notConnectedToInternet)) + transport.on(path: "credit-usage-events", .text(#"{"data":[]}"#)) + + let result = await codexProvider(MemoryCodexStore(validCodex), transport: transport).fetch( + now: fixedNow, options: FetchOptions(includeAnalytics: true, analyticsDays: 7)) + let analytics = try #require(result.analytics) + let expected: Set = [ + AnalyticsCoverageScope( + metrics: [.surfaceUsagePercent, .modelCredits], startDay: "2026-08-23", endDay: "2026-08-29"), + AnalyticsCoverageScope( + metrics: [.inputTokens, .cachedInputTokens, .outputTokens, .turns, .threads, .credits], + startDay: "2026-08-23", endDay: "2026-08-29"), + AnalyticsCoverageScope(metrics: [.pluginInvocations], startDay: "2026-08-23", endDay: "2026-08-29"), + AnalyticsCoverageScope(metrics: [.codeReviews], startDay: "2026-08-23", endDay: "2026-08-29"), + ] + + #expect(analytics.points.isEmpty) + #expect(Set(analytics.coveredScopes) == expected) + #expect(result.warnings.count == 1) + #expect(result.warnings[0].hasPrefix("Skills analytics unavailable")) +} + +@Test func codexAnalyticsEndpointsDeclareProducedMetrics() { + #expect(CodexAPI.Analytics.tokenUsage.metrics == [.surfaceUsagePercent, .modelCredits]) + #expect( + CodexAPI.Analytics.workspaceCounts.metrics + == [.inputTokens, .cachedInputTokens, .outputTokens, .turns, .threads, .credits]) + #expect(CodexAPI.Analytics.skills.metrics == [.skillInvocations]) + #expect(CodexAPI.Analytics.plugins.metrics == [.pluginInvocations]) + #expect(CodexAPI.Analytics.codeReview.metrics == [.codeReviews]) +} + +@Test func providerAnalyticsCoverageRoundTripsAndLegacyPayloadsDecode() throws { + let analytics = ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: "2026-08-29", metric: .surfaceUsagePercent, series: "cli", value: 9)], + fetchedAt: fixedNow, + accountFingerprint: "account", + coveredScopes: [tokenUsageScope]) + let encoded = try JSONEncoder().encode(analytics) + #expect(try JSONDecoder().decode(ProviderAnalytics.self, from: encoded) == analytics) + + var document = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + document.removeValue(forKey: "accountFingerprint") + document.removeValue(forKey: "coveredScopes") + let legacy = try JSONDecoder().decode( + ProviderAnalytics.self, from: JSONSerialization.data(withJSONObject: document)) + #expect(legacy.accountFingerprint == nil) + #expect(legacy.coveredScopes.isEmpty) +} + +private let tokenUsageScope = AnalyticsCoverageScope( + metrics: [.surfaceUsagePercent, .modelCredits], startDay: "2026-08-28", endDay: "2026-08-29") + +private let correctionSeed = [ + AnalyticsPoint(day: "2026-08-28", metric: .surfaceUsagePercent, series: "web", value: 3), + AnalyticsPoint(day: "2026-08-29", metric: .surfaceUsagePercent, series: "cli", value: 4), + AnalyticsPoint(day: "2026-08-29", metric: .surfaceUsagePercent, series: "web", value: 5), + AnalyticsPoint(day: "2026-08-29", metric: .modelCredits, series: "removed-model", value: 7), + AnalyticsPoint(day: "2026-08-29", metric: .skillInvocations, series: "failed-endpoint", value: 6), +] + +private let correctedPoints = [ + AnalyticsPoint(day: "2026-08-29", metric: .surfaceUsagePercent, series: "cli", value: 9), + AnalyticsPoint(day: "2026-08-29", metric: .skillInvocations, series: "failed-endpoint", value: 6), +] + +extension UsageHistoryStore { + fileprivate func rejectAnalyticsInsertions() throws { + try database.execute( + """ + CREATE TRIGGER reject_analytics_insert BEFORE INSERT ON analytics + BEGIN + SELECT RAISE(ABORT, 'rejected'); + END + """) + } +} diff --git a/Tests/TokenMenuBarCoreTests/BrandTests.swift b/Tests/TokenMenuBarCoreTests/BrandTests.swift new file mode 100644 index 0000000..a544d92 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/BrandTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func brandColorsRoundTripThroughHex() { + #expect(Brand.gradientStart.hex == "#4C3BE0") + #expect(Brand.gradientEnd.hex == "#9A6BFF") + #expect(Brand.iris.hex == "#5A46E8") + #expect(Brand.irisDark.hex == "#A78BFA") + #expect(Brand.pageDark.hex == "#0F1117") + #expect(Brand.pageLight.hex == "#FAFAFC") + #expect(Brand.card(dark: true).hex == "#171A22") + #expect(Brand.card(dark: false).hex == "#FFFFFF") + #expect(BrandColor(red: -1, green: 2, blue: 0.5).hex == "#00FF80") + #expect(BrandColor(0xFFFFFF) == BrandColor(red: 1, green: 1, blue: 1)) +} + +@Test func brandGradientInterpolatesBetweenStops() { + #expect(Brand.gradient(at: 0) == Brand.gradientStart) + #expect(Brand.gradient(at: 1) == Brand.gradientEnd) + #expect(Brand.gradient(at: 2) == Brand.gradientEnd) + let middle = Brand.gradient(at: 0.5) + #expect(middle.red > Brand.gradientStart.red && middle.red < Brand.gradientEnd.red) + #expect(middle.blue > Brand.gradientStart.blue) + #expect(Brand.name == "Token Menu Bar") + #expect(!Brand.tagline.isEmpty) +} + +@Test func usageStopsConvertTheSemanticScaleToHex() { + let stops = Brand.usageStops + #expect(stops.map(\.name) == ["green", "orange", "red"]) + #expect(stops[0].color.green > stops[0].color.red) + #expect(stops[1].color.red > stops[1].color.green && stops[1].color.green > stops[1].color.blue) + #expect(stops[2].color.red > stops[2].color.green) + #expect(stops.allSatisfy { $0.color.hex.count == 7 }) + #expect(Brand.usage(0).hex == stops[0].color.hex) +} + +@Test(arguments: [0.0, 0.1, 0.25, 0.4, 0.55, 0.7, 0.85, 1.0]) +func usageConversionCoversEveryHueSector(hueFraction: Double) { + let color = Brand.usage(hueFraction * 100) + #expect((0...1).contains(color.red) && (0...1).contains(color.green) && (0...1).contains(color.blue)) + #expect(Brand.rgb(HSBColor(hue: hueFraction, saturation: 0.8, brightness: 0.8)).hex.count == 7) +} diff --git a/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift b/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift new file mode 100644 index 0000000..e041c5a --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift @@ -0,0 +1,532 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func bucketKeepsLatestSamplePerBucket() { + let samples = [sample(0.2, 1), sample(0.8, 2), sample(1.5, 3), sample(0.5, 9, key: other)] + let buckets = ChartPipeline.bucket(samples, rollup: .minute, timeZone: utc) + #expect(buckets.map(\.value) == [2, 3]) + #expect(ChartPipeline.bucket(samples, rollup: .hour, timeZone: utc).map(\.value) == [3]) + #expect(ChartPipeline.bucket([sample(0.8, 2), sample(0.2, 1)], rollup: .minute, timeZone: utc).map(\.value) == [2]) +} + +private let key = WindowKey(provider: .claude, windowID: "session") +private let other = WindowKey(provider: .codex, windowID: "weekly") +private let utc = TimeZone(identifier: "UTC")! + +private func sample(_ minutes: Double, _ value: Double, resets: Double? = 300, key: WindowKey = key) -> UsageSample { + UsageSample( + timestamp: fixedNow.addingTimeInterval(minutes * 60), key: key, usedPercent: value, + resetsAt: resets.map { fixedNow.addingTimeInterval($0 * 60) }) +} + +@Test func insertResetZerosAddsCliffAtResetBoundary() { + let points = [raw(0, 80, resets: 10), raw(20, 5, resets: 310)] + let result = ChartPipeline.insertResetZeros(points) + #expect(result.map(\.value) == [80, 0, 5]) + #expect(result[1].date == fixedNow.addingTimeInterval(600)) + #expect(result[1].resetsAt == fixedNow.addingTimeInterval(600)) + let dropOnly = ChartPipeline.insertResetZeros([raw(0, 80, resets: 10), raw(5, 5, resets: 310)]) + #expect(dropOnly.map(\.value) == [80, 0, 5]) + #expect(dropOnly[1].date == fixedNow.addingTimeInterval(299)) + #expect(ChartPipeline.insertResetZeros([raw(0, 80), raw(5, 5)]).map(\.value) == [80, 5]) + #expect(ChartPipeline.insertResetZeros([raw(0, 1)]).count == 1) + #expect(ChartPipeline.insertResetZeros([raw(0, 5, resets: 10), raw(5, 80, resets: 310)]).map(\.value) == [5, 80]) +} + +private func raw(_ minutes: Double, _ value: Double, resets: Double? = nil) -> ChartPipeline.Raw { + ChartPipeline.Raw( + date: fixedNow.addingTimeInterval(minutes * 60), value: value, + resetsAt: resets.map { fixedNow.addingTimeInterval($0 * 60) }) +} + +@Test func clipCarriesLastValueIntoDomain() { + let points = [raw(-1, 30), raw(5, 40), raw(20, 50)] + let clipped = ChartPipeline.clip(points, start: fixedNow, end: fixedNow.addingTimeInterval(600), cadence: 300) + #expect(clipped.map(\.value) == [30, 40]) + #expect(clipped[0].date == fixedNow) + let exact = ChartPipeline.clip( + [raw(0, 1), raw(1, 2)], start: fixedNow, end: fixedNow.addingTimeInterval(600), cadence: 300) + #expect(exact.map(\.value) == [1, 2]) + #expect( + ChartPipeline.clip([raw(5, 1)], start: fixedNow, end: fixedNow.addingTimeInterval(600), cadence: 300).count == 1) + #expect( + ChartPipeline.clip([raw(-10, 1)], start: fixedNow, end: fixedNow.addingTimeInterval(600), cadence: 300).isEmpty) +} + +@Test func changePointsKeepsRunEndsAndResets() { + let points = [raw(0, 1), raw(1, 1), raw(2, 1), raw(3, 2), raw(4, 0), raw(5, 0), raw(6, 3)] + let kept = ChartPipeline.changePoints(points) + #expect(kept.map(\.value) == [1, 1, 2, 0, 0, 3]) + #expect(kept.map { $0.date.timeIntervalSince(fixedNow) / 60 } == [0, 2, 3, 4, 5, 6]) + #expect(ChartPipeline.changePoints([raw(0, 1), raw(1, 1)]).count == 2) + #expect(ChartPipeline.changePoints([raw(0, 1), raw(1, 2), raw(2, 2)]).map(\.value) == [1, 2, 2]) +} + +@Test func changePointsKeepBothSidesOfAStaleGap() { + let points = [raw(0, 20), raw(5, 20), raw(10, 20), raw(30, 20), raw(35, 20)] + let changed = ChartPipeline.changePoints(points, cadence: 300) + #expect(changed.map { $0.date.timeIntervalSince(fixedNow) / 60 } == [0, 10, 30, 35]) + let data = ChartPipeline.render( + samples: points.map { + UsageSample(timestamp: $0.date, key: key, usedPercent: $0.value, resetsAt: $0.resetsAt) + }, + request: HistoryRequest( + keys: [key], start: fixedNow, end: fixedNow.addingTimeInterval(35 * 60), rollup: .minute, timeZone: utc), + labels: [:], now: fixedNow.addingTimeInterval(35 * 60)) + #expect(data.series[0].points.map(\.segment) == [0, 0, 1, 1]) +} + +@Test func renderConnectsSamplesAtTheObservedRefreshCadence() { + let samples = (0..<5).map { sample(Double($0 * 30), Double($0 * 10), resets: nil) } + + let data = ChartPipeline.render( + samples: samples, + request: HistoryRequest( + keys: [key], start: fixedNow, end: fixedNow.addingTimeInterval(120 * 60), rollup: .minute, timeZone: utc), + labels: [:], now: fixedNow.addingTimeInterval(120 * 60)) + + let points = data.series[0].points + #expect(points.count == 5) + #expect(Set(points.map(\.segment)) == [0]) + #expect( + data.series[0].value(at: fixedNow.addingTimeInterval(45 * 60), metric: .windowUsagePercent)?.value == 10) +} + +@Test func extendToNowPinsLatestValue() { + let extended = ChartPipeline.extendToNow([raw(0, 4)], end: fixedNow.addingTimeInterval(120)) + #expect(extended.map(\.value) == [4, 4]) + #expect(extended[1].date == fixedNow.addingTimeInterval(120)) + #expect(ChartPipeline.extendToNow([], end: fixedNow).isEmpty) + #expect(ChartPipeline.extendToNow([raw(5, 4)], end: fixedNow).count == 1) +} + +@Test func downsamplePreservesExtremes() { + let points = (0..<1000).map { raw(Double($0), $0 == 500 ? 100 : $0 == 700 ? 0 : 50) } + let reduced = ChartPipeline.downsample(points, limit: 100) + #expect(reduced.count <= 100) + #expect(reduced.first == points.first) + #expect(reduced.last == points.last) + #expect(reduced.contains { $0.value == 100 }) + #expect(reduced.contains { $0.value == 0 }) + #expect(zip(reduced, reduced.dropFirst()).allSatisfy { $0.date <= $1.date }) + #expect(ChartPipeline.downsample(points, limit: 2000).count == 1000) + #expect(ChartPipeline.downsample((0..<50).map { raw(Double($0), Double(50 - $0)) }, limit: 10).count <= 10) +} + +@Test(arguments: [0, 1, 2]) +func downsampleHonorsTinyPointBudgets(limit: Int) { + let points = (0..<10).map { raw(Double($0), Double($0)) } + + #expect(ChartPipeline.downsample(points, limit: limit).count == limit) +} + +@Test func renderSharesOnePointBudgetAcrossEveryPopulatedWindow() { + let keys = (0..<13).map { WindowKey(provider: .codex, windowID: "model-\($0)") } + let samples = keys.enumerated().flatMap { keyIndex, key in + (0..<300).map { point in + UsageSample( + timestamp: fixedNow.addingTimeInterval(Double(point) * 60), key: key, + usedPercent: Double((keyIndex + point) % 100), resetsAt: nil) + } + } + let data = ChartPipeline.render( + samples: samples, + request: HistoryRequest( + keys: keys, start: fixedNow, end: fixedNow.addingTimeInterval(299 * 60), rollup: .minute, + timeZone: utc), + labels: [:], now: fixedNow.addingTimeInterval(299 * 60)) + + #expect(data.series.count == keys.count) + #expect(data.series.allSatisfy { !$0.points.isEmpty }) + #expect(data.series.reduce(0) { $0 + $1.points.count } <= ChartPipeline.maxTotalPoints) + #expect(data.dataPointCount == samples.count) +} + +@Test func analyticsRenderSharesOnePointBudgetAcrossEveryPopulatedSeries() { + let start = fixedNow.addingTimeInterval(-199 * 86400) + let rows = (0..<13).flatMap { series in + (0..<200).map { day in + HistoryAnalyticsRow( + provider: .claude, + point: AnalyticsPoint( + day: DayStamp.string(start.addingTimeInterval(Double(day) * 86400)), metric: .inputTokens, + series: "model:\(series)", value: Double(series + day))) + } + } + + let data = ChartPipeline.renderAnalytics( + rows: rows, metric: .analytics(.inputTokens), start: start, end: fixedNow) + + #expect(data.series.count == 13) + #expect(data.series.allSatisfy { !$0.points.isEmpty }) + #expect(data.series.reduce(0) { $0 + $1.points.count } <= ChartPipeline.maxTotalPoints) + #expect(data.dataPointCount == rows.count) +} + +@Test func downsampleKeepsAHardLimitAcrossManyGaps() { + let points = (0..<1000).map { raw(Double($0 * 10), Double($0 % 100)) } + let reduced = ChartPipeline.downsample(points, limit: 100, cadence: 300) + + #expect(reduced.count == 100) + #expect(reduced.first == points.first) + #expect(reduced.last == points.last) +} + +@Test func downsampleUsesTheSingleRemainingSlotForTheLargestPoint() { + let points = [ + raw(0, 1), ChartPipeline.Raw(date: raw(1, 2).date, value: 2, resetsAt: nil, isReset: true), + ChartPipeline.Raw(date: raw(2, 3).date, value: 3, resetsAt: nil, isReset: true), raw(3, 40), raw(4, 4), + raw(5, 5), + ] + + let reduced = ChartPipeline.downsample(points, limit: 5) + + #expect(reduced.count == 5) + #expect(reduced.contains { $0.value == 40 }) + #expect(!reduced.contains { $0.value == 4 }) +} + +@Test func stackAccumulatesBases() { + let lower = HistorySeries( + key: key, label: "a", + points: [SeriesPoint(date: fixedNow, value: 10), SeriesPoint(date: fixedNow.addingTimeInterval(60), value: 20)]) + let upper = HistorySeries( + key: other, label: "b", points: [SeriesPoint(date: fixedNow.addingTimeInterval(30), value: 5)]) + let stacked = ChartPipeline.stack([lower, upper]) + #expect(stacked[0].points.map(\.value) == [10, 10, 20]) + #expect(stacked[1].points.map(\.stackBase) == [10, 10, 20]) + #expect(stacked[1].points.map(\.value) == [0, 5, 5]) + #expect(stacked[1].points.last?.stackTop == 25) + + // A series with no points holds the stack flat rather than dropping the ones above it. + let empty = HistorySeries(key: WindowKey(provider: .codex, windowID: "none"), label: "c", points: []) + let withEmpty = ChartPipeline.stack([empty, lower]) + #expect(withEmpty[0].points.map(\.value) == [0, 0]) + #expect(withEmpty[1].points.map(\.value) == [10, 20]) + #expect(withEmpty[1].points.map(\.stackBase) == [0, 0]) +} + +@Test func renderProducesSeriesDomainAndYMax() { + let samples = [sample(-30, 10), sample(0, 80, resets: 5), sample(20, 5, resets: 305), sample(0, 60, key: other)] + let request = HistoryRequest( + keys: [key, other], start: fixedNow.addingTimeInterval(-600), end: fixedNow.addingTimeInterval(3600), + rollup: .minute, timeZone: utc) + let data = ChartPipeline.render( + samples: samples, request: request, labels: [key: "Session"], now: fixedNow.addingTimeInterval(1800)) + #expect(data.series.map(\.label) == ["Session", "weekly"]) + #expect(data.domain == request.start...fixedNow.addingTimeInterval(1800)) + #expect(data.yMax == 100) + #expect(data.series[0].points.map(\.value) == [10, 80, 0, 5, 5]) + #expect(data.series[0].points.last?.date == fixedNow.addingTimeInterval(1800)) + #expect(!data.isEmpty) + #expect(data.dataPointCount == 3) + #expect(data.series[0].value(at: fixedNow.addingTimeInterval(10))?.value == 80) + #expect( + ChartPipeline.nearestDate(in: data, to: fixedNow.addingTimeInterval(1190)) == fixedNow.addingTimeInterval(1200)) + let stacked = ChartPipeline.render( + samples: samples, + request: HistoryRequest( + keys: [key, other], start: request.start, end: request.end, rollup: .minute, stacked: true, timeZone: utc), + labels: [:], now: fixedNow.addingTimeInterval(1800)) + #expect(stacked.yMax == 140) + let empty = ChartPipeline.render(samples: [], request: request, labels: [:], now: fixedNow) + #expect(empty.isEmpty) + #expect(ChartPipeline.nearestDate(in: empty, to: fixedNow) == nil) + let future = ChartPipeline.render( + samples: [], + request: HistoryRequest( + keys: [key], start: fixedNow.addingTimeInterval(60), end: fixedNow.addingTimeInterval(120), rollup: .day), + labels: [:], now: fixedNow) + #expect(future.domain.lowerBound == future.domain.upperBound) +} + +@Test func dailyBucketsKeepEverySeriesAndZero() { + var points: [AnalyticsPoint] = [] + for index in 0..<10 { + points.append(AnalyticsPoint(day: "2026-08-01", metric: .turns, series: "s\(index)", value: Double(index + 1))) + } + points.append(AnalyticsPoint(day: "2026-08-02", metric: .turns, series: "s9", value: 0)) + points.append(AnalyticsPoint(day: "2026-08-02", metric: .credits, series: "ignored", value: 5)) + let buckets = ChartPipeline.dailyBuckets(points, metric: .turns, topSeries: 3) + #expect(Set(buckets.map(\.series)) == Set((0..<10).map { "s\($0)" })) + #expect(buckets.count == 11) + #expect(buckets.last?.day == "2026-08-02") + #expect(buckets.last?.series == "s9") + #expect(buckets.last?.value == 0) + #expect(ChartPipeline.dailyBuckets([], metric: .turns).isEmpty) +} + +@Test func historyEnumsExposeSpans() { + #expect(HistoryRange.allCases.map(\.days) == [1, 7, 30, 60, nil]) + #expect(Rollup.allCases.map(\.seconds) == [60, 3600, 86400]) + #expect(HistorySeries(key: key, label: "x", points: []).value(at: fixedNow) == nil) + #expect(HistorySeries(key: key, label: "x", points: []).id == .window(key)) +} + +@Test func historyMetricsEncodeSupplierAndMarkRules() { + #expect(HistoryMetric.allCases.count == 17) + #expect(HistoryMetric.allCases.filter { $0.group == .windows }.count == 1) + #expect(HistoryMetric.allCases.filter { $0.group == .bothProviders }.count == 3) + #expect(HistoryMetric.allCases.filter { $0.group == .claude }.count == 5) + #expect(HistoryMetric.allCases.filter { $0.group == .codex }.count == 8) + #expect(HistoryMetric.windowUsagePercent.markKind == .stepLine) + #expect(HistoryMetric.analytics(.surfaceUsagePercent).markKind == .line) + #expect(HistoryMetric.analytics(.turns).markKind == .bars) + #expect(!HistoryMetric.windowUsagePercent.supportsStacking) + #expect(!HistoryMetric.analytics(.turns).supportsStacking) + #expect(HistoryMetric.analytics(.inputTokens).supportsStacking) + #expect(HistoryMetric.analytics(.inputTokens).suppliers == [.claude, .codex]) + for metric in HistoryMetric.allCases { + #expect(HistoryMetric(storageID: metric.storageID) == metric) + } + #expect(HistoryMetric(storageID: "unknown") == nil) +} + +@Test func historyMetricsExplainEveryProviderBreakdown() { + let expected: [HistoryMetric: String] = [ + .windowUsagePercent: "Every model · step line · selected time zone", + .analytics(.inputTokens): "Claude + Codex · Claude by model, Codex total · daily UTC", + .analytics(.cachedInputTokens): "Claude + Codex · Claude by model, Codex total · daily UTC", + .analytics(.outputTokens): "Claude + Codex · Claude by model, Codex total · daily UTC", + .analytics(.surfaceUsagePercent): "Codex · by surface · daily UTC", + .analytics(.modelCredits): "Codex · by model · daily UTC", + .analytics(.turns): "Codex · by model and surface · daily UTC", + .analytics(.threads): "Codex · by model and surface · daily UTC", + .analytics(.credits): "Codex · by model and surface · daily UTC", + .analytics(.skillInvocations): "Codex · by skill · daily UTC", + .analytics(.pluginInvocations): "Codex · by plugin · daily UTC", + .analytics(.codeReviews): "Codex · by review type · daily UTC", + .analytics(.cacheWriteTokens): "Claude · by model · daily UTC", + .analytics(.costUSD): "Claude · by model · daily UTC", + .analytics(.messages): "Claude · one series · daily UTC", + .analytics(.sessions): "Claude · one series · daily UTC", + .analytics(.toolCalls): "Claude · one series · daily UTC", + ] + + #expect(Dictionary(uniqueKeysWithValues: HistoryMetric.allCases.map { ($0, $0.attribution) }) == expected) + #expect( + HistoryMetric.analytics(.inputTokens).attribution(providers: [.codex]) + == "Codex · total · daily UTC") + #expect( + HistoryMetric.analytics(.inputTokens).attribution(providers: [.claude, .codex]) + == "Claude + Codex · Claude by model, Codex total · daily UTC") + #expect( + HistoryMetric.windowUsagePercent.attribution(providers: [.claude]) + == "Claude · enabled models · step line · selected time zone") + #expect(HistoryMetric.analytics(.turns).attribution(providers: []) == "No enabled provider data in this period") +} + +@Test func historyDataScopeRequiresAnActiveProviderAndSelectedModel() { + let selected = WindowKey(provider: .claude, windowID: "selected") + let other = WindowKey(provider: .claude, windowID: "other") + let scope = HistoryDataScope(activeProviders: [.claude], selectedWindows: [selected]) + #expect(scope.includes(selected)) + #expect(!scope.includes(other)) + #expect(!scope.includes(WindowKey(provider: .codex, windowID: "selected"))) + #expect(HistoryDataScope.all.includes(other)) +} + +@Test func resetEventIdentityIncludesTheSeriesAndDate() { + let first = HistoryResetEvent(seriesID: .window(key), date: fixedNow, resetsAt: fixedNow) + let second = HistoryResetEvent(seriesID: .window(other), date: fixedNow, resetsAt: fixedNow) + let later = HistoryResetEvent( + seriesID: .window(key), date: fixedNow.addingTimeInterval(1), resetsAt: fixedNow.addingTimeInterval(1)) + + #expect(first.id != second.id) + #expect(first.id != later.id) + #expect(first.id.hasPrefix("window:\(key.storageKey):")) +} + +@Test func historyStylesAreDeterministicAndStayDistinct() { + let identities = Set( + (0..<65).map { + let slot = HistoryStyleSlot(index: $0) + return slot.visualIdentity + }) + #expect(identities.count == 65) + #expect(HistoryStyleSlot(index: 8).hueIndex == 0) + #expect(HistoryStyleSlot(index: 8).variant == 1) + let first = HistoryStyleSlot(storageKey: "analytics:codex:surface:cli") + #expect(first == HistoryStyleSlot(storageKey: "analytics:codex:surface:cli")) + #expect(first != HistoryStyleSlot(storageKey: "analytics:codex:surface:web")) + let ids = (0..<20).map { HistorySeriesID.analytics(provider: .codex, series: "series:\($0)") } + let forward = HistoryStyleSlot.allocate(ids) + let reverse = HistoryStyleSlot.allocate(ids.reversed()) + #expect(forward == reverse) + #expect(Set(forward.values.map(\.visualIdentity)).count == ids.count) +} + +@Test func analyticsRenderKeepsProviderQualifiedSeriesAndGaps() { + let start = DayStamp.date("2026-08-01")! + let end = DayStamp.date("2026-08-04")! + let rows = [ + HistoryAnalyticsRow( + provider: .claude, + point: AnalyticsPoint(day: "2026-08-01", metric: .inputTokens, series: "model:a", value: 5)), + HistoryAnalyticsRow( + provider: .claude, + point: AnalyticsPoint(day: "2026-08-03", metric: .inputTokens, series: "model:a", value: 0)), + HistoryAnalyticsRow( + provider: .codex, + point: AnalyticsPoint(day: "2026-08-01", metric: .inputTokens, series: "model:a", value: 7)), + ] + let model = ChartPipeline.renderAnalytics( + rows: rows, metric: .analytics(.inputTokens), start: start, end: end) + #expect(model.series.count == 2) + #expect( + Set(model.series.map(\.id)) == [ + .analytics(provider: .claude, series: "model:a"), .analytics(provider: .codex, series: "model:a"), + ]) + #expect(model.series.first { $0.id.provider == .claude }?.points.map(\.value) == [5, 0]) + #expect(model.series.first { $0.id.provider == .claude }?.points.map(\.segment) == [0, 0]) + #expect(model.series.first { $0.id.provider == .claude }?.label == "Claude · Model · a") + #expect(model.summaryText == "12 tokens") + #expect(model.dataPointCount == 3) + let stacked = ChartPipeline.renderAnalytics( + rows: rows, metric: .analytics(.inputTokens), start: start, end: end, stacked: true) + #expect(stacked.yMax == 12 * 1.08) +} + +@Test func percentageAnalyticsStartsANewSegmentAfterAMissingDay() { + let rows = [ + HistoryAnalyticsRow( + provider: .codex, + point: AnalyticsPoint(day: "2026-08-01", metric: .surfaceUsagePercent, series: "cli", value: 5)), + HistoryAnalyticsRow( + provider: .codex, + point: AnalyticsPoint(day: "2026-08-04", metric: .surfaceUsagePercent, series: "cli", value: 8)), + ] + + let model = ChartPipeline.renderAnalytics( + rows: rows, metric: .analytics(.surfaceUsagePercent), start: DayStamp.date("2026-08-01")!, + end: DayStamp.date("2026-08-05")!) + + #expect(model.series[0].points.map(\.segment) == [0, 1]) +} + +@Test func analyticsRendererRejectsAWindowMetric() { + let model = ChartPipeline.renderAnalytics( + rows: [], metric: .windowUsagePercent, start: fixedNow, end: fixedNow.addingTimeInterval(60)) + + #expect(model.metric == .windowUsagePercent) + #expect(model.isEmpty) + #expect(model.domain == fixedNow...fixedNow.addingTimeInterval(60)) +} + +@Test func analyticsMixedBreakdownsUseTheWorkspaceTotalWithoutDrawingIt() { + let day = "2026-08-01" + let rows = [ + HistoryAnalyticsRow( + provider: .codex, point: AnalyticsPoint(day: day, metric: .turns, series: "total", value: 10)), + HistoryAnalyticsRow( + provider: .codex, point: AnalyticsPoint(day: day, metric: .turns, series: "model:gpt", value: 10)), + HistoryAnalyticsRow( + provider: .codex, point: AnalyticsPoint(day: day, metric: .turns, series: "surface:cli", value: 10)), + ] + let model = ChartPipeline.renderAnalytics( + rows: rows, metric: .analytics(.turns), start: DayStamp.date(day)!, end: DayStamp.date("2026-08-02")!) + #expect(model.series.map(\.label) == ["Model · gpt", "Surface · cli"]) + #expect(model.summaryText == "10 total") + #expect(model.dataPointCount == 2) +} + +@Test func analyticsMixedBreakdownsDrawTheTotalWhenItIsTheOnlyDetail() { + let day = "2026-08-01" + let row = HistoryAnalyticsRow( + provider: .codex, point: AnalyticsPoint(day: day, metric: .turns, series: "total", value: 10)) + let model = ChartPipeline.renderAnalytics( + rows: [row], metric: .analytics(.turns), start: DayStamp.date(day)!, end: DayStamp.date("2026-08-02")!) + #expect(model.series.map(\.label) == ["total"]) + #expect(model.summaryText == "10 total") + #expect(model.dataPointCount == 1) +} + +@Test func analyticsMixedBreakdownsFallBackPerDay() { + let rows = [ + HistoryAnalyticsRow( + provider: .codex, + point: AnalyticsPoint(day: "2026-08-01", metric: .turns, series: "total", value: 10)), + HistoryAnalyticsRow( + provider: .codex, + point: AnalyticsPoint(day: "2026-08-02", metric: .turns, series: "surface:cli", value: 4)), + HistoryAnalyticsRow( + provider: .codex, + point: AnalyticsPoint(day: "2026-08-02", metric: .turns, series: "model:gpt", value: 4)), + ] + let model = ChartPipeline.renderAnalytics( + rows: rows, metric: .analytics(.turns), start: DayStamp.date("2026-08-01")!, + end: DayStamp.date("2026-08-03")!) + + #expect(model.summaryText == "14 total") + #expect(model.series.flatMap(\.points).allSatisfy { $0.segment == 0 }) +} + +@Test func analyticsTimelineContainsVisibleSeriesDates() { + let rows = [ + HistoryAnalyticsRow( + provider: .codex, + point: AnalyticsPoint(day: "2026-08-01", metric: .surfaceUsagePercent, series: "cli", value: 10)), + HistoryAnalyticsRow( + provider: .codex, + point: AnalyticsPoint(day: "2026-08-02", metric: .surfaceUsagePercent, series: "web", value: 20)), + ] + let hidden = Set([HistorySeriesID.analytics(provider: .codex, series: "web")]) + let model = ChartPipeline.renderAnalytics( + rows: rows, metric: .analytics(.surfaceUsagePercent), start: DayStamp.date("2026-08-01")!, + end: DayStamp.date("2026-08-03")!, hidden: hidden) + + #expect(model.timeline == [DayStamp.date("2026-08-01")!]) + #expect(model.summaryText.contains("Aug 1")) +} + +@Test func selectedValuesConnectMissingLineBuckets() { + let day1 = DayStamp.date("2026-08-01")! + let day2 = DayStamp.date("2026-08-02")! + let day3 = DayStamp.date("2026-08-03")! + let gap = HistorySeries( + key: key, label: "gap", + points: [SeriesPoint(date: day1, value: 10, segment: 0), SeriesPoint(date: day3, value: 30, segment: 1)]) + #expect(gap.value(at: day2, metric: .windowUsagePercent)?.value == 10) + #expect(gap.value(at: day2, metric: .analytics(.surfaceUsagePercent))?.value == 20) + #expect(gap.value(at: day1.addingTimeInterval(-1), metric: .windowUsagePercent) == nil) + #expect(gap.value(at: day3.addingTimeInterval(1), metric: .windowUsagePercent) == nil) + let connected = HistorySeries( + key: key, label: "connected", + points: [SeriesPoint(date: day1, value: 10), SeriesPoint(date: day3, value: 30)]) + #expect(connected.value(at: day2, metric: .windowUsagePercent)?.value == 10) + #expect(connected.value(at: day2, metric: .analytics(.surfaceUsagePercent))?.value == 20) + #expect(connected.value(at: day2, metric: .analytics(.turns)) == nil) +} + +@Test func calendarBucketsHonorDaylightSavingOffsets() { + let losAngeles = TimeZone(identifier: "America/Los_Angeles")! + let before = ISODate.parse("2026-03-07T06:30:00Z")! + let after = ISODate.parse("2026-03-07T07:30:00Z")! + let buckets = ChartPipeline.bucket( + [ + UsageSample(timestamp: before, key: key, usedPercent: 1, resetsAt: nil), + UsageSample(timestamp: after, key: key, usedPercent: 2, resetsAt: nil), + ], rollup: .day, timeZone: losAngeles) + #expect(buckets.count == 1) + #expect(buckets[0].value == 2) +} + +@Test func percentageAnalyticsUsesLatestSummaryAndFixedScale() { + let start = DayStamp.date("2026-08-01")! + let rows = [ + HistoryAnalyticsRow( + provider: .codex, + point: AnalyticsPoint(day: "2026-08-01", metric: .surfaceUsagePercent, series: "cli", value: 15)), + HistoryAnalyticsRow( + provider: .codex, + point: AnalyticsPoint(day: "2026-08-02", metric: .surfaceUsagePercent, series: "cli", value: 40)), + ] + let model = ChartPipeline.renderAnalytics( + rows: rows, metric: .analytics(.surfaceUsagePercent), start: start, + end: DayStamp.date("2026-08-02")!) + #expect(model.metric.markKind == .line) + #expect(model.yMax == 100) + #expect(model.series[0].summaryValue == 40) +} diff --git a/Tests/TokenMenuBarCoreTests/ClaudeMapperTests.swift b/Tests/TokenMenuBarCoreTests/ClaudeMapperTests.swift new file mode 100644 index 0000000..60c35f3 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ClaudeMapperTests.swift @@ -0,0 +1,177 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@MainActor +private func claudeSnapshot( + usage: StubTransport.Response, profile: StubTransport.Response = .json("claude_profile"), + credentials: ClaudeOAuthCredentials = validClaude +) async -> ProviderSnapshot? { + let transport = StubTransport() + transport.on(path: "/api/oauth/usage", usage) + transport.on(path: "/api/oauth/profile", profile) + let provider = claudeProvider(MemoryClaudeStore(credentials), transport: transport) + guard case .success(let snapshot) = await provider.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected a snapshot") + return nil + } + return snapshot +} + +/// A credential that names no plan, so the profile decides the identity rather than the Keychain entry +private let plainClaude = ClaudeOAuthCredentials( + accessToken: "tok", refreshToken: nil, expiresAt: fixedNow.addingTimeInterval(86400)) + +/// The usage response carries a vendor-chosen key per window, so the decoder reads string keys and refuses integers. +@Test func claudeWindowKeysAreStringsOnly() async throws { + let usage = #"{"12": {"utilization": 5}, "five_hour": {"utilization": 12}}"# + let snapshot = try #require(await claudeSnapshot(usage: .text(usage))) + #expect(snapshot.windows.map(\.id).sorted() == ["12", "session"]) +} + +@Test func claudeReportsTheLimitsArrayAsWindows() async throws { + let snapshot = try #require(await claudeSnapshot(usage: .json("claude_usage"))) + #expect(snapshot.windows.map(\.id) == ["session", "weekly:fable"]) + #expect(snapshot.windows[0].usedPercent == 36) + #expect(snapshot.windows[0].isActive == false) + #expect(snapshot.windows[0].duration == 18000) + #expect(snapshot.windows[1].label == "Fable") + #expect(snapshot.windows[1].scope == "Fable") + #expect(snapshot.windows[1].resetsAt == ISODate.parse("2026-09-01T14:59:59.522121+00:00")) +} + +@Test func claudeFallsBackToTheFlatWindowKeys() async throws { + let usage = #""" + {"five_hour": {"utilization": 12, "resets_at": "2026-08-29T18:00:00Z"}, + "seven_day_opus": {"utilization": 40}, "seven_day_mystery": {"utilization": 5}, "tangelo": {"utilization": 1}} + """# + let windows = try #require(await claudeSnapshot(usage: .text(usage))).windows + #expect(windows.map(\.id).sorted() == ["session", "seven_day_mystery", "tangelo", "weekly:opus"]) + #expect(windows.first { $0.id == "seven_day_mystery" }?.label == "Seven Day Mystery") + #expect(windows.first { $0.id == "seven_day_mystery" }?.group == .weekly) + #expect(windows.first { $0.id == "tangelo" }?.group == .other) + #expect(windows.first { $0.id == "weekly:opus" }?.duration == 604_800) +} + +@Test( + arguments: [ + (#"{"kind": "session", "percent": 10, "severity": "warning"}"#, "session", "Current session"), + (#"{"kind": "weekly_all", "percent": 10, "severity": "warning"}"#, "weekly", "All models"), + ( + #""" + {"kind": "weekly_scoped", "percent": 10, "severity": "warning", + "scope": {"model": {"display_name": "Opus 4"}}} + """#, + "weekly:opus-4", "Opus 4" + ), + ( + #"{"kind": "weekly_scoped", "percent": 10, "severity": "warning", "scope": {"surface": "cowork"}}"#, + "weekly:cowork", "cowork" + ), + ( + #""" + {"kind": "daily_scoped", "percent": 10, "severity": "warning", + "scope": {"model": {"display_name": "Sonnet"}}} + """#, + "daily_scoped:sonnet", "Daily Scoped Sonnet" + ), + (#"{"kind": "monthly", "group": "monthly", "percent": 10, "severity": "warning"}"#, "monthly", "Monthly"), + ]) +func claudeNamesAWindowAfterItsLimitKind(limit: String, id: String, label: String) async throws { + let snapshot = try #require(await claudeSnapshot(usage: .text(#"{"limits": [\#(limit)]}"#))) + #expect(snapshot.windows.map(\.id) == [id]) + #expect(snapshot.windows[0].label == label) + #expect(snapshot.windows[0].severity == .warning) + #expect(snapshot.windows[0].isActive) +} + +@Test func claudeReportsTheSpendBlockAndItsMonthlyReset() async throws { + let snapshot = try #require(await claudeSnapshot(usage: .json("claude_usage"))) + #expect(snapshot.spend?.enabled == false) + #expect(snapshot.spend?.used?.amountMinor == 0) + #expect(snapshot.spend?.limit?.currency == "USD") + #expect(snapshot.spend?.disabledReason == "org_level_disabled_until") + #expect(snapshot.spend?.autoReload == nil) + #expect( + snapshot.spend?.resetsAt == Calendar.current.date(from: DateComponents(year: 2026, month: 9, day: 1))) +} + +@Test func claudeDerivesSpendFromExtraUsageWhenTheBlockIsMissing() async throws { + let usage = #""" + {"extra_usage": {"is_enabled": true, "monthly_limit": 80, "used_credits": 24.45, "currency": "EUR", + "decimal_places": 2, "spend_limit_reached": true}} + """# + let snapshot = try #require(await claudeSnapshot(usage: .text(usage))) + let spend = try #require(snapshot.spend) + #expect(spend.enabled) + #expect(spend.used == Money(amountMinor: 2445, currency: "EUR")) + #expect(spend.limit == Money(amountMinor: 8000, currency: "EUR")) + #expect(spend.percent.map { Int($0.rounded()) } == 31) + #expect(spend.limitReached) + #expect(spend.disabledReason == nil) +} + +@Test func claudeReportsNoSpendWithoutSpendData() async throws { + let snapshot = try #require(await claudeSnapshot(usage: .text("{}"))) + #expect(snapshot.spend == nil) +} + +@Test func claudeReportsZeroPercentAgainstAZeroLimit() async throws { + let usage = #""" + {"extra_usage": {"is_enabled": false, "monthly_limit": 0, "used_credits": 0, "disabled_reason": "x"}} + """# + let snapshot = try #require(await claudeSnapshot(usage: .text(usage))) + let spend = try #require(snapshot.spend) + #expect(spend.percent == 0) + #expect(spend.used?.currency == "USD") + #expect(spend.disabledReason == "x") +} + +@Test func claudeReadsIdentityFromTheProfile() async throws { + let snapshot = try #require(await claudeSnapshot(usage: .json("claude_usage"))) + let identity = try #require(snapshot.identity) + #expect(identity.planName == "Max 20x") + #expect(identity.tier == "default_claude_max_20x") + #expect(identity.email == "user@example.com") + #expect(identity.organization == "user@example.com's Organization") +} + +@Test( + arguments: [ + (#"{"organization_type": "claude_pro", "rate_limit_tier": "default_claude_pro"}"#, "Pro"), + (#"{"organization_type": "claude_team"}"#, "Team"), + (#"{"organization_type": "claude_enterprise"}"#, "Enterprise"), + (#"{"organization_type": "claude_free"}"#, "Free"), + (#"{"organization_type": "claude_max", "rate_limit_tier": "default_claude_max_5x"}"#, "Max 5x"), + ]) +func claudeNamesThePlanFromTheOrganization(organization: String, expected: String) async throws { + let profile = #"{"organization": \#(organization)}"# + let snapshot = try #require( + await claudeSnapshot(usage: .json("claude_usage"), profile: .text(profile), credentials: plainClaude)) + #expect(snapshot.identity?.planName == expected) +} + +@Test( + arguments: [ + (#"{"account": {"has_claude_max": true, "has_claude_pro": false}}"#, "Max"), + (#"{"account": {"has_claude_max": false, "has_claude_pro": true}}"#, "Pro"), + ]) +func claudeNamesThePlanFromTheAccountFlags(profile: String, expected: String) async throws { + let snapshot = try #require( + await claudeSnapshot(usage: .json("claude_usage"), profile: .text(profile), credentials: plainClaude)) + #expect(snapshot.identity?.planName == expected) +} + +@Test func claudeNoticesReportSpendLimitAndExhaustedWindows() async throws { + let usage = #""" + {"limits": [{"kind": "session", "percent": 100, "severity": "critical", + "resets_at": "2026-08-29T18:00:00Z", "is_active": true}], + "extra_usage": {"is_enabled": true, "monthly_limit": 1, "used_credits": 1, "utilization": 100, + "currency": "USD", "decimal_places": 2, "spend_limit_reached": true}} + """# + let notices = try #require(await claudeSnapshot(usage: .text(usage))).notices + #expect(notices.map(\.kind) == [.spendControl, .limitReached]) + #expect(notices[1].text.contains("Current session")) + let clean = try #require(await claudeSnapshot(usage: .json("claude_usage"))) + #expect(clean.notices.isEmpty) +} diff --git a/Tests/TokenMenuBarCoreTests/ClaudeTranscriptReaderTests.swift b/Tests/TokenMenuBarCoreTests/ClaudeTranscriptReaderTests.swift new file mode 100644 index 0000000..b60fc1f --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ClaudeTranscriptReaderTests.swift @@ -0,0 +1,466 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func transcriptReaderParsesIncrementallyAndDedupes() async throws { + let root = try transcriptRoot() + let file = root.appendingPathComponent("session.jsonl") + let first = line(id: "m1", at: fixedNow.addingTimeInterval(-3600)) + try + (first + "\n" + line(id: "m1", at: fixedNow.addingTimeInterval(-3600)) + "\n" + #"{"type":"user","message":{}}"# + + "\n").write(to: file, atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader(root: root.deletingLastPathComponent()) + var snapshot = await reader.refresh(now: fixedNow) + #expect(snapshot.messageCount == 1) + #expect(snapshot.localUsage(windowResetsAt: nil, windowDuration: 7200, now: fixedNow)?.windowTokens == 100) + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write( + contentsOf: Data((line(id: "m2", at: fixedNow.addingTimeInterval(-60), tools: 0) + "\n" + "partial").utf8)) + try handle.close() + snapshot = await reader.refresh(now: fixedNow) + #expect(snapshot.messageCount == 2) + #expect(snapshot.analytics(now: fixedNow)?.points.first { $0.metric == .messages }?.value == 2) + snapshot = await reader.refresh(now: fixedNow) + #expect(snapshot.messageCount == 2) + let ancient = line(id: "old", at: fixedNow.addingTimeInterval(-90 * 86400)) + try (ancient + "\n").write(to: root.appendingPathComponent("old.jsonl"), atomically: true, encoding: .utf8) + snapshot = await reader.refresh(now: fixedNow) + #expect(snapshot.messageCount == 2) + #expect( + await ClaudeTranscriptReader(root: root.appendingPathComponent("missing")).refresh(now: fixedNow).messageCount == 0) +} + +@Test func transcriptReaderResumesWhereTheLastRunStopped() async throws { + let root = try transcriptRoot() + let file = root.appendingPathComponent("session.jsonl") + let state = root.deletingLastPathComponent().appendingPathComponent("offsets.json") + try (line(id: "m1", at: fixedNow.addingTimeInterval(-3600)) + "\n").write( + to: file, atomically: true, encoding: .utf8) + let first = ClaudeTranscriptReader(root: root.deletingLastPathComponent(), stateURL: state) + #expect(await first.refresh(now: fixedNow).messageCount == 1) + + let resumed = ClaudeTranscriptReader(root: root.deletingLastPathComponent(), stateURL: state) + #expect(await resumed.refresh(now: fixedNow).messageCount == 1) + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data((line(id: "m2", at: fixedNow.addingTimeInterval(-60)) + "\n").utf8)) + try handle.close() + #expect(await resumed.refresh(now: fixedNow).messageCount == 2) + #expect( + await resumed.refresh(now: fixedNow).localUsage(windowResetsAt: nil, windowDuration: 7200, now: fixedNow)? + .windowTokens == 200) + + // Without a state file every reader starts from the beginning. + let cold = ClaudeTranscriptReader(root: root.deletingLastPathComponent()) + #expect(await cold.refresh(now: fixedNow).messageCount == 2) +} + +@Test func transcriptReaderAppliesAndExpandsConfiguredRetention() async throws { + let root = try transcriptRoot() + let file = root.appendingPathComponent("retention.jsonl") + try + ([ + line(id: "recent", at: fixedNow.addingTimeInterval(-5 * 86400)), + line(id: "month", at: fixedNow.addingTimeInterval(-30 * 86400)), + line(id: "quarter", at: fixedNow.addingTimeInterval(-90 * 86400)), + ].joined(separator: "\n") + "\n").write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: fixedNow], ofItemAtPath: file.path) + let reader = ClaudeTranscriptReader(root: root.deletingLastPathComponent()) + + #expect(await reader.refresh(now: fixedNow, retentionDays: 60).messageCount == 2) + #expect(await reader.refresh(now: fixedNow, retentionDays: 7).messageCount == 1) + #expect(await reader.refresh(now: fixedNow, retentionDays: 365).messageCount == 3) +} + +@Test func transcriptReaderLoadsLegacyIntegerOffsets() async throws { + let root = try transcriptRoot() + let file = root.appendingPathComponent("session.jsonl") + let transcript = line(id: "already-read", at: fixedNow) + "\n" + try transcript.write(to: file, atomically: true, encoding: .utf8) + let stateURL = root.deletingLastPathComponent().appendingPathComponent("legacy-state.json") + let enumerator = try #require( + FileManager.default.enumerator(at: root.deletingLastPathComponent(), includingPropertiesForKeys: nil)) + let enumeratedPath = try #require( + (enumerator.allObjects as? [URL])?.first { $0.lastPathComponent == file.lastPathComponent } + ).path + let state: [String: Any] = [ + "offsets": [enumeratedPath: transcript.utf8.count], + "seenByDay": [String: [String]](), + "days": [String: Any](), + "recent": [String: Any](), + ] + try JSONSerialization.data(withJSONObject: state).write(to: stateURL) + let reader = ClaudeTranscriptReader(root: root.deletingLastPathComponent(), stateURL: stateURL) + #expect(await reader.refresh(now: fixedNow).messageCount == 0) +} + +@Test func transcriptReaderIndexesTheTreePeriodicallyButTailsKnownFiles() async throws { + let root = try transcriptRoot() + let first = root.appendingPathComponent("first.jsonl") + try (line(id: "m1", at: fixedNow) + "\n").write(to: first, atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader( + root: root.deletingLastPathComponent(), fileScanInterval: 300) + #expect(await reader.refresh(now: fixedNow).messageCount == 1) + + let handle = try FileHandle(forWritingTo: first) + try handle.seekToEnd() + try handle.write(contentsOf: Data((line(id: "m2", at: fixedNow) + "\n").utf8)) + try handle.close() + let second = root.appendingPathComponent("second.jsonl") + try (line(id: "m3", at: fixedNow) + "\n").write(to: second, atomically: true, encoding: .utf8) + + #expect(await reader.refresh(now: fixedNow.addingTimeInterval(60)).messageCount == 2) + #expect(await reader.refresh(now: fixedNow.addingTimeInterval(301)).messageCount == 3) +} + +@Test func transcriptReaderBoundsTheFilesTailedBetweenTreeScans() async throws { + let root = try transcriptRoot() + for index in 0...ClaudeTranscriptReader.maxIndexedFiles { + let file = root.appendingPathComponent("session-\(index).jsonl") + try (line(id: "m\(index)", at: fixedNow) + "\n").write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: fixedNow.addingTimeInterval(Double(index - ClaudeTranscriptReader.maxIndexedFiles))], + ofItemAtPath: file.path) + } + let reader = ClaudeTranscriptReader(root: root.deletingLastPathComponent(), fileScanInterval: 300) + _ = await reader.refresh(now: fixedNow) + await waitForTranscriptScan(reader) + #expect(await reader.refresh(now: fixedNow).messageCount == ClaudeTranscriptReader.maxIndexedFiles + 1) + + for (index, id) in [(0, "excluded"), (ClaudeTranscriptReader.maxIndexedFiles, "indexed")] { + let handle = try FileHandle(forWritingTo: root.appendingPathComponent("session-\(index).jsonl")) + try handle.seekToEnd() + try handle.write(contentsOf: Data((line(id: id, at: fixedNow) + "\n").utf8)) + try handle.close() + } + #expect(await reader.refresh(now: fixedNow.addingTimeInterval(60)).messageCount == 66) + #expect(await reader.refresh(now: fixedNow.addingTimeInterval(301)).messageCount == 67) +} + +@Test func transcriptReaderIgnoresJsonlDirectories() async throws { + let root = try transcriptRoot() + try FileManager.default.createDirectory( + at: root.appendingPathComponent("broken.jsonl"), withIntermediateDirectories: true) + try (line(id: "m1", at: fixedNow) + "\n").write( + to: root.appendingPathComponent("session.jsonl"), atomically: true, encoding: .utf8) + + let snapshot = await ClaudeTranscriptReader(root: root.deletingLastPathComponent()).refresh(now: fixedNow) + #expect(snapshot.messageCount == 1) +} + +@Test func emptyTranscriptSnapshotHasNoDerivedUsage() async { + let snapshot = await ClaudeTranscriptReader(root: temporaryDirectory()).refresh(now: fixedNow) + #expect(snapshot.analytics(now: fixedNow) == nil) + #expect(snapshot.localUsage(windowResetsAt: nil, windowDuration: 3600, now: fixedNow) == nil) +} + +@Test func transcriptReaderCompletesASplitRecordAfterAppend() async throws { + let root = try transcriptRoot() + let file = root.appendingPathComponent("session.jsonl") + let record = line(id: "split", at: fixedNow) + let split = record.index(record.startIndex, offsetBy: record.count / 2) + try String(record[.. 0) + #expect(workload.lastCheckpointBytes < transcript.utf8.count) +} + +@Test func transcriptReaderBacksOffAfterCheckpointFailure() async throws { + let root = try transcriptRoot() + let parent = root.deletingLastPathComponent().appendingPathComponent("file") + try Data().write(to: parent) + try (line(id: "m1", at: fixedNow) + "\n").write( + to: root.appendingPathComponent("session.jsonl"), atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader( + root: root.deletingLastPathComponent(), stateURL: parent.appendingPathComponent("state.json"), + maximumCheckpointBytes: 1) + _ = await reader.refresh(now: fixedNow) + #expect((await reader.workload).checkpointAttempts == 1) + _ = await reader.refresh(now: fixedNow.addingTimeInterval(0.5)) + #expect((await reader.workload).checkpointAttempts == 1) + _ = await reader.refresh(now: fixedNow.addingTimeInterval(61)) + #expect((await reader.workload).checkpointAttempts == 2) +} + +@Test func transcriptReaderBoundsUnterminatedColdTails() async throws { + let root = try transcriptRoot() + for index in 0..<200 { + try String(repeating: "x", count: 1_024).write( + to: root.appendingPathComponent("session-\(index).jsonl"), atomically: true, encoding: .utf8) + } + let reader = ClaudeTranscriptReader( + root: root.deletingLastPathComponent(), workByteBudget: 4_096, workEntryBudget: 8) + _ = await reader.refresh(now: fixedNow) + await waitForTranscriptScan(reader) + let workload = await reader.workload + #expect(workload.retainedPartialFiles <= ClaudeTranscriptReader.maxIndexedFiles) + #expect(workload.retainedPartialBytes <= ClaudeTranscriptReader.maxIndexedFiles * 1_024) +} + +@Test func transcriptReaderReleasesCompletedLineStorageBeforeRetainingATail() async throws { + let root = try transcriptRoot() + let complete = String(repeating: "x", count: 1_048_576) + try (complete + "\nnext").write( + to: root.appendingPathComponent("session.jsonl"), atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader( + root: root.deletingLastPathComponent(), maximumLineBytes: complete.utf8.count + 1) + _ = await reader.refresh(now: fixedNow) + let workload = await reader.workload + #expect(workload.retainedPartialFiles == 1) + #expect(workload.retainedPartialBytes == 4) +} + +@Test func transcriptReaderStopsAfterAFileShrinksDuringARead() async throws { + let root = try transcriptRoot() + let file = root.appendingPathComponent("session.jsonl") + try String(repeating: "x", count: 1_048_576).write(to: file, atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader(root: root.deletingLastPathComponent(), workByteBudget: 1) + _ = await reader.refresh(now: fixedNow) + try Data().write(to: file, options: .atomic) + await waitForTranscriptScan(reader) + #expect((await reader.workload).scansCompleted == 1) +} + +@Test func transcriptReaderIgnoresDirectoriesBeforeChoosingHotFiles() async throws { + let root = try transcriptRoot() + for index in 0...ClaudeTranscriptReader.maxIndexedFiles { + try FileManager.default.createDirectory( + at: root.appendingPathComponent("fake-\(index).jsonl"), withIntermediateDirectories: true) + } + try (line(id: "m1", at: fixedNow) + "\n").write( + to: root.appendingPathComponent("session.jsonl"), atomically: true, encoding: .utf8) + #expect(await ClaudeTranscriptReader(root: root.deletingLastPathComponent()).refresh(now: fixedNow).messageCount == 1) +} + +@Test func transcriptReaderBreaksEqualModificationDatesByPath() async throws { + let root = try transcriptRoot() + for index in 0...ClaudeTranscriptReader.maxIndexedFiles { + let file = root.appendingPathComponent(String(format: "session-%03d.jsonl", index)) + try (line(id: "m\(index)", at: fixedNow) + "\n").write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: fixedNow], ofItemAtPath: file.path) + } + let reader = ClaudeTranscriptReader(root: root.deletingLastPathComponent(), fileScanInterval: 300) + _ = await reader.refresh(now: fixedNow) + await waitForTranscriptScan(reader) + #expect(await reader.refresh(now: fixedNow).messageCount == ClaudeTranscriptReader.maxIndexedFiles + 1) + for (index, id) in [(0, "indexed"), (ClaudeTranscriptReader.maxIndexedFiles, "excluded")] { + let file = root.appendingPathComponent(String(format: "session-%03d.jsonl", index)) + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data((line(id: id, at: fixedNow) + "\n").utf8)) + try handle.close() + } + #expect(await reader.refresh(now: fixedNow.addingTimeInterval(60)).messageCount == 66) +} + +private func waitForTranscriptScan(_ reader: ClaudeTranscriptReader) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(30)) + while clock.now < deadline { + if (await reader.workload).scansCompleted > 0 { return } + await Task.yield() + } + Issue.record("Transcript scan did not finish") +} + +private func line( + id: String, request: String = "req", at: Date, model: String = "claude-opus-5", session: String = "s1", + input: Int = 10, + output: Int = 20, cacheWrite: Int = 30, cacheRead: Int = 40, tools: Int = 1 +) -> String { + let content = (0.. URL { + let root = temporaryDirectory().appendingPathComponent("projects/-Users-me-repo") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root +} + +@Test func transcriptParseRejectsIncompleteRecords() { + #expect(ClaudeTranscriptReader.parse(line: Data("{".utf8)) == nil) + #expect(ClaudeTranscriptReader.parse(line: Data(#"{"type":"user","message":{"usage":{}}}"#.utf8)) == nil) + #expect( + ClaudeTranscriptReader.parse(line: Data(#"{"type":"assistant","message":{"usage":{},"model":"m"}}"#.utf8)) == nil) + let minimal = ClaudeTranscriptReader.parse( + line: Data( + #"{"type":"assistant","timestamp":"2026-08-29T10:00:00Z","message":{"usage":{},"model":"claude-sonnet-4-6"}}"# + .utf8))! + #expect(minimal.id == ":") + #expect(minimal.usage == TokenUsage()) + #expect(minimal.toolCalls == 0) + #expect(minimal.cost == 0) +} + +@Test func pricingCoversKnownFamilies() { + let usage = TokenUsage(input: 1_000_000, output: 1_000_000, cacheWrite: 1_000_000, cacheRead: 1_000_000) + #expect(ClaudePricing.cost(usage, model: "claude-opus-5") == 110.25) + #expect(ClaudePricing.cost(usage, model: "claude-haiku-4-5-20251001") == 7.35) + #expect(ClaudePricing.cost(usage, model: "claude-sonnet-4-6") == 22.05) + #expect(ClaudePricing.cost(usage, model: "claude-fable-5") == ClaudePricing.cost(usage, model: "claude-mythos-5")) + #expect(ClaudePricing.cost(usage, model: "gpt-5") == 0) + #expect(ClaudePricing.price(for: "unknown") == nil) +} + +@Test func transcriptAnalyticsAggregatesPerDayAndModel() { + let messages = [ + TranscriptMessage( + id: "a", timestamp: fixedNow, session: "s1", model: "claude-opus-5", + usage: TokenUsage(input: 1, output: 2, cacheWrite: 3, cacheRead: 4), toolCalls: 2), + TranscriptMessage( + id: "b", timestamp: fixedNow.addingTimeInterval(60), session: "s2", model: "claude-opus-5", + usage: TokenUsage(input: 1), toolCalls: 0), + TranscriptMessage( + id: "c", timestamp: fixedNow.addingTimeInterval(-86400), session: "s1", model: "claude-haiku-4-5", + usage: TokenUsage(output: 5), toolCalls: 1), + ] + let analytics = ClaudeTranscriptReader.analytics(messages, now: fixedNow)! + let today = DayStamp.string(fixedNow) + #expect(analytics.provider == .claude) + #expect( + analytics.points.first { $0.day == today && $0.metric == .inputTokens && $0.series == "claude-opus-5" }?.value == 2) + #expect(analytics.points.first { $0.day == today && $0.metric == .cacheWriteTokens }?.value == 3) + #expect(analytics.points.first { $0.day == today && $0.metric == .messages }?.value == 2) + #expect(analytics.points.first { $0.day == today && $0.metric == .sessions }?.value == 2) + #expect(analytics.points.first { $0.day == today && $0.metric == .toolCalls }?.value == 2) + #expect( + analytics.points.first { $0.metric == .costUSD && $0.series == "claude-haiku-4-5" }?.value == 5.0 * 5 / 1_000_000) + #expect(analytics.points.contains { $0.metric == .outputTokens && $0.series == "claude-haiku-4-5" }) + #expect(ClaudeTranscriptReader.analytics([], now: fixedNow) == nil) + #expect(AnalyticsMetric.costUSD.unit == "USD") + #expect(AnalyticsMetric.cacheWriteTokens.unit == "tokens") + #expect(AnalyticsMetric.toolCalls.title == "Tool calls") +} + +@Test func transcriptLocalUsageSummarizesWindowAndToday() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let messages = [ + TranscriptMessage( + id: "a", timestamp: fixedNow.addingTimeInterval(-7200), session: "s", model: "claude-opus-5", + usage: TokenUsage(output: 1_000_000), toolCalls: 0), + TranscriptMessage( + id: "b", timestamp: fixedNow.addingTimeInterval(-3600), session: "s", model: "claude-opus-5", + usage: TokenUsage(output: 1_000_000), toolCalls: 0), + TranscriptMessage( + id: "c", timestamp: fixedNow.addingTimeInterval(-6 * 3600), session: "s", model: "claude-opus-5", + usage: TokenUsage(output: 1_000_000), toolCalls: 0), + TranscriptMessage( + id: "d", timestamp: fixedNow.addingTimeInterval(-40 * 3600), session: "s", model: "claude-opus-5", + usage: TokenUsage(output: 1), toolCalls: 0), + ] + let usage = ClaudeTranscriptReader.localUsage( + messages, windowResetsAt: fixedNow.addingTimeInterval(2 * 3600), windowDuration: 5 * 3600, now: fixedNow, + calendar: calendar)! + #expect(usage.windowTokens == 2_000_000) + #expect(usage.windowCost == 150) + #expect(usage.costPerHour == 75) + #expect(usage.todayTokens == 3_000_000) + #expect(usage.todayMessages == 3) + #expect(usage.todayCost == 225) + let noReset = ClaudeTranscriptReader.localUsage( + messages, windowResetsAt: nil, windowDuration: 5 * 3600, now: fixedNow, calendar: calendar)! + #expect(noReset.windowTokens == 2_000_000) + let quiet = ClaudeTranscriptReader.localUsage( + [messages[3]], windowResetsAt: nil, windowDuration: 5 * 3600, now: fixedNow, calendar: calendar)! + #expect(quiet.windowTokens == 0) + #expect(quiet.costPerHour == 0) + #expect(ClaudeTranscriptReader.localUsage([], windowResetsAt: nil, windowDuration: 1, now: fixedNow) == nil) +} diff --git a/Tests/TokenMenuBarCoreTests/CodexAnalyticsRetentionTests.swift b/Tests/TokenMenuBarCoreTests/CodexAnalyticsRetentionTests.swift new file mode 100644 index 0000000..9f98dbc --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CodexAnalyticsRetentionTests.swift @@ -0,0 +1,358 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func codexAnalyticsWatermarksSurviveRelaunchWithOneDayOverlap() async { + let defaults = watermarkDefaults() + let firstTransport = analyticsTransport() + let first = await codexProvider( + MemoryCodexStore(validCodex), transport: firstTransport, analyticsWatermarkPersistence: persistence(defaults) + ).fetch(now: fixedNow, options: FetchOptions(includeAnalytics: true, analyticsDays: 30)) + #expect(first.outcome.snapshot != nil, "\(first.outcome)") + #expect(first.warnings.isEmpty) + #expect(first.analytics != nil) + #expect(startDates(firstTransport) == ["2026-07-31"]) + #expect(allAnalyticsStartDates(firstTransport) == Array(repeating: "2026-07-31", count: 5)) + + let secondTransport = analyticsTransport() + _ = await codexProvider( + MemoryCodexStore(validCodex), transport: secondTransport, analyticsWatermarkPersistence: persistence(defaults) + ).fetch( + now: fixedNow.addingTimeInterval(86400), + options: FetchOptions(includeAnalytics: true, analyticsDays: 30)) + #expect(startDates(secondTransport) == ["2026-08-28"]) + #expect(allAnalyticsStartDates(secondTransport) == Array(repeating: "2026-08-28", count: 5)) +} + +@Test func codexAnalyticsRetentionExpansionBackfillsTheUncoveredRange() async { + let defaults = watermarkDefaults() + let transport = analyticsTransport() + let provider = codexProvider( + MemoryCodexStore(validCodex), transport: transport, analyticsWatermarkPersistence: persistence(defaults)) + _ = await provider.fetch(now: fixedNow, options: FetchOptions(includeAnalytics: true, analyticsDays: 7)) + _ = await provider.fetch( + now: fixedNow.addingTimeInterval(86400), + options: FetchOptions(includeAnalytics: true, analyticsDays: 30)) + #expect( + transport.requests(matching: "daily-token-usage-breakdown").compactMap(\.url?.query).map(analyticsStartDate) + == ["2026-08-23", "2026-08-01"]) +} + +@Test func codexCredentialIdentityChangeResetsAccountCaches() async throws { + let store = MemoryCodexStore(validCodex) + let transport = analyticsTransport(inlineResetCredits: false) + let provider = codexProvider( + store, transport: transport, analyticsWatermarkPersistence: persistence(watermarkDefaults())) + _ = await provider.fetch(now: fixedNow, options: FetchOptions(includeAnalytics: true, analyticsDays: 7)) + try store.save(CodexAuth(accessToken: "other-token", accountID: "other-account")) + _ = await provider.fetch( + now: fixedNow.addingTimeInterval(60), + options: FetchOptions(includeAnalytics: true, analyticsDays: 7)) + #expect(transport.requests(matching: "rate-limit-reset-credits").count == 2) + #expect(startDates(transport) == ["2026-08-23"]) +} + +@Test func codexCredentialIdentityChangeEvictsCachesBeforeANetworkFailure() async throws { + let store = MemoryCodexStore(validCodex) + let base = analyticsTransport(inlineResetCredits: false) + let transport = FailingSecondUsageTransport(base: base) + let provider = codexProvider( + store, transport: transport, analyticsWatermarkPersistence: persistence(watermarkDefaults())) + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + try store.save(CodexAuth(accessToken: "other-token", accountID: "other-account")) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + try store.save(validCodex) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(120), options: FetchOptions()) + #expect(base.requests(matching: "rate-limit-reset-credits").count == 2) +} + +@Test func codexAnalyticsWatermarksKeepOnlyFourRecentAccounts() async { + let defaults = watermarkDefaults() + var transports: [StubTransport] = [] + for index in 0..<5 { + let transport = analyticsTransport() + transports.append(transport) + let auth = CodexAuth(accessToken: "token-\(index)", accountID: "account-\(index)") + _ = await codexProvider( + MemoryCodexStore(auth), transport: transport, analyticsWatermarkPersistence: persistence(defaults) + ).fetch( + now: fixedNow.addingTimeInterval(Double(index) * 60), + options: FetchOptions(includeAnalytics: true, analyticsDays: 30)) + } + #expect(transports.allSatisfy { startDates($0) == ["2026-07-31"] }) + + let evictedTransport = analyticsTransport() + _ = await codexProvider( + MemoryCodexStore(CodexAuth(accessToken: "token-0", accountID: "account-0")), + transport: evictedTransport, + analyticsWatermarkPersistence: persistence(defaults) + ).fetch( + now: fixedNow.addingTimeInterval(360), + options: FetchOptions(includeAnalytics: true, analyticsDays: 30)) + #expect(startDates(evictedTransport) == ["2026-07-31"]) + + let retainedTransport = analyticsTransport() + _ = await codexProvider( + MemoryCodexStore(CodexAuth(accessToken: "token-4", accountID: "account-4")), + transport: retainedTransport, + analyticsWatermarkPersistence: persistence(defaults) + ).fetch( + now: fixedNow.addingTimeInterval(420), + options: FetchOptions(includeAnalytics: true, analyticsDays: 30)) + #expect(startDates(retainedTransport) == ["2026-08-28"]) +} + +@Test func codexAnalyticsWatermarksRecoverFromCorruptStorage() async { + let defaults = watermarkDefaults() + defaults.set(Data("not json".utf8), forKey: CodexAnalyticsWatermarkStore.storageKey) + let firstTransport = analyticsTransport() + _ = await codexProvider( + MemoryCodexStore(validCodex), transport: firstTransport, analyticsWatermarkPersistence: persistence(defaults) + ).fetch(now: fixedNow, options: FetchOptions(includeAnalytics: true, analyticsDays: 30)) + #expect(startDates(firstTransport) == ["2026-07-31"]) + + let secondTransport = analyticsTransport() + _ = await codexProvider( + MemoryCodexStore(validCodex), transport: secondTransport, analyticsWatermarkPersistence: persistence(defaults) + ).fetch( + now: fixedNow.addingTimeInterval(86400), + options: FetchOptions(includeAnalytics: true, analyticsDays: 30)) + #expect(startDates(secondTransport) == ["2026-08-28"]) +} + +@Test func codexAnalyticsMigratesLegacyWatermarksWithoutPersistingIdentityPlaintext() async throws { + let defaults = watermarkDefaults() + let auth = CodexAuth(accessToken: "private-token", accountID: "private-account") + let legacy = ["accounts": [auth.accountFingerprint: [CodexAPI.Analytics.tokenUsage.rawValue: "2026-08-29"]]] + defaults.set(try JSONEncoder().encode(legacy), forKey: CodexAnalyticsWatermarkStore.storageKey) + let transport = analyticsTransport() + _ = await codexProvider( + MemoryCodexStore(auth), transport: transport, analyticsWatermarkPersistence: persistence(defaults) + ).fetch(now: fixedNow, options: FetchOptions(includeAnalytics: true, analyticsDays: 30)) + #expect(startDates(transport) == ["2026-07-31"]) + let stored = String( + data: defaults.data(forKey: CodexAnalyticsWatermarkStore.storageKey)!, encoding: .utf8)! + #expect(!stored.contains("private-token")) + #expect(!stored.contains("private-account")) +} + +@Test func codexAnalyticsWatermarksDiscardInvalidAndExpiredCoverage() throws { + let defaults = watermarkDefaults() + let account = "account" + let encodedDate = fixedNow.timeIntervalSinceReferenceDate + let document: [String: Any] = [ + "version": 1, + "accounts": [ + account: [ + "lastAccess": encodedDate, + "coverage": [ + CodexAPI.Analytics.tokenUsage.rawValue: ["start": "2026-01-01", "through": "2026-08-29"], + CodexAPI.Analytics.workspaceCounts.rawValue: ["start": "bad", "through": "2026-08-29"], + CodexAPI.Analytics.skills.rawValue: ["start": "2026-08-30", "through": "2026-08-29"], + CodexAPI.Analytics.plugins.rawValue: ["start": "2026-01-01", "through": "2026-08-01"], + CodexAPI.Analytics.codeReview.rawValue: ["start": "2026-08-01", "through": "2026-08-30"], + "unknown": ["start": "2026-08-01", "through": "2026-08-29"], + ], + ], + "empty": ["lastAccess": encodedDate, "coverage": [:]], + ], + ] + defaults.set(try JSONSerialization.data(withJSONObject: document), forKey: CodexAnalyticsWatermarkStore.storageKey) + let store = CodexAnalyticsWatermarkStore(persistence: persistence(defaults)) + #expect( + store.load(account: account, now: fixedNow, retentionDays: 7) + == [.tokenUsage: CodexAnalyticsCoverage(start: "2026-08-23", through: "2026-08-29")]) + #expect(store.load(account: "empty", now: fixedNow, retentionDays: 7).isEmpty) + + var unknownVersion = document + unknownVersion["version"] = 99 + defaults.set( + try JSONSerialization.data(withJSONObject: unknownVersion), forKey: CodexAnalyticsWatermarkStore.storageKey) + #expect(store.load(account: account, now: fixedNow, retentionDays: 7).isEmpty) +} + +@Test func codexAnalyticsWatermarkEvictionIsDeterministicWhenRecencyMatches() { + let store = CodexAnalyticsWatermarkStore(persistence: persistence(watermarkDefaults())) + let coverage = [CodexAPI.Analytics.tokenUsage: CodexAnalyticsCoverage(start: "2026-08-01", through: "2026-08-29")] + for account in ["a", "b", "c", "d", "e"] { + store.update(account: account, coverage: coverage, now: fixedNow, retentionDays: 30) + } + #expect(store.load(account: "a", now: fixedNow, retentionDays: 30).isEmpty) + #expect(store.load(account: "e", now: fixedNow, retentionDays: 30) == coverage) +} + +@Test func codexAnalyticsBoundsServerRowsAndCreditEventsToRetention() async { + let transport = StubTransport() + transport.on( + path: "/wham/usage", + .text( + #"{"rate_limit_reset_credits":{"available_count":1,"total_earned_count":1,"# + + #""immediate_reset_purchase_eligible":true}}"# + )) + transport.on( + path: "daily-token-usage-breakdown", + .text( + #"{"data":[{"date":"2026-07-01","product_surface_usage_values":{"cli":1}},"# + + #"{"date":"2026-08-29","product_surface_usage_values":{"cli":2}},"# + + #"{"date":"2026-08-30","product_surface_usage_values":{"cli":3}}]}"# + )) + for path in [ + "daily-workspace-usage-counts", "daily-skill-usage-metrics", "daily-plugin-usage-metrics", + "daily-code-review-metrics", + ] { + transport.on(path: path, .text(#"{"data":[]}"#)) + } + transport.on( + path: "credit-usage-events", + .text( + #"{"data":[{"id":"old","date":"2026-07-01","credits_used":1},"# + + #"{"id":"current","date":"2026-08-29","credits_used":2},"# + + #"{"id":"future","date":"2026-08-30","credits_used":3}]}"# + )) + let result = await codexProvider(MemoryCodexStore(validCodex), transport: transport).fetch( + now: fixedNow, options: FetchOptions(includeAnalytics: true, analyticsDays: 7)) + #expect(result.analytics?.points.map(\.value) == [2]) + #expect(result.analytics?.creditEvents.map(\.id) == ["current"]) +} + +@Test func codexAccountFingerprintUsesStableNonSecretIdentity() { + let accountA = CodexAuth(accessToken: "first", accountID: "account") + let accountB = CodexAuth(accessToken: "second", accountID: "account") + #expect(accountA.accountFingerprint == accountB.accountFingerprint) + + let emailA = CodexAuth(accessToken: "first", idToken: makeJWT(.object(["email": .string("USER@example.com")]))) + let emailB = CodexAuth(accessToken: "second", idToken: makeJWT(.object(["email": .string("user@example.com")]))) + #expect(emailA.accountFingerprint == emailB.accountFingerprint) + #expect(CodexAuth(accessToken: "first").accountFingerprint != CodexAuth(accessToken: "second").accountFingerprint) + #expect(!accountA.accountFingerprint.contains("account")) + #expect(accountA.accountFingerprint.count == 64) +} + +@Test func providerAnalyticsMergePrunesEveryMetricAndCreditEvent() { + let cutoff = DayStamp.string(fixedNow.addingTimeInterval(-6 * 86400)) + let old = DayStamp.string(fixedNow.addingTimeInterval(-7 * 86400)) + let today = DayStamp.string(fixedNow) + let future = DayStamp.string(fixedNow.addingTimeInterval(86400)) + let previous = ProviderAnalytics( + provider: .codex, + points: AnalyticsMetric.allCases.flatMap { metric in + [ + AnalyticsPoint(day: old, metric: metric, series: "series", value: 1), + AnalyticsPoint(day: cutoff, metric: metric, series: "series", value: 2), + ] + }, + creditEvents: [ + CreditEvent(id: "old", date: fixedNow.addingTimeInterval(-7 * 86400), service: "Codex", creditsUsed: 1), + CreditEvent(id: "cutoff", date: DayStamp.date(cutoff)!, service: "Codex", creditsUsed: 2), + ], + fetchedAt: fixedNow.addingTimeInterval(-60), + accountFingerprint: "account") + let current = ProviderAnalytics( + provider: .codex, + points: AnalyticsMetric.allCases.flatMap { metric in + [ + AnalyticsPoint(day: cutoff, metric: metric, series: "series", value: 3), + AnalyticsPoint(day: today, metric: metric, series: "series", value: 4), + AnalyticsPoint(day: future, metric: metric, series: "series", value: 5), + ] + }, + creditEvents: [ + CreditEvent(id: "cutoff", date: DayStamp.date(cutoff)!, service: "Codex", creditsUsed: 3), + CreditEvent(id: "today", date: fixedNow, service: "Codex", creditsUsed: 4), + CreditEvent(id: "future", date: fixedNow.addingTimeInterval(86400), service: "Codex", creditsUsed: 5), + ], + fetchedAt: fixedNow, + accountFingerprint: "account") + let merged = previous.merging(current, retentionDays: 7) + for metric in AnalyticsMetric.allCases { + #expect(merged.points.filter { $0.metric == metric }.map(\.value) == [3, 4]) + } + #expect(merged.creditEvents.map(\.id) == ["cutoff", "today"]) + #expect(merged.creditEvents.map(\.creditsUsed) == [3, 4]) +} + +@Test func providerAnalyticsMergeDoesNotCrossAccountsOrProviders() { + let previous = ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .turns, series: "old", value: 1)], + fetchedAt: fixedNow, + accountFingerprint: "old") + let accountChanged = ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .turns, series: "new", value: 2)], + fetchedAt: fixedNow, + accountFingerprint: "new") + #expect(previous.merging(accountChanged, retentionDays: 0).points.map(\.series) == ["new"]) + + let providerChanged = ProviderAnalytics( + provider: .claude, + points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .turns, series: "claude", value: 3)], + fetchedAt: fixedNow, + accountFingerprint: "old") + #expect(previous.merging(providerChanged, retentionDays: 7).points.map(\.series) == ["claude"]) +} + +private func watermarkDefaults() -> UserDefaults { + UserDefaults(suiteName: "codex-retention-\(UUID().uuidString)")! +} + +private func persistence(_ defaults: UserDefaults) -> CodexAnalyticsWatermarkPersistence { + CodexAnalyticsWatermarkPersistence(defaults: defaults) +} + +private func analyticsTransport(inlineResetCredits: Bool = true) -> StubTransport { + let transport = StubTransport() + transport.on( + path: "/wham/usage", + .text( + inlineResetCredits + ? #"{"plan_type":"pro","rate_limit_reset_credits":{"available_count":1,"total_earned_count":1,"# + + #""immediate_reset_purchase_eligible":true}}"# + : #"{"plan_type":"pro"}"#)) + if !inlineResetCredits { + transport.on(path: "rate-limit-reset-credits", .json("codex_reset_credits")) + } + stubCodexAnalytics(transport) + return transport +} + +private func startDates(_ transport: StubTransport) -> [String] { + Array( + Set( + transport.requests(matching: "daily-token-usage-breakdown").compactMap(\.url?.query).map( + analyticsStartDate)) + ) + .sorted() +} + +private func allAnalyticsStartDates(_ transport: StubTransport) -> [String] { + CodexAPI.Analytics.allCases.flatMap { endpoint in + transport.requests(matching: endpoint.rawValue).compactMap(\.url?.query).map(analyticsStartDate) + }.sorted() +} + +private func analyticsStartDate(_ query: String) -> String { + URLComponents(string: "https://example.test?\(query)")?.queryItems?.first { $0.name == "start_date" }?.value ?? "" +} + +private final class FailingSecondUsageTransport: HTTPTransport, @unchecked Sendable { + private let base: StubTransport + private let lock = NSLock() + private var usageRequests = 0 + + init(base: StubTransport) { + self.base = base + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + if request.url?.path.hasSuffix("/wham/usage") == true { + let requestNumber = lock.withLock { + usageRequests += 1 + return usageRequests + } + if requestNumber == 2 { throw URLError(.notConnectedToInternet) } + } + return try await base.data(for: request) + } +} diff --git a/Tests/TokenMenuBarCoreTests/CodexMapperTests.swift b/Tests/TokenMenuBarCoreTests/CodexMapperTests.swift new file mode 100644 index 0000000..132b51e --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CodexMapperTests.swift @@ -0,0 +1,291 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@MainActor +private func codexFetch( + usage: StubTransport.Response, analytics: Bool = false, stub: (StubTransport) -> Void = { _ in } +) async -> (result: ProviderFetchResult, transport: StubTransport) { + let transport = StubTransport() + transport.on(path: "/wham/usage", usage) + transport.on(path: "rate-limit-reset-credits", .json("codex_reset_credits")) + stub(transport) + let provider = codexProvider(MemoryCodexStore(validCodex), transport: transport) + let options = FetchOptions(includeAnalytics: analytics, analyticsDays: 30) + return (await provider.fetch(now: fixedNow, options: options), transport) +} + +@MainActor +private func codexSnapshot(usage: StubTransport.Response) async -> ProviderSnapshot? { + guard case .success(let snapshot) = await codexFetch(usage: usage).result.outcome else { + Issue.record("expected a snapshot") + return nil + } + return snapshot +} + +@Test func codexReportsPrimaryAdditionalAndCodeReviewWindows() async throws { + let snapshot = try #require(await codexSnapshot(usage: .json("codex_usage"))) + #expect( + snapshot.windows.map(\.id) == [ + "additional:gpt-5-3-codex-spark:session", "additional:gpt-5-3-codex-spark:weekly", "weekly", + ]) + #expect(snapshot.window("weekly")?.label == "Weekly") + #expect(snapshot.window("weekly")?.usedPercent == 62) + #expect(snapshot.window("weekly")?.resetsAt == Date(timeIntervalSince1970: 1_788_558_705)) + let spark = try #require(snapshot.window("additional:gpt-5-3-codex-spark:session")) + #expect(spark.label == "GPT-5.3-Codex-Spark 5-hour") + #expect(spark.scope == "GPT-5.3-Codex-Spark") + #expect(spark.group == .session) +} + +@Test func codexReportsMonthlyAndCodeReviewWindows() async throws { + let usage = #""" + {"rate_limit": {"allowed": false, "limit_reached": true, + "primary_window": {"used_percent": 100, "limit_window_seconds": 18000, "reset_after_seconds": 10, "reset_at": 1}, + "secondary_window": {"used_percent": 40, "limit_window_seconds": 2592000}}, + "code_review_rate_limit": {"allowed": true, "limit_reached": false, + "primary_window": {"used_percent": 5, "limit_window_seconds": 3600}}} + """# + let snapshot = try #require(await codexSnapshot(usage: .text(usage))) + #expect(snapshot.windows.map(\.id) == ["session", "monthly", "code_review:window-3600"]) + #expect(snapshot.window("session")?.severity == .critical) + #expect(snapshot.window("monthly")?.group == .monthly) + #expect(snapshot.window("code_review:window-3600")?.label == "Code review 1h") + #expect(snapshot.window("code_review:window-3600")?.group == .other) +} + +@Test func codexReportsNoWindowsWithoutRateLimits() async throws { + let snapshot = try #require(await codexSnapshot(usage: .text("{}"))) + #expect(snapshot.windows.isEmpty) +} + +@Test func codexTreatsAWindowWithoutADurationAsTheSession() async throws { + let usage = #"{"rate_limit": {"primary_window": {"used_percent": 3}}}"# + let snapshot = try #require(await codexSnapshot(usage: .text(usage))) + #expect(snapshot.windows.map(\.id) == ["session"]) + #expect(snapshot.windows[0].duration == 18000) +} + +@Test func codexReportsTheCreditBalance() async throws { + let snapshot = try #require(await codexSnapshot(usage: .json("codex_usage"))) + #expect(snapshot.credits?.balance == 0) + #expect(snapshot.credits?.hasCredits == false) + #expect(snapshot.credits?.approxLocalMessages == 0...0) +} + +@Test func codexReportsUnlimitedCreditsWithoutAMessageRange() async throws { + let usage = #""" + {"credits": {"has_credits": true, "unlimited": true, "balance": "12.5", "approx_local_messages": [5, 2]}} + """# + let snapshot = try #require(await codexSnapshot(usage: .text(usage))) + #expect(snapshot.credits?.balance == Decimal(string: "12.5")) + #expect(snapshot.credits?.unlimited == true) + #expect(snapshot.credits?.approxLocalMessages == nil) + #expect(snapshot.credits?.approxCloudMessages == nil) +} + +@Test func codexReportsTheIndividualSpendLimit() async throws { + let usage = #""" + {"spend_control": {"reached": true, "individual_limit": {"limit": "100", "used": "42.5", "remaining": "57.5", + "used_percent": 42.5, "reset_at": 1788558705}}} + """# + let snapshot = try #require(await codexSnapshot(usage: .text(usage))) + let spend = try #require(snapshot.spend) + #expect(spend.used == Money(amountMinor: 4250, currency: "USD")) + #expect(spend.limit == Money(amountMinor: 10000, currency: "USD")) + #expect(spend.percent == 42.5) + #expect(spend.limitReached) + #expect(spend.resetsAt == Date(timeIntervalSince1970: 1_788_558_705)) +} + +@Test( + arguments: [ + "{}", #"{"spend_control": {"reached": false}}"#, + ]) +func codexReportsNoSpendWithoutALimit(usage: String) async throws { + let snapshot = try #require(await codexSnapshot(usage: .text(usage))) + #expect(snapshot.spend == nil) +} + +@Test func codexIgnoresAnUnreadableSpendLimit() async throws { + let usage = #"{"spend_control": {"individual_limit": {"limit": "x"}}}"# + let snapshot = try #require(await codexSnapshot(usage: .text(usage))) + #expect(snapshot.spend?.limit == nil) +} + +@Test func codexReportsTheResetCreditsSummary() async throws { + let snapshot = try #require(await codexSnapshot(usage: .json("codex_usage"))) + #expect( + snapshot.resetCredits == ResetCredits(available: 0, applicable: 0, totalEarned: 0, immediatePurchaseEligible: false) + ) +} + +@Test func codexFallsBackToTheAvailableCountForApplicableCredits() async throws { + let transport = StubTransport() + transport.on(path: "/wham/usage", .json("codex_usage")) + transport.on(path: "rate-limit-reset-credits", .text(#"{"available_count": 2}"#)) + let provider = codexProvider(MemoryCodexStore(validCodex), transport: transport) + guard case .success(let snapshot) = await provider.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected a snapshot") + return + } + #expect(snapshot.resetCredits?.applicable == 2) +} + +@Test func codexNoticesCoverLimitSpendPromoAndOverage() async throws { + let usage = #""" + {"rate_limit": {"allowed": false, "limit_reached": true}, + "credits": {"overage_limit_reached": true}, + "spend_control": {"reached": true}, + "rate_limit_reached_type": {"type": "workspace_owner_credits_depleted"}, + "promo": {"text": "Double limits this week"}} + """# + let snapshot = try #require(await codexSnapshot(usage: .text(usage))) + let notices = snapshot.notices + #expect(notices.map(\.kind) == [.limitReached, .spendControl, .promotion, .spendControl]) + #expect(notices[0].text == "Limit reached: Workspace Owner Credits Depleted.") + #expect(notices[2].text == "Double limits this week") +} + +@Test func codexNoticesReadAPromoAndALimitInWhateverShapeTheyArrive() async throws { + let untyped = #""" + {"rate_limit": {"allowed": false, "limit_reached": true}, "rate_limit_reached_type": null, + "promo": {"title": "Promo"}} + """# + let plain = try #require(await codexSnapshot(usage: .text(untyped))) + #expect(plain.notices.map(\.text) == ["Usage limit reached.", "Promo"]) + let odd = #"{"rate_limit_reached_type": "odd", "promo": {"pct": 2}}"# + let strange = try #require(await codexSnapshot(usage: .text(odd))) + #expect(strange.notices.map(\.text) == ["Limit reached: Odd.", "pct: 2"]) +} + +@Test func codexReportsNoNoticesOnAHealthyAccount() async throws { + let snapshot = try #require(await codexSnapshot(usage: .json("codex_usage"))) + #expect(snapshot.notices.isEmpty) +} + +@Test( + arguments: [ + ("", "ChatGPT"), ("pro", "Pro"), ("prolite", "Pro Lite"), ("plus", "Plus"), + ("go", "Go"), ("free", "Free"), ("team", "Team"), ("free_workspace", "Team"), ("business", "Business"), + ("self_serve_business_prolite", "Business"), ("enterprise", "Enterprise"), ("edu", "Education"), + ("education", "Education"), ("k12", "K12"), ("quorum_plus", "Quorum Plus"), + ]) +func codexNamesThePlanFromItsType(planType: String, expected: String) async throws { + let snapshot = try #require(await codexSnapshot(usage: .text(#"{"plan_type": "\#(planType)"}"#))) + #expect(snapshot.identity?.planName == expected) +} + +@Test func codexFallsBackToThePlanInTheToken() async throws { + let snapshot = try #require(await codexSnapshot(usage: .text("{}"))) + #expect(snapshot.identity?.planName == "Pro") +} + +@Test func codexIdentityMergesTheResponseAndTheToken() async throws { + let snapshot = try #require(await codexSnapshot(usage: .json("codex_usage"))) + let identity = try #require(snapshot.identity) + #expect(identity.planName == "Pro") + #expect(identity.tier == "pro") + #expect(identity.email == "user@example.com") + #expect(identity.subscriptionActiveUntil == CodexAuth(document: Fixtures.codexAuth())?.subscriptionActiveUntil) +} + +@MainActor +private func codexAnalytics(_ stub: @escaping (StubTransport) -> Void = { _ in }) async -> ProviderAnalytics? { + let (result, _) = await codexFetch( + usage: .json("codex_usage"), analytics: true, + stub: { transport in + stub(transport) + stubCodexAnalytics(transport) + }) + return result.analytics +} + +@Test func codexAnalyticsCarryUsageBySurfaceAndModel() async throws { + let points = try #require(await codexAnalytics()).points.filter { $0.day == "2026-08-29" } + #expect(points.first { $0.metric == .surfaceUsagePercent && $0.series == "cli" }?.value.rounded() == 38) + #expect(points.contains { $0.metric == .modelCredits && $0.series == "gpt-5.6-sol" }) +} + +@Test func codexAnalyticsSkipARowWithoutUsage() async throws { + let points = try #require( + await codexAnalytics { $0.on(path: "daily-token-usage-breakdown", .text(#"{"data": [{"models": []}]}"#)) }) + #expect(!points.points.contains { $0.metric == .modelCredits }) +} + +@Test func codexAnalyticsCarryWorkspaceTokenCounts() async throws { + let points = try #require(await codexAnalytics()).points + let day = points.filter { $0.day == "2026-08-29" } + #expect(day.first { $0.metric == .inputTokens }?.value == 23_112_562) + #expect(day.first { $0.metric == .cachedInputTokens }?.value == 1_218_464_768) + #expect(day.first { $0.metric == .outputTokens }?.value == 2_018_632) + #expect(day.contains { $0.metric == .credits && $0.series == "surface:CODEX_CLI" }) + #expect(points.contains { $0.metric == .turns && $0.series.hasPrefix("model:") }) + #expect(points.contains { $0.day == "2026-08-28" && $0.metric == .turns && $0.series == "total" && $0.value == 42 }) +} + +@Test func codexAnalyticsSkipAWorkspaceRowWithoutTotals() async throws { + let sparse = #"{"data": [{"date": "2026-01-01", "models": [{"turns": 1}]}]}"# + let points = try #require(await codexAnalytics { $0.on(path: "daily-workspace-usage-counts", .text(sparse)) }) + #expect(!points.points.contains { $0.metric == .inputTokens }) +} + +@Test func codexAnalyticsCarrySkillsPluginsAndCodeReviews() async throws { + let points = try #require(await codexAnalytics()).points + #expect(points.contains { $0.day == "2026-08-29" && $0.series == "Simp" && $0.value == 38 }) + let github = points.filter { + $0.day == "2026-08-07" && $0.series == "github" && $0.metric == .pluginInvocations + } + #expect(github.map(\.value) == [10]) + let review = #"{"data": [{"date": "2026-08-01", "reviews": 3, "note": "x"}]}"# + let reviewed = try #require(await codexAnalytics { $0.on(path: "daily-code-review-metrics", .text(review)) }) + #expect( + reviewed.points.contains(AnalyticsPoint(day: "2026-08-01", metric: .codeReviews, series: "reviews", value: 3))) +} + +@Test func codexAnalyticsSkipASkillOrPluginWithoutAName() async throws { + let skills = #""" + {"data": [{"date": "2026-08-29", "skill_usage_overviews": [{"skill_name": "raw", "invocation_counts": 1}, + {"invocation_counts": 2}]}]} + """# + let plugins = #""" + {"data": [{"date": "2026-08-29", + "plugin_usage_overviews": [{"display_name": "Disp", "invocation_counts": 1}, {}]}]} + """# + let points = try #require( + await codexAnalytics { + $0.on(path: "daily-skill-usage-metrics", .text(skills)) + $0.on(path: "daily-plugin-usage-metrics", .text(plugins)) + } + ).points + #expect(points.filter { $0.metric == .skillInvocations }.map(\.series) == ["raw"]) + #expect(points.filter { $0.metric == .pluginInvocations }.map(\.series) == ["Disp"]) +} + +@Test func codexCreditEventsTolerateEveryShapeTheAPIReturns() async throws { + let rows = #""" + {"data": [{"id": "e1", "date": "2026-08-01", "service": "Codex", "credits_used": 3}, + {"created_at": "2026-08-02T10:00:00Z", "product": "Review", "credits": 1.5}, + {"timestamp": "2026-08-03T00:00:00Z", "amount": 2}, {"note": "no date"}]} + """# + let analytics = try #require(await codexAnalytics { $0.on(path: "credit-usage-events", .text(rows)) }) + #expect(analytics.creditEvents.map(\.id) == ["e1", "2026-08-02T10:00:00Z-1", "2026-08-03T00:00:00Z-2"]) + #expect(analytics.creditEvents.map(\.service) == ["Codex", "Review", "Codex"]) + #expect(analytics.creditEvents.map(\.creditsUsed) == [3, 1.5, 2]) +} + +@Test func codexAsksEachAnalyticsEndpointForTheRangeItNeeds() async throws { + let (_, transport) = await codexFetch(usage: .json("codex_usage"), analytics: true, stub: stubCodexAnalytics) + let skills = try #require(transport.requests(matching: "daily-skill-usage-metrics").first?.url) + #expect(skills.query?.contains("group_by=day&workspace_user=true&top_skill_limit=20") == true) + #expect( + transport.requests(matching: "daily-plugin-usage-metrics").first?.url?.query?.contains("top_plugin_limit=20") + == true) + #expect( + transport.requests(matching: "daily-token-usage-breakdown").first?.url?.query?.hasSuffix("group_by=day") == true) + #expect( + transport.requests(matching: "daily-code-review-metrics").first?.url?.query?.hasSuffix("workspace_user=true") + == true) + #expect(transport.requests.allSatisfy { $0.value(forHTTPHeaderField: "ChatGPT-Account-Id") == "acct" }) +} diff --git a/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift b/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift new file mode 100644 index 0000000..ac1db82 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift @@ -0,0 +1,222 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func rolloutReaderPicksLastRateLimitsInNewestFile() async throws { + let root = temporaryDirectory() + let older = root.appendingPathComponent("2026/08/28") + let newer = root.appendingPathComponent("2026/08/29") + try FileManager.default.createDirectory(at: older, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: newer, withIntermediateDirectories: true) + let oldFile = older.appendingPathComponent("rollout-old.jsonl") + try (rolloutLine(primary: 1) + "\n").write(to: oldFile, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1000)], ofItemAtPath: oldFile.path) + let newFile = newer.appendingPathComponent("rollout-new.jsonl") + try + ([ + #"{"type":"other"}"#, rolloutLine(primary: 12), "not json {\"rate_limits\"", + rolloutLine(primary: 55, secondary: nil), + ].joined(separator: "\n") + "\n").write(to: newFile, atomically: true, encoding: .utf8) + try "ignored".write(to: newer.appendingPathComponent("notes.txt"), atomically: true, encoding: .utf8) + let reading = try #require(await CodexRolloutReader(sessionsRoot: root).latest(now: fixedNow)) + #expect(reading.rateLimit.primaryWindow?.usedPercent == 55) + #expect(reading.rateLimit.primaryWindow?.limitWindowSeconds == 18000) + #expect(reading.rateLimit.primaryWindow?.resetAt == 1_788_205_600) + #expect(reading.rateLimit.secondaryWindow == nil) + #expect(reading.rateLimit.limitReached == false) + #expect(reading.planType == "pro") + #expect(reading.credits?.balance == "9.5") + #expect(reading.observedAt == ISODate.parse("2026-08-29T10:00:00.000Z")) +} + +@Test func rolloutReaderFallsThroughToOlderFilesAndHandlesMissingRoot() async throws { + let root = temporaryDirectory() + let dir = root.appendingPathComponent("a") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let empty = dir.appendingPathComponent("rollout-empty.jsonl") + try "{\"type\":\"nothing\"}\n".write(to: empty, atomically: true, encoding: .utf8) + let withData = dir.appendingPathComponent("rollout-data.jsonl") + try (rolloutLine(primary: 7) + "\n").write(to: withData, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 10)], ofItemAtPath: withData.path) + #expect( + await CodexRolloutReader(sessionsRoot: root).latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 7) + #expect( + await CodexRolloutReader(sessionsRoot: root.appendingPathComponent("missing")).latest(now: fixedNow) == nil) + #expect(await CodexRolloutReader(sessionsRoot: temporaryDirectory()).latest(now: fixedNow) == nil) +} + +@Test func rolloutReaderLimitsFileCount() async throws { + let root = temporaryDirectory() + for index in 0..<(CodexRolloutReader.maxFiles + 3) { + let file = root.appendingPathComponent("rollout-\(index).jsonl") + try "{}\n".write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: Double(index))], ofItemAtPath: file.path) + } + let newest = await CodexRolloutReader(sessionsRoot: root).newestRollouts() + #expect(newest.count == CodexRolloutReader.maxFiles) + #expect(newest.first?.lastPathComponent == "rollout-\(CodexRolloutReader.maxFiles + 2).jsonl") +} + +@Test func rolloutReaderCachesSuccessAndFailureUntilExpiry() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("rollout-current.jsonl") + try (rolloutLine(primary: 7) + "\n").write(to: file, atomically: true, encoding: .utf8) + let reader = CodexRolloutReader(sessionsRoot: root, cacheInterval: 300) + #expect(await reader.latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 7) + + try (rolloutLine(primary: 42) + "\n").write(to: file, atomically: true, encoding: .utf8) + #expect( + await reader.latest(now: fixedNow.addingTimeInterval(60))?.rateLimit.primaryWindow?.usedPercent == 42) + #expect((await reader.workload).treesScanned == 1) + #expect( + await reader.latest(now: fixedNow.addingTimeInterval(301))?.rateLimit.primaryWindow?.usedPercent == 42) + + let emptyRoot = temporaryDirectory() + let empty = CodexRolloutReader(sessionsRoot: emptyRoot, cacheInterval: 300) + #expect(await empty.latest(now: fixedNow) == nil) + let added = emptyRoot.appendingPathComponent("rollout-added.jsonl") + try (rolloutLine(primary: 9) + "\n").write(to: added, atomically: true, encoding: .utf8) + #expect(await empty.latest(now: fixedNow.addingTimeInterval(60)) == nil) + #expect((await empty.workload).treesScanned == 1) + #expect(await empty.latest(now: fixedNow.addingTimeInterval(301)) != nil) +} + +@Test func rolloutReaderBoundsEachReverseReadSlice() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("rollout-current.jsonl") + try (rolloutLine(primary: 7) + "\n" + String(repeating: "x", count: 4_096) + "\n").write( + to: file, atomically: true, encoding: .utf8) + let reader = CodexRolloutReader(sessionsRoot: root, workByteBudget: 128) + #expect(await reader.latest(now: fixedNow) == nil) + let reading = await waitForRolloutReading(reader) + #expect(reading?.rateLimit.primaryWindow?.usedPercent == 7) + #expect((await reader.workload).largestSliceBytesRead <= 128) +} + +@Test func rolloutReaderBoundsTreeEnumerationSlices() async throws { + let root = temporaryDirectory() + for index in 0..<300 { + try "{}\n".write( + to: root.appendingPathComponent("rollout-\(index).jsonl"), atomically: true, encoding: .utf8) + } + let reader = CodexRolloutReader(sessionsRoot: root, workEntryBudget: 4) + _ = await reader.latest(now: fixedNow) + await waitForRolloutSearch(reader) + let workload = await reader.workload + #expect(workload.largestTreeSliceEntries <= 4) + #expect(workload.treeEntriesExamined == 300) +} + +@Test func rolloutReaderStopsAfterAFileShrinksDuringARead() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("rollout-current.jsonl") + try String(repeating: "x", count: 1_048_576).write(to: file, atomically: true, encoding: .utf8) + let reader = CodexRolloutReader(sessionsRoot: root, workByteBudget: 1) + _ = await reader.latest(now: fixedNow) + try Data().write(to: file, options: .atomic) + await waitForRolloutSearch(reader) + #expect((await reader.workload).searchesCompleted == 1) +} + +@Test func rolloutReaderSkipsOversizedNonRateLimitLines() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("rollout-current.jsonl") + let oversized = String(repeating: "x", count: CodexRolloutReader.maximumLineBytes + 1) + try (rolloutLine(primary: 7) + "\n" + oversized + "\n").write(to: file, atomically: true, encoding: .utf8) + let reader = CodexRolloutReader(sessionsRoot: root) + _ = await reader.latest(now: fixedNow) + #expect(await waitForRolloutReading(reader)?.rateLimit.primaryWindow?.usedPercent == 7) +} + +@Test func rolloutReaderSkipsJsonlDirectories() async throws { + let root = temporaryDirectory() + let valid = root.appendingPathComponent("rollout-valid.jsonl") + try (rolloutLine(primary: 7) + "\n").write(to: valid, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: fixedNow.addingTimeInterval(-60)], ofItemAtPath: valid.path) + let invalid = root.appendingPathComponent("rollout-broken.jsonl") + try FileManager.default.createDirectory(at: invalid, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.modificationDate: fixedNow], ofItemAtPath: invalid.path) + + #expect( + await CodexRolloutReader(sessionsRoot: root).latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 7) +} + +@Test func rolloutReaderIgnoresDirectoriesBeforeApplyingItsFileLimit() async throws { + let root = temporaryDirectory() + for index in 0...CodexRolloutReader.maxFiles { + try FileManager.default.createDirectory( + at: root.appendingPathComponent("rollout-fake-\(index).jsonl"), withIntermediateDirectories: true) + } + try (rolloutLine(primary: 7) + "\n").write( + to: root.appendingPathComponent("rollout-valid.jsonl"), atomically: true, encoding: .utf8) + #expect( + await CodexRolloutReader(sessionsRoot: root).latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 7) +} + +@Test func rolloutReaderBreaksEqualModificationDatesByPath() async throws { + let root = temporaryDirectory() + for name in ["rollout-c.jsonl", "rollout-a.jsonl", "rollout-b.jsonl"] { + let file = root.appendingPathComponent(name) + try "{}\n".write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: fixedNow], ofItemAtPath: file.path) + } + #expect( + await CodexRolloutReader(sessionsRoot: root).newestRollouts().map(\.lastPathComponent) == [ + "rollout-a.jsonl", "rollout-b.jsonl", "rollout-c.jsonl", + ]) +} + +@Test func rolloutParseHandlesNestedAndInvalidShapes() { + #expect(CodexRolloutReader.parse(line: "{") == nil) + #expect(CodexRolloutReader.parse(line: #"{"a":[{"rate_limits":"str"}]}"#) == nil) + let nested = CodexRolloutReader.parse( + line: #"{"a":[1,{"b":{"rate_limits":{"primary":{"used_percent":3,"limit_window_seconds":18000,"# + + #""resets_in_seconds":5,"reset_at":9},"rate_limit_reached_type":{"type":"x"}}}}]}"#)! + #expect(nested.rateLimit.primaryWindow?.limitWindowSeconds == 18000) + #expect(nested.rateLimit.primaryWindow?.resetAfterSeconds == 5) + #expect(nested.rateLimit.primaryWindow?.resetAt == 9) + #expect(nested.rateLimit.limitReached == true) + #expect(nested.credits == nil) + #expect(nested.observedAt == nil) + let noPercent = CodexRolloutReader.parse(line: #"{"rate_limits":{"primary":{"window_minutes":5}}}"#)! + #expect(noPercent.rateLimit.primaryWindow == nil) + #expect(CodexRolloutReader.findRateLimits(.string("x")) == nil) +} + +private func rolloutLine( + primary: Double, secondary: Double? = 34, plan: String = "pro", timestamp: String = "2026-08-29T10:00:00.000Z" +) -> String { + let secondaryText = + secondary.map { #"{"used_percent":\#($0),"window_minutes":10080,"resets_at":1788544000}"# } ?? "null" + return #"{"timestamp":"\#(timestamp)","type":"event_msg","payload":{"type":"token_count","# + + #""rate_limits":{"primary":{"used_percent":\#(primary),"window_minutes":300,"# + + #""resets_at":1788205600},"secondary":\#(secondaryText),"# + + #""credits":{"has_credits":true,"unlimited":false,"balance":"9.5"},"# + + #""plan_type":"\#(plan)","rate_limit_reached_type":null}}}"# +} + +private func waitForRolloutReading(_ reader: CodexRolloutReader) async -> CodexRolloutReader.Reading? { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while clock.now < deadline { + if let reading = await reader.latest(now: fixedNow) { return reading } + await Task.yield() + } + Issue.record("Rollout search did not finish") + return nil +} + +private func waitForRolloutSearch(_ reader: CodexRolloutReader) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while clock.now < deadline { + if (await reader.workload).searchesCompleted > 0 { return } + await Task.yield() + } + Issue.record("Rollout search did not finish") +} diff --git a/Tests/TokenMenuBarCoreTests/CopilotTests.swift b/Tests/TokenMenuBarCoreTests/CopilotTests.swift new file mode 100644 index 0000000..203685d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CopilotTests.swift @@ -0,0 +1,184 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func copilotFileStoreFindsGitHubEntries() throws { + let root = temporaryDirectory() + let hosts = root.appendingPathComponent("hosts.json") + let apps = root.appendingPathComponent("apps.json") + let store = FileCopilotAuthStore(urls: [hosts, apps]) + #expect(store.description == "\(hosts.path), \(apps.path)") + #expect(try store.load() == nil) + try Fixtures.data("copilot_hosts").write(to: apps) + #expect(try store.load() == CopilotAuth(token: "gho_test_token_123", user: "octocat", host: "github.com")) + try Data(#"{"ghe.example.com:Iv1.x": {"user": "ent", "oauth_token": "gho_ent"}, "empty": {"oauth_token": ""}}"#.utf8) + .write(to: hosts) + #expect(try store.load() == CopilotAuth(token: "gho_ent", user: "ent", host: "ghe.example.com")) + try Data("[]".utf8).write(to: hosts) + #expect(throws: CredentialStoreError.self) { try store.load() } + try Data(#"{"github.com": {"user": "x"}}"#.utf8).write(to: hosts) + try FileManager.default.removeItem(at: apps) + #expect(try store.load() == nil) + #expect( + FileCopilotAuthStore.defaultURLs(environment: [:], home: root).map(\.lastPathComponent) == [ + "hosts.json", "apps.json", + ]) + #expect( + FileCopilotAuthStore.defaultURLs(environment: ["XDG_CONFIG_HOME": "/xdg"], home: root).first?.path + == "/xdg/github-copilot/hosts.json") + #expect(validCopilot.state(now: fixedNow) == .valid(expiresAt: nil)) +} + +private let validCopilot = CopilotAuth(token: "gho_test", user: "octocat") + +@MainActor +private func copilotSnapshot( + _ response: StubTransport.Response, auth: CopilotAuth = validCopilot +) async + -> ProviderSnapshot? +{ + let (provider, transport) = makeProvider(auth) + transport.on(path: "/copilot_internal/user", response) + guard case .success(let snapshot) = await provider.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected a snapshot") + return nil + } + return snapshot +} + +@Test func copilotReportsAPaidPlanAsMonthlyWindows() async throws { + let snapshot = try #require(await copilotSnapshot(.json("copilot_user"))) + #expect(snapshot.windows.map(\.id) == ["completions", "premium_interactions"]) + #expect(snapshot.windows.map(\.label) == ["Completions", "Premium requests"]) + #expect(snapshot.windows.map(\.usedPercent) == [75, 100]) + #expect(snapshot.windows.allSatisfy { $0.resetsAt == DayStamp.date("2026-09-01") }) + #expect(snapshot.identity?.planName == "Pro Plus") + #expect(snapshot.identity?.tier == "copilot_pro_seat") + #expect(snapshot.identity?.email == "octocat") +} + +@Test func copilotReportsBillingAndOverageAsNotices() async throws { + let snapshot = try #require(await copilotSnapshot(.json("copilot_user"))) + #expect( + snapshot.notices.map(\.text) == [ + "Token-based billing: 33 credits used this cycle.", "Premium requests: quota exceeded, 15 overage requests.", + ]) + #expect(snapshot.notices[1].kind == .info) +} + +@Test func copilotReportsAFreePlanFromItsMonthlyAllowances() async throws { + let snapshot = try #require(await copilotSnapshot(.json("copilot_user_free"))) + #expect(snapshot.windows.map(\.id) == ["free:chat", "free:completions", "premium_interactions"]) + #expect(snapshot.windows.map(\.label) == ["Chat", "Completions", "Premium requests"]) + #expect(abs(snapshot.windows[0].usedPercent - 18) < 0.001) + #expect(snapshot.windows[0].resetsAt == DayStamp.date("2026-09-11")) + #expect(snapshot.windows[1].usedPercent == 0) + #expect(snapshot.windows[2].usedPercent == 60) + #expect(snapshot.identity?.planName == "Individual") + #expect(snapshot.notices.isEmpty) +} + +@Test func copilotCallsAnExhaustedQuotaWithoutOverageALimit() async throws { + let body = #""" + {"quota_snapshots": {"chat": {"percent_remaining": -10, "overage_permitted": false}, + "premium_interactions": {"entitlement": 0, "remaining": 0}}} + """# + let snapshot = try #require(await copilotSnapshot(.text(body), auth: CopilotAuth(token: "t"))) + #expect(snapshot.notices.map(\.kind) == [.limitReached]) + #expect(snapshot.windows.map(\.id) == ["chat"]) + #expect(snapshot.identity?.planName == "Copilot") +} + +@Test func copilotLeavesTheResetOpenWhenTheDateMakesNoSense() async throws { + let body = #"{"quota_reset_date": "whenever", "quota_snapshots": {"chat": {"percent_remaining": 40}}}"# + let snapshot = try #require(await copilotSnapshot(.text(body), auth: CopilotAuth(token: "t"))) + #expect(snapshot.windows.map(\.resetsAt) == [nil]) + #expect(snapshot.windows.map(\.usedPercent) == [60]) +} + +@Test func copilotReportsNoWindowsWhenTheAccountHasNoQuota() async throws { + let snapshot = try #require(await copilotSnapshot(.text("{}"), auth: CopilotAuth(token: "t"))) + #expect(snapshot.windows.isEmpty) + #expect(snapshot.notices.isEmpty) +} + +@Test func copilotProviderIdentifiesItselfAsAnEditor() async { + let store = MemoryCopilotStore(validCopilot) + let (provider, transport) = makeProvider(validCopilot, store: store) + transport.on(path: "/copilot_internal/user", .json("copilot_user")) + #expect(provider.credentialDescription == "memory") + #expect(provider.credentialState(now: fixedNow) == .valid(expiresAt: nil)) + let health = await provider.credentialHealth(now: fixedNow) + guard case .valid(let source, let expiresAt) = health else { + Issue.record("expected a valid Copilot credential, got \(health)") + return + } + #expect(source == store.source) + #expect(expiresAt == nil) + let readsBeforeFetch = store.readCount + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(store.readCount == readsBeforeFetch + 1) + #expect( + result.credentialStatus + == ProviderCredentialStatus( + state: .valid(expiresAt: nil), health: .valid(source: store.source, expiresAt: nil))) + let request = transport.requests(matching: "/copilot_internal/user").first! + #expect(request.url?.host() == "api.github.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "token gho_test") + // GitHub answers this endpoint for editor clients, so the request carries an editor and plugin version + #expect(request.value(forHTTPHeaderField: "Editor-Version")?.hasPrefix("vscode/") == true) + #expect(request.value(forHTTPHeaderField: "Editor-Plugin-Version")?.isEmpty == false) +} + +@Test func copilotProviderAsksTheEnterpriseHost() async { + let (provider, transport) = makeProvider(CopilotAuth(token: "gho_ent", user: "ent", host: "ghe.example.com")) + transport.on(path: "/copilot_internal/user", .json("copilot_user")) + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(transport.requests.first?.url?.host() == "api.ghe.example.com") +} + +@Test func copilotProviderRejectsAnInvalidHostWithoutSendingTheToken() async { + let (provider, transport) = makeProvider(CopilotAuth(token: "secret", host: "https://ghe.example/path")) + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + guard case .notAuthenticated = result.outcome else { + Issue.record("expected invalid credentials") + return + } + #expect(result.recoveryIssue?.kind == .credentialUnreadable) + #expect(transport.requests.isEmpty) +} + +private func makeProvider(_ auth: CopilotAuth?, store: MemoryCopilotStore? = nil) -> (CopilotProvider, StubTransport) { + let transport = StubTransport() + let provider = CopilotProvider( + auth: store ?? MemoryCopilotStore(auth), client: APIClient(transport: transport, log: makeLog(), clock: testClock), + log: makeLog()) + return (provider, transport) +} + +@Test func copilotProviderHandlesFailures() async { + let (missing, _) = makeProvider(nil) + #expect(missing.credentialState(now: fixedNow) == .missing("no Copilot sign-in found")) + guard case .notAuthenticated(let reason) = await missing.fetch(now: fixedNow, options: FetchOptions()).outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(reason.contains("No Copilot credentials")) + let store = MemoryCopilotStore(validCopilot) + store.loadError = TestError() + let (broken, _) = makeProvider(nil, store: store) + #expect(broken.credentialState(now: fixedNow).isMissing) + guard case .notAuthenticated(let loadReason) = await broken.fetch(now: fixedNow, options: FetchOptions()).outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(loadReason.contains("Cannot read")) + let (rejected, transport) = makeProvider(validCopilot) + transport.on(path: "/copilot_internal/user", .text("bad credentials", status: 401)) + guard case .notAuthenticated = await rejected.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected notAuthenticated") + return + } +} diff --git a/Tests/TokenMenuBarCoreTests/CoreCoverageClosureTests.swift b/Tests/TokenMenuBarCoreTests/CoreCoverageClosureTests.swift new file mode 100644 index 0000000..0d3398b --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CoreCoverageClosureTests.swift @@ -0,0 +1,464 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func coverageClosureDiagnosticMessagesRenderPresentOptionalsAndWarnings() { + let status = DiagnosticEvent.status( + StatusDiagnostic( + action: .retier, + trigger: "fit", + buttonFrame: nil, + oldTier: 1, + newTier: 2, + visible: true, + popoverVisible: false, + fits: true, + layoutContext: nil)) + let refresh = DiagnosticEvent.refresh( + RefreshDiagnostic( + cycleID: "cycle", + trigger: "scheduled", + provider: .codex, + usagePolicy: "force", + analyticsPolicy: "skip", + outcome: .partial, + durationMilliseconds: 4, + includeAnalytics: false, + analyticsReturned: false, + analyticsPointCount: 0, + warnings: ["quota delayed"])) + + #expect(status.message.contains("oldTier=1")) + #expect(status.message.contains("newTier=2")) + #expect(refresh.message.contains("warning1=\"quota delayed\"")) +} + +@Test func coverageClosureLogBufferHandlesEmptyAndTruncatedRetainedFiles() async throws { + #expect(LogLevel.warning < .error) + + let emptyURL = temporaryDirectory().appendingPathComponent("empty.log") + let empty = LogBuffer(fileURL: emptyURL, clock: testClock) + #expect(await empty.retainedSnapshot().isEmpty) + empty.logWarning("persistence warning", category: .persistence) + empty.flush() + #expect(await empty.retainedSnapshot().map(\.message) == ["persistence warning"]) + + let truncatedURL = temporaryDirectory().appendingPathComponent("truncated.log") + try Data(repeating: 0x61, count: 64).write(to: truncatedURL) + let truncated = LogBuffer(fileURL: truncatedURL, clock: testClock, maximumFileBytes: 8) + #expect(truncated.snapshot.isEmpty) + #expect(!FileManager.default.fileExists(atPath: truncatedURL.path)) +} + +@Test func coverageClosureLogBufferRecordsNilDiagnosticGeometry() { + let log = makeLog() + log.record( + .panel( + PanelDiagnostic( + action: .open, + trigger: "test", + tab: "Usage", + anchor: nil, + screenID: "main", + screenFrame: nil, + maximum: DiagnosticSize(width: 832, height: 700), + proposed: DiagnosticSize(width: 832, height: 900), + clamped: DiagnosticSize(width: 832, height: 700), + resultFrame: nil, + appActive: false, + windowKey: nil, + windowMain: nil, + frontmostBundleID: nil)), + level: .info) + log.record( + .status( + StatusDiagnostic( + action: .probe, + trigger: "test", + buttonFrame: nil, + oldTier: nil, + newTier: nil, + visible: false, + popoverVisible: false, + fits: nil, + layoutContext: nil)), + level: .warning) + log.record( + .refresh( + RefreshDiagnostic( + cycleID: "coverage-cycle", + trigger: "test", + provider: .codex, + usagePolicy: "force", + analyticsPolicy: "force", + outcome: .success, + durationMilliseconds: 12, + includeAnalytics: true, + analyticsReturned: true, + analyticsPointCount: 3, + warnings: [])), + level: .error) + log.record( + .request( + RequestDiagnostic( + requestID: "coverage-request", + operation: "refresh", + method: "GET", + status: 200, + byteCount: 42, + durationMilliseconds: 7, + errorDomain: "coverage", + errorCode: 0)), + level: .error) + + #expect(log.snapshot.map(\.category) == [.geometry, .status, .refresh, .network]) + #expect(log.snapshot[0].message.contains("screen=main")) + #expect(!log.snapshot[0].message.contains("anchor=")) +} + +@Test func coverageClosureNotificationsHandleMissingResetMetadataAndThresholds() { + let previous = ProviderSnapshot( + provider: .claude, + windows: [QuotaWindow(id: "session", label: "Session", group: .session, usedPercent: 70, resetsAt: nil)], + fetchedAt: fixedNow) + let crossed = ProviderSnapshot( + provider: .claude, + windows: [QuotaWindow(id: "session", label: "Session", group: .session, usedPercent: 80, resetsAt: nil)], + fetchedAt: fixedNow) + let threshold = NotificationPlanner.events( + previous: previous, + current: crossed, + previousAvailability: .current, + currentAvailability: .current, + provider: .claude, + settings: NotificationSettings(thresholds: [75]), + now: fixedNow) + #expect(threshold.map(\.body) == ["Crossed 75% of the session limit."]) + #expect(threshold[0].id.hasSuffix(":0")) + + let reset = ProviderSnapshot( + provider: .claude, + windows: [QuotaWindow(id: "session", label: "Session", group: .session, usedPercent: 5, resetsAt: nil)], + fetchedAt: fixedNow) + let resetEvents = NotificationPlanner.events( + previous: crossed, + current: reset, + previousAvailability: .current, + currentAvailability: .current, + provider: .claude, + settings: NotificationSettings(thresholds: []), + now: fixedNow) + #expect(resetEvents.map(\.kind) == [.reset]) +} + +@Test func coverageClosurePaceSummaryDefaultsMissingExpectedUsage() { + let projected = PaceEstimate( + status: .ahead, + expectedPercent: nil, + ratio: nil, + projectedExhaustion: fixedNow.addingTimeInterval(3_600)) + let lasting = PaceEstimate(status: .behind, expectedPercent: nil, ratio: nil, projectedExhaustion: nil) + + #expect(projected.summary(now: fixedNow).contains("expected 0%")) + #expect(lasting.summary(now: fixedNow) == "Under pace (expected 0%); lasts until reset") +} + +@Test @MainActor func coverageClosureAppStateRestoresCachedAndExpiredCredentials() { + let state = AppState() + let source = ProviderID.codex.credentialSource("codex.file") + state.update(.codex) { + $0.snapshot = DemoData.snapshot(.codex, now: fixedNow) + $0.availability = .authenticationRequired + } + state.applySetupStates([ + .codex: ProviderSetupState( + enabled: true, + credential: .valid(source: source, expiresAt: fixedNow.addingTimeInterval(3_600))) + ]) + #expect(state.state(for: .codex).availability == .stale) + + let expiry = fixedNow.addingTimeInterval(-60) + state.applySetupStates([ + .codex: ProviderSetupState(enabled: true, credential: .expired(source: source, at: expiry)) + ]) + #expect(state.state(for: .codex).credentialState == .expired(expiry)) + + state.setStatusLadder([]) + #expect(state.statusLadder == [.empty]) +} + +@Test @MainActor func coverageClosureAppStateUsesSetupAndProviderRecoveryFallbacks() throws { + let source = ProviderID.codex.credentialSource("codex.file") + let custom = ProviderRecoveryIssue( + kind: .accountUnsupported, + title: "Unsupported account", + detail: "Use a supported account.", + action: .contactAdministrator) + let customState = AppState() + customState.applySetupStates([ + .codex: ProviderSetupState( + enabled: true, + credential: .valid(source: source, expiresAt: nil), + issue: custom) + ]) + customState.update(.codex) { $0.availability = .authenticationRequired } + #expect(customState.state(for: .codex).recoveryIssue == custom) + + let fallbackState = AppState() + fallbackState.applySetupStates([ + .codex: ProviderSetupState(enabled: true, credential: .valid(source: source, expiresAt: nil)) + ]) + fallbackState.update(.codex) { $0.availability = .authenticationRequired } + #expect(fallbackState.state(for: .codex).recoveryIssue == ProviderID.codex.setup.missingCredentialIssue) +} + +@Test @MainActor func coverageClosureCoordinatorLogsEveryDetailedFailureOutcome() async throws { + let cases: [(ProviderFetchResult, String)] = [ + ( + ProviderFetchResult(outcome: .partial(DemoData.snapshot(.codex, now: fixedNow), "partial")), + "outcome=partial" + ), + (ProviderFetchResult(outcome: .notAuthenticated("expired")), "outcome=authentication-required"), + (ProviderFetchResult(outcome: .networkUnavailable("offline")), "outcome=network-unavailable"), + (ProviderFetchResult(outcome: .failed("failed")), "outcome=failed"), + ] + + for (result, expected) in cases { + let log = makeLog() + log.debugEnabled = true + let settings = coverageClosureSettings() + settings.setProvider(.codex, enabled: true) + let coordinator = try coverageClosureCoordinator( + provider: ScriptedProvider(id: .codex, results: [result]), settings: settings, log: log) + + await coordinator.refresh(RefreshRequest(reason: .export, usage: .force, analytics: .skip)) + + #expect(log.text.contains("trigger=export")) + #expect(log.text.contains(expected)) + } +} + +@Test @MainActor func coverageClosureCoordinatorRestoresOnlyMissingSnapshotsAndReportsCacheFailure() async throws { + let root = temporaryDirectory() + let cached = DemoData.snapshot(.codex, now: fixedNow.addingTimeInterval(600)) + let cache = SnapshotCache(url: root.appendingPathComponent("snapshot.json")) + try cache.store([.codex: cached]) + let state = AppState() + let current = DemoData.snapshot(.codex, now: fixedNow) + state.update(.codex) { + $0.snapshot = current + $0.availability = .current + } + let settings = coverageClosureSettings() + settings.setProvider(.codex, enabled: true) + let coordinator = RefreshCoordinator( + registry: ProviderRegistry([ScriptedProvider(id: .codex, results: [.init(outcome: .failed("unused"))])]), + settings: settings, + state: state, + history: try UsageHistoryStore(url: nil), + log: makeLog(), + clock: testClock, + cache: cache + ) { _ in } + await coordinator.restoreCachedSnapshots() + #expect(state.state(for: .codex).snapshot?.fetchedAt == current.fetchedAt) + + let malformed = SnapshotCache(url: root.appendingPathComponent("malformed.json")) + try Data("{".utf8).write(to: malformed.url!) + let log = makeLog() + let broken = RefreshCoordinator( + registry: ProviderRegistry([]), + settings: coverageClosureSettings(), + state: AppState(), + history: try UsageHistoryStore(url: nil), + log: log, + clock: testClock, + cache: malformed + ) { _ in } + await broken.restoreCachedSnapshots() + #expect(log.text.contains("snapshot cache load failed")) +} + +@Test @MainActor func coverageClosureCoordinatorPreservesRateLimitStrikesAcrossRegistryReplacement() async throws { + let first = ScriptedProvider( + id: .codex, + results: [ProviderFetchResult(outcome: .rateLimited("busy", retryAfter: 60))]) + let settings = coverageClosureSettings() + settings.setProvider(.codex, enabled: true) + let state = AppState() + let coordinator = RefreshCoordinator( + registry: ProviderRegistry([first]), + settings: settings, + state: state, + history: try UsageHistoryStore(url: nil), + log: makeLog(), + clock: testClock + ) { _ in } + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force, providers: [.codex])) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force, providers: [.codex])) + #expect(first.callCount == 1) + + let second = ScriptedProvider( + id: .codex, + results: [ProviderFetchResult(outcome: .rateLimited("busy", retryAfter: 60))]) + coordinator.replaceRegistry(ProviderRegistry([second])) + state.update(.codex) { + $0.availability = .current + $0.retryNotBefore = nil + } + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force, providers: [.codex])) + + #expect(second.callCount == 1) + #expect(coordinator.nextAttempt(for: .codex) == fixedNow.addingTimeInterval(240)) +} + +@Test @MainActor func coverageClosureCoordinatorCanSkipWidgetAndAdaptiveWidthPublication() throws { + let settings = coverageClosureSettings() + settings.adaptiveWidth = false + let state = AppState() + let coordinator = RefreshCoordinator( + registry: ProviderRegistry([]), + settings: settings, + state: state, + history: try UsageHistoryStore(url: nil), + log: makeLog(), + clock: testClock + ) { _ in } + var publications = 0 + coordinator.widgetSink = { _ in publications += 1 } + let initialPublications = publications + + coordinator.rebuildStatus(now: fixedNow, publishWidget: false) + + #expect(state.statusLadder.count == 1) + #expect(publications == initialPublications) +} + +@Test @MainActor func coverageClosureCoordinatorLoopDoesNotRetainItsOwner() async throws { + let provider = ScriptedProvider( + id: .codex, + results: [ProviderFetchResult(outcome: .success(DemoData.snapshot(.codex, now: fixedNow)))]) + let settings = coverageClosureSettings() + settings.setProvider(.codex, enabled: true) + var coordinator: RefreshCoordinator? = try coverageClosureCoordinator(provider: provider, settings: settings) + weak let released = coordinator + coordinator?.start() + coordinator = nil + + await Task.yield() + + #expect(released == nil) + #expect(provider.callCount == 0) +} + +@Test func coverageClosureSnapshotPersistenceCachesLoadsAndCoalescesWidgets() async throws { + let root = temporaryDirectory() + let cache = SnapshotCache(url: root.appendingPathComponent("snapshot.json")) + let failures = CoverageClosureFailureRecorder() + try Data("{".utf8).write(to: cache.url!) + let persistence = SnapshotPersistence( + cache: cache, + widgetStore: WidgetSnapshotStore(url: root.appendingPathComponent("widget.json")) + ) { await failures.record($0.message) } + + #expect(await persistence.loadSnapshots().isEmpty) + #expect(await persistence.loadSnapshots().isEmpty) + await withTaskGroup(of: Void.self) { group in + for offset in 0..<100 { + group.addTask { + await persistence.submitWidget( + WidgetSnapshot( + rows: [], attention: offset.isMultiple(of: 2), + updatedAt: fixedNow.addingTimeInterval(Double(offset)))) + } + } + } + await persistence.flush() + + #expect(await failures.messages.first?.hasPrefix("snapshot cache load failed:") == true) + let workload = await persistence.workload + #expect(workload.cacheLoads == 1) + #expect(workload.coalescedWidgetSubmissions > 0) +} + +@Test func coverageClosureStatusLabelsAndTagsHandleEverySemanticFallback() { + let labels: [(ProviderID, QuotaWindow, String)] = [ + (.claude, coverageClosureWindow(id: "opus", label: "Opus"), "OP"), + (.claude, coverageClosureWindow(id: "haiku", label: "Haiku"), "HA"), + (.codex, coverageClosureWindow(id: "flash", label: "Flash"), "FLA"), + (.copilot, coverageClosureWindow(id: "completion", label: "Completion"), "GHX"), + (.copilot, coverageClosureWindow(id: "chat", label: "Chat"), "GHC"), + ] + for (provider, window, expected) in labels { + #expect(StatusItemBuilder.defaultShortLabel(provider: provider, window: window) == expected) + } + + #expect(StatusTemplate.windowTag(coverageClosureWindow(id: "scope", label: "Scope", scope: "")) == "") + #expect(StatusTemplate.windowTag(coverageClosureWindow(id: "", label: "Empty")) == "") +} + +@Test func coverageClosureUsagePresentationExplainsUnselectedAndInitiallyRefreshingRows() throws { + let window = coverageClosureWindow(id: "session", label: "Session") + let row = WindowRow( + key: WindowKey(provider: .codex, windowID: window.id), + window: window, + pace: PaceEstimate(status: .unknown, expectedPercent: nil, ratio: nil, projectedExhaustion: nil), + countdown: "--", + resetClock: "--", + isSelected: false) + #expect(row.accessibilityValue(at: fixedNow).contains("not shown in the menu bar")) + + let refreshing = UsagePresenter.card( + provider: .codex, + state: ProviderState(availability: .loading, isRefreshing: true), + samples: [:], + now: fixedNow) + #expect(refreshing.statusHelp == "Fetching the first values.") + + let snapshot = ProviderSnapshot(provider: .codex, windows: [window], fetchedAt: fixedNow) + let card = UsagePresenter.card( + provider: .codex, + state: ProviderState(snapshot: snapshot, availability: .current), + samples: [:], + now: fixedNow) + #expect(try #require(card.rows.first).detail == "window") +} + +@MainActor +private func coverageClosureCoordinator( + provider: ScriptedProvider, + settings: Settings, + log: LogBuffer = makeLog() +) throws -> RefreshCoordinator { + let state = AppState() + state.update(provider.id) { $0.credentialState = .valid(expiresAt: nil) } + return RefreshCoordinator( + registry: ProviderRegistry([provider]), + settings: settings, + state: state, + history: try UsageHistoryStore(url: nil), + log: log, + clock: testClock + ) { _ in } +} + +@MainActor +private func coverageClosureSettings() -> Settings { + Settings(defaults: UserDefaults(suiteName: "core-coverage-closure-\(UUID().uuidString)")!) +} + +private func coverageClosureWindow( + id: String, + label: String, + scope: String? = nil +) -> QuotaWindow { + QuotaWindow(id: id, label: label, group: .other, usedPercent: 25, resetsAt: nil, scope: scope) +} + +private actor CoverageClosureFailureRecorder { + private(set) var messages: [String] = [] + + func record(_ message: String) { + messages.append(message) + } +} diff --git a/Tests/TokenMenuBarCoreTests/CorrectedCoverageBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/CorrectedCoverageBehaviorTests.swift new file mode 100644 index 0000000..3f40c12 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CorrectedCoverageBehaviorTests.swift @@ -0,0 +1,249 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func correctedCoverageClaudeIgnoresNonStringScopes() throws { + let credentials = try #require( + ClaudeOAuthCredentials( + document: .object([ + "claudeAiOauth": .object([ + "accessToken": .string("token"), + "scopes": .array([.string("user:profile"), .number(1)]), + ]) + ]))) + + #expect(credentials.scopes == ["user:profile"]) +} + +@Test func correctedCoverageCodexRefreshesAnAPIKeyDocument() throws { + let auth = try #require(CodexAuth(document: .object(["OPENAI_API_KEY": .string("old")]))) + + let refreshed = auth.refreshed(accessToken: "new", refreshToken: nil, idToken: nil, now: fixedNow) + + #expect(refreshed.accessToken == "new") + #expect(refreshed.apiKey == "old") + #expect(refreshed.document["tokens"]?["access_token"] == .string("new")) +} + +@Test func correctedCoverageCopilotCLIReadsAlternateAccountShapes() throws { + let directory = temporaryDirectory() + let url = directory.appendingPathComponent("config.json") + try Data( + #"{"loggedInUsers":{"a.example":{"username":"ada","oauthToken":"first"},"b.example":{"login":"bob","oauth_token":"second"},"c.example":"carol","d.example":{}}}"# + .utf8 + ).write(to: url) + let store = FileCopilotCLIAuthStore(url: url) + + #expect(try store.load() == CopilotAuth(token: "first", user: "ada", host: "a.example")) + #expect( + store.keychainAccounts() == [ + "https://a.example:ada", "https://b.example:bob", "https://c.example:carol", "d.example", + ]) + + try Data(#"{"loggedInUsers":1}"#.utf8).write(to: url) + #expect(try store.load() == nil) + #expect(store.keychainAccounts().isEmpty) +} + +@Test func correctedCoverageCopilotKeychainReadsAlternateJSONFields() throws { + let parsedAccessToken = try KeychainCopilotAuthStore.parse( + Data(#"{"access_token":"access","login":"ada"}"#.utf8), account: nil) + let accessToken = try #require(parsedAccessToken) + #expect(accessToken == CopilotAuth(token: "access", user: "ada")) + + let parsedOAuthToken = try KeychainCopilotAuthStore.parse( + Data(#"{"oauth_token":"oauth"}"#.utf8), account: "https://ghe.example:bob") + let oauthToken = try #require(parsedOAuthToken) + #expect(oauthToken == CopilotAuth(token: "oauth", user: "bob", host: "ghe.example")) +} + +@Test func correctedCoverageCursorStateKeepsTheLastDuplicateValue() throws { + let url = temporaryDirectory().appendingPathComponent("state.vscdb") + let database = try SQLiteDatabase(path: url.path) + try database.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)") + try database.execute( + "INSERT INTO ItemTable (key, value) VALUES (?, ?), (?, ?), (?, ?)", + [ + .text("cursorAuth/accessToken"), .text("first"), + .text("cursorAuth/accessToken"), .text("second"), + .text("cursorAuth/cachedEmail"), .text("you@example.com"), + ]) + + let loaded = try CursorStateStore(url: url).load() + let auth = try #require(loaded) + + #expect(auth.accessToken == "second") + #expect(auth.email == "you@example.com") +} + +@Test func correctedCoverageAPIClientEncodesAnEmptyFormAndOverridesItsContentType() async throws { + let transport = StubTransport() + transport.on(path: "/form", .text("ok")) + let client = APIClient(transport: transport, log: makeLog()) + + let data = try await client.post( + URL(string: "https://example.com/form")!, form: [:], headers: ["Content-Type": "text/plain"], + operation: "form") + + #expect(data == Data("ok".utf8)) + #expect(transport.requests[0].httpBody == Data()) + #expect(transport.requests[0].value(forHTTPHeaderField: "Content-Type") == "application/x-www-form-urlencoded") +} + +@Test func correctedCoverageAPIClientRejectsANonHTTPResponse() async { + let transport = StubTransport() + let url = URL(string: "https://example.com/raw")! + transport.on( + path: "/raw", data: Data("body".utf8), + response: URLResponse(url: url, mimeType: "text/plain", expectedContentLength: 4, textEncodingName: nil)) + let client = APIClient(transport: transport, log: makeLog()) + + await #expect(throws: APIError.http(status: 0, body: "body", retryAfter: nil)) { + try await client.get(url, headers: [:], operation: "raw") + } +} + +@Test func correctedCoverageDiscoveryTreatsMissingSnapshotResourcesAsEmpty() { + let discovery = ProviderDiscoverySnapshot( + providerIDs: [.claude], credentials: [.claude: .unchecked], resources: [:]) + + #expect( + !discovery.differs( + from: [.claude: ProviderState(credentialHealth: .unchecked, resourceAccess: [])], providerIDs: [.claude])) +} + +@Test func correctedCoverageDiscoveryTreatsMissingProviderStateResourcesAsEmpty() { + let discovery = ProviderDiscoverySnapshot(providerIDs: [.claude], credentials: [:], resources: [.claude: []]) + + #expect(!discovery.differs(from: [:], providerIDs: [.claude])) +} + +@Test func correctedCoverageCustomSetupWithoutACommandRefreshesTheProvider() { + let metadata = ProviderSetupMetadata( + provider: .gemini, signInTitle: "Sign in", signInDetail: "Authenticate", signInCommand: nil, + credentialSources: []) + + #expect(metadata.missingCredentialIssue.action == .refreshProvider(.gemini)) +} + +@Test func correctedCoverageCopilotUsesTheGeneralResetAndIgnoresInvalidCredits() async throws { + let transport = StubTransport() + transport.on( + path: "/copilot_internal/user", + .text( + #"{"quota_reset_date":"2026-09-01","quota_snapshots":{"chat":{"percent_remaining":50,"credits_used":"invalid"}},"limited_user_quotas":{"completions":5},"monthly_quotas":{"completions":10},"token_based_billing":true}"# + )) + let provider = CopilotProvider( + auth: MemoryCopilotStore(CopilotAuth(token: "token")), + client: APIClient(transport: transport, log: makeLog(), clock: testClock), log: makeLog()) + + let snapshot = try #require(await provider.fetch(now: fixedNow, options: FetchOptions()).outcome.snapshot) + + #expect(snapshot.windows.map(\.id) == ["chat", "free:completions"]) + #expect(snapshot.windows.map(\.resetsAt) == [DayStamp.date("2026-09-01"), DayStamp.date("2026-09-01")]) + #expect(snapshot.notices.map(\.text) == ["Token-based billing: 0 credits used this cycle."]) +} + +@Test(arguments: [#"{"individualUsage":{"onDemand":{}}}"#, #"{"individualUsage":{"onDemand":{"remaining":0}}}"#]) +func correctedCoverageCursorTreatsMissingSpendLimitsAsNotReached(body: String) async throws { + let transport = StubTransport() + transport.on(path: "/api/usage-summary", .text(body)) + transport.on(path: "/api/auth/me", .text("{}")) + let provider = CursorProvider( + auth: MemoryCursorStore(CursorAuth(accessToken: "token")), + client: APIClient(transport: transport, log: makeLog(), clock: testClock), log: makeLog()) + + let snapshot = try #require(await provider.fetch(now: fixedNow, options: FetchOptions()).outcome.snapshot) + + #expect(snapshot.spend?.enabled == true) + #expect(snapshot.spend?.limit == nil) + #expect(snapshot.spend?.limitReached == false) +} + +@Test func correctedCoverageGeminiUsesTheAlternateProjectIDAndAllowsEmptyQuota() async throws { + let (provider, transport, _) = correctedCoverageGeminiProvider( + GeminiAuth(accessToken: "token", expiresAt: nil), allowRefresh: false) + transport.on( + path: ":loadCodeAssist", + .text(#"{"currentTier":{"id":"free-tier"},"cloudaicompanionProject":{"projectId":"projects/fallback"}}"#)) + transport.on(path: ":retrieveUserQuota", .text("{}")) + + let snapshot = try #require(await provider.fetch(now: fixedNow, options: FetchOptions()).outcome.snapshot) + + #expect(snapshot.windows.isEmpty) + #expect(snapshot.identity?.planName == "Free") + let body = try #require(transport.requests(matching: ":retrieveUserQuota")[0].httpBody) + #expect(try JSONDecoder().decode(JSONValue.self, from: body)["project"] == .string("projects/fallback")) +} + +@Test func correctedCoverageGeminiExplainsAnUnsupportedClientWithoutAReasonMessage() async { + let (provider, transport, _) = correctedCoverageGeminiProvider( + GeminiAuth(accessToken: "token", expiresAt: nil), allowRefresh: false) + transport.on( + path: ":loadCodeAssist", + .text(#"{"ineligibleTiers":[{"reasonCode":"UNSUPPORTED_CLIENT"}]}"#)) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .notAuthenticated(GeminiAPI.unsupportedClientMessage)) + #expect(result.recoveryIssue?.kind == .accountUnsupported) +} + +@Test(arguments: [#"{"error":"invalid_grant"}"#, #"{}"#]) +func correctedCoverageGeminiRejectsRefreshResponsesWithoutAnAccessToken(body: String) async { + let expired = GeminiAuth( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let (provider, transport, _) = correctedCoverageGeminiProvider(expired, allowRefresh: true) + transport.on(path: "/token", .text(body)) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .notAuthenticated("Gemini token refresh failed: HTTP 401")) + #expect(transport.requests(matching: "/token").count == 1) +} + +@Test func correctedCoverageGeminiDefaultsTheRefreshedTokenLifetime() async throws { + let expired = GeminiAuth( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let (provider, transport, store) = correctedCoverageGeminiProvider(expired, allowRefresh: true) + transport.on(path: "/token", .text(#"{"access_token":"fresh"}"#)) + transport.on(path: ":loadCodeAssist", .text(#"{"currentTier":{"id":"free-tier"}}"#)) + transport.on(path: ":retrieveUserQuota", .text("{}")) + + _ = try #require(await provider.fetch(now: fixedNow, options: FetchOptions()).outcome.snapshot) + + #expect(store.saved.first?.expiresAt == fixedNow.addingTimeInterval(3600)) +} + +@Test func correctedCoverageGeminiRetriesCredentialPersistenceWithoutRefreshingAgain() async throws { + let expired = GeminiAuth( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let (provider, transport, store) = correctedCoverageGeminiProvider(expired, allowRefresh: true) + store.saveError = TestError() + transport.on(path: "/token", .text(#"{"access_token":"fresh","expires_in":1800}"#)) + transport.on(path: ":loadCodeAssist", .text(#"{"currentTier":{"id":"free-tier"}}"#)) + transport.on(path: ":retrieveUserQuota", .text("{}")) + + let first = await provider.fetch(now: fixedNow, options: FetchOptions()) + store.saveError = nil + let second = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + + #expect(first.recoveryIssue?.kind == .credentialPersistence) + #expect(second.recoveryIssue == nil) + #expect(second.outcome.snapshot != nil) + #expect(store.saved.first?.accessToken == "fresh") + #expect(transport.requests(matching: "/token").count == 1) +} + +private func correctedCoverageGeminiProvider( + _ auth: GeminiAuth?, allowRefresh: Bool +) -> (GeminiProvider, StubTransport, MemoryGeminiStore) { + let transport = StubTransport() + let store = MemoryGeminiStore(auth) + let provider = GeminiProvider( + auth: store, client: APIClient(transport: transport, log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { allowRefresh }, + oauthClient: { GeminiOAuthClient(id: "client", secret: "secret") }) + return (provider, transport, store) +} diff --git a/Tests/TokenMenuBarCoreTests/CoverageGateSettingsPresentationBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/CoverageGateSettingsPresentationBehaviorTests.swift new file mode 100644 index 0000000..6812443 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CoverageGateSettingsPresentationBehaviorTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func coverageGateLastUsageAdvancesWhenThePercentageRisesBeforeReset() { + let key = WindowKey(provider: .codex, windowID: "weekly") + let reset = Date(timeIntervalSince1970: 1_800_000_000) + let first = UsageSample( + timestamp: reset.addingTimeInterval(-300), key: key, usedPercent: 10, resetsAt: reset) + let second = UsageSample( + timestamp: reset.addingTimeInterval(-200), key: key, usedPercent: 20, resetsAt: reset) + + #expect(SettingsModelPresentation.lastUsageDates([first, second])[key] == second.timestamp) +} + +@Test func coverageGateProviderServiceReportsEveryFailureDetail() { + let retry = Date(timeIntervalSince1970: 1_800_000_000) + + #expect(SettingsProviderPresentation.service(.checking) == "Checking service") + #expect(SettingsProviderPresentation.service(.offline(detail: "No route")) == "Offline · No route") + #expect(SettingsProviderPresentation.service(.unavailable(detail: "Maintenance")) == "Unavailable · Maintenance") + #expect( + SettingsProviderPresentation.service(.rateLimited(retryAt: retry, detail: "Slow down")) + == "Rate limited · retry \(retry.formatted(date: .abbreviated, time: .shortened)) · Slow down") + #expect( + SettingsProviderPresentation.service(.rateLimited(retryAt: nil, detail: "Slow down")) + == "Rate limited · Slow down") +} diff --git a/Tests/TokenMenuBarCoreTests/CoverageRefreshTests.swift b/Tests/TokenMenuBarCoreTests/CoverageRefreshTests.swift new file mode 100644 index 0000000..07974b0 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CoverageRefreshTests.swift @@ -0,0 +1,164 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func geminiRefreshReportsTokenEndpointFailures() async { + let expired = GeminiAuth( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-10)) + let transport = StubTransport() + transport.on(path: "/token", .text("unavailable", status: 503)) + let provider = GeminiProvider( + auth: MemoryGeminiStore(expired), + client: APIClient(transport: transport, log: makeLog(), clock: testClock), + log: makeLog(), + allowRefresh: { true }, + oauthClient: { GeminiOAuthClient(id: "client", secret: "secret") }) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .notAuthenticated("Gemini token refresh failed: HTTP 503")) + #expect(transport.requests(matching: "/token").count == 1) +} + +@Test @MainActor func appStateRestoresUnreadableCredentialHealthFromSetup() { + let state = AppState() + let source = ProviderID.codex.credentialSource("codex.file") + state.applySetupStates([ + .codex: ProviderSetupState( + enabled: true, + credential: .unreadable(source: source, detail: "auth.json is not JSON")) + ]) + + state.update(.codex) { + $0.availability = .authenticationRequired + $0.credentialState = .missing("Cannot read Codex credentials") + $0.credentialHealth = .unchecked + } + + let provider = state.state(for: .codex) + #expect(provider.credentialHealth == .unreadable(source: source, detail: "auth.json is not JSON")) + #expect(provider.recoveryIssue?.kind == .credentialUnreadable) +} + +@Test @MainActor func appStateBuildsNeededResourceRecovery() { + let state = AppState() + let resource = ProviderID.codex.sandboxResources[0] + state.applySetupStates([ + .codex: ProviderSetupState( + enabled: true, + credential: .missing(expected: ProviderID.codex.setup.credentialSources), + resources: [ResourceAccessState(resource: resource, health: .needed)]) + ]) + + state.update(.codex) { $0.availability = .authenticationRequired } + + let issue = state.state(for: .codex).recoveryIssue + #expect(issue?.kind == .resourceAccess) + #expect(issue?.title == "File access needed") + #expect(issue?.detail == "Grant access to ~/.codex so Codex data can be read.") + #expect(issue?.action == .grantAccess(resource)) +} + +@Test @MainActor func coordinatorJoinsSubsetScopeCoveredByActiveRefresh() async throws { + let gate = TestGate() + let provider = ScriptedProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .success(DemoData.snapshot(.claude, now: fixedNow)))], + gate: gate) + let settings = coverageSettings() + settings.setProvider(.claude, enabled: true) + let coordinator = RefreshCoordinator( + registry: ProviderRegistry([provider]), + settings: settings, + state: AppState(), + history: try UsageHistoryStore(url: nil), + log: makeLog(), + clock: testClock + ) { _ in } + let active = Task { + await coordinator.refresh( + RefreshRequest(reason: .userInitiated, usage: .force, providers: [.claude, .codex])) + } + while provider.callCount == 0 { await Task.yield() } + + async let covered: Void = coordinator.refresh( + RefreshRequest(reason: .userInitiated, usage: .force, providers: [.claude])) + await Task.yield() + gate.open() + await covered + await active.value + + #expect(provider.callCount == 1) +} + +@Test @MainActor func coordinatorDisablesUndiscoveredFailedProbe() async throws { + let issue = ProviderRecoveryIssue( + kind: .credentialMissing, + title: "Claude sign-in required", + detail: "Sign in to Claude.", + action: .copyCommand("claude")) + let provider = ScriptedProvider( + id: .claude, + results: [ + ProviderFetchResult( + outcome: .failed("request failed"), + warnings: ["stale warning"], + recoveryIssue: issue, + credentialStatus: ProviderCredentialStatus( + state: .missing("not signed in"), + health: .missing(expected: ProviderID.claude.setup.credentialSources))) + ]) + let state = AppState() + let coordinator = RefreshCoordinator( + registry: ProviderRegistry([provider]), + settings: coverageSettings(), + state: state, + history: try UsageHistoryStore(url: nil), + log: makeLog(), + clock: testClock + ) { _ in } + + await coordinator.refresh( + RefreshRequest(reason: .userInitiated, usage: .force, providers: [.claude])) + + let result = state.state(for: .claude) + #expect(result.availability == .disabled) + #expect(result.lastError == nil) + #expect(result.warnings.isEmpty) + #expect(result.recoveryIssue == nil) +} + +@Test func snapshotPersistenceDefaultCallbacksHandleFailureAndReload() async throws { + let root = temporaryDirectory() + let cache = SnapshotCache(url: root.appendingPathComponent("malformed.json")) + try Data("{".utf8).write(to: cache.url!) + let widgetStore = WidgetSnapshotStore(url: root.appendingPathComponent("widget.json")) + let persistence = SnapshotPersistence(cache: cache, widgetStore: widgetStore) + + #expect(await persistence.loadSnapshots().isEmpty) + await persistence.submitWidget(.placeholder) + await persistence.flush() + + #expect(widgetStore.read() != nil) + let workload = await persistence.workload + #expect(workload.cacheLoads == 1) + #expect(workload.widgetWrites == 1) + #expect(workload.widgetReloads == 1) +} + +@Test func snapshotPersistenceIgnoresWidgetsWithoutStore() async { + let persistence = SnapshotPersistence(cache: SnapshotCache(url: nil)) + + await persistence.submitWidget(.placeholder) + await persistence.flush() + + let workload = await persistence.workload + #expect(workload.widgetSubmissions == 0) + #expect(workload.widgetWrites == 0) +} + +@MainActor +private func coverageSettings() -> Settings { + Settings(defaults: UserDefaults(suiteName: "coverage-refresh-\(UUID().uuidString)")!) +} diff --git a/Tests/TokenMenuBarCoreTests/CredentialRefreshRaceTests.swift b/Tests/TokenMenuBarCoreTests/CredentialRefreshRaceTests.swift new file mode 100644 index 0000000..42e99cb --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CredentialRefreshRaceTests.swift @@ -0,0 +1,327 @@ +import Foundation +import Security +import Testing + +@testable import TokenMenuBarCore + +@Test func claudeRefreshUsesCredentialsChangedByTheCLI() async { + let expired = ClaudeOAuthCredentials( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let current = ClaudeOAuthCredentials(accessToken: "cli", refreshToken: "new", expiresAt: nil) + let store = SwitchingCredentialStore(expired: expired, current: current) + let transport = StubTransport() + transport.on(path: "/v1/oauth/token", .text(#"{"access_token":"app","expires_in":3600}"#)) + transport.on(path: "/api/oauth/usage", .json("claude_usage")) + transport.on(path: "/api/oauth/profile", .json("claude_profile")) + let result = await claudeProvider(store, transport: transport, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(store.saved.isEmpty) + #expect( + transport.requests(matching: "/api/oauth/usage")[0].value(forHTTPHeaderField: "Authorization") == "Bearer cli") +} + +@Test func codexRefreshUsesCredentialsChangedByTheCLI() async { + let expired = CodexAuth( + accessToken: makeJWT(.object(["exp": .number(fixedNow.timeIntervalSince1970 - 1)])), refreshToken: "refresh") + let current = CodexAuth(accessToken: "cli", refreshToken: "new", accountID: "acct") + let store = SwitchingCredentialStore(expired: expired, current: current) + let transport = StubTransport() + transport.on(path: "/oauth/token", .text(#"{"access_token":"app"}"#)) + transport.on(path: "/wham/usage", .json("codex_usage")) + let result = await codexProvider(store, transport: transport, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(store.saved.isEmpty) + #expect( + transport.requests(matching: "/wham/usage")[0].value(forHTTPHeaderField: "Authorization") == "Bearer cli") +} + +@Test func geminiRefreshUsesCredentialsChangedByTheCLI() async { + let expired = GeminiAuth( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let current = GeminiAuth(accessToken: "cli", refreshToken: "new", expiresAt: nil) + let store = SwitchingCredentialStore(expired: expired, current: current) + let transport = StubTransport() + transport.on(path: "/token", .text(#"{"access_token":"app","expires_in":3600}"#)) + transport.on(path: ":loadCodeAssist", .json("gemini_load_code_assist")) + transport.on(path: ":retrieveUserQuota", .json("gemini_quota")) + let provider = GeminiProvider( + auth: store, client: APIClient(transport: transport, log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { true }, oauthClient: { GeminiOAuthClient(id: "client", secret: "secret") }) + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(store.saved.isEmpty) + #expect( + transport.requests(matching: ":retrieveUserQuota")[0].value(forHTTPHeaderField: "Authorization") == "Bearer cli") +} + +@Test func claudeRefreshStopsWhenCredentialsWereRemoved() async { + let expired = ClaudeOAuthCredentials( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let store = SwitchingCredentialStore(expired: expired, current: nil) + let transport = StubTransport() + transport.on(path: "/v1/oauth/token", .text(#"{"access_token":"app","expires_in":3600}"#)) + let result = await claudeProvider(store, transport: transport, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + guard case .notAuthenticated(let reason) = result.outcome else { + Issue.record("expected authentication cancellation") + return + } + #expect(reason.contains("HTTP 401")) + #expect(transport.requests(matching: "/api/oauth/usage").isEmpty) +} + +@Test func codexRefreshStopsWhenCredentialsWereRemoved() async { + let expired = CodexAuth( + accessToken: makeJWT(.object(["exp": .number(fixedNow.timeIntervalSince1970 - 1)])), refreshToken: "refresh") + let store = SwitchingCredentialStore(expired: expired, current: nil) + let transport = StubTransport() + transport.on(path: "/oauth/token", .text(#"{"access_token":"app"}"#)) + let result = await codexProvider(store, transport: transport, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + guard case .notAuthenticated(let reason) = result.outcome else { + Issue.record("expected authentication cancellation") + return + } + #expect(reason.contains("HTTP 401")) + #expect(transport.requests(matching: "/wham/usage").isEmpty) +} + +@Test func geminiRefreshStopsWhenCredentialsWereRemoved() async { + let expired = GeminiAuth( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let store = SwitchingCredentialStore(expired: expired, current: nil) + let transport = StubTransport() + transport.on(path: "/token", .text(#"{"access_token":"app","expires_in":3600}"#)) + let provider = GeminiProvider( + auth: store, client: APIClient(transport: transport, log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { true }, oauthClient: { GeminiOAuthClient(id: "client", secret: "secret") }) + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + guard case .notAuthenticated(let reason) = result.outcome else { + Issue.record("expected authentication cancellation") + return + } + #expect(reason.contains("HTTP 401")) + #expect(transport.requests(matching: ":retrieveUserQuota").isEmpty) +} + +@Test func claudeRetriesAFailedCredentialSaveWithoutRefreshingAgain() async { + let expired = ClaudeOAuthCredentials( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let store = RetryCredentialStore(expired) + let transport = StubTransport() + transport.on(path: "/v1/oauth/token", .text(#"{"access_token":"app","expires_in":3600}"#)) + transport.on(path: "/api/oauth/usage", .json("claude_usage")) + transport.on(path: "/api/oauth/profile", .json("claude_profile")) + let provider = claudeProvider(store, transport: transport, allowRefresh: true) + let first = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(first.recoveryIssue?.kind == .credentialPersistence) + let second = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(second.recoveryIssue == nil) + #expect(transport.requests(matching: "/v1/oauth/token").count == 1) + #expect(store.saved.count == 1) +} + +@Test func claudeKeepsARefreshedCredentialWhenTheSourceTemporarilyCannotBeRead() async { + let expired = ClaudeOAuthCredentials( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let store = RetryCredentialStore(expired) + let transport = claudePersistenceTransport() + let provider = claudeProvider(store, transport: transport, allowRefresh: true) + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + store.failReads(CredentialStoreError.keychain(errSecNotAvailable)) + + let result = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(result.recoveryIssue?.kind == .credentialPersistence) + #expect(await provider.credentialHealth(now: fixedNow).isUsable) +} + +@Test func claudeClearsPendingPersistenceWhenTheCLIAlreadyStoredTheRefresh() async { + let expired = ClaudeOAuthCredentials( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let refreshed = expired.refreshed(accessToken: "app", refreshToken: nil, expiresIn: 3600, now: fixedNow) + let store = RetryCredentialStore(expired) + let transport = claudePersistenceTransport() + let provider = claudeProvider(store, transport: transport, allowRefresh: true) + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + store.replace(refreshed) + + let result = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(result.recoveryIssue == nil) + #expect(store.saved.isEmpty) +} + +@Test func claudeUsesANewerCLICredentialInsteadOfRetryingPersistence() async { + let expired = ClaudeOAuthCredentials( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let current = ClaudeOAuthCredentials(accessToken: "cli", refreshToken: nil, expiresAt: nil) + let store = RetryCredentialStore(expired) + let transport = claudePersistenceTransport() + let provider = claudeProvider(store, transport: transport, allowRefresh: true) + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + store.replace(current) + + let result = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(result.recoveryIssue == nil) + #expect( + transport.requests(matching: "/api/oauth/usage").last?.value(forHTTPHeaderField: "Authorization") + == "Bearer cli") +} + +@Test func claudeUsesACredentialChangedDuringPersistenceRetry() async { + let expired = ClaudeOAuthCredentials( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let current = ClaudeOAuthCredentials(accessToken: "cli", refreshToken: nil, expiresAt: nil) + let store = RetryCredentialStore(expired) + let transport = claudePersistenceTransport() + let provider = claudeProvider(store, transport: transport, allowRefresh: true) + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + store.replace(current, onRead: 4) + + let result = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(result.recoveryIssue == nil) + #expect( + transport.requests(matching: "/api/oauth/usage").last?.value(forHTTPHeaderField: "Authorization") + == "Bearer cli") +} + +@Test func claudeRetainsPendingPersistenceAfterASecondWriteFailure() async { + let expired = ClaudeOAuthCredentials( + accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + let store = RetryCredentialStore(expired, failures: 2) + let transport = claudePersistenceTransport() + let provider = claudeProvider(store, transport: transport, allowRefresh: true) + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + + let result = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(result.recoveryIssue?.kind == .credentialPersistence) + #expect(store.saved.isEmpty) +} + +@Test func geminiUnsupportedAccountReturnsTypedRecovery() async { + let auth = GeminiAuth(accessToken: "token", expiresAt: nil) + let transport = StubTransport() + transport.on(path: ":loadCodeAssist", .json("gemini_unsupported")) + let provider = GeminiProvider( + auth: MemoryGeminiStore(auth), + client: APIClient(transport: transport, log: makeLog(), clock: testClock), + log: makeLog(), + allowRefresh: { false }) + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(result.recoveryIssue?.kind == .accountUnsupported) + #expect(result.recoveryIssue?.action == .copyCommand("gemini")) +} + +private final class SwitchingCredentialStore: @unchecked Sendable { + private let lock = NSLock() + private let expired: Value + private let current: Value? + private var reads = 0 + private(set) var saved: [Value] = [] + + init(expired: Value, current: Value?) { + self.expired = expired + self.current = current + } + + var description: String { "switching memory" } + + func read() -> Value? { + lock.withLock { + reads += 1 + return reads == 1 ? expired : current + } + } + + func write(_ value: Value) { + lock.withLock { saved.append(value) } + } +} + +extension SwitchingCredentialStore: ClaudeCredentialStore where Value == ClaudeOAuthCredentials { + func load() throws -> ClaudeOAuthCredentials? { read() } + func save(_ credentials: ClaudeOAuthCredentials) throws { write(credentials) } +} + +extension SwitchingCredentialStore: CodexAuthStore where Value == CodexAuth { + func load() throws -> CodexAuth? { read() } + func save(_ auth: CodexAuth) throws { write(auth) } +} + +extension SwitchingCredentialStore: GeminiAuthStore where Value == GeminiAuth { + func load() throws -> GeminiAuth? { read() } + func save(_ auth: GeminiAuth) throws { write(auth) } +} + +private final class RetryCredentialStore: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + private var failures: Int + private var loadError: (any Error)? + private var reads = 0 + private var replacement: (value: Value, read: Int)? + private(set) var saved: [Value] = [] + + init(_ stored: Value, failures: Int = 1) { + self.stored = stored + self.failures = failures + } + + var description: String { "retry memory" } + + func read() throws -> Value { + try lock.withLock { + if let loadError { throw loadError } + reads += 1 + if let replacement, reads == replacement.read { + stored = replacement.value + self.replacement = nil + } + return stored + } + } + + func failReads(_ error: any Error) { + lock.withLock { loadError = error } + } + + func replace(_ value: Value, onRead: Int? = nil) { + lock.withLock { + if let onRead { + replacement = (value, onRead) + } else { + stored = value + } + } + } + + func write(_ value: Value) throws { + try lock.withLock { + guard failures == 0 else { + failures -= 1 + throw CocoaError(.fileWriteUnknown) + } + stored = value + saved.append(value) + } + } +} + +extension RetryCredentialStore: ClaudeCredentialStore where Value == ClaudeOAuthCredentials { + func load() throws -> ClaudeOAuthCredentials? { try read() } + func save(_ credentials: ClaudeOAuthCredentials) throws { try write(credentials) } +} + +private func claudePersistenceTransport() -> StubTransport { + let transport = StubTransport() + transport.on(path: "/v1/oauth/token", .text(#"{"access_token":"app","expires_in":3600}"#)) + transport.on(path: "/api/oauth/usage", .json("claude_usage")) + transport.on(path: "/api/oauth/profile", .json("claude_profile")) + return transport +} diff --git a/Tests/TokenMenuBarCoreTests/CredentialsTests.swift b/Tests/TokenMenuBarCoreTests/CredentialsTests.swift new file mode 100644 index 0000000..3d3bb3f --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CredentialsTests.swift @@ -0,0 +1,398 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func claudeCredentialsParseKeychainDocument() { + let credentials = ClaudeOAuthCredentials(document: Fixtures.json("claude_keychain"))! + #expect(credentials.accessToken == "sk-ant-oat01-EXAMPLE") + #expect(credentials.refreshToken == "sk-ant-ort01-EXAMPLE") + #expect(credentials.expiresAt == Date(timeIntervalSince1970: 1_788_039_901.877)) + #expect(credentials.hasProfileScope) + #expect(credentials.subscriptionType == "max") + #expect(credentials.rateLimitTier == "default_claude_max_20x") + #expect(credentials.state(now: fixedNow) == .valid(expiresAt: credentials.expiresAt)) + #expect(credentials.state(now: fixedNow.addingTimeInterval(20000)) == .expired(credentials.expiresAt!)) +} + +@Test func claudeCredentialsRejectDocumentsWithoutToken() { + #expect(ClaudeOAuthCredentials(document: .object(["mcpOAuth": .object([:])])) == nil) + #expect(ClaudeOAuthCredentials(document: .object(["claudeAiOauth": .object(["scopes": .array([])])])) == nil) +} + +@Test func claudeCredentialsDefaultToNoScopesWhenTheDocumentOmitsThem() throws { + let document = JSONValue.object(["claudeAiOauth": .object(["accessToken": .string("token")])]) + let credentials = try #require(ClaudeOAuthCredentials(document: document)) + + #expect(credentials.scopes.isEmpty) + #expect(!credentials.hasProfileScope) +} + +@Test func claudeCredentialsRefreshPreservesUnknownKeys() { + let document = Fixtures.json("claude_keychain").merging("mcpOAuth", .object(["x": .number(1)])) + let credentials = ClaudeOAuthCredentials(document: document)! + let refreshed = credentials.refreshed(accessToken: "new", refreshToken: "newer", expiresIn: 3600, now: fixedNow) + #expect(refreshed.accessToken == "new") + #expect(refreshed.refreshToken == "newer") + #expect(refreshed.expiresAt == fixedNow.addingTimeInterval(3600)) + #expect(refreshed.document["mcpOAuth"] == .object(["x": .number(1)])) + #expect(refreshed.rateLimitTier == "default_claude_max_20x") + let keepRefresh = credentials.refreshed(accessToken: "n", refreshToken: nil, expiresIn: 1, now: fixedNow) + #expect(keepRefresh.refreshToken == "sk-ant-ort01-EXAMPLE") +} + +@Test func claudeCredentialsConvenienceInit() { + let credentials = ClaudeOAuthCredentials( + accessToken: "a", refreshToken: "r", expiresAt: fixedNow, subscriptionType: "pro", rateLimitTier: nil) + #expect( + credentials.document["claudeAiOauth"]?["expiresAt"]?.doubleValue + == (fixedNow.timeIntervalSince1970 * 1000).rounded()) + #expect(credentials.scopes == ["user:profile"]) + #expect(ClaudeOAuthCredentials(accessToken: "a", refreshToken: nil, expiresAt: nil).expiresAt == nil) +} + +@Test func claudeKeychainServiceNameHashesConfigDir() { + #expect(ClaudeOAuthCredentials.keychainService(configDir: nil) == "Claude Code-credentials") + #expect(ClaudeOAuthCredentials.keychainService(configDir: "") == "Claude Code-credentials") + let hashed = ClaudeOAuthCredentials.keychainService(configDir: "/Users/me/.claude-work") + #expect(hashed.hasPrefix("Claude Code-credentials-")) + #expect(hashed.count == "Claude Code-credentials-".count + 8) +} + +@Test func keychainParseRejectsNonJSON() throws { + #expect(throws: CredentialStoreError.malformed("Keychain item is not JSON")) { + try KeychainClaudeCredentialStore.parse(Data("nope".utf8)) + } + #expect( + try KeychainClaudeCredentialStore.parse(Fixtures.data("claude_keychain"))?.accessToken == "sk-ant-oat01-EXAMPLE") + #expect( + KeychainClaudeCredentialStore(account: "me", keychain: .empty).description + == "Keychain item Claude Code-credentials") +} + +@Test func keychainStoreReadsAndWritesInMemoryCredentials() throws { + let keychain = MemoryKeychain() + let store = KeychainClaudeCredentialStore(service: "claude-test", account: "tests", keychain: keychain.client) + #expect(try store.load() == nil) + let credentials = ClaudeOAuthCredentials(accessToken: "first", refreshToken: nil, expiresAt: nil) + try store.save(credentials) + #expect(try store.load()?.accessToken == "first") + try store.save(ClaudeOAuthCredentials(accessToken: "second", refreshToken: nil, expiresAt: nil)) + #expect(try store.load()?.accessToken == "second") +} + +@Test func keychainClientAddsUpdatesAndFindsAnAccountInMemory() throws { + let client = MemoryKeychain().client + + #expect(try client.load(service: "test", account: "account") == nil) + try client.save(Data("first".utf8), service: "test", account: "account") + #expect(try client.load(service: "test", account: "account")?.data == Data("first".utf8)) + #expect(try client.load(service: "test")?.account == "account") + try client.save(Data("second".utf8), service: "test", account: "account") + #expect(try client.load(service: "test", account: "account")?.data == Data("second".utf8)) +} + +@Test func fileClaudeStoreRoundTripsAndRejectsGarbage() throws { + let directory = temporaryDirectory() + let store = FileClaudeCredentialStore(url: directory.appendingPathComponent(".credentials.json")) + #expect(try store.load() == nil) + #expect(store.description == store.url.path) + try store.save(ClaudeOAuthCredentials(accessToken: "tok", refreshToken: nil, expiresAt: nil)) + #expect(try store.load()?.accessToken == "tok") + try Data("{".utf8).write(to: store.url) + #expect(throws: CredentialStoreError.malformed(".credentials.json is not JSON")) { try store.load() } +} + +@Test func chainedClaudeStorePrefersFirstHitAndSavesWhereFound() throws { + let directory = temporaryDirectory() + let empty = FileClaudeCredentialStore(url: directory.appendingPathComponent("empty.json")) + let filled = FileClaudeCredentialStore(url: directory.appendingPathComponent("filled.json")) + try filled.save(ClaudeOAuthCredentials(accessToken: "filled", refreshToken: nil, expiresAt: nil)) + let chain = ChainedClaudeCredentialStore([empty, filled]) + #expect(chain.description == "\(empty.url.path), \(filled.url.path)") + #expect(try chain.load()?.accessToken == "filled") + try chain.save(ClaudeOAuthCredentials(accessToken: "updated", refreshToken: nil, expiresAt: nil)) + #expect(try filled.load()?.accessToken == "updated") + #expect(try empty.load() == nil) + let broken = FileClaudeCredentialStore(url: directory.appendingPathComponent("broken.json")) + try Data("{".utf8).write(to: broken.url) + #expect(throws: CredentialReadFailure.self) { try ChainedClaudeCredentialStore([broken, empty]).load() } + #expect(try ChainedClaudeCredentialStore([broken, filled]).load()?.accessToken == "updated") + let onlyEmpty = ChainedClaudeCredentialStore([empty]) + try onlyEmpty.save(ClaudeOAuthCredentials(accessToken: "fresh", refreshToken: nil, expiresAt: nil)) + #expect(try empty.load()?.accessToken == "fresh") + #expect(try ChainedClaudeCredentialStore([]).load() == nil) + try ChainedClaudeCredentialStore([]).save(ClaudeOAuthCredentials(accessToken: "x", refreshToken: nil, expiresAt: nil)) +} + +@Test func claudeLocalAccountReadsOauthAccount() throws { + let directory = temporaryDirectory() + let url = directory.appendingPathComponent(".claude.json") + #expect(ClaudeLocalAccount.load(from: url) == nil) + try Data( + #""" + {"oauthAccount":{"emailAddress":"a@b.c","organizationName":"Org", + "organizationRateLimitTier":"default_claude_max_5x","hasExtraUsageEnabled":true}} + """# + .utf8 + ).write(to: url) + #expect( + ClaudeLocalAccount.load(from: url) + == ClaudeLocalAccount( + email: "a@b.c", organizationName: "Org", rateLimitTier: "default_claude_max_5x", hasExtraUsageEnabled: true)) + try Data(#"{"other":1}"#.utf8).write(to: url) + #expect(ClaudeLocalAccount.load(from: url) == nil) +} + +@Test func codexAuthParsesTokensAndClaims() { + let auth = CodexAuth(document: Fixtures.codexAuth())! + #expect(auth.accessToken == "ACCESS-EXAMPLE") + #expect(auth.refreshToken == "REFRESH-EXAMPLE") + #expect(auth.accountID == "00000000-0000-4000-8000-000000000000") + #expect(auth.email == "user@example.com") + #expect(auth.planType == "pro") + #expect(auth.subscriptionActiveUntil == ISODate.parse("2026-07-23T19:57:23+00:00")) + #expect(auth.lastRefresh == ISODate.parse("2026-08-28T19:59:48.413665Z")) + #expect(auth.apiKey == nil) + #expect(auth.state(now: fixedNow) == .valid(expiresAt: nil)) +} + +@Test func codexAuthFallsBackToAPIKeyAndClaimAccount() { + let claims = JWT.payload(CodexAuth(document: Fixtures.codexAuth())!.idToken!)! + let idToken = makeJWT(claims) + let auth = CodexAuth( + document: .object(["OPENAI_API_KEY": .string("sk-key"), "tokens": .object(["id_token": .string(idToken)])]))! + #expect(auth.accessToken == "sk-key") + #expect(auth.accountID == claims["https://api.openai.com/auth"]?["chatgpt_account_id"]?.stringValue) + #expect(CodexAuth(document: .object(["tokens": .object([:])])) == nil) + #expect(CodexAuth(document: .object(["OPENAI_API_KEY": .null])) == nil) +} + +@Test func codexAuthExpiryComesFromAccessTokenJWT() { + let auth = CodexAuth(accessToken: makeJWT(.object(["exp": .number(fixedNow.timeIntervalSince1970 + 60)]))) + #expect(auth.state(now: fixedNow) == .expired(Date(timeIntervalSince1970: fixedNow.timeIntervalSince1970 + 60))) + #expect(auth.claims == nil) + #expect(auth.email == nil) + #expect(auth.planType == nil) + #expect(auth.subscriptionActiveUntil == nil) +} + +@Test func codexAuthRefreshedPreservesUnknownKeysAndStampsTime() { + let document = Fixtures.codexAuth().merging("custom", .bool(true)) + let auth = CodexAuth(document: document)! + let refreshed = auth.refreshed(accessToken: "A2", refreshToken: nil, idToken: nil, now: fixedNow) + #expect(refreshed.accessToken == "A2") + #expect(refreshed.refreshToken == "REFRESH-EXAMPLE") + #expect(refreshed.idToken == auth.idToken) + #expect(refreshed.lastRefresh == Date(timeIntervalSince1970: fixedNow.timeIntervalSince1970.rounded(.down))) + #expect(refreshed.document["custom"] == .bool(true)) + let full = auth.refreshed(accessToken: "A3", refreshToken: "R3", idToken: "I3", now: fixedNow) + #expect(full.refreshToken == "R3") + #expect(full.idToken == "I3") +} + +@Test func codexAuthConvenienceInit() { + let auth = CodexAuth(accessToken: "a", refreshToken: "r", idToken: nil, accountID: "acct", lastRefresh: fixedNow) + #expect(auth.accountID == "acct") + #expect(auth.document["auth_mode"]?.stringValue == "chatgpt") + #expect(auth.lastRefresh == Date(timeIntervalSince1970: fixedNow.timeIntervalSince1970.rounded(.down))) +} + +@Test func fileCodexStoreRoundTripsAndDefaultsLocation() throws { + let store = FileCodexAuthStore(url: temporaryDirectory().appendingPathComponent("auth.json")) + #expect(try store.load() == nil) + #expect(store.description == store.url.path) + try store.save(CodexAuth(accessToken: "tok")) + #expect(try store.load()?.accessToken == "tok") + try Data("nope".utf8).write(to: store.url) + #expect(throws: CredentialStoreError.malformed("auth.json is not JSON")) { try store.load() } + let home = URL(fileURLWithPath: "/Users/me") + #expect(FileCodexAuthStore.defaultURL(environment: [:], home: home).path == "/Users/me/.codex/auth.json") + #expect( + FileCodexAuthStore.defaultURL(environment: ["CODEX_HOME": "/tmp/codex"], home: home).path == "/tmp/codex/auth.json") +} + +@Test func codexKeychainStoreRoundTripsInMemoryCredentials() throws { + #expect(KeychainCodexAuthStore(account: "public-tests", keychain: .empty).account == "public-tests") + let store = KeychainCodexAuthStore(account: "tests", keychain: MemoryKeychain().client) + + #expect(store.source == ProviderID.codex.credentialSource("codex.keyring")) + #expect(try store.load() == nil) + try store.save(CodexAuth(accessToken: "first")) + #expect(try store.load()?.accessToken == "first") + try store.save(CodexAuth(accessToken: "second")) + #expect(try store.load()?.accessToken == "second") +} + +@Test func codexChainSavesToTheExistingStoreOrFirstFallback() throws { + let directory = temporaryDirectory() + let empty = FileCodexAuthStore(url: directory.appendingPathComponent("empty.json")) + let filled = FileCodexAuthStore(url: directory.appendingPathComponent("filled.json")) + try filled.save(CodexAuth(accessToken: "existing")) + + try ChainedCodexAuthStore([empty, filled]).save(CodexAuth(accessToken: "updated")) + #expect(try empty.load() == nil) + #expect(try filled.load()?.accessToken == "updated") + + try ChainedCodexAuthStore([empty]).save(CodexAuth(accessToken: "fallback")) + #expect(try empty.load()?.accessToken == "fallback") + try ChainedCodexAuthStore([]).save(CodexAuth(accessToken: "ignored")) +} + +@Test func codexCredentialStorageLoadsReadableConfiguration() { + let url = URL(fileURLWithPath: "/configuration.toml") + #expect( + CodexCredentialStorageReader.load(from: url, read: { _ in "cli_auth_credentials_store = 'keyring'" }) + == .keyring) + #expect( + CodexCredentialStorageReader.load(from: url, read: { _ in throw CocoaError(.fileReadNoSuchFile) }) + == .automatic) +} + +@Test func concreteCredentialStoresIdentifyTheirSources() { + let directory = temporaryDirectory() + #expect(KeychainClaudeCredentialStore(account: "tests", keychain: .empty).source.id == "claude.keychain") + #expect(CursorStateStore(url: directory.appendingPathComponent("state.vscdb")).source.id == "cursor.app") + #expect(KeychainGeminiAuthStore(keychain: .empty).source.id == "gemini.keychain") + #expect(KeychainGeminiAuthStore(keychain: .empty).description == "Keychain item gemini-cli-oauth") + #expect(FileCopilotAuthStore(urls: []).source.id == "copilot.legacy-file") + #expect(EnvironmentCopilotAuthStore(environment: [:]).source.id == "copilot.environment") + #expect(KeychainCopilotAuthStore(keychain: .empty).source.id == "copilot.keychain") +} + +@Test func cursorCredentialHealthReportsAReadableSession() { + let store = MemoryCursorStore(CursorAuth(accessToken: "token")) + #expect(store.credentialHealth(now: fixedNow) == .valid(source: store.source, expiresAt: nil)) +} + +@Test func geminiKeychainFormatsFlatCredentialsAndRejectsMalformedData() throws { + let auth = GeminiAuth( + accessToken: "access", refreshToken: "refresh", idToken: "identity", expiresAt: fixedNow) + let document = KeychainGeminiAuthStore.document(for: auth, updatedAt: fixedNow) + #expect(document["serverName"] == .string("main-account")) + #expect(document["token"]?["accessToken"] == .string("access")) + #expect(document["token"]?["refreshToken"] == .string("refresh")) + #expect(document["token"]?["idToken"] == .string("identity")) + #expect(document["token"]?["expiresAt"] == .number(fixedNow.timeIntervalSince1970 * 1000)) + #expect(document["updatedAt"] == .number(fixedNow.timeIntervalSince1970 * 1000)) + #expect(throws: CredentialStoreError.malformed("Gemini Keychain item is not JSON")) { + try KeychainGeminiAuthStore.parse(Data("not-json".utf8)) + } +} + +@Test func geminiKeychainStoreRoundTripsInMemoryCredentials() throws { + let store = KeychainGeminiAuthStore(service: "gemini-test", keychain: MemoryKeychain().client) + + #expect(try store.load() == nil) + try store.save(GeminiAuth(accessToken: "first", refreshToken: "refresh")) + #expect(try store.load()?.accessToken == "first") + try store.save(GeminiAuth(accessToken: "second", refreshToken: "refresh")) + #expect(try store.load()?.accessToken == "second") +} + +@Test func geminiChainLoadsAndSavesThroughItsAvailableStore() throws { + let directory = temporaryDirectory() + let empty = FileGeminiAuthStore(url: directory.appendingPathComponent("empty.json")) + let filled = FileGeminiAuthStore(url: directory.appendingPathComponent("filled.json")) + try filled.save(GeminiAuth(accessToken: "existing")) + let chain = ChainedGeminiAuthStore([empty, filled]) + #expect(chain.description == "\(empty.url.path), \(filled.url.path)") + #expect(try chain.load()?.accessToken == "existing") + + try chain.save(GeminiAuth(accessToken: "updated")) + #expect(try filled.load()?.accessToken == "updated") + #expect(try empty.load() == nil) + try ChainedGeminiAuthStore([empty]).save(GeminiAuth(accessToken: "fallback")) + #expect(try empty.load()?.accessToken == "fallback") + #expect(try ChainedGeminiAuthStore([]).load() == nil) + try ChainedGeminiAuthStore([]).save(GeminiAuth(accessToken: "ignored")) +} + +@Test func copilotCLIConfigReadsArrayAccounts() throws { + let url = temporaryDirectory().appendingPathComponent("config.json") + try Data( + #"{"loggedInUsers":[{"user":"ada","host":"corp.example","token":"enterprise"}]}"#.utf8 + ).write(to: url) + let store = FileCopilotCLIAuthStore(url: url) + #expect(try store.load() == CopilotAuth(token: "enterprise", user: "ada", host: "corp.example")) + #expect(store.keychainAccounts() == ["https://corp.example:ada"]) +} + +@Test func copilotCLIConfigDefaultsAnArrayAccountToGitHub() throws { + let url = temporaryDirectory().appendingPathComponent("config.json") + try Data(#"{"loggedInUsers":[{"user":"ada","token":"enterprise"}]}"#.utf8).write(to: url) + let store = FileCopilotCLIAuthStore(url: url) + + #expect(try store.load() == CopilotAuth(token: "enterprise", user: "ada")) + #expect(store.keychainAccounts() == ["https://github.com:ada"]) +} + +@Test func legacyCopilotCredentialDefaultsAnEmptyAccountKeyToGitHub() throws { + let url = temporaryDirectory().appendingPathComponent("hosts.json") + try Data(#"{"":{"oauth_token":"token"}}"#.utf8).write(to: url) + + #expect(try FileCopilotAuthStore(urls: [url]).load() == CopilotAuth(token: "token")) +} + +@Test func copilotKeychainParsesHTTPAndMalformedEnterpriseAccounts() throws { + #expect( + try KeychainCopilotAuthStore.parse(Data("token".utf8), account: "http://corp.example:ada") + == CopilotAuth(token: "token", user: "ada", host: "corp.example")) + #expect( + try KeychainCopilotAuthStore.parse(Data("token".utf8), account: "https://bad/path:ada") + == CopilotAuth(token: "token", host: "github.com")) +} + +@Test func copilotKeychainStoreReadsInMemoryCredentials() throws { + let service = "copilot-test" + let account = "https://tests.example:tester" + let keychain = MemoryKeychain() + try keychain.client.save(Data("copilot-token".utf8), service: service, account: account) + + let auth = try KeychainCopilotAuthStore(service: service, accounts: [account], keychain: keychain.client).load() + #expect(auth?.token == "copilot-token") + #expect(auth?.host == "tests.example") +} + +@Test func copilotKeychainStoreReportsMalformedCredentialData() throws { + let service = "copilot-test" + let account = "broken" + let keychain = MemoryKeychain() + try keychain.client.save(Data([0xFF]), service: service, account: account) + + #expect(throws: CredentialReadFailure.self) { + try KeychainCopilotAuthStore(service: service, accounts: [account], keychain: keychain.client).load() + } +} + +@Test func copilotChainReturnsTheFirstReadableCredential() throws { + let empty = MemoryCopilotStore(nil) + let filled = MemoryCopilotStore(CopilotAuth(token: "token")) + #expect(try ChainedCopilotAuthStore([empty, filled]).load()?.token == "token") + #expect(try ChainedCopilotAuthStore([]).load() == nil) +} + +@Test func jwtPayloadHandlesMalformedTokens() { + #expect(JWT.payload("abc") == nil) + #expect(JWT.payload("a.!!!.c") == nil) + #expect(JWT.payload("a.bm90anNvbg.c") == nil) + #expect(JWT.expiry(makeJWT(.object(["exp": .number(10)]))) == Date(timeIntervalSince1970: 10)) + #expect(JWT.expiry(makeJWT(.object(["sub": .string("x")]))) == nil) +} + +@Test func credentialStateDescriptions() { + #expect(CredentialState.missing("nothing").description == "No credentials: nothing") + #expect(CredentialState.valid(expiresAt: nil).description == "Token present") + #expect(CredentialState.valid(expiresAt: fixedNow).description.hasPrefix("Token valid until")) + #expect(CredentialState.expired(fixedNow).description.hasPrefix("Token expired")) + #expect(!CredentialState.expired(fixedNow).isUsable) + #expect(CredentialState.from(expiresAt: nil, now: fixedNow).isUsable) +} + +@Test func isoDateParsesFractionalAndPlain() { + #expect(ISODate.parse("2026-08-29T18:49:59.521847+00:00") != nil) + #expect(ISODate.parse("2026-08-29T18:49:59Z") == Date(timeIntervalSince1970: 1_788_029_399)) + #expect(ISODate.parse("not a date") == nil) + #expect(ISODate.parse(nil) == nil) + #expect(ISODate.string(Date(timeIntervalSince1970: 0)) == "1970-01-01T00:00:00.000Z") +} diff --git a/Tests/TokenMenuBarCoreTests/CursorTests.swift b/Tests/TokenMenuBarCoreTests/CursorTests.swift new file mode 100644 index 0000000..fd420fd --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CursorTests.swift @@ -0,0 +1,358 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func cursorAuthDerivesUserAndCookie() { + #expect(validCursor.userID == "user_01ABC") + #expect(validCursor.sessionCookie == "WorkosCursorSessionToken=user_01ABC%3A%3A\(validCursor.accessToken)") + #expect(validCursor.state(now: fixedNow) == .valid(expiresAt: Date(timeIntervalSince1970: 1_900_000_000))) + let opaque = CursorAuth(accessToken: "opaque") + #expect(opaque.userID == nil) + #expect(opaque.sessionCookie == "WorkosCursorSessionToken=%3A%3Aopaque") + #expect(opaque.state(now: fixedNow) == .valid(expiresAt: nil)) + #expect(CursorAuth(accessToken: cursorJWT(exp: 1)).state(now: fixedNow).isUsable == false) +} + +private func cursorJWT(subject: String = "auth0|user_01ABC", exp: Double = 1_900_000_000) -> String { + let payload = try! JSONEncoder().encode(JSONValue.object(["sub": .string(subject), "exp": .number(exp)])) + .base64EncodedString().replacingOccurrences(of: "=", with: "") + return "eyJhbGciOiJIUzI1NiJ9.\(payload).sig" +} + +private let validCursor = CursorAuth(accessToken: cursorJWT(), email: "cached@example.com", membershipType: "pro") + +@Test func cursorStateStoreReadsTheAppDatabase() throws { + let root = temporaryDirectory() + let url = root.appendingPathComponent("state.vscdb") + let database = try SQLiteDatabase(path: url.path) + try database.execute("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)") + let store = CursorStateStore(url: url) + #expect(try store.load() == nil) + for (key, value) in [ + ("cursorAuth/accessToken", validCursor.accessToken), ("cursorAuth/refreshToken", "refresh"), + ("cursorAuth/cachedEmail", "cached@example.com"), ("cursorAuth/stripeMembershipType", "pro"), + ("storage.serviceMachineId", "m"), + ] { + try database.execute("INSERT INTO ItemTable (key, value) VALUES (?, ?)", [.text(key), .text(value)]) + } + let loaded = try store.load() + #expect(loaded?.accessToken == validCursor.accessToken) + #expect(loaded?.refreshToken == "refresh") + #expect(loaded?.email == "cached@example.com") + #expect(loaded?.membershipType == "pro") + #expect(store.description == url.path) + #expect(CursorStateStore.defaultURL(home: root).path.hasSuffix("Cursor/User/globalStorage/state.vscdb")) + #expect(try CursorStateStore(url: root.appendingPathComponent("missing.vscdb")).load() == nil) + try Data("not a database".utf8).write(to: root.appendingPathComponent("broken.vscdb")) + #expect(throws: (any Error).self) { try CursorStateStore(url: root.appendingPathComponent("broken.vscdb")).load() } +} + +@Test func cursorStateStoreReadsCredentialsFromTheLiveWAL() throws { + let directory = temporaryDirectory().appendingPathComponent("Application Support") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("state.vscdb") + let writer = try SQLiteDatabase(path: url.path) + try writer.execute("PRAGMA journal_mode = WAL") + try writer.execute("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)") + try writer.execute("PRAGMA wal_checkpoint(TRUNCATE)") + try writer.execute("PRAGMA wal_autocheckpoint = 0") + try writer.execute( + "INSERT INTO ItemTable (key, value) VALUES (?, ?)", + [.text("cursorAuth/accessToken"), .text(validCursor.accessToken)]) + + #expect(FileManager.default.fileExists(atPath: "\(url.path)-wal")) + let loaded = try withExtendedLifetime(writer) { try CursorStateStore(url: url).load() } + #expect(loaded?.accessToken == validCursor.accessToken) +} + +@Test func cursorFileStoreAndChainFallThrough() throws { + let root = temporaryDirectory() + let url = root.appendingPathComponent("auth.json") + let store = FileCursorAuthStore(url: url) + #expect(store.description == url.path) + #expect(try store.load() == nil) + try Data(#"{"accessToken":"\#(validCursor.accessToken)","refreshToken":"r"}"#.utf8).write(to: url) + #expect(try store.load()?.refreshToken == "r") + try Data(#"{"other":1}"#.utf8).write(to: url) + #expect(try store.load() == nil) + try Data("nope".utf8).write(to: url) + #expect(throws: CredentialStoreError.self) { try store.load() } + #expect(FileCursorAuthStore.defaultURL(environment: [:], home: root).path == root.path + "/.cursor/auth.json") + let failing = MemoryCursorStore(nil) + failing.loadError = TestError() + let chain = ChainedCursorAuthStore([failing, MemoryCursorStore(validCursor)]) + #expect(chain.description == "memory, memory") + #expect(try chain.load() == validCursor) + #expect(throws: CredentialReadFailure.self) { try ChainedCursorAuthStore([failing, MemoryCursorStore(nil)]).load() } + #expect(try ChainedCursorAuthStore([MemoryCursorStore(nil)]).load() == nil) +} + +@MainActor +private func cursorSnapshot( + summary: StubTransport.Response, me: StubTransport.Response = .json("cursor_me"), + period: StubTransport.Response? = nil, auth: CursorAuth = validCursor +) async -> ProviderSnapshot? { + let (provider, transport) = makeProvider(auth) + transport.on(path: "/api/usage-summary", summary) + transport.on(path: "/api/auth/me", me) + if let period { transport.on(path: "/GetCurrentPeriodUsage", period) } + guard case .success(let snapshot) = await provider.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected a snapshot") + return nil + } + return snapshot +} + +@Test func cursorReportsOneWindowPerQuotaBucket() async throws { + let snapshot = try #require(await cursorSnapshot(summary: .json("cursor_usage_summary"))) + #expect(snapshot.windows.map(\.id) == ["on_demand", "plan", "team_pool"]) + #expect(snapshot.windows.first { $0.id == "plan" }?.usedPercent == 30) + #expect(snapshot.windows.first { $0.id == "on_demand" }?.usedPercent == 5) + #expect(snapshot.windows.first { $0.id == "team_pool" }?.usedPercent == 25) + #expect(snapshot.windows.first { $0.id == "plan" }?.duration == Double(31 * 86400)) + #expect(snapshot.windows.first { $0.id == "plan" }?.resetsAt == ISODate.parse("2026-09-10T00:00:00.000Z")) +} + +@Test func cursorReportsTheOnDemandSpendLimit() async throws { + let snapshot = try #require(await cursorSnapshot(summary: .json("cursor_usage_summary"))) + #expect(snapshot.spend?.used == Money(amountMinor: 500, currency: "USD")) + #expect(snapshot.spend?.limit == Money(amountMinor: 10000, currency: "USD")) + #expect(snapshot.spend?.percent == 5) + #expect(snapshot.spend?.limitReached == false) +} + +@Test func cursorPrefersTheAccountEmailOverTheCachedOne() async throws { + let withAccount = try #require(await cursorSnapshot(summary: .json("cursor_usage_summary"))) + #expect(withAccount.identity?.planName == "Pro Plus") + #expect(withAccount.identity?.email == "you@example.com") + let cached = try #require( + await cursorSnapshot(summary: .json("cursor_usage_summary"), me: .text("nope", status: 403))) + #expect(cached.identity?.email == "cached@example.com") +} + +@Test func cursorFallsBackToThePeriodEndpoint() async throws { + let snapshot = try #require( + await cursorSnapshot( + summary: .text("nope", status: 403), me: .text("nope", status: 403), period: .json("cursor_period_usage"), + auth: CursorAuth(accessToken: "x"))) + #expect(snapshot.windows.map(\.id) == ["plan"]) + #expect(snapshot.windows.first?.usedPercent == 60) + #expect(snapshot.spend == nil) + #expect(snapshot.identity?.planName == "Cursor") + #expect(snapshot.notices.map(\.text) == ["You have used 60% of your plan."]) +} + +@Test func cursorCallsAnUnlimitedPlanOut() async throws { + let body = #"{"isUnlimited": true, "individualUsage": null}"# + let snapshot = try #require( + await cursorSnapshot(summary: .text(body), period: .json("cursor_period_usage"))) + #expect(snapshot.notices.map(\.text).contains("This plan has unlimited usage.")) +} + +@Test func cursorIgnoresADisabledOnDemandBucket() async throws { + let body = #""" + {"individualUsage": {"onDemand": {"enabled": false, "used": 1, "limit": 1, "remaining": 0}}} + """# + let snapshot = try #require(await cursorSnapshot(summary: .text(body))) + #expect(snapshot.spend == nil) + #expect(snapshot.windows.isEmpty) +} + +@Test func cursorCallsAFullOnDemandBucketAReachedLimit() async throws { + let body = #""" + {"billingCycleStart": "bad", + "individualUsage": {"onDemand": {"used": 100, "limit": 100, "remaining": 0}}} + """# + let snapshot = try #require(await cursorSnapshot(summary: .text(body))) + #expect(snapshot.spend?.limitReached == true) + #expect(snapshot.spend?.percent == 100) + #expect(snapshot.windows.first?.duration == nil) +} + +@MainActor +private func cursorPlanWindows(_ fields: String) async throws -> [QuotaWindow] { + let body = "{\"individualUsage\": {\"plan\": {\"enabled\": true, " + fields + "}}}" + return try #require(await cursorSnapshot(summary: .text(body))).windows +} + +/// The vendor reports a bucket's usage four ways and the app has to agree on one number: the total wins, then the +/// mean of the auto and API shares, then whichever share is present, then used against the limit. +@Test( + arguments: [ + (#""autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 3"#, 3.0), + (#""autoPercentUsed": 10, "apiPercentUsed": 20"#, 15.0), + (#""autoPercentUsed": 10"#, 10.0), + (#""apiPercentUsed": 20"#, 20.0), + (#""used": 25, "limit": 100"#, 25.0), + ]) +func cursorPicksThePercentTheVendorReports(fields: String, percent: Double) async throws { + #expect(try await cursorPlanWindows(fields).map(\.usedPercent) == [percent]) +} + +@Test(arguments: [#""used": 25, "limit": 0"#, #""remaining": 3"#]) +func cursorSkipsABucketWithoutAPercent(fields: String) async throws { + #expect(try await cursorPlanWindows(fields).isEmpty) +} + +@Test func cursorProviderFetchesSummaryAndIdentity() async { + let store = MemoryCursorStore(validCursor) + let (provider, transport) = makeProvider(validCursor, store: store) + transport.on(path: "/api/usage-summary", .json("cursor_usage_summary")) + transport.on(path: "/api/auth/me", .json("cursor_me")) + #expect(provider.credentialDescription == "memory") + #expect(provider.credentialState(now: fixedNow).isUsable) + let credentialState = validCursor.state(now: fixedNow) + #expect( + await provider.credentialHealth(now: fixedNow) + == .from(credentialState, source: store.source, expected: ProviderID.cursor.setup.credentialSources)) + let readsBeforeFetch = store.readCount + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(store.readCount == readsBeforeFetch + 1) + #expect( + result.credentialStatus + == ProviderCredentialStatus( + state: credentialState, + health: .from(credentialState, source: store.source, expected: ProviderID.cursor.setup.credentialSources))) + guard case .success(let snapshot) = result.outcome else { + Issue.record("expected success") + return + } + #expect(snapshot.windows.count == 3) + #expect(snapshot.identity?.email == "you@example.com") + #expect(snapshot.spend?.percent == 5) + let request = transport.requests(matching: "/api/usage-summary").first! + #expect(request.value(forHTTPHeaderField: "Cookie")?.hasPrefix("WorkosCursorSessionToken=user_01ABC") == true) + #expect(request.value(forHTTPHeaderField: "Origin") == "https://cursor.com") +} + +@Test func cursorProviderCachesIdentityUntilItsTTL() async { + let (provider, transport) = makeProvider(validCursor) + transport.on(path: "/api/usage-summary", .json("cursor_usage_summary")) + transport.on(path: "/api/auth/me", .json("cursor_me")) + + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(transport.requests(matching: "/api/auth/me").count == 1) + _ = await provider.fetch( + now: fixedNow.addingTimeInterval(CursorProvider.identityTTL + 1), options: FetchOptions()) + #expect(transport.requests(matching: "/api/auth/me").count == 2) +} + +@Test func cursorProviderCachesIdentityFailuresBriefly() async { + let (provider, transport) = makeProvider(validCursor) + transport.on(path: "/api/usage-summary", .json("cursor_usage_summary")) + transport.on(path: "/api/auth/me", .text("unavailable", status: 503)) + + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(transport.requests(matching: "/api/auth/me").count == 1) + _ = await provider.fetch( + now: fixedNow.addingTimeInterval(CursorProvider.identityFailureTTL + 1), options: FetchOptions()) + #expect(transport.requests(matching: "/api/auth/me").count == 2) +} + +@Test func cursorProviderCoalescesConcurrentIdentityRequests() async { + let transport = StubTransport() + transport.on(path: "/api/usage-summary", .json("cursor_usage_summary")) + transport.on(path: "/api/auth/me", .json("cursor_me")) + let gate = TestGate() + let delayed = CursorIdentityDelayedTransport(base: transport, gate: gate) + let provider = CursorProvider( + auth: MemoryCursorStore(validCursor), client: APIClient(transport: delayed, log: makeLog(), clock: testClock), + log: makeLog()) + + async let first = provider.fetch(now: fixedNow, options: FetchOptions()) + while !delayed.identityStarted { await Task.yield() } + async let second = provider.fetch(now: fixedNow, options: FetchOptions()) + while transport.requests(matching: "/api/usage-summary").count < 2 { await Task.yield() } + for _ in 0..<100 { await Task.yield() } + gate.open() + _ = await (first, second) + #expect(transport.requests(matching: "/api/auth/me").count == 1) +} + +private final class CursorIdentityDelayedTransport: HTTPTransport, @unchecked Sendable { + private let base: any HTTPTransport + private let gate: TestGate + private let lock = NSLock() + private var didStartIdentity = false + + init(base: any HTTPTransport, gate: TestGate) { + self.base = base + self.gate = gate + } + + var identityStarted: Bool { lock.withLock { didStartIdentity } } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + if request.url?.path.hasSuffix("/api/auth/me") == true { + lock.withLock { didStartIdentity = true } + try await gate.wait() + } + return try await base.data(for: request) + } +} + +private func makeProvider(_ auth: CursorAuth?, store: MemoryCursorStore? = nil) -> (CursorProvider, StubTransport) { + let transport = StubTransport() + let provider = CursorProvider( + auth: store ?? MemoryCursorStore(auth), client: APIClient(transport: transport, log: makeLog(), clock: testClock), + log: makeLog()) + return (provider, transport) +} + +@Test func cursorProviderFallsBackToBearerEndpoint() async { + let (provider, transport) = makeProvider(validCursor) + transport.on(path: "/api/usage-summary", .text("nope", status: 403)) + transport.on(path: "/GetCurrentPeriodUsage", .json("cursor_period_usage")) + transport.on(path: "/api/auth/me", .text("nope", status: 403)) + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + guard case .success(let snapshot) = result.outcome else { + Issue.record("expected success") + return + } + #expect(snapshot.windows.map(\.id) == ["plan"]) + #expect(snapshot.identity?.planName == "Pro") + #expect(snapshot.notices.map(\.text) == ["You have used 60% of your plan."]) + #expect(result.warnings.first?.contains("Dashboard summary unavailable") == true) + let bearer = transport.requests(matching: "/GetCurrentPeriodUsage").first! + #expect(bearer.value(forHTTPHeaderField: "Authorization") == "Bearer \(validCursor.accessToken)") + #expect(bearer.value(forHTTPHeaderField: "Connect-Protocol-Version") == "1") + let (failing, transport2) = makeProvider(validCursor) + transport2.on(path: "/api/usage-summary", .text("nope", status: 401)) + transport2.on(path: "/GetCurrentPeriodUsage", .text("nope", status: 401)) + guard case .notAuthenticated = await failing.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected notAuthenticated") + return + } +} + +@Test func cursorProviderHandlesCredentialProblems() async { + let (missing, _) = makeProvider(nil) + #expect(missing.credentialState(now: fixedNow) == .missing("no Cursor sign-in found")) + guard case .notAuthenticated(let reason) = await missing.fetch(now: fixedNow, options: FetchOptions()).outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(reason.contains("No Cursor credentials")) + let store = MemoryCursorStore(validCursor) + store.loadError = TestError() + let (broken, _) = makeProvider(nil, store: store) + #expect(broken.credentialState(now: fixedNow).isMissing) + guard case .notAuthenticated(let loadReason) = await broken.fetch(now: fixedNow, options: FetchOptions()).outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(loadReason.contains("Cannot read")) + let (expired, _) = makeProvider(CursorAuth(accessToken: cursorJWT(exp: 1))) + guard + case .notAuthenticated(let expiredReason) = await expired.fetch(now: fixedNow, options: FetchOptions()) + .outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(expiredReason.contains("expired")) +} diff --git a/Tests/TokenMenuBarCoreTests/DemoDataTests.swift b/Tests/TokenMenuBarCoreTests/DemoDataTests.swift new file mode 100644 index 0000000..7fb157d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/DemoDataTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test(arguments: ProviderID.allCases) +func demoSnapshotsAreDeterministicAndBounded(provider: ProviderID) { + let snapshot = DemoData.snapshot(provider, now: fixedNow) + #expect(snapshot == DemoData.snapshot(provider, now: fixedNow)) + #expect(snapshot.provider == provider) + #expect(!snapshot.windows.isEmpty) + #expect(snapshot.windows.allSatisfy { (0...100).contains($0.usedPercent) && $0.resetsAt! > fixedNow }) + #expect(snapshot.identity?.planName.isEmpty == false) + #expect(snapshot.fetchedAt == fixedNow) + let later = DemoData.snapshot(provider, now: fixedNow.addingTimeInterval(1800)) + #expect(later.windows.map(\.id) == snapshot.windows.map(\.id)) +} + +@Test func demoWindowsResetAtBoundaries() { + let boundary = DemoData.boundary(now: fixedNow, duration: 3600, offset: 600) + #expect(boundary > fixedNow && boundary.timeIntervalSince(fixedNow) <= 3600) + #expect(Int(boundary.timeIntervalSince1970 - 600) % 3600 == 0) + let midWindow = DemoData.boundary(now: fixedNow, duration: 3600, offset: 0).addingTimeInterval(-1800) + let window = DemoData.window( + id: "w", label: "W", group: .session, duration: 3600, offset: 0, pace: 100, now: midWindow) + #expect(window.usedPercent == 100) + let fresh = DemoData.window( + id: "w", label: "W", group: .session, duration: 3600, offset: 0, pace: 0, now: midWindow) + #expect(fresh.usedPercent <= 3) + #expect(DemoData.activity(0) > 0 && DemoData.activity(3) > 0) +} + +@Test func demoAnalyticsCoverClaudeAndCodexOnly() { + let claude = DemoData.analytics(.claude, now: fixedNow, days: 3)! + #expect(claude.series(for: .costUSD) == ["fable", "haiku", "sonnet"]) + #expect(claude.total(.sessions) > 0) + #expect(Set(claude.points.map(\.day)).count == 3) + let codex = DemoData.analytics(.codex, now: fixedNow, days: 2)! + #expect(codex.series(for: .surfaceUsagePercent) == ["cli", "vscode", "web"]) + #expect(codex.total(.codeReviews) >= 0) + #expect(DemoData.analytics(.gemini, now: fixedNow, days: 2) == nil) + #expect(DemoData.analytics(.cursor, now: fixedNow, days: 2) == nil) +} + +@Test func demoProviderReturnsSnapshotsAndAnalytics() async { + let provider = DemoProvider(id: .codex) + #expect(provider.credentialDescription == "Demo data") + #expect(provider.credentialState(now: fixedNow) == .valid(expiresAt: nil)) + #expect(provider.pollingPolicy.minimumInterval == 60) + let plain = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(plain.outcome == .success(DemoData.snapshot(.codex, now: fixedNow))) + #expect(plain.analytics == nil) + let withAnalytics = await provider.fetch( + now: fixedNow, options: FetchOptions(includeAnalytics: true, analyticsDays: 4)) + #expect(withAnalytics.analytics?.points.isEmpty == false) + #expect( + await DemoProvider(id: .gemini).fetch(now: fixedNow, options: FetchOptions(includeAnalytics: true)).analytics == nil + ) +} + +@Test func longTextFixtureExpandsEveryDynamicSurfaceWithoutChangingValues() async { + let snapshot = DemoData.snapshot(.codex, now: fixedNow, fixture: .longText) + let standard = DemoData.snapshot(.codex, now: fixedNow) + let provider = DemoProvider(id: .codex, fixture: .longText) + let result = await provider.fetch( + now: fixedNow, options: FetchOptions(includeAnalytics: true, analyticsDays: 2)) + + #expect(provider.credentialDescription.hasSuffix("account-profile-with-a-deliberately-long-file-name.json")) + #expect(snapshot.windows.map(\.usedPercent) == standard.windows.map(\.usedPercent)) + #expect(snapshot.windows.allSatisfy { $0.id.count > 50 && $0.label.count > 50 }) + #expect(snapshot.identity?.email?.count ?? 0 > 50) + #expect(snapshot.notices.contains { $0.text.count > 100 }) + #expect(result.analytics?.points.allSatisfy { $0.series.count > 50 } == true) +} + +@Test(arguments: ProviderID.allCases) +func controlAuditFixtureKeepsUsageAndExposesRecovery(provider: ProviderID) async { + let result = await DemoProvider(id: provider, fixture: .controlAudit).fetch( + now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .success(DemoData.snapshot(provider, now: fixedNow))) + #expect(result.recoveryIssue != nil) +} + +@Test func demoSeedPopulatesHistory() async throws { + let history = try UsageHistoryStore(url: nil) + try await DemoData.seed(history, providers: [.claude, .gemini], now: fixedNow) + let stats = try await history.stats() + #expect(stats.sampleCount > 1000) + #expect(stats.oldest! <= fixedNow.addingTimeInterval(-Double(DemoData.historyDays - 1) * 86400)) + #expect(try await history.analytics(provider: .claude, from: "2000-01-01", to: "2100-01-01").isEmpty == false) + #expect(try await history.analytics(provider: .gemini, from: "2000-01-01", to: "2100-01-01").isEmpty) + let summaries = try await history.summaries() + #expect(summaries.contains { $0.key == WindowKey(provider: .claude, windowID: "session") }) + #expect(summaries.contains { $0.key == WindowKey(provider: .gemini, windowID: "model:gemini-2.5-pro") }) +} + +@Test func historySeedIsTransactional() async throws { + let history = try UsageHistoryStore(url: nil) + try await history.seed([(DemoData.snapshot(.codex, now: fixedNow), fixedNow)]) + #expect(try await history.stats().sampleCount == DemoData.snapshot(.codex, now: fixedNow).windows.count) + try await history.breakDatabase() + await #expect(throws: (any Error).self) { + try await history.seed([(DemoData.snapshot(.codex, now: fixedNow), fixedNow)]) + } +} diff --git a/Tests/TokenMenuBarCoreTests/DiagnosticsTests.swift b/Tests/TokenMenuBarCoreTests/DiagnosticsTests.swift new file mode 100644 index 0000000..cf83acc --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/DiagnosticsTests.swift @@ -0,0 +1,183 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func appInfoReadsBundleWithFallbacks() { + let info = AppInfo.from(bundle: Bundle(for: DateBox.self), isAppStore: true) + #expect(!info.name.isEmpty) + #expect(!info.version.isEmpty) + #expect(info.isAppStore) + #expect(info.repository == AppInfo.repositoryURL) + #expect(info.releasesURL.path == "/tox-dev/token-menu-bar-macos/releases") + let bare = AppInfo.from(bundle: Bundle(), isAppStore: false) + #expect(bare.name == "Token Menu Bar") + #expect(bare.version == "0.0.0") + #expect(bare.build == "0") + #expect(bare.bundleIdentifier == "dev.tox.token-menu-bar") + #expect(bare.sourceVersion == bare.version) + #expect(!bare.isPrerelease) + #expect(!bare.canSelfUpdate) +} + +@Test(arguments: [ + ("direct", DistributionChannel.direct), + ("App Store", DistributionChannel.appStore), + ("appstore", DistributionChannel.appStore), + ("HOMEBREW", DistributionChannel.homebrew), +]) +func distributionReadsBuildConfiguration(value: String, expected: DistributionChannel) { + #expect(DistributionChannel(configurationValue: value) == expected) +} + +@Test func distributionRejectsUnknownBuildConfiguration() { + #expect(DistributionChannel(configurationValue: "nightly") == nil) +} + +@Test(arguments: [ + (DistributionChannel.direct, "Direct", false, true), + (DistributionChannel.appStore, "App Store", true, false), + (DistributionChannel.homebrew, "Homebrew", false, false), +]) +func distributionControlsRuntimeCapabilities( + channel: DistributionChannel, name: String, appStore: Bool, selfUpdate: Bool +) { + #expect(channel.displayName == name) + #expect(channel.isAppStore == appStore) + #expect(channel.allowsSelfUpdate == selfUpdate) +} + +@Test(arguments: [ + (DistributionChannel.direct, true, true), + (DistributionChannel.direct, false, false), + (DistributionChannel.appStore, true, false), + (DistributionChannel.homebrew, true, false), +]) +func appInfoLimitsSelfUpdatesToEnabledDirectReleases( + distribution: DistributionChannel, enabled: Bool, expected: Bool +) { + let info = AppInfo( + name: "Token Menu Bar", version: "1", build: "2", bundleIdentifier: "dev.tox.token-menu-bar", + distribution: distribution, selfUpdateEnabled: enabled, repository: AppInfo.repositoryURL) + #expect(info.canSelfUpdate == expected) +} + +@Test func appInfoPreservesLegacyDistributionInitializer() { + let appStore = AppInfo( + name: "Token Menu Bar", version: "1", build: "2", bundleIdentifier: "dev.tox.token-menu-bar", + isAppStore: true, repository: AppInfo.repositoryURL) + #expect(appStore.distribution == .appStore) +} + +@Test func appInfoPrefersTheBundledDistribution() throws { + let bundleURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).bundle") + let contents = bundleURL.appendingPathComponent("Contents", isDirectory: true) + defer { try? FileManager.default.removeItem(at: bundleURL) } + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + let data = try PropertyListSerialization.data( + fromPropertyList: [ + "CFBundleIdentifier": "dev.tox.token-menu-bar.test", + "CFBundleName": "Test", + "CFBundlePackageType": "BNDL", + "TMBDistribution": "Homebrew", + "TMBSelfUpdateEnabled": "YES", + ], + format: .xml, options: 0) + try data.write(to: contents.appendingPathComponent("Info.plist")) + let bundle = try #require(Bundle(url: bundleURL)) + + let info = AppInfo.from(bundle: bundle, distribution: .direct) + + #expect(info.distribution == .homebrew) + #expect(info.selfUpdateEnabled) + #expect(!info.canSelfUpdate) + #expect(info.bundleIdentifier == "dev.tox.token-menu-bar.test") +} + +@Test(arguments: [ + ("1.2.3", false), + ("1.2.4.dev5+gabc123", true), + ("1.2.4.dev5+gabc123.d20260830", true), +]) +func appInfoNamesPrereleaseBuilds(sourceVersion: String, prerelease: Bool) { + let info = AppInfo( + name: "Token Menu Bar", version: "1.2.3", sourceVersion: sourceVersion, build: "7", + bundleIdentifier: "dev.tox.token-menu-bar", isAppStore: false, repository: AppInfo.repositoryURL) + #expect(info.isPrerelease == prerelease) + #expect(info.sourceVersion == sourceVersion) +} + +@Test @MainActor func diagnosticsReportListsProvidersAndLog() throws { + let settings = Settings(defaults: UserDefaults(suiteName: "diag-\(UUID().uuidString)")!) + let state = AppState() + state.update(.claude) { + $0.snapshot = ProviderSnapshot( + provider: .claude, identity: ProviderIdentity(planName: "Max 20x"), + windows: [QuotaWindow(id: "session", label: "S", group: .session, usedPercent: 36, resetsAt: nil)], + fetchedAt: fixedNow) + $0.availability = .current + $0.lastError = "old error" + $0.credentialState = .valid(expiresAt: nil) + } + state.update(.codex) { $0.availability = .authenticationRequired } + state.setRefreshing(false, at: fixedNow.addingTimeInterval(-30)) + let log = makeLog() + log.log("hello") + let app = AppInfo( + name: "Token Menu Bar", version: "1.0", build: "7", bundleIdentifier: "dev.tox.token-menu-bar", isAppStore: false, + repository: AppInfo.repositoryURL) + let report = Diagnostics.report( + app: app, osVersion: "26.6", settings: settings, state: state, historyLocation: nil, log: log, now: fixedNow) + #expect(report.hasPrefix("Token Menu Bar 1.0 (7) Direct\nmacOS 26.6")) + #expect(report.contains("History: in memory")) + #expect(report.contains("Last refresh: 30s ago")) + #expect(report.contains("- Claude: current, plan Max 20x, windows session=36%")) + #expect(report.contains(" error: old error")) + #expect(report.contains(" credentials: Token present")) + #expect(!report.contains("- Codex:")) + #expect(report.hasSuffix("[info] hello")) + let url = Diagnostics.issueURL(repository: app.repository, title: "Bug", report: report) + #expect(url.absoluteString.hasPrefix("https://github.com/tox-dev/token-menu-bar-macos/issues/new?title=Bug&body=")) + #expect(url.absoluteString.count <= Diagnostics.maxIssueURLLength) +} + +@Test func diagnosticsIssueURLTrimsLongReports() { + let long = (0..<400).map { "line \($0) " + String(repeating: "x", count: 40) }.joined(separator: "\n") + let url = Diagnostics.issueURL(repository: AppInfo.repositoryURL, title: "Long", report: long) + #expect(url.absoluteString.count <= Diagnostics.maxIssueURLLength) + #expect(url.absoluteString.contains("line%200%20")) + #expect(!url.absoluteString.contains("line%20399%20")) + let single = Diagnostics.issueURL( + repository: AppInfo.repositoryURL, title: "One", report: String(repeating: "y", count: 9000)) + #expect(single.absoluteString.count <= Diagnostics.maxIssueURLLength) +} + +@Test @MainActor func diagnosticsRedactsPrivateReportFields() { + let settings = Settings(defaults: UserDefaults(suiteName: "diag-private-\(UUID().uuidString)")!) + settings.setProvider(.codex, enabled: true) + let state = AppState() + state.update(.codex) { + $0.lastError = "Bearer secret user@example.com" + $0.credentialHealth = .valid(source: ProviderID.codex.setup.credentialSources[0], expiresAt: nil) + } + let report = Diagnostics.report( + app: AppInfo( + name: "Token Menu Bar", version: "1", build: "1", bundleIdentifier: "dev.tox.token-menu-bar", + isAppStore: false, repository: AppInfo.repositoryURL), + osVersion: "26", settings: settings, state: state, + historyLocation: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/history.sqlite"), + log: makeLog(), now: fixedNow) + #expect(report.contains("History: ~/Library/history.sqlite")) + #expect(report.contains("Bearer ")) + #expect(report.contains("")) + #expect(!report.contains("secret")) +} + +@Test @MainActor func diagnosticsNamesHomebrewDistribution() { + let settings = Settings(defaults: UserDefaults(suiteName: "diag-homebrew-\(UUID().uuidString)")!) + let report = Diagnostics.report( + app: AppInfo( + name: "Token Menu Bar", version: "1", build: "1", bundleIdentifier: "dev.tox.token-menu-bar", + distribution: .homebrew, repository: AppInfo.repositoryURL), + osVersion: "15", settings: settings, state: AppState(), historyLocation: nil, log: makeLog(), now: fixedNow) + #expect(report.hasPrefix("Token Menu Bar 1 (1) Homebrew\n")) +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/claude_keychain.json b/Tests/TokenMenuBarCoreTests/Fixtures/claude_keychain.json new file mode 100644 index 0000000..eefc486 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/claude_keychain.json @@ -0,0 +1,17 @@ +{ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-EXAMPLE", + "refreshToken": "sk-ant-ort01-EXAMPLE", + "expiresAt": 1788039901877, + "refreshTokenExpiresAt": 1788977546877, + "scopes": [ + "user:file_upload", + "user:inference", + "user:mcp_servers", + "user:profile", + "user:sessions:claude_code" + ], + "subscriptionType": "max", + "rateLimitTier": "default_claude_max_20x" + } +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/claude_profile.json b/Tests/TokenMenuBarCoreTests/Fixtures/claude_profile.json new file mode 100644 index 0000000..0044a0d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/claude_profile.json @@ -0,0 +1,33 @@ +{ + "account": { + "uuid": "00000000-0000-4000-8000-000000000000", + "full_name": "Example User", + "display_name": "Example User", + "email": "user@example.com", + "has_claude_max": true, + "has_claude_pro": false, + "created_at": "2024-04-23T13:55:23.005051Z" + }, + "organization": { + "uuid": "00000000-0000-4000-8000-000000000000", + "name": "user@example.com's Organization", + "organization_type": "claude_max", + "billing_type": "stripe_subscription", + "rate_limit_tier": "default_claude_max_20x", + "seat_tier": null, + "has_extra_usage_enabled": true, + "subscription_status": "active", + "subscription_created_at": "2026-03-03T01:03:10.276151Z", + "cc_onboarding_flags": {}, + "claude_code_trial_ends_at": null, + "claude_code_trial_duration_days": null, + "payment_auth_hosted_invoice_url": null, + "claude_ai_completion_feedback_enabled": true + }, + "application": { + "uuid": "00000000-0000-4000-8000-000000000000", + "name": "Example User", + "slug": "claude-code" + }, + "enabled_plugins": [] +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/claude_usage.json b/Tests/TokenMenuBarCoreTests/Fixtures/claude_usage.json new file mode 100644 index 0000000..47c8044 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/claude_usage.json @@ -0,0 +1,99 @@ +{ + "five_hour": { + "utilization": 36.0, + "resets_at": "2026-08-29T18:49:59.521847+00:00", + "limit_dollars": null, + "used_dollars": null, + "remaining_dollars": null, + "locked_reason": null + }, + "seven_day": null, + "seven_day_oauth_apps": null, + "seven_day_opus": null, + "seven_day_sonnet": null, + "seven_day_cowork": null, + "seven_day_omelette": null, + "tangelo": null, + "iguana_necktie": null, + "omelette_promotional": null, + "nimbus_quill": { + "utilization": 0.0, + "resets_at": null, + "limit_dollars": null, + "used_dollars": null, + "remaining_dollars": null, + "locked_reason": null + }, + "cinder_cove": null, + "amber_ladder": null, + "juniper_tide": null, + "extra_usage": { + "is_enabled": false, + "monthly_limit": 0, + "used_credits": 0.0, + "utilization": null, + "currency": "USD", + "decimal_places": 2, + "disabled_reason": "org_level_disabled_until", + "user_disabled": false, + "spend_limit_reached": false, + "credits_ever_enabled": true, + "daily": null, + "weekly": null + }, + "limits": [ + { + "kind": "session", + "group": "session", + "percent": 36, + "severity": "normal", + "resets_at": "2026-08-29T18:49:59.521847+00:00", + "scope": null, + "is_active": false + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 61, + "severity": "normal", + "resets_at": "2026-09-01T14:59:59.522121+00:00", + "scope": { + "model": { + "id": null, + "display_name": "Fable" + }, + "surface": null + }, + "is_active": true + } + ], + "spend": { + "used": { + "amount_minor": 0, + "currency": "USD", + "exponent": 2 + }, + "limit": { + "amount_minor": 0, + "currency": "USD", + "exponent": 2 + }, + "percent": 0, + "severity": "normal", + "enabled": false, + "disabled_reason": "org_level_disabled_until", + "cap": { + "money": null, + "credits": { + "amount_minor": 0, + "exponent": 2 + } + }, + "balance": null, + "auto_reload": null, + "disclaimer": "Usage credits cover you when you hit your plan limits. [Learn", + "can_purchase_credits": false, + "can_toggle": false + }, + "member_dashboard_available": false +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/codex_auth.json b/Tests/TokenMenuBarCoreTests/Fixtures/codex_auth.json new file mode 100644 index 0000000..2ffff7e --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/codex_auth.json @@ -0,0 +1,19 @@ +{ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token_claims": { + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "pro", + "chatgpt_account_id": "acct_123", + "chatgpt_subscription_active_until": "2026-07-23T19:57:23+00:00" + }, + "exp": 1788044000 + }, + "access_token": "ACCESS-EXAMPLE", + "refresh_token": "REFRESH-EXAMPLE", + "account_id": "00000000-0000-4000-8000-000000000000" + }, + "last_refresh": "2026-08-28T19:59:48.413665Z" +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/codex_credit_events.json b/Tests/TokenMenuBarCoreTests/Fixtures/codex_credit_events.json new file mode 100644 index 0000000..268c73f --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/codex_credit_events.json @@ -0,0 +1,3 @@ +{ + "data": [] +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_code_review.json b/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_code_review.json new file mode 100644 index 0000000..261357f --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_code_review.json @@ -0,0 +1,4 @@ +{ + "data": [], + "group_by": "day" +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_plugins.json b/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_plugins.json new file mode 100644 index 0000000..793dd97 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_plugins.json @@ -0,0 +1,142 @@ +{ + "data": [ + { + "date": "2026-08-07", + "plugin_usage_overviews": [ + { + "plugin_id": "github@openai-curated-remote", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated-remote", + "invocation_counts": 7 + }, + { + "plugin_id": "github@openai-curated", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated", + "invocation_counts": 3 + } + ] + }, + { + "date": "2026-08-08", + "plugin_usage_overviews": [ + { + "plugin_id": "github@openai-curated-remote", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated-remote", + "invocation_counts": 5 + }, + { + "plugin_id": "github@openai-curated", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated", + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-09", + "plugin_usage_overviews": [ + { + "plugin_id": "github@openai-curated-remote", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated-remote", + "invocation_counts": 9 + }, + { + "plugin_id": "github@openai-curated", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated", + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-10", + "plugin_usage_overviews": [ + { + "plugin_id": "github@openai-curated-remote", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated-remote", + "invocation_counts": 14 + }, + { + "plugin_id": "github@openai-curated", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated", + "invocation_counts": 3 + } + ] + }, + { + "date": "2026-08-11", + "plugin_usage_overviews": [ + { + "plugin_id": "github@openai-curated-remote", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated-remote", + "invocation_counts": 2 + } + ] + }, + { + "date": "2026-08-13", + "plugin_usage_overviews": [ + { + "plugin_id": "github@openai-curated-remote", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated-remote", + "invocation_counts": 3 + } + ] + }, + { + "date": "2026-08-14", + "plugin_usage_overviews": [ + { + "plugin_id": "github@openai-curated-remote", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated-remote", + "invocation_counts": 3 + } + ] + }, + { + "date": "2026-08-15", + "plugin_usage_overviews": [ + { + "plugin_id": "github@openai-curated-remote", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated-remote", + "invocation_counts": 5 + } + ] + }, + { + "date": "2026-08-16", + "plugin_usage_overviews": [ + { + "plugin_id": "github@openai-curated-remote", + "plugin_name": "github", + "display_name": "Example User", + "marketplace": "openai-curated-remote", + "invocation_counts": 1 + } + ] + } + ], + "data_freshness_ts": "2026-08-29T10:50:15.724764Z", + "group_by": "day" +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_skills.json b/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_skills.json new file mode 100644 index 0000000..2794286 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_skills.json @@ -0,0 +1,640 @@ +{ + "data": [ + { + "date": "2026-08-06", + "skill_usage_overviews": [ + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 9 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 7 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-07", + "skill_usage_overviews": [ + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 72 + }, + { + "skill_name": "github:github", + "display_name": "Github: Github", + "skill_ids": [], + "invocation_counts": 5 + }, + { + "skill_name": "github:gh-fix-ci", + "display_name": "Github: Gh Fix Ci", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-08", + "skill_usage_overviews": [ + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 76 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 3 + }, + { + "skill_name": "github:github", + "display_name": "Github: Github", + "skill_ids": [], + "invocation_counts": 3 + }, + { + "skill_name": "github:gh-fix-ci", + "display_name": "Github: Gh Fix Ci", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-09", + "skill_usage_overviews": [ + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 51 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 17 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 9 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 9 + }, + { + "skill_name": "github:gh-fix-ci", + "display_name": "Github: Gh Fix Ci", + "skill_ids": [], + "invocation_counts": 8 + }, + { + "skill_name": "github:github", + "display_name": "Github: Github", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-10", + "skill_usage_overviews": [ + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 151 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 51 + }, + { + "skill_name": "github:gh-fix-ci", + "display_name": "Github: Gh Fix Ci", + "skill_ids": [], + "invocation_counts": 6 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 5 + }, + { + "skill_name": "github:github", + "display_name": "Github: Github", + "skill_ids": [], + "invocation_counts": 5 + }, + { + "skill_name": "commit-pr", + "display_name": "Commit Pr", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "github:gh-address-comments", + "display_name": "Github: Gh Address Comments", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "github:yeet", + "display_name": "Github: Yeet", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-11", + "skill_usage_overviews": [ + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 138 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 64 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 3 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "github:gh-fix-ci", + "display_name": "Github: Gh Fix Ci", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "github:github", + "display_name": "Github: Github", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-12", + "skill_usage_overviews": [ + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 37 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 25 + } + ] + }, + { + "date": "2026-08-13", + "skill_usage_overviews": [ + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 9 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 6 + }, + { + "skill_name": "github:gh-fix-ci", + "display_name": "Github: Gh Fix Ci", + "skill_ids": [], + "invocation_counts": 3 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 3 + } + ] + }, + { + "date": "2026-08-14", + "skill_usage_overviews": [ + { + "skill_name": "github:gh-fix-ci", + "display_name": "Github: Gh Fix Ci", + "skill_ids": [], + "invocation_counts": 3 + }, + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-15", + "skill_usage_overviews": [ + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 6 + }, + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 6 + }, + { + "skill_name": "github:gh-fix-ci", + "display_name": "Github: Gh Fix Ci", + "skill_ids": [], + "invocation_counts": 4 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "github:github", + "display_name": "Github: Github", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-16", + "skill_usage_overviews": [ + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "commit-pr", + "display_name": "Commit Pr", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "github:github", + "display_name": "Github: Github", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-22", + "skill_usage_overviews": [ + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 3 + }, + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "commit-pr", + "display_name": "Commit Pr", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-23", + "skill_usage_overviews": [ + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 2 + } + ] + }, + { + "date": "2026-08-24", + "skill_usage_overviews": [ + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 3 + }, + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 3 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 3 + }, + { + "skill_name": "commit-pr", + "display_name": "Commit Pr", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-25", + "skill_usage_overviews": [ + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 14 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 6 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 4 + }, + { + "skill_name": "commit-pr", + "display_name": "Commit Pr", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-26", + "skill_usage_overviews": [ + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 7 + }, + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 3 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "commit-pr", + "display_name": "Commit Pr", + "skill_ids": [], + "invocation_counts": 1 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-27", + "skill_usage_overviews": [ + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 49 + }, + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 42 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 17 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 15 + }, + { + "skill_name": "openai-docs", + "display_name": "Openai Docs", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-28", + "skill_usage_overviews": [ + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 70 + }, + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 40 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 25 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 15 + }, + { + "skill_name": "openai-docs", + "display_name": "Openai Docs", + "skill_ids": [], + "invocation_counts": 2 + }, + { + "skill_name": "commit-pr", + "display_name": "Commit Pr", + "skill_ids": [], + "invocation_counts": 1 + } + ] + }, + { + "date": "2026-08-29", + "skill_usage_overviews": [ + { + "skill_name": "simp", + "display_name": "Simp", + "skill_ids": [], + "invocation_counts": 38 + }, + { + "skill_name": "no-slop", + "display_name": "No Slop", + "skill_ids": [], + "invocation_counts": 21 + }, + { + "skill_name": "pr", + "display_name": "Pr", + "skill_ids": [], + "invocation_counts": 5 + }, + { + "skill_name": "commit", + "display_name": "Commit", + "skill_ids": [], + "invocation_counts": 3 + } + ] + } + ], + "group_by": "day" +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_token_usage.json b/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_token_usage.json new file mode 100644 index 0000000..93e5acc --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_token_usage.json @@ -0,0 +1,1282 @@ +{ + "data": [ + { + "date": "2026-08-01", + "product_surface_usage_values": { + "cli": 0.0, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-02", + "product_surface_usage_values": { + "cli": 0.0, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-03", + "product_surface_usage_values": { + "cli": 0.008616891589564392, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-04", + "product_surface_usage_values": { + "cli": 0.0, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-05", + "product_surface_usage_values": { + "cli": 0.0, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-06", + "product_surface_usage_values": { + "cli": 7.431959807673002, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 1.816577755656448 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-07", + "product_surface_usage_values": { + "cli": 32.92682533490686, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 15.448646245110535, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 48.37547158001739 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-08", + "product_surface_usage_values": { + "cli": 13.649294702078462, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 11.635947925033271, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 25.284924688431765 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.00031793867996467016 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-09", + "product_surface_usage_values": { + "cli": 71.18984992742185, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 2.4534338713719115, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 73.64328379879377 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-10", + "product_surface_usage_values": { + "cli": 69.36765555872888, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 13.853589784285674, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 82.66435998229976 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.5568853607148061 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-11", + "product_surface_usage_values": { + "cli": 89.85307528066926, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 3.9551195106344057, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 93.80819479130366 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-12", + "product_surface_usage_values": { + "cli": 33.7323793227585, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 1.2740618310325609, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 35.006441153791066 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-13", + "product_surface_usage_values": { + "cli": 8.583601152146494, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.12283446665072245, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 8.706435618797217 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-14", + "product_surface_usage_values": { + "cli": 6.94889975797563, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 6.948899757975628 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-15", + "product_surface_usage_values": { + "cli": 9.664126551740603, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 1.6744282803108146, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 11.338554832051418 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-16", + "product_surface_usage_values": { + "cli": 3.2951091301763, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 3.2951091301763 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-17", + "product_surface_usage_values": { + "cli": 0.0, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-18", + "product_surface_usage_values": { + "cli": 0.0, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-19", + "product_surface_usage_values": { + "cli": 0.0, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-20", + "product_surface_usage_values": { + "cli": 0.0, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-21", + "product_surface_usage_values": { + "cli": 0.0, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-22", + "product_surface_usage_values": { + "cli": 0.9288385270599329, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 0.9288385270599329 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-23", + "product_surface_usage_values": { + "cli": 2.5012638238559384, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 2.5012638238559384 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-24", + "product_surface_usage_values": { + "cli": 13.038553787393644, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 13.038553787393644 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-25", + "product_surface_usage_values": { + "cli": 15.500802492664778, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 15.500802492664778 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-26", + "product_surface_usage_values": { + "cli": 6.944162424866743, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 6.944162424866743 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 0.0 + } + ] + }, + { + "date": "2026-08-27", + "product_surface_usage_values": { + "cli": 86.35639563462055, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 86.35629768764484 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 9.794697571406566e-05 + } + ] + }, + { + "date": "2026-08-28", + "product_surface_usage_values": { + "cli": 100.0, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 99.99991015290239 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 8.984709761987512e-05 + } + ] + }, + { + "date": "2026-08-29", + "product_surface_usage_values": { + "cli": 38.172517544402055, + "vscode": 0.0, + "web": 0.0, + "work_web": 0.0, + "mobile": 0.0, + "work_mobile": 0.0, + "slack": 0.0, + "linear": 0.0, + "jetbrains": 0.0, + "sdk": 0.0, + "exec": 0.0, + "github": 0.0, + "desktop_app": 0.0, + "work_desktop": 0.0, + "github_code_review": 0.0, + "agent_identity": 0.0, + "unknown": 0.0 + }, + "models": [ + { + "model": "gpt-5.6-sol", + "speed": "standard", + "credits": 38.1724716237558 + }, + { + "model": "gpt-5.4", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "codex-auto-review", + "speed": "standard", + "credits": 0.0 + }, + { + "model": "gpt-5.6-luna", + "speed": "standard", + "credits": 4.5920646265301434e-05 + } + ] + } + ], + "units": "percent", + "group_by": "day" +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_workspace_usage.json b/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_workspace_usage.json new file mode 100644 index 0000000..52ffcf3 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_workspace_usage.json @@ -0,0 +1,804 @@ +{ + "data": [ + { + "date": "2026-08-03", + "totals": { + "users": 1, + "threads": 0, + "turns": 1, + "credits": 3.5250000000000004 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 1, + "credits": 3.5250000000000004 + } + ], + "models": [ + { + "model": "gpt-5.3-codex-spark", + "credits": 0.0, + "users": 1, + "threads": 0, + "turns": 1 + } + ] + }, + { + "date": "2026-08-06", + "totals": { + "users": 1, + "threads": 2, + "turns": 52, + "credits": 3018.73065, + "uncached_text_input_tokens": 1508758, + "cached_text_input_tokens": 36543232, + "text_output_tokens": 123294, + "text_total_tokens": 38175284 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 2, + "turns": 52, + "credits": 3018.73065, + "uncached_text_input_tokens": 1508758, + "cached_text_input_tokens": 36543232, + "text_output_tokens": 123294, + "text_total_tokens": 38175284 + } + ], + "models": [ + { + "model": "gpt-5.3-codex-spark", + "credits": 0.0, + "users": 1, + "threads": 2, + "turns": 40 + }, + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 2, + "turns": 12 + } + ] + }, + { + "date": "2026-08-07", + "totals": { + "users": 1, + "threads": 63, + "turns": 123, + "credits": 19649.098375, + "uncached_text_input_tokens": 39549213, + "cached_text_input_tokens": 906882560, + "text_output_tokens": 4492553, + "text_total_tokens": 950924326 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 60, + "credits": 13374.183425, + "uncached_text_input_tokens": 20526747, + "cached_text_input_tokens": 680495104, + "text_output_tokens": 3069535, + "text_total_tokens": 704091386 + }, + { + "client_id": "CODEX_SERVICE_EXEC", + "users": 1, + "threads": 63, + "turns": 63, + "credits": 6274.91495, + "uncached_text_input_tokens": 19022466, + "cached_text_input_tokens": 226387456, + "text_output_tokens": 1423018, + "text_total_tokens": 246832940 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 63, + "turns": 123 + } + ] + }, + { + "date": "2026-08-08", + "totals": { + "users": 1, + "threads": 79, + "turns": 128, + "credits": 10270.333364999999, + "uncached_text_input_tokens": 20372459, + "cached_text_input_tokens": 470971648, + "text_output_tokens": 2452973, + "text_total_tokens": 493797080 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 49, + "credits": 5544.056225, + "uncached_text_input_tokens": 7691937, + "cached_text_input_tokens": 308174848, + "text_output_tokens": 973838, + "text_total_tokens": 316840623 + }, + { + "client_id": "CODEX_SERVICE_EXEC", + "users": 1, + "threads": 79, + "turns": 79, + "credits": 4726.27714, + "uncached_text_input_tokens": 12680522, + "cached_text_input_tokens": 162796800, + "text_output_tokens": 1479135, + "text_total_tokens": 176956457 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 79, + "turns": 128 + } + ] + }, + { + "date": "2026-08-09", + "totals": { + "users": 1, + "threads": 9, + "turns": 331, + "credits": 29912.351875, + "uncached_text_input_tokens": 44238447, + "cached_text_input_tokens": 1659520640, + "text_output_tokens": 4851384, + "text_total_tokens": 1708610471 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 2, + "turns": 324, + "credits": 28915.818675, + "uncached_text_input_tokens": 42753767, + "cached_text_input_tokens": 1607922304, + "text_output_tokens": 4630092, + "text_total_tokens": 1655306163 + }, + { + "client_id": "CODEX_SERVICE_EXEC", + "users": 1, + "threads": 7, + "turns": 7, + "credits": 996.5332, + "uncached_text_input_tokens": 1484680, + "cached_text_input_tokens": 51598336, + "text_output_tokens": 221292, + "text_total_tokens": 53304308 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 9, + "turns": 331 + } + ] + }, + { + "date": "2026-08-10", + "totals": { + "users": 1, + "threads": 103, + "turns": 842, + "credits": 33802.7182625, + "uncached_text_input_tokens": 56054469, + "cached_text_input_tokens": 1752140160, + "text_output_tokens": 6827137, + "text_total_tokens": 1815021766 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 1, + "turns": 740, + "credits": 28175.681675, + "uncached_text_input_tokens": 44165077, + "cached_text_input_tokens": 1495448064, + "text_output_tokens": 5282595, + "text_total_tokens": 1544895736 + }, + { + "client_id": "CODEX_SERVICE_EXEC", + "users": 1, + "threads": 102, + "turns": 102, + "credits": 5627.0365875, + "uncached_text_input_tokens": 11889392, + "cached_text_input_tokens": 256692096, + "text_output_tokens": 1544542, + "text_total_tokens": 270126030 + } + ], + "models": [ + { + "model": "gpt-5.4", + "credits": 0.0, + "users": 1, + "threads": 13, + "turns": 13 + }, + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 90, + "turns": 829 + } + ] + }, + { + "date": "2026-08-11", + "totals": { + "users": 1, + "threads": 33, + "turns": 1155, + "credits": 38102.914300000004, + "uncached_text_input_tokens": 67455600, + "cached_text_input_tokens": 1923154304, + "text_output_tokens": 7508714, + "text_total_tokens": 1998118618 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 1122, + "credits": 36496.428, + "uncached_text_input_tokens": 63538094, + "cached_text_input_tokens": 1863936640, + "text_output_tokens": 7006611, + "text_total_tokens": 1934481345 + }, + { + "client_id": "CODEX_SERVICE_EXEC", + "users": 1, + "threads": 33, + "turns": 33, + "credits": 1606.4863, + "uncached_text_input_tokens": 3917506, + "cached_text_input_tokens": 59217664, + "text_output_tokens": 502103, + "text_total_tokens": 63637273 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 33, + "turns": 1155 + } + ] + }, + { + "date": "2026-08-12", + "totals": { + "users": 1, + "threads": 4, + "turns": 717, + "credits": 14218.8796, + "uncached_text_input_tokens": 27345642, + "cached_text_input_tokens": 680626688, + "text_output_tokens": 3057121, + "text_total_tokens": 711029451 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 713, + "credits": 13701.3825, + "uncached_text_input_tokens": 26550796, + "cached_text_input_tokens": 655646720, + "text_output_tokens": 2915932, + "text_total_tokens": 685113448 + }, + { + "client_id": "CODEX_SERVICE_EXEC", + "users": 1, + "threads": 4, + "turns": 4, + "credits": 517.4971, + "uncached_text_input_tokens": 794846, + "cached_text_input_tokens": 24979968, + "text_output_tokens": 141189, + "text_total_tokens": 25916003 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 4, + "turns": 717 + } + ] + }, + { + "date": "2026-08-13", + "totals": { + "users": 1, + "threads": 3, + "turns": 139, + "credits": 3536.3709, + "uncached_text_input_tokens": 7276246, + "cached_text_input_tokens": 173345792, + "text_output_tokens": 613357, + "text_total_tokens": 181235395 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 136, + "credits": 3486.478125, + "uncached_text_input_tokens": 7027287, + "cached_text_input_tokens": 172369920, + "text_output_tokens": 604591, + "text_total_tokens": 180001798 + }, + { + "client_id": "CODEX_SERVICE_EXEC", + "users": 1, + "threads": 3, + "turns": 3, + "credits": 49.892775, + "uncached_text_input_tokens": 248959, + "cached_text_input_tokens": 975872, + "text_output_tokens": 8766, + "text_total_tokens": 1233597 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 3, + "turns": 139 + } + ] + }, + { + "date": "2026-08-14", + "totals": { + "users": 1, + "threads": 0, + "turns": 70, + "credits": 2822.4968249999997, + "uncached_text_input_tokens": 5523999, + "cached_text_input_tokens": 142511616, + "text_output_tokens": 467469, + "text_total_tokens": 148503084 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 70, + "credits": 2822.4968249999997, + "uncached_text_input_tokens": 5523999, + "cached_text_input_tokens": 142511616, + "text_output_tokens": 467469, + "text_total_tokens": 148503084 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 0, + "turns": 70 + } + ] + }, + { + "date": "2026-08-15", + "totals": { + "users": 1, + "threads": 5, + "turns": 61, + "credits": 4605.482325, + "uncached_text_input_tokens": 7058219, + "cached_text_input_tokens": 251124736, + "text_output_tokens": 778861, + "text_total_tokens": 258961816 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 56, + "credits": 3925.3648, + "uncached_text_input_tokens": 5958100, + "cached_text_input_tokens": 215140864, + "text_output_tokens": 655122, + "text_total_tokens": 221754086 + }, + { + "client_id": "CODEX_SERVICE_EXEC", + "users": 1, + "threads": 5, + "turns": 5, + "credits": 680.117525, + "uncached_text_input_tokens": 1100119, + "cached_text_input_tokens": 35983872, + "text_output_tokens": 123739, + "text_total_tokens": 37207730 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 5, + "turns": 61 + } + ] + }, + { + "date": "2026-08-16", + "totals": { + "users": 1, + "threads": 1, + "turns": 16, + "credits": 1338.403975, + "uncached_text_input_tokens": 1856879, + "cached_text_input_tokens": 77852928, + "text_output_tokens": 177510, + "text_total_tokens": 79887317 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 1, + "turns": 16, + "credits": 1338.403975, + "uncached_text_input_tokens": 1856879, + "cached_text_input_tokens": 77852928, + "text_output_tokens": 177510, + "text_total_tokens": 79887317 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 1, + "turns": 16 + } + ] + }, + { + "date": "2026-08-22", + "totals": { + "users": 1, + "threads": 3, + "turns": 7, + "credits": 377.27466, + "uncached_text_input_tokens": 645739, + "cached_text_input_tokens": 26910976, + "text_output_tokens": 87182, + "text_total_tokens": 27643897 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 3, + "turns": 7, + "credits": 377.27466, + "uncached_text_input_tokens": 645739, + "cached_text_input_tokens": 26910976, + "text_output_tokens": 87182, + "text_total_tokens": 27643897 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 3, + "turns": 7 + } + ] + }, + { + "date": "2026-08-23", + "totals": { + "users": 1, + "threads": 0, + "turns": 10, + "credits": 1015.96072, + "uncached_text_input_tokens": 2155598, + "cached_text_input_tokens": 71739392, + "text_output_tokens": 166014, + "text_total_tokens": 74061004 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 10, + "credits": 1015.96072, + "uncached_text_input_tokens": 2155598, + "cached_text_input_tokens": 71739392, + "text_output_tokens": 166014, + "text_total_tokens": 74061004 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 0, + "turns": 10 + } + ] + }, + { + "date": "2026-08-24", + "totals": { + "users": 1, + "threads": 0, + "turns": 22, + "credits": 5295.98612, + "uncached_text_input_tokens": 9045420, + "cached_text_input_tokens": 395142912, + "text_output_tokens": 880030, + "text_total_tokens": 405068362 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 22, + "credits": 5295.98612, + "uncached_text_input_tokens": 9045420, + "cached_text_input_tokens": 395142912, + "text_output_tokens": 880030, + "text_total_tokens": 405068362 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 0, + "turns": 22 + } + ] + }, + { + "date": "2026-08-25", + "totals": { + "users": 1, + "threads": 2, + "turns": 52, + "credits": 6296.09972, + "uncached_text_input_tokens": 8352574, + "cached_text_input_tokens": 495282432, + "text_output_tokens": 1016036, + "text_total_tokens": 504651042 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 2, + "turns": 52, + "credits": 6296.09972, + "uncached_text_input_tokens": 8352574, + "cached_text_input_tokens": 495282432, + "text_output_tokens": 1016036, + "text_total_tokens": 504651042 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 2, + "turns": 52 + } + ] + }, + { + "date": "2026-08-26", + "totals": { + "users": 1, + "threads": 0, + "turns": 17, + "credits": 2820.57262, + "uncached_text_input_tokens": 4265811, + "cached_text_input_tokens": 217482752, + "text_output_tokens": 438328, + "text_total_tokens": 222186891 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 0, + "turns": 17, + "credits": 2820.57262, + "uncached_text_input_tokens": 4265811, + "cached_text_input_tokens": 217482752, + "text_output_tokens": 438328, + "text_total_tokens": 222186891 + } + ], + "models": [ + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 0, + "turns": 17 + } + ] + }, + { + "date": "2026-08-27", + "totals": { + "users": 1, + "threads": 4, + "turns": 61, + "credits": 35076.150324, + "uncached_text_input_tokens": 43399968, + "cached_text_input_tokens": 2827323392, + "text_output_tokens": 4927304, + "text_total_tokens": 2875650664 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 4, + "turns": 61, + "credits": 35076.150324, + "uncached_text_input_tokens": 43399968, + "cached_text_input_tokens": 2827323392, + "text_output_tokens": 4927304, + "text_total_tokens": 2875650664 + } + ], + "models": [ + { + "model": "gpt-5.6-luna", + "credits": 0.0, + "users": 1, + "threads": 2, + "turns": 2 + }, + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 2, + "turns": 59 + } + ] + }, + { + "date": "2026-08-28", + "totals": { + "users": 1, + "threads": 3, + "turns": 42, + "credits": 40617.895253999995, + "uncached_text_input_tokens": 50816369, + "cached_text_input_tokens": 3287721984, + "text_output_tokens": 5319439, + "text_total_tokens": 3343857792 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 1, + "threads": 3, + "turns": 42, + "credits": 40617.895253999995, + "uncached_text_input_tokens": 50816369, + "cached_text_input_tokens": 3287721984, + "text_output_tokens": 5319439, + "text_total_tokens": 3343857792 + } + ], + "models": [ + { + "model": "gpt-5.6-luna", + "credits": 0.0, + "users": 1, + "threads": 2, + "turns": 2 + }, + { + "model": "gpt-5.6-sol", + "credits": 0.0, + "users": 1, + "threads": 1, + "turns": 40 + } + ] + }, + { + "date": "2026-08-29", + "totals": { + "users": 0, + "threads": 0, + "turns": 0, + "credits": 15504.873192, + "uncached_text_input_tokens": 23112562, + "cached_text_input_tokens": 1218464768, + "text_output_tokens": 2018632, + "text_total_tokens": 1243595962 + }, + "clients": [ + { + "client_id": "CODEX_CLI", + "users": 0, + "threads": 0, + "turns": 0, + "credits": 15504.873192, + "uncached_text_input_tokens": 23112562, + "cached_text_input_tokens": 1218464768, + "text_output_tokens": 2018632, + "text_total_tokens": 1243595962 + } + ] + } + ], + "group_by": "day" +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/codex_reset_credits.json b/Tests/TokenMenuBarCoreTests/Fixtures/codex_reset_credits.json new file mode 100644 index 0000000..5ee4422 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/codex_reset_credits.json @@ -0,0 +1,7 @@ +{ + "credits": [], + "available_count": 0, + "total_earned_count": 0, + "immediate_reset_purchase_eligible": false, + "history_enabled": false +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/codex_usage.json b/Tests/TokenMenuBarCoreTests/Fixtures/codex_usage.json new file mode 100644 index 0000000..c5af564 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/codex_usage.json @@ -0,0 +1,64 @@ +{ + "user_id": "00000000-0000-4000-8000-000000000000", + "account_id": "00000000-0000-4000-8000-000000000000", + "email": "user@example.com", + "plan_type": "pro", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 62, + "limit_window_seconds": 604800, + "reset_after_seconds": 544337, + "reset_at": 1788558705 + }, + "secondary_window": null + }, + "code_review_rate_limit": null, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "codex_bengalfox", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 0, + "limit_window_seconds": 18000, + "reset_after_seconds": 18000, + "reset_at": 1788032369 + }, + "secondary_window": { + "used_percent": 0, + "limit_window_seconds": 604800, + "reset_after_seconds": 604800, + "reset_at": 1788619169 + } + } + } + ], + "credits": { + "has_credits": false, + "unlimited": false, + "overage_limit_reached": false, + "balance": "0", + "approx_local_messages": [ + 0, + 0 + ], + "approx_cloud_messages": [ + 0, + 0 + ] + }, + "spend_control": { + "reached": false, + "individual_limit": null + }, + "rate_limit_reached_type": null, + "promo": null, + "rate_limit_reset_credits": { + "available_count": 0, + "applicable_available_count": 0 + } +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/copilot_hosts.json b/Tests/TokenMenuBarCoreTests/Fixtures/copilot_hosts.json new file mode 100644 index 0000000..4e273f7 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/copilot_hosts.json @@ -0,0 +1,11 @@ +{ + "github.com": { + "user": "octocat", + "oauth_token": "gho_test_token_123", + "githubAppId": "Iv1.b507a08c87ecfe98" + }, + "ghe.example.com:Iv1.x": { + "user": "ent", + "oauth_token": "gho_ent" + } +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/copilot_user.json b/Tests/TokenMenuBarCoreTests/Fixtures/copilot_user.json new file mode 100644 index 0000000..a4f9475 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/copilot_user.json @@ -0,0 +1,40 @@ +{ + "access_type_sku": "copilot_pro_seat", + "analytics_tracking_id": "abc", + "assigned_date": "2024-11-01T00:00:00Z", + "can_signup_for_limited": false, + "chat_enabled": true, + "copilot_plan": "pro_plus", + "organization_login_list": [], + "quota_reset_date": "2026-09-01", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": -15, + "percent_remaining": -5, + "quota_id": "premium_interactions", + "quota_remaining": -15, + "overage_count": 15, + "overage_permitted": true, + "unlimited": false, + "credits_used": 31 + }, + "chat": { + "entitlement": 0, + "remaining": 0, + "unlimited": true + }, + "completions": { + "entitlement": "4000", + "remaining": "1000", + "unlimited": false, + "credits_used": "2" + }, + "zz_custom": { + "entitlement": 0, + "remaining": 0, + "unlimited": false + } + } +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/copilot_user_free.json b/Tests/TokenMenuBarCoreTests/Fixtures/copilot_user_free.json new file mode 100644 index 0000000..410a7a6 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/copilot_user_free.json @@ -0,0 +1,25 @@ +{ + "copilot_plan": "individual", + "access_type_sku": "free_limited_copilot", + "limited_user_quotas": { + "chat": 410, + "completions": 4000, + "other": 5 + }, + "monthly_quotas": { + "chat": 500, + "completions": 4000, + "other": 0 + }, + "limited_user_reset_date": "2026-09-11", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 50, + "remaining": 20, + "percent_remaining": 40, + "unlimited": false, + "overage_permitted": false, + "overage_count": 0 + } + } +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/cursor_me.json b/Tests/TokenMenuBarCoreTests/Fixtures/cursor_me.json new file mode 100644 index 0000000..513f03d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/cursor_me.json @@ -0,0 +1,7 @@ +{ + "email": "you@example.com", + "email_verified": true, + "name": "You", + "sub": "auth0|user_01ABC", + "picture": null +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/cursor_period_usage.json b/Tests/TokenMenuBarCoreTests/Fixtures/cursor_period_usage.json new file mode 100644 index 0000000..646a63e --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/cursor_period_usage.json @@ -0,0 +1,15 @@ +{ + "billingCycleStart": "2026-08-10T00:00:00.000Z", + "billingCycleEnd": "2026-09-10T00:00:00.000Z", + "planUsage": { + "limit": 5000, + "remaining": 2000, + "used": 3000, + "autoPercentUsed": 40, + "apiPercentUsed": 80 + }, + "spendLimitUsage": { + "limitType": "user" + }, + "displayMessage": "You have used 60% of your plan." +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/cursor_usage_summary.json b/Tests/TokenMenuBarCoreTests/Fixtures/cursor_usage_summary.json new file mode 100644 index 0000000..02e9a8e --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/cursor_usage_summary.json @@ -0,0 +1,49 @@ +{ + "billingCycleStart": "2026-08-10T00:00:00.000Z", + "billingCycleEnd": "2026-09-10T00:00:00.000Z", + "membershipType": "pro_plus", + "limitType": "user", + "isUnlimited": false, + "individualUsage": { + "plan": { + "enabled": true, + "used": 1500, + "limit": 5000, + "remaining": 3500, + "breakdown": { + "included": 0, + "bonus": 0, + "total": 0 + }, + "autoPercentUsed": 12.5, + "apiPercentUsed": 47.5, + "totalPercentUsed": 30.0 + }, + "onDemand": { + "enabled": true, + "used": 500, + "limit": 10000, + "remaining": 9500 + }, + "overall": { + "enabled": false, + "used": 7384, + "limit": 10000, + "remaining": 2616 + } + }, + "teamUsage": { + "onDemand": { + "enabled": true, + "used": 2000, + "limit": 50000, + "remaining": 48000 + }, + "pooled": { + "enabled": true, + "used": 250, + "limit": 1000, + "remaining": 750 + } + } +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/gemini_creds.json b/Tests/TokenMenuBarCoreTests/Fixtures/gemini_creds.json new file mode 100644 index 0000000..170bdfa --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/gemini_creds.json @@ -0,0 +1,7 @@ +{ + "access_token": "ya29.test", + "refresh_token": "1//0g-refresh", + "scope": "https://www.googleapis.com/auth/cloud-platform", + "token_type": "Bearer", + "expiry_date": 1788033600000 +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/gemini_load_code_assist.json b/Tests/TokenMenuBarCoreTests/Fixtures/gemini_load_code_assist.json new file mode 100644 index 0000000..0ba720a --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/gemini_load_code_assist.json @@ -0,0 +1,24 @@ +{ + "currentTier": { + "id": "standard-tier", + "name": "Gemini Code Assist Standard", + "availableCredits": [ + { + "creditType": "GOOGLE_ONE_AI", + "creditAmount": "1200" + } + ] + }, + "allowedTiers": [], + "cloudaicompanionProject": "gen-lang-client-0123456789", + "paidTier": { + "id": "paid", + "name": "Google AI Pro", + "availableCredits": [ + { + "creditType": "GOOGLE_ONE_AI", + "creditAmount": "300" + } + ] + } +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/gemini_quota.json b/Tests/TokenMenuBarCoreTests/Fixtures/gemini_quota.json new file mode 100644 index 0000000..bcb97e2 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/gemini_quota.json @@ -0,0 +1,31 @@ +{ + "buckets": [ + { + "modelId": "gemini-2.5-pro", + "tokenType": "REQUESTS", + "remainingFraction": 0.75, + "remainingAmount": "150", + "resetTime": "2026-08-30T07:00:00Z" + }, + { + "modelId": "gemini-2.5-pro", + "tokenType": "TOKENS", + "remainingFraction": 0.4, + "resetTime": "2026-08-30T07:00:00Z" + }, + { + "modelId": "gemini-2.5-flash", + "tokenType": "REQUESTS", + "remainingFraction": 0.9, + "resetTime": "2026-08-30T07:00:00Z" + }, + { + "modelId": null, + "remainingFraction": 0.1 + }, + { + "modelId": "gemini-2.5-flash-lite", + "tokenType": "REQUESTS" + } + ] +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/gemini_unsupported.json b/Tests/TokenMenuBarCoreTests/Fixtures/gemini_unsupported.json new file mode 100644 index 0000000..fcc1d40 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/gemini_unsupported.json @@ -0,0 +1,13 @@ +{ + "ineligibleTiers": [ + { + "reasonCode": "UNSUPPORTED_CLIENT", + "reasonMessage": "Login with Google is no longer supported; migrate to Antigravity.", + "tierId": "free-tier", + "tierName": "Free" + } + ], + "cloudaicompanionProject": { + "id": "projects/legacy-123" + } +} diff --git a/Tests/TokenMenuBarCoreTests/FormatTests.swift b/Tests/TokenMenuBarCoreTests/FormatTests.swift new file mode 100644 index 0000000..9bb7a6e --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/FormatTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +import TokenMenuBarCore + +private let percentCases: [(Double, Int, String)] = [ + (36.0, 0, "36%"), (36.46, 1, "36.5%"), (-5.0, 0, "0%"), (250.0, 0, "100%"), +] + +@Test(arguments: percentCases) +func formatPercentClampsAndRounds(value: Double, decimals: Int, expected: String) { + #expect(Format.percent(value, decimals: decimals) == expected) +} + +private let countdownCases: [(TimeInterval?, String, String)] = [ + (nil, "—", "--"), + (-10, "reset due", "0m"), + (30, "< 1 min", "0m"), + (300, "5 min", "5m"), + (15_840, "4 hr 24 min", "4h24m"), + (273_900, "3d 4h", "3d4h"), +] + +@Test(arguments: countdownCases) +func formatCountdowns(offset: TimeInterval?, long: String, compact: String) { + let date = offset.map { fixedNow.addingTimeInterval($0) } + #expect(Format.countdown(to: date, now: fixedNow) == long) + #expect(Format.compactCountdown(to: date, now: fixedNow) == compact) +} + +@Test func formatResetClockPicksGranularity() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + #expect(Format.resetClock(nil, now: fixedNow) == "—") + #expect(Format.resetClock(fixedNow.addingTimeInterval(-1), now: fixedNow) == "now") + let sameDay = Format.resetClock(fixedNow.addingTimeInterval(3600), now: fixedNow, calendar: calendar) + #expect(!sameDay.isEmpty && !sameDay.contains(",")) + let thisWeek = Format.resetClock(fixedNow.addingTimeInterval(3 * 86400), now: fixedNow, calendar: calendar) + #expect(thisWeek.count > sameDay.count) + #expect(Format.resetClock(fixedNow.addingTimeInterval(20 * 86400), now: fixedNow, calendar: calendar).contains("Sep")) +} + +private let compactCases: [(Double, String)] = [ + (999.0, "999"), (1500.0, "1.5K"), (23_112_562.0, "23M"), (1_243_595_962.0, "1.2B"), (-2500.0, "-2.5K"), +] + +@Test(arguments: compactCases) +func formatCompactNumbers(value: Double, expected: String) { + #expect(Format.compactNumber(value) == expected) +} + +private let durationCases: [(TimeInterval, String, String)] = [ + (18000, "5h", "5-hour"), (604_800, "7d", "Weekly"), (86400, "24h", "Daily"), (2_592_000, "30d", "Monthly"), + (900, "15m", "15m"), (7200, "2h", "2h"), +] + +@Test(arguments: durationCases) +func formatDurations(seconds: TimeInterval, duration: String, label: String) { + #expect(Format.duration(seconds) == duration) + #expect(Format.windowLabel(seconds: seconds) == label) +} + +@Test func formatHumanizeAndSlug() { + #expect(Format.humanize("seven_day_oauth-apps") == "Seven Day Oauth Apps") + #expect(Format.slug("GPT-5.3-Codex Spark!") == "gpt-5-3-codex-spark") +} + +private let ageCases: [(TimeInterval?, String)] = [ + (nil, "never"), (2, "just now"), (30, "30s ago"), (600, "10 min ago"), (7200, "2 hr ago"), (172_800, "2 d ago"), + (-60, "just now"), +] + +@Test(arguments: ageCases) +func formatRelativeAge(offset: TimeInterval?, expected: String) { + #expect(Format.relativeAge(offset.map { fixedNow.addingTimeInterval(-$0) }, now: fixedNow) == expected) +} + +@Test func moneyFormatsWithExponent() { + let money = Money(amountMinor: 2445, currency: "EUR") + #expect(money.amount == Decimal(string: "24.45")) + #expect(money.formatted.contains("24.45")) + #expect(Money(amountMinor: 5, currency: "USD", exponent: 0).amount == 5) +} + +@Test func creditBalanceFormatting() { + #expect(CreditBalance(balance: nil).formattedBalance == "—") + #expect(CreditBalance(balance: 12.5, currency: "USD").formattedBalance.contains("12.50")) + #expect(CreditBalance(balance: 12.5).formattedBalance == "12.5") +} diff --git a/Tests/TokenMenuBarCoreTests/GeminiTests.swift b/Tests/TokenMenuBarCoreTests/GeminiTests.swift new file mode 100644 index 0000000..8fd6f42 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/GeminiTests.swift @@ -0,0 +1,344 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func geminiAuthParsesDocumentAndClaims() { + let auth = GeminiAuth(document: Fixtures.json("gemini_creds"))! + #expect(auth.accessToken == "ya29.test") + #expect(auth.refreshToken == "1//0g-refresh") + #expect(auth.expiresAt == Date(timeIntervalSince1970: 1_788_033_600)) + #expect(auth.email == nil) + #expect(GeminiAuth(document: .object([:])) == nil) + let workspace = GeminiAuth(accessToken: "t", idToken: idToken(email: "a@corp.example", hd: "corp.example")) + #expect(workspace.email == "a@corp.example") + #expect(workspace.hostedDomain == "corp.example") + #expect(workspace.expiresAt == nil) + #expect(workspace.state(now: fixedNow) == .valid(expiresAt: nil)) + #expect(validGemini.state(now: fixedNow.addingTimeInterval(3600)) == .expired(validGemini.expiresAt!)) + let refreshed = validGemini.refreshed(accessToken: "new", expiresIn: 60, idToken: "id2", now: fixedNow) + #expect(refreshed.accessToken == "new") + #expect(refreshed.idToken == "id2") + #expect(refreshed.expiresAt == fixedNow.addingTimeInterval(60)) + #expect(refreshed.refreshToken == "1//refresh") + #expect( + validGemini.refreshed(accessToken: "n", expiresIn: 1, idToken: nil, now: fixedNow).idToken == validGemini.idToken) +} + +private func idToken(email: String, hd: String? = nil) -> String { + var claims: [String: JSONValue] = ["email": .string(email), "exp": .number(1_900_000_000)] + claims["hd"] = hd.map(JSONValue.string) + let payload = try! JSONEncoder().encode(JSONValue.object(claims)).base64EncodedString() + .replacingOccurrences(of: "=", with: "") + return "eyJhbGciOiJIUzI1NiJ9.\(payload).sig" +} + +private let validGemini = GeminiAuth( + accessToken: "ya29.valid", refreshToken: "1//refresh", idToken: idToken(email: "you@example.com"), + expiresAt: fixedNow.addingTimeInterval(3600)) + +@Test func geminiFileStoreRoundTripsAndValidates() throws { + let root = temporaryDirectory() + let url = root.appendingPathComponent("oauth_creds.json") + let store = FileGeminiAuthStore(url: url) + #expect(store.description == url.path) + #expect(try store.load() == nil) + try store.save(validGemini) + #expect(try store.load() == validGemini) + try Data("nope".utf8).write(to: url) + #expect(throws: CredentialStoreError.self) { try store.load() } + #expect(FileGeminiAuthStore.defaultURL(environment: [:], home: root).path == root.path + "/.gemini/oauth_creds.json") + #expect( + FileGeminiAuthStore.defaultURL(environment: ["GEMINI_CLI_HOME": "/custom"], home: root).path + == "/custom/.gemini/oauth_creds.json") +} + +@MainActor +private func geminiSnapshot( + assist: StubTransport.Response, quota: StubTransport.Response, auth: GeminiAuth = validGemini +) + async -> ProviderSnapshot? +{ + let (provider, transport, _) = makeProvider(auth) + transport.on(path: ":loadCodeAssist", assist) + transport.on(path: ":retrieveUserQuota", quota) + guard case .success(let snapshot) = await provider.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected a snapshot") + return nil + } + return snapshot +} + +@Test func geminiReportsOneWindowPerModelQuota() async throws { + let snapshot = try #require( + await geminiSnapshot(assist: .json("gemini_load_code_assist"), quota: .json("gemini_quota"))) + #expect(snapshot.windows.map(\.id) == ["model:gemini-2.5-flash", "model:gemini-2.5-pro"]) + #expect(snapshot.windows.first { $0.id == "model:gemini-2.5-pro" }?.usedPercent == 60) + #expect(snapshot.windows.first { $0.id == "model:gemini-2.5-flash" }?.label == "Gemini 2.5 Flash") + #expect(snapshot.windows.allSatisfy { $0.resetsAt == ISODate.parse("2026-08-30T07:00:00Z") && $0.duration == 86400 }) +} + +@Test func geminiReportsTheTierAndItsCredits() async throws { + let snapshot = try #require( + await geminiSnapshot(assist: .json("gemini_load_code_assist"), quota: .json("gemini_quota"))) + #expect(snapshot.identity?.planName == "Google AI Pro") + #expect(snapshot.identity?.email == "you@example.com") + #expect(snapshot.identity?.tier == "standard-tier") + #expect(snapshot.credits?.balance == 1500) +} + +@Test( + arguments: [ + ("standard-tier", nil as String?, "Standard"), ("legacy-tier", nil, "Legacy"), ("free-tier", nil, "Free"), + ("free-tier", "corp.example", "Workspace"), ("other", nil, "Other tier"), + ]) +func geminiNamesThePlanFromTheTier(tier: String, hosted: String?, expected: String) async throws { + let assist = #""" + {"currentTier": {"id": "\#(tier)", "name": "Other tier"}, + "cloudaicompanionProject": {"id": "projects/p"}} + """# + let auth = GeminiAuth( + accessToken: "ya29.valid", refreshToken: "1//refresh", + idToken: idToken(email: "you@example.com", hd: hosted), expiresAt: fixedNow.addingTimeInterval(3600)) + let snapshot = try #require( + await geminiSnapshot(assist: .text(assist), quota: .json("gemini_quota"), auth: auth)) + #expect(snapshot.identity?.planName == expected) +} + +@Test func geminiNamesThePlanGenericallyWithoutATier() async throws { + let snapshot = try #require( + await geminiSnapshot( + assist: .text(#"{"cloudaicompanionProject": {"id": "projects/p"}}"#), + quota: .json("gemini_quota"))) + #expect(snapshot.identity?.planName == "Gemini") + #expect(snapshot.credits == nil) +} + +@Test func geminiProviderFetchesQuota() async throws { + let (provider, transport, store) = makeProvider(validGemini) + transport.on(path: ":loadCodeAssist", .json("gemini_load_code_assist")) + transport.on(path: ":retrieveUserQuota", .json("gemini_quota")) + #expect(provider.credentialDescription == "memory") + #expect(provider.credentialState(now: fixedNow) == .valid(expiresAt: validGemini.expiresAt)) + #expect( + await provider.credentialHealth(now: fixedNow) + == .valid(source: store.source, expiresAt: validGemini.expiresAt)) + let readsBeforeFetch = store.readCount + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(store.readCount == readsBeforeFetch + 1) + #expect( + result.credentialStatus + == ProviderCredentialStatus( + state: .valid(expiresAt: validGemini.expiresAt), + health: .valid(source: store.source, expiresAt: validGemini.expiresAt))) + guard case .success(let snapshot) = result.outcome else { + Issue.record("expected success") + return + } + #expect(snapshot.windows.count == 2) + #expect(snapshot.identity?.planName == "Google AI Pro") + #expect(snapshot.credits?.balance == 1500) + let quotaRequest = transport.requests(matching: ":retrieveUserQuota").first! + #expect(String(decoding: quotaRequest.httpBody!, as: UTF8.self).contains("gen-lang-client")) + #expect(quotaRequest.value(forHTTPHeaderField: "Authorization") == "Bearer ya29.valid") + _ = await provider.fetch(now: fixedNow.addingTimeInterval(10), options: FetchOptions()) + #expect(transport.requests(matching: ":loadCodeAssist").count == 1) + try store.save( + GeminiAuth( + accessToken: "other", refreshToken: "1//other", idToken: idToken(email: "other@example.com"), + expiresAt: fixedNow.addingTimeInterval(3600))) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(20), options: FetchOptions()) + #expect(transport.requests(matching: ":loadCodeAssist").count == 2) +} + +private func makeProvider( + _ auth: GeminiAuth?, allowRefresh: Bool = false, store: MemoryGeminiStore? = nil, + oauth: GeminiOAuthClient? = testOAuth +) -> (GeminiProvider, StubTransport, MemoryGeminiStore) { + let transport = StubTransport() + let store = store ?? MemoryGeminiStore(auth) + let provider = GeminiProvider( + auth: store, client: APIClient(transport: transport, log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { allowRefresh }, oauthClient: { oauth }) + return (provider, transport, store) +} + +@Test func geminiProviderHandlesMissingExpiredAndErrors() async { + let (missing, _, _) = makeProvider(nil) + #expect(missing.credentialState(now: fixedNow) == .missing("no Gemini CLI sign-in found")) + guard case .notAuthenticated = await missing.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected notAuthenticated") + return + } + let broken = MemoryGeminiStore(validGemini) + broken.loadError = TestError() + let (failing, _, _) = makeProvider(nil, store: broken) + #expect(failing.credentialState(now: fixedNow).isMissing) + guard case .notAuthenticated(let reason) = await failing.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected notAuthenticated") + return + } + #expect(reason.contains("Cannot read")) + let (expired, _, _) = makeProvider(validGemini) + guard + case .notAuthenticated(let expiredReason) = await expired.fetch( + now: fixedNow.addingTimeInterval(7200), options: FetchOptions() + ).outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(expiredReason.contains("expired")) +} + +@Test func geminiProviderReportsUnsupportedAccounts() async { + let (provider, transport, _) = makeProvider(validGemini) + transport.on(path: ":loadCodeAssist", .json("gemini_unsupported")) + guard case .notAuthenticated(let reason) = await provider.fetch(now: fixedNow, options: FetchOptions()).outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(reason.contains("Antigravity")) + let (subscription, transport2, _) = makeProvider(validGemini) + transport2.on(path: ":loadCodeAssist", .text("boom", status: 500)) + transport2.on(path: ":retrieveUserQuota", .text(#"{"error":{"status":"SUBSCRIPTION_REQUIRED"}}"#, status: 403)) + let result = await subscription.fetch(now: fixedNow, options: FetchOptions()) + guard case .notAuthenticated(let subscriptionReason) = result.outcome else { + Issue.record("expected notAuthenticated") + return + } + #expect(subscriptionReason.contains("Login with Google")) +} + +@Test func geminiProviderPropagatesHTTPFailures() async { + let (authFailure, transport, _) = makeProvider(validGemini) + transport.on(path: ":loadCodeAssist", .text("denied", status: 401)) + guard case .notAuthenticated = await authFailure.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected notAuthenticated") + return + } + let (partial, transport2, _) = makeProvider(validGemini) + transport2.on(path: ":loadCodeAssist", .text("boom", status: 500)) + transport2.on(path: ":retrieveUserQuota", .json("gemini_quota")) + let result = await partial.fetch(now: fixedNow, options: FetchOptions()) + #expect(result.warnings.first?.contains("Plan details unavailable") == true) + #expect(result.outcome.snapshot?.identity?.planName == "Gemini") + let (quotaFailure, transport3, _) = makeProvider(validGemini) + transport3.on(path: ":loadCodeAssist", .json("gemini_load_code_assist")) + transport3.on(path: ":retrieveUserQuota", .text("slow", status: 429, headers: ["Retry-After": "30"])) + #expect( + await quotaFailure.fetch(now: fixedNow, options: FetchOptions()).outcome + == .rateLimited("HTTP 429", retryAfter: 30)) + let (offline, transport4, _) = makeProvider(validGemini) + transport4.on(path: ":loadCodeAssist", error: URLError(.notConnectedToInternet)) + transport4.on(path: ":retrieveUserQuota", error: URLError(.notConnectedToInternet)) + guard case .networkUnavailable = await offline.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected networkUnavailable") + return + } +} + +@Test func geminiProviderRefreshesExpiredTokens() async { + let expired = GeminiAuth( + accessToken: "old", refreshToken: "1//refresh", idToken: nil, expiresAt: fixedNow.addingTimeInterval(-10)) + let (provider, transport, store) = makeProvider(expired, allowRefresh: true) + transport.on(path: "/token", .text(#"{"access_token":"fresh","expires_in":1800,"id_token":"id"}"#)) + transport.on(path: ":loadCodeAssist", .json("gemini_load_code_assist")) + transport.on(path: ":retrieveUserQuota", .json("gemini_quota")) + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(store.saved.first?.accessToken == "fresh") + let body = String(decoding: transport.requests(matching: "/token").first!.httpBody!, as: UTF8.self) + #expect(body.contains("grant_type=refresh_token") && body.contains("refresh_token=1//refresh")) + #expect(body.contains("client_id=test-client.apps.googleusercontent.com")) + #expect( + transport.requests(matching: "/token").first?.value(forHTTPHeaderField: "Content-Type") + == "application/x-www-form-urlencoded") +} + +@Test func geminiProviderReportsRefreshFailures() async { + let expired = GeminiAuth(accessToken: "old", refreshToken: nil, expiresAt: fixedNow.addingTimeInterval(-10)) + let (noToken, _, _) = makeProvider(expired, allowRefresh: true) + guard case .notAuthenticated(let reason) = await noToken.fetch(now: fixedNow, options: FetchOptions()).outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(reason.contains("no refresh token")) + let withToken = GeminiAuth(accessToken: "old", refreshToken: "r", expiresAt: fixedNow.addingTimeInterval(-10)) + let (rejected, transport, _) = makeProvider(withToken, allowRefresh: true) + transport.on(path: "/token", .text(#"{"error":"invalid_grant","error_description":"Token has been revoked."}"#)) + guard case .notAuthenticated(let revoked) = await rejected.fetch(now: fixedNow, options: FetchOptions()).outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(revoked == "Gemini token refresh failed: HTTP 401") + let store = MemoryGeminiStore(withToken) + store.saveError = TestError() + let (unsaved, transport2, _) = makeProvider(nil, allowRefresh: true, store: store) + transport2.on(path: "/token", .text(#"{"access_token":"fresh","expires_in":1800}"#)) + transport2.on(path: ":loadCodeAssist", .json("gemini_load_code_assist")) + transport2.on(path: ":retrieveUserQuota", .json("gemini_quota")) + let result = await unsaved.fetch(now: fixedNow, options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(result.recoveryIssue?.kind == .credentialPersistence) + #expect(store.saved.isEmpty) + #expect(await unsaved.credentialHealth(now: fixedNow).isUsable) +} + +@Test func geminiOAuthClientComesFromTheEnvironmentOrTheInstalledCLI() { + let home = URL(fileURLWithPath: "/Users/tester") + let fromEnvironment = GeminiOAuthConfig.resolve( + environment: ["GEMINI_OAUTH_CLIENT_ID": "env-id", "GEMINI_OAUTH_CLIENT_SECRET": "env-secret"], home: home, + read: { _ in nil }) + #expect(fromEnvironment == GeminiOAuthClient(id: "env-id", secret: "env-secret")) + let source = """ + const OAUTH_CLIENT_ID = '123-abc.apps.googleusercontent.com'; + const OAUTH_CLIENT_SECRET = 'SECRET-value'; + """ + #expect( + GeminiOAuthConfig.extract(from: source) + == GeminiOAuthClient(id: "123-abc.apps.googleusercontent.com", secret: "SECRET-value")) + #expect(GeminiOAuthConfig.extract(from: "const OAUTH_CLIENT_ID = 'only-id';") == nil) + var probed: [String] = [] + let found = GeminiOAuthConfig.resolve( + environment: [:], home: home, + read: { url in + probed.append(url.path) + return url.path.hasSuffix(GeminiOAuthConfig.relativePaths[1]) ? source : nil + }) + #expect(found?.id == "123-abc.apps.googleusercontent.com") + #expect(probed.contains { $0.hasPrefix("/Users/tester/.npm-global") }) + #expect(GeminiOAuthConfig.resolve(environment: [:], home: home, read: { _ in nil }) == nil) + #expect( + GeminiOAuthConfig.searchRoots(environment: ["NPM_CONFIG_PREFIX": "/custom"], home: home).first?.path == "/custom") + // the default reader hits the filesystem; on a machine without the CLI it simply finds nothing + _ = GeminiOAuthConfig.resolve(environment: [:], home: home) +} + +@Test func geminiRefreshNeedsTheCLIOAuthClient() async { + let expired = GeminiAuth(accessToken: "old", refreshToken: "r", expiresAt: fixedNow.addingTimeInterval(-10)) + let (provider, _, _) = makeProvider(expired, allowRefresh: true, oauth: nil) + guard case .notAuthenticated(let reason) = await provider.fetch(now: fixedNow, options: FetchOptions()).outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(reason.contains("OAuth client could not be read")) +} + +@Test func geminiResolvesItsOAuthClientFromTheInstalledCLIByDefault() async { + let stale = GeminiAuth(accessToken: "old", refreshToken: "r", expiresAt: fixedNow.addingTimeInterval(-10)) + let provider = GeminiProvider( + auth: MemoryGeminiStore(stale), client: APIClient(transport: StubTransport(), log: makeLog(), clock: testClock), + log: makeLog(), allowRefresh: { true }) + guard case .notAuthenticated(let reason) = await provider.fetch(now: fixedNow, options: FetchOptions()).outcome + else { + Issue.record("expected notAuthenticated") + return + } + #expect(reason.contains("refresh failed")) +} + +private let testOAuth = GeminiOAuthClient(id: "test-client.apps.googleusercontent.com", secret: "test-secret") diff --git a/Tests/TokenMenuBarCoreTests/HistoryClosureBehaviorCoverageTests.swift b/Tests/TokenMenuBarCoreTests/HistoryClosureBehaviorCoverageTests.swift new file mode 100644 index 0000000..b6bd232 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/HistoryClosureBehaviorCoverageTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func chartCarriesAFreshSampleIntoAnEmptyViewport() throws { + let start = historyClosureNow.addingTimeInterval(-600) + let end = start.addingTimeInterval(60) + let key = WindowKey(provider: .claude, windowID: "session") + let data = ChartPipeline.render( + samples: [ + UsageSample(timestamp: start.addingTimeInterval(-60), key: key, usedPercent: 42, resetsAt: nil) + ], + request: HistoryRequest(keys: [key], start: start, end: end, rollup: .minute), labels: [:], now: end) + + let series = try #require(data.series.first) + #expect(series.points.first?.date == start) + #expect(series.points.first?.value == 42) + #expect(data.dataPointCount == 0) +} + +@Test @MainActor func historyPresenterReportsStackingOnlyAfterMultipleSeriesLoad() async throws { + let settings = historyClosureSettings() + let history = try UsageHistoryStore(url: nil) + let presenter = HistoryPresenter( + history: history, settings: settings, clock: .fixed(historyClosureNow), + initialMetric: .analytics(.inputTokens)) + + #expect(!presenter.canStack) + try await historyClosureRecordStackableAnalytics(in: history) + presenter.reload() + await presenter.waitForLoad() + + #expect(presenter.canStack) +} + +@Test @MainActor func historyPresenterRedrawsStackableDataAsAStack() async throws { + let (presenter, settings) = try await historyClosureLoadedStackingPresenter() + let unstackedMaximum = try #require(presenter.state.data?.yMax) + + presenter.setStacked(true) + await presenter.waitForLoad() + + #expect(settings.historyStacked) + #expect(try #require(presenter.state.data?.yMax) > unstackedMaximum) +} + +@Test @MainActor func historyPresenterCanIsolateTheFirstAnalyticsSeries() async throws { + let (presenter, _) = try await historyClosureLoadedStackingPresenter() + let id = try #require(presenter.state.data?.series.first?.id) + + presenter.isolate(id) + await presenter.waitForLoad() + + #expect(presenter.state.data?.visibleSeries.map(\.id) == [id]) +} + +@Test @MainActor func historyPresenterResetRejectsAnInvalidStoredMetric() async throws { + let settings = historyClosureSettings() + settings.historyMetricID = "invalid" + let presenter = HistoryPresenter( + history: try UsageHistoryStore(url: nil), settings: settings, clock: .fixed(historyClosureNow), + initialMetric: .analytics(.turns)) + + presenter.reset() + await presenter.waitForLoad() + + #expect(presenter.selectedMetric == .windowUsagePercent) +} + +@Test @MainActor func historyPresenterChangesMetricWhileInitiallyPinned() async throws { + let presenter = HistoryPresenter( + history: try UsageHistoryStore(url: nil), settings: historyClosureSettings(), clock: .fixed(historyClosureNow)) + presenter.followNow = false + + presenter.setMetric(.analytics(.turns)) + await presenter.waitForLoad() + + #expect(presenter.state.data?.metric == .analytics(.turns)) + #expect(!presenter.followNow) +} + +private let historyClosureNow = Date(timeIntervalSince1970: 1_788_030_000) + +@MainActor +private func historyClosureSettings() -> Settings { + Settings(defaults: UserDefaults(suiteName: "history-closure-\(UUID().uuidString)")!) +} + +private func historyClosureRecordStackableAnalytics(in history: UsageHistoryStore) async throws { + try await history.record( + ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: DayStamp.string(historyClosureNow), metric: .inputTokens, series: "model:a", value: 20), + AnalyticsPoint(day: DayStamp.string(historyClosureNow), metric: .inputTokens, series: "model:b", value: 30), + ], fetchedAt: historyClosureNow)) +} + +@MainActor +private func historyClosureLoadedStackingPresenter() async throws -> (HistoryPresenter, Settings) { + let settings = historyClosureSettings() + let history = try UsageHistoryStore(url: nil) + try await historyClosureRecordStackableAnalytics(in: history) + let presenter = HistoryPresenter( + history: history, settings: settings, clock: .fixed(historyClosureNow), + initialMetric: .analytics(.inputTokens)) + presenter.reload() + await presenter.waitForLoad() + return (presenter, settings) +} diff --git a/Tests/TokenMenuBarCoreTests/HistoryCoverageBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/HistoryCoverageBehaviorTests.swift new file mode 100644 index 0000000..353b3d0 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/HistoryCoverageBehaviorTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func canonicalAnalyticsTotalUsesOneBreakdownPerDay() { + let points = [ + AnalyticsPoint(day: "2026-08-01", metric: .turns, series: "total", value: 8), + AnalyticsPoint(day: "2026-08-01", metric: .turns, series: "model:gpt-5", value: 8), + AnalyticsPoint(day: "2026-08-02", metric: .turns, series: "surface:cli", value: 5), + AnalyticsPoint(day: "2026-08-02", metric: .turns, series: "model:gpt-5", value: 5), + ] + + #expect(ChartPipeline.canonicalTotal(points, metric: .analytics(.turns)) == 13) +} + +@Test func lastUsageDatesObservesCancellationBeforeQuery() async throws { + let store = try UsageHistoryStore(url: nil) + let (stream, continuation) = AsyncStream.makeStream() + let task = Task { + for await _ in stream.prefix(1) {} + return try await store.lastUsageDates( + keys: [WindowKey(provider: .codex, windowID: "weekly")], from: .distantPast, to: .distantFuture) + } + + task.cancel() + continuation.finish() + + do { + _ = try await task.value + Issue.record("expected cancellation") + } catch is CancellationError { + return + } catch { + Issue.record("expected CancellationError, got \(error)") + } +} + +@Test func resolvedLabelsRejectAnOverrideThatHidesAnotherDefault() throws { + let claude = historyCoverageWindow("session", label: "Session") + let codex = historyCoverageWindow("weekly", label: "Weekly") + let gemini = historyCoverageWindow("monthly", label: "Monthly") + let claudeKey = WindowKey(.claude, claude) + let codexKey = WindowKey(.codex, codex) + let geminiKey = WindowKey(.gemini, gemini) + let windows = [claudeKey: claude, codexKey: codex, geminiKey: gemini] + let defaults = ShortLabelPolicy.derivedLabels(windows: windows) + let codexDefault = try #require(defaults[codexKey]) + let overrides = [claudeKey: codexDefault] + + #expect(ShortLabelPolicy.resolvedLabels(windows: windows, overrides: overrides) == defaults) + #expect( + ShortLabelPolicy.conflictingKey( + codexDefault, for: geminiKey, windows: windows, overrides: overrides) == claudeKey) +} + +@Test func derivedLabelsAdvancePastAnOccupiedSuffix() { + let windows = ["alpha", "beta", "gamma"].map { + historyCoverageWindow("additional:spark-\($0)", label: "Spark \($0)") + } + let keyed = Dictionary(uniqueKeysWithValues: windows.map { (WindowKey(.codex, $0), $0) }) + + #expect(Set(ShortLabelPolicy.derivedLabels(windows: keyed).values) == ["SPK", "SPK2", "SPK3"]) +} + +@Test func providerPresentationHandlesMissingSnapshotIdentity() { + let presentation = SettingsProviderPresentation( + state: ProviderState(serviceHealth: .available), now: Date(timeIntervalSince1970: 1_788_030_000)) + + #expect(presentation.identity == nil) + #expect(presentation.lastSuccess == "No successful refresh") + #expect(presentation.service == "Service available") +} + +private func historyCoverageWindow(_ id: String, label: String) -> QuotaWindow { + QuotaWindow(id: id, label: label, group: .other, usedPercent: 20, resetsAt: nil) +} diff --git a/Tests/TokenMenuBarCoreTests/HistoryPresenterAnalyticsTests.swift b/Tests/TokenMenuBarCoreTests/HistoryPresenterAnalyticsTests.swift new file mode 100644 index 0000000..d0c85a8 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/HistoryPresenterAnalyticsTests.swift @@ -0,0 +1,185 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test(arguments: HistoryRange.allCases) +@MainActor func historyPresenterLoadsCodexOnlyAnalyticsForEachRange(_ range: HistoryRange) async throws { + let settings = Settings(defaults: UserDefaults(suiteName: "history-analytics-\(UUID().uuidString)")!) + settings.historyRange = range + let history = try UsageHistoryStore(url: nil) + let presenter = HistoryPresenter(history: history, settings: settings, clock: testClock) + try await history.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .turns, series: "codex", value: 3)], + fetchedAt: fixedNow)) + + presenter.setMetric(.analytics(.turns)) + await presenter.waitForLoad() + + let data = try #require(presenter.state.data) + #expect(data.metric == .analytics(.turns)) + #expect(data.series.map(\.id.provider) == [.codex]) + #expect(data.series.flatMap(\.points).map(\.value) == [3]) +} + +@Test @MainActor func historyPresenterLimitsAnalyticsToActiveProviders() async throws { + let settings = Settings(defaults: UserDefaults(suiteName: "history-active-providers-\(UUID().uuidString)")!) + let history = try UsageHistoryStore(url: nil) + let presenter = HistoryPresenter( + history: history, settings: settings, clock: testClock, initialMetric: .analytics(.inputTokens)) + let day = DayStamp.string(fixedNow) + try await history.record( + ProviderAnalytics( + provider: .claude, + points: [AnalyticsPoint(day: day, metric: .inputTokens, series: "opus", value: 2)], fetchedAt: fixedNow)) + try await history.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: day, metric: .inputTokens, series: "total", value: 3)], fetchedAt: fixedNow)) + + presenter.setDataScope(HistoryDataScope(activeProviders: [.codex])) + await presenter.waitForLoad() + + #expect(presenter.state.data?.series.map(\.id.provider) == [.codex]) +} + +@Test @MainActor func historyPresenterPagesThroughExactNonoverlappingUTCDays() async throws { + let settings = Settings(defaults: UserDefaults(suiteName: "history-pages-\(UUID().uuidString)")!) + settings.historyRange = .week + let history = try UsageHistoryStore(url: nil) + let presenter = HistoryPresenter( + history: history, settings: settings, clock: testClock, initialMetric: .analytics(.inputTokens)) + let points = (0..<14).map { offset in + AnalyticsPoint( + day: DayStamp.string(fixedNow.addingTimeInterval(-Double(offset) * 86400)), metric: .inputTokens, + series: "total", value: Double(offset + 1)) + } + try await history.record(ProviderAnalytics(provider: .codex, points: points, fetchedAt: fixedNow)) + + presenter.reload() + await presenter.waitForLoad() + let current = try #require(presenter.state.data?.series.first).points.map { DayStamp.string($0.date) } + #expect(current.count == 7) + #expect(Set(current) == Set((0..<7).map { DayStamp.string(fixedNow.addingTimeInterval(-Double($0) * 86400)) })) + let currentURL = temporaryDirectory().appendingPathComponent("current.csv") + await presenter.exportCSV(to: currentURL).value + let currentExport = try exportedDays(from: currentURL) + #expect(currentExport == Set(current)) + + presenter.page(forward: false, now: fixedNow) + await presenter.waitForLoad() + let previous = try #require(presenter.state.data?.series.first).points.map { DayStamp.string($0.date) } + #expect(previous.count == 7) + #expect(Set(previous).isDisjoint(with: current)) + let previousURL = temporaryDirectory().appendingPathComponent("previous.csv") + await presenter.exportCSV(to: previousURL).value + let previousExport = try exportedDays(from: previousURL) + #expect(previousExport == Set(previous)) + #expect(previousExport.isDisjoint(with: currentExport)) +} + +private func exportedDays(from url: URL) throws -> Set { + Set( + try String(contentsOf: url, encoding: .utf8).split(separator: "\n").dropFirst().compactMap { + $0.split(separator: ",").dropFirst().first.map(String.init) + }) +} + +@Test @MainActor func historyPresenterUsesTheSelectedAnalyticsMetricForNavigation() async throws { + let settings = Settings(defaults: UserDefaults(suiteName: "history-earliest-\(UUID().uuidString)")!) + settings.historyRange = .today + let history = try UsageHistoryStore(url: nil) + let presenter = HistoryPresenter( + history: history, settings: settings, clock: testClock, initialMetric: .analytics(.turns)) + try await history.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: "2026-08-10", metric: .turns, series: "total", value: 3)], + fetchedAt: fixedNow)) + + presenter.reload() + await presenter.waitForLoad() + #expect(presenter.earliest == DayStamp.date("2026-08-10")) + #expect(presenter.canPageBack) +} + +@Test @MainActor func historyPresenterPagesTodayWithoutDuplicatingUTCDays() async throws { + let settings = Settings(defaults: UserDefaults(suiteName: "history-today-pages-\(UUID().uuidString)")!) + settings.historyRange = .today + let history = try UsageHistoryStore(url: nil) + let presenter = HistoryPresenter( + history: history, settings: settings, clock: testClock, initialMetric: .analytics(.turns)) + try await history.record( + ProviderAnalytics( + provider: .codex, + points: (0..<3).map { offset in + AnalyticsPoint( + day: DayStamp.string(fixedNow.addingTimeInterval(-Double(offset) * 86400)), metric: .turns, + series: "total", value: Double(offset + 1)) + }, fetchedAt: fixedNow)) + + presenter.reload() + await presenter.waitForLoad() + let current = try #require(presenter.state.data?.series.first?.points) + #expect(current.map { DayStamp.string($0.date) } == [DayStamp.string(fixedNow)]) + + presenter.page(forward: false, now: fixedNow) + await presenter.waitForLoad() + let previous = try #require(presenter.state.data?.series.first?.points) + #expect(previous.map { DayStamp.string($0.date) } == [DayStamp.string(fixedNow.addingTimeInterval(-86400))]) +} + +@Test @MainActor func historyPresenterPagesACustomAnalyticsDurationByWholeUTCDays() async throws { + let settings = Settings(defaults: UserDefaults(suiteName: "history-custom-pages-\(UUID().uuidString)")!) + settings.historyRange = .custom + let history = try UsageHistoryStore(url: nil) + let presenter = HistoryPresenter( + history: history, settings: settings, clock: testClock, initialMetric: .analytics(.surfaceUsagePercent)) + presenter.customStart = DayStamp.date("2026-08-20")! + presenter.customEnd = DayStamp.date("2026-08-22")! + presenter.followNow = false + + presenter.page(forward: false, now: fixedNow) + await presenter.waitForLoad() + + #expect(presenter.customStart == DayStamp.date("2026-08-17")) + #expect(presenter.customEnd == DayStamp.date("2026-08-19")) + #expect(!presenter.followNow) + + presenter.page(forward: true, now: fixedNow) + await presenter.waitForLoad() + + #expect(presenter.customStart == DayStamp.date("2026-08-20")) + #expect(presenter.customEnd == DayStamp.date("2026-08-22")) +} + +@Test @MainActor func historyPresenterTogglesAndRestoresAnalyticsSeries() async throws { + let settings = Settings(defaults: UserDefaults(suiteName: "history-analytics-visibility-\(UUID().uuidString)")!) + let history = try UsageHistoryStore(url: nil) + let presenter = HistoryPresenter( + history: history, settings: settings, clock: testClock, initialMetric: .analytics(.surfaceUsagePercent)) + try await history.record( + ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .surfaceUsagePercent, series: "cli", value: 20), + AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .surfaceUsagePercent, series: "web", value: 40), + ], fetchedAt: fixedNow)) + presenter.reload() + await presenter.waitForLoad() + let id = try #require(presenter.state.data?.series.first?.id) + + presenter.toggleVisibility(id) + await presenter.waitForLoad() + #expect(!presenter.isVisible(id)) + presenter.toggleVisibility(id) + await presenter.waitForLoad() + #expect(presenter.isVisible(id)) + presenter.isolate(id) + await presenter.waitForLoad() + #expect(presenter.state.data?.visibleSeries.map(\.id) == [id]) + presenter.isolate(id) + await presenter.waitForLoad() + #expect(presenter.state.data?.visibleSeries.count == 2) +} diff --git a/Tests/TokenMenuBarCoreTests/InterfaceTokensTests.swift b/Tests/TokenMenuBarCoreTests/InterfaceTokensTests.swift new file mode 100644 index 0000000..71f22b5 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/InterfaceTokensTests.swift @@ -0,0 +1,39 @@ +import Testing +import TokenMenuBarCore + +@Test func interfaceTokensReserveAccentForSelectionAndData() { + let tokens = InterfaceTokens.standard + + #expect(tokens.bodyForeground == .primary) + #expect(tokens.detailForeground == .secondary) + #expect(tokens.quietForeground == .tertiary) + #expect(tokens.controls.appearance(for: .action) == ControlAppearance(foreground: .primary)) + #expect(tokens.controls.appearance(for: .selection) == ControlAppearance(foreground: .primary)) + #expect( + tokens.controls.appearance(for: .selection, selected: true) + == ControlAppearance(foreground: .primary, tint: .accent)) + #expect( + tokens.controls.appearance(for: .data) == ControlAppearance(foreground: .primary, tint: .accent)) +} + +@Test func interfaceTokensKeepWarningAndDestructiveControlsSemantic() { + let controls = InterfaceTokens.standard.controls + + #expect(controls.appearance(for: .warning) == ControlAppearance(foreground: .warning)) + #expect(controls.appearance(for: .destructive) == ControlAppearance(foreground: .destructive)) + #expect(controls.action.foreground != .accent) + #expect(controls.destructive.foreground != .accent) + #expect(controls.warning.foreground != .accent) +} + +@Test func interfaceTokenPolicyCanBeReplacedWithoutUIFrameworkTypes() { + let appearance = ControlAppearance(foreground: .secondary, tint: .warning) + let controls = ControlPolicy( + action: appearance, selected: appearance, destructive: appearance, warning: appearance, data: appearance) + let tokens = InterfaceTokens( + bodyForeground: .secondary, detailForeground: .tertiary, quietForeground: .primary, controls: controls) + + #expect(tokens.controls.appearance(for: .action) == appearance) + #expect(SemanticColorRole.allCases.map(\.rawValue).count == 6) + #expect(ControlIntent.allCases.map(\.rawValue).count == 5) +} diff --git a/Tests/TokenMenuBarCoreTests/JSONValueTests.swift b/Tests/TokenMenuBarCoreTests/JSONValueTests.swift new file mode 100644 index 0000000..13f3a55 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/JSONValueTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func jsonValueRoundTripsAllKinds() throws { + let text = #"{"a":null,"b":true,"c":1.5,"d":"x","e":[1,"y"],"f":{"g":2}}"# + let value = try JSONDecoder().decode(JSONValue.self, from: Data(text.utf8)) + #expect(value["a"] == .null) + #expect(value["b"]?.boolValue == true) + #expect(value["c"]?.doubleValue == 1.5) + #expect(value["d"]?.stringValue == "x") + #expect(value["e"]?.arrayValue == [.number(1), .string("y")]) + #expect(value["f"]?.objectValue == ["g": .number(2)]) + #expect(try JSONDecoder().decode(JSONValue.self, from: try JSONEncoder().encode(value)) == value) + #expect(String(decoding: try JSONEncoder().encode(JSONValue.number(2)), as: UTF8.self) == "2") + #expect(String(decoding: try JSONEncoder().encode(JSONValue.number(2.5)), as: UTF8.self) == "2.5") + #expect(String(decoding: try JSONEncoder().encode(JSONValue.number(1e16)), as: UTF8.self).hasPrefix("1e+16")) +} + +@Test func jsonValueAccessorsReturnNilForOtherKinds() { + let value = JSONValue.string("7") + #expect(value["missing"] == nil) + #expect(value.boolValue == nil) + #expect(value.arrayValue == nil) + #expect(value.objectValue == nil) + #expect(value.doubleValue == 7) + #expect(JSONValue.bool(true).doubleValue == nil) + #expect(JSONValue.bool(true).stringValue == nil) + #expect(JSONValue.null.isNull) + #expect(!JSONValue.bool(false).isNull) +} + +@Test func jsonValueMergingAddsKeys() { + #expect( + JSONValue.object(["a": .number(1)]).merging("b", .string("x")) == .object(["a": .number(1), "b": .string("x")])) + #expect(JSONValue.null.merging("b", .string("x")) == .object(["b": .string("x")])) +} + +@Test func jsonValueSummaryFlattens() { + let value = JSONValue.object(["z": .array([.number(1.25), .bool(true)]), "a": .null, "m": .string("s")]) + #expect(value.summary == "a: null, m: s, z: 1.25, true") +} diff --git a/Tests/TokenMenuBarCoreTests/LaunchPolicyTests.swift b/Tests/TokenMenuBarCoreTests/LaunchPolicyTests.swift new file mode 100644 index 0000000..7c18a1a --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/LaunchPolicyTests.swift @@ -0,0 +1,153 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func standardLaunchKeepsTheProcessEnvironmentAndDefaults() { + let suite = "launch-policy-standard-\(UUID().uuidString)" + let standard = UserDefaults(suiteName: suite)! + defer { standard.removePersistentDomain(forName: suite) } + let policy = LaunchPolicy(arguments: ["TokenMenuBar"], environment: ["EXISTING": "value"]) + + #expect(policy.mode == .standard) + #expect(policy.environment == ["EXISTING": "value"]) + #expect(policy.defaultsSuiteName == nil) + #expect(policy.supportDirectory == nil) + #expect(policy.verificationProfile == nil) + #expect(policy.defaults(standard: standard) === standard) +} + +@Test func verificationArgumentForcesIsolatedDemoState() { + let temporaryDirectory = URL(fileURLWithPath: "/tmp/launch-policy-tests", isDirectory: true) + let policy = LaunchPolicy( + arguments: ["TokenMenuBar", "--verify-ui"], + environment: [LaunchPolicy.verificationSessionKey: "test/session 1", "EXISTING": "value"], + temporaryDirectory: temporaryDirectory, + verificationIdentifier: "unused" + ) + + #expect(policy.mode == .verification) + #expect(policy.environment["EXISTING"] == "value") + #expect(policy.environment["TOKEN_MENU_BAR_DEMO"] == "1") + #expect(policy.environment["TOKEN_MENU_BAR_OPEN_POPOVER"] == "1") + #expect(policy.defaultsSuiteName == "\(LaunchPolicy.verificationSuitePrefix).test-session-1") + #expect( + policy.supportDirectory + == temporaryDirectory.appendingPathComponent("token-menu-bar-verify-test-session-1", isDirectory: true)) + #expect(policy.verificationProfile == VerificationProfile()) +} + +@Test func verificationProfileParsesLongTextAndVisibleWidth() { + let policy = LaunchPolicy( + arguments: ["TokenMenuBar", "--verify-ui"], + environment: [ + VerificationProfile.fixtureEnvironmentKey: VerificationProfile.Fixture.longText.rawValue, + VerificationProfile.visibleFrameWidthEnvironmentKey: "752", + ]) + + #expect(policy.verificationProfile == VerificationProfile(fixture: .longText, visibleFrameWidth: 752)) +} + +@Test func verificationProfileParsesControlAuditFixture() { + let policy = LaunchPolicy( + arguments: ["TokenMenuBar", "--verify-ui"], + environment: [ + VerificationProfile.fixtureEnvironmentKey: VerificationProfile.Fixture.controlAudit.rawValue, + VerificationProfile.nativePanelsEnvironmentKey: "1", + ]) + + #expect(policy.verificationProfile?.fixture == .controlAudit) + #expect(policy.verificationProfile?.nativePanels == true) +} + +@Test func standardLaunchIgnoresVerificationProfileEnvironment() { + let policy = LaunchPolicy( + arguments: ["TokenMenuBar"], + environment: [ + VerificationProfile.fixtureEnvironmentKey: VerificationProfile.Fixture.longText.rawValue, + VerificationProfile.visibleFrameWidthEnvironmentKey: "752", + ]) + + #expect(policy.mode == .standard) + #expect(policy.verificationProfile == nil) +} + +@Test(arguments: ["nan", "infinity", "-1", "0", "narrow"]) +func verificationProfileRejectsInvalidVisibleWidth(_ value: String) { + let policy = LaunchPolicy( + arguments: ["TokenMenuBar", "--verify-ui"], + environment: [VerificationProfile.visibleFrameWidthEnvironmentKey: value]) + + #expect(policy.verificationProfile?.visibleFrameWidth == nil) +} + +@Test func verificationEnvironmentUsesTheGeneratedIdentifierWithoutASession() { + let policy = LaunchPolicy( + arguments: ["TokenMenuBar"], environment: [LaunchPolicy.verificationEnvironmentKey: "1"], + verificationIdentifier: "generated-42") + + #expect(policy.mode == .verification) + #expect(policy.defaultsSuiteName == "\(LaunchPolicy.verificationSuitePrefix).generated-42") +} + +@Test func verificationUsesTheSharedSupportDirectory() { + let directory = URL(fileURLWithPath: "/tmp/token-menu-bar-shared-verification", isDirectory: true) + let policy = LaunchPolicy( + arguments: ["TokenMenuBar", "--verify-ui"], + environment: [LaunchPolicy.verificationSupportDirectoryKey: directory.path]) + + #expect(policy.supportDirectory == directory) +} + +@Test func verificationEnvironmentReplacesAnEmptySession() { + let policy = LaunchPolicy( + arguments: ["TokenMenuBar"], + environment: [LaunchPolicy.verificationEnvironmentKey: "1", LaunchPolicy.verificationSessionKey: ""], + verificationIdentifier: "unused" + ) + + #expect(policy.mode == .verification) + #expect(policy.defaultsSuiteName == "\(LaunchPolicy.verificationSuitePrefix).session") +} + +@Test func verificationDefaultsDiscardPersistedChoices() throws { + let policy = LaunchPolicy(arguments: ["TokenMenuBar", "--verify-ui"], environment: [:]) + let suite = try #require(policy.defaultsSuiteName) + #expect(suite == "\(LaunchPolicy.verificationSuitePrefix).manual") + let stale = UserDefaults(suiteName: suite)! + stale.set(false, forKey: "demoMode") + + let defaults = policy.defaults() + + #expect(defaults.object(forKey: "demoMode") == nil) + defaults.removePersistentDomain(forName: suite) +} + +@Test func verificationCleanupRemovesDefaultsAndSupportFiles() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let policy = LaunchPolicy( + arguments: ["TokenMenuBar", "--verify-ui"], environment: [:], temporaryDirectory: root, + verificationIdentifier: "cleanup") + let suite = try #require(policy.defaultsSuiteName) + let supportDirectory = try #require(policy.supportDirectory) + UserDefaults(suiteName: suite)!.set("stale", forKey: "value") + try FileManager.default.createDirectory(at: supportDirectory, withIntermediateDirectories: true) + try Data("stale".utf8).write(to: supportDirectory.appendingPathComponent("state")) + + try policy.cleanup() + + #expect(UserDefaults(suiteName: suite)!.object(forKey: "value") == nil) + #expect(!FileManager.default.fileExists(atPath: supportDirectory.path)) +} + +@Test func standardCleanupLeavesItsFilesAlone() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let state = root.appendingPathComponent("state") + try Data("keep".utf8).write(to: state) + + try LaunchPolicy(arguments: ["TokenMenuBar"], environment: [:], temporaryDirectory: root).cleanup() + + #expect(FileManager.default.fileExists(atPath: state.path)) +} diff --git a/Tests/TokenMenuBarCoreTests/LogBufferTests.swift b/Tests/TokenMenuBarCoreTests/LogBufferTests.swift new file mode 100644 index 0000000..f78c378 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/LogBufferTests.swift @@ -0,0 +1,563 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func logBufferKeepsEntriesAndLevels() { + let log = makeLog() + log.log("hello") + log.logWarning("careful") + log.logError("bad") + log.logDebug("hidden") + log.debugEnabled = true + log.logDebug("shown") + #expect(log.snapshot.map(\.message) == ["hello", "careful", "bad", "shown"]) + #expect(log.snapshot.map(\.level) == [.info, .warning, .error, .debug]) + #expect(log.tail(1).map(\.message) == ["shown"]) + #expect(log.text.contains("[error] bad")) + #expect(log.snapshot[0].line.hasPrefix("[2026-08-29")) + #expect(log.snapshot[0].id != log.snapshot[1].id) + #expect(LogLevel.debug < .info && LogLevel.info < .error) + log.clear() + #expect(log.snapshot.isEmpty) +} + +@Test func logBufferCapsCapacity() { + let log = makeLog() + for index in 0..<(LogBuffer.capacity + 10) { log.log("line \(index)") } + #expect(log.snapshot.count == LogBuffer.capacity) + #expect(log.snapshot.first?.message == "line 10") +} + +@Test func logBufferPersistsAndPrunesOnLoad() throws { + let url = temporaryDirectory().appendingPathComponent("logs/app.log") + let old = LogEntry( + timestamp: fixedNow.addingTimeInterval(-LogBuffer.retention - 10), level: .info, message: "ancient") + let recent = LogEntry(timestamp: fixedNow.addingTimeInterval(-10), level: .info, message: "recent") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data((old.line + "\n" + recent.line + "\n").utf8).write(to: url) + + let log = LogBuffer(fileURL: url, clock: testClock) + #expect(log.snapshot.map(\.message) == ["recent"]) + // Loading drops what aged out, so the file no longer carries it either. + #expect(try !String(contentsOf: url, encoding: .utf8).contains("ancient")) + + log.log("new") + log.flush() + #expect(LogBuffer(fileURL: url, clock: testClock).snapshot.map(\.message) == ["recent", "new"]) + // Appending leaves what was already there alone rather than rewriting the file. + #expect(try String(contentsOf: url, encoding: .utf8).hasPrefix(recent.line)) +} + +@Test func logBufferKeepsLinesItCannotParse() throws { + let url = temporaryDirectory().appendingPathComponent("logs/app.log") + let recent = LogEntry(timestamp: fixedNow.addingTimeInterval(-10), level: .info, message: "recent") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data((recent.line + "\ncrash: not a log line\n[2026-08-29 19:00:00.000] no level here\n").utf8).write(to: url) + + // A malformed line is what the bug report is about, so it survives the round trip whole. + let messages = LogBuffer(fileURL: url, clock: testClock).snapshot.map(\.message) + #expect(messages == ["recent", "crash: not a log line", "no level here"]) +} + +@Test(arguments: [ + "2024-02-29 12:34:56.789", + "2026-04-30 12:34:56.789", + "2026-08-29 12:34:56.789", +]) +func logBufferParsesStoredUtcTimestamps(timestamp: String) throws { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expected = try #require(formatter.date(from: timestamp.replacingOccurrences(of: " ", with: "T") + "Z")) + let url = temporaryDirectory().appendingPathComponent("app.log") + try Data("[\(timestamp)] [info] retained\n".utf8).write(to: url) + + let entry = try #require( + LogBuffer(fileURL: url, clock: .fixed(expected.addingTimeInterval(60))).snapshot.first) + + #expect(entry.timestamp == expected) +} + +@Test(arguments: [ + "202x-08-29 12:34:56.789", + "0000-08-29 12:34:56.789", + "2025-02-29 12:34:56.789", + "2026-04-31 12:34:56.789", + "2026-13-29 12:34:56.789", +]) +func logBufferFallsBackForInvalidStoredTimestamps(timestamp: String) throws { + let url = temporaryDirectory().appendingPathComponent("app.log") + try Data("[\(timestamp)] [info] retained\n".utf8).write(to: url) + let now = Date(timeIntervalSince1970: 1_788_030_000) + + let entry = try #require(LogBuffer(fileURL: url, clock: .fixed(now)).snapshot.first) + + #expect(entry.timestamp == now.addingTimeInterval(-LogBuffer.retention)) +} + +@Test func logBufferSanitizesAValidStoredLine() throws { + let url = temporaryDirectory().appendingPathComponent("app.log") + try Data("[2026-08-29 19:00:00.000] [warning] access_token=secret\n".utf8).write(to: url) + + let entry = try #require(LogBuffer(fileURL: url, clock: testClock).snapshot.first) + + #expect(entry.message == "access_token=") +} + +@Test func logBufferPrunesHourlyWhileAppending() { + let box = DateBox(fixedNow) + let log = LogBuffer(fileURL: nil, clock: box.clock) + log.log("first") + box.date = fixedNow.addingTimeInterval(LogBuffer.retention + 7200) + log.log("second") + #expect(log.snapshot.map(\.message) == ["second"]) +} + +@Test func logBufferDoesNotBuildDisabledDetailedMessages() { + let log = makeLog() + var evaluations = 0 + log.logDebug( + { + evaluations += 1 + return "debug" + }()) + log.detailed( + { + evaluations += 1 + return tabDiagnostic() + }()) + #expect(evaluations == 0) + #expect(log.snapshot.isEmpty) + + log.debugEnabled = true + log.logDebug( + { + evaluations += 1 + return "debug" + }()) + log.detailed( + { + evaluations += 1 + return tabDiagnostic() + }()) + #expect(evaluations == 2) + #expect(log.snapshot.map(\.level) == [.debug, .debug]) +} + +@Test func diagnosticEventsKeepCategoryAndFields() { + let log = makeLog() + log.record(tabDiagnostic(), level: .info) + let entry = log.snapshot[0] + #expect(entry.category == .tabs) + #expect(entry.message == "tab.measurement source=History active=Settings filedUnder=History size=832x700") + #expect(entry.line.contains("[info] [tabs] tab.measurement")) +} + +@Test func diagnosticFactoriesReportRealStateChangesAndSkippedWork() throws { + let unchanged = StatusDiagnostic.retierIfChanged( + trigger: "fit", buttonFrame: nil, oldTier: 1, newTier: 1, visible: true, popoverVisible: false, + fits: true, layoutContext: nil) + #expect(unchanged == nil) + let changed = try #require( + StatusDiagnostic.retierIfChanged( + trigger: "fit", buttonFrame: nil, oldTier: 1, newTier: 2, visible: true, popoverVisible: false, + fits: false, layoutContext: "closed")) + #expect(changed.action == .retier) + #expect(changed.oldTier == 1) + #expect(changed.newTier == 2) + + let skipped = RefreshDiagnostic.skipped( + cycleID: "cycle", trigger: "scheduled", provider: .codex, usagePolicy: "due", + analyticsPolicy: "due", reason: .retryBackoff) + #expect(skipped.outcome == .skipped) + #expect(skipped.skipReason == .retryBackoff) + #expect(DiagnosticEvent.refresh(skipped).message.contains("skipReason=retry-backoff")) +} + +@Test func undiscoveredProviderMissesRequireDetailedLogging() { + let log = makeLog() + let event = DiagnosticEvent.refresh( + RefreshDiagnostic.skipped( + cycleID: "discovery", trigger: "scheduled", provider: .copilot, usagePolicy: "due", + analyticsPolicy: "due", reason: .notDiscovered)) + + log.detailed(event) + #expect(log.snapshot.isEmpty) + + log.debugEnabled = true + log.detailed(event) + #expect(log.snapshot.map(\.level) == [.debug]) + #expect(log.snapshot[0].message.contains("provider=copilot")) + #expect(log.snapshot[0].message.contains("skipReason=not-discovered")) +} + +@Test func explicitProviderFailuresRemainVisibleWithoutDetailedLogging() { + let log = makeLog() + + log.logError("refresh provider=codex outcome=authenticationRequired error=Sign in required", category: .refresh) + + #expect(log.snapshot.map(\.level) == [.error]) + #expect(log.snapshot.map(\.category) == [.refresh]) + #expect(log.snapshot[0].message.contains("provider=codex")) +} + +@Test func diagnosticCategoriesExposeReadableTitles() { + #expect( + LogCategory.allCases.map(\.title) == [ + "App", "Geometry", "Network", "Persistence", "Refresh", "Status item", "Tabs", + ]) +} + +@Test func panelPostResizeFactoryCapturesTheResultingWindowState() { + let result = DiagnosticRect(x: 10, y: 20, width: 832, height: 700) + let event = PanelDiagnostic.postResize( + trigger: "backing-window", + tab: "Settings", + anchor: DiagnosticRect(x: 100, y: 900, width: 24, height: 22), + screenID: "main", + screenFrame: DiagnosticRect(x: 0, y: 0, width: 1_440, height: 900), + maximum: DiagnosticSize(width: 832, height: 760), + proposed: DiagnosticSize(width: 832, height: 720), + clamped: DiagnosticSize(width: 832, height: 700), + resultFrame: result, + appActive: true, + windowKey: true, + windowMain: false, + frontmostBundleID: "dev.tox.token-menu-bar") + + #expect(event.action == .resize) + #expect(event.resultFrame == result) + #expect(DiagnosticEvent.panel(event).message.contains("result=\"\(result)\"")) +} + +@Test func logFilterMatchesLevelsCategoriesAndSearch() { + let log = makeLog() + log.logInfo("opened panel", category: .geometry) + log.logWarning("Codex delayed", category: .refresh) + log.logError("Claude failed", category: .refresh) + let filter = LogFilter(search: "CODEX", levels: [.warning, .error], categories: [.refresh]) + #expect(log.filtered(filter).map(\.message) == ["Codex delayed"]) + #expect(log.filtered(LogFilter(levels: [])).isEmpty) +} + +@Test func logBufferSanitizesMessagesBeforeKeepingThem() { + let log = makeLog() + let home = FileManager.default.homeDirectoryForCurrentUser.path + log.logWarning( + "Bearer abc access_token=xyz user@example.com \(home)/private https://example.com/path?key=value\nnext") + let entry = log.snapshot[0] + #expect(entry.message.contains("Bearer ")) + #expect(entry.message.contains("access_token=")) + #expect(entry.message.contains("")) + #expect(entry.message.contains("~/private")) + #expect(entry.message.contains("https://example.com/path?")) + #expect(entry.message.contains("\\nnext")) + #expect(!entry.message.contains("abc")) + #expect(!entry.message.contains("xyz")) + #expect(!entry.message.contains(home)) +} + +@Test(arguments: [ + (#"warning HTTP 500: {"access_token":"sk-secret"}"#, "sk-secret"), + ("request body={\n \"access_token\": \"sk-secret\"\n}", "sk-secret"), + ("Authorization: Basic dXNlcjpwYXNz", "dXNlcjpwYXNz"), + (#"{\"client_secret\":\"private-value\"}"#, "private-value"), +]) +func logSanitizerRedactsStructuredAndMultilineSecrets(message: String, secret: String) { + let sanitized = LogSanitizer.message(message) + #expect(sanitized.contains("")) + #expect(!sanitized.contains(secret)) +} + +@Test func logExportRedactsEachMultilineReportFieldWithoutRemovingLaterLines() { + let report = "first body=private\nsecond access_token: secret\nthird visible" + let sanitized = LogSanitizer.redact(report) + #expect(sanitized == "first body=\nsecond access_token=\nthird visible") +} + +@Test func logEntryBoundsUTF8LinesWithoutBreakingCharacters() { + let entry = LogEntry(timestamp: fixedNow, level: .info, message: String(repeating: "é", count: 2_000)) + #expect(entry.line.utf8.count <= LogEntry.maximumLineBytes) + #expect(entry.message.hasSuffix("…")) + #expect(!entry.message.contains("�")) +} + +@Test func logExportAddsAHeaderAndHonorsFilters() { + let log = makeLog() + log.logInfo("visible", category: .app) + log.logError("hidden", category: .network) + let header = LogExportHeader( + appName: "Token Menu Bar", sourceVersion: "1.2.3", build: "7", distribution: "Direct", + osVersion: "26.0") + let export = log.export(filter: LogFilter(levels: [.info]), header: header) + #expect(export.hasPrefix("Token Menu Bar 1.2.3 (7) Direct\nmacOS 26.0\n\n")) + #expect(export.contains("visible")) + #expect(!export.contains("hidden")) +} + +@Test func logExportSanitizesHeaderFieldsWithoutChangingEntryIdentity() { + let log = makeLog() + log.log("visible") + let sequenceID = log.snapshot[0].sequenceID + let header = LogExportHeader( + appName: "user@example.com", sourceVersion: "1", build: "7", distribution: "Direct", + osVersion: "26") + + let export = log.export(header: header) + + #expect(export.contains("")) + #expect(log.snapshot[0].sequenceID == sequenceID) + #expect(export.contains(log.snapshot[0].line)) +} + +@Test func logExportHeaderUsesTheRuntimeDistribution() { + let header = LogExportHeader( + app: AppInfo( + name: "Token Menu Bar", version: "1", build: "7", bundleIdentifier: "dev.tox.token-menu-bar", + distribution: .homebrew, repository: AppInfo.repositoryURL), + osVersion: "15") + #expect(header.distribution == "Homebrew") +} + +@Test func logBufferRotatesFilesWithinTheConfiguredBound() throws { + let url = temporaryDirectory().appendingPathComponent("logs/app.log") + let log = LogBuffer(fileURL: url, clock: testClock, maximumFileBytes: 150, maximumFileCount: 3) + for index in 0..<12 { log.log("line-\(index)-" + String(repeating: "x", count: 30)) } + log.flush() + + let urls = [url, URL(fileURLWithPath: "\(url.path).1"), URL(fileURLWithPath: "\(url.path).2")] + #expect(urls.allSatisfy { FileManager.default.fileExists(atPath: $0.path) }) + #expect(!FileManager.default.fileExists(atPath: "\(url.path).3")) + for file in urls { + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + let size = try #require(attributes[.size] as? NSNumber) + #expect(size.intValue <= 150) + #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600) + } + let restored = LogBuffer(fileURL: url, clock: testClock, maximumFileBytes: 150, maximumFileCount: 3) + #expect(restored.snapshot.last?.message.hasPrefix("line-11-") == true) + #expect(restored.snapshot.first?.message.hasPrefix("line-0-") == false) +} + +@Test func logBufferSubscriptionsCoalesceAndDeliverTheLatestSnapshot() async { + let log = makeLog() + var snapshots = log.snapshots().makeAsyncIterator() + #expect(await snapshots.next()?.isEmpty == true) + log.log("first") + log.log("second") + var update: [LogEntry] = [] + while update.last?.message != "second" { update = await snapshots.next() ?? [] } + #expect(update.map(\.message) == ["first", "second"]) +} + +@Test func logBufferSubscriptionStopsAfterCancellation() async throws { + let log = makeLog() + let counter = InvocationCounter() + let subscription = log.subscribe { Task { await counter.increment() } } + log.log("first") + await counter.wait(for: 1) + #expect(await counter.value == 1) + + subscription.cancel() + let barrier = InvocationCounter() + let barrierSubscription = log.subscribe { Task { await barrier.increment() } } + log.log("second") + await barrier.wait(for: 1) + #expect(await counter.value == 1) + barrierSubscription.cancel() +} + +@Test func persistedDiagnosticRetainsItsCategory() { + let url = temporaryDirectory().appendingPathComponent("logs/app.log") + let log = LogBuffer(fileURL: url, clock: testClock) + log.record(tabDiagnostic(), level: .info) + log.flush() + + let restored = LogBuffer(fileURL: url, clock: testClock) + + #expect(restored.snapshot.map(\.category) == [.tabs]) + #expect( + restored.snapshot.map(\.message) == [ + "tab.measurement source=History active=Settings filedUnder=History size=832x700" + ]) +} + +@Test func logBufferRewritesTheLiveTailWhenPendingStorageReachesItsBound() async { + let url = temporaryDirectory().appendingPathComponent("logs/app.log") + let log = LogBuffer(fileURL: url, clock: testClock, maximumFileBytes: 100_000, maximumFileCount: 2) + for index in 0...LogBuffer.pendingCapacity { log.log("pending-\(index)") } + + log.flush() + let retained = await log.retainedSnapshot() + + #expect(retained.count == LogBuffer.capacity) + #expect(retained.first?.message == "pending-501") + #expect(retained.last?.message == "pending-1000") +} + +@Test func logBufferFitsARecordIntoAOneByteFile() throws { + let url = temporaryDirectory().appendingPathComponent("logs/app.log") + let log = LogBuffer(fileURL: url, clock: testClock, maximumFileBytes: 1, maximumFileCount: 1) + log.log("value") + + log.flush() + + #expect(try Data(contentsOf: url) == Data("\n".utf8)) +} + +@Test func logBufferDoesNotSplitUTF8WhenFittingAFileRecord() throws { + let url = temporaryDirectory().appendingPathComponent("logs/app.log") + let entry = LogEntry(timestamp: fixedNow, level: .info, message: "é") + let log = LogBuffer( + fileURL: url, clock: testClock, maximumFileBytes: entry.line.utf8.count, maximumFileCount: 1) + log.log("é") + + log.flush() + let data = try Data(contentsOf: url) + + #expect(String(data: data, encoding: .utf8) != nil) + #expect(data.count <= entry.line.utf8.count) + #expect(!String(decoding: data, as: UTF8.self).contains("é")) +} + +@Test func logBufferRetriesAnInitializationRewriteAfterPermissionsRecover() throws { + let directory = temporaryDirectory().appendingPathComponent("logs") + let url = directory.appendingPathComponent("app.log") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("partial\n".utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: directory.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) } + + let log = LogBuffer(fileURL: url, clock: testClock, maximumFileBytes: 4, maximumFileCount: 1) + #expect(FileManager.default.fileExists(atPath: url.path)) + + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + log.log("recovered") + log.flush() + + #expect(try !String(contentsOf: url, encoding: .utf8).contains("partial")) +} + +@Test func logBufferRetriesClearAfterPermissionsRecover() throws { + let directory = temporaryDirectory().appendingPathComponent("logs") + let url = directory.appendingPathComponent("app.log") + let log = LogBuffer(fileURL: url, clock: testClock) + log.log("stored") + log.flush() + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: directory.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) } + + log.clear() + #expect(FileManager.default.fileExists(atPath: url.path)) + + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + log.flush() + + #expect(!FileManager.default.fileExists(atPath: url.path)) +} + +@Test func logBufferBoundsRequeuedEntriesWhenWritesAndLoggingOverlap() async throws { + let directory = temporaryDirectory().appendingPathComponent("logs") + let url = directory.appendingPathComponent("app.log") + let longMessage = String(repeating: "x", count: 256) + let seedBytes = LogEntry(timestamp: fixedNow, level: .info, message: "seed").line.utf8.count + 1 + let entryBytes = LogEntry(timestamp: fixedNow, level: .info, message: longMessage).line.utf8.count + 1 + let fileLimit = seedBytes + entryBytes * LogBuffer.pendingCapacity - 1 + let log = LogBuffer( + fileURL: url, clock: testClock, maximumFileBytes: fileLimit, maximumFileCount: 2) + log.log("seed") + log.flush() + for _ in 0.. seedBytes { break } + await Task.yield() + } + log.log("concurrent") + await flushing.value + + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + log.flush() + let retained = await log.retainedSnapshot() + + #expect(retained.count == LogBuffer.capacity) + #expect(retained.last?.message == "concurrent") +} + +@Test func logBufferRequeuesAWriteAfterTheStoragePathRecovers() throws { + let parent = temporaryDirectory().appendingPathComponent("blocked") + try Data("not a directory".utf8).write(to: parent) + let url = parent.appendingPathComponent("app.log") + let log = LogBuffer(fileURL: url, clock: testClock) + log.log("survives") + log.flush() + + try FileManager.default.removeItem(at: parent) + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + log.flush() + + #expect(try String(contentsOf: url, encoding: .utf8).contains("survives")) +} + +@Test func logBufferBoundsStartupReadsToTheNewestFileTail() throws { + let url = temporaryDirectory().appendingPathComponent("logs/app.log") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let lines = (0..<2_000).map { "malformed-\($0)" }.joined(separator: "\n") + "\n" + try Data(lines.utf8).write(to: url) + + let log = LogBuffer(fileURL: url, clock: testClock, maximumFileBytes: 512, maximumFileCount: 1) + + #expect(log.snapshot.count <= LogBuffer.capacity) + #expect(log.snapshot.last?.message == "malformed-1999") +} + +@Test func retainedSnapshotLoadsRotatedLinesBeyondTheLiveCapacity() async { + let url = temporaryDirectory().appendingPathComponent("logs/app.log") + let log = LogBuffer(fileURL: url, clock: testClock, maximumFileBytes: 4_096, maximumFileCount: 20) + for index in 0..<700 { log.log("retained-\(index)") } + log.flush() + + let retained = await log.retainedSnapshot() + + #expect(log.snapshot.count == LogBuffer.capacity) + #expect(retained.count == 700) + #expect(retained.first?.message == "retained-0") + #expect(retained.last?.message == "retained-699") + #expect(retained.suffix(log.snapshot.count).map(\.sequenceID) == log.snapshot.map(\.sequenceID)) +} + +@Test func duplicateLogLinesReceiveDistinctSequenceIDs() { + let log = makeLog() + + log.log("same") + log.log("same") + + #expect(Set(log.snapshot.map(\.sequenceID)).count == 2) +} + +private func tabDiagnostic() -> DiagnosticEvent { + .tab( + TabDiagnostic( + action: .measurement, sourceTab: "History", activeTab: "Settings", filedUnderTab: "History", + size: DiagnosticSize(width: 832, height: 700))) +} + +private actor InvocationCounter { + private(set) var value = 0 + private var waiters: [(Int, CheckedContinuation)] = [] + + func increment() { + value += 1 + let ready = waiters.filter { value >= $0.0 } + waiters.removeAll { value >= $0.0 } + for (_, waiter) in ready { waiter.resume() } + } + + func wait(for expected: Int) async { + guard value < expected else { return } + await withCheckedContinuation { waiters.append((expected, $0)) } + } +} diff --git a/Tests/TokenMenuBarCoreTests/MenuCommandTests.swift b/Tests/TokenMenuBarCoreTests/MenuCommandTests.swift new file mode 100644 index 0000000..73a8aa1 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/MenuCommandTests.swift @@ -0,0 +1,27 @@ +import Testing +import TokenMenuBarCore + +@Test func menuHoldsOnlyRefreshAndQuit() { + let commands = MenuCommand.menu(canCheckForUpdates: false, appName: "Token Menu Bar") + #expect(commands == [.refresh, .separator, .quit(appName: "Token Menu Bar")]) + #expect(commands.first?.title == "Refresh Now") + #expect(commands.first?.keyEquivalent == "r") + #expect(commands.last?.title == "Quit Token Menu Bar") + #expect(commands.last?.keyEquivalent == "q") +} + +@Test func menuAddsTheUpdateCommandOnlyWhenTheBuildCanCheck() { + let without = MenuCommand.menu(canCheckForUpdates: false, appName: "App") + #expect(!without.contains(.checkForUpdates)) + let with = MenuCommand.menu(canCheckForUpdates: true, appName: "App") + #expect(with.contains(.checkForUpdates)) + #expect(with.count == without.count + 2) +} + +@Test func menuCommandIdentifiersAreStable() { + #expect(MenuCommand.refresh.id == "refresh") + #expect(MenuCommand.separator.title.isEmpty) + #expect(MenuCommand.separator.keyEquivalent.isEmpty) + #expect(MenuCommand.checkForUpdates.id == "updates") + #expect(MenuCommand.quit(appName: "A").id == MenuCommand.quit(appName: "B").id) +} diff --git a/Tests/TokenMenuBarCoreTests/MissingCredentialHealthTests.swift b/Tests/TokenMenuBarCoreTests/MissingCredentialHealthTests.swift new file mode 100644 index 0000000..92fea8d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/MissingCredentialHealthTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func claudeCredentialHealthReportsMissingCredentials() { + #expect( + MemoryClaudeStore(nil).credentialHealth(now: fixedNow) + == .missing(expected: ProviderID.claude.setup.credentialSources)) +} + +@Test func codexCredentialHealthReportsMissingCredentials() { + #expect( + MemoryCodexStore(nil).credentialHealth(now: fixedNow) + == .missing(expected: ProviderID.codex.setup.credentialSources)) +} + +@Test func copilotCredentialHealthReportsMissingCredentials() { + #expect( + MemoryCopilotStore(nil).credentialHealth(now: fixedNow) + == .missing(expected: ProviderID.copilot.setup.credentialSources)) +} + +@Test func cursorCredentialHealthReportsMissingCredentials() { + #expect( + MemoryCursorStore(nil).credentialHealth(now: fixedNow) + == .missing(expected: ProviderID.cursor.setup.credentialSources)) +} + +@Test func geminiCredentialHealthReportsMissingCredentials() { + #expect( + MemoryGeminiStore(nil).credentialHealth(now: fixedNow) + == .missing(expected: ProviderID.gemini.setup.credentialSources)) +} + +@Test func copilotKeychainStoreReturnsNilForAnUnusedService() throws { + let store = KeychainCopilotAuthStore(service: "unused", keychain: MemoryKeychain().client) + + #expect(try store.load() == nil) +} + +@Test func emptyKeychainDoesNotLoadOrPersistCredentials() throws { + let keychain = KeychainCredentialClient.empty + + try keychain.save(Data("secret".utf8), service: "tests", account: "tester") + #expect(try keychain.load(service: "tests", account: "tester") == nil) +} diff --git a/Tests/TokenMenuBarCoreTests/ModelTests.swift b/Tests/TokenMenuBarCoreTests/ModelTests.swift new file mode 100644 index 0000000..a79ead7 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ModelTests.swift @@ -0,0 +1,416 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func providerIDMetadata() { + #expect(ProviderID.claude < .codex) + #expect(ProviderID.claude.shortLabel == "CC") + #expect(ProviderID.codex.displayName == "Codex") + #expect(ProviderID.codex.loginHint.contains("codex login")) + #expect(ProviderID.claude.loginHint.contains("claude")) +} + +@Test(arguments: [("warning", Severity.warning), ("CRITICAL", .critical), ("limit_reached", .critical)]) +func severityReadsVendorStrings(raw: String, expected: Severity) { + #expect(Severity(raw: raw) == expected) +} + +@Test func severityWithoutARawStringIsNormal() { + #expect(Severity(raw: nil) == .normal) +} + +@Test(arguments: [(74.0, Severity.normal), (75.0, .warning), (89.9, .warning), (90.0, .critical)]) +func severityThresholdsOnPercent(percent: Double, expected: Severity) { + #expect(Severity(percent: percent) == expected) +} + +@Test func windowGroupsSortShortestFirst() { + #expect([WindowGroup.other, .monthly, .weekly, .session].sorted() == [.session, .weekly, .monthly, .other]) +} + +@Test func quotaWindowClampsAndComputes() { + let window = QuotaWindow( + id: "w", label: "W", group: .session, usedPercent: 140, resetsAt: fixedNow.addingTimeInterval(3600), duration: 18000 + ) + #expect(window.usedPercent == 100) + #expect(window.remainingPercent == 0) + #expect(window.severity == .critical) + #expect(window.windowStart(now: fixedNow) == fixedNow.addingTimeInterval(3600 - 18000)) + #expect( + QuotaWindow(id: "w", label: "W", group: .session, usedPercent: -3, resetsAt: nil).windowStart(now: fixedNow) == nil) + let earlier = QuotaWindow(id: "w", label: "W", group: .session, usedPercent: 90, resetsAt: fixedNow) + #expect(window.hasReset(since: earlier)) + #expect(!earlier.hasReset(since: window)) + let noDates = QuotaWindow(id: "w", label: "W", group: .session, usedPercent: 5, resetsAt: nil) + #expect(noDates.hasReset(since: QuotaWindow(id: "w", label: "W", group: .session, usedPercent: 50, resetsAt: nil))) + #expect(!noDates.hasReset(since: QuotaWindow(id: "w", label: "W", group: .session, usedPercent: 5.5, resetsAt: nil))) +} + +@Test func windowKeyStorageRoundTrip() { + let key = WindowKey(provider: .codex, windowID: "additional:spark:session") + #expect(key.storageKey == "codex:additional:spark:session") + #expect(WindowKey(storageKey: key.storageKey) == key) + #expect(WindowKey(storageKey: "nope") == nil) + #expect(WindowKey(storageKey: "unknown:x") == nil) + #expect(WindowKey(provider: .claude, windowID: "b") < WindowKey(provider: .claude, windowID: "c")) + #expect(WindowKey(provider: .claude, windowID: "z") < WindowKey(provider: .codex, windowID: "a")) + let window = QuotaWindow(id: "session", label: "S", group: .session, usedPercent: 1, resetsAt: nil) + #expect(WindowKey(.claude, window).windowID == "session") +} + +@Test func snapshotSortsWindowsAndFindsWorst() { + let snapshot = ProviderSnapshot( + provider: .claude, + windows: [ + QuotaWindow(id: "weekly", label: "W", group: .weekly, usedPercent: 70, resetsAt: nil), + QuotaWindow(id: "session", label: "S", group: .session, usedPercent: 20, resetsAt: nil), + QuotaWindow(id: "other", label: "O", group: .other, usedPercent: 99, resetsAt: nil, isActive: false), + ], + fetchedAt: fixedNow + ) + #expect(snapshot.windows.map(\.id) == ["session", "weekly", "other"]) + #expect(snapshot.worstWindow?.id == "weekly") + #expect(snapshot.window("missing") == nil) + #expect(ProviderSnapshot(provider: .codex, windows: [], fetchedAt: fixedNow).worstWindow == nil) + #expect(Notice(kind: .info, text: "x").id == "info:x") +} + +@Test func fetchOutcomeAccessors() { + let snapshot = ProviderSnapshot(provider: .claude, windows: [], fetchedAt: fixedNow) + #expect(ProviderFetchOutcome.success(snapshot).snapshot == snapshot) + #expect(ProviderFetchOutcome.partial(snapshot, "why").snapshot == snapshot) + #expect(ProviderFetchOutcome.notAuthenticated("x").snapshot == nil) + #expect(ProviderFetchOutcome.success(snapshot).errorDescription == nil) + #expect(ProviderFetchOutcome.networkUnavailable("down").errorDescription == "down") + #expect(ProviderFetchOutcome.failed("boom").errorDescription == "boom") + #expect(QuotaAvailability.allTitles.count == 8) +} + +extension QuotaAvailability { + static var allTitles: [String] { + [ + QuotaAvailability.loading, .current, .stale, .authenticationRequired, .networkUnavailable, .rateLimited, + .unavailable, .disabled, + ].map(\.title) + } +} + +@Test func analyticsHelpers() { + let analytics = ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: "2026-08-01", metric: .turns, series: "b", value: 2), + AnalyticsPoint(day: "2026-08-01", metric: .turns, series: "a", value: 3), + AnalyticsPoint(day: "2026-08-01", metric: .credits, series: "a", value: 9), + ], + fetchedAt: fixedNow + ) + #expect(analytics.total(.turns) == 5) + #expect(analytics.series(for: .turns) == ["a", "b"]) + #expect(analytics.creditEvents.isEmpty) + #expect(AnalyticsMetric.allCases.map(\.unit).contains("%")) + #expect(AnalyticsMetric.allCases.allSatisfy { !$0.title.isEmpty }) + #expect(DayStamp.string(Date(timeIntervalSince1970: 0)) == "1970-01-01") + #expect(DayStamp.date("not-a-day") == nil) +} + +@Test func providerOutcomeBuilderMapsErrors() { + #expect(ProviderOutcomeBuilder.outcome(for: .network("x"), hint: "h") == .networkUnavailable("x")) + #expect( + ProviderOutcomeBuilder.outcome(for: .http(status: 401, body: "no", retryAfter: nil), hint: "h") + == .notAuthenticated("HTTP 401. h")) + #expect( + ProviderOutcomeBuilder.outcome(for: .http(status: 500, body: "", retryAfter: nil), hint: "h") == .failed("HTTP 500") + ) + #expect(ProviderOutcomeBuilder.outcome(for: .decoding("bad"), hint: "h") == .failed("Unexpected response: bad")) + #expect( + ProviderOutcomeBuilder.outcome(for: .http(status: 429, body: "slow", retryAfter: 42), hint: "h") + == .rateLimited("HTTP 429", retryAfter: 42)) + #expect(ProviderFetchOutcome.rateLimited("x", retryAfter: nil).errorDescription == "x") + let policy = PollingPolicy.defaults(for: .claude) + #expect(policy.interval(active: true, requested: 300) == 120) + #expect(policy.interval(active: true, requested: 60) == 120) + #expect(policy.interval(active: false, requested: 60) == 120) + #expect(policy.interval(active: false, requested: 600) == 600) + #expect(PollingPolicy.defaults(for: .codex).defaultInterval == 120) +} + +@Test func registryOrdersAndLooksUpProviders() { + let registry = ProviderRegistry([scriptedProvider(.codex), scriptedProvider(.claude)]) + #expect(registry.ids == [.claude, .codex]) + #expect(registry[.codex]?.id == .codex) + #expect(ProviderRegistry([])[.claude] == nil) +} + +@Test func clockFixedNeverSleeps() async throws { + let clock = Clock.fixed(fixedNow) + #expect(clock.now() == fixedNow) + try await clock.sleep(1000) + #expect(Clock.system.now().timeIntervalSinceNow < 1) + try await Clock.system.sleep(0.001) +} + +@Test func usageColorInterpolatesGreenToRed() { + #expect( + UsageColor.color(percent: 0) == UsageColor.green) + #expect(UsageColor.color(percent: 100) == UsageColor.red) + #expect(UsageColor.color(percent: 150) == UsageColor.red) + #expect(UsageColor.color(percent: 60) == UsageColor.orange) + let mid = UsageColor.color(percent: 30) + #expect(mid.hue > UsageColor.orange.hue && mid.hue < UsageColor.green.hue) + #expect(mid.brightness > UsageColor.green.brightness && mid.brightness < UsageColor.orange.brightness) + #expect(UsageColor.color(percent: 80).hue < UsageColor.orange.hue) + #expect(UsageColor.color(pace: .ahead, percent: 10) == UsageColor.orange) + #expect(UsageColor.color(pace: .exhausted, percent: 10) == UsageColor.red) + #expect(UsageColor.color(pace: .onTrack, percent: 10) == UsageColor.color(percent: 10)) +} + +@Test func popoverDismissalGateRules() { + let gate = PopoverDismissalGate() + let frame = CGRect(x: 0, y: 0, width: 100, height: 100) + let button = CGRect(x: 200, y: 200, width: 20, height: 20) + let far = CGPoint(x: 500, y: 500) + let movedBeforeEntering = gate.shouldClose(mouseLocation: far, popoverFrame: frame, trigger: .mouseMoved) + #expect(!movedBeforeEntering) + let clickedOutside = gate.shouldClose(mouseLocation: far, popoverFrame: frame, trigger: .mouseDown) + #expect(clickedOutside) + let clickedButton = gate.shouldClose( + mouseLocation: CGPoint(x: 205, y: 205), popoverFrame: frame, excludedFrame: button, trigger: .mouseDown) + #expect(!clickedButton) + let clickedInside = gate.shouldClose(mouseLocation: CGPoint(x: 10, y: 10), popoverFrame: frame, trigger: .mouseDown) + #expect(!clickedInside) + let movedAfterEntering = gate.shouldClose(mouseLocation: far, popoverFrame: frame, trigger: .mouseMoved) + #expect(!movedAfterEntering) + let escaped = gate.shouldClose(mouseLocation: CGPoint(x: 10, y: 10), popoverFrame: nil, trigger: .keyEscape) + #expect(escaped) + let noFrame = gate.shouldClose(mouseLocation: CGPoint(x: 10, y: 10), popoverFrame: nil, trigger: .mouseDown) + #expect(noFrame) +} + +@Test func popoverMaximumUsesTheCurrentDrawableSpace() { + let screen = CGRect(x: 0, y: 0, width: 1440, height: 900) + let centred = PopoverGeometry.maxSize(anchor: CGRect(x: 700, y: 880, width: 40, height: 20), visibleFrame: screen) + #expect(centred.height == 880 - PopoverGeometry.margin) + #expect(centred.width == PopoverGeometry.stableTabWidth) + let shiftedScreen = CGRect(x: 0, y: 100, width: 1440, height: 800) + let shifted = PopoverGeometry.maxSize( + anchor: CGRect(x: 700, y: 850, width: 40, height: 20), visibleFrame: shiftedScreen) + #expect(shifted.height == 850 - shiftedScreen.minY - PopoverGeometry.margin) + let withChrome = PopoverGeometry.maxSize( + anchor: CGRect(x: 700, y: 850, width: 40, height: 20), visibleFrame: shiftedScreen, + popoverChromeSize: CGSize(width: 26, height: 37)) + #expect(withChrome.height == shifted.height - 37) + let narrowWithChrome = PopoverGeometry.maxSize( + anchor: CGRect(x: 280, y: 850, width: 40, height: 20), + visibleFrame: CGRect(x: 0, y: 100, width: 600, height: 800), + popoverChromeSize: CGSize(width: 26, height: 37)) + #expect(narrowWithChrome.width == 600 - PopoverGeometry.margin * 2 - 26) +} + +@Test func popoverKeepsOneWidthAcrossEveryTab() { + #expect(PopoverGeometry.stableWidth() == 880) + for tab in PopoverTab.allCases { + #expect(PopoverGeometry.contentWidth(for: tab) == PopoverGeometry.stableContentWidth) + #expect(PopoverGeometry.tabWidth(for: tab) == PopoverGeometry.stableTabWidth) + } +} + +@Test func popoverSeedsEachTabWithoutReadingTheViewGraph() { + #expect(PopoverGeometry.preferredHeight(for: .usage) == PopoverGeometry.usageInitialHeight) + #expect(PopoverGeometry.preferredHeight(for: .usage, measured: 440) == 440) + #expect(PopoverGeometry.preferredHeight(for: .history, measured: 440) == 440) + #expect(PopoverGeometry.preferredHeight(for: .history, measured: 1_100) == 1_100) + #expect(PopoverGeometry.preferredHeight(for: .settings) == PopoverGeometry.settingsInitialHeight) + #expect(PopoverGeometry.preferredHeight(for: .settings, measured: 800) == 800) +} + +@Test func settingsHeightTracksMountedDynamicContent() { + let about = SettingsHeightInput( + mountedSections: [.about], showsModelFilter: false, providerCount: 0, modelCount: 0, logLineCount: 0, + showsCustomTemplate: false, showsUpdates: false) + let populated = SettingsHeightInput( + mountedSections: Set(SettingsSection.allCases), showsModelFilter: true, providerCount: 5, modelCount: 13, + logLineCount: 8, showsCustomTemplate: true, showsUpdates: true) + let filtered = SettingsHeightInput( + mountedSections: Set(SettingsSection.allCases), showsModelFilter: true, providerCount: 2, modelCount: 3, + logLineCount: 1, showsCustomTemplate: false, showsUpdates: false) + + #expect(PopoverGeometry.settingsHeight(about) < PopoverGeometry.settingsHeight(filtered)) + #expect(PopoverGeometry.settingsHeight(filtered) < PopoverGeometry.settingsHeight(populated)) + #expect(PopoverGeometry.settingsHeight(populated) < 2_500) +} + +@Test func popoverWidthClampsToTheCurrentScreen() { + #expect(PopoverGeometry.stableWidth(maximum: 2000) == 880) + #expect(PopoverGeometry.stableWidth(maximum: 800) == 800) + let tiny = PopoverGeometry.maxSize( + anchor: CGRect(x: 10, y: 50, width: 40, height: 20), visibleFrame: CGRect(x: 0, y: 0, width: 300, height: 100)) + #expect(tiny.width == 300 - PopoverGeometry.margin * 2) +} + +@Test func popoverVisibleFrameUsesTheAvailableRightEdge() { + let screen = CGRect(x: -1200, y: 40, width: 1200, height: 800) + + #expect(PopoverGeometry.visibleFrame(nil, cappedTo: 880) == nil) + #expect(PopoverGeometry.visibleFrame(screen, cappedTo: nil) == screen) + #expect(PopoverGeometry.visibleFrame(screen, cappedTo: 2000) == screen) + #expect( + PopoverGeometry.visibleFrame(screen, cappedTo: 880) + == CGRect(x: -880, y: 40, width: 880, height: 800)) +} + +@Test func popoverBodyUsesMoreThanTheMockupPreviewHeightWhenAvailable() { + let screen = CGRect(x: 0, y: 0, width: 1440, height: 1300) + let anchor = CGRect(x: 700, y: 1260, width: 40, height: 20) + let body = PopoverGeometry.maximumBodyHeight( + anchor: anchor, visibleFrame: screen, chromeHeight: 36) + #expect(body == 1260 - PopoverGeometry.margin - 36) + #expect(body > 620) +} + +@Test func popoverBodyMaximumNeverExceedsThePhysicalDrawableArea() { + let screen = CGRect(x: 0, y: 0, width: 300, height: 100) + let anchor = CGRect(x: 10, y: 50, width: 40, height: 20) + #expect(PopoverGeometry.maximumBodyHeight(anchor: anchor, visibleFrame: screen, chromeHeight: 58) == 0) +} + +@Test func popoverMaximumKeepsADegenerateAnchorVisibleWithoutExceedingTheScreen() { + let screen = CGRect(x: 0, y: 0, width: 1440, height: 900) + #expect(PopoverGeometry.maxSize(anchor: .zero, visibleFrame: screen).height == PopoverGeometry.minimumHeight) + let tiny = CGRect(x: 0, y: 0, width: 300, height: 100) + let maximum = PopoverGeometry.maxSize( + anchor: .zero, visibleFrame: tiny, popoverChromeSize: CGSize(width: 0, height: 20)) + #expect(maximum.height == tiny.height - PopoverGeometry.margin - 20) + #expect(PopoverGeometry.clamp(CGSize(width: 300, height: 500), maximum: maximum).height == maximum.height) +} + +@Test func popoverClampKeepsSizesWithinTheMaximum() { + #expect( + PopoverGeometry.clamp(CGSize(width: 200, height: 5000), maximum: CGSize(width: 480, height: 400)) + == CGSize(width: 480, height: 400)) + #expect( + PopoverGeometry.clamp(CGSize(width: 900, height: 300), maximum: CGSize(width: 800, height: 900)) + == CGSize(width: 800, height: 300)) + #expect(PopoverGeometry.clamp(.zero, maximum: .zero) == .zero) +} + +@Test func popoverRecoveryPlacesAnOffscreenWindowAtTheVisibleTopRight() { + let frame = PopoverGeometry.recoveredFrame( + windowFrame: CGRect(x: -4604, y: 1054, width: 880, height: 620), + visibleFrame: CGRect(x: 0, y: 0, width: 1920, height: 1050)) + #expect(frame == CGRect(x: 1028, y: 430, width: 880, height: 620)) +} + +@Test func popoverPinnedOriginStaysInsideTheVisibleFrame() { + let screen = CGRect(x: 0, y: 0, width: 1440, height: 900) + let origin = PopoverGeometry.pinnedOrigin( + lastTopCenter: CGPoint(x: 1430, y: 890), size: CGSize(width: 400, height: 300), visibleFrame: screen) + #expect(origin == CGPoint(x: 1040, y: 590)) + #expect( + PopoverGeometry.pinnedOrigin( + lastTopCenter: CGPoint(x: -100, y: 100), size: CGSize(width: 400, height: 300), visibleFrame: screen) + == CGPoint(x: 0, y: 0)) +} + +@Test func popoverMaximumUsesShortAndOffsetDisplayCoordinates() { + let cases: [(visible: CGRect, anchor: CGRect, chrome: CGSize, expected: CGSize)] = [ + ( + CGRect(x: 0, y: 40, width: 960, height: 560), + CGRect(x: 880, y: 570, width: 40, height: 20), + CGSize(width: 18, height: 22), + CGSize(width: 880, height: 496) + ), + ( + CGRect(x: -1920, y: -340, width: 1280, height: 720), + CGRect(x: -720, y: 350, width: 40, height: 20), + CGSize(width: 18, height: 24), + CGSize(width: 880, height: 654) + ), + ] + + for item in cases { + let maximum = PopoverGeometry.maxSize( + anchor: item.anchor, visibleFrame: item.visible, popoverChromeSize: item.chrome) + #expect(maximum == item.expected) + #expect(PopoverGeometry.clamp(CGSize(width: 2_000, height: 5_000), maximum: maximum) == maximum) + } +} + +@Test func popoverPinnedOriginUsesTheOffsetVisibleFrame() { + let screen = CGRect(x: -1920, y: 40, width: 1280, height: 800) + let size = CGSize(width: 880, height: 700) + + #expect( + PopoverGeometry.pinnedOrigin( + lastTopCenter: CGPoint(x: -1900, y: 820), size: size, visibleFrame: screen) + == CGPoint(x: -1920, y: 120)) + #expect( + PopoverGeometry.pinnedOrigin( + lastTopCenter: CGPoint(x: -650, y: 820), size: size, visibleFrame: screen) + == CGPoint(x: -1520, y: 120)) + #expect( + PopoverGeometry.pinnedOrigin( + lastTopCenter: CGPoint(x: -1000, y: 100), size: size, visibleFrame: screen) + == CGPoint(x: -1520, y: 40)) +} + +@Test func launchAtLoginBackendReportsStatus() { + #expect(LaunchAtLoginBackend.unsupported.status() == .unknown) + #expect(LaunchAtLoginBackend.unsupported.setEnabled(true) == .unknown) + #expect(LaunchAtLoginBackend.unsupported.setEnabled(false) == .unknown) + #expect(!LaunchAtLoginBackend.Status.notRegistered.isEnabled) + #expect(LaunchAtLoginBackend.Status.requiresApproval.explanation?.contains("Login Items") == true) + #expect(LaunchAtLoginBackend.Status.notFound.explanation?.contains("/Applications") == true) + #expect(LaunchAtLoginBackend.Status.enabled.explanation == nil) + struct Boom: Error {} + let failing = LaunchAtLoginBackend( + status: { .notRegistered }, register: { throw Boom() }, unregister: { throw Boom() }) + #expect(failing.setEnabled(true) == .notRegistered) + #expect(failing.setEnabled(false) == .notRegistered) + let ok = LaunchAtLoginBackend(status: { .enabled }, register: {}, unregister: {}) + #expect(ok.setEnabled(true) == .enabled) + ok.openSettings() + let memory = LaunchAtLoginBackend.inMemory() + #expect(memory.status() == .notRegistered) + #expect(memory.setEnabled(true) == .enabled) + #expect(memory.setEnabled(false) == .notRegistered) + #expect(LaunchAtLoginBackend.inMemory(initiallyEnabled: true).status() == .enabled) +} + +@Test(arguments: [ + ("claude.home", ["CLAUDE_CONFIG_DIR": "/cfg/claude"], "/cfg/claude"), + ("codex.home", ["CODEX_HOME": "/cfg/codex"], "/cfg/codex"), + ("gemini.home", ["GEMINI_CLI_HOME": "/cfg"], "/cfg/.gemini"), + ("copilot.config", ["XDG_CONFIG_HOME": "/xdg"], "/xdg/github-copilot"), + ("copilot.home", ["COPILOT_HOME": "/copilot"], "/copilot"), + ("cursor.home", [:], "/home/.cursor"), +]) +func sandboxResourcesFollowTheirConfiguredLocation(id: String, environment: [String: String], expected: String) { + let resource = ProviderID.allSandboxResources.first { $0.id == id }! + #expect(resource.configuredURL(environment: environment, home: URL(fileURLWithPath: "/home")).path == expected) + #expect(resource.configuredURL(environment: [:], home: URL(fileURLWithPath: "/home")).path.hasPrefix("/home")) +} + +@Test func sandboxResourcesDescribeEveryProviderPath() { + #expect(ProviderID.allSandboxResources.count == 8) + #expect(ProviderID.claude.sandboxResources.map(\.kind) == [.directory, .file]) + #expect(ProviderID.allSandboxResources.allSatisfy { $0.label.hasPrefix("~/") }) + #expect(Set(ProviderID.allSandboxResources.map(\.id)).count == 8) +} + +@Test( + arguments: [ + (["app", "--export-icon", "/tmp/a"], ExportCommand.icons), (["--export-menubar", "/tmp/a"], .menuBar), + (["--export-popover", "/tmp/a"], .popover), + ]) +func exportCommandParsesItsFlag(arguments: [String], command: ExportCommand) { + let parsed = ExportCommand.parse(arguments) + #expect(parsed?.command == command) + #expect(parsed?.directory.path == "/tmp/a") + #expect(!command.failureMessage.isEmpty) +} + +@Test(arguments: [["app"], ["app", "--export-icon"], []]) +func exportCommandNeedsAFlagAndADirectory(arguments: [String]) { + #expect(ExportCommand.parse(arguments) == nil) +} diff --git a/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift b/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift new file mode 100644 index 0000000..0b5546b --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func plannerIsSilentWhenDisabledOrWithoutHistory() { + #expect(plan(nil, snapshot(99)).isEmpty) + #expect(plan(snapshot(10), snapshot(99), settings: NotificationSettings(enabled: false)).isEmpty) + #expect(plan(snapshot(10), nil).isEmpty) +} + +private func snapshot( + _ percent: Double, resets: TimeInterval = 3600, credits: Bool? = nil, extra: [QuotaWindow] = [] +) -> ProviderSnapshot { + ProviderSnapshot( + provider: .claude, + windows: [ + QuotaWindow( + id: "session", label: "Current session", group: .session, usedPercent: percent, + resetsAt: fixedNow.addingTimeInterval(resets), duration: 18000) + ] + extra, + credits: credits.map { CreditBalance(balance: nil, hasCredits: $0) }, + fetchedAt: fixedNow + ) +} + +private func plan( + _ previous: ProviderSnapshot?, _ current: ProviderSnapshot?, from: QuotaAvailability = .current, + to: QuotaAvailability = .current, settings: NotificationSettings = NotificationSettings() +) -> [NotificationEvent] { + NotificationPlanner.events( + previous: previous, current: current, previousAvailability: from, currentAvailability: to, provider: .claude, + settings: settings, now: fixedNow) +} + +@Test func plannerReportsHighestCrossedThreshold() { + let events = plan(snapshot(70), snapshot(92)) + #expect(events.count == 1) + #expect(events[0].kind == .threshold) + #expect(events[0].title == "Claude Current session at 92%") + #expect(events[0].body.hasPrefix("Crossed 90% of the current session limit.")) + #expect(events[0].body.contains("Resets")) + #expect(events[0].id.hasPrefix("claude:session:90:")) + #expect(plan(snapshot(92), snapshot(93)).isEmpty) + #expect(plan(snapshot(95), snapshot(100))[0].body.hasPrefix("Limit reached.")) +} + +@Test func plannerSkipsThresholdsAcrossResetAndReportsReset() { + let events = plan(snapshot(95, resets: 60), snapshot(5, resets: 18060)) + #expect(events.map(\.kind) == [.reset]) + #expect(events[0].title == "Claude Current session reset") + #expect(events[0].body == "Usage is back to 5%.") + #expect(plan(snapshot(20, resets: 60), snapshot(5, resets: 18060)).isEmpty) + #expect( + plan(snapshot(95, resets: 60), snapshot(5, resets: 18060), settings: NotificationSettings(notifyOnReset: false)) + .isEmpty) +} + +@Test func plannerDetectsResetWithoutResetDates() { + let previous = ProviderSnapshot( + provider: .claude, windows: [QuotaWindow(id: "w", label: "W", group: .other, usedPercent: 80, resetsAt: nil)], + fetchedAt: fixedNow) + let current = ProviderSnapshot( + provider: .claude, windows: [QuotaWindow(id: "w", label: "W", group: .other, usedPercent: 3, resetsAt: nil)], + fetchedAt: fixedNow) + #expect(plan(previous, current).map(\.kind) == [.reset]) + #expect(plan(previous, current)[0].id == "claude:w:reset:0") +} + +@Test func plannerIgnoresNewWindowsWithoutPrevious() { + let extra = QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: 99, resetsAt: nil) + #expect(plan(snapshot(10), snapshot(10, extra: [extra])).isEmpty) +} + +@Test func plannerReportsAuthenticationAndCredits() { + let auth = plan(snapshot(10), snapshot(10), from: .current, to: .authenticationRequired) + #expect(auth.map(\.kind) == [.authentication]) + #expect(auth[0].title == "Claude sign-in needed") + #expect(plan(snapshot(10), snapshot(10), from: .authenticationRequired, to: .current).isEmpty) + #expect(plan(nil, nil, from: .current, to: .authenticationRequired).count == 1) + #expect( + plan( + nil, nil, from: .current, to: .authenticationRequired, settings: NotificationSettings(notifyOnAuthProblems: false) + ).isEmpty) + let credits = plan(snapshot(10, credits: true), snapshot(10, credits: false)) + #expect(credits.map(\.kind) == [.credits]) + #expect(plan(snapshot(10, credits: false), snapshot(10, credits: true)).isEmpty) +} + +@Test func notificationSettingsSanitizeThresholds() { + let settings = NotificationSettings(thresholds: [150, 90, 0, 50]) + #expect(settings.thresholds == [50, 90]) + #expect(NotificationSettings().thresholds == [75, 90, 100]) +} + +@Test func plannerStaysQuietForProvidersThatNeverSignedIn() { + let settings = NotificationSettings(enabled: true, thresholds: [], notifyOnReset: false, notifyOnAuthProblems: true) + let events = NotificationPlanner.events( + previous: nil, current: nil, previousAvailability: .loading, currentAvailability: .authenticationRequired, + provider: .gemini, settings: settings, credentialMissing: true, now: fixedNow) + #expect(events.isEmpty) + let signedOut = NotificationPlanner.events( + previous: nil, current: nil, previousAvailability: .loading, currentAvailability: .authenticationRequired, + provider: .gemini, settings: settings, now: fixedNow) + #expect(signedOut.map(\.kind) == [.authentication]) +} diff --git a/Tests/TokenMenuBarCoreTests/PaceComparisonTests.swift b/Tests/TokenMenuBarCoreTests/PaceComparisonTests.swift new file mode 100644 index 0000000..0e20956 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/PaceComparisonTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +private let paceNow = Date(timeIntervalSince1970: 1_788_030_000) + +@Test func unknownPaceExplainsThatItIsStillLearning() { + let pace = PaceEstimate(status: .unknown, expectedPercent: nil, ratio: nil, projectedExhaustion: nil) + #expect(pace.comparison(now: paceNow) == "Learning pace") +} + +@Test func earlyPaceShowsExpectedUsageAndProjectionWhenKnown() { + let pace = PaceEstimate( + status: .unknown, expectedPercent: 3, ratio: nil, + projectedExhaustion: paceNow.addingTimeInterval(2 * 86400)) + let comparison = pace.comparison(now: paceNow) + #expect(comparison.contains("Learning pace")) + #expect(comparison.contains("expected 3%")) + #expect(comparison.contains("hits 100% in 2d")) +} + +@Test func onTrackPaceShowsRatioAndThatItLastsToReset() { + let pace = PaceEstimate(status: .onTrack, expectedPercent: 42, ratio: 1, projectedExhaustion: nil) + let comparison = pace.comparison(now: paceNow) + #expect(comparison.contains("On pace")) + #expect(comparison.contains("expected 42%")) + #expect(comparison.contains("1.0×")) + #expect(comparison.hasSuffix("lasts to reset")) +} + +@Test func aheadPaceShowsRatioAndProjectedExhaustion() { + let pace = PaceEstimate( + status: .ahead, expectedPercent: 42, ratio: 1.6, + projectedExhaustion: paceNow.addingTimeInterval(2 * 86400)) + let comparison = pace.comparison(now: paceNow) + #expect(comparison.contains("Ahead of pace")) + #expect(comparison.contains("expected 42%")) + #expect(comparison.contains("1.6×")) + #expect(comparison.contains("hits 100% in 2d")) +} + +@Test func underPaceShowsRatioAndThatItLastsToReset() { + let pace = PaceEstimate(status: .behind, expectedPercent: 42, ratio: 0.6, projectedExhaustion: nil) + let comparison = pace.comparison(now: paceNow) + #expect(comparison.contains("Under pace")) + #expect(comparison.contains("0.6×")) + #expect(comparison.hasSuffix("lasts to reset")) +} + +@Test func exhaustedPaceStatesThatTheLimitWasReached() { + let pace = PaceEstimate(status: .exhausted, expectedPercent: nil, ratio: nil, projectedExhaustion: paceNow) + #expect(pace.comparison(now: paceNow) == "Limit reached") +} diff --git a/Tests/TokenMenuBarCoreTests/PaceTests.swift b/Tests/TokenMenuBarCoreTests/PaceTests.swift new file mode 100644 index 0000000..eb1031b --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/PaceTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func paceExhaustedAt100() { + let estimate = PaceEstimate.estimate(window: window(used: 100, elapsedFraction: 0.5), now: fixedNow) + #expect(estimate.status == .exhausted) + #expect(estimate.projectedExhaustion == fixedNow) + #expect(estimate.summary(now: fixedNow) == "Limit reached") +} + +private func window(used: Double, elapsedFraction: Double, duration: TimeInterval = 18000) -> QuotaWindow { + QuotaWindow( + id: "session", label: "Session", group: .session, usedPercent: used, + resetsAt: fixedNow.addingTimeInterval(duration * (1 - elapsedFraction)), duration: duration) +} + +@Test func paceUnknownWithoutResetOrDuration() { + let noReset = QuotaWindow(id: "x", label: "X", group: .other, usedPercent: 10, resetsAt: nil) + #expect(PaceEstimate.estimate(window: noReset, now: fixedNow).status == .unknown) + let past = QuotaWindow( + id: "x", label: "X", group: .other, usedPercent: 10, resetsAt: fixedNow.addingTimeInterval(-1), duration: 10) + #expect(PaceEstimate.estimate(window: past, now: fixedNow).status == .unknown) + #expect(PaceEstimate.estimate(window: noReset, now: fixedNow).summary(now: fixedNow) == "Early in window") +} + +@Test func paceTooEarlyReportsUnknownWithProjection() { + let estimate = PaceEstimate.estimate(window: window(used: 20, elapsedFraction: 0.02), now: fixedNow) + #expect(estimate.status == .unknown) + #expect(estimate.expectedPercent.map { $0 < 5 } == true) + #expect(estimate.projectedExhaustion != nil) + #expect(estimate.summary(now: fixedNow).hasPrefix("Early in window; at this rate")) +} + +@Test(arguments: ratioCases) +func paceStatusFromRatio(used: Double, elapsed: Double, expected: PaceStatus) { + let estimate = PaceEstimate.estimate(window: window(used: used, elapsedFraction: elapsed), now: fixedNow) + #expect(estimate.status == expected) + #expect(estimate.ratio == used / (elapsed * 100)) + #expect(estimate.expectedPercent == 50) +} + +private let ratioCases: [(Double, Double, PaceStatus)] = [(50, 0.5, .onTrack), (80, 0.5, .ahead), (10, 0.5, .behind)] + +@Test func paceProjectionUsesSampleSlopeWhenAvailable() { + let key = WindowKey(provider: .claude, windowID: "session") + let samples = [ + UsageSample(timestamp: fixedNow.addingTimeInterval(-1200), key: key, usedPercent: 40, resetsAt: nil), + UsageSample(timestamp: fixedNow, key: key, usedPercent: 50, resetsAt: nil), + ] + let estimate = PaceEstimate.estimate(window: window(used: 50, elapsedFraction: 0.5), samples: samples, now: fixedNow) + #expect(estimate.projectedExhaustion == fixedNow.addingTimeInterval(6000)) + #expect(estimate.summary(now: fixedNow).contains("hits 100%")) + let flat = [ + UsageSample(timestamp: fixedNow.addingTimeInterval(-60), key: key, usedPercent: 50, resetsAt: nil), samples[1], + ] + let fallback = PaceEstimate.estimate(window: window(used: 50, elapsedFraction: 0.5), samples: flat, now: fixedNow) + #expect(fallback.projectedExhaustion == nil) + #expect(fallback.summary(now: fixedNow).hasSuffix("lasts until reset")) +} + +@Test func paceProjectionNilWhenUsageIsZeroOrBeyondReset() { + #expect( + PaceEstimate.estimate(window: window(used: 0, elapsedFraction: 0.5), now: fixedNow).projectedExhaustion == nil) + #expect( + PaceEstimate.estimate(window: window(used: 10, elapsedFraction: 0.5), now: fixedNow).projectedExhaustion == nil) + #expect(PaceStatus.behind.title == "Under pace") +} diff --git a/Tests/TokenMenuBarCoreTests/PanelMaterialTests.swift b/Tests/TokenMenuBarCoreTests/PanelMaterialTests.swift new file mode 100644 index 0000000..7ba4760 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/PanelMaterialTests.swift @@ -0,0 +1,20 @@ +import Testing + +@testable import TokenMenuBarCore + +@Test(arguments: PlatformDesignGeneration.allCases, PanelSurfaceRole.allCases) +func panelMaterialPolicyCoversEveryGenerationAndSurface( + generation: PlatformDesignGeneration, + surface: PanelSurfaceRole +) { + let expected: PanelMaterial = + switch surface { + case .popoverChrome: .system + case .content: .standardContent + } + #expect(PanelMaterialPolicy.material(for: surface, generation: generation) == expected) +} + +@Test func platformDesignGenerationsMatchSupportedPolicyForks() { + #expect(PlatformDesignGeneration.allCases == [.macOS14, .macOS15, .macOS26]) +} diff --git a/Tests/TokenMenuBarCoreTests/PresenterTests.swift b/Tests/TokenMenuBarCoreTests/PresenterTests.swift new file mode 100644 index 0000000..2690f7d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/PresenterTests.swift @@ -0,0 +1,789 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func usagePresenterOrdersCardsByProvider() { + #expect(presentedCards().map(\.provider) == [.claude, .codex]) + #expect(presentedCards()[0].id == .claude) + #expect(UsagePresenter.iconTone([.claude: ProviderState(availability: .authenticationRequired)]) == .attention) + #expect(UsagePresenter.iconTone([.claude: ProviderState(availability: .networkUnavailable)]) == .offline) + #expect(UsagePresenter.iconTone([.claude: ProviderState(availability: .current)]) == .normal) +} + +private func presentedCards() -> [ProviderCard] { + let state: [ProviderID: ProviderState] = [ + .claude: ProviderState( + snapshot: snapshot(), availability: .current, warnings: ["w"], credentialState: .valid(expiresAt: nil)), + .codex: ProviderState( + availability: .networkUnavailable, lastError: "offline", credentialState: .valid(expiresAt: nil)), + ] + return UsagePresenter.cards(state: state, enabled: [.claude, .codex], samples: [:], now: fixedNow) +} + +@Test func usagePresenterBuildsIdentityChips() { + let claude = presentedCards()[0] + #expect( + claude.chips.map(\.text) == [ + "Max 20x", "user@example.com", + "Renews \(fixedNow.addingTimeInterval(86400).formatted(date: .abbreviated, time: .omitted))", + ]) +} + +@Test func usagePresenterBuildsWindowRows() { + let claude = presentedCards()[0] + #expect(claude.rows.count == 1) + #expect(claude.rows[0].percentText == "36%") + #expect(claude.rows[0].countdown == "1 hr 0 min") + #expect(claude.rows[0].id == claude.rows[0].key) + #expect(claude.rows[0].color == UsageColor.color(pace: claude.rows[0].pace.status, percent: 36)) + #expect(claude.rows[0].detail == "window · 5h") + #expect(claude.groups.map(\.rows.count) == [1]) + #expect(claude.groups[0].resetText(at: fixedNow)?.contains("Resets in") == true) +} + +@Test func usagePresenterReportsFreshnessAndWarnings() { + let claude = presentedCards()[0] + #expect(claude.fetchedAge == "30s ago") + #expect(!claude.isStale) + #expect(claude.warnings == ["w"]) + #expect(!claude.isRefreshing) + #expect(claude.notices.count == 1) +} + +@Test func usagePresentationKeepsQuotaAndSupplementaryData() throws { + let state = ProviderState( + snapshot: snapshot(), + analytics: ProviderAnalytics( + provider: .claude, + points: [ + AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .codeReviews, series: "reviews", value: 4) + ], fetchedAt: fixedNow), + availability: .current) + let key = WindowKey(provider: .claude, windowID: "session") + let presentation = UsagePresenter.presentation( + state: [.claude: state], enabled: [.claude], selected: [key], samples: [:], + analytics: [.claude: UsagePresenter.analyticsPresentation(state.analytics, now: fixedNow)], + lastRefresh: fixedNow.addingTimeInterval(-30), iconTone: .attention, isRefreshing: true, now: fixedNow) + let card = try #require(presentation.cards.first) + let row = try #require(card.rows.first) + #expect(presentation.iconTone == .attention) + #expect(presentation.isRefreshing) + #expect(row.key == key) + #expect(row.isSelected) + #expect(row.window == state.snapshot?.windows.first) + #expect(card.identity == state.snapshot?.identity) + #expect(card.creditsPresentation?.credits == state.snapshot?.credits) + #expect(card.spendPresentation?.spend == state.snapshot?.spend) + #expect(card.codeReviews == "4 today · 4 this week") + #expect(card.notices == state.snapshot?.notices) + #expect(card.source == state.snapshot?.source) +} + +@Test func usagePresentationOmitsModelsOutsideTheCuratedSelection() throws { + let state = ProviderState(snapshot: snapshot(), availability: .current) + let presentation = UsagePresenter.presentation( + state: [.claude: state], enabled: [.claude], selected: [], samples: [:], analytics: [:], + lastRefresh: nil, iconTone: .normal, isRefreshing: false, now: fixedNow) + #expect(try #require(presentation.cards.first).rows.isEmpty) +} + +@Test func usageDeadlinesScheduleTheirNextVisibleChange() { + let age = UsageDeadline.age(fixedNow.addingTimeInterval(-30)) + #expect(age.text(at: fixedNow) == "30s ago") + #expect(age.nextUpdate(after: fixedNow) == fixedNow.addingTimeInterval(30)) + #expect(UsageDeadline.age(fixedNow).nextUpdate(after: fixedNow) == fixedNow.addingTimeInterval(60)) + let minuteAge = UsageDeadline.age(fixedNow.addingTimeInterval(-120)) + #expect(minuteAge.nextUpdate(after: fixedNow) == fixedNow.addingTimeInterval(60)) + #expect(UsageDeadline.age(nil).nextUpdate(after: fixedNow) == nil) + #expect( + UsageDeadline.age(fixedNow.addingTimeInterval(-2 * 3600)).nextUpdate(after: fixedNow) + == fixedNow.addingTimeInterval(3600)) + #expect( + UsageDeadline.age(fixedNow.addingTimeInterval(-2 * 86400)).nextUpdate(after: fixedNow) + == fixedNow.addingTimeInterval(86400)) + let reset = UsageDeadline.reset(fixedNow.addingTimeInterval(3601)) + #expect(reset.text(at: fixedNow).hasPrefix("Resets in 1 hr 0 min")) + #expect(reset.lines(at: fixedNow).count == 2) + #expect(reset.lines(at: fixedNow).joined(separator: " · ") == reset.text(at: fixedNow)) + #expect(reset.nextUpdate(after: fixedNow) == fixedNow.addingTimeInterval(1)) + #expect( + UsageDeadline.reset(fixedNow.addingTimeInterval(30)).nextUpdate(after: fixedNow) + == fixedNow.addingTimeInterval(30)) + #expect(UsageDeadline.reset(nil).text(at: fixedNow) == "No reset scheduled") + #expect(UsageDeadline.age(nil).lines(at: fixedNow) == ["never"]) + #expect(UsageDeadline.reset(fixedNow).nextUpdate(after: fixedNow) == nil) +} + +@Test func usagePresentationFormatsItsLastRefresh() { + let presentation = UsagePresentation( + builtAt: fixedNow, lastRefresh: fixedNow.addingTimeInterval(-30), iconTone: .normal, isRefreshing: false, + cards: [], emptyTitle: "", emptyDescription: "") + #expect(presentation.updatedText(at: fixedNow) == "Updated 30s ago") +} + +@Test func usagePresenterHidesAuthenticationOnlyRecoveryCards() { + let state = ProviderState(availability: .authenticationRequired, lastError: "expired") + #expect(UsagePresenter.cards(state: [.codex: state], enabled: [.codex], samples: [:], now: fixedNow).isEmpty) +} + +@Test func usagePresenterFiltersDisabledProvidersWithoutSnapshots() { + let state: [ProviderID: ProviderState] = [ + .claude: ProviderState(snapshot: snapshot(), availability: .disabled), + .codex: ProviderState(availability: .disabled), + ] + let cards = UsagePresenter.cards(state: state, enabled: [], samples: [:], now: fixedNow) + #expect(cards.isEmpty) +} + +private func snapshot(source: DataSource = .network) -> ProviderSnapshot { + ProviderSnapshot( + provider: .claude, + identity: ProviderIdentity( + planName: "Max 20x", tier: "t", email: "user@example.com", organization: "user@example.com's Organization", + subscriptionActiveUntil: fixedNow.addingTimeInterval(86400)), + windows: [ + QuotaWindow( + id: "session", label: "Current session", group: .session, usedPercent: 36, + resetsAt: fixedNow.addingTimeInterval(3600), duration: 18000) + ], + credits: CreditBalance(balance: 12.5, currency: "USD", hasCredits: true, approxLocalMessages: 5...10), + spend: SpendControl( + enabled: true, used: Money(amountMinor: 100, currency: "USD"), limit: Money(amountMinor: 1000, currency: "USD"), + percent: 10), + resetCredits: ResetCredits(available: 1, applicable: 1), + notices: [Notice(kind: .info, text: "hi")], + source: source, + fetchedAt: fixedNow.addingTimeInterval(-30) + ) +} + +@Test func usagePresenterChipsAndEmptyStates() { + let local = snapshot(source: .localLog) + #expect(UsagePresenter.chips(provider: .codex, snapshot: local).last?.text == "From local logs") + let plain = ProviderSnapshot( + provider: .codex, identity: ProviderIdentity(planName: "Pro", organization: "Org"), windows: [], fetchedAt: fixedNow + ) + #expect(UsagePresenter.chips(provider: .codex, snapshot: plain).map(\.text) == ["Pro", "Org"]) + #expect(UsagePresenter.chips(provider: .codex, snapshot: nil).isEmpty) + let states: [(QuotaAvailability, String)] = [ + (.loading, "Loading Claude"), (.authenticationRequired, "No usage available"), + (.networkUnavailable, "Claude is offline"), + (.disabled, "Claude disabled"), (.unavailable, "Claude unavailable"), (.rateLimited, "Claude rate limited"), + (.current, "No usage yet"), + (.stale, "No usage yet"), + ] + for (availability, title) in states { + #expect(UsagePresenter.emptyState(provider: .claude, state: ProviderState(availability: availability)).0 == title) + } + #expect( + UsagePresenter.emptyState(provider: .claude, state: ProviderState(availability: .networkUnavailable)).1 + == "No network connection.") + #expect( + UsagePresenter.emptyState(provider: .claude, state: ProviderState(availability: .unavailable)).1 + == "The usage endpoint returned an error.") + #expect( + UsagePresenter.emptyState(provider: .claude, state: ProviderState(availability: .authenticationRequired)).1 + == "Review Claude under Settings > Providers.") +} + +@Test func usagePresenterCodeReviewSummary() { + #expect(UsagePresenter.codeReviewSummary(nil, now: fixedNow) == nil) + let empty = ProviderAnalytics( + provider: .codex, points: [AnalyticsPoint(day: "d", metric: .turns, series: "m", value: 1)], fetchedAt: fixedNow) + #expect(UsagePresenter.codeReviewSummary(empty, now: fixedNow) == nil) + let today = DayStamp.string(fixedNow) + let old = DayStamp.string(fixedNow.addingTimeInterval(-3 * 86400)) + let ancient = DayStamp.string(fixedNow.addingTimeInterval(-10 * 86400)) + let analytics = ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: today, metric: .codeReviews, series: "reviews", value: 2), + AnalyticsPoint(day: old, metric: .codeReviews, series: "reviews", value: 7), + AnalyticsPoint(day: ancient, metric: .codeReviews, series: "reviews", value: 100), + ], fetchedAt: fixedNow) + #expect(UsagePresenter.codeReviewSummary(analytics, now: fixedNow) == "2 today · 9 this week") + let state = ProviderState(snapshot: snapshot(), analytics: analytics, availability: .current) + #expect( + UsagePresenter.card(provider: .codex, state: state, samples: [:], now: fixedNow).codeReviews + == "2 today · 9 this week") +} + +@Test func usagePresenterSummaries() { + #expect(UsagePresenter.spendSummary(SpendControl(enabled: false)) == "Off") + #expect( + UsagePresenter.spendSummary(SpendControl(enabled: false, disabledReason: "org_level_disabled")) + == "Off (Org Level Disabled)") + let spend = SpendControl( + enabled: true, used: Money(amountMinor: 2445, currency: "USD"), limit: Money(amountMinor: 8000, currency: "USD"), + percent: 31) + #expect(UsagePresenter.spendSummary(spend).contains("24.45")) + #expect(UsagePresenter.spendSummary(spend).hasSuffix("(31%)")) + #expect(UsagePresenter.spendSummary(SpendControl(enabled: true)) == "— of no limit") + #expect(UsagePresenter.creditsSummary(CreditBalance(balance: nil, unlimited: true)) == "Unlimited") + #expect(UsagePresenter.creditsSummary(CreditBalance(balance: 0, hasCredits: false)) == "No credits") + #expect(UsagePresenter.creditsSummary(CreditBalance(balance: 3, currency: "USD", hasCredits: true)).contains("3.00")) + #expect( + UsagePresenter.creditsSummary(CreditBalance(balance: 3, hasCredits: true, approxLocalMessages: 1...4)) + == "3 · ~1–4 local messages") + #expect(Chip(text: "x").id == "x") +} + +@Test @MainActor func historyPresenterLoadsOneSeriesPerWindow() async throws { + let (presenter, _, data) = try await loadedPresenter() + #expect(data.series.map(\.label) == ["Claude Session", "Codex Session"]) + #expect(presenter.availableWindows.count == 2) + #expect(presenter.earliest == fixedNow.addingTimeInterval(-3600)) +} + +@MainActor +private func loadedPresenter() async throws -> (HistoryPresenter, Settings, HistoryRenderData) { + let (presenter, history, settings) = try makePresenter() + try await history.record( + sample(.claude, 10, at: fixedNow.addingTimeInterval(-3600)), now: fixedNow.addingTimeInterval(-3600)) + try await history.record( + sample(.claude, 40, at: fixedNow.addingTimeInterval(-600)), now: fixedNow.addingTimeInterval(-600)) + try await history.record( + sample(.codex, 20, at: fixedNow.addingTimeInterval(-600)), now: fixedNow.addingTimeInterval(-600)) + try await history.record( + ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .turns, series: "m", value: 3), + AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .surfaceUsagePercent, series: "cli", value: 40), + ], fetchedAt: fixedNow)) + #expect(presenter.state == .loading) + presenter.reload() + await presenter.waitForLoad() + guard case .loaded(let data, false, nil) = presenter.state else { + Issue.record("unexpected state \(presenter.state)") + throw CancellationError() + } + return (presenter, settings, data) +} + +@Test @MainActor func historyPresenterGroupsAnalyticsByMetric() async throws { + let (presenter, _, _) = try await loadedPresenter() + presenter.setMetric(.analytics(.turns)) + await presenter.waitForLoad() + #expect(presenter.state.data?.series.map(\.id.provider) == [.codex]) + #expect(presenter.state.data?.series.flatMap(\.points).map(\.value) == [3]) + presenter.setMetric(.analytics(.surfaceUsagePercent)) + await presenter.waitForLoad() + #expect(presenter.state.data?.summaryText == "1 series · latest Aug 29") +} + +@Test @MainActor func historyPresenterReloadInvalidatesCachedMetrics() async throws { + let (presenter, history, _) = try makePresenter() + try await history.record( + sample(.claude, 10, at: fixedNow.addingTimeInterval(-600)), now: fixedNow.addingTimeInterval(-600)) + try await history.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .turns, series: "m", value: 3)], + fetchedAt: fixedNow)) + presenter.reload() + await presenter.waitForLoad() + presenter.setMetric(.analytics(.turns)) + await presenter.waitForLoad() + try await history.record( + sample(.claude, 90, at: fixedNow.addingTimeInterval(-60)), now: fixedNow.addingTimeInterval(-60)) + + presenter.reload() + await presenter.waitForLoad() + presenter.setMetric(.windowUsagePercent) + await presenter.waitForLoad() + + let values = presenter.state.data?.series.flatMap(\.points).map(\.value) + #expect(values?.contains(90) == true) +} + +@Test @MainActor func historyPresenterReadsTheSelectedPoint() async throws { + let (presenter, _, data) = try await loadedPresenter() + #expect(presenter.value(for: data.series[0]) == "40%") + presenter.select(x: fixedNow.addingTimeInterval(-3500)) + #expect(presenter.selectedDate == data.series[0].points.first?.date) + #expect(presenter.value(for: data.series[0]) == "10%") + presenter.select(x: nil) + #expect(presenter.selectedDate == nil) + #expect( + presenter.value(for: HistorySeries(key: WindowKey(provider: .claude, windowID: "session"), label: "x", points: [])) + == "—") +} + +@Test @MainActor func historyPresenterMovesSelectionAcrossVisibleDates() async throws { + let (presenter, _, data) = try await loadedPresenter() + + for date in data.timeline { + presenter.moveSelection(1) + #expect(presenter.selectedDate == date) + } + presenter.moveSelection(1) + #expect(presenter.selectedDate == data.timeline.last) + for date in data.timeline.dropLast().reversed() { + presenter.moveSelection(-1) + #expect(presenter.selectedDate == date) + } + presenter.selectedDate = nil + presenter.moveSelection(-1) + #expect(presenter.selectedDate == data.timeline.last) +} + +@Test @MainActor func historyPresenterKeepsWindowAndAnalyticsHoverStateConsistent() async throws { + let (presenter, _, data) = try await loadedPresenter() + let window = data.series[0].id + + presenter.setHovered(window) + #expect(presenter.hoveredSeriesID == window) + #expect(presenter.hoveredKey == data.series[0].key) + let analytics = HistorySeriesID.analytics(provider: .codex, series: "cli") + presenter.setHovered(analytics) + #expect(presenter.hoveredSeriesID == analytics) + #expect(presenter.hoveredKey == nil) + presenter.setHovered(nil) + #expect(presenter.hoveredSeriesID == nil) + #expect(presenter.hoveredKey == nil) +} + +@Test @MainActor func historyPresenterDescribesASelectedReset() async throws { + let (presenter, history, _) = try makePresenter() + let earlier = fixedNow.addingTimeInterval(-3600) + let later = fixedNow.addingTimeInterval(-600) + try await history.record(sample(.claude, 80, at: earlier), now: earlier) + try await history.record(sample(.claude, 5, at: later), now: later) + presenter.reload() + await presenter.waitForLoad() + let series = try #require(presenter.state.data?.series.first) + let reset = try #require(series.points.first { $0.isReset }) + + presenter.select(x: reset.date) + + #expect(presenter.resetDescription(for: series)?.hasPrefix("Reset ") == true) +} + +@Test @MainActor func historyPresenterInterpolatesAcrossMissingBuckets() throws { + let (presenter, _, _) = try makePresenter() + let first = fixedNow.addingTimeInterval(-600) + let series = HistorySeries( + id: .window(WindowKey(provider: .claude, windowID: "session")), label: "Session", + points: [ + SeriesPoint(date: first, value: 10, segment: 0), + SeriesPoint(date: fixedNow, value: 40, segment: 1), + ], summaryValue: 40) + presenter.selectedDate = fixedNow.addingTimeInterval(-300) + + #expect(presenter.value(for: series) == "10%") +} + +@Test @MainActor func historyPresenterLimitsHistoryToEnabledModels() async throws { + let (presenter, _, _) = try await loadedPresenter() + let selected = WindowKey(provider: .claude, windowID: "session") + + presenter.setDataScope(HistoryDataScope(activeProviders: [.claude], selectedWindows: [selected])) + await presenter.waitForLoad() + + #expect(presenter.state.data?.series.map(\.id) == [.window(selected)]) + #expect(presenter.request(now: fixedNow).allKeys == [selected]) +} + +@Test @MainActor func historyPresenterHidesAndIsolatesWindows() async throws { + let (presenter, settings, _) = try await loadedPresenter() + let key = WindowKey(provider: .claude, windowID: "session") + let codex = WindowKey(provider: .codex, windowID: "session") + presenter.toggleVisibility(key) + await presenter.waitForLoad() + #expect(!presenter.isVisible(key)) + #expect(presenter.request(now: fixedNow).keys == [codex]) + presenter.toggleVisibility(codex) + await presenter.waitForLoad() + #expect(presenter.isVisible(codex)) + presenter.toggleVisibility(key) + await presenter.waitForLoad() + #expect(presenter.isVisible(key)) + presenter.isolate(key) + await presenter.waitForLoad() + #expect(settings.historyHiddenKeys == [codex]) + presenter.isolate(key) + await presenter.waitForLoad() + #expect(settings.historyHiddenKeys.isEmpty) +} + +@Test @MainActor func historyPresenterPairsRangeWithRollup() async throws { + let (presenter, _, settings) = try makePresenter() + presenter.setRollup(.day) + #expect(settings.historyRange == .week) + presenter.setRange(.today) + #expect(settings.historyRollup == .hour) +} + +@MainActor +private func makePresenter() throws -> (HistoryPresenter, UsageHistoryStore, Settings) { + let settings = Settings(defaults: UserDefaults(suiteName: "history-presenter-\(UUID().uuidString)")!) + let history = try UsageHistoryStore(url: nil) + return (HistoryPresenter(history: history, settings: settings, clock: testClock), history, settings) +} + +@Test @MainActor func historyPresenterLoadsOnFirstDemand() async throws { + let (presenter, _, _) = try makePresenter() + + presenter.ensureLoaded() + await presenter.waitForLoad() + + #expect(presenter.state.data?.isEmpty == true) +} + +@Test @MainActor func historyPresenterRequestCarriesTheDisplayChoices() async throws { + let (presenter, _, _) = try makePresenter() + presenter.setRange(.today) + presenter.setStacked(true) + presenter.setUseUTC(true) + #expect(presenter.timeZone.secondsFromGMT() == 0) + let today = presenter.request(now: fixedNow) + #expect(!today.stacked) + #expect(today.timeZone.secondsFromGMT() == 0) + #expect(today.start <= fixedNow) + presenter.setMetric(.analytics(.turns)) + #expect(!presenter.request(now: fixedNow).stacked) + #expect(presenter.request(now: fixedNow).rollup == .day) + presenter.setRange(.month) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let endDay = calendar.startOfDay(for: fixedNow) + #expect(presenter.request(now: fixedNow).start == calendar.date(byAdding: .day, value: -29, to: endDay)) + await presenter.waitForLoad() +} + +@Test @MainActor func historyPresenterPersistsTheSelectedMetric() throws { + let settings = Settings(defaults: UserDefaults(suiteName: "history-persistence-\(UUID().uuidString)")!) + let history = try UsageHistoryStore(url: nil) + var persisted: HistoryMetric? + let presenter = HistoryPresenter( + history: history, settings: settings, clock: testClock, initialMetric: .analytics(.turns), + persistMetric: { persisted = $0 }) + + #expect(presenter.selectedMetric == .analytics(.turns)) + presenter.setMetric(.analytics(.surfaceUsagePercent)) + #expect(persisted == .analytics(.surfaceUsagePercent)) +} + +@Test @MainActor func historyPresenterIsolationRestoresPreviouslyHiddenSeries() async throws { + let (presenter, settings, _) = try await loadedPresenter() + let hidden = WindowKey(provider: .gemini, windowID: "retired") + let claude = WindowKey(provider: .claude, windowID: "session") + let codex = WindowKey(provider: .codex, windowID: "session") + settings.historyHiddenKeys = [hidden] + presenter.redraw() + await presenter.waitForLoad() + + presenter.isolate(claude) + await presenter.waitForLoad() + #expect(settings.historyHiddenKeys == [hidden, codex]) + presenter.isolate(claude) + await presenter.waitForLoad() + #expect(settings.historyHiddenKeys == [hidden]) +} + +@Test @MainActor func historyPresenterCustomRangeFollowsNowUntilPinned() async throws { + let (presenter, _, _) = try makePresenter() + presenter.setRange(.custom) + presenter.customStart = fixedNow.addingTimeInterval(-7200) + presenter.customEnd = fixedNow.addingTimeInterval(-3600) + presenter.followNow = false + let custom = presenter.request(now: fixedNow) + #expect(custom.start == fixedNow.addingTimeInterval(-7200)) + #expect(custom.end == fixedNow.addingTimeInterval(-3600)) + presenter.followNow = true + #expect(presenter.request(now: fixedNow).end == fixedNow) + presenter.customStart = fixedNow.addingTimeInterval(10) + #expect(presenter.request(now: fixedNow).start == fixedNow.addingTimeInterval(-60)) +} + +@Test @MainActor func historyPresenterNowPreservesTheCustomDuration() async throws { + let (presenter, _, _) = try makePresenter() + presenter.setRange(.custom) + presenter.customStart = fixedNow.addingTimeInterval(-7200) + presenter.customEnd = fixedNow.addingTimeInterval(-3600) + presenter.followNow = false + + presenter.setPeriod(.now) + await presenter.waitForLoad() + + #expect(presenter.customStart == fixedNow.addingTimeInterval(-3600)) + #expect(presenter.customEnd == fixedNow) + #expect(presenter.currentViewport.upperBound.timeIntervalSince(presenter.currentViewport.lowerBound) == 3600) +} + +@Test @MainActor func historyPresenterResetRestoresRuntimeState() async throws { + let (presenter, _, settings) = try makePresenter() + presenter.setMetric(.analytics(.turns)) + presenter.setRange(.custom) + presenter.followNow = false + presenter.selectedDate = fixedNow + settings.historyMetricID = HistoryMetric.windowUsagePercent.storageID + + presenter.reset() + await presenter.waitForLoad() + + #expect(presenter.selectedMetric == .windowUsagePercent) + #expect(presenter.followNow) + #expect(presenter.selectedDate == nil) + #expect(presenter.customEnd == fixedNow) + #expect(presenter.customStart == fixedNow.addingTimeInterval(-86400)) +} + +@Test @MainActor func historyPresenterQueryRollupHasAHardPerSeriesBudget() { + let request = HistoryRequest( + keys: [], start: fixedNow.addingTimeInterval(-60 * 86400), end: fixedNow, rollup: .minute) + + let interval = HistoryPresenter.queryRollup(for: request) + + #expect(60 * 86400 / interval <= Double(HistoryPresenter.maxQueryPointsPerSeries)) + #expect(interval.truncatingRemainder(dividingBy: UsageHistoryStore.sampleInterval) == 0) +} + +@Test @MainActor func historyPresenterPagesWithinTheCustomRange() async throws { + let (presenter, _, _) = try makePresenter() + presenter.setRange(.custom) + presenter.customStart = fixedNow.addingTimeInterval(-7200) + presenter.customEnd = fixedNow.addingTimeInterval(-3600) + presenter.followNow = false + #expect(!presenter.canPageBack) + #expect(presenter.canPageForward) + presenter.page(forward: true, now: fixedNow) + await presenter.waitForLoad() + #expect(presenter.followNow) + #expect(presenter.customEnd == fixedNow) + #expect(!presenter.canPageForward) + presenter.page(forward: false, now: fixedNow) + await presenter.waitForLoad() + #expect(presenter.customEnd == fixedNow.addingTimeInterval(-3600)) + #expect(!presenter.followNow) + presenter.select(x: fixedNow) + #expect(presenter.selectedDate == nil) +} + +@Test @MainActor func historyPresenterPagesTodayByCalendarDay() async throws { + let (presenter, _, settings) = try makePresenter() + settings.historyUseUTC = true + presenter.setRange(.today) + await presenter.waitForLoad() + presenter.page(forward: false, now: fixedNow) + await presenter.waitForLoad() + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let yesterday = calendar.date(byAdding: .day, value: -1, to: fixedNow)! + #expect(presenter.currentViewport.upperBound == calendar.startOfDay(for: fixedNow)) + #expect(presenter.currentViewport.lowerBound == calendar.startOfDay(for: yesterday)) + presenter.page(forward: true, now: fixedNow) + await presenter.waitForLoad() + #expect(presenter.followNow) +} + +@Test @MainActor func historyPresenterEditingAnEndpointStopsFollowingNow() async throws { + let (presenter, _, _) = try makePresenter() + presenter.setRange(.custom) + presenter.setCustomStart(fixedNow.addingTimeInterval(-9000)) + await presenter.waitForLoad() + #expect(presenter.customStart == fixedNow.addingTimeInterval(-9000)) + presenter.setFollowNow(true) + await presenter.waitForLoad() + #expect(presenter.followNow) + presenter.setCustomEnd(fixedNow.addingTimeInterval(-100)) + await presenter.waitForLoad() + #expect(!presenter.followNow) + #expect(presenter.customEnd == fixedNow.addingTimeInterval(-100)) + + presenter.setFollowNow(true) + await presenter.waitForLoad() + presenter.setFollowNow(false) + await presenter.waitForLoad() + #expect(!presenter.followNow) + #expect(presenter.currentViewport.upperBound == fixedNow) +} + +enum HistoryEndpoint: CaseIterable, Sendable { + case start + case end +} + +enum HistoryStartingPeriod: CaseIterable, Sendable { + case today + case week + case now +} + +@Test(arguments: HistoryStartingPeriod.allCases, HistoryEndpoint.allCases) +@MainActor func historyPresenterEditingDatesEntersCustomWithTheDisplayedViewport( + startingPeriod: HistoryStartingPeriod, + endpoint: HistoryEndpoint +) async throws { + let (presenter, _, settings) = try makePresenter() + switch startingPeriod { + case .today: + presenter.setRange(.today) + case .week: + presenter.setRange(.week) + case .now: + presenter.setRange(.week) + presenter.setPeriod(.now) + } + let displayed = presenter.currentViewport + let requested = + endpoint == .start + ? displayed.lowerBound.addingTimeInterval(60) + : displayed.upperBound.addingTimeInterval(-60) + + switch endpoint { + case .start: presenter.setCustomStart(requested) + case .end: presenter.setCustomEnd(requested) + } + await presenter.waitForLoad() + + let request = presenter.request(now: fixedNow) + #expect(settings.historyRange == .custom) + #expect(presenter.period == .range(.custom)) + #expect(!presenter.followNow) + #expect(request.start == (endpoint == .start ? requested : displayed.lowerBound)) + #expect(request.end == (endpoint == .end ? requested : displayed.upperBound)) + #expect(request.start < request.end) +} + +@Test @MainActor func historyPresenterReportsStoreFailures() async throws { + let (presenter, history, _) = try makePresenter() + try await history.breakDatabase() + presenter.reload() + await presenter.waitForLoad() + guard case .failed(let message) = presenter.state else { + Issue.record("expected failure") + return + } + #expect(message.contains("samples")) + #expect(presenter.state.data == nil) +} + +@Test @MainActor func historyPresenterReportsExportFailures() async throws { + let (presenter, history, _) = try makePresenter() + try await history.record(sample(.claude, 10, at: fixedNow), now: fixedNow) + presenter.reload() + await presenter.waitForLoad() + try await history.breakDatabase() + + await presenter.exportCSV(to: temporaryDirectory().appendingPathComponent("broken.csv")).value + + #expect(presenter.exportError?.contains("Export failed") == true) +} + +@Test @MainActor func historyPresenterCancelsSupersededRendering() async throws { + let (presenter, _, _) = try await loadedPresenter() + + presenter.redraw() + presenter.redraw() + await presenter.waitForLoad() + + #expect(presenter.state.data?.series.count == 2) +} + +@Test @MainActor func historyPresenterMarksRefreshingWhileReloading() async throws { + let (presenter, _, _) = try makePresenter() + presenter.reload() + await presenter.waitForLoad() + presenter.reload() + guard case .loaded(_, true, _) = presenter.state else { + Issue.record("expected refreshing state") + return + } + presenter.reload() + await presenter.waitForLoad() + #expect(presenter.state.data?.isEmpty == true) +} + +@Test func cardsRequireDiscoveredAuthenticationOrData() { + let missing = ProviderState(availability: .authenticationRequired, credentialState: .missing("none")) + let expired = ProviderState(availability: .authenticationRequired, credentialState: .expired(fixedNow)) + let cached = ProviderState( + snapshot: DemoData.snapshot(.copilot, now: fixedNow), availability: .stale, credentialState: .missing("none")) + let authenticated = ProviderState(credentialState: .valid(expiresAt: nil)) + let authenticatedHealth = ProviderState( + credentialHealth: .valid(source: ProviderID.claude.setup.credentialSources[0], expiresAt: nil)) + let analytics = ProviderState( + analytics: ProviderAnalytics(provider: .codex, points: [], fetchedAt: fixedNow)) + let cards = UsagePresenter.cards( + state: [ + .gemini: missing, .cursor: expired, .copilot: cached, .claude: authenticatedHealth, .codex: analytics, + ], enabled: Set(ProviderID.allCases), + samples: [:], now: fixedNow) + #expect(cards.map(\.provider) == [.claude, .codex, .copilot]) + #expect(!UsagePresenter.isVisible(missing, enabled: true)) + #expect(!UsagePresenter.isVisible(expired, enabled: true)) + #expect(UsagePresenter.isVisible(cached, enabled: true)) + #expect(UsagePresenter.isVisible(authenticated, enabled: true)) + #expect(UsagePresenter.isVisible(authenticatedHealth, enabled: true)) + #expect(UsagePresenter.isVisible(analytics, enabled: true)) + #expect(!UsagePresenter.isVisible(cached, enabled: false)) + #expect(!UsagePresenter.isVisible(ProviderState(), enabled: true)) +} + +@Test func windowsSharingAResetAreGroupedTogether() { + let reset = fixedNow.addingTimeInterval(3600) + func row(_ id: String, resets: Date?) -> WindowRow { + WindowRow( + key: WindowKey(provider: .claude, windowID: id), + window: QuotaWindow(id: id, label: id, group: .weekly, usedPercent: 10, resetsAt: resets), + pace: PaceEstimate(status: .onTrack, expectedPercent: 10, ratio: 1, projectedExhaustion: nil), + countdown: "1h", resetClock: "2:00 PM") + } + // Claude's weekly models share one reset, so the card printed the same line under three rows running. + let groups = UsagePresenter.groups([ + row("session", resets: fixedNow.addingTimeInterval(600)), + row("all", resets: reset), row("fable", resets: reset), row("sonnet", resets: reset), + row("none", resets: nil), + ]) + #expect(groups.map(\.rows.count) == [1, 3, 1]) + #expect(groups[1].resetText == "Resets in 1h · 2:00 PM") + #expect(groups[1].isSingle == false) + #expect(groups[0].isSingle) + // A window with no reset never joins a group, because it shares nothing with the one before it. + #expect(groups[2].resetText == nil) + #expect(UsagePresenter.groups([]).isEmpty) +} + +@Test func cardStatusTextTracksRefreshAndAge() { + let snapshot = DemoData.snapshot(.codex, now: fixedNow.addingTimeInterval(-120)) + func card(_ state: ProviderState) -> ProviderCard { + UsagePresenter.card(provider: .codex, state: state, samples: [:], now: fixedNow) + } + let current = card(ProviderState(snapshot: snapshot, availability: .current)) + #expect(current.statusText == "fetched \(current.fetchedAge)") + #expect(current.statusHelp.contains("last successful fetch")) + let refreshing = card(ProviderState(snapshot: snapshot, availability: .stale, isRefreshing: true)) + #expect(refreshing.statusText == "refreshing… · showing \(refreshing.fetchedAge)") + // Being offline or rate limited leads, because a refresh will not fix it and the numbers below stay put. + let offline = card( + ProviderState(snapshot: snapshot, availability: .networkUnavailable, isRefreshing: true)) + #expect(offline.statusText == "Offline · refreshing… · showing \(offline.fetchedAge)") + let limited = card(ProviderState(snapshot: snapshot, availability: .rateLimited, isRefreshing: true)) + #expect(limited.statusText == "Rate limited · refreshing… · showing \(limited.fetchedAge)") + #expect(refreshing.statusHelp.contains("from the last successful fetch")) + // The rows a refresh is replacing stay on screen while it runs. + #expect(!refreshing.rows.isEmpty) + let stale = card(ProviderState(snapshot: snapshot, availability: .rateLimited)) + #expect(stale.statusText == "\(QuotaAvailability.rateLimited.title) · \(stale.fetchedAge)") + let cached = card( + ProviderState( + snapshot: ProviderSnapshot( + provider: .codex, windows: snapshot.windows, source: .cache, fetchedAt: snapshot.fetchedAt), + availability: .stale, isRefreshing: true)) + #expect(cached.statusHelp.contains("last ran")) + #expect(card(ProviderState(availability: .loading, isRefreshing: true)).statusText == "Refreshing…") + #expect(card(ProviderState(availability: .unavailable)).statusText == QuotaAvailability.unavailable.title) +} + +private func sample(_ provider: ProviderID, _ percent: Double, at date: Date) -> ProviderSnapshot { + ProviderSnapshot( + provider: provider, + windows: [ + QuotaWindow( + id: "session", label: "Session", group: .session, usedPercent: percent, resetsAt: date.addingTimeInterval(3600), + duration: 18000) + ], fetchedAt: date) +} diff --git a/Tests/TokenMenuBarCoreTests/ProcessPerformanceSnapshotTests.swift b/Tests/TokenMenuBarCoreTests/ProcessPerformanceSnapshotTests.swift new file mode 100644 index 0000000..2decf16 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ProcessPerformanceSnapshotTests.swift @@ -0,0 +1,11 @@ +import Testing +import TokenMenuBarCore + +@Test func currentProcessPerformanceSnapshotContainsLiveCounters() throws { + let snapshot = try #require(ProcessPerformanceSnapshot.current()) + + #expect(snapshot.processIdentifier > 0) + #expect(snapshot.residentMemoryBytes > 0) + #expect(snapshot.physicalFootprintBytes > 0) + #expect(snapshot.cpuNanoseconds > 0) +} diff --git a/Tests/TokenMenuBarCoreTests/ProviderActivationTests.swift b/Tests/TokenMenuBarCoreTests/ProviderActivationTests.swift new file mode 100644 index 0000000..92dd470 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ProviderActivationTests.swift @@ -0,0 +1,315 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func providerActivationDiscoversCredentialsAndData() { + let source = ProviderID.claude.setup.credentialSources[0] + let snapshot = DemoData.snapshot(.claude, now: Date(timeIntervalSince1970: 1_000)) + let analytics = ProviderAnalytics(provider: .claude, points: [], fetchedAt: snapshot.fetchedAt) + let discovered = [ + ProviderState(credentialHealth: .valid(source: source, expiresAt: nil)), + ProviderState(credentialState: .valid(expiresAt: nil)), + ProviderState(snapshot: snapshot), + ProviderState(analytics: analytics), + ] + + for state in discovered { + #expect(ProviderSettingsVisibility.discovered(state)) + } +} + +@Test func providerActivationRejectsMissingOrUnknownCredentialsWithoutData() { + let expected = ProviderID.claude.setup.credentialSources + #expect(!ProviderSettingsVisibility.discovered(nil)) + #expect(!ProviderSettingsVisibility.discovered(ProviderState())) + #expect(!ProviderSettingsVisibility.discovered(ProviderState(credentialHealth: .missing(expected: expected)))) + #expect( + !ProviderSettingsVisibility.discovered( + ProviderState(credentialHealth: .expired(source: expected[0], at: fixedNow)))) + #expect( + !ProviderSettingsVisibility.discovered( + ProviderState(credentialHealth: .unreadable(source: expected[0], detail: "permission denied")))) + #expect( + !ProviderSettingsVisibility.discovered( + ProviderState(credentialHealth: .unreadable(source: nil, detail: "credential store unavailable")))) +} + +@Test func providerSettingsHideConfiguredAuthenticationFailuresUntilShowAllIsEnabled() { + let state = ProviderState( + credentialHealth: .expired(source: ProviderID.gemini.setup.credentialSources[0], at: fixedNow)) + + #expect( + ProviderSettingsVisibility.providers( + states: [.gemini: state], configured: [.gemini], showAll: false + ).isEmpty) + #expect( + ProviderSettingsVisibility.providers( + states: [.gemini: state], configured: [.gemini], showAll: true + ) == ProviderID.allCases) +} + +@Test @MainActor func providerActivationRequiresDiscoveryAndHonorsExplicitOverrides() { + let defaults = UserDefaults(suiteName: "ProviderActivationTests.overrides")! + defaults.removePersistentDomain(forName: "ProviderActivationTests.overrides") + let settings = Settings(defaults: defaults) + let discovered = ProviderState(snapshot: DemoData.snapshot(.claude, now: Date())) + + #expect(settings.isProviderActive(.claude, state: discovered)) + settings.setProvider(.claude, enabled: false) + #expect(!settings.isProviderActive(.claude, state: discovered)) + settings.setProvider(.claude, enabled: true) + #expect(settings.isProviderActive(.claude, state: discovered)) + #expect(!settings.isProviderActive(.claude, state: nil)) +} + +@Test @MainActor func providerActivationMigratesLegacySelectionsToExplicitOverrides() throws { + let name = "ProviderActivationTests.legacy" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + defaults.set(try JSONEncoder().encode(Set([ProviderID.codex])), forKey: "enabledProviders") + + let settings = Settings(defaults: defaults) + + #expect(settings.providerOverride(for: .codex) == true) + #expect(settings.providerOverride(for: .claude) == false) +} + +@Test @MainActor func providerActivationResetReturnsEveryProviderToAutomaticDiscovery() { + let defaults = UserDefaults(suiteName: "ProviderActivationTests.reset")! + defaults.removePersistentDomain(forName: "ProviderActivationTests.reset") + let settings = Settings(defaults: defaults) + settings.setProvider(.claude, enabled: false) + settings.showAllProviders = true + + settings.resetToDefaults() + + #expect(settings.providerOverride(for: .claude) == nil) + #expect(!settings.showAllProviders) +} + +@Test @MainActor func configuredProviderSettingsRetainRowsWithoutChangingAutomaticActivation() { + let defaults = UserDefaults(suiteName: "ProviderActivationTests.configuration")! + defaults.removePersistentDomain(forName: "ProviderActivationTests.configuration") + let settings = Settings(defaults: defaults) + + settings.setRefreshInterval(300, for: .gemini) + + #expect(settings.configuredProviderSettings.contains(.gemini)) + #expect(settings.providerOverride(for: .gemini) == nil) + #expect(!settings.isProviderActive(.gemini, state: nil)) +} + +@Test func providerRediscoveryPolicyThrottlesApplicationActivation() { + var policy = ProviderRediscoveryPolicy(activationInterval: 60) + let first = policy.begin(.applicationActivated, at: fixedNow) + let throttled = policy.begin(.applicationActivated, at: fixedNow.addingTimeInterval(59)) + let boundary = policy.begin(.applicationActivated, at: fixedNow.addingTimeInterval(60)) + + #expect(first) + #expect(!throttled) + #expect(boundary) +} + +@Test func providerRediscoveryPolicyRecordsStartupDiscovery() { + var policy = ProviderRediscoveryPolicy(activationInterval: 60) + policy.recordDiscovery(at: fixedNow) + let allowed = policy.begin(.applicationActivated, at: fixedNow.addingTimeInterval(1)) + + #expect(!allowed) +} + +@Test func providerRediscoveryPolicyAlwaysAllowsManualRefresh() { + var policy = ProviderRediscoveryPolicy(activationInterval: 60, lastDiscoveryAt: fixedNow) + let first = policy.begin(.userInitiated, at: fixedNow.addingTimeInterval(1)) + let second = policy.begin(.userInitiated, at: fixedNow.addingTimeInterval(2)) + + #expect(first) + #expect(second) +} + +@Test func providerRediscoveryPolicyRecoversFromAClockRollback() { + var policy = ProviderRediscoveryPolicy(activationInterval: 60, lastDiscoveryAt: fixedNow) + let allowed = policy.begin(.applicationActivated, at: fixedNow.addingTimeInterval(-1)) + + #expect(allowed) +} + +@Test func providerDiscoveryReadsCredentialProvenanceWithoutFetching() async { + let source = ProviderID.claude.setup.credentialSources[0] + let health = ProviderCredentialHealth.valid(source: source, expiresAt: fixedNow) + let probe = ProviderDiscoveryProbe(health: health) + let resource = ResourceAccessState(resource: ProviderID.claude.sandboxResources[0], health: .granted) + let registry = ProviderRegistry( + [ProviderDiscoveryTestProvider(probe: probe)], + setupStates: [.claude: ProviderSetupState(enabled: true, resources: [resource])]) + + let discovery = await ProviderDiscoverySnapshot.inspect(registry, now: fixedNow) + + #expect(discovery.providerIDs == [.claude]) + #expect(discovery.credentials == [.claude: health]) + #expect(discovery.resources == [.claude: [resource]]) + #expect(await probe.healthReads == 1) + #expect(await probe.fetches == 0) +} + +@Test func providerDiscoveryMatchesExactCredentialSourcesAndResources() { + let source = ProviderID.claude.setup.credentialSources[0] + let health = ProviderCredentialHealth.valid(source: source, expiresAt: fixedNow) + let resource = ResourceAccessState(resource: ProviderID.claude.sandboxResources[0], health: .granted) + let discovery = ProviderDiscoverySnapshot( + providerIDs: [.claude], credentials: [.claude: health], resources: [.claude: [resource]]) + let state = ProviderState(credentialHealth: health, resourceAccess: [resource]) + + #expect(!discovery.differs(from: [.claude: state], providerIDs: [.claude])) +} + +@Test func providerDiscoveryDetectsACredentialSourceChange() { + let keychain = ProviderID.claude.setup.credentialSources[0] + let file = ProviderID.claude.setup.credentialSources[1] + let discovery = ProviderDiscoverySnapshot( + providerIDs: [.claude], credentials: [.claude: .valid(source: file, expiresAt: nil)], resources: [:]) + let state = ProviderState(credentialHealth: .valid(source: keychain, expiresAt: nil)) + + #expect(discovery.differs(from: [.claude: state], providerIDs: [.claude])) +} + +@Test func providerDiscoveryDetectsAResourceChange() { + let resource = ProviderID.claude.sandboxResources[0] + let discovery = ProviderDiscoverySnapshot( + providerIDs: [.claude], credentials: [.claude: .unchecked], + resources: [.claude: [ResourceAccessState(resource: resource, health: .granted)]]) + let state = ProviderState( + credentialHealth: .unchecked, + resourceAccess: [ResourceAccessState(resource: resource, health: .needed)]) + + #expect(discovery.differs(from: [.claude: state], providerIDs: [.claude])) +} + +@Test func providerDiscoveryDetectsAProviderSetChange() { + let discovery = ProviderDiscoverySnapshot(providerIDs: [.claude], credentials: [.claude: .unchecked], resources: [:]) + + #expect(discovery.differs(from: [.claude: ProviderState()], providerIDs: [.codex])) +} + +@Test @MainActor func providerDiscoveryReplacesMissingHealthWithTheExactSource() { + let state = AppState() + let file = ProviderID.claude.setup.credentialSources[1] + state.applySetupStates([ + .claude: ProviderSetupState.from( + provider: .claude, enabled: false, + credential: .missing(expected: ProviderID.claude.setup.credentialSources), resources: []) + ]) + state.update(.claude) { $0.availability = .authenticationRequired } + + state.applySetupStates([ + .claude: ProviderSetupState.from( + provider: .claude, enabled: true, credential: .valid(source: file, expiresAt: fixedNow), resources: []) + ]) + + #expect(state.state(for: .claude).credentialHealth == .valid(source: file, expiresAt: fixedNow)) + #expect(state.state(for: .claude).credentialState == .valid(expiresAt: fixedNow)) + #expect(state.state(for: .claude).availability == .loading) + #expect(state.state(for: .claude).recoveryIssue == nil) +} + +@Test @MainActor func providerDiscoveryKeepsLastKnownDataWhenCredentialsDisappear() { + let state = AppState() + let snapshot = DemoData.snapshot(.claude, now: fixedNow) + let source = ProviderID.claude.setup.credentialSources[0] + state.update(.claude) { + $0.snapshot = snapshot + $0.availability = .current + $0.credentialHealth = .valid(source: source, expiresAt: nil) + } + + state.applySetupStates([ + .claude: ProviderSetupState.from( + provider: .claude, enabled: false, + credential: .missing(expected: ProviderID.claude.setup.credentialSources), resources: []) + ]) + + #expect(state.state(for: .claude).snapshot == snapshot) + #expect( + state.state(for: .claude).credentialHealth + == .missing(expected: ProviderID.claude.setup.credentialSources)) + #expect( + !ProviderSettingsVisibility.discovered(ProviderState(credentialHealth: state.state(for: .claude).credentialHealth))) +} + +@Test @MainActor func providerDiscoveryClearsAResolvedResourceIssue() { + let state = AppState() + let resource = ProviderID.claude.sandboxResources[0] + let source = ProviderID.claude.setup.credentialSources[0] + state.applySetupStates([ + .claude: ProviderSetupState.from( + provider: .claude, enabled: true, credential: .valid(source: source, expiresAt: nil), + resources: [ResourceAccessState(resource: resource, health: .needed)]) + ]) + state.update(.claude) { + $0.recoveryIssue = ProviderRecoveryIssue( + kind: .resourceAccess, title: "File access needed", detail: "Grant access.", + action: .grantAccess(resource)) + } + + state.applySetupStates([ + .claude: ProviderSetupState.from( + provider: .claude, enabled: true, credential: .valid(source: source, expiresAt: nil), + resources: [ResourceAccessState(resource: resource, health: .granted)]) + ]) + + #expect(state.state(for: .claude).resourceAccess == [ResourceAccessState(resource: resource, health: .granted)]) + #expect(state.state(for: .claude).recoveryIssue == nil) +} + +@Test @MainActor func providerDiscoveryClearsAResolvedResourceIssueWhileCredentialsRemainUnchecked() { + let state = AppState() + let resource = ProviderID.claude.sandboxResources[0] + state.applySetupStates([ + .claude: ProviderSetupState( + enabled: true, credential: .unchecked, + resources: [ResourceAccessState(resource: resource, health: .needed)]) + ]) + #expect(state.state(for: .claude).recoveryIssue?.kind == .resourceAccess) + + state.applySetupStates([ + .claude: ProviderSetupState( + enabled: true, credential: .unchecked, + resources: [ResourceAccessState(resource: resource, health: .granted)]) + ]) + + #expect(state.state(for: .claude).credentialHealth == .unchecked) + #expect(state.state(for: .claude).resourceAccess == [ResourceAccessState(resource: resource, health: .granted)]) + #expect(state.state(for: .claude).recoveryIssue == nil) +} + +private actor ProviderDiscoveryProbe { + let health: ProviderCredentialHealth + private(set) var healthReads = 0 + private(set) var fetches = 0 + + init(health: ProviderCredentialHealth) { + self.health = health + } + + func readHealth() -> ProviderCredentialHealth { + healthReads += 1 + return health + } + + func fetch() -> ProviderFetchResult { + fetches += 1 + return ProviderFetchResult(outcome: .failed("fetch should not run during discovery")) + } +} + +private struct ProviderDiscoveryTestProvider: UsageProvider { + let id = ProviderID.claude + let probe: ProviderDiscoveryProbe + let pollingPolicy = PollingPolicy(minimumInterval: 60, activeInterval: 60, defaultInterval: 300) + + var credentialDescription: String { "discovery test" } + func credentialState(now: Date) -> CredentialState { .valid(expiresAt: nil) } + func credentialHealth(now: Date) async -> ProviderCredentialHealth { await probe.readHealth() } + func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult { await probe.fetch() } +} diff --git a/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift new file mode 100644 index 0000000..6b763fd --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift @@ -0,0 +1,453 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func nestedCoverageClaudeMoneyUsesBothDefaults() { + let money = ClaudeAPI.MoneyDTO(amountMinor: 125, currency: nil, exponent: nil).money(defaultCurrency: "CAD") + + #expect(money == Money(amountMinor: 125, currency: "CAD", exponent: 2)) +} + +@Test func nestedCoverageClaudeUsageAcceptsANonobjectDocument() throws { + let response = try decodeClaudeUsage("[]") + + #expect(response.limits.isEmpty) + #expect(response.windows.isEmpty) + #expect(response.spend == nil) +} + +@Test func nestedCoverageClaudeMultiplierRejectsATierWithoutDigits() { + #expect(ClaudeMapper.trailingMultiplier(in: "defaultx") == nil) +} + +@Test func nestedCoverageClaudeScopedWindowUsesGenericNamesWithoutAScope() throws { + let response = try decodeClaudeUsage( + #"{"limits":[{"kind":"weekly_scoped","percent":2,"severity":"warning"}]}"#) + + #expect(ClaudeMapper.windows(response).map(\.id) == ["weekly:scoped"]) + #expect(ClaudeMapper.windows(response).map(\.label) == ["Scoped weekly"]) +} + +@Test func nestedCoverageClaudeSpendDefaultsDisabledAndReadsAutoReload() throws { + let response = try decodeClaudeUsage(#"{"spend":{"auto_reload":{"enabled":true}}}"#) + let spend = try #require(ClaudeMapper.spend(response, now: fixedNow)) + + #expect(!spend.enabled) + #expect(spend.autoReload == true) +} + +@Test func nestedCoverageClaudeLimitNoticeUsesALaterResetWhenMissing() throws { + let response = try decodeClaudeUsage( + #"{"limits":[{"kind":"session","percent":100,"severity":"critical"}]}"#) + + #expect(ClaudeMapper.notices(response).map(\.text) == ["Current session limit reached; resets later."]) +} + +@Test func nestedCoverageClaudeProfileFallsBackToItsExpiredCache() async throws { + let transport = NestedCoverageClaudeTransport() + let provider = ClaudeProvider( + credentials: MemoryClaudeStore(validClaude), localAccountURL: nil, + client: APIClient(transport: transport, log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { false }) + + let first = await provider.fetch(now: fixedNow, options: FetchOptions()) + let second = await provider.fetch( + now: fixedNow.addingTimeInterval(ClaudeProvider.profileCacheInterval), options: FetchOptions()) + + #expect(first.outcome.snapshot?.identity?.email == "user@example.com") + #expect(second.outcome.snapshot?.identity?.email == "user@example.com") + #expect(second.warnings.isEmpty) +} + +@Test func nestedCoverageCodexRetriesAPendingCredentialSave() async { + let expired = CodexAuth( + accessToken: makeJWT(.object(["exp": .number(fixedNow.timeIntervalSince1970 - 5)])), refreshToken: "refresh") + let store = MemoryCodexStore(expired) + store.saveError = CredentialStoreError.keychain(-1) + let transport = StubTransport() + transport.on(path: "/oauth/token", .text(#"{"access_token":"fresh"}"#)) + transport.on(path: "/wham/usage", .json("codex_usage")) + transport.on(path: "rate-limit-reset-credits", .json("codex_reset_credits")) + let provider = codexProvider(store, transport: transport, allowRefresh: true) + + let unsaved = await provider.fetch(now: fixedNow, options: FetchOptions()) + store.saveError = nil + let retried = await provider.fetch(now: fixedNow.addingTimeInterval(1), options: FetchOptions()) + + #expect(unsaved.recoveryIssue?.kind == .credentialPersistence) + #expect(retried.recoveryIssue == nil) + #expect(store.saved.map(\.accessToken) == ["fresh"]) +} + +@Test func nestedCoverageCodexResetCreditsDefaultsEveryCount() { + let summary = CodexAPI.ResetCreditsSummary( + availableCount: nil, applicableAvailableCount: nil, totalEarnedCount: nil, + immediateResetPurchaseEligible: nil) + + #expect(CodexMapper.resetCredits(summary) == ResetCredits(available: 0, applicable: 0)) +} + +@Test func nestedCoverageCodexTokenAnalyticsDefaultsMissingSurfaces() { + let rows: [JSONValue] = [ + .object([ + "date": .string("2026-08-29"), + "models": .array([.object(["model": .string("gpt"), "credits": .number(2)])]), + ]) + ] + + #expect( + CodexMapper.analytics(.tokenUsage, rows: rows) + == [AnalyticsPoint(day: "2026-08-29", metric: .modelCredits, series: "gpt", value: 2)]) +} + +@Test func nestedCoverageCodexSkillAnalyticsDefaultsMissingSkills() { + #expect(CodexMapper.analytics(.skills, rows: [.object(["date": .string("2026-08-29")])]).isEmpty) +} + +@Test func nestedCoverageCodexPluginAnalyticsDefaultsMissingPlugins() { + #expect(CodexMapper.analytics(.plugins, rows: [.object(["date": .string("2026-08-29")])]).isEmpty) +} + +@Test func nestedCoverageCodexPluginAnalyticsSortsNames() { + let rows: [JSONValue] = [ + .object([ + "date": .string("2026-08-29"), + "plugin_usage_overviews": .array([ + .object(["plugin_name": .string("zeta"), "invocation_counts": .number(1)]), + .object(["plugin_name": .string("alpha"), "invocation_counts": .number(2)]), + ]), + ]) + ] + + #expect(CodexMapper.analytics(.plugins, rows: rows).map(\.series) == ["alpha", "zeta"]) +} + +@Test func nestedCoverageCodexCreditEventDefaultsUsageAndID() { + let rows: [JSONValue] = [.object(["date": .string("2026-08-29")])] + let event = CodexMapper.creditEvents(rows).first + + #expect(event?.creditsUsed == 0) + #expect(event?.service == "Codex") + #expect(event?.id == "2026-08-29-0") +} + +@Test func nestedCoverageCodexRefreshBuildsTokensForAnAPIKeyDocument() throws { + let auth = try #require(CodexAuth(document: .object(["OPENAI_API_KEY": .string("key")]))) + + #expect( + auth.refreshed(accessToken: "fresh", refreshToken: nil, idToken: nil, now: fixedNow).accessToken == "fresh") +} + +@Test func nestedCoverageLegacyTranscriptUsesAggregateFallbacksAcrossBoundaries() async throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = calendar.startOfDay(for: fixedNow).addingTimeInterval(3600) + let todayStart = calendar.startOfDay(for: now) + let stateURL = temporaryDirectory().appendingPathComponent("state.json") + let recent: [String: Any] = [ + "timestamp": todayStart.addingTimeInterval(1).timeIntervalSinceReferenceDate, + "tokens": 7, + "cost": 0.5, + "messages": 1, + "firstTimestamp": todayStart.addingTimeInterval(-1).timeIntervalSinceReferenceDate, + "lastTimestamp": now.timeIntervalSinceReferenceDate, + ] + try nestedCoverageTranscriptState(now: now, recent: ["minute": recent]).write(to: stateURL) + let snapshot = await ClaudeTranscriptReader(root: temporaryDirectory(), stateURL: stateURL).refresh(now: now) + + let usage = try #require( + snapshot.localUsage(windowResetsAt: nil, windowDuration: 1800, now: now, calendar: calendar)) + #expect(usage.windowTokens == 0) + #expect(usage.todayTokens == 7) +} + +@Test func nestedCoverageEmptyRecentTranscriptReportsZeroVelocity() async throws { + let stateURL = temporaryDirectory().appendingPathComponent("state.json") + try nestedCoverageTranscriptState(now: fixedNow, recent: [:]).write(to: stateURL) + let snapshot = await ClaudeTranscriptReader(root: temporaryDirectory(), stateURL: stateURL).refresh(now: fixedNow) + + let usage = try #require(snapshot.localUsage(windowResetsAt: nil, windowDuration: 3600, now: fixedNow)) + #expect(usage.windowTokens == 0) + #expect(usage.costPerHour == 0) +} + +@Test func nestedCoverageConcurrentTranscriptRefreshesShareStateLoading() async throws { + let stateURL = temporaryDirectory().appendingPathComponent("state.json") + try nestedCoverageTranscriptState(now: fixedNow, recent: [:]).write(to: stateURL) + let reader = ClaudeTranscriptReader(root: temporaryDirectory(), stateURL: stateURL) + + async let first = reader.refresh(now: fixedNow) + async let second = reader.refresh(now: fixedNow) + let snapshots = await (first, second) + + #expect(snapshots.0.messageCount == 1) + #expect(snapshots.1.messageCount == 1) +} + +@Test func nestedCoverageTranscriptDropsAnIndexedFileThatDisappears() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("session.jsonl") + try (nestedCoverageClaudeLine(id: "first") + "\n").write(to: file, atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader(root: root, fileScanInterval: 300) + #expect(await reader.refresh(now: fixedNow).messageCount == 1) + + try FileManager.default.removeItem(at: file) + #expect(await reader.refresh(now: fixedNow.addingTimeInterval(1)).messageCount == 1) +} + +@Test func nestedCoverageTranscriptRereadsASameSizeReplacement() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("session.jsonl") + try (nestedCoverageClaudeLine(id: "first") + "\n").write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: fixedNow], ofItemAtPath: file.path) + let reader = ClaudeTranscriptReader(root: root, fileScanInterval: 300) + #expect(await reader.refresh(now: fixedNow).messageCount == 1) + + try (nestedCoverageClaudeLine(id: "other") + "\n").write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: fixedNow.addingTimeInterval(1)], ofItemAtPath: file.path) + #expect(await reader.refresh(now: fixedNow.addingTimeInterval(1)).messageCount == 2) +} + +@Test func nestedCoverageTranscriptKeepsSkippingAnOversizedTailWithoutANewline() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("session.jsonl") + try "12345".write(to: file, atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader(root: root, fileScanInterval: 300, maximumLineBytes: 4) + #expect(await reader.refresh(now: fixedNow).messageCount == 0) + + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data("6789".utf8)) + try handle.close() + #expect(await reader.refresh(now: fixedNow.addingTimeInterval(1)).messageCount == 0) + #expect((await reader.workload).retainedPartialFiles == 1) +} + +@Test func nestedCoverageTranscriptEvictsDifferentSizedPartialTails() async throws { + let root = temporaryDirectory() + for (name, text) in [("a", "1"), ("b", "23"), ("c", "456")] { + try text.write(to: root.appendingPathComponent("\(name).jsonl"), atomically: true, encoding: .utf8) + } + let reader = ClaudeTranscriptReader(root: root, maximumLineBytes: 4, maximumRetainedPartialBytes: 4) + + _ = await reader.refresh(now: fixedNow) + let workload = await reader.workload + #expect(workload.retainedPartialFiles == 2) + #expect(workload.retainedPartialBytes <= 4) +} + +@Test func nestedCoverageTranscriptEvictsEqualSizedPartialTails() async throws { + let root = temporaryDirectory() + for name in ["a", "b", "c", "d"] { + try "1".write(to: root.appendingPathComponent("\(name).jsonl"), atomically: true, encoding: .utf8) + } + let reader = ClaudeTranscriptReader(root: root, maximumLineBytes: 1, maximumRetainedPartialBytes: 3) + + _ = await reader.refresh(now: fixedNow) + let workload = await reader.workload + #expect(workload.retainedPartialFiles == 3) + #expect(workload.retainedPartialBytes == 3) +} + +@Test func nestedCoverageTranscriptHandlesAReadFailure() async throws { + let root = temporaryDirectory() + try "mock".write(to: root.appendingPathComponent("session.jsonl"), atomically: true, encoding: .utf8) + let failure = NestedCoverageReadFailure() + let reader = ClaudeTranscriptReader(root: root, readChunk: failure.read) + + let snapshot = await reader.refresh(now: fixedNow) + + #expect(snapshot.messageCount == 0) + #expect(failure.count == 1) + #expect((await reader.workload).filesOpened == 1) +} + +@Test func nestedCoverageTranscriptCheckpointsAfterCrossingItsByteThreshold() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("session.jsonl") + let stateURL = temporaryDirectory().appendingPathComponent("state.json") + try (nestedCoverageClaudeLine(id: "first") + "\n").write(to: file, atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader( + root: root, stateURL: stateURL, fileScanInterval: 300, checkpointInterval: 300, maximumCheckpointBytes: 1) + _ = await reader.refresh(now: fixedNow) + + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data((nestedCoverageClaudeLine(id: "other") + "\n").utf8)) + try handle.close() + _ = await reader.refresh(now: fixedNow.addingTimeInterval(1)) + + #expect((await reader.workload).checkpoints == 2) +} + +@Test func nestedCoverageTranscriptKeepsAnIncompleteIndexedTailAcrossRefreshes() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("session.jsonl") + try Data().write(to: file) + let reader = ClaudeTranscriptReader( + root: root, fileScanInterval: 300, workByteBudget: 4, backgroundWorkDelay: 60) + _ = await reader.refresh(now: fixedNow) + + try (nestedCoverageClaudeLine(id: "later") + "\n").write(to: file, atomically: true, encoding: .utf8) + _ = await reader.refresh(now: fixedNow.addingTimeInterval(1)) + let snapshot = await reader.refresh(now: fixedNow.addingTimeInterval(2)) + + #expect(snapshot.messageCount == 0) + #expect((await reader.workload).bytesRead == 8) + #expect((await reader.workload).retainedPartialFiles == 1) +} + +@Test func nestedCoverageTranscriptContinuesAnIncompleteColdScanFile() async throws { + let root = temporaryDirectory() + try (nestedCoverageClaudeLine(id: "slow") + "\n").write( + to: root.appendingPathComponent("session.jsonl"), atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader(root: root, workByteBudget: 1, backgroundWorkDelay: 60) + + _ = await reader.refresh(now: fixedNow) + let snapshot = await reader.refresh(now: fixedNow.addingTimeInterval(1)) + + #expect(snapshot.messageCount == 0) + #expect((await reader.workload).bytesRead == 2) + #expect((await reader.workload).scansCompleted == 0) +} + +@Test func nestedCoverageTranscriptSkipsAFileThatCannotBeOpened() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("session.jsonl") + try (nestedCoverageClaudeLine(id: "unreadable") + "\n").write( + to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: file.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) } + + let reader = ClaudeTranscriptReader(root: root) + #expect(await reader.refresh(now: fixedNow).messageCount == 0) + #expect((await reader.workload).filesOpened == 0) + #expect((await reader.workload).scansCompleted == 1) +} + +@Test func nestedCoverageTranscriptDefersANonurgentCheckpoint() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("session.jsonl") + let stateURL = temporaryDirectory().appendingPathComponent("state.json") + try (nestedCoverageClaudeLine(id: "first") + "\n").write(to: file, atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader( + root: root, stateURL: stateURL, fileScanInterval: 300, checkpointInterval: 300) + _ = await reader.refresh(now: fixedNow) + + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data((nestedCoverageClaudeLine(id: "second") + "\n").utf8)) + try handle.close() + let snapshot = await reader.refresh(now: fixedNow.addingTimeInterval(1)) + + #expect(snapshot.messageCount == 2) + #expect((await reader.workload).checkpoints == 1) +} + +@Test func nestedCoverageTranscriptCoalescesConcurrentCheckpoints() async throws { + let root = temporaryDirectory() + let stateURL = temporaryDirectory().appendingPathComponent("state.json") + let reader = ClaudeTranscriptReader(root: root, stateURL: stateURL, fileScanInterval: 0, workTimeBudget: 2) + _ = await reader.refresh(now: fixedNow) + try ((0..<1_000).map { nestedCoverageClaudeLine(id: "message-\($0)") }.joined(separator: "\n") + "\n") + .write(to: root.appendingPathComponent("session.jsonl"), atomically: true, encoding: .utf8) + + async let first = reader.refresh(now: fixedNow.addingTimeInterval(1)) + async let second = reader.refresh(now: fixedNow.addingTimeInterval(1)) + let snapshots = await (first, second) + + #expect(snapshots.0.messageCount == 1_000) + #expect(snapshots.1.messageCount == 1_000) + #expect((await reader.workload).checkpointAttempts == 1) +} + +@Test func nestedCoverageRolloutResortsRefreshedCandidates() async throws { + let root = temporaryDirectory() + let first = root.appendingPathComponent("rollout-first.jsonl") + let second = root.appendingPathComponent("rollout-second.jsonl") + try (nestedCoverageRolloutLine(percent: 10) + "\n").write(to: first, atomically: true, encoding: .utf8) + try (nestedCoverageRolloutLine(percent: 20) + "\n").write(to: second, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: fixedNow], ofItemAtPath: first.path) + try FileManager.default.setAttributes( + [.modificationDate: fixedNow.addingTimeInterval(-10)], ofItemAtPath: second.path) + let reader = CodexRolloutReader(sessionsRoot: root, cacheInterval: 300) + #expect(await reader.latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 10) + + try FileManager.default.setAttributes( + [.modificationDate: fixedNow.addingTimeInterval(10)], ofItemAtPath: second.path) + #expect(await reader.latest(now: fixedNow.addingTimeInterval(1))?.rateLimit.primaryWindow?.usedPercent == 20) +} + +@Test func nestedCoverageCancelledNewestRolloutsReturnNoPartialTree() async throws { + let root = temporaryDirectory() + for index in 0..<20 { + try Data().write(to: root.appendingPathComponent("rollout-\(index).jsonl")) + } + let reader = CodexRolloutReader( + sessionsRoot: root, workEntryBudget: 1, backgroundWorkDelay: 0.05) + let task = Task { await reader.newestRollouts() } + while (await reader.workload).treeEntriesExamined == 0 { await Task.yield() } + + task.cancel() + #expect(await task.value.isEmpty) +} + +private func decodeClaudeUsage(_ text: String) throws -> ClaudeAPI.UsageResponse { + try JSONDecoder().decode(ClaudeAPI.UsageResponse.self, from: Data(text.utf8)) +} + +private func nestedCoverageTranscriptState(now: Date, recent: [String: Any]) throws -> Data { + let day = DayStamp.string(now) + return try JSONSerialization.data(withJSONObject: [ + "offsets": [:], + "seenByDay": [day: ["message"]], + "days": [day: ["models": [:], "messages": 1, "sessions": ["session"], "toolCalls": 0]], + "recent": recent, + ]) +} + +private func nestedCoverageClaudeLine(id: String) -> String { + #"{"type":"assistant","uuid":"uuid-\#(id)","requestId":"request","sessionId":"session","# + + #""timestamp":"2026-08-29T10:00:00Z","message":{"id":"\#(id)","model":"claude-haiku-4-5","# + + #""content":[],"usage":{"input_tokens":1,"output_tokens":0}}}"# +} + +private func nestedCoverageRolloutLine(percent: Int) -> String { + #"{"timestamp":"2026-08-29T10:00:00Z","rate_limits":{"primary":{"used_percent":\#(percent),"# + + #""window_minutes":300}}}"# +} + +private final class NestedCoverageReadFailure: @unchecked Sendable { + private let lock = NSLock() + private var calls = 0 + + var count: Int { lock.withLock { calls } } + + func read(_ handle: FileHandle, count: Int) throws -> Data? { + lock.withLock { calls += 1 } + throw CocoaError(.fileReadUnknown) + } +} + +private final class NestedCoverageClaudeTransport: HTTPTransport, @unchecked Sendable { + private let lock = NSLock() + private var profiles = 0 + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + if request.url?.path.hasSuffix("/profile") == true { + let attempt = lock.withLock { + profiles += 1 + return profiles + } + if attempt > 1 { throw URLError(.notConnectedToInternet) } + return response(Fixtures.data("claude_profile"), for: request) + } + return response(Fixtures.data("claude_usage"), for: request) + } + + private func response(_ data: Data, for request: URLRequest) -> (Data, URLResponse) { + (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} diff --git a/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift b/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift new file mode 100644 index 0000000..e4a8c7c --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift @@ -0,0 +1,209 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func providerRegistryFactoryDerivesSandboxRequirementsFromCredentialSources() { + let all = Set(ProviderID.allSandboxResources) + #expect(ProviderRegistryFactory.resourcesRequiringSandboxAccess(environment: [:]) == all) + + let token = ProviderRegistryFactory.resourcesRequiringSandboxAccess( + environment: ["COPILOT_GITHUB_TOKEN": "token"]) + #expect(token == all.subtracting(ProviderID.copilot.sandboxResources)) + #expect( + ProviderRegistryFactory.resourcesRequiringSandboxAccess(environment: ["COPILOT_GITHUB_TOKEN": ""]) == all) +} + +@Test @MainActor func providerRegistryFactoryBuildsExactCredentialChainsAndSetup() async throws { + let root = temporaryDirectory() + let home = root.appendingPathComponent("home") + let support = root.appendingPathComponent("support") + let environment = [ + "CLAUDE_CONFIG_DIR": home.appendingPathComponent(".claude").path, + "COPILOT_GITHUB_TOKEN": "copilot-token", + ] + let resourceURLs = providerResourceURLs(home: home, environment: environment) + try prepareProviderCredentials(resourceURLs: resourceURLs) + + let codexResource = ProviderID.codex.sandboxResources[0] + let codexAccess = ResourceAccessState(resource: codexResource, health: .granted) + let transport = StubTransport() + let registry = ProviderRegistryFactory.make( + configuration: ProviderRegistryFactory.Configuration( + home: home, + supportDirectory: support, + environment: environment, + userName: "factory-test-\(UUID().uuidString)", + resourceURLs: resourceURLs, + resourceAccess: [.codex: [codexAccess]], + enabledProviders: Set(ProviderID.allCases).subtracting([.gemini]), + keychain: MemoryKeychain().client, + allowTokenRefresh: { false }), + client: APIClient(transport: transport, log: makeLog()), + log: makeLog()) + + #expect(registry.ids == ProviderID.allCases.sorted()) + #expect(registry.setupStates[.codex]?.resources == [codexAccess]) + #expect(registry.setupStates[.gemini]?.enabled == false) + #expect( + registry[.codex]?.credentialDescription + == resourceURLs[codexResource.id]!.appendingPathComponent("auth.json").path) + + let expectedSources: [ProviderID: String] = [ + .claude: "claude.file", + .codex: "codex.file", + .copilot: "copilot.environment", + .cursor: "cursor.agent", + .gemini: "gemini.file", + ] + for (providerID, sourceID) in expectedSources { + let provider = try #require(registry[providerID]) + let health = await provider.credentialHealth(now: fixedNow) + #expect(health.source?.id == sourceID) + } + + let claude = try #require(registry[.claude]) + _ = await claude.fetch(now: fixedNow, options: FetchOptions()) + #expect(transport.requests.isEmpty) +} + +@Test @MainActor func providerRegistryFactoryFollowsKeychainStoragePolicies() { + let root = temporaryDirectory() + let home = root.appendingPathComponent("home") + let environment = ["GEMINI_FORCE_ENCRYPTED_FILE_STORAGE": "true"] + let registry = ProviderRegistryFactory.make( + configuration: ProviderRegistryFactory.Configuration( + home: home, + supportDirectory: root.appendingPathComponent("support"), + environment: environment, + userName: "factory-test", + resourceURLs: [:], + enabledProviders: Set(ProviderID.allCases), + keychain: MemoryKeychain().client, + allowTokenRefresh: { true }), + client: APIClient(transport: StubTransport(), log: makeLog()), + log: makeLog()) + + let codexHome = ProviderID.codex.sandboxResources[0].configuredURL(environment: environment, home: home) + let codexDescription = [ + KeychainCodexAuthStore(codexHome: codexHome, keychain: .empty).description, + FileCodexAuthStore(url: codexHome.appendingPathComponent("auth.json")).description, + ].joined(separator: ", ") + let geminiHome = ProviderID.gemini.sandboxResources[0].configuredURL(environment: environment, home: home) + let geminiDescription = [ + KeychainGeminiAuthStore(keychain: .empty).description, + FileGeminiAuthStore(url: geminiHome.appendingPathComponent("oauth_creds.json")).description, + ].joined(separator: ", ") + + #expect(registry[.codex]?.credentialDescription == codexDescription) + #expect(registry[.gemini]?.credentialDescription == geminiDescription) +} + +@Test @MainActor func providerRegistryFactoryResolvesGeminiOAuthWhenItsProviderRefreshes() async throws { + let root = temporaryDirectory() + let home = root.appendingPathComponent("home") + let support = root.appendingPathComponent("support") + let environment = [ + "GEMINI_OAUTH_CLIENT_ID": "factory-client", + "GEMINI_OAUTH_CLIENT_SECRET": "factory-secret", + ] + let resourceURLs = providerResourceURLs(home: home, environment: environment) + let geminiHome = resourceURLs[ProviderID.gemini.sandboxResources[0].id]! + try FileManager.default.createDirectory(at: geminiHome, withIntermediateDirectories: true) + let expired = GeminiAuth( + accessToken: "expired", + refreshToken: "refresh", + expiresAt: fixedNow.addingTimeInterval(-60)) + try JSONEncoder().encode(expired.document).write(to: geminiHome.appendingPathComponent("oauth_creds.json")) + let transport = StubTransport() + transport.on(path: "/token", .text("unavailable", status: 503)) + let registry = ProviderRegistryFactory.make( + configuration: ProviderRegistryFactory.Configuration( + home: home, + supportDirectory: support, + environment: environment, + userName: "factory-gemini", + resourceURLs: resourceURLs, + enabledProviders: [.gemini], + keychain: MemoryKeychain().client, + allowTokenRefresh: { true }), + client: APIClient(transport: transport, log: makeLog()), + log: makeLog()) + let provider = try #require(registry[.gemini]) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .notAuthenticated("Gemini token refresh failed: HTTP 503")) + #expect(transport.requests(matching: "/token").count == 1) +} + +@Test @MainActor func providerRegistryFactoryTransfersResourceLeaseOwnership() throws { + let root = temporaryDirectory() + let probe = RegistryLeaseProbe() + var lease: SecurityScopedResourceLease? = SecurityScopedResourceLease(url: root, stop: probe.release) + var configuration: ProviderRegistryFactory.Configuration? = ProviderRegistryFactory.Configuration( + home: root, + supportDirectory: root.appendingPathComponent("support"), + environment: [:], + userName: "factory-test", + resourceURLs: [:], + resourceLeases: [try #require(lease)], + enabledProviders: Set(ProviderID.allCases), + keychain: MemoryKeychain().client, + allowTokenRefresh: { false }) + var registry: ProviderRegistry? = ProviderRegistryFactory.make( + configuration: try #require(configuration), + client: APIClient(transport: StubTransport(), log: makeLog()), + log: makeLog()) + + configuration = nil + lease = nil + #expect(registry?.ids.count == ProviderID.allCases.count) + #expect(probe.releases == 0) + registry = nil + #expect(probe.releases == 1) +} + +private func providerResourceURLs(home: URL, environment: [String: String]) -> [String: URL] { + Dictionary( + uniqueKeysWithValues: ProviderID.allSandboxResources.map { + ($0.id, $0.configuredURL(environment: environment, home: home)) + }) +} + +private func prepareProviderCredentials(resourceURLs: [String: URL]) throws { + for resource in ProviderID.allSandboxResources { + let url = resourceURLs[resource.id]! + try FileManager.default.createDirectory( + at: resource.kind == .file ? url.deletingLastPathComponent() : url, + withIntermediateDirectories: true) + } + + let claudeHome = resourceURLs[ProviderID.claude.sandboxResources[0].id]! + let claude = ClaudeOAuthCredentials( + accessToken: "claude-token", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-1)) + try JSONEncoder().encode(claude.document).write(to: claudeHome.appendingPathComponent(".credentials.json")) + + let codexHome = resourceURLs[ProviderID.codex.sandboxResources[0].id]! + let codex = CodexAuth(accessToken: "codex-token") + try JSONEncoder().encode(codex.document).write(to: codexHome.appendingPathComponent("auth.json")) + try Data(#"cli_auth_credentials_store = "file""#.utf8).write(to: codexHome.appendingPathComponent("config.toml")) + + let geminiHome = resourceURLs[ProviderID.gemini.sandboxResources[0].id]! + let gemini = GeminiAuth(accessToken: "gemini-token", expiresAt: fixedNow.addingTimeInterval(3600)) + try JSONEncoder().encode(gemini.document).write(to: geminiHome.appendingPathComponent("oauth_creds.json")) + + let cursorHome = resourceURLs[ProviderID.cursor.sandboxResources[1].id]! + try Data(#"{"accessToken":"cursor-token"}"#.utf8).write(to: cursorHome.appendingPathComponent("auth.json")) +} + +private final class RegistryLeaseProbe: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var releases: Int { lock.withLock { count } } + + func release(_: URL) { + lock.withLock { count += 1 } + } +} diff --git a/Tests/TokenMenuBarCoreTests/ProviderSetupTests.swift b/Tests/TokenMenuBarCoreTests/ProviderSetupTests.swift new file mode 100644 index 0000000..8701752 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ProviderSetupTests.swift @@ -0,0 +1,531 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func providerSetupMetadataCoversEveryProvider() { + for provider in ProviderID.allCases { + #expect(provider.setup.provider == provider) + #expect(!provider.setup.signInTitle.isEmpty) + #expect(!provider.setup.signInDetail.isEmpty) + #expect(!provider.setup.credentialSources.isEmpty) + #expect(provider.setup.credentialSources.allSatisfy { $0.provider == provider }) + } + #expect(ProviderID.codex.setup.signInCommand == "codex login") + #expect(ProviderID.copilot.setup.signInCommand == "copilot login") +} + +@Test func providerSetupPrefersARequiredResourceRecovery() { + let resource = ProviderID.codex.sandboxResources[0] + let state = ProviderSetupState.from( + provider: .codex, + enabled: true, + credentialState: .missing("No credential source could be read."), + source: ProviderID.codex.credentialSource("codex.file"), + resources: [ResourceAccessState(resource: resource, health: .needed)]) + #expect(state.credential == .missing(expected: ProviderID.codex.setup.credentialSources)) + #expect(state.issue?.kind == .resourceAccess) + #expect(state.issue?.action == .grantAccess(resource)) +} + +@Test func providerCredentialHealthExposesOnlyItsActiveSource() { + let source = ProviderID.codex.credentialSource("codex.file") + #expect(ProviderCredentialHealth.valid(source: source, expiresAt: fixedNow).source == source) + #expect(ProviderCredentialHealth.expired(source: source, at: fixedNow).source == source) + #expect(ProviderCredentialHealth.unreadable(source: source, detail: "broken").source == source) + #expect(ProviderCredentialHealth.unreadable(source: nil, detail: "broken").source == nil) + #expect(ProviderCredentialHealth.missing(expected: [source]).source == nil) + #expect(ProviderCredentialHealth.unchecked.source == nil) +} + +@Test func providerRecoveryActionsUseControlSpecificTitles() { + let resource = ProviderID.codex.sandboxResources[0] + #expect(ProviderRecoveryAction.copyCommand("codex login").title == "Copy command") + #expect(ProviderRecoveryAction.checkAgain.title == "Check again") + #expect(ProviderRecoveryAction.refreshProvider(.codex).title == "Check again") + #expect(ProviderRecoveryAction.grantAccess(resource).title == "Grant access") + #expect(ProviderRecoveryAction.openLoginItems.title == "Open Login Items") + #expect(ProviderRecoveryAction.contactAdministrator.title == "Contact administrator") +} + +@Test func providerSetupRetainsLegacyMissingDetailWithoutAResourceFailure() { + let state = ProviderSetupState.from( + provider: .codex, + enabled: true, + credentialState: .missing("The selected auth.json disappeared."), + source: ProviderID.codex.credentialSource("codex.file"), + resources: []) + #expect(state.issue?.kind == .credentialMissing) + #expect(state.issue?.detail == "The selected auth.json disappeared.") + #expect(state.issue?.action == .copyCommand("codex login")) +} + +@Test func providerSetupReportsExpiredCredentials() { + let source = ProviderID.gemini.credentialSource("gemini.file") + let state = ProviderSetupState.from( + provider: .gemini, + enabled: true, + credential: .expired(source: source, at: fixedNow), + resources: []) + #expect(state.issue?.kind == .credentialExpired) + #expect(state.issue?.title == "Gemini sign-in expired") + #expect(state.issue?.action == .copyCommand("gemini")) +} + +@Test func providerSetupDoesNotRequestAnUnusedResource() { + let resource = ProviderID.copilot.sandboxResources[0] + let source = ProviderID.copilot.credentialSource("copilot.environment") + let state = ProviderSetupState.from( + provider: .copilot, + enabled: true, + credentialState: .valid(expiresAt: nil), + source: source, + resources: [ResourceAccessState(resource: resource, health: .needed)]) + #expect(state.credential == .valid(source: source, expiresAt: nil)) + #expect(state.issue == nil) +} + +@Test(arguments: [ + (QuotaAvailability.loading, ProviderServiceHealth.checking), + (.current, .available), + (.stale, .available), + (.networkUnavailable, .offline(detail: "detail")), + (.rateLimited, .rateLimited(retryAt: nil, detail: "detail")), + (.unavailable, .unavailable(detail: "detail")), + (.authenticationRequired, .unavailable(detail: "detail")), + (.disabled, .unchecked), +]) +func providerServiceHealthUsesTypedAvailability( + availability: QuotaAvailability, + expected: ProviderServiceHealth +) { + #expect(ProviderServiceHealth.from(availability: availability, detail: "detail") == expected) +} + +@Test func providerStateCarriesSetupHealthWithoutReplacingLegacyState() { + let source = ProviderID.claude.credentialSource("claude.keychain") + let state = ProviderState( + availability: .current, + credentialState: .valid(expiresAt: nil), + credentialHealth: .valid(source: source, expiresAt: nil), + serviceHealth: .available) + #expect(state.credentialState == .valid(expiresAt: nil)) + #expect(state.credentialHealth == .valid(source: source, expiresAt: nil)) + #expect(state.serviceHealth == .available) +} + +@Test func unsupportedAccountsHaveATypedRecoveryAction() { + let issue = ProviderRecoveryIssue.unsupportedAccount(provider: .gemini, detail: "Use a supported account.") + #expect(issue.kind == .accountUnsupported) + #expect(issue.action == .copyCommand("gemini")) +} + +@MainActor +@Test func disablingAProviderRetainsItsLastKnownData() { + let state = AppState() + let snapshot = ProviderSnapshot(provider: .codex, windows: [], fetchedAt: fixedNow) + let analytics = ProviderAnalytics(provider: .codex, points: [], fetchedAt: fixedNow) + state.update(.codex) { + $0.snapshot = snapshot + $0.analytics = analytics + $0.lastSuccess = fixedNow + $0.availability = .current + } + state.update(.codex) { + $0.snapshot = nil + $0.analytics = nil + $0.lastSuccess = nil + $0.availability = .disabled + } + #expect(state.state(for: .codex).snapshot == snapshot) + #expect(state.state(for: .codex).analytics == analytics) + #expect(state.state(for: .codex).lastSuccess == fixedNow) +} + +@MainActor +@Test func disablingAProviderRetainsItsFailureButSuppressesRecovery() { + let state = AppState() + let issue = ProviderRecoveryIssue( + kind: .credentialUnreadable, + title: "Codex credentials could not be read", + detail: "auth.json is not JSON", + action: .checkAgain) + state.update(.codex) { + $0.availability = .authenticationRequired + $0.lastError = "auth.json is not JSON" + $0.warnings = ["Using the last known quota."] + $0.credentialState = .missing("Cannot read Codex credentials") + $0.recoveryIssue = issue + } + state.update(.codex) { + $0.availability = .disabled + $0.lastError = nil + $0.warnings = [] + $0.credentialState = nil + $0.recoveryIssue = nil + } + let disabled = state.state(for: .codex) + #expect(disabled.lastError == "auth.json is not JSON") + #expect(disabled.warnings == ["Using the last known quota."]) + #expect(disabled.credentialState == .missing("Cannot read Codex credentials")) + #expect(disabled.recoveryIssue == nil) +} + +@MainActor +@Test func legacyMissingStateDoesNotReplaceUnreadableCredentialHealth() { + let state = AppState() + let source = ProviderID.codex.credentialSource("codex.file") + state.applySetupStates([ + .codex: ProviderSetupState.from( + provider: .codex, + enabled: true, + credential: .unreadable(source: source, detail: "auth.json is not JSON"), + resources: []) + ]) + state.update(.codex) { + $0.availability = .authenticationRequired + $0.credentialState = .missing("Cannot read Codex credentials") + $0.recoveryIssue = nil + } + let provider = state.state(for: .codex) + #expect(provider.credentialHealth == .unreadable(source: source, detail: "auth.json is not JSON")) + #expect(provider.recoveryIssue?.kind == .credentialUnreadable) +} + +@Test(arguments: [ + ( + CredentialState.missing("not signed in"), + ProviderCredentialHealth.missing(expected: ProviderID.claude.setup.credentialSources) + ), + ( + CredentialState.expired(fixedNow), + ProviderCredentialHealth.expired(source: ProviderID.claude.setup.credentialSources[0], at: fixedNow) + ), + ( + CredentialState.valid(expiresAt: fixedNow), + ProviderCredentialHealth.valid(source: ProviderID.claude.setup.credentialSources[0], expiresAt: fixedNow) + ), +]) +@MainActor +func appStateMapsLegacyCredentialStates( + credentialState: CredentialState, + expected: ProviderCredentialHealth +) { + let state = AppState() + state.applySetupStates([.claude: ProviderSetupState(enabled: true)]) + + state.update(.claude) { $0.credentialState = credentialState } + + #expect(state.state(for: .claude).credentialHealth == expected) +} + +@Test @MainActor func appStatePrefersRequiredResourceRecoveryForAuthentication() { + let state = AppState() + let resource = ProviderID.codex.sandboxResources[0] + state.applySetupStates([ + .codex: ProviderSetupState( + enabled: true, + credential: .missing(expected: ProviderID.codex.setup.credentialSources), + resources: [ResourceAccessState(resource: resource, health: .stale)]) + ]) + + state.update(.codex) { $0.availability = .authenticationRequired } + + let issue = state.state(for: .codex).recoveryIssue + #expect(issue?.kind == .resourceAccess) + #expect(issue?.title == "Access grant needs renewal") + #expect(issue?.action == .grantAccess(resource)) +} + +@Test(arguments: [ + (QuotaAvailability.networkUnavailable, ProviderRecoveryIssue.Kind.network, "The provider could not be reached."), + ( + QuotaAvailability.rateLimited, + ProviderRecoveryIssue.Kind.rateLimited, + "Token Menu Bar will retry after the provider allows another request." + ), + (QuotaAvailability.unavailable, ProviderRecoveryIssue.Kind.service, "The provider did not return usable data."), +]) +@MainActor +func appStateBuildsServiceRecovery( + availability: QuotaAvailability, + kind: ProviderRecoveryIssue.Kind, + detail: String +) { + let state = AppState() + let source = ProviderID.claude.setup.credentialSources[0] + state.applySetupStates([ + .claude: ProviderSetupState(enabled: true, credential: .valid(source: source, expiresAt: nil)) + ]) + + state.update(.claude) { $0.availability = availability } + + let issue = state.state(for: .claude).recoveryIssue + #expect(issue?.kind == kind) + #expect(issue?.detail == detail) + #expect(issue?.action == .refreshProvider(.claude)) +} + +@Test(arguments: [QuotaAvailability.loading, .current, .stale]) +@MainActor +func appStateOmitsRecoveryForHealthyAvailability(_ availability: QuotaAvailability) { + let state = AppState() + let source = ProviderID.claude.setup.credentialSources[0] + state.applySetupStates([ + .claude: ProviderSetupState(enabled: true, credential: .valid(source: source, expiresAt: nil)) + ]) + + state.update(.claude) { $0.availability = availability } + + #expect(state.state(for: .claude).recoveryIssue == nil) +} + +@Test func credentialInspectionReportsTheWinningSource() throws { + let first = SourcedCodexStore(auth: nil, sourceID: "first") + let second = SourcedCodexStore(auth: CodexAuth(accessToken: "token"), sourceID: "second") + let found = try #require(try ChainedCodexAuthStore([first, second]).loadWithSource()) + #expect(found.auth.accessToken == "token") + #expect(found.source.id == "second") +} + +@Test func credentialInspectionReportsReadFailures() { + let store = MemoryGeminiStore(nil) + store.loadError = CredentialStoreError.malformed("broken") + #expect( + store.credentialHealth(now: fixedNow) + == .unreadable(source: store.source, detail: #"malformed("broken")"#)) +} + +@Test func credentialChainsRetainTheFailingSource() throws { + let malformed = temporaryDirectory().appendingPathComponent("broken.json") + try Data("not JSON".utf8).write(to: malformed) + + let claude = FileClaudeCredentialStore(url: malformed) + let codex = FileCodexAuthStore(url: malformed) + let gemini = FileGeminiAuthStore(url: malformed) + let cursor = FileCursorAuthStore(url: malformed) + let copilot = FileCopilotCLIAuthStore(url: malformed) + + expectUnreadable( + ChainedClaudeCredentialStore([claude, MemoryClaudeStore(nil)]).credentialHealth(now: fixedNow), + source: claude.source) + expectUnreadable( + ChainedCodexAuthStore([codex, MemoryCodexStore(nil)]).credentialHealth(now: fixedNow), + source: codex.source) + expectUnreadable( + ChainedGeminiAuthStore([gemini, MemoryGeminiStore(nil)]).credentialHealth(now: fixedNow), + source: gemini.source) + expectUnreadable( + ChainedCursorAuthStore([cursor, MemoryCursorStore(nil)]).credentialHealth(now: fixedNow), + source: cursor.source) + expectUnreadable( + ChainedCopilotAuthStore([copilot, MemoryCopilotStore(nil)]).credentialHealth(now: fixedNow), + source: copilot.source) +} + +@Test(arguments: [ + ("", CodexCredentialStorage.automatic), + (#"cli_auth_credentials_store = "file""#, .file), + (#"cli_auth_credentials_store='keyring' # secure"#, .keyring), + (#"cli_auth_credentials_store = "auto""#, .automatic), + (#"cli_auth_credentials_store = "future""#, .unknown("future")), + ("[profile.work]\ncli_auth_credentials_store = \"file\"", .automatic), +]) +func codexCredentialStorageReadsOnlyTheUserLevelSetting(text: String, expected: CodexCredentialStorage) { + #expect(CodexCredentialStorageReader.parse(text) == expected) +} + +@Test func codexKeychainReaderUsesTheCLIsAccountAndDocument() throws { + let root = temporaryDirectory().appendingPathComponent(".codex") + let linked = root.deletingLastPathComponent().appendingPathComponent("codex-link") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: root) + #expect(KeychainCodexAuthStore.account(codexHome: linked) == KeychainCodexAuthStore.account(codexHome: root)) + #expect(KeychainCodexAuthStore.account(codexHome: root).hasPrefix("cli|")) + #expect(KeychainCodexAuthStore.account(codexHome: root).count == 20) + let data = try JSONEncoder().encode(CodexAuth(accessToken: "token").document) + #expect(try KeychainCodexAuthStore.parse(data)?.accessToken == "token") + #expect(throws: CredentialStoreError.malformed("Codex Keychain item is not JSON")) { + try KeychainCodexAuthStore.parse(Data("bad".utf8)) + } +} + +@Test func geminiStorageAndKeychainDocumentFollowTheCLIFormats() throws { + #expect(GeminiCredentialStorage.resolve(environment: [:]) == .file) + #expect( + GeminiCredentialStorage.resolve(environment: ["GEMINI_FORCE_ENCRYPTED_FILE_STORAGE": "true"]) == .keychain) + #expect( + GeminiCredentialStorage.resolve(environment: ["GEMINI_FORCE_ENCRYPTED_FILE_STORAGE": "TRUE"]) == .file) + let document: JSONValue = .object([ + "serverName": .string("main-account"), + "token": .object([ + "accessToken": .string("access"), + "refreshToken": .string("refresh"), + "expiresAt": .number(1_900_000_000_000), + ]), + ]) + let data = try JSONEncoder().encode(document) + let auth = try #require(try KeychainGeminiAuthStore.parse(data)) + #expect(auth.accessToken == "access") + #expect(auth.refreshToken == "refresh") + #expect(auth.expiresAt == Date(timeIntervalSince1970: 1_900_000_000)) +} + +@Test func geminiKeychainRefreshPreservesWrapperMetadata() throws { + let document: JSONValue = .object([ + "futureTopLevel": .string("keep"), + "serverName": .string("main-account"), + "token": .object([ + "accessToken": .string("old"), + "expiresAt": .number(1_900_000_000_000), + "futureTokenField": .string("keep"), + "refreshToken": .string("refresh"), + "scope": .string("openid profile"), + "tokenType": .string("Bearer"), + ]), + ]) + let auth = try #require(GeminiAuth(document: document)) + let refreshed = auth.refreshed(accessToken: "new", expiresIn: 3600, idToken: nil, now: fixedNow) + let saved = KeychainGeminiAuthStore.document(for: refreshed, updatedAt: fixedNow) + + #expect(saved["futureTopLevel"] == .string("keep")) + #expect(saved["token"]?["accessToken"] == .string("new")) + #expect(saved["token"]?["futureTokenField"] == .string("keep")) + #expect(saved["token"]?["refreshToken"] == .string("refresh")) + #expect(saved["token"]?["scope"] == .string("openid profile")) + #expect(saved["token"]?["tokenType"] == .string("Bearer")) + #expect(saved["updatedAt"] == .number(fixedNow.timeIntervalSince1970 * 1000)) +} + +@Test func credentialBackedResourcesAreNotRequired() { + let source = ProviderID.copilot.credentialSource("copilot.environment") + let resources = ProviderID.copilot.sandboxResources.map(ResourceAccessState.notRequired) + let state = ProviderSetupState.from( + provider: .copilot, + enabled: true, + credential: .valid(source: source, expiresAt: nil), + resources: resources) + + #expect(!ProviderID.copilot.needsSandboxResources(for: source)) + #expect(ProviderID.copilot.needsSandboxResources(for: ProviderID.copilot.credentialSource("copilot.keychain"))) + #expect(resources.allSatisfy { !$0.isRequired && $0.health == .notRequired }) + #expect(state.issue == nil) +} + +@Test func notRequiredResourcesNeverMaskCredentialRecovery() { + let resources = ProviderID.copilot.sandboxResources.map(ResourceAccessState.notRequired) + let state = ProviderSetupState.from( + provider: .copilot, + enabled: true, + credential: .missing(expected: ProviderID.copilot.setup.credentialSources), + resources: resources) + #expect(state.issue?.kind == .credentialMissing) + #expect(state.issue?.action == .copyCommand("copilot login")) +} + +@Test func copilotCurrentSourcesFollowDocumentedPrecedence() throws { + let environment = EnvironmentCopilotAuthStore(environment: [ + "COPILOT_GITHUB_TOKEN": "copilot", "GH_TOKEN": "gh", "GITHUB_TOKEN": "github", "GH_HOST": "ghe.example", + ]) + #expect(try environment.load() == CopilotAuth(token: "copilot", host: "ghe.example")) + #expect( + CopilotCredentialStorageReader.detect( + environmentTokenExists: true, + keychainItemExists: true, + cliFileExists: true, + legacyFileExists: true) == .environment) + #expect( + CopilotCredentialStorageReader.detect( + environmentTokenExists: false, + keychainItemExists: false, + cliFileExists: true, + legacyFileExists: true) == .cliFile) + #expect(CopilotCredentialStorageReader.detect(keychainItemExists: true, legacyFileExists: true) == .cliKeychain) + #expect(CopilotCredentialStorageReader.detect(keychainItemExists: false, legacyFileExists: true) == .legacyFile) + #expect(CopilotCredentialStorageReader.detect(keychainItemExists: false, legacyFileExists: false) == .missing) +} + +@Test func copilotCLIConfigReadsPlaintextFallbackAndKeychainAccounts() throws { + let url = temporaryDirectory().appendingPathComponent("config.json") + try Data( + #"{"loggedInUsers":{"https://github.com":{"login":"octo","token":"plain"},"https://corp.ghe.com":"ada"}}"# + .utf8 + ).write(to: url) + let store = FileCopilotCLIAuthStore(url: url) + #expect(try store.load() == CopilotAuth(token: "plain", user: "octo", host: "github.com")) + #expect(store.keychainAccounts() == ["https://corp.ghe.com:ada", "https://github.com:octo"]) + #expect(try KeychainCopilotAuthStore.parse(Data("token".utf8), account: "octo")?.token == "token") + #expect( + try KeychainCopilotAuthStore.parse(Data("enterprise".utf8), account: "https://corp.ghe.com:ada") + == CopilotAuth(token: "enterprise", user: "ada", host: "corp.ghe.com")) + let json = Data(#"{"oauth_token":"json-token","user":"hubot","host":"github.com"}"#.utf8) + #expect(try KeychainCopilotAuthStore.parse(json, account: nil) == CopilotAuth(token: "json-token", user: "hubot")) +} + +@Test func credentialRefreshSaveDoesNotOverwriteAChangedSource() throws { + let original = ClaudeOAuthCredentials(accessToken: "old", refreshToken: "refresh", expiresAt: fixedNow) + let current = ClaudeOAuthCredentials(accessToken: "cli", refreshToken: "new", expiresAt: nil) + let refreshed = original.refreshed(accessToken: "app", refreshToken: nil, expiresIn: 3600, now: fixedNow) + let store = MemoryClaudeStore(original) + try store.save(current) + guard case .changed(let found, let source) = try store.save(refreshed, replacing: original) else { + Issue.record("expected the changed credential") + return + } + #expect(found == current) + #expect(source == store.source) + #expect(try store.load() == current) +} + +@Test func codexRefreshSaveDoesNotOverwriteAChangedSource() throws { + let originalCodex = CodexAuth(accessToken: "old") + let currentCodex = CodexAuth(accessToken: "cli") + let codex = MemoryCodexStore(originalCodex) + try codex.save(currentCodex) + guard + case .changed(let foundCodex, let source) = try codex.save( + CodexAuth(accessToken: "app"), replacing: originalCodex) + else { + Issue.record("expected the changed Codex credential") + return + } + #expect(foundCodex == currentCodex) + #expect(source == codex.source) +} + +@Test func geminiRefreshSaveDoesNotOverwriteAChangedSource() throws { + let originalGemini = GeminiAuth(accessToken: "old") + let currentGemini = GeminiAuth(accessToken: "cli") + let gemini = MemoryGeminiStore(originalGemini) + try gemini.save(currentGemini) + guard + case .changed(let foundGemini, let source) = try gemini.save( + GeminiAuth(accessToken: "app"), replacing: originalGemini) + else { + Issue.record("expected the changed Gemini credential") + return + } + #expect(foundGemini == currentGemini) + #expect(source == gemini.source) +} + +private struct SourcedCodexStore: CodexAuthStore { + let auth: CodexAuth? + let sourceID: String + + var description: String { sourceID } + var source: CredentialSource { + CredentialSource(id: sourceID, provider: .codex, title: sourceID, detail: sourceID) + } + + func load() throws -> CodexAuth? { auth } + func save(_ auth: CodexAuth) throws {} +} + +private func expectUnreadable(_ health: ProviderCredentialHealth, source: CredentialSource) { + guard case .unreadable(let found, _) = health else { + Issue.record("expected unreadable credential health") + return + } + #expect(found == source) +} diff --git a/Tests/TokenMenuBarCoreTests/ProviderTests.swift b/Tests/TokenMenuBarCoreTests/ProviderTests.swift new file mode 100644 index 0000000..86db7be --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ProviderTests.swift @@ -0,0 +1,550 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func claudeProviderFetchesUsageAndProfile() async { + let transport = StubTransport() + transport.on(path: "/api/oauth/usage", .json("claude_usage")) + transport.on(path: "/api/oauth/profile", .json("claude_profile")) + let store = MemoryClaudeStore(validClaude) + let provider = claudeProvider(store, transport: transport) + #expect(provider.id == .claude) + #expect(provider.credentialDescription == "memory") + #expect(provider.credentialState(now: fixedNow) == .valid(expiresAt: validClaude.expiresAt)) + #expect( + await provider.credentialHealth(now: fixedNow) + == .valid(source: store.source, expiresAt: validClaude.expiresAt)) + let readsBeforeFetch = store.readCount + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(store.readCount == readsBeforeFetch + 1) + #expect( + result.credentialStatus + == ProviderCredentialStatus( + state: .valid(expiresAt: validClaude.expiresAt), + health: .valid(source: store.source, expiresAt: validClaude.expiresAt))) + guard case .success(let snapshot) = result.outcome else { + Issue.record("expected success, got \(result.outcome)") + return + } + #expect(snapshot.identity?.planName == "Max 20x") + #expect(snapshot.identity?.email == "user@example.com") + #expect(snapshot.windows.map(\.id) == ["session", "weekly:fable"]) + #expect(snapshot.spend?.enabled == false) + #expect(result.warnings.isEmpty) + #expect(result.analytics == nil) + let usageRequest = transport.requests(matching: "/api/oauth/usage")[0] + #expect(usageRequest.value(forHTTPHeaderField: "Authorization") == "Bearer tok") + #expect(usageRequest.value(forHTTPHeaderField: "anthropic-beta") == ClaudeAPI.betaHeader) + #expect(usageRequest.value(forHTTPHeaderField: "User-Agent") == ClaudeAPI.userAgent) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(transport.requests(matching: "/api/oauth/profile").count == 1) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(7 * 3600), options: FetchOptions()) + #expect(transport.requests(matching: "/api/oauth/profile").count == 2) +} + +let validClaude = ClaudeOAuthCredentials( + accessToken: "tok", refreshToken: "ref", expiresAt: fixedNow.addingTimeInterval(86400), subscriptionType: "max", + rateLimitTier: "default_claude_max_20x") +private let expiredClaude = ClaudeOAuthCredentials( + accessToken: "old", refreshToken: "ref", expiresAt: fixedNow.addingTimeInterval(-10)) + +func claudeProvider( + _ store: any ClaudeCredentialStore, transport: StubTransport, allowRefresh: Bool = false, localAccount: URL? = nil, + transcripts: URL? = nil +) -> ClaudeProvider { + ClaudeProvider( + credentials: store, localAccountURL: localAccount, + transcripts: transcripts.map { ClaudeTranscriptReader(root: $0) }, + client: APIClient(transport: transport, log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { allowRefresh }) +} + +@Test func claudeProviderReportsMissingAndUnreadableCredentials() async { + let transport = StubTransport() + let missing = await claudeProvider(MemoryClaudeStore(nil), transport: transport).fetch( + now: fixedNow, options: FetchOptions()) + #expect(missing.outcome == .notAuthenticated("No Claude Code credentials. \(ProviderID.claude.loginHint)")) + let store = MemoryClaudeStore(nil) + store.loadError = CredentialStoreError.keychain(-25300) + let provider = claudeProvider(store, transport: transport) + let unreadable = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(unreadable.outcome == .notAuthenticated("Cannot read Claude credentials: keychain(-25300)")) + #expect(provider.credentialState(now: fixedNow) == .missing("keychain(-25300)")) + #expect( + claudeProvider(MemoryClaudeStore(nil), transport: transport).credentialState(now: fixedNow) + == .missing("no Claude Code sign-in found")) +} + +@Test func claudeProviderExpiredTokenWithoutRefresh() async { + let result = await claudeProvider(MemoryClaudeStore(expiredClaude), transport: StubTransport()).fetch( + now: fixedNow, options: FetchOptions()) + #expect(result.outcome == .notAuthenticated("Claude token expired. \(ProviderID.claude.loginHint)")) +} + +@Test func claudeProviderRefreshesAndStoresToken() async { + let transport = StubTransport() + transport.on(path: "/v1/oauth/token", .text(#"{"access_token":"fresh","refresh_token":"fresh-r","expires_in":7200}"#)) + transport.on(path: "/api/oauth/usage", .json("claude_usage")) + transport.on(path: "/api/oauth/profile", .text("{}", status: 500)) + let store = MemoryClaudeStore(expiredClaude) + let result = await claudeProvider(store, transport: transport, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(result.warnings == ["Profile unavailable: HTTP 500"]) + #expect(store.saved.first?.accessToken == "fresh") + #expect(store.saved.first?.refreshToken == "fresh-r") + #expect( + transport.requests(matching: "/api/oauth/usage")[0].value(forHTTPHeaderField: "Authorization") == "Bearer fresh") + let body = try! JSONDecoder().decode( + [String: String].self, from: transport.requests(matching: "/v1/oauth/token")[0].httpBody!) + #expect(body == ["grant_type": "refresh_token", "refresh_token": "ref", "client_id": ClaudeAPI.clientID]) +} + +@Test func claudeProviderRefreshFailuresAndUnsavableTokens() async { + let transport = StubTransport() + transport.on(path: "/v1/oauth/token", .text(#"{"error":"invalid_grant"}"#, status: 400)) + let failed = await claudeProvider(MemoryClaudeStore(expiredClaude), transport: transport, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(failed.outcome == .notAuthenticated("Claude token refresh failed: HTTP 400")) + let noRefresh = ClaudeOAuthCredentials( + accessToken: "old", refreshToken: nil, expiresAt: fixedNow.addingTimeInterval(-10)) + let noToken = await claudeProvider(MemoryClaudeStore(noRefresh), transport: transport, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(noToken.outcome == .notAuthenticated("Claude token refresh failed: HTTP 401")) + let saving = StubTransport() + saving.on(path: "/v1/oauth/token", .text(#"{"access_token":"fresh"}"#)) + saving.on(path: "/api/oauth/usage", .json("claude_usage")) + saving.on(path: "/api/oauth/profile", .json("claude_profile")) + let store = MemoryClaudeStore(expiredClaude) + store.saveError = CredentialStoreError.keychain(-1) + let unsavedProvider = claudeProvider(store, transport: saving, allowRefresh: true) + let unsaved = await unsavedProvider.fetch(now: fixedNow, options: FetchOptions()) + #expect(unsaved.outcome.snapshot != nil) + #expect(unsaved.recoveryIssue?.kind == .credentialPersistence) + #expect(store.saved.isEmpty) + #expect(await unsavedProvider.credentialHealth(now: fixedNow).isUsable) + let malformed = StubTransport() + malformed.on(path: "/v1/oauth/token", .text("nope")) + let decoding = await claudeProvider(MemoryClaudeStore(expiredClaude), transport: malformed, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(decoding.outcome.errorDescription?.hasPrefix("Claude token refresh failed: Unexpected response") == true) +} + +@Test func claudeProviderMapsUsageErrors() async { + let transport = StubTransport() + transport.on(path: "/api/oauth/usage", .text("busy", status: 429, headers: ["Retry-After": "90"])) + transport.on(path: "/api/oauth/profile", .json("claude_profile")) + let limited = await claudeProvider(MemoryClaudeStore(validClaude), transport: transport).fetch( + now: fixedNow, options: FetchOptions()) + #expect(limited.outcome == .rateLimited("HTTP 429", retryAfter: 90)) + #expect(limited.warnings.isEmpty) + let unauthorized = StubTransport() + unauthorized.on(path: "/api/oauth/usage", .text("", status: 403)) + unauthorized.on(path: "/api/oauth/profile", .json("claude_profile")) + let scoped = ClaudeOAuthCredentials(accessToken: "tok", refreshToken: nil, expiresAt: nil, scopes: ["user:inference"]) + let denied = await claudeProvider(MemoryClaudeStore(scoped), transport: unauthorized).fetch( + now: fixedNow, options: FetchOptions()) + #expect(denied.outcome == .notAuthenticated("HTTP 403. \(ProviderID.claude.loginHint)")) + #expect(denied.warnings.first?.contains("user:profile") == true) + let offline = StubTransport() + offline.on(path: "/api/oauth/usage", error: URLError(.notConnectedToInternet)) + offline.on(path: "/api/oauth/profile", error: URLError(.notConnectedToInternet)) + let down = await claudeProvider(MemoryClaudeStore(validClaude), transport: offline).fetch( + now: fixedNow, options: FetchOptions()) + guard case .networkUnavailable = down.outcome else { + Issue.record("expected network failure") + return + } + #expect(down.warnings.count == 1) +} + +@Test func claudeProviderDoesNotReuseCachedProfileAcrossCredentials() async throws { + let transport = StubTransport() + transport.on(path: "/api/oauth/usage", .json("claude_usage")) + transport.on( + { $0.url?.path.hasSuffix("/profile") == true && $0.value(forHTTPHeaderField: "Authorization") == "Bearer tok" }, + .respond(.json("claude_profile"))) + let localURL = temporaryDirectory().appendingPathComponent(".claude.json") + try Data(#"{"oauthAccount":{"emailAddress":"local@example.com","organizationName":"Local"}}"#.utf8).write( + to: localURL) + let store = MemoryClaudeStore(validClaude) + let provider = claudeProvider(store, transport: transport, localAccount: localURL) + let first = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(first.outcome.snapshot?.identity?.email == "user@example.com") + try store.save( + ClaudeOAuthCredentials(accessToken: "other", refreshToken: nil, expiresAt: nil, subscriptionType: "max")) + let second = await provider.fetch(now: fixedNow.addingTimeInterval(7200), options: FetchOptions()) + #expect(second.outcome.snapshot?.identity?.email == "local@example.com") + #expect(second.warnings.count == 1) +} + +@Test func codexProviderFetchesUsageAndAnalytics() async { + let transport = StubTransport() + transport.on(path: "/wham/usage", .json("codex_usage")) + transport.on( + path: "rate-limit-reset-credits", + .text( + #"{"available_count":2,"applicable_available_count":1,"total_earned_count":3,"# + + #""immediate_reset_purchase_eligible":true}"#)) + stubCodexAnalytics(transport) + let store = MemoryCodexStore(validCodex) + let provider = codexProvider(store, transport: transport) + #expect(provider.id == .codex) + #expect(provider.credentialDescription == "memory") + #expect(provider.credentialState(now: fixedNow) == .valid(expiresAt: nil)) + #expect( + await provider.credentialHealth(now: fixedNow) + == .valid(source: store.source, expiresAt: nil)) + let readsBeforeFetch = store.readCount + let result = await provider.fetch(now: fixedNow, options: FetchOptions(includeAnalytics: true, analyticsDays: 7)) + #expect(store.readCount == readsBeforeFetch + 1) + #expect( + result.credentialStatus + == ProviderCredentialStatus( + state: .valid(expiresAt: nil), health: .valid(source: store.source, expiresAt: nil))) + guard case .success(let snapshot) = result.outcome else { + Issue.record("expected success, got \(result.outcome)") + return + } + #expect(snapshot.identity?.planName == "Pro") + #expect(snapshot.window("weekly") != nil) + #expect( + snapshot.resetCredits + == ResetCredits(available: 2, applicable: 1, totalEarned: 3, immediatePurchaseEligible: true)) + #expect(transport.requests(matching: "rate-limit-reset-credits").count == 1) + #expect(snapshot.credits?.balance == 0) + #expect(result.warnings.isEmpty) + #expect(result.analytics?.points.contains { $0.metric == .surfaceUsagePercent } == true) + #expect(result.analytics?.creditEvents.isEmpty == true) + let usage = transport.requests(matching: "/wham/usage")[0] + #expect(usage.value(forHTTPHeaderField: "Authorization") == "Bearer codex-tok") + #expect(usage.value(forHTTPHeaderField: "ChatGPT-Account-Id") == "acct") + #expect(usage.value(forHTTPHeaderField: "originator") == CodexAPI.originator) + let breakdown = transport.requests(matching: "daily-token-usage-breakdown")[0].url!.query! + #expect(breakdown.contains("start_date=2026-08-23&end_date=2026-08-29")) + _ = await provider.fetch( + now: fixedNow.addingTimeInterval(86400), options: FetchOptions(includeAnalytics: true, analyticsDays: 7)) + let incremental = transport.requests(matching: "daily-token-usage-breakdown")[1].url!.query! + #expect(incremental.contains("start_date=2026-08-28&end_date=2026-08-30")) +} + +let validCodex = CodexAuth( + accessToken: "codex-tok", refreshToken: "codex-ref", + idToken: CodexAuth(document: Fixtures.codexAuth())!.idToken, accountID: "acct") + +func codexProvider( + _ store: any CodexAuthStore, + transport: any HTTPTransport, + allowRefresh: Bool = false, + rollouts: URL? = nil, + analyticsWatermarkPersistence: CodexAnalyticsWatermarkPersistence = CodexAnalyticsWatermarkPersistence( + defaults: UserDefaults(suiteName: "codex-watermarks-\(UUID().uuidString)")!) +) -> CodexProvider { + CodexProvider( + auth: store, rollouts: rollouts.map { CodexRolloutReader(sessionsRoot: $0) }, + client: APIClient(transport: transport, log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { allowRefresh }, analyticsWatermarkPersistence: analyticsWatermarkPersistence) +} + +@Test func codexProviderUsesCompleteInlineResetCreditsWithoutASupplementalRequest() async { + let transport = StubTransport() + transport.on( + path: "/wham/usage", + .text( + #"{"plan_type":"pro","rate_limit_reset_credits":{"available_count":2,"applicable_available_count":1,"# + + #""total_earned_count":3,"immediate_reset_purchase_eligible":true}}"#)) + let result = await codexProvider(MemoryCodexStore(validCodex), transport: transport).fetch( + now: fixedNow, options: FetchOptions()) + #expect( + result.outcome.snapshot?.resetCredits + == ResetCredits(available: 2, applicable: 1, totalEarned: 3, immediatePurchaseEligible: true)) + #expect(transport.requests(matching: "rate-limit-reset-credits").isEmpty) +} + +@Test func codexProviderCoalescesAndCachesSupplementalResetCredits() async throws { + let transport = StubTransport() + transport.on(path: "/wham/usage", .text(#"{"plan_type":"pro"}"#)) + transport.on( + path: "rate-limit-reset-credits", + .text(#"{"available_count":2,"applicable_available_count":1}"#)) + let gate = TestGate() + let delayed = DelayedTransport(base: transport, suffix: "rate-limit-reset-credits", gate: gate) + let provider = codexProvider(MemoryCodexStore(validCodex), transport: delayed) + let first = Task { await provider.fetch(now: fixedNow, options: FetchOptions()) } + while !delayed.started { await Task.yield() } + let second = Task { await provider.fetch(now: fixedNow, options: FetchOptions()) } + while transport.requests(matching: "/wham/usage").count < 2 { await Task.yield() } + try await Task.sleep(for: .milliseconds(10)) + gate.open() + let results = await (first.value, second.value) + #expect( + [results.0, results.1].allSatisfy { + $0.outcome.snapshot?.resetCredits == ResetCredits(available: 2, applicable: 1) + }) + #expect(transport.requests(matching: "rate-limit-reset-credits").count == 1) + _ = await provider.fetch( + now: fixedNow.addingTimeInterval(CodexProvider.resetCreditsTTL - 1), options: FetchOptions()) + #expect(transport.requests(matching: "rate-limit-reset-credits").count == 1) + _ = await provider.fetch( + now: fixedNow.addingTimeInterval(CodexProvider.resetCreditsTTL), options: FetchOptions()) + #expect(transport.requests(matching: "rate-limit-reset-credits").count == 2) +} + +@Test func codexProviderNegativeCachesSupplementalResetCreditFailures() async { + let transport = StubTransport() + transport.on(path: "/wham/usage", .text(#"{"plan_type":"pro"}"#)) + transport.on(path: "rate-limit-reset-credits", error: URLError(.notConnectedToInternet)) + let provider = codexProvider(MemoryCodexStore(validCodex), transport: transport) + let first = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(first.outcome.snapshot?.resetCredits == nil) + #expect(first.warnings.first?.contains("Reset credits unavailable") == true) + let second = await provider.fetch( + now: fixedNow.addingTimeInterval(CodexProvider.resetCreditsFailureTTL - 1), options: FetchOptions()) + #expect(second.warnings == first.warnings) + #expect(transport.requests(matching: "rate-limit-reset-credits").count == 1) + _ = await provider.fetch( + now: fixedNow.addingTimeInterval(CodexProvider.resetCreditsFailureTTL), options: FetchOptions()) + #expect(transport.requests(matching: "rate-limit-reset-credits").count == 2) +} + +@Test func codexProviderAdvancesOnlySuccessfulAnalyticsWatermarks() async { + let transport = StubTransport() + transport.on(path: "/wham/usage", .json("codex_usage")) + stubCodexAnalytics(transport) + let intermittent = FailingNthTransport( + base: transport, suffix: "daily-token-usage-breakdown", failingRequest: 2) + let provider = codexProvider(MemoryCodexStore(validCodex), transport: intermittent) + for day in 0...2 { + _ = await provider.fetch( + now: fixedNow.addingTimeInterval(Double(day) * 86400), + options: FetchOptions(includeAnalytics: true, analyticsDays: 7)) + } + #expect(intermittent.queries.map(startDate) == ["2026-08-23", "2026-08-28", "2026-08-28"]) + #expect( + transport.requests(matching: "daily-workspace-usage-counts").compactMap(\.url?.query).map(startDate) + == ["2026-08-23", "2026-08-28", "2026-08-29"]) +} + +private func startDate(_ query: String) -> String { + URLComponents(string: "https://example.test?\(query)")?.queryItems?.first { $0.name == "start_date" }?.value ?? "" +} + +private final class DelayedTransport: HTTPTransport, @unchecked Sendable { + let base: any HTTPTransport + let suffix: String + let gate: TestGate + private let lock = NSLock() + private var didStart = false + + init(base: any HTTPTransport, suffix: String, gate: TestGate) { + self.base = base + self.suffix = suffix + self.gate = gate + } + + var started: Bool { lock.withLock { didStart } } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + if request.url?.path.hasSuffix(suffix) == true { + lock.withLock { didStart = true } + try await gate.wait() + } + return try await base.data(for: request) + } +} + +private final class FailingNthTransport: HTTPTransport, @unchecked Sendable { + let base: any HTTPTransport + let suffix: String + let failingRequest: Int + private let lock = NSLock() + private var matchingRequests: [URLRequest] = [] + + init(base: any HTTPTransport, suffix: String, failingRequest: Int) { + self.base = base + self.suffix = suffix + self.failingRequest = failingRequest + } + + var queries: [String] { + lock.withLock { matchingRequests.compactMap(\.url?.query) } + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + if request.url?.path.hasSuffix(suffix) == true { + let count = lock.withLock { + matchingRequests.append(request) + return matchingRequests.count + } + if count == failingRequest { throw URLError(.notConnectedToInternet) } + } + return try await base.data(for: request) + } +} + +func stubCodexAnalytics(_ transport: StubTransport) { + transport.on(path: "daily-token-usage-breakdown", .json("codex_daily_token_usage")) + transport.on(path: "daily-workspace-usage-counts", .json("codex_daily_workspace_usage")) + transport.on(path: "daily-skill-usage-metrics", .json("codex_daily_skills")) + transport.on(path: "daily-plugin-usage-metrics", .json("codex_daily_plugins")) + transport.on(path: "daily-code-review-metrics", .json("codex_daily_code_review")) + transport.on(path: "credit-usage-events", .json("codex_credit_events")) +} + +@Test func codexProviderAnalyticsWarningsAndEmptyResult() async { + let transport = StubTransport() + transport.on(path: "/wham/usage", .json("codex_usage")) + let result = await codexProvider(MemoryCodexStore(validCodex), transport: transport).fetch( + now: fixedNow, options: FetchOptions(includeAnalytics: true)) + #expect(result.outcome.snapshot?.resetCredits == ResetCredits(available: 0, applicable: 0)) + #expect(result.analytics == nil) + #expect(result.warnings.count == CodexAPI.Analytics.allCases.count + 2) + #expect(result.warnings.contains { $0.hasPrefix("Reset credits unavailable") }) + #expect(result.warnings.contains { $0.hasPrefix("Credit usage history unavailable") }) + #expect(result.warnings.contains { $0.hasPrefix("Skills analytics unavailable") }) +} + +@Test func codexProviderFallsBackToRolloutsWhenSignedOut() async throws { + let root = temporaryDirectory() + let line = + #"{"timestamp":"2026-08-29T09:00:00Z","payload":{"rate_limits":{"primary":{"# + + #""used_percent":44,"window_minutes":300,"resets_at":1788040000},"secondary":null,"plan_type":"plus"}}}"# + try (line + "\n").write(to: root.appendingPathComponent("rollout-a.jsonl"), atomically: true, encoding: .utf8) + let transport = StubTransport() + let result = await codexProvider(MemoryCodexStore(nil), transport: transport, rollouts: root).fetch( + now: fixedNow, options: FetchOptions()) + guard case .partial(let snapshot, let reason) = result.outcome else { + Issue.record("expected partial, got \(result.outcome)") + return + } + #expect(reason == "No Codex credentials. \(ProviderID.codex.loginHint)") + #expect(snapshot.source == .localLog) + #expect(snapshot.identity?.planName == "Plus") + #expect(snapshot.windows.map(\.id) == ["session"]) + #expect(snapshot.windows[0].usedPercent == 44) + #expect(snapshot.fetchedAt == ISODate.parse("2026-08-29T09:00:00Z")) + #expect(result.warnings == ["Showing the last values Codex CLI logged locally."]) + #expect(transport.requests.isEmpty) + let store = MemoryCodexStore(nil) + store.loadError = CredentialStoreError.malformed("x") + let unreadable = await codexProvider(store, transport: transport).fetch(now: fixedNow, options: FetchOptions()) + #expect(unreadable.outcome == .notAuthenticated("Cannot read Codex credentials: malformed(\"x\")")) + #expect(codexProvider(store, transport: transport).credentialState(now: fixedNow) == .missing("malformed(\"x\")")) + #expect( + codexProvider(MemoryCodexStore(nil), transport: transport).credentialState(now: fixedNow) + == .missing("no Codex sign-in found")) +} + +@Test func codexProviderNetworkFailuresUseFallbackOrReport() async throws { + let transport = StubTransport() + transport.on(path: "/wham/usage", error: URLError(.timedOut)) + let noRollouts = await codexProvider(MemoryCodexStore(validCodex), transport: transport).fetch( + now: fixedNow, options: FetchOptions()) + guard case .networkUnavailable = noRollouts.outcome else { + Issue.record("expected network failure") + return + } + let root = temporaryDirectory() + let weekly = + #"{"payload":{"rate_limits":{"primary":{"used_percent":9,"window_minutes":10080,"# + + #""resets_at":1788540000}}}}"# + try (weekly + "\n").write(to: root.appendingPathComponent("rollout-b.jsonl"), atomically: true, encoding: .utf8) + let fallback = await codexProvider(MemoryCodexStore(validCodex), transport: transport, rollouts: root).fetch( + now: fixedNow, options: FetchOptions()) + #expect(fallback.outcome.snapshot?.windows.map(\.id) == ["weekly"]) + #expect(fallback.outcome.snapshot?.fetchedAt == fixedNow) + #expect(fallback.outcome.snapshot?.identity?.email == "user@example.com") + let server = StubTransport() + server.on(path: "/wham/usage", .text("boom", status: 500)) + let failed = await codexProvider(MemoryCodexStore(validCodex), transport: server, rollouts: root).fetch( + now: fixedNow, options: FetchOptions()) + #expect(failed.outcome == .failed("HTTP 500")) + let unauthorized = StubTransport() + unauthorized.on(path: "/wham/usage", .text("", status: 401)) + let denied = await codexProvider(MemoryCodexStore(validCodex), transport: unauthorized).fetch( + now: fixedNow, options: FetchOptions()) + #expect(denied.outcome == .notAuthenticated("HTTP 401. \(ProviderID.codex.loginHint)")) +} + +@Test func codexProviderRefreshesTokens() async { + let expired = CodexAuth( + accessToken: makeJWT(.object(["exp": .number(fixedNow.timeIntervalSince1970 - 5)])), refreshToken: "r") + let transport = StubTransport() + transport.on( + path: "/oauth/token", .text(#"{"access_token":"new-access","refresh_token":"new-refresh","id_token":"new-id"}"#)) + transport.on(path: "/wham/usage", .json("codex_usage")) + let store = MemoryCodexStore(expired) + let result = await codexProvider(store, transport: transport, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(result.outcome.snapshot != nil) + #expect(store.saved.first?.accessToken == "new-access") + #expect(store.saved.first?.refreshToken == "new-refresh") + #expect(store.saved.first?.idToken == "new-id") + let body = try! JSONDecoder().decode( + [String: String].self, from: transport.requests(matching: "/oauth/token")[0].httpBody!) + #expect(body == ["client_id": CodexAPI.clientID, "grant_type": "refresh_token", "refresh_token": "r"]) + #expect( + transport.requests(matching: "/wham/usage")[0].value(forHTTPHeaderField: "Authorization") == "Bearer new-access") +} + +@Test func codexProviderRefreshFailures() async { + let expired = CodexAuth( + accessToken: makeJWT(.object(["exp": .number(fixedNow.timeIntervalSince1970 - 5)])), refreshToken: "r") + let noRefresh = await codexProvider(MemoryCodexStore(expired), transport: StubTransport()).fetch( + now: fixedNow, options: FetchOptions()) + #expect(noRefresh.outcome == .notAuthenticated("Codex token expired. \(ProviderID.codex.loginHint)")) + let rejected = StubTransport() + rejected.on(path: "/oauth/token", .text(#"{"error":"refresh_token_expired"}"#, status: 400)) + let failed = await codexProvider(MemoryCodexStore(expired), transport: rejected, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(failed.outcome == .notAuthenticated("Codex token refresh failed: HTTP 400")) + let empty = StubTransport() + empty.on(path: "/oauth/token", .text(#"{"error":"odd"}"#)) + let noToken = await codexProvider(MemoryCodexStore(expired), transport: empty, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(noToken.outcome == .notAuthenticated("Codex token refresh failed: HTTP 401")) + let blank = StubTransport() + blank.on(path: "/oauth/token", .text("{}")) + let blankToken = await codexProvider(MemoryCodexStore(expired), transport: blank, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(blankToken.outcome == .notAuthenticated("Codex token refresh failed: HTTP 401")) + let withoutRefreshToken = CodexAuth(accessToken: makeJWT(.object(["exp": .number(1)]))) + let missing = await codexProvider(MemoryCodexStore(withoutRefreshToken), transport: blank, allowRefresh: true).fetch( + now: fixedNow, options: FetchOptions()) + #expect(missing.outcome == .notAuthenticated("Codex token refresh failed: HTTP 401")) + let saving = StubTransport() + saving.on(path: "/oauth/token", .text(#"{"access_token":"n"}"#)) + saving.on(path: "/wham/usage", .json("codex_usage")) + let store = MemoryCodexStore(expired) + store.saveError = CredentialStoreError.malformed("ro") + let unsavedProvider = codexProvider(store, transport: saving, allowRefresh: true) + let unsaved = await unsavedProvider.fetch(now: fixedNow, options: FetchOptions()) + #expect(unsaved.outcome.snapshot != nil) + #expect(unsaved.recoveryIssue?.kind == .credentialPersistence) + #expect(store.saved.isEmpty) + #expect(await unsavedProvider.credentialHealth(now: fixedNow).isUsable) +} + +@Test func claudeProviderReportsLocalUsageAndAnalytics() async throws { + let root = temporaryDirectory() + let project = root.appendingPathComponent("-Users-me-repo") + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + let stamp = ISODate.string(fixedNow.addingTimeInterval(-600)) + let record = + #"{"type":"assistant","uuid":"u1","requestId":"r1","sessionId":"s1","# + + #""timestamp":"\#(stamp)","message":{"id":"m1","model":"claude-opus-5","content":[],"# + + #""usage":{"input_tokens":10,"output_tokens":1000000,"cache_creation_input_tokens":0,"# + + #""cache_read_input_tokens":0}}}"# + try (record + "\n").write(to: project.appendingPathComponent("a.jsonl"), atomically: true, encoding: .utf8) + let transport = StubTransport() + transport.on(path: "/api/oauth/usage", .json("claude_usage")) + transport.on(path: "/api/oauth/profile", .json("claude_profile")) + let provider = claudeProvider(MemoryClaudeStore(validClaude), transport: transport, transcripts: root) + let plain = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(plain.outcome.snapshot?.localUsage?.windowTokens == 1_000_010) + #expect(plain.analytics == nil) + let withAnalytics = await provider.fetch(now: fixedNow, options: FetchOptions(includeAnalytics: true)) + #expect(withAnalytics.analytics?.provider == .claude) + #expect(withAnalytics.analytics?.points.contains { $0.metric == .costUSD && $0.value == 75.00015 } == true) +} diff --git a/Tests/TokenMenuBarCoreTests/ReaderBackgroundWorkTests.swift b/Tests/TokenMenuBarCoreTests/ReaderBackgroundWorkTests.swift new file mode 100644 index 0000000..3d6837f --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ReaderBackgroundWorkTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func claudeReaderCancelsThrottledBackgroundScan() async throws { + let root = try backgroundScanRoot(prefix: "session") + let reader = ClaudeTranscriptReader(root: root, workEntryBudget: 1, backgroundWorkDelay: 0.05) + _ = await reader.refresh(now: fixedNow) + #expect((await reader.workload).treeEntriesExamined == 1) + + await reader.cancelBackgroundWork() + try await ContinuousClock().sleep(for: .milliseconds(100)) + let workload = await reader.workload + #expect(workload.treeEntriesExamined == 1) + #expect(workload.scansCompleted == 0) +} + +@Test func rolloutReaderCancelsThrottledBackgroundScan() async throws { + let root = try backgroundScanRoot(prefix: "rollout") + let reader = CodexRolloutReader(sessionsRoot: root, workEntryBudget: 1, backgroundWorkDelay: 0.05) + _ = await reader.latest(now: fixedNow) + #expect((await reader.workload).treeEntriesExamined == 1) + + await reader.cancelBackgroundWork() + try await ContinuousClock().sleep(for: .milliseconds(100)) + let workload = await reader.workload + #expect(workload.treeEntriesExamined == 1) + #expect(workload.searchesCompleted == 0) +} + +@Test func claudeReaderCompletesASmallInitialScanBeforeThrottling() async throws { + let root = temporaryDirectory() + try (backgroundClaudeLine + "\n").write( + to: root.appendingPathComponent("session.jsonl"), atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader(root: root, backgroundWorkDelay: 60) + + #expect(await reader.refresh(now: fixedNow).messageCount == 1) + #expect((await reader.workload).scansCompleted == 1) +} + +@Test func rolloutReaderCompletesASmallInitialScanBeforeThrottling() async throws { + let root = temporaryDirectory() + try (backgroundRolloutLine + "\n").write( + to: root.appendingPathComponent("rollout-current.jsonl"), atomically: true, encoding: .utf8) + let reader = CodexRolloutReader(sessionsRoot: root, backgroundWorkDelay: 60) + + #expect(await reader.latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 7) + #expect((await reader.workload).searchesCompleted == 1) +} + +@Test func claudeBackgroundTaskStopsAfterForegroundCompletion() async throws { + let reader = ClaudeTranscriptReader( + root: try backgroundScanRoot(prefix: "session"), workEntryBudget: 1, backgroundWorkDelay: 0.05) + repeat { + _ = await reader.refresh(now: fixedNow) + } while (await reader.workload).scansCompleted == 0 + + try await ContinuousClock().sleep(for: .milliseconds(100)) + #expect((await reader.workload).scansCompleted == 1) +} + +@Test func rolloutBackgroundTaskStopsAfterForegroundCompletion() async throws { + let reader = CodexRolloutReader( + sessionsRoot: try backgroundScanRoot(prefix: "rollout"), workEntryBudget: 1, backgroundWorkDelay: 0.05) + repeat { + _ = await reader.latest(now: fixedNow) + } while (await reader.workload).searchesCompleted == 0 + + try await ContinuousClock().sleep(for: .milliseconds(100)) + #expect((await reader.workload).searchesCompleted == 1) +} + +@Test func newestRolloutsPacesLargeTreeScans() async throws { + let reader = CodexRolloutReader( + sessionsRoot: try backgroundScanRoot(prefix: "rollout"), workEntryBudget: 1, backgroundWorkDelay: 0.001) + + #expect(await reader.newestRollouts().count == CodexRolloutReader.maxFiles) + #expect((await reader.workload).largestTreeSliceEntries == 1) +} + +@Test func replacingClaudeProviderReleasesItsThrottledReader() async throws { + var reader: ClaudeTranscriptReader? = ClaudeTranscriptReader( + root: try backgroundScanRoot(prefix: "session"), workEntryBudget: 1, backgroundWorkDelay: 60) + weak let retainedReader: ClaudeTranscriptReader? = reader + var provider: ClaudeProvider? = ClaudeProvider( + credentials: MemoryClaudeStore(validClaude), localAccountURL: nil, transcripts: reader, + client: APIClient(transport: StubTransport(), log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { false }) + _ = await reader?.refresh(now: fixedNow) + #expect(provider != nil) + reader = nil + + provider = ClaudeProvider( + credentials: MemoryClaudeStore(validClaude), localAccountURL: nil, transcripts: nil, + client: APIClient(transport: StubTransport(), log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { false }) + #expect(retainedReader == nil) + _ = provider +} + +@Test func replacingCodexProviderReleasesItsThrottledReader() async throws { + var reader: CodexRolloutReader? = CodexRolloutReader( + sessionsRoot: try backgroundScanRoot(prefix: "rollout"), workEntryBudget: 1, backgroundWorkDelay: 60) + weak let retainedReader: CodexRolloutReader? = reader + var provider: CodexProvider? = CodexProvider( + auth: MemoryCodexStore(validCodex), rollouts: reader, + client: APIClient(transport: StubTransport(), log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { false }) + _ = await reader?.latest(now: fixedNow) + #expect(provider != nil) + reader = nil + + provider = CodexProvider( + auth: MemoryCodexStore(validCodex), rollouts: nil, + client: APIClient(transport: StubTransport(), log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { false }) + #expect(retainedReader == nil) + _ = provider +} + +private func backgroundScanRoot(prefix: String) throws -> URL { + let root = temporaryDirectory() + for index in 0..<20 { + try Data().write(to: root.appendingPathComponent("\(prefix)-\(index).jsonl")) + } + return root +} + +private let backgroundClaudeLine = + #"{"type":"assistant","uuid":"uuid","requestId":"request","sessionId":"session","# + + #""timestamp":"2026-08-29T10:00:00Z","message":{"id":"message","model":"claude-haiku-4-5","# + + #""content":[],"usage":{"input_tokens":1,"output_tokens":0}}}"# + +private let backgroundRolloutLine = + #"{"timestamp":"2026-08-29T10:00:00Z","rate_limits":{"primary":{"used_percent":7,"window_minutes":300}}}"# diff --git a/Tests/TokenMenuBarCoreTests/RefreshCoordinatorTests.swift b/Tests/TokenMenuBarCoreTests/RefreshCoordinatorTests.swift new file mode 100644 index 0000000..5d6ab1f --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/RefreshCoordinatorTests.swift @@ -0,0 +1,828 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test @MainActor func coordinatorAppliesSuccessAndRecordsHistory() async throws { + let claude = ScriptedProvider( + id: .claude, results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 20)), warnings: ["w"])]) + let codex = ScriptedProvider( + id: .codex, + results: [ + ProviderFetchResult( + outcome: .success(snapshot(.codex, 50)), + analytics: ProviderAnalytics( + provider: .codex, points: [AnalyticsPoint(day: "2026-08-29", metric: .turns, series: "m", value: 1)], + fetchedAt: fixedNow)) + ]) + let (coordinator, state, _, history, sink) = try makeCoordinator([claude, codex]) + await coordinator.refresh(RefreshRequest()) + #expect(state.state(for: .claude).availability == .current) + #expect(state.state(for: .claude).warnings == ["w"]) + #expect(state.state(for: .claude).lastSuccess == fixedNow) + #expect(state.state(for: .claude).credentialState == .valid(expiresAt: nil)) + #expect(state.state(for: .codex).analytics?.points.count == 1) + #expect(state.lastRefresh == fixedNow) + #expect(state.sampleRevision == 1) + #expect(state.historyRevision == 1) + #expect(!state.isRefreshing) + #expect(state.statusModel.cells.map(\.id) == ["claude:session", "codex:session"]) + #expect(try await history.samples(from: .distantPast, to: .distantFuture).count == 2) + #expect(try await history.analytics(provider: .codex, from: "2026-01-01", to: "2026-12-31").count == 1) + #expect(sink.events.isEmpty) + #expect(codex.calls.first?.includeAnalytics == true) + #expect(state.orderedProviders == [.claude, .codex]) + #expect(!state.state(for: .claude).isStale) +} + +private func snapshot(_ provider: ProviderID, _ percent: Double, resets: TimeInterval = 3600) -> ProviderSnapshot { + ProviderSnapshot( + provider: provider, + windows: [ + QuotaWindow( + id: "session", label: "Session", group: .session, usedPercent: percent, + resetsAt: fixedNow.addingTimeInterval(resets), duration: 18000) + ], + fetchedAt: fixedNow + ) +} + +@MainActor +private func makeCoordinator( + _ providers: [any UsageProvider], settings: Settings? = nil, clock: Clock = testClock, + history: UsageHistoryStore? = nil, log: LogBuffer? = nil, activateProviders: Bool = true +) throws -> (RefreshCoordinator, AppState, Settings, UsageHistoryStore, NotificationSink) { + let settings = settings ?? makeSettings() + if activateProviders { + for provider in providers where settings.providerOverride(for: provider.id) == nil { + settings.setProvider(provider.id, enabled: true) + } + } + for provider in ProviderID.allCases { settings.setRefreshInterval(60, for: provider) } + let state = AppState() + if activateProviders { + for provider in providers { + state.update(provider.id) { $0.credentialState = .valid(expiresAt: nil) } + } + } + let history = try history ?? UsageHistoryStore(url: nil) + let sink = NotificationSink() + let coordinator = RefreshCoordinator( + registry: ProviderRegistry(providers), settings: settings, state: state, history: history, log: log ?? makeLog(), + clock: clock + ) { sink.events += $0 } + return (coordinator, state, settings, history, sink) +} + +@MainActor +final class NotificationSink { + var events: [NotificationEvent] = [] +} + +@Test func refreshRequestMergesReasonAndPoliciesByPriority() { + let merged = RefreshRequest(reason: .export, usage: .skip, analytics: .force) + .merged(with: RefreshRequest(reason: .scheduled, usage: .ifDue, analytics: .skip)) + #expect(merged == RefreshRequest(reason: .export, usage: .ifDue, analytics: .force)) +} + +@Test @MainActor func coordinatorCoalescesHistoryRevisionAndSkipsItWhenNoSamplesChange() async throws { + let claude = ScriptedProvider( + id: .claude, results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 20)))]) + let codex = ScriptedProvider( + id: .codex, results: [ProviderFetchResult(outcome: .success(snapshot(.codex, 50)))]) + let (coordinator, state, _, _, _) = try makeCoordinator([claude, codex]) + + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(state.sampleRevision == 1) + #expect(state.historyRevision == 1) + + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(state.sampleRevision == 1) + #expect(claude.callCount == 2) + #expect(codex.callCount == 2) +} + +@Test func refreshRequestMergesProviderScopes() { + let scoped = RefreshRequest(providers: [.claude]) + .merged(with: RefreshRequest(providers: [.codex])) + #expect(scoped.providers == [.claude, .codex]) + #expect(RefreshRequest().merged(with: scoped).providers == nil) +} + +@Test @MainActor func coordinatorLogsDisabledProviderSkips() async throws { + let log = makeLog() + log.debugEnabled = true + let settings = makeSettings() + settings.setProvider(.gemini, enabled: false) + let (coordinator, _, _, _, _) = try makeCoordinator([scriptedProvider(.gemini)], settings: settings, log: log) + + await coordinator.refresh(RefreshRequest()) + + #expect(log.text.contains("provider=gemini")) + #expect(log.text.contains("skipReason=disabled")) +} + +@Test @MainActor func coordinatorDoesNotPollAnUndiscoveredProviderAutomatically() async throws { + let provider = scriptedProvider(.gemini) + let (coordinator, state, _, _, _) = try makeCoordinator([provider], activateProviders: false) + + await coordinator.refresh(RefreshRequest()) + + #expect(provider.calls.isEmpty) + #expect(state.state(for: .gemini).availability == .disabled) + #expect(coordinator.nextRefreshDate() == nil) +} + +@Test @MainActor func coordinatorTargetedRefreshCanDiscoverAndActivateAProvider() async throws { + let provider = scriptedProvider( + .gemini, ProviderFetchResult(outcome: .success(snapshot(.gemini, 25)))) + let (coordinator, state, settings, _, _) = try makeCoordinator([provider], activateProviders: false) + + await coordinator.refresh( + RefreshRequest(reason: .userInitiated, usage: .force, providers: [.gemini])) + + #expect(provider.calls.count == 1) + #expect(state.state(for: .gemini).availability == .current) + #expect(settings.isProviderActive(.gemini, state: state.state(for: .gemini))) + #expect(coordinator.nextRefreshDate() != nil) +} + +@Test @MainActor func coordinatorLogsAnalyticsNotDueSkips() async throws { + let log = makeLog() + log.debugEnabled = true + let provider = scriptedProvider(.codex, ProviderFetchResult(outcome: .success(snapshot(.codex, 20)))) + let (coordinator, _, _, _, _) = try makeCoordinator([provider], log: log) + await coordinator.refresh(RefreshRequest(usage: .skip, analytics: .force)) + + await coordinator.refresh(RefreshRequest(usage: .skip, analytics: .ifDue)) + + #expect(log.text.contains("skipReason=analytics-not-due")) +} + +@Test @MainActor func coordinatorLogsNoWorkSkips() async throws { + let log = makeLog() + log.debugEnabled = true + let (coordinator, _, _, _, _) = try makeCoordinator([scriptedProvider(.gemini)], log: log) + + await coordinator.refresh(RefreshRequest(usage: .skip, analytics: .skip)) + + #expect(log.text.contains("skipReason=no-work")) +} + +@Test @MainActor func coordinatorLogsRetryBackoffSkips() async throws { + let log = makeLog() + log.debugEnabled = true + let provider = scriptedProvider(.codex, ProviderFetchResult(outcome: .rateLimited("busy", retryAfter: 60))) + let (coordinator, _, _, _, _) = try makeCoordinator([provider], log: log) + await coordinator.refresh(RefreshRequest()) + + await coordinator.refresh(RefreshRequest()) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + + #expect(log.text.contains("skipReason=retry-backoff")) + #expect(provider.callCount == 1) +} + +@Test @MainActor func coordinatorRefreshesOnlyRequestedProvider() async throws { + let claude = scriptedProvider(.claude, ProviderFetchResult(outcome: .success(snapshot(.claude, 20)))) + let codex = scriptedProvider(.codex, ProviderFetchResult(outcome: .success(snapshot(.codex, 30)))) + let (coordinator, _, _, _, _) = try makeCoordinator([claude, codex]) + + await coordinator.refresh( + RefreshRequest(reason: .userInitiated, usage: .force, analytics: .ifDue, providers: [.codex])) + + #expect(claude.callCount == 0) + #expect(codex.callCount == 1) +} + +actor Ticks { + var count = 0 + func increment() { count += 1 } +} + +actor SleepRecorder { + private(set) var durations: [TimeInterval] = [] + let gate = TestGate() + + func sleep(_ duration: TimeInterval) async throws { + durations.append(duration) + try await gate.wait() + } +} + +@Test @MainActor func coordinatorHandlesFailuresStaleAndBackoff() async throws { + let claude = ScriptedProvider( + id: .claude, + results: [ + ProviderFetchResult(outcome: .success(snapshot(.claude, 20))), + ProviderFetchResult(outcome: .networkUnavailable("down")), + ProviderFetchResult(outcome: .success(snapshot(.claude, 25))), + ]) + let codex = ScriptedProvider( + id: .codex, + results: [ + ProviderFetchResult(outcome: .rateLimited("HTTP 429: busy", retryAfter: nil)), + ProviderFetchResult(outcome: .notAuthenticated("expired")), + ProviderFetchResult(outcome: .partial(snapshot(.codex, 5), "stale reason")), + ]) + let box = DateBox(fixedNow) + let (coordinator, state, _, _, sink) = try makeCoordinator([claude, codex], clock: box.clock) + await coordinator.refresh(RefreshRequest()) + #expect(state.state(for: .codex).availability == .rateLimited) + #expect(state.state(for: .codex).lastError?.hasPrefix("HTTP 429: busy. Next attempt") == true) + #expect(coordinator.nextAttempt(for: .codex) == fixedNow.addingTimeInterval(300)) + box.date = fixedNow.addingTimeInterval(120) + await coordinator.refresh(RefreshRequest()) + #expect(claude.calls.count == 2) + #expect(codex.calls.count == 1) + #expect(state.state(for: .claude).availability == .networkUnavailable) + #expect(state.state(for: .claude).snapshot?.windows.first?.usedPercent == 20) + #expect(state.state(for: .claude).isStale) + #expect(state.statusModel.iconTone == .offline) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(codex.calls.count == 1) + box.date = fixedNow.addingTimeInterval(300) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(codex.calls.count == 2) + #expect(state.state(for: .codex).availability == .authenticationRequired) + #expect(sink.events.map(\.kind) == [.authentication]) + box.date = fixedNow.addingTimeInterval(400) + await coordinator.refresh(RefreshRequest()) + #expect(state.state(for: .codex).availability == .stale) + #expect(state.state(for: .codex).snapshot?.windows.first?.usedPercent == 5) + #expect(state.state(for: .codex).lastError == "stale reason") + #expect(state.state(for: .claude).availability == .current) +} + +@Test @MainActor func coordinatorKeepsNewerSnapshotOverPartial() async throws { + let newer = ProviderSnapshot(provider: .codex, windows: [], fetchedAt: fixedNow.addingTimeInterval(100)) + let older = ProviderSnapshot( + provider: .codex, windows: [], source: .localLog, fetchedAt: fixedNow.addingTimeInterval(-100)) + let codex = ScriptedProvider( + id: .codex, + results: [ProviderFetchResult(outcome: .success(newer)), ProviderFetchResult(outcome: .partial(older, "old"))]) + let (coordinator, state, _, _, _) = try makeCoordinator([codex]) + await coordinator.refresh(RefreshRequest()) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(state.state(for: .codex).snapshot == newer) + #expect(state.state(for: .codex).availability == .stale) +} + +@Test @MainActor func coordinatorSkipsDisabledProvidersAndCoalesces() async throws { + let gate = TestGate() + let claude = ScriptedProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))], + gate: gate) + let codex = ScriptedProvider(id: .codex, results: [ProviderFetchResult(outcome: .success(snapshot(.codex, 10)))]) + let settings = makeSettings() + settings.setProvider(.claude, enabled: true) + settings.setProvider(.codex, enabled: false) + let (coordinator, state, _, _, _) = try makeCoordinator([claude, codex], settings: settings) + let first = Task { await coordinator.refresh(RefreshRequest()) } + for _ in 0..<1000 { + if claude.callCount > 0 { break } + await Task.yield() + } + #expect(claude.callCount == 1) + async let second: Void = coordinator.refresh(RefreshRequest(analytics: .force)) + async let third: Void = coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + gate.open() + _ = await (second, third) + await first.value + #expect(state.state(for: .codex).availability == .disabled) + #expect(codex.calls.isEmpty) + #expect(claude.calls.count == 2) + #expect( + RefreshRequest(reason: .popoverOpened).merged( + with: RefreshRequest(reason: .userInitiated, usage: .force, analytics: .force)) + == RefreshRequest(reason: .userInitiated, usage: .force, analytics: .force)) +} + +@Test @MainActor func coordinatorJoinsRepeatedManualRefreshes() async throws { + let gate = TestGate() + let claude = ScriptedProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))], + gate: gate) + let (coordinator, _, _, _, _) = try makeCoordinator([claude]) + let first = Task { await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) } + for _ in 0..<1000 { + if claude.callCount > 0 { break } + await Task.yield() + } + #expect(claude.callCount == 1) + async let second: Void = coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + async let third: Void = coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + gate.open() + _ = await (second, third) + await first.value + #expect(claude.calls.count == 1) +} + +@Test @MainActor func coordinatorRunsAnUnscopedRefreshAfterAScopedRefresh() async throws { + let gate = TestGate() + let claude = ScriptedProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))], + gate: gate) + let codex = scriptedProvider(.codex, ProviderFetchResult(outcome: .success(snapshot(.codex, 20)))) + let (coordinator, _, _, _, _) = try makeCoordinator([claude, codex]) + let scoped = Task { + await coordinator.refresh( + RefreshRequest(reason: .userInitiated, usage: .force, providers: [.claude])) + } + while claude.callCount == 0 { await Task.yield() } + + let unscoped = Task { await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) } + await Task.yield() + gate.open() + await unscoped.value + await scoped.value + + #expect(claude.callCount == 2) + #expect(codex.callCount == 1) +} + +@Test @MainActor func coordinatorStopCancelsRefreshAndClearsTransientState() async throws { + let gate = TestGate() + let claude = ScriptedProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))], + gate: gate) + let (coordinator, state, _, _, _) = try makeCoordinator([claude]) + let refresh = Task { await coordinator.refresh(RefreshRequest()) } + for _ in 0..<1000 { + if state.isRefreshing { break } + await Task.yield() + } + #expect(state.isRefreshing) + coordinator.stop() + await refresh.value + #expect(!state.isRefreshing) + #expect(!state.state(for: .claude).isRefreshing) + #expect(state.state(for: .claude).lastAttempt == nil) +} + +@MainActor +private func makeSettings() -> Settings { + let defaults = UserDefaults(suiteName: "tests-\(UUID().uuidString)")! + return Settings(defaults: defaults) +} + +@Test @MainActor func coordinatorLoopRunsUntilStopped() async throws { + let claude = ScriptedProvider(id: .claude, results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))]) + let ticks = Ticks() + let box = DateBox(fixedNow) + let clock = Clock( + now: { box.date }, + sleep: { _ in + box.date = box.date.addingTimeInterval(120) + await ticks.increment() + if await ticks.count >= 3 { throw CancellationError() } + }) + let (coordinator, state, _, _, _) = try makeCoordinator([claude], clock: clock) + #expect(!coordinator.isRunning) + coordinator.start() + coordinator.start() + #expect(coordinator.isRunning) + while await ticks.count < 3 { await Task.yield() } + while claude.calls.count < 3 { await Task.yield() } + #expect(claude.calls.count == 3) + #expect(state.state(for: .claude).availability == .current) + coordinator.stop() + #expect(!coordinator.isRunning) + coordinator.stop() +} + +@Test @MainActor func coordinatorUsesProviderAndVisibilityDeadlines() async throws { + let codex = ScriptedProvider(id: .codex, results: [ProviderFetchResult(outcome: .success(snapshot(.codex, 10)))]) + codex.pollingPolicy = PollingPolicy(minimumInterval: 60, activeInterval: 90, defaultInterval: 300) + let box = DateBox(fixedNow) + let (coordinator, state, settings, _, _) = try makeCoordinator([codex], clock: box.clock) + settings.setRefreshInterval(300, for: .codex) + #expect(coordinator.nextRefreshDate() == fixedNow) + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(300)) + state.popoverVisible = true + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(90)) + coordinator.stop() + #expect(state.nextRefreshAt == nil) +} + +@Test @MainActor func coordinatorReschedulesWhenVisibilityChanges() async throws { + let codex = ScriptedProvider(id: .codex, results: [ProviderFetchResult(outcome: .success(snapshot(.codex, 10)))]) + codex.pollingPolicy = PollingPolicy(minimumInterval: 60, activeInterval: 90, defaultInterval: 300) + let recorder = SleepRecorder() + let clock = Clock(now: { fixedNow }, sleep: { try await recorder.sleep($0) }) + let (coordinator, state, settings, _, _) = try makeCoordinator([codex], clock: clock) + settings.setRefreshInterval(300, for: .codex) + coordinator.start() + for _ in 0..<1000 { + if await recorder.durations.count >= 1 { break } + await Task.yield() + } + #expect(await recorder.durations.count == 1) + state.popoverVisible = true + for _ in 0..<1000 { + if await recorder.durations.count >= 2 { break } + await Task.yield() + } + #expect(await recorder.durations == [300, 90]) + coordinator.stop() +} + +@Test @MainActor func coordinatorDoesNotScheduleAnalyticsForUnsupportedProviders() async throws { + let gemini = ScriptedProvider( + id: .gemini, results: [ProviderFetchResult(outcome: .success(snapshot(.gemini, 10)))]) + gemini.pollingPolicy = PollingPolicy(minimumInterval: 60, activeInterval: 90, defaultInterval: 3600) + let box = DateBox(fixedNow) + let (coordinator, _, settings, _, _) = try makeCoordinator([gemini], clock: box.clock) + settings.setRefreshInterval(3600, for: .gemini) + settings.analyticsRefreshMinutes = 5 + await coordinator.refresh(RefreshRequest()) + #expect( + gemini.calls == [FetchOptions(includeAnalytics: false, analyticsDays: settings.historyRetentionDays)]) + let usageInterval = gemini.pollingPolicy.interval( + active: false, requested: TimeInterval(settings.refreshInterval(for: .gemini))) + #expect(usageInterval > TimeInterval(settings.analyticsRefreshMinutes * 60)) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(usageInterval)) + settings.setProvider(.gemini, enabled: false) + #expect(coordinator.nextRefreshDate() == nil) +} + +@Test @MainActor func coordinatorRetriesAtConfiguredClosedPopoverInterval() async throws { + let claude = ScriptedProvider( + id: .claude, + results: [ + ProviderFetchResult(outcome: .networkUnavailable("down")), + ProviderFetchResult(outcome: .success(snapshot(.claude, 10))), + ]) + claude.pollingPolicy = PollingPolicy(minimumInterval: 120, activeInterval: 120, defaultInterval: 300) + let box = DateBox(fixedNow) + let (coordinator, state, settings, _, _) = try makeCoordinator([claude], clock: box.clock) + settings.setRefreshInterval(300, for: .claude) + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(300)) + box.date = fixedNow.addingTimeInterval(300) + #expect(coordinator.nextRefreshDate() == box.date) + await coordinator.refresh(RefreshRequest()) + #expect(claude.calls.count == 2) + #expect(state.state(for: .claude).availability == .current) +} + +@Test @MainActor func providerRecoveryRefreshBypassesANonRateLimitDeadline() async throws { + let claude = ScriptedProvider( + id: .claude, + results: [ + ProviderFetchResult(outcome: .networkUnavailable("down")), + ProviderFetchResult(outcome: .success(snapshot(.claude, 10))), + ]) + let (coordinator, state, _, _, _) = try makeCoordinator([claude]) + await coordinator.refresh(RefreshRequest()) + await coordinator.refresh( + RefreshRequest(reason: .userInitiated, usage: .force, providers: [.claude])) + #expect(claude.calls.count == 2) + #expect(state.state(for: .claude).availability == .current) +} + +@Test @MainActor func coordinatorAnalyticsCadenceAndStoredFallback() async throws { + let analytics = ProviderAnalytics( + provider: .codex, points: [AnalyticsPoint(day: "2026-08-29", metric: .turns, series: "m", value: 4)], + fetchedAt: fixedNow) + let codex = ScriptedProvider( + id: .codex, + results: [ + ProviderFetchResult(outcome: .success(snapshot(.codex, 1)), analytics: analytics), + ProviderFetchResult(outcome: .success(snapshot(.codex, 2))), + ]) + let box = DateBox(fixedNow) + let history = try UsageHistoryStore(url: nil) + let (coordinator, state, _, _, _) = try makeCoordinator([codex], clock: box.clock, history: history) + await coordinator.refresh(RefreshRequest()) + #expect(codex.calls[0].includeAnalytics) + #expect(state.state(for: .codex).lastAnalyticsAttempt == fixedNow) + box.date = fixedNow.addingTimeInterval(60) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(!codex.calls[1].includeAnalytics) + #expect(state.state(for: .codex).lastAnalyticsAttempt == fixedNow) + #expect(state.state(for: .codex).analytics == analytics) + box.date = fixedNow.addingTimeInterval(61) + await coordinator.refresh(RefreshRequest(usage: .skip, analytics: .force)) + #expect(codex.calls[2].includeAnalytics) + #expect(state.state(for: .codex).lastAnalyticsAttempt == box.date) + let fresh = ScriptedProvider(id: .codex, results: [ProviderFetchResult(outcome: .success(snapshot(.codex, 3)))]) + let (second, secondState, _, _, _) = try makeCoordinator([fresh], history: history) + await second.refresh(RefreshRequest()) + #expect(secondState.state(for: .codex).analytics?.points.map(\.value) == [4]) + let empty = ScriptedProvider(id: .claude, results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 3)))]) + let (third, thirdState, _, _, _) = try makeCoordinator([empty]) + await third.refresh(RefreshRequest()) + #expect(thirdState.state(for: .claude).analytics == nil) +} + +@Test @MainActor func coordinatorMergesOverlappingIncrementalAnalytics() async throws { + let first = ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: "2026-08-28", metric: .turns, series: "m", value: 1), + AnalyticsPoint(day: "2026-08-28", metric: .turns, series: "m", value: 2), + ], + creditEvents: [CreditEvent(id: "event", date: fixedNow, service: "api", creditsUsed: 1)], + fetchedAt: fixedNow) + let second = ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: "2026-08-28", metric: .turns, series: "m", value: 3), + AnalyticsPoint(day: "2026-08-29", metric: .turns, series: "m", value: 4), + ], + creditEvents: [CreditEvent(id: "event", date: fixedNow, service: "api", creditsUsed: 2)], + fetchedAt: fixedNow.addingTimeInterval(60)) + let codex = ScriptedProvider( + id: .codex, + results: [ + ProviderFetchResult(outcome: .success(snapshot(.codex, 1)), analytics: first), + ProviderFetchResult(outcome: .success(snapshot(.codex, 2)), analytics: second), + ]) + let (coordinator, state, _, _, _) = try makeCoordinator([codex]) + await coordinator.refresh(RefreshRequest()) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force, analytics: .force)) + #expect(state.state(for: .codex).analytics?.points.map(\.value) == [3, 4]) + #expect(state.state(for: .codex).analytics?.creditEvents.map(\.creditsUsed) == [2]) +} + +@Test @MainActor func coordinatorRebuildsStatusFromCustomSelection() async throws { + let claude = ScriptedProvider(id: .claude, results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))]) + let (coordinator, state, settings, _, _) = try makeCoordinator([claude]) + await coordinator.refresh(RefreshRequest()) + settings.hasCustomSelection = true + settings.selectedWindows = [] + coordinator.rebuildStatus() + #expect(state.statusModel.cells.isEmpty) + settings.selectedWindows = [WindowKey(provider: .claude, windowID: "session")] + coordinator.rebuildStatus(now: fixedNow) + #expect(state.statusModel.cells.count == 1) + state.remove(.claude) + #expect(state.providers.isEmpty) + state.popoverVisible = true + #expect(state.popoverVisible) +} + +@Test @MainActor func coordinatorSurvivesHistoryErrors() async throws { + let claude = ScriptedProvider( + id: .claude, + results: [ + ProviderFetchResult( + outcome: .success(snapshot(.claude, 10)), + analytics: ProviderAnalytics( + provider: .claude, points: [AnalyticsPoint(day: "d", metric: .turns, series: "s", value: 1)], + fetchedAt: fixedNow)) + ]) + let history = try UsageHistoryStore(url: nil) + try await history.breakDatabase() + let (coordinator, state, _, _, _) = try makeCoordinator([claude], history: history) + await coordinator.refresh(RefreshRequest()) + #expect(state.state(for: .claude).availability == .current) +} + +extension UsageHistoryStore { + func breakDatabase() throws { + try database.execute("DROP TABLE samples") + try database.execute("DROP TABLE analytics") + } +} + +@Test @MainActor func coordinatorDoublesRateLimitBackoffAndRespectsPolicies() async throws { + let codex = ScriptedProvider( + id: .codex, + results: [ + ProviderFetchResult(outcome: .rateLimited("HTTP 429", retryAfter: 30)), + ProviderFetchResult(outcome: .rateLimited("HTTP 429", retryAfter: nil)), + ProviderFetchResult(outcome: .rateLimited("HTTP 429", retryAfter: 5000)), + ProviderFetchResult(outcome: .success(snapshot(.codex, 5))), + ProviderFetchResult(outcome: .failed("HTTP 500")), + ]) + codex.pollingPolicy = PollingPolicy(minimumInterval: 60, activeInterval: 90, defaultInterval: 300) + let box = DateBox(fixedNow) + let (coordinator, state, settings, _, _) = try makeCoordinator([codex], clock: box.clock) + settings.setRefreshInterval(300, for: .codex) + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextAttempt(for: .codex) == fixedNow.addingTimeInterval(300)) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(codex.calls.count == 1) + box.date = fixedNow.addingTimeInterval(300) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(coordinator.nextAttempt(for: .codex) == box.date.addingTimeInterval(600)) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(codex.calls.count == 2) + box.date = fixedNow.addingTimeInterval(900) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(coordinator.nextAttempt(for: .codex) == box.date.addingTimeInterval(1800)) + #expect(codex.calls.count == 3) + box.date = fixedNow.addingTimeInterval(2699) + await coordinator.refresh(RefreshRequest()) + #expect(codex.calls.count == 3) + box.date = fixedNow.addingTimeInterval(2700) + await coordinator.refresh(RefreshRequest()) + #expect(codex.calls.count == 4) + #expect(state.state(for: .codex).availability == .current) + #expect(coordinator.nextAttempt(for: .codex) == nil) + box.date = fixedNow.addingTimeInterval(2800) + await coordinator.refresh(RefreshRequest()) + #expect(codex.calls.count == 4) + state.popoverVisible = true + await coordinator.refresh(RefreshRequest()) + #expect(codex.calls.count == 5) + #expect(state.state(for: .codex).availability == .unavailable) + #expect(coordinator.nextAttempt(for: .codex) == box.date.addingTimeInterval(90)) +} + +@Test @MainActor func coordinatorDiscardsResultsFromAReplacedRegistry() async throws { + let gate = TestGate() + let old = ScriptedProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))], + gate: gate) + let replacement = ScriptedProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 80)))]) + let (coordinator, state, _, _, _) = try makeCoordinator([old]) + let refresh = Task { await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) } + while old.callCount == 0 { await Task.yield() } + coordinator.replaceRegistry(ProviderRegistry([replacement])) + await refresh.value + #expect(state.state(for: .claude).snapshot == nil) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(state.state(for: .claude).snapshot?.windows.first?.usedPercent == 80) +} + +@Test @MainActor func coordinatorDiscardsAResultWhenTheProviderWasDisabled() async throws { + let gate = TestGate() + let provider = ScriptedProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 70)))], + gate: gate) + let (coordinator, state, settings, _, _) = try makeCoordinator([provider]) + let refresh = Task { await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) } + while provider.callCount == 0 { await Task.yield() } + settings.setProvider(.claude, enabled: false) + gate.open() + await refresh.value + #expect(state.state(for: .claude).availability == .disabled) + #expect(state.state(for: .claude).snapshot == nil) +} + +@Test @MainActor func coordinatorCarriesTypedCredentialHealthFromTheFetch() async throws { + let provider = ScriptedProvider( + id: .copilot, + results: [ProviderFetchResult(outcome: .success(snapshot(.copilot, 30)))]) + let source = ProviderID.copilot.credentialSource("copilot.environment") + provider.health = .valid(source: source, expiresAt: nil) + let (coordinator, state, _, _, _) = try makeCoordinator([provider]) + await coordinator.refresh(RefreshRequest()) + #expect(state.state(for: .copilot).credentialHealth == .valid(source: source, expiresAt: nil)) + #expect(provider.credentialStateCallCount == 1) + #expect(provider.credentialHealthCallCount == 1) +} + +@Test @MainActor func coordinatorReusesCredentialStatusReturnedByTheFetch() async throws { + let source = ProviderID.copilot.credentialSource("copilot.environment") + let credentialStatus = ProviderCredentialStatus( + state: .valid(expiresAt: nil), + health: .valid(source: source, expiresAt: nil)) + let provider = ScriptedProvider( + id: .copilot, + results: [ + ProviderFetchResult( + outcome: .success(snapshot(.copilot, 30)), + credentialStatus: credentialStatus) + ]) + provider.credentials = .missing("the coordinator must not read this") + provider.health = .unreadable(source: nil, detail: "the coordinator must not read this") + let (coordinator, state, _, _, _) = try makeCoordinator([provider]) + + await coordinator.refresh(RefreshRequest()) + + #expect(state.state(for: .copilot).credentialState == credentialStatus.state) + #expect(state.state(for: .copilot).credentialHealth == credentialStatus.health) + #expect(provider.credentialStateCallCount == 0) + #expect(provider.credentialHealthCallCount == 0) +} + +@Test @MainActor func coordinatorBatchesProviderResultsBeforeTheCoalescedStatus() async throws { + let gate = TestGate() + let claude = ScriptedProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))]) + let codex = ScriptedProvider( + id: .codex, + results: [ProviderFetchResult(outcome: .success(snapshot(.codex, 20)))], + gate: gate) + let (coordinator, state, _, _, _) = try makeCoordinator([claude, codex]) + let refresh = Task { await coordinator.refresh(RefreshRequest()) } + + while claude.callCount == 0 { await Task.yield() } + + #expect(state.state(for: .claude).snapshot == nil) + #expect(state.state(for: .claude).isRefreshing) + #expect(state.state(for: .codex).isRefreshing) + #expect(state.isRefreshing) + #expect(state.statusModel.cells.isEmpty) + + gate.open() + await refresh.value + + #expect(!state.isRefreshing) + #expect(state.statusModel.cells.map(\.id) == ["claude:session", "codex:session"]) +} + +@Test @MainActor func coordinatorRetainsTheReplacedRegistryUntilItsRefreshDrains() async throws { + let gate = TestGate() + let provider = ScriptedProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))], + gate: gate) + let leaseProbe = LeaseProbe() + var original: ProviderRegistry? = ProviderRegistry( + [provider], + resourceLeases: [ + SecurityScopedResourceLease(url: URL(fileURLWithPath: "/leased")) { _ in leaseProbe.stopped() } + ]) + let state = AppState() + state.update(.claude) { $0.credentialState = .valid(expiresAt: nil) } + let settings = makeSettings() + settings.setProvider(.claude, enabled: true) + let coordinator = RefreshCoordinator( + registry: original!, settings: settings, state: state, history: try UsageHistoryStore(url: nil), log: makeLog(), + clock: testClock + ) { _ in } + original = nil + let refresh = Task { await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) } + while provider.callCount == 0 { await Task.yield() } + coordinator.replaceRegistry(ProviderRegistry([])) + #expect(leaseProbe.stopCount == 0) + gate.open() + await refresh.value + #expect(leaseProbe.stopCount == 1) +} + +@Test @MainActor func coordinatorOrdersDefaultSelectionByTheStableDraft() async throws { + let claude = ScriptedProvider( + id: .claude, results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))]) + let codex = ScriptedProvider( + id: .codex, results: [ProviderFetchResult(outcome: .success(snapshot(.codex, 20)))]) + let settings = makeSettings() + settings.providerOrder = [.codex, .claude] + let (coordinator, state, _, _, _) = try makeCoordinator([claude, codex], settings: settings) + await coordinator.refresh(RefreshRequest()) + #expect(state.statusModel.cells.map(\.id) == ["codex:session", "claude:session"]) +} + +private final class LeaseProbe: @unchecked Sendable { + private let lock = NSLock() + private var stops = 0 + + var stopCount: Int { lock.withLock { stops } } + func stopped() { lock.withLock { stops += 1 } } +} + +@Test @MainActor func coordinatorRestoresAndStoresCachedSnapshots() async throws { + let cache = SnapshotCache(url: temporaryDirectory().appendingPathComponent("snapshots.json")) + #expect(cache.load().isEmpty) + try cache.store([.codex: DemoData.snapshot(.codex, now: fixedNow)]) + let restored = cache.load() + #expect(restored[.codex]?.source == .cache) + #expect(restored[.codex]?.fetchedAt == fixedNow) + let provider = DemoProvider(id: .codex) + let state = AppState() + let settings = makeSettings() + let history = try UsageHistoryStore(url: nil) + let persistence = SnapshotPersistence(cache: cache) + let coordinator = RefreshCoordinator( + registry: ProviderRegistry([provider]), settings: settings, state: state, history: history, log: makeLog(), + clock: testClock, cache: cache, persistence: persistence + ) { _ in } + await coordinator.restoreCachedSnapshots() + #expect(state.state(for: .codex).snapshot?.source == .cache) + #expect(state.state(for: .codex).availability == .stale) + #expect(!state.statusModel.cells.isEmpty) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + await coordinator.flushPersistence() + #expect(state.state(for: .codex).snapshot?.source == .network) + #expect(cache.load()[.codex]?.windows.isEmpty == false) + let unwritable = SnapshotCache(url: URL(fileURLWithPath: "/dev/null/snapshots.json")) + #expect(unwritable.load().isEmpty) + let log = makeLog() + let failingPersistence = SnapshotPersistence( + cache: unwritable, + failureHandler: { failure in log.logError(failure.message) }) + let failing = RefreshCoordinator( + registry: ProviderRegistry([provider]), settings: settings, state: AppState(), history: history, log: log, + clock: testClock, cache: unwritable, persistence: failingPersistence + ) { _ in } + failing.storeCache() + await failing.flushPersistence() + #expect(log.text.contains("snapshot cache write failed")) + #expect(SnapshotCache(url: nil).load().isEmpty) + try SnapshotCache(url: nil).store([:]) +} diff --git a/Tests/TokenMenuBarCoreTests/SQLiteTests.swift b/Tests/TokenMenuBarCoreTests/SQLiteTests.swift new file mode 100644 index 0000000..d24b612 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/SQLiteTests.swift @@ -0,0 +1,79 @@ +import Testing + +@testable import TokenMenuBarCore + +@Test func sqliteTransactionCommitsAllRows() throws { + let database = try SQLiteDatabase(path: ":memory:") + try database.execute("CREATE TABLE values_table (value INTEGER NOT NULL)") + try database.withTransaction { + try database.executeMany("INSERT INTO values_table (value) VALUES (?)", [[.integer(1)], [.integer(2)]]) + } + #expect(try database.query("SELECT value FROM values_table ORDER BY value") { $0.int(0) } == [1, 2]) +} + +@Test func sqliteTransactionRollsBackEveryRowAfterFailure() throws { + let database = try SQLiteDatabase(path: ":memory:") + try database.execute("CREATE TABLE values_table (value INTEGER NOT NULL UNIQUE)") + #expect(throws: SQLiteError.self) { + try database.withTransaction { + try database.executeMany("INSERT INTO values_table (value) VALUES (?)", [[.integer(1)], [.integer(1)]]) + } + } + #expect(try database.query("SELECT value FROM values_table") { $0.int(0) }.isEmpty) +} + +@Test func sqliteInterruptDoesNotPoisonTheNextStatement() throws { + let database = try SQLiteDatabase(path: ":memory:") + + database.interrupt() + + #expect(try database.query("SELECT 42") { $0.int(0) } == [42]) +} + +@Test func sqliteTransactionRollsBackAfterCancellation() async throws { + let database = try SQLiteDatabase(path: ":memory:") + try database.execute("CREATE TABLE values_table (value INTEGER NOT NULL)") + let transaction = Task { + try database.withTransaction { + try database.execute("INSERT INTO values_table (value) VALUES (1)") + withUnsafeCurrentTask { $0?.cancel() } + try database.execute("INSERT INTO values_table (value) VALUES (2)") + } + } + + do { + try await transaction.value + Issue.record("expected cancellation") + } catch is CancellationError { + } catch { + Issue.record("expected CancellationError, got \(error)") + } + + #expect(try database.query("SELECT value FROM values_table") { $0.int(0) }.isEmpty) + try database.withTransaction { + try database.execute("INSERT INTO values_table (value) VALUES (3)") + } + #expect(try database.query("SELECT value FROM values_table") { $0.int(0) } == [3]) +} + +@Test func sqliteTransactionPreservesBodyErrorWhenRollbackFails() throws { + let database = try SQLiteDatabase(path: ":memory:") + + do { + try database.withTransaction { + try database.execute("ROLLBACK") + throw TransactionFixtureError.body + } + Issue.record("expected body error") + } catch let error as TransactionFixtureError { + #expect(error == .body) + } catch { + Issue.record("expected TransactionFixtureError, got \(error)") + } + + #expect(try database.query("SELECT 42") { $0.int(0) } == [42]) +} + +private enum TransactionFixtureError: Error { + case body +} diff --git a/Tests/TokenMenuBarCoreTests/SecurityScopedAccessTests.swift b/Tests/TokenMenuBarCoreTests/SecurityScopedAccessTests.swift new file mode 100644 index 0000000..8081636 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/SecurityScopedAccessTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func missingBookmarkNeedsAccessWithoutStartingALease() { + let probe = SecurityScopeProbe() + let resource = ProviderID.codex.sandboxResources[0] + let fallback = URL(fileURLWithPath: "/fallback") + let result = SecurityScopedResourceResolver(client: probe.client()).resolve( + resource: resource, bookmark: nil, fallback: fallback) + #expect(result.url == fallback) + #expect(result.access == ResourceAccessState(resource: resource, health: .needed)) + #expect(result.lease == nil) + #expect(probe.starts == 0) +} + +@Test func resolvedBookmarkStopsExactlyOnceWhenReleased() { + let probe = SecurityScopeProbe() + let resource = ProviderID.codex.sandboxResources[0] + let result = SecurityScopedResourceResolver(client: probe.client()).resolve( + resource: resource, bookmark: Data([1]), fallback: URL(fileURLWithPath: "/fallback")) + #expect(result.url == probe.url) + #expect(result.access.health == .granted) + #expect(probe.starts == 1) + result.lease?.release() + result.lease?.release() + #expect(probe.stops == 1) +} + +@Test func staleBookmarkIsReplacedWhileTheLeaseRemainsValid() { + let probe = SecurityScopeProbe(stale: true) + let resource = ProviderID.codex.sandboxResources[0] + let result = SecurityScopedResourceResolver(client: probe.client()).resolve( + resource: resource, bookmark: Data([1]), fallback: URL(fileURLWithPath: "/fallback")) + #expect(result.access.health == .granted) + #expect(result.replacementBookmark == Data([2])) + #expect(probe.creates == 1) + #expect(probe.stops == 0) + result.lease?.release() + #expect(probe.stops == 1) +} + +@Test func failedStaleReplacementKeepsTheLeaseAndReportsStale() { + let probe = SecurityScopeProbe(stale: true, createFails: true) + let resource = ProviderID.codex.sandboxResources[0] + let result = SecurityScopedResourceResolver(client: probe.client()).resolve( + resource: resource, bookmark: Data([1]), fallback: URL(fileURLWithPath: "/fallback")) + #expect(result.url == probe.url) + #expect(result.access.health == .stale) + #expect(result.replacementBookmark == nil) + result.lease?.release() + #expect(probe.stops == 1) +} + +@Test func deniedSecurityScopeFallsBackWithoutALease() { + let probe = SecurityScopeProbe(startDenied: true) + let resource = ProviderID.codex.sandboxResources[0] + let fallback = URL(fileURLWithPath: "/fallback") + let result = SecurityScopedResourceResolver(client: probe.client()).resolve( + resource: resource, bookmark: Data([1]), fallback: fallback) + #expect(result.url == fallback) + #expect(result.access.health == .error("macOS denied access to the selected location.")) + #expect(result.lease == nil) + #expect(probe.stops == 0) +} + +@Test func invalidSecurityScopeBookmarkReportsAnAccessError() { + let probe = SecurityScopeProbe(resolveFails: true) + let resource = ProviderID.codex.sandboxResources[0] + let fallback = URL(fileURLWithPath: "/fallback") + let result = SecurityScopedResourceResolver(client: probe.client()).resolve( + resource: resource, bookmark: Data([1]), fallback: fallback) + #expect(result.url == fallback) + #expect(result.access.health == .error("The saved access grant is no longer valid.")) + #expect(result.lease == nil) + #expect(probe.starts == 0) +} + +@Test func liveSecurityScopeClientCreatesBookmarkData() throws { + let directory = temporaryDirectory() + let bookmark = try SecurityScopedBookmarkClient.live.create(directory) + #expect(!bookmark.isEmpty) +} + +@Test func providerRegistryRetainsAndThenReleasesResourceLeases() throws { + let probe = SecurityScopeProbe() + var registry: ProviderRegistry? + do { + let result = SecurityScopedResourceResolver(client: probe.client()).resolve( + resource: ProviderID.codex.sandboxResources[0], bookmark: Data([1]), + fallback: URL(fileURLWithPath: "/fallback")) + registry = ProviderRegistry([], resourceLeases: [try #require(result.lease)]) + } + #expect(probe.stops == 0) + registry = nil + #expect(registry == nil) + #expect(probe.stops == 1) +} + +private final class SecurityScopeProbe: @unchecked Sendable { + let url = URL(fileURLWithPath: "/granted") + private let lock = NSLock() + private let stale: Bool + private let createFails: Bool + private let startDenied: Bool + private let resolveFails: Bool + private var counts = (starts: 0, stops: 0, creates: 0) + + init(stale: Bool = false, createFails: Bool = false, startDenied: Bool = false, resolveFails: Bool = false) { + self.stale = stale + self.createFails = createFails + self.startDenied = startDenied + self.resolveFails = resolveFails + } + + var starts: Int { lock.withLock { counts.starts } } + var stops: Int { lock.withLock { counts.stops } } + var creates: Int { lock.withLock { counts.creates } } + + func client() -> SecurityScopedBookmarkClient { + SecurityScopedBookmarkClient( + resolve: { [url, stale, resolveFails] _ in + if resolveFails { throw CocoaError(.fileReadCorruptFile) } + return SecurityScopedBookmarkResolution(url: url, isStale: stale) + }, + create: { [self] _ in + lock.withLock { counts.creates += 1 } + if createFails { throw CocoaError(.fileWriteUnknown) } + return Data([2]) + }, + start: { [self] _ in + lock.withLock { counts.starts += 1 } + return !startDenied + }, + stop: { [self] _ in lock.withLock { counts.stops += 1 } }) + } +} diff --git a/Tests/TokenMenuBarCoreTests/SettingsPresentationTests.swift b/Tests/TokenMenuBarCoreTests/SettingsPresentationTests.swift new file mode 100644 index 0000000..3218a2a --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/SettingsPresentationTests.swift @@ -0,0 +1,245 @@ +import Foundation +import Testing +import TokenMenuBarCore + +private let presentationNow = Date(timeIntervalSince1970: 1_788_030_000) + +private func presentationSnapshot(_ provider: ProviderID, windows: [QuotaWindow]) -> ProviderSnapshot { + ProviderSnapshot(provider: provider, windows: windows, fetchedAt: presentationNow) +} + +private func presentationWindow(_ id: String, percent: Double, group: WindowGroup = .other) -> QuotaWindow { + QuotaWindow(id: id, label: Format.humanize(id), group: group, usedPercent: percent, resetsAt: nil) +} + +@Test func settingsSectionTitlesAreStable() { + #expect( + SettingsSection.allCases.map(\.title) == ["About", "Menu bar", "Providers", "Data", "Notifications", "Log"]) +} + +@Test func providerGroupReportsNoneSomeAndAllSelection() { + #expect(SettingsProviderGroup(provider: .codex, rows: [], selectedCount: 0, totalCount: 2).selection == .none) + #expect(SettingsProviderGroup(provider: .codex, rows: [], selectedCount: 1, totalCount: 2).selection == .some) + #expect(SettingsProviderGroup(provider: .codex, rows: [], selectedCount: 2, totalCount: 2).selection == .all) +} + +@Test func shortLabelPolicyCapsGraphemesAndRemovesDefaults() { + #expect(ShortLabelPolicy.draft("A👨‍👩‍👧‍👦BCDEF") == "A👨‍👩‍👧‍👦BCDE") + #expect(ShortLabelPolicy.override(" ", default: "CX") == nil) + #expect(ShortLabelPolicy.override("CX", default: "CX") == nil) + #expect(ShortLabelPolicy.override("SPARKLES", default: "CX") == "SPARKL") +} + +@Test func shortLabelPolicyDerivesUniqueLabelsForSamePrefixModels() { + let alpha = QuotaWindow( + id: "additional:spark-alpha", label: "Spark Alpha", group: .other, usedPercent: 20, resetsAt: nil) + let beta = QuotaWindow( + id: "additional:spark-beta", label: "Spark Beta", group: .other, usedPercent: 30, resetsAt: nil) + let alphaKey = WindowKey(.codex, alpha) + let betaKey = WindowKey(.codex, beta) + let labels = ShortLabelPolicy.derivedLabels(windows: [betaKey: beta, alphaKey: alpha]) + #expect(labels[alphaKey] == "SPK") + #expect(labels[betaKey] == "SPK2") + #expect(labels.values.allSatisfy { $0.count <= ShortLabelPolicy.limit }) + #expect(Set(labels.values).count == 2) + #expect(labels == ShortLabelPolicy.derivedLabels(windows: [alphaKey: alpha, betaKey: beta])) +} + +@Test func shortLabelPolicyRejectsUnicodeCaseAndWhitespaceEquivalentOverrides() { + let claude = presentationWindow("session", percent: 20) + let codex = presentationWindow("weekly", percent: 30) + let claudeKey = WindowKey(.claude, claude) + let codexKey = WindowKey(.codex, codex) + let windows = [claudeKey: claude, codexKey: codex] + let overrides = [claudeKey: "É X"] + #expect( + ShortLabelPolicy.conflictingKey( + "\u{00A0}e\u{301}\tX ", for: codexKey, windows: windows, overrides: overrides) == claudeKey) +} + +@Test func shortLabelPolicyKeepsPersistedLabelWhenDraftConflicts() { + let claude = presentationWindow("session", percent: 20) + let codex = presentationWindow("weekly", percent: 30) + let claudeKey = WindowKey(.claude, claude) + let codexKey = WindowKey(.codex, codex) + let labels = ShortLabelPolicy.validOverrides( + windows: [claudeKey: claude, codexKey: codex], persisted: [codexKey: "PAIR"], drafts: [claudeKey: " pair "]) + #expect(labels[claudeKey] == nil) + #expect(labels[codexKey] == "PAIR") +} + +@Test func settingsPresentationRejectsLegacyDuplicateShortLabelOverrides() { + let claude = presentationWindow("session", percent: 20) + let codex = presentationWindow("weekly", percent: 30) + let claudeKey = WindowKey(.claude, claude) + let codexKey = WindowKey(.codex, codex) + let groups = SettingsModelPresentation.groups( + snapshots: [ + .claude: presentationSnapshot(.claude, windows: [claude]), + .codex: presentationSnapshot(.codex, windows: [codex]), + ], + selected: [claudeKey, codexKey], labels: [claudeKey: "SAME", codexKey: " same "], + providerOrder: [.claude, .codex], modelOrder: [claudeKey, codexKey], query: "", hideUnused: false, + now: presentationNow) + let rows = groups.flatMap(\.rows) + #expect(Set(rows.map { $0.label.lowercased() }).count == 2) + #expect(rows.count(where: \.isLabelOverridden) == 1) +} + +@Test func settingsPresentationGroupsFiltersAndKeepsDerivedLabels() throws { + let opus = presentationWindow("opus-5", percent: 30) + let weekly = presentationWindow("weekly", percent: 0, group: .weekly) + let codex = presentationWindow("gpt-5.5", percent: 80) + let opusKey = WindowKey(.claude, opus) + let codexKey = WindowKey(.codex, codex) + let groups = SettingsModelPresentation.groups( + snapshots: [ + .claude: presentationSnapshot(.claude, windows: [opus, weekly]), + .codex: presentationSnapshot(.codex, windows: [codex]), + ], + selected: [codexKey, opusKey], labels: [codexKey: "GPT"], providerOrder: [.codex, .claude], + modelOrder: [codexKey, opusKey, WindowKey(.claude, weekly)], query: "gpt", hideUnused: true, + now: presentationNow) + let group = try #require(groups.first) + let row = try #require(group.rows.first) + #expect(groups.map(\.provider) == [.codex]) + #expect(row.key == codexKey) + #expect(row.label == "GPT") + #expect(row.isLabelOverridden) + #expect(row.recency == "today") + #expect(row.detail == "gpt-5.5") +} + +@Test func settingsPresentationDerivesLastUseFromIncreasesAndResets() { + let key = WindowKey(provider: .codex, windowID: "weekly") + let reset = presentationNow.addingTimeInterval(3600) + let samples = [ + UsageSample(timestamp: presentationNow.addingTimeInterval(-400), key: key, usedPercent: 0, resetsAt: nil), + UsageSample(timestamp: presentationNow.addingTimeInterval(-300), key: key, usedPercent: 10, resetsAt: nil), + UsageSample(timestamp: presentationNow.addingTimeInterval(-200), key: key, usedPercent: 10, resetsAt: nil), + UsageSample(timestamp: presentationNow.addingTimeInterval(-100), key: key, usedPercent: 1, resetsAt: reset), + ] + #expect(SettingsModelPresentation.lastUsageDates(samples)[key] == presentationNow.addingTimeInterval(-100)) +} + +@Test func settingsPresentationDoesNotTreatAnUnchangedPollAsUse() { + let key = WindowKey(provider: .claude, windowID: "weekly") + let firstUse = presentationNow.addingTimeInterval(-300) + let samples = [ + UsageSample(timestamp: firstUse, key: key, usedPercent: 10, resetsAt: nil), + UsageSample(timestamp: presentationNow, key: key, usedPercent: 10, resetsAt: nil), + ] + #expect(SettingsModelPresentation.lastUsageDates(samples)[key] == firstUse) +} + +@Test func settingsPresentationRevealsAFilteredUnusedModel() throws { + let window = presentationWindow("gpt-5.5", percent: 0) + let key = WindowKey(.codex, window) + let groups = SettingsModelPresentation.groups( + snapshots: [.codex: presentationSnapshot(.codex, windows: [window])], selected: [key], labels: [:], + providerOrder: [], modelOrder: [], query: "no match", hideUnused: true, revealedKey: key, + now: presentationNow) + #expect(try #require(groups.first?.rows.first).key == key) +} + +@Test func settingsPresentationKeepsModelsUsedEarlierInTheRange() throws { + let window = presentationWindow("gpt-5.5", percent: 0) + let key = WindowKey(.codex, window) + let groups = SettingsModelPresentation.groups( + snapshots: [.codex: presentationSnapshot(.codex, windows: [window])], selected: [key], labels: [:], + providerOrder: [], modelOrder: [], query: "", hideUnused: true, + lastUsedAt: [key: presentationNow.addingTimeInterval(-86400)], now: presentationNow) + #expect(try #require(groups.first?.rows.first).key == key) +} + +@Test func settingsPresentationHidesModelsUnusedThroughoutTheRange() { + let window = presentationWindow("gpt-5.5", percent: 0) + let key = WindowKey(.codex, window) + let groups = SettingsModelPresentation.groups( + snapshots: [.codex: presentationSnapshot(.codex, windows: [window])], selected: [key], labels: [:], + providerOrder: [], modelOrder: [], query: "", hideUnused: true, now: presentationNow) + #expect(groups.isEmpty) +} + +@Test func settingsPresentationNamesRateLimitWindows() throws { + let weekly = presentationWindow("weekly", percent: 40, group: .weekly) + let key = WindowKey(.claude, weekly) + let groups = SettingsModelPresentation.groups( + snapshots: [.claude: presentationSnapshot(.claude, windows: [weekly])], selected: [key], labels: [:], + providerOrder: [], modelOrder: [], query: "", hideUnused: false, now: presentationNow) + let row = try #require(groups.first?.rows.first) + #expect(row.detail == "window · 7d") + #expect(row.label == row.defaultLabel) + #expect(!row.isLabelOverridden) +} + +@Test func settingsPresentationReplacesEmptyStoredLabelsWithDerivedLabels() throws { + let window = presentationWindow("gpt-5.5", percent: 40) + let key = WindowKey(.codex, window) + let groups = SettingsModelPresentation.groups( + snapshots: [.codex: presentationSnapshot(.codex, windows: [window])], selected: [key], labels: [key: ""], + providerOrder: [], modelOrder: [], query: "", hideUnused: false, now: presentationNow) + let row = try #require(groups.first?.rows.first) + #expect(row.label == row.defaultLabel) + #expect(!row.isLabelOverridden) +} + +@Test func settingsPresentationNamesOldAndBuiltInWindows() throws { + let old = presentationNow.addingTimeInterval(-86400) + let windows = [ + presentationWindow("session", percent: 10, group: .session), + presentationWindow("monthly", percent: 20, group: .monthly), + ] + let keys = windows.map { WindowKey(.claude, $0) } + let groups = SettingsModelPresentation.groups( + snapshots: [.claude: ProviderSnapshot(provider: .claude, windows: windows, fetchedAt: old)], + selected: keys, labels: [:], providerOrder: [], modelOrder: [], query: "", + hideUnused: false, lastUsedAt: Dictionary(uniqueKeysWithValues: keys.map { ($0, old) }), now: presentationNow) + #expect(groups.first?.rows.map(\.detail) == ["window · 5h", "window · 1mo"]) + #expect(groups.first?.rows.allSatisfy { $0.recency.hasPrefix("last ") } == true) +} + +@Test func settingsProviderPresentationIncludesIdentitySuccessAndRetry() { + let retry = presentationNow.addingTimeInterval(600) + let snapshot = ProviderSnapshot( + provider: .codex, + identity: ProviderIdentity(planName: "Team", email: "dev@example.com", organization: "Acme"), + windows: [], fetchedAt: presentationNow) + let presentation = SettingsProviderPresentation( + state: ProviderState( + snapshot: snapshot, availability: .rateLimited, lastSuccess: presentationNow.addingTimeInterval(-120), + serviceHealth: .rateLimited(retryAt: retry, detail: "Slow down")), + now: presentationNow) + #expect(presentation.identity == "dev@example.com · Acme · Team") + #expect(presentation.lastSuccess == "Last success 2 min ago") + #expect(presentation.service.contains("Slow down")) + #expect(presentation.service.contains(retry.formatted(date: .abbreviated, time: .shortened))) +} + +@Test func settingsOrderDraftUsesProviderMajorMoves() { + let claudeA = WindowKey(provider: .claude, windowID: "a") + let claudeB = WindowKey(provider: .claude, windowID: "b") + let codexA = WindowKey(provider: .codex, windowID: "a") + var draft = SettingsOrderDraft( + providers: [.claude, .codex], models: [claudeA, claudeB, codexA], + available: [claudeA, claudeB, codexA]) + draft.moveProvider(.codex, before: .claude) + draft.moveModel(claudeB, by: -1) + #expect(draft.providers == [.codex, .claude]) + #expect(draft.models == [claudeB, claudeA, codexA]) + #expect(draft.orderedSelection([claudeA, codexA, claudeB]) == [codexA, claudeB, claudeA]) + draft.moveModel(claudeA, before: codexA) + #expect(draft.models == [claudeB, claudeA, codexA]) + draft.moveProvider(.codex, by: 1) + #expect(draft.providers == [.claude, .codex]) + draft.moveModel(claudeA, by: 1) + #expect(draft.models == [claudeB, claudeA, codexA]) +} + +@Test func settingsOrderDraftMovesAModelBeforeAnotherModelFromItsProvider() { + let first = WindowKey(provider: .claude, windowID: "first") + let second = WindowKey(provider: .claude, windowID: "second") + var draft = SettingsOrderDraft(providers: [.claude], models: [first, second], available: [first, second]) + draft.moveModel(second, before: first) + #expect(draft.models == [second, first]) +} diff --git a/Tests/TokenMenuBarCoreTests/SettingsTests.swift b/Tests/TokenMenuBarCoreTests/SettingsTests.swift new file mode 100644 index 0000000..17e9ebf --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/SettingsTests.swift @@ -0,0 +1,247 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test @MainActor func settingsDefaultsAndClamping() { + let defaults = freshDefaults() + let settings = Settings(defaults: defaults) + #expect(settings.refreshInterval(for: .claude) == 300) + #expect(settings.refreshInterval(for: .codex) == 120) + #expect(settings.enabledProviders == Set(ProviderID.allCases)) + #expect(settings.statusFormat == .stacked) + #expect(settings.activeTemplate == "{label}\n{pct}") + #expect(settings.hideZeroCells) + #expect(settings.historyRetentionDays == 60) + #expect(settings.providerOrder == ProviderID.allCases) + #expect(settings.modelOrder.isEmpty) + #expect(!settings.hideUnusedModels) + #expect(settings.automaticUpdates) + #expect(settings.lastLaunchedVersion == nil) + #expect(settings.historyMetricID == HistoryMetric.windowUsagePercent.storageID) + settings.setRefreshInterval(5, for: .claude) + #expect(settings.refreshInterval(for: .claude) == 120) + settings.setRefreshInterval(5000, for: .codex) + #expect(settings.refreshInterval(for: .codex) == Settings.maximumRefreshSeconds) + #expect(Settings(defaults: defaults).refreshInterval(for: .codex) == Settings.maximumRefreshSeconds) + settings.analyticsRefreshMinutes = 1 + #expect(settings.analyticsRefreshMinutes == 5) + settings.percentDecimals = 9 + #expect(settings.percentDecimals == 2) + settings.percentDecimals = -1 + #expect(settings.percentDecimals == 0) + settings.historyRetentionDays = 1 + #expect(settings.historyRetentionDays == 7) + settings.historyRetentionDays = 500 + #expect(settings.historyRetentionDays == 365) + settings.statusFormat = .custom + settings.customTemplate = "{pct}" + #expect(settings.activeTemplate == "{pct}") +} + +@MainActor +private func freshDefaults() -> UserDefaults { + let suite = "tests-settings-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults +} + +@Test @MainActor func menuBarSettingsSurviveAReload() { + let defaults = freshDefaults() + let settings = Settings(defaults: defaults) + let key = WindowKey(provider: .codex, windowID: "weekly") + settings.enabledProviders = [.codex] + settings.selectedWindows = [key] + settings.hasCustomSelection = true + settings.statusFormat = .miniBars + settings.customTemplate = "x" + settings.percentDecimals = 1 + settings.hideZeroCells = false + settings.windowOrder = .percent + settings.shortLabels = [key: "W"] + settings.providerOrder = [.codex, .claude] + settings.modelOrder = [key] + settings.hideUnusedModels = true + let reloaded = Settings(defaults: defaults) + #expect(reloaded.enabledProviders == [.codex]) + #expect(reloaded.selectedWindows == [key]) + #expect(reloaded.hasCustomSelection) + #expect(reloaded.statusFormat == .miniBars) + #expect(reloaded.customTemplate == "x") + #expect(reloaded.percentDecimals == 1) + #expect(!reloaded.hideZeroCells) + #expect(reloaded.windowOrder == .percent) + #expect(reloaded.shortLabels == [key: "W"]) + #expect(reloaded.providerOrder == [.codex, .claude]) + #expect(reloaded.modelOrder == [key]) + #expect(reloaded.hideUnusedModels) +} + +@Test @MainActor func shortLabelEditsApplyImmediatelyAndPersistAsOneBatch() async throws { + let defaults = freshDefaults() + let settings = Settings(defaults: defaults) + let key = WindowKey(provider: .codex, windowID: "weekly") + + settings.setShortLabel("O", for: key) + settings.setShortLabel("OP", for: key) + #expect(settings.shortLabels[key] == "OP") + #expect(Settings(defaults: defaults).shortLabels[key] == nil) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while Settings(defaults: defaults).shortLabels[key] != "OP", clock.now < deadline { + try await clock.sleep(for: .milliseconds(10)) + } + #expect(Settings(defaults: defaults).shortLabels[key] == "OP") + + settings.setShortLabel(nil, for: key) + settings.flush() + #expect(Settings(defaults: defaults).shortLabels[key] == nil) +} + +@Test @MainActor func historySettingsSurviveAReload() { + let defaults = freshDefaults() + let settings = Settings(defaults: defaults) + let key = WindowKey(provider: .codex, windowID: "weekly") + settings.lastTab = .history + settings.historyRange = .month + settings.historyRollup = .day + settings.historyStacked = true + settings.historyUseUTC = true + settings.historyHiddenKeys = [key] + settings.historyAnalyticsMetric = .turns + settings.historyMetricID = HistoryMetric.analytics(.turns).storageID + let reloaded = Settings(defaults: defaults) + #expect(reloaded.lastTab == .history) + #expect(reloaded.historyRange == .month) + #expect(reloaded.historyRollup == .day) + #expect(reloaded.historyStacked) + #expect(reloaded.historyUseUTC) + #expect(reloaded.historyHiddenKeys == [key]) + #expect(reloaded.historyAnalyticsMetric == .turns) + #expect(reloaded.historyMetricID == HistoryMetric.analytics(.turns).storageID) +} + +@Test @MainActor func appSettingsSurviveAReload() { + let defaults = freshDefaults() + let settings = Settings(defaults: defaults) + settings.allowTokenRefresh = true + settings.notifications = NotificationSettings( + enabled: false, thresholds: [50], notifyOnReset: false, notifyOnAuthProblems: false) + settings.detailedLogging = true + settings.automaticUpdates = false + settings.lastLaunchedVersion = "1.2.3" + let reloaded = Settings(defaults: defaults) + #expect(reloaded.allowTokenRefresh) + #expect( + reloaded.notifications + == NotificationSettings(enabled: false, thresholds: [50], notifyOnReset: false, notifyOnAuthProblems: false)) + #expect(reloaded.detailedLogging) + #expect(!reloaded.automaticUpdates) + #expect(reloaded.lastLaunchedVersion == "1.2.3") +} + +@Test @MainActor func clearingTheLastVersionRemovesItFromDefaults() { + let defaults = freshDefaults() + let settings = Settings(defaults: defaults) + settings.lastLaunchedVersion = "1.2.3" + settings.lastLaunchedVersion = nil + #expect(defaults.object(forKey: "lastLaunchedVersion") == nil) +} + +@Test @MainActor func bookmarksAreStoredPerSandboxResource() { + let defaults = freshDefaults() + let settings = Settings(defaults: defaults) + settings.setBookmark(Data([1, 2]), for: ProviderID.codex.sandboxResources[0]) + let reloaded = Settings(defaults: defaults) + #expect(reloaded.bookmark(for: ProviderID.codex.sandboxResources[0]) == Data([1, 2])) + #expect(reloaded.bookmark(for: ProviderID.gemini.sandboxResources[0]) == nil) + #expect(reloaded.missingAccess(for: .codex).isEmpty) + #expect(reloaded.missingAccess(for: .gemini) == ProviderID.gemini.sandboxResources) +} + +@Test @MainActor func settingsResetRestoresDefaults() { + let defaults = freshDefaults() + let settings = Settings(defaults: defaults) + settings.setRefreshInterval(600, for: .claude) + settings.statusFormat = .inline + settings.enabledProviders = [] + settings.lastTab = .settings + settings.lastLaunchedVersion = "9" + settings.historyMetricID = HistoryMetric.analytics(.turns).storageID + settings.resetToDefaults() + #expect(settings.refreshInterval(for: .claude) == 300) + #expect(settings.statusFormat == .stacked) + #expect(settings.enabledProviders == Set(ProviderID.allCases)) + #expect(settings.lastTab == .settings) + #expect(settings.lastLaunchedVersion == nil) + #expect(defaults.object(forKey: "refreshSeconds") == nil) + #expect(defaults.object(forKey: "lastTab") == nil) + #expect(settings.historyMetricID == HistoryMetric.windowUsagePercent.storageID) +} + +@Test @MainActor func resetToDefaultsRestoresValuesFromAllSixSections() { + let defaults = freshDefaults() + let settings = Settings(defaults: defaults) + settings.statusFormat = .custom + settings.shortLabels = [WindowKey(provider: .codex, windowID: "weekly"): "W"] + settings.automaticUpdates = false + settings.enabledProviders = [.codex] + settings.allowTokenRefresh = true + settings.analyticsRefreshMinutes = 30 + settings.historyRetentionDays = 90 + settings.notifications = NotificationSettings(enabled: false) + settings.detailedLogging = true + settings.demoMode = true + settings.resetToDefaults() + #expect(settings.automaticUpdates) + #expect(settings.statusFormat == .stacked) + #expect(settings.shortLabels.isEmpty) + #expect(settings.enabledProviders == Set(ProviderID.allCases)) + #expect(!settings.allowTokenRefresh) + #expect(settings.analyticsRefreshMinutes == Settings.defaultAnalyticsMinutes) + #expect(settings.historyRetentionDays == Settings.defaultHistoryRetentionDays) + #expect(settings.notifications == NotificationSettings()) + #expect(!settings.detailedLogging) + #expect(settings.demoMode == nil) +} + +@Test @MainActor func settingsIgnoreCorruptStoredValues() { + let defaults = freshDefaults() + defaults.set(Data("junk".utf8), forKey: "enabledProviders") + defaults.set("Nope", forKey: "statusFormat") + defaults.set("Nope", forKey: "windowOrder") + defaults.set("Nope", forKey: "lastTab") + defaults.set("Nope", forKey: "historyRange") + defaults.set("Nope", forKey: "historyRollup") + defaults.set("Nope", forKey: "historyAnalyticsMetric") + defaults.set("Nope", forKey: "historyMetricID") + let settings = Settings(defaults: defaults) + #expect(settings.enabledProviders == Set(ProviderID.allCases)) + #expect(settings.statusFormat == .stacked) + #expect(settings.windowOrder == .provider) + #expect(settings.lastTab == .usage) + #expect(settings.historyRange == .today) + #expect(settings.historyRollup == .minute) + #expect(settings.historyAnalyticsMetric == .surfaceUsagePercent) + #expect(settings.historyMetricID == HistoryMetric.windowUsagePercent.storageID) + #expect(PopoverTab.allCases.count == 3) +} + +@Test @MainActor func historyMetricMigratesOnlyAnExplicitLegacySelection() { + let defaults = freshDefaults() + defaults.set(AnalyticsMetric.turns.rawValue, forKey: "historyAnalyticsMetric") + let settings = Settings(defaults: defaults) + #expect(settings.historyMetricID == HistoryMetric.analytics(.turns).storageID) + #expect(Settings(defaults: freshDefaults()).historyMetricID == HistoryMetric.windowUsagePercent.storageID) +} + +@Test @MainActor func settingsFlushPersistsImmediately() { + let name = "flush-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + let settings = Settings(defaults: defaults) + #expect(settings.demoMode == nil) + settings.demoMode = true + settings.flush() + #expect(Settings(defaults: UserDefaults(suiteName: name)!).demoMode == true) + defaults.removePersistentDomain(forName: name) +} diff --git a/Tests/TokenMenuBarCoreTests/ShutdownPolicyTests.swift b/Tests/TokenMenuBarCoreTests/ShutdownPolicyTests.swift new file mode 100644 index 0000000..37000f0 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ShutdownPolicyTests.swift @@ -0,0 +1,16 @@ +import Testing +import TokenMenuBarCore + +@Test func shutdownPolicyReportsCompletedWork() async { + #expect(await ShutdownPolicy.waitForCompletion(timeout: .seconds(1)) {}) +} + +@Test func shutdownPolicyStopsWaitingAtTheDeadline() async { + let completed = await ShutdownPolicy.waitForCompletion( + timeout: .milliseconds(5), pollInterval: .milliseconds(1) + ) { + try? await Task.sleep(for: .seconds(1)) + } + + #expect(!completed) +} diff --git a/Tests/TokenMenuBarCoreTests/SnapshotPersistenceTests.swift b/Tests/TokenMenuBarCoreTests/SnapshotPersistenceTests.swift new file mode 100644 index 0000000..c1c4ef3 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/SnapshotPersistenceTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func snapshotPersistenceLoadsCacheOnceAndReportsMalformedData() async throws { + let root = temporaryDirectory() + let cache = SnapshotCache(url: root.appendingPathComponent("snapshots.json")) + try cache.store([.claude: DemoData.snapshot(.claude, now: fixedNow)]) + let persistence = SnapshotPersistence(cache: cache) + async let first = persistence.loadSnapshots() + async let second = persistence.loadSnapshots() + let loaded = await [first, second] + #expect(loaded.allSatisfy { $0[.claude]?.source == .cache }) + #expect((await persistence.workload).cacheLoads == 1) + + let malformed = SnapshotCache(url: root.appendingPathComponent("malformed.json")) + try Data("{".utf8).write(to: malformed.url!) + let failures = PersistenceFailureRecorder() + let broken = SnapshotPersistence(cache: malformed) { await failures.record($0) } + #expect(await broken.loadSnapshots().isEmpty) + #expect(await failures.values.count == 1) +} + +@Test func snapshotPersistenceCoalescesCacheWritesToTheLatestValue() async throws { + let cache = SnapshotCache(url: temporaryDirectory().appendingPathComponent("snapshots.json")) + let persistence = SnapshotPersistence(cache: cache) + await withTaskGroup(of: Void.self) { group in + for percent in 0..<100 { + group.addTask { + let snapshot = DemoData.snapshot(.claude, now: fixedNow.addingTimeInterval(Double(percent))) + await persistence.submitSnapshots([.claude: snapshot]) + } + } + } + let latest = DemoData.snapshot(.codex, now: fixedNow.addingTimeInterval(1_000)) + await persistence.submitSnapshots([.codex: latest]) + await persistence.flush() + #expect(cache.load()[.codex]?.fetchedAt == latest.fetchedAt) + let workload = await persistence.workload + #expect(workload.cacheSubmissions == 101) + #expect(workload.coalescedCacheSubmissions > 0) + #expect(workload.cacheWrites < workload.cacheSubmissions) +} + +@Test @MainActor func snapshotPersistenceReloadsWidgetsOncePerWrittenValue() async throws { + let store = WidgetSnapshotStore(url: temporaryDirectory().appendingPathComponent("widget.json")) + let reloads = WidgetReloadRecorder() + let failures = PersistenceFailureRecorder() + let persistence = SnapshotPersistence(cache: SnapshotCache(url: nil), widgetStore: store) { failure in + await failures.record(failure) + } reloadWidgets: { + reloads.count += 1 + } + await persistence.submitWidget(.placeholder) + await persistence.flush() + let stored = try #require(store.read()) + #expect(stored.rows.map(\.key) == WidgetSnapshot.placeholder.rows.map(\.key)) + #expect(stored.rows.map(\.usedPercent) == WidgetSnapshot.placeholder.rows.map(\.usedPercent)) + #expect(stored.attention == WidgetSnapshot.placeholder.attention) + #expect(reloads.count == 1) + await persistence.submitWidget(.placeholder) + await persistence.flush() + #expect(reloads.count == 1) + let workload = await persistence.workload + #expect(workload.widgetWrites == 1) + #expect(workload.widgetReloads == 1) + #expect(await failures.values.isEmpty) +} + +@Test func snapshotPersistenceReportsWriteFailures() async { + let failures = PersistenceFailureRecorder() + let persistence = SnapshotPersistence( + cache: SnapshotCache(url: URL(fileURLWithPath: "/dev/null/snapshots.json")), + widgetStore: WidgetSnapshotStore(url: URL(fileURLWithPath: "/dev/null/widget.json")) + ) { await failures.record($0) } + await persistence.submitSnapshots([.claude: DemoData.snapshot(.claude, now: fixedNow)]) + await persistence.submitWidget(.placeholder) + await persistence.flush() + let values = await failures.values + #expect(values.contains { if case .cacheWrite = $0 { true } else { false } }) + #expect(values.contains { if case .widgetWrite = $0 { true } else { false } }) +} + +private actor PersistenceFailureRecorder { + private(set) var values: [SnapshotPersistenceFailure] = [] + + func record(_ failure: SnapshotPersistenceFailure) { + values.append(failure) + } +} + +@MainActor +private final class WidgetReloadRecorder { + var count = 0 +} diff --git a/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift b/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift new file mode 100644 index 0000000..396bb3a --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift @@ -0,0 +1,197 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func templateParserHandlesEscapesAndNewlines() { + #expect(StatusTemplate.parse("a{{b}}c\\nd\ne") == [.text("a{b}c"), .newline, .text("d"), .newline, .text("e")]) + #expect(StatusTemplate.parse("{pct}") == [.placeholder("pct")]) + #expect(StatusTemplate.parse("{unclosed") == [.text("{unclosed")]) + #expect(StatusTemplate.parse("x{") == [.text("x{")]) + #expect(StatusTemplate.parse("}x") == [.text("}x")]) + #expect(StatusTemplate.parse("a}") == [.text("a}")]) + #expect(StatusTemplate.parse("\\t\\") == [.text("t")]) + #expect(StatusTemplate.parse("") == []) +} + +@Test func templateRendersEveryToken() { + let template = + "{cell} {provider} {providerName} {window} {label}\n" + + "{pct} {pct0} {pct1} {pct2} {remaining} {reset} {resetClock} {plan} {credits} {unknown}" + let lines = StatusTemplate.render(template, context: context(decimals: 1)) + #expect(lines.count == 2) + #expect(StatusTemplate.plainText([lines[0]]) == "CC·5h CC Claude 5h CC 5h") + let second = StatusTemplate.plainText([lines[1]]) + #expect(second.hasPrefix("36.4% 36% 36.4% 36.40% 63.6% 4h24m ")) + #expect(second.hasSuffix(" Max 20x $5.00 ")) + #expect(lines[1][0].kind == .usage(36.4)) + #expect(lines[1].contains { $0.kind == .number }) + #expect(StatusTemplate.render("{plan}{credits}", context: context(plan: nil, credits: nil)).isEmpty) + #expect(StatusTemplate.render("\n\n", context: context()).isEmpty) +} + +private func context( + _ window: QuotaWindow = session, decimals: Int = 0, plan: String? = "Max 20x", credits: String? = "$5.00" +) -> StatusCellContext { + StatusCellContext( + provider: .claude, window: window, cellLabel: "CC·5h", shortLabel: "CC 5h", decimals: decimals, planName: plan, + credits: credits, + now: fixedNow) +} + +@Test func templateDetectsCountdownUsage() { + #expect(StatusTemplate.referencesCountdown("{reset}")) + #expect(!StatusTemplate.referencesCountdown("{resetClock}")) + #expect(StatusFormat.stacked.template == "{label}\n{pct}") + #expect(StatusFormat.inline.template == "{label}:{pct}") + #expect(StatusFormat.custom.template == nil) + #expect(StatusTemplate.tokens.count == 13) +} + +@Test( + arguments: [ + (session, "5h"), + (QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: 0, resetsAt: nil), "7d"), + (QuotaWindow(id: "monthly", label: "Monthly", group: .monthly, usedPercent: 0, resetsAt: nil), "1mo"), + (fable, "FAB"), + (QuotaWindow(id: "code_review:weekly", label: "x", group: .weekly, usedPercent: 0, resetsAt: nil), "WEE"), + ( + QuotaWindow(id: "tangelo", label: "x", group: .other, usedPercent: 0, resetsAt: nil, scope: "GPT-5.3 Spark"), + "GPT" + ), + ]) +func windowTagsAbbreviate(window: QuotaWindow, tag: String) { + #expect(StatusTemplate.windowTag(window) == tag) +} + +private let session = QuotaWindow( + id: "session", label: "Current session", group: .session, usedPercent: 36.4, + resetsAt: fixedNow.addingTimeInterval(4 * 3600 + 24 * 60), duration: 18000) +private let fable = QuotaWindow( + id: "weekly:fable", label: "Fable", group: .weekly, usedPercent: 61, resetsAt: fixedNow.addingTimeInterval(3 * 86400), + duration: 604_800, scope: "Fable") + +@Test func defaultSelectionPrefersSessionAndWeeklyWindows() { + let keys = StatusItemBuilder.defaultSelection(input().snapshots) + #expect(keys.map(\.storageKey) == ["claude:session", "claude:weekly:fable", "codex:weekly"]) + let odd: [ProviderID: ProviderSnapshot] = [ + .codex: ProviderSnapshot( + provider: .codex, + windows: [ + QuotaWindow(id: "a", label: "A", group: .other, usedPercent: 1, resetsAt: nil), + QuotaWindow(id: "b", label: "B", group: .other, usedPercent: 1, resetsAt: nil), + QuotaWindow(id: "c", label: "C", group: .other, usedPercent: 1, resetsAt: nil), + ], fetchedAt: fixedNow) + ] + #expect(StatusItemBuilder.defaultSelection(odd).map(\.windowID) == ["a", "b"]) +} + +private func input( + format: StatusFormat = .stacked, selected: [WindowKey]? = nil, hideZero: Bool = true, order: WindowOrder = .provider, + availability: [ProviderID: QuotaAvailability] = [.claude: .current, .codex: .current], + labels: [WindowKey: String] = [:] +) -> StatusItemInput { + let claude = ProviderSnapshot( + provider: .claude, identity: ProviderIdentity(planName: "Max"), windows: [session, fable], fetchedAt: fixedNow) + let codex = ProviderSnapshot( + provider: .codex, + windows: [ + QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: 62, resetsAt: nil), + QuotaWindow( + id: "additional:spark:session", label: "Spark 5h", group: .session, usedPercent: 0, resetsAt: nil, + scope: "Spark"), + ], + credits: CreditBalance(balance: 0), + fetchedAt: fixedNow + ) + let snapshots: [ProviderID: ProviderSnapshot] = [.claude: claude, .codex: codex] + return StatusItemInput( + snapshots: snapshots, + availability: availability, + selectedKeys: selected ?? StatusItemBuilder.defaultSelection(snapshots), + format: format, + customTemplate: "{label}={pct} {reset}", + decimals: 0, + hideZeroCells: hideZero, + order: order, + labels: labels, + now: fixedNow + ) +} + +@Test func builderRendersStackedCells() { + let model = StatusItemBuilder.build(input()) + #expect(model.cells.map(\.id) == ["claude:session", "claude:weekly:fable", "codex:weekly"]) + #expect(model.cells[0].lines.map { StatusTemplate.plainText([$0]) } == ["CC 5h", "36%"]) + #expect(model.cells[1].lines.map { StatusTemplate.plainText([$0]) } == ["FAB", "61%"]) + #expect(model.cells[2].lines.map { StatusTemplate.plainText([$0]) } == ["CX 7d", "62%"]) + #expect(model.cells[0].tooltip == "Claude Current session: 36%, resets 4 hr 24 min") + #expect(!model.cells[0].isMiniBar) + #expect(model.iconTone == .normal) + #expect(!model.showsIcon) + #expect(!model.countdownActive) +} + +@Test func builderHonoursHideZeroOrderAndCustomTemplate() { + let all = input( + format: .custom, + selected: [ + WindowKey(provider: .codex, windowID: "additional:spark:session"), + WindowKey(provider: .claude, windowID: "session"), + ], hideZero: false, order: .percent, labels: [WindowKey(provider: .claude, windowID: "session"): "S"]) + let model = StatusItemBuilder.build(all) + #expect(model.cells.map(\.id) == ["claude:session", "codex:additional:spark:session"]) + #expect(StatusTemplate.plainText(model.cells[0].lines) == "S=36% 4h24m") + #expect(StatusTemplate.plainText(model.cells[1].lines) == "SPK=0% --") + #expect(model.countdownActive) + let hidden = StatusItemBuilder.build( + input(format: .custom, selected: [WindowKey(provider: .codex, windowID: "additional:spark:session")])) + #expect(hidden.cells.isEmpty) + #expect(hidden.showsIcon) + #expect(!hidden.countdownActive) + #expect(StatusItemBuilder.build(input(selected: [WindowKey(provider: .claude, windowID: "nope")])).cells.isEmpty) +} + +@Test func builderDerivesShortLabelsFromProviderAndWindow() { + let window = QuotaWindow( + id: "additional:spark:session", label: "Spark", group: .session, usedPercent: 0, resetsAt: nil) + #expect(StatusItemBuilder.defaultShortLabel(provider: .codex, window: window) == "SPK") + let generic = QuotaWindow( + id: "additional:codex:model", label: "Codex Model", group: .other, usedPercent: 0, resetsAt: nil, + scope: "Codex Model") + #expect(StatusItemBuilder.defaultShortLabel(provider: .codex, window: generic) == "COD") +} + +@Test func builderRejectsLegacyDuplicateShortLabels() { + let claude = WindowKey(provider: .claude, windowID: "session") + let codex = WindowKey(provider: .codex, windowID: "weekly") + let model = StatusItemBuilder.build( + input( + format: .custom, selected: [codex, claude], hideZero: false, + labels: [claude: "SAME", codex: " same "])) + #expect( + model.cells.map { StatusTemplate.plainText($0.lines).split(separator: "=").first.map(String.init) } == [ + "CX 7d", "SAME", + ]) +} + +@Test func builderRendersMiniBarsPerProvider() { + let model = StatusItemBuilder.build(input(format: .miniBars, order: .percent)) + #expect(model.cells.map(\.id) == ["codex", "claude"]) + #expect(model.cells[1].bars == [StatusBar(label: "FAB", percent: 61), StatusBar(label: "CC 5h", percent: 36.4)]) + #expect(model.cells[1].isMiniBar) + #expect(model.cells[1].percent == 61) + #expect(model.cells[0].tooltip == "Weekly: 62%") + #expect(StatusItemBuilder.build(input(format: .miniBars)).cells.map(\.id) == ["claude", "codex"]) +} + +@Test func builderIconToneFollowsAvailability() { + #expect( + StatusItemBuilder.build(input(availability: [.claude: .authenticationRequired, .codex: .networkUnavailable])) + .iconTone == .attention) + let offline = StatusItemBuilder.build(input(availability: [.claude: .networkUnavailable])) + #expect(offline.iconTone == .offline) + #expect(!offline.showsIcon) + #expect(StatusItemModel.empty.showsIcon) + #expect(StatusItemBuilder.orderedProviders([]).isEmpty) +} diff --git a/Tests/TokenMenuBarCoreTests/Support/Fixtures.swift b/Tests/TokenMenuBarCoreTests/Support/Fixtures.swift new file mode 100644 index 0000000..ee06e06 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Support/Fixtures.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +import TokenMenuBarCore + +enum Fixtures { + static func url(_ name: String) -> URL { + Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures")! + } + + static func data(_ name: String) -> Data { + try! Data(contentsOf: url(name)) + } + + static func json(_ name: String) -> JSONValue { + try! JSONDecoder().decode(JSONValue.self, from: data(name)) + } + + // the fixture stores readable claims; CodexAuth wants them as a token, so sign them on the way out + static func codexAuth() -> JSONValue { + let document = json("codex_auth") + var tokens = document["tokens"]!.objectValue! + tokens["id_token"] = .string(makeJWT(tokens.removeValue(forKey: "id_token_claims")!)) + return document.merging("tokens", .object(tokens)) + } + + static func decode(_ type: Value.Type, _ name: String) -> Value { + try! JSONDecoder().decode(type, from: data(name)) + } +} + +let fixedNow = Date(timeIntervalSince1970: 1_788_030_000) + +let testClock = Clock.fixed(fixedNow) + +func makeLog() -> LogBuffer { + LogBuffer(fileURL: nil, clock: testClock) +} + +func temporaryDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-tests-\(UUID().uuidString)") + try! FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +final class DateBox: @unchecked Sendable { + private let lock = NSLock() + private var value: Date + + init(_ value: Date) { + self.value = value + } + + var date: Date { + get { lock.withLock { value } } + set { lock.withLock { value = newValue } } + } + + var clock: Clock { + Clock(now: { self.date }, sleep: { _ in }) + } +} + +struct TestError: Error {} + +func makeJWT(_ payload: JSONValue) -> String { + let body = try! JSONEncoder().encode(payload).base64EncodedString().replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + return "\(Data(#"{"alg":"none"}"#.utf8).base64EncodedString()).\(body).sig" +} diff --git a/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift b/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift new file mode 100644 index 0000000..3841c64 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift @@ -0,0 +1,63 @@ +import Foundation +import TokenMenuBarCore + +// One double for all five credential seams: each provider reads its own type, but the behaviour under test is the +// same everywhere — hand back what was stored, or throw what the case asked for. +final class MemoryCredentialStore: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value? + private var reads = 0 + var loadError: (any Error)? + var saveError: (any Error)? + private(set) var saved: [Value] = [] + + init(_ value: Value?) { + stored = value + } + + var description: String { "memory" } + var readCount: Int { lock.withLock { reads } } + + func read() throws -> Value? { + lock.withLock { reads += 1 } + if let loadError { throw loadError } + return lock.withLock { stored } + } + + func write(_ value: Value) throws { + if let saveError { throw saveError } + lock.withLock { + stored = value + saved.append(value) + } + } +} + +extension MemoryCredentialStore: ClaudeCredentialStore where Value == ClaudeOAuthCredentials { + func load() throws -> ClaudeOAuthCredentials? { try read() } + func save(_ credentials: ClaudeOAuthCredentials) throws { try write(credentials) } +} + +extension MemoryCredentialStore: CodexAuthStore where Value == CodexAuth { + func load() throws -> CodexAuth? { try read() } + func save(_ auth: CodexAuth) throws { try write(auth) } +} + +extension MemoryCredentialStore: GeminiAuthStore where Value == GeminiAuth { + func load() throws -> GeminiAuth? { try read() } + func save(_ auth: GeminiAuth) throws { try write(auth) } +} + +extension MemoryCredentialStore: CursorAuthStore where Value == CursorAuth { + func load() throws -> CursorAuth? { try read() } +} + +extension MemoryCredentialStore: CopilotAuthStore where Value == CopilotAuth { + func load() throws -> CopilotAuth? { try read() } +} + +typealias MemoryClaudeStore = MemoryCredentialStore +typealias MemoryCodexStore = MemoryCredentialStore +typealias MemoryGeminiStore = MemoryCredentialStore +typealias MemoryCursorStore = MemoryCredentialStore +typealias MemoryCopilotStore = MemoryCredentialStore diff --git a/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift b/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift new file mode 100644 index 0000000..611769d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift @@ -0,0 +1,35 @@ +import Foundation + +@testable import TokenMenuBarCore + +final class MemoryKeychain: @unchecked Sendable { + private struct Key: Hashable { + let service: String + let account: String + } + + private let lock = NSLock() + private var values: [Key: Data] = [:] + + var client: KeychainCredentialClient { + KeychainCredentialClient( + load: { [self] service, account in + lock.withLock { + if let account { + return values[Key(service: service, account: account)].map { + KeychainCredentialItem(data: $0, account: account) + } + } + return + values + .filter { $0.key.service == service } + .sorted { $0.key.account < $1.key.account } + .first + .map { KeychainCredentialItem(data: $0.value, account: $0.key.account) } + } + }, + save: { [self] data, service, account in + lock.withLock { values[Key(service: service, account: account)] = data } + }) + } +} diff --git a/Tests/TokenMenuBarCoreTests/Support/ScriptedProvider.swift b/Tests/TokenMenuBarCoreTests/Support/ScriptedProvider.swift new file mode 100644 index 0000000..bd6f34d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Support/ScriptedProvider.swift @@ -0,0 +1,96 @@ +import Foundation +import TokenMenuBarCore + +// The provider seam for every test that drives the refresh loop: hand it the results a case needs, in order, and it +// replays them while recording the options it was called with. +final class ScriptedProvider: UsageProvider, @unchecked Sendable { + let id: ProviderID + var pollingPolicy = PollingPolicy(minimumInterval: 0, activeInterval: 0, defaultInterval: 0) + private let lock = NSLock() + private var queue: [ProviderFetchResult] + private(set) var calls: [FetchOptions] = [] + private var credentialStateCalls = 0 + private var credentialHealthCalls = 0 + private let gate: TestGate? + var credentials: CredentialState = .valid(expiresAt: nil) + var health: ProviderCredentialHealth = .unchecked + + init(id: ProviderID, results: [ProviderFetchResult], gate: TestGate? = nil) { + self.id = id + queue = results + self.gate = gate + } + + var credentialDescription: String { "scripted" } + var callCount: Int { lock.withLock { calls.count } } + var credentialStateCallCount: Int { lock.withLock { credentialStateCalls } } + var credentialHealthCallCount: Int { lock.withLock { credentialHealthCalls } } + + func credentialState(now: Date) -> CredentialState { + lock.withLock { credentialStateCalls += 1 } + return credentials + } + + func credentialHealth(now: Date) async -> ProviderCredentialHealth { + lock.withLock { credentialHealthCalls += 1 } + return health + } + + func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult { + let result = lock.withLock { + calls.append(options) + return queue.count > 1 ? queue.removeFirst() : queue[0] + } + if let gate { try? await gate.wait() } + return result + } +} + +final class TestGate: @unchecked Sendable { + private let lock = NSLock() + private var waiters: [UUID: CheckedContinuation] = [:] + private var cancelled: Set = [] + private var isOpen = false + + func wait() async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let state = lock.withLock { + if isOpen { return 1 } + if cancelled.remove(id) != nil { return 2 } + waiters[id] = continuation + return 0 + } + if state == 1 { continuation.resume() } + if state == 2 { continuation.resume(throwing: CancellationError()) } + } + } onCancel: { + let waiter = lock.withLock { () -> CheckedContinuation? in + guard let waiter = waiters.removeValue(forKey: id) else { + cancelled.insert(id) + return nil + } + return waiter + } + waiter?.resume(throwing: CancellationError()) + } + } + + func open() { + let pending = lock.withLock { + isOpen = true + defer { waiters.removeAll() } + return Array(waiters.values) + } + for waiter in pending { waiter.resume() } + } +} + +func scriptedProvider( + _ id: ProviderID, _ result: ProviderFetchResult = ProviderFetchResult(outcome: .failed("unset")) +) + -> ScriptedProvider +{ + ScriptedProvider(id: id, results: [result]) +} diff --git a/Tests/TokenMenuBarCoreTests/Support/StubTransport.swift b/Tests/TokenMenuBarCoreTests/Support/StubTransport.swift new file mode 100644 index 0000000..359a9f7 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Support/StubTransport.swift @@ -0,0 +1,75 @@ +import Foundation +import TokenMenuBarCore + +final class StubTransport: HTTPTransport, @unchecked Sendable { + struct Response { + let status: Int + let body: Data + let headers: [String: String] + + init(status: Int = 200, body: Data = Data(), headers: [String: String] = [:]) { + self.status = status + self.body = body + self.headers = headers + } + + static func json(_ name: String, status: Int = 200) -> Response { + Response(status: status, body: Fixtures.data(name)) + } + + static func text(_ text: String, status: Int = 200, headers: [String: String] = [:]) -> Response { + Response(status: status, body: Data(text.utf8), headers: headers) + } + } + + enum Rule { + case respond(Response) + case respondRaw(Data, URLResponse) + case fail(any Error) + } + + private let lock = NSLock() + private var rules: [(match: (URLRequest) -> Bool, rule: Rule)] = [] + private(set) var requests: [URLRequest] = [] + + init() {} + + func on(_ predicate: @escaping (URLRequest) -> Bool, _ rule: Rule) { + lock.withLock { rules.append((predicate, rule)) } + } + + func on(path: String, _ response: Response) { + on({ $0.url?.path.hasSuffix(path) == true }, .respond(response)) + } + + func on(path: String, error: any Error) { + on({ $0.url?.path.hasSuffix(path) == true }, .fail(error)) + } + + func on(path: String, data: Data, response: URLResponse) { + on({ $0.url?.path.hasSuffix(path) == true }, .respondRaw(data, response)) + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let rule = lock.withLock { () -> Rule? in + requests.append(request) + return rules.first { $0.match(request) }?.rule + } + switch rule { + case .respond(let response): + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, httpVersion: nil, headerFields: response.headers)! + return (response.body, http) + case .respondRaw(let data, let response): + return (data, response) + case .fail(let error): + throw error + case nil: + throw URLError(.unsupportedURL) + } + } + + func requests(matching path: String) -> [URLRequest] { + lock.withLock { requests.filter { $0.url?.path.hasSuffix(path) == true } } + } +} diff --git a/Tests/TokenMenuBarCoreTests/TooltipPolicyTests.swift b/Tests/TokenMenuBarCoreTests/TooltipPolicyTests.swift new file mode 100644 index 0000000..8569939 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/TooltipPolicyTests.swift @@ -0,0 +1,121 @@ +import CoreGraphics +import Testing + +@testable import TokenMenuBarCore + +@Test func tooltipTimingMatchesInteractionContract() { + #expect(TooltipTiming.presentationDelay == .milliseconds(150)) + #expect(TooltipTiming.dismissalDelay == .milliseconds(150)) + #expect(TooltipTiming.fadeDuration == 0.09) +} + +@Test func tooltipArbiterRejectsStalePresentation() { + var arbiter = TooltipArbiter() + let first = arbiter.arm(owner: TooltipOwner(rawValue: 1))! + let second = arbiter.arm(owner: TooltipOwner(rawValue: 2))! + let firstPresented = arbiter.present(first) + let secondPresented = arbiter.present(second) + + #expect(!firstPresented) + #expect(secondPresented) + #expect(arbiter.visible == second) +} + +@Test func tooltipArbiterIgnoresStaleDismissal() { + var arbiter = TooltipArbiter() + _ = arbiter.arm(owner: TooltipOwner(rawValue: 1)) + let second = arbiter.arm(owner: TooltipOwner(rawValue: 2))! + let dismissed = arbiter.dismiss(owner: TooltipOwner(rawValue: 1)) + + #expect(!dismissed) + #expect(arbiter.pending == second) +} + +@Test func tooltipArbiterKeepsVisibleContentWhileItsReplacementIsPending() { + var arbiter = TooltipArbiter() + let first = arbiter.arm(owner: TooltipOwner(rawValue: 1))! + _ = arbiter.present(first) + let second = arbiter.arm(owner: TooltipOwner(rawValue: 2))! + + #expect(arbiter.visible == first) + #expect(arbiter.pending == second) +} + +@Test func tooltipArbiterCancelsAPendingReplacementWithoutDismissingVisibleContent() { + var arbiter = TooltipArbiter() + let first = arbiter.arm(owner: TooltipOwner(rawValue: 1))! + _ = arbiter.present(first) + let second = arbiter.arm(owner: TooltipOwner(rawValue: 2))! + let dismissed = arbiter.dismiss(owner: second.owner) + + #expect(dismissed) + #expect(arbiter.pending == nil) + #expect(arbiter.visible == first) +} + +@Test func tooltipArbiterDoesNotRearmCurrentOwner() { + var arbiter = TooltipArbiter() + let request = arbiter.arm(owner: TooltipOwner(rawValue: 1))! + let duplicatePending = arbiter.arm(owner: request.owner) + let presented = arbiter.present(request) + let duplicateVisible = arbiter.arm(owner: request.owner) + #expect(duplicatePending == nil) + #expect(presented) + #expect(duplicateVisible == nil) +} + +@Test func tooltipArbiterDismissesMatchingOwner() { + var arbiter = TooltipArbiter() + let request = arbiter.arm(owner: TooltipOwner(rawValue: 1))! + let presented = arbiter.present(request) + let dismissed = arbiter.dismiss(owner: request.owner) + #expect(presented) + #expect(dismissed) + #expect(arbiter.pending == nil) + #expect(arbiter.visible == nil) + let duplicateDismiss = arbiter.dismiss(owner: request.owner) + #expect(!duplicateDismiss) +} + +@Test( + arguments: [ + ( + "center below", CGRect(x: 100, y: 200, width: 40, height: 20), CGSize(width: 80, height: 40), + CGRect(x: 0, y: 0, width: 300, height: 300), CGPoint(x: 80, y: 153), TooltipSide.below + ), + ( + "flip above", CGRect(x: 100, y: 20, width: 40, height: 20), CGSize(width: 80, height: 40), + CGRect(x: 0, y: 0, width: 300, height: 300), CGPoint(x: 80, y: 47), TooltipSide.above + ), + ( + "clamp left", CGRect(x: -95, y: 200, width: 10, height: 20), CGSize(width: 80, height: 40), + CGRect(x: -100, y: 0, width: 300, height: 300), CGPoint(x: -92, y: 153), TooltipSide.below + ), + ( + "clamp right", CGRect(x: 185, y: 200, width: 10, height: 20), CGSize(width: 80, height: 40), + CGRect(x: -100, y: 0, width: 300, height: 300), CGPoint(x: 112, y: 153), TooltipSide.below + ), + ] +) +func tooltipPlacementStaysInsideVisibleFrame( + _: String, + anchor: CGRect, + size: CGSize, + visibleFrame: CGRect, + expectedOrigin: CGPoint, + expectedSide: TooltipSide +) { + let placement = TooltipGeometry.placement(anchor: anchor, tooltipSize: size, visibleFrame: visibleFrame) + #expect(placement.origin == expectedOrigin) + #expect(placement.side == expectedSide) +} + +@Test func tooltipPlacementClampsOversizedContentToTheSafeOrigin() { + let placement = TooltipGeometry.placement( + anchor: CGRect(x: 20, y: 20, width: 10, height: 10), + tooltipSize: CGSize(width: 500, height: 500), + visibleFrame: CGRect(x: -100, y: -50, width: 300, height: 200) + ) + #expect(placement.origin == CGPoint(x: -92, y: -42)) + #expect(placement.side == .above) +} diff --git a/Tests/TokenMenuBarCoreTests/TranscriptReaderCoverageBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/TranscriptReaderCoverageBehaviorTests.swift new file mode 100644 index 0000000..5917714 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/TranscriptReaderCoverageBehaviorTests.swift @@ -0,0 +1,192 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func transcriptSnapshotFiltersTodayAtASecondOffsetDayBoundary() async throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 30)) + let now = calendar.startOfDay(for: fixedNow).addingTimeInterval(3600) + let todayStart = calendar.startOfDay(for: now) + let root = temporaryDirectory() + try + ([ + coverageClaudeLine(id: "before", at: todayStart.addingTimeInterval(-1), input: 1), + coverageClaudeLine(id: "after", at: todayStart.addingTimeInterval(1), input: 2), + ].joined(separator: "\n") + "\n").write( + to: root.appendingPathComponent("session.jsonl"), atomically: true, encoding: .utf8) + + let snapshot = await ClaudeTranscriptReader(root: root).refresh(now: now) + let usage = try #require( + snapshot.localUsage(windowResetsAt: nil, windowDuration: 7200, now: now, calendar: calendar)) + #expect(usage.windowTokens == 3) + #expect(usage.todayTokens == 2) + #expect(usage.todayMessages == 1) +} + +@Test func transcriptReaderMigratesLegacyRecentUsage() async throws { + let root = temporaryDirectory() + let stateURL = root.appendingPathComponent("state.json") + try coverageClaudeState(recent: (fixedNow.addingTimeInterval(-60), 17, 2.5, 1)).write(to: stateURL) + + let snapshot = await ClaudeTranscriptReader(root: root, stateURL: stateURL).refresh(now: fixedNow) + let usage = try #require(snapshot.localUsage(windowResetsAt: nil, windowDuration: 3600, now: fixedNow)) + #expect(usage.windowTokens == 17) + #expect(usage.windowCost == 2.5) + #expect(usage.todayMessages == 1) +} + +@Test func transcriptReaderRemovesOffsetsForMissingFiles() async throws { + let root = temporaryDirectory() + let stateURL = root.appendingPathComponent("state.json") + try coverageClaudeState(offsets: [root.appendingPathComponent("gone.jsonl").path: 10]).write(to: stateURL) + + let reader = ClaudeTranscriptReader(root: root, stateURL: stateURL) + _ = await reader.refresh(now: fixedNow) + #expect((await reader.workload).checkpoints == 1) + + let reopened = ClaudeTranscriptReader(root: root, stateURL: stateURL) + _ = await reopened.refresh(now: fixedNow) + #expect((await reopened.workload).checkpoints == 0) +} + +@Test func transcriptReaderRecoversFromAnOffsetBeyondEndOfFile() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("session.jsonl") + let stateURL = root.appendingPathComponent("state.json") + try Data().write(to: file) + let enumerator = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) + let canonicalFile = try #require(enumerator.nextObject() as? URL) + try coverageClaudeState(offsets: [canonicalFile.path: 100]).write(to: stateURL) + let reader = ClaudeTranscriptReader( + root: root, stateURL: stateURL, fileScanInterval: 300, checkpointInterval: 0) + #expect(await reader.refresh(now: fixedNow).messageCount == 0) + #expect((await reader.workload).filesOpened == 0) + let stored = try #require(try JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let offsets = try #require(stored["offsets"] as? [String: Any]) + let offset = try #require(offsets[canonicalFile.path] as? [String: Any]) + #expect((offset["bytes"] as? NSNumber)?.intValue == 0) + + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data((coverageClaudeLine(id: "after-reset", at: fixedNow, input: 4) + "\n").utf8)) + try handle.close() + #expect(await reader.refresh(now: fixedNow.addingTimeInterval(60)).messageCount == 1) +} + +@Test func transcriptReaderResumesAfterAnOversizedUnterminatedLine() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("session.jsonl") + let record = coverageClaudeLine(id: "valid", at: fixedNow, input: 5) + try String(repeating: "x", count: record.utf8.count + 1).write( + to: file, atomically: true, encoding: .utf8) + let reader = ClaudeTranscriptReader( + root: root, fileScanInterval: 300, maximumLineBytes: record.utf8.count, + maximumRetainedPartialBytes: record.utf8.count) + #expect(await reader.refresh(now: fixedNow).messageCount == 0) + + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data(("\n" + record + "\n").utf8)) + try handle.close() + #expect(await reader.refresh(now: fixedNow.addingTimeInterval(60)).messageCount == 1) +} + +@Test func transcriptReaderEvictsTheLargestOtherPartialLine() async throws { + let root = temporaryDirectory() + for name in ["a", "b", "c"] { + try "data".write( + to: root.appendingPathComponent("\(name).jsonl"), atomically: true, encoding: .utf8) + } + let reader = ClaudeTranscriptReader( + root: root, maximumLineBytes: 8, maximumRetainedPartialBytes: 8) + _ = await reader.refresh(now: fixedNow) + let workload = await reader.workload + #expect(workload.retainedPartialFiles == 2) + #expect(workload.retainedPartialBytes == 8) +} + +@Test func rolloutReaderRebuildsCandidatesAfterACachedFileDisappears() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("rollout-current.jsonl") + try (coverageRolloutLine(primary: 7) + "\n").write(to: file, atomically: true, encoding: .utf8) + let reader = CodexRolloutReader(sessionsRoot: root, cacheInterval: 300) + #expect(await reader.latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 7) + + try FileManager.default.removeItem(at: file) + #expect(await reader.latest(now: fixedNow.addingTimeInterval(60)) == nil) + let workload = await reader.workload + #expect(workload.treesScanned == 2) + #expect(workload.searchesCompleted == 2) +} + +@Test func rolloutReaderReusesCandidatesAfterTheClockMovesBackward() async throws { + let root = temporaryDirectory() + let file = root.appendingPathComponent("rollout-current.jsonl") + try (coverageRolloutLine(primary: 7) + "\n").write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: fixedNow], ofItemAtPath: file.path) + let reader = CodexRolloutReader(sessionsRoot: root, cacheInterval: 300) + #expect(await reader.latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 7) + + try (coverageRolloutLine(primary: 42) + "\n").write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: fixedNow.addingTimeInterval(1)], ofItemAtPath: file.path) + #expect( + await reader.latest(now: fixedNow.addingTimeInterval(-100))?.rateLimit.primaryWindow?.usedPercent == 42) + #expect(await reader.latest(now: fixedNow.addingTimeInterval(250))?.rateLimit.primaryWindow?.usedPercent == 42) + #expect((await reader.workload).treesScanned == 1) +} + +@Test func rolloutReaderSkipsAFileThatCannotBeOpened() async throws { + let root = temporaryDirectory() + let readable = root.appendingPathComponent("rollout-readable.jsonl") + try (coverageRolloutLine(primary: 7) + "\n").write(to: readable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: fixedNow.addingTimeInterval(-1)], ofItemAtPath: readable.path) + let unreadable = root.appendingPathComponent("rollout-unreadable.jsonl") + try (coverageRolloutLine(primary: 99) + "\n").write(to: unreadable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: fixedNow, .posixPermissions: 0o000], ofItemAtPath: unreadable.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: unreadable.path) } + + let reader = CodexRolloutReader(sessionsRoot: root) + #expect(await reader.latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 7) + #expect((await reader.workload).filesOpened == 1) +} + +private func coverageClaudeState( + offsets: [String: Int] = [:], recent: (Date, Int, Double, Int)? = nil +) throws -> Data { + var days: [String: Any] = [:] + var seenByDay: [String: Any] = [:] + var recentByMinute: [String: Any] = [:] + if let (timestamp, tokens, cost, messages) = recent { + let day = DayStamp.string(timestamp) + days[day] = ["models": [:], "messages": messages, "sessions": ["session"], "toolCalls": 0] + seenByDay[day] = ["message"] + recentByMinute[String(Int64(timestamp.timeIntervalSince1970 / 60) * 60)] = [ + "timestamp": timestamp.timeIntervalSinceReferenceDate, + "tokens": tokens, + "cost": cost, + "messages": messages, + ] + } + return try JSONSerialization.data(withJSONObject: [ + "offsets": offsets, + "seenByDay": seenByDay, + "days": days, + "recent": recentByMinute, + ]) +} + +private func coverageClaudeLine(id: String, at date: Date, input: Int) -> String { + #"{"type":"assistant","uuid":"u-\#(id)","requestId":"request","sessionId":"session","# + + #""timestamp":"\#(ISODate.string(date))","message":{"id":"\#(id)","model":"claude-haiku-4-5","# + + #""content":[],"usage":{"input_tokens":\#(input),"output_tokens":0,"cache_creation_input_tokens":0,"# + + #""cache_read_input_tokens":0}}}"# +} + +private func coverageRolloutLine(primary: Double) -> String { + #"{"timestamp":"2026-08-29T10:00:00.000Z","rate_limits":{"primary":{"used_percent":\#(primary),"# + + #""window_minutes":300},"rate_limit_reached_type":null}}"# +} diff --git a/Tests/TokenMenuBarCoreTests/UsageHistoryStoreTests.swift b/Tests/TokenMenuBarCoreTests/UsageHistoryStoreTests.swift new file mode 100644 index 0000000..23502d6 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/UsageHistoryStoreTests.swift @@ -0,0 +1,529 @@ +import Foundation +import Testing + +@testable import TokenMenuBarCore + +@Test func historyRecordsThrottlesAndReloads() async throws { + let url = temporaryDirectory().appendingPathComponent("history/usage.sqlite") + let store = try UsageHistoryStore(url: url) + #expect(store.location == url) + #expect(try await store.record(snapshot(10), now: fixedNow) == 2) + #expect(try await store.record(snapshot(11), now: fixedNow.addingTimeInterval(60)) == 0) + #expect(try await store.record(snapshot(20), now: fixedNow.addingTimeInterval(120)) == 2) + #expect(try await store.record(snapshot(20, resets: 7200), now: fixedNow.addingTimeInterval(180)) == 1) + #expect(try await store.record(snapshot(21, resets: 7200), now: fixedNow.addingTimeInterval(600)) == 2) + let samples = try await store.samples(from: .distantPast, to: .distantFuture) + #expect(samples.count == 7) + #expect(samples.first?.usedPercent == 10) + let reopened = try UsageHistoryStore(url: url) + #expect(try await reopened.record(snapshot(21, resets: 7200), now: fixedNow.addingTimeInterval(660)) == 0) + #expect(try await reopened.record(snapshot(30, resets: 7200), now: fixedNow.addingTimeInterval(700)) == 1) +} + +private func snapshot( + _ percent: Double, resets: TimeInterval = 3600, at date: Date = fixedNow, provider: ProviderID = .claude +) -> ProviderSnapshot { + ProviderSnapshot( + provider: provider, + windows: [ + QuotaWindow( + id: "session", label: "Current session", group: .session, usedPercent: percent, + resetsAt: date.addingTimeInterval(resets), duration: 18000), + QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: percent / 2, resetsAt: nil), + ], + fetchedAt: date + ) +} + +@Test func historyQueriesFilterByKeysAndRange() async throws { + let store = try UsageHistoryStore(url: nil) + try await store.record(snapshot(10), now: fixedNow) + try await store.record(snapshot(40, provider: .codex), now: fixedNow.addingTimeInterval(10)) + let key = WindowKey(provider: .codex, windowID: "session") + let codex = try await store.samples(keys: [key], from: .distantPast, to: .distantFuture) + #expect(codex.map(\.key) == [key]) + #expect(codex[0].usedPercent == 40) + #expect(codex[0].resetsAt == fixedNow.addingTimeInterval(3600)) + #expect(try await store.samples(from: fixedNow.addingTimeInterval(100), to: .distantFuture).isEmpty) + #expect(try await store.recentSamples(key: key, since: fixedNow.addingTimeInterval(5)).count == 1) + let summaries = try await store.summaries() + #expect(summaries.map(\.key.storageKey) == ["claude:session", "claude:weekly", "codex:session", "codex:weekly"]) + #expect(summaries[0].label == "Current session") + #expect(summaries[0].id == summaries[0].key) + #expect(summaries[0].lastPercent == 10) + #expect(try await store.earliestSample() == fixedNow) + let stats = try await store.stats() + #expect( + stats == HistoryStats(sampleCount: 4, analyticsCount: 0, oldest: fixedNow, newest: fixedNow.addingTimeInterval(10))) + #expect(try await store.lastUsageDates(keys: [], from: .distantPast, to: .distantFuture).isEmpty) +} + +@Test func historyQueriesLastUseFromTheFirstPositiveSampleIncreasesAndResets() async throws { + let store = try UsageHistoryStore(url: nil) + let start = fixedNow.addingTimeInterval(-500) + let stamps = (0..<6).map { start.addingTimeInterval(Double($0) * 60) } + let resets = [600.0, 600, 600, 1200, 1200, 1200] + let percents = [80.0, 70, 60, 60, 65, 0] + try await store.seed( + zip(stamps, zip(percents, resets)).map { stamp, values in + (snapshot(values.0, resets: values.1, at: stamp), stamp) + }) + let session = WindowKey(provider: .claude, windowID: "session") + + let dates = try await store.lastUsageDates(keys: [session, session], from: stamps[1], to: stamps[5]) + let samples = try await store.samples(keys: [session], from: stamps[1], to: stamps[5]) + + #expect(dates == [session: stamps[4]]) + #expect(dates == SettingsModelPresentation.lastUsageDates(samples)) + #expect(try await store.lastUsageDates(keys: [session], from: stamps[5], to: stamps[5]).isEmpty) +} + +@Test func historyLastUseQueryReturnsAtMostOneRowPerRequestedKeyAcrossALargeRange() async throws { + let store = try UsageHistoryStore(url: nil) + let keys = (0..<13).map { WindowKey(provider: .codex, windowID: "model-\($0)") } + let snapshots = (0..<2400).map { step -> (ProviderSnapshot, Date) in + let stamp = fixedNow.addingTimeInterval(Double(step - 2399) * UsageHistoryStore.sampleInterval) + return ( + ProviderSnapshot( + provider: .codex, + windows: keys.map { + QuotaWindow( + id: $0.windowID, label: $0.windowID, group: .other, usedPercent: Double(step % 100), resetsAt: nil) + }, + fetchedAt: stamp), + stamp + ) + } + try await store.seed(snapshots) + #expect(try await store.stats().sampleCount == 31_200) + + let dates = try await store.lastUsageDates( + keys: keys, from: snapshots[0].1, to: snapshots[snapshots.count - 1].1) + + #expect(dates.count == keys.count) + #expect(Set(dates.keys) == Set(keys)) + #expect(Set(dates.values) == [snapshots[snapshots.count - 1].1]) +} + +@Test func historyStoresAnalyticsIdempotently() async throws { + let store = try UsageHistoryStore(url: nil) + let analytics = ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: "2026-08-28", metric: .turns, series: "model:a", value: 3), + AnalyticsPoint(day: "2026-08-29", metric: .turns, series: "model:a", value: 5), + ], + fetchedAt: fixedNow + ) + #expect(try await store.record(analytics) == 2) + #expect( + try await store.record( + ProviderAnalytics( + provider: .codex, points: [AnalyticsPoint(day: "2026-08-29", metric: .turns, series: "model:a", value: 9)], + fetchedAt: fixedNow)) == 1) + #expect(try await store.analytics(provider: .codex, from: "2026-08-01", to: "2026-08-31").map(\.value) == [3, 9]) + #expect(try await store.analytics(provider: .claude, from: "2026-08-01", to: "2026-08-31").isEmpty) + #expect(try await store.stats().analyticsCount == 2) +} + +@Test func historyAggregatesDuplicateAnalyticsRows() async throws { + let store = try UsageHistoryStore(url: nil) + #expect( + try await store.record( + ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: "2026-08-29", metric: .pluginInvocations, series: "github", value: 7), + AnalyticsPoint(day: "2026-08-29", metric: .pluginInvocations, series: "github", value: 3), + ], fetchedAt: fixedNow)) == 1) + let points = try await store.analytics(provider: .codex, from: "2026-08-29", to: "2026-08-29") + #expect(points.map(\.value) == [10]) +} + +@Test func historyReplacesProviderAnalyticsWhenTheAccountChangesAfterRelaunch() async throws { + let url = temporaryDirectory().appendingPathComponent("usage.sqlite") + var store: UsageHistoryStore? = try UsageHistoryStore(url: url) + try await store?.record( + ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: "2026-08-28", metric: .turns, series: "account-a-only", value: 4), + AnalyticsPoint(day: "2026-08-29", metric: .turns, series: "total", value: 3), + ], + fetchedAt: fixedNow, + accountFingerprint: "account-a")) + store = nil + + let reopened = try UsageHistoryStore(url: url) + try await reopened.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: "2026-08-29", metric: .turns, series: "total", value: 9)], + fetchedAt: fixedNow, + accountFingerprint: "account-b")) + + let points = try await reopened.analytics(provider: .codex, from: "2026-08-01", to: "2026-08-31") + #expect(points.map(\.day) == ["2026-08-29"]) + #expect(points.map(\.series) == ["total"]) + #expect(points.map(\.value) == [9]) +} + +@Test func historyPreservesLegacyAnalyticsWhenAttachingTheFirstAccount() async throws { + let url = temporaryDirectory().appendingPathComponent("usage.sqlite") + var store: UsageHistoryStore? = try UsageHistoryStore(url: url) + try await store?.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: "2026-08-28", metric: .turns, series: "total", value: 3)], + fetchedAt: fixedNow)) + store = nil + + let reopened = try UsageHistoryStore(url: url) + try await reopened.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: "2026-08-29", metric: .turns, series: "total", value: 9)], + fetchedAt: fixedNow, + accountFingerprint: "account-a")) + + #expect( + try await reopened.analytics(provider: .codex, from: "2026-08-01", to: "2026-08-31").map(\.value) == [3, 9]) +} + +@Test func historyCannotReinsertAnalyticsPastRetention() async throws { + let store = try UsageHistoryStore(url: nil, retentionDays: 60) + let old = AnalyticsPoint(day: "2026-08-01", metric: .turns, series: "total", value: 1) + try await store.record(ProviderAnalytics(provider: .codex, points: [old], fetchedAt: fixedNow)) + #expect(try await store.stats().analyticsCount == 1) + + await store.setRetentionDays(7) + #expect(try await store.record(ProviderAnalytics(provider: .codex, points: [old], fetchedAt: fixedNow)) == 0) + #expect(try await store.analytics(provider: .codex, from: "2026-01-01", to: "2026-12-31").isEmpty) +} + +@Test func historyFiltersAnalyticsByMetricProviderAndDay() async throws { + let store = try UsageHistoryStore(url: nil) + try await store.record( + ProviderAnalytics( + provider: .claude, + points: [ + AnalyticsPoint(day: "2026-08-01", metric: .inputTokens, series: "a", value: 1), + AnalyticsPoint(day: "2026-08-02", metric: .turns, series: "ignored", value: 2), + ], fetchedAt: fixedNow)) + try await store.record( + ProviderAnalytics( + provider: .codex, + points: [ + AnalyticsPoint(day: "2026-08-02", metric: .inputTokens, series: "b", value: 3), + AnalyticsPoint(day: "2026-08-03", metric: .inputTokens, series: "late", value: 4), + ], fetchedAt: fixedNow)) + + let rows = try await store.analytics( + metric: .inputTokens, providers: [.claude, .codex], from: "2026-08-01", to: "2026-08-02") + #expect(rows.map(\.provider) == [.claude, .codex]) + #expect(rows.map(\.point.value) == [1, 3]) + #expect( + try await store.earliestAnalytics(metric: .inputTokens, providers: [.claude, .codex]) == DayStamp.date("2026-08-01") + ) + #expect(try await store.earliestAnalytics(metric: .credits, providers: [.claude, .codex]) == nil) + #expect(try await store.analytics(metric: .inputTokens, providers: [], from: "a", to: "z").isEmpty) + #expect(try await store.earliestAnalytics(metric: .inputTokens, providers: []) == nil) +} + +@Test func historyExportsCSVAndClears() async throws { + let store = try UsageHistoryStore(url: nil) + try await store.record(snapshot(12.5), now: fixedNow) + try await store.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .turns, series: "total", value: 3)], + fetchedAt: fixedNow)) + let url = temporaryDirectory().appendingPathComponent("history.csv") + try await store.exportCSV(to: url) + let lines = try String(contentsOf: url, encoding: .utf8).split(separator: "\n") + #expect(lines[0] == "kind,timestamp,key,label,used_percent,resets_at,provider,day,metric,series,value") + #expect(lines[1].hasPrefix("sample,2026-08-29T")) + #expect(lines[1].contains(",claude:session,Current session,12.50,2026-08-29T")) + #expect(lines[2].contains(",claude:weekly,Weekly,6.25,")) + #expect(lines[3] == "analytics,,,,,,codex,2026-08-29,turns,total,3.0000") + #expect(try await store.clear() == 2) + #expect(try await store.stats().sampleCount == 0) + #expect(try await store.stats().analyticsCount == 0) + #expect(try await store.record(snapshot(1), now: fixedNow) == 2) +} + +@Test func historyExportStreamsMoreThanOneChunk() async throws { + let store = try UsageHistoryStore(url: nil) + // Enough rows to spill past the write buffer, so the export is exercised as the several writes it becomes. + try await store.seed( + (0..<4000).map { step in + let stamp = fixedNow.addingTimeInterval(-Double(step) * 300) + return (snapshot(Double(step % 100), at: stamp), stamp) + }) + let url = temporaryDirectory().appendingPathComponent("large.csv") + try await store.exportCSV(to: url) + let text = try String(contentsOf: url, encoding: .utf8) + #expect(text.utf8.count > 256 * 1024) + let lines = text.split(separator: "\n") + #expect(lines.count == 8001) + #expect(lines[0] == "kind,timestamp,key,label,used_percent,resets_at,provider,day,metric,series,value") + #expect(lines.last!.contains(",claude:")) +} + +@Test func historyExportsTheSelectedMetricAndPeriod() async throws { + let store = try UsageHistoryStore(url: nil) + try await store.record(snapshot(12.5), now: fixedNow) + try await store.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .turns, series: "m,1", value: 3)], + fetchedAt: fixedNow)) + let sampleURL = temporaryDirectory().appendingPathComponent("selected-samples.csv") + let session = WindowKey(provider: .claude, windowID: "session") + try await store.exportCSV( + to: sampleURL, metric: .windowUsagePercent, from: fixedNow.addingTimeInterval(-1), + to: fixedNow.addingTimeInterval(1), keys: [session]) + let sampleLines = try String(contentsOf: sampleURL, encoding: .utf8).split(separator: "\n") + #expect(sampleLines.count == 2) + #expect(sampleLines[1].contains("claude:session")) + + let analyticsURL = temporaryDirectory().appendingPathComponent("selected-analytics.csv") + try await store.exportCSV( + to: analyticsURL, metric: .analytics(.turns), from: fixedNow.addingTimeInterval(-1), + to: fixedNow.addingTimeInterval(1)) + let analyticsText = try String(contentsOf: analyticsURL, encoding: .utf8) + #expect(analyticsText.contains("provider,day,metric,series,value")) + #expect(analyticsText.contains("codex,\(DayStamp.string(fixedNow)),turns,\"m,1\",3.0000")) +} + +@Test func historyExportsOnlyTheHeaderWhenNoWindowsAreSelected() async throws { + let store = try UsageHistoryStore(url: nil) + try await store.record(snapshot(12.5), now: fixedNow) + let url = temporaryDirectory().appendingPathComponent("no-selected-samples.csv") + + try await store.exportCSV( + to: url, metric: .windowUsagePercent, from: fixedNow.addingTimeInterval(-1), + to: fixedNow.addingTimeInterval(1), keys: []) + + #expect(try String(contentsOf: url, encoding: .utf8) == "timestamp,key,label,used_percent,resets_at\n") +} + +@Test func historyQueriesAndExportsRespondToCancellation() async throws { + let store = try UsageHistoryStore(url: nil) + try await store.seed( + (0..<200).map { step in + let stamp = fixedNow.addingTimeInterval(-Double(step) * 300) + return (snapshot(Double(step % 100), at: stamp), stamp) + }) + + let samples = Task { + try await store.samples(from: fixedNow.addingTimeInterval(-200 * 300), to: fixedNow) + } + samples.cancel() + await expectCancellation(samples) + + let fullURL = temporaryDirectory().appendingPathComponent("cancelled-full.csv") + let fullExport = Task { try await store.exportCSV(to: fullURL) } + fullExport.cancel() + await expectCancellation(fullExport) + + let selectedURL = temporaryDirectory().appendingPathComponent("cancelled-selected.csv") + let selectedExport = Task { + try await store.exportCSV( + to: selectedURL, metric: .windowUsagePercent, from: fixedNow.addingTimeInterval(-200 * 300), to: fixedNow) + } + selectedExport.cancel() + await expectCancellation(selectedExport) +} + +private func expectCancellation(_ task: Task) async { + do { + _ = try await task.value + Issue.record("expected cancellation") + } catch is CancellationError { + return + } catch { + Issue.record("expected CancellationError, got \(error)") + } +} + +@Test func historyRollsSamplesUpInTheDatabase() async throws { + let store = try UsageHistoryStore(url: nil) + // Four samples inside one hour, one in the next. + let stamps = [0.0, 600, 1200, 1800, 3900].map { fixedNow.addingTimeInterval($0) } + try await store.seed(stamps.enumerated().map { index, stamp in (snapshot(Double(index) * 10, at: stamp), stamp) }) + + let all = try await store.samples(from: .distantPast, to: .distantFuture) + #expect(all.count == 10) + + // An hourly rollup keeps the newest sample of each window in each hour, which is what the chart draws. + let hourly = try await store.samples(from: .distantPast, to: .distantFuture, rollup: 3600) + #expect(hourly.count == 4) + #expect(hourly.map(\.timestamp) == [stamps[3], stamps[3], stamps[4], stamps[4]]) + #expect(hourly.filter { $0.key.windowID == "session" }.map(\.usedPercent) == [30, 40]) +} + +@Test func historyMinuteRollupKeepsTheNewestSampleInEachMinute() async throws { + let store = try UsageHistoryStore(url: nil) + let minute = Date(timeIntervalSince1970: (fixedNow.timeIntervalSince1970 / 60).rounded(.down) * 60) + let stamps = [minute.addingTimeInterval(5), minute.addingTimeInterval(45), minute.addingTimeInterval(65)] + try await store.seed(stamps.enumerated().map { (snapshot(Double($0) * 10, at: $1), $1) }) + + let samples = try await store.samples( + from: minute, to: minute.addingTimeInterval(120), rollup: Rollup.minute.seconds, + timeZone: TimeZone(identifier: "UTC")!) + + #expect(samples.count == 4) + #expect(samples.filter { $0.key.windowID == "session" }.map(\.timestamp) == [stamps[1], stamps[2]]) + #expect(samples.filter { $0.key.windowID == "session" }.map(\.usedPercent) == [10, 20]) +} + +@Test func historyRollupUsesTheOffsetAtEachSample() async throws { + let store = try UsageHistoryStore(url: nil) + let before = ISODate.parse("2026-03-07T06:30:00Z")! + let after = ISODate.parse("2026-03-07T07:30:00Z")! + try await store.seed([(snapshot(10, at: before), before), (snapshot(20, at: after), after)]) + + let samples = try await store.samples( + from: before.addingTimeInterval(-1), to: after.addingTimeInterval(1), rollup: Rollup.day.seconds, + timeZone: TimeZone(identifier: "America/Los_Angeles")!) + #expect(samples.count == 2) + #expect(samples.allSatisfy { $0.timestamp == after }) + #expect(samples.filter { $0.key.windowID == "session" }.map(\.usedPercent) == [20]) +} + +@Test func historyRollupSplitsTheQueryAtADaylightSavingTransition() async throws { + let store = try UsageHistoryStore(url: nil) + let before = ISODate.parse("2026-03-08T09:30:00Z")! + let after = ISODate.parse("2026-03-08T10:30:00Z")! + try await store.seed([(snapshot(10, at: before), before), (snapshot(20, at: after), after)]) + + let samples = try await store.samples( + from: ISODate.parse("2026-03-08T08:00:00Z")!, to: ISODate.parse("2026-03-08T12:00:00Z")!, + rollup: Rollup.day.seconds, timeZone: TimeZone(identifier: "America/Los_Angeles")!) + + #expect(samples.count == 2) + #expect(samples.allSatisfy { $0.timestamp == after }) +} + +@Test func historyPrunesOldRows() async throws { + let store = try UsageHistoryStore(url: nil) + try await store.record(snapshot(10, at: fixedNow), now: fixedNow) + let later = fixedNow.addingTimeInterval(UsageHistoryStore.retention + 7200) + try await store.record(snapshot(20, at: later), now: later) + #expect(try await store.samples(from: .distantPast, to: .distantFuture).map(\.usedPercent) == [20, 10]) + let muchLater = later.addingTimeInterval(7200) + try await store.record(snapshot(30, at: muchLater), now: muchLater) + #expect(try await store.samples(from: .distantPast, to: .distantFuture).count == 4) +} + +@Test func historyUsesTheConfiguredRetentionPeriod() async throws { + let store = try UsageHistoryStore(url: nil, retentionDays: 1) + #expect(await store.retentionDays == 7) + try await store.record(snapshot(10, at: fixedNow), now: fixedNow) + let later = fixedNow.addingTimeInterval(8 * 86400) + try await store.record(snapshot(20, at: later), now: later) + #expect(try await store.samples(from: .distantPast, to: .distantFuture).count == 2) + await store.setRetentionDays(999) + #expect(await store.retentionDays == 365) +} + +@Test func historyAppliesShorterRetentionAtomically() async throws { + let store = try UsageHistoryStore(url: nil, retentionDays: 60) + let old = fixedNow.addingTimeInterval(-30 * 86400) + try await store.seed([(snapshot(10, at: old), old)]) + try await store.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: DayStamp.string(old), metric: .turns, series: "total", value: 2)], + fetchedAt: fixedNow)) + + let pruned = try await store.setRetentionDays(7, now: fixedNow) + + #expect(pruned == HistoryPruneResult(samples: 2, analytics: 1)) + #expect(try await store.stats().sampleCount == 0) + #expect(try await store.stats().analyticsCount == 0) +} + +@Test func historyCancelledRetentionDoesNotPrune() async throws { + let store = try UsageHistoryStore(url: nil, retentionDays: 60) + let old = fixedNow.addingTimeInterval(-30 * 86400) + try await store.seed([(snapshot(10, at: old), old)]) + try await store.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: DayStamp.string(old), metric: .turns, series: "total", value: 2)], + fetchedAt: fixedNow)) + let retention = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await store.setRetentionDays(7, now: fixedNow) + } + + await expectCancellation(retention) + + #expect(await store.retentionDays == 60) + #expect(try await store.stats().sampleCount == 2) + #expect(try await store.stats().analyticsCount == 1) +} + +@Test func historySampleRangesCanExcludeTheEndBoundary() async throws { + let store = try UsageHistoryStore(url: nil) + try await store.seed([(snapshot(10, at: fixedNow), fixedNow)]) + + let excluded = try await store.samples( + from: fixedNow.addingTimeInterval(-60), to: fixedNow, includesEnd: false) + let included = try await store.samples( + from: fixedNow.addingTimeInterval(-60), to: fixedNow, includesEnd: true) + + #expect(excluded.isEmpty) + #expect(included.count == 2) +} + +@Test func historyRejectsUnopenablePath() { + #expect(throws: (any Error).self) { + try UsageHistoryStore(url: URL(fileURLWithPath: "/dev/null/impossible/usage.sqlite")) + } +} + +@Test func historyShouldRecordRules() { + let key = WindowKey(provider: .claude, windowID: "session") + let base = UsageSample(timestamp: fixedNow, key: key, usedPercent: 10, resetsAt: fixedNow) + #expect(UsageHistoryStore.shouldRecord(base, after: nil)) + #expect( + !UsageHistoryStore.shouldRecord( + UsageSample(timestamp: fixedNow.addingTimeInterval(10), key: key, usedPercent: 12, resetsAt: fixedNow), + after: base)) + #expect( + UsageHistoryStore.shouldRecord( + UsageSample(timestamp: fixedNow.addingTimeInterval(10), key: key, usedPercent: 15, resetsAt: fixedNow), + after: base)) + #expect( + UsageHistoryStore.shouldRecord( + UsageSample(timestamp: fixedNow.addingTimeInterval(10), key: key, usedPercent: 10, resetsAt: nil), after: base)) + #expect( + UsageHistoryStore.shouldRecord( + UsageSample(timestamp: fixedNow.addingTimeInterval(400), key: key, usedPercent: 10, resetsAt: fixedNow), + after: base)) +} + +@Test func sqliteWrapperReportsErrors() throws { + #expect(throws: SQLiteError.self) { try SQLiteDatabase(path: "/dev/null/nope.sqlite") } + let database = try SQLiteDatabase(path: ":memory:") + #expect(throws: SQLiteError.self) { try database.execute("NOT SQL") } + try database.execute("CREATE TABLE t (a INTEGER, b REAL, c TEXT, d REAL)") + try database.execute("INSERT INTO t VALUES (?, ?, ?, ?)", [.integer(1), .real(2.5), .text("x"), .null]) + let rows = try database.query("SELECT a, b, c, d FROM t") { + ($0.int(0), $0.double(1), $0.text(2), $0.double(3), $0.date(3)) + } + #expect(rows.count == 1) + #expect(rows[0].0 == 1) + #expect(rows[0].1 == 2.5) + #expect(rows[0].2 == "x") + #expect(rows[0].3 == nil) + #expect(rows[0].4 == nil) + #expect(database.changes == 1) + #expect(throws: SQLiteError.self) { try database.execute("INSERT INTO missing VALUES (1)") } + #expect(throws: SQLiteError.self) { try database.execute("INSERT INTO t VALUES (?, ?, ?, ?, ?)", [.integer(1)]) } + #expect(SQLiteValue(nil as Double?) == .null) + #expect(SQLiteValue(fixedNow) == .real(fixedNow.timeIntervalSince1970)) +} diff --git a/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift b/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift new file mode 100644 index 0000000..30c6881 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test func widgetSnapshotBuildsRowsFromSelection() { + let snapshot = DemoData.snapshot(.claude, now: fixedNow) + let keys = [ + WindowKey(provider: .claude, windowID: "session"), WindowKey(provider: .claude, windowID: "missing"), + WindowKey(provider: .codex, windowID: "weekly"), + ] + let widget = WidgetSnapshot.build( + snapshots: [.claude: snapshot], availability: [.codex: .authenticationRequired], selectedKeys: keys, now: fixedNow) + #expect(widget.rows.map(\.key) == [keys[0]]) + #expect(widget.rows[0].providerName == "Claude") + #expect(widget.rows[0].label == "Current session") + #expect(widget.rows[0].percentText == Format.percent(snapshot.windows[0].usedPercent)) + #expect(widget.rows[0].resetText(now: fixedNow) == Format.countdown(to: snapshot.windows[0].resetsAt, now: fixedNow)) + #expect(widget.attention) + #expect(widget.updatedAt == fixedNow) + #expect(widget.isStale) + #expect(!WidgetSnapshot.placeholder.isStale) + #expect(WidgetSnapshot.placeholder.rows.count == 3) + #expect(widget.rows[0].id == keys[0]) +} + +@Test func widgetStoreRoundTripsAndResolvesSharedURL() throws { + let root = temporaryDirectory() + let store = WidgetSnapshotStore(url: root.appendingPathComponent("nested/widget.json")) + #expect(store.read() == nil) + let snapshot = WidgetSnapshot.build( + snapshots: [.claude: DemoData.snapshot(.claude, now: fixedNow)], availability: [:], + selectedKeys: [WindowKey(provider: .claude, windowID: "session")], now: fixedNow) + try store.write(snapshot) + #expect(store.read()?.rows == snapshot.rows) + #expect(store.read()?.updatedAt == fixedNow) + try Data("{".utf8).write(to: store.url) + #expect(store.read() == nil) + let shared = WidgetSnapshotStore.sharedURL( + containerURL: { URL(fileURLWithPath: "/container/\($0)") }, fallbackDirectory: root) + #expect(shared.path == "/container/\(WidgetSnapshot.appGroup)/widget.json") + let fallback = WidgetSnapshotStore.sharedURL(containerURL: { _ in nil }, fallbackDirectory: root) + #expect(fallback == root.appendingPathComponent("widget.json")) + #expect(WidgetSnapshot.appGroup(info: nil) == WidgetSnapshot.appGroup) + #expect(WidgetSnapshot.appGroup(info: ["TokenMenuBarAppGroup": "$(APP_GROUP_ID)"]) == WidgetSnapshot.appGroup) + #expect(WidgetSnapshot.appGroup(info: ["TokenMenuBarAppGroup": ""]) == WidgetSnapshot.appGroup) + #expect(WidgetSnapshot.appGroup(info: ["TokenMenuBarAppGroup": "TEAM.dev.tox"]) == "TEAM.dev.tox") + let unwritable = WidgetSnapshotStore(url: URL(fileURLWithPath: "/dev/null/widget.json")) + #expect(throws: (any Error).self) { try unwritable.write(snapshot) } +} diff --git a/Tests/TokenMenuBarUITests/AdapterTests.swift b/Tests/TokenMenuBarUITests/AdapterTests.swift new file mode 100644 index 0000000..304587a --- /dev/null +++ b/Tests/TokenMenuBarUITests/AdapterTests.swift @@ -0,0 +1,75 @@ +import Foundation +import ServiceManagement +import Testing +import UserNotifications + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func notifierDeliversWhenAuthorized() async { + let center = FakeNotificationCenter() + let log = makeLog() + log.debugEnabled = true + let notifier = Notifier(center: center, log: log) + let held = event(.credits, id: "held") + await notifier.deliver([held]) + #expect(center.requests.isEmpty) + #expect(notifier.pending.count == 1) + await notifier.requestAuthorization() + #expect(notifier.authorized) + #expect(log.text.contains("notifications authorized=true")) + #expect(log.text.contains("flushing 1 notifications")) + // the event held during the prompt is delivered once authorization lands + #expect(center.requests.map(\.identifier) == [held.id]) + #expect(notifier.pending.isEmpty) + await notifier.deliver([event(.threshold, id: "a"), event(.threshold, id: "b")]) + #expect(center.requests.map(\.identifier).suffix(2) == ["a", "b"]) + #expect(center.requests[0].content.threadIdentifier == "claude") + let otherWindow = NotificationEvent( + id: "other", kind: .threshold, provider: .claude, window: WindowKey(provider: .claude, windowID: "weekly"), + title: "t", body: "b") + await notifier.deliver([otherWindow]) + await notifier.deliver([event(.reset, id: "r")]) + #expect(Set(center.removed) == ["a", "b"]) + #expect(notifier.delivered.contains { $0.id == "other" }) + #expect(notifier.delivered.map(\.kind) == [.credits, .threshold, .reset]) +} + +private func event(_ kind: NotificationEvent.Kind, id: String = UUID().uuidString) -> NotificationEvent { + NotificationEvent( + id: id, kind: kind, provider: .claude, window: WindowKey(provider: .claude, windowID: "session"), title: "t", + body: "b") +} + +@Test @MainActor func notifierHandlesErrorsAndMissingCenter() async { + let none = Notifier(center: nil, log: makeLog()) + await none.requestAuthorization() + await none.deliver([event(.credits)]) + #expect(!none.authorized) + let failing = FakeNotificationCenter() + failing.authorizationError = TestError() + let log = makeLog() + let notifier = Notifier(center: failing, log: log) + await notifier.requestAuthorization() + #expect(!notifier.authorized) + #expect(log.text.contains("authorization failed")) + failing.authorizationError = nil + failing.authorize = false + await notifier.requestAuthorization() + #expect(!notifier.authorized) + failing.authorize = true + await notifier.requestAuthorization() + failing.addError = TestError() + await notifier.deliver([event(.authentication)]) + #expect(log.text.contains("delivery failed")) +} + +@Test( + arguments: [ + (SMAppService.Status.enabled, LaunchAtLoginBackend.Status.enabled), (.notRegistered, .notRegistered), + (.notFound, .notFound), + (.requiresApproval, .requiresApproval), + ]) +func launchAtLoginServiceMapsStatuses(status: SMAppService.Status, expected: LaunchAtLoginBackend.Status) { + #expect(LaunchAtLoginService.status(status) == expected) +} diff --git a/Tests/TokenMenuBarUITests/AppControllerTests.swift b/Tests/TokenMenuBarUITests/AppControllerTests.swift new file mode 100644 index 0000000..cddddec --- /dev/null +++ b/Tests/TokenMenuBarUITests/AppControllerTests.swift @@ -0,0 +1,735 @@ +import AppKit +import Darwin +import Testing +import UserNotifications + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func appControllerStartInstallsTheStatusItemAndRecordsTheUpgrade() throws { + let (controller, dependencies, _) = try startedController() + defer { controller.stop() } + #expect(controller.environment.credentialDescriptions == [.claude: "scripted claude"]) + #expect(controller.environment.canCheckForUpdates) + #expect(controller.statusItem != nil) + #expect(controller.popover != nil) + #expect(dependencies.settings.lastLaunchedVersion == "1.2.3") + #expect(dependencies.log.text.contains("updated from 0.9")) +} + +@MainActor +private func startedController() throws -> (AppController, AppDependencies, Recorder) { + let provider = ScriptedProvider(id: .claude, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude)))) + let (dependencies, recorder) = try makeDependencies(providers: [provider]) + dependencies.settings.setProvider(.claude, enabled: true) + dependencies.settings.lastLaunchedVersion = "0.9" + dependencies.settings.detailedLogging = true + let controller = AppController(dependencies: dependencies) + controller.start() + return (controller, dependencies, recorder) +} + +@Test @MainActor func appControllerRefreshFeedsTheStatusItem() async throws { + let (controller, dependencies, _) = try startedController() + defer { controller.stop() } + await controller.coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + await waitUntil { controller.statusItem?.model.cells.count == dependencies.state.statusModel.cells.count } + #expect(dependencies.state.state(for: .claude).availability == .current) + #expect(!dependencies.state.statusModel.cells.isEmpty) + #expect(controller.statusItem?.model.cells.count == dependencies.state.statusModel.cells.count) +} + +@Test @MainActor func appControllerRefreshesOnlyTheRequestedProvider() async throws { + let providers = [ + ScriptedProvider(id: .claude, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude)))), + ScriptedProvider(id: .codex, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.codex)))), + ] + let (dependencies, _) = try makeDependencies(providers: providers) + let controller = AppController(dependencies: dependencies) + + controller.refreshNow(provider: .claude) + + await waitUntil { dependencies.state.state(for: .claude).snapshot != nil } + #expect(dependencies.state.state(for: .claude).snapshot?.provider == .claude) + #expect(dependencies.state.state(for: .codex).snapshot == nil) +} + +@Test @MainActor func globalRefreshRediscoversBeforeFetching() async throws { + let source = ProviderID.codex.setup.credentialSources[1] + let probe = RediscoveryFetchProbe() + let provider = RediscoveryProvider( + id: .codex, health: .valid(source: source, expiresAt: nil), + result: ProviderFetchResult(outcome: .success(sampleSnapshot(.codex))), probe: probe) + let (dependencies, recorder) = try makeDependencies { + _ in ProviderRegistry([provider]) + } + let controller = AppController(dependencies: dependencies) + + controller.refreshNow() + + await waitUntil { dependencies.state.state(for: .codex).snapshot != nil } + #expect(recorder.rebuilt == 1) + #expect(dependencies.state.state(for: .codex).credentialHealth == .valid(source: source, expiresAt: nil)) + #expect(await probe.fetches == 1) +} + +@Test @MainActor func applicationActivationRediscoversLocallyAndUsesExactProvenance() async throws { + let source = ProviderID.claude.setup.credentialSources[1] + let probe = RediscoveryFetchProbe() + let provider = RediscoveryProvider( + id: .claude, health: .valid(source: source, expiresAt: fixedNow), + result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude))), probe: probe) + let (dependencies, recorder) = try makeDependencies { + _ in ProviderRegistry([provider]) + } + let controller = AppController(dependencies: dependencies) + + controller.handleApplicationActivation() + + await waitUntil { dependencies.state.state(for: .claude).credentialHealth.isUsable } + #expect(recorder.rebuilt == 1) + #expect(dependencies.state.state(for: .claude).credentialHealth == .valid(source: source, expiresAt: fixedNow)) + #expect(await probe.fetches == 0) + + controller.handleApplicationActivation() + await mainActorTurn() + #expect(recorder.rebuilt == 1) +} + +@Test @MainActor func applicationActivationKeepsAnUnauthenticatedProviderHidden() async throws { + let probe = RediscoveryFetchProbe() + let provider = RediscoveryProvider( + id: .gemini, health: .missing(expected: ProviderID.gemini.setup.credentialSources), + result: ProviderFetchResult(outcome: .failed("fetch should not run")), probe: probe) + let (dependencies, _) = try makeDependencies { + _ in ProviderRegistry([provider]) + } + let controller = AppController(dependencies: dependencies) + + controller.handleApplicationActivation() + + await waitUntil { + dependencies.state.state(for: .gemini).credentialHealth + == .missing(expected: ProviderID.gemini.setup.credentialSources) + } + #expect( + ProviderSettingsVisibility.providers( + states: dependencies.state.providers, configured: [], showAll: false + ).isEmpty) + #expect(await probe.fetches == 0) +} + +@Test @MainActor func appControllerTogglesThePopover() async throws { + let (controller, dependencies, _) = try startedController() + defer { controller.stop() } + controller.togglePopover() + #expect(dependencies.state.popoverVisible == controller.popover?.isShown) + #expect(controller.statusItem?.popoverVisible == controller.popover?.isShown) + controller.popover?.close() + await waitUntil { controller.popover?.isShown == false } + #expect(controller.popover?.isShown == false) + #expect(controller.statusItem?.popoverVisible == false) +} + +@Test @MainActor func appControllerReanchorsThePopoverWhenScreenGeometryChanges() async throws { + let (controller, _, _) = try startedController() + defer { controller.stop() } + await waitUntil { controller.statusItem?.buttonFrameOnScreen != nil } + #expect(controller.popover?.maximum.height == CGFloat.greatestFiniteMagnitude) + + NotificationCenter.default.post(name: NSApplication.didChangeScreenParametersNotification, object: nil) + + await waitUntil { controller.popover?.maximum.height.isFinite == true } + #expect(controller.popover?.maximum.height.isFinite == true) +} + +@Test @MainActor func appControllerDefersOpeningUntilTheStatusItemIsAttached() async throws { + let providers = [ + ScriptedProvider(id: .claude, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude)))), + ScriptedProvider(id: .codex, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.codex)))), + ] + let (dependencies, _) = try makeDependencies(providers: providers) + let controller = AppController(dependencies: dependencies) + + controller.start() + controller.statusItem?.visibleItemFrame = { _ in CGRect(x: 10, y: 10, width: 36, height: 24) } + defer { controller.stop() } + + #expect(controller.popover?.isShown == false) + await waitUntil { controller.popover?.isShown == true } + #expect(controller.popover?.isShown == true) +} + +@Test @MainActor func verificationCommandReopensAnOffscreenPopover() async throws { + var (dependencies, _) = try makeDependencies() + dependencies.verificationSession = "offscreen-status-item" + dependencies.recoversOffscreenPopover = true + let controller = AppController(dependencies: dependencies) + controller.start() + controller.statusItem?.visibleItemFrame = { _ in CGRect(x: -1_000, y: -1_000, width: 36, height: 24) } + defer { controller.stop() } + + for _ in 0..<20 where controller.popover?.isShown != true { + DistributedNotificationCenter.default().post( + name: LaunchPolicy.verificationOpenPopoverNotification, + object: dependencies.verificationSession, + userInfo: nil) + try await Task.sleep(for: .milliseconds(50)) + } + + await waitUntil { controller.popover?.isShown == true } + #expect(controller.popover?.isShown == true) +} + +@Test @MainActor func verificationSnapshotCommandFlushesProcessMetrics() async throws { + var (dependencies, _) = try makeDependencies() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("process-snapshot.json") + let expected = ProcessPerformanceSnapshot( + residentMemoryBytes: 12, + physicalFootprintBytes: 10, + cpuNanoseconds: 8) + dependencies.verificationSession = "process-snapshot" + dependencies.verificationSnapshotURL = url + dependencies.captureProcessSnapshot = { expected } + let controller = AppController(dependencies: dependencies) + controller.start() + defer { controller.stop() } + + DistributedNotificationCenter.default().post( + name: LaunchPolicy.verificationSnapshotNotification, + object: dependencies.verificationSession, + userInfo: nil) + + await waitUntil { FileManager.default.fileExists(atPath: url.path) } + let snapshot = try JSONDecoder().decode(ProcessPerformanceSnapshot.self, from: Data(contentsOf: url)) + #expect(snapshot == expected) +} + +@Test @MainActor func appControllerSuspendsPollingWhileTheMacSleeps() async throws { + let (controller, dependencies, _) = try startedController() + defer { controller.stop() } + controller.handleSleep() + #expect(!controller.coordinator.isRunning) + controller.handleWake() + #expect(controller.coordinator.isRunning) + controller.refreshNow() + controller.settingsChanged() + #expect((dependencies.updater as? FakeUpdater)?.automaticallyChecks == true) + NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.willSleepNotification, object: nil) + NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.didWakeNotification, object: nil) + await Task.yield() +} + +@Test @MainActor func appControllerContextMenuItemsRunTheirCommands() throws { + let (controller, dependencies, recorder) = try startedController() + defer { controller.stop() } + let menu = controller.contextMenu() + #expect(menu.items.count == 5) + _ = menu.items[0].target?.perform(menu.items[0].action, with: menu.items[0]) + _ = menu.items[2].target?.perform(menu.items[2].action, with: menu.items[2]) + #expect((dependencies.updater as? FakeUpdater)?.checks == 1) + _ = menu.items[4].target?.perform(menu.items[4].action, with: menu.items[4]) + #expect(recorder.terminated == 1) +} + +@Test @MainActor func appControllerStatusMenuDoesNotRemainAttachedAfterTracking() throws { + let (controller, _, _) = try startedController() + defer { controller.stop() } + let item = try #require(controller.statusItem) + let menu = NSMenu() + + item.show(menu) + + #expect(item.item.menu == nil) +} + +@Test @MainActor func appControllerIgnoresCommandsItDoesNotKnow() throws { + let (controller, _, recorder) = try startedController() + defer { controller.stop() } + controller.menuTarget.run(NSMenuItem(title: "x", action: nil, keyEquivalent: "")) + controller.run("usage:nope") + #expect(recorder.urls.isEmpty) +} + +@Test @MainActor func appControllerAppliesSetupStateWhenProvidersAreRebuilt() throws { + let (controller, dependencies, _) = try startedController() + defer { controller.stop() } + let setup = ProviderSetupState( + enabled: true, + credential: .unreadable(source: nil, detail: "Credential store is unavailable.")) + controller.replaceProviders(ProviderRegistry([], setupStates: [.claude: setup])) + #expect(dependencies.state.state(for: .claude).credentialHealth == setup.credential) +} + +@Test @MainActor func appControllerReleasesReplacedProviderRegistry() throws { + weak var replacedLease: SecurityScopedResourceLease? + let controller = try { () -> AppController in + var dependencies = try makeDependencies().0 + let lease = SecurityScopedResourceLease(url: URL(fileURLWithPath: "/tmp/provider-registry")) { _ in } + replacedLease = lease + dependencies.registry = ProviderRegistry(dependencies.registry.providers, resourceLeases: [lease]) + return AppController(dependencies: dependencies) + }() + + controller.replaceProviders(ProviderRegistry([])) + + #expect(replacedLease == nil) +} + +@Test @MainActor func appControllerResetRestoresAllRuntimeState() async throws { + let (dependencies, recorder) = try makeDependencies(isDemo: true) + let controller = AppController(dependencies: dependencies) + controller.environment.historyPresenter.setMetric(.analytics(.turns)) + controller.environment.historyPresenter.setPeriod(.range(.week)) + dependencies.settings.resetToDefaults() + + await controller.settingsReset() + + #expect(recorder.unregisteredLoginItem == 1) + #expect(recorder.rebuilt == 1) + #expect(controller.dependencies.registry.ids == [.codex]) + #expect(controller.environment.historyPresenter.selectedMetric == .windowUsagePercent) + #expect(controller.environment.historyPresenter.followNow) + #expect(recorder.relaunched == 1) +} + +@Test @MainActor func appControllerPrunesHistoryWhenRetentionChanges() async throws { + let history = try UsageHistoryStore(url: nil) + try await history.record( + sampleSnapshot(.claude), now: fixedNow.addingTimeInterval(-10 * 86_400)) + let (dependencies, _) = try makeDependencies(history: history) + let controller = AppController(dependencies: dependencies) + dependencies.settings.historyRetentionDays = 7 + + controller.settingsChanged() + await waitUntil { dependencies.log.text.contains("history retention updated days=7") } + + #expect(try await history.stats().sampleCount == 0) + await waitUntil { dependencies.state.sampleRevision == 1 } + #expect(dependencies.log.text.contains("history retention updated days=7 removed=3")) +} + +@Test @MainActor func appControllerDoesNotPruneForASupersededRetentionChange() async throws { + let history = try UsageHistoryStore(url: nil) + try await history.record( + sampleSnapshot(.claude), now: fixedNow.addingTimeInterval(-30 * 86_400)) + let (dependencies, _) = try makeDependencies(history: history) + let controller = AppController(dependencies: dependencies) + dependencies.settings.historyRetentionDays = 7 + controller.settingsChanged() + + dependencies.settings.historyRetentionDays = 60 + controller.settingsChanged() + + #expect(try await history.stats().sampleCount == 3) + #expect(await history.retentionDays == 60) +} + +@Test @MainActor func appControllerReportsHistoryRetentionFailures() async throws { + let history = try UsageHistoryStore(url: nil) + try await history.breakDatabase() + let (dependencies, _) = try makeDependencies(history: history) + let controller = AppController(dependencies: dependencies) + dependencies.settings.historyRetentionDays = 7 + + controller.settingsChanged() + + await waitUntil { dependencies.log.text.contains("history retention update failed") } + #expect(dependencies.log.text.contains("history retention update failed")) +} + +@Test @MainActor func appControllerStopReleasesTheStatusItem() throws { + let (controller, dependencies, _) = try startedController() + controller.stop() + #expect(controller.statusItem == nil) + #expect(!controller.coordinator.isRunning) + #expect(dependencies.log.text.contains("stopped")) +} + +@Test @MainActor func appControllerWithoutUpdaterHidesUpdateItems() throws { + let (dependencies, _) = try makeDependencies(updater: nil) + let controller = AppController(dependencies: dependencies) + #expect(!controller.environment.canCheckForUpdates) + #expect(controller.contextMenu().items.map(\.title) == ["Refresh Now", "", "Quit Token Menu Bar"]) + controller.environment.actions.checkForUpdates() + controller.environment.actions.quit() +} + +@Test @MainActor func appControllerActionsRouteToDependencies() async throws { + let (dependencies, recorder) = try makeDependencies() + let controller = AppController(dependencies: dependencies) + let actions = controller.environment.actions + actions.showProviders(.codex) + #expect(dependencies.settings.lastTab == .settings) + #expect(controller.environment.providerFocusRequest?.provider == .codex) + actions.openURL(URL(string: "https://example.com")!) + actions.copy("text") + #expect(recorder.urls.first?.host == "example.com") + #expect(recorder.copied == ["text"]) + await controller.exportHistory().value + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-export-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + recorder.exportURL = directory.appendingPathComponent("history.csv") + await controller.exportHistory().value + #expect(try String(contentsOf: recorder.exportURL!, encoding: .utf8).hasPrefix("kind,timestamp,")) + recorder.exportURL = URL(fileURLWithPath: "/dev/null/impossible.csv") + await controller.exportHistory().value + #expect(dependencies.log.text.contains("export failed")) + try await dependencies.history.record(sampleSnapshot(.claude), now: fixedNow) + await controller.clearHistory().value + #expect(dependencies.log.text.contains("history cleared")) + #expect(dependencies.state.sampleRevision == 1) + actions.exportHistory() + actions.clearHistory() + actions.revealHistory() + #expect(recorder.revealed.isEmpty) + actions.copyDiagnostics() + #expect(recorder.copied.last?.hasPrefix("Token Menu Bar 1.2.3") == true) + actions.reportIssue() + #expect(recorder.urls.last?.path == "/tox-dev/token-menu-bar-macos/issues/new") + actions.showFullLog() + actions.showFullLog() + actions.setLaunchAtLogin(true) + #expect(controller.environment.launchAtLoginStatus == .notRegistered) + actions.openLoginItems() + #expect(recorder.openedLoginItems == 1) + let codexHome = ProviderID.codex.sandboxResources[0] + actions.grantAccess(codexHome) + #expect(recorder.rebuilt == 0) + recorder.codexHome = FileManager.default.temporaryDirectory + actions.grantAccess(codexHome) + await waitUntil { recorder.rebuilt >= 1 } + #expect(recorder.rebuilt >= 1) + #expect(dependencies.settings.bookmark(for: codexHome) != nil) + #expect(controller.environment.credentialDescriptions == [.codex: "scripted codex"]) + recorder.codexHome = URL(fileURLWithPath: "/nonexistent/path/\(UUID().uuidString)") + actions.grantAccess(codexHome) + await waitUntil { dependencies.log.text.contains("bookmark for ~/.codex failed") } + #expect(dependencies.log.text.contains("bookmark for ~/.codex failed")) + actions.refresh() + actions.settingsChanged() +} + +@Test @MainActor func appControllerSurvivesHistoryFailures() async throws { + let history = try UsageHistoryStore(url: nil) + try await history.breakDatabase() + let (dependencies, _) = try makeDependencies(history: history) + let controller = AppController(dependencies: dependencies) + await controller.clearHistory().value + #expect(dependencies.log.text.contains("history clear failed")) + let located = try UsageHistoryStore( + url: FileManager.default.temporaryDirectory.appendingPathComponent("tmb-\(UUID().uuidString)/usage.sqlite")) + let (deps, recorder) = try makeDependencies(history: located) + AppController(dependencies: deps).revealHistory() + #expect(recorder.revealed.count == 1) +} + +@Test @MainActor func appDelegateLifecycle() throws { + let (dependencies, _) = try makeDependencies() + let controller = AppController(dependencies: dependencies) + let delegate = AppDelegate(controller: controller) + delegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + #expect(controller.statusItem != nil) + #expect(!delegate.applicationShouldHandleReopen(NSApp, hasVisibleWindows: false)) + delegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + #expect(controller.statusItem == nil) +} + +@Test @MainActor func deferredAppDelegateShowsTheStatusShellBeforeDependenciesFinish() async throws { + quietTestApp() + let (dependencies, _) = try makeDependencies() + let gate = DeferredDependencyGate() + var failure: String? + let delegate = DeferredAppDelegate { + try await gate.wait() + } failureHandler: { + failure = $0 + } + let clock = ContinuousClock() + let start = clock.now + delegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + let statusShellDuration = start.duration(to: clock.now) + #expect(statusShellDuration < .milliseconds(200)) + #expect(delegate.statusShellVisible) + #expect(delegate.controller == nil) + + gate.resolve(dependencies) + await waitUntil { delegate.controller != nil } + #expect(!delegate.statusShellVisible) + #expect(delegate.controller?.statusItem != nil) + #expect(failure == nil) + delegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) +} + +@Test @MainActor func deferredAppDelegateDiscardsDependenciesReturnedAfterCancellation() async throws { + let (dependencies, _) = try makeDependencies() + let gate = DeferredDependencyGate() + let delegate = DeferredAppDelegate { + try await gate.wait() + } failureHandler: { _ in + Issue.record("cancelled loading reported a failure") + } + + delegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + await waitUntil { gate.isWaiting } + delegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + gate.resolve(dependencies) + await waitUntil { !gate.isWaiting } + await mainActorTurn() + + #expect(delegate.controller == nil) + #expect(!delegate.statusShellVisible) +} + +@Test @MainActor func deferredBootstrapDoesNotTouchStorageDuringConstruction() { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-deferred-\(UUID().uuidString)") + let support = root.appendingPathComponent("support") + let delegate = AppRunner.bootstrapDeferred( + distribution: .appStore, notificationCenter: nil, updater: nil, isSandboxed: false, + paths: LiveDependencies.Paths(home: root, supportDirectory: support, environment: [:], userName: "tester"), + defaults: UserDefaults(suiteName: "deferred-\(UUID().uuidString)")!, transport: NoNetworkTransport(), + keychain: testKeychain, launchAtLogin: .inMemory()) + #expect(!FileManager.default.fileExists(atPath: support.path)) + #expect(delegate.controller == nil) +} + +@Test @MainActor func appControllerProbesCredentialStoresAwayFromTheMainThread() async throws { + let probe = CredentialHealthThreadProbe() + let provider = CredentialHealthProbeProvider(probe: probe) + let (dependencies, _) = try makeDependencies(providers: [provider]) + let controller = AppController(dependencies: dependencies) + controller.start() + await waitUntil { dependencies.state.state(for: .claude).credentialHealth.isUsable } + #expect(probe.wasMainThread == false) + controller.stop() +} + +@Test @MainActor func liveDependenciesResolveBookmarksAndBuildProvidersAwayFromTheMainThread() async { + let resource = ProviderID.codex.sandboxResources[0] + let settings = makeSettings() + settings.setBookmark(Data([1]), for: resource) + let resolverProbe = CredentialHealthThreadProbe() + let builderProbe = CredentialHealthThreadProbe() + let resolver = SecurityScopedResourceResolver( + client: SecurityScopedBookmarkClient( + resolve: { _ in + resolverProbe.record(pthread_main_np() != 0) + return SecurityScopedBookmarkResolution(url: FileManager.default.temporaryDirectory, isStale: false) + }, + create: { _ in Data() }, + start: { _ in true }, + stop: { _ in })) + + _ = await LiveDependencies.providers( + paths: LiveDependencies.Paths(environment: [:]), + client: APIClient(transport: NoNetworkTransport(), log: makeLog()), + log: makeLog(), + settings: settings, + isSandboxed: true, + keychain: testKeychain, + resolver: resolver, + buildRegistry: { _, _, _ in + builderProbe.record(pthread_main_np() != 0) + return ProviderRegistry([]) + }) + + #expect(resolverProbe.wasMainThread == false) + #expect(builderProbe.wasMainThread == false) +} + +@Test @MainActor func liveDependenciesBuildRealGraph() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-live-\(UUID().uuidString)") + let paths = LiveDependencies.Paths( + home: root.appendingPathComponent("home"), supportDirectory: root.appendingPathComponent("support"), + environment: ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("claude").path], userName: "tester") + let defaults = UserDefaults(suiteName: "live-\(UUID().uuidString)")! + var openedReplacement = false + let dependencies = try await LiveDependencies.make( + appInfo: testAppInfo, paths: paths, defaults: defaults, notificationCenter: nil, updater: nil, isSandboxed: false, + transport: NoNetworkTransport(), keychain: testKeychain, + launchAtLogin: .inMemory(), + workspaceOpen: { _, _, _ in openedReplacement = true }) + #expect(dependencies.registry.ids == [.claude, .codex, .copilot, .cursor, .gemini]) + #expect(dependencies.history.location?.lastPathComponent == "usage.sqlite") + #expect(dependencies.registry[.codex]?.credentialDescription.hasSuffix(".codex/auth.json") == true) + #expect(dependencies.registry[.claude]?.credentialDescription.contains("Claude Code-credentials-") == true) + #expect(dependencies.registry[.claude]?.credentialState(now: fixedNow).isUsable == false) + #expect(dependencies.registry[.codex]?.credentialState(now: fixedNow).isUsable == false) + _ = await dependencies.rebuildProviders(dependencies.settings) + dependencies.relaunch() + #expect(openedReplacement) + let controller = AppController(dependencies: dependencies) + #expect(controller.environment.isSandboxed == false) + let sandboxed = try await LiveDependencies.make( + appInfo: testAppInfo, paths: paths, defaults: defaults, notificationCenter: nil, isSandboxed: true, + transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) + #expect(sandboxed.isSandboxed) + let log = makeLog() + let codexHome = ProviderID.codex.sandboxResources[0] + let fallback = codexHome.configuredURL(environment: paths.environment, home: paths.home) + #expect(LiveDependencies.resolve(bookmark: nil, fallback: fallback, log: log).path.hasSuffix(".codex")) + #expect(LiveDependencies.resolve(bookmark: Data([1, 2, 3]), fallback: fallback, log: log) == fallback) + #expect(log.text.contains("could not be resolved")) + let bookmark = try (FileManager.default.temporaryDirectory as NSURL).bookmarkData( + options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) + #expect( + LiveDependencies.resolve(bookmark: bookmark, fallback: fallback, log: log).path.contains( + FileManager.default.temporaryDirectory.lastPathComponent)) + let settings = makeSettings() + #expect( + LiveDependencies.directory(codexHome, paths: paths, settings: settings, isSandboxed: false, log: log) == fallback) + settings.setBookmark(bookmark, for: codexHome) + #expect( + LiveDependencies.directory(codexHome, paths: paths, settings: settings, isSandboxed: true, log: log) != fallback) + // an explicit CODEX_HOME still wins over the bookmark + let configured = LiveDependencies.Paths(home: root, environment: ["CODEX_HOME": "/tmp/cx"], userName: "tester") + let sandboxedGraph = try await LiveDependencies.make( + appInfo: testAppInfo, paths: configured, defaults: UserDefaults(suiteName: "cfg-\(UUID().uuidString)")!, + notificationCenter: nil, isSandboxed: true, transport: NoNetworkTransport(), keychain: testKeychain, + launchAtLogin: .inMemory()) + #expect(sandboxedGraph.registry[.codex]?.credentialDescription.contains("/tmp/cx/auth.json") == true) + #expect( + ProviderID.claude.sandboxResources[1].configuredURL(environment: [:], home: root).lastPathComponent + == ".claude.json") + // a stored bookmark replaces the configured path when the build is sandboxed + let bookmarked = makeSettings() + bookmarked.setBookmark(bookmark, for: codexHome) + let redirected = await LiveDependencies.providers( + paths: paths, client: APIClient(transport: NoNetworkTransport(), log: log), log: log, settings: bookmarked, + isSandboxed: true, keychain: testKeychain) + #expect(redirected[.codex]?.credentialDescription.hasSuffix("/auth.json") == true) + #expect(redirected[.codex]?.credentialDescription.contains(paths.home.path) == false) + let environmentTokenPaths = LiveDependencies.Paths( + home: root.appendingPathComponent("environment-home"), supportDirectory: root.appendingPathComponent("support"), + environment: ["COPILOT_GITHUB_TOKEN": "token"], userName: "tester") + let environmentRegistry = await LiveDependencies.providers( + paths: environmentTokenPaths, client: APIClient(transport: NoNetworkTransport(), log: log), log: log, + settings: makeSettings(), isSandboxed: true, keychain: testKeychain) + #expect( + environmentRegistry.setupStates[.copilot]?.resources + == ProviderID.copilot.sandboxResources.map(ResourceAccessState.notRequired)) + + let bookmarkRoot = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-stale-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: bookmarkRoot) } + let original = bookmarkRoot.appendingPathComponent("original") + let moved = bookmarkRoot.appendingPathComponent("moved") + try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true) + let staleBookmark = try (original as NSURL).bookmarkData( + options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) + try FileManager.default.moveItem(at: original, to: moved) + let staleSettings = makeSettings() + staleSettings.setBookmark(staleBookmark, for: codexHome) + + _ = await LiveDependencies.providers( + paths: paths, client: APIClient(transport: NoNetworkTransport(), log: log), log: log, settings: staleSettings, + isSandboxed: true, keychain: testKeychain) + + #expect(staleSettings.bookmark(for: codexHome) != staleBookmark) + #expect(log.text.contains("replaced stale bookmark for ~/.codex")) + let home = LiveDependencies.Paths() + #expect(home.userName == NSUserName()) + #expect(ProviderID.allSandboxResources.count >= ProviderID.allCases.count) + #expect(ProviderID.cursor.sandboxResources.map(\.label).contains("~/.cursor")) +} + +@Test @MainActor func appRunnerBootstrapsAgainstTemporaryDefaults() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-runner-\(UUID().uuidString)") + let paths = LiveDependencies.Paths( + home: root, supportDirectory: root.appendingPathComponent("support"), environment: [:], userName: "tester") + let delegate = try await AppRunner.bootstrap( + distribution: .appStore, notificationCenter: nil, updater: FakeUpdater(), isSandboxed: false, paths: paths, + defaults: UserDefaults(suiteName: "runner-\(UUID().uuidString)")!, transport: NoNetworkTransport(), + keychain: testKeychain, launchAtLogin: .inMemory()) + #expect(delegate.controller.dependencies.appInfo.isAppStore) + #expect(delegate.controller.environment.canCheckForUpdates) +} + +private actor RediscoveryFetchProbe { + private(set) var fetches = 0 + + func recordFetch() { + fetches += 1 + } +} + +private struct RediscoveryProvider: UsageProvider { + let id: ProviderID + let health: ProviderCredentialHealth + let result: ProviderFetchResult + let probe: RediscoveryFetchProbe + let pollingPolicy = PollingPolicy(minimumInterval: 0, activeInterval: 0, defaultInterval: 0) + + var credentialDescription: String { "rediscovered \(id.rawValue)" } + + func credentialState(now: Date) -> CredentialState { + switch health { + case .unchecked, .missing, .unreadable: .missing(id.setup.signInDetail) + case .valid(_, let expiresAt): .valid(expiresAt: expiresAt) + case .expired(_, let date): .expired(date) + } + } + + func credentialHealth(now: Date) async -> ProviderCredentialHealth { health } + + func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult { + await probe.recordFetch() + return result + } +} + +@MainActor +private final class DeferredDependencyGate { + private var result: Result? + private var continuation: CheckedContinuation? + private(set) var isWaiting = false + + func wait() async throws -> AppDependencies { + if let result { return try result.get() } + isWaiting = true + defer { isWaiting = false } + return try await withCheckedThrowingContinuation { continuation = $0 } + } + + func resolve(_ dependencies: AppDependencies) { + if let continuation { + self.continuation = nil + continuation.resume(returning: dependencies) + } else { + result = .success(dependencies) + } + } +} + +private struct CredentialHealthProbeProvider: UsageProvider { + let probe: CredentialHealthThreadProbe + let id = ProviderID.claude + let pollingPolicy = PollingPolicy(minimumInterval: 3_600, activeInterval: 3_600, defaultInterval: 3_600) + + var credentialDescription: String { "probe" } + + func credentialState(now: Date) -> CredentialState { + .valid(expiresAt: nil) + } + + func credentialHealth(now: Date) async -> ProviderCredentialHealth { + probe.record(pthread_main_np() != 0) + return .valid(source: id.setup.credentialSources[0], expiresAt: nil) + } + + func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult { + ProviderFetchResult(outcome: .failed("unused")) + } +} + +private final class CredentialHealthThreadProbe: @unchecked Sendable { + private let lock = NSLock() + private var value: Bool? + + var wasMainThread: Bool? { lock.withLock { value } } + + func record(_ value: Bool) { + lock.withLock { self.value = value } + } +} diff --git a/Tests/TokenMenuBarUITests/BrandIconTests.swift b/Tests/TokenMenuBarUITests/BrandIconTests.swift new file mode 100644 index 0000000..9b500a5 --- /dev/null +++ b/Tests/TokenMenuBarUITests/BrandIconTests.swift @@ -0,0 +1,77 @@ +import AppKit +import SwiftUI +import Testing +import TokenMenuBarCore + +@testable import TokenMenuBarUI + +@Test @MainActor func productIconDrawsAndExports() throws { + #expect(AppIcon.squircleRatio > 0 && AppIcon.productInset > 0) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-icon-\(UUID().uuidString)") + try AppIcon.exportIconSet(to: directory) + let files = try FileManager.default.contentsOfDirectory(atPath: directory.path) + #expect(files.contains("icon_16x16.png")) + #expect(files.contains("icon_512x512@2x.png")) + #expect(files.count > AppIcon.appIconSizes.count) + + // actool drops a slot whose image is not exactly the size its name claims, and says so as a warning, so an icon + // rendered at the wrong scale leaves the app with no icon at all rather than a broken build. + for file in files { + let name = file.replacingOccurrences(of: "icon_", with: "").replacingOccurrences(of: ".png", with: "") + let scale = name.hasSuffix("@2x") ? 2 : 1 + guard let nominal = Int(name.replacingOccurrences(of: "@2x", with: "").split(separator: "x").first ?? "") else { + continue + } + let image = try #require(NSImage(contentsOf: directory.appendingPathComponent(file))) + let rep = try #require(image.representations.first) + #expect(rep.pixelsWide == nominal * scale, "\(file) is \(rep.pixelsWide)px, expected \(nominal * scale)") + #expect(rep.pixelsHigh == nominal * scale) + } + try FileManager.default.removeItem(at: directory) +} + +@Test(arguments: [true, false]) +@MainActor func menuBarStripRendersForBothAppearances(dark: Bool) { + let image = StatusItemRenderer.stripImage(for: statusModel(), dark: dark) + #expect(image.size == CGSize(width: 520, height: 28)) + #expect(StatusItemRenderer.stripData(for: statusModel(), dark: dark)?.isEmpty == false) +} + +@Test @MainActor func menuBarStripTakesTheRequestedWidth() { + #expect(StatusItemRenderer.stripImage(for: .empty, dark: false, width: 200).size.width == 200) +} + +@Test @MainActor func popoverExporterRendersAViewToPNG() { + let view = Text("Token Menu Bar").frame(width: 200, height: 60) + #expect(PopoverExporter.image(view, dark: true)?.size == CGSize(width: 200, height: 60)) + #expect(PopoverExporter.png(view, dark: false)?.isEmpty == false) + // a view with no intrinsic size has nothing to render + #expect(PopoverExporter.image(Color.clear.frame(width: 0, height: 0), dark: false) == nil) + #expect(PopoverExporter.png(Color.clear.frame(width: 0, height: 0), dark: false) == nil) +} + +@Test @MainActor func popoverExportUsesTheCompactMeasuredHeight() { + let fallback = CGSize(width: 880, height: 760) + + #expect( + ExportRunner.exportSize(measured: CGSize(width: 880, height: 640), fallback: fallback) + == CGSize(width: 880, height: 640)) + #expect(ExportRunner.exportSize(measured: .zero, fallback: fallback) == fallback) +} + +@Test @MainActor func popoverExportFilesMeasurementsUnderTheirOwnTab() throws { + let environment = try makeEnvironment() + environment.settings.lastTab = .history + var measured = CGSize.zero + let view = RootView( + environment: environment, + onMeasure: { measurement in + if measurement.tab == .history { measured = measurement.size } + }) + + _ = PopoverExporter.image(view, dark: false, size: ExportRunner.shotSize) + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + + #expect(measured.height > 0) + #expect(measured.height <= 760) +} diff --git a/Tests/TokenMenuBarUITests/ChartInteractionCoverageTests.swift b/Tests/TokenMenuBarUITests/ChartInteractionCoverageTests.swift new file mode 100644 index 0000000..073d32f --- /dev/null +++ b/Tests/TokenMenuBarUITests/ChartInteractionCoverageTests.swift @@ -0,0 +1,178 @@ +import AppKit +import ObjectiveC +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func chartSelectionRendersSquareAndDiamondPoints() throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + let selected = fixedNow.addingTimeInterval(-60) + let model = HistoryChartModel( + series: [ + HistorySeries( + id: .analytics(provider: .claude, series: "square"), label: "Square", + points: [SeriesPoint(date: selected, value: 30)], style: HistoryStyleSlot(index: 8)), + HistorySeries( + id: .analytics(provider: .codex, series: "diamond"), label: "Diamond", + points: [SeriesPoint(date: selected, value: 70)], style: HistoryStyleSlot(index: 16)), + ], domain: selected.addingTimeInterval(-60)...selected.addingTimeInterval(60), yMax: 100) + let chart = UsageChart(data: model, presenter: presenter, stacked: false, timeZone: .current) + let unselected = renderedChart(chart) + + presenter.selectedDate = selected + + #expect(try #require(renderedChart(chart)) != #require(unselected)) +} + +@Test @MainActor func legendHoverDoesNotRedrawTheChartMarks() throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + let model = HistoryChartModel( + series: [ + HistorySeries( + key: WindowKey(provider: .claude, windowID: "session"), label: "Session", + points: [ + SeriesPoint(date: fixedNow.addingTimeInterval(-60), value: 30), + SeriesPoint(date: fixedNow, value: 40), + ]), + HistorySeries( + key: WindowKey(provider: .codex, windowID: "weekly"), label: "Weekly", + points: [ + SeriesPoint(date: fixedNow.addingTimeInterval(-60), value: 60), + SeriesPoint(date: fixedNow, value: 70), + ]), + ], domain: fixedNow.addingTimeInterval(-60)...fixedNow, yMax: 100) + let chart = UsageChart(data: model, presenter: presenter, stacked: false, timeZone: .current) + let before = try #require(renderedChart(chart)) + + presenter.setHovered(model.series[0].id) + + #expect(try #require(renderedChart(chart)) == before) +} + +@Test @MainActor func chartHandlesKeyboardEventsThroughTheResponderChain() async throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + try await environment.history.record(sampleSnapshot(.claude), now: fixedNow.addingTimeInterval(-60)) + try await environment.history.record(sampleSnapshot(.claude, percent: 40), now: fixedNow) + presenter.reload() + await presenter.waitForLoad() + let model = try #require(presenter.state.data) + let hosting = host( + UsageChart(data: model, presenter: presenter, stacked: false, timeZone: .current), width: 500, height: 300) + let window = try #require(hosting.window) + window.makeKey() + window.recalculateKeyViewLoop() + #expect(window.makeFirstResponder(hosting)) + + await pressUntilHandled(.rightArrow, keyCode: 124, window: window) { presenter.selectedDate != nil } + #expect(presenter.selectedDate == model.timeline.first) + window.sendEvent(chartCoverageKeyEvent(.rightArrow, keyCode: 124, window: window)) + #expect(presenter.selectedDate == model.timeline.last) + window.sendEvent(chartCoverageKeyEvent(.leftArrow, keyCode: 123, window: window)) + #expect(presenter.selectedDate == model.timeline.first) + window.sendEvent(chartCoverageKeyEvent(.escape, keyCode: 53, window: window)) + #expect(presenter.selectedDate == nil) + +} + +@Test @MainActor func inspectorIsolatesTheFocusedSeriesFromAKeyboardEvent() async throws { + let environment = try makeEnvironment(populate: false) + try await environment.history.record(sampleSnapshot(.claude), now: fixedNow.addingTimeInterval(-60)) + try await environment.history.record(sampleSnapshot(.codex), now: fixedNow.addingTimeInterval(-60)) + let presenter = environment.historyPresenter + presenter.reload() + await presenter.waitForLoad() + let seriesCount = try #require(presenter.state.data?.series.count) + #expect(seriesCount > 1) + let hosting = host(HistoryInspector(environment: environment), width: 320, height: 400) + let window = try #require(hosting.window) + window.makeKey() + window.recalculateKeyViewLoop() + #expect(window.makeFirstResponder(hosting)) + + for _ in 0..<(seriesCount * 2 + 2) where environment.settings.historyHiddenKeys.isEmpty { + window.sendEvent(chartCoverageKeyEvent(.tab, keyCode: 48, window: window)) + await mainActorTurn() + window.sendEvent(chartCoverageKeyEvent("i", keyCode: 34, window: window)) + await mainActorTurn() + } + + #expect(environment.settings.historyHiddenKeys.count == seriesCount - 1) +} + +@Test @MainActor func historyTabDefaultExportPresentsTheSavePanel() throws { + let environment = try makeEnvironment(populate: false) + let export = try #require(chartCoverageButtons(in: HistoryTab(environment: environment).body).first) + let original = try #require(class_getInstanceMethod(NSSavePanel.self, #selector(NSSavePanel.runModal))) + let replacement = try #require( + class_getInstanceMethod(NSSavePanel.self, #selector(NSSavePanel.chartCoverageRunModal))) + method_exchangeImplementations(original, replacement) + defer { method_exchangeImplementations(replacement, original) } + ChartCoverageSavePanelObservation.wasPresented = false + + export.action() + + #expect(ChartCoverageSavePanelObservation.wasPresented) +} + +@MainActor +private func pressUntilHandled( + _ key: KeyEquivalent, keyCode: UInt16, window: NSWindow, handled: () -> Bool +) async { + for _ in 0..<4 where !handled() { + window.sendEvent(chartCoverageKeyEvent(key, keyCode: keyCode, window: window)) + await mainActorTurn() + if !handled() { window.sendEvent(chartCoverageKeyEvent(.tab, keyCode: 48, window: window)) } + } +} + +@MainActor +private func chartCoverageKeyEvent(_ key: KeyEquivalent, keyCode: UInt16, window: NSWindow) -> NSEvent { + chartCoverageKeyEvent(String(key.character), keyCode: keyCode, window: window) +} + +@MainActor +private func chartCoverageKeyEvent(_ characters: String, keyCode: UInt16, window: NSWindow) -> NSEvent { + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, + characters: characters, charactersIgnoringModifiers: characters, isARepeat: false, keyCode: keyCode)! +} + +@MainActor +private func renderedChart(_ chart: UsageChart) -> Data? { + renderedView(host(chart, width: 500, height: 300)) +} + +@MainActor +private func renderedView(_ view: NSView) -> Data? { + guard let representation = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return nil } + view.cacheDisplay(in: view.bounds, to: representation) + return representation.representation(using: .png, properties: [:]) +} + +@MainActor +private func chartCoverageView(in root: NSView) -> Wanted? { + if let match = root as? Wanted { return match } + return root.subviews.lazy.compactMap { chartCoverageView(in: $0) }.first +} + +private func chartCoverageButtons(in value: Any, depth: Int = 0) -> [NativeActionButton] { + if let button = value as? NativeActionButton { return [button] } + guard depth < 48 else { return [] } + return Mirror(reflecting: value).children.flatMap { chartCoverageButtons(in: $0.value, depth: depth + 1) } +} + +@MainActor private enum ChartCoverageSavePanelObservation { + static var wasPresented = false +} + +extension NSSavePanel { + @objc fileprivate func chartCoverageRunModal() -> NSApplication.ModalResponse { + ChartCoverageSavePanelObservation.wasPresented = true + return .cancel + } +} diff --git a/Tests/TokenMenuBarUITests/ComponentPolicyTests.swift b/Tests/TokenMenuBarUITests/ComponentPolicyTests.swift new file mode 100644 index 0000000..e7fa79c --- /dev/null +++ b/Tests/TokenMenuBarUITests/ComponentPolicyTests.swift @@ -0,0 +1,118 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func semanticColorsUseSystemRoles() { + #expect(Color(.primary) == Color(nsColor: .labelColor)) + #expect(Color(.accent) == Color(nsColor: .controlAccentColor)) + #expect(Color(.secondary) != Color(.primary)) + #expect(Color(.tertiary) != Color(.secondary)) +} + +@Test @MainActor func readableSemanticColorsMeetPanelContrastInLightAndDarkAppearances() throws { + for appearanceName in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: appearanceName)) + var colors: (NSColor?, [NSColor?])? + appearance.performAsCurrentDrawingAppearance { + colors = ( + NSColor.windowBackgroundColor.usingColorSpace(.sRGB), + [SemanticColorRole.primary, .secondary, .tertiary, .warning, .destructive].map { + SemanticColorPalette.color(for: $0).usingColorSpace(.sRGB) + } + [LogTextView.textColor.usingColorSpace(.sRGB)] + ) + } + let resolvedColors = try #require(colors) + let background = try #require(resolvedColors.0) + for foreground in resolvedColors.1 { + #expect(contrastRatio(try #require(foreground), background: background) >= 4.5) + } + } +} + +@Test @MainActor func nativeComponentsRenderWithIntrinsicControlHeights() { + #expect(inkFraction(NativeActionButton("Refresh") {}, width: 160, height: 40) > 0) + #expect( + inkFraction( + NativeActionButton(intent: .destructive, action: {}) { Label("Clear", systemImage: "trash") }, width: 160, + height: 40) > 0) + #expect(NativeActionButton("Refresh") {}.role == nil) + #expect(NativeActionButton("Clear", intent: .destructive) {}.role == .destructive) + #expect( + inkFraction( + NativeIconButton(symbol: "arrow.clockwise", accessibilityLabel: "Refresh", action: {}), width: 48, height: 40) + > 0) + #expect( + inkFraction(IconButton(symbol: "arrow.clockwise", help: "Refresh", action: {}), width: 48, height: 40) > 0) + #expect(inkFraction(Button("Action") {}.buttonStyle(.bordered), width: 120, height: 40) > 0) +} + +@Test @MainActor func sectionComponentsKeepLabelsSeparateFromContent() { + #expect(inkFraction(SectionLabel("Menu bar"), width: 200, height: 28) > 0) + #expect( + inkFraction( + PanelSection("Menu bar") { PanelRow("Format") { Text("Percent") } }, width: 420, height: 80) > 0) + #expect(PanelRow("Format") { Text("Percent") }.labelWidth == 116) + #expect(PanelRow("Format", labelWidth: 140) { Text("Percent") }.labelWidth == 140) +} + +@Test @MainActor func semanticControlModifiersRenderEachIntent() { + #expect(inkFraction(Text("Detail").semanticForeground(.secondary), width: 100, height: 30) > 0) + for intent in ControlIntent.allCases { + #expect( + inkFraction(Button(intent.rawValue) {}.buttonStyle(.bordered).semanticControl(intent, selected: true), width: 140) + > 0) + } +} + +@Test @MainActor func scrollerStylerUsesAutoHidingVerticalOverlay() { + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + let probe = ScrollerStyler.ProbeView(frame: .zero) + let document = NSView(frame: .zero) + let nested = NSScrollView(frame: .zero) + nested.hasHorizontalScroller = true + nested.horizontalScrollElasticity = .automatic + document.addSubview(probe) + document.addSubview(nested) + scrollView.documentView = document + + ScrollerStyler.apply(from: probe) + probe.viewDidMoveToSuperview() + probe.viewDidMoveToWindow() + + #expect(scrollView.scrollerStyle == .overlay) + #expect(scrollView.hasVerticalScroller) + #expect(!scrollView.hasHorizontalScroller) + #expect(scrollView.autohidesScrollers) + #expect(scrollView.horizontalScrollElasticity == .none) + #expect(!nested.hasVerticalScroller) + #expect(nested.hasHorizontalScroller) + #expect(nested.horizontalScrollElasticity == .automatic) +} + +private func contrastRatio(_ foreground: NSColor, background: NSColor) -> Double { + let foreground = composite(foreground, over: background) + let lighter = max(luminance(foreground), luminance(background)) + let darker = min(luminance(foreground), luminance(background)) + return (lighter + 0.05) / (darker + 0.05) +} + +private func composite(_ foreground: NSColor, over background: NSColor) -> NSColor { + let alpha = foreground.alphaComponent + return NSColor( + red: foreground.redComponent * alpha + background.redComponent * (1 - alpha), + green: foreground.greenComponent * alpha + background.greenComponent * (1 - alpha), + blue: foreground.blueComponent * alpha + background.blueComponent * (1 - alpha), + alpha: 1) +} + +private func luminance(_ color: NSColor) -> Double { + func linear(_ component: CGFloat) -> Double { + let value = Double(component) + return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(color.redComponent) + 0.7152 * linear(color.greenComponent) + + 0.0722 * linear(color.blueComponent) +} diff --git a/Tests/TokenMenuBarUITests/CoverageBehaviorTests.swift b/Tests/TokenMenuBarUITests/CoverageBehaviorTests.swift new file mode 100644 index 0000000..2e4daaf --- /dev/null +++ b/Tests/TokenMenuBarUITests/CoverageBehaviorTests.swift @@ -0,0 +1,194 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func statusPreviewButtonsSelectTheirModel() async throws { + let model = statusModel() + let cell = try #require(model.cells.first) + let key = try #require(WindowKey(storageKey: cell.id)) + var highlighted: WindowKey? + var selected: WindowKey? + let preview = StatusPreview( + model: model, + highlightedKey: Binding(get: { highlighted }, set: { highlighted = $0 }), + select: { selected = $0 }) + let hosting = host(preview, width: 520, height: 60) + + #expect(pressAccessibilityElement(label: cell.tooltip, in: hosting)) + await Task.yield() + #expect(highlighted == key) + #expect(selected == key) +} + +@Test @MainActor func statusPreviewRendersCellsWithoutWindowKeys() { + let cell = StatusCell( + id: ProviderID.claude.rawValue, provider: .claude, + lines: [[StatusRun(text: "CC", kind: .label)]], percent: 36, tooltip: "Claude usage") + let model = StatusItemModel(cells: [cell], iconTone: .normal, showsIcon: false, countdownActive: false) + #expect(inkFraction(StatusPreview(model: model), width: 180, height: 48) > 0) +} + +@Test @MainActor func statusPreviewWrapsCellsAtNarrowWidths() { + let model = statusModel() + let wide = fittingSize(StatusPreview(model: model), width: 700) + let narrow = fittingSize(StatusPreview(model: model), width: 180) + + #expect(narrow.width <= 180) + #expect(narrow.height > wide.height) +} + +@Test @MainActor func providerChipConstrainsLongValues() { + let text = String(repeating: "long-provider-identity-", count: 8) + var copied: String? + let chip = UsageIdentityChip( + chip: Chip(text: text), provider: .claude, + onCopy: { copied = $0 }) + let size = fittingSize(chip, width: 260) + + #expect(size.width <= 260) + #expect(chip.primaryHelp.title == text) + chip.primaryAction() + #expect(copied == text) +} + +@Test @MainActor func providerCardDefaultRefreshActionIsSafe() throws { + let environment = try makeEnvironment() + let card = ProviderCardView(card: try #require(environment.cards.first), environment: environment) + card.refresh() +} + +@MainActor private func fittingSize(_ view: Content, width: CGFloat) -> CGSize { + let hosting = NSHostingView( + rootView: view.frame(width: width, alignment: .leading).fixedSize(horizontal: false, vertical: true)) + hosting.layoutSubtreeIfNeeded() + return hosting.fittingSize +} + +@Test @MainActor func settingsFocusesARequestedProvider() async throws { + let environment = try makeEnvironment() + let request = ProviderSettingsFocusRequest(provider: .codex) + environment.providerFocusRequest = request + let hosting = host( + SettingsTab(environment: environment, providerFocusRequest: request), width: 720, height: 1_000) + #expect(hosting.fittingSize.width > 0) + await waitUntil { environment.providerFocusRequest == nil } + #expect(environment.providerFocusRequest == nil) +} + +@Test @MainActor func settingsHostedConstructionAndLayoutStaysUnderTwoHundredMilliseconds() throws { + let environment = try makeEnvironment() + _ = NSHostingView(rootView: Color.clear) + let start = ContinuousClock.now + let hosting = NSHostingView(rootView: SettingsTab(environment: environment)) + hosting.frame = NSRect(x: 0, y: 0, width: 880, height: 800) + hosting.layoutSubtreeIfNeeded() + let elapsed = start.duration(to: .now) + #expect(hosting.fittingSize.width > 0) + #expect(elapsed < .milliseconds(200)) +} + +@Test @MainActor func settingsDeferredSectionsBecomeAccessible() async throws { + let environment = try makeEnvironment() + var contentReady = false + let hosting = host( + SettingsTab(environment: environment) + .onPreferenceChange(SettingsContentReadyKey.self) { contentReady = $0 }, + width: 880, height: 1_600) + + await waitUntil { contentReady } + + #expect(contentReady) + #expect(hosting.fittingSize.height > 0) +} + +@Test @MainActor func settingsProviderVisibilityFollowsDiscoveryAndShowAll() throws { + let environment = try makeEnvironment(populate: false) + let tab = SettingsTab(environment: environment) + #expect(tab.visibleProviders.isEmpty) + + environment.settings.showAllProviders = true + #expect(tab.visibleProviders == ProviderID.allCases) + + environment.settings.showAllProviders = false + tab.setProvider(.gemini, enabled: false) + #expect(tab.visibleProviders.isEmpty) + #expect(environment.settings.providerOverride(for: .gemini) == false) + + environment.state.update(.gemini) { + $0.credentialHealth = .valid(source: ProviderID.gemini.setup.credentialSources[0], expiresAt: nil) + } + #expect(tab.visibleProviders == [.gemini]) +} + +@Test @MainActor func usageWindowRowsExposeLongLabelsAndPaceAtNarrowWidth() { + let label = "Extremely Long Localized Model Window Name That Must Remain Readable" + let projection = fixedNow.addingTimeInterval(2 * 86400) + let window = QuotaWindow( + id: "long-model-identifier-that-must-remain-readable", label: label, group: .weekly, usedPercent: 67, + resetsAt: fixedNow.addingTimeInterval(4 * 86400), duration: 7 * 86400) + let row = WindowRow( + key: WindowKey(.claude, window), window: window, + pace: PaceEstimate(status: .ahead, expectedPercent: 42, ratio: 1.6, projectedExhaustion: projection), + countdown: "4d", resetClock: "Friday at 10:00 AM") + let view = WindowRowView(row: row, now: fixedNow) + #expect(inkFraction(view, width: 320, height: 180) > 0) + + #expect(view.accessibilityLabelText.contains(label)) + #expect(view.accessibilityValue.contains("Ahead of pace")) + #expect(view.accessibilityValue.contains("expected 42%")) + #expect(view.accessibilityValue.contains("1.6×")) +} + +@Test @MainActor func everyPaceStateRemainsVisibleAtNarrowWidth() { + let cases: [(PaceStatus, Double?, Double?, String)] = [ + (.unknown, nil, nil, "Learning pace"), + (.onTrack, 42, 1, "On pace"), + (.ahead, 42, 1.6, "Ahead of pace"), + (.behind, 42, 0.6, "Under pace"), + (.exhausted, nil, nil, "Limit reached"), + ] + for (status, expected, ratio, text) in cases { + let window = QuotaWindow( + id: status.rawValue, label: "Long (status.rawValue) model window", group: .weekly, + usedPercent: status == .exhausted ? 100 : 48, + resetsAt: fixedNow.addingTimeInterval(4 * 86400), duration: 7 * 86400) + let row = WindowRow( + key: WindowKey(.claude, window), window: window, + pace: PaceEstimate(status: status, expectedPercent: expected, ratio: ratio, projectedExhaustion: nil), + countdown: "4d", resetClock: "Friday at 10:00 AM") + let view = WindowRowView(row: row, now: fixedNow) + #expect(inkFraction(view, width: 320, height: 150) > 0) + #expect(view.accessibilityValue.contains(text)) + } +} + +@MainActor +private func pressAccessibilityElement(label: String, in root: NSView) -> Bool { + pressAccessibilityElement(label: label, in: root as Any, depth: 0) +} + +@MainActor +private func pressAccessibilityElement(label: String, in value: Any, depth: Int) -> Bool { + guard depth < 30 else { return false } + if let view = value as? NSView { + if view.accessibilityLabel() == label, view.accessibilityPerformPress() { return true } + if (view.accessibilityChildren() ?? []).contains(where: { + pressAccessibilityElement(label: label, in: $0, depth: depth + 1) + }) { + return true + } + return view.subviews.contains { + pressAccessibilityElement(label: label, in: $0, depth: depth + 1) + } + } + if let element = value as? NSAccessibilityElement { + if element.accessibilityLabel() == label, element.accessibilityPerformPress() { return true } + return (element.accessibilityChildren() ?? []).contains { + pressAccessibilityElement(label: label, in: $0, depth: depth + 1) + } + } + return false +} diff --git a/Tests/TokenMenuBarUITests/CoverageClosureUIBehaviorTests.swift b/Tests/TokenMenuBarUITests/CoverageClosureUIBehaviorTests.swift new file mode 100644 index 0000000..0a714eb --- /dev/null +++ b/Tests/TokenMenuBarUITests/CoverageClosureUIBehaviorTests.swift @@ -0,0 +1,137 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func coverageClosureRootTabControlChangesTheSelectedTab() throws { + let environment = try makeEnvironment() + var selected: [PopoverTab] = [] + let hosting = host( + RootView(environment: environment, onMeasure: { _ in }, onTabChange: { selected.append($0) }), + width: 880, height: 900) + let control: NSSegmentedControl = try #require( + coverageClosureViews(in: hosting).first { $0.accessibilityLabel() == "Popover tabs" }) + + control.selectedSegment = 2 + NSApp.sendAction(try #require(control.action), to: control.target, from: control) + + #expect(environment.settings.lastTab == .settings) + #expect(selected == [.settings]) +} + +@Test @MainActor func coverageClosureSettingsConfirmationsAndActionsRemainOperable() throws { + let environment = try makeEnvironment() + environment.settings.historyRetentionDays = 7 + var resets = 0 + var cleared = 0 + var launchValues: [Bool] = [] + environment.actions.settingsReset = { resets += 1 } + environment.actions.clearHistory = { cleared += 1 } + environment.actions.setLaunchAtLogin = { launchValues.append($0) } + let tab = SettingsTab(environment: environment, mountsIncrementally: false) + + tab.requestResetDefaults() + tab.cancelResetAction() + tab.resetAllSettingsAction() + + tab.requestClearHistory() + tab.clearHistoryAction() + tab.launchAtLoginBinding.wrappedValue = true + + #expect(cleared == 1) + #expect(launchValues == [true]) + #expect(resets == 1) + #expect(environment.settings.historyRetentionDays == 60) +} + +@Test @MainActor func coverageClosureWindowFilterShortcutAndSettingRemainOperable() async throws { + let environment = try makeEnvironment() + let hosting = host(WindowSelectionList(environment: environment), width: 880, height: 900) + let window = try #require(hosting.window) + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: .command, timestamp: 0, + windowNumber: window.windowNumber, context: nil, characters: "f", charactersIgnoringModifiers: "f", + isARepeat: false, keyCode: 3)) + + #expect(window.performKeyEquivalent(with: event)) + await Task.yield() + #expect(window.firstResponder is NSTextView) + + environment.settings.hideUnusedModels.toggle() + await Task.yield() + #expect(environment.settings.hideUnusedModels) +} + +@Test @MainActor func coverageClosureProviderContextAndAccessibilityActionsReorder() throws { + let environment = try makeEnvironment() + environment.settings.windowOrder = .provider + let list = WindowSelectionList(environment: environment) + let group = try #require(list.groups.dropFirst().first) + let hosting = host(list.providerHeader(group), width: 880, height: 90) + let original = environment.settings.providerOrder + let available = Set(list.groups.map(\.provider)) + + let menuItem = try #require(coverageClosureMenuItems(in: hosting).first { $0.title == "Move Earlier" }) + #expect(NSApp.sendAction(try #require(menuItem.action), to: menuItem.target, from: menuItem)) + #expect(environment.settings.providerOrder != original) + + let moveLater = try #require(coverageClosureMenuItems(in: hosting).first { $0.title == "Move Later" }) + #expect(NSApp.sendAction(try #require(moveLater.action), to: moveLater.target, from: moveLater)) + #expect(environment.settings.providerOrder == original.filter(available.contains)) +} + +@Test @MainActor func coverageClosureModelContextAndRevertActionsPersistChanges() throws { + let environment = try makeEnvironment() + environment.settings.windowOrder = .provider + var list = WindowSelectionList(environment: environment) + let keys = list.orderDraft.models.filter { $0.provider == .claude } + let second = try #require(keys.dropFirst().first) + let row = try #require(list.row(second)) + let hosting = host(list.modelRow(row), width: 880, height: 100) + let original = environment.settings.modelOrder + + let moveEarlier = try #require(coverageClosureMenuItems(in: hosting).first { $0.title == "Move Earlier" }) + #expect(NSApp.sendAction(try #require(moveEarlier.action), to: moveEarlier.target, from: moveEarlier)) + #expect(environment.settings.modelOrder != original) + + environment.settings.setShortLabel("CUSTOM", for: second) + list = WindowSelectionList(environment: environment) + let overridden = try #require(list.row(second)) + let button = try #require( + coverageClosureIconButtons(in: list.modelRow(overridden)).first { + $0.accessibilityLabel.hasPrefix("Revert label") + }) + button.action() + #expect(environment.settings.shortLabels[second] == nil) +} + +@MainActor +private func coverageClosureViews(in root: NSView) -> [Wanted] { + coverageClosureAllViews(in: root).compactMap { $0 as? Wanted } +} + +@MainActor +private func coverageClosureAllViews(in root: NSView) -> [NSView] { + [root] + root.subviews.flatMap(coverageClosureAllViews) +} + +@MainActor +private func coverageClosureMenuItems(in root: NSView) -> [NSMenuItem] { + guard let window = root.window, + let event = NSEvent.mouseEvent( + with: .rightMouseDown, location: NSPoint(x: root.bounds.midX, y: root.bounds.midY), modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1) + else { return [] } + return coverageClosureAllViews(in: root).flatMap { $0.menu(for: event)?.items ?? [] } +} + +private func coverageClosureIconButtons(in value: Any, depth: Int = 0) -> [NativeIconButton] { + if let button = value as? NativeIconButton { return [button] } + guard depth < 48 else { return [] } + return Mirror(reflecting: value).children.flatMap { + coverageClosureIconButtons(in: $0.value, depth: depth + 1) + } +} diff --git a/Tests/TokenMenuBarUITests/CoverageGateComponentBehaviorTests.swift b/Tests/TokenMenuBarUITests/CoverageGateComponentBehaviorTests.swift new file mode 100644 index 0000000..aab3324 --- /dev/null +++ b/Tests/TokenMenuBarUITests/CoverageGateComponentBehaviorTests.swift @@ -0,0 +1,105 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func coverageGateSegmentedControlClearsAnUnavailableSelection() throws { + var selection = "Missing" + let control = NativeSegmentedControl( + [(value: "Stable", label: "Stable"), (value: "Usage", label: "Usage")], + selection: Binding(get: { selection }, set: { selection = $0 }), + accessibilityLabel: "Order") + let hosting = host(control, width: 180, height: 40) + let segmented: NSSegmentedControl = try #require(coverageGateView(in: hosting)) + + #expect(segmented.selectedSegment == -1) +} + +@Test @MainActor func coverageGateEmptyWrappingStackHasNoIntrinsicContent() { + let hosting = NSHostingView(rootView: WrappingHStack { EmptyView() }) + hosting.layoutSubtreeIfNeeded() + + #expect(hosting.fittingSize == .zero) +} + +@Test @MainActor func coverageGateChipButtonsAndContextActionCopyTheValue() { + var copied: [String] = [] + let chip = ChipView(chip: Chip(text: "Max"), onCopy: { copied.append($0) }) + chip.primaryAction() + chip.copyAction() + + #expect(copied == ["Max", "Max"]) +} + +@Test @MainActor func coverageGateFullLogSearchButtonFocusesTheField() async throws { + let hosting = host(FullLogView(log: makeLog()), width: 620, height: 260) + let window = try #require(hosting.window) + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: .command, timestamp: 0, + windowNumber: window.windowNumber, context: nil, characters: "f", charactersIgnoringModifiers: "f", + isARepeat: false, keyCode: 3)) + #expect(window.performKeyEquivalent(with: event)) + await Task.yield() + + #expect(hosting.window?.firstResponder is NSTextView) +} + +@Test @MainActor func coverageGateProviderHeaderButtonsRefreshAndOpenSetup() throws { + let environment = try makeEnvironment(populate: false) + environment.settings.setProvider(.claude, enabled: true) + environment.state.update(.claude) { + $0.snapshot = sampleSnapshot(.claude) + $0.availability = .authenticationRequired + } + environment.refreshUsagePresentation() + let card = try #require(environment.cards.first { $0.provider == .claude }) + var refreshed: [ProviderID] = [] + var opened: [ProviderID?] = [] + environment.actions.showProviders = { opened.append($0) } + let view = ProviderCardView( + card: card, environment: environment, onRefreshProvider: { refreshed.append($0) }) + let buttons = coverageGateNativeIconButtons(in: view.body) + + try #require(buttons.first { $0.accessibilityLabel == "Refresh Claude" }).action() + try #require(buttons.first { $0.accessibilityLabel == "Set up Claude" }).action() + + #expect(refreshed == [.claude]) + #expect(opened == [.claude]) +} + +@Test @MainActor func coverageGateUnselectedInactiveWindowRendersInBothLayouts() { + let window = QuotaWindow( + id: "inactive", label: "Inactive model", group: .other, usedPercent: 0, resetsAt: nil, isActive: false) + let row = WindowRow( + key: WindowKey(.claude, window), window: window, + pace: PaceEstimate(status: .unknown, expectedPercent: nil, ratio: nil, projectedExhaustion: nil), + countdown: "", resetClock: "", isSelected: false) + let wide = WindowRowView(row: row, now: fixedNow) + let narrow = wide.environment(\.dynamicTypeSize, .accessibility1) + + #expect(inkFraction(wide, width: 852, height: 90) > 0) + #expect(inkFraction(narrow, width: 548, height: 150) > 0) + #expect(wide.accessibilityValue.contains("not shown in the menu bar")) +} + +@MainActor +private func coverageGateView(in root: NSView) -> Wanted? { + coverageGateViews(in: root).compactMap { $0 as? Wanted }.first +} + +@MainActor +private func coverageGateViews(in root: NSView) -> [NSView] { + [root] + root.subviews.flatMap(coverageGateViews) +} + +@MainActor +private func coverageGateNativeIconButtons(in value: Any, depth: Int = 0) -> [NativeIconButton] { + if let button = value as? NativeIconButton { return [button] } + guard depth < 48 else { return [] } + return Mirror(reflecting: value).children.flatMap { + coverageGateNativeIconButtons(in: $0.value, depth: depth + 1) + } +} diff --git a/Tests/TokenMenuBarUITests/CoverageGateProviderMarkFallbackTests.swift b/Tests/TokenMenuBarUITests/CoverageGateProviderMarkFallbackTests.swift new file mode 100644 index 0000000..8f704f3 --- /dev/null +++ b/Tests/TokenMenuBarUITests/CoverageGateProviderMarkFallbackTests.swift @@ -0,0 +1,61 @@ +import AppKit +import Foundation +import Testing + +@testable import TokenMenuBarUI + +@Test func coverageGatePopoverExportUsesTextWhenAProviderMarkAssetIsMissing() throws { + let metadata = try #require(ProviderMarkCatalog.metadataURL) + let executable = try #require(providerMarkGateExecutable(near: metadata)) + let asset = try #require(ProviderMarkCatalog.resourceURL(named: "Claude.svg")) + let baselineDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "provider-mark-baseline-\(UUID().uuidString)") + let fallbackDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "provider-mark-fallback-\(UUID().uuidString)") + let backupDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "provider-mark-backup-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: backupDirectory, withIntermediateDirectories: true) + let backup = backupDirectory.appendingPathComponent(asset.lastPathComponent) + defer { + try? FileManager.default.removeItem(at: baselineDirectory) + try? FileManager.default.removeItem(at: fallbackDirectory) + try? FileManager.default.removeItem(at: backupDirectory) + } + + try providerMarkGateExport(executable: executable, output: baselineDirectory) + try FileManager.default.moveItem(at: asset, to: backup) + defer { + if FileManager.default.fileExists(atPath: backup.path) { + try? FileManager.default.moveItem(at: backup, to: asset) + } + } + try providerMarkGateExport(executable: executable, output: fallbackDirectory) + + let baseline = try Data(contentsOf: baselineDirectory.appendingPathComponent("popover-usage-light.png")) + let fallback = try Data(contentsOf: fallbackDirectory.appendingPathComponent("popover-usage-light.png")) + let image = try #require(NSImage(data: fallback)) + #expect(image.size.width > 0) + #expect(image.size.height > 0) + #expect(fallback != baseline) +} + +private func providerMarkGateExport(executable: URL, output: URL) throws { + let process = Process() + process.executableURL = executable + process.arguments = ["--export-popover", output.path] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + #expect(process.terminationStatus == 0) +} + +private func providerMarkGateExecutable(near resource: URL) -> URL? { + var directory = resource.deletingLastPathComponent() + for _ in 0..<12 { + let candidate = directory.appendingPathComponent("TokenMenuBar") + if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate } + directory.deleteLastPathComponent() + } + return nil +} diff --git a/Tests/TokenMenuBarUITests/CoverageGateSettingsInteractionTests.swift b/Tests/TokenMenuBarUITests/CoverageGateSettingsInteractionTests.swift new file mode 100644 index 0000000..e0471ef --- /dev/null +++ b/Tests/TokenMenuBarUITests/CoverageGateSettingsInteractionTests.swift @@ -0,0 +1,78 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func coverageGateSettingsBindingsUseVisibleContentCounts() throws { + let environment = try makeEnvironment() + environment.settings.hideUnusedModels = true + let tab = SettingsTab(environment: environment, mountsIncrementally: false) + let usedModels = environment.state.snapshots.values.reduce(0) { + $0 + $1.windows.count(where: { $0.usedPercent > 0 }) + } + + #expect(tab.heightInput.modelCount == usedModels) + + tab.refreshMinutes(.claude).wrappedValue = 7 + #expect(environment.settings.refreshInterval(for: .claude) == 420) + + tab.settingWithoutRefresh(\.showAllProviders).wrappedValue = true + #expect(environment.settings.showAllProviders) +} + +@Test @MainActor func coverageGateInactiveProviderRecoveryAppearsOnlyWhenSetupIsVisible() throws { + let environment = try makeEnvironment(populate: false) + let issue = ProviderRecoveryIssue( + kind: .credentialMissing, title: "Sign in needed", detail: "No credential was found.", action: .checkAgain) + environment.state.update(.gemini) { $0.recoveryIssue = issue } + let tab = SettingsTab(environment: environment, mountsIncrementally: false) + + #expect(tab.actionableRecoveryIssue(.gemini) == nil) + environment.settings.showAllProviders = true + #expect(tab.actionableRecoveryIssue(.gemini) == issue) +} + +@Test @MainActor func coverageGateSettingsProviderResourceActionGrantsAccess() throws { + let environment = try makeEnvironment() + let resource = try #require(ProviderID.claude.sandboxResources.first) + environment.state.update(.claude) { + $0.resourceAccess = [ResourceAccessState(resource: resource, health: .needed)] + } + var granted: [SandboxResource] = [] + environment.actions.grantAccess = { granted.append($0) } + let tab = SettingsTab(environment: environment, mountsIncrementally: false) + + #expect(tab.actionableRecoveryIssue(.claude)?.action == .grantAccess(resource)) + tab.resourceGrantAction(resource)() + + let buttons = settingsGateNativeButtons(in: tab.providerRow(.claude)) + for button in buttons { button.action() } + #expect(!granted.isEmpty) + #expect(granted.allSatisfy { $0 == resource }) +} + +@Test @MainActor func coverageGateSettingsRespondsToAChangedProviderFocusRequest() async throws { + let environment = try makeEnvironment() + let hosting = host( + SettingsTab(environment: environment, providerFocusRequest: nil, mountsIncrementally: false), + width: 880, height: 1_200) + let request = ProviderSettingsFocusRequest(provider: .codex) + environment.providerFocusRequest = request + hosting.rootView = SettingsTab( + environment: environment, providerFocusRequest: request, mountsIncrementally: false) + hosting.layoutSubtreeIfNeeded() + + await waitUntil { environment.providerFocusRequest == nil } + #expect(environment.providerFocusRequest == nil) +} + +@MainActor +private func settingsGateNativeButtons(in value: Any, depth: Int = 0) -> [NativeActionButton] { + if let button = value as? NativeActionButton { return [button] } + guard depth < 48 else { return [] } + return Mirror(reflecting: value).children.flatMap { + settingsGateNativeButtons(in: $0.value, depth: depth + 1) + } +} diff --git a/Tests/TokenMenuBarUITests/CoverageGateWindowSelectionBehaviorTests.swift b/Tests/TokenMenuBarUITests/CoverageGateWindowSelectionBehaviorTests.swift new file mode 100644 index 0000000..33444ec --- /dev/null +++ b/Tests/TokenMenuBarUITests/CoverageGateWindowSelectionBehaviorTests.swift @@ -0,0 +1,113 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func coverageGateWindowSelectionBindingsPersistSelectionAndLabels() throws { + let environment = try makeEnvironment() + var drafts: [WindowKey: String] = [:] + let list = WindowSelectionList( + environment: environment, + labelDrafts: Binding(get: { drafts }, set: { drafts = $0 })) + let row = try #require(list.groups.first?.rows.first) + + #expect(list.availableKeys.contains(row.key)) + list.selectionBinding(row.key).wrappedValue = false + #expect(!environment.settings.selectedWindows.contains(row.key)) + list.selectionBinding(row.key).wrappedValue = true + #expect(environment.settings.selectedWindows.contains(row.key)) + + list.label(row.key, window: row.window).wrappedValue = "CUSTOM" + #expect(environment.settings.shortLabels[row.key] == "CUSTOM") + + drafts[row.key] = nil + list.commitLabel(row.key, default: row.defaultLabel) + #expect(environment.settings.shortLabels[row.key] == nil) + + let missingWindow = QuotaWindow( + id: "missing", label: "Missing model", group: .other, usedPercent: 0, resetsAt: nil) + let missingKey = WindowKey(.gemini, missingWindow) + #expect( + list.label(missingKey, window: missingWindow).wrappedValue + == StatusItemBuilder.defaultShortLabel(provider: .gemini, window: missingWindow)) + #expect(list.labelConflictDescription(row, conflictingKey: missingKey).contains("Gemini missing")) +} + +@Test @MainActor func coverageGateWindowReorderControlsRunButtonAndAccessibilityActions() throws { + let environment = try makeEnvironment() + environment.settings.windowOrder = .provider + var list = WindowSelectionList(environment: environment) + let firstGroup = try #require(list.groups.first) + let originalProviders = list.orderDraft.providers + let providerHeader = list.providerHeader(firstGroup) + + for button in selectionGateNativeIconButtons(in: providerHeader) { button.action() } + #expect(environment.settings.providerOrder != originalProviders) + + list = WindowSelectionList(environment: environment) + let row = try #require(list.groups.first?.rows.first) + let originalModels = list.orderDraft.models + for button in selectionGateNativeIconButtons(in: list.modelRow(row)) { button.action() } + #expect(environment.settings.modelOrder != originalModels) + + for action in selectionGateVoidActions(in: providerHeader) { action() } + for action in selectionGateVoidActions(in: list.modelRow(row)) { action() } +} + +@Test @MainActor func coverageGateWindowContextAndHoverActionsRemainOperable() throws { + let environment = try makeEnvironment() + environment.settings.windowOrder = .provider + var drafts: [WindowKey: String] = [:] + var highlighted: WindowKey? + let list = WindowSelectionList( + environment: environment, + highlightedKey: Binding(get: { highlighted }, set: { highlighted = $0 }), + labelDrafts: Binding(get: { drafts }, set: { drafts = $0 })) + let group = try #require(list.groups.first) + let row = try #require(list.groups.first?.rows.first) + drafts[row.key] = "CUSTOM" + #expect(inkFraction(list.modelRow(row), width: 880, height: 100) > 0) + + list.queryChangeAction("before", "after") + list.modelMoveAction(row.key, by: 1)() + list.revertAction(row)() + let provider = list.reorderDragAction("model:\(row.key.storageKey)")() + #expect(provider.canLoadObject(ofClass: NSString.self)) + + let hover = try #require(selectionGateHoverActions(in: list.providerHeader(group)).first) + hover(true) + hover(false) + list.hover(true, row: row, target: .model(row.key)) + #expect(highlighted == row.key) + list.hover(false, row: row, target: .model(row.key)) + #expect(highlighted == nil) +} + +@MainActor +private func selectionGateNativeIconButtons(in value: Any, depth: Int = 0) -> [NativeIconButton] { + if let button = value as? NativeIconButton { return [button] } + guard depth < 48 else { return [] } + return Mirror(reflecting: value).children.flatMap { + selectionGateNativeIconButtons(in: $0.value, depth: depth + 1) + } +} + +@MainActor +private func selectionGateHoverActions(in value: Any, depth: Int = 0) -> [(Bool) -> Void] { + guard depth < 48 else { return [] } + if let action = value as? (Bool) -> Void { return [action] } + return Mirror(reflecting: value).children.flatMap { + selectionGateHoverActions(in: $0.value, depth: depth + 1) + } +} + +@MainActor +private func selectionGateVoidActions(in value: Any, depth: Int = 0) -> [() -> Void] { + if let action = value as? () -> Void { return [action] } + guard depth < 48 else { return [] } + return Mirror(reflecting: value).children.flatMap { + selectionGateVoidActions(in: $0.value, depth: depth + 1) + } +} diff --git a/Tests/TokenMenuBarUITests/DensityBudgetTests.swift b/Tests/TokenMenuBarUITests/DensityBudgetTests.swift new file mode 100644 index 0000000..07c1fc6 --- /dev/null +++ b/Tests/TokenMenuBarUITests/DensityBudgetTests.swift @@ -0,0 +1,39 @@ +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func usageContentFitsTheDensityBudget() async throws { + let height = try await measuredHeight(for: .usage) + #expect(height <= 1_100, "Usage measured \(height) points") +} + +@Test @MainActor func historyContentFitsTheDensityBudget() async throws { + let height = try await measuredHeight(for: .history) + #expect((700...760).contains(height), "History measured \(height) points") +} + +@Test @MainActor func settingsContentFitsTheDensityBudget() async throws { + let height = try await measuredHeight(for: .settings) + #expect(height <= 1_650, "Settings measured \(height) points") +} + +@MainActor +private func measuredHeight(for tab: PopoverTab) async throws -> CGFloat { + let environment = try makeEnvironment() + environment.settings.lastTab = tab + var measurements: [PopoverMeasurement] = [] + var settingsContentReady = tab != .settings + let hosting = host( + RootView(environment: environment, onMeasure: { measurements.append($0) }, onTabChange: { _ in }) + .onPreferenceChange(SettingsContentReadyKey.self) { settingsContentReady = $0 }, + width: PopoverGeometry.stableTabWidth, height: 1_600) + #expect(hosting.frame.width == PopoverGeometry.stableTabWidth) + await waitUntil { settingsContentReady } + hosting.layoutSubtreeIfNeeded() + await mainActorTurn() + hosting.layoutSubtreeIfNeeded() + await waitUntil { measurements.contains { $0.tab == tab } } + return try #require(measurements.last { $0.tab == tab }?.size.height) +} diff --git a/Tests/TokenMenuBarUITests/FeatureTests.swift b/Tests/TokenMenuBarUITests/FeatureTests.swift new file mode 100644 index 0000000..b38164b --- /dev/null +++ b/Tests/TokenMenuBarUITests/FeatureTests.swift @@ -0,0 +1,298 @@ +import AppKit +import SwiftUI +import Testing +import WidgetKit + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func demoModeTogglesSettingsAndRelaunches() throws { + let (dependencies, recorder) = try makeDependencies(isDemo: true) + let controller = AppController(dependencies: dependencies) + #expect(controller.environment.isDemo) + controller.environment.actions.setDemoMode(false) + #expect(dependencies.settings.demoMode == false) + #expect(!controller.environment.isDemo) + #expect(recorder.relaunched == 1) + #expect(dependencies.log.text.contains("demo mode off")) + #expect(inkFraction(SettingsTab(environment: controller.environment), width: 760, height: 900) > 0) + let usage = UsageTab(environment: controller.environment) + #expect(inkFraction(usage, width: 760, height: 600) > 0) +} + +@Test @MainActor func widgetSnapshotsArePublishedOnStatusRebuild() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-widget-\(UUID().uuidString)") + let store = WidgetSnapshotStore(url: root.appendingPathComponent("widget.json")) + let provider = ScriptedProvider(id: .claude, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude)))) + let (dependencies, recorder) = try makeDependencies(providers: [provider], widgetStore: store) + dependencies.settings.setProvider(.claude, enabled: true) + let controller = AppController(dependencies: dependencies) + // one publish when the sink attaches to the empty model, one when the refresh lands + await controller.flushPersistence() + #expect(recorder.reloadedWidgets == 1) + await controller.coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + await controller.flushPersistence() + #expect(store.read()?.rows.isEmpty == false) + #expect(recorder.reloadedWidgets == 2) + controller.coordinator.rebuildStatus() + await controller.flushPersistence() + #expect(recorder.reloadedWidgets == 2) + controller.publishWidget(WidgetSnapshot(rows: [], attention: false, updatedAt: fixedNow)) + await controller.flushPersistence() + #expect(store.read()?.rows.isEmpty == true) + let unwritable = WidgetSnapshotStore(url: URL(fileURLWithPath: "/dev/null/widget.json")) + let (failing, _) = try makeDependencies(widgetStore: unwritable) + let failingController = AppController(dependencies: failing) + failingController.publishWidget(.placeholder) + await failingController.flushPersistence() + #expect(failing.log.text.contains("widget snapshot write failed")) + let (none, noneRecorder) = try makeDependencies() + AppController(dependencies: none).publishWidget(.placeholder) + #expect(noneRecorder.reloadedWidgets == 0) +} + +@Test @MainActor func lifecycleFlushesTheFinalCacheAndWidgetOffMain() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-lifecycle-\(UUID().uuidString)") + let cache = SnapshotCache(url: root.appendingPathComponent("snapshots.json")) + let widget = WidgetSnapshotStore(url: root.appendingPathComponent("widget.json")) + let provider = ScriptedProvider(id: .claude, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude)))) + let (dependencies, recorder) = try makeDependencies( + providers: [provider], widgetStore: widget, snapshotCache: cache) + dependencies.settings.setProvider(.claude, enabled: true) + let controller = AppController(dependencies: dependencies) + await controller.coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + controller.handleSleep() + await controller.prepareToTerminate() + #expect(cache.load()[.claude]?.windows.isEmpty == false) + #expect(widget.read()?.rows.isEmpty == false) + #expect(recorder.reloadedWidgets == 2) +} + +@Test @MainActor func statusItemLadderStepsDownWhenHidden() async throws { + let controller = StatusItemController(log: makeLog(), diagnosticProbeInterval: 0.01) { _ in } + defer { controller.remove() } + let ladder = StatusItemBuilder.candidates( + StatusItemInput( + snapshots: [.claude: sampleSnapshot(.claude), .codex: sampleSnapshot(.codex, percent: 80)], + availability: [.claude: .current, .codex: .current], + selectedKeys: StatusItemBuilder.defaultSelection([ + .claude: sampleSnapshot(.claude), .codex: sampleSnapshot(.codex), + ]), + format: .stacked, customTemplate: "", decimals: 0, hideZeroCells: true, order: .provider, labels: [:], + now: fixedNow)) + controller.frontmostContext = { "test.app" } + controller.fitCheckDelay = .milliseconds(5) + controller.visibleItemFrame = { _ in CGRect(x: 500, y: 0, width: 80, height: 30) } + controller.notchAreas = { + (CGRect(x: 0, y: 0, width: 10, height: 30), CGRect(x: 100_000, y: 0, width: 10, height: 30)) + } + controller.update(ladder: ladder) + #expect( + controller.ladder.count + == AdaptiveWidthPlanner.ladder( + ladder, + widths: ladder.map { + Double( + StatusItemRenderer.attributedTitle(for: $0, height: controller.barHeight, dark: controller.isDark).size() + .width) + } + ).count) + #expect(controller.model == ladder[0]) + // each failed fit schedules the next check, so wait for the ladder to settle rather than a single tick + for _ in 0..<50 where controller.model == ladder[0] { await controller.settleFitCheck() } + #expect(controller.model == controller.ladder[1]) + controller.update(ladder: ladder) + #expect(!controller.checkFit()) + #expect(controller.model == controller.ladder[1]) + while controller.checkFit() == false, controller.model != controller.ladder.last! {} + #expect(controller.model.cells.isEmpty) + #expect(!controller.checkFit()) + controller.notchAreas = { (nil, nil) } + controller.update(ladder: ladder) + #expect(controller.model == ladder[0]) + #expect(controller.checkFit()) + controller.visibleItemFrame = { _ in nil } + controller.layoutChanged(forgetting: false) + // each failed fit schedules the next check, so wait for the ladder to settle rather than a single tick + for _ in 0..<50 where controller.model == ladder[0] { await controller.settleFitCheck() } + #expect(controller.model != ladder[0]) + // space came back, so the next layout pass probes a wider tier again + controller.visibleItemFrame = { _ in CGRect(x: 500, y: 0, width: 80, height: 30) } + controller.layoutChanged(forgetting: false) + #expect(controller.checkFit()) + controller.layoutChanged(forgetting: true) + #expect(controller.model == ladder[0]) + await controller.settleFitCheck() + controller.adaptive = false + controller.update(ladder: ladder) + #expect(controller.model == ladder[0]) + controller.update(.empty) + #expect(controller.ladder == [.empty]) + NotificationCenter.default.post(name: NSApplication.didChangeScreenParametersNotification, object: nil) + NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.didActivateApplicationNotification, object: nil) + await Task.yield() +} + +@Test @MainActor func onScreenFrameRequiresAVisibleWindowInsideAScreen() { + #expect(StatusItemController.onScreenFrame(of: nil) == nil) + let window = NSWindow( + contentRect: NSRect(x: 10, y: 10, width: 50, height: 20), styleMask: [.borderless], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + window.alphaValue = 0 + #expect(StatusItemController.onScreenFrame(of: window) == nil) + window.orderFrontRegardless() + let screens = NSScreen.screens + #expect(StatusItemController.onScreenFrame(of: window, screens: screens) == window.frame) + window.setFrameOrigin(NSPoint(x: 100_000, y: 10)) + #expect(StatusItemController.onScreenFrame(of: window, screens: screens) == nil) + window.setFrameOrigin(NSPoint(x: (screens.first?.frame.maxX ?? 0) - 10, y: 10)) + #expect(StatusItemController.onScreenFrame(of: window, screens: screens) == window.frame) + window.setFrame(NSRect(x: 10, y: 10, width: 0, height: 20), display: false) + #expect(StatusItemController.onScreenFrame(of: window, screens: screens) == nil) + window.orderOut(nil) +} + +@Test @MainActor func liveDependenciesBuildDemoGraph() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-demo-\(UUID().uuidString)") + let paths = LiveDependencies.Paths( + home: root.appendingPathComponent("home"), supportDirectory: root.appendingPathComponent("support"), + environment: ["TOKEN_MENU_BAR_DEMO": "1"], userName: "tester", arguments: []) + #expect(paths.demoRequested) + #expect(LiveDependencies.Paths(environment: [:], arguments: ["app", "--demo"]).demoRequested) + #expect(!LiveDependencies.Paths(environment: [:], arguments: ["app"]).demoRequested) + let defaults = UserDefaults(suiteName: "demo-\(UUID().uuidString)")! + let dependencies = try await LiveDependencies.make( + appInfo: testAppInfo, paths: paths, defaults: defaults, notificationCenter: nil, updater: nil, isSandboxed: false, + transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) + #expect(dependencies.isDemo) + #expect(dependencies.widgetStore == nil) + #expect(dependencies.history.location?.lastPathComponent == "usage-demo.sqlite") + #expect(dependencies.registry.providers.allSatisfy { $0.credentialDescription == "Demo data" }) + #expect(await dependencies.rebuildProviders(dependencies.settings).ids == ProviderID.allCases.sorted()) + #expect(dependencies.settings.enabledProviders == Set(ProviderID.allCases)) + while try await dependencies.history.stats().sampleCount == 0 { await Task.yield() } + #expect(try await dependencies.history.stats().sampleCount > 0) + await LiveDependencies.seedDemo(dependencies.history, log: dependencies.log).value + let broken = try UsageHistoryStore(url: nil) + try await broken.breakDatabase() + await LiveDependencies.seedDemo(broken, log: dependencies.log).value + #expect(dependencies.log.text.contains("demo history seeding failed")) + let settings = makeSettings() + settings.demoMode = true + let viaSetting = try await LiveDependencies.make( + appInfo: testAppInfo, + paths: LiveDependencies.Paths( + home: root, supportDirectory: root, environment: [:], userName: "tester", arguments: []), + defaults: UserDefaults(suiteName: "demo-\(UUID().uuidString)")!, notificationCenter: nil, updater: nil, + isSandboxed: false, transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) + #expect(!viaSetting.isDemo) +} + +@Test @MainActor func liveDependenciesBuildIsolatedControlAuditGraph() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-control-audit-\(UUID().uuidString)") + let support = root.appendingPathComponent("support") + let dependencies = try await LiveDependencies.make( + appInfo: testAppInfo, + paths: LiveDependencies.Paths( + home: root, supportDirectory: support, environment: ["TOKEN_MENU_BAR_DEMO": "1"], userName: "verification", + arguments: [], verificationProfile: VerificationProfile(fixture: .controlAudit)), + defaults: UserDefaults(suiteName: "control-audit-\(UUID().uuidString)")!, notificationCenter: nil, updater: nil, + isSandboxed: false, transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) + + #expect(dependencies.isDemo) + #expect(dependencies.isSandboxed) + #expect( + dependencies.registry.setupStates.values.flatMap(\.resources).count + == ProviderID.allCases.flatMap(\.sandboxResources).count) + #expect(dependencies.launchAtLogin.status() == .notRegistered) + #expect(dependencies.launchAtLogin.setEnabled(true) == .enabled) + #expect(dependencies.launchAtLogin.setEnabled(false) == .notRegistered) + #expect(dependencies.chooseDirectory(ProviderID.codex.sandboxResources[0]) == nil) + #expect(dependencies.chooseExportURL() == support.appendingPathComponent("verification-history.csv")) +} + +@Test @MainActor func widgetStoreAndRelaunchHelpers() async { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-helpers-\(UUID().uuidString)") + #expect( + LiveDependencies.widgetStore(supportDirectory: root, containerURL: { _ in nil }).url + == root.appendingPathComponent("widget.json")) + #expect(LiveDependencies.widgetStore(supportDirectory: root).url.lastPathComponent == "widget.json") + // a stub launcher, so the test never asks macOS to open a bundle + let terminated = await withCheckedContinuation { continuation in + LiveDependencies.relaunch( + bundle: .main, + open: { url, configuration, done in + #expect(url == Bundle.main.bundleURL) + #expect(configuration.createsNewApplicationInstance) + #expect(configuration.environment["TOKEN_MENU_BAR_DEMO"] == nil) + done() + }, + then: { continuation.resume(returning: true) }) + } + #expect(terminated) +} + +@Test @MainActor func dependencyDefaultsAreInert() async throws { + let dependencies = AppDependencies( + appInfo: testAppInfo, settings: makeSettings(), state: AppState(), history: try UsageHistoryStore(url: nil), + log: makeLog(), registry: ProviderRegistry([]), notifier: Notifier(center: nil, log: makeLog()), + launchAtLogin: LaunchAtLoginBackend(status: { .notRegistered }, register: {}, unregister: {}), + openURL: { _ in }, copyToPasteboard: { _ in }, revealInFinder: { _ in }, chooseExportURL: { nil }, + chooseDirectory: { _ in nil }, terminate: {}, rebuildProviders: { _ in ProviderRegistry([]) }, + screenVisibleFrame: { nil }) + dependencies.relaunch() + dependencies.reloadWidgets() + #expect(dependencies.widgetStore == nil) + UIActions().setDemoMode(true) + let controller = AppController(dependencies: dependencies) + // Opening the popover only refreshes what is due, so with nothing registered it reports nothing. + controller.refreshIfStale() + await waitUntil(within: 0.5) { dependencies.state.lastRefresh != nil } + #expect(dependencies.state.lastRefresh == nil) + controller.refreshNow() + await Task.yield() + #expect(dependencies.state.lastRefresh == nil) +} + +@Test @MainActor func helperViewsHost() { + #expect( + inkFraction( + HelpText("A fairly long explanation that needs to wrap across several lines inside the popover."), width: 300, + height: 100) > 0) + #expect( + inkFraction( + EmptyStateView(title: "Nothing", systemImage: "hourglass", description: "Waiting"), width: 300, height: 100) > 0) + var measured: [PopoverMeasurement] = [] + let environment = try! makeEnvironment() + let root = RootView(environment: environment, onMeasure: { measured.append($0) }, onTabChange: { _ in }) + root.measured(PopoverMeasurement(tab: .usage, size: .zero)) + #expect(measured.isEmpty) + root.select(.settings) + root.measured(PopoverMeasurement(tab: .history, size: CGSize(width: 10, height: 20))) + #expect(measured.last?.tab == .history) + #expect( + measured.last?.size.height + == 20 + PopoverGeometry.tabBarHeight + PopoverGeometry.footerHeight) + #expect(PopoverMeasurementKey.defaultValue == nil) +} + +@Test @MainActor func exportRunnerWritesIconsAndMenuBarStrips() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-export-\(UUID().uuidString)") + let icons = try await ExportRunner.run(.icons, directory: directory) + #expect(icons.contains { $0.lastPathComponent == "icon_512x512@2x.png" }) + let strips = try await ExportRunner.run(.menuBar, directory: directory, now: fixedNow) + #expect(strips.map(\.lastPathComponent) == ["menubar-light.png", "menubar-dark.png"]) + #expect(try Data(contentsOf: strips[0]).isEmpty == false) + try FileManager.default.removeItem(at: directory) +} + +@Test @MainActor func exportRunnerWritesOnePopoverShotPerTabAndAppearance() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-export-\(UUID().uuidString)") + #expect( + try await ExportRunner.run(.popover, directory: directory, settle: .zero).map(\.lastPathComponent) + == PopoverTab.allCases.flatMap { + ["popover-\($0.rawValue.lowercased())-light.png", "popover-\($0.rawValue.lowercased())-dark.png"] + }) + try FileManager.default.removeItem(at: directory) +} diff --git a/Tests/TokenMenuBarUITests/HistoryAnalyticsRefreshTests.swift b/Tests/TokenMenuBarUITests/HistoryAnalyticsRefreshTests.swift new file mode 100644 index 0000000..9f1dada --- /dev/null +++ b/Tests/TokenMenuBarUITests/HistoryAnalyticsRefreshTests.swift @@ -0,0 +1,29 @@ +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func historyTabReloadsAnalyticsAfterRefreshCompletes() async throws { + let environment = try makeEnvironment(populate: false) + environment.state.update(.codex) { + $0.availability = .current + $0.credentialState = .valid(expiresAt: nil) + } + let presenter = environment.historyPresenter + presenter.setMetric(.analytics(.turns)) + let hosting = host(HistoryTab(environment: environment)) + defer { withExtendedLifetime(hosting) {} } + await waitUntil { presenter.state.data != nil } + await presenter.waitForLoad() + #expect(presenter.state.data?.series.isEmpty == true) + + try await environment.history.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .turns, series: "codex", value: 3)], + fetchedAt: fixedNow)) + environment.state.markHistoryChanged() + await waitUntil { presenter.state.data?.series.isEmpty == false } + + #expect(presenter.state.data?.series.flatMap(\.points).map(\.value) == [3]) +} diff --git a/Tests/TokenMenuBarUITests/HistoryViewClosureBehaviorCoverageTests.swift b/Tests/TokenMenuBarUITests/HistoryViewClosureBehaviorCoverageTests.swift new file mode 100644 index 0000000..f489dd1 --- /dev/null +++ b/Tests/TokenMenuBarUITests/HistoryViewClosureBehaviorCoverageTests.swift @@ -0,0 +1,191 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func historyTabPagesUsingItsArrowButtons() async throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + environment.settings.historyRange = .custom + presenter.customStart = fixedNow.addingTimeInterval(-7200) + presenter.customEnd = fixedNow.addingTimeInterval(-3600) + presenter.followNow = false + presenter.reload() + await presenter.waitForLoad() + let buttons: [NativeIconButton] = historyViewValues(in: HistoryTab(environment: environment).body) + let next = try #require(buttons.first { $0.accessibilityLabel == "Next period" }) + let previous = try #require(buttons.first { $0.accessibilityLabel == "Previous period" }) + + next.action() + await presenter.waitForLoad() + #expect(presenter.followNow) + previous.action() + await presenter.waitForLoad() + #expect(!presenter.followNow) +} + +@Test @MainActor func historyTabChangesMetricThroughItsPicker() async throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + presenter.reload() + await presenter.waitForLoad() + let bindings: [Binding] = historyViewValues(in: HistoryTab(environment: environment).body) + + bindings.first?.wrappedValue = .analytics(.turns) + await presenter.waitForLoad() + + #expect(presenter.selectedMetric == .analytics(.turns)) +} + +@Test @MainActor func historyBindingsAndLegendHoverDispatchToThePresenter() async throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + let snapshot = sampleSnapshot(.claude) + environment.state.update(.claude) { $0.snapshot = snapshot } + try await environment.history.record(snapshot, now: fixedNow) + presenter.reload() + await presenter.waitForLoad() + let series = try #require(presenter.state.data?.series.first) + let tab = HistoryTab(environment: environment) + let start = fixedNow.addingTimeInterval(-7200) + let end = fixedNow.addingTimeInterval(-3600) + + tab.stackedBinding.wrappedValue = true + tab.startBinding.wrappedValue = start + await presenter.waitForLoad() + tab.endBinding.wrappedValue = end + await presenter.waitForLoad() + + #expect(environment.settings.historyStacked) + #expect(presenter.customStart == start) + #expect(presenter.customEnd == end) + + let inspector = HistoryInspector(environment: environment) + inspector.useUTCBinding.wrappedValue = true + inspector.visibilityBinding(for: series.id).wrappedValue.toggle() + HistoryLegendHoverAction(presenter: presenter, seriesID: series.id)(true) + + #expect(environment.settings.historyUseUTC) + #expect(!presenter.isVisible(series.id)) + #expect(presenter.hoveredSeriesID == series.id) + + HistoryLegendHoverAction(presenter: presenter, seriesID: series.id)(false) + #expect(presenter.hoveredSeriesID == nil) +} + +@Test @MainActor func chartPointerActionsMapThePlotToTheVisibleDomain() async throws { + struct PointerValue { + let location: CGPoint + } + + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + try await environment.history.record(sampleSnapshot(.claude), now: fixedNow) + presenter.reload() + await presenter.waitForLoad() + let data = try #require(presenter.state.data) + let chart = UsageChart(data: data, presenter: presenter, stacked: false, timeZone: .current) + let plot = CGRect(x: 10, y: 20, width: 100, height: 50) + + ChartDragAction(chart: chart, plot: plot, location: \PointerValue.location)( + PointerValue(location: CGPoint(x: 60, y: 30))) + let midpoint = data.domain.lowerBound.addingTimeInterval( + data.domain.upperBound.timeIntervalSince(data.domain.lowerBound) / 2) + #expect( + presenter.selectedDate + == ChartPipeline.nearestDate(in: data, to: midpoint)) + + ChartHoverAction(chart: chart, plot: plot)(.ended) + #expect(presenter.selectedDate == nil) + chart.pick(CGPoint(x: 10, y: 20), in: CGRect(x: 10, y: 20, width: 0, height: 50)) + #expect(presenter.selectedDate == nil) +} + +@Test @MainActor func historyTabRetryRecoversAnInitialFailure() async throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + try await environment.history.breakDatabase() + presenter.reload() + await presenter.waitForLoad() + guard case .failed = presenter.state else { + Issue.record("expected failed history load") + return + } + try await historyClosureRestoreSamples(in: environment.history) + let buttons: [NativeActionButton] = historyViewValues(in: HistoryTab(environment: environment).body) + + try #require(buttons.last).action() + await presenter.waitForLoad() + + #expect(presenter.state.data?.isEmpty == true) +} + +@Test @MainActor func historyTabRetryClearsARefreshError() async throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + presenter.reload() + await presenter.waitForLoad() + try await environment.history.breakDatabase() + presenter.reload() + await presenter.waitForLoad() + guard case .loaded(_, false, .some) = presenter.state else { + Issue.record("expected loaded history with refresh error") + return + } + try await historyClosureRestoreSamples(in: environment.history) + let buttons: [NativeActionButton] = historyViewValues(in: HistoryTab(environment: environment).body) + + try #require(buttons.last).action() + await presenter.waitForLoad() + + guard case .loaded(_, false, nil) = presenter.state else { + Issue.record("expected recovered history") + return + } +} + +@Test @MainActor func persistentTabsExposeOnlyTheSelectedHostToAccessibility() { + let container = PersistentTabContainer() + #expect(container.accessibilityChildren()?.isEmpty == true) + let usage = NSHostingView(rootView: AnyView(Text("Usage"))) + let history = NSHostingView(rootView: AnyView(Text("History"))) + container.install(usage, for: .usage) + container.install(history, for: .history) + + container.select(.history) + + let selected = container.accessibilityChildren() as? [PersistentTabSlot] + #expect(selected?.count == 1) + #expect(selected?.first?.accessibilityChildren()?.first as? NSView === history) + let inactive = container.subviews.compactMap { $0 as? PersistentTabSlot }.first { !$0.isActive } + #expect(inactive?.accessibilityChildren()?.isEmpty == true) +} + +@Test func popoverMeasurementPreferenceKeepsTheNewestMeasurement() { + let usage = PopoverMeasurement(tab: .usage, size: CGSize(width: 320, height: 400)) + let history = PopoverMeasurement(tab: .history, size: CGSize(width: 520, height: 700)) + var value: PopoverMeasurement? = usage + + PopoverMeasurementKey.reduce(value: &value) { history } + #expect(value == history) + PopoverMeasurementKey.reduce(value: &value) { nil } + #expect(value == history) +} + +private func historyViewValues(in value: Any, depth: Int = 0) -> [Value] { + if let match = value as? Value { return [match] } + guard depth < 96 else { return [] } + return Mirror(reflecting: value).children.flatMap { historyViewValues(in: $0.value, depth: depth + 1) } +} + +private func historyClosureRestoreSamples(in history: UsageHistoryStore) async throws { + try await history.database.execute( + """ + CREATE TABLE samples ( + ts REAL NOT NULL, key TEXT NOT NULL, label TEXT NOT NULL, used REAL NOT NULL, resets_at REAL, + PRIMARY KEY (key, ts) + ) + """) +} diff --git a/Tests/TokenMenuBarUITests/InteractionTests.swift b/Tests/TokenMenuBarUITests/InteractionTests.swift new file mode 100644 index 0000000..675fec5 --- /dev/null +++ b/Tests/TokenMenuBarUITests/InteractionTests.swift @@ -0,0 +1,425 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func uiActionsDefaultsChangeNothing() { + let actions = UIActions() + let environment = try! makeEnvironment() + actions.refresh() + actions.refreshProvider(.codex) + actions.showProviders(nil) + actions.openURL(URL(string: "https://example.com")!) + actions.copy("x") + actions.exportHistory() + actions.clearHistory() + actions.revealHistory() + actions.copyDiagnostics() + actions.reportIssue() + actions.showFullLog() + actions.setLaunchAtLogin(true) + actions.openLoginItems() + actions.grantAccess(ProviderID.codex.sandboxResources[0]) + actions.checkForUpdates() + actions.quit() + actions.settingsChanged() + actions.settingsReset() + actions.setDemoMode(true) + // the defaults are placeholders: none of them may reach settings, the log or the pasteboard + #expect(environment.settings.demoMode == environment.settings.demoMode) + #expect(environment.log.text.isEmpty) +} + +@Test @MainActor func chipActionsRouteToCallbacks() { + var copied: [String] = [] + let chip = ChipView(chip: Chip(text: "Max"), onCopy: { copied.append($0) }) + chip.primaryAction() + chip.copyAction() + #expect(copied == ["Max", "Max"]) +} + +@Test func repeatedModelFocusRequestsHaveDistinctIdentity() { + let key = WindowKey(provider: .codex, windowID: "weekly") + #expect(SettingsModelFocusRequest(key: key).id != SettingsModelFocusRequest(key: key).id) +} + +@Test @MainActor func usageOrderRejectsSavedModelMoves() throws { + let environment = try makeEnvironment() + environment.settings.windowOrder = .percent + let list = WindowSelectionList(environment: environment) + list.moveModel(try #require(list.availableKeys.first), by: 1) + #expect(environment.settings.modelOrder.isEmpty) +} + +@Test @MainActor func stableOrderMoveControlsStopAtGroupBoundaries() throws { + let environment = try makeEnvironment() + environment.settings.windowOrder = .provider + let list = WindowSelectionList(environment: environment) + let providers = list.orderDraft.providers + let firstProvider = try #require(providers.first) + let lastProvider = try #require(providers.last) + #expect(!list.canMoveProvider(firstProvider, by: -1)) + #expect(!list.canMoveProvider(lastProvider, by: 1)) + + let models = list.orderDraft.models.filter { $0.provider == firstProvider } + let firstModel = try #require(models.first) + let lastModel = try #require(models.last) + #expect(!list.canMoveModel(firstModel, by: -1)) + #expect(!list.canMoveModel(lastModel, by: 1)) + if models.count > 1 { + #expect(list.canMoveModel(firstModel, by: 1)) + #expect(list.canMoveModel(lastModel, by: -1)) + } +} + +@Test @MainActor func windowHelpViewRenders() { + let card = UsagePresenter.card( + provider: .claude, state: ProviderState(snapshot: sampleSnapshot(.claude), availability: .current), samples: [:], + now: fixedNow) + for row in card.rows { + #expect(inkFraction(WindowHelpView(row: row), width: 300, height: 200) > 0) + } +} + +@Test @MainActor func settingsTabBindingsAndMutations() throws { + let environment = try makeEnvironment() + var changes = 0 + var resets = 0 + environment.actions.settingsChanged = { changes += 1 } + environment.actions.settingsReset = { resets += 1 } + let tab = SettingsTab(environment: environment) + environment.isDemo = true + tab.openRepository() + tab.grantAccess(ProviderID.codex.sandboxResources[0]) + var refreshedProviders: [ProviderID] = [] + environment.actions.refreshProvider = { refreshedProviders.append($0) } + tab.perform(.checkAgain, provider: .codex, detail: "Check credentials") + tab.perform(.refreshProvider(.claude), provider: .codex, detail: "Check credentials") + #expect(refreshedProviders == [.codex, .claude]) + tab.setting(\.allowTokenRefresh).wrappedValue = true + #expect(environment.settings.allowTokenRefresh) + #expect(tab.setting(\.allowTokenRefresh).wrappedValue) + tab.menuBarSetting(\.windowOrder).wrappedValue = .percent + #expect(environment.settings.windowOrder == .percent) + #expect(tab.menuBarSetting(\.windowOrder).wrappedValue == .percent) + #expect(changes == 1) + tab.setProvider(.codex, enabled: false) + #expect(environment.settings.enabledProviders == Set(ProviderID.allCases).subtracting([.codex])) + tab.provider(.codex).wrappedValue = true + #expect(tab.provider(.codex).wrappedValue) + #expect(environment.settings.enabledProviders == Set(ProviderID.allCases)) + let issue = ProviderRecoveryIssue( + kind: .credentialPersistence, title: "Token not saved", detail: "The credential file changed.", + action: .refreshProvider(.claude)) + environment.state.update(.claude) { $0.recoveryIssue = issue } + #expect(tab.actionableRecoveryIssue(.claude) == issue) + environment.settings.setProvider(.claude, enabled: false) + #expect(tab.actionableRecoveryIssue(.claude) == nil) + environment.settings.setProvider(.claude, enabled: true) + let resources = ProviderID.claude.sandboxResources + environment.state.update(.claude) { + $0.resourceAccess = [ + ResourceAccessState.notRequired(resources[0]), ResourceAccessState(resource: resources[1], health: .needed), + ] + } + #expect(tab.visibleResourceStates(.claude).map(\.resource) == [resources[1]]) + #expect(tab.resourceText(.notRequired) == "Not required") + #expect(!tab.resourceNeedsGrant(.notRequired)) + tab.setThreshold(50, on: true) + #expect(environment.settings.notifications.thresholds == [50, 75, 90, 100]) + tab.threshold(90).wrappedValue = false + #expect(!tab.threshold(90).wrappedValue) + #expect(environment.settings.notifications.thresholds == [50, 75, 100]) + tab.historyRetentionDays.wrappedValue = 90 + #expect(environment.settings.historyRetentionDays == 90) + tab.resetDefaults() + #expect(environment.settings.windowOrder == .provider) + #expect(changes == 5) + #expect(resets == 1) + let log = LogSection(environment: environment) + log.setDetailedLogging(true) + #expect(environment.settings.detailedLogging) + #expect(environment.log.debugEnabled) + #expect(changes == 6) +} + +@Test @MainActor func settingsFormatsCredentialHealthStates() throws { + let environment = try makeEnvironment(populate: false) + let tab = SettingsTab(environment: environment) + let source = ProviderID.codex.credentialSource("test") + + environment.state.update(.codex) { $0.credentialHealth = .missing(expected: [source]) } + #expect(tab.credentialText(.codex) == "Not found") + environment.state.update(.codex) { $0.credentialHealth = .valid(source: source, expiresAt: fixedNow) } + #expect(tab.credentialText(.codex).contains("expires")) + environment.state.update(.codex) { $0.credentialHealth = .valid(source: source, expiresAt: nil) } + #expect(tab.credentialText(.codex) == "\(source.title) · \(source.detail)") + environment.credentialDescriptions[.codex] = "/custom/CODEX_HOME/auth.json" + #expect(tab.credentialText(.codex) == "\(source.title) · \(source.detail)") + environment.state.update(.codex) { $0.credentialHealth = .expired(source: source, at: fixedNow) } + #expect(tab.credentialText(.codex).contains("expired")) + environment.state.update(.codex) { $0.credentialHealth = .unreadable(source: nil, detail: "denied") } + #expect(tab.credentialText(.codex) == "/custom/CODEX_HOME/auth.json · unreadable: denied") + #expect(tab.missingAccess(.codex) == environment.settings.missingAccess(for: .codex)) +} + +@Test @MainActor func modelLabelsAndStableOrderMutateThroughSettings() throws { + let environment = try makeEnvironment() + environment.settings.windowOrder = .provider + var drafts: [WindowKey: String] = [:] + let list = WindowSelectionList( + environment: environment, + labelDrafts: Binding(get: { drafts }, set: { drafts = $0 })) + let firstRow = try #require(list.groups.first?.rows.first) + + list.setting(\.hideUnusedModels).wrappedValue = true + #expect(environment.settings.hideUnusedModels) + drafts[firstRow.key] = "NEW" + list.commitLabel(firstRow.key, default: firstRow.defaultLabel) + #expect(environment.settings.shortLabels[firstRow.key] == "NEW") + list.revert(firstRow) + #expect(environment.settings.shortLabels[firstRow.key] == nil) + + let providers = list.orderDraft.providers + let firstProvider = try #require(providers.first) + let secondProvider = try #require(providers.dropFirst().first) + list.moveProvider(secondProvider, before: firstProvider) + #expect(environment.settings.providerOrder.first == secondProvider) + list.moveProvider(secondProvider, by: 1) + #expect(environment.settings.providerOrder.first == firstProvider) + + let models = list.orderDraft.models.filter { $0.provider == firstProvider } + let firstModel = try #require(models.first) + let secondModel = try #require(models.dropFirst().first) + list.moveModel(secondModel, before: firstModel) + #expect(environment.settings.modelOrder.first { $0.provider == firstProvider } == secondModel) + list.moveModel(secondModel, by: 1) + #expect(environment.settings.modelOrder.first { $0.provider == firstProvider } == firstModel) +} + +@Test @MainActor func modelLabelConflictRetainsSavedValueUntilRevert() throws { + let environment = try makeEnvironment() + var changes = 0 + environment.actions.settingsChanged = { changes += 1 } + var drafts: [WindowKey: String] = [:] + let list = WindowSelectionList( + environment: environment, + labelDrafts: Binding(get: { drafts }, set: { drafts = $0 })) + let rows = list.groups.flatMap(\.rows) + let first = try #require(rows.first) + let second = try #require(rows.dropFirst().first) + + list.label(first).wrappedValue = "PAIR" + list.label(second).wrappedValue = " pair " + list.commitLabel(second.key, default: second.defaultLabel) + #expect(environment.settings.shortLabels[first.key] == "PAIR") + #expect(environment.settings.shortLabels[second.key] == nil) + #expect(drafts[second.key] == " pair ") + #expect(list.shortLabelAccessibilityValue(second).contains("Already used by")) + #expect(changes == 1) + + environment.settings.statusFormat = .custom + environment.settings.customTemplate = "{label}" + let preview = SettingsTab(environment: environment).previewModel + #expect(preview.cells.contains { StatusTemplate.plainText($0.lines).contains("PAIR") }) + + list.revert(first) + list.commitLabel(second.key, default: second.defaultLabel) + #expect(environment.settings.shortLabels[first.key] == nil) + #expect(environment.settings.shortLabels[second.key] == "pair") + list.revert(try #require(list.row(second.key))) + #expect(environment.settings.shortLabels[second.key] == nil) +} + +@Test @MainActor func modelRowsRenderStableUsageAndOverrideStates() throws { + let environment = try makeEnvironment() + let key = WindowKey(provider: .claude, windowID: "session") + environment.settings.shortLabels[key] = "CUSTOM" + var list = WindowSelectionList(environment: environment) + var row = try #require(list.groups.flatMap(\.rows).first { $0.key == key }) + #expect(inkFraction(list.modelRow(row), width: 760, height: 80) > 0) + + environment.settings.windowOrder = .percent + list = WindowSelectionList(environment: environment) + row = try #require(list.groups.flatMap(\.rows).first { $0.key == key }) + #expect(inkFraction(list.modelRow(row), width: 760, height: 80) > 0) + let group = try #require(list.groups.first) + #expect(inkFraction(list.providerHeader(group), width: 760, height: 80) > 0) +} + +@Test @MainActor func modelRowPreservesLongIdentityAtNarrowWidthAndInAccessibility() throws { + let environment = try makeEnvironment(populate: false) + let name = "Codex Enterprise Reasoning Model With Extended Context" + let identifier = "additional:codex-enterprise-reasoning-model-with-extended-context" + let window = QuotaWindow( + id: identifier, label: name, group: .other, usedPercent: 42, resetsAt: nil) + environment.state.update(.codex) { + $0.snapshot = ProviderSnapshot(provider: .codex, windows: [window], fetchedAt: fixedNow) + } + let list = WindowSelectionList(environment: environment) + let row = try #require(list.groups.first?.rows.first) + #expect(list.modelAccessibilityLabel(row) == "\(name), \(identifier)") + #expect(list.modelAccessibilityValue(row) == "shown, 42.00%, today, label ERW") + #expect(inkFraction(list.modelRow(row), width: 320, height: 160) > 0) +} + +@Test @MainActor func settingsActivityReloadsForHistoryChangesRatherThanEmptyRefreshes() throws { + let environment = try makeEnvironment() + let initial = WindowSelectionList(environment: environment).activityRequest + + environment.state.setRefreshing(false, at: fixedNow.addingTimeInterval(60)) + #expect(WindowSelectionList(environment: environment).activityRequest == initial) + + environment.state.markSamplesChanged() + let changed = WindowSelectionList(environment: environment).activityRequest + #expect(changed.sampleRevision == initial.sampleRevision + 1) + #expect(changed != initial) +} + +@Test @MainActor func settingsActivityCoalescesRepeatedRequests() async throws { + let environment = try makeEnvironment() + let stamp = fixedNow.addingTimeInterval(-60) + try await environment.history.record(sampleSnapshot(.claude), now: stamp) + environment.state.markSamplesChanged() + let request = WindowSelectionList(environment: environment).activityRequest + + let first = await environment.settingsActivity(for: request) + try await environment.history.breakDatabase() + + #expect(await environment.settingsActivity(for: request) == first) + environment.state.markSamplesChanged() + let changed = WindowSelectionList(environment: environment).activityRequest + #expect(await environment.settingsActivity(for: changed).isEmpty) +} + +@Test @MainActor func historyTabPagingAndChartInteractions() async throws { + let environment = try makeEnvironment() + let presenter = environment.historyPresenter + environment.settings.historyRange = .custom + presenter.customStart = fixedNow.addingTimeInterval(-7200) + presenter.customEnd = fixedNow.addingTimeInterval(-3600) + presenter.followNow = false + let tab = HistoryTab(environment: environment) + tab.pageForward() + await presenter.waitForLoad() + #expect(presenter.followNow) + tab.pageBack() + await presenter.waitForLoad() + #expect(!presenter.followNow) + try await environment.history.record(sampleSnapshot(.claude), now: fixedNow.addingTimeInterval(-60)) + presenter.reload() + await presenter.waitForLoad() + presenter.setRange(.today) + await presenter.waitForLoad() + let data = presenter.state.data! + let chart = UsageChart(data: data, presenter: presenter, stacked: false, timeZone: .current) + let plot = CGRect(x: 10, y: 0, width: 100, height: 50) + chart.hover(.active(CGPoint(x: 60, y: 10)), in: plot) + #expect(presenter.selectedDate != nil) + chart.hover(.ended, in: plot) + #expect(presenter.selectedDate == nil) + chart.pick(CGPoint(x: 20, y: 0), in: plot) + #expect(presenter.selectedDate != nil) + #expect(inkFraction(chart, width: 400, height: 240) > 0) + #expect(inkFraction(EmptyHistoryView(), width: 300, height: 200) > 0) + #expect(inkFraction(UpdatingBadge(), width: 100, height: 30) > 0) +} + +@Test @MainActor func historyTabShowsEmptyAndUpdatingStates() async throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + presenter.reload() + await presenter.waitForLoad() + #expect(presenter.state.data?.isEmpty == true) + #expect(inkFraction(HistoryTab(environment: environment), width: 700, height: 600) > 0) + await presenter.waitForLoad() + presenter.reload() + guard case .loaded(_, true, _) = presenter.state else { + Issue.record("expected refreshing state") + return + } + #expect(inkFraction(HistoryTab(environment: environment), width: 700, height: 600) > 0) + await presenter.waitForLoad() +} + +@Test @MainActor func liveDependencyHelpers() { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("dev.tox.token-menu-bar.tests")) + LiveDependencies.copy("hello", to: pasteboard) + #expect(pasteboard.string(forType: .string) == "hello") + let export = LiveDependencies.exportPanel() + #expect(export.nameFieldStringValue == "token-menu-bar-history.csv") + #expect(export.allowedContentTypes == [.commaSeparatedText]) + let codex = LiveDependencies.directoryPanel( + resource: ProviderID.codex.sandboxResources[0], default: URL(fileURLWithPath: "/tmp")) + let configured = LiveDependencies.directoryPanel( + ProviderID.codex.sandboxResources[0], paths: LiveDependencies.Paths(environment: ["CODEX_HOME": "/tmp/cx"])) + #expect(configured.directoryURL?.path == "/tmp/cx") + let accountFile = LiveDependencies.directoryPanel( + ProviderID.claude.sandboxResources[1], paths: LiveDependencies.Paths()) + #expect(accountFile.canChooseFiles) + #expect(!accountFile.canChooseDirectories) + #expect(accountFile.directoryURL?.lastPathComponent != ".claude.json") + #expect(codex.canChooseDirectories) + #expect(!codex.canChooseFiles) + #expect(codex.showsHiddenFiles) + #expect(codex.directoryURL?.path == "/tmp") + #expect(LiveDependencies.chosen(export) { _ in .cancel } == nil) + #expect(LiveDependencies.chosen(codex) { _ in .OK } == codex.url) +} + +@Test @MainActor func verificationChoosersKeepEveryPanelInsideTemporarySupport() { + let support = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-panels-\(UUID().uuidString)") + let paths = LiveDependencies.Paths( + home: support.appendingPathComponent("home"), supportDirectory: support, environment: [:], userName: "verify") + + let deterministic = LiveDependencies.exportChooser( + profile: VerificationProfile(), supportDirectory: support, run: { _ in .cancel }) + #expect(deterministic() == support.appendingPathComponent("verification-history.csv")) + let directExport = LiveDependencies.exportChooser( + profile: nil, supportDirectory: support, run: { _ in .cancel }) + #expect(directExport() == nil) + let noDirectory = LiveDependencies.directoryChooser( + profile: VerificationProfile(), paths: paths, supportDirectory: support, run: { _ in .cancel }) + #expect(noDirectory(ProviderID.codex.sandboxResources[0]) == nil) + + let nativeProfile = VerificationProfile(nativePanels: true) + var saveDirectory: URL? + let nativeExport = LiveDependencies.exportChooser( + profile: nativeProfile, supportDirectory: support, + run: { + saveDirectory = $0.directoryURL + return .cancel + }) + #expect(nativeExport() == nil) + #expect(saveDirectory == support) + + var openDirectories: [URL?] = [] + let nativeDirectory = LiveDependencies.directoryChooser( + profile: nativeProfile, paths: paths, supportDirectory: support, + run: { + openDirectories.append($0.directoryURL) + return .cancel + }) + #expect(nativeDirectory(ProviderID.codex.sandboxResources[0]) == nil) + #expect(nativeDirectory(ProviderID.claude.sandboxResources[1]) == nil) + #expect(openDirectories.compactMap { $0?.standardizedFileURL.path } == [support.path, support.path]) + + var directDirectory: URL? + let direct = LiveDependencies.directoryChooser( + profile: nil, paths: paths, supportDirectory: support, + run: { + directDirectory = $0.directoryURL + return .cancel + }) + #expect(direct(ProviderID.codex.sandboxResources[0]) == nil) + #expect(directDirectory == paths.home.appendingPathComponent(".codex")) +} + +@Test @MainActor func popoverForwardsEvents() { + let event = NSEvent.mouseEvent( + with: .mouseMoved, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, eventNumber: 0, + clickCount: 0, pressure: 0)! + #expect(PopoverController(content: AnyView(Text("x"))).forward(event) === event) +} diff --git a/Tests/TokenMenuBarUITests/LogSectionCoverageClosureTests.swift b/Tests/TokenMenuBarUITests/LogSectionCoverageClosureTests.swift new file mode 100644 index 0000000..6a29889 --- /dev/null +++ b/Tests/TokenMenuBarUITests/LogSectionCoverageClosureTests.swift @@ -0,0 +1,52 @@ +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func logSectionRoutesActionsAndOptionBindings() throws { + let environment = try makeEnvironment(populate: false) + environment.log.log("copy me") + var copied = "" + var fullLogRequests = 0 + var demoValues: [Bool] = [] + var settingsChanges = 0 + environment.actions.copy = { copied = $0 } + environment.actions.showFullLog = { fullLogRequests += 1 } + environment.actions.setDemoMode = { + demoValues.append($0) + environment.isDemo = $0 + } + environment.actions.settingsChanged = { settingsChanges += 1 } + + let section = LogSection(environment: environment) + section.copyDisplayedEntries() + section.clear() + section.showFullLog() + + #expect(copied.contains("copy me")) + #expect(environment.log.snapshot.isEmpty) + #expect(fullLogRequests == 1) + + section.demoModeBinding.wrappedValue = true + section.detailedLoggingBinding.wrappedValue = true + + #expect(demoValues.contains(true)) + #expect(environment.isDemo) + #expect(environment.settings.detailedLogging) + #expect(environment.log.debugEnabled) + #expect(settingsChanges >= 1) +} + +@Test @MainActor func logSectionSelectsAllLevelsOrOnlyTheRequestedLevel() { + #expect(LogSection.selectedLevels(nil) == Set(LogLevel.allCases)) + #expect(LogSection.selectedLevels(.warning) == [.warning]) +} + +@Test @MainActor func fullLogMergeHandlesEmptyRetainedAndMismatchedOverlap() { + let first = LogEntry(timestamp: fixedNow, level: .info, message: "first") + let second = LogEntry(timestamp: fixedNow, level: .info, message: "second") + let replacement = LogEntry(timestamp: fixedNow, level: .warning, message: "replacement") + + #expect(FullLogView.merge(retained: [], live: [first]) == [first]) + #expect(FullLogView.overlap([first, second], [first, replacement]) == 0) +} diff --git a/Tests/TokenMenuBarUITests/LogTooltipCoverageTests.swift b/Tests/TokenMenuBarUITests/LogTooltipCoverageTests.swift new file mode 100644 index 0000000..1020c92 --- /dev/null +++ b/Tests/TokenMenuBarUITests/LogTooltipCoverageTests.swift @@ -0,0 +1,137 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func tooltipTrackingContextRequiresHoverOrFocus() throws { + let screen = try #require(NSScreen.screens.first) + let presenter = TooltipPresenter(sleep: { _ in }) + let view = TooltipTrackingView( + content: TooltipContent(title: "Idle", body: "No trigger is active."), + presenter: presenter + ) + let window = NSWindow( + contentRect: CGRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.midY, width: 180, height: 60), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.alphaValue = 0 + window.contentView = view + window.orderFrontRegardless() + defer { + view.dismantle() + presenter.tearDown() + window.orderOut(nil) + } + + #expect(view.tooltipPresentationContext == nil) +} + +@Test @MainActor func logSectionCopyExportsCurrentEntriesNewestFirst() throws { + let environment = try makeEnvironment(populate: false) + environment.log.log("first") + environment.log.logWarning("second") + var copied: [String] = [] + environment.actions.copy = { copied.append($0) } + let section = LogSection(environment: environment) + let copy = try #require(findCoverageButtons(in: section.body).first) + + copy.action() + + #expect(copied == [LogExport.text(entries: environment.log.snapshot.reversed())]) +} + +@Test @MainActor func fullLogViewRespondsToLiveAppendReloadAndClear() async throws { + let directory = try makeCoverageDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let log = LogBuffer(fileURL: directory.appendingPathComponent("app.log")) + log.log("retained") + log.flush() + let hosting = host(FullLogView(log: log), width: 600, height: 300) + let window = try #require(hosting.window) + defer { + window.contentView = nil + window.close() + } + let textView: NSTextView = try #require(findCoverageView(in: hosting)) + await waitUntil { textView.string.contains("retained") } + + log.log("appended") + await waitUntil { textView.string.contains("appended") } + #expect(textView.string.contains("retained")) + + log.clear() + log.log("reloaded") + await waitUntil { textView.string.contains("reloaded") } + #expect(!textView.string.contains("retained")) + #expect(!textView.string.contains("appended")) + + log.clear() + await waitUntil { textView.string.isEmpty } + #expect(textView.string.isEmpty) +} + +@Test @MainActor func fullLogViewTrimsRetainedHistoryAfterLiveAppends() async throws { + let directory = try makeCoverageDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("app.log") + var retained = "" + retained.reserveCapacity(LogBuffer.retainedEntryLimit * 48) + let prefix = "[\(LogBuffer.timestampFormat.string(from: fixedNow))] [info] retained-" + for index in 0.. [NativeActionButton] { + if let button = value as? NativeActionButton { return [button] } + guard depth < 48 else { return [] } + return Mirror(reflecting: value).children.flatMap { findCoverageButtons(in: $0.value, depth: depth + 1) } +} + +@MainActor +private func findCoverageView(in root: NSView) -> Wanted? { + if let match = root as? Wanted { return match } + for subview in root.subviews { + if let match: Wanted = findCoverageView(in: subview) { return match } + } + return nil +} + +private func makeCoverageDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "token-menu-bar-log-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory +} diff --git a/Tests/TokenMenuBarUITests/PanelMaterialAdapterTests.swift b/Tests/TokenMenuBarUITests/PanelMaterialAdapterTests.swift new file mode 100644 index 0000000..c752ef9 --- /dev/null +++ b/Tests/TokenMenuBarUITests/PanelMaterialAdapterTests.swift @@ -0,0 +1,29 @@ +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func panelMaterialAdapterUsesFloorAvailability() { + let expected: PlatformDesignGeneration = + if #available(macOS 26, *) { + .macOS26 + } else if #available(macOS 15, *) { + .macOS15 + } else { + .macOS14 + } + #expect(PanelMaterialAdapter.generation == expected) +} + +@Test(arguments: PanelSurfaceRole.allCases) @MainActor +func panelMaterialAdapterResolvesCorePolicy(surface: PanelSurfaceRole) { + #expect( + PanelMaterialAdapter.material(for: surface) + == PanelMaterialPolicy.material(for: surface, generation: PanelMaterialAdapter.generation)) +} + +@Test(arguments: [PanelSurfaceRole.popoverChrome, .content]) @MainActor +func panelMaterialAdapterMapsPolicyToLowCostFill(surface: PanelSurfaceRole) { + let expected: PanelSurfaceFill = surface == .popoverChrome ? .inherited : .windowBackground + #expect(PanelMaterialAdapter.fill(for: surface) == expected) +} diff --git a/Tests/TokenMenuBarUITests/PopoverTests.swift b/Tests/TokenMenuBarUITests/PopoverTests.swift new file mode 100644 index 0000000..5910a18 --- /dev/null +++ b/Tests/TokenMenuBarUITests/PopoverTests.swift @@ -0,0 +1,609 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func popoverGeometryFallbacksPreferTheNearestScreenAndPinnedAnchor() { + let explicit = CGRect(x: 1, y: 2, width: 3, height: 4) + let window = CGRect(x: 5, y: 6, width: 7, height: 8) + let main = CGRect(x: 9, y: 10, width: 11, height: 12) + let previous = CGRect(x: 20, y: 40, width: 10, height: 8) + let current = CGRect(x: 30, y: 50, width: 20, height: 8) + + #expect(PopoverController.resolveVisibleFrame(explicit, windowScreen: window, mainScreen: main) == explicit) + #expect(PopoverController.resolveVisibleFrame(nil, windowScreen: window, mainScreen: main) == window) + #expect(PopoverController.resolveVisibleFrame(nil, windowScreen: nil, mainScreen: main) == main) + #expect(PopoverController.anchorDeltaX(current: current, previous: previous) == 15) + #expect(PopoverController.anchorDeltaX(current: current, previous: nil) == 0) + #expect(PopoverController.anchorOffset(pinnedTopY: 60, previous: previous) == 20) + #expect(PopoverController.anchorOffset(pinnedTopY: nil, previous: previous) == 0) +} + +@MainActor +private func anchoredPopover( + isKeyWindow: @escaping (NSWindow) -> Bool = { $0.isKeyWindow } +) -> (PopoverController, NSView, NSWindow) { + quietTestApp() + let controller = PopoverController( + content: AnyView(Text("hello").frame(width: 300, height: 200)), log: nil, animates: false, + isKeyWindow: isKeyWindow, presentsWindow: false) + let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + // A window the size of the screen, ordered front, painted a blank rectangle over the whole desktop on every run. + // The popover only needs an anchor view in a window that is on a screen, so this is small and transparent. + let frame = NSRect(x: screen.midX - 20, y: screen.maxY - 40, width: 40, height: 20) + let window = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.alphaValue = 0 + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 20)) + window.contentView?.addSubview(anchor) + window.orderFrontRegardless() + return (controller, anchor, window) +} + +@MainActor +private func keyEvent( + _ characters: String, + keyCode: UInt16, + window: NSWindow, + modifiers: NSEvent.ModifierFlags = [] +) -> NSEvent { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode)! +} + +@Test @MainActor func popoverNeedsAnAnchorToOpen() { + let (controller, _, window) = anchoredPopover() + defer { window.orderOut(nil) } + #expect(!controller.isShown) + controller.excludedFrame = { CGRect(x: 0, y: 0, width: 10, height: 10) } + controller.show(relativeTo: nil, anchorFrame: nil, visibleFrame: nil) + #expect(!controller.isShown) +} + +@Test @MainActor func popoverRejectsADetachedAnchorView() { + quietTestApp() + let controller = PopoverController(content: AnyView(EmptyView()), presentsWindow: false) + + controller.show( + relativeTo: NSView(frame: CGRect(x: 0, y: 0, width: 20, height: 20)), anchorFrame: nil, visibleFrame: nil) + + #expect(!controller.isShown) +} + +@Test @MainActor func popoverOpensAtTheMeasuredSize() { + let (controller, anchor, window) = anchoredPopover() + defer { window.orderOut(nil) } + var visibility: [Bool] = [] + controller.onVisibilityChange = { visibility.append($0) } + controller.measure(PopoverMeasurement(tab: .usage, size: CGSize(width: 480, height: 300))) + controller.select(tab: .history) + controller.toggle( + relativeTo: anchor, anchorFrame: CGRect(x: 120, y: 380, width: 40, height: 20), + visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 900)) + #expect(controller.isShown) + #expect(visibility == [true]) + #expect(controller.popover.contentSize.width >= PopoverGeometry.minimumWidth) +} + +@Test @MainActor func popoverKeepsItsWidthAndIgnoresLateOutgoingMeasurements() async throws { + let (controller, anchorView, window) = anchoredPopover() + defer { window.orderOut(nil) } + let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + // A status item near the right edge is where moving the window was most visible. + let anchorFrame = CGRect(x: screen.maxX - 60, y: screen.maxY - 24, width: 40, height: 20) + controller.show(relativeTo: anchorView, anchorFrame: anchorFrame, visibleFrame: screen) + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + let openedX = popoverWindow.frame.minX + let openedTop = popoverWindow.frame.maxY + controller.measure(PopoverMeasurement(tab: .history, size: CGSize(width: 700, height: 700))) + controller.select(tab: .history) + await waitUntil { abs(controller.popover.contentSize.height - 700) < 0.5 } + #expect(abs(popoverWindow.frame.maxY - openedTop) < 0.5) + let width = controller.popover.contentSize.width + let historyHeight = controller.popover.contentSize.height + + controller.select(tab: .settings) + controller.measure(PopoverMeasurement(tab: .history, size: CGSize(width: 700, height: 710))) + #expect(controller.popover.contentSize.height == historyHeight) + controller.measure(PopoverMeasurement(tab: .settings, size: CGSize(width: 500, height: 400))) + await waitUntil { abs(controller.popover.contentSize.height - 400) < 0.5 } + #expect(controller.activeTab == .settings) + #expect(controller.measured[.history]?.height == 710) + #expect(controller.popover.contentSize.width == width) + #expect(width == PopoverGeometry.stableWidth(maximum: screen.width - PopoverGeometry.margin * 2)) + #expect(abs(popoverWindow.frame.minX - openedX) < 0.5) + #expect(abs(popoverWindow.frame.maxY - openedTop) < 0.5) + controller.close() +} + +@Test @MainActor func popoverBudgetsItsMeasuredArrowAndWindowChrome() async throws { + let (controller, anchorView, anchorWindow) = anchoredPopover() + defer { anchorWindow.orderOut(nil) } + let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let anchorFrame = anchorWindow.frame + controller.measure(PopoverMeasurement(tab: .usage, size: CGSize(width: 880, height: 5000))) + controller.show(relativeTo: anchorView, anchorFrame: anchorFrame, visibleFrame: screen) + defer { controller.close() } + + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + let frameBudget = anchorFrame.minY - screen.minY - PopoverGeometry.margin + #expect(controller.popoverChromeSize.width > 0) + #expect(controller.popoverChromeSize.height > 0) + #expect(abs(controller.maximum.height - (frameBudget - controller.popoverChromeSize.height)) < 0.5) + #expect(controller.popover.contentSize.height <= controller.maximum.height + 0.5) + await waitUntil { + abs(popoverWindow.frame.height - controller.popover.contentSize.height - controller.popoverChromeSize.height) + < 0.5 + } + #expect(popoverWindow.frame.height <= frameBudget + 0.5) + #expect(popoverWindow.frame.width <= screen.width - PopoverGeometry.margin * 2 + 0.5) +} + +@Test @MainActor func popoverUsesDeterministicDefaultsWithoutForcingLayout() { + let measured = PopoverController(content: AnyView(Text("measured"))) + measured.measure(PopoverMeasurement(tab: .usage, size: CGSize(width: 880, height: 320))) + measured.applySize() + #expect(measured.popover.contentSize.height == 320) + + let fallback = PopoverController(content: AnyView(Text("fallback"))) + fallback.applySize() + #expect(fallback.popover.contentSize.height == PopoverGeometry.usageInitialHeight) +} + +@Test @MainActor func popoverDoesNotFightAppKitsNormalizationOfAnAppliedSize() { + let controller = PopoverController(content: AnyView(Text("measured"))) + let initial = PopoverMeasurement(tab: .usage, size: CGSize(width: 880, height: 320)) + controller.measure(initial) + controller.popover.contentSize.height = 324 + + controller.measure(initial) + + #expect(controller.popover.contentSize.height == 324) + controller.measure(PopoverMeasurement(tab: .usage, size: CGSize(width: 880, height: 360))) + #expect(controller.popover.contentSize.height == 360) +} + +@Test @MainActor func popoverPinsItsFirstVisibleFrameAfterTheBackingWindowResizes() async throws { + // The window-server arrow check is the "Verifying the live panel" recipe in website/content/contributing/_index.md. + let (controller, anchorView, anchorWindow) = anchoredPopover() + defer { anchorWindow.orderOut(nil) } + let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + controller.show(relativeTo: anchorView, anchorFrame: anchorWindow.frame, visibleFrame: screen) + defer { controller.close() } + + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + let top = popoverWindow.frame.maxY + let x = popoverWindow.frame.minX + let movedX = x + 120 + popoverWindow.setFrame( + CGRect( + origin: CGPoint(x: movedX, y: popoverWindow.frame.minY), + size: CGSize(width: popoverWindow.frame.width, height: 240)), + display: false) + await waitUntil { abs(popoverWindow.frame.maxY - top) < 0.5 } + #expect(abs(popoverWindow.frame.minX - movedX) < 0.5) + #expect(abs(popoverWindow.frame.maxY - top) < 0.5) +} + +@Test @MainActor func popoverRecoversAnOffscreenVerificationAnchor() throws { + quietTestApp() + let controller = PopoverController( + content: AnyView(Text("hello").frame(width: 300, height: 200)), animates: false, + presentsWindow: false, recoversOffscreenAnchor: true) + let anchorWindow = NSWindow( + contentRect: CGRect(x: -4604, y: 1054, width: 40, height: 20), styleMask: [.borderless], + backing: .buffered, defer: false) + anchorWindow.isReleasedWhenClosed = false + anchorWindow.alphaValue = 0 + let anchor = NSView(frame: CGRect(x: 0, y: 0, width: 40, height: 20)) + anchorWindow.contentView?.addSubview(anchor) + anchorWindow.orderFrontRegardless() + defer { anchorWindow.orderOut(nil) } + let visibleFrame = NSScreen.main?.visibleFrame ?? CGRect(x: 0, y: 0, width: 1440, height: 900) + + controller.show(relativeTo: anchor, anchorFrame: anchorWindow.frame, visibleFrame: visibleFrame) + defer { controller.close() } + + let window = try #require(controller.popover.contentViewController?.view.window) + #expect(window.frame.intersects(visibleFrame)) + #expect(window.frame.maxY <= visibleFrame.maxY) +} + +@Test @MainActor func popoverMovesItsPinnedTopWithAChangedAnchor() async throws { + let (controller, anchorView, anchorWindow) = anchoredPopover() + defer { anchorWindow.orderOut(nil) } + let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + controller.measure(PopoverMeasurement(tab: .usage, size: CGSize(width: 880, height: 10_000))) + controller.show(relativeTo: anchorView, anchorFrame: anchorWindow.frame, visibleFrame: screen) + defer { controller.close() } + + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + let oldX = popoverWindow.frame.minX + let oldTop = popoverWindow.frame.maxY + let movedAnchor = anchorWindow.frame.offsetBy(dx: 120, dy: -120) + controller.updateGeometry(anchorFrame: movedAnchor, visibleFrame: screen) + let expectedX = min(oldX + 120, screen.maxX - popoverWindow.frame.width) + await waitUntil { + abs(popoverWindow.frame.minX - expectedX) < 0.5 && abs(popoverWindow.frame.maxY - (oldTop - 120)) < 0.5 + } + #expect(abs(popoverWindow.frame.minX - expectedX) < 0.5) + #expect(abs(popoverWindow.frame.maxY - (oldTop - 120)) < 0.5) +} + +@Test @MainActor func popoverLogsEachBackingWindowResizeAfterPinning() async throws { + let log = makeLog() + log.debugEnabled = true + let (_, anchorView, anchorWindow) = anchoredPopover() + let controller = PopoverController( + content: AnyView(Text("hello").frame(width: 300, height: 200)), log: log, animates: false, + presentsWindow: false) + defer { anchorWindow.orderOut(nil) } + let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + controller.show(relativeTo: anchorView, anchorFrame: anchorWindow.frame, visibleFrame: screen) + defer { controller.close() } + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + await mainActorTurn() + log.clear() + + for height in [240.0, 280.0] { + let priorCount = log.snapshot.count { $0.message.contains("panel.resize trigger=backing-window") } + popoverWindow.setFrame( + CGRect(origin: popoverWindow.frame.origin, size: CGSize(width: popoverWindow.frame.width, height: height)), + display: false) + await waitUntil { + log.snapshot.count { $0.message.contains("panel.resize trigger=backing-window") } > priorCount + } + let entry = try #require( + log.snapshot.last { $0.message.contains("panel.resize trigger=backing-window") }) + #expect(entry.message.contains("result=")) + } +} + +@Test @MainActor func popoverReclampsItsSessionWidthWhenTheScreenChanges() { + let controller = PopoverController(content: AnyView(Text("hello"))) + controller.measure(PopoverMeasurement(tab: .usage, size: CGSize(width: 1200, height: 300))) + let screen = CGRect(x: -800, y: 0, width: 800, height: 900) + controller.updateGeometry( + anchorFrame: CGRect(x: -60, y: 880, width: 40, height: 20), visibleFrame: screen) + #expect(controller.sessionWidth == screen.width - PopoverGeometry.margin * 2) + #expect(controller.popover.contentSize.width == controller.sessionWidth) +} + +@Test @MainActor func popoverExpandsHistoryToItsIdealHeightBeforeScrolling() async { + let controller = PopoverController(content: AnyView(Text("history"))) + let screen = CGRect(x: 0, y: 0, width: 1440, height: 1300) + controller.updateGeometry( + anchorFrame: CGRect(x: 700, y: 1260, width: 40, height: 20), visibleFrame: screen) + controller.measure(PopoverMeasurement(tab: .usage, size: CGSize(width: 880, height: 320))) + controller.measure(PopoverMeasurement(tab: .history, size: CGSize(width: 880, height: 960))) + + controller.select(tab: .history) + + await waitUntil { controller.popover.contentSize.height == 960 } + #expect(controller.maximum.height > 960) + #expect(controller.popover.contentSize.height == 960) +} + +@Test @MainActor func popoverToggleClosesWhatItOpened() async throws { + let (controller, anchor, window) = anchoredPopover() + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + controller.setContent(AnyView(Text("changed"))) + controller.toggle(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + await waitUntil { !controller.isShown } + #expect(!controller.isShown) + controller.toggle(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + #expect(controller.isShown) + controller.close() +} + +@Test @MainActor func popoverDismissalIgnoresMouseMovesAndKeysItDoesNotOwn() async throws { + let (controller, anchor, window) = anchoredPopover() + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + #expect(!controller.evaluate(trigger: .mouseMoved, mouseLocation: CGPoint(x: -5000, y: -5000))) + #expect(controller.evaluate(trigger: .mouseDown, mouseLocation: CGPoint(x: -5000, y: -5000))) + let otherKey = NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, characters: "a", + charactersIgnoringModifiers: "a", isARepeat: false, keyCode: 0)! + #expect(controller.handle(otherKey) == false) + #expect(controller.routeLocal(otherKey) === otherKey) + let scroll = NSEvent.enterExitEvent( + with: .mouseEntered, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, + eventNumber: 0, trackingNumber: 0, userData: nil)! + #expect(controller.handle(scroll) == false) + let move = NSEvent.mouseEvent( + with: .mouseMoved, location: CGPoint(x: -5000, y: -5000), modifierFlags: [], timestamp: 0, windowNumber: 0, + context: nil, eventNumber: 0, clickCount: 0, pressure: 0)! + _ = controller.handle(move) + controller.close() + await waitUntil { !controller.isShown } +} + +@Test @MainActor func popoverMonitorsMouseClicksWithoutMonitoringMouseMovement() { + #expect(!PopoverController(content: AnyView(EmptyView())).popover.animates) + #expect(PopoverController.globalEventMask.contains(.leftMouseDown)) + #expect(PopoverController.globalEventMask.contains(.rightMouseDown)) + #expect(!PopoverController.globalEventMask.contains(.mouseMoved)) + #expect(PopoverController.localEventMask.contains(.keyDown)) + #expect(!PopoverController.localEventMask.contains(.mouseMoved)) +} + +@Test @MainActor func popoverRoutesEventsFromTheInstalledGlobalMonitor() throws { + var installedMask: NSEvent.EventTypeMask = [] + var installedHandler: ((NSEvent) -> Void)? + let controller = PopoverController( + content: AnyView(EmptyView()), log: nil, animates: false, isKeyWindow: { _ in true }, presentsWindow: false, + addGlobalEventMonitor: { mask, handler in + installedMask = mask + installedHandler = handler + return nil + }) + var refreshes = 0 + controller.onRefresh = { refreshes += 1 } + + controller.installMonitors() + let event = NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: .command, timestamp: 0, windowNumber: 0, context: nil, + characters: "r", charactersIgnoringModifiers: "r", isARepeat: false, keyCode: 15)! + let handler = try #require(installedHandler) + handler(event) + + #expect(installedMask == PopoverController.globalEventMask) + #expect(refreshes == 1) +} + +@Test @MainActor func popoverIgnoresMouseMovementBeforeItHasAWindow() { + let controller = PopoverController(content: AnyView(EmptyView()), presentsWindow: false) + + #expect(!controller.evaluate(trigger: .mouseMoved, mouseLocation: .zero)) + #expect(!controller.isShown) +} + +@Test @MainActor func popoverClosesOnEscape() async throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }) + defer { window.orderOut(nil) } + var visibility: [Bool] = [] + controller.onVisibilityChange = { visibility.append($0) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + popoverWindow.makeKey() + let escape = keyEvent("", keyCode: 53, window: popoverWindow) + #expect(controller.routeLocal(escape) == nil) + await waitUntil { !controller.isShown } + #expect(!controller.isShown) + #expect(visibility.last == false) +} + +@Test @MainActor func popoverPublicInitializerRejectsKeysFromANonkeyBackingWindow() throws { + let (_, anchor, window) = anchoredPopover() + defer { window.orderOut(nil) } + let controller = PopoverController(content: AnyView(EmptyView()), presentsWindow: false) + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + defer { controller.close() } + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + popoverWindow.makeKey() + let event = keyEvent("r", keyCode: 15, window: popoverWindow, modifiers: .command) + var refreshes = 0 + controller.onRefresh = { refreshes += 1 } + + #expect(!popoverWindow.isKeyWindow) + #expect(controller.routeLocal(event) === event) + #expect(refreshes == 0) +} + +@Test @MainActor func popoverInstalledMonitorConsumesOwnedEscape() async throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + + NSApplication.shared.sendEvent(keyEvent("", keyCode: 53, window: popoverWindow)) + + await waitUntil { !controller.isShown } + #expect(!controller.isShown) +} + +@Test @MainActor func popoverLocalRoutePreservesMouseEventsInsideTheExcludedFrame() { + let (controller, anchor, window) = anchoredPopover() + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + defer { controller.close() } + controller.excludedFrame = { CGRect(x: -10_000, y: -10_000, width: 20_000, height: 20_000) } + let event = NSEvent.mouseEvent( + with: .leftMouseDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: 1, pressure: 1)! + + #expect(controller.routeLocal(event) === event) + #expect(controller.isShown) +} + +@Test @MainActor func popoverStaysOpenAcrossMenuTrackingAndPointerExit() throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + let menu = NSMenu() + NotificationCenter.default.post(name: NSMenu.didBeginTrackingNotification, object: menu) + let escape = keyEvent("", keyCode: 53, window: popoverWindow) + + #expect(controller.routeLocal(escape) === escape) + #expect(!controller.evaluate(trigger: .mouseMoved, mouseLocation: CGPoint(x: -1_000, y: -1_000))) + #expect(controller.isShown) + NotificationCenter.default.post(name: NSMenu.didEndTrackingNotification, object: menu) + #expect(!controller.evaluate(trigger: .mouseMoved, mouseLocation: CGPoint(x: -1_000, y: -1_000))) + #expect(controller.isShown) +} + +@Test @MainActor func popoverClosesOnAClickOutsideIt() async throws { + let (controller, anchor, window) = anchoredPopover() + defer { window.orderOut(nil) } + controller.toggle( + relativeTo: anchor, anchorFrame: CGRect(x: 120, y: 380, width: 40, height: 20), + visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 900)) + #expect(controller.isShown) + let click = NSEvent.mouseEvent( + with: .leftMouseDown, location: CGPoint(x: -5000, y: -5000), modifierFlags: [], timestamp: 0, windowNumber: 0, + context: nil, eventNumber: 0, clickCount: 1, pressure: 1)! + _ = controller.handle(click) + await waitUntil { !controller.isShown } + #expect(!controller.isShown) + #expect(controller.popoverShouldClose(controller.popover)) +} + +@Test @MainActor func popoverRoutesCommandRToRefresh() throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + defer { controller.close() } + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + popoverWindow.makeKey() + var refreshes = 0 + controller.onRefresh = { refreshes += 1 } + let event = keyEvent("r", keyCode: 15, window: popoverWindow, modifiers: .command) + #expect(controller.routeLocal(event) == nil) + #expect(refreshes == 1) +} + +@Test @MainActor func popoverRoutesOwnedKeysFromANontextResponder() throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + defer { controller.close() } + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + #expect(popoverWindow.makeFirstResponder(nil)) + #expect(popoverWindow.firstResponder === popoverWindow) + var refreshes = 0 + controller.onRefresh = { refreshes += 1 } + + let event = keyEvent("r", keyCode: 15, window: popoverWindow, modifiers: .command) + + #expect(controller.routeLocal(event) == nil) + #expect(refreshes == 1) +} + +@Test @MainActor func popoverLeavesUnownedKeysFromItsKeyWindowUntouched() throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + defer { controller.close() } + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + let event = keyEvent("a", keyCode: 0, window: popoverWindow) + + #expect(controller.routeLocal(event) === event) + #expect(controller.isShown) +} + +@Test @MainActor func popoverLeavesKeysFromAnotherWindowUntouched() throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + defer { controller.close() } + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + popoverWindow.makeKey() + var refreshes = 0 + controller.onRefresh = { refreshes += 1 } + let event = keyEvent("r", keyCode: 15, window: window, modifiers: .command) + #expect(controller.routeLocal(event) === event) + #expect(refreshes == 0) +} + +@Test @MainActor func popoverLeavesKeysUntouchedWhenItIsNotTheKeyWindow() throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in false }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + defer { controller.close() } + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + var refreshes = 0 + controller.onRefresh = { refreshes += 1 } + let event = keyEvent("r", keyCode: 15, window: popoverWindow, modifiers: .command) + #expect(controller.routeLocal(event) === event) + #expect(refreshes == 0) +} + +@Test @MainActor func popoverLeavesKeysUntouchedWhileAMenuTracks() throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + defer { controller.close() } + let popoverWindow = try #require(controller.popover.contentViewController?.view.window) + popoverWindow.makeKey() + let menu = NSMenu() + NotificationCenter.default.post(name: NSMenu.didBeginTrackingNotification, object: menu) + defer { NotificationCenter.default.post(name: NSMenu.didEndTrackingNotification, object: menu) } + var refreshes = 0 + controller.onRefresh = { refreshes += 1 } + let event = keyEvent("r", keyCode: 15, window: popoverWindow, modifiers: .command) + #expect(controller.routeLocal(event) === event) + #expect(refreshes == 0) +} + +@Test @MainActor func popoverLeavesFieldEditorEscapeUntouched() throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + defer { controller.close() } + let popoverView = try #require(controller.popover.contentViewController?.view) + let popoverWindow = try #require(popoverView.window) + let field = NSTextField(frame: CGRect(x: 0, y: 0, width: 100, height: 24)) + popoverView.addSubview(field) + popoverWindow.makeKey() + field.selectText(nil) + let editor = try #require(popoverWindow.firstResponder as? NSTextView) + #expect(editor.isFieldEditor) + let escape = keyEvent("", keyCode: 53, window: popoverWindow) + #expect(controller.routeLocal(escape) === escape) + #expect(controller.isShown) +} + +@Test @MainActor func popoverLeavesMarkedTextShortcutsUntouched() throws { + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + defer { controller.close() } + let popoverView = try #require(controller.popover.contentViewController?.view) + let popoverWindow = try #require(popoverView.window) + let editor = NSTextView(frame: CGRect(x: 0, y: 0, width: 100, height: 24)) + popoverView.addSubview(editor) + popoverWindow.makeKey() + popoverWindow.makeFirstResponder(editor) + editor.setMarkedText( + "r", selectedRange: NSRange(location: 1, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0)) + #expect(editor.hasMarkedText()) + var refreshes = 0 + controller.onRefresh = { refreshes += 1 } + let event = keyEvent("r", keyCode: 15, window: popoverWindow, modifiers: .command) + #expect(controller.routeLocal(event) === event) + #expect(refreshes == 0) +} + +@Test @MainActor func popoverTabsDoNotForceTheWideLayoutOnANarrowDisplay() throws { + let environment = try makeEnvironment() + let views = [ + AnyView(UsageTab(environment: environment)), AnyView(HistoryTab(environment: environment)), + AnyView(RootView(environment: environment, onMeasure: { _ in }, onTabChange: { _ in })), + ] + for view in views { + let hosting = host(view, width: 600, height: 500) + let scrollView = try #require(descendant(NSScrollView.self, in: hosting)) + #expect((scrollView.documentView?.frame.width ?? 0) <= 600) + } +} + +@MainActor +private func descendant(_ type: View.Type, in root: NSView) -> View? { + if let root = root as? View { return root } + return root.subviews.lazy.compactMap { descendant(type, in: $0) }.first +} diff --git a/Tests/TokenMenuBarUITests/ProviderMarkTests.swift b/Tests/TokenMenuBarUITests/ProviderMarkTests.swift new file mode 100644 index 0000000..30095e7 --- /dev/null +++ b/Tests/TokenMenuBarUITests/ProviderMarkTests.swift @@ -0,0 +1,185 @@ +import AppKit +import CryptoKit +import Foundation +import SwiftUI +import Testing +import TokenMenuBarCore + +@testable import TokenMenuBarUI + +@Test func providerMarkCatalogLoadsAssetsFromExecutableResourceBundle() throws { + let metadataURL = try #require(ProviderMarkCatalog.metadataURL) + let executable = try #require(resolveExecutable(named: "TokenMenuBar", near: metadataURL)) + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("provider-mark-bundle-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: output) } + let process = Process() + process.executableURL = executable + process.arguments = ["--export-popover", output.path] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + try process.run() + process.waitUntilExit() + + #expect(process.terminationStatus == 0) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("popover-usage-light.png").path)) +} + +private func resolveExecutable(named name: String, near resource: URL) -> URL? { + var directory = resource.deletingLastPathComponent() + for _ in 0..<12 { + let candidate = directory.appendingPathComponent(name) + if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate } + directory.deleteLastPathComponent() + } + return nil +} + +@Test(arguments: ProviderID.allCases, ProviderMarkAppearance.allCases) +@MainActor func providerMarkLoaderLoadsDistributedAssets( + provider: ProviderID, appearance: ProviderMarkAppearance +) throws { + let image = try #require(ProviderMarkImageLoader.shared.image(for: provider, appearance: appearance)) + #expect(image.size.width > 0) + #expect(image.size.height > 0) +} + +@Test(arguments: ProviderID.allCases, ProviderMarkAppearance.allCases) +@MainActor func providerMarkLoaderPreservesOriginalRendering( + provider: ProviderID, appearance: ProviderMarkAppearance +) throws { + let image = try #require(ProviderMarkImageLoader.shared.image(for: provider, appearance: appearance)) + #expect(!image.isTemplate) +} + +@Test(arguments: ProviderID.allCases, ProviderMarkAppearance.allCases) +@MainActor func providerMarkLoaderCachesByProviderAndAppearance( + provider: ProviderID, appearance: ProviderMarkAppearance +) throws { + let first = try #require(ProviderMarkImageLoader.shared.image(for: provider, appearance: appearance)) + let second = try #require(ProviderMarkImageLoader.shared.image(for: provider, appearance: appearance)) + #expect(first === second) +} + +@Test(arguments: ProviderID.allCases) +func providerMarkBadgesAdaptTheirPaletteToAppearance(provider: ProviderID) { + let light = ProviderMarkCatalog.descriptor(for: provider, appearance: .light) + let dark = ProviderMarkCatalog.descriptor(for: provider, appearance: .dark) + #expect(light.backgroundColor != dark.backgroundColor) + #expect(light.foregroundColor != dark.foregroundColor) +} + +@Test func providerMarkBadgesUseProviderSpecificColors() { + let colors = ProviderID.allCases.map { + ProviderMarkCatalog.descriptor(for: $0, appearance: .light).backgroundColor + } + #expect(Set(colors).count == ProviderID.allCases.count) +} + +@Test(arguments: ProviderID.allCases, ProviderMarkAppearance.allCases) +func providerMarkDescriptorsExposeReadableAccessibilityLabels( + provider: ProviderID, appearance: ProviderMarkAppearance +) { + let descriptor = ProviderMarkCatalog.descriptor(for: provider, appearance: appearance) + #expect(descriptor.accessibilityLabel == provider.displayName) + #expect(!descriptor.fallbackText.isEmpty) +} + +@Test(arguments: ProviderID.allCases, [ColorScheme.light, .dark]) +@MainActor func providerMarkViewKeepsOneSlotSize(provider: ProviderID, colorScheme: ColorScheme) { + let view = ProviderMarkView(provider).environment(\.colorScheme, colorScheme) + let hosting = NSHostingView(rootView: view) + #expect(hosting.fittingSize == ProviderMarkView.defaultSize) +} + +@Test func providerMarkMetadataCoversEveryProvider() throws { + let metadata = try providerMarkMetadata() + let providers = Set(metadata.assets.map(\.provider) + metadata.fallbacks.map(\.provider)) + #expect(providers == Set(ProviderID.allCases.map(\.rawValue))) +} + +@Test func providerMarkMetadataRecordsApprovalAndSources() throws { + let metadata = try providerMarkMetadata() + #expect(metadata.retrieved == "2026-09-01") + for record in metadata.assets + metadata.fallbacks { + #expect(!record.approvalState.isEmpty) + #expect(record.sourcePage.scheme == "https") + #expect(record.terms.scheme == "https") + } +} + +@Test func providerMarkAssetArchivesHaveProvenance() throws { + for asset in try providerMarkMetadata().assets { + #expect(asset.sourceArchive?.scheme == "https") + #expect(asset.sourceArchiveSHA256?.count == 64) + } +} + +@Test func providerMarkCatalogMatchesRecordedVariants() throws { + for asset in try providerMarkMetadata().assets { + let provider = try #require(ProviderID(rawValue: asset.provider)) + for variant in asset.variants { + let appearance = try #require(ProviderMarkAppearance(rawValue: variant.appearance)) + #expect(ProviderMarkCatalog.descriptor(for: provider, appearance: appearance).resourceName == variant.resource) + } + } +} + +@Test func providerMarkFilesMatchRecordedHashes() throws { + for asset in try providerMarkMetadata().assets { + for variant in asset.variants { + let url = try #require(ProviderMarkCatalog.resourceURL(named: variant.resource)) + let digest = SHA256.hash(data: try Data(contentsOf: url)).map { String(format: "%02x", $0) }.joined() + #expect(digest == variant.sha256) + } + } +} + +private struct ProviderMarkMetadata: Decodable { + let retrieved: String + let assets: [ProviderMarkRecord] + let fallbacks: [ProviderMarkRecord] +} + +private struct ProviderMarkRecord: Decodable { + let provider: String + let approvalState: String + let sourcePage: URL + let terms: URL + let sourceArchive: URL? + let sourceArchiveSHA256: String? + let variants: [ProviderMarkVariant] + + init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + provider = try values.decode(String.self, forKey: .provider) + approvalState = try values.decode(String.self, forKey: .approvalState) + sourcePage = try values.decode(URL.self, forKey: .sourcePage) + terms = try values.decode(URL.self, forKey: .terms) + sourceArchive = try values.decodeIfPresent(URL.self, forKey: .sourceArchive) + sourceArchiveSHA256 = try values.decodeIfPresent(String.self, forKey: .sourceArchiveSHA256) + variants = try values.decodeIfPresent([ProviderMarkVariant].self, forKey: .variants) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case provider + case approvalState + case sourcePage + case terms + case sourceArchive + case sourceArchiveSHA256 + case variants + } +} + +private struct ProviderMarkVariant: Decodable { + let appearance: String + let resource: String + let sha256: String +} + +private func providerMarkMetadata() throws -> ProviderMarkMetadata { + let url = try #require(ProviderMarkCatalog.metadataURL) + return try JSONDecoder().decode(ProviderMarkMetadata.self, from: Data(contentsOf: url)) +} diff --git a/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift b/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift new file mode 100644 index 0000000..e6bc441 --- /dev/null +++ b/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift @@ -0,0 +1,45 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func usageRowsReflowAt548Points() throws { + let card = UsagePresenter.card( + provider: .claude, state: ProviderState(snapshot: sampleSnapshot(.claude), availability: .current), samples: [:], + now: fixedNow) + let row = try #require(card.rows.first) + let wide = fittingSize(WindowRowView(row: row, now: fixedNow), width: 852) + let narrow = fittingSize(WindowRowView(row: row, now: fixedNow), width: 548) + #expect(narrow.width <= 548) + #expect(narrow.height > wide.height) +} + +@Test @MainActor func modelRowsReflowAt548PointsAndAccessibilityText() throws { + let environment = try makeEnvironment() + let list = WindowSelectionList(environment: environment) + let row = try #require(list.groups.first?.rows.first) + let wide = fittingSize(list.modelRow(row), width: 852) + let narrow = fittingSize(list.modelRow(row), width: 548) + let accessible = fittingSize( + list.modelRow(row).environment(\.dynamicTypeSize, .accessibility1), width: 548) + #expect(narrow.width <= 548) + #expect(narrow.height > wide.height) + #expect(accessible.height >= narrow.height) +} + +@Test @MainActor func settingsRendersAt548PointsWithAccessibilityText() throws { + let environment = try makeEnvironment() + environment.settings.statusFormat = .custom + let view = SettingsTab(environment: environment).environment(\.dynamicTypeSize, .accessibility1) + #expect(inkFraction(view, width: 548, height: 1800) > 0) +} + +@MainActor private func fittingSize(_ view: Content, width: CGFloat) -> CGSize { + quietTestApp() + let hosting = NSHostingView( + rootView: view.frame(width: width, alignment: .leading).fixedSize(horizontal: false, vertical: true)) + hosting.layoutSubtreeIfNeeded() + return hosting.fittingSize +} diff --git a/Tests/TokenMenuBarUITests/SettingsUIBehaviorCoverageTests.swift b/Tests/TokenMenuBarUITests/SettingsUIBehaviorCoverageTests.swift new file mode 100644 index 0000000..002e281 --- /dev/null +++ b/Tests/TokenMenuBarUITests/SettingsUIBehaviorCoverageTests.swift @@ -0,0 +1,306 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func defaultViewActionsAreInert() throws { + let environment = try makeEnvironment() + let root = RootView(environment: environment, onMeasure: { _ in }, onTabChange: { _ in }) + #expect(root.chooseHistoryExportURL() == nil) + + let cell = try #require(statusModel().cells.first) + let model = StatusItemModel(cells: [cell], iconTone: .normal, showsIcon: false, countdownActive: false) + let hosting = host(StatusPreview(model: model), width: 180, height: 48) + #expect(pressElement(label: cell.tooltip, in: hosting)) +} + +@Test @MainActor func settingsReportsUndiscoveredProvidersAsNotConfigured() throws { + let environment = try makeEnvironment(populate: false) + #expect(SettingsTab(environment: environment).providerAvailabilityText(.codex) == "Not configured") +} + +@Test @MainActor func authenticationRecoveryPrefersRequiredResourceAccess() { + for (health, title) in [ + (ResourceAccessHealth.needed, "File access needed"), + (ResourceAccessHealth.stale, "Access grant needs renewal"), + ] { + let state = AppState() + let resource = ProviderID.codex.sandboxResources[0] + let source = ProviderID.codex.credentialSource("codex.file") + state.applySetupStates([ + .codex: ProviderSetupState( + enabled: true, credential: .valid(source: source, expiresAt: nil), + resources: [ResourceAccessState(resource: resource, health: health)]) + ]) + + state.update(.codex) { $0.availability = .authenticationRequired } + + let issue = state.state(for: .codex).recoveryIssue + #expect(issue?.kind == .resourceAccess) + #expect(issue?.title == title) + #expect(issue?.detail == "Grant access to ~/.codex so Codex data can be read.") + #expect(issue?.action == .grantAccess(resource)) + } +} + +@Test @MainActor func settingsPreviewFocusesItsModelAndCommitsPreviousLabel() async throws { + let environment = try makeEnvironment() + let cells = SettingsTab(environment: environment).previewModel.cells + let first = try #require(cells.first) + let second = try #require(cells.dropFirst().first) + let firstKey = try #require(WindowKey(storageKey: first.id)) + let hosting = host( + SettingsTab(environment: environment, mountsIncrementally: false), width: 880, height: 3_000) + + #expect(pressElement(label: first.tooltip, in: hosting)) + await waitUntil { hosting.window?.firstResponder is NSTextView } + let editor = try #require(hosting.window?.firstResponder as? NSTextView) + editor.selectAll(nil) + editor.insertText("FOCUS", replacementRange: editor.selectedRange()) + #expect(pressElement(label: second.tooltip, in: hosting)) + await waitUntil { environment.settings.shortLabels[firstKey] == "FOCUS" } + #expect(environment.settings.shortLabels[firstKey] == "FOCUS") +} + +@Test @MainActor func settingsRecoveryButtonRunsItsAction() throws { + let environment = try makeEnvironment() + let issue = ProviderRecoveryIssue( + kind: .credentialPersistence, title: "Token not saved", detail: "The credential file changed.", + action: .checkAgain) + environment.state.update(.claude) { $0.recoveryIssue = issue } + var refreshed: [ProviderID] = [] + environment.actions.refreshProvider = { refreshed.append($0) } + let row = SettingsTab(environment: environment).providerRow(.claude) + + let button = try #require(nativeButtons(in: row).first) + button.action() + #expect(refreshed == [.claude]) +} + +@Test @MainActor func settingsRemountIsReadyWithoutADeferredFill() async throws { + let environment = try makeEnvironment() + let firstReady = ReadyState() + let first = host( + AnyView( + SettingsTab(environment: environment) + .onPreferenceChange(SettingsContentReadyKey.self) { firstReady.set($0) }), + width: 880, height: 1_600) + await waitUntil { firstReady.value } + #expect(firstReady.value) + first.rootView = AnyView(EmptyView()) + + let lastReady = ReadyState() + let last = host( + AnyView( + SettingsTab(environment: environment) + .onPreferenceChange(SettingsContentReadyKey.self) { lastReady.set($0) }), + width: 880, height: 3_000) + await waitUntil { lastReady.value } + #expect(lastReady.value) + #expect(last.frame.width == 880) +} + +@Test @MainActor func statusPreviewHoverTracksTheCellUnderThePointer() throws { + let cell = try #require(statusModel().cells.first) + let key = try #require(WindowKey(storageKey: cell.id)) + let model = StatusItemModel(cells: [cell], iconTone: .normal, showsIcon: false, countdownActive: false) + var highlighted: WindowKey? + let preview = StatusPreview( + model: model, + highlightedKey: Binding(get: { highlighted }, set: { highlighted = $0 })) + let hover = try #require(hoverActions(in: preview.preview(cell)).first) + + hover(true) + #expect(highlighted == key) +} + +@Test @MainActor func modelRowHoverTracksTheModelUnderThePointer() throws { + let environment = try makeEnvironment() + let list = WindowSelectionList(environment: environment) + let row = try #require(list.groups.first?.rows.first) + var highlighted: WindowKey? + let bound = WindowSelectionList( + environment: environment, + highlightedKey: Binding(get: { highlighted }, set: { highlighted = $0 })) + let hover = try #require(hoverActions(in: bound.modelRow(row)).first) + + hover(true) + #expect(highlighted == row.key) + + bound.hover(false, row: row, target: .model(row.key)) + #expect(highlighted == nil) +} + +@Test @MainActor func modelRowsRenderConflictOverrideAndPlainLabelStates() throws { + let environment = try makeEnvironment() + environment.settings.windowOrder = .percent + var drafts: [WindowKey: String] = [:] + var list = WindowSelectionList( + environment: environment, + labelDrafts: Binding(get: { drafts }, set: { drafts = $0 })) + let rows = list.groups.flatMap(\.rows) + let first = try #require(rows.first) + let second = try #require(rows.dropFirst().first) + + list.label(first).wrappedValue = "PAIR" + list.label(second).wrappedValue = "pair" + #expect(inkFraction(list.modelRow(second), width: 760, height: 100) > 0) + + drafts = [:] + environment.settings.shortLabels[first.key] = "CUSTOM" + list = WindowSelectionList(environment: environment) + #expect(inkFraction(list.modelRow(try #require(list.row(first.key))), width: 760, height: 100) > 0) + + environment.settings.shortLabels[first.key] = nil + list = WindowSelectionList(environment: environment) + #expect(inkFraction(list.modelRow(try #require(list.row(first.key))), width: 760, height: 100) > 0) +} + +@Test @MainActor func stableOrderDropTargetsMoveProvidersAndModels() async throws { + let environment = try makeEnvironment() + environment.settings.windowOrder = .provider + var list = WindowSelectionList(environment: environment) + let firstGroup = try #require(list.groups.first) + let secondProvider = try #require(list.groups.dropFirst().first?.provider) + let providerHeader = host(list.providerHeader(firstGroup), width: 760, height: 80) + let unchangedProviders = environment.settings.providerOrder + + performDrop("wrong:\(secondProvider.rawValue)", on: providerHeader) + await mainActorTurn() + #expect(environment.settings.providerOrder == unchangedProviders) + performDrop("provider:\(secondProvider.rawValue)", on: providerHeader) + await waitUntil { environment.settings.providerOrder.first == secondProvider } + #expect(environment.settings.providerOrder.first == secondProvider) + + list = WindowSelectionList(environment: environment) + let models = list.orderDraft.models.filter { $0.provider == firstGroup.provider } + let firstModel = try #require(models.first) + let secondModel = try #require(models.dropFirst().first) + let row = try #require(list.row(firstModel)) + let modelRow = host(list.modelRow(row), width: 760, height: 100) + let unchangedModels = environment.settings.modelOrder + + performDrop("model:invalid", on: modelRow) + await mainActorTurn() + #expect(environment.settings.modelOrder == unchangedModels) + performDrop("model:\(secondModel.storageKey)", on: modelRow) + await waitUntil { environment.settings.modelOrder.first { $0.provider == firstGroup.provider } == secondModel } + #expect(environment.settings.modelOrder.first { $0.provider == firstGroup.provider } == secondModel) +} + +@MainActor +private func pressElement(label: String, in root: NSView) -> Bool { + pressElement(label: label, in: root as Any, depth: 0) +} + +@MainActor +private func pressElement(label: String, in value: Any, depth: Int) -> Bool { + guard depth < 30 else { return false } + if let view = value as? NSView { + if view.accessibilityLabel() == label, view.accessibilityPerformPress() { return true } + if view.subviews.contains(where: { pressElement(label: label, in: $0, depth: depth + 1) }) { return true } + if (view.accessibilityChildren() ?? []).contains(where: { + pressElement(label: label, in: $0, depth: depth + 1) + }) { + return true + } + return false + } + if let element = value as? NSAccessibilityElement { + if element.accessibilityLabel() == label, element.accessibilityPerformPress() { return true } + return (element.accessibilityChildren() ?? []).contains { + pressElement(label: label, in: $0, depth: depth + 1) + } + } + return false +} + +@MainActor +private func allViews(in root: NSView) -> [NSView] { + root.subviews.reduce(into: [root]) { views, subview in + views.append(contentsOf: allViews(in: subview)) + } +} + +private func nativeButtons(in value: Any, depth: Int = 0) -> [NativeActionButton] { + if let button = value as? NativeActionButton { return [button] } + guard depth < 48 else { return [] } + return Mirror(reflecting: value).children.flatMap { nativeButtons(in: $0.value, depth: depth + 1) } +} + +private func hoverActions(in value: Any, depth: Int = 0) -> [(Bool) -> Void] { + guard depth < 48 else { return [] } + let typeName = String(reflecting: type(of: value)) + if typeName.split(separator: "<", maxSplits: 1).first?.hasSuffix("HoverRegionModifier") == true { + return booleanActions(in: value) + } + return Mirror(reflecting: value).children.flatMap { hoverActions(in: $0.value, depth: depth + 1) } +} + +private func booleanActions(in value: Any, depth: Int = 0) -> [(Bool) -> Void] { + if let action = value as? (Bool) -> Void { return [action] } + guard depth < 16 else { return [] } + return Mirror(reflecting: value).children.flatMap { booleanActions(in: $0.value, depth: depth + 1) } +} + +@MainActor +private func performDrop(_ payload: String, on root: NSView) { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("ui-drop-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + for view in allViews(in: root) where !view.registeredDraggedTypes.isEmpty { + let info = TestDraggingInfo( + window: root.window, location: view.convert(NSPoint(x: view.bounds.midX, y: view.bounds.midY), to: nil), + pasteboard: pasteboard) + guard view.draggingEntered(info) != [] else { continue } + guard view.prepareForDragOperation(info) else { continue } + if view.performDragOperation(info) { return } + } +} + +@MainActor +private final class TestDraggingInfo: NSObject, NSDraggingInfo { + let draggingDestinationWindow: NSWindow? + let draggingSourceOperationMask: NSDragOperation = .move + let draggingLocation: NSPoint + let draggedImageLocation: NSPoint = .zero + nonisolated var draggedImage: NSImage? { nil } + let draggingPasteboard: NSPasteboard + let draggingSource: Any? = nil + let draggingSequenceNumber = 1 + var draggingFormation: NSDraggingFormation = .none + var animatesToDestination = false + var numberOfValidItemsForDrop = 1 + let springLoadingHighlight: NSSpringLoadingHighlight = .none + + init(window: NSWindow?, location: NSPoint, pasteboard: NSPasteboard) { + draggingDestinationWindow = window + draggingLocation = location + draggingPasteboard = pasteboard + } + + func slideDraggedImage(to _: NSPoint) {} + + override func namesOfPromisedFilesDropped(atDestination _: URL) -> [String]? { nil } + + func enumerateDraggingItems( + options _: NSDraggingItemEnumerationOptions, for _: NSView?, classes _: [AnyClass], + searchOptions _: [NSPasteboard.ReadingOptionKey: Any], + using _: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void + ) {} + + func resetSpringLoading() {} +} + +private final class ReadyState: @unchecked Sendable { + private let lock = NSLock() + private var ready = false + + var value: Bool { lock.withLock { ready } } + + func set(_ value: Bool) { + lock.withLock { ready = value } + } +} diff --git a/Tests/TokenMenuBarUITests/StartupClosureCoverageTests.swift b/Tests/TokenMenuBarUITests/StartupClosureCoverageTests.swift new file mode 100644 index 0000000..7740d4b --- /dev/null +++ b/Tests/TokenMenuBarUITests/StartupClosureCoverageTests.swift @@ -0,0 +1,342 @@ +import AppKit +import Foundation +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Suite(.serialized) +struct StartupClosureCoverageTests { + @Test @MainActor func startupClosureControllerCallbacksDriveTheirPublicBehaviors() async throws { + let center = FakeNotificationCenter() + let provider = StartupSequenceProvider( + id: .claude, + results: [ProviderFetchResult(outcome: .notAuthenticated("expired session"))]) + var (dependencies, recorder) = try makeDependencies(providers: [provider]) + dependencies.notifier = Notifier(center: center, log: dependencies.log) + dependencies.persistsStatusItemPosition = false + dependencies.openPopoverOnLaunch = false + dependencies.settings.configuredProviders = [.claude] + dependencies.settings.enabledProviders = [.claude] + dependencies.state.update(.claude) { + $0.snapshot = sampleSnapshot(.claude, percent: 70) + $0.availability = .current + } + let processSnapshot = try #require(dependencies.captureProcessSnapshot()) + #expect(processSnapshot.residentMemoryBytes > 0) + await dependencies.notifier.requestAuthorization() + let controller = AppController(dependencies: dependencies) + await controller.coordinator.refresh( + RefreshRequest(reason: .userInitiated, usage: .force, analytics: .skip, providers: [.claude])) + await waitUntil { center.requests.contains { $0.identifier.contains(":auth:") } } + controller.start() + defer { controller.stop() } + let statusItem = try #require(controller.statusItem) + let popover = try #require(controller.popover) + let window = NSWindow( + contentRect: CGRect(x: 100, y: 100, width: 36, height: 24), styleMask: [.borderless], backing: .buffered, + defer: false) + window.isReleasedWhenClosed = false + window.alphaValue = 0 + let button = try #require(statusItem.item.button) + button.removeFromSuperview() + window.contentView?.addSubview(button) + window.orderFrontRegardless() + defer { window.orderOut(nil) } + + #expect(statusItem.item.autosaveName != StatusItemController.autosaveName(bundleIdentifier: nil)) + statusItem.onCountdownTick?() + #expect(statusItem.model == dependencies.state.statusModel) + #expect(statusItem.menuProvider?().items.isEmpty == false) + #expect(popover.excludedFrame?() == statusItem.buttonFrameOnScreen) + let wasShown = popover.isShown + statusItem.onClick?() + #expect(popover.isShown != wasShown) + + #expect(center.requests.contains { $0.identifier.contains(":auth:") }) + + controller.environment.actions.refreshProvider(.claude) + await waitUntil { dependencies.state.state(for: .claude).snapshot != nil } + popover.onRefresh?() + let rebuilds = recorder.rebuilt + controller.environment.actions.settingsReset() + await waitUntil { recorder.rebuilt > rebuilds } + #expect(recorder.rebuilt > rebuilds) + } + + @Test @MainActor func startupClosureControllerRootCallbacksSelectAndExportHistory() async throws { + var (dependencies, recorder) = try makeDependencies() + dependencies.openPopoverOnLaunch = false + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + recorder.exportURL = directory.appendingPathComponent("history.csv") + let controller = AppController(dependencies: dependencies) + let popover = PopoverController(content: AnyView(EmptyView()), presentsWindow: false) + let root = try #require(startupClosureRootView(in: controller.rootView(popover))) + + root.select(.history) + #expect(popover.activeTab == .history) + #expect(root.chooseHistoryExportURL() == recorder.exportURL) + } + + @Test @MainActor func startupClosureReleasedControllerActionsRemainSafe() async throws { + let (dependencies, _) = try makeDependencies() + var controller: AppController? = AppController(dependencies: dependencies) + let actions = try #require(controller?.environment.actions) + let released = WeakReference(controller) + + controller = nil + actions.refreshProvider(.claude) + actions.settingsReset() + await mainActorTurn() + + #expect(released.value == nil) + #expect(AppControllerMenuSource(nil).menu().items.isEmpty) + } + + @Test @MainActor func startupClosureAppFallbacksUseAvailableGeometryAndCredentialState() throws { + var (dependencies, _) = try makeDependencies() + dependencies.recoversOffscreenPopover = true + let controller = AppController(dependencies: dependencies) + let explicit = CGRect(x: 1, y: 2, width: 3, height: 4) + let anchor = CGRect(x: 5, y: 6, width: 7, height: 8) + let previous = CGRect(x: 9, y: 10, width: 11, height: 12) + + #expect(AppController.resolveVisibleFrame(explicit, anchorScreen: anchor, previousScreen: previous) == explicit) + #expect(AppController.resolveVisibleFrame(nil, anchorScreen: anchor, previousScreen: previous) == anchor) + #expect(AppController.resolveVisibleFrame(nil, anchorScreen: nil, previousScreen: previous) == previous) + #expect(AppController.credentialHealth([:], provider: .claude) == .unchecked) + #expect( + AppController.credentialHealth([.claude: .missing(expected: [])], provider: .claude) + == .missing(expected: [])) + #expect(!controller.retryOpening(remainingAttempts: 1, previousButtonFrame: nil, forcedNarrowest: false)) + #expect(controller.retryOpening(remainingAttempts: 1, previousButtonFrame: nil, forcedNarrowest: true)) + } + + @Test @MainActor func startupClosureVerificationSnapshotFailureIsLogged() async throws { + var (dependencies, _) = try makeDependencies() + let blocker = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data().write(to: blocker) + defer { try? FileManager.default.removeItem(at: blocker) } + dependencies.verificationSession = "snapshot-write-failure" + dependencies.verificationSnapshotURL = blocker.appendingPathComponent("process-snapshot.json") + dependencies.captureProcessSnapshot = { + ProcessPerformanceSnapshot(residentMemoryBytes: 1, physicalFootprintBytes: 1, cpuNanoseconds: 1) + } + let controller = AppController(dependencies: dependencies) + controller.start() + defer { controller.stop() } + + DistributedNotificationCenter.default().post( + name: LaunchPolicy.verificationSnapshotNotification, + object: dependencies.verificationSession, + userInfo: nil) + await waitUntil { dependencies.log.text.contains("Could not write verification process snapshot") } + + #expect(dependencies.log.text.contains("Could not write verification process snapshot")) + } + + @Test @MainActor func startupClosureLogWindowPresentsWhenEnabled() { + let presented = StartupClosureLockedValue() + #expect(LogWindowController(log: makeLog()).window != nil) + let controller = LogWindowController(log: makeLog()) { _, _ in presented.set(true) } + controller.showWindow(nil) + + #expect(presented.value == true) + #expect(controller.window?.isVisible == false) + } + + @Test @MainActor func startupClosureDeferredGraphUsesInjectedLaunchAndVerificationHooks() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let application = NSApplication.shared + let previousDelegate = application.delegate + let terminator = StartupClosureTerminationDelegate() + application.delegate = terminator + defer { application.delegate = previousDelegate } + var opened = false + let dependencies = try await LiveDependencies.makeDeferred( + appInfo: testAppInfo, + paths: LiveDependencies.Paths( + home: root, supportDirectory: root.appendingPathComponent("support"), + environment: ["TOKEN_MENU_BAR_DEMO": "1"], userName: "tester", arguments: [], + verificationProfile: VerificationProfile(fixture: .standard)), + defaults: UserDefaults(suiteName: "startup-closure-deferred-\(UUID().uuidString)")!, notificationCenter: nil, + updater: nil, isSandboxed: false, transport: NoNetworkTransport(), keychain: testKeychain, + launchAtLogin: .inMemory(), + workspaceOpen: { _, configuration, completion in + opened = configuration.createsNewApplicationInstance + completion() + }) + + dependencies.launchAtLogin.openSettings() + let visibleFrame = try #require(dependencies.screenVisibleFrame()) + dependencies.relaunch() + await waitUntil { terminator.requests == 1 } + + #expect(opened) + #expect(terminator.requests == 1) + #expect(visibleFrame == NSScreen.main?.visibleFrame) + } + + @Test @MainActor func startupClosureProviderBuildReportsDeniedAndStaleGrants() async throws { + let settings = makeSettings() + settings.allowTokenRefresh = true + let resources = Array(ProviderID.allSandboxResources.prefix(2)) + settings.setBookmark(Data([1]), for: resources[0]) + settings.setBookmark(Data([2]), for: resources[1]) + let log = makeLog() + let captured = StartupClosureLockedValue() + let resolver = SecurityScopedResourceResolver( + client: SecurityScopedBookmarkClient( + resolve: { data in + SecurityScopedBookmarkResolution( + url: URL(fileURLWithPath: data == Data([1]) ? "/tmp/denied" : "/tmp/stale"), + isStale: data == Data([2])) + }, + create: { _ in throw TestError() }, + start: { $0.lastPathComponent != "denied" }, + stop: { _ in })) + + _ = await LiveDependencies.providers( + paths: LiveDependencies.Paths(environment: [:]), + client: APIClient(transport: NoNetworkTransport(), log: log), log: log, settings: settings, isSandboxed: true, + keychain: testKeychain, resolver: resolver, + buildRegistry: { configuration, _, _ in + captured.set(configuration) + return ProviderRegistry([]) + }) + let configuration = try #require(captured.value) + + #expect(configuration.allowTokenRefresh()) + #expect(log.text.contains("access failed")) + #expect(log.text.contains("access grant is stale")) + } + + @Test @MainActor func startupClosureStaleDirectBookmarkResolutionIsLogged() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let original = root.appendingPathComponent("original") + let moved = root.appendingPathComponent("moved") + try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true) + let bookmark = try (original as NSURL).bookmarkData( + options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) + try FileManager.default.moveItem(at: original, to: moved) + let log = makeLog() + + _ = LiveDependencies.resolve(bookmark: bookmark, fallback: root, log: log) + + #expect(log.text.contains("is stale")) + } + + @Test @MainActor func startupClosureEnvironmentFallbacksRemainObservable() async throws { + let settings = makeSettings() + settings.historyMetricID = "invalid-metric" + settings.hasCustomSelection = false + let history = try UsageHistoryStore(url: nil) + let state = AppState() + state.update(.claude) { + $0.snapshot = sampleSnapshot(.claude) + $0.availability = .current + } + let environment = UIEnvironment( + state: state, settings: settings, history: history, log: makeLog(), appInfo: testAppInfo, clock: testClock) + #expect(environment.historyPresenter.selectedMetric == .windowUsagePercent) + #expect(!environment.advanceUsageDeadlines()) + #expect(environment.cards.isEmpty == false) + try await history.breakDatabase() + + await environment.loadRecentSamples(force: true) + let request = SettingsActivityRequest( + keys: [WindowKey(.claude, sampleSnapshot(.claude).windows[0])], sampleRevision: state.sampleRevision, + retentionDays: 30, rangeHour: 1) + async let first = environment.settingsActivity(for: request) + async let second = environment.settingsActivity(for: request) + let results = await (first, second) + + #expect(environment.samples.values.allSatisfy { $0.isEmpty }) + #expect(results.0.isEmpty) + #expect(results.1.isEmpty) + } + + @Test @MainActor func startupClosureEnvironmentObservationDoesNotRetainIt() async throws { + let settings = makeSettings() + var environment: UIEnvironment? = try makeEnvironment(settings: settings) + let released = WeakReference(environment) + + environment = nil + settings.hasCustomSelection.toggle() + await mainActorTurn() + await mainActorTurn() + + #expect(released.value == nil) + } +} + +private final class WeakReference { + weak var value: Value? + + init(_ value: Value?) { + self.value = value + } +} + +private final class StartupSequenceProvider: UsageProvider, @unchecked Sendable { + let id: ProviderID + let pollingPolicy = PollingPolicy(minimumInterval: 0, activeInterval: 0, defaultInterval: 0) + private let lock = NSLock() + private let results: [ProviderFetchResult] + private var index = 0 + + init(id: ProviderID, results: [ProviderFetchResult]) { + self.id = id + self.results = results + } + + var credentialDescription: String { "sequence \(id.rawValue)" } + + func credentialState(now: Date) -> CredentialState { + .valid(expiresAt: nil) + } + + func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult { + lock.withLock { + defer { index += 1 } + return results[min(index, results.count - 1)] + } + } +} + +@MainActor +private final class StartupClosureTerminationDelegate: NSObject, NSApplicationDelegate { + private(set) var requests = 0 + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + requests += 1 + return .terminateCancel + } +} + +private final class StartupClosureLockedValue: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value? + + var value: Value? { + lock.withLock { stored } + } + + func set(_ value: Value) { + lock.withLock { stored = value } + } +} + +private func startupClosureRootView(in value: Any, depth: Int = 0) -> RootView? { + if let root = value as? RootView { return root } + guard depth < 48 else { return nil } + for child in Mirror(reflecting: value).children { + if let root = startupClosureRootView(in: child.value, depth: depth + 1) { return root } + } + return nil +} diff --git a/Tests/TokenMenuBarUITests/StartupCoverageTests.swift b/Tests/TokenMenuBarUITests/StartupCoverageTests.swift new file mode 100644 index 0000000..39528bc --- /dev/null +++ b/Tests/TokenMenuBarUITests/StartupCoverageTests.swift @@ -0,0 +1,339 @@ +import AppKit +import ObjectiveC +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Suite(.serialized) +struct StartupCoverageTests { + @Test @MainActor func launchPopoverWaitsWhileTheStatusItemIsDetached() async throws { + let providers = [ + ScriptedProvider(id: .claude, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude)))), + ScriptedProvider(id: .codex, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.codex)))), + ] + let (dependencies, _) = try makeDependencies(providers: providers) + let controller = AppController(dependencies: dependencies) + controller.start() + defer { controller.stop() } + let button = try #require(controller.statusItem?.item.button) + + button.removeFromSuperview() + try await Task.sleep(for: .milliseconds(1_100)) + + #expect(button.window == nil) + #expect(controller.popover?.isShown == false) + } + + @Test @MainActor func launchPopoverOpensOnceAfterStableAttachment() async throws { + let providers = [ + ScriptedProvider(id: .claude, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude)))), + ScriptedProvider(id: .codex, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.codex)))), + ] + let (dependencies, _) = try makeDependencies(providers: providers) + let controller = AppController(dependencies: dependencies) + controller.start() + defer { controller.stop() } + let button = try #require(controller.statusItem?.item.button) + let visibleFrame = NSScreen.main?.visibleFrame ?? CGRect(x: 0, y: 0, width: 1_440, height: 900) + let window = NSWindow( + contentRect: CGRect(x: visibleFrame.midX, y: visibleFrame.maxY - 24, width: 24, height: 24), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.alphaValue = 0 + button.removeFromSuperview() + button.frame = window.contentView?.bounds ?? CGRect(x: 0, y: 0, width: 24, height: 24) + window.contentView?.addSubview(button) + window.orderFrontRegardless() + defer { window.orderOut(nil) } + let popover = try #require(controller.popover) + + await waitUntil(within: 1) { popover.isShown } + + #expect(popover.isShown) + popover.close() + #expect(!popover.isShown) + } + + @Test @MainActor func applicationActivationNotificationRediscoversProviders() async throws { + let dateSource = StartupDateSource() + let clock = Clock( + now: { dateSource.now }, sleep: { _ in try await CancellationSuspension.wait() }) + let (dependencies, _) = try makeDependencies(clock: clock) + let controller = AppController(dependencies: dependencies) + controller.start() + defer { controller.stop() } + dateSource.advance(by: ProviderRediscoveryPolicy.activationInterval + 1) + + NotificationCenter.default.post(name: NSApplication.didBecomeActiveNotification, object: NSApp) + + await waitUntil { controller.dependencies.registry.ids == [.codex] } + #expect(controller.dependencies.registry.ids == [.codex]) + } + + @Test @MainActor func popoverUsesTheVisibleFrameWhenItsAnchorHasNoFrame() { + quietTestApp() + let visibleFrame = NSScreen.main?.visibleFrame ?? CGRect(x: 0, y: 0, width: 1_440, height: 900) + let window = NSWindow( + contentRect: CGRect(x: visibleFrame.midX, y: visibleFrame.maxY - 20, width: 20, height: 20), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.alphaValue = 0 + let anchor = NSView(frame: .zero) + window.contentView?.addSubview(anchor) + window.orderFrontRegardless() + defer { window.orderOut(nil) } + let controller = PopoverController( + content: AnyView(Text("fallback")), animates: false, presentsWindow: false) + + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: visibleFrame) + defer { controller.close() } + + let fallbackAnchor = CGRect(x: visibleFrame.midX, y: visibleFrame.maxY, width: 1, height: 1) + #expect( + controller.maximum + == PopoverGeometry.maxSize( + anchor: fallbackAnchor, visibleFrame: visibleFrame, popoverChromeSize: controller.popoverChromeSize)) + } + + @Test @MainActor func appDelegateDefersRepeatedTerminationRequestsUntilPersistenceFinishes() async throws { + let (dependencies, _) = try makeDependencies() + let controller = AppController(dependencies: dependencies) + controller.coordinator.start() + let delegate = AppDelegate(controller: controller) + + #expect(delegate.applicationShouldTerminate(NSApp) == .terminateLater) + #expect(delegate.applicationShouldTerminate(NSApp) == .terminateLater) + await waitUntil { !controller.coordinator.isRunning } + await mainActorTurn() + await mainActorTurn() + + #expect(!controller.coordinator.isRunning) + } + + @Test @MainActor func deferredAppDelegateReportsLoadingFailuresAndRemovesItsShell() async { + var failures: [String] = [] + let delegate = DeferredAppDelegate { + throw StartupFailure.failed + } failureHandler: { + failures.append($0) + } + delegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + defer { delegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } + + await waitUntil { failures.count == 1 } + + #expect(failures == ["failed"]) + #expect(!delegate.statusShellVisible) + #expect(delegate.controller == nil) + } + + @Test @MainActor func deferredAppDelegateTerminatesImmediatelyBeforeLoadingStarts() { + let delegate = DeferredAppDelegate { + throw StartupFailure.failed + } failureHandler: { _ in + } + + #expect(delegate.applicationShouldTerminate(NSApp) == .terminateNow) + #expect(!delegate.applicationShouldHandleReopen(NSApp, hasVisibleWindows: false)) + } + + @Test @MainActor func deferredAppDelegateCancelsBeforeTheLoaderStarts() async { + var loads = 0 + let delegate = DeferredAppDelegate { + loads += 1 + throw StartupFailure.failed + } failureHandler: { _ in + } + + delegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + delegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + await mainActorTurn() + + #expect(loads == 0) + #expect(!delegate.statusShellVisible) + } + + @Test @MainActor func deferredAppDelegateReopensAndDefersTerminationAfterLoading() async throws { + let (dependencies, _) = try makeDependencies() + let delegate = DeferredAppDelegate { + dependencies + } failureHandler: { + Issue.record("unexpected startup failure: \($0)") + } + delegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + defer { delegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } + await waitUntil { delegate.controller != nil } + let controller = try #require(delegate.controller) + + #expect(!delegate.applicationShouldHandleReopen(NSApp, hasVisibleWindows: false)) + #expect(delegate.applicationShouldTerminate(NSApp) == .terminateLater) + #expect(delegate.applicationShouldTerminate(NSApp) == .terminateLater) + await waitUntil { !controller.coordinator.isRunning } + await mainActorTurn() + await mainActorTurn() + + #expect(!controller.coordinator.isRunning) + } + + @Test @MainActor func deferredBootstrapShowsFailureThenRequestsTermination() async { + quietTestApp() + let application = NSApplication.shared + let previousDelegate = application.delegate + let terminationCanceller = TerminationCancellingDelegate() + application.delegate = terminationCanceller + let original = class_getInstanceMethod(NSAlert.self, #selector(NSAlert.runModal))! + let replacement = class_getInstanceMethod(NSAlert.self, #selector(NSAlert.startupCoverageRunModal))! + method_exchangeImplementations(original, replacement) + let delegate = AppRunner.bootstrapDeferred( + distribution: .appStore, notificationCenter: nil, updater: nil, isSandboxed: false, + paths: LiveDependencies.Paths( + home: URL(fileURLWithPath: "/dev/null"), supportDirectory: URL(fileURLWithPath: "/dev/null"), environment: [:], + userName: "tester"), + defaults: UserDefaults(suiteName: "deferred-failure-\(UUID().uuidString)")!, transport: NoNetworkTransport(), + keychain: testKeychain, launchAtLogin: .inMemory()) + defer { + method_exchangeImplementations(replacement, original) + delegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + application.delegate = previousDelegate + } + + delegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + await waitUntil { terminationCanceller.requests == 1 } + + #expect(terminationCanceller.requests == 1) + #expect(!delegate.statusShellVisible) + #expect(delegate.controller == nil) + } + + @Test @MainActor func deferredBootstrapStartsTheDemoGraph() async throws { + quietTestApp() + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let delegate = AppRunner.bootstrapDeferred( + distribution: .direct, notificationCenter: nil, updater: nil, isSandboxed: false, + paths: LiveDependencies.Paths( + home: root, supportDirectory: root.appendingPathComponent("support"), + environment: ["TOKEN_MENU_BAR_DEMO": "1"], userName: "tester"), + defaults: UserDefaults(suiteName: "deferred-success-\(UUID().uuidString)")!, transport: NoNetworkTransport(), + keychain: testKeychain, launchAtLogin: .inMemory()) + delegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + defer { delegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } + + await waitUntil(within: 30) { delegate.controller != nil } + + #expect(delegate.controller != nil) + #expect(!delegate.statusShellVisible) + } + + @Test @MainActor func brandAccentResolvesToTheAppearanceSpecificIris() throws { + let cases: [(NSAppearance.Name, BrandColor)] = [(.aqua, Brand.iris), (.darkAqua, Brand.irisDark)] + + for (name, expectedBrand) in cases { + let appearance = try #require(NSAppearance(named: name)) + var resolved: NSColor? + appearance.performAsCurrentDrawingAppearance { + resolved = NSColor(Color.brandAccent).usingColorSpace(.sRGB) + } + let actual = try #require(resolved) + let expected = try #require(NSColor(cgColor: expectedBrand.cgColor)?.usingColorSpace(.sRGB)) + + #expect(abs(actual.redComponent - expected.redComponent) < 0.001) + #expect(abs(actual.greenComponent - expected.greenComponent) < 0.001) + #expect(abs(actual.blueComponent - expected.blueComponent) < 0.001) + #expect(abs(actual.alphaComponent - expected.alphaComponent) < 0.001) + } + } + + @Test @MainActor func deferredDependenciesAssembleAndCapTheVerificationFrame() async throws { + let screen = try #require(NSScreen.main) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-deferred-live-\(UUID().uuidString)") + let support = root.appendingPathComponent("support") + let dependencies = try await LiveDependencies.makeDeferred( + appInfo: testAppInfo, + paths: LiveDependencies.Paths( + home: root, supportDirectory: support, environment: ["TOKEN_MENU_BAR_DEMO": "1"], userName: "tester", + arguments: [], + verificationProfile: VerificationProfile( + fixture: .longText, visibleFrameWidth: Double(screen.visibleFrame.width + 100))), + defaults: UserDefaults(suiteName: "deferred-live-\(UUID().uuidString)")!, notificationCenter: nil, updater: nil, + isSandboxed: false, transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) + + #expect(dependencies.history.location == support.appendingPathComponent("usage-demo.sqlite")) + #expect(dependencies.registry.ids == ProviderID.allCases.sorted()) + #expect( + dependencies.state.state(for: .codex).credentialHealth.source?.detail + .hasSuffix("account-profile-with-a-deliberately-long-file-name.json") == true) + let visibleFrame = try #require(dependencies.screenVisibleFrame()) + #expect(visibleFrame.width == screen.visibleFrame.width) + #expect(visibleFrame.maxX == screen.visibleFrame.maxX) + #expect(visibleFrame.minY == screen.visibleFrame.minY) + #expect(visibleFrame.height == screen.visibleFrame.height) + } + + @Test @MainActor func liveChooserDefaultsReturnNilWhenTheirNativePanelsAreAborted() { + quietTestApp() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-native-panels-\(UUID().uuidString)") + let paths = LiveDependencies.Paths(home: root, supportDirectory: root, environment: [:], userName: "tester") + let chooseExport = LiveDependencies.exportChooser(profile: nil, supportDirectory: root) + let saveOriginal = class_getInstanceMethod(NSSavePanel.self, #selector(NSSavePanel.runModal))! + let saveReplacement = class_getInstanceMethod(NSSavePanel.self, #selector(NSSavePanel.startupCoverageRunModal))! + method_exchangeImplementations(saveOriginal, saveReplacement) + let export = chooseExport() + method_exchangeImplementations(saveReplacement, saveOriginal) + #expect(export == nil) + + let chooseDirectory = LiveDependencies.directoryChooser(profile: nil, paths: paths, supportDirectory: root) + let openOriginal = class_getInstanceMethod(NSOpenPanel.self, #selector(NSOpenPanel.runModal))! + let openReplacement = class_getInstanceMethod( + NSOpenPanel.self, #selector(NSOpenPanel.startupCoverageOpenPanelRunModal))! + method_exchangeImplementations(openOriginal, openReplacement) + let directory = chooseDirectory(ProviderID.codex.sandboxResources[0]) + method_exchangeImplementations(openReplacement, openOriginal) + #expect(directory == nil) + } +} + +private enum StartupFailure: Error { + case failed +} + +private final class StartupDateSource: @unchecked Sendable { + private let lock = NSLock() + private var date = fixedNow + + var now: Date { lock.withLock { date } } + + func advance(by interval: TimeInterval) { + lock.withLock { date.addTimeInterval(interval) } + } +} + +@MainActor +private final class TerminationCancellingDelegate: NSObject, NSApplicationDelegate { + private(set) var requests = 0 + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + requests += 1 + return .terminateCancel + } +} + +extension NSAlert { + @objc fileprivate func startupCoverageRunModal() -> NSApplication.ModalResponse { + .cancel + } +} + +extension NSSavePanel { + @objc fileprivate func startupCoverageRunModal() -> NSApplication.ModalResponse { + .cancel + } +} + +extension NSOpenPanel { + @objc fileprivate func startupCoverageOpenPanelRunModal() -> NSApplication.ModalResponse { + .cancel + } +} diff --git a/Tests/TokenMenuBarUITests/StatusItemTests.swift b/Tests/TokenMenuBarUITests/StatusItemTests.swift new file mode 100644 index 0000000..ae2fba6 --- /dev/null +++ b/Tests/TokenMenuBarUITests/StatusItemTests.swift @@ -0,0 +1,514 @@ +import AppKit +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test(arguments: [StatusIconTone.normal, .offline, .attention], [false, true]) +@MainActor func appIconDrawsEveryTone(tone: StatusIconTone, dark: Bool) { + let image = AppIcon.image(height: 18, tone: tone, dark: dark) + #expect(image.size == CGSize(width: 18, height: 18)) + #expect(image.tiffRepresentation != nil) + #expect(!image.isTemplate) +} + +@Test( + arguments: [ + (StatusIconTone.offline, false, NSColor.systemGray), (.attention, true, .white), (.normal, false, .black), + ]) +func appIconInkFollowsTone(tone: StatusIconTone, dark: Bool, ink: NSColor) { + #expect(AppIcon.inkColor(tone: tone, dark: dark) == ink) +} + +@Test @MainActor func appIconViewDraws() { + #expect(host(AppIconView(size: 24, tone: .attention), width: 40, height: 40).fittingSize.width > 0) + #expect(inkFraction(AppIconView(size: 24).environment(\.colorScheme, .dark), width: 40, height: 40) > 0) +} + +@Test @MainActor func statusItemPlacementIsNamespacedByBundle() { + #expect(StatusItemController.autosaveName(bundleIdentifier: nil) == "dev.tox.token-menu-bar.status") + #expect( + StatusItemController.autosaveName(bundleIdentifier: "dev.tox.token-menu-bar.verification") + == "dev.tox.token-menu-bar.verification.status") +} + +@Test @MainActor func statusItemPlacementCanBeEphemeral() { + let controller = StatusItemController(log: makeLog(), autosaveName: nil) { _ in } + defer { controller.remove() } + + #expect(controller.item.autosaveName != StatusItemController.autosaveName(bundleIdentifier: nil)) +} + +@Test @MainActor func statusItemCanReattachAfterItsWindowLeavesTheScreen() { + let controller = statusController() + defer { controller.remove() } + + controller.reattach() + + #expect(controller.item.isVisible) + #expect(controller.item.button?.image != nil) +} + +@Test @MainActor func providerGlyphs() { + #expect(ProviderGlyph.image(.claude, pointSize: 12).size.width > 0) + #expect(ProviderGlyph.image(.codex, pointSize: 12).size.width > 0) + #expect(ProviderGlyph.symbolName(.claude) != ProviderGlyph.symbolName(.codex)) + #expect(ProviderGlyph.color(.claude) != ProviderGlyph.color(.codex)) +} + +@Test @MainActor func rendererFontSizeAndColors() { + #expect(StatusItemRenderer.fontSizes(height: 18, lineCount: 1) == [13]) + #expect(StatusItemRenderer.fontSizes(height: 24, lineCount: 2) == [9, 11.5]) + #expect(StatusItemRenderer.fontSizes(height: 30, lineCount: 3) == [8, 8, 8]) + #expect(StatusItemRenderer.fontSizes(height: 60, lineCount: 4) == [9, 9, 9, 9]) + #expect(StatusItemRenderer.color(for: .label, dark: true) == .white) + #expect(StatusItemRenderer.color(for: .label, dark: false) == .black) + #expect(StatusItemRenderer.color(for: .number, dark: false).alphaComponent < 1) + let dark = StatusItemRenderer.color(for: .usage(50), dark: true) + #expect(dark.brightnessComponent > StatusItemRenderer.color(for: .usage(50), dark: false).brightnessComponent) +} + +@Test @MainActor func rendererBuildsTitlesForEachFormat() { + for format in StatusFormat.allCases { + let model = statusModel(format: format) + let title = StatusItemRenderer.attributedTitle(for: model, height: 18, dark: false) + #expect(title.length == max(model.cells.count * 2 - 1, 0)) + for cell in model.cells { + let image = StatusItemRenderer.cellImage(cell, height: 18, dark: true) + #expect(image.size.height == 18) + #expect(image.size.width > 0) + #expect(image.tiffRepresentation != nil) + } + let preview = StatusItemRenderer.previewImage(for: model, height: 18, dark: false) + #expect(preview.size.width > 0) + #expect(preview.tiffRepresentation != nil) + } + #expect(StatusItemRenderer.accessibilityDescription(for: .empty) == "Token Menu Bar, no usage yet") + #expect( + StatusItemRenderer.accessibilityDescription(for: statusModel()).contains("Claude Current session: 36%, resets")) + #expect(StatusItemRenderer.accessibilityDescription(for: statusModel()).contains("Displayed CC 5h / 36%")) + #expect( + StatusItemRenderer.accessibilityDescription(for: statusModel(format: .miniBars)) + .contains("Displayed CC 5h bar at 36%")) + let empty = StatusItemRenderer.previewImage(for: .empty, height: 18, dark: true) + #expect(empty.size.width == 18) + #expect(StatusItemRenderer.attributedTitle(for: .empty, height: 18, dark: false).length == 0) + let signature = StatusRenderSignature(model: .empty, dark: true, height: 22) + #expect(signature == StatusRenderSignature(model: .empty, dark: true, height: 22)) +} + +@Test @MainActor func rendererSizesEmptyTextAndBarCells() { + let cell = StatusCell(id: "empty", provider: .claude, lines: [], percent: 0, tooltip: "") + + #expect(StatusItemRenderer.textImage(cell, height: 18, dark: false).size.width == StatusItemRenderer.cellPadding * 2) + #expect(StatusItemRenderer.miniBarImage(cell, height: 18, dark: false).size.width > 0) +} + +@MainActor +private func statusController( + clock: Clock = .system, + diagnosticProbeInterval: Double = 0.05, + onPresent: @escaping (NSStatusBarButton) -> Void = { _ in } +) + -> StatusItemController +{ + let log = makeLog() + log.debugEnabled = true + return StatusItemController( + log: log, clock: clock, diagnosticProbeInterval: diagnosticProbeInterval + ) { onPresent($0) } +} + +private final class CadenceClock: @unchecked Sendable { + private let lock = NSLock() + private var date: Date + private var immediateSleeps: Int + private var intervals: [TimeInterval] = [] + + init(date: Date, immediateSleeps: Int) { + self.date = date + self.immediateSleeps = immediateSleeps + } + + var recordedIntervals: [TimeInterval] { + lock.withLock { intervals } + } + + var clock: Clock { + Clock( + now: { self.lock.withLock { self.date } }, + sleep: { interval in + let returnsImmediately = self.lock.withLock { + self.intervals.append(interval) + guard self.immediateSleeps > 0 else { return false } + self.immediateSleeps -= 1 + self.date = self.date.addingTimeInterval(interval) + return true + } + if !returnsImmediately { try await CancellationSuspension.wait() } + }) + } +} + +private final class ResumableClock: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var returnCount = 0 + + var isSleeping: Bool { + lock.withLock { continuation != nil } + } + + var returnedSleeps: Int { + lock.withLock { returnCount } + } + + var clock: Clock { + Clock( + now: { fixedNow }, + sleep: { _ in + try await withCheckedThrowingContinuation { continuation in + self.lock.withLock { self.continuation = continuation } + } + self.lock.withLock { self.returnCount += 1 } + }) + } + + func resume() { + let continuation = lock.withLock { + defer { self.continuation = nil } + return self.continuation + } + continuation?.resume() + } +} + +@Test @MainActor func statusItemDrawsTheIconWhenThereIsNothingToShow() { + let controller = statusController() + defer { controller.remove() } + #expect(controller.item.button != nil) + #expect(controller.barHeight >= 22) + controller.update(.empty) + #expect(controller.item.button?.image != nil) + #expect(controller.item.button?.imagePosition != .noImage) +} + +@Test @MainActor func statusItemRendersAnIconBesideNonemptyCells() { + let controller = statusController() + defer { controller.remove() } + let source = statusModel(format: .stacked) + let model = StatusItemModel( + cells: source.cells, iconTone: source.iconTone, showsIcon: true, countdownActive: source.countdownActive) + + controller.update(model) + + #expect(controller.item.button?.imagePosition == .imageLeading) +} + +@Test @MainActor func statusItemUsesSafeValuesWithoutAStatusBarButton() { + let controller = StatusItemController(item: NSStatusItem(), log: makeLog(), presentMenu: { _ in }) + defer { controller.remove() } + + _ = controller.isDark + controller.update(.empty) + let probe = controller.probe() + + #expect(probe.buttonHidden) + #expect(probe.buttonWidth == 0) +} + +@Test func statusItemProbeSummaryMarksUnavailableValues() { + let probe = StatusItemProbe( + isVisible: false, buttonHidden: true, windowVisible: nil, occlusionVisible: nil, length: 0, buttonWidth: 0, + frontmostApp: nil) + + #expect(probe.summary.contains("window=- occlusion=-")) + #expect(probe.summary.hasSuffix("front=-")) +} + +@Test func statusItemNormalizesAnUnavailableFrontmostContext() { + #expect(StatusItemController.normalizedContext(nil).isEmpty) + #expect(StatusItemController.normalizedContext("com.example.editor") == "com.example.editor") +} + +@Test @MainActor func statusItemTreatsAnEmptyLadderAsTheEmptyModel() { + let controller = statusController() + defer { controller.remove() } + + controller.update(ladder: []) + + #expect(controller.ladder == [.empty]) + #expect(controller.model == .empty) + #expect(controller.collapseToNarrowest()) +} + +@Test @MainActor func statusItemDrawsCellsAsAttributedTitle() { + let controller = statusController() + defer { controller.remove() } + controller.update(statusModel(format: .custom)) + #expect(controller.item.button?.attributedTitle.length == 7) + #expect(controller.item.button?.image == nil) + #expect(controller.item.button?.toolTip?.contains("Claude") == true) +} + +@Test @MainActor func statusItemButtonReadsTheValuesItDraws() { + let controller = statusController() + defer { controller.remove() } + controller.update(.empty) + #expect(controller.item.button?.accessibilityLabel() == "Token Menu Bar, no usage yet") + controller.update(statusModel(format: .miniBars)) + let label = controller.item.button?.accessibilityLabel() + #expect(label?.contains("Current session: 36%") == true) + #expect(label?.contains("\n") == false) +} + +@Test @MainActor func statusItemAccessibilityTracksVisibleSettingsWithoutMovingTheOpenAnchor() throws { + let controller = statusController() + defer { controller.remove() } + controller.adaptive = false + controller.update(configuredStatusModel(format: .stacked, decimals: 0)) + controller.popoverVisible = true + let frozenFrame = try #require(controller.item.button?.frame) + let frozenLength = controller.item.length + let stackedDescription = try #require(controller.item.button?.accessibilityLabel()) + #expect(stackedDescription.contains("Displayed CC 5h / 36%")) + + controller.update(configuredStatusModel(format: .inline, decimals: 1)) + let inlineDescription = try #require(controller.item.button?.accessibilityLabel()) + #expect(inlineDescription.contains("Displayed CC 5h:36.0%")) + #expect(inlineDescription != stackedDescription) + #expect(controller.item.length == frozenLength) + #expect(controller.item.button?.frame == frozenFrame) + + controller.update(configuredStatusModel(format: .custom, decimals: 2, label: "SOL")) + let labelDescription = try #require(controller.item.button?.accessibilityLabel()) + #expect(labelDescription.contains("Displayed SOL 36.00%")) + #expect(labelDescription != inlineDescription) + #expect(controller.item.length == frozenLength) + #expect(controller.item.button?.frame == frozenFrame) +} + +@Test @MainActor func statusItemRunsTheCountdownOnlyForTemplatesThatNeedIt() async throws { + let cadence = CadenceClock(date: Date(timeIntervalSince1970: 125), immediateSleeps: 1) + let controller = statusController(clock: cadence.clock) + defer { controller.remove() } + var ticks = 0 + controller.onCountdownTick = { ticks += 1 } + controller.update(statusModel(format: .custom)) + await waitUntil { ticks == 1 && cadence.recordedIntervals.count == 2 } + #expect(controller.countdownRunning) + #expect(cadence.recordedIntervals == [55, 60]) + #expect(ticks == 1) + controller.update(statusModel(format: .custom)) + #expect(cadence.recordedIntervals == [55, 60]) + controller.update(statusModel(format: .stacked)) + #expect(!controller.countdownRunning) +} + +@Test @MainActor func statusItemClearsAStoppedCountdownTask() async { + let clock = Clock(now: { Date(timeIntervalSince1970: 125) }, sleep: { _ in throw CancellationError() }) + let controller = statusController(clock: clock) + defer { controller.remove() } + controller.update(statusModel(format: .custom)) + await waitUntil { !controller.countdownRunning } + #expect(!controller.countdownRunning) +} + +@Test @MainActor func statusItemDoesNotTickAfterCountdownCancellation() async throws { + let sleeper = ResumableClock() + let controller = statusController(clock: sleeper.clock) + defer { controller.remove() } + var ticks = 0 + controller.onCountdownTick = { ticks += 1 } + controller.update(statusModel(format: .custom)) + await waitUntil { sleeper.isSleeping } + + controller.update(statusModel(format: .stacked)) + sleeper.resume() + + await waitUntil { sleeper.returnedSleeps == 1 } + await mainActorTurn() + #expect(ticks == 0) +} + +@Test @MainActor func statusItemTickerDoesNotTickAfterCancellation() async { + let sleeper = ResumableClock() + var ticks = 0 + let task = StatusItemController.ticker(every: 30, clock: sleeper.clock) { ticks += 1 } + await waitUntil { sleeper.isSleeping } + + task.cancel() + sleeper.resume() + await task.value + + #expect(ticks == 0) +} + +@Test func statusItemCountdownDeadlineIsTheNextMinuteBoundary() { + #expect( + StatusItemController.nextCountdownUpdate(after: Date(timeIntervalSince1970: 125)) + == Date(timeIntervalSince1970: 180)) + #expect( + StatusItemController.nextCountdownUpdate(after: Date(timeIntervalSince1970: 180)) + == Date(timeIntervalSince1970: 240)) +} + +@Test @MainActor func statusItemProbesPeriodicallyOnlyForDetailedLogging() async throws { + let cadence = CadenceClock(date: fixedNow, immediateSleeps: 1) + let controller = statusController(clock: cadence.clock, diagnosticProbeInterval: 30) + defer { controller.remove() } + controller.update(statusModel(format: .stacked)) + var probes: [StatusItemProbe] = [] + controller.onProbeChange = { probes.append($0) } + controller.popoverVisible = true + controller.layoutChanged(forgetting: false) + #expect(!controller.diagnosticProbeRunning) + #expect(probes.isEmpty) + controller.detailedLoggingEnabled = true + await waitUntil { probes.count == 1 && cadence.recordedIntervals.count == 2 } + #expect(controller.diagnosticProbeRunning) + #expect(cadence.recordedIntervals == [30, 30]) + let first = controller.probe() + #expect(first.summary.contains("visible=")) + #expect(controller.probe() == first) + controller.layoutChanged(forgetting: false) + controller.detailedLoggingEnabled = false + #expect(!controller.diagnosticProbeRunning) + _ = controller.buttonFrameOnScreen +} + +@Test @MainActor func statusItemLeftClickTogglesAndRightClickOpensTheMenu() { + var presented = 0 + let controller = statusController { _ in presented += 1 } + defer { controller.remove() } + var clicks = 0 + controller.onClick = { clicks += 1 } + controller.buttonClicked(nil) + #expect(clicks == 1) + controller.menuProvider = { NSMenu() } + controller.buttonClicked(nil) + #expect(clicks == 2) + let rightClick = NSEvent.mouseEvent( + with: .rightMouseUp, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, + eventNumber: 0, + clickCount: 1, pressure: 1)! + controller.handleClick(rightClick) + #expect(clicks == 2) + #expect(presented == 1) + #expect(controller.item.menu != nil) + controller.item.menu?.delegate?.menuDidClose?(controller.item.menu!) + #expect(controller.item.menu == nil) +} + +@Test @MainActor func statusItemFollowsTheMenuBarAppearance() async throws { + let controller = statusController() + defer { controller.remove() } + controller.update(statusModel(format: .stacked)) + NotificationCenter.default.post(name: NSApplication.didChangeScreenParametersNotification, object: nil) + controller.item.button?.appearance = NSAppearance(named: .darkAqua) + controller.appearanceChanged() + await mainActorTurn() + #expect(controller.isDark) +} + +@Test @MainActor func statusItemKeepsTheContextOfTheAppBehindIt() { + let controller = StatusItemController(log: makeLog(), presentMenu: { _ in }) + defer { controller.remove() } + controller.frontmostContext = { "com.example.editor" } + #expect(controller.layoutContext() == "com.example.editor") + // Opening the popover activates this app; the width the item has to fit into is still the editor's menu bar. + controller.frontmostContext = { Bundle.main.bundleIdentifier ?? "" } + #expect(controller.layoutContext() == "com.example.editor") + controller.frontmostContext = { "" } + #expect(controller.layoutContext() == "com.example.editor") +} + +@Test @MainActor func statusItemHoldsItsTierWhileThePopoverIsVisible() { + let controller = statusController() + defer { controller.remove() } + controller.fitCheckDelay = .seconds(10) + var fitChecks = 0 + controller.visibleItemFrame = { _ in + fitChecks += 1 + return nil + } + controller.update(ladder: [statusModel(format: .stacked), .empty]) + #expect(!controller.checkFit()) + #expect(fitChecks == 1) + #expect(controller.model == .empty) + controller.popoverVisible = true + controller.layoutChanged(forgetting: true) + controller.restart() + #expect(controller.checkFit()) + #expect(fitChecks == 1) + #expect(controller.model == .empty) + controller.popoverVisible = false + #expect(controller.model == statusModel(format: .stacked)) +} + +@Test @MainActor func statusItemUpdatesTheCurrentTierWithoutMovingTheOpenPopoverAnchor() throws { + let controller = statusController() + defer { controller.remove() } + controller.fitCheckDelay = .seconds(10) + controller.visibleItemFrame = { _ in nil } + controller.update(ladder: [statusModel(format: .stacked), .empty]) + #expect(!controller.checkFit()) + #expect(controller.model == .empty) + let frozenWidth = try #require(controller.item.button?.frame.width) + #expect(frozenWidth > 0) + controller.popoverVisible = true + let wide = statusModel(format: .custom) + let currentTier = statusModel(format: .miniBars) + + controller.update(ladder: [wide, currentTier]) + + #expect(controller.ladder == [wide, currentTier]) + #expect(controller.model == currentTier) + #expect(controller.item.length == frozenWidth) + #expect(controller.item.button?.accessibilityLabel() == StatusItemRenderer.accessibilityDescription(for: currentTier)) + + controller.popoverVisible = false + #expect(controller.model == wide) + #expect(controller.item.length == NSStatusItem.variableLength) +} + +@Test @MainActor func statusItemIgnoresRepeatedPopoverVisibilityNotifications() throws { + let controller = statusController() + defer { controller.remove() } + controller.update(statusModel(format: .stacked)) + controller.popoverVisible = true + let frozenLength = controller.item.length + let button = try #require(controller.item.button) + button.setFrameSize(CGSize(width: button.frame.width + 40, height: button.frame.height)) + + controller.popoverVisible = true + + #expect(controller.item.length == frozenLength) + controller.popoverVisible = false +} + +@Test @MainActor func statusItemBuildsGeometryDiagnosticsOnlyWhenDetailedLoggingIsEnabled() { + let log = makeLog() + log.debugEnabled = true + let controller = StatusItemController(log: log, presentMenu: { _ in }) + defer { controller.remove() } + controller.popoverVisible = true + + controller.layoutChanged(forgetting: false, trigger: "quiet") + #expect(!log.text.contains("status.deferred")) + + controller.detailedLoggingEnabled = true + controller.layoutChanged(forgetting: false, trigger: "detailed") + #expect(log.text.contains("status.deferred trigger=detailed")) +} + +private func configuredStatusModel(format: StatusFormat, decimals: Int, label: String? = nil) -> StatusItemModel { + let snapshot = sampleSnapshot(.claude) + let window = snapshot.windows[0] + let key = WindowKey(.claude, window) + return StatusItemBuilder.build( + StatusItemInput( + snapshots: [.claude: snapshot], availability: [.claude: .current], selectedKeys: [key], format: format, + customTemplate: "{label} {pct}", decimals: decimals, hideZeroCells: false, order: .provider, + labels: label.map { [key: $0] } ?? [:], now: fixedNow)) +} diff --git a/Tests/TokenMenuBarUITests/Support/NoNetworkTransport.swift b/Tests/TokenMenuBarUITests/Support/NoNetworkTransport.swift new file mode 100644 index 0000000..de0b9f9 --- /dev/null +++ b/Tests/TokenMenuBarUITests/Support/NoNetworkTransport.swift @@ -0,0 +1,9 @@ +import Foundation + +@testable import TokenMenuBarCore + +struct NoNetworkTransport: HTTPTransport { + func data(for _: URLRequest) async throws -> (Data, URLResponse) { + throw URLError(.unsupportedURL) + } +} diff --git a/Tests/TokenMenuBarUITests/Support/UIFixtures.swift b/Tests/TokenMenuBarUITests/Support/UIFixtures.swift new file mode 100644 index 0000000..0db1063 --- /dev/null +++ b/Tests/TokenMenuBarUITests/Support/UIFixtures.swift @@ -0,0 +1,324 @@ +import AppKit +import Foundation +import SwiftUI +import Testing +import UserNotifications + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +let fixedNow = Date(timeIntervalSince1970: 1_788_030_000) +let testClock = Clock.fixed(fixedNow) +let sleepingClock = Clock(now: { fixedNow }, sleep: { _ in try await CancellationSuspension.wait() }) +let testKeychain = KeychainCredentialClient(load: { _, _ in nil }, save: { _, _, _ in }) + +final class CancellationSuspension: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var cancelled = false + + static func wait() async throws { + let suspension = CancellationSuspension() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let cancelled = suspension.lock.withLock { + guard !suspension.cancelled else { return true } + suspension.continuation = continuation + return false + } + if cancelled { continuation.resume(throwing: CancellationError()) } + } + } onCancel: { + let continuation = suspension.lock.withLock { + suspension.cancelled = true + defer { suspension.continuation = nil } + return suspension.continuation + } + continuation?.resume(throwing: CancellationError()) + } + } +} + +@MainActor +func makeSettings() -> TokenMenuBarCore.Settings { + TokenMenuBarCore.Settings(defaults: UserDefaults(suiteName: "ui-tests-\(UUID().uuidString)")!) +} + +func makeLog() -> LogBuffer { + LogBuffer(fileURL: nil, clock: testClock) +} + +let testAppInfo = AppInfo( + name: "Token Menu Bar", version: "1.2.3", build: "4", bundleIdentifier: "dev.tox.token-menu-bar", isAppStore: false, + repository: AppInfo.repositoryURL) + +func sampleSnapshot(_ provider: ProviderID, percent: Double = 36) -> ProviderSnapshot { + ProviderSnapshot( + provider: provider, + identity: ProviderIdentity( + planName: provider == .claude ? "Max 20x" : "Pro", email: "user@example.com", + subscriptionActiveUntil: provider == .codex ? fixedNow.addingTimeInterval(86400) : nil), + windows: [ + QuotaWindow( + id: "session", label: "Current session", group: .session, usedPercent: percent, + resetsAt: fixedNow.addingTimeInterval(4 * 3600), duration: 18000), + QuotaWindow( + id: "weekly:fable", label: "Fable", group: .weekly, usedPercent: 61, + resetsAt: fixedNow.addingTimeInterval(3 * 86400), duration: 604_800, scope: "Fable"), + QuotaWindow(id: "extra", label: "Inactive", group: .other, usedPercent: 0, resetsAt: nil, isActive: false), + ], + credits: CreditBalance( + balance: 12.5, currency: "USD", hasCredits: true, overageLimitReached: true, approxLocalMessages: 1...3, + approxCloudMessages: 2...4), + spend: SpendControl( + enabled: true, canToggle: true, used: Money(amountMinor: 100, currency: "USD"), + limit: Money(amountMinor: 1000, currency: "USD"), percent: 10, resetsAt: fixedNow.addingTimeInterval(86400 * 3), + limitReached: false, balance: Money(amountMinor: 50, currency: "USD"), autoReload: true, + canPurchaseCredits: true), + resetCredits: ResetCredits(available: 1, applicable: 1, totalEarned: 2), + notices: [Notice(kind: .promotion, text: "Boosted limits"), Notice(kind: .limitReached, text: "Limit reached")], + localUsage: LocalUsage( + windowTokens: 1_200_000, windowCost: 14.2, costPerHour: 5.5, todayTokens: 3_000_000, todayCost: 40, + todayMessages: 120), + fetchedAt: fixedNow.addingTimeInterval(-10) + ) +} + +@MainActor +func makeEnvironment( + settings: TokenMenuBarCore.Settings? = nil, populate: Bool = true, clock: Clock = testClock +) throws -> UIEnvironment { + let settings = settings ?? makeSettings() + let state = AppState() + if populate { + state.update(.claude) { + $0.snapshot = sampleSnapshot(.claude) + $0.availability = .stale + $0.lastError = "network down https://example.com/help" + $0.warnings = ["Profile unavailable"] + $0.credentialState = .valid(expiresAt: nil) + $0.isRefreshing = true + } + state.update(.codex) { + $0.snapshot = sampleSnapshot(.codex, percent: 80) + $0.availability = .current + $0.analytics = ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .codeReviews, series: "reviews", value: 3)], + fetchedAt: fixedNow) + } + state.setRefreshing(true, at: fixedNow.addingTimeInterval(-60)) + } + let history = try UsageHistoryStore(url: nil) + let environment = UIEnvironment( + state: state, settings: settings, history: history, log: makeLog(), appInfo: testAppInfo, clock: clock, + launchAtLoginStatus: .requiresApproval, credentialDescriptions: [.claude: "Keychain"], canCheckForUpdates: true, + isSandboxed: true) + return environment +} + +@MainActor +private var hostingWindows: [NSWindow] = [] + +/// Keeps the suite off the screen it is running on. These tests put real NSWindows up, and without this they steal +/// focus and flash over whatever the developer is doing. +@MainActor +func quietTestApp() { + guard !preparedTestApp else { return } + preparedTestApp = true + NSApplication.shared.setActivationPolicy(.prohibited) +} + +@MainActor private var preparedTestApp = false + +@MainActor +func host(_ view: Content, width: CGFloat = 520, height: CGFloat = 700) -> NSHostingView { + quietTestApp() + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(x: 0, y: 0, width: width, height: height) + let window = NSWindow(contentRect: hosting.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = hosting + hosting.layoutSubtreeIfNeeded() + hosting.displayIfNeeded() + hostingWindows.append(window) + return hosting +} + +@MainActor +func inkFraction(_ view: Content, width: CGFloat = 520, height: CGFloat = 700) -> Double { + let hosting = host(view, width: width, height: height) + guard let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds) else { return 0 } + hosting.cacheDisplay(in: hosting.bounds, to: rep) + guard let image = rep.cgImage, image.width > 0, image.height > 0 else { return 0 } + var pixels = [UInt8](repeating: 0, count: image.width * image.height * 4) + let context = CGContext( + data: &pixels, width: image.width, height: image.height, bitsPerComponent: 8, bytesPerRow: image.width * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return Double(stride(from: 3, to: pixels.count, by: 4).count { pixels[$0] > 8 }) / Double(image.width * image.height) +} + +@MainActor +func statusModel(format: StatusFormat = .stacked) -> StatusItemModel { + let snapshots: [ProviderID: ProviderSnapshot] = [ + .claude: sampleSnapshot(.claude), .codex: sampleSnapshot(.codex, percent: 80), + ] + return StatusItemBuilder.build( + StatusItemInput( + snapshots: snapshots, + availability: [.claude: .current, .codex: .current], + selectedKeys: StatusItemBuilder.defaultSelection(snapshots), + format: format, + customTemplate: "{label} {pct} {reset}", + decimals: 0, + hideZeroCells: true, + order: .provider, + labels: [:], + now: fixedNow + ) + ) +} + +final class FakeNotificationCenter: NotificationCenterProtocol, @unchecked Sendable { + private let lock = NSLock() + var authorize: Bool = true + var authorizationError: (any Error)? + var addError: (any Error)? + private(set) var requests: [UNNotificationRequest] = [] + private(set) var removed: [String] = [] + + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { + if let authorizationError { throw authorizationError } + return authorize + } + + func add(_ request: UNNotificationRequest) async throws { + if let addError { throw addError } + lock.withLock { requests.append(request) } + } + + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { + lock.withLock { removed += identifiers } + } +} + +struct TestError: Error {} + +extension UsageHistoryStore { + func breakDatabase() throws { + try database.execute("DROP TABLE samples") + try database.execute("DROP TABLE analytics") + } +} + +@MainActor +final class FakeUpdater: UpdaterHook { + var canCheck = true + var automaticallyChecks = false + var checks = 0 + + func checkForUpdates() { + checks += 1 + } +} + +struct ScriptedProvider: UsageProvider { + let id: ProviderID + let result: ProviderFetchResult + let pollingPolicy = PollingPolicy(minimumInterval: 0, activeInterval: 0, defaultInterval: 0) + + var credentialDescription: String { "scripted \(id.rawValue)" } + func credentialState(now: Date) -> CredentialState { .valid(expiresAt: nil) } + func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult { result } +} + +@MainActor +func makeDependencies( + providers: [any UsageProvider] = [], updater: FakeUpdater? = FakeUpdater(), history: UsageHistoryStore? = nil, + widgetStore: WidgetSnapshotStore? = nil, snapshotCache: SnapshotCache = SnapshotCache(url: nil), + isDemo: Bool = false, clock: Clock = sleepingClock, + rebuildProviders: (@MainActor @Sendable (TokenMenuBarCore.Settings) async -> ProviderRegistry)? = nil +) throws -> (AppDependencies, Recorder) { + quietTestApp() + let recorder = Recorder() + let history = try history ?? UsageHistoryStore(url: nil) + let settings = makeSettings() + let state = AppState() + for provider in providers { + state.update(provider.id) { $0.credentialState = .valid(expiresAt: nil) } + } + let dependencies = AppDependencies( + appInfo: testAppInfo, + settings: settings, + state: state, + history: history, + log: makeLog(), + registry: ProviderRegistry(providers), + notifier: Notifier(center: FakeNotificationCenter(), log: makeLog()), + launchAtLogin: LaunchAtLoginBackend( + status: { .notRegistered }, register: {}, + unregister: { MainActor.assumeIsolated { recorder.unregisteredLoginItem += 1 } }, + openSettings: { MainActor.assumeIsolated { recorder.openedLoginItems += 1 } }), + clock: clock, + updater: updater, + isSandboxed: true, + isDemo: isDemo, + openURL: { recorder.urls.append($0) }, + copyToPasteboard: { recorder.copied.append($0) }, + revealInFinder: { recorder.revealed.append($0) }, + chooseExportURL: { recorder.exportURL }, + chooseDirectory: { _ in recorder.codexHome }, + terminate: { recorder.terminated += 1 }, + relaunch: { recorder.relaunched += 1 }, + widgetStore: widgetStore, + snapshotCache: snapshotCache, + reloadWidgets: { recorder.reloadedWidgets += 1 }, + rebuildProviders: { settings in + recorder.rebuilt += 1 + if let rebuildProviders { return await rebuildProviders(settings) } + return ProviderRegistry([ + ScriptedProvider(id: .codex, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.codex)))) + ]) + }, + screenVisibleFrame: { CGRect(x: 0, y: 0, width: 1440, height: 900) }, + openPopoverOnLaunch: providers.count > 1, + presentsWindows: false + ) + return (dependencies, recorder) +} + +@MainActor +final class Recorder { + var relaunched = 0 + var reloadedWidgets = 0 + var urls: [URL] = [] + var copied: [String] = [] + var revealed: [URL] = [] + var exportURL: URL? + var codexHome: URL? + var terminated = 0 + var rebuilt = 0 + var openedLoginItems = 0 + var unregisteredLoginItem = 0 +} + +/// Waits for work the controller schedules onto the main actor, so a loaded machine does not decide the outcome. +/// Spinning the run loop as well lets hosted SwiftUI views take the render pass their `.task` modifiers depend on. +@MainActor +@discardableResult +func waitUntil(within seconds: Double = 5, _ condition: () -> Bool) async -> Bool { + let deadline = Date().addingTimeInterval(seconds) + while !condition(), Date() < deadline { + await mainActorTurn() + try? await Task.sleep(for: .milliseconds(1)) + } + return condition() +} + +@MainActor +func mainActorTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } +} diff --git a/Tests/TokenMenuBarUITests/TooltipCoverageClosureTests.swift b/Tests/TokenMenuBarUITests/TooltipCoverageClosureTests.swift new file mode 100644 index 0000000..d016fe9 --- /dev/null +++ b/Tests/TokenMenuBarUITests/TooltipCoverageClosureTests.swift @@ -0,0 +1,139 @@ +import AppKit +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@MainActor +private final class CoverageTooltipPanel: TooltipPanelPresenting { + private(set) var contents: [TooltipContent] = [] + var isVisible = false + + func show( + content: TooltipContent, + anchorRect: CGRect, + visibleFrame: CGRect, + parentWindow: NSWindow, + reduceMotion: Bool, + reduceTransparency: Bool + ) { + contents.append(content) + isVisible = true + } + + func hide() { + isVisible = false + } + + func tearDown() { + isVisible = false + } +} + +@MainActor +private final class CoverageTooltipWindow: NSWindow { + var pointerLocation = CGPoint.zero + + override var mouseLocationOutsideOfEventStream: NSPoint { pointerLocation } +} + +@Suite(.serialized) +struct TooltipCoverageClosureTests { + @Test @MainActor func defaultCursorContextFindsTheWindowUnderThePointer() async throws { + quietTestApp() + let pointer = NSEvent.mouseLocation + let screen = try #require(NSScreen.screens.first { $0.frame.contains(pointer) }) + let window = NSWindow( + contentRect: screen.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.alphaValue = 0 + window.orderFrontRegardless() + let panel = CoverageTooltipPanel() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let content = TooltipContent(title: "Pointer", body: "Explains the control under the pointer.") + defer { + presenter.tearDown() + window.orderOut(nil) + } + + presenter.updateCursor(content: content, hovering: true) + await presenter.settle() + + #expect(panel.contents == [content]) + } + + @Test @MainActor func hoveredTrackingViewRefreshesChangedContent() async throws { + let screen = try #require(NSScreen.screens.first) + let panel = CoverageTooltipPanel() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let initial = TooltipContent(title: "Initial", body: "First explanation.") + let view = TooltipTrackingView(content: initial, presenter: presenter) + let window = CoverageTooltipWindow( + contentRect: CGRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.midY, width: 180, height: 60), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.alphaValue = 0 + window.contentView = view + view.frame = CGRect(x: 0, y: 0, width: 180, height: 60) + window.pointerLocation = CGPoint(x: 90, y: 30) + window.orderFrontRegardless() + defer { + view.dismantle() + presenter.tearDown() + window.orderOut(nil) + } + + view.mouseEntered(with: try mouseEvent()) + await presenter.settle() + let updated = TooltipContent(title: "Updated", body: "Second explanation.") + view.update(content: updated, focused: false) + + #expect(panel.contents == [initial, updated]) + } + + @Test @MainActor func cursorPresenterUpdatesTheSharedPresenter() async throws { + let screen = try #require(NSScreen.screens.first) + let window = NSWindow( + contentRect: CGRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.midY, width: 180, height: 60), + styleMask: [.borderless], backing: .buffered, defer: false) + let help = TooltipContent(title: "Refresh", body: "Fetches current usage.") + let panel = CoverageTooltipPanel() + let presenter = TooltipPresenter( + sleep: { _ in }, panelFactory: { panel }, + cursorContext: { + TooltipPresentationContext( + anchorRect: window.frame, visibleFrame: screen.visibleFrame, parentWindow: window) + }) + defer { + presenter.tearDown() + } + + presenter.updateCursor(content: help, hovering: true) + await presenter.settle() + #expect(panel.contents == [help]) + presenter.updateCursor(content: help, hovering: false) + await presenter.settle() + #expect(!panel.isVisible) + } + + private func mouseEvent() throws -> NSEvent { + try #require( + NSEvent.mouseEvent( + with: .mouseMoved, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 0, + pressure: 0 + ) + ) + } +} diff --git a/Tests/TokenMenuBarUITests/TooltipTests.swift b/Tests/TokenMenuBarUITests/TooltipTests.swift new file mode 100644 index 0000000..7e9e9f4 --- /dev/null +++ b/Tests/TokenMenuBarUITests/TooltipTests.swift @@ -0,0 +1,1269 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +private actor TooltipSleepGate { + private var continuations: [CheckedContinuation] = [] + private(set) var durations: [Duration] = [] + + func sleep(_ duration: Duration) async { + durations.append(duration) + await withCheckedContinuation { continuations.append($0) } + } + + func releaseAll() { + let waiting = continuations + continuations.removeAll() + for continuation in waiting { continuation.resume() } + } +} + +private actor TooltipTestClock { + private struct Sleeper { + let deadline: Duration + let continuation: CheckedContinuation + } + + private var now: Duration = .zero + private var sleepers: [UUID: Sleeper] = [:] + + var pendingCount: Int { sleepers.count } + + func sleep(_ duration: Duration) async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + sleepers[id] = Sleeper(deadline: now + duration, continuation: continuation) + } + } + } onCancel: { + Task { await self.cancel(id: id) } + } + } + + func advance(by duration: Duration) { + now += duration + let ready = sleepers.filter { $0.value.deadline <= now } + for (id, sleeper) in ready { + sleepers.removeValue(forKey: id) + sleeper.continuation.resume() + } + } + + private func cancel(id: UUID) { + sleepers.removeValue(forKey: id)?.continuation.resume(throwing: CancellationError()) + } +} + +@MainActor +private final class TooltipPanelSpy: TooltipPanelPresenting { + private(set) var anchorRects: [CGRect] = [] + private(set) var contents: [TooltipContent] = [] + private(set) var hideCount = 0 + private(set) var parentWindows: [NSWindow] = [] + private(set) var tearDownCount = 0 + private(set) var reduceMotionValues: [Bool] = [] + var isVisible = false + + func show( + content: TooltipContent, + anchorRect: CGRect, + visibleFrame: CGRect, + parentWindow: NSWindow, + reduceMotion: Bool, + reduceTransparency: Bool + ) { + anchorRects.append(anchorRect) + contents.append(content) + parentWindows.append(parentWindow) + reduceMotionValues.append(reduceMotion) + isVisible = true + } + + func hide() { + hideCount += 1 + isVisible = false + } + + func tearDown() { + tearDownCount += 1 + isVisible = false + } +} + +@MainActor +private final class TooltipMouseWindow: NSWindow { + var mouseLocation = CGPoint.zero + + override var mouseLocationOutsideOfEventStream: NSPoint { mouseLocation } +} + +@MainActor +private final class FlippedTooltipContainer: NSView { + override var isFlipped: Bool { true } +} + +@MainActor +private final class TooltipSourceStub: TooltipPresentationSource { + let tooltipOwner: TooltipOwner + var tooltipContent: TooltipContent + var tooltipPresentationContext: TooltipPresentationContext? + var tooltipClipView: NSClipView? + + init( + presenter: TooltipPresenter, + title: String, + window: NSWindow, + clipView: NSClipView? = nil, + anchorRect: CGRect = CGRect(x: 100, y: 200, width: 40, height: 20), + visibleFrame: CGRect = CGRect(x: 0, y: 0, width: 800, height: 600) + ) { + tooltipOwner = presenter.makeOwner() + tooltipContent = TooltipContent(title: title, body: "Explanation") + tooltipPresentationContext = TooltipPresentationContext( + anchorRect: anchorRect, + visibleFrame: visibleFrame, + parentWindow: window + ) + tooltipClipView = clipView + } +} + +@MainActor +private final class VolatileTooltipSource: TooltipPresentationSource { + let tooltipOwner: TooltipOwner + let tooltipContent = TooltipContent(title: "Transient", body: "Explains the current control.") + let tooltipClipView: NSClipView? = nil + private var contexts: [TooltipPresentationContext] + + init(presenter: TooltipPresenter, contexts: [TooltipPresentationContext]) { + tooltipOwner = presenter.makeOwner() + self.contexts = contexts + } + + var tooltipPresentationContext: TooltipPresentationContext? { + contexts.isEmpty ? nil : contexts.removeFirst() + } +} + +private enum TooltipTestError: Error { + case failedSleep +} + +@MainActor +private struct TooltipModifierProbe: View { + @FocusState private var focused: Bool + + let help: TooltipContent + let presenter: TooltipPresenter + + var body: some View { + VStack { + Button("Bound") {} + .richHelp(help, focus: $focused, presenter: presenter) + Button("Shared bound") {} + .richHelp(help, focus: $focused) + Text("Explicit") + .richHelp(help, isFocused: false, presenter: presenter) + Text("Shared explicit") + .richHelp(help, isFocused: false) + Text("Accessibility") + .richHelpAccessibility(help) + } + } +} + +@MainActor +@Observable +private final class TooltipFocusProbeModel { + var content: TooltipContent + var focused = false + + init(content: TooltipContent) { + self.content = content + } +} + +@MainActor +private struct TooltipFocusProbe: View { + @Bindable var model: TooltipFocusProbeModel + let presenter: TooltipPresenter + + var body: some View { + Text("Focused control") + .richHelp(model.content, isFocused: model.focused, presenter: presenter) + } +} + +@Suite(.serialized) +struct TooltipTests { + @Test func tooltipContentIncludesTitleAndFlattensRichSpansForAccessibility() { + let content = TooltipContent( + title: "Format", + body: [.code("{cell}:{pct}"), .text(" inline, or Custom to write your own")] + ) + #expect(content.accessibilityHint == "Format. {cell}:{pct} inline, or Custom to write your own") + } + + @Test func tooltipContentDoesNotRepeatTitleFromBody() { + let content = TooltipContent( + title: "Code reviews", + body: "Code reviews counted today across all repositories." + ) + #expect(content.accessibilityHint == "Code reviews counted today across all repositories.") + } + + @Test func tooltipContentKeepsDistinctWordsWithTheSamePrefix() { + let content = TooltipContent(title: "Log", body: "Logging records provider refresh details.") + #expect(content.accessibilityHint == "Log. Logging records provider refresh details.") + } + + @Test func tooltipContentUsesTheOnlyNonemptyPartForAccessibility() { + let titleOnly = TooltipContent(title: " Current period ", body: " \n") + let bodyOnly = TooltipContent(title: "\n", body: " Returns to live usage. ") + #expect(titleOnly.accessibilityHint == "Current period") + #expect(bodyOnly.accessibilityHint == "Returns to live usage.") + } + + @Test @MainActor func tooltipPanelIsNonactivatingAndMouseTransparent() { + let panel = TooltipPanel() + #expect(panel.styleMask.contains(.borderless)) + #expect(panel.styleMask.contains(.nonactivatingPanel)) + #expect(panel.ignoresMouseEvents) + #expect(!panel.canBecomeKey) + #expect(!panel.canBecomeMain) + #expect(panel.contentView is NSVisualEffectView) + #expect((panel.contentView as? NSVisualEffectView)?.material == .toolTip) + panel.tearDown() + } + + @Test @MainActor func tooltipPanelRendersRichContentAndMovesBetweenWindows() throws { + let panel = TooltipPanel() + let firstWindow = NSWindow( + contentRect: CGRect(x: -9_980, y: -9_980, width: 200, height: 200), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + let secondWindow = NSWindow( + contentRect: CGRect(x: -9_960, y: -9_960, width: 200, height: 200), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + let visibleFrame = CGRect(x: -10_000, y: -10_000, width: 400, height: 300) + let content = TooltipContent(title: "Template", body: [.code("{pct}"), .text(" shows percent used")]) + + panel.show( + content: content, + anchorRect: CGRect(x: -9_900, y: -9_800, width: 40, height: 20), + visibleFrame: visibleFrame, + parentWindow: firstWindow, + reduceMotion: true, + reduceTransparency: true + ) + + let effectView = try #require(panel.contentView as? NSVisualEffectView) + let label = try #require(effectView.subviews.compactMap { $0 as? NSTextField }.first) + #expect(panel.isVisible) + #expect(panel.parent === firstWindow) + #expect(effectView.state == .inactive) + #expect(label.stringValue == "Template\n{pct} shows percent used") + var foregrounds: [NSColor] = [] + label.attributedStringValue.enumerateAttribute( + .foregroundColor, + in: NSRange(location: 0, length: label.attributedStringValue.length) + ) { value, _, _ in + if let color = value as? NSColor { foregrounds.append(color) } + } + #expect(foregrounds.count >= 2) + #expect(foregrounds.allSatisfy { $0.isEqual(NSColor.labelColor) }) + #expect(visibleFrame.insetBy(dx: -1, dy: -1).contains(panel.frame)) + let expectedOrigin = TooltipGeometry.placement( + anchor: CGRect(x: -9_900, y: -9_800, width: 40, height: 20), + tooltipSize: panel.frame.size, + visibleFrame: visibleFrame + ).origin + #expect(abs(panel.frame.origin.x - expectedOrigin.x) <= 0.5) + #expect(abs(panel.frame.origin.y - expectedOrigin.y) <= 0.5) + + panel.show( + content: TooltipContent(title: "Updated", body: "Current value"), + anchorRect: CGRect(x: -9_850, y: -9_900, width: 40, height: 20), + visibleFrame: visibleFrame, + parentWindow: secondWindow, + reduceMotion: false, + reduceTransparency: false + ) + + #expect(panel.parent === secondWindow) + #expect(firstWindow.childWindows?.contains(panel) != true) + #expect(secondWindow.childWindows?.contains(panel) == true) + #expect(effectView.state == .active) + #expect(label.stringValue == "Updated\nCurrent value") + panel.hide() + #expect(!panel.isVisible) + #expect(panel.parent == nil) + panel.tearDown() + #expect(panel.contentView == nil) + } + + @Test @MainActor func richHelpBuildsEveryFocusBridge() { + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { TooltipPanelSpy() }) + let hosting = NSHostingView( + rootView: TooltipModifierProbe( + help: TooltipContent(title: "Format", body: "Controls the status text."), + presenter: presenter + )) + hosting.frame = CGRect(x: 0, y: 0, width: 220, height: 180) + hosting.layoutSubtreeIfNeeded() + + #expect(hosting.fittingSize.width > 0) + presenter.tearDown() + TooltipPresenter.shared.dismissAll() + } + + @Test @MainActor func focusedRichHelpSupportsHoverAndFocus() async throws { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let initial = TooltipContent(title: "Focus", body: "Initial help") + let model = TooltipFocusProbeModel(content: initial) + let hosting = host(TooltipFocusProbe(model: model, presenter: presenter), width: 220, height: 80) + defer { presenter.tearDown() } + + let anchor = try #require(tooltipTrackingViews(in: hosting).first) + #expect(!anchor.trackingAreas.isEmpty) + + let source = TooltipSourceStub(presenter: presenter, title: "Focus", window: NSWindow()) + source.tooltipContent = initial + presenter.update(source: source, hovering: false, focused: true) + await presenter.settle() + #expect(panel.contents == [initial]) + + presenter.update(source: source, hovering: false, focused: false) + await presenter.settle() + #expect(presenter.visibleOwner == nil) + #expect(!panel.isVisible) + } + + @Test @MainActor func tooltipPresenterPrefersTheNewestFocusedSource() async { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let first = TooltipSourceStub( + presenter: presenter, title: "First", window: window, + anchorRect: CGRect(x: 100, y: 100, width: 40, height: 20)) + let second = TooltipSourceStub( + presenter: presenter, title: "Second", window: window, + anchorRect: CGRect(x: 200, y: 100, width: 40, height: 20)) + defer { presenter.tearDown() } + + presenter.update(source: first, hovering: false, focused: true) + await presenter.settle() + presenter.update(source: second, hovering: false, focused: true) + await presenter.settle() + + #expect(panel.contents.last?.title == "Second") + } + + @Test @MainActor func tooltipPresenterUsesItsDefaultDependencies() async { + let presenter = TooltipPresenter() + let visibleFrame = CGRect(x: -10_000, y: -10_000, width: 400, height: 300) + let source = TooltipSourceStub( + presenter: presenter, + title: "Defaults", + window: NSWindow(), + anchorRect: CGRect(x: -9_900, y: -9_800, width: 40, height: 20), + visibleFrame: visibleFrame + ) + + presenter.arm(source: source) + await presenter.settle() + + #expect(presenter.hasPanel) + #expect(presenter.visibleOwner == source.tooltipOwner) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterTracksCursorHelpWithoutPerControlViews() async { + let panel = TooltipPanelSpy() + let window = NSWindow() + let anchor = CGRect(x: 120, y: 240, width: 1, height: 1) + let context = TooltipPresentationContext( + anchorRect: anchor, + visibleFrame: CGRect(x: 0, y: 0, width: 800, height: 600), + parentWindow: window) + let presenter = TooltipPresenter( + sleep: { _ in }, panelFactory: { panel }, cursorContext: { context }) + let content = TooltipContent(title: "Refresh", body: "Fetches current usage.") + + presenter.updateCursor(content: content, hovering: true) + await presenter.settle() + + #expect(panel.contents == [content]) + #expect(panel.anchorRects == [anchor]) + presenter.updateCursor(content: content, hovering: false) + await presenter.settle() + #expect(!panel.isVisible) + presenter.tearDown() + } + + @Test @MainActor func cursorContextUsesTheFrontEligibleWindow() throws { + quietTestApp() + let screen = try #require(NSScreen.screens.first) + let point = CGPoint(x: screen.visibleFrame.midX, y: screen.visibleFrame.midY) + let window = NSWindow( + contentRect: CGRect(x: point.x - 50, y: point.y - 50, width: 100, height: 100), + styleMask: [.borderless], backing: .buffered, defer: false) + window.alphaValue = 0 + window.orderFrontRegardless() + + let context = try #require(TooltipPresenter.currentCursorContext(point: point, windows: [window])) + + #expect(context.parentWindow === window) + #expect(context.anchorRect == CGRect(origin: point, size: CGSize(width: 1, height: 1))) + #expect(TooltipPresenter.currentCursorContext(point: point, windows: []) == nil) + window.orderOut(nil) + } + + @Test @MainActor func tooltipPresenterClearsAFailedDelay() async { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter( + sleep: { _ in throw TooltipTestError.failedSleep }, + panelFactory: { panel } + ) + let source = TooltipSourceStub(presenter: presenter, title: "Failure", window: NSWindow()) + + presenter.arm(source: source) + await presenter.settle() + + #expect(panel.contents.isEmpty) + #expect(!presenter.hasPendingTask) + #expect(!presenter.hasEventMonitor) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterWaitsForTheInjectedDelay() async { + let gate = TooltipSleepGate() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { await gate.sleep($0) }, panelFactory: { panel }) + let source = TooltipSourceStub(presenter: presenter, title: "Period", window: NSWindow()) + + presenter.arm(source: source) + while await gate.durations.isEmpty { await Task.yield() } + #expect(!presenter.hasPanel) + #expect(await gate.durations == [TooltipTiming.presentationDelay]) + + await gate.releaseAll() + await presenter.settle() + #expect(panel.contents == [source.tooltipContent]) + #expect(presenter.visibleOwner == source.tooltipOwner) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterShowsAfterTheFullHoverThreshold() async { + let clock = TooltipTestClock() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { try await clock.sleep($0) }, panelFactory: { panel }) + let source = TooltipSourceStub(presenter: presenter, title: "Threshold", window: NSWindow()) + + presenter.arm(source: source) + await waitForSleeps(clock, count: 1) + await clock.advance(by: .milliseconds(149)) + await Task.yield() + #expect(panel.contents.isEmpty) + + await clock.advance(by: .milliseconds(1)) + await presenter.settle() + #expect(panel.contents == [source.tooltipContent]) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterDoesNotShowWhenHoverEndsBeforeTheEntryThreshold() async { + let clock = TooltipTestClock() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { try await clock.sleep($0) }, panelFactory: { panel }) + let source = TooltipSourceStub(presenter: presenter, title: "Brief hover", window: NSWindow()) + + presenter.arm(source: source) + await waitForSleeps(clock, count: 1) + await clock.advance(by: .milliseconds(149)) + presenter.update(source: source, hovering: false, focused: false) + await waitForSleeps(clock, count: 1) + await clock.advance(by: TooltipTiming.dismissalDelay) + await presenter.settle() + + #expect(panel.contents.isEmpty) + #expect(presenter.visibleOwner == nil) + #expect(!presenter.hasPendingTask) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterRejectsAnAnchorInvalidatedWhileArming() { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let context = TooltipPresentationContext( + anchorRect: CGRect(x: 100, y: 200, width: 40, height: 20), + visibleFrame: CGRect(x: 0, y: 0, width: 800, height: 600), + parentWindow: window + ) + let source = VolatileTooltipSource(presenter: presenter, contexts: [context]) + + presenter.arm(source: source) + + #expect(panel.contents.isEmpty) + #expect(!presenter.hasPendingTask) + #expect(presenter.visibleOwner == nil) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterHidesAfterTheFullMouseExitThreshold() async { + let clock = TooltipTestClock() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { try await clock.sleep($0) }, panelFactory: { panel }) + let source = TooltipSourceStub(presenter: presenter, title: "Threshold", window: NSWindow()) + + presenter.arm(source: source) + await waitForSleeps(clock, count: 1) + await clock.advance(by: TooltipTiming.presentationDelay) + await presenter.settle() + presenter.update(source: source, hovering: false, focused: false) + await waitForSleeps(clock, count: 1) + + await clock.advance(by: .milliseconds(149)) + await Task.yield() + #expect(panel.isVisible) + #expect(presenter.visibleOwner == source.tooltipOwner) + + await clock.advance(by: .milliseconds(1)) + await presenter.settle() + #expect(!panel.isVisible) + #expect(presenter.visibleOwner == nil) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterCancelsMouseExitDismissalOnReentry() async { + let clock = TooltipTestClock() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { try await clock.sleep($0) }, panelFactory: { panel }) + let source = TooltipSourceStub(presenter: presenter, title: "Reentry", window: NSWindow()) + + presenter.arm(source: source) + await waitForSleeps(clock, count: 1) + await clock.advance(by: TooltipTiming.presentationDelay) + await presenter.settle() + presenter.update(source: source, hovering: false, focused: false) + await waitForSleeps(clock, count: 1) + await clock.advance(by: .milliseconds(149)) + + presenter.update(source: source, hovering: true, focused: false) + await waitForSleeps(clock, count: 0) + await clock.advance(by: .milliseconds(1)) + await Task.yield() + + #expect(panel.isVisible) + #expect(panel.hideCount == 0) + #expect(panel.contents == [source.tooltipContent]) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterKeepsThePanelVisibleAcrossAdjacentSources() async { + let clock = TooltipTestClock() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { try await clock.sleep($0) }, panelFactory: { panel }) + let window = NSWindow() + let first = TooltipSourceStub(presenter: presenter, title: "First", window: window) + let second = TooltipSourceStub(presenter: presenter, title: "Second", window: window) + + presenter.arm(source: first) + await waitForSleeps(clock, count: 1) + await clock.advance(by: TooltipTiming.presentationDelay) + await presenter.settle() + presenter.update(source: first, hovering: false, focused: false) + presenter.update(source: second, hovering: true, focused: false) + await waitForSleeps(clock, count: 1) + + await clock.advance(by: .milliseconds(149)) + await Task.yield() + #expect(panel.contents == [first.tooltipContent]) + #expect(panel.isVisible) + + await clock.advance(by: .milliseconds(1)) + await presenter.settle() + #expect(panel.contents == [first.tooltipContent, second.tooltipContent]) + #expect(panel.hideCount == 0) + #expect(presenter.visibleOwner == second.tooltipOwner) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterDismissesImmediatelyWhenKeyboardFocusLeaves() async { + let clock = TooltipTestClock() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { try await clock.sleep($0) }, panelFactory: { panel }) + let source = TooltipSourceStub(presenter: presenter, title: "Focused", window: NSWindow()) + + presenter.update(source: source, hovering: false, focused: true) + await waitForSleeps(clock, count: 1) + await clock.advance(by: TooltipTiming.presentationDelay) + await presenter.settle() + presenter.update(source: source, hovering: false, focused: false) + + #expect(!panel.isVisible) + #expect(!presenter.hasPendingTask) + #expect(presenter.visibleOwner == nil) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterKeepsHelpWhenFocusLeavesUnderThePointer() async { + let clock = TooltipTestClock() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { try await clock.sleep($0) }, panelFactory: { panel }) + let source = TooltipSourceStub(presenter: presenter, title: "Focused", window: NSWindow()) + + presenter.update(source: source, hovering: true, focused: true) + await waitForSleeps(clock, count: 1) + await clock.advance(by: TooltipTiming.presentationDelay) + await presenter.settle() + presenter.update(source: source, hovering: true, focused: false) + + #expect(panel.isVisible) + #expect(!presenter.hasPendingTask) + #expect(presenter.visibleOwner == source.tooltipOwner) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterSafetyDismissalCancelsMouseExitGrace() async { + let clock = TooltipTestClock() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { try await clock.sleep($0) }, panelFactory: { panel }) + let source = TooltipSourceStub(presenter: presenter, title: "Safety", window: NSWindow()) + + presenter.arm(source: source) + await waitForSleeps(clock, count: 1) + await clock.advance(by: TooltipTiming.presentationDelay) + await presenter.settle() + presenter.update(source: source, hovering: false, focused: false) + await waitForSleeps(clock, count: 1) + presenter.dismissAll() + + #expect(!panel.isVisible) + #expect(!presenter.hasPendingTask) + #expect(presenter.visibleOwner == nil) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterRejectsCancelledRequestThatStillResumes() async { + let gate = TooltipSleepGate() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { await gate.sleep($0) }, panelFactory: { panel }) + let window = NSWindow() + let first = TooltipSourceStub(presenter: presenter, title: "First", window: window) + let second = TooltipSourceStub(presenter: presenter, title: "Second", window: window) + + presenter.arm(source: first) + while await gate.durations.count < 1 { await Task.yield() } + presenter.arm(source: second) + while await gate.durations.count < 2 { await Task.yield() } + await gate.releaseAll() + await presenter.settle() + await Task.yield() + + #expect(panel.contents == [second.tooltipContent]) + #expect(presenter.visibleOwner == second.tooltipOwner) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterCleansUpWhenItsSourceDisappears() async { + let gate = TooltipSleepGate() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { await gate.sleep($0) }, panelFactory: { panel }) + weak var weakSource: TooltipSourceStub? + do { + let source = TooltipSourceStub(presenter: presenter, title: "Removed", window: NSWindow()) + weakSource = source + presenter.arm(source: source) + while await gate.durations.isEmpty { await Task.yield() } + } + #expect(weakSource == nil) + await gate.releaseAll() + await presenter.settle() + + #expect(panel.contents.isEmpty) + #expect(!presenter.hasPendingTask) + #expect(!presenter.hasEventMonitor) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterIgnoresStaleExit() async { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let first = TooltipSourceStub(presenter: presenter, title: "First", window: window) + let second = TooltipSourceStub(presenter: presenter, title: "Second", window: window) + + presenter.arm(source: first) + await presenter.settle() + presenter.arm(source: second) + presenter.dismiss(owner: first.tooltipOwner) + await presenter.settle() + + #expect(presenter.visibleOwner == second.tooltipOwner) + #expect(panel.contents.last == second.tooltipContent) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterRestoresActiveParentAfterNestedChildExits() async { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let parent = TooltipSourceStub( + presenter: presenter, + title: "Status", + window: window, + anchorRect: CGRect(x: 80, y: 180, width: 120, height: 60) + ) + let child = TooltipSourceStub( + presenter: presenter, + title: "Copy", + window: window, + anchorRect: CGRect(x: 100, y: 200, width: 24, height: 20) + ) + + presenter.update(source: parent, hovering: true, focused: false) + await presenter.settle() + presenter.update(source: child, hovering: true, focused: false) + await presenter.settle() + presenter.update(source: parent, hovering: true, focused: false) + await presenter.settle() + #expect(presenter.visibleOwner == child.tooltipOwner) + + presenter.update(source: child, hovering: false, focused: false) + await presenter.settle() + #expect(presenter.visibleOwner == parent.tooltipOwner) + #expect(panel.contents.map(\.title) == ["Status", "Copy", "Status"]) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterRestoresParentAfterNestedChildDisappears() async { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let parent = TooltipSourceStub( + presenter: presenter, + title: "Parent", + window: window, + anchorRect: CGRect(x: 80, y: 180, width: 120, height: 60) + ) + presenter.update(source: parent, hovering: true, focused: false) + await presenter.settle() + weak var releasedChild: TooltipSourceStub? + do { + let child = TooltipSourceStub( + presenter: presenter, + title: "Child", + window: window, + anchorRect: CGRect(x: 100, y: 200, width: 24, height: 20) + ) + releasedChild = child + presenter.update(source: child, hovering: true, focused: false) + await presenter.settle() + } + presenter.update(source: parent, hovering: true, focused: false) + await presenter.settle() + + #expect(releasedChild == nil) + #expect(presenter.visibleOwner == parent.tooltipOwner) + #expect(panel.contents.map(\.title) == ["Parent", "Child", "Parent"]) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterLetsNewKeyboardFocusSupersedeParkedPointer() async { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let hovered = TooltipSourceStub(presenter: presenter, title: "Hovered", window: window) + let focused = TooltipSourceStub(presenter: presenter, title: "Focused", window: window) + + presenter.update(source: hovered, hovering: true, focused: false) + await presenter.settle() + presenter.update(source: focused, hovering: false, focused: true) + await presenter.settle() + + #expect(presenter.visibleOwner == focused.tooltipOwner) + #expect(panel.contents.map(\.title) == ["Hovered", "Focused"]) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterDoesNotRestoreOwnersAfterGlobalDismissal() async { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let parent = TooltipSourceStub(presenter: presenter, title: "Parent", window: window) + let child = TooltipSourceStub(presenter: presenter, title: "Child", window: window) + + presenter.update(source: parent, hovering: true, focused: false) + presenter.update(source: child, hovering: true, focused: false) + await presenter.settle() + presenter.dismissAll() + presenter.dismiss(owner: child.tooltipOwner) + await presenter.settle() + + #expect(presenter.visibleOwner == nil) + #expect(!panel.isVisible) + #expect(!presenter.hasPendingTask) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterReusesOnePanelUntilTeardown() async { + var factoryCount = 0 + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter( + sleep: { _ in }, + panelFactory: { + factoryCount += 1 + return panel + } + ) + let window = NSWindow() + let first = TooltipSourceStub(presenter: presenter, title: "First", window: window) + let second = TooltipSourceStub(presenter: presenter, title: "Second", window: window) + + presenter.arm(source: first) + await presenter.settle() + presenter.dismiss(owner: first.tooltipOwner) + presenter.arm(source: second) + await presenter.settle() + + #expect(factoryCount == 1) + #expect(panel.contents.count == 2) + presenter.tearDown() + #expect(!presenter.hasPanel) + #expect(!presenter.hasPendingTask) + #expect(!presenter.hasEventMonitor) + #expect(panel.tearDownCount == 1) + } + + @Test @MainActor func tooltipPresenterCancelsWhenItsScrollViewMoves() async { + let gate = TooltipSleepGate() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { await gate.sleep($0) }, panelFactory: { panel }) + let clipView = NSClipView() + let source = TooltipSourceStub(presenter: presenter, title: "Scrolled", window: NSWindow(), clipView: clipView) + + presenter.arm(source: source) + while await gate.durations.isEmpty { await Task.yield() } + NotificationCenter.default.post(name: NSView.boundsDidChangeNotification, object: clipView) + await gate.releaseAll() + await presenter.settle() + + #expect(panel.contents.isEmpty) + #expect(!presenter.hasPendingTask) + #expect(!presenter.hasEventMonitor) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterRefreshesForAccessibilityChangesAndDismissesWithItsWindow() async { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let source = TooltipSourceStub(presenter: presenter, title: "Accessible", window: window) + + presenter.arm(source: source) + await presenter.settle() + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, + object: nil + ) + await Task.yield() + await Task.yield() + + #expect(panel.contents.count == 2) + #expect(panel.reduceMotionValues.last == true) + + NotificationCenter.default.post(name: NSWindow.didMiniaturizeNotification, object: window) + await Task.yield() + await Task.yield() + #expect(presenter.visibleOwner == nil) + #expect(!presenter.hasEventMonitor) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterDismissesOnEscape() async throws { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let source = TooltipSourceStub(presenter: presenter, title: "Dismiss", window: window) + + presenter.arm(source: source) + await presenter.settle() + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + characters: "\u{1B}", + charactersIgnoringModifiers: "\u{1B}", + isARepeat: false, + keyCode: 53 + )) + NSApplication.shared.sendEvent(event) + + #expect(presenter.visibleOwner == nil) + #expect(!panel.isVisible) + #expect(!presenter.hasEventMonitor) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterRejectsAnInvalidAnchorOnMouseMovement() async throws { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let source = TooltipSourceStub(presenter: presenter, title: "Invalid", window: NSWindow()) + + presenter.arm(source: source) + await presenter.settle() + source.tooltipPresentationContext = nil + NSApplication.shared.sendEvent(try mouseEvent()) + + #expect(!panel.isVisible) + #expect(!presenter.hasPendingTask) + #expect(presenter.visibleOwner == nil) + presenter.tearDown() + } + + @Test @MainActor func tooltipTrackingViewDoesNotAddAKeyboardStop() { + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { TooltipPanelSpy() }) + let view = TooltipTrackingView( + content: TooltipContent(title: "Refresh", body: "Fetches current limits"), + presenter: presenter + ) + #expect(!view.acceptsFirstResponder) + #expect(view.trackingAreas.count == 1) + view.dismantle() + presenter.tearDown() + } + + @Test @MainActor func tooltipTrackingViewConvertsAFlippedFocusedAnchorToScreenCoordinates() throws { + let screen = try #require(NSScreen.screens.first) + let origin = CGPoint(x: screen.visibleFrame.minX + 100, y: screen.visibleFrame.minY + 120) + let window = TooltipMouseWindow( + contentRect: CGRect(origin: origin, size: CGSize(width: 300, height: 240)), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.alphaValue = 0 + let container = FlippedTooltipContainer(frame: CGRect(x: 0, y: 0, width: 300, height: 240)) + window.contentView = container + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { TooltipPanelSpy() }) + let content = TooltipContent(title: "Focused", body: "Explains the focused control.") + let view = TooltipTrackingView(content: content, presenter: presenter) + view.frame = CGRect(x: 30, y: 40, width: 120, height: 24) + container.addSubview(view) + window.orderFrontRegardless() + + view.update(content: content, focused: true) + let context = try #require(view.tooltipPresentationContext) + + #expect(context.parentWindow === window) + #expect(context.anchorRect == CGRect(x: origin.x + 30, y: origin.y + 176, width: 120, height: 24)) + presenter.tearDown() + window.orderOut(nil) + } + + @Test @MainActor func tooltipTrackingViewAccountsForAScrolledAnchor() throws { + let screen = try #require(NSScreen.screens.first) + let origin = CGPoint(x: screen.visibleFrame.minX + 140, y: screen.visibleFrame.minY + 160) + let window = TooltipMouseWindow( + contentRect: CGRect(origin: origin, size: CGSize(width: 320, height: 240)), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.alphaValue = 0 + let container = NSView(frame: CGRect(x: 0, y: 0, width: 320, height: 240)) + window.contentView = container + let clipView = NSClipView(frame: CGRect(x: 50, y: 40, width: 200, height: 100)) + let documentView = NSView(frame: CGRect(x: 0, y: 0, width: 200, height: 400)) + clipView.documentView = documentView + clipView.bounds.origin = CGPoint(x: 0, y: 100) + container.addSubview(clipView) + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { TooltipPanelSpy() }) + let content = TooltipContent(title: "Scrolled", body: "Explains the visible row.") + let view = TooltipTrackingView(content: content, presenter: presenter) + view.frame = CGRect(x: 20, y: 120, width: 100, height: 30) + documentView.addSubview(view) + window.orderFrontRegardless() + + view.update(content: content, focused: true) + let context = try #require(view.tooltipPresentationContext) + + #expect(context.anchorRect == CGRect(x: origin.x + 70, y: origin.y + 60, width: 100, height: 30)) + presenter.tearDown() + window.orderOut(nil) + } + + @Test @MainActor func tooltipTrackingViewAnchorsHoverToTheCurrentPointerAndRejectsAStaleHover() async throws { + let screen = try #require(NSScreen.screens.first) + let origin = CGPoint(x: screen.visibleFrame.minX + 180, y: screen.visibleFrame.minY + 180) + let window = TooltipMouseWindow( + contentRect: CGRect(origin: origin, size: CGSize(width: 260, height: 180)), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.alphaValue = 0 + let container = NSView(frame: CGRect(x: 0, y: 0, width: 260, height: 180)) + window.contentView = container + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let content = TooltipContent(title: "Pointer", body: "Explains the hovered control.") + let view = TooltipTrackingView(content: content, presenter: presenter) + view.frame = CGRect(x: 30, y: 40, width: 120, height: 30) + container.addSubview(view) + window.orderFrontRegardless() + window.mouseLocation = CGPoint(x: 50, y: 55) + + view.mouseEntered(with: try mouseEvent()) + await presenter.settle() + + #expect(panel.anchorRects == [CGRect(x: origin.x + 50, y: origin.y + 55, width: 1, height: 1)]) + window.mouseLocation = CGPoint(x: 230, y: 160) + presenter.refresh(source: view) + #expect(presenter.visibleOwner == nil) + #expect(!panel.isVisible) + presenter.tearDown() + window.orderOut(nil) + } + + @Test @MainActor func tooltipPresenterRejectsAWindowChangedDuringTheDelay() async { + let gate = TooltipSleepGate() + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { await gate.sleep($0) }, panelFactory: { panel }) + let firstWindow = NSWindow() + let secondWindow = NSWindow() + let source = TooltipSourceStub(presenter: presenter, title: "Moved", window: firstWindow) + + presenter.arm(source: source) + while await gate.durations.isEmpty { await Task.yield() } + source.tooltipPresentationContext = TooltipPresentationContext( + anchorRect: CGRect(x: 120, y: 220, width: 40, height: 20), + visibleFrame: CGRect(x: 0, y: 0, width: 800, height: 600), + parentWindow: secondWindow + ) + await gate.releaseAll() + await presenter.settle() + + #expect(panel.contents.isEmpty) + #expect(presenter.visibleOwner == nil) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterDismissesOnOwnerWindowGeometryChanges() async { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let source = TooltipSourceStub(presenter: presenter, title: "Moved", window: window) + + presenter.arm(source: source) + await presenter.settle() + NotificationCenter.default.post(name: NSWindow.didMoveNotification, object: window) + await Task.yield() + await Task.yield() + + #expect(panel.anchorRects == [CGRect(x: 100, y: 200, width: 40, height: 20)]) + #expect(!panel.isVisible) + #expect(presenter.visibleOwner == nil) + presenter.tearDown() + } + + @Test @MainActor func tooltipPresenterDismissesWhenTheAnchorBecomesInvalidDuringResize() async { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let window = NSWindow() + let source = TooltipSourceStub(presenter: presenter, title: "Removed", window: window) + + presenter.arm(source: source) + await presenter.settle() + source.tooltipPresentationContext = nil + NotificationCenter.default.post(name: NSWindow.didResizeNotification, object: window) + await Task.yield() + await Task.yield() + + #expect(presenter.visibleOwner == nil) + #expect(!panel.isVisible) + presenter.tearDown() + } + + @Test @MainActor func tooltipTrackingViewRefreshesVisibleContentInItsWindow() async throws { + let screen = try #require(NSScreen.screens.first) + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let initial = TooltipContent(title: "Initial", body: "First explanation") + let view = TooltipTrackingView(content: initial, presenter: presenter) + let frame = CGRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.midY, width: 160, height: 40) + let window = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.alphaValue = 0 + window.contentView = view + view.frame = CGRect(origin: .zero, size: frame.size) + window.orderFrontRegardless() + + view.update(content: initial, focused: true) + let context = try #require(view.tooltipPresentationContext) + #expect(context.parentWindow === window) + #expect(!context.anchorRect.isEmpty) + + await presenter.settle() + let updated = TooltipContent(title: "Updated", body: "Second explanation") + view.update(content: updated, focused: true) + + #expect(panel.contents == [initial, updated]) + #expect(panel.reduceMotionValues.last == true) + window.contentView = nil + #expect(presenter.visibleOwner == nil) + view.dismantle() + presenter.tearDown() + } + + @Test @MainActor func tooltipTrackingViewKeepsFocusedHelpAfterPointerExit() async throws { + let gate = TooltipSleepGate() + let presenter = TooltipPresenter( + sleep: { await gate.sleep($0) }, + panelFactory: { TooltipPanelSpy() } + ) + let view = TooltipTrackingView( + content: TooltipContent(title: "Refresh", body: "Fetches current limits"), + presenter: presenter + ) + let window = try showInvisibleWindow(containing: view) + view.update(content: view.tooltipContent, focused: true) + while await gate.durations.isEmpty { await Task.yield() } + + let exit = try #require( + NSEvent.mouseEvent( + with: .mouseMoved, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 0, + pressure: 0 + ) + ) + view.mouseExited(with: exit) + #expect(presenter.hasPendingTask) + + view.update(content: view.tooltipContent, focused: false) + await gate.releaseAll() + await presenter.settle() + #expect(!presenter.hasPendingTask) + view.dismantle() + presenter.tearDown() + window.orderOut(nil) + } + + @Test @MainActor func tooltipTrackingViewDismissesHelpWhenHidden() async throws { + let panel = TooltipPanelSpy() + let presenter = TooltipPresenter(sleep: { _ in }, panelFactory: { panel }) + let view = TooltipTrackingView( + content: TooltipContent(title: "Refresh", body: "Fetches current limits"), + presenter: presenter + ) + let window = try showInvisibleWindow(containing: view) + view.update(content: view.tooltipContent, focused: true) + await presenter.settle() + #expect(panel.isVisible) + + view.viewDidHide() + + #expect(!panel.isVisible) + #expect(presenter.visibleOwner == nil) + view.dismantle() + presenter.tearDown() + window.orderOut(nil) + } + + @Test @MainActor func tooltipTrackingViewTransitionsBetweenPointerAndFocusWithoutRearming() async throws { + let gate = TooltipSleepGate() + let presenter = TooltipPresenter( + sleep: { await gate.sleep($0) }, + panelFactory: { TooltipPanelSpy() } + ) + let view = TooltipTrackingView( + content: TooltipContent(title: "Refresh", body: "Fetches current limits"), + presenter: presenter + ) + let window = try showInvisibleWindow(containing: view) + view.update(content: view.tooltipContent, focused: true) + while await gate.durations.isEmpty { await Task.yield() } + view.mouseEntered(with: try mouseEvent()) + view.mouseExited(with: try mouseEvent()) + + #expect(await gate.durations == [TooltipTiming.presentationDelay]) + #expect(presenter.hasPendingTask) + await gate.releaseAll() + await presenter.settle() + #expect(!presenter.hasPendingTask) + view.dismantle() + presenter.tearDown() + window.orderOut(nil) + } + + @MainActor + private func showInvisibleWindow(containing view: NSView) throws -> TooltipMouseWindow { + let screen = try #require(NSScreen.screens.first) + let window = TooltipMouseWindow( + contentRect: CGRect( + x: screen.visibleFrame.midX, + y: screen.visibleFrame.midY, + width: 180, + height: 60 + ), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.alphaValue = 0 + window.contentView = view + view.frame = CGRect(x: 0, y: 0, width: 180, height: 60) + window.mouseLocation = CGPoint(x: 90, y: 30) + window.orderFrontRegardless() + return window + } + + private func waitForSleeps(_ clock: TooltipTestClock, count: Int) async { + while await clock.pendingCount != count { await Task.yield() } + } + + @MainActor + private func tooltipTrackingViews(in view: NSView) -> [TooltipTrackingView] { + (view as? TooltipTrackingView).map { [$0] } ?? view.subviews.flatMap { tooltipTrackingViews(in: $0) } + } + + private func mouseEvent() throws -> NSEvent { + try #require( + NSEvent.mouseEvent( + with: .mouseMoved, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 0, + pressure: 0 + ) + ) + } +} diff --git a/Tests/TokenMenuBarUITests/ViewTests.swift b/Tests/TokenMenuBarUITests/ViewTests.swift new file mode 100644 index 0000000..927efe9 --- /dev/null +++ b/Tests/TokenMenuBarUITests/ViewTests.swift @@ -0,0 +1,869 @@ +import Accessibility +import AppKit +import SwiftUI +import Testing + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func rootViewHostsEveryTab() async throws { + let environment = try makeEnvironment() + environment.log.debugEnabled = true + var measured: [PopoverTab] = [] + var selected: [PopoverTab] = [] + for tab in PopoverTab.allCases { + environment.settings.lastTab = tab + let view = RootView( + environment: environment, onMeasure: { measured.append($0.tab) }, onTabChange: { selected.append($0) }) + let hosting = host(view) + #expect(hosting.frame.width == 520) + await waitUntil { measured.contains(tab) } + view.select(.history) + } + #expect(selected == [.history, .history]) + #expect(environment.settings.lastTab == .history) + environment.tick() + #expect(environment.now == fixedNow) + // The cards are the only reader, so nothing is fetched while the popover is closed. + await environment.loadRecentSamples() + #expect(environment.samples.isEmpty) + environment.state.popoverVisible = true + environment.settings.lastTab = .usage + await environment.loadRecentSamples() + #expect(environment.samples.count == 6) + #expect(environment.cards.count == 2) + #expect(environment.log.text.contains("tab.transition from=")) +} + +@Test @MainActor func rootTabsKeepOneFrameAcrossSwitches() async throws { + let environment = try makeEnvironment() + let root = RootView(environment: environment, onMeasure: { _ in }, onTabChange: { _ in }) + let hosting = host(root, width: 880, height: 900) + await mainActorTurn() + await mainActorTurn() + hosting.layoutSubtreeIfNeeded() + let controls: [NSSegmentedControl] = findViews(in: hosting) + let tabControl = try #require(controls.first { $0.accessibilityLabel() == "Popover tabs" }) + #expect(controls.count { $0.accessibilityLabel() == "Popover tabs" } == 1) + let frame = tabControl.frame + + for tab in PopoverTab.allCases + PopoverTab.allCases { + root.select(tab) + hosting.layoutSubtreeIfNeeded() + #expect(tabControl.frame == frame) + } +} + +@Test @MainActor func rootViewAdvancesUsageDeadlinesOnlyWhileThePopoverIsOpen() async throws { + let cases: [(popoverVisible: Bool, tab: PopoverTab, advances: Bool)] = [ + (false, .usage, false), (true, .history, false), (true, .settings, false), (true, .usage, true), + ] + for item in cases { + let clock = SteppableClock() + let environment = try makeEnvironment(clock: Clock(now: { clock.reading }, sleep: { try await clock.sleep($0) })) + environment.state.popoverVisible = item.popoverVisible + environment.settings.lastTab = item.tab + let hosting = host( + RootView(environment: environment, onMeasure: { _ in }, onTabChange: { _ in })) + #expect(hosting.frame.width == 520) + if item.popoverVisible { + await waitUntil { environment.samples.count == 6 } + } + let initialDeadlineNow = environment.usageDeadlineNow + let initialNow = environment.now + + let later = fixedNow.addingTimeInterval(600) + clock.reading = later + if item.advances { + await waitUntil { environment.usageDeadlineNow == later } + } else { + await mainActorTurn() + } + #expect(environment.usageDeadlineNow == (item.advances ? later : initialDeadlineNow)) + #expect(environment.now == initialNow) + } +} + +@Test @MainActor func rootViewStopsTheUsageClockWhenSleepFails() async throws { + let sleeper = ThrowingSleeper() + let environment = try makeEnvironment( + clock: Clock(now: { fixedNow }, sleep: { try await sleeper.sleep($0) })) + environment.state.popoverVisible = true + environment.settings.lastTab = .usage + let initialDeadlineNow = environment.usageDeadlineNow + + let hosting = host(RootView(environment: environment, onMeasure: { _ in }, onTabChange: { _ in })) + + #expect(hosting.frame.width == 520) + await waitUntil { sleeper.calls > 0 } + #expect(sleeper.calls == 1) + #expect(environment.usageDeadlineNow == initialDeadlineNow) +} + +final class SteppableClock: @unchecked Sendable { + private let lock = NSLock() + private var stored = fixedNow + private let changes: AsyncStream + private let continuation: AsyncStream.Continuation + + init() { + (changes, continuation) = AsyncStream.makeStream() + } + + var reading: Date { + get { lock.withLock { stored } } + set { + lock.withLock { stored = newValue } + continuation.yield(newValue) + } + } + + func sleep(_ interval: TimeInterval) async throws { + let deadline = reading.addingTimeInterval(interval) + for await date in changes { + try Task.checkCancellation() + if date >= deadline { return } + } + throw CancellationError() + } +} + +final class ThrowingSleeper: @unchecked Sendable { + private let lock = NSLock() + private var storedCalls = 0 + + var calls: Int { lock.withLock { storedCalls } } + + func sleep(_ interval: TimeInterval) async throws { + lock.withLock { storedCalls += 1 } + throw TestError() + } +} + +@Test @MainActor func tabPickerRoundTripsWithoutTooltips() throws { + var selection = PopoverTab.usage + let binding = Binding(get: { selection }, set: { selection = $0 }) + let hosting = host(TabPicker(selection: binding), width: 300, height: 40) + #expect(inkFraction(TabPicker(selection: binding), width: 300, height: 40) > 0) + let hostedControl = try #require(firstSubview(NSSegmentedControl.self, in: hosting)) + #expect(hostedControl.toolTip == nil) + for segment in PopoverTab.allCases.indices { + #expect(hostedControl.toolTip(forSegment: segment) == nil) + } + let coordinator = TabPicker.Coordinator(selection: binding) + let control = NSSegmentedControl( + labels: PopoverTab.allCases.map(\.rawValue), trackingMode: .selectOne, target: nil, action: nil) + control.selectedSegment = 2 + coordinator.changed(control) + #expect(selection == .settings) + control.selectedSegment = -1 + coordinator.changed(control) + #expect(selection == .usage) +} + +@Test @MainActor func nativeSegmentedControlRoundTripsThroughAppKit() throws { + var selection = "Stable" + let binding = Binding(get: { selection }, set: { selection = $0 }) + let hosting = host( + NativeSegmentedControl( + [(value: "Stable", label: "Stable"), (value: "Usage", label: "Usage")], + selection: binding, + accessibilityLabel: "Order", + accessibilityIdentifier: "model-order"), + width: 180, + height: 40) + let hostedControl = try #require(firstSubview(NSSegmentedControl.self, in: hosting)) + + #expect(hostedControl.accessibilityLabel() == "Order") + #expect(hostedControl.accessibilityIdentifier() == "model-order") + #expect(hostedControl.selectedSegment == 0) + #expect((0.. 100) + + let coordinator = NativeSegmentedControl.Coordinator( + selection: binding, + values: ["Stable", "Usage"]) + hostedControl.selectedSegment = 1 + coordinator.changed(hostedControl) + #expect(selection == "Usage") + hostedControl.selectedSegment = -1 + coordinator.changed(hostedControl) + #expect(selection == "Usage") +} + +@MainActor +private func firstSubview(_ type: View.Type, in root: NSView) -> View? { + var pending = [root] + while let view = pending.popLast() { + if let match = view as? View { return match } + pending.append(contentsOf: view.subviews) + } + return nil +} + +@Test @MainActor func usageTabRendersCardsAndEmptyStates() throws { + let environment = try makeEnvironment() + #expect(inkFraction(UsageTab(environment: environment)) > 0) + environment.settings.enabledProviders = [] + environment.state.remove(.claude) + environment.state.remove(.codex) + environment.refreshUsagePresentation() + #expect(inkFraction(UsageTab(environment: environment)) > 0) + let empty = try makeEnvironment(populate: false) + empty.state.update(.claude) { + $0.availability = .authenticationRequired + $0.credentialState = .expired(fixedNow) + } + empty.state.update(.codex) { $0.availability = .networkUnavailable } + empty.refreshUsagePresentation() + #expect(empty.cards.isEmpty) + #expect(inkFraction(UsageTab(environment: empty)) > 0) + let authenticated = try makeEnvironment(populate: false) + authenticated.state.update(.claude) { + $0.availability = .current + $0.credentialState = .valid(expiresAt: nil) + } + authenticated.refreshUsagePresentation() + #expect(authenticated.cards.map(\.provider) == [.claude]) + #expect(inkFraction(UsageTab(environment: authenticated)) > 0) + let stale = try makeEnvironment(populate: false) + stale.state.update(.claude) { + $0.snapshot = sampleSnapshot(.claude) + $0.availability = .stale + $0.lastError = "network down" + } + stale.refreshUsagePresentation() + #expect(inkFraction(UsageTab(environment: stale)) > 0) + let card = ProviderCardView(card: try #require(stale.cards.first), environment: stale) + #expect(card.icon(for: .authenticationRequired) == "person.crop.circle.badge.exclamationmark") + #expect(card.icon(for: .networkUnavailable) == "wifi.slash") + #expect(card.icon(for: .disabled) == "pause.circle") + #expect(card.icon(for: .loading) == "hourglass") + #expect(card.icon(for: .current) == "chart.bar") +} + +@Test @MainActor func usageDemoControlDisablesDemoMode() throws { + let environment = try makeEnvironment(populate: false) + environment.isDemo = true + var values: [Bool] = [] + environment.actions.setDemoMode = { values.append($0) } + let buttons = findNativeTextButtons(in: UsageTab(environment: environment).body) + + for button in buttons { button.action() } + + #expect(values == [false]) +} + +@Test @MainActor func providerCardRefreshRoutesItsProvider() throws { + let environment = try makeEnvironment() + var refreshed: [ProviderID] = [] + let card = ProviderCardView( + card: try #require(environment.cards.first { $0.provider == .codex }), environment: environment, + onRefreshProvider: { refreshed.append($0) }) + card.refresh() + #expect(refreshed == [.codex]) +} + +@Test @MainActor func providerRecoveryCardRoutesToSettings() throws { + let environment = try makeEnvironment(populate: false) + environment.state.update(.claude) { + $0.snapshot = sampleSnapshot(.claude) + $0.availability = .authenticationRequired + $0.credentialState = .expired(fixedNow) + } + environment.refreshUsagePresentation() + var opened: [ProviderID?] = [] + environment.actions.showProviders = { opened.append($0) } + let card = ProviderCardView(card: try #require(environment.cards.first), environment: environment) + card.showProviders() + #expect(opened == [.claude]) +} + +@Test @MainActor func usageTabUsesTheProviderRefreshAction() throws { + let environment = try makeEnvironment() + var refreshed: [ProviderID] = [] + environment.actions.refreshProvider = { refreshed.append($0) } + UsageTab(environment: environment).onRefreshProvider(.codex) + #expect(refreshed == [.codex]) +} + +@Test @MainActor func usageIdentityChipRoutesBothCopyControls() { + var copied: [String] = [] + let chip = UsageIdentityChip(chip: Chip(text: "Max"), provider: .claude, onCopy: { copied.append($0) }) + chip.primaryAction() + chip.copyAction() + #expect(copied == ["Max", "Max"]) +} + +@Test @MainActor func usageIdentityChipKeepsExplanatoryHelp() { + let chip = UsageIdentityChip(chip: Chip(text: "Max"), provider: .claude, onCopy: { _ in }) + #expect(chip.primaryHelp.accessibilityHint.contains("Claude plan")) + #expect(chip.copyHelp.accessibilityHint == "Copy Max. Copies this value to the clipboard.") +} + +@Test @MainActor func windowRowsSpendAndCreditsRender() { + let snapshot = sampleSnapshot(.claude) + let card = UsagePresenter.card( + provider: .claude, state: ProviderState(snapshot: snapshot, availability: .current), samples: [:], now: fixedNow) + for row in card.rows { + let view = WindowRowView(row: row, now: fixedNow) + #expect(inkFraction(view, width: 820, height: 50) > 0) + } + let ahead = WindowRow( + key: card.rows[0].key, window: card.rows[0].window, + pace: PaceEstimate(status: .ahead, expectedPercent: 10, ratio: 3, projectedExhaustion: nil), countdown: "1h", + resetClock: "x") + #expect(WindowRowView(row: ahead, now: fixedNow).paceColor == .primary) + let behind = WindowRow( + key: card.rows[0].key, window: card.rows[0].window, + pace: PaceEstimate(status: .behind, expectedPercent: 10, ratio: 0.1, projectedExhaustion: nil), countdown: "1h", + resetClock: "x") + #expect(WindowRowView(row: behind, now: fixedNow).paceColor == .primary) + #expect(WindowRowView(row: card.rows[0], now: fixedNow).paceColor == .primary) + let onTrack = WindowRow( + key: card.rows[0].key, window: card.rows[0].window, + pace: PaceEstimate(status: .onTrack, expectedPercent: 30, ratio: 1, projectedExhaustion: nil), countdown: "1h", + resetClock: "x") + // Pace text remains readable; only the marker and bar carry the state colour. + #expect(WindowRowView(row: onTrack, now: fixedNow).paceColor == .primary) + #expect(inkFraction(SpendView(spend: snapshot.spend!, provider: .claude, now: fixedNow), width: 400, height: 200) > 0) + #expect( + inkFraction( + SpendView(spend: SpendControl(enabled: false, disabledReason: "off"), provider: .codex, now: fixedNow), + width: 400, + height: 200) > 0) + #expect( + inkFraction(CreditsView(credits: snapshot.credits, resetCredits: snapshot.resetCredits), width: 400, height: 200) + > 0) + #expect(inkFraction(CreditsView(credits: nil, resetCredits: nil), width: 400, height: 200) == 0) + #expect(inkFraction(LocalUsageView(usage: snapshot.localUsage!), width: 400, height: 200) > 0) + #expect(LocalUsageView.money(3.456) == "$3.46") + #expect(LocalUsageView.money(42.4) == "$42") +} + +@Test @MainActor func usageEnvironmentStoresPresentationAndAdvancesLeafClock() throws { + let environment = try makeEnvironment() + let presentation = environment.usagePresentation + #expect(presentation.cards.count == 2) + #expect(environment.nextUsageDeadline() == fixedNow.addingTimeInterval(1)) + #expect(environment.advanceUsageDeadlines(to: fixedNow.addingTimeInterval(1))) + #expect(environment.usageDeadlineNow == fixedNow.addingTimeInterval(1)) + #expect(environment.usagePresentation == presentation) + environment.state.remove(.codex) + environment.refreshUsagePresentation() + #expect(environment.usagePresentation.cards.map(\.provider) == [.claude]) +} + +@Test @MainActor func usageEnvironmentRefreshesVisibleUsageWhenProviderStateChanges() async throws { + let environment = try makeEnvironment() + environment.state.popoverVisible = true + environment.settings.lastTab = .usage + + environment.state.update(.codex) { + $0.snapshot = sampleSnapshot(.codex, percent: 12) + } + + await waitUntil { + environment.cards.first { $0.provider == .codex }?.rows.first?.window.usedPercent == 12 + } + #expect(environment.cards.first { $0.provider == .codex }?.rows.first?.window.usedPercent == 12) +} + +@Test @MainActor func usageTabStaysWithinTheDenseContentBudget() async throws { + let environment = try makeEnvironment() + var measured: [PopoverMeasurement] = [] + let hosting = host( + RootView(environment: environment, onMeasure: { measured.append($0) }, onTabChange: { _ in }), width: 880, + height: 1200) + #expect(hosting.frame.width == 880) + await waitUntil { measured.contains { $0.tab == .usage } } + let usageHeight = try #require(measured.last { $0.tab == .usage }?.size.height) + #expect(usageHeight <= 1100) +} + +@Test @MainActor func historyTabRendersStatesAndInteractions() async throws { + let environment = try makeEnvironment() + let history = environment.history + let earlier = fixedNow.addingTimeInterval(-3600) + try await history.record( + ProviderSnapshot( + provider: .claude, + windows: [ + QuotaWindow( + id: "session", label: "Session", group: .session, usedPercent: 10, + resetsAt: fixedNow.addingTimeInterval(3600), duration: 18000) + ], fetchedAt: earlier), now: earlier) + try await history.record(sampleSnapshot(.claude), now: fixedNow.addingTimeInterval(-60)) + try await history.record( + ProviderAnalytics( + provider: .codex, points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .turns, series: "m", value: 3)], + fetchedAt: fixedNow)) + #expect(inkFraction(HistoryTab(environment: environment), width: 700, height: 800) > 0) + let presenter = environment.historyPresenter + presenter.reload() + await presenter.waitForLoad() + #expect(inkFraction(HistoryTab(environment: environment), width: 700, height: 800) > 0) + environment.settings.historyRange = .custom + environment.settings.historyStacked = true + environment.settings.historyRollup = .day + #expect(inkFraction(HistoryTab(environment: environment), width: 700, height: 800) > 0) + let data = presenter.state.data! + let chart = UsageChart(data: data, presenter: presenter, stacked: false, timeZone: .current) + #expect(UsageChart.color(index: 9) == UsageChart.palette[1]) + #expect( + UsageChart.axisFormat(for: fixedNow...fixedNow.addingTimeInterval(3600)) + != UsageChart.axisFormat(for: fixedNow...fixedNow.addingTimeInterval(5 * 86400))) + presenter.select(x: fixedNow) + #expect( + inkFraction( + UsageChart(data: data, presenter: presenter, stacked: true, timeZone: .current), width: 400, height: 240) > 0) + #expect(inkFraction(HistoryInspector(environment: environment), width: 240, height: 300) > 0) + environment.settings.historyHiddenKeys = [data.series[0].key] + #expect(inkFraction(HistoryInspector(environment: environment), width: 240, height: 300) > 0) + presenter.setMetric(.analytics(.turns)) + await presenter.waitForLoad() + let analytics = try #require(presenter.state.data) + #expect(analytics.metric == .analytics(.turns)) + #expect(analytics.series.map(\.summaryValue) == [3]) + #expect( + inkFraction( + UsageChart(data: analytics, presenter: presenter, stacked: false, timeZone: presenter.chartTimeZone), width: 500, + height: 240) > 0) + #expect( + inkFraction( + UsageChart(data: analytics, presenter: presenter, stacked: true, timeZone: presenter.chartTimeZone), width: 500, + height: 240) > 0) + #expect( + inkFraction( + Rectangle().fill(UsageChart.barStyle(HistoryStyleSlot(index: 8))), width: 40, height: 20) > 0) + #expect( + inkFraction( + HistoryLegendSwatch(style: HistoryStyleSlot(index: 16), markKind: .line), width: 18, height: 10) > 0) + chart.pick(CGPoint(x: -1, y: -1), in: CGRect(x: 0, y: 0, width: 100, height: 100)) + #expect(presenter.selectedDate == nil) + try await history.breakDatabase() + presenter.reload() + await presenter.waitForLoad() + await presenter.exportCSV(to: try uiTemporaryDirectory().appendingPathComponent("failed-history.csv")).value + #expect(inkFraction(HistoryTab(environment: environment), width: 700, height: 800) > 0) +} + +@Test @MainActor func historyTabRendersAnInitialFailure() async throws { + let environment = try makeEnvironment(populate: false) + try await environment.history.breakDatabase() + environment.historyPresenter.reload() + await environment.historyPresenter.waitForLoad() + + #expect(inkFraction(HistoryTab(environment: environment), width: 700, height: 800) > 0) +} + +@Test @MainActor func historyInspectorRendersSelectedResetMetadata() async throws { + let environment = try makeEnvironment(populate: false) + let earlier = fixedNow.addingTimeInterval(-3600) + let later = fixedNow.addingTimeInterval(-600) + let snapshot: (Double, Date) -> ProviderSnapshot = { percent, date in + ProviderSnapshot( + provider: .claude, + windows: [ + QuotaWindow( + id: "session", label: "Session", group: .session, usedPercent: percent, + resetsAt: date.addingTimeInterval(3600), duration: 18000) + ], fetchedAt: date) + } + try await environment.history.record(snapshot(80, earlier), now: earlier) + try await environment.history.record(snapshot(5, later), now: later) + let presenter = environment.historyPresenter + presenter.reload() + await presenter.waitForLoad() + let series = try #require(presenter.state.data?.series.first) + let reset = try #require(series.points.first { $0.isReset }) + presenter.select(x: reset.date) + + #expect(inkFraction(HistoryInspector(environment: environment), width: 260, height: 300) > 0) +} + +@Test @MainActor func historyInspectorKeepsEveryLegendRowReadableDuringHover() async throws { + let environment = try makeEnvironment(populate: false) + try await environment.history.record(sampleSnapshot(.claude), now: fixedNow.addingTimeInterval(-60)) + try await environment.history.record(sampleSnapshot(.codex), now: fixedNow.addingTimeInterval(-60)) + let presenter = environment.historyPresenter + presenter.reload() + await presenter.waitForLoad() + let first = try #require(presenter.state.data?.series.first?.id) + let inspector = HistoryInspector(environment: environment) + let normal = opaqueInkFraction(inspector, width: 300, height: 300) + + presenter.setHovered(first) + let hovered = opaqueInkFraction(inspector, width: 300, height: 300) + + #expect(hovered >= normal * 0.9) +} + +@Test @MainActor func historyChartBuildsAccessibleLineAndBarDescriptors() throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + let resetDate = fixedNow.addingTimeInterval(60) + let line = HistoryChartModel( + metric: .analytics(.surfaceUsagePercent), + series: [ + HistorySeries( + id: .analytics(provider: .codex, series: "cli"), label: "CLI", + points: [ + SeriesPoint(date: fixedNow, value: 10), + SeriesPoint(date: resetDate, value: 20, segment: 1, isReset: true), + SeriesPoint( + date: resetDate.addingTimeInterval(60), value: 30, resetsAt: resetDate, segment: 1, isReset: true), + ]) + ], domain: fixedNow...resetDate.addingTimeInterval(60), yMax: 100, summaryText: "CLI usage") + let lineDescriptor = try #require( + findChartDescriptor( + in: UsageChart(data: line, presenter: presenter, stacked: false, timeZone: TimeZone(secondsFromGMT: 0)!).body)) + + #expect(lineDescriptor.title == "Usage by surface history") + #expect(lineDescriptor.series.map(\.name) == ["CLI"]) + #expect(lineDescriptor.series.flatMap(\.dataPoints).compactMap(\.label).count == 2) + let xAxis = try #require(lineDescriptor.xAxis as? AXNumericDataAxisDescriptor) + #expect(!xAxis.valueDescriptionProvider(fixedNow.timeIntervalSinceReferenceDate).isEmpty) + #expect(lineDescriptor.yAxis?.valueDescriptionProvider(25) == "25%") + + let units: [(HistoryMetric, String)] = [ + (.analytics(.costUSD), "$2.50"), (.analytics(.inputTokens), "2.5K"), (.analytics(.turns), "2.5K"), + ] + for (metric, expected) in units { + let model = HistoryChartModel( + metric: metric, + series: [ + HistorySeries( + id: .analytics(provider: metric.suppliers[0], series: "total"), label: "Total", + points: [SeriesPoint(date: fixedNow, value: 2500)]) + ], domain: fixedNow...fixedNow.addingTimeInterval(86400), yMax: 3000) + let descriptor = try #require( + findChartDescriptor( + in: UsageChart(data: model, presenter: presenter, stacked: false, timeZone: TimeZone(secondsFromGMT: 0)!) + .body)) + #expect(descriptor.series.count == 1) + #expect(!descriptor.series[0].isContinuous) + #expect(descriptor.yAxis?.valueDescriptionProvider(metric.unit == .usd ? 2.5 : 2500) == expected) + } +} + +@Test @MainActor func historyTabExportsTheSelectedPeriodToTheChosenURL() async throws { + let environment = try makeEnvironment(populate: false) + let outside = fixedNow.addingTimeInterval(-8 * 86400) + let current = fixedNow.addingTimeInterval(-60) + try await environment.history.record(sampleSnapshot(.claude), now: outside) + try await environment.history.record(sampleSnapshot(.claude), now: current) + let presenter = environment.historyPresenter + presenter.setRange(.week) + presenter.reload() + await presenter.waitForLoad() + let directory = try uiTemporaryDirectory() + let url = directory.appendingPathComponent("selected-history.csv") + let tab = HistoryTab(environment: environment, chooseExportURL: { url }) + let export = try #require(findNativeTextButtons(in: tab.body).first) + + export.action() + await waitUntil { ((try? String(contentsOf: url, encoding: .utf8)) ?? "").contains("claude:session") } + + let text = try String(contentsOf: url, encoding: .utf8) + #expect(text.hasPrefix("timestamp,key,label,used_percent,resets_at")) + #expect(text.contains(ISODate.string(current))) + #expect(!text.contains(ISODate.string(outside))) +} + +@Test @MainActor func historyPeriodAndRollupControlsDispatchNativeActions() async throws { + let environment = try makeEnvironment(populate: false) + let presenter = environment.historyPresenter + presenter.reload() + await presenter.waitForLoad() + let hosting = host(HistoryTab(environment: environment), width: 900, height: 900) + let controls: [NSSegmentedControl] = findViews(in: hosting) + let period = try #require(controls.first { $0.segmentCount == HistoryPeriod.allCases.count }) + let rollup = try #require(controls.first { $0.segmentCount == Rollup.allCases.count }) + + period.selectedSegment = 2 + period.sendAction(period.action, to: period.target) + await waitUntil { presenter.period == .range(.week) } + rollup.selectedSegment = 1 + rollup.sendAction(rollup.action, to: rollup.target) + await waitUntil { presenter.effectiveRollup == .hour } + + #expect(environment.settings.historyRange == .week) + #expect(environment.settings.historyRollup == .hour) +} + +private func findChartDescriptor(in value: Any, depth: Int = 0) -> AXChartDescriptor? { + if let representable = value as? any AXChartDescriptorRepresentable { return representable.makeChartDescriptor() } + guard depth < 48 else { return nil } + for child in Mirror(reflecting: value).children { + if let descriptor = findChartDescriptor(in: child.value, depth: depth + 1) { return descriptor } + } + return nil +} + +@MainActor private func keyEvent(_ key: KeyEquivalent, keyCode: UInt16, window: NSWindow) -> NSEvent { + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, + characters: String(key.character), charactersIgnoringModifiers: String(key.character), isARepeat: false, + keyCode: keyCode)! +} + +private func findNativeTextButtons(in value: Any, depth: Int = 0) -> [NativeActionButton] { + if let button = value as? NativeActionButton { return [button] } + guard depth < 48 else { return [] } + return Mirror(reflecting: value).children.flatMap { findNativeTextButtons(in: $0.value, depth: depth + 1) } +} + +@MainActor private func findViews(in root: NSView) -> [Wanted] { + (root as? Wanted).map { [$0] } ?? root.subviews.flatMap { findViews(in: $0) } +} + +private func uiTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + "token-menu-bar-ui-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +@MainActor private func opaqueInkFraction( + _ view: Content, width: CGFloat, height: CGFloat +) -> Double { + let hosting = host(view, width: width, height: height) + guard let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds) else { return 0 } + hosting.cacheDisplay(in: hosting.bounds, to: rep) + guard let image = rep.cgImage else { return 0 } + var pixels = [UInt8](repeating: 0, count: image.width * image.height * 4) + let context = CGContext( + data: &pixels, width: image.width, height: image.height, bitsPerComponent: 8, bytesPerRow: image.width * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return Double(stride(from: 3, to: pixels.count, by: 4).count { pixels[$0] > 200 }) + / Double(image.width * image.height) +} + +@Test @MainActor func settingsTabRendersAndMutates() throws { + let environment = try makeEnvironment() + var changes = 0 + environment.actions.settingsChanged = { changes += 1 } + environment.settings.statusFormat = .custom + environment.settings.customTemplate = "{label} {pct}" + #expect(inkFraction(SettingsTab(environment: environment), width: 520, height: 1200) > 0) + environment.launchAtLoginStatus = .enabled + environment.canCheckForUpdates = false + environment.isSandboxed = false + #expect(inkFraction(SettingsTab(environment: environment), width: 520, height: 1200) > 0) + let list = WindowSelectionList(environment: environment) + #expect(list.rows.count == 6) + let key = WindowKey(provider: .claude, windowID: "session") + let window = try #require(list.rows.first { $0.key == key }?.window) + #expect(list.label(key, window: window).wrappedValue == "CC 5h") + list.toggle(key, on: false) + #expect(!environment.settings.selectedWindows.contains(key)) + list.toggle(key, on: true) + list.toggle(key, on: true) + #expect(environment.settings.selectedWindows.contains(key)) + environment.settings.selectedWindows = [key] + list.toggle(key, on: false) + #expect(environment.settings.selectedWindows == [key]) + list.setLabel(key, "S") + #expect(environment.settings.shortLabels[key] == "S") + #expect(list.label(key, window: window).wrappedValue == "S") + list.setLabel(key, "") + #expect(environment.settings.shortLabels[key] == nil) + #expect(list.label(key, window: window).wrappedValue == "CC 5h") + environment.state.update(.claude) { $0.availability = .authenticationRequired } + #expect(SettingsTab(environment: environment).authenticationHint(.claude) == ProviderID.claude.loginHint) + environment.settings.setProvider(.claude, enabled: false) + #expect(SettingsTab(environment: environment).authenticationHint(.claude) == nil) + #expect(changes == 6) + #expect(inkFraction(WindowSelectionList(environment: environment), width: 400, height: 300) > 0) + let empty = try makeEnvironment(populate: false) + #expect(inkFraction(WindowSelectionList(environment: empty), width: 400, height: 100) > 0) + #expect(inkFraction(StatusPreview(model: statusModel()), width: 300, height: 40) > 0) + #expect(inkFraction(LogSection(environment: environment), width: 400, height: 300) > 0) +} + +@Test @MainActor func windowRowReadsAsOneSentence() { + let card = UsagePresenter.card( + provider: .claude, state: ProviderState(snapshot: sampleSnapshot(.claude), availability: .current), samples: [:], + now: fixedNow) + let session = WindowRowView(row: card.rows[0], now: fixedNow).accessibilityValue + #expect(session.hasPrefix("36% used, resets in 4 hr 0 min")) + #expect(!session.contains("inactive")) + let inactive = WindowRowView(row: card.rows[2], now: fixedNow).accessibilityValue + #expect(inactive.contains("inactive")) + #expect(inactive.contains("no reset scheduled")) +} + +@Test @MainActor func spendViewSaysWhenTheLimitIsReached() { + let reached = SpendControl( + enabled: true, used: Money(amountMinor: 1000, currency: "USD"), limit: Money(amountMinor: 1000, currency: "USD"), + percent: 100, limitReached: true) + let view = SpendView(spend: reached, provider: .codex, now: fixedNow) + #expect(view.title == "Spend control") + #expect(SpendView(spend: reached, provider: .claude, now: fixedNow).title == "Usage credits") + #expect(inkFraction(view, width: 400, height: 200) > 0) +} + +@Test @MainActor func chartMarksNameTheirSeriesAndTime() { + let utc = UsageChart.markLabel("Claude Session", at: fixedNow, timeZone: TimeZone(identifier: "UTC")!) + #expect(utc.hasPrefix("Claude Session, ")) + #expect(utc != UsageChart.markLabel("Claude Session", at: fixedNow, timeZone: TimeZone(identifier: "Asia/Tokyo")!)) +} + +@Test @MainActor func chartStylesRemainDistinctAfterThePaletteWraps() { + let lineIdentities = Set( + (0..<65).map { index in + let slot = HistoryStyleSlot(index: index) + let stroke = UsageChart.stroke(variant: slot.variant) + return "\(slot.hueIndex):\(stroke.lineWidth):\(stroke.dash):\(stroke.dashPhase)" + }) + let barIdentities = Set( + (0..<65).map { index in + let slot = HistoryStyleSlot(index: index) + return "\(slot.hueIndex):\(UsageChart.barPattern(variant: slot.variant))" + }) + #expect(lineIdentities.count == 65) + #expect(barIdentities.count == 65) +} + +@Test @MainActor func chartPointSymbolsUseABoundedRepresentativeSet() { + let points = (0..<100).map { + SeriesPoint(date: fixedNow.addingTimeInterval(Double($0) * 60), value: Double($0)) + } + let symbols = UsageChart.symbolPoints(points) + + #expect(symbols.count == 16) + #expect(symbols.first == points.first) + #expect(symbols.last == points.last) +} + +@Test @MainActor func chartSelectionBuildsAtMostOneOverlayPointPerVisibleSeries() throws { + let environment = try makeEnvironment(populate: false) + let points = (0..(_ view: NSView) -> Wanted? { + for subview in view.subviews { + if let match = subview as? Wanted { return match } + if let found: Wanted = findView(subview) { return found } + } + return nil +} + +@Test @MainActor func componentsRender() { + #expect(inkFraction(Banner("see https://example.com/docs now", tone: .info), width: 300, height: 60) > 0) + #expect(inkFraction(Banner("plain"), width: 300, height: 60) > 0) + let attributed = LinkifiedText.attributed("a https://x.y/z) b") + #expect(attributed.runs.contains { $0.link != nil }) + #expect(attributed.runs.compactMap(\.link?.absoluteString) == ["https://x.y/z"]) + #expect(LinkifiedText.attributed("no link, http not a scheme").runs.allSatisfy { $0.link == nil }) + let both = LinkifiedText.attributed("http://a.b and https://c.d/e") + #expect(both.runs.compactMap(\.link?.absoluteString) == ["http://a.b", "https://c.d/e"]) + var copied: [String] = [] + let chip = ChipView(chip: Chip(text: "Max"), onCopy: { copied.append($0) }) + #expect(inkFraction(chip, width: 200, height: 40) > 0) + #expect( + inkFraction( + ChipView(chip: Chip(text: "plain"), onCopy: { copied.append($0) }), width: 200, height: 40) > 0) + #expect(inkFraction(UsageBar(percent: 150, color: .red, label: "Session"), width: 200, height: 10) > 0) + #expect(inkFraction(MetricCell(title: "t", value: "v", help: "h"), width: 200, height: 40) > 0) + #expect( + inkFraction( + WrappingHStack { ForEach(0..<12, id: \.self) { Text("chip \($0)").padding(4) } }, width: 200, height: 200) > 0) + #expect(WrappingHStack().horizontalSpacing == 6) + var size = CGSize.zero + SizeKey.reduce(value: &size) { CGSize(width: 1, height: 2) } + SizeKey.reduce(value: &size) { .zero } + #expect(size == CGSize(width: 1, height: 2)) + #expect(inkFraction(Color.red.frame(width: 10, height: 10).measureSize { _ in }, width: 20, height: 20) > 0) + #expect(inkFraction(ScrollingTab(tab: .usage) { Text("x") }, width: 200, height: 200) > 0) + #expect(Color(HSBColor(hue: 0.3, saturation: 0.5, brightness: 0.5)) != Color.clear) + #expect( + inkFraction( + Text("help").richHelp(TooltipContent(title: "Help", body: "Explains this control.")), + width: 100, + height: 40 + ) > 0 + ) + ScrollerStyler.apply(from: NSView(frame: .zero)) + let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + let inner = NSView(frame: .zero) + scroll.documentView = inner + ScrollerStyler.apply(from: inner) + #expect(scroll.scrollerStyle == .overlay) + #expect(scroll.hasVerticalScroller) + #expect(scroll.autohidesScrollers) + _ = host(ScrollerStyler(), width: 10, height: 10) +} diff --git a/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift b/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift new file mode 100644 index 0000000..476fcdf --- /dev/null +++ b/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift @@ -0,0 +1,107 @@ +import AppKit +import SwiftUI +import Testing +import TokenMenuBarCore +import TokenMenuBarWidgets +import WidgetKit + +@Test func timelineProviderReadsStoreOrPlaceholder() throws { + let store = temporaryStore() + let provider = UsageTimelineProvider(store: store, now: { fixedNow }) + // a live timeline must never show the sample percentages + #expect(provider.entry().snapshot == .unavailable) + #expect(!provider.entry().snapshot.hasData) + let snapshot = WidgetSnapshot( + rows: Array(WidgetSnapshot.placeholder.rows.prefix(1)), attention: true, updatedAt: fixedNow) + try store.write(snapshot) + #expect(provider.entry().snapshot.rows.count == 1) + #expect(provider.entry().date == fixedNow) + #expect(provider.placeholderEntry().snapshot == .placeholder) + #expect(WidgetSnapshot.placeholder.hasData) + let timeline = provider.timeline() + #expect(timeline.entries.count == 1) + #expect(timeline.entries[0].snapshot.attention) + #expect( + UsageTimelineProvider.defaultStore( + containerURL: { _ in nil }, fallbackDirectory: store.url.deletingLastPathComponent() + ).url == store.url) + #expect(UsageTimelineProvider.defaultStore().url.lastPathComponent == WidgetSnapshot.fileName) +} + +@Test func timelineProviderDefaultClockUsesTheCurrentDate() { + let before = Date() + + let entry = UsageTimelineProvider(store: temporaryStore()).placeholderEntry() + + #expect(entry.date >= before) + #expect(entry.date <= Date()) +} + +private let fixedNow = Date(timeIntervalSince1970: 1_788_030_000) + +private func temporaryStore() -> WidgetSnapshotStore { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-widget-\(UUID().uuidString)") + return WidgetSnapshotStore(url: root.appendingPathComponent("widget.json")) +} + +@Test(arguments: [WidgetFamily.systemSmall, .systemMedium, .systemLarge]) +@MainActor func widgetViewsRenderEveryFamily(family: WidgetFamily) { + let view = UsageWidgetView(entry: populatedEntry, family: family) + #expect(view.rows.count == min(view.rowLimit, 3)) + #expect(inkFraction(view, width: 300, height: 300) > 0) +} + +@MainActor +private func inkFraction(_ view: Content, width: CGFloat, height: CGFloat) -> Double { + let hosting = host(view, width: width, height: height) + guard let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds) else { return 0 } + hosting.cacheDisplay(in: hosting.bounds, to: rep) + guard let image = rep.cgImage, image.width > 0, image.height > 0 else { return 0 } + var pixels = [UInt8](repeating: 0, count: image.width * image.height * 4) + let context = CGContext( + data: &pixels, width: image.width, height: image.height, bitsPerComponent: 8, bytesPerRow: image.width * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return Double(stride(from: 3, to: pixels.count, by: 4).count { pixels[$0] > 8 }) / Double(image.width * image.height) +} + +private let populatedEntry = UsageEntry( + date: fixedNow, + snapshot: WidgetSnapshot(rows: WidgetSnapshot.placeholder.rows, attention: true, updatedAt: fixedNow)) + +@Test @MainActor func widgetViewsHostEntryAndRows() { + let entry = populatedEntry + #expect(UsageWidgetView(entry: entry, family: .systemLarge).rowLimit == 8) + let empty = UsageWidgetView( + entry: UsageEntry(date: fixedNow, snapshot: WidgetSnapshot(rows: [], attention: false, updatedAt: fixedNow)), + family: .systemSmall) + #expect(inkFraction(empty, width: 160, height: 160) > 0) + #expect(inkFraction(UsageWidgetEntryView(entry: entry), width: 300, height: 300) > 0) + let row = WidgetRowView(row: WidgetSnapshot.placeholder.rows[0], now: fixedNow, compact: false) + #expect(inkFraction(row, width: 300, height: 60) > 0) + #expect(row.color != Color.clear) + #expect(UsageWidget.kind.hasPrefix("dev.tox")) + _ = UsageWidget().body +} + +@Test @MainActor func widgetViewRendersTheUnavailableInstruction() { + let entry = UsageEntry(date: fixedNow, snapshot: .unavailable) + + #expect(inkFraction(UsageWidgetView(entry: entry, family: .systemSmall), width: 160, height: 160) > 0) +} + +@MainActor +private var hostingWindows: [NSWindow] = [] + +@MainActor +private func host(_ view: Content, width: CGFloat, height: CGFloat) -> NSHostingView { + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(x: 0, y: 0, width: width, height: height) + let window = NSWindow(contentRect: hosting.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = hosting + hosting.layoutSubtreeIfNeeded() + hosting.displayIfNeeded() + hostingWindows.append(window) + return hosting +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..68f2bab --- /dev/null +++ b/justfile @@ -0,0 +1,164 @@ +# Every workflow in this repository. Run `just` to see them. + +set shell := ["bash", "-euo", "pipefail", "-c"] + +# List the recipes +default: + @just --list --unsorted + +# Build the package +build: toolchain + swift build + +# Check the toolchain can build this app, and say what to fix when it cannot +toolchain: + @Scripts/check-toolchain.sh + +# Run the test suite +test *filter: + Scripts/check-test-isolation.sh + swift test {{ if filter == "" { "" } else { "--filter " + filter } }} + +# Run the tests and fail if a line in Core or UI never executed +coverage: + Scripts/coverage.sh + +# Stream the app's unified log +logs: + log stream --level info --style compact --predicate 'subsystem == "dev.tox.token-menu-bar"' + +# Format Swift, Markdown and YAML in place +fmt: + swift format -i -r Sources Tests + pre-commit run mdformat --all-files || true + pre-commit run yamlfmt --all-files || true + +# Run every pre-commit hook over the whole tree +lint: + pre-commit run --all-files + +# Build, test with the coverage gate, and lint +check: toolchain build coverage lint + +# Assemble an ad-hoc signed .app in dist/ for machines without Xcode +app *args: toolchain + Scripts/bundle-dev.sh {{ args }} + +# Assemble the .app and launch it with real provider data +run: + Scripts/bundle-dev.sh --run + +# Assemble the .app and launch it with isolated demo data +run-demo: + Scripts/bundle-dev.sh --run-demo + +# Build the app and install it into /Applications, replacing any copy already there +_install: + Scripts/bundle-dev.sh + osascript -e 'quit app "Token Menu Bar"' 2>/dev/null || true + rm -rf "/Applications/Token Menu Bar.app" + cp -R "dist/Token Menu Bar.app" /Applications/ + +# Install the app and launch it with real provider data +install: _install + open -a "Token Menu Bar" + +# Install the app and launch it with isolated demo data +install-demo: _install + open -a "Token Menu Bar" --args --verify-ui + +# Re-render the website screenshots from demo data +shots: + Scripts/screenshots.sh + +# Redraw App/Assets.xcassets from the icon the app draws in code +icons: + #!/usr/bin/env bash + set -euo pipefail + set="App/Assets.xcassets/AppIcon.appiconset" + rm -rf "$set" && mkdir -p "$set" + swift run TokenMenuBar --export-icon "$set" >/dev/null + python3 - "$set" <<'PYTHON' + import json, pathlib, sys + icons = pathlib.Path(sys.argv[1]) + images = [ + {"filename": f"icon_{size}x{size}{suffix}.png", "idiom": "mac", "scale": scale, "size": f"{size}x{size}"} + for size in (16, 32, 128, 256, 512) + for suffix, scale in (("", "1x"), ("@2x", "2x")) + ] + for file in icons.glob("*.png"): + if file.name not in {image["filename"] for image in images}: + file.unlink() + (icons / "Contents.json").write_text(json.dumps({"images": images, "info": {"author": "xcode", "version": 1}}, + indent=2) + "\n") + PYTHON + python3 Scripts/optimize-png.py "$set" + +# Build the website into website/public +site: + hugo --source website --minify + +# Serve the website with live reload +site-serve: + hugo server --source website + +# Build the website the way Read the Docs does, into the directory it serves +site-readthedocs: + : "${READTHEDOCS_CANONICAL_URL:?}" + : "${READTHEDOCS_OUTPUT:?}" + mkdir -p "$READTHEDOCS_OUTPUT/html" + hugo --source website --gc --minify --baseURL "$READTHEDOCS_CANONICAL_URL" \ + --destination "$READTHEDOCS_OUTPUT/html" + +# Generate the Xcode project from App/project.yml +xcode: + cd App && xcodegen generate + +# Print the version git says this working tree is +version: + @Scripts/version.sh + +# Write a version into App/project.yml, deriving it from git when no tag is given +stamp tag="": + Scripts/stamp-version.sh {{ tag }} + +# Start a release: Prepare Release tags the commit, which triggers the build, the cask and the App Store upload +release bump="patch": + gh workflow run "Prepare Release" --field bump={{ bump }} + @echo "Watch it with: gh run watch \$(gh run list --workflow 'Prepare Release' --limit 1 --json databaseId -q '.[0].databaseId')" + +# Archive and export the Developer ID build into dist/direct +build-direct: + Scripts/build-direct.sh + +# Archive and export the Homebrew build without Sparkle into dist/homebrew +build-homebrew: + Scripts/build-homebrew.sh + +# Archive the sandboxed build and upload it to App Store Connect +build-app-store: + Scripts/build-app-store.sh + +# Zip, package and checksum the Developer ID build +package tag: + Scripts/package-direct.sh {{ tag }} + +# Zip, package and checksum the Homebrew build +package-homebrew tag: + Scripts/package-homebrew.sh {{ tag }} + +# Notarize and staple everything in a directory +notarize directory="dist/direct": + Scripts/notarize.sh {{ directory }} + +# Import a base64 signing certificate into a temporary keychain (CI) +import-certificate: + Scripts/import-certificate.sh + +# Write the Sparkle appcast for a release +appcast tag: + Scripts/appcast.sh {{ tag }} + +# Point the Homebrew cask at a released DMG +cask tag dmg="dist/homebrew/TokenMenuBar-Homebrew.dmg": + Scripts/update-cask.sh {{ tag }} {{ dmg }} diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..6c3b1ec --- /dev/null +++ b/mise.lock @@ -0,0 +1,127 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +[[tools.hugo]] +version = "0.165.0" +backend = "aqua:gohugoio/hugo" + +[tools.hugo."platforms.linux-arm64"] +checksum = "sha256:65c9fdd75e82d5f1eaf565f6e9fede6c0ceecaa267798e10c73068986996b77d" +url = "https://github.com/gohugoio/hugo/releases/download/v0.165.0/hugo_0.165.0_linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/gohugoio/hugo/releases/assets/511697401" + +[tools.hugo."platforms.linux-arm64-musl"] +checksum = "sha256:65c9fdd75e82d5f1eaf565f6e9fede6c0ceecaa267798e10c73068986996b77d" +url = "https://github.com/gohugoio/hugo/releases/download/v0.165.0/hugo_0.165.0_linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/gohugoio/hugo/releases/assets/511697401" + +[tools.hugo."platforms.linux-x64"] +checksum = "sha256:5c3a37a5450b3e386e5b75a87a790fea2d04a796d75e171216c80ef48a32b432" +url = "https://github.com/gohugoio/hugo/releases/download/v0.165.0/hugo_0.165.0_linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/gohugoio/hugo/releases/assets/511697404" + +[tools.hugo."platforms.linux-x64-musl"] +checksum = "sha256:5c3a37a5450b3e386e5b75a87a790fea2d04a796d75e171216c80ef48a32b432" +url = "https://github.com/gohugoio/hugo/releases/download/v0.165.0/hugo_0.165.0_linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/gohugoio/hugo/releases/assets/511697404" + +[tools.hugo."platforms.macos-arm64"] +checksum = "sha256:10ea75335975a13d0e73ac298402179335c55fa4e99d1687452d9cfa70b30d16" +url = "https://github.com/gohugoio/hugo/releases/download/v0.165.0/hugo_0.165.0_darwin-universal.pkg" +url_api = "https://api.github.com/repos/gohugoio/hugo/releases/assets/511697483" + +[tools.hugo."platforms.macos-x64"] +checksum = "sha256:10ea75335975a13d0e73ac298402179335c55fa4e99d1687452d9cfa70b30d16" +url = "https://github.com/gohugoio/hugo/releases/download/v0.165.0/hugo_0.165.0_darwin-universal.pkg" +url_api = "https://api.github.com/repos/gohugoio/hugo/releases/assets/511697483" + +[tools.hugo."platforms.windows-x64"] +checksum = "sha256:bdd9cc7837a42389067b2d0df7858d5878ceddef21307c2dd27fa10885fbbcf9" +url = "https://github.com/gohugoio/hugo/releases/download/v0.165.0/hugo_0.165.0_windows-amd64.zip" +url_api = "https://api.github.com/repos/gohugoio/hugo/releases/assets/511697493" + +[[tools.just]] +version = "1.58.0" +backend = "aqua:casey/just" + +[tools.just."platforms.linux-arm64"] +checksum = "sha256:748237128c4c40cbdabc65e841d05ceba13cc23a91eaba395495894c1d9764df" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500510099" + +[tools.just."platforms.linux-arm64-musl"] +checksum = "sha256:748237128c4c40cbdabc65e841d05ceba13cc23a91eaba395495894c1d9764df" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500510099" + +[tools.just."platforms.linux-x64"] +checksum = "sha256:4a5cc2f53e6f0f8c59092a6cc38291eb729d46a7dd95d3ae582008881b84931d" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500509978" + +[tools.just."platforms.linux-x64-musl"] +checksum = "sha256:4a5cc2f53e6f0f8c59092a6cc38291eb729d46a7dd95d3ae582008881b84931d" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500509978" + +[tools.just."platforms.macos-arm64"] +checksum = "sha256:50ae3e996c974a0bf32ea7d10f495070df33f1b43e0616b2769e3d4821ed8f48" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500509965" + +[tools.just."platforms.macos-x64"] +checksum = "sha256:9a09cfef66aaa79da58203970103a0684307716caaabd3e9844cacc4dc0f4023" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500510084" + +[tools.just."platforms.windows-x64"] +checksum = "sha256:759f16fb7aa17c5c8b9594b6d4a8c1a6630dfd042cf2b3ff84841454d3d188dc" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500511374" + +[[tools.pre-commit]] +version = "4.6.0" +backend = "aqua:pre-commit/pre-commit" + +[tools.pre-commit."platforms.linux-arm64"] +checksum = "sha256:ea8a0c84902e48c1875558f2f362ed8476773aa5fc8c16c5d8f2acc2a2830a65" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.0/pre-commit-4.6.0.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/401888770" + +[tools.pre-commit."platforms.linux-arm64-musl"] +checksum = "sha256:ea8a0c84902e48c1875558f2f362ed8476773aa5fc8c16c5d8f2acc2a2830a65" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.0/pre-commit-4.6.0.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/401888770" + +[tools.pre-commit."platforms.linux-x64"] +checksum = "sha256:ea8a0c84902e48c1875558f2f362ed8476773aa5fc8c16c5d8f2acc2a2830a65" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.0/pre-commit-4.6.0.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/401888770" + +[tools.pre-commit."platforms.linux-x64-musl"] +checksum = "sha256:ea8a0c84902e48c1875558f2f362ed8476773aa5fc8c16c5d8f2acc2a2830a65" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.0/pre-commit-4.6.0.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/401888770" + +[tools.pre-commit."platforms.macos-arm64"] +checksum = "sha256:ea8a0c84902e48c1875558f2f362ed8476773aa5fc8c16c5d8f2acc2a2830a65" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.0/pre-commit-4.6.0.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/401888770" + +[tools.pre-commit."platforms.macos-x64"] +checksum = "sha256:ea8a0c84902e48c1875558f2f362ed8476773aa5fc8c16c5d8f2acc2a2830a65" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.0/pre-commit-4.6.0.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/401888770" + +[[tools.xcodegen]] +version = "2.46.0" +backend = "aqua:yonaskolb/XcodeGen" + +[tools.xcodegen."platforms.macos-arm64"] +checksum = "sha256:4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806" +url = "https://github.com/yonaskolb/XcodeGen/releases/download/2.46.0/xcodegen.zip" +url_api = "https://api.github.com/repos/yonaskolb/XcodeGen/releases/assets/478866069" + +[tools.xcodegen."platforms.macos-x64"] +checksum = "sha256:4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806" +url = "https://github.com/yonaskolb/XcodeGen/releases/download/2.46.0/xcodegen.zip" +url_api = "https://api.github.com/repos/yonaskolb/XcodeGen/releases/assets/478866069" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..04824e7 --- /dev/null +++ b/mise.toml @@ -0,0 +1,10 @@ +# Developer tools, pinned here and checksummed in mise.lock. `mise install` provides them; `just` drives the +# workflows. The Swift toolchain comes from Xcode or swiftly, since the release builds need Xcode anyway. +[tools] +hugo = "0.165.0" +just = "1.58.0" +pre-commit = "4.6.0" +xcodegen = "2.46.0" + +[settings] +lockfile = true diff --git a/mockups/approved.html b/mockups/approved.html new file mode 100644 index 0000000..076f3e7 --- /dev/null +++ b/mockups/approved.html @@ -0,0 +1,927 @@ +Token Menu Bar Panel + + +
+
+

Token Menu Bar — panel mockup

+

Mock data throughout. Styling adapted from your reference: the accent marks state only (selected tab, checked + boxes, selected segments), every button carries a real bezel, sections are flat. All six Settings sections are + present. Toggle each proposed improvement below to see it on or off.

+
+ +
+
Theme +
+ + + +
+
+
Density +
+ + + +
+
+
Build +
+ + +
+
+
Proposed improvements +
+ + + + + + + + + + + +
+
+
+ +
+ +
+
+
+
+ + + +
+ +
+ +
+
+
CCClaude + Max 20×68% of weekly + +
+
+
Current session 5h
+
51%
resets 3h 12m
exp 34% · 1.50× · 100% 16:12
+
Weekly 7d
+
68%
resets Mon 09:00
exp 55% · 1.24× · 100% Sun 03:40
+
Weekly · Opus claude-opus-5
+
30%
resets Mon 09:00
exp 55% · 0.55× · projected 55%
+
The marker shows expected usage now; pace and projection remain visible without hovering.
+
+
+ +
+
CXCodex + Pro86% of weekly + +
+
+
GPT-5.3 Spark gpt-5.3-spark
+
86%
resets 3h 12m
exp 60% · 1.43× · 100% in 2h 10m
+
GPT-5.5 gpt-5.5
+
24%
resets 3h 12m
exp 60% · 0.40× · projected 40%
+
Weekly 7d
+
62%
resets Fri 00:00
exp 55% · 1.13× · 100% Thu 15:20
+
Credits $18.40 remaining · code review 42 runs this week
+
+
+ +
+
GMGemini + Code Assist + +
+
+
2.5 Pro gemini-2.5-pro
+
12%
resets 00:00 UTC
exp 48% · 0.25× · projected 25%
+
2.5 Flash gemini-2.5-flash
+
4%
resets 00:00 UTC
exp 48% · 0.08× · projected 8%
+
+
+ +
+
CUCursor + Not signed in +
+
+
Sign-in lives in Settings › Providers. Usage shows data only.
+
+
+ +
+
GHGitHub Copilot + Business + +
+
+
Chat monthly
+
41%
resets Sep 1
exp 50% · 0.82× · projected 82%
+
Completions monthly
+
9%
resets Sep 1
exp 50% · 0.18× · projected 18%
+
+
+
+ + + + + + +
+ +
+ + + + + +
+
+
+
+ +
+

What changed against your reference, and why

+
    +
  1. No “Usage Site” in the footer — you asked for it gone. Removal spans ProviderID.usagePage, + the card button, the plan-chip link and the context menu.
  2. +
  3. “Check for Updates” and auto-update are absent, not disabled, outside Direct builds. Flip the Build + switch above to see the Direct variant.
  4. +
  5. Sign-in moved out of Usage into Providers — Cursor's card in Usage now points there instead of + carrying a CLI hint.
  6. +
  7. “Windows” is now “Models”, with the model id in monospace beneath the name. Genuine rate-limit windows + label themselves window · 7d rather than pretending to be models.
  8. +
  9. Per-provider refresh intervals kept — they already exist, with per-provider floors.
  10. +
  11. All six sections present: About, Menu bar, Providers, Data, Notifications, Log. Earlier mockups + dropped three of them.
  12. +
  13. The reference shows only a filename for the history path; this shows the full path, which is more + useful when you need to find it.
  14. +
+
+
+ + diff --git a/mockups/implementation-brief.md b/mockups/implementation-brief.md new file mode 100644 index 0000000..10adbe0 --- /dev/null +++ b/mockups/implementation-brief.md @@ -0,0 +1,373 @@ +# Token Menu Bar rebuild brief + +Research checked on 31 August 2026. The approved visual and interaction reference is [`approved.html`](approved.html). +Its layout is authoritative unless it conflicts with a later explicit decision in this brief. + +## Outcome + +The app will use one `NSPopover` on macOS 14, 15, 26, and 27. Width stays at 880 points across tab switches when the +screen can hold it and clamps on narrower screens. Each tab keeps its measured ideal height. Content uses that height +when it fits and otherwise consumes the drawable room below the status item. Overflow uses overlay scrollbars that the +system hides automatically. Opening the panel freezes status-item re-tiering. Closing it permits one deferred fit pass. + +The rebuild keeps the Core/UI boundary: + +- Core owns geometry policy, presentation models, filtering, ordering, chart specifications, tooltip timing and + placement, diagnostics, launch policy, provider setup state, refresh policy, and persistence. +- UI owns SwiftUI views, AppKit windows and panels, controls, tracking areas, pasteboard, open and save panels, and + status-item screen conversion. + +Standard controls are the modern path. They provide native pressed, hover, disabled, inactive-window, keyboard, and +focus states on every supported release and adopt the current OS appearance automatically. Custom Liquid Glass is not +used for Settings content. It would add rendering cost and cannot compile with the older SDK used by the macOS 14 job. + +The tab picker sits in one compact integrated header rather than a separate padded strip. Tab labels are +self-explanatory and do not show tooltips. + +Meaningful strings do not truncate with ellipses at either the 880-point width or the minimum supported width. Dense +rows reflow or wrap. Paths, identifiers, and log lines use selectable scrolling text when wrapping would destroy their +meaning. + +Small meaningful secondary text maintains at least 4.5:1 contrast against its surface in light and dark appearances. +Model IDs, recency, paths, authentication sources, timestamps, chart legends, pace, and reset text must not resemble a +disabled control. Only unavailable controls use the subdued disabled appearance. + +## Sources and adopted patterns + +The open-source references were read at fixed commits: + +- [OpenUsage](https://github.com/robinebers/openusage/tree/05c40a1dc50a16ecdc7b55d2e4fadf26827b4f61), `05c40a1d`: + constant width, per-screen height, a drawable cap, one animation clock, lazy screens, and a shared tooltip panel. +- [Stats](https://github.com/exelban/stats/tree/d4c10b8ac6df1aa010a600148a6fc74dd32272dd), `d4c10b8a`: a scrollbar + budget, dense cards, hidden-popup work suspension, and history gaps. +- [Maccy](https://github.com/p0deje/Maccy/tree/39e0ba5e8161dc75ac082ea51bcedc74d6a23564), `39e0ba5e`: + top-edge-preserving panel resize, macOS 14 `onGeometryChange`, and IME-safe keyboard routing. +- [Itsycal](https://github.com/sfsam/Itsycal/tree/8d7676d2269a37c926ecf79a5095944559beeea3), `8d7676d2`: screen + re-resolution, custom tooltip content, and an on-screen arrow escape hatch. +- [Ice](https://github.com/jordanbaird/Ice/tree/11edd39115f3f43a83ae114b5348df6a0e1741cf), `11edd391`: custom flat forms + and an AppKit drag island inside a SwiftUI Settings shell. +- [SwiftBar](https://github.com/swiftbar/SwiftBar/tree/b9fa1ed3fc75868913bf604c1620500e7468460a), `b9fa1ed3`: stable + object identity and collection diffs while a menu is open. +- [xbar](https://github.com/matryer/xbar/tree/d624239058997c80118eaebe2e7f8331b3c765e0), `d6242390`: deferred + status-item reconstruction while its menu is visible. +- [FluidMenuBarExtra](https://github.com/wadetregaskis/FluidMenuBarExtra/tree/568f9defa5ce12bcfca6318284c38004dfb16450), + `568f9def`: complete frame recomputation from the status-item screen and top anchor. +- [Shopify Tophat](https://github.com/Shopify/tophat/tree/0f597d6b9116e3064e269c9c2f8abbddb90afbdb), `0f597d6b`: + top-edge preservation during height changes. +- [CodexBar](https://github.com/steipete/CodexBar/tree/8a732e743564abdb68ab3bee9332153ef88597a4), `8a732e74`: provider + search, counts, ordering, status, and scalable navigation. + +[iStat Menus 7.3](https://bjango.com/mac/istatmenus/) is proprietary. Its current trial and official help establish the +interaction and density reference: fixed-width arrowless menu windows, live status-item and dropdown previews, +hover-revealed enable and drag controls, anchored component editors, neutral bounded controls, compact card groups, and +color reserved for state and data. The relevant official demonstrations are the +[welcome editor](https://bjango.com/help/istatmenus7/welcome/), +[dropdown editor](https://bjango.com/help/istatmenus7/menus/), and +[history behavior](https://bjango.com/help/istatmenus7/historygraphs/). + +Apple's current guidance supports the component choices: + +- [`NSPopover`](https://developer.apple.com/documentation/appkit/nspopover) owns the native arrow, but Apple does not + promise which edge remains fixed when `contentSize` changes. +- The one-value + [`onGeometryChange`]() + overload is back-deployed to macOS 13. The old/new overload begins on macOS 15. +- [`NSHostingController.sizingOptions`](https://developer.apple.com/documentation/swiftui/nshostingcontroller/sizingoptions) + can center content whose ideal and assigned frames differ, so the root's top alignment is required. +- [AppKit's current design guidance](https://developer.apple.com/videos/play/wwdc2025/310/) recommends native controls, + semantic materials, Auto Layout, and compact control metrics for dense inspectors. +- [SwiftUI performance guidance](https://developer.apple.com/documentation/xcode/understanding-and-improving-swiftui-performance) + recommends small geometry observations, stored presentation work, narrow invalidation, and lazy construction. + +## Geometry and lifecycle + +### Current `NSPopover` phase + +The first repair stays on supported popover APIs: + +1. Each tab emits a typed `(tab, size)` event from inside that tab's view. No asynchronous callback reads the mutable + selected tab. +2. The controller stores height by tab and only applies a measurement when it belongs to the active tab. +3. Width resolves once per open session: 880 points on a normal display, or the available screen width minus margins. It + never uses `fittingSize` or a transient measured width. +4. The maximum body height is the room below the status item after fixed chrome and screen margins. Oversized content + uses that complete viewport and scrolls; shorter content keeps its ideal height. +5. The status-item button's screen and current `visibleFrame` are read on every open and after screen-parameter changes. +6. The hosting root fills its assigned frame with `.top` alignment. +7. `NSPopover.animates` drives the size change. A simultaneous independent SwiftUI transition is not used. +8. The visibility guard is set before `NSApplication.activate()`. Fit tasks are cancelled while open and one restart + runs after close. + +The UI must not call `setFrameTopLeftPoint` on the popover's private backing window. Every surveyed implementation with +explicit top-edge control owns an `NSPanel` or `NSWindow`; none resizes an `NSPopover` this way. + +### Panel gate + +On-screen verification decides the next step. If the corrected popover still moves its top edge or clips its arrow, the +host changes to an owned panel. The Core geometry already exposes the required invariant: + +```text +frame.origin.y = anchorTopY - frame.height +frame.maxY = anchorTopY +``` + +That migration needs an integrated body-and-arrow AppKit path or an intentionally arrowless panel. It does not use a +SwiftUI-only triangle over a visual-effect sibling. + +## Usage + +Usage becomes a compact quota surface: + +- Provider cards retain every quota window, plan, identity, credits, pace, spend, source, refresh state, warning, and + accessibility value. +- Provider setup and sign-in move to Settings > Providers. Undiscovered providers do not appear as missing Usage cards. +- Usage-site URLs are removed from plan chips, provider cards, the status menu, and command handling. +- Official provider marks appear in the card header where distribution permission exists. The common slot falls back to + the official provider name when it does not. +- Cards use immutable Core presentation snapshots. A one-second clock does not rebuild 60-day analytics summaries. +- Only leaf reset-age text observes a visible-only deadline clock. +- Every quota row shows an expected-use marker on its meter and compact expected, ratio, and projection text. A status + title alone is insufficient. The tooltip explains the calculation but does not carry the only visible pace value. +- The curated model selection decision applies consistently to the status renderer and Settings preview. Usage keeps all + provider data reachable and can visually de-emphasize unselected models; it does not delete them. + +## Settings + +One outer `ScrollView` contains six flat sections in this order: About, Menu bar, Providers, Data, Notifications, and +Log. Section labels are small, gray, and left aligned. A custom section shell and explicit label/value grid replace +`Form`, `GroupBox`, and the current multi-row content inside a single labeled cell. + +All existing controls remain: + +- About: Launch at login, Open Login Items, Reset All Settings, Copy Diagnostics, Report Issue, and Source. +- About in direct builds: Check for updates automatically and Check Now. +- Menu bar: Order, Format, decimals, Hide 0%, Fit to space, the custom template, model selection, and labels. +- Providers: per-provider enablement and intervals, resource grants, token-refresh consent, setup, and recovery. +- Data: retention, analytics refresh, the full History path, Open, Export, and Clear with confirmation. +- Notifications: Notify at, threshold toggles, Window resets, and Sign-in needed. +- Log: search, level filter, Copy, Clear, Show Full Log, detailed logging, and a fixed-height log view. + +Update controls do not exist in App Store or Homebrew builds. `Report Issue` appears once. Reset All Settings and Clear +use native destructive confirmation. Settings has one global reset action and no scoped reset controls. + +### Menu bar editor + +The approved preview is a real editor surface backed by the same Core status-item model as the renderer: + +- Order and Format use segmented pickers. The template field and token reference appear only under Custom. +- The preview renders the actual status string. +- Preview cells and model rows share local hover and focus identity. Selecting a preview cell scrolls to and focuses its + model row. +- The list groups by provider. Headers expose provider selection, count, and provider ordering. +- The filter matches provider, display name, model ID, and effective short label. Command-F focuses it. +- Each row has a checkbox, display name, monospaced ID, usage percentage and recency, faint usage gauge, prefilled short + label, character budget, override state, and a conditional revert action. +- Clearing an override restores the derived label. An empty stored override is never shown as an empty default. +- Effective short labels are unique across selected models. Core owns the invariant, and the UI reports a conflict + inline before it persists an ambiguous override. +- Hide unused changes presentation only; selection, label, usage, and order stay reachable. +- Stable order is provider-major in the grouped surface: provider headers reorder providers and rows reorder within a + provider. The preview exposes the resulting global status order. Drag actions have Move Earlier and Move Later + keyboard and accessibility actions. + +The model list mounts its regular stack in stages inside the tab's single scroller. A lazy stack cannot be the source of +an intrinsic popover height: its reported height changes with the viewport and creates a resize-layout feedback loop. An +AppKit drag island is used only if native SwiftUI drop feedback proves unstable on screen. + +### Providers + +Core exposes typed setup metadata and separate resource, credential, and service health. The UI never parses provider +error strings to choose an action. + +The provider header contains enablement, mark or text fallback, official name, last-known account and plan, compact +status, and refresh interval. Recovery details remain lazy and follow this order: + +1. Disabled: preserve account, credential source, and failure detail while polling stops. +2. Access needed: show each resource as Needed, Granted, Stale, or Error with Grant or Grant Again. +3. Credential store unreadable or unsupported: name the store and show the matching setup path. +4. Missing, expired, or revoked: show a copyable official CLI recovery command and Check Again. +5. Policy or license denial: show the account or administrator action. +6. Offline or rate limited: keep stale data and show retry timing. +7. Connected: show account, plan, credential source, and last successful refresh. + +Providers discovered from installed CLIs, credential stores, or existing snapshots appear by default. Show All reveals +the remaining supported providers for manual setup. Recovery and empty states keep the provider mark or fallback badge +beside the provider name. + +Current credential sources remain supported. New source detection does not erase legacy Claude, Codex, Gemini, Cursor, +or Copilot paths. Security-scoped resource leases balance `startAccessingSecurityScopedResource()` with stop calls, +replace stale bookmarks, and release when providers rebuild or the app exits. + +Token refresh consent stays off by default and names the affected providers. Shared credential writes re-read and +compare the source after the network exchange so a rotated token cannot overwrite a concurrent CLI login. + +### Provider marks + +The asset loader caches one decoded image per provider and appearance. Marks keep their original colors and are never +template-tinted. Each vendored asset records its source, retrieval date, approval state, and required attribution. + +OpenAI and Cursor publish usable source assets. Anthropic, Gemini, and GitHub require permission, partner access, or a +current approved product lockup for this distribution. Those providers use a compact provider-colored name badge in the +common mark slot until approval is recorded. The badge is a text fallback, not a fabricated icon. Simple Icons are not +shipped. + +## History + +History uses one metric picker, one summary, a materially taller chart of about 360 points, one complete legend, +selected-period CSV export, and the existing footer. The legend contributes its ideal height to the tab's one outer +scroll view; it has no fixed-height nested scroller. All presenter state remains reachable: available models, earliest +sample, selection and hover, custom dates, paging, follow-now, and reset timestamps. + +There are 17 metric choices: + +- Windows: Usage %. +- Claude and Codex: input, cached-input, and output tokens. +- Claude: cache-write tokens, cost, messages, sessions, and tool calls. +- Codex: surface usage, model credits, turns, threads, credits, skill calls, plugin calls, and code reviews. + +The capability table belongs to Core. The picker names contributors and the selected metric displays provider, shape, +and time-basis attribution. + +The approved prototype contains two stale controls: + +- Mark Type is removed. Window Usage % and surface usage are lines. Additive daily metrics are bars. +- Top-series and Show All controls are removed. Every series with a stored row in the selected period starts visible. + Zero-valued rows count as data. A user-hidden series stays in the legend and can be restored. + +Window usage uses a step line. Daily percentages use linear interpolation. Additive metrics use grouped or stacked bars. +Stacked is enabled only when the metric supports it and more than one series is visible. Percentage axes use 0...100; +quantity axes start at zero. + +Series identity is stable across range, metric, and visibility changes. Core assigns a style slot to each provider- +qualified series ID. UI maps the slot to hue, stroke dash, point symbol, and bar outline or pattern so 13 series never +repeat the same identity. Toggling a series does not renumber the others. + +Now is the leading range segment. Selecting it returns to live. Paging leaves Now while preserving the chosen span. +Fixed and custom ranges page with calendar arithmetic, not fixed seconds, so daylight-saving transitions do not drift. +Changing either date field enters Custom immediately. Analytics use UTC day axes. Rollup remains available for window +samples and shows Day as the effective read-only value for daily provider analytics. + +Reset boundaries survive downsampling and appear in chart selection and accessibility. Stale data creates a gap rather +than extending to the viewport edge. Chart hover uses one sorted timeline and binary search. The chart supplies an +accessibility descriptor and keyboard selection. + +History loads one selected metric and supplier set for the visible day range. Transforms run off the main actor. The +existing SQL rollup and extrema-preserving point limit remain. Metric and legend changes reuse cached rows; a range +change cancels and replaces one task. + +macOS 14 renders line collections with `LineMark`. macOS 15 and later may use vectorized `LinePlot` behind a floor +availability check. Bars use `BarMark` on every release. + +## Rich help + +Every control except the tab picker gets explanatory help without `.help()`. Hover presentation and dismissal each wait +150 ms: + +- One lazy, reusable, mouse-transparent, nonactivating AppKit panel exists per open popover session. +- One `NSVisualEffectView` with `.toolTip` material and one wrapping `NSTextField` render title, body, and monospaced + inline spans. +- Lightweight tracking views report only enter and exit. Pointer movement inside a target performs no work. +- One cancellable task, owner token, and generation reject stale presentation and dismissal events. +- Placement is screen-aware, inset by 8 points, seven points from the target, and flips above near the bottom edge. +- Keyboard focus uses the same presenter without adding focus stops. +- Escape, scrolling, removal, window close, and popover close cancel pending work and release the panel. +- Reduce Motion removes the 90 ms fade. Reduce Transparency uses an opaque semantic background. + +The tooltip panel is absent from the accessibility tree. Each source control carries the same explanation in its label, +value, and hint. + +## Logging + +`LogBuffer` remains a bounded Core-owned support log and gains a second Apple Unified Logging sink. A closed typed event +model covers panel geometry, tab measurement, status re-tiering, provider refresh, and safe request results. AppKit +objects are converted to Core geometry and identifiers at the UI boundary. + +Detailed events use an autoclosure and are not constructed while logging is off. Unified loggers use component +categories and typed interpolation at the call site so privacy remains intact. Response bodies, headers, tokens, +cookies, emails, URL queries, and raw Keychain failures never enter logs. + +The app-owned buffer keeps 500 records. Each line is capped at 2 KiB. File output uses a serial utility queue, rotates +at 1 MiB, retains at most three files younger than seven days, and schedules one delayed flush when pending output +changes from empty to nonempty. It has no repeating timer. + +Core adds warning to Debug, Info, Warning, and Error and fixes debug entries currently stored as info. The Settings log +uses a fixed-height `NSTextView` with search and level filters. It subscribes only while visible. Show Full Log follows +the tail until the user scrolls up. Core owns filtering and sanitized export; UI owns pasteboard and save panels. + +Signposts measure provider fetches, History reload and queries, panel open, and resize. They remain in Instruments and +do not inflate the in-app buffer. + +## Performance work + +The approved appearance does not require continuous work. The implementation follows these rules: + +- Views render stored immutable Core presentation snapshots. View bodies do not scan analytics or decode credentials. +- Only visible leaf text schedules its next meaningful age or reset update. +- Closed tabs do not create charts, tooltip windows, log subscriptions, or clocks. +- Closed panels stop presentation clocks and geometry work. +- Status rendering coalesces changes, skips identical output, and defers width fitting while the panel is open. +- Usage refresh, analytics refresh, and forced recovery are separate policies. Refresh Now does not request every Codex + analytics endpoint unless analytics is due or explicitly requested. +- Codex reset-credit lookup uses a time-to-live cache and an in-flight guard. +- History has one reload owner and one cancellable selected-metric task. +- Provider work is concurrent by provider, deduplicated in flight, and retains last-good data. +- SQLite writes batch in explicit rollback-safe transactions. +- Provider marks decode once per provider and appearance. + +Performance verification measures app launch, panel open-to-ready, tab-to-stable-frame, 13-series chart preparation, +repeated open and close, Settings scroll, idle CPU, and retained memory. A disabled detailed-log benchmark constructs no +events and writes no files. + +## OS and build matrix + +- Shell: native `NSPopover` on macOS 14 and 15. macOS 26 adopts the current material; macOS 27 follows the same floor. +- Controls: native bordered buttons, segmented controls, checkboxes, switches, and steppers on every release. macOS 26 + adopts current control geometry after compact-metric verification; macOS 27 follows the same floor. +- Charts: `LineMark` and `BarMark` on macOS 14. macOS 15 and later may use `LinePlot` where it earns its cost. +- Content: a semantic standard surface without glass cards on every release. +- Rich help: one shared AppKit tooltip panel on every release. + +`Package.swift` uses Swift tools 6.0 and Swift 6 language mode so the manifest remains parseable on the macOS 14 job. +The deployment target remains 14.0. + +The CI matrix covers: + +- macOS 14 runtime smoke for the release artifact and fallback source compilation while GitHub's runner remains + available; a self-hosted or third-party runner replaces it after retirement. +- macOS 15 full behavior and UI tests. +- macOS 26 strict coverage, both distribution flavors, and performance gates. +- Xcode 27 compile and test on its available host, plus self-hosted macOS 27 runtime verification. + +Any 26- or 27-only source needs both a compiler gate and a runtime floor. Shared interfaces expose Core enums and +protocols. No newer AppKit type appears in a file parsed by the Xcode 16.2 job. + +## Verification contract + +The offscreen exporter remains a design artifact renderer. It cannot prove an arrow, AppKit bezel, focus ring, tab event +order, window placement, screen selection, or real analytics. + +Verification uses: + +1. Core tests for typed measurements, constant width, caps, geometry, chart rules, style identity, launch policy, + tooltip timing, provider setup, refresh policy, and diagnostics. +2. AppKit integration tests with a real popover and window frame. +3. An Xcode UI-test target for status-item opening, Command-R, Command-F, Escape, Tab and Shift-Tab order, controls, + accessibility, and performance. +4. An on-screen release matrix for macOS 14, 15, 26, and 27 covering light and dark mode, contrast, reduced motion and + transparency, both screen edges, notch-adjacent placement, external displays, every Dock position, every tab pair, + arrow shape, bezels, hover, press, and focus. + +UI verification runs in a dedicated defaults suite and temporary support directory. It forces demo state independently +of persisted user settings. Development launch recipes use that isolated mode by default; real-data launch is explicit. + +## Delivery order + +1. Preserve the existing quick-win changes, correct their sizing seam, and add the research brief and approved artifact. +2. Land geometry, measurement, visibility, status re-tier, launch isolation, and CI foundations. +3. Land Core presentation, setup, refresh, diagnostics, tooltip, and chart models. +4. Land the approved Usage and Settings sections with native controls and complete inventory. +5. Land the unified History chart, legend, selection, export, and accessibility. +6. Land the shared tooltip panel, log UI, keyboard routes, and provider marks with approved licensing metadata. +7. Run the full unit, integration, UI, accessibility, coverage, performance, and OS verification matrix. + +Nothing except usage-site links is removed. Condensed information remains reachable and stays in accessibility values. diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..e763c1d --- /dev/null +++ b/renovate.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + "helpers:pinGitHubActionDigests", + ":preserveSemverRanges" + ], + "timezone": "Europe/London", + "minimumReleaseAge": "3 days", + "schedule": ["before 6am on Tuesday"], + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 6am on Tuesday"] + }, + "packageRules": [ + { + "description": "Keep action digest updates in one review", + "matchManagers": ["github-actions"], + "matchUpdateTypes": ["minor", "patch", "pin", "digest"], + "groupName": "GitHub Actions" + }, + { + "description": "Update the pinned developer tools together", + "matchManagers": ["mise"], + "groupName": "Developer tools" + }, + { + "description": "Update the Swift packages together", + "matchManagers": ["swift"], + "matchUpdateTypes": ["minor", "patch"], + "groupName": "Swift packages" + } + ], + "pre-commit": { + "enabled": true + } +} diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..2e92efc --- /dev/null +++ b/ruff.toml @@ -0,0 +1,12 @@ +# The repository, CI and pre-commit.ci have to agree on one style, so the settings live here rather than in whatever +# global configuration a machine happens to carry. +line-length = 120 + +[lint] +select = ["ALL"] +ignore = [ + "COM812", # the formatter owns trailing commas + "D203", # incompatible with D211, which this repository follows + "D213", # incompatible with D212, which this repository follows + "CPY001", # the licence lives in LICENSE, not atop every file +] diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..73fdcda --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,3 @@ +/public +/resources +/.hugo_build.lock diff --git a/website/assets/css/site.css b/website/assets/css/site.css new file mode 100644 index 0000000..f6e43bf --- /dev/null +++ b/website/assets/css/site.css @@ -0,0 +1,925 @@ +/* Palette mirrors Sources/TokenMenuBarCore/Brand.swift so the site and the app never drift. + The iris gradient stays cool: green, orange and red belong to the usage scale alone. */ + +:root { + color-scheme: light; + + --iris-1: #4c3be0; + --iris-2: #9a6bff; + --iris: #5a46e8; + --iris-soft: rgba(90, 70, 232, 0.1); + /* Text printed on a solid --iris fill. The dark theme's iris is light enough that white on it is 2.7:1. */ + --on-iris: #ffffff; + + --usage-green: #258540; + --usage-orange: #db7516; + --usage-red: #cc1f1f; + + --ink: #0f1117; + --body: #3c4250; + --muted: #667085; + --paper: #fafafc; + --surface: #ffffff; + --raised: #f2f2f7; + --line: #e3e3ec; + + --radius-sm: 8px; + --radius: 12px; + --radius-lg: 18px; + --shadow-sm: + 0 1px 2px rgba(15, 17, 23, 0.06), 0 1px 3px rgba(15, 17, 23, 0.05); + --shadow: 0 10px 30px rgba(15, 17, 23, 0.09); + --shadow-lg: 0 24px 60px rgba(15, 17, 23, 0.16); + + --page: 1240px; + --prose: 1140px; + --sans: + -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Inter, + system-ui, sans-serif; + --mono: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, "Cascadia Mono", monospace; +} + +body.theme-dark, +body.theme-auto { + --dark-ink: #f4f5f9; + --dark-body: #c2c7d6; + --dark-muted: #8d94a8; + --dark-paper: #0f1117; + --dark-surface: #171a22; + --dark-raised: #1d2130; + --dark-line: #272c3b; +} + +body.theme-dark { + color-scheme: dark; + --iris: #a78bfa; + --iris-soft: rgba(167, 139, 250, 0.14); + --on-iris: #0f1117; + --usage-green: #46b969; + --usage-orange: #f0993f; + --usage-red: #e8544e; + --ink: var(--dark-ink); + --body: var(--dark-body); + --muted: var(--dark-muted); + --paper: var(--dark-paper); + --surface: var(--dark-surface); + --raised: var(--dark-raised); + --line: var(--dark-line); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5); + --shadow: 0 10px 30px rgba(0, 0, 0, 0.55); + --shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.65); +} + +@media (prefers-color-scheme: dark) { + body.theme-auto { + color-scheme: dark; + --iris: #a78bfa; + --iris-soft: rgba(167, 139, 250, 0.14); + --on-iris: #0f1117; + --usage-green: #46b969; + --usage-orange: #f0993f; + --usage-red: #e8544e; + --ink: var(--dark-ink); + --body: var(--dark-body); + --muted: var(--dark-muted); + --paper: var(--dark-paper); + --surface: var(--dark-surface); + --raised: var(--dark-raised); + --line: var(--dark-line); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5); + --shadow: 0 10px 30px rgba(0, 0, 0, 0.55); + --shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.65); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body.preload * { + transition: none !important; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--paper); + color: var(--body); + font-family: var(--sans); + font-size: 17px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3, +h4 { + color: var(--ink); + line-height: 1.2; + letter-spacing: -0.022em; + margin: 2.2rem 0 0.8rem; + font-weight: 640; +} + +h1 { + font-size: 2.2rem; + margin-top: 0; +} +h2 { + font-size: 1.5rem; +} +h3 { + font-size: 1.16rem; +} + +p, +ul, +ol { + margin: 0 0 1rem; +} + +a { + color: var(--iris); + text-decoration-color: color-mix(in srgb, var(--iris) 35%, transparent); + text-underline-offset: 2px; +} + +a:hover { + text-decoration-color: currentColor; +} + +code { + font-family: var(--mono); + font-size: 0.86em; + background: var(--raised); + padding: 0.15em 0.38em; + border-radius: 5px; + /* break-word, not anywhere: a path too long for a phone breaks, but table columns keep their min-content width */ + overflow-wrap: break-word; +} + +pre { + background: var(--raised); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.9rem 1.05rem; + overflow-x: auto; + font-size: 0.86rem; + line-height: 1.55; +} + +pre code { + background: none; + padding: 0; + font-size: inherit; +} + +/* diagrams.js turns Mermaid's own fit-to-container off; this puts the wide-screen behaviour back in CSS, + which leaves the phone free to keep the diagram at full size and scroll it. */ +pre.mermaid svg { + max-width: 100%; + height: auto; +} + +img { + max-width: 100%; + height: auto; +} + +hr { + border: 0; + border-top: 1px solid var(--line); + margin: 2.5rem 0; +} + +table { + border-collapse: collapse; + width: 100%; + font-size: 0.94rem; +} + +th, +td { + border-bottom: 1px solid var(--line); + padding: 0.5rem 0.7rem; + text-align: left; +} + +.table-scroll { + overflow-x: auto; + overscroll-behavior-x: contain; +} + +.wrap { + max-width: var(--page); + margin: 0 auto; + padding: 0 1.4rem; +} + +.skip { + position: absolute; + left: -9999px; +} + +.skip:focus { + left: 1rem; + top: 1rem; + z-index: 100; + background: var(--surface); + padding: 0.6rem 1rem; + border-radius: var(--radius-sm); + box-shadow: var(--shadow); +} + +:focus-visible { + outline: 2px solid var(--iris); + outline-offset: 2px; +} + +.masthead { + position: sticky; + top: 0; + z-index: 20; + background: color-mix(in srgb, var(--paper) 88%, transparent); + backdrop-filter: saturate(180%) blur(14px); + border-bottom: 1px solid var(--line); +} + +.masthead .wrap { + display: flex; + align-items: center; + gap: 0.2rem; + min-height: 60px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-weight: 640; + color: var(--ink); + text-decoration: none; + letter-spacing: -0.02em; +} + +.brand img { + width: 28px; + height: 28px; + border-radius: 8px; +} + +.nav { + display: flex; + gap: 0.2rem; + margin-left: auto; + align-items: center; + flex-wrap: wrap; +} + +.nav a svg { + width: 15px; + height: 15px; + flex: none; +} + +.nav a { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--muted); + text-decoration: none; + padding: 0.35rem 0.65rem; + border-radius: var(--radius-sm); + font-size: 0.94rem; + font-weight: 520; +} + +.nav a:hover { + color: var(--ink); + background: var(--raised); +} + +.nav a[aria-current="page"] { + color: var(--iris); + background: var(--iris-soft); +} + +.theme-toggle { + border: 1px solid var(--line); + background: var(--surface); + color: var(--muted); + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.theme-toggle:hover { + color: var(--ink); + border-color: var(--muted); +} + +.theme-toggle svg { + width: 16px; + height: 16px; +} + +.button { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.62rem 1.15rem; + border-radius: 999px; + font-weight: 580; + font-size: 0.97rem; + text-decoration: none; + border: 1px solid transparent; + transition: + transform 120ms ease, + box-shadow 120ms ease; +} + +.button:hover { + transform: translateY(-1px); +} + +.button-primary { + /* Stopping at 180% keeps the tokens the app's Brand.swift sets: reaching --iris-2 itself puts white + on #9a6bff at 3.55:1, while the corner this ends on is 4.8:1. */ + background-image: linear-gradient(135deg, var(--iris-1), var(--iris-2) 180%); + color: #fff; + box-shadow: 0 6px 20px rgba(76, 59, 224, 0.35); +} + +.button-secondary { + border-color: var(--line); + background: var(--surface); + color: var(--ink); + box-shadow: var(--shadow-sm); +} + +.hero { + position: relative; + overflow: hidden; + padding: 4rem 0 3rem; +} + +.hero::before { + content: ""; + position: absolute; + inset: -40% 30% auto -10%; + height: 620px; + background: radial-gradient( + closest-side, + rgba(122, 90, 245, 0.22), + transparent + ); + pointer-events: none; +} + +.hero .wrap { + position: relative; + display: grid; + gap: 2rem; + justify-items: start; + max-width: 780px; +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + font-weight: 620; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--iris); + background: var(--iris-soft); + padding: 0.3rem 0.7rem; + border-radius: 999px; + margin-bottom: 1rem; +} + +.hero h1 { + font-size: clamp(2.1rem, 4.4vw, 3.1rem); + letter-spacing: -0.032em; + font-weight: 700; + margin-bottom: 0.9rem; +} + +.hero .lede { + font-size: 1.1rem; + color: var(--muted); + margin-bottom: 1.6rem; +} + +.hero-actions { + display: flex; + gap: 0.7rem; + flex-wrap: wrap; + align-items: center; +} + +.hero-note { + margin-top: 0.9rem; + font-size: 0.87rem; + color: var(--muted); +} + +.hero-media { + display: grid; + gap: 0.9rem; + width: 100%; +} + +.shot { + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + border: 1px solid var(--line); + overflow: hidden; + display: block; + max-width: 100%; + height: auto; +} + +.shot-bar { + max-width: 520px; + border-radius: var(--radius); +} + +.providers { + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: var(--surface); + padding: 1.1rem 0; +} + +.providers .wrap { + display: flex; + align-items: center; + gap: 1.4rem; + flex-wrap: wrap; + justify-content: center; + color: var(--muted); + font-size: 0.92rem; +} + +.providers strong { + color: var(--ink); + font-weight: 600; +} + +.section { + padding: 3.4rem 0; +} + +.section-head { + max-width: 640px; + margin-bottom: 1.8rem; +} + +.section-head p { + color: var(--muted); + margin: 0; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(255px, 1fr)); + gap: 1rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.2rem 1.25rem; + box-shadow: var(--shadow-sm); +} + +.card h2, +.card h3 { + margin: 0 0 0.4rem; + font-size: 1.02rem; +} + +.card p { + margin: 0; + font-size: 0.94rem; + color: var(--muted); +} + +.card-icon { + width: 30px; + height: 30px; + border-radius: 9px; + display: grid; + place-items: center; + margin-bottom: 0.7rem; + background: var(--iris-soft); + color: var(--iris); +} + +.card-icon svg { + width: 17px; + height: 17px; +} + +/* One popover at a time, switched by the radios above it, so three tall shots do not stack into a scroll marathon */ +.tabshots { + display: grid; + justify-items: center; + gap: 1rem; +} + +.tabshot-input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.tabshot-bar { + display: inline-flex; + gap: 0.25rem; + padding: 0.25rem; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface); +} + +.tabshot-bar label { + padding: 0.4rem 1.15rem; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 560; + color: var(--muted); + cursor: pointer; +} + +.tabshot-bar label:hover { + color: var(--ink); +} + +.tabshot-panel { + margin: 0; + display: none; +} + +/* One tab at a time, each shown whole: no crop, no inner scrollbar */ +.tabshot-frame img { + display: block; + margin-inline: auto; +} + +.tabshot-panel figcaption { + margin-top: 0.7rem; + font-size: 0.88rem; + color: var(--muted); + text-align: center; +} + +#tabshot-usage:checked ~ .tabshot-bar label[for="tabshot-usage"], +#tabshot-history:checked ~ .tabshot-bar label[for="tabshot-history"], +#tabshot-settings:checked ~ .tabshot-bar label[for="tabshot-settings"] { + background: var(--iris); + color: var(--on-iris); +} + +#tabshot-usage:focus-visible ~ .tabshot-bar label[for="tabshot-usage"], +#tabshot-history:focus-visible ~ .tabshot-bar label[for="tabshot-history"], +#tabshot-settings:focus-visible ~ .tabshot-bar label[for="tabshot-settings"] { + outline: 2px solid var(--iris); + outline-offset: 2px; +} + +#tabshot-usage:checked ~ .tabshot-panels #panel-usage, +#tabshot-history:checked ~ .tabshot-panels #panel-history, +#tabshot-settings:checked ~ .tabshot-panels #panel-settings { + display: block; +} + +/* The usage scale is semantic: green through red maps to the thresholds the site documents */ + +.legend { + display: flex; + gap: 0.4rem; + align-items: center; + flex-wrap: wrap; + font-size: 0.88rem; + color: var(--muted); +} + +.legend span { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.legend i { + width: 11px; + height: 11px; + border-radius: 3px; + display: inline-block; +} + +.legend .g { + background: var(--usage-green); +} +.legend .o { + background: var(--usage-orange); +} +.legend .r { + background: var(--usage-red); +} + +.page { + padding: 2.6rem 0 3.5rem; +} + +.page .wrap { + max-width: var(--prose); +} + +.page-wide .wrap { + max-width: var(--page); +} + +.page-title { + font-size: 2.1rem; + margin-bottom: 0.4rem; +} + +.page-lede { + color: var(--muted); + font-size: 1.04rem; + margin-bottom: 2rem; +} + +.prose img { + border-radius: var(--radius); + border: 1px solid var(--line); + box-shadow: var(--shadow-sm); +} + +.prose li { + margin-bottom: 0.3rem; +} + +.prose blockquote { + margin: 1.4rem 0; + padding: 0.2rem 1rem; + border-left: 3px solid var(--iris); + color: var(--muted); +} + +.prose h2, +.prose h3 { + scroll-margin-top: 5rem; +} + +.anchor { + color: var(--muted); + opacity: 0; + margin-left: 0.4rem; + text-decoration: none; + font-weight: 400; +} + +h2:hover .anchor, +h3:hover .anchor, +.anchor:focus, +.anchor:focus-visible { + opacity: 1; +} + +.foot { + border-top: 1px solid var(--line); + padding: 2rem 0 2.6rem; + color: var(--muted); + font-size: 0.9rem; +} + +.foot .wrap { + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; +} + +.foot a { + color: var(--muted); +} + +.foot a:hover { + color: var(--ink); +} + +.foot-links { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +@media (max-width: 900px) { + .hero { + padding-top: 2.6rem; + } +} + +/* Below this the header no longer fits on one line. The brand takes the first row and the links become a + side-scrolling strip, so the sticky header stays one row of links tall instead of wrapping to three. */ +@media (max-width: 720px) { + .masthead .wrap { + flex-wrap: wrap; + row-gap: 0.15rem; + min-height: 0; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .brand { + width: 100%; + } + + .nav { + flex: 1 1 auto; + min-width: 0; + /* The strip scrolls, so it also clips: 4px of padding leaves room for the focus ring, and the negative + margin puts the first link back under the brand. */ + margin-left: -4px; + padding: 4px; + flex-wrap: nowrap; + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; + } + + .nav::-webkit-scrollbar { + display: none; + } + + .nav a { + flex: 0 0 auto; + } + + .theme-toggle { + flex: none; + margin-left: 0.3rem; + } + + .prose h2, + .prose h3 { + scroll-margin-top: 6.5rem; + } +} + +@media (max-width: 620px) { + body { + font-size: 16px; + } + + .wrap { + padding: 0 1.15rem; + } + + .nav a { + padding: 0.3rem 0.45rem; + font-size: 0.88rem; + } + + .section { + padding: 2.4rem 0; + } + + .page { + padding: 1.9rem 0 2.8rem; + } + + .providers .wrap { + gap: 0.5rem 1rem; + } + + .tabshot-bar { + max-width: 100%; + } + + .tabshot-bar label { + padding: 0.4rem 0.85rem; + } + + /* Fitted to a 390px column a six-node flowchart's labels fall to a few pixels, so it keeps its size and pans */ + pre.mermaid svg { + max-width: none; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + scroll-behavior: auto !important; + } +} + +.callout { + border: 1px solid var(--line); + border-left: 3px solid var(--iris); + background: var(--surface); + border-radius: var(--radius); + padding: 0.9rem 1.1rem; + margin: 1.4rem 0; +} + +.callout p:last-child { + margin-bottom: 0; +} + +.callout-title { + font-weight: 620; + color: var(--ink); + margin: 0 0 0.3rem; +} + +.shot-figure { + margin: 1.5rem 0; +} + +.shot-figure figcaption { + margin-top: 0.5rem; + font-size: 0.88rem; + color: var(--muted); +} + +body.theme-light .only-dark, +body.theme-dark .only-light { + display: none; +} + +@media (prefers-color-scheme: dark) { + body.theme-auto .only-light { + display: none; + } +} + +@media (prefers-color-scheme: light) { + body.theme-auto .only-dark { + display: none; + } +} + +.provider { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.provider img { + width: 18px; + height: 18px; +} + +.card-link { + text-decoration: none; + display: block; + transition: + border-color 120ms ease, + transform 120ms ease; +} + +.card-link:hover { + border-color: var(--iris); + transform: translateY(-2px); +} + +.card-link h2, +.card-link h3 { + color: var(--ink); +} + +.breadcrumb { + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 0.6rem; +} + +.breadcrumb a { + color: var(--muted); + text-decoration: none; +} + +.breadcrumb a:hover { + color: var(--iris); +} diff --git a/website/assets/images/menubar-dark.webp b/website/assets/images/menubar-dark.webp new file mode 100644 index 0000000..2ef177b Binary files /dev/null and b/website/assets/images/menubar-dark.webp differ diff --git a/website/assets/images/menubar-light.webp b/website/assets/images/menubar-light.webp new file mode 100644 index 0000000..6e8a02d Binary files /dev/null and b/website/assets/images/menubar-light.webp differ diff --git a/website/assets/images/popover-history-dark.webp b/website/assets/images/popover-history-dark.webp new file mode 100644 index 0000000..c1a765d Binary files /dev/null and b/website/assets/images/popover-history-dark.webp differ diff --git a/website/assets/images/popover-history-light.webp b/website/assets/images/popover-history-light.webp new file mode 100644 index 0000000..d7dee5c Binary files /dev/null and b/website/assets/images/popover-history-light.webp differ diff --git a/website/assets/images/popover-settings-dark.webp b/website/assets/images/popover-settings-dark.webp new file mode 100644 index 0000000..21b5889 Binary files /dev/null and b/website/assets/images/popover-settings-dark.webp differ diff --git a/website/assets/images/popover-settings-light.webp b/website/assets/images/popover-settings-light.webp new file mode 100644 index 0000000..e3985ed Binary files /dev/null and b/website/assets/images/popover-settings-light.webp differ diff --git a/website/assets/images/popover-usage-dark.webp b/website/assets/images/popover-usage-dark.webp new file mode 100644 index 0000000..8d5cf33 Binary files /dev/null and b/website/assets/images/popover-usage-dark.webp differ diff --git a/website/assets/images/popover-usage-light.webp b/website/assets/images/popover-usage-light.webp new file mode 100644 index 0000000..4ff868e Binary files /dev/null and b/website/assets/images/popover-usage-light.webp differ diff --git a/website/assets/js/diagrams.js b/website/assets/js/diagrams.js new file mode 100644 index 0000000..7ed8290 --- /dev/null +++ b/website/assets/js/diagrams.js @@ -0,0 +1,48 @@ +// Mermaid takes its palette from the site tokens, so a diagram follows the light and dark themes. +(() => { + // The theme classes live on , so that is where the tokens resolve + const token = (name, fallback) => + getComputedStyle(document.body).getPropertyValue(name).trim() || fallback; + const blocks = [...document.querySelectorAll("pre.mermaid")]; + const sources = blocks.map((node) => node.textContent); + + const render = () => { + if (!window.mermaid) return; + const dark = + document.body.classList.contains("theme-dark") || + (document.body.classList.contains("theme-auto") && + window.matchMedia("(prefers-color-scheme: dark)").matches); + window.mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: "base", + // Draw at natural size and let CSS decide: shrinking a six-node flowchart to 390px leaves 4px labels, + // so on a phone the stylesheet drops the cap and the block scrolls instead. + flowchart: { useMaxWidth: false }, + sequence: { useMaxWidth: false }, + themeVariables: { + background: token("--surface", "#ffffff"), + primaryColor: dark ? "#272247" : "#efeafe", + primaryBorderColor: token("--iris", "#5a46e8"), + primaryTextColor: token("--ink", "#0f1117"), + secondaryColor: dark ? "#1e3038" : "#e6f2f7", + secondaryBorderColor: dark ? "#5087a0" : "#7fb3c6", + tertiaryColor: dark ? "#2f2b1b" : "#faf3dc", + tertiaryBorderColor: dark ? "#8c7c3f" : "#cbb46a", + lineColor: token("--muted", "#667085"), + textColor: token("--body", "#3c4250"), + fontSize: "15px", + }, + }); + blocks.forEach((node, index) => { + node.removeAttribute("data-processed"); + node.textContent = sources[index]; + }); + window.mermaid.run({ querySelector: "pre.mermaid" }); + }; + + const start = () => + window.mermaid ? render() : window.setTimeout(start, 50); + start(); + document.addEventListener("themechange", render); +})(); diff --git a/website/assets/js/theme.js b/website/assets/js/theme.js new file mode 100644 index 0000000..df6d4c8 --- /dev/null +++ b/website/assets/js/theme.js @@ -0,0 +1,39 @@ +// The stored choice is applied by an inline script in before paint, so this file only wires the toggle. +(() => { + const body = document.body; + const toggle = document.getElementById("theme-toggle"); + const icon = document.getElementById("theme-icon"); + const moon = "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"; + const sun = + "M12 4v1m0 14v1m8-8h-1M5 12H4m13.7-5.7-.7.7M7 17l-.7.7m11.4 0-.7-.7M7 7l-.7-.7" + + "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"; + + const isDark = () => + body.classList.contains("theme-dark") || + (body.classList.contains("theme-auto") && + window.matchMedia("(prefers-color-scheme: dark)").matches); + + const paintIcon = () => { + const dark = isDark(); + if (icon) icon.setAttribute("d", dark ? sun : moon); + if (toggle) toggle.setAttribute("aria-pressed", String(dark)); + // Diagrams pick their palette at render time, so they need to hear about the switch + document.dispatchEvent(new CustomEvent("themechange")); + }; + + if (toggle) { + toggle.addEventListener("click", () => { + const next = isDark() ? "light" : "dark"; + body.classList.remove("theme-auto", "theme-light", "theme-dark"); + body.classList.add("theme-" + next); + localStorage.setItem("tmb-theme", next); + paintIcon(); + }); + } + + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", paintIcon); + paintIcon(); + requestAnimationFrame(() => body.classList.remove("preload")); +})(); diff --git a/website/content/_index.md b/website/content/_index.md new file mode 100644 index 0000000..3154a32 --- /dev/null +++ b/website/content/_index.md @@ -0,0 +1,17 @@ +--- +title: Token Menu Bar +--- + +## Made to stay out of the way + +- Polls Claude every 5 minutes and Codex every 2 (faster while the popover is open), and backs off when a vendor asks it + to, since Anthropic's usage endpoint rate-limits after a handful of calls. +- Falls back to the Codex CLI's own session logs when you are offline or signed out, so the last numbers stay on screen. +- Pauses while the Mac sleeps, and refreshes on wake. + +## Where to go next + +Pick the door that matches what you need. [Get started](/start/) walks the install through to your first numbers. +[Reference](/reference/) describes each screen and option. [Explanation](/explanation/) covers what the app reads and +why it polls at the pace it does. [Troubleshooting](/troubleshooting/) turns a symptom into a report someone can act on. +[Contributing](/contributing/) is for working on the app itself. diff --git a/website/content/contributing/_index.md b/website/content/contributing/_index.md new file mode 100644 index 0000000..97b61e0 --- /dev/null +++ b/website/content/contributing/_index.md @@ -0,0 +1,246 @@ +--- +title: Contributing +description: The architecture, the house style, and the workflows that build, test and release the app. +weight: 5 +aliases: [/guides/, /guides/contributing/] +--- + +## Get the tools + +[mise](https://mise.jdx.dev) pins every tool this repository needs, with checksums in `mise.lock`, and +[just](https://just.systems) drives the workflows. + +```sh +git clone https://github.com/tox-dev/token-menu-bar-macos +cd token-menu-bar-macos +mise install # hugo, just, pre-commit, xcodegen +just # the list of workflows +just check # build, tests with the coverage gate, and every lint hook +just run # ad-hoc signed .app in dist/, launched +``` + +The Swift toolchain stays outside mise, since a release build needs [Xcode](https://developer.apple.com/xcode/) anyway. +A [swiftly](https://swiftlang.github.io/swiftly/) toolchain works for everything except the Xcode schemes. Ad-hoc builds +take a new code signature each time, so macOS repeats the Keychain prompt after every rebuild. + +## How the app is put together + +Three SwiftPM targets and a widget extension. Core holds every decision that does not need a screen, which is what keeps +the test suite fast and the UI thin. + +```mermaid +flowchart TD + accTitle: Data flow through the four targets + accDescr: The Keychain, the CLI dotfiles and the vendor endpoints feed one UsageProvider per vendor. RefreshCoordinator drives them and writes AppState, which fans out to the SQLite history, the menu bar status model, the popover cards and the widget snapshot. + Keychain[Keychain and CLI dotfiles] --> Providers + Vendors[Vendor HTTPS endpoints] --> Providers + Providers[UsageProvider per vendor] --> Coordinator[RefreshCoordinator] + Coordinator --> State[AppState: ProviderSnapshot per vendor] + State --> History[(SQLite, 60 days)] + State --> Status[StatusItemModel] + State --> Cards[ProviderCard, HistoryRenderData] + Status --> Bar[Menu bar cells] + Cards --> Popover[Usage, History, Settings] + State --> Widget[WidgetSnapshot in the app group] + Widget --> Widgets[WidgetKit timeline] + classDef source fill:#efeafe,stroke:#5a46e8,color:#0f1117; + classDef core fill:#faf3dc,stroke:#cbb46a,color:#0f1117; + classDef sink fill:#e6f2f7,stroke:#7fb3c6,color:#0f1117; + class Keychain,Vendors,Providers source; + class Coordinator,State,History core; + class Status,Cards,Bar,Popover,Widget,Widgets sink; +``` + +- `TokenMenuBarCore` holds providers, credentials, history, presentation policy, settings, and the status-bar model. It + uses Foundation, SQLite, Security, and OSLog; it does not import AppKit or SwiftUI. +- `TokenMenuBarUI` renders Core values through the status item, popover, and three tabs. It contains no vendor parsing. +- `TokenMenuBarWidgets` reads the snapshot that Core writes. It does not call vendors. +- `TokenMenuBar` contains `main.swift`, which parses arguments and starts the run loop. + +A refresh is one pass over the registry, and a provider that fails does not stop the others. + +```mermaid +sequenceDiagram + accTitle: One refresh pass + accDescr: A timer calls RefreshCoordinator, which asks each provider for its credential state. A missing or expired token publishes authenticationRequired; a usable one fetches usage, publishes the snapshot, and records it in the history store when a window moved five percent. The coordinator then rebuilds the status model and the widget snapshot and schedules the next tick. + participant T as Timer + participant C as RefreshCoordinator + participant P as UsageProvider + participant S as AppState + participant H as UsageHistoryStore + T->>+C: refresh(force:analytics:) + C->>+P: credentialState(now:) + alt token missing or expired + P-->>-C: notAuthenticated + C->>+S: availability = .authenticationRequired + S-->>-C: state published + else token usable + C->>+P: fetch(now:options:) + P-->>-C: success, partial, or networkUnavailable + C->>+S: snapshot, warnings, lastError + S-->>-C: state published + C->>+H: record(snapshot) when a window moved 5% + H-->>-C: stored + end + C->>+S: rebuild the status model and the widget snapshot + S-->>-C: cells and snapshot ready + C-->>-T: next tick scheduled +``` + +## Adding a provider + +Everything downstream of `ProviderSnapshot` is generic, so a new vendor is one conformance plus its mapping. + +```mermaid +flowchart LR + accTitle: Steps to add a provider + accDescr: Add a ProviderID case and setup metadata, give it a polling policy and a provider mark, conform to UsageProvider, map the response to QuotaWindow and ProviderAnalytics, register it in ProviderRegistryFactory, and add a DemoData snapshot. + A[Add a ProviderID case
name, tag, setup metadata] --> B[PollingPolicy default
and provider mark] + B --> C[Conform to UsageProvider
credentialState, fetch] + C --> D[Map the response to
QuotaWindow and ProviderAnalytics] + D --> E[Register in
ProviderRegistryFactory] + E --> F[Add a DemoData snapshot] + classDef step fill:#efeafe,stroke:#5a46e8,color:#0f1117; + class A,B,C,D,E,F step; +``` + +The menu bar, popover, history, widgets and notifications then pick the provider up on their own. Record a fixture from +the live endpoint under `Tests/TokenMenuBarCoreTests/Fixtures/`, with the account details replaced, and drive the +provider's `fetch` through `StubTransport` rather than calling the mapper. + +## House style + +- Core owns the logic. If a rule can be decided without a screen, it belongs in `TokenMenuBarCore` with a test. +- Tests describe behaviour through public API. A mapper case feeds vendor JSON through `StubTransport` and asserts on + the `ProviderSnapshot`, so a rename inside Core does not rewrite the suite. +- `Scripts/coverage.sh` fails when a line in Core or UI never runs. Its `glue` array is the authoritative list of files + that require an application, framework, widget, or Xcode host. The script derives SwiftPM exclusions from that list + and caps each file at 40 lines, so logic cannot accumulate where no test reaches. +- Comments carry the why. Anything that restates the line below it comes out. +- [swift-format](https://github.com/swiftlang/swift-format) settles layout at 120 columns; `just fmt` applies it. +- Helpers sit below their first caller, so a file reads top to bottom. +- Prose, commit messages and UI copy avoid the AI writing tells: no filler adverbs, no passive voice hiding the actor, + no sweeping every/never claims that nothing enforces. + +## Refreshing the docs screenshots + +```sh +just shots +``` + +The app renders the shots itself: `--export-menubar` and `--export-popover` draw the status strip and each popover tab +on demo data, at the size the tab reports, in light and dark. Nothing captures the screen, so a shot carries no part of +your desktop and no part of your account. + +These exports do not create an `NSPopover` or a window-server surface. They cannot verify the arrow, control bezels, +focus rings, screen selection, or top-edge anchoring. + +## Verifying the live panel + +Run this check on macOS 14, 15, 26, and 27 before a release: + +```sh +just run +``` + +`just run` uses provider data from the current account. Use `just run-demo` for seeded providers, a separate defaults +suite, and a temporary support directory. + +1. Open the status item near the left edge, centre, and right edge of the menu bar. The arrow must meet the status item + at each position. +2. Switch through Usage, History, and Settings. The panel's top edge and width must stay fixed while its bottom edge + moves. Repeat on a short display and a secondary display with a different scale. +3. Make each tab exceed the screen height. The body must scroll without clipping the tab control or moving the arrow. +4. Press each action button. It must show a bezel, pressed state, and non-accent label. Check light, dark, increased + contrast, and reduced transparency appearances. +5. Enable Keyboard navigation under System Settings > Keyboard. Use Tab and Shift-Tab to reach each control, Command-R + to refresh, Command-F to focus the Settings model filter, and Escape to close the panel. Focus rings must remain + visible. +6. Leave the panel open for two minutes. Activity Monitor should show stable memory and no sustained CPU work while the + data remains unchanged. + +The application UI suite checks all three tabs for accessibility faults, enforces launch, memory, and idle-CPU budgets, +and removes its defaults and support files after each test. Xcode 27 jobs verify the SDK but do not claim macOS 27 +runtime coverage. The required **macOS 27 runtime** status stays red until `MACOS_27_RUNTIME_RUNNER` names a self-hosted +Mac running macOS 27; releases use the same runner. + +## The website + +`website/` is a self-contained [Hugo](https://gohugo.io) site built from plain CSS and templates, with no theme module, +Node or Sass. Its structure follows [Diátaxis](https://diataxis.fr): a tutorial, a reference, an explanation, and this +page. + +```sh +just site-serve +``` + +The brand lives in two places that have to stay in step: `Sources/TokenMenuBarCore/Brand.swift` for the app, and the +custom properties at the top of `website/assets/css/site.css` for the site. `website/static/brand/` holds the logo files +(`mark`, `mark-mono`, `lockup`, `lockup-stacked`, `icon`, `seal`). Each page also ships as raw markdown next to itself, +and `/llms.txt` indexes them for crawlers in the [llms.txt](https://llmstxt.org) format. + +Diagrams are [Mermaid](https://mermaid.js.org) fenced blocks. The renderer loads only on pages that hold one, and takes +its palette from the site tokens, so a diagram follows the light and dark themes. + +## Releasing + +One button: run the **Prepare Release** workflow (`just release patch|minor|major`, or the Actions tab). It works out +the next version from the newest tag and pushes `vX.Y.Z` with a token of its own, because a tag pushed with the default +`GITHUB_TOKEN` starts no further workflow. + +That tag triggers **Release**, and one run publishes all three channels: + +```mermaid +flowchart LR + accTitle: What one tag publishes + accDescr: Prepare Release bumps the version and pushes a tag. The release builds separate Direct, Homebrew, and App Store applications. Direct includes Sparkle, Homebrew omits it, and App Store uses the sandboxed entitlement set. + P[Prepare Release
bump and tag] --> T((tag vX.Y.Z)) + T --> D[Direct: Sparkle,
Developer ID] + T --> H[Homebrew: no Sparkle,
Developer ID] + T --> A[App Store:
sandboxed upload] + D --> G[GitHub release:
zip, DMG, checksums, appcast] + H --> C[Homebrew cask:
version and sha256 on main] + classDef start fill:#efeafe,stroke:#5a46e8,color:#0f1117; + classDef build fill:#faf3dc,stroke:#cbb46a,color:#0f1117; + classDef ship fill:#e6f2f7,stroke:#7fb3c6,color:#0f1117; + class P,T start; + class D,H build; + class G,C,A ship; +``` + +The direct leg refuses to run without its credentials, so a tag never publishes an app Gatekeeper rejects or a feed +[Sparkle](https://sparkle-project.org) cannot verify. The App Store leg checks its own credentials first and skips with +a note in the run summary when they are missing, so the rest of a release still ships while the Apple Developer account +is pending. + +The three channels are separate Xcode application targets. Only Direct compiles and links `SparkleUpdater`; Homebrew and +App Store do not link the Sparkle product and carry no Sparkle keys in `Info.plist`. Release verification checks both +arm64 and x86_64 slices, Mach-O validity, and updater load commands in the app, zip, and disk image without launching +the application. + +### What Apple needs + +Enrol in the [Apple Developer Program](https://developer.apple.com/programs/), then create these and store each in the +`release` +[environment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments), +which only `main` and `v*` tags can deploy to. Every job that reads a signing identity names that environment, so a pull +request build cannot reach one: + +- `DEVELOPER_ID_CERTIFICATE_BASE64` and `DEVELOPER_ID_CERTIFICATE_PASSWORD`: a **Developer ID Application** certificate + exported from Keychain Access as a base64-encoded `.p12`. +- `APPLE_DISTRIBUTION_CERTIFICATE_BASE64` and `APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD`: an **Apple Distribution** + certificate exported the same way. +- `MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_BASE64` and `MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_PASSWORD`: a **Mac + Installer Distribution** certificate exported the same way. +- `APP_STORE_PROVISIONING_PROFILE_BASE64` and `APP_STORE_WIDGET_PROVISIONING_PROFILE_BASE64`: Mac App Store profiles for + `dev.tox.token-menu-bar` and its widget extension. +- `APPLE_TEAM_ID`: the ten-character identifier on the [membership page](https://developer.apple.com/account). +- `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`, and `APP_STORE_CONNECT_KEY_BASE64`: a base64-encoded + [App Store Connect API key](https://appstoreconnect.apple.com/access/integrations/api) with the App Manager role. +- `SPARKLE_PUBLIC_ED_KEY` and `SPARKLE_PRIVATE_ED_KEY`: keys from Sparkle's `generate_keys` command. + +The app record in [App Store Connect](https://appstoreconnect.apple.com) has to exist under the same bundle identifier +before the first upload, along with a `dev.tox.token-menu-bar` App ID and an app group for the widget. + +[Renovate](https://docs.renovatebot.com) opens a grouped pull request each week for the pinned tools, the action digests +and the Swift packages. The docs deploy to GitHub Pages on every push to `main`. diff --git a/website/content/explanation/_index.md b/website/content/explanation/_index.md new file mode 100644 index 0000000..5277078 --- /dev/null +++ b/website/content/explanation/_index.md @@ -0,0 +1,38 @@ +--- +title: Privacy and rate limits +description: What the app reads, where it sends it, and why the poll interval stays long. +weight: 4 +aliases: [/explanation/privacy/] +--- + +## What the app reads + +| Source | Files it reads | Endpoints it calls | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Claude](https://docs.claude.com/en/docs/claude-code/overview) | Keychain item `Claude Code-credentials` (or `~/.claude/.credentials.json`), `~/.claude.json`, `~/.claude/projects/**/*.jsonl` | `GET api.anthropic.com/api/oauth/usage`, `GET api.anthropic.com/api/oauth/profile` | +| [Codex](https://developers.openai.com/codex/cli/) | `~/.codex/auth.json` (`CODEX_HOME` honoured), `~/.codex/sessions/**/rollout-*.jsonl` | `chatgpt.com/backend-api/wham/usage`, `wham/rate-limit-reset-credits`, `wham/usage/daily-token-usage-breakdown`, `wham/analytics/*` | +| [Gemini](https://github.com/google-gemini/gemini-cli) | `~/.gemini/oauth_creds.json` (`GEMINI_CLI_HOME` honoured) | `cloudcode-pa.googleapis.com/v1internal:loadCodeAssist`, `:retrieveUserQuota`, and `oauth2.googleapis.com/token` once you opt into token refresh | +| [Cursor](https://cursor.com/docs) | Cursor's `state.vscdb` (read-only, immutable open) or `~/.cursor/auth.json` | `cursor.com/api/usage-summary`, `cursor.com/api/auth/me`, falling back to `api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage` | +| [Copilot](https://docs.github.com/en/copilot) | `~/.config/github-copilot/hosts.json`, `apps.json` (`XDG_CONFIG_HOME` honoured) | `api.github.com/copilot_internal/user` | +| [Widgets](https://developer.apple.com/documentation/widgetkit) | the JSON snapshot the app writes into the app group container: window labels, percentages and reset times, and no tokens | none | + +Those five hosts are the only ones the app contacts. It runs no telemetry, reports no crashes, and keeps no account of +yours. The [SQLite](https://sqlite.org) history database and the log live under +`~/Library/Application Support/Token Menu Bar/`; the log records short error snippets, and leaves out tokens, request +headers and response bodies. + +## Token refresh + +Both CLIs rotate their [OAuth refresh token](https://datatracker.ietf.org/doc/html/rfc6749#section-6) each time they +refresh. If the app refreshed on your behalf and then failed to write the new token back, the CLI would lose its +session, so the app leaves expired tokens alone and shows a sign-in hint instead. Settings > Providers turns the refresh +on. + +## Rate limits + +The [Anthropic](https://www.anthropic.com/pricing) usage endpoint carries no documentation and allows a handful of +requests per token before it answers `429` for a long time; +[Claude Code](https://docs.claude.com/en/docs/claude-code/overview) itself leaves it alone. So the app reads Claude +every 5 minutes by default (2 minutes while the popover is open) and Codex every 2 minutes (1 minute while open). It +backs off from `Retry-After` between a 60-second floor and a 30-minute cap, and keeps the last good values on screen +with their age. diff --git a/website/content/reference/_index.md b/website/content/reference/_index.md new file mode 100644 index 0000000..209b3ef --- /dev/null +++ b/website/content/reference/_index.md @@ -0,0 +1,5 @@ +--- +title: Reference +description: Every screen, option and endpoint, described exactly. +weight: 3 +--- diff --git a/website/content/reference/interface.md b/website/content/reference/interface.md new file mode 100644 index 0000000..3f1a68e --- /dev/null +++ b/website/content/reference/interface.md @@ -0,0 +1,93 @@ +--- +title: Interface reference +description: What the menu bar, the tabs and the widgets show. +icon: M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 9h18 +weight: 2 +--- + +## Menu bar + +{{< shot name="menubar" scale="3" alt="The macOS menu bar with one Token Menu Bar cell per selected window, each showing a label and a percentage" caption="Menu bar cells for the selected windows." >}} + +One cell per selected window. The label is the provider tag (`CC`, `CX`); when a provider shows more than one window the +window tag joins it (`CC 5h`, `CC FAB`, `CX 7d`) to keep the two apart. The percent takes a traffic-light colour from +green to red. Four formats exist: + +| Format | Looks like | +| ----------------- | -------------------------------------------------------------------------------------------------- | +| Stacked (default) | Label over value, in the proportions the [Stats](https://github.com/exelban/stats) CPU widget uses | +| Inline | `CC:36%` on one line, the narrowest option | +| Mini bars | Provider glyph plus tiny bars, one cell per provider carrying its windows | +| Custom | Any template built from tokens such as `{cell}`, `{pct1}` and `{reset}` | + +Windows at 0% stay hidden until you ask for them. The countdown redraws once a second, and only while the template +references `{reset}`. + +The app icon replaces the cells rather than sitting beside them, so it appears only when there is nothing to show: no +provider has reported yet, every selected window is hidden, or **Fit to space** has stepped down to its narrowest +layout. It is grey while a provider is offline and orange while one needs a sign-in, sign-in winning when both are true. +A provider that is signed out beside one that is working leaves the cells on screen and no icon, so watch the Usage tab +rather than the menu bar for that. + +With **Fit to space** on, the app notices when macOS hides the item (typically behind the notch once a busy app menu +takes the left half of the bar) and steps down through narrower layouts: the configured format, stacked, one cell per +provider, mini bars, icon only. It remembers which layout fit for each frontmost app, so a switch between apps holds +steady. + +## Widgets + +Small, medium and large widgets show the windows selected for the menu bar with percent bars and reset countdowns. After +each refresh the app writes a snapshot to the shared app group and asks +[WidgetKit](https://developer.apple.com/documentation/widgetkit) to reload, which puts the widget at most one poll +behind the menu bar. The signed builds carry the widgets; an ad-hoc development bundle has no extension. + +## Usage tab + +{{< shot name="popover-usage" alt="Usage tab: a card per provider listing each limit window with its percent used, a progress bar, a reset countdown and a pace line" caption="Usage tab: every window with percent, reset countdown and pace." >}} + +One card per provider: + +| Section | What it shows | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Plan chips | `Max 20x`, `Pro`, the account e-mail, renewal date; a click opens the vendor page, or copies | +| Windows | Each limit the vendor reports with percent used, a bar, "Resets in 4 hr 24 min · 6:49 PM", and a pace line ("Ahead of pace (expected 20%); hits 100% at 3:40 PM"). Hover a row for the full numbers | +| Claude usage credits | The monthly spend cap, amount spent, balance, auto-reload state and reset date, as on [claude.ai/settings/usage](https://claude.ai/settings/usage) | +| Claude local session logs | Tokens and API-equivalent cost of the current 5-hour block, burn rate per hour, and today's totals, read from Claude Code's own transcripts | +| Codex credits and reset credits | Balance, approximate messages left, limit resets available, spend controls | +| Notices | Promotions, limit-reached and spend-limit messages, stale-data and rate-limit banners | +| Gemini | One row per model with the daily request bucket, the [Code Assist](https://codeassist.google) tier and any [Google One AI](https://one.google.com/about/google-ai-plans/) credits; a personal account that Google cut off in June 2026 reads an explanation rather than a sign-in loop | +| Cursor | Plan usage for the billing cycle, on-demand spend against its limit, team pools, and the [membership tier](https://cursor.com/pricing) | +| Copilot | [Premium requests](https://docs.github.com/en/copilot/managing-copilot/monitoring-usage-and-entitlements/about-premium-requests), chat and completion quotas for the month, overage counts and token-based billing credits | + +## History tab + +{{< shot name="popover-history" alt="History tab: stacked line charts of the last sixty days of usage per provider, with reset cliffs marked" caption="History tab: 60 days of samples plus vendor analytics." >}} + +The chart draws window percentages over time with the reset cliffs in place, min/max-preserving downsampling, stacked +mode, UTC or local day boundaries, and Today / 7d / 30d / 60d / custom ranges with paging. In the inspector legend, a +click hides a row, a double-click isolates it, and a hover highlights it while the value column follows the cursor. + +Below the chart sit the **Codex analytics** from [chatgpt.com](https://chatgpt.com/codex/settings/analytics) (usage by +surface, credits by model, turns, tokens, skills, plugin calls, code review metrics) and the **Claude analytics** from +the local transcripts (input, output, cache-read and cache-write tokens by model, API-equivalent cost, messages, +sessions and tool calls per day). + +## Settings tab + +{{< shot name="popover-settings" alt="Settings tab: menu bar format and window pickers, provider toggles, data and notification options, and the log" caption="Settings tab: menu bar, providers, data and the log." >}} + +[Settings](/reference/settings/) describes each option. + +## Where the numbers come from + +| Provider | Token | Endpoints | +| -------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| [Claude](https://docs.claude.com/en/docs/claude-code/overview) | Keychain item `Claude Code-credentials`, or `~/.claude/.credentials.json` | `GET api.anthropic.com/api/oauth/usage`, `GET api.anthropic.com/api/oauth/profile` | +| [Codex](https://developers.openai.com/codex/cli/) | `~/.codex/auth.json` (`CODEX_HOME` honoured) | `chatgpt.com/backend-api/wham/usage`, `wham/rate-limit-reset-credits`, `wham/usage/daily-token-usage-breakdown`, `wham/analytics/*` | +| [Gemini](https://github.com/google-gemini/gemini-cli) | `~/.gemini/oauth_creds.json` (`GEMINI_CLI_HOME` honoured) | `cloudcode-pa.googleapis.com/v1internal:loadCodeAssist`, then `:retrieveUserQuota` | +| [Cursor](https://cursor.com/docs) | Cursor's `state.vscdb`, or `~/.cursor/auth.json` from `cursor-agent` | `cursor.com/api/usage-summary`, `/api/auth/me`, falling back to `api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage` | +| [Copilot](https://docs.github.com/en/copilot) | `~/.config/github-copilot/hosts.json` or `apps.json` | `api.github.com/copilot_internal/user` | + +Google ended Login with Google for personal accounts in June 2026, so quota reaches Workspace and Code Assist Standard +or Enterprise accounts alone; the app says so rather than looping on a sign-in prompt. When Codex is offline or signed +out, the app falls back to the last `rate_limits` event in `~/.codex/sessions`. diff --git a/website/content/reference/settings.md b/website/content/reference/settings.md new file mode 100644 index 0000000..3fe961c --- /dev/null +++ b/website/content/reference/settings.md @@ -0,0 +1,69 @@ +--- +title: Settings reference +description: What each option in the Settings tab does. +icon: M4 6h16M4 12h16M4 18h16M8 4v4M16 10v4M11 16v4 +weight: 3 +--- + +The popover's Settings tab holds these options. A change takes effect as you make it and survives a restart. + +## About + +Version and build flavour, +**[Launch at login](https://developer.apple.com/documentation/servicemanagement/smappservice)** (with a shortcut to +Login Items when macOS wants approval), **Reset Defaults**, **Copy Diagnostics** (a report with versions, provider state +and the last log lines), **Report Issue** (opens a pre-filled GitHub issue), and, in the direct build, automatic update +checks through [Sparkle](https://sparkle-project.org). + +## Menu bar + +| Option | What it does | +| ------------- | ------------------------------------------------------------------------------------------------------------------- | +| Order | Keeps provider order, or sorts cells by percent used | +| Format | Stacked, Inline, Mini bars, or Custom | +| Decimals | 0 to 2 decimals on the percent | +| Hide 0% | Drops cells whose window sits at 0% | +| Fit to space | Steps down to narrower layouts when macOS hides the item for lack of room, and remembers what fit per frontmost app | +| Template | The custom format string, built from the tokens below | +| Windows shown | Ticks the windows that get a cell; one stays selected, and each label is editable | + +The same code that draws the menu bar renders the preview under the controls. + +### Template tokens + +- `{cell}`: provider tag, plus the window tag when a provider shows several windows +- `{provider}`: `CC` or `CX` +- `{providerName}`: `Claude` or `Codex` +- `{window}`: `5h`, `7d`, `FAB`, … +- `{label}`: the editable short label +- `{pct}`, `{pct0}`, `{pct1}`, `{pct2}`: percent used at the configured / 0 / 1 / 2 decimals +- `{remaining}`: percent left +- `{reset}`: live countdown to the reset +- `{resetClock}`: reset time +- `{plan}`: plan name +- `{credits}`: credit balance + +`\n` starts a second line; `{{` and `}}` produce literal braces. + +## Providers + +Enable or disable each provider (Claude, Codex, Gemini, Cursor, Copilot), see the credential state, and set the refresh +interval per provider. The floors are 2 minutes for Claude and 1 minute for the others; while the popover is open the +app polls at the floor. **Refresh expired tokens on my behalf** starts off, since a refresh rotates the CLI's refresh +token and writes the new one back to the +[Keychain](https://developer.apple.com/documentation/security/keychain-services) or `~/.codex/auth.json`. + +## Data + +How often the app fetches analytics, where the history database lives, and buttons to reveal, export +([CSV](https://datatracker.ietf.org/doc/html/rfc4180)) or clear it. + +## Notifications + +Threshold notifications at 50/75/90/100%, window-reset notifications, and sign-in alerts. + +## Log + +The last 200 log lines, a full-log window, copy and clear, a detailed-logging switch that also turns on the status item +probe, which helps when the cell disappears behind the notch, and **Demo data**, which relaunches the app on generated +numbers with a separate history file, so you can walk the screens or take screenshots without showing your account. diff --git a/website/content/start/_index.md b/website/content/start/_index.md new file mode 100644 index 0000000..d56b1b0 --- /dev/null +++ b/website/content/start/_index.md @@ -0,0 +1,60 @@ +--- +title: Get started +description: Install the app, sign in to the clients you use, and read your first numbers. +weight: 1 +aliases: [/start/install/] +--- + +## Before you start + +Token Menu Bar needs macOS 14 or later on an Apple Silicon Mac, and one signed-in client. It reads the token that client +already stored, so no password reaches this app and nothing asks you to sign in twice. A provider you have not signed +into stays hidden until its token appears; Settings > Providers toggles them. + +| Client | Sign in with | Token lands in | +| ------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------- | +| [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) | `claude` | Keychain item `Claude Code-credentials` | +| [Codex](https://developers.openai.com/codex/cli/) | `codex login` | `~/.codex/auth.json` | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini`, through Login with Google | `~/.gemini/oauth_creds.json` | +| [Cursor](https://cursor.com/docs) | the Cursor app, or `cursor-agent login` | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`, or `~/.cursor/auth.json` | +| [Copilot](https://docs.github.com/en/copilot) | Copilot CLI, Neovim or JetBrains | `~/.config/github-copilot/hosts.json` or `apps.json` | + +## Get the app + +### Mac App Store + +The release pipeline uploads the sandboxed build once the Apple Developer account is enrolled. On first launch it asks +you to point it at `~/.codex`, which the sandbox cannot read on its own. + +### Homebrew + +```sh +brew install --cask token-menu-bar +``` + +Until the cask lands in homebrew-cask you can install it from a checkout: + +```sh +brew install --cask Casks/token-menu-bar.rb +``` + +### Direct download + +1. Download `TokenMenuBar.dmg` from the + [latest release](https://github.com/tox-dev/token-menu-bar-macos/releases/latest). +2. Drag **Token Menu Bar** into `/Applications` and open it. +3. Approve the Keychain prompt with **Always Allow** so the app can read the Claude Code token. + +The direct build carries a +[Developer ID signature and Apple's notarization](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution), +and updates itself through [Sparkle](https://sparkle-project.org) (Settings > About). + +## First launch + +The menu bar shows the app icon until the first refresh lands; then one cell per selected window appears. Open the +popover to see every window, and pick the windows and their format under **Settings > Menu bar**. + +{{< callout kind="tip" title="Launch at login" >}} Turn on **Launch at login** under Settings > About. macOS may ask you +to approve the item under +[System Settings > General > Login Items](https://support.apple.com/guide/mac-help/open-items-automatically-when-you-log-in-mh15189/mac); +the app offers a shortcut to that pane. {{< /callout >}} diff --git a/website/content/troubleshooting/_index.md b/website/content/troubleshooting/_index.md new file mode 100644 index 0000000..94b0364 --- /dev/null +++ b/website/content/troubleshooting/_index.md @@ -0,0 +1,149 @@ +--- +title: Troubleshooting +description: The log to capture, the report to send, and what each symptom means. +weight: 6 +--- + +## Capture the evidence first + +A report without a log is a guess. The order matters, because the log only records what happened after you turned it on. + +1. Turn on **Detailed logging** under Settings > Log, before you reproduce the problem. It is high-volume: a line for + every request sent and every response received, and a line each time the menu bar item changes size or visibility. + The buffer keeps the last 500 lines and 7 days, so a day spent with it on pushes out the very thing you wanted to + report. +2. Reproduce the problem. +3. Send the report, or attach the log. +4. Turn **Detailed logging** back off. + +The log sits next to the history database: + +```sh +open -R ~/Library/Application\ Support/Token\ Menu\ Bar/log.txt +``` + +The App Store build is sandboxed, so its copy sits under `~/Library/Containers/dev.tox.token-menu-bar/Data/` instead. +Lines reach the file in batches, up to five seconds behind the app; Settings > Log shows the last 200 straight from +memory, so read there for the newest ones. + +### What the log leaves out + +- Request URLs lose their query string, and any UUID in the path becomes `{id}`. +- The app writes no request headers, so no token reaches the file. +- A successful response leaves its status, byte count and duration behind. The body stays out. +- A rejected request records the first 200 bytes of its body, which is the vendor's own error text. Skim it before you + attach it, since vendors sometimes name the account or the organisation there. + +### Which route to use + +**Report Issue** under Settings > About opens a GitHub issue pre-filled with the diagnostics report, cut to the whole +lines that fit in an 8000-character URL, and the log tail is what falls off first. When the log is the point, use **Copy +Diagnostics** and paste it into the issue yourself. + +Either way the report carries the version and build flavour, the macOS version, the poll intervals and menu bar format, +the enabled providers, the history path, the age of the last refresh, one line per provider with its status, plan, +window percentages, last error and credential state, and the last 80 log lines. It names your plan and how much of it +you have used, so read it before you send it. + +## A provider shows nothing + +When a provider has no card in the Usage tab at all, it is either switched off or the app found no credential for it. +Settings > Providers holds a switch and a credential line for each one: `No credentials: …`, `Token expired …`, or +`Token valid until …`. + +When the card is there, its status line names one of eight states, and those eight are the only distinctions the app can +draw: + +| Status | What it means | What to do | +| ------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Loading | The first fetch is in flight | Wait a poll | +| Up to date | The last fetch worked; "No usage yet" under it means the vendor reported no window | Nothing | +| Showing last known values | A fetch failed and the numbers below are the previous ones, with their age | Read the reason next to it | +| Sign-in required | No usable token, or the vendor answered `401`/`403` | Sign in with the client; Settings > Providers can let the app refresh Claude, Codex and Gemini for you | +| Offline | The request never reached the vendor | Check the network | +| Rate limited | The vendor answered `429` | See [below](#a-provider-is-rate-limited) | +| Unavailable | The vendor answered, but with an error or a shape the app could not read | The card and the log carry the vendor's own message | +| Disabled | Switched off | Settings > Providers | + +A vendor outage, a changed response shape and a body the app cannot decode all land on **Unavailable** with whatever the +vendor said. The app does not guess between them, and neither should a report. After any failure it waits a minute +before trying that provider again. + +## The menu bar shows only the icon + +The icon appears only when there is no cell to draw, and disappears the moment one number lands. So an icon on its own +means one of: + +- No refresh has finished yet. +- No window is ticked under Settings > Menu bar, or **Hide 0%** is on and every ticked window sits at 0%. +- **Fit to space** stepped all the way down to the icon-only layout, because macOS had no room for the numbers. + +## The icon is grey, or carries an orange dot + +Grey ink means at least one enabled provider is offline. An orange dot means at least one needs a sign-in, and it wins +when both are true. Both tones are visible only in the icon-only state above; once cells show, the popover card is where +a provider says it is offline or signed out. + +## The menu bar item disappears + +macOS hides status items it has no room for, most often behind the notch once a busy app takes the left half of the bar. +**Fit to space** under Settings > Menu bar exists for this, and steps down through narrower layouts instead. + +To report one that vanishes anyway, turn on **Detailed logging**, which also starts the status item probe. The probe +samples once a second and writes a line whenever the reading changes: + +```text +status item visible=true buttonHidden=false window=true occlusion=false length=64 width=64 front=Xcode +``` + +Attach those lines, along with `status item does not fit; stepping down to tier N`, and say which app was frontmost. +`visible=true` with a non-zero `width` and `occlusion=false` is the notch case; `visible=false` is macOS removing the +item outright, which is a different bug. + +## Cursor stops reporting + +The app reads Cursor's session from `state.vscdb` read-only and with SQLite's `immutable=1`, so it never locks the +database and cannot disturb a running Cursor. The cost is that an immutable open ignores the write-ahead log, so a token +Cursor wrote since its last checkpoint is invisible to the app. Sign in or out while Cursor is running and the card can +say **Sign-in required** while Cursor itself works fine. + +Quit Cursor, which checkpoints the database, then press **Refresh**. Or run `cursor-agent login`, which writes +`~/.cursor/auth.json`; the app reads that next when `state.vscdb` yields nothing. + +## A provider is rate limited + +The card says **Rate limited** and names the time of the next attempt, and the last good numbers stay on screen with +their age. [Privacy and rate limits](/explanation/#rate-limits) covers why the poll interval is what it is and how the +backoff works. + +**Refresh** ignores the backoff window, and each `429` doubles the hold that follows, so a run of manual refreshes +against a rate-limited provider makes the wait longer rather than shorter. + +## Widgets show stale numbers + +Widgets read a snapshot the app writes into the shared app group. The app rewrites it and asks WidgetKit to reload at +the end of a refresh cycle, and only when a value in it changed; otherwise the widget re-renders from what it has, every +15 minutes, within whatever budget WidgetKit allows. So a widget is at most one poll behind the menu bar, and one that +never moves points at an app that is not running. + +Opening the popover and pressing **Refresh** fetches every enabled provider at once, ignoring both the poll intervals +and the backoff, and republishes the snapshot if anything moved. Widgets ship with the signed builds only; an ad-hoc +development build has no widget extension, and demo mode writes no snapshot. + +## What VoiceOver reads + +The menu bar item draws its numbers as an image, which VoiceOver would otherwise read as nothing, so the button carries +a label with the same readings the tooltip shows, rebuilt on every redraw: +`Token Menu Bar, Claude Session: 36%, resets 2 hr 14 min`, or `Token Menu Bar, no usage yet` when there are no cells. + +- Icon-only controls carry their own labels: **Refresh usage**, **Previous period**, **Next period**, and the copy + button beside each copyable value. +- The history chart exposes one chart descriptor with every visible series, date, and value. A legend row reads its + total and can hide, restore, or isolate its series. VoiceOver skips decorative swatches; provider marks use the + provider name as their label. +- Meaning survives without colour. A banner spells out `Warning:` or `Note:`, each meter carries its label and + percentage, and the provider status is words before it is a tint. The menu bar icon is the exception, since its grey + and orange tones stand alone in the bar, and the popover card is where that state appears in words. +- No alert is sound-only. Every notification carries a title and a body, and the sound comes with them. + +If VoiceOver reads something other than the above, that is a bug worth a report, with the diagnostics attached. diff --git a/website/data/features.yaml b/website/data/features.yaml new file mode 100644 index 0000000..92a73e3 --- /dev/null +++ b/website/data/features.yaml @@ -0,0 +1,35 @@ +- title: The windows the vendors meter + icon: M3 12h4l3 8 4-16 3 8h4 + body: >- + Claude's 5-hour session and weekly per-model limits, Codex's weekly and model windows, Gemini's daily buckets, + Cursor's plan and on-demand spend, Copilot's premium requests, plus credits and spend caps. + +- title: Pace beside the percent + icon: M12 8v4l3 2m6-2a9 9 0 1 1-18 0 9 9 0 0 1 18 0z + body: >- + Each window says whether you are ahead of an even pace and when it would hit 100%, so a red bar on Friday + arrives with warning. + +- title: History that survives the reset + icon: M3 3v18h18M7 15l4-4 3 3 5-6 + body: >- + Samples land in a local SQLite store every few minutes. Charts keep 60 days, draw the reset cliffs, and stack + windows next to the vendor analytics. + +- title: Widgets and notifications + icon: M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z + body: >- + Small, medium and large desktop widgets mirror the selected windows. Threshold crossings, resets and sign-in + problems arrive as macOS notifications you can tune. + +- title: Fits any menu bar + icon: M4 6h16M4 12h10M4 18h6 + body: >- + The app draws each cell as an image, which holds two lines and per-value colour at any height, and steps down + to narrower layouts when the bar runs out of room next to the notch. + +- title: Private by construction + icon: M12 3l7 4v5c0 4.4-3 8.4-7 9-4-0.6-7-4.6-7-9V7z + body: >- + It keeps no account, sends no telemetry, and refreshes a token only after you opt in. The App Store build runs + sandboxed; the direct build carries Apple's notarization and updates through Sparkle. diff --git a/website/hugo.toml b/website/hugo.toml new file mode 100644 index 0000000..8f4f470 --- /dev/null +++ b/website/hugo.toml @@ -0,0 +1,100 @@ +baseURL = "https://token-menu-bar-macos.readthedocs.io/en/latest/" +locale = "en-US" +defaultContentLanguage = "en" +title = "Token Menu Bar" +enableRobotsTXT = true +disableHugoGeneratorInject = true +disableKinds = ["taxonomy", "term", "rss"] +timeout = "60s" + +[params] +description = """ +Claude, Codex, Gemini, Cursor and GitHub Copilot plan usage in the macOS menu bar: session and weekly \ +windows, reset countdowns, pace projections, history and widgets.\ +""" +keywords = """ +claude usage,codex usage,menu bar,macos,claude code limits,copilot quota,cursor usage,gemini quota\ +""" +tagline = "Your AI coding plan limits, one glance away" +repository = "https://github.com/tox-dev/token-menu-bar-macos" +latestRelease = "https://github.com/tox-dev/token-menu-bar-macos/releases/latest" +minimumOS = "macOS 14" +license = "MIT" +author = "Bernát Gábor" +brandGradientStart = "#4C3BE0" +brandGradientEnd = "#9A6BFF" +themeColor = "#5A46E8" +mermaidVersion = "11.17.2" + +[[menu.main]] +name = "Get started" +url = "/start/" +params.icon = "M5 3l14 9-14 9V3z" +weight = 1 + +[[menu.main]] +name = "Reference" +url = "/reference/" +params.icon = "M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5zM8 2v20" +weight = 3 + +[[menu.main]] +name = "Explanation" +url = "/explanation/" +params.icon = "M12 17h.01M12 13a3 3 0 1 0-3-3M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" +weight = 4 + +[[menu.main]] +name = "Troubleshooting" +url = "/troubleshooting/" +params.icon = "M12 9v4M12 17h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" +weight = 5 + +[[menu.main]] +name = "Contributing" +url = "/contributing/" +params.icon = "M8 6l-6 6 6 6M16 6l6 6-6 6" +weight = 6 + +[outputs] +home = ["HTML", "llms", "webmanifest"] +page = ["HTML", "md"] +section = ["HTML", "md"] + +# Format defined at llmstxt.org. +[outputFormats.llms] +mediaType = "text/plain" +baseName = "llms" +isPlainText = true +rel = "alternate" + +# The icons partial links this on every page, so it has to exist. +[outputFormats.webmanifest] +mediaType = "application/manifest+json" +baseName = "site" +isPlainText = true +notAlternative = true + +[outputFormats.md] +mediaType = "text/markdown" +isPlainText = true +rel = "alternate" + +[markup.goldmark.renderer] +unsafe = true + +[markup.tableOfContents] +startLevel = 2 +endLevel = 3 + +[imaging] +quality = 92 +resampleFilter = "Lanczos" + +# "drawing" keeps the flat panels and hairlines of a UI screenshot crisp; "picture" softens them. +[imaging.webp] +quality = 92 +hint = "drawing" + +[minify] +minifyOutput = true diff --git a/website/layouts/404.html b/website/layouts/404.html new file mode 100644 index 0000000..337dc8c --- /dev/null +++ b/website/layouts/404.html @@ -0,0 +1,13 @@ +{{ define "title" }}Page not found · {{ .Site.Title }}{{ end }} +{{ define "main" }} +
+
+

404

+

That page does not exist.

+ +
+
+{{ end }} diff --git a/website/layouts/_default/list.md.md b/website/layouts/_default/list.md.md new file mode 100644 index 0000000..0f03812 --- /dev/null +++ b/website/layouts/_default/list.md.md @@ -0,0 +1,9 @@ +# {{ .Title }} +{{ with .Description }} +> {{ . }} +{{ end }} +{{ .RawContent }} + +--- + +{{ .Site.Title }}: {{ .Site.Params.repository }} diff --git a/website/layouts/_default/single.md.md b/website/layouts/_default/single.md.md new file mode 100644 index 0000000..0f03812 --- /dev/null +++ b/website/layouts/_default/single.md.md @@ -0,0 +1,9 @@ +# {{ .Title }} +{{ with .Description }} +> {{ . }} +{{ end }} +{{ .RawContent }} + +--- + +{{ .Site.Title }}: {{ .Site.Params.repository }} diff --git a/website/layouts/_default/sitemap.xml b/website/layouts/_default/sitemap.xml new file mode 100644 index 0000000..df7bc1e --- /dev/null +++ b/website/layouts/_default/sitemap.xml @@ -0,0 +1,14 @@ +{{ printf "" | safeHTML }} + + {{- range .Data.Pages }} + {{- if and (ne .Kind "taxonomy") (ne .Kind "term") (ne .Kind "404") }} + + {{ .Permalink }} + {{- if not .Lastmod.IsZero }} + {{ .Lastmod.Format "2006-01-02T15:04:05-07:00" | safeHTML }} + {{- end }} + {{ if .IsHome }}1.0{{ else if eq .Kind "page" }}0.8{{ else }}0.5{{ end }} + + {{- end }} + {{- end }} + diff --git a/website/layouts/_markup/render-codeblock-mermaid.html b/website/layouts/_markup/render-codeblock-mermaid.html new file mode 100644 index 0000000..9d25f75 --- /dev/null +++ b/website/layouts/_markup/render-codeblock-mermaid.html @@ -0,0 +1,4 @@ +{{- .Page.Store.Set "hasMermaid" true -}} +{{- /* A rendered diagram is wider than a phone, so the block scrolls; tabindex makes that reachable from a + keyboard. Mermaid reads the accTitle and accDescr lines in the source into the SVG's title and desc. */ -}} +
{{- .Inner | htmlEscape | safeHTML -}}
diff --git a/website/layouts/_markup/render-heading.html b/website/layouts/_markup/render-heading.html new file mode 100644 index 0000000..425de8f --- /dev/null +++ b/website/layouts/_markup/render-heading.html @@ -0,0 +1,8 @@ +{{- /* Keep one h1 per page (the title): demote an in-content h1 to h2 and leave deeper levels alone. */ -}} +{{- $level := .Level -}} +{{- if le .Level 1 }}{{ $level = 2 }}{{ end -}} + + {{- .Text | safeHTML -}} + {{/* Naming every anchor "Link to this section" leaves a screen reader with a list of identical links */}} + # + diff --git a/website/layouts/_markup/render-image.html b/website/layouts/_markup/render-image.html new file mode 100644 index 0000000..27716bf --- /dev/null +++ b/website/layouts/_markup/render-image.html @@ -0,0 +1,23 @@ +{{- $u := urls.Parse .Destination -}} +{{- $src := $u.String -}} +{{- $img := false -}} +{{- if not $u.IsAbs -}} + {{- with or (.Page.Resources.Get $u.Path) (resources.Get $u.Path) -}} + {{- $img = . -}} + {{- $src = .RelPermalink -}} + {{- end -}} +{{- end -}} +{{- $alt := .Text | default .Title -}} +{{- if $img -}} + {{- $set := slice -}} + {{- range $w := (slice 600 900 1400) -}} + {{- if ge $img.Width $w -}} + {{- $set = $set | append (printf "%s %dw" ($img.Resize (printf "%dx webp" $w)).RelPermalink $w) -}} + {{- end -}} + {{- end -}} + {{ $alt }} +{{- else -}} + {{ $alt }} +{{- end -}} diff --git a/website/layouts/_markup/render-link.html b/website/layouts/_markup/render-link.html new file mode 100644 index 0000000..3fa22cb --- /dev/null +++ b/website/layouts/_markup/render-link.html @@ -0,0 +1,7 @@ +{{- $u := urls.Parse .Destination -}} +{{- $host := (urls.Parse site.BaseURL).Hostname -}} +{{- $external := and $u.IsAbs (ne $u.Hostname "") (ne $u.Hostname $host) -}} +{{ .Text | safeHTML }} diff --git a/website/layouts/_markup/render-table.html b/website/layouts/_markup/render-table.html new file mode 100644 index 0000000..31a8cf4 --- /dev/null +++ b/website/layouts/_markup/render-table.html @@ -0,0 +1,32 @@ +{{- /* These tables carry file paths and endpoint URLs that cannot wrap, so they are wider than a phone. + The wrapper scrolls them on their own; tabindex and the label make that reachable from a keyboard. */ -}} +{{- $label := "Table" -}} +{{- with .THead -}} + {{- with (index . 0) -}} + {{- with (index . 0) -}} + {{- with (.Text | plainify | strings.TrimSpace) }}{{ $label = printf "%s table" . }}{{ end -}} + {{- end -}} + {{- end -}} +{{- end -}} +
+ + + {{- range .THead }} + + {{- range . }} + + {{- end }} + + {{- end }} + + + {{- range .TBody }} + + {{- range . }} + {{ .Text }} + {{- end }} + + {{- end }} + +
{{ .Text }}
+
diff --git a/website/layouts/_partials/footer.html b/website/layouts/_partials/footer.html new file mode 100644 index 0000000..6007c5a --- /dev/null +++ b/website/layouts/_partials/footer.html @@ -0,0 +1,14 @@ + diff --git a/website/layouts/_partials/head.html b/website/layouts/_partials/head.html new file mode 100644 index 0000000..559aa4f --- /dev/null +++ b/website/layouts/_partials/head.html @@ -0,0 +1,10 @@ +{{ partial "head/meta.html" . }} + +{{ partialCached "head/styles.html" . }} +{{ partialCached "head/icons.html" . }} +{{ partial "head/schema.html" . }} + +{{ range .AlternativeOutputFormats -}} + {{ printf `` .Rel .MediaType.Type .RelPermalink $.Site.Title + | safeHTML }} +{{ end -}} diff --git a/website/layouts/_partials/head/icons.html b/website/layouts/_partials/head/icons.html new file mode 100644 index 0000000..8316fc9 --- /dev/null +++ b/website/layouts/_partials/head/icons.html @@ -0,0 +1,5 @@ + + + + + diff --git a/website/layouts/_partials/head/meta.html b/website/layouts/_partials/head/meta.html new file mode 100644 index 0000000..0a87b60 --- /dev/null +++ b/website/layouts/_partials/head/meta.html @@ -0,0 +1,31 @@ +{{- $description := .Description | default .Site.Params.description -}} +{{- $image := printf "%sbrand/og.png" .Site.BaseURL -}} + + + + + + + + + +{{- /* GitHub Pages cannot send response headers, so the CSP is delivered as meta. The site loads no + third-party scripts, fonts, frames or beacons at all, which keeps this policy this tight. */ -}} +{{- $csp := slice + "default-src 'self'" "script-src 'self' 'unsafe-inline'" "style-src 'self' 'unsafe-inline'" + "img-src 'self' data:" "font-src 'self'" "connect-src 'self'" "frame-src 'none'" "base-uri 'self'" + "form-action 'none'" "object-src 'none'" -}} + + + + + + + + + + + + + diff --git a/website/layouts/_partials/head/schema.html b/website/layouts/_partials/head/schema.html new file mode 100644 index 0000000..e8117e8 --- /dev/null +++ b/website/layouts/_partials/head/schema.html @@ -0,0 +1,67 @@ +{{- /* A Go dict through jsonify, so no field needs hand-escaping. */ -}} +{{- $author := dict + "@type" "Person" + "name" .Site.Params.author + "url" "https://bernat.tech" +-}} + +{{- if .IsHome }} + + +{{- else }} + + +{{- end }} diff --git a/website/layouts/_partials/head/styles.html b/website/layouts/_partials/head/styles.html new file mode 100644 index 0000000..1c5b449 --- /dev/null +++ b/website/layouts/_partials/head/styles.html @@ -0,0 +1,11 @@ +{{- /* Dev serves the raw stylesheet for fast rebuilds; production minifies, fingerprints and pins it + with a subresource-integrity hash. Plain CSS keeps the Hugo binary as the whole toolchain. */ -}} +{{ if hugo.IsServer }} + {{ with resources.Get "css/site.css" }} + + {{ end }} +{{ else }} + {{ with resources.Get "css/site.css" | resources.Minify | resources.Fingerprint }} + + {{ end }} +{{ end }} diff --git a/website/layouts/_partials/header.html b/website/layouts/_partials/header.html new file mode 100644 index 0000000..446977b --- /dev/null +++ b/website/layouts/_partials/header.html @@ -0,0 +1,37 @@ +
+
+ + + {{ .Site.Title }} + + + {{/* Outside the nav: a theme switch is not navigation, and on a phone the links scroll under a toggle + that stays put. */}} + +
+
diff --git a/website/layouts/_partials/mermaid.html b/website/layouts/_partials/mermaid.html new file mode 100644 index 0000000..561039d --- /dev/null +++ b/website/layouts/_partials/mermaid.html @@ -0,0 +1,7 @@ +{{/* Only the pages that hold a diagram pay for the renderer, and it ships from this origin rather than a CDN. */}} +{{ $url := printf "https://cdn.jsdelivr.net/npm/mermaid@%s/dist/mermaid.min.js" .Site.Params.mermaidVersion }} +{{ with resources.GetRemote $url }} + {{ $script := . | resources.Copy "js/mermaid.js" | resources.Fingerprint }} + +{{ end }} + diff --git a/website/layouts/_partials/shot-image.html b/website/layouts/_partials/shot-image.html new file mode 100644 index 0000000..de943d3 --- /dev/null +++ b/website/layouts/_partials/shot-image.html @@ -0,0 +1,21 @@ +{{- /* One screenshot, laid out at source width divided by scale, with a candidate set a phone can pick from: + the full-resolution file is worth megabytes on a 390px viewport. Takes image, scale, alt, class, + and optional loading and fetchpriority. */ -}} +{{- $img := .image -}} +{{- $scale := .scale -}} +{{- $display := int (div $img.Width $scale) -}} +{{- $cap := int (math.Min $img.Width (mul $display 2)) -}} +{{- $widths := slice -}} +{{- range $w := (slice 400 800 $display (mul $display 2)) -}} + {{- $w = int (math.Min $w $cap) -}} + {{- if not (in $widths $w) }}{{ $widths = $widths | append $w }}{{ end -}} +{{- end -}} +{{- $set := slice -}} +{{- range $w := (sort $widths) -}} + {{- $set = $set | append (printf "%s %dw" ($img.Resize (printf "%dx webp" $w)).RelPermalink $w) -}} +{{- end -}} +{{ .alt }} diff --git a/website/layouts/_shortcodes/callout.html b/website/layouts/_shortcodes/callout.html new file mode 100644 index 0000000..7096f4e --- /dev/null +++ b/website/layouts/_shortcodes/callout.html @@ -0,0 +1,5 @@ +{{- $kind := .Get "kind" | default "note" -}} + diff --git a/website/layouts/_shortcodes/shot.html b/website/layouts/_shortcodes/shot.html new file mode 100644 index 0000000..3fb8691 --- /dev/null +++ b/website/layouts/_shortcodes/shot.html @@ -0,0 +1,13 @@ +{{- $name := .Get "name" -}} +{{- $alt := .Get "alt" | default "" -}} +{{- $scale := int (.Get "scale" | default 2) -}} +
+ {{- range $variant := (slice "light" "dark") -}} + {{- with resources.Get (printf "images/%s-%s.webp" $name $variant) -}} + {{- partial "shot-image.html" (dict + "image" . "scale" $scale "alt" $alt "loading" "lazy" + "class" (printf "shot only-%s" $variant)) -}} + {{- end -}} + {{- end -}} + {{- with .Get "caption" }}
{{ . }}
{{ end -}} +
diff --git a/website/layouts/baseof.html b/website/layouts/baseof.html new file mode 100644 index 0000000..57c1bf5 --- /dev/null +++ b/website/layouts/baseof.html @@ -0,0 +1,50 @@ + + + + + + {{- block "title" . -}} + {{ if .IsHome }}{{ .Site.Title }}: {{ .Site.Params.tagline }}{{ else }}{{ .Title }} · {{ .Site.Title }}{{ end }} + {{- end -}} + + {{ partial "head.html" . }} + + + + {{/* Must stay inline and blocking, ahead of any markup: theme.js is deferred, so a reader whose stored + choice differs from their OS preference would paint the wrong theme first and watch it snap over. */}} + + + + {{ partial "header.html" . }} + +
+ {{ block "main" . }}{{ end }} +
+ + {{ partial "footer.html" . }} + + {{ if or (.Store.Get "hasMermaid") (strings.Contains .RawContent "```mermaid") }} + {{ partial "mermaid.html" . }} + {{ end }} + + {{ if hugo.IsServer }} + {{ with resources.Get "js/theme.js" }} + + {{ end }} + {{ else }} + {{ with resources.Get "js/theme.js" | resources.Minify | resources.Fingerprint }} + + {{ end }} + {{ end }} + + + diff --git a/website/layouts/home.html b/website/layouts/home.html new file mode 100644 index 0000000..eff28ce --- /dev/null +++ b/website/layouts/home.html @@ -0,0 +1,121 @@ +{{ define "main" }} +
+
+
+ {{ .Site.Params.minimumOS }} · {{ .Site.Params.license }} +

{{ .Site.Params.tagline }}

+

{{ .Site.Params.description }}

+ +

+ Reads the tokens the CLIs keep on your Mac. It holds no account, sends no telemetry, and leaves your machine + for the vendors' own endpoints alone. +

+
+
+ {{ range $variant := (slice "light" "dark") }} + {{ with resources.Get (printf "images/menubar-%s.webp" $variant) }} + {{ partial "shot-image.html" (dict + "image" . "scale" 3 "fetchpriority" "high" + "alt" "Menu bar showing Claude and Codex usage cells" + "class" (printf "shot shot-bar only-%s" $variant)) }} + {{ end }} + {{ end }} +
+
+
+ +
+
+ Tracks + {{ range $p := (slice + (dict "id" "claude" "name" "Claude") (dict "id" "codex" "name" "Codex") + (dict "id" "gemini" "name" "Gemini CLI") (dict "id" "cursor" "name" "Cursor") + (dict "id" "copilot" "name" "GitHub Copilot")) }} + + + {{ $p.name }} + + {{ end }} + + under pace + ahead + spent + +
+
+ +
+
+
+

Everything the vendor dashboards show, without opening them

+

One cell per window in the menu bar, the full picture a click away.

+
+
+ {{ range hugo.Data.features }} +
+
+ +
+

{{ .title }}

+

{{ .body }}

+
+ {{ end }} +
+
+
+ +
+
+
+

One popover, three tabs

+

It sizes itself to the screen and holds its position when you switch tabs.

+
+
+ {{ $shots := slice + (dict "id" "usage" "name" "Usage" "alt" "Usage tab with provider cards" + "caption" "Each window with percent, reset countdown and pace.") + (dict "id" "history" "name" "History" "alt" "History tab with usage charts" + "caption" "Sixty days of samples, reset cliffs and vendor analytics.") + (dict "id" "settings" "name" "Settings" "alt" "Settings tab" + "caption" "Menu bar format, providers, data, notifications and the log.") }} + {{ range $index, $shot := $shots }} + + {{ end }} +
+ {{ range $shots }}{{ end }} +
+
+ {{ range $shots }} +
+ {{ $shot := . }} +
+ {{ range $variant := (slice "light" "dark") }} + {{ with resources.Get (printf "images/popover-%s-%s.webp" $shot.id $variant) }} + {{ partial "shot-image.html" (dict + "image" . "scale" 2 "alt" $shot.alt "loading" "lazy" + "class" (printf "shot only-%s" $variant)) }} + {{ end }} + {{ end }} +
+
{{ .caption }}
+
+ {{ end }} +
+
+
+
+ +{{ with .Content }} +
+
{{ . }}
+
+{{ end }} +{{ end }} diff --git a/website/layouts/home.llms.txt b/website/layouts/home.llms.txt new file mode 100644 index 0000000..9901cdd --- /dev/null +++ b/website/layouts/home.llms.txt @@ -0,0 +1,29 @@ +# {{ .Site.Title }} + +> {{ .Site.Params.description }} + +{{ .Site.Title }} is a {{ .Site.Params.license }}-licensed {{ .Site.Params.minimumOS }} menu bar app +by {{ .Site.Params.author }}. +It reads the OAuth tokens the Claude Code, Codex, Gemini, Cursor and GitHub Copilot clients keep on the machine, and +shows how much of each plan's quota is left. Source: {{ .Site.Params.repository }} + +Append `index.md` to any page URL, or follow the links below, to fetch that page as clean markdown. + +## Pages +{{ range (slice "/start" "/reference/interface" "/reference/settings" "/explanation" "/troubleshooting" "/contributing") }} +{{- with $.Site.GetPage . }} +- [{{ .Title }}]({{ (.OutputFormats.Get "md").Permalink }}){{ with .Description }}: {{ . }}{{ end }} +{{- end }}{{ end }} + +## Facts +{{ range hugo.Data.features }} +- {{ .title }}: {{ .body | plainify | chomp }} +{{- end }} + +## Where the data comes from + +- Claude: Keychain item `Claude Code-credentials`, `api.anthropic.com/api/oauth/usage` +- Codex: `~/.codex/auth.json`, `chatgpt.com/backend-api/wham/*` +- Gemini: `~/.gemini/oauth_creds.json`, `cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota` +- Cursor: Cursor's `state.vscdb` session, `cursor.com/api/usage-summary` +- Copilot: `~/.config/github-copilot/hosts.json`, `api.github.com/copilot_internal/user` diff --git a/website/layouts/home.webmanifest b/website/layouts/home.webmanifest new file mode 100644 index 0000000..cc26087 --- /dev/null +++ b/website/layouts/home.webmanifest @@ -0,0 +1,14 @@ +{ + "name": {{ site.Title | jsonify }}, + "short_name": {{ site.Title | jsonify }}, + "description": {{ site.Params.description | default "" | jsonify }}, + "start_url": {{ "" | relURL | jsonify }}, + "display": "standalone", + "background_color": {{ site.Params.themeColor | jsonify }}, + "theme_color": {{ site.Params.themeColor | jsonify }}, + "icons": [ + {"src": {{ "brand/icon.svg" | relURL | jsonify }}, "sizes": "any", "type": "image/svg+xml"}, + {"src": {{ "brand/icon-32.png" | relURL | jsonify }}, "sizes": "32x32", "type": "image/png"}, + {"src": {{ "brand/icon-180.png" | relURL | jsonify }}, "sizes": "180x180", "type": "image/png"} + ] +} diff --git a/website/layouts/list.html b/website/layouts/list.html new file mode 100644 index 0000000..bd03416 --- /dev/null +++ b/website/layouts/list.html @@ -0,0 +1,23 @@ +{{ define "main" }} +
+
+

{{ .Title }}

+ {{ with .Description }}

{{ . }}

{{ end }} +
{{ .Content }}
+ +
+
+{{ end }} diff --git a/website/layouts/robots.txt b/website/layouts/robots.txt new file mode 100644 index 0000000..6b4c001 --- /dev/null +++ b/website/layouts/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: {{ "sitemap.xml" | absURL }} diff --git a/website/layouts/single.html b/website/layouts/single.html new file mode 100644 index 0000000..446d110 --- /dev/null +++ b/website/layouts/single.html @@ -0,0 +1,16 @@ +{{ define "main" }} +
+
+ {{ with .Parent }} + {{ if not .IsHome }} + + {{ end }} + {{ end }} +

{{ .Title }}

+ {{ with .Description }}

{{ . }}

{{ end }} +
+ {{ .Content }} +
+
+
+{{ end }} diff --git a/website/static/brand/glyph/claude.svg b/website/static/brand/glyph/claude.svg new file mode 100644 index 0000000..d05bad8 --- /dev/null +++ b/website/static/brand/glyph/claude.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/static/brand/glyph/codex.svg b/website/static/brand/glyph/codex.svg new file mode 100644 index 0000000..4c54987 --- /dev/null +++ b/website/static/brand/glyph/codex.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/website/static/brand/glyph/copilot.svg b/website/static/brand/glyph/copilot.svg new file mode 100644 index 0000000..fc5dcc9 --- /dev/null +++ b/website/static/brand/glyph/copilot.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/website/static/brand/glyph/cursor.svg b/website/static/brand/glyph/cursor.svg new file mode 100644 index 0000000..7749631 --- /dev/null +++ b/website/static/brand/glyph/cursor.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/website/static/brand/glyph/gemini.svg b/website/static/brand/glyph/gemini.svg new file mode 100644 index 0000000..f4bc541 --- /dev/null +++ b/website/static/brand/glyph/gemini.svg @@ -0,0 +1,4 @@ + + + + diff --git a/website/static/brand/icon-180.png b/website/static/brand/icon-180.png new file mode 100644 index 0000000..55f05c7 Binary files /dev/null and b/website/static/brand/icon-180.png differ diff --git a/website/static/brand/icon-32.png b/website/static/brand/icon-32.png new file mode 100644 index 0000000..b719ffc Binary files /dev/null and b/website/static/brand/icon-32.png differ diff --git a/website/static/brand/icon.svg b/website/static/brand/icon.svg new file mode 100644 index 0000000..307c0e5 --- /dev/null +++ b/website/static/brand/icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/website/static/brand/lockup-stacked.svg b/website/static/brand/lockup-stacked.svg new file mode 100644 index 0000000..7023710 --- /dev/null +++ b/website/static/brand/lockup-stacked.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + Token + Menu Bar + diff --git a/website/static/brand/lockup.svg b/website/static/brand/lockup.svg new file mode 100644 index 0000000..a092c0c --- /dev/null +++ b/website/static/brand/lockup.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + Token + Menu Bar + diff --git a/website/static/brand/mark-mono.svg b/website/static/brand/mark-mono.svg new file mode 100644 index 0000000..55c439d --- /dev/null +++ b/website/static/brand/mark-mono.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/website/static/brand/mark.svg b/website/static/brand/mark.svg new file mode 100644 index 0000000..51211d8 --- /dev/null +++ b/website/static/brand/mark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/website/static/brand/og.png b/website/static/brand/og.png new file mode 100644 index 0000000..c03060e Binary files /dev/null and b/website/static/brand/og.png differ diff --git a/website/static/brand/seal.svg b/website/static/brand/seal.svg new file mode 100644 index 0000000..1b68a3d --- /dev/null +++ b/website/static/brand/seal.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + TOKEN MENU BAR · macOS + + diff --git a/website/static/humans.txt b/website/static/humans.txt new file mode 100644 index 0000000..d5bf094 --- /dev/null +++ b/website/static/humans.txt @@ -0,0 +1,8 @@ +/* TEAM */ +Developer: Bernát Gábor +Site: https://bernat.tech + +/* SITE */ +Standards: HTML5, CSS3 +Components: Hugo, GitHub Actions, GitHub Pages +Software: Swift 6.2, SwiftUI, AppKit