From 06ed98c0021df578083fcbc1007bac287bb2fe60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 1 Sep 2026 19:12:36 -0700 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20feat(app):=20build=20Token=20Me?= =?UTF-8?q?nu=20Bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prototype moved on tab switches and hid content behind scrolling. Provider work repeated during redraws. Anchor the popover to the status item. Keep tab views alive and cache presenter results. Isolate tests from credentials and HTTP, then verify each distribution on macOS 14 through 27. --- .github/workflows/ci.yml | 365 +++++ .github/workflows/docs.yml | 37 + .github/workflows/prepare-release.yml | 97 ++ .github/workflows/release.yml | 303 ++++ .gitignore | 12 + .pre-commit-config.yaml | 98 ++ .readthedocs.yaml | 12 + .swift-format | 23 + App/.gitignore | 3 + App/AppStore.entitlements | 18 + .../AppIcon.appiconset/Contents.json | 68 + .../AppIcon.appiconset/icon_128x128.png | Bin 0 -> 6027 bytes .../AppIcon.appiconset/icon_128x128@2x.png | Bin 0 -> 14743 bytes .../AppIcon.appiconset/icon_16x16.png | Bin 0 -> 542 bytes .../AppIcon.appiconset/icon_16x16@2x.png | Bin 0 -> 1220 bytes .../AppIcon.appiconset/icon_256x256.png | Bin 0 -> 14743 bytes .../AppIcon.appiconset/icon_256x256@2x.png | Bin 0 -> 76876 bytes .../AppIcon.appiconset/icon_32x32.png | Bin 0 -> 1220 bytes .../AppIcon.appiconset/icon_32x32@2x.png | Bin 0 -> 2799 bytes .../AppIcon.appiconset/icon_512x512.png | Bin 0 -> 76876 bytes .../AppIcon.appiconset/icon_512x512@2x.png | Bin 0 -> 286036 bytes App/Assets.xcassets/Contents.json | 6 + App/Direct.entitlements | 12 + App/Info-AppStore.plist | 44 + App/Info-Homebrew.plist | 44 + App/PrivacyInfo.xcprivacy | 31 + App/Sources/SparkleUpdater.swift | 22 + App/TokenMenuBar.xctestplan | 29 + App/UITests/LiveControlAuditUITests.swift | 1340 +++++++++++++++++ App/UITests/TabSwitchBenchmarkUITests.swift | 386 +++++ .../TokenMenuBarApplicationUITests.swift | 134 ++ App/UITests/VerificationApplication.swift | 160 ++ App/Widget/Sources/WidgetBundle.swift | 10 + App/Widget/Widget.entitlements | 12 + App/project.yml | 248 +++ CONTRIBUTING.md | 16 + Casks/token-menu-bar.rb | 26 + LICENSE | 21 + Package.swift | 38 + README.md | 47 +- SECURITY.md | 65 + Scripts/appcast.sh | 39 + Scripts/build-app-store.sh | 49 + Scripts/build-direct.sh | 49 + Scripts/build-homebrew.sh | 7 + Scripts/bundle-dev.sh | 72 + Scripts/check-test-isolation.sh | 11 + Scripts/check-toolchain.sh | 57 + Scripts/coverage.sh | 71 + Scripts/import-certificate.sh | 24 + Scripts/notarize.sh | 30 + Scripts/optimize-png.py | 131 ++ Scripts/package-direct.sh | 56 + Scripts/package-homebrew.sh | 6 + Scripts/screenshots.sh | 37 + Scripts/select-xcode.sh | 54 + Scripts/stamp-version.sh | 13 + Scripts/update-cask.sh | 10 + Scripts/verify-app-bundle.sh | 81 + Scripts/verify-build-settings.sh | 28 + Scripts/verify-deployment-targets.sh | 35 + Scripts/version.sh | 58 + Sources/TokenMenuBar/Updater.swift | 14 + Sources/TokenMenuBar/main.swift | 44 + Sources/TokenMenuBarCore/Brand.swift | 80 + Sources/TokenMenuBarCore/Clock.swift | 52 + .../Credentials/ClaudeCredentials.swift | 251 +++ .../Credentials/CodexCredentials.swift | 292 ++++ .../Credentials/CopilotCredentials.swift | 320 ++++ .../Credentials/CredentialState.swift | 117 ++ .../Credentials/CursorCredentials.swift | 154 ++ .../Credentials/GeminiCredentials.swift | 238 +++ .../Credentials/KeychainCredentialData.swift | 35 + .../Credentials/SystemKeychain.swift | 37 + Sources/TokenMenuBarCore/Demo/DemoData.swift | 329 ++++ Sources/TokenMenuBarCore/Diagnostics.swift | 166 ++ Sources/TokenMenuBarCore/ExportCommand.swift | 23 + Sources/TokenMenuBarCore/Formatting.swift | 106 ++ Sources/TokenMenuBarCore/HTTP/APIClient.swift | 207 +++ .../HTTP/SystemHTTPTransport.swift | 7 + .../History/ChartPipeline.swift | 605 ++++++++ .../History/HistoryModels.swift | 279 ++++ Sources/TokenMenuBarCore/History/SQLite.swift | 160 ++ .../History/UsageHistoryStore.swift | 607 ++++++++ .../TokenMenuBarCore/InterfaceTokens.swift | 90 ++ Sources/TokenMenuBarCore/JSONValue.swift | 99 ++ Sources/TokenMenuBarCore/LaunchAtLogin.swift | 76 + Sources/TokenMenuBarCore/LaunchPolicy.swift | 104 ++ .../Logging/DiagnosticEvent.swift | 521 +++++++ .../TokenMenuBarCore/Logging/LogBuffer.swift | 767 ++++++++++ .../TokenMenuBarCore/Logging/LogQuery.swift | 127 ++ .../TokenMenuBarCore/Models/Analytics.swift | 218 +++ .../Models/FetchOutcome.swift | 119 ++ .../Models/ProviderSnapshot.swift | 203 +++ .../TokenMenuBarCore/Models/QuotaWindow.swift | 115 ++ .../Notifications/NotificationPlanner.swift | 125 ++ Sources/TokenMenuBarCore/Pace.swift | 105 ++ Sources/TokenMenuBarCore/PanelMaterial.swift | 21 + Sources/TokenMenuBarCore/PlatformDesign.swift | 5 + .../TokenMenuBarCore/PopoverGeometry.swift | 172 +++ .../Presenters/HistoryPresenter.swift | 649 ++++++++ .../Presenters/UsagePresenter.swift | 464 ++++++ .../ProcessPerformanceSnapshot.swift | 34 + .../TokenMenuBarCore/ProviderDiscovery.swift | 80 + Sources/TokenMenuBarCore/ProviderID.swift | 44 + Sources/TokenMenuBarCore/ProviderSetup.swift | 386 +++++ .../Providers/Claude/ClaudeAPI.swift | 359 +++++ .../Providers/Claude/ClaudeProvider.swift | 210 +++ .../Claude/ClaudeTranscriptReader.swift | 977 ++++++++++++ .../Providers/Codex/CodexAPI.swift | 438 ++++++ .../Codex/CodexAnalyticsWatermarkStore.swift | 116 ++ .../Providers/Codex/CodexProvider.swift | 391 +++++ .../Providers/Codex/CodexRolloutReader.swift | 393 +++++ .../Providers/Copilot/CopilotAPI.swift | 100 ++ .../Providers/Copilot/CopilotProvider.swift | 78 + .../Providers/Cursor/CursorAPI.swift | 126 ++ .../Providers/Cursor/CursorProvider.swift | 129 ++ .../Providers/Gemini/GeminiAPI.swift | 138 ++ .../Providers/Gemini/GeminiOAuthConfig.swift | 60 + .../Providers/Gemini/GeminiProvider.swift | 229 +++ .../Providers/ProviderRegistryFactory.swift | 160 ++ .../Providers/UsageProvider.swift | 86 ++ .../TokenMenuBarCore/Refresh/AppState.swift | 311 ++++ .../Refresh/RefreshCoordinator.swift | 708 +++++++++ .../Refresh/SnapshotCache.swift | 40 + .../Refresh/SnapshotPersistence.swift | 169 +++ Sources/TokenMenuBarCore/SandboxAccess.swift | 92 ++ .../SecurityScopedAccess.swift | 136 ++ Sources/TokenMenuBarCore/Settings.swift | 309 ++++ .../SettingsPresentation.swift | 391 +++++ .../StatusBar/AdaptiveWidth.swift | 64 + .../StatusBar/MenuCommand.swift | 41 + .../StatusBar/StatusItemModel.swift | 277 ++++ .../StatusBar/StatusTemplate.swift | 221 +++ .../StatusBar/UsageColor.swift | 44 + Sources/TokenMenuBarCore/TooltipPolicy.swift | 116 ++ .../TokenMenuBarCore/UsagePresentation.swift | 140 ++ .../Widgets/WidgetSnapshot.swift | 117 ++ .../Adapters/LaunchAtLoginService.swift | 27 + .../TokenMenuBarUI/Adapters/Notifier.swift | 74 + .../Adapters/PanelMaterialAdapter.swift | 46 + Sources/TokenMenuBarUI/AppController.swift | 874 +++++++++++ Sources/TokenMenuBarUI/AppDelegate.swift | 161 ++ Sources/TokenMenuBarUI/AppIcon.swift | 197 +++ Sources/TokenMenuBarUI/ExportRunner.swift | 110 ++ Sources/TokenMenuBarUI/LiveDependencies.swift | 447 ++++++ .../Popover/PopoverController.swift | 487 ++++++ .../ProviderMarks/ProviderMarkCatalog.swift | 99 ++ .../ProviderMarkImageLoader.swift | 36 + .../ProviderSettingsFocusRequest.swift | 12 + .../Resources/ProviderMarks/CUBE_2D_DARK.svg | 12 + .../Resources/ProviderMarks/CUBE_2D_LIGHT.svg | 12 + .../Resources/ProviderMarks/Claude.svg | 1 + .../Resources/ProviderMarks/GitHubCopilot.svg | 1 + .../Resources/ProviderMarks/GoogleGemini.svg | 1 + .../OpenAI-black-monoblossom.svg | 15 + .../OpenAI-white-monoblossom.svg | 15 + .../ProviderMarks/provider-marks.json | 122 ++ .../StatusItem/StatusItemController.swift | 460 ++++++ .../StatusItem/StatusItemRenderer.swift | 227 +++ .../Tooltip/TooltipContent.swift | 41 + .../TokenMenuBarUI/Tooltip/TooltipPanel.swift | 174 +++ .../Tooltip/TooltipPresenter.swift | 445 ++++++ .../Tooltip/TooltipTrackingView.swift | 155 ++ Sources/TokenMenuBarUI/UIActions.swift | 336 +++++ .../TokenMenuBarUI/Views/ChartOverlay.swift | 84 ++ Sources/TokenMenuBarUI/Views/Components.swift | 534 +++++++ .../TokenMenuBarUI/Views/CreditsView.swift | 24 + .../Views/EmptyHistoryView.swift | 13 + .../Views/HistoryInspector.swift | 174 +++ Sources/TokenMenuBarUI/Views/HistoryTab.swift | 385 +++++ Sources/TokenMenuBarUI/Views/HoverHelp.swift | 136 ++ .../TokenMenuBarUI/Views/LocalUsageView.swift | 29 + Sources/TokenMenuBarUI/Views/LogSection.swift | 255 ++++ .../TokenMenuBarUI/Views/LogTextView.swift | 176 +++ .../Views/PersistentTabContent.swift | 171 +++ .../TokenMenuBarUI/Views/PopoverFooter.swift | 57 + .../Views/ProviderCardView.swift | 251 +++ .../Views/ProviderMarkView.swift | 54 + Sources/TokenMenuBarUI/Views/RootView.swift | 132 ++ .../TokenMenuBarUI/Views/ScrollerStyler.swift | 47 + .../TokenMenuBarUI/Views/ScrollingTab.swift | 60 + .../TokenMenuBarUI/Views/SemanticStyles.swift | 64 + .../TokenMenuBarUI/Views/SettingsTab.swift | 1089 ++++++++++++++ Sources/TokenMenuBarUI/Views/SpendView.swift | 44 + .../TokenMenuBarUI/Views/StatusPreview.swift | 131 ++ Sources/TokenMenuBarUI/Views/TabPicker.swift | 56 + .../TokenMenuBarUI/Views/UpdatingBadge.swift | 12 + Sources/TokenMenuBarUI/Views/UsageChart.swift | 305 ++++ Sources/TokenMenuBarUI/Views/UsageTab.swift | 93 ++ .../TokenMenuBarUI/Views/WindowHelpView.swift | 25 + .../TokenMenuBarUI/Views/WindowRowView.swift | 149 ++ .../Views/WindowSelectionList.swift | 779 ++++++++++ Sources/TokenMenuBarUI/WorkspaceGlue.swift | 45 + Sources/TokenMenuBarWidgets/UsageWidget.swift | 148 ++ .../TokenMenuBarWidgets/WidgetKitGlue.swift | 34 + .../APIClientTests.swift | 169 +++ .../AdaptiveWidthTests.swift | 134 ++ .../AnalyticsCorrectionScopeTests.swift | 147 ++ Tests/TokenMenuBarCoreTests/BrandTests.swift | 45 + .../ChartPipelineTests.swift | 532 +++++++ .../ClaudeMapperTests.swift | 177 +++ .../ClaudeTranscriptReaderTests.swift | 466 ++++++ .../CodexAnalyticsRetentionTests.swift | 358 +++++ .../CodexMapperTests.swift | 291 ++++ .../CodexRolloutReaderTests.swift | 222 +++ .../TokenMenuBarCoreTests/CopilotTests.swift | 184 +++ .../CoreCoverageClosureTests.swift | 464 ++++++ .../CorrectedCoverageBehaviorTests.swift | 249 +++ ...ateSettingsPresentationBehaviorTests.swift | 29 + .../CoverageRefreshTests.swift | 164 ++ .../CredentialRefreshRaceTests.swift | 327 ++++ .../CredentialsTests.swift | 398 +++++ Tests/TokenMenuBarCoreTests/CursorTests.swift | 358 +++++ .../TokenMenuBarCoreTests/DemoDataTests.swift | 106 ++ .../DiagnosticsTests.swift | 183 +++ .../Fixtures/claude_keychain.json | 17 + .../Fixtures/claude_profile.json | 33 + .../Fixtures/claude_usage.json | 99 ++ .../Fixtures/codex_auth.json | 19 + .../Fixtures/codex_credit_events.json | 3 + .../Fixtures/codex_daily_code_review.json | 4 + .../Fixtures/codex_daily_plugins.json | 142 ++ .../Fixtures/codex_daily_skills.json | 640 ++++++++ .../Fixtures/codex_daily_token_usage.json | 1282 ++++++++++++++++ .../Fixtures/codex_daily_workspace_usage.json | 804 ++++++++++ .../Fixtures/codex_reset_credits.json | 7 + .../Fixtures/codex_usage.json | 64 + .../Fixtures/copilot_hosts.json | 11 + .../Fixtures/copilot_user.json | 40 + .../Fixtures/copilot_user_free.json | 25 + .../Fixtures/cursor_me.json | 7 + .../Fixtures/cursor_period_usage.json | 15 + .../Fixtures/cursor_usage_summary.json | 49 + .../Fixtures/gemini_creds.json | 7 + .../Fixtures/gemini_load_code_assist.json | 24 + .../Fixtures/gemini_quota.json | 31 + .../Fixtures/gemini_unsupported.json | 13 + Tests/TokenMenuBarCoreTests/FormatTests.swift | 88 ++ Tests/TokenMenuBarCoreTests/GeminiTests.swift | 344 +++++ .../HistoryClosureBehaviorCoverageTests.swift | 111 ++ .../HistoryCoverageBehaviorTests.swift | 77 + .../HistoryPresenterAnalyticsTests.swift | 185 +++ .../InterfaceTokensTests.swift | 39 + .../JSONValueTests.swift | 42 + .../LaunchPolicyTests.swift | 153 ++ .../LogBufferTests.swift | 563 +++++++ .../MenuCommandTests.swift | 27 + .../MissingCredentialHealthTests.swift | 47 + Tests/TokenMenuBarCoreTests/ModelTests.swift | 416 +++++ .../NotificationPlannerTests.swift | 105 ++ .../PaceComparisonTests.swift | 54 + Tests/TokenMenuBarCoreTests/PaceTests.swift | 68 + .../PanelMaterialTests.swift | 20 + .../PresenterTests.swift | 789 ++++++++++ .../ProcessPerformanceSnapshotTests.swift | 11 + .../ProviderActivationTests.swift | 315 ++++ .../ProviderNestedCoverageBehaviorTests.swift | 453 ++++++ .../ProviderRegistryFactoryTests.swift | 209 +++ .../ProviderSetupTests.swift | 531 +++++++ .../TokenMenuBarCoreTests/ProviderTests.swift | 550 +++++++ .../ReaderBackgroundWorkTests.swift | 136 ++ .../RefreshCoordinatorTests.swift | 828 ++++++++++ Tests/TokenMenuBarCoreTests/SQLiteTests.swift | 79 + .../SecurityScopedAccessTests.swift | 138 ++ .../SettingsPresentationTests.swift | 245 +++ .../TokenMenuBarCoreTests/SettingsTests.swift | 247 +++ .../ShutdownPolicyTests.swift | 16 + .../SnapshotPersistenceTests.swift | 96 ++ .../StatusTemplateTests.swift | 197 +++ .../Support/Fixtures.swift | 69 + .../Support/MemoryCredentialStore.swift | 63 + .../Support/MemoryKeychain.swift | 35 + .../Support/ScriptedProvider.swift | 96 ++ .../Support/StubTransport.swift | 75 + .../TooltipPolicyTests.swift | 121 ++ ...ranscriptReaderCoverageBehaviorTests.swift | 192 +++ .../UsageHistoryStoreTests.swift | 529 +++++++ .../WidgetSnapshotTests.swift | 49 + Tests/TokenMenuBarUITests/AdapterTests.swift | 75 + .../AppControllerTests.swift | 735 +++++++++ .../TokenMenuBarUITests/BrandIconTests.swift | 77 + .../ChartInteractionCoverageTests.swift | 178 +++ .../ComponentPolicyTests.swift | 118 ++ .../CoverageBehaviorTests.swift | 194 +++ .../CoverageClosureUIBehaviorTests.swift | 137 ++ .../CoverageGateComponentBehaviorTests.swift | 105 ++ ...overageGateProviderMarkFallbackTests.swift | 61 + ...CoverageGateSettingsInteractionTests.swift | 78 + ...rageGateWindowSelectionBehaviorTests.swift | 113 ++ .../DensityBudgetTests.swift | 39 + Tests/TokenMenuBarUITests/FeatureTests.swift | 298 ++++ .../HistoryAnalyticsRefreshTests.swift | 29 + ...toryViewClosureBehaviorCoverageTests.swift | 191 +++ .../InteractionTests.swift | 425 ++++++ .../LogSectionCoverageClosureTests.swift | 52 + .../LogTooltipCoverageTests.swift | 137 ++ .../PanelMaterialAdapterTests.swift | 29 + Tests/TokenMenuBarUITests/PopoverTests.swift | 609 ++++++++ .../ProviderMarkTests.swift | 185 +++ .../ResponsiveLayoutTests.swift | 45 + .../SettingsUIBehaviorCoverageTests.swift | 306 ++++ .../StartupClosureCoverageTests.swift | 342 +++++ .../StartupCoverageTests.swift | 339 +++++ .../TokenMenuBarUITests/StatusItemTests.swift | 514 +++++++ .../Support/NoNetworkTransport.swift | 9 + .../Support/UIFixtures.swift | 324 ++++ .../TooltipCoverageClosureTests.swift | 139 ++ Tests/TokenMenuBarUITests/TooltipTests.swift | 1269 ++++++++++++++++ Tests/TokenMenuBarUITests/ViewTests.swift | 869 +++++++++++ .../WidgetTests.swift | 107 ++ justfile | 164 ++ mise.lock | 127 ++ mise.toml | 10 + mockups/approved.html | 927 ++++++++++++ mockups/implementation-brief.md | 373 +++++ renovate.json | 37 + ruff.toml | 12 + website/.gitignore | 3 + website/assets/css/site.css | 925 ++++++++++++ website/assets/images/menubar-dark.webp | Bin 0 -> 13044 bytes website/assets/images/menubar-light.webp | Bin 0 -> 13888 bytes .../assets/images/popover-history-dark.webp | Bin 0 -> 292160 bytes .../assets/images/popover-history-light.webp | Bin 0 -> 284604 bytes .../assets/images/popover-settings-dark.webp | Bin 0 -> 148296 bytes .../assets/images/popover-settings-light.webp | Bin 0 -> 144058 bytes website/assets/images/popover-usage-dark.webp | Bin 0 -> 248010 bytes .../assets/images/popover-usage-light.webp | Bin 0 -> 247938 bytes website/assets/js/diagrams.js | 48 + website/assets/js/theme.js | 39 + website/content/_index.md | 17 + website/content/contributing/_index.md | 246 +++ website/content/explanation/_index.md | 38 + website/content/reference/_index.md | 5 + website/content/reference/interface.md | 93 ++ website/content/reference/settings.md | 69 + website/content/start/_index.md | 60 + website/content/troubleshooting/_index.md | 149 ++ website/data/features.yaml | 35 + website/hugo.toml | 100 ++ website/layouts/404.html | 13 + website/layouts/_default/list.md.md | 9 + website/layouts/_default/single.md.md | 9 + website/layouts/_default/sitemap.xml | 14 + .../_markup/render-codeblock-mermaid.html | 4 + website/layouts/_markup/render-heading.html | 8 + website/layouts/_markup/render-image.html | 23 + website/layouts/_markup/render-link.html | 7 + website/layouts/_markup/render-table.html | 32 + website/layouts/_partials/footer.html | 14 + website/layouts/_partials/head.html | 10 + website/layouts/_partials/head/icons.html | 5 + website/layouts/_partials/head/meta.html | 31 + website/layouts/_partials/head/schema.html | 67 + website/layouts/_partials/head/styles.html | 11 + website/layouts/_partials/header.html | 37 + website/layouts/_partials/mermaid.html | 7 + website/layouts/_partials/shot-image.html | 21 + website/layouts/_shortcodes/callout.html | 5 + website/layouts/_shortcodes/shot.html | 13 + website/layouts/baseof.html | 50 + website/layouts/home.html | 121 ++ website/layouts/home.llms.txt | 29 + website/layouts/home.webmanifest | 14 + website/layouts/list.html | 23 + website/layouts/robots.txt | 4 + website/layouts/single.html | 16 + website/static/brand/glyph/claude.svg | 3 + website/static/brand/glyph/codex.svg | 7 + website/static/brand/glyph/copilot.svg | 7 + website/static/brand/glyph/cursor.svg | 8 + website/static/brand/glyph/gemini.svg | 4 + website/static/brand/icon-180.png | Bin 0 -> 5403 bytes website/static/brand/icon-32.png | Bin 0 -> 1113 bytes website/static/brand/icon.svg | 17 + website/static/brand/lockup-stacked.svg | 23 + website/static/brand/lockup.svg | 21 + website/static/brand/mark-mono.svg | 13 + website/static/brand/mark.svg | 19 + website/static/brand/og.png | Bin 0 -> 176112 bytes website/static/brand/seal.svg | 24 + website/static/humans.txt | 8 + 382 files changed, 59703 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/prepare-release.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .readthedocs.yaml create mode 100644 .swift-format create mode 100644 App/.gitignore create mode 100644 App/AppStore.entitlements create mode 100644 App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png create mode 100644 App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png create mode 100644 App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png create mode 100644 App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png create mode 100644 App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png create mode 100644 App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png create mode 100644 App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png create mode 100644 App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png create mode 100644 App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png create mode 100644 App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png create mode 100644 App/Assets.xcassets/Contents.json create mode 100644 App/Direct.entitlements create mode 100644 App/Info-AppStore.plist create mode 100644 App/Info-Homebrew.plist create mode 100644 App/PrivacyInfo.xcprivacy create mode 100644 App/Sources/SparkleUpdater.swift create mode 100644 App/TokenMenuBar.xctestplan create mode 100644 App/UITests/LiveControlAuditUITests.swift create mode 100644 App/UITests/TabSwitchBenchmarkUITests.swift create mode 100644 App/UITests/TokenMenuBarApplicationUITests.swift create mode 100644 App/UITests/VerificationApplication.swift create mode 100644 App/Widget/Sources/WidgetBundle.swift create mode 100644 App/Widget/Widget.entitlements create mode 100644 App/project.yml create mode 100644 CONTRIBUTING.md create mode 100644 Casks/token-menu-bar.rb create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 SECURITY.md create mode 100755 Scripts/appcast.sh create mode 100755 Scripts/build-app-store.sh create mode 100755 Scripts/build-direct.sh create mode 100755 Scripts/build-homebrew.sh create mode 100755 Scripts/bundle-dev.sh create mode 100755 Scripts/check-test-isolation.sh create mode 100755 Scripts/check-toolchain.sh create mode 100755 Scripts/coverage.sh create mode 100755 Scripts/import-certificate.sh create mode 100755 Scripts/notarize.sh create mode 100755 Scripts/optimize-png.py create mode 100755 Scripts/package-direct.sh create mode 100755 Scripts/package-homebrew.sh create mode 100755 Scripts/screenshots.sh create mode 100755 Scripts/select-xcode.sh create mode 100755 Scripts/stamp-version.sh create mode 100755 Scripts/update-cask.sh create mode 100755 Scripts/verify-app-bundle.sh create mode 100755 Scripts/verify-build-settings.sh create mode 100755 Scripts/verify-deployment-targets.sh create mode 100755 Scripts/version.sh create mode 100644 Sources/TokenMenuBar/Updater.swift create mode 100644 Sources/TokenMenuBar/main.swift create mode 100644 Sources/TokenMenuBarCore/Brand.swift create mode 100644 Sources/TokenMenuBarCore/Clock.swift create mode 100644 Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift create mode 100644 Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift create mode 100644 Sources/TokenMenuBarCore/Credentials/CopilotCredentials.swift create mode 100644 Sources/TokenMenuBarCore/Credentials/CredentialState.swift create mode 100644 Sources/TokenMenuBarCore/Credentials/CursorCredentials.swift create mode 100644 Sources/TokenMenuBarCore/Credentials/GeminiCredentials.swift create mode 100644 Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift create mode 100644 Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift create mode 100644 Sources/TokenMenuBarCore/Demo/DemoData.swift create mode 100644 Sources/TokenMenuBarCore/Diagnostics.swift create mode 100644 Sources/TokenMenuBarCore/ExportCommand.swift create mode 100644 Sources/TokenMenuBarCore/Formatting.swift create mode 100644 Sources/TokenMenuBarCore/HTTP/APIClient.swift create mode 100644 Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift create mode 100644 Sources/TokenMenuBarCore/History/ChartPipeline.swift create mode 100644 Sources/TokenMenuBarCore/History/HistoryModels.swift create mode 100644 Sources/TokenMenuBarCore/History/SQLite.swift create mode 100644 Sources/TokenMenuBarCore/History/UsageHistoryStore.swift create mode 100644 Sources/TokenMenuBarCore/InterfaceTokens.swift create mode 100644 Sources/TokenMenuBarCore/JSONValue.swift create mode 100644 Sources/TokenMenuBarCore/LaunchAtLogin.swift create mode 100644 Sources/TokenMenuBarCore/LaunchPolicy.swift create mode 100644 Sources/TokenMenuBarCore/Logging/DiagnosticEvent.swift create mode 100644 Sources/TokenMenuBarCore/Logging/LogBuffer.swift create mode 100644 Sources/TokenMenuBarCore/Logging/LogQuery.swift create mode 100644 Sources/TokenMenuBarCore/Models/Analytics.swift create mode 100644 Sources/TokenMenuBarCore/Models/FetchOutcome.swift create mode 100644 Sources/TokenMenuBarCore/Models/ProviderSnapshot.swift create mode 100644 Sources/TokenMenuBarCore/Models/QuotaWindow.swift create mode 100644 Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift create mode 100644 Sources/TokenMenuBarCore/Pace.swift create mode 100644 Sources/TokenMenuBarCore/PanelMaterial.swift create mode 100644 Sources/TokenMenuBarCore/PlatformDesign.swift create mode 100644 Sources/TokenMenuBarCore/PopoverGeometry.swift create mode 100644 Sources/TokenMenuBarCore/Presenters/HistoryPresenter.swift create mode 100644 Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift create mode 100644 Sources/TokenMenuBarCore/ProcessPerformanceSnapshot.swift create mode 100644 Sources/TokenMenuBarCore/ProviderDiscovery.swift create mode 100644 Sources/TokenMenuBarCore/ProviderID.swift create mode 100644 Sources/TokenMenuBarCore/ProviderSetup.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Claude/ClaudeAPI.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Codex/CodexAnalyticsWatermarkStore.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Copilot/CopilotAPI.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Copilot/CopilotProvider.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Cursor/CursorProvider.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Gemini/GeminiAPI.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Gemini/GeminiOAuthConfig.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Gemini/GeminiProvider.swift create mode 100644 Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift create mode 100644 Sources/TokenMenuBarCore/Providers/UsageProvider.swift create mode 100644 Sources/TokenMenuBarCore/Refresh/AppState.swift create mode 100644 Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift create mode 100644 Sources/TokenMenuBarCore/Refresh/SnapshotCache.swift create mode 100644 Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift create mode 100644 Sources/TokenMenuBarCore/SandboxAccess.swift create mode 100644 Sources/TokenMenuBarCore/SecurityScopedAccess.swift create mode 100644 Sources/TokenMenuBarCore/Settings.swift create mode 100644 Sources/TokenMenuBarCore/SettingsPresentation.swift create mode 100644 Sources/TokenMenuBarCore/StatusBar/AdaptiveWidth.swift create mode 100644 Sources/TokenMenuBarCore/StatusBar/MenuCommand.swift create mode 100644 Sources/TokenMenuBarCore/StatusBar/StatusItemModel.swift create mode 100644 Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift create mode 100644 Sources/TokenMenuBarCore/StatusBar/UsageColor.swift create mode 100644 Sources/TokenMenuBarCore/TooltipPolicy.swift create mode 100644 Sources/TokenMenuBarCore/UsagePresentation.swift create mode 100644 Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift create mode 100644 Sources/TokenMenuBarUI/Adapters/LaunchAtLoginService.swift create mode 100644 Sources/TokenMenuBarUI/Adapters/Notifier.swift create mode 100644 Sources/TokenMenuBarUI/Adapters/PanelMaterialAdapter.swift create mode 100644 Sources/TokenMenuBarUI/AppController.swift create mode 100644 Sources/TokenMenuBarUI/AppDelegate.swift create mode 100644 Sources/TokenMenuBarUI/AppIcon.swift create mode 100644 Sources/TokenMenuBarUI/ExportRunner.swift create mode 100644 Sources/TokenMenuBarUI/LiveDependencies.swift create mode 100644 Sources/TokenMenuBarUI/Popover/PopoverController.swift create mode 100644 Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift create mode 100644 Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkImageLoader.swift create mode 100644 Sources/TokenMenuBarUI/ProviderSettingsFocusRequest.swift create mode 100644 Sources/TokenMenuBarUI/Resources/ProviderMarks/CUBE_2D_DARK.svg create mode 100644 Sources/TokenMenuBarUI/Resources/ProviderMarks/CUBE_2D_LIGHT.svg create mode 100644 Sources/TokenMenuBarUI/Resources/ProviderMarks/Claude.svg create mode 100644 Sources/TokenMenuBarUI/Resources/ProviderMarks/GitHubCopilot.svg create mode 100644 Sources/TokenMenuBarUI/Resources/ProviderMarks/GoogleGemini.svg create mode 100644 Sources/TokenMenuBarUI/Resources/ProviderMarks/OpenAI-black-monoblossom.svg create mode 100644 Sources/TokenMenuBarUI/Resources/ProviderMarks/OpenAI-white-monoblossom.svg create mode 100644 Sources/TokenMenuBarUI/Resources/ProviderMarks/provider-marks.json create mode 100644 Sources/TokenMenuBarUI/StatusItem/StatusItemController.swift create mode 100644 Sources/TokenMenuBarUI/StatusItem/StatusItemRenderer.swift create mode 100644 Sources/TokenMenuBarUI/Tooltip/TooltipContent.swift create mode 100644 Sources/TokenMenuBarUI/Tooltip/TooltipPanel.swift create mode 100644 Sources/TokenMenuBarUI/Tooltip/TooltipPresenter.swift create mode 100644 Sources/TokenMenuBarUI/Tooltip/TooltipTrackingView.swift create mode 100644 Sources/TokenMenuBarUI/UIActions.swift create mode 100644 Sources/TokenMenuBarUI/Views/ChartOverlay.swift create mode 100644 Sources/TokenMenuBarUI/Views/Components.swift create mode 100644 Sources/TokenMenuBarUI/Views/CreditsView.swift create mode 100644 Sources/TokenMenuBarUI/Views/EmptyHistoryView.swift create mode 100644 Sources/TokenMenuBarUI/Views/HistoryInspector.swift create mode 100644 Sources/TokenMenuBarUI/Views/HistoryTab.swift create mode 100644 Sources/TokenMenuBarUI/Views/HoverHelp.swift create mode 100644 Sources/TokenMenuBarUI/Views/LocalUsageView.swift create mode 100644 Sources/TokenMenuBarUI/Views/LogSection.swift create mode 100644 Sources/TokenMenuBarUI/Views/LogTextView.swift create mode 100644 Sources/TokenMenuBarUI/Views/PersistentTabContent.swift create mode 100644 Sources/TokenMenuBarUI/Views/PopoverFooter.swift create mode 100644 Sources/TokenMenuBarUI/Views/ProviderCardView.swift create mode 100644 Sources/TokenMenuBarUI/Views/ProviderMarkView.swift create mode 100644 Sources/TokenMenuBarUI/Views/RootView.swift create mode 100644 Sources/TokenMenuBarUI/Views/ScrollerStyler.swift create mode 100644 Sources/TokenMenuBarUI/Views/ScrollingTab.swift create mode 100644 Sources/TokenMenuBarUI/Views/SemanticStyles.swift create mode 100644 Sources/TokenMenuBarUI/Views/SettingsTab.swift create mode 100644 Sources/TokenMenuBarUI/Views/SpendView.swift create mode 100644 Sources/TokenMenuBarUI/Views/StatusPreview.swift create mode 100644 Sources/TokenMenuBarUI/Views/TabPicker.swift create mode 100644 Sources/TokenMenuBarUI/Views/UpdatingBadge.swift create mode 100644 Sources/TokenMenuBarUI/Views/UsageChart.swift create mode 100644 Sources/TokenMenuBarUI/Views/UsageTab.swift create mode 100644 Sources/TokenMenuBarUI/Views/WindowHelpView.swift create mode 100644 Sources/TokenMenuBarUI/Views/WindowRowView.swift create mode 100644 Sources/TokenMenuBarUI/Views/WindowSelectionList.swift create mode 100644 Sources/TokenMenuBarUI/WorkspaceGlue.swift create mode 100644 Sources/TokenMenuBarWidgets/UsageWidget.swift create mode 100644 Sources/TokenMenuBarWidgets/WidgetKitGlue.swift create mode 100644 Tests/TokenMenuBarCoreTests/APIClientTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/AdaptiveWidthTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/AnalyticsCorrectionScopeTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/BrandTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ClaudeMapperTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ClaudeTranscriptReaderTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CodexAnalyticsRetentionTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CodexMapperTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CopilotTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CoreCoverageClosureTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CorrectedCoverageBehaviorTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CoverageGateSettingsPresentationBehaviorTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CoverageRefreshTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CredentialRefreshRaceTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CredentialsTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CursorTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/DemoDataTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/DiagnosticsTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/claude_keychain.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/claude_profile.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/claude_usage.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/codex_auth.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/codex_credit_events.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_code_review.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_plugins.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_skills.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_token_usage.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/codex_daily_workspace_usage.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/codex_reset_credits.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/codex_usage.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/copilot_hosts.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/copilot_user.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/copilot_user_free.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/cursor_me.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/cursor_period_usage.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/cursor_usage_summary.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/gemini_creds.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/gemini_load_code_assist.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/gemini_quota.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/gemini_unsupported.json create mode 100644 Tests/TokenMenuBarCoreTests/FormatTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/GeminiTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/HistoryClosureBehaviorCoverageTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/HistoryCoverageBehaviorTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/HistoryPresenterAnalyticsTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/InterfaceTokensTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/JSONValueTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/LaunchPolicyTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/LogBufferTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/MenuCommandTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/MissingCredentialHealthTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ModelTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/PaceComparisonTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/PaceTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/PanelMaterialTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/PresenterTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ProcessPerformanceSnapshotTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ProviderActivationTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ProviderSetupTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ProviderTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ReaderBackgroundWorkTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/RefreshCoordinatorTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/SQLiteTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/SecurityScopedAccessTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/SettingsPresentationTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/SettingsTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ShutdownPolicyTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/SnapshotPersistenceTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/Support/Fixtures.swift create mode 100644 Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift create mode 100644 Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift create mode 100644 Tests/TokenMenuBarCoreTests/Support/ScriptedProvider.swift create mode 100644 Tests/TokenMenuBarCoreTests/Support/StubTransport.swift create mode 100644 Tests/TokenMenuBarCoreTests/TooltipPolicyTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/TranscriptReaderCoverageBehaviorTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/UsageHistoryStoreTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift create mode 100644 Tests/TokenMenuBarUITests/AdapterTests.swift create mode 100644 Tests/TokenMenuBarUITests/AppControllerTests.swift create mode 100644 Tests/TokenMenuBarUITests/BrandIconTests.swift create mode 100644 Tests/TokenMenuBarUITests/ChartInteractionCoverageTests.swift create mode 100644 Tests/TokenMenuBarUITests/ComponentPolicyTests.swift create mode 100644 Tests/TokenMenuBarUITests/CoverageBehaviorTests.swift create mode 100644 Tests/TokenMenuBarUITests/CoverageClosureUIBehaviorTests.swift create mode 100644 Tests/TokenMenuBarUITests/CoverageGateComponentBehaviorTests.swift create mode 100644 Tests/TokenMenuBarUITests/CoverageGateProviderMarkFallbackTests.swift create mode 100644 Tests/TokenMenuBarUITests/CoverageGateSettingsInteractionTests.swift create mode 100644 Tests/TokenMenuBarUITests/CoverageGateWindowSelectionBehaviorTests.swift create mode 100644 Tests/TokenMenuBarUITests/DensityBudgetTests.swift create mode 100644 Tests/TokenMenuBarUITests/FeatureTests.swift create mode 100644 Tests/TokenMenuBarUITests/HistoryAnalyticsRefreshTests.swift create mode 100644 Tests/TokenMenuBarUITests/HistoryViewClosureBehaviorCoverageTests.swift create mode 100644 Tests/TokenMenuBarUITests/InteractionTests.swift create mode 100644 Tests/TokenMenuBarUITests/LogSectionCoverageClosureTests.swift create mode 100644 Tests/TokenMenuBarUITests/LogTooltipCoverageTests.swift create mode 100644 Tests/TokenMenuBarUITests/PanelMaterialAdapterTests.swift create mode 100644 Tests/TokenMenuBarUITests/PopoverTests.swift create mode 100644 Tests/TokenMenuBarUITests/ProviderMarkTests.swift create mode 100644 Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift create mode 100644 Tests/TokenMenuBarUITests/SettingsUIBehaviorCoverageTests.swift create mode 100644 Tests/TokenMenuBarUITests/StartupClosureCoverageTests.swift create mode 100644 Tests/TokenMenuBarUITests/StartupCoverageTests.swift create mode 100644 Tests/TokenMenuBarUITests/StatusItemTests.swift create mode 100644 Tests/TokenMenuBarUITests/Support/NoNetworkTransport.swift create mode 100644 Tests/TokenMenuBarUITests/Support/UIFixtures.swift create mode 100644 Tests/TokenMenuBarUITests/TooltipCoverageClosureTests.swift create mode 100644 Tests/TokenMenuBarUITests/TooltipTests.swift create mode 100644 Tests/TokenMenuBarUITests/ViewTests.swift create mode 100644 Tests/TokenMenuBarWidgetsTests/WidgetTests.swift create mode 100644 justfile create mode 100644 mise.lock create mode 100644 mise.toml create mode 100644 mockups/approved.html create mode 100644 mockups/implementation-brief.md create mode 100644 renovate.json create mode 100644 ruff.toml create mode 100644 website/.gitignore create mode 100644 website/assets/css/site.css create mode 100644 website/assets/images/menubar-dark.webp create mode 100644 website/assets/images/menubar-light.webp create mode 100644 website/assets/images/popover-history-dark.webp create mode 100644 website/assets/images/popover-history-light.webp create mode 100644 website/assets/images/popover-settings-dark.webp create mode 100644 website/assets/images/popover-settings-light.webp create mode 100644 website/assets/images/popover-usage-dark.webp create mode 100644 website/assets/images/popover-usage-light.webp create mode 100644 website/assets/js/diagrams.js create mode 100644 website/assets/js/theme.js create mode 100644 website/content/_index.md create mode 100644 website/content/contributing/_index.md create mode 100644 website/content/explanation/_index.md create mode 100644 website/content/reference/_index.md create mode 100644 website/content/reference/interface.md create mode 100644 website/content/reference/settings.md create mode 100644 website/content/start/_index.md create mode 100644 website/content/troubleshooting/_index.md create mode 100644 website/data/features.yaml create mode 100644 website/hugo.toml create mode 100644 website/layouts/404.html create mode 100644 website/layouts/_default/list.md.md create mode 100644 website/layouts/_default/single.md.md create mode 100644 website/layouts/_default/sitemap.xml create mode 100644 website/layouts/_markup/render-codeblock-mermaid.html create mode 100644 website/layouts/_markup/render-heading.html create mode 100644 website/layouts/_markup/render-image.html create mode 100644 website/layouts/_markup/render-link.html create mode 100644 website/layouts/_markup/render-table.html create mode 100644 website/layouts/_partials/footer.html create mode 100644 website/layouts/_partials/head.html create mode 100644 website/layouts/_partials/head/icons.html create mode 100644 website/layouts/_partials/head/meta.html create mode 100644 website/layouts/_partials/head/schema.html create mode 100644 website/layouts/_partials/head/styles.html create mode 100644 website/layouts/_partials/header.html create mode 100644 website/layouts/_partials/mermaid.html create mode 100644 website/layouts/_partials/shot-image.html create mode 100644 website/layouts/_shortcodes/callout.html create mode 100644 website/layouts/_shortcodes/shot.html create mode 100644 website/layouts/baseof.html create mode 100644 website/layouts/home.html create mode 100644 website/layouts/home.llms.txt create mode 100644 website/layouts/home.webmanifest create mode 100644 website/layouts/list.html create mode 100644 website/layouts/robots.txt create mode 100644 website/layouts/single.html create mode 100644 website/static/brand/glyph/claude.svg create mode 100644 website/static/brand/glyph/codex.svg create mode 100644 website/static/brand/glyph/copilot.svg create mode 100644 website/static/brand/glyph/cursor.svg create mode 100644 website/static/brand/glyph/gemini.svg create mode 100644 website/static/brand/icon-180.png create mode 100644 website/static/brand/icon-32.png create mode 100644 website/static/brand/icon.svg create mode 100644 website/static/brand/lockup-stacked.svg create mode 100644 website/static/brand/lockup.svg create mode 100644 website/static/brand/mark-mono.svg create mode 100644 website/static/brand/mark.svg create mode 100644 website/static/brand/og.png create mode 100644 website/static/brand/seal.svg create mode 100644 website/static/humans.txt 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 0000000000000000000000000000000000000000..7de23d4d1dfff6e066294ef2de4d67515c76781d GIT binary patch literal 6027 zcmZ`-cOcaN|9_voSHsL5WhR>_&d4l;WQ6W0RAeOk+?~C*$m&pLnOPO?jBHLtMA=+6 zcNxbyKcCQlz?InlhJ{U>D4clVrY_U`DXqLXWdx;21@|LBIonE@lNIpa(>I#Z zq_`z0Yp>LSIT#v%r!?BlZ$k=t6%Tl}(~grBvNlokAd>^0BUB=a9M{$7x;7e+++*l9 zQyUf-!2AzG5BKHSEdtEIG z!^JooDBGc0LK{>Xh;OeOE(|b)YSHFj*3Qp9{N_$wO5(T%V0JndfhL_W5E38t+K^fq$IzdU)K6tstk2oh_adZ zZTihGwP%B9T3r6qs^*=9wvn5T7<*G0obyPHn%&;*2#zTh*L1~i)^(o+#5i?qBzo=( zjiPAkR^(2-X#5{!=+lm&MCSgAhm8^ zu*Y+yE%(Zr$uNY60dFC}M#CVq`tAd1xKejL;cu^!FS>UOrPBAdhKQ!}de&(1@+jfn zsLK^RzJJg`xSUrSz008sg3pZoypV{iY@O`xcJ!yC^Lsw}F@9J7b%Us>_06Uuw6fCUJ3(Ki}BF-_zug5~%2Ad>41|9sRA%i!xgf!EE)2G;lkyVj|bt<}6rH0jDlQ zeodqlzjCwlUCj&dgb1A-vvSdobI|Z5U3&fX$7%iXF_9Mr9THhC9(6}*&7+z3t>ddG zt;vkPq~XzXHY;AnEFKH@1dn)S)~*99!)iM;;G{45ZMon;DUO<>=E=Fg+&bcMLB;%t zDYE)gk;D=i{(rFrD`H0P&lBTw)MBSg>4TOa$qLX^jM1K#2^9P8^{p9AMBVaush1Ty zfb^K%g-dK#yC-)E>iIn6NiDtsYhH1w^ZJ?d^=tE!&9{g!9>G2b1H~82Yi7Aea#7h) zSgr`0q8e6O_j7$yayVEujIUa==i*%)T<{2>q?l`NilhWwf64-Pn-9rttd8 z+DT;L$m37${dFhKw25|G&K?+eQzFl7Dw)2E3}@i55T6t&eu48S+Tc4ExDscRE8M^V zV7~7qUwSfF7hJDW!`8xgGX~gAoC29^gqM{wj}@G+3ix)Q`incdAL1-=XAQft!1Hxf zo7To?EWPM)j%OfoJ7uG^OHMlbwyYq)>MHcQqj$;C)Putj3fup6KVS?JCXZvRP!-ik z^t|eFe7804!OxV*#}@!nX>-*=^8@Z(G6NKrpuIBZB_PouoEonGItGx$S{8QwTI+rI z{J~RRK#9X+!C?1aEM9JaaP>g!ue##nvGO9EI%JM+JXUOHW^rB6&6XZVS!GO4A5yJ8 zI~Rq&o?#R_AbR9eInrCcL9t*<)}%gLRC5RZPUGyR`FF`D^Q45mS$X6GSe=`+5N6+U z_- zi*CXJ25M9x@#Jznd?s*~7ire42=t6WI8!`%_$6lhbn$fMmW01`*WeES1hg`c&2wrO{VBcF7aS#*14pO&SK@IpWJ>&>o-05pS7DkSb&a!j^vTJCaURMs^UGYc`&oBgtXK zIZ`wXnm?l?Q1Zb>K%vX-)4v{W0vP;QU*YQ~o&R<- z#&u_XGI^!}2(vwcdTtg>|F-u_L@@DYvPIyFRlBoy#3|*)iaVtl4SFxP&E0vkKz z<{1l4(&qQj1^fo@CA^Aj275*C^7b-}FM+lG z`YhH6ng!HrLou24V9*XOl~cTT88|G`2e>$ZX#~=gJ%Szu6+#jY)<*0_B`1PI_z*2p z^rw5y>8Oh~sw0G}zR4Tf95Zf49<;b@V0?kJr~+VBDlYb*zC|5HqpP1aC`&q9`5PLg zSwIHd7!H-_*`L)XbnJGW_ecEIk-mQeBex|z%_$xmBgM^JrX$dYl7rAoC;& zWwrTd$FOfq$7^3M0cc2kXBoJ}srRSyXamK&{80bx8+xy~ZYPGa8@9%Xc3Bbr@}LNG z)P(Opg-JTxM6#JvrntunVd5vZZEG=i_2>QHUTo?<-4Ow>?XaznE2*k{??fd1(>T$$ zjwR(;!Vv9teyi`n_Go`GfD!Np;8`IhL{fIuv&gs!_IyVjXb@@Tjh7N36H^fOv#nT-MHjb}Ug~;S~5B2ODqhlcS$;3r3FrZ2#{uBzx4 ze=AH%zL5mjmSR%lXrfk9dgJj;dqa6Z(h6l9t$6fVw-e|EiK8zOtp;Lx}t)SqvmsEvg z=zd{YUJ(L0A}$|YZkqG!6O-6mf6&|!0BBkXs%p*JfKpE3-9be(iD4q+RM3zLFxl8x zNYn5l_(7k|tbF)$&Z}0;b=N=$L7XL$It!gvh9}|J-P8FmnT(sHsg4y=;Kv<+H6kB?6?v0_q{D3?Dq{kd{e(IkA z=anz1kQApFu8vmFf+0vsXdwe{E03v30(T`C=M8KHWlm)QG8XJm)YSGw=a9M|;mTc{ zM1;S`o}yiAB7-0zooe08yNe3`0x}aS<BO>&0;fDwkt| z{zAcvL{uFUpbHUM|3|{kAjL=|1!SQR6jmks@b(`u1>)`4K@e9nBhcIblabG1Z~tl* z#%m2#foOkVZ78G3S;<=Ui<|0(Z>QtS+}kfwB$J)Uh~cS;M5rj^pN8FLDUjzP4?4XY zQq>GgqflxQP*AvhXknX|h2Q}t^wKnd)@xkb>|J0(lHRNE-}1JNXnio2d~7hm@7V^P zBKX`I*+J{CZD!|UI6-7`P{7clfeWYh0lzriZaj$$muz4*azMv^YiVVvWdpL)A_HN9 zd;2FZYk$pth%x?nWOI4vFNhOKeJH)y>uzTvLPq=wq$R%!41d{o>8nO(8%ypg>)Wqn zMB+n<_?XL#@0JopW*&tAEits{WdQJB+dS?sx?{~A@xA_N>vzXC$V zoIS>-u`V6*hY??5?kiUYe9q{^qOgYXf8);nHYk>B&;}N9l`#dV+fQjy{HtM*glIpL zG1pvwp_nODwz4bM6ll0Re>tc2z8C82lmVYgHL+;cRBJ7)>CbsyvQc0&A0}l*`w2H| zK7M<(bZ3222GS(=C1}Vh9y~0z1c7IX0f(ySsNmzbJuS5_95Y@`Qb%C#=1*tLM2gQ3 ztdKfduokdAaVQF}%CdTXguqx*=%f;E6$gjW0M`~nK_wH@r$ig)+Cz(*As|ZI;r2;+ zvnT>85JabG;Iu$G!8U9a=IExKSs{pcp(8bZj=T4VB9QDl(EY0qfBOBE?C%L@2?_YA z87Fdoac1-^aeqSJfYBs!vHd5nFWFAa3Q$-|jC_(B>xWiXdm63FBUEL?S*rQ}whiB#&{Zi;P$tl;EfHIDf%ImL&@Wgy!g+ zmCtQ=evWL!Ct?WAEJ7itEO2m{v@0!9+32-G8V|`o$?xd+Gt@^&IP1ju^7%GclSV5! zfZGI;MI1c#;=|B3RutLDWfRa+rfl`hAdtw8EX9_f=p4$M6IIb;1)X~^i> zd;P|6rK&XQy+LW5h>TMP9=li*)3KE2epQkD=l5tZDeAHKrNJ{i<~*xR?&ezR-z697 z^RVtmfjK<2=)w?C@zB~M2I(inS#<{*4KHp$mJ$6Nis}&M2)BQxyUdh-!+5^!dhq-| z|RX{sovkE>AXsaAjm?ygDR_GhO zSMMvtPGrsuXY@*KEL-opO@9yV&mV}tQaUOtzj7_lps5inAnB3W=qYw^^k%-+C^vbrCY0 z5y)5yWe3q-QJOYmIA8K@+yaDBIiB>BZ2@0cBrUTPsjI^Shg(}}IKuY_l!gVGEWETl zkjOwNX8xEy=SGjTQ)XNGtr}QUQ_slzN4$8b|0mk5Y6uh-oMN8#xoN@GH2nFpC7U?g z6Y(aK#@BbMn!z`;w60*}Bu3zKOo4kcr*bP_t8Iyz&V=P}2^am9zlJ<1i^ESO3NhVg z(Z4;wNA@lq=RM20|Fqhkfl%tz1Y=6u#osF-Ke=z#q#R7Qp(c@;0nRc#1jY%5PK5g_ zo$l_ukLfcT;EI{l6H>giNE&}O z!M83!Zq{Fz(_3|^lgYU~HqDub0cYV2?|grDA`x_;`&m57^bYpimwss=YuFR^mFWZS z<+*Zf#M42(!p|4{7#ow`Wvy?<4BziWjMvqGYm8_=4FCHWLFsrwsD}ZzH_CizKwc7t)s-YaE)AH!4^FMN7D6FDpmt7e@QGP_#FhTtudb8WeLJ4*5NFsQ-ClfLf`W1zHaF zNACRLJBfr9$`0UbgfKN&WcG!1RC>@>cgd{SaU~0Y{d0YLi3J&U-vWtTK13kW;L*>K z4%WnR>}RYm-`UjdVqIWYTt*(*MLfrO;g+11mJmiRJi0u{Krbn}GmFo!?9a#KcO%RI zWq+epvUpuex9n8T`fu-T-^ZxeZrdwfqXLvOk zFTrrxauvlZGVa#K(F5vvIG+NWLx@TIA%#dr6!;Cz z^oDW1UPNks7-{A}=Ld{V`mBj;fi#A0!gzPdVkEMWcAOabAmwZM51yW>8foCgW)Zm{ zJM512uG{B~(=`JGk{b%kIxnn+)Z`^!{nR(>?w(vp^p9SKZRgJcb|!qkzj~=d7f8cv z$*>b9AT+LiGMC!D&v*sUlMV_Gajm?yY9hN`s=#7HVg%ppCQ}|cU{M2DG8nqMo#>wO zTsHf=rWb&2#M(`M)5Bdl7x1VnEYOeGj4buOaeU8nm!j2L5{V!9wcOMa_{sJ+V%M_T zOYM)C%xdZ1)z|hRt@-Tv5j=p!&E{(~+H~kM6dU{Ce-ZYm{nM}j5oi0FT;$o19Wd56 K)2r5nhyNcjmd#TD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..20e73f05d6c802d9fee16bda56ac6d4a185b1308 GIT binary patch literal 14743 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4eafd8656c9e2b7d8ef0226b147d6e0b372883aa GIT binary patch literal 542 zcmV+(0^$9MP)L+d;pMGr%ILHKz(2tg!UMqmuU zby;oF{89-kYo#ERtN#QX?mVJ*>w%$^DJ5uQM zM&qQ9VuA3`^Z5NE4aXnC95oN+PR#Z; zbAWjJ9oy5f!&DUOHu^o72s4=na5UAn^&B9c7H`j*PK_g%Rj-g@CUy$ruA({3;!oor gQ-p}O|Mhcz27?!S*hgbw{r~^~07*qoM6N<$g8E(ZQ~&?~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a66c39b532986440e3045a87e4de93bc2428bec1 GIT binary patch literal 1220 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a27fb8076d960316b467e25658f7828ad51e2764 GIT binary patch literal 76876 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�o;2Wnd^WJ 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[ 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 C=w5XI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a66c39b532986440e3045a87e4de93bc2428bec1 GIT binary patch literal 1220 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 BRo(yq literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a27fb8076d960316b467e25658f7828ad51e2764 GIT binary patch literal 76876 zcmd42XH-+~f;UPPkPadsy$d3}w}60jLKRfHAWH952u0~d1nIqlQUvKG6ahtgi_}mQ z2%!pr5JGa}`M+o8%(-*sy|dm~_ul=5e8?jE+50KK8gFE%MS1hiO*}k2N*!$t6FfWu z+(!aD(i^xxXF+o)JbYn#9St?Jpr!pD^7cv#bN=@8n%b2QCLJ`5s`#QmN~30_S{bW( z<78F?GI)t@xwkmqy2Y~mGVGQo)0>rcTXy5pl-8R+A4CzBwVo!G$_b@P1SF=)eO1*K zGUxiLS?u$n_Vnn|uy*3|;t4zqvWBg>iPdRZI90Va*@0p&!j9T5I(t3SKL2&a7QF8! ze)+cxhF4zk*G2d?^RJ6a^~qnC0^VQWdHMg?i_0f>;mdxh)o@5|O}Tsb82FrbPv z{FW2KSDU?+Fkd4mPP(Jnm=$o zJVFI7A$PYYT|w2ew)NOGkQ!EA3v^I-BpQlYS;neh6}gIx&}AMT?QSb>Z<;Qj1p;lZ zJHzkeV;k3dwY9Y(6g(ZFyX*{KfD?CnMdC%fL#YCO+89AW|w=gPk@(86unIusCF2j zP#4&IZd8NVRhea17~d!bJ>gi{)f!8MBj6~VCwYi*HNJ4qHA`^mWWC3;Rb* zwcs{=Z0Yt|4wdRs^q5GZ>OomR8(JZH@f))g`$AhcbRG z;j2Gt^C)}XA;$GwZg*obd4T4)<>w|EYy~JY;R)xvoDAY%k)GaQLl*efGebL{GO6j4 zPas&mYgpKI*i`V^P*HH7%QY8zk}ZFiI0EU5BtgPg`aoO2W6T#0Q(?o}k~lb=cO4z2?e5Buk}Mm--v|Xk$SoG50fS znKQ0Ne~19?7kMgIS7ckl^B%O4R4Bx}&SL-CGG;n^u0hTnp0PwSd6O-Zg{AXqWY>PO zKyqhlefbC?+vXB}oOMojaxKka#3YtIxLb;S;Axv5042iQA-OjIxE5JOTp>SHQuMvM z^C}Snd3?i$6EA+SVgX$3^b7p{{cjS_S0j%>d0ig3ZBMW1yU2&-J%MvuvAp@6`Z)9z zjBhpC#D%R`Z+tK@d+6q5O-m;=aMAgo&=)-yfkX24~IT+0|^1 zwX!{YiMuwqWA(&)@eW=V3t|aOXQNmN-p-uFn(q_c3MoO~7-M_4X*jSkc*jwC$%fW? z^q3DX^S9`*nrUQr*9hk?!(*T%E3aPjVFW@#*xkgA6vTyrG>phej`UUyj5#0yMu}*S zdnTUk&qm}yK3mC7(t*GJECstR$0PSpy~md+FoSNtCH)Cs7EgLI_G?dJ0>-QXa~5-7 zyBR6|#Aj|i%)*3YWp{pR{O}774Lg;3e#_iEo4t)?Z5XTuPnUSTCQ5E-+Qy5sJE{msJ{m*tW*;_J+0 z^_RS4Y1_A7yu!=SW!J%WX|L|*4y8QlV5`wd+te3!(=|1=4js)o1L$82pn~Xga{D;g z-oa^WkC2d(wq)V5TOA@!$T3|xLmi=;;d9hbJjmBw2^RFpDi)|c0>1a%Y*7jGpepUX zS?*v{G-xgwVu0u6*ncVtJ4(ctxd6Z0N{7X;w{ zLVp}ux!i^H&1$$(M_HeYv+b?}<)v<6W%k&=6vZ7W;$5^+tb?tg7iJL@X?KWTu;EKS zB)fTI|Hj9y2LB(H^fau(FV>1=e#b5*iD16lLKH&h*%T*w1w)Bv@U;ogqpG?+M+7E= z=0N#2B}MNPLRm+bmzF*&X9m0HgEA9^r%9^Q1dLdCNx+`^sWJmE!op}yuRB(QGz>(3 z2;Mj#HA|1|%Tw&FQin_MR1V{)bSQ-Ak{WtDNod*U;3 z?WsB{k#rSo|2K-zV@Neoix{SRHjWh^9Dc$Bay%P!GIB`*m~lS|bPO@2n4|SdR=mSe zXIjCA4>~49hxws^7OSCakHOvtEw0Q_pZ)M8HleL3;1y=@EWFzf1>G8&x5}$l-DbJ( z@ruqOriB)eaF=ITj;qE{&YLr!Y`{2S+0XTDfJJN>P(V`ahKynhobaJSmx_;#-Ii^` zwh-pY0AhNBoCT|4ISxQp_X)D-U9~wSr;Ru>R>X3%Q=cH!7a5kvc4cG_?hfwCY3FRT zDwnt=?RuRdMQ3`hS+b`KgsRaa&oMn^E>(d+*eHq(==$W9$KI1ikA7w<+yl3RMh>A9 zNvaBX)K9y9qQe-0qBHZeX~5(pq8_FlSf2}p{7h5qW`ee5r5HAJ9haYx)jB4zO<5@Q)e4r z>wGodbKeUwmB^lb&W&TBZ-eK?(EqmfUaM$ViNGs&CxzK6Maa} zP#ZkV*j&F-9*wVfyGnY$IJo$ZMj`bRsG?LSRx^Uf4I+w_yZ8H@?8>VJ#otoeB3a#?9wA&JXM}AbzEd?XXRY`*WhgMkv1Lx!A5qrF9ZAS`_X%|9H?+0z%RJ{ebddxs+qJ#BB|{WEo@_3o8j zC{v6Ky{~)iijgvgt&79K&JWoZ?S@HyK$-Z~794187F)u#?OFPh2^+$d1X7pz<)NAc z)~v5#J5{gXd#Ll`9;=7gP?GjUpj>d<$FHfqn962oQI$IGWjwxGs9FtK(7E@kmJAIA z^sp@p=|(05>#zn>H0W{D$+0jDcD6@Q4oZ2MO9af9Ob1RsbK=9(`_1FVJ6=9)T-V$& zaXIqneVFRL3xO^(ORP@bjGzBYz-V01@zdPZyJhyxQth!NP8YMW&%Z_=EjSBHhM0Oj z<`Tph-RQEj3N7WJZ6Gw}41RadSp#V?Vb;$V!G$K-4>xF?$n5(pIa?0^gZdAOx~R+> zoP6Q6u+*1Uw~p*Ikr!~u;V)?jW{k}3rv;cz$f4rr-=<)6XcwTMN)tCM!O-)yr$t>R ztvzac`)S3+WRiHo4-g2)4E(-W-Z8;PW9mSNKw3MHdNX;l5^z9#eCe`wkjgD~MYc-i zQT8hK{mlE@r(#yjUp#KiIuolnA+=i4Ah#@ zSaAzkf!J&zw2m3)u40d!wa^-zu@5+~uO5`QEq2}3mkZOBWKD`g zXcOi+O+12^*~k*~!a_Vi;?j=#-lL}2><%Nd!J0C0L$v+=IkhgERn>_`?RJQ zV2_1%`Rkd{!i7kAH~;;$ci}g-*Tp`SCM+;U0G3DaJc{?6#HgsYoQ@X-Y!({1HK&z- zE0es@!m^P)*Lb$#t{}zA?moyd#9=R@fz&DzO9GrQ>m&VRVusoYT_U`;m;60 zCte(0)sta7bHlst&6$#rWT?EjeyRQQ>*_FxR@)N4z+3kH7@AV8VWAgPcS`5^VBgY2 zrxM@E*S)Mr@ZL3YT!rOMeg*OK_I;X6SV$WW<61d24!+SmJhZw@1Nuqu;I`5Rk&!sY zJ9kz2gM-nd4oTV#kUd%rO`e|!u?5G1@TWmi?0pM6!F<&sFGBrad`{gu&jdtn*y`8X zp6LI0P7({c+&>GmJ?jk2zs^)Ln86R4`r$N*KF*1yJ_wri^DR0mV_&9qLyQsrl7sn| zCnmviMshC#2sHr?;Z_Zv@SmBptofhfC`iQH9_O%G!YS(S?`hjY$BtXfZJxla`r;qk z4v2UN35dqcg6zU(M(h+f%e>_h01sRTk4NM@{hSUU;^&`EE@vTsiR+Tau zeiS`%DuVfvC28ijGz9ii*xe>qMa!ep;}kn1TcQC|;KrBe94ZjNdq4<5i_z>UGMk=x z1vN$k16&{HC}X8P0Xw3Lza!kI>uP zq#J9t1NN#tcTd8&0ydp2xr2;+ayC=n+GxUK*|2jZn@DxG3m)SR(%uP)V}g=Js=LBY zHO0$>EKQgBmT1!s2mP+#AQYA(bamXhM;w@kd(27JXBmW5?tts#me~We2;+P1Rym$# zCu+gv-PkB+j_My7p)@8IP)-fR+>k7R{Xnao-}i%ug-yxuoGX5TtXyf@?c&1A;|+%@ zY&$8X-lcDdZBR0Q<$0+4K2e6YT;VkONq`PIyR(9VWu7aNd%?8d1k*}OHM?;`Ffa;~ zyAem^FOx4`XfKGA&f5Ltg-$2@{o_JI13>KR=~R6t2TfIM$WoqrT5HnR=Q{e6Ruv>&A={Prz)hr9RyW%;#=^a>gQQZ> z?O88~4T&I6jaXpRR5rKIym8+j${uJLgX2M+%hzU>IyMnx&ea9^o4 z^$sG*NAYA(&d4^WMkefL)dOwjT1K6}A$LKtfMZ7eF)wszyu-tL%26P$$CTBJ(>4}) z<`3Y6P4{Y1KL$AxFymK}DcEEz8nf6MG7a}sx@uK=GlOrLpnUbRO{L;vMxa3int;J8 z9sjB9yIXNpTbC~TYSgeC3%w;1KF3mlSf})R0nc7=ck(C8^rjKc;NMCqHE+alwqYX* zLoDzo{|4V@3mrpocuwI3OVHIeGj@aVm(<=z5&=BZyhFuvKXS6SG; z2SkJ&P~6gVP4(-G$yU|9d-ZU_;@0(FBPKClzr0- zg{aH1*5!tC$7AaaijHxt^4+byg&nEFp+iOtI0Robo-BFksFsr)`3JqLD*OLhnW_(> zPYT6cS^f}6+JCK2_$~!xkr|2P_+q;&)nwN6i*g;Qr5v<+239Qha<@vm_B-C0!S*27 zFfGwX$8A$6m1f$lB00U5% z17xqkQuba?Y0X(Z2)5Le{oDQq=Dm=r31@S9qDv(t-*Asu(-;gpW907~X2#eJ+9GB? z3d8NfxPiG+rOqOU1aDO-(h)7RK3@+Zu5@qq0uAUbtZ6`pzk962PaI^3yq1>2Uju$k z8KL#s_$f~WHm|7quJ02fAw(u|byW9IHP68}0PPNgD>TXA^FBudRj=cfExrJ2d3@%$LlVOMb!~cCAz@0t}?mW z!qR43v1693UAB2sXSnQR(jPh=ao~A>X03?#l5fFpNK+dktYdLLWEAATp9j}XV)fUpRGFL z`$G?4+RXaQ`gcc zd#CrBhAO6-Fu@&KC(uG9K0)XldD^oSXm@ILJKt>ZfIMs9&oE$KUL+h#9k0=C9t3*^rqbK=Z zL=F?v>(`=K%W**cmhlQ_S4nzzkD++bsNHUc6myMA z68Cltivf6<-pBmduyu_9O;aY_1|@XnD@EigIq+018N+~{sQOa@%Q=wY&!Jb%`k7<&}0Zlzpc>`#{h?cj%Wf%g==Q{qct`A zi=JcT&S-dowqVFL-wg4Ys2wwITpMalRM|Ew-{~8$h>u^XxBYOum3(S9TBZDNrHwWQb+DY zrjb@YcO%LF9h}F6>`8>X3n@RnJ!jgGHuz}d@IK7hj*5!jwPt`X$X=a~=UN=u_AhX` z93#~AKj3mkA3LfIAkFxZ12StMzDciY`=|`dg>KL-utwMZB-19bn_FPQJZh~&!-y(p z%@^7~I8RVL0o|dK_h~!P4B1vu?qyc?y~X#PsZK-(nDQ#pCS&ZS!9DXN^;mAWA>`FV zt4Y@QukYzRw-0-14lk>fq91Q<9=gW?eDJx*3%v-cnXq!>DclNgnnnxaNwMTx)SI^J zR^71sN9(q#XB_?`&4^$dvRd*b2l?c4@0GRR0%GVy9mKstvgQ5tV=x&yr`SatZAwty zwU>}zloScW^E+T_!-iHt!|_u>+_W0!C8;yE${}eY5|z&Eu4~Jb8D2hK%Dztp+VZ#8 z`P3cdEP+N9`4X0L>>u}q-;XZCzG14xD2sDVIm$a%;>9J zFz6u#RtwOz_Ni{Jp^Lu-he7ND%;t~2+K!6bbMQJwA;wzllGY~s-0Elqij#Tc-cms% zJ#A^uq5{t5D3>2M>?UIL$iC+3;%f14)Pem_|@%Z@=@sO%lc3ug*fY=ra zMZxemyMm7Dz>-;Kd?DM)wU3sXly5Wi5vJFJe-N%@6e_sPGSkG772si=R<7ZnhCAk@ zvel__n1F8!J2*fOsJo*0`6F{~b*h+0&N)gQW9X;|_cS$;RdhoV`gWgbFr+Z^&*48# zfNVl3fH`jf-TTCKxGY{Ao?&*EW6Fo@Ld-v`dU;=mq1yu&t6=+(5R zW!if{PG1+HxL(bfoyFve-!F^Xk%@X6nQ@EM^&F|gX&e`Hb$#*cA=_!s`!_?jPqizP zq}d6=gR(1QOqnw^ZhQoATCmRK1|Y^cM|xE{`nb(?REYb`_YXK%dehjfkX%8#bD35Im=# z-aDaF>LJ@HQf0bdHGoulbx(^GG46lY*qK;0KiFNXd0_FC5%mOx)RS((o0GtT=yxKa6yalVtviYn|{SNn-h1&U3&c@=a$v%>mE@}NcME{K2-i2uf z9mCvTvo_@+ay32`UJgGA-_t-XQk*bO-Q1uNeXfpIRd=9;G^L^(0)hVtqG#7pNgLPx zGemEc>bGIDOQ$eFB4(2jsj^~;frv-{43!cl;^!N+coqMM5hum@eXC+CvT zcT|0YVXRe1ofS&wZ#~lDFM8wh-Da|zEtsH1&F2_$M`uK& zwrb6sRfdIn`hG+T9%FL++1%qBgPKYYo_{DY*-$N`7jlhbv@QwR!@Y#F8>%L(v%fqU zc3-q9l)M?nW6v9FBmpJs8j){JDkguWJ%($Ufi-ICbps{Twc}s#u&w1d6sO=NrVK%@ z(j2|0&pMag%{)BG^_(~uqT2+{Lb7K9&TLmd5CR?ZZD#1=H_3H+K+LJ3EIWi=5?>^gO zq3)6f!C*OoF7@21ATG-yxA22X$cNw?91Lc(1xp z;VJmrgDJzM1VkSXLb00eMVaH)e~I05W+YP=>0O;jGTTvgr2M%vFCWv2kuf++abnyp z&;&@=>Tr1s;#6%wg&n(JoS!8rhPfYF#rU~&+OZn5tC{d{7#-z86Re^i2E@U}t3;9F z`_7!~l*?yTWqxE$nwt6yiIA)5IR-1}I&}guKAJ0>rV%{x91+~miIY%acuHRmni`;i z=V@#R!dZ1tIs3`1PGfRzM)9^>*xgo^8{p|TyYYEv1xw4p8KbXdnQYZQIv>uDh3tp> zg*^zgREX_SG+IHO#L?Ha)MoWa{#R&THOP8)l=P(tLj-8~_b=$(xkl1UL?}elpK1F; ztFccKsE1}4K!;!+fpSC2iON)8#*1$*-i(DOk>uhtyyw7ME*i28G9Vrpl}mixtsTnf z{qlzVds2|2hVd47CD`(3d_&L47!b#4X<8F;8`izqK@gXNta|5;jTrxwsaD7~zfg?7 zH$F?utmIC}-p`SK6sJkRkOP~VrB=~G#UD4>-KH_F`9p~cn*bAGs37_-&t|{p%Yd?^ z8@1n1>@k_EI6>v~hHy`~Mw5efj)MP*G5aeKHF7DDY&(0Oo({ ztY?SG7EcJTe*B=@v`kV{)wltV9W_9l{_yw+;+z!E1D8aXxODV$;C402W6{pY=}qdU zE5r$h(-{hRLDf58S>qZ0i?RzO#ugD4Iixx=GH7%MLf#E_=XPlKHcj&o$#upIlkqhLh zp!)r)xQtSWEw{BQmLL;nIIyzw!XscqJxZVK##_A6BqX4z$wq~|I?Q*ARloDAS^OaF zhuAiZx8Oa-2g<%*Ss|5}1ZZ?bp}Zb^NveYY{H&hm)(5rnmEWyf9Z!ny+lds{aXm|z zRU7xD?>FPjV%1%Lr%(+p8mfAK&x4c#G3SI{G7D)HuCB7-iLFb4*QD<{mz$M00|}0J zDlGYAjSvz{B(WzF$bEWISIe~0UG~eu=z%h93Xx>K-jOuh9jesUjnvIlF-C4Qxx85H zu|VAOKykF*hQ?xr>FLEZwiHQyEKr`sMRU}&*zgR1J+xS9zheHgO!nWMURWz#`}ltR zH~eohIrP89|HcFmNdcdq1qw~=x%MBO*8uv%XJh!7+w(Y?U+bVQwGhBqZej39SWhwv z`{sn?MAS>f26WAiA=bbe2P!-*+Ie5xS$scp_65ht!F>?HAUN9$yqVKDPDO>y>nn1) zcuI=J$EP!DqiB{&QWaVkvBbh)wl+REjcqNBs!#okNY_V?r#-$z#1IEpPr?GVBBn-t=pd&8q|4Pd5BE^O|-&ox`LFhEUi~x0FxYL>Y zK1J>&n%D;6=6{0$$-0`$1q1`=J;LU(Z*SS%@zwzL{P*?z&9@7mKRDYAD#*;4a!gA) z|E1;kKe#_nMlpnB1TZ`pHR{ne*}}O|)z6d$Y{kR}PmcH!nCs3+F>f5Di=UIkCv2c1 z_7iGV&W~eDL8q9cEn5M;Y$Mts+b<7<4t`yMqaWyxyaC-A{D>V%4KF`L&&2wO_Ng!7 zxBDH{P1aRd0TFYACohz26O?zklzp{wjsK?US1jD0V#BunUD7A|CrKYz`sM-mGe$O| zs7>6$)r;C9q>KWLl$t7pY)8|Ah>@l+uOd2Um{q9dKnNChtAWFbUtS@N@I~#OIi?J8 zBgtF%XfT_8Ogp%Z1|4;DJhJm%JJ<;io2z=WdA@{ScYYtTPGaGf%~E;-VbA_ZMj9|Idql05?#YfN zaC}9m<&s9axDFNMH$f-9Ja5Lo-OsVNAuRKOwyEgl!7ZcBXk?-%cmO)8yBIp}--Quo ziy$#S1HqT8zIRE@NXEWP|Cpk$Fk|e#2+wKb0T#<>Q(>O)z5>L^t zmx`wqDtxzVkJI|AgwzqYk}a6J<`Tq%U3-dI=2}kK(Syuk?`v2 zIkvaHhi}&r$!z#(Vyg{Mz-v(2#JSo9L5=b>cLPfk4MxMGb34X*QU|xX9iZn19Kk=& zd6q8>sC{Fz#NGGOBMXBqcbxME*3oV()()trts6Jj{9@0>#!+hWWv?YFdrumlo@Nym z23Kth5u7nbA--)xJg9ndosW$pO=U9AJ!J>dMn+2I0`G2~(4+!xS!dcz;%^o)K4Elb z8P`@|{65HyW@OGtm!ch+tF2+M{z953KofYDvV z$q^gc8%@sc>}F1vMx}mg{o!LiXq-|JigUd)4Y5#6CCnHzW#o+uo(Pn3+^w<{rw%gH zdYBeOM%=T*Qe*%Usdt`OINGhztBTIs%OPp$zpE@lpy7;?ZK*Q*5#D|17tkrGO*$|qji%l2Bt__4&iruU9~}fNvE|n00pUWrGNz-ch}$H=1{Cjy ze@$)~bIjot^!6iRCF8~KFHzEv;S&y4bWeB;eJrR8J1T1Hf1M+$OJbL}WV{Vtrq-v1 zci&ENKYR8vd|8=S_n+xRbIh%(|0bOnZ7fuX4}Ykusc^-6tYVU2?n7uU8tv`HBpN-b za?%vTAXv+`c24IvSHkS`@QiS0Ce$D{X0Xg1iNnETZ%DBZsK&U^WTlJN6j&3vcn{bd zI_wO57ek09gE^6Y>2f=pJOSoU;iL0dj!&o|)}idWV-eJXBO&Y&RPGAKEzl&Yj9PHaQ8Mf z;>%U+x9mcBxfhK8kyjZ0*SrFWmPq+eJ%z^s;5Ww{G@SVU-^KI>Ny`Zi30hTthu(Tt z>pQjdE@@)#y+r8td&mK%47b*ctwS)qvOv*Y@_qaXt#-?a@1Dc;DQ*dxxC3M3Cr*-W zHS~}l$Nx(EPvjzt>|c_L@Ql^5dno)^-5Xa-cK@9IG15A<*^5H5^nrl#UbnEVpywH@ z)ZVBOxUctz@pUK!eJ$yLObsHJdMJl!Z)X?)OlQBvX{qSC44;GdkfRWL1-z$IBDll+ zdUk86tVkfcj$xDNa~cut%tpv|F7fy1_OdFEA2JUT9)((S+m8U2_E}RS$u??!vz2TO zGvoE=-Jg@RRRtE56U`%3Wt75fhs;&{%Ssa4RWD>jXgA^n-u+1dL8i$%MNa5Rcok=d zijaQw35H!rMne+!y>==P6_$51#l!Akoq8@+HW@IxaU@`9S)nojt{kk051HD)C8KX| ziy5270Z@3{=tP05rdjfJayuFD3k`ydHyWfIuT|Nj&voq4P%Jf-mH&0URuy66(Ei_I z|Dzu<)N6m)5cGa&7er4S1S0AtEtg-7gG}LV(yhhf6Aedf_*uCRPdi^LpJ?pmT%8D< zHQsySu<38u{tr$B8GNQwv^;n4f0qu@r2D+L-eNX&5J|cC%1{_nW94174Gl&UvSIN) z?6wHd3rO9E>)Q43Pg_RRK+5$W^D$uj&tE=z)VrMvpF}R>W{?RA8x=Roxl3_1((|kJ3NVeY97J&olS%Lz90)DSV33Ex z9{;;7Q6bL9p3DO{*C}FI^|RCw>=j1_BBsKEhlC-iq~{zI?fwkBQUgR@}nfi_{-xG0-L_! zudIoh$BZs|oM_7O^yR^K>Fc*Pe?)w4AF_>SmJ@DiD02M4ijggh4RWZ|Ua#sY>W6FH zVtPYfNx92FDkw?mb45=yS&^WZMv9FE_Fk2z;UYck z#X=wwLZxzG&-4V!ktvReZ!Sl@B%OZ$l9yEv+t2{KMLvi~ROB_5;rs$lGU!HrGCGiI z$_G((BZ;Xudcd~gFH#+=<1l1C)ab6Ho?~u z_h#^a+@$Keyb1?}2xBuL!*2r0%a)pObA2ZLu4Wze{|NK2oXpQCzCBzNcqbMy_mWZ8 zxc0+L!5EDR_tKAc#Xl_pyXjBJ5WqWO5j-pvsNV{0R92(NxwFF64`)#%;I{9y1%pVj z+yw{Ghr}kKrJ6RfjyN?`Fve0M$3UF*=^k}`cQ^#BM`8yH0HdH)mG>6!<5wxTocsRw zXh(1UzoH#r?(!4SJ$Y61=>zztTN#tijzh2|&b^5C`CZ9XpS}AzhJ=r|n2beI(W!y` zAIuANgm(g9wp}g;bkMDeW)r}C(&fH8XO#**2U_l+!k?Vzk`%uVw8j8(~?6Yx`H<23~ zbH(>df3hTQYstsO&K}L5aGG3CSn2Gx$C$oO#PEK6i>}3_(kZ#d`*APe5YdW0m&zs$wMLSS+T>#vw@quF}GoP%{*4B{G&*S_5txoWs%=WH^Oo8D4 zG4Y5Ng_vr^n$xqXhhMMx8?(hc1op(gdxnO;5yPZaPs(9#ckuHu5$ozD(N5wh+JB39 zl(F9csFnCd#*|`%U$#u_4Oy%tp|_#MOiLZE*UaK1Wn{2$A@+Gc9H&&s+QATvvy!QMn`KJKJe(_n zG*#!m4I0hiP6$^=QvbMq4XfcvQ5vfsme?3tPt~2e-KTE*AM#1yV!<)Ir?b+*~;); zUp(8vS@~|)DTSSW3hgz8!A2}eGh3LjJDq<|E`^(&rv6g#*HM>uR2z|;myFF}nEB%L zV2HlT+2&>KN*y2yT)YN&4aj|Ga|=t}I6PEG+*r7OGWq`cR+`BfO0S%J*S1gMH8x@} z+L2AHr#(v`lVEb|Z$3st(Z-jJqJ8jijl=8>YqUl1)qC0fPMmg9k%{Ml?K>j{mu4-r zy!0UsAF{8#@|Yoa>!+^{Vl%KnnHe}l}%2IdBd8mj-=OU?qb4Jc+suS)LdTef{f85$Y(ti zkwIC>7}O54Ec;Dz3ZsgHA5EVwjg*j+>>|K(w-7`mw%YG<{n_ISGl=2ERgt{*e?#?%WH&K$% zJgPp4J?swjykM3v_?{cDy7YzW}Av;346W=!V$aMv=O!2etB6F?>-Zd|9~YH!y^ujw1kV8k?|1?Z1+$ z@rxU&@V$@xmd;ddG{i)$jYP)rRQ?mP%4et`7qoLi?T04(SPCiB&o_F0-^*^;p!5@- zX@gEq6G{-Bw+l{MRmuE4?-F%y61?f|+)OD z$*S3n_nB6GNKZ4|an_hDLC0K1ycpwxYO`n%W!n@rF*v@JQsy zSX^OnL&h{P_~t>4ogir0$~+c_=xu>)@kNEsM2A_&ja{6@|?;92@=!l}P9#6DBCRzAJ6@TsH_6;~*a#H+<^Ls%R~nMVC%L80hw z&53#f@Sh6`YNZbCDx6)VwW-SJl3i#wU<(P+A>%jGIAQ$sRyXt#b5u+->%CDlEQSKy zxbj>+VA%rGaPb$I`_pT&p&1`{?EQiioXq|;hK*j=D(7Rue~3eU=eO7&o^tzNai~yL zuUmCd2$GI#lC!z4n?V6#boX!3F?a29(%d?NX$8VzqQxUqh_j1FW4g~czZkWnfj&7EJpb-ppeRz|qnM)^|M+n&wN0#Teq$ovP?dgRux(}h}gT0fTz+2g!E z;`T3%alQdsCowWJ+rJGm0NaE%Ju%3+zz`wz7RF=c-VLmi14ODxyzb21)9uGxt4 zE$DlvS~qr3$?R8?Z<(^3Oe3W=KXLz*O3;c0;=3&V?}g)+)Pqx~Y>CW&DlMQ;8lS|h z!0qaEPJ|E8-+z2b{qaF^AZ-P*Hc@5njI~BMW(l4S2 zf)*J99X&%iHAD6)7!RhR9aVunH|yhhlzq8f2yA(6ivt%a%-0B@-Qu44-Yf({F_l`uEGUer7!CX0upF$0JLkUQSv4Lt zRM}$Q>j^?eXHBdB2Mmt(j%RKXgz%hny14=5so@q*Iy~6q*Y!8=OlN+etHHt&__sQP zu)=NIBQ0HT;&mu4PngKzBIQr=GhdN6DD0V*DzE+p{DOMJw!ArPsz=oY`1vb5M));A zVTeYLzgaNmE3N9yH{zIIVYnj?UW0otRYb>aP8CXm4oURy_B40_YaIu$9C8b3-_sAz z!`}Ul6;$(eSsotr7`F`x*)GWaKx%Z0d_f?us68tYq7wl`J|i55U{0>=E^s7sC&h*@jcE5_k-S z9JLqLvDc!YUhfh zJSQF2el)*O-bgF&Ea;@7N5 z?s^zd3r6Rdm_#HZU+@?Lu=%&N)LYIcYZRD4L5CCN459x|s}DSd z$=ue?N>dco)o8pQ7%!K6E7QnEi3x5lQ6J*#VyWnS-PZt#(&`gxlEWU!XJEh(V9)-T z{+2G|i@PXBAerYy%{KZjF%%N~uwun$$lv*uH*WR7@Y};eXi^`oeO@H}qA_~6KtFt? zV%NTW-^D;7sxd{SPA_lQnXUXEl2eP<|1vrCZ(aw;H)NX?+pWhwwJvoLZP_6wtcs-xhAn~}@ib3LiHf)CF7qgAZygTOaHk3cL*bHU zDh!6OB_8YB&ls)?M*6-S{Uewf|8;#C$LD*~zWjtQx^(J2B;VrYw2E9*zY-IDXj483ZwF#X$Rp0`KU%4Oh8w9; z)A#ubg&9YYLgXrfL*QQP#rN{B6~RR$OsYNNJGLPGM+$Ky`>loWGs9Lzuk zn&(mA2xySo5n|`!kcSx&_kCmvAzpzucI($hip&4o!i-T}9H_qL!uEOqqGF@>hWF)} zshw#m&*B&IXtlFK#*@vT{vKUKC@vC3KjlMyR(aSCwFD$515+6Sx3>1Ga0LFp`wLRb z|HC(NaoUHHo-%};KAVia)b!-9*d$R()@Xv`8QY-Dtp7zogFSPTzDd?jn7<-@IPrf6$LSrqJ+^tY>8+4=oT zA4Pri6dI49n%re*{P2G{#K={%Bv*f+z{9Lto=55adYOY%(X;Sh@~B)zE!iSecq;LS zY~Q`b3a^{KLPZ_If632?4pk8i@enn$b!&C{4Kf%o;}32{*m(>Yj5esnr@~%V)OFoj3y@$33j>13cxy)fj&X$fIeZ!@`6@ ze&)NBqCj-7Fj;Na{f!%C6pbemW+34PMu-0B{g*Pz$&s2ZFX$r0nO4z&W)#9Q#=!lP zqCF&`7t8rr{fPSCq*1p8r+X#_WA_Eq`c`9+c;F+-|LqV?0ZQ9G@| zvVhwdb_9=EeO!UR@>a{Yv83gXl$BMlk^#vU7Z(75hk@kMpp9mnj_*TU;k@cooi|e9 zkIJfPgl=yoPW~*t>vHVYKK1i?TYyGN&L<*)4AtGdz$|uL}66|?ge9L zu+Sy{t8cnyXbzAK(rhrD4tx`&R->%{b?{$(06ARhbR=w4?jw%*^;CrJ=Li;I0RpIY z!f&R)H4DJ`9*k*Z*@dr!zqj3>7&WU)+8j374KV#I|1jR8^-KYxRpo}fU~`Y zro>rfZotSEkBWN?KeGKje7nHVa9sNBG~uHCn*j#m4qii(A~bbXMw&aOeD2Ay1h`o_ z`?xmoWKmt2-W?Bp^B-1d(qgoaczT}^Z@7bVJC2HYD3vGiOisb086{Al`y-i9fmUu1 z52xT!-2`jPnactRU1*RYwR(Ee!@!4@fK#eKiA%efd^7C8`rt8XMu`52njg2niw9zu ze3kTm)Yw2%gvno_(Q1s^cuCc6n4Y`EL5*7c!DUdE>-JXKwz{&>=zdF6j~HgBYz0=Lq3OUS=D6J1 zCs}w=9fOT?!sD{5yiV}zlY^MBNI=g!P&K%EUw7sHIQ{j)dFKM9Pt+>6wXu;G;Tqu=XlPg``63;n9rS zhMKaNv}f1XFUL<(KuJwt0(Du|U71ViVCzPSsWY8~Vu*Iz64``43hxCj^&E$8Iv+l} zmhJOy&x8+g7$PfH-37D#$yy6MmupO?i6uVvlmMC!0U_Om{8-W2g{`H8Cz!oCXmUE2 z9DM((>CA)wPxE_lVUeW3GEMY=wNraQ+mIaSsMz6{QXl4lslX;&a*Ru!9ZjLs+KRw$ zlvbUnW$^?UarrVI+b_O(HHU{n%=~Dq`1V}0`b2M9bK9p)fC7c9WYVPWU=QU~e#Dxq zfZRm9XVkC|T+eXFf;e{o_GMTe5KGMskFUlu-z0&Au1sRl4tEqcKwqd)@~3^D%XrK$ zPX5cHdz+#5CQpY_WM`N9s&N9wWd&v>!_?Li^q{XO`F(z>gOUayrMI;td~ zNRlvMN(Os>3NVpXm)JbNpd-W(H3)IA=(2l?D}r$w0m;j87OJF3B-dvN=GPLmuAQe8;&(g>AP|Jh75ysxfc6ko z;&l6rw$~hjT!)-bYh-baYNs68jqq10fZuZM!G#uJ$;szeRf4U7cF-eknzZlfQ093H zHuGMV;EiQ2^>QZFa|r?qVFc4w7t;`zX*$d5j@Ta@|A3scL_+J5^Jc4E7(?LqqTuyt zmREB=sbYmi^CSEO1YBC}W|1zEQ9;(N(b+z~U__Vvy9L83zvoh<29UWMFzyup0lG%E zq5??v9$7_`K63P6mN}G;qja4+^(jA~*9|b<5g2O;7{#sjaP56`C;Qod@xZeMHBzT7q?RWu!`Pzp zH+9CCZqdc0`gOqhnN{!kp3-3=_V2G7W7D0#9Pye{2knZ6D_Qa^x6{0Z;0Zev$H}9K zxZF%xjw6o{QWiR_K)yOjs&E}r)hBv*7bfrk?ESUakpN_Oejwg5xNpp=K+{uDXB15Z|3t_m_~5!3Vg>Q?X_(Y1@;Gss$eak+=3SP79|Ww-hLkT!>2jx2erheJ>cPIDQcqq zZlRw(J+46ZD|Cx&taY2bny%g(!3ZSUp+}_C`guoSU=TzT{PL>l$fhm&e&KB1S9!|H>BkdnixSVUh~Dvz6^akPxs&elLj=X#;wF` z(&j|K!Z`jx9e1_b+!!%qW&3Opu>yS@X@_6>TvEWkax!t74CXu%D1CTym~0qbL<;Z5_uT)LLZRco%Cj7;V~rBS41t17f$xb1doB?hTWcJ92VWcFHcIZ5(focRl>7MG5|(J3 z`LkSyoNQfz2^70CmGM{r2C}2I1BPf0KAQNCh%~Mg7C1cf+lvDw#mx!1g%@OPxRuy1 zeZvUJ7?!-VW|qZYkB?gBx-*RVVTn`YRzA`0*%|d=a8mwy*a-9-BVrhA zI@q4b{tL@0Ys)9A6PQksN_|@u7g8mo->YQf1$zq~)NcHs(J$g*l-_@#C}@xMJR*Y| zZ&hlsI9=AL#aQsv=F+GU*&_}nkO&di`D_(TQGtWt^=g;paU@8Dkiox#9)~}ykggp2 zTF}<&e0cD4$y}@JhK}=(Y#%yCR9LWK>dF8Hl+URMr9l9qbZih$DQ;ya?%+xs-s>+< z*A8;cHy-K89f;|i&?KJoG#}v=4#@^)dR9svN;}7p%s`EoU+*y>PBDl>|QG-W9%d z?g!!9KF;%7q``hbEToJ7`~b$Z$~5q+Vv7@!WbcHd@z2Em=@8k=l$s;oD4pik&wm7H za(Xo`=d{V;24m`ACEfwcT8f;L9Z`&7QTGMP+Vkcxbep#H_v7J+6`AFSgB#%-;+#(? z?p61DVX=Y^$3jHmhry5993)+s4!H$igZ7A)G``>l6i8UW8ClVEQwFyH-6&b9H~ke| z`(2?<#xXgncW#BZL?P!8-|4VUiu$Mi>+bp`C4cZ_cqQ{%Ze0VVS9wm1uIG5B9}P5c z?qFavvsZ)k=LizS_f?FO)V zCb=+{xn=&5CZ@naE)Cx}{M415q&nMw2OxXqjz3|JS7$A-|CPm%O((M6iq|f2-dX~K zfXPe%{ZMr(tu$kj?2tNR!(XCl|Iq0|r#|56lW3}24wks(WcMl8RiP>?a(E7BhB%WH zq=5r~zzufIdd#eL*(s&-N@S=`+T@Yc+0!HOSu}(q-IF=nee#Ix5`VC(9CJAa4~P{% z9-0EACfDiiQF`^Q>SC^#=l?+h;jy=%hkqy|Go1j*1GtY&l7P-)StS=g1K#_H^TaZ? zB}KeVG})+h=ZYv`xD2b$%*KxC?DOyjV>=t)--DdTSkKpc6**icDvsB`@E41*opZPL z1w7T(5R28iefoT|x1(Hjpe@}H%G^B{P%r^MGoJF$8^&Pkgs#|ST3Yii0qJd6RK0kk zufUXDLekPEy*)_M`gSIuNq-wvU0HD;OzbSf_6pXAXwu@1K~G@cWo0)&>sBf*;4FW| zx{>Cg-6R14JVN`zZIFPgeh)Ncg@cYZTli*}r>`I*2<4TyE6U#wgKiZnbe9x;UFFV5 zu9MrF_bW9kO;B8**=#uThu{)y@uC|b_5+ZGkDTTtEz2HFu==3GT7-^gv%#_7uzFnA<}J z;ie4fdS!(B@RqaRXrrqjY(r5X0vMQtLwzOvd>JvDm0qi)?4dlIqIEo!gg>gXYo&s6 zuMlV)VMBt#9-Uaghf4=`8Ch{ky%DNB$a_|}|4F0kaY-6bQF*4+b94SN4=Q^=v(C5y zQ=&NMdBuO_GUv5MniM!p^VLMPtJRHDgPF&E`Sxy(8sVRPc=qcidNrHEMU`eG7tkdnK9lW=F?fiDsF&FQ5KrCVd zDg?~fXKA{9;LqyX0`#Nu;)^wHIB;u=lMwedm%#{G2;XqAwa0rB3WV7ej#D)O|N@uoR!kWq$pFytCuCSi(XZ}c|d=QrBsVPzKRHWHe0&hb`SjpX{B^E{aVAd0~*L=X0{9aSFwOqFFip1F}YU(o^n91_r@(@z_mfT!X4 z)W3@b-`$}?v9`yi=@A3e0k8f9N6PDKWCgmT6u2Gf>55f)!6bs z`sUh%v?uG@>Lp=OD3{+aMVOBMJ9F?}F3K{leP?AIykIbpnC{TtcHk6%aoGC!P~(E~ z$O8%u*tGAgI^)N!q~-&l7sr}MvWZQMvzX+&YKPP-SIylHn`$$%xWZnBH7^Z_3MRJ< zM!pe#$jjdCAn!bR$KbkbqIHj9>YErn5L@@tzg7X>90fP2bSwyAyC+P4I~hlaXl&s0 zgzcWjSHD+pF232XIs{YHNp9nEX01Q=IwXFxAi!IQTiHj_cG`WRL$vASP+CNt%^4yH zt-9m#7Y9UVObuUi_ZWAN*r?v;LUVe*c1bst-p~w(r}g6HLK}kO06tHyLE66xFehu%VhlVFYV3>^5~N}{=RT%+wyTwH z?DyXwK!nMEK>&)_%Pr`7FFxn>;r4=vf+sA?A`n{<-oa3(D&>gwrqQMRJacj})}lI2 zx)^j&e#KmoP#rj_fQo#G4Vy=xovveer%8!E026a|$LKRil!}@+_XM{bj2Xnt zNMg6n!s@e~)kSwN3H)(4%^YMj;xF;7yV_0K9u-A4Z`c3%q^++0YO%J~Vy4AQzi~TU zk|TUyZst4KdjshBkR&(%t>njJIXXc7NSZH{TD@~RlxcYqI2r*Xv_IZq@ccLfR}G=< z3X?_cAqWD{UReB?j>~K(0mxx%JzK{Gm)-uY=UVhxf3qSiBRn!Y_6sW;2GwH3nXx@>$LM^mbtSnySIaNXq z8@;O)tVzjxqgb=o_1ot0l%fX_=Hm+m$9_Zb1^Oj)_*De<&OY@+^B-UbF5d|x+))j| zAtoIq*CLxwXmp=X89G9V1!NFKxL0o&?~EbaPo*~dQp@B8Bml~+t2k=YEUs%6FgB~K zjFy`Zs2=dQWv}o*k~Wmctv~f7ZSCLwYz-1r*vZZ1xC%0SX&b8d8joqP+eJE5dSwUK zs*sVqSQs(!G{lqi-O3XSd{Z0Jw&!zI@E-d(F#+Ay5=C{t(p$9^W-Rdd;%f(C$}^w= zHnCRkaW@x^tT6tKA)YH}NE;SVja*dEN^81lsdv9tG#R1ydE)Tsr8HTE!@8r6VfV`T z9>)Xdj1~st#rXI%-^n>+_P(m0FWhJJ-b3uy$Z-Y@4Z(ktv{l=;5J(lFj#5F+_HXaT zp5?2WDLLFnM=%RYoD%64ibgz=D>%=W>)?MYsQPT$sx5drLUc-%FX=_VtJUxTQ6J#& zaPnI_H;fZxuZ3N!{xw=9pgHZ9Ji!a8EjzNFv&Gyy4>Dz>h3-~EZhyQUao-xl!iM`8 z&MDqp#@Y!b5t7>6oo5^DSURN_u)DxxM;fT~ym6C37|SG_e&c6h#O#Jq^xThVR)48$ zOulET`;PXtuSsp|2Q(L5xbFv1&|rDM@Nd=>%aL&bsm);_bH)Vh$emf22{o~8Sufv0yN0+ z1r~y(0YAE`kfz22Ym7CO{+T@^h$2-g+7G{PlSD(eU{Ih@JZ|+XByet^xyJWDD;6l? z_*s91nqk(J;kMz(BAA%A+CthIw)j(8c!A1}jVc<998rsUCLdtE_Il!=>^ZL-rISxbP}ZD z>;O&3TtU^pk%-ZWz9u~WPyXLvrygGlp7lwH^?uv4qIKd{=O*Ogz)D!j0ufDwio zLF-=a=`Zl3MDxgx<;CA|0A7B+1LdA}I#1P$D02TEs9`3dY+H8=7Y@Hd=?gw0*bZQA z-9N}E^AN(d`=Qe+*&2i;kSy^R(M*86w&&xU90sn{BA2geO7|FaJzPhnHrX)ulCS_} z>~onTkaAUsO}KVwz?}S})@NJXkHt}XfSpBT|3ZLP`~fOH>$rkP+$SQRjf<uk5Fz=r`5=KLJW-laL2A#AT60e?9ccQ^huSA2C-iF^0`Rh^v4;^EJaKU+ zdh%$n@52vGk)L*cQq$%7RUgkm@S<3nW+TI zE~msy80BwUHzDk$c!^DLt6=Y%HOHvf0uoH;A84n~174V$_t4E}P*1>_aqyxt>v?e4 zfh*DPpV)2c2VB>&4t(DgXgti-wwT!8y`!jX=6T4^e-#B5e{gU$CsX}BGEaxv@nL_%LR@AC z{6IyyCPkC6L$Rmcj|Io*j)*(0YU@qG%n?YrALL-vfd3Sr2;YP3=Uv*qZ`2(Hdj@2+ z{NErM-TbX69iQp?Ox8GGm2-mpg0C}RMuSfms3Y>L6L5pNUR>=^Y1>a*wA%B7eT`Ex zox@$pZ&j!&PQ$p%p)*w*txr08><*Wx=i|>4l>#;^UxO-(tIw2H7tkCNKNkgX_c2IZ z9#sNUk#nTgK30D*D6u{|L^j%kc;N}wbbcp(`OdtROsgf;oCCau`oH4%8%gsClYQP_Cx5y$eccbEwYw*VF{@^q~PrfW6P|!1VF& zeAcGuu*c4P1t#6xn@R!3i`LY-8`oagN|Fxb^dJ*uo@+md6T+Iex=`1$jw~1XKVzY7 z&jTgvAS{iu8_0`k7eekF1ks003UZJ)FnHd*mDYS=JOEvr#j1{ksU>uYduC)_r0%(% zH$HJaek7-sj;@C&q|*Bw8tIqFK4^fvj(uOdHvQ^k{=UxunljnDUAD0GN zYj_dk6`kFP=ouIzt4djM?%AF2VEUp5{|PnfgGaRusnpq=?#E*@JEKkRV%+c#99JxwKNfF=J~drmEN(fA&lb*O)Skl6E~tu0^OV�o;2Wnd^WJ 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[ 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 C=w5XI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9d669633aab47b79917e01783db1016d39aecec6 GIT binary patch literal 286036 zcmdqI2T)UA_wP+dMZijjP*jlK5d;D%O_1I}KzfrZ(g}zZr3*+W(gcx?2uKONS7{<8 zK}(_T2w{p1Jp(=iYbbojEhfT)E>N6*z*zahiO8NZt36WG_xt&)C9rCJ)`0cv=uA1~gUl^|jIe=d z^*)dFuOI1`u5cZS)7lKO7mgb*-+rgfA*lB<*7}`Q8*B(-6g@|LR+-ME~XGiZn$3 z@gEeg{Ht&MpB>2GTju}hK>mqk{(m~gf3?^B{bc+udi;6W{p#4U)n zC0#x!WUnp|zkIeS%;-=L2~o*vY2#khafyNlrq3{=%NB4?rCQ~IQ0$phNZa+Dyab^m z+lYhZ9cO|*m$3;#-OsBAOZ9`bwH_KYDloWQ>)8RvyUyUev-uOb!5)l{G|;oYzDlTj zc6xfx-M^f5!{K<3~AiCi{b9ri?Q~aicxGgt5_itP|1B@%+S0e7ExDUXx}k8{5*Ve*)Ah{h08q-vSzYa=N@#DTh6LcG3!B za*kPBPg*+>gz&{VH2NjMmlg*41|nI3K!UF?{Yyj(xKxKc{n2~o-m=>Z#UA^_TqXM} zb!cM0Yr!`51zVSXLJ(tu__=blWrO>X%2{g*=E41-g*k54zUAvQX~`RFgCB|pr{V_n zo7=t(GMs*69Zt|}^+n|?QA^lse~f|P4|^eFT7q3$feV_5<%;pe55Hd8;D?{$+9A@kP0y%sR~BN-vf^K5?B6lW_taM)HERENK58SmjOpcpO?lO2~uo9uKxI9wU5k zdpW}|jc8#g3`nS+{=I!j*flz8ItI6yk$Yk5=NTC)yvrrJe;}H7nYyn<1 z4L$VG!uirNgGlLL0CbOx^1v#3{lb`;ro0)B;7%=DwF3R~Q5eMXrY>!0;RG(awF^_vK+sAZ}oY*|glC zaF@E&=8>Gmi!3o2X4=L;QqiHb91HE)*pxwDLSx-MO#m-HMKU}ga*Cv|c|8IXA@22& zU@!__mWIH@?=VXQBfjYACKR;XX#c+9@TaS;0tkCG1%LXTmxT$hB#<$y=QUMo19qi z4HQ4v2?r3JzOWMvR|pof0S3ZwAObA#HJ|#e_BV@ju~gzGErX7Lcy+ileFZG@sXP4Z z+@aRo@ezcj^^oL-u&kIVw%$5Ri~|ZP9?}ML8HDeCG61ZgUm{){FI8S_t&}E^|m3trw*#3($@DdKO-nFTHA20xkTI!C!w1Nm42p+$TBs9F>D0X%e>(AXUwqa2=@DPB-^Y&T zgG&0}gV|U}78}GJfBAOC+?oD1jS^y!U|*BC`=Cl9aAgd;KN%$R6)PHdqnz}tm<2iu zt&1RGkti|r7gp|>^s2X49Pkr2+}lvF_yy`=?fPPcK#B6amoAriOn}c zV)C6USnG~KWUHx?*)(-3cL5ZhiAqmM2uf2ms2u-NswFm`-@F)aslm<>1 zazlU_E~6pSqpi!!>WI?0boM@C_6r?RBRPzN>uYPa3NiT1i91~lTva3Eow2n%k zf0(FCv)g&s>}&M}hSt{!qwfWS1m&d7@jpl}qc`pe8QMQ+h^CkE?2-1OKjeHmo60z; zW22uNmhUC}q_I|ZJLqIL25uMg)A2Qgng7sSLby0M9g$A#arp)3!~3r-ql(sHa&OOH zI~!(WrTU32c2F5tfK0(ki3(L4wsiVG#;1kM*y0mITa4{qh@T$pY4=!Dd=YFeh`cY` z(zN^nuLnTi6PN8H){e7qwD*{v9?^Rk8wNV9qMiIHEc8(L1#=()Z-hlaVP1e@Fj1}p zUObp`4@?FL!<{f-=6vZo*Vn>N6fi-ax$Ca)u@1|SYI-hmdo$S*CL^{ zBbUG4GpfQb#D0wTeMh`hW^_yjyc^jD1%Ql^o1Z`__4eUbuXMpj?`yBK-}{&->i^<9 z|F652LeIt8k5|j8$Nby*K2=K7^`;zI-1EdhRaT;&e4;Svm8CUWdh z>xrj+Nu2(??dIt+l@&mdWJ7HqD6j{*p5!b&jT#ME!L&nLJz5B;8N}LA?~APKe--Ig z6yS9UJmeTZXx}6x!_p=s&C&+u#%$l|w}NO|>3qoGJ?1AM=R`O}r7{OYTDiT*0nc;5i9gIHSu2?_FOD(k6tcd zj`AgaE6yXjb@C@bmHFF3d+Juf zVMU4JjyXN~pHSDc=f5S;L$W?@nd+~^$A@wriYX3&RC*P zi;xn0R!$ySTh($jCn#P1(@aSxDZ4Z=Yq_t6@j`n}ELwT>0@=X)esT1u_1hLRsQjy; zUNnSADX51I=7%ppwM+Wg_QkVY!J=(q?hAU49BI|BaI&zqsc5&vQ#{XJqWLbN=9S(& z=GRwf-HW}%|lM8qJBS7h}!sQfhzy{V8rWa-H2%RiAG3;kmCT9oGNSO)REiNpr; z8&(?UR-*K|+P*VWrNG6kVY!B2EVc!D~7pCC%pz1BV>Ge*;wCHyN0_E4V@r$FG83tQgaj+yHQafu)6F3#kU`oK~8q1 z5KER@FM0li!H$Vbm3X{m zT)zqlEqO?KgX7Uiw@9$ZxN4u~;Ql_iglP@Ip|nhqgtjyI0>mE`UJvHF#l2#u3}p|} zU#Sc_1y9Og`>ztfj#hM~jBA~cOt#;s$TcO+w?l%D8Ql!-UZU3r2PI(E_k&i;| zl!79eSJ*y~7pp+@kBxFTzYI-kXNl{chUWXEa^0GD#^SPkC%fk0VXpSZbfH~Sp#>DS zHYxE$&f2HV!zo3?x&fc87%dGYPg$*UKu;0^F4-$bs>I_As;jH}q^<-Zrd)$h2UF7+a@(^9R~ausuhe-$+lY{S`r4eWcxoKfA=z^P~9!Lp)9$w_91 zR5a5R*RFA{1r=eVg_;#t-KHf=0z$FKx!Iemd7h%v!KQ|Fxy9>D!Z=R^P8{|*PX+b$ z>+J0J-V9I1xWJ$4^6|(bgdvLdl>=20y0ycWAlU>!Tmi zXVi#w-r7sRk2D`rji9rc7SFDt+&Fjp)Ot4*gZQ5PzJtH)W3yt=! zyGn(p#U;FS5bn&-QgZ5{0EA9x_rM_HM;tv}utB`LerT0($ZJs1gw;d22%9XKCj|h@ zAYf3xkX3ewCY)C>fY6Jk8-G&oGS~UqO{fSNs&hCNl5;IV1l;SD@0__Y!ZiShv17cw z+7jT6hL0n7ed9w8u4t3cge^x&D_PU&5Yc)Z*ewC`()qGZ!FI}AUaa^QaR7TH8Wb!8 zU^Mx8J{HLDjXBwvh_KbAIdFzbgQ-C482f+Ui(HGZJr=b{^xkcbea-VJC1rsG?uC%3K7M|UNrF$OZ%KR zYz1bKqFfu_8yuD`^(oa{gYYt**E}uTw7*(1Rp1^}o3C-#|GTc`>l0ayV$=GF=*Y z*s3!(4sR)4x{h)WPbl@KCb}V!eu2oIl6oEh8m;f^*p!Rw7)4G%Fy7O?$l&E!G$GF& zhKYcEFf0DcIbn_s8w2;{?cC`>=ba-s8%>G2PKNAs%KZZ8vfh~cHJi^}BZb2E%Pkm_ zt8*Zt*{Y|0exa$0BW4r5e9sWFA^fe~5jpWft%^~PT*=%G#pea7K|_YrHS;H??-cF8 z?X-q3gA}x{oq`1=Sd!#Ib-g@k{l5?gwICE76o8Gkz9(ng$B9xe`s=3XtG-Ly7;5D@ zu+N-r1nd>5+zw#!!0#R=^;1xfa)9I&odKLd#CyM^rhxXslpcN2?04(LTRlrVh8?Aw z?P_&l;5aVr1SX+G>FXGcr%grOADg&$9V|HTME!oK*aCZT=OelK^obE3@$OJR8coFA z1Pf-yRhAqljEL8SjrM}i@ZtnWo%8Q%=J1;Voa*@Frl?V%mk}cq$`9VSg;xgi_ zPxR`fdXIWaMp?K|QD=|JX6!-o0~%&qt5?`s46|6(z*pZL39wA4{=Q#QB*k7pk;YAU z3$aVT4Xgj%QzI(p^-b^;2pb2NyB(;hWr^={4Q#I-+3LD(a-fgjTNcPMWh*=+i$|;= zPL7Icn2Y@&qD0MM{V=s*u#7J}wfk3fJj+hMDM(_t6lwvbnae53uAn^9n}8PXEA;EVs!ODJ?J`dflA_E>w2 z@H+!$KpPjr4vpEyXaH4Hams4y7pDV81QqEazWaixv$PMMKoMtZQ$BHr_DZQv0?JbD z%u@J{cgl$07g+3ABz^5N(WKWPKXb!Y;~pulcXZ`7ozTX6 zWH*N7lSqG#jur4h{61)A{V;Vj0ZIinBZYu8zIvbd+1wB(iqK=pOA5-jH z=_ie)mpzax$&WR5JfG48r1^`AQm%v<@moPNYBKx-xrdq2h4D4w`Zy|utyPb6ju=** z(N#Hy8#ZS3N~M8|bHto<)~mCZ(wW+=VWq6J$y+WNCnS~MFqR0dt9gHPP7teFH&Iml zN3<2GZ0MDJnLSy6f53Prn!!U;pv*EwQ9L6wVk?}&&O}8X1>KN(lu0Q8i;d)7f&SPN zYLV{%J>pLHqxNCXwLjLRE~fNx{nP=)#kjjyxNu;aJe79B~!k zok5%6p!=5JDXdr-2PQW7@vzCYHsp?4vk1BfL()!o;{#*->_r2A+A{VlYzJMBJ{hTB zz`8A1kpCgIKVpq6{Cl}~kLg?8kUuS*T9EJfa}vMOnavheCUpRQ-X0`|`~gH{HDM)8 z?+EeR?Vo(KFOZ*xEbmkHJ%5EMqW=ty(v8BIeh9#if4FC1Ym>7p3}Ev$dfj&)NEq-y zm^_QI#nV5$Z7wnD>&vkfrthbOw})Qdx7*bRNk{+6rny>VoQrRu2H*x-#eYB z>7|YMZmo_G2cCUYgDW)|`PH-(2N~q0-L$ z_c?cD_FjRC+(|DZNiQ&-iUC?hOjfz?ah168H%9&NxfGBJFk1%w7JG8o?!06MnI%qx zC!rWhm;H_RFptR;ynFB05Gp)eNon8gAs#Mc$O4`;(SrUcN+F9TG#ZM-B0kNP{S_(o znhyr z>{+ROoH#hNd8X(cS!2MBSTvMUV8rHER%&ruJ;x_U-)?-}O9;Qw)US2uA~Bg-K$l-j z=;Q)8>Of`2E6bUsQFuBaTtI7!%r9JmiCZcHREX)^$3mE(O9&;vA<#J$`h)g4M~>W4 z5)=YD{J#99_-n;E1RoLIKIH|4Fp!~;s^U=4MKhMBuIA>La?;`xJpHIOCw^cCeTfuR zcKHQ9ft2NO46uZOBYc;CcDpyB1?rpC>&^aJ)z?}vN(qZczhdZHD-H=WY&7v3X0z~l zg-lV^6OHZ0rIl;j-hsb3TyLE!?R-|$r-^#b3jvkQK*GGzkqe7)2}*6ZBco!|y*o_Wl z?g5SH_oo#R*SUBSq)6XxRaLO41eeA$I^k^z_7Z#NVR+7ct=3g%My}CeYfVFBfpGQ= z5Un(RyJ~N6)I90Otb87X2TK+l%_Tb^gpaX^B>y*c+sW@a>}^4f9@>k3BjA{oTFkIl zXPR3?=R^~8&;B`b6gsGn1KV(l%sr^R!P33wK0@6UqP7HmeA);)s|Spn6Mk@t-Jliq z{7j0GMHO$|PXyIml-JSBtzjpyc|>z;!L<|A8WXD71Mu$1&j3Tvn1!=rG!n`lNAxE^ z`bDl3!GVztxij(^uqOlwW_2nzunrJix7#bh!DI_nVQ1-l3^&t;tz4kCuU+}b*#}lT z(Z?+f*xMaXHy^ZE{X0i|nAM;!bRi&#xiOu)8(b=smZz+mS5z-f@xK_WdMzdoKCwBX zT{@ob$6|6zBV(R!&-Sv1i1V^i>+PCjg(?BT=-MeEcbT4ZaQ5OA z7CqGrSs5p~qZ^ynja9{oqP{OUEAG=tgFd;tqmof+^~moQ!5NlT>Ig+FxXGg6-Q^HE zYS0Z@DTIRg%lFdkriQ2UFX+eqQggM6!cgbnNRO zQa{{*sHy(Y2r)7t#7?!4%R=k#Dq;)2FDlkTdyeDj^tv`l5kF9w zpWa1Xf2$N`UpilV0CVqs-8b(yIl|V)ue9DnSrQ+RX>G!g(U2mdyl&V7xltKdgrjxy z?Tiz@)E{>^&?lWN^jc{c8gtr37uwQ9mDzNq_iKuNDs%BWt)T1TsZ3LPsF$HGMdHde z#JaXM!v6&(djB0v%wl|Dk!P`L_8s)MSGFr;_%(1P$FMqAHUW`Zr{QtN=p0rrKtRmf zj6kLwwF1i?yG2?zO*-b`>wCazn@$<9SEB&6{}8Z2CE)AdfE!O44F?cj_K#Cd$ejBZ za5}uw9u?ghaoXgT{(Q&dYBbaQWFR)Z^X@AiF)cxjdTJSSG!hHZbo_oDUIM$3X4Z z)?XW{9^nnP;pA)?3$MYpyl4=AE zomF`_0!NalXsxt!FO7Q49_6k-PpdvwUT(Rb_#i&i>Gj}K)#08JBmKu(TRBeu3@+Z= z`Vdhg`YxEVN9N`%8ND~Dz+~AI8Al?;9$VE&I&X^0;4HqX@_bVRI7m!sj2+10krVin z$!#97zO0>Yg3Z1twF)FyKBNZru^iw5&-6W|LYVLG#5UIqfK+%D9AcYDI1m0DFXd>j zzn|6xvWE#^;Ed%bT9VTK`1DisIn6~pCeVJqk3m2IK0wN*SBe#MQjR5{1LqK@hY9m1 zu+8`Ue>yH&+?`-I;z%W6E_mlzJIJk-&1Z7!9s`hfW%nT8I${788|qo~LBDu#0Ov?g zlxQPu!II9^rbKORTjv+5kET7iGBl!1V)eu0Q4*MK_`95wPBGfs-u_;VE5!p3ZdB7% zX_7F}WBlEf+H`C5bV36AKK6`K;VQf#WFM!}8~%K|FOgN6eFc4FRK6Gc-+*Js{|q>` z;%Wprg9!N+tF>%<4Qb7lGdcf`8$W2!WIL&v3tkfbh)pJhe~Vr^bTR&rME$XH4;iZ` z3fia?ph>C*z}o36{6Omvtljgy!D_X|Ls7iRDqQ9j;j}>X()H!jE-*4{)EA zJc#0WdeklU21ifQkAsJh;&U)m*Z~|~;M+!^7NVty8z>+RkXu;@3uqR2+imc6bW)l) z0$x^j(t8*#UmnybNYC2!p9nJIKM>?T1L3Z&#YPX;$VjxZhG=f2zuMO7Qf9yP>xmb( zUX43CxPEQO#$JGvPAx|T{mAEFY3>vg-eH{XlIqWNrv$5RS!vhIaf%p_&A1gzV2(h z_mNi6Vy|{$504*JXIW=v-xeLCi+dv4 z8rR#Eek68{JUWCbU+*COvUI>;0Q=tqlji;Z2`24(Qde8vKV^XFR_s&UbOTi$KQ6s^ z&t%RY?+@ztsGIDdr@1{yUZXd@dQmjlcYOgZQYSSYAs3q4ur^a?->)$UO=@Ic#!v+i z4Zwz(Gj-~k#RcPH5tQLkpMYKx%qP7sHp`uxCB%65_76k9E$MNU#zfjsvbvE+c=}_b zuh6l9GBz3@maLw$b8zU67%?T_I+!2r+CL93rDOnGa-J%( zF>#~L>dTzvw1y?DFsKl$6JYo-q38MY+S;N7F_b9N@t3q97KA#iya&!_HbnPJ?)%E6 zDZ=@K{-m}X66&QR6O*>J{C*A*A0x2?da&hT@9v{f9kb3*Uk+YxNG&8NM%-`*8f4+P zUmgd)yCmdFRG35jTKri#G&^*jljiB8fsxC}eb5zJ^RxKrPtO^k((;M(rz!ssQx-UC zWb7!2pkQsVFZaV4M}l}Ci%DW&{jfxEgNpr<$Q{6J9acc{x_gfcPdl(3D#jZJ|8nur zgsB!QrR{^7LAnxm?97&+XjIp$JhNB#J_CUp&>gSUyWyeon>+?04+hD(utiPy%a3N7*Nr7H{}>H9Bgqe=Wi zQP19Sjqdkm8{N%+^*V>y8TS@Y~3_qGec8cAu??4QN8WKGXLVRysgu9o1l+@o-Hzg z1<3dwgyBAX#y0vOYlbBKBClA~lPH>ZWu%F+`(0_HCx|{CQx9PpSO|LLr_{n;u~6_# z%kkG1x8=Vw)(zB}(IVEpju0Wm+KP7#Pm_ViXYYK!Y(9o9l4aEFwtEFpR*-{DW6B=vU2&Lp)fqZrX%~MYyWR(XOLKSE(@%U2|U?Rc=%hRAGmw=t}+&8qY}wXyE$Ot zx|-@{yyWptvZVuN1rE(rwlA)lop5C$Tc^PWH?iPcUpxv}evoe#`>6}YbDH1`wiFJ&uT1Km8P1O? zn7JazTbL$%t##*1yfCu3K8Q78A4K5GE; z`&x8qw~R)uZqj@lWLjsx2UF!tW@yos1Xz8)7%Y&C-U8Pq_am zC2gOoSYV)=x4w^f5OF+&^?;xBNrxt`fj1EnSl$!wti5vq8y_ArI@d7K+2w|U6(}PT zPALJa=fM0PFXO$LT#|%aRXiB*E zcH!gxgZVPf$e?O#uJg)G%)OWPJ7>*U;$mu!W8#AUn8wM;-B8O1*0Pr6Ey zd$aWZL=~AA*$orMRCXKe>{~WnxDtQC)!9BAHzVH^1rL|S!QtT+Sy>$HTJ^0oOam;@ zTI%GJ{fFz}?GlY&VSZhFg-1wL-`nPThl$7zSk>Cy93uBBWz_0N3}g+g-TMbPbYglt z>jc3Bb71rdP6g?4wD!;+dTpQtw{n!}>?OwnRrDPo#zEw2$l8u?M3|=`g$&S3{OvoZ zsKdqvxE^6kO$9y!qWmUb68I2=ygBfV+s>a5>T&(AUQyQ5mYJg?>L}=Ldfsf}tMZ%W z6}tbGi{9VTaxruu|BwQE!D#9IUp19N5`+HwG>`b4f;rIbac*go4X=HZtVc43d!aD^ zy^h*(eCT0(1^Ca}}T&2vExaa%a#? za)FNR>JI8e>f9uZ!jn(;;x{6xKMOLJnh~l88#!zc^b%8{(2sMpPrsAu=yN&^aU{-l zC6(n@!SYj>19H}~>*l{#Mg-VycZJ~{wB_WnmMdVpa|C{r_JcG?f7O1l9F=($2*<-8 z9`q>=XB*F*AeIoezeoX6ny?=--es)Ec=Vunx@QKVAZWz!D{QMonidQ{(_4iNz{tT$ z{_Kt1m;HNsq*&W?T_Cd`cAy zN$JfZ?tWox6ps=#=#3Q*sR^lN@^7sw_+7s@J76a?qLykga${lv0a+K?e_N#LW@tC6 z9#{XY3vZMToBF` zk^lqq5YO?HJ+wcC+0rZxQ{6uYUVNlAe)2jKIbn3vL%we-cA4<)a_JifEQ3s6_QNGH;qFMR&8llW<2Ac{dO0@`BNK$#b#r8v) zU}*ZqD<{aY3~d<rln%yW}{81-7Usx1D+ekIs=;2ZCg`ThdXhs{L=l>wnW&W<2vZ z1mFUQFTI&3HE+8>o?+N+;p!tY?2J?&m`v_R0rVb%Cc;G$d^v5Hj`wtwsbaxKb9F*3 zV4gZOZ?5@Id0)l^`aUJN`%Z%+VJ<=qVw}n!v*oS@CaJ{qG$%5}@OQDzXr#(!Yx7?c z*IUai0*fy&Fu`s{zD;7fVxq9Mj(N~*yq|D+0Q-(c)H4Q4*}Y?N7YLtwT6Pkn91}BS zb%;x?xGe5`#f<1^SI}|y0n41Ou7O+jV|nb8F@0x!t++Yjiq!86{S>N6{T)9JX0uET z*lxenplwdo-)rw*7krJn9sKUGoBf!7L_m_e8n}_cn3`l=#rN=qX?MVR8`N-GwEee3 z3HjpJ1JEl$5pejM08?7rlm{%w!hZdz8UaNnHgWhw@CS|ye7*wu8%3t>Rc2@C+R=rX zdP#+(Jf_KZ5z(XlZ~DsxIVJpPP1}cu;`a6mz(YKu4`z3NIGxe4)nIw6@j>>!deNg* z1SvSPX1QB$i8MLuIb&-)U|QHFKbP0!wb8?l5Fzc|RA_LKGzocKXl>d{T>5zRxsrbS z_x9D6J}L>PGE(*{hPiczMNv0Ct5MMJV=3?7-d!P1WH)m2k#IUQx}+99?ZlrcBj4U% ze&&a*_Rmn(Ur~NiueR;*mh<2Q-%THS`5K>TZ=-9dBr~jFCFI_E)z^@WhnJ*DK3)~{ z<&cQ!cwB9E<2N(1fJ3PVKKF9JWOVf$n7pw6`jH*!@t`W{d&WU*GL0z@AJ?q!UouS( z5vjkIY4+OK9o`N${J3lgtN*sqf;vCrAh`$A+TYOoTrkA$UO|7HFgz&E5`1ui>jyHv zo)+NIdff}S=fBFt_6GiH-YtWrq;0Q5zG9IdGIWM5uYuuYU)x~zU)G_stE*U>ZNvst z65u_AmhHBIzupzD>_gpA$V%C%xsg z((6jg$(W$LHoZ;o zl`;hW7~gCnD%%6gB**Sm%VrWU!{|a>8F?&X+5Tnf#I~bq zPYrbivG4{={dSP6pTWzXW_9umXs^2qo^}*HaPcDUIAI<9pf< zG3re(fA5~LweF|AnER`yx;`I#vduSi1a^iwT55lcE8b-FfOQUrD;9V~Lj+Ws?`tR2 zD^)i<#%9QHf50TCN`MU^W5uaLGi%?yZxn65I|Y~Xdv~bRs48^t#ml+`t&5!M6*iB= zsbuF|jZ1&w&#->GC^_84o-g0aPDZqG{*VW6Taw`I-}0qoHc>A{=dtr{aYO|APTKv? z+uj>J{ko6*?Rmyuc;QcnJ-T3agi|BK3l4((p$%zxTz@7ny_ITlZXBeizpDMg#7A|Y zQb2A9eBQF&BFq%XF!>hS;13Xiaqv;u_tG);T%aQA?!T)%zqPXbLzQRGTDp9LpW5gj zl_yllGu=J3WKh9O{LU%AF0gz`-H~yt>Elmmi`iC6o&?C_{7@Hgz{BIu#xeEA8;As5VTkV5QI{Z|b3>vqM$< zL~lM5t5Em#@URk3emH1+cUPUX@Rn}7XlvO}EMNawX{InxXud(sI}aqJr08GwsQJ2o ze(vd0Y+B9qM7h=y*#+e2^R>$_hohaq7T!-8(XCDXVJPmWTDU)5;cZ2oitm4{$&nF^#;b$}+>zDXc!7?x6D!5p8E@t2 zl7|b$phS(w+VB!gUQ0)dEYTr!qm_P-<#9ZeOe?W28Z_>4n(QAplBl)unkZOT9MKjfhn{@X%9 z{uMs}X=fJ(jNl`?U7Lg6dR3b`pmQTa68RICK>~D8DEwqpP`y5V+Ig{0U}a1x|JRW4 z8$1`#R)==X`5B2#Mv?ZHYn?zambKkhCj^`Q1BzjP=TbkS6)&8qb32dVDTWW#`cz2$fy7!c*|usII!uCi^6%x z_3K%>4L=#Impzsq^~Ye==-C~9r*m$T6ADy<8;sMTw?ESv**B80TPV%j4b{a}qZA#R zQ<-$}D!kic99Mcrx4n|u-`$w-gzd8Ew706=LVyc+KSf*843$iQ0aVb0iBJ@=xXDRsGSJN2gV?a{~wrDHnZ8 zfqd*1q~!*@y?AdIf>gOLGjUp@%Ef?q_B7Iy_!x6MMq2pdr#Qedc!9@aL&-^e$8Vm# z&p+9;zkAYTqol*9|5GZ9i{5O<8hzcnom}o=RiRz$+(fZ zl7MHDapYi{a&giAMl&8gBGd*}h2~$i=Ds^A0g64P0LX&yVt`aQb>MlfTr0^;Kv2t< zjz|z`Pg-#jrL&5AoSkjFB?;{@b;_@}>{o6LaO3M=5HTCzp(BH?TF+OZgEY)PK30#} zwTZs=w^%xKK*I3YjYFf5jbkL%&4m1X1J^OZCe(C(RC9jzu%7J#Mk_4P?D;+A0FwzXyx`FiA-}te1weP*1r2Jo}7gFRcE}pGnZLk7@zgAJg zvm95MiL-7}*Q<9TOjr~1yd|8}C{hWZEx)%giEys|cXcV_)P!Sq9Mdj*{^8=-?HNPM zw#OT>CUX9{VdiM~PJbF(>ZVg0Hay1tH19K>zJX@v4Tik09%6V#nXJpC*YEK-ld_Qc z;Wh=SxYp8M76!@$ih`9NHVcLmDM+3hSDC;D+Uwhg!eM^IxUVog zku43w>SYD0X!K_7J}4Xp&R6_KU;s!9s2{w)6e{Fs);GUS~Uo=;KY z2H`l(%2%=4Z*#N&jN?7+{^s?I!FU2?hUWOy18pu4;bK?TxPR?#Oz>LN;LPNhb#Q;4 z4ZG}4zm%2`LqFkpR~!GjP#p4ubu&!z2H}B8`gJUKRt$i}rY-wIOzyl2==XawlPhCv zB;%^<^5n-q;FJ{(;ReCykRuqs1t533|BWpi6vLM>o z_6)8mnSa+J2W!#iy;n)$ecX&Qu%CH3DP-T2NKDagy=rSr@tqIRJBM7{-uZT#QHhGC zCxeLnyRN7g>~RFw6t;Sa-nD7rejpMyPM}2|?blp8mzlpV&cnhEg?^WVm@o!7#HE|s zVmnC2t3q8&b*##=c{zskH1=OfYNKtgC{}i?_uTST4m%0&x#gBKedo-PaDJJ{;)@Y7oOM!zmc#X`7`4kLGS3cB_r4qe_4uc zEVboFAKyY%+w!gF0TEyukWpzF-qK&cry`CBE(bjq>`!IUxZy(?hrb84oP|#)5hyw! zYT`m{|Gz0x-TqUNx`GoKa38-b;jLVbK}rHb$poUX^kH8trI*VEwhT)?k0^#JWhL%K+xud*SduU^y zOltDPA!DaFjJ7ap*K5+DK}}@&&eb#%W%{>#QBgjV%_6d8Ol_tn>Sx8`=Ejx>SaP~E z%9+OrzF2=&e>NDMU763L%lC@uriBLBGd2D?^j=fO1r-9daHRFy`ETcK&#nVeE|YL* zHs}&Anp%DtF}ZKaZOS28eomJudsZ27Fawoa!W&YOMYih;vjs1iSlf zNJccJc+><3mJEg=W|IAE7S)%@U!7go1M5A2+zfDO%C&+^loc!_FPq!X*@yCqJ!oY( zXgR@Y&5YJ{a#wx>lhmR7pZ{1ESJJ!SD|ci-S#y?4I=h?S0UID<*t?(8D-&QXV9sNn zV-}Blc(}u}@r$g?_<-nwEvb>uxjsZ@rxlS1uh8yf$bP=g5}Tg$!XdMKsttg+ttr zYGPmO=&OK5JaRt5xl~(Td_@zbRbIW(b3?SGBtTeIq%7b99GDljTo<&97{9NVe6ASB z-#ghq`=TYTCGg4pjBtG&kshiOI&_!>c_gueRk3;JLc}tY5b-{Qf|aD8#I*UqfR(Ao zjmVg?y(BH!-ALDT*QDICYr?04x^c&8Cr9g4xM}KBV zuI0oI`pGx=%m!t`ye5rbKAQGfB?rSbIe%ZKydrwwfQLVpXp;Hk~?)+DpYp*a*F%wk7)+qeV`j+*Z*ejU-hr0K0i>k}hc1eN~M1o{2 z5fI6eB#DKRgCaSY(h~#44z&eTw((nd$E7 znd$z%xvq20pHREjwf5Tg?|JT3@^~(#VI`I`v#Y0oggJar|1joM;5mLAtlR6%|n5{+n|o<6SG8J2~d|O2x$qd6YW$;(dkc>fW8s zJ~|!ETW2gSC)Jj|KB6Z}Livg{`Myz(-|13m(#M*+d}LXP)lD%c^UC?05<&~6ImuQ3 zQIvO5+!DrQVNi8>F8-LHY|@-YmUXSm6ArFrMonAH_$vCaOa;(`9F0o}qGd6^oAUbF ztYpXTYF|Wd!~t%(v`l!{+i9|5Bkps<801?TtLX77?I%QT;=< zZa(re9_5vxO@XzNl@9^yZ!V`x#ekGWojf=V4AhV)21FBzuYosO9qK(y#9HwQ1GjZL z>4FqGds+O-Op5St2AVmp7xs(^&Ssh(qDs{VsLXdP4umya>n1AB-B z&>6Jrvm6ntZ!Loa4-j0TEvWL-meM*X`ySPaoZs)=o{xV#R{W`$Kia=)dpngD@%fI) z0`0;(+XuSRDmAj=;#h9y(5Ld{{uGbSO8BCQZ{1uXa4OLE z>k+$s9QANhm)3l;lj&^6h7bAaX@488b9e_meaH-O9?;s4@dzsPb>UJXAhdgF;6jj` z;GJG7Si{xJTDwk6gGnF3??d>v5%Ch4BU5y*aB)xV*mshwe4GE2e0AdKq;5XIpSpJC zaXh8WDR5^L=_>dV+!u{fUye?)k2q*W3oC&%pwS(_&cEWi5UAUVkAVFJuV?KK)y#eH zgW&C_XCLrO_qTIz(I3yab$Dhj3Bb-!gV<^Bm-~X-Z)mplE#4k2tq1lLF6F@e`>sZW z^;%WbKtvyWO(eQvxxa97W;Tm5cV%pNEIg@JU0Dh>lfw<&6dI$*dJ^-$19^x4fV`Bm zU7;vgxQIeizC2@ZkX%qgeKy_8xi4Ml@&k9^t8`-{_QUZ;FNVnYlqw9g3Vo7nX9-I# z4g*^P2|%Sb8s=NY6xXeY6mgR7H|fb@)_`oG(1(X)of#*((Z1Xlz*p9h^-xIk>5#_v zhXF5OQha$BxXxJ%Rd3aKjXqbdhn6D$EJC5yTC$84x5UGOn}xc3F9U1 zL~RttPuWZc-08rim7IF+>?QarU_78Z_Z`<1;_P3uMR9)6<&@k9&>O_vC4ry?)qSmJI@Rjfz(kH~TeJ#s6kf zv-fAQDv1l|V7fPv2?IISY3@T0jF`Kdh%M5?9NvW2zh;9lU8-KCz z?HywaDN6uq7;4%{B9NIeR^v{6e|9%jwfQo$qW&J-sLxma4Ilxg zepyP5g4~(5$W`JWEt9>ke#`blS~owZQ_^t=SyFKhO3MVdj$91YBNh!+JPdG8C#H_N+jj5 zHlj#}+xQ8uRazVxBFo~>7^sKKs0lVlo><~2f>rcmJCAz0_pD=qjf=;d(LJ{`bMUC&j+7XmZ$ECg=2THV&pU;}>cnHShNv>WDY2e^kRW8-)S-Z6o9p@ylO$>!@( zfXk8Ryp72sd|v8ZERZu{!SIwo*pc88F*!a(4`G(FJvvE8etI)XYH@hE4!*F`lBu1N zl|LG)a_nRvXgBn`mp-G=jffm-WONX{N#_3)?P@9{r=lGuR0u+Zd>fl$s^|HFmb$7s ztgmH5m3lU3y?;zG^ER*vQ@1tdxVyUaJ1FUvX{vn6p|VP}xFYvS?ynmCsU|xmMT6xNBKgmt&)JNj1(^_V$F6Z zdHi-{O+8P5Dke_#LNBUPRIH3^x7e-~-}sOHhmC)&|FZF);VxGw@EZA`|3^0gJd-RF zGtu~&S+7@P$(0jq!y*XakMnIhTSJDmiBX6*_fuxzLVK_;1epOB?b6?)gm=nYc_VZ| z?KlS6uimK>b{M6v%D2&32YIU?%I`!VH*m}Db)Jo)7Iy&INnZvA?PtT8N(^ztI8*r?4c{v zOZ3{;oCPuwVq$-<2I#s0E4%6SS89NT1@R`Jw`ly}rwK7BbcZox)mh@uW_J9JtnsOo zm{9ghtykTh$Zv=EA68#!$$)Md;1l5LJZoR0iV6BDP$;AIB)-3LKK<%PmJWU`Y>@Ea z)fuRYBNg$lpn^XC2po#RwqzVYH4d#BvqGOV!zKw-Wr;7^wmt=<2Ic`z-v69qJ2nsaDztn9qSnA+y%3*a_2bf!P!6 z=ndGkF*w|o#AtLgL3+2Ikzk8-`d-TaG5_re2@m;o?hhdSr~Ls}xwd8>hx7dOAP42( z`YA$how+5OcqD;#61J1*-RwRhOEEH5wto@-GM^b|>$}b!ra>Cm+mz$<_d7jo%GP3y%-1srr~y5J__#Wqhh?+<)dT1PJePQ~U=e(2X7$RB z6EyOiY`zm2Bt-myqwnBB85AF=z+zNh_iQyvPeCUx0|%Dn{-|-_R6U#DHfpFXz4_qYBc8Bh@Naz_&X&bSpATB~lU+3}A8y2Vc}PmCH5RUauk zIgmK~X&JEckPjXh%i(6$JuVMWJF)u_`Z)in{%|pHi!dQB{8d*r)%+&5zu=QHztc|_ zaQWK)ZF1ZHvw$v#$6LaWJTB;mC`crb0vbGVcL@pZ0v}6%Czgv8LqgRdlE3a^?9;bKS$PrA&82gCEaVwR+vvV{cK^$x>5RwhL%@;YmODNIHJ-YOQ# z@)60NTsjPAJ@M-kVXuuYyc?}dUd6}4%)-d&CuE!I zArI|JFP*7zgiwjzZb2ELBAE>>XTBYM1jdtQ*iBRwh<(26=4EBaIY@wd8w3D`o2aHB5JW&=8|gTg+9kzZU8-srXW8_EDJ`fdp7pMybzG z)hXj@Le*%Aal=_0NV-v4-yfC=cq_MD%oSFblR=KNMi?&v4(6-K@-fVs1oI8-Y7K$a zo1VUBqo3b$Z4z(|-bJps;ei$Af-YmaT_PiFj#2|A^ZkLEcs0bTN)$!4BO;C_Pq1 z0vuwd8OeM57iGb}C3%_gUuOSGl9yx{D%K28Rvo;e10nE7meF#X1l3n@WWv+Vu=xlM z41gMh0-#$Nd|eg}MtSuR>XSjtR(Gdj<}{5KbRh2@7co>+9r7bh4V&_7^Ux*)vT zu~0x=H}{#f{#${bW8y&&yMHtyr0Nz`i2@h(ZL__hlgu#D>Gtq*djRPni{a1Ck(EVv zT%S3+(002c&M(TASzx{5^gq(RS)LDUF;*p)>(xWFKE||T7G#O9=!JVETjk!UXN8tz zuSPCB8zUI+c{+{v(6*jtMGIF8^Z@TyADuQw=7CIpEpa_R0L>~4!^u@sQp-@uhX{d&S!* z?*csE02u}lVh51MkNqBsc}p49fgZ1#+;EavU!3LRWw^C9@!#!QcUk#xn^t$mhM6k4 zo*n&+N6dhz1F+u_=Dj;JBVin7L^hR`+jy5H~ijlq_;Y%mJl=xBCU7oK3I zh20fYziB}p`O4dT*+^(j$0yb&U71-ME!W#CHaPrF-P-(8z9n2$-qm~<5XyymxxaQ| zn_=_msSe`!jHeH;PR%Ar+2n+V(IzfMcW_M*NR>e-1hJGdB+A+~-oJ}w#3SYiDlWg# zK63v7WUKXV%F41q>D9vpu_LIEl$-tx3&-{*U)X#L-UQy5fl4n@b!ee7YNV-pWto$Z zelDApXmN14;LBEM$z~S?jx1eqn)&ke7wdy4Xrf)9&(lTtV(m>cZougYP8j7J1TOio zdif?#I^w;8>nGY^Go++H!x;BtMj42FsL7Xb1=C%A+a+)vAo?gCdu4KIUda^?Q( zBLGLS68=RgC3#+KMR?DrId?ODAC_uqwKP(ii$m%2EV|aOLS{|E7srg6_xe(ADqno$ z^po9G%8=T}j;Q@tHEzEqp_F{9lzvQY(c(k9W9YKrW-LWE|6ly7Tb#f> zTz=`8tAkG)qyYwwzi?TnM-l5dSBXK0*evh@z;04B{ z#~Cwfz%)HJ>uBscADLo z;0@!r-O?F;*x6ogh-Le^Pj4m?&bCHUse4II6{6cA1tC6(dA*&ksX$tm|^E0 z6l+S7Vevg%=I6@CgXdb-H4L@zmhBfKC)7x(i+AuHlQy)@0i5x4rUNj?WKu1?L|f{i zzcwVvAJUg*gn=Vy|4|geg;iM*QFQW*(KOsjC*}K3T3C@EPDnv5|x&t%o(0-3W zx<hIBla~a zt7`@2Hoz#=)Z>?^v3)7XJ5S-J74V@-t8+Y5SfYrIgvA7rT;#4 z{7-{u9UCCxzfK)X{ST?*wEvsbF$=sT-qRI*gpVETub8cX$+jdnip9h8=HU-9(A}O2 z9b6)3yq*;vL`TP}h1+)jJBenlc{0=Yze1wZ;9U?+;~&yQU-zKC7%OV{af=3Eq#ub7 zdf#rJODYe*zLvo`H6|GM4lmEf*Iw1Wpg|UYazprM&X>!9@`gU+<6aE+qsSN<{yQ%H zD152CbJdbX`#r0~+|9xa-msyy14bBusA`D~`&uR8v;!tG?Ce?o+4QO9W!~kB$;x#C zwO3ez5}&VeOiMPYD1=2oYqwXmGrGBNo2~abJiOnu@XnkwSL`=44}0}vedWsCX2bj& zsq@dBGJ`(WsF6`!?@Fdr8#-Vi3*XQ`>KqF=_*9_+btVd4XyYvITJl}_ z8_(1_{8SQcD*fOh=vWsJi_C@uQa8IH!TF8t z=Ha>@c%Q^YPEfVHQ=ppQ-VCWZ2I~3JXd2q~T?li$Inj@6#ZsG|UNVx#AG;s4_6mU= z6vEY;;9)a|*M4;i(|6(>XK$a|HoAH1zaRYgMttOLOdnqSPs41_Vc^rgjO4YKp2}fD zoxN?hg?ZE2OvLO35v5BfjMBe4>l@9U{&eW#?YYG*aWdN4>J{Icu6I=Lw)BEG>#kUW zjf`{vR$#HJGT>mPjPa=ch$!?`cXUQcp(|mq`^FT?h=Jg?q0O{=H+ZDPcmNoe`v?nt zzkMnpa!qm&G7m={q;!f}4?8SFZSFn`aus+Gtcdg0oM1pfEg7#;+&+M}T&9|RA4)C% z^D|-Cl^aX0nh=vlF6;aiG&&r{yiqUkr+IMm*kM45Fac30yP}TnU7uK`CH&8rF!j-UUC)mi0`Ze@Q-i zd2Sn=!EI9J=wZMJYL9msspm3xHCq4>8eF6|;9;9cnQ$UnesOA9*QcEYWp&4i=3OwZ~ z@VL1MuY;54-`2sZs6E}9Ty!)mMlw#Zv@C}Yq<#dK4rsKp##}G)V=~%(7|{qCCV;>_ zpmXH=i^oD+#f|sgwv{pqOswZ5VbG|I!6!fI?&q?q<+reFx1)H9zyeBwBocF+{6X9@% zm0US~g1h~QW(ChzE?^9I&?S>p;klMZ$op3m3~_)(b$U;_N^(D6J8V1l`fE z)wnT|GCKNb=hv_8acwm?qeJRF2zlrv#>oSPMeKhhbH>h8`r=QEBxk+&OxWV)+%G3B zyCBr9x#3qk7YM$+{;IXA)m_w{Xe2}!YCz+~?B-~(<$Z<4IX9Rdj17}*&qlHAEj+0sdVgW@^Mn91cb|86={pRsL_oxJuNX};EA+x3MQE4w z=Z>_+v@EctPR2A}ZV=*TmK#b=>*-X`PPZWw1b%`QYbLeS(y}1pQ{m5vqRu)`w5?uO zro5$!LR1K~)djvny;>*Kk#S@JG?Mu48ssF}M2w{4%C;KSN|&E+l(^|GG%WvAkE$t* zz?N$NVa<&=TohWpBt3Sj{`aY86+Ne(zcuD+UU$M9bICpaX=82{LFO0zj|Ke1X2r?i zB~XyPS@|j$eIPpXgcUtl*+%-+R=}OPg7VoXMptaTEoOHOO0H|Q*6)csxxCm1GQ{P? z6m`bKw)>9_%+7|kLB;WCJYf-I`iq8~>=Txv^hj%mKRA2%4WB~K@|V0>W<3kJYp(U# z;Sg0&k?@BRy)^5&X;(%{?cB86KHiWz(s^#$#cd?C9UU@=JARK1pB<=P@U%rq788rQ zm)#WX&a4EEG#U&zmETE~4(2kFy`auaFqu`j=12hBbyhwSfBG^z_}sG_qOEG|PspDx zW_7geL+bh+DC2?o>SpB=c2Wy9{SGPHht12B@U${sM+6vdLp0TGHM!e(ZLh^RQ5LdN z4&8m->)Z<3V0tm|k6CYJ#T9WOJ-^x4ZPVU^+NX8M?#Y5{4c+EaJV?Y(T^D~`urJsJJkqjA1Zy3kKfvgnX`B`?bFMHixsLx- zvc9zwfV6zoKk|whBGfJr6lTFn_&cLsOUN}HU2Ap1Sm;)gT`C^m zx4co%e!4Cg6;R5QtS5gsQHQ9We<>Qx zAaqmh|8ErBMI|^4jJN2w7nwg}P0xn-ZR5|2Ci*ew>-frvN&RUNwh2IVQHxZL**gDX zvkc|6<+mMO7Ykq_{8QhX;`UeiUjO!f@971jh;FuAv7Mchhkr?Ix$_BW7A4rK=XRzQyLc(y_AA$9Vi?*XJyLCa}f;f?}RR>_G#Z{}xiiU*cOiiK7 z={uvf1HvB_rij@EfnSk8$`;whwEW17yZnxwHXk-+F%1t}Vy>TBlcJ)Zc?LKyf=d&H zTVJf%1HZO{^Gavi6lo%te=ktt>ybto#*{eE*eEJHYT%%WZ#?j3~^fC$B`_GWCnh zG9UXr+XoB>A-k0Fx`N}+J|>1OJ7FrKK1v3M=n)(8+uMQ(!4#X}_nMlYgY!>qHL}$8 z{&8@+rVZd+aOG4zx?e>xI)gyEP0(aDUP=sMipNiP z_1I&@0c|OFfzlKtwDJNE-lu{66aB@FALnii*wummoWG{(1?z; zgy=O2@+$~2mx@i9kn{e{UKw^iIVP-|INf0sf?*4ZCH*|FIC%W78CYVL`@$eZUuZ_I z2*k7oB&0>cgR2h}f*G}rer(kLMGjp5A97$aYF9r&e8b<7f=n^o#>WtTE zSSWp2@ABZsO0!~>&Zt}mW%kK%(~)r2Rd^g|G2CT@f|g|*AVF^+{O;Os&V8u!!CEbB z$K?$gQvIBqsL}~j3LbU_Y~=Qy{F*tgj5_~kR_oUG$I9o70fL%3by8~`?OLv;vG;W* zWoWWKy+1|5X6GW|)`EWyjjvxJ8vNfv;}q?%uY0iC)=c%mk5{wL9?Dc?qYPL7lQbjp zciorh^P}}weMBEPhLY_q*eu4tn}OSBEuV;R<RP0Pzzp08? z_pdHxD1qSxIcKw>lQ5JL8u{|!!YPVEg-X>&Vkm>HY--u0%5O@{EH74w(Hu-z%I6lN z;1+N87A2OebB;jtr!Z?UuVK55O<})~vlY+AxD`B#p`Owq*z|u3Uz=Bt(ET&vYlS2? z{VH+kz*P)PF{n4zVTns65n%%LpmkSIc(!h&NHY?GSPmrA zJ(v)OIKlmD+6DHTRGePv>c^cI;Km4DCdO*nljnuA{xK%^<}2=5BA~QyYo#t>8dHtE zo~J%=>O#yplKU;TK*RFgp;LPhb+vXlp5)9WCH%W*2`9tof7VayB9;wZ#pn`fMLf z^z*LVE*iOy$dSS+;fk|7zeLh4Mh86UKy)?!czUGPdqu1gxVaQUf**c+KhyfdLu@u@ zp}<aZDY5Dl$fLcs3cW1H;6eX;}X_F0Ky&;X5i;~g*ISNk!Ut$3UP1}BTp4*KT zx$P}0nvzJs=+Q@GQ5l>)uJ%a1`eWB_Bn!*0FUm6FeIf7)-v`CeMMH(l9U}Y|^KgUj ztDlYGu<<{Z{YByL`vbE0kR28p@-QGjTvPfL*YS;LhDaX{2?nE3xv~^KAF0oV^fq}t zavIdr)r|J<9P~q-~*bOzseFU8!RO$#2SR z^Yi*wf~`5zq1hm*uemuzvr>sJi>aA~KMOvw)DoNU=ZeHp#oUMw`trL<@=KA+%=%+v zb#6$S!_EUKvk{)cWh>Hjg91kMheO~~iZ(MZ={Hfue7zE$!6j!)LAu$1;HUGB8u%O2 zn3jQK*iU38vRB5ICkcjN0j>3R^l;HB%GWE^t8B<|Ge5wCGD`jxn6#+TxvgWLDWy93 z17$>Ua$!h5q#;9Fwx%C%$hT$E0;5ZJKHb1s8<`Jru!xCW2RU%CWq4(}RUf4|qG*UY zzF8WstFDNX-2+Tr~GyuNF8HM_f>$C>{;-JJ(!eD|U{7XLPI zI__jtDJU-2T1P1*^8VpfjuyX*W%ERRnyI3MA&>8~@S@`ACvIGQPITd>-pjZ!9dDsH z2O=ibpv9*_E5BpwaxM}QB^nd*sy?_!aOt(V(wxP86OuQM4l%jEq=P@*dsTY$YJ7SZ zJgm++JPUYSJeYEC%@q88QZ;ep>712+(65}GN%$^;H-@C&UWj4rk*{Rlf+tx0^x&RuJU5R02beM* zUF53ExJ)IlrzV}ZFmG{u9DXpWj#a}95_Vri?Z){hI$^j`JbQ2*0-O0YCY)dz^Vli( zK9r<_3M2OLM$&WF&Ejq@j(pkBl4d~TH5UzT4CS0x=4@{_Ndx*+-*O)fByj}-_&Tpg zD-?`_C(erpn> z4T6w2pzH~NOFrfq*9SFe*@(c~7f-shzr0z2>Nkr&dHiuyFZ-F!1p;3IgU z>I+9WKv;zOflb{$3238H3Qp^K9R`<$N%V@PXV?aDvY9m-JPz8~>v~zhEJog&6L~*B z_oJY$z(&PAE8i%SA_6AaCUqsnUD(JC#Fbc5L9|qe(KLjMDYm2KPI7(tM$`{zB`gi1 zL$iRj*BmN{Nco1Ec&6R~JQD9vpSyiDMu#M@FKKafm^0k)R1)5`{kzpq!$KEqs;l)fD zmaataGfL9XYv}t3{U*62{#|Xm;j0H=cy|68MG%RB8XM0qiKePz+~$Xgd)~`C5xS;P z18u(2?VheKV)jCa?BiD|kMLUZom{1X;Mpuq!9pXg@b|kcYXe2-*?Uq`MYr5v$2st< zMZ@&`Jdd3{cdSG*jGY6$KQv;WYbCPYu72WmTr3orb*gZty98|8iQ=NMDJQM?@Qwl9 z-D_shX86&jX@{uSFxw9(<)`G&X=+`e%UToej>O;-FYPacVmMYl@pY~nu)v(5K(2T6 zCS}`iQy)1!diskEJPK6+2yO@~7$LvP$CW(Ay#z@YHHS@~q-RK%YW8%c07cWGA5JcV zVn_&=g9~Vd2^YiP2$6Wa78R%X;Fi$5&5)72eND%K$lRMV?#K85vQ^a_1k z-bJc9v5bR;4=)PeelXHjo4C!bw47WRtND@1Khw@M@!};j!5OGu)=gi4bgoZ|L4MgT zrr@IquM|a|qZf1icqv=a&*!T6f48HPIw=Alv)eumhcz%Uu zWbk?OTFcGh-f_vJ0zM>)>Gp>@f>kVqwyKD^hD_{(qnt1j{g9`z@q|r76sj23dqWW= zE0!*{tvF_)Ywf=5WX&@<>n|4DlJWNE+AEK(&=XV`{yPNwAu;YZ7hrc`N;=7gV4<3Z zsqxd*no9Wk_(cww=C7Y92tw-xZE2pyWDBS{@a;W#x%rS{2+OW=q9b4bvTPqmd8zi9 zRGCQ72TcDSto+F`-RI%RAEWbS1+BQ?ql^v$Z!Y{LbDI2zZ0&#}wD?X0EvPbi?FHEU zGE&i`WKS4{(CehTf(^t*&UV6|pcGI)b%>DJBfK1#WiElo|tA zyA;H0%ArYcONCIkBIC`hdWE;QC}pQ;KdCI zjgkRJ`%0smja6@+v>h%NdZG5879P0ump+Rz4egtq7=sAM=2>CCi*d^y)NUD2qFBiS z>%;<8>8!CY2`d%ZOn>BxDm+2+VCJW^rt>|U177;~HDG#B)F?@ZCyUO79YHDkEa1W? z*E27}{OR2Bi*E}h)`gLfJ*^_KAx`>PFzCwbR?OoyVj z@Q8^}Kc+~jqt{G4rcQ1^o|Vr8t_PUD0p+{9SKCZb)k(v+1@L z=E~`Bd6VDuXLsUOen6e~Zz_%Z@5#+76Jp@Gem1Y%LG9yCd8TI9xA#>fWDYC9?!y zv>bBI+}_5KakqHGp(A~K;YPctQo9dYv;{xvP9ZdAT@4b}dc*#yAf+tiAXKWL9L!UZR z)uEx;0e^*D#?h4|u$k=M{+6&@cA*%d%e@Sx!EYJx6Jb;KU+~-d595}%D(82vTsAGK zXz;nKULU_iww+-mE7nal16PuUHb^O*d3(QpXgjB{y>#^p>?^!s#rxAQqWn^7SKe)b z3$?w?fT3wjto@j397h$NMpnR`B(eOP0ObRSb?1waN3#rSjmLEI z@nX$S`hHk-ND4w!e!L*v#tLZ|^rH<>@X;gP zm|gj}Ilzx>uRQrAy1QD`rGXxNi><`M6hD1YGC6q9)(j76W$5!Phdp%dzWAT2?$iBCa_28aFeIKj18#mjW?-8vh;O@|vGi)YpNxq0U*;O7B z-e`5`%4m?_+ZOKKZK@Qwc%-vOJNIcdpp^EG+EcotoLkQn2yaf7_c-%4^I7g5TjLUi zYA9?6+87rZNlsNn`2Zb{1Pdd+Kn6 z(xQ1@32%ktP`&RSa;-g=uJt99OI0sRUFc;rD}d}e2^@+TN7V07M+8k#t?B-uizUVRbQSC7L6-!J9; zUA=nYd6{>zlWZeba-W{e=`X9B8}s(&}2^m$%^F{j(*C?qmg224kkP>%!>wO7I)j`KHT?6$*Z@HrT(7zg+Ij z)Y|=zM=gdz2f>iYTKExr2FBiC?NnoY+$1hu?0Q)E_m?I|gu799UDpcqDto4$16Y+2r~$6B&?Il@KLv-+i7zamUL%06-q)-P@L{6CCiR;W;Oeb5~sCJ|zSw}w3GdXAm6CeW#o3bEA}jgvil8TFK+ZRsb0o26rVhNz2n z!}=zfVH)I9;#b8Y-TqkXGcjI^e$MAHY15PNpZ9+XGn&g~VrB%RMvjEu9i5tM`W#hG&`u>SGr?u}AxBuR_JwHjIZFsWAK`!)imaZ++)^KjZP&loPY0&%h4GN5;rDKmNx@6FupOG! zVW0iF-FeR9#XTXK+Vb2}Z$GzJj9dvgbZhfXaq9`othI=T@m>1$2#agljvu$>I(7sC zT2C1%DrjS*+0ls!1=Uc}`0om_$|6}H>p>eyL%!vRNt~8KVp^$4I2Vqb`GqeXtNv?Mv3Ad+VG!FnAx}fOB9<3`K;tGdfTPivSx-Jqg~I zThELu$Kiv0)DRl5rY{2;V6^ZK3CT$&X#=7`AF)H_*-A}l9t3j0SLC2!Kte>heOKZnm zQGa>u9PwlQ#^p}QnG2Iu@Ipg!o*SParbwExx3&cjAJJybp@*+#3{BTpztW!x-w;;W zbAC$mIM=H`V)#JBv@W)hccTjy1-yXH<6Ha1Vm0BAjgSt{*#b^L43jjn5rTwQ0!2{` z_2D2ZDrAoOm}C#$@gw;$e{SIAxWj9$aBoJ6MT75v*OlaNqU)zHDowYQwqGO|$S2kD zWK=W~CZ}FjS%!nOfeZ0sy>$vNFoW!@{n$j>s4V_maBbf}`v?(MtAo;_jpW8T{*sTC z#a|5!KRd4V-`7B#q(fKF25dI}I3`XW?5yp8od*1;O(nYmIb}MjqSbykk4;=mTK#tD zPpb=0xz~z6^O14!dOT>Bn#m;U$yyn;zz9g&d+0M1CA!{wXg@Tvee12pp;DLx>-Y>p z4-7AZ4*!^-aZ~3F8_26N$va8TMCgHd@HYJjV<>IG4 z`L}0OXLBD@3O~Fo)w;KV-tV^0?kiFWwkw3Lq-8f92v`*nLZ_EP#(H8`Wv;4yRKNHl zMQT`&en6|FXSma-Y^p0RU^&x*mJB%nAaLb};_o^S&)Fq7Wb|>1m~q*qHaA;Fx9FFuvuBuM?PUkgy z*@0_cWHfjbZs&ai;3cq*tGz$epCrJNO7kq4$a=2n>3zGkl*1J7>;zufq_rMn?Y<)R zGJxfbCh!$TB}C5gPVXDxs(p*MloPJ-pQzpUxTpolB(-S7#EfuNPxM?Bmtj3Wb4^|T z2o^iJhcFDLS?EMS1$gp>n^Px+XS}X}LE_mxCxQrb3~aFc+ERfk?y#<-K?v{6D{j1m zpmwcM{bD3L<92-i9*Ea@4i$v&s(k8jWQf3`$9WQqvN_XzA{74Aeuq*3%X0CMb zrs^rb2uR)DUr)Tsnz)iia(l0f1dRxlQ1H)_Z+BhNq!IJdys^W>Ac1>0w^M#OB)=A# zx7+w$_}1^*+c+2xSrLPYdE*Cry#*)}35t2}d`zOB&|p>n@(|Itcx8moa)I4E0%Po_80gWtBTi)1-3==Pc>PGh{t>0UYC zzCM|rW2h~R58AAuLCs{*;6ycZ_sNmfp%<X zpx|Ga$T_+koZ|CH+6a3+=prCjrcHfNjgsut*af?`!TE@WKcE-sUHINWCw#B;ARhAl z>42BF;6BrEGLO!a#GrzgrH0*YRJld+?EGF6xC~i>o01Pah0Qg+p* z+&mvU{9%$cS7ewfhA&>J;`cYn6g_V?%>u`oIAOIrYgfu1`Htc(+{ukM1ot_&!3MEx zave%N=I0}P1M${pD$>30ItNvWb`8$}pAM6IcB&6wS#e8dPnS_X<-%<*09Gr%+QCWN z7gksCSD*jw?Jcs;Ym@Efd_JXsnlLmkL+#P_Z5$6b)#x^;y|Hs? z0lq=h_ReOjPaeaGLHvO0JQDLH{g}k(-onMTR;&E}M+OCMVhNU3eR4dJEzZYrMOG|M zmvb8U9zX-7uMO28VQT@7PJ{T`VvoVA%M5%Xa&unMFBlqb_?Qug==h zwz)Vi+*;rZ5{y@Ole#317DI=E@e+vdxl!VajuCcPS~P@ck`$M6Ggd>S{%Gt)T+ArD zqW(NQS3d3{Xorz}GEplbu@ws5eM;Y8D^FApg(h0WbaLo!0zqIj!%&z=KNvt*q7La#@kEx)bxI~jWh z`Z1zH?$@bAg|qH^rCbG1PP!b9qFAYMLH0F#dm~R$ zPsqq06?lH4a@fyn=0?WvGH<;#RQ3nh7^DPkH$`#2>4``fL+odL{5>;AO1bb|+u%?e z>Gh0$JjB>DxVt~sVb>Pyj3S+nGelq;Wu zBOkW+LR;Zw$`KuK;{CKwkV}~!go~GfG{Xa7qG9ul4*vfrRevp()8)k2g}~h}RmOZH zMrVtmm)|ZS8%A&LUKBJ%73a`{SKdElG^U`EK3yUcTXBZ1{GWQ~_{=Q2QNl?Ksu;VC zA@C^8dHvyDlSM$hespCz00}W2WxhgQnJBB>>Kf!|f}IwrdWq;;U@d9R=5hM6QR@)f zFI-qOQQV z=6&=B->*NwOmkCX2_sr5?YrlQ)T&+1da>*gk`Mi~e1kV@B?Pz~aV0L?=8FoL(Jtt_ zPo3B-q}T{14P`P5Q&6WT|IirHuqSgrDC)!;DVPg_{jR)gy3Lh{g2S=Ub&~cyY}LZO z7J@&mbopF&%mCaIjc;Qd?L#=f?9u)1naB9_5$Q9@iA%Hy+7qN3zM&`w3Wp#hI?I>Y z6XvDLfzO$=R@Scz-nt9TX+-sj8*pbxiJvP@HVe|xx0=^OQOa7sag@y&p28u0lQ;B; z7g+4M6iJiYO(cAs6*aPWgy(*enkGqBZr9W@$f6m%^(vq0IKAplNOU2*zQ6YrdJk-b z&>wlxj3rmYL-N8xj>VaDbNFeg`s44~TYT@MGme{L{x1yIXZxB8v;UcJ{rf=bz zgia%P>GaGUy`I*3pa2~@&H4T#gFqI>k7-3W1;SUhsr0D4rz7qCMm%2 zTMdyjC%7xLVs|u2#B$?pl947)(edgLVV|-~s%g&LlwFOczavRe_^*D01H?w1{?=CJ zZ=b}l*9#yU4A;JB2E+m$I!#!Ga)Uj6*VmmBY`@d+@jL${>ri8=S&YNA z4t$kP3!tgqMNSCUsUEDr)rh{7o{#o|xUQ-`;zhsFw7Bw79ZjaWfWl5cQ}lt0c0z9? zvNJD3_s0xSOKxCO!%(OzmpwCZo3^nBoO?W401bf}(tf5|D&4_w0d-x_H`XyCdYpH7 z(_Bw`{U(Nx!&O1+Ck?Paf7__}CQ0xX#pce^OW!MSJ6CZPeGt0H2}&UW{l zOG>2hQ4G+P7e^kt@m5RnZkh(6hK;7<%Y6$yw}Pr6yN7Y|O^MhE8fLM!CjvM1IyF-1 z4;v=V?Gw{XctY^9a4Y8GKDoS}`PJ-FfV1&Mt&XD=IHgsciHYm^bsnU0sR3r`tgY0a zwKZ9{TmoG;#=$BnoSb&%Tx7vA?`#>K8k@BO&u3QMtL?`nWD8meaq_a2`NRjCXfIO! zs?+^{BVix>uO;l#ZZOTE{;W0B0YeunAR?tkS4JkaB4cS?9|?RPo$Uc$OM2kCoqiG5 zpxuGRImk&n;1+ZkGjOp*-TAx?VBH(d+YWf@0nTg9rcZp2{FA-#md9D}@N!x}%F_a%sFxw)2Ra-C|4TVL z5E~E91xY#`6qQ2R`u%I6?MN&r_(aLk@cBiXRgIS*7k&!2z#_*j9ve4OIky|o6R1Ce z%bJWthTgr`{*vlR+Ob7hVp2_!vxAG>_1C56bqv;1*&-{Jpx`4q#iHL0kpwlSa634s zZ7Wo!G+2+>K?yfeq2~-W3qDP;zh9gdmiF^R>eMym6Ou`u;e~$krw;@SyCxelOx7vY zerQQRJ#8+`Hc?jU=1z*d(P25inqf0R4G*@AT-y8C5x`gWSjZo>9i zxV@~$T+b0-zC=w|`#+Yq7xUjV3>Yg9RR8ZM?n7Y-0^-8+AWb4W41m8)A!;C=kL)ix z9J#6p;M@el(E{*Y$NfmGAHS^y8Be48wB>I6PEBI#{FI%v?qk7tCi9K3BxzR{Q~M(^i&epjYV= zjc13)Sd8y&)e*yNn0{JNFYZ+orF-jp_Q20fCLhH+pN!vbdj<6fKF7KiKYPgtwn0_Q zdP@c^P6T_KZ$+v8R=t%{=JW8ZTmks}On#jseiHmD}UX|$lh*4t7 zo_HwsqcXM`WJg#D)?d~QXG6Es?`?&q$%z4YQ zIi8>Ap1hS5pc>_*lG|y*1u}T+77l%wIk_e`=UypqGMunAk36<#69NMMIC+nZsr~-9 z$$JR9_y``Pk69lH&d#6uf{>N&urPbPRYYl#EP8%Ucy;ToW1&cbppp0b3{G>v^eBRb zr|9;Hm@lm~#i!3fKgCkL*Ew(xOUk8=%-6^?e5GUNf2}c~;sYm^@M8&fLL_1)R&*z+dG#WQN2 z9Smz)@%b)#RR=%d={s4X1-?n|1Sp%M*~v})Hj8?o-CQ7Vanpxxd!t+Yi})d#T9U2- zQmLY9*|{zVNaT-O6qi=u*JtvNYWUtrighni)gqDZXt*rSw=Ec$6slUlkvRJNX7XPImf=7ULT8HwdTFyEq^E^fZvTM`2yc^#o8#Nw^kP; zx9tkQeJUlqMH8lW;&Y??IcG)gf!*6Hkz;uUkZP!z^M{&#;S4LJasY2i=PqyEs={)ka(P=K+z1kJ#BrlZU-1 znm!}Ss^=tMqh&11Y40dcI8&|tGHx-L_;@@rZQLN(^|+)osjpW!Y3}mC^v?UFA;VM|<<2cwO^8SKRl1~pmQCTaik5^+86e<6Fnv}Q9LethCL32+JRwY!D=$!30j$Dq+2 znot~?YC`XMGb`%Y{))@^vmaIkTTA9Nga zwqB>_Fv+b{Hv#h+(OU|*DmfVf+CzSI*--V)iQ>`Xy;QB4{ZsJD)?4#?LYSi0s>#2( z$vBc^BcS2CNOGW0&O}mUm}FuNs`sRR{!m_X8MF$mY|{%f=lvYF1`UM@C=y~`-QZL2 zt*BYM4q(&6uZVgF8&=JXAjOD>vzEd2N%2zD6;qlkzzC@@5<=tzqy2cSYx!gjHO(l_ zh%*BFGvXLjiY>k95C`M0fpp3w6xduPc528P?t(6(&KeqihdCtzs&FVMSlk_ogr2V; zl^kpTw5^DP4_@FF#aiswPJ7Jcu1K{dH5iKAas?A<+ZzXncHCsW`tnJ4>7&w10>)m2 zU#adFxaQLvr3qfn7{BtC#%>}^DR=f|H;bY>)Q9pzVfJO$Nfs4(M3iT<2a2@++dC;% zGU4eR8+-n)8w)wh4CN!;x2!dLo4lS23Uz zLya&>LI`}j&!bTN+f9xWTa^3Rkrs+9@{x54sKrt3lb4Bvi>Hp9O;c|aU_7+)1z!35q!#M*Eu#ItR(Dh>s31Hcpu9JA5V09|6O_|t(p^d}aE zxiWRPh+z#l=H>mpXIo`$XK`D}8GPkITrAOLhr^B2KoPh0^lT;OO_HKw<2dYe*&dCA zr?b^NxBDyU{5`a*mgfCyVc=RYtVB*NyzIp*vqSqkS8P)K1flEmje_Qwr3~!1(2YXW(lc<{l`W+qA&+6lH4I19 zntwZ}xW;zxGan<{izAumI`7UIBBj-DRT^OHw>dLt4mNXf(wg((~)SA6RoRf+Rx`MQH~qn%P;|3qYLyS9lINJjU~GvAYm89;z|t(#+=ePNj*q=RxJ5@d(X%o@NnqumRJP6a4Erf-9=!@8Y}{ z2=Z&7s$O92=7T**%DX&e#m6hv!ko4ALJv1MOW5z&-r(I;Sy;|#y+?K)J~Tr03bPz; z&5$qoi-Vm+WP3mr$?;=0@oAW?+l>zn`Fh_79`rD(hHXkSN4xO1v5$MSK+P-8u9f!F z19)~$b4SHU&PzIgSl38D&RG_f-iBSp)apa&0dGYKB3z3dq>vp-jw8oZF%qz$T;AdDrcOYESMd5-yWyyscGXY1B0hk}=$8;6-5_5NHq*oC3{o_BR`DYqJ4Ey%09+5W7Qg1u$Vst2nAb`MynNy$sww zdvO`K43UrgelubPOYEv5y?V|I$nX*s5F^^4r#YjTarlm3Ea+Z-!v(-0gD>g5515DL za1kGBVH}nII@a+GTGfqFNHaeEcc}h>3}`Os_p^;T@211+g;y=b0XV_uTroy^3trD` z|7p}n<$2X(EA(0$XV>eN6uj_bV|~O#yl$^wwRce)eqpW| zG%f3AJiK+%*>C*t14Xi(f*cf6x>{2Mt?`zLKAV3*+*Px_#~fHXAMzomxd!4($T$g_ zEgA`yWYMe;w9>^^5C5Ne_r^ZCX=%&34q(F4@M%X35PpS9KC+MbhJe%!a!!&oSM>77 zBr{T8=2iH0&!04k>-IN=7O$!%8V!Q?mlU%Fnq5neB#TniSmlm%jM%>)F0Mc~V_&_d z+Fy!+dTzqI;A1$8Z`wSsSBVzumo6a%W=Ffr{DXtWT28D>qE%M`+zI@i2_Cq4Ez1Bf zp-wB)AT3yx(Vu_XYqan;)+Mx(Njh@hw?4DXcd*mPXn^2W$rWZ(KqCWwCAWj_Hdm9g zDPSwj+1hbgjb_QXK8-QK_TwW7XPe)lrM^a7JpOf00YqfxPb{mCL;1EAx}ap%=OjOIaruD)@8@Mr0-)iq=Wbn_PJf_* zzZFzX3mis%`$?Kq_Uy;fC!7JhTtl)XF3b5%Za%7N&v7zbZxy&a_i7g;TbU3M&4eTI z>Mva*j|lF-WqTMb;E!P^`#}-pV9_Rp){e-SMWJ(?i{W9k9) z_B*KPD6VWE&AKA$iv1=aStGhUy&g>GVRYi%)fxC#)caPhaEQBAw~>IgM=>;WT+17NB_Q#%i_6FUW)hIbJ~Pj^UR3Zt?J)0ngv^L z_Z-08i1X0C%t z&&Z1tx@7y5^~qE4MJlgylq+oeHTU#DMZMXC2$()P z4W60g!Y)?k?ZQbffwgbNkxuu;#zwVpy*Z3}R{xMDxZ8r|g|(8d-4)FG44@ zvvxuKC98Gx`gy52R~Y)UINPqO8zL>d5)%z1QuLNG3)VmOs!4w6wk&7ho!r!Z==%(O zMNK-TRiEdRtWCZ@D-lk`*ew#U|K|J)*Cc>zK~o?=>=9*GEhS4bMo?A$tiRcv&C}|Y`~`0hZjF|Tt=0)pqg+M5QCoW?=AT~^1O&LMT4yTaf`5ad2@&olY>mWih!RN z<~?@&sUu(06LiDPEfwY>ClHO;CxME<;+L&lCxa&@4cA7;A1~J)-xn>+DO`TeilQl_ zknpNtQt{POeN($YW33{W zpT*yhx6|2y`@Hlp|MI*3V2_}28j{YV`-IOrPC1zs@ zOIiG*?5ePzgGlhGR(OY80x)vTaKG{&VDNt%&m(Ll0|-9ZWM=FVH1d4MNLIC;?2g{e zZ@f5u(+mOV<`OOsIi-WCC{yw;E5p(JBPd?Zc&z;oa(YRA)lcUeZ)5M0y$byQVN?$- z{;gBf1Kr6<7FjCY4rWu&ODEwrurX}}r(gPsfXqOz0pR1WFct{r8FW44;BJ1?#rIAI zW6p(QO|$|LFq3EFzolQY5(eTNNHTc5E1~cwx~}T-6#=1~f*9|jJ*bcwdzsJ2ajey) zAFeb?|A3J%J&#B8CoyF9<+V)N?c|W>#DpCuO$NKo*Kjztnubi|w%P*cl`NW%Y(&)` z`ENbmNqlTBX*~La$C`5Y%waw^+vSITR?tf@l!Gc!l4+}Gf_LdN@Sf`{yV%bjD~n?& z8^g8R&&Cx{WLrf`@bK9w6(o1~Qt4fpuUlGsGDW_X5z6tG44TwUl1H15Kyx3J7Lk{6 zYZ4EH$5%i{k#_yHOh5w9uxKwl@fz%RuchwT-!k&^CMR-81qm|(lB7ZTIL;IJMD+BI zpBxSX08%Hik~AGZI-#idSmYbKAx_LF#W$HI#4oEEU;!t?(C8(JG3l4$zQVdwO}P2U zfGfDz11Wo)t18B<}D2mOy*=Dfh&a z%&b)rN11jWP4)}Ji}z$4@{A}qG(IZKzzRO2t)qwp`>#`qgr|ff@?q>#gV_egm)bb8 zF#2kU=i8^J&tSFM+@IaiM+{SN(~y*qp)ge`!+6gRiBe z9d|DC&AbZzH2bFZMSBv5Adh-K*K+QTL0*p+S9LD87y^{6+(W|r?OV4(B;agFJcSpYM=fP~7-3%4q9Dsl z)VN;s?cCp=KJ_A&?7kh8;RwOWLy&S!{eno8yS7;SS19k%^a@nJ{O0W(kA`=KR9Ovb z!AdBz&o&mb;8-V^Dt{Lz-t}G}>07YK6ga zVP2xB%9tp;BYu_|G?p6v$+N-bXy@4}K{Aur8FKhJ;mNMMVCt91h%FV9Amw9gHkYKN z*G_)HKHdDa&qeogCr|RuH8#y|8$|JsM}-KsxjMw;#{c>fy{@IFa^{8MMiT{tShW<6fE?o% z?pyBV1rG9ZVD*cL0h(;{o8bCi^n40` z0~R>JQru&tadj#6U*LJAi`!OjWnR?vF$NiW+*ZCO3P1$He{W2?0ilwm7coQ48J@d| ze`y!JBx*idB#8@9_2EzK9J}jY_^jpD;(VIpLK!{JnSCuO+JNq^%bQ z3@3UMZWYx+;)b5ad{ac!J|`;g?3*>;KPh)QvwL}{H=3qn#cpqWSHe&OE3kF#ILA>O zKaceLb#S?HoYZuQ>X_k!8-rbum3bb)#TzwWpz0CIYGAK#;*qaVj(yi*i1=)8>6!@( z#k)9WCWk?hs;`&uk65>6*?EhN=yzoC9jP=T#t!ghn>?R+d4+xCQoM8Vvc#6kapvAN zK;l~~InGw$Om*#>%P=GE;Asavz#DQx{=}SSlBqrxszs-?g4f2YtbjdV&+_7hIQO(r z@%^_VfqdTyr(w#IvU>RTr~Y}$ekk}g@QQ**3-927nm{oh()JgFM_DH?3i=uRoyq{W zJ$F55We@Is4279V`c@7Le7Y2nJy3pV5uzs{eEucMDAtf%zkFT&Eqj>i^dsI2cm0yN0yU*xlJd0ndjU4fcdTQF}qpdS|$e9e>$f?(sQ2NIPba{ML(D?K#}3 zo~)%@Q=G{!bz!#8X0$yV-UHcKu9m;xX0_oP(Xj;+d89rWLNi}Gsm0qy+lL0?d7o`@ zU`~~xy@>eP2i25o$0vj3hqkA}7LD+{uWsFjh!FJ71GGJw zdKLc8!SJkogFOb=Ih(Y-CT-iyHf=ina1G*+A`=R%U=&sr#L@71Ar2fzCxDWc$t`L& zHhq)wP|f4c!EiT_V&~RZ$ut`xgfk%o^K^#Uke^j;V`@(tP#_}#Yiub=Ocd*_%7*hv zIw0wat|Vy?0niS3Z%IN>?KJT^+20o~I$4_mD*HcEt zTS;Q@C0Ea*?)7WUx5R!3#JROvJ%hfB)Hn+Bp}5j}M;w<$!arNvA*?E2IHGZxYe&7> z0iz-Swh6jAraf^Y_nyMG>MuOKVJo?}F&nW^CUf?rdE^=NrrQX|t%YLYEuN526}~>; zQ90|jF3skfUgj_iG1@dl^b>x!Wl~r2I2ke>Vu$qL$B>PaJ*qR<~O{>8f z_g0V5cl^`l-M7@jV%>lnI-^{>C&ZyC(ya*+Gq#-2goxdw$Hgz_`D0+ucedIOXBBWmtP2Vs~eRJpELG=@D9_3luC+rUG;%W?6-@6qq63piqAM*BZUi&hs=5F3UOqpa- z@G!Ul3-NO>ydU*JP)XCgJK*psaMLjZfnrr#=5PhXM$^6f!Y67SjlCp=EUG!z*AIK~ zWN}7HATwZFl#pSBD%y=l`CSGAjly$~RTddF;U8M6Vdnj6se_k1i~DE1v}Y|M05!F* zK4@ribKnD1RddHJPBsNeTHrDugRDv(kI4z$EWEH)GZD z_`6r4i!!=uT5R>L3$I^f2sf(A3a)D}JBEWziP4%2*u5+n@G!v0ERcL!#g7j@ScUwI z5loaefg>B*y-sH!evkYvuCUFQI*v|(_%h<7q(>?!nRL_k(!&G8)D2Au+07lA=bE+M z6ttWssm(5SfI64+J8gr>iDRnkdHZ#1w$HLEAM&};FI=sFl|BRIRhQ^S_VPOK%k=y<;~`;WkN< zAu!_INDqG%KQkvbq9b(7j|7wPa4*9(PK=t7Y0IzB$!dlqDsF4i3J?C&+9*ib0A?V(GO&(RBxPa6?st}%D#5NRw01zMCRfj=~}MS z0p&kG_LGy(-2wAPIvfO_SrU_X^S=Sk*1&Nr7pA*cD5@put4&Q{0?`Eame10D=4th`sh&tJ@U}O&q=! zv9sBXyB-zLOCxx>WX!%N#Xg@tJHz!u*n^Bfzz{j}^s^uH+keICd&5Y*>zB|}DvYdN zTx5bc?bTw30Bt6_dH70LofDieI!N5@SBOu%K5N#&#I+ALdT)=#$Gz9}NjiQYjRd(~ z0EsNfxF~Vi_9xUewP4j-T(Pg7-STFhF@PeCH;_9Uz1~iY^Ru% z=DV1K0#3m9 zt{ycVlSVi?hi?<@mv|9}Ki?h+o+QyVggU|G(ZlbjKbQL7KOVg8xJXgNTn5oX`1@lC zfT=1!*1`FQ-n76if17YGz7?)5i@!717*<>O5GFn#PV~_8XsV}ilaI95_2R_s(7nYG zC|iYBuRaUqF{{I@;i$SY@>)L1gT2i&k`p8W!#X;V0}hh-?5@bYZG2EX?QTV=J+B)nJV~z9}GWWATLS ztF6Oi7sePHU$@6e!xAewOxbK7`E}a9rcJBoz+By^UhO`x+ib7I`A14g?;PX^%w)jr z;WiyhvIJc@gvyz+JMk3ncvm$I&-N6*>7=Y(1w3H0gy^=f{FA5k2oS>k$Hy*MQJyZUHzsB<~{u-lWSUgx+m^Oyz@A>+PX!s={RMpKQ1ZdLGS%J1- zt@TK758F^2m^sB|Ho$)hOqTQqd_8x}UeUVO9?oeo$^Pk&B%CD>Qa<7#iCMY@tpg5P zeNHpPn$Mv}F~=9l&g(5pzx|hI6>>NUdW`H0OH2D@EsYYoH9WLo`@s6_$++g|hJHsP z%eWa#!6cdemyo1cLcVsT9X^`t)^InyqHc&tEPRa-sS7rXc^J@2Y4-f@2>bQ8;nrZ% z6!r4|SYyK_&td(Ch`q~9$*qJ|)-l%)%1ooY`~q^k2CT;wYU*w;vZWZz=d%{r=ynF8 zwr7JG6I}!Ym>517)5fS9NOseT{#HSPgX0dVl;{IogO!iVfpuT2l@)`@&k7{u@z*`!nObFF$N*01yFn&J7|1;yK{A zGx{O{0VJdz>mGzbV$$yif{n1_s^Orle&P_O@gY01jv(e(TTDC$8yQM@5w+Fu6r&)v z_;S-P_xs|-B@A&V6X3tgp!AScL{7vJbSm}Qa>~0xU3(<=V}?Ml`mD(xNNzSSksHG? zpFVF{d;ABUoAw8e4|JmFZ!=nlVM{R|iOTg)+@PC~UtW(rH0j@x+PCQ!splT^M5;4e zrxrk^KR8t4c446MU=dKz=C@@MRSrF4nFdhCV8v+aQobZ;L+4#8I_ z$wMd18a)1NcChLmI(qb9mbxavd=t=6Nj)0>R{;KOhB&2*mRgat*e&HZ(EVDhJFlzl zpZWX1y(h&IUGX|D3Q8#?;-A%ep865ITTeBk_9)xNqq&;ePF&IZ=1CQPd_EFargV(a zP5(T7FQFKxm-Dyuz2_w0tm*NiR_kCHlno08*jyfiy5m`b%TDGgVKQ|~`16U2{w}@d zhM=Y1y`2&0(=6(MUT#$U;N{PnZ=pl{iM->+7?nh+mTQ2zo5vW)@4i}E%xovz76rMD zCJfoB_k_$aOF3l-hy2-j1KIB0ImZ9>G~b(B|J6XhDk`!L`@3!c+bF&34);Gji3jJm zT@;?K2tXQymO!sOhDurnm4|fIjIo>=t$STZmC7A%lNgi5x4#u0sHKqE>YEAno@{c7 zyR#KnB{pt)Ho8X3x^ag;FFjwFmkcM4G>KEcadk&_?>Uy2-)%rva zu8=y8A|tQS>Q2W)ziHIvADE()v=g6G#XX0NOJ8 z2E!);2K&#+6K>2wl`xWD(M4-?&U@}W)(aUhHh?KQXsFwosi_VOgZaxCqGX24H@=%T z^LV}}?S%5$d=;jB4ZN_69~sSL9BE|?l=;@v3*Q55PH_n3$zCK>))=_L{um=ERLHi3 z0xqKC)r;uZ52m;DCuSLM*jhrLY-L*4bV_whAwTf z8t_}q0<%n`N8$TT1mKZh=B0?yv0z(1^KRYt8<1+Sn(b*PBZ*<+T^4gnA*vM>uuOTH zTv7Su7Yet}3KN(2CJqkf?m6c?vRQhbL+go+CNU-N%Z|H~Fk7r>f7|E0^Mh(W%XeH; zqhFr9nSfNe3&GGUYy5{@F0)iE+ftbM@F1`D=C8C}h_U%k;Z-Di=I43khjMeHBF*iq zgeUx0^)+l+BcNTn?a$dFji>8|C3#(h`MGxF4vB#KYIYVLgjANTNhsJOY3rPNMAcYL zT%#cFo;ltd;!Xyvy=2#UVfq74+)DgBH3C9F>YHN5d;(+27?~pGr4@MM*$x6Mp30m> zjlF_udAi7VMz!MDz3t=-NcF(!fwgaayeQD==rCfbzl-EwN&JnolcrGMMmG-&E)X-C zMb5;=w+0Ca>RKvUtzvZ9^a5(}-8kMkm_6<=UXcE@5DVCGTpv24y6Erv<^0P}qe32n zh97UoLuD3Z9(E#DAI39<1uTdrhv+)@4btBHHy2Lwq~_nba0&;4GaWFF>aei152Yx- zzX$T~GBnv>+)L$2H}p(H?xM4z3O%@1@%|f--re-@*>yGk;5E$8C>^Igi`lzndk>G( zhH9P`56AA9h$`mFW#a?=sJ{yIPwL@!3|dfnMnO)sJIG~tvN1j;OTn6rxcofAvk9Tl zA6RQ00>D)>hP+Py%Fi2W2b`E_zt1;rNXi2e-wc}@x3R$S~RPMuukd&(H#eO6X{?%w^lzf@jTDizfbK>To@G7><~ zfm)&jU#RNLtD<1>5-Z>l4Cu1x_X6?YIs86<QPPZl-CDq}cnTAhqoUrp!s`Eo z4u}3u|1y22EX_tDOGque499HdeCo?!cQFcfdMo40OLVj{Rc$NV&!n|nD(e3HV~xYH zR)_wkkRyXxF%^f~kDPA!n=24bS=&7m@e4Gr4XVC(R=hRHUll7+cAd4f5SQ#7cRo^+ zUP6j-iU%1BsbuaL@;D1}zO!FdHqny>PaHZ&-fc6jN#Moy-S6eW;~SiTL0`aLBa=h= z-h{^}h#_8b-%8DZdW(X&>LkB6Rx4k_x2hmlbP0eP5s_n_JJ%|Eq2P}n!UnTJldkpN z7!4hO4;8<+&K;{%CT)O%*AQx8(g<3*wJPS5f!Q zVct{`E%J`PeP;g82l~s5|6HJ7>{NeL$Ke=nnp2p>$uhox_mjKxJOF)-W*@8?G#;0T zA-0b%EwxPc)Q{?Yn?GSPzRKH)Y?WaKOcI+oTa2BE zEAiYX=G`TVDBg8=>CyP?(kj%jFyy%G>McQsj>g4p6BUe(pyDtK!uAvzc)AvQdfqj6 z{m#)4`re&;o{ymTJ8lWo%6~r)_mG_iN3Zs8dA`Wqe>3E0d-|*$kMZ8+Gz}oWG2VJ( zA?%B|P7L7C8F^de7Db|V$q{{6UvpwPE1)+J&jNc>%TV`{`sbG)u34{;eq{EZ$?5G8_%R|wfsgK#A=;`( zmEY6cK0KYb=GFN~7Ah<;m|_j^^?L8pNz&umWc*>ggiDzAzpKfqmS-g{-KbQ5hEsSG z*(PIcI7~~f{x+2-!D#GJj}U(-gxecxo$+VZQ9gw0?ZTMYND$SB&;#as4*=} zGHG}EEF7kJ-99Fzi33f%c3jQ^bhboGLQ87l4hb1|gH=-Q8&l<7TFxH1?@jGFL6urQ zkG}$~^VtbW4;$26d%yq2cEP85>DE!K8e6xzkbK7Dj(KNZi0Iva&Xkj_bi3f=-I_$D zXJ0kRMCgHz;IO4m`n%+`edNenxF`1I*_&z#*+i41fKA&d;4=NM#nQT)A z{!?teIew7QU;OjkD?)vc!KdExa;!Mx@+pkkFAt-1u>{;WhiyVKMD){wZB&-pAW|`dOiaDI~_Q@K~&8?_^d! zTZV3n3f{KvfhxTTS5J)=Rn&J9ZpR;T)IGOT6Y+ zLHh;@)^I;m!MW9s5M>@2MX{GuALS#beJjTkFexaIIn5Z3W2)qNiS!q1byW*{yjCY^ zo6^BzQ23D~^)fUP8UbKQ`;;wG%?nNuOFV=oM%b-28aj$g-2K6v3~6B{V>m(snP9+| zo%VQD><_#-dSbz3fAQvY@+^axl%IZW*p`R)K}C=dP8Qq2%eH8NCW{tu=BZuA!D#_> z8hoQna-I^986wjp#87O#MS@j*!0BM1a5@)j;K=CDN07UDsiO+y9TeC-kW&VdWK6!n z`3dvM`Qw(CfVEo8ukz6xp@G=nDH-K`di*BUu=ADGLK$HaUP1qU{Rg;Ib9i}A-ZiUm zVTCFR!}V)=wr-xCyN4k^jz`BW%HMgN-6-+-%j80PiJ7|MUs*Wi`L; z77oN&-KZPqL#ud|`d8##Ei4|pdu}NgAXzZ|AW|?_LtEcV{iDS8h^t%40DR{ zN5oc9d2!{Q3TMg3L=$m37n086b^BX7vn&^CdKpW$1nYggn%-{ok3;>zOR=)%2KdtC zujTarWpxfnvpNnVjyWY?jRCG^7DP-EXCkshZ{a5p{d9b`2K~Y+3*gl`>Mx>D?(c4> z+P@+NIHrVm98*0W3GgMLJ-IOrkKrrbEeELKfB9J!FLmUg;b;{e@t*GzFRt+AXM-%W z^IqPu`_xc(_Zj?8fIj)}?bNk^7Zlm`_xcKJYZ&K6iGe4+_a9XnajS(7;U>i*XX`!Q z&z%YXlGVMVDB@hssbdlbJC)HSU58TNcXNS_{AUpT|755iBU1TsWxBp!?)3wuE~uS0 ztfR=24rhRFbUu^i;=}uF{LGqNM&Mt7oJ#)t?eDJPt!j6opxv!tUxL1lU-}pt{DT81 zT|m!O&tZNq0)S|wCtk-2aHv+WT!OX|x=2WC$R7qCtp^bQVt6Ts+iL2kmE}KN%6K)f zaQt`^%Z)1xIOM>ObH~hDCo%vAtMI4jB@}AyO#IbJaOrtb0wwr%5*dNe$BcgJkTX%X zUKB_{iW(7l(=A*Yx9t1bFuhruVCnp>So?V#B;Zl##dYlEA{v7QaG^8afk8oNwlAVP zO~$2Y<@gB5a{x-AkliTY$ z3v4MTX&ZhJv#YOP2>3_3>3GA9$=M+vBtP&!Bxk>Jt25Xr*nrSjAg}>^ROQnoBFh)4 z^s3sb#DI77MWS*c#Vw67c&4tT3G`%?#u%PTbI;oeLDU#E_A_fPerRLLaq!?fE3x)6 z?R~TmW+xu5`9s-DG&QMAMllvpPw5%A8szXq_#;|JP;p0gD{DRP``-8S{BA!8^j~iF0S>?0 za`UP5Pw~;NkxG#dhVQ#3e5^c^2iG?0BWD|dN{@RL6WL}bVN5R7>a^C+&?v)u-tTL9 zg%t*~w&_8sbF#zW`__1oBXVBvt0}s{m4n|@SyVX4w?xYEbIsnWV<9!5QuWY&!ja>Z ztb`tiR|$J#P2Yr?>b)uoY79HrzaNY?t1C}^C{j4ZL=sfkdHdi09nijc6Syj@boWPb z@E*gMIRZ-8PzhVo4?y z`M*iy&->>Z|A&94@%z)~uddkOh7Vf3@D#i_n58`vP`p4>^C&5AJI-aF1czK@GTdT| z*Pi~8Ll20t-d!6wAbcVNK=4U#NBY$s1hKbG*F4OrlyE1-@l*$JJ_4|684 zUW8^F5GUVNYuAN0UR%%{K5miY`MP)V|68G-@4qPYuTT-Zu}6UILmeY3t}cYpxXMx) z8haZ>w9PN!U?=y~He6j{i-^J3NLxH$B?=gxK?GF6dnp z#824jc2)v(6~zQCd4aHepFND9T;j>gybH}Z9fp({%w--^LMwZq602;PC-_q7`G~dZ zye|vHgTrRQN%+wx=;#{yT^yT~4b|Gij}|HqjI^DeXl4W(CUDWl7SW4XNEMebddr99K_2x?z zaU|CACSHbMd+e=T{$`wE37{ajp2Vtyan)C@2!BmIuOoK=^}wd1*>>HuaAhaE^dy0{ z6plc7v`{IdZ%d;54k%vCaWDfXO^` zlT`}Nz3e?`UtU*hQTgh?=Volif+0mFRbQxRW)G+k?|6h5F#1@X%ZojS5JhN=pWm5v zUby;4=NAysd|<#`>71ImCT~3UH2ZTf>)+7#-^`-q+3jx)≶ff>-=g_PvoXy`?4e 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

wj~ZzUM~1$s%zrF}W_+9az?wkWqEr!RWge@+`J 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 + + + + 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 0000000000000000000000000000000000000000..2ef177badcb46a8ff58434185d2fb6731ae6d877 GIT binary patch literal 13044 zcmZv@V~i#a3@1FcZQHhObH}!A+t$vGZSEL5wr$&I z94wUvpIj(|dAmhlA#kt0;yci6-r9C1g<&QmNa6ELY~QKAI_mk58$-V zg!JcV^fD2c>637mBtO`|IN%8IFzBKG-3Y+^f2&5xeY%6)jiISJqPDuCSGuBRLcjOT z{)-1eKE2FI>(3@I9teEWyafLU`fN}k9uv+9O8>d|YJ1H3HUluf z-Z=wn3l95xgWNwres}?2ci3;hn=F8Eqx5^y)9!%LLeK4ZsH?lfJ(_R%hlclWAq8U zsdyI#0=3`rKB6wy2C$!quL`dF`++IImG8BSypM*@f@9!(;DDbx;0~Y-=zjoS_TL8m z{VxYpK-Q<%C-d#zsqke`!hczj-|hkb0*!)%{}PP?>xm!xg~zmb)63MGmWq_?Ra~q8 z4^=^8>p8!wU2u3-?+neq*Ek^Io0}{UR?$N1ht{Wc!BDB!cw^xzO<_B>PFLs4vQ1LO zx+~X3=debeGK4fo*Zob7JQ=A(53%m)`i;TxcaF#Vy#6phUWh!jbJ;~ zJU{|I&c08+OR%g%tq=RMu~gU!B&neH&VH=g=|Ej}53VCy?3*V913}9eeZ(XB@{-f2 zkx+W=TeKmzLjBXqti1&_K~4)j8D&;Ii<-lub!XMDmty?bLUr}3%9Bxyws)JJuTV$Y zhIj2$ipoY?2`CEpsXy&}ntYvtJ{SserIcD~Q;szAV7&?ydi?~$3nIRi@>Qgk*I2ZC z&6xH5{y$9l*VdtL8;Cf%g1k1?CO_=uH9vI~l89hL*~rHV-5tt~6?isNQbV17Y-J6! z18;j`c;{EOY$Zt9-op8a?(8APEwq!dWDIY|BAHW4|9=erCm0$QVsvO|U5>%Q!x!*Q zLCoqN2}EY{yi@s?P~CP*ZUUM;V9Lh{n*y@J+)`B=V=hE2Cto%Gb#o*yprGEJsWp6j z6Ye&dxZ(4XdPU}Kw_x0y!$-1k)U~wHyU+Wrgs~TnJ+5P3epQ^?Aw*kT|DXE!UzJj& z^(9=T3U>pR<0A@E_-FN2X0;saX^DH3n;VNq$zmY?4y?__T1tu7%K%HPY|cQ!3zl
a(AWKq|l$82qORLV$Y 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 literal 0 HcmV?d00001 diff --git a/website/assets/images/popover-history-dark.webp b/website/assets/images/popover-history-dark.webp new file mode 100644 index 0000000000000000000000000000000000000000..c1a765d3a56c1194bdf05bc8f1254d0b26b7f6c3 GIT binary patch literal 292160 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

GzvygWxNrK>RhYDh`=L=@Fe4caP{Nb} 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

M-~dZoq2(8!@0Id0#^Zx|XG7(&ZJjC7azB#- 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�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

rXx12Y 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

jlPbxMmHzra5y_@3ONgPTH& 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 X=nR&-WJ;QbfuVMC)EH3v?*{(?0$rgs literal 0 HcmV?d00001 diff --git a/website/assets/images/popover-usage-dark.webp b/website/assets/images/popover-usage-dark.webp new file mode 100644 index 0000000000000000000000000000000000000000..8d5cf33190ac63957fba6c06b5a2fe508cfce4e8 GIT binary patch literal 248010 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 z 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 Z0000000000000000000000000008>`v*`c; literal 0 HcmV?d00001 diff --git a/website/assets/images/popover-usage-light.webp b/website/assets/images/popover-usage-light.webp new file mode 100644 index 0000000000000000000000000000000000000000..4ff868ed84ee40efb5d4d228296181151c797f98 GIT binary patch literal 247938 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­bR*SdYuUURp(rf>%gBD36(H%c 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%0Aetrn9J#8{Vx{9nfiPz2w z(ImIA?LIjz%g`#XUt$jRs+OS%aj+z1f{0<1Sm47Z$+qOoeVkm3tC4OlnUI%$Fm>Q` zStn^!a^^?%3r{V^^frc}M#m!>gKh8|f4l1b zihKPn3A5QOj9QGr0sei8ZJ_zXcJ_LC3cl|5ufPESS9(j6I0-@6y$aUdK?Rd(*d;@8 z!p?r(7YzEp#{vDjDmH2Ty820p&n+Q~Pl-y7w)2knf&Dx2U+A^(>Hf0@2YemKoZ!{z z4~7--jX|mjL?SuB+PQdt4*^hYX@AqW7RGX{DNNo`C3acur0#!aNGtq;JHyjw@}F`N zsTd`yJ0DNd6qJZGn32JaRHTX#$dqbyjGG3?fL?+OgPXVH&GEe}A+5^I65(fI>W__D^e zU>;Gj!HK@XtU;MfFsRTg75xqVnu9W1L94)uUPu~udbe?MK$zzZ{F`hYCYguGOQ+lI z6Awx;b>}%;&?Ieo_gc@vELWgJP{auCQln|5tsq#Zqc0z2G>8jmrJ-LZP$|c7QoXy+ zPJ01fEB3xxH#avG+5wYZ&1(X`Mea7wz+&O*ZXj_WfS>u^gTkf0ry*foj{u!mD~jXa zZ#b>s1C5vmx0Q$YX7cRN9G(Z=`3+U*$@%?{^K!3`5OxawI$~V)k#gJ=d_;)kM!r!& zls+e)G}$L$oUsgQW?6xwb`t-G?Yf@XKZ4@Hb9~8Ql6$8kU|&LK2wBWEi_pePz_69M zvB6Dekwo}2u_LKNcYK#W)9nu1S9${gfh_aj4HsTPkzU82LF|j6D}rB(rUp5C>20S@ zkbUI)c&Y+YYOL3big4s1;t>5`xmfJn?O*wC_wWvSplgy=O7(+gp%WIC?4h?w4yUSk zh>vE}F%FQUu+V!9(8*Ol`^Yz99Bj!ETp%jRU54&(8sjzMN#5|LTZPFOFa24MTAEjI zA0B?!xz922W)-ISFuoGI>zbo)Rw)bTtXxy|INO9Ru`)ICzuJ_!e5D!Dg}Twjar+-G z>(&h7CdZ@{(kJn&RoR)bjdC4p^If2N;jIb8!$J9uj)Y>>fi+m>Jm7)EAA6Hhzk@2& zWU^Qij$klrMPpz67C8? z+#oK=aZeZ-C}%Isj2)=4cjDDLTaqT)QNk{6&o`&su|1Jkoarz-xWlQ8v?`O{S(U^# z*S*G>ynwokT4)>Br7sIoLtfI_aHlqHp^3Qw;iNvr<=+p2J5(J$_6EJ~l>LmMcc;K8 z!X1~tLM#?zTSGB?z+A(M8I;LKT>u_tCjnjQ*&NC(=v62NF*goB z{p9Xo%Xo!fRir%Eo%G{R0vJz#OiZSA<=PvU``#>RH1nGx#NPx@>OCvT!%+BF1itPd^G|2>< zo%HOg>mHmMy}-kB-SvJaCZsHjs4Z=qaOPOo*2m+&WAj3Y55Xo3CQ2%@rD2IO?;6TR zPWQjWaY^52vh`Y28~WQAE%*UIdPI|$FX(4g;pSL3#zEc))zZ1uR6h%`grTOur?(T3 z5gwZtMdOyP(X3v_Fz@6S~XKcdzj^$nNyI+1dgkb+F;clIhX*)uiEpSf}1t=v5+^QlDIr4WSjWUk| zk;3^uC22Q(ASj!bMwX6maKlifMO>_!^GUP;zTfd+E@qmorZE ztyqX+jQV`A8bH(oO=(O24Q8eU=JD=uicIOuZ;T-d>yoX5spD@xx4D^1sgBwrlL$!O z68-iMQEH_tCXAN2{#StA;uHW;V}jEyu@&z*Y-dWKJU( zmx?7VK&27vuZe~|p>%mcPLhWb9c^vqy62g8Bnr|u?$tToeyb4lqvWk6qR_hx_^qdB zpy$!3jsKeb2^mihoS+@dGsgF0TbKglpYH5L+7O$^6AbRAjlFKBj;YXle>%KQ>Bnik zgEA1*WJwE(=nUM7=iBUY%pKcCb59Y)?ee}zWcQpyja%Kq=ce?uR5m;giGb*Yuj$!4 zHX;v%Yk<+QTRtcX7XdWr!oDvqz^sYZ9fsMbg|JW;!8Ls%K??9ws@EbafoekpVUuGq zE#@-}dbEf{5?Aqos@)XxJw$C#kzG&gg!#`qyd6y(64O=<3&~vlP;{}GW=u>NY_(8n zgjJ{nwW};p&X1cQqhFn|0~f068`M}L`HVk9`eKmROwpw4=gpjY-aeour*uKgX_F2e z5>n+(A`n})Xh$Ev^}RL3d0MmJ9@1z06Ayhpqe3kG54D!9lLv36S03h7Sw(Zf(Yu-; zumr`IQ9)uj{yOq&|DMY%kXQ#qQVQ+!8o5feRaT{&q-xh}+`SjxKTH~`9@Z3NAp1^s^^*w`z-lCv$0;aPKLplL1X`-t(N zixHimXod?&kqn(^GGi=7%2adz(b9n-{7Va@EJBrEc5x~*momnp^OR`PocspKRptc&qiTcW-xcJ_f zF@krz4qW=(bvaLFRJo(a6oRpIq;`1lesFij5MnKzj0-WFwWB_;RiN5vQO17(X+W0002K)DfB*mhgh8cn zsFp<&oV(1*yA_S@W}aTq`?&CI6%Dbv^?`y?)Nn0QS{fxGUuCnY=O`!vp#&qAr(A)x z5`HVy_bu3}Y;*w48&9bjj1a3>*|)QyKu9oBE@oE5Vx>O~LRtV5KrXi~rpEwFe#rxF zS-4Y|VxdQgS44xy)q&T=h+@n|DbE2st}-N2mnYb^vO-yIj3kpnmAy3l000F|oJRT! z?z;LA`B3$4B+48X9d((C*5zFQhA%D1X{%f7gMHBSW($%QV+A=ASj-zFDCEc>9r zZZ_XqXn>oY0+TlYy71P;4ZgPuNBjJR(qE8yA~}AW`coQctzGLv9v;U6)NZDHu54q@)(t<&Ovh*hBXcuv`%&YWwu;tK++_k0r`7?s=Z~&H zkC~9XFRMPK+z$?0?RJhdUBNubKuypDl6`U(1VJtn4RhfkJ%?7_sd%~3)E0@o zq=UMm;eS+w#X5EJ;<+id#}hb)dgDT$PZz{ zp;$PhX~79ehS5yP?@qp)f7qeUlOP+g$!h1`yxPN3wu#|1n!n?sXA@c6iFMesC2D`r z0e>SG`afDmUb`*7{Of#%P67%A7s5?E$W&kE*L(MH`{|k(qVpp7OM*)j_M2x3)sX7? zUa0vTJrRZkecF3ejjnF{g;TxB%-Z!8tlz@0qK!28RSz`2gmG^4g5}@xdD3yKbq};|u+*zp7YbCyNBpWymFjz}5nl#afb7W3%*IBl`toc#K7e!n4FB1y-HH zuQIlh#L?L$iII`X-AEe5Nol*Ytb1h+*`%6hVrSYL*Mo=jLRsL0ZBf~22xBE80_4QB z1f;u@rd(N0!afmqD|T8vT-RUK&+2fNM%Y3TauJ15^D<*H9-M6=l>;d25zj`N_@}Mj z%Tf1c%B+2)nyI1U+xa(wMYGL32noX}bUuo2&$b~q&Z0dNqbQxnW+CPwH6=^=tGL(B zS0gPW{U1KIFgY<*95e`7R7MoyvS^@N2{WX)oa@9m)+a3He-hG>WdiSWbDpPFJjIMp z69s2%>kO5eq~8$!=k6anQQz708azN5`EaAqD^5r~fw+h=z|YZ=u*uGGu9-scwVv@^ z+Wa5U3`uqaFAqT=@fX{`3wIJrC+h_O(YP4R9+YyWBD!#zh!dx;{W)0u3AG&Oo@AngKIP{BV=I#9IV-~}2Xjc2D z#1zGKdtNIiIgGHrBj9Pm_J(hpEFvS^IoM0`ygeSLCUl+x5hww(oB zq5oIuqj~%h#qlS{$wwExa;~uKa*hDvxc=LcNiRF%#+xZGMdFJPF;=2Y=_U1jFK1W( z);RH%wVHf#t9pmh7zt#pF_ony@Z&Oa3L(9)b#ghgt6cEh4;rHUL7}-F{r2F0TjoLv zoo{Jt9_|Dze`;1$IAA<9cTH~QOq{Po>J-TmoR>Fb@q zq}Gk8{rL_k zwD}h|mG8u7E(9bJ=aEq?fH+`wVcC!e;7>|+Jz(JoCgkwLWXIiI-YBo*u^Qk@DB(9m{Qc+6=b(datm#pzltZxLIL2y zLB(jv88hRrsN5CBu>G3Y@l8peFKJ_IMu9D;YK!+bhZL|azyJV37=&;l6r&Drqn&M^ zgUMyv_V&m3+jnW8x}7>tgUVzJOe{dSu`Fm8&*%6WA#2L6pilE&SrOfYeHFN9Ec%tZ zLb5V(BHAo_VGVy!5c5D&RR+9cIVTwD+p)g-Z$Oh*K`UnrZO9ytZmaYa_my9c5OYk@ zd@gS>{yP2|Xb@tRkylBZl1!?&2$QYcrr({QR(OaIm4zu!?~oUkm5NHI^~^`N**0~# zG0fG@ZS9v4EYn1U!&;y8%rqDX2yRs&iVN_X8m~I4qKxG3i6Hrp-0rn?iQ$X2-y+lR zZfkWz82Yje)5oLRp9LMheWca|uO=aP;TpDj^+;yp>1=}xb%Ls;npS^M>|+D3cdWMjg{jK?CwR@_(jaYt%F8Edd}_qXJ3e510y?c za!JV$4`bQI;hcAM=+Aa{JCLfn`7ZxVfzjKWhR zI%gG=OZVR*k+7o*Wgs-L1=Rqh09MK%^a;m@DfSp_H>7?4n|&BpTPH6^_yHyzUQ#6U z9Dq=cGT+cArk4TcIhEpjVr*-F_&StNvkd&P=HyulmS8@@)l7x8p2jtLFhOw#u z`xhVp*34EBThm(xHiMV~z9qVhSfw_Rma)t=I}(tVR!Cz-zyrK#MIuveus$vf-BL%H*Et+^01Tcn<>r`eS$!lLrzOpP$>@l+g z+PSKOUP;5XHkk#YQ?@L6XpbuBWY80yiVsTujBB$8#i92;*-93`$q{LJCKx*ZuD@rP zWzxjEC=AhM4!?^)YrN)U32Z$A$`k(dP<950_If`7LE*n5207tSPkjO;ii-`D{-j+Gxq9FYU?V5A$(9?ZNi8P(_UA^^i%I9;oT>2h- zfsMxhNM4cdg-o)X6KqTmU*y8D+|2imO466QTIt*jO+}lq`hTNahzk+|oIUQ6w0#^9 zPkC=E`0e`aE=A|;_HWy^pFB7#s|0Vf8t2Q3oVE2!x)T zh$#y>ol zU{4fiIaWI<6qYe3h58UG#Ot-#BK!lVFfJcJ1h76GN?oJXlqroJHxUiPQwh5r+uD6)P3e^WG^!TE z3WTa6=E69+Tc|XmUOtDDZ&+Fb+FPTwGa)IdSWwz{sXtSJPNY|T*H)ezKJ+MS zn-mliME!$G@pV?-rP5@A!~IOmAwB?$NpFAWnH;M9(r>{{NNu;VDCLhu5#Y?v;!%P4PN zoa*n>m1g_=n)pado8BCLroAMS&@PI|`FpUMtnr?-@$6SXBEc`?PVg(r(Dgz^Kzab{ z8}2nQOa@p|@>Ik8M`ijx z;Ya81%yjk*AON5|L+w5<-9kyJN4F z<4&(V#mj&?lN|?M4Zbs(CGJ_Jt9x>nfz5XtdhA0R+(jK(9=~>DvWZF<_j)ahG=$YM z#F7c#pvEdF$)Qe~+ctjO>A^eWI@JR)R&#*a@SN?#18prtl2^QQ4X52 zSkmYVOD@^}`OMog&ndO@uB1ybK@K_cw1IVkX**&o=Z*Ipc7v>$m5>}goHS9`82Cc_ zgf3-xR@@HMB?b?Jlzj&wNTBhuC+hLYg~uW5>cTDo_NlUGLKr9FFn!zTi%Kky4n82q z39L;L*_}osDzBAi23c;-DNc+qNx;*qf20|^Ra7Z35QuWk4m=;NB2@|qHw$P``BQd) zqIUy#n4K&SgW&kMdTPG=0y#=X!T0_8CO(K-_XueF#=00wk(uY~X-Iw>#J386KE7d+ zxEx}XuFgJF7qX6GD~PY8i zy1cDRWBv5c=RXszudv$V;Qg2c$-iO#FyVeiALwPHU_6vpsE^Lgou5$UUewVMG{m)1 zyF6cU%)JLPxX|@eJ_%8u!aJHjeDz4xzG!f2tT9e0u=4tX!1_#Bpx6X zegfWs@}OFi0vB8iX6U>)I2g^*cuk=tTVd;vo!Fx9>glv!p!cQ#2^ zoJa06jKV79>kU(g804WX^1mejQk&BNqFA7)7g;4_w79sqSozTQ0#e^k!JJOc^faX0 z5yaRc9qUX>*|_+ui7*NwQ(6{|@b}HA5vz}@D-!HyEanDMdA?o$-?Q%~q2_I#K=?WU z@c#^o{~EQJQ)g!W7k0tG1b*%7o3Vpmr z0^9mHOCq!vTfw5)c>5&V9*!kY%2NM?D1Y2q@}(-Gf(Y`aJ4GvL$W;fK)I zRy}aIWjeTzDVRG_4%;s^9-dQ9fl-q((O$z#uCF}Z-)YSH>kJ;G@zB$T@4j0T{NwJ= z2z77T3CM~x=6&5*!noiV1t%j*X_oD; z5b-Mnfpr_;Ak8)<&wlFw-$NiWu@%N)w?MZZNs8a*sr;U=XT$n)4>3rzz?%t=bN&ZZ zbmpzLkiihu7^a7RR3IKlz1&;cl-hrQLuui#Tg00dy$#2(axsez9E`k?DQ(S+WyLk! zJ{4+RaCja>U&G0oTa^%|-iOOM2(`A`mJ}^dyp}-KaTV3WH(>6t5(J+wN?wNwT&U(H za><0vDuI7szcWf~{}3{_eB3M`aJsG-k-*0ELnT}Yo+w7&dZ#7c0@dZYc1z@tD|dxB z=@-}l7O&LiI@6N&6{a zTT~4MN@r@E`RH9)3USQKiXMGC2&+3cY@}3AFCQcHRU5m#a7w$6WBGvRl>x^77=3 zP~_vnZybB`5Frpc1((-1K>~TT7twJG++^q`2PD~!KMx*$t=1)^YE$+D>jus0G{Y~8 zOR4&VK}3Sa-dQg}ajrDnQ!g;K)7mPjUuAUuhI^TD)q3}lWhz}wxt1Zs{178xIf2!dZPS7doaCtQF>ximc1CL$ zlI5ZGkLdK@*gL?&DCW6eFHh5U+uJJ$2zNXIPQ+s1JU_!Czs9XQhsD%6UPhaX^Q5`t zi2#t*;-4=@GPsSXusdb~y2?PidA{t zzA!7VV1nfhSMBF6@=z|hFhmoVECN%(U@|#JSEw8OsirUb>EV;Cm(^m_Zv{PM>|T@z zA*osZGbp}tO>2ZzmByuA>r^8ZOsa)C$M#|O$8#<^wwPg1&=t@Eiyt~3z)D-`_%n&w z-iL`CMKmRf=vT@&HYE8qUB0pIgQz8Q&%o=sS7I*3{QQ9+RSpMI-XkNpqk_ znSQJ5xQlaffH*g=hbfYpB?{$)ym`7)WeoqbHd;ye0?mx&@$w1T6*!v$s_?8Tb%H*X z;tT!{aT?naMd@2}9z-jr**%)PY_J4b>1srU3EuZh?MlqzMz1h2xzLLso|xMo{KThi z$&;>P2bGxbVPF=e(tRE71TU(cib!!Lv7U zzeM66+8M)Oz3z^5WfPhbTiPzbHjd^yNDw%Ig!4bKstwIqQ-}i9jfdG5aVj)z zxFr$Op00Z!rJ164i$J0NEkB!fR|D%<=m$l}HXs@ z`5xh}+9Jb&HK?y-g*jlA%L`gta%ri?!nigXPW-941CD7jMJ&j9PsCo z9-?0k82{Q|-pJiV%4s0WC&`i75{W*WDsuGAH69Em52e#kSD0}5%Cj$Tjr7ekr9aq*)qdccC_GY;=z)b=ti5%En zWDz(iqv8c2-%URE*T1=cv!8kRf@8^>mWHWm@8u+g{z{r=YzEWS zyzx}3y)CPH=ow~7D-sf7McrQPahUB|mOl^^w3y1eK9;w**olN_sUo9z6WE1_#`7xd z;2gP&t?fgSQmjqC_UF6{|Ju^W65sS$7({y5n3aRMwHe&Se|EUA94kf*nGTe{i4!V* zZWUjGzittS>BDbXM&!RV`YUqT4LDravrvCBy{rQYq34f@#v+FQz%iXmkB>QPj*Qng zt@${Bw_{+Uh>L)Z9I#LC{0pQf(wNglc|9icveYXVA6{m`6&a-pTl3+$rC*;@SnIB9 z&`2cwojG^=-pjj@D1{4Dn%$Uqi{bzQ}%HuH_vu|`sl0hqS> zS3uKUi3STw)5B(yNli;!3XHCSh&7QS;p`~;(M6F}i;xpGzkR_Jy{kp?1;uSMXy&nG z+>2*?cuEh6fH=6hp!4m5w=lo?e{kUx2H(sLiG2_~pigW3Qt|2DZkM#l9>_rXJ_}zK za7&`!O{a%1Z@hS%Y+9h@MvB$e6HP}6L>q}-$=>PEo8R&0%RH&Wy%sW)+-E@o(a@oK z-w?4D-?7Nf<%boo^^{}(00EECw>=R_!uRrL`Wf2Ai!$h05Z|7H3jC;d8=U0E!E z9hN|PvYE9lLBl=sW@$m|SHM8Kdj_hWUZRCK^Lp>QVe&@G2r4A0CYLSLKuHsiqyW;g zkEK0R&E)%LEBfwg`6hxH3RYMY4u* z7{{I|dY~j+9_dfaURT)O@$mI{UbJ(0EQRKee|1zZiLSd78nkn~%|WpQJ8CAz2Q~cU z1bXI7uVz6{_jW8kjV+>U4oxRg26uLpujd_6_8F(T9(>jkRG_@kg~+vyA4qz3pnvpt z_%H`{DSvSQL1BCKdW%j=VZ_`RVrQ4TBcIGbCcc0B>~?39Db;#AT+BW^J>Dx?r*Xrr zz-)9ad;{F{o{(K9Vfo|4y}iGn!O;6Y0vb`!XEs0^(*B9I`0a6pJ&|P?5^8C_NAkwt z4-kx)<2Qlbn=>Ac&!Q)+{<{+hWzM>bpaR-v%?1i?Is04GTN zLHDr5g|_`~RtP=uVS@}8<9*j!_d8rrJ(<(X8Z9Si^HrkZRuUmhci2K>PTp5gGCE&_ z$(n$+wTZ-m+nfK*0RId-fU1xd9eAp^37)AWzF=!1b6b{At&i4; zxT`1{V#c+rL_kc!spR}l^XV^>1O>KHtv}j-W(00emb53aW!Z7}0<(930QO8Iy>3_( z1e{9PVT+`8t&qb2?g&M*tCr4YFx+n(sDJ29gw9WOy^ID=Vn3%O7eV3A{m;N{0a~xZ z7uk)sx`36jzQ;H|lSPLsYpH;IY&G8sJdNWjcNPhDR1!u6=^%nESPvo*1z8TV zQ`cerJ5*pF=_X^~CRIyydIqP@1&B3d4lKZ}ZPptAeM9=q`0yTu0i^j>$};Rso7|{z z^g!4{-0HtPPC}ub)nJf?_;tpuF{KHlr4xQ84xu|T*$RSU4?xApn#inpG>fAB4iHM+ z)liEpU|=#}0M(pRyMZ9Ipf$(~V|l|od_oM_-5xL`K#Xc9B3Vdl9k25EN66-2P=Ww{ zsu+c!;^YPm6e<$+jy?-YrIwS3CuU+wD9bQ|HMT^ALjcz#3L>{#FYk}&JYA}_Tn9>B zTn`5p+OM4hz7DQ1Qg%dg8=`g;Dpf;FYuC*?lG1$NdWIZ>v?O(mmNudLwiaLp^VBA> z{P(|TN-Ob}+E$M~?KNUv_mxkO}}KLaV=&cz3NH8Zzu@6Ve0V;{YtM_VtD?eh28O4$5e z0qj0Jj}%da0@y!Jo#~ZsCfL*x=#XW*VTW5W+U8V(oq@)d7wJ(nxR#|`pn18Z-*LgO zZh~#fY?}nzUlW&2ll!(?(uvyg^3iP?DMbM>jaAgpFaA*td{rQaF-S!}cUi&AZf#-6 z@iebj;JybxS;gd4Tm+V~HEoM@p!8_0NFg(_!Gwu`t;O(oFAhpK=9g09Hdrk|GjCG< zQ6=j?j4a|uF|&tkAg14v<7SGRt8%Jvhed0&mbi~ttxRv|wStzQ?t?R6)deZc$jJ5? zy=R03)Sw2nWip>tMiw&iI8}H0;%hvc5x;-_Prm#*@+LrS&>XEPi-V8rj9!c@v)BLC zl}~H)AV(_5IC9j96*0Vef9=Yxv^kE`asB*zrSxKa( zrLG26tMYWbByQDj+bt~{1~UmIF&bk>w=644za%zkUk!_{7ZFbwHljcOFM-jFbZ{jf z@2}}Er!(QAlNpE;5SGQ0(0oKTCnF|S$7fART)PP&CZsI}>Wt*rvv0;Ao~46Q(2XO? zaYD`F2RaT?@Bx3oHR2Gw(7{U$$PXu82dHgAqUL>{cMUFa<+gw6P{Pr=+b;XISKr=Ic5UXMmvwH|J1n*|mDOz!Zy5y{72m zDrnie0k&J@B@3wcKvGGs$~21+U9~_e3BK4->-~Kp?`zU`=A)e)^bKP}4i(gf2WJY)tAw6;1uNGg*p$m#g>xJJJ;ch44XP8PIz2@=5L zoYr}0=B{~cyBRo7>9KOqmgc^sn*raf*NTM0Ob%s9u3@Ln;Io*`kmunDFD65^V;YYD zIh$g0I1hm+4@w1tU=27rP6_|^ouUNw;(H`cH6nTP`VK)+IB>7`f*s_tL^9X_fco+G zPhI}@w5k_ezaZ3FC4u!T=ScDS&aYd%p7WIL6`@F^Bn-c zUv$FHLHiSrM(N@-mQ3R=ci(q97j_+ItSzuC0Ps{Sp{4Ix0>P2MySTXn_3KoIjfN+n zns-%uc{+Kvh(7ihy+V7sYW_cM2Y#uSV)}kim9&i;U;ak9VH3KPvy5>IRwt=8qnNg+ z8ugh#2c&@TImr$HQ6SM!eJBpIoANB)$z7J?;Z~+d1d1M7#YWeD<+YMqkyH4MbznIRv&%Q~q({|A(_g2iVPgiit{Bhb- zh~fFj8R9h05+@=ZUH#Cbo+UYNBDAPtnssni@NwI`L9UP?Y_X3HEY^Sg>B^ijc?64Q zSs0w8txukTqW3JuAk2rqGPAnL(lN7?-c}48CXKwq$RpO%Z)GVUawgvnvw;M00J)j8?Jtyg%R?f66Q4Q5$F(TbThFAdk$jm zfk@FQSu`6zHvvc1uG+#Ub?TRX7?tcAIgw~Dzy4p|j| zXr~^O#*$v^oLMRrike5nI~6%W>YoF#qv6Ofu#Hcs_be%cMb7-k(ReNw4yY|b9KEOdyg z2CKHxmk$QY4-guGus-mELf9AYXj@W@+9iO7^Oe7SGuXv>1T`9yYNT)b9qjTw!te*(t{+e%Q}zToDIN*{8!qn=GDauBFtaBh!s5k=Qz-i(=Pu+trj)vQPwwco`} z&>bH+c!|EfU8<(T#WlsK3I0Rhhq_`d+uCQZn@M~UmLt0vJA6}-G`9(Z-*NS&@+3~a zEzt6+VIy<*F(U;!Tg#!c9>>X}>{tV5L(Yl8<)B0s)IE;XFf)fqVd(LqcjST=c5^HH zL;qdXItCEKg%Dp&PH#$9LEs0DG;R@x%@#*@0_E7C=96ronwXX~0T%}fa9*N%M9BiM zM;ahK=%Eie`r^hzRUIUB?-(Z#>kL7sD_mcA+Jp7oQ%4r!l{1H$H3FDmq4yxw(ClB} zW}m>NFmd}x|C@h^(Lrc|WTJQSew}`bWCi3nDF)&2)|I`wHK+e47|;PpuN~e5aR(kr zb<)*x=nvs+Ui&E-wP5LJy6K}MH9SKQr9^1&)}YUtU{5+GVb=9(lJb@fg(uJ;gzC04 zbDn{MThB9oY7KAr3g)F+@qr$+b_hM`xz|tr_hQ&X2=U z18TNo+T{a04LJBYOizU;8*oOwxwuo>cY=wxJE>t{wa4oggb3&4Z zyQclB4K_76I`N}2?!72t8L+lo#|dpdJQ1=)p-~t0?U?{{HH$B1+PsIRtNZ*%#Xnlp z_zD6hFu2RRQS$;344%jVy9(|RtM=YFewf1v8U-}@eiCULU_Ie3sskWINxD!KMzkn@ z1h*FO?3r?9?`r+1m6~aDyN29anbz}E$^Bgm2Xy4>Rm*c3y~1B}@5XATFY?AgP4!kc zlTnL})maOkqAcZ`eFXire%*eajcb(39;dMQlK^nA3 za8ahEbIfv>DY#TIs2{Uiv!S1bK3#rRrBE%B0yX~Mf9N6){{KkGI>|f}-Hz+_C;BQ- zDy33+A$v0lF_1BzM-2NhLngK8*$X#0Moq=eM_Z7?ZGjkX#y3xJN@%v_hr^a1NF&(% z_4Z70@HmRYpfwTFIz>5J`((MIB6}TgXSz@zM?&h>z|wHMcDN=SPMZKC z)0jC31hdL_eEy`NF5xmOmsZXh)FK!^U|KND3!w_Gz|rmWNd*UQ}>mWM(? z%wCV|Fivzu!QhBUXXdc;^)mfo<}W26ipNe#XO$6gk3MUwhfsV?jn@+$6=@BZJwM2m z+BDoAuMmPyKk31Z2x~ejHM+oibBFNny*lcn|HSIgkRg@d9$CapJub@SN7!-#?4M!i z*<#NYI!I$`ha5O~mI))>JlBrE@;1=u_4=G&r* zhOG!}ZH(@~2(Pg&|37a7khU>7;Dl=pW?&D%gffJAfRJ4j^RRa|g8I7s;SeR{>6fpX zQF1Wji&Q-PXc^9T)#%Ww}Shy=9im>m$ zWuAR8un`2Y{dwtRMc4+vAROoC&3n|R(E;YOL^g)WxgzNz`lX!;Y>;jjwbcqYm{Kp@)45rv$>Y#*?#`MS_8LyVb4!`E9}mNSP^AzIW=Ev+_P z&}81uqX;LC$D?1@9B0>NnzToPt^2WMP3!K8CCyg z>=h4!f8eAAp>q*@J=tNumR?C9LDU9qKa?tbi%Qgh<-2&~M45hn28yTc*FU^d*+H_G zY19Ab!GmpN^0mZ?IJh=cx7pBvm^fO=$m?^?Uu}#I5-Kz&j3`R@J1TYx#CA$$O-$me z+m%#-Y74p2g@xZMHZKL1v5>`Fb47UAaL7O9RbqAx%x>C4h3`ck{lcg=jkLkI2F&*NN{i~pQ9~|L7mR{ z^xSA0e~SXb{Ql+t-q_TCQNUye0zcy(k5<0ZCAMOjQxX6G0006l<=J3v1VYIr?7a8R z4i+Ju@2fZLDDA1EfHhbxOMNltoY$GZ8*y*;ai6ShF5`Aq#@2xF^a?V*>h1#phyLP$ zC*v;qT%RMA*be9PvZ)s7F93^%?7e5(a01WbJ*Twxp3~ZUPigHvr?mE-)7pDaY3)6y zwDy|UHjbx5!Jk5=&bAdT^RRP**Gjz^&-3ca(H7?TpnPq#t+3FBw>lUaXrvRQnd1c+ zTH*p&_zz5{rvaEsdso#o!~krJihKP{d!s>L=@H|mFEDDr`rhoVcAR!VipE~P{PHly zgZp|MecZpCo-m08IPe0I?58onUotrpOD;J^1*SY;Ctxp?AtzgJjDcp3pOup}~27cMzfZBubJ>nAoXl~hwo z(g+OhzS=ByrLy%rqG9OaN|x6;63UHr#lx0@WpL&O&`!_l^=YWvkG@kMrb;?LRVePT zdW2ebPBOt2d}|<(Cgx>WW@?%NF_?>ft%~`TCKYTpJYuo4Wx}c?0}_Qj@Qm-H{0FX{ z-&jl3V>&>{v2)ANqSB9SKA!o^{T~FS6^41v=Zh%|&tJp6RH!Zo$JhF{E9uugkGK#8 z2ID*ZT8l;Z11O3p526t_Gxe3$o{l`I2 zvRG>mS6b6B*Q)gcu24%DQ9m|@*H=@!i8w+YH5qK(fGr(c>eLIM*L^?3X1R-g7LyyF zvQk?PK=+Fhq}H9C|A`Y8dipr)gu%8%F);Y9T(8S=un#_s45h_{gu~WjQA>IN@dLlg zZSlWz`$DJy00X>aq`j)fVlq_EfO3Pus6)iEW1V+~gQ?|_3P}RqtD@Cm z5-dv*RY|Eb8ihBS$CHngje#5eD@N9?wvx&4VjY%f^@<2D)I!2#(`nf1Xid)L(H-*G z|K{0H{}S82u!;#9R2mNjKeXw;iIxSB{w|X>dEgZoQs57d3sNz{7T0TX1k^tWV50BD z)u*8mrYZ$IAt1yll3naa*SWkOp7!99;ZF`2TmaiDO?#w zuWHQa8mxC_=l!@$l?>3x2tKwSd?RtGWZ9yK%F)hQKUr7uYV-^t5O;GIyWR~o`xgg2 z6h}md!u*8BJ;yQcWJM$qrWM)Z0is+!p9_Q3U&yo@%aN5=Zs2`3{Tu^C)%5S51oMXI zW#f~#N-KPUE3)xcHW)TJ*rA`(WTm?bPnNOVR2EyH(0Xl3twc>1g*bqh&bjUU<+1jv zI+m7yHRSH~bj8;oq^MiRxPK@DCzRS5%t>nH%JZNr z+?sH9g7}Ax#{$Koht+6XAVUHr&-T$YqsQK&@1R8!K{ViGGWDBc(@+WT50rySLws& z?jxX0)|N8((5tK-c`XkzR2d{}Ay;cW5z*z?>czZgoTB-2dmzXHnEm zwlEiujpIL1wv;Mu!HzigryJ16F^$3$7f@I3c+%INIk|x;36M-OBp8f~>%`@s;0_W~ zM@zl^_*`Jc?T^F!89JduAU{xoQaX?pzwUI}Co}iw+27J6ht%{c@Zm(qQ9NXS4IhHk z%(V$hn4IOP$=?1>$@W;0@QCLRq+!QQdusTn{>`8qpryLdceJ9Z%7VwR%oXP9FBV58 z5)NZ|zu=!H{xc*yDS@CqM(uvNzayB#vsja0ZDaTTM)hIK)-VN}#Y~LjL2}H}BDV~N zzm}vO9SRGmEB96H+|S~V!0p0rO@>zRKFJ+3z=PpaWP^v|WF0g^s{Akj@yKA(KmfGz zp3;gOoL1+#9+LtqgaoQ7+szZnoYK=F(f`Lqz#pwFb`({wW{I0Bk}qrn`K4wk)@ghs zQ5jz33`1fnhr_5Sf{ZU8u*nroamYoD}Uo?-|_->KSrjB7)Ox1 zqM0HE@os-P9V+HmJZqqKjAEv$UEr>yCx!?_l(|}R<0Tirp6sv7!7dX-BZ)6bvDrwK z#&wJVo%64*;wujw@Iyj@b+>783u@bCjcxc`p*fNx>OM36M55rHZ&>jFxc6RerL}p6 znkE5_^#O0!9?nHXU7wv&Hrmx>EyYo}(y)`PY7#oqG##_|@ro+}kLFT&O(v z{}iH(auKC5f|fHhO}i25C_1Z{krl%EXEWA4jiRU*-}y+E7^!&_Z=BS&>6}z7(`{tf zBm{&7R7eSEPe=%c-z)q)tG1hB^X>}!I=ysb#PpHrTX8XxrH#l?}9dWSBWLEL_eUVPTt5!V6W3|erFO-_3W^H|V zWfRmR#=NCdI$10_#wMLXJ!b(9nQ>6esRaof+|g&43K&N)%}b2@?3h!cXG&-301;QE zqz9B1tm#n_SdG~(vNe`}sf)hQRtJ2Z+N`{Q?4V8irjnS^vf*>+-bk92ZP?%*(B_e3 z$wTHtfs48!{5u1t1HqZF`VMB>LMD}@hbqzdrp_Owv&ZWd=9b9Q0;dsog8+%Lz|FYpmic774 zGT+bQufLBS+@-E~GMHxX75zxt+@KX@U`qb={WOYm4++APPWY)~x5RwoyJw_oao0;1MS7SNPHL3pMe7A|2 z=F76Ny@k?cMG?i$!oD>FnAr8DyBc-6eA@z^JHSlEqz_|+MzXU^EJHwd^6%p`4)lfG zbqaG0<4I|Q#M{L)8qd<{M{>=ERr8UP1bIK$fY0%Qid~G@pf-IQsnGJ0NkLyzYScS+ zO=i_7dDfFYxsFkr!fsJAUZ{AUD~x@;4yBb zMf`#650b#YAUmI%6@ZghPnFK|Zu|@9N_AQ(NpUD7AL-JdpHJw{w>he;Rd7Oa*#aQz zczxVemrq7yQ`2p2;>XH3C2AuD_*oi=D_STF?k*H#!J5x9WyA~ka5~J;!dXAx&wQ$0 zVuAWFt4^4d>5s!>7vx}C7P*f_y*1M>nClQpaN`)x9y8{160DH&}8$dhTrbm>$gc(cHQy4rnE8Rysno zOPQ&T_}dt`Ydij{Y&Zv86=yU007Y{G`tD1`gyrOo>_dzs0oBdB2b|a zzR)dJjf~po(x7l}U=8Ien;Yn3$kVNuT>0*i<2=3X0EFifX-8_*ErrTWC zjwg$YTH8V1U62xYqP*4*eVGS{=45q>=Ys}xcAT)!zk2Nf7~gYSO{_X~lk}UGw&``W zwvH>``wzUd<3$4-kYZq=Nohy+nEMHNb&$a~yg*N5{wu>K(@w&oIS3Xzo&ZxotiJ*R zl~YY)>1@>9`&pt}%YXu)09O^$IjCGzKN!m{LY@5&UbJ69govJ;Rwx7Beg%}}5;Adx zV7m(iPLqE;-K&7{h5HP?~V7vYrqP^>S($Q>Hidlck0&VnHVpFH^zNecoj>6=69|$2!p*pt# zI}q|}s9jqEUn^hNGG!03ybEM77%BzI=(hKiwniHdj)$fs_R#2-Tc+L30o^$dG~xs3 zMMKc-ElK20Sad%ti@Om5;j$<99+Tb8N(6EUtQ(_(v)SCcMttHqQ33$Uh}34Ro{bTD z%}VzB`@w<> z?$MKk@)kRq@$-MC%v3H}r^Hm?P+}OJgr^)+`REo!G0M8plb*O>RLqAP97G@P5@Nuh zjQaG{c;zaF>4Dzl+Y|x0?eg(^T8+%m};}^?~j7d z768n)|M6qL$y)t4#L|_f@km2s)PgVZ-Qxjdtd)i63^V?L7UeDXC7l1DA<1VIFk zvES_d+1i4}EtJkb-&xiW9J)l-ukDRg_OB79(KCr0woZkrkFv2092B-RK2?K}0o^?R z$4Y1mgQn5ewq%E1@PWaxZU_&R=VdT_yPE2NJmZ9n<<*?D1B@2jK$TwF8awk2JJc*3 zm-mp_bzTA+*#%Eesn>2h9vVw;vt~)^>r)m;+A0Vdvp+9N>v=2duw3Ao0*8OyZy#yP z8vncvt$Wdn*Z0BlV4q5ZX7EArr9~wp5TlDoPVIdXkYKGw-8<7b0~;zTKwPgz-~kUN zDh)9_xXAp1Zbn%zJ9}+sG4}UPa)%sR!ivH^qVc^rY;nCuqFpJD;}@tA0oH=k{fTQ! z*u4b9(%kd?Mdv=kmnlH6;b`<^#pYGzN9%oWYr+6_I}o}w%n~~ullq!8%<0M=(LsSj z$&zPY=5Kb+P{)o?MKfX>;Q8rt09B-Ae%=Fcc?pYf9kM-kSBx-(>$>ks?V|vz-cNLP z*}==|1f^K$#}jS=Azl!W>e|&L^EZ#q8!io z@C75{IMKw2?DO~kRlCGE13Lz~nSfNA$>31NYWA2mrc@=ve|#WT!`6^s42_pDs#GB| zH6>+;*+GY)od;7$GELF@XN6Df$EU_F#@lgpDANy&zmCvK9TQu)g*}0q+uI!=rn6jQ zOWH1Ke1@FUgR#UIn7hX03vn6w2Zd^seVfz78FPOP7ZU0a7{SQZi*ULOg?wFnU1v@B9_pMd#pkeNoMu z`a`!uCqyz3b&uqK?&QE0AAM7i+)t2g<>HvvEgqce7Tf5kXHjN7ecLwn8&NvG>O2jT z*WcAIDpdw|-x!}fzghlX%wL|!Uh`(|sxxFPVC_rKr+A5~-jJZ`&apDzqx@PV$C`YT zIZjjDGF-e@J~5Q8X>dK(EZZTCOqWkhuo&yWoSD@BWY9A7pm6cgwZ~2h(A@xWL);cL z=NHc6g=3;REa+?vA&hgO^pLrl7ySS%8E8XzxauZ#t!=;@ z)wThFlw*Z>7Xjp3ipXu~RdX@$^iXK^kuC}c#%BAc;sYQ3VQkCRTUaMa#$! zfnWs7qXPOvdHg($MnowgnL;*i)C7R&X4OP$Zt6z?VG0oMe{32?z*w((HeWw&tbg*% z3dvzO(RQj*yYI_7AB}x#A)w2j9PRf)vY_QlRPF3tIy!*T49WUb=Q3&t#n(iY>1lA7 zt{ZNudi9&cs!}f|OZQJ6oRK9ZAXEgZEV<@>FUBEoOGDA~DzVK`jAO<$ARTp8UecrZ zGRQGIHxP1n)Gq(FzMrqGaJ+-qMtX4vvB`PoCI#3W8QI^7 zB_`N(=^{ozr3~vYF(Z<&1Cv?7MaJY%QTS~tAUN9Y9*0}WwIuSd+rqSZ11{*Vz~Zs1nWkb7pgt#e{XWBpCamP2CS=LW`je3KBajE1sDsQ zzW!<4;Ii-Hy#)Xz6Wx`pL;~nqdU7tQVt7Z4|ODBX~5kKjauTp!4T3Hx-S$ljpZX;QnnL8w30(R@53&Qx(&PNNEHbbW*9-dFiqbBWLdaXjSqD zFaza%LVaLZSqypFbnTOj*%Mi(=*On{~`k$Sf+|5K86`awh!?M(IHtYAKb|cZ!Vl zN(BnLb-R}$`$dLgpQXkk{$_D19YtuCr)2ryYnIF0&}!Cy%y29ph0z$bdn@&T^rzX} zZT3N*jdATnr65Ag$^@8$;m+v6fLR;FUu-K`BRo01$og5)Lr;VxX9IzKnzV_grxD1n zcAZUt0AKHLJyzCBGPA-)if+YcKO&>t)cLK8W&iqtyc)Fgun7jA<|z+y^JQ_7fO-*;vLsA7ZJgTb{q3}o=G0P|lnI(&=4 zVE;{HY&IlZJ>^?6U$Hp$+x2NOi`}a;hM<86C0fxV0Bq11* zc!xD!hXF0IV1q7FjZqGlVGiR%P$GQ*4I|@esYOsev_+?&)Wh~``nY2N%^IxpOUWm5 zHog-Nv+_5$Hi+CQT0aa7ooiqK#itAAcgI8X&9l+ppeHR$lV0T#s5vrUC#?%rs&NA_DrCXLF?)aJ@SyMs}`QDDi0000000l4P=!hZeL7kew z01B6jM&lDRoj{j?6xIybiY-TC(xC^y000A{=mg!yw-$&Y>zJj@mm35`-Q|b?00000 z0D}@t(#bA*mQ{wA^kmAgI`Qi@STf!MM6+v4FCajQYi@L^W_HDARg6aI_Y4N?$$*S8(xZ(4lVc6TQ7G#*#Q0MY5Ow4zykXCe(5fx?ElMU`nh-Gg5(1=?IYmKAqJM zNfIim#~-UmiL32R+chKu}{$W-#SCtpQbBG8`l3un6 zy4x>8O#gk*F%*X~Zb0xyQ+Ww4p?($Om_6VALp1Jk!M4G)Iv`PTRkI3fh^$%~^&O_- zugls5MtLaO#R>$~!*QiP$^KXW(&X1Kxzee2CYd?vT=ue`AEmXpQ3egfEY0E~mj>){ zRT2knSH{hLzHi8V%_ceLF_0E{#zw1wI!L^~qpr4dU-$ki!Z&f1m-Y=cSHbr_B!~lt zD3tW~hw2Us_#h~~Q>_QHty!}tMi%#@>hjs~GVasziyT{i8aeVuFZxuN+||4Ij=s+0 zw8sx|H*qbSRb0V#DS}B=B|fB-Bnx-2r|BRF7ezyZO6#;r3xq2rmyk%WYq4j6}Y{&oO~vi+-v?FQ&>AQWQK zvhS?LYjV!TIg!<81D)V>7XWE5cvC%^DE`Q;r)oXMEEsT8D-MU$Sd5}yJ(d`bV6Ieq zqCub1^xpR@kx!yrI(Qpq-1!4EEqOb1B_b{Elv(breElu_q+x1D{(gw%lu%-!s2+`= z=G%?{WiB@7hpQ$gEnsSZOfF)js?_4sS37MS8NyR|iYe%mBkGS~ewN@J}f1!?Ui1q^6pSG9t^JIa1aPE+`}Z zJp*IwZ>Kjfx&D}ddjYfi&5{kFHjO`IgR=tyeiAL9qfk6Kx0O&X37R?l{ z{ZhFIn-IWp@DQj(_P768g#Llx3$L<*YfRVD5@BM7c`r2C)h7gOJZOG@p{)7}i+5K# z_2^#QUm2L~oMuosF1eO*{ePi@F;jx%33=8)MN}Ar$#wHv*Bf-O?s?UE8 zH45r5fUoa~Q4rIU(E|$$cX-F;?Gv8Hf{dvLXo4wDv#` z4(-ZT*_1LKnlGLjDr`~^Ytgd~iA#IV&)ptKHy(u02t_}AivjFuj5r!@vCU+c^^t6= zr+HU1uY(%wmOiPRus4Q==5)Q2$>@3&+l)PgEr=u3AdJ<1oTasU3iG?fVG z+~1n&uM%@WQLAy`p$!Y80Yxa31Lt9GGhNpwsDM_WD5CH{-{WM4Fxj~R)*L9haOA$S zA>f-dddX*FX8)K*OW3j>n*0+69)9d&4QPBobdvW~RM{|w5sR+%!y(|gQb*Fd0+u@& z`J33NIAqYV>*Khk0_Z;uV*u@aqj{Gyp*c2(5~u6Z z@sv-V^eiAc3?z+QtuEmh&zMBg3Tnf->UWvgX6;h+mM-SkxMlm^c6CM1Gucugwt1x; zjaprJn5@72iO8-^8vWv78{~DI(C8|k)Y=6=+&Eg@Y-)$I6d{hew{pD{1X0XlO5}Dt zCZYL;?T)*eurz})bJKF0yX5m2CiLXi?;~*48djNV4-AcP0pus^g-r&f=jORsh}uDR z8L#8q5<|x^OT>o)4U69E{RA442~0|!;xw=R2c;y2yP0?T0(|nJ7gWe&-}g+mSa098 zX=}{~^!;6z^c^j=nc9zQP)tFCHf(NNP7N42#q_IzFQ_)&3Yb(+k_^xLXyBlmGv*~U ze9=oVccZh2Po0d$lyDRX4(4o0db4Y(>D!~ihl+ITP`~Q?hzY)EGI$;IOEo4wtqmh` zGI}thQq{jQ&5N~+8AmRb_ac@G!QImhHtTp#k;bBH2tl08EnwsWf?uw>$k}neI_*L9 z1unr9q+scl%4<4T+6ACxNgm}L?c`b z`H^i0j<0LtT}&2m0^U_bZ#!l`0`$9Ew!lz5etGPsB&RmhuK=P|+ogU!VeB(D$w6-| z2=6}eQXC28wLm4ne9s!F!LJt4%xM*{-AaFqQZWVP1ZMDyDmYXb(*P8;RV|pnRqq#k zyR;40M2#<3rzwKYA+$u}zS9+Y`>+L?pOBPKVy|L(v3ZhWG8xi`p<`gbj#z)+uGdD~ zj?vaYj_qO$JJwBqLoG@<`k)^|$aUmv!YruaP-i38J-I>@M*QS9>i)!!As>}K#NuE+ zz`r|)mE0ca!&9!n7HWP%Q9X*iiRQ)TNr=d2N*;xcP0Y4`Hb_0fTn-6|tRB}c7oG7{ z0A=k9s}opbeOjh>*cl(YlyZhidABV)G^Py+R3%YGOVJ%-%h(DvY9fcMK-FD+iZ}nhMK>f1tX=AlkDo0;8>|j8pqKbecWFDT zHJ{U1r=VG^bo%Jk7-679pNl$L*xg)mk2cZH|EaA;#+2$M#AE2!wzxpf+NX$XskbOs zWLkz?iZhmf)MjvA${5ZuX*^QDNo!=pg4IWha+EfL000Rv1$SMd-@q|Jn&yV%TbG zWBR~F)BzJhD#Kk9YP=(>3=mHCB_6L5mDIG)j?jc$YFI^5$T$OzTq&i)#<0OF#Gd66 zs_TbB$EAj`Li(DEwMBZQ_MWeAREuERAWe#$0y9wxYQ8?LnafT}E`4YHiR2sXq(fuN z4}`VJ0o}l9g8~jj38#F_BcvAxI-mdm0XKej zosPq0MqJo*)l87U@7#DN1GrrXtpvvrJKkG&Agb?c92W}u57ctys&0y zJh2sxX4KbKIV-{5c5Xt1;cr$2d;f7HBMQQZp(bqJ;O<_A zRQ3amIS*{&{jkW1G;O=WuozgZoB$FSW+l20)J2L$ zy=kIs---D^FYY6+%pn~NT^?zghx#Yeg(lENbrrc4XmJ&k+!3M>DjYjw_|7mSEWGv6J@==Zc(o>JM)pynI=AaP zPN_J>a29iY!W{)IF%3`ZHhMaC@m0IBA458vjb}5T9Zu*`QIDb>^<=5-{xCG>v)(KD|_WyV{TE93fyHDZBWy&SGskEJp=|e zlyD1&u@^krpuH=sltT)J^AqksQk20vc&pE8{%QzAf?;ZDw*#$P5_FgNv|mNdoTKaU zmQLI&c{Wmb#Z@aw6B;nicz})CZW-!=QHKl#KsJvny5m+j(GBzrdW(^=zvS6mJLqrf zS5jOxLWrnB_=y?9wtlY2!Jzh=F6!-||9wyBAtCv@ThBVry!q5+g+1zHa`GJXm=ZCk zBP6%c?3^s2(O+1;_99CMF1~7E%{P&AQrS}uwsxikT#)Emb(b$LlTFrMLyCb12Hkl` zD9}g3TtLx}eJ^ZggD>Df8NKD3*ILZ@YbFx@PcA%TaF!EkH%_EB;>a3eZ9kim?MC7c zo%bezpWdp@7U=9L`;{QIhCQSL?q5Z^ai~D3xqJ7KA5sMhShWf!kNv?UGtgbXP|RA z8k;uoRrdP`DN{n?r^2+H>!A#|jU|S8R%WM^-@+EloRwbd7wDORcgOok;A}S3V!iUFRg= zSxP>_?;;uK4uvYr{IQSdVL&=Bt%w}NKp-EPWoii&Kx_cST8Urp-Y)W@J!pfj-~P=j z!Gy=QaUX%$&Y>CN;z3K+BHh$DKR5I(dtBNrMj5`2X>Tky9hJ5lB8(V@+JYJJGI#|c zIO?&AxG6+F(Z;1Oin)5_O;@n>*Pts2J+tw3h;iYe>&>~6XtNu*!=}G!f?QfGkR>C# z|0wF1VG_S65k?`$p^#sBU!(SdS?|!<$dtK!??l=mBSsgk8O86^QLE`F7qL4hO#dv> z_5vaI7ot;=ZqfgtA`kM^D#iIKm-bbp&!MP*yw_vpabMDETC11~Nb#fw`C%Tt9UJ8C z3jyAy=Yt7ZG)8nOzcq0HzyYKzRf^!;)KdahpiHVo!@Fv8-vCXVaN-|ca7SCArf7Z+ zTLvFKlh+S1*-x`?y!Ey$n#okSj`!1w6@9E)5Q7W`j_1vrBaihKA1kDbztwa_E{6nh2Eef`f2h#wX$38%=)<$%)m84t zfM~wC1P_E2W{;%;edM}mrt~|{8~N&wXK6xNalEHKO0Pvd)S@DeZf`MSc17Ob^Ni_D zu3IrmCF<;L@WiD(WT*9gtVkgBO28Jg=dxzRv+N^Fp&&5oAA5bxa<4x({b46$b14@TO)=RdCV~}>mMv5Q8k73;KoM8hjRG^nB*Dw)E zo1d$TnGWF&#`ar+Ezc^R>*(`*-Mqk#igG0`4HUQ&^++Gx171%WvG8aH8)}(Q2C<;o zcwxM2fkiL=m`K^(u>|>z1R>AQ^&n^ER)Wb1$=IXMRv8PIP@=9#1?bnl$%xuFY4_T| zhY|`9(W$KUWWEb>Gu|5gNYCP`p(*t@_iqi&_M5hYYl%|{DHN2UtjjKo>O7zdv!upD znqWhh;rp3qj>q#3Sp3;%j2iDNHfvzER_6^hKZ+-v^wqqFb{sCc4)!z~Rt+dp6d|zwY@#$9lQQoYa_v0!ghcAnkO2Qb z#AIC==Z!VK8KSDL>gNJ&3|z!umQ9#hWd(vDGw+?D=nI>u_R1k4qO$5bx|>Cqi_S=Y z_jNPn_}L%#5Zy@69FX~dX0~8oqx{M=iOI_kDcD>0*Zd>?Hx1xr@!A-{^(O)}?d0OA z6-8MEm}Susa@YkDVAKS*o6?zw6e|2;ol|0|kNGIW+3|e9!~*1fFC23gcD>nbcCUm@&-Vp9RNsZ;*Hti~R{o=gXDMpvC!ZsE?|R$5Ps8;Q^|1Y> zGTsy-UtP<<#xXa^=yl4M%3Ay1qIa;gF)96>1m62C^|}PpARU;#^EyL8o?v8J^g=yO zy;U2F^B(IX2J&#iJby415nk13+J3m~9k_lPi?<~JU0wuEb*V$|04g!55We&XR-`3v z7$}vMKM0_kyqN;V#DdRWOgUurEECNFsPxg7#q*o)_S|w~pjm`01H_18$aRLu1a=H> z(|^JMCRhy@@YBE@;+~p!d>Ld3i7lXT)s=6d-yi8cLTv_V9u!Tt!w&8Pl}znAJ5KE2 zayT>h>AGpnUn zNIVm4z9O$5!zyh6_qYLYLynHocx{U*;v*2<+#6AUIZv`UI9)|7mPAv5@TeHPBjIxD?n z8Yz>KOBlu231K_&?RnVTCg(5b86srQbkVC2TmS%(wl!Zp%Ak_WY(?(7#oXn@mOfi3 zAxTO4n}Lk8m)v$w$EWS+#@W>}o@KWu`AsPD#cGDl>wtxW>D26SP5o;id!sW;A!eWu zzUKco1>z3i6O%(;wJzFwKPW6G>yCML*ze57kVJ||$&b&Rl&8woC z5w&s$SFEzBd6vF2hPDkjV{Ij)`X6uL_#cE5&i3G~c3oLA=};pBI?o#sJSIG0a85hv zRo+=2lr+fKJ-^K)?6E{`^m?YChE_O6I!22*eZL=NCq+c#2!oTHy)Z9}F` z!m|`T1*QYq#0{nkU`RVYwAuNo`)mIDs_QWW-7+{k<65I;K<1w&edQr5;$VP}#k35U_nZosJ2=tz-{$W@%(B z)A>_dkIKXJ_(@pSB}7&g8E)9J3MU%5_+ytKN*_t9>nm-=C?2VE8MBBCqY__Yx-UM{ z$l>>&v-xf0nQOT)RIu8N4H&qT*ZucZ)?x?8R)Fdyg3Ae+Hh=QWBw%xPU%ZB65Zl%% z<^Ju42Z`QK3}wZ`NXOb*-i(^X!IVvzQcGA0fE!y|l4$VzB*~=@O`7s3`E$imqrXTw zVP9_0?Bn5Chc4A6Mu2cS!~&`d{AVnSeSQHjovDdr&l5p2vloUnnc9E<`t}6iFI?|8 z;(Wi--SGUi*S?q1upb)3(0x&dtA!w^@FgpweW|m6VSyNX7v>*3f{a^`X8fqgW{3;} zPRxE_7GNtGurVmQU9Zk0ku$MDjF9Cz`p;{Iu3GPc2`6~TTW=7E3{=3TIaL+l?Ju@{ScH5dD$`B}jS*|1t= zM+KTvj$upVQ)fl3^B-1C4yyX_Wk9-N#uzYfo|*e|JM=;@19_&IsROIKdaxl8{#`E| zIsI87aD=eM9dXn5l~<*Gqmw)ysPV)M*Mc@hy(?LI*2}4hU_p3Vj06D|<4gT5ul_Jg zwG>;?bRg2j`8gq*O$tOaG44(Zl)v1K6o6{FXE6C;!{RF1*>;N!0H7AD)vD=X=PlI6 zqNJuts7o{YutBku?fQpRoG6V3Q~}eVhHMLC(4az0k4MBZ;U2op*zOG1O02@PIMd_a@nmA!&_fvYlWs?C zg<#I;!wKKYNEf%sM)0wtM!ylBWw8C&T8qopVHEX!5YZGf%Z%M$nC_`5=*1UeO<)Sz zZ^FUIUlbQn%hP==f(@wI;ekdsgVF1WnRp7TeR7L8yrk=Lb#PV@o;+^hiD$M-<-pxl zE{u&)P@VDXwCI+YQmSz~W%p$h0QSBva1LC0BRmUAqEwZ3L1?=gSKPQB)+?&ML z2dBqU2n;|Qe1%dDFjuLZ7V?ToErl=^ujQEwtzi@xZ)QcQM0=Zh959I)E;edDCqQm7 z6bA|PP%UGB0!n>oiFEIO!@c>UBl#|aD@Q@+Z{M4-%w4`bB0=IlRyF>bQPdL@hd%uf zC+uI-s202^N9U2KDLX9q)L*s;gPyvw1&vm}yev*wio{o-Y`TxurY`0ZC;q+H7w!JU zz{J3=4dZX|g$&?9sfBw9|sB^QUSYIV#Oc zR95Lk5ber9%>2Sr^3|5Ep1)^gKG!1qU-#1j(Y5Qa|EZPV&0m_~ZcRp0;u}7pccs)+ z;efC9O&`Ki19j*>p@9R#{|^{S+(+mAYp1o&>&|g+u)<{QAPWuvKJS9Q3bwxSyA3rI*-}0`ql<@U=8ex z#7uNjzE|r~O2_ktUjW+J+dhwGI7zw{<{F`uaNJAtnP4XR2)P*&$o`Xr-W|GauS$|a zdEXM>?ZT%AZtT*%l`+r{a;0TF$*tIJfI8qqdyOAAa;2Kx@SH&~K|SrScW# z+IQYWHDj9d3#A%P`L&#ZOL}#Y>W%)6M#vw~k>M(YwV*rd$T2Vc#aM$C9BeXyXBE0f z9ex*Kt)AtjQ)acGYmRiDci;tCNN@mh13{6nMtk3oALvx~iINR{y_8e&y{-p2SP-O= z62sUVQl&`WiK|?0g{4~`$7U&fQcwq3*X2Sfcn4FC+wM{-0R>ZjftZ~ z0ocfLp!*nWW?JQ{>nV-9&X|(krZYeHsu18RwU}rzf;p-W)E?L=R7WGSv}s@U-(_6P zsH3*QI#w0iE81o|j?&|sP(vW{b=ZaBJLD8ym!k#J{XR6(WUvjyHn^?qH}45>ftaTl zRM#7AP;;sBZw(gzl^>M?5na*r82XfL>+VnVYn)ZJT!NZA4l*3q`kq4@am5wMf`whKnbR*j0>g1ZZnV>;axE zcmq`9+HGKrghu?RCma?#oqV8o5G0`f(DNtVs(J(Nv;yd0BF`MOri`Y{K61a0=z%GW z=>v_q+CC<1i$%To^%zk}&MhL(^gSn;P*!`=U^Amj59SMWpm5A$D>k6?fE^kQSoR+l z%+s_0{f+w&f9z+E0io}EhPT*vAJY+x%V%CH$UK2AfFz)wdajzHsy}$HiN3!xib(+| zZwlJI)s*ToEn|RUb!^uA4&(Y_F_~=Z#aRc6umf9xeb7y#SI|swoF~y4xK)%%{hZ(} z7+{H3XZIXIWYDR=`ERsAyI(;MxQXQ%lCl^^eGEK;L^$x6Gipo5s21;C2$~_Y3;p%$ zQl=XJ&O{dgUJR8~1maaTC4nY?>FIX%F9F(?yObWuCLiV3+MS%NsTxE$1lx>il7Dji z9{ia)DNC#K$uzMn5q$%+ z;~F?YaR9;FjXb-`5hLy0zBc1=5TrFI{Ss?7-UxZ6K81yfpoV)HcC+=X&*{o5yqT)*%@*T!tGcz+?G?TYZH;7j`op%}}?4 zwy5?xNs8HaQ3+eV^hJ|5`|U^xo)iOn`Cpl*x}dII=4j`Js4s{dBMHJws!IP9$7fs# z3|8rAqGB=jfkIcAa|$G!p=e|Y4g}@jBTh)86=u(GO@VYw&B=zu?Nmxz#zPyQRSbl# z+B+X0dg%vT?)R5Kmavd89D=BJ5Go^n3afOwh5u5hw=-9&`TP|21WeJMc=)G5YA*=| zk1Nd4z3y$OMI-1AbWSz4kOuH0LL{&e;DEu|8u0BIuZU4PM^Kz_aHc3#`1FN7tU>V) zFOdyk&CL2lO#RQwfCvE_Ha-rckQtanD93eg;J}-DaJ1@q0N`Ycxz@}&TrW&b8gvgx zJX}Qo0rQNTn}At=robLR0i+K^A?y@9QTn8$dvfP=3Ynbh>DANe;26!c00000iv6sn zWC%bE$j&bg_#d2E1)VeiSza_&Aa8o}*x*9@m57`AYk}gkyLw3vfB*mh!7%+{yKHy} zjvOdF0tf*{ivX1iXI&cnCfB;<;CAaM4S!RK2|36${N+NsKI1?D00EZzPgOVfAKQ(i zVBIleNinCR&^qXA_m~3F-7gxp)VS^(T=c;*qigGcVwFR00000006c*JMP8X|J%C8x zQh>Qxe787ec%w!wQLc~;2hC+Jr$)u!7%(Bc4n8}BQp-w1|2wm3z*OWETQ1mXP_JZi zEjj2bdfbSMPlAO?7mhW&Mqg2!#FItM(G}s2tPCx4RmfM?20d(viv1pu!8veG6-Ry< zJ)Ku9*~o}JhqvaD+Z#J+zDKuJIU>=G3H5nr1&u>+cj+CYXiP}V<(g}3eza;Sn%4k+&3A`YOlKuPnF zUfT&$l9r|my%%sVDppr*k9h_fzd#2u%!_316~LD$_^^Vzg2 zjPL}i>>5y8ine5?dwC@=@&1!6RiJA!uOJN`TMo5vn9g8eGPe#~L&Z(`F!`;0!uF$<7Nl2sKW6#8)L{d|acfs`O!+LGeylFN zIxO0R1-K{s6)`mVmg>BJDG<|LoRq5NfDZsKc4zG1uJGj+WurRlEa_X&u?h!|RRd0U zykpSv&i}(#een_R+cgOE>BbX&C5>eEa3BxamIQBU8Vm}!Fe6gQcM15VERIB2%$8SC z4ZsLv&Z+O`1EX^dN41&%B#g%NHlY_gv@7Zu4<9D+R(x(q+6d9`g;QnI$Tj0UI7V%- zyz~x|RKF)&^jg2vhJMR&?Ut|W3PYlNK3}@8YC)S~uu@;mH+XK0hey^ycBr`;d#-iM z;T~pFKH(I(Z$+$l$nZF?3KFx@lPoPlCx3V9b4*eSCaZ+@!ptGfqqKW>i!f*TCnF8| zKjnPRivjY6O6%&dwfl}n9xygG@+$s>GZ&)MpF|81G-RHd{kn{xr?eZ+V5tQJoYzVsx=hjF@LW(P{0TBGom1uj z`MMb1jDREAUOPPGo(WI{EUB(`s#I`~f^{y#bPuPb3DfWAaW(!W(~*VSmf&&|^n36* zSI;-Ubvg5z>6gHO&Oh(@bFw}L%bR7*6o6Eqd){BY`5y8ikJ&SjtqB~4CuYs_I51S4 z376(>LD61TyKFrsvY*;6fPsFweOtzlNvj8nSN}eR4c3i211I@4#AAH&v#3lS+F$?x zAc)8nB9#cyy13=2RPW${jXP_*KtiIJD^cMT{z)-mT$uTGIdcNr-w-I|OszBz#|<51 z{TQm#3_1BJQMlnyPja6sc;G?Cq zefCGS_$<=Gzc2@(JkX;M!AE-Z4T<~I`;a1+4%TwLDUB`~5j3+60J*1W6k&%BabzLA z%HgRSMld||C>;XOi|v2Nwu$QSrnkt@+3>ByN@A~EYAad_c}WIYK1=@MY6IgK#L@o2 z@sflLa%ktfsBI@;2(-0!=UJoYccWoG*5Y_Qd^70nL@76{FmX$FUkJRT;Vw`e(jnPu z%55g2%;RPlBt-K3I_QQa;lJ_cCzE8S_vpf(A%)0py8rJ}QA>pQ2JXfn!HoQhwoAG_ z2>!^wTu~wAA_YQtOMghuwhj_lx?f+ZB>@WCl?Xrx=oF!+4!ajD-9 z1%uf9VLM3z=PP3Iug|tcZ?wv2%yZ>0jEJVS4P|aRkzYdSqSK^8S2&{ADKn~J@bmb5 zgbEJn%$0WGD2~3nqPORzl9kc-1*jFLbImAgyoM(pee3zgVbdF!_uvM^UolvRph~lWz!V!uPZt-us-GGp3oYvCg7gvTh(>zY*Ojo3|$jM@tM=#51PTs3EE#|8v z=)V-y`Y1X99x;juju~fsP#C#iMeH+#BW4J##Cfl=;u{7{*SYB1#L-T*HCJ9>1peZg zd@A{#OW9wAWj|l#PH#7QRn6*Nf#90E3EA;MvF{VgR^JGXold}7Dkb7@#!T|;9xEYt z_$}tqA*KA!(Hco%zT_no1S##S0GOwd#D1xytdO6{AY2fJoxqjxB;5P2*PtgP`uP&$ zlvM_y`BNI5JjFVWj=Gb@AAHFXE~NV0#lBm0VYJ{OpPl%K7}}_2ZK-l}8aFU+(NMxc zTidt}KW62krieQ@+XfS!qgvot|K5vDanTFcta`)+%*S$7*zq7Jz4UyNW2G>Mth2aB zX9@112t|p65}Sx0(2LZ6m-h?);j!)v;HJrE`sg@ZEr|j&2K6vZ!zIEPz6PTIF%+xu z$f~;jU%H2kjO-X$d70i`f8m#MdGLpP_k-PF=UQYKWzAV|v6dUMK!5-M5s`N<{Jr*+ zeLat9;VZ_q%VIXJDGH3qQRhL$o!IrGI;A%z{ES4)u+h5n!ffqhvo{`gXUDGKkN3P} zx24?aIv*z|mA+*~70HTZjkR3rEIQP^Mp^6*=LzXG3UAQ&==e&N}!d5^}e8S!?8iQ?#8~ z9fHYeQu5aeON#*);7eTgvuC)bd+b+Cqf2Jk8|T3n9ir zas|j?)^3_-m7V33O zbghl2M5i1QpMFgc5efZOPA4(Hz^9qq^MVM;e(MsPz&EMks(J`JOy901Bt4)Gmjk3I zTF`D^Q3AU9BH|jrjdCiTT#3j1gqSbt-4aIRB(mhK{8<1Oek`t0GU9F*eA;nhz&ODt zSA6V(KC|9kCPQSPq)uxE5R(>S(F@I#LdsN_fuLYmp};kitR{9X3y1X@XA8DObi0z$ z<8&a4sF*^ZQztrnswjCOTv!ZcX)EH)nuRFd(WC$X01?^;H_$jZIt|D$Nij4k-K@iK zxf*oVzlY`>?JCC>7u}McYdu#Pc0{q+k1N*Y&Omo&%KK3%4_~gp5M|FvK+h-|mfVZ+ zRgm>ACo*b=VgWun92DdCH0%HXU^2Spq5$O#^R1EIq2dU$66_`Cq|n7r2r&Lip-D9& zRs)*^4aGr4j>Tq3C|t(rXe0tueQztXZNriq@{I3v15M_BWg>w6!xLN~XlHL@wK9v0 zP?#8|ez!Pinz?6L_6Q$3h&|6e*=>Dp?mTXfsfxLD;+SB+^qI_)m<1S8&74hdFxpJJ$C5E0oK z@PR8{l*GO2Mb_4x-NPdD7tSx<+^`H3Ull^~z&pckMfuL&=fm{D=;ynn-A%*Q4f41l zTJ+BE%e%tRoNSML>UwoR0wU$6G@nkXB#y2tvXzvyr;ss(-0$TExCRqLXEZHRrc?bj zcvI9B;2B^Fut}V_NF;7>(n8OdWAraLdRtz5Xub8;q-*tP;D4G+L);a^0dCE_cpq9; zU{s+1r*PQuC3}e`4_f6wwSd8@&QsP}CU-hrt6wJnF z15C7E)PoHjx$pHl{o#5?_d!J0yA{xL9AWA$yqXDPal>s?^yR&Ol_n;85zQ*|WkE`7 zRm;lZ>g6Bv2(z4Dd?za64#?)8AzK$H;-Q|=aNQzrRzpkMN?9qfv zG_MoDL$9>}D4;0(r)Op}_ru=2&qF?>dV4;&Xo2nJJ*4(D zMhjw;2*>@lTHmksm}WkIP`N~T#3*jaI2cpn%4K(}FzAS=+&eqEnxU8g?` zo{eNohUlD8(-}=Lh(>K6{g+Kt-W#Rr_4Tv#+ploQhISumqC&F2-mmr$(9UwP|1y%< zp&Lg*c%XSVS4k4YqdrGOV3LkSIWKhS#=j+qP}nwr#z& zZQI^`YumPMGp|yqR1V24=b4(mOjmXP)ARlOSb=))TxWuT~kz=XRD=*IjczZup9y9HS!o8YQBftwF4;qo=Z-vV30X4sW z7X<&oZ*5{QscOrzvHS3lZN(rCz$~{IgG*M@<**6FplyLN340`}Gmu#4h>QD+KaBzQ zUZ+<9-*Z2DE*jEmnIU-{JaI^G8d{mZuAC~EwpNQtQWq$K2SxA$Y6c^O)NNBugCAIQ z#8(gOf5WrSDm(J!(Yh{rSk~r9nRyld+1s@ZujNg2(iiFQCoK@=r2b8Qw-8up41Tpt$Yd5 zJ1wt`@qeT5Xda5BaeAp?9wbihFHE2Pcbi7p`v6c1=%nN333Co{M9FQXTt8n=(0p56 zk0sc%0P~+tj&4Ugm82|cSnF-P2F;9jB;))M8ZiUa(Pt-Qp=(~kz)&WEq?xnL z$9*Tn&G~?4x^AgzXf`Jn*1g?!05O0c*c)?S)xx*P*c;g2;I1}`;;6rZ0P)*=n%xGN zlyhezKxOtFw#(eS)RlEh%Ix2>0b%|INOMng+HCRVGgQ0Uzf+hT-^{nTg zX#_U)%u53A%RY3;#b#+@BJ2G8_CUIfIK=YfEhP3_8Rk!h8v`UJ=gFw_sSiHS$X{-C@ ztA1R0#3cnvki(CrDDBkhoKOxl*;xly)FtK_Ll*e*#9Ij=uLS5I*O*4)ws7^{U|A{n zF&h;M7fGSnWtSC*k7&YRao5Zjg+>gKcoAOe^`C)tpPwRo;~^G099K85I6$4GZYnXo z>)gpk-8NSl>YaFLjvWXa!mAdYpKiq+`2|_Mopql}Gc%U=fTbMwf_1}U&pTj{0a1UB zk=MeoE)7tEGZ3Z_K9&>0@zf&bP17=yf2lltv zVfi6%egL_3f^Bcge>h;{wIvLy-`$& zOkTYhu{D?L`612qsZ2)wSycE>3P{yDouX$Q<74~X1xR31LKArD-4T~nx8}w*)>eZL zAbPolKoMH@n=k8!6|oVtO5zIM5t^jxOyY2{fsIwD<}q-eDqw-cSs6h8mW9ZZD~!jH zm4_0cqj7Z;VF>|dBj%dLKSymD2+_?Np?_Z{*#>?H6$l^KOq9k-rBz>))+f5G{z zZ!dW8SeMw`ru|ROt`uTJsK?n(f?Q^bIpV6Fw~*$17sbI(?r2G`S;FQq zvxN=W3)LLU5POgEmu9$<*{D`waFD~gS0y^ymq+qa z`6MO|bLLcF0yZZQOLz-s@nv`aXzp}?he~}od7&!W;FhYf@pCxAxp*K{k=+(~5hVGG zZ-~Kh^P6vp%X`j9i|YWfBL}H!Av3xzL+|uAEu!Y4Vq4$F$?F&5)fK|x5#xSXzSCDi zUsG1J(rfE&QsN%qlU>Z!wOu7!L6|XRWjH3WurC}bSQ$p|i*A~2ndwO#WJH2ZS zxX3$X$aJ^G(PuuS1HZyFuAQnl2caWhzF2^~}k~z}{Qm-X=Gav(rg|Rbii{;jlu*D0m+)nM&u2ZbtID zeze+4001b?NQXW-i^H(aNPsf2c&uaQD3vaiYU9-w4NPZ=DNNkUjUZ^lvfdyVAwzC| zg;ivdh3)2>>eBZ?GR+CI>iPvVS4D$ADV_kfh3?H!Y_I+?K31+@zB=2IKrk;oNe!^9 zYBf{sSU(m?{2Ha=pW^Cle>N>Sp2Ajg_pIcxd3ZDQm}TzKAd^sL>ND+Y4a59Db(`h2z-ywmNfDenaF9+t0Ejz0Q4#BiW;4^QPx6ewj=#CshJ3yHT%xWui zeK!cADDcb;H2>K|1l{pv|1BY6rD$Kn$^(gu?>6Afabo|`((X`zvWWOVK-N}PMFAYS zpabe4MyNl&3itv(14FI(!@U6&?zYoJnIdAN1p?h)AM7FW9sW&unoD3tT$zE3kI(JC0VEEhx*kwlW_m_#iVw_x+F^xWT!YwTghU_ zWUo^+3zqE1_;CYpr%78SE}BbNeV!QCs(g$eSm}edwVTE#gy+wX(7tp2;BBx#$sMBw z@rX0|1)VtSyi#0uJ1mzkm6R^D9Q&p5VT5iwK z`nwa-0u|rj(kSoRQCi~@0s)KWcTc^FW0PnXpc~w^thXgeq)xdQF;+j>pRH)O^6cqz z+LLGCfbN`Gz6R20C>*qp@08Ip--P6EVEVn=QV+N?EI4}jB%NtV#cr8! z?a80Og4~X2S8KM^M}yJ;$}DPU)pg;(f9?DokcQV;I`@}(XZTlclM^Lom9-;PGJv`5 zRI)aXI;}`PCeT`!fQejHX}sFK*=Qw*^cvy8-jqrc=p7y72hh>2X9O|tGRFfCAzIO9vhXMr#KUi#A4@5Z*)VjV)gJFsX7XDi&HcW_n_ak23OBWx4%HSL-r~ik_18G>y5_) z7U7te0UQTk*46Q+N>(<@rDjT5krI|so&I9-5RgX4$V1;uE`?#Bt+k73C*bXs(t6Qn z&L-3s@|iSdH(bslOp;xVAKHX0=0hkb$5|%vhc$3=WdkFEog_P~|IY9pOgfpJt%KhnJdnnD;i~2|gl_02u0(Ct+}dj7HVay^eL*{2>65 zDb+VEs+c=&-d3jN&{3)v7m$$c=&%Bx-V;vW(UnL1JpdBMS6Gy7E@~rM(O)L$Vn605;&iH1jd3i6IBzQ?qT8V=I>K71{jgz5>Q|X zuqy8Y#>)!JRPa!VAJi?{%Dand;pL2npZj7Oj83e;=R6RU+BJwx%!BejtRa>-3$=SB zd1RN+Le**tyL-{vAUIgI~EAT;+{xOCTg%Af5H$X{%l| z_p$dvZ@JvShIvq4b>^rn=Sv*;rY=r@dDBl0`k(aQGiU&wx`(Vu(%73w9SrbE)y#d9T3?VE%#ML0C zM7$#3U;4BmxPCVB6w({=Qh??i1)QtyzmnR~roHilxfi&UK(p$Otx0`9WiGVWHT9fE zfQu0toZxL1kk1BY4BB{1f5QQnZOS;%^gn*_eWdaLl&#VEx~>Wu=$kY4jQ&Q7@J(*f zTixMS_ASzK?q}+qfq;SEA|YMmAo!G4mYB(yU>sehvc}h&C8wl zgaV5>96cYlzR&i~TC>(P^AE@z_)HAmi7K)t!xMzPjt9k>uAJnRPU-%y274;x2HUvfqgCuO$816l;0q(DsrHf3+04z zqzYZi+Qfs?n>mPAWP9A@<9)dYL1&hm3G=__gD)z)3)ya%5>BhkH4caj>|U(DlYnZd z3jo=Z){5j0#JUJ|z$2!ch}G_iw17BY?zjhkSiOD(lya@yjC~n1e0=Ohybp1k->%P8 z%z1lP6hIDK6zH|m69L?i@T1p3rknkVXmO4j%pfRMnv)nPJU34Jg?gcP_nRP&Fksty zz@f0T{tK0y^yxgtEaZqw&);VcBT0XFwsA^UU>M^LfOqjxptFo-cT3%mAr9olARug?YHyp3 zR}`;jg8D97RqIT2iT+b}4%iS4m>5BHt_`Fm~BDIwZ#z!VoAiQ3fTJFx$NaXh~F8%7q+|`l~)?krT zQ&Co2eTL4{A_f<+Ct5^*Rt#Znp&7*Bo>w#uJW$VZw){BV&SyY){qzo;Ci8o4xmvP6 zG!|8IxI9Rn#EUEVO2nSX-h4b^vIe9ZZI9TvnN&~CQAJu{+||it4uB2Pyg5+KIP28f zy0Y&mo)-JSKqQFi2*b~DK!P8Fq1K)zRJvZ&$RBNFH2f!Sh&w-RUi~AYOd3Ez;HOE;Lx)yo0OtHTpadI)n&$HJCV0gcp5XsvL zUSSW3s&9@@RT&f3m&%tro%(ao7~@`;|Am8U=`nZXM~A;7OS|KEZ>2Qg>5R&de9RuM zNFu7!hEEd^g~*Hb(2ef@lU5!dEz}8`P(JIk>)5CuIl- z!6vmVbyt5PU_r(tL{8%ZCn_8s{;XqAv3|zjtfsd7dQ@>Q`Tf?inF|>|D32ud%I($@=#1XV6R9MU$c^GNqV-=vKhFCi(8}*2mcm4oi%i0VD_T^#SDti} zYhGK0%)|NX^XO5Gjo!kNp?kh0r$STEumK1zHe}7DWt(s z#8S_YInuND3Ccm~A2|BXq9%|^@q!S6$|*&?Mr`HI(0X}MNSr!Vyu z(JUnqFX6H%0k&~zI`HsGp#V3z)P{T(1$%ZUV#QI2JL85V*eO4Fs++P6o-c>4ouWfb zo?pHlb~F)_eQXl>13J#G4%lLRp`dxbm1ZyAZiudIk697Up#tx62!xkSQjlcm-01_3 zb2aqQ?O3ovoq5yvfB=bog6EO}Spi>j057qyrkk7c_T(eIK;XNV_S^UHt#fviVTLuY z(XinQGGL%p#y;n%UMxLf?dfAKt?roW9+r!Wb$)C}l>7P0G_WJ<^PtQ2`<0 zQlI`-6cWL@!z#B_5#tUMxE5;>KhyEJT%m+Z<>*>n$7FU@zptIPdk|1_h~BOL2l1O5 zQXe48o&wH1m6s4xTx{TPn{znAU0eq3Ulh86|CYz#e$gC?MUmi=JkRiMFPs9dbv^!C%?#sQRgFIJQZKrUj?Fv8oJCsyf#tIr;ZLVDN8q$ zeiXbi@qzOS$t0y<^Ct1doo{1y6JlH+UtCD)eo%vTKa|@1cm~1BhKAqHd58THK+9Iq)Zymflwa^fJ*M+P z0#j$tX|Fzwc}6EJU_-}xn2+nAM1YGDEGK_LD0va3*W0lwe$6Yag2LPM3tgEh&$@j{ z_nYlVo;j4JDKItoU0IWw-{vlPy10TZkupF^(2K`h?j5RVvZ{HQ@|2U7-*(MdEr9;3{gm%{Y@yo+x*dLR@~9uw5hUum4n`TZ=6%6w@WMfjhiS^%{?t34`1=2j2iiB z`yuflmfw<cynCdF+!;;z`z) zD)noh)F{2spx5D8q4Jj(ImHc|_1?ycz7m5HypG78L;mK3W z_sR;cXrbQ&-wpX3^l7NuuiaS$Ztt^loaOvDE_mX>K(y9b6e$eiRt$h|k}=`ieWQsN zrST7n&?SX?bVD`lA*i+kC&QySnF|XqqA6l#E~JVYg7^}g|=xna2Y43 zHIHu-87g?ZWUDT#=y&Bazg7lAXMT`;fGAlgoa3g<=7SzSYA3qKevkk?g5zB%e%7xU z4xuc0s55@@QF29>Bl0mOWRej%)&*9u6cW6XrOMB7e4A93;5aU5gRQ2ftlmT6z;l7}KY1?p@N3VLsl zwX*D=?D*FvSHZI*7FD(0P@npU)a$ZHy;xibt7}S(c{j*nQYs~?VhOyltMYz4flqRn ztX7J>(oFzp32KkJE0g&pDo?UKiB;?A>t+>_nzLA(@fzbzxVl7PsPa2baMz;dIn|J| z{ABOuS*3XHDA4jt79D1sb}>E0ZwbRn`s&wt5;w0+>6U)sER3!n(9(PZVW5k%8!?4A z2Q0J%1SZCQkDFTMU+%_fTxU0L$J_cISdY-gHIcGnc*IstMAKt;*S729j9iIzVljA9 z1X-v*LE%f{coDG>&7f|Rx7acOgT>$w4~!$2Z8u&O8r)5)6I6ncCKX^t(NI~8()KW< z6Z^g&QZ&5gyq-l05Pdw!jF?TFiQDG}rsuyj=Yy(o3qnaMN&x^cgYv$uk0G~KhB!CK zeo~>X#>+^8l0LEo)nU}CL2L!YNBwAAShYx~&mum(vD9HkN|4{=J?lZSuWrnZ;Vr$B&PhS^uuy&t9mRS6ivXYkO#uY+weXmi z*HkR~#^$2hjJsst)}0t%ls(y$jn`tFY8;6-(`NvZuy->!d|>o{ZZrCu3ma+z{H-!2 z{*H$cB{Y1wMe;CK0=!j0=|VD3)BId*9pnn}1(oc4MG8_31zj`A_Z>>4i)TY!Ypqs^2t7GBbf&-_9jjes08-wt>Q67ICFCrjAwY$B{y}8wuS7mRb*5tOkN%dq=O%LsOuaUm8$8poL zgb;cyP7i73q_{Ocl%2M}wGtZJ`x{YogdL^F8#aAU;mh_1Ke~WnCTe$9;QR^1xrAGN zJZ0l`#kT~Qy8iZ@9}9-}1_b5MJZoAQ-xy=N)go5uy1b-i@$lVDmKI4Fa>+>954yNGfK68exTG z8QvZd@!FPDxR+cgVrNn3c4Z!h1UWD!ei|Od(Tl@cO_4+z=wOt`?vcT3eIO&WphVD4 zNuFy&W_#O*Y?avvQw<0+fjOtZjUWQ=GTT4wE+Q#cDCvuSdIT4yXI@66+Or_8JwZDnE67d&oAM%_ zZYRHs_tB?hqZ>wGh|fz-@%3cbRLiJKidbBmk>%28nkoCGl+5>d9%}dxvNO&JG()PL z=LmCi6dU*153JG4%39(7-?bl-{Q=3Y%I@A<81^}5Hf=bj5d{< z2&3KMxmJ(uOret0l<)^c)o5aVuz zPys|X*A`+$S^Hg{*}a7HvR=j-t?4ik%aV_G*^oE!Y4oFtsi1$QX(?JEXZZkOPgo8L zE`e*s-eAu|)2Sp#QL3*BxO3oW_0Sb1<1A9~4J*}mKHqkU@Ae{nP@tL(<=6?@NK#qP z?YhHicF)4O0efZg!BZUeJ=4NUZ!T||zlO2`^rv7TqnCajYYnSV?JkrtxDRAh8n?i< zZuKEZbu;Q&-6aFJ7lYI?$46Fd}++3Q=@QY!_S%XC*xz_GGffTyPD&Ko&anN*8jo(sq zFa@JRUumPQJ!KdycA6b>16Z_>ESd=toJltFds62Y)_N)EGZ^@O{ll`POfot?<6I>= zR+YKez5mg?%oY)Sm$KXrzm;@ZL!!JXJcP|GRL}54r0u-#sZ;09!j^N9k(AJxcb zA|Y`%9vw(6AJ=|z5FAdla4H^!^cR8NpgrFVs|-zbc=ueJVJnWMFUvYKyIp^Q=A&6M zHNuYnt5+WOVDZT`ff_yalRK?5BSooq=}RS*b{os)moyCbxF#>|o9;l@jElvr3+1Xk zlr~5vF2`~PbWs3#RDyh7Hn@9%bzjDniipeDkVf)wJyNBdjHjQ_EXJ2Ta{|dfdEa~A zv*B;CiCa-#;LRovblc^xV0yd%Sw3rx-$}}bh$ZdEnyZJS?o=12-LH=U@w49*H?;$g zLfPEa9BsUllzr4^!@1{dfoOf0n;c|qnsp(l*uX~`E%7w}Ffy;Q5RHLJTV2(%DTcQ6 z&1@|efw!l;C9j*m&Rq#!gou(FomV+-qK6)C_eW%_9liULqbxL{(Y61heW~F8T2wZi zr4MAZJ~I^qnnn~sMsGQq^n*F19BWW&*vJlm!MH+@Il~!p0L^{uBbqKMafmY2Y5Q&% zqWHROwH0m}rNwa?o@S~aQ!$hykgWnnI>Us;hNA zVhJ=zi;jIsmas&W;B}B*%TTK>1GI&}-+3lu9^nUjJ{gUnD9VB!mCJ3cuL>4}Z*Br! zObt0Rmmc7@n9}{~mI>-537-klr510Ea+Mr_D4Fc~ttHR;D2hxoj(hT(v#!_OX|V9k zW^pkz>XJfzxRTz&aD+P&nYhL<$8XS`J*!!YJK<~DN>%(p{7BHnop(?}o_#s|1lLD} z1*K1%a?mm&M`q3{A+{aq#099b^fPH6Jb8t#0Vj5BL^MXKt9sl72U8h=k1XE0%ou~E z7DC%Qr-pWV_H(m4Zf-iRx^9np)uDqZYVm!*)W5p1*T3` zXUb8bT)B_uGxW!qLatff3W*|bm?3S(?C>WCKufWMmRqoR!2OZGPM$8kbJk$U0)ul@ z>J0KO0GXa4s0b%~^P6M@-K)^RD`GKmT*l!Ybl$L{i!2J7sbX3>?+Kap9=QO9!$FY^ zo}x)us2DdwO{I7XI+q~tcB5f{b{zqJ#%Az?BxFGcf*5{85V~&%|3@#=fj4`w6Ia+H zBAh^LLg)V!9dn03`3W@&4l+O+oLLNHt0X1nJR6Dp0GAiihW7H<*ro!?CyZZBn1ayu ziasmlEg_NPYXjgB(qZ(RwO|hd04)-6fLZt!XD!e<+H?${=mB_^8m>u(n$Lm!v*e7J`Y3R)+_LC_Pto4zTV@Zd>!CJ^r=+ z&$sZ>*OfRF&rS4=_CP{zjH}=VEt1b2E2j_8L=|%&?Hh@{k?OJ=SVQ(l=boiyFxoOH z_ax{ZxCEO=c@BiA&`TdH@QwD1&Lm1hNW@Bx8%~q3-f{wXVcz~iy=>F(( zKCsR7SYO*RZC=EUEBhwd8IS*MUu;y?)~8tV9#s^7kkFG@++hR)Lei}Tt|h;)G`Zyg z-!fp26;`;`Qff9vj28iAIecwp2|hB$7xK6g4#lJYXe17tqQxU4vdkj78l*aZ!9Ud= z6`9H;VOT_A&&mAB{rPdNN=`&#Kl^Lxm#_U;EO)@ZH(fm4Oj0Yk(CHA)0} zNg?(sv;<2!CPLPcU93c_Rdg~tr{hmLaalS%;l8X?x8Rx8{S-Z4k%t^MC%tTGG)0?6 zrTmNab6SJkUoC+_CG53~?oD&$r*AT^tF7RrmnK{vEi07g?4T9pkTlv>94g9tC>T+R zGKc`8MvA)YQyuPn(3ATQ(H%wa;JT1Hdv(qL>I3cBs7cNnP-a|G*nm0i_nWjU zIV>xUB|2O-R#S3;GlGzQ@Zu0?gEQ}3mRPcWGqfO%JmLy~EMfh2HzDhI96N~^tpHu? zw%NLLdD39#&|PKs&;+xyP0&=~aAEo*AL6VU%R~_=A*?_1D2XFM@S9>mG0XQ@2}Y4* z8oPbDy|O+Y@=EH~vO4Odj3|`yr6Z||>jEAVURGhK&-2?t?H7Do!wl}}_U_DY%TSzG zm%NXjv&&Syd_j5}$@A2f6Xbvc2uky38!f)nx}R6Z<&gvf@kAxc4*y1?Hg|feaj)jD z)T(2hu#NR`k3?WSW>ZV!-q=A3BHw>{c8l=N_lD6w87H7 zuhWnIP7G$36xH)D>XA59Plnev1Y1FC2t`lQkJIY&J z!h;EmMiAjAg`h0S*B$wW3br>||u(Zr#oeGprWJg0mS-#vgRIJwfN%RS;ao2h`w&;ko>zx|Daq5|0I8g z^`S?5R6YjmMR@JKZKwwmq$_Q|3&JZv&)IH}(Y0!M&nFLdm^;X>s6_NAN-|`&*0D#@H#nAcP1MA=eS4}W%cCot_ zF#@3o_pP_%@O+&xEwzMarEBZzqv0h%a~fDcx(J8to~1-*$`*dOxFmnwLA4Z7Ew_IEQN1)bz1@@F~*fRos) zLNqFhIw$lj>u#YO0!QQcTO-6Q)UY)5x`BdLz(oNxZjEXOJg?vnnEHeHlK>b z`T9bJn(o=#o6ry0t25X$%=w2s4#9ktVROMQ!q|1e0x_3yI_x=3ymyaYVozA6axPXB zpBxssE>bb9`>OkMN}+Joo8$_&Dth0#gsv66!d5WQZHwG)U_~#Szy@W|Dz;tWC@qA) z$Ja$&JSMwPTq}3r652k`yjs>u!*z;hYNIH$Mujx2_o2W;vR(HcyH0_|zup;cusajH ze7%TJvV=-`DK41|?eMY0-8RO(mB&Kjdm}iIt;zv(n)Xg61_y0Bjkt-Zve!E|2BnK! zRntgKi=Yu#x56xk63zR2pmk3M)r@hS z+D=C(uSq@uj@E=Ids=^&1h7AQ@Zi3+_(SePXLLTZc16^eE!+ z8|!o7z6j=wB(cz8cb}DU3>#x=8GT#rgq~Wk?b)|v7*I6wkgd&F zD|~7S06<*ex_1Lub5S=}DB~3FEiIx19^o|1POVC^yq>?pfHpmH3kU7InV;ZSRC~#% zYT+-4XFz3TK6BK|YQix^?iR!1v5=4-YZgEDWH#?cx56OSRmjDX0Nz2zzPssE;6GJZ z8xIg?y4k2;=T79 zT@wx2tOBtoE|cn#+GuqnBoZ~1sJvCL)wdywM0ZRHKOTgL?eh4GOciDlz(4xrxhdQ2 z?ZM~Ybn5W*;1f5&#G|{!MWuTjJc`>RfFUCKKx|O`Qk`+1KRb#5JEzeziJThvaU6+$ zLcTase?R3WKc`Gcc35P+VYCMe0&W`GJ2csoe}Vt=*F!D&qVZ4iHCFrbPH$4Yw$ez< z(eDu-ozFowX!&2L>_}i9VC^%kLF9V)h2kFJsh=f*N~U6b2b_8?q4qr;fbr3S<=vql za}7Cww&mj0ehTfwzUUPKs6o7uqV}6fCiM%o4o65e9x{J`-Sczmf4n&WP}WM&fS74k{VtZTSY6a$k^ zFJqaX2(=XU$k?7j*&sBvR*YE$g2eQf@l~vp967cLce5efR^?7P zuwBg|f}YOmppAW!Ny7RR%%AW*N)yXh zB%V4y{jZ|;ly5x63}_J(8_kt!DZb2&`CLdY3&1MJ zLimaJDBOlUbzuw0BIM~J`eYSFxTwSKHe_L9$KJenl&R4IY=I`A#y5)&?wGGFK3eb^ z9dhrGR!ZhcU6rd)W*)Wy@wEL#;xU6+ip^RA-8Gb{h_$b0(`zIt<0Qu!4CMXu887T++!k*A>&JW zxe2ZBcPJ+4GKe?4fi+_X+A`D-6f4dJwT#80(_BU@yWdN7te$yLfa`&pvbtjyzqtb_ zYb+Rk;l2y+%4z9x9FwuB%PNNkMy!h60n0f(a&><;e*l?w>C9l0&pph>6pmgp1`@jg zHBiHZ`H%AA_4QPA#;nK0Xr|aXNV@}ETA8RcgYkn%|8Z+2_8`DRtJ=j{A;44B9b#+{ z;Av|2aagyA!@5EoRvluoZV(4`2RN+T#bMnc_W$3O1X7S*kzsXxnrUPNJCFQDmrBW7Y;>k=C1Ke{xIFY?9=6a$2>mxzygJ)i!4EA| zE4CK>*1~78I)gd95Q$D83Lrdz?JR_t*S>%fzw`=g4;hVvlW+!=zsn8vbnD(jDQ7!R z(7cpCT;3B;>VWE~ArpvhN^Dmi0(kz(A4_iWUhw!^#{L7M?kpUYIbq*vK!QS0{GdJI zA80dXa~*r4=VenMbJc}StM^MML~i5G^tc!Zu9h7)2kVwbfNe}ITTua}IB+~BvMXb0 z151P<8EA+Ao~!Q$)|nrfc>+5lA{NkRoe9nmZXbn@%xOWAU5pdkmW8H#SC-!N@$<{<)xbi_jh4C#c22pG}<4-qh+6CNU9NGCi%z>r3`|KA&t@1arQ zllT67-s4vaQ%k&9{P0n{SaxdAJgh~43EF~PYd?uaAG(VrBctfFj}*M~MJxhJgRO%z zzvt7iOf|C7MU-FX7XQP1%P0fYe)d#TUh@;@Z-o3wRE%HK{rRV zIcu`fe?EUY)N$Q(-L{8GLb~NmovsL)p}b!hzs%%>JW7u0k`dQ2s70 z@T&W@jWoTy1%qR zF|3ZaCg-UQuqh`^I*pE35~xY-zK#Isdmwb3h2I&gd<1^w&`W5%!j>-?`K4zjDb6mdbG>`gdB-d$W*+hDq z970bNx$C@&P>K7A<@L;Q$fyR$FaurHk}~4D;%^>(2Km-TPkhwCH?e?Qh65>hkQ<$$ zc3n#-e#!v!lb^Bz@J$h6E2=VWjO)af_&KPeY&?JY(l(X^D4pbKtbZ<5p>BYx?k!=g zP6HfI`_%)0xV9&ON#Qb6Qki|JW)Yx(C016wkbxm_%#8~&LEFj}9g=-mBiP2I@r9Q8 zmgZNw#WbqgQ{-L}IqpdLDJ@`@N(@0__)2F78z%w+TWNIjDa_LaX905!sFdt3 zaPSH*_Yr01aX(lf0K0gO1N(I09n5NHo820d$Ik)(rr)wEjc)5ilIXxs&0znTqcpsUV}6rJLF@cQQdphEP6P^Iopa`eugt!n z29{0u1OjPm>k|b$0ieB7?%T7)@7$)#yYCs!Z^TEY2%DEanbAr$s&;^JdX}eHdNa*C z`9Zy02aGW-V1gcLm;?7$(tkH1c%{emFx*0RrtBd!pu^4J?r(LR-JCFdL^CbX$Zab% zlgeEB4y3(v;5miJ+K6KQcxfoY5HYenO>1(>)XxA9E(?gHaMo0&VnOFMsxsU0hT3HH zk``Y*Ue<|4aoveCl6%-sN9tZ+C9wPvW$5o9?PZP8UjRVvHpyfz$84cZ$wqnrcxyX% zzNCSE0#g#H_RKS$di+EEquXNL)Mt>ZluGm74GU(_5WMAc*n$m2rFaP48_vn+BT6vK zJDnb0#Y$0oykS=@68t!@Cf^BO>%?gb+lazdU5CI8ceal`SJr%RpcFlW7Wu4JUz`YO zCgpaA51!nJeo-P#fm>~`Wgd_nztk-uk4Qrj2ZxYb`VL`A)vvk%f*O#K|qk4-q{D**l_s^0paKh+`pdhL61#6%m=_G58ej4ly zJfc!#q+oIG>UblErITkGBtbH5t%&AL+n&zu&E*Jne{(MevbbQ;gCTLxoxbKz#_?W# zK4Y#A{A$}QzyHPuhq|=c4zO7`hFNhDs#7WkfiMw}o4>!o5!Nb6EAilme6z6l_lj-j z(tXV&6w8*&;q7Pp;h_PW&-|qyDEk(A|HwmQNns9TrT76P{Gdopp8i?lcH6l}1z{g| z_bKxRGB-}vbHc16a(RPZrGL9i+2}sYcY?I>YT(tF$VTAzR1BC_kQcsE95lG5iDa>)P$5=F{$FT z?t>KXonhrARO?y_pCVJ9ih||Bt=)Z9wgFu_RuAlal@b7vRNZoxvugCHN+th{Luse5 z?!uy=$OuU*$<)Ijs{*nrg7+(|)^!a~j7r|~4-oIbfdL1E31ksF!g6={ zfn}nqFMCw^Dun8&{a2h+s@E<&5(7lw9n4uOlZox+TNHjQR=QShs;I<45T^# zz+AJ{>KG?b+P&ed&AzIzBxtGj(2zkfSo=ngS>0W zd#r4sv?gwkHW4ib4Z<3mR1BGOMKsY>#U$)! z^(OpT!rmkBK*EV=D1RMH#pT;4Hvq)<;rRr&rL_-c*3N%uw37;@?p-?QyT|*}xU7Oo z3Qc_obeakyhX8ClZ$7@WV_T;H;jX7R@O7)5D(Kh?14MF~Ldx(h$qk%;j=m(6IuCJI zDD+Ry@i9pCP%Es&x`W`5(Lh9w^&h>YxV{r!LXZ+U+8S*tgs7=@bSP+;BzAP`rnt{e zJ5CG@=5eywZ?>DfW7TSqSu@`AprzRKtWWqQXwAh({$RFnRrv~&87gMqgA#O4>Q&BShO zyRmKCHYc9gNt4D+ZtwGbyyq|2YoDjH*4e);>59p+Be+hjYS+Ypt`N-S%D{0!C`DVh z*eZ@uAY!?oTT$0tn*onl&5t%czJ9@ZE0!0zh$lKFcG(d5Wb8T$CNU`XxY)8s0_dx? zK2mHVdKXLR`3L2%il8(l=?fKVe7EU5>_d>EL~+nmB!Z*nCQEuazjyUrrKT77@jkqI zS)(3u88!NewAbV4TZTO68MXYCiXbU6*q0{jhAId3O$ zcq#v(G1NHROmkSl?NDFVs5g?msz>Jlm40XjE#xyPiV415rIK-(jAozQD}@IzYcwtq zkNd-DxZ>g26&E}ks2kDB=gu_pm)Vm=ig=K)XlF>-#o;)(5gbz5R&1a=J3umxgzEuq ze{AzHRMVvJR!t%w*mtSyKe=0M<49?Cv(Z6?MscDzQX4ynG)>OM`nRF~Cv${FXd)e2 zY()SIBr}hi$cio!``178lKjNC3##}54Ygr^>?=ur{@6hi{#L>_5pIxd`*XeB5O%g? zu2aj{{rvCJlv*p{3vi#nO;8Y<~^(-YR>>4j^X`DyfdkH={=1+BQueT z{6t;&!D?j{$yTGmsn-8HUMa9j)bliZ}i+@(+}=*-T)#AJx{Y=qp`* zY<`l8pI!|PSh{8*%l!#fQf5uJx3CI=@G?v9^^0={!%M>krf!;6cKyQr=toJDOF2Kr zM9J4ELb6t|eq;(z?1Lx)yNn-yz|NWm&HCKK32=;O(x?%aI#ft)Ep`eB?)C<2A$cV@?nnYwHO*(K^QO*6Nuepqe9fJ27YAV77qq=8dnc5ilduZoIO>o`~@SOrJEIddz}j-5e~F( zH#~5dMzM5`D88XP7Jor8oeJp`9*Ck^J8qM8@tCs%ciWTJlsBV|Z*wJnp*L8*$ySp% zj2^07=Y_ENSXmgwtnLVQ*<4AqX`s)UeA8L+F$8?8&Uh0y62Nlj-fxlpmesRZ8EZH0 zzDjaXz!_;=bj9iE9!fCddFg~MpZK!{$?V{Auo$wFwp?@{bp5Kb+jBi^T|gX-^g0%G z_gGr^DQ>J3xc2s2cX5JBpZm zazU2poYx(%k94)_-`He4a%VdU*Rh^jxWNp#cPHHX%=mWmF08~2~NK_!`n%UyN2l9) zxTJoxlz`k6b0p4A-wlj~4ZDty5 zLw(}H+2IAmrjSQ0E=1OOX1*&z^0>pbRNli3QQu%Zx})bzqD#ThA=vFhwBr5^DQ!oUMn7c4DqrDgPXe5K*_j=O zu)oTBeb~u{PSkl~Oo3jTy zI~+Rd_=B`E$4ke#v7hTGWZZG?N~Bj^r^us`9*baWVYbdlbVgk7=usIunI*}B0T~s_ z6wd+yh8i}UZ?P^OD7Gri>d6f$nYEB^Rr(KhL~ge_P$zXvIQel07Ca+CPc)!sKF9uA z1g1OlDz~G&nv9+&d72MvqtOKlt0)Ob0D#j`Ds^f%q`rKKUCX9QQp+-wRXx}V6&mfS z5qSy*_zL=vDLg&V$vV1?MBBg*TK0TZ39hkNf+ZCI8?LLCVsfqVB##}|g-GziKvNB^ zK5Rav7)DY0shXKyj-%903Bjaj^2y|ZmUM&(r-|E9{-ZATHHJ=Hy5x@yA|8ZX8L^07 z@CuN-+XS45GH2k>kFYzr#u`L|$`eHM&j0YtTIv*NKu~5h5u>1dt+0HZLLJ_Pg*Yd7 zr$%S@up45~8N4gB-Haxcpq9^!!!u`G5=V}IoSp_PS!4+0DpBjeS49_)j$N;;`Z5x0 zXZ7n%uhAd+r7hsFrT4lZY_a&{#{>U|nRtiDhef3yYC15ym@vFx3uU(CDNQChk>r4t z)U!`+xEMacEbEV3G%Nus&iDI2A#&~f-}c96g+v$sG!F`W`+XrC+s?x}HbcKWX}+H- z|IJ_obf*>tx6*$8`|gCHa4`#yXAqQwW7?VJrqg^`Jla*XJkd?CzI;0M7K}j%3s}`9 z$diVS>9}A=8-d+t`2iqu!D%wn{cA>CVQD^>(zWZqEXZ6(B?BC8<~ObDJ9n2}@gt~3 zATP2;zg!ttN=mZpt&!XgV#_A$HatO|)-md#B@^$S6#eqY9kzCQj(N}5Ld^@b!puvP zGPV7za`~H3KQJ6M3K`&w#1r0mm`C%`u-TRKotDEtZ*xat*Q~u*Xq;I<1HkmhdxOfg zF4egSx0k`qV#$7VoAZ_erNS79!Ry=_a2B2`h#SuTz_xWCwxV?b*O!AcGi;U^S>sm4 zWIb+7UoY6gg-rkQ-t2IN!TIuWe;-O14{|0qP!!~!DYNkTn`xKg+tm{Y4YuHe)t)sPo`#w4pRDg`NnLM5)Vb@3~r`=;NsxzX?j@09k@B<%2& z$+iOqdP&#rR0pmS3Map4j&Sd}T>KQa@xy-Leyma07ATrw%8Vg5LTl)e zW4oVZDUG&gPLU&dvaQChko{KwFKqZFR!QPTjY-jHuj0mwQL{X2tLdpjQsVWhUddF@ z+5bW1h$7(YD`T%t=cW!(9b8u3`MSlxQ%ArmN~2{ijH$y@hUz2|359@v;PkNtWq6fa4`yu57o2<7BDAP;X<5h!P#<9z(yh%}h*(xg-x=fO2 zB?~hh>Wr#6*R;Qsxxsh=rxz6Fvi!tN7MG@(-{C1`EGDv4M7p4udPg zC7Dv8)RV#@#%&lQk^BQ;iKWs$tlmpNYYyi2M}t|Luo-E&0_S-inC2QS@mv1>J_GVr zNJ70jvpd?Xi@{WZf;HPJZU}7P?a1APqOCB%(*0^U<#EdEGd%M&x^C%`Xn0U51RXv&sm7{>p$Z0o-d`2l*=g0JAFe?1^XzMM&Xr5SARf7yq2bP*Ll z0`W#mA{f-=&MI}#XBASRtEDuD7IrwiJMF=2le%(6&XRlk9DLZzH9#5-gkr4KG>9zo zc(ajO?b?xT%2SU6u&R-A2v5Gw9ZaT#?RIqfa`+wUxDyFZ(bb@rWji|o@RQq0>XW(4 zz?j(Ba86ID)IDl)L#f6#BlVKdaLGbKqJlQiEII6cE{!J>nZUku3aWo7Kq5O#g3w^73TR^yUoPX(N^8*N2-OCo&D)|Wwjei(cQQ!Y_lS*<|#PMQ4Y z*^kzfA~U$E*tBIg?-FAjcIDj-b*#|#n>s=jU0Y?9TOT28~>vybHEnq?q zuBCM>tV$U^a9KQ51OP^dJw=38bwS|$cv|4##5HFXFFgKFq2!tJagnR-YMX?^X;gH_B;Qk+tfr^vznZGA#u|E_*{ zcmNIo?_DBnm>(t^shXGpQ+9U4S?Y6(2y*x3>C<>7sGWuJu=@lqID2}oG4OnOv-G~ zQ&o8qMJ?oEEg&K+Z5jUsnN^2HC3Rw!Ifo%R`?V+@w<^%w#j35Qza}FS9@>MVDUcTLB zWkd1xT_FyP=6dHwDaxb}QI}He$obj*W32QFSuoZ_n?M4hkWI20X?3IWaYu&Lhss1k z@*LAF9HuD)QBpDaVwf?suV*rkS|BiQ@-Qmauzw*1DVFn}3WX6Za#!_6Cya+8>*oc} z;p_!rU%U^huh4A|Hh+ZR5(aU$x)YY6t<0rD2(l$*F9xI{@`$=FY~ArUMUkLbq>2Tj z^5))B?6(t+{D;M^knHS1>LqglK}&V|Ih>NDKQ^dglSKKgzWa(wkj<2_yRz}Aw> zhx9Dw`B9Zzx$ERB>TXCYNWqEQ7f1@J-}g!F+WN|b^q_t6h^Ssorwy_s*s~Fn%_69{{2rG!>t*DMAtx|wR|m9`aU@_pUkEYDrD8pnZAnj;x(@Ra%*w3%n>zTqGDrD zLXViB0=vB8^sfyvKL$(rzg>%#{Ql_Z~fUq!>cCaCvNoqkoE2f&vn`+t#z3^vA( z@%dy#da%f8UAFF#UFk&aU>LKWNii$MVG{Rf+%O?LL3*Lrg||fwLaK`DXSDNSh6fR~ zYbdzv3AZ1z?Rx(<2CeiRSzP}9Np%m(D%wL>>s2vK8#0f9x_zCS(^U`wwhKwX-;T`a z^&PPhNV|9iCrgGh$cHVZfIj;%`iA()K%~Ss7HcEYxWqP=9^zrtUd+xeKa} zR#lt?4SM}+1h3&zW5BIjSdTAklw{+QHP-WpE>gf3%~Pz={7wtX;pC75WePyCnRQg* zcVyFy0N$gPH+57_CUeRx)6Ql7kcadki$daz)Vi%e;iYP;o9+X$?$c$D{b=)gtm5&x zFY+I){^Is;^jj-zDfxw0+jZ8kx_)ozfVYaw%+Gb_;x z%8Ayj8oLC|KW`vHbOidHhH9DZqNs{HyYS|6NbW&p+HB0g$N$GKrYL7tqN{M})ImQ2 z4N;O?;GMmze?OCQCcHu9+nkyH5@%lF8$=b@=7{yu#x+9PReCC$TAq}0|2|DwvB-=; z_dQ*}Hns#s{rTS;5;UkmGFxsqo_dEbReMmkHNuS8!kQ4DCA^fq-PV~kAd&SX9%>T% zE`*3iWO~ZPJ#}0(HhTJua>`H4S+r`WU_>pYh&=!Mh?B@Dw67gKg^%YY2wAjvoL1U)6B(u0;kF26(EY2 zVE>P4aNl2*ae&ylx=r_cgM_aq#jK*dL%lcG^qM3;sur+9P7)k+F2N}(m?-nEmV`+f z#q-+aH>lm+I||1D4OBH$&)B(^7o;m%0uHvdp5YwD>7P^y%leWxy#Oqt{DoCT_pQ1L~yoQ4)>lO&8kUH$62jw4Gsv8|ZYH&VVpG z^xtUvSMb=xs?{W~K!pg#o9*oWVG{U`p1ACPJ9%i{onq%d6Y4nwBW~;|z(*E=wz1ci zahvewQ1v|&B4KAyanz5oTETH~Q+Y07g{P}HH~l=Hhf6~9V*8(bY0MTQR&)YYU z$!%|t)SPTGq0N|d+}gyGCV7BoH4&Ttkt89?pi5Q};1=5>Dj|AJV?F&_NcFc|(x;H9p^K53gvoWK7UMxmi5VY~<(^@^8=%AA zw-dLK%kCZNXp5+6AwvNG!1Lri%flW zWFg^COz~Kr*3XLAJ8>T`li$+Yf2Y?`I>(JEq};RgL`fpo`@h!sLE{rB=+tCw24B}U zQG05g)}Xw}k>+VDn4%@D#>nX|!4+bb{8$*XUeVB% zvfwSkTD5RS{)Jl*U~|xPUco}yNVE`f!Nnh{8a3b7?7HzsD-Cl4&1M*sNjz94WAhCx zaQRxPgy;~~Y$h}vyRTW;%zj1U?x#%~!Y8HANopl~Td|;z{}?AMTA4IROj$z^_x}~< h%N6Xe=&HWSPM5!P|BM~cdl9MTMgWul&#?b9{{uIWpVj~X literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..55f05c70936c12ee12bf2e6c99501839bef34ded GIT binary patch literal 5403 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 PLI6ZnOQq(iRrvn^cY1NR literal 0 HcmV?d00001 diff --git a/website/static/brand/icon-32.png b/website/static/brand/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..b719ffc7245142fcb3ef19d1d45355c9013b37e9 GIT binary patch literal 1113 zcmeAS@N?(olHy`uVBq!ia0vp^3Lq@N0wg;h=h=Wb$sR$z3=CCj3=9n|3=F@3LJcn% z7)lKo7+xhXFj&oCU=S~uvnvp!mMO{G-GzZ+Rj;xUkjGiz5n0T@z&HVn!#>;wYGYt8 z@$_|Nf5jmyX2J8mtMV#PNVUW@q9i4;B-JXpC>2OC7#SFu=^9w*8d-)I8CjW{Ss5B> z8yHv_7~D9@ybDD`ZhlH;S|x4`D`)Ix18UHK+fb63n_66wm|K8b&%fSTHyIe1H9TD$ zLn00zz3!b6lPbdg;l6L$tQq06T~~^DZf!c66)a%xw1H#eS_#)2&7~ddR)eEx9Y z;qLo$jPL)Pf4uy?4)h_cq|o_4HAJNm8bN0USem)WdS4*A^sr}R(n z(($Y}5f46ZJ(_irL153BcRc+z4xyTf*@d25^HM$qp5rS_<2T;TwMwo1#lCX}GBSMA zeUo1r*!)O)7&1kqX>0nVUg>|%FFt4XbZN@HQr=-4xm2Oyp!uWrRjCXsdZyW%34K#p zy@c!U_am_;HK+fod)T(UQ|i4w`E)^DTlxAU`&q@V1{MikOPKQNbk6#vo0w%?nQk24 zs#nD>@r%zqA!%dO!RQ4tlU=2BU0xeZ)zRWf=v|TW(|wItLuvNov`@Mn-3GONyADtJ z`lv)_wG;PZO}BZI@K54mejFEE}Kn$WY* z(XX%Iq46B^@3pg+CJ683m+<-g_qfj+-#&+fN;SU}@=K#8)#W~(D&}@iZO1MLi6rZT z^S|UQeSR=0s3E%e^TmM4Yl44shf43L>UfaSiQcWSjcWv7;*iv^RMfdzuNp|JlJ>R_Mhf_ z#oOoKn_s-{@NMx@#wSnWr|o&Bc-8%7T1;PRW{Kh6{tpHR>rUpMDLQnw#r)aPDmB5w zS2q4%sXDvscjNtYM?J3nnmQ-#PrHy^Wxkzkw%f<@jlXLzt|`mB{nhz!)ESn;-!GX+ ze|>f=Bj!|igs5EqwKtMRrBkjQX9&u@xW=rYCh`8hNv!7YE0^_k?M%xrbGavUHfwoY r%9Ev2zt_D{O7C|`od0>xGx@(~^H#o5|9B9X&lo&i{an^LB{Ts5>!j?x literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c03060e8a03a10ac38062789adceb33c4736db6e GIT binary patch literal 176112 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

_UA?{J@qkO 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 Dn{9dE literal 0 HcmV?d00001 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 From b202812f9a38f040303357f3c11441d9b1e1a53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 2 Sep 2026 07:50:45 -0700 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=94=A7=20build(release):=20make=20rel?= =?UTF-8?q?eases=20updatable=20and=20verifiable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tagged build stamped bundle version 1 because the release jobs checked the tag out at depth one, so Sparkle never saw a newer build and App Store Connect would reject a second upload. The widget extension also shipped without the hardened runtime, and a missing generate_appcast silently published an unsigned feed that installed copies cannot verify. The release jobs now fetch full history, verify the stamped build number against the published appcast, fail on an unsigned feed or an empty Sparkle key, and check runtime flags and entitlements on every nested bundle before notarization. The cask update moves to its own job that opens a pull request from a clean main instead of pushing over stashed release state. Fork pull requests no longer run on the self-hosted runner, and unset runner variables skip their jobs instead of waiting for a label that does not exist. Local workflows change to match: just install refuses to overwrite a distribution-signed copy and waits for the app to quit, the test isolation gate fails on tool errors instead of passing them, ripgrep is pinned, and the cask quits the running app before replacing it. The docs describe the group containers each channel really uses, warn that token refresh can sign the CLI out, and explain which environment variables a GUI launch does not see. Claude-Session: https://claude.ai/code/session_01CGckDFXvufWVmRH4uyE4z8 --- .github/workflows/ci.yml | 204 +++++++++++++++++----- .github/workflows/release.yml | 81 +++++++-- App/PrivacyInfo.xcprivacy | 9 + App/project.yml | 6 +- CONTRIBUTING.md | 6 + Casks/token-menu-bar.rb | 3 + README.md | 2 +- SECURITY.md | 40 +++-- Scripts/appcast.sh | 32 +++- Scripts/build-direct.sh | 11 +- Scripts/check-test-isolation.sh | 50 +++++- Scripts/coverage.sh | 4 +- Scripts/notarize.sh | 44 +++-- Scripts/verify-app-bundle.sh | 69 +++++++- justfile | 38 +++- mise.lock | 34 ++++ mise.toml | 1 + website/content/explanation/_index.md | 37 ++-- website/content/reference/interface.md | 17 +- website/content/reference/settings.md | 9 +- website/content/start/_index.md | 10 +- website/content/troubleshooting/_index.md | 18 +- 22 files changed, 587 insertions(+), 138 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f429e4..7edb494 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,13 +40,11 @@ jobs: - 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 + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - name: Select Xcode id: xcode env: @@ -108,9 +106,6 @@ jobs: - 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: @@ -181,39 +176,6 @@ jobs: 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: @@ -318,9 +280,164 @@ jobs: kill "$pid" 2>/dev/null || true wait "$pid" || true + package-xcode-27: + name: Package (Xcode 27 SDK) + if: >- + vars.XCODE_27_RUNNER != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name + == github.repository) + runs-on: ${{ vars.XCODE_27_RUNNER }} + 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: 27 + EXACT_XCODE_MAJOR: 27 + 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 }}-${{ vars.XCODE_27_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 + + application-ui-xcode-27: + name: Application UI (Xcode 27 SDK) + if: >- + vars.XCODE_27_RUNNER != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name + == github.repository) + runs-on: ${{ vars.XCODE_27_RUNNER }} + 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: 27 + EXACT_XCODE_MAJOR: 27 + 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 }}-${{ vars.XCODE_27_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-xcode-27: + name: Build ${{ matrix.scheme }} (Xcode 27 SDK) + if: >- + vars.XCODE_27_RUNNER != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name + == github.repository) + runs-on: ${{ vars.XCODE_27_RUNNER }} + strategy: + fail-fast: false + matrix: + include: + - scheme: TokenMenuBar-Direct + target: TokenMenuBarDirect + configuration: Release + distribution: Direct + condition: DIRECT + sandbox: NO + entitlements: Direct.entitlements + updater: required + - scheme: TokenMenuBar-AppStore + target: TokenMenuBarAppStore + configuration: AppStore + distribution: App Store + condition: APPSTORE + sandbox: YES + entitlements: AppStore.entitlements + updater: forbidden + - 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: 27 + EXACT_XCODE_MAJOR: 27 + 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 }}-${{ vars.XCODE_27_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 + env: + DISTRIBUTION: ${{ matrix.distribution }} + UPDATER: ${{ matrix.updater }} + 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" "$DISTRIBUTION" "$UPDATER" + macos-27-runtime: name: Application UI (macOS 27 runtime) - if: vars.MACOS_27_RUNTIME_RUNNER != '' + if: >- + vars.MACOS_27_RUNTIME_RUNNER != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name + == github.repository) runs-on: ${{ vars.MACOS_27_RUNTIME_RUNNER }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -351,8 +468,13 @@ jobs: env: RUNNER: ${{ vars.MACOS_27_RUNTIME_RUNNER }} RESULT: ${{ needs.macos-27-runtime.result }} + UNTRUSTED_FORK: >- + ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + }} run: | - if [ -z "$RUNNER" ]; then + if [ "$UNTRUSTED_FORK" = "true" ]; then + echo "macOS 27 runtime tests skip fork pull requests to protect the self-hosted runner." >> "$GITHUB_STEP_SUMMARY" + elif [ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 299e516..169ba41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,6 +89,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.TAG }} + fetch-depth: 0 persist-credentials: false - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 with: @@ -112,7 +113,7 @@ jobs: needs: [preflight, macos-27-runtime] runs-on: macos-26 environment: release - # This job pushes the cask back to main and publishes the release. + # This job publishes the release; the cask job opens its branch from a clean main checkout. permissions: contents: write outputs: @@ -121,13 +122,39 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.TAG }} + fetch-depth: 0 persist-credentials: false + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + cache: 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: Verify the release build number + env: + GH_TOKEN: ${{ github.token }} + run: | + current="$(Scripts/version.sh --build)" + published_tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName // ""')" + if [ -n "$published_tag" ]; then + appcast="$RUNNER_TEMP/published-appcast.xml" + gh release download "$published_tag" --pattern appcast.xml --output "$appcast" + published="$(sed -n 's:.*\([0-9][0-9]*\).*:\1:p' "$appcast" | head -1)" + [[ "$current" =~ ^[0-9]+$ && "$published" =~ ^[0-9]+$ ]] || { + echo "::error::Could not read numeric build numbers from git and $published_tag" + exit 1 + } + if [ "$published_tag" = "$TAG" ]; then + ((current == published)) || { + echo "::error::$TAG was published with build $published, but this checkout produces $current" + exit 1 + } + elif ((current <= published)); then + echo "::error::Build $current must be greater than $published from $published_tag" + exit 1 + fi + fi - 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. @@ -178,8 +205,7 @@ jobs: 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 + Scripts/notarize.sh "dist/direct/Token Menu Bar.app" "dist/homebrew/Token Menu Bar.app" - name: Package downloads run: | Scripts/package-direct.sh "${TAG#v}" @@ -191,8 +217,7 @@ jobs: 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 + Scripts/notarize.sh dist/direct/TokenMenuBar.dmg dist/homebrew/TokenMenuBar-Homebrew.dmg - name: Refresh checksums run: | for file in dist/direct/TokenMenuBar.zip dist/direct/TokenMenuBar.dmg \ @@ -239,21 +264,45 @@ jobs: 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 + cask: + name: Open the Homebrew cask update + needs: direct + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: downloads + path: release + - name: Open a cask pull request env: GH_TOKEN: ${{ github.token }} + TAG: ${{ env.TAG }} run: | remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + branch="automation/cask-${TAG#v}" + cp release/Casks/token-menu-bar.rb Casks/token-menu-bar.rb 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 + remote_sha="$(git ls-remote --heads "$remote" "refs/heads/$branch" | awk '{print $1}')" + if [ -n "$remote_sha" ]; then + git push --force-with-lease="refs/heads/$branch:$remote_sha" "$remote" "HEAD:refs/heads/$branch" + else + git push "$remote" "HEAD:refs/heads/$branch" + fi + if ! gh pr list --head "$branch" --state open --json number --jq 'length > 0' | grep -Fx true; then + gh pr create --base main --head "$branch" --title "Update Homebrew cask for ${TAG}" \ + --body "Publish the ${TAG} Homebrew package checksum." + fi fi app-store: @@ -268,11 +317,13 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.TAG }} + fetch-depth: 0 persist-credentials: false + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + cache: 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 diff --git a/App/PrivacyInfo.xcprivacy b/App/PrivacyInfo.xcprivacy index 215a9bb..0fadcdb 100644 --- a/App/PrivacyInfo.xcprivacy +++ b/App/PrivacyInfo.xcprivacy @@ -24,6 +24,15 @@ NSPrivacyAccessedAPITypeReasons C617.1 + 3B52.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 diff --git a/App/project.yml b/App/project.yml index 73f11f7..3548edb 100644 --- a/App/project.yml +++ b/App/project.yml @@ -15,6 +15,7 @@ settings: base: SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY: YES SWIFT_TREAT_WARNINGS_AS_ERRORS: YES MARKETING_VERSION: "0.1.0" CURRENT_PROJECT_VERSION: "1" @@ -40,7 +41,7 @@ packages: path: .. Sparkle: url: https://github.com/sparkle-project/Sparkle - from: "2.9.0" + exactVersion: "2.9.0" targetTemplates: Application: type: application @@ -109,6 +110,7 @@ targets: properties: SUFeedURL: https://github.com/tox-dev/token-menu-bar-macos/releases/latest/download/appcast.xml SUPublicEDKey: $(SPARKLE_PUBLIC_ED_KEY) + SUEnableAutomaticChecks: false SUEnableInstallerLauncherService: true settings: base: @@ -160,6 +162,7 @@ targets: sources: - path: Widget/Sources name: Widget + - path: PrivacyInfo.xcprivacy dependencies: - package: TokenMenuBarPackage product: TokenMenuBarWidgets @@ -184,6 +187,7 @@ targets: PRODUCT_NAME: TokenMenuBarWidget PRODUCT_BUNDLE_IDENTIFIER: dev.tox.token-menu-bar.widget CODE_SIGN_ENTITLEMENTS: Widget/Widget.entitlements + ENABLE_HARDENED_RUNTIME: YES ENABLE_APP_SANDBOX: YES SKIP_INSTALL: YES configs: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e4126d..d8d604f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,3 +14,9 @@ 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. + +CI runs on GitHub-hosted macOS runners; four repository variables point extra jobs at self-hosted Macs. +`XCODE_27_RUNNER` names a Mac with Xcode 27 and the Xcode 27 SDK jobs skip while it is unset. `MACOS_27_RUNTIME_RUNNER` +names a Mac running macOS 27; the required **macOS 27 runtime** status stays red and releases refuse to start while it +is unset. `MACOS_14_RUNNER` and `MACOS_14_RUNTIME_RUNNER` replace the hosted `macos-14` label for the package and smoke +jobs. Self-hosted jobs skip pull requests from forks. diff --git a/Casks/token-menu-bar.rb b/Casks/token-menu-bar.rb index ffc528c..aeb0d3f 100644 --- a/Casks/token-menu-bar.rb +++ b/Casks/token-menu-bar.rb @@ -15,10 +15,13 @@ depends_on macos: :sonoma app "Token Menu Bar.app" + uninstall quit: "dev.tox.token-menu-bar" zap trash: [ "~/Library/Application Support/Token Menu Bar", "~/Library/Caches/dev.tox.token-menu-bar", + "~/Library/Group Containers/*.dev.tox.token-menu-bar", + "~/Library/Group Containers/group.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", diff --git a/README.md b/README.md index adb45ab..8855f55 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ clients rather than to this app, and it calls only the vendors' own endpoints. | Cursor | Cursor app, `~/.cursor` | plan, spend | | Copilot | `~/.config/github-copilot` | premium | -Requires macOS 14 or later on Apple Silicon, and one signed-in client. +Requires macOS 14 or later on Apple silicon or Intel, 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 diff --git a/SECURITY.md b/SECURITY.md index a7a83a3..a11c9b4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,30 +34,42 @@ advisory unless you would rather not have it. 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 | +| Provider | Reads | Writes | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| Claude | Keychain item `Claude Code-credentials`, or `~/.claude/.credentials.json` (`CLAUDE_CONFIG_DIR` honoured); `~/.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 | + +Environment overrides apply only when they exist in the app process. These include the four path variables in the table, +`GH_TOKEN`, and the Gemini credential-storage and OAuth-client variables. Finder and login-item launches do not load +exports from shell startup files such as `.zshrc`; Settings > Providers shows the credential source and path the app +resolved. **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. +If the app and a running CLI refresh the same credential at once, the CLI can retain the old rotated token. Its next +refresh can fail and require another `claude`, `codex login`, or `gemini` sign-in. + 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. +Direct and Homebrew builds keep the SQLite history, the last-value cache, and `log.txt` under +`~/Library/Application Support/Token Menu Bar/`. The sandboxed App Store build keeps the same files under +`~/Library/Containers/dev.tox.token-menu-bar/Data/Library/Application Support/Token Menu Bar/`. The cache includes the +plan name and the account email when the vendor returns one. 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 holds window labels, percentages and reset times, with no token and no email. The App Store build +uses `~/Library/Group Containers/group.dev.tox.token-menu-bar/widget.json`; Direct and Homebrew builds use +`~/Library/Group Containers/.dev.tox.token-menu-bar/widget.json`. The channels do not share widget snapshots +because the app-group identifiers differ. 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 diff --git a/Scripts/appcast.sh b/Scripts/appcast.sh index 62d9c2f..c068f6f 100755 --- a/Scripts/appcast.sh +++ b/Scripts/appcast.sh @@ -1,20 +1,38 @@ #!/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. +# Generates dist/direct/appcast.xml for Sparkle from the release zip. Local builds may pass --unsigned; releases fail +# before publishing an appcast that Sparkle cannot verify. set -euo pipefail cd "$(dirname "$0")/.." version="${1:?usage: appcast.sh }" +mode="${2:-}" +[[ -z "$mode" || "$mode" == "--unsigned" ]] || { + echo "usage: appcast.sh [--unsigned]" >&2 + exit 1 +} 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 | +generate_appcast="$( + find ~/Library/Developer/Xcode/DerivedData "$PWD/App" -path '*/artifacts/sparkle/Sparkle/bin/generate_appcast' \ + -type f 2> /dev/null | head -1 || true )" -if [[ -n "$sparkle_bin" && -n "${SPARKLE_PRIVATE_ED_KEY:-}" ]]; then +if [[ "$mode" != "--unsigned" ]]; then + [[ -n "${SPARKLE_PRIVATE_ED_KEY:-}" ]] || { + echo "SPARKLE_PRIVATE_ED_KEY is required; pass --unsigned for a local appcast" >&2 + exit 1 + } + [[ -x "$generate_appcast" ]] || { + echo "Sparkle generate_appcast was not found in DerivedData or App" >&2 + exit 1 + } echo "$SPARKLE_PRIVATE_ED_KEY" | - "$sparkle_bin/generate_appcast" --ed-key-file - --download-url-prefix "$download_prefix" "$out" + "$generate_appcast" --ed-key-file - --download-url-prefix "$download_prefix" "$out" + grep -q 'sparkle:edSignature=' "$out/appcast.xml" || { + echo "generated appcast has no Sparkle EdDSA signature" >&2 + exit 1 + } exit 0 fi @@ -36,4 +54,4 @@ cat > "$out/appcast.xml" << XML XML -echo "wrote unsigned appcast (set SPARKLE_PRIVATE_ED_KEY to sign)" +echo "wrote unsigned local appcast" diff --git a/Scripts/build-direct.sh b/Scripts/build-direct.sh index 245756d..92bc9b6 100755 --- a/Scripts/build-direct.sh +++ b/Scripts/build-direct.sh @@ -9,14 +9,19 @@ 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=}" +placeholder_key="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +public_key="${SPARKLE_PUBLIC_ED_KEY:-$placeholder_key}" +if [[ "$signed" == "true" && "$public_key" == "$placeholder_key" ]]; then + echo "a signed build needs SPARKLE_PUBLIC_ED_KEY set to the release EdDSA public key" >&2 + exit 1 +fi 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:?}" \ + DEVELOPMENT_TEAM="${TEAM_ID:?}" SPARKLE_PUBLIC_ED_KEY="$public_key" \ APP_GROUP_ID="${TEAM_ID}.dev.tox.token-menu-bar" SELF_UPDATE_ENABLED=YES archive | tail -20 cat > "$out/export.plist" << PLIST @@ -38,7 +43,7 @@ else 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 + DEVELOPMENT_TEAM="" SPARKLE_PUBLIC_ED_KEY="$public_key" archive | tail -20 cp -R "$archive/Products/Applications/Token Menu Bar.app" "$out/" codesign --force --deep --sign - "$out/Token Menu Bar.app" fi diff --git a/Scripts/check-test-isolation.sh b/Scripts/check-test-isolation.sh index 0e8b7a1..36109bd 100755 --- a/Scripts/check-test-isolation.sh +++ b/Scripts/check-test-isolation.sh @@ -3,9 +3,49 @@ 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 +reject_matches() { + message="$1" + pattern="$2" + shift 2 + if matches="$(rg -n "$pattern" "$@")"; then + echo "$message" >&2 + echo "$matches" >&2 + exit 1 + else + status=$? + if ((status != 1)); then + echo "test isolation scan failed with status $status" >&2 + exit "$status" + fi + fi +} + +reject_matches \ + "tests and benchmarks must use isolated credential, HTTP, notification, and login-item clients" \ + 'SecItem(Add|CopyMatching|Delete|Update)|URLSession(\.shared|[[:space:]]*\()|SystemHTTPTransport\.make|KeychainCredentialClient\.system|keychain:[[:space:]]*\.system|LaunchAtLoginService\.backend|UNUserNotificationCenter\.current' \ + Tests App/UITests --glob '*.swift' +reject_matches \ + "tests must use TestDefaults so temporary preference domains are removed" \ + 'UserDefaults[[:space:]]*\([[:space:]]*suiteName:' \ + Tests App/UITests --glob '*.swift' --glob '!**/Support/Fixtures.swift' --glob '!**/Support/UIFixtures.swift' \ + --glob '!**/LaunchPolicyTests.swift' +reject_matches \ + "tests must inject process boundaries instead of launching subprocesses" \ + 'Process[[:space:]]*\(' Tests App/UITests --glob '*.swift' + +global_mutation='method_exchangeImplementations|URLProtocol\.(registerClass|unregisterClass)|setClass\(|(NSApplication\.shared|NSApp|application)\.delegate[[:space:]]*=' +if mutation_files="$(rg -l "$global_mutation" Tests App/UITests --glob '*.swift')"; then + while IFS= read -r file; do + if ! rg -q '@Suite\([.]serialized\)' "$file"; then + echo "$file mutates process-global state outside a serialized suite" >&2 + rg -n "$global_mutation" "$file" >&2 + exit 1 + fi + done <<< "$mutation_files" +else + status=$? + if ((status != 1)); then + echo "test isolation scan failed with status $status" >&2 + exit "$status" + fi fi diff --git a/Scripts/coverage.sh b/Scripts/coverage.sh index 7e33dd6..4bcc7cb 100755 --- a/Scripts/coverage.sh +++ b/Scripts/coverage.sh @@ -7,8 +7,7 @@ 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. +# Running the suite in parallel drops counters, and the gate then reports a line as unexecuted that a test runs. swift test --enable-code-coverage --no-parallel "$@" bin_dir="$(swift build --show-bin-path)" @@ -30,6 +29,7 @@ glue=( Sources/TokenMenuBar/main.swift Sources/TokenMenuBarUI/WorkspaceGlue.swift Sources/TokenMenuBarUI/Adapters/LaunchAtLoginService.swift Sources/TokenMenuBarUI/Adapters/PanelMaterialAdapter.swift + Sources/TokenMenuBarUI/Adapters/NotificationCenterGlue.swift Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift Sources/TokenMenuBarWidgets/WidgetKitGlue.swift App/Sources/SparkleUpdater.swift diff --git a/Scripts/notarize.sh b/Scripts/notarize.sh index c085558..44b406b 100755 --- a/Scripts/notarize.sh +++ b/Scripts/notarize.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -directory="${1:?usage: notarize.sh }" +(("$#" > 0)) || { + echo "usage: notarize.sh [...]" >&2 + exit 1 +} : "${APP_STORE_CONNECT_KEY_ID:?}" : "${APP_STORE_CONNECT_ISSUER_ID:?}" : "${APP_STORE_CONNECT_KEY_BASE64:?}" @@ -13,18 +16,31 @@ notarize() { --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" +for artifact in "$@"; do + case "$artifact" in + *.app) + [[ -d "$artifact" ]] || { + echo "missing application: $artifact" >&2 + exit 1 + } + archive="$RUNNER_TEMP/$(basename "$artifact" .app).zip" + ditto -c -k --keepParent "$artifact" "$archive" + notarize "$archive" + xcrun stapler staple "$artifact" + spctl --assess --type exec --verbose=2 "$artifact" + ;; + *.dmg) + [[ -f "$artifact" ]] || { + echo "missing disk image: $artifact" >&2 + exit 1 + } + notarize "$artifact" + xcrun stapler staple "$artifact" + ;; + *) + echo "unsupported notarization artifact: $artifact" >&2 + exit 1 + ;; + esac done rm -f "$key" diff --git a/Scripts/verify-app-bundle.sh b/Scripts/verify-app-bundle.sh index 674cbb0..0d7b964 100755 --- a/Scripts/verify-app-bundle.sh +++ b/Scripts/verify-app-bundle.sh @@ -37,6 +37,8 @@ done < <(find "$app" -type f -print0) otool_dependencies="$(xcrun otool -L "$executable")" dyld_dependencies="$(xcrun dyld_info -linked_dylibs "$executable")" sparkle_framework="$app/Contents/Frameworks/Sparkle.framework" +self_update="$(/usr/libexec/PlistBuddy -c 'Print:TMBSelfUpdateEnabled' "$plist" 2> /dev/null || true)" +placeholder_key="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" if [[ "$expected_updater" == "required" ]]; then [[ -d "$sparkle_framework" ]] || { echo "Direct build does not embed Sparkle.framework" >&2 @@ -56,8 +58,19 @@ if [[ "$expected_updater" == "required" ]]; then echo "Direct build has an empty or unexpanded SUPublicEDKey" >&2 exit 1 } + if [[ "$self_update" == "YES" ]]; then + key_bytes="$(base64 --decode <<< "$public_key" 2> /dev/null | wc -c | tr -d ' ')" + [[ "$key_bytes" -eq 32 && "$public_key" != "$placeholder_key" ]] || { + echo "self-update is enabled but SUPublicEDKey is not a real EdDSA public key: $public_key" >&2 + exit 1 + } + fi /usr/libexec/PlistBuddy -c 'Print:SUEnableInstallerLauncherService' "$plist" > /dev/null else + [[ "$self_update" != "YES" ]] || { + echo "self-update is enabled in $expected_distribution metadata" >&2 + exit 1 + } [[ ! -e "$sparkle_framework" ]] || { echo "Sparkle.framework is embedded in $expected_distribution" >&2 exit 1 @@ -78,4 +91,58 @@ else done fi -echo "verified $expected_distribution app: universal, dyld-valid, updater=$expected_updater" +signature_info="$(codesign -dv --verbose=4 "$app" 2>&1 || true)" +team_identifier="$(sed -n 's/^TeamIdentifier=//p' <<< "$signature_info" | head -1)" +signing_check=ad-hoc +if [[ -n "$team_identifier" && "$team_identifier" != "not set" ]]; then + signing_check=distribution + if [[ "$expected_distribution" == "App Store" ]]; then + expected_group="group.dev.tox.token-menu-bar" + expected_app_sandbox=true + else + expected_group="${team_identifier}.dev.tox.token-menu-bar" + expected_app_sandbox=false + while IFS= read -r -d '' bundle; do + bundle_signature="$(codesign -dv --verbose=4 "$bundle" 2>&1)" + grep -Eq 'flags=.*\([^)]*runtime[^)]*\)' <<< "$bundle_signature" || { + echo "$bundle is missing the hardened-runtime signature flag" >&2 + exit 1 + } + codesign --verify --strict --verbose=2 "$bundle" + done < <(find "$app" -type d \( -name '*.app' -o -name '*.appex' -o -name '*.xpc' -o -name '*.framework' \) -print0) + fi + + entitlements="$(mktemp)" + trap 'rm -f "$entitlements"' EXIT + for bundle in "$app" "$app/Contents/PlugIns/TokenMenuBarWidget.appex"; do + [[ -d "$bundle" ]] || { + echo "missing signed bundle: $bundle" >&2 + exit 1 + } + codesign -d --entitlements - --xml "$bundle" > "$entitlements" 2> /dev/null + actual_group="$(plutil -extract 'com\.apple\.security\.application-groups.0' raw -o - "$entitlements")" + [[ "$actual_group" == "$expected_group" ]] || { + echo "$bundle uses app group $actual_group; expected $expected_group" >&2 + exit 1 + } + configured_group="$(/usr/libexec/PlistBuddy -c 'Print:TokenMenuBarAppGroup' "$bundle/Contents/Info.plist")" + [[ "$configured_group" == "$expected_group" ]] || { + echo "$bundle metadata uses app group $configured_group; expected $expected_group" >&2 + exit 1 + } + actual_sandbox="$(plutil -extract 'com\.apple\.security\.app-sandbox' raw -o - "$entitlements" 2> /dev/null || echo false)" + if [[ "$bundle" == *.appex ]]; then + expected_sandbox=true + else + expected_sandbox="$expected_app_sandbox" + fi + [[ "$actual_sandbox" == "$expected_sandbox" ]] || { + echo "$bundle sandbox=$actual_sandbox; expected $expected_sandbox" >&2 + exit 1 + } + done + rm -f "$entitlements" + trap - EXIT +fi + +echo "verified $expected_distribution app: universal, dyld-valid, updater=$expected_updater, signing=$signing_check" diff --git a/justfile b/justfile index 68f2bab..993dbd5 100644 --- a/justfile +++ b/justfile @@ -17,7 +17,7 @@ toolchain: # Run the test suite test *filter: Scripts/check-test-isolation.sh - swift test {{ if filter == "" { "" } else { "--filter " + filter } }} + swift test --no-parallel {{ if filter == "" { "" } else { "--filter " + filter } }} # Run the tests and fail if a line in Core or UI never executed coverage: @@ -52,12 +52,40 @@ run: run-demo: Scripts/bundle-dev.sh --run-demo -# Build the app and install it into /Applications, replacing any copy already there +# Build the app and install it into /Applications _install: + #!/usr/bin/env bash + set -euo pipefail + installed="/Applications/Token Menu Bar.app" + if [[ -d "$installed" && "${TOKEN_MENU_BAR_FORCE_INSTALL:-0}" != "1" ]]; then + team="$(codesign -dv --verbose=4 "$installed" 2>&1 | sed -n 's/^TeamIdentifier=//p' | head -1 || true)" + if [[ -n "$team" && "$team" != "not set" ]]; then + echo "Refusing to replace a distribution-signed app. Set TOKEN_MENU_BAR_FORCE_INSTALL=1 to replace it." >&2 + exit 1 + fi + fi 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/ + if [[ -d "$installed" ]]; then + osascript -e 'quit app "Token Menu Bar"' 2>/dev/null || true + for _ in {1..20}; do + pgrep -x TokenMenuBar >/dev/null || break + sleep 0.25 + done + if pgrep -x TokenMenuBar >/dev/null; then + echo "Token Menu Bar is still running; installation stopped." >&2 + exit 1 + fi + backup="$(mktemp -d /Applications/.token-menu-bar-install.XXXXXX)" + mv "$installed" "$backup/" + installed_new_app=false + trap 'if [[ "$installed_new_app" != "true" ]]; then rm -rf "$installed"; mv "$backup/Token Menu Bar.app" "$installed"; fi; rm -rf "$backup"' EXIT + fi + ditto "dist/Token Menu Bar.app" "$installed" + if [[ -n "${backup:-}" ]]; then + installed_new_app=true + rm -rf "$backup" + trap - EXIT + fi # Install the app and launch it with real provider data install: _install diff --git a/mise.lock b/mise.lock index 6c3b1ec..5a7c2fa 100644 --- a/mise.lock +++ b/mise.lock @@ -112,6 +112,40 @@ checksum = "sha256:ea8a0c84902e48c1875558f2f362ed8476773aa5fc8c16c5d8f2acc2a2830 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.ripgrep]] +version = "15.1.0" +backend = "aqua:BurntSushi/ripgrep" + +[tools.ripgrep."platforms.linux-arm64"] +checksum = "sha256:2b661c6ef508e902f388e9098d9c4c5aca72c87b55922d94abdba830b4dc885e" +url = "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/BurntSushi/ripgrep/releases/assets/307306472" + +[tools.ripgrep."platforms.linux-x64"] +checksum = "sha256:1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599" +url = "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/BurntSushi/ripgrep/releases/assets/307306614" + +[tools.ripgrep."platforms.linux-x64-musl"] +checksum = "sha256:1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599" +url = "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/BurntSushi/ripgrep/releases/assets/307306614" + +[tools.ripgrep."platforms.macos-arm64"] +checksum = "sha256:378e973289176ca0c6054054ee7f631a065874a352bf43f0fa60ef079b6ba715" +url = "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/BurntSushi/ripgrep/releases/assets/307305438" + +[tools.ripgrep."platforms.macos-x64"] +checksum = "sha256:64811cb24e77cac3057d6c40b63ac9becf9082eedd54ca411b475b755d334882" +url = "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/BurntSushi/ripgrep/releases/assets/307305422" + +[tools.ripgrep."platforms.windows-x64"] +checksum = "sha256:124510b94b6baa3380d051fdf4650eaa80a302c876d611e9dba0b2e18d87493a" +url = "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/BurntSushi/ripgrep/releases/assets/307305871" + [[tools.xcodegen]] version = "2.46.0" backend = "aqua:yonaskolb/XcodeGen" diff --git a/mise.toml b/mise.toml index 04824e7..a7c5b9e 100644 --- a/mise.toml +++ b/mise.toml @@ -4,6 +4,7 @@ hugo = "0.165.0" just = "1.58.0" pre-commit = "4.6.0" +ripgrep = "15.1.0" xcodegen = "2.46.0" [settings] diff --git a/website/content/explanation/_index.md b/website/content/explanation/_index.md index 5277078..08573c6 100644 --- a/website/content/explanation/_index.md +++ b/website/content/explanation/_index.md @@ -7,26 +7,33 @@ 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 | +| 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_CONFIG_DIR` honoured), `~/.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. +yours. Direct and Homebrew builds keep the [SQLite](https://sqlite.org) history database and the log under +`~/Library/Application Support/Token Menu Bar/`. The App Store build uses +`~/Library/Containers/dev.tox.token-menu-bar/Data/Library/Application Support/Token Menu Bar/`. The log records short +error snippets and leaves out tokens, request headers and response bodies. + +Environment overrides apply only when they exist in the app process. Finder and login-item launches do not load exports +from shell startup files such as `.zshrc`; Settings > Providers shows the source and path the app resolved. + +The App Store widget uses the `group.dev.tox.token-menu-bar` app group. Direct and Homebrew builds use +`.dev.tox.token-menu-bar`; the channels do not share widget snapshots. ## 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. +Claude, Codex, and Gemini rotate their [OAuth refresh token](https://datatracker.ietf.org/doc/html/rfc6749#section-6) +when they refresh. The app leaves expired tokens alone and shows a sign-in hint unless Settings > Providers enables +refresh. If the app and a running CLI refresh the same credential at once, the CLI can retain the old rotated token; its +next refresh can fail and require another sign-in. ## Rate limits diff --git a/website/content/reference/interface.md b/website/content/reference/interface.md index 3f1a68e..680e828 100644 --- a/website/content/reference/interface.md +++ b/website/content/reference/interface.md @@ -80,14 +80,17 @@ sessions and tool calls per day). ## 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` | +| Provider | Token | Endpoints | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| [Claude](https://docs.claude.com/en/docs/claude-code/overview) | Keychain item `Claude Code-credentials`, or `~/.claude/.credentials.json` (`CLAUDE_CONFIG_DIR` honoured) | `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`. + +The path variables in the table apply only when they exist in the app process. Finder and login-item launches do not +load shell startup exports; Settings > Providers shows the credential source and resolved path. diff --git a/website/content/reference/settings.md b/website/content/reference/settings.md index 3fe961c..46151d0 100644 --- a/website/content/reference/settings.md +++ b/website/content/reference/settings.md @@ -51,7 +51,14 @@ Enable or disable each provider (Claude, Codex, Gemini, Cursor, Copilot), see th 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`. +[Keychain](https://developer.apple.com/documentation/security/keychain-services) or credential file. If the app and a +running CLI refresh the same credential at once, the CLI can retain the old rotated token; its next refresh can fail and +require another sign-in. + +Each provider row shows the credential source and resolved path. `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `GEMINI_CLI_HOME`, +and `XDG_CONFIG_HOME` apply only when the app process receives them. The same rule covers `GH_TOKEN` and Gemini's +credential-storage and OAuth-client variables. Finder and login-item launches do not load exports from shell startup +files such as `.zshrc`. ## Data diff --git a/website/content/start/_index.md b/website/content/start/_index.md index d56b1b0..dc15530 100644 --- a/website/content/start/_index.md +++ b/website/content/start/_index.md @@ -7,9 +7,9 @@ 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. +Token Menu Bar needs macOS 14 or later on an Apple silicon or Intel Mac, and one signed-in client. It reads the token +that client already stored, so no password reaches this app. A provider you have not signed into stays hidden until its +token appears; Settings > Providers toggles them. | Client | Sign in with | Token lands in | | ------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------- | @@ -19,6 +19,10 @@ into stays hidden until its token appears; Settings > Providers toggles them. | [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` | +Credential path variables such as `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `GEMINI_CLI_HOME`, and `XDG_CONFIG_HOME` apply only +when the app process receives them. An export in `.zshrc` does not reach a Finder or login-item launch. Settings > +Providers shows the source and path the app resolved. + ## Get the app ### Mac App Store diff --git a/website/content/troubleshooting/_index.md b/website/content/troubleshooting/_index.md index 94b0364..4c9d108 100644 --- a/website/content/troubleshooting/_index.md +++ b/website/content/troubleshooting/_index.md @@ -22,9 +22,10 @@ The log sits next to the history database: 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. +The App Store build is sandboxed, so its copy sits under +`~/Library/Containers/dev.tox.token-menu-bar/Data/Library/Application Support/Token Menu Bar/` 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 @@ -69,6 +70,17 @@ A vendor outage, a changed response shape and a body the app cannot decode all l 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 app reads a different credential path from the CLI + +Settings > Providers shows the credential source and resolved path for each provider. `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, +`GEMINI_CLI_HOME`, and `XDG_CONFIG_HOME` apply only when the app process receives them. Finder and login-item launches +do not load exports from `.zshrc` or another shell startup file, so a CLI launched from a terminal can use a different +path from Token Menu Bar. + +Move the credential to the default path, or place the variable in the app's launch environment before starting it. For +example, `launchctl setenv CODEX_HOME /path/to/codex-home` sets `CODEX_HOME` for applications launched in the current +login session. Check the resolved path in Settings before enabling token refresh. + ## 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 From f3a61554f3943dedc236d0bb2f4d2564a06b550a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 2 Sep 2026 07:51:02 -0700 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=90=9B=20fix(app):=20apply=20the=20re?= =?UTF-8?q?view=20findings=20across=20core=20and=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review found that failures never backed off, so a revoked refresh token hit the vendor's token endpoint at the polling cadence indefinitely, and that the app rotated tokens the CLI still held, wrote refreshed credentials through symlinks as world-readable copies, and reloaded widget timelines on every poll until WidgetKit cut the widget off. On the AppKit side there was no main menu, so edit shortcuts were dead in the popover, the app kept activation after the popover closed, a status item dragged off the bar could never come back, and notifications had no delegate so banners never showed while the app was active. Failures now back off exponentially, terminal refresh errors stop refresh until the stored credential changes, keychain reads fail silently instead of prompting and cache a denial, credential files are written owner-only through resolved symlinks, and the wake path waits for the network before polling without lengthening the user's interval. Widget reloads happen only when row content changes. The app installs an edit menu, yields activation back on close, restores a hidden status item on reopen, requests notification permission provisionally at launch and fully only when the user turns notifications on, and closes the popover on app or space switches. Provider parsing keeps a malformed Claude limits entry from flipping the whole payload to the legacy shape, maps self-hosted GitHub Enterprise hosts to their /api/v3 path, carries the day precision of Copilot reset dates through to the UI, holds a suspected spurious Codex reset for one fetch, and stops fabricating a Claude spend reset from the local calendar. The HTTP layer parses HTTP-date Retry-After headers, refuses cross-host redirects, disables the URL cache for authenticated responses, and bounds slow responses with a resource timeout. Tests move their shared helpers into one TokenMenuBarTestSupport target, clean up the preference domains they create, drive the controller's delays through the injected clock instead of sleeping, and no longer mutate the shared resource bundle or spawn the real binary. Claude-Session: https://claude.ai/code/session_01CGckDFXvufWVmRH4uyE4z8 --- App/Sources/SparkleUpdater.swift | 12 +- Package.swift | 21 +- Sources/TokenMenuBar/main.swift | 7 +- .../Credentials/ClaudeCredentials.swift | 5 +- .../Credentials/CodexCredentials.swift | 11 +- .../Credentials/CopilotCredentials.swift | 3 +- .../Credentials/CredentialState.swift | 94 +++++++- .../Credentials/GeminiCredentials.swift | 7 +- .../Credentials/KeychainCredentialData.swift | 1 + .../Credentials/KeychainDenialCache.swift | 56 +++++ .../Credentials/KeychainQuery.swift | 23 ++ .../Credentials/SecureCredentialFile.swift | 18 ++ .../Credentials/SystemKeychain.swift | 10 +- Sources/TokenMenuBarCore/Formatting.swift | 15 +- Sources/TokenMenuBarCore/HTTP/APIClient.swift | 84 +++++-- .../HTTP/SystemHTTPTransport.swift | 12 +- .../History/ChartPipeline.swift | 2 +- Sources/TokenMenuBarCore/History/SQLite.swift | 2 + .../History/UsageHistoryStore.swift | 75 ++++--- Sources/TokenMenuBarCore/LaunchPolicy.swift | 6 + .../TokenMenuBarCore/Logging/LogBuffer.swift | 9 +- .../TokenMenuBarCore/Models/QuotaWindow.swift | 21 +- .../Notifications/NotificationPlanner.swift | 11 +- .../TokenMenuBarCore/PopoverGeometry.swift | 7 +- .../Presenters/HistoryPresenter.swift | 2 +- .../Presenters/UsagePresenter.swift | 2 +- Sources/TokenMenuBarCore/ProviderSetup.swift | 18 +- .../Providers/Claude/ClaudeAPI.swift | 28 +-- .../Providers/Claude/ClaudeProvider.swift | 52 ++--- .../Claude/ClaudeTranscriptReader.swift | 48 +++- .../Providers/Codex/CodexAPI.swift | 36 ++- .../Codex/CodexAnalyticsWatermarkStore.swift | 8 +- .../Providers/Codex/CodexProvider.swift | 91 +++++--- .../Providers/Codex/CodexRolloutReader.swift | 63 +++--- .../Providers/Copilot/CopilotAPI.swift | 28 ++- .../Providers/Cursor/CursorAPI.swift | 31 ++- .../Providers/Gemini/GeminiAPI.swift | 21 +- .../Providers/Gemini/GeminiOAuthConfig.swift | 13 +- .../Providers/Gemini/GeminiProvider.swift | 62 ++--- .../Providers/ProviderRegistryFactory.swift | 15 +- .../Refresh/RefreshCoordinator.swift | 131 ++++++++--- .../Refresh/SnapshotPersistence.swift | 7 +- Sources/TokenMenuBarCore/Settings.swift | 5 + .../StatusBar/StatusTemplate.swift | 4 +- .../Widgets/WidgetSnapshot.swift | 10 + .../Adapters/NotificationCenterGlue.swift | 20 ++ .../TokenMenuBarUI/Adapters/Notifier.swift | 12 +- Sources/TokenMenuBarUI/AppController.swift | 93 +++++--- Sources/TokenMenuBarUI/AppDelegate.swift | 5 +- Sources/TokenMenuBarUI/ApplicationMenu.swift | 38 ++++ Sources/TokenMenuBarUI/LiveDependencies.swift | 8 +- .../Popover/PopoverController.swift | 75 ++++++- .../ProviderMarks/ProviderMarkCatalog.swift | 9 +- .../ProviderMarkImageLoader.swift | 7 +- .../TokenMenuBarUI/SingleInstanceGuard.swift | 21 ++ .../StatusItem/StatusItemController.swift | 18 +- .../StatusItem/StatusItemRenderer.swift | 37 ++- Sources/TokenMenuBarUI/Views/LogSection.swift | 4 +- .../TokenMenuBarUI/Views/LogTextView.swift | 20 +- .../Views/ProviderCardView.swift | 2 +- .../Views/ProviderMarkView.swift | 8 +- .../TokenMenuBarUI/Views/SettingsTab.swift | 27 ++- .../TokenMenuBarUI/Views/StatusPreview.swift | 5 +- Sources/TokenMenuBarUI/Views/UsageChart.swift | 4 +- .../Views/WindowSelectionList.swift | 13 +- Sources/TokenMenuBarUI/WorkspaceGlue.swift | 6 + Sources/TokenMenuBarWidgets/UsageWidget.swift | 15 +- .../APIClientTests.swift | 112 ++++++++- .../AdaptiveWidthTests.swift | 1 + .../AnalyticsCorrectionScopeTests.swift | 1 + Tests/TokenMenuBarCoreTests/BrandTests.swift | 1 + .../ChartPipelineTests.swift | 1 + .../ClaudeMapperTests.swift | 28 ++- .../ClaudeTranscriptReaderTests.swift | 54 +++++ .../CodexAnalyticsRetentionTests.swift | 14 +- .../CodexMapperTests.swift | 10 + .../CodexRolloutReaderTests.swift | 1 + .../TokenMenuBarCoreTests/CopilotTests.swift | 42 +++- .../CoreCoverageClosureTests.swift | 13 +- .../CorrectedCoverageBehaviorTests.swift | 4 +- ...ateSettingsPresentationBehaviorTests.swift | 11 +- .../CoverageRefreshTests.swift | 3 +- .../CredentialRefreshRaceTests.swift | 7 +- .../CredentialsTests.swift | 145 ++++++++++++ Tests/TokenMenuBarCoreTests/CursorTests.swift | 22 +- .../TokenMenuBarCoreTests/DemoDataTests.swift | 1 + .../DiagnosticsTests.swift | 7 +- Tests/TokenMenuBarCoreTests/FormatTests.swift | 9 + Tests/TokenMenuBarCoreTests/GeminiTests.swift | 38 +++- .../HistoryClosureBehaviorCoverageTests.swift | 3 +- .../HistoryCoverageBehaviorTests.swift | 1 + .../HistoryPresenterAnalyticsTests.swift | 15 +- .../InterfaceTokensTests.swift | 1 + .../JSONValueTests.swift | 1 + .../LaunchPolicyTests.swift | 1 + .../LogBufferTests.swift | 1 + .../MenuCommandTests.swift | 1 + .../MissingCredentialHealthTests.swift | 1 + Tests/TokenMenuBarCoreTests/ModelTests.swift | 18 +- .../NotificationPlannerTests.swift | 6 +- .../PaceComparisonTests.swift | 1 + Tests/TokenMenuBarCoreTests/PaceTests.swift | 1 + .../PanelMaterialTests.swift | 1 + .../PresenterTests.swift | 5 +- .../ProcessPerformanceSnapshotTests.swift | 1 + .../ProviderActivationTests.swift | 14 +- .../ProviderNestedCoverageBehaviorTests.swift | 14 +- .../ProviderRegistryFactoryTests.swift | 1 + .../ProviderSetupTests.swift | 12 +- .../TokenMenuBarCoreTests/ProviderTests.swift | 91 +++++++- .../ReaderBackgroundWorkTests.swift | 9 +- .../RefreshCoordinatorTests.swift | 172 +++++++++++++- .../RefreshRejectionTests.swift | 83 +++++++ Tests/TokenMenuBarCoreTests/SQLiteTests.swift | 13 ++ .../SecurityScopedAccessTests.swift | 1 + .../SettingsPresentationTests.swift | 1 + .../TokenMenuBarCoreTests/SettingsTests.swift | 21 +- .../ShutdownPolicyTests.swift | 1 + .../SnapshotPersistenceTests.swift | 18 +- .../StatusTemplateTests.swift | 1 + .../Support/Fixtures.swift | 18 +- .../Support/MemoryCredentialStore.swift | 1 + .../Support/MemoryKeychain.swift | 1 + .../Support/ScriptedProvider.swift | 1 + .../Support/StubTransport.swift | 1 + .../TooltipPolicyTests.swift | 1 + ...ranscriptReaderCoverageBehaviorTests.swift | 14 ++ .../UsageHistoryStoreTests.swift | 61 +++++ .../WidgetSnapshotTests.swift | 28 +++ .../TokenMenuBarTestSupport/TestSupport.swift | 75 +++++++ Tests/TokenMenuBarUITests/AdapterTests.swift | 9 + .../AppControllerTests.swift | 212 +++++++++++++++--- .../ApplicationMenuTests.swift | 42 ++++ .../TokenMenuBarUITests/BrandIconTests.swift | 1 + .../ChartInteractionCoverageTests.swift | 210 ++++++++--------- .../ComponentPolicyTests.swift | 1 + .../CoverageBehaviorTests.swift | 20 +- .../CoverageClosureUIBehaviorTests.swift | 1 + .../CoverageGateComponentBehaviorTests.swift | 1 + ...overageGateProviderMarkFallbackTests.swift | 68 ++---- ...CoverageGateSettingsInteractionTests.swift | 1 + ...rageGateWindowSelectionBehaviorTests.swift | 7 +- .../DensityBudgetTests.swift | 1 + Tests/TokenMenuBarUITests/FeatureTests.swift | 8 +- .../HistoryAnalyticsRefreshTests.swift | 1 + ...toryViewClosureBehaviorCoverageTests.swift | 1 + .../InteractionTests.swift | 1 + .../LogSectionCoverageClosureTests.swift | 1 + .../LogTooltipCoverageTests.swift | 27 ++- .../PanelMaterialAdapterTests.swift | 1 + Tests/TokenMenuBarUITests/PopoverTests.swift | 54 +++-- .../ProviderMarkTests.swift | 41 +--- .../ResponsiveLayoutTests.swift | 1 + .../SettingsUIBehaviorCoverageTests.swift | 21 +- .../SingleInstanceGuardTests.swift | 66 ++++++ .../StartupClosureCoverageTests.swift | 10 +- .../StartupCoverageTests.swift | 61 +++-- .../TokenMenuBarUITests/StatusItemTests.swift | 48 ++++ .../Support/NoNetworkTransport.swift | 1 + .../Support/UIFixtures.swift | 25 ++- .../TooltipCoverageClosureTests.swift | 1 + Tests/TokenMenuBarUITests/TooltipTests.swift | 1 + Tests/TokenMenuBarUITests/ViewTests.swift | 34 ++- .../WidgetTests.swift | 16 +- 164 files changed, 3025 insertions(+), 839 deletions(-) create mode 100644 Sources/TokenMenuBarCore/Credentials/KeychainDenialCache.swift create mode 100644 Sources/TokenMenuBarCore/Credentials/KeychainQuery.swift create mode 100644 Sources/TokenMenuBarCore/Credentials/SecureCredentialFile.swift create mode 100644 Sources/TokenMenuBarUI/Adapters/NotificationCenterGlue.swift create mode 100644 Sources/TokenMenuBarUI/ApplicationMenu.swift create mode 100644 Sources/TokenMenuBarUI/SingleInstanceGuard.swift create mode 100644 Tests/TokenMenuBarCoreTests/RefreshRejectionTests.swift create mode 100644 Tests/TokenMenuBarTestSupport/TestSupport.swift create mode 100644 Tests/TokenMenuBarUITests/ApplicationMenuTests.swift create mode 100644 Tests/TokenMenuBarUITests/SingleInstanceGuardTests.swift diff --git a/App/Sources/SparkleUpdater.swift b/App/Sources/SparkleUpdater.swift index 85747f8..475835d 100644 --- a/App/Sources/SparkleUpdater.swift +++ b/App/Sources/SparkleUpdater.swift @@ -5,7 +5,9 @@ import TokenMenuBarUI @MainActor final class SparkleUpdater: UpdaterHook { private let controller = SPUStandardUpdaterController( - startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil) + private var canCheckObservation: NSKeyValueObservation? + var onCanCheckChange: (@MainActor (Bool) -> Void)? var canCheck: Bool { controller.updater.canCheckForUpdates @@ -16,6 +18,14 @@ final class SparkleUpdater: UpdaterHook { set { controller.updater.automaticallyChecksForUpdates = newValue } } + func start() { + canCheckObservation = controller.updater.observe(\.canCheckForUpdates, options: [.initial, .new]) { + [weak self] updater, _ in + Task { @MainActor [weak self] in self?.onCanCheckChange?(updater.canCheckForUpdates) } + } + controller.startUpdater() + } + func checkForUpdates() { controller.checkForUpdates(nil) } diff --git a/Package.swift b/Package.swift index 584ad5d..da8238a 100644 --- a/Package.swift +++ b/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let strict: [SwiftSetting] = [ .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), + // SwiftPM 6.0 has no typed warnings-as-errors setting; this keeps Xcode 16 support. .unsafeFlags(["-warnings-as-errors"]), ] @@ -26,13 +27,27 @@ let package = Package( ), .target(name: "TokenMenuBarWidgets", dependencies: ["TokenMenuBarCore"], swiftSettings: strict), .executableTarget(name: "TokenMenuBar", dependencies: ["TokenMenuBarUI"], swiftSettings: strict), + .target( + name: "TokenMenuBarTestSupport", + dependencies: ["TokenMenuBarCore"], + path: "Tests/TokenMenuBarTestSupport", + swiftSettings: strict + ), .testTarget( name: "TokenMenuBarCoreTests", - dependencies: ["TokenMenuBarCore"], + dependencies: ["TokenMenuBarCore", "TokenMenuBarTestSupport"], resources: [.copy("Fixtures")], swiftSettings: strict ), - .testTarget(name: "TokenMenuBarUITests", dependencies: ["TokenMenuBarUI"], swiftSettings: strict), - .testTarget(name: "TokenMenuBarWidgetsTests", dependencies: ["TokenMenuBarWidgets"], swiftSettings: strict), + .testTarget( + name: "TokenMenuBarUITests", + dependencies: ["TokenMenuBarUI", "TokenMenuBarTestSupport"], + swiftSettings: strict + ), + .testTarget( + name: "TokenMenuBarWidgetsTests", + dependencies: ["TokenMenuBarWidgets", "TokenMenuBarTestSupport"], + swiftSettings: strict + ), ] ) diff --git a/Sources/TokenMenuBar/main.swift b/Sources/TokenMenuBar/main.swift index 6472ee4..1470eca 100644 --- a/Sources/TokenMenuBar/main.swift +++ b/Sources/TokenMenuBar/main.swift @@ -15,9 +15,10 @@ if let (command, directory) = ExportCommand.parse(CommandLine.arguments) { } } -let app = NSApplication.shared let launchPolicy = LaunchPolicy() +if SingleInstanceGuard.handOff(policy: launchPolicy) { exit(0) } let appInfo = AppInfo.from(bundle: .main, distribution: .direct) +ApplicationMenu.install(on: NSApplication.shared, appName: appInfo.name) let transport: any HTTPTransport = launchPolicy.mode == .verification ? DisabledHTTPTransport() : SystemHTTPTransport.make() let keychain: KeychainCredentialClient = launchPolicy.mode == .verification ? .empty : .system @@ -40,5 +41,5 @@ let delegate = AppRunner.bootstrapDeferred( 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() +NSApplication.shared.delegate = delegate +NSApplication.shared.run() diff --git a/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift b/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift index 64dd4c7..805429d 100644 --- a/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift +++ b/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift @@ -90,7 +90,8 @@ extension ClaudeCredentialStore { ) throws -> CredentialSaveResult { let current = try loadWithSource() guard current?.credentials == expected else { - return .changed(current?.credentials, source: current?.source) + guard let current else { return .removed } + return .changed(current.credentials, source: current.source) } try save(credentials) return .saved @@ -179,7 +180,7 @@ public struct FileClaudeCredentialStore: ClaudeCredentialStore { } public func save(_ credentials: ClaudeOAuthCredentials) throws { - try JSONEncoder().encode(credentials.document).write(to: url, options: .atomic) + try writeCredentialData(JSONEncoder().encode(credentials.document), to: url) } } diff --git a/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift b/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift index 8890586..db5b6de 100644 --- a/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift +++ b/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift @@ -55,6 +55,10 @@ public struct CodexAuth: Sendable, Equatable { ISODate.parse(claims?["https://api.openai.com/auth"]?["chatgpt_subscription_active_until"]?.stringValue) } + public var supportsUsageAPI: Bool { + document["tokens"]?["access_token"]?.stringValue != nil + } + var accountFingerprint: String { let value: String if let accountID = accountID?.trimmingCharacters(in: .whitespacesAndNewlines), !accountID.isEmpty { @@ -98,7 +102,10 @@ extension CodexAuthStore { 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) } + guard current?.auth == expected else { + guard let current else { return .removed } + return .changed(current.auth, source: current.source) + } try save(auth) return .saved } @@ -150,7 +157,7 @@ public struct FileCodexAuthStore: CodexAuthStore { } public func save(_ auth: CodexAuth) throws { - try JSONEncoder().encode(auth.document).write(to: url, options: .atomic) + try writeCredentialData(JSONEncoder().encode(auth.document), to: url) } } diff --git a/Sources/TokenMenuBarCore/Credentials/CopilotCredentials.swift b/Sources/TokenMenuBarCore/Credentials/CopilotCredentials.swift index 1a9e521..3d7c700 100644 --- a/Sources/TokenMenuBarCore/Credentials/CopilotCredentials.swift +++ b/Sources/TokenMenuBarCore/Credentials/CopilotCredentials.swift @@ -202,7 +202,8 @@ public struct KeychainCopilotAuthStore: CopilotAuthStore { public func load() throws -> CopilotAuth? { var firstError: CredentialReadFailure? - for account in accounts.map(Optional.some) + [nil] { + let candidates = accounts.isEmpty ? [nil] : accounts.map(Optional.some) + for account in candidates { 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 } diff --git a/Sources/TokenMenuBarCore/Credentials/CredentialState.swift b/Sources/TokenMenuBarCore/Credentials/CredentialState.swift index 5530bc0..3f3d531 100644 --- a/Sources/TokenMenuBarCore/Credentials/CredentialState.swift +++ b/Sources/TokenMenuBarCore/Credentials/CredentialState.swift @@ -2,7 +2,8 @@ import Foundation public enum CredentialSaveResult: Sendable { case saved - case changed(Value?, source: CredentialSource?) + case changed(Value, source: CredentialSource) + case removed } struct PendingCredentialSave: Sendable { @@ -52,10 +53,13 @@ func resolveCredential( 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) + return ResolvedCredential(credential: current, source: source, issue: nil) + case .removed: + pending = nil + return ResolvedCredential(credential: nil, source: nil, issue: nil) } } catch { - let detail = credentialPersistenceDetail(error) + let detail = credentialPersistenceDetail() pending = PendingCredentialSave( credential: cached.credential, replacing: cached.replacing, source: cached.source, detail: detail) return ResolvedCredential( @@ -65,8 +69,88 @@ func resolveCredential( } } -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)" +func credentialPersistenceDetail() -> String { + "The refreshed session remains active in memory. Token Menu Bar will retry saving it on the next refresh." +} + +enum CredentialRefreshError: Error { + case api(APIError) + case missingRefreshToken + case credentialsRemoved + case oauthClientUnavailable + case invalidResponse(String) + case signInRequired(code: String) + + var message: String { + switch self { + case .api(let error): error.message + case .missingRefreshToken: "the credential has no refresh token" + case .credentialsRemoved: "the credentials were removed during refresh" + case .oauthClientUnavailable: "the Gemini CLI OAuth client could not be read from the installed CLI" + case .invalidResponse(let detail): detail + case .signInRequired(let code): "the provider rejected the stored sign-in (\(code)). Sign in with the CLI again." + } + } +} + +let terminalRefreshErrorCodes: Set = [ + "invalid_grant", "invalid_client", "unauthorized_client", "refresh_token_expired", "refresh_token_reused", + "refresh_token_invalidated", +] + +// A rejected refresh token stays rejected, so the token endpoint is left alone until the CLI writes a new credential. +struct RefreshRejection: Sendable { + private var rejected: (credential: Value, code: String)? + + func check(_ stored: Value) throws(CredentialRefreshError) { + guard let rejected, rejected.credential == stored else { return } + throw .signInRequired(code: rejected.code) + } + + mutating func failure(_ error: APIError, refreshing stored: Value) -> CredentialRefreshError { + guard case .http(let status, let body, _) = error, (400..<500).contains(status), + let code = (try? JSONDecoder().decode(JSONValue.self, from: Data(body.utf8)))?["error"]?.stringValue, + terminalRefreshErrorCodes.contains(code) + else { return .api(error) } + rejected = (stored, code) + return .signInRequired(code: code) + } +} + +func persistRefreshedCredential( + _ credential: Value, + replacing previous: Value, + source: CredentialSource, + provider: ProviderID, + pending: inout PendingCredentialSave?, + log: LogBuffer, + save: (Value, Value) throws -> CredentialSaveResult +) throws(CredentialRefreshError) -> ( + credential: Value, + source: CredentialSource, + issue: ProviderRecoveryIssue? +) { + let result: CredentialSaveResult + do { + result = try save(credential, previous) + } catch { + log.logError("\(provider.rawValue) token refreshed but could not be stored: \(error)") + let detail = credentialPersistenceDetail() + pending = PendingCredentialSave( + credential: credential, replacing: previous, source: source, detail: detail) + return (credential, source, .credentialPersistence(provider: provider, detail: detail)) + } + switch result { + case .saved: + log.log("\(provider.rawValue) token refreshed and stored") + return (credential, source, nil) + case .changed(let current, let currentSource): + log.log("\(provider.rawValue) token refresh not stored because the credential source changed") + return (current, currentSource, nil) + case .removed: + log.log("\(provider.rawValue) token refresh not stored because the credentials were removed") + throw .credentialsRemoved + } } public enum CredentialState: Sendable, Equatable { diff --git a/Sources/TokenMenuBarCore/Credentials/GeminiCredentials.swift b/Sources/TokenMenuBarCore/Credentials/GeminiCredentials.swift index caaba66..0202335 100644 --- a/Sources/TokenMenuBarCore/Credentials/GeminiCredentials.swift +++ b/Sources/TokenMenuBarCore/Credentials/GeminiCredentials.swift @@ -79,7 +79,10 @@ extension GeminiAuthStore { 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) } + guard current?.auth == expected else { + guard let current else { return .removed } + return .changed(current.auth, source: current.source) + } try save(auth) return .saved } @@ -132,7 +135,7 @@ public struct FileGeminiAuthStore: GeminiAuthStore { } public func save(_ auth: GeminiAuth) throws { - try JSONEncoder().encode(auth.document).write(to: url, options: .atomic) + try writeCredentialData(JSONEncoder().encode(auth.document), to: url) } } diff --git a/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift b/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift index 8daa185..489e678 100644 --- a/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift +++ b/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift @@ -32,4 +32,5 @@ public struct KeychainCredentialClient: Sendable { public static let empty = KeychainCredentialClient(load: { _, _ in nil }, save: { _, _, _ in }) public static let system = KeychainCredentialClient(load: systemKeychainLoad, save: systemKeychainSave) + .cachingDenials(clock: .system) } diff --git a/Sources/TokenMenuBarCore/Credentials/KeychainDenialCache.swift b/Sources/TokenMenuBarCore/Credentials/KeychainDenialCache.swift new file mode 100644 index 0000000..a7bd9ab --- /dev/null +++ b/Sources/TokenMenuBarCore/Credentials/KeychainDenialCache.swift @@ -0,0 +1,56 @@ +import Foundation +import Security + +extension KeychainCredentialClient { + public static let deniedStatuses: Set = [errSecAuthFailed, errSecUserCanceled, errSecInteractionNotAllowed] + public static let denialCacheInterval: TimeInterval = 30 * 60 + + // Claude Code recreates its item periodically, which drops an earlier "Always Allow"; without this cache a user who + // clicked Deny would be asked again on every poll. + public func cachingDenials(clock: Clock) -> KeychainCredentialClient { + let cache = KeychainDenialCache(clock: clock) + return KeychainCredentialClient( + load: { service, account in + if let status = cache.status(service: service, account: account) { throw CredentialStoreError.keychain(status) } + do { + return try load(service: service, account: account) + } catch CredentialStoreError.keychain(let status) where Self.deniedStatuses.contains(status) { + cache.record(status, service: service, account: account) + throw CredentialStoreError.keychain(status) + } + }, + save: save) + } +} + +private final class KeychainDenialCache: @unchecked Sendable { + private struct Key: Hashable { + let service: String + let account: String? + } + + private let clock: Clock + private let lock = NSLock() + private var denials: [Key: (status: OSStatus, until: Date)] = [:] + + init(clock: Clock) { + self.clock = clock + } + + func status(service: String, account: String?) -> OSStatus? { + lock.withLock { + guard let denial = denials[Key(service: service, account: account)], denial.until > clock.now() else { + return nil + } + return denial.status + } + } + + func record(_ status: OSStatus, service: String, account: String?) { + lock.withLock { + denials[Key(service: service, account: account)] = ( + status, clock.now().addingTimeInterval(KeychainCredentialClient.denialCacheInterval) + ) + } + } +} diff --git a/Sources/TokenMenuBarCore/Credentials/KeychainQuery.swift b/Sources/TokenMenuBarCore/Credentials/KeychainQuery.swift new file mode 100644 index 0000000..796ba2e --- /dev/null +++ b/Sources/TokenMenuBarCore/Credentials/KeychainQuery.swift @@ -0,0 +1,23 @@ +import Foundation +import LocalAuthentication +import Security + +// The raw value of kSecUseAuthenticationUIFail, whose symbol is deprecated in favour of the LAContext set alongside +// it; both are passed so a background read fails instead of prompting on every SDK that honours either. +let keychainAuthenticationUIFail = "u_AuthUIF" + +func systemKeychainQuery(service: String, account: String?) -> [String: Any] { + let context = LAContext() + context.interactionNotAllowed = true + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context, + kSecUseAuthenticationUI as String: keychainAuthenticationUIFail, + ] + query[kSecAttrAccount as String] = account + return query +} diff --git a/Sources/TokenMenuBarCore/Credentials/SecureCredentialFile.swift b/Sources/TokenMenuBarCore/Credentials/SecureCredentialFile.swift new file mode 100644 index 0000000..5f5df32 --- /dev/null +++ b/Sources/TokenMenuBarCore/Credentials/SecureCredentialFile.swift @@ -0,0 +1,18 @@ +import Darwin +import Foundation + +func writeCredentialData(_ data: Data, to url: URL, fileManager: FileManager = .default) throws { + let destination = url.resolvingSymlinksInPath() + let temporary = destination.deletingLastPathComponent() + .appendingPathComponent(".\(destination.lastPathComponent).\(UUID().uuidString)") + do { + try data.write(to: temporary, options: .withoutOverwriting) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) + guard rename(temporary.path, destination.path) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + } catch { + try? fileManager.removeItem(at: temporary) + throw error + } +} diff --git a/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift b/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift index c0a4b78..988b90f 100644 --- a/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift +++ b/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift @@ -2,16 +2,8 @@ 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) + let status = SecItemCopyMatching(systemKeychainQuery(service: service, account: account) 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 diff --git a/Sources/TokenMenuBarCore/Formatting.swift b/Sources/TokenMenuBarCore/Formatting.swift index f6e9b62..146d6d1 100644 --- a/Sources/TokenMenuBarCore/Formatting.swift +++ b/Sources/TokenMenuBarCore/Formatting.swift @@ -1,5 +1,10 @@ import Foundation +public enum ResetPrecision: String, Codable, Sendable, Hashable { + case instant + case day +} + 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 @@ -37,9 +42,13 @@ public enum Format { return "\(mins)m" } - public static func resetClock(_ date: Date?, now: Date, calendar: Calendar = Format.calendar) -> String { + public static func resetClock( + _ date: Date?, precision: ResetPrecision = .instant, now: Date, calendar: Calendar = Format.calendar + ) -> String { guard let date else { return "—" } guard date > now else { return "now" } + // A day-precision reset is stored as midnight UTC, so only a UTC read-back names the day the provider published. + if precision == .day { return date.formatted(Date.FormatStyle(timeZone: .gmt).month(.abbreviated).day()) } 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()) @@ -59,6 +68,10 @@ public enum Format { + suffix } + public static func currency(_ value: Double, code: String = "USD", fractionLength: Int = 2) -> String { + value.formatted(.currency(code: code).precision(.fractionLength(fractionLength))) + } + 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" } diff --git a/Sources/TokenMenuBarCore/HTTP/APIClient.swift b/Sources/TokenMenuBarCore/HTTP/APIClient.swift index b4b1947..42004e4 100644 --- a/Sources/TokenMenuBarCore/HTTP/APIClient.swift +++ b/Sources/TokenMenuBarCore/HTTP/APIClient.swift @@ -16,12 +16,21 @@ public struct DisabledHTTPTransport: HTTPTransport { public enum APIError: Error, Equatable, Sendable { case http(status: Int, body: String, retryAfter: TimeInterval?) + case vendorSecurityCheck(status: Int) case network(String) case decoding(String) public var isAuthenticationFailure: Bool { - if case .http(let status, _, _) = self { return status == 401 || status == 403 } - return false + guard case .http(let status, let body, _) = self else { return false } + if status == 401 { return true } + guard status == 403, + (try? JSONDecoder().decode(JSONValue.self, from: Data(body.utf8))) != nil + else { return false } + let normalized = body.lowercased() + return [ + "unauthenticated", "unauthorized", "authentication_required", "invalid_token", "token_expired", + "expired token", "invalid session", "session expired", "login_required", "sign in", "sign-in", + ].contains { normalized.contains($0) } } public var isRateLimited: Bool { @@ -36,7 +45,9 @@ public enum APIError: Error, Equatable, Sendable { public var message: String { switch self { + case .http(let status, _, _) where (300..<400).contains(status): "Unexpected redirect (HTTP \(status))" case .http(let status, _, _): "HTTP \(status)" + case .vendorSecurityCheck(let status): "Blocked by provider security check (HTTP \(status))" case .network(let text): "Network error: \(LogSanitizer.message(text))" case .decoding(let text): "Unexpected response: \(LogSanitizer.message(text))" } @@ -44,9 +55,9 @@ public enum APIError: Error, Equatable, Sendable { } public struct APIClient: Sendable { - static let bodySnippetLength = 200 - static let liveCacheCapacity = 4 * 1024 * 1024 + static let maximumErrorBodyBytes = 64 * 1024 public static let timeout: TimeInterval = 20 + public static let resourceTimeout: TimeInterval = 60 private let transport: any HTTPTransport private let log: LogBuffer @@ -64,11 +75,10 @@ public struct APIClient: Sendable { let configuration = URLSessionConfiguration.ephemeral configuration.httpCookieStorage = nil configuration.urlCredentialStorage = nil - configuration.urlCache = URLCache( - memoryCapacity: liveCacheCapacity, - diskCapacity: 0, - diskPath: nil) - configuration.requestCachePolicy = .useProtocolCachePolicy + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = resourceTimeout return configuration } @@ -93,7 +103,8 @@ public struct APIClient: Sendable { 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) + // Assigning queryItems, even an empty array, makes the query non-nil. + request.httpBody = Data(components.percentEncodedQuery!.replacingOccurrences(of: "+", with: "%2B").utf8) return try await send( request, method: "POST", headers: headers.merging(["Content-Type": "application/x-www-form-urlencoded"]) { $1 }, @@ -113,9 +124,8 @@ public struct APIClient: Sendable { 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)", + "decode failed operation=\(operation) \(Self.decodingDiagnostic(error))", category: .network) throw APIError.decoding("\(operation): invalid payload") } @@ -126,6 +136,7 @@ public struct APIClient: Sendable { ) async throws(APIError) -> Data { var request = base request.httpMethod = method + request.cachePolicy = .reloadIgnoringLocalCacheData request.setValue("application/json", forHTTPHeaderField: "Accept") for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } let id = String(UUID().uuidString.prefix(8)).lowercased() @@ -168,28 +179,63 @@ public struct APIClient: Sendable { 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) + if Self.isVendorSecurityCheck(status: status, response: response as? HTTPURLResponse, data: data) { + throw APIError.vendorSecurityCheck(status: status) + } + let body = String(decoding: data.prefix(Self.maximumErrorBodyBytes), as: UTF8.self) throw APIError.http( - status: status, body: snippet, retryAfter: Self.retryAfter(response as? HTTPURLResponse, data)) + status: status, body: body, + retryAfter: Self.retryAfter(response as? HTTPURLResponse, data, now: clock.now())) } return data } - static func retryAfter(_ response: HTTPURLResponse?, _ body: Data) -> TimeInterval? { - if let header = response?.value(forHTTPHeaderField: "Retry-After"), let seconds = TimeInterval(header) { - return seconds + static func retryAfter(_ response: HTTPURLResponse?, _ body: Data, now: Date = Date()) -> TimeInterval? { + if let header = response?.value(forHTTPHeaderField: "Retry-After") { + if let seconds = TimeInterval(header), seconds.isFinite, seconds >= 0 { return seconds } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss zzz" + if let date = formatter.date(from: header) { return max(date.timeIntervalSince(now), 0) } } - if let json = try? JSONDecoder().decode(JSONValue.self, from: body), let seconds = json["retry_after"]?.doubleValue + if let json = try? JSONDecoder().decode(JSONValue.self, from: body), + let seconds = json["retry_after"]?.doubleValue, seconds.isFinite { - return seconds + return max(seconds, 0) } return nil } + private static func isVendorSecurityCheck( + status: Int, response: HTTPURLResponse?, data: Data + ) -> Bool { + guard status == 403 else { return false } + if response?.value(forHTTPHeaderField: "Content-Type")?.lowercased().contains("text/html") == true { + return true + } + let body = String(decoding: data.prefix(maximumErrorBodyBytes), as: UTF8.self).lowercased() + return body.contains(" String { + LogSanitizer.message(String(describing: error)) + } + + static func redirectedRequest(from source: URL?, to request: URLRequest) -> URLRequest? { + guard let source, let destination = request.url else { return nil } + guard source.scheme?.lowercased() == destination.scheme?.lowercased(), + source.host?.lowercased() == destination.host?.lowercased(), + source.port == destination.port + else { return nil } + return request + } + static func redact(_ url: URL?) -> String { guard let url, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return "-" } components.query = nil diff --git a/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift b/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift index 5020e1a..ae78320 100644 --- a/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift +++ b/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift @@ -2,6 +2,16 @@ import Foundation public enum SystemHTTPTransport { public static func make() -> any HTTPTransport { - URLSession(configuration: APIClient.liveConfiguration()) + URLSession( + configuration: APIClient.liveConfiguration(), delegate: SameOriginRedirectDelegate(), delegateQueue: nil) + } +} + +private final class SameOriginRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession( + _: URLSession, task _: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + completionHandler(APIClient.redirectedRequest(from: response.url, to: request)) } } diff --git a/Sources/TokenMenuBarCore/History/ChartPipeline.swift b/Sources/TokenMenuBarCore/History/ChartPipeline.swift index 6df3249..b43f970 100644 --- a/Sources/TokenMenuBarCore/History/ChartPipeline.swift +++ b/Sources/TokenMenuBarCore/History/ChartPipeline.swift @@ -595,7 +595,7 @@ public enum ChartPipeline { 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 .usd: "\(Format.currency(total)) total" case .percentage: Format.percent(total) case .tokens: "\(Format.compactNumber(total)) tokens" case .credits: "\(Format.compactNumber(total)) credits" diff --git a/Sources/TokenMenuBarCore/History/SQLite.swift b/Sources/TokenMenuBarCore/History/SQLite.swift index bd6fcf5..16cb6fc 100644 --- a/Sources/TokenMenuBarCore/History/SQLite.swift +++ b/Sources/TokenMenuBarCore/History/SQLite.swift @@ -4,6 +4,7 @@ import SQLite3 public enum SQLiteError: Error, Equatable { case open(String) case prepare(String, String) + case schema(Int) case step(String, String) } @@ -60,6 +61,7 @@ public final class SQLiteDatabase: @unchecked Sendable { sqlite3_close(handle) throw SQLiteError.open(message) } + sqlite3_busy_timeout(handle, 5000) self.handle = handle } diff --git a/Sources/TokenMenuBarCore/History/UsageHistoryStore.swift b/Sources/TokenMenuBarCore/History/UsageHistoryStore.swift index c4e06d5..eb2bbaf 100644 --- a/Sources/TokenMenuBarCore/History/UsageHistoryStore.swift +++ b/Sources/TokenMenuBarCore/History/UsageHistoryStore.swift @@ -65,6 +65,7 @@ public actor UsageHistoryStore { public static let retention: TimeInterval = TimeInterval(defaultRetentionDays) * 86400 public static let sampleInterval: TimeInterval = 300 static let changeThreshold: Double = 5 + static let schemaVersion = 1 let database: SQLiteDatabase private var lastRecorded: [WindowKey: UsageSample] = [:] @@ -79,33 +80,10 @@ public actor UsageHistoryStore { try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) } database = try SQLiteDatabase(path: url?.path ?? ":memory:") + // SQLite ignores this once the file has pages, so it must precede the WAL switch to take effect on a new file. + try database.execute("PRAGMA auto_vacuum = INCREMENTAL") 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)") + try Self.migrate(database) let latest = try database.query( "SELECT key, MAX(ts), used, resets_at FROM samples GROUP BY key" ) { row -> UsageSample? in @@ -174,6 +152,7 @@ public actor UsageHistoryStore { let points = totals.map { key, value in AnalyticsPoint(day: key.day, metric: key.metric, series: key.series, value: value) } + try pruneIfNeeded(now: analytics.fetchedAt) try database.withTransaction { if let fingerprint = analytics.accountFingerprint { let stored = try database.query( @@ -204,7 +183,6 @@ public actor UsageHistoryStore { .text(point.series), .real(point.value), ] }) - try database.execute("DELETE FROM analytics WHERE day < ?", [.text(cutoff)]) } return points.count } @@ -474,7 +452,9 @@ public actor UsageHistoryStore { 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) + let result = HistoryPruneResult(samples: samples, analytics: database.changes) + if result.total > 0 { try database.execute("PRAGMA incremental_vacuum(256)") } + return result } retentionDays = clamped lastRecorded = lastRecorded.filter { $0.value.timestamp >= cutoff } @@ -495,6 +475,45 @@ public actor UsageHistoryStore { DayStamp.string(now.addingTimeInterval(-TimeInterval(max(retentionDays - 1, 0)) * 86400)) } + private static func migrate(_ database: SQLiteDatabase) throws { + let version = try database.query("PRAGMA user_version") { $0.int(0) }.first! + guard version <= schemaVersion else { throw SQLiteError.schema(version) } + if version == 0 { try migrateToVersion1(database) } + } + + private static func migrateToVersion1(_ database: SQLiteDatabase) throws { + try database.withTransaction { + 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)") + try database.execute("CREATE INDEX IF NOT EXISTS analytics_day ON analytics (day)") + try database.execute("PRAGMA user_version = 1") + } + } + private static func offsetSegments( from start: Date, to end: Date, timeZone: TimeZone, includesEnd: Bool ) -> [OffsetSegment] { diff --git a/Sources/TokenMenuBarCore/LaunchPolicy.swift b/Sources/TokenMenuBarCore/LaunchPolicy.swift index 507b95b..e0f52d3 100644 --- a/Sources/TokenMenuBarCore/LaunchPolicy.swift +++ b/Sources/TokenMenuBarCore/LaunchPolicy.swift @@ -37,6 +37,7 @@ public struct LaunchPolicy: Equatable, Sendable { } public static let verificationArgument = "--verify-ui" + public static let relaunchedArgument = "--relaunched" 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" @@ -51,6 +52,9 @@ public struct LaunchPolicy: Equatable, Sendable { public let defaultsSuiteName: String? public let supportDirectory: URL? public let verificationProfile: VerificationProfile? + /// Verification runs sit beside an installed copy on purpose, and a relaunch starts its replacement while this + /// instance is still winding down, so neither hands off to the instance already running. + public let yieldsToRunningInstance: Bool public init( arguments: [String] = CommandLine.arguments, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -63,6 +67,7 @@ public struct LaunchPolicy: Equatable, Sendable { defaultsSuiteName = nil supportDirectory = nil verificationProfile = nil + yieldsToRunningInstance = !arguments.contains(Self.relaunchedArgument) return } @@ -78,6 +83,7 @@ public struct LaunchPolicy: Equatable, Sendable { URL(fileURLWithPath: $0, isDirectory: true) } ?? temporaryDirectory.appendingPathComponent("token-menu-bar-verify-\(session)", isDirectory: true) verificationProfile = VerificationProfile(environment: environment) + yieldsToRunningInstance = false } public func defaults(standard: UserDefaults = .standard) -> UserDefaults { diff --git a/Sources/TokenMenuBarCore/Logging/LogBuffer.swift b/Sources/TokenMenuBarCore/Logging/LogBuffer.swift index 413134f..99f0399 100644 --- a/Sources/TokenMenuBarCore/Logging/LogBuffer.swift +++ b/Sources/TokenMenuBarCore/Logging/LogBuffer.swift @@ -221,6 +221,7 @@ public final class LogBuffer: @unchecked Sendable { private let fileURL: URL? private let maximumFileBytes: Int private let maximumFileCount: Int + public let retainedEntryLimit: 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] = [] @@ -236,12 +237,14 @@ public final class LogBuffer: @unchecked Sendable { fileURL: URL?, clock: Clock = .system, maximumFileBytes: Int = LogBuffer.fileByteLimit, - maximumFileCount: Int = LogBuffer.retainedFileCount + maximumFileCount: Int = LogBuffer.retainedFileCount, + retainedEntryLimit: Int = LogBuffer.retainedEntryLimit ) { self.fileURL = fileURL self.clock = clock self.maximumFileBytes = max(maximumFileBytes, 1) self.maximumFileCount = max(maximumFileCount, 1) + self.retainedEntryLimit = max(retainedEntryLimit, 1) guard let fileURL else { return } let now = clock.now() let stored = Self.load( @@ -249,7 +252,7 @@ public final class LogBuffer: @unchecked Sendable { count: self.maximumFileCount, after: now.addingTimeInterval(-Self.retention), maximumFileBytes: self.maximumFileBytes, - maximumEntries: Self.retainedEntryLimit) + maximumEntries: self.retainedEntryLimit) entries = Array(stored.entries.suffix(Self.capacity)) lastPrune = now guard stored.droppedEntries else { return } @@ -348,7 +351,7 @@ public final class LogBuffer: @unchecked Sendable { count: maximumFileCount, after: clock.now().addingTimeInterval(-Self.retention), maximumFileBytes: maximumFileBytes, - maximumEntries: Self.retainedEntryLimit) + maximumEntries: retainedEntryLimit) continuation.resume(returning: Self.align(result.entries, with: snapshot)) } } diff --git a/Sources/TokenMenuBarCore/Models/QuotaWindow.swift b/Sources/TokenMenuBarCore/Models/QuotaWindow.swift index c53aefd..e67fc4c 100644 --- a/Sources/TokenMenuBarCore/Models/QuotaWindow.swift +++ b/Sources/TokenMenuBarCore/Models/QuotaWindow.swift @@ -48,6 +48,7 @@ public struct QuotaWindow: Codable, Sendable, Hashable, Identifiable { public let severity: Severity public let isActive: Bool public let scope: String? + public let resetPrecision: ResetPrecision public init( id: String, @@ -58,7 +59,8 @@ public struct QuotaWindow: Codable, Sendable, Hashable, Identifiable { duration: TimeInterval? = nil, severity: Severity? = nil, isActive: Bool = true, - scope: String? = nil + scope: String? = nil, + resetPrecision: ResetPrecision = .instant ) { self.id = id self.label = label @@ -69,6 +71,23 @@ public struct QuotaWindow: Codable, Sendable, Hashable, Identifiable { self.severity = severity ?? Severity(percent: usedPercent) self.isActive = isActive self.scope = scope + self.resetPrecision = resetPrecision + } + + // Snapshots cached before the precision existed decode as instants. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + id: try container.decode(String.self, forKey: .id), + label: try container.decode(String.self, forKey: .label), + group: try container.decode(WindowGroup.self, forKey: .group), + usedPercent: try container.decode(Double.self, forKey: .usedPercent), + resetsAt: try container.decodeIfPresent(Date.self, forKey: .resetsAt), + duration: try container.decodeIfPresent(TimeInterval.self, forKey: .duration), + severity: try container.decode(Severity.self, forKey: .severity), + isActive: try container.decode(Bool.self, forKey: .isActive), + scope: try container.decodeIfPresent(String.self, forKey: .scope), + resetPrecision: try container.decodeIfPresent(ResetPrecision.self, forKey: .resetPrecision) ?? .instant) } public var remainingPercent: Double { diff --git a/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift b/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift index 03ee8b4..81d581c 100644 --- a/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift +++ b/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift @@ -55,7 +55,7 @@ public enum NotificationPlanner { provider: ProviderID, settings: NotificationSettings, credentialMissing: Bool = false, - now: Date + now _: Date ) -> [NotificationEvent] { guard settings.enabled else { return [] } var events: [NotificationEvent] = [] @@ -63,7 +63,7 @@ public enum NotificationPlanner { if currentAvailability == .authenticationRequired { events.append( NotificationEvent( - id: "\(provider.rawValue):auth:\(Int(now.timeIntervalSince1970))", kind: .authentication, + id: "\(provider.rawValue):auth", kind: .authentication, provider: provider, title: "\(provider.displayName) sign-in needed", body: provider.loginHint)) } } @@ -74,7 +74,7 @@ public enum NotificationPlanner { if previous.credits?.hasCredits == true, current.credits?.hasCredits == false { events.append( NotificationEvent( - id: "\(provider.rawValue):credits:\(Int(now.timeIntervalSince1970))", kind: .credits, provider: provider, + id: "\(provider.rawValue):credits", kind: .credits, provider: provider, title: "\(provider.displayName) credits depleted", body: "Usage credits ran out; plan limits now apply.")) } } @@ -88,7 +88,10 @@ public enum NotificationPlanner { 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))." } ?? "" + let resets = + window.resetsAt.map { + " Resets \(Format.resetClock($0, precision: window.resetPrecision, now: current.fetchedAt))." + } ?? "" return [ NotificationEvent( id: diff --git a/Sources/TokenMenuBarCore/PopoverGeometry.swift b/Sources/TokenMenuBarCore/PopoverGeometry.swift index 1dc0253..f1ac50f 100644 --- a/Sources/TokenMenuBarCore/PopoverGeometry.swift +++ b/Sources/TokenMenuBarCore/PopoverGeometry.swift @@ -3,7 +3,6 @@ import Foundation public enum PopoverDismissalTrigger: Sendable, Equatable { case mouseDown - case mouseMoved case keyEscape } @@ -19,11 +18,7 @@ public struct PopoverDismissalGate: Sendable, Equatable { 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 - } + return true } } diff --git a/Sources/TokenMenuBarCore/Presenters/HistoryPresenter.swift b/Sources/TokenMenuBarCore/Presenters/HistoryPresenter.swift index 8ecb51b..bdfcf4f 100644 --- a/Sources/TokenMenuBarCore/Presenters/HistoryPresenter.swift +++ b/Sources/TokenMenuBarCore/Presenters/HistoryPresenter.swift @@ -635,7 +635,7 @@ public final class HistoryPresenter { switch unit { case .percentage: Format.percent(value) case .tokens, .credits, .count: Format.compactNumber(value) - case .usd: "$\(value.formatted(.number.precision(.fractionLength(2))))" + case .usd: Format.currency(value) } } diff --git a/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift b/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift index ae2a12f..5e45716 100644 --- a/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift +++ b/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift @@ -228,7 +228,7 @@ public enum UsagePresenter { window: window, pace: pace, countdown: Format.countdown(to: window.resetsAt, now: now), - resetClock: Format.resetClock(window.resetsAt, now: now), + resetClock: Format.resetClock(window.resetsAt, precision: window.resetPrecision, now: now), detail: detail(window), paceLabel: pace.status.title, paceText: pace.summary(now: now), diff --git a/Sources/TokenMenuBarCore/ProviderSetup.swift b/Sources/TokenMenuBarCore/ProviderSetup.swift index 682d2be..bc904a5 100644 --- a/Sources/TokenMenuBarCore/ProviderSetup.swift +++ b/Sources/TokenMenuBarCore/ProviderSetup.swift @@ -1,4 +1,5 @@ import Foundation +import Security public struct CredentialSource: Sendable, Equatable, Hashable, Identifiable { public let id: String @@ -27,13 +28,28 @@ public struct CredentialReadFailure: Error, Sendable, Equatable, CustomStringCon if let failure = error as? CredentialReadFailure { self = failure } else { - self.init(source: source, detail: String(describing: error)) + self.init(source: source, detail: credentialReadDetail(error)) } } public var description: String { detail } } +private func credentialReadDetail(_ error: any Error) -> String { + switch error { + case CredentialStoreError.keychain(let status) where KeychainCredentialClient.deniedStatuses.contains(status): + "Keychain access denied; allow it in Keychain Access or sign in again." + case CredentialStoreError.keychain: + "The Keychain item could not be read." + case CredentialStoreError.malformed(let detail): + detail + case is CocoaError: + "The credential file could not be read." + default: + "The credential source could not be read." + } +} + public enum ProviderCredentialHealth: Sendable, Equatable { case unchecked case missing(expected: [CredentialSource]) diff --git a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeAPI.swift b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeAPI.swift index 7d90076..39947b5 100644 --- a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeAPI.swift +++ b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeAPI.swift @@ -121,6 +121,8 @@ enum ClaudeAPI { ] let limits: [Limit] + let limitsWereSupplied: Bool + let droppedLimitCount: Int let windows: [String: Window] let spend: Spend? let extraUsage: ExtraUsage? @@ -135,7 +137,13 @@ enum ClaudeAPI { 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") ?? [] + let limitFields = fields["limits"]?.arrayValue ?? [] + limitsWereSupplied = !limitFields.isEmpty + limits = limitFields.compactMap { field in + guard let data = try? JSONEncoder().encode(field) else { return nil } + return try? decoder.decode(Limit.self, from: data) + } + droppedLimitCount = limitFields.count - limits.count spend = decode(Spend.self, "spend") extraUsage = decode(ExtraUsage.self, "extra_usage") var windows: [String: Window] = [:] @@ -206,7 +214,7 @@ enum ClaudeMapper { static let weeklyDuration: TimeInterval = 7 * 86400 static func windows(_ response: ClaudeAPI.UsageResponse) -> [QuotaWindow] { - if !response.limits.isEmpty { return response.limits.map(window) } + if response.limitsWereSupplied { 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) @@ -267,9 +275,7 @@ enum ClaudeMapper { ) } - static func spend( - _ response: ClaudeAPI.UsageResponse, now: Date, calendar: Calendar = .current - ) -> SpendControl? { + static func spend(_ response: ClaudeAPI.UsageResponse) -> SpendControl? { let extra = response.extraUsage let spend = response.spend guard extra != nil || spend != nil else { return nil } @@ -302,7 +308,7 @@ enum ClaudeMapper { used: used, limit: limit, percent: percent, - resetsAt: nextMonthStart(after: now, calendar: calendar), + resetsAt: nil, limitReached: extra?.spendLimitReached ?? false, disabledReason: enabled ? nil : (spend?.disabledReason ?? extra?.disabledReason), balance: spend?.balance?.money(defaultCurrency: currency), @@ -311,11 +317,6 @@ enum ClaudeMapper { ) } - 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 { @@ -345,14 +346,15 @@ enum ClaudeMapper { ) } - static func notices(_ response: ClaudeAPI.UsageResponse) -> [Notice] { + static func notices(_ response: ClaudeAPI.UsageResponse, now: Date) -> [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 }) { + let reset = ISODate.parse(limit.resetsAt).map { Format.resetClock($0, now: now) } ?? "later" notices.append( - Notice(kind: .limitReached, text: "\(window(limit).label) limit reached; resets \(limit.resetsAt ?? "later").")) + Notice(kind: .limitReached, text: "\(window(limit).label) limit reached; resets \(reset).")) } return notices } diff --git a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift index 62691d1..b4f8759 100644 --- a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift +++ b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift @@ -13,6 +13,7 @@ public actor ClaudeProvider: UsageProvider { private let allowRefresh: @MainActor @Sendable () -> Bool private var cachedProfile: (fingerprint: String, profile: ClaudeAPI.ProfileResponse, at: Date)? private var pendingCredentialSave: PendingCredentialSave? + private var refreshRejection = RefreshRejection() public init( credentials: any ClaudeCredentialStore, @@ -39,7 +40,7 @@ public actor ClaudeProvider: UsageProvider { guard let stored = try credentials.load() else { return .missing("no Claude Code sign-in found") } return stored.state(now: now) } catch { - return .missing("\(error)") + return .missing(CredentialReadFailure(source: credentials.source, error: error).detail) } } @@ -67,7 +68,8 @@ public actor ClaudeProvider: UsageProvider { .withCredentialStatus(.missing("no Claude Code sign-in found", provider: id)) } } catch { - return ProviderFetchResult(outcome: .notAuthenticated("Cannot read Claude credentials: \(error)")) + let failure = CredentialReadFailure(source: credentials.source, error: error) + return ProviderFetchResult(outcome: .notAuthenticated("Cannot read Claude credentials: \(failure.detail)")) .withCredentialStatus(.unreadable(error, provider: id, fallbackSource: credentials.source)) } let stored = resolved.credential! @@ -112,14 +114,18 @@ public actor ClaudeProvider: UsageProvider { let transcript = await transcripts?.refresh(now: now, retentionDays: options.analyticsDays) switch usageResult { case .success(let response): + if response.droppedLimitCount > 0 { + let noun = response.droppedLimitCount == 1 ? "limit" : "limits" + warnings.append("Skipped \(response.droppedLimitCount) malformed Claude usage \(noun).") + } 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), + spend: ClaudeMapper.spend(response), + notices: ClaudeMapper.notices(response, now: now), localUsage: transcript?.localUsage( windowResetsAt: session?.resetsAt, windowDuration: ClaudeMapper.sessionDuration, now: now), fetchedAt: now @@ -169,42 +175,30 @@ public actor ClaudeProvider: UsageProvider { _ stored: ClaudeOAuthCredentials, source: CredentialSource, now: Date - ) async throws(APIError) -> ( + ) async throws(CredentialRefreshError) -> ( credential: ClaudeOAuthCredentials, source: CredentialSource, issue: ProviderRecoveryIssue? ) { guard let refreshToken = stored.refreshToken else { - throw APIError.http(status: 401, body: "no refresh token", retryAfter: nil) + throw .missingRefreshToken } + try refreshRejection.check(stored) 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 + let token: ClaudeAPI.TokenResponse do { - saveResult = try credentials.save(refreshed, replacing: stored) + let data = try await client.post( + ClaudeAPI.tokenURL, json: body, headers: ["User-Agent": ClaudeAPI.userAgent], operation: "claude.refresh") + token = try client.decode(ClaudeAPI.TokenResponse.self, data, operation: "claude.refresh") } 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)) + throw refreshRejection.failure(error, refreshing: stored) } - 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) + let refreshed = stored.refreshed( + accessToken: token.accessToken, refreshToken: token.refreshToken, expiresIn: token.expiresIn ?? 3600, now: now) + return try persistRefreshedCredential( + refreshed, replacing: stored, source: source, provider: id, pending: &pendingCredentialSave, log: log, + save: { try credentials.save($0, replacing: $1) }) } } diff --git a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift index 8c8d616..a39c6a9 100644 --- a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift +++ b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift @@ -199,6 +199,28 @@ fileprivate struct ClaudeTranscriptState: Codable, Sendable { var seenByDay: [String: Set] = [:] var days: [String: DayAggregate] = [:] var recent: [String: RecentAggregate] = [:] + var activeRetentionDays = UsageHistoryStore.defaultRetentionDays + + private enum CodingKeys: String, CodingKey { + case offsets + case seenByDay + case days + case recent + case activeRetentionDays + } + + init() {} + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + offsets = try container.decode([String: TranscriptOffset].self, forKey: .offsets) + seenByDay = try container.decode([String: Set].self, forKey: .seenByDay) + days = try container.decode([String: DayAggregate].self, forKey: .days) + recent = try container.decode([String: RecentAggregate].self, forKey: .recent) + activeRetentionDays = + try container.decodeIfPresent(Int.self, forKey: .activeRetentionDays) + ?? UsageHistoryStore.defaultRetentionDays + } @discardableResult mutating func ingest(_ message: TranscriptMessage) -> Bool { @@ -383,6 +405,7 @@ public actor ClaudeTranscriptReader { private let backgroundWorkDelay: TimeInterval private let maximumLineBytes: Int private let maximumRetainedPartialBytes: Int + private let seek: @Sendable (FileHandle, UInt64) throws -> Void private let readChunk: @Sendable (FileHandle, Int) throws -> Data? private var state = ClaudeTranscriptState() private var indexedFiles: [String: TranscriptFile] = [:] @@ -405,7 +428,6 @@ public actor ClaudeTranscriptReader { 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 @@ -423,6 +445,9 @@ public actor ClaudeTranscriptReader { backgroundWorkDelay: TimeInterval = defaultBackgroundWorkDelay, maximumLineBytes: Int = defaultMaximumLineBytes, maximumRetainedPartialBytes: Int = defaultMaximumRetainedPartialBytes, + seek: @escaping @Sendable (FileHandle, UInt64) throws -> Void = { handle, offset in + try handle.seek(toOffset: offset) + }, readChunk: @escaping @Sendable (FileHandle, Int) throws -> Data? = { handle, count in try handle.read(upToCount: count) } @@ -438,6 +463,7 @@ public actor ClaudeTranscriptReader { self.backgroundWorkDelay = max(backgroundWorkDelay, 0.001) self.maximumLineBytes = max(maximumLineBytes, 1) self.maximumRetainedPartialBytes = max(maximumRetainedPartialBytes, self.maximumLineBytes) + self.seek = seek self.readChunk = readChunk } @@ -461,14 +487,14 @@ public actor ClaudeTranscriptReader { ) async -> ClaudeTranscriptSnapshot { await loadState() let retentionDays = min(max(retentionDays, 7), 365) - if retentionDays != activeRetentionDays { - if retentionDays > activeRetentionDays { + if retentionDays != state.activeRetentionDays { + if retentionDays > state.activeRetentionDays { state.offsets = state.offsets.mapValues { TranscriptOffset(bytes: 0, scanGeneration: $0.scanGeneration) } - markDirty() } - activeRetentionDays = retentionDays + state.activeRetentionDays = retentionDays + markDirty() scan = nil hasScanned = false pendingTails.removeAll(keepingCapacity: true) @@ -677,7 +703,15 @@ public actor ClaudeTranscriptReader { 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)) + do { + try seek(handle, UInt64(partial.cursor)) + } catch { + partialReads.removeValue(forKey: key) + guard partial.cursor > 0 else { return .failed } + state.offsets[key] = TranscriptOffset(bytes: 0, scanGeneration: generation) + return TranscriptIngestResult( + changed: storedOffset != 0, bytesRead: 0, succeeded: false, complete: false) + } let clock = ContinuousClock() var bytesRead = 0 var aggregateChanged = false @@ -802,7 +836,7 @@ public actor ClaudeTranscriptReader { checkpointInFlightRevision = revision workload.checkpointAttempts += 1 let writtenBytes = await Task.detached(priority: .utility) { () -> Int? in - let data = try! JSONEncoder().encode(snapshot) + guard let data = try? JSONEncoder().encode(snapshot) else { return nil } guard (try? FileManager.default.createDirectory( at: stateURL.deletingLastPathComponent(), withIntermediateDirectories: true)) != nil, diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift index 298418d..84a4db2 100644 --- a/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift +++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift @@ -5,9 +5,6 @@ enum CodexAPI { 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") } @@ -308,8 +305,7 @@ enum CodexMapper { ) } - static func resetCredits(_ summary: CodexAPI.ResetCreditsSummary?) -> ResetCredits? { - guard let summary else { return nil } + static func resetCredits(_ summary: CodexAPI.ResetCreditsSummary) -> ResetCredits { return ResetCredits( available: summary.availableCount ?? 0, applicable: summary.applicableAvailableCount ?? summary.availableCount ?? 0, @@ -318,6 +314,10 @@ enum CodexMapper { ) } + static func resetCredits(_ summary: CodexAPI.ResetCreditsSummary?) -> ResetCredits? { + summary.map(resetCredits) + } + static func notices(_ response: CodexAPI.UsageResponse) -> [Notice] { var notices: [Notice] = [] if let reached = response.rateLimitReachedType, !reached.isNull { @@ -425,14 +425,28 @@ enum CodexMapper { } } - static func creditEvents(_ rows: [JSONValue]) -> [CreditEvent] { - rows.enumerated().compactMap { index, row in + static func creditEventMapping(_ rows: [JSONValue]) -> CreditEventMapping { + var events: [CreditEvent] = [] + var missingAmountCount = 0 + for (index, row) in rows.enumerated() { 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 + guard let date = dateText.flatMap({ ISODate.parse($0) ?? DayStamp.date($0) }) else { continue } + guard + let used = row["credits_used"]?.doubleValue ?? row["credits"]?.doubleValue ?? row["amount"]?.doubleValue + else { + missingAmountCount += 1 + continue + } let service = row["service"]?.stringValue ?? row["product"]?.stringValue ?? "Codex" - return CreditEvent( - id: row["id"]?.stringValue ?? "\(dateText!)-\(index)", date: date, service: service, creditsUsed: used) + events.append( + CreditEvent( + id: row["id"]?.stringValue ?? "\(dateText!)-\(index)", date: date, service: service, creditsUsed: used)) } + return CreditEventMapping(events: events, missingAmountCount: missingAmountCount) } } + +struct CreditEventMapping { + let events: [CreditEvent] + let missingAmountCount: Int +} diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexAnalyticsWatermarkStore.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexAnalyticsWatermarkStore.swift index 5a1727f..935a374 100644 --- a/Sources/TokenMenuBarCore/Providers/Codex/CodexAnalyticsWatermarkStore.swift +++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexAnalyticsWatermarkStore.swift @@ -1,6 +1,7 @@ import Foundation public struct CodexAnalyticsWatermarkPersistence: @unchecked Sendable { + public static let storageKey = "codexAnalyticsWatermarks" public static let standard = CodexAnalyticsWatermarkPersistence(defaults: .standard) let defaults: UserDefaults @@ -16,7 +17,6 @@ struct CodexAnalyticsCoverage: Codable, Equatable { } struct CodexAnalyticsWatermarkStore { - static let storageKey = "codexAnalyticsWatermarks" static let maximumAccounts = 4 let persistence: CodexAnalyticsWatermarkPersistence @@ -46,7 +46,9 @@ struct CodexAnalyticsWatermarkStore { } private func decoded(now: Date) -> (StoredState, Bool) { - guard let data = persistence.defaults.data(forKey: Self.storageKey) else { return (StoredState(), false) } + guard let data = persistence.defaults.data(forKey: CodexAnalyticsWatermarkPersistence.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) @@ -95,7 +97,7 @@ struct CodexAnalyticsWatermarkStore { } private func write(_ state: StoredState) { - persistence.defaults.set(try! JSONEncoder().encode(state), forKey: Self.storageKey) + persistence.defaults.set(try! JSONEncoder().encode(state), forKey: CodexAnalyticsWatermarkPersistence.storageKey) } } diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift index 6272c47..60dc937 100644 --- a/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift +++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift @@ -14,9 +14,12 @@ public actor CodexProvider: UsageProvider { private let analyticsWatermarks: CodexAnalyticsWatermarkStore private var activeAccount: String? private var resetCreditsCache: CachedResetCredits? - private var resetCreditsTask: Task, Never>? + private var resetCreditsTask: Task, Never>? private var analyticsCoverage: [CodexAPI.Analytics: CodexAnalyticsCoverage] = [:] + private var acceptedWindows: [String: QuotaWindow] = [:] + private var pendingResets: Set = [] private var pendingCredentialSave: PendingCredentialSave? + private var refreshRejection = RefreshRejection() public init( auth: any CodexAuthStore, @@ -43,7 +46,7 @@ public actor CodexProvider: UsageProvider { guard let stored = try auth.load() else { return .missing("no Codex sign-in found") } return stored.state(now: now) } catch { - return .missing("\(error)") + return .missing(CredentialReadFailure(source: auth.source, error: error).detail) } } @@ -70,14 +73,21 @@ public actor CodexProvider: UsageProvider { credentialStatus: .missing("no Codex sign-in found", provider: id)) } } catch { + let failure = CredentialReadFailure(source: auth.source, error: error) return await fallback( - reason: "Cannot read Codex credentials: \(error)", auth: nil, now: now, notAuthenticated: true, + reason: "Cannot read Codex credentials: \(failure.detail)", 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! + guard stored.supportsUsageAPI else { + return await fallback( + reason: "Codex API-key sign-in has no usage endpoint. Sign in with the Codex CLI to view usage.", + auth: stored, now: now, notAuthenticated: true, recoveryIssue: recoveryIssue, + credentialStatus: .resolved(stored.state(now: now), provider: id, source: activeSource)) + } if case .expired = stored.state(now: now) { guard await allowRefresh() else { return await fallback( @@ -125,7 +135,7 @@ public actor CodexProvider: UsageProvider { let snapshot = ProviderSnapshot( provider: .codex, identity: CodexMapper.identity(response, auth: active), - windows: CodexMapper.windows(response), + windows: confirmResets(CodexMapper.windows(response), now: now), credits: CodexMapper.credits(response.credits), spend: CodexMapper.spend(response.spendControl), resetCredits: resetCredits, @@ -166,7 +176,7 @@ public actor CodexProvider: UsageProvider { return (cached.value ?? inline, cached.warning) } let stale = resetCreditsCache?.value ?? inline - let task: Task, Never> + let task: Task, Never> if activeAccount == account, let current = resetCreditsTask { task = current } else { @@ -177,12 +187,11 @@ public actor CodexProvider: UsageProvider { 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)) + value: value, warning: nil, expiresAt: now.addingTimeInterval(Self.resetCreditsTTL)) } - return (resolved, nil) + return (value, nil) case .failure(let error): let warning = "Reset credits unavailable: \(error.message)" if activeAccount == account { @@ -247,7 +256,12 @@ public actor CodexProvider: UsageProvider { 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 } + let mapping = CodexMapper.creditEventMapping(rows.data) + events = mapping.events.filter { $0.date >= cutoffDate && $0.date < endDate } + if mapping.missingAmountCount > 0 { + let noun = mapping.missingAmountCount == 1 ? "event" : "events" + warnings.append("Skipped \(mapping.missingAmountCount) Codex credit \(noun) without a credit amount.") + } case .failure(let error): warnings.append("Credit usage history unavailable: \(error.message)") } guard !points.isEmpty || !events.isEmpty || !coveredScopes.isEmpty else { return (nil, warnings) } @@ -263,6 +277,21 @@ public actor CodexProvider: UsageProvider { ) } + // wham/usage occasionally answers with a freshly reset window that the next call contradicts, so a drop to zero + // ahead of the known reset time is held back until a second fetch agrees. + private func confirmResets(_ windows: [QuotaWindow], now: Date) -> [QuotaWindow] { + let confirmed = windows.map { window -> QuotaWindow in + let wasPending = pendingResets.remove(window.id) != nil + guard !wasPending, let previous = acceptedWindows[window.id], window.usedPercent < 1, previous.usedPercent >= 1, + let resetsAt = previous.resetsAt, resetsAt > now + else { return window } + pendingResets.insert(window.id) + return previous + } + acceptedWindows = confirmed.reduce(into: [:]) { $0[$1.id] = $1 } + return confirmed + } + private func analyticsStart(endpoint: CodexAPI.Analytics, fallback: String) -> String { guard let coverage = analyticsCoverage[endpoint], coverage.start <= fallback, let date = DayStamp.date(coverage.through) @@ -277,6 +306,8 @@ public actor CodexProvider: UsageProvider { activeAccount = account resetCreditsCache = nil resetCreditsTask = nil + acceptedWindows = [:] + pendingResets = [] analyticsCoverage = analyticsWatermarks.load(account: account, now: now, retentionDays: retentionDays) } @@ -292,7 +323,7 @@ public actor CodexProvider: UsageProvider { private static func fetchResetCredits( _ client: APIClient, headers: [String: String] - ) async -> Result { + ) async -> Result { do { let summary = try await client.getJSON( CodexAPI.ResetCreditsSummary.self, @@ -342,45 +373,33 @@ public actor CodexProvider: UsageProvider { _ stored: CodexAuth, source: CredentialSource, now: Date - ) async throws(APIError) -> ( + ) async throws(CredentialRefreshError) -> ( credential: CodexAuth, source: CredentialSource, issue: ProviderRecoveryIssue? ) { guard let refreshToken = stored.refreshToken else { - throw APIError.http(status: 401, body: "no refresh token", retryAfter: nil) + throw .missingRefreshToken } + try refreshRejection.check(stored) 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 + let token: CodexAPI.TokenResponse do { - saveResult = try auth.save(refreshed, replacing: stored) + let data = try await client.post(CodexAPI.tokenURL, json: body, headers: [:], operation: "codex.refresh") + token = try client.decode(CodexAPI.TokenResponse.self, data, operation: "codex.refresh") } 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)) + throw refreshRejection.failure(error, refreshing: stored) } - 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) + guard let accessToken = token.accessToken else { + throw .invalidResponse(token.error ?? "refresh returned no access token") } - return (refreshed, source, nil) + let refreshed = stored.refreshed( + accessToken: accessToken, refreshToken: token.refreshToken, idToken: token.idToken, now: now) + return try persistRefreshedCredential( + refreshed, replacing: stored, source: source, provider: id, pending: &pendingCredentialSave, log: log, + save: { try auth.save($0, replacing: $1) }) } } diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift index 1c9f1f8..986e688 100644 --- a/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift +++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift @@ -19,14 +19,31 @@ private struct RolloutFile: Sendable, Equatable { let modified: Date } -private struct RolloutCandidateCache { - var files: [RolloutFile] +private struct RolloutCache { + let files: [RolloutFile] let expiresAt: Date + var state: State + + enum State { + case candidates + case searched(CodexRolloutReader.Reading?) + } + + var hasSearchResult: Bool { + if case .searched = state { return true } + return false + } + + var reading: CodexRolloutReader.Reading? { + guard case .searched(let reading) = state else { return nil } + return reading + } } private struct RolloutSearch { let files: [RolloutFile] let referenceNow: Date + let expiresAt: Date var fileIndex = 0 var current: ReverseRolloutRead? } @@ -60,8 +77,7 @@ public actor CodexRolloutReader { private let workEntryBudget: Int private let workTimeBudget: TimeInterval private let backgroundWorkDelay: TimeInterval - private var cache: Cache? - private var candidateCache: RolloutCandidateCache? + private var cache: RolloutCache? private var treeScan: RolloutTreeScan? private var search: RolloutSearch? private var backgroundTask: Task? @@ -100,23 +116,20 @@ public actor CodexRolloutReader { } func latest(now: Date = Date()) -> Reading? { - if let cache, cache.expiresAt > now { + if let cache, cache.expiresAt > now, cache.hasSearchResult { 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) + self.cache = RolloutCache(files: refreshed, expiresAt: cache.expiresAt, state: .candidates) + search = RolloutSearch(files: refreshed, referenceNow: now, expiresAt: cache.expiresAt) } 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) + if let cache, cache.expiresAt > now { + search = RolloutSearch(files: cache.files, referenceNow: now, expiresAt: cache.expiresAt) } else { beginTreeScan(now: now) } @@ -167,10 +180,10 @@ public actor CodexRolloutReader { 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)) + let expiresAt = active.referenceNow.addingTimeInterval(cacheInterval) + cache = RolloutCache(files: files, expiresAt: expiresAt, state: .candidates) treeScan = nil - search = RolloutSearch(files: files, referenceNow: active.referenceNow) + search = RolloutSearch(files: files, referenceNow: active.referenceNow, expiresAt: expiresAt) return } workload.treeEntriesExamined += 1 @@ -231,8 +244,7 @@ public actor CodexRolloutReader { if found != nil { break } } if let found { - cache = Cache( - reading: found, files: active.files, expiresAt: active.referenceNow.addingTimeInterval(cacheInterval)) + cache = RolloutCache(files: active.files, expiresAt: active.expiresAt, state: .searched(found)) workload.searchesCompleted += 1 search = nil return @@ -242,8 +254,7 @@ public actor CodexRolloutReader { 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)) + cache = RolloutCache(files: active.files, expiresAt: active.expiresAt, state: .searched(reading)) workload.searchesCompleted += 1 search = nil return @@ -255,8 +266,7 @@ public actor CodexRolloutReader { } } if active.fileIndex >= active.files.count { - cache = Cache( - reading: nil, files: active.files, expiresAt: active.referenceNow.addingTimeInterval(cacheInterval)) + cache = RolloutCache(files: active.files, expiresAt: active.expiresAt, state: .searched(nil)) workload.searchesCompleted += 1 search = nil } else { @@ -302,7 +312,6 @@ public actor CodexRolloutReader { backgroundTask?.cancel() backgroundTask = nil cache = nil - candidateCache = nil treeScan = nil search = nil beginTreeScan(now: Date()) @@ -312,7 +321,7 @@ public actor CodexRolloutReader { try? await ContinuousClock().sleep(for: .seconds(backgroundWorkDelay)) } } - return candidateCache?.files.map(\.url) ?? [] + return cache?.files.map(\.url) ?? [] } private func insertCandidate(_ file: RolloutFile, into candidates: inout [RolloutFile]) { @@ -354,7 +363,7 @@ public actor CodexRolloutReader { ) } let credits = limits["credits"].flatMap { value -> CodexAPI.Credits? in - let data = try! JSONEncoder().encode(value) + guard let data = try? JSONEncoder().encode(value) else { return nil } return try? JSONDecoder().decode(CodexAPI.Credits.self, from: data) } let observed = json["timestamp"]?.stringValue.flatMap { ISODate.parse($0) } @@ -385,9 +394,3 @@ public actor CodexRolloutReader { } } } - -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 index 504e2d6..05a7311 100644 --- a/Sources/TokenMenuBarCore/Providers/Copilot/CopilotAPI.swift +++ b/Sources/TokenMenuBarCore/Providers/Copilot/CopilotAPI.swift @@ -5,8 +5,12 @@ enum CopilotAPI { 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")! + if host == "github.com" { return URL(string: "https://api.github.com/copilot_internal/user")! } + if host.hasSuffix(".ghe.com") { + let apiHost = host.hasPrefix("api.") ? host : "api.\(host)" + return URL(string: "https://\(apiHost)/copilot_internal/user")! + } + return URL(string: "https://\(host)/api/v3/copilot_internal/user")! } static func headers(token: String) -> [String: String] { @@ -21,7 +25,7 @@ enum CopilotAPI { enum CopilotMapper { static func windows(_ user: JSONValue) -> [QuotaWindow] { - let resetsAt = date(user["quota_reset_date"]?.stringValue) + let cycleReset = reset(user["quota_reset_date"]?.stringValue) var windows: [QuotaWindow] = [] if let snapshots = user["quota_snapshots"]?.objectValue { let ordered = @@ -30,11 +34,13 @@ enum CopilotMapper { 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)) + QuotaWindow( + id: key, label: label(key), group: .monthly, usedPercent: percent, resetsAt: cycleReset?.date, + resetPrecision: cycleReset?.precision ?? .instant)) } } if let limited = user["limited_user_quotas"]?.objectValue, let monthly = user["monthly_quotas"]?.objectValue { - let freeReset = date(user["limited_user_reset_date"]?.stringValue) ?? resetsAt + let freeReset = reset(user["limited_user_reset_date"]?.stringValue) ?? cycleReset for key in limited.keys.sorted() { guard let remaining = limited[key]?.doubleValue, let limit = monthly[key]?.doubleValue, limit > 0 else { continue @@ -42,7 +48,7 @@ enum CopilotMapper { windows.append( QuotaWindow( id: "free:\(key)", label: label(key), group: .monthly, usedPercent: (1 - remaining / limit) * 100, - resetsAt: freeReset)) + resetsAt: freeReset?.date, resetPrecision: freeReset?.precision ?? .instant)) } } return windows @@ -68,9 +74,15 @@ enum CopilotMapper { } } - static func date(_ text: String?) -> Date? { + struct Reset { + let date: Date + let precision: ResetPrecision + } + + static func reset(_ text: String?) -> Reset? { guard let text else { return nil } - return ISODate.parse(text) ?? DayStamp.date(text) + if let instant = ISODate.parse(text) { return Reset(date: instant, precision: .instant) } + return DayStamp.date(text).map { Reset(date: $0, precision: .day) } } static func identity(_ user: JSONValue, auth: CopilotAuth) -> ProviderIdentity { diff --git a/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift b/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift index f8d431e..5c6a514 100644 --- a/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift +++ b/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift @@ -23,17 +23,18 @@ enum CursorAPI { let apiPercentUsed: Double? let totalPercentUsed: Double? - var percentUsed: Double? { + var aggregatePercentUsed: 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 } + + var componentPercents: [(id: String, label: String, percent: Double)] { + var components: [(id: String, label: String, percent: Double)] = [] + if let autoPercentUsed { components.append(("auto", "Auto", autoPercentUsed)) } + if let apiPercentUsed { components.append(("api", "API", apiPercentUsed)) } + return components + } } struct IndividualUsage: Decodable, Sendable, Equatable { @@ -83,10 +84,18 @@ enum CursorMapper { 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) - ) + guard let bucket, bucket.enabled != false else { return } + if let percent = bucket.aggregatePercentUsed { + windows.append( + QuotaWindow( + id: id, label: label, group: .monthly, usedPercent: percent, resetsAt: resetsAt, duration: duration)) + return + } + windows += bucket.componentPercents.map { component in + QuotaWindow( + id: "\(id):\(component.id)", label: "\(label) (\(component.label))", group: .monthly, + usedPercent: component.percent, resetsAt: resetsAt, duration: duration) + } } add("plan", "Plan usage", summary.individualUsage?.plan) if summary.individualUsage?.onDemand?.limit ?? 0 > 0 { diff --git a/Sources/TokenMenuBarCore/Providers/Gemini/GeminiAPI.swift b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiAPI.swift index 7ccb5a4..85f55c1 100644 --- a/Sources/TokenMenuBarCore/Providers/Gemini/GeminiAPI.swift +++ b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiAPI.swift @@ -9,13 +9,29 @@ enum GeminiAPI { Standard or Enterprise accounts still report quota. """ + #if arch(arm64) + static let architecture = "arm64" + #elseif arch(x86_64) + static let architecture = "x64" + #endif + 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)"] + ["Authorization": "Bearer \(token)", "User-Agent": "GeminiCLI/token-menu-bar (darwin; \(architecture); cli)"] + } + + static func isSubscriptionRequired(_ body: String) -> Bool { + guard let data = body.data(using: .utf8), let document = try? JSONDecoder().decode(JSONValue.self, from: data) + else { return body.uppercased().contains("SUBSCRIPTION_REQUIRED") } + let error = document["error"] ?? document + if error["status"]?.stringValue?.uppercased() == "SUBSCRIPTION_REQUIRED" { return true } + return error["details"]?.arrayValue?.contains { + $0["reason"]?.stringValue?.uppercased() == "SUBSCRIPTION_REQUIRED" + } == true } static func quotaBody(project: String?) -> Data { @@ -125,8 +141,7 @@ enum GeminiMapper { 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) + usedPercent: (1 - bucket.remainingFraction!) * 100, resetsAt: ISODate.parse(bucket.resetTime), scope: model) } } diff --git a/Sources/TokenMenuBarCore/Providers/Gemini/GeminiOAuthConfig.swift b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiOAuthConfig.swift index 1211559..3eeff47 100644 --- a/Sources/TokenMenuBarCore/Providers/Gemini/GeminiOAuthConfig.swift +++ b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiOAuthConfig.swift @@ -29,14 +29,21 @@ public enum GeminiOAuthConfig { return roots } + nonisolated(unsafe) private static let clientIDPattern = pattern(for: "OAUTH_CLIENT_ID") + nonisolated(unsafe) private static let clientSecretPattern = pattern(for: "OAUTH_CLIENT_SECRET") + 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) + guard let id = value(matching: clientIDPattern, in: source), + let secret = value(matching: clientSecretPattern, 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*['\"]([^'\"]+)['\"]") + private static func pattern(for name: String) -> Regex { + try! Regex("\(name)\\s*=\\s*['\"]([^'\"]+)['\"]") + } + + private static func value(matching pattern: Regex, in source: String) -> String? { guard let match = try? pattern.firstMatch(in: source), let range = match[1].range else { return nil } return String(source[range]) } diff --git a/Sources/TokenMenuBarCore/Providers/Gemini/GeminiProvider.swift b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiProvider.swift index ff9f390..97223e9 100644 --- a/Sources/TokenMenuBarCore/Providers/Gemini/GeminiProvider.swift +++ b/Sources/TokenMenuBarCore/Providers/Gemini/GeminiProvider.swift @@ -10,6 +10,7 @@ public actor GeminiProvider: UsageProvider { private let oauthClient: @Sendable () -> GeminiOAuthClient? private var cachedAssist: (fingerprint: String, assist: GeminiAPI.LoadCodeAssistResponse, at: Date)? private var pendingCredentialSave: PendingCredentialSave? + private var refreshRejection = RefreshRejection() public init( auth: any GeminiAuthStore, @@ -34,7 +35,7 @@ public actor GeminiProvider: UsageProvider { guard let stored = try auth.load() else { return .missing("no Gemini CLI sign-in found") } return stored.state(now: now) } catch { - return .missing("\(error)") + return .missing(CredentialReadFailure(source: auth.source, error: error).detail) } } @@ -60,7 +61,8 @@ public actor GeminiProvider: UsageProvider { .withCredentialStatus(.missing("no Gemini CLI sign-in found", provider: id)) } } catch { - return ProviderFetchResult(outcome: .notAuthenticated("Cannot read Gemini credentials: \(error)")) + let failure = CredentialReadFailure(source: auth.source, error: error) + return ProviderFetchResult(outcome: .notAuthenticated("Cannot read Gemini credentials: \(failure.detail)")) .withCredentialStatus(.unreadable(error, provider: id, fallbackSource: auth.source)) } let stored = resolved.credential! @@ -116,8 +118,7 @@ public actor GeminiProvider: UsageProvider { 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") - { + if case .http(let status, let body, _) = error, status == 403, GeminiAPI.isSubscriptionRequired(body) { return ProviderFetchResult( outcome: .notAuthenticated(GeminiAPI.unsupportedClientMessage), recoveryIssue: .unsupportedAccount(provider: id, detail: GeminiAPI.unsupportedClientMessage) @@ -158,17 +159,18 @@ public actor GeminiProvider: UsageProvider { _ stored: GeminiAuth, source: CredentialSource, now: Date - ) async throws(GeminiRefreshError) -> ( + ) async throws(CredentialRefreshError) -> ( credential: GeminiAuth, source: CredentialSource, issue: ProviderRecoveryIssue? ) { guard let refreshToken = stored.refreshToken else { - throw GeminiRefreshError.noRefreshToken + throw .missingRefreshToken } guard let oauth = oauthClient() else { - throw GeminiRefreshError.oauthClientUnavailable + throw .oauthClientUnavailable } + try refreshRejection.check(stored) let token: GeminiAPI.TokenResponse do { let data = try await client.post( @@ -179,51 +181,15 @@ public actor GeminiProvider: UsageProvider { ], headers: [:], operation: "gemini.refresh") token = try client.decode(GeminiAPI.TokenResponse.self, data, operation: "gemini.refresh") } catch { - throw GeminiRefreshError.api(error) + throw refreshRejection.failure(error, refreshing: stored) } 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)) + throw .invalidResponse(token.errorDescription ?? token.error ?? "refresh returned no access token") } 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" - } + return try persistRefreshedCredential( + refreshed, replacing: stored, source: source, provider: id, pending: &pendingCredentialSave, log: log, + save: { try auth.save($0, replacing: $1) }) } } diff --git a/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift b/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift index ddffaaa..b12f594 100644 --- a/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift +++ b/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift @@ -12,6 +12,8 @@ public enum ProviderRegistryFactory { public let enabledProviders: Set public let keychain: KeychainCredentialClient public let allowTokenRefresh: @MainActor @Sendable () -> Bool + public let allowsGeminiTokenRefresh: Bool + public let analyticsWatermarkPersistence: CodexAnalyticsWatermarkPersistence public init( home: URL, @@ -23,7 +25,9 @@ public enum ProviderRegistryFactory { resourceLeases: [SecurityScopedResourceLease] = [], enabledProviders: Set, keychain: KeychainCredentialClient, - allowTokenRefresh: @escaping @MainActor @Sendable () -> Bool + allowTokenRefresh: @escaping @MainActor @Sendable () -> Bool, + allowsGeminiTokenRefresh: Bool = true, + analyticsWatermarkPersistence: CodexAnalyticsWatermarkPersistence = .standard ) { self.home = home self.supportDirectory = supportDirectory @@ -35,6 +39,8 @@ public enum ProviderRegistryFactory { self.enabledProviders = enabledProviders self.keychain = keychain self.allowTokenRefresh = allowTokenRefresh + self.allowsGeminiTokenRefresh = allowsGeminiTokenRefresh + self.analyticsWatermarkPersistence = analyticsWatermarkPersistence } fileprivate func url(for resource: SandboxResource) -> URL { @@ -95,7 +101,8 @@ public enum ProviderRegistryFactory { rollouts: CodexRolloutReader(sessionsRoot: codexHome.appendingPathComponent("sessions")), client: client, log: log, - allowRefresh: configuration.allowTokenRefresh + allowRefresh: configuration.allowTokenRefresh, + analyticsWatermarkPersistence: configuration.analyticsWatermarkPersistence ) let geminiHome = configuration.url(for: ProviderID.gemini.sandboxResources[0]) @@ -113,7 +120,9 @@ public enum ProviderRegistryFactory { auth: geminiAuth, client: client, log: log, - allowRefresh: configuration.allowTokenRefresh, + allowRefresh: { + configuration.allowsGeminiTokenRefresh && configuration.allowTokenRefresh() + }, oauthClient: { GeminiOAuthConfig.resolve(environment: configuration.environment, home: configuration.home) } ) diff --git a/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift b/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift index 536d65b..1de7431 100644 --- a/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift +++ b/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift @@ -100,6 +100,7 @@ public final class RefreshCoordinator { public static let minimumBackoff: TimeInterval = 60 public static let maximumBackoff: TimeInterval = 1800 public static let networkBackoff: TimeInterval = 60 + public static let wakeDelay: TimeInterval = 3 public private(set) var registry: ProviderRegistry private let settings: Settings @@ -114,8 +115,12 @@ public final class RefreshCoordinator { private var activeGeneration: Int? private var pending: RefreshRequest? private var rateLimitStrikes: [ProviderID: Int] = [:] + private var failureStrikes: [ProviderID: Int] = [:] + private var notifiedSignIns: [ProviderID: CredentialState] = [:] + private var staggerPending: Set = [] private var refreshGeneration = 0 private var registryGeneration = 0 + private var scheduleObservationGeneration = 0 private var lastWidget: WidgetSnapshot? private let persistence: SnapshotPersistence private var cacheSubmissionTask: Task? @@ -175,12 +180,23 @@ public final class RefreshCoordinator { } public func start() { + start(after: 0) + } + + public func resume() { + start(after: Self.wakeDelay) + } + + private func start(after delay: TimeInterval) { guard loop == nil else { return } - observeScheduleInputs() - loop = makeLoop() + scheduleObservationGeneration &+= 1 + let generation = scheduleObservationGeneration + loop = makeLoop(initialDelay: delay) + observeScheduleInputs(generation: generation) } public func stop() { + scheduleObservationGeneration &+= 1 loop?.cancel() loop = nil refreshRun?.task.cancel() @@ -225,6 +241,8 @@ public final class RefreshCoordinator { state.applySetupStates(registry.setupStates) state.removeProviders(removed) rateLimitStrikes = rateLimitStrikes.filter { registry[$0.key] != nil } + failureStrikes = failureStrikes.filter { registry[$0.key] != nil } + notifiedSignIns = notifiedSignIns.filter { registry[$0.key] != nil } reschedule() } @@ -243,13 +261,16 @@ public final class RefreshCoordinator { let usage = providerState.lastAttempt.map { $0.addingTimeInterval( - provider.pollingPolicy.interval( - active: state.popoverVisible, requested: TimeInterval(settings.refreshInterval(for: provider.id)))) + scheduledInterval( + provider.pollingPolicy.interval( + active: state.popoverVisible, requested: TimeInterval(settings.refreshInterval(for: provider.id))), + for: provider.id)) } ?? now guard provider.supportsAnalytics else { return usage } let analytics = providerState.lastAnalyticsAttempt.map { - $0.addingTimeInterval(TimeInterval(settings.analyticsRefreshMinutes * 60)) + $0.addingTimeInterval( + scheduledInterval(TimeInterval(settings.analyticsRefreshMinutes * 60), for: provider.id)) } ?? now return min(usage, analytics) }.min() @@ -277,9 +298,17 @@ public final class RefreshCoordinator { } } - private func makeLoop() -> Task { + private func makeLoop(initialDelay: TimeInterval = 0) -> Task { Task { [weak self] in guard let self else { return } + if initialDelay > 0 { + state.setNextRefresh(clock.now().addingTimeInterval(initialDelay)) + do { + try await clock.sleep(initialDelay) + } catch { + return + } + } while !Task.isCancelled { await refresh(RefreshRequest()) guard !Task.isCancelled, let deadline = nextRefreshDate() else { break } @@ -293,7 +322,7 @@ public final class RefreshCoordinator { } } - private func observeScheduleInputs() { + private func observeScheduleInputs(generation: Int) { withObservationTracking { _ = state.popoverVisible _ = settings.enabledProviders @@ -302,8 +331,8 @@ public final class RefreshCoordinator { _ = settings.analyticsRefreshMinutes } onChange: { [weak self] in Task { @MainActor [weak self] in - guard let self, self.loop != nil else { return } - self.observeScheduleInputs() + guard let self, self.loop != nil, self.scheduleObservationGeneration == generation else { return } + self.observeScheduleInputs(generation: generation) self.reschedule() } } @@ -460,15 +489,19 @@ public final class RefreshCoordinator { let retryDue = providerState.retryNotBefore != nil let interval = provider.pollingPolicy.interval( active: state.popoverVisible, requested: TimeInterval(settings.refreshInterval(for: provider.id))) + let usageInterval = + request.reason == .scheduled ? scheduledInterval(interval, for: provider.id) : interval + let analyticsInterval = TimeInterval(settings.analyticsRefreshMinutes * 60) let usageDue = request.usage != .skip - && (retryDue || due(request.usage, lastAttempt: providerState.lastAttempt, interval: interval, now: now)) + && (retryDue || due(request.usage, lastAttempt: providerState.lastAttempt, interval: usageInterval, now: now)) let analyticsDue = provider.supportsAnalytics && due( request.analytics, lastAttempt: providerState.lastAnalyticsAttempt, - interval: TimeInterval(settings.analyticsRefreshMinutes * 60), + interval: request.reason == .scheduled + ? scheduledInterval(analyticsInterval, for: provider.id) : analyticsInterval, now: now) guard usageDue || analyticsDue else { return nil } return ProviderRefreshPlan(provider: provider, includeAnalytics: analyticsDue, retryInterval: interval) @@ -498,6 +531,15 @@ public final class RefreshCoordinator { } } + // Every provider's first attempt shares one timestamp, so the second is pushed out by a provider-specific phase; + // after that each keeps the user's interval and the offsets persist through the differing attempt times. + private func scheduledInterval(_ interval: TimeInterval, for id: ProviderID) -> TimeInterval { + guard !state.popoverVisible, registry.providers.count > 1, staggerPending.contains(id), + let index = ProviderID.allCases.firstIndex(of: id) + else { return interval } + return interval * (1 + Double(index) * 0.02) + } + public func nextAttempt(for id: ProviderID) -> Date? { state.state(for: id).retryNotBefore } @@ -509,7 +551,18 @@ public final class RefreshCoordinator { ) -> TimeInterval { let strikes = (rateLimitStrikes[id] ?? 0) + 1 rateLimitStrikes[id] = strikes - let base = max(max(retryAfter ?? Self.rateLimitBackoff, Self.minimumBackoff), retryInterval) + let base = max(max(Self.rateLimitBackoff, Self.minimumBackoff), retryInterval) + let localBackoff = min(base * pow(2, Double(strikes - 1)), Self.maximumBackoff) + return max(retryAfter ?? 0, localBackoff) + } + + func failureBlock( + for id: ProviderID, + retryInterval: TimeInterval = RefreshCoordinator.minimumBackoff + ) -> TimeInterval { + let strikes = (failureStrikes[id] ?? 0) + 1 + failureStrikes[id] = strikes + let base = max(max(Self.networkBackoff, Self.minimumBackoff), retryInterval) return min(base * pow(2, Double(strikes - 1)), Self.maximumBackoff) } @@ -531,6 +584,7 @@ public final class RefreshCoordinator { let previous = state.state(for: id) var next = previous next.isRefreshing = false + if previous.lastAttempt == nil { staggerPending.insert(id) } else { staggerPending.remove(id) } next.lastAttempt = now if includedAnalytics { next.lastAnalyticsAttempt = now } next.warnings = result.warnings @@ -547,32 +601,39 @@ public final class RefreshCoordinator { next.lastSuccess = now next.retryNotBefore = nil rateLimitStrikes[id] = nil + failureStrikes[id] = nil + notifiedSignIns[id] = nil samplesChanged = await record(snapshot, now: now) case .partial(let snapshot, let reason): + rateLimitStrikes[id] = nil 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)) + next.retryNotBefore = now.addingTimeInterval(failureBlock(for: id, retryInterval: retryInterval)) case .notAuthenticated(let reason): + rateLimitStrikes[id] = nil next.availability = .authenticationRequired next.lastError = reason - next.retryNotBefore = now.addingTimeInterval(max(Self.networkBackoff, retryInterval)) + next.retryNotBefore = now.addingTimeInterval(failureBlock(for: id, retryInterval: retryInterval)) case .networkUnavailable(let reason): + rateLimitStrikes[id] = nil next.availability = .networkUnavailable next.lastError = reason - next.retryNotBefore = now.addingTimeInterval(max(Self.networkBackoff, retryInterval)) + next.retryNotBefore = now.addingTimeInterval(failureBlock(for: id, retryInterval: retryInterval)) case .rateLimited(let reason, let retryAfter): + failureStrikes[id] = nil 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): + rateLimitStrikes[id] = nil next.availability = .unavailable next.lastError = reason - next.retryNotBefore = now.addingTimeInterval(max(Self.networkBackoff, retryInterval)) + next.retryNotBefore = now.addingTimeInterval(failureBlock(for: id, retryInterval: retryInterval)) } if let analytics = result.analytics { next.analytics = previous.analytics?.merging(analytics, retentionDays: settings.historyRetentionDays) ?? analytics @@ -599,24 +660,36 @@ public final class RefreshCoordinator { "refresh provider=\(id.rawValue) outcome=\(next.availability.rawValue) " + "windows=\(next.snapshot?.windows.count ?? 0)" ) + let 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) + : [] 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) - : [], + events: signInNotifiedOnce(events, for: id, credential: credentialStatus.state), samplesChanged: samplesChanged, analyticsChanged: analyticsChanged ) } + // Availability flaps (sign-in needed, stale, sign-in needed) would otherwise re-announce the same dead credential. + private func signInNotifiedOnce( + _ events: [NotificationEvent], for id: ProviderID, credential: CredentialState + ) -> [NotificationEvent] { + guard events.contains(where: { $0.kind == .authentication }) else { return events } + guard notifiedSignIns[id] != credential else { return events.filter { $0.kind != .authentication } } + notifiedSignIns[id] = credential + return events + } + private func record(_ snapshot: ProviderSnapshot, now: Date) async -> Bool { do { return try await history.record(snapshot, now: now) > 0 @@ -627,7 +700,7 @@ public final class RefreshCoordinator { } private func storedAnalytics(_ id: ProviderID, now: Date) async -> ProviderAnalytics? { - let start = DayStamp.string(now.addingTimeInterval(-60 * 86400)) + let start = DayStamp.string(now.addingTimeInterval(-TimeInterval(settings.historyRetentionDays) * 86400)) guard let points = try? await history.analytics(provider: id, from: start, to: DayStamp.string(now)), !points.isEmpty else { return nil } @@ -666,7 +739,7 @@ public final class RefreshCoordinator { let widget = WidgetSnapshot.build( snapshots: snapshots, availability: availability, selectedKeys: selection, now: snapshots.values.map(\.fetchedAt).max() ?? input.now) - if widget != lastWidget { + if widget.shouldPublish(after: lastWidget) { lastWidget = widget widgetSink?(widget) } diff --git a/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift b/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift index c35ffc2..86d8a5b 100644 --- a/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift +++ b/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift @@ -147,6 +147,7 @@ public actor SnapshotPersistence { while let snapshot = pendingWidget { pendingWidget = nil guard snapshot != lastWrittenWidget else { continue } + let shouldReload = lastWrittenWidget?.hasSameContent(as: snapshot) != true let failure = await Task.detached(priority: .utility) { () -> String? in do { try widgetStore.write(snapshot) @@ -160,8 +161,10 @@ public actor SnapshotPersistence { } else { lastWrittenWidget = snapshot workload.widgetWrites += 1 - await reloadWidgets() - workload.widgetReloads += 1 + if shouldReload { + await reloadWidgets() + workload.widgetReloads += 1 + } } } widgetDrainTask = nil diff --git a/Sources/TokenMenuBarCore/Settings.swift b/Sources/TokenMenuBarCore/Settings.swift index 5478b95..21bc83e 100644 --- a/Sources/TokenMenuBarCore/Settings.swift +++ b/Sources/TokenMenuBarCore/Settings.swift @@ -211,6 +211,10 @@ public final class Settings { accessBookmarks[resource.id] } + public var codexAnalyticsWatermarkPersistence: CodexAnalyticsWatermarkPersistence { + CodexAnalyticsWatermarkPersistence(defaults: defaults) + } + public func setBookmark(_ data: Data, for resource: SandboxResource) { accessBookmarks[resource.id] = data } @@ -232,6 +236,7 @@ public final class Settings { shortLabelStoreTask = nil let activeTab = lastTab for key in Key.allCases { defaults.removeObject(forKey: key.rawValue) } + defaults.removeObject(forKey: CodexAnalyticsWatermarkPersistence.storageKey) let fresh = Settings(defaults: defaults) loading = true defer { diff --git a/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift b/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift index 1ab8f95..cfd4f92 100644 --- a/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift +++ b/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift @@ -195,7 +195,9 @@ public enum StatusTemplate { 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) + return StatusRun( + text: Format.resetClock(context.window.resetsAt, precision: context.window.resetPrecision, 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 diff --git a/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift b/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift index 3eb8367..64c25fc 100644 --- a/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift +++ b/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift @@ -30,6 +30,7 @@ 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 let refreshInterval: TimeInterval = 900 public static func appGroup(info: [String: Any]?) -> String { guard let configured = info?[appGroupInfoKey] as? String, !configured.isEmpty, !configured.hasPrefix("$(") else { @@ -83,6 +84,15 @@ public struct WidgetSnapshot: Codable, Sendable, Hashable { updatedAt != .distantPast } + public func hasSameContent(as other: WidgetSnapshot) -> Bool { + rows == other.rows && attention == other.attention + } + + public func shouldPublish(after previous: WidgetSnapshot?) -> Bool { + guard let previous else { return true } + return !hasSameContent(as: previous) || updatedAt.timeIntervalSince(previous.updatedAt) >= Self.refreshInterval + } + public var isStale: Bool { Date().timeIntervalSince(updatedAt) > 3600 } diff --git a/Sources/TokenMenuBarUI/Adapters/NotificationCenterGlue.swift b/Sources/TokenMenuBarUI/Adapters/NotificationCenterGlue.swift new file mode 100644 index 0000000..1d7a453 --- /dev/null +++ b/Sources/TokenMenuBarUI/Adapters/NotificationCenterGlue.swift @@ -0,0 +1,20 @@ +import UserNotifications + +@MainActor +final class NotificationResponseDelegate: NSObject, UNUserNotificationCenterDelegate { + var onResponse: (() -> Void)? + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [.banner, .sound] + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + await MainActor.run { onResponse?() } + } +} diff --git a/Sources/TokenMenuBarUI/Adapters/Notifier.swift b/Sources/TokenMenuBarUI/Adapters/Notifier.swift index 47ca7d8..78bd58c 100644 --- a/Sources/TokenMenuBarUI/Adapters/Notifier.swift +++ b/Sources/TokenMenuBarUI/Adapters/Notifier.swift @@ -6,6 +6,7 @@ public protocol NotificationCenterProtocol: AnyObject, Sendable { func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool func add(_ request: UNNotificationRequest) async throws func removeDeliveredNotifications(withIdentifiers identifiers: [String]) + var delegate: (any UNUserNotificationCenterDelegate)? { get set } } extension UNUserNotificationCenter: @retroactive @unchecked Sendable {} @@ -15,6 +16,7 @@ extension UNUserNotificationCenter: NotificationCenterProtocol {} public final class Notifier { private let center: (any NotificationCenterProtocol)? private let log: LogBuffer + private let responseDelegate = NotificationResponseDelegate() 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 @@ -26,12 +28,18 @@ public final class Notifier { public init(center: (any NotificationCenterProtocol)?, log: LogBuffer) { self.center = center self.log = log + center?.delegate = responseDelegate } - public func requestAuthorization() async { + public var onResponse: (() -> Void)? { + didSet { responseDelegate.onResponse = onResponse } + } + + public func requestAuthorization(provisional: Bool = false) async { guard let center else { return } do { - authorized = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + let options: UNAuthorizationOptions = provisional ? [.alert, .sound, .provisional] : [.alert, .sound] + authorized = try await center.requestAuthorization(options: options) log.logDebug("notifications authorized=\(authorized)") } catch { log.logError("notification authorization failed: \(error.localizedDescription)") diff --git a/Sources/TokenMenuBarUI/AppController.swift b/Sources/TokenMenuBarUI/AppController.swift index 4d6608e..7f963eb 100644 --- a/Sources/TokenMenuBarUI/AppController.swift +++ b/Sources/TokenMenuBarUI/AppController.swift @@ -2,24 +2,12 @@ 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 } + var onCanCheckChange: (@MainActor (Bool) -> Void)? { get set } + func start() func checkForUpdates() } @@ -143,6 +131,7 @@ public final class AppController { public let coordinator: RefreshCoordinator public private(set) var statusItem: StatusItemController? private var stopped = false + private var launchPopoverTask: Task? public private(set) var popover: PopoverController? private var logWindow: LogWindowController? private var workspaceObservers: [Any] = [] @@ -160,6 +149,7 @@ public final class AppController { private var providerGeneration = 0 private var providerRediscoveryGeneration = 0 private var providerRediscoveryPolicy = ProviderRediscoveryPolicy() + private var notificationsEnabled: Bool private let initialStatusItem: NSStatusItem? public init(dependencies: AppDependencies, initialStatusItem: NSStatusItem? = nil) { @@ -167,6 +157,7 @@ public final class AppController { self.initialStatusItem = initialStatusItem registry = dependencies.registry appliedRetentionDays = dependencies.settings.historyRetentionDays + notificationsEnabled = dependencies.settings.notifications.enabled let notifier = dependencies.notifier let state = dependencies.state coordinator = RefreshCoordinator( @@ -257,7 +248,7 @@ public final class AppController { } item.onClick = { [weak self] in self?.togglePopover() } item.onCountdownTick = { [weak self] in self?.coordinator.rebuildStatus() } - item.menuProvider = AppControllerMenuSource(self).menu + item.menuProvider = menuTarget.menu item.adaptive = dependencies.settings.adaptiveWidth item.detailedLoggingEnabled = dependencies.settings.detailedLogging item.update(ladder: dependencies.state.statusLadder) @@ -278,25 +269,38 @@ public final class AppController { } self.popover = popover installObservers() - dependencies.updater?.automaticallyChecks = dependencies.settings.automaticUpdates + if let updater = dependencies.updater { + updater.automaticallyChecks = dependencies.settings.automaticUpdates + updater.onCanCheckChange = { [weak self] in self?.environment.canCheckForUpdates = $0 } + updater.start() + environment.canCheckForUpdates = updater.canCheck + } + dependencies.notifier.onResponse = { [weak self] in self?.handleNotificationResponse() } probeProviderHealth() providerRediscoveryPolicy.recordDiscovery(at: dependencies.clock.now()) Task { [coordinator] in await coordinator.restoreCachedSnapshots() } coordinator.start() - Task { await dependencies.notifier.requestAuthorization() } + if notificationsEnabled { Task { await dependencies.notifier.requestAuthorization(provisional: true) } } observeStatusModel() if dependencies.openPopoverOnLaunch { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in + launchPopoverTask = Task { [weak self, clock = dependencies.clock] in + guard (try? await clock.sleep(Self.launchPopoverDelay)) != nil else { return } self?.openPopoverAfterStatusItemAttachment() } } } + static let launchPopoverDelay: TimeInterval = 0.2 + static let attachmentPollInterval: TimeInterval = 0.05 + static let retentionDebounce: TimeInterval = 0.15 + 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 } + launchPopoverTask = Task { [weak self, clock = dependencies.clock] in + guard (try? await clock.sleep(Self.attachmentPollInterval)) != nil, let self, !stopped, + popover?.isShown != true + else { return } guard let buttonFrame = statusItem?.buttonFrameOnScreen, statusItem?.fits() == true, !buttonFrame.isEmpty, buttonFrame.minX.isFinite, buttonFrame.minY.isFinite, buttonFrame.width.isFinite, buttonFrame.height.isFinite @@ -366,12 +370,15 @@ public final class AppController { applicationObservers.removeAll() for observer in distributedObservers { DistributedNotificationCenter.default().removeObserver(observer) } distributedObservers.removeAll() + dependencies.updater?.onCanCheckChange = nil stopped = true dependencies.log.log("stopped") dependencies.log.flush() } private func cancelBackgroundTasks() { + launchPopoverTask?.cancel() + launchPopoverTask = nil retentionTask?.cancel() retentionTask = nil providerHealthTask?.cancel() @@ -445,8 +452,8 @@ public final class AppController { } public func handleWake() { - dependencies.log.logDebug("wake: resuming refresh loop") - coordinator.start() + dependencies.log.logDebug("wake: resuming refresh loop after its network grace period") + coordinator.resume() } public func togglePopover() { @@ -459,6 +466,12 @@ public final class AppController { screenFrame: geometry.screenFrame) } + public func reopen() { + statusItem?.restoreVisibility() + guard popover?.isShown != true else { return } + openPopoverAfterStatusItemAttachment() + } + private func openPopoverForVerification() { guard popover?.isShown != true else { return } let geometry = popoverGeometry() @@ -484,12 +497,8 @@ public final class AppController { ) { 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 anchorScreen = Self.screen( + near: anchorFrame, preferred: statusItem?.item.button?.window?.screen, among: screens) let previousScreen = lastPopoverVisibleFrame.flatMap { previous in screens.first { $0.frame.intersects(previous) } } @@ -502,6 +511,13 @@ public final class AppController { return (anchorFrame, visibleFrame, screenID, screen?.frame) } + static func screen(near anchor: CGRect?, preferred: NSScreen?, among screens: [NSScreen]) -> NSScreen? { + if let preferred { return preferred } + guard let anchor else { return nil } + return screens.first { $0.frame.intersects(anchor) } + ?? screens.first { $0.frame.contains(CGPoint(x: anchor.midX, y: anchor.midY - 100)) } + } + static func resolveVisibleFrame( _ explicit: CGRect?, anchorScreen: CGRect?, previousScreen: CGRect? ) -> CGRect? { @@ -533,6 +549,13 @@ public final class AppController { Task { [weak self] in await self?.rediscoverProviders(.applicationActivated) } } + func handleNotificationResponse() { + dependencies.log.logDebug("notification opened") + dependencies.settings.lastTab = .usage + popover?.select(tab: .usage) + reopen() + } + public func showProviders(_ provider: ProviderID?) { environment.providerFocusRequest = ProviderSettingsFocusRequest(provider: provider) dependencies.settings.lastTab = .settings @@ -543,6 +566,9 @@ public final class AppController { dependencies.log.debugEnabled = dependencies.settings.detailedLogging statusItem?.detailedLoggingEnabled = dependencies.settings.detailedLogging dependencies.updater?.automaticallyChecks = dependencies.settings.automaticUpdates + let shouldRequestNotifications = dependencies.settings.notifications.enabled && !notificationsEnabled + notificationsEnabled = dependencies.settings.notifications.enabled + if shouldRequestNotifications { Task { await dependencies.notifier.requestAuthorization() } } statusItem?.adaptive = dependencies.settings.adaptiveWidth coordinator.rebuildStatus() let retentionDays = dependencies.settings.historyRetentionDays @@ -553,10 +579,11 @@ public final class AppController { retentionTask = nil return } - let now = dependencies.clock.now() + let clock = dependencies.clock + let now = clock.now() retentionTask = Task { @MainActor [weak self, history = dependencies.history, log = dependencies.log] in do { - try await Task.sleep(for: .milliseconds(150)) + try await clock.sleep(Self.retentionDebounce) } catch { return } @@ -829,12 +856,16 @@ public enum PopoverExporter { @MainActor final class MenuTarget: NSObject { - weak var controller: AppController? + private weak var controller: AppController? init(controller: AppController) { self.controller = controller } + func menu() -> NSMenu { + controller?.contextMenu() ?? NSMenu() + } + @objc func run(_ sender: NSMenuItem) { guard let id = sender.representedObject as? String else { return } controller?.run(id) diff --git a/Sources/TokenMenuBarUI/AppDelegate.swift b/Sources/TokenMenuBarUI/AppDelegate.swift index e6f8767..e793091 100644 --- a/Sources/TokenMenuBarUI/AppDelegate.swift +++ b/Sources/TokenMenuBarUI/AppDelegate.swift @@ -30,7 +30,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } public func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - controller.togglePopover() + controller.reopen() return false } } @@ -102,12 +102,13 @@ public final class DeferredAppDelegate: NSObject, NSApplicationDelegate { } public func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - controller?.togglePopover() + controller?.reopen() return false } private func showStatusShell() { let item = statusBar.statusItem(withLength: NSStatusItem.variableLength) + item.autosaveName = StatusItemController.autosaveName(bundleIdentifier: Bundle.main.bundleIdentifier) item.button?.title = "…" item.button?.toolTip = "Token Menu Bar is starting" item.button?.setAccessibilityLabel("Token Menu Bar is starting") diff --git a/Sources/TokenMenuBarUI/ApplicationMenu.swift b/Sources/TokenMenuBarUI/ApplicationMenu.swift new file mode 100644 index 0000000..adbc383 --- /dev/null +++ b/Sources/TokenMenuBarUI/ApplicationMenu.swift @@ -0,0 +1,38 @@ +import AppKit + +@MainActor +public enum ApplicationMenu { + public static func install(on application: NSApplication, appName: String) { + let mainMenu = NSMenu() + + let appMenu = NSMenu() + appMenu.addItem( + NSMenuItem(title: "Quit \(appName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + let appMenuItem = NSMenuItem() + appMenuItem.submenu = appMenu + mainMenu.addItem(appMenuItem) + + let editMenu = NSMenu(title: "Edit") + editMenu.addItem(NSMenuItem(title: "Undo", action: Selector(("undo:")), keyEquivalent: "z")) + let redo = NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + redo.keyEquivalentModifierMask = [.command, .shift] + editMenu.addItem(redo) + editMenu.addItem(.separator()) + editMenu.addItem(NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")) + editMenu.addItem(NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")) + editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")) + editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) + let editMenuItem = NSMenuItem() + editMenuItem.submenu = editMenu + mainMenu.addItem(editMenuItem) + + let windowMenu = NSMenu(title: "Window") + windowMenu.addItem(NSMenuItem(title: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")) + let windowMenuItem = NSMenuItem() + windowMenuItem.submenu = windowMenu + mainMenu.addItem(windowMenuItem) + + application.mainMenu = mainMenu + application.windowsMenu = windowMenu + } +} diff --git a/Sources/TokenMenuBarUI/LiveDependencies.swift b/Sources/TokenMenuBarUI/LiveDependencies.swift index e8b2b3c..ee802ea 100644 --- a/Sources/TokenMenuBarUI/LiveDependencies.swift +++ b/Sources/TokenMenuBarUI/LiveDependencies.swift @@ -245,6 +245,7 @@ public enum LiveDependencies { settings.isProviderActive($0, state: ProviderState(credentialHealth: .unchecked)) }) let allowTokenRefresh: @MainActor @Sendable () -> Bool = { settings.allowTokenRefresh } + let analyticsWatermarkPersistence = settings.codexAnalyticsWatermarkPersistence let result = await Task.detached(priority: .userInitiated) { buildProviders( paths: paths, @@ -254,6 +255,7 @@ public enum LiveDependencies { enabledProviders: enabledProviders, keychain: keychain, allowTokenRefresh: allowTokenRefresh, + analyticsWatermarkPersistence: analyticsWatermarkPersistence, isSandboxed: isSandboxed, resolver: resolver, buildRegistry: buildRegistry) @@ -280,6 +282,7 @@ public enum LiveDependencies { enabledProviders: Set, keychain: KeychainCredentialClient, allowTokenRefresh: @escaping @MainActor @Sendable () -> Bool, + analyticsWatermarkPersistence: CodexAnalyticsWatermarkPersistence, isSandboxed: Bool, resolver: SecurityScopedResourceResolver, buildRegistry: @@ -329,7 +332,9 @@ public enum LiveDependencies { resourceLeases: leases, enabledProviders: enabledProviders, keychain: keychain, - allowTokenRefresh: allowTokenRefresh) + allowTokenRefresh: allowTokenRefresh, + allowsGeminiTokenRefresh: !isSandboxed, + analyticsWatermarkPersistence: analyticsWatermarkPersistence) let registry = buildRegistry(configuration, client, log) return ProviderBuildResult( registry: registry, replacementBookmarks: replacementBookmarks, logEvents: logEvents) @@ -380,6 +385,7 @@ public enum LiveDependencies { ) { let configuration = NSWorkspace.OpenConfiguration() configuration.createsNewApplicationInstance = true + configuration.arguments = [LaunchPolicy.relaunchedArgument] // 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() } } diff --git a/Sources/TokenMenuBarUI/Popover/PopoverController.swift b/Sources/TokenMenuBarUI/Popover/PopoverController.swift index d32fad8..7ca1faf 100644 --- a/Sources/TokenMenuBarUI/Popover/PopoverController.swift +++ b/Sources/TokenMenuBarUI/Popover/PopoverController.swift @@ -5,6 +5,7 @@ import TokenMenuBarCore typealias GlobalEventMonitorInstaller = ( NSEvent.EventTypeMask, @escaping (NSEvent) -> Void ) -> Any? +typealias PopoverActivationHandler = @MainActor () -> (@MainActor () -> Void)? @MainActor public final class PopoverController: NSObject, NSPopoverDelegate { @@ -14,6 +15,7 @@ public final class PopoverController: NSObject, NSPopoverDelegate { private let log: LogBuffer? private var gate = PopoverDismissalGate() private var menuObservers: [any NSObjectProtocol] = [] + private var dismissalObservers: [(NotificationCenter, any NSObjectProtocol)] = [] private var menuTrackingDepth = 0 private var monitors: [Any] = [] private var resizeObserver: (any NSObjectProtocol)? @@ -27,6 +29,10 @@ public final class PopoverController: NSObject, NSPopoverDelegate { private let presentsWindow: Bool private let recoversOffscreenAnchor: Bool private let addGlobalEventMonitor: GlobalEventMonitorInstaller + private let beginActivation: PopoverActivationHandler + private let applicationCenter: NotificationCenter + private let workspaceCenter: NotificationCenter + private var restoreActivation: (@MainActor () -> Void)? public var onVisibilityChange: ((Bool) -> Void)? public var onRefresh: (() -> Void)? public var excludedFrame: (() -> CGRect?)? @@ -48,7 +54,10 @@ public final class PopoverController: NSObject, NSPopoverDelegate { init( content: AnyView, log: LogBuffer?, animates: Bool, isKeyWindow: @escaping (NSWindow) -> Bool, presentsWindow: Bool = true, recoversOffscreenAnchor: Bool = false, - addGlobalEventMonitor: @escaping GlobalEventMonitorInstaller = NSEvent.addGlobalMonitorForEvents + addGlobalEventMonitor: @escaping GlobalEventMonitorInstaller = NSEvent.addGlobalMonitorForEvents, + beginActivation: @escaping PopoverActivationHandler = PopoverController.activateApplication, + applicationCenter: NotificationCenter = .default, + workspaceCenter: NotificationCenter = NSWorkspace.shared.notificationCenter ) { hosting = NSHostingController(rootView: content) self.isKeyWindow = isKeyWindow @@ -56,6 +65,9 @@ public final class PopoverController: NSObject, NSPopoverDelegate { self.presentsWindow = presentsWindow self.recoversOffscreenAnchor = recoversOffscreenAnchor self.addGlobalEventMonitor = addGlobalEventMonitor + self.beginActivation = beginActivation + self.applicationCenter = applicationCenter + self.workspaceCenter = workspaceCenter popover = NSPopover() super.init() popover.behavior = .applicationDefined @@ -124,10 +136,11 @@ public final class PopoverController: NSObject, NSPopoverDelegate { 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() + restoreActivation = beginActivation() popover.show(relativeTo: presentation.view.bounds, of: presentation.view, preferredEdge: .minY) guard popover.isShown else { removeRecoveryAnchor() + restorePreviousApplication() onVisibilityChange?(false) return } @@ -137,6 +150,7 @@ public final class PopoverController: NSObject, NSPopoverDelegate { } installMonitors() installMenuTrackingObservers() + installDismissalObservers() recordPanel(action: .open, trigger: "status-item") } @@ -151,7 +165,9 @@ public final class PopoverController: NSObject, NSPopoverDelegate { pinnedTopY = nil removeMonitors() removeMenuTrackingObservers() + removeDismissalObservers() removeRecoveryAnchor() + restorePreviousApplication() onVisibilityChange?(false) } @@ -276,7 +292,7 @@ public final class PopoverController: NSObject, NSPopoverDelegate { } as Any) } - static let globalEventMask: NSEvent.EventTypeMask = [.leftMouseDown, .rightMouseDown] + static let globalEventMask: NSEvent.EventTypeMask = [.leftMouseDown, .rightMouseDown, .otherMouseDown] static let localEventMask = globalEventMask.union(.keyDown) func removeMonitors() { @@ -310,8 +326,7 @@ public final class PopoverController: NSObject, NSPopoverDelegate { } let trigger: PopoverDismissalTrigger switch event.type { - case .leftMouseDown, .rightMouseDown: trigger = .mouseDown - case .mouseMoved: trigger = .mouseMoved + case .leftMouseDown, .rightMouseDown, .otherMouseDown: trigger = .mouseDown case .keyDown: trigger = .keyEscape default: return false } @@ -360,6 +375,7 @@ public final class PopoverController: NSObject, NSPopoverDelegate { gate.shouldClose( mouseLocation: mouseLocation, popoverFrame: frame, excludedFrame: excludedFrame?(), trigger: trigger) else { return false } + if trigger == .mouseDown { restoreActivation = nil } close() return true } @@ -479,6 +495,55 @@ public final class PopoverController: NSObject, NSPopoverDelegate { menuTrackingDepth = 0 } + private func installDismissalObservers() { + removeDismissalObservers() + dismissalObservers.append( + ( + applicationCenter, + applicationCenter.addObserver( + forName: NSApplication.didResignActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.dismissForExternalTransition() } + } + )) + dismissalObservers.append( + ( + workspaceCenter, + workspaceCenter.addObserver( + forName: NSWorkspace.activeSpaceDidChangeNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.dismissForExternalTransition() } + } + )) + } + + private func removeDismissalObservers() { + for (center, observer) in dismissalObservers { center.removeObserver(observer) } + dismissalObservers.removeAll() + } + + private func dismissForExternalTransition() { + restoreActivation = nil + close() + } + + private func restorePreviousApplication() { + let restore = restoreActivation + restoreActivation = nil + restore?() + } + + private static func activateApplication() -> (@MainActor () -> Void)? { + let previous = NSWorkspace.shared.frontmostApplication + let currentPID = ProcessInfo.processInfo.processIdentifier + NSApplication.shared.activate() + guard let previous, previous.processIdentifier != currentPID else { return nil } + return { + guard !previous.isTerminated else { return } + NSApplication.shared.yieldActivation(to: previous) + } + } + private func removeResizeObserver() { guard let resizeObserver else { return } NotificationCenter.default.removeObserver(resizeObserver) diff --git a/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift b/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift index cf829f8..e498c63 100644 --- a/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift +++ b/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift @@ -42,14 +42,7 @@ public enum ProviderMarkCatalog { ?? 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 let resourceBundle = providerMarkResourceBundle private static func resourceName( for provider: ProviderID, appearance: ProviderMarkAppearance diff --git a/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkImageLoader.swift b/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkImageLoader.swift index 5d1c1ba..0c7c7fa 100644 --- a/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkImageLoader.swift +++ b/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkImageLoader.swift @@ -12,8 +12,11 @@ public final class ProviderMarkImageLoader { private var images: [Key: NSImage] = [:] private var unavailable: Set = [] + private let resourceURL: (String) -> URL? - private init() {} + init(resourceURL: @escaping (String) -> URL? = ProviderMarkCatalog.resourceURL(named:)) { + self.resourceURL = resourceURL + } public func image(for provider: ProviderID, appearance: ProviderMarkAppearance) -> NSImage? { let key = Key(provider: provider, appearance: appearance) @@ -22,7 +25,7 @@ public final class ProviderMarkImageLoader { let descriptor = ProviderMarkCatalog.descriptor(for: provider, appearance: appearance) guard let resourceName = descriptor.resourceName, - let url = ProviderMarkCatalog.resourceURL(named: resourceName), + let url = resourceURL(resourceName), let image = NSImage(contentsOf: url) else { unavailable.insert(key) diff --git a/Sources/TokenMenuBarUI/SingleInstanceGuard.swift b/Sources/TokenMenuBarUI/SingleInstanceGuard.swift new file mode 100644 index 0000000..9f4ea52 --- /dev/null +++ b/Sources/TokenMenuBarUI/SingleInstanceGuard.swift @@ -0,0 +1,21 @@ +import AppKit +import TokenMenuBarCore + +public enum SingleInstanceGuard { + /// Brings the instance already running under this bundle identifier forward and reports whether this process + /// should exit in its favour. A bare `swift run` has no bundle identifier and is left alone. + public static func handOff( + policy: LaunchPolicy, bundleIdentifier: String? = Bundle.main.bundleIdentifier, + currentProcessIdentifier: pid_t = ProcessInfo.processInfo.processIdentifier, + runningApplications: (String) -> [NSRunningApplication] = NSRunningApplication.runningApplications( + withBundleIdentifier:) + ) -> Bool { + guard policy.yieldsToRunningInstance, let bundleIdentifier, + let running = runningApplications(bundleIdentifier).first(where: { + $0.processIdentifier != currentProcessIdentifier + }) + else { return false } + running.activate() + return true + } +} diff --git a/Sources/TokenMenuBarUI/StatusItem/StatusItemController.swift b/Sources/TokenMenuBarUI/StatusItem/StatusItemController.swift index fc1c138..b347e97 100644 --- a/Sources/TokenMenuBarUI/StatusItem/StatusItemController.swift +++ b/Sources/TokenMenuBarUI/StatusItem/StatusItemController.swift @@ -118,7 +118,7 @@ public final class StatusItemController { item.autosaveName = autosaveName item.button?.target = self item.button?.action = #selector(buttonClicked(_:)) - item.button?.sendAction(on: [.leftMouseUp, .rightMouseUp]) + item.button?.sendAction(on: [.leftMouseDown, .rightMouseDown]) appearanceObservation = item.button?.observe(\.effectiveAppearance) { [weak self] _, _ in self?.appearanceChanged() } let center = NotificationCenter.default @@ -286,6 +286,15 @@ public final class StatusItemController { render(force: true) } + @discardableResult + public func restoreVisibility() -> Bool { + guard !item.isVisible else { return false } + item.isVisible = true + lastSignature = nil + render(force: true) + return 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 } @@ -432,10 +441,15 @@ public final class StatusItemController { } public func handleClick(_ event: NSEvent?) { - if event?.type == .rightMouseUp, let menu = menuProvider?() { return show(menu) } + if Self.isSecondaryClick(event), let menu = menuProvider?() { return show(menu) } onClick?() } + static func isSecondaryClick(_ event: NSEvent?) -> Bool { + guard let event else { return false } + return event.type == .rightMouseDown || event.type == .rightMouseUp || event.modifierFlags.contains(.control) + } + public func show(_ menu: NSMenu) { menu.delegate = menuDelegate item.menu = menu diff --git a/Sources/TokenMenuBarUI/StatusItem/StatusItemRenderer.swift b/Sources/TokenMenuBarUI/StatusItem/StatusItemRenderer.swift index ad46060..f207d8c 100644 --- a/Sources/TokenMenuBarUI/StatusItem/StatusItemRenderer.swift +++ b/Sources/TokenMenuBarUI/StatusItem/StatusItemRenderer.swift @@ -39,15 +39,24 @@ public enum StatusItemRenderer { } } - /// 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)? + private struct CachedTitle { + let signature: StatusRenderSignature + let title: NSAttributedString + } + + /// Keep one title per possible width tier so measuring the ladder and applying any tier share the same build. + private static var titleCache: [CachedTitle] = [] 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 } + if let index = titleCache.firstIndex(where: { $0.signature == signature }) { + let cached = titleCache.remove(at: index) + titleCache.append(cached) + return cached.title + } let title = build(model, height: height, dark: dark) - lastTitle = (signature, title) + if titleCache.count == StatusTier.allCases.count { titleCache.removeFirst() } + titleCache.append(CachedTitle(signature: signature, title: title)) return title } @@ -135,10 +144,16 @@ public enum StatusItemRenderer { 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 labels = cell.bars.map { + NSAttributedString( + string: $0.label, attributes: [.font: labelFont, .foregroundColor: color(for: .label, dark: dark)]) + } + let labelSizes = labels.map { $0.size() } + let labelWidth = labelSizes.map(\.width).max() ?? 0 let width = cellPadding + glyph.size.width + 4 + ceil(labelWidth) + 3 + barWidth + cellPadding let bars = cell.bars + let trackColor = color(for: .label, dark: dark).withAlphaComponent(0.18) + let barColors = bars.map { color(for: .usage($0.percent), dark: dark) } return NSImage(size: CGSize(width: width, height: height), flipped: true) { rect in let glyphY = (rect.height - glyph.size.height) / 2 glyph.draw( @@ -148,16 +163,14 @@ public enum StatusItemRenderer { 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)) + labels[index].draw(at: CGPoint(x: labelLeft, y: centerY - labelSizes[index].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() + trackColor.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() + barColors[index].setFill() NSBezierPath( roundedRect: CGRect(x: trackRect.minX, y: trackRect.minY, width: filled, height: barHeight), xRadius: barHeight / 2, yRadius: barHeight / 2 diff --git a/Sources/TokenMenuBarUI/Views/LogSection.swift b/Sources/TokenMenuBarUI/Views/LogSection.swift index b1337e3..60049eb 100644 --- a/Sources/TokenMenuBarUI/Views/LogSection.swift +++ b/Sources/TokenMenuBarUI/Views/LogSection.swift @@ -166,9 +166,7 @@ public struct FullLogView: View { entries = Self.merge(retained: retained, live: snapshot) } } - if entries.count > LogBuffer.retainedEntryLimit + LogBuffer.capacity { - entries.removeFirst(entries.count - LogBuffer.retainedEntryLimit) - } + entries.removeFirst(max(entries.count - (log.retainedEntryLimit + LogBuffer.capacity), 0)) previousLive = snapshot } } diff --git a/Sources/TokenMenuBarUI/Views/LogTextView.swift b/Sources/TokenMenuBarUI/Views/LogTextView.swift index 2bc059b..d8db953 100644 --- a/Sources/TokenMenuBarUI/Views/LogTextView.swift +++ b/Sources/TokenMenuBarUI/Views/LogTextView.swift @@ -36,8 +36,7 @@ public struct LogTextView: NSViewRepresentable { scrollView.hasVerticalScroller = true scrollView.autohidesScrollers = true scrollView.scrollerStyle = .overlay - scrollView.borderType = bordered ? .bezelBorder : .noBorder - if let height { scrollView.heightAnchor.constraint(equalToConstant: height).isActive = true } + context.coordinator.update(scrollView, height: height, bordered: bordered) return scrollView } @@ -46,6 +45,7 @@ public struct LogTextView: NSViewRepresentable { } public func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.update(scrollView, height: height, bordered: bordered) 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 @@ -73,6 +73,22 @@ public struct LogTextView: NSViewRepresentable { @MainActor public final class Coordinator { private var snapshot = EntrySnapshot.empty + private var heightConstraint: NSLayoutConstraint? + + func update(_ scrollView: NSScrollView, height: CGFloat?, bordered: Bool) { + scrollView.borderType = bordered ? .bezelBorder : .noBorder + if let height { + if let heightConstraint { + heightConstraint.constant = height + } else { + heightConstraint = scrollView.heightAnchor.constraint(equalToConstant: height) + heightConstraint?.isActive = true + } + } else { + heightConstraint?.isActive = false + heightConstraint = nil + } + } func change(to entries: [LogEntry]) -> Change? { guard !snapshot.matches(entries) else { return nil } diff --git a/Sources/TokenMenuBarUI/Views/ProviderCardView.swift b/Sources/TokenMenuBarUI/Views/ProviderCardView.swift index a94e161..0d39ead 100644 --- a/Sources/TokenMenuBarUI/Views/ProviderCardView.swift +++ b/Sources/TokenMenuBarUI/Views/ProviderCardView.swift @@ -7,7 +7,7 @@ public struct ProviderCardView: View { public let onRefreshProvider: (ProviderID) -> Void public init( - card: ProviderCard, environment: UIEnvironment, onRefreshProvider: @escaping (ProviderID) -> Void = { _ in } + card: ProviderCard, environment: UIEnvironment, onRefreshProvider: @escaping (ProviderID) -> Void ) { self.card = card self.environment = environment diff --git a/Sources/TokenMenuBarUI/Views/ProviderMarkView.swift b/Sources/TokenMenuBarUI/Views/ProviderMarkView.swift index ac0ca54..00b6231 100644 --- a/Sources/TokenMenuBarUI/Views/ProviderMarkView.swift +++ b/Sources/TokenMenuBarUI/Views/ProviderMarkView.swift @@ -6,12 +6,18 @@ public struct ProviderMarkView: View { public let provider: ProviderID public let size: CGSize + private let imageLoader: ProviderMarkImageLoader @Environment(\.colorScheme) private var colorScheme public init(_ provider: ProviderID, size: CGSize = defaultSize) { + self.init(provider, size: size, imageLoader: .shared) + } + + init(_ provider: ProviderID, size: CGSize = defaultSize, imageLoader: ProviderMarkImageLoader) { self.provider = provider self.size = size + self.imageLoader = imageLoader } public var body: some View { @@ -20,7 +26,7 @@ public struct ProviderMarkView: View { 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) { + if let image = imageLoader.image(for: provider, appearance: appearance) { Image(nsImage: image) .resizable() .interpolation(.high) diff --git a/Sources/TokenMenuBarUI/Views/SettingsTab.swift b/Sources/TokenMenuBarUI/Views/SettingsTab.swift index 2532996..a2131c3 100644 --- a/Sources/TokenMenuBarUI/Views/SettingsTab.swift +++ b/Sources/TokenMenuBarUI/Views/SettingsTab.swift @@ -15,6 +15,7 @@ public struct SettingsTab: View { @State private var measurementHeight: CGFloat @FocusState private var providerFocus: ProviderID? @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion private let mountsIncrementally: Bool public init( @@ -133,7 +134,7 @@ public struct SettingsTab: View { 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)) { + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.12)) { scroll.scrollTo(target, anchor: .center) } providerFocus = provider @@ -573,7 +574,7 @@ public struct SettingsTab: View { focusRequest = SettingsModelFocusRequest(key: key) Task { @MainActor in await Task.yield() - withAnimation(.easeOut(duration: 0.12)) { scroll.scrollTo(key, anchor: .center) } + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.12)) { scroll.scrollTo(key, anchor: .center) } } } } @@ -655,9 +656,13 @@ public struct SettingsTab: View { tokenRefreshFloor } } - Text("Token refresh supports Claude, Codex, and Gemini and writes rotated tokens to their credential stores.") - .font(.caption) - .semanticForeground(.secondary) + Text( + "Token refresh writes rotated tokens for \(tokenRefreshProviderNames) to their credential stores. " + + "Concurrent CLI refresh can require another sign-in." + ) + .font(.caption) + .semanticForeground(.secondary) + .fixedSize(horizontal: false, vertical: true) } } @@ -673,15 +678,21 @@ public struct SettingsTab: View { Binding(get: { settings[keyPath: keyPath] }, set: { settings[keyPath: keyPath] = $0 }) } + var tokenRefreshProviderNames: String { + environment.isSandboxed ? "Claude and Codex" : "Claude, Codex, and Gemini" + } + private var tokenRefreshToggle: some View { - Toggle("Refresh expired Claude, Codex, and Gemini tokens on my behalf", isOn: setting(\.allowTokenRefresh)) + Toggle("Refresh expired \(tokenRefreshProviderNames) 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." + "Rotates \(tokenRefreshProviderNames) refresh tokens and writes the replacements to the credential " + + "file or Keychain. If Token Menu Bar and a provider CLI rotate the same token at the same time, " + + "either one can invalidate the other session and require another sign-in. When off, Token Menu Bar " + + "never rotates tokens; an expired provider stops updating until you sign in again." )) } diff --git a/Sources/TokenMenuBarUI/Views/StatusPreview.swift b/Sources/TokenMenuBarUI/Views/StatusPreview.swift index 5f88f1d..a9366df 100644 --- a/Sources/TokenMenuBarUI/Views/StatusPreview.swift +++ b/Sources/TokenMenuBarUI/Views/StatusPreview.swift @@ -8,10 +8,7 @@ public struct StatusPreview: View { private let select: (WindowKey) -> Void @Environment(\.colorScheme) private var colorScheme - public init( - model: StatusItemModel, highlightedKey: Binding = .constant(nil), - select: @escaping (WindowKey) -> Void = { _ in } - ) { + public init(model: StatusItemModel, highlightedKey: Binding, select: @escaping (WindowKey) -> Void) { self.model = model _highlightedKey = highlightedKey self.select = select diff --git a/Sources/TokenMenuBarUI/Views/UsageChart.swift b/Sources/TokenMenuBarUI/Views/UsageChart.swift index 53b413d..b244774 100644 --- a/Sources/TokenMenuBarUI/Views/UsageChart.swift +++ b/Sources/TokenMenuBarUI/Views/UsageChart.swift @@ -164,7 +164,7 @@ public struct UsageChart: View { static func formatted(_ value: Double, unit: HistoryUnit) -> String { switch unit { case .percentage: Format.percent(value) - case .usd: "$\(value.formatted(.number.precision(.fractionLength(2))))" + case .usd: Format.currency(value) case .tokens, .credits, .count: Format.compactNumber(value) } } @@ -265,7 +265,7 @@ private struct HistoryChartAccessibility: AXChartDescriptorRepresentable { value in switch data.metric.unit { case .percentage: Format.percent(value) - case .usd: "$\(value.formatted(.number.precision(.fractionLength(2))))" + case .usd: Format.currency(value) case .tokens, .credits, .count: Format.compactNumber(value) } } diff --git a/Sources/TokenMenuBarUI/Views/WindowSelectionList.swift b/Sources/TokenMenuBarUI/Views/WindowSelectionList.swift index 57ef200..ecbb507 100644 --- a/Sources/TokenMenuBarUI/Views/WindowSelectionList.swift +++ b/Sources/TokenMenuBarUI/Views/WindowSelectionList.swift @@ -131,8 +131,7 @@ public struct WindowSelectionList: View { focus = .label(request.key) } } - .onChange(of: query, queryChangeAction) - .onChange(of: settings.hideUnusedModels) { _, _ in revealedKey = nil } + .onChange(of: RevealScope(query: query, hideUnused: settings.hideUnusedModels)) { _, _ in revealedKey = nil } .onChange(of: focus) { old, new in guard case .label(let key) = old, old != new else { return } commitLabel(key) @@ -140,10 +139,6 @@ public struct WindowSelectionList: View { .onDisappear { commitDrafts() } } - var queryChangeAction: (String, String) -> Void { - { _, _ in revealedKey = nil } - } - private var modelFilter: some View { TextField("Filter models…", text: $query) .textFieldStyle(.roundedBorder) @@ -777,3 +772,9 @@ struct SettingsActivityRequest: Hashable, Sendable { let retentionDays: Int let rangeHour: Int64 } + +/// A revealed row stays visible until the filter or the hide setting changes. +private struct RevealScope: Equatable { + let query: String + let hideUnused: Bool +} diff --git a/Sources/TokenMenuBarUI/WorkspaceGlue.swift b/Sources/TokenMenuBarUI/WorkspaceGlue.swift index 9e64949..69a46dd 100644 --- a/Sources/TokenMenuBarUI/WorkspaceGlue.swift +++ b/Sources/TokenMenuBarUI/WorkspaceGlue.swift @@ -43,3 +43,9 @@ extension LiveDependencies { } } + +// Inside the .app the marks ship in a nested bundle that only exists once the app is assembled, so package tests can +// never take that branch. +let providerMarkResourceBundle = + Bundle.main.url(forResource: "TokenMenuBar_TokenMenuBarUI", withExtension: "bundle").flatMap(Bundle.init(url:)) + ?? Bundle.module diff --git a/Sources/TokenMenuBarWidgets/UsageWidget.swift b/Sources/TokenMenuBarWidgets/UsageWidget.swift index f6bfa7e..07d165b 100644 --- a/Sources/TokenMenuBarWidgets/UsageWidget.swift +++ b/Sources/TokenMenuBarWidgets/UsageWidget.swift @@ -13,7 +13,7 @@ public struct UsageEntry: TimelineEntry, Sendable { } public struct UsageTimelineProvider: Sendable { - public static let refreshInterval: TimeInterval = 900 + public static let refreshInterval = WidgetSnapshot.refreshInterval public let store: WidgetSnapshotStore public let now: @Sendable () -> Date @@ -128,10 +128,21 @@ public struct WidgetRowView: View { } .frame(height: 4) if !compact { - Text("Resets in \(row.resetText(now: now))").font(.caption2).foregroundStyle(.secondary) + resetText.font(.caption2).foregroundStyle(.secondary) } } } + + @ViewBuilder private var resetText: some View { + if let resetsAt = row.resetsAt, resetsAt > now { + HStack(spacing: 3) { + Text("Resets in") + Text(timerInterval: now...resetsAt, countsDown: true) + } + } else { + Text(row.resetsAt == nil ? "Reset unavailable" : "Reset due") + } + } } public struct UsageWidgetEntryView: View { diff --git a/Tests/TokenMenuBarCoreTests/APIClientTests.swift b/Tests/TokenMenuBarCoreTests/APIClientTests.swift index 4878ff7..70decbb 100644 --- a/Tests/TokenMenuBarCoreTests/APIClientTests.swift +++ b/Tests/TokenMenuBarCoreTests/APIClientTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -18,6 +19,7 @@ private let endpoint = URL( #expect(request.value(forHTTPHeaderField: "X-Test") == "1") #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") #expect(request.httpMethod == "GET") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) let lines = log.text #expect(lines.contains("endpoint=https://example.com/api/organizations/{id}/usage")) #expect(!lines.contains("x=1")) @@ -38,17 +40,35 @@ private let endpoint = URL( #expect(request.value(forHTTPHeaderField: "A") == "b") } +@Test func apiClientFormEncodingPreservesLiteralPlusSigns() 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")!, form: ["a+b": "c+d e"], headers: [:], operation: "post") + + #expect(transport.requests[0].httpBody == Data("a%2Bb=c%2Bd%20e".utf8)) +} + @Test func apiClientMapsHTTPErrorsWithRetryAfter() async { let transport = StubTransport() transport.on(path: "/header", .text("slow down", status: 429, headers: ["Retry-After": "120"])) + transport.on( + path: "/date", + .text("slow down", status: 503, headers: ["Retry-After": "Sat, 29 Aug 2026 19:02:00 GMT"])) transport.on(path: "/body", .text(#"{"retry_after": 30}"#, status: 429)) transport.on(path: "/plain", .text(String(repeating: "x", count: 300), status: 500)) + transport.on(path: "/large", .text(String(repeating: "x", count: 70_000), status: 500)) let log = makeLog() log.debugEnabled = true - let client = APIClient(transport: transport, log: log) + let client = APIClient(transport: transport, log: log, clock: testClock) 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: 503, body: "slow down", retryAfter: 120)) { + try await client.get(URL(string: "https://example.com/date")!, headers: [:], operation: "date") + } 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") } @@ -61,15 +81,59 @@ private let endpoint = URL( return } #expect(status == 500) - #expect(body.count == APIClient.bodySnippetLength) + #expect(body.count == 300) #expect(retryAfter == nil) } + do { + _ = try await client.get(URL(string: "https://example.com/large")!, headers: [:], operation: "large") + Issue.record("expected failure") + } catch { + guard case .http(_, let body, _) = error else { + Issue.record("wrong error") + return + } + #expect(body.utf8.count == APIClient.maximumErrorBodyBytes) + } #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 apiClientRetainsStructuredErrorReasonsBeyondTheFormerSnippetLimit() async { + let transport = StubTransport() + let message = String(repeating: "x", count: 300) + let body = #"{"error":{"message":"\#(message)","details":[{"reason":"SUBSCRIPTION_REQUIRED"}]}}"# + transport.on(path: "/reason", .text(body, status: 403)) + let client = APIClient(transport: transport, log: makeLog()) + + do { + _ = try await client.get(URL(string: "https://example.com/reason")!, headers: [:], operation: "reason") + Issue.record("expected failure") + } catch { + guard case .http(_, let retainedBody, _) = error else { + Issue.record("wrong error") + return + } + #expect(retainedBody == body) + #expect(retainedBody.contains("SUBSCRIPTION_REQUIRED")) + } +} + +@Test func apiClientDistinguishesVendorSecurityChallengesFromAuthenticationFailures() async { + let transport = StubTransport() + transport.on( + path: "/challenge", + .text("Just a moment", status: 403, headers: ["Content-Type": "text/html"])) + let client = APIClient(transport: transport, log: makeLog()) + + await #expect(throws: APIError.vendorSecurityCheck(status: 403)) { + try await client.get(URL(string: "https://example.com/challenge")!, headers: [:], operation: "challenge") + } + #expect(!APIError.vendorSecurityCheck(status: 403).isAuthenticationFailure) + #expect(APIError.vendorSecurityCheck(status: 403).message.contains("provider security check")) +} + @Test func apiClientMapsNetworkAndDecodingErrors() async { let transport = StubTransport() transport.on(path: "/down", error: URLError(.notConnectedToInternet)) @@ -88,6 +152,14 @@ private let endpoint = URL( #expect(!text.isEmpty) #expect(error.message.hasPrefix("Network error")) } + struct Account: Decodable { let remaining: Int } + struct Envelope: Decodable { let account: Account } + do { + _ = try client.decode(Envelope.self, Data(#"{"account":{"remaining":"many"}}"#.utf8), operation: "shape") + Issue.record("expected failure") + } catch { + #expect(error.message.hasPrefix("Unexpected response")) + } do { _ = try await client.getJSON( [String: Int].self, URL(string: "https://example.com/bad")!, headers: [:], operation: "decode") @@ -111,6 +183,8 @@ private let endpoint = URL( $0.category == .network && $0.message.contains("request.finished") && $0.message.contains("errorDomain=NSURLErrorDomain") }) + #expect(log.text.contains("Path: account.remaining")) + #expect(log.text.contains("Expected to decode Int but found a string instead")) } @Test func apiClientKeepsRequestFailuresOutOfTheDefaultLog() async { @@ -128,13 +202,22 @@ private let endpoint = URL( @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: 403, body: #"{"error":{"status":"UNAUTHENTICATED"}}"#, retryAfter: nil + ).isAuthenticationFailure) + #expect(!APIError.http(status: 403, body: "", retryAfter: nil).isAuthenticationFailure) + #expect( + !APIError.http( + status: 403, body: #"{"error":{"status":"PERMISSION_DENIED"}}"#, 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") + #expect(APIError.http(status: 302, body: "", retryAfter: nil).message == "Unexpected redirect (HTTP 302)") } @Test func apiClientRedactsURLs() { @@ -150,13 +233,28 @@ private let endpoint = URL( #expect(APIClient.isIdentifier("abcdef12-3456-7890-abcd-ef1234567890")) } -@Test func apiClientLiveSessionUsesBoundedMemoryCache() { +@Test func apiClientLiveSessionDisablesCachingAndBoundsRequestLifetime() { let configuration = APIClient.liveConfiguration() - #expect(configuration.urlCache?.memoryCapacity == APIClient.liveCacheCapacity) - #expect(configuration.urlCache?.diskCapacity == 0) + #expect(configuration.urlCache == nil) #expect(configuration.httpCookieStorage == nil) #expect(configuration.urlCredentialStorage == nil) - #expect(configuration.requestCachePolicy == .useProtocolCachePolicy) + #expect(configuration.requestCachePolicy == .reloadIgnoringLocalCacheData) + #expect(configuration.timeoutIntervalForRequest == APIClient.timeout) + #expect(configuration.timeoutIntervalForResource == APIClient.resourceTimeout) +} + +@Test func apiClientRedirectPolicyAllowsOnlyTheOriginalOrigin() { + let source = URL(string: "https://first.test/start")! + var sameOrigin = URLRequest(url: URL(string: "https://first.test/end")!) + sameOrigin.setValue("token", forHTTPHeaderField: "X-Secret") + #expect(APIClient.redirectedRequest(from: source, to: sameOrigin) == sameOrigin) + #expect( + APIClient.redirectedRequest( + from: source, to: URLRequest(url: URL(string: "https://second.test/end")!)) == nil) + #expect( + APIClient.redirectedRequest( + from: source, to: URLRequest(url: URL(string: "http://first.test/end")!)) == nil) + #expect(APIClient.redirectedRequest(from: nil, to: sameOrigin) == nil) } @Test func disabledHTTPTransportRejectsRequests() async { diff --git a/Tests/TokenMenuBarCoreTests/AdaptiveWidthTests.swift b/Tests/TokenMenuBarCoreTests/AdaptiveWidthTests.swift index 61913dc..6df1bea 100644 --- a/Tests/TokenMenuBarCoreTests/AdaptiveWidthTests.swift +++ b/Tests/TokenMenuBarCoreTests/AdaptiveWidthTests.swift @@ -1,6 +1,7 @@ import CoreGraphics import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/AnalyticsCorrectionScopeTests.swift b/Tests/TokenMenuBarCoreTests/AnalyticsCorrectionScopeTests.swift index 04b3e3a..56114e8 100644 --- a/Tests/TokenMenuBarCoreTests/AnalyticsCorrectionScopeTests.swift +++ b/Tests/TokenMenuBarCoreTests/AnalyticsCorrectionScopeTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/BrandTests.swift b/Tests/TokenMenuBarCoreTests/BrandTests.swift index a544d92..dc494ff 100644 --- a/Tests/TokenMenuBarCoreTests/BrandTests.swift +++ b/Tests/TokenMenuBarCoreTests/BrandTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift b/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift index e041c5a..5bc957a 100644 --- a/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift +++ b/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/ClaudeMapperTests.swift b/Tests/TokenMenuBarCoreTests/ClaudeMapperTests.swift index 60c35f3..8f97f69 100644 --- a/Tests/TokenMenuBarCoreTests/ClaudeMapperTests.swift +++ b/Tests/TokenMenuBarCoreTests/ClaudeMapperTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @MainActor private func claudeSnapshot( @@ -40,6 +41,27 @@ private let plainClaude = ClaudeOAuthCredentials( #expect(snapshot.windows[1].resetsAt == ISODate.parse("2026-09-01T14:59:59.522121+00:00")) } +@Test func claudeSkipsOnlyTheMalformedLimit() async throws { + let usage = #""" + {"limits": [{"kind": "session", "percent": null}, + {"kind": "weekly_all", "percent": 34}], + "five_hour": {"utilization": 99}} + """# + let transport = StubTransport() + transport.on(path: "/api/oauth/usage", .text(usage)) + transport.on(path: "/api/oauth/profile", .json("claude_profile")) + let result = await claudeProvider(MemoryClaudeStore(validClaude), transport: transport).fetch( + now: fixedNow, options: FetchOptions()) + #expect(result.outcome.snapshot?.windows.map(\.id) == ["weekly"]) + #expect(result.warnings.contains("Skipped 1 malformed Claude usage limit.")) +} + +@Test func claudeDoesNotFallBackWhenEverySuppliedLimitIsMalformed() async throws { + let usage = #"{"limits": [{"kind": "session", "percent": null}], "five_hour": {"utilization": 99}}"# + let snapshot = try #require(await claudeSnapshot(usage: .text(usage))) + #expect(snapshot.windows.isEmpty) +} + @Test func claudeFallsBackToTheFlatWindowKeys() async throws { let usage = #""" {"five_hour": {"utilization": 12, "resets_at": "2026-08-29T18:00:00Z"}, @@ -85,15 +107,14 @@ func claudeNamesAWindowAfterItsLimitKind(limit: String, id: String, label: Strin #expect(snapshot.windows[0].isActive) } -@Test func claudeReportsTheSpendBlockAndItsMonthlyReset() async throws { +@Test func claudeReportsTheSpendBlockWithoutInventingAReset() 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))) + #expect(snapshot.spend?.resetsAt == nil) } @Test func claudeDerivesSpendFromExtraUsageWhenTheBlockIsMissing() async throws { @@ -172,6 +193,7 @@ func claudeNamesThePlanFromTheAccountFlags(profile: String, expected: String) as let notices = try #require(await claudeSnapshot(usage: .text(usage))).notices #expect(notices.map(\.kind) == [.spendControl, .limitReached]) #expect(notices[1].text.contains("Current session")) + #expect(notices[1].text.contains("2026-") == false) 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 index b60fc1f..d38b2f0 100644 --- a/Tests/TokenMenuBarCoreTests/ClaudeTranscriptReaderTests.swift +++ b/Tests/TokenMenuBarCoreTests/ClaudeTranscriptReaderTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -57,6 +58,21 @@ import Testing #expect(await cold.refresh(now: fixedNow).messageCount == 2) } +@Test func transcriptReaderPersistsExpandedRetentionAcrossLaunches() 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(-90 * 86400)) + "\n").write( + to: file, atomically: true, encoding: .utf8) + let first = ClaudeTranscriptReader(root: root.deletingLastPathComponent(), stateURL: state) + #expect(await first.refresh(now: fixedNow, retentionDays: 365).messageCount == 1) + #expect((await first.workload).bytesRead > 0) + + let resumed = ClaudeTranscriptReader(root: root.deletingLastPathComponent(), stateURL: state) + #expect(await resumed.refresh(now: fixedNow, retentionDays: 365).messageCount == 1) + #expect((await resumed.workload).bytesRead == 0) +} + @Test func transcriptReaderAppliesAndExpandsConfiguredRetention() async throws { let root = try transcriptRoot() let file = root.appendingPathComponent("retention.jsonl") @@ -313,6 +329,27 @@ import Testing #expect((await reader.workload).scansCompleted == 1) } +@Test func transcriptReaderRestartsFromZeroAfterSeekFailure() 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) + "\n").write(to: file, atomically: true, encoding: .utf8) + #expect( + await ClaudeTranscriptReader(root: root.deletingLastPathComponent(), stateURL: state).refresh(now: fixedNow) + .messageCount == 1) + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data((line(id: "m2", at: fixedNow) + "\n").utf8)) + try handle.close() + + let seek = FailingFirstTranscriptSeek() + let resumed = ClaudeTranscriptReader( + root: root.deletingLastPathComponent(), stateURL: state, backgroundWorkDelay: 60, seek: seek.call) + #expect(await resumed.refresh(now: fixedNow).messageCount == 1) + #expect(await resumed.refresh(now: fixedNow).messageCount == 2) + #expect(seek.callCount == 2) +} + @Test func transcriptReaderIgnoresDirectoriesBeforeChoosingHotFiles() async throws { let root = try transcriptRoot() for index in 0...ClaudeTranscriptReader.maxIndexedFiles { @@ -374,6 +411,23 @@ private func transcriptRoot() throws -> URL { return root } +private final class FailingFirstTranscriptSeek: @unchecked Sendable { + private let lock = NSLock() + private var calls = 0 + + var callCount: Int { + lock.withLock { calls } + } + + func call(_ handle: FileHandle, _ offset: UInt64) throws { + try lock.withLock { + calls += 1 + if calls == 1 { throw CocoaError(.fileReadUnknown) } + } + try handle.seek(toOffset: offset) + } +} + @Test func transcriptParseRejectsIncompleteRecords() { #expect(ClaudeTranscriptReader.parse(line: Data("{".utf8)) == nil) #expect(ClaudeTranscriptReader.parse(line: Data(#"{"type":"user","message":{"usage":{}}}"#.utf8)) == nil) diff --git a/Tests/TokenMenuBarCoreTests/CodexAnalyticsRetentionTests.swift b/Tests/TokenMenuBarCoreTests/CodexAnalyticsRetentionTests.swift index 9f98dbc..6e4146c 100644 --- a/Tests/TokenMenuBarCoreTests/CodexAnalyticsRetentionTests.swift +++ b/Tests/TokenMenuBarCoreTests/CodexAnalyticsRetentionTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -105,7 +106,7 @@ import Testing @Test func codexAnalyticsWatermarksRecoverFromCorruptStorage() async { let defaults = watermarkDefaults() - defaults.set(Data("not json".utf8), forKey: CodexAnalyticsWatermarkStore.storageKey) + defaults.set(Data("not json".utf8), forKey: CodexAnalyticsWatermarkPersistence.storageKey) let firstTransport = analyticsTransport() _ = await codexProvider( MemoryCodexStore(validCodex), transport: firstTransport, analyticsWatermarkPersistence: persistence(defaults) @@ -125,14 +126,14 @@ import Testing 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) + defaults.set(try JSONEncoder().encode(legacy), forKey: CodexAnalyticsWatermarkPersistence.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)! + data: defaults.data(forKey: CodexAnalyticsWatermarkPersistence.storageKey)!, encoding: .utf8)! #expect(!stored.contains("private-token")) #expect(!stored.contains("private-account")) } @@ -158,7 +159,8 @@ import Testing "empty": ["lastAccess": encodedDate, "coverage": [:]], ], ] - defaults.set(try JSONSerialization.data(withJSONObject: document), forKey: CodexAnalyticsWatermarkStore.storageKey) + defaults.set( + try JSONSerialization.data(withJSONObject: document), forKey: CodexAnalyticsWatermarkPersistence.storageKey) let store = CodexAnalyticsWatermarkStore(persistence: persistence(defaults)) #expect( store.load(account: account, now: fixedNow, retentionDays: 7) @@ -168,7 +170,7 @@ import Testing var unknownVersion = document unknownVersion["version"] = 99 defaults.set( - try JSONSerialization.data(withJSONObject: unknownVersion), forKey: CodexAnalyticsWatermarkStore.storageKey) + try JSONSerialization.data(withJSONObject: unknownVersion), forKey: CodexAnalyticsWatermarkPersistence.storageKey) #expect(store.load(account: account, now: fixedNow, retentionDays: 7).isEmpty) } @@ -294,7 +296,7 @@ import Testing } private func watermarkDefaults() -> UserDefaults { - UserDefaults(suiteName: "codex-retention-\(UUID().uuidString)")! + testDefaults("codex-retention") } private func persistence(_ defaults: UserDefaults) -> CodexAnalyticsWatermarkPersistence { diff --git a/Tests/TokenMenuBarCoreTests/CodexMapperTests.swift b/Tests/TokenMenuBarCoreTests/CodexMapperTests.swift index 132b51e..01d193e 100644 --- a/Tests/TokenMenuBarCoreTests/CodexMapperTests.swift +++ b/Tests/TokenMenuBarCoreTests/CodexMapperTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @MainActor private func codexFetch( @@ -275,6 +276,15 @@ private func codexAnalytics(_ stub: @escaping (StubTransport) -> Void = { _ in } #expect(analytics.creditEvents.map(\.creditsUsed) == [3, 1.5, 2]) } +@Test func codexWarnsWhenACreditEventHasNoRecognizedAmount() async { + let (result, _) = await codexFetch(usage: .json("codex_usage"), analytics: true) { transport in + transport.on(path: "credit-usage-events", .text(#"{"data":[{"date":"2026-08-29","cost":3}]}"#)) + stubCodexAnalytics(transport) + } + #expect(result.analytics?.creditEvents.isEmpty == true) + #expect(result.warnings.contains("Skipped 1 Codex credit event without a credit amount.")) +} + @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) diff --git a/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift b/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift index ac1db82..3a0cef9 100644 --- a/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift +++ b/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/CopilotTests.swift b/Tests/TokenMenuBarCoreTests/CopilotTests.swift index 203685d..3627c90 100644 --- a/Tests/TokenMenuBarCoreTests/CopilotTests.swift +++ b/Tests/TokenMenuBarCoreTests/CopilotTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func copilotFileStoreFindsGitHubEntries() throws { let root = temporaryDirectory() @@ -51,7 +52,7 @@ private func copilotSnapshot( #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.windows.allSatisfy { $0.resetsAt == DayStamp.date("2026-09-01") && $0.resetPrecision == .day }) #expect(snapshot.identity?.planName == "Pro Plus") #expect(snapshot.identity?.tier == "copilot_pro_seat") #expect(snapshot.identity?.email == "octocat") @@ -72,6 +73,7 @@ private func copilotSnapshot( #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[0].resetPrecision == .day) #expect(snapshot.windows[1].usedPercent == 0) #expect(snapshot.windows[2].usedPercent == 60) #expect(snapshot.identity?.planName == "Individual") @@ -134,7 +136,16 @@ private func copilotSnapshot( 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") + #expect(transport.requests.first?.url?.host() == "ghe.example.com") + #expect(transport.requests.first?.url?.path == "/api/v3/copilot_internal/user") +} + +@Test func copilotProviderUsesTheEnterpriseCloudAPIHost() async { + let (provider, transport) = makeProvider(CopilotAuth(token: "gho_ent", user: "ent", host: "octocorp.ghe.com")) + transport.on(path: "/copilot_internal/user", .json("copilot_user")) + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(transport.requests.first?.url?.host() == "api.octocorp.ghe.com") + #expect(transport.requests.first?.url?.path == "/copilot_internal/user") } @Test func copilotProviderRejectsAnInvalidHostWithoutSendingTheToken() async { @@ -182,3 +193,30 @@ private func makeProvider(_ auth: CopilotAuth?, store: MemoryCopilotStore? = nil return } } + +@Test( + arguments: [ + (#""quota_reset_date": "2026-09-01T10:00:00Z""#, ResetPrecision.instant), + (#""quota_reset_date": "2026-09-01""#, ResetPrecision.day), + ]) +func copilotKeepsTheResetPrecisionTheAPIGave(field: String, precision: ResetPrecision) async throws { + let body = #"{"quota_snapshots": {"premium_interactions": {"entitlement": 300, "remaining": 120}}, "# + field + "}" + let snapshot = try #require(await copilotSnapshot(.text(body))) + #expect(snapshot.windows.map(\.usedPercent) == [60]) + #expect(snapshot.windows.first?.resetPrecision == precision) +} + +@Test func copilotFreePlanWithoutResetDatesShowsNoCountdown() async throws { + let snapshot = try #require( + await copilotSnapshot(.text(#"{"limited_user_quotas": {"chat": 41}, "monthly_quotas": {"chat": 50}}"#))) + #expect(snapshot.windows.map(\.id) == ["free:chat"]) + #expect(snapshot.windows.first?.resetsAt == nil) + #expect(snapshot.windows.first?.resetPrecision == .instant) +} + +@Test func copilotKeepsAnAPIHostAsIs() async { + let (provider, transport) = makeProvider(CopilotAuth(token: "gho_ent", user: "ent", host: "api.octocorp.example")) + transport.on(path: "/copilot_internal/user", .json("copilot_user")) + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(transport.requests.first?.url?.host() == "api.octocorp.example") +} diff --git a/Tests/TokenMenuBarCoreTests/CoreCoverageClosureTests.swift b/Tests/TokenMenuBarCoreTests/CoreCoverageClosureTests.swift index 0d3398b..8ffc714 100644 --- a/Tests/TokenMenuBarCoreTests/CoreCoverageClosureTests.swift +++ b/Tests/TokenMenuBarCoreTests/CoreCoverageClosureTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func coverageClosureDiagnosticMessagesRenderPresentOptionalsAndWarnings() { let status = DiagnosticEvent.status( @@ -309,7 +310,7 @@ import TokenMenuBarCore await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force, providers: [.codex])) #expect(second.callCount == 1) - #expect(coordinator.nextAttempt(for: .codex) == fixedNow.addingTimeInterval(240)) + #expect(coordinator.nextAttempt(for: .codex) == fixedNow.addingTimeInterval(2 * RefreshCoordinator.rateLimitBackoff)) } @Test @MainActor func coverageClosureCoordinatorCanSkipWidgetAndAdaptiveWidthPublication() throws { @@ -327,11 +328,19 @@ import TokenMenuBarCore var publications = 0 coordinator.widgetSink = { _ in publications += 1 } let initialPublications = publications + state.update(.codex) { + $0.snapshot = DemoData.snapshot(.codex, now: fixedNow) + $0.availability = .current + } coordinator.rebuildStatus(now: fixedNow, publishWidget: false) #expect(state.statusLadder.count == 1) #expect(publications == initialPublications) + + coordinator.rebuildStatus(now: fixedNow, publishWidget: true) + + #expect(publications == initialPublications + 1) } @Test @MainActor func coverageClosureCoordinatorLoopDoesNotRetainItsOwner() async throws { @@ -444,7 +453,7 @@ private func coverageClosureCoordinator( @MainActor private func coverageClosureSettings() -> Settings { - Settings(defaults: UserDefaults(suiteName: "core-coverage-closure-\(UUID().uuidString)")!) + Settings(defaults: testDefaults("core-coverage-closure")) } private func coverageClosureWindow( diff --git a/Tests/TokenMenuBarCoreTests/CorrectedCoverageBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/CorrectedCoverageBehaviorTests.swift index 3f40c12..0c1c477 100644 --- a/Tests/TokenMenuBarCoreTests/CorrectedCoverageBehaviorTests.swift +++ b/Tests/TokenMenuBarCoreTests/CorrectedCoverageBehaviorTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -199,7 +200,8 @@ func correctedCoverageGeminiRejectsRefreshResponsesWithoutAnAccessToken(body: St let result = await provider.fetch(now: fixedNow, options: FetchOptions()) - #expect(result.outcome == .notAuthenticated("Gemini token refresh failed: HTTP 401")) + let detail = body.contains("invalid_grant") ? "invalid_grant" : "refresh returned no access token" + #expect(result.outcome == .notAuthenticated("Gemini token refresh failed: \(detail)")) #expect(transport.requests(matching: "/token").count == 1) } diff --git a/Tests/TokenMenuBarCoreTests/CoverageGateSettingsPresentationBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/CoverageGateSettingsPresentationBehaviorTests.swift index 6812443..e2a4067 100644 --- a/Tests/TokenMenuBarCoreTests/CoverageGateSettingsPresentationBehaviorTests.swift +++ b/Tests/TokenMenuBarCoreTests/CoverageGateSettingsPresentationBehaviorTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -20,9 +21,13 @@ import Testing #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") + let limited = SettingsProviderPresentation.service(.rateLimited(retryAt: retry, detail: "Slow down")) + let limitedLater = SettingsProviderPresentation.service( + .rateLimited(retryAt: retry.addingTimeInterval(86_400), detail: "Slow down")) + #expect(limited.hasPrefix("Rate limited · retry ")) + #expect(limited.hasSuffix(" · Slow down")) + #expect(limited.count > "Rate limited · retry · Slow down".count) + #expect(limited != limitedLater) #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 index 07974b0..8e50fd3 100644 --- a/Tests/TokenMenuBarCoreTests/CoverageRefreshTests.swift +++ b/Tests/TokenMenuBarCoreTests/CoverageRefreshTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -160,5 +161,5 @@ import Testing @MainActor private func coverageSettings() -> Settings { - Settings(defaults: UserDefaults(suiteName: "coverage-refresh-\(UUID().uuidString)")!) + Settings(defaults: testDefaults("coverage-refresh")) } diff --git a/Tests/TokenMenuBarCoreTests/CredentialRefreshRaceTests.swift b/Tests/TokenMenuBarCoreTests/CredentialRefreshRaceTests.swift index 42e99cb..153aca2 100644 --- a/Tests/TokenMenuBarCoreTests/CredentialRefreshRaceTests.swift +++ b/Tests/TokenMenuBarCoreTests/CredentialRefreshRaceTests.swift @@ -1,6 +1,7 @@ import Foundation import Security import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -68,7 +69,7 @@ import Testing Issue.record("expected authentication cancellation") return } - #expect(reason.contains("HTTP 401")) + #expect(reason.contains("credentials were removed during refresh")) #expect(transport.requests(matching: "/api/oauth/usage").isEmpty) } @@ -84,7 +85,7 @@ import Testing Issue.record("expected authentication cancellation") return } - #expect(reason.contains("HTTP 401")) + #expect(reason.contains("credentials were removed during refresh")) #expect(transport.requests(matching: "/wham/usage").isEmpty) } @@ -102,7 +103,7 @@ import Testing Issue.record("expected authentication cancellation") return } - #expect(reason.contains("HTTP 401")) + #expect(reason.contains("credentials were removed during refresh")) #expect(transport.requests(matching: ":retrieveUserQuota").isEmpty) } diff --git a/Tests/TokenMenuBarCoreTests/CredentialsTests.swift b/Tests/TokenMenuBarCoreTests/CredentialsTests.swift index 3d3bb3f..4e049bc 100644 --- a/Tests/TokenMenuBarCoreTests/CredentialsTests.swift +++ b/Tests/TokenMenuBarCoreTests/CredentialsTests.swift @@ -1,5 +1,8 @@ import Foundation +import LocalAuthentication +import Security import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -92,6 +95,80 @@ import Testing #expect(try client.load(service: "test", account: "account")?.data == Data("second".utf8)) } +@Test func systemKeychainReadsDisallowAuthenticationUI() throws { + let query = systemKeychainQuery(service: "test", account: "account") + let context = try #require(query[kSecUseAuthenticationContext as String] as? LAContext) + + #expect(context.interactionNotAllowed) + #expect(query[kSecUseAuthenticationUI as String] as? String == "u_AuthUIF") +} + +@Test(arguments: [errSecAuthFailed, errSecUserCanceled, errSecInteractionNotAllowed]) +func keychainClientSkipsReadsForThirtyMinutesAfterADenial(status: OSStatus) throws { + let box = DateBox(fixedNow) + let reads = ReadCounter() + let client = KeychainCredentialClient( + load: { _, _ in + reads.increment() + throw CredentialStoreError.keychain(status) + }, save: { _, _, _ in } + ).cachingDenials(clock: box.clock) + + #expect(throws: CredentialStoreError.keychain(status)) { try client.load(service: "svc", account: "me") } + #expect(throws: CredentialStoreError.keychain(status)) { try client.load(service: "svc", account: "me") } + #expect(reads.count == 1) + #expect(throws: CredentialStoreError.keychain(status)) { try client.load(service: "svc", account: "other") } + #expect(reads.count == 2) + box.date = fixedNow.addingTimeInterval(KeychainCredentialClient.denialCacheInterval) + #expect(throws: CredentialStoreError.keychain(status)) { try client.load(service: "svc", account: "me") } + #expect(reads.count == 3) +} + +@Test func keychainClientRetriesOtherReadFailuresImmediately() throws { + let reads = ReadCounter() + let client = KeychainCredentialClient( + load: { _, _ in + reads.increment() + throw CredentialStoreError.keychain(errSecNotAvailable) + }, save: { _, _, _ in } + ).cachingDenials(clock: testClock) + + #expect(throws: CredentialStoreError.keychain(errSecNotAvailable)) { try client.load(service: "svc", account: "me") } + #expect(throws: CredentialStoreError.keychain(errSecNotAvailable)) { try client.load(service: "svc", account: "me") } + #expect(reads.count == 2) +} + +@Test func keychainClientCachingDenialsStillRoundTripsItems() throws { + let client = MemoryKeychain().client.cachingDenials(clock: testClock) + + try client.save(Data("secret".utf8), service: "svc", account: "me") + + #expect(try client.load(service: "svc", account: "me")?.data == Data("secret".utf8)) +} + +@Test(arguments: [errSecAuthFailed, errSecUserCanceled, errSecInteractionNotAllowed]) +func deniedKeychainReadsReportTheDenialInsteadOfAMissingSignIn(status: OSStatus) { + let client = KeychainCredentialClient( + load: { _, _ in throw CredentialStoreError.keychain(status) }, save: { _, _, _ in }) + let store = KeychainClaudeCredentialStore(account: "me", keychain: client) + + #expect( + store.credentialHealth(now: fixedNow) + == .unreadable( + source: store.source, detail: "Keychain access denied; allow it in Keychain Access or sign in again.")) +} + +private final class ReadCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + var count: Int { lock.withLock { value } } + + func increment() { + lock.withLock { value += 1 } + } +} + @Test func fileClaudeStoreRoundTripsAndRejectsGarbage() throws { let directory = temporaryDirectory() let store = FileClaudeCredentialStore(url: directory.appendingPathComponent(".credentials.json")) @@ -103,6 +180,23 @@ import Testing #expect(throws: CredentialStoreError.malformed(".credentials.json is not JSON")) { try store.load() } } +@Test(arguments: ["claude", "codex", "gemini"]) +func credentialFileSavePreservesSymlinksAndOwnerOnlyPermissions(kindName: String) throws { + let kind = try #require(CredentialFileKind(rawValue: kindName)) + let directory = temporaryDirectory() + let target = directory.appendingPathComponent("stored.json") + let link = directory.appendingPathComponent("credentials.json") + try kind.save(token: "old", to: target) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + + try kind.save(token: "updated", to: link) + + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: link.path) == target.path) + #expect(try kind.token(at: target) == "updated") + let attributes = try FileManager.default.attributesOfItem(atPath: target.path) + #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600) +} + @Test func chainedClaudeStorePrefersFirstHitAndSavesWhereFound() throws { let directory = temporaryDirectory() let empty = FileClaudeCredentialStore(url: directory.appendingPathComponent("empty.json")) @@ -396,3 +490,54 @@ import Testing #expect(ISODate.parse(nil) == nil) #expect(ISODate.string(Date(timeIntervalSince1970: 0)) == "1970-01-01T00:00:00.000Z") } + +private enum CredentialFileKind: String { + case claude + case codex + case gemini + + func save(token: String, to url: URL) throws { + switch self { + case .claude: + try FileClaudeCredentialStore(url: url).save( + ClaudeOAuthCredentials(accessToken: token, refreshToken: nil, expiresAt: nil)) + case .codex: + try FileCodexAuthStore(url: url).save(CodexAuth(accessToken: token)) + case .gemini: + try FileGeminiAuthStore(url: url).save(GeminiAuth(accessToken: token)) + } + } + + func token(at url: URL) throws -> String? { + switch self { + case .claude: try FileClaudeCredentialStore(url: url).load()?.accessToken + case .codex: try FileCodexAuthStore(url: url).load()?.accessToken + case .gemini: try FileGeminiAuthStore(url: url).load()?.accessToken + } + } +} + +@Test func credentialFileWriteLeavesNothingBehindWhenTheDestinationCannotBeReplaced() throws { + let directory = temporaryDirectory() + let occupied = directory.appendingPathComponent("credentials.json") + try FileManager.default.createDirectory(at: occupied, withIntermediateDirectories: true) + try Data("keep".utf8).write(to: occupied.appendingPathComponent("child")) + + #expect(throws: (any Error).self) { try writeCredentialData(Data("{}".utf8), to: occupied) } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path) == ["credentials.json"]) + #expect(try FileManager.default.contentsOfDirectory(atPath: occupied.path) == ["child"]) +} + +@Test func resolveCredentialDropsAPendingSaveWhoseTargetVanished() throws { + let source = CredentialSource(id: "memory", provider: .codex, title: "Memory", detail: "memory") + var pending: PendingCredentialSave? = PendingCredentialSave( + credential: "refreshed", replacing: "stale", source: source, detail: "write failed") + + let resolved = try resolveCredential( + pending: &pending, provider: .codex, load: { ("stale", source) }, save: { _, _ in .removed }) + + #expect(resolved.credential == nil) + #expect(resolved.source == nil) + #expect(resolved.issue == nil) + #expect(pending == nil) +} diff --git a/Tests/TokenMenuBarCoreTests/CursorTests.swift b/Tests/TokenMenuBarCoreTests/CursorTests.swift index fd420fd..af6799e 100644 --- a/Tests/TokenMenuBarCoreTests/CursorTests.swift +++ b/Tests/TokenMenuBarCoreTests/CursorTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func cursorAuthDerivesUserAndCookie() { #expect(validCursor.userID == "user_01ABC") @@ -175,18 +176,21 @@ private func cursorPlanWindows(_ fields: String) async throws -> [QuotaWindow] { 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), + (#""autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 3"#, ["plan"], [3.0]), + (#""autoPercentUsed": 10, "apiPercentUsed": 20"#, ["plan:api", "plan:auto"], [20.0, 10.0]), + (#""autoPercentUsed": 10"#, ["plan:auto"], [10.0]), + (#""apiPercentUsed": 20"#, ["plan:api"], [20.0]), + (#""used": 25, "limit": 100"#, ["plan"], [25.0]), + (#""used": 25, "limit": 100, "autoPercentUsed": 10, "apiPercentUsed": 20"#, ["plan"], [25.0]), ]) -func cursorPicksThePercentTheVendorReports(fields: String, percent: Double) async throws { - #expect(try await cursorPlanWindows(fields).map(\.usedPercent) == [percent]) +func cursorKeepsAggregateAndComponentPercentsSeparate( + fields: String, ids: [String], percents: [Double] +) async throws { + let windows = try await cursorPlanWindows(fields) + #expect(windows.map(\.id) == ids) + #expect(windows.map(\.usedPercent) == percents) } @Test(arguments: [#""used": 25, "limit": 0"#, #""remaining": 3"#]) diff --git a/Tests/TokenMenuBarCoreTests/DemoDataTests.swift b/Tests/TokenMenuBarCoreTests/DemoDataTests.swift index 7fb157d..25fa0ff 100644 --- a/Tests/TokenMenuBarCoreTests/DemoDataTests.swift +++ b/Tests/TokenMenuBarCoreTests/DemoDataTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/DiagnosticsTests.swift b/Tests/TokenMenuBarCoreTests/DiagnosticsTests.swift index cf83acc..7d25fb9 100644 --- a/Tests/TokenMenuBarCoreTests/DiagnosticsTests.swift +++ b/Tests/TokenMenuBarCoreTests/DiagnosticsTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func appInfoReadsBundleWithFallbacks() { let info = AppInfo.from(bundle: Bundle(for: DateBox.self), isAppStore: true) @@ -107,7 +108,7 @@ func appInfoNamesPrereleaseBuilds(sourceVersion: String, prerelease: Bool) { } @Test @MainActor func diagnosticsReportListsProvidersAndLog() throws { - let settings = Settings(defaults: UserDefaults(suiteName: "diag-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("diagnostics")) let state = AppState() state.update(.claude) { $0.snapshot = ProviderSnapshot( @@ -152,7 +153,7 @@ func appInfoNamesPrereleaseBuilds(sourceVersion: String, prerelease: Bool) { } @Test @MainActor func diagnosticsRedactsPrivateReportFields() { - let settings = Settings(defaults: UserDefaults(suiteName: "diag-private-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("diagnostics-private")) settings.setProvider(.codex, enabled: true) let state = AppState() state.update(.codex) { @@ -173,7 +174,7 @@ func appInfoNamesPrereleaseBuilds(sourceVersion: String, prerelease: Bool) { } @Test @MainActor func diagnosticsNamesHomebrewDistribution() { - let settings = Settings(defaults: UserDefaults(suiteName: "diag-homebrew-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("diagnostics-homebrew")) let report = Diagnostics.report( app: AppInfo( name: "Token Menu Bar", version: "1", build: "1", bundleIdentifier: "dev.tox.token-menu-bar", diff --git a/Tests/TokenMenuBarCoreTests/FormatTests.swift b/Tests/TokenMenuBarCoreTests/FormatTests.swift index 9bb7a6e..d0fbcc3 100644 --- a/Tests/TokenMenuBarCoreTests/FormatTests.swift +++ b/Tests/TokenMenuBarCoreTests/FormatTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport private let percentCases: [(Double, Int, String)] = [ (36.0, 0, "36%"), (36.46, 1, "36.5%"), (-5.0, 0, "0%"), (250.0, 0, "100%"), @@ -39,6 +40,14 @@ func formatCountdowns(offset: TimeInterval?, long: String, compact: String) { #expect(Format.resetClock(fixedNow.addingTimeInterval(20 * 86400), now: fixedNow, calendar: calendar).contains("Sep")) } +// GitHub publishes the Copilot reset as a bare date; the published day must show whatever zone the Mac is in. +@Test func formatResetClockNamesOnlyTheDayForADayPrecisionReset() throws { + let reset = try #require(DayStamp.date("2026-09-01")) + #expect(Format.resetClock(reset, precision: .day, now: fixedNow) == "Sep 1") + #expect(Format.resetClock(reset, precision: .day, now: reset) == "now") + #expect(Format.resetClock(nil, precision: .day, now: fixedNow) == "—") +} + 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"), ] diff --git a/Tests/TokenMenuBarCoreTests/GeminiTests.swift b/Tests/TokenMenuBarCoreTests/GeminiTests.swift index 8fd6f42..074a2d1 100644 --- a/Tests/TokenMenuBarCoreTests/GeminiTests.swift +++ b/Tests/TokenMenuBarCoreTests/GeminiTests.swift @@ -1,6 +1,8 @@ import Foundation import Testing -import TokenMenuBarCore +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore @Test func geminiAuthParsesDocumentAndClaims() { let auth = GeminiAuth(document: Fixtures.json("gemini_creds"))! @@ -74,7 +76,7 @@ private func geminiSnapshot( #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 }) + #expect(snapshot.windows.allSatisfy { $0.resetsAt == ISODate.parse("2026-08-30T07:00:00Z") && $0.duration == nil }) } @Test func geminiReportsTheTierAndItsCredits() async throws { @@ -140,6 +142,11 @@ func geminiNamesThePlanFromTheTier(tier: String, hosted: String?, expected: Stri 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") + #if arch(arm64) + #expect(quotaRequest.value(forHTTPHeaderField: "User-Agent")?.contains("darwin; arm64; cli") == true) + #elseif arch(x86_64) + #expect(quotaRequest.value(forHTTPHeaderField: "User-Agent")?.contains("darwin; x64; cli") == true) + #endif _ = await provider.fetch(now: fixedNow.addingTimeInterval(10), options: FetchOptions()) #expect(transport.requests(matching: ":loadCodeAssist").count == 1) try store.save( @@ -210,6 +217,24 @@ private func makeProvider( #expect(subscriptionReason.contains("Login with Google")) } +@Test func geminiFindsSubscriptionRequiredInErrorDetails() { + let message = String(repeating: "account does not support this operation ", count: 8) + let body = + #"{"error":{"message":"\#(message)","status":"PERMISSION_DENIED","details":[{"reason":"SUBSCRIPTION_REQUIRED"}]}}"# + #expect(GeminiAPI.isSubscriptionRequired(body)) +} + +@Test( + arguments: [ + (#"{"error":{"status":"SUBSCRIPTION_REQUIRED"}}"#, true), + ("prefix SUBSCRIPTION_REQUIRED suffix", true), + (#"{"error":{"status":"PERMISSION_DENIED"}}"#, false), + (#"{"status":"SUBSCRIPTION_REQUIRED"}"#, true), + ]) +func geminiClassifiesSubscriptionErrors(body: String, expected: Bool) { + #expect(GeminiAPI.isSubscriptionRequired(body) == expected) +} + @Test func geminiProviderPropagatesHTTPFailures() async { let (authFailure, transport, _) = makeProvider(validGemini) transport.on(path: ":loadCodeAssist", .text("denied", status: 401)) @@ -267,13 +292,18 @@ private func makeProvider( #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."}"#)) + transport.on( + path: "/token", + .text(#"{"error":"invalid_grant","error_description":"Token has been revoked."}"#, status: 401)) 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") + #expect( + revoked + == "Gemini token refresh failed: the provider rejected the stored sign-in (invalid_grant). " + + "Sign in with the CLI again.") let store = MemoryGeminiStore(withToken) store.saveError = TestError() let (unsaved, transport2, _) = makeProvider(nil, allowRefresh: true, store: store) diff --git a/Tests/TokenMenuBarCoreTests/HistoryClosureBehaviorCoverageTests.swift b/Tests/TokenMenuBarCoreTests/HistoryClosureBehaviorCoverageTests.swift index b6bd232..bf5d497 100644 --- a/Tests/TokenMenuBarCoreTests/HistoryClosureBehaviorCoverageTests.swift +++ b/Tests/TokenMenuBarCoreTests/HistoryClosureBehaviorCoverageTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -84,7 +85,7 @@ private let historyClosureNow = Date(timeIntervalSince1970: 1_788_030_000) @MainActor private func historyClosureSettings() -> Settings { - Settings(defaults: UserDefaults(suiteName: "history-closure-\(UUID().uuidString)")!) + Settings(defaults: testDefaults("history-closure")) } private func historyClosureRecordStackableAnalytics(in history: UsageHistoryStore) async throws { diff --git a/Tests/TokenMenuBarCoreTests/HistoryCoverageBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/HistoryCoverageBehaviorTests.swift index 353b3d0..3394887 100644 --- a/Tests/TokenMenuBarCoreTests/HistoryCoverageBehaviorTests.swift +++ b/Tests/TokenMenuBarCoreTests/HistoryCoverageBehaviorTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/HistoryPresenterAnalyticsTests.swift b/Tests/TokenMenuBarCoreTests/HistoryPresenterAnalyticsTests.swift index d0c85a8..8ceeac4 100644 --- a/Tests/TokenMenuBarCoreTests/HistoryPresenterAnalyticsTests.swift +++ b/Tests/TokenMenuBarCoreTests/HistoryPresenterAnalyticsTests.swift @@ -1,10 +1,11 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test(arguments: HistoryRange.allCases) @MainActor func historyPresenterLoadsCodexOnlyAnalyticsForEachRange(_ range: HistoryRange) async throws { - let settings = Settings(defaults: UserDefaults(suiteName: "history-analytics-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("history-analytics")) settings.historyRange = range let history = try UsageHistoryStore(url: nil) let presenter = HistoryPresenter(history: history, settings: settings, clock: testClock) @@ -24,7 +25,7 @@ import TokenMenuBarCore } @Test @MainActor func historyPresenterLimitsAnalyticsToActiveProviders() async throws { - let settings = Settings(defaults: UserDefaults(suiteName: "history-active-providers-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("history-active-providers")) let history = try UsageHistoryStore(url: nil) let presenter = HistoryPresenter( history: history, settings: settings, clock: testClock, initialMetric: .analytics(.inputTokens)) @@ -45,7 +46,7 @@ import TokenMenuBarCore } @Test @MainActor func historyPresenterPagesThroughExactNonoverlappingUTCDays() async throws { - let settings = Settings(defaults: UserDefaults(suiteName: "history-pages-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("history-pages")) settings.historyRange = .week let history = try UsageHistoryStore(url: nil) let presenter = HistoryPresenter( @@ -87,7 +88,7 @@ private func exportedDays(from url: URL) throws -> Set { } @Test @MainActor func historyPresenterUsesTheSelectedAnalyticsMetricForNavigation() async throws { - let settings = Settings(defaults: UserDefaults(suiteName: "history-earliest-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("history-earliest")) settings.historyRange = .today let history = try UsageHistoryStore(url: nil) let presenter = HistoryPresenter( @@ -105,7 +106,7 @@ private func exportedDays(from url: URL) throws -> Set { } @Test @MainActor func historyPresenterPagesTodayWithoutDuplicatingUTCDays() async throws { - let settings = Settings(defaults: UserDefaults(suiteName: "history-today-pages-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("history-today-pages")) settings.historyRange = .today let history = try UsageHistoryStore(url: nil) let presenter = HistoryPresenter( @@ -131,7 +132,7 @@ private func exportedDays(from url: URL) throws -> Set { } @Test @MainActor func historyPresenterPagesACustomAnalyticsDurationByWholeUTCDays() async throws { - let settings = Settings(defaults: UserDefaults(suiteName: "history-custom-pages-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("history-custom-pages")) settings.historyRange = .custom let history = try UsageHistoryStore(url: nil) let presenter = HistoryPresenter( @@ -155,7 +156,7 @@ private func exportedDays(from url: URL) throws -> Set { } @Test @MainActor func historyPresenterTogglesAndRestoresAnalyticsSeries() async throws { - let settings = Settings(defaults: UserDefaults(suiteName: "history-analytics-visibility-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("history-analytics-visibility")) let history = try UsageHistoryStore(url: nil) let presenter = HistoryPresenter( history: history, settings: settings, clock: testClock, initialMetric: .analytics(.surfaceUsagePercent)) diff --git a/Tests/TokenMenuBarCoreTests/InterfaceTokensTests.swift b/Tests/TokenMenuBarCoreTests/InterfaceTokensTests.swift index 71f22b5..c2da435 100644 --- a/Tests/TokenMenuBarCoreTests/InterfaceTokensTests.swift +++ b/Tests/TokenMenuBarCoreTests/InterfaceTokensTests.swift @@ -1,5 +1,6 @@ import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func interfaceTokensReserveAccentForSelectionAndData() { let tokens = InterfaceTokens.standard diff --git a/Tests/TokenMenuBarCoreTests/JSONValueTests.swift b/Tests/TokenMenuBarCoreTests/JSONValueTests.swift index 13f3a55..73a2f6b 100644 --- a/Tests/TokenMenuBarCoreTests/JSONValueTests.swift +++ b/Tests/TokenMenuBarCoreTests/JSONValueTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func jsonValueRoundTripsAllKinds() throws { let text = #"{"a":null,"b":true,"c":1.5,"d":"x","e":[1,"y"],"f":{"g":2}}"# diff --git a/Tests/TokenMenuBarCoreTests/LaunchPolicyTests.swift b/Tests/TokenMenuBarCoreTests/LaunchPolicyTests.swift index 7c18a1a..3af53e1 100644 --- a/Tests/TokenMenuBarCoreTests/LaunchPolicyTests.swift +++ b/Tests/TokenMenuBarCoreTests/LaunchPolicyTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func standardLaunchKeepsTheProcessEnvironmentAndDefaults() { let suite = "launch-policy-standard-\(UUID().uuidString)" diff --git a/Tests/TokenMenuBarCoreTests/LogBufferTests.swift b/Tests/TokenMenuBarCoreTests/LogBufferTests.swift index f78c378..8f447e4 100644 --- a/Tests/TokenMenuBarCoreTests/LogBufferTests.swift +++ b/Tests/TokenMenuBarCoreTests/LogBufferTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func logBufferKeepsEntriesAndLevels() { let log = makeLog() diff --git a/Tests/TokenMenuBarCoreTests/MenuCommandTests.swift b/Tests/TokenMenuBarCoreTests/MenuCommandTests.swift index 73a8aa1..c1886b2 100644 --- a/Tests/TokenMenuBarCoreTests/MenuCommandTests.swift +++ b/Tests/TokenMenuBarCoreTests/MenuCommandTests.swift @@ -1,5 +1,6 @@ import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func menuHoldsOnlyRefreshAndQuit() { let commands = MenuCommand.menu(canCheckForUpdates: false, appName: "Token Menu Bar") diff --git a/Tests/TokenMenuBarCoreTests/MissingCredentialHealthTests.swift b/Tests/TokenMenuBarCoreTests/MissingCredentialHealthTests.swift index 92fea8d..620b7e3 100644 --- a/Tests/TokenMenuBarCoreTests/MissingCredentialHealthTests.swift +++ b/Tests/TokenMenuBarCoreTests/MissingCredentialHealthTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/ModelTests.swift b/Tests/TokenMenuBarCoreTests/ModelTests.swift index a79ead7..50092d0 100644 --- a/Tests/TokenMenuBarCoreTests/ModelTests.swift +++ b/Tests/TokenMenuBarCoreTests/ModelTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func providerIDMetadata() { #expect(ProviderID.claude < .codex) @@ -170,8 +171,6 @@ extension QuotaAvailability { 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( @@ -179,8 +178,6 @@ extension QuotaAvailability { #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) @@ -414,3 +411,16 @@ func exportCommandParsesItsFlag(arguments: [String], command: ExportCommand) { func exportCommandNeedsAFlagAndADirectory(arguments: [String]) { #expect(ExportCommand.parse(arguments) == nil) } + +@Test func quotaWindowDecodesSnapshotsStoredBeforeResetPrecisionExisted() throws { + let window = QuotaWindow( + id: "session", label: "Session", group: .session, usedPercent: 40, resetsAt: fixedNow, resetPrecision: .day) + var document = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(window)) as? [String: Any]) + document["resetPrecision"] = nil + + let legacy = try JSONDecoder().decode(QuotaWindow.self, from: JSONSerialization.data(withJSONObject: document)) + + #expect(legacy.resetPrecision == .instant) + #expect(legacy.id == window.id) + #expect(legacy.resetsAt == window.resetsAt) +} diff --git a/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift b/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift index 0b5546b..2f2595a 100644 --- a/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift +++ b/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func plannerIsSilentWhenDisabledOrWithoutHistory() { #expect(plan(nil, snapshot(99)).isEmpty) @@ -25,7 +26,7 @@ private func snapshot( private func plan( _ previous: ProviderSnapshot?, _ current: ProviderSnapshot?, from: QuotaAvailability = .current, - to: QuotaAvailability = .current, settings: NotificationSettings = NotificationSettings() + to: QuotaAvailability = .current, settings: NotificationSettings = NotificationSettings(enabled: true) ) -> [NotificationEvent] { NotificationPlanner.events( previous: previous, current: current, previousAvailability: from, currentAvailability: to, provider: .claude, @@ -75,6 +76,7 @@ private func plan( 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(auth[0].id == "claude:auth") #expect(plan(snapshot(10), snapshot(10), from: .authenticationRequired, to: .current).isEmpty) #expect(plan(nil, nil, from: .current, to: .authenticationRequired).count == 1) #expect( @@ -83,11 +85,13 @@ private func plan( ).isEmpty) let credits = plan(snapshot(10, credits: true), snapshot(10, credits: false)) #expect(credits.map(\.kind) == [.credits]) + #expect(credits[0].id == "claude: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.enabled) #expect(settings.thresholds == [50, 90]) #expect(NotificationSettings().thresholds == [75, 90, 100]) } diff --git a/Tests/TokenMenuBarCoreTests/PaceComparisonTests.swift b/Tests/TokenMenuBarCoreTests/PaceComparisonTests.swift index 0e20956..ae8de07 100644 --- a/Tests/TokenMenuBarCoreTests/PaceComparisonTests.swift +++ b/Tests/TokenMenuBarCoreTests/PaceComparisonTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/PaceTests.swift b/Tests/TokenMenuBarCoreTests/PaceTests.swift index eb1031b..fb79b88 100644 --- a/Tests/TokenMenuBarCoreTests/PaceTests.swift +++ b/Tests/TokenMenuBarCoreTests/PaceTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func paceExhaustedAt100() { let estimate = PaceEstimate.estimate(window: window(used: 100, elapsedFraction: 0.5), now: fixedNow) diff --git a/Tests/TokenMenuBarCoreTests/PanelMaterialTests.swift b/Tests/TokenMenuBarCoreTests/PanelMaterialTests.swift index 7ba4760..7b37646 100644 --- a/Tests/TokenMenuBarCoreTests/PanelMaterialTests.swift +++ b/Tests/TokenMenuBarCoreTests/PanelMaterialTests.swift @@ -1,4 +1,5 @@ import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/PresenterTests.swift b/Tests/TokenMenuBarCoreTests/PresenterTests.swift index 2690f7d..001346a 100644 --- a/Tests/TokenMenuBarCoreTests/PresenterTests.swift +++ b/Tests/TokenMenuBarCoreTests/PresenterTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -418,7 +419,7 @@ private func loadedPresenter() async throws -> (HistoryPresenter, Settings, Hist @MainActor private func makePresenter() throws -> (HistoryPresenter, UsageHistoryStore, Settings) { - let settings = Settings(defaults: UserDefaults(suiteName: "history-presenter-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("history-presenter")) let history = try UsageHistoryStore(url: nil) return (HistoryPresenter(history: history, settings: settings, clock: testClock), history, settings) } @@ -454,7 +455,7 @@ private func makePresenter() throws -> (HistoryPresenter, UsageHistoryStore, Set } @Test @MainActor func historyPresenterPersistsTheSelectedMetric() throws { - let settings = Settings(defaults: UserDefaults(suiteName: "history-persistence-\(UUID().uuidString)")!) + let settings = Settings(defaults: testDefaults("history-persistence")) let history = try UsageHistoryStore(url: nil) var persisted: HistoryMetric? let presenter = HistoryPresenter( diff --git a/Tests/TokenMenuBarCoreTests/ProcessPerformanceSnapshotTests.swift b/Tests/TokenMenuBarCoreTests/ProcessPerformanceSnapshotTests.swift index 2decf16..4c5577b 100644 --- a/Tests/TokenMenuBarCoreTests/ProcessPerformanceSnapshotTests.swift +++ b/Tests/TokenMenuBarCoreTests/ProcessPerformanceSnapshotTests.swift @@ -1,5 +1,6 @@ import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func currentProcessPerformanceSnapshotContainsLiveCounters() throws { let snapshot = try #require(ProcessPerformanceSnapshot.current()) diff --git a/Tests/TokenMenuBarCoreTests/ProviderActivationTests.swift b/Tests/TokenMenuBarCoreTests/ProviderActivationTests.swift index 92dd470..d34d281 100644 --- a/Tests/TokenMenuBarCoreTests/ProviderActivationTests.swift +++ b/Tests/TokenMenuBarCoreTests/ProviderActivationTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -50,8 +51,7 @@ import Testing } @Test @MainActor func providerActivationRequiresDiscoveryAndHonorsExplicitOverrides() { - let defaults = UserDefaults(suiteName: "ProviderActivationTests.overrides")! - defaults.removePersistentDomain(forName: "ProviderActivationTests.overrides") + let defaults = testDefaults("provider-activation-overrides") let settings = Settings(defaults: defaults) let discovered = ProviderState(snapshot: DemoData.snapshot(.claude, now: Date())) @@ -64,9 +64,7 @@ import Testing } @Test @MainActor func providerActivationMigratesLegacySelectionsToExplicitOverrides() throws { - let name = "ProviderActivationTests.legacy" - let defaults = UserDefaults(suiteName: name)! - defaults.removePersistentDomain(forName: name) + let defaults = testDefaults("provider-activation-legacy") defaults.set(try JSONEncoder().encode(Set([ProviderID.codex])), forKey: "enabledProviders") let settings = Settings(defaults: defaults) @@ -76,8 +74,7 @@ import Testing } @Test @MainActor func providerActivationResetReturnsEveryProviderToAutomaticDiscovery() { - let defaults = UserDefaults(suiteName: "ProviderActivationTests.reset")! - defaults.removePersistentDomain(forName: "ProviderActivationTests.reset") + let defaults = testDefaults("provider-activation-reset") let settings = Settings(defaults: defaults) settings.setProvider(.claude, enabled: false) settings.showAllProviders = true @@ -89,8 +86,7 @@ import Testing } @Test @MainActor func configuredProviderSettingsRetainRowsWithoutChangingAutomaticActivation() { - let defaults = UserDefaults(suiteName: "ProviderActivationTests.configuration")! - defaults.removePersistentDomain(forName: "ProviderActivationTests.configuration") + let defaults = testDefaults("provider-activation-configuration") let settings = Settings(defaults: defaults) settings.setRefreshInterval(300, for: .gemini) diff --git a/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift index 6b763fd..14e58fa 100644 --- a/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift +++ b/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -31,7 +32,7 @@ import Testing @Test func nestedCoverageClaudeSpendDefaultsDisabledAndReadsAutoReload() throws { let response = try decodeClaudeUsage(#"{"spend":{"auto_reload":{"enabled":true}}}"#) - let spend = try #require(ClaudeMapper.spend(response, now: fixedNow)) + let spend = try #require(ClaudeMapper.spend(response)) #expect(!spend.enabled) #expect(spend.autoReload == true) @@ -41,7 +42,7 @@ import Testing let response = try decodeClaudeUsage( #"{"limits":[{"kind":"session","percent":100,"severity":"critical"}]}"#) - #expect(ClaudeMapper.notices(response).map(\.text) == ["Current session limit reached; resets later."]) + #expect(ClaudeMapper.notices(response, now: fixedNow).map(\.text) == ["Current session limit reached; resets later."]) } @Test func nestedCoverageClaudeProfileFallsBackToItsExpiredCache() async throws { @@ -123,13 +124,12 @@ import Testing #expect(CodexMapper.analytics(.plugins, rows: rows).map(\.series) == ["alpha", "zeta"]) } -@Test func nestedCoverageCodexCreditEventDefaultsUsageAndID() { +@Test func nestedCoverageCodexCreditEventRequiresUsage() { let rows: [JSONValue] = [.object(["date": .string("2026-08-29")])] - let event = CodexMapper.creditEvents(rows).first + let mapping = CodexMapper.creditEventMapping(rows) - #expect(event?.creditsUsed == 0) - #expect(event?.service == "Codex") - #expect(event?.id == "2026-08-29-0") + #expect(mapping.events.isEmpty) + #expect(mapping.missingAmountCount == 1) } @Test func nestedCoverageCodexRefreshBuildsTokensForAnAPIKeyDocument() throws { diff --git a/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift b/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift index e4a8c7c..5329040 100644 --- a/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift +++ b/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/ProviderSetupTests.swift b/Tests/TokenMenuBarCoreTests/ProviderSetupTests.swift index 8701752..49aee82 100644 --- a/Tests/TokenMenuBarCoreTests/ProviderSetupTests.swift +++ b/Tests/TokenMenuBarCoreTests/ProviderSetupTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -293,7 +294,7 @@ func appStateOmitsRecoveryForHealthyAvailability(_ availability: QuotaAvailabili store.loadError = CredentialStoreError.malformed("broken") #expect( store.credentialHealth(now: fixedNow) - == .unreadable(source: store.source, detail: #"malformed("broken")"#)) + == .unreadable(source: store.source, detail: "broken")) } @Test func credentialChainsRetainTheFailingSource() throws { @@ -529,3 +530,12 @@ private func expectUnreadable(_ health: ProviderCredentialHealth, source: Creden } #expect(found == source) } + +@Test func credentialInspectionNamesAnUnreadableFile() { + let store = MemoryGeminiStore(nil) + store.loadError = CocoaError(.fileReadNoSuchFile) + + #expect( + store.credentialHealth(now: fixedNow) + == .unreadable(source: store.source, detail: "The credential file could not be read.")) +} diff --git a/Tests/TokenMenuBarCoreTests/ProviderTests.swift b/Tests/TokenMenuBarCoreTests/ProviderTests.swift index 86db7be..9c14f41 100644 --- a/Tests/TokenMenuBarCoreTests/ProviderTests.swift +++ b/Tests/TokenMenuBarCoreTests/ProviderTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -69,8 +70,9 @@ func claudeProvider( 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( + unreadable.outcome == .notAuthenticated("Cannot read Claude credentials: The Keychain item could not be read.")) + #expect(provider.credentialState(now: fixedNow) == .missing("The Keychain item could not be read.")) #expect( claudeProvider(MemoryClaudeStore(nil), transport: transport).credentialState(now: fixedNow) == .missing("no Claude Code sign-in found")) @@ -103,7 +105,7 @@ func claudeProvider( @Test func claudeProviderRefreshFailuresAndUnsavableTokens() async { let transport = StubTransport() - transport.on(path: "/v1/oauth/token", .text(#"{"error":"invalid_grant"}"#, status: 400)) + transport.on(path: "/v1/oauth/token", .text(#"{"error":"server_error"}"#, 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")) @@ -111,7 +113,7 @@ func claudeProvider( 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")) + #expect(noToken.outcome == .notAuthenticated("Claude token refresh failed: the credential has no refresh token")) let saving = StubTransport() saving.on(path: "/v1/oauth/token", .text(#"{"access_token":"fresh"}"#)) saving.on(path: "/api/oauth/usage", .json("claude_usage")) @@ -140,7 +142,8 @@ func claudeProvider( #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/usage", .text(#"{"error":{"type":"permission_error","message":"Unauthorized"}}"#, 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( @@ -239,7 +242,7 @@ func codexProvider( allowRefresh: Bool = false, rollouts: URL? = nil, analyticsWatermarkPersistence: CodexAnalyticsWatermarkPersistence = CodexAnalyticsWatermarkPersistence( - defaults: UserDefaults(suiteName: "codex-watermarks-\(UUID().uuidString)")!) + defaults: testDefaults("codex-watermarks")) ) -> CodexProvider { CodexProvider( auth: store, rollouts: rollouts.map { CodexRolloutReader(sessionsRoot: $0) }, @@ -247,6 +250,17 @@ func codexProvider( allowRefresh: { allowRefresh }, analyticsWatermarkPersistence: analyticsWatermarkPersistence) } +@Test func codexProviderDoesNotSendAnAPIKeyToTheChatGPTEndpoint() async throws { + let auth = try #require(CodexAuth(document: .object(["OPENAI_API_KEY": .string("sk-test")]))) + let transport = StubTransport() + let result = await codexProvider(MemoryCodexStore(auth), transport: transport).fetch( + now: fixedNow, options: FetchOptions()) + #expect( + result.outcome + == .notAuthenticated("Codex API-key sign-in has no usage endpoint. Sign in with the Codex CLI to view usage.")) + #expect(transport.requests.isEmpty) +} + @Test func codexProviderUsesCompleteInlineResetCreditsWithoutASupplementalRequest() async { let transport = StubTransport() transport.on( @@ -308,6 +322,59 @@ func codexProvider( #expect(transport.requests(matching: "rate-limit-reset-credits").count == 2) } +struct CodexResetCase: Sendable, CustomTestStringConvertible { + let name: String + let reported: [Double] + let offsets: [TimeInterval] + let shown: [Double] + + var testDescription: String { name } +} + +@Test(arguments: [ + CodexResetCase( + name: "zero confirmed by the next fetch", reported: [80, 0, 0], offsets: [0, 60, 120], shown: [80, 80, 0]), + CodexResetCase( + name: "zero contradicted by the next fetch", reported: [80, 0, 78], offsets: [0, 60, 120], shown: [80, 80, 78]), + CodexResetCase(name: "zero after the reset time", reported: [80, 0], offsets: [0, 7200], shown: [80, 0]), + CodexResetCase(name: "drop that is not a reset", reported: [80, 2, 0], offsets: [0, 60, 120], shown: [80, 2, 2]), +]) +func codexProviderHoldsASuspectedSpuriousResetForOneFetch(testCase: CodexResetCase) async { + let resetAt = fixedNow.addingTimeInterval(3600).timeIntervalSince1970 + let transport = SequencedTransport( + suffix: "/wham/usage", + bodies: testCase.reported.map { + #"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":\#($0),"limit_window_seconds":604800,"# + + #""reset_at":\#(resetAt)}}}"# + }) + let provider = codexProvider(MemoryCodexStore(validCodex), transport: transport) + + var shown: [Double?] = [] + for offset in testCase.offsets { + let result = await provider.fetch(now: fixedNow.addingTimeInterval(offset), options: FetchOptions()) + shown.append(result.outcome.snapshot?.window("weekly")?.usedPercent) + } + + #expect(shown == testCase.shown) +} + +private final class SequencedTransport: HTTPTransport, @unchecked Sendable { + private let suffix: String + private let lock = NSLock() + private var bodies: [String] + + init(suffix: String, bodies: [String]) { + self.suffix = suffix + self.bodies = bodies + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard request.url?.path.hasSuffix(suffix) == true else { throw URLError(.unsupportedURL) } + let body = lock.withLock { bodies.removeFirst() } + return (Data(body.utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + @Test func codexProviderAdvancesOnlySuccessfulAnalyticsWatermarks() async { let transport = StubTransport() transport.on(path: "/wham/usage", .json("codex_usage")) @@ -429,8 +496,8 @@ func stubCodexAnalytics(_ transport: StubTransport) { 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(unreadable.outcome == .notAuthenticated("Cannot read Codex credentials: x")) + #expect(codexProvider(store, transport: transport).credentialState(now: fixedNow) == .missing("x")) #expect( codexProvider(MemoryCodexStore(nil), transport: transport).credentialState(now: fixedNow) == .missing("no Codex sign-in found")) @@ -495,7 +562,7 @@ func stubCodexAnalytics(_ transport: StubTransport) { 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)) + rejected.on(path: "/oauth/token", .text(#"{"error":"server_error"}"#, 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")) @@ -503,16 +570,16 @@ func stubCodexAnalytics(_ transport: 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")) + #expect(noToken.outcome == .notAuthenticated("Codex token refresh failed: odd")) 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")) + #expect(blankToken.outcome == .notAuthenticated("Codex token refresh failed: refresh returned no access token")) 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")) + #expect(missing.outcome == .notAuthenticated("Codex token refresh failed: the credential has no refresh token")) let saving = StubTransport() saving.on(path: "/oauth/token", .text(#"{"access_token":"n"}"#)) saving.on(path: "/wham/usage", .json("codex_usage")) diff --git a/Tests/TokenMenuBarCoreTests/ReaderBackgroundWorkTests.swift b/Tests/TokenMenuBarCoreTests/ReaderBackgroundWorkTests.swift index 3d6837f..73cb498 100644 --- a/Tests/TokenMenuBarCoreTests/ReaderBackgroundWorkTests.swift +++ b/Tests/TokenMenuBarCoreTests/ReaderBackgroundWorkTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -82,7 +83,7 @@ import Testing @Test func replacingClaudeProviderReleasesItsThrottledReader() async throws { var reader: ClaudeTranscriptReader? = ClaudeTranscriptReader( root: try backgroundScanRoot(prefix: "session"), workEntryBudget: 1, backgroundWorkDelay: 60) - weak let retainedReader: ClaudeTranscriptReader? = reader + let retainedReader = WeakReference(reader) var provider: ClaudeProvider? = ClaudeProvider( credentials: MemoryClaudeStore(validClaude), localAccountURL: nil, transcripts: reader, client: APIClient(transport: StubTransport(), log: makeLog(), clock: testClock), log: makeLog(), @@ -95,14 +96,14 @@ import Testing credentials: MemoryClaudeStore(validClaude), localAccountURL: nil, transcripts: nil, client: APIClient(transport: StubTransport(), log: makeLog(), clock: testClock), log: makeLog(), allowRefresh: { false }) - #expect(retainedReader == nil) + #expect(retainedReader.value == 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 + let retainedReader = WeakReference(reader) var provider: CodexProvider? = CodexProvider( auth: MemoryCodexStore(validCodex), rollouts: reader, client: APIClient(transport: StubTransport(), log: makeLog(), clock: testClock), log: makeLog(), @@ -115,7 +116,7 @@ import Testing auth: MemoryCodexStore(validCodex), rollouts: nil, client: APIClient(transport: StubTransport(), log: makeLog(), clock: testClock), log: makeLog(), allowRefresh: { false }) - #expect(retainedReader == nil) + #expect(retainedReader.value == nil) _ = provider } diff --git a/Tests/TokenMenuBarCoreTests/RefreshCoordinatorTests.swift b/Tests/TokenMenuBarCoreTests/RefreshCoordinatorTests.swift index 5d6ab1f..2451dc2 100644 --- a/Tests/TokenMenuBarCoreTests/RefreshCoordinatorTests.swift +++ b/Tests/TokenMenuBarCoreTests/RefreshCoordinatorTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -366,8 +367,7 @@ actor SleepRecorder { @MainActor private func makeSettings() -> Settings { - let defaults = UserDefaults(suiteName: "tests-\(UUID().uuidString)")! - return Settings(defaults: defaults) + Settings(defaults: testDefaults("refresh-coordinator")) } @Test @MainActor func coordinatorLoopRunsUntilStopped() async throws { @@ -395,6 +395,87 @@ private func makeSettings() -> Settings { coordinator.stop() } +@Test @MainActor func coordinatorResumeDelaysTheFirstScheduledRefresh() async throws { + let provider = ScriptedProvider( + id: .claude, results: [ProviderFetchResult(outcome: .success(snapshot(.claude, 10)))]) + let recorder = SleepRecorder() + let clock = Clock(now: { fixedNow }, sleep: { try await recorder.sleep($0) }) + let (coordinator, state, _, _, _) = try makeCoordinator([provider], clock: clock) + + coordinator.resume() + for _ in 0..<1_000 { + if await recorder.durations.count == 1 { break } + await Task.yield() + } + + #expect(await recorder.durations == [RefreshCoordinator.wakeDelay]) + #expect(state.nextRefreshAt == fixedNow.addingTimeInterval(RefreshCoordinator.wakeDelay)) + #expect(provider.calls.isEmpty) + coordinator.stop() +} + +@Test @MainActor func coordinatorSeparatesEqualBackgroundCadences() 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, 10)))]) + let box = DateBox(fixedNow) + let (coordinator, _, settings, _, _) = try makeCoordinator([claude, codex], clock: box.clock) + settings.setRefreshInterval(300, for: .claude) + settings.setRefreshInterval(300, for: .codex) + + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(300)) + box.date = fixedNow.addingTimeInterval(300) + await coordinator.refresh(RefreshRequest()) + #expect(claude.calls.count == 2) + #expect(codex.calls.count == 1) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(306)) + box.date = fixedNow.addingTimeInterval(306) + await coordinator.refresh(RefreshRequest()) + #expect(codex.calls.count == 2) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(600)) + box.date = fixedNow.addingTimeInterval(600) + await coordinator.refresh(RefreshRequest()) + #expect(claude.calls.count == 3) + #expect(codex.calls.count == 2) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(606)) + box.date = fixedNow.addingTimeInterval(606) + await coordinator.refresh(RefreshRequest()) + #expect(codex.calls.count == 3) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(900)) +} + +@Test @MainActor func coordinatorAnnouncesASignInOncePerCredential() async throws { + let expired = ProviderFetchResult(outcome: .notAuthenticated("expired")) + let stale = ProviderFetchResult(outcome: .partial(snapshot(.codex, 5), "stale")) + let codex = ScriptedProvider( + id: .codex, + results: [ + expired, stale, expired, stale, expired, ProviderFetchResult(outcome: .success(snapshot(.codex, 5))), expired, + ]) + let box = DateBox(fixedNow) + let (coordinator, _, _, _, sink) = try makeCoordinator([codex], clock: box.clock) + + for step in 1...3 { + box.date = fixedNow.addingTimeInterval(Double(step) * 3600) + await coordinator.refresh(RefreshRequest()) + } + #expect(sink.events.map(\.kind) == [.authentication]) + box.date = fixedNow.addingTimeInterval(4 * 3600) + await coordinator.refresh(RefreshRequest()) + codex.credentials = .valid(expiresAt: fixedNow.addingTimeInterval(86400)) + box.date = fixedNow.addingTimeInterval(5 * 3600) + await coordinator.refresh(RefreshRequest()) + #expect(sink.events.map(\.kind) == [.authentication, .authentication]) + for step in 6...7 { + box.date = fixedNow.addingTimeInterval(Double(step) * 3600) + await coordinator.refresh(RefreshRequest()) + } + #expect(sink.events.map(\.kind) == [.authentication, .authentication, .authentication]) + #expect(codex.calls.count == 7) +} + @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) @@ -432,6 +513,37 @@ private func makeSettings() -> Settings { coordinator.stop() } +@Test @MainActor func coordinatorInvalidatesScheduleObservationAcrossRestart() 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..<1_000 { + if await recorder.durations.count == 1 { break } + await Task.yield() + } + coordinator.stop() + coordinator.start() + for _ in 0..<1_000 { + if await recorder.durations.count == 2 { break } + await Task.yield() + } + + state.popoverVisible = true + for _ in 0..<1_000 { + if await recorder.durations.count == 3 { break } + await Task.yield() + } + for _ in 0..<100 { await Task.yield() } + + #expect(await recorder.durations == [300, 300, 90]) + coordinator.stop() +} + @Test @MainActor func coordinatorDoesNotScheduleAnalyticsForUnsupportedProviders() async throws { let gemini = ScriptedProvider( id: .gemini, results: [ProviderFetchResult(outcome: .success(snapshot(.gemini, 10)))]) @@ -521,6 +633,25 @@ private func makeSettings() -> Settings { #expect(thirdState.state(for: .claude).analytics == nil) } +@Test @MainActor func coordinatorRestoresAnalyticsForTheConfiguredRetention() async throws { + let oldDay = DayStamp.string(fixedNow.addingTimeInterval(-200 * 86_400)) + let history = try UsageHistoryStore(url: nil, retentionDays: 365) + try await history.record( + ProviderAnalytics( + provider: .codex, + points: [AnalyticsPoint(day: oldDay, metric: .turns, series: "m", value: 4)], + fetchedAt: fixedNow)) + let settings = makeSettings() + settings.historyRetentionDays = 365 + let provider = ScriptedProvider( + id: .codex, results: [ProviderFetchResult(outcome: .success(snapshot(.codex, 1)))]) + let (coordinator, state, _, _, _) = try makeCoordinator([provider], settings: settings, history: history) + + await coordinator.refresh(RefreshRequest()) + + #expect(state.state(for: .codex).analytics?.points.map(\.day) == [oldDay]) +} + @Test @MainActor func coordinatorMergesOverlappingIncrementalAnalytics() async throws { let first = ProviderAnalytics( provider: .codex, @@ -617,17 +748,17 @@ extension UsageHistoryStore { #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(coordinator.nextAttempt(for: .codex) == box.date.addingTimeInterval(5000)) #expect(codex.calls.count == 3) - box.date = fixedNow.addingTimeInterval(2699) + box.date = fixedNow.addingTimeInterval(5899) await coordinator.refresh(RefreshRequest()) #expect(codex.calls.count == 3) - box.date = fixedNow.addingTimeInterval(2700) + box.date = fixedNow.addingTimeInterval(5900) 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) + box.date = fixedNow.addingTimeInterval(6000) await coordinator.refresh(RefreshRequest()) #expect(codex.calls.count == 4) state.popoverVisible = true @@ -637,6 +768,35 @@ extension UsageHistoryStore { #expect(coordinator.nextAttempt(for: .codex) == box.date.addingTimeInterval(90)) } +@Test @MainActor func coordinatorBacksOffConsecutiveFailuresAndResetsAfterSuccess() async throws { + let provider = ScriptedProvider( + id: .gemini, + results: [ + ProviderFetchResult(outcome: .failed("HTTP 500")), + ProviderFetchResult(outcome: .networkUnavailable("offline")), + ProviderFetchResult(outcome: .notAuthenticated("expired")), + ProviderFetchResult(outcome: .success(snapshot(.gemini, 10))), + ProviderFetchResult(outcome: .failed("HTTP 500")), + ]) + let box = DateBox(fixedNow) + let (coordinator, _, _, _, _) = try makeCoordinator([provider], clock: box.clock) + + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextAttempt(for: .gemini) == fixedNow.addingTimeInterval(60)) + box.date = fixedNow.addingTimeInterval(60) + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextAttempt(for: .gemini) == box.date.addingTimeInterval(120)) + box.date = fixedNow.addingTimeInterval(180) + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextAttempt(for: .gemini) == box.date.addingTimeInterval(240)) + box.date = fixedNow.addingTimeInterval(420) + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextAttempt(for: .gemini) == nil) + box.date = fixedNow.addingTimeInterval(480) + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextAttempt(for: .gemini) == box.date.addingTimeInterval(60)) +} + @Test @MainActor func coordinatorDiscardsResultsFromAReplacedRegistry() async throws { let gate = TestGate() let old = ScriptedProvider( diff --git a/Tests/TokenMenuBarCoreTests/RefreshRejectionTests.swift b/Tests/TokenMenuBarCoreTests/RefreshRejectionTests.swift new file mode 100644 index 0000000..87bb173 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/RefreshRejectionTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +struct TerminalRefreshCase: Sendable, CustomTestStringConvertible { + let provider: ProviderID + let code: String + let tokenPath: String + let make: @Sendable (StubTransport) -> (provider: any UsageProvider, signInAgain: @Sendable () throws -> Void) + + var testDescription: String { "\(provider.rawValue) \(code)" } +} + +private let expiredAt = fixedNow.addingTimeInterval(-10) + +let terminalRefreshCases = [ + TerminalRefreshCase(provider: .claude, code: "invalid_grant", tokenPath: "/v1/oauth/token") { transport in + let store = MemoryClaudeStore(ClaudeOAuthCredentials(accessToken: "old", refreshToken: "ref", expiresAt: expiredAt)) + return ( + claudeProvider(store, transport: transport, allowRefresh: true), + { try store.write(ClaudeOAuthCredentials(accessToken: "old", refreshToken: "ref-2", expiresAt: expiredAt)) } + ) + }, + TerminalRefreshCase(provider: .codex, code: "refresh_token_reused", tokenPath: "/oauth/token") { transport in + let token = makeJWT(.object(["exp": .number(expiredAt.timeIntervalSince1970)])) + let store = MemoryCodexStore(CodexAuth(accessToken: token, refreshToken: "ref")) + return ( + codexProvider(store, transport: transport, allowRefresh: true), + { try store.write(CodexAuth(accessToken: token, refreshToken: "ref-2")) } + ) + }, + TerminalRefreshCase(provider: .gemini, code: "invalid_grant", tokenPath: "/token") { transport in + let store = MemoryGeminiStore(GeminiAuth(accessToken: "old", refreshToken: "ref", expiresAt: expiredAt)) + let provider = GeminiProvider( + auth: store, client: APIClient(transport: transport, log: makeLog(), clock: testClock), log: makeLog(), + allowRefresh: { true }, oauthClient: { GeminiOAuthClient(id: "client", secret: "secret") }) + return (provider, { try store.write(GeminiAuth(accessToken: "old", refreshToken: "ref-2", expiresAt: expiredAt)) }) + }, +] + +@Test(arguments: terminalRefreshCases) +func refreshStopsAfterATerminalRejectionUntilTheCredentialChanges(testCase: TerminalRefreshCase) async throws { + let transport = StubTransport() + transport.on(path: testCase.tokenPath, .text(#"{"error":"\#(testCase.code)"}"#, status: 400)) + let (provider, signInAgain) = testCase.make(transport) + + let first = await provider.fetch(now: fixedNow, options: FetchOptions()) + let second = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + + #expect( + first.outcome + == .notAuthenticated( + "\(testCase.provider.displayName) token refresh failed: the provider rejected the stored sign-in " + + "(\(testCase.code)). Sign in with the CLI again.")) + #expect(second.outcome == first.outcome) + #expect(transport.requests(matching: testCase.tokenPath).count == 1) + + try signInAgain() + _ = await provider.fetch(now: fixedNow.addingTimeInterval(120), options: FetchOptions()) + + #expect(transport.requests(matching: testCase.tokenPath).count == 2) +} + +@Test(arguments: [ + (status: 400, body: #"{"error":"temporarily_unavailable"}"#), + (status: 400, body: "not json"), + (status: 500, body: #"{"error":"invalid_grant"}"#), +]) +func refreshRetriesAfterANonTerminalRejection(response: (status: Int, body: String)) async { + let transport = StubTransport() + transport.on(path: "/oauth/token", .text(response.body, status: response.status)) + let token = makeJWT(.object(["exp": .number(expiredAt.timeIntervalSince1970)])) + let provider = codexProvider( + MemoryCodexStore(CodexAuth(accessToken: token, refreshToken: "ref")), transport: transport, allowRefresh: true) + + let first = await provider.fetch(now: fixedNow, options: FetchOptions()) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + + #expect(first.outcome == .notAuthenticated("Codex token refresh failed: HTTP \(response.status)")) + #expect(transport.requests(matching: "/oauth/token").count == 2) +} diff --git a/Tests/TokenMenuBarCoreTests/SQLiteTests.swift b/Tests/TokenMenuBarCoreTests/SQLiteTests.swift index d24b612..a8fdb98 100644 --- a/Tests/TokenMenuBarCoreTests/SQLiteTests.swift +++ b/Tests/TokenMenuBarCoreTests/SQLiteTests.swift @@ -1,4 +1,5 @@ import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -30,6 +31,12 @@ import Testing #expect(try database.query("SELECT 42") { $0.int(0) } == [42]) } +@Test func sqliteWaitsForTransientWriterContention() throws { + let database = try SQLiteDatabase(path: ":memory:") + + #expect(try database.query("PRAGMA busy_timeout") { $0.int(0) } == [5000]) +} + @Test func sqliteTransactionRollsBackAfterCancellation() async throws { let database = try SQLiteDatabase(path: ":memory:") try database.execute("CREATE TABLE values_table (value INTEGER NOT NULL)") @@ -77,3 +84,9 @@ import Testing private enum TransactionFixtureError: Error { case body } + +@Test func sqliteReportsWhyADatabaseCannotOpen() { + let missingDirectory = temporaryDirectory().appendingPathComponent("missing/usage.sqlite") + + #expect(throws: SQLiteError.self) { try SQLiteDatabase(path: missingDirectory.path) } +} diff --git a/Tests/TokenMenuBarCoreTests/SecurityScopedAccessTests.swift b/Tests/TokenMenuBarCoreTests/SecurityScopedAccessTests.swift index 8081636..84368be 100644 --- a/Tests/TokenMenuBarCoreTests/SecurityScopedAccessTests.swift +++ b/Tests/TokenMenuBarCoreTests/SecurityScopedAccessTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/SettingsPresentationTests.swift b/Tests/TokenMenuBarCoreTests/SettingsPresentationTests.swift index 3218a2a..029ced6 100644 --- a/Tests/TokenMenuBarCoreTests/SettingsPresentationTests.swift +++ b/Tests/TokenMenuBarCoreTests/SettingsPresentationTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport private let presentationNow = Date(timeIntervalSince1970: 1_788_030_000) diff --git a/Tests/TokenMenuBarCoreTests/SettingsTests.swift b/Tests/TokenMenuBarCoreTests/SettingsTests.swift index 17e9ebf..ac5ae3a 100644 --- a/Tests/TokenMenuBarCoreTests/SettingsTests.swift +++ b/Tests/TokenMenuBarCoreTests/SettingsTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test @MainActor func settingsDefaultsAndClamping() { let defaults = freshDefaults() @@ -40,10 +41,7 @@ import TokenMenuBarCore @MainActor private func freshDefaults() -> UserDefaults { - let suite = "tests-settings-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - return defaults + testDefaults("settings") } @Test @MainActor func menuBarSettingsSurviveAReload() { @@ -205,6 +203,15 @@ private func freshDefaults() -> UserDefaults { #expect(settings.demoMode == nil) } +@Test @MainActor func resetToDefaultsClearsCodexAnalyticsWatermarksFromTheInjectedSuite() { + let defaults = freshDefaults() + defaults.set(Data("watermark".utf8), forKey: CodexAnalyticsWatermarkPersistence.storageKey) + + Settings(defaults: defaults).resetToDefaults() + + #expect(defaults.object(forKey: CodexAnalyticsWatermarkPersistence.storageKey) == nil) +} + @Test @MainActor func settingsIgnoreCorruptStoredValues() { let defaults = freshDefaults() defaults.set(Data("junk".utf8), forKey: "enabledProviders") @@ -236,12 +243,10 @@ private func freshDefaults() -> UserDefaults { } @Test @MainActor func settingsFlushPersistsImmediately() { - let name = "flush-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: name)! + let defaults = testDefaults("settings-flush") 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) + #expect(Settings(defaults: defaults).demoMode == true) } diff --git a/Tests/TokenMenuBarCoreTests/ShutdownPolicyTests.swift b/Tests/TokenMenuBarCoreTests/ShutdownPolicyTests.swift index 37000f0..26a548b 100644 --- a/Tests/TokenMenuBarCoreTests/ShutdownPolicyTests.swift +++ b/Tests/TokenMenuBarCoreTests/ShutdownPolicyTests.swift @@ -1,5 +1,6 @@ import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func shutdownPolicyReportsCompletedWork() async { #expect(await ShutdownPolicy.waitForCompletion(timeout: .seconds(1)) {}) diff --git a/Tests/TokenMenuBarCoreTests/SnapshotPersistenceTests.swift b/Tests/TokenMenuBarCoreTests/SnapshotPersistenceTests.swift index c1c4ef3..2da93ad 100644 --- a/Tests/TokenMenuBarCoreTests/SnapshotPersistenceTests.swift +++ b/Tests/TokenMenuBarCoreTests/SnapshotPersistenceTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -43,7 +44,7 @@ import Testing #expect(workload.cacheWrites < workload.cacheSubmissions) } -@Test @MainActor func snapshotPersistenceReloadsWidgetsOncePerWrittenValue() async throws { +@Test @MainActor func snapshotPersistenceWritesFreshnessWithoutReloadingUnchangedContent() async throws { let store = WidgetSnapshotStore(url: temporaryDirectory().appendingPathComponent("widget.json")) let reloads = WidgetReloadRecorder() let failures = PersistenceFailureRecorder() @@ -62,9 +63,20 @@ import Testing await persistence.submitWidget(.placeholder) await persistence.flush() #expect(reloads.count == 1) + let heartbeat = WidgetSnapshot( + rows: WidgetSnapshot.placeholder.rows, attention: WidgetSnapshot.placeholder.attention, + updatedAt: stored.updatedAt.addingTimeInterval(WidgetSnapshot.refreshInterval)) + await persistence.submitWidget(heartbeat) + await persistence.flush() + #expect(store.read()?.updatedAt == heartbeat.updatedAt) + #expect(reloads.count == 1) + await persistence.submitWidget( + WidgetSnapshot(rows: heartbeat.rows, attention: !heartbeat.attention, updatedAt: heartbeat.updatedAt)) + await persistence.flush() + #expect(reloads.count == 2) let workload = await persistence.workload - #expect(workload.widgetWrites == 1) - #expect(workload.widgetReloads == 1) + #expect(workload.widgetWrites == 3) + #expect(workload.widgetReloads == 2) #expect(await failures.values.isEmpty) } diff --git a/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift b/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift index 396bb3a..fcf68df 100644 --- a/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift +++ b/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/Support/Fixtures.swift b/Tests/TokenMenuBarCoreTests/Support/Fixtures.swift index ee06e06..1ba8cff 100644 --- a/Tests/TokenMenuBarCoreTests/Support/Fixtures.swift +++ b/Tests/TokenMenuBarCoreTests/Support/Fixtures.swift @@ -1,6 +1,8 @@ +import Darwin import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport enum Fixtures { static func url(_ name: String) -> URL { @@ -28,14 +30,6 @@ enum Fixtures { } } -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) @@ -60,7 +54,13 @@ final class DateBox: @unchecked Sendable { } } -struct TestError: Error {} +final class WeakReference { + weak var value: Value? + + init(_ value: Value?) { + self.value = value + } +} func makeJWT(_ payload: JSONValue) -> String { let body = try! JSONEncoder().encode(payload).base64EncodedString().replacingOccurrences(of: "=", with: "") diff --git a/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift b/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift index 3841c64..aa81214 100644 --- a/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift +++ b/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift @@ -1,5 +1,6 @@ import Foundation import TokenMenuBarCore +import TokenMenuBarTestSupport // 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. diff --git a/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift b/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift index 611769d..23c190a 100644 --- a/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift +++ b/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift @@ -1,4 +1,5 @@ import Foundation +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/Support/ScriptedProvider.swift b/Tests/TokenMenuBarCoreTests/Support/ScriptedProvider.swift index bd6f34d..77640c4 100644 --- a/Tests/TokenMenuBarCoreTests/Support/ScriptedProvider.swift +++ b/Tests/TokenMenuBarCoreTests/Support/ScriptedProvider.swift @@ -1,5 +1,6 @@ import Foundation import TokenMenuBarCore +import TokenMenuBarTestSupport // 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. diff --git a/Tests/TokenMenuBarCoreTests/Support/StubTransport.swift b/Tests/TokenMenuBarCoreTests/Support/StubTransport.swift index 359a9f7..1a8c10e 100644 --- a/Tests/TokenMenuBarCoreTests/Support/StubTransport.swift +++ b/Tests/TokenMenuBarCoreTests/Support/StubTransport.swift @@ -1,5 +1,6 @@ import Foundation import TokenMenuBarCore +import TokenMenuBarTestSupport final class StubTransport: HTTPTransport, @unchecked Sendable { struct Response { diff --git a/Tests/TokenMenuBarCoreTests/TooltipPolicyTests.swift b/Tests/TokenMenuBarCoreTests/TooltipPolicyTests.swift index 8569939..9d3fbdc 100644 --- a/Tests/TokenMenuBarCoreTests/TooltipPolicyTests.swift +++ b/Tests/TokenMenuBarCoreTests/TooltipPolicyTests.swift @@ -1,5 +1,6 @@ import CoreGraphics import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore diff --git a/Tests/TokenMenuBarCoreTests/TranscriptReaderCoverageBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/TranscriptReaderCoverageBehaviorTests.swift index 5917714..1996e30 100644 --- a/Tests/TokenMenuBarCoreTests/TranscriptReaderCoverageBehaviorTests.swift +++ b/Tests/TokenMenuBarCoreTests/TranscriptReaderCoverageBehaviorTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -190,3 +191,16 @@ 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}}"# } + +@Test func rolloutReaderResumesFromCachedCandidatesAfterCancellation() 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, workByteBudget: 1) + #expect(await reader.latest(now: fixedNow) == nil) + await reader.cancelBackgroundWork() + + _ = await reader.latest(now: fixedNow.addingTimeInterval(1)) + + #expect((await reader.workload).treesScanned == 1) +} diff --git a/Tests/TokenMenuBarCoreTests/UsageHistoryStoreTests.swift b/Tests/TokenMenuBarCoreTests/UsageHistoryStoreTests.swift index 23502d6..28c483f 100644 --- a/Tests/TokenMenuBarCoreTests/UsageHistoryStoreTests.swift +++ b/Tests/TokenMenuBarCoreTests/UsageHistoryStoreTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @@ -485,6 +486,66 @@ private func expectCancellation(_ task: Task) async { } } +@Test func historyMigratesExistingUnversionedDatabasesWithoutLosingRows() async throws { + let url = temporaryDirectory().appendingPathComponent("usage.sqlite") + var database: SQLiteDatabase? = try SQLiteDatabase(path: url.path) + try 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) + ) + """) + try database?.execute( + """ + CREATE TABLE 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 analytics_accounts (provider TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)") + try database?.execute( + "INSERT INTO samples (ts, key, label, used) VALUES (?, ?, ?, ?)", + [ + .real(fixedNow.timeIntervalSince1970), + .text(WindowKey(provider: .claude, windowID: "session").storageKey), .text("Session"), .real(12), + ]) + database = nil + + let store = try UsageHistoryStore(url: url) + + #expect(try await store.stats().sampleCount == 1) +} + +@Test func historyCreatesVersionedIndexedSchema() throws { + let url = temporaryDirectory().appendingPathComponent("usage.sqlite") + _ = try UsageHistoryStore(url: url) + let database = try SQLiteDatabase(path: url.path, readOnly: true) + + #expect(try database.query("PRAGMA user_version") { $0.int(0) } == [UsageHistoryStore.schemaVersion]) + #expect( + try database.query("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name") { $0.text(0) } + .contains("analytics_day")) +} + +@Test func historyRejectsNewerSchemas() throws { + let url = temporaryDirectory().appendingPathComponent("usage.sqlite") + var database: SQLiteDatabase? = try SQLiteDatabase(path: url.path) + try database?.execute("PRAGMA user_version = 2") + database = nil + + #expect(throws: SQLiteError.schema(2)) { try UsageHistoryStore(url: url) } +} + +@Test func historyEnablesIncrementalVacuumForNewDatabases() throws { + let url = temporaryDirectory().appendingPathComponent("usage.sqlite") + _ = try UsageHistoryStore(url: url) + let database = try SQLiteDatabase(path: url.path, readOnly: true) + + #expect(try database.query("PRAGMA auto_vacuum") { $0.int(0) } == [2]) +} + @Test func historyShouldRecordRules() { let key = WindowKey(provider: .claude, windowID: "session") let base = UsageSample(timestamp: fixedNow, key: key, usedPercent: 10, resetsAt: fixedNow) diff --git a/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift b/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift index 30c6881..2d5ab5a 100644 --- a/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift +++ b/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @Test func widgetSnapshotBuildsRowsFromSelection() { let snapshot = DemoData.snapshot(.claude, now: fixedNow) @@ -23,6 +24,33 @@ import TokenMenuBarCore #expect(widget.rows[0].id == keys[0]) } +@Test func widgetSnapshotContentComparisonIgnoresFreshness() { + let first = WidgetSnapshot(rows: WidgetSnapshot.placeholder.rows, attention: false, updatedAt: fixedNow) + let refreshed = WidgetSnapshot( + rows: first.rows, attention: first.attention, updatedAt: fixedNow.addingTimeInterval(120)) + + #expect(first != refreshed) + #expect(first.hasSameContent(as: refreshed)) + #expect(first.shouldPublish(after: nil)) + #expect(!refreshed.shouldPublish(after: first)) + #expect( + WidgetSnapshot( + rows: first.rows, attention: first.attention, + updatedAt: first.updatedAt.addingTimeInterval(WidgetSnapshot.refreshInterval) + ).shouldPublish(after: first)) +} + +@Test func widgetSnapshotContentComparisonDetectsVisibleChanges() { + let first = WidgetSnapshot(rows: WidgetSnapshot.placeholder.rows, attention: false, updatedAt: fixedNow) + let differentRows = WidgetSnapshot(rows: Array(first.rows.dropLast()), attention: false, updatedAt: first.updatedAt) + let differentAttention = WidgetSnapshot(rows: first.rows, attention: true, updatedAt: first.updatedAt) + + #expect(!first.hasSameContent(as: differentRows)) + #expect(!first.hasSameContent(as: differentAttention)) + #expect(differentRows.shouldPublish(after: first)) + #expect(differentAttention.shouldPublish(after: first)) +} + @Test func widgetStoreRoundTripsAndResolvesSharedURL() throws { let root = temporaryDirectory() let store = WidgetSnapshotStore(url: root.appendingPathComponent("nested/widget.json")) diff --git a/Tests/TokenMenuBarTestSupport/TestSupport.swift b/Tests/TokenMenuBarTestSupport/TestSupport.swift new file mode 100644 index 0000000..596d656 --- /dev/null +++ b/Tests/TokenMenuBarTestSupport/TestSupport.swift @@ -0,0 +1,75 @@ +import Darwin +import Foundation +import TokenMenuBarCore + +public let fixedNow = Date(timeIntervalSince1970: 1_788_030_000) + +public let testClock = Clock.fixed(fixedNow) + +public func makeLog() -> LogBuffer { + LogBuffer(fileURL: nil, clock: testClock) +} + +public struct TestError: Error { + public init() {} +} + +public func testDefaults(_ prefix: String = "tmb-tests") -> UserDefaults { + TestDefaults(prefix: prefix) +} + +// A suite that persists to ~/Library/Preferences is removed both when the instance dies and at process exit, so a +// crashed or interrupted run leaves nothing behind. +public final class TestDefaults: UserDefaults { + private let suite: String + + public init(prefix: String = "tmb-tests") { + suite = "\(prefix)-\(UUID().uuidString)" + super.init(suiteName: suite)! + removePersistentDomain(forName: suite) + TestDefaultsCleanup.shared.insert(suite) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + nil + } + + deinit { + removePersistentDomain(forName: suite) + TestDefaultsCleanup.shared.remove(suite) + } +} + +private final class TestDefaultsCleanup: @unchecked Sendable { + static let shared = TestDefaultsCleanup() + + private let lock = NSLock() + private var suites: Set = [] + + private init() { + atexit(removeTestDefaults) + } + + func insert(_ suite: String) { + _ = lock.withLock { suites.insert(suite) } + } + + func remove(_ suite: String) { + _ = lock.withLock { suites.remove(suite) } + } + + func removeAll() { + let suites = lock.withLock { + defer { self.suites.removeAll() } + return self.suites + } + for suite in suites { + UserDefaults().removePersistentDomain(forName: suite) + } + } +} + +private func removeTestDefaults() { + TestDefaultsCleanup.shared.removeAll() +} diff --git a/Tests/TokenMenuBarUITests/AdapterTests.swift b/Tests/TokenMenuBarUITests/AdapterTests.swift index 304587a..7259c8f 100644 --- a/Tests/TokenMenuBarUITests/AdapterTests.swift +++ b/Tests/TokenMenuBarUITests/AdapterTests.swift @@ -1,6 +1,7 @@ import Foundation import ServiceManagement import Testing +import TokenMenuBarTestSupport import UserNotifications @testable import TokenMenuBarCore @@ -73,3 +74,11 @@ private func event(_ kind: NotificationEvent.Kind, id: String = UUID().uuidStrin func launchAtLoginServiceMapsStatuses(status: SMAppService.Status, expected: LaunchAtLoginBackend.Status) { #expect(LaunchAtLoginService.status(status) == expected) } + +@Test @MainActor func notifierRequestsProvisionalAuthorizationWithoutAPrompt() async { + let center = FakeNotificationCenter() + let notifier = Notifier(center: center, log: makeLog()) + await notifier.requestAuthorization(provisional: true) + await notifier.requestAuthorization() + #expect(center.requestedOptions == [[.alert, .sound, .provisional], [.alert, .sound]]) +} diff --git a/Tests/TokenMenuBarUITests/AppControllerTests.swift b/Tests/TokenMenuBarUITests/AppControllerTests.swift index cddddec..0516efe 100644 --- a/Tests/TokenMenuBarUITests/AppControllerTests.swift +++ b/Tests/TokenMenuBarUITests/AppControllerTests.swift @@ -1,6 +1,7 @@ import AppKit import Darwin import Testing +import TokenMenuBarTestSupport import UserNotifications @testable import TokenMenuBarCore @@ -148,7 +149,8 @@ private func startedController() throws -> (AppController, AppDependencies, Reco 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 clock = ManualClock() + let (dependencies, _) = try makeDependencies(providers: providers, clock: clock.clock) let controller = AppController(dependencies: dependencies) controller.start() @@ -156,8 +158,18 @@ private func startedController() throws -> (AppController, AppDependencies, Reco defer { controller.stop() } #expect(controller.popover?.isShown == false) - await waitUntil { controller.popover?.isShown == true } + await clock.advance(by: AppController.launchPopoverDelay) { + clock.sleeps.contains(AppController.attachmentPollInterval) + } + #expect(controller.popover?.isShown == false) + await clock.advance(by: AppController.attachmentPollInterval) { controller.popover?.isShown == true } #expect(controller.popover?.isShown == true) + let polls = clock.sleeps.count(where: { $0 == AppController.attachmentPollInterval }) + for _ in 0..<5 { + clock.advance(by: AppController.attachmentPollInterval) + await mainActorTurn() + } + #expect(clock.sleeps.count(where: { $0 == AppController.attachmentPollInterval }) == polls) } @Test @MainActor func verificationCommandReopensAnOffscreenPopover() async throws { @@ -207,19 +219,40 @@ private func startedController() throws -> (AppController, AppDependencies, Reco #expect(snapshot == expected) } -@Test @MainActor func appControllerSuspendsPollingWhileTheMacSleeps() async throws { - let (controller, dependencies, _) = try startedController() +@Test @MainActor func workspaceSleepAndWakeSuspendThenResumePolling() async throws { + let probe = RediscoveryFetchProbe() + let provider = RediscoveryProvider( + id: .claude, health: .valid(source: ProviderID.claude.setup.credentialSources[0], expiresAt: nil), + result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude))), probe: probe) + let clock = ManualClock() + let (dependencies, _) = try makeDependencies(providers: [provider], clock: clock.clock) + dependencies.settings.detailedLogging = true + let controller = AppController(dependencies: dependencies) + controller.start() 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) + + await waitUntil { dependencies.state.state(for: .claude).lastAttempt != nil } + let fetchesBeforeSleep = await probe.fetches + NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.willSleepNotification, object: nil) + await waitUntil { !controller.coordinator.isRunning } + #expect(!controller.coordinator.isRunning) + NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.didWakeNotification, object: nil) - await Task.yield() + await waitUntil { controller.coordinator.isRunning } + #expect(await probe.fetches == fetchesBeforeSleep) + await clock.advance(by: RefreshCoordinator.wakeDelay) { + dependencies.state.state(for: .claude).lastAttempt.map { $0 > fixedNow } == true + } + for _ in 0..<100 { + if await probe.fetches > fetchesBeforeSleep { break } + await mainActorTurn() + } + + #expect(controller.coordinator.isRunning) + #expect(await probe.fetches == fetchesBeforeSleep + 1) + #expect(dependencies.log.text.contains("sleep: pausing refresh loop")) + #expect(dependencies.log.text.contains("wake: resuming refresh loop")) } @Test @MainActor func appControllerContextMenuItemsRunTheirCommands() throws { @@ -299,12 +332,17 @@ private func startedController() throws -> (AppController, AppDependencies, Reco 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 clock = ManualClock() + let (dependencies, _) = try makeDependencies(history: history, clock: clock.clock) let controller = AppController(dependencies: dependencies) dependencies.settings.historyRetentionDays = 7 controller.settingsChanged() - await waitUntil { dependencies.log.text.contains("history retention updated days=7") } + await mainActorTurn() + #expect(try await history.stats().sampleCount == 3) + await clock.advance(by: AppController.retentionDebounce) { + dependencies.log.text.contains("history retention updated days=7") + } #expect(try await history.stats().sampleCount == 0) await waitUntil { dependencies.state.sampleRevision == 1 } @@ -315,28 +353,36 @@ private func startedController() throws -> (AppController, AppDependencies, Reco 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 clock = ManualClock() + let (dependencies, _) = try makeDependencies(history: history, clock: clock.clock) let controller = AppController(dependencies: dependencies) dependencies.settings.historyRetentionDays = 7 controller.settingsChanged() dependencies.settings.historyRetentionDays = 60 controller.settingsChanged() + await mainActorTurn() + clock.advance(by: AppController.retentionDebounce) + await mainActorTurn() #expect(try await history.stats().sampleCount == 3) #expect(await history.retentionDays == 60) + #expect(!dependencies.log.text.contains("history retention updated")) } @Test @MainActor func appControllerReportsHistoryRetentionFailures() async throws { let history = try UsageHistoryStore(url: nil) try await history.breakDatabase() - let (dependencies, _) = try makeDependencies(history: history) + let clock = ManualClock() + let (dependencies, _) = try makeDependencies(history: history, clock: clock.clock) let controller = AppController(dependencies: dependencies) dependencies.settings.historyRetentionDays = 7 controller.settingsChanged() - await waitUntil { dependencies.log.text.contains("history retention update failed") } + await clock.advance(by: AppController.retentionDebounce) { + dependencies.log.text.contains("history retention update failed") + } #expect(dependencies.log.text.contains("history retention update failed")) } @@ -447,11 +493,7 @@ private func startedController() throws -> (AppController, AppDependencies, Reco } 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) @@ -489,7 +531,7 @@ private func startedController() throws -> (AppController, AppDependencies, Reco 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(), + defaults: testDefaults("deferred"), transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) #expect(!FileManager.default.fileExists(atPath: support.path)) #expect(delegate.controller == nil) @@ -544,7 +586,7 @@ private func startedController() throws -> (AppController, AppDependencies, Reco 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)")! + let defaults = testDefaults("live") var openedReplacement = false let dependencies = try await LiveDependencies.make( appInfo: testAppInfo, paths: paths, defaults: defaults, notificationCenter: nil, updater: nil, isSandboxed: false, @@ -586,7 +628,7 @@ private func startedController() throws -> (AppController, AppDependencies, Reco // 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)")!, + appInfo: testAppInfo, paths: configured, defaults: testDefaults("configured"), notificationCenter: nil, isSandboxed: true, transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) #expect(sandboxedGraph.registry[.codex]?.credentialDescription.contains("/tmp/cx/auth.json") == true) @@ -640,7 +682,7 @@ private func startedController() throws -> (AppController, AppDependencies, Reco 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(), + defaults: testDefaults("runner"), transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) #expect(delegate.controller.dependencies.appInfo.isAppStore) #expect(delegate.controller.environment.canCheckForUpdates) @@ -733,3 +775,123 @@ private final class CredentialHealthThreadProbe: @unchecked Sendable { lock.withLock { self.value = value } } } + +/// Sleeps park on this clock until a test moves its reading past their deadline, so timers under test run without +/// wall-clock waits and everything else the controller schedules stays parked. +final class ManualClock: @unchecked Sendable { + private struct Sleeper { + let deadline: Date + let continuation: CheckedContinuation + } + + private let lock = NSLock() + private var date = fixedNow + private var sleepers: [UUID: Sleeper] = [:] + private var cancelled: Set = [] + private var recorded: [TimeInterval] = [] + + var now: Date { lock.withLock { date } } + var sleeps: [TimeInterval] { lock.withLock { recorded } } + var clock: Clock { Clock(now: { self.now }, sleep: { try await self.sleep($0) }) } + + func sleep(_ interval: TimeInterval) async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let parked = lock.withLock { + recorded.append(interval) + guard cancelled.remove(id) == nil else { return false } + sleepers[id] = Sleeper(deadline: date.addingTimeInterval(interval), continuation: continuation) + return true + } + if !parked { continuation.resume(throwing: CancellationError()) } + } + } onCancel: { + let sleeper = lock.withLock { + guard let sleeper = sleepers.removeValue(forKey: id) else { + cancelled.insert(id) + return Sleeper?.none + } + return sleeper + } + sleeper?.continuation.resume(throwing: CancellationError()) + } + } + + func advance(by interval: TimeInterval) { + let ready = lock.withLock { + date.addTimeInterval(interval) + let ready = sleepers.filter { $0.value.deadline <= date } + for id in ready.keys { sleepers.removeValue(forKey: id) } + return ready.values.sorted { $0.deadline < $1.deadline } + } + for sleeper in ready { sleeper.continuation.resume() } + } + + /// Keeps stepping the clock until the condition holds, because a sleeper only registers its deadline once the task + /// that owns it has reached the sleep call. + @MainActor + @discardableResult + func advance(by interval: TimeInterval, until condition: () -> Bool) async -> Bool { + await waitUntil { + advance(by: interval) + return condition() + } + } +} + +@Test @MainActor func reopenWaitsForTheStatusItemBeforeShowingThePopover() async throws { + let clock = ManualClock() + let provider = ScriptedProvider(id: .claude, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude)))) + let (dependencies, _) = try makeDependencies(providers: [provider], clock: clock.clock) + let controller = AppController(dependencies: dependencies) + controller.start() + defer { controller.stop() } + + controller.reopen() + + await clock.advance(by: AppController.attachmentPollInterval) { + clock.sleeps.contains(AppController.attachmentPollInterval) + } + #expect(controller.popover?.isShown == false) +} + +@Test @MainActor func openingANotificationSelectsTheUsageTabAndReopens() throws { + let (controller, dependencies, _) = try startedController() + defer { controller.stop() } + dependencies.settings.lastTab = .history + + dependencies.notifier.onResponse?() + + #expect(dependencies.settings.lastTab == .usage) + #expect(dependencies.log.text.contains("notification opened")) +} + +@Test @MainActor func popoverScreenLookupPrefersTheWindowThenTheAnchor() throws { + let screen = try #require(NSScreen.screens.first) + let inside = screen.frame.insetBy(dx: 10, dy: 10) + let justAbove = CGRect(x: screen.frame.midX, y: screen.frame.maxY + 50, width: 10, height: 10) + let far = CGRect(x: -100_000, y: -100_000, width: 10, height: 10) + + #expect(AppController.screen(near: nil, preferred: screen, among: []) === screen) + #expect(AppController.screen(near: nil, preferred: nil, among: [screen]) == nil) + #expect(AppController.screen(near: inside, preferred: nil, among: [screen]) === screen) + #expect(AppController.screen(near: justAbove, preferred: nil, among: [screen]) === screen) + #expect(AppController.screen(near: far, preferred: nil, among: [screen]) == nil) +} + +@Test @MainActor func enablingNotificationsLaterRequestsAuthorization() async throws { + let (dependencies, _) = try makeDependencies() + dependencies.settings.notifications.enabled = false + let controller = AppController(dependencies: dependencies) + controller.start() + defer { controller.stop() } + await mainActorTurn() + #expect(!dependencies.notifier.authorized) + + dependencies.settings.notifications.enabled = true + controller.settingsChanged() + + await waitUntil { dependencies.notifier.authorized } + #expect(dependencies.notifier.authorized) +} diff --git a/Tests/TokenMenuBarUITests/ApplicationMenuTests.swift b/Tests/TokenMenuBarUITests/ApplicationMenuTests.swift new file mode 100644 index 0000000..4db17e2 --- /dev/null +++ b/Tests/TokenMenuBarUITests/ApplicationMenuTests.swift @@ -0,0 +1,42 @@ +import AppKit +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarUI + +@Suite(.serialized) +struct ApplicationMenuTests { + @Test @MainActor func installedMainMenuBindsEditingAndWindowCommands() throws { + quietTestApp() + let application = NSApplication.shared + let previousMainMenu = application.mainMenu + let previousWindowsMenu = application.windowsMenu + defer { + application.mainMenu = previousMainMenu + application.windowsMenu = previousWindowsMenu + } + + ApplicationMenu.install(on: application, appName: "Token Menu Bar") + + let mainMenu = try #require(application.mainMenu) + let submenus = Dictionary(uniqueKeysWithValues: mainMenu.items.compactMap(\.submenu).map { ($0.title, $0) }) + let edit = try #require(submenus["Edit"]) + let expected: [(title: String, action: Selector, key: String)] = [ + ("Cut", #selector(NSText.cut(_:)), "x"), ("Copy", #selector(NSText.copy(_:)), "c"), + ("Paste", #selector(NSText.paste(_:)), "v"), ("Select All", #selector(NSText.selectAll(_:)), "a"), + ] + for (title, action, key) in expected { + let item = try #require(edit.items.first { $0.title == title }) + #expect(item.action == action) + #expect(item.keyEquivalent == key) + #expect(item.keyEquivalentModifierMask == .command) + } + let window = try #require(submenus["Window"]) + let close = try #require(window.items.first { $0.title == "Close" }) + #expect(close.action == #selector(NSWindow.performClose(_:))) + #expect(close.keyEquivalent == "w") + #expect(application.windowsMenu === window) + #expect(submenus[""]?.items.first?.title == "Quit Token Menu Bar") + #expect(submenus[""]?.items.first?.action == #selector(NSApplication.terminate(_:))) + } +} diff --git a/Tests/TokenMenuBarUITests/BrandIconTests.swift b/Tests/TokenMenuBarUITests/BrandIconTests.swift index 9b500a5..0d55195 100644 --- a/Tests/TokenMenuBarUITests/BrandIconTests.swift +++ b/Tests/TokenMenuBarUITests/BrandIconTests.swift @@ -2,6 +2,7 @@ import AppKit import SwiftUI import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/ChartInteractionCoverageTests.swift b/Tests/TokenMenuBarUITests/ChartInteractionCoverageTests.swift index 073d32f..a8ce73c 100644 --- a/Tests/TokenMenuBarUITests/ChartInteractionCoverageTests.swift +++ b/Tests/TokenMenuBarUITests/ChartInteractionCoverageTests.swift @@ -2,121 +2,125 @@ import AppKit import ObjectiveC import SwiftUI import Testing +import TokenMenuBarTestSupport @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) -} +@Suite(.serialized) +struct ChartInteractionCoverageTests { + @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 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 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 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 + @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() + export.action() - #expect(ChartCoverageSavePanelObservation.wasPresented) + #expect(ChartCoverageSavePanelObservation.wasPresented) + } } @MainActor diff --git a/Tests/TokenMenuBarUITests/ComponentPolicyTests.swift b/Tests/TokenMenuBarUITests/ComponentPolicyTests.swift index e7fa79c..8287f56 100644 --- a/Tests/TokenMenuBarUITests/ComponentPolicyTests.swift +++ b/Tests/TokenMenuBarUITests/ComponentPolicyTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/CoverageBehaviorTests.swift b/Tests/TokenMenuBarUITests/CoverageBehaviorTests.swift index 2e4daaf..ae5a993 100644 --- a/Tests/TokenMenuBarUITests/CoverageBehaviorTests.swift +++ b/Tests/TokenMenuBarUITests/CoverageBehaviorTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI @@ -28,13 +29,16 @@ import Testing 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) + #expect( + inkFraction(StatusPreview(model: model, highlightedKey: .constant(nil), select: { _ in }), 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) + let preview = StatusPreview(model: model, highlightedKey: .constant(nil), select: { _ in }) + let wide = fittingSize(preview, width: 700) + let narrow = fittingSize(preview, width: 180) #expect(narrow.width <= 180) #expect(narrow.height > wide.height) @@ -54,10 +58,16 @@ import Testing #expect(copied == text) } -@Test @MainActor func providerCardDefaultRefreshActionIsSafe() throws { +@Test @MainActor func providerCardRefreshesItsProvider() throws { let environment = try makeEnvironment() - let card = ProviderCardView(card: try #require(environment.cards.first), environment: environment) + var refreshed: [ProviderID] = [] + let card = ProviderCardView( + card: try #require(environment.cards.first), environment: environment, + onRefreshProvider: { refreshed.append($0) }) + card.refresh() + + #expect(refreshed == [card.card.provider]) } @MainActor private func fittingSize(_ view: Content, width: CGFloat) -> CGSize { diff --git a/Tests/TokenMenuBarUITests/CoverageClosureUIBehaviorTests.swift b/Tests/TokenMenuBarUITests/CoverageClosureUIBehaviorTests.swift index 0a714eb..b3ea19f 100644 --- a/Tests/TokenMenuBarUITests/CoverageClosureUIBehaviorTests.swift +++ b/Tests/TokenMenuBarUITests/CoverageClosureUIBehaviorTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/CoverageGateComponentBehaviorTests.swift b/Tests/TokenMenuBarUITests/CoverageGateComponentBehaviorTests.swift index aab3324..e9f3b1a 100644 --- a/Tests/TokenMenuBarUITests/CoverageGateComponentBehaviorTests.swift +++ b/Tests/TokenMenuBarUITests/CoverageGateComponentBehaviorTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/CoverageGateProviderMarkFallbackTests.swift b/Tests/TokenMenuBarUITests/CoverageGateProviderMarkFallbackTests.swift index 8f704f3..2fab2f9 100644 --- a/Tests/TokenMenuBarUITests/CoverageGateProviderMarkFallbackTests.swift +++ b/Tests/TokenMenuBarUITests/CoverageGateProviderMarkFallbackTests.swift @@ -1,61 +1,25 @@ import AppKit -import Foundation +import SwiftUI import Testing +import TokenMenuBarCore +import TokenMenuBarTestSupport @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) - } +@Test @MainActor func providerMarkUsesItsTextFallbackWhenTheAssetIsMissing() throws { + let missing = ProviderMarkImageLoader(resourceURL: { _ in nil }) + let fallback = try rendered(ProviderMarkView(.claude, imageLoader: missing)) + let image = try rendered(ProviderMarkView(.claude, imageLoader: .shared)) - 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) + #expect(missing.image(for: .claude, appearance: .light) == nil) + #expect(!fallback.isEmpty) + #expect(fallback != image) } -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 +@MainActor +private func rendered(_ view: ProviderMarkView) throws -> Data { + let hosting = host(view, width: ProviderMarkView.defaultSize.width, height: ProviderMarkView.defaultSize.height) + let rep = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: rep) + return try #require(rep.tiffRepresentation) } diff --git a/Tests/TokenMenuBarUITests/CoverageGateSettingsInteractionTests.swift b/Tests/TokenMenuBarUITests/CoverageGateSettingsInteractionTests.swift index e0471ef..fc573f5 100644 --- a/Tests/TokenMenuBarUITests/CoverageGateSettingsInteractionTests.swift +++ b/Tests/TokenMenuBarUITests/CoverageGateSettingsInteractionTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/CoverageGateWindowSelectionBehaviorTests.swift b/Tests/TokenMenuBarUITests/CoverageGateWindowSelectionBehaviorTests.swift index 33444ec..34f01a3 100644 --- a/Tests/TokenMenuBarUITests/CoverageGateWindowSelectionBehaviorTests.swift +++ b/Tests/TokenMenuBarUITests/CoverageGateWindowSelectionBehaviorTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI @@ -68,11 +69,15 @@ import Testing let group = try #require(list.groups.first) let row = try #require(list.groups.first?.rows.first) drafts[row.key] = "CUSTOM" + environment.settings.setShortLabel("CUSTOM", for: row.key) #expect(inkFraction(list.modelRow(row), width: 880, height: 100) > 0) - list.queryChangeAction("before", "after") + let originalModels = environment.settings.modelOrder list.modelMoveAction(row.key, by: 1)() + #expect(environment.settings.modelOrder != originalModels) list.revertAction(row)() + #expect(environment.settings.shortLabels[row.key] == nil) + #expect(drafts[row.key] == row.defaultLabel) let provider = list.reorderDragAction("model:\(row.key.storageKey)")() #expect(provider.canLoadObject(ofClass: NSString.self)) diff --git a/Tests/TokenMenuBarUITests/DensityBudgetTests.swift b/Tests/TokenMenuBarUITests/DensityBudgetTests.swift index 07c1fc6..d344dd2 100644 --- a/Tests/TokenMenuBarUITests/DensityBudgetTests.swift +++ b/Tests/TokenMenuBarUITests/DensityBudgetTests.swift @@ -1,5 +1,6 @@ import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/FeatureTests.swift b/Tests/TokenMenuBarUITests/FeatureTests.swift index b38164b..995b2c8 100644 --- a/Tests/TokenMenuBarUITests/FeatureTests.swift +++ b/Tests/TokenMenuBarUITests/FeatureTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport import WidgetKit @testable import TokenMenuBarCore @@ -161,7 +162,7 @@ import WidgetKit #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 defaults = testDefaults("demo") let dependencies = try await LiveDependencies.make( appInfo: testAppInfo, paths: paths, defaults: defaults, notificationCenter: nil, updater: nil, isSandboxed: false, transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) @@ -184,7 +185,7 @@ import WidgetKit appInfo: testAppInfo, paths: LiveDependencies.Paths( home: root, supportDirectory: root, environment: [:], userName: "tester", arguments: []), - defaults: UserDefaults(suiteName: "demo-\(UUID().uuidString)")!, notificationCenter: nil, updater: nil, + defaults: testDefaults("demo-disabled"), notificationCenter: nil, updater: nil, isSandboxed: false, transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) #expect(!viaSetting.isDemo) } @@ -197,7 +198,7 @@ import WidgetKit 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, + defaults: testDefaults("control-audit"), notificationCenter: nil, updater: nil, isSandboxed: false, transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) #expect(dependencies.isDemo) @@ -225,6 +226,7 @@ import WidgetKit open: { url, configuration, done in #expect(url == Bundle.main.bundleURL) #expect(configuration.createsNewApplicationInstance) + #expect(configuration.arguments == [LaunchPolicy.relaunchedArgument]) #expect(configuration.environment["TOKEN_MENU_BAR_DEMO"] == nil) done() }, diff --git a/Tests/TokenMenuBarUITests/HistoryAnalyticsRefreshTests.swift b/Tests/TokenMenuBarUITests/HistoryAnalyticsRefreshTests.swift index 9f1dada..dd7ee4e 100644 --- a/Tests/TokenMenuBarUITests/HistoryAnalyticsRefreshTests.swift +++ b/Tests/TokenMenuBarUITests/HistoryAnalyticsRefreshTests.swift @@ -1,4 +1,5 @@ import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/HistoryViewClosureBehaviorCoverageTests.swift b/Tests/TokenMenuBarUITests/HistoryViewClosureBehaviorCoverageTests.swift index f489dd1..2325c60 100644 --- a/Tests/TokenMenuBarUITests/HistoryViewClosureBehaviorCoverageTests.swift +++ b/Tests/TokenMenuBarUITests/HistoryViewClosureBehaviorCoverageTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/InteractionTests.swift b/Tests/TokenMenuBarUITests/InteractionTests.swift index 675fec5..87264b2 100644 --- a/Tests/TokenMenuBarUITests/InteractionTests.swift +++ b/Tests/TokenMenuBarUITests/InteractionTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/LogSectionCoverageClosureTests.swift b/Tests/TokenMenuBarUITests/LogSectionCoverageClosureTests.swift index 6a29889..2e78f5b 100644 --- a/Tests/TokenMenuBarUITests/LogSectionCoverageClosureTests.swift +++ b/Tests/TokenMenuBarUITests/LogSectionCoverageClosureTests.swift @@ -1,4 +1,5 @@ import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/LogTooltipCoverageTests.swift b/Tests/TokenMenuBarUITests/LogTooltipCoverageTests.swift index 1020c92..d59d82c 100644 --- a/Tests/TokenMenuBarUITests/LogTooltipCoverageTests.swift +++ b/Tests/TokenMenuBarUITests/LogTooltipCoverageTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI @@ -78,17 +79,20 @@ import Testing let directory = try makeCoverageDirectory() defer { try? FileManager.default.removeItem(at: directory) } let fileURL = directory.appendingPathComponent("app.log") + let retainedEntryLimit = 500 var retained = "" - retained.reserveCapacity(LogBuffer.retainedEntryLimit * 48) + retained.reserveCapacity(retainedEntryLimit * 48) let prefix = "[\(LogBuffer.timestampFormat.string(from: fixedNow))] [info] retained-" - for index in 0.. [NativeActionButton] { diff --git a/Tests/TokenMenuBarUITests/PanelMaterialAdapterTests.swift b/Tests/TokenMenuBarUITests/PanelMaterialAdapterTests.swift index c752ef9..fae96d3 100644 --- a/Tests/TokenMenuBarUITests/PanelMaterialAdapterTests.swift +++ b/Tests/TokenMenuBarUITests/PanelMaterialAdapterTests.swift @@ -1,4 +1,5 @@ import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/PopoverTests.swift b/Tests/TokenMenuBarUITests/PopoverTests.swift index 5910a18..532b17a 100644 --- a/Tests/TokenMenuBarUITests/PopoverTests.swift +++ b/Tests/TokenMenuBarUITests/PopoverTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI @@ -23,12 +24,16 @@ import Testing @MainActor private func anchoredPopover( - isKeyWindow: @escaping (NSWindow) -> Bool = { $0.isKeyWindow } + isKeyWindow: @escaping (NSWindow) -> Bool = { $0.isKeyWindow }, + beginActivation: @escaping PopoverActivationHandler = { nil }, + applicationCenter: NotificationCenter = .default, + workspaceCenter: NotificationCenter = NSWorkspace.shared.notificationCenter ) -> (PopoverController, NSView, NSWindow) { quietTestApp() let controller = PopoverController( content: AnyView(Text("hello").frame(width: 300, height: 200)), log: nil, animates: false, - isKeyWindow: isKeyWindow, presentsWindow: false) + isKeyWindow: isKeyWindow, presentsWindow: false, beginActivation: beginActivation, + applicationCenter: applicationCenter, workspaceCenter: workspaceCenter) 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. @@ -308,11 +313,10 @@ private func keyEvent( controller.close() } -@Test @MainActor func popoverDismissalIgnoresMouseMovesAndKeysItDoesNotOwn() async throws { +@Test @MainActor func popoverDismissalClosesOnOutsideClicksAndIgnoresUnownedKeys() 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", @@ -364,13 +368,6 @@ private func keyEvent( #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) } @@ -386,6 +383,39 @@ private func keyEvent( #expect(visibility.last == false) } +@Test(arguments: [NSApplication.didResignActiveNotification, NSWorkspace.activeSpaceDidChangeNotification]) +@MainActor func popoverClosesWhenTheUserSwitchesAppOrSpace(name: Notification.Name) async throws { + let center = NotificationCenter() + var yielded = 0 + let (controller, anchor, window) = anchoredPopover( + isKeyWindow: { _ in true }, beginActivation: { { yielded += 1 } }, applicationCenter: center, + workspaceCenter: center) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + #expect(controller.isShown) + + center.post(name: name, object: nil) + + await waitUntil { !controller.isShown } + #expect(!controller.isShown) + // The user already moved on, so pulling focus back to the previous app would fight the switch they made. + #expect(yielded == 0) +} + +@Test @MainActor func popoverYieldsActivationBackToThePreviousAppWhenClosed() async throws { + var yielded = 0 + let (controller, anchor, window) = anchoredPopover(isKeyWindow: { _ in true }, beginActivation: { { yielded += 1 } }) + defer { window.orderOut(nil) } + controller.show(relativeTo: anchor, anchorFrame: nil, visibleFrame: nil) + #expect(yielded == 0) + + controller.close() + + await waitUntil { yielded == 1 } + #expect(yielded == 1) + #expect(!controller.isShown) +} + @Test @MainActor func popoverPublicInitializerRejectsKeysFromANonkeyBackingWindow() throws { let (_, anchor, window) = anchoredPopover() defer { window.orderOut(nil) } @@ -439,10 +469,8 @@ private func keyEvent( 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) } diff --git a/Tests/TokenMenuBarUITests/ProviderMarkTests.swift b/Tests/TokenMenuBarUITests/ProviderMarkTests.swift index 30095e7..3451583 100644 --- a/Tests/TokenMenuBarUITests/ProviderMarkTests.swift +++ b/Tests/TokenMenuBarUITests/ProviderMarkTests.swift @@ -4,43 +4,16 @@ import Foundation import SwiftUI import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport @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)) + let loader = ProviderMarkImageLoader() + let image = try #require(loader.image(for: provider, appearance: appearance)) #expect(image.size.width > 0) #expect(image.size.height > 0) } @@ -49,7 +22,8 @@ private func resolveExecutable(named name: String, near resource: URL) -> URL? { @MainActor func providerMarkLoaderPreservesOriginalRendering( provider: ProviderID, appearance: ProviderMarkAppearance ) throws { - let image = try #require(ProviderMarkImageLoader.shared.image(for: provider, appearance: appearance)) + let loader = ProviderMarkImageLoader() + let image = try #require(loader.image(for: provider, appearance: appearance)) #expect(!image.isTemplate) } @@ -57,8 +31,9 @@ private func resolveExecutable(named name: String, near resource: URL) -> URL? { @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)) + let loader = ProviderMarkImageLoader() + let first = try #require(loader.image(for: provider, appearance: appearance)) + let second = try #require(loader.image(for: provider, appearance: appearance)) #expect(first === second) } diff --git a/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift b/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift index e6bc441..2a5f3aa 100644 --- a/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift +++ b/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/SettingsUIBehaviorCoverageTests.swift b/Tests/TokenMenuBarUITests/SettingsUIBehaviorCoverageTests.swift index 002e281..61ba5c2 100644 --- a/Tests/TokenMenuBarUITests/SettingsUIBehaviorCoverageTests.swift +++ b/Tests/TokenMenuBarUITests/SettingsUIBehaviorCoverageTests.swift @@ -1,19 +1,14 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI -@Test @MainActor func defaultViewActionsAreInert() throws { - let environment = try makeEnvironment() - let root = RootView(environment: environment, onMeasure: { _ in }, onTabChange: { _ in }) +@Test @MainActor func rootViewWithoutAChooserOffersNoHistoryExportDestination() throws { + let root = RootView(environment: try makeEnvironment(), 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 { @@ -21,6 +16,14 @@ import Testing #expect(SettingsTab(environment: environment).providerAvailabilityText(.codex) == "Not configured") } +@Test @MainActor func settingsListsOnlyRefreshProvidersSupportedByItsSandboxPolicy() throws { + let environment = try makeEnvironment(populate: false) + #expect(SettingsTab(environment: environment).tokenRefreshProviderNames == "Claude and Codex") + + environment.isSandboxed = false + #expect(SettingsTab(environment: environment).tokenRefreshProviderNames == "Claude, Codex, and Gemini") +} + @Test @MainActor func authenticationRecoveryPrefersRequiredResourceAccess() { for (health, title) in [ (ResourceAccessHealth.needed, "File access needed"), @@ -109,7 +112,7 @@ import Testing var highlighted: WindowKey? let preview = StatusPreview( model: model, - highlightedKey: Binding(get: { highlighted }, set: { highlighted = $0 })) + highlightedKey: Binding(get: { highlighted }, set: { highlighted = $0 }), select: { _ in }) let hover = try #require(hoverActions(in: preview.preview(cell)).first) hover(true) diff --git a/Tests/TokenMenuBarUITests/SingleInstanceGuardTests.swift b/Tests/TokenMenuBarUITests/SingleInstanceGuardTests.swift new file mode 100644 index 0000000..dcc8b1b --- /dev/null +++ b/Tests/TokenMenuBarUITests/SingleInstanceGuardTests.swift @@ -0,0 +1,66 @@ +import AppKit +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test(arguments: [ + (arguments: ["TokenMenuBar"], environment: [String: String](), yields: true), + (arguments: ["TokenMenuBar", LaunchPolicy.relaunchedArgument], environment: [:], yields: false), + (arguments: ["TokenMenuBar", LaunchPolicy.verificationArgument], environment: [:], yields: false), + (arguments: ["TokenMenuBar"], environment: [LaunchPolicy.verificationEnvironmentKey: "1"], yields: false), +]) +func launchPolicyYieldsToARunningInstanceOnlyForPlainLaunches( + arguments: [String], environment: [String: String], yields: Bool +) { + #expect(LaunchPolicy(arguments: arguments, environment: environment).yieldsToRunningInstance == yields) +} + +@Test @MainActor func singleInstanceGuardActivatesTheOtherInstanceAndAsksThisOneToExit() { + quietTestApp() + var lookups: [String] = [] + + let handedOff = SingleInstanceGuard.handOff( + policy: LaunchPolicy(arguments: ["TokenMenuBar"], environment: [:]), bundleIdentifier: "dev.tox.token-menu-bar", + currentProcessIdentifier: 0, + runningApplications: { + lookups.append($0) + return [.current] + }) + + #expect(handedOff) + #expect(lookups == ["dev.tox.token-menu-bar"]) +} + +@Test @MainActor func singleInstanceGuardIgnoresItself() { + let handedOff = SingleInstanceGuard.handOff( + policy: LaunchPolicy(arguments: ["TokenMenuBar"], environment: [:]), bundleIdentifier: "dev.tox.token-menu-bar", + runningApplications: { _ in [.current] }) + + #expect(!handedOff) +} + +@Test @MainActor func singleInstanceGuardLeavesUnbundledAndRelaunchedProcessesAlone() { + var lookups = 0 + let find: (String) -> [NSRunningApplication] = { _ in + lookups += 1 + return [.current] + } + + #expect( + !SingleInstanceGuard.handOff( + policy: LaunchPolicy(arguments: ["TokenMenuBar"], environment: [:]), bundleIdentifier: nil, + currentProcessIdentifier: 0, runningApplications: find)) + #expect( + !SingleInstanceGuard.handOff( + policy: LaunchPolicy(arguments: ["TokenMenuBar", LaunchPolicy.relaunchedArgument], environment: [:]), + bundleIdentifier: "dev.tox.token-menu-bar", currentProcessIdentifier: 0, runningApplications: find)) + #expect(lookups == 0) +} + +@Test @MainActor func singleInstanceGuardConsultsTheRunningApplicationList() { + let policy = LaunchPolicy(arguments: ["TokenMenuBar"], environment: [:]) + + #expect(!SingleInstanceGuard.handOff(policy: policy, bundleIdentifier: "dev.tox.token-menu-bar.absent")) +} diff --git a/Tests/TokenMenuBarUITests/StartupClosureCoverageTests.swift b/Tests/TokenMenuBarUITests/StartupClosureCoverageTests.swift index 7740d4b..704c6e5 100644 --- a/Tests/TokenMenuBarUITests/StartupClosureCoverageTests.swift +++ b/Tests/TokenMenuBarUITests/StartupClosureCoverageTests.swift @@ -2,6 +2,7 @@ import AppKit import Foundation import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI @@ -29,7 +30,7 @@ struct StartupClosureCoverageTests { 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:") } } + await waitUntil { center.requests.contains { $0.identifier.hasSuffix(":auth") } } controller.start() defer { controller.stop() } let statusItem = try #require(controller.statusItem) @@ -54,7 +55,7 @@ struct StartupClosureCoverageTests { statusItem.onClick?() #expect(popover.isShown != wasShown) - #expect(center.requests.contains { $0.identifier.contains(":auth:") }) + #expect(center.requests.contains { $0.identifier.hasSuffix(":auth") }) controller.environment.actions.refreshProvider(.claude) await waitUntil { dependencies.state.state(for: .claude).snapshot != nil } @@ -85,6 +86,7 @@ struct StartupClosureCoverageTests { let (dependencies, _) = try makeDependencies() var controller: AppController? = AppController(dependencies: dependencies) let actions = try #require(controller?.environment.actions) + let menuTarget = try #require(controller?.menuTarget) let released = WeakReference(controller) controller = nil @@ -93,7 +95,7 @@ struct StartupClosureCoverageTests { await mainActorTurn() #expect(released.value == nil) - #expect(AppControllerMenuSource(nil).menu().items.isEmpty) + #expect(menuTarget.menu().items.isEmpty) } @Test @MainActor func startupClosureAppFallbacksUseAvailableGeometryAndCredentialState() throws { @@ -163,7 +165,7 @@ struct StartupClosureCoverageTests { 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, + defaults: testDefaults("startup-closure-deferred"), notificationCenter: nil, updater: nil, isSandboxed: false, transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory(), workspaceOpen: { _, configuration, completion in diff --git a/Tests/TokenMenuBarUITests/StartupCoverageTests.swift b/Tests/TokenMenuBarUITests/StartupCoverageTests.swift index 39528bc..80dab13 100644 --- a/Tests/TokenMenuBarUITests/StartupCoverageTests.swift +++ b/Tests/TokenMenuBarUITests/StartupCoverageTests.swift @@ -2,6 +2,7 @@ import AppKit import ObjectiveC import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI @@ -13,17 +14,26 @@ struct StartupCoverageTests { 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 clock = ManualClock() + let (dependencies, _) = try makeDependencies(providers: providers, clock: clock.clock) let controller = AppController(dependencies: dependencies) controller.start() defer { controller.stop() } let button = try #require(controller.statusItem?.item.button) + let popover = try #require(controller.popover) + let polls = { clock.sleeps.count(where: { $0 == AppController.attachmentPollInterval }) } button.removeFromSuperview() - try await Task.sleep(for: .milliseconds(1_100)) + await clock.advance(by: AppController.attachmentPollInterval) { polls() >= 10 } #expect(button.window == nil) - #expect(controller.popover?.isShown == false) + #expect(!popover.isShown) + + let window = detachedStatusWindow(holding: button) + defer { window.orderOut(nil) } + await clock.advance(by: AppController.attachmentPollInterval) { popover.isShown } + + #expect(popover.isShown) } @Test @MainActor func launchPopoverOpensOnceAfterStableAttachment() async throws { @@ -31,27 +41,21 @@ struct StartupCoverageTests { 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 clock = ManualClock() + let (dependencies, _) = try makeDependencies(providers: providers, clock: clock.clock) 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() + let window = detachedStatusWindow(holding: button) defer { window.orderOut(nil) } let popover = try #require(controller.popover) - await waitUntil(within: 1) { popover.isShown } + await clock.advance(by: AppController.attachmentPollInterval) { popover.isShown } #expect(popover.isShown) + // Two polls: the first records the button frame and the second confirms it stayed put. + #expect(clock.sleeps.count(where: { $0 == AppController.attachmentPollInterval }) == 2) popover.close() #expect(!popover.isShown) } @@ -191,7 +195,7 @@ struct StartupCoverageTests { 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(), + defaults: testDefaults("deferred-failure"), transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) defer { method_exchangeImplementations(replacement, original) @@ -216,7 +220,7 @@ struct StartupCoverageTests { 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(), + defaults: testDefaults("deferred-success"), transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) delegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) defer { delegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } @@ -257,7 +261,7 @@ struct StartupCoverageTests { arguments: [], verificationProfile: VerificationProfile( fixture: .longText, visibleFrameWidth: Double(screen.visibleFrame.width + 100))), - defaults: UserDefaults(suiteName: "deferred-live-\(UUID().uuidString)")!, notificationCenter: nil, updater: nil, + defaults: testDefaults("deferred-live"), notificationCenter: nil, updater: nil, isSandboxed: false, transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory()) #expect(dependencies.history.location == support.appendingPathComponent("usage-demo.sqlite")) @@ -280,8 +284,8 @@ struct StartupCoverageTests { let saveOriginal = class_getInstanceMethod(NSSavePanel.self, #selector(NSSavePanel.runModal))! let saveReplacement = class_getInstanceMethod(NSSavePanel.self, #selector(NSSavePanel.startupCoverageRunModal))! method_exchangeImplementations(saveOriginal, saveReplacement) + defer { method_exchangeImplementations(saveReplacement, saveOriginal) } let export = chooseExport() - method_exchangeImplementations(saveReplacement, saveOriginal) #expect(export == nil) let chooseDirectory = LiveDependencies.directoryChooser(profile: nil, paths: paths, supportDirectory: root) @@ -289,8 +293,8 @@ struct StartupCoverageTests { let openReplacement = class_getInstanceMethod( NSOpenPanel.self, #selector(NSOpenPanel.startupCoverageOpenPanelRunModal))! method_exchangeImplementations(openOriginal, openReplacement) + defer { method_exchangeImplementations(openReplacement, openOriginal) } let directory = chooseDirectory(ProviderID.codex.sandboxResources[0]) - method_exchangeImplementations(openReplacement, openOriginal) #expect(directory == nil) } } @@ -299,6 +303,23 @@ private enum StartupFailure: Error { case failed } +/// Moves the status button into a transparent window at the menu bar's edge, which is what a status item attached +/// to the system bar looks like to the controller. +@MainActor +private func detachedStatusWindow(holding button: NSStatusBarButton) -> NSWindow { + 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() + return window +} + private final class StartupDateSource: @unchecked Sendable { private let lock = NSLock() private var date = fixedNow diff --git a/Tests/TokenMenuBarUITests/StatusItemTests.swift b/Tests/TokenMenuBarUITests/StatusItemTests.swift index ae2fba6..87c3782 100644 --- a/Tests/TokenMenuBarUITests/StatusItemTests.swift +++ b/Tests/TokenMenuBarUITests/StatusItemTests.swift @@ -1,5 +1,6 @@ import AppKit import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI @@ -104,6 +105,25 @@ func appIconInkFollowsTone(tone: StatusIconTone, dark: Bool, ink: NSColor) { #expect(StatusItemRenderer.miniBarImage(cell, height: 18, dark: false).size.width > 0) } +@Test @MainActor func rendererCachesEveryAdaptiveWidthTier() { + let models = (0.. TokenMenuBarCore.Settings { - TokenMenuBarCore.Settings(defaults: UserDefaults(suiteName: "ui-tests-\(UUID().uuidString)")!) -} - -func makeLog() -> LogBuffer { - LogBuffer(fileURL: nil, clock: testClock) + TokenMenuBarCore.Settings(defaults: testDefaults()) } let testAppInfo = AppInfo( @@ -187,8 +183,11 @@ final class FakeNotificationCenter: NotificationCenterProtocol, @unchecked Senda var addError: (any Error)? private(set) var requests: [UNNotificationRequest] = [] private(set) var removed: [String] = [] + private(set) var requestedOptions: [UNAuthorizationOptions] = [] + weak var delegate: (any UNUserNotificationCenterDelegate)? func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { + lock.withLock { requestedOptions.append(options) } if let authorizationError { throw authorizationError } return authorize } @@ -201,9 +200,8 @@ final class FakeNotificationCenter: NotificationCenterProtocol, @unchecked Senda func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { lock.withLock { removed += identifiers } } -} -struct TestError: Error {} +} extension UsageHistoryStore { func breakDatabase() throws { @@ -214,10 +212,17 @@ extension UsageHistoryStore { @MainActor final class FakeUpdater: UpdaterHook { - var canCheck = true + var canCheck = true { didSet { onCanCheckChange?(canCheck) } } var automaticallyChecks = false + var onCanCheckChange: (@MainActor (Bool) -> Void)? + var starts = 0 var checks = 0 + func start() { + starts += 1 + onCanCheckChange?(canCheck) + } + func checkForUpdates() { checks += 1 } diff --git a/Tests/TokenMenuBarUITests/TooltipCoverageClosureTests.swift b/Tests/TokenMenuBarUITests/TooltipCoverageClosureTests.swift index d016fe9..6637e05 100644 --- a/Tests/TokenMenuBarUITests/TooltipCoverageClosureTests.swift +++ b/Tests/TokenMenuBarUITests/TooltipCoverageClosureTests.swift @@ -1,5 +1,6 @@ import AppKit import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/TooltipTests.swift b/Tests/TokenMenuBarUITests/TooltipTests.swift index 7e9e9f4..b7f787e 100644 --- a/Tests/TokenMenuBarUITests/TooltipTests.swift +++ b/Tests/TokenMenuBarUITests/TooltipTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI diff --git a/Tests/TokenMenuBarUITests/ViewTests.swift b/Tests/TokenMenuBarUITests/ViewTests.swift index 927efe9..7f0aaa1 100644 --- a/Tests/TokenMenuBarUITests/ViewTests.swift +++ b/Tests/TokenMenuBarUITests/ViewTests.swift @@ -2,6 +2,7 @@ import Accessibility import AppKit import SwiftUI import Testing +import TokenMenuBarTestSupport @testable import TokenMenuBarCore @testable import TokenMenuBarUI @@ -234,7 +235,7 @@ private func firstSubview(_ type: View.Type, in root: NSView) -> V } stale.refreshUsagePresentation() #expect(inkFraction(UsageTab(environment: stale)) > 0) - let card = ProviderCardView(card: try #require(stale.cards.first), environment: stale) + let card = ProviderCardView(card: try #require(stale.cards.first), environment: stale, onRefreshProvider: { _ in }) #expect(card.icon(for: .authenticationRequired) == "person.crop.circle.badge.exclamationmark") #expect(card.icon(for: .networkUnavailable) == "wifi.slash") #expect(card.icon(for: .disabled) == "pause.circle") @@ -274,7 +275,8 @@ private func firstSubview(_ type: View.Type, in root: NSView) -> V environment.refreshUsagePresentation() var opened: [ProviderID?] = [] environment.actions.showProviders = { opened.append($0) } - let card = ProviderCardView(card: try #require(environment.cards.first), environment: environment) + let card = ProviderCardView( + card: try #require(environment.cards.first), environment: environment, onRefreshProvider: { _ in }) card.showProviders() #expect(opened == [.claude]) } @@ -679,7 +681,10 @@ private func uiTemporaryDirectory() throws -> URL { #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( + StatusPreview(model: statusModel(), highlightedKey: .constant(nil), select: { _ in }), width: 300, height: 40) > 0 + ) #expect(inkFraction(LogSection(environment: environment), width: 400, height: 300) > 0) } @@ -801,6 +806,29 @@ private func uiTemporaryDirectory() throws -> URL { #expect(textView.string.isEmpty) } +@Test @MainActor func logTextViewUpdatesItsHeight() { + let entries = [LogEntry(timestamp: fixedNow, level: .info, message: "one")] + let hosting = host(LogTextView(entries: entries, height: 100), width: 300, height: 160) + let scroll: NSScrollView = hosting.subviews.compactMap { $0 as? NSScrollView }.first ?? findView(hosting)! + + hosting.rootView = LogTextView(entries: entries, height: 140) + hosting.layoutSubtreeIfNeeded() + + #expect( + scroll.constraints.first { $0.firstAttribute == .height && $0.isActive }?.constant == 140) +} + +@Test @MainActor func logTextViewUpdatesItsBorder() { + let entries = [LogEntry(timestamp: fixedNow, level: .info, message: "one")] + let hosting = host(LogTextView(entries: entries, height: 100), width: 300, height: 120) + let scroll: NSScrollView = hosting.subviews.compactMap { $0 as? NSScrollView }.first ?? findView(hosting)! + + hosting.rootView = LogTextView(entries: entries, height: 100, bordered: false) + hosting.layoutSubtreeIfNeeded() + + #expect(scroll.borderType == .noBorder) +} + @Test @MainActor func fullLogMergePreservesRetainedLinesAndAppendsOnlyNewLiveLines() { let first = LogEntry(timestamp: fixedNow, level: .info, message: "one") let second = LogEntry(timestamp: fixedNow, level: .info, message: "two") diff --git a/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift b/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift index 476fcdf..8b66855 100644 --- a/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift +++ b/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift @@ -2,6 +2,7 @@ import AppKit import SwiftUI import Testing import TokenMenuBarCore +import TokenMenuBarTestSupport import TokenMenuBarWidgets import WidgetKit @@ -21,6 +22,7 @@ import WidgetKit let timeline = provider.timeline() #expect(timeline.entries.count == 1) #expect(timeline.entries[0].snapshot.attention) + #expect(UsageTimelineProvider.refreshInterval == WidgetSnapshot.refreshInterval) #expect( UsageTimelineProvider.defaultStore( containerURL: { _ in nil }, fallbackDirectory: store.url.deletingLastPathComponent() @@ -37,8 +39,6 @@ import WidgetKit #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")) @@ -90,6 +90,18 @@ private let populatedEntry = UsageEntry( #expect(inkFraction(UsageWidgetView(entry: entry, family: .systemSmall), width: 160, height: 160) > 0) } +@Test @MainActor func widgetRowsRenderUnavailableAndElapsedResetTimes() { + let key = WindowKey(provider: .claude, windowID: "session") + let unavailable = WidgetRow( + key: key, providerName: "Claude", label: "Session", usedPercent: 20, resetsAt: nil) + let elapsed = WidgetRow( + key: key, providerName: "Claude", label: "Session", usedPercent: 20, + resetsAt: fixedNow.addingTimeInterval(-60)) + + #expect(inkFraction(WidgetRowView(row: unavailable, now: fixedNow, compact: false), width: 300, height: 60) > 0) + #expect(inkFraction(WidgetRowView(row: elapsed, now: fixedNow, compact: false), width: 300, height: 60) > 0) +} + @MainActor private var hostingWindows: [NSWindow] = [] From 7b1910519d13db5d33e21803a22067177a24c174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 2 Sep 2026 13:20:29 -0700 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20fix(app):=20close=20the=20co?= =?UTF-8?q?rrectness=20gaps=20found=20against=20rival=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparing against the other usage menu bar apps and their issue trackers surfaced a set of problems users of those apps hit that this app shared. A Claude keychain item holding only an MCP token read as no sign-in at all, a GUI launch could not see the hashed keychain items or alternate Codex homes that CLAUDE_CONFIG_DIR and CODEX_HOME create, and a keychain miss was reported as missing before the credential file was tried. Local transcript cost came from a table keyed by model family that silently mispriced unknown models, and the transcript and rollout readers walked their trees every five minutes on battery. Keychain discovery now lists every Claude Code-credentials item and every Codex home, picks the freshest account and says how many were found. Pricing keys on full model ids, prefers the costUSD Claude Code writes, and warns once when a model is unpriced. Scans run every fifteen minutes, pause on low power or thermal pressure, and skip files older than the retention cutoff. Codex reset credits warn a day before they lapse, a held spurious reset says it is confirming, Cursor windows say which pool they are and carry the component percentages, and org-managed Copilot seats show their organization instead of an error. Notifications gain a sound toggle and group several crossings into one banner, and the usage ladder gets a high-contrast variant with outlined bars when Increase Contrast is on. The README states the five-provider scope and the Gemini personal-account cutoff. Claude-Session: https://claude.ai/code/session_01CGckDFXvufWVmRH4uyE4z8 --- README.md | 5 + .../Credentials/AccountDiscovery.swift | 19 ++ .../Credentials/ClaudeCredentials.swift | 114 ++++++++- .../Credentials/CodexCredentials.swift | 114 +++++++++ .../Credentials/KeychainCredentialData.swift | 15 +- .../Credentials/KeychainDenialCache.swift | 3 +- .../Credentials/KeychainQuery.swift | 16 ++ .../Credentials/SystemKeychain.swift | 10 + .../Models/ProviderSnapshot.swift | 27 ++- .../TokenMenuBarCore/Models/QuotaWindow.swift | 15 +- .../Notifications/NotificationPlanner.swift | 107 +++++++-- .../Presenters/UsagePresenter.swift | 3 +- Sources/TokenMenuBarCore/ProviderSetup.swift | 2 +- .../Providers/Claude/ClaudeProvider.swift | 1 + .../Claude/ClaudeTranscriptReader.swift | 127 ++++++++-- .../Providers/Codex/CodexAPI.swift | 29 ++- .../Providers/Codex/CodexProvider.swift | 2 +- .../Providers/Codex/CodexRolloutReader.swift | 13 +- .../Providers/Copilot/CopilotAPI.swift | 7 +- .../Providers/Cursor/CursorAPI.swift | 10 +- .../Providers/PowerState.swift | 23 ++ .../Providers/ProviderRegistryFactory.swift | 15 +- .../StatusBar/UsageColor.swift | 52 +++- .../TokenMenuBarUI/Adapters/Notifier.swift | 11 +- Sources/TokenMenuBarUI/AppController.swift | 23 +- Sources/TokenMenuBarUI/UIActions.swift | 5 +- Sources/TokenMenuBarUI/Views/Components.swift | 8 +- .../TokenMenuBarUI/Views/SettingsTab.swift | 20 +- .../TokenMenuBarUI/Views/WindowRowView.swift | 19 +- Sources/TokenMenuBarWidgets/UsageWidget.swift | 19 +- .../ClaudeAccountDiscoveryTests.swift | 225 ++++++++++++++++++ .../ClaudeTranscriptReaderTests.swift | 177 ++++++++++++-- .../CodexHomeDiscoveryTests.swift | 166 +++++++++++++ .../CodexResetCreditExpiryTests.swift | 44 ++++ .../CodexRolloutReaderTests.swift | 34 +++ .../TokenMenuBarCoreTests/CopilotTests.swift | 21 ++ .../CredentialFallbackTests.swift | 63 +++++ .../CredentialsTests.swift | 41 +++- Tests/TokenMenuBarCoreTests/CursorTests.swift | 63 ++++- .../Fixtures/copilot_user_org.json | 39 +++ .../Fixtures/cursor_usage_summary_pro.json | 36 +++ Tests/TokenMenuBarCoreTests/ModelTests.swift | 14 ++ .../NotificationPlannerTests.swift | 82 +++++++ .../PowerStateTests.swift | 18 ++ .../ProviderNestedCoverageBehaviorTests.swift | 2 +- .../TokenMenuBarCoreTests/ProviderTests.swift | 61 ++++- .../TokenMenuBarCoreTests/SettingsTests.swift | 7 +- .../Support/MemoryKeychain.swift | 3 + .../Support/PowerSwitch.swift | 20 ++ .../UsageColorContrastTests.swift | 31 +++ Tests/TokenMenuBarUITests/AdapterTests.swift | 22 ++ .../AppControllerTests.swift | 33 +++ Tests/TokenMenuBarUITests/ViewTests.swift | 22 ++ .../WidgetTests.swift | 10 + website/content/explanation/_index.md | 6 + website/content/reference/settings.md | 4 +- 56 files changed, 1935 insertions(+), 143 deletions(-) create mode 100644 Sources/TokenMenuBarCore/Credentials/AccountDiscovery.swift create mode 100644 Sources/TokenMenuBarCore/Providers/PowerState.swift create mode 100644 Tests/TokenMenuBarCoreTests/ClaudeAccountDiscoveryTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CodexHomeDiscoveryTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CodexResetCreditExpiryTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CredentialFallbackTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/copilot_user_org.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/cursor_usage_summary_pro.json create mode 100644 Tests/TokenMenuBarCoreTests/PowerStateTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/Support/PowerSwitch.swift create mode 100644 Tests/TokenMenuBarCoreTests/UsageColorContrastTests.swift diff --git a/README.md b/README.md index 8855f55..7ccf9be 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ clients rather than to this app, and it calls only the vendors' own endpoints. Requires macOS 14 or later on Apple silicon or Intel, and one signed-in client. +The app tracks these five providers on purpose: they are the ones it can read locally. A provider is added only when a +local credential and an official usage endpoint exist for it; Antigravity is the next candidate. Gemini CLI personal +accounts stopped reporting quota on 2026-06-18, so the Gemini card shows an unsupported-account notice for them; +Workspace and Code Assist accounts still work. + - [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 diff --git a/Sources/TokenMenuBarCore/Credentials/AccountDiscovery.swift b/Sources/TokenMenuBarCore/Credentials/AccountDiscovery.swift new file mode 100644 index 0000000..9d8d548 --- /dev/null +++ b/Sources/TokenMenuBarCore/Credentials/AccountDiscovery.swift @@ -0,0 +1,19 @@ +import Foundation + +/// The account whose credential was issued or refreshed most recently; one without a timestamp loses to any that has +/// one, and ties keep the earlier entry. +func latestAccount(_ accounts: [Account], stamp: (Account) -> Date?) -> Account? { + var best: (account: Account, stamp: Date)? + for account in accounts { + let candidate = stamp(account) ?? .distantPast + if best.map({ candidate > $0.stamp }) ?? true { best = (account, candidate) } + } + return best?.account +} + +func summarySource(_ source: CredentialSource, count: Int, showing label: String) -> CredentialSource { + guard count > 1 else { return source } + return CredentialSource( + id: source.id, provider: source.provider, title: source.title, + detail: "\(count) accounts found; showing \(label)") +} diff --git a/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift b/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift index 805429d..fca5aba 100644 --- a/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift +++ b/Sources/TokenMenuBarCore/Credentials/ClaudeCredentials.swift @@ -4,6 +4,7 @@ import Security public struct ClaudeOAuthCredentials: Sendable, Equatable { public static let keychainService = "Claude Code-credentials" + public static let usageTokenMissingDetail = "Claude Code has not stored a usage token yet; run `claude` once." public let accessToken: String public let refreshToken: String? @@ -117,6 +118,8 @@ extension ClaudeCredentialStore { public enum CredentialStoreError: Error, Equatable { case keychain(OSStatus) case malformed(String) + /// The item exists but holds no usable token yet. + case incomplete(String) } public struct KeychainClaudeCredentialStore: ClaudeCredentialStore { @@ -149,11 +152,118 @@ public struct KeychainClaudeCredentialStore: ClaudeCredentialStore { try keychain.save(try JSONEncoder().encode(credentials.document), service: service, account: account) } - static func parse(_ data: Data) throws -> ClaudeOAuthCredentials? { + // Claude Code 2.1 can write the item with only an mcpOAuth block; that is a sign-in without a usage token, not an + // absent sign-in. + 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) + guard let credentials = ClaudeOAuthCredentials(document: document) else { + throw CredentialStoreError.incomplete(ClaudeOAuthCredentials.usageTokenMissingDetail) + } + return credentials + } +} + +public struct ClaudeKeychainAccount: Sendable, Equatable { + public let service: String + public let credentials: ClaudeOAuthCredentials + + public init(service: String, credentials: ClaudeOAuthCredentials) { + self.service = service + self.credentials = credentials + } + + /// The hashed service suffix is all the item reveals about its configuration directory. + public var label: String { + service == ClaudeOAuthCredentials.keychainService + ? "the default configuration" + : "configuration \(service.dropFirst(ClaudeOAuthCredentials.keychainService.count + 1))" + } + + public var source: CredentialSource { + CredentialSource( + id: "claude.keychain:\(service)", provider: .claude, title: "Claude Code Keychain", + detail: "Keychain item \(service)") + } +} + +/// Reads every `Claude Code-credentials*` item, since a GUI launch has no `CLAUDE_CONFIG_DIR` to name the hashed +/// ones, and serves the account whose token expires last. +public struct DiscoveredClaudeKeychainStore: 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 \(service), plus any \(ClaudeOAuthCredentials.keychainService)* item" + } + + public var source: CredentialSource { ProviderID.claude.credentialSource("claude.keychain") } + + public func accounts() throws -> [ClaudeKeychainAccount] { + let scan = scan() + if scan.accounts.isEmpty, let error = scan.error { throw error } + return scan.accounts + } + + public func load() throws -> ClaudeOAuthCredentials? { + try loadWithSource()?.credentials + } + + public func loadWithSource() throws -> (credentials: ClaudeOAuthCredentials, source: CredentialSource)? { + try selected().map { ($0.account.credentials, summarySource(source, count: $0.count, showing: $0.account.label)) } + } + + public func save(_ credentials: ClaudeOAuthCredentials) throws { + let target = try selected()?.account.service ?? service + try KeychainClaudeCredentialStore(service: target, account: account, keychain: keychain).save(credentials) + } + + private func selected() throws -> (account: ClaudeKeychainAccount, count: Int)? { + let scan = scan() + guard let best = latestAccount(scan.accounts, stamp: \.credentials.expiresAt) else { + if let error = scan.error { throw error } + guard scan.incompleteServices.isEmpty else { + throw CredentialStoreError.incomplete(ClaudeOAuthCredentials.usageTokenMissingDetail) + } + return nil + } + return (best, scan.accounts.count) + } + + private func scan() -> (accounts: [ClaudeKeychainAccount], incompleteServices: [String], error: (any Error)?) { + var accounts: [ClaudeKeychainAccount] = [] + var incompleteServices: [String] = [] + var firstError: (any Error)? + for service in services() { + do { + let store = KeychainClaudeCredentialStore(service: service, account: account, keychain: keychain) + if let credentials = try store.load() { + accounts.append(ClaudeKeychainAccount(service: service, credentials: credentials)) + } + } catch CredentialStoreError.incomplete { + incompleteServices.append(service) + } catch { + firstError = firstError ?? error + } + } + return (accounts, incompleteServices, firstError) + } + + private func services() -> [String] { + let listed = (try? keychain.services(prefix: ClaudeOAuthCredentials.keychainService)) ?? [] + return [service] + listed.filter { $0 != service } } } diff --git a/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift b/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift index db5b6de..d45d0d7 100644 --- a/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift +++ b/Sources/TokenMenuBarCore/Credentials/CodexCredentials.swift @@ -242,6 +242,120 @@ public struct ChainedCodexAuthStore: CodexAuthStore { } } +public enum CodexHomeDiscovery { + /// The configured home plus the directories the CLI is commonly pointed at, which a GUI launch cannot learn from + /// `CODEX_HOME`. The CLI config names no alternate home, so nothing is read from config.toml here. + public static func homes(configured: URL, home: URL, fileManager: FileManager = .default) -> [URL] { + var seen: Set = [] + return [configured, home.appendingPathComponent(".codex"), home.appendingPathComponent(".config/codex")] + .filter { candidate in + seen.insert(candidate.standardizedFileURL.path).inserted + && (candidate == configured || fileManager.fileExists(atPath: candidate.path)) + } + } +} + +public enum CodexAuthStores { + public static func store(codexHome: URL, keychain: KeychainCredentialClient) -> any CodexAuthStore { + let file = FileCodexAuthStore(url: codexHome.appendingPathComponent("auth.json")) + return switch CodexCredentialStorageReader.load(from: codexHome.appendingPathComponent("config.toml")) { + case .file: file + case .automatic, .keyring, .unknown: + ChainedCodexAuthStore([KeychainCodexAuthStore(codexHome: codexHome, keychain: keychain), file]) + } + } + + public static func discovering( + configured: URL, home: URL, keychain: KeychainCredentialClient, fileManager: FileManager = .default + ) -> any CodexAuthStore { + let homes = CodexHomeDiscovery.homes(configured: configured, home: home, fileManager: fileManager) + guard homes.count > 1 else { return store(codexHome: configured, keychain: keychain) } + return DiscoveredCodexAuthStore( + homes.map { CodexHomeAuthStore(home: $0, store: store(codexHome: $0, keychain: keychain)) }) + } +} + +public struct CodexHomeAuthStore: Sendable { + public let home: URL + public let store: any CodexAuthStore + + public init(home: URL, store: any CodexAuthStore) { + self.home = home + self.store = store + } +} + +public struct CodexHomeAccount: Sendable, Equatable { + public let home: URL + public let auth: CodexAuth + public let source: CredentialSource + + public init(home: URL, auth: CodexAuth, source: CredentialSource) { + self.home = home + self.auth = auth + self.source = source + } + + public var label: String { + auth.email ?? (home.path as NSString).abbreviatingWithTildeInPath + } +} + +/// Serves the account refreshed most recently across every Codex home found on the machine. +public struct DiscoveredCodexAuthStore: CodexAuthStore { + public let stores: [CodexHomeAuthStore] + + public init(_ stores: [CodexHomeAuthStore]) { + self.stores = stores + } + + public var description: String { stores.map(\.store.description).joined(separator: ", ") } + + public var source: CredentialSource { + CredentialSource( + id: "codex.automatic", provider: .codex, title: "Codex credentials", + detail: stores.map { ($0.home.path as NSString).abbreviatingWithTildeInPath }.joined(separator: ", ")) + } + + public func accounts() throws -> [CodexHomeAccount] { + var accounts: [CodexHomeAccount] = [] + var firstError: CredentialReadFailure? + for entry in stores { + do { + if let found = try entry.store.loadWithSource() { + accounts.append(CodexHomeAccount(home: entry.home, auth: found.auth, source: found.source)) + } + } catch { + firstError = firstError ?? CredentialReadFailure(source: entry.store.source, error: error) + } + } + if accounts.isEmpty, let firstError { throw firstError } + return accounts + } + + public func load() throws -> CodexAuth? { + try loadWithSource()?.auth + } + + public func loadWithSource() throws -> (auth: CodexAuth, source: CredentialSource)? { + try selected().map { + ($0.account.auth, summarySource($0.account.source, count: $0.count, showing: $0.account.label)) + } + } + + public func save(_ auth: CodexAuth) throws { + let selectedHome = try selected()?.account.home + let target = stores.first { $0.home == selectedHome } ?? stores.first + guard let target else { return } + try target.store.save(auth) + } + + private func selected() throws -> (account: CodexHomeAccount, count: Int)? { + let accounts = try accounts() + return latestAccount(accounts, stamp: \.auth.lastRefresh).map { ($0, accounts.count) } + } +} + public enum CodexCredentialStorage: Sendable, Equatable { case automatic case file diff --git a/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift b/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift index 489e678..2c14204 100644 --- a/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift +++ b/Sources/TokenMenuBarCore/Credentials/KeychainCredentialData.swift @@ -13,13 +13,16 @@ public struct KeychainCredentialItem: Sendable, Equatable { public struct KeychainCredentialClient: Sendable { private let loadValue: @Sendable (String, String?) throws -> KeychainCredentialItem? private let saveValue: @Sendable (Data, String, String) throws -> Void + private let listValue: @Sendable (String) throws -> [String] public init( load: @escaping @Sendable (String, String?) throws -> KeychainCredentialItem?, - save: @escaping @Sendable (Data, String, String) throws -> Void + save: @escaping @Sendable (Data, String, String) throws -> Void, + list: @escaping @Sendable (String) throws -> [String] = { _ in [] } ) { loadValue = load saveValue = save + listValue = list } public func load(service: String, account: String? = nil) throws -> KeychainCredentialItem? { @@ -30,7 +33,13 @@ public struct KeychainCredentialClient: Sendable { try saveValue(data, service, account) } + /// The generic-password services starting with `prefix`, sorted and without duplicates. + public func services(prefix: String) throws -> [String] { + try listValue(prefix) + } + public static let empty = KeychainCredentialClient(load: { _, _ in nil }, save: { _, _, _ in }) - public static let system = KeychainCredentialClient(load: systemKeychainLoad, save: systemKeychainSave) - .cachingDenials(clock: .system) + public static let system = KeychainCredentialClient( + load: systemKeychainLoad, save: systemKeychainSave, list: systemKeychainServices + ).cachingDenials(clock: .system) } diff --git a/Sources/TokenMenuBarCore/Credentials/KeychainDenialCache.swift b/Sources/TokenMenuBarCore/Credentials/KeychainDenialCache.swift index a7bd9ab..19b9a7b 100644 --- a/Sources/TokenMenuBarCore/Credentials/KeychainDenialCache.swift +++ b/Sources/TokenMenuBarCore/Credentials/KeychainDenialCache.swift @@ -19,7 +19,8 @@ extension KeychainCredentialClient { throw CredentialStoreError.keychain(status) } }, - save: save) + save: save, + list: services(prefix:)) } } diff --git a/Sources/TokenMenuBarCore/Credentials/KeychainQuery.swift b/Sources/TokenMenuBarCore/Credentials/KeychainQuery.swift index 796ba2e..e24f448 100644 --- a/Sources/TokenMenuBarCore/Credentials/KeychainQuery.swift +++ b/Sources/TokenMenuBarCore/Credentials/KeychainQuery.swift @@ -21,3 +21,19 @@ func systemKeychainQuery(service: String, account: String?) -> [String: Any] { query[kSecAttrAccount as String] = account return query } + +// Attributes alone never trip an item's access control list, so listing every generic password cannot prompt. +func systemKeychainListQuery() -> [String: Any] { + var query = systemKeychainQuery(service: "", account: nil) + query[kSecAttrService as String] = nil + query[kSecReturnData as String] = nil + query[kSecMatchLimit as String] = kSecMatchLimitAll + return query +} + +func keychainServices(_ attributes: [[String: Any]], prefix: String) -> [String] { + attributes.compactMap { $0[kSecAttrService as String] as? String } + .filter { $0.hasPrefix(prefix) } + .uniqued() + .sorted() +} diff --git a/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift b/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift index 988b90f..db59dc0 100644 --- a/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift +++ b/Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift @@ -11,6 +11,16 @@ func systemKeychainLoad(service: String, account: String?) throws -> KeychainCre return KeychainCredentialItem(data: data, account: values[kSecAttrAccount as String] as? String) } +func systemKeychainServices(prefix: String) throws -> [String] { + var items: CFTypeRef? + let status = SecItemCopyMatching(systemKeychainListQuery() as CFDictionary, &items) + guard status != errSecItemNotFound else { return [] } + guard status == errSecSuccess, let values = items as? [[String: Any]] else { + throw CredentialStoreError.keychain(status) + } + return keychainServices(values, prefix: prefix) +} + func systemKeychainSave(data: Data, service: String, account: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, diff --git a/Sources/TokenMenuBarCore/Models/ProviderSnapshot.swift b/Sources/TokenMenuBarCore/Models/ProviderSnapshot.swift index 6d7ab82..6f2d0b1 100644 --- a/Sources/TokenMenuBarCore/Models/ProviderSnapshot.swift +++ b/Sources/TokenMenuBarCore/Models/ProviderSnapshot.swift @@ -116,17 +116,42 @@ public struct SpendControl: Codable, Sendable, Hashable { } } +public struct ResetCreditExpiry: Codable, Sendable, Hashable, Identifiable { + public let id: String + public let expiresAt: Date + + public init(id: String, expiresAt: Date) { + self.id = id + self.expiresAt = expiresAt + } +} + public struct ResetCredits: Codable, Sendable, Hashable { public let available: Int public let applicable: Int public let totalEarned: Int? public let immediatePurchaseEligible: Bool + public let expiries: [ResetCreditExpiry] - public init(available: Int, applicable: Int, totalEarned: Int? = nil, immediatePurchaseEligible: Bool = false) { + public init( + available: Int, applicable: Int, totalEarned: Int? = nil, immediatePurchaseEligible: Bool = false, + expiries: [ResetCreditExpiry] = [] + ) { self.available = available self.applicable = applicable self.totalEarned = totalEarned self.immediatePurchaseEligible = immediatePurchaseEligible + self.expiries = expiries + } + + // Snapshots cached before expiries existed still have to load. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + available = try container.decode(Int.self, forKey: .available) + applicable = try container.decode(Int.self, forKey: .applicable) + totalEarned = try container.decodeIfPresent(Int.self, forKey: .totalEarned) + immediatePurchaseEligible = try container.decode(Bool.self, forKey: .immediatePurchaseEligible) + expiries = try container.decodeIfPresent([ResetCreditExpiry].self, forKey: .expiries) ?? [] } } diff --git a/Sources/TokenMenuBarCore/Models/QuotaWindow.swift b/Sources/TokenMenuBarCore/Models/QuotaWindow.swift index e67fc4c..6db52b1 100644 --- a/Sources/TokenMenuBarCore/Models/QuotaWindow.swift +++ b/Sources/TokenMenuBarCore/Models/QuotaWindow.swift @@ -49,6 +49,8 @@ public struct QuotaWindow: Codable, Sendable, Hashable, Identifiable { public let isActive: Bool public let scope: String? public let resetPrecision: ResetPrecision + /// Text the card shows under the label instead of the window id, such as which pools an aggregate combines. + public let detail: String? public init( id: String, @@ -60,7 +62,8 @@ public struct QuotaWindow: Codable, Sendable, Hashable, Identifiable { severity: Severity? = nil, isActive: Bool = true, scope: String? = nil, - resetPrecision: ResetPrecision = .instant + resetPrecision: ResetPrecision = .instant, + detail: String? = nil ) { self.id = id self.label = label @@ -72,6 +75,7 @@ public struct QuotaWindow: Codable, Sendable, Hashable, Identifiable { self.isActive = isActive self.scope = scope self.resetPrecision = resetPrecision + self.detail = detail } // Snapshots cached before the precision existed decode as instants. @@ -87,7 +91,14 @@ public struct QuotaWindow: Codable, Sendable, Hashable, Identifiable { severity: try container.decode(Severity.self, forKey: .severity), isActive: try container.decode(Bool.self, forKey: .isActive), scope: try container.decodeIfPresent(String.self, forKey: .scope), - resetPrecision: try container.decodeIfPresent(ResetPrecision.self, forKey: .resetPrecision) ?? .instant) + resetPrecision: try container.decodeIfPresent(ResetPrecision.self, forKey: .resetPrecision) ?? .instant, + detail: try container.decodeIfPresent(String.self, forKey: .detail)) + } + + public func withDetail(_ detail: String?) -> QuotaWindow { + QuotaWindow( + id: id, label: label, group: group, usedPercent: usedPercent, resetsAt: resetsAt, duration: duration, + severity: severity, isActive: isActive, scope: scope, resetPrecision: resetPrecision, detail: detail) } public var remainingPercent: Double { diff --git a/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift b/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift index 81d581c..756b26d 100644 --- a/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift +++ b/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift @@ -7,15 +7,31 @@ public struct NotificationSettings: Sendable, Equatable, Codable { public var thresholds: [Int] public var notifyOnReset: Bool public var notifyOnAuthProblems: Bool + public var notifyOnExpiringCredits: Bool + public var playSound: Bool public init( enabled: Bool = true, thresholds: [Int] = defaultThresholds, notifyOnReset: Bool = true, - notifyOnAuthProblems: Bool = true + notifyOnAuthProblems: Bool = true, notifyOnExpiringCredits: Bool = true, playSound: Bool = true ) { self.enabled = enabled self.thresholds = thresholds.filter { (1...100).contains($0) }.sorted() self.notifyOnReset = notifyOnReset self.notifyOnAuthProblems = notifyOnAuthProblems + self.notifyOnExpiringCredits = notifyOnExpiringCredits + self.playSound = playSound + } + + // Settings saved before these switches existed would otherwise fail to decode and fall back to the defaults. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + enabled: try container.decode(Bool.self, forKey: .enabled), + thresholds: try container.decode([Int].self, forKey: .thresholds), + notifyOnReset: try container.decode(Bool.self, forKey: .notifyOnReset), + notifyOnAuthProblems: try container.decode(Bool.self, forKey: .notifyOnAuthProblems), + notifyOnExpiringCredits: try container.decodeIfPresent(Bool.self, forKey: .notifyOnExpiringCredits) ?? true, + playSound: try container.decodeIfPresent(Bool.self, forKey: .playSound) ?? true) } } @@ -25,6 +41,7 @@ public struct NotificationEvent: Sendable, Hashable, Identifiable { case reset case authentication case credits + case expiringCredit } public let id: String @@ -47,6 +64,8 @@ public struct NotificationEvent: Sendable, Hashable, Identifiable { } public enum NotificationPlanner { + public static let creditExpiryWarning: TimeInterval = 24 * 3600 + public static func events( previous: ProviderSnapshot?, current: ProviderSnapshot?, @@ -55,7 +74,7 @@ public enum NotificationPlanner { provider: ProviderID, settings: NotificationSettings, credentialMissing: Bool = false, - now _: Date + now: Date ) -> [NotificationEvent] { guard settings.enabled else { return [] } var events: [NotificationEvent] = [] @@ -78,33 +97,53 @@ public enum NotificationPlanner { title: "\(provider.displayName) credits depleted", body: "Usage credits ran out; plan limits now apply.")) } } + if settings.notifyOnExpiringCredits { + events += expiringCreditEvents(previous: previous, current: current, now: now) + } 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 crossings = current.windows.compactMap { window -> (window: QuotaWindow, threshold: Int)? in + guard let before = previous.window(window.id), !window.hasReset(since: before) else { return nil } 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, precision: window.resetPrecision, 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)" - ) - ] + return crossed.max().map { (window, $0) } + } + guard crossings.count > 1, let lowest = crossings.map(\.threshold).min() else { + return crossings.map { thresholdEvent(for: $0.window, threshold: $0.threshold, in: current) } } + let windowIDs = crossings.map(\.window.id).joined(separator: "+") + let epochs = crossings.map { String(Int($0.window.resetsAt?.timeIntervalSince1970 ?? 0)) }.joined(separator: "+") + return [ + NotificationEvent( + id: "\(current.provider.rawValue):thresholds:\(windowIDs):\(lowest):\(epochs)", + kind: .threshold, + provider: current.provider, + title: "\(current.provider.displayName): \(crossings.count) limits crossed \(lowest)%", + body: crossings.map { "\($0.window.label) at \(Format.percent($0.window.usedPercent))" } + .joined(separator: ", ") + "." + ) + ] + } + + private static func thresholdEvent( + for window: QuotaWindow, threshold: Int, in current: ProviderSnapshot + ) -> NotificationEvent { + let resets = + window.resetsAt.map { + " Resets \(Format.resetClock($0, precision: window.resetPrecision, now: current.fetchedAt))." + } ?? "" + return NotificationEvent( + id: "\(current.provider.rawValue):\(window.id):\(threshold):\(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: threshold >= 100 + ? "Limit reached.\(resets)" : "Crossed \(threshold)% of the \(window.label.lowercased()) limit.\(resets)" + ) } static func resetEvents( @@ -125,4 +164,30 @@ public enum NotificationPlanner { ) } } + + static func expiringCreditEvents( + previous: ProviderSnapshot?, current: ProviderSnapshot, now: Date + ) -> [NotificationEvent] { + let warned = Set(previous.map { expiringCredits($0, at: $0.fetchedAt) } ?? []) + return expiringCredits(current, at: now).filter { !warned.contains($0) }.map { credit in + let expiresIn = remaining(until: credit.expiresAt, now: now) + return NotificationEvent( + id: "\(current.provider.rawValue):reset-credit:\(credit.id):\(Int(credit.expiresAt.timeIntervalSince1970))", + kind: .expiringCredit, + provider: current.provider, + title: "\(current.provider.displayName) reset credit expires in \(expiresIn)", + body: "Use it before it lapses." + ) + } + } + + private static func expiringCredits(_ snapshot: ProviderSnapshot, at now: Date) -> [ResetCreditExpiry] { + guard let credits = snapshot.resetCredits else { return [] } + return credits.expiries.filter { $0.expiresAt > now && $0.expiresAt.timeIntervalSince(now) <= creditExpiryWarning } + } + + private static func remaining(until date: Date, now: Date) -> String { + let seconds = date.timeIntervalSince(now) + return seconds >= 3600 ? "\(Int(seconds / 3600)) h" : "\(max(Int(seconds / 60), 1)) min" + } } diff --git a/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift b/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift index 5e45716..b32171f 100644 --- a/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift +++ b/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift @@ -388,7 +388,8 @@ public enum UsagePresenter { } static func detail(_ window: QuotaWindow) -> String { - switch window.id { + if let detail = window.detail { return detail } + return switch window.id { case "session", "weekly", "monthly": window.duration.map { "window · \(Format.duration($0))" } ?? "window" default: window.id diff --git a/Sources/TokenMenuBarCore/ProviderSetup.swift b/Sources/TokenMenuBarCore/ProviderSetup.swift index bc904a5..efbb6e8 100644 --- a/Sources/TokenMenuBarCore/ProviderSetup.swift +++ b/Sources/TokenMenuBarCore/ProviderSetup.swift @@ -41,7 +41,7 @@ private func credentialReadDetail(_ error: any Error) -> String { "Keychain access denied; allow it in Keychain Access or sign in again." case CredentialStoreError.keychain: "The Keychain item could not be read." - case CredentialStoreError.malformed(let detail): + case CredentialStoreError.malformed(let detail), CredentialStoreError.incomplete(let detail): detail case is CocoaError: "The credential file could not be read." diff --git a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift index b4f8759..a6319e7 100644 --- a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift +++ b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeProvider.swift @@ -112,6 +112,7 @@ public actor ClaudeProvider: UsageProvider { } let identity = ClaudeMapper.identity(profile: profile, credentials: active, local: local) let transcript = await transcripts?.refresh(now: now, retentionDays: options.analyticsDays) + warnings += transcript?.pricingWarnings ?? [] switch usageResult { case .success(let response): if response.droppedLimitCount > 0 { diff --git a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift index a39c6a9..3484097 100644 --- a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift +++ b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift @@ -15,17 +15,36 @@ public struct ModelPrice: Sendable, Equatable { } public enum ClaudePricing { + /// Anthropic list prices per million tokens by model id. A transcript's model id is matched to the longest key it + /// starts with, then to the newest entry of the same family, so a dated or point release prices like the model it + /// derives from. 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), + "claude-fable-5-1": ModelPrice(input: 10, output: 50, cacheWrite: 12.5, cacheRead: 0.25), + "claude-mythos-5-1": ModelPrice(input: 10, output: 50, cacheWrite: 12.5, cacheRead: 0.25), + "claude-fable-5": ModelPrice(input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1), + "claude-mythos-5": ModelPrice(input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1), + "claude-opus-5": ModelPrice(input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5), + "claude-opus-4-8": ModelPrice(input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5), + "claude-opus-4-7": ModelPrice(input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5), + "claude-opus-4-6": ModelPrice(input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5), + "claude-sonnet-5": ModelPrice(input: 2, output: 10, cacheWrite: 2.5, cacheRead: 0.2), + "claude-sonnet-4-6": ModelPrice(input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3), + "claude-haiku-4-5": ModelPrice(input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1), ] public static func price(for model: String) -> ModelPrice? { let lowered = model.lowercased() - return perMillion.first { lowered.contains($0.key) }?.value + if let key = perMillion.keys.filter({ lowered.hasPrefix($0) }).max(by: { $0.count < $1.count }) { + return perMillion[key] + } + guard let family = family(of: lowered) else { return nil } + return perMillion.keys.filter { Self.family(of: $0) == family }.max().map { perMillion[$0]! } + } + + private static func family(of model: String) -> Substring? { + let parts = model.split(separator: "-") + guard parts.count > 1, parts[0] == "claude" else { return nil } + return parts[1] } public static func cost(_ usage: TokenUsage, model: String) -> Double { @@ -69,18 +88,28 @@ public struct TranscriptMessage: Sendable, Equatable, Hashable { public let model: String public let usage: TokenUsage public let toolCalls: Int + /// The `costUSD` Claude Code wrote on the line, which beats the table when present. + public let reportedCost: Double? - public init(id: String, timestamp: Date, session: String, model: String, usage: TokenUsage, toolCalls: Int) { + public init( + id: String, timestamp: Date, session: String, model: String, usage: TokenUsage, toolCalls: Int, + reportedCost: Double? = nil + ) { self.id = id self.timestamp = timestamp self.session = session self.model = model self.usage = usage self.toolCalls = toolCalls + self.reportedCost = reportedCost } public var cost: Double { - ClaudePricing.cost(usage, model: model) + reportedCost ?? ClaudePricing.cost(usage, model: model) + } + + public var isCostEstimated: Bool { + reportedCost == nil && ClaudePricing.price(for: model) == nil } } @@ -107,10 +136,17 @@ public struct LocalUsage: Sendable, Equatable, Hashable, Codable { public struct ClaudeTranscriptSnapshot: Sendable { fileprivate let days: [String: DayAggregate] fileprivate let recent: [String: RecentAggregate] + /// Models the pricing table cannot price that no earlier refresh in this run reported. + public let unpricedModels: [String] - fileprivate init(state: ClaudeTranscriptState) { + fileprivate init(state: ClaudeTranscriptState, unpricedModels: [String]) { days = state.days recent = state.recent + self.unpricedModels = unpricedModels + } + + public var pricingWarnings: [String] { + unpricedModels.map { "Cost for \($0) is estimated; the pricing table has no entry" } } public var messageCount: Int { @@ -129,7 +165,9 @@ public struct ClaudeTranscriptSnapshot: Sendable { 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))) + AnalyticsPoint( + day: day, metric: .costUSD, series: model, + value: aggregate.costs[model] ?? ClaudePricing.cost(usage, model: model))) } points.append(AnalyticsPoint(day: day, metric: .messages, series: "messages", value: Double(aggregate.messages))) points.append( @@ -200,6 +238,7 @@ fileprivate struct ClaudeTranscriptState: Codable, Sendable { var days: [String: DayAggregate] = [:] var recent: [String: RecentAggregate] = [:] var activeRetentionDays = UsageHistoryStore.defaultRetentionDays + var unpricedModels: Set = [] private enum CodingKeys: String, CodingKey { case offsets @@ -207,6 +246,7 @@ fileprivate struct ClaudeTranscriptState: Codable, Sendable { case days case recent case activeRetentionDays + case unpricedModels } init() {} @@ -220,6 +260,7 @@ fileprivate struct ClaudeTranscriptState: Codable, Sendable { activeRetentionDays = try container.decodeIfPresent(Int.self, forKey: .activeRetentionDays) ?? UsageHistoryStore.defaultRetentionDays + unpricedModels = try container.decodeIfPresent(Set.self, forKey: .unpricedModels) ?? [] } @discardableResult @@ -228,10 +269,12 @@ fileprivate struct ClaudeTranscriptState: Codable, Sendable { 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.costs[message.model, default: 0] += message.cost aggregate.messages += 1 aggregate.sessions.insert(message.session) aggregate.toolCalls += message.toolCalls days[day] = aggregate + if message.isCostEstimated { unpricedModels.insert(message.model) } let minute = floor(message.timestamp.timeIntervalSince1970 / 60) * 60 let key = String(Int64(minute)) recent[key, default: RecentAggregate(timestamp: Date(timeIntervalSince1970: minute))].append( @@ -281,9 +324,30 @@ fileprivate struct TranscriptOffset: Codable, Sendable { fileprivate struct DayAggregate: Codable, Sendable { var models: [String: TokenUsage] = [:] + var costs: [String: Double] = [:] var messages = 0 var sessions: Set = [] var toolCalls = 0 + + private enum CodingKeys: String, CodingKey { + case models + case costs + case messages + case sessions + case toolCalls + } + + init() {} + + // Checkpoints written before costs were stored price their days from the table when read back. + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + models = try container.decode([String: TokenUsage].self, forKey: .models) + costs = try container.decodeIfPresent([String: Double].self, forKey: .costs) ?? [:] + messages = try container.decode(Int.self, forKey: .messages) + sessions = try container.decode(Set.self, forKey: .sessions) + toolCalls = try container.decode(Int.self, forKey: .toolCalls) + } } fileprivate struct RecentAggregate: Codable, Sendable, Equatable { @@ -382,7 +446,7 @@ public struct ClaudeTranscriptWorkload: Sendable, Equatable { } public actor ClaudeTranscriptReader { - public static let defaultFileScanInterval: TimeInterval = 5 * 60 + public static let defaultFileScanInterval: TimeInterval = 15 * 60 public static let defaultCheckpointInterval: TimeInterval = 5 * 60 public static let defaultMaximumCheckpointBytes = 1024 * 1024 public static let defaultWorkByteBudget = 8 * 1024 * 1024 @@ -405,6 +469,7 @@ public actor ClaudeTranscriptReader { private let backgroundWorkDelay: TimeInterval private let maximumLineBytes: Int private let maximumRetainedPartialBytes: Int + private let powerState: @Sendable () -> PowerState private let seek: @Sendable (FileHandle, UInt64) throws -> Void private let readChunk: @Sendable (FileHandle, Int) throws -> Data? private var state = ClaudeTranscriptState() @@ -428,6 +493,7 @@ public actor ClaudeTranscriptReader { private var stateLoaded = false private var hasScanned = false private var scanGeneration: UInt64 = 0 + private var reportedUnpricedModels: Set = [] public private(set) var workload = ClaudeTranscriptWorkload() /// - Parameter stateURL: where to keep how far each session file has been read. Without it the first refresh @@ -445,6 +511,7 @@ public actor ClaudeTranscriptReader { backgroundWorkDelay: TimeInterval = defaultBackgroundWorkDelay, maximumLineBytes: Int = defaultMaximumLineBytes, maximumRetainedPartialBytes: Int = defaultMaximumRetainedPartialBytes, + powerState: @escaping @Sendable () -> PowerState = PowerState.current, seek: @escaping @Sendable (FileHandle, UInt64) throws -> Void = { handle, offset in try handle.seek(toOffset: offset) }, @@ -463,6 +530,7 @@ public actor ClaudeTranscriptReader { self.backgroundWorkDelay = max(backgroundWorkDelay, 0.001) self.maximumLineBytes = max(maximumLineBytes, 1) self.maximumRetainedPartialBytes = max(maximumRetainedPartialBytes, self.maximumLineBytes) + self.powerState = powerState self.seek = seek self.readChunk = readChunk } @@ -510,11 +578,15 @@ public actor ClaudeTranscriptReader { nextPruneAt = nextPruneDate(after: now) } prepareIndexedTails() - if scan == nil, !hasScanned || now >= nextFileScanAt { beginScan(cutoff: cutoff, now: now) } + if scan == nil, !hasScanned || now >= nextFileScanAt && !powerState().throttlesScanning { + beginScan(cutoff: cutoff, now: now) + } runWorkSlice() await checkpointIfNeeded(now: now) scheduleBackgroundWork() - return ClaudeTranscriptSnapshot(state: state) + let unpriced = state.unpricedModels.subtracting(reportedUnpricedModels).sorted() + reportedUnpricedModels.formUnion(unpriced) + return ClaudeTranscriptSnapshot(state: state, unpricedModels: unpriced) } private func loadState() async { @@ -633,9 +705,14 @@ public actor ClaudeTranscriptReader { } workload.treeEntriesExamined += 1 entriesRemaining -= 1 - guard url.pathExtension == "jsonl", let file = transcriptFile(url), file.modified >= activeScan.cutoff else { + guard url.pathExtension == "jsonl" else { continue } + // The enumerator already fetched the modification date, so a file outside the retention window costs no stat. + if let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate, + modified < activeScan.cutoff + { continue } + guard 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 } @@ -923,6 +1000,7 @@ public actor ClaudeTranscriptReader { let requestId: String? let sessionId: String? let timestamp: String? + let costUSD: Double? let message: Message? } @@ -944,35 +1022,38 @@ public actor ClaudeTranscriptReader { cacheWrite: usage.cacheCreationInputTokens ?? 0, cacheRead: usage.cacheReadInputTokens ?? 0 ), - toolCalls: message.content?.count { $0.type == "tool_use" } ?? 0 + toolCalls: message.content?.count { $0.type == "tool_use" } ?? 0, + reportedCost: json.costUSD ) } 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)] = [:] + var byDayModel: [String: [String: (usage: TokenUsage, cost: Double)]] = [:] + var daily: [String: (messages: Int, sessions: Set, toolCalls: Int)] = [:] 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) + var model = byDayModel[day, default: [:]][message.model] ?? (TokenUsage(), 0) + model.usage += message.usage + model.cost += message.cost + byDayModel[day, default: [:]][message.model] = model + var entry = daily[day] ?? (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 { + for (model, totals) in models { + let usage = totals.usage 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: .costUSD, series: model, value: totals.cost)) } } for (day, entry) in daily { diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift index 84a4db2..6747093 100644 --- a/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift +++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexAPI.swift @@ -154,17 +154,29 @@ enum CodexAPI { } } + struct ResetCreditEntry: Decodable, Sendable, Equatable { + let id: String? + let expiresAt: JSONValue? + + enum CodingKeys: String, CodingKey { + case id + case expiresAt = "expires_at" + } + } + struct ResetCreditsSummary: Decodable, Sendable, Equatable { let availableCount: Int? let applicableAvailableCount: Int? let totalEarnedCount: Int? let immediateResetPurchaseEligible: Bool? + let credits: [ResetCreditEntry]? enum CodingKeys: String, CodingKey { case availableCount = "available_count" case applicableAvailableCount = "applicable_available_count" case totalEarnedCount = "total_earned_count" case immediateResetPurchaseEligible = "immediate_reset_purchase_eligible" + case credits } } @@ -310,10 +322,25 @@ enum CodexMapper { available: summary.availableCount ?? 0, applicable: summary.applicableAvailableCount ?? summary.availableCount ?? 0, totalEarned: summary.totalEarnedCount, - immediatePurchaseEligible: summary.immediateResetPurchaseEligible ?? false + immediatePurchaseEligible: summary.immediateResetPurchaseEligible ?? false, + expiries: (summary.credits ?? []).compactMap(resetCreditExpiry) ) } + static func resetCreditExpiry(_ entry: CodexAPI.ResetCreditEntry) -> ResetCreditExpiry? { + guard let id = entry.id, let expiresAt = entry.expiresAt.flatMap(timestamp) else { return nil } + return ResetCreditExpiry(id: id, expiresAt: expiresAt) + } + + // The reset-credit endpoint is undocumented; the usage endpoint sends epoch seconds and the analytics ones ISO 8601. + static func timestamp(_ value: JSONValue) -> Date? { + switch value { + case .number(let seconds): Date(timeIntervalSince1970: seconds) + case .string(let text): ISODate.parse(text) + default: nil + } + } + static func resetCredits(_ summary: CodexAPI.ResetCreditsSummary?) -> ResetCredits? { summary.map(resetCredits) } diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift index 60dc937..2baaaec 100644 --- a/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift +++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexProvider.swift @@ -286,7 +286,7 @@ public actor CodexProvider: UsageProvider { let resetsAt = previous.resetsAt, resetsAt > now else { return window } pendingResets.insert(window.id) - return previous + return previous.withDetail("Confirming reset") } acceptedWindows = confirmed.reduce(into: [:]) { $0[$1.id] = $1 } return confirmed diff --git a/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift b/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift index 986e688..9cfb081 100644 --- a/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift +++ b/Sources/TokenMenuBarCore/Providers/Codex/CodexRolloutReader.swift @@ -63,7 +63,7 @@ private struct ReverseRolloutRead { public actor CodexRolloutReader { static let maxFiles = 8 - public static let defaultCacheInterval: TimeInterval = 5 * 60 + public static let defaultCacheInterval: TimeInterval = 15 * 60 public static let defaultWorkByteBudget = 2 * 1024 * 1024 public static let defaultWorkEntryBudget = 256 public static let defaultWorkTimeBudget: TimeInterval = 0.05 @@ -77,6 +77,7 @@ public actor CodexRolloutReader { private let workEntryBudget: Int private let workTimeBudget: TimeInterval private let backgroundWorkDelay: TimeInterval + private let powerState: @Sendable () -> PowerState private var cache: RolloutCache? private var treeScan: RolloutTreeScan? private var search: RolloutSearch? @@ -89,7 +90,8 @@ public actor CodexRolloutReader { workByteBudget: Int = defaultWorkByteBudget, workEntryBudget: Int = defaultWorkEntryBudget, workTimeBudget: TimeInterval = defaultWorkTimeBudget, - backgroundWorkDelay: TimeInterval = defaultBackgroundWorkDelay + backgroundWorkDelay: TimeInterval = defaultBackgroundWorkDelay, + powerState: @escaping @Sendable () -> PowerState = PowerState.current ) { self.sessionsRoot = sessionsRoot self.cacheInterval = cacheInterval @@ -97,6 +99,7 @@ public actor CodexRolloutReader { self.workEntryBudget = max(workEntryBudget, 1) self.workTimeBudget = max(workTimeBudget, 0.001) self.backgroundWorkDelay = max(backgroundWorkDelay, 0.001) + self.powerState = powerState } deinit { backgroundTask?.cancel() } @@ -116,7 +119,9 @@ public actor CodexRolloutReader { } func latest(now: Date = Date()) -> Reading? { - if let cache, cache.expiresAt > now, cache.hasSearchResult { + // On battery saver or under thermal pressure an expired cache keeps serving; only the known files are re-checked. + let throttled = powerState().throttlesScanning + if let cache, cache.expiresAt > now || throttled, cache.hasSearchResult { if let refreshed = refreshedFiles(cache.files) { if refreshed == cache.files { return cache.reading } self.cache = RolloutCache(files: refreshed, expiresAt: cache.expiresAt, state: .candidates) @@ -128,7 +133,7 @@ public actor CodexRolloutReader { } } if treeScan == nil, search == nil { - if let cache, cache.expiresAt > now { + if let cache, cache.expiresAt > now || throttled { search = RolloutSearch(files: cache.files, referenceNow: now, expiresAt: cache.expiresAt) } else { beginTreeScan(now: now) diff --git a/Sources/TokenMenuBarCore/Providers/Copilot/CopilotAPI.swift b/Sources/TokenMenuBarCore/Providers/Copilot/CopilotAPI.swift index 05a7311..50d52f2 100644 --- a/Sources/TokenMenuBarCore/Providers/Copilot/CopilotAPI.swift +++ b/Sources/TokenMenuBarCore/Providers/Copilot/CopilotAPI.swift @@ -86,10 +86,11 @@ enum CopilotMapper { } static func identity(_ user: JSONValue, auth: CopilotAuth) -> ProviderIdentity { - let plan = user["copilot_plan"]?.stringValue ?? user["access_type_sku"]?.stringValue ?? "Copilot" + let plan = Format.humanize(user["copilot_plan"]?.stringValue ?? user["access_type_sku"]?.stringValue ?? "Copilot") + let organizations = user["organization_login_list"]?.arrayValue?.compactMap(\.stringValue) ?? [] return ProviderIdentity( - planName: Format.humanize(plan), tier: user["access_type_sku"]?.stringValue, email: auth.user, - subscriptionActiveUntil: nil) + planName: organizations.isEmpty ? plan : "\(plan) via \(organizations.joined(separator: ", "))", + tier: user["access_type_sku"]?.stringValue, email: auth.user, subscriptionActiveUntil: nil) } static func notices(_ user: JSONValue) -> [Notice] { diff --git a/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift b/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift index 5c6a514..bbbed2e 100644 --- a/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift +++ b/Sources/TokenMenuBarCore/Providers/Cursor/CursorAPI.swift @@ -85,13 +85,19 @@ enum CursorMapper { var windows: [QuotaWindow] = [] func add(_ id: String, _ label: String, _ bucket: CursorAPI.Bucket?) { guard let bucket, bucket.enabled != false else { return } + let components = bucket.componentPercents if let percent = bucket.aggregatePercentUsed { + // The dashboard shows the aggregate; the Auto and API shares it mixes stay visible as the row's detail. + let detail = + components.isEmpty + ? nil : components.map { "\($0.label) \(Format.percent($0.percent))" }.joined(separator: " · ") windows.append( QuotaWindow( - id: id, label: label, group: .monthly, usedPercent: percent, resetsAt: resetsAt, duration: duration)) + id: id, label: label, group: .monthly, usedPercent: percent, resetsAt: resetsAt, duration: duration, + detail: detail)) return } - windows += bucket.componentPercents.map { component in + windows += components.map { component in QuotaWindow( id: "\(id):\(component.id)", label: "\(label) (\(component.label))", group: .monthly, usedPercent: component.percent, resetsAt: resetsAt, duration: duration) diff --git a/Sources/TokenMenuBarCore/Providers/PowerState.swift b/Sources/TokenMenuBarCore/Providers/PowerState.swift new file mode 100644 index 0000000..bb7069f --- /dev/null +++ b/Sources/TokenMenuBarCore/Providers/PowerState.swift @@ -0,0 +1,23 @@ +import Foundation + +/// The conditions under which the local log readers skip their periodic tree walks and keep serving what they have. +public struct PowerState: Sendable, Equatable { + public let lowPowerMode: Bool + public let thermalPressure: Bool + + public init(lowPowerMode: Bool, thermalPressure: Bool) { + self.lowPowerMode = lowPowerMode + self.thermalPressure = thermalPressure + } + + public var throttlesScanning: Bool { + lowPowerMode || thermalPressure + } + + public static func current() -> PowerState { + let processInfo = ProcessInfo.processInfo + return PowerState( + lowPowerMode: processInfo.isLowPowerModeEnabled, + thermalPressure: [.serious, .critical].contains(processInfo.thermalState)) + } +} diff --git a/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift b/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift index b12f594..99bbfca 100644 --- a/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift +++ b/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift @@ -66,7 +66,7 @@ public enum ProviderRegistryFactory { ) -> ProviderRegistry { let claudeHome = configuration.url(for: ProviderID.claude.sandboxResources[0]) let claudeCredentials = ChainedClaudeCredentialStore([ - KeychainClaudeCredentialStore( + DiscoveredClaudeKeychainStore( service: ClaudeOAuthCredentials.keychainService( configDir: configuration.environment["CLAUDE_CONFIG_DIR"]), account: configuration.userName, @@ -85,17 +85,8 @@ public enum ProviderRegistryFactory { ) 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 codexAuth = CodexAuthStores.discovering( + configured: codexHome, home: configuration.home, keychain: configuration.keychain) let codex = CodexProvider( auth: codexAuth, rollouts: CodexRolloutReader(sessionsRoot: codexHome.appendingPathComponent("sessions")), diff --git a/Sources/TokenMenuBarCore/StatusBar/UsageColor.swift b/Sources/TokenMenuBarCore/StatusBar/UsageColor.swift index 1aacb5e..9d7a8ac 100644 --- a/Sources/TokenMenuBarCore/StatusBar/UsageColor.swift +++ b/Sources/TokenMenuBarCore/StatusBar/UsageColor.swift @@ -21,24 +21,56 @@ public struct HSBColor: Hashable, Sendable { } } +public enum UsageContrast: Sendable, Hashable { + case standard + case increased + + public init(increased: Bool) { + self = increased ? .increased : .standard + } +} + +public struct UsageLadder: Hashable, Sendable { + public let green: HSBColor + public let orange: HSBColor + public let red: HSBColor + + public static let standard = UsageLadder( + green: HSBColor(hue: 0.38, saturation: 0.72, brightness: 0.52), + orange: HSBColor(hue: 0.08, saturation: 0.9, brightness: 0.86), + red: HSBColor(hue: 0.0, saturation: 0.85, brightness: 0.8)) + public static let increased = UsageLadder( + green: HSBColor(hue: 0.38, saturation: 0.95, brightness: 0.4), + orange: HSBColor(hue: 0.08, saturation: 1, brightness: 0.74), + red: HSBColor(hue: 0.0, saturation: 1, brightness: 0.6)) + + public static func ladder(for contrast: UsageContrast) -> UsageLadder { + switch contrast { + case .standard: standard + case .increased: increased + } + } +} + 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 green = UsageLadder.standard.green + public static let orange = UsageLadder.standard.orange + public static let red = UsageLadder.standard.red public static let orangeAt = 0.6 - public static func color(percent: Double) -> HSBColor { + public static func color(percent: Double, contrast: UsageContrast = .standard) -> HSBColor { + let ladder = UsageLadder.ladder(for: contrast) 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)) + ? ladder.green.mixed(with: ladder.orange, fraction: pow(fraction / orangeAt, 2)) + : ladder.orange.mixed(with: ladder.red, fraction: (fraction - orangeAt) / (1 - orangeAt)) } - public static func color(pace: PaceStatus, percent: Double) -> HSBColor { + public static func color(pace: PaceStatus, percent: Double, contrast: UsageContrast = .standard) -> HSBColor { switch pace { - case .ahead: orange - case .exhausted: red - default: color(percent: percent) + case .ahead: UsageLadder.ladder(for: contrast).orange + case .exhausted: UsageLadder.ladder(for: contrast).red + default: color(percent: percent, contrast: contrast) } } } diff --git a/Sources/TokenMenuBarUI/Adapters/Notifier.swift b/Sources/TokenMenuBarUI/Adapters/Notifier.swift index 78bd58c..128cbdd 100644 --- a/Sources/TokenMenuBarUI/Adapters/Notifier.swift +++ b/Sources/TokenMenuBarUI/Adapters/Notifier.swift @@ -18,6 +18,7 @@ public final class Notifier { private let log: LogBuffer private let responseDelegate = NotificationResponseDelegate() private(set) var authorized = false + public var playSound = true // 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. @@ -65,7 +66,7 @@ public final class Notifier { content.title = event.title content.body = event.body content.threadIdentifier = event.provider.rawValue - content.sound = .default + content.sound = playSound ? .default : nil do { try await center.add(UNNotificationRequest(identifier: event.id, content: content, trigger: nil)) } catch { @@ -74,7 +75,13 @@ public final class Notifier { } 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)) + // A grouped banner names several windows of one provider and has no window of its own; once any of them resets + // it is out of date. + let providers = Set(reset.map(\.provider)) + let stale = Set( + delivered.filter { + $0.kind == .threshold && ($0.window.map(reset.contains) ?? providers.contains($0.provider)) + }.map(\.id)) center.removeDeliveredNotifications(withIdentifiers: Array(stale)) delivered.removeAll { stale.contains($0.id) } } diff --git a/Sources/TokenMenuBarUI/AppController.swift b/Sources/TokenMenuBarUI/AppController.swift index 7f963eb..2aa7f65 100644 --- a/Sources/TokenMenuBarUI/AppController.swift +++ b/Sources/TokenMenuBarUI/AppController.swift @@ -26,6 +26,7 @@ public struct AppDependencies { public var statusBar: NSStatusBar public var isSandboxed: Bool public var isDemo: Bool + public var increaseContrast: @MainActor () -> Bool public var openURL: @MainActor (URL) -> Void public var copyToPasteboard: @MainActor (String) -> Void public var revealInFinder: @MainActor (URL) -> Void @@ -61,6 +62,9 @@ public struct AppDependencies { statusBar: NSStatusBar = .system, isSandboxed: Bool = false, isDemo: Bool = false, + increaseContrast: @escaping @MainActor () -> Bool = { + NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast + }, openURL: @escaping @MainActor (URL) -> Void, copyToPasteboard: @escaping @MainActor (String) -> Void, revealInFinder: @escaping @MainActor (URL) -> Void, @@ -95,6 +99,7 @@ public struct AppDependencies { self.statusBar = statusBar self.isSandboxed = isSandboxed self.isDemo = isDemo + self.increaseContrast = increaseContrast self.openURL = openURL self.copyToPasteboard = copyToPasteboard self.revealInFinder = revealInFinder @@ -169,7 +174,10 @@ public final class AppController { clock: dependencies.clock, cache: dependencies.snapshotCache, persistence: dependencies.persistence - ) { events in Task { await notifier.deliver(events) } } + ) { [settings = dependencies.settings] events in + notifier.playSound = settings.notifications.playSound + Task { await notifier.deliver(events) } + } environment = UIEnvironment( state: state, settings: dependencies.settings, @@ -182,7 +190,8 @@ public final class AppController { uniqueKeysWithValues: dependencies.registry.providers.map { ($0.id, $0.credentialDescription) }), canCheckForUpdates: dependencies.updater?.canCheck ?? false, isSandboxed: dependencies.isSandboxed, - isDemo: dependencies.isDemo + isDemo: dependencies.isDemo, + increaseContrast: dependencies.increaseContrast() ) environment.actions = actions() dependencies.log.debugEnabled = dependencies.settings.detailedLogging @@ -397,6 +406,12 @@ public final class AppController { center.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in Task { @MainActor [weak self] in self?.handleWake() } }) + workspaceObservers.append( + center.addObserver( + forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.refreshIncreaseContrast() } + }) applicationObservers.append( NotificationCenter.default.addObserver( forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main @@ -429,6 +444,10 @@ public final class AppController { } } + private func refreshIncreaseContrast() { + environment.increaseContrast = dependencies.increaseContrast() + } + private func writeVerificationSnapshot() { dependencies.log.flush() guard let url = dependencies.verificationSnapshotURL, let snapshot = dependencies.captureProcessSnapshot() diff --git a/Sources/TokenMenuBarUI/UIActions.swift b/Sources/TokenMenuBarUI/UIActions.swift index f05cf26..97385c7 100644 --- a/Sources/TokenMenuBarUI/UIActions.swift +++ b/Sources/TokenMenuBarUI/UIActions.swift @@ -82,6 +82,7 @@ public final class UIEnvironment { public var canCheckForUpdates: Bool public var isSandboxed: Bool public var isDemo: Bool + public var increaseContrast: Bool public var providerFocusRequest: ProviderSettingsFocusRequest? public var samples: [WindowKey: [UsageSample]] = [:] public var now: Date @@ -107,7 +108,8 @@ public final class UIEnvironment { credentialDescriptions: [ProviderID: String] = [:], canCheckForUpdates: Bool = false, isSandboxed: Bool = false, - isDemo: Bool = false + isDemo: Bool = false, + increaseContrast: Bool = false ) { self.state = state self.settings = settings @@ -121,6 +123,7 @@ public final class UIEnvironment { self.canCheckForUpdates = canCheckForUpdates self.isSandboxed = isSandboxed self.isDemo = isDemo + self.increaseContrast = increaseContrast providerFocusRequest = nil historyPresenter = HistoryPresenter( history: history, diff --git a/Sources/TokenMenuBarUI/Views/Components.swift b/Sources/TokenMenuBarUI/Views/Components.swift index a84d2a2..a221416 100644 --- a/Sources/TokenMenuBarUI/Views/Components.swift +++ b/Sources/TokenMenuBarUI/Views/Components.swift @@ -14,15 +14,18 @@ public struct UsageBar: View { public let color: Color public let height: CGFloat public let label: String + public let outlined: Bool public init( - percent: Double, expectedPercent: Double? = nil, color: Color, height: CGFloat = 5, label: String + percent: Double, expectedPercent: Double? = nil, color: Color, height: CGFloat = 5, label: String, + outlined: Bool = false ) { self.percent = percent self.expectedPercent = expectedPercent self.color = color self.height = height self.label = label + self.outlined = outlined } public var body: some View { @@ -42,6 +45,9 @@ public struct UsageBar: View { .frame(width: 2, height: height + 4) .offset(x: max(min(proxy.size.width * expected - 1, proxy.size.width - 2), 0)) } + if outlined { + Capsule().strokeBorder(Color.primary, lineWidth: 1) + } } } .frame(height: height) diff --git a/Sources/TokenMenuBarUI/Views/SettingsTab.swift b/Sources/TokenMenuBarUI/Views/SettingsTab.swift index a2131c3..66471bc 100644 --- a/Sources/TokenMenuBarUI/Views/SettingsTab.swift +++ b/Sources/TokenMenuBarUI/Views/SettingsTab.swift @@ -246,7 +246,9 @@ public struct SettingsTab: View { settings.notifications = NotificationSettings( enabled: settings.notifications.enabled, thresholds: Array(thresholds), notifyOnReset: settings.notifications.notifyOnReset, - notifyOnAuthProblems: settings.notifications.notifyOnAuthProblems) + notifyOnAuthProblems: settings.notifications.notifyOnAuthProblems, + notifyOnExpiringCredits: settings.notifications.notifyOnExpiringCredits, + playSound: settings.notifications.playSound) } public func threshold(_ threshold: Int) -> Binding { @@ -1059,6 +1061,22 @@ public struct SettingsTab: View { title: "Sign-in needed", body: "Sends a notice when a provider needs authentication. Disabled while usage notifications are off." )) + Toggle("Expiring reset credits", isOn: setting(\.notifications.notifyOnExpiringCredits)) + .toggleStyle(.checkbox) + .disabled(!settings.notifications.enabled) + .richHelp( + TooltipContent( + title: "Expiring reset credits", + body: + "Sends a notice when a Codex reset credit expires within 24 hours. " + + "Disabled while usage notifications are off.")) + Toggle("Sound", isOn: setting(\.notifications.playSound)) + .toggleStyle(.checkbox) + .disabled(!settings.notifications.enabled) + .richHelp( + TooltipContent( + title: "Sound", + body: "Plays the default alert sound with each notice. Disabled while usage notifications are off.")) } } } diff --git a/Sources/TokenMenuBarUI/Views/WindowRowView.swift b/Sources/TokenMenuBarUI/Views/WindowRowView.swift index 0416380..38eb59d 100644 --- a/Sources/TokenMenuBarUI/Views/WindowRowView.swift +++ b/Sources/TokenMenuBarUI/Views/WindowRowView.swift @@ -6,12 +6,14 @@ public struct WindowRowView: View { /// 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 fixedIncreaseContrast: Bool private let environment: UIEnvironment? - public init(row: WindowRow, now: Date, showsReset: Bool = true) { + public init(row: WindowRow, now: Date, showsReset: Bool = true, increaseContrast: Bool = false) { self.row = row self.showsReset = showsReset fixedNow = now + fixedIncreaseContrast = increaseContrast environment = nil } @@ -19,6 +21,7 @@ public struct WindowRowView: View { self.row = row self.showsReset = showsReset fixedNow = nil + fixedIncreaseContrast = false self.environment = environment } @@ -55,7 +58,7 @@ public struct WindowRowView: View { HStack(spacing: 8) { UsageBar( percent: row.window.usedPercent, expectedPercent: row.pace.expectedPercent, - color: usageBarColor, label: row.window.label) + color: usageBarColor, label: row.window.label, outlined: increaseContrast) Text(row.percentText) .font(.callout.monospacedDigit().weight(.semibold)) .semanticForeground(.primary) @@ -96,7 +99,7 @@ public struct WindowRowView: View { } UsageBar( percent: row.window.usedPercent, expectedPercent: row.pace.expectedPercent, - color: usageBarColor, label: row.window.label) + color: usageBarColor, label: row.window.label, outlined: increaseContrast) Text(row.pace.comparison(now: currentNow)) .font(.caption) .semanticForeground(.primary) @@ -107,8 +110,14 @@ public struct WindowRowView: View { } } - private var usageBarColor: Color { - Color(row.color) + var usageBarColor: Color { + Color( + UsageColor.color( + pace: row.pace.status, percent: row.window.usedPercent, contrast: UsageContrast(increased: increaseContrast))) + } + + var increaseContrast: Bool { + environment.map(\.increaseContrast) ?? fixedIncreaseContrast } var accessibilityValue: String { diff --git a/Sources/TokenMenuBarWidgets/UsageWidget.swift b/Sources/TokenMenuBarWidgets/UsageWidget.swift index 07d165b..818f673 100644 --- a/Sources/TokenMenuBarWidgets/UsageWidget.swift +++ b/Sources/TokenMenuBarWidgets/UsageWidget.swift @@ -53,10 +53,12 @@ public struct UsageTimelineProvider: Sendable { public struct UsageWidgetView: View { public let entry: UsageEntry public let family: WidgetFamily + public let increaseContrast: Bool - public init(entry: UsageEntry, family: WidgetFamily) { + public init(entry: UsageEntry, family: WidgetFamily, increaseContrast: Bool = false) { self.entry = entry self.family = family + self.increaseContrast = increaseContrast } public var rows: [WidgetRow] { @@ -89,7 +91,8 @@ public struct UsageWidgetView: View { .foregroundStyle(.secondary) } ForEach(rows) { row in - WidgetRowView(row: row, now: entry.date, compact: family == .systemSmall) + WidgetRowView( + row: row, now: entry.date, compact: family == .systemSmall, increaseContrast: increaseContrast) } Spacer(minLength: 0) } @@ -101,15 +104,17 @@ public struct WidgetRowView: View { public let row: WidgetRow public let now: Date public let compact: Bool + public let increaseContrast: Bool - public init(row: WidgetRow, now: Date, compact: Bool) { + public init(row: WidgetRow, now: Date, compact: Bool, increaseContrast: Bool = false) { self.row = row self.now = now self.compact = compact + self.increaseContrast = increaseContrast } public var color: Color { - let hsb = UsageColor.color(percent: row.usedPercent) + let hsb = UsageColor.color(percent: row.usedPercent, contrast: UsageContrast(increased: increaseContrast)) return Color(hue: hsb.hue, saturation: hsb.saturation, brightness: hsb.brightness) } @@ -124,6 +129,9 @@ public struct WidgetRowView: View { ZStack(alignment: .leading) { Capsule().fill(Color.primary.opacity(0.12)) Capsule().fill(color).frame(width: max(proxy.size.width * row.usedPercent / 100, 3)) + if increaseContrast { + Capsule().strokeBorder(Color.primary, lineWidth: 1) + } } } .frame(height: 4) @@ -148,12 +156,13 @@ public struct WidgetRowView: View { public struct UsageWidgetEntryView: View { public let entry: UsageEntry @Environment(\.widgetFamily) private var family + @Environment(\.colorSchemeContrast) private var contrast public init(entry: UsageEntry) { self.entry = entry } public var body: some View { - UsageWidgetView(entry: entry, family: family) + UsageWidgetView(entry: entry, family: family, increaseContrast: contrast == .increased) } } diff --git a/Tests/TokenMenuBarCoreTests/ClaudeAccountDiscoveryTests.swift b/Tests/TokenMenuBarCoreTests/ClaudeAccountDiscoveryTests.swift new file mode 100644 index 0000000..572a0fb --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ClaudeAccountDiscoveryTests.swift @@ -0,0 +1,225 @@ +import Foundation +import Security +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +private let baseService = ClaudeOAuthCredentials.keychainService +private let hashedService = ClaudeOAuthCredentials.keychainService(configDir: "/Users/me/.claude-work") +private let hashedLabel = "configuration \(hashedService.suffix(8))" +private let mcpOnlyItem = Data(#"{"mcpOAuth":{"server":{"accessToken":"mcp"}}}"#.utf8) + +private func claudeItem(token: String, expiresAt: Date?) -> Data { + try! JSONEncoder().encode( + ClaudeOAuthCredentials(accessToken: token, refreshToken: nil, expiresAt: expiresAt).document) +} + +private func keychain(_ items: [(service: String, data: Data)]) throws -> MemoryKeychain { + let keychain = MemoryKeychain() + for item in items { try keychain.client.save(item.data, service: item.service, account: "me") } + return keychain +} + +@Test func keychainItemWithOnlyMCPOAuthReportsAMissingUsageToken() throws { + let store = KeychainClaudeCredentialStore( + account: "me", keychain: try keychain([(baseService, mcpOnlyItem)]).client) + + #expect(throws: CredentialStoreError.incomplete(ClaudeOAuthCredentials.usageTokenMissingDetail)) { + try store.load() + } +} + +@Test func incompleteKeychainItemFallsBackToTheCredentialFile() throws { + let file = FileClaudeCredentialStore(url: temporaryDirectory().appendingPathComponent(".credentials.json")) + try file.save(ClaudeOAuthCredentials(accessToken: "file", refreshToken: nil, expiresAt: nil)) + let chain = ChainedClaudeCredentialStore([ + KeychainClaudeCredentialStore(account: "me", keychain: try keychain([(baseService, mcpOnlyItem)]).client), file, + ]) + + let found = try #require(try chain.loadWithSource()) + + #expect(found.credentials.accessToken == "file") + #expect(found.source.id == "claude.file") + #expect(chain.credentialHealth(now: fixedNow) == .valid(source: file.source, expiresAt: nil)) +} + +@Test func incompleteKeychainItemWithoutAFileTellsTheUserToRunClaude() throws { + let chain = ChainedClaudeCredentialStore([ + DiscoveredClaudeKeychainStore(account: "me", keychain: try keychain([(baseService, mcpOnlyItem)]).client), + FileClaudeCredentialStore(url: temporaryDirectory().appendingPathComponent(".credentials.json")), + ]) + + #expect( + chain.credentialHealth(now: fixedNow) + == .unreadable( + source: ProviderID.claude.credentialSource("claude.keychain"), + detail: "Claude Code has not stored a usage token yet; run `claude` once.")) +} + +@Test func claudeProviderReportsAMissingUsageToken() async throws { + let chain = ChainedClaudeCredentialStore([ + DiscoveredClaudeKeychainStore(account: "me", keychain: try keychain([(baseService, mcpOnlyItem)]).client) + ]) + let provider = claudeProvider(chain, transport: StubTransport()) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect( + result.outcome + == .notAuthenticated( + "Cannot read Claude credentials: Claude Code has not stored a usage token yet; run `claude` once.")) + #expect(provider.credentialState(now: fixedNow) == .missing(ClaudeOAuthCredentials.usageTokenMissingDetail)) +} + +@Test func discoveredClaudeKeychainPrefersTheAccountExpiringLast() throws { + let store = DiscoveredClaudeKeychainStore( + account: "me", + keychain: try keychain([ + (baseService, claudeItem(token: "default", expiresAt: fixedNow.addingTimeInterval(3600))), + (hashedService, claudeItem(token: "work", expiresAt: fixedNow.addingTimeInterval(7200))), + ]).client) + + let found = try #require(try store.loadWithSource()) + + #expect(found.credentials.accessToken == "work") + #expect( + found.source + == CredentialSource( + id: "claude.keychain", provider: .claude, title: "Claude Code Keychain", + detail: "2 accounts found; showing \(hashedLabel)")) + #expect(try store.accounts().map(\.label) == ["the default configuration", hashedLabel]) + #expect( + try store.accounts().map(\.source.id) == ["claude.keychain:\(baseService)", "claude.keychain:\(hashedService)"]) + #expect(try store.accounts()[1].source.detail == "Keychain item \(hashedService)") +} + +@Test func discoveredClaudeKeychainRanksATokenWithoutExpiryLast() throws { + let store = DiscoveredClaudeKeychainStore( + account: "me", + keychain: try keychain([ + (baseService, claudeItem(token: "default", expiresAt: nil)), + (hashedService, claudeItem(token: "work", expiresAt: fixedNow)), + ]).client) + + #expect(try store.load()?.accessToken == "work") +} + +@Test func discoveredClaudeKeychainKeepsTheEarlierAccountOnATie() throws { + let store = DiscoveredClaudeKeychainStore( + account: "me", + keychain: try keychain([ + (baseService, claudeItem(token: "default", expiresAt: fixedNow)), + (hashedService, claudeItem(token: "work", expiresAt: fixedNow)), + ]).client) + + #expect(try store.load()?.accessToken == "default") +} + +@Test func discoveredClaudeKeychainKeepsTheStandardSourceForASingleAccount() throws { + let store = DiscoveredClaudeKeychainStore( + account: "me", keychain: try keychain([(hashedService, claudeItem(token: "work", expiresAt: nil))]).client) + + let found = try #require(try store.loadWithSource()) + + #expect(found.credentials.accessToken == "work") + #expect(found.source == ProviderID.claude.credentialSource("claude.keychain")) + #expect(store.credentialHealth(now: fixedNow) == .valid(source: found.source, expiresAt: nil)) +} + +@Test func discoveredClaudeKeychainListsTheConfiguredServiceFirst() throws { + let store = DiscoveredClaudeKeychainStore( + service: hashedService, account: "me", + keychain: try keychain([ + (baseService, claudeItem(token: "default", expiresAt: nil)), + (hashedService, claudeItem(token: "work", expiresAt: nil)), + ]).client) + + #expect(try store.accounts().map(\.service) == [hashedService, baseService]) +} + +@Test func discoveredClaudeKeychainReturnsNothingWithoutItems() throws { + let store = DiscoveredClaudeKeychainStore(account: "me", keychain: MemoryKeychain().client) + + #expect(try store.loadWithSource() == nil) + #expect(try store.accounts().isEmpty) + #expect(store.description == "Keychain Claude Code-credentials, plus any Claude Code-credentials* item") + #expect(store.source.id == "claude.keychain") +} + +@Test func discoveredClaudeKeychainReportsAnIncompleteItemWhenNoAccountIsUsable() throws { + let store = DiscoveredClaudeKeychainStore( + account: "me", keychain: try keychain([(hashedService, mcpOnlyItem)]).client) + + #expect(try store.accounts().isEmpty) + #expect(throws: CredentialStoreError.incomplete(ClaudeOAuthCredentials.usageTokenMissingDetail)) { + try store.load() + } +} + +@Test func discoveredClaudeKeychainPrefersAReadErrorOverAnIncompleteItem() { + let client = KeychainCredentialClient( + load: { service, account in + guard service == hashedService else { throw CredentialStoreError.keychain(errSecAuthFailed) } + return KeychainCredentialItem(data: mcpOnlyItem, account: account) + }, + save: { _, _, _ in }, + list: { _ in [hashedService] }) + let store = DiscoveredClaudeKeychainStore(account: "me", keychain: client) + + #expect(throws: CredentialStoreError.keychain(errSecAuthFailed)) { try store.load() } + #expect(throws: CredentialStoreError.keychain(errSecAuthFailed)) { try store.accounts() } +} + +@Test func discoveredClaudeKeychainIgnoresAReadErrorWhenAnotherAccountIsUsable() throws { + let client = KeychainCredentialClient( + load: { service, account in + guard service == hashedService else { throw CredentialStoreError.keychain(errSecAuthFailed) } + return KeychainCredentialItem(data: claudeItem(token: "work", expiresAt: nil), account: account) + }, + save: { _, _, _ in }, + list: { _ in [hashedService] }) + let store = DiscoveredClaudeKeychainStore(account: "me", keychain: client) + + let found = try #require(try store.loadWithSource()) + + #expect(found.credentials.accessToken == "work") + #expect(found.source == ProviderID.claude.credentialSource("claude.keychain")) +} + +@Test func discoveredClaudeKeychainFallsBackToTheConfiguredServiceWhenListingFails() throws { + let keychain = try keychain([(baseService, claudeItem(token: "default", expiresAt: nil))]) + let client = KeychainCredentialClient( + load: { try keychain.client.load(service: $0, account: $1) }, + save: { _, _, _ in }, + list: { _ in throw CredentialStoreError.keychain(errSecNotAvailable) }) + + #expect(try DiscoveredClaudeKeychainStore(account: "me", keychain: client).load()?.accessToken == "default") +} + +@Test func discoveredClaudeKeychainSavesToTheSelectedAccount() throws { + let keychain = try keychain([ + (baseService, claudeItem(token: "default", expiresAt: fixedNow)), + (hashedService, claudeItem(token: "work", expiresAt: fixedNow.addingTimeInterval(60))), + ]) + let store = DiscoveredClaudeKeychainStore(account: "me", keychain: keychain.client) + + try store.save(ClaudeOAuthCredentials(accessToken: "refreshed", refreshToken: nil, expiresAt: nil)) + + #expect( + try KeychainClaudeCredentialStore(service: hashedService, account: "me", keychain: keychain.client).load()? + .accessToken == "refreshed") + #expect( + try KeychainClaudeCredentialStore(service: baseService, account: "me", keychain: keychain.client).load()? + .accessToken == "default") +} + +@Test func discoveredClaudeKeychainSavesToTheConfiguredServiceWhenNothingIsStored() throws { + let keychain = MemoryKeychain() + let store = DiscoveredClaudeKeychainStore(service: hashedService, account: "me", keychain: keychain.client) + + try store.save(ClaudeOAuthCredentials(accessToken: "fresh", refreshToken: nil, expiresAt: nil)) + + #expect(try keychain.client.services(prefix: baseService) == [hashedService]) + #expect(try store.load()?.accessToken == "fresh") +} diff --git a/Tests/TokenMenuBarCoreTests/ClaudeTranscriptReaderTests.swift b/Tests/TokenMenuBarCoreTests/ClaudeTranscriptReaderTests.swift index d38b2f0..60689bf 100644 --- a/Tests/TokenMenuBarCoreTests/ClaudeTranscriptReaderTests.swift +++ b/Tests/TokenMenuBarCoreTests/ClaudeTranscriptReaderTests.swift @@ -395,11 +395,12 @@ private func waitForTranscriptScan(_ reader: ClaudeTranscriptReader) async { 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 + output: Int = 20, cacheWrite: Int = 30, cacheRead: Int = 40, tools: Int = 1, costUSD: Double? = nil ) -> String { let content = (0.. URL { + let home = temporaryDirectory().appendingPathComponent("codex") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + if let auth { try FileCodexAuthStore(url: home.appendingPathComponent("auth.json")).save(auth) } + if let config { try Data(config.utf8).write(to: home.appendingPathComponent("config.toml")) } + return home +} + +private func fileStore(_ home: URL) -> CodexHomeAuthStore { + CodexHomeAuthStore(home: home, store: FileCodexAuthStore(url: home.appendingPathComponent("auth.json"))) +} + +@Test func codexHomeDiscoveryKeepsTheConfiguredHomeAndExistingDefaults() throws { + let root = temporaryDirectory() + let home = root.appendingPathComponent("home") + let defaults = [home.appendingPathComponent(".codex"), home.appendingPathComponent(".config/codex")] + for url in defaults { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } + let configured = root.appendingPathComponent("elsewhere") + + #expect( + CodexHomeDiscovery.homes(configured: configured, home: home).map(\.path) == ([configured] + defaults).map(\.path)) +} + +@Test func codexHomeDiscoveryDropsDuplicatesAndMissingDirectories() { + let home = temporaryDirectory().appendingPathComponent("home") + let configured = home.appendingPathComponent(".codex") + + #expect(CodexHomeDiscovery.homes(configured: configured, home: home) == [configured]) +} + +@Test(arguments: [ + (#"cli_auth_credentials_store = "file""#, false), + (#"cli_auth_credentials_store = "keyring""#, true), + (nil, true), +]) +func codexAuthStoresFollowTheConfiguredCredentialStore(config: String?, usesKeychain: Bool) throws { + let home = try codexHome(config: config) + let file = FileCodexAuthStore(url: home.appendingPathComponent("auth.json")) + let expected = + usesKeychain + ? "\(KeychainCodexAuthStore(codexHome: home, keychain: .empty).description), \(file.description)" + : file.description + + #expect(CodexAuthStores.store(codexHome: home, keychain: .empty).description == expected) +} + +@Test func codexAuthStoresUseThePlainStoreForASingleHome() throws { + let root = temporaryDirectory() + let configured = root.appendingPathComponent("codex") + + let store = CodexAuthStores.discovering(configured: configured, home: root, keychain: .empty) + + #expect(!(store is DiscoveredCodexAuthStore)) + #expect(store.description == CodexAuthStores.store(codexHome: configured, keychain: .empty).description) +} + +@Test func codexAuthStoresDiscoverEveryHome() throws { + let root = temporaryDirectory() + let home = root.appendingPathComponent("home") + let defaultHome = home.appendingPathComponent(".codex") + try FileManager.default.createDirectory(at: defaultHome, withIntermediateDirectories: true) + let configured = root.appendingPathComponent("elsewhere") + + let store = try #require( + CodexAuthStores.discovering(configured: configured, home: home, keychain: .empty) as? DiscoveredCodexAuthStore) + + #expect(store.stores.map(\.home.path) == [configured, defaultHome].map(\.path)) + #expect( + store.description + == [configured, defaultHome].map { CodexAuthStores.store(codexHome: $0, keychain: .empty).description } + .joined(separator: ", ")) + #expect(store.source.id == "codex.automatic") + #expect( + store.source.detail + == [configured, defaultHome].map { ($0.path as NSString).abbreviatingWithTildeInPath }.joined(separator: ", ")) +} + +@Test func discoveredCodexStorePrefersTheMostRecentlyRefreshedAccount() throws { + let stale = try codexHome(auth: CodexAuth(accessToken: "stale", lastRefresh: fixedNow.addingTimeInterval(-100))) + let fresh = try codexHome(auth: CodexAuth(accessToken: "fresh", lastRefresh: fixedNow)) + let store = DiscoveredCodexAuthStore([fileStore(stale), fileStore(fresh)]) + + let found = try #require(try store.loadWithSource()) + + #expect(found.auth.accessToken == "fresh") + #expect( + found.source + == CredentialSource( + id: "codex.file", provider: .codex, title: "Codex auth.json", + detail: "2 accounts found; showing \((fresh.path as NSString).abbreviatingWithTildeInPath)")) + #expect(try store.accounts().map(\.home) == [stale, fresh]) +} + +@Test func discoveredCodexStoreShowsTheAccountEmailWhenTheTokenCarriesOne() throws { + let signedIn = try codexHome(auth: CodexAuth(document: Fixtures.codexAuth())!) + let anonymous = try codexHome(auth: CodexAuth(accessToken: "anonymous")) + let store = DiscoveredCodexAuthStore([fileStore(anonymous), fileStore(signedIn)]) + + let found = try #require(try store.loadWithSource()) + + #expect(found.auth.email == "user@example.com") + #expect(found.source.detail == "2 accounts found; showing user@example.com") +} + +@Test func discoveredCodexStoreKeepsTheStandardSourceForASingleAccount() throws { + let home = try codexHome(auth: CodexAuth(accessToken: "only")) + let store = DiscoveredCodexAuthStore([fileStore(try codexHome()), fileStore(home)]) + + #expect( + store.credentialHealth(now: fixedNow) + == .valid(source: ProviderID.codex.credentialSource("codex.file"), expiresAt: nil)) +} + +@Test func discoveredCodexStoreReturnsNothingWhenEveryHomeIsEmpty() throws { + let store = DiscoveredCodexAuthStore([fileStore(try codexHome()), fileStore(try codexHome())]) + + #expect(try store.load() == nil) + #expect(try store.accounts().isEmpty) +} + +@Test func discoveredCodexStoreThrowsWhenNoHomeIsReadable() throws { + let broken = try codexHome() + try Data("{".utf8).write(to: broken.appendingPathComponent("auth.json")) + let store = DiscoveredCodexAuthStore([fileStore(broken), fileStore(try codexHome())]) + + #expect(throws: CredentialReadFailure.self) { try store.load() } + #expect(throws: CredentialReadFailure.self) { try store.accounts() } +} + +@Test func discoveredCodexStoreIgnoresAnUnreadableHomeWhenAnotherWorks() throws { + let broken = try codexHome() + try Data("{".utf8).write(to: broken.appendingPathComponent("auth.json")) + let store = DiscoveredCodexAuthStore([ + fileStore(broken), fileStore(try codexHome(auth: CodexAuth(accessToken: "ok"))), + ]) + + #expect(try store.load()?.accessToken == "ok") +} + +@Test func discoveredCodexStoreSavesToTheSelectedHome() throws { + let stale = try codexHome(auth: CodexAuth(accessToken: "stale", lastRefresh: fixedNow.addingTimeInterval(-100))) + let fresh = try codexHome(auth: CodexAuth(accessToken: "fresh", lastRefresh: fixedNow)) + let store = DiscoveredCodexAuthStore([fileStore(stale), fileStore(fresh)]) + + try store.save(CodexAuth(accessToken: "refreshed")) + + #expect(try FileCodexAuthStore(url: fresh.appendingPathComponent("auth.json")).load()?.accessToken == "refreshed") + #expect(try FileCodexAuthStore(url: stale.appendingPathComponent("auth.json")).load()?.accessToken == "stale") +} + +@Test func discoveredCodexStoreSavesToTheFirstHomeWhenNothingIsStored() throws { + let first = try codexHome() + let store = DiscoveredCodexAuthStore([fileStore(first), fileStore(try codexHome())]) + + try store.save(CodexAuth(accessToken: "fresh")) + + #expect(try FileCodexAuthStore(url: first.appendingPathComponent("auth.json")).load()?.accessToken == "fresh") + try DiscoveredCodexAuthStore([]).save(CodexAuth(accessToken: "ignored")) + #expect(try DiscoveredCodexAuthStore([]).load() == nil) +} diff --git a/Tests/TokenMenuBarCoreTests/CodexResetCreditExpiryTests.swift b/Tests/TokenMenuBarCoreTests/CodexResetCreditExpiryTests.swift new file mode 100644 index 0000000..3853372 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CodexResetCreditExpiryTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +@Test func codexReadsResetCreditExpiriesAndDropsIncompleteEntries() 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,"credits":[{"id":"a","expires_at":1756512000},"# + + #"{"id":"b","expires_at":"2026-09-03T10:00:00Z"},{"id":"c"},{"expires_at":1},{"id":"d","expires_at":"soon"}]}"# + )) + let result = await codexProvider(MemoryCodexStore(validCodex), transport: transport).fetch( + now: fixedNow, options: FetchOptions()) + #expect( + result.outcome.snapshot?.resetCredits?.expiries == [ + ResetCreditExpiry(id: "a", expiresAt: Date(timeIntervalSince1970: 1_756_512_000)), + ResetCreditExpiry(id: "b", expiresAt: try #require(ISODate.parse("2026-09-03T10:00:00Z"))), + ]) +} + +private let timestampCases: [(JSONValue, Date?)] = [ + (.number(5), Date(timeIntervalSince1970: 5)), + (.string("2026-09-03T10:00:00Z"), ISODate.parse("2026-09-03T10:00:00Z")), + (.string("soon"), nil), + (.bool(true), nil), +] + +@Test(arguments: timestampCases) +func codexMapperReadsEpochAndISOTimestamps(value: JSONValue, expected: Date?) { + #expect(CodexMapper.timestamp(value) == expected) +} + +@Test func resetCreditsDecodeWithoutExpiries() throws { + let legacy = #"{"available":1,"applicable":1,"immediatePurchaseEligible":false}"# + #expect( + try JSONDecoder().decode(ResetCredits.self, from: Data(legacy.utf8)) == ResetCredits(available: 1, applicable: 1)) + let full = ResetCredits( + available: 1, applicable: 0, totalEarned: 2, expiries: [ResetCreditExpiry(id: "a", expiresAt: fixedNow)]) + #expect(try JSONDecoder().decode(ResetCredits.self, from: JSONEncoder().encode(full)) == full) +} diff --git a/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift b/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift index 3a0cef9..14295d2 100644 --- a/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift +++ b/Tests/TokenMenuBarCoreTests/CodexRolloutReaderTests.swift @@ -86,6 +86,40 @@ import TokenMenuBarTestSupport #expect(await empty.latest(now: fixedNow.addingTimeInterval(301)) != nil) } +@Test(arguments: throttledPowerStates) +func rolloutReaderServesTheExpiredCacheWhilePowerIsConstrained(throttled: PowerState) 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 power = PowerSwitch() + let reader = CodexRolloutReader(sessionsRoot: root, cacheInterval: 300, powerState: power.read) + #expect(await reader.latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 7) + + power.current = throttled + try (rolloutLine(primary: 42) + "\n").write( + to: root.appendingPathComponent("rollout-newer.jsonl"), atomically: true, encoding: .utf8) + #expect(await reader.latest(now: fixedNow.addingTimeInterval(301))?.rateLimit.primaryWindow?.usedPercent == 7) + #expect((await reader.workload).treesScanned == 1) + try (rolloutLine(primary: 55) + "\n").write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSince1970: 1000)], ofItemAtPath: file.path) + #expect(await reader.latest(now: fixedNow.addingTimeInterval(302))?.rateLimit.primaryWindow?.usedPercent == 55) + #expect((await reader.workload).treesScanned == 1) + + power.current = PowerState(lowPowerMode: false, thermalPressure: false) + #expect(await reader.latest(now: fixedNow.addingTimeInterval(303))?.rateLimit.primaryWindow?.usedPercent == 42) + #expect((await reader.workload).treesScanned == 2) +} + +@Test func rolloutReaderStillScansAnEmptyCacheOnLowPower() async throws { + let root = temporaryDirectory() + try (rolloutLine(primary: 7) + "\n").write( + to: root.appendingPathComponent("rollout-current.jsonl"), atomically: true, encoding: .utf8) + let reader = CodexRolloutReader( + sessionsRoot: root, powerState: { PowerState(lowPowerMode: true, thermalPressure: true) }) + #expect(await reader.latest(now: fixedNow)?.rateLimit.primaryWindow?.usedPercent == 7) + #expect(CodexRolloutReader.defaultCacheInterval == 15 * 60) +} + @Test func rolloutReaderBoundsEachReverseReadSlice() async throws { let root = temporaryDirectory() let file = root.appendingPathComponent("rollout-current.jsonl") diff --git a/Tests/TokenMenuBarCoreTests/CopilotTests.swift b/Tests/TokenMenuBarCoreTests/CopilotTests.swift index 3627c90..565e716 100644 --- a/Tests/TokenMenuBarCoreTests/CopilotTests.swift +++ b/Tests/TokenMenuBarCoreTests/CopilotTests.swift @@ -80,6 +80,27 @@ private func copilotSnapshot( #expect(snapshot.notices.isEmpty) } +@Test func copilotOrganizationManagedSeatNamesTheOrganizationAndKeepsItsPremiumWindow() async throws { + let (provider, transport) = makeProvider(validCopilot) + transport.on(path: "/copilot_internal/user", .json("copilot_user_org")) + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + let snapshot = try #require(result.outcome.snapshot) + #expect(result.outcome.errorDescription == nil) + #expect(result.credentialStatus?.state == .valid(expiresAt: nil)) + #expect(snapshot.identity?.planName == "Business via octo-org") + #expect(snapshot.identity?.tier == "copilot_for_business_seat") + #expect(snapshot.identity?.email == "octocat") + #expect(snapshot.windows.map(\.id) == ["premium_interactions"]) + #expect(snapshot.windows.map(\.usedPercent) == [30]) + #expect(snapshot.notices.isEmpty) +} + +@Test func copilotListsEveryManagingOrganization() async throws { + let body = #"{"copilot_plan": "enterprise", "organization_login_list": ["octo-org", "octo-labs"]}"# + let snapshot = try #require(await copilotSnapshot(.text(body))) + #expect(snapshot.identity?.planName == "Enterprise via octo-org, octo-labs") +} + @Test func copilotCallsAnExhaustedQuotaWithoutOverageALimit() async throws { let body = #""" {"quota_snapshots": {"chat": {"percent_remaining": -10, "overage_permitted": false}, diff --git a/Tests/TokenMenuBarCoreTests/CredentialFallbackTests.swift b/Tests/TokenMenuBarCoreTests/CredentialFallbackTests.swift new file mode 100644 index 0000000..87808b1 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CredentialFallbackTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Security +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +private func claudeChain(keychain: KeychainCredentialClient, fileToken: String?) throws -> ChainedClaudeCredentialStore +{ + let file = FileClaudeCredentialStore(url: temporaryDirectory().appendingPathComponent(".credentials.json")) + if let fileToken { + try file.save(ClaudeOAuthCredentials(accessToken: fileToken, refreshToken: nil, expiresAt: fixedNow + 3600)) + } + return ChainedClaudeCredentialStore([DiscoveredClaudeKeychainStore(account: "me", keychain: keychain), file]) +} + +@Test func claudeKeychainMissWithAValidFileYieldsTheFileCredential() throws { + let chain = try claudeChain(keychain: MemoryKeychain().client, fileToken: "file") + + let found = try #require(try chain.loadWithSource()) + + #expect(found.credentials.accessToken == "file") + #expect(found.source.id == "claude.file") + #expect( + chain.credentialHealth(now: fixedNow) + == .valid(source: ProviderID.claude.credentialSource("claude.file"), expiresAt: fixedNow + 3600)) +} + +@Test func claudeDeniedKeychainWithAValidFileYieldsTheFileCredential() throws { + let denied = KeychainCredentialClient( + load: { _, _ in throw CredentialStoreError.keychain(errSecAuthFailed) }, save: { _, _, _ in }) + let chain = try claudeChain(keychain: denied, fileToken: "file") + + #expect(try chain.loadWithSource()?.source.id == "claude.file") +} + +@Test func claudeReportsMissingOnlyWhenEverySourceIsEmpty() throws { + let chain = try claudeChain(keychain: MemoryKeychain().client, fileToken: nil) + + #expect(try chain.loadWithSource() == nil) + #expect(chain.credentialHealth(now: fixedNow) == .missing(expected: ProviderID.claude.setup.credentialSources)) +} + +@Test func codexKeychainMissWithAValidAuthFileYieldsTheFileCredential() throws { + let home = temporaryDirectory() + try Data(#"cli_auth_credentials_store = "auto""#.utf8).write(to: home.appendingPathComponent("config.toml")) + try FileCodexAuthStore(url: home.appendingPathComponent("auth.json")).save(CodexAuth(accessToken: "file")) + let store = CodexAuthStores.store(codexHome: home, keychain: MemoryKeychain().client) + + let found = try #require(try store.loadWithSource()) + + #expect(found.auth.accessToken == "file") + #expect(found.source.id == "codex.file") + #expect( + store.credentialHealth(now: fixedNow) + == .valid(source: ProviderID.codex.credentialSource("codex.file"), expiresAt: nil)) +} + +@Test func codexReportsMissingOnlyWhenEverySourceIsEmpty() { + let store = CodexAuthStores.store(codexHome: temporaryDirectory(), keychain: MemoryKeychain().client) + + #expect(store.credentialHealth(now: fixedNow) == .missing(expected: ProviderID.codex.setup.credentialSources)) +} diff --git a/Tests/TokenMenuBarCoreTests/CredentialsTests.swift b/Tests/TokenMenuBarCoreTests/CredentialsTests.swift index 4e049bc..6d56bf2 100644 --- a/Tests/TokenMenuBarCoreTests/CredentialsTests.swift +++ b/Tests/TokenMenuBarCoreTests/CredentialsTests.swift @@ -67,7 +67,7 @@ import TokenMenuBarTestSupport try KeychainClaudeCredentialStore.parse(Data("nope".utf8)) } #expect( - try KeychainClaudeCredentialStore.parse(Fixtures.data("claude_keychain"))?.accessToken == "sk-ant-oat01-EXAMPLE") + try KeychainClaudeCredentialStore.parse(Fixtures.data("claude_keychain")).accessToken == "sk-ant-oat01-EXAMPLE") #expect( KeychainClaudeCredentialStore(account: "me", keychain: .empty).description == "Keychain item Claude Code-credentials") @@ -144,6 +144,45 @@ func keychainClientSkipsReadsForThirtyMinutesAfterADenial(status: OSStatus) thro try client.save(Data("secret".utf8), service: "svc", account: "me") #expect(try client.load(service: "svc", account: "me")?.data == Data("secret".utf8)) + #expect(try client.services(prefix: "svc") == ["svc"]) +} + +@Test func keychainClientListsServicesByPrefixWithoutDuplicates() throws { + let keychain = MemoryKeychain() + try keychain.client.save(Data("a".utf8), service: "Claude Code-credentials", account: "me") + try keychain.client.save(Data("b".utf8), service: "Claude Code-credentials-1a2b3c4d", account: "me") + try keychain.client.save(Data("c".utf8), service: "Claude Code-credentials-1a2b3c4d", account: "other") + try keychain.client.save(Data("d".utf8), service: "Codex Auth", account: "me") + + #expect( + try keychain.client.services(prefix: "Claude Code-credentials") + == ["Claude Code-credentials", "Claude Code-credentials-1a2b3c4d"]) + #expect(try KeychainCredentialClient(load: { _, _ in nil }, save: { _, _, _ in }).services(prefix: "Claude") == []) +} + +@Test func systemKeychainListQueryMatchesEveryGenericPasswordWithoutData() throws { + let query = systemKeychainListQuery() + let context = try #require(query[kSecUseAuthenticationContext as String] as? LAContext) + + #expect(query[kSecAttrService as String] == nil) + #expect(query[kSecReturnData as String] == nil) + #expect(query[kSecReturnAttributes as String] as? Bool == true) + #expect(query[kSecMatchLimit as String] as? String == kSecMatchLimitAll as String) + #expect(context.interactionNotAllowed) +} + +@Test func keychainServicesFilterSortAndDeduplicateAttributes() { + let attributes: [[String: Any]] = [ + [kSecAttrService as String: "Claude Code-credentials-b"], + [kSecAttrService as String: "Codex Auth"], + [kSecAttrAccount as String: "no service"], + [kSecAttrService as String: "Claude Code-credentials"], + [kSecAttrService as String: "Claude Code-credentials-b"], + ] + + #expect( + keychainServices(attributes, prefix: "Claude Code-credentials") + == ["Claude Code-credentials", "Claude Code-credentials-b"]) } @Test(arguments: [errSecAuthFailed, errSecUserCanceled, errSecInteractionNotAllowed]) diff --git a/Tests/TokenMenuBarCoreTests/CursorTests.swift b/Tests/TokenMenuBarCoreTests/CursorTests.swift index af6799e..effdfe9 100644 --- a/Tests/TokenMenuBarCoreTests/CursorTests.swift +++ b/Tests/TokenMenuBarCoreTests/CursorTests.swift @@ -114,6 +114,25 @@ private func cursorSnapshot( #expect(snapshot.windows.first { $0.id == "plan" }?.resetsAt == ISODate.parse("2026-09-10T00:00:00.000Z")) } +@Test func cursorLabelsEachPoolAndKeepsTheAggregateComponentsAsDetail() async throws { + let snapshot = try #require(await cursorSnapshot(summary: .json("cursor_usage_summary"))) + #expect(snapshot.windows.map(\.label) == ["On-demand", "Plan usage", "Team pool"]) + #expect(snapshot.windows.map(\.detail) == [nil, "Auto 12% · API 48%", nil]) +} + +@Test func cursorProAccountShowsThePlanAggregateWithItsAutoAndAPIShares() async throws { + let snapshot = try #require(await cursorSnapshot(summary: .json("cursor_usage_summary_pro"))) + #expect(snapshot.identity?.planName == "Pro") + #expect(snapshot.windows.map(\.id) == ["overall", "plan"]) + #expect(snapshot.windows.map(\.label) == ["Overall", "Plan usage"]) + #expect(snapshot.windows.map(\.usedPercent) == [42.3, 42.3]) + #expect(snapshot.windows.map(\.detail) == [nil, "Auto 18% · API 62%"]) + #expect(snapshot.spend == nil) + let card = UsagePresenter.card( + provider: .cursor, state: ProviderState(snapshot: snapshot, availability: .current), samples: [:], now: fixedNow) + #expect(card.rows.map(\.detail) == ["overall", "Auto 18% · API 62%"]) +} + @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")) @@ -176,21 +195,41 @@ private func cursorPlanWindows(_ fields: String) async throws -> [QuotaWindow] { return try #require(await cursorSnapshot(summary: .text(body))).windows } +struct CursorPlanCase: Sendable, CustomTestStringConvertible { + let fields: String + let ids: [String] + let labels: [String] + let percents: [Double] + let details: [String?] + + var testDescription: String { fields } +} + @Test( arguments: [ - (#""autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 3"#, ["plan"], [3.0]), - (#""autoPercentUsed": 10, "apiPercentUsed": 20"#, ["plan:api", "plan:auto"], [20.0, 10.0]), - (#""autoPercentUsed": 10"#, ["plan:auto"], [10.0]), - (#""apiPercentUsed": 20"#, ["plan:api"], [20.0]), - (#""used": 25, "limit": 100"#, ["plan"], [25.0]), - (#""used": 25, "limit": 100, "autoPercentUsed": 10, "apiPercentUsed": 20"#, ["plan"], [25.0]), + CursorPlanCase( + fields: #""autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 3"#, ids: ["plan"], + labels: ["Plan usage"], percents: [3], details: ["Auto 1% · API 2%"]), + CursorPlanCase( + fields: #""autoPercentUsed": 10, "apiPercentUsed": 20"#, ids: ["plan:api", "plan:auto"], + labels: ["Plan usage (API)", "Plan usage (Auto)"], percents: [20, 10], details: [nil, nil]), + CursorPlanCase( + fields: #""autoPercentUsed": 10"#, ids: ["plan:auto"], labels: ["Plan usage (Auto)"], percents: [10], + details: [nil]), + CursorPlanCase( + fields: #""apiPercentUsed": 20"#, ids: ["plan:api"], labels: ["Plan usage (API)"], percents: [20], details: [nil]), + CursorPlanCase( + fields: #""used": 25, "limit": 100"#, ids: ["plan"], labels: ["Plan usage"], percents: [25], details: [nil]), + CursorPlanCase( + fields: #""used": 25, "limit": 100, "autoPercentUsed": 10, "apiPercentUsed": 20"#, ids: ["plan"], + labels: ["Plan usage"], percents: [25], details: ["Auto 10% · API 20%"]), ]) -func cursorKeepsAggregateAndComponentPercentsSeparate( - fields: String, ids: [String], percents: [Double] -) async throws { - let windows = try await cursorPlanWindows(fields) - #expect(windows.map(\.id) == ids) - #expect(windows.map(\.usedPercent) == percents) +func cursorKeepsAggregateAndComponentPercentsSeparate(testCase: CursorPlanCase) async throws { + let windows = try await cursorPlanWindows(testCase.fields) + #expect(windows.map(\.id) == testCase.ids) + #expect(windows.map(\.label) == testCase.labels) + #expect(windows.map(\.usedPercent) == testCase.percents) + #expect(windows.map(\.detail) == testCase.details) } @Test(arguments: [#""used": 25, "limit": 0"#, #""remaining": 3"#]) diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/copilot_user_org.json b/Tests/TokenMenuBarCoreTests/Fixtures/copilot_user_org.json new file mode 100644 index 0000000..491091b --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/copilot_user_org.json @@ -0,0 +1,39 @@ +{ + "access_type_sku": "copilot_for_business_seat", + "analytics_tracking_id": "def", + "assigned_date": "2025-03-12T00:00:00Z", + "can_signup_for_limited": false, + "chat_enabled": true, + "copilot_plan": "business", + "organization_login_list": ["octo-org"], + "organization_list": [ + { + "login": "octo-org", + "name": "Octo Org" + } + ], + "quota_reset_date": "2026-09-01", + "token_based_billing": false, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 210, + "percent_remaining": 70, + "quota_id": "premium_interactions", + "quota_remaining": 210, + "overage_count": 0, + "overage_permitted": false, + "unlimited": false + }, + "chat": { + "entitlement": 0, + "remaining": 0, + "unlimited": true + }, + "completions": { + "entitlement": 0, + "remaining": 0, + "unlimited": true + } + } +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/cursor_usage_summary_pro.json b/Tests/TokenMenuBarCoreTests/Fixtures/cursor_usage_summary_pro.json new file mode 100644 index 0000000..85a3a28 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/cursor_usage_summary_pro.json @@ -0,0 +1,36 @@ +{ + "billingCycleStart": "2026-08-15T00:00:00.000Z", + "billingCycleEnd": "2026-09-15T00:00:00.000Z", + "membershipType": "pro", + "limitType": "user", + "isUnlimited": false, + "individualUsage": { + "plan": { + "enabled": true, + "used": 846, + "limit": 2000, + "remaining": 1154, + "breakdown": { + "included": 2000, + "bonus": 0, + "total": 2000 + }, + "autoPercentUsed": 18.2, + "apiPercentUsed": 61.7, + "totalPercentUsed": 42.3 + }, + "onDemand": { + "enabled": false, + "used": 0, + "limit": 0, + "remaining": 0 + }, + "overall": { + "enabled": true, + "used": 846, + "limit": 2000, + "remaining": 1154 + } + }, + "teamUsage": null +} diff --git a/Tests/TokenMenuBarCoreTests/ModelTests.swift b/Tests/TokenMenuBarCoreTests/ModelTests.swift index 50092d0..6044b21 100644 --- a/Tests/TokenMenuBarCoreTests/ModelTests.swift +++ b/Tests/TokenMenuBarCoreTests/ModelTests.swift @@ -412,6 +412,20 @@ func exportCommandNeedsAFlagAndADirectory(arguments: [String]) { #expect(ExportCommand.parse(arguments) == nil) } +@Test func quotaWindowDecodesSnapshotsStoredBeforeDetailExisted() throws { + let window = QuotaWindow( + id: "plan", label: "Plan usage", group: .monthly, usedPercent: 40, resetsAt: fixedNow, detail: "Auto 10%") + var document = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(window)) as? [String: Any]) + #expect(document["detail"] as? String == "Auto 10%") + document["detail"] = nil + + let legacy = try JSONDecoder().decode(QuotaWindow.self, from: JSONSerialization.data(withJSONObject: document)) + + #expect(legacy.detail == nil) + #expect(legacy.withDetail("Confirming reset") == window.withDetail("Confirming reset")) + #expect(window.withDetail(nil) == legacy) +} + @Test func quotaWindowDecodesSnapshotsStoredBeforeResetPrecisionExisted() throws { let window = QuotaWindow( id: "session", label: "Session", group: .session, usedPercent: 40, resetsAt: fixedNow, resetPrecision: .day) diff --git a/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift b/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift index 2f2595a..37585ee 100644 --- a/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift +++ b/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift @@ -107,3 +107,85 @@ private func plan( provider: .gemini, settings: settings, now: fixedNow) #expect(signedOut.map(\.kind) == [.authentication]) } + +private func codexSnapshot(expiries: [ResetCreditExpiry]?, fetchedAt: Date = fixedNow) -> ProviderSnapshot { + ProviderSnapshot( + provider: .codex, windows: [], + resetCredits: expiries.map { ResetCredits(available: 1, applicable: 1, expiries: $0) }, fetchedAt: fetchedAt) +} + +private func planCodex( + _ previous: ProviderSnapshot?, _ current: ProviderSnapshot, settings: NotificationSettings = NotificationSettings(), + now: Date = fixedNow +) -> [NotificationEvent] { + NotificationPlanner.events( + previous: previous, current: current, previousAvailability: .current, currentAvailability: .current, + provider: .codex, settings: settings, now: now) +} + +@Test func plannerWarnsOnceAboutAResetCreditExpiringWithinADay() { + let credit = ResetCreditExpiry(id: "c1", expiresAt: fixedNow.addingTimeInterval(5 * 3600)) + let current = codexSnapshot(expiries: [credit]) + let events = planCodex(nil, current) + #expect(events.count == 1) + #expect(events[0].kind == .expiringCredit) + #expect(events[0].id == "codex:reset-credit:c1:\(Int(credit.expiresAt.timeIntervalSince1970))") + #expect(events[0].title == "Codex reset credit expires in 5 h") + #expect(events[0].body == "Use it before it lapses.") + #expect(events[0].window == nil) + let later = fixedNow.addingTimeInterval(60) + #expect(planCodex(current, codexSnapshot(expiries: [credit], fetchedAt: later), now: later).isEmpty) +} + +@Test func plannerWarnsWhenAResetCreditEntersTheDayWindow() { + let credit = ResetCreditExpiry(id: "c1", expiresAt: fixedNow.addingTimeInterval(23 * 3600)) + let earlier = codexSnapshot(expiries: [credit], fetchedAt: fixedNow.addingTimeInterval(-2 * 3600)) + #expect(planCodex(nil, earlier, now: earlier.fetchedAt).isEmpty) + #expect(planCodex(earlier, codexSnapshot(expiries: [credit])).map(\.kind) == [.expiringCredit]) +} + +@Test( + arguments: [ + ("disabled", NotificationSettings(notifyOnExpiringCredits: false), 5 * 3600.0), + ("beyond a day", NotificationSettings(), 25 * 3600.0), + ("already expired", NotificationSettings(), -60.0), + ]) +func plannerStaysQuietAboutResetCredits(name: String, settings: NotificationSettings, offset: TimeInterval) { + let credit = ResetCreditExpiry(id: "c1", expiresAt: fixedNow.addingTimeInterval(offset)) + #expect(planCodex(nil, codexSnapshot(expiries: [credit]), settings: settings).isEmpty, Comment(rawValue: name)) +} + +@Test func plannerIgnoresSnapshotsWithoutResetCredits() { + #expect(planCodex(codexSnapshot(expiries: nil), codexSnapshot(expiries: nil)).isEmpty) +} + +private let expiryWordings: [(TimeInterval, String)] = [ + (45 * 60, "45 min"), (30, "1 min"), (23 * 3600 + 59 * 60, "23 h"), +] + +@Test(arguments: expiryWordings) +func plannerFormatsTheTimeUntilACreditExpires(offset: TimeInterval, expected: String) { + let credit = ResetCreditExpiry(id: "c1", expiresAt: fixedNow.addingTimeInterval(offset)) + #expect(planCodex(nil, codexSnapshot(expiries: [credit]))[0].title == "Codex reset credit expires in \(expected)") +} + +@Test func plannerGroupsSeveralThresholdCrossingsIntoOneNotice() { + let before = QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: 60, resetsAt: nil) + let after = QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: 78, resetsAt: nil) + let events = plan(snapshot(70, extra: [before]), snapshot(92, extra: [after])) + #expect(events.count == 1) + #expect(events[0].kind == .threshold) + #expect(events[0].window == nil) + #expect(events[0].title == "Claude: 2 limits crossed 75%") + #expect(events[0].body == "Current session at 92%, Weekly at 78%.") + let epoch = Int(fixedNow.addingTimeInterval(3600).timeIntervalSince1970) + #expect(events[0].id == "claude:thresholds:session+weekly:75:\(epoch)+0") +} + +@Test func notificationSettingsDecodeWithoutTheNewerSwitches() throws { + let legacy = #"{"enabled":true,"thresholds":[90],"notifyOnReset":false,"notifyOnAuthProblems":true}"# + let decoded = try JSONDecoder().decode(NotificationSettings.self, from: Data(legacy.utf8)) + #expect(decoded == NotificationSettings(thresholds: [90], notifyOnReset: false)) + let full = NotificationSettings(notifyOnExpiringCredits: false, playSound: false) + #expect(try JSONDecoder().decode(NotificationSettings.self, from: JSONEncoder().encode(full)) == full) +} diff --git a/Tests/TokenMenuBarCoreTests/PowerStateTests.swift b/Tests/TokenMenuBarCoreTests/PowerStateTests.swift new file mode 100644 index 0000000..a74a6ab --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/PowerStateTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing +import TokenMenuBarCore + +@Test(arguments: [ + (PowerState(lowPowerMode: false, thermalPressure: false), false), + (PowerState(lowPowerMode: true, thermalPressure: false), true), + (PowerState(lowPowerMode: false, thermalPressure: true), true), +]) +func powerStateThrottlesScanningWhenEitherConditionHolds(state: PowerState, throttles: Bool) { + #expect(state.throttlesScanning == throttles) +} + +@Test func powerStateCurrentReadsTheRunningMachine() { + let current = PowerState.current() + + #expect(current.throttlesScanning == (current.lowPowerMode || current.thermalPressure)) +} diff --git a/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift b/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift index 14e58fa..896e23f 100644 --- a/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift +++ b/Tests/TokenMenuBarCoreTests/ProviderNestedCoverageBehaviorTests.swift @@ -84,7 +84,7 @@ import TokenMenuBarTestSupport @Test func nestedCoverageCodexResetCreditsDefaultsEveryCount() { let summary = CodexAPI.ResetCreditsSummary( availableCount: nil, applicableAvailableCount: nil, totalEarnedCount: nil, - immediateResetPurchaseEligible: nil) + immediateResetPurchaseEligible: nil, credits: nil) #expect(CodexMapper.resetCredits(summary) == ResetCredits(available: 0, applicable: 0)) } diff --git a/Tests/TokenMenuBarCoreTests/ProviderTests.swift b/Tests/TokenMenuBarCoreTests/ProviderTests.swift index 9c14f41..1a94acc 100644 --- a/Tests/TokenMenuBarCoreTests/ProviderTests.swift +++ b/Tests/TokenMenuBarCoreTests/ProviderTests.swift @@ -327,17 +327,23 @@ struct CodexResetCase: Sendable, CustomTestStringConvertible { let reported: [Double] let offsets: [TimeInterval] let shown: [Double] + let details: [String?] var testDescription: String { name } } @Test(arguments: [ CodexResetCase( - name: "zero confirmed by the next fetch", reported: [80, 0, 0], offsets: [0, 60, 120], shown: [80, 80, 0]), + name: "zero confirmed by the next fetch", reported: [80, 0, 0], offsets: [0, 60, 120], shown: [80, 80, 0], + details: [nil, "Confirming reset", nil]), CodexResetCase( - name: "zero contradicted by the next fetch", reported: [80, 0, 78], offsets: [0, 60, 120], shown: [80, 80, 78]), - CodexResetCase(name: "zero after the reset time", reported: [80, 0], offsets: [0, 7200], shown: [80, 0]), - CodexResetCase(name: "drop that is not a reset", reported: [80, 2, 0], offsets: [0, 60, 120], shown: [80, 2, 2]), + name: "zero contradicted by the next fetch", reported: [80, 0, 78], offsets: [0, 60, 120], shown: [80, 80, 78], + details: [nil, "Confirming reset", nil]), + CodexResetCase( + name: "zero after the reset time", reported: [80, 0], offsets: [0, 7200], shown: [80, 0], details: [nil, nil]), + CodexResetCase( + name: "drop that is not a reset", reported: [80, 2, 0], offsets: [0, 60, 120], shown: [80, 2, 2], + details: [nil, nil, "Confirming reset"]), ]) func codexProviderHoldsASuspectedSpuriousResetForOneFetch(testCase: CodexResetCase) async { let resetAt = fixedNow.addingTimeInterval(3600).timeIntervalSince1970 @@ -350,12 +356,33 @@ func codexProviderHoldsASuspectedSpuriousResetForOneFetch(testCase: CodexResetCa let provider = codexProvider(MemoryCodexStore(validCodex), transport: transport) var shown: [Double?] = [] + var details: [String?] = [] for offset in testCase.offsets { let result = await provider.fetch(now: fixedNow.addingTimeInterval(offset), options: FetchOptions()) - shown.append(result.outcome.snapshot?.window("weekly")?.usedPercent) + let window = result.outcome.snapshot?.window("weekly") + shown.append(window?.usedPercent) + details.append(window?.detail) } #expect(shown == testCase.shown) + #expect(details == testCase.details) +} + +@Test func codexProviderExplainsAHeldResetOnTheCard() async throws { + let resetAt = fixedNow.addingTimeInterval(3600).timeIntervalSince1970 + let transport = SequencedTransport( + suffix: "/wham/usage", + bodies: [80, 0].map { + #"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":\#($0),"limit_window_seconds":604800,"# + + #""reset_at":\#(resetAt)}}}"# + }) + let provider = codexProvider(MemoryCodexStore(validCodex), transport: transport) + _ = await provider.fetch(now: fixedNow, options: FetchOptions()) + let held = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + let snapshot = try #require(held.outcome.snapshot) + let card = UsagePresenter.card( + provider: .codex, state: ProviderState(snapshot: snapshot, availability: .current), samples: [:], now: fixedNow) + #expect(card.rows.map(\.detail) == ["Confirming reset"]) } private final class SequencedTransport: HTTPTransport, @unchecked Sendable { @@ -613,5 +640,27 @@ func stubCodexAnalytics(_ transport: StubTransport) { #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) + #expect(withAnalytics.analytics?.points.contains { $0.metric == .costUSD && $0.value == 25.00005 } == true) + #expect(withAnalytics.warnings.isEmpty) +} + +@Test func claudeProviderWarnsOncePerRunAboutModelsThePricingTableLacks() 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-zeta-7","content":[],"# + + #""usage":{"input_tokens":10,"output_tokens":20}}}"# + 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 first = await provider.fetch(now: fixedNow, options: FetchOptions(includeAnalytics: true)) + #expect(first.warnings == ["Cost for claude-zeta-7 is estimated; the pricing table has no entry"]) + #expect(first.analytics?.points.contains { $0.metric == .costUSD && $0.value == 0 } == true) + let second = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(second.warnings.isEmpty) } diff --git a/Tests/TokenMenuBarCoreTests/SettingsTests.swift b/Tests/TokenMenuBarCoreTests/SettingsTests.swift index ac5ae3a..9c90e75 100644 --- a/Tests/TokenMenuBarCoreTests/SettingsTests.swift +++ b/Tests/TokenMenuBarCoreTests/SettingsTests.swift @@ -124,7 +124,8 @@ private func freshDefaults() -> UserDefaults { let settings = Settings(defaults: defaults) settings.allowTokenRefresh = true settings.notifications = NotificationSettings( - enabled: false, thresholds: [50], notifyOnReset: false, notifyOnAuthProblems: false) + enabled: false, thresholds: [50], notifyOnReset: false, notifyOnAuthProblems: false, + notifyOnExpiringCredits: false, playSound: false) settings.detailedLogging = true settings.automaticUpdates = false settings.lastLaunchedVersion = "1.2.3" @@ -132,7 +133,9 @@ private func freshDefaults() -> UserDefaults { #expect(reloaded.allowTokenRefresh) #expect( reloaded.notifications - == NotificationSettings(enabled: false, thresholds: [50], notifyOnReset: false, notifyOnAuthProblems: false)) + == NotificationSettings( + enabled: false, thresholds: [50], notifyOnReset: false, notifyOnAuthProblems: false, + notifyOnExpiringCredits: false, playSound: false)) #expect(reloaded.detailedLogging) #expect(!reloaded.automaticUpdates) #expect(reloaded.lastLaunchedVersion == "1.2.3") diff --git a/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift b/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift index 23c190a..1fa1fb5 100644 --- a/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift +++ b/Tests/TokenMenuBarCoreTests/Support/MemoryKeychain.swift @@ -31,6 +31,9 @@ final class MemoryKeychain: @unchecked Sendable { }, save: { [self] data, service, account in lock.withLock { values[Key(service: service, account: account)] = data } + }, + list: { [self] prefix in + lock.withLock { Set(values.keys.map(\.service).filter { $0.hasPrefix(prefix) }).sorted() } }) } } diff --git a/Tests/TokenMenuBarCoreTests/Support/PowerSwitch.swift b/Tests/TokenMenuBarCoreTests/Support/PowerSwitch.swift new file mode 100644 index 0000000..b56f59e --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Support/PowerSwitch.swift @@ -0,0 +1,20 @@ +import Foundation +import TokenMenuBarCore + +final class PowerSwitch: @unchecked Sendable { + private let lock = NSLock() + private var state = PowerState(lowPowerMode: false, thermalPressure: false) + + var current: PowerState { + get { lock.withLock { state } } + set { lock.withLock { state = newValue } } + } + + func read() -> PowerState { + current + } +} + +let throttledPowerStates = [ + PowerState(lowPowerMode: true, thermalPressure: false), PowerState(lowPowerMode: false, thermalPressure: true), +] diff --git a/Tests/TokenMenuBarCoreTests/UsageColorContrastTests.swift b/Tests/TokenMenuBarCoreTests/UsageColorContrastTests.swift new file mode 100644 index 0000000..189963d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/UsageColorContrastTests.swift @@ -0,0 +1,31 @@ +import Testing +import TokenMenuBarCore + +@Test func increasedContrastLadderIsMoreSaturatedAndDarkerAtEveryStop() { + let pairs = [ + (UsageLadder.standard.green, UsageLadder.increased.green), + (UsageLadder.standard.orange, UsageLadder.increased.orange), + (UsageLadder.standard.red, UsageLadder.increased.red), + ] + for (standard, increased) in pairs { + #expect(increased.hue == standard.hue) + #expect(increased.saturation > standard.saturation) + #expect(increased.brightness < standard.brightness) + } +} + +@Test func increasedContrastColoursFollowTheSameLadderShape() { + #expect(UsageColor.color(percent: 0, contrast: .increased) == UsageLadder.increased.green) + #expect(UsageColor.color(percent: 60, contrast: .increased) == UsageLadder.increased.orange) + #expect(UsageColor.color(percent: 100, contrast: .increased) == UsageLadder.increased.red) + #expect(UsageColor.color(percent: 30, contrast: .increased) != UsageColor.color(percent: 30)) + #expect(UsageColor.color(pace: .ahead, percent: 10, contrast: .increased) == UsageLadder.increased.orange) + #expect(UsageColor.color(pace: .exhausted, percent: 10, contrast: .increased) == UsageLadder.increased.red) + #expect(UsageColor.color(pace: .onTrack, percent: 0, contrast: .increased) == UsageLadder.increased.green) +} + +@Test(arguments: [(false, UsageContrast.standard), (true, .increased)]) +func usageContrastMapsTheAccessibilityFlag(increased: Bool, expected: UsageContrast) { + #expect(UsageContrast(increased: increased) == expected) + #expect(UsageLadder.ladder(for: expected) == (increased ? UsageLadder.increased : UsageLadder.standard)) +} diff --git a/Tests/TokenMenuBarUITests/AdapterTests.swift b/Tests/TokenMenuBarUITests/AdapterTests.swift index 7259c8f..572df6f 100644 --- a/Tests/TokenMenuBarUITests/AdapterTests.swift +++ b/Tests/TokenMenuBarUITests/AdapterTests.swift @@ -82,3 +82,25 @@ func launchAtLoginServiceMapsStatuses(status: SMAppService.Status, expected: Lau await notifier.requestAuthorization() #expect(center.requestedOptions == [[.alert, .sound, .provisional], [.alert, .sound]]) } + +@Test(arguments: [true, false]) +@MainActor func notifierAttachesTheDefaultSoundOnlyWhenAsked(playSound: Bool) async { + let center = FakeNotificationCenter() + let notifier = Notifier(center: center, log: makeLog()) + await notifier.requestAuthorization() + notifier.playSound = playSound + await notifier.deliver([event(.threshold, id: "a")]) + #expect(center.requests[0].content.sound == (playSound ? UNNotificationSound.default : nil)) +} + +@Test @MainActor func notifierDropsAGroupedBannerWhenOneOfItsWindowsResets() async { + let center = FakeNotificationCenter() + let notifier = Notifier(center: center, log: makeLog()) + await notifier.requestAuthorization() + let grouped = NotificationEvent(id: "claude:thresholds", kind: .threshold, provider: .claude, title: "t", body: "b") + let other = NotificationEvent(id: "codex:thresholds", kind: .threshold, provider: .codex, title: "t", body: "b") + await notifier.deliver([grouped, other]) + await notifier.deliver([event(.reset, id: "r")]) + #expect(center.removed == ["claude:thresholds"]) + #expect(notifier.delivered.map(\.id) == ["codex:thresholds", "r"]) +} diff --git a/Tests/TokenMenuBarUITests/AppControllerTests.swift b/Tests/TokenMenuBarUITests/AppControllerTests.swift index 0516efe..5e11a3d 100644 --- a/Tests/TokenMenuBarUITests/AppControllerTests.swift +++ b/Tests/TokenMenuBarUITests/AppControllerTests.swift @@ -895,3 +895,36 @@ final class ManualClock: @unchecked Sendable { await waitUntil { dependencies.notifier.authorized } #expect(dependencies.notifier.authorized) } + +@MainActor +private final class ContrastFlag { + var value = false +} + +@Test @MainActor func appControllerFollowsTheIncreaseContrastSetting() async throws { + var (dependencies, _) = try makeDependencies() + let flag = ContrastFlag() + dependencies.increaseContrast = { flag.value } + let controller = AppController(dependencies: dependencies) + controller.start() + defer { controller.stop() } + #expect(!controller.environment.increaseContrast) + + flag.value = true + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil) + + #expect(await waitUntil { controller.environment.increaseContrast }) +} + +@Test @MainActor func appControllerDeliversNotificationsWithTheCurrentSoundSetting() async throws { + let provider = ScriptedProvider(id: .claude, result: ProviderFetchResult(outcome: .notAuthenticated("expired"))) + let (dependencies, _) = try makeDependencies(providers: [provider]) + dependencies.settings.notifications.playSound = false + let controller = AppController(dependencies: dependencies) + controller.start() + defer { controller.stop() } + + #expect(await waitUntil { dependencies.notifier.delivered.contains { $0.kind == .authentication } }) + #expect(!dependencies.notifier.playSound) +} diff --git a/Tests/TokenMenuBarUITests/ViewTests.swift b/Tests/TokenMenuBarUITests/ViewTests.swift index 7f0aaa1..e51a175 100644 --- a/Tests/TokenMenuBarUITests/ViewTests.swift +++ b/Tests/TokenMenuBarUITests/ViewTests.swift @@ -895,3 +895,25 @@ func findView(_ view: NSView) -> Wanted? { #expect(scroll.autohidesScrollers) _ = host(ScrollerStyler(), width: 10, height: 10) } + +@Test @MainActor func windowRowsOutlineBarsAndDeepenColoursWithIncreasedContrast() throws { + let card = UsagePresenter.card( + provider: .claude, state: ProviderState(snapshot: sampleSnapshot(.claude), availability: .current), samples: [:], + now: fixedNow) + let row = card.rows[0] + let standard = WindowRowView(row: row, now: fixedNow) + let contrast = WindowRowView(row: row, now: fixedNow, increaseContrast: true) + #expect(!standard.increaseContrast) + #expect(contrast.increaseContrast) + #expect(contrast.usageBarColor != standard.usageBarColor) + let deepened = UsageColor.color(pace: row.pace.status, percent: row.window.usedPercent, contrast: .increased) + #expect(contrast.usageBarColor == Color(deepened)) + #expect(inkFraction(contrast, width: 820, height: 50) > 0) + #expect(inkFraction(contrast.environment(\.dynamicTypeSize, .accessibility1), width: 548, height: 150) > 0) + let environment = try makeEnvironment() + #expect(!WindowRowView(row: row, environment: environment).increaseContrast) + environment.increaseContrast = true + #expect(WindowRowView(row: row, environment: environment).increaseContrast) + let outlined = UsageBar(percent: 40, color: .green, label: "Session", outlined: true) + #expect(inkFraction(outlined, width: 200, height: 10) > 0) +} diff --git a/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift b/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift index 8b66855..c6884e8 100644 --- a/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift +++ b/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift @@ -84,6 +84,16 @@ private let populatedEntry = UsageEntry( _ = UsageWidget().body } +@Test @MainActor func widgetRowsOutlineBarsAndDeepenColoursWithIncreasedContrast() { + let row = WidgetSnapshot.placeholder.rows[0] + let standard = WidgetRowView(row: row, now: fixedNow, compact: false) + let contrast = WidgetRowView(row: row, now: fixedNow, compact: false, increaseContrast: true) + #expect(contrast.color != standard.color) + #expect(inkFraction(contrast, width: 300, height: 60) > 0) + let widget = UsageWidgetView(entry: populatedEntry, family: .systemMedium, increaseContrast: true) + #expect(inkFraction(widget, width: 300, height: 300) > 0) +} + @Test @MainActor func widgetViewRendersTheUnavailableInstruction() { let entry = UsageEntry(date: fixedNow, snapshot: .unavailable) diff --git a/website/content/explanation/_index.md b/website/content/explanation/_index.md index 08573c6..c2644d2 100644 --- a/website/content/explanation/_index.md +++ b/website/content/explanation/_index.md @@ -28,6 +28,12 @@ from shell startup files such as `.zshrc`; Settings > Providers shows the source The App Store widget uses the `group.dev.tox.token-menu-bar` app group. Direct and Homebrew builds use `.dev.tox.token-menu-bar`; the channels do not share widget snapshots. +## Scope + +The app tracks these five providers on purpose: they are the ones it can read locally. A provider is added only when a +local credential and an official usage endpoint exist for it, so it never asks for a browser session or scrapes a +dashboard; Antigravity is the next candidate. + ## Token refresh Claude, Codex, and Gemini rotate their [OAuth refresh token](https://datatracker.ietf.org/doc/html/rfc6749#section-6) diff --git a/website/content/reference/settings.md b/website/content/reference/settings.md index 46151d0..a6b97dd 100644 --- a/website/content/reference/settings.md +++ b/website/content/reference/settings.md @@ -67,7 +67,9 @@ How often the app fetches analytics, where the history database lives, and butto ## Notifications -Threshold notifications at 50/75/90/100%, window-reset notifications, and sign-in alerts. +Threshold notifications at 50/75/90/100%, window-reset notifications, sign-in alerts, a warning when a Codex reset +credit expires within 24 hours, and a sound switch. Several windows crossing a threshold in one refresh arrive as one +grouped notice. ## Log From d8ec58f3dce9419133d276f394de896abb0f9c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 7 Sep 2026 19:38:25 -0700 Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=A8=20feat(app):=20track=20Antigravit?= =?UTF-8?q?y=20and=20extend=20the=20usage=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google stopped serving quota to personal Gemini CLI accounts on 18 June, so the provider that shipped as one of five was dead for most users while the rival apps had already moved to Antigravity. Antigravity now reads the token Antigravity and the agy CLI keep in the Keychain, asks the local language server when the IDE or CLI is running, and falls back to the Cloud Code endpoint otherwise, reporting the shared Gemini pool and the Claude and GPT pool over their five-hour and weekly windows. Gemini stays for Workspace and Code Assist accounts. Providers added since a user last launched are appended to their order and enabled, so the new card appears without resetting anything. The rest closes the gaps that every comparable app had and this one did not. Percentages can read as used or as remaining everywhere, the menu bar gains a countdown preset and one that switches to the countdown at 100%, and pace now warns before a window runs out or runs ahead, with hysteresis so a recovering window re-arms. A spend row across the providers shows today, yesterday and thirty days with the model breakdown behind the hover, history gains per project cost and messages from the transcripts, and the pace of windows longer than a day can be spread over a working week instead of seven even days. Scripts get the same numbers as the app: --usage-json prints the last cached snapshots and an optional usage.json is written beside the widget file after every cycle. Polling doubles its interval on low power or thermal pressure and triples it after an hour without the popover, and a first-run card names the providers it detected. Account details can be hidden from the popover, tooltip and diagnostics for screen sharing. Claude-Session: https://claude.ai/code/session_01CGckDFXvufWVmRH4uyE4z8 --- Casks/token-menu-bar.rb | 2 +- README.md | 36 +- Scripts/coverage.sh | 1 + Sources/TokenMenuBar/main.swift | 7 +- .../Credentials/AntigravityCredentials.swift | 97 ++++ .../Credentials/CredentialState.swift | 2 +- Sources/TokenMenuBarCore/Demo/DemoData.swift | 26 +- Sources/TokenMenuBarCore/Diagnostics.swift | 9 +- Sources/TokenMenuBarCore/ExportCommand.swift | 36 ++ Sources/TokenMenuBarCore/Formatting.swift | 17 + Sources/TokenMenuBarCore/HTTP/APIClient.swift | 10 + .../HTTP/SystemHTTPTransport.swift | 16 +- .../History/HistoryModels.swift | 8 +- .../TokenMenuBarCore/Models/Analytics.swift | 6 +- .../Notifications/NotificationPlanner.swift | 56 ++- Sources/TokenMenuBarCore/Pace.swift | 37 +- .../Presenters/SpendSummaryPresenter.swift | 134 ++++++ .../Presenters/UsagePresenter.swift | 52 ++- Sources/TokenMenuBarCore/ProviderID.swift | 5 + Sources/TokenMenuBarCore/ProviderSetup.swift | 10 + .../Antigravity/AntigravityAPI.swift | 140 ++++++ .../Antigravity/AntigravityOAuthConfig.swift | 75 +++ .../Antigravity/AntigravityProvider.swift | 234 ++++++++++ .../Antigravity/LibprocProcessScanner.swift | 42 ++ .../Antigravity/ProcessScanner.swift | 99 ++++ .../Claude/ClaudeTranscriptReader.swift | 67 ++- .../Providers/ProviderRegistryFactory.swift | 19 +- .../Providers/UsageProvider.swift | 2 +- .../Refresh/RefreshCoordinator.swift | 34 +- .../Refresh/SnapshotPersistence.swift | 73 ++- .../Refresh/UsageExport.swift | 60 +++ Sources/TokenMenuBarCore/SandboxAccess.swift | 2 + Sources/TokenMenuBarCore/Settings.swift | 47 +- .../SettingsPresentation.swift | 5 +- .../StatusBar/StatusItemModel.swift | 24 +- .../StatusBar/StatusTemplate.swift | 47 +- .../TokenMenuBarCore/UsagePresentation.swift | 34 ++ .../Widgets/WidgetSnapshot.swift | 24 +- Sources/TokenMenuBarUI/AppController.swift | 15 + Sources/TokenMenuBarUI/AppIcon.swift | 2 + Sources/TokenMenuBarUI/ExportRunner.swift | 13 + Sources/TokenMenuBarUI/LiveDependencies.swift | 3 +- .../ProviderMarks/ProviderMarkCatalog.swift | 5 + .../ProviderMarks/provider-marks.json | 11 +- Sources/TokenMenuBarUI/UIActions.swift | 8 +- .../TokenMenuBarUI/Views/SettingsTab.swift | 76 +++- Sources/TokenMenuBarUI/Views/SpendView.swift | 43 ++ Sources/TokenMenuBarUI/Views/UsageTab.swift | 98 +++- Sources/TokenMenuBarWidgets/UsageWidget.swift | 12 +- .../AdaptiveRefreshTests.swift | 121 +++++ .../AntigravityCredentialsTests.swift | 103 +++++ .../AntigravityOAuthConfigTests.swift | 57 +++ .../AntigravityProcessScanTests.swift | 88 ++++ .../AntigravityTests.swift | 430 ++++++++++++++++++ .../ChartPipelineTests.swift | 5 +- .../ClaudeProjectAttributionTests.swift | 128 ++++++ .../CountdownPresetTests.swift | 64 +++ .../Fixtures/antigravity_cloud_quota.json | 43 ++ .../antigravity_cloud_quota_free.json | 28 ++ .../Fixtures/antigravity_keychain_token.json | 8 + .../antigravity_load_code_assist.json | 4 + .../Fixtures/antigravity_local_quota.json | 55 +++ .../Fixtures/antigravity_user_status.json | 8 + .../HidePersonalInformationTests.swift | 90 ++++ .../NotificationPlannerTests.swift | 6 +- .../PaceNotificationTests.swift | 107 +++++ .../PaceWorkdayTests.swift | 79 ++++ .../PresentationSettingsTests.swift | 49 ++ .../ProviderRegistryFactoryTests.swift | 66 +++ .../TokenMenuBarCoreTests/SettingsTests.swift | 15 + .../SpendSummaryPresenterTests.swift | 99 ++++ .../StatusTemplateTests.swift | 2 +- .../Support/MemoryCredentialStore.swift | 5 + .../Support/StubProcessScanner.swift | 23 + .../UsageDisplayTests.swift | 108 +++++ .../UsageExportTests.swift | 152 +++++++ .../WidgetSnapshotTests.swift | 2 +- .../AppControllerTests.swift | 9 +- .../DisplaySettingsUITests.swift | 132 ++++++ .../ProviderMarkTests.swift | 19 +- .../ResponsiveLayoutTests.swift | 2 +- .../SpendAndFirstRunTests.swift | 200 ++++++++ .../StartupCoverageTests.swift | 4 +- .../TokenMenuBarUITests/StatusItemTests.swift | 9 +- .../WidgetTests.swift | 16 + website/content/explanation/_index.md | 11 +- website/content/reference/interface.md | 44 +- website/content/reference/settings.md | 44 +- website/content/start/_index.md | 15 +- website/content/troubleshooting/_index.md | 43 +- website/static/brand/glyph/antigravity.svg | 4 + 91 files changed, 4182 insertions(+), 196 deletions(-) create mode 100644 Sources/TokenMenuBarCore/Credentials/AntigravityCredentials.swift create mode 100644 Sources/TokenMenuBarCore/Presenters/SpendSummaryPresenter.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityAPI.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityOAuthConfig.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityProvider.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Antigravity/LibprocProcessScanner.swift create mode 100644 Sources/TokenMenuBarCore/Providers/Antigravity/ProcessScanner.swift create mode 100644 Sources/TokenMenuBarCore/Refresh/UsageExport.swift create mode 100644 Tests/TokenMenuBarCoreTests/AdaptiveRefreshTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/AntigravityCredentialsTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/AntigravityOAuthConfigTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/AntigravityProcessScanTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/AntigravityTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/ClaudeProjectAttributionTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/CountdownPresetTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/antigravity_cloud_quota.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/antigravity_cloud_quota_free.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/antigravity_keychain_token.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/antigravity_load_code_assist.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/antigravity_local_quota.json create mode 100644 Tests/TokenMenuBarCoreTests/Fixtures/antigravity_user_status.json create mode 100644 Tests/TokenMenuBarCoreTests/HidePersonalInformationTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/PaceNotificationTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/PaceWorkdayTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/PresentationSettingsTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/SpendSummaryPresenterTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/Support/StubProcessScanner.swift create mode 100644 Tests/TokenMenuBarCoreTests/UsageDisplayTests.swift create mode 100644 Tests/TokenMenuBarCoreTests/UsageExportTests.swift create mode 100644 Tests/TokenMenuBarUITests/DisplaySettingsUITests.swift create mode 100644 Tests/TokenMenuBarUITests/SpendAndFirstRunTests.swift create mode 100644 website/static/brand/glyph/antigravity.svg diff --git a/Casks/token-menu-bar.rb b/Casks/token-menu-bar.rb index aeb0d3f..c15aa16 100644 --- a/Casks/token-menu-bar.rb +++ b/Casks/token-menu-bar.rb @@ -4,7 +4,7 @@ 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" + desc "Menu bar monitor for Claude, Codex, Gemini, Antigravity, Cursor and Copilot usage limits" homepage "https://github.com/tox-dev/token-menu-bar-macos" livecheck do diff --git a/README.md b/README.md index 7ccf9be..e637a4b 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,31 @@ # 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. +A macOS menu bar app that shows how much of your Claude (Pro/Max), OpenAI Codex (Plus/Pro), Gemini CLI, Google +Antigravity, 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. +It reads the tokens the `claude`, `codex`, `gemini`, Antigravity, 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 | +| Provider | Reads | Windows | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| Claude | Keychain, `~/.claude` | session, weekly | +| Codex | `~/.codex` | 5-hour, weekly | +| Gemini | `~/.gemini` | daily per model; personal accounts stopped reporting on 2026-06-18 | +| Antigravity | Keychain (Antigravity), local language server when the IDE or `agy` runs | 5-hour, weekly | +| Cursor | Cursor app, `~/.cursor` | plan, spend | +| Copilot | `~/.config/github-copilot` | premium | Requires macOS 14 or later on Apple silicon or Intel, and one signed-in client. -The app tracks these five providers on purpose: they are the ones it can read locally. A provider is added only when a -local credential and an official usage endpoint exist for it; Antigravity is the next candidate. Gemini CLI personal -accounts stopped reporting quota on 2026-06-18, so the Gemini card shows an unsupported-account notice for them; -Workspace and Code Assist accounts still work. +The app tracks these six providers on purpose: they are the ones it can read locally. A provider is added only when a +local credential and an official usage endpoint exist for it. Antigravity reads the quota its language server reports +while the IDE or `agy` runs and asks Cloud Code with the Keychain token otherwise. Gemini CLI personal accounts stopped +reporting quota on 2026-06-18, so the Gemini card shows an unsupported-account notice for them; Workspace and Code +Assist accounts still work. - [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 diff --git a/Scripts/coverage.sh b/Scripts/coverage.sh index 4bcc7cb..a80a29a 100755 --- a/Scripts/coverage.sh +++ b/Scripts/coverage.sh @@ -32,6 +32,7 @@ glue=( Sources/TokenMenuBarUI/Adapters/NotificationCenterGlue.swift Sources/TokenMenuBarCore/Credentials/SystemKeychain.swift Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift + Sources/TokenMenuBarCore/Providers/Antigravity/LibprocProcessScanner.swift Sources/TokenMenuBarWidgets/WidgetKitGlue.swift App/Sources/SparkleUpdater.swift App/Widget/Sources/WidgetBundle.swift ) diff --git a/Sources/TokenMenuBar/main.swift b/Sources/TokenMenuBar/main.swift index 1470eca..18ad784 100644 --- a/Sources/TokenMenuBar/main.swift +++ b/Sources/TokenMenuBar/main.swift @@ -4,13 +4,12 @@ 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) { +if let invocation = ExportInvocation.parse(CommandLine.arguments) { do { - let written = try await ExportRunner.run(command, directory: directory) - print("wrote \(written.count) files to \(directory.path)") + print(try await ExportRunner.run(invocation)) exit(0) } catch { - FileHandle.standardError.write(Data("\(command.failureMessage): \(error)\n".utf8)) + FileHandle.standardError.write(Data("\(invocation.failureMessage): \(error)\n".utf8)) exit(1) } } diff --git a/Sources/TokenMenuBarCore/Credentials/AntigravityCredentials.swift b/Sources/TokenMenuBarCore/Credentials/AntigravityCredentials.swift new file mode 100644 index 0000000..b3f1f1d --- /dev/null +++ b/Sources/TokenMenuBarCore/Credentials/AntigravityCredentials.swift @@ -0,0 +1,97 @@ +import Foundation + +public struct AntigravityAuth: Sendable, Equatable { + public static let keychainPrefix = "go-keyring-base64:" + + public let accessToken: String + public let refreshToken: String? + public let expiresAt: Date? + + public init(accessToken: String, refreshToken: String? = nil, expiresAt: Date? = nil) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresAt = expiresAt + } + + /// Antigravity nests the token under `token`; `agy` has written the same fields at the root, in either case style. + public init?(document: JSONValue) { + let fields = document["token"] ?? document + guard let accessToken = fields["access_token"]?.stringValue ?? fields["accessToken"]?.stringValue else { + return nil + } + self.init( + accessToken: accessToken, + refreshToken: fields["refresh_token"]?.stringValue ?? fields["refreshToken"]?.stringValue, + expiresAt: Self.expiry(fields["expiry"] ?? fields["expires_at"] ?? fields["expiresAt"])) + } + + static func expiry(_ value: JSONValue?) -> Date? { + if let seconds = value?.doubleValue { return Date(timeIntervalSince1970: seconds) } + return ISODate.parse(value?.stringValue) + } + + public static func parse(keychainValue data: Data) throws -> AntigravityAuth? { + var text = String(decoding: data, as: UTF8.self) + if text.hasPrefix(keychainPrefix) { + guard + let decoded = Data( + base64Encoded: String(text.dropFirst(keychainPrefix.count)), options: .ignoreUnknownCharacters) + else { throw CredentialStoreError.malformed("Antigravity Keychain item is not base64") } + text = String(decoding: decoded, as: UTF8.self) + } + guard let document = try? JSONDecoder().decode(JSONValue.self, from: Data(text.utf8)) else { + throw CredentialStoreError.malformed("Antigravity Keychain item is not JSON") + } + return AntigravityAuth(document: document) + } + + public func state(now: Date) -> CredentialState { + CredentialState.from(expiresAt: expiresAt, now: now) + } + + public func refreshed(accessToken: String, expiresIn: TimeInterval, now: Date) -> AntigravityAuth { + AntigravityAuth(accessToken: accessToken, refreshToken: refreshToken, expiresAt: now.addingTimeInterval(expiresIn)) + } +} + +/// Antigravity owns its Keychain item, so the store only reads; a refreshed access token stays in the provider. +public protocol AntigravityAuthStore: Sendable { + func load() throws -> AntigravityAuth? + var description: String { get } + var source: CredentialSource { get } +} + +extension AntigravityAuthStore { + public var source: CredentialSource { + CredentialSource( + id: "antigravity.custom", provider: .antigravity, title: "Antigravity credentials", detail: description) + } + + public func credentialHealth(now: Date) -> ProviderCredentialHealth { + let expected = ProviderID.antigravity.setup.credentialSources + do { + guard let auth = try load() else { return .missing(expected: expected) } + return .from(auth.state(now: now), source: source, expected: expected) + } catch { + return .from(readError: error, fallbackSource: source) + } + } +} + +public struct KeychainAntigravityAuthStore: AntigravityAuthStore { + public static let service = "gemini" + public static let account = "antigravity" + private let keychain: KeychainCredentialClient + + public init(keychain: KeychainCredentialClient) { + self.keychain = keychain + } + + public var description: String { "Keychain item \(Self.service) (\(Self.account))" } + public var source: CredentialSource { ProviderID.antigravity.credentialSource("antigravity.keychain") } + + public func load() throws -> AntigravityAuth? { + guard let item = try keychain.load(service: Self.service, account: Self.account) else { return nil } + return try AntigravityAuth.parse(keychainValue: item.data) + } +} diff --git a/Sources/TokenMenuBarCore/Credentials/CredentialState.swift b/Sources/TokenMenuBarCore/Credentials/CredentialState.swift index 3f3d531..195cb7f 100644 --- a/Sources/TokenMenuBarCore/Credentials/CredentialState.swift +++ b/Sources/TokenMenuBarCore/Credentials/CredentialState.swift @@ -86,7 +86,7 @@ enum CredentialRefreshError: Error { case .api(let error): error.message case .missingRefreshToken: "the credential has no refresh token" case .credentialsRemoved: "the credentials were removed during refresh" - case .oauthClientUnavailable: "the Gemini CLI OAuth client could not be read from the installed CLI" + case .oauthClientUnavailable: "the OAuth client could not be read from the installed Google client" case .invalidResponse(let detail): detail case .signInRequired(let code): "the provider rejected the stored sign-in (\(code)). Sign in with the CLI again." } diff --git a/Sources/TokenMenuBarCore/Demo/DemoData.swift b/Sources/TokenMenuBarCore/Demo/DemoData.swift index 863cd7b..2765bfd 100644 --- a/Sources/TokenMenuBarCore/Demo/DemoData.swift +++ b/Sources/TokenMenuBarCore/Demo/DemoData.swift @@ -13,6 +13,7 @@ public enum DemoData { case .claude: claude(now: now) case .codex: codex(now: now) case .gemini: gemini(now: now) + case .antigravity: antigravity(now: now) case .cursor: cursor(now: now) case .copilot: copilot(now: now) } @@ -63,7 +64,7 @@ public enum DemoData { AnalyticsPoint(day: day, metric: .pluginInvocations, series: "github", value: (3 * activity).rounded())) points.append( AnalyticsPoint(day: day, metric: .codeReviews, series: "reviews", value: (2 * activity).rounded())) - case .gemini, .cursor, .copilot: break + case .gemini, .antigravity, .cursor, .copilot: break } } if fixture == .longText { @@ -147,6 +148,25 @@ public enum DemoData { credits: CreditBalance(balance: 1500, hasCredits: true), fetchedAt: now) } + static func antigravity(now: Date) -> ProviderSnapshot { + let geminiSession = window( + id: "gemini:session", label: "Gemini 5-hour", group: .session, duration: 5 * 3600, offset: 0, pace: 0.75, now: now + ) + let geminiWeekly = window( + id: "gemini:weekly", label: "Gemini weekly", group: .weekly, duration: 7 * 86400, offset: 4 * 86400 + 12 * 3600, + pace: 0.6, now: now) + let thirdPartySession = window( + id: "3p:session", label: "Claude and GPT 5-hour", group: .session, duration: 5 * 3600, offset: 0, pace: 1.05, + now: now) + let thirdPartyWeekly = window( + id: "3p:weekly", label: "Claude and GPT weekly", group: .weekly, duration: 7 * 86400, + offset: 4 * 86400 + 12 * 3600, pace: 0.85, now: now) + return ProviderSnapshot( + provider: .antigravity, + identity: ProviderIdentity(planName: "Google AI Ultra", tier: "google_ai_ultra", email: email), + windows: [geminiSession, geminiWeekly, thirdPartySession, thirdPartyWeekly], 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) @@ -316,6 +336,10 @@ public struct DemoProvider: UsageProvider { ProviderRecoveryIssue( kind: .network, title: "Gemini check required", detail: "Check the isolated verification provider again.", action: .checkAgain) + case .antigravity: + ProviderRecoveryIssue( + kind: .credentialMissing, title: "Antigravity sign-in required", + detail: "Run the deterministic verification command.", action: .copyCommand("agy")) case .cursor: ProviderRecoveryIssue( kind: .service, title: "Cursor check required", diff --git a/Sources/TokenMenuBarCore/Diagnostics.swift b/Sources/TokenMenuBarCore/Diagnostics.swift index 2a58ac7..0b98432 100644 --- a/Sources/TokenMenuBarCore/Diagnostics.swift +++ b/Sources/TokenMenuBarCore/Diagnostics.swift @@ -129,7 +129,14 @@ public enum Diagnostics { out.append("") out.append("Log (last \(lines) lines):") out += log.tail(lines).map(\.line) - return LogSanitizer.redact(out.joined(separator: "\n")) + var report = out.joined(separator: "\n") + if settings.hidePersonalInformation { + let identities = ProviderID.allCases.compactMap { state.state(for: $0).snapshot?.identity } + for (value, replacement) in identities.flatMap(\.personalReplacements) { + report = report.replacingOccurrences(of: value, with: replacement) + } + } + return LogSanitizer.redact(report) } static func windowSummary(_ state: ProviderState) -> String { diff --git a/Sources/TokenMenuBarCore/ExportCommand.swift b/Sources/TokenMenuBarCore/ExportCommand.swift index 6f80a5f..f2b9430 100644 --- a/Sources/TokenMenuBarCore/ExportCommand.swift +++ b/Sources/TokenMenuBarCore/ExportCommand.swift @@ -21,3 +21,39 @@ public enum ExportCommand: String, CaseIterable, Sendable { } } } + +/// Everything the executable does instead of starting the app: the image exports and the usage dump for scripts. +public enum ExportInvocation: Sendable, Equatable { + case files(ExportCommand, directory: URL) + case usageJSON + + public static let usageJSONFlag = "--usage-json" + + public static func parse(_ arguments: [String]) -> ExportInvocation? { + if arguments.contains(usageJSONFlag) { return .usageJSON } + return ExportCommand.parse(arguments).map { .files($0.command, directory: $0.directory) } + } + + public var failureMessage: String { + switch self { + case .files(let command, _): command.failureMessage + case .usageJSON: "usage export failed" + } + } +} + +public enum UsageExportCommand { + public struct NoCachedUsage: Error, CustomStringConvertible, Equatable { + public init() {} + + public var description: String { + "no cached usage yet; launch the app and let it finish one refresh" + } + } + + public static func output(cache: SnapshotCache) throws -> String { + let snapshots = try cache.read() + guard !snapshots.isEmpty else { throw NoCachedUsage() } + return String(decoding: try UsageExport(snapshots: snapshots).json(), as: UTF8.self) + } +} diff --git a/Sources/TokenMenuBarCore/Formatting.swift b/Sources/TokenMenuBarCore/Formatting.swift index 146d6d1..4eb27b9 100644 --- a/Sources/TokenMenuBarCore/Formatting.swift +++ b/Sources/TokenMenuBarCore/Formatting.swift @@ -5,6 +5,19 @@ public enum ResetPrecision: String, Codable, Sendable, Hashable { case day } +public enum UsageDisplay: String, CaseIterable, Codable, Sendable { + case used = "Used" + case remaining = "Left" + + public func percent(used: Double) -> Double { + self == .used ? used : 100 - used + } + + public var suffix: String { + self == .used ? "used" : "left" + } +} + 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 @@ -15,6 +28,10 @@ public enum Format { min(max(value, 0), 100).formatted(.number.precision(.fractionLength(decimals))) + "%" } + public static func percent(used: Double, display: UsageDisplay, decimals: Int = 0) -> String { + percent(display.percent(used: used), decimals: decimals) + } + public static func countdown(to date: Date?, now: Date) -> String { guard let date else { return "—" } let seconds = date.timeIntervalSince(now) diff --git a/Sources/TokenMenuBarCore/HTTP/APIClient.swift b/Sources/TokenMenuBarCore/HTTP/APIClient.swift index 42004e4..aec15cd 100644 --- a/Sources/TokenMenuBarCore/HTTP/APIClient.swift +++ b/Sources/TokenMenuBarCore/HTTP/APIClient.swift @@ -14,6 +14,16 @@ public struct DisabledHTTPTransport: HTTPTransport { } } +/// Antigravity's language server answers on the loopback interface with a self-signed certificate. Trust is extended +/// to that interface alone; every other host stays on the system trust store. +public enum LoopbackTrust { + public static let hosts: Set = ["127.0.0.1", "localhost", "::1"] + + public static func accepts(host: String, authenticationMethod: String) -> Bool { + authenticationMethod == NSURLAuthenticationMethodServerTrust && hosts.contains(host) + } +} + public enum APIError: Error, Equatable, Sendable { case http(status: Int, body: String, retryAfter: TimeInterval?) case vendorSecurityCheck(status: Int) diff --git a/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift b/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift index ae78320..c1ccf04 100644 --- a/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift +++ b/Sources/TokenMenuBarCore/HTTP/SystemHTTPTransport.swift @@ -2,16 +2,26 @@ import Foundation public enum SystemHTTPTransport { public static func make() -> any HTTPTransport { - URLSession( - configuration: APIClient.liveConfiguration(), delegate: SameOriginRedirectDelegate(), delegateQueue: nil) + URLSession(configuration: APIClient.liveConfiguration(), delegate: LiveSessionDelegate(), delegateQueue: nil) } } -private final class SameOriginRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { +private final class LiveSessionDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { func urlSession( _: URLSession, task _: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void ) { completionHandler(APIClient.redirectedRequest(from: response.url, to: request)) } + + func urlSession( + _: URLSession, didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + let space = challenge.protectionSpace + guard LoopbackTrust.accepts(host: space.host, authenticationMethod: space.authenticationMethod), + let trust = space.serverTrust + else { return completionHandler(.performDefaultHandling, nil) } + completionHandler(.useCredential, URLCredential(trust: trust)) + } } diff --git a/Sources/TokenMenuBarCore/History/HistoryModels.swift b/Sources/TokenMenuBarCore/History/HistoryModels.swift index 54f9ea2..4395983 100644 --- a/Sources/TokenMenuBarCore/History/HistoryModels.swift +++ b/Sources/TokenMenuBarCore/History/HistoryModels.swift @@ -5,6 +5,7 @@ public enum HistoryMetricGroup: String, CaseIterable, Sendable { case bothProviders = "Claude and Codex" case claude = "Claude" case codex = "Codex" + case projects = "Projects" } public enum HistoryMarkKind: Sendable, Hashable { @@ -69,6 +70,7 @@ public enum HistoryMetric: Sendable, Hashable, Identifiable { case .analytics(.cacheWriteTokens), .analytics(.costUSD), .analytics(.messages), .analytics(.sessions), .analytics(.toolCalls): .claude + case .analytics(.projectCost), .analytics(.projectMessages): .projects case .analytics: .codex } } @@ -77,7 +79,7 @@ public enum HistoryMetric: Sendable, Hashable, Identifiable { switch group { case .windows: ProviderID.allCases.sorted() case .bothProviders: [.claude, .codex] - case .claude: [.claude] + case .claude, .projects: [.claude] case .codex: [.codex] } } @@ -97,7 +99,7 @@ public enum HistoryMetric: Sendable, Hashable, Identifiable { .analytics(.cacheWriteTokens): .tokens case .analytics(.modelCredits), .analytics(.credits): .credits - case .analytics(.costUSD): .usd + case .analytics(.costUSD), .analytics(.projectCost): .usd case .analytics: .count } } @@ -147,6 +149,8 @@ public enum HistoryMetric: Sendable, Hashable, Identifiable { "Claude · by model · daily UTC" case .analytics(.messages), .analytics(.sessions), .analytics(.toolCalls): "Claude · one series · daily UTC" + case .analytics(.projectCost), .analytics(.projectMessages): + "Claude · by project · daily UTC" } } diff --git a/Sources/TokenMenuBarCore/Models/Analytics.swift b/Sources/TokenMenuBarCore/Models/Analytics.swift index 400a7d5..92183d9 100644 --- a/Sources/TokenMenuBarCore/Models/Analytics.swift +++ b/Sources/TokenMenuBarCore/Models/Analytics.swift @@ -17,6 +17,8 @@ public enum AnalyticsMetric: String, Codable, CaseIterable, Sendable, Hashable { case messages case sessions case toolCalls + case projectCost + case projectMessages public var title: String { switch self { @@ -36,6 +38,8 @@ public enum AnalyticsMetric: String, Codable, CaseIterable, Sendable, Hashable { case .messages: "Messages" case .sessions: "Sessions" case .toolCalls: "Tool calls" + case .projectCost: "Cost by project" + case .projectMessages: "Messages by project" } } @@ -44,7 +48,7 @@ public enum AnalyticsMetric: String, Codable, CaseIterable, Sendable, Hashable { case .surfaceUsagePercent: "%" case .modelCredits, .credits: "credits" case .inputTokens, .cachedInputTokens, .outputTokens, .cacheWriteTokens: "tokens" - case .costUSD: "USD" + case .costUSD, .projectCost: "USD" default: "count" } } diff --git a/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift b/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift index 756b26d..b370dad 100644 --- a/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift +++ b/Sources/TokenMenuBarCore/Notifications/NotificationPlanner.swift @@ -8,17 +8,20 @@ public struct NotificationSettings: Sendable, Equatable, Codable { public var notifyOnReset: Bool public var notifyOnAuthProblems: Bool public var notifyOnExpiringCredits: Bool + public var notifyOnPace: Bool public var playSound: Bool public init( enabled: Bool = true, thresholds: [Int] = defaultThresholds, notifyOnReset: Bool = true, - notifyOnAuthProblems: Bool = true, notifyOnExpiringCredits: Bool = true, playSound: Bool = true + notifyOnAuthProblems: Bool = true, notifyOnExpiringCredits: Bool = true, notifyOnPace: Bool = true, + playSound: Bool = true ) { self.enabled = enabled self.thresholds = thresholds.filter { (1...100).contains($0) }.sorted() self.notifyOnReset = notifyOnReset self.notifyOnAuthProblems = notifyOnAuthProblems self.notifyOnExpiringCredits = notifyOnExpiringCredits + self.notifyOnPace = notifyOnPace self.playSound = playSound } @@ -31,6 +34,7 @@ public struct NotificationSettings: Sendable, Equatable, Codable { notifyOnReset: try container.decode(Bool.self, forKey: .notifyOnReset), notifyOnAuthProblems: try container.decode(Bool.self, forKey: .notifyOnAuthProblems), notifyOnExpiringCredits: try container.decodeIfPresent(Bool.self, forKey: .notifyOnExpiringCredits) ?? true, + notifyOnPace: try container.decodeIfPresent(Bool.self, forKey: .notifyOnPace) ?? true, playSound: try container.decodeIfPresent(Bool.self, forKey: .playSound) ?? true) } } @@ -42,6 +46,8 @@ public struct NotificationEvent: Sendable, Hashable, Identifiable { case authentication case credits case expiringCredit + case willRunOut + case aheadOfPace } public let id: String @@ -74,6 +80,7 @@ public enum NotificationPlanner { provider: ProviderID, settings: NotificationSettings, credentialMissing: Bool = false, + workdays: Int = PaceEstimate.workdayRange.upperBound, now: Date ) -> [NotificationEvent] { guard settings.enabled else { return [] } @@ -90,6 +97,9 @@ public enum NotificationPlanner { if let previous { events += thresholdEvents(previous: previous, current: current, settings: settings) if settings.notifyOnReset { events += resetEvents(previous: previous, current: current, settings: settings) } + if settings.notifyOnPace { + events += paceEvents(previous: previous, current: current, workdays: workdays, now: now) + } if previous.credits?.hasCredits == true, current.credits?.hasCredits == false { events.append( NotificationEvent( @@ -165,6 +175,49 @@ public enum NotificationPlanner { } } + /// Each notice fires when its condition first holds and arms again only after a poll on which it did not, so a + /// window hovering around the pace line does not announce itself on every refresh. + static func paceEvents( + previous: ProviderSnapshot, current: ProviderSnapshot, workdays: Int, now: Date + ) -> [NotificationEvent] { + current.windows.flatMap { window -> [NotificationEvent] in + guard let before = previous.window(window.id), let resetsAt = window.resetsAt else { return [] } + let pace = PaceEstimate.estimate(window: window, workdays: workdays, now: now) + let earlier = + window.hasReset(since: before) + ? nil : PaceEstimate.estimate(window: before, workdays: workdays, now: previous.fetchedAt) + let key = WindowKey(current.provider, window) + let epoch = Int(resetsAt.timeIntervalSince1970) + let name = "\(current.provider.displayName) \(window.label)" + var events: [NotificationEvent] = [] + if let projected = runsOut(pace), earlier.flatMap(runsOut) == nil { + events.append( + NotificationEvent( + id: "\(current.provider.rawValue):\(window.id):pace-runout:\(epoch)", kind: .willRunOut, + provider: current.provider, window: key, + title: "\(name) will run out in \(remaining(until: projected, now: now))", + body: "At this pace it hits 100% before the reset in \(remaining(until: resetsAt, now: now)).")) + } + if pace.status == .ahead, earlier?.status != .ahead { + events.append( + NotificationEvent( + id: "\(current.provider.rawValue):\(window.id):pace-ahead:\(epoch)", kind: .aheadOfPace, + provider: current.provider, window: key, + title: "\(name) is ahead of pace", + body: "\(Format.percent(window.usedPercent)) used with \(Format.countdown(to: resetsAt, now: now)) left.")) + } + return events + } + } + + /// The projection is meaningful only once the window is past its learning phase and not already exhausted. + private static func runsOut(_ pace: PaceEstimate) -> Date? { + switch pace.status { + case .unknown, .exhausted: nil + case .onTrack, .ahead, .behind: pace.projectedExhaustion + } + } + static func expiringCreditEvents( previous: ProviderSnapshot?, current: ProviderSnapshot, now: Date ) -> [NotificationEvent] { @@ -188,6 +241,7 @@ public enum NotificationPlanner { private static func remaining(until date: Date, now: Date) -> String { let seconds = date.timeIntervalSince(now) + if seconds >= 86400 { return "\(Int(seconds / 86400)) d" } return seconds >= 3600 ? "\(Int(seconds / 3600)) h" : "\(max(Int(seconds / 60), 1)) min" } } diff --git a/Sources/TokenMenuBarCore/Pace.swift b/Sources/TokenMenuBarCore/Pace.swift index 33d35c4..5b4a284 100644 --- a/Sources/TokenMenuBarCore/Pace.swift +++ b/Sources/TokenMenuBarCore/Pace.swift @@ -23,6 +23,7 @@ public struct PaceEstimate: Sendable, Hashable { public static let behindRatio = 0.75 public static let minimumElapsedPercent = 5.0 public static let slopeWindow: TimeInterval = 3600 + public static let workdayRange = 4...7 public let status: PaceStatus public let expectedPercent: Double? @@ -36,7 +37,10 @@ public struct PaceEstimate: Sendable, Hashable { self.projectedExhaustion = projectedExhaustion } - public static func estimate(window: QuotaWindow, samples: [UsageSample] = [], now: Date) -> PaceEstimate { + public static func estimate( + window: QuotaWindow, samples: [UsageSample] = [], workdays: Int = workdayRange.upperBound, + calendar: Calendar = Format.calendar, now: Date + ) -> PaceEstimate { if window.usedPercent >= 100 { return PaceEstimate(status: .exhausted, expectedPercent: nil, ratio: nil, projectedExhaustion: now) } @@ -44,7 +48,7 @@ public struct PaceEstimate: Sendable, Hashable { 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 expected = expectedPercent(start: start, resetsAt: resetsAt, workdays: workdays, calendar: calendar, now: now) 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) @@ -54,6 +58,35 @@ public struct PaceEstimate: Sendable, Hashable { return PaceEstimate(status: status, expectedPercent: expected, ratio: ratio, projectedExhaustion: projection) } + /// Windows longer than a day expect usage only on the configured workdays, so a weekly window that opened over the + /// weekend is not ahead of pace by Monday morning. + static func expectedPercent(start: Date, resetsAt: Date, workdays: Int, calendar: Calendar, now: Date) -> Double { + let duration = resetsAt.timeIntervalSince(start) + let linear = min(max(now.timeIntervalSince(start) / duration, 0), 1) * 100 + guard workdays < workdayRange.upperBound, duration > 86400 else { return linear } + let total = workdaySeconds(from: start, to: resetsAt, workdays: workdays, calendar: calendar) + guard total > 0 else { return linear } + let elapsed = workdaySeconds(from: start, to: min(now, resetsAt), workdays: workdays, calendar: calendar) + return min(max(elapsed / total, 0), 1) * 100 + } + + static func workdaySeconds(from start: Date, to end: Date, workdays: Int, calendar: Calendar) -> TimeInterval { + var total: TimeInterval = 0 + var cursor = start + while cursor < end { + let nextDay = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: cursor))! + let segmentEnd = min(nextDay, end) + if isWorkday(cursor, workdays: workdays, calendar: calendar) { total += segmentEnd.timeIntervalSince(cursor) } + cursor = segmentEnd + } + return total + } + + static func isWorkday(_ date: Date, workdays: Int, calendar: Calendar) -> Bool { + let mondayBased = (calendar.component(.weekday, from: date) + 5) % 7 + return mondayBased < workdays + } + static func projectedExhaustion( window: QuotaWindow, start: Date, resetsAt: Date, samples: [UsageSample], now: Date ) -> Date? { diff --git a/Sources/TokenMenuBarCore/Presenters/SpendSummaryPresenter.swift b/Sources/TokenMenuBarCore/Presenters/SpendSummaryPresenter.swift new file mode 100644 index 0000000..5348767 --- /dev/null +++ b/Sources/TokenMenuBarCore/Presenters/SpendSummaryPresenter.swift @@ -0,0 +1,134 @@ +import Foundation + +public struct SpendTile: Sendable, Equatable, Identifiable { + public let title: String + public let cost: Double + + public init(title: String, cost: Double) { + self.title = title + self.cost = cost + } + + public var id: String { title } + + public var text: String { + Format.currency(cost) + } +} + +public struct SpendModelShare: Sendable, Equatable, Identifiable { + public let provider: ProviderID + public let model: String + public let cost: Double + + public init(provider: ProviderID, model: String, cost: Double) { + self.provider = provider + self.model = model + self.cost = cost + } + + public var id: String { "\(provider.rawValue):\(model)" } + + public var text: String { + "\(provider.displayName) · \(model): \(Format.currency(cost))" + } +} + +public struct SpendSummary: Sendable, Equatable { + public static let windowDays = 30 + public static let topModelCount = 5 + + public let today: Double + public let yesterday: Double + public let lastWindow: Double + public let topModels: [SpendModelShare] + public let providers: [ProviderID] + + public init( + today: Double, yesterday: Double, lastWindow: Double, topModels: [SpendModelShare], providers: [ProviderID] + ) { + self.today = today + self.yesterday = yesterday + self.lastWindow = lastWindow + self.topModels = topModels + self.providers = providers + } + + public static let empty = SpendSummary(today: 0, yesterday: 0, lastWindow: 0, topModels: [], providers: []) + + public var hasData: Bool { + !providers.isEmpty + } + + public var tiles: [SpendTile] { + [ + SpendTile(title: "Today", cost: today), SpendTile(title: "Yesterday", cost: yesterday), + SpendTile(title: "Last \(Self.windowDays) days", cost: lastWindow), + ] + } + + public var attribution: String { + "API-equivalent cost across \(providers.map(\.displayName).joined(separator: " + ")) · daily UTC" + } + + public var breakdown: String { + (["Top models over the last \(Self.windowDays) days:"] + topModels.map(\.text)).joined(separator: "\n") + } +} + +public enum SpendSummaryPresenter { + // The History metric lists Claude alone because that is who reports cost today; demo data and future readers + // store Codex cost under the same metric, so the summary asks for both. + public static let suppliers: [ProviderID] = [.claude, .codex] + + public static func load( + history: UsageHistoryStore, providers: [ProviderID], now: Date, timeZone: TimeZone + ) async throws -> SpendSummary { + let days = dayStamps(now: now, timeZone: timeZone) + let rows = try await history.analytics( + metric: .costUSD, providers: suppliers.filter(Set(providers).contains), from: days.start, to: days.today) + return summary(rows: rows, now: now, timeZone: timeZone) + } + + public static func summary(rows: [HistoryAnalyticsRow], now: Date, timeZone: TimeZone) -> SpendSummary { + let days = dayStamps(now: now, timeZone: timeZone) + let window = rows.filter { $0.point.day >= days.start && $0.point.day <= days.today } + var byModel: [String: SpendModelShare] = [:] + for row in window { + let share = SpendModelShare(provider: row.provider, model: row.point.series, cost: row.point.value) + byModel[share.id] = + byModel[share.id].map { + SpendModelShare(provider: $0.provider, model: $0.model, cost: $0.cost + share.cost) + } ?? share + } + return SpendSummary( + today: total(window, day: days.today), + yesterday: total(window, day: days.yesterday), + lastWindow: window.reduce(0) { $0 + $1.point.value }, + topModels: Array( + byModel.values.sorted { ($1.cost, $0.id) < ($0.cost, $1.id) }.prefix(SpendSummary.topModelCount)), + providers: Array(Set(window.map(\.provider))).sorted()) + } + + private static func total(_ rows: [HistoryAnalyticsRow], day: String) -> Double { + rows.filter { $0.point.day == day }.reduce(0) { $0 + $1.point.value } + } + + /// Analytics rows carry UTC day stamps, so a local day boundary can only pick which stamps count as today and + /// yesterday; the rows themselves are not re-bucketed. + static func dayStamps(now: Date, timeZone: TimeZone) -> (start: String, yesterday: String, today: String) { + var calendar = Calendar(identifier: .iso8601) + calendar.timeZone = timeZone + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd" + let today = calendar.startOfDay(for: now) + return ( + formatter.string(from: calendar.date(byAdding: .day, value: -(SpendSummary.windowDays - 1), to: today)!), + formatter.string(from: calendar.date(byAdding: .day, value: -1, to: today)!), + formatter.string(from: today) + ) + } +} diff --git a/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift b/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift index b32171f..77c1193 100644 --- a/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift +++ b/Sources/TokenMenuBarCore/Presenters/UsagePresenter.swift @@ -36,11 +36,12 @@ public struct WindowRow: Sendable, Hashable, Identifiable { public let paceText: String public let helpText: String public let isSelected: Bool + public let display: UsageDisplay 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 + isSelected: Bool = true, display: UsageDisplay = .used ) { self.key = key self.window = window @@ -52,12 +53,13 @@ public struct WindowRow: Sendable, Hashable, Identifiable { self.paceText = paceText ?? pace.status.title self.helpText = helpText ?? paceText ?? pace.status.title self.isSelected = isSelected + self.display = display } public var id: WindowKey { key } public var percentText: String { - Format.percent(window.usedPercent) + Format.percent(used: window.usedPercent, display: display) } public var color: HSBColor { @@ -76,7 +78,8 @@ public struct WindowRow: Sendable, Hashable, Identifiable { 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))" + "\(percentText) \(display.suffix)\(inactive)\(selection), \(resetText(at: now).lowercased()), " + + pace.comparison(now: now) } } @@ -175,22 +178,23 @@ public enum UsagePresenter { } public static func cards( - state: [ProviderID: ProviderState], enabled: Set, samples: [WindowKey: [UsageSample]], now: Date + state: [ProviderID: ProviderState], enabled: Set, samples: [WindowKey: [UsageSample]], + options: UsageDisplayOptions = .standard, 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) + card(provider: provider, state: state[provider]!, samples: samples, options: options, 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 + iconTone: StatusIconTone, isRefreshing: Bool, options: UsageDisplayOptions = .standard, 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) + analytics: analytics[provider] ?? UsageAnalyticsPresentation(), options: options, now: now) } return UsagePresentation( builtAt: now, lastRefresh: lastRefresh, iconTone: iconTone, isRefreshing: isRefreshing, cards: cards, @@ -206,23 +210,25 @@ public enum UsagePresenter { } public static func card( - provider: ProviderID, state: ProviderState, samples: [WindowKey: [UsageSample]], now: Date + provider: ProviderID, state: ProviderState, samples: [WindowKey: [UsageSample]], + options: UsageDisplayOptions = .standard, now: Date ) -> ProviderCard { card( provider: provider, state: state, samples: samples, selected: nil, - analytics: analyticsPresentation(state.analytics, now: now), now: now) + analytics: analyticsPresentation(state.analytics, now: now), options: options, now: now) } static func card( provider: ProviderID, state: ProviderState, samples: [WindowKey: [UsageSample]], selected: Set?, - analytics: UsageAnalyticsPresentation, now: Date + analytics: UsageAnalyticsPresentation, options: UsageDisplayOptions, 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) + let pace = PaceEstimate.estimate( + window: window, samples: samples[key] ?? [], workdays: options.paceWorkdays, now: now) return WindowRow( key: key, window: window, @@ -233,18 +239,20 @@ public enum UsagePresenter { paceLabel: pace.status.title, paceText: pace.summary(now: now), helpText: helpText(window: window, pace: pace, now: now), - isSelected: selected?.contains(key) ?? true + isSelected: selected?.contains(key) ?? true, + display: options.display ) } 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) + let identity = snapshot?.identity return ProviderCard( provider: provider, availability: state.availability, - identity: snapshot?.identity, - chips: chips(provider: provider, snapshot: snapshot), + identity: options.hidePersonalInformation ? identity?.hidingPersonalInformation : identity, + chips: chips(provider: provider, snapshot: snapshot, hidePersonalInformation: options.hidePersonalInformation), rows: rows, credits: snapshot?.credits, spend: snapshot?.spend, @@ -413,17 +421,21 @@ public enum UsagePresenter { return parts.joined(separator: " ") } - static func chips(provider: ProviderID, snapshot: ProviderSnapshot?) -> [Chip] { + static func chips(provider: ProviderID, snapshot: ProviderSnapshot?, hidePersonalInformation: Bool = false) -> [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" }) + let identity = snapshot.identity + let shown = hidePersonalInformation ? identity?.hidingPersonalInformation : identity + if let plan = shown?.planName { chips.append(Chip(text: plan)) } + if let email = shown?.email { chips.append(Chip(text: email)) } + // The personal-organization check runs on the real names; a masked one would never match. + if let organization = shown?.organization, + identity?.organization != identity?.email.map({ "\($0)'s Organization" }) { chips.append(Chip(text: organization)) } - if let until = snapshot.identity?.subscriptionActiveUntil { + if let until = shown?.subscriptionActiveUntil { chips.append(Chip(text: "Renews \(until.formatted(date: .abbreviated, time: .omitted))")) } if snapshot.source == .localLog { chips.append(Chip(text: "From local logs")) } diff --git a/Sources/TokenMenuBarCore/ProviderID.swift b/Sources/TokenMenuBarCore/ProviderID.swift index 2507611..768195c 100644 --- a/Sources/TokenMenuBarCore/ProviderID.swift +++ b/Sources/TokenMenuBarCore/ProviderID.swift @@ -4,6 +4,7 @@ public enum ProviderID: String, Codable, CaseIterable, Sendable, Hashable, Compa case claude case codex case gemini + case antigravity case cursor case copilot @@ -12,6 +13,7 @@ public enum ProviderID: String, Codable, CaseIterable, Sendable, Hashable, Compa case .claude: "Claude" case .codex: "Codex" case .gemini: "Gemini" + case .antigravity: "Antigravity" case .cursor: "Cursor" case .copilot: "GitHub Copilot" } @@ -22,6 +24,7 @@ public enum ProviderID: String, Codable, CaseIterable, Sendable, Hashable, Compa case .claude: "CC" case .codex: "CX" case .gemini: "GM" + case .antigravity: "AG" case .cursor: "CU" case .copilot: "CP" } @@ -32,6 +35,8 @@ public enum ProviderID: String, Codable, CaseIterable, Sendable, Hashable, Compa 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 .antigravity: + "Sign in to Antigravity or run `agy` once; the app reads the token Antigravity stores in the Keychain." 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." diff --git a/Sources/TokenMenuBarCore/ProviderSetup.swift b/Sources/TokenMenuBarCore/ProviderSetup.swift index efbb6e8..096ad52 100644 --- a/Sources/TokenMenuBarCore/ProviderSetup.swift +++ b/Sources/TokenMenuBarCore/ProviderSetup.swift @@ -364,6 +364,16 @@ extension ProviderID { 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 .antigravity: + sources = [ + CredentialSource( + id: "antigravity.keychain", provider: self, title: "Antigravity Keychain", + detail: "The Google sign-in that Antigravity and agy store in the gemini Keychain item.") + ] + title = "Sign in to Antigravity" + detail = + "Sign in to the Antigravity IDE or run agy once. Token Menu Bar reads the token they store in the Keychain." + command = "agy" case .cursor: sources = [ CredentialSource( diff --git a/Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityAPI.swift b/Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityAPI.swift new file mode 100644 index 0000000..187f0ab --- /dev/null +++ b/Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityAPI.swift @@ -0,0 +1,140 @@ +import Foundation + +enum AntigravityAPI { + static let tokenURL = URL(string: "https://oauth2.googleapis.com/token")! + static let cloudBases = ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"] + static let serverSchemes = ["https", "http"] + static let serverService = "exa.language_server_pb.LanguageServerService" + + static let serverQuotaBody = Data(#"{"forceRefresh":true}"#.utf8) + static let serverStatusBody = Data( + #"{"metadata":{"ideName":"antigravity","extensionName":"antigravity","ideVersion":"unknown","locale":"en"}}"#.utf8) + static let cloudQuotaBody = Data("{}".utf8) + static let cloudAssistBody = Data( + #"{"metadata":{"ideType":"ANTIGRAVITY","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}}"#.utf8) + + static func serverURL(port: UInt16, scheme: String, method: String) -> URL { + URL(string: "\(scheme)://127.0.0.1:\(port)/\(serverService)/\(method)")! + } + + static func serverHeaders(csrfToken: String?) -> [String: String] { + var headers = ["Connect-Protocol-Version": "1"] + headers["X-Codeium-Csrf-Token"] = csrfToken + return headers + } + + static func cloudURL(base: String, method: String) -> URL { + URL(string: "\(base)/v1internal:\(method)")! + } + + static func cloudHeaders(token: String) -> [String: String] { + ["Authorization": "Bearer \(token)", "Accept": "application/json", "User-Agent": "antigravity"] + } + + struct Bucket: Decodable, Sendable, Equatable { + let bucketId: String? + let displayName: String? + let window: String? + let remainingFraction: Double? + let remaining: JSONValue? + let resetTime: String? + let disabled: Bool? + + // proto3 JSON drops a zero fraction, so a bucket that reports no remaining share is used up. + var remainingShare: Double { + if let remainingFraction { return remainingFraction } + if remaining?["case"]?.stringValue == "remainingFraction", let value = remaining?["value"]?.doubleValue { + return value + } + return 0 + } + } + + struct Group: Decodable, Sendable, Equatable { + let displayName: String? + let buckets: [Bucket]? + } + + struct QuotaPayload: Decodable, Sendable, Equatable { + let groups: [Group]? + } + + struct QuotaSummary: Decodable, Sendable, Equatable { + let code: Int? + let response: QuotaPayload? + let groups: [Group]? + + var allGroups: [Group] { response?.groups ?? groups ?? [] } + var isUnauthenticated: Bool { code == 16 } + } + + struct Tier: Decodable, Sendable, Equatable { + let id: String? + let name: String? + } + + struct PlanInfo: Decodable, Sendable, Equatable { + let planName: String? + } + + struct PlanStatus: Decodable, Sendable, Equatable { + let planInfo: PlanInfo? + } + + struct UserStatus: Decodable, Sendable, Equatable { + let email: String? + let userTier: Tier? + let planStatus: PlanStatus? + } + + struct UserStatusResponse: Decodable, Sendable, Equatable { + let userStatus: UserStatus? + } + + struct LoadCodeAssistResponse: Decodable, Sendable, Equatable { + let currentTier: Tier? + } +} + +enum AntigravityMapper { + struct WindowSpec { + let id: String + let label: String + let group: WindowGroup + let duration: TimeInterval? + } + + static func windows(_ groups: [AntigravityAPI.Group]) -> [QuotaWindow] { + groups.flatMap { $0.buckets ?? [] }.compactMap(window) + } + + static func window(_ bucket: AntigravityAPI.Bucket) -> QuotaWindow? { + guard let bucketID = bucket.bucketId, bucket.disabled != true else { return nil } + let spec = spec(for: bucketID, displayName: bucket.displayName) + return QuotaWindow( + id: spec.id, label: spec.label, group: spec.group, usedPercent: (1 - bucket.remainingShare) * 100, + resetsAt: ISODate.parse(bucket.resetTime), duration: spec.duration) + } + + static func spec(for bucketID: String, displayName: String?) -> WindowSpec { + switch bucketID { + case "gemini-5h": WindowSpec(id: "gemini:session", label: "Gemini 5-hour", group: .session, duration: 5 * 3600) + case "gemini-weekly": WindowSpec(id: "gemini:weekly", label: "Gemini weekly", group: .weekly, duration: 7 * 86400) + case "3p-5h": WindowSpec(id: "3p:session", label: "Claude and GPT 5-hour", group: .session, duration: 5 * 3600) + case "3p-weekly": + WindowSpec(id: "3p:weekly", label: "Claude and GPT weekly", group: .weekly, duration: 7 * 86400) + default: WindowSpec(id: bucketID, label: displayName ?? bucketID, group: .other, duration: nil) + } + } + + // `planInfo.planName` says Pro for Ultra accounts, so the tier name wins when the server reports one. + static func identity(_ status: AntigravityAPI.UserStatus?) -> ProviderIdentity { + ProviderIdentity( + planName: status?.userTier?.name ?? status?.planStatus?.planInfo?.planName ?? "Antigravity", + tier: status?.userTier?.id, email: status?.email) + } + + static func identity(_ tier: AntigravityAPI.Tier?) -> ProviderIdentity { + ProviderIdentity(planName: tier?.name ?? "Antigravity", tier: tier?.id) + } +} diff --git a/Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityOAuthConfig.swift b/Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityOAuthConfig.swift new file mode 100644 index 0000000..53ed7b7 --- /dev/null +++ b/Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityOAuthConfig.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Refreshing an Antigravity token needs the OAuth client Google ships inside Antigravity itself. Those values belong +/// to Google's app, not to this one, so they are read from the installed copy at runtime rather than vendored here. +public enum AntigravityOAuthConfig { + public static let applications = ["Antigravity.app", "Gemini.app"] + public static let relativePaths = [ + "Contents/Resources/bin/language_server_macos_arm", + "Contents/Resources/app/out/main.js", + "Contents/MacOS/Gemini", + ] + + static let clientIDSuffix = Data(".apps.googleusercontent.com".utf8) + static let clientSecretPrefix = Data("GOCSPX-".utf8) + static let clientSecretLength = clientSecretPrefix.count + 28 + nonisolated(unsafe) private static let clientIDPattern = try! Regex( + #"[0-9]+-[A-Za-z0-9_-]+\.apps\.googleusercontent\.com$"#) + nonisolated(unsafe) private static let clientSecretPattern = try! Regex(#"^GOCSPX-[A-Za-z0-9_-]{28}$"#) + + public static func searchRoots(home: URL) -> [URL] { + [home.appendingPathComponent("Applications"), URL(fileURLWithPath: "/Applications")] + } + + public static func extract(from data: Data) -> GeminiOAuthClient? { + guard let id = clientID(in: data), let secret = clientSecret(in: data) else { return nil } + return GeminiOAuthClient(id: id, secret: secret) + } + + /// The binaries are large, so the id is located by its fixed suffix and read back to the start of its token. + static func clientID(in data: Data) -> String? { + guard let suffix = data.range(of: clientIDSuffix) else { return nil } + var start = suffix.lowerBound + while start > data.startIndex, isTokenByte(data[start - 1]) { start -= 1 } + let candidate = String(decoding: data[start.. String? { + guard let prefix = data.range(of: clientSecretPrefix) else { return nil } + let candidate = String(decoding: data[prefix.lowerBound...].prefix(clientSecretLength), as: UTF8.self) + guard (try? clientSecretPattern.wholeMatch(in: candidate)) != nil else { return nil } + return candidate + } + + private static func isTokenByte(_ byte: UInt8) -> Bool { + switch byte { + case UInt8(ascii: "0")...UInt8(ascii: "9"), UInt8(ascii: "a")...UInt8(ascii: "z"), + UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "_"), UInt8(ascii: "-"): + true + default: false + } + } + + public static func resolve( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser, + read: (URL) -> Data? = { try? Data(contentsOf: $0, options: .mappedIfSafe) } + ) -> GeminiOAuthClient? { + if let id = environment["ANTIGRAVITY_OAUTH_CLIENT_ID"], let secret = environment["ANTIGRAVITY_OAUTH_CLIENT_SECRET"] + { + return GeminiOAuthClient(id: id, secret: secret) + } + for root in searchRoots(home: home) { + for application in applications { + for path in relativePaths { + let url = root.appendingPathComponent(application).appendingPathComponent(path) + guard let data = read(url), let client = extract(from: data) else { continue } + return client + } + } + } + return nil + } +} diff --git a/Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityProvider.swift b/Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityProvider.swift new file mode 100644 index 0000000..865d322 --- /dev/null +++ b/Sources/TokenMenuBarCore/Providers/Antigravity/AntigravityProvider.swift @@ -0,0 +1,234 @@ +import Foundation + +public actor AntigravityProvider: UsageProvider { + public nonisolated let id: ProviderID = .antigravity + public nonisolated let pollingPolicy = PollingPolicy.defaults(for: .antigravity) + private let auth: any AntigravityAuthStore + private let client: APIClient + private let log: LogBuffer + private let allowRefresh: @MainActor @Sendable () -> Bool + private let oauthClient: @Sendable () -> GeminiOAuthClient? + private let processScanner: (any ProcessScanner)? + // Google refresh tokens do not rotate and Antigravity owns its Keychain item, so a refreshed access token is only + // remembered here and dropped as soon as the stored credential changes. + private var refreshed: (replacing: AntigravityAuth, active: AntigravityAuth)? + private var refreshRejection = RefreshRejection() + private var cachedTier: (token: String, tier: AntigravityAPI.Tier?, at: Date)? + + public init( + auth: any AntigravityAuthStore, + client: APIClient, + log: LogBuffer, + allowRefresh: @escaping @MainActor @Sendable () -> Bool, + oauthClient: @escaping @Sendable () -> GeminiOAuthClient? = { AntigravityOAuthConfig.resolve() }, + processScanner: (any ProcessScanner)? = nil + ) { + self.auth = auth + self.client = client + self.log = log + self.allowRefresh = allowRefresh + self.oauthClient = oauthClient + self.processScanner = processScanner + } + + public nonisolated var credentialDescription: String { + auth.description + } + + static let missingCredentialDetail = "no Antigravity sign-in found" + + public nonisolated func credentialState(now: Date) -> CredentialState { + do { + guard let stored = try auth.load() else { + return .missing(Self.missingCredentialDetail) + } + return stored.state(now: now) + } catch { + return .missing(CredentialReadFailure(source: auth.source, error: error).detail) + } + } + + public func credentialHealth(now: Date) async -> ProviderCredentialHealth { + auth.credentialHealth(now: now) + } + + public func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult { + let stored: AntigravityAuth + do { + guard let loaded = try auth.load() else { + return ProviderFetchResult(outcome: .notAuthenticated("No Antigravity credentials. \(id.loginHint)")) + .withCredentialStatus(.missing(Self.missingCredentialDetail, provider: id)) + } + stored = loaded + } catch { + let failure = CredentialReadFailure(source: auth.source, error: error) + return ProviderFetchResult(outcome: .notAuthenticated("Cannot read Antigravity credentials: \(failure.detail)")) + .withCredentialStatus(.unreadable(error, provider: id, fallbackSource: auth.source)) + } + if refreshed?.replacing != stored { refreshed = nil } + var active = refreshed?.active ?? stored + if case .expired = active.state(now: now) { + guard await allowRefresh() else { + return ProviderFetchResult(outcome: .notAuthenticated("Antigravity token expired. \(id.loginHint)")) + .withCredentialStatus(.resolved(active.state(now: now), provider: id, source: auth.source)) + } + do { + active = try await refresh(stored, now: now) + } catch { + return ProviderFetchResult(outcome: .notAuthenticated("Antigravity token refresh failed: \(error.message)")) + .withCredentialStatus(.resolved(stored.state(now: now), provider: id, source: auth.source)) + } + } + let status = ProviderCredentialStatus.resolved(active.state(now: now), provider: id, source: auth.source) + if let processScanner, let snapshot = await localSnapshot(scanner: processScanner, now: now) { + return ProviderFetchResult(outcome: .success(snapshot)).withCredentialStatus(status) + } + return await cloudResult(active, stored: stored, now: now, status: status, retrying: false) + } + + private func localSnapshot(scanner: any ProcessScanner, now: Date) async -> ProviderSnapshot? { + for server in AntigravityLanguageServers.discover(using: scanner) { + guard let local = await localQuota(server) else { continue } + let status = await localStatus(server, scheme: local.scheme) + log.log("antigravity: quota read from the language server on port \(server.port)") + return ProviderSnapshot( + provider: id, identity: AntigravityMapper.identity(status), + windows: AntigravityMapper.windows(local.summary.allGroups), fetchedAt: now) + } + return nil + } + + private func localQuota( + _ server: LocalLanguageServer + ) async -> (summary: AntigravityAPI.QuotaSummary, scheme: String)? { + for scheme in AntigravityAPI.serverSchemes { + do { + let data = try await client.post( + AntigravityAPI.serverURL(port: server.port, scheme: scheme, method: "RetrieveUserQuotaSummary"), + json: AntigravityAPI.serverQuotaBody, headers: AntigravityAPI.serverHeaders(csrfToken: server.csrfToken), + operation: "antigravity.local-quota") + let summary = try client.decode(AntigravityAPI.QuotaSummary.self, data, operation: "antigravity.local-quota") + guard !summary.isUnauthenticated else { + log.log("antigravity: the language server on port \(server.port) is not signed in") + return nil + } + return (summary, scheme) + } catch { + log.logDebug( + "antigravity: language server port=\(server.port) scheme=\(scheme) \(error.message)", category: .network) + } + } + return nil + } + + private func localStatus(_ server: LocalLanguageServer, scheme: String) async -> AntigravityAPI.UserStatus? { + do { + let data = try await client.post( + AntigravityAPI.serverURL(port: server.port, scheme: scheme, method: "GetUserStatus"), + json: AntigravityAPI.serverStatusBody, headers: AntigravityAPI.serverHeaders(csrfToken: server.csrfToken), + operation: "antigravity.local-status") + return try client.decode(AntigravityAPI.UserStatusResponse.self, data, operation: "antigravity.local-status") + .userStatus + } catch { + log.logDebug("antigravity: user status unavailable port=\(server.port) \(error.message)", category: .network) + return nil + } + } + + private func cloudResult( + _ active: AntigravityAuth, stored: AntigravityAuth, now: Date, status: ProviderCredentialStatus, retrying: Bool + ) async -> ProviderFetchResult { + let headers = AntigravityAPI.cloudHeaders(token: active.accessToken) + switch await cloudQuota(headers: headers) { + case .success(let quota): + let tier = await cloudTier(base: quota.base, headers: headers, token: active.accessToken, now: now) + let snapshot = ProviderSnapshot( + provider: id, identity: AntigravityMapper.identity(tier.tier), + windows: AntigravityMapper.windows(quota.summary.allGroups), fetchedAt: now) + return ProviderFetchResult(outcome: .success(snapshot), warnings: tier.warning.map { [$0] } ?? []) + .withCredentialStatus(status) + case .failure(let error) where error.isAuthenticationFailure: + guard !retrying, await allowRefresh() else { + return ProviderFetchResult(outcome: .notAuthenticated("\(error.message). \(id.loginHint)")) + .withCredentialStatus(status) + } + do { + let renewed = try await refresh(stored, now: now) + return await cloudResult( + renewed, stored: stored, now: now, + status: .resolved(renewed.state(now: now), provider: id, source: auth.source), retrying: true) + } catch { + return ProviderFetchResult(outcome: .notAuthenticated("Antigravity token refresh failed: \(error.message)")) + .withCredentialStatus(status) + } + case .failure(let error): + return ProviderFetchResult(outcome: ProviderOutcomeBuilder.outcome(for: error, hint: id.loginHint)) + .withCredentialStatus(status) + } + } + + private func cloudQuota( + headers: [String: String] + ) async -> Result<(base: String, summary: AntigravityAPI.QuotaSummary), APIError> { + var failure: APIError? + for base in AntigravityAPI.cloudBases { + do { + let data = try await client.post( + AntigravityAPI.cloudURL(base: base, method: "retrieveUserQuotaSummary"), json: AntigravityAPI.cloudQuotaBody, + headers: headers, operation: "antigravity.quota") + return .success( + (base, try client.decode(AntigravityAPI.QuotaSummary.self, data, operation: "antigravity.quota"))) + } catch { + if error.isAuthenticationFailure { return .failure(error) } + log.logDebug("antigravity: \(base) \(error.message)", category: .network) + failure = error + } + } + return .failure(failure!) + } + + private func cloudTier( + base: String, headers: [String: String], token: String, now: Date + ) async -> (tier: AntigravityAPI.Tier?, warning: String?) { + if let cachedTier, cachedTier.token == token, now.timeIntervalSince(cachedTier.at) < 3600 { + return (cachedTier.tier, nil) + } + do { + let data = try await client.post( + AntigravityAPI.cloudURL(base: base, method: "loadCodeAssist"), json: AntigravityAPI.cloudAssistBody, + headers: headers, operation: "antigravity.load-code-assist") + let tier = try client.decode( + AntigravityAPI.LoadCodeAssistResponse.self, data, operation: "antigravity.load-code-assist" + ).currentTier + cachedTier = (token, tier, now) + return (tier, nil) + } catch { + return (nil, "Plan details unavailable: \(error.message)") + } + } + + private func refresh(_ stored: AntigravityAuth, now: Date) async throws(CredentialRefreshError) -> AntigravityAuth { + guard let refreshToken = stored.refreshToken else { throw .missingRefreshToken } + guard let oauth = oauthClient() else { throw .oauthClientUnavailable } + try refreshRejection.check(stored) + let token: GeminiAPI.TokenResponse + do { + let data = try await client.post( + AntigravityAPI.tokenURL, + form: [ + "client_id": oauth.id, "client_secret": oauth.secret, "grant_type": "refresh_token", + "refresh_token": refreshToken, + ], headers: [:], operation: "antigravity.refresh") + token = try client.decode(GeminiAPI.TokenResponse.self, data, operation: "antigravity.refresh") + } catch { + throw refreshRejection.failure(error, refreshing: stored) + } + guard let accessToken = token.accessToken else { + throw .invalidResponse(token.errorDescription ?? token.error ?? "refresh returned no access token") + } + let active = stored.refreshed(accessToken: accessToken, expiresIn: token.expiresIn ?? 3600, now: now) + refreshed = (stored, active) + log.log("antigravity token refreshed and kept in memory") + return active + } +} diff --git a/Sources/TokenMenuBarCore/Providers/Antigravity/LibprocProcessScanner.swift b/Sources/TokenMenuBarCore/Providers/Antigravity/LibprocProcessScanner.swift new file mode 100644 index 0000000..72b8cbf --- /dev/null +++ b/Sources/TokenMenuBarCore/Providers/Antigravity/LibprocProcessScanner.swift @@ -0,0 +1,42 @@ +import Darwin +import Foundation + +/// The libproc calls need a live process table; what they return is parsed in ProcessScanner.swift. +public struct LibprocProcessScanner: ProcessScanner { + public init() {} + + public func processes() -> [ProcessEntry] { + var pids = [pid_t](repeating: 0, count: Int(proc_listallpids(nil, 0)) + 64) + let count = Int(proc_listallpids(&pids, Int32(pids.count * MemoryLayout.stride))) + return pids.prefix(max(count, 0)).compactMap { pid in + var path = [UInt8](repeating: 0, count: 4096) + guard proc_pidpath(pid, &path, UInt32(path.count)) > 0 else { return nil } + let executable = String(decoding: path.prefix { $0 != 0 }, as: UTF8.self) + return ProcessEntry(pid: pid, path: executable, arguments: ProcessArguments.parse(procargs(pid))) + } + } + + private func procargs(_ pid: pid_t) -> Data { + var name: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid] + var size = 0 + guard sysctl(&name, 3, nil, &size, nil, 0) == 0, size > 0 else { return Data() } + var buffer = Data(count: size) + let status = buffer.withUnsafeMutableBytes { sysctl(&name, 3, $0.baseAddress, &size, nil, 0) } + return status == 0 ? buffer.prefix(size) : Data() + } + + public func listeningPorts(pid: pid_t) -> [UInt16] { + let stride = MemoryLayout.stride + let size = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0) + guard size > 0 else { return [] } + var fds = [proc_fdinfo](repeating: proc_fdinfo(), count: Int(size) / stride) + let filled = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, &fds, size) + return fds.prefix(max(Int(filled), 0) / stride).compactMap { fd in + guard fd.proc_fdtype == UInt32(PROX_FDTYPE_SOCKET) else { return nil } + var info = socket_fdinfo() + let wanted = Int32(MemoryLayout.size) + guard proc_pidfdinfo(pid, fd.proc_fd, PROC_PIDFDSOCKETINFO, &info, wanted) == wanted else { return nil } + return ListeningSocket.port(of: info) + } + } +} diff --git a/Sources/TokenMenuBarCore/Providers/Antigravity/ProcessScanner.swift b/Sources/TokenMenuBarCore/Providers/Antigravity/ProcessScanner.swift new file mode 100644 index 0000000..f5d93a7 --- /dev/null +++ b/Sources/TokenMenuBarCore/Providers/Antigravity/ProcessScanner.swift @@ -0,0 +1,99 @@ +import Darwin +import Foundation + +public struct ProcessEntry: Sendable, Equatable { + public let pid: pid_t + public let path: String + public let arguments: [String] + + public init(pid: pid_t, path: String, arguments: [String]) { + self.pid = pid + self.path = path + self.arguments = arguments + } +} + +/// `ps` and `lsof` trigger a TCC prompt on macOS 26, so the process table is read through libproc instead. +public protocol ProcessScanner: Sendable { + func processes() -> [ProcessEntry] + func listeningPorts(pid: pid_t) -> [UInt16] +} + +public struct LocalLanguageServer: Sendable, Equatable { + public let pid: pid_t + public let port: UInt16 + public let csrfToken: String? + + public init(pid: pid_t, port: UInt16, csrfToken: String?) { + self.pid = pid + self.port = port + self.csrfToken = csrfToken + } +} + +public enum AntigravityLanguageServers { + static let serverPrefixes = ["language_server", "language-server"] + static let cliNames: Set = ["agy", "antigravity-cli"] + static let appDataDirs: Set = ["antigravity", "antigravity-ide"] + + enum Role: Equatable { + case languageServer(csrfToken: String) + case cli + + var csrfToken: String? { + if case .languageServer(let token) = self { return token } + return nil + } + } + + public static func discover(using scanner: any ProcessScanner) -> [LocalLanguageServer] { + scanner.processes().flatMap { process -> [LocalLanguageServer] in + guard let role = role(of: process) else { return [] } + return scanner.listeningPorts(pid: process.pid).sorted().map { + LocalLanguageServer(pid: process.pid, port: $0, csrfToken: role.csrfToken) + } + } + } + + /// The IDE's language server needs its CSRF token and names its app data directory; the CLI needs neither. + static func role(of process: ProcessEntry) -> Role? { + let name = (process.path as NSString).lastPathComponent + if cliNames.contains(name) { return .cli } + guard serverPrefixes.contains(where: name.hasPrefix), + let token = value(of: "--csrf_token", in: process.arguments), + let directory = value(of: "--app_data_dir", in: process.arguments), appDataDirs.contains(directory) + else { return nil } + return .languageServer(csrfToken: token) + } + + static func value(of flag: String, in arguments: [String]) -> String? { + for (index, argument) in arguments.enumerated() { + if argument.hasPrefix("\(flag)=") { return String(argument.dropFirst(flag.count + 1)) } + if argument == flag, arguments.indices.contains(index + 1) { return arguments[index + 1] } + } + return nil + } +} + +enum ProcessArguments { + /// KERN_PROCARGS2 lays out argc, the executable path, NUL padding, then argc NUL-terminated arguments before the + /// environment. + static func parse(_ buffer: Data) -> [String] { + guard buffer.count > 4 else { return [] } + let argc = Int(buffer.prefix(4).withUnsafeBytes { $0.loadUnaligned(as: Int32.self) }) + let rest = buffer.dropFirst(4) + guard let pathEnd = rest.firstIndex(of: 0) else { return [] } + return rest[pathEnd...].drop { $0 == 0 } + .split(separator: 0, maxSplits: argc, omittingEmptySubsequences: false) + .prefix(argc) + .map { String(decoding: $0, as: UTF8.self) } + } +} + +enum ListeningSocket { + static func port(of info: socket_fdinfo) -> UInt16? { + let tcp = info.psi.soi_proto.pri_tcp + guard info.psi.soi_kind == Int32(SOCKINFO_TCP), tcp.tcpsi_state == Int32(TSI_S_LISTEN) else { return nil } + return UInt16(bigEndian: UInt16(truncatingIfNeeded: tcp.tcpsi_ini.insi_lport)) + } +} diff --git a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift index 3484097..67fd768 100644 --- a/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift +++ b/Sources/TokenMenuBarCore/Providers/Claude/ClaudeTranscriptReader.swift @@ -90,10 +90,12 @@ public struct TranscriptMessage: Sendable, Equatable, Hashable { public let toolCalls: Int /// The `costUSD` Claude Code wrote on the line, which beats the table when present. public let reportedCost: Double? + /// The last path component of the `cwd` on the line; nil when the line carries none. + public let project: String? public init( id: String, timestamp: Date, session: String, model: String, usage: TokenUsage, toolCalls: Int, - reportedCost: Double? = nil + reportedCost: Double? = nil, project: String? = nil ) { self.id = id self.timestamp = timestamp @@ -102,6 +104,7 @@ public struct TranscriptMessage: Sendable, Equatable, Hashable { self.usage = usage self.toolCalls = toolCalls self.reportedCost = reportedCost + self.project = project } public var cost: Double { @@ -174,6 +177,11 @@ public struct ClaudeTranscriptSnapshot: Sendable { 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))) + for (project, totals) in aggregate.projects { + points.append(AnalyticsPoint(day: day, metric: .projectCost, series: project, value: totals.cost)) + points.append( + AnalyticsPoint(day: day, metric: .projectMessages, series: project, value: Double(totals.messages))) + } } return ProviderAnalytics( provider: .claude, @@ -264,7 +272,7 @@ fileprivate struct ClaudeTranscriptState: Codable, Sendable { } @discardableResult - mutating func ingest(_ message: TranscriptMessage) -> Bool { + mutating func ingest(_ message: TranscriptMessage, project: String) -> Bool { let day = DayStamp.string(message.timestamp) guard seenByDay[day, default: []].insert(message.id).inserted else { return false } var aggregate = days[day] ?? DayAggregate() @@ -273,6 +281,7 @@ fileprivate struct ClaudeTranscriptState: Codable, Sendable { aggregate.messages += 1 aggregate.sessions.insert(message.session) aggregate.toolCalls += message.toolCalls + aggregate.projects[project, default: ProjectAggregate()].add(message) days[day] = aggregate if message.isCostEstimated { unpricedModels.insert(message.model) } let minute = floor(message.timestamp.timeIntervalSince1970 / 60) * 60 @@ -322,12 +331,23 @@ fileprivate struct TranscriptOffset: Codable, Sendable { } } +fileprivate struct ProjectAggregate: Codable, Sendable, Equatable { + var cost = 0.0 + var messages = 0 + + mutating func add(_ message: TranscriptMessage) { + cost += message.cost + messages += 1 + } +} + fileprivate struct DayAggregate: Codable, Sendable { var models: [String: TokenUsage] = [:] var costs: [String: Double] = [:] var messages = 0 var sessions: Set = [] var toolCalls = 0 + var projects: [String: ProjectAggregate] = [:] private enum CodingKeys: String, CodingKey { case models @@ -335,11 +355,13 @@ fileprivate struct DayAggregate: Codable, Sendable { case messages case sessions case toolCalls + case projects } init() {} - // Checkpoints written before costs were stored price their days from the table when read back. + // Checkpoints written before costs and projects were stored price their days from the table when read back and + // leave project attribution to the next full scan. init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) models = try container.decode([String: TokenUsage].self, forKey: .models) @@ -347,6 +369,7 @@ fileprivate struct DayAggregate: Codable, Sendable { messages = try container.decode(Int.self, forKey: .messages) sessions = try container.decode(Set.self, forKey: .sessions) toolCalls = try container.decode(Int.self, forKey: .toolCalls) + projects = try container.decodeIfPresent([String: ProjectAggregate].self, forKey: .projects) ?? [:] } } @@ -793,6 +816,7 @@ public actor ClaudeTranscriptReader { var bytesRead = 0 var aggregateChanged = false var reachedStaleEOF = false + let project = Self.projectName(transcript: file.url, root: root) 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) } @@ -804,7 +828,9 @@ public actor ClaudeTranscriptReader { let chunkStart = partial.cursor partial.cursor += chunk.count bytesRead += chunk.count - aggregateChanged = consume(chunk, startingAt: chunkStart, cutoff: cutoff, partial: &partial) || aggregateChanged + aggregateChanged = + consume(chunk, startingAt: chunkStart, cutoff: cutoff, project: project, partial: &partial) + || aggregateChanged } state.offsets[key] = TranscriptOffset(bytes: partial.committedOffset, scanGeneration: generation) if reachedStaleEOF || (partial.pending.isEmpty && !partial.oversized && partial.cursor >= file.size) @@ -823,8 +849,22 @@ public actor ClaudeTranscriptReader { complete: reachedStaleEOF || partial.cursor >= file.size) } + /// Claude Code names each project directory after the working directory with `/` turned into `-`, which loses + /// dashes inside path components, so a line's own `cwd` wins and the directory name only covers lines without one. + static func projectName(transcript url: URL, root: URL) -> String { + let rootComponents = root.standardizedFileURL.pathComponents + let components = url.standardizedFileURL.pathComponents + guard components.count > rootComponents.count + 1 else { return url.deletingPathExtension().lastPathComponent } + return projectName(encodedDirectory: components[rootComponents.count]) + } + + public static func projectName(encodedDirectory name: String) -> String { + let decoded = name.replacingOccurrences(of: "-", with: "/") + return decoded.split(separator: "/").last.map(String.init) ?? name + } + private func consume( - _ chunk: Data, startingAt chunkStart: Int, cutoff: Date, partial: inout PartialTranscriptRead + _ chunk: Data, startingAt chunkStart: Int, cutoff: Date, project: String, partial: inout PartialTranscriptRead ) -> Bool { var changed = false var start = chunk.startIndex @@ -852,7 +892,7 @@ public actor ClaudeTranscriptReader { if partial.pending.contains(Self.usageMarker), let message = Self.parse(line: partial.pending), message.timestamp >= cutoff { - changed = state.ingest(message) || changed + changed = state.ingest(message, project: message.project ?? project) || changed } } partial.pending.removeAll(keepingCapacity: false) @@ -1001,6 +1041,7 @@ public actor ClaudeTranscriptReader { let sessionId: String? let timestamp: String? let costUSD: Double? + let cwd: String? let message: Message? } @@ -1023,7 +1064,8 @@ public actor ClaudeTranscriptReader { cacheRead: usage.cacheReadInputTokens ?? 0 ), toolCalls: message.content?.count { $0.type == "tool_use" } ?? 0, - reportedCost: json.costUSD + reportedCost: json.costUSD, + project: json.cwd.flatMap { $0.split(separator: "/").last.map(String.init) } ) } @@ -1031,6 +1073,7 @@ public actor ClaudeTranscriptReader { guard !messages.isEmpty else { return nil } var byDayModel: [String: [String: (usage: TokenUsage, cost: Double)]] = [:] var daily: [String: (messages: Int, sessions: Set, toolCalls: Int)] = [:] + var byDayProject: [String: [String: ProjectAggregate]] = [:] for message in messages { let day = DayStamp.string(message.timestamp) var model = byDayModel[day, default: [:]][message.model] ?? (TokenUsage(), 0) @@ -1042,8 +1085,18 @@ public actor ClaudeTranscriptReader { entry.sessions.insert(message.session) entry.toolCalls += message.toolCalls daily[day] = entry + if let project = message.project { + byDayProject[day, default: [:]][project, default: ProjectAggregate()].add(message) + } } var points: [AnalyticsPoint] = [] + for (day, projects) in byDayProject { + for (project, totals) in projects { + points.append(AnalyticsPoint(day: day, metric: .projectCost, series: project, value: totals.cost)) + points.append( + AnalyticsPoint(day: day, metric: .projectMessages, series: project, value: Double(totals.messages))) + } + } for (day, models) in byDayModel { for (model, totals) in models { let usage = totals.usage diff --git a/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift b/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift index 99bbfca..d5e1a28 100644 --- a/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift +++ b/Sources/TokenMenuBarCore/Providers/ProviderRegistryFactory.swift @@ -14,6 +14,8 @@ public enum ProviderRegistryFactory { public let allowTokenRefresh: @MainActor @Sendable () -> Bool public let allowsGeminiTokenRefresh: Bool public let analyticsWatermarkPersistence: CodexAnalyticsWatermarkPersistence + /// Nil in the sandboxed build, where the process table is out of reach and Antigravity reads Cloud Code alone. + public let processScanner: (any ProcessScanner)? public init( home: URL, @@ -27,7 +29,8 @@ public enum ProviderRegistryFactory { keychain: KeychainCredentialClient, allowTokenRefresh: @escaping @MainActor @Sendable () -> Bool, allowsGeminiTokenRefresh: Bool = true, - analyticsWatermarkPersistence: CodexAnalyticsWatermarkPersistence = .standard + analyticsWatermarkPersistence: CodexAnalyticsWatermarkPersistence = .standard, + processScanner: (any ProcessScanner)? = nil ) { self.home = home self.supportDirectory = supportDirectory @@ -41,6 +44,7 @@ public enum ProviderRegistryFactory { self.allowTokenRefresh = allowTokenRefresh self.allowsGeminiTokenRefresh = allowsGeminiTokenRefresh self.analyticsWatermarkPersistence = analyticsWatermarkPersistence + self.processScanner = processScanner } fileprivate func url(for resource: SandboxResource) -> URL { @@ -117,6 +121,17 @@ public enum ProviderRegistryFactory { oauthClient: { GeminiOAuthConfig.resolve(environment: configuration.environment, home: configuration.home) } ) + let antigravity = AntigravityProvider( + auth: KeychainAntigravityAuthStore(keychain: configuration.keychain), + client: client, + log: log, + allowRefresh: configuration.allowTokenRefresh, + oauthClient: { + AntigravityOAuthConfig.resolve(environment: configuration.environment, home: configuration.home) + }, + processScanner: configuration.processScanner + ) + let cursorAuth = ChainedCursorAuthStore([ CursorStateStore( url: configuration.url(for: ProviderID.cursor.sandboxResources[0]) @@ -153,7 +168,7 @@ public enum ProviderRegistryFactory { ) }) return ProviderRegistry( - [claude, codex, gemini, cursor, copilot], + [claude, codex, gemini, antigravity, cursor, copilot], setupStates: setupStates, resourceLeases: configuration.resourceLeases) } diff --git a/Sources/TokenMenuBarCore/Providers/UsageProvider.swift b/Sources/TokenMenuBarCore/Providers/UsageProvider.swift index e6efbca..d6b506d 100644 --- a/Sources/TokenMenuBarCore/Providers/UsageProvider.swift +++ b/Sources/TokenMenuBarCore/Providers/UsageProvider.swift @@ -29,7 +29,7 @@ public struct PollingPolicy: Sendable, Equatable { 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 .gemini, .antigravity: PollingPolicy(minimumInterval: 60, activeInterval: 60, defaultInterval: 120) case .cursor, .copilot: PollingPolicy(minimumInterval: 60, activeInterval: 60, defaultInterval: 300) } } diff --git a/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift b/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift index 1de7431..21cbd9c 100644 --- a/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift +++ b/Sources/TokenMenuBarCore/Refresh/RefreshCoordinator.swift @@ -101,6 +101,9 @@ public final class RefreshCoordinator { public static let maximumBackoff: TimeInterval = 1800 public static let networkBackoff: TimeInterval = 60 public static let wakeDelay: TimeInterval = 3 + public static let throttledMultiplier: Double = 2 + public static let idleMultiplier: Double = 3 + public static let idleThreshold: TimeInterval = 3600 public private(set) var registry: ProviderRegistry private let settings: Settings @@ -108,6 +111,8 @@ public final class RefreshCoordinator { private let history: UsageHistoryStore private let log: LogBuffer private let clock: Clock + private let powerState: @Sendable () -> PowerState + private var lastPopoverOpenedAt: Date private let notify: @MainActor ([NotificationEvent]) -> Void private var loop: Task? private var refreshRun: RefreshRun? @@ -142,6 +147,7 @@ public final class RefreshCoordinator { clock: Clock = .system, cache: SnapshotCache = SnapshotCache(url: nil), persistence: SnapshotPersistence? = nil, + powerState: @escaping @Sendable () -> PowerState = PowerState.current, notify: @escaping @MainActor ([NotificationEvent]) -> Void ) { self.registry = registry @@ -150,6 +156,8 @@ public final class RefreshCoordinator { self.history = history self.log = log self.clock = clock + self.powerState = powerState + lastPopoverOpenedAt = clock.now() self.persistence = persistence ?? SnapshotPersistence( @@ -261,10 +269,10 @@ public final class RefreshCoordinator { let usage = providerState.lastAttempt.map { $0.addingTimeInterval( - scheduledInterval( + scheduledUsageInterval( provider.pollingPolicy.interval( active: state.popoverVisible, requested: TimeInterval(settings.refreshInterval(for: provider.id))), - for: provider.id)) + for: provider.id, now: now)) } ?? now guard provider.supportsAnalytics else { return usage } let analytics = @@ -332,6 +340,7 @@ public final class RefreshCoordinator { } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self, self.loop != nil, self.scheduleObservationGeneration == generation else { return } + if self.state.popoverVisible { self.lastPopoverOpenedAt = self.clock.now() } self.observeScheduleInputs(generation: generation) self.reschedule() } @@ -463,10 +472,11 @@ public final class RefreshCoordinator { func storeCache() { let snapshots = state.snapshots + let writesUsageFile = settings.writeUsageFile let previous = cacheSubmissionTask cacheSubmissionTask = Task { [persistence] in await previous?.value - await persistence.submitSnapshots(snapshots) + await persistence.submitSnapshots(snapshots, writesUsageFile: writesUsageFile) } } @@ -490,7 +500,7 @@ public final class RefreshCoordinator { let interval = provider.pollingPolicy.interval( active: state.popoverVisible, requested: TimeInterval(settings.refreshInterval(for: provider.id))) let usageInterval = - request.reason == .scheduled ? scheduledInterval(interval, for: provider.id) : interval + request.reason == .scheduled ? scheduledUsageInterval(interval, for: provider.id, now: now) : interval let analyticsInterval = TimeInterval(settings.analyticsRefreshMinutes * 60) let usageDue = request.usage != .skip @@ -540,6 +550,16 @@ public final class RefreshCoordinator { return interval * (1 + Double(index) * 0.02) } + // Low power or thermal pressure doubles the wait and an hour without the popover triples it; the input already + // sits at or above the vendor floor, so only the ceiling needs enforcing. + private func scheduledUsageInterval(_ interval: TimeInterval, for id: ProviderID, now: Date) -> TimeInterval { + let throttled = powerState().throttlesScanning ? Self.throttledMultiplier : 1 + let idle = + !state.popoverVisible && now.timeIntervalSince(lastPopoverOpenedAt) >= Self.idleThreshold + ? Self.idleMultiplier : 1 + return min(scheduledInterval(interval, for: id) * throttled * idle, TimeInterval(Settings.maximumRefreshSeconds)) + } + public func nextAttempt(for id: ProviderID) -> Date? { state.state(for: id).retryNotBefore } @@ -670,6 +690,7 @@ public final class RefreshCoordinator { provider: id, settings: settings.notifications, credentialMissing: credentialStatus.state.isMissing, + workdays: settings.paceWorkdays, now: now) : [] return ProviderApplyResult( @@ -729,7 +750,8 @@ public final class RefreshCoordinator { hideZeroCells: settings.hideZeroCells, order: settings.windowOrder, labels: settings.shortLabels, - now: now ?? clock.now() + now: now ?? clock.now(), + display: settings.usageDisplay ) state.setStatusLadder( settings.adaptiveWidth ? StatusItemBuilder.candidates(input) : [StatusItemBuilder.build(input)]) @@ -737,7 +759,7 @@ public final class RefreshCoordinator { // per provider as each one reports. guard publishWidget else { return } let widget = WidgetSnapshot.build( - snapshots: snapshots, availability: availability, selectedKeys: selection, + snapshots: snapshots, availability: availability, selectedKeys: selection, display: settings.usageDisplay, now: snapshots.values.map(\.fetchedAt).max() ?? input.now) if widget.shouldPublish(after: lastWidget) { lastWidget = widget diff --git a/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift b/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift index 86d8a5b..ecdbf2b 100644 --- a/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift +++ b/Sources/TokenMenuBarCore/Refresh/SnapshotPersistence.swift @@ -3,12 +3,14 @@ import Foundation public enum SnapshotPersistenceFailure: Sendable, Equatable { case cacheLoad(String) case cacheWrite(String) + case usageFileWrite(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 .usageFileWrite(let detail): "usage file write failed: \(detail)" case .widgetWrite(let detail): "widget snapshot write failed: \(detail)" } } @@ -19,6 +21,7 @@ public struct SnapshotPersistenceWorkload: Sendable, Equatable { public fileprivate(set) var cacheSubmissions = 0 public fileprivate(set) var cacheWrites = 0 public fileprivate(set) var coalescedCacheSubmissions = 0 + public fileprivate(set) var usageFileWrites = 0 public fileprivate(set) var widgetSubmissions = 0 public fileprivate(set) var widgetWrites = 0 public fileprivate(set) var coalescedWidgetSubmissions = 0 @@ -32,19 +35,26 @@ private enum SnapshotCacheLoadResult: Sendable { case failure(String) } +private struct PendingSnapshots { + let snapshots: [ProviderID: ProviderSnapshot] + let writesUsageFile: Bool +} + 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 usageFileURL: URL? private let failureHandler: FailureHandler private let reloadWidgets: WidgetReload private var cachedLoad: [ProviderID: ProviderSnapshot]? private var cacheLoadTask: Task? - private var pendingSnapshots: [ProviderID: ProviderSnapshot]? + private var pendingSnapshots: PendingSnapshots? private var pendingWidget: WidgetSnapshot? private var lastWrittenSnapshots: [ProviderID: ProviderSnapshot]? + private var lastUsageFileSnapshots: [ProviderID: ProviderSnapshot]? private var lastWrittenWidget: WidgetSnapshot? private var cacheDrainTask: Task? private var widgetDrainTask: Task? @@ -53,11 +63,13 @@ public actor SnapshotPersistence { public init( cache: SnapshotCache, widgetStore: WidgetSnapshotStore? = nil, + usageFileURL: URL? = nil, failureHandler: @escaping FailureHandler = { _ in }, reloadWidgets: @escaping WidgetReload = {} ) { self.cache = cache self.widgetStore = widgetStore + self.usageFileURL = usageFileURL self.failureHandler = failureHandler self.reloadWidgets = reloadWidgets } @@ -94,10 +106,10 @@ public actor SnapshotPersistence { return snapshots } - public func submitSnapshots(_ snapshots: [ProviderID: ProviderSnapshot]) { + public func submitSnapshots(_ snapshots: [ProviderID: ProviderSnapshot], writesUsageFile: Bool = false) { workload.cacheSubmissions += 1 if pendingSnapshots != nil { workload.coalescedCacheSubmissions += 1 } - pendingSnapshots = snapshots + pendingSnapshots = PendingSnapshots(snapshots: snapshots, writesUsageFile: writesUsageFile) guard cacheDrainTask == nil else { return } cacheDrainTask = Task { [weak self] in await self?.drainSnapshots() } } @@ -121,28 +133,51 @@ public actor SnapshotPersistence { } private func drainSnapshots() async { - while let snapshots = pendingSnapshots { + while let pending = 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 + if pending.snapshots != lastWrittenSnapshots { await writeCache(pending.snapshots) } + if pending.writesUsageFile, let usageFileURL, pending.snapshots != lastUsageFileSnapshots { + await writeUsageFile(pending.snapshots, to: usageFileURL) } } cacheDrainTask = nil } + private func writeCache(_ snapshots: [ProviderID: ProviderSnapshot]) async { + 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 + } + } + + private func writeUsageFile(_ snapshots: [ProviderID: ProviderSnapshot], to url: URL) async { + let failure = await Task.detached(priority: .utility) { () -> String? in + do { + try UsageExport(snapshots: snapshots).write(to: url) + return nil + } catch { + return String(describing: error) + } + }.value + if let failure { + await failureHandler(.usageFileWrite(failure)) + } else { + lastUsageFileSnapshots = snapshots + workload.usageFileWrites += 1 + } + } + private func drainWidgets(_ widgetStore: WidgetSnapshotStore) async { while let snapshot = pendingWidget { pendingWidget = nil diff --git a/Sources/TokenMenuBarCore/Refresh/UsageExport.swift b/Sources/TokenMenuBarCore/Refresh/UsageExport.swift new file mode 100644 index 0000000..91fa292 --- /dev/null +++ b/Sources/TokenMenuBarCore/Refresh/UsageExport.swift @@ -0,0 +1,60 @@ +import Foundation + +/// The snapshot set as scripts read it: `--usage-json` prints it and `Settings.writeUsageFile` keeps a copy next to +/// the widget snapshot after every refresh cycle. +public struct UsageExport: Codable, Sendable, Equatable { + public static let fileName = "usage.json" + + public struct Window: Codable, Sendable, Equatable { + public let id: String + public let label: String + public let usedPercent: Double + public let resetsAt: Date? + + public init(id: String, label: String, usedPercent: Double, resetsAt: Date?) { + self.id = id + self.label = label + self.usedPercent = usedPercent + self.resetsAt = resetsAt + } + } + + public struct Provider: Codable, Sendable, Equatable { + public let id: ProviderID + public let plan: String? + public let fetchedAt: Date + public let windows: [Window] + + public init(id: ProviderID, plan: String?, fetchedAt: Date, windows: [Window]) { + self.id = id + self.plan = plan + self.fetchedAt = fetchedAt + self.windows = windows + } + } + + public let providers: [Provider] + + public init(snapshots: [ProviderID: ProviderSnapshot]) { + providers = snapshots.keys.sorted().map { id in + let snapshot = snapshots[id]! + return Provider( + id: id, plan: snapshot.identity?.planName, fetchedAt: snapshot.fetchedAt, + windows: snapshot.windows.map { + Window(id: $0.id, label: $0.label, usedPercent: $0.usedPercent, resetsAt: $0.resetsAt) + }) + } + } + + public func json() throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(self) + } + + public func write(to url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try json().write(to: url, options: .atomic) + } +} diff --git a/Sources/TokenMenuBarCore/SandboxAccess.swift b/Sources/TokenMenuBarCore/SandboxAccess.swift index 793df7e..a6eb65e 100644 --- a/Sources/TokenMenuBarCore/SandboxAccess.swift +++ b/Sources/TokenMenuBarCore/SandboxAccess.swift @@ -70,6 +70,8 @@ extension ProviderID { [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 .antigravity: + [] case .cursor: [ SandboxResource(id: "cursor.support", relativePath: "Library/Application Support/Cursor", provider: self), diff --git a/Sources/TokenMenuBarCore/Settings.swift b/Sources/TokenMenuBarCore/Settings.swift index 21bc83e..b449ef6 100644 --- a/Sources/TokenMenuBarCore/Settings.swift +++ b/Sources/TokenMenuBarCore/Settings.swift @@ -156,6 +156,27 @@ public final class Settings { /// 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 var usageDisplay: UsageDisplay { didSet { store(usageDisplay.rawValue, key: .usageDisplay) } } + public var hidePersonalInformation: Bool { + didSet { store(hidePersonalInformation, key: .hidePersonalInformation) } + } + public var paceWorkdays: Int { + didSet { + let clamped = min(max(paceWorkdays, PaceEstimate.workdayRange.lowerBound), PaceEstimate.workdayRange.upperBound) + if clamped != paceWorkdays { + paceWorkdays = clamped + return + } + store(paceWorkdays, key: .paceWorkdays) + } + } + + public var presentationOptions: UsageDisplayOptions { + UsageDisplayOptions( + display: usageDisplay, paceWorkdays: paceWorkdays, hidePersonalInformation: hidePersonalInformation) + } + public var writeUsageFile: Bool { didSet { store(writeUsageFile, key: .writeUsageFile) } } + public var firstRunCardDismissed: Bool { didSet { store(firstRunCardDismissed, key: .firstRunCardDismissed) } } public init(defaults: UserDefaults) { self.defaults = defaults @@ -164,8 +185,10 @@ public final class Settings { defaults.object(forKey: Key.analyticsMinutes.rawValue) as? Int ?? Self.defaultAnalyticsMinutes historyRetentionDays = defaults.object(forKey: Key.historyRetentionDays.rawValue) as? Int ?? Self.defaultHistoryRetentionDays + let knownProviders = Self.loadCodable(Set.self, defaults, .knownProviders) + let addedProviders = knownProviders.map { Set(ProviderID.allCases).subtracting($0) } ?? [] let storedProviders = Self.loadCodable(Set.self, defaults, .enabledProviders) - enabledProviders = storedProviders ?? Set(ProviderID.allCases) + enabledProviders = (storedProviders ?? Set(ProviderID.allCases)).union(addedProviders) showAllProviders = defaults.bool(forKey: Key.showAllProviders.rawValue) configuredProviders = Self.loadCodable(Set.self, defaults, .configuredProviders) @@ -180,7 +203,9 @@ public final class Settings { 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 + let storedOrder = Self.loadCodable([ProviderID].self, defaults, .providerOrder) ?? ProviderID.allCases + providerOrder = + storedOrder + ProviderID.allCases.filter { addedProviders.contains($0) && !storedOrder.contains($0) } modelOrder = Self.loadCodable([WindowKey].self, defaults, .modelOrder) ?? [] hideUnusedModels = defaults.bool(forKey: Key.hideUnusedModels.rawValue) allowTokenRefresh = defaults.bool(forKey: Key.allowTokenRefresh.rawValue) @@ -204,7 +229,18 @@ public final class Settings { lastLaunchedVersion = defaults.string(forKey: Key.lastLaunchedVersion.rawValue) accessBookmarks = Self.loadCodable([String: Data].self, defaults, .accessBookmarks) ?? [:] demoMode = defaults.object(forKey: Key.demoMode.rawValue) as? Bool + usageDisplay = defaults.string(forKey: Key.usageDisplay.rawValue).flatMap(UsageDisplay.init(rawValue:)) ?? .used + hidePersonalInformation = defaults.bool(forKey: Key.hidePersonalInformation.rawValue) + paceWorkdays = + defaults.object(forKey: Key.paceWorkdays.rawValue) as? Int ?? PaceEstimate.workdayRange.upperBound + writeUsageFile = defaults.bool(forKey: Key.writeUsageFile.rawValue) + firstRunCardDismissed = defaults.bool(forKey: Key.firstRunCardDismissed.rawValue) loading = false + storeCodable(Set(ProviderID.allCases), key: .knownProviders) + if !addedProviders.isEmpty { + storeCodable(providerOrder, key: .providerOrder) + storeCodable(enabledProviders, key: .enabledProviders) + } } public func bookmark(for resource: SandboxResource) -> Data? { @@ -276,6 +312,11 @@ public final class Settings { lastLaunchedVersion = fresh.lastLaunchedVersion accessBookmarks = fresh.accessBookmarks demoMode = fresh.demoMode + usageDisplay = fresh.usageDisplay + hidePersonalInformation = fresh.hidePersonalInformation + paceWorkdays = fresh.paceWorkdays + writeUsageFile = fresh.writeUsageFile + firstRunCardDismissed = fresh.firstRunCardDismissed } public var activeTemplate: String { @@ -290,6 +331,8 @@ public final class Settings { case historyRange, historyRollup, historyStacked, historyUseUTC, historyHiddenKeys, historyAnalyticsMetric, historyMetricID case detailedLogging, automaticUpdates, lastLaunchedVersion, accessBookmarks, demoMode + case usageDisplay, hidePersonalInformation, paceWorkdays + case writeUsageFile, firstRunCardDismissed, knownProviders } diff --git a/Sources/TokenMenuBarCore/SettingsPresentation.swift b/Sources/TokenMenuBarCore/SettingsPresentation.swift index 826ad55..21f8fa2 100644 --- a/Sources/TokenMenuBarCore/SettingsPresentation.swift +++ b/Sources/TokenMenuBarCore/SettingsPresentation.swift @@ -362,9 +362,10 @@ public struct SettingsProviderPresentation: Sendable, Equatable { public let lastSuccess: String public let service: String - public init(state: ProviderState, now: Date) { + public init(state: ProviderState, now: Date, hidePersonalInformation: Bool = false) { if let identity = state.snapshot?.identity { - self.identity = [identity.email, identity.organization, identity.planName, identity.tier] + let shown = hidePersonalInformation ? identity.hidingPersonalInformation : identity + self.identity = [shown.email, shown.organization, shown.planName, shown.tier] .compactMap { $0 } .uniqued() .joined(separator: " · ") diff --git a/Sources/TokenMenuBarCore/StatusBar/StatusItemModel.swift b/Sources/TokenMenuBarCore/StatusBar/StatusItemModel.swift index 0bc9327..3a115a7 100644 --- a/Sources/TokenMenuBarCore/StatusBar/StatusItemModel.swift +++ b/Sources/TokenMenuBarCore/StatusBar/StatusItemModel.swift @@ -72,6 +72,7 @@ public struct StatusItemInput: Sendable { public let order: WindowOrder public let labels: [WindowKey: String] public let now: Date + public let display: UsageDisplay public let tier: StatusTier public init( @@ -85,6 +86,7 @@ public struct StatusItemInput: Sendable { order: WindowOrder, labels: [WindowKey: String], now: Date, + display: UsageDisplay = .used, tier: StatusTier = .configured ) { self.snapshots = snapshots @@ -97,6 +99,7 @@ public struct StatusItemInput: Sendable { self.order = order self.labels = labels self.now = now + self.display = display self.tier = tier } @@ -104,7 +107,7 @@ public struct StatusItemInput: Sendable { StatusItemInput( snapshots: snapshots, availability: availability, selectedKeys: selectedKeys, format: format, customTemplate: customTemplate, decimals: decimals, hideZeroCells: hideZeroCells, order: order, labels: labels, - now: now, tier: tier) + now: now, display: display, tier: tier) } var effectiveFormat: StatusFormat { @@ -178,7 +181,6 @@ public enum StatusItemBuilder { } 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) @@ -192,6 +194,10 @@ public enum StatusItemBuilder { 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 countdown = + format != .miniBars + && (template.referencesCountdown + || template.referencesExhaustedCountdown && entries.contains { $0.2.usedPercent >= 100 }) let cells: [StatusCell] if format == .miniBars { let providers = input.order == .percent ? orderedProviders(entries) : entries.map(\.0.provider).uniqued() @@ -202,7 +208,8 @@ public enum StatusItemBuilder { label: labels[$0.0]!, percent: $0.2.usedPercent) } - let tooltip = own.map { "\($0.2.label): \(Format.percent($0.2.usedPercent))" }.joined(separator: "\n") + let tooltip = own.map { "\($0.2.label): \(percentText($0.2, display: input.display))" } + .joined(separator: "\n") return StatusCell( id: provider.rawValue, provider: provider, lines: [], bars: bars, percent: own.map(\.2.usedPercent).max()!, tooltip: tooltip) @@ -220,11 +227,12 @@ public enum StatusItemBuilder { decimals: input.decimals, planName: snapshot.identity?.planName, credits: snapshot.credits?.formattedBalance, - now: input.now + now: input.now, + display: input.display ) let lines = StatusTemplate.render(template, context: context) let tooltip = - "\(key.provider.displayName) \(window.label): \(Format.percent(window.usedPercent)), " + "\(key.provider.displayName) \(window.label): \(percentText(window, display: input.display)), " + "resets \(Format.countdown(to: window.resetsAt, now: input.now))" return StatusCell( id: key.storageKey, provider: key.provider, lines: lines, percent: window.usedPercent, tooltip: tooltip) @@ -235,6 +243,12 @@ public enum StatusItemBuilder { countdownActive: countdown && !cells.isEmpty) } + /// A bare percent reads as used, so only the remaining form says which it is. + static func percentText(_ window: QuotaWindow, display: UsageDisplay) -> String { + let text = Format.percent(used: window.usedPercent, display: display) + return display == .used ? text : "\(text) \(display.suffix)" + } + static func worstPerProvider( _ entries: [(WindowKey, ProviderSnapshot, QuotaWindow)] ) -> [( diff --git a/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift b/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift index cfd4f92..b0f674d 100644 --- a/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift +++ b/Sources/TokenMenuBarCore/StatusBar/StatusTemplate.swift @@ -4,15 +4,28 @@ public enum StatusFormat: String, CaseIterable, Codable, Sendable { case stacked = "Stacked" case inline = "Inline" case miniBars = "Mini bars" + case percentCountdown = "Percent + countdown" + case countdownWhenExhausted = "Countdown when exhausted" case custom = "Custom" public var template: String? { switch self { case .stacked: "{label}\n{pct}" case .inline: "{label}:{pct}" + case .percentCountdown: "{label} {pct} · {reset}" + case .countdownWhenExhausted: "{label}\n{pctOrReset}" case .miniBars, .custom: nil } } + + /// The segment title; the two countdown presets would not fit the picker under their full names. + public var pickerLabel: String { + switch self { + case .percentCountdown: "Countdown" + case .countdownWhenExhausted: "Countdown at 100%" + case .stacked, .inline, .miniBars, .custom: rawValue + } + } } public struct StatusRun: Hashable, Sendable { @@ -40,10 +53,11 @@ public struct StatusCellContext: Sendable { public let planName: String? public let credits: String? public let now: Date + public let display: UsageDisplay public init( provider: ProviderID, window: QuotaWindow, cellLabel: String, shortLabel: String, decimals: Int, planName: String?, - credits: String?, now: Date + credits: String?, now: Date, display: UsageDisplay = .used ) { self.provider = provider self.window = window @@ -53,6 +67,7 @@ public struct StatusCellContext: Sendable { self.planName = planName self.credits = credits self.now = now + self.display = display } } @@ -63,10 +78,11 @@ public enum StatusTemplate { ("{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"), + ("{pct}", "percent at the configured decimals, used or left per the Show usage as setting"), + ("{pct0}", "percent, no decimals"), + ("{pct1}", "percent, one decimal"), ("{remaining}", "remaining percent"), + ("{pctOrReset}", "percent, or the reset countdown once the window is at 100%"), ("{reset}", "countdown to reset"), ("{resetClock}", "reset time"), ("{plan}", "plan name"), @@ -84,10 +100,13 @@ public enum StatusTemplate { public struct Compiled: Sendable { let tokens: [Token] public let referencesCountdown: Bool + /// True for `{pctOrReset}`, which counts down only while its window is exhausted. + public let referencesExhaustedCountdown: Bool init(_ template: String) { tokens = StatusTemplate.parse(template) referencesCountdown = tokens.contains { $0 == .placeholder("reset") } + referencesExhaustedCountdown = tokens.contains { $0 == .placeholder("pctOrReset") } } } @@ -179,19 +198,31 @@ public enum StatusTemplate { static func run(for name: String, context: StatusCellContext) -> StatusRun? { let percent = context.window.usedPercent + let display = context.display 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 "pct": + return StatusRun( + text: Format.percent(used: percent, display: display, decimals: context.decimals), kind: .usage(percent)) + case "pct0": return StatusRun(text: Format.percent(used: percent, display: display), kind: .usage(percent)) + case "pct1": + return StatusRun(text: Format.percent(used: percent, display: display, decimals: 1), kind: .usage(percent)) + case "pct2": + return StatusRun(text: Format.percent(used: percent, display: display, decimals: 2), kind: .usage(percent)) case "remaining": return StatusRun( text: Format.percent(context.window.remainingPercent, decimals: context.decimals), kind: .usage(percent)) + case "pctOrReset": + // Stays in the usage colour while counting down, so the red still says the window is exhausted. + return StatusRun( + text: percent >= 100 + ? Format.compactCountdown(to: context.window.resetsAt, now: context.now) + : Format.percent(used: percent, display: display, decimals: context.decimals), + kind: .usage(percent)) case "reset": return StatusRun(text: Format.compactCountdown(to: context.window.resetsAt, now: context.now), kind: .number) case "resetClock": diff --git a/Sources/TokenMenuBarCore/UsagePresentation.swift b/Sources/TokenMenuBarCore/UsagePresentation.swift index b238de3..22d6a5d 100644 --- a/Sources/TokenMenuBarCore/UsagePresentation.swift +++ b/Sources/TokenMenuBarCore/UsagePresentation.swift @@ -40,6 +40,40 @@ public enum UsageDeadline: Sendable, Hashable { } } +/// The settings that change how a window reads without changing what it measures. +public struct UsageDisplayOptions: Sendable, Hashable { + public let display: UsageDisplay + public let paceWorkdays: Int + public let hidePersonalInformation: Bool + + public init(display: UsageDisplay = .used, paceWorkdays: Int = 7, hidePersonalInformation: Bool = false) { + self.display = display + self.paceWorkdays = paceWorkdays + self.hidePersonalInformation = hidePersonalInformation + } + + public static let standard = UsageDisplayOptions() +} + +extension ProviderIdentity { + public static let hiddenEmail = "account" + public static let hiddenOrganization = "workspace" + + public var hidingPersonalInformation: ProviderIdentity { + ProviderIdentity( + planName: planName, tier: tier, email: email.map { _ in Self.hiddenEmail }, + organization: organization.map { _ in Self.hiddenOrganization }, + subscriptionActiveUntil: subscriptionActiveUntil) + } + + /// What `hidingPersonalInformation` swaps out, for text that was assembled before the setting could apply. + public var personalReplacements: [(value: String, replacement: String)] { + [(email, Self.hiddenEmail), (organization, Self.hiddenOrganization)].compactMap { value, replacement in + value.map { ($0, replacement) } + } + } +} + public struct UsageMetricPresentation: Sendable, Hashable, Identifiable { public let title: String public let value: String diff --git a/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift b/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift index 64c25fc..462c516 100644 --- a/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift +++ b/Sources/TokenMenuBarCore/Widgets/WidgetSnapshot.swift @@ -17,8 +17,8 @@ public struct WidgetRow: Codable, Sendable, Hashable, Identifiable { public var id: WindowKey { key } - public var percentText: String { - Format.percent(usedPercent) + public func percentText(display: UsageDisplay = .used) -> String { + Format.percent(used: usedPercent, display: display) } public func resetText(now: Date) -> String { @@ -42,11 +42,23 @@ public struct WidgetSnapshot: Codable, Sendable, Hashable { public let rows: [WidgetRow] public let attention: Bool public let updatedAt: Date + public let display: UsageDisplay - public init(rows: [WidgetRow], attention: Bool, updatedAt: Date) { + public init(rows: [WidgetRow], attention: Bool, updatedAt: Date, display: UsageDisplay = .used) { self.rows = rows self.attention = attention self.updatedAt = updatedAt + self.display = display + } + + // A snapshot the app wrote before the display setting existed still has to render. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + rows: try container.decode([WidgetRow].self, forKey: .rows), + attention: try container.decode(Bool.self, forKey: .attention), + updatedAt: try container.decode(Date.self, forKey: .updatedAt), + display: try container.decodeIfPresent(UsageDisplay.self, forKey: .display) ?? .used) } /// Shown until the app writes its first snapshot. Empty on purpose: `placeholder` carries sample percentages @@ -68,7 +80,7 @@ public struct WidgetSnapshot: Codable, Sendable, Hashable { public static func build( snapshots: [ProviderID: ProviderSnapshot], availability: [ProviderID: QuotaAvailability], - selectedKeys: [WindowKey], now: Date + selectedKeys: [WindowKey], display: UsageDisplay = .used, 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 } @@ -77,7 +89,7 @@ public struct WidgetSnapshot: Codable, Sendable, Hashable { resetsAt: window.resetsAt) } return WidgetSnapshot( - rows: rows, attention: availability.values.contains(.authenticationRequired), updatedAt: now) + rows: rows, attention: availability.values.contains(.authenticationRequired), updatedAt: now, display: display) } public var hasData: Bool { @@ -85,7 +97,7 @@ public struct WidgetSnapshot: Codable, Sendable, Hashable { } public func hasSameContent(as other: WidgetSnapshot) -> Bool { - rows == other.rows && attention == other.attention + rows == other.rows && attention == other.attention && display == other.display } public func shouldPublish(after previous: WidgetSnapshot?) -> Bool { diff --git a/Sources/TokenMenuBarUI/AppController.swift b/Sources/TokenMenuBarUI/AppController.swift index 2aa7f65..004b491 100644 --- a/Sources/TokenMenuBarUI/AppController.swift +++ b/Sources/TokenMenuBarUI/AppController.swift @@ -114,6 +114,9 @@ public struct AppDependencies { ?? SnapshotPersistence( cache: snapshotCache, widgetStore: widgetStore, + usageFileURL: snapshotCache.url.map { + $0.deletingLastPathComponent().appendingPathComponent(UsageExport.fileName) + }, failureHandler: { failure in log.logError(failure.message) }, reloadWidgets: reloadWidgets) self.reloadWidgets = reloadWidgets @@ -155,6 +158,7 @@ public final class AppController { private var providerRediscoveryGeneration = 0 private var providerRediscoveryPolicy = ProviderRediscoveryPolicy() private var notificationsEnabled: Bool + private var firstRunCardPending = false private let initialStatusItem: NSStatusItem? public init(dependencies: AppDependencies, initialStatusItem: NSStatusItem? = nil) { @@ -246,6 +250,9 @@ public final class AppController { if let previous = dependencies.settings.lastLaunchedVersion, previous != dependencies.appInfo.version { log.log("updated from \(previous) to \(dependencies.appInfo.version)") } + firstRunCardPending = + dependencies.settings.lastLaunchedVersion == nil && dependencies.settings.configuredProviders.isEmpty + && !dependencies.settings.firstRunCardDismissed dependencies.settings.lastLaunchedVersion = dependencies.appInfo.version log.log("launch \(dependencies.appInfo.name) \(dependencies.appInfo.version) (\(dependencies.appInfo.build))") let item = StatusItemController( @@ -828,6 +835,14 @@ public final class AppController { }) dependencies.state.applySetupStates(setups) coordinator.reschedule() + guard firstRunCardPending else { return } + firstRunCardPending = false + environment.firstRunCard = FirstRunCard( + detected: registry.ids.filter { Self.credentialHealth(discovery.credentials, provider: $0).isUsable }) + dependencies.settings.lastTab = .usage + popover?.select(tab: .usage) + dependencies.log.log("first run: opening the usage tab with the detected providers") + openPopoverAfterStatusItemAttachment() } static func credentialHealth( diff --git a/Sources/TokenMenuBarUI/AppIcon.swift b/Sources/TokenMenuBarUI/AppIcon.swift index 4456fc6..5e105ae 100644 --- a/Sources/TokenMenuBarUI/AppIcon.swift +++ b/Sources/TokenMenuBarUI/AppIcon.swift @@ -174,6 +174,7 @@ public enum ProviderGlyph { case .claude: "sparkle" case .codex: "chevron.left.forwardslash.chevron.right" case .gemini: "sparkles" + case .antigravity: "atom" case .cursor: "cursorarrow.rays" case .copilot: "circle.hexagongrid" } @@ -184,6 +185,7 @@ public enum ProviderGlyph { 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 .antigravity: Color(red: 0.17, green: 0.24, blue: 0.48) case .cursor: Color(red: 0.45, green: 0.45, blue: 0.5) case .copilot: Color(red: 0.42, green: 0.35, blue: 0.8) } diff --git a/Sources/TokenMenuBarUI/ExportRunner.swift b/Sources/TokenMenuBarUI/ExportRunner.swift index 907ab2c..8284b68 100644 --- a/Sources/TokenMenuBarUI/ExportRunner.swift +++ b/Sources/TokenMenuBarUI/ExportRunner.swift @@ -3,6 +3,19 @@ import TokenMenuBarCore @MainActor public enum ExportRunner { + public static func run( + _ invocation: ExportInvocation, supportDirectory: URL = LiveDependencies.Paths().supportDirectory + ) async throws -> String { + switch invocation { + case .files(let command, let directory): + let written = try await run(command, directory: directory) + return "wrote \(written.count) files to \(directory.path)" + case .usageJSON: + return try UsageExportCommand.output( + cache: SnapshotCache(url: supportDirectory.appendingPathComponent("snapshots.json"))) + } + } + public static func run( _ command: ExportCommand, directory: URL, now: Date = Date(), settle: Duration = .seconds(2) ) async throws -> [URL] { diff --git a/Sources/TokenMenuBarUI/LiveDependencies.swift b/Sources/TokenMenuBarUI/LiveDependencies.swift index ee802ea..f8963a3 100644 --- a/Sources/TokenMenuBarUI/LiveDependencies.swift +++ b/Sources/TokenMenuBarUI/LiveDependencies.swift @@ -334,7 +334,8 @@ public enum LiveDependencies { keychain: keychain, allowTokenRefresh: allowTokenRefresh, allowsGeminiTokenRefresh: !isSandboxed, - analyticsWatermarkPersistence: analyticsWatermarkPersistence) + analyticsWatermarkPersistence: analyticsWatermarkPersistence, + processScanner: isSandboxed ? nil : LibprocProcessScanner()) let registry = buildRegistry(configuration, client, log) return ProviderBuildResult( registry: registry, replacementBookmarks: replacementBookmarks, logEvents: logEvents) diff --git a/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift b/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift index e498c63..d77bb69 100644 --- a/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift +++ b/Sources/TokenMenuBarUI/ProviderMarks/ProviderMarkCatalog.swift @@ -53,6 +53,7 @@ public enum ProviderMarkCatalog { case (.claude, _): "Claude.svg" case (.gemini, _): "GoogleGemini.svg" case (.copilot, _): "GitHubCopilot.svg" + case (.antigravity, _): nil } } @@ -66,6 +67,8 @@ public enum ProviderMarkCatalog { case (.codex, .dark): BrandColor(0x0C_8064) case (.gemini, .light): BrandColor(0x76_51B5) case (.gemini, .dark): BrandColor(0x5D_3D91) + case (.antigravity, .light): BrandColor(0x2C_3E7A) + case (.antigravity, .dark): BrandColor(0x1C_2A57) case (.cursor, .light): BrandColor(0x67_78C4) case (.cursor, .dark): BrandColor(0x43_4D8E) case (.copilot, .light): BrandColor(0x6E_40C9) @@ -83,6 +86,8 @@ public enum ProviderMarkCatalog { case (.codex, .dark): BrandColor(0xFF_FFFF) case (.gemini, .light): BrandColor(0x17_4EA6) case (.gemini, .dark): BrandColor(0xDC_E8FF) + case (.antigravity, .light): BrandColor(0xE4_EAFF) + case (.antigravity, .dark): BrandColor(0xFF_FFFF) case (.cursor, .light): BrandColor(0x26_251E) case (.cursor, .dark): BrandColor(0xED_ECEC) case (.copilot, .light): BrandColor(0x3C_2D91) diff --git a/Sources/TokenMenuBarUI/Resources/ProviderMarks/provider-marks.json b/Sources/TokenMenuBarUI/Resources/ProviderMarks/provider-marks.json index 8670e2a..a604716 100644 --- a/Sources/TokenMenuBarUI/Resources/ProviderMarks/provider-marks.json +++ b/Sources/TokenMenuBarUI/Resources/ProviderMarks/provider-marks.json @@ -118,5 +118,14 @@ ] } ], - "fallbacks": [] + "fallbacks": [ + { + "provider": "antigravity", + "brand": "Google Antigravity", + "approvalState": "text-fallback-no-published-mark", + "sourcePage": "https://antigravity.google/", + "terms": "https://about.google/brand-resource-center/", + "fallbackText": "AG" + } + ] } diff --git a/Sources/TokenMenuBarUI/UIActions.swift b/Sources/TokenMenuBarUI/UIActions.swift index 97385c7..6563ce7 100644 --- a/Sources/TokenMenuBarUI/UIActions.swift +++ b/Sources/TokenMenuBarUI/UIActions.swift @@ -84,6 +84,7 @@ public final class UIEnvironment { public var isDemo: Bool public var increaseContrast: Bool public var providerFocusRequest: ProviderSettingsFocusRequest? + public var firstRunCard: FirstRunCard? public var samples: [WindowKey: [UsageSample]] = [:] public var now: Date public private(set) var usagePresentation: UsagePresentation @@ -144,7 +145,7 @@ public final class UIEnvironment { 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) + isRefreshing: state.isRefreshing, options: settings.presentationOptions, now: now) observeUsageInputs() } @@ -183,7 +184,7 @@ public final class UIEnvironment { 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) + isRefreshing: state.isRefreshing, options: settings.presentationOptions, now: now) usagePresentationDirty = false } @@ -266,6 +267,9 @@ public final class UIEnvironment { _ = settings.configuredProviders _ = settings.selectedWindows _ = settings.hasCustomSelection + _ = settings.usageDisplay + _ = settings.paceWorkdays + _ = settings.hidePersonalInformation } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } diff --git a/Sources/TokenMenuBarUI/Views/SettingsTab.swift b/Sources/TokenMenuBarUI/Views/SettingsTab.swift index 66471bc..b7ee370 100644 --- a/Sources/TokenMenuBarUI/Views/SettingsTab.swift +++ b/Sources/TokenMenuBarUI/Views/SettingsTab.swift @@ -248,6 +248,7 @@ public struct SettingsTab: View { notifyOnReset: settings.notifications.notifyOnReset, notifyOnAuthProblems: settings.notifications.notifyOnAuthProblems, notifyOnExpiringCredits: settings.notifications.notifyOnExpiringCredits, + notifyOnPace: settings.notifications.notifyOnPace, playSound: settings.notifications.playSound) } @@ -392,7 +393,7 @@ public struct SettingsTab: View { selectedKeys: order.orderedSelection(selection), format: settings.statusFormat, customTemplate: settings.customTemplate, decimals: settings.percentDecimals, hideZeroCells: settings.hideZeroCells, order: settings.windowOrder, - labels: labels, now: environment.now)) + labels: labels, now: environment.now, display: settings.usageDisplay)) } private var about: some View { @@ -551,8 +552,20 @@ public struct SettingsTab: View { "Uses shorter status renderings when menu bar room runs low. " + "When off, macOS may truncate a wide status item." )) + Toggle("Hide account details", isOn: menuBarSetting(\.hidePersonalInformation)) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Hide account details", + body: + "Replaces e-mail addresses and workspace names with \"account\" and \"workspace\" in the " + + "popover, the provider list and diagnostics, for screen sharing and screenshots." + )) } } + PanelRow("Show usage as") { + usageDisplayPicker + } if settings.statusFormat == .custom { PanelRow("Template") { VStack(alignment: .leading, spacing: 3) { @@ -565,7 +578,7 @@ public struct SettingsTab: View { "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}") + Text("{cell} {pct} {label} {provider} {window} {reset} {pctOrReset}") .font(.caption.monospaced()) .semanticForeground(.secondary) } @@ -605,7 +618,7 @@ public struct SettingsTab: View { private var formatPicker: some View { NativeSegmentedControl( - StatusFormat.allCases.map { (value: $0, label: $0.rawValue) }, + StatusFormat.allCases.map { (value: $0, label: $0.pickerLabel) }, selection: menuBarSetting(\.statusFormat), accessibilityLabel: "Format" ) @@ -615,7 +628,25 @@ public struct SettingsTab: View { 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." + + "Mini bars use compact gauges. Countdown adds the time to reset after the percentage. " + + "Countdown at 100% shows the percentage until a window is exhausted, then the time to its reset. " + + "Custom uses the template and per-model short labels." + )) + } + + private var usageDisplayPicker: some View { + NativeSegmentedControl( + UsageDisplay.allCases.map { (value: $0, label: $0.rawValue) }, + selection: menuBarSetting(\.usageDisplay), + accessibilityLabel: "Show usage as" + ) + .fixedSize() + .richHelp( + TooltipContent( + title: "Show usage as", + body: + "Used shows the share of each window already spent; Left shows what remains. " + + "Applies to the menu bar, the Usage tab, the widgets and the {pct} tokens; colours still follow usage." )) } @@ -707,7 +738,8 @@ public struct SettingsTab: View { func providerRow(_ providerID: ProviderID) -> some View { let state = environment.state.state(for: providerID) - let presentation = SettingsProviderPresentation(state: state, now: environment.now) + let presentation = SettingsProviderPresentation( + state: state, now: environment.now, hidePersonalInformation: settings.hidePersonalInformation) let issue = recoveryIssue(providerID) return VStack(alignment: .leading, spacing: 3) { ResponsivePanelLayout { @@ -951,6 +983,30 @@ public struct SettingsTab: View { + "Shorter intervals use more disk, network, and CPU." )) } + PanelRow("Pace") { + Stepper( + "Expect usage on \(settings.paceWorkdays) days a week", value: setting(\.paceWorkdays), + in: PaceEstimate.workdayRange + ) + .richHelp( + TooltipContent( + title: "Workdays", + body: + "Spreads the expected pace of windows longer than a day over Monday to the chosen weekday, so a " + + "weekly window is not ahead of pace on Monday morning. Seven days keeps an even pace." + )) + } + PanelRow("Scripts") { + Toggle("Write usage.json", isOn: setting(\.writeUsageFile)) + .toggleStyle(.checkbox) + .richHelp( + TooltipContent( + title: "Usage file for scripts", + body: + "Keeps the last snapshot set as usage.json in the support folder after every refresh, in the same " + + "shape as the --usage-json command. Off by default." + )) + } PanelRow("History") { ResponsivePanelLayout { HStack(spacing: 8) { @@ -1070,6 +1126,16 @@ public struct SettingsTab: View { body: "Sends a notice when a Codex reset credit expires within 24 hours. " + "Disabled while usage notifications are off.")) + Toggle("Pace", isOn: setting(\.notifications.notifyOnPace)) + .toggleStyle(.checkbox) + .disabled(!settings.notifications.enabled) + .richHelp( + TooltipContent( + title: "Pace", + body: + "Sends a notice when a window is on course to run out before it resets, and when it pulls ahead " + + "of pace. Each fires once per window until the pace recovers. Disabled while usage " + + "notifications are off.")) Toggle("Sound", isOn: setting(\.notifications.playSound)) .toggleStyle(.checkbox) .disabled(!settings.notifications.enabled) diff --git a/Sources/TokenMenuBarUI/Views/SpendView.swift b/Sources/TokenMenuBarUI/Views/SpendView.swift index c6c663b..8ba5b4b 100644 --- a/Sources/TokenMenuBarUI/Views/SpendView.swift +++ b/Sources/TokenMenuBarUI/Views/SpendView.swift @@ -42,3 +42,46 @@ public struct SpendView: View { } } } + +@MainActor +@Observable +public final class SpendSummaryModel { + public private(set) var summary: SpendSummary? + + public init() {} + + public func load(history: UsageHistoryStore, providers: Set, now: Date, timeZone: TimeZone) async { + summary = + (try? await SpendSummaryPresenter.load( + history: history, providers: providers.sorted(), now: now, timeZone: timeZone)) ?? .empty + } +} + +public struct SpendSummaryTiles: View { + public let summary: SpendSummary + + public init(summary: SpendSummary) { + self.summary = summary + } + + public var body: some View { + HStack(spacing: 8) { + ForEach(summary.tiles) { tile in + VStack(alignment: .leading, spacing: 2) { + Text(tile.title).font(.callout).semanticForeground(.secondary) + Text(tile.text).font(.body.monospacedDigit()) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.05))) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(tile.title): \(tile.text)") + .accessibilityIdentifier("spend-\(tile.id)") + } + } + .richHelp(TooltipContent(title: summary.attribution, body: summary.breakdown)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spend-summary") + } +} diff --git a/Sources/TokenMenuBarUI/Views/UsageTab.swift b/Sources/TokenMenuBarUI/Views/UsageTab.swift index 819bcaf..2a1ec35 100644 --- a/Sources/TokenMenuBarUI/Views/UsageTab.swift +++ b/Sources/TokenMenuBarUI/Views/UsageTab.swift @@ -1,14 +1,37 @@ import SwiftUI import TokenMenuBarCore +public struct FirstRunCard: Equatable, Sendable { + public let detected: [ProviderID] + + public init(detected: [ProviderID]) { + self.detected = detected + } + + public var title: String { + detected.isEmpty + ? "No providers detected yet" : "Detected: \(detected.map(\.displayName).joined(separator: ", "))" + } + + public var description: String { + detected.isEmpty + ? "Sign in to a supported CLI or editor, then review the providers to choose what the menu bar shows." + : "Their quotas appear below once the first refresh finishes. Review the providers to change what the " + + "menu bar shows." + } +} + public struct UsageTab: View { @Bindable var environment: UIEnvironment + public let spend: SpendSummaryModel public let onRefreshProvider: (ProviderID) -> Void public init( - environment: UIEnvironment, onRefreshProvider: ((ProviderID) -> Void)? = nil + environment: UIEnvironment, spend: SpendSummaryModel = SpendSummaryModel(), + onRefreshProvider: ((ProviderID) -> Void)? = nil ) { self.environment = environment + self.spend = spend self.onRefreshProvider = onRefreshProvider ?? environment.actions.refreshProvider } @@ -17,6 +40,12 @@ public struct UsageTab: View { let presentation = environment.usagePresentation VStack(alignment: .leading, spacing: 8) { header(presentation) + if let card = environment.firstRunCard { + FirstRunCardView(environment: environment, card: card) + } + if let summary = spend.summary, summary.hasData { + SpendSummaryTiles(summary: summary) + } if presentation.cards.isEmpty { HStack(alignment: .center, spacing: 12) { EmptyStateView( @@ -38,9 +67,25 @@ public struct UsageTab: View { } } .frame(maxWidth: .infinity, alignment: .leading) + .task(id: spendSchedule) { await loadSpend(spendSchedule) } } } + private var spendSchedule: SpendSummarySchedule { + SpendSummarySchedule( + visible: environment.state.popoverVisible && environment.settings.lastTab == .usage, + revision: environment.state.historyRevision, useUTC: environment.settings.historyUseUTC, + day: DayStamp.string(environment.now), + providers: environment.settings.activeProviders(states: environment.state.providers)) + } + + private func loadSpend(_ schedule: SpendSummarySchedule) async { + guard schedule.visible else { return } + await spend.load( + history: environment.history, providers: schedule.providers, now: environment.clock.now(), + timeZone: schedule.useUTC ? TimeZone(identifier: "UTC")! : .current) + } + private func header(_ presentation: UsagePresentation) -> some View { HStack(spacing: 8) { AppIconView(size: 22, tone: presentation.iconTone) @@ -75,6 +120,57 @@ public struct UsageTab: View { } } +private struct SpendSummarySchedule: Equatable { + let visible: Bool + let revision: UInt64 + let useUTC: Bool + let day: String + let providers: Set +} + +struct FirstRunCardView: View { + @Bindable var environment: UIEnvironment + let card: FirstRunCard + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "sparkles").font(.system(size: 22)).semanticForeground(.secondary).frame(width: 32) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 6) { + Text(card.title).font(.headline) + Text(card.description).font(.callout).semanticForeground(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + NativeActionButton("Review providers", action: review) + .controlSize(.small) + .accessibilityIdentifier("first-run-review-providers") + .richHelp( + TooltipContent( + title: "Review providers", + body: "Opens the Providers section of Settings, where each provider can be turned on or off.")) + NativeActionButton("Got it", action: dismiss) + .controlSize(.small) + .accessibilityIdentifier("first-run-dismiss") + .richHelp(TooltipContent(title: "Got it", body: "Hides this card. It does not come back.")) + } + } + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.accentColor.opacity(0.08))) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("first-run-card") + } + + func review() { + environment.actions.showProviders(nil) + } + + func dismiss() { + environment.settings.firstRunCardDismissed = true + environment.firstRunCard = nil + } +} + private struct UsageUpdatedText: View { let lastRefresh: Date? @Bindable var environment: UIEnvironment diff --git a/Sources/TokenMenuBarWidgets/UsageWidget.swift b/Sources/TokenMenuBarWidgets/UsageWidget.swift index 818f673..982bef6 100644 --- a/Sources/TokenMenuBarWidgets/UsageWidget.swift +++ b/Sources/TokenMenuBarWidgets/UsageWidget.swift @@ -92,7 +92,8 @@ public struct UsageWidgetView: View { } ForEach(rows) { row in WidgetRowView( - row: row, now: entry.date, compact: family == .systemSmall, increaseContrast: increaseContrast) + row: row, now: entry.date, compact: family == .systemSmall, increaseContrast: increaseContrast, + display: entry.snapshot.display) } Spacer(minLength: 0) } @@ -105,12 +106,16 @@ public struct WidgetRowView: View { public let now: Date public let compact: Bool public let increaseContrast: Bool + public let display: UsageDisplay - public init(row: WidgetRow, now: Date, compact: Bool, increaseContrast: Bool = false) { + public init( + row: WidgetRow, now: Date, compact: Bool, increaseContrast: Bool = false, display: UsageDisplay = .used + ) { self.row = row self.now = now self.compact = compact self.increaseContrast = increaseContrast + self.display = display } public var color: Color { @@ -123,7 +128,8 @@ public struct WidgetRowView: View { 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) + Text(row.percentText(display: display)).font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(color) } GeometryReader { proxy in ZStack(alignment: .leading) { diff --git a/Tests/TokenMenuBarCoreTests/AdaptiveRefreshTests.swift b/Tests/TokenMenuBarCoreTests/AdaptiveRefreshTests.swift new file mode 100644 index 0000000..aec1db8 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/AdaptiveRefreshTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +private let lowPower = PowerState(lowPowerMode: true, thermalPressure: false) + +// Cursor has no analytics clock, so the next refresh date reflects the usage interval alone. +@MainActor +private func makeAdaptiveCoordinator( + interval: Int = 300, clock: Clock = testClock, power: PowerSwitch = PowerSwitch(), + persistence: SnapshotPersistence? = nil, settings: Settings? = nil +) throws -> (RefreshCoordinator, AppState, ScriptedProvider) { + let settings = settings ?? Settings(defaults: testDefaults("adaptive-refresh")) + settings.setProvider(.cursor, enabled: true) + settings.setRefreshInterval(interval, for: .cursor) + let provider = ScriptedProvider( + id: .cursor, + results: [ + ProviderFetchResult( + outcome: .success( + ProviderSnapshot( + provider: .cursor, + windows: [ + QuotaWindow( + id: "plan", label: "Plan", group: .monthly, usedPercent: 40, resetsAt: nil, duration: nil) + ], fetchedAt: fixedNow))) + ]) + provider.pollingPolicy = PollingPolicy(minimumInterval: 60, activeInterval: 120, defaultInterval: 300) + let state = AppState() + state.update(.cursor) { $0.credentialState = .valid(expiresAt: nil) } + let coordinator = RefreshCoordinator( + registry: ProviderRegistry([provider]), settings: settings, state: state, + history: try UsageHistoryStore(url: nil), log: makeLog(), clock: clock, persistence: persistence, + powerState: { power.current } + ) { _ in } + return (coordinator, state, provider) +} + +@Test(arguments: throttledPowerStates) +@MainActor func scheduledRefreshWaitsTwiceAsLongUnderPowerPressure(_ pressure: PowerState) async throws { + let box = DateBox(fixedNow) + let power = PowerSwitch() + let (coordinator, _, provider) = try makeAdaptiveCoordinator(clock: box.clock, power: power) + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(300)) + + power.current = pressure + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(600)) + box.date = fixedNow.addingTimeInterval(300) + await coordinator.refresh(RefreshRequest()) + #expect(provider.callCount == 1) + await coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + #expect(provider.callCount == 2) + box.date = fixedNow.addingTimeInterval(900) + await coordinator.refresh(RefreshRequest()) + #expect(provider.callCount == 3) +} + +@Test @MainActor func scheduledRefreshWaitsThreeTimesAsLongAfterAnHourWithoutThePopover() async throws { + let box = DateBox(fixedNow) + let power = PowerSwitch() + let (coordinator, _, _) = try makeAdaptiveCoordinator(interval: 600, clock: box.clock, power: power) + await coordinator.refresh(RefreshRequest()) + + box.date = fixedNow.addingTimeInterval(RefreshCoordinator.idleThreshold - 1) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(600)) + box.date = fixedNow.addingTimeInterval(RefreshCoordinator.idleThreshold) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(1800)) + power.current = lowPower + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(TimeInterval(Settings.maximumRefreshSeconds))) +} + +@Test @MainActor func scheduledRefreshNeverExceedsTheMaximumInterval() async throws { + let power = PowerSwitch() + power.current = lowPower + let (coordinator, _, _) = try makeAdaptiveCoordinator(interval: Settings.maximumRefreshSeconds, power: power) + await coordinator.refresh(RefreshRequest()) + #expect(coordinator.nextRefreshDate() == fixedNow.addingTimeInterval(TimeInterval(Settings.maximumRefreshSeconds))) +} + +@Test @MainActor func openingThePopoverResetsTheIdleMultiplier() async throws { + let box = DateBox(fixedNow) + let recorder = SleepRecorder() + let clock = Clock(now: { box.date }, sleep: { try await recorder.sleep($0) }) + let (coordinator, state, provider) = try makeAdaptiveCoordinator(interval: 600, clock: clock) + coordinator.start() + defer { coordinator.stop() } + while await recorder.durations.count < 1 { await Task.yield() } + #expect(await recorder.durations == [600]) + + box.date = fixedNow.addingTimeInterval(RefreshCoordinator.idleThreshold) + coordinator.reschedule() + while await recorder.durations.count < 2 { await Task.yield() } + #expect(await recorder.durations == [600, 1800]) + #expect(provider.callCount == 2) + + state.popoverVisible = true + while await recorder.durations.count < 3 { await Task.yield() } + #expect(await recorder.durations == [600, 1800, 120]) + state.popoverVisible = false + while await recorder.durations.count < 4 { await Task.yield() } + #expect(await recorder.durations == [600, 1800, 120, 600]) + #expect(provider.callCount == 2) +} + +@Test(arguments: [true, false]) +@MainActor func coordinatorWritesTheUsageFileWhenTheSettingIsOn(enabled: Bool) async throws { + let root = temporaryDirectory() + let usageURL = root.appendingPathComponent(UsageExport.fileName) + let persistence = SnapshotPersistence( + cache: SnapshotCache(url: root.appendingPathComponent("snapshots.json")), usageFileURL: usageURL) + let settings = Settings(defaults: testDefaults("adaptive-usage-file")) + settings.writeUsageFile = enabled + let (coordinator, _, _) = try makeAdaptiveCoordinator(persistence: persistence, settings: settings) + await coordinator.refresh(RefreshRequest()) + await coordinator.flushPersistence() + #expect(FileManager.default.fileExists(atPath: usageURL.path) == enabled) + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent("snapshots.json").path)) +} diff --git a/Tests/TokenMenuBarCoreTests/AntigravityCredentialsTests.swift b/Tests/TokenMenuBarCoreTests/AntigravityCredentialsTests.swift new file mode 100644 index 0000000..63fb0c3 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/AntigravityCredentialsTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +private let expiry = Date(timeIntervalSince1970: 1_788_033_600) + +func antigravityKeychainValue(_ document: JSONValue, prefixed: Bool = true) -> Data { + let json = try! JSONEncoder().encode(document) + return prefixed ? Data((AntigravityAuth.keychainPrefix + json.base64EncodedString()).utf8) : json +} + +@Test( + arguments: [ + ("nested, prefixed", Fixtures.json("antigravity_keychain_token"), true, "1//0g-antigravity"), + ( + "root snake case, bare", + JSONValue.object([ + "access_token": .string("ya29.antigravity"), "refresh_token": .string("1//root"), + "expires_at": .string("2026-08-29T20:00:00Z"), + ]), false, "1//root" + ), + ( + "root camel case with epoch seconds", + .object([ + "accessToken": .string("ya29.antigravity"), "refreshToken": .string("1//camel"), + "expiresAt": .number(1_788_033_600), + ]), true, "1//camel" + ), + ( + "nested camel case with expiry", + .object([ + "token": .object([ + "accessToken": .string("ya29.antigravity"), "refreshToken": .string("1//nested"), + "expiry": .string("2026-08-29T20:00:00.5Z"), + ]) + ]), true, "1//nested" + ), + ]) +func antigravityAuthDecodesEveryKeychainLayout( + name: String, document: JSONValue, prefixed: Bool, refreshToken: String +) throws { + let auth = try #require( + try AntigravityAuth.parse(keychainValue: antigravityKeychainValue(document, prefixed: prefixed))) + #expect(auth.accessToken == "ya29.antigravity") + #expect(auth.refreshToken == refreshToken) + #expect(auth.expiresAt.map { abs($0.timeIntervalSince(expiry)) < 1 } == true) +} + +@Test func antigravityAuthRejectsDocumentsWithoutAnAccessToken() throws { + #expect(try AntigravityAuth.parse(keychainValue: antigravityKeychainValue(.object(["token": .object([:])]))) == nil) + #expect(AntigravityAuth(document: .object(["refresh_token": .string("r")])) == nil) +} + +@Test( + arguments: [ + ("go-keyring-base64:a", "base64"), ("go-keyring-base64:bm90IGpzb24=", "JSON"), ("plain text", "JSON"), + ]) +func antigravityAuthReportsMalformedItems(value: String, detail: String) { + #expect(throws: CredentialStoreError.malformed("Antigravity Keychain item is not \(detail)")) { + try AntigravityAuth.parse(keychainValue: Data(value.utf8)) + } +} + +@Test func antigravityAuthTracksExpiryAndRefresh() { + let auth = AntigravityAuth(accessToken: "a", refreshToken: "r", expiresAt: expiry) + #expect(auth.state(now: fixedNow) == .valid(expiresAt: expiry)) + #expect(auth.state(now: expiry) == .expired(expiry)) + #expect(AntigravityAuth(accessToken: "a").state(now: fixedNow) == .valid(expiresAt: nil)) + let refreshed = auth.refreshed(accessToken: "b", expiresIn: 60, now: fixedNow) + #expect(refreshed == AntigravityAuth(accessToken: "b", refreshToken: "r", expiresAt: fixedNow.addingTimeInterval(60))) +} + +@Test func antigravityKeychainStoreReadsTheItemAntigravityWrites() throws { + let keychain = MemoryKeychain() + let store = KeychainAntigravityAuthStore(keychain: keychain.client) + #expect(store.description == "Keychain item gemini (antigravity)") + #expect(store.source.id == "antigravity.keychain") + #expect(store.source.provider == .antigravity) + #expect(try store.load() == nil) + #expect(store.credentialHealth(now: fixedNow) == .missing(expected: ProviderID.antigravity.setup.credentialSources)) + try keychain.client.save( + antigravityKeychainValue(Fixtures.json("antigravity_keychain_token")), service: "gemini", account: "antigravity") + let auth = try #require(try store.load()) + #expect(auth.accessToken == "ya29.antigravity") + #expect(store.credentialHealth(now: fixedNow) == .valid(source: store.source, expiresAt: auth.expiresAt)) + try keychain.client.save(Data("nope".utf8), service: "gemini", account: "antigravity") + #expect( + store.credentialHealth(now: fixedNow) + == .unreadable(source: store.source, detail: "Antigravity Keychain item is not JSON")) +} + +@Test func antigravitySetupDescribesTheKeychainSource() { + let setup = ProviderID.antigravity.setup + #expect(setup.signInCommand == "agy") + #expect(setup.credentialSources.map(\.id) == ["antigravity.keychain"]) + #expect(ProviderID.antigravity.sandboxResources.isEmpty) + #expect(ProviderID.antigravity.shortLabel == "AG") + #expect(ProviderID.antigravity.loginHint.contains("agy")) + #expect(ProviderID.allCases.firstIndex(of: .antigravity) == ProviderID.allCases.firstIndex(of: .gemini)! + 1) + #expect(PollingPolicy.defaults(for: .antigravity) == PollingPolicy.defaults(for: .gemini)) +} diff --git a/Tests/TokenMenuBarCoreTests/AntigravityOAuthConfigTests.swift b/Tests/TokenMenuBarCoreTests/AntigravityOAuthConfigTests.swift new file mode 100644 index 0000000..17d4bec --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/AntigravityOAuthConfigTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +private let clientID = "123456789012-abcdefghijklmnopqrstuvwxyz012345.apps.googleusercontent.com" +// Built from parts so the literal never matches GitHub's secret scanner, which blocks pushes on the real pattern. +private let secretPrefix = "GOCSPX" + "-" +private let clientSecret = secretPrefix + "abcdefghijklmnopqrstuvwxyz01" +private let binary = + Data([0x00, 0xFF, 0x7F]) + Data("\"\(clientID)\"\u{0}secret=\(clientSecret)\u{0}".utf8) + Data([0xC3]) + +@Test func antigravityOAuthConfigExtractsTheClientFromBinaryData() { + #expect(AntigravityOAuthConfig.extract(from: binary) == GeminiOAuthClient(id: clientID, secret: clientSecret)) +} + +@Test( + arguments: [ + ("no id", Data(clientSecret.utf8)), + ("no secret", Data("1-a.apps.googleusercontent.com".utf8)), + ("id without digits", Data("abc-def.apps.googleusercontent.com \(clientSecret)".utf8)), + ("short secret", Data("\(clientID) \(secretPrefix)short".utf8)), + ("secret with a bad character", Data("\(clientID) \(secretPrefix)abcdefghijklmnopqrstuvwxyz0!".utf8)), + ]) +func antigravityOAuthConfigRejectsPartialMatches(name: String, data: Data) { + #expect(AntigravityOAuthConfig.extract(from: data) == nil) +} + +@Test func antigravityOAuthConfigPrefersTheEnvironment() { + let environment = ["ANTIGRAVITY_OAUTH_CLIENT_ID": "env-id", "ANTIGRAVITY_OAUTH_CLIENT_SECRET": "env-secret"] + #expect( + AntigravityOAuthConfig.resolve(environment: environment, home: temporaryDirectory(), read: { _ in nil }) + == GeminiOAuthClient(id: "env-id", secret: "env-secret")) +} + +@Test func antigravityOAuthConfigScansInstalledApplications() throws { + let home = temporaryDirectory() + let mainJS = home.appendingPathComponent("Applications/Gemini.app/Contents/Resources/app/out/main.js") + try FileManager.default.createDirectory(at: mainJS.deletingLastPathComponent(), withIntermediateDirectories: true) + try binary.write(to: mainJS) + var visited: [String] = [] + let client = AntigravityOAuthConfig.resolve( + environment: [:], home: home, + read: { url in + visited.append(url.path.replacingOccurrences(of: home.path, with: "~")) + return try? Data(contentsOf: url) + }) + #expect(client == GeminiOAuthClient(id: clientID, secret: clientSecret)) + #expect(visited.first == "~/Applications/Antigravity.app/Contents/Resources/bin/language_server_macos_arm") + #expect(visited.last == "~/Applications/Gemini.app/Contents/Resources/app/out/main.js") + #expect(AntigravityOAuthConfig.resolve(environment: [:], home: home) == client) +} + +@Test func antigravityOAuthConfigReportsNothingWithoutAnInstalledClient() { + #expect(AntigravityOAuthConfig.resolve(environment: [:], home: temporaryDirectory(), read: { _ in nil }) == nil) +} diff --git a/Tests/TokenMenuBarCoreTests/AntigravityProcessScanTests.swift b/Tests/TokenMenuBarCoreTests/AntigravityProcessScanTests.swift new file mode 100644 index 0000000..0c028a9 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/AntigravityProcessScanTests.swift @@ -0,0 +1,88 @@ +import Darwin +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +@Test func antigravityDiscoveryPairsCandidatesWithTheirListeningPorts() { + let windsurf = ProcessEntry( + pid: 1, path: "/Applications/Windsurf.app/Contents/Resources/app/bin/language_server_macos_arm", + arguments: ["language_server_macos_arm", "--csrf_token", "other", "--app_data_dir", "windsurf"]) + let tokenless = ProcessEntry( + pid: 2, path: "/Applications/Antigravity.app/Contents/Resources/bin/language_server_macos_arm", + arguments: ["language_server_macos_arm", "--app_data_dir", "antigravity"]) + let hyphenated = ProcessEntry( + pid: 3, path: "/Applications/Gemini.app/Contents/Resources/bin/language-server", + arguments: ["language-server", "--csrf_token=csrf-3", "--app_data_dir=antigravity-ide"]) + let cli = ProcessEntry(pid: 4, path: "/usr/local/bin/antigravity-cli", arguments: ["antigravity-cli"]) + let silent = ProcessEntry(pid: 5, path: "/opt/homebrew/bin/agy", arguments: ["agy"]) + let scanner = StubProcessScanner( + [ + windsurf, tokenless, antigravityServer, hyphenated, cli, silent, + ProcessEntry(pid: 6, path: "/bin/zsh", arguments: []), + ], + ports: [1: [9000], 2: [9001], 4242: [4321, 1234], 3: [7000], 4: [8000]]) + + #expect( + AntigravityLanguageServers.discover(using: scanner) == [ + LocalLanguageServer(pid: 4242, port: 1234, csrfToken: "csrf-1"), + LocalLanguageServer(pid: 4242, port: 4321, csrfToken: "csrf-1"), + LocalLanguageServer(pid: 3, port: 7000, csrfToken: "csrf-3"), + LocalLanguageServer(pid: 4, port: 8000, csrfToken: nil), + ]) +} + +@Test( + arguments: [ + (["--csrf_token", "a"], "a"), (["--csrf_token=b"], "b"), (["--csrf_token"], nil), (["--other", "c"], nil), + ]) +func antigravityDiscoveryReadsFlagValuesInBothForms(arguments: [String], expected: String?) { + #expect(AntigravityLanguageServers.value(of: "--csrf_token", in: arguments) == expected) +} + +private func procargs(argc: Int32, path: String, arguments: [String], environment: [String]) -> Data { + var data = withUnsafeBytes(of: argc) { Data($0) } + data.append(contentsOf: Data(path.utf8) + [0, 0, 0]) + for item in arguments + environment { data.append(contentsOf: Data(item.utf8) + [0]) } + return data +} + +@Test( + arguments: [ + ( + "full layout", procargs(argc: 3, path: "/bin/ls", arguments: ["ls", "-l", ""], environment: ["HOME=/x"]), + ["ls", "-l", ""] + ), + ("no environment", procargs(argc: 1, path: "/bin/ls", arguments: ["ls"], environment: []), ["ls"]), + ("no arguments", procargs(argc: 0, path: "/bin/ls", arguments: [], environment: ["A=1"]), []), + ("empty buffer", Data(), []), + ("path without terminator", Data([1, 0, 0, 0, 0x2F, 0x62, 0x69, 0x6E]), []), + ]) +func antigravityDiscoveryParsesProcArgs(name: String, buffer: Data, expected: [String]) { + #expect(ProcessArguments.parse(buffer) == expected) +} + +private func socketInfo(kind: Int32, state: Int32, port: UInt16) -> socket_fdinfo { + var info = socket_fdinfo() + info.psi.soi_kind = kind + info.psi.soi_proto.pri_tcp.tcpsi_state = state + info.psi.soi_proto.pri_tcp.tcpsi_ini.insi_lport = Int32(port.bigEndian) + return info +} + +@Test func antigravityDiscoveryKeepsListeningTCPSocketsOnly() { + #expect( + ListeningSocket.port(of: socketInfo(kind: Int32(SOCKINFO_TCP), state: Int32(TSI_S_LISTEN), port: 8080)) == 8080) + #expect( + ListeningSocket.port(of: socketInfo(kind: Int32(SOCKINFO_TCP), state: Int32(TSI_S_ESTABLISHED), port: 8080)) == nil) + #expect(ListeningSocket.port(of: socketInfo(kind: Int32(SOCKINFO_IN), state: Int32(TSI_S_LISTEN), port: 8080)) == nil) +} + +@Test func libprocScannerSeesItself() { + let scanner = LibprocProcessScanner() + let current = scanner.processes().first { $0.pid == getpid() } + #expect(current?.path.isEmpty == false) + #expect(current?.arguments.isEmpty == false) + #expect(scanner.listeningPorts(pid: getpid()).isEmpty) +} diff --git a/Tests/TokenMenuBarCoreTests/AntigravityTests.swift b/Tests/TokenMenuBarCoreTests/AntigravityTests.swift new file mode 100644 index 0000000..afdd341 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/AntigravityTests.swift @@ -0,0 +1,430 @@ +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +private let validAuth = AntigravityAuth( + accessToken: "ya29.valid", refreshToken: "1//refresh", expiresAt: fixedNow.addingTimeInterval(3600)) +private let expiredAuth = AntigravityAuth( + accessToken: "ya29.expired", refreshToken: "1//refresh", expiresAt: fixedNow.addingTimeInterval(-60)) +private let testOAuth = GeminiOAuthClient(id: "client", secret: "secret") +private let localQuotaPath = "LanguageServerService/RetrieveUserQuotaSummary" +private let localStatusPath = "LanguageServerService/GetUserStatus" +private let cloudQuotaPath = ":retrieveUserQuotaSummary" +private let cloudAssistPath = ":loadCodeAssist" +private let dailyBase = "daily-cloudcode-pa.googleapis.com" + +private func makeProvider( + _ auth: AntigravityAuth?, allowRefresh: Bool = false, store: MemoryAntigravityStore? = nil, + oauth: GeminiOAuthClient? = testOAuth, scanner: (any ProcessScanner)? = nil +) -> (AntigravityProvider, StubTransport, MemoryAntigravityStore) { + let transport = StubTransport() + let store = store ?? MemoryAntigravityStore(auth) + let log = makeLog() + log.debugEnabled = true + let provider = AntigravityProvider( + auth: store, client: APIClient(transport: transport, log: log, clock: testClock), log: log, + allowRefresh: { allowRefresh }, oauthClient: { oauth }, processScanner: scanner) + return (provider, transport, store) +} + +private func snapshot(_ result: ProviderFetchResult) throws -> ProviderSnapshot { + try #require(result.outcome.snapshot, "expected a snapshot, got \(result.outcome)") +} + +private let serverScanner = StubProcessScanner( + [antigravityServer, antigravityCLI], ports: [4242: [4321], 4343: [5353]]) + +@Test func antigravityReadsTheLanguageServerOverHTTPSThenHTTP() async throws { + let (provider, transport, _) = makeProvider(validAuth, scanner: serverScanner) + transport.on({ $0.url?.scheme == "https" }, .fail(URLError(.secureConnectionFailed))) + transport.on(path: localQuotaPath, .json("antigravity_local_quota")) + transport.on(path: localStatusPath, .json("antigravity_user_status")) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + let snapshot = try snapshot(result) + #expect(snapshot.windows.map(\.id) == ["gemini:session", "3p:weekly", "gemini:weekly", "legacy-daily"]) + #expect( + snapshot.identity + == ProviderIdentity(planName: "Google AI Ultra", tier: "google_ai_ultra", email: "you@example.com")) + #expect(result.credentialStatus?.state == .valid(expiresAt: validAuth.expiresAt)) + let quotaRequests = transport.requests(matching: localQuotaPath) + #expect(quotaRequests.map { $0.url!.scheme! } == ["https", "http"]) + #expect(quotaRequests.map { $0.url!.port! } == [4321, 4321]) + #expect(quotaRequests.last?.value(forHTTPHeaderField: "X-Codeium-Csrf-Token") == "csrf-1") + #expect(quotaRequests.last?.value(forHTTPHeaderField: "Connect-Protocol-Version") == "1") + #expect(quotaRequests.last?.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(quotaRequests.last.map { String(decoding: $0.httpBody!, as: UTF8.self) } == #"{"forceRefresh":true}"#) + let statusRequest = try #require(transport.requests(matching: localStatusPath).first) + #expect(statusRequest.url?.scheme == "http") + #expect(String(decoding: statusRequest.httpBody!, as: UTF8.self).contains(#""ideName":"antigravity""#)) + #expect(transport.requests(matching: cloudQuotaPath).isEmpty) +} + +@Test func antigravityMapsLocalBucketsIncludingUsedUpAndDisabledOnes() async throws { + let (provider, transport, _) = makeProvider(validAuth, scanner: serverScanner) + transport.on(path: localQuotaPath, .json("antigravity_local_quota")) + transport.on(path: localStatusPath, .json("antigravity_user_status")) + + let snapshot = try snapshot(await provider.fetch(now: fixedNow, options: FetchOptions())) + + let session = try #require(snapshot.window("gemini:session")) + #expect(abs(session.usedPercent - 25.1) < 0.001) + #expect(session.label == "Gemini 5-hour") + #expect(session.group == .session) + #expect(session.duration == 18000) + #expect(session.resetsAt == ISODate.parse("2026-08-29T22:05:10Z")) + let weekly = try #require(snapshot.window("gemini:weekly")) + #expect(abs(weekly.usedPercent - 4.2) < 0.001) + #expect(weekly.group == .weekly) + #expect(weekly.duration == 604_800) + let thirdParty = try #require(snapshot.window("3p:weekly")) + #expect(thirdParty.usedPercent == 100) + #expect(thirdParty.label == "Claude and GPT weekly") + #expect(snapshot.window("3p:session") == nil) + let legacy = try #require(snapshot.window("legacy-daily")) + #expect(legacy.label == "legacy-daily") + #expect(legacy.group == .other) + #expect(legacy.usedPercent == 100) + #expect(legacy.resetsAt == nil) +} + +@Test func antigravityUsesTheCLIWithoutACSRFToken() async throws { + let scanner = StubProcessScanner([antigravityCLI], ports: [4343: [5353]]) + let (provider, transport, _) = makeProvider(validAuth, scanner: scanner) + transport.on(path: localQuotaPath, .json("antigravity_local_quota")) + transport.on(path: localStatusPath, .text("boom", status: 500)) + + let snapshot = try snapshot(await provider.fetch(now: fixedNow, options: FetchOptions())) + + let request = try #require(transport.requests(matching: localQuotaPath).first) + #expect(request.url?.port == 5353) + #expect(request.value(forHTTPHeaderField: "X-Codeium-Csrf-Token") == nil) + #expect(snapshot.identity == ProviderIdentity(planName: "Antigravity")) +} + +@Test( + arguments: [ + (404, "not found", 4), (200, #"{"code":16,"message":"unauthenticated"}"#, 2), (200, "not json", 4), + ]) +func antigravityFallsBackToCloudCodeWhenTheLanguageServerCannotAnswer( + status: Int, body: String, attempts: Int +) async throws { + let (provider, transport, _) = makeProvider(validAuth, scanner: serverScanner) + transport.on(path: localQuotaPath, .text(body, status: status)) + transport.on(path: cloudQuotaPath, .json("antigravity_cloud_quota")) + transport.on(path: cloudAssistPath, .json("antigravity_load_code_assist")) + + let snapshot = try snapshot(await provider.fetch(now: fixedNow, options: FetchOptions())) + + #expect(snapshot.windows.map(\.id) == ["3p:session", "gemini:session", "3p:weekly", "gemini:weekly"]) + #expect(transport.requests(matching: localQuotaPath).count == attempts) + #expect(transport.requests(matching: localStatusPath).isEmpty) + #expect(transport.requests(matching: cloudQuotaPath).count == 1) +} + +@Test func antigravityReadsCloudCodeAndCachesTheTier() async throws { + let longLived = AntigravityAuth(accessToken: "ya29.valid", expiresAt: fixedNow.addingTimeInterval(86400)) + let (provider, transport, _) = makeProvider(longLived) + transport.on(path: cloudQuotaPath, .json("antigravity_cloud_quota")) + transport.on(path: cloudAssistPath, .json("antigravity_load_code_assist")) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + let snapshot = try snapshot(result) + #expect(result.warnings.isEmpty) + #expect( + snapshot.identity == ProviderIdentity(planName: "Gemini Code Assist in Google One AI Pro", tier: "standard-tier")) + #expect(abs(snapshot.window("3p:weekly")!.usedPercent - 70) < 0.001) + #expect(abs(snapshot.window("gemini:session")!.usedPercent - 40) < 0.001) + let quotaRequest = try #require(transport.requests(matching: cloudQuotaPath).first) + #expect(quotaRequest.url?.host == dailyBase) + #expect(quotaRequest.value(forHTTPHeaderField: "Authorization") == "Bearer ya29.valid") + #expect(quotaRequest.value(forHTTPHeaderField: "User-Agent") == "antigravity") + #expect(quotaRequest.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(String(decoding: quotaRequest.httpBody!, as: UTF8.self) == "{}") + let assistRequest = try #require(transport.requests(matching: cloudAssistPath).first) + #expect(String(decoding: assistRequest.httpBody!, as: UTF8.self).contains(#""ideType":"ANTIGRAVITY""#)) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(60), options: FetchOptions()) + #expect(transport.requests(matching: cloudAssistPath).count == 1) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(3601), options: FetchOptions()) + #expect(transport.requests(matching: cloudAssistPath).count == 2) +} + +@Test func antigravityUsesTheSecondCloudBaseAfterAServerError() async throws { + let (provider, transport, _) = makeProvider(validAuth) + transport.on({ $0.url?.host == dailyBase }, .respond(.text("down", status: 503))) + transport.on(path: cloudQuotaPath, .json("antigravity_cloud_quota_free")) + transport.on(path: cloudAssistPath, .text("boom", status: 500)) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + let snapshot = try snapshot(result) + #expect(snapshot.windows.map(\.id) == ["3p:weekly", "gemini:weekly"]) + #expect(snapshot.identity == ProviderIdentity(planName: "Antigravity")) + #expect(result.warnings == ["Plan details unavailable: HTTP 500"]) + #expect( + transport.requests(matching: cloudQuotaPath).map { $0.url!.host! } == [dailyBase, "cloudcode-pa.googleapis.com"]) + #expect(transport.requests(matching: cloudAssistPath).map { $0.url!.host! } == ["cloudcode-pa.googleapis.com"]) +} + +@Test func antigravityReportsTheLastCloudFailure() async { + let (failing, transport, _) = makeProvider(validAuth) + transport.on({ $0.url?.host == dailyBase }, .respond(.text("down", status: 503))) + transport.on(path: cloudQuotaPath, .text("gone", status: 500)) + #expect(await failing.fetch(now: fixedNow, options: FetchOptions()).outcome == .failed("HTTP 500")) + let (offline, transport2, _) = makeProvider(validAuth) + transport2.on(path: cloudQuotaPath, error: URLError(.notConnectedToInternet)) + guard case .networkUnavailable = await offline.fetch(now: fixedNow, options: FetchOptions()).outcome else { + Issue.record("expected networkUnavailable") + return + } +} + +@Test func antigravityReportsRejectedTokensWithoutRefreshing() async { + let (provider, transport, _) = makeProvider(validAuth) + transport.on(path: cloudQuotaPath, .text("denied", status: 401)) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .notAuthenticated("HTTP 401. \(ProviderID.antigravity.loginHint)")) + #expect(transport.requests(matching: cloudQuotaPath).count == 1) + #expect(transport.requests(matching: "/token").isEmpty) +} + +@Test func antigravityRefreshesOnceWhenCloudCodeRejectsTheToken() async throws { + let (provider, transport, _) = makeProvider(validAuth, allowRefresh: true) + transport.on( + { $0.value(forHTTPHeaderField: "Authorization") == "Bearer ya29.valid" }, .respond(.text("denied", status: 401))) + transport.on(path: "/token", .text(#"{"access_token":"ya29.renewed","expires_in":1800}"#)) + transport.on(path: cloudQuotaPath, .json("antigravity_cloud_quota")) + transport.on(path: cloudAssistPath, .json("antigravity_load_code_assist")) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + _ = try snapshot(result) + #expect(result.credentialStatus?.state == .valid(expiresAt: fixedNow.addingTimeInterval(1800))) + #expect(transport.requests(matching: cloudQuotaPath).count == 2) + let tokenRequest = try #require(transport.requests(matching: "/token").first) + let form = String(decoding: tokenRequest.httpBody!, as: UTF8.self) + #expect(form.contains("client_id=client") && form.contains("refresh_token=1//refresh")) + #expect(form.contains("grant_type=refresh_token")) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(30), options: FetchOptions()) + #expect(transport.requests(matching: "/token").count == 1) + #expect( + transport.requests(matching: cloudQuotaPath).last?.value(forHTTPHeaderField: "Authorization") + == "Bearer ya29.renewed") +} + +@Test func antigravityGivesUpAfterOneRefreshAttempt() async { + let (provider, transport, _) = makeProvider(validAuth, allowRefresh: true) + transport.on(path: "/token", .text(#"{"access_token":"ya29.renewed","expires_in":1800}"#)) + transport.on(path: cloudQuotaPath, .text("denied", status: 401)) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .notAuthenticated("HTTP 401. \(ProviderID.antigravity.loginHint)")) + #expect(transport.requests(matching: cloudQuotaPath).count == 2) + #expect(transport.requests(matching: "/token").count == 1) +} + +@Test func antigravityReportsAFailedRefreshAfterARejectedToken() async { + let (provider, transport, _) = makeProvider(validAuth, allowRefresh: true) + transport.on(path: "/token", .text("unavailable", status: 503)) + transport.on(path: cloudQuotaPath, .text("denied", status: 401)) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .notAuthenticated("Antigravity token refresh failed: HTTP 503")) +} + +@Test func antigravityDropsTheRememberedTokenWhenAntigravityWritesANewOne() async throws { + let store = MemoryAntigravityStore(expiredAuth) + let (provider, transport, _) = makeProvider(nil, allowRefresh: true, store: store) + transport.on(path: "/token", .text(#"{"access_token":"ya29.renewed"}"#)) + transport.on(path: cloudQuotaPath, .json("antigravity_cloud_quota")) + transport.on(path: cloudAssistPath, .json("antigravity_load_code_assist")) + + let first = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(first.credentialStatus?.state == .valid(expiresAt: fixedNow.addingTimeInterval(3600))) + _ = try snapshot(first) + #expect( + transport.requests(matching: cloudQuotaPath).last?.value(forHTTPHeaderField: "Authorization") + == "Bearer ya29.renewed") + _ = await provider.fetch(now: fixedNow.addingTimeInterval(10), options: FetchOptions()) + #expect(transport.requests(matching: "/token").count == 1) + try store.write(validAuth) + _ = await provider.fetch(now: fixedNow.addingTimeInterval(20), options: FetchOptions()) + #expect( + transport.requests(matching: cloudQuotaPath).last?.value(forHTTPHeaderField: "Authorization") == "Bearer ya29.valid" + ) + #expect(transport.requests(matching: "/token").count == 1) +} + +@Test func antigravityLeavesExpiredTokensAloneUnlessRefreshIsAllowed() async { + let (provider, transport, _) = makeProvider(expiredAuth) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .notAuthenticated("Antigravity token expired. \(ProviderID.antigravity.loginHint)")) + #expect(result.credentialStatus?.state == .expired(expiredAuth.expiresAt!)) + #expect(transport.requests.isEmpty) +} + +@Test( + arguments: [ + (AntigravityAuth(accessToken: "a", expiresAt: fixedNow), testOAuth, "no refresh token"), + (expiredAuth, nil, "OAuth client could not be read"), + ]) +func antigravityExplainsWhyARefreshCannotStart(auth: AntigravityAuth, oauth: GeminiOAuthClient?, detail: String) async { + let (provider, transport, _) = makeProvider(auth, allowRefresh: true, oauth: oauth) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome.errorDescription?.contains(detail) == true) + #expect(transport.requests.isEmpty) +} + +@Test( + arguments: [ + (#"{"error":"server_error","error_description":"try later"}"#, "try later"), + (#"{"error":"server_error"}"#, "server_error"), + ("{}", "refresh returned no access token"), + ]) +func antigravityExplainsARefreshWithoutAToken(body: String, detail: String) async { + let (provider, transport, _) = makeProvider(expiredAuth, allowRefresh: true) + transport.on(path: "/token", .text(body)) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .notAuthenticated("Antigravity token refresh failed: \(detail)")) +} + +@Test func antigravityStopsRefreshingARejectedToken() async { + let (provider, transport, _) = makeProvider(expiredAuth, allowRefresh: true) + transport.on(path: "/token", .text(#"{"error":"invalid_grant"}"#, status: 400)) + + let first = await provider.fetch(now: fixedNow, options: FetchOptions()) + let second = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(first.outcome.errorDescription?.contains("invalid_grant") == true) + #expect(second.outcome == first.outcome) + #expect(transport.requests(matching: "/token").count == 1) +} + +@Test func antigravityHandlesMissingAndUnreadableCredentials() async { + let (missing, transport, store) = makeProvider(nil) + #expect(missing.credentialDescription == "memory") + #expect(missing.credentialState(now: fixedNow) == .missing("no Antigravity sign-in found")) + #expect( + await missing.credentialHealth(now: fixedNow) == .missing(expected: ProviderID.antigravity.setup.credentialSources)) + let result = await missing.fetch(now: fixedNow, options: FetchOptions()) + #expect(result.outcome == .notAuthenticated("No Antigravity credentials. \(ProviderID.antigravity.loginHint)")) + #expect(result.credentialStatus == .missing("no Antigravity sign-in found", provider: .antigravity)) + #expect(transport.requests.isEmpty) + store.loadError = TestError() + #expect(missing.credentialState(now: fixedNow) == .missing("The credential source could not be read.")) + #expect( + await missing.credentialHealth(now: fixedNow) + == .unreadable(source: store.source, detail: "The credential source could not be read.")) + let unreadable = await missing.fetch(now: fixedNow, options: FetchOptions()) + #expect( + unreadable.outcome + == .notAuthenticated("Cannot read Antigravity credentials: The credential source could not be read.")) + #expect( + unreadable.credentialStatus?.health + == .unreadable(source: store.source, detail: "The credential source could not be read.")) +} + +@Test func antigravitySkipsTheLanguageServerWithoutAProcessScanner() async throws { + let (provider, transport, _) = makeProvider(validAuth, scanner: nil) + transport.on(path: localQuotaPath, .json("antigravity_local_quota")) + transport.on(path: cloudQuotaPath, .json("antigravity_cloud_quota")) + transport.on(path: cloudAssistPath, .json("antigravity_load_code_assist")) + + let snapshot = try snapshot(await provider.fetch(now: fixedNow, options: FetchOptions())) + + #expect(snapshot.identity?.tier == "standard-tier") + #expect(transport.requests(matching: localQuotaPath).isEmpty) + #expect(transport.requests(matching: cloudQuotaPath).count == 1) +} + +@Test func antigravityIgnoresProcessesWithoutPorts() async throws { + let (provider, transport, _) = makeProvider(validAuth, scanner: StubProcessScanner([antigravityServer])) + transport.on(path: cloudQuotaPath, .json("antigravity_cloud_quota")) + transport.on(path: cloudAssistPath, .json("antigravity_load_code_assist")) + + _ = try snapshot(await provider.fetch(now: fixedNow, options: FetchOptions())) + + #expect(transport.requests(matching: localQuotaPath).isEmpty) +} + +@Test( + arguments: [ + ( + #"{"userTier":{"id":"ultra","name":"Google AI Ultra"},"planStatus":{"planInfo":{"planName":"Pro"}}}"#, + "Google AI Ultra", "ultra" + ), + (#"{"planStatus":{"planInfo":{"planName":"Pro"}}}"#, "Pro", nil), + (#"{"email":"a@b"}"#, "Antigravity", nil), + ]) +func antigravityPrefersTheTierNameOverThePlanName(status: String, planName: String, tier: String?) throws { + let decoded = try JSONDecoder().decode(AntigravityAPI.UserStatus.self, from: Data(status.utf8)) + let identity = AntigravityMapper.identity(decoded) + #expect(identity.planName == planName) + #expect(identity.tier == tier) +} + +@Test func antigravityIdentityWithoutAnyStatusIsGeneric() { + #expect(AntigravityMapper.identity(nil as AntigravityAPI.UserStatus?) == ProviderIdentity(planName: "Antigravity")) + #expect(AntigravityMapper.identity(nil as AntigravityAPI.Tier?) == ProviderIdentity(planName: "Antigravity")) +} + +@Test( + arguments: [ + (#"{"response":{"groups":[{"displayName":"a"}]}}"#, ["a"]), + (#"{"groups":[{"displayName":"b"}]}"#, ["b"]), + (#"{"code":16}"#, []), + ]) +func antigravityQuotaSummaryAcceptsWrappedAndBareGroups(body: String, names: [String]) throws { + let summary = try JSONDecoder().decode(AntigravityAPI.QuotaSummary.self, from: Data(body.utf8)) + #expect(summary.allGroups.map(\.displayName) == names) +} + +@Test( + arguments: [ + ("127.0.0.1", NSURLAuthenticationMethodServerTrust, true), + ("localhost", NSURLAuthenticationMethodServerTrust, true), + ("::1", NSURLAuthenticationMethodServerTrust, true), + ("cloudcode-pa.googleapis.com", NSURLAuthenticationMethodServerTrust, false), + ("127.0.0.1", NSURLAuthenticationMethodHTTPBasic, false), + ]) +func loopbackTrustCoversTheLoopbackInterfaceOnly(host: String, method: String, expected: Bool) { + #expect(LoopbackTrust.accepts(host: host, authenticationMethod: method) == expected) +} + +@Test @MainActor func antigravityProviderLooksForTheInstalledAppsOAuthClientByDefault() async { + let transport = StubTransport() + let provider = AntigravityProvider( + auth: MemoryAntigravityStore(expiredAuth), + client: APIClient(transport: transport, log: makeLog(), clock: testClock), + log: makeLog(), allowRefresh: { true }) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.credentialStatus?.state == .expired(expiredAuth.expiresAt!)) + guard case .notAuthenticated = result.outcome else { + Issue.record("expected a sign-in failure, got \(result.outcome)") + return + } +} + +@Test @MainActor func antigravityCredentialStateFollowsTheStoredToken() { + let (missing, _, _) = makeProvider(nil) + let (signedIn, _, _) = makeProvider(validAuth) + + #expect(missing.credentialState(now: fixedNow) == .missing("no Antigravity sign-in found")) + #expect(signedIn.credentialState(now: fixedNow) == .valid(expiresAt: validAuth.expiresAt)) +} diff --git a/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift b/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift index 5bc957a..6bb8f6d 100644 --- a/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift +++ b/Tests/TokenMenuBarCoreTests/ChartPipelineTests.swift @@ -267,11 +267,12 @@ func downsampleHonorsTinyPointBudgets(limit: Int) { } @Test func historyMetricsEncodeSupplierAndMarkRules() { - #expect(HistoryMetric.allCases.count == 17) + #expect(HistoryMetric.allCases.count == 19) #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.allCases.filter { $0.group == .projects }.count == 2) #expect(HistoryMetric.windowUsagePercent.markKind == .stepLine) #expect(HistoryMetric.analytics(.surfaceUsagePercent).markKind == .line) #expect(HistoryMetric.analytics(.turns).markKind == .bars) @@ -304,6 +305,8 @@ func downsampleHonorsTinyPointBudgets(limit: Int) { .analytics(.messages): "Claude · one series · daily UTC", .analytics(.sessions): "Claude · one series · daily UTC", .analytics(.toolCalls): "Claude · one series · daily UTC", + .analytics(.projectCost): "Claude · by project · daily UTC", + .analytics(.projectMessages): "Claude · by project · daily UTC", ] #expect(Dictionary(uniqueKeysWithValues: HistoryMetric.allCases.map { ($0, $0.attribution) }) == expected) diff --git a/Tests/TokenMenuBarCoreTests/ClaudeProjectAttributionTests.swift b/Tests/TokenMenuBarCoreTests/ClaudeProjectAttributionTests.swift new file mode 100644 index 0000000..366961a --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/ClaudeProjectAttributionTests.swift @@ -0,0 +1,128 @@ +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +private let recent = fixedNow.addingTimeInterval(-3600) + +private func transcriptLine(id: String, cost: Double, cwd: String? = nil) -> String { + let cwdField = cwd.map { #""cwd":"\#($0)","# } ?? "" + return #"{"type":"assistant","uuid":"u-\#(id)","requestId":"r-\#(id)","sessionId":"s1",\#(cwdField)"# + + #""timestamp":"\#(ISODate.string(recent))","costUSD":\#(cost),"message":{"id":"\#(id)","# + + #""model":"claude-opus-5","content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":10,"# + + #""output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"# +} + +private func projectsRoot(_ transcripts: [String: [String]]) throws -> URL { + let root = temporaryDirectory().appendingPathComponent("projects") + for (directory, lines) in transcripts { + let folder = root.appendingPathComponent(directory) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try (lines.joined(separator: "\n") + "\n").write( + to: folder.appendingPathComponent("session.jsonl"), atomically: true, encoding: .utf8) + } + return root +} + +private func byProject(_ analytics: ProviderAnalytics, _ metric: AnalyticsMetric) -> [String: Double] { + Dictionary(uniqueKeysWithValues: analytics.points.filter { $0.metric == metric }.map { ($0.series, $0.value) }) +} + +@Test func transcriptReaderAttributesCostAndMessagesToProjects() async throws { + let root = try projectsRoot([ + "-Users-me-repo-alpha": [transcriptLine(id: "m1", cost: 1.25), transcriptLine(id: "m2", cost: 0.75)], + "-Users-me-beta": [transcriptLine(id: "m3", cost: 2, cwd: "/Users/me/beta-tool")], + ]) + let reader = ClaudeTranscriptReader(root: root) + + let analytics = try #require(await reader.refresh(now: fixedNow).analytics(now: fixedNow)) + + #expect(byProject(analytics, .projectCost) == ["alpha": 2, "beta-tool": 2]) + #expect(byProject(analytics, .projectMessages) == ["alpha": 2, "beta-tool": 1]) + #expect(analytics.total(.projectCost) == analytics.total(.costUSD)) + #expect( + analytics.points.filter { $0.metric == .projectCost }.map(\.day) == [ + DayStamp.string(recent), DayStamp.string(recent), + ]) +} + +@Test func transcriptReaderReadsCheckpointsWrittenBeforeProjectsExisted() async throws { + let root = try projectsRoot(["-Users-me-repo": [transcriptLine(id: "m1", cost: 1)]]) + let stateURL = temporaryDirectory().appendingPathComponent("offsets.json") + let first = ClaudeTranscriptReader(root: root, stateURL: stateURL, checkpointInterval: 0) + #expect(await first.refresh(now: fixedNow).messageCount == 1) + while (await first.workload).checkpoints == 0 { await Task.yield() } + + var json = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + var days = try #require(json["days"] as? [String: [String: Any]]) + #expect(days.values.allSatisfy { $0["projects"] != nil }) + for key in days.keys { days[key]?["projects"] = nil } + json["days"] = days + try JSONSerialization.data(withJSONObject: json).write(to: stateURL) + + let resumed = ClaudeTranscriptReader(root: root, stateURL: stateURL) + let snapshot = await resumed.refresh(now: fixedNow) + #expect(snapshot.messageCount == 1) + #expect(snapshot.analytics(now: fixedNow)?.total(.costUSD) == 1) + #expect(snapshot.analytics(now: fixedNow)?.points.contains { $0.metric == .projectCost } == false) +} + +@Test(arguments: [("-Users-me-repo", "repo"), ("-private-tmp", "tmp"), ("plain", "plain"), ("-", "-")]) +func projectNameDecodesTheDirectoryName(encoded: String, expected: String) { + #expect(ClaudeTranscriptReader.projectName(encodedDirectory: encoded) == expected) +} + +@Test func projectNameFallsBackToTheFileOutsideAProjectDirectory() { + let root = URL(fileURLWithPath: "/tmp/projects") + #expect( + ClaudeTranscriptReader.projectName(transcript: root.appendingPathComponent("loose.jsonl"), root: root) == "loose") + #expect( + ClaudeTranscriptReader.projectName( + transcript: root.appendingPathComponent("-Users-me-repo/nested/b.jsonl"), root: root) == "repo") +} + +@Test func transcriptAnalyticsGroupProjectSeriesFromMessagesThatCarryOne() throws { + func message(_ id: String, cost: Double, project: String? = nil) -> TranscriptMessage { + TranscriptMessage( + id: id, timestamp: fixedNow, session: "s", model: "claude-opus-5", usage: TokenUsage(input: 10), toolCalls: 0, + reportedCost: cost, project: project) + } + let analytics = try #require( + ClaudeTranscriptReader.analytics( + [message("1", cost: 1, project: "repo"), message("2", cost: 2, project: "repo"), message("3", cost: 4)], + now: fixedNow)) + #expect(byProject(analytics, .projectCost) == ["repo": 3]) + #expect(byProject(analytics, .projectMessages) == ["repo": 2]) + #expect(analytics.total(.costUSD) == 7) +} + +@Test @MainActor func historyPresenterShowsProjectSeriesUnderTheProjectsGroup() async throws { + let settings = Settings(defaults: testDefaults("history-projects")) + let history = try UsageHistoryStore(url: nil) + let presenter = HistoryPresenter(history: history, settings: settings, clock: testClock) + let day = DayStamp.string(fixedNow) + try await history.record( + ProviderAnalytics( + provider: .claude, + points: [ + AnalyticsPoint(day: day, metric: .projectCost, series: "alpha", value: 2), + AnalyticsPoint(day: day, metric: .projectCost, series: "beta-tool", value: 1), + AnalyticsPoint(day: day, metric: .projectMessages, series: "alpha", value: 3), + ], fetchedAt: fixedNow)) + + presenter.setMetric(.analytics(.projectCost)) + await presenter.waitForLoad() + + let data = try #require(presenter.state.data) + #expect(data.series.map(\.label) == ["alpha", "beta-tool"]) + #expect(data.summaryText == "\(Format.currency(3)) total") + #expect( + HistoryMetric.allCases.filter { $0.group == .projects } + == [.analytics(.projectCost), .analytics(.projectMessages)]) + #expect(HistoryMetric.analytics(.projectMessages).suppliers == [.claude]) + #expect(HistoryMetric.analytics(.projectMessages).unit == .count) + #expect(AnalyticsMetric.projectCost.unit == "USD") + #expect(AnalyticsMetric.projectMessages.title == "Messages by project") + #expect(AnalyticsMetric.projectCost.title == "Cost by project") +} diff --git a/Tests/TokenMenuBarCoreTests/CountdownPresetTests.swift b/Tests/TokenMenuBarCoreTests/CountdownPresetTests.swift new file mode 100644 index 0000000..ede37ab --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/CountdownPresetTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +import TokenMenuBarCore +import TokenMenuBarTestSupport + +@Test func countdownPresetsHaveTemplatesAndShortPickerLabels() { + #expect(StatusFormat.percentCountdown.template == "{label} {pct} · {reset}") + #expect(StatusFormat.countdownWhenExhausted.template == "{label}\n{pctOrReset}") + #expect(StatusFormat.percentCountdown.pickerLabel == "Countdown") + #expect(StatusFormat.countdownWhenExhausted.pickerLabel == "Countdown at 100%") + #expect(StatusFormat.stacked.pickerLabel == "Stacked") + #expect(StatusFormat.allCases.last == .custom) + #expect(StatusTemplate.tokens.contains { $0.token == "{pctOrReset}" }) +} + +private func window(_ used: Double) -> QuotaWindow { + QuotaWindow( + id: "session", label: "Current session", group: .session, usedPercent: used, + resetsAt: fixedNow.addingTimeInterval(4 * 3600 + 24 * 60), duration: 18000) +} + +private func context(_ used: Double) -> StatusCellContext { + StatusCellContext( + provider: .claude, window: window(used), cellLabel: "CC", shortLabel: "CC 5h", decimals: 1, planName: nil, + credits: nil, now: fixedNow) +} + +@Test(arguments: [(36.4, "36.4%"), (100, "4h24m")]) +func pctOrResetSwitchesToTheCountdownOnceExhausted(used: Double, text: String) { + let lines = StatusTemplate.render("{pctOrReset}", context: context(used)) + #expect(StatusTemplate.plainText(lines) == text) + #expect(lines[0][0].kind == .usage(used)) +} + +@Test func compiledTemplateTellsTheTwoCountdownTokensApart() { + let conditional = StatusTemplate.compile("{pctOrReset}") + #expect(conditional.referencesExhaustedCountdown) + #expect(!conditional.referencesCountdown) + let live = StatusTemplate.compile("{reset}") + #expect(live.referencesCountdown) + #expect(!live.referencesExhaustedCountdown) +} + +private func input(format: StatusFormat, used: Double) -> StatusItemInput { + StatusItemInput( + snapshots: [.claude: ProviderSnapshot(provider: .claude, windows: [window(used)], fetchedAt: fixedNow)], + availability: [.claude: .current], selectedKeys: [WindowKey(provider: .claude, windowID: "session")], + format: format, customTemplate: "", decimals: 0, hideZeroCells: true, order: .provider, labels: [:], + now: fixedNow) +} + +@Test(arguments: [ + (StatusFormat.percentCountdown, 36.4, true, ["CC 5h 36% · 4h24m"]), + (.percentCountdown, 100, true, ["CC 5h 100% · 4h24m"]), + (.countdownWhenExhausted, 36.4, false, ["CC 5h", "36%"]), + (.countdownWhenExhausted, 100, true, ["CC 5h", "4h24m"]), +]) +func builderRunsTheCountdownOnlyWhenAPresetShowsOne( + format: StatusFormat, used: Double, countdown: Bool, lines: [String] +) { + let model = StatusItemBuilder.build(input(format: format, used: used)) + #expect(model.countdownActive == countdown) + #expect(model.cells[0].lines.map { StatusTemplate.plainText([$0]) } == lines) +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_cloud_quota.json b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_cloud_quota.json new file mode 100644 index 0000000..42ad0b1 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_cloud_quota.json @@ -0,0 +1,43 @@ +{ + "groups": [ + { + "displayName": "Gemini Models", + "buckets": [ + { + "bucketId": "gemini-weekly", + "displayName": "Weekly Limit", + "window": "weekly", + "remainingFraction": 0.9, + "resetTime": "2026-09-05T12:05:10Z" + }, + { + "bucketId": "gemini-5h", + "displayName": "Five Hour Limit", + "window": "5h", + "remainingFraction": 0.6, + "resetTime": "2026-08-29T22:05:10Z" + } + ] + }, + { + "displayName": "Claude and GPT models", + "buckets": [ + { + "bucketId": "3p-weekly", + "displayName": "Weekly Limit", + "window": "weekly", + "remainingFraction": 0.3, + "resetTime": "2026-09-05T12:05:10Z" + }, + { + "bucketId": "3p-5h", + "displayName": "Five Hour Limit", + "window": "5h", + "remainingFraction": 0.85, + "resetTime": "2026-08-29T22:05:10Z" + } + ] + }, + { "displayName": "Image models" } + ] +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_cloud_quota_free.json b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_cloud_quota_free.json new file mode 100644 index 0000000..af65fc5 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_cloud_quota_free.json @@ -0,0 +1,28 @@ +{ + "groups": [ + { + "displayName": "Gemini Models", + "buckets": [ + { + "bucketId": "gemini-weekly", + "displayName": "Weekly Limit", + "window": "weekly", + "remainingFraction": 0.4, + "resetTime": "2026-09-05T12:05:10Z" + } + ] + }, + { + "displayName": "Claude and GPT models", + "buckets": [ + { + "bucketId": "3p-weekly", + "displayName": "Weekly Limit", + "window": "weekly", + "remainingFraction": 0.1, + "resetTime": "2026-09-05T12:05:10Z" + } + ] + } + ] +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_keychain_token.json b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_keychain_token.json new file mode 100644 index 0000000..557c1ca --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_keychain_token.json @@ -0,0 +1,8 @@ +{ + "token": { + "access_token": "ya29.antigravity", + "refresh_token": "1//0g-antigravity", + "expiry": "2026-08-29T20:00:00.123456Z" + }, + "email": "you@example.com" +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_load_code_assist.json b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_load_code_assist.json new file mode 100644 index 0000000..7892f36 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_load_code_assist.json @@ -0,0 +1,4 @@ +{ + "currentTier": { "id": "standard-tier", "name": "Gemini Code Assist in Google One AI Pro" }, + "cloudaicompanionProject": "projects/gen-lang-client" +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_local_quota.json b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_local_quota.json new file mode 100644 index 0000000..bfb1975 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_local_quota.json @@ -0,0 +1,55 @@ +{ + "response": { + "description": "Usage across the model pools", + "groups": [ + { + "displayName": "Gemini Models", + "description": "Gemini 3 Pro and Flash", + "buckets": [ + { + "bucketId": "gemini-weekly", + "displayName": "Weekly Limit", + "window": "weekly", + "remainingFraction": 0.958, + "resetTime": "2026-09-05T12:05:10Z" + }, + { + "bucketId": "gemini-5h", + "displayName": "Five Hour Limit", + "window": "5h", + "remaining": { "case": "remainingFraction", "value": 0.749 }, + "resetTime": "2026-08-29T22:05:10Z" + } + ] + }, + { + "displayName": "Claude and GPT models", + "buckets": [ + { + "bucketId": "3p-weekly", + "displayName": "Weekly Limit", + "window": "weekly", + "resetTime": "2026-09-05T12:05:10Z" + }, + { + "bucketId": "3p-5h", + "displayName": "Five Hour Limit", + "window": "5h", + "remainingFraction": 0.2, + "resetTime": "2026-08-29T22:05:10Z", + "disabled": true + }, + { + "bucketId": "legacy-daily", + "window": "daily", + "remaining": { "case": "remainingAmount", "value": "12" } + }, + { + "displayName": "Unnamed bucket", + "remainingFraction": 0.5 + } + ] + } + ] + } +} diff --git a/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_user_status.json b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_user_status.json new file mode 100644 index 0000000..c54be6d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Fixtures/antigravity_user_status.json @@ -0,0 +1,8 @@ +{ + "code": 0, + "userStatus": { + "email": "you@example.com", + "userTier": { "id": "google_ai_ultra", "name": "Google AI Ultra" }, + "planStatus": { "planInfo": { "planName": "Pro" } } + } +} diff --git a/Tests/TokenMenuBarCoreTests/HidePersonalInformationTests.swift b/Tests/TokenMenuBarCoreTests/HidePersonalInformationTests.swift new file mode 100644 index 0000000..a5e0e7f --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/HidePersonalInformationTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +private let identity = ProviderIdentity( + planName: "Team", tier: "Standard", email: "dev@example.com", organization: "Acme Corp", + subscriptionActiveUntil: fixedNow.addingTimeInterval(86400)) + +private func snapshot(_ identity: ProviderIdentity) -> ProviderSnapshot { + ProviderSnapshot(provider: .codex, identity: identity, windows: [], fetchedAt: fixedNow) +} + +@Test func hiddenIdentityKeepsThePlanAndMasksTheAccount() { + let hidden = identity.hidingPersonalInformation + #expect(hidden.email == "account") + #expect(hidden.organization == "workspace") + #expect(hidden.planName == "Team") + #expect(hidden.tier == "Standard") + #expect(hidden.subscriptionActiveUntil == identity.subscriptionActiveUntil) + #expect(identity.personalReplacements.map(\.value) == ["dev@example.com", "Acme Corp"]) + #expect(identity.personalReplacements.map(\.replacement) == ["account", "workspace"]) + let plain = ProviderIdentity(planName: "Pro") + #expect(plain.hidingPersonalInformation == plain) + #expect(plain.personalReplacements.isEmpty) +} + +@Test func chipsMaskTheAccountAndWorkspaceWhenAsked() { + let renews = "Renews \(fixedNow.addingTimeInterval(86400).formatted(date: .abbreviated, time: .omitted))" + #expect( + UsagePresenter.chips(provider: .codex, snapshot: snapshot(identity), hidePersonalInformation: true).map(\.text) + == ["Team", "account", "workspace", renews]) + #expect( + UsagePresenter.chips(provider: .codex, snapshot: snapshot(identity)).map(\.text) + == ["Team", "dev@example.com", "Acme Corp", renews]) +} + +@Test func chipsStillDropThePersonalOrganizationWhenMasked() { + let personal = ProviderIdentity( + planName: "Pro", email: "dev@example.com", organization: "dev@example.com's Organization") + #expect( + UsagePresenter.chips(provider: .codex, snapshot: snapshot(personal), hidePersonalInformation: true).map(\.text) + == ["Pro", "account"]) +} + +@Test func cardIdentityFollowsTheSetting() { + let state = ProviderState(snapshot: snapshot(identity), availability: .current) + let hidden = UsagePresenter.card( + provider: .codex, state: state, samples: [:], options: UsageDisplayOptions(hidePersonalInformation: true), + now: fixedNow) + #expect(hidden.identity == identity.hidingPersonalInformation) + #expect(UsagePresenter.card(provider: .codex, state: state, samples: [:], now: fixedNow).identity == identity) +} + +@Test func settingsProviderPresentationMasksTheAccount() { + let state = ProviderState(snapshot: snapshot(identity), availability: .current) + #expect( + SettingsProviderPresentation(state: state, now: fixedNow, hidePersonalInformation: true).identity + == "account · workspace · Team · Standard") + #expect( + SettingsProviderPresentation(state: state, now: fixedNow).identity + == "dev@example.com · Acme Corp · Team · Standard") +} + +@Test @MainActor func diagnosticsMaskTheWorkspaceWhereverItAppears() { + let settings = Settings(defaults: testDefaults("diagnostics-hidden")) + settings.setProvider(.codex, enabled: true) + let state = AppState() + state.update(.codex) { + $0.snapshot = snapshot(identity) + $0.availability = .current + $0.lastError = "Acme Corp rejected the request" + } + let log = makeLog() + log.log("workspace Acme Corp") + let app = AppInfo( + name: "Token Menu Bar", version: "1", build: "1", bundleIdentifier: "dev.tox.token-menu-bar", isAppStore: false, + repository: AppInfo.repositoryURL) + let visible = Diagnostics.report( + app: app, osVersion: "26", settings: settings, state: state, historyLocation: nil, log: log, now: fixedNow) + #expect(visible.contains("error: Acme Corp rejected")) + settings.hidePersonalInformation = true + let hidden = Diagnostics.report( + app: app, osVersion: "26", settings: settings, state: state, historyLocation: nil, log: log, now: fixedNow) + #expect(!hidden.contains("Acme Corp")) + #expect(hidden.contains("error: workspace rejected the request")) + #expect(hidden.hasSuffix("[info] workspace workspace")) + #expect(hidden.contains("plan Team")) +} diff --git a/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift b/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift index 37585ee..79141fc 100644 --- a/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift +++ b/Tests/TokenMenuBarCoreTests/NotificationPlannerTests.swift @@ -35,8 +35,7 @@ private func plan( @Test func plannerReportsHighestCrossedThreshold() { let events = plan(snapshot(70), snapshot(92)) - #expect(events.count == 1) - #expect(events[0].kind == .threshold) + #expect(events.map(\.kind) == [.threshold, .willRunOut]) #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")) @@ -173,8 +172,7 @@ func plannerFormatsTheTimeUntilACreditExpires(offset: TimeInterval, expected: St let before = QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: 60, resetsAt: nil) let after = QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: 78, resetsAt: nil) let events = plan(snapshot(70, extra: [before]), snapshot(92, extra: [after])) - #expect(events.count == 1) - #expect(events[0].kind == .threshold) + #expect(events.map(\.kind) == [.threshold, .willRunOut]) #expect(events[0].window == nil) #expect(events[0].title == "Claude: 2 limits crossed 75%") #expect(events[0].body == "Current session at 92%, Weekly at 78%.") diff --git a/Tests/TokenMenuBarCoreTests/PaceNotificationTests.swift b/Tests/TokenMenuBarCoreTests/PaceNotificationTests.swift new file mode 100644 index 0000000..086f1dc --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/PaceNotificationTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +import TokenMenuBarCore +import TokenMenuBarTestSupport + +private let sessionResets = fixedNow.addingTimeInterval(9000) + +private func snapshot( + _ percent: Double, at fetchedAt: Date, resetsAt: Date = sessionResets, duration: TimeInterval = 18000, + id: String = "session", label: String = "Current session" +) -> ProviderSnapshot { + ProviderSnapshot( + provider: .claude, + windows: [ + QuotaWindow(id: id, label: label, group: .session, usedPercent: percent, resetsAt: resetsAt, duration: duration) + ], + fetchedAt: fetchedAt) +} + +private func plan( + _ previous: ProviderSnapshot, _ current: ProviderSnapshot, settings: NotificationSettings = NotificationSettings() +) -> [NotificationEvent] { + NotificationPlanner.events( + previous: previous, current: current, previousAvailability: .current, currentAvailability: .current, + provider: .claude, settings: settings, now: current.fetchedAt) +} + +private let onPace = snapshot(45, at: fixedNow.addingTimeInterval(-600)) +private let ahead = snapshot(70, at: fixedNow) + +@Test func paceNoticesFireWhenAWindowFirstRunsAheadAndShort() { + let events = plan(onPace, ahead) + #expect(events.map(\.kind) == [.willRunOut, .aheadOfPace]) + let epoch = Int(sessionResets.timeIntervalSince1970) + #expect(events[0].id == "claude:session:pace-runout:\(epoch)") + #expect(events[0].title == "Claude Current session will run out in 1 h") + #expect(events[0].body == "At this pace it hits 100% before the reset in 2 h.") + #expect(events[1].id == "claude:session:pace-ahead:\(epoch)") + #expect(events[1].title == "Claude Current session is ahead of pace") + #expect(events[1].body == "70% used with 2 hr 30 min left.") + #expect(events.allSatisfy { $0.window == WindowKey(provider: .claude, windowID: "session") }) +} + +@Test func paceNoticesFireOncePerEpoch() { + let later = snapshot(72, at: fixedNow.addingTimeInterval(300)) + #expect(plan(ahead, later).isEmpty) +} + +@Test func paceNoticesArmAgainAfterARefreshOnPace() { + let recovered = snapshot(50, at: fixedNow.addingTimeInterval(600)) + #expect(plan(ahead, recovered).isEmpty) + let aheadAgain = snapshot(74, at: fixedNow.addingTimeInterval(1200)) + #expect(plan(recovered, aheadAgain).map(\.kind) == [.willRunOut, .aheadOfPace]) +} + +@Test func paceNoticesArmAcrossAReset() { + let previousEpoch = snapshot(90, at: fixedNow.addingTimeInterval(-600), resetsAt: fixedNow.addingTimeInterval(-100)) + let events = plan(previousEpoch, ahead) + #expect(events.map(\.kind) == [.reset, .willRunOut, .aheadOfPace]) +} + +@Test func paceNoticesStaySilentWhenDisabled() { + #expect(plan(onPace, ahead, settings: NotificationSettings(notifyOnPace: false)).isEmpty) +} + +@Test func paceNoticesStaySilentEarlyInAWindow() { + let resetsAt = fixedNow.addingTimeInterval(17280) + let idle = snapshot(0, at: fixedNow.addingTimeInterval(-300), resetsAt: resetsAt) + let burst = snapshot(50, at: fixedNow, resetsAt: resetsAt) + #expect(PaceEstimate.estimate(window: burst.windows[0], now: fixedNow).projectedExhaustion != nil) + #expect(plan(idle, burst).isEmpty) +} + +@Test func paceNoticesGiveWayToTheLimitReachedThreshold() { + let events = plan(ahead, snapshot(100, at: fixedNow.addingTimeInterval(300))) + #expect(events.map(\.kind) == [.threshold]) +} + +@Test func paceNoticesCountLongerRunwaysInDays() { + let resetsAt = fixedNow.addingTimeInterval(302_400) + let steady = snapshot( + 45, at: fixedNow.addingTimeInterval(-3600), resetsAt: resetsAt, duration: 604_800, id: "weekly", label: "Weekly") + let heavy = snapshot(60, at: fixedNow, resetsAt: resetsAt, duration: 604_800, id: "weekly", label: "Weekly") + let events = plan(steady, heavy) + #expect(events.map(\.kind) == [.willRunOut]) + #expect(events[0].title == "Claude Weekly will run out in 2 d") + #expect(events[0].body == "At this pace it hits 100% before the reset in 3 d.") +} + +@Test func paceNoticesNeedAResetTime() { + let previous = ProviderSnapshot( + provider: .claude, windows: [QuotaWindow(id: "w", label: "W", group: .other, usedPercent: 10, resetsAt: nil)], + fetchedAt: fixedNow.addingTimeInterval(-600)) + let current = ProviderSnapshot( + provider: .claude, windows: [QuotaWindow(id: "w", label: "W", group: .other, usedPercent: 60, resetsAt: nil)], + fetchedAt: fixedNow) + #expect(plan(previous, current).isEmpty) +} + +@Test func notificationSettingsSavedBeforePaceNoticesDecodeThemOn() throws { + let json = #"{"enabled":true,"thresholds":[75],"notifyOnReset":true,"notifyOnAuthProblems":false}"# + let decoded = try JSONDecoder().decode(NotificationSettings.self, from: Data(json.utf8)) + #expect(decoded.notifyOnPace) + #expect(!decoded.notifyOnAuthProblems) + let stored = try JSONEncoder().encode(NotificationSettings(notifyOnPace: false)) + #expect(try JSONDecoder().decode(NotificationSettings.self, from: stored).notifyOnPace == false) +} diff --git a/Tests/TokenMenuBarCoreTests/PaceWorkdayTests.swift b/Tests/TokenMenuBarCoreTests/PaceWorkdayTests.swift new file mode 100644 index 0000000..77efe85 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/PaceWorkdayTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +import TokenMenuBarCore +import TokenMenuBarTestSupport + +private let utc: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar +}() + +private func date(_ month: Int, _ day: Int, hour: Int = 0) -> Date { + utc.date(from: DateComponents(year: 2026, month: month, day: day, hour: hour))! +} + +private func window(used: Double, start: Date, duration: TimeInterval = 7 * 86400) -> QuotaWindow { + QuotaWindow( + id: "weekly", label: "Weekly", group: .weekly, usedPercent: used, resetsAt: start.addingTimeInterval(duration), + duration: duration) +} + +private let saturdayStart = date(8, 29) +private let mondayMorning = date(8, 31, hour: 9) + +private let workdayCases: [(workdays: Int, expected: Double, status: PaceStatus)] = [ + (7, 57.0 / 168 * 100, .onTrack), + (6, 33.0 / 144 * 100, .ahead), + (5, 9.0 / 120 * 100, .ahead), + (4, 9.0 / 96 * 100, .ahead), +] + +@Test(arguments: workdayCases) +func paceExpectsUsageOnlyOnWorkdays(workdays: Int, expected: Double, status: PaceStatus) { + let estimate = PaceEstimate.estimate( + window: window(used: 30, start: saturdayStart), workdays: workdays, calendar: utc, now: mondayMorning) + #expect(estimate.expectedPercent.map { abs($0 - expected) < 0.0001 } == true) + #expect(estimate.status == status) +} + +@Test func paceWorkdaysLeaveWindowsOfADayOrLessLinear() { + let session = window(used: 30, start: date(8, 30, hour: 20), duration: 18000) + let estimate = PaceEstimate.estimate( + window: session, workdays: 5, calendar: utc, now: date(8, 30, hour: 22, minute: 30)) + #expect(estimate.expectedPercent == 50) +} + +private func date(_ month: Int, _ day: Int, hour: Int, minute: Int) -> Date { + utc.date(from: DateComponents(year: 2026, month: month, day: day, hour: hour, minute: minute))! +} + +@Test func paceWorkdaysFallBackToLinearWhenAWindowHasNoWorkday() { + let weekend = window(used: 30, start: saturdayStart, duration: 2 * 86400) + let estimate = PaceEstimate.estimate( + window: weekend, workdays: 5, calendar: utc, now: date(8, 30, hour: 12)) + #expect(estimate.expectedPercent == 75) +} + +@Test func paceWorkdaysCountElapsedTimeOnlyUpToTheReset() { + let start = date(8, 24) + let estimate = PaceEstimate.estimate( + window: window(used: 60, start: start), workdays: 5, calendar: utc, now: date(8, 28, hour: 12)) + #expect(estimate.expectedPercent.map { abs($0 - 4.5 / 5 * 100) < 0.0001 } == true) + #expect(estimate.status == .behind) +} + +// The presenter counts workdays in the user's calendar, so this checks the wiring rather than a fixed percentage. +@Test func paceWorkdaysApplyToPresentedRowsAndDefaultToEveryDay() { + let weekly = window(used: 30, start: saturdayStart) + let state = ProviderState( + snapshot: ProviderSnapshot(provider: .claude, windows: [weekly], fetchedAt: mondayMorning), + availability: .current) + let fiveDays = UsagePresenter.card( + provider: .claude, state: state, samples: [:], options: UsageDisplayOptions(paceWorkdays: 5), now: mondayMorning) + let everyDay = UsagePresenter.card(provider: .claude, state: state, samples: [:], now: mondayMorning) + #expect(fiveDays.rows[0].pace == PaceEstimate.estimate(window: weekly, workdays: 5, now: mondayMorning)) + #expect(everyDay.rows[0].pace == PaceEstimate.estimate(window: weekly, now: mondayMorning)) + #expect(fiveDays.rows[0].pace.expectedPercent != everyDay.rows[0].pace.expectedPercent) + #expect(UsageDisplayOptions.standard.paceWorkdays == PaceEstimate.workdayRange.upperBound) +} diff --git a/Tests/TokenMenuBarCoreTests/PresentationSettingsTests.swift b/Tests/TokenMenuBarCoreTests/PresentationSettingsTests.swift new file mode 100644 index 0000000..cdb067a --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/PresentationSettingsTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +import TokenMenuBarCore +import TokenMenuBarTestSupport + +@Test @MainActor func presentationSettingsDefaultToUsedEveryDayAndVisible() { + let settings = Settings(defaults: testDefaults()) + #expect(settings.usageDisplay == .used) + #expect(settings.paceWorkdays == 7) + #expect(!settings.hidePersonalInformation) + #expect(settings.notifications.notifyOnPace) + #expect(settings.presentationOptions == .standard) +} + +@Test(arguments: [(3, 4), (5, 5), (9, 7)]) +@MainActor func paceWorkdaysClampToTheSupportedWeek(requested: Int, stored: Int) { + let defaults = testDefaults() + let settings = Settings(defaults: defaults) + settings.paceWorkdays = requested + #expect(settings.paceWorkdays == stored) + #expect(Settings(defaults: defaults).paceWorkdays == stored) +} + +@Test @MainActor func presentationSettingsSurviveAReloadAndReset() { + let defaults = testDefaults() + let settings = Settings(defaults: defaults) + settings.usageDisplay = .remaining + settings.hidePersonalInformation = true + settings.paceWorkdays = 5 + settings.notifications.notifyOnPace = false + let reloaded = Settings(defaults: defaults) + #expect(reloaded.usageDisplay == .remaining) + #expect(reloaded.hidePersonalInformation) + #expect(reloaded.paceWorkdays == 5) + #expect(!reloaded.notifications.notifyOnPace) + #expect( + reloaded.presentationOptions + == UsageDisplayOptions(display: .remaining, paceWorkdays: 5, hidePersonalInformation: true)) + reloaded.resetToDefaults() + #expect(reloaded.presentationOptions == .standard) + #expect(reloaded.notifications.notifyOnPace) + #expect(Settings(defaults: defaults).presentationOptions == .standard) +} + +@Test @MainActor func presentationSettingsIgnoreAnUnknownStoredDisplay() { + let defaults = testDefaults() + defaults.set("Sideways", forKey: "usageDisplay") + #expect(Settings(defaults: defaults).usageDisplay == .used) +} diff --git a/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift b/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift index 5329040..16afb2c 100644 --- a/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift +++ b/Tests/TokenMenuBarCoreTests/ProviderRegistryFactoryTests.swift @@ -138,6 +138,72 @@ import TokenMenuBarTestSupport #expect(transport.requests(matching: "/token").count == 1) } +@Test @MainActor func providerRegistryFactoryWiresAntigravityToTheKeychainAndProcessTable() async throws { + let root = temporaryDirectory() + let keychain = MemoryKeychain() + try keychain.client.save( + antigravityKeychainValue(Fixtures.json("antigravity_keychain_token")), service: "gemini", account: "antigravity") + let transport = StubTransport() + transport.on(path: "LanguageServerService/RetrieveUserQuotaSummary", .json("antigravity_local_quota")) + transport.on(path: "LanguageServerService/GetUserStatus", .json("antigravity_user_status")) + let registry = ProviderRegistryFactory.make( + configuration: ProviderRegistryFactory.Configuration( + home: root, + supportDirectory: root.appendingPathComponent("support"), + environment: [:], + userName: "factory-antigravity", + resourceURLs: [:], + enabledProviders: [.antigravity], + keychain: keychain.client, + allowTokenRefresh: { false }, + processScanner: StubProcessScanner([antigravityServer], ports: [4242: [4321]])), + client: APIClient(transport: transport, log: makeLog()), + log: makeLog()) + let provider = try #require(registry[.antigravity]) + + #expect(provider.credentialDescription == "Keychain item gemini (antigravity)") + #expect(await provider.credentialHealth(now: fixedNow).source?.id == "antigravity.keychain") + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + #expect(result.outcome.snapshot?.identity?.email == "you@example.com") + #expect(transport.requests.first?.url?.port == 4321) +} + +@Test @MainActor func providerRegistryFactoryResolvesAntigravityOAuthWhenItsProviderRefreshes() async throws { + let root = temporaryDirectory() + let keychain = MemoryKeychain() + let expired = AntigravityAuth( + accessToken: "expired", refreshToken: "refresh", expiresAt: fixedNow.addingTimeInterval(-60)) + try keychain.client.save( + antigravityKeychainValue( + .object([ + "access_token": .string(expired.accessToken), "refresh_token": .string(expired.refreshToken!), + "expiry": .number(expired.expiresAt!.timeIntervalSince1970), + ])), service: "gemini", account: "antigravity") + let transport = StubTransport() + transport.on(path: "/token", .text("unavailable", status: 503)) + let registry = ProviderRegistryFactory.make( + configuration: ProviderRegistryFactory.Configuration( + home: root, + supportDirectory: root.appendingPathComponent("support"), + environment: [ + "ANTIGRAVITY_OAUTH_CLIENT_ID": "factory-client", "ANTIGRAVITY_OAUTH_CLIENT_SECRET": "factory-secret", + ], + userName: "factory-antigravity", + resourceURLs: [:], + enabledProviders: [.antigravity], + keychain: keychain.client, + allowTokenRefresh: { true }), + client: APIClient(transport: transport, log: makeLog()), + log: makeLog()) + let provider = try #require(registry[.antigravity]) + + let result = await provider.fetch(now: fixedNow, options: FetchOptions()) + + #expect(result.outcome == .notAuthenticated("Antigravity token refresh failed: HTTP 503")) + let tokenRequest = try #require(transport.requests(matching: "/token").first) + #expect(String(decoding: tokenRequest.httpBody!, as: UTF8.self).contains("client_id=factory-client")) +} + @Test @MainActor func providerRegistryFactoryTransfersResourceLeaseOwnership() throws { let root = temporaryDirectory() let probe = RegistryLeaseProbe() diff --git a/Tests/TokenMenuBarCoreTests/SettingsTests.swift b/Tests/TokenMenuBarCoreTests/SettingsTests.swift index 9c90e75..141f532 100644 --- a/Tests/TokenMenuBarCoreTests/SettingsTests.swift +++ b/Tests/TokenMenuBarCoreTests/SettingsTests.swift @@ -253,3 +253,18 @@ private func freshDefaults() -> UserDefaults { settings.flush() #expect(Settings(defaults: defaults).demoMode == true) } + +@Test @MainActor func settingsAppendAndEnableProvidersAddedSinceTheLastLaunch() throws { + let defaults = testDefaults() + let settings = Settings(defaults: defaults) + settings.providerOrder = [.codex, .claude] + settings.enabledProviders = [.codex] + defaults.set(try JSONEncoder().encode(Set([.codex, .claude])), forKey: "knownProviders") + + let reloaded = Settings(defaults: defaults) + + let added = ProviderID.allCases.filter { ![.codex, .claude].contains($0) } + #expect(reloaded.providerOrder == [.codex, .claude] + added) + #expect(reloaded.enabledProviders == Set([.codex] + added)) + #expect(Settings(defaults: defaults).providerOrder == [.codex, .claude] + added) +} diff --git a/Tests/TokenMenuBarCoreTests/SpendSummaryPresenterTests.swift b/Tests/TokenMenuBarCoreTests/SpendSummaryPresenterTests.swift new file mode 100644 index 0000000..89b966a --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/SpendSummaryPresenterTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +private let utc = TimeZone(identifier: "UTC")! + +private func day(_ offset: Int) -> String { + DayStamp.string(fixedNow.addingTimeInterval(Double(offset) * 86400)) +} + +private func row(_ provider: ProviderID, _ model: String, day offset: Int, cost: Double) -> HistoryAnalyticsRow { + HistoryAnalyticsRow( + provider: provider, point: AnalyticsPoint(day: day(offset), metric: .costUSD, series: model, value: cost)) +} + +private let rows = [ + row(.claude, "claude-opus-5", day: 0, cost: 3), row(.codex, "gpt-5", day: 0, cost: 1.5), + row(.claude, "claude-opus-5", day: -1, cost: 2), row(.claude, "claude-haiku-4-5", day: -29, cost: 0.5), + row(.claude, "claude-opus-5", day: -30, cost: 9), row(.codex, "gpt-5", day: 1, cost: 7), +] + +@Test func spendSummaryTotalsTodayYesterdayAndTheWindow() { + let summary = SpendSummaryPresenter.summary(rows: rows, now: fixedNow, timeZone: utc) + #expect(summary.today == 4.5) + #expect(summary.yesterday == 2) + #expect(summary.lastWindow == 7) + #expect(summary.providers == [.claude, .codex]) + #expect(summary.hasData) + #expect(summary.topModels.map(\.id) == ["claude:claude-opus-5", "codex:gpt-5", "claude:claude-haiku-4-5"]) + #expect(summary.topModels.map(\.cost) == [5, 1.5, 0.5]) + #expect(summary.tiles.map(\.title) == ["Today", "Yesterday", "Last 30 days"]) + #expect(summary.tiles.map(\.text) == [Format.currency(4.5), Format.currency(2), Format.currency(7)]) + #expect(summary.attribution == "API-equivalent cost across Claude + Codex · daily UTC") + #expect( + summary.breakdown + == "Top models over the last 30 days:\nClaude · claude-opus-5: \(Format.currency(5))\n" + + "Codex · gpt-5: \(Format.currency(1.5))\nClaude · claude-haiku-4-5: \(Format.currency(0.5))") +} + +@Test func spendSummaryKeepsTheFiveCostliestModels() { + let many = (0..<7).map { row(.claude, "model-\($0)", day: 0, cost: Double($0)) } + let summary = SpendSummaryPresenter.summary(rows: many, now: fixedNow, timeZone: utc) + #expect(summary.topModels.map(\.model) == ["model-6", "model-5", "model-4", "model-3", "model-2"]) + #expect(summary.today == 21) +} + +@Test( + arguments: [ + (0, day(0), 4.5, 2.0, 7.0), + (12 * 3600, day(1), 7.0, 4.5, 13.5), + ]) +func spendSummaryPicksTodayByTheChosenDayBoundary( + secondsFromGMT: Int, expectedToday: String, today: Double, yesterday: Double, window: Double +) { + let timeZone = TimeZone(secondsFromGMT: secondsFromGMT)! + #expect(SpendSummaryPresenter.dayStamps(now: fixedNow, timeZone: timeZone).today == expectedToday) + let summary = SpendSummaryPresenter.summary(rows: rows, now: fixedNow, timeZone: timeZone) + #expect(summary.today == today) + #expect(summary.yesterday == yesterday) + #expect(summary.lastWindow == window) +} + +@Test func spendSummaryHasNoDataWithoutCostRows() { + let summary = SpendSummaryPresenter.summary(rows: [], now: fixedNow, timeZone: utc) + #expect(summary == .empty) + #expect(!summary.hasData) + #expect(summary.tiles.map(\.text) == Array(repeating: Format.currency(0), count: 3)) + #expect(summary.breakdown == "Top models over the last 30 days:") +} + +@Test func spendSummaryLoadsCostRowsForTheRequestedProviders() async throws { + let history = try UsageHistoryStore(url: nil) + try await history.record( + ProviderAnalytics( + provider: .claude, + points: [ + AnalyticsPoint(day: day(0), metric: .costUSD, series: "claude-opus-5", value: 3), + AnalyticsPoint(day: day(0), metric: .projectCost, series: "repo", value: 3), + ], fetchedAt: fixedNow)) + try await history.record( + ProviderAnalytics( + provider: .codex, points: [AnalyticsPoint(day: day(-1), metric: .costUSD, series: "gpt-5", value: 1.5)], + fetchedAt: fixedNow)) + + let claudeOnly = try await SpendSummaryPresenter.load( + history: history, providers: [.claude, .gemini], now: fixedNow, timeZone: utc) + #expect(claudeOnly.today == 3) + #expect(claudeOnly.yesterday == 0) + #expect(claudeOnly.providers == [.claude]) + + let everyone = try await SpendSummaryPresenter.load( + history: history, providers: ProviderID.allCases, now: fixedNow, timeZone: utc) + #expect(everyone.today == 3) + #expect(everyone.yesterday == 1.5) + #expect(everyone.lastWindow == 4.5) + #expect(everyone.topModels.map(\.id) == ["claude:claude-opus-5", "codex:gpt-5"]) +} diff --git a/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift b/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift index fcf68df..06faaf1 100644 --- a/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift +++ b/Tests/TokenMenuBarCoreTests/StatusTemplateTests.swift @@ -46,7 +46,7 @@ private func context( #expect(StatusFormat.stacked.template == "{label}\n{pct}") #expect(StatusFormat.inline.template == "{label}:{pct}") #expect(StatusFormat.custom.template == nil) - #expect(StatusTemplate.tokens.count == 13) + #expect(StatusTemplate.tokens.count == 14) } @Test( diff --git a/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift b/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift index aa81214..035e931 100644 --- a/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift +++ b/Tests/TokenMenuBarCoreTests/Support/MemoryCredentialStore.swift @@ -49,6 +49,10 @@ extension MemoryCredentialStore: GeminiAuthStore where Value == GeminiAuth { func save(_ auth: GeminiAuth) throws { try write(auth) } } +extension MemoryCredentialStore: AntigravityAuthStore where Value == AntigravityAuth { + func load() throws -> AntigravityAuth? { try read() } +} + extension MemoryCredentialStore: CursorAuthStore where Value == CursorAuth { func load() throws -> CursorAuth? { try read() } } @@ -60,5 +64,6 @@ extension MemoryCredentialStore: CopilotAuthStore where Value == CopilotAuth { typealias MemoryClaudeStore = MemoryCredentialStore typealias MemoryCodexStore = MemoryCredentialStore typealias MemoryGeminiStore = MemoryCredentialStore +typealias MemoryAntigravityStore = MemoryCredentialStore typealias MemoryCursorStore = MemoryCredentialStore typealias MemoryCopilotStore = MemoryCredentialStore diff --git a/Tests/TokenMenuBarCoreTests/Support/StubProcessScanner.swift b/Tests/TokenMenuBarCoreTests/Support/StubProcessScanner.swift new file mode 100644 index 0000000..ee2d27d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/Support/StubProcessScanner.swift @@ -0,0 +1,23 @@ +import Darwin +import Foundation +import TokenMenuBarCore + +struct StubProcessScanner: ProcessScanner { + let entries: [ProcessEntry] + let ports: [pid_t: [UInt16]] + + init(_ entries: [ProcessEntry] = [], ports: [pid_t: [UInt16]] = [:]) { + self.entries = entries + self.ports = ports + } + + func processes() -> [ProcessEntry] { entries } + + func listeningPorts(pid: pid_t) -> [UInt16] { ports[pid, default: []] } +} + +let antigravityServer = ProcessEntry( + pid: 4242, path: "/Applications/Antigravity.app/Contents/Resources/bin/language_server_macos_arm", + arguments: ["language_server_macos_arm", "--csrf_token", "csrf-1", "--app_data_dir", "antigravity"]) + +let antigravityCLI = ProcessEntry(pid: 4343, path: "/opt/homebrew/bin/agy", arguments: ["agy", "quota"]) diff --git a/Tests/TokenMenuBarCoreTests/UsageDisplayTests.swift b/Tests/TokenMenuBarCoreTests/UsageDisplayTests.swift new file mode 100644 index 0000000..254c7d2 --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/UsageDisplayTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing +import TokenMenuBarCore +import TokenMenuBarTestSupport + +@Test(arguments: [(UsageDisplay.used, "36.4%", "used"), (.remaining, "63.6%", "left")]) +func usageDisplayFormatsThePercentItNames(display: UsageDisplay, text: String, suffix: String) { + #expect(Format.percent(used: 36.4, display: display, decimals: 1) == text) + #expect(display.suffix == suffix) +} + +private let session = QuotaWindow( + id: "session", label: "Current session", group: .session, usedPercent: 36.4, + resetsAt: fixedNow.addingTimeInterval(4 * 3600 + 24 * 60), duration: 18000) + +private func snapshots() -> [ProviderID: ProviderSnapshot] { + [.claude: ProviderSnapshot(provider: .claude, windows: [session], fetchedAt: fixedNow)] +} + +private func input(format: StatusFormat, display: UsageDisplay) -> StatusItemInput { + StatusItemInput( + snapshots: snapshots(), availability: [.claude: .current], + selectedKeys: [WindowKey(provider: .claude, windowID: "session")], format: format, customTemplate: "", + decimals: 0, hideZeroCells: true, order: .provider, labels: [:], now: fixedNow, display: display) +} + +@Test func templatePercentTokensFollowTheDisplayButKeepTheUsedColour() { + let context = StatusCellContext( + provider: .claude, window: session, cellLabel: "CC", shortLabel: "CC 5h", decimals: 0, planName: nil, + credits: nil, now: fixedNow, display: .remaining) + let lines = StatusTemplate.render("{pct} {pct0} {pct1} {pct2} {remaining}", context: context) + #expect(StatusTemplate.plainText(lines) == "64% 64% 63.6% 63.60% 64%") + #expect(lines[0].allSatisfy { $0.kind == .usage(36.4) || $0.kind == .label }) + #expect(lines[0].contains { $0.kind == .usage(36.4) }) +} + +@Test(arguments: [ + (UsageDisplay.used, "36%", "Claude Current session: 36%, resets 4 hr 24 min", "Current session: 36%"), + (.remaining, "64%", "Claude Current session: 64% left, resets 4 hr 24 min", "Current session: 64% left"), +]) +func builderRendersCellsAndTooltipsInTheChosenDisplay( + display: UsageDisplay, percent: String, tooltip: String, barTooltip: String +) { + let stacked = StatusItemBuilder.build(input(format: .stacked, display: display)) + #expect(stacked.cells[0].lines.map { StatusTemplate.plainText([$0]) } == ["CC 5h", percent]) + #expect(stacked.cells[0].tooltip == tooltip) + #expect(stacked.cells[0].percent == 36.4) + let bars = StatusItemBuilder.build(input(format: .miniBars, display: display)) + #expect(bars.cells[0].tooltip == barTooltip) + #expect(bars.cells[0].bars.map(\.percent) == [36.4]) +} + +@Test func builderCarriesTheDisplayAcrossAdaptiveTiers() { + let remaining = input(format: .stacked, display: .remaining) + #expect(remaining.with(tier: .stacked).display == .remaining) + for candidate in StatusItemBuilder.candidates(remaining) where !candidate.cells.isEmpty { + #expect(candidate.cells[0].tooltip.contains("64% left")) + } +} + +@Test(arguments: [(UsageDisplay.used, "36%", "36% used"), (.remaining, "64%", "64% left")]) +func windowRowsReadInTheChosenDisplay(display: UsageDisplay, percent: String, prefix: String) { + let state = ProviderState(snapshot: snapshots()[.claude], availability: .current) + let card = UsagePresenter.card( + provider: .claude, state: state, samples: [:], options: UsageDisplayOptions(display: display), now: fixedNow) + #expect(card.rows[0].percentText == percent) + #expect(card.rows[0].accessibilityValue(at: fixedNow).hasPrefix(prefix)) + #expect(card.rows[0].color == UsageColor.color(pace: card.rows[0].pace.status, percent: 36.4)) +} + +@Test func presentationAndCardsPassTheDisplayThrough() { + let state: [ProviderID: ProviderState] = [ + .claude: ProviderState(snapshot: snapshots()[.claude], availability: .current) + ] + let options = UsageDisplayOptions(display: .remaining) + #expect( + UsagePresenter.cards(state: state, enabled: [.claude], samples: [:], options: options, now: fixedNow)[0] + .rows[0].percentText == "64%") + let presentation = UsagePresenter.presentation( + state: state, enabled: [.claude], selected: [WindowKey(provider: .claude, windowID: "session")], samples: [:], + analytics: [:], lastRefresh: nil, iconTone: .normal, isRefreshing: false, options: options, now: fixedNow) + #expect(presentation.cards[0].rows[0].percentText == "64%") +} + +@Test func widgetSnapshotCarriesTheDisplayAndTreatsItAsContent() throws { + let keys = [WindowKey(provider: .claude, windowID: "session")] + let remaining = WidgetSnapshot.build( + snapshots: snapshots(), availability: [:], selectedKeys: keys, display: .remaining, now: fixedNow) + let used = WidgetSnapshot.build(snapshots: snapshots(), availability: [:], selectedKeys: keys, now: fixedNow) + #expect(remaining.display == .remaining) + #expect(remaining.rows[0].percentText(display: remaining.display) == "64%") + #expect(used.rows[0].percentText(display: used.display) == "36%") + #expect(!remaining.hasSameContent(as: used)) + #expect(remaining.shouldPublish(after: used)) + let store = WidgetSnapshotStore(url: temporaryDirectory().appendingPathComponent("widget.json")) + try store.write(remaining) + #expect(store.read()?.display == .remaining) +} + +@Test func widgetSnapshotWrittenBeforeTheDisplayExistedDecodesAsUsed() throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let legacy = try decoder.decode( + WidgetSnapshot.self, from: Data(#"{"rows":[],"attention":true,"updatedAt":1788030000}"#.utf8)) + #expect(legacy.display == .used) + #expect(legacy.attention) + #expect(legacy.updatedAt == fixedNow) +} diff --git a/Tests/TokenMenuBarCoreTests/UsageExportTests.swift b/Tests/TokenMenuBarCoreTests/UsageExportTests.swift new file mode 100644 index 0000000..91ed93d --- /dev/null +++ b/Tests/TokenMenuBarCoreTests/UsageExportTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore + +private func cachedSnapshot(_ provider: ProviderID, plan: String?) -> ProviderSnapshot { + ProviderSnapshot( + provider: provider, + identity: plan.map { ProviderIdentity(planName: $0) }, + windows: [ + QuotaWindow( + id: "session", label: "Current session", group: .session, usedPercent: 36, + resetsAt: fixedNow.addingTimeInterval(3600), duration: 18000), + QuotaWindow(id: "weekly", label: "Weekly", group: .weekly, usedPercent: 61, resetsAt: nil, duration: nil), + ], + fetchedAt: fixedNow) +} + +private let cachedSnapshots: [ProviderID: ProviderSnapshot] = [ + .claude: cachedSnapshot(.claude, plan: "Max 20x"), .codex: cachedSnapshot(.codex, plan: nil), +] + +@Test( + arguments: [ + (["app", "--usage-json"], ExportInvocation.usageJSON), + (["--usage-json", "--export-icon", "/tmp/a"], .usageJSON), + (["--export-menubar", "/tmp/a"], .files(.menuBar, directory: URL(fileURLWithPath: "/tmp/a"))), + ]) +func exportInvocationParsesTheUsageFlagWithoutADirectory(arguments: [String], invocation: ExportInvocation) { + #expect(ExportInvocation.parse(arguments) == invocation) +} + +@Test(arguments: [["app"], ["app", "--export-popover"], []]) +func exportInvocationNeedsAKnownFlag(arguments: [String]) { + #expect(ExportInvocation.parse(arguments) == nil) +} + +@Test func exportInvocationsNameTheirFailure() { + #expect(ExportInvocation.usageJSON.failureMessage == "usage export failed") + #expect( + ExportInvocation.files(.popover, directory: URL(fileURLWithPath: "/tmp/a")).failureMessage + == "popover export failed") +} + +@Test func usageJSONCommandPrintsTheCachedSnapshotSet() throws { + let cache = SnapshotCache(url: temporaryDirectory().appendingPathComponent("snapshots.json")) + try cache.store(cachedSnapshots) + + let output = try UsageExportCommand.output(cache: cache) + + let json = try #require(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [String: Any]) + let providers = try #require(json["providers"] as? [[String: Any]]) + #expect(providers.map { $0["id"] as? String } == ["claude", "codex"]) + #expect(providers[0]["plan"] as? String == "Max 20x") + #expect(providers[1]["plan"] == nil) + #expect(providers.map { $0["fetchedAt"] as? String } == ["2026-08-29T19:00:00Z", "2026-08-29T19:00:00Z"]) + let windows = try #require(providers[0]["windows"] as? [[String: Any]]) + #expect(windows.map { $0["id"] as? String } == ["session", "weekly"]) + #expect(windows.map { $0["label"] as? String } == ["Current session", "Weekly"]) + #expect(windows.map { $0["usedPercent"] as? Double } == [36, 61]) + #expect(windows[0]["resetsAt"] as? String == "2026-08-29T20:00:00Z") + #expect(windows[1]["resetsAt"] == nil) + #expect(output.hasSuffix("}")) +} + +@Test(arguments: [URL?.none, temporaryDirectory().appendingPathComponent("missing.json")]) +func usageJSONCommandFailsWithoutACache(url: URL?) { + #expect(throws: UsageExportCommand.NoCachedUsage()) { + try UsageExportCommand.output(cache: SnapshotCache(url: url)) + } + #expect(UsageExportCommand.NoCachedUsage().description.contains("no cached usage")) +} + +@Test func usageExportRoundTripsThroughItsFile() throws { + let url = temporaryDirectory().appendingPathComponent("nested/usage.json") + let export = UsageExport(snapshots: cachedSnapshots) + try export.write(to: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + #expect(try decoder.decode(UsageExport.self, from: Data(contentsOf: url)) == export) + #expect(export.providers.map(\.windows.count) == [2, 2]) +} + +@Test func snapshotPersistenceWritesTheUsageFileOnlyWhenAsked() async throws { + let root = temporaryDirectory() + let usageURL = root.appendingPathComponent(UsageExport.fileName) + let persistence = SnapshotPersistence( + cache: SnapshotCache(url: root.appendingPathComponent("snapshots.json")), usageFileURL: usageURL) + + await persistence.submitSnapshots(cachedSnapshots) + await persistence.flush() + #expect(!FileManager.default.fileExists(atPath: usageURL.path)) + + await persistence.submitSnapshots(cachedSnapshots, writesUsageFile: true) + await persistence.flush() + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + #expect( + try decoder.decode(UsageExport.self, from: Data(contentsOf: usageURL)) == UsageExport(snapshots: cachedSnapshots)) + + await persistence.submitSnapshots(cachedSnapshots, writesUsageFile: true) + await persistence.flush() + let workload = await persistence.workload + #expect(workload.usageFileWrites == 1) + #expect(workload.cacheWrites == 1) +} + +@Test func snapshotPersistenceSkipsTheUsageFileWithoutALocation() async throws { + let persistence = SnapshotPersistence(cache: SnapshotCache(url: nil)) + await persistence.submitSnapshots(cachedSnapshots, writesUsageFile: true) + await persistence.flush() + #expect((await persistence.workload).usageFileWrites == 0) +} + +@Test func snapshotPersistenceReportsUsageFileWriteFailures() async throws { + let blocker = temporaryDirectory().appendingPathComponent("blocker") + try Data().write(to: blocker) + let failures = UsageFileFailureRecorder() + let persistence = SnapshotPersistence( + cache: SnapshotCache(url: nil), usageFileURL: blocker.appendingPathComponent("usage.json") + ) { await failures.record($0) } + await persistence.submitSnapshots(cachedSnapshots, writesUsageFile: true) + await persistence.flush() + let recorded = await failures.values + #expect(recorded.count == 1) + #expect(recorded.first?.message.hasPrefix("usage file write failed: ") == true) + #expect((await persistence.workload).usageFileWrites == 0) +} + +@Test @MainActor func settingsPersistTheUsageFileAndFirstRunChoices() { + let defaults = testDefaults("script-output") + let settings = Settings(defaults: defaults) + #expect(!settings.writeUsageFile) + #expect(!settings.firstRunCardDismissed) + settings.writeUsageFile = true + settings.firstRunCardDismissed = true + let reloaded = Settings(defaults: defaults) + #expect(reloaded.writeUsageFile) + #expect(reloaded.firstRunCardDismissed) + reloaded.resetToDefaults() + #expect(!reloaded.writeUsageFile) + #expect(!reloaded.firstRunCardDismissed) +} + +private actor UsageFileFailureRecorder { + private(set) var values: [SnapshotPersistenceFailure] = [] + + func record(_ failure: SnapshotPersistenceFailure) { + values.append(failure) + } +} diff --git a/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift b/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift index 2d5ab5a..85c268c 100644 --- a/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift +++ b/Tests/TokenMenuBarCoreTests/WidgetSnapshotTests.swift @@ -14,7 +14,7 @@ import TokenMenuBarTestSupport #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].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) diff --git a/Tests/TokenMenuBarUITests/AppControllerTests.swift b/Tests/TokenMenuBarUITests/AppControllerTests.swift index 5e11a3d..c836e14 100644 --- a/Tests/TokenMenuBarUITests/AppControllerTests.swift +++ b/Tests/TokenMenuBarUITests/AppControllerTests.swift @@ -593,7 +593,7 @@ private func startedController() throws -> (AppController, AppDependencies, Reco transport: NoNetworkTransport(), keychain: testKeychain, launchAtLogin: .inMemory(), workspaceOpen: { _, _, _ in openedReplacement = true }) - #expect(dependencies.registry.ids == [.claude, .codex, .copilot, .cursor, .gemini]) + #expect(dependencies.registry.ids == [.antigravity, .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) @@ -928,3 +928,10 @@ private final class ContrastFlag { #expect(await waitUntil { dependencies.notifier.delivered.contains { $0.kind == .authentication } }) #expect(!dependencies.notifier.playSound) } + +@Test @MainActor func retryOpeningCollapsesTheStatusItemOnTheTwelfthAttempt() throws { + let (controller, _, _) = try startedController() + defer { controller.stop() } + + #expect(!controller.retryOpening(remainingAttempts: 12, previousButtonFrame: nil, forcedNarrowest: false)) +} diff --git a/Tests/TokenMenuBarUITests/DisplaySettingsUITests.swift b/Tests/TokenMenuBarUITests/DisplaySettingsUITests.swift new file mode 100644 index 0000000..155c573 --- /dev/null +++ b/Tests/TokenMenuBarUITests/DisplaySettingsUITests.swift @@ -0,0 +1,132 @@ +import AppKit +import SwiftUI +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +@Test @MainActor func settingsTabBindsTheDisplayPaceAndPrivacyControls() throws { + let environment = try makeEnvironment() + let tab = SettingsTab(environment: environment, mountsIncrementally: false) + var changes = 0 + environment.actions.settingsChanged = { changes += 1 } + + tab.menuBarSetting(\.usageDisplay).wrappedValue = .remaining + tab.menuBarSetting(\.hidePersonalInformation).wrappedValue = true + tab.setting(\.paceWorkdays).wrappedValue = 5 + tab.setting(\.notifications.notifyOnPace).wrappedValue = false + tab.setThreshold(50, on: true) + + #expect(environment.settings.usageDisplay == .remaining) + #expect(environment.settings.hidePersonalInformation) + #expect(environment.settings.paceWorkdays == 5) + #expect(!environment.settings.notifications.notifyOnPace) + #expect(environment.settings.notifications.thresholds.contains(50)) + #expect(changes == 2) + let hosting = host(tab, width: 880, height: 3_000) + #expect(hosting.frame.width == 880) +} + +@Test @MainActor func settingsPreviewFollowsTheUsageDisplay() throws { + let environment = try makeEnvironment() + let tab = SettingsTab(environment: environment, mountsIncrementally: false) + let used = tab.previewModel.cells.map { StatusTemplate.plainText($0.lines) } + environment.settings.usageDisplay = .remaining + let remaining = tab.previewModel.cells.map { StatusTemplate.plainText($0.lines) } + + #expect(used.contains { $0.hasSuffix("36%") }) + #expect(remaining.contains { $0.hasSuffix("64%") }) + #expect(tab.previewModel.cells[0].tooltip.contains("64% left")) + let hosting = host( + StatusPreview(model: tab.previewModel, highlightedKey: .constant(nil), select: { _ in }), width: 600, + height: 60) + #expect(hosting.frame.height == 60) + #expect(StatusItemRenderer.accessibilityDescription(for: tab.previewModel).contains("64% left")) +} + +@Test @MainActor func formatPickerListsTheCountdownPresets() throws { + let environment = try makeEnvironment() + let hosting = host(SettingsTab(environment: environment, mountsIncrementally: false), width: 880, height: 3_000) + let controls = segmentedControls(in: hosting) + let labels = controls.map { control in (0.. [NSSegmentedControl] { + var found: [NSSegmentedControl] = [] + var pending = [root] + while let view = pending.popLast() { + if let control = view as? NSSegmentedControl { found.append(control) } + pending.append(contentsOf: view.subviews) + } + return found +} + +@Test @MainActor func windowRowsRenderTheRemainingShare() throws { + let card = UsagePresenter.card( + provider: .claude, state: ProviderState(snapshot: sampleSnapshot(.claude), availability: .current), samples: [:], + options: UsageDisplayOptions(display: .remaining), now: fixedNow) + let row = try #require(card.rows.first) + let view = WindowRowView(row: row, now: fixedNow) + + #expect(row.percentText == "64%") + #expect(view.accessibilityValue.hasPrefix("64% left")) + #expect(inkFraction(view, width: 820, height: 50) > 0) +} + +@Test @MainActor func usagePresentationRefreshesWhenTheDisplaySettingsChange() async throws { + let environment = try makeEnvironment() + environment.state.popoverVisible = true + environment.settings.lastTab = .usage + let before = try #require(environment.usagePresentation.cards.first?.rows.first) + #expect(before.percentText == "36%") + #expect(environment.usagePresentation.cards[0].chips.map(\.text).contains("user@example.com")) + + environment.settings.usageDisplay = .remaining + environment.settings.hidePersonalInformation = true + await waitUntil { environment.usagePresentation.cards.first?.rows.first?.percentText == "64%" } + + #expect(environment.usagePresentation.cards[0].rows[0].percentText == "64%") + #expect(environment.usagePresentation.cards[0].chips.map(\.text).contains("account")) + #expect(!environment.usagePresentation.cards[0].chips.map(\.text).contains("user@example.com")) +} + +@Test @MainActor func providerRowsMaskTheAccountWhenAsked() throws { + let environment = try makeEnvironment() + environment.settings.hidePersonalInformation = true + let hosting = host(SettingsTab(environment: environment, mountsIncrementally: false), width: 880, height: 3_000) + let presentation = SettingsProviderPresentation( + state: environment.state.state(for: .claude), now: fixedNow, + hidePersonalInformation: environment.settings.hidePersonalInformation) + + #expect(presentation.identity == "account · Max 20x") + #expect(hosting.frame.width == 880) +} + +@Test @MainActor func exhaustedPresetRendersTheCountdownInTheStatusItem() { + let snapshots: [ProviderID: ProviderSnapshot] = [.claude: sampleSnapshot(.claude, percent: 100)] + let model = StatusItemBuilder.build( + StatusItemInput( + snapshots: snapshots, availability: [.claude: .current], + selectedKeys: [WindowKey(provider: .claude, windowID: "session")], format: .countdownWhenExhausted, + customTemplate: "", decimals: 0, hideZeroCells: true, order: .provider, labels: [:], now: fixedNow)) + + #expect(model.countdownActive) + #expect(model.cells[0].lines.map { StatusTemplate.plainText([$0]) } == ["CC 5h", "4h00m"]) + let title = StatusItemRenderer.attributedTitle(for: model, height: 18, dark: false) + #expect(title.length == 1) +} + +@Test @MainActor func settingsTabBindsTheUsageFileToggle() throws { + let environment = try makeEnvironment() + let tab = SettingsTab(environment: environment, mountsIncrementally: false) + + tab.setting(\.writeUsageFile).wrappedValue = true + + #expect(environment.settings.writeUsageFile) + #expect(host(tab).fittingSize.height > 0) +} diff --git a/Tests/TokenMenuBarUITests/ProviderMarkTests.swift b/Tests/TokenMenuBarUITests/ProviderMarkTests.swift index 3451583..763a2a8 100644 --- a/Tests/TokenMenuBarUITests/ProviderMarkTests.swift +++ b/Tests/TokenMenuBarUITests/ProviderMarkTests.swift @@ -8,7 +8,11 @@ import TokenMenuBarTestSupport @testable import TokenMenuBarUI -@Test(arguments: ProviderID.allCases, ProviderMarkAppearance.allCases) +private let distributedMarkProviders = ProviderID.allCases.filter { + ProviderMarkCatalog.descriptor(for: $0, appearance: .light).resourceName != nil +} + +@Test(arguments: distributedMarkProviders, ProviderMarkAppearance.allCases) @MainActor func providerMarkLoaderLoadsDistributedAssets( provider: ProviderID, appearance: ProviderMarkAppearance ) throws { @@ -18,7 +22,7 @@ import TokenMenuBarTestSupport #expect(image.size.height > 0) } -@Test(arguments: ProviderID.allCases, ProviderMarkAppearance.allCases) +@Test(arguments: distributedMarkProviders, ProviderMarkAppearance.allCases) @MainActor func providerMarkLoaderPreservesOriginalRendering( provider: ProviderID, appearance: ProviderMarkAppearance ) throws { @@ -27,7 +31,7 @@ import TokenMenuBarTestSupport #expect(!image.isTemplate) } -@Test(arguments: ProviderID.allCases, ProviderMarkAppearance.allCases) +@Test(arguments: distributedMarkProviders, ProviderMarkAppearance.allCases) @MainActor func providerMarkLoaderCachesByProviderAndAppearance( provider: ProviderID, appearance: ProviderMarkAppearance ) throws { @@ -37,6 +41,15 @@ import TokenMenuBarTestSupport #expect(first === second) } +@Test(arguments: ProviderMarkAppearance.allCases) +@MainActor func providerMarkCatalogFallsBackToTextForAntigravity(appearance: ProviderMarkAppearance) { + let descriptor = ProviderMarkCatalog.descriptor(for: .antigravity, appearance: appearance) + #expect(descriptor.resourceName == nil) + #expect(descriptor.fallbackText == "AG") + #expect(ProviderMarkImageLoader().image(for: .antigravity, appearance: appearance) == nil) + #expect(distributedMarkProviders == ProviderID.allCases.filter { $0 != .antigravity }) +} + @Test(arguments: ProviderID.allCases) func providerMarkBadgesAdaptTheirPaletteToAppearance(provider: ProviderID) { let light = ProviderMarkCatalog.descriptor(for: provider, appearance: .light) diff --git a/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift b/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift index 2a5f3aa..e854061 100644 --- a/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift +++ b/Tests/TokenMenuBarUITests/ResponsiveLayoutTests.swift @@ -20,7 +20,7 @@ import TokenMenuBarTestSupport @Test @MainActor func modelRowsReflowAt548PointsAndAccessibilityText() throws { let environment = try makeEnvironment() let list = WindowSelectionList(environment: environment) - let row = try #require(list.groups.first?.rows.first) + let row = try #require(list.groups.first { $0.provider == .claude }?.rows.first) let wide = fittingSize(list.modelRow(row), width: 852) let narrow = fittingSize(list.modelRow(row), width: 548) let accessible = fittingSize( diff --git a/Tests/TokenMenuBarUITests/SpendAndFirstRunTests.swift b/Tests/TokenMenuBarUITests/SpendAndFirstRunTests.swift new file mode 100644 index 0000000..d6be557 --- /dev/null +++ b/Tests/TokenMenuBarUITests/SpendAndFirstRunTests.swift @@ -0,0 +1,200 @@ +import AppKit +import Foundation +import SwiftUI +import Testing +import TokenMenuBarTestSupport + +@testable import TokenMenuBarCore +@testable import TokenMenuBarUI + +private let costAnalytics = ProviderAnalytics( + provider: .claude, + points: [AnalyticsPoint(day: DayStamp.string(fixedNow), metric: .costUSD, series: "claude-opus-5", value: 3)], + fetchedAt: fixedNow) + +@Test @MainActor func spendTilesRenderTheThreeTotals() { + let summary = SpendSummary( + today: 4.5, yesterday: 2, lastWindow: 7, + topModels: [SpendModelShare(provider: .claude, model: "claude-opus-5", cost: 7)], providers: [.claude]) + #expect(inkFraction(SpendSummaryTiles(summary: summary), width: 520, height: 80) > 0) +} + +@Test @MainActor func usageTabLoadsSpendWhileVisibleAndFollowsHistoryChanges() async throws { + let environment = try makeEnvironment() + environment.settings.lastTab = .usage + environment.state.popoverVisible = true + let model = SpendSummaryModel() + let hosting = host(UsageTab(environment: environment, spend: model)) + await waitUntil { model.summary != nil } + #expect(model.summary == .empty) + + try await environment.history.record(costAnalytics) + environment.state.markHistoryChanged() + await waitUntil { model.summary?.hasData == true } + #expect(model.summary?.today == 3) + #expect(model.summary?.providers == [.claude]) + hosting.layoutSubtreeIfNeeded() +} + +@Test @MainActor func usageTabLeavesSpendAloneWhileHidden() async throws { + let environment = try makeEnvironment() + environment.settings.lastTab = .history + let model = SpendSummaryModel() + let hosting = host(UsageTab(environment: environment, spend: model)) + for _ in 0..<5 { await mainActorTurn() } + #expect(model.summary == nil) + hosting.layoutSubtreeIfNeeded() +} + +@Test @MainActor func spendModelFallsBackToAnEmptySummaryWhenTheStoreFails() async throws { + let history = try UsageHistoryStore(url: nil) + try await history.breakDatabase() + let model = SpendSummaryModel() + await model.load(history: history, providers: [.claude], now: fixedNow, timeZone: .current) + #expect(model.summary == .empty) +} + +@Test func firstRunCardDescribesTheDetectedProviders() { + #expect(FirstRunCard(detected: [.claude, .codex]).title == "Detected: Claude, Codex") + #expect(FirstRunCard(detected: [.claude]).description.hasPrefix("Their quotas appear below")) + #expect(FirstRunCard(detected: []).title == "No providers detected yet") + #expect(FirstRunCard(detected: []).description.hasPrefix("Sign in to a supported CLI")) +} + +@Test @MainActor func firstRunCardButtonsNavigateAndDismissForGood() throws { + let defaults = testDefaults("first-run-card") + let settings = TokenMenuBarCore.Settings(defaults: defaults) + let environment = try makeEnvironment(settings: settings) + let shown = ShownProviders() + environment.actions = UIActions(showProviders: { shown.requests.append($0) }) + environment.firstRunCard = FirstRunCard(detected: [.claude]) + #expect(inkFraction(UsageTab(environment: environment)) > 0) + let view = FirstRunCardView(environment: environment, card: FirstRunCard(detected: [.claude])) + #expect(inkFraction(view, width: 520, height: 140) > 0) + + view.review() + #expect(shown.requests == [nil]) + #expect(environment.firstRunCard != nil) + + view.dismiss() + #expect(environment.firstRunCard == nil) + #expect(settings.firstRunCardDismissed) + #expect(TokenMenuBarCore.Settings(defaults: defaults).firstRunCardDismissed) +} + +@MainActor +private final class ShownProviders { + var requests: [ProviderID?] = [] +} + +private struct DetectedProvider: UsageProvider { + let id: ProviderID + let pollingPolicy = PollingPolicy(minimumInterval: 0, activeInterval: 0, defaultInterval: 0) + + var credentialDescription: String { "detected \(id.rawValue)" } + + func credentialState(now: Date) -> CredentialState { .valid(expiresAt: nil) } + + func credentialHealth(now: Date) async -> ProviderCredentialHealth { + .valid( + source: CredentialSource(id: "test-\(id.rawValue)", provider: id, title: "Test", detail: "test"), expiresAt: nil) + } + + func fetch(now: Date, options: FetchOptions) async -> ProviderFetchResult { + ProviderFetchResult(outcome: .success(sampleSnapshot(id))) + } +} + +@Test @MainActor func firstLaunchOpensTheUsageTabWithTheDetectedProviders() async throws { + let clock = ManualClock() + var (dependencies, _) = try makeDependencies( + providers: [ + DetectedProvider(id: .claude), + ScriptedProvider(id: .codex, result: ProviderFetchResult(outcome: .notAuthenticated("no token"))), + ], clock: clock.clock) + dependencies.openPopoverOnLaunch = false + dependencies.settings.lastTab = .history + 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.environment.firstRunCard != nil } + #expect(controller.environment.firstRunCard == FirstRunCard(detected: [.claude])) + #expect(dependencies.settings.lastTab == .usage) + #expect(dependencies.settings.lastLaunchedVersion == testAppInfo.version) + #expect(dependencies.log.text.contains("first run: opening the usage tab")) + await clock.advance(by: AppController.attachmentPollInterval) { controller.popover?.isShown == true } + #expect(controller.popover?.isShown == true) + #expect(clock.sleeps.contains(AppController.attachmentPollInterval)) +} + +enum FirstRunSkip: CaseIterable { + case launchedBefore + case providerConfigured + case dismissed + + @MainActor func apply(to settings: TokenMenuBarCore.Settings) { + switch self { + case .launchedBefore: settings.lastLaunchedVersion = "1.0.0" + case .providerConfigured: settings.setProvider(.claude, enabled: true) + case .dismissed: settings.firstRunCardDismissed = true + } + } +} + +@Test(arguments: FirstRunSkip.allCases) +@MainActor func laterLaunchesKeepTheFirstRunCardHidden(skip: FirstRunSkip) async throws { + let clock = ManualClock() + let (dependencies, _) = try makeDependencies(providers: [DetectedProvider(id: .claude)], clock: clock.clock) + skip.apply(to: dependencies.settings) + let controller = AppController(dependencies: dependencies) + controller.start() + controller.statusItem?.visibleItemFrame = { _ in CGRect(x: 10, y: 10, width: 36, height: 24) } + defer { controller.stop() } + + await waitUntil { dependencies.state.state(for: .claude).credentialHealth.isUsable } + for _ in 0..<5 { + clock.advance(by: AppController.attachmentPollInterval) + await mainActorTurn() + } + #expect(controller.environment.firstRunCard == nil) + #expect(controller.popover?.isShown == false) + #expect(!dependencies.log.text.contains("first run")) +} + +@Test @MainActor func appControllerWritesUsageJSONNextToTheSnapshotCache() async throws { + let support = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-usage-\(UUID().uuidString)") + let provider = ScriptedProvider(id: .claude, result: ProviderFetchResult(outcome: .success(sampleSnapshot(.claude)))) + let (dependencies, _) = try makeDependencies( + providers: [provider], snapshotCache: SnapshotCache(url: support.appendingPathComponent("snapshots.json"))) + dependencies.settings.setProvider(.claude, enabled: true) + dependencies.settings.writeUsageFile = true + let controller = AppController(dependencies: dependencies) + defer { controller.stop() } + + await controller.coordinator.refresh(RefreshRequest(reason: .userInitiated, usage: .force)) + await controller.flushPersistence() + + let output = try await ExportRunner.run(.usageJSON, supportDirectory: support) + #expect(output == String(decoding: try Data(contentsOf: support.appendingPathComponent("usage.json")), as: UTF8.self)) + try FileManager.default.removeItem(at: support) +} + +@Test @MainActor func exportRunnerPrintsUsageJSONFromTheSupportDirectory() async throws { + let support = FileManager.default.temporaryDirectory.appendingPathComponent("tmb-usage-\(UUID().uuidString)") + await #expect(throws: UsageExportCommand.NoCachedUsage.self) { + try await ExportRunner.run(.usageJSON, supportDirectory: support) + } + try SnapshotCache(url: support.appendingPathComponent("snapshots.json")).store([.claude: sampleSnapshot(.claude)]) + + let output = try await ExportRunner.run(.usageJSON, supportDirectory: support) + #expect(output.contains(#""id" : "claude""#)) + #expect(output.contains(#""plan" : "Max 20x""#)) + #expect(output.contains(#""id" : "weekly:fable""#)) + + let files = try await ExportRunner.run(.files(.menuBar, directory: support)) + #expect(files == "wrote 2 files to \(support.path)") + try FileManager.default.removeItem(at: support) +} diff --git a/Tests/TokenMenuBarUITests/StartupCoverageTests.swift b/Tests/TokenMenuBarUITests/StartupCoverageTests.swift index 80dab13..bdffac4 100644 --- a/Tests/TokenMenuBarUITests/StartupCoverageTests.swift +++ b/Tests/TokenMenuBarUITests/StartupCoverageTests.swift @@ -54,8 +54,8 @@ struct StartupCoverageTests { await clock.advance(by: AppController.attachmentPollInterval) { popover.isShown } #expect(popover.isShown) - // Two polls: the first records the button frame and the second confirms it stayed put. - #expect(clock.sleeps.count(where: { $0 == AppController.attachmentPollInterval }) == 2) + // The frame has to repeat before the popover opens, so a single poll can never be enough. + #expect(clock.sleeps.count(where: { $0 == AppController.attachmentPollInterval }) >= 2) popover.close() #expect(!popover.isShown) } diff --git a/Tests/TokenMenuBarUITests/StatusItemTests.swift b/Tests/TokenMenuBarUITests/StatusItemTests.swift index 87c3782..5e3959d 100644 --- a/Tests/TokenMenuBarUITests/StatusItemTests.swift +++ b/Tests/TokenMenuBarUITests/StatusItemTests.swift @@ -51,10 +51,11 @@ func appIconInkFollowsTone(tone: StatusIconTone, dark: Bool, ink: NSColor) { } @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)) + for provider in ProviderID.allCases { + #expect(ProviderGlyph.image(provider, pointSize: 12).size.width > 0) + } + #expect(Set(ProviderID.allCases.map(ProviderGlyph.symbolName)).count == ProviderID.allCases.count) + #expect(Set(ProviderID.allCases.map(ProviderGlyph.color)).count == ProviderID.allCases.count) } @Test @MainActor func rendererFontSizeAndColors() { diff --git a/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift b/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift index c6884e8..12473f0 100644 --- a/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift +++ b/Tests/TokenMenuBarWidgetsTests/WidgetTests.swift @@ -127,3 +127,19 @@ private func host(_ view: Content, width: CGFloat, height: CGFloa hostingWindows.append(window) return hosting } + +@Test @MainActor func widgetRowsPrintTheShareTheSnapshotAsksFor() { + let row = WidgetSnapshot.placeholder.rows[0] + let remaining = WidgetRowView(row: row, now: fixedNow, compact: false, display: .remaining) + + #expect(row.percentText(display: .remaining) == "64%") + #expect(row.percentText(display: .used) == "36%") + #expect(remaining.display == .remaining) + #expect(WidgetRowView(row: row, now: fixedNow, compact: false).display == .used) + #expect(inkFraction(remaining, width: 300, height: 60) > 0) + let entry = UsageEntry( + date: fixedNow, + snapshot: WidgetSnapshot( + rows: WidgetSnapshot.placeholder.rows, attention: false, updatedAt: fixedNow, display: .remaining)) + #expect(inkFraction(UsageWidgetView(entry: entry, family: .systemMedium), width: 300, height: 300) > 0) +} diff --git a/website/content/explanation/_index.md b/website/content/explanation/_index.md index c2644d2..efb7d3d 100644 --- a/website/content/explanation/_index.md +++ b/website/content/explanation/_index.md @@ -30,16 +30,19 @@ The App Store widget uses the `group.dev.tox.token-menu-bar` app group. Direct a ## Scope -The app tracks these five providers on purpose: they are the ones it can read locally. A provider is added only when a +The app tracks these six providers on purpose: they are the ones it can read locally. A provider is added only when a local credential and an official usage endpoint exist for it, so it never asks for a browser session or scrapes a -dashboard; Antigravity is the next candidate. +dashboard. Antigravity joined once its language server and the `agy` CLI exposed a quota summary; Gemini CLI stays for +Workspace and Code Assist accounts. ## Token refresh Claude, Codex, and Gemini rotate their [OAuth refresh token](https://datatracker.ietf.org/doc/html/rfc6749#section-6) when they refresh. The app leaves expired tokens alone and shows a sign-in hint unless Settings > Providers enables -refresh. If the app and a running CLI refresh the same credential at once, the CLI can retain the old rotated token; its -next refresh can fail and require another sign-in. +refresh. Antigravity uses a Google refresh token that does not rotate; the same setting gates its refresh, and the +renewed access token stays in memory because Antigravity owns the Keychain item. If the app and a running CLI refresh +the same credential at once, the CLI can retain the old rotated token; its next refresh can fail and require another +sign-in. ## Rate limits diff --git a/website/content/reference/interface.md b/website/content/reference/interface.md index 680e828..162f7d0 100644 --- a/website/content/reference/interface.md +++ b/website/content/reference/interface.md @@ -11,17 +11,19 @@ weight: 2 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: +green to red, keyed on the used share even when **Show usage as** prints what is left. Six 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 | +| Countdown | `CC 36% · 4h24m`, the percent followed by the time to reset | +| Countdown at 100% | Stacked, until a window is exhausted; then the countdown to its reset takes the percent's place | | 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}`. +references `{reset}`, or `{pctOrReset}` with a window at 100%. 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 @@ -45,6 +47,14 @@ behind the menu bar. The signed builds carry the widgets; an ad-hoc development {{< 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." >}} +On the first launch, before any provider has been switched on or off, a card under the header lists what the app found +("Detected: Claude, Codex"). **Review providers** jumps to the Providers section of Settings; **Got it** hides the card +for good. + +Above the cards, once any cost data exists, three tiles total the API-equivalent spend for today, yesterday and the last +30 days across Claude and Codex, from the same daily analytics History draws. Hover the row for the five costliest +models of the period. The day boundary follows the UTC toggle on the History tab. + One card per provider: | Section | What it shows | @@ -56,6 +66,7 @@ One card per provider: | 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 | +| Antigravity | The shared Gemini pool and the Claude and GPT pool, each with a 5-hour and a weekly window, plus the account e-mail and tier the local language server reports | | 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 | @@ -70,7 +81,8 @@ click hides a row, a double-click isolates it, and a hover highlights it while t 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). +sessions and tool calls per day). The **Projects** group splits Claude's cost and message counts by the working +directory each transcript was recorded in, named after the last path component. ## Settings tab @@ -80,17 +92,21 @@ sessions and tool calls per day). ## 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` (`CLAUDE_CONFIG_DIR` honoured) | `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`. +| Provider | Token | Endpoints | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Claude](https://docs.claude.com/en/docs/claude-code/overview) | Keychain item `Claude Code-credentials`, or `~/.claude/.credentials.json` (`CLAUDE_CONFIG_DIR` honoured) | `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` | +| [Antigravity](https://antigravity.google) | Keychain item `gemini`, account `antigravity`, written by the IDE and `agy` | `127.0.0.1:/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary` and `GetUserStatus` from the running IDE or `agy`, else `daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary` and `:loadCodeAssist` | +| [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 on 2026-06-18, so Gemini CLI quota reaches Workspace and Code +Assist Standard or Enterprise accounts alone; the app says so rather than looping on a sign-in prompt. Antigravity is +where personal accounts report now: the app asks the language server the IDE or `agy` starts on the loopback interface +(the direct and Homebrew builds find it through libproc, never `ps` or `lsof`) and otherwise calls Cloud Code with the +token Antigravity keeps in the Keychain. Antigravity owns that item, so a refreshed access token stays in memory. When +Codex is offline or signed out, the app falls back to the last `rate_limits` event in `~/.codex/sessions`. The path variables in the table apply only when they exist in the app process. Finder and login-item launches do not load shell startup exports; Settings > Providers shows the credential source and resolved path. diff --git a/website/content/reference/settings.md b/website/content/reference/settings.md index a6b97dd..063d244 100644 --- a/website/content/reference/settings.md +++ b/website/content/reference/settings.md @@ -17,15 +17,17 @@ 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 | +| Option | What it does | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Order | Keeps provider order, or sorts cells by percent used | +| Format | Stacked, Inline, Mini bars, Countdown (percent then the time to reset), Countdown at 100% (percent until a window is exhausted, then the time to its reset), 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 | +| Hide account details | Replaces e-mail addresses with `account` and workspace names with `workspace` in the popover, the provider list and the diagnostics report | +| Show usage as | Used shows the share of each window spent; Left shows what remains. Applies to the menu bar, the Usage tab, the widgets and the `{pct}` tokens; colours still follow the used share | +| 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. @@ -36,8 +38,10 @@ The same code that draws the menu bar renders the preview under the controls. - `{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 +- `{pct}`, `{pct0}`, `{pct1}`, `{pct2}`: percent at the configured / 0 / 1 / 2 decimals, used or left per **Show usage + as** +- `{remaining}`: percent left, whatever **Show usage as** says +- `{pctOrReset}`: the percent, or the reset countdown once the window is at 100% - `{reset}`: live countdown to the reset - `{resetClock}`: reset time - `{plan}`: plan name @@ -47,10 +51,10 @@ The same code that draws the menu bar renders the preview under the controls. ## 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 +Enable or disable each provider (Claude, Codex, Gemini, Antigravity, 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 credential file. If the app and a running CLI refresh the same credential at once, the CLI can retain the old rotated token; its next refresh can fail and require another sign-in. @@ -63,13 +67,17 @@ files such as `.zshrc`. ## 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. +([CSV](https://datatracker.ietf.org/doc/html/rfc4180)) or clear it. **Expect usage on N days a week** (4 to 7) spreads +the expected pace of windows longer than a day over Monday to the chosen weekday, so a weekly window is not ahead of +pace on Monday morning; 7 keeps an even pace. ## Notifications Threshold notifications at 50/75/90/100%, window-reset notifications, sign-in alerts, a warning when a Codex reset -credit expires within 24 hours, and a sound switch. Several windows crossing a threshold in one refresh arrive as one -grouped notice. +credit expires within 24 hours, pace notices, and a sound switch. Several windows crossing a threshold in one refresh +arrive as one grouped notice. The pace notices say when a window will run out before it resets and when it pulls ahead +of pace; each fires once per window and reset period, arms again after a refresh on which the pace had recovered, and +stays quiet during the first 5% of a window. ## Log diff --git a/website/content/start/_index.md b/website/content/start/_index.md index dc15530..01f14d1 100644 --- a/website/content/start/_index.md +++ b/website/content/start/_index.md @@ -11,13 +11,14 @@ Token Menu Bar needs macOS 14 or later on an Apple silicon or Intel Mac, and one that client already stored, so no password reaches this app. 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` | +| 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 (personal accounts stopped reporting on 2026-06-18) | `~/.gemini/oauth_creds.json` | +| [Antigravity](https://antigravity.google) | the Antigravity IDE, or `agy` | Keychain item `gemini`, account `antigravity` | +| [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` | Credential path variables such as `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `GEMINI_CLI_HOME`, and `XDG_CONFIG_HOME` apply only when the app process receives them. An export in `.zshrc` does not reach a Finder or login-item launch. Settings > diff --git a/website/content/troubleshooting/_index.md b/website/content/troubleshooting/_index.md index 4c9d108..4b85a68 100644 --- a/website/content/troubleshooting/_index.md +++ b/website/content/troubleshooting/_index.md @@ -55,16 +55,16 @@ Settings > Providers holds a switch and a credential line for each one: `No cred 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 | +| 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, Gemini and Antigravity 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 @@ -142,6 +142,29 @@ Opening the popover and pressing **Refresh** fetches every enabled provider at o 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. +## For scripts + +A statusline or a shell prompt wants the numbers without the popover. Two routes, both fed by the snapshot cache the app +writes after every refresh cycle, so neither starts a fetch of its own: + +```sh +/Applications/Token\ Menu\ Bar.app/Contents/MacOS/Token\ Menu\ Bar --usage-json +``` + +prints the last cached snapshot set and exits 0. Each provider carries its `id`, `plan`, `fetchedAt`, and a `windows` +array of `id`, `label`, `usedPercent` and `resetsAt` (ISO 8601, omitted when the window has no reset). With no cache +yet, it prints `usage export failed: no cached usage yet` on stderr and exits 1; launch the app and let one refresh +finish. The command runs while the app is open and never touches the UI. + +**Write usage.json** under Settings > Data keeps the same document at +`~/Library/Application Support/Token Menu Bar/usage.json`, rewritten at the end of every cycle whose values changed. It +is off by default; a script that polls the file pays nothing while the box is unticked. + +```sh +jq -r '.providers[] | "\(.id) \(.windows[0].usedPercent)%"' \ + ~/Library/Application\ Support/Token\ Menu\ Bar/usage.json +``` + ## What VoiceOver reads The menu bar item draws its numbers as an image, which VoiceOver would otherwise read as nothing, so the button carries diff --git a/website/static/brand/glyph/antigravity.svg b/website/static/brand/glyph/antigravity.svg new file mode 100644 index 0000000..ecd0a3c --- /dev/null +++ b/website/static/brand/glyph/antigravity.svg @@ -0,0 +1,4 @@ + + + +

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�uJ>&bZeyVlki562kW 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 z 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%0DAyURxSH7eX2pveu3QV5x}?>zT1RM)5Qr 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 R0000000000000000074D$~XW3 literal 0 HcmV?d00001 diff --git a/website/assets/images/popover-settings-dark.webp b/website/assets/images/popover-settings-dark.webp new file mode 100644 index 0000000000000000000000000000000000000000..21b5889d6164c9c52e8a76ea9ac589ffc84be41e GIT binary patch literal 148296 zcmce;byQthwmpoyI|L1G!QI_8xH|-Q3GQyegS)$XaCZnU!QI{NkE-r^uez(!UG>H{ zKK_C`4(IN(_ma8hn(HV^h>G$HfdHwB2+FI-vlBAE{T?$7l@3hR1d#^CONkaLM3ha0 zG&mcA1rku#=tZrAk(KZ&ABCrfQ(Tw1383)ye(Bg+H+gw_fjlm}lY7zKef8uZsGx&& zWqRT}C0L<52dsJtd+Xh~RPr6ZG`bJEneG5qy&JrP0lqJH0Kh9>Q|A%i$g40w834Hb z_!#}DdxO8hm+!smUFq!(NP2mCyQZ=v`kHUua}i(-aC+T)T7R1G;T!BM^DcS4c&dK8 zVC$mi`R<|en4pc1!MoTy06_lo^mO(N$U@xeoB+(c+P(q+fafwm=&|W5;*04zU=s7P z^O)e!o4Z5PdloPaKz=O*tUue-$}LPhu5WwKc%K3cZ?T_SpCF$r7Xh#9C!N3C00>YC z04#vMW&!|?XB!0P0Kil2P27{s1Km8}xD&5~?@8<$aNfD&-2~7A%)g2)b@qBmJe=Lh zU1rYnRe6sCST3TU#qMmD2qZhQpV#l@E_IK*C%nS|SC?!T-sS5`uT!t8&)&CkOVQEZ zW8UciiC2dwwo|@(@2{_&ukO#8$H8}qOVL%j{d|$$%C8fzT+h0<6R+zBJR9B{0KV74 zSFuphlc((0$_31g^?tw{K>6*n#s|I?zRc)6K>6#yo$eiAm#-%C-h1}-#uM0E761+q zyE1%U2LO@*b$}kgyVpj**~6JuWgfr;;Pi6!LU%&2z;_8a=-lUP27~}EUd7&aBD(Kv zi*M6A93Ta-dG>xgAYJ;72!O&X!Slq+#CazG@bVhzt^f8kN6&JPau42{d;q}l(>-AB z^y6(MK(5*5@C-mOaRC%cnRiAs1LlVEQDRM~0P2#YAg(P~8hKknhTIJynRxKaSJ_KE zda+ni=Jah&z3BInpST7cyYPkMr(I$W{J4Mlcf8zMSfB3_mZDwjJOI-J16D}AcO(Ww z<0J2jl}ePU;?_3@tz7$fH8=nJy*XdzVOIJ!rc+mCtog`cbQI+9Q~8cf_wWt(X9)cU zTx!Yz<@qrDYM?*{s`-nP_s3TLXfMgi`@71c-UT$DNd{R; z_PUeTfH*gk=E^OXL}WB$5J?4W}d zp-)iQu>~7UNG{fygTQn*092~!dsL7yhkZ;8S1D@&q)v%M%O(mZ`V0rN_HkMn$U)um4J{ciFeTniW0WYl*Mc`c#Mfs21rzg6Zw-$0vncVej?5BQEfPJe(;e>rZcYE$7{SS%oS{q) zU)Sv+jt)DaYIqfyB5KC)DM=0gy7hiLudjwS_m^z1eDPa^oeFS=hHP>{!$TvoY~%aR z<&Gg`bDxXoi$nPj>;BK{`OP`%!$SVbS^dt*BG<@i!?k=f0ZWi_tuaInm<;!iPS0Vd z3!OG(q=jSW0E?-oJ}*kLG#XzN{6<4c{iCjW*xM$SV6xT|dSI8#^il92cC|bMzpN10 zfp-Y;(cQOV6sI@I6pyvP^7IXGYS6o(I(Crn^hDgBSjq41!d!-LI&1KeB< zwSpoB_B)k$ATn2*c)PrIz!QfQ3qjq-{-OG8?G@-NQtfcr_5?GeeJIW+8f*A2;cxZJ zSJ$CC-^i0mK+w8Rum$d1X1{B9s2L@R<)bvpwH0uTYa`z2d6s$9aiT?@H6JI0PT`Pu8Py+|3i5Cdf>OSd}Cqbti`VipE2?LT7 ze25D<#uj!#$6&)e_%2j!$%_>})WM}j=A>k;Sb51mXIPr9{2GR8SDa-wb$0*#7NM~B z^)>PO=Vtdk^NY0ZjuEwm;OR)2#HCPu^jz1m_uAblwP2%Drj$YIxYuGyiyKu%+f zMrrSvRj{kAV|@*4#H2M<&FKr<_I;ddKN4N+V#TD0UNL5nUMyAAG=EpvS>mz1uWL1~ z+_eQWYPKmSn?^%G8B}$e^d*D(1YK}XiVhQXqyq^$!qOn$&!s5O&CDgt%KQpFig+~k zvg32Mef82yjq3AyL}da*wbs%8fyI3!9_oYdyDCIZniMRh%#Vh-M2GOSFQxm*25aol zboQTJ_P-r%3w_y9g}WK!cs}g{^J~p<86x0h$i9@+14Uh%EFwo~F-YQ$_69Ip5 zlMmc@ya5AF7GmWyib|-69^Xs)bgoMpgN7K4xk$S?v#W7Z_sGl~lE_?0IJ+t?qNu@E=p;p9K+f4oLEcD`#RHTCoi#{~0;~ry+7jKUvZ+%(oS=rAgRD{4yn z>G}L=O8;fOUas#>!b0dTe3}wcqXQ3JX2kMp!i~n)uJXCFBzhQ-gz>+}_=`}e=cYUt zY+^m*3_|1)T|;#2UU2MQxajZ7-WR09Xm|@pVX<_GpNosx4xAqtK2ikZ4v932su8MV zNG}YCO&~s;&5%O5j$=b!!{1o-4_PjsZSL}e`wps|KYJuC3cb!Ep17M^EfCvMxO`5e{u-!lBqQ~S)g4vmjgVKCuzwNLSXT>if3?0(HmfvB z3}cLtCk)?I*kCCmX6XLYtwoQ<_eKkcaqF-%zH?I{H8Bo<-(=)9lY>AqXrD=+mitM4 zT_7qZ2EIUQU^c$;z=_X3EbAKKwPdlZq)+szdWgNUSyvraMRP_*BWkcx2Ovc#I&vJ1 zAE#h)5io3NzB1z5coGRZU?N_?Y4{!DFLjH{T2ab)KmDFvab=eH#@&!zgC<=+*gBM}0>UN#={~)wk0b1pT|p$eO!D^wab! zd6-a?1Eqw;en_F6&}aKsu zv;f;^Zh;UN^n0&+t}-#F%6lw*OySJ<;X-8$Gez2`CsziVB#9H3Fmb*Aba@A4b8M7D zVz9r^&U1aPLD5xzqQmdY|Dw}xyIWmcKbD5kp6(oUvJZuM8K%!QC@=LCIMlas7tNMz z$a(>+)*}8eWMs1)DC}d*XtLyuWAPy$6)wUPLRsIXX6fQv4NcEW6u@v=N{F*I-S+r@ zbF3B@IZ`0CVm5VZ1x4+ce~;91VCYP6Pd};A4xrw*$G4-IAcfWUtK4ZuK45e6@9$Ai zoMroT3hwZ6vf-ex5va?0!JWTir!mrcwYAvb?Byrj7T~GI6Al9 z>{82q?W9>QF!hKvTx`B(;*#N{A5LZkd`e#zoV>WAy927FGSiivQHAT!E{;KMvv7Ig zOXaVt2S44ZKaU}{^XO(rU{SFoQSqT$gKnXC$?z1GzXQhGxcU&&KEelNK3w84aDJB2 z;1&XMQAc>dEyqM1011yRBhT4AyV{{f8$(j^A&yG_4)syz#HhNe_mgP84*3PT*l?=21cs$XGl5-b$GaNX zm`yI2N>DUsPn_xec)BnuPH_vYwv4<#l$V(oEOcf8q1rFVET^BWW5rN5jPD#mDNl5Q5OgkGSwTLs+!*Ee{tpwN zS5_jbsq~btdLnnsAr%I1PF`)3M5`7ifUA&&hM z%Cft$CfP-f_AM!+3Q@&(8}AQVPmf;Gbb6a&GZ2U!|(EJi}>||8~W%+p|<%Nm&Q6 zPSN4%Zvyo0a|_#zLaKMP%rp>^`$k4ni0#go@sgu8sxok_H5(r>vd}z%osjFWqi*w& znC1`y+K6W6qhQv3S8|VkQY3s5d|FHTWuL6*a7akvVFFFkGOaJBN@=`EHm);H&Tyn< zk|L{>@&!5Y3Lin(`Gk~|JqLT*Ge{MQPuG*|98r0yNB0b6;}X%DYZU`g&m(w}c|R9M zSyaU!!G}hBUTB1Nbv|usDpqn=WjZjpYj>ki)g( z-Fu4_Dv*`mov00aN_@AFGRiy0QRxc3nUxaUKF*w&VF~UveT_T4Bkmb0*uY17klP(8 zJuupLXLCp|bVD3~6?|F748c|qF?vm!EkxYQ_@ia_W4G}bMuEycwS5E&_Y9#o?Ma04 zyA#7v7K$!X)w_J!aAWCKT-qqm7;<4;rxsSUIlthmvrr^oRA(^59(z!T8aB_q!Nyo;7OF-_#FHDiubk2fo3+k)^`fpF zTq?`&GA8lR{gcZ2Go1c!xw&4Oc568-aa`+EHQobP{wu>**nN`{!I}fj$Ix)MAsLNG z68i3=jIS`J&oNit>U4*#V1JQatGpY4nSW86CM5LAa+JTEcKCKN2{4pkqgK2rJ4G#= z#!Z-z5lucg&IV`##_Oz&<|D~P@LwsQ_U2YN_NV9$z%av!rIo_=d%i%z-HT?a2!c-r zFJsP+0EIxzUHw9jRMYLpW<2}MhhxIyz7DSmx(Q!V0g|I(ytV3#|j)ZaN*)fYhc z3i2wgbZaCe1=^YpLv%aeteo$zR_>n0!k1pA;(I**KprP~9G4&rENvubW1cUQu9~M> zYvKLnT-jk;;w&N1c8{I@n5_B3EF7;`Bp>&;c>Xzd)_Q9`WKGOS@@0exl9D<&A3bL> zcBS{x`0@uXLf(~B^7@Cbo~xM}wAD(F6oYc+Cysj9bLqPFHrJCyW}6QVO%?jjtk=|F zZmQ4^ZY-<JVtKcn6NWrb}DUY zEDA(V*~_t+q#uYig=_IyLnis6=qop`FGOKQrQv421F!LMu=nd2^z%|h1U#Cv*1ncI zQH4-f=3(%RO8lQ)Pxdd^t4i9fCs)<(Vh#c@DrSA3#i=U{G;WZ{*rEN!a^p({en6Qf zH+KIZ%B4Ye2(XRPDPeGYe;62I$%eWxVvSKTWJLPTR&gPc&eUIzpdnj}6(nCg4vY@b@6VZr2oLmVAT{pe8iL=&f12^;#{ zLCgqG3P}&GL$aYrhTu$MeYyJPo+2wk{#{ZE1G=G$nj$fhIT;{E2oVViv7Dr?*!Iyf z=Y?^~(D_DjjcM^VX#$@RbaN+xO6uMO_ocJ25cRy z)@hP8DZ`H5eSf4bW4clzZ`q{aDLhT@li`~bfkf%pQEazq1|POLkiPIA@8Ilw(!6oh zz%G^Zj2Evy0HM&g&n@>UMFKYre*cF2hVuujW`G)L_2fXYzrer|TN*$j!heI0s?TK*$bZ0- zoRlvM*PRj;`p`jjz7|)y;yjHe@wvl&G@9nPhyAujOOHuZdeL&B*=R=vAfG{S%78^! zZ;o7{4*bTrPbCH0BgJvc*woKc116IU@cm-)*}GAuA5A*@v*oDBb{}5f)kmy^QiM@+ z8rDv-#!q3tYHx-bkjsF*#9$X{5wzKJeHcwHC&V0&3#8Y)x!{ZgB1q~57x!#k^;*@C z`Yu&dz*E~9$<8Dx9IOYYW^&XF;Jp(&&=Az==Y?d$BWz=JB?LMq~z=qWu{fE&QwXGC}*OOOx7*nBwLFY)G;?#C~i`J3VLdfbJQ~ zT6788>(piQ(4FkadRl><$M>9>ekj(7ykEN=f&#Un%+R*igEWP=7Im9_!XU2v?#^{# z0M^7cM2+^|;oj<7&r8YrU^cB&5EG1+$DnUDo}-IQ*K$MiG%FwQ=;=jL$3?(M&$&Vt zv?`(O!78D{?6`2PM{G@>TAdpIv$b2R%xpO9;>P()owm~p8GN-?2_B&PIQ;zHOrK2A zC#{RrKSaS>WjgsN9B7TuBIYjk3*^C(S^`8f=HbAv{TZoQ(KS85K~O9((hJ=?eM|6I z!b9-QJ+dPo5I7lDTAR45q4JJ_jv&od>jH5Z;j@r0U!^HKCS`a8*LtfJyBw_my5m0v zj+WcEoWyr#b;krHaJM%N>HBCG23kVBg(L2^A==bhtU#n0&#=ExTF1C5P`HeMGO9L1 z>bMWuX+5d~k?s!QI{FB$mfD)q|1M3~$G`f}8RDUC3DE{%&3>kT7ZmkAGYoVc3=Afp z+uNw=M6; zfZhCl0F%Jvo0zg_td|wY?$Jv$Y7$+ohT=y82ifA!r8SkwumvLQQXEQu_}M<( zVt>nH$;gZFv}FCD>4%mc=bKX}juym1n}OahxknuE5w5_X+KwACOgc|uYE3#vb<gfu#4sYU52Ebe? zZ0J}AUlBconJ!Na^C{A>(3ZyHkUQ-=ZQ|tf#P}3;+{062gB}-W`x1UmO~ zMvY#+@0Bgp8=jP4*oY393&=vt$;_PReHHah$cflGi_i3n8XZZ}sUQVK=OgigD$X<7 zozW{Di8Ml3-6l1 zi^a###m7!Llun(sYH{8DLsIQ8#>xC8z3r(~R&4{d5OMBL` z-<<^!JCA|7erXH91b9<0dmE?h+5?ISom3Yifa_fw%c!e2d*))C9txXFG{2U6j%q?T zXWnI!WU|C#xdUmL^L7M3^}A-6B3W9{DUmX#b#}pd6j8+RtX@6xJQD3AF)78-G@?VS7^u%M7N?51PKYs zk~{(rq)U7XmDwH2oK&1_6Io7_$_7SzPL$9=oOpXk#pQxH8nZ$*b_$N*Re0#41aq;1 zF^P^ksdMC3@KwK(gL@vmX*?fPb-EX^VWjcT)~yJg=?ND?X~TL^o8g~-1p3+6UtLZ6 z-$>{3g%Vv|h&f|_g5}1Oo27zK+}k`#xR&W#MVCZ6%D$^h(C1Mnk7+QNBqSb;QDA6? z&y8g)@3QP&-(8*hayYNga^q9s6InErJFUqh7>#=7L-4-Mj(#!=r{9CFo*w*E1s`Vs zuhd~t4l5|%_XP(?PT}M3{$LzhW6#Bjs);{W# znlNHcoU%$i4=jz_s40)@1gwtg6yM%7=j2R0~@m{=c zi%zCXeO#X4LnFhO(!nR#RL?0%_8Cb{J2Y^V$lmt&Y%>qMK=v{}BNdntqx3v$C-$Qj zPiLZpCg)reK|de4{aElR7h*##1JCMI z$|5Toe7#R~c46Z`Ae#6ymM^h!{P;B?8ZW(&|AR>3pR0P@Ig6Be`8p2u{V%t=|D6b* zmV(0WpT=bR`+A8yZ4U_6R8LXeKlaoA{Y(D5k*=E?_}0}lS8fGB=ae0VYBK7;f10=p zh+|WEE06#CY5Xb!_+TYidwkr)<0%k;Rr#f7-Xg1EIf1;g5zfbGD(Bymx{N znFvclGwSiGx-&TWqln@6$`l>&0yW1k@|6QY9h`0_25gaJE0X~VOhwz+c{g)4$Jo)6 zSba3~-3$F7^HOmD2Fqxh~Zm5$`7 zzI)%T#^PvUQZM3f#8s#-4t{*Ft$IS-sr4@pl($vv^!?rT!c-Xtbe5;cQ5qv3{LW1> zF^D)Gu-AqG)sFi+WGQC$bFGOusLElB&OM&$MhJ0bnN_}Z3`%52Mi=2gI$M>=Le z<^xrvT3C+x>PTou1XE;ahz%gkWHZlJs>ej*A)NJn!2dHH(C;1pUlb>jH+#fG4ZBiM zIHgULXLD*={kv}PqN8X=2tkw)dC%)KU zFQ~z~$a1+>+*R@9)u6_{6ceW#l8(fq*|q$N{r+6IloTXeUBsBya3Wy!xr~1j5_i|xMBC)o=X9_KrntgaJM5z{Tr3gGExF$ z3WYmO;~J2M!hlBVu!;_COg!e2+^-iUjc5Y>V+H!?KGg5+OmxO0Ls{c1ph8hTuHgz4E@-oq=6vVWi{Sc& z?fAJO%wj|{iQR9Rg;F*13$BI+s)&=zwVuqT{c_vtfL-1>#*campI|DDIXukR=pDBy z+`8vc@AGro>X;{_GZVX$BW@0?k4z@H}F+09@oUU)%?78tCVr@F5E}bQw`#WWEc~NH`xoNyHwm2poGj*#ZuB9GIuBTh@O4If z{vNd?KZ5ybi!S(!ipQHambg6BCg_G%5Es{Uo&T`^e^BJb?a*`_I}#3Fk6!*S6qcaJ zPWoz%F5tPIS$}Izp&y zy~eCJhvV8p{Egu@aA7TYUz7H&SZuq;Zb0-AQh{H>N7<$*3;>>kV~v3n-sA0(`=)(l z72BWniS=osjLSJ?{Yh|EKejG2U0 z`&UdcIA#+fUsA}Mj=T%ByJ)Bc8{C<zkDdn_Ybx0F+LH)8hUVR%v(IR>&>9v%^)&v+-0j>}-|P_Ltz!TISqOE;MdM%I zVNk=Id{SCZ=w5#rb@@wP6XObC-L-D>wGdcmWtrCYnxQF(zGv+bGxfNA4XGq4o$~(H z?T$i48_J`ktbP^7yWPLKT^-xvD=8o>`dm#&(7!W&YPvVO^C1Tg2YNRECK0&9h=TZO z*52Gvj?QAdTDJWF*6aMv5dY^wP;gfoLztb9AKJ@5tMp@kl17|V*RVZ&?i_BmE(hL+ z7YXGeWSMNe?;Fr6K*XuP?scZ?MZ{~D{!B!Dfe(!5lX;Yee6PI>g1x<3c{U_h@7oo` ziZa?W+Qu1MU3&kHxG9}DyCsh^a^>2ZQ~N7)95}^D9!loWfLQ{w2|R2B8iHIl0cIY> zKaS`pQI=CHIm1uJE^+evn9D<^M|rJ8AeI0QBHSUH*-eN|q|1Eym8H}T3h|He@C7B; zj^y^+A8aQ()VU8Y^ty0s`!|Mb>>u%ybz$}T|H;7ose8YHFsXY2=HlI$u6QreEsc>< zAK{MO+2viHks0R3*G=a$#N0*oEOF?NJ>hFy?XLj|UApno^VH7eZS;ua(aZI!xcHi8 z8ig@R?7q!ku3G5SqH-&SkWOV^Q1H&C(Fqh1fyOOiK5`j8>K6cn9* zE1arG28L_-01qjY^s1*&Lb&?=udKJ3xasKfpkNFZ?9L{TkY#3gucN99igW)>=BSdC ze0}H_vHs)D_xk;rgY_1Mv!k+2uMHA+he09C%&b9n90WV)iPjdj$mt-p>|`9RBiT#m zOiINBzn`zOj>%35>OwVN9YzV;q9j-`qM5=AXgOaudf{(t#pBQVxlCDGv(#@h&+Gx-RT`t$cq)P-d}$V)pYbH2rEu;KNG~09UN{+y6zRD(2Nmj zncE_2A5}HQpJEr>e~(NGZcf(TyD@$OK-Nm+_#bI>eqZTfZ-CU$_RlvCN95{zJ^lQ$Zmr9l1FS*xrD#Iw zRZG-tc*ui?#UMW6GjXPb!9_8T)Q>Q(iM25zkW2gOj*eaQHijP`X6+^evrfkrZ)(YM zy*#ldavCAk*g=2lJ*k|7$DhvrGg$k^B4j-aW4-UT6GE+|!eF zgBX8Hec@9}(Pp~O7xBTv1z!0_%4D++28PD@ozKJ|Jp|0{K(RapdqA3x!|+JR!iUw% zL%!xLk<1lMNYMP$3%ig@`5ZpVTHQ^g_%NlQ!Osy?r!+OtP2o5e1R3=?s9GX}r2a(9 zIN!1cxcl=k&xz~eeX2>Zk8zN`%KS;1*XX_IUrnF*Pl57}-46nf?(0d%mKx*RHTaT_ zUo_l*ONjlC!m%(Ymt;Q6F(t9^?@O`&S&UA6hMPhANP$|v6+2m;(0kWHHQ&@GxvyEt6QKLGgb7RQO5^|MBW z)vLynW^ny^__b|Qir+8;5wf>Go)^GNhO#UaD#a+-=xwNj4b1rmh;f-^xhpZqeE;Q9T??=~8Na zc~DcrZY^OLE5UZ~VxWq&e!w9Wv?G1@Xd`RJ0+kEGl^ygXnoLNdmaxEk%c>3|?N@8s zV28Rquo17g=??wLk#@aH(z6D3<1Rer^KH&ZaK5CRdRyS&5=p?o{6H_JU+GwK%u*>n zUA1E$LJe|T3G>Se7B|VbW34RmVaHrmB-g}C_MTm^>QVDD4WH{;`EDyE@|oA%F%Z*J zlU~SmjY1me@!-Pse7t!)?A@t;oh6amMW`lvr_|8_iGC?kmP2ctuxgs~#TUI1zpC$G zO23~$A({alfTpyuq811We_(Q?7Epp0K9~RQTyVgTs%Gky;6{usAN3XEY2B!AG8$-)FQeq{v6BX}WQ#SG@t4k2GeW==#(Vqi$**)H+f z933KgvR>wh;$^e=_0FYnbUrx)KdYG5HDP$QJ5|VkpsDQ@`!4N1q0~6`6VJ|Qdt7{lm{I3EU_klGKr@%q(I?4Z!5-im1BdGT##R-Q zH^nNsTz&-9z{u9j(z)-pva}6$5dmS=kQ;2~@8)Vtm>g$yTSbJ24IIfuiD`y2Ir+*q zeDD{Dn$=i2msF(P^_1&cXNnx^JJ8RbR(GA6az6Q20qqO}x}vq4+H}v|-HuspyVN!} zi0$#QGK8C{lWC3FpYX{D%}*%xk>E5Z)J$tiVOaUiyWK7oA%H4iJ!`3^z+;TTzdj~_ zWxAob9;89lS3J>xs6xIDz}y)jXqWk$u*Kd27gMFLF?xK=+W=?;3RO0MqN=Ne3i6>V zG$3=~dkJJqFD%}vgZ*m_AkqyS;&ZzMSom;lNUMedN7igY46y7}hY&#@)vcE;JI9*a z3R^Wdg+n9xY#0_L4}IJ(RadU1V1w>XSysQed>7Q?pa0FCP=0Jz1tHzae+0KGZNh5N z+WJQd=8gD9Q%u#(tyev87gBvpXC8TTIPljKoDTj0!BM)VgnwJge;n8&nr8APR>*oU zgXyIDR|mfSJse#*f@7vGm;WC|zy5(Y>Peljq;mG4#fct7|6bT3OZE*@;+}Ho(Ft`h zy;Fhob%N6|JNqyF_J18{Bw&d#!ye}49-a(au1v)j;QVV78~;4*>$n?3z_HhqD}c^7 z!O#92$Up1(62Q1%M{mhLy1Y48&6S+*{Gj~MjeOiIQWR2;jNz#6mOJu`c;;UxCo|V9 z2sS_J9MyWPIZpUk>`|@MaMdwdMizDkI|JV)EiKW-#zvH*a`KjjIgMV%miD0S>PYs4 zX?`3sZ>yAb%JD%r`e5uQ(x49PY)ehE`n4x$naV$5&~z3y@Fk?nCSEyY4%|E=hS)tss5ytzg#cgg8(t+Q|xbi z)&F)C*d5Dgjm>*gMZ9;3Zi|ZYTN=FK`BA|$ftsm^S?3L%_@Ljn&k*g7@!y3b3Te!A zm}^R(Q2>ehm}Rx?1XaBmuM$gwu-Cl9Dia9H=X<<4@@~(l#O}jX#KfqEUgEwa)*Qao zVQ-^}b61g~T=7@$RaLkKH}&N^z~$-5u(_%Dq-y7gCfTHBG&2NH8NJm5lMn?5O=t(L@=xdjfP zq_CK*hU+?<-E<^02!)Zig_Qjfrw+iXLcyth7BI@SU~o#^sr0~VhiFeO!5tI&S-u=PRKxh;r9N7wbK(2r&IqI2VC&gD)R!zmep_G3hap)4wgWjl zch;wR1_(ER({|>v@>VzNW89|rAxPuSByFY&G{^aeX?BBGj4chB$pU#yDTD& zqTGQH7-a212|V>1+?EIm$(N0z0S)^)R^j5~c%0a}h3tJP&Vxb`pAML{Qgw%1)_ph<9>MRxrN&43EW0FtyaqyF|9_!W+tP1^mv~iOdE%4fg z5_4K~Gz<@93-`Jv1TWgvpg7L&9}8?4kieuyZOd@Jtn_-H;9&2(Q-E{e)q`jL3mRUhXMv$Mk|&G1&O+9u=!vMN-{ebU9R;FWm=$S``9PzSjwV`|7Ve% zZ;#}~PuKJ(>=)O+g`QvgRJbM_kY;ruoz`_n1%-+{j?+N*CzCVyuHo#;FK>jRzbUt7>Wu^|eq`6+e6&&S$-x1cvT z^y^eSfP?%P<>^oN@@IH+i(!yU@GpbjU%%#6s@@*1;vM6e*DcAp<$p4e2|NCyH z*knLzvi+?#UX{(=kbA(-@cN$)S;c6o+G!R4ogzN^VTeyJ=}j>7$AJ8^cM*tTM+q#U z{jC=nm|kj^#u)##65*eF#B8Uo65ju}UXC#0an8Be{4>k=Gh=n~_wJBd`CA?t;J4Ho zw)3|<(tBLu39MuM@4znhQG0~RynlteKZ`{FY(nyVInnZ``hAoBf3d{BmhQh*YtXr2 z!*x1;%OiI+_k6#P{me4{^lQrAyniQ!*_E^0Os$G9!7Y4~(BRFr0TziTI-%}%Ls5ii_CBhZ z!leHCxw{>KwB`@8FDE$-ZQPGM=pwRiht{bCmgXZ2N6`m5uO5ATcT1+<1=UqJy^_!J z3I+$QRVo`b7`y3c=j1U)Doi;tOR$Mh7GZ+Kg&VM-wTrH6RpVGMfRDRO_mXejPMq8L z0MN%I$$cP`AcZ|^!UqLi6HLbW2Ch0AZn@B*)K}vq`Oh}cU2wrT5kNq|P!X^SUWce1 zFT(jCKBogbsDTGE4G=#2ALdIz-N>3xP~Ja$1+7m%=tW5bfgC~YZF7*UHK)_*4dg|m zK{}$=(x^Ok{S=;6F1#Z~*s!>l9YGZgZ=3lZ6>l?3hXkNg=mmD($fnHUs&{($0iWoj z!E7=N^E~%iAW9q;K`_&(3$IkSvCgD`noHLf=u+H8(w) z#r3(&^YVCen9Q@bRThE1OmfaC@?N+;T=XQQk08W$ON_5ylboCnAMzA+0z%9wr^%k{ zZhaZQ!*+qM7ppb&9djRj&E**DcU>THEa%59>%VXIn2{B_xU~1~kMYtrI-Ldu+(!n* zl`B{aG7rTyr$L197#M~MvP>o~tK9!!hk^E{Ja=VcYDIVR)KU(iZP$BN<6rDLv7mQO zgr;6;m2^A-^?Y&){kB7xrIF?<_wfiR=dd_GCR_O~)-%N4MxJEPbqg2*t1Nv#+wf65 z*K~iA#-1EyzVQCzoM%3B$~c5{1yv^{!;%UDJ$%(*W|&Gg#vG_3Zy9;L@@GV%l*P)8 z(uCIpDm%c8|$=Z)9p*!&vc*R;#iVYLl(^v%M?u9Ky%1y%ngqf{FZgKbBVV< zSGU~jg?Z^|R%*bl8CIQ1N5@2c;t)eh71#*XVrH)R?(fY%Rf-ccoMCPJv=&UhAnU~8 zq0OFNHswR*5%d+q6*Z{#?g`RGhDVd2ah-JO{sXmANa%+N;nPma0UK7dKIY;yR3z}o z9&{$8&E(R0ySADW23CB3NQlhZ@A+od`U)iBZf`Hn_=+kAY?5LRA$Y)POb^wiI1uf} zGD?9fUTf&2E4U43Nx_e$QM5vQ`C`ey2BI~bcY_ld^HDfJ4f;Vkhihi06$Wa)MMMo9 zE8t5I>R4S(&~&g|#5zb@w;X@WoE@(Qtf;Y@KqpTRTK+gn17>tLJ^W$XN_3I1}ay_z9wJs|S7yD%{uYV8fD>qWp#awVJtQZ8u9Gy4O(& zW+B542_)UIJLyttb`Il(D1dOpnY z?=fkC=+4j~k|PM@X-Vd9%(Ogvo=2t%-Vo;SuKw;Hi6i7q)t=nYeoe*oS>HcL!6s>v zj#aiYptJV8g@vn??sqlq>!kO1 zLj*x91D28R^wd+!I6NN$9#nQisY11DIu>z|VE2qLi3I@YJDmw|*925X&(KLhEsHV( zSc%5`IEw-8^#h9L9v55stxI~%HD63ph|%9+S=EEqqwIquY-cuFIuswCb)YZEdCq!Z zstARZ(1;c0M^hVd{hlGD=`>Z^IhXb55TUH}*@p@j6R^MD z$a4cteF3TOjP3b0oL8K)=hm3?ZkLoDvtXmWhd1!>axparVKzfN9 z%k90Wo05#yT2&+z`s&jruhXlIiC2{AW`G)jU3md@zrT9?Wwd|3-n_2aqA4RV88*xJ z&cGj*dOr75D1>B6odkY{POLgc{^UG}>0GgnzLZ9g-7&FGiiMrgevhc@Wf;<=7!AmB z)5R*Pud;6PX;n~^`zbfzBb$PTYzSK0A)kc&9lCgb$ira(5xIx(xF~rVbfCP-`bC#~ z&pRa4(NhwYJd(a_)XG=k%PFZQG%-@=dA=jn@8Cf)o5*%u!!TS;9f7boik?yQ12vg+ z%f>Y!N1BsIxAd(I7F_j|9KC)iwKtEnl00(h(zZ-Lb_IcDI#qrrc~dx?U{GO;ub16q zzemC+w5NMTwJG$qp|Z)twoV6l1>7|my(bWCQapYTZi?O1kVeRVWtv3P<-BTotH!j7 zrCgXAutSQOAwGlg)uL$Itez#^aZ3-8JM}hmJDJp4EDQ4m9(2Ep?RX63^QPVmfvblf zheS+{TXRlZ#ioCB&&|hK^+knS86+I*s`e-e)&3jS0Es;3rs7W-OVyNx!XtK zp+NA&&piTUl~5;HYg^%F2#L$PI1ttoD})dWA+j+G08L3(h=?{4zOs;>tb}=$h->57 z;V-eH@nF-ZHUkqAH=Dp=XgL*%Hv?oDgq*7&qkO^9(6FURnSn~ek4Yv@jrVO2zAj0x z#vJG;kzwT4MTg~)aMffJd}tQOXYW1OpwY;_b|jVT2yJc1k3A7=vHJZl*Ni)Jg<1N7A|2P6lj2c*^?|zcQ4< zdnm;7aK8&#Iz-s<0^N!PjCa)cd_?SfT7dydeed#_gqv=l¬z#l~v^Kc%0uXNZ1LSsm4pSQm2rz!x|p0;Z&)j*<#GvQKXw3)Fd z7CiR+Fb};eLzM~vL|FUe*o~xCpyhi~6LcGarLKhw^QH>21C9k%{c!})@_@2gq|D$R z1`g+ILILlHks9xZ_-)k&Rto${m|BBf*m;`1TG7kh)d%Vu)IqOrFH)r;yA$uYcW?(5 zaTK>@a6zA+Nb+?j4O0X`ovn9Ks*{Dl$710Dh57?+b4u*WGEjp-PwgK{i=_(*#B5u# zZo|@bYg%YFyPpTew+@|fVw{aGvESySpe!mezR*6pdsbc77+hk(#b!kndA0jS-Ke-7 zRh#w66AsIj)m6kvz^4w1&{q^#iqn_E20e z$iDLdW%h|qbY2n@kmMGR+4uhdT0o`0DCP-7HD;;HXb$GHExU^L5kRbOaoUFlDgc2> z`ko{QU{p~$@2Fvwo5waNyU1O)+aSUfQXPxxhNs+X=ivC$jv)2uTbNiNkB;9|ui$SD z<3%YUgIYXfspOgZxSX;OHisO{lzzd zdw$yr4*(nAD)*Qvu!kvB*jms5CQXQ^D#*g6T;_f<5;K=vi;BrqGjJxz0R{9w>X?5x zrLpiT02=@ueOIYoN>;Y-Z+2^p=f~(g+U;7pU|5J2tBOUQHV;Qg_p9==GfNAziTNn) zVEur84?E1@3zP#l-DSUN9)>wL!Zny5?lm7bFpz;15ar6!OTk*XI>rp#S~B>ppzdw**YwB%Yu8(N zfx(NdBq;{_8t&TUnrrM{;&?4+?yb5g0>CVZCxbqsn!4in42eK!`)#Mp$L%rATGMop zK?Gm5JsDkFz-7mG3-^%w1B2~6F;S2B)2UvwgV{;^#IBMveCMfe zYVH6h{v^Q0aR}F;IVOAt!II9pTgCo+Se2{UgrkeY>-RE7G+K6uVgSi+y^pGI>+x@vV$>@e*Yf{*Q50CS9_T%IV1 zI3rb7qSPA0k*_-*E2EOoXmO=K3`}a@WX8&A8;buC;A4#{Q>b9J`)^?e!F9o_@u)ma zzkcmz1NuPreKCs)#sYI@-95?93&0qrAcO=kJ_R248M{h3*>P`x00008iNM6}VG6m_ z9^4%Z)cyyOG*2RfGV&Ggu9;3bXPyx;II2|h^K0HXsK;jw%=%*sg9SJ)qi0Y%C&m!^ zGZ?k0<6z$U#B{6LN7TnXaqVW22}GHQN|o7KW1pE}kw7JYk*^df(abWA0wpH8rZP_k z>B_FuI7<`pBm9T_(nt+chLM`F@|GJeQ3#hcxW^mZyNn5S7fK+F5?>YDh_o0OL5aXs z13$0De-I16Z`mCF3jTp5p?5E|%msgS@Ab?1k^MjEx2Cn#mKoeFsT>l=Wt=*FzHfi! zl$dV-nb65F=p#Gg=p7s0gnE!awNarja%Lq51SICZGRB@~k8o`CAj2_QPCTckUsG!|6WpBN}u$fQDKQ> zfzY5~2W0*B`;XTSB(yPjamMkXDUA-t^!}h0XWdTSBjYjD;<+|Y7Q@E{VbIWPGwkNs zG|h+7lS_a=A2#?|9DpRFM=QX^1sFd=?^A+pN&}^LaLBR9NimCU)NR~*Pz9A%trH$; zzO@|P2DHod^MFeD9&IuOjP}@O9{j?V=k*#$+|W_2F;?b;)6asAx?8zJ*-rUsHQXv$Zy`U@#!G7il9_RsBY^d_WUhgzS{>3!M({j0qx8^wIcBXNhg<=!U5b4z6u z0q|6NZXp`v-8(LDn#bB_{yN!8pXX8s@fhrEi^rpp;u>4Hj#DdLo7mH9?h$?6w^b3X zzk&mQi+xEJf#BWSDI%bFu$|SBkZmbdjs=R?-M8{tG~K!`@<8ngB5A+%@yC7@r(8#Xl@qM#bXAWUO+$r@PFBX>`C zHG#$O;2BocB)s?=@1U)BACQ$OVns&#-=FNzdp^jjVr3bgipr^^D{Sl+x zEN}y$>b<;PXfjKFUszw%?e<5$sA3%2gCE}>*DwK*QSTfW(i;7SFc|QfSNyGAq>dk= zBdkOw$xADuICyhqiV~=(b39tK7YQ^0gE0SLt3UoXIP>Pdyy%u>8Awv`;Nc*D7LX84 zUC}SZ7F$fX%VP6$ILCm9;{(KrnBkra)<`HaV|%YO1o%E<@_&I@3x*BfON22X-QVuq z-z`1f0<+yP09-s!E@e0coYpRAuW_#6oU%pDVf$89?P+nnI%#$_ zuQRj4!tl0BKFkPa78e!TLB#fa8@d<&Vux*%pGC1;_zD|#D>7iWS> zXWWKIUl~p5T&?|wXVzh9oXj@F12=Wk z6X;bLS}hPa`b-CQtxzZ(NTAhTu{cTk(1?dexJvton!LR{V`5EL#MaKv9vldEbECo2 z@gfqvA8n^_RY*v}0>)1o`64Hn3t13BS3J76`z+9okKjLeD=Jt=@RTMb6x*iArmIIZ zuBK40MPh?i=P-#Iy=_v${&ve(7Ce$^8fFRI!&nvc+ggo@6A;$1zPt3;9DYj$K~;QX z8ZIX1BktWW+=CSxw)<6REe#u08k?k?FDpN3f+M1G-uSNWzB>x%AIC zZlFg)#0P-*{KRtM&{XHWj=qAgaqz98m)$MH*-UMg2t*$Lvj>ln@D8c#(Vh*G)7&PU z%88rvkL2jI_)}$UV zE+Z%d^TjNZFusN=Uzdr+&2nR5n6&9B?IA|VQ=&^AaP0m8&0Qbz_pa^5O9xIp5k}Bn z)LB?n`eGF&k|2`dz?$Ji$-Pni)d$4Z3Xmy#0cYnLjgG&Y`MsE%4Bypv<4?~fBUt-i zGRt;UW8QxL|4kS&_N{Op{%_@1ARI@eA(dep6K*{gdvrkwHPo#jCSN(RsJKt11fh(Y z*-sSbeSFE#U&0eY$~ME_X7H6cxHSp6^-Krbj08`wxSh^f0b|YNW!uhn#M9`AX5vnG zVBC~Tmg?51rZ^5vPpD&;NH|e#uy0wsIDG$$#xDQ)$V+A@3y{pIqGgv<n}Wdr!8$>{mNblg<*$bR8#y4B6>TLeIrfQOwX zatqs zPoa6(`AK-M=;udrw8cA4EqEdY)2z|tg)k#?z%c@&&DLnf z@V|K*L_cKrZ1YBz>kiu-TOgJEL2$zY#t)|6B z8SyznL2FvIXvcpPJn}6tfZLM66nZ501B*>t{+-wc#v-x{EVYrt>I-1Slw5%B;MU)3WkBeE%%H^-WSR+WGc*RUQne@Vqjm_lC^0SAX1)z zjq7JB2udMB6UCJ(jFPt7vrS^XuM4^QcId)MIsqdqyHtf*84?^bT3P^wnsV_uv$X$h ze|6k$m(ZuqpI)qw97SX6G24OEfHWK?Nz9@UU(@n|6Bp5z3Xs3U;Lz*ynex_0bX7f@ zV?NtFV;jw9WPTu7qr4!MO7)=ZT8y2g4}nczu$e&C+d^hi3EH-X`$|>A3HyDuk)0%b zu$+>A7>}mt0RmMI7BQCy;V=Qx`8GLCjA8L|g5iz-tRH0pVJbOt85l}@Leuu~7+8_H z3MpGQQDLn>WC5rhqxOadYMIvQVKu6;xgX-|T-?NF=r5Djy6C?&W=Xddak+obi#FV( zh^V1SLz6$y;U+A}zE!wGfVp0?=7%OKj4m{hP->joON!R|COF3rWn|VO8>8Ly)u#SW zJ&RuYev4Rona9D;Kc-$>FWaN5!8N81n@jm$6YT!m{!t z!lfh)4=WFK_rf~r-Y|=3yvkQU-A8`zYr31cx*mt0Gl1XP<84KbGEQ`zsTD~2)hF25 zfkZ)SkM?HK+nM4yrU>4L0k)R=hUhBgesnE2bPV#;buoL5gg!Hr9*ZZ@!f zFpDE_o^N_7ctgb?MxU`=qjhvLdBLS_F}wz}71(T9rdJRwRDfP9)Q;y;^s&6zzX-12 z)x&EARM*8RQ8PGYzGMf!!L(%+EKE%tsc2AXwO*81B4>wl$ev(-Sq;J1!4)#u+5whD zk+$CoclPcavNq2k?Xpi@{5S{AUc-fnlJW+*en`JQK3j-mNwW979E7tS+0Gr}aS1Mj zmN#pXViZP&`%~N+B&P-qO;QiG?63W@{GS~YlYkW`^oGNUl5Jfdz#`a_>TG4^Ugg*5 z?c3-T1*O+6t{{p`PPMz?bLj-rib!z1c0$y!-EIr*QZi~+kL=I5j`@GQrl(cs0m+86 zR^8jLP}^SeK31+7ecUPP%21q^Po;Y7vU>!Ze~M*PJAJyMhC3_jJRDU;eCd+CCXO%%sA-8F8<0c4U z*h)M1r1jCo81>_&FM}jS3;hxbKFiNgWacEFf}I$7ahx7mLDwJNT$jbrsATfxIz-TE zE_;cJCw_()r%HG(UTV#)erB&sf&D%g3nq6lD#cue$x%4F_!G`NB5&FXlnKXNj%5sB2# z_f#85Dfi@2e#Fmm+{i5pa?^gLqY;`vf78#2;98j7?p#e;Rb*(q!NN&H+E#osgFCAX zld=e6*Q#P1`MKBc8x^mGu27nv4h@m1!}2R$=J&+93HuFSHIGVaEm28%-Trc3ztN?= z=uTuH@*6Zxr>Ba=x9O7xYs?|2;)t_qZAaoz$)d$<3qp!|bHGRxjbvZHxI2f@bfu9% z9T#&9XAN6e_Fr!nZ!6^;Bvc>ZXR1Z?Y)$`9AuF;w{w!*(2Wv~x-vrJc&U2?VQ61jm|1slm-YyeTse(MPyRV+@Kf_o zwo^=ys}|Iiny{yJMSbB^K6F@tDS&nQN3jKo2}1$3ys zt67eifSc{TZSz%0j`JMwAcwf&*8GI;AIReP?l0E#Av*djCwXmp;2LsKXZ;AO+wAn0 z1)cSH6fQgWx0G3BP$*)?XAep6uPmZbAmB#c=%XA0au86kIg49<14wPpaj(0(=#e>S}Ot0r20REQ~6Lh}ueM z9acv($AD`0(ld=C)e0LD_>l}}ckF_J!G-@av-i#ihVe+91n-kjP?zKf*<&s!`NKLj z)?p_)iF#jX<;%he=4c39XL7@tTFMd2X~nAQANAy+W2QV&b5`Ko{J0B7OuI%TSsm+h zA4!5{{S&|99%vp#Q1RiE7vUA>B9-_epAZjOOyYl-P&x`%z-45R(_Ugm8L@g8;tvaX ztV4Ly|1%ol=8HFD&7>U)*c*DdgyFk;qc;_000%+^`gG|hr$>|FH+I?ya#G}>YenQ` z{A$8XEvdPdsbC(yp+b>*f(KEjqyK11IKWBw89IbtrY#jd)FkrgXrW&g2;<)9h=@~A zXc}MKrI_n7TcGalMdIWizx#Yy4zVTYLw7byyj5J`&C_$%R%5k=XBEGxE{{f+%Ty)H z-~*z5ds%hgra>B|Kn2kpdfw0r6VZPzX2lod4z;juBC)Y zb{EZDT!1)xTP>r15-9R47*&Z4JiOe2T8^aX`BPNK0zOe)+ia+(y6rw3Rq7!33e@%9 z4Y!wvA(Pb=WlN0V%7vR&7*h|b4*hfI8JR;@B8eS5X*JQEW>#|3wpmBwloFXXN zdz%Yn_(`7&g_8*}g=Z=Y@Vtuo_PFZ-d~4cfm9?PJ_#mQsEe9`VzzP1yfiX2etd&WM zngVyJ{lvG3xy{hrIo?-seDUvM8(_uUgY0X_Guh1nAD>5klORXN4q7~k-+!x}6olcB z{!t2`4}rXP2|fSc>8P6vQaS==S`v?Ku~qs7BIk?1nVJxl*)Fk+xU?YyHFH&BS6_z4h^J%}s zlB7<2Jr&P~A8KyIUug+L?h!&xb!Bw38bP6RCbk8jAFLLO+#4Y%VSBN`G^-a|%UKC^ zI@Ij2vZ_pq^iB~7%}K*5`)J298{O>Z2;t*q%axKdBxNI(Q0XD)dEh0#j|j?Wk-mJ8 zYJ>p$v!yxhpxX}weNH;0d@|K7+N(^}^7Tw=@ig>vA;WfWr&EM?wTqt!g}T{fUnI4A z?6lgzCKjjcsb4fkSlnq5A6OiYamL)8NolJI{v5oJ@zA?5AP81ZKm#K|*D>F^vi<8h zyaH?cG?;jY0&rMb{jfw zAzz031LA0=mH92hOS~&=c`d~2zx9b(!Jt7?QbQpk7wg3)wVcbV>);@5jvhlo*ds|{ zSCHN2*Mh7F$h;G*5Qb)`Nh8s;$XJ+}J|Fnzos)Gg3t;)$Pd*)S+eh5x*xXgL#tQBc zwK_19{MewURTF>ll{f?HU^++xk2Ao3+Ca!!n%UpRzBsp@JGCv$b?aJ)P9}7`Wl4*9 zP`x1Xj+(tNWEv9tg+%1H4dsIb&pr9kHh2k9r@@*A5vyvU^diaz(a$C z(dN8E&_-G2^`Lx30^Wrh166B##V2t79rZqWWb<}3+LWZwK2p=B<%+!8fVEt-gACS| zfFoGR5HbZA^x@7vtS)v{^#LlDMnIaO6LqiO+44v$@xV6GM>p*|h7*Y-67VlqqkU@D zN2Q791>o+DNas}j;{0plcUTqN%|_+;9`EnGUG0#zz9xtDwV-2;H#B43$}fwa&@c{* zcl#0lp@z)6@%gL`R&4wI{+tuU^z2vIY9aegQXkl{tt{COK@5(Jt$Tc}l|J*^L1~Uy z9rod-cZnrhVDu4qN|97E;vFPKg`0#c6UFESh!$D!AW)tW1-yBPK+E}zUP;COn2)um z{`Ji5Rb5L~61Zw>dB7!pHfkL1BgQhbQq@Zh2&%sr6nJkoty?-Jv>dqCl6*>060et1 z|FUuO6cC19VlSqv&*Av75F>Pk zIS^A{ew3&4<|DroyQX})a?A$c3(g2Po^K;hH=-qTlvFT5JW)g_!+1@JOdd;MfdT+Z zcw^s8v2M0^^))?f>VN1vPuDk5=!R1y1c3HrHgUWcY zHr>#O!GKQ!FMedklAbj?T`ys&h(#@cHc7sZXKOm6$D@xN zC(^cjvq7lfrZs-oly~GV5u4_ssn+i$D+Kgho~5^vI2i{1gi)AxA-&KyF4=VkG|vNJ z-XK+NxvptnUaT3eczFJ1*QL7`vMOm%1}G+T0xTgL4}Gfu#_>j_QtePS!>PKN_VBm2|!n}JE?3ztB@!hO)~$jJgzPzpsz z`SC>!gPBYda;lR-j7pyUM6P$*Y`fVG)|!Jg_1=?-3qZt3!b8rURO41-`QxV1(|?L+ z{c4C@Vt?M${RIR!Isw-F>au}kv>Zf{bas}a)t$Ij;@rTD!*&ZsD;I!9%KWPDFeP+& zRP3>(?;I=~UN-rSU$I5%pCM0|omzRBbFvSOjaWS7xdcC9hkWUnX;Jh}3MNws_`l?u z>D^1b%)cdBT9<~3>|B-6Dy2`&KC*)wr^*B>G$Eq6pdx9=_?*DACGZ9?^~DneVux85 zAz}SY@Gsk>@-~%7sb=&0z3xv(4AqCi{AIG&YTk3q{!hE#9w)E%5R=>WW)mkmPc?g7 zql$q(Zut`7ZEBsB>JCQR7wKj6<{pTrDwq@XF!yl`yz0kSwv=;=E2z1dK(|*6Z{gmm z`^2K>jl1G4$$P+#``xR>DMgp-4dp35 zfAbG-MBq z?QC>*oM-LW-DG<(uE@iKTe__BTm3j_Y=Z9aOit-4MgEB62rHyPYzyh^d+Hw>GbTZh zjM2j8sU{*^%$@j4s(IlS_{e6_t=&c7p9|IXRCykXG9`wl)@dtG#BbbEzj^3HctZjb z7A?H{%NScmBZ}8oL3Pgy%cQ5C!~_`8vaMmdXH76C8b`yAF{efx;IuE#3;!xTRE+Q{ zZ2$ccZs{n;9!9*yf9e7nzwvqIyWUcPYQNG^*z@IJ51T25E8ul~`lZtn%^SOB+A4 z*XiAYaXzxoY^JERBlf|~wiJr~T&k`k`7h@Y*)KMVu`S!mgK%zzp$CxdPF(@-bmK?U zzTc47r%fiCU`jJ1$xigbH&*B zD5Z0QH}8R?lvH{j8&E~zST>lC)6Ml+ZT1FnX}>Cgb~N~-dCc03>``NYp?x-#2^bzRRTNyg-?(asGq~qY_c%chXQvdWEZ*CRu)!YCKHm7WSX#}q$@wunWaak2%yUJmOHPrlHtgMGm;_QHtq6k&P2g7E`_yVmuJF#gh~K zkfUZ;V5GYWomO=Q;dav^UEdsVCTE`WB*C{CYTsjp8)JVeL~=ozHmD|eJ(Bh5E@q?W zGmvSEPFuj*&3h#$uXP6DrB-`iMk(GHaD>a%s!pwjJwP2M;cCy>^X4dQD>{+(-PgpC zI`a>dHAP@R^!19425gciz5sz+2VEi;wlgjN0y0FX{8UiAw7?)x<1mvpWL_-J2~*zu z*#E%k+X8|aSuO~pahmr(-hCA}+}IU}NW0v-brh60h3$dg3kn#goSl=uF}1V4t+)Xa zTTsIf#}UIcxzicYmUTi_CXjIV(P9ae=OTd*Q_X$=c--US;?3l9mhTT5_zr^nh}gtsUlYnyCg`n1k| zN?;EXFA3eO6qCEm%&p*hU#3DBkhsjkVy%{m^W?n(^>M4 zV5}Eb>RzT7zn(-kP1s%N?e;wI2vkNGkO{gY_grlD4eXob&hknf^KvIp+X19DZT{V~ z+$BuTrUlTkxP)8ZhIDY9%j+d`nf#V+aX?{LOq*LvKM0x+Z?H0I=zgJ7;AYtDk+Uha zy9Rc+F6=k3iR|4824=U#LQnN(VGh+uCBzNh{WOz1ZJtR4mSmXs75b(2obk{^kbd%2 zxnnj5_Y|DD6K7eTKlvZWhL(d^tda`ZFnBP8q?W&qG8kJu4#&^_FPRc>j0Ir0t^o)H zAYI4&4`^);CNI3r@{Ghx_qLT2C%GjLX3}z(10C8NF$Pu3k5~H!SC90+Z%@1h*(InZ zgCC*~I>!El&TID<0;q4a$xgtQ@0=oY@6CD?>6C6QfRvOqV~*(ui1!dCdkEoD?T)FV zve?$s*O-Gcfw|GH=%-2AGTLk!L|DC+jWTAy zj1!F)O-n?@yzzq0nEx-ojCHe@z{T_{z=lox0Besyy=(?Qvsn(jbI~-HPH>qlMc>c> zMp+2MoIhvKF4=+xpWsOW3cgZ$M$OBS(RH*jY*$&q8P{jlC<&F7;G=8bWdx7w!^W%b z>?14Ohnog1M8=R(EbJH;8KD{NS65A}R$1w+n}8}iew=3y4{pW)Rae6Z_dcEn_uS0| zo$L0uJAeIgQ2qhho_k=H9nSVwK6u>4DKz3hvLZ?yiA)JWb9xnG9eH%xL^JGR;)iT& z^0~o+S?wd)-pzYoeu9mq+2$>fL1AaX9v4VL8BFsgG;jjne4V-N_ny#9HmT(5!NQ@);ItF000E4pYjA}!(K~1>|Zi6 zZw9Sz%!+riR;>mzO%(VC5*If^)Q$j!08cU#Nx&9NoH{Bl(}K+xHdqs1sR$x92TaYL z?_PI$5l}&_OxlSo@211rbwB6lH<%pD^B(D-p`O?{od=t{8+&2jJ<6mELvEerT9c>4 z2>*ZLxPyA|dicOKJs!Ly>hhH>=iu%>nPVga&xUJI2b#N0QsyZ-5Eem%-Y#d~8>12# zRQ>@h9F~xe>@zrINxi*Eq4nCnYoKNz{L^;#|MzY)*NtJ0dPE(_R3jELQ5d$VV(ICQ zOS{D?nht6J37ALJ8~j?3sC2v0Z33&B2Xd_QZKv}cS2 zq0+fYn)oGsyYdaOe1yu=N7#y;q^uilc-*WF3y$8p^{-PmqpGG-|1C^mZpS72fj%+5 z)3(ft5Fv>Z8$zHQlb}4cP-Y-khVqOiu+TtYLyw9BY{AE|S{v_-SId-0kqR3!g4)BW zl#9NaRD~JB%cmuh7+YHXphkkHF%eB7=o44pR^13vwVs40#=Fw2 zZSNqTt?p$iR)wap6h2gu+E-Mm7ze^21O+y9pj4k_PqDmakBmhb(zgeNsl+r>ieOs$ zgAIM6NHPxq003hCm9RreskA~NGK8H#>)MTlkGB|x`?~ls>ssz9sA~xurnMhN!gQ_O z-e_#KyZ{yjh0mRer@1R#`5@Im0wfb$7oFzcx`Cq82-F#vKISC3+3Qm+NVz^l3I{-j zjY6CarMV~ZEg12HdxC&T6eb5vx%F%RTv1^WK(gz2<30ZnOWAQ9dwm}|XA6*P>%s$- z?vEBB%4d61;rwd}1AR1%MtVowL7v9J;BTSmrU_RSmm(yN99+Sg3M}Zbt{dQRo9)G^ zw@+?I#e``eCiPah@&AB*BL;W#(p2IIQ~$soVnyCuVwbGz33(*}QrDhlH6${gq%rIq=D1U@T; z(r7c6wc_!J-n~tM?k9Z<9Ec(KNHufz2Gty$Q^*tUCb1w%7!|)$J|)sgf0v%k-e0hp zDwqhYUGO}qDNlJ8C91v**^0w1i(5^^=^W4MalT#yw{9bWhW23JY+l@}IWY-%;XdXl zE`lrZWhC|m$K`?hZ;b53Oz|vviTzKQz}PK5E-Ru()8pO^GWef{M_%Xotknc}K0_j5 zPAi4Q7qI%H^!)s}3IaG_oeu>ZFW4?ZplBo_#U;st>DJQ3tgH=sK}N4Oj+BE_8DKjQ zNNhczHP-_a9!J{(`{xAA+?!*0FsUwJKd&4Dxv6T0jgn8IeL}b91vz4W>40Z}iNWVf zC%6%95J5B=y_P>S=Me6#-8a;AmMrTR3Fz9=dF$JOB4tYp*(jmJla_C?B&8r$1muSn zmCmQ-s~r88Ua1dGJ90bq$WI%pjC=n#N=i0CP16#1#U|Jtbz~#=RWeQ8e^JM;Hh9$L z-F!s*dZV%~TyuM>oW0}WEYpa z)W%@_K~B0WvTPsU=@H$62}jx=?MN3`3A?LEuq@*W=YnGu7z5EmM=BN z7`sV|^LB2Y#AKLj`ci9sui!GPsru`e_@i_N%XeAa!eY_rC{|)oj24Fm8tXGGptZ{BYSHe+UbON+i@PX&@U`R%2N z&ZISuk2$|Ars6X=coaeAj>uYL$!}-F9m3xXRKFD1OtMi1Izmq7?cIT4<8YC@CM_yw z$=|u8ZqU;9n$-D4qw%f$Ok*Rtm6y2xn>I2N1M(+a*6oXv!G>Y^NZyx;lU9^feQ+z; zVPN}=g=F*T#4kHjf(F9gq+ADOMmhh#pjCs9tM89)_nB3$8zJhHeBt z>&~$=?1q^=cN&rGsuMs?dm%%ILNS?@n(|$FD*ET*`OxDR;Cl3$mms9JKd}FQ+ z1CwAhmQevCk(}r51yAMeT|vUCg|MqN6x#?1%yeBjl=;2Xbf}e$5Xk*@ z2%wRs`6fwL$rnnHa9PL&^yMEygGWH-TgNm%>Sc{B7yAJYGG+XrYHK~G<#X5812#Tx z@QKJ01#z$zgS4eSgBX~H8*iy@kb)ylanwWF8&a3)$c!$TKSHJA*_>nJCqYfDIsDB zBz7o;7;g!&DTB#u5Fj?<9}Ii(r2<7{bbV6uHEW5aA40dxeBXrTTX1Gi)AGB6Mk~eYEKB4K+F)JBUTyaLGuJ7DwYf z*s^xRbC3*=n0H}>$R0mCidLz0k~n@2$B?6Yc!2GP2I6GUas)yPIBSGTR^aXr-ImJR zcQlRK6Rx=e#g99>aFWG`AhO?;10yw`^!3I^=N1;8ZU3B!C&=;Xn_fOc8Z8`ESp>at&zJEc znAE=YLFy_^{E*I@%eB8G2fMjQS$8EzkTHF=RYc6u(AStpU;$fh&9}7*7k64mPI=a= zy3|;P>Kw3lXPEG>{mo*rCBliw+1U=mNFb!)rei9(olgfWH8jwIQeR{#2te_ks+#*_ zy@9=M9bN8$Ix{cWN)arJH~HuWKX1v)7#m43?Y*1E8rps3!d7dSe0&FUoW~sBtKKrF zUA3Pztd?d&L6&?n_-pk#8%)cVHh9S)a&-|VR${_d%%=86`%o;BZ`yj#U*rBjqIB_= z>(bcB?cRZGr!klXXyZJu-@3Kd63rwjjMCHX4lMfexciySFP<5?Xcf&M(&=ueF#w3= zK^m+Y=zA?X=3V(N3p;e%XPI@Jv0P{=*GD#{vdv6739;!J=V2;;qXdMcx5yqS*Z&kS zdzalkPlp`QuO(Cs^lHZBtR7J(SbNW|291U#XE~L*(%I_SuY|^9aDhAs(j#(Sl=ENe3j~--$RSsB6`R02s4io=t3Oj7B1-Hwu073&{9=_ zIL;CZ5x{4(YBA>Y%wT7W&;mxbMGY%%mRO{u$tq#?|8{QN5C8xJTzpb5PV+q3S0_e7oCm=XZgNT?!>T7Xko?hvVF3(xDiK{q zb%Fr(0?IoYRzC{*1X2MpAHo0xk6-}y?0LQwyz_R5zGQ)0^xy!#XwTQ5kSdnGlmGxe za#;Qt00Oc__DMOW)B^ed00002G)W!&p#%T`0002+4MJ0?mC?hl@R4{K==+V1EM$Vo z55NEb06FQg^cabB3S71u*gDNhKw!^n%_{=e;60NZkD`RnZ$?YKS zG+S{py`6S8tz9k^mpe(_WgapNOFr_2kAMIG00F2p?6o9gliv*ZRWit=+EE;t?u+=B9WuD@^l*XgV>m_zP$EGTDK&;RL3jQ*?3~4)qg9BzS?L_=v>77UBdIkm~ zWtTcTl$Sg+wX*722_)BrtiBmuq_o#0x(cV%4#g2z3z2gX5i=WfG-JT>l1`)X(@#(B zgpwn{^XL56WnUpDA&CZI7|ur8B%JsD8mjMz*o^5Y*c0_%xKr zI|z*6KUMK^z)eB6HWsD+=Tfj*)^d$Yi|bYDmbL1q@Kx~R0ukGl{L%DF9Xyr-s`mYO z7v?%$`>1({z$?$PFEjQcVtBfyr805Iq0|I8YL7OjjF;_hqL1Z99PfpE_JTUrxTx~} zR(}apM?f>nq;i(^s-Y#2yNHOaV$#YYCxGNGf9rIXAx@q4!8}ws;k5XFjcoORx(WaQ z00r=@>KMo{Re!&G?u9S7%a))3p6-92#Cdws=`KFs7!{GBi{m<78V>WB1l?Z18`|+c zTUol^6c#d29*G~{A+Gc&6%Sg&Pbv+l?g+5>n~*cgi(b=?6SiwETnEF>^f9XfSO5S3 z0N-rI@t6P6#qPH-@_PR&)5uULyqktB_m~wb)JN=yAhwXa1Wt;+I|5?f(7MAlG&xfr zt_`nq#zFcT8tcvqQ^%@8A-lItyl=(+LdFD7$#W~R##Kl)-MFzu|M?9*x^C{RtGW&x z=@N>75Nf=oR6fZ^yFVCSwYounEkO2EabnV`-^Owa#ugr0ZbWP)--@n(#9O`kIQ#zjf@f|M7=8X z`U;posz^#ifJZW6?05){Fg!?^jvf3|whR6kedyZ5UEKXChZW>DJP%E9WX03USkI2r zu~YTzWnZZIw z0O~ZA*ThP(0OPBhwaX5fbD6kwr^NXtzL3oLoky$G8-=rSMbrld9e_GV za_XU!JviQnzTc!+m?*RI;X2bP!+GsBb>olYgM?R>9dW?V`DSq|>j#qHB_ygklQm~^7ld3Z(~ z^;8l_h_0o|K1K|SR<%Bbg)(U7bho^K3|V#oOS0G>Zk1p5;c|kO6000(Q`0d0G$*IxCmjyIIAL~-Z(Ln+G0|XZwJe3JMZ+kU0(y~D{lP!enn%T zmwu}|!`!3VAeHj=<*Lcg&5QGi72b!qeuWdp=uAc*Jk!Q z0rkFDsiZ>i(u$s_&RCo_Tn$NgJiYh?yF_=`I=TP(ImbETGOzdS?E+||%~jpjoAg-T zca1T4gM(s{cP?~K3*iiJ?&>()gMS`v*=5CIt&RN*LY9IutlDi7Ei~U4Zm62s(DBFr zCBK`hN3)hGO=Z1KYXM3~Gr6`fx7T%vu>C#yr+J|Nh1A1L8F7pU5T5cJ7lXJ;8+Tjq zwDmR?dMp2mF1)@VU7je~Ej|$z$1(qs8Tby#NwPoyR0stZxZU>hmZchAu1;tBg4@`FW3RQH#V19V+qm}t4 z_F;R;94XMH=_VFKB}J_HRi~+In&qR3o`rFQ6RPS?UzY3L(rY!k4JQkM z;Qz}7?=xuy&X3B}1r1bDF=L_KuEQdM{NSDyBsRPKHU)T}{M%oYk zWql6(6s}aBAUJ5*8y^Lm6OX^0OO3_Nt_Bv={V?i&kX~IKwcyAD$zk3tU~^N1>HaEA zldUsjv(?RZV~dn^A}I0hiLvl7{F+Y3V~o|SNCaV5E#4tu%mpmtf{$^Ebz~@f&jp4B zB)mtGKT3l%v+gr2Q?@z}7Xv^|lPo^H+05Hu9D|k@SIXt(lTjOIy1qN=!|}|L!p3#6 zh)dP?Y6R2so#^Ud^s0!zUn^aMHC-}APxIj6jwk4WIDA>nIn~+~CPqGDKFHJeBE5_c zb;qOVl{4+6D#VEc=TWWoc%%bSoC)ADT$lH=EepbwXxCeQwAm4xqUe61B@;Knrqz&v z=7!UYkBc~XC~BU-NY-6UwLTSl9ruF{XdIu#ZsXPBt&?P;K7;!`EKv&#WLtFIek(VL zSJ^N-)%;GY=SH^Ot@VXHliyv`m+ARFHCkbzQC?0sO}i|0ff@R6pz zWxI!aJv5}0*(VUrx6t zBpZMGhiKCmY05ju{EjDcr&!)MU_{RXiRDtwxlffSK+AnUCD-lGpmf);L{OHXsy|=l z1yP|Qh}(vqS3cj#x?{lud&N!E9DmDa4OZ0??wA0HZun?s3O9Tg>y}JH({tn6h_Zi1 zerw<$IKi;*R9R{{K#GRTpv&tzqLRJgE%jP&L6R; z0*?onCb!pBML3m$Mq^BzJlC1D#1X2^| z{0&v*`0~AP-VsZXI>pdYA&qH_*7%_kL9a{P^&gyvWxP1jL)%^a{)qZM`pV2$#kH#H zZ#}9b#w7_u<#P{OtzrXDzC3TZHiK0W7RP^0-N7}7Y^r9PUh9XyWwWC|j zvC}nlEVNh2Y`)0NqjudV)Aedm9$`e@aFQldfIox5iN#UU(#}Q*&nuH>eh4i*cqPViyF50{hrN ztdE+2zemAGKOGR6PnID_omq@*Cbd;T!qIA9QnZvrITnun=-8j{AyAv13NvDka3FMe zC~yWt`sIXmjJ4hZbiB1uWpXOPwA}6C4v3cfFv8kxsS0Oi@2>Lg^}sq8`>knam$y^b z=cfs80o*)F77n6SsA%cV4f)-Ul{tBvxBS>u*xz+uDoMc86i_wUM_@V~P5xkOqAM&( zNp!2%DU&2Ns@LxlOs>8+kI~>a@b*xTmx^nJ8{rdyw4|Ll@hHxY4eiK-w;lgBsu1XU z$iAidvokJ6nCSx~l_I{D2Z@q-^C_BBD)zBFiISg+E_x3RlVW|~Wn(Rz8a>Erf{V0| zPQB@n12lA5X$?6%D&4RpG}$|=M}VcX@O)LECNWND6w%qWgDY&5RZ_mtdpM9VPZ3>T zLtVZ!Gc@rjD7CmS7K&udU+ z3yE)zX^^oWm-*6&yK00DTmH_x z{xD-BLB5g{Z*R_4_u}3ekF!GPSm{&*@``=o#HkR-FSY)e5XGc3CjC%uT*~{jpUh?P zzk4Mz?5(O(xz#pXoYN`=wlkWE3@PQR-8=x34mr#>fh$iWBZgR(k2i9=(YEq)(9wsS z16UQ6J|u9%K$Slhhbe4exZ}2fH)b3G*R>Vl(#Fq8zsMj|`)zI-kiPrsy*=(c^RxvS zuYEWJgLhL4)qy9Q+8;e*vav|2LeUC58D8=674~>*y&@7_XU=R*>osfhFg(%6J_Kig z>0NQ#gh*tPevjhwFXnz(VrKnI$NnU+BZoL8&<$p|-O;+5CJcjP?91eG{ht_y$DvL` z<8O-fHH7WK?67+?t0D!1ja{%JAC4>x^o2;+R7lmD)HJq@ZOBtWbN?AWbSBvA;6wO4 zG%KOW9Mil0b;{O&UQ3f8!zBJJE{L~Sd~Ep9Kolkf#lW(EJfMyzn~z;2CVh`t3XC(` zRdARUgQwAFLz$cj#4kv4OmBMo(vEH$NCPKn;Z9Y>yQgT0f5_Lcwg==ABW;J|#h(dr zJGtVfPy?}l2=r1dCy}{0Jd91rXmyl4vaTj-~whUgxiEf1}3rH;#o|WE0n76_! z4}3g9V%x+g`1XZLjFOTUCsb+CCuuRtsylX@nvLvmD&t @i}(C=W#D_NgZ9AF-pl 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 LKTd8z`~Uy|uHQyU literal 0 HcmV?d00001 diff --git a/website/assets/images/popover-history-light.webp b/website/assets/images/popover-history-light.webp new file mode 100644 index 0000000000000000000000000000000000000000..d7dee5cd24b6b6dc6187d5cbb7edfbf034271d8d GIT binary patch literal 284604 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%0A7s>~IFWtP3cuDA01zJY~ADEj0%~AN^ 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